final flows including emails for passwords
This commit is contained in:
@@ -22,7 +22,7 @@
|
||||
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')) || !hasAuth);
|
||||
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
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
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 { pbAdmin, createSuperClient } from '$lib/server/pocketbase';
|
||||
import { servicesFor } from '$lib/server/servicesFor';
|
||||
import { issueAccess, createChild } from '$lib/server/member-otp';
|
||||
import { slugify } from '@shared/slugify';
|
||||
import { issueAccess, createChild, inviteParent } from '$lib/server/member-otp';
|
||||
import { sendParentInviteEmail } from '$lib/server/email';
|
||||
import { slugify, handle, famUsername } from '@shared/slugify';
|
||||
import { applyAccessCode } from '$lib/server/access';
|
||||
import { createBillingPortalSession, cancelSubscriptionAtPeriodEnd, getSubscriptionStatus } from '$lib/server/stripe';
|
||||
import {
|
||||
createBillingPortalSession,
|
||||
cancelSubscriptionAtPeriodEnd,
|
||||
getSubscriptionStatus
|
||||
} from '$lib/server/stripe';
|
||||
|
||||
function famIdOf(event: RequestEvent): string {
|
||||
if (!event.locals.user) throw redirect(303, '/login');
|
||||
@@ -67,6 +72,64 @@ export const actions = {
|
||||
}
|
||||
},
|
||||
|
||||
inviteParent: async (event: RequestEvent) => {
|
||||
const famId = famIdOf(event);
|
||||
const famSlug: string = event.params.fam as string;
|
||||
const fd = await event.request.formData();
|
||||
const name = (fd.get('name') || '').toString().trim();
|
||||
const email = (fd.get('email') || '').toString().trim().toLowerCase();
|
||||
if (!name || !email) return { error: 'Name and email required' };
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return { error: 'Invalid email' };
|
||||
try {
|
||||
const { otp, joinUrl } = await inviteParent({ famId, famSlug, name, email });
|
||||
const fam = await pbAdmin.getOne('fams', famId);
|
||||
await sendParentInviteEmail({
|
||||
to: email,
|
||||
inviteLink: `${event.url.origin}${joinUrl}?code=${otp}`,
|
||||
famName: fam?.name || famSlug,
|
||||
otp
|
||||
});
|
||||
return { ok: true };
|
||||
} catch (e) {
|
||||
return { error: e instanceof Error ? e.message : 'Failed to invite parent' };
|
||||
}
|
||||
},
|
||||
|
||||
currentAccess: async (event: RequestEvent) => {
|
||||
const famId = famIdOf(event);
|
||||
const famSlug: string = event.params.fam as string;
|
||||
const fd = await event.request.formData();
|
||||
const name = (fd.get('name') || '').toString().trim();
|
||||
if (!name) return { error: 'Select a child to reveal' };
|
||||
try {
|
||||
const pb = await createSuperClient();
|
||||
const username = famUsername(famSlug, handle(name));
|
||||
const child = await pb
|
||||
.collection('users')
|
||||
.getFirstListItem(`famId='${famId}' && username='${username}'`)
|
||||
.catch(() => null);
|
||||
if (!child) return { error: 'No such child' };
|
||||
const otpRec = await pb
|
||||
.collection('otp')
|
||||
.getFirstListItem(`famId='${famId}' && userId='${child.id}'`)
|
||||
.catch(() => null);
|
||||
if (!otpRec?.otp) return { error: 'No active code yet — issue one first' };
|
||||
const ageMs = Date.now() - new Date(otpRec.updatedAt).getTime();
|
||||
if (ageMs > 20 * 60 * 1000) {
|
||||
return { error: 'Current code has expired — issue a new one' };
|
||||
}
|
||||
const handleName = handle(name);
|
||||
return {
|
||||
ok: true,
|
||||
otp: otpRec.otp as string,
|
||||
joinUrl: `/${famSlug}/join/${encodeURIComponent(handleName)}`,
|
||||
name
|
||||
};
|
||||
} catch (e) {
|
||||
return { error: e instanceof Error ? e.message : 'Failed to reveal current code' };
|
||||
}
|
||||
},
|
||||
|
||||
renameFam: async (event: RequestEvent) => {
|
||||
const famId = famIdOf(event);
|
||||
const fd = await event.request.formData();
|
||||
@@ -144,9 +207,7 @@ export const actions = {
|
||||
const id = fd.get('id') as string;
|
||||
const active = fd.get('active') === '1';
|
||||
if (!id) return { error: 'Season ID required' };
|
||||
await pbUser(event)
|
||||
.collection('seasons')
|
||||
.update(id, { active });
|
||||
await pbUser(event).collection('seasons').update(id, { active });
|
||||
return { ok: true };
|
||||
},
|
||||
|
||||
|
||||
@@ -17,6 +17,10 @@
|
||||
import { handleOf } from '@shared/slugify';
|
||||
import { addMonthsUTC, formatShortDate } from '$lib/format';
|
||||
import QRCode from 'qrcode';
|
||||
import House from '@lucide/svelte/icons/house';
|
||||
import UserPlus from '@lucide/svelte/icons/user-plus';
|
||||
import Shield from '@lucide/svelte/icons/shield';
|
||||
import Smartphone from '@lucide/svelte/icons/smartphone';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
@@ -77,14 +81,25 @@
|
||||
}
|
||||
}
|
||||
});
|
||||
let showQR = $state(false);
|
||||
let qrDataUrl = $state('');
|
||||
let copied = $state(false);
|
||||
let parentInviteName = $state('');
|
||||
let parentInviteEmail = $state('');
|
||||
|
||||
let members = $derived(famStore.initialized ? famStore.members : (data.members || []));
|
||||
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 issueModal = $state<{ otp: string; joinUrl: string; name: string } | null>(null);
|
||||
let seasonColor = $state('#6366f1');
|
||||
const seasonColors = [
|
||||
'#6366f1',
|
||||
'#ec4899',
|
||||
'#f59e0b',
|
||||
'#10b981',
|
||||
'#3b82f6',
|
||||
'#ef4444',
|
||||
'#8b5cf6',
|
||||
'#14b8a6'
|
||||
];
|
||||
|
||||
// Optimistic season active toggle: flip immediately in the shared famStore
|
||||
// (which drives both this list and the TopNav), then persist via the
|
||||
@@ -108,8 +123,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
let invitePath = $derived(issued ? `${issued.joinUrl}?code=${issued.otp}` : '');
|
||||
let inviteUrl = $derived(issued ? `${page.url.origin}${invitePath}` : '');
|
||||
let invitePath = $derived(issueModal ? `${issueModal.joinUrl}?code=${issueModal.otp}` : '');
|
||||
|
||||
function copy(url: string) {
|
||||
navigator.clipboard.writeText(url);
|
||||
@@ -117,20 +131,38 @@
|
||||
setTimeout(() => (copied = false), 2000);
|
||||
}
|
||||
|
||||
function openIssueModal(data: { otp: string; joinUrl: string; name: string }) {
|
||||
issueModal = data;
|
||||
qrDataUrl = '';
|
||||
generateQR(`${page.url.origin}${data.joinUrl}?code=${data.otp}`);
|
||||
}
|
||||
|
||||
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 handleInviteResult(result: any) {
|
||||
if (result.type === 'success') {
|
||||
const d = result.data as { ok?: boolean; error?: string } | undefined;
|
||||
if (d?.ok) {
|
||||
notices.success(
|
||||
'Invite sent',
|
||||
`Email sent to ${parentInviteEmail} with a join link and code.`
|
||||
);
|
||||
parentInviteName = '';
|
||||
parentInviteEmail = '';
|
||||
} else if (d?.error) {
|
||||
notices.error('Could not invite parent', d.error);
|
||||
}
|
||||
} else if (result.type === 'failure') {
|
||||
notices.error('Could not invite parent', (result.data as any)?.error || 'Please try again.');
|
||||
}
|
||||
}
|
||||
|
||||
function timeUntil(iso: string): string {
|
||||
const diffMs = new Date(iso).getTime() - Date.now();
|
||||
if (diffMs <= 0) return 'expired';
|
||||
@@ -158,9 +190,14 @@
|
||||
|
||||
<ViewHeader title="Settings" hero />
|
||||
|
||||
{#snippet famIcon()}<House />{/snippet}
|
||||
{#snippet inviteIcon()}<UserPlus />{/snippet}
|
||||
{#snippet accountIcon()}<Shield />{/snippet}
|
||||
{#snippet appIcon()}<Smartphone />{/snippet}
|
||||
|
||||
<Accordion>
|
||||
<!-- Family -->
|
||||
<AccordionItem title="Family" open>
|
||||
<AccordionItem title="Family" icon={famIcon} open>
|
||||
<CardGrid>
|
||||
<Card title="Family Name">
|
||||
<p class="hint">
|
||||
@@ -232,35 +269,53 @@
|
||||
</p>
|
||||
</Card>
|
||||
<Card title="Seasons" cols={1}>
|
||||
<p class="hint">Group chores into seasons. Tick a season on to make it available for assignment; untick to disable it.</p>
|
||||
<p class="hint">
|
||||
Group chores into seasons. Tick a season on to make it available for assignment; untick to
|
||||
disable it.
|
||||
</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 class="season-color-row">
|
||||
<label>Colour</label>
|
||||
<div class="season-swatches">
|
||||
{#each seasonColors as color}
|
||||
<button
|
||||
type="button"
|
||||
class="season-swatch"
|
||||
class:selected={color === seasonColor}
|
||||
style="background:{color}"
|
||||
aria-label={color}
|
||||
onclick={() => (seasonColor = color)}
|
||||
></button>
|
||||
{/each}
|
||||
</div>
|
||||
<input type="hidden" name="color" value={seasonColor} />
|
||||
</div>
|
||||
<Button type="submit" size="sm">Add</Button>
|
||||
</form>
|
||||
|
||||
<ul>
|
||||
{#each (famStore.initialized ? famStore.seasons : data.seasons) as s}
|
||||
<ul class="season-list">
|
||||
<li class="season-head" aria-hidden="true">
|
||||
<span class="season-name">Season</span>
|
||||
<span>Active</span>
|
||||
<span>Action</span>
|
||||
</li>
|
||||
{#each famStore.initialized ? famStore.seasons : data.seasons as s}
|
||||
<li>
|
||||
<span class="dot" style="background:{s.color}"></span>
|
||||
<span class="season-name">{s.name}</span>
|
||||
<span class="season-cell">
|
||||
<span class="dot" style="background:{s.color}"></span>
|
||||
<span class="season-name">{s.name}</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="season-check"
|
||||
class:on={s.active !== false}
|
||||
role="switch"
|
||||
aria-checked={s.active !== false}
|
||||
title={s.active !== false ? 'Active — click to disable' : 'Disabled — click to enable'}
|
||||
title={s.active !== false
|
||||
? 'Active — click to disable'
|
||||
: 'Disabled — click to enable'}
|
||||
onclick={() => toggleSeasonActive(s, !(s.active !== false))}
|
||||
>
|
||||
{#if s.active !== false}
|
||||
@@ -272,12 +327,15 @@
|
||||
stroke="currentColor"
|
||||
stroke-width="3.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
><polyline points="20 6 9 17 4 12" /></svg
|
||||
stroke-linejoin="round"><polyline points="20 6 9 17 4 12" /></svg
|
||||
>
|
||||
{/if}
|
||||
</button>
|
||||
<Button variant="danger" size="sm" onclick={() => (deletingSeason = s)}>Remove</Button>
|
||||
<span class="season-action">
|
||||
<Button variant="danger" size="sm" onclick={() => (deletingSeason = s)}
|
||||
>Remove</Button
|
||||
>
|
||||
</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
@@ -312,129 +370,190 @@
|
||||
</CardGrid>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem title="Invites">
|
||||
<AccordionItem title="Invites" icon={inviteIcon}>
|
||||
<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 title="New member" cols={1}>
|
||||
<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>
|
||||
</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"
|
||||
>
|
||||
<Card title="Child access codes" cols={1} class="access-card">
|
||||
<div class="access-body">
|
||||
<p class="hint">
|
||||
Generates a 6-digit code valid for 20 minutes. The child enters it at the join link.
|
||||
</p>
|
||||
|
||||
<label class="field-label" for="invite-child">Child</label>
|
||||
<select id="invite-child" bind:value={inviteChild} name="name" required>
|
||||
<select id="invite-child" bind:value={inviteChild}>
|
||||
<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
|
||||
<div class="access-cta-row">
|
||||
<form
|
||||
method="POST"
|
||||
action="?/issueAccess"
|
||||
use:enhance={() => {
|
||||
return async ({ formData, result }) => {
|
||||
if (result.type !== 'success') return;
|
||||
const d = result.data as
|
||||
| {
|
||||
ok?: boolean;
|
||||
otp?: string;
|
||||
joinUrl?: string;
|
||||
error?: string;
|
||||
}
|
||||
| undefined;
|
||||
if (d?.ok && d.otp && d.joinUrl) {
|
||||
openIssueModal({
|
||||
otp: d.otp,
|
||||
joinUrl: d.joinUrl,
|
||||
name: String(formData.get('name') || '')
|
||||
});
|
||||
} else if (d?.error) {
|
||||
alert(d.error);
|
||||
}
|
||||
};
|
||||
}}
|
||||
class="inline"
|
||||
>
|
||||
<input type="hidden" name="name" value={inviteChild} />
|
||||
<Button type="submit" size="sm" variant="primary" disabled={!inviteChild}
|
||||
>Issue new code</Button
|
||||
>
|
||||
<Button variant="secondary" size="sm" onclick={toggleQR}
|
||||
>{showQR ? 'Hide QR' : 'Show QR'}</Button
|
||||
</form>
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
action="?/currentAccess"
|
||||
use:enhance={() => {
|
||||
return async ({ formData, result }) => {
|
||||
if (result.type !== 'success') return;
|
||||
const d = result.data as
|
||||
| {
|
||||
ok?: boolean;
|
||||
otp?: string;
|
||||
joinUrl?: string;
|
||||
error?: string;
|
||||
}
|
||||
| undefined;
|
||||
if (d?.ok && d.otp && d.joinUrl) {
|
||||
openIssueModal({
|
||||
otp: d.otp,
|
||||
joinUrl: d.joinUrl,
|
||||
name: String(formData.get('name') || '')
|
||||
});
|
||||
} else if (d?.error) {
|
||||
alert(d.error);
|
||||
}
|
||||
};
|
||||
}}
|
||||
class="inline"
|
||||
>
|
||||
<input type="hidden" name="name" value={inviteChild} />
|
||||
<Button type="submit" size="sm" variant="purple" disabled={!inviteChild}
|
||||
>Reveal current</Button
|
||||
>
|
||||
</div>
|
||||
{#if showQR && qrDataUrl}
|
||||
<div class="qr-wrap"><img src={qrDataUrl} alt="QR Code" class="qr" /></div>
|
||||
{/if}
|
||||
</form>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="All members ({members.length})" cols={1}>
|
||||
<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>
|
||||
<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>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</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">
|
||||
<form
|
||||
method="POST"
|
||||
action="?/inviteParent"
|
||||
use:enhance={() => {
|
||||
return async ({ result }) => handleInviteResult(result);
|
||||
}}
|
||||
>
|
||||
<label class="field-label" for="parent-name">Parent name</label>
|
||||
<input
|
||||
id="parent-name"
|
||||
name="name"
|
||||
bind:value={parentInviteName}
|
||||
placeholder="e.g. Alex"
|
||||
required
|
||||
/>
|
||||
<label class="field-label" for="parent-email">Parent email</label>
|
||||
<input
|
||||
id="parent-email"
|
||||
name="email"
|
||||
type="email"
|
||||
bind:value={parentInviteEmail}
|
||||
placeholder="parent@example.com"
|
||||
required
|
||||
/>
|
||||
<Button onclick={handleParentInvite} size="sm">Send invite</Button>
|
||||
</div>
|
||||
<p class="hint">They will set up their own password on first login.</p>
|
||||
<Button type="submit" size="sm">Send invite</Button>
|
||||
</form>
|
||||
<p class="hint">They'll receive a link with a code to set up their own password.</p>
|
||||
</Card>
|
||||
</CardGrid>
|
||||
|
||||
{#if issueModal}
|
||||
<div class="overlay" onclick={() => (issueModal = null)} role="presentation">
|
||||
<div class="modal" onclick={(e) => e.stopPropagation()} role="dialog">
|
||||
<h3>Join code for {issueModal.name}</h3>
|
||||
<p class="hint">Valid 20 minutes — the child enters it at the join link.</p>
|
||||
<p class="code-display">{issueModal.otp}</p>
|
||||
<p class="invite-url">{invitePath}</p>
|
||||
<div class="qr-wrap">
|
||||
{#if qrDataUrl}
|
||||
<img src={qrDataUrl} alt="QR Code" class="qr" />
|
||||
{/if}
|
||||
</div>
|
||||
<div class="actions justify-center">
|
||||
<Button variant="secondary" size="sm" onclick={() => copy(invitePath)}
|
||||
>{copied ? 'Copied!' : 'Copy URL'}</Button
|
||||
>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button type="button" onclick={() => (issueModal = null)}>Done</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem title="Account">
|
||||
<AccordionItem title="Account" icon={accountIcon}>
|
||||
<CardGrid>
|
||||
<!-- Access (codes) only relevant when NOT on a subscription -->
|
||||
{#if fam?.paymentMode !== 'sub'}
|
||||
@@ -515,8 +634,9 @@
|
||||
<!-- 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.
|
||||
<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">
|
||||
@@ -560,7 +680,7 @@
|
||||
</CardGrid>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem title="App">
|
||||
<AccordionItem title="App" icon={appIcon}>
|
||||
<CardGrid>
|
||||
{#if page.data.platformFlags?.debug}
|
||||
<Card title="Debug Tools" cols={1} accent="#f59e0b">
|
||||
@@ -703,25 +823,40 @@
|
||||
}
|
||||
|
||||
/* ── Colour rows (seasons) ── */
|
||||
.color-row {
|
||||
.season-color-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.color-row label {
|
||||
.season-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;
|
||||
.season-swatches {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.season-swatch {
|
||||
width: 1.65rem;
|
||||
height: 1.65rem;
|
||||
border-radius: 50%;
|
||||
border: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
transition:
|
||||
border-color 0.12s ease,
|
||||
transform 0.12s ease;
|
||||
}
|
||||
.season-swatch.selected {
|
||||
border-color: #111827;
|
||||
}
|
||||
.season-swatch:hover {
|
||||
transform: scale(1.08);
|
||||
}
|
||||
|
||||
/* ── Lists ── */
|
||||
@@ -742,9 +877,45 @@
|
||||
li:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/* ── Seasons table ── */
|
||||
.season-list {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
.season-list li {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 3rem 5rem;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
.season-list .season-head {
|
||||
padding: 0.4rem 0;
|
||||
border-bottom: 2px solid #e5e7eb;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
color: #6b7280;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.season-list .season-head .season-name {
|
||||
padding-left: 0;
|
||||
}
|
||||
.season-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.season-action {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.season-name {
|
||||
flex: 1;
|
||||
min-width: 80px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.season-check {
|
||||
width: 26px;
|
||||
@@ -792,25 +963,20 @@
|
||||
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 ── */
|
||||
.members-list {
|
||||
border-left: 1px solid #f3f4f6;
|
||||
padding-left: 1.25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.member-left,
|
||||
.member-actions {
|
||||
.members-list li {
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
.member-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.member-color {
|
||||
width: 22px;
|
||||
@@ -834,21 +1000,6 @@
|
||||
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 {
|
||||
@@ -873,6 +1024,43 @@
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* ── Child access codes (hierarchy-level card) ── */
|
||||
.access-card {
|
||||
background: linear-gradient(160deg, #ddd6fe 0%, #ede9fe 55%, #f5f3ff 100%) !important;
|
||||
border: 1px solid #c4b5fd !important;
|
||||
}
|
||||
.access-card :global(.card-header) {
|
||||
background: rgba(255, 255, 255, 0.45);
|
||||
border-bottom-color: rgba(196, 181, 253, 0.5);
|
||||
}
|
||||
.access-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
.access-cta-row {
|
||||
display: flex;
|
||||
gap: 0.6rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.access-cta-row form.inline {
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
min-width: 150px;
|
||||
}
|
||||
.access-cta-row form.inline :global(.btn) {
|
||||
width: 100%;
|
||||
}
|
||||
.code-display {
|
||||
font-size: 2.6rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.28em;
|
||||
text-align: center;
|
||||
color: #4338ca;
|
||||
margin: 0.75rem 0;
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
}
|
||||
|
||||
/* ── Narrow cards: stack rows / actions full width (container query) ── */
|
||||
@container (max-width: 380px) {
|
||||
.payday-row {
|
||||
@@ -882,7 +1070,7 @@
|
||||
.payday-row select {
|
||||
width: 100%;
|
||||
}
|
||||
.color-row {
|
||||
.season-color-row {
|
||||
align-items: stretch;
|
||||
}
|
||||
.actions {
|
||||
|
||||
@@ -1,6 +1,30 @@
|
||||
import { fail, redirect } from '@sveltejs/kit';
|
||||
import { redeemOtp } from '$lib/server/member-otp';
|
||||
import { redeemOtp, redeemParentOtp } from '$lib/server/member-otp';
|
||||
import { createSuperClient } from '$lib/server/pocketbase';
|
||||
import { setSessionCookie, clearLegacyCookies } from '$lib/server/session';
|
||||
import { famUsername, handle } from '@shared/slugify';
|
||||
|
||||
export async function load(event) {
|
||||
const famSlug = event.params.fam;
|
||||
const username = event.params.username;
|
||||
const pb = await createSuperClient();
|
||||
|
||||
let isParent = false;
|
||||
let famName = '';
|
||||
try {
|
||||
const fam = await pb.collection('fams').getFirstListItem(`slug='${famSlug}'`);
|
||||
famName = fam?.name || famSlug;
|
||||
const user = await pb
|
||||
.collection('users')
|
||||
.getFirstListItem(`famId='${fam.id}' && username='${famUsername(famSlug, handle(username))}'`)
|
||||
.catch(() => null);
|
||||
isParent = user?.role === 'parent';
|
||||
} catch {
|
||||
// Invalid fam slug — render as a child join, the action will reject.
|
||||
}
|
||||
|
||||
return { isParent, famName };
|
||||
}
|
||||
|
||||
export const actions = {
|
||||
default: async (event) => {
|
||||
@@ -8,11 +32,35 @@ export const actions = {
|
||||
const username = event.params.username;
|
||||
const fd = await event.request.formData();
|
||||
const otp = (fd.get('otp') || '').toString().trim();
|
||||
const password = (fd.get('password') || '').toString();
|
||||
const confirmPassword = (fd.get('confirmPassword') || '').toString();
|
||||
|
||||
if (!otp) return fail(400, { error: 'Enter the code shown by your parent.' });
|
||||
|
||||
try {
|
||||
const token = await redeemOtp({ famSlug: fam, username, otp });
|
||||
// Parents set their own password on join; children use the derived one.
|
||||
const pb = await createSuperClient();
|
||||
const famRecord = await pb.collection('fams').getFirstListItem(`slug='${fam}'`);
|
||||
const user = await pb
|
||||
.collection('users')
|
||||
.getFirstListItem(
|
||||
`famId='${famRecord.id}' && username='${famUsername(fam, handle(username))}'`
|
||||
)
|
||||
.catch(() => null);
|
||||
const isParent = user?.role === 'parent';
|
||||
|
||||
if (isParent) {
|
||||
if (!password || password.length < 10) {
|
||||
return fail(400, { error: 'Password must be at least 10 characters' });
|
||||
}
|
||||
if (password !== confirmPassword) {
|
||||
return fail(400, { error: 'Passwords do not match' });
|
||||
}
|
||||
}
|
||||
|
||||
const token = isParent
|
||||
? await redeemParentOtp({ famSlug: fam, username, otp, password })
|
||||
: await redeemOtp({ famSlug: fam, username, otp });
|
||||
clearLegacyCookies(event.cookies);
|
||||
setSessionCookie(event.cookies, token);
|
||||
} catch (e) {
|
||||
@@ -21,4 +69,4 @@ export const actions = {
|
||||
|
||||
throw redirect(303, `/${fam}/${encodeURIComponent(username)}`);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@@ -7,21 +7,36 @@
|
||||
const famSlug = page.params.fam;
|
||||
const username = page.params.username;
|
||||
let otp = $state(page.url.searchParams.get('code') || '');
|
||||
let { form } = $props();
|
||||
let password = $state('');
|
||||
let confirmPassword = $state('');
|
||||
let { data, form } = $props();
|
||||
|
||||
const isParent = $derived(data?.isParent);
|
||||
const famName = $derived(data?.famName || famSlug);
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Join {famSlug}</title></svelte:head>
|
||||
<svelte:head><title>{isParent ? 'Join as parent' : `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
|
||||
class="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-indigo-100 text-2xl"
|
||||
>
|
||||
<span class="home-badge">{@html homeIcon}</span>
|
||||
</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>
|
||||
{#if isParent}
|
||||
<h1 class="text-xl font-bold text-slate-900">Join {famSlug}</h1>
|
||||
<p class="mt-1 text-sm text-slate-500">
|
||||
Hi <span class="font-semibold text-slate-700">{username}</span> — enter the code from your
|
||||
email and create a password to join <strong>{famName}</strong> as a parent.
|
||||
</p>
|
||||
{:else}
|
||||
<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>
|
||||
{/if}
|
||||
|
||||
<form
|
||||
class="mt-6 flex flex-col gap-3"
|
||||
@@ -45,10 +60,32 @@
|
||||
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 isParent}
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
bind:value={password}
|
||||
placeholder="Create a password"
|
||||
autocomplete="new-password"
|
||||
minlength="10"
|
||||
class="w-full rounded-lg border border-slate-300 px-4 py-3 text-slate-900 outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-200"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
name="confirmPassword"
|
||||
bind:value={confirmPassword}
|
||||
placeholder="Confirm password"
|
||||
autocomplete="new-password"
|
||||
minlength="10"
|
||||
class="w-full rounded-lg border border-slate-300 px-4 py-3 text-slate-900 outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-200"
|
||||
/>
|
||||
{/if}
|
||||
{#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>
|
||||
<Button type="submit" variant="primary" size="lg"
|
||||
>{isParent ? 'Set up my account' : 'Join'}</Button
|
||||
>
|
||||
</form>
|
||||
|
||||
<p class="mt-6 text-xs text-slate-400">
|
||||
@@ -56,6 +93,7 @@
|
||||
</p>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<style>
|
||||
.home-badge :global(svg) {
|
||||
width: 1.75rem;
|
||||
|
||||
Reference in New Issue
Block a user