diff --git a/frontend/src/lib/components/Sidebar.svelte b/frontend/src/lib/components/Sidebar.svelte index 1fe05bb..4b90f14 100644 --- a/frontend/src/lib/components/Sidebar.svelte +++ b/frontend/src/lib/components/Sidebar.svelte @@ -18,7 +18,7 @@ famStore.initialized ? famStore.rewards.filter((r: any) => r.status === 'requested').length : 0 ); - let { famName = '', session = null, isParent = false, role = 'child' } = $props(); + let { famName = '', session = null, isParent = false, role = 'child', isDemo = false } = $props(); let famSlug = $derived(page.data.famSlug ?? page.params.fam); let memberName = $derived(session?.memberName || page.params.username || ''); @@ -43,10 +43,10 @@ ); let footerItems = $derived([ - ...(memberName + ...(memberName && !isDemo ? [{ href: `/${famSlug}/${memberName}/preferences`, label: 'Preferences', icon: prefsIcon }] : []), - ...(isParent && memberName + ...(isParent && memberName && !isDemo ? [{ href: `/${famSlug}/${memberName}/settings`, label: 'Settings', icon: settingsIcon }] : []), { diff --git a/frontend/src/lib/server/email.ts b/frontend/src/lib/server/email.ts index 95350b9..b1ea3a8 100644 --- a/frontend/src/lib/server/email.ts +++ b/frontend/src/lib/server/email.ts @@ -1,14 +1,31 @@ import { Resend } from 'resend'; import { RESEND_API } from '$app/env/private'; import { getAccessEmailTemplate } from './email-templates'; +import { getPlatformFlags } from '$lib/server/platform'; const resend = new Resend(String(RESEND_API)); +let demoCache: { value: boolean; at: number } | null = null; +const DEMO_TTL_MS = 10_000; + +async function isDemoMode(): Promise { + if (demoCache && Date.now() - demoCache.at < DEMO_TTL_MS) return demoCache.value; + try { + const flags = await getPlatformFlags(); + demoCache = { value: flags.demo === true, at: Date.now() }; + return flags.demo === true; + } catch { + demoCache = { value: false, at: Date.now() }; + return false; + } +} + export async function sendPasswordResetEmail(opts: { to: string; resetLink: string; famName: string; }) { + if (await isDemoMode()) return; const { to, resetLink, famName } = opts; await resend.emails.send({ @@ -30,6 +47,7 @@ export async function sendParentInviteEmail(opts: { famName: string; otp: string; }) { + if (await isDemoMode()) return; const { to, inviteLink, famName, otp } = opts; await resend.emails.send({ @@ -52,6 +70,7 @@ export async function sendAccessUnlockedEmail(opts: { codeValue: string; dashboardUrl: string; }) { + if (await isDemoMode()) return; const { to, famName, codeName, codeValue, dashboardUrl } = opts; const template = getAccessEmailTemplate(codeValue); @@ -70,6 +89,7 @@ export async function sendWelcomeEmail(opts: { famName: string; dashboardUrl: string; }) { + if (await isDemoMode()) return; const { to, famName, dashboardUrl } = opts; await resend.emails.send({ @@ -112,6 +132,7 @@ export async function sendMentionEmail(opts: { message: string; dashboardUrl: string; }) { + if (await isDemoMode()) return; const { to, parentName, authorName, message, dashboardUrl } = opts; await resend.emails.send({ @@ -138,6 +159,7 @@ export async function sendClaimRequestEmail(opts: { rewardValue: string; dashboardUrl: string; }) { + if (await isDemoMode()) return; const { to, parentName, childName, rewardName, rewardValue, dashboardUrl } = opts; await resend.emails.send({ diff --git a/frontend/src/lib/server/migrate.ts b/frontend/src/lib/server/migrate.ts index 1b99a76..77a2e7b 100644 --- a/frontend/src/lib/server/migrate.ts +++ b/frontend/src/lib/server/migrate.ts @@ -1,6 +1,7 @@ import { SCHEMA_PLAN } from '@shared/pb/schema'; import { PB_ENDPOINT } from '$lib/server/pocketbase'; import { PB_EMAIL, PB_PASSWORD, MEMBER_SECRET } from '$app/env/private'; +import { getPlatformFlags } from '$lib/server/platform'; let token: string | null = null; let tokenExpiry = 0; @@ -20,6 +21,11 @@ async function auth(): Promise { return token!; } +async function isDemo(): Promise { + const flags = await getPlatformFlags(); + return flags.demo === true; +} + async function getCollection(name: string): Promise { const t = await auth(); const res = await fetch(`${PB_ENDPOINT}/api/collections?filter=name='${name}'`, { @@ -444,8 +450,10 @@ export async function migrate(): Promise { await ensureAccessCodes(); await ensurePlatform(); await ensureDefaultSeasons(); - await seedDemoFamily(); - await seedDemoCompletions(); + if (await isDemo()) { + await seedDemoFamily(); + await seedDemoCompletions(); + } console.log('[migrate] Done'); } diff --git a/frontend/src/routes/[fam]/+layout.server.ts b/frontend/src/routes/[fam]/+layout.server.ts index aeb214f..449d86c 100644 --- a/frontend/src/routes/[fam]/+layout.server.ts +++ b/frontend/src/routes/[fam]/+layout.server.ts @@ -3,6 +3,7 @@ import { createPbClient } from '$lib/server/pocketbase'; import { createServices, type ChatActor } from '$lib/server/services'; import { ensureFamAccess } from '$lib/server/access'; import { seedDemoCompletions } from '$lib/server/migrate'; +import { getPlatformFlags } from '$lib/server/platform'; async function paydayCheck(famId: string, pbToken: string) { try { @@ -47,8 +48,8 @@ async function resolveChatIdentity( } export async function load(event) { - // Demo family: rotate 3-week completion data on every visit (idempotent). - if (event.params.fam === 'showboaters') { + // Demo family: rotate 3-week completion data on every visit when demo mode is on (idempotent). + if (event.params.fam === 'showboaters' && (await getPlatformFlags()).demo) { seedDemoCompletions().catch(() => {}); } @@ -94,6 +95,8 @@ export async function load(event) { } } + const demoMode = (await getPlatformFlags()).demo; + return { // Canonical fam slug — from the URL param ([fam] routes). Client code // reads page.data.famSlug; never copy it into local $state. @@ -117,6 +120,7 @@ export async function load(event) { chat, pbToken, fam, - famAccess + famAccess, + demoMode }; } diff --git a/frontend/src/routes/[fam]/+layout.svelte b/frontend/src/routes/[fam]/+layout.svelte index 738a751..5482c8a 100644 --- a/frontend/src/routes/[fam]/+layout.svelte +++ b/frontend/src/routes/[fam]/+layout.svelte @@ -32,6 +32,7 @@ let session = $state(data.session); let isParent = $state(data.isParent); let role = $state(data.role); + let isDemo = $state(data.demoMode); let famName = $derived( famStore.initialized ? (famStore.fam as any)?.name || page.params.fam : page.params.fam ); @@ -198,7 +199,7 @@ class="app-shell {bgPattern ? `pattern-${bgPattern}` : ''}" style={`${bgSizeVw ? `--s:calc(${bgSizeVw} * 1vw);` : ''}--c1:${bgShades.c1};--c2:${bgShades.c2};--c3:${bgShades.c3};--c4:${bgShades.c4}`} > - + s.active !== false)} diff --git a/frontend/src/routes/[fam]/[username]/+page.server.ts b/frontend/src/routes/[fam]/[username]/+page.server.ts index a2bc0d0..76b729f 100644 --- a/frontend/src/routes/[fam]/[username]/+page.server.ts +++ b/frontend/src/routes/[fam]/[username]/+page.server.ts @@ -1,19 +1,27 @@ import { fail, redirect } from '@sveltejs/kit'; import { pbUser, createPbClient } from '$lib/server/pocketbase'; import { createServices } from '$lib/server/services'; +import { getPlatformFlags } from '$lib/server/platform'; +import { pbAdmin } from '$lib/server/pocketbase'; export async function load(event) { const session = event.locals.user; + const demoMode = (await getPlatformFlags()).demo; + const isDemoParent = session?.username === 'demo'; + const famSlug = event.params.fam; + const username = event.params.username; + + // Demo parent visiting a child's route → load child kanban view. + const demoParentVisitingChild = demoMode && isDemoParent && session?.username !== username; // Parent (role=parent, session auth) → admin overview. // Branch on role, NOT presence: a child who OTP-logged-in also has a // `users` session (locals.user) and must not hit the parent path. - if (session && session.role === 'parent') { - const famId = session.famId; - const famSlug = event.params.fam; - const username = event.params.username; - if (session.name && session.username && session.username !== username) { - throw redirect(303, `/${famSlug}/${session.username}`); + if (session && session.role === 'parent' && !demoParentVisitingChild) { + if (!(demoMode && isDemoParent)) { + if (session.name && session.username && session.username !== username) { + throw redirect(303, `/${famSlug}/${session.username}`); + } } const s = createServices(pbUser(event), session); const [ @@ -27,15 +35,15 @@ export async function load(event) { completions, settings ] = await Promise.all([ - s.crud.list('members', famId), - s.crud.list('chore-templates', famId), - s.crud.list('assigned-chores', famId), - s.fam.weeklySummary(famId), - s.fam.get(famId), - s.crud.list('rewards', famId), - s.crud.list('bonus-configs', famId), - s.crud.list('completions', famId), - s.settings.get(famId) + s.crud.list('members', session.famId), + s.crud.list('chore-templates', session.famId), + s.crud.list('assigned-chores', session.famId), + s.fam.weeklySummary(session.famId), + s.fam.get(session.famId), + s.crud.list('rewards', session.famId), + s.crud.list('bonus-configs', session.famId), + s.crud.list('completions', session.famId), + s.settings.get(session.famId) ]); return { role: 'parent', @@ -52,18 +60,23 @@ export async function load(event) { }; } - // Child (users auth, role=child) → kanban - const famSlug = event.params.fam; - const username = event.params.username; + // Child (users auth, role=child) → kanban. + // Also demo parent visiting a child's route. // A child should always land on their own kanban route. - if (session?.username && session.username !== username) { - throw redirect(303, `/${famSlug}/${session.username}`); + if (!(demoMode && isDemoParent)) { + if (session?.username && session.username !== username) { + throw redirect(303, `/${famSlug}/${session.username}`); + } } const pbToken = event.cookies.get('pb_token') || ''; const famId = session?.famId || ''; - const childId = session?.id || ''; + // When a demo parent visits a child's route, session.id is the parent's PB + // record. Look up the actual child user by username so chores load correctly. + const childId = demoParentVisitingChild + ? (await pbAdmin.getList('users', `famId='${famId}' && username='${famSlug}:${username}'`))?.[0]?.id || '' + : session?.id || ''; const empty = { role: 'child' as const, diff --git a/frontend/src/routes/[fam]/[username]/preferences/+page.server.ts b/frontend/src/routes/[fam]/[username]/preferences/+page.server.ts index e7828d4..21335d9 100644 --- a/frontend/src/routes/[fam]/[username]/preferences/+page.server.ts +++ b/frontend/src/routes/[fam]/[username]/preferences/+page.server.ts @@ -1,7 +1,16 @@ import { redirect } from '@sveltejs/kit'; import { servicesFor } from '$lib/server/servicesFor'; +import { getPlatformFlags } from '$lib/server/platform'; + +async function isDemoMode(): Promise { + return (await getPlatformFlags()).demo; +} export async function load(event) { + if (await isDemoMode()) { + return { verified: false, role: '', token: '', memberId: '', famId: '', memberName: '', memberColor: '', pattern: '', themeSize: '', themeOpacity: '', email: '', demoMode: true }; + } + const session = event.locals.user; const famSlug = event.params.fam; const username = event.params.username; diff --git a/frontend/src/routes/[fam]/[username]/settings/+page.server.ts b/frontend/src/routes/[fam]/[username]/settings/+page.server.ts index dddbe88..b9572ba 100644 --- a/frontend/src/routes/[fam]/[username]/settings/+page.server.ts +++ b/frontend/src/routes/[fam]/[username]/settings/+page.server.ts @@ -7,6 +7,7 @@ import { issueAccess, createChild, inviteParent } from '$lib/server/member-otp'; import { sendParentInviteEmail } from '$lib/server/email'; import { slugify, handle, famUsername } from '@shared/slugify'; import { applyAccessCode } from '$lib/server/access'; +import { getPlatformFlags } from '$lib/server/platform'; import { createBillingPortalSession, cancelSubscriptionAtPeriodEnd, @@ -18,7 +19,15 @@ function famIdOf(event: RequestEvent): string { return event.locals.user.famId; } +// In demo mode, settings is disabled for all users. +async function isDemoMode(): Promise { + return (await getPlatformFlags()).demo; +} + export async function load(event: RequestEvent) { + if (await isDemoMode()) { + return { members: [], fam: null, seasons: [], accessCode: null, subStatus: null, demoMode: true }; + } const famId = famIdOf(event); const pb = pbUser(event); const [members, fam, seasons] = await Promise.all([ diff --git a/frontend/src/routes/admin/+page.server.ts b/frontend/src/routes/admin/+page.server.ts index 1d7901f..987770b 100644 --- a/frontend/src/routes/admin/+page.server.ts +++ b/frontend/src/routes/admin/+page.server.ts @@ -89,12 +89,15 @@ export const load: PageServerLoad = async (event) => { pbAdmin.getList('bonus_templates', 'global = true') ]); + const platformFlags = await getPlatformFlags(); + return { authenticated: true, fams: famsWithStats, codes: codeList, choreTemplates: choreTemplates as any[], bonusTemplates: bonusTemplates as any[], + platformFlags, totalFams: fams.length, totalMembers: famsWithStats.reduce((s, f) => s + f.memberCount, 0), totalRewards: famsWithStats.reduce((s, f) => s + f.totalRewards, 0), @@ -121,6 +124,7 @@ export const load: PageServerLoad = async (event) => { codeCount: 0, choreTemplates: [], bonusTemplates: [], + platformFlags: {}, loadError: e instanceof Error ? e.message : 'Failed to load platform data' }; } diff --git a/frontend/src/routes/admin/+page.svelte b/frontend/src/routes/admin/+page.svelte index 84c6966..f298f52 100644 --- a/frontend/src/routes/admin/+page.svelte +++ b/frontend/src/routes/admin/+page.svelte @@ -188,6 +188,20 @@ +
+ demo + Showboaters demo family with sample data for prospectus viewers +
+ + +
+
rewardsTargets Multi-target reward rules (Competition / Collaboration)