add platform demo fam

This commit is contained in:
JCEEE
2026-09-10 18:54:38 +01:00
parent 4730ab3eb9
commit 41033914b4
10 changed files with 114 additions and 30 deletions
+3 -3
View File
@@ -18,7 +18,7 @@
famStore.initialized ? famStore.rewards.filter((r: any) => r.status === 'requested').length : 0 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 famSlug = $derived(page.data.famSlug ?? page.params.fam);
let memberName = $derived(session?.memberName || page.params.username || ''); let memberName = $derived(session?.memberName || page.params.username || '');
@@ -43,10 +43,10 @@
); );
let footerItems = $derived([ let footerItems = $derived([
...(memberName ...(memberName && !isDemo
? [{ href: `/${famSlug}/${memberName}/preferences`, label: 'Preferences', icon: prefsIcon }] ? [{ href: `/${famSlug}/${memberName}/preferences`, label: 'Preferences', icon: prefsIcon }]
: []), : []),
...(isParent && memberName ...(isParent && memberName && !isDemo
? [{ href: `/${famSlug}/${memberName}/settings`, label: 'Settings', icon: settingsIcon }] ? [{ href: `/${famSlug}/${memberName}/settings`, label: 'Settings', icon: settingsIcon }]
: []), : []),
{ {
+22
View File
@@ -1,14 +1,31 @@
import { Resend } from 'resend'; import { Resend } from 'resend';
import { RESEND_API } from '$app/env/private'; import { RESEND_API } from '$app/env/private';
import { getAccessEmailTemplate } from './email-templates'; import { getAccessEmailTemplate } from './email-templates';
import { getPlatformFlags } from '$lib/server/platform';
const resend = new Resend(String(RESEND_API)); 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<boolean> {
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: { export async function sendPasswordResetEmail(opts: {
to: string; to: string;
resetLink: string; resetLink: string;
famName: string; famName: string;
}) { }) {
if (await isDemoMode()) return;
const { to, resetLink, famName } = opts; const { to, resetLink, famName } = opts;
await resend.emails.send({ await resend.emails.send({
@@ -30,6 +47,7 @@ export async function sendParentInviteEmail(opts: {
famName: string; famName: string;
otp: string; otp: string;
}) { }) {
if (await isDemoMode()) return;
const { to, inviteLink, famName, otp } = opts; const { to, inviteLink, famName, otp } = opts;
await resend.emails.send({ await resend.emails.send({
@@ -52,6 +70,7 @@ export async function sendAccessUnlockedEmail(opts: {
codeValue: string; codeValue: string;
dashboardUrl: string; dashboardUrl: string;
}) { }) {
if (await isDemoMode()) return;
const { to, famName, codeName, codeValue, dashboardUrl } = opts; const { to, famName, codeName, codeValue, dashboardUrl } = opts;
const template = getAccessEmailTemplate(codeValue); const template = getAccessEmailTemplate(codeValue);
@@ -70,6 +89,7 @@ export async function sendWelcomeEmail(opts: {
famName: string; famName: string;
dashboardUrl: string; dashboardUrl: string;
}) { }) {
if (await isDemoMode()) return;
const { to, famName, dashboardUrl } = opts; const { to, famName, dashboardUrl } = opts;
await resend.emails.send({ await resend.emails.send({
@@ -112,6 +132,7 @@ export async function sendMentionEmail(opts: {
message: string; message: string;
dashboardUrl: string; dashboardUrl: string;
}) { }) {
if (await isDemoMode()) return;
const { to, parentName, authorName, message, dashboardUrl } = opts; const { to, parentName, authorName, message, dashboardUrl } = opts;
await resend.emails.send({ await resend.emails.send({
@@ -138,6 +159,7 @@ export async function sendClaimRequestEmail(opts: {
rewardValue: string; rewardValue: string;
dashboardUrl: string; dashboardUrl: string;
}) { }) {
if (await isDemoMode()) return;
const { to, parentName, childName, rewardName, rewardValue, dashboardUrl } = opts; const { to, parentName, childName, rewardName, rewardValue, dashboardUrl } = opts;
await resend.emails.send({ await resend.emails.send({
+8
View File
@@ -1,6 +1,7 @@
import { SCHEMA_PLAN } from '@shared/pb/schema'; import { SCHEMA_PLAN } from '@shared/pb/schema';
import { PB_ENDPOINT } from '$lib/server/pocketbase'; import { PB_ENDPOINT } from '$lib/server/pocketbase';
import { PB_EMAIL, PB_PASSWORD, MEMBER_SECRET } from '$app/env/private'; import { PB_EMAIL, PB_PASSWORD, MEMBER_SECRET } from '$app/env/private';
import { getPlatformFlags } from '$lib/server/platform';
let token: string | null = null; let token: string | null = null;
let tokenExpiry = 0; let tokenExpiry = 0;
@@ -20,6 +21,11 @@ async function auth(): Promise<string> {
return token!; return token!;
} }
async function isDemo(): Promise<boolean> {
const flags = await getPlatformFlags();
return flags.demo === true;
}
async function getCollection(name: string): Promise<any | null> { async function getCollection(name: string): Promise<any | null> {
const t = await auth(); const t = await auth();
const res = await fetch(`${PB_ENDPOINT}/api/collections?filter=name='${name}'`, { const res = await fetch(`${PB_ENDPOINT}/api/collections?filter=name='${name}'`, {
@@ -444,8 +450,10 @@ export async function migrate(): Promise<void> {
await ensureAccessCodes(); await ensureAccessCodes();
await ensurePlatform(); await ensurePlatform();
await ensureDefaultSeasons(); await ensureDefaultSeasons();
if (await isDemo()) {
await seedDemoFamily(); await seedDemoFamily();
await seedDemoCompletions(); await seedDemoCompletions();
}
console.log('[migrate] Done'); console.log('[migrate] Done');
} }
+7 -3
View File
@@ -3,6 +3,7 @@ import { createPbClient } from '$lib/server/pocketbase';
import { createServices, type ChatActor } from '$lib/server/services'; import { createServices, type ChatActor } from '$lib/server/services';
import { ensureFamAccess } from '$lib/server/access'; import { ensureFamAccess } from '$lib/server/access';
import { seedDemoCompletions } from '$lib/server/migrate'; import { seedDemoCompletions } from '$lib/server/migrate';
import { getPlatformFlags } from '$lib/server/platform';
async function paydayCheck(famId: string, pbToken: string) { async function paydayCheck(famId: string, pbToken: string) {
try { try {
@@ -47,8 +48,8 @@ async function resolveChatIdentity(
} }
export async function load(event) { export async function load(event) {
// Demo family: rotate 3-week completion data on every visit (idempotent). // Demo family: rotate 3-week completion data on every visit when demo mode is on (idempotent).
if (event.params.fam === 'showboaters') { if (event.params.fam === 'showboaters' && (await getPlatformFlags()).demo) {
seedDemoCompletions().catch(() => {}); seedDemoCompletions().catch(() => {});
} }
@@ -94,6 +95,8 @@ export async function load(event) {
} }
} }
const demoMode = (await getPlatformFlags()).demo;
return { return {
// Canonical fam slug — from the URL param ([fam] routes). Client code // Canonical fam slug — from the URL param ([fam] routes). Client code
// reads page.data.famSlug; never copy it into local $state. // reads page.data.famSlug; never copy it into local $state.
@@ -117,6 +120,7 @@ export async function load(event) {
chat, chat,
pbToken, pbToken,
fam, fam,
famAccess famAccess,
demoMode
}; };
} }
+2 -1
View File
@@ -32,6 +32,7 @@
let session = $state<Session | null>(data.session); let session = $state<Session | null>(data.session);
let isParent = $state(data.isParent); let isParent = $state(data.isParent);
let role = $state(data.role); let role = $state(data.role);
let isDemo = $state(data.demoMode);
let famName = $derived( let famName = $derived(
famStore.initialized ? (famStore.fam as any)?.name || page.params.fam : page.params.fam famStore.initialized ? (famStore.fam as any)?.name || page.params.fam : page.params.fam
); );
@@ -198,7 +199,7 @@
class="app-shell {bgPattern ? `pattern-${bgPattern}` : ''}" class="app-shell {bgPattern ? `pattern-${bgPattern}` : ''}"
style={`${bgSizeVw ? `--s:calc(${bgSizeVw} * 1vw);` : ''}--c1:${bgShades.c1};--c2:${bgShades.c2};--c3:${bgShades.c3};--c4:${bgShades.c4}`} style={`${bgSizeVw ? `--s:calc(${bgSizeVw} * 1vw);` : ''}--c1:${bgShades.c1};--c2:${bgShades.c2};--c3:${bgShades.c3};--c4:${bgShades.c4}`}
> >
<Sidebar {famName} session={data.session} {isParent} {role} /> <Sidebar {famName} session={data.session} {isParent} {role} {isDemo} />
<TopNav <TopNav
role={data.session ? role : ''} role={data.session ? role : ''}
seasons={famStore.seasons.filter((s) => s.active !== false)} seasons={famStore.seasons.filter((s) => s.active !== false)}
@@ -1,20 +1,28 @@
import { fail, redirect } from '@sveltejs/kit'; import { fail, redirect } from '@sveltejs/kit';
import { pbUser, createPbClient } from '$lib/server/pocketbase'; import { pbUser, createPbClient } from '$lib/server/pocketbase';
import { createServices } from '$lib/server/services'; import { createServices } from '$lib/server/services';
import { getPlatformFlags } from '$lib/server/platform';
import { pbAdmin } from '$lib/server/pocketbase';
export async function load(event) { export async function load(event) {
const session = event.locals.user; 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. // Parent (role=parent, session auth) → admin overview.
// Branch on role, NOT presence: a child who OTP-logged-in also has a // 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. // `users` session (locals.user) and must not hit the parent path.
if (session && session.role === 'parent') { if (session && session.role === 'parent' && !demoParentVisitingChild) {
const famId = session.famId; if (!(demoMode && isDemoParent)) {
const famSlug = event.params.fam;
const username = event.params.username;
if (session.name && session.username && session.username !== username) { if (session.name && session.username && session.username !== username) {
throw redirect(303, `/${famSlug}/${session.username}`); throw redirect(303, `/${famSlug}/${session.username}`);
} }
}
const s = createServices(pbUser(event), session); const s = createServices(pbUser(event), session);
const [ const [
members, members,
@@ -27,15 +35,15 @@ export async function load(event) {
completions, completions,
settings settings
] = await Promise.all([ ] = await Promise.all([
s.crud.list('members', famId), s.crud.list('members', session.famId),
s.crud.list('chore-templates', famId), s.crud.list('chore-templates', session.famId),
s.crud.list('assigned-chores', famId), s.crud.list('assigned-chores', session.famId),
s.fam.weeklySummary(famId), s.fam.weeklySummary(session.famId),
s.fam.get(famId), s.fam.get(session.famId),
s.crud.list('rewards', famId), s.crud.list('rewards', session.famId),
s.crud.list('bonus-configs', famId), s.crud.list('bonus-configs', session.famId),
s.crud.list('completions', famId), s.crud.list('completions', session.famId),
s.settings.get(famId) s.settings.get(session.famId)
]); ]);
return { return {
role: 'parent', role: 'parent',
@@ -52,18 +60,23 @@ export async function load(event) {
}; };
} }
// Child (users auth, role=child) → kanban // Child (users auth, role=child) → kanban.
const famSlug = event.params.fam; // Also demo parent visiting a child's route.
const username = event.params.username;
// A child should always land on their own kanban route. // A child should always land on their own kanban route.
if (!(demoMode && isDemoParent)) {
if (session?.username && session.username !== username) { if (session?.username && session.username !== username) {
throw redirect(303, `/${famSlug}/${session.username}`); throw redirect(303, `/${famSlug}/${session.username}`);
} }
}
const pbToken = event.cookies.get('pb_token') || ''; const pbToken = event.cookies.get('pb_token') || '';
const famId = session?.famId || ''; 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 = { const empty = {
role: 'child' as const, role: 'child' as const,
@@ -1,7 +1,16 @@
import { redirect } from '@sveltejs/kit'; import { redirect } from '@sveltejs/kit';
import { servicesFor } from '$lib/server/servicesFor'; import { servicesFor } from '$lib/server/servicesFor';
import { getPlatformFlags } from '$lib/server/platform';
async function isDemoMode(): Promise<boolean> {
return (await getPlatformFlags()).demo;
}
export async function load(event) { 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 session = event.locals.user;
const famSlug = event.params.fam; const famSlug = event.params.fam;
const username = event.params.username; const username = event.params.username;
@@ -7,6 +7,7 @@ import { issueAccess, createChild, inviteParent } from '$lib/server/member-otp';
import { sendParentInviteEmail } from '$lib/server/email'; import { sendParentInviteEmail } from '$lib/server/email';
import { slugify, handle, famUsername } from '@shared/slugify'; import { slugify, handle, famUsername } from '@shared/slugify';
import { applyAccessCode } from '$lib/server/access'; import { applyAccessCode } from '$lib/server/access';
import { getPlatformFlags } from '$lib/server/platform';
import { import {
createBillingPortalSession, createBillingPortalSession,
cancelSubscriptionAtPeriodEnd, cancelSubscriptionAtPeriodEnd,
@@ -18,7 +19,15 @@ function famIdOf(event: RequestEvent): string {
return event.locals.user.famId; return event.locals.user.famId;
} }
// In demo mode, settings is disabled for all users.
async function isDemoMode(): Promise<boolean> {
return (await getPlatformFlags()).demo;
}
export async function load(event: RequestEvent) { 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 famId = famIdOf(event);
const pb = pbUser(event); const pb = pbUser(event);
const [members, fam, seasons] = await Promise.all([ const [members, fam, seasons] = await Promise.all([
@@ -89,12 +89,15 @@ export const load: PageServerLoad = async (event) => {
pbAdmin.getList('bonus_templates', 'global = true') pbAdmin.getList('bonus_templates', 'global = true')
]); ]);
const platformFlags = await getPlatformFlags();
return { return {
authenticated: true, authenticated: true,
fams: famsWithStats, fams: famsWithStats,
codes: codeList, codes: codeList,
choreTemplates: choreTemplates as any[], choreTemplates: choreTemplates as any[],
bonusTemplates: bonusTemplates as any[], bonusTemplates: bonusTemplates as any[],
platformFlags,
totalFams: fams.length, totalFams: fams.length,
totalMembers: famsWithStats.reduce((s, f) => s + f.memberCount, 0), totalMembers: famsWithStats.reduce((s, f) => s + f.memberCount, 0),
totalRewards: famsWithStats.reduce((s, f) => s + f.totalRewards, 0), totalRewards: famsWithStats.reduce((s, f) => s + f.totalRewards, 0),
@@ -121,6 +124,7 @@ export const load: PageServerLoad = async (event) => {
codeCount: 0, codeCount: 0,
choreTemplates: [], choreTemplates: [],
bonusTemplates: [], bonusTemplates: [],
platformFlags: {},
loadError: e instanceof Error ? e.message : 'Failed to load platform data' loadError: e instanceof Error ? e.message : 'Failed to load platform data'
}; };
} }
+14
View File
@@ -188,6 +188,20 @@
</button> </button>
</form> </form>
</div> </div>
<div class="flag-row">
<span class="flag-name">demo</span>
<span class="flag-desc">Showboaters demo family with sample data for prospectus viewers</span>
<form method="POST" action="?/togglePlatformFlag" use:enhance>
<input type="hidden" name="flag" value="demo" />
<button
type="submit"
class="toggle-btn"
class:active={page.data.platformFlags?.demo}
>
{page.data.platformFlags?.demo ? 'ON' : 'OFF'}
</button>
</form>
</div>
<div class="flag-row"> <div class="flag-row">
<span class="flag-name">rewardsTargets</span> <span class="flag-name">rewardsTargets</span>
<span class="flag-desc">Multi-target reward rules (Competition / Collaboration)</span> <span class="flag-desc">Multi-target reward rules (Competition / Collaboration)</span>