feature plus ux rearrangements
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user