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