update from user testing payment platform
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user