update from user testing payment platform

This commit is contained in:
JCEEE
2026-08-24 18:09:44 +01:00
parent d71353fb3e
commit 09d73199ea
10 changed files with 615 additions and 260 deletions
+170 -98
View File
@@ -1,24 +1,23 @@
<script lang="ts">
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';
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');
// 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';
}
});
// 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('');
@@ -34,11 +33,23 @@ import { page } from '$app/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 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) => {
@@ -56,61 +67,76 @@ import { page } from '$app/state';
// Capture famSlug from the signup action result
if (result.data?.famSlug) submittedFamSlug = result.data.famSlug;
step = 'child';
} else if (step === 'child') step = 'code';
} else if (step === 'child') step = urlPlan ? 'plan' : '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 });
function mountEmbedded(clientSecret: string, planName: string) {
activeSecret = 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';
// 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;
}
} 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);
// 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.';
}
} 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() {
checkout?.unmount();
checkout = null;
showCheckout = false;
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}
@@ -125,7 +151,10 @@ import { page } from '$app/state';
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>
<span class="preview"
>Family page address: <code>/</code><code class="inline-code">{famSlugPreview}</code
></span
>
{/if}
</label>
<label>
@@ -133,11 +162,15 @@ import { page } from '$app/state';
<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>
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>
<span class="preview-hint"
>No spaces in your address — e.g. “Joe Edhook” → <code>joeedhook</code></span
>
{/if}
</label>
<label>
@@ -158,65 +191,80 @@ import { page } from '$app/state';
<button type="submit" disabled={submitting}>Create my family</button>
</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"
/>
<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">
<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>
{: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>
<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" />
<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>
<button onclick={() => (step = 'plan')} class="skip-link">
{urlPlan ? 'Skip — continue to your plan →' : '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 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>
@@ -261,8 +309,13 @@ import { page } from '$app/state';
font-weight: 600;
cursor: pointer;
}
button:hover { background: #3730a3; }
button:disabled { opacity: 0.6; cursor: default; }
button:hover {
background: #3730a3;
}
button:disabled {
opacity: 0.6;
cursor: default;
}
.step-title {
margin: 0 0 0.25rem;
font-size: 1.1rem;
@@ -292,7 +345,8 @@ import { page } from '$app/state';
color: #9ca3af;
font-weight: 400;
}
.preview .inline-code, .preview-hint .inline-code {
.preview .inline-code,
.preview-hint .inline-code {
font-family: ui-monospace, monospace;
background: #f3f4f6;
border-radius: 4px;
@@ -305,7 +359,11 @@ import { page } from '$app/state';
color: #6b7280;
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 {
background: none;
border: none;
@@ -315,7 +373,9 @@ import { page } from '$app/state';
cursor: pointer;
padding: 0;
}
.skip-link:hover { text-decoration: underline; }
.skip-link:hover {
text-decoration: underline;
}
.access-msg {
margin-top: 0.5rem;
font-size: 0.85rem;
@@ -335,5 +395,17 @@ import { page } from '$app/state';
font-weight: 600;
text-decoration: none;
}
.btn-primary:hover { background: #3730a3; }
</style>
.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>