update from user testing payment platform
This commit is contained in:
@@ -336,4 +336,6 @@
|
|||||||
- Note: `svelte-kit sync` needed after changing load return shapes or `$types` staleness doubles the error count.
|
- Note: `svelte-kit sync` needed after changing load return shapes or `$types` staleness doubles the error count.
|
||||||
- **/admin login pattern:** action sets `platform_session` cookie + returns `{success:true}`; the form's `use:enhance` callback flips a local `authed` view state — no redirect, no reliance on inline invalidation or fetch-time Set-Cookie behavior (which proved flaky in-browser despite curl proving both response paths carried it). Cookie still covers subsequent loads; load errors surface as `data.loadError` on the login card instead of silently masquerading as logged-out.
|
- **/admin login pattern:** action sets `platform_session` cookie + returns `{success:true}`; the form's `use:enhance` callback flips a local `authed` view state — no redirect, no reliance on inline invalidation or fetch-time Set-Cookie behavior (which proved flaky in-browser despite curl proving both response paths carried it). Cookie still covers subsequent loads; load errors surface as `data.loadError` on the login card instead of silently masquerading as logged-out.
|
||||||
- **Platform-admin auth via hooks:** `hooks.server.ts` resolves `locals.platformAdmin` from the `platform_session` cookie (=== 'authenticated') on every request; `/admin` load/actions consume `event.locals.platformAdmin` (`requirePlatform(event)`) instead of raw cookie reads. Same central pattern as `pb_token` → `locals.user`.
|
- **Platform-admin auth via hooks:** `hooks.server.ts` resolves `locals.platformAdmin` from the `platform_session` cookie (=== 'authenticated') on every request; `/admin` load/actions consume `event.locals.platformAdmin` (`requirePlatform(event)`) instead of raw cookie reads. Same central pattern as `pb_token` → `locals.user`.
|
||||||
|
- **`isDummyStripe` removed:** real test keys made every dummy branch dead code — and the webhook's `|| !signature` fallback accepted UNSIGNED events (forgeable access grants). Signature is now mandatory (400 without it); settings' simulated endSubscription/portal branches deleted. Dev verification stays via stripe-cli signed events (`pnpm stripe:listen`).
|
||||||
- **Platform-admin auth hardened (supersedes the constant-cookie version):** `/admin` login now does a real `_superusers.authWithPassword` via PB; the minted superuser JWT goes in `platform_session` (`setPlatformSession` in session.ts). `hooks.server.ts` deviates on `/admin`: verifies the token with `_superusers.authRefresh` → `locals.platformAdmin` (forged values fail authRefresh and get cleared); fam-user pb_token flow skipped on that route. FINAL login shape (user-amended, working): action returns `{success:true}` (no redirect); form's enhance callback does `goto('/admin', { invalidateAll: true })` on success — forcing the load re-run with the fresh cookie; view branches on `data.authenticated`. Verified: real token renders dashboard, forged cookie gets login.
|
- **Platform-admin auth hardened (supersedes the constant-cookie version):** `/admin` login now does a real `_superusers.authWithPassword` via PB; the minted superuser JWT goes in `platform_session` (`setPlatformSession` in session.ts). `hooks.server.ts` deviates on `/admin`: verifies the token with `_superusers.authRefresh` → `locals.platformAdmin` (forged values fail authRefresh and get cleared); fam-user pb_token flow skipped on that route. FINAL login shape (user-amended, working): action returns `{success:true}` (no redirect); form's enhance callback does `goto('/admin', { invalidateAll: true })` on success — forcing the load re-run with the fresh cookie; view branches on `data.authenticated`. Verified: real token renders dashboard, forged cookie gets login.
|
||||||
|
- **use:enhance redirect gotcha (session-wide lesson):** action-thrown redirects surface as `result.type === 'redirect'` in the RESOLVE callback — handlers checking only `'success'` silently drop them (bit /admin login, pricing choose, and settings billingPortal). Shared fix: `lib/forms.ts` → `handleResult(handler?)` factory follows redirects via `goto`, delegates the rest to the handler or default `update()`. Use it for any form whose action can throw a redirect. Also: enhance is two-stage — `{result}` only exists in the resolve fn, not the submit params (was mis-handled in pricing/signup handlers).
|
||||||
|
|||||||
@@ -15,12 +15,12 @@
|
|||||||
const tiers: Tier[] = [
|
const tiers: Tier[] = [
|
||||||
{
|
{
|
||||||
id: 'trial',
|
id: 'trial',
|
||||||
name: 'Trial',
|
name: 'Have a code?',
|
||||||
price: 'Free',
|
price: 'Free',
|
||||||
period: 'for the trial period',
|
period: 'with a valid code',
|
||||||
blurb: 'Kick the tires on us.',
|
blurb: 'Been given an access or trial code?',
|
||||||
features: ['Full family access', 'No card required', 'Code from us or a partner'],
|
features: ['Full family access', 'Redeemed during signup', 'No card required'],
|
||||||
cta: 'Start free trial'
|
cta: 'Enter your code'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'monthly',
|
id: 'monthly',
|
||||||
@@ -70,13 +70,17 @@
|
|||||||
</p>
|
</p>
|
||||||
<p class="blurb">{tier.blurb}</p>
|
<p class="blurb">{tier.blurb}</p>
|
||||||
|
|
||||||
<form method="POST" action={action} use:enhance={onsubmit ?? undefined}>
|
{#if tier.id === 'trial'}
|
||||||
<input type="hidden" name="plan" value={tier.id} />
|
<!-- No form: codes are redeemed inside the signup wizard (step 3).
|
||||||
{#if tier.id === 'trial'}
|
data-sveltekit-reload: full document nav — immune to stale-router
|
||||||
<input name="code" placeholder="Trial code" autocomplete="off" />
|
click-swallowing. -->
|
||||||
{/if}
|
<a href="/signup" data-sveltekit-reload class="cta">{tier.cta}</a>
|
||||||
<button type="submit" class="cta" class:primary={tier.featured}>{tier.cta}</button>
|
{:else}
|
||||||
</form>
|
<form method="POST" action={action} use:enhance={onsubmit ?? undefined}>
|
||||||
|
<input type="hidden" name="plan" value={tier.id} />
|
||||||
|
<button type="submit" class="cta" class:primary={tier.featured}>{tier.cta}</button>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<ul>
|
<ul>
|
||||||
{#each tier.features as f}
|
{#each tier.features as f}
|
||||||
@@ -158,12 +162,15 @@
|
|||||||
outline-offset: -1px;
|
outline-offset: -1px;
|
||||||
}
|
}
|
||||||
.cta {
|
.cta {
|
||||||
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.65rem 1rem;
|
padding: 0.65rem 1rem;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
font-size: 0.92rem;
|
font-size: 0.92rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
text-align: center;
|
||||||
|
text-decoration: none;
|
||||||
border: 1.5px solid #6366f1;
|
border: 1.5px solid #6366f1;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
color: #4338ca;
|
color: #4338ca;
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { goto } from '$app/navigation';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve-callback factory for `use:enhance`.
|
||||||
|
*
|
||||||
|
* Follows `result.type === 'redirect'` automatically (actions like login,
|
||||||
|
* billing portal, and logged-out pricing choose throw redirects), then hands
|
||||||
|
* everything else to the optional handler — falling back to the default
|
||||||
|
* `update()` when none is given.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* <form method="POST" use:enhance={handleResult(({ result }) => {...})}>
|
||||||
|
* <form method="POST" use:enhance={handleResult()}>
|
||||||
|
*/
|
||||||
|
export function handleResult(
|
||||||
|
handler?: (ctx: { result: any; update: () => Promise<void> }) => Promise<void>
|
||||||
|
) {
|
||||||
|
return async ({ result, update }: any) => {
|
||||||
|
if (result?.type === 'redirect') {
|
||||||
|
const target = String(result.location);
|
||||||
|
const isExternal = /^https?:\/\//i.test(target) && !target.startsWith(window.location.origin);
|
||||||
|
if (isExternal) {
|
||||||
|
window.location.assign(target);
|
||||||
|
} else {
|
||||||
|
await goto(target);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (result?.type === 'error') {
|
||||||
|
// Unhandled server exception — never let these vanish silently.
|
||||||
|
const { notices } = await import('$lib/stores/notices.svelte');
|
||||||
|
notices.error('Something went wrong', result.error?.message || 'Internal error.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (handler) await handler({ result, update });
|
||||||
|
else await update();
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -11,11 +11,6 @@ import {
|
|||||||
// Dummy key placeholder — swap for a real test/live secret when the account is connected.
|
// Dummy key placeholder — swap for a real test/live secret when the account is connected.
|
||||||
const KEY: string = String(STRIPE_SECRET_KEY) || 'sk_test_dummy_famchamp_not_connected';
|
const KEY: string = String(STRIPE_SECRET_KEY) || 'sk_test_dummy_famchamp_not_connected';
|
||||||
|
|
||||||
// True until a real key is wired in .env. When dummy, we don't hit the Stripe
|
|
||||||
// API — checkout/billing actions return simulated session URLs so the flow can
|
|
||||||
// be established end-to-end before the account is connected.
|
|
||||||
export const isDummyStripe = !STRIPE_SECRET_KEY;
|
|
||||||
|
|
||||||
export const stripe = new Stripe(KEY);
|
export const stripe = new Stripe(KEY);
|
||||||
|
|
||||||
// 3-tier plans: `trial` is not a paid Stripe price — it's app-side validated
|
// 3-tier plans: `trial` is not a paid Stripe price — it's app-side validated
|
||||||
@@ -93,6 +88,47 @@ export async function createCheckoutSession(opts: {
|
|||||||
return stripe.checkout.sessions.create(params);
|
return stripe.checkout.sessions.create(params);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Live subscription state for the settings UI (source of truth = Stripe).
|
||||||
|
export async function getSubscriptionStatus(
|
||||||
|
customerId: string
|
||||||
|
): Promise<{ active: boolean; cancelAtPeriodEnd: boolean; endsAt?: string }> {
|
||||||
|
const subs = await stripe.subscriptions.list({
|
||||||
|
customer: customerId,
|
||||||
|
status: 'all',
|
||||||
|
limit: 10
|
||||||
|
});
|
||||||
|
const active = subs.data.find((s) => ['active', 'trialing', 'past_due'].includes(s.status));
|
||||||
|
if (!active) return { active: false, cancelAtPeriodEnd: false };
|
||||||
|
const itemEnd = active.items.data[0]?.current_period_end;
|
||||||
|
return {
|
||||||
|
active: true,
|
||||||
|
cancelAtPeriodEnd: !!active.cancel_at_period_end,
|
||||||
|
endsAt: new Date((itemEnd ?? Math.floor(Date.now() / 1000)) * 1000).toISOString()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// In-app cancellation: flags the customer's active subscription to end at the
|
||||||
|
// close of the current paid period (no refund, access continues until then).
|
||||||
|
// The existing webhook flips paymentMode='canceled' when it actually ends.
|
||||||
|
export async function cancelSubscriptionAtPeriodEnd(
|
||||||
|
customerId: string
|
||||||
|
): Promise<{ ended: boolean; endsAt?: string }> {
|
||||||
|
const subs = await stripe.subscriptions.list({
|
||||||
|
customer: customerId,
|
||||||
|
status: 'all',
|
||||||
|
limit: 10
|
||||||
|
});
|
||||||
|
const active = subs.data.find((s) => ['active', 'trialing', 'past_due'].includes(s.status));
|
||||||
|
if (!active) return { ended: false };
|
||||||
|
if (!active.cancel_at_period_end) {
|
||||||
|
await stripe.subscriptions.update(active.id, { cancel_at_period_end: true });
|
||||||
|
}
|
||||||
|
// Newer Stripe API versions carry current_period_end on the sub item.
|
||||||
|
const itemEnd = active.items.data[0]?.current_period_end;
|
||||||
|
const periodEnd = itemEnd ?? Math.floor(Date.now() / 1000) + 30 * 86400;
|
||||||
|
return { ended: true, endsAt: new Date(periodEnd * 1000).toISOString() };
|
||||||
|
}
|
||||||
|
|
||||||
// Stripe Billing portal session for managing/cancelling the subscription.
|
// Stripe Billing portal session for managing/cancelling the subscription.
|
||||||
export async function createBillingPortalSession(customerId: string, origin: string, famSlug = '') {
|
export async function createBillingPortalSession(customerId: string, origin: string, famSlug = '') {
|
||||||
return stripe.billingPortal.sessions.create({
|
return stripe.billingPortal.sessions.create({
|
||||||
@@ -143,4 +179,4 @@ export async function createEmbeddedCheckoutSession(opts: {
|
|||||||
|
|
||||||
const session = await stripe.checkout.sessions.create(params);
|
const session = await stripe.checkout.sessions.create(params);
|
||||||
return { clientSecret: session.client_secret, sessionId: session.id };
|
return { clientSecret: session.client_secret, sessionId: session.id };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,7 +36,6 @@
|
|||||||
function handleCheckoutReturn() {
|
function handleCheckoutReturn() {
|
||||||
if (returnHandled || page.url.searchParams.get('checkout') !== 'return') return;
|
if (returnHandled || page.url.searchParams.get('checkout') !== 'return') return;
|
||||||
returnHandled = true;
|
returnHandled = true;
|
||||||
history.replaceState({}, '', page.url.pathname); // cosmetic only
|
|
||||||
if (!disabled) {
|
if (!disabled) {
|
||||||
notices.success('Welcome to FamChore!', 'Your subscription is active.');
|
notices.success('Welcome to FamChore!', 'Your subscription is active.');
|
||||||
return;
|
return;
|
||||||
@@ -81,7 +80,11 @@
|
|||||||
|
|
||||||
onDestroy(stopActivating);
|
onDestroy(stopActivating);
|
||||||
|
|
||||||
handleCheckoutReturn();
|
// Client-only: touches history/notices — must not run during SSR
|
||||||
|
// (history.replaceState throws server-side → 500 on ?checkout=return).
|
||||||
|
$effect(() => {
|
||||||
|
handleCheckoutReturn();
|
||||||
|
});
|
||||||
|
|
||||||
function accessMessage(reason: string, parent: boolean) {
|
function accessMessage(reason: string, parent: boolean) {
|
||||||
const map: Record<string, string> = {
|
const map: Record<string, string> = {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { servicesFor } from '$lib/server/servicesFor';
|
|||||||
import { issueAccess, createChild } from '$lib/server/member-otp';
|
import { issueAccess, createChild } from '$lib/server/member-otp';
|
||||||
import { slugify } from '@shared/slugify';
|
import { slugify } from '@shared/slugify';
|
||||||
import { applyAccessCode } from '$lib/server/access';
|
import { applyAccessCode } from '$lib/server/access';
|
||||||
import { createBillingPortalSession, isDummyStripe } from '$lib/server/stripe';
|
import { createBillingPortalSession, cancelSubscriptionAtPeriodEnd, getSubscriptionStatus } from '$lib/server/stripe';
|
||||||
|
|
||||||
function famIdOf(event: RequestEvent): string {
|
function famIdOf(event: RequestEvent): string {
|
||||||
if (!event.locals.user) throw redirect(303, '/login');
|
if (!event.locals.user) throw redirect(303, '/login');
|
||||||
@@ -28,7 +28,13 @@ export async function load(event: RequestEvent) {
|
|||||||
if (fam?.paymentMode === 'code' && fam?.accessCodeId) {
|
if (fam?.paymentMode === 'code' && fam?.accessCodeId) {
|
||||||
accessCode = await pbAdmin.getOne('accesscodes', fam.accessCodeId).catch(() => null);
|
accessCode = await pbAdmin.getOne('accesscodes', fam.accessCodeId).catch(() => null);
|
||||||
}
|
}
|
||||||
return { members, fam, seasons, accessCode };
|
// Live Stripe state — drives "ending / X left" UX once cancel is requested
|
||||||
|
// (paymentMode stays 'sub' until the period actually closes).
|
||||||
|
let subStatus: any = null;
|
||||||
|
if (fam?.paymentMode === 'sub' && fam?.stripeCustomerId) {
|
||||||
|
subStatus = await getSubscriptionStatus(fam.stripeCustomerId).catch(() => null);
|
||||||
|
}
|
||||||
|
return { members, fam, seasons, accessCode, subStatus };
|
||||||
}
|
}
|
||||||
|
|
||||||
export const actions = {
|
export const actions = {
|
||||||
@@ -87,7 +93,8 @@ export const actions = {
|
|||||||
const payday = parseInt(fd.get('payday') as string, 10);
|
const payday = parseInt(fd.get('payday') as string, 10);
|
||||||
if (isNaN(payday) || payday < 0 || payday > 6) return { error: 'Payday must be 0-6' };
|
if (isNaN(payday) || payday < 0 || payday > 6) return { error: 'Payday must be 0-6' };
|
||||||
const paydayTime = fd.get('paydayTime') as string;
|
const paydayTime = fd.get('paydayTime') as string;
|
||||||
if (paydayTime && !/^\d{2}:\d{2}$/.test(paydayTime)) return { error: 'Payday time must be HH:MM' };
|
if (paydayTime && !/^\d{2}:\d{2}$/.test(paydayTime))
|
||||||
|
return { error: 'Payday time must be HH:MM' };
|
||||||
const timezone = fd.get('timezone') as string;
|
const timezone = fd.get('timezone') as string;
|
||||||
if (timezone && timezone !== 'auto' && !/^[A-Za-z_+-]+\/[A-Za-z_+-]+$/.test(timezone)) {
|
if (timezone && timezone !== 'auto' && !/^[A-Za-z_+-]+\/[A-Za-z_+-]+$/.test(timezone)) {
|
||||||
return { error: 'Timezone must be an IANA name or auto' };
|
return { error: 'Timezone must be an IANA name or auto' };
|
||||||
@@ -117,15 +124,15 @@ export const actions = {
|
|||||||
if (!id) return { error: 'Season ID required' };
|
if (!id) return { error: 'Season ID required' };
|
||||||
|
|
||||||
const pb = pbUser(event);
|
const pb = pbUser(event);
|
||||||
const assigned = await pb.collection('assigned_chores').getFullList({ filter: `famId = '${famId}'` });
|
const assigned = await pb
|
||||||
const toDelete = (Array.isArray(assigned) ? assigned : []).filter(
|
.collection('assigned_chores')
|
||||||
(a: any) => a.seasonIds?.includes(id)
|
.getFullList({ filter: `famId = '${famId}'` });
|
||||||
|
const toDelete = (Array.isArray(assigned) ? assigned : []).filter((a: any) =>
|
||||||
|
a.seasonIds?.includes(id)
|
||||||
);
|
);
|
||||||
const deletedIds = toDelete.map((a: any) => a.id);
|
const deletedIds = toDelete.map((a: any) => a.id);
|
||||||
|
|
||||||
await Promise.all(
|
await Promise.all(toDelete.map((a: any) => pb.collection('assigned_chores').delete(a.id)));
|
||||||
toDelete.map((a: any) => pb.collection('assigned_chores').delete(a.id))
|
|
||||||
);
|
|
||||||
await pb.collection('seasons').delete(id);
|
await pb.collection('seasons').delete(id);
|
||||||
|
|
||||||
return { deletedChoreIds: deletedIds };
|
return { deletedChoreIds: deletedIds };
|
||||||
@@ -164,6 +171,23 @@ export const actions = {
|
|||||||
return result.error ? { error: result.error } : { ok: true, ...result };
|
return result.error ? { error: result.error } : { ok: true, ...result };
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// End subscription — cancels at period end via Stripe API (in-app, no
|
||||||
|
// portal bounce). Webhook flips paymentMode='canceled' when it ends.
|
||||||
|
endSubscription: async (event: RequestEvent) => {
|
||||||
|
const famId = famIdOf(event);
|
||||||
|
const fam = await pbAdmin.getOne('fams', famId);
|
||||||
|
if (!fam.stripeCustomerId) {
|
||||||
|
return fail(400, { error: 'No subscription to end.' });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await cancelSubscriptionAtPeriodEnd(fam.stripeCustomerId);
|
||||||
|
if (!res.ended) return fail(400, { error: 'No active subscription found.' });
|
||||||
|
return { success: true, endsAt: res.endsAt };
|
||||||
|
} catch (e) {
|
||||||
|
return fail(500, { error: e instanceof Error ? e.message : 'Failed to end subscription' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
// Revoke the applied access code — sets paymentMode back to 'none'.
|
// Revoke the applied access code — sets paymentMode back to 'none'.
|
||||||
// No debug flag gate; the button is only visible when a code is applied.
|
// No debug flag gate; the button is only visible when a code is applied.
|
||||||
revokeCode: async (event: RequestEvent) => {
|
revokeCode: async (event: RequestEvent) => {
|
||||||
@@ -185,8 +209,11 @@ export const actions = {
|
|||||||
return fail(400, { error: 'No Stripe customer linked yet. Start with a plan first.' });
|
return fail(400, { error: 'No Stripe customer linked yet. Start with a plan first.' });
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const session = await createBillingPortalSession(fam.stripeCustomerId, event.url.origin, event.params.fam as string);
|
const session = await createBillingPortalSession(
|
||||||
if (isDummyStripe) return { success: true, portalUrl: session.url };
|
fam.stripeCustomerId,
|
||||||
|
event.url.origin,
|
||||||
|
event.params.fam as string
|
||||||
|
);
|
||||||
if (!session.url) return fail(500, { error: 'Billing portal session has no URL' });
|
if (!session.url) return fail(500, { error: 'Billing portal session has no URL' });
|
||||||
throw redirect(303, session.url);
|
throw redirect(303, session.url);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -194,4 +221,4 @@ export const actions = {
|
|||||||
return fail(500, { error: e instanceof Error ? e.message : 'Failed to open billing portal' });
|
return fail(500, { error: e instanceof Error ? e.message : 'Failed to open billing portal' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,8 +1,18 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
|
import { handleResult } from '$lib/forms';
|
||||||
|
import { notices } from '$lib/stores/notices.svelte';
|
||||||
import { famStore } from '$lib/stores/fam.svelte';
|
import { famStore } from '$lib/stores/fam.svelte';
|
||||||
import { ViewHeader, CardGrid, Card, Button, Accordion, AccordionItem, NoticeDialog } from '$lib/components';
|
import {
|
||||||
|
ViewHeader,
|
||||||
|
CardGrid,
|
||||||
|
Card,
|
||||||
|
Button,
|
||||||
|
Accordion,
|
||||||
|
AccordionItem,
|
||||||
|
NoticeDialog
|
||||||
|
} from '$lib/components';
|
||||||
import { COMMON_TIMEZONES } from '@shared/timezone';
|
import { COMMON_TIMEZONES } from '@shared/timezone';
|
||||||
import { handleOf } from '@shared/slugify';
|
import { handleOf } from '@shared/slugify';
|
||||||
import { addMonthsUTC, formatShortDate } from '$lib/format';
|
import { addMonthsUTC, formatShortDate } from '$lib/format';
|
||||||
@@ -37,9 +47,23 @@
|
|||||||
let payday = $state(fam?.payday != null ? Number(fam.payday) : 1);
|
let payday = $state(fam?.payday != null ? Number(fam.payday) : 1);
|
||||||
let paydayTime = $state(fam?.paydayTime || '18:00');
|
let paydayTime = $state(fam?.paydayTime || '18:00');
|
||||||
let paydayTimes = $state([
|
let paydayTimes = $state([
|
||||||
'06:00', '07:00', '08:00', '09:00', '10:00', '11:00', '12:00',
|
'06:00',
|
||||||
'13:00', '14:00', '15:00', '16:00', '17:00', '18:00', '19:00',
|
'07:00',
|
||||||
'20:00', '21:00', '22:00'
|
'08:00',
|
||||||
|
'09:00',
|
||||||
|
'10:00',
|
||||||
|
'11:00',
|
||||||
|
'12:00',
|
||||||
|
'13:00',
|
||||||
|
'14:00',
|
||||||
|
'15:00',
|
||||||
|
'16:00',
|
||||||
|
'17:00',
|
||||||
|
'18:00',
|
||||||
|
'19:00',
|
||||||
|
'20:00',
|
||||||
|
'21:00',
|
||||||
|
'22:00'
|
||||||
]);
|
]);
|
||||||
let timezone = $state(fam?.timezone || 'auto');
|
let timezone = $state(fam?.timezone || 'auto');
|
||||||
let timezoneOptions = $state(COMMON_TIMEZONES);
|
let timezoneOptions = $state(COMMON_TIMEZONES);
|
||||||
@@ -48,7 +72,9 @@
|
|||||||
if (typeof Intl !== 'undefined') {
|
if (typeof Intl !== 'undefined') {
|
||||||
try {
|
try {
|
||||||
detectedTz = Intl.DateTimeFormat().resolvedOptions().timeZone || '';
|
detectedTz = Intl.DateTimeFormat().resolvedOptions().timeZone || '';
|
||||||
} catch { detectedTz = ''; }
|
} catch {
|
||||||
|
detectedTz = '';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
let showQR = $state(false);
|
let showQR = $state(false);
|
||||||
@@ -83,6 +109,16 @@
|
|||||||
alert('Parent invite coming soon — email would be sent to ' + parentInviteEmail);
|
alert('Parent invite coming soon — email would be sent to ' + parentInviteEmail);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function timeUntil(iso: string): string {
|
||||||
|
const diffMs = new Date(iso).getTime() - Date.now();
|
||||||
|
if (diffMs <= 0) return 'expired';
|
||||||
|
const days = Math.floor(diffMs / 864e5);
|
||||||
|
if (days < 1) return 'less than a day';
|
||||||
|
if (days < 31) return `${days} day${days === 1 ? '' : 's'}`;
|
||||||
|
const months = Math.floor(days / 30.44);
|
||||||
|
return `about ${months} month${months === 1 ? '' : 's'}`;
|
||||||
|
}
|
||||||
|
|
||||||
function formatCountdown(entryDate: Date | null, durationMonths: number): string {
|
function formatCountdown(entryDate: Date | null, durationMonths: number): string {
|
||||||
if (!entryDate) return '';
|
if (!entryDate) return '';
|
||||||
if (durationMonths === 0) return 'Never expires';
|
if (durationMonths === 0) return 'Never expires';
|
||||||
@@ -112,7 +148,13 @@
|
|||||||
</p>
|
</p>
|
||||||
<form method="POST" action="?/renameFam" use:enhance>
|
<form method="POST" action="?/renameFam" use:enhance>
|
||||||
<label class="field-label" for="fam-name">Display name</label>
|
<label class="field-label" for="fam-name">Display name</label>
|
||||||
<input id="fam-name" name="name" bind:value={rename} placeholder={fam?.name || 'Family name'} required />
|
<input
|
||||||
|
id="fam-name"
|
||||||
|
name="name"
|
||||||
|
bind:value={rename}
|
||||||
|
placeholder={fam?.name || 'Family name'}
|
||||||
|
required
|
||||||
|
/>
|
||||||
<Button type="submit" size="sm">Rename</Button>
|
<Button type="submit" size="sm">Rename</Button>
|
||||||
</form>
|
</form>
|
||||||
{#if fam?.slug}
|
{#if fam?.slug}
|
||||||
@@ -163,8 +205,8 @@
|
|||||||
<Button type="submit" size="sm">Set payday</Button>
|
<Button type="submit" size="sm">Set payday</Button>
|
||||||
</form>
|
</form>
|
||||||
<p class="hint">
|
<p class="hint">
|
||||||
Payday: the week starts on this day and weekly earnings are settled at this time. Auto timezone
|
Payday: the week starts on this day and weekly earnings are settled at this time. Auto
|
||||||
follows each device.
|
timezone follows each device.
|
||||||
</p>
|
</p>
|
||||||
</Card>
|
</Card>
|
||||||
<Card title="Seasons" cols={1}>
|
<Card title="Seasons" cols={1}>
|
||||||
@@ -174,7 +216,13 @@
|
|||||||
<input id="season-name" name="name" placeholder="Season name" required />
|
<input id="season-name" name="name" placeholder="Season name" required />
|
||||||
<div class="color-row">
|
<div class="color-row">
|
||||||
<label for="season-color">Colour</label>
|
<label for="season-color">Colour</label>
|
||||||
<input id="season-color" name="color" type="color" value="#6366f1" class="color-input" />
|
<input
|
||||||
|
id="season-color"
|
||||||
|
name="color"
|
||||||
|
type="color"
|
||||||
|
value="#6366f1"
|
||||||
|
class="color-input"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button type="submit" size="sm">Add</Button>
|
<Button type="submit" size="sm">Add</Button>
|
||||||
</form>
|
</form>
|
||||||
@@ -184,7 +232,8 @@
|
|||||||
<li>
|
<li>
|
||||||
<span class="dot" style="background:{s.color}"></span>
|
<span class="dot" style="background:{s.color}"></span>
|
||||||
{s.name}
|
{s.name}
|
||||||
<Button variant="danger" size="sm" onclick={() => (deletingSeason = s)}>Remove</Button>
|
<Button variant="danger" size="sm" onclick={() => (deletingSeason = s)}>Remove</Button
|
||||||
|
>
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -195,10 +244,18 @@
|
|||||||
<div class="overlay" onclick={() => (deletingSeason = null)} role="presentation">
|
<div class="overlay" onclick={() => (deletingSeason = null)} role="presentation">
|
||||||
<div class="modal" onclick={(e) => e.stopPropagation()} role="dialog">
|
<div class="modal" onclick={(e) => e.stopPropagation()} role="dialog">
|
||||||
<h3>Delete "{deletingSeason.name}"?</h3>
|
<h3>Delete "{deletingSeason.name}"?</h3>
|
||||||
<p class="warning">All chores assigned to this season will also be removed. This cannot be undone.</p>
|
<p class="warning">
|
||||||
<form method="POST" action="?/deleteSeason" use:enhance={() => {
|
All chores assigned to this season will also be removed. This cannot be undone.
|
||||||
return async ({ result }) => { if (result.type === 'success') deletingSeason = null; };
|
</p>
|
||||||
}}>
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/deleteSeason"
|
||||||
|
use:enhance={() => {
|
||||||
|
return async ({ result }) => {
|
||||||
|
if (result.type === 'success') deletingSeason = null;
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
>
|
||||||
<input type="hidden" name="id" value={deletingSeason.id} />
|
<input type="hidden" name="id" value={deletingSeason.id} />
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button type="button" onclick={() => (deletingSeason = null)}>Cancel</button>
|
<button type="button" onclick={() => (deletingSeason = null)}>Cancel</button>
|
||||||
@@ -219,7 +276,13 @@
|
|||||||
<p class="hint">Add a child. They'll pick their own colour after joining.</p>
|
<p class="hint">Add a child. They'll pick their own colour after joining.</p>
|
||||||
<form method="POST" action="?/addMember" use:enhance>
|
<form method="POST" action="?/addMember" use:enhance>
|
||||||
<label class="field-label" for="new-child">New child</label>
|
<label class="field-label" for="new-child">New child</label>
|
||||||
<input id="new-child" name="name" bind:value={addName} placeholder="Child name" required />
|
<input
|
||||||
|
id="new-child"
|
||||||
|
name="name"
|
||||||
|
bind:value={addName}
|
||||||
|
placeholder="Child name"
|
||||||
|
required
|
||||||
|
/>
|
||||||
<Button type="submit" size="sm">Add child</Button>
|
<Button type="submit" size="sm">Add child</Button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -235,10 +298,17 @@
|
|||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span class="member-actions">
|
<span class="member-actions">
|
||||||
<Button href="/{famSlug}/{handleOf(m.username)}" variant="secondary" size="sm">Preview</Button>
|
<Button href="/{famSlug}/{handleOf(m.username)}" variant="secondary" size="sm"
|
||||||
|
>Preview</Button
|
||||||
|
>
|
||||||
<form method="POST" action="?/deleteMember" use:enhance class="inline">
|
<form method="POST" action="?/deleteMember" use:enhance class="inline">
|
||||||
<input type="hidden" name="id" value={m.id} />
|
<input type="hidden" name="id" value={m.id} />
|
||||||
<Button type="submit" variant="danger" size="sm" onclick={() => confirm('Remove {m.name}?')}>Remove</Button>
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="danger"
|
||||||
|
size="sm"
|
||||||
|
onclick={() => confirm('Remove {m.name}?')}>Remove</Button
|
||||||
|
>
|
||||||
</form>
|
</form>
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
@@ -284,11 +354,17 @@
|
|||||||
{#if issued?.otp}
|
{#if issued?.otp}
|
||||||
<div class="mt-3 rounded-lg border border-indigo-200 bg-indigo-50 p-4">
|
<div class="mt-3 rounded-lg border border-indigo-200 bg-indigo-50 p-4">
|
||||||
<p class="text-xs text-slate-500">Code for {issued.name} (valid 20 min):</p>
|
<p class="text-xs text-slate-500">Code for {issued.name} (valid 20 min):</p>
|
||||||
<p class="my-2 text-center text-4xl font-bold tracking-[0.3em] text-indigo-700">{issued.otp}</p>
|
<p class="my-2 text-center text-4xl font-bold tracking-[0.3em] text-indigo-700">
|
||||||
|
{issued.otp}
|
||||||
|
</p>
|
||||||
<p class="invite-url">{invitePath}</p>
|
<p class="invite-url">{invitePath}</p>
|
||||||
<div class="actions justify-center">
|
<div class="actions justify-center">
|
||||||
<Button variant="secondary" size="sm" onclick={() => copy(inviteUrl)}>{copied ? 'Copied!' : 'Copy URL'}</Button>
|
<Button variant="secondary" size="sm" onclick={() => copy(inviteUrl)}
|
||||||
<Button variant="secondary" size="sm" onclick={toggleQR}>{showQR ? 'Hide QR' : 'Show QR'}</Button>
|
>{copied ? 'Copied!' : 'Copy URL'}</Button
|
||||||
|
>
|
||||||
|
<Button variant="secondary" size="sm" onclick={toggleQR}
|
||||||
|
>{showQR ? 'Hide QR' : 'Show QR'}</Button
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
{#if showQR && qrDataUrl}
|
{#if showQR && qrDataUrl}
|
||||||
<div class="qr-wrap"><img src={qrDataUrl} alt="QR Code" class="qr" /></div>
|
<div class="qr-wrap"><img src={qrDataUrl} alt="QR Code" class="qr" /></div>
|
||||||
@@ -301,7 +377,12 @@
|
|||||||
<p class="hint">Send an email invitation for another parent to join as an admin.</p>
|
<p class="hint">Send an email invitation for another parent to join as an admin.</p>
|
||||||
<div class="invite-form">
|
<div class="invite-form">
|
||||||
<label class="field-label" for="parent-email">Parent email</label>
|
<label class="field-label" for="parent-email">Parent email</label>
|
||||||
<input id="parent-email" type="email" bind:value={parentInviteEmail} placeholder="parent@example.com" />
|
<input
|
||||||
|
id="parent-email"
|
||||||
|
type="email"
|
||||||
|
bind:value={parentInviteEmail}
|
||||||
|
placeholder="parent@example.com"
|
||||||
|
/>
|
||||||
<Button onclick={handleParentInvite} size="sm">Send invite</Button>
|
<Button onclick={handleParentInvite} size="sm">Send invite</Button>
|
||||||
</div>
|
</div>
|
||||||
<p class="hint">They will set up their own password on first login.</p>
|
<p class="hint">They will set up their own password on first login.</p>
|
||||||
@@ -311,79 +392,116 @@
|
|||||||
|
|
||||||
<AccordionItem title="Account">
|
<AccordionItem title="Account">
|
||||||
<CardGrid>
|
<CardGrid>
|
||||||
<!-- Access -->
|
<!-- Access (codes) only relevant when NOT on a subscription -->
|
||||||
<Card title="Access" cols={1} accent={hasCode ? '#059669' : '#dc2626'}>
|
{#if fam?.paymentMode !== 'sub'}
|
||||||
{#if hasCode}
|
<!-- Access -->
|
||||||
<p class="hint">
|
<Card title="Access" cols={1} accent={hasCode ? '#059669' : '#dc2626'}>
|
||||||
Access active via access code
|
{#if hasCode}
|
||||||
{#if data.accessCode?.duration}
|
<p class="hint">
|
||||||
— <strong>{formatCountdown(codeEntryDate, data.accessCode.duration)}</strong>
|
Access active via access code
|
||||||
(expires {formatShortDate(addMonthsUTC(codeEntryDate!, data.accessCode.duration))})
|
{#if data.accessCode?.duration}
|
||||||
{:else}
|
— <strong>{formatCountdown(codeEntryDate, data.accessCode.duration)}</strong>
|
||||||
— <strong>never expires</strong>
|
(expires {formatShortDate(addMonthsUTC(codeEntryDate!, data.accessCode.duration))})
|
||||||
{/if}.
|
{:else}
|
||||||
</p>
|
— <strong>never expires</strong>
|
||||||
<form
|
{/if}.
|
||||||
class="revoke-form"
|
</p>
|
||||||
method="POST"
|
<form
|
||||||
action="?/revokeCode"
|
class="revoke-form"
|
||||||
use:enhance={() => {
|
method="POST"
|
||||||
return async ({ result, update }) => {
|
action="?/revokeCode"
|
||||||
if (result.type === 'success') accessMsg = 'Access code revoked.';
|
use:enhance={() => {
|
||||||
else if (result.type === 'failure') accessMsg = (result.data as any)?.error || 'Revoke failed.';
|
return async ({ result, update }) => {
|
||||||
await update();
|
if (result.type === 'success') accessMsg = 'Access code revoked.';
|
||||||
};
|
else if (result.type === 'failure')
|
||||||
}}
|
accessMsg = (result.data as any)?.error || 'Revoke failed.';
|
||||||
>
|
await update();
|
||||||
<Button type="submit" size="sm" variant="danger">Revoke code</Button>
|
};
|
||||||
</form>
|
}}
|
||||||
{:else}
|
>
|
||||||
<p class="hint">No access applied. Enter a valid access code to enable your family.</p>
|
<Button type="submit" size="sm" variant="danger">Revoke code</Button>
|
||||||
<form method="POST" action="?/applyCode" use:enhance={() => {
|
</form>
|
||||||
return async ({ result, update }) => {
|
{:else}
|
||||||
if (result.type === 'success' && result.data) {
|
<p class="hint">No access applied. Enter a valid access code to enable your family.</p>
|
||||||
const d = result.data as { error?: string; ok?: boolean };
|
<form
|
||||||
accessMsg = d.error || 'Code applied — access enabled.';
|
method="POST"
|
||||||
accessCodeInput = '';
|
action="?/applyCode"
|
||||||
}
|
use:enhance={() => {
|
||||||
await update();
|
return async ({ result, update }) => {
|
||||||
};
|
if (result.type === 'success' && result.data) {
|
||||||
}}>
|
const d = result.data as { error?: string; ok?: boolean };
|
||||||
<input name="code" bind:value={accessCodeInput} placeholder="Enter access code" autocomplete="off" />
|
accessMsg = d.error || 'Code applied — access enabled.';
|
||||||
<Button type="submit" size="sm" variant="primary">Apply code</Button>
|
accessCodeInput = '';
|
||||||
</form>
|
}
|
||||||
{/if}
|
await update();
|
||||||
{#if accessMsg}
|
};
|
||||||
<p class="access-msg">{accessMsg}</p>
|
}}
|
||||||
{/if}
|
>
|
||||||
</Card>
|
<input
|
||||||
|
name="code"
|
||||||
|
bind:value={accessCodeInput}
|
||||||
|
placeholder="Enter access code"
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
<Button type="submit" size="sm" variant="primary">Apply code</Button>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
{#if accessMsg}
|
||||||
|
<p class="access-msg">{accessMsg}</p>
|
||||||
|
{/if}
|
||||||
|
</Card>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<Card title="Subscription" cols={1} accent={fam?.paymentMode === 'sub' ? '#059669' : '#f59e0b'}>
|
<Card
|
||||||
|
title="Subscription"
|
||||||
|
cols={1}
|
||||||
|
accent={fam?.paymentMode === 'sub' ? '#059669' : '#f59e0b'}
|
||||||
|
>
|
||||||
<p class="hint">
|
<p class="hint">
|
||||||
{fam?.paymentMode === 'sub' ? 'Current plan: Subscription.' :
|
{fam?.paymentMode === 'sub'
|
||||||
fam?.paymentMode === 'code' ? `Current plan: Access code${hasCode ? '' : ' (invalid)'}.` :
|
? 'Current plan: Subscription.'
|
||||||
fam?.paymentMode === 'canceled' ? 'Your subscription was canceled.' : 'No active plan.'}
|
: fam?.paymentMode === 'code'
|
||||||
|
? `Current plan: Access code${hasCode ? '' : ' (invalid)'}.`
|
||||||
|
: fam?.paymentMode === 'canceled'
|
||||||
|
? 'Your subscription was canceled.'
|
||||||
|
: 'No active plan.'}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{#if fam?.paymentMode === 'sub'}
|
{#if fam?.paymentMode === 'sub' && data.subStatus?.cancelAtPeriodEnd}
|
||||||
|
<!-- Cancel requested: no CTA — just the countdown to expiry -->
|
||||||
|
<p class="hint">
|
||||||
|
<strong>No active subscription.</strong> You have
|
||||||
|
<strong>{timeUntil(data.subStatus.endsAt)}</strong> of access left
|
||||||
|
(until {formatShortDate(String(data.subStatus.endsAt))}). No further charges.
|
||||||
|
</p>
|
||||||
|
{:else if fam?.paymentMode === 'sub'}
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<Button href="/pricing" variant="primary" size="sm">Change plan</Button>
|
<form
|
||||||
<form method="POST" action="?/billingPortal" use:enhance={() => {
|
method="POST"
|
||||||
return async ({ result, update }) => {
|
action="?/endSubscription"
|
||||||
if (result.type === 'success' && (result.data as any)?.portalUrl) {
|
use:enhance={() => {
|
||||||
window.open((result.data as any).portalUrl, '_blank');
|
return async ({ result }) => {
|
||||||
} else if (result.type === 'failure') {
|
if (result.type === 'success' && (result.data as any)?.endsAt) {
|
||||||
accessMsg = (result.data as any)?.error || 'Failed to open billing portal';
|
notices.success(
|
||||||
}
|
'Subscription ending',
|
||||||
await update();
|
`Ends ${formatShortDate(String((result.data as any).endsAt))} — access continues until then, no further charges.`
|
||||||
};
|
);
|
||||||
}}>
|
} else if (result.type === 'failure') {
|
||||||
<Button type="submit" variant="secondary" size="sm" disabled={!fam?.stripeCustomerId}>Open billing portal</Button>
|
notices.error(
|
||||||
|
'Couldn’t end subscription',
|
||||||
|
(result.data as any)?.error || 'Please try again.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button type="submit" variant="danger" size="sm">End subscription</Button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<p class="hint resume-hint">
|
<p class="hint resume-hint">
|
||||||
To pause your plan, cancel in the billing portal — you keep your data and can resubscribe
|
Ending stops future charges at the close of the paid period — your data is kept and you
|
||||||
anytime. Cancelling takes effect at period end.
|
can resubscribe anytime. To switch plans, end this subscription first, then pick a new
|
||||||
|
one from Plans.
|
||||||
</p>
|
</p>
|
||||||
{:else if fam?.paymentMode === 'code'}
|
{:else if fam?.paymentMode === 'code'}
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
@@ -402,24 +520,36 @@
|
|||||||
<CardGrid>
|
<CardGrid>
|
||||||
{#if page.data.platformFlags?.debug}
|
{#if page.data.platformFlags?.debug}
|
||||||
<Card title="Debug Tools" cols={1} accent="#f59e0b">
|
<Card title="Debug Tools" cols={1} accent="#f59e0b">
|
||||||
<p class="hint">Debug mode is enabled. These tools are for development and testing only.</p>
|
<p class="hint">
|
||||||
|
Debug mode is enabled. These tools are for development and testing only.
|
||||||
|
</p>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<form method="POST" action="?/completeWeek" use:enhance={() => {
|
<form
|
||||||
return async ({ result, update }) => {
|
method="POST"
|
||||||
if (result.type === 'success') alert('Week completed!');
|
action="?/completeWeek"
|
||||||
await update();
|
use:enhance={() => {
|
||||||
};
|
return async ({ result, update }) => {
|
||||||
}}>
|
if (result.type === 'success') alert('Week completed!');
|
||||||
|
await update();
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Button type="submit" size="sm" variant="secondary">Complete Week</Button>
|
<Button type="submit" size="sm" variant="secondary">Complete Week</Button>
|
||||||
</form>
|
</form>
|
||||||
<form method="POST" action="?/generateData" use:enhance={() => {
|
<form
|
||||||
return async ({ result, update }) => {
|
method="POST"
|
||||||
if (result.type === 'success') alert('Test data generated!');
|
action="?/generateData"
|
||||||
await update();
|
use:enhance={() => {
|
||||||
};
|
return async ({ result, update }) => {
|
||||||
}}>
|
if (result.type === 'success') alert('Test data generated!');
|
||||||
|
await update();
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
>
|
||||||
<input type="hidden" name="days" value="7" />
|
<input type="hidden" name="days" value="7" />
|
||||||
<Button type="submit" size="sm" variant="secondary">Generate Test Data (7 days)</Button>
|
<Button type="submit" size="sm" variant="secondary"
|
||||||
|
>Generate Test Data (7 days)</Button
|
||||||
|
>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -437,6 +567,7 @@
|
|||||||
</Accordion>
|
</Accordion>
|
||||||
|
|
||||||
<NoticeDialog />
|
<NoticeDialog />
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.hint {
|
.hint {
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { json, type RequestHandler } from '@sveltejs/kit';
|
import { json, type RequestHandler } from '@sveltejs/kit';
|
||||||
import { verifyStripeEvent, isDummyStripe } from '$lib/server/stripe';
|
import { verifyStripeEvent } from '$lib/server/stripe';
|
||||||
import { handleStripeEvent } from '$lib/server/stripe-events';
|
import { handleStripeEvent } from '$lib/server/stripe-events';
|
||||||
|
|
||||||
// Stripe webhook: updates fams.stripeCustomerId + fams.active + fams.paymentMode
|
// Stripe webhook: updates fams.stripeCustomerId + fams.active + fams.paymentMode
|
||||||
@@ -9,10 +9,10 @@ export const POST: RequestHandler = async ({ request }) => {
|
|||||||
const rawBody = await request.text();
|
const rawBody = await request.text();
|
||||||
const signature = request.headers.get('stripe-signature');
|
const signature = request.headers.get('stripe-signature');
|
||||||
|
|
||||||
// Dummy mode: no webhook secret configured — accept the event without
|
// Signature is mandatory — unsigned requests are rejected outright
|
||||||
// verification so the flow is testable before the account is connected.
|
// (a forged checkout.session.completed would otherwise grant access).
|
||||||
if (isDummyStripe || !signature) {
|
if (!signature) {
|
||||||
return json({ received: true, dummy: true });
|
return json({ error: 'Missing stripe-signature header' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
let event;
|
let event;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { tick } from 'svelte';
|
import { tick } from 'svelte';
|
||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
import { PUBLIC_STRIPE_PUBLISHABLE_KEY } from '$app/env/public';
|
import { PUBLIC_STRIPE_PUBLISHABLE_KEY } from '$app/env/public';
|
||||||
import { loadStripe, type StripeEmbeddedCheckout } from '@stripe/stripe-js';
|
import { loadStripe, type StripeEmbeddedCheckout } from '@stripe/stripe-js';
|
||||||
import { Button, PricingPlans } from '$lib/components';
|
import { Button, PricingPlans } from '$lib/components';
|
||||||
@@ -27,16 +28,23 @@
|
|||||||
if (checkoutEl) checkout.mount(checkoutEl);
|
if (checkoutEl) checkout.mount(checkoutEl);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleChoose({ result, formData }: { result: any; formData?: FormData }) {
|
// use:enhance is two-stage: this factory receives submit params (no result
|
||||||
if (result.type === 'success' && result.data) {
|
// yet), and the RESOLVE function it returns receives { result, formData }.
|
||||||
const d = result.data as { clientSecret?: string };
|
const handleChoose = () =>
|
||||||
if (d.clientSecret) {
|
async ({ result, formData }: any) => {
|
||||||
const planName = String(formData?.get('plan') || '');
|
if (result?.type === 'success' && result.data) {
|
||||||
const label = { trial: 'Trial', monthly: 'Monthly', yearly: 'Yearly' }[planName] || planName;
|
const d = result.data as { clientSecret?: string };
|
||||||
mountEmbedded(d.clientSecret, label);
|
if (d.clientSecret) {
|
||||||
|
const planName = String(formData?.get('plan') || '');
|
||||||
|
const label =
|
||||||
|
{ trial: 'Trial', monthly: 'Monthly', yearly: 'Yearly' }[planName] || planName;
|
||||||
|
mountEmbedded(d.clientSecret, label);
|
||||||
|
}
|
||||||
|
} else if (result?.type === 'redirect') {
|
||||||
|
// Logged-out choose → server sends us to signup with the plan.
|
||||||
|
await goto(String(result.location));
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
}
|
|
||||||
|
|
||||||
function resetCheckout() {
|
function resetCheckout() {
|
||||||
checkout?.unmount();
|
checkout?.unmount();
|
||||||
@@ -90,9 +98,7 @@
|
|||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-rows: auto 1fr auto;
|
grid-template-rows: auto 1fr auto;
|
||||||
background:
|
background: radial-gradient(60rem 30rem at 50% -10rem, #eef2ff 0%, transparent 65%), #fafafa;
|
||||||
radial-gradient(60rem 30rem at 50% -10rem, #eef2ff 0%, transparent 65%),
|
|
||||||
#fafafa;
|
|
||||||
}
|
}
|
||||||
.topbar {
|
.topbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -116,13 +122,19 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
.topbar nav { display: flex; align-items: center; gap: 1rem; }
|
.topbar nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
.nav-link {
|
.nav-link {
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
color: #4b5563;
|
color: #4b5563;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
.nav-link:hover { color: #111827; }
|
.nav-link:hover {
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
.nav-cta {
|
.nav-cta {
|
||||||
font-size: 0.88rem;
|
font-size: 0.88rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -132,7 +144,9 @@
|
|||||||
padding: 0.45rem 1rem;
|
padding: 0.45rem 1rem;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
}
|
}
|
||||||
.nav-cta:hover { background: linear-gradient(135deg, #3730a3, #4f46e5); }
|
.nav-cta:hover {
|
||||||
|
background: linear-gradient(135deg, #3730a3, #4f46e5);
|
||||||
|
}
|
||||||
|
|
||||||
main {
|
main {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -145,7 +159,9 @@
|
|||||||
max-width: 1040px;
|
max-width: 1040px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
.head { text-align: center; }
|
.head {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
.head h1 {
|
.head h1 {
|
||||||
margin: 0 0 0.35rem;
|
margin: 0 0 0.35rem;
|
||||||
font-size: clamp(1.5rem, 3vw, 2.1rem);
|
font-size: clamp(1.5rem, 3vw, 2.1rem);
|
||||||
@@ -159,7 +175,10 @@
|
|||||||
color: #6b7280;
|
color: #6b7280;
|
||||||
}
|
}
|
||||||
|
|
||||||
.checkout-wrap { width: 100%; max-width: 720px; }
|
.checkout-wrap {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 720px;
|
||||||
|
}
|
||||||
.checkout-card {
|
.checkout-card {
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border: 1px solid #e5e7eb;
|
border: 1px solid #e5e7eb;
|
||||||
@@ -173,10 +192,22 @@
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
margin-bottom: 0.75rem;
|
margin-bottom: 0.75rem;
|
||||||
}
|
}
|
||||||
.checkout-head h2 { margin: 0; font-size: 1.05rem; color: #111827; }
|
.checkout-head h2 {
|
||||||
.checkout-host { min-height: 420px; }
|
margin: 0;
|
||||||
.checkout-host :global(iframe) { width: 100%; }
|
font-size: 1.05rem;
|
||||||
.hint { font-size: 0.82rem; color: #9ca3af; margin: 0.75rem 0 0; }
|
color: #111827;
|
||||||
|
}
|
||||||
|
.checkout-host {
|
||||||
|
min-height: 420px;
|
||||||
|
}
|
||||||
|
.checkout-host :global(iframe) {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.hint {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: #9ca3af;
|
||||||
|
margin: 0.75rem 0 0;
|
||||||
|
}
|
||||||
.dash-link {
|
.dash-link {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
margin-top: 0.5rem;
|
margin-top: 0.5rem;
|
||||||
@@ -185,10 +216,18 @@
|
|||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
.dash-link:hover { text-decoration: underline; }
|
.dash-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-height: 760px) {
|
@media (max-height: 760px) {
|
||||||
main { gap: 1rem; padding-top: 0.75rem; padding-bottom: 0.75rem; }
|
main {
|
||||||
.head h1 { font-size: 1.35rem; }
|
gap: 1rem;
|
||||||
|
padding-top: 0.75rem;
|
||||||
|
padding-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
.head h1 {
|
||||||
|
font-size: 1.35rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,24 +1,23 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
import { tick } from 'svelte';
|
import { PUBLIC_STRIPE_PUBLISHABLE_KEY } from '$app/env/public';
|
||||||
import { PUBLIC_STRIPE_PUBLISHABLE_KEY } from '$app/env/public';
|
import { loadStripe, type StripeEmbeddedCheckout } from '@stripe/stripe-js';
|
||||||
import { loadStripe, type StripeEmbeddedCheckout } from '@stripe/stripe-js';
|
import AuthShell from '$lib/components/AuthShell.svelte';
|
||||||
import AuthShell from '$lib/components/AuthShell.svelte';
|
import { ViewHeader, CardGrid, Card, Button, PricingPlans } from '$lib/components';
|
||||||
import { ViewHeader, CardGrid, Card, Button, PricingPlans } from '$lib/components';
|
import { slugify, handle } from '@shared/slugify';
|
||||||
import { slugify, handle } from '@shared/slugify';
|
import { page } from '$app/state';
|
||||||
import { page } from '$app/state';
|
|
||||||
|
|
||||||
let { form } = $props();
|
let { form } = $props();
|
||||||
|
|
||||||
// State machine: 'fam' | 'child' | 'code' | 'plan' | 'done'
|
// State machine: 'fam' | 'child' | 'code' | 'plan' | 'done'
|
||||||
let step = $state<'fam' | 'child' | 'code' | 'plan' | 'done'>('fam');
|
let step = $state<'fam' | 'child' | 'code' | 'plan' | 'done'>('fam');
|
||||||
// Determine initial step from URL param (plan preselection)
|
// A plan carried from /pricing (?plan=monthly|yearly) skips the picker:
|
||||||
$effect(() => {
|
// the plan step auto-confirms straight into embedded checkout.
|
||||||
const plan = page.url.searchParams.get('plan');
|
let urlPlan = $derived(
|
||||||
if (plan && (plan === 'monthly' || plan === 'yearly') && form?.type !== 'failure') {
|
['monthly', 'yearly'].includes(page.url.searchParams.get('plan') || '')
|
||||||
step = 'plan';
|
? (page.url.searchParams.get('plan') as string)
|
||||||
}
|
: ''
|
||||||
});
|
);
|
||||||
|
|
||||||
let famName = $state('');
|
let famName = $state('');
|
||||||
let yourName = $state('');
|
let yourName = $state('');
|
||||||
@@ -34,11 +33,23 @@ import { page } from '$app/state';
|
|||||||
let famSlugPreview = $derived(slugify(famName) || 'your-family');
|
let famSlugPreview = $derived(slugify(famName) || 'your-family');
|
||||||
let handlePreview = $derived(handle(yourName) || 'your-name');
|
let handlePreview = $derived(handle(yourName) || 'your-name');
|
||||||
let selectedPlan = $derived(page.url.searchParams.get('plan') || '');
|
let selectedPlan = $derived(page.url.searchParams.get('plan') || '');
|
||||||
let showCheckout = $state(false);
|
let activeSecret = $state('');
|
||||||
|
let mountedFor = $state('');
|
||||||
let checkoutTitle = $state('');
|
let checkoutTitle = $state('');
|
||||||
let checkoutEl = $state<HTMLDivElement | null>(null);
|
let checkoutEl = $state<HTMLDivElement | null>(null);
|
||||||
let checkout: StripeEmbeddedCheckout | null = null;
|
let checkout: StripeEmbeddedCheckout | null = null;
|
||||||
|
|
||||||
|
// Auto-confirm: when arriving at the plan step with a URL plan, submit the
|
||||||
|
// hidden choose form once so embedded checkout mounts without a picker.
|
||||||
|
let chooseForm = $state<HTMLFormElement | null>(null);
|
||||||
|
let autoSubmittedFor = $state('');
|
||||||
|
$effect(() => {
|
||||||
|
if (step === 'plan' && urlPlan && !activeSecret && chooseForm && autoSubmittedFor !== urlPlan) {
|
||||||
|
autoSubmittedFor = urlPlan;
|
||||||
|
chooseForm.requestSubmit();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const enhanceForm = () => {
|
const enhanceForm = () => {
|
||||||
return () =>
|
return () =>
|
||||||
async ({ update, result }: any) => {
|
async ({ update, result }: any) => {
|
||||||
@@ -56,61 +67,76 @@ import { page } from '$app/state';
|
|||||||
// Capture famSlug from the signup action result
|
// Capture famSlug from the signup action result
|
||||||
if (result.data?.famSlug) submittedFamSlug = result.data.famSlug;
|
if (result.data?.famSlug) submittedFamSlug = result.data.famSlug;
|
||||||
step = 'child';
|
step = 'child';
|
||||||
} else if (step === 'child') step = 'code';
|
} else if (step === 'child') step = urlPlan ? 'plan' : 'code';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
async function mountEmbedded(clientSecret: string, planName: string) {
|
function mountEmbedded(clientSecret: string, planName: string) {
|
||||||
const stripe = await loadStripe(String(PUBLIC_STRIPE_PUBLISHABLE_KEY));
|
activeSecret = clientSecret;
|
||||||
if (!stripe) {
|
|
||||||
alert('Stripe failed to load');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
checkout?.unmount();
|
|
||||||
checkout = await stripe.createEmbeddedCheckoutPage({ clientSecret });
|
|
||||||
checkoutTitle = planName;
|
checkoutTitle = planName;
|
||||||
showCheckout = true;
|
|
||||||
await tick();
|
|
||||||
if (checkoutEl) checkout.mount(checkoutEl);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleAccess({ result, formData }: { result: any; formData?: FormData }) {
|
// Mounts once the host element exists (no bind/tick race): re-runs when
|
||||||
if (result.type === 'success' && result.data) {
|
// activeSecret or checkoutEl changes; mountedFor guards against remounts.
|
||||||
if (result.data.skipped) {
|
let mounting = false;
|
||||||
step = 'plan';
|
$effect(() => {
|
||||||
} else {
|
if (!activeSecret || !checkoutEl || mounting || mountedFor === activeSecret) return;
|
||||||
accessMsg = 'Access code applied — you\'re all set!';
|
mounting = true;
|
||||||
step = 'done';
|
mountedFor = activeSecret;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const stripe = await loadStripe(String(PUBLIC_STRIPE_PUBLISHABLE_KEY));
|
||||||
|
if (!stripe) return;
|
||||||
|
checkout?.unmount();
|
||||||
|
checkout = await stripe.createEmbeddedCheckoutPage({ clientSecret: activeSecret });
|
||||||
|
if (checkoutEl) checkout.mount(checkoutEl);
|
||||||
|
} finally {
|
||||||
|
mounting = false;
|
||||||
}
|
}
|
||||||
} else if (result.type === 'failure') {
|
})();
|
||||||
accessMsg = result.data?.error || 'Invalid code.';
|
});
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleChoose({ result, formData }: { result: any; formData?: FormData }) {
|
// use:enhance two-stage: factory → resolve fn receives { result, formData }.
|
||||||
if (result.type === 'success' && result.data) {
|
const handleAccess =
|
||||||
const d = result.data as { clientSecret?: string };
|
() =>
|
||||||
if (d.clientSecret) {
|
async ({ result }: any) => {
|
||||||
const planName = String(formData?.get('plan') || '');
|
if (result?.type === 'success' && result.data) {
|
||||||
const label = { monthly: 'Monthly', yearly: 'Yearly' }[planName] || planName;
|
if (result.data.skipped) {
|
||||||
mountEmbedded(d.clientSecret, label);
|
step = 'plan';
|
||||||
|
} else {
|
||||||
|
accessMsg = "Access code applied — you're all set!";
|
||||||
|
step = 'done';
|
||||||
|
}
|
||||||
|
} else if (result?.type === 'failure') {
|
||||||
|
accessMsg = result.data?.error || 'Invalid code.';
|
||||||
}
|
}
|
||||||
} else if (result.type === 'failure') {
|
};
|
||||||
localError = result.data?.error || 'Failed to start checkout.';
|
|
||||||
}
|
const handleChoose =
|
||||||
}
|
() =>
|
||||||
|
async ({ result, formData }: any) => {
|
||||||
|
if (result?.type === 'success' && result.data) {
|
||||||
|
const d = result.data as { clientSecret?: string };
|
||||||
|
if (d.clientSecret) {
|
||||||
|
const planName = String(formData?.get('plan') || '');
|
||||||
|
const label = { monthly: 'Monthly', yearly: 'Yearly' }[planName] || planName;
|
||||||
|
mountEmbedded(d.clientSecret, label);
|
||||||
|
}
|
||||||
|
} else if (result?.type === 'failure') {
|
||||||
|
localError = result.data?.error || 'Failed to start checkout.';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
function resetCheckout() {
|
function resetCheckout() {
|
||||||
checkout?.unmount();
|
checkout?.unmount();
|
||||||
checkout = null;
|
checkout = null;
|
||||||
showCheckout = false;
|
activeSecret = '';
|
||||||
checkoutTitle = '';
|
checkoutTitle = '';
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<AuthShell title="Create your family" subtitle="Set up in about a minute. Free to get going.">
|
<AuthShell title="Create your family" subtitle="Set up in about a minute. Free to get going.">
|
||||||
|
|
||||||
{#if form?.message}
|
{#if form?.message}
|
||||||
<p class="form-error">{form.message}</p>
|
<p class="form-error">{form.message}</p>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -125,7 +151,10 @@ import { page } from '$app/state';
|
|||||||
Family name
|
Family name
|
||||||
<input name="familyName" bind:value={famName} placeholder="The Smiths" required />
|
<input name="familyName" bind:value={famName} placeholder="The Smiths" required />
|
||||||
{#if famName}
|
{#if famName}
|
||||||
<span class="preview">Family page address: <code>/</code><code class="inline-code">{famSlugPreview}</code></span>
|
<span class="preview"
|
||||||
|
>Family page address: <code>/</code><code class="inline-code">{famSlugPreview}</code
|
||||||
|
></span
|
||||||
|
>
|
||||||
{/if}
|
{/if}
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
@@ -133,11 +162,15 @@ import { page } from '$app/state';
|
|||||||
<input name="yourName" bind:value={yourName} placeholder="Mum / Dad" required />
|
<input name="yourName" bind:value={yourName} placeholder="Mum / Dad" required />
|
||||||
{#if yourName}
|
{#if yourName}
|
||||||
<span class="preview">
|
<span class="preview">
|
||||||
Your address: <code>/</code><code class="inline-code">{famSlugPreview}/{handlePreview}</code>
|
Your address: <code>/</code><code class="inline-code"
|
||||||
|
>{famSlugPreview}/{handlePreview}</code
|
||||||
|
>
|
||||||
<small class="preview-hint">(no spaces — {yourName.trim()} → {handlePreview})</small>
|
<small class="preview-hint">(no spaces — {yourName.trim()} → {handlePreview})</small>
|
||||||
</span>
|
</span>
|
||||||
{:else}
|
{:else}
|
||||||
<span class="preview-hint">No spaces in your address — e.g. “Joe Edhook” → <code>joeedhook</code></span>
|
<span class="preview-hint"
|
||||||
|
>No spaces in your address — e.g. “Joe Edhook” → <code>joeedhook</code></span
|
||||||
|
>
|
||||||
{/if}
|
{/if}
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
@@ -158,65 +191,80 @@ import { page } from '$app/state';
|
|||||||
<button type="submit" disabled={submitting}>Create my family</button>
|
<button type="submit" disabled={submitting}>Create my family</button>
|
||||||
</form>
|
</form>
|
||||||
<p class="alt">Already have a family? <a href="/login">Log in</a></p>
|
<p class="alt">Already have a family? <a href="/login">Log in</a></p>
|
||||||
|
|
||||||
{:else if step === 'child'}
|
{:else if step === 'child'}
|
||||||
<h3 class="step-title">Add a child now?</h3>
|
<h3 class="step-title">Add a child now?</h3>
|
||||||
<p class="step-note">We'll create a shareable join code so they can jump in on any device.</p>
|
<p class="step-note">We'll create a shareable join code so they can jump in on any device.</p>
|
||||||
<form method="POST" action="?/child" use:enhance={enhanceForm()}>
|
<form method="POST" action="?/child" use:enhance={enhanceForm()}>
|
||||||
<label>
|
<label>
|
||||||
Child's name
|
Child's name
|
||||||
<input
|
<input type="text" name="member" bind:value={childName} placeholder="Their first name" />
|
||||||
type="text"
|
|
||||||
name="member"
|
|
||||||
bind:value={childName}
|
|
||||||
placeholder="Their first name"
|
|
||||||
/>
|
|
||||||
</label>
|
</label>
|
||||||
<button type="submit" disabled={submitting}>Create join code</button>
|
<button type="submit" disabled={submitting}>Create join code</button>
|
||||||
</form>
|
</form>
|
||||||
<p class="alt">
|
<p class="alt">
|
||||||
<a href="/{submittedFamSlug}" onclick={() => (step = 'code')}>Skip for now →</a>
|
<button onclick={() => (step = urlPlan ? 'plan' : 'code')} class="skip-link">
|
||||||
|
{urlPlan ? 'Skip — continue to your plan →' : 'Skip for now →'}
|
||||||
|
</button>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{:else if step === 'code'}
|
{:else if step === 'code'}
|
||||||
<h3 class="step-title">Have an access code?</h3>
|
<h3 class="step-title">Have an access code?</h3>
|
||||||
<p class="step-note">If you have a code (e.g. from your employer or a gift), enter it here. Otherwise skip to choose a plan.</p>
|
<p class="step-note">
|
||||||
|
If you have a code (e.g. from your employer or a gift), enter it here. Otherwise skip to
|
||||||
|
choose a plan.
|
||||||
|
</p>
|
||||||
<form method="POST" action="?/access" use:enhance={handleAccess}>
|
<form method="POST" action="?/access" use:enhance={handleAccess}>
|
||||||
<input name="code" bind:value={accessCode} placeholder="Enter access code" autocomplete="off" />
|
<input
|
||||||
|
name="code"
|
||||||
|
bind:value={accessCode}
|
||||||
|
placeholder="Enter access code"
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
<Button type="submit" size="md" variant="primary" disabled={submitting}>Apply code</Button>
|
<Button type="submit" size="md" variant="primary" disabled={submitting}>Apply code</Button>
|
||||||
</form>
|
</form>
|
||||||
{#if accessMsg}
|
{#if accessMsg}
|
||||||
<p class="access-msg">{accessMsg}</p>
|
<p class="access-msg">{accessMsg}</p>
|
||||||
{/if}
|
{/if}
|
||||||
<p class="alt">
|
<p class="alt">
|
||||||
<button onclick={() => (step = 'plan')} class="skip-link">Skip — choose a plan instead →</button>
|
<button onclick={() => (step = 'plan')} class="skip-link">
|
||||||
|
{urlPlan ? 'Skip — continue to your plan →' : 'Skip — choose a plan instead →'}
|
||||||
|
</button>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{:else if step === 'plan'}
|
{:else if step === 'plan'}
|
||||||
<h3 class="step-title">Choose a plan</h3>
|
{#if urlPlan && !activeSecret}
|
||||||
<p class="step-note">Pick the plan that works for your family. Your trial or subscription starts immediately.</p>
|
<!-- Plan came from /pricing — no picker; confirm straight into checkout. -->
|
||||||
<PricingPlans
|
<h3 class="step-title">Your plan: {urlPlan === 'monthly' ? 'Monthly' : 'Yearly'}</h3>
|
||||||
action="?/choose"
|
<p class="step-note">
|
||||||
hideTrial={true}
|
{urlPlan === 'monthly' ? '£3/month, cancel anytime.' : '£30/year — two months free.'}
|
||||||
selected={selectedPlan}
|
Payment opens below.
|
||||||
error={form?.error}
|
</p>
|
||||||
onsubmit={handleChoose}
|
<form bind:this={chooseForm} method="POST" action="?/choose" use:enhance={handleChoose}>
|
||||||
/>
|
<input type="hidden" name="plan" value={urlPlan} />
|
||||||
{#if showCheckout}
|
</form>
|
||||||
<CardGrid>
|
{:else if !activeSecret}
|
||||||
<Card title={`Checkout — ${checkoutTitle}`} accent="#059669">
|
<h3 class="step-title">Choose a plan</h3>
|
||||||
<div class="checkout-actions">
|
<p class="step-note">
|
||||||
<Button variant="ghost" size="sm" onclick={resetCheckout}>← Back to plans</Button>
|
Pick the plan that works for your family. Your subscription starts immediately.
|
||||||
</div>
|
</p>
|
||||||
<div bind:this={checkoutEl} class="checkout-host"></div>
|
<PricingPlans
|
||||||
<p class="hint" style="margin-top: 1rem;">
|
action="?/choose"
|
||||||
You can close and go to your dashboard any time — access unlocks once payment completes.
|
hideTrial={true}
|
||||||
</p>
|
selected={selectedPlan}
|
||||||
<a href={submittedFamSlug ? `/${submittedFamSlug}` : '/'} class="btn-primary" style="margin-top: 1rem; display: inline-block;">Go to dashboard</a>
|
error={form?.error}
|
||||||
</Card>
|
onsubmit={handleChoose}
|
||||||
</CardGrid>
|
/>
|
||||||
|
{/if}
|
||||||
|
{#if activeSecret}
|
||||||
|
<div class="checkout-actions">
|
||||||
|
<Button variant="ghost" size="sm" onclick={resetCheckout}>← Back to plans</Button>
|
||||||
|
</div>
|
||||||
|
<div bind:this={checkoutEl} class="checkout-host"></div>
|
||||||
|
<p class="hint">
|
||||||
|
You can close and go to your dashboard any time — access unlocks once payment completes.
|
||||||
|
</p>
|
||||||
|
<a href={submittedFamSlug ? `/${submittedFamSlug}` : '/'} class="btn-primary"
|
||||||
|
>Go to dashboard</a
|
||||||
|
>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{:else if step === 'done'}
|
{:else if step === 'done'}
|
||||||
<h3 class="step-title">Your family is ready!</h3>
|
<h3 class="step-title">Your family is ready!</h3>
|
||||||
<p class="step-note">You can add kids and share join codes any time from Family Settings.</p>
|
<p class="step-note">You can add kids and share join codes any time from Family Settings.</p>
|
||||||
@@ -261,8 +309,13 @@ import { page } from '$app/state';
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
button:hover { background: #3730a3; }
|
button:hover {
|
||||||
button:disabled { opacity: 0.6; cursor: default; }
|
background: #3730a3;
|
||||||
|
}
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
.step-title {
|
.step-title {
|
||||||
margin: 0 0 0.25rem;
|
margin: 0 0 0.25rem;
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
@@ -292,7 +345,8 @@ import { page } from '$app/state';
|
|||||||
color: #9ca3af;
|
color: #9ca3af;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
}
|
}
|
||||||
.preview .inline-code, .preview-hint .inline-code {
|
.preview .inline-code,
|
||||||
|
.preview-hint .inline-code {
|
||||||
font-family: ui-monospace, monospace;
|
font-family: ui-monospace, monospace;
|
||||||
background: #f3f4f6;
|
background: #f3f4f6;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
@@ -305,7 +359,11 @@ import { page } from '$app/state';
|
|||||||
color: #6b7280;
|
color: #6b7280;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
.alt a { color: #4338ca; text-decoration: none; font-weight: 500; }
|
.alt a {
|
||||||
|
color: #4338ca;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
.skip-link {
|
.skip-link {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
@@ -315,7 +373,9 @@ import { page } from '$app/state';
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
.skip-link:hover { text-decoration: underline; }
|
.skip-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
.access-msg {
|
.access-msg {
|
||||||
margin-top: 0.5rem;
|
margin-top: 0.5rem;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
@@ -335,5 +395,17 @@ import { page } from '$app/state';
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
.btn-primary:hover { background: #3730a3; }
|
.btn-primary:hover {
|
||||||
</style>
|
background: #3730a3;
|
||||||
|
}
|
||||||
|
.checkout-actions {
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
.checkout-host {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 480px;
|
||||||
|
}
|
||||||
|
.checkout-host :global(iframe) {
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user