feature plus ux rearrangements

This commit is contained in:
JCEEE
2026-08-22 19:49:46 +01:00
parent f14f4e2ac1
commit 42b9a27ce6
44 changed files with 2020 additions and 538 deletions
+160 -4
View File
@@ -1,9 +1,11 @@
<script lang="ts">
import { page } from '$app/state';
import { onMount } from 'svelte';
import { initRealtimePb } from '$lib/pocketbase';
import { invalidateAll } from '$app/navigation';
import { onDestroy, onMount } 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 type { Session } from '$lib/types';
@@ -15,6 +17,82 @@
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 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;
history.replaceState({}, '', page.url.pathname); // cosmetic only
if (!disabled) {
notices.success('Welcome to FamChore!', '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);
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('');
@@ -64,7 +142,11 @@
<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}>
<TopNav
{role}
seasons={famStore.seasons}
announcement={disabled && !activating ? accessMessage(accessReason, isParent) : ''}
>
<button class="chat-toggle" onclick={() => chatStore.toggle()} aria-label="Open chat">
{@html chatIcon}
{#if chatStore.unread > 0}
@@ -73,7 +155,27 @@
</button>
</TopNav>
<main class="app-main">
{@render children()}
<div class="page-wrap">
<div class="page-content" class:locked>{@render children()}</div>
{#if locked}
<div class="disabled-overlay">
<div class="disabled-card">
{#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 FamChore.'
: 'Your family access is paused.'}
</span>
{/if}
</div>
</div>
{/if}
</div>
</main>
{#if claimToast}
<div class="claim-toast">{claimToast}</div>
@@ -136,6 +238,60 @@
flex: 1;
transition: margin-left 0.2s;
}
.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;