feature plus ux rearrangements
This commit is contained in:
@@ -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' });
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user