Files
famdone/frontend/src/routes/[fam]/+layout.svelte
T
2026-09-16 21:07:21 +01:00

604 lines
18 KiB
Svelte

<script lang="ts">
import { page } from '$app/state';
import { invalidateAll } from '$app/navigation';
import { onDestroy } 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, SharedPicker } from '$lib/components';
import { chatIcon } from '$lib/components/icons';
import { recordShortcut } from '$lib/shortcut';
import { installLockTracking, lockDue } from '$lib/client/lock';
import { sharedDevice } from '$lib/stores/shared-device.svelte';
import { themeShades } from '$lib/theme';
import '$lib/theme-patterns.css';
import { themeDraft } from '$lib/stores/theme.svelte';
import type { Session } from '$lib/types';
let { children, data } = $props();
// Background pattern ('' = none) tinted with the user's colour. A live
// draft (clicked in the picker, not yet saved) previews immediately;
// otherwise the saved value from the session applies.
let bgPattern = $derived(themeDraft.pattern ?? (data.session?.memberPattern || ''));
let bgColor = $derived(themeDraft.color ?? (data.session?.memberColor || '#6366f1'));
let bgOpacity = $derived(
themeDraft.opacity ??
(Number(data.session?.memberThemeOpacity) > 0 ? data.session?.memberThemeOpacity : 1)
);
let bgShades = $derived(themeShades(bgColor, Number(bgOpacity) || 1));
// Pattern size in vw units ('' = untouched → pattern class default).
let bgSize = $derived(themeDraft.size ?? (data.session?.memberThemeSize || ''));
let bgSizeVw = $derived(Number(bgSize) > 0 ? bgSize : '');
// Shared-device picker: standalone when the device has child sessions but
// none is active (layout load redirects here); overlay when the idle lock
// fires or the top-nav switcher is opened.
const pickerMode = $derived(!!data.picker);
let pickerOpen = $state(false);
let lockTimer: ReturnType<typeof setInterval> | null = null;
let deviceProfiles = $derived(
(data.deviceChildIds || [])
.map((id) => famStore.members.find((m) => m.id === id))
.filter(Boolean)
.map((m: any) => ({ id: m.id, name: m.name, color: m.color, username: m.username }))
);
// Idle lock (children on shared devices only): return to the picker after
// `lockMins` of no interaction. localStorage-backed (see lib/client/lock.ts)
// so a closed browser still trips the lock on next launch.
$effect(() => {
const isChild = data.session?.role === 'child';
const lockMins = Number(data.lockMins) || 0;
if (!isChild || !sharedDevice.on || lockMins <= 0 || data.picker) return;
installLockTracking();
if (lockDue(lockMins)) pickerOpen = true;
if (!lockTimer) {
lockTimer = setInterval(() => {
if (lockDue(lockMins)) pickerOpen = true;
}, 15_000);
}
return () => {
if (lockTimer) {
clearInterval(lockTimer);
lockTimer = null;
}
};
});
let session = $state<Session | null>(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
);
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 hasAuth = $derived(!!data.session);
let locked = $derived(disabled && !page.url.pathname.endsWith('/settings'));
// Expired-token zombie state: public PB rules still let logged-out users
// READ everything, so the dashboard looks alive while every write fails.
// Say so explicitly (join/switch/picker flows render their own UI).
let showLoggedOut = $derived(
!data.session &&
!pickerMode &&
!['join', 'switch'].includes(page.url.pathname.split('/')[2] || '')
);
// ── 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;
if (!disabled) {
notices.success('Welcome to FamDone!', '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);
// Client-only: touches history/notices — must not run during SSR
// (history.replaceState throws server-side → 500 on ?checkout=return).
$effect(() => {
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('');
let prevClaimedIds = new Set<string>();
let mounted = false;
// Persist a returning-user shortcut (localStorage) whenever an authenticated
// session exists. $effect (not onMount) so it also fires when the session
// appears after the layout was already mounted — e.g. a member redeeming
// their OTP on /{fam}/join/{username} then landing on /{fam}/{username}.
// The handle comes from the session (not page.params) so jump-back-in lands
// on the user's own dashboard even from the bare /{fam} page.
// The slug MUST come from the fam record — page.params.fam may be the PB
// id (login ID-fallback / stale link) when page.data.fam failed to load,
// and recording that poisons the shortcut (famSlug === famName === id).
$effect(() => {
if (!page.data.session) return;
const slug = page.data.fam?.slug as string;
if (!slug) return;
recordShortcut({
famSlug: slug,
famName: (page.data.fam?.name as string) || slug,
userName: page.data.session.username || page.params.username || ''
});
});
$effect(() => {
if (!isParent || !session || !famStore.initialized) return;
const rewards = famStore.rewards;
if (!mounted) {
mounted = true;
prevClaimedIds = new Set(
rewards.filter((r: any) => r.status === 'requested').map((r: any) => r.id)
);
return;
}
for (const r of rewards) {
if (r.status === 'requested' && !prevClaimedIds.has(r.id)) {
const m = famStore.memberMap().get(r.memberId);
const name = m?.name || 'Unknown';
claimToast = `Claim requested by ${name}`;
setTimeout(() => (claimToast = ''), 5000);
}
}
prevClaimedIds = new Set(
rewards.filter((r: any) => r.status === 'requested').map((r: any) => r.id)
);
});
// Client-only realtime wiring. Must be reactive (not onMount): after a
// member redeems their OTP on /{fam}/join/{username}, the 303 redirect is a
// client-side navigation that REUSES this layout instance, so onMount would
// never re-fire. Keying on data.famId ensures the PB client is re-seeded with
// the fresh token and famStore inits + subscribes the moment a session lands.
$effect(() => {
const famId = page.data.famId;
if (!famId) return;
initRealtimePb(page.data.pbToken || '');
famStore.init(famId, page.data.fam as any);
});
$effect(() => {
const chat = page.data.chat;
if (!chat?.famId || !chat?.actor) return;
initRealtimePb(page.data.pbToken || '');
chatStore.init({
famId: chat.famId,
actorId: chat.actor.id,
actorType: chat.actor.type,
actorName: chat.actor.name,
actorColor: chat.actor.color || '#6366f1',
pbToken: page.data.pbToken || '',
members: chat.members || []
});
});
</script>
<!-- layout.svelte picker-mode branch -->
{#if pickerMode}
<main class="picker-stage">
<SharedPicker
standalone
famSlug={data.famSlug}
famName={data.pickerFamName}
profiles={data.pickerChildren || []}
/>
</main>
{:else}
<div class="layout-stage" class:chat-open={chatStore.open} class:has-pattern={!!bgPattern}>
<div
class="app-shell {bgPattern ? `pattern-${bgPattern}` : ''}"
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} {isDemo} />
<TopNav
role={data.session ? role : ''}
seasons={famStore.seasons.filter((s) => s.active !== false)}
announcement={disabled && !activating ? accessMessage(accessReason, isParent) : ''}
connected={!famStore.connectionDown}
>
<button class="chat-toggle" onclick={() => chatStore.toggle()} aria-label="Open chat">
{@html chatIcon}
{#if chatStore.unread > 0}
<span class="chat-badge">{chatStore.unread > 9 ? '9+' : chatStore.unread}</span>
{/if}
</button>
{#if data.session && sharedDevice.on && (data.deviceChildIds?.length || 0) > 0}
<button
class="kid-switch"
onclick={() => (pickerOpen = true)}
aria-label="Switch user (shared device)"
title="Switch user"
>
<span class="kid-switch-dot" style="background:{data.session.memberColor || '#6366f1'}"
></span>
</button>
{/if}
</TopNav>
<main class="app-main" class:join-page={page.url.pathname.split('/')[2] === 'join'}>
<div class="page-wrap">
<div class="page-content" class:locked>{@render children()}</div>
{#if showLoggedOut}
<div class="disabled-overlay loggedout-overlay">
<div class="disabled-card">
<strong>You're logged out</strong>
<span>
Your session expired, so everything below is read-only — toggles and
claims won't work until you log back in.
</span>
<span class="loggedout-actions">
<a class="btn-primary" href="/login">Parent log in</a>
{#if page.params.username}
<a
class="btn-secondary"
href={`/${data.famSlug || page.params.fam}/join/${page.params.username}`}
>Rejoin as {page.params.username}</a
>
{/if}
</span>
<span class="hint">Kids: re-open your invite link, or ask a parent to send it again.</span>
</div>
</div>
{/if}
{#if locked}
<div class="disabled-overlay">
<div class="disabled-card">
{#if !hasAuth}
<h2>You need to be logged in to view this page</h2>
{:else 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 FamDone.'
: 'Your family access is paused.'}
</span>
{/if}
</div>
</div>
{/if}
</div>
</main>
{#if claimToast}
<a
class="claim-toast"
href={`/${page.params.fam}/${data.session?.username || page.params.username || ''}`}
>
{claimToast} — view dashboard →
</a>
{/if}
<Footer sidebar />
</div>
{#if chatStore.open}
<button class="chat-backdrop" onclick={() => chatStore.closeChat()} aria-label="Close chat"
></button>
{/if}
<Chat {role} />
{#if pickerOpen && deviceProfiles.length > 0}
<SharedPicker
profiles={deviceProfiles}
activeId={data.session?.userId}
famSlug={data.famSlug}
oncancel={() => (pickerOpen = false)}
/>
{/if}
{#if famStore.connectionDown}
<div class="conn-toast" role="status">Reconnecting… live updates paused</div>
{/if}
</div>
{/if}
<style>
.claim-toast {
position: fixed;
top: 52px;
left: 220px;
right: 0;
background: #f59e0b;
color: #fff;
padding: 0.5rem 1.5rem;
font-size: 0.85rem;
font-weight: 600;
z-index: 95;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.18);
text-decoration: none;
animation: fadein 0.2s;
}
.claim-toast:hover {
background: #d97706;
}
.conn-toast {
position: fixed;
bottom: 1rem;
left: 50%;
transform: translateX(-50%);
background: #1f2937;
color: #fff;
padding: 0.5rem 1.1rem;
border-radius: 999px;
font-size: 0.8rem;
font-weight: 600;
z-index: 95;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.3);
animation: fadein 0.2s;
}
@media (max-width: 1023px) {
.claim-toast {
left: 56px;
}
}
@media (max-width: 639px) {
.claim-toast {
left: 0;
padding: 0.5rem 1rem;
}
}
.app-shell {
min-height: 100vh;
display: flex;
flex-direction: column;
background: whitesmoke;
width: 100%;
transition: transform 0.25s ease;
}
/* Desktop: slide the whole app-shell left to reveal the 500px chat on the right. */
@media (min-width: 768px) {
.layout-stage.chat-open .app-shell {
transform: translateX(-500px);
}
}
/* Mobile: chat covers the screen; app stays put under a dimmed backdrop. */
.chat-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
border: none;
z-index: 105;
display: none;
}
@media (max-width: 767.98px) {
.chat-backdrop {
display: block;
}
}
.app-main {
margin-left: 220px;
margin-top: 52px;
padding: 1.5rem 2rem;
flex: 1;
transition: margin-left 0.2s;
}
@media (max-width: 1023px) {
.app-main {
margin-left: 56px;
}
}
@media (max-width: 639px) {
.app-main {
padding-left: 1rem;
padding-right: 1rem;
}
/* Join pages center their own content — no shell padding needed */
.app-main.join-page {
padding: 0;
}
}
.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;
}
/* Logged-out card must be clickable (the paused overlay is display-only). */
.loggedout-overlay {
pointer-events: auto;
}
.loggedout-actions {
display: flex;
gap: 0.6rem;
justify-content: center;
margin-top: 0.4rem;
}
.loggedout-actions a {
border-radius: 8px;
padding: 0.5rem 1.1rem;
font-size: 0.85rem;
font-weight: 700;
text-decoration: none;
}
.loggedout-actions .btn-primary {
background: #6366f1;
color: #fff;
}
.loggedout-actions .btn-secondary {
background: #eef2ff;
color: #4338ca;
}
.disabled-card .hint {
font-size: 0.78rem;
}
.chat-toggle {
position: relative;
width: 40px;
height: 40px;
border: none;
border-radius: 10px;
background: #f3f4f6;
color: #374151;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.kid-switch {
position: relative;
width: 40px;
height: 40px;
border: none;
border-radius: 10px;
background: #f3f4f6;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.kid-switch:hover {
background: #e5e7eb;
}
.kid-switch-dot {
width: 22px;
height: 22px;
border-radius: 50%;
border: 2px solid #fff;
box-shadow: 0 0 0 1px #e2e8f0;
}
.picker-stage {
min-height: 100vh;
width: 100%;
background: whitesmoke;
}
.chat-toggle:hover {
background: #e5e7eb;
color: #4338ca;
}
.chat-badge {
position: absolute;
top: -4px;
right: -4px;
background: #10b981;
color: #fff;
font-size: 0.7rem;
font-weight: 700;
min-width: 18px;
height: 18px;
border-radius: 999px;
display: flex;
align-items: center;
justify-content: center;
padding: 0 4px;
}
</style>