feature plus ux rearrangements
This commit is contained in:
@@ -5,6 +5,9 @@ import { createPbClient } from '$lib/server/pocketbase';
|
||||
import { setSessionCookie } from '$lib/server/session';
|
||||
import { issueAccess } from '$lib/server/member-otp';
|
||||
import { slugify, handle, famUsername, handleOf } from '@shared/slugify';
|
||||
import { applyAccessCode } from '$lib/server/access';
|
||||
import { createEmbeddedCheckoutSession, PLAN_IDS } from '$lib/server/stripe';
|
||||
import type { PlanId } from '$lib/server/stripe';
|
||||
|
||||
class SignupError extends Error {}
|
||||
|
||||
@@ -32,11 +35,14 @@ export const actions = {
|
||||
const username = famUsername(slug, handleName);
|
||||
|
||||
try {
|
||||
const fam = await pbAdmin.create('fams', {
|
||||
const famData: Record<string, unknown> = {
|
||||
name: famName,
|
||||
slug,
|
||||
timezone: 'auto'
|
||||
});
|
||||
timezone: 'auto',
|
||||
paymentMode: 'none',
|
||||
active: false
|
||||
};
|
||||
const fam = await pbAdmin.create('fams', famData);
|
||||
const user = await pbAdmin.create('users', {
|
||||
username,
|
||||
name: parentName,
|
||||
@@ -61,7 +67,6 @@ export const actions = {
|
||||
.catch(() => null);
|
||||
if (authResult?.token) setSessionCookie(event.cookies, authResult.token);
|
||||
|
||||
// `username` here is the handle (URL segment), not the composite.
|
||||
return { success: true, famSlug: slug, username: handleName };
|
||||
},
|
||||
|
||||
@@ -71,11 +76,14 @@ export const actions = {
|
||||
const fd = await event.request.formData();
|
||||
const name = ((fd.get('member') as string) || '').trim();
|
||||
|
||||
// No [fam] URL param here (signup isn't fam-scoped) — resolve the slug
|
||||
// from DB. Everywhere else, the slug comes from event.params.fam /
|
||||
// page.data.famSlug ([fam] layout load) — never copy it into state.
|
||||
const fam = await pbAdmin.getOne('fams', user.famId);
|
||||
const famSlug = fam?.slug || user.famId;
|
||||
|
||||
if (!name) {
|
||||
return { success: true, famSlug, username: handleOf(user.username) };
|
||||
return { success: true, famSlug, username: handleOf(user.username || '') };
|
||||
}
|
||||
|
||||
const { otp, joinUrl } = await issueAccess({
|
||||
@@ -84,11 +92,57 @@ export const actions = {
|
||||
name
|
||||
});
|
||||
|
||||
return { success: true, code: otp, joinUrl, famSlug, username: handleOf(user.username) };
|
||||
return { success: true, code: otp, joinUrl, famSlug, username: handleOf(user.username || '') };
|
||||
},
|
||||
|
||||
// Step 3 — apply an access code (or skip via client-side navigation).
|
||||
access: async (event: RequestEvent) => {
|
||||
const user = requireUser(event);
|
||||
const fd = await event.request.formData();
|
||||
const code = ((fd.get('code') as string) || '').trim();
|
||||
|
||||
if (!code) {
|
||||
return { ok: true, skipped: true };
|
||||
}
|
||||
|
||||
const result = await applyAccessCode(user.famId, code);
|
||||
if (result.error) return fail(400, { error: result.error });
|
||||
|
||||
return { ok: true, ...result };
|
||||
},
|
||||
|
||||
// Step 4 — choose a plan, start embedded checkout.
|
||||
choose: async (event: RequestEvent) => {
|
||||
const user = requireUser(event);
|
||||
const fd = await event.request.formData();
|
||||
const plan = fd.get('plan') as PlanId;
|
||||
|
||||
if (!['monthly', 'yearly'].includes(plan)) {
|
||||
return fail(400, { error: 'Unknown plan' });
|
||||
}
|
||||
|
||||
const fam = await pbAdmin.getOne('fams', user.famId);
|
||||
const parents = await pbAdmin.getList('users', `famId = '${user.famId}' && role = 'parent'`);
|
||||
const email = (parents[0] as { email?: string } | undefined)?.email || null;
|
||||
|
||||
try {
|
||||
const { clientSecret, sessionId } = await createEmbeddedCheckoutSession({
|
||||
plan,
|
||||
famId: user.famId,
|
||||
famSlug: fam.slug,
|
||||
email,
|
||||
customerId: fam.stripeCustomerId || null,
|
||||
origin: event.url.origin
|
||||
});
|
||||
return { success: true, clientSecret, sessionId, plan };
|
||||
} catch (e) {
|
||||
if (e instanceof redirect) throw e;
|
||||
return fail(500, { error: e instanceof Error ? e.message : 'Failed to start checkout' });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function requireUser(event: RequestEvent) {
|
||||
if (!event.locals.user) throw redirect(303, '/signup');
|
||||
return event.locals.user;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user