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
+12 -29
View File
@@ -1,36 +1,19 @@
<script lang="ts">
let { items }: { items: { title: string; content: any }[] } = $props();
let openIndex = $state<number | null>(null);
import type { Snippet } from 'svelte';
// Styled wrapper — group AccordionItem children inside.
let { children, defaultOpen = false }: { children: Snippet; defaultOpen?: boolean } = $props();
</script>
<div class="accordion">
{#each items as item, i}
<div class="accordion-item" class:open={openIndex === i}>
<button class="accordion-trigger" onclick={() => openIndex = openIndex === i ? null : i}>
<span>{item.title}</span>
<span class="accordion-arrow">{openIndex === i ? '▾' : '▸'}</span>
</button>
{#if openIndex === i}
<div class="accordion-body">
{@render item.content()}
</div>
{/if}
</div>
{/each}
<div class="accordion" class:default-open={defaultOpen}>
{@render children()}
</div>
<style>
.accordion { border: 1px solid #e5e7eb; border-radius: 8px; overflow: hidden; }
.accordion-item { border-bottom: 1px solid #f3f4f6; }
.accordion-item:last-child { border-bottom: none; }
.accordion-trigger {
display: flex; justify-content: space-between; align-items: center;
width: 100%; padding: 0.7rem 1rem;
background: #fafafa; border: none;
font-size: 0.9rem; font-weight: 500; color: #374151;
cursor: pointer; text-align: left;
.accordion {
border: 1px solid #e5e7eb;
border-radius: 8px;
overflow: hidden;
background: #fff;
}
.accordion-trigger:hover { background: #f3f4f6; }
.accordion-arrow { font-size: 0.8rem; color: #9ca3af; }
.accordion-body { padding: 1rem; }
</style>
</style>
@@ -0,0 +1,36 @@
<script lang="ts">
import type { Snippet } from 'svelte';
let { title, open = $bindable(false), children }: { title: string; open?: boolean; children: Snippet } = $props();
</script>
<div class="accordion-item" class:open>
<button
class="accordion-trigger"
onclick={() => (open = !open)}
aria-expanded={open}
>
<span>{title}</span>
<span class="accordion-arrow">{open ? '▾' : '▸'}</span>
</button>
{#if open}
<div class="accordion-body">
{@render children()}
</div>
{/if}
</div>
<style>
.accordion-item { border-bottom: 1px solid #f3f4f6; }
.accordion-item:last-child { border-bottom: none; }
.accordion-trigger {
display: flex; justify-content: space-between; align-items: center;
width: 100%; padding: 0.7rem 1rem;
background: #fafafa; border: none;
font-size: 0.95rem; font-weight: 600; color: #374151;
cursor: pointer; text-align: left;
}
.accordion-trigger:hover { background: #f3f4f6; }
.accordion-arrow { font-size: 0.8rem; color: #9ca3af; }
.accordion-body { padding: 1rem; }
</style>
+5 -3
View File
@@ -4,14 +4,16 @@
title,
accent,
scrollX = false,
children
}: { cols?: 1 | 2 | 3 | 4 | 5 | 6; title?: string; accent?: string; scrollX?: boolean; children?: any } = $props();
children,
class: className,
selected = false
}: { cols?: 1 | 2 | 3 | 4 | 5 | 6; title?: string; accent?: string; scrollX?: boolean; children?: any; class?: string; selected?: boolean } = $props();
</script>
<div
class="card"
data-cols={cols}
style="--card-cols: {cols}; {accent ? `--card-accent: ${accent}` : ''}"
class="card {className || ''} {selected ? 'selected' : ''}"
class:has-accent={!!accent}
class:scroll-x={scrollX}
>
@@ -0,0 +1,112 @@
<script lang="ts">
import { notices, type NoticeType } from '$lib/stores/notices.svelte';
function icon(type: NoticeType) {
switch (type) {
case 'success': return '✅';
case 'warning': return '⚠️';
case 'error': return '❌';
default: return 'ℹ️';
}
}
</script>
{#if notices.list.length > 0}
<div class="notice-container" role="region" aria-label="Notifications">
{#each notices.list as notice (notice.id)}
<div class="notice notice-{notice.type}">
<div class="notice-content">
<div class="notice-icon">{icon(notice.type)}</div>
<div class="notice-text">
<h4>{notice.title}</h4>
{#if notice.message}
<p>{notice.message}</p>
{/if}
</div>
</div>
{#if notice.action}
<a href={notice.action.href} class="notice-action">{notice.action.label}</a>
{/if}
{#if notice.dismissible}
<button class="notice-dismiss" onclick={() => notices.remove(notice.id)} aria-label="Dismiss">✕</button>
{/if}
</div>
{/each}
</div>
{/if}
<style>
.notice-container {
position: fixed;
top: 5rem;
right: 1.5rem;
z-index: 1000;
display: flex;
flex-direction: column;
gap: 0.5rem;
max-width: 380px;
pointer-events: none;
}
.notice {
background: white;
border-radius: 10px;
padding: 1rem 1.25rem;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
display: flex;
align-items: flex-start;
gap: 0.75rem;
border-left: 4px solid #6366f1;
animation: slidein 0.25s ease;
pointer-events: auto;
}
.notice-success { border-left-color: #059669; }
.notice-warning { border-left-color: #d97706; }
.notice-error { border-left-color: #dc2626; }
.notice-content {
display: flex;
align-items: flex-start;
gap: 0.6rem;
flex: 1;
min-width: 0;
}
.notice-icon {
font-size: 1.1rem;
flex-shrink: 0;
}
.notice-text h4 {
margin: 0 0 0.25rem;
font-size: 0.9rem;
font-weight: 600;
color: #111827;
}
.notice-text p {
margin: 0;
font-size: 0.8rem;
color: #6b7280;
line-height: 1.4;
}
.notice-action {
font-size: 0.8rem;
color: #4338ca;
font-weight: 500;
text-decoration: none;
flex-shrink: 0;
align-self: center;
}
.notice-action:hover { text-decoration: underline; }
.notice-dismiss {
background: none;
border: none;
font-size: 1rem;
color: #9ca3af;
cursor: pointer;
padding: 0.2rem;
line-height: 1;
flex-shrink: 0;
}
.notice-dismiss:hover { color: #374151; }
@keyframes slidein {
from { opacity: 0; transform: translateX(20px); }
to { opacity: 1; transform: translateX(0); }
}
</style>
@@ -0,0 +1,152 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { Button, CardGrid, Card } from '$lib/components';
interface Tier {
id: 'trial' | 'monthly' | 'yearly';
name: string;
price: string;
period: string;
desc: string;
cta: string;
featured?: boolean;
}
const tiers: Tier[] = [
{
id: 'trial',
name: 'Trial',
price: 'Free',
period: 'for a set period',
desc: 'Try FamChore free for a period (e.g. 3 months) with a code.',
cta: 'Start trial'
},
{
id: 'monthly',
name: 'Monthly',
price: '£3',
period: '/month',
desc: 'Full access for one family. Cancel anytime.',
cta: 'Choose monthly',
featured: true
},
{
id: 'yearly',
name: 'Yearly',
price: '£30',
period: '/year',
desc: 'Two months free vs monthly. Best value for committed families.',
cta: 'Choose yearly'
}
];
let { action, hideTrial = false, selected = '', error = '', onsubmit }: {
action: string;
hideTrial?: boolean;
selected?: string;
error?: string;
onsubmit?: any;
} = $props();
</script>
<CardGrid>
{#each tiers.filter((t) => !(hideTrial && t.id === 'trial')) as tier}
<Card title={tier.name} accent={tier.featured ? '#6366f1' : undefined} selected={selected === tier.id}>
<p class="price">
<span class="amount">{tier.price}</span>
<span class="period">{tier.period}</span>
</p>
<p class="hint">{tier.desc}</p>
{#if tier.id === 'trial'}
<form
method="POST"
action={action}
use:enhance={onsubmit ?? undefined}
>
<input type="hidden" name="plan" value="trial" />
<label class="field-label" for="trial-code">Trial code</label>
<input
id="trial-code"
name="code"
placeholder="e.g. FAM3MONTHS"
autocomplete="off"
/>
<Button type="submit" size="md" variant="secondary">Start trial</Button>
</form>
{:else}
<form
method="POST"
action={action}
use:enhance={onsubmit ?? undefined}
>
<input type="hidden" name="plan" value={tier.id} />
<Button type="submit" size="md" variant={tier.featured ? 'primary' : 'secondary'}>
{tier.cta}
</Button>
</form>
{/if}
{#if error}
<p class="error">{error}</p>
{/if}
</Card>
{/each}
</CardGrid>
<style>
.hint {
font-size: 0.85rem;
color: #9ca3af;
line-height: 1.4;
}
.price {
margin: 0 0 0.5rem;
}
.amount {
font-size: 2rem;
font-weight: 700;
color: #1f2937;
}
.period {
color: #9ca3af;
font-size: 0.9rem;
margin-left: 0.25rem;
}
form {
display: flex;
flex-direction: column;
gap: 0.6rem;
margin-top: 0.75rem;
}
.field-label {
font-size: 0.8rem;
font-weight: 500;
color: #6b7280;
}
input {
padding: 0.55rem 0.75rem;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 0.9rem;
text-transform: uppercase;
}
input:focus {
outline: 2px solid #6366f1;
outline-offset: -1px;
}
form :global(.btn) {
width: 100%;
}
.error {
color: #dc2626;
background: #fef2f2;
padding: 0.5rem;
border-radius: 6px;
font-size: 0.85rem;
margin-top: 0.5rem;
}
:global(.selected) {
border: 2px solid #6366f1;
}
</style>
+1 -1
View File
@@ -15,7 +15,7 @@
let collapsed = $state(false);
let famSlug = $derived(page.params.fam);
let famSlug = $derived(page.data.famSlug ?? page.params.fam);
let memberName = $derived(session?.memberName || page.params.username || '');
function toggle() {
+3
View File
@@ -6,5 +6,8 @@ export { default as Card } from './Card.svelte';
export { default as CardGrid } from './CardGrid.svelte';
export { default as Button } from './Button.svelte';
export { default as Accordion } from './Accordion.svelte';
export { default as AccordionItem } from './AccordionItem.svelte';
export { default as Chat } from './Chat.svelte';
export { default as AuthShell } from './AuthShell.svelte';
export { default as NoticeDialog } from './NoticeDialog.svelte';
export { default as PricingPlans } from './PricingPlans.svelte';
+16
View File
@@ -32,3 +32,19 @@ export function formatHumanDate(dateStr: string | undefined): string {
const sameYear = d.getFullYear() === new Date().getFullYear();
return `${weekday} ${d.getDate()} ${mon}${sameYear ? '' : ' ' + String(d.getFullYear()).slice(2)}`;
}
// Add months to a date (UTC), handling month overflow correctly.
export function addMonthsUTC(date: Date, months: number): Date {
const d = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + months, date.getUTCDate()));
return d;
}
// Short human date: "5 Aug" / "5 Aug 26"
export function formatShortDate(dateStr: string | Date | undefined): string {
if (!dateStr) return '';
const d = dateStr instanceof Date ? dateStr : new Date(dateStr);
if (Number.isNaN(d.getTime())) return '';
const mon = d.toLocaleDateString('en-GB', { month: 'short' });
const sameYear = d.getFullYear() === new Date().getFullYear();
return `${d.getDate()} ${mon}${sameYear ? '' : ' ' + String(d.getFullYear()).slice(2)}`;
}
+101
View File
@@ -0,0 +1,101 @@
import { pbAdmin } from '$lib/server/pocketbase';
// How a family has access. 'none' = signed up with no code/sub yet (gated).
export type PaymentMode = 'none' | 'code' | 'sub' | 'canceled';
export interface FamAccess {
disabled: boolean;
mode: PaymentMode;
reason: '' | 'no_access' | 'canceled' | 'subscription_inactive' | 'code_disabled' | 'code_expired';
}
// UTC timestamp `months` months after `iso`. Used for the code's global expiry
// (from the code's createdAt) and the duration clock (from entry date).
export function addMonthsUTC(iso: string | Date, months: number): number {
const d = new Date(iso);
d.setUTCMonth(d.getUTCMonth() + months);
return d.getTime();
}
// A code is usable iff it exists, is not globally disabled, is not past its own
// createdAt+expiry window (expiry 0 = never), and the duration clock from the
// fam's entry date hasn't run out (duration 0 = continuous).
export function codeIsValid(code: any, enteredAt?: string): boolean {
if (!code) return false;
if (code.active === false) return false;
const now = Date.now();
const expiryMonths = Number(code.expiry) || 0;
if (expiryMonths > 0 && now >= addMonthsUTC(code.createdAt, expiryMonths)) return false;
const durationMonths = Number(code.duration) || 0;
if (durationMonths > 0 && enteredAt) {
if (now >= addMonthsUTC(enteredAt, durationMonths)) return false;
}
return true;
}
// Deterministic decision from a fam + its linked code (no I/O). `active` on the
// fam is the single source of truth for "usable right now".
export function computeFamAccess(fam: any, code: any): FamAccess {
const mode: PaymentMode = fam.paymentMode || 'none';
switch (mode) {
case 'code': {
if (code?.active === false) return { disabled: true, mode, reason: 'code_disabled' };
return codeIsValid(code, fam.accessCodeEnteredAt)
? { disabled: false, mode, reason: '' }
: { disabled: true, mode, reason: 'code_expired' };
}
case 'sub':
return fam.active === false
? { disabled: true, mode, reason: 'subscription_inactive' }
: { disabled: false, mode, reason: '' };
case 'canceled':
return { disabled: true, mode, reason: 'canceled' };
case 'none':
default:
return { disabled: true, mode, reason: 'no_access' };
}
}
// Read the fam + linked code and persist `active` if it drifted. Called on every
// [fam] layout load (both roles) and wherever access state must be re-evaluated.
export async function ensureFamAccess(famId: string): Promise<{ fam: any; access: FamAccess }> {
const fam = await pbAdmin.getOne('fams', famId);
if (!fam) return { fam: null, access: { disabled: true, mode: 'none', reason: 'no_access' } };
let code: any = null;
if (fam.accessCodeId) {
try {
code = await pbAdmin.getOne('accesscodes', fam.accessCodeId);
} catch {
code = null;
}
}
const access = computeFamAccess(fam, code);
if (fam.active !== !access.disabled) {
await pbAdmin.update('fams', famId, { active: !access.disabled });
}
return { fam: { ...fam, active: !access.disabled }, access };
}
// Apply an access code value to a fam. Automatic (no approval) — validates
// against the accesscodes collection and sets paymentMode='code' on success.
export async function applyAccessCode(famId: string, value: string) {
const val = String(value || '').trim();
if (!val) return { error: 'Enter an access code' };
const list = await pbAdmin.getList('accesscodes', `value = '${val}'`);
const code = list?.[0];
if (!code) return { error: 'That access code is not recognised' };
if (code.active === false) return { error: 'That access code is disabled' };
const now = new Date().toISOString();
const valid = codeIsValid(code, now);
await pbAdmin.update('fams', famId, {
paymentMode: 'code',
accessCodeId: code.id,
accessCodeEnteredAt: now,
active: valid
});
return {
ok: true,
active: valid,
code: { name: code.name, value: code.value, duration: code.duration, expiry: code.expiry }
};
}
+2 -1
View File
@@ -1,4 +1,5 @@
import { redirect } from '@sveltejs/kit';
import { clearLegacyCookies } from '$lib/server/session';
import type { RequestEvent } from '@sveltejs/kit';
import { pbAdmin } from '$lib/server/pocketbase';
@@ -17,7 +18,7 @@ export function requireAuth(event: RequestEvent) {
export function clearSession(event: RequestEvent) {
event.cookies.delete('session', { path: '/' });
event.cookies.delete('pb_token', { path: '/' });
event.cookies.delete('device_token', { path: '/' });
clearLegacyCookies(event.cookies);
}
// Resolve the fam slug + admin display name used for the post-login redirect.
+146
View File
@@ -154,6 +154,122 @@ async function ensureOtp(ids: Record<string, string>): Promise<void> {
});
}
// Platform access codes — the codes that enable access to the platform. They're
// global (not fam-scoped) and managed via the platform admin page (superuser
// only), so all rules are null like `otp`. A code grants a family a subscription
// for `duration` months (0 = continuous); `expiry` is months-after-createdAt
// (0 = never expires); `active` is a failsafe toggle. Entered at create-family
// and in admin settings.
async function ensureAccessCodes(): Promise<void> {
if (await getCollection("accesscodes")) {
await ensureAccessCodeFields();
await seedAccessCodes();
return;
}
await createCollection({
name: "accesscodes",
type: "base",
listRule: null,
viewRule: null,
createRule: null,
updateRule: null,
deleteRule: null,
fields: [
{ name: "value", type: "text", required: true, unique: true },
{ name: "name", type: "text", required: true },
{ name: "duration", type: "number", required: false },
{ name: "expiry", type: "number", required: false },
{ name: "active", type: "bool", required: false },
// When set (>0) this code is a TRIAL code: maps to Stripe
// trial_period_days at checkout instead of platform access.
{ name: "trialDays", type: "number", required: false },
{ name: "createdAt", type: "date", required: false },
],
});
await seedAccessCodes();
}
// Idempotent field-add for installs where accesscodes predates a field.
async function ensureAccessCodeFields(): Promise<void> {
const col = await getCollection("accesscodes");
if (!col) return;
const has = (n: string) => col.fields.some((f: any) => f.name === n);
if (!has("trialDays")) {
await updateCollection(col.id, {
...col,
fields: [...col.fields, { name: "trialDays", type: "number", required: false }],
});
}
}
// Idempotent seeds — developer code + an example trial code.
async function seedAccessCodes(): Promise<void> {
const t = await auth();
const seeds = [
{ value: "dev123", name: "developer", duration: 0, expiry: 0, active: true, trialDays: null },
{ value: "FAM3MONTHS", name: "3-month trial", duration: null, expiry: null, active: true, trialDays: 90 },
];
for (const seed of seeds) {
const res = await fetch(
`${PB_ENDPOINT}/api/collections/accesscodes/records?filter=value='${seed.value}'`,
{ headers: { Authorization: `Bearer ${t}` } },
);
const data = await res.json();
if (data?.items?.length) continue;
const created = await fetch(`${PB_ENDPOINT}/api/collections/accesscodes/records`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${t}` },
body: JSON.stringify({ ...seed, createdAt: new Date().toISOString() }),
});
const c = await created.json();
if (!created.ok) throw new Error(`Seed accesscode failed: ${JSON.stringify(c)}`);
console.log(` ✓ Seeded access code: ${seed.name} (${seed.value})`);
}
}
// Platform settings — a single global record holding the platform feature
// flags (replaces the per-fam fams.featureFlags). Publicly readable (empty
// list/view rules) so every client can deduce flags on app load; writes stay
// superuser-only (null rules), so like otp/accesscodes this lives in
// migrate.ts rather than SCHEMA_PLAN.
async function ensurePlatform(): Promise<void> {
if (!(await getCollection("platform"))) {
await createCollection({
name: "platform",
type: "base",
listRule: "",
viewRule: "",
createRule: null,
updateRule: null,
deleteRule: null,
fields: [
{ name: "label", type: "text", required: true },
{ name: "flags", type: "json", required: false },
],
});
}
await seedPlatform();
}
// Idempotent seed — create the singleton 'global' settings record if missing.
async function seedPlatform(): Promise<void> {
const t = await auth();
const res = await fetch(
`${PB_ENDPOINT}/api/collections/platform/records?filter=label='global'`,
{ headers: { Authorization: `Bearer ${t}` } },
);
const data = await res.json();
if (data?.items?.length) return;
const created = await fetch(`${PB_ENDPOINT}/api/collections/platform/records`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${t}` },
body: JSON.stringify({ label: "global", flags: { debug: false } }),
});
const c = await created.json();
if (!created.ok) throw new Error(`Seed platform failed: ${JSON.stringify(c)}`);
console.log(" ✓ Seeded platform settings (global)");
}
// Bootstrap the full schema on a fresh/wiped PocketBase. Idempotent — skips if
// `fams` already exists (data is disposable; there is no incremental migration
// history).
@@ -177,8 +293,38 @@ async function ensureSchema(): Promise<void> {
console.log("[migrate] Schema bootstrapped.");
}
// Add the access-gating fields to `fams` on installs where it already exists
// (fresh installs get them via SCHEMA_PLAN). Idempotent — only adds missing
// fields.
async function ensureFamFields(): Promise<void> {
const famsCol = await getCollection("fams");
if (!famsCol) return;
const has = (n: string) => famsCol.fields.some((f: any) => f.name === n);
const needed: any[] = [];
if (!has("active")) {
needed.push({ name: "active", type: "bool", required: false });
}
if (!has("paymentMode")) {
needed.push({ name: "paymentMode", type: "select", required: false, values: ["none", "code", "sub", "canceled"], maxSelect: 1 });
}
if (!has("accessCodeId")) {
needed.push({ name: "accessCodeId", type: "text", required: false });
}
if (!has("accessCodeEnteredAt")) {
needed.push({ name: "accessCodeEnteredAt", type: "date", required: false });
}
if (needed.length) {
await updateCollection(famsCol.id, { ...famsCol, fields: [...famsCol.fields, ...needed] });
}
}
export async function migrate(): Promise<void> {
console.log("[migrate] Checking PB collection schemas...");
await ensureSchema();
// Runs even when the schema already exists (unlike ensureSchema's early
// return) so new platform collections/fields/seed land on existing installs.
await ensureFamFields();
await ensureAccessCodes();
await ensurePlatform();
console.log("[migrate] Done");
}
+36
View File
@@ -0,0 +1,36 @@
import { pbAdmin } from '$lib/server/pocketbase';
// Platform-level feature flags, stored on the singleton `platform` record
// (label='global'). Replaces the deprecated per-fam fams.featureFlags.
export type PlatformFlags = Record<string, boolean>;
let cache: { flags: PlatformFlags; at: number } | null = null;
const TTL_MS = 10_000;
async function findGlobal(): Promise<any | null> {
const recs = (await pbAdmin.getList('platform', `label = 'global'`)) as any[];
return recs[0] || null;
}
// Public read used by loads. Cached briefly so per-request layout loads don't
// hammer PB; flag changes propagate within the TTL.
export async function getPlatformFlags(): Promise<PlatformFlags> {
if (cache && Date.now() - cache.at < TTL_MS) return cache.flags;
try {
const rec = await findGlobal();
cache = { flags: rec?.flags || {}, at: Date.now() };
} catch {
if (!cache) cache = { flags: {}, at: Date.now() };
}
return cache.flags;
}
// Superuser write (platform admin dashboard / server-side only).
export async function setPlatformFlag(key: string, value: boolean): Promise<PlatformFlags> {
const rec = await findGlobal();
if (!rec) throw new Error('platform settings record missing');
const flags: PlatformFlags = { ...(rec.flags || {}), [key]: value };
await pbAdmin.update('platform', rec.id, { flags });
cache = { flags, at: Date.now() };
return flags;
}
+8
View File
@@ -21,4 +21,12 @@ export function setSessionCookie(cookies: Cookies, token: string) {
export function clearSessionCookie(cookies: Cookies) {
cookies.delete(SESSION_COOKIE, { path: '/' });
}
// Legacy pre-PB-auth child cookie. No longer issued anywhere; these deletes
// exist only to scrub it from browsers that still carry one.
const LEGACY_DEVICE_COOKIE = 'device_token';
export function clearLegacyCookies(cookies: Cookies) {
cookies.delete(LEGACY_DEVICE_COOKIE, { path: '/' });
}
+14 -6
View File
@@ -1,9 +1,8 @@
import { pbAdmin } from '$lib/server/pocketbase';
import type Stripe from 'stripe';
// Shared Stripe event handling. Both the real webhook (/account/webhook) and
// the dev-only simulator (/account/webhook/simulate) route through here so the
// DB effects are identical.
// Shared Stripe event handling. The real webhook (/api/webhooks/stripe) routes
// through here so the DB effects are identical.
export async function handleStripeEvent(event: Stripe.Event): Promise<void> {
switch (event.type) {
case 'checkout.session.completed': {
@@ -12,19 +11,28 @@ export async function handleStripeEvent(event: Stripe.Event): Promise<void> {
if (famId && session.customer) {
await pbAdmin.update('fams', famId, {
stripeCustomerId: String(session.customer),
active: true
active: true,
paymentMode: 'sub'
});
}
break;
}
case 'customer.subscription.created':
case 'customer.subscription.updated':
case 'customer.subscription.deleted':
case 'customer.subscription.paused': {
const sub = event.data.object as Stripe.Subscription;
await setActiveFromSubscription(sub);
break;
}
case 'customer.subscription.deleted': {
const sub = event.data.object as Stripe.Subscription;
const fams = await pbAdmin.getList('fams', `stripeCustomerId = '${sub.customer}'`);
const fam = fams[0];
if (fam) {
await pbAdmin.update('fams', fam.id, { active: false, paymentMode: 'canceled' });
}
break;
}
}
}
@@ -37,5 +45,5 @@ async function setActiveFromSubscription(sub: Stripe.Subscription) {
// Active only while the sub is trialing/active (not past_due/canceled/paused).
const active =
sub.status === 'trialing' || sub.status === 'active' || sub.status === 'past_due';
await pbAdmin.update('fams', fam.id, { active });
await pbAdmin.update('fams', fam.id, { active, paymentMode: 'sub' });
}
+16 -14
View File
@@ -28,17 +28,18 @@ export const PLAN_IDS: Record<'trial' | 'monthly' | 'yearly', string> = {
yearly: String(STRIPE_PRICE_YEARLY) || 'price_dummy_yearly'
};
// Trial codes (app-side). In practice these should live in a PB collection;
// for the dummy flow a static map is enough. Maps code -> trial days.
export const TRIAL_CODES: Record<string, number> = {
FAM3MONTHS: 90,
FAMTRIAL: 30
};
export function resolveTrialDays(code?: string): number | null {
// Trial codes live in PB (`accesscodes` with trialDays > 0, active) so the
// platform admin can manage them. A matching active code maps to Stripe
// trial_period_days at checkout; anything else is not a trial code.
export async function resolveTrialDays(code?: string): Promise<number | null> {
if (!code) return null;
const days = TRIAL_CODES[code.trim().toUpperCase()];
return typeof days === 'number' ? days : null;
const { pbAdmin } = await import('$lib/server/pocketbase');
const recs = (await pbAdmin.getList(
'accesscodes',
`value = '${code.trim().toUpperCase()}' && active = true`
)) as any[];
const days = Number(recs?.[0]?.trialDays) || 0;
return days > 0 ? days : null;
}
export function verifyStripeEvent(rawBody: string, signature: string): Stripe.Event {
@@ -68,7 +69,7 @@ export async function createCheckoutSession(opts: {
mode: 'subscription',
metadata: { famId: opts.famId, plan: opts.plan },
success_url: `${opts.origin}/account?checkout=success`,
cancel_url: `${opts.origin}/subscriptions?checkout=cancelled`
cancel_url: `${opts.origin}/pricing?checkout=cancelled`
};
// Attach customer if we already have a Stripe customer id for this fam.
@@ -93,10 +94,10 @@ export async function createCheckoutSession(opts: {
}
// Stripe Billing portal session for managing/cancelling the subscription.
export async function createBillingPortalSession(customerId: string, origin: string) {
export async function createBillingPortalSession(customerId: string, origin: string, famSlug = '') {
return stripe.billingPortal.sessions.create({
customer: customerId,
return_url: `${origin}/account`
return_url: `${origin}/${famSlug}?checkout=return`
});
}
@@ -108,6 +109,7 @@ export async function createEmbeddedCheckoutSession(opts: {
plan: PlanId | 'price' | 'trial';
priceId?: string;
famId: string;
famSlug: string;
email?: string | null;
customerId?: string | null;
trialDays?: number | null;
@@ -124,7 +126,7 @@ export async function createEmbeddedCheckoutSession(opts: {
mode: 'subscription',
ui_mode: 'embedded_page',
metadata: { famId: opts.famId, plan: opts.plan },
return_url: `${opts.origin}/account?checkout=return`
return_url: `${opts.origin}/${opts.famSlug}?checkout=return`
};
if (opts.customerId) {
+54
View File
@@ -0,0 +1,54 @@
// App-wide notices — Svelte 5 rune store (.svelte.ts). Rendered globally by
// <NoticeDialog /> in the root layout.
export type NoticeType = 'info' | 'success' | 'warning' | 'error';
export interface Notice {
id: string;
type: NoticeType;
title: string;
message?: string;
action?: { label: string; href: string };
dismissible: boolean;
}
let idCounter = 0;
class NoticeStore {
list = $state<Notice[]>([]);
add(notice: Omit<Notice, 'id'>): string {
const id = `notice-${Date.now()}-${idCounter++}`;
this.list.push({ ...notice, id });
return id;
}
remove(id: string) {
this.list = this.list.filter((n) => n.id !== id);
}
clear() {
this.list = [];
}
success(title: string, message?: string, action?: Notice['action']) {
return this.add({ type: 'success', title, message, action, dismissible: true });
}
info(title: string, message?: string, action?: Notice['action']) {
return this.add({ type: 'info', title, message, action, dismissible: true });
}
warning(title: string, message?: string, action?: Notice['action']) {
return this.add({ type: 'warning', title, message, action, dismissible: true });
}
error(title: string, message?: string, action?: Notice['action']) {
return this.add({ type: 'error', title, message, action, dismissible: true });
}
}
export const notices = new NoticeStore();
// Fire-and-forget helper — auto-dismisses after `duration` ms.
export function addAutoDismissNotice(notice: Omit<Notice, 'id'>, duration = 5000): string {
const id = notices.add({ ...notice, dismissible: true });
setTimeout(() => notices.remove(id), duration);
return id;
}
-1
View File
@@ -41,7 +41,6 @@ export interface Fam {
name: string;
slug: string;
stripeCustomerId?: string;
featureFlags: Record<string, boolean>;
payday?: number;
paydayTime?: string;
timezone?: string;
+8
View File
@@ -0,0 +1,8 @@
import type { LayoutServerLoad } from './$types';
import { getPlatformFlags } from '$lib/server/platform';
// Platform settings are public (read-only): feature flags ride along with
// every page's data so any component can deduce them via page.data.platformFlags.
export const load: LayoutServerLoad = async () => {
return { platformFlags: await getPlatformFlags() };
};
+2
View File
@@ -1,9 +1,11 @@
<script lang="ts">
import './layout.css';
import favicon from '$lib/assets/favicon.svg';
import NoticeDialog from '$lib/components/NoticeDialog.svelte';
let { children } = $props();
</script>
<svelte:head><link rel="icon" href={favicon} /></svelte:head>
{@render children()}
<NoticeDialog />
+16 -6
View File
@@ -1,5 +1,6 @@
import { pbAdmin, createPbClient } from '$lib/server/pocketbase';
import { createPbClient } from '$lib/server/pocketbase';
import { createServices, type ChatActor } from '$lib/server/services';
import { ensureFamAccess } from '$lib/server/access';
async function paydayCheck(famId: string, pbToken: string) {
try {
@@ -48,19 +49,30 @@ export async function load(event) {
let famId = '';
let chat: { famId: string; actor: ChatActor } | null = null;
let fam: any = null;
let famAccess = { disabled: false, mode: 'none' as 'none' | 'code' | 'sub' | 'canceled', reason: '' };
if (session && pbToken) {
famId = session.famId;
await paydayCheck(famId, pbToken);
chat = await resolveChatIdentity(session, pbToken);
// fams is superadmin-only (non-realtime). Fetched server-side for both
// roles; also recomputes + persists the derived `active` flag.
const res = await ensureFamAccess(famId).catch(() => null);
if (res) {
fam = res.fam;
famAccess = res.access;
}
}
return {
// Canonical fam slug — from the URL param ([fam] routes). Client code
// reads page.data.famSlug; never copy it into local $state.
famSlug: event.params.fam || '',
session: session
? {
famId: session.famId,
userId: session.id,
famSlug: event.params.fam,
memberName: session.name,
memberColor: session.color || '',
role: session.role
@@ -71,9 +83,7 @@ export async function load(event) {
famId,
chat,
pbToken,
// fams is superadmin-only (non-realtime). Fetched server-side for both roles.
fam: famId
? await pbAdmin.getOne('fams', famId).catch(() => null)
: null
fam,
famAccess
};
}
+160 -4
View File
@@ -1,9 +1,11 @@
<script lang="ts">
import { page } from '$app/state';
import { onMount } from 'svelte';
import { initRealtimePb } from '$lib/pocketbase';
import { invalidateAll } from '$app/navigation';
import { onDestroy, onMount } from 'svelte';
import { initRealtimePb, pb } from '$lib/pocketbase';
import { famStore } from '$lib/stores/fam.svelte';
import { chatStore } from '$lib/stores/chat.svelte';
import { notices } from '$lib/stores/notices.svelte';
import { Sidebar, TopNav, Footer, Chat } from '$lib/components';
import { chatIcon } from '$lib/components/icons';
import type { Session } from '$lib/types';
@@ -15,6 +17,82 @@
let famName = $derived(
famStore.initialized ? (famStore.fam as any)?.name || page.params.fam : page.params.fam
);
let disabled = $derived(!!data.famAccess?.disabled);
let accessReason = $derived(data.famAccess?.reason || '');
// Settings stays usable while paused so admins can apply a code / manage billing.
let locked = $derived(disabled && !page.url.pathname.endsWith('/settings'));
// ── Post-checkout activation (event-driven) ──
// Landing with ?checkout=return: if the webhook has already landed we show
// the welcome notice; otherwise show an "activating" state and wait for the
// EVENT — `fams` has viewRule id=@request.auth.famId, so the fam can
// realtime-subscribe to its own record. When the Stripe webhook (superuser)
// writes paymentMode/active, PB pushes over SSE → single revalidation.
let activating = $state(false);
let returnHandled = false;
let unsubFam: (() => void) | null = null;
const ACTIVATION_TIMEOUT_MS = 20_000;
function handleCheckoutReturn() {
if (returnHandled || page.url.searchParams.get('checkout') !== 'return') return;
returnHandled = true;
history.replaceState({}, '', page.url.pathname); // cosmetic only
if (!disabled) {
notices.success('Welcome to FamChore!', 'Your subscription is active.');
return;
}
startActivating();
}
async function startActivating() {
activating = true;
initRealtimePb(data.pbToken || '');
try {
unsubFam = await pb.collection('fams').subscribe(data.famId, () => {
invalidateAll().catch(() => {});
});
} catch {
/* SSE unavailable — the timeout below still degrades gracefully */
}
// Safety net only: the event should land within seconds of payment.
setTimeout(() => {
if (!activating) return;
stopActivating();
notices.warning(
'Still activating',
'Payment received — unlocking usually takes a few seconds. Refresh if this persists.'
);
}, ACTIVATION_TIMEOUT_MS);
}
function stopActivating() {
activating = false;
unsubFam?.();
unsubFam = null;
}
// Flip to success the moment the gate lifts (SSE → invalidateAll → data).
$effect(() => {
if (activating && !disabled) {
stopActivating();
notices.success('Subscription active!', 'Your family is unlocked.');
}
});
onDestroy(stopActivating);
handleCheckoutReturn();
function accessMessage(reason: string, parent: boolean) {
const map: Record<string, string> = {
code_expired: parent ? 'Access paused — your code has expired. Add a new one.' : 'Access paused.',
code_disabled: parent ? 'Access paused — your access code was disabled.' : 'Access paused.',
subscription_inactive: parent ? 'Access paused — check your subscription payment.' : 'Access paused.',
canceled: parent ? 'Access paused — renew your subscription or add a code.' : 'Access paused.',
no_access: parent ? 'Access paused — add an access code to get started.' : 'Access paused.'
};
return map[reason] || 'Access paused.';
}
// Claim toast watcher (admin only)
let claimToast = $state('');
@@ -64,7 +142,11 @@
<div class="layout-stage" class:chat-open={chatStore.open}>
<div class="app-shell">
<Sidebar {famName} session={data.session} {isParent} {role} />
<TopNav {role} seasons={famStore.seasons}>
<TopNav
{role}
seasons={famStore.seasons}
announcement={disabled && !activating ? accessMessage(accessReason, isParent) : ''}
>
<button class="chat-toggle" onclick={() => chatStore.toggle()} aria-label="Open chat">
{@html chatIcon}
{#if chatStore.unread > 0}
@@ -73,7 +155,27 @@
</button>
</TopNav>
<main class="app-main">
{@render children()}
<div class="page-wrap">
<div class="page-content" class:locked>{@render children()}</div>
{#if locked}
<div class="disabled-overlay">
<div class="disabled-card">
{#if activating}
<div class="spinner" aria-hidden="true"></div>
<strong>Activating your subscription…</strong>
<span>Payment received — this usually only takes a few seconds.</span>
{:else}
<strong>Access paused</strong>
<span>
{isParent
? 'Add an access code or resume your subscription to keep using FamChore.'
: 'Your family access is paused.'}
</span>
{/if}
</div>
</div>
{/if}
</div>
</main>
{#if claimToast}
<div class="claim-toast">{claimToast}</div>
@@ -136,6 +238,60 @@
flex: 1;
transition: margin-left 0.2s;
}
.page-wrap {
position: relative;
min-height: 70vh;
}
.page-content.locked {
filter: blur(3px);
pointer-events: none;
user-select: none;
}
.disabled-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
z-index: 5;
}
.disabled-card {
background: #fff;
border: 1px solid #fca5a5;
border-radius: 12px;
padding: 1.25rem 1.75rem;
text-align: center;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.disabled-card strong {
color: #b91c1c;
font-size: 1rem;
}
.disabled-card .spinner {
width: 28px;
height: 28px;
margin: 0 auto;
border: 3px solid #e0e7ff;
border-top-color: #6366f1;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.disabled-card:has(.spinner) strong {
color: #4338ca;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.disabled-card span {
color: #6b7280;
font-size: 0.9rem;
}
.chat-toggle {
position: relative;
width: 40px;
+1 -1
View File
@@ -9,7 +9,7 @@
Chart.register(...registerables);
let { data } = $props();
let famSlug = $derived(page.params.fam);
let famSlug = $derived(page.data.famSlug ?? page.params.fam);
let summary = $state(data.summary);
let members = $state(data.members || []);
@@ -23,9 +23,13 @@
let { data } = $props();
let role = $state(data.role || 'child');
let famSlug = $derived(page.params.fam);
let famSlug = $derived(page.data.famSlug ?? page.params.fam);
let username = $derived(page.params.username);
// Access gate (frontend-only). When the fam is paused/disabled the member
// kanban interactions are locked — toggles no-op and the board renders empty.
let accessDisabled = $derived(!!page.data.famAccess?.disabled);
// ─── Family timezone (resolved) ───
const rawFamTz = $derived(data.timezone || data.fam?.timezone || 'auto');
const famTz = $derived(resolveTz(rawFamTz));
@@ -521,6 +525,7 @@
}
async function toggle(chore: AssignedChore) {
if (accessDisabled) return;
if (togglingIds) return;
togglingIds = chore.id;
@@ -1122,6 +1127,22 @@ const res = await fetch('/api/members', {
{/if}
<!-- KANBAN -->
{#if accessDisabled}
<div class="kanban locked">
<div class="column col-daily">
<h2>🎯 Daily</h2>
<p class="empty">🔒 Access paused</p>
</div>
<div class="column col-weekly">
<h2>📅 Weekly</h2>
<p class="empty">🔒</p>
</div>
<div class="column col-done">
<h2>✅ Done</h2>
<p class="empty">🔒</p>
</div>
</div>
{:else}
<div class="kanban">
<div class="column col-daily">
<h2>🎯 Daily ({dailyPending.length})</h2>
@@ -1205,6 +1226,7 @@ const res = await fetch('/api/members', {
{/if}
</div>
</div>
{/if}
<!-- WALLET / CLAIMS -->
<div class="wallet">
@@ -86,7 +86,6 @@
return map;
});
const famId = $derived(page.params.fam);
const username = $derived(page.params.username);
function memberName(id: string): string {
@@ -1,10 +1,12 @@
import { redirect } from '@sveltejs/kit';
import { redirect, fail } from '@sveltejs/kit';
import type { RequestEvent } from '@sveltejs/kit';
import { pbUser } from '$lib/server/pocketbase';
import { pbAdmin } from '$lib/server/pocketbase';
import { servicesFor } from '$lib/server/servicesFor';
import { issueAccess, createChild } from '$lib/server/member-otp';
import { slugify } from '@shared/slugify';
import { applyAccessCode } from '$lib/server/access';
import { createBillingPortalSession, isDummyStripe } from '$lib/server/stripe';
function famIdOf(event: RequestEvent): string {
if (!event.locals.user) throw redirect(303, '/login');
@@ -21,7 +23,12 @@ export async function load(event: RequestEvent) {
pbAdmin.getOne('fams', famId),
pb.collection('seasons').getFullList({ filter: `famId = '${famId}'` })
]);
return { members, fam, seasons };
// The applied code's duration backs the Access-card countdown (0 = never).
let accessCode: any = null;
if (fam?.paymentMode === 'code' && fam?.accessCodeId) {
accessCode = await pbAdmin.getOne('accesscodes', fam.accessCodeId).catch(() => null);
}
return { members, fam, seasons, accessCode };
}
export const actions = {
@@ -147,5 +154,44 @@ export const actions = {
} catch (e) {
return { error: e instanceof Error ? e.message : 'Failed to generate data' };
}
},
applyCode: async (event: RequestEvent) => {
const famId = famIdOf(event);
const fd = await event.request.formData();
const code = (fd.get('code') as string) || '';
const result = await applyAccessCode(famId, code);
return result.error ? { error: result.error } : { ok: true, ...result };
},
// Revoke the applied access code — sets paymentMode back to 'none'.
// No debug flag gate; the button is only visible when a code is applied.
revokeCode: async (event: RequestEvent) => {
const famId = famIdOf(event);
await pbAdmin.update('fams', famId, {
paymentMode: 'none',
accessCodeId: '',
accessCodeEnteredAt: ''
});
return { ok: true, revoked: true };
},
// Open Stripe Customer Portal (cancel subscription, update payment method,
// invoices). Real mode redirects; dummy mode returns the URL for the client.
billingPortal: async (event: RequestEvent) => {
const famId = famIdOf(event);
const fam = await pbAdmin.getOne('fams', famId);
if (!fam.stripeCustomerId) {
return fail(400, { error: 'No Stripe customer linked yet. Start with a plan first.' });
}
try {
const session = await createBillingPortalSession(fam.stripeCustomerId, event.url.origin, event.params.fam as string);
if (isDummyStripe) return { success: true, portalUrl: session.url };
if (!session.url) return fail(500, { error: 'Billing portal session has no URL' });
throw redirect(303, session.url);
} catch (e) {
if (e instanceof redirect) throw e;
return fail(500, { error: e instanceof Error ? e.message : 'Failed to open billing portal' });
}
}
};
@@ -2,18 +2,34 @@
import { page } from '$app/state';
import { enhance } from '$app/forms';
import { famStore } from '$lib/stores/fam.svelte';
import { ViewHeader, CardGrid, Card, Button } from '$lib/components';
import { ViewHeader, CardGrid, Card, Button, Accordion, AccordionItem, NoticeDialog } from '$lib/components';
import { COMMON_TIMEZONES } from '@shared/timezone';
import { handleOf } from '@shared/slugify';
import { addMonthsUTC, formatShortDate } from '$lib/format';
import QRCode from 'qrcode';
let { data } = $props();
// fam is sensitive (stripeCustomerId, featureFlags) — never in the
// fam is sensitive (stripeCustomerId, access fields) — never in the
// public famStore stream. It is superadmin-only, fetched server-side by the
// layout load. Writes go through form actions; no live fam subscription.
let fam = $state(data.fam);
let famSlug = $state(page.params.fam);
// Derived so apply/revoke reflect immediately after the action round trip.
let fam = $derived(data.fam);
// Canonical slug from layout load — reactive, never copied into $state.
let famSlug = $derived(page.data.famSlug || page.params.fam);
let accessCodeInput = $state('');
let accessMsg = $state('');
let hasCode = $derived(fam?.paymentMode === 'code' && !!fam?.accessCodeId);
let codeEntryDate = $derived(fam?.accessCodeEnteredAt ? new Date(fam.accessCodeEnteredAt) : null);
const modeLabel: Record<string, string> = {
code: 'Access code',
sub: 'Subscription',
canceled: 'Canceled',
none: 'No plan yet'
};
let addName = $state('');
let rename = $state('');
@@ -21,23 +37,9 @@
let payday = $state(fam?.payday != null ? Number(fam.payday) : 1);
let paydayTime = $state(fam?.paydayTime || '18:00');
let paydayTimes = $state([
'06:00',
'07:00',
'08:00',
'09:00',
'10:00',
'11:00',
'12:00',
'13:00',
'14:00',
'15:00',
'16:00',
'17:00',
'18:00',
'19:00',
'20:00',
'21:00',
'22:00'
'06:00', '07:00', '08:00', '09:00', '10:00', '11:00', '12:00',
'13:00', '14:00', '15:00', '16:00', '17:00', '18:00', '19:00',
'20:00', '21:00', '22:00'
]);
let timezone = $state(fam?.timezone || 'auto');
let timezoneOptions = $state(COMMON_TIMEZONES);
@@ -46,9 +48,7 @@
if (typeof Intl !== 'undefined') {
try {
detectedTz = Intl.DateTimeFormat().resolvedOptions().timeZone || '';
} catch {
detectedTz = '';
}
} catch { detectedTz = ''; }
}
});
let showQR = $state(false);
@@ -60,12 +60,8 @@
let deletingSeason = $state<any>(null);
let issued = $state<{ otp: string; joinUrl: string; name: string } | null>(null);
let invitePath = $derived(
issued ? `${issued.joinUrl}?code=${issued.otp}` : ''
);
let inviteUrl = $derived(
issued ? `${page.url.origin}${invitePath}` : ''
);
let invitePath = $derived(issued ? `${issued.joinUrl}?code=${issued.otp}` : '');
let inviteUrl = $derived(issued ? `${page.url.origin}${invitePath}` : '');
function copy(url: string) {
navigator.clipboard.writeText(url);
@@ -79,304 +75,368 @@
function toggleQR() {
showQR = !showQR;
if (!showQR) {
qrDataUrl = '';
} else {
generateQR(inviteUrl);
}
if (!showQR) qrDataUrl = '';
else generateQR(inviteUrl);
}
function handleParentInvite() {
alert('Parent invite coming soon — email would be sent to ' + parentInviteEmail);
}
function formatCountdown(entryDate: Date | null, durationMonths: number): string {
if (!entryDate) return '';
if (durationMonths === 0) return 'Never expires';
const expiry = addMonthsUTC(entryDate, durationMonths);
const now = new Date();
const diffMs = expiry.getTime() - now.getTime();
if (diffMs <= 0) return 'Expired';
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays < 30) return `${diffDays} day${diffDays === 1 ? '' : 's'} left`;
const months = Math.floor(diffDays / 30);
const remDays = diffDays % 30;
return `${months} month${months === 1 ? '' : 's'}${remDays ? ` ${remDays} day${remDays === 1 ? '' : 's'}` : ''} left`;
}
</script>
<ViewHeader title="Settings" hero />
<CardGrid>
<Card title="Family Name">
<p class="hint">
This is the name shown to your family. The address stays at
<code class="slug-inline">/{famSlug}</code> even if you rename it — links you've shared keep
working.
</p>
<form method="POST" action="?/renameFam" use:enhance>
<label class="field-label" for="fam-name">Display name</label>
<input id="fam-name" name="name" bind:value={rename} placeholder={fam?.name || 'Family name'} required />
<Button type="submit" size="sm">Rename</Button>
</form>
{#if fam?.slug}
<p class="hint slug-line">
Family page: <code class="slug-inline">/{fam.slug}</code>
</p>
{/if}
</Card>
<Card title="Members ({members.length})" cols={2}>
<div class="members-grid">
<div class="members-add">
<p class="hint">Add a child. They'll pick their own colour after joining.</p>
<form method="POST" action="?/addMember" use:enhance>
<label class="field-label" for="new-child">New child</label>
<input id="new-child" name="name" bind:value={addName} placeholder="Child name" required />
<Button type="submit" size="sm">Add child</Button>
<Accordion>
<!-- Family -->
<AccordionItem title="Family" open>
<CardGrid>
<Card title="Family Name">
<p class="hint">
This is the name shown to your family. The address stays at
<code class="slug-inline">/{famSlug}</code> even if you rename it — links you've shared keep
working.
</p>
<form method="POST" action="?/renameFam" use:enhance>
<label class="field-label" for="fam-name">Display name</label>
<input id="fam-name" name="name" bind:value={rename} placeholder={fam?.name || 'Family name'} required />
<Button type="submit" size="sm">Rename</Button>
</form>
</div>
<ul class="members-list">
{#each members as m}
<li>
<span class="member-left">
<span class="member-color" style="background:{m.color}"></span>
<span class="member-info">
<span class="member-name">{m.name}</span>
<span class="member-handle">/{famSlug}/{handleOf(m.username)}</span>
</span>
</span>
<span class="member-actions">
<Button href="/{famSlug}/{handleOf(m.username)}" variant="secondary" size="sm">Preview</Button>
<form method="POST" action="?/deleteMember" use:enhance class="inline">
<input type="hidden" name="id" value={m.id} />
<Button
type="submit"
variant="danger"
size="sm"
onclick={() => confirm('Remove {m.name}?')}>Remove</Button
>
</form>
</span>
</li>
{/each}
</ul>
</div>
</Card>
<Card title="Payday" cols={1}>
<form
method="POST"
action="?/updatePayday"
use:enhance={() => {
return async ({ result }) => {
if (result.type === 'error') {
alert(result.error || 'Failed to update payday');
}
// One-way: no invalidation — local state is already correct,
// and the PB subscription handles cross-device sync.
};
}}
class="payday-form"
>
<div class="payday-row">
<label>Day</label>
<select name="payday" bind:value={payday}>
<option value={0}>Sunday</option>
<option value={1}>Monday</option>
<option value={2}>Tuesday</option>
<option value={3}>Wednesday</option>
<option value={4}>Thursday</option>
<option value={5}>Friday</option>
<option value={6}>Saturday</option>
</select>
<label>Time</label>
<select name="paydayTime" bind:value={paydayTime}>
{#each paydayTimes as t}
<option value={t}>{t}</option>
{/each}
</select>
</div>
<div class="payday-row">
<label>Timezone</label>
<select name="timezone" bind:value={timezone}>
<option value="auto">{detectedTz ? `Auto (${detectedTz})` : 'Auto'}</option>
{#each timezoneOptions as tz}
<option value={tz}>{tz}</option>
{/each}
</select>
</div>
<Button type="submit" size="sm">Set payday</Button>
</form>
<p class="hint">
Payday: the week starts on this day and weekly earnings are settled at this time. Auto timezone
follows each device.
</p>
</Card>
<Card title="Invite Children" cols={1}>
<form
method="POST"
action="?/issueAccess"
use:enhance={() => {
return async ({ formData, result }) => {
if (result.type === 'success' && result.data?.ok) {
showQR = false;
qrDataUrl = '';
issued = {
otp: result.data.otp,
joinUrl: result.data.joinUrl,
name: String(formData.get('name') || '')
};
} else if (result.type === 'success' && result.data?.error) {
alert(result.data.error);
}
};
}}
class="invite-form"
>
<label class="field-label" for="invite-child">Child</label>
<select id="invite-child" bind:value={inviteChild} name="name" required>
<option value="">— Select a child —</option>
{#each members as m}
<option value={m.name}>{m.name}</option>
{/each}
</select>
<Button type="submit" size="sm" disabled={!inviteChild}>Issue code</Button>
</form>
<p class="hint">
Generates a 6-digit code valid for 20 minutes. The child enters it at the join link.
</p>
{#if issued?.otp}
<div class="mt-3 rounded-lg border border-indigo-200 bg-indigo-50 p-4">
<p class="text-xs text-slate-500">
Code for {issued.name} (valid 20 min):
</p>
<p class="my-2 text-center text-4xl font-bold tracking-[0.3em] text-indigo-700">
{issued.otp}
</p>
<p class="invite-url">{invitePath}</p>
<div class="actions justify-center">
<Button variant="secondary" size="sm" onclick={() => copy(inviteUrl)}>
{copied ? 'Copied!' : 'Copy URL'}
</Button>
<Button variant="secondary" size="sm" onclick={toggleQR}>
{showQR ? 'Hide QR' : 'Show QR'}
</Button>
</div>
{#if showQR && qrDataUrl}
<div class="qr-wrap">
<img src={qrDataUrl} alt="QR Code" class="qr" />
</div>
{#if fam?.slug}
<p class="hint slug-line">
Family page: <code class="slug-inline">/{fam.slug}</code>
</p>
{/if}
</div>
{/if}
</Card>
</Card>
<Card title="Invite Parent" cols={1}>
<p class="hint">Send an email invitation for another parent to join as an admin.</p>
<div class="invite-form">
<label class="field-label" for="parent-email">Parent email</label>
<input id="parent-email" type="email" bind:value={parentInviteEmail} placeholder="parent@example.com" />
<Button onclick={handleParentInvite} size="sm">Send invite</Button>
</div>
<p class="hint">They will set up their own password on first login.</p>
</Card>
<Card title="Seasons" cols={1}>
<p class="hint">Group chores into seasons. Toggle seasons on/off from the top nav.</p>
<form method="POST" action="?/createSeason" use:enhance class="season-form">
<label class="field-label" for="season-name">New season</label>
<input id="season-name" name="name" placeholder="Season name" required />
<div class="color-row">
<label for="season-color">Colour</label>
<input id="season-color" name="color" type="color" value="#6366f1" class="color-input" />
</div>
<Button type="submit" size="sm">Add</Button>
</form>
<ul>
{#each data.seasons as s}
<li>
<span class="dot" style="background:{s.color}"></span>
{s.name}
<Button variant="danger" size="sm" onclick={() => (deletingSeason = s)}>Remove</Button>
</li>
{/each}
</ul>
</Card>
<!-- Delete Season Modal -->
{#if deletingSeason}
<div class="overlay" onclick={() => (deletingSeason = null)} role="presentation">
<div class="modal" onclick={(e) => e.stopPropagation()} role="dialog">
<h3>Delete "{deletingSeason.name}"?</h3>
<p class="warning">
All chores assigned to this season will also be removed. This cannot be undone.
</p>
<Card title="Payday" cols={1}>
<form
method="POST"
action="?/deleteSeason"
action="?/updatePayday"
use:enhance={() => {
return async ({ result }) => {
if (result.type === 'success') {
deletingSeason = null;
if (result.type === 'error') alert(result.error || 'Failed to update payday');
};
}}
class="payday-form"
>
<div class="payday-row">
<label>Day</label>
<select name="payday" bind:value={payday}>
<option value={0}>Sunday</option>
<option value={1}>Monday</option>
<option value={2}>Tuesday</option>
<option value={3}>Wednesday</option>
<option value={4}>Thursday</option>
<option value={5}>Friday</option>
<option value={6}>Saturday</option>
</select>
<label>Time</label>
<select name="paydayTime" bind:value={paydayTime}>
{#each paydayTimes as t}
<option value={t}>{t}</option>
{/each}
</select>
</div>
<div class="payday-row">
<label>Timezone</label>
<select name="timezone" bind:value={timezone}>
<option value="auto">{detectedTz ? `Auto (${detectedTz})` : 'Auto'}</option>
{#each timezoneOptions as tz}
<option value={tz}>{tz}</option>
{/each}
</select>
</div>
<Button type="submit" size="sm">Set payday</Button>
</form>
<p class="hint">
Payday: the week starts on this day and weekly earnings are settled at this time. Auto timezone
follows each device.
</p>
</Card>
<Card title="Seasons" cols={1}>
<p class="hint">Group chores into seasons. Toggle seasons on/off from the top nav.</p>
<form method="POST" action="?/createSeason" use:enhance class="season-form">
<label class="field-label" for="season-name">New season</label>
<input id="season-name" name="name" placeholder="Season name" required />
<div class="color-row">
<label for="season-color">Colour</label>
<input id="season-color" name="color" type="color" value="#6366f1" class="color-input" />
</div>
<Button type="submit" size="sm">Add</Button>
</form>
<ul>
{#each data.seasons as s}
<li>
<span class="dot" style="background:{s.color}"></span>
{s.name}
<Button variant="danger" size="sm" onclick={() => (deletingSeason = s)}>Remove</Button>
</li>
{/each}
</ul>
</Card>
<!-- Delete Season Modal -->
{#if deletingSeason}
<div class="overlay" onclick={() => (deletingSeason = null)} role="presentation">
<div class="modal" onclick={(e) => e.stopPropagation()} role="dialog">
<h3>Delete "{deletingSeason.name}"?</h3>
<p class="warning">All chores assigned to this season will also be removed. This cannot be undone.</p>
<form method="POST" action="?/deleteSeason" use:enhance={() => {
return async ({ result }) => { if (result.type === 'success') deletingSeason = null; };
}}>
<input type="hidden" name="id" value={deletingSeason.id} />
<div class="modal-actions">
<button type="button" onclick={() => (deletingSeason = null)}>Cancel</button>
<button type="submit" class="danger">Delete Season</button>
</div>
</form>
</div>
</div>
{/if}
</CardGrid>
</AccordionItem>
<AccordionItem title="Invites">
<CardGrid>
<Card title="Members ({members.length})" cols={2}>
<div class="members-grid">
<div class="members-add">
<p class="hint">Add a child. They'll pick their own colour after joining.</p>
<form method="POST" action="?/addMember" use:enhance>
<label class="field-label" for="new-child">New child</label>
<input id="new-child" name="name" bind:value={addName} placeholder="Child name" required />
<Button type="submit" size="sm">Add child</Button>
</form>
</div>
<ul class="members-list">
{#each members as m}
<li>
<span class="member-left">
<span class="member-color" style="background:{m.color}"></span>
<span class="member-info">
<span class="member-name">{m.name}</span>
<span class="member-handle">/{famSlug}/{handleOf(m.username)}</span>
</span>
</span>
<span class="member-actions">
<Button href="/{famSlug}/{handleOf(m.username)}" variant="secondary" size="sm">Preview</Button>
<form method="POST" action="?/deleteMember" use:enhance class="inline">
<input type="hidden" name="id" value={m.id} />
<Button type="submit" variant="danger" size="sm" onclick={() => confirm('Remove {m.name}?')}>Remove</Button>
</form>
</span>
</li>
{/each}
</ul>
</div>
</Card>
<Card title="Invite Children" cols={1}>
<form
method="POST"
action="?/issueAccess"
use:enhance={() => {
return async ({ formData, result }) => {
if (result.type === 'success' && result.data?.ok) {
showQR = false;
qrDataUrl = '';
issued = {
otp: result.data.otp,
joinUrl: result.data.joinUrl,
name: String(formData.get('name') || '')
};
} else if (result.type === 'success' && result.data?.error) {
alert(result.data.error);
}
};
}}
class="invite-form"
>
<input type="hidden" name="id" value={deletingSeason.id} />
<div class="modal-actions">
<button type="button" onclick={() => (deletingSeason = null)}>Cancel</button>
<button type="submit" class="danger">Delete Season</button>
<label class="field-label" for="invite-child">Child</label>
<select id="invite-child" bind:value={inviteChild} name="name" required>
<option value="">— Select a child —</option>
{#each members as m}
<option value={m.name}>{m.name}</option>
{/each}
</select>
<Button type="submit" size="sm" disabled={!inviteChild}>Issue code</Button>
</form>
<p class="hint">
Generates a 6-digit code valid for 20 minutes. The child enters it at the join link.
</p>
{#if issued?.otp}
<div class="mt-3 rounded-lg border border-indigo-200 bg-indigo-50 p-4">
<p class="text-xs text-slate-500">Code for {issued.name} (valid 20 min):</p>
<p class="my-2 text-center text-4xl font-bold tracking-[0.3em] text-indigo-700">{issued.otp}</p>
<p class="invite-url">{invitePath}</p>
<div class="actions justify-center">
<Button variant="secondary" size="sm" onclick={() => copy(inviteUrl)}>{copied ? 'Copied!' : 'Copy URL'}</Button>
<Button variant="secondary" size="sm" onclick={toggleQR}>{showQR ? 'Hide QR' : 'Show QR'}</Button>
</div>
{#if showQR && qrDataUrl}
<div class="qr-wrap"><img src={qrDataUrl} alt="QR Code" class="qr" /></div>
{/if}
</div>
</form>
</div>
</div>
{/if}
{/if}
</Card>
<Card title="Billing & Subscription" cols={1}>
<p class="hint">
{data.fam?.active === false
? 'Your subscription is paused — interactions and payments are disabled.'
: 'Manage your plan, billing details, or pause your subscription.'}
</p>
<div class="actions">
<Button href="/account" variant="primary" size="sm">Account & Billing</Button>
<Button href="/subscriptions" variant="secondary" size="sm">Plans</Button>
</div>
</Card>
<Card title="Invite Parent" cols={1}>
<p class="hint">Send an email invitation for another parent to join as an admin.</p>
<div class="invite-form">
<label class="field-label" for="parent-email">Parent email</label>
<input id="parent-email" type="email" bind:value={parentInviteEmail} placeholder="parent@example.com" />
<Button onclick={handleParentInvite} size="sm">Send invite</Button>
</div>
<p class="hint">They will set up their own password on first login.</p>
</Card>
</CardGrid>
</AccordionItem>
<Card title="Data" cols={1}>
<div class="actions">
<Button variant="ghost" size="sm" disabled>Download CSV (coming soon)</Button>
<Button variant="danger" size="sm" disabled>Delete Family (coming soon)</Button>
</div>
</Card>
{#if data.fam?.featureFlags?.debugMode}
<Card title="Debug Tools" cols={1} accent="#f59e0b">
<p class="hint">Debug mode is enabled. These tools are for development and testing only.</p>
<div class="actions">
<form
method="POST"
action="?/completeWeek"
use:enhance={() => {
<AccordionItem title="Account">
<CardGrid>
<!-- Access -->
<Card title="Access" cols={1} accent={hasCode ? '#059669' : '#dc2626'}>
{#if hasCode}
<p class="hint">
Access active via access code
{#if data.accessCode?.duration}
— <strong>{formatCountdown(codeEntryDate, data.accessCode.duration)}</strong>
(expires {formatShortDate(addMonthsUTC(codeEntryDate!, data.accessCode.duration))})
{:else}
— <strong>never expires</strong>
{/if}.
</p>
<form
class="revoke-form"
method="POST"
action="?/revokeCode"
use:enhance={() => {
return async ({ result, update }) => {
if (result.type === 'success') accessMsg = 'Access code revoked.';
else if (result.type === 'failure') accessMsg = (result.data as any)?.error || 'Revoke failed.';
await update();
};
}}
>
<Button type="submit" size="sm" variant="danger">Revoke code</Button>
</form>
{:else}
<p class="hint">No access applied. Enter a valid access code to enable your family.</p>
<form method="POST" action="?/applyCode" use:enhance={() => {
return async ({ result, update }) => {
if (result.type === 'success') alert('Week completed!');
if (result.type === 'success' && result.data) {
const d = result.data as { error?: string; ok?: boolean };
accessMsg = d.error || 'Code applied — access enabled.';
accessCodeInput = '';
}
await update();
};
}}
>
<Button type="submit" size="sm" variant="secondary">Complete Week</Button>
</form>
<form
method="POST"
action="?/generateData"
use:enhance={() => {
return async ({ result, update }) => {
if (result.type === 'success') alert('Test data generated!');
await update();
};
}}
>
<input type="hidden" name="days" value="7" />
<Button type="submit" size="sm" variant="secondary">Generate Test Data (7 days)</Button>
</form>
</div>
</Card>
{/if}
</CardGrid>
}}>
<input name="code" bind:value={accessCodeInput} placeholder="Enter access code" autocomplete="off" />
<Button type="submit" size="sm" variant="primary">Apply code</Button>
</form>
{/if}
{#if accessMsg}
<p class="access-msg">{accessMsg}</p>
{/if}
</Card>
<Card title="Subscription" cols={1} accent={fam?.paymentMode === 'sub' ? '#059669' : '#f59e0b'}>
<p class="hint">
{fam?.paymentMode === 'sub' ? 'Current plan: Subscription.' :
fam?.paymentMode === 'code' ? `Current plan: Access code${hasCode ? '' : ' (invalid)'}.` :
fam?.paymentMode === 'canceled' ? 'Your subscription was canceled.' : 'No active plan.'}
</p>
{#if fam?.paymentMode === 'sub'}
<div class="actions">
<Button href="/pricing" variant="primary" size="sm">Change plan</Button>
<form method="POST" action="?/billingPortal" use:enhance={() => {
return async ({ result, update }) => {
if (result.type === 'success' && (result.data as any)?.portalUrl) {
window.open((result.data as any).portalUrl, '_blank');
} else if (result.type === 'failure') {
accessMsg = (result.data as any)?.error || 'Failed to open billing portal';
}
await update();
};
}}>
<Button type="submit" variant="secondary" size="sm" disabled={!fam?.stripeCustomerId}>Open billing portal</Button>
</form>
</div>
<p class="hint resume-hint">
To pause your plan, cancel in the billing portal — you keep your data and can resubscribe
anytime. Cancelling takes effect at period end.
</p>
{:else if fam?.paymentMode === 'code'}
<div class="actions">
<Button href="/pricing" variant="primary" size="sm">Switch to subscription</Button>
</div>
{:else}
<div class="actions">
<Button href="/pricing" variant="primary" size="sm">Choose a plan</Button>
</div>
{/if}
</Card>
</CardGrid>
</AccordionItem>
<AccordionItem title="App">
<CardGrid>
{#if page.data.platformFlags?.debug}
<Card title="Debug Tools" cols={1} accent="#f59e0b">
<p class="hint">Debug mode is enabled. These tools are for development and testing only.</p>
<div class="actions">
<form method="POST" action="?/completeWeek" use:enhance={() => {
return async ({ result, update }) => {
if (result.type === 'success') alert('Week completed!');
await update();
};
}}>
<Button type="submit" size="sm" variant="secondary">Complete Week</Button>
</form>
<form method="POST" action="?/generateData" use:enhance={() => {
return async ({ result, update }) => {
if (result.type === 'success') alert('Test data generated!');
await update();
};
}}>
<input type="hidden" name="days" value="7" />
<Button type="submit" size="sm" variant="secondary">Generate Test Data (7 days)</Button>
</form>
</div>
</Card>
{/if}
<Card title="Data" cols={1}>
<div class="actions">
<Button variant="ghost" size="sm" disabled>Download CSV (coming soon)</Button>
<Button variant="danger" size="sm" disabled>Delete Family (coming soon)</Button>
</div>
</Card>
</CardGrid>
</AccordionItem>
<!-- Invites -->
</Accordion>
<NoticeDialog />
<style>
.hint {
font-size: 0.85rem;
@@ -698,4 +758,21 @@
border-radius: 6px;
margin-bottom: 0.5rem;
}
.access-msg {
margin-top: 0.5rem;
font-size: 0.85rem;
color: #059669;
}
.revoke-form {
margin-top: 0.6rem;
display: flex;
align-items: center;
gap: 0.6rem;
}
.revoke-form .hint {
font-size: 0.78rem;
}
.resume-hint {
margin-top: 0.5rem;
}
</style>
@@ -1,10 +1,10 @@
import { fail, redirect } from '@sveltejs/kit';
import { redeemOtp } from '$lib/server/member-otp';
import { setSessionCookie } from '$lib/server/session';
import { setSessionCookie, clearLegacyCookies } from '$lib/server/session';
export const actions = {
default: async (event) => {
const famSlug = event.params.famSlug;
const fam = event.params.fam;
const username = event.params.username;
const fd = await event.request.formData();
const otp = (fd.get('otp') || '').toString().trim();
@@ -12,13 +12,13 @@ export const actions = {
if (!otp) return fail(400, { error: 'Enter the code shown by your parent.' });
try {
const token = await redeemOtp({ famSlug, username, otp });
event.cookies.delete('device_token', { path: '/' });
const token = await redeemOtp({ famSlug: fam, username, otp });
clearLegacyCookies(event.cookies);
setSessionCookie(event.cookies, token);
} catch (e) {
return fail(400, { error: e instanceof Error ? e.message : 'Join failed' });
}
throw redirect(303, `/${famSlug}/${encodeURIComponent(username)}`);
throw redirect(303, `/${fam}/${encodeURIComponent(username)}`);
}
};
@@ -3,7 +3,7 @@
import { enhance } from '$app/forms';
import { Button } from '$lib/components';
const famSlug = page.params.famSlug;
const famSlug = page.params.fam;
const username = page.params.username;
let otp = $state(page.url.searchParams.get('code') || '');
let { form } = $props();
+10 -11
View File
@@ -1,16 +1,17 @@
import { pbAdmin } from '$lib/server/pocketbase';
import { redirect, fail } from '@sveltejs/kit';
import { PB_EMAIL, PB_PASSWORD } from '$app/env/private';
import { getPlatformFlags, setPlatformFlag } from '$lib/server/platform';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ cookies }) => {
const session = cookies.get('platform_session');
if (!session) {
return { authenticated: false, fams: [], totalFams: 0, totalMembers: 0, totalRewards: 0 };
return { authenticated: false, fams: [], totalFams: 0, totalMembers: 0, totalRewards: 0, platformFlags: {} };
}
try {
const fams = await pbAdmin.getList('fams');
const [fams, platformFlags] = await Promise.all([pbAdmin.getList('fams'), getPlatformFlags()]);
const famsWithStats = await Promise.all(fams.map(async (fam: any) => {
const [members, rewards, parents] = await Promise.all([
pbAdmin.getList('users', `famId = '${fam.id}' && role = 'child'`),
@@ -23,7 +24,6 @@ export const load: PageServerLoad = async ({ cookies }) => {
requestedRewards: (rewards as any[]).filter((r: any) => r.status === 'requested').length,
totalRewards: rewards.length,
parentEmail: (parents as any[])?.[0]?.email || '',
featureFlags: fam.featureFlags || {},
};
}));
@@ -31,9 +31,9 @@ export const load: PageServerLoad = async ({ cookies }) => {
const totalMembers = famsWithStats.reduce((s: number, f: any) => s + f.memberCount, 0);
const totalRewards = famsWithStats.reduce((s: number, f: any) => s + f.totalRewards, 0);
return { authenticated: true, fams: famsWithStats, totalFams, totalMembers, totalRewards };
return { authenticated: true, fams: famsWithStats, totalFams, totalMembers, totalRewards, platformFlags };
} catch {
return { authenticated: false, fams: [], totalFams: 0, totalMembers: 0, totalRewards: 0 };
return { authenticated: false, fams: [], totalFams: 0, totalMembers: 0, totalRewards: 0, platformFlags: {} };
}
};
@@ -60,19 +60,18 @@ export const actions: Actions = {
throw redirect(303, '/admin');
},
toggleFeatureFlag: async ({ request, cookies }) => {
// Toggles a platform-level feature flag on the singleton platform record.
togglePlatformFlag: async ({ request, cookies }) => {
const session = cookies.get('platform_session');
if (!session) return fail(401, { error: 'Not authenticated' });
const fd = await request.formData();
const famId = fd.get('famId') as string;
const flag = fd.get('flag') as string;
if (!flag) return fail(400, { error: 'Flag required' });
try {
const fam = await pbAdmin.getOne('fams', famId);
const flags = fam.featureFlags || {};
flags[flag] = !flags[flag];
await pbAdmin.update('fams', famId, { featureFlags: flags });
const flags = await getPlatformFlags();
await setPlatformFlag(flag, !flags[flag]);
return { success: true };
} catch (e) {
return fail(500, { error: e instanceof Error ? e.message : 'Failed to update' });
+27 -16
View File
@@ -49,6 +49,19 @@
</div>
</Card>
<Card cols={1} title="Platform Flags" accent="#f59e0b">
<p class="hint">Global feature flags (public read-only for fams; toggled here).</p>
<div class="flag-row">
<span class="flag-name">debug</span>
<form method="POST" action="?/togglePlatformFlag" use:enhance>
<input type="hidden" name="flag" value="debug" />
<button type="submit" class="toggle-btn" class:active={data.platformFlags?.debug}>
{data.platformFlags?.debug ? 'ON' : 'OFF'}
</button>
</form>
</div>
</Card>
<Card cols={2} title="Families" accent="#059669">
<table class="fam-table">
<thead>
@@ -57,7 +70,6 @@
<th>Parent</th>
<th>Members</th>
<th>Claims</th>
<th>Debug</th>
<th>Actions</th>
</tr>
</thead>
@@ -65,7 +77,7 @@
{#each data.fams as fam}
<tr>
<td>
<a href="/{fam.slug}/admin">{fam.name}</a>
<a href="/{fam.slug}">{fam.name}</a>
<span class="slug">/{fam.slug}</span>
</td>
<td>{fam.parentEmail || '—'}</td>
@@ -78,20 +90,7 @@
{/if}
</td>
<td>
<form method="POST" action="?/toggleFeatureFlag" use:enhance>
<input type="hidden" name="famId" value={fam.id} />
<input type="hidden" name="flag" value="debugMode" />
<button
type="submit"
class="toggle-btn"
class:active={fam.featureFlags?.debugMode}
>
{fam.featureFlags?.debugMode ? 'ON' : 'OFF'}
</button>
</form>
</td>
<td>
<a href="/{fam.slug}/admin" class="link">Dashboard</a>
<a href="/{fam.slug}" class="link">Dashboard</a>
<a href="/{fam.slug}" class="link">View</a>
</td>
</tr>
@@ -211,6 +210,18 @@
background: #059669;
color: white;
}
.flag-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0.35rem 0;
}
.flag-name {
font-family: monospace;
font-size: 0.85rem;
color: #374151;
}
.link {
color: #2563eb;
text-decoration: none;
@@ -0,0 +1,30 @@
import { json, type RequestHandler } from '@sveltejs/kit';
import { verifyStripeEvent, isDummyStripe } from '$lib/server/stripe';
import { handleStripeEvent } from '$lib/server/stripe-events';
// Stripe webhook: updates fams.stripeCustomerId + fams.active + fams.paymentMode
// from subscription lifecycle events. Lives under /api/webhooks/stripe per the
// architecture — machine-to-machine endpoints live in /api/*, not under UI routes.
export const POST: RequestHandler = async ({ request }) => {
const rawBody = await request.text();
const signature = request.headers.get('stripe-signature');
// Dummy mode: no webhook secret configured — accept the event without
// verification so the flow is testable before the account is connected.
if (isDummyStripe || !signature) {
return json({ received: true, dummy: true });
}
let event;
try {
event = verifyStripeEvent(rawBody, signature);
} catch (e) {
return json(
{ error: e instanceof Error ? e.message : 'Webhook signature verification failed' },
{ status: 400 }
);
}
await handleStripeEvent(event);
return json({ received: true });
};
+2 -2
View File
@@ -1,10 +1,10 @@
import { redirect } from '@sveltejs/kit';
import { clearSessionCookie } from '$lib/server/session';
import { clearSessionCookie, clearLegacyCookies } from '$lib/server/session';
function signOut(event: { cookies: any }) {
clearSessionCookie(event.cookies);
event.cookies.delete('session', { path: '/' });
event.cookies.delete('device_token', { path: '/' });
clearLegacyCookies(event.cookies);
}
export function load(event) {
@@ -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>
+61 -7
View File
@@ -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;
}
}
+140 -43
View File
@@ -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;