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
@@ -0,0 +1,70 @@
import { redirect, fail } from '@sveltejs/kit';
import type { RequestEvent } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { pbAdmin } from '$lib/server/pocketbase';
import { createEmbeddedCheckoutSession, resolveTrialDays, PLAN_IDS } from '$lib/server/stripe';
import type { PlanId } from '$lib/server/stripe';
function famOf(event: RequestEvent) {
if (!event.locals.user) throw redirect(303, '/login');
return event.locals.user.famId;
}
export const load: PageServerLoad = async (event) => {
const authenticated = !!event.locals.user;
let famSlug: string | null = null;
if (authenticated) {
const fam = await pbAdmin.getOne('fams', event.locals.user!.famId).catch(() => null);
famSlug = fam?.slug || null;
}
return {
authenticated,
famSlug,
plans: { monthly: PLAN_IDS.monthly, yearly: PLAN_IDS.yearly }
};
};
export const actions: Actions = {
choose: async (event) => {
const fd = await event.request.formData();
const plan = fd.get('plan') as PlanId;
const code = (fd.get('code') as string) || '';
if (!['trial', 'monthly', 'yearly'].includes(plan)) {
return fail(400, { error: 'Unknown plan' });
}
// Trial requires a valid app-side code (which maps to trial days).
let trialDays: number | null = null;
if (plan === 'trial') {
trialDays = await resolveTrialDays(code);
if (!trialDays) return fail(400, { error: 'Invalid trial code' });
}
// Not logged in → redirect to signup with plan preselected.
if (!event.locals.user) {
throw redirect(303, `/signup?plan=${plan}`);
}
const famId = famOf(event);
const fam = await pbAdmin.getOne('fams', famId);
const parents = await pbAdmin.getList('users', `famId = '${famId}' && role = 'parent'`);
const email = (parents[0] as { email?: string } | undefined)?.email || null;
try {
const { clientSecret, sessionId } = await createEmbeddedCheckoutSession({
plan,
famId,
famSlug: fam.slug,
email,
customerId: fam.stripeCustomerId || null,
trialDays,
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' });
}
}
};
+99
View File
@@ -0,0 +1,99 @@
<script lang="ts">
import { tick } from 'svelte';
import { enhance } from '$app/forms';
import { PUBLIC_STRIPE_PUBLISHABLE_KEY } from '$app/env/public';
import { loadStripe, type StripeEmbeddedCheckout } from '@stripe/stripe-js';
import { ViewHeader, CardGrid, Card, Button, PricingPlans } from '$lib/components';
let { data, form } = $props();
let showCheckout = $state(false);
let checkoutTitle = $state('');
let checkoutEl = $state<HTMLDivElement | null>(null);
let checkout: StripeEmbeddedCheckout | null = null;
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 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 = { trial: 'Trial', monthly: 'Monthly', yearly: 'Yearly' }[planName] || planName;
mountEmbedded(d.clientSecret, label);
}
}
}
function resetCheckout() {
checkout?.unmount();
checkout = null;
showCheckout = false;
checkoutTitle = '';
}
</script>
<ViewHeader title="Pricing" subtitle="Choose a plan for your family" hero />
{#if !showCheckout}
<PricingPlans
action="?/choose"
selected=""
error={form?.error}
onsubmit={handleChoose}
/>
{:else}
<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={data.famSlug ? `/${data.famSlug}` : '/'} class="btn-primary" style="margin-top: 1rem; display: inline-block;">Go to dashboard</a>
</Card>
</CardGrid>
{/if}
<style>
.hint {
font-size: 0.85rem;
color: #9ca3af;
line-height: 1.4;
}
.checkout-actions {
margin-bottom: 0.75rem;
}
.checkout-host {
min-height: 480px;
}
.checkout-host :global(iframe) {
width: 100%;
}
.btn-primary {
display: inline-block;
text-align: center;
background: #4338ca;
color: #fff;
border-radius: 8px;
padding: 0.75rem 1.5rem;
font-size: 1rem;
font-weight: 600;
text-decoration: none;
}
.btn-primary:hover { background: #3730a3; }
</style>