604 lines
18 KiB
Svelte
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>
|