Files
famdone/frontend/src/routes/[fam]/[username]/settings/+page.svelte
T
2026-08-25 07:57:50 +01:00

910 lines
24 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { page } from '$app/state';
import { enhance } from '$app/forms';
import { handleResult } from '$lib/forms';
import { notices } from '$lib/stores/notices.svelte';
import { famStore } from '$lib/stores/fam.svelte';
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, 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.
// 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('');
let inviteChild = $state('');
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'
]);
let timezone = $state(fam?.timezone || 'auto');
let timezoneOptions = $state(COMMON_TIMEZONES);
let detectedTz = $state('');
$effect(() => {
if (typeof Intl !== 'undefined') {
try {
detectedTz = Intl.DateTimeFormat().resolvedOptions().timeZone || '';
} catch {
detectedTz = '';
}
}
});
let showQR = $state(false);
let qrDataUrl = $state('');
let copied = $state(false);
let parentInviteEmail = $state('');
let members = $derived(famStore.initialized ? famStore.members : (data.members || []));
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}` : '');
function copy(url: string) {
navigator.clipboard.writeText(url);
copied = true;
setTimeout(() => (copied = false), 2000);
}
async function generateQR(url: string) {
qrDataUrl = await QRCode.toDataURL(url, { width: 200, margin: 1 });
}
function toggleQR() {
showQR = !showQR;
if (!showQR) qrDataUrl = '';
else generateQR(inviteUrl);
}
function handleParentInvite() {
alert('Parent invite coming soon — email would be sent to ' + parentInviteEmail);
}
function timeUntil(iso: string): string {
const diffMs = new Date(iso).getTime() - Date.now();
if (diffMs <= 0) return 'expired';
const days = Math.floor(diffMs / 864e5);
if (days < 1) return 'less than a day';
if (days < 31) return `${days} day${days === 1 ? '' : 's'}`;
const months = Math.floor(days / 30.44);
return `about ${months} month${months === 1 ? '' : 's'}`;
}
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 />
<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>
{#if fam?.slug}
<p class="hint slug-line">
Family page: <code class="slug-inline">/{fam.slug}</code>
</p>
{/if}
</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');
};
}}
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"
>
<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>
{/if}
</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>
<AccordionItem title="Account">
<CardGrid>
<!-- Access (codes) only relevant when NOT on a subscription -->
{#if fam?.paymentMode !== 'sub'}
<!-- 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' && result.data) {
const d = result.data as { error?: string; ok?: boolean };
accessMsg = d.error || 'Code applied — access enabled.';
accessCodeInput = '';
}
await update();
};
}}
>
<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>
{/if}
<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' && data.subStatus?.cancelAtPeriodEnd}
<!-- Cancel requested: no CTA — just the countdown to expiry -->
<p class="hint">
<strong>No active subscription.</strong> You have
<strong>{timeUntil(data.subStatus.endsAt)}</strong> of access left
(until {formatShortDate(String(data.subStatus.endsAt))}). No further charges.
</p>
{:else if fam?.paymentMode === 'sub'}
<div class="actions">
<form
method="POST"
action="?/endSubscription"
use:enhance={() => {
return async ({ result }) => {
if (result.type === 'success' && (result.data as any)?.endsAt) {
notices.success(
'Subscription ending',
`Ends ${formatShortDate(String((result.data as any).endsAt))} — access continues until then, no further charges.`
);
} else if (result.type === 'failure') {
notices.error(
'Couldn’t end subscription',
(result.data as any)?.error || 'Please try again.'
);
}
};
}}
>
<Button type="submit" variant="danger" size="sm">End subscription</Button>
</form>
</div>
<p class="hint resume-hint">
Ending stops future charges at the close of the paid period — your data is kept and you
can resubscribe anytime. To switch plans, end this subscription first, then pick a new
one from Plans.
</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;
color: #9ca3af;
line-height: 1.4;
}
.invite-url {
font-family: monospace;
font-size: 0.85rem;
word-break: break-all;
background: #fff;
border: 1px solid #e5e7eb;
padding: 0.5rem 0.6rem;
border-radius: 8px;
}
/* ── Forms: full-width, balanced fields ── */
form {
display: flex;
flex-direction: column;
gap: 0.6rem;
margin-bottom: 1rem;
}
form.inline {
display: inline-flex;
flex-direction: row;
align-items: center;
gap: 0.5rem;
margin: 0;
}
.field-label {
font-size: 0.8rem;
font-weight: 500;
color: #6b7280;
}
input,
select {
width: 100%;
padding: 0.55rem 0.75rem;
border: 1px solid #d1d5db;
border-radius: 8px;
background: #fff;
font-size: 0.9rem;
}
input:focus,
select:focus {
outline: 2px solid #6366f1;
outline-offset: -1px;
border-color: #6366f1;
}
/* Buttons are rendered by <Button> (scoped in its own component) — reach
them with :global so primary CTAs stretch to full width. */
form :global(.btn) {
width: 100%;
}
form.inline :global(.btn) {
width: auto;
}
.invite-form {
display: flex;
flex-direction: column;
gap: 0.6rem;
margin-bottom: 1rem;
}
.invite-form :global(.btn) {
width: 100%;
}
/* ── Payday ── */
.payday-row {
display: flex;
gap: 0.5rem;
align-items: center;
flex-wrap: wrap;
}
.payday-row select {
flex: 1;
min-width: 0;
max-width: 100%;
}
.payday-row label {
font-size: 0.8rem;
color: #6b7280;
flex-shrink: 0;
font-weight: 500;
}
.payday-row + .payday-row {
margin-top: 0.5rem;
}
/* ── Colour rows (seasons) ── */
.color-row {
display: flex;
align-items: center;
gap: 0.75rem;
}
.color-row label {
font-size: 0.8rem;
font-weight: 500;
color: #6b7280;
flex-shrink: 0;
}
.color-input {
width: 100%;
max-width: 160px;
height: 36px;
padding: 2px;
border: 1px solid #d1d5db;
border-radius: 8px;
cursor: pointer;
}
/* ── Lists ── */
ul {
list-style: none;
padding: 0;
margin: 0;
}
li {
padding: 0.45rem 0;
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
justify-content: space-between;
border-bottom: 1px solid #f3f4f6;
}
li:last-child {
border-bottom: none;
}
.dot {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.link {
font-size: 0.85rem;
color: #6366f1;
}
.slug-inline {
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 0.85em;
background: #f3f4f6;
border-radius: 4px;
padding: 0.1em 0.35em;
color: #374151;
}
.slug-line {
margin-top: 0.75rem;
}
/* ── Members: two-column (add | list) ── */
.members-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.25rem;
align-items: start;
}
.members-add form {
margin-bottom: 0;
}
.members-list {
border-left: 1px solid #f3f4f6;
padding-left: 1.25rem;
}
.member-left,
.member-actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
.member-color {
width: 22px;
height: 22px;
border-radius: 50%;
flex-shrink: 0;
border: 2px solid #fff;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.12);
}
.member-name {
font-weight: 500;
color: #374151;
}
.member-info {
display: flex;
flex-direction: column;
line-height: 1.25;
}
.member-handle {
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 0.72rem;
color: #9ca3af;
}
.member-actions :global(.btn) {
flex: none;
width: auto;
}
@container (max-width: 380px) {
.members-grid {
grid-template-columns: 1fr;
}
.members-list {
border-left: none;
padding-left: 0;
border-top: 1px solid #f3f4f6;
padding-top: 0.5rem;
}
}
/* ── Action rows ── */
.actions {
display: flex;
gap: 0.5rem;
margin-top: 0.75rem;
flex-wrap: wrap;
}
.actions.justify-center {
justify-content: center;
}
.actions :global(.btn) {
flex: 1;
}
.qr-wrap {
display: flex;
justify-content: center;
margin-top: 0.5rem;
}
.qr {
border: 1px solid #e5e7eb;
border-radius: 8px;
}
/* ── Narrow cards: stack rows / actions full width (container query) ── */
@container (max-width: 380px) {
.payday-row {
flex-direction: column;
align-items: stretch;
}
.payday-row select {
width: 100%;
}
.color-row {
align-items: stretch;
}
.actions {
flex-direction: column;
}
.actions :global(.btn) {
flex: none;
width: 100%;
}
}
/* ── Overlay / modal (plain buttons live in this template) ── */
button {
background: #6366f1;
color: white;
border: none;
cursor: pointer;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.danger {
background: #dc2626;
font-size: 0.8rem;
padding: 0.2rem 0.5rem;
}
.overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
}
.modal {
background: white;
border-radius: 12px;
padding: 1.5rem;
width: 100%;
max-width: 440px;
margin: 0 1rem;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.15);
}
.modal h3 {
margin: 0 0 0.75rem;
}
.modal-actions {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
margin-top: 1rem;
}
.modal-actions button {
padding: 0.5rem 1rem;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 0.9rem;
}
.modal-actions button[type='button'] {
background: #e5e7eb;
color: #374151;
}
.modal-actions button[type='submit'] {
background: #dc2626;
color: white;
}
.warning {
color: #dc2626;
font-size: 0.9rem;
background: #fef2f2;
padding: 0.6rem;
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>