Files
famdone/frontend/src/routes/signup/+page.svelte
T

431 lines
12 KiB
Svelte

<script lang="ts">
import { enhance } from '$app/forms';
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();
// State machine: 'fam' | 'child' | 'code' | 'plan' | 'done'
let step = $state<'fam' | 'child' | 'code' | 'plan' | 'done'>('fam');
// A plan carried from /pricing (?plan=monthly|yearly) skips the picker:
// the plan step auto-confirms straight into embedded checkout.
let urlPlan = $derived(
['monthly', 'yearly'].includes(page.url.searchParams.get('plan') || '')
? (page.url.searchParams.get('plan') as string)
: ''
);
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 activeSecret = $state('');
let mountedFor = $state('');
let checkoutTitle = $state('');
let checkoutEl = $state<HTMLDivElement | 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 = () => {
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') {
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 = urlPlan ? 'plan' : 'code';
}
};
};
function mountEmbedded(clientSecret: string, planName: string) {
activeSecret = clientSecret;
checkoutTitle = planName;
}
// Mounts once the host element exists (no bind/tick race): re-runs when
// activeSecret or checkoutEl changes; mountedFor guards against remounts.
let mounting = false;
$effect(() => {
if (!activeSecret || !checkoutEl || mounting || mountedFor === activeSecret) return;
mounting = true;
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;
}
})();
});
// use:enhance two-stage: factory → resolve fn receives { result, formData }.
const handleAccess =
() =>
async ({ result }: any) => {
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.';
}
};
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() {
checkout?.unmount();
checkout = null;
activeSecret = '';
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}
{#if localError}
<p class="form-error">{localError}</p>
{/if}
<!-- Step 1: Family + Parent -->
{#if step === 'fam'}
<form method="POST" action="?/signup" use:enhance={enhanceForm()}>
<label>
Family name
<input name="familyName" bind:value={famName} placeholder="The Smiths" required />
{#if famName}
<span class="preview"
>Family page address: <code>/</code><code class="inline-code">{famSlugPreview}</code
></span
>
{/if}
</label>
<label>
Your name
<input name="yourName" bind:value={yourName} placeholder="Mum / Dad" required />
{#if yourName}
<span class="preview">
Your address: <code>/</code><code class="inline-code"
>{famSlugPreview}/{handlePreview}</code
>
<small class="preview-hint">(no spaces — {yourName.trim()} → {handlePreview})</small>
</span>
{:else}
<span class="preview-hint"
>No spaces in your address — e.g. “Joe Edhook” → <code>joeedhook</code></span
>
{/if}
</label>
<label>
Email
<input type="email" name="email" bind:value={email} placeholder="you@email.com" required />
</label>
<label>
Password
<input
type="password"
name="password"
bind:value={password}
placeholder="8+ characters"
minlength={8}
required
/>
</label>
<button type="submit" disabled={submitting}>Create my family</button>
<p class="privacy-note">
By signing up you agree to our <a href="/privacy">Privacy Policy</a>. We collect your email
only to create and log into your account, never sell or share your data.
</p>
</form>
<p class="alt">Already have a family? <a href="/login">Log in</a></p>
{: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()}>
<label>
Child's name
<input type="text" name="member" bind:value={childName} placeholder="Their first name" />
</label>
<button type="submit" disabled={submitting}>Create join code</button>
</form>
<p class="alt">
<button onclick={() => (step = urlPlan ? 'plan' : 'code')} class="skip-link">
{urlPlan ? 'Skip — continue to your plan →' : 'Skip for now →'}
</button>
</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">
{urlPlan ? 'Skip — continue to your plan →' : 'Skip — choose a plan instead →'}
</button>
</p>
{:else if step === 'plan'}
{#if urlPlan && !activeSecret}
<!-- Plan came from /pricing — no picker; confirm straight into checkout. -->
<h3 class="step-title">Your plan: {urlPlan === 'monthly' ? 'Monthly' : 'Yearly'}</h3>
<p class="step-note">
{urlPlan === 'monthly' ? '£3/month, cancel anytime.' : '£30/year — two months free.'}
Payment opens below.
</p>
<form bind:this={chooseForm} method="POST" action="?/choose" use:enhance={handleChoose}>
<input type="hidden" name="plan" value={urlPlan} />
</form>
{:else if !activeSecret}
<h3 class="step-title">Choose a plan</h3>
<p class="step-note">
Pick the plan that works for your family. Your subscription starts immediately.
</p>
<PricingPlans
action="?/choose"
hideTrial={true}
selected={selectedPlan}
error={form?.error}
onsubmit={handleChoose}
/>
{/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}
{: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="/{submittedFamSlug}" class="btn-primary">Go to dashboard</a>
</div>
{/if}
</AuthShell>
<style>
form {
display: grid;
gap: 0.9rem;
}
label {
display: flex;
flex-direction: column;
gap: 0.3rem;
font-size: 0.85rem;
font-weight: 500;
color: #374151;
}
input {
padding: 0.6rem 0.75rem;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 0.95rem;
}
input:focus {
outline: none;
border-color: #4338ca;
box-shadow: 0 0 0 3px rgba(67, 56, 202, 0.15);
}
button {
margin-top: 0.25rem;
background: #4338ca;
color: #fff;
border: none;
border-radius: 8px;
padding: 0.75rem;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
}
button:hover {
background: #3730a3;
}
button:disabled {
opacity: 0.6;
cursor: default;
}
.step-title {
margin: 0 0 0.25rem;
font-size: 1.1rem;
color: #111827;
}
.step-note {
margin: 0 0 1rem;
font-size: 0.85rem;
color: #6b7280;
}
.form-error {
background: #fef2f2;
color: #b91c1c;
border: 1px solid #fecaca;
border-radius: 8px;
padding: 0.6rem 0.75rem;
font-size: 0.85rem;
margin: 0 0 1rem;
}
.preview {
font-size: 0.78rem;
color: #6b7280;
font-weight: 400;
}
.preview-hint {
font-size: 0.75rem;
color: #9ca3af;
font-weight: 400;
}
.preview .inline-code,
.preview-hint .inline-code {
font-family: ui-monospace, monospace;
background: #f3f4f6;
border-radius: 4px;
padding: 0.05em 0.3em;
color: #374151;
}
.alt {
margin: 1.25rem 0 0;
font-size: 0.85rem;
color: #6b7280;
text-align: center;
}
.alt a {
color: #4338ca;
text-decoration: none;
font-weight: 500;
}
.skip-link {
background: none;
border: none;
color: #4338ca;
font-weight: 500;
font-size: 0.85rem;
cursor: pointer;
padding: 0;
}
.skip-link:hover {
text-decoration: underline;
}
.access-msg {
margin-top: 0.5rem;
font-size: 0.85rem;
color: #059669;
}
.privacy-note {
margin: 0.25rem 0 0;
font-size: 0.78rem;
color: #6b7280;
line-height: 1.5;
text-align: center;
}
.privacy-note a {
color: #4338ca;
text-decoration: none;
font-weight: 500;
}
.privacy-note a:hover {
text-decoration: underline;
}
.actions {
margin-top: 1.25rem;
}
.btn-primary {
display: block;
text-align: center;
background: #4338ca;
color: #fff;
border-radius: 8px;
padding: 0.75rem;
font-size: 1rem;
font-weight: 600;
text-decoration: none;
}
.btn-primary:hover {
background: #3730a3;
}
.checkout-actions {
margin-bottom: 0.75rem;
}
.checkout-host {
width: 100%;
min-height: 480px;
}
.checkout-host :global(iframe) {
width: 100% !important;
}
</style>