feature plus ux rearrangements
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { pbAdmin, createPbClient } from '$lib/server/pocketbase';
|
||||
import { createPbClient } from '$lib/server/pocketbase';
|
||||
import { createServices, type ChatActor } from '$lib/server/services';
|
||||
import { ensureFamAccess } from '$lib/server/access';
|
||||
|
||||
async function paydayCheck(famId: string, pbToken: string) {
|
||||
try {
|
||||
@@ -48,19 +49,30 @@ export async function load(event) {
|
||||
|
||||
let famId = '';
|
||||
let chat: { famId: string; actor: ChatActor } | null = null;
|
||||
let fam: any = null;
|
||||
let famAccess = { disabled: false, mode: 'none' as 'none' | 'code' | 'sub' | 'canceled', reason: '' };
|
||||
|
||||
if (session && pbToken) {
|
||||
famId = session.famId;
|
||||
await paydayCheck(famId, pbToken);
|
||||
chat = await resolveChatIdentity(session, pbToken);
|
||||
// fams is superadmin-only (non-realtime). Fetched server-side for both
|
||||
// roles; also recomputes + persists the derived `active` flag.
|
||||
const res = await ensureFamAccess(famId).catch(() => null);
|
||||
if (res) {
|
||||
fam = res.fam;
|
||||
famAccess = res.access;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// Canonical fam slug — from the URL param ([fam] routes). Client code
|
||||
// reads page.data.famSlug; never copy it into local $state.
|
||||
famSlug: event.params.fam || '',
|
||||
session: session
|
||||
? {
|
||||
famId: session.famId,
|
||||
userId: session.id,
|
||||
famSlug: event.params.fam,
|
||||
memberName: session.name,
|
||||
memberColor: session.color || '',
|
||||
role: session.role
|
||||
@@ -71,9 +83,7 @@ export async function load(event) {
|
||||
famId,
|
||||
chat,
|
||||
pbToken,
|
||||
// fams is superadmin-only (non-realtime). Fetched server-side for both roles.
|
||||
fam: famId
|
||||
? await pbAdmin.getOne('fams', famId).catch(() => null)
|
||||
: null
|
||||
fam,
|
||||
famAccess
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
Chart.register(...registerables);
|
||||
|
||||
let { data } = $props();
|
||||
let famSlug = $derived(page.params.fam);
|
||||
let famSlug = $derived(page.data.famSlug ?? page.params.fam);
|
||||
|
||||
let summary = $state(data.summary);
|
||||
let members = $state(data.members || []);
|
||||
|
||||
@@ -23,9 +23,13 @@
|
||||
let { data } = $props();
|
||||
|
||||
let role = $state(data.role || 'child');
|
||||
let famSlug = $derived(page.params.fam);
|
||||
let famSlug = $derived(page.data.famSlug ?? page.params.fam);
|
||||
let username = $derived(page.params.username);
|
||||
|
||||
// Access gate (frontend-only). When the fam is paused/disabled the member
|
||||
// kanban interactions are locked — toggles no-op and the board renders empty.
|
||||
let accessDisabled = $derived(!!page.data.famAccess?.disabled);
|
||||
|
||||
// ─── Family timezone (resolved) ───
|
||||
const rawFamTz = $derived(data.timezone || data.fam?.timezone || 'auto');
|
||||
const famTz = $derived(resolveTz(rawFamTz));
|
||||
@@ -521,6 +525,7 @@
|
||||
}
|
||||
|
||||
async function toggle(chore: AssignedChore) {
|
||||
if (accessDisabled) return;
|
||||
if (togglingIds) return;
|
||||
togglingIds = chore.id;
|
||||
|
||||
@@ -1122,6 +1127,22 @@ const res = await fetch('/api/members', {
|
||||
{/if}
|
||||
|
||||
<!-- KANBAN -->
|
||||
{#if accessDisabled}
|
||||
<div class="kanban locked">
|
||||
<div class="column col-daily">
|
||||
<h2>🎯 Daily</h2>
|
||||
<p class="empty">🔒 Access paused</p>
|
||||
</div>
|
||||
<div class="column col-weekly">
|
||||
<h2>📅 Weekly</h2>
|
||||
<p class="empty">🔒</p>
|
||||
</div>
|
||||
<div class="column col-done">
|
||||
<h2>✅ Done</h2>
|
||||
<p class="empty">🔒</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="kanban">
|
||||
<div class="column col-daily">
|
||||
<h2>🎯 Daily ({dailyPending.length})</h2>
|
||||
@@ -1205,6 +1226,7 @@ const res = await fetch('/api/members', {
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- WALLET / CLAIMS -->
|
||||
<div class="wallet">
|
||||
|
||||
@@ -86,7 +86,6 @@
|
||||
return map;
|
||||
});
|
||||
|
||||
const famId = $derived(page.params.fam);
|
||||
const username = $derived(page.params.username);
|
||||
|
||||
function memberName(id: string): string {
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { redirect, fail } from '@sveltejs/kit';
|
||||
import type { RequestEvent } from '@sveltejs/kit';
|
||||
import { pbUser } from '$lib/server/pocketbase';
|
||||
import { pbAdmin } from '$lib/server/pocketbase';
|
||||
import { servicesFor } from '$lib/server/servicesFor';
|
||||
import { issueAccess, createChild } from '$lib/server/member-otp';
|
||||
import { slugify } from '@shared/slugify';
|
||||
import { applyAccessCode } from '$lib/server/access';
|
||||
import { createBillingPortalSession, isDummyStripe } from '$lib/server/stripe';
|
||||
|
||||
function famIdOf(event: RequestEvent): string {
|
||||
if (!event.locals.user) throw redirect(303, '/login');
|
||||
@@ -21,7 +23,12 @@ export async function load(event: RequestEvent) {
|
||||
pbAdmin.getOne('fams', famId),
|
||||
pb.collection('seasons').getFullList({ filter: `famId = '${famId}'` })
|
||||
]);
|
||||
return { members, fam, seasons };
|
||||
// The applied code's duration backs the Access-card countdown (0 = never).
|
||||
let accessCode: any = null;
|
||||
if (fam?.paymentMode === 'code' && fam?.accessCodeId) {
|
||||
accessCode = await pbAdmin.getOne('accesscodes', fam.accessCodeId).catch(() => null);
|
||||
}
|
||||
return { members, fam, seasons, accessCode };
|
||||
}
|
||||
|
||||
export const actions = {
|
||||
@@ -147,5 +154,44 @@ export const actions = {
|
||||
} catch (e) {
|
||||
return { error: e instanceof Error ? e.message : 'Failed to generate data' };
|
||||
}
|
||||
},
|
||||
|
||||
applyCode: async (event: RequestEvent) => {
|
||||
const famId = famIdOf(event);
|
||||
const fd = await event.request.formData();
|
||||
const code = (fd.get('code') as string) || '';
|
||||
const result = await applyAccessCode(famId, code);
|
||||
return result.error ? { error: result.error } : { ok: true, ...result };
|
||||
},
|
||||
|
||||
// Revoke the applied access code — sets paymentMode back to 'none'.
|
||||
// No debug flag gate; the button is only visible when a code is applied.
|
||||
revokeCode: async (event: RequestEvent) => {
|
||||
const famId = famIdOf(event);
|
||||
await pbAdmin.update('fams', famId, {
|
||||
paymentMode: 'none',
|
||||
accessCodeId: '',
|
||||
accessCodeEnteredAt: ''
|
||||
});
|
||||
return { ok: true, revoked: true };
|
||||
},
|
||||
|
||||
// Open Stripe Customer Portal (cancel subscription, update payment method,
|
||||
// invoices). Real mode redirects; dummy mode returns the URL for the client.
|
||||
billingPortal: async (event: RequestEvent) => {
|
||||
const famId = famIdOf(event);
|
||||
const fam = await pbAdmin.getOne('fams', famId);
|
||||
if (!fam.stripeCustomerId) {
|
||||
return fail(400, { error: 'No Stripe customer linked yet. Start with a plan first.' });
|
||||
}
|
||||
try {
|
||||
const session = await createBillingPortalSession(fam.stripeCustomerId, event.url.origin, event.params.fam as string);
|
||||
if (isDummyStripe) return { success: true, portalUrl: session.url };
|
||||
if (!session.url) return fail(500, { error: 'Billing portal session has no URL' });
|
||||
throw redirect(303, session.url);
|
||||
} catch (e) {
|
||||
if (e instanceof redirect) throw e;
|
||||
return fail(500, { error: e instanceof Error ? e.message : 'Failed to open billing portal' });
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -2,18 +2,34 @@
|
||||
import { page } from '$app/state';
|
||||
import { enhance } from '$app/forms';
|
||||
import { famStore } from '$lib/stores/fam.svelte';
|
||||
import { ViewHeader, CardGrid, Card, Button } from '$lib/components';
|
||||
import { ViewHeader, CardGrid, Card, Button, Accordion, AccordionItem, NoticeDialog } from '$lib/components';
|
||||
import { COMMON_TIMEZONES } from '@shared/timezone';
|
||||
import { handleOf } from '@shared/slugify';
|
||||
import { addMonthsUTC, formatShortDate } from '$lib/format';
|
||||
import QRCode from 'qrcode';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
// fam is sensitive (stripeCustomerId, featureFlags) — never in the
|
||||
// fam is sensitive (stripeCustomerId, access fields) — never in the
|
||||
// public famStore stream. It is superadmin-only, fetched server-side by the
|
||||
// layout load. Writes go through form actions; no live fam subscription.
|
||||
let fam = $state(data.fam);
|
||||
let famSlug = $state(page.params.fam);
|
||||
// Derived so apply/revoke reflect immediately after the action round trip.
|
||||
let fam = $derived(data.fam);
|
||||
// Canonical slug from layout load — reactive, never copied into $state.
|
||||
let famSlug = $derived(page.data.famSlug || page.params.fam);
|
||||
|
||||
let accessCodeInput = $state('');
|
||||
let accessMsg = $state('');
|
||||
|
||||
let hasCode = $derived(fam?.paymentMode === 'code' && !!fam?.accessCodeId);
|
||||
let codeEntryDate = $derived(fam?.accessCodeEnteredAt ? new Date(fam.accessCodeEnteredAt) : null);
|
||||
|
||||
const modeLabel: Record<string, string> = {
|
||||
code: 'Access code',
|
||||
sub: 'Subscription',
|
||||
canceled: 'Canceled',
|
||||
none: 'No plan yet'
|
||||
};
|
||||
|
||||
let addName = $state('');
|
||||
let rename = $state('');
|
||||
@@ -21,23 +37,9 @@
|
||||
let payday = $state(fam?.payday != null ? Number(fam.payday) : 1);
|
||||
let paydayTime = $state(fam?.paydayTime || '18:00');
|
||||
let paydayTimes = $state([
|
||||
'06:00',
|
||||
'07:00',
|
||||
'08:00',
|
||||
'09:00',
|
||||
'10:00',
|
||||
'11:00',
|
||||
'12:00',
|
||||
'13:00',
|
||||
'14:00',
|
||||
'15:00',
|
||||
'16:00',
|
||||
'17:00',
|
||||
'18:00',
|
||||
'19:00',
|
||||
'20:00',
|
||||
'21:00',
|
||||
'22:00'
|
||||
'06:00', '07:00', '08:00', '09:00', '10:00', '11:00', '12:00',
|
||||
'13:00', '14:00', '15:00', '16:00', '17:00', '18:00', '19:00',
|
||||
'20:00', '21:00', '22:00'
|
||||
]);
|
||||
let timezone = $state(fam?.timezone || 'auto');
|
||||
let timezoneOptions = $state(COMMON_TIMEZONES);
|
||||
@@ -46,9 +48,7 @@
|
||||
if (typeof Intl !== 'undefined') {
|
||||
try {
|
||||
detectedTz = Intl.DateTimeFormat().resolvedOptions().timeZone || '';
|
||||
} catch {
|
||||
detectedTz = '';
|
||||
}
|
||||
} catch { detectedTz = ''; }
|
||||
}
|
||||
});
|
||||
let showQR = $state(false);
|
||||
@@ -60,12 +60,8 @@
|
||||
let deletingSeason = $state<any>(null);
|
||||
let issued = $state<{ otp: string; joinUrl: string; name: string } | null>(null);
|
||||
|
||||
let invitePath = $derived(
|
||||
issued ? `${issued.joinUrl}?code=${issued.otp}` : ''
|
||||
);
|
||||
let inviteUrl = $derived(
|
||||
issued ? `${page.url.origin}${invitePath}` : ''
|
||||
);
|
||||
let invitePath = $derived(issued ? `${issued.joinUrl}?code=${issued.otp}` : '');
|
||||
let inviteUrl = $derived(issued ? `${page.url.origin}${invitePath}` : '');
|
||||
|
||||
function copy(url: string) {
|
||||
navigator.clipboard.writeText(url);
|
||||
@@ -79,304 +75,368 @@
|
||||
|
||||
function toggleQR() {
|
||||
showQR = !showQR;
|
||||
if (!showQR) {
|
||||
qrDataUrl = '';
|
||||
} else {
|
||||
generateQR(inviteUrl);
|
||||
}
|
||||
if (!showQR) qrDataUrl = '';
|
||||
else generateQR(inviteUrl);
|
||||
}
|
||||
|
||||
function handleParentInvite() {
|
||||
alert('Parent invite coming soon — email would be sent to ' + parentInviteEmail);
|
||||
}
|
||||
|
||||
function formatCountdown(entryDate: Date | null, durationMonths: number): string {
|
||||
if (!entryDate) return '';
|
||||
if (durationMonths === 0) return 'Never expires';
|
||||
const expiry = addMonthsUTC(entryDate, durationMonths);
|
||||
const now = new Date();
|
||||
const diffMs = expiry.getTime() - now.getTime();
|
||||
if (diffMs <= 0) return 'Expired';
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
if (diffDays < 30) return `${diffDays} day${diffDays === 1 ? '' : 's'} left`;
|
||||
const months = Math.floor(diffDays / 30);
|
||||
const remDays = diffDays % 30;
|
||||
return `${months} month${months === 1 ? '' : 's'}${remDays ? ` ${remDays} day${remDays === 1 ? '' : 's'}` : ''} left`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<ViewHeader title="Settings" hero />
|
||||
|
||||
<CardGrid>
|
||||
<Card title="Family Name">
|
||||
<p class="hint">
|
||||
This is the name shown to your family. The address stays at
|
||||
<code class="slug-inline">/{famSlug}</code> even if you rename it — links you've shared keep
|
||||
working.
|
||||
</p>
|
||||
<form method="POST" action="?/renameFam" use:enhance>
|
||||
<label class="field-label" for="fam-name">Display name</label>
|
||||
<input id="fam-name" name="name" bind:value={rename} placeholder={fam?.name || 'Family name'} required />
|
||||
<Button type="submit" size="sm">Rename</Button>
|
||||
</form>
|
||||
{#if fam?.slug}
|
||||
<p class="hint slug-line">
|
||||
Family page: <code class="slug-inline">/{fam.slug}</code>
|
||||
</p>
|
||||
{/if}
|
||||
</Card>
|
||||
|
||||
<Card title="Members ({members.length})" cols={2}>
|
||||
<div class="members-grid">
|
||||
<div class="members-add">
|
||||
<p class="hint">Add a child. They'll pick their own colour after joining.</p>
|
||||
<form method="POST" action="?/addMember" use:enhance>
|
||||
<label class="field-label" for="new-child">New child</label>
|
||||
<input id="new-child" name="name" bind:value={addName} placeholder="Child name" required />
|
||||
<Button type="submit" size="sm">Add child</Button>
|
||||
<Accordion>
|
||||
<!-- Family -->
|
||||
<AccordionItem title="Family" open>
|
||||
<CardGrid>
|
||||
<Card title="Family Name">
|
||||
<p class="hint">
|
||||
This is the name shown to your family. The address stays at
|
||||
<code class="slug-inline">/{famSlug}</code> even if you rename it — links you've shared keep
|
||||
working.
|
||||
</p>
|
||||
<form method="POST" action="?/renameFam" use:enhance>
|
||||
<label class="field-label" for="fam-name">Display name</label>
|
||||
<input id="fam-name" name="name" bind:value={rename} placeholder={fam?.name || 'Family name'} required />
|
||||
<Button type="submit" size="sm">Rename</Button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<ul class="members-list">
|
||||
{#each members as m}
|
||||
<li>
|
||||
<span class="member-left">
|
||||
<span class="member-color" style="background:{m.color}"></span>
|
||||
<span class="member-info">
|
||||
<span class="member-name">{m.name}</span>
|
||||
<span class="member-handle">/{famSlug}/{handleOf(m.username)}</span>
|
||||
</span>
|
||||
</span>
|
||||
<span class="member-actions">
|
||||
<Button href="/{famSlug}/{handleOf(m.username)}" variant="secondary" size="sm">Preview</Button>
|
||||
<form method="POST" action="?/deleteMember" use:enhance class="inline">
|
||||
<input type="hidden" name="id" value={m.id} />
|
||||
<Button
|
||||
type="submit"
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onclick={() => confirm('Remove {m.name}?')}>Remove</Button
|
||||
>
|
||||
</form>
|
||||
</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="Payday" cols={1}>
|
||||
<form
|
||||
method="POST"
|
||||
action="?/updatePayday"
|
||||
use:enhance={() => {
|
||||
return async ({ result }) => {
|
||||
if (result.type === 'error') {
|
||||
alert(result.error || 'Failed to update payday');
|
||||
}
|
||||
// One-way: no invalidation — local state is already correct,
|
||||
// and the PB subscription handles cross-device sync.
|
||||
};
|
||||
}}
|
||||
class="payday-form"
|
||||
>
|
||||
<div class="payday-row">
|
||||
<label>Day</label>
|
||||
<select name="payday" bind:value={payday}>
|
||||
<option value={0}>Sunday</option>
|
||||
<option value={1}>Monday</option>
|
||||
<option value={2}>Tuesday</option>
|
||||
<option value={3}>Wednesday</option>
|
||||
<option value={4}>Thursday</option>
|
||||
<option value={5}>Friday</option>
|
||||
<option value={6}>Saturday</option>
|
||||
</select>
|
||||
<label>Time</label>
|
||||
<select name="paydayTime" bind:value={paydayTime}>
|
||||
{#each paydayTimes as t}
|
||||
<option value={t}>{t}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div class="payday-row">
|
||||
<label>Timezone</label>
|
||||
<select name="timezone" bind:value={timezone}>
|
||||
<option value="auto">{detectedTz ? `Auto (${detectedTz})` : 'Auto'}</option>
|
||||
{#each timezoneOptions as tz}
|
||||
<option value={tz}>{tz}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<Button type="submit" size="sm">Set payday</Button>
|
||||
</form>
|
||||
<p class="hint">
|
||||
Payday: the week starts on this day and weekly earnings are settled at this time. Auto timezone
|
||||
follows each device.
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
<Card title="Invite Children" cols={1}>
|
||||
<form
|
||||
method="POST"
|
||||
action="?/issueAccess"
|
||||
use:enhance={() => {
|
||||
return async ({ formData, result }) => {
|
||||
if (result.type === 'success' && result.data?.ok) {
|
||||
showQR = false;
|
||||
qrDataUrl = '';
|
||||
issued = {
|
||||
otp: result.data.otp,
|
||||
joinUrl: result.data.joinUrl,
|
||||
name: String(formData.get('name') || '')
|
||||
};
|
||||
} else if (result.type === 'success' && result.data?.error) {
|
||||
alert(result.data.error);
|
||||
}
|
||||
};
|
||||
}}
|
||||
class="invite-form"
|
||||
>
|
||||
<label class="field-label" for="invite-child">Child</label>
|
||||
<select id="invite-child" bind:value={inviteChild} name="name" required>
|
||||
<option value="">— Select a child —</option>
|
||||
{#each members as m}
|
||||
<option value={m.name}>{m.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<Button type="submit" size="sm" disabled={!inviteChild}>Issue code</Button>
|
||||
</form>
|
||||
<p class="hint">
|
||||
Generates a 6-digit code valid for 20 minutes. The child enters it at the join link.
|
||||
</p>
|
||||
|
||||
{#if issued?.otp}
|
||||
<div class="mt-3 rounded-lg border border-indigo-200 bg-indigo-50 p-4">
|
||||
<p class="text-xs text-slate-500">
|
||||
Code for {issued.name} (valid 20 min):
|
||||
</p>
|
||||
<p class="my-2 text-center text-4xl font-bold tracking-[0.3em] text-indigo-700">
|
||||
{issued.otp}
|
||||
</p>
|
||||
<p class="invite-url">{invitePath}</p>
|
||||
<div class="actions justify-center">
|
||||
<Button variant="secondary" size="sm" onclick={() => copy(inviteUrl)}>
|
||||
{copied ? 'Copied!' : 'Copy URL'}
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" onclick={toggleQR}>
|
||||
{showQR ? 'Hide QR' : 'Show QR'}
|
||||
</Button>
|
||||
</div>
|
||||
{#if showQR && qrDataUrl}
|
||||
<div class="qr-wrap">
|
||||
<img src={qrDataUrl} alt="QR Code" class="qr" />
|
||||
</div>
|
||||
{#if fam?.slug}
|
||||
<p class="hint slug-line">
|
||||
Family page: <code class="slug-inline">/{fam.slug}</code>
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</Card>
|
||||
</Card>
|
||||
|
||||
<Card title="Invite Parent" cols={1}>
|
||||
<p class="hint">Send an email invitation for another parent to join as an admin.</p>
|
||||
<div class="invite-form">
|
||||
<label class="field-label" for="parent-email">Parent email</label>
|
||||
<input id="parent-email" type="email" bind:value={parentInviteEmail} placeholder="parent@example.com" />
|
||||
<Button onclick={handleParentInvite} size="sm">Send invite</Button>
|
||||
</div>
|
||||
<p class="hint">They will set up their own password on first login.</p>
|
||||
</Card>
|
||||
|
||||
<Card title="Seasons" cols={1}>
|
||||
<p class="hint">Group chores into seasons. Toggle seasons on/off from the top nav.</p>
|
||||
|
||||
<form method="POST" action="?/createSeason" use:enhance class="season-form">
|
||||
<label class="field-label" for="season-name">New season</label>
|
||||
<input id="season-name" name="name" placeholder="Season name" required />
|
||||
<div class="color-row">
|
||||
<label for="season-color">Colour</label>
|
||||
<input id="season-color" name="color" type="color" value="#6366f1" class="color-input" />
|
||||
</div>
|
||||
<Button type="submit" size="sm">Add</Button>
|
||||
</form>
|
||||
|
||||
<ul>
|
||||
{#each data.seasons as s}
|
||||
<li>
|
||||
<span class="dot" style="background:{s.color}"></span>
|
||||
{s.name}
|
||||
<Button variant="danger" size="sm" onclick={() => (deletingSeason = s)}>Remove</Button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</Card>
|
||||
|
||||
<!-- Delete Season Modal -->
|
||||
{#if deletingSeason}
|
||||
<div class="overlay" onclick={() => (deletingSeason = null)} role="presentation">
|
||||
<div class="modal" onclick={(e) => e.stopPropagation()} role="dialog">
|
||||
<h3>Delete "{deletingSeason.name}"?</h3>
|
||||
<p class="warning">
|
||||
All chores assigned to this season will also be removed. This cannot be undone.
|
||||
</p>
|
||||
<Card title="Payday" cols={1}>
|
||||
<form
|
||||
method="POST"
|
||||
action="?/deleteSeason"
|
||||
action="?/updatePayday"
|
||||
use:enhance={() => {
|
||||
return async ({ result }) => {
|
||||
if (result.type === 'success') {
|
||||
deletingSeason = null;
|
||||
if (result.type === 'error') alert(result.error || 'Failed to update payday');
|
||||
};
|
||||
}}
|
||||
class="payday-form"
|
||||
>
|
||||
<div class="payday-row">
|
||||
<label>Day</label>
|
||||
<select name="payday" bind:value={payday}>
|
||||
<option value={0}>Sunday</option>
|
||||
<option value={1}>Monday</option>
|
||||
<option value={2}>Tuesday</option>
|
||||
<option value={3}>Wednesday</option>
|
||||
<option value={4}>Thursday</option>
|
||||
<option value={5}>Friday</option>
|
||||
<option value={6}>Saturday</option>
|
||||
</select>
|
||||
<label>Time</label>
|
||||
<select name="paydayTime" bind:value={paydayTime}>
|
||||
{#each paydayTimes as t}
|
||||
<option value={t}>{t}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div class="payday-row">
|
||||
<label>Timezone</label>
|
||||
<select name="timezone" bind:value={timezone}>
|
||||
<option value="auto">{detectedTz ? `Auto (${detectedTz})` : 'Auto'}</option>
|
||||
{#each timezoneOptions as tz}
|
||||
<option value={tz}>{tz}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<Button type="submit" size="sm">Set payday</Button>
|
||||
</form>
|
||||
<p class="hint">
|
||||
Payday: the week starts on this day and weekly earnings are settled at this time. Auto timezone
|
||||
follows each device.
|
||||
</p>
|
||||
</Card>
|
||||
<Card title="Seasons" cols={1}>
|
||||
<p class="hint">Group chores into seasons. Toggle seasons on/off from the top nav.</p>
|
||||
<form method="POST" action="?/createSeason" use:enhance class="season-form">
|
||||
<label class="field-label" for="season-name">New season</label>
|
||||
<input id="season-name" name="name" placeholder="Season name" required />
|
||||
<div class="color-row">
|
||||
<label for="season-color">Colour</label>
|
||||
<input id="season-color" name="color" type="color" value="#6366f1" class="color-input" />
|
||||
</div>
|
||||
<Button type="submit" size="sm">Add</Button>
|
||||
</form>
|
||||
|
||||
<ul>
|
||||
{#each data.seasons as s}
|
||||
<li>
|
||||
<span class="dot" style="background:{s.color}"></span>
|
||||
{s.name}
|
||||
<Button variant="danger" size="sm" onclick={() => (deletingSeason = s)}>Remove</Button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</Card>
|
||||
|
||||
<!-- Delete Season Modal -->
|
||||
{#if deletingSeason}
|
||||
<div class="overlay" onclick={() => (deletingSeason = null)} role="presentation">
|
||||
<div class="modal" onclick={(e) => e.stopPropagation()} role="dialog">
|
||||
<h3>Delete "{deletingSeason.name}"?</h3>
|
||||
<p class="warning">All chores assigned to this season will also be removed. This cannot be undone.</p>
|
||||
<form method="POST" action="?/deleteSeason" use:enhance={() => {
|
||||
return async ({ result }) => { if (result.type === 'success') deletingSeason = null; };
|
||||
}}>
|
||||
<input type="hidden" name="id" value={deletingSeason.id} />
|
||||
<div class="modal-actions">
|
||||
<button type="button" onclick={() => (deletingSeason = null)}>Cancel</button>
|
||||
<button type="submit" class="danger">Delete Season</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</CardGrid>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem title="Invites">
|
||||
<CardGrid>
|
||||
<Card title="Members ({members.length})" cols={2}>
|
||||
<div class="members-grid">
|
||||
<div class="members-add">
|
||||
<p class="hint">Add a child. They'll pick their own colour after joining.</p>
|
||||
<form method="POST" action="?/addMember" use:enhance>
|
||||
<label class="field-label" for="new-child">New child</label>
|
||||
<input id="new-child" name="name" bind:value={addName} placeholder="Child name" required />
|
||||
<Button type="submit" size="sm">Add child</Button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<ul class="members-list">
|
||||
{#each members as m}
|
||||
<li>
|
||||
<span class="member-left">
|
||||
<span class="member-color" style="background:{m.color}"></span>
|
||||
<span class="member-info">
|
||||
<span class="member-name">{m.name}</span>
|
||||
<span class="member-handle">/{famSlug}/{handleOf(m.username)}</span>
|
||||
</span>
|
||||
</span>
|
||||
<span class="member-actions">
|
||||
<Button href="/{famSlug}/{handleOf(m.username)}" variant="secondary" size="sm">Preview</Button>
|
||||
<form method="POST" action="?/deleteMember" use:enhance class="inline">
|
||||
<input type="hidden" name="id" value={m.id} />
|
||||
<Button type="submit" variant="danger" size="sm" onclick={() => confirm('Remove {m.name}?')}>Remove</Button>
|
||||
</form>
|
||||
</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="Invite Children" cols={1}>
|
||||
<form
|
||||
method="POST"
|
||||
action="?/issueAccess"
|
||||
use:enhance={() => {
|
||||
return async ({ formData, result }) => {
|
||||
if (result.type === 'success' && result.data?.ok) {
|
||||
showQR = false;
|
||||
qrDataUrl = '';
|
||||
issued = {
|
||||
otp: result.data.otp,
|
||||
joinUrl: result.data.joinUrl,
|
||||
name: String(formData.get('name') || '')
|
||||
};
|
||||
} else if (result.type === 'success' && result.data?.error) {
|
||||
alert(result.data.error);
|
||||
}
|
||||
};
|
||||
}}
|
||||
class="invite-form"
|
||||
>
|
||||
<input type="hidden" name="id" value={deletingSeason.id} />
|
||||
<div class="modal-actions">
|
||||
<button type="button" onclick={() => (deletingSeason = null)}>Cancel</button>
|
||||
<button type="submit" class="danger">Delete Season</button>
|
||||
<label class="field-label" for="invite-child">Child</label>
|
||||
<select id="invite-child" bind:value={inviteChild} name="name" required>
|
||||
<option value="">— Select a child —</option>
|
||||
{#each members as m}
|
||||
<option value={m.name}>{m.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<Button type="submit" size="sm" disabled={!inviteChild}>Issue code</Button>
|
||||
</form>
|
||||
<p class="hint">
|
||||
Generates a 6-digit code valid for 20 minutes. The child enters it at the join link.
|
||||
</p>
|
||||
|
||||
{#if issued?.otp}
|
||||
<div class="mt-3 rounded-lg border border-indigo-200 bg-indigo-50 p-4">
|
||||
<p class="text-xs text-slate-500">Code for {issued.name} (valid 20 min):</p>
|
||||
<p class="my-2 text-center text-4xl font-bold tracking-[0.3em] text-indigo-700">{issued.otp}</p>
|
||||
<p class="invite-url">{invitePath}</p>
|
||||
<div class="actions justify-center">
|
||||
<Button variant="secondary" size="sm" onclick={() => copy(inviteUrl)}>{copied ? 'Copied!' : 'Copy URL'}</Button>
|
||||
<Button variant="secondary" size="sm" onclick={toggleQR}>{showQR ? 'Hide QR' : 'Show QR'}</Button>
|
||||
</div>
|
||||
{#if showQR && qrDataUrl}
|
||||
<div class="qr-wrap"><img src={qrDataUrl} alt="QR Code" class="qr" /></div>
|
||||
{/if}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</Card>
|
||||
|
||||
<Card title="Billing & Subscription" cols={1}>
|
||||
<p class="hint">
|
||||
{data.fam?.active === false
|
||||
? 'Your subscription is paused — interactions and payments are disabled.'
|
||||
: 'Manage your plan, billing details, or pause your subscription.'}
|
||||
</p>
|
||||
<div class="actions">
|
||||
<Button href="/account" variant="primary" size="sm">Account & Billing</Button>
|
||||
<Button href="/subscriptions" variant="secondary" size="sm">Plans</Button>
|
||||
</div>
|
||||
</Card>
|
||||
<Card title="Invite Parent" cols={1}>
|
||||
<p class="hint">Send an email invitation for another parent to join as an admin.</p>
|
||||
<div class="invite-form">
|
||||
<label class="field-label" for="parent-email">Parent email</label>
|
||||
<input id="parent-email" type="email" bind:value={parentInviteEmail} placeholder="parent@example.com" />
|
||||
<Button onclick={handleParentInvite} size="sm">Send invite</Button>
|
||||
</div>
|
||||
<p class="hint">They will set up their own password on first login.</p>
|
||||
</Card>
|
||||
</CardGrid>
|
||||
</AccordionItem>
|
||||
|
||||
<Card title="Data" cols={1}>
|
||||
<div class="actions">
|
||||
<Button variant="ghost" size="sm" disabled>Download CSV (coming soon)</Button>
|
||||
<Button variant="danger" size="sm" disabled>Delete Family (coming soon)</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{#if data.fam?.featureFlags?.debugMode}
|
||||
<Card title="Debug Tools" cols={1} accent="#f59e0b">
|
||||
<p class="hint">Debug mode is enabled. These tools are for development and testing only.</p>
|
||||
<div class="actions">
|
||||
<form
|
||||
method="POST"
|
||||
action="?/completeWeek"
|
||||
use:enhance={() => {
|
||||
<AccordionItem title="Account">
|
||||
<CardGrid>
|
||||
<!-- Access -->
|
||||
<Card title="Access" cols={1} accent={hasCode ? '#059669' : '#dc2626'}>
|
||||
{#if hasCode}
|
||||
<p class="hint">
|
||||
Access active via access code
|
||||
{#if data.accessCode?.duration}
|
||||
— <strong>{formatCountdown(codeEntryDate, data.accessCode.duration)}</strong>
|
||||
(expires {formatShortDate(addMonthsUTC(codeEntryDate!, data.accessCode.duration))})
|
||||
{:else}
|
||||
— <strong>never expires</strong>
|
||||
{/if}.
|
||||
</p>
|
||||
<form
|
||||
class="revoke-form"
|
||||
method="POST"
|
||||
action="?/revokeCode"
|
||||
use:enhance={() => {
|
||||
return async ({ result, update }) => {
|
||||
if (result.type === 'success') accessMsg = 'Access code revoked.';
|
||||
else if (result.type === 'failure') accessMsg = (result.data as any)?.error || 'Revoke failed.';
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
>
|
||||
<Button type="submit" size="sm" variant="danger">Revoke code</Button>
|
||||
</form>
|
||||
{:else}
|
||||
<p class="hint">No access applied. Enter a valid access code to enable your family.</p>
|
||||
<form method="POST" action="?/applyCode" use:enhance={() => {
|
||||
return async ({ result, update }) => {
|
||||
if (result.type === 'success') alert('Week completed!');
|
||||
if (result.type === 'success' && result.data) {
|
||||
const d = result.data as { error?: string; ok?: boolean };
|
||||
accessMsg = d.error || 'Code applied — access enabled.';
|
||||
accessCodeInput = '';
|
||||
}
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
>
|
||||
<Button type="submit" size="sm" variant="secondary">Complete Week</Button>
|
||||
</form>
|
||||
<form
|
||||
method="POST"
|
||||
action="?/generateData"
|
||||
use:enhance={() => {
|
||||
return async ({ result, update }) => {
|
||||
if (result.type === 'success') alert('Test data generated!');
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="days" value="7" />
|
||||
<Button type="submit" size="sm" variant="secondary">Generate Test Data (7 days)</Button>
|
||||
</form>
|
||||
</div>
|
||||
</Card>
|
||||
{/if}
|
||||
</CardGrid>
|
||||
}}>
|
||||
<input name="code" bind:value={accessCodeInput} placeholder="Enter access code" autocomplete="off" />
|
||||
<Button type="submit" size="sm" variant="primary">Apply code</Button>
|
||||
</form>
|
||||
{/if}
|
||||
{#if accessMsg}
|
||||
<p class="access-msg">{accessMsg}</p>
|
||||
{/if}
|
||||
</Card>
|
||||
|
||||
<Card title="Subscription" cols={1} accent={fam?.paymentMode === 'sub' ? '#059669' : '#f59e0b'}>
|
||||
<p class="hint">
|
||||
{fam?.paymentMode === 'sub' ? 'Current plan: Subscription.' :
|
||||
fam?.paymentMode === 'code' ? `Current plan: Access code${hasCode ? '' : ' (invalid)'}.` :
|
||||
fam?.paymentMode === 'canceled' ? 'Your subscription was canceled.' : 'No active plan.'}
|
||||
</p>
|
||||
|
||||
{#if fam?.paymentMode === 'sub'}
|
||||
<div class="actions">
|
||||
<Button href="/pricing" variant="primary" size="sm">Change plan</Button>
|
||||
<form method="POST" action="?/billingPortal" use:enhance={() => {
|
||||
return async ({ result, update }) => {
|
||||
if (result.type === 'success' && (result.data as any)?.portalUrl) {
|
||||
window.open((result.data as any).portalUrl, '_blank');
|
||||
} else if (result.type === 'failure') {
|
||||
accessMsg = (result.data as any)?.error || 'Failed to open billing portal';
|
||||
}
|
||||
await update();
|
||||
};
|
||||
}}>
|
||||
<Button type="submit" variant="secondary" size="sm" disabled={!fam?.stripeCustomerId}>Open billing portal</Button>
|
||||
</form>
|
||||
</div>
|
||||
<p class="hint resume-hint">
|
||||
To pause your plan, cancel in the billing portal — you keep your data and can resubscribe
|
||||
anytime. Cancelling takes effect at period end.
|
||||
</p>
|
||||
{:else if fam?.paymentMode === 'code'}
|
||||
<div class="actions">
|
||||
<Button href="/pricing" variant="primary" size="sm">Switch to subscription</Button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="actions">
|
||||
<Button href="/pricing" variant="primary" size="sm">Choose a plan</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</Card>
|
||||
</CardGrid>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem title="App">
|
||||
<CardGrid>
|
||||
{#if page.data.platformFlags?.debug}
|
||||
<Card title="Debug Tools" cols={1} accent="#f59e0b">
|
||||
<p class="hint">Debug mode is enabled. These tools are for development and testing only.</p>
|
||||
<div class="actions">
|
||||
<form method="POST" action="?/completeWeek" use:enhance={() => {
|
||||
return async ({ result, update }) => {
|
||||
if (result.type === 'success') alert('Week completed!');
|
||||
await update();
|
||||
};
|
||||
}}>
|
||||
<Button type="submit" size="sm" variant="secondary">Complete Week</Button>
|
||||
</form>
|
||||
<form method="POST" action="?/generateData" use:enhance={() => {
|
||||
return async ({ result, update }) => {
|
||||
if (result.type === 'success') alert('Test data generated!');
|
||||
await update();
|
||||
};
|
||||
}}>
|
||||
<input type="hidden" name="days" value="7" />
|
||||
<Button type="submit" size="sm" variant="secondary">Generate Test Data (7 days)</Button>
|
||||
</form>
|
||||
</div>
|
||||
</Card>
|
||||
{/if}
|
||||
<Card title="Data" cols={1}>
|
||||
<div class="actions">
|
||||
<Button variant="ghost" size="sm" disabled>Download CSV (coming soon)</Button>
|
||||
<Button variant="danger" size="sm" disabled>Delete Family (coming soon)</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</CardGrid>
|
||||
</AccordionItem>
|
||||
|
||||
<!-- Invites -->
|
||||
</Accordion>
|
||||
|
||||
<NoticeDialog />
|
||||
<style>
|
||||
.hint {
|
||||
font-size: 0.85rem;
|
||||
@@ -698,4 +758,21 @@
|
||||
border-radius: 6px;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.access-msg {
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
color: #059669;
|
||||
}
|
||||
.revoke-form {
|
||||
margin-top: 0.6rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
.revoke-form .hint {
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
.resume-hint {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { fail, redirect } from '@sveltejs/kit';
|
||||
import { redeemOtp } from '$lib/server/member-otp';
|
||||
import { setSessionCookie, clearLegacyCookies } from '$lib/server/session';
|
||||
|
||||
export const actions = {
|
||||
default: async (event) => {
|
||||
const fam = event.params.fam;
|
||||
const username = event.params.username;
|
||||
const fd = await event.request.formData();
|
||||
const otp = (fd.get('otp') || '').toString().trim();
|
||||
|
||||
if (!otp) return fail(400, { error: 'Enter the code shown by your parent.' });
|
||||
|
||||
try {
|
||||
const token = await redeemOtp({ famSlug: fam, username, otp });
|
||||
clearLegacyCookies(event.cookies);
|
||||
setSessionCookie(event.cookies, token);
|
||||
} catch (e) {
|
||||
return fail(400, { error: e instanceof Error ? e.message : 'Join failed' });
|
||||
}
|
||||
|
||||
throw redirect(303, `/${fam}/${encodeURIComponent(username)}`);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { enhance } from '$app/forms';
|
||||
import { Button } from '$lib/components';
|
||||
|
||||
const famSlug = page.params.fam;
|
||||
const username = page.params.username;
|
||||
let otp = $state(page.url.searchParams.get('code') || '');
|
||||
let { form } = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Join {famSlug}</title></svelte:head>
|
||||
|
||||
<main class="mx-auto flex min-h-screen max-w-md flex-col items-center justify-center px-6">
|
||||
<section class="w-full rounded-2xl border border-slate-200 bg-white p-8 text-center shadow-sm">
|
||||
<div class="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-indigo-100 text-2xl">
|
||||
🏠
|
||||
</div>
|
||||
<h1 class="text-xl font-bold text-slate-900">Welcome to {famSlug}!</h1>
|
||||
<p class="mt-1 text-sm text-slate-500">
|
||||
Hi <span class="font-semibold text-slate-700">{username}</span> — enter the code your parent
|
||||
gave you to get started.
|
||||
</p>
|
||||
|
||||
<form class="mt-6 flex flex-col gap-3" method="POST" use:enhance={() => ({})}>
|
||||
<input
|
||||
type="text"
|
||||
name="otp"
|
||||
bind:value={otp}
|
||||
inputmode="numeric"
|
||||
maxlength="6"
|
||||
placeholder="6-digit code"
|
||||
autocomplete="one-time-code"
|
||||
class="w-full rounded-lg border border-slate-300 px-4 py-3 text-center text-2xl tracking-[0.5em] text-slate-900 outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-200"
|
||||
/>
|
||||
{#if form?.error}
|
||||
<p class="text-sm font-medium text-rose-600">{form.error}</p>
|
||||
{/if}
|
||||
<Button type="submit" variant="primary" size="lg">Join</Button>
|
||||
</form>
|
||||
|
||||
<p class="mt-6 text-xs text-slate-400">
|
||||
Code is valid for 20 minutes. Ask your parent for a new one if it expires.
|
||||
</p>
|
||||
</section>
|
||||
</main>
|
||||
Reference in New Issue
Block a user