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
+19 -12
View File
@@ -15,12 +15,12 @@
const tiers: Tier[] = [
{
id: 'trial',
name: 'Trial',
name: 'Have a code?',
price: 'Free',
period: 'for the trial period',
blurb: 'Kick the tires on us.',
features: ['Full family access', 'No card required', 'Code from us or a partner'],
cta: 'Start free trial'
period: 'with a valid code',
blurb: 'Been given an access or trial code?',
features: ['Full family access', 'Redeemed during signup', 'No card required'],
cta: 'Enter your code'
},
{
id: 'monthly',
@@ -70,13 +70,17 @@
</p>
<p class="blurb">{tier.blurb}</p>
<form method="POST" action={action} use:enhance={onsubmit ?? undefined}>
<input type="hidden" name="plan" value={tier.id} />
{#if tier.id === 'trial'}
<input name="code" placeholder="Trial code" autocomplete="off" />
{/if}
<button type="submit" class="cta" class:primary={tier.featured}>{tier.cta}</button>
</form>
{#if tier.id === 'trial'}
<!-- No form: codes are redeemed inside the signup wizard (step 3).
data-sveltekit-reload: full document nav — immune to stale-router
click-swallowing. -->
<a href="/signup" data-sveltekit-reload class="cta">{tier.cta}</a>
{:else}
<form method="POST" action={action} use:enhance={onsubmit ?? undefined}>
<input type="hidden" name="plan" value={tier.id} />
<button type="submit" class="cta" class:primary={tier.featured}>{tier.cta}</button>
</form>
{/if}
<ul>
{#each tier.features as f}
@@ -158,12 +162,15 @@
outline-offset: -1px;
}
.cta {
display: block;
width: 100%;
padding: 0.65rem 1rem;
border-radius: 10px;
font-size: 0.92rem;
font-weight: 600;
cursor: pointer;
text-align: center;
text-decoration: none;
border: 1.5px solid #6366f1;
background: #fff;
color: #4338ca;
+38
View File
@@ -0,0 +1,38 @@
import { goto } from '$app/navigation';
/**
* Resolve-callback factory for `use:enhance`.
*
* Follows `result.type === 'redirect'` automatically (actions like login,
* billing portal, and logged-out pricing choose throw redirects), then hands
* everything else to the optional handler — falling back to the default
* `update()` when none is given.
*
* Usage:
* <form method="POST" use:enhance={handleResult(({ result }) => {...})}>
* <form method="POST" use:enhance={handleResult()}>
*/
export function handleResult(
handler?: (ctx: { result: any; update: () => Promise<void> }) => Promise<void>
) {
return async ({ result, update }: any) => {
if (result?.type === 'redirect') {
const target = String(result.location);
const isExternal = /^https?:\/\//i.test(target) && !target.startsWith(window.location.origin);
if (isExternal) {
window.location.assign(target);
} else {
await goto(target);
}
return;
}
if (result?.type === 'error') {
// Unhandled server exception — never let these vanish silently.
const { notices } = await import('$lib/stores/notices.svelte');
notices.error('Something went wrong', result.error?.message || 'Internal error.');
return;
}
if (handler) await handler({ result, update });
else await update();
};
}
+42 -6
View File
@@ -11,11 +11,6 @@ import {
// Dummy key placeholder — swap for a real test/live secret when the account is connected.
const KEY: string = String(STRIPE_SECRET_KEY) || 'sk_test_dummy_famchamp_not_connected';
// True until a real key is wired in .env. When dummy, we don't hit the Stripe
// API — checkout/billing actions return simulated session URLs so the flow can
// be established end-to-end before the account is connected.
export const isDummyStripe = !STRIPE_SECRET_KEY;
export const stripe = new Stripe(KEY);
// 3-tier plans: `trial` is not a paid Stripe price — it's app-side validated
@@ -93,6 +88,47 @@ export async function createCheckoutSession(opts: {
return stripe.checkout.sessions.create(params);
}
// Live subscription state for the settings UI (source of truth = Stripe).
export async function getSubscriptionStatus(
customerId: string
): Promise<{ active: boolean; cancelAtPeriodEnd: boolean; endsAt?: string }> {
const subs = await stripe.subscriptions.list({
customer: customerId,
status: 'all',
limit: 10
});
const active = subs.data.find((s) => ['active', 'trialing', 'past_due'].includes(s.status));
if (!active) return { active: false, cancelAtPeriodEnd: false };
const itemEnd = active.items.data[0]?.current_period_end;
return {
active: true,
cancelAtPeriodEnd: !!active.cancel_at_period_end,
endsAt: new Date((itemEnd ?? Math.floor(Date.now() / 1000)) * 1000).toISOString()
};
}
// In-app cancellation: flags the customer's active subscription to end at the
// close of the current paid period (no refund, access continues until then).
// The existing webhook flips paymentMode='canceled' when it actually ends.
export async function cancelSubscriptionAtPeriodEnd(
customerId: string
): Promise<{ ended: boolean; endsAt?: string }> {
const subs = await stripe.subscriptions.list({
customer: customerId,
status: 'all',
limit: 10
});
const active = subs.data.find((s) => ['active', 'trialing', 'past_due'].includes(s.status));
if (!active) return { ended: false };
if (!active.cancel_at_period_end) {
await stripe.subscriptions.update(active.id, { cancel_at_period_end: true });
}
// Newer Stripe API versions carry current_period_end on the sub item.
const itemEnd = active.items.data[0]?.current_period_end;
const periodEnd = itemEnd ?? Math.floor(Date.now() / 1000) + 30 * 86400;
return { ended: true, endsAt: new Date(periodEnd * 1000).toISOString() };
}
// Stripe Billing portal session for managing/cancelling the subscription.
export async function createBillingPortalSession(customerId: string, origin: string, famSlug = '') {
return stripe.billingPortal.sessions.create({
@@ -143,4 +179,4 @@ export async function createEmbeddedCheckoutSession(opts: {
const session = await stripe.checkout.sessions.create(params);
return { clientSecret: session.client_secret, sessionId: session.id };
}
}