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

371 lines
10 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 } from '$lib/components';
import { chatIcon } from '$lib/components/icons';
import { recordShortcut } from '$lib/shortcut';
import type { Session } from '$lib/types';
let { children, data } = $props();
let session = $state<Session | null>(data.session);
let isParent = $state(data.isParent);
let role = $state(data.role);
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'));
// ── 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}.
$effect(() => {
if (!page.data.session) return;
recordShortcut({
famSlug: page.params.fam || '',
famName: (page.data.fam?.name as string) || page.params.fam || '',
userName: page.data.role === 'parent' ? '' : page.params.username || ''
});
});
$effect(() => {
if (!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 || ''
});
});
</script>
<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.filter((s) => s.active !== false)}
announcement={disabled && !activating ? accessMessage(accessReason, isParent) : ''}
>
<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>
</TopNav>
<main class="app-main">
<div class="page-wrap">
<div class="page-content" class:locked>{@render children()}</div>
{#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}
<div class="claim-toast">{claimToast}</div>
{/if}
<Footer sidebar />
</div>
{#if chatStore.open}
<button class="chat-backdrop" onclick={() => chatStore.closeChat()} aria-label="Close chat"
></button>
{/if}
<Chat {role} />
</div>
<style>
.claim-toast {
position: fixed;
top: 3.5rem;
right: 1.5rem;
background: #059669;
color: white;
padding: 0.6rem 1rem;
border-radius: 8px;
font-size: 0.85rem;
z-index: 999;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
animation: fadein 0.2s;
}
.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;
}
}
.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;
height: 40px;
border: none;
border-radius: 10px;
background: #f3f4f6;
color: #374151;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.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>