feature plus ux rearrangements

This commit is contained in:
JCEEE
2026-08-22 19:49:46 +01:00
parent f14f4e2ac1
commit 42b9a27ce6
44 changed files with 2020 additions and 538 deletions
+140 -43
View File
@@ -1,40 +1,116 @@
<script lang="ts">
import { enhance } from '$app/forms';
import AuthShell from '$lib/components/AuthShell.svelte';
import { slugify, handle } from '@shared/slugify';
import { enhance } from '$app/forms';
import { tick } from 'svelte';
import { PUBLIC_STRIPE_PUBLISHABLE_KEY } from '$app/env/public';
import { loadStripe, type StripeEmbeddedCheckout } from '@stripe/stripe-js';
import AuthShell from '$lib/components/AuthShell.svelte';
import { ViewHeader, CardGrid, Card, Button, PricingPlans } from '$lib/components';
import { slugify, handle } from '@shared/slugify';
import { page } from '$app/state';
let { form } = $props();
let step = $state(1);
// State machine: 'fam' | 'child' | 'code' | 'plan' | 'done'
let step = $state<'fam' | 'child' | 'code' | 'plan' | 'done'>('fam');
// Determine initial step from URL param (plan preselection)
$effect(() => {
const plan = page.url.searchParams.get('plan');
if (plan && (plan === 'monthly' || plan === 'yearly') && form?.type !== 'failure') {
step = 'plan';
}
});
let famName = $state('');
let yourName = $state('');
let email = $state('');
let password = $state('');
let childName = $state('');
let accessCode = $state('');
let accessMsg = $state('');
let submittedFamSlug = $state('');
let submitting = $state(false);
let localError = $state('');
let famSlugPreview = $derived(slugify(famName) || 'your-family');
let handlePreview = $derived(handle(yourName) || 'your-name');
let selectedPlan = $derived(page.url.searchParams.get('plan') || '');
let showCheckout = $state(false);
let checkoutTitle = $state('');
let checkoutEl = $state<HTMLDivElement | null>(null);
let checkout: StripeEmbeddedCheckout | null = null;
const enhanceForm = () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- canary $types lacks SubmitFunction
return () =>
async ({ update, result }: any) => {
submitting = true;
localError = '';
accessMsg = '';
try {
await update();
} catch (e) {
localError = e instanceof Error ? e.message : 'Something went wrong. Please try again.';
}
submitting = false;
if (result.type !== 'failure' && result.type !== 'error') step++;
if (result.type !== 'failure' && result.type !== 'error') {
if (step === 'fam') {
// Capture famSlug from the signup action result
if (result.data?.famSlug) submittedFamSlug = result.data.famSlug;
step = 'child';
} else if (step === 'child') step = 'code';
}
};
};
async function mountEmbedded(clientSecret: string, planName: string) {
const stripe = await loadStripe(String(PUBLIC_STRIPE_PUBLISHABLE_KEY));
if (!stripe) {
alert('Stripe failed to load');
return;
}
checkout?.unmount();
checkout = await stripe.createEmbeddedCheckoutPage({ clientSecret });
checkoutTitle = planName;
showCheckout = true;
await tick();
if (checkoutEl) checkout.mount(checkoutEl);
}
function handleAccess({ result, formData }: { result: any; formData?: FormData }) {
if (result.type === 'success' && result.data) {
if (result.data.skipped) {
step = 'plan';
} else {
accessMsg = 'Access code applied — you\'re all set!';
step = 'done';
}
} else if (result.type === 'failure') {
accessMsg = result.data?.error || 'Invalid code.';
}
}
function handleChoose({ result, formData }: { result: any; formData?: FormData }) {
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() {
checkout?.unmount();
checkout = null;
showCheckout = false;
checkoutTitle = '';
}
</script>
<AuthShell title="Create your family" subtitle="Set up in about a minute. Free to get going.">
{#if form?.message}
<p class="form-error">{form.message}</p>
{/if}
@@ -42,7 +118,8 @@
<p class="form-error">{localError}</p>
{/if}
{#if step === 1}
<!-- Step 1: Family + Parent -->
{#if step === 'fam'}
<form method="POST" action="?/signup" use:enhance={enhanceForm()}>
<label>
Family name
@@ -81,9 +158,8 @@
<button type="submit" disabled={submitting}>Create my family</button>
</form>
<p class="alt">Already have a family? <a href="/login">Log in</a></p>
{/if}
{#if step === 2}
{:else if step === 'child'}
<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>
<form method="POST" action="?/child" use:enhance={enhanceForm()}>
@@ -99,24 +175,53 @@
<button type="submit" disabled={submitting}>Create join code</button>
</form>
<p class="alt">
<a href="/{form?.famSlug}">Skip for now →</a>
<a href="/{submittedFamSlug}" onclick={() => (step = 'code')}>Skip for now →</a>
</p>
{/if}
{#if step === 3}
<h3 class="step-title">{childName ? `Nice — share this code with ${childName}:` : 'Your family is ready!'}</h3>
{#if form?.code}
<div class="code">
<span class="code-text">{form.code}</span>
</div>
<p class="step-note">
They open <code class="inline-code">{form?.joinUrl}</code> and enter this code.
</p>
{:else}
<p class="step-note">You can add kids and share join codes any time from Family Settings.</p>
{:else if step === 'code'}
<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>
<form method="POST" action="?/access" use:enhance={handleAccess}>
<input name="code" bind:value={accessCode} placeholder="Enter access code" autocomplete="off" />
<Button type="submit" size="md" variant="primary" disabled={submitting}>Apply code</Button>
</form>
{#if accessMsg}
<p class="access-msg">{accessMsg}</p>
{/if}
<p class="alt">
<button onclick={() => (step = 'plan')} class="skip-link">Skip — choose a plan instead →</button>
</p>
{:else if step === 'plan'}
<h3 class="step-title">Choose a plan</h3>
<p class="step-note">Pick the plan that works for your family. Your trial or subscription starts immediately.</p>
<PricingPlans
action="?/choose"
hideTrial={true}
selected={selectedPlan}
error={form?.error}
onsubmit={handleChoose}
/>
{#if showCheckout}
<CardGrid>
<Card title={`Checkout — ${checkoutTitle}`} accent="#059669">
<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" style="margin-top: 1rem;">
You can close and go to your dashboard any time — access unlocks once payment completes.
</p>
<a href={submittedFamSlug ? `/${submittedFamSlug}` : '/'} class="btn-primary" style="margin-top: 1rem; display: inline-block;">Go to dashboard</a>
</Card>
</CardGrid>
{/if}
{:else if step === 'done'}
<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>
<div class="actions">
<a href="/{form?.famSlug}" class="btn-primary">Go to dashboard</a>
<a href="/{submittedFamSlug}" class="btn-primary">Go to dashboard</a>
</div>
{/if}
</AuthShell>
@@ -201,28 +306,20 @@
text-align: center;
}
.alt a { color: #4338ca; text-decoration: none; font-weight: 500; }
.code {
background: #eef2ff;
border: 1px dashed #a5b4fc;
border-radius: 10px;
padding: 1rem;
text-align: center;
margin: 0 0 0.75rem;
}
.code-text {
font-family: ui-monospace, monospace;
font-size: 1.6rem;
letter-spacing: 0.35em;
font-weight: 700;
.skip-link {
background: none;
border: none;
color: #4338ca;
font-weight: 500;
font-size: 0.85rem;
cursor: pointer;
padding: 0;
}
.inline-code {
font-family: ui-monospace, monospace;
font-size: 0.85em;
background: #f3f4f6;
border-radius: 4px;
padding: 0.1em 0.35em;
color: #374151;
.skip-link:hover { text-decoration: underline; }
.access-msg {
margin-top: 0.5rem;
font-size: 0.85rem;
color: #059669;
}
.actions {
margin-top: 1.25rem;