Compare commits

...

2 Commits

Author SHA1 Message Date
JCEEE 87c28cb6ce 1.8.7 2026-09-01 11:58:47 +01:00
JCEEE 40d7bf7398 final flows including emails for passwords 2026-09-01 11:58:43 +01:00
23 changed files with 1978 additions and 860 deletions
+1
View File
@@ -35,6 +35,7 @@
"chart.js": "^4.4.0",
"pocketbase": "^0.27.0",
"qrcode": "^1.5.4",
"resend": "^6.25.0",
"stripe": "^22.5.0"
}
}
+5 -3
View File
@@ -1,13 +1,14 @@
import { defineEnvVars } from '@sveltejs/kit/hooks';
// Default when the env var isn't set, so a missing value never crashes startup.
const withDefault = (value: string) => ({
const withDefault = (value: string) =>
({
'~standard': {
version: 1,
vendor: 'famchamp',
validate: (v: unknown) => ({ value: typeof v === 'string' && v ? v : value })
}
} as const);
}) as const;
export const variables = defineEnvVars({
SERVER_IP: { public: true, schema: withDefault('192.168.1.225') },
@@ -28,5 +29,6 @@ export const variables = defineEnvVars({
STRIPE_PRICE_MONTHLY: { public: false, schema: withDefault('') },
STRIPE_PRICE_YEARLY: { public: false, schema: withDefault('') },
// Stripe publishable key (client-side for Checkout redirect).
PUBLIC_STRIPE_PUBLISHABLE_KEY: { public: true, schema: withDefault('') }
PUBLIC_STRIPE_PUBLISHABLE_KEY: { public: true, schema: withDefault('') },
RESEND_API: { public: false, schema: withDefault('') }
});
@@ -1,17 +1,24 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import ChevronDown from '@lucide/svelte/icons/chevron-down';
let { title, open = $bindable(false), children }: { title: string; open?: boolean; children: Snippet } = $props();
let {
title,
icon,
open = $bindable(false),
children
}: { title: string; icon?: Snippet; open?: boolean; children: Snippet } = $props();
</script>
<div class="accordion-item" class:open>
<button
class="accordion-trigger"
onclick={() => (open = !open)}
aria-expanded={open}
>
<button class="accordion-trigger" onclick={() => (open = !open)} aria-expanded={open}>
<span class="accordion-title">
{#if icon}
<span class="accordion-icon">{@render icon()}</span>
{/if}
<span>{title}</span>
<span class="accordion-arrow">{open ? '▾' : '▸'}</span>
</span>
<span class="accordion-arrow" class:rotated={open}><ChevronDown /></span>
</button>
{#if open}
<div class="accordion-body">
@@ -21,16 +28,58 @@
</div>
<style>
.accordion-item { border-bottom: 1px solid #f3f4f6; }
.accordion-item:last-child { border-bottom: none; }
.accordion-trigger {
display: flex; justify-content: space-between; align-items: center;
width: 100%; padding: 0.7rem 1rem;
background: #fafafa; border: none;
font-size: 0.95rem; font-weight: 600; color: #374151;
cursor: pointer; text-align: left;
.accordion-item {
border-bottom: 1px solid #f3f4f6;
}
.accordion-item:last-child {
border-bottom: none;
}
.accordion-trigger {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
padding: 0.7rem 1rem;
background: #fafafa;
border: none;
font-size: 0.95rem;
font-weight: 600;
color: #374151;
cursor: pointer;
text-align: left;
}
.accordion-trigger:hover {
background: #f3f4f6;
}
.accordion-title {
display: flex;
align-items: center;
gap: 0.5rem;
}
.accordion-icon {
display: inline-flex;
align-items: center;
color: #6366f1;
}
.accordion-icon :global(svg) {
width: 1.05em;
height: 1.05em;
}
.accordion-arrow {
display: inline-flex;
align-items: center;
color: #9ca3af;
transition: transform 0.15s ease;
transform: rotate(-90deg);
}
.accordion-arrow.rotated {
transform: rotate(0deg);
}
.accordion-arrow :global(svg) {
width: 1.1em;
height: 1.1em;
}
.accordion-body {
padding: 1rem;
}
.accordion-trigger:hover { background: #f3f4f6; }
.accordion-arrow { font-size: 0.8rem; color: #9ca3af; }
.accordion-body { padding: 1rem; }
</style>
+84 -17
View File
@@ -1,6 +1,13 @@
<script lang="ts">
let { variant = 'primary', size = 'md', href, onclick, children, ...rest }: {
variant?: 'primary' | 'secondary' | 'ghost' | 'danger' | 'success';
let {
variant = 'primary',
size = 'md',
href,
onclick,
children,
...rest
}: {
variant?: 'primary' | 'secondary' | 'ghost' | 'danger' | 'success' | 'purple';
size?: 'sm' | 'md' | 'lg';
href?: string;
onclick?: () => void;
@@ -10,7 +17,7 @@
</script>
{#if href}
<a href={href} class="btn btn-{variant} btn-{size}" {...rest}>
<a {href} class="btn btn-{variant} btn-{size}" {...rest}>
{@render children?.()}
</a>
{:else}
@@ -28,25 +35,85 @@
border-radius: 6px;
cursor: pointer;
text-decoration: none;
transition: background 0.15s, border-color 0.15s;
transition:
background 0.15s,
border-color 0.15s;
border: 1px solid transparent;
}
.btn-sm { padding: 0.3rem 0.6rem; font-size: 0.8rem; }
.btn-md { padding: 0.45rem 0.9rem; font-size: 0.85rem; }
.btn-lg { padding: 0.6rem 1.2rem; font-size: 0.95rem; }
.btn-sm {
padding: 0.3rem 0.6rem;
font-size: 0.8rem;
}
.btn-md {
padding: 0.45rem 0.9rem;
font-size: 0.85rem;
}
.btn-lg {
padding: 0.6rem 1.2rem;
font-size: 0.95rem;
}
.btn-primary { background: #4338ca; color: #fff; border-color: #4338ca; }
.btn-primary:hover { background: #3730a3; }
.btn-primary {
background: #4338ca;
color: #fff;
border-color: #4338ca;
}
.btn-primary:hover {
background: #3730a3;
}
.btn-primary:disabled {
background: #a5b4fc;
border-color: #a5b4fc;
color: #fff;
}
.btn-secondary { background: #f3f4f6; color: #374151; border-color: #d1d5db; }
.btn-secondary:hover { background: #e5e7eb; }
.btn-secondary {
background: #f3f4f6;
color: #374151;
border-color: #d1d5db;
}
.btn-secondary:hover {
background: #e5e7eb;
}
.btn-ghost { background: transparent; color: #6b7280; border-color: transparent; }
.btn-ghost:hover { background: #f3f4f6; }
.btn-ghost {
background: transparent;
color: #6b7280;
border-color: transparent;
}
.btn-ghost:hover {
background: #f3f4f6;
}
.btn-danger { background: #dc2626; color: #fff; border-color: #dc2626; }
.btn-danger:hover { background: #b91c1c; }
.btn-danger {
background: #dc2626;
color: #fff;
border-color: #dc2626;
}
.btn-danger:hover {
background: #b91c1c;
}
.btn-success { background: #059669; color: #fff; border-color: #059669; }
.btn-success:hover { background: #047857; }
.btn-success {
background: #059669;
color: #fff;
border-color: #059669;
}
.btn-success:hover {
background: #047857;
}
.btn-purple {
background: #7c3aed;
color: #fff;
border-color: #7c3aed;
}
.btn-purple:hover {
background: #6d28d9;
}
.btn-purple:disabled {
background: #c4b5fd;
border-color: #c4b5fd;
color: #fff;
}
</style>
+1 -1
View File
@@ -80,7 +80,7 @@
position: fixed;
top: 0;
left: 0;
height: 100vh;
height: 100dvh;
width: 220px;
background: #1e1b4b;
color: #e0e7ff;
+5 -1
View File
@@ -5,11 +5,15 @@ export const pb = new PocketBase(PB_ENDPOINT);
pb.autoCancellation(false);
// Seed the browser PB singleton with the session token so shared stores can
// do authenticated reads + realtime .subscribe() from the client.
// do authenticated reads + realtime .subscribe() from the client. When the
// session is gone (logged out), clear the authStore — the default LocalAuthStore
// persists the token in localStorage, so without this the client keeps an
// authenticated singleton and effectively stays logged in.
export function initRealtimePb(token: string) {
if (token) {
pb.authStore.save(token, null);
return true;
}
pb.authStore.clear();
return false;
}
+45
View File
@@ -0,0 +1,45 @@
import { Resend } from 'resend';
import { RESEND_API } from '$app/env/private';
const resend = new Resend(String(RESEND_API));
export async function sendPasswordResetEmail(opts: {
to: string;
resetLink: string;
famName: string;
}) {
const { to, resetLink, famName } = opts;
await resend.emails.send({
from: 'no-reply@famchamp.ai',
to,
subject: `Reset your password for ${famName}`,
html: `
<p>You requested a password reset for your <strong>${famName}</strong> account.</p>
<p><a href="${resetLink}">Click here to reset your password</a></p>
<p>This link expires in 1 hour.</p>
<p>If you didn't request this, you can ignore this email.</p>
`
});
}
export async function sendParentInviteEmail(opts: {
to: string;
inviteLink: string;
famName: string;
otp: string;
}) {
const { to, inviteLink, famName, otp } = opts;
await resend.emails.send({
from: 'no-reply@famchamp.ai',
to,
subject: `You're invited to ${famName} on FamChore`,
html: `
<p>You've been invited as a parent on <strong>${famName}</strong>.</p>
<p><a href="${inviteLink}">Click here to join</a> and set up your password.</p>
<p>Your code is <strong>${otp}</strong> (valid 20 minutes).</p>
<p>If you weren't expecting this, you can ignore this email.</p>
`
});
}
+88
View File
@@ -118,6 +118,94 @@ export async function issueAccess(opts: {
return { otp, joinUrl: `/${famSlug}/join/${encodeURIComponent(username)}` };
}
// Admin invites a second parent: creates a role='parent' users record with the
// shared derived password (never known — the invited parent sets their own at
// the join page) + issues an OTP email code. Rejects duplicates in the family.
export async function inviteParent(opts: {
famId: string;
famSlug: string;
name: string;
email: string;
}) {
const { famId, famSlug, name, email } = opts;
const handleName = handle(name);
const username = famUsername(famSlug, handleName);
const password = derivePassword(famSlug, handleName);
const pb = await createSuperClient();
const existing = await pb
.collection('users')
.getFirstListItem(`famId='${famId}' && (username='${username}' || email='${email}')`)
.catch(() => null);
if (existing) {
throw new Error('A user with that name or email already exists in this family');
}
const user = await pb.collection('users').create({
username,
name,
email,
emailVisibility: false,
password,
passwordConfirm: password,
famId,
role: 'parent'
});
const otp = generateOtp();
await pb.collection('otp').create({
famId,
userId: user.id,
otp,
updatedAt: new Date().toISOString()
});
return { otp, joinUrl: `/${famSlug}/join/${encodeURIComponent(handleName)}` };
}
// Invited parent redeems their OTP at the join page, sets their own password,
// and is logged in. Single-use — the OTP record is deleted on success.
export async function redeemParentOtp(opts: {
famSlug: string;
username: string;
otp: string;
password: string;
}) {
const { famSlug, username, otp, password } = opts;
const handleName = handle(username);
const fullUsername = famUsername(famSlug, handleName);
const pb = await createSuperClient();
const fam = await pb.collection('fams').getFirstListItem(`slug='${famSlug}'`);
if (!fam) throw new Error('Invalid join link');
let user = await pb
.collection('users')
.getFirstListItem(`famId='${fam.id}' && username='${fullUsername}'`)
.catch(() => null);
if (!user || user.role !== 'parent') throw new Error('Invalid join link');
const config = await pb
.collection('otp')
.getFirstListItem(`famId='${fam.id}' && userId='${user.id}'`)
.catch(() => null);
if (!config || config.otp !== otp) throw new Error('Invalid code');
const issued = Date.parse(config.updatedAt || '');
if (!issued || Date.now() - issued > OTP_TTL_MS) throw new Error('Code expired');
await pb.collection('users').update(user.id, { password, passwordConfirm: password });
await pb
.collection('otp')
.delete(config.id)
.catch(() => null);
const authPb = createPbClient();
await authPb.collection('users').authWithPassword(user.email, password);
return authPb.authStore.token;
}
// Child redeems their OTP at /{famSlug}/join/{username}. Verifies the code,
// the 20-minute window, and that the account is a child, then authenticates via
// authWithPassword and returns a fresh PB JWT. Throws on any failure.
+308 -226
View File
@@ -6,14 +6,11 @@ let token: string | null = null;
async function auth(): Promise<string> {
if (token) return token;
const res = await fetch(
`${PB_ENDPOINT}/api/collections/_superusers/auth-with-password`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ identity: PB_EMAIL, password: PB_PASSWORD }),
},
);
const res = await fetch(`${PB_ENDPOINT}/api/collections/_superusers/auth-with-password`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ identity: PB_EMAIL, password: PB_PASSWORD })
});
const data = await res.json();
if (!res.ok) throw new Error(`PB auth failed: ${JSON.stringify(data)}`);
token = data.token;
@@ -22,10 +19,9 @@ async function auth(): Promise<string> {
async function getCollection(name: string): Promise<any | null> {
const t = await auth();
const res = await fetch(
`${PB_ENDPOINT}/api/collections?filter=name='${name}'`,
{ headers: { Authorization: `Bearer ${t}` } },
);
const res = await fetch(`${PB_ENDPOINT}/api/collections?filter=name='${name}'`, {
headers: { Authorization: `Bearer ${t}` }
});
const data = await res.json();
return data?.items?.[0] || null;
}
@@ -33,9 +29,9 @@ async function getCollection(name: string): Promise<any | null> {
async function createCollection(col: any): Promise<string | null> {
const t = await auth();
const res = await fetch(`${PB_ENDPOINT}/api/collections`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${t}` },
body: JSON.stringify(col),
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${t}` },
body: JSON.stringify(col)
});
const data = await res.json();
if (!res.ok) throw new Error(`Create ${col.name} failed: ${JSON.stringify(data)}`);
@@ -46,9 +42,9 @@ async function createCollection(col: any): Promise<string | null> {
async function updateCollection(id: string, col: any): Promise<void> {
const t = await auth();
const res = await fetch(`${PB_ENDPOINT}/api/collections/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${t}` },
body: JSON.stringify(col),
method: 'PATCH',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${t}` },
body: JSON.stringify(col)
});
const data = await res.json();
if (!res.ok) throw new Error(`Update collection ${id} failed: ${JSON.stringify(data)}`);
@@ -58,9 +54,9 @@ async function updateCollection(id: string, col: any): Promise<void> {
async function createRecord(collection: string, data: any): Promise<void> {
const t = await auth();
const res = await fetch(`${PB_ENDPOINT}/api/collections/${collection}/records`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${t}` },
body: JSON.stringify(data),
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${t}` },
body: JSON.stringify(data)
});
if (!res.ok) {
const d = await res.json();
@@ -75,14 +71,14 @@ async function createRecord(collection: string, data: any): Promise<void> {
// boot. Families that want it can add it themselves.
// Only seeds when a family has zero seasons, so user deletions stick.
async function ensureDefaultSeasons(): Promise<void> {
const famsCol = await getCollection("fams");
const famsCol = await getCollection('fams');
if (!famsCol) return;
const t = await auth();
const famsRes = await fetch(`${PB_ENDPOINT}/api/collections/fams/records?perPage=200`, {
headers: { Authorization: `Bearer ${t}` },
headers: { Authorization: `Bearer ${t}` }
});
const fams = (await famsRes.json()).items || [];
const defaults = [{ name: "Holidays", color: "#f59e0b", active: true }];
const defaults = [{ name: 'Holidays', color: '#f59e0b', active: true }];
for (const fam of fams) {
const sRes = await fetch(
`${PB_ENDPOINT}/api/collections/seasons/records?filter=(famId='${fam.id}')&fields=name&perPage=200`,
@@ -95,7 +91,7 @@ async function ensureDefaultSeasons(): Promise<void> {
// at least one season remains.
if (have.length === 0) {
for (const d of defaults) {
await createRecord("seasons", { famId: fam.id, ...d });
await createRecord('seasons', { famId: fam.id, ...d });
console.log(` ✓ Seeded season ${d.name} for fam ${fam.id}`);
}
}
@@ -107,74 +103,100 @@ async function ensureDefaultSeasons(): Promise<void> {
// role='child'; username is a password-auth identity so the server can
// authWithPassword(derivedPassword) at OTP join time.
async function ensureUsers(ids: Record<string, string>): Promise<void> {
const usersCol = await getCollection("users");
if (!usersCol) throw new Error("users collection not found");
const famsId = ids.fams || (await getCollection("fams"))?.id;
if (!famsId) throw new Error("fams collection not found");
const usersCol = await getCollection('users');
if (!usersCol) throw new Error('users collection not found');
const famsId = ids.fams || (await getCollection('fams'))?.id;
if (!famsId) throw new Error('fams collection not found');
const has = (n: string) => usersCol.fields.some((f: any) => f.name === n);
let changed = false;
const emailField = usersCol.fields.find((f: any) => f.name === "email");
const emailField = usersCol.fields.find((f: any) => f.name === 'email');
if (emailField && emailField.required) {
emailField.required = false;
changed = true;
}
if (!has("famId")) {
if (!has('famId')) {
usersCol.fields.push({
name: "famId", type: "relation", required: false,
collectionId: famsId, maxSelect: 1, cascadeDelete: false,
name: 'famId',
type: 'relation',
required: false,
collectionId: famsId,
maxSelect: 1,
cascadeDelete: false
});
changed = true;
}
if (!has("role")) {
usersCol.fields.push({ name: "role", type: "select", required: false, values: ["parent", "child"], maxSelect: 1 });
if (!has('role')) {
usersCol.fields.push({
name: 'role',
type: 'select',
required: false,
values: ['parent', 'child'],
maxSelect: 1
});
changed = true;
}
if (!has("username")) {
usersCol.fields.push({ name: "username", type: "text", required: true });
if (!has('username')) {
usersCol.fields.push({ name: 'username', type: 'text', required: true });
changed = true;
}
if (!has("color")) {
usersCol.fields.push({ name: "color", type: "text", required: false });
if (!has('color')) {
usersCol.fields.push({ name: 'color', type: 'text', required: false });
changed = true;
}
if (!has('passwordResetToken')) {
usersCol.fields.push({ name: 'passwordResetToken', type: 'text', required: false });
changed = true;
}
if (!has('passwordResetExpiry')) {
usersCol.fields.push({ name: 'passwordResetExpiry', type: 'text', required: false });
changed = true;
}
let indexes = usersCol.indexes || [];
if (!indexes.some((i: string) => /username/i.test(i))) {
indexes = [...indexes, "CREATE UNIQUE INDEX `idx_username__users` ON `users` (`username`) WHERE `username` != ''"];
indexes = [
...indexes,
"CREATE UNIQUE INDEX `idx_username__users` ON `users` (`username`) WHERE `username` != ''"
];
changed = true;
}
const pwAuth = usersCol.passwordAuth || { enabled: true, identityFields: ["email"] };
const identityFields = Array.isArray(pwAuth.identityFields) ? pwAuth.identityFields : ["email"];
if (!identityFields.includes("username")) {
identityFields.push("username");
const pwAuth = usersCol.passwordAuth || { enabled: true, identityFields: ['email'] };
const identityFields = Array.isArray(pwAuth.identityFields) ? pwAuth.identityFields : ['email'];
if (!identityFields.includes('username')) {
identityFields.push('username');
changed = true;
}
const listRule = "famId = @request.auth.famId";
const listRule = 'famId = @request.auth.famId';
const parentWrite = "famId = @request.auth.famId && @request.auth.role = 'parent'";
// Members can edit their own record (name/colour); parents can edit any
// family member. Delete stays parent-only.
const selfOrParentWrite = "@request.auth.id = id || (famId = @request.auth.famId && @request.auth.role = 'parent')";
if (usersCol.listRule !== listRule || usersCol.viewRule !== listRule ||
usersCol.updateRule !== selfOrParentWrite || usersCol.deleteRule !== parentWrite) {
const selfOrParentWrite =
"@request.auth.id = id || (famId = @request.auth.famId && @request.auth.role = 'parent')";
if (
usersCol.listRule !== listRule ||
usersCol.viewRule !== listRule ||
usersCol.updateRule !== selfOrParentWrite ||
usersCol.deleteRule !== parentWrite
) {
changed = true;
}
if (changed) {
await updateCollection(usersCol.id, {
name: "users",
type: "auth",
name: 'users',
type: 'auth',
listRule,
viewRule: listRule,
createRule: usersCol.createRule || "",
createRule: usersCol.createRule || '',
updateRule: selfOrParentWrite,
deleteRule: parentWrite,
fields: usersCol.fields,
indexes,
passwordAuth: { enabled: true, identityFields },
passwordAuth: { enabled: true, identityFields }
});
}
}
@@ -183,24 +205,38 @@ async function ensureUsers(ids: Record<string, string>): Promise<void> {
// its issue timestamp (20-min window). Not public — read/written via the
// superuser client only.
async function ensureOtp(ids: Record<string, string>): Promise<void> {
if (await getCollection("otp")) return;
const famsId = ids.fams || (await getCollection("fams"))?.id;
const usersId = ids.users || (await getCollection("users"))?.id;
if (!famsId || !usersId) throw new Error("fams/users collection not found");
if (await getCollection('otp')) return;
const famsId = ids.fams || (await getCollection('fams'))?.id;
const usersId = ids.users || (await getCollection('users'))?.id;
if (!famsId || !usersId) throw new Error('fams/users collection not found');
await createCollection({
name: "otp",
type: "base",
name: 'otp',
type: 'base',
listRule: null,
viewRule: null,
createRule: null,
updateRule: null,
deleteRule: null,
fields: [
{ name: "famId", type: "relation", required: true, collectionId: famsId, maxSelect: 1, cascadeDelete: false },
{ name: "userId", type: "relation", required: true, collectionId: usersId, maxSelect: 1, cascadeDelete: false },
{ name: "otp", type: "text", required: false },
{ name: "updatedAt", type: "text", required: false },
],
{
name: 'famId',
type: 'relation',
required: true,
collectionId: famsId,
maxSelect: 1,
cascadeDelete: false
},
{
name: 'userId',
type: 'relation',
required: true,
collectionId: usersId,
maxSelect: 1,
cascadeDelete: false
},
{ name: 'otp', type: 'text', required: false },
{ name: 'updatedAt', type: 'text', required: false }
]
});
}
@@ -211,43 +247,43 @@ async function ensureOtp(ids: Record<string, string>): Promise<void> {
// (0 = never expires); `active` is a failsafe toggle. Entered at create-family
// and in admin settings.
async function ensureAccessCodes(): Promise<void> {
if (await getCollection("accesscodes")) {
if (await getCollection('accesscodes')) {
await ensureAccessCodeFields();
await seedAccessCodes();
return;
}
await createCollection({
name: "accesscodes",
type: "base",
name: 'accesscodes',
type: 'base',
listRule: null,
viewRule: null,
createRule: null,
updateRule: null,
deleteRule: null,
fields: [
{ name: "value", type: "text", required: true, unique: true },
{ name: "name", type: "text", required: true },
{ name: "duration", type: "number", required: false },
{ name: "expiry", type: "number", required: false },
{ name: "active", type: "bool", required: false },
{ name: 'value', type: 'text', required: true, unique: true },
{ name: 'name', type: 'text', required: true },
{ name: 'duration', type: 'number', required: false },
{ name: 'expiry', type: 'number', required: false },
{ name: 'active', type: 'bool', required: false },
// When set (>0) this code is a TRIAL code: maps to Stripe
// trial_period_days at checkout instead of platform access.
{ name: "trialDays", type: "number", required: false },
{ name: "createdAt", type: "date", required: false },
],
{ name: 'trialDays', type: 'number', required: false },
{ name: 'createdAt', type: 'date', required: false }
]
});
await seedAccessCodes();
}
// Idempotent field-add for installs where accesscodes predates a field.
async function ensureAccessCodeFields(): Promise<void> {
const col = await getCollection("accesscodes");
const col = await getCollection('accesscodes');
if (!col) return;
const has = (n: string) => col.fields.some((f: any) => f.name === n);
if (!has("trialDays")) {
if (!has('trialDays')) {
await updateCollection(col.id, {
...col,
fields: [...col.fields, { name: "trialDays", type: "number", required: false }],
fields: [...col.fields, { name: 'trialDays', type: 'number', required: false }]
});
}
}
@@ -256,20 +292,27 @@ async function ensureAccessCodeFields(): Promise<void> {
async function seedAccessCodes(): Promise<void> {
const t = await auth();
const seeds = [
{ value: "dev123", name: "developer", duration: 0, expiry: 0, active: true, trialDays: null },
{ value: "FAM3MONTHS", name: "3-month trial", duration: null, expiry: null, active: true, trialDays: 90 },
{ value: 'dev123', name: 'developer', duration: 0, expiry: 0, active: true, trialDays: null },
{
value: 'FAM3MONTHS',
name: '3-month trial',
duration: null,
expiry: null,
active: true,
trialDays: 90
}
];
for (const seed of seeds) {
const res = await fetch(
`${PB_ENDPOINT}/api/collections/accesscodes/records?filter=value='${seed.value}'`,
{ headers: { Authorization: `Bearer ${t}` } },
{ headers: { Authorization: `Bearer ${t}` } }
);
const data = await res.json();
if (data?.items?.length) continue;
const created = await fetch(`${PB_ENDPOINT}/api/collections/accesscodes/records`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${t}` },
body: JSON.stringify({ ...seed, createdAt: new Date().toISOString() }),
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${t}` },
body: JSON.stringify({ ...seed, createdAt: new Date().toISOString() })
});
const c = await created.json();
if (!created.ok) throw new Error(`Seed accesscode failed: ${JSON.stringify(c)}`);
@@ -283,19 +326,19 @@ async function seedAccessCodes(): Promise<void> {
// superuser-only (null rules), so like otp/accesscodes this lives in
// migrate.ts rather than SCHEMA_PLAN.
async function ensurePlatform(): Promise<void> {
if (!(await getCollection("platform"))) {
if (!(await getCollection('platform'))) {
await createCollection({
name: "platform",
type: "base",
listRule: "",
viewRule: "",
name: 'platform',
type: 'base',
listRule: '',
viewRule: '',
createRule: null,
updateRule: null,
deleteRule: null,
fields: [
{ name: "label", type: "text", required: true },
{ name: "flags", type: "json", required: false },
],
{ name: 'label', type: 'text', required: true },
{ name: 'flags', type: 'json', required: false }
]
});
}
await seedPlatform();
@@ -304,34 +347,33 @@ async function ensurePlatform(): Promise<void> {
// Idempotent seed — create the singleton 'global' settings record if missing.
async function seedPlatform(): Promise<void> {
const t = await auth();
const res = await fetch(
`${PB_ENDPOINT}/api/collections/platform/records?filter=label='global'`,
{ headers: { Authorization: `Bearer ${t}` } },
);
const res = await fetch(`${PB_ENDPOINT}/api/collections/platform/records?filter=label='global'`, {
headers: { Authorization: `Bearer ${t}` }
});
const data = await res.json();
if (data?.items?.length) return;
const created = await fetch(`${PB_ENDPOINT}/api/collections/platform/records`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${t}` },
body: JSON.stringify({ label: "global", flags: { debug: false } }),
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${t}` },
body: JSON.stringify({ label: 'global', flags: { debug: false } })
});
const c = await created.json();
if (!created.ok) throw new Error(`Seed platform failed: ${JSON.stringify(c)}`);
console.log(" ✓ Seeded platform settings (global)");
console.log(' ✓ Seeded platform settings (global)');
}
// Bootstrap the full schema on a fresh/wiped PocketBase. Idempotent — skips if
// `fams` already exists (data is disposable; there is no incremental migration
// history).
async function ensureSchema(): Promise<void> {
if (await getCollection("fams")) {
console.log("[migrate] Schema already present — skipping bootstrap.");
if (await getCollection('fams')) {
console.log('[migrate] Schema already present — skipping bootstrap.');
return;
}
console.log("[migrate] Bootstrapping schema on fresh PocketBase...");
console.log('[migrate] Bootstrapping schema on fresh PocketBase...');
const ids: Record<string, string> = {};
const nativeUsers = await getCollection("users");
const nativeUsers = await getCollection('users');
if (nativeUsers) ids.users = nativeUsers.id;
for (const entry of SCHEMA_PLAN) {
const createdId = await createCollection(entry.build(ids));
@@ -340,28 +382,34 @@ async function ensureSchema(): Promise<void> {
await ensureUsers(ids);
await ensureOtp(ids);
console.log("[migrate] Schema bootstrapped.");
console.log('[migrate] Schema bootstrapped.');
}
// Add the access-gating fields to `fams` on installs where it already exists
// (fresh installs get them via SCHEMA_PLAN). Idempotent — only adds missing
// fields.
async function ensureFamFields(): Promise<void> {
const famsCol = await getCollection("fams");
const famsCol = await getCollection('fams');
if (!famsCol) return;
const has = (n: string) => famsCol.fields.some((f: any) => f.name === n);
const needed: any[] = [];
if (!has("active")) {
needed.push({ name: "active", type: "bool", required: false });
if (!has('active')) {
needed.push({ name: 'active', type: 'bool', required: false });
}
if (!has("paymentMode")) {
needed.push({ name: "paymentMode", type: "select", required: false, values: ["none", "code", "sub", "canceled"], maxSelect: 1 });
if (!has('paymentMode')) {
needed.push({
name: 'paymentMode',
type: 'select',
required: false,
values: ['none', 'code', 'sub', 'canceled'],
maxSelect: 1
});
}
if (!has("accessCodeId")) {
needed.push({ name: "accessCodeId", type: "text", required: false });
if (!has('accessCodeId')) {
needed.push({ name: 'accessCodeId', type: 'text', required: false });
}
if (!has("accessCodeEnteredAt")) {
needed.push({ name: "accessCodeEnteredAt", type: "date", required: false });
if (!has('accessCodeEnteredAt')) {
needed.push({ name: 'accessCodeEnteredAt', type: 'date', required: false });
}
if (needed.length) {
await updateCollection(famsCol.id, { ...famsCol, fields: [...famsCol.fields, ...needed] });
@@ -369,7 +417,7 @@ async function ensureFamFields(): Promise<void> {
}
export async function migrate(): Promise<void> {
console.log("[migrate] Checking PB collection schemas...");
console.log('[migrate] Checking PB collection schemas...');
await ensureSchema();
// Runs even when the schema already exists (unlike ensureSchema's early
// return) so new platform collections/fields/seed land on existing installs.
@@ -380,29 +428,29 @@ export async function migrate(): Promise<void> {
await ensureAccessCodes();
await ensurePlatform();
await ensureDefaultSeasons();
console.log("[migrate] Done");
console.log('[migrate] Done');
}
// Add colour / icon / description to `assigned_chores` on existing installs so
// family-admins can override a template's look per assignment. Idempotent.
async function ensureAssignedChoreFields(): Promise<void> {
const col = await getCollection("assigned_chores");
const col = await getCollection('assigned_chores');
if (!col) return;
const has = (n: string) => col.fields.some((f: any) => f.name === n);
const needed: any[] = [];
if (!has("description")) needed.push({ name: "description", type: "text", required: false });
if (!has("icon")) needed.push({ name: "icon", type: "text", required: false });
if (!has("color")) needed.push({ name: "color", type: "text", required: false });
if (!has("emoji")) needed.push({ name: "emoji", type: "text", required: false });
if (!has('description')) needed.push({ name: 'description', type: 'text', required: false });
if (!has('icon')) needed.push({ name: 'icon', type: 'text', required: false });
if (!has('color')) needed.push({ name: 'color', type: 'text', required: false });
if (!has('emoji')) needed.push({ name: 'emoji', type: 'text', required: false });
// Todos can be celebration-only (emoji) as well as points/money.
let changed = !!needed.length;
const typeField = col.fields.find((f: any) => f.name === "type");
if (typeField && Array.isArray(typeField.values) && !typeField.values.includes("emoji")) {
typeField.values = [...typeField.values, "emoji"];
const typeField = col.fields.find((f: any) => f.name === 'type');
if (typeField && Array.isArray(typeField.values) && !typeField.values.includes('emoji')) {
typeField.values = [...typeField.values, 'emoji'];
changed = true;
}
// Direct todos (created by a parent, not from a template) have no templateId.
const tplField = col.fields.find((f: any) => f.name === "templateId");
const tplField = col.fields.find((f: any) => f.name === 'templateId');
if (tplField && tplField.required) {
tplField.required = false;
changed = true;
@@ -410,11 +458,11 @@ async function ensureAssignedChoreFields(): Promise<void> {
if (changed) {
await updateCollection(col.id, { ...col, fields: [...col.fields] });
}
const comp = await getCollection("completions");
if (comp && !comp.fields.some((f: any) => f.name === "rewardId")) {
const comp = await getCollection('completions');
if (comp && !comp.fields.some((f: any) => f.name === 'rewardId')) {
await updateCollection(comp.id, {
...comp,
fields: [...comp.fields, { name: "rewardId", type: "text", required: false }],
fields: [...comp.fields, { name: 'rewardId', type: 'text', required: false }]
});
}
}
@@ -423,44 +471,50 @@ async function ensureAssignedChoreFields(): Promise<void> {
// backfill a default pocket-money droplet (£10 / 50% chores) for any child that
// doesn't yet have one. Idempotent.
async function ensureBonusFields(): Promise<void> {
for (const name of ["bonus_templates", "bonus_configs"]) {
for (const name of ['bonus_templates', 'bonus_configs']) {
const col = await getCollection(name);
if (!col) continue;
const has = (n: string) => col.fields.some((f: any) => f.name === n);
let changed = false;
const fields = [...col.fields];
if (!has("thresholdType")) {
if (!has('thresholdType')) {
fields.push({
name: "thresholdType", type: "select", required: false,
values: ["points", "percent"], maxSelect: 1,
name: 'thresholdType',
type: 'select',
required: false,
values: ['points', 'percent'],
maxSelect: 1
});
changed = true;
}
if (!has("isPocketMoney")) {
fields.push({ name: "isPocketMoney", type: "bool", required: false });
if (!has('isPocketMoney')) {
fields.push({ name: 'isPocketMoney', type: 'bool', required: false });
changed = true;
}
// Standalone-reward progress window (bonus_configs only): how far progress
// counts (`completeBy`) and the window's start/custom-end dates.
if (name === "bonus_configs") {
if (!has("completeBy")) {
if (name === 'bonus_configs') {
if (!has('completeBy')) {
fields.push({
name: "completeBy", type: "select", required: false,
values: ["unlimited", "week", "custom"], maxSelect: 1,
name: 'completeBy',
type: 'select',
required: false,
values: ['unlimited', 'week', 'custom'],
maxSelect: 1
});
changed = true;
}
if (!has("startDate")) {
fields.push({ name: "startDate", type: "text", required: false });
if (!has('startDate')) {
fields.push({ name: 'startDate', type: 'text', required: false });
changed = true;
}
if (!has("completeByDate")) {
fields.push({ name: "completeByDate", type: "text", required: false });
if (!has('completeByDate')) {
fields.push({ name: 'completeByDate', type: 'text', required: false });
changed = true;
}
}
// rewardValue must be optional so an unset pocket-money droplet can exist.
const rv = fields.find((f: any) => f.name === "rewardValue");
const rv = fields.find((f: any) => f.name === 'rewardValue');
if (rv && rv.required) {
rv.required = false;
changed = true;
@@ -482,9 +536,9 @@ async function backfillPocketMoney(): Promise<void> {
);
for (const rec of (await legacyRes.json())?.items || []) {
await fetch(`${PB_ENDPOINT}/api/collections/bonus_configs/records/${rec.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${t}` },
body: JSON.stringify({ isPocketMoney: true }),
method: 'PATCH',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${t}` },
body: JSON.stringify({ isPocketMoney: true })
}).catch(() => {});
}
const childrenRes = await fetch(
@@ -499,26 +553,26 @@ async function backfillPocketMoney(): Promise<void> {
);
if ((await existingRes.json())?.items?.length) continue;
await fetch(`${PB_ENDPOINT}/api/collections/bonus_configs/records`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${t}` },
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${t}` },
body: JSON.stringify({
famId: c.famId,
name: "Pocket Money",
target: "individual",
type: "threshold",
thresholdType: "percent",
occurrence: "recurring",
rewardType: "cash",
name: 'Pocket Money',
target: 'individual',
type: 'threshold',
thresholdType: 'percent',
occurrence: 'recurring',
rewardType: 'cash',
rewardValue: 10,
criteriaValue: 50,
memberId: c.id,
period: "weekly",
status: "active",
isPocketMoney: true,
}),
period: 'weekly',
status: 'active',
isPocketMoney: true
})
}).catch(() => {});
}
console.log("[migrate] Pocket-money droplets ensured.");
console.log('[migrate] Pocket-money droplets ensured.');
}
// Promote chore_templates / bonus_templates to platform-owned: add global/icon/
@@ -526,24 +580,24 @@ async function backfillPocketMoney(): Promise<void> {
// global (platform) templates as assignable droplets. Idempotent. Seeds a
// starter set of global templates on first run for out-of-the-box usage.
async function ensureTemplateFields(): Promise<void> {
const readRule = "global = true || famId = @request.auth.famId";
for (const name of ["chore_templates", "bonus_templates"]) {
const readRule = 'global = true || famId = @request.auth.famId';
for (const name of ['chore_templates', 'bonus_templates']) {
const col = await getCollection(name);
if (!col) continue;
const has = (n: string) => col.fields.some((f: any) => f.name === n);
let changed = false;
const fields = [...col.fields];
for (const f of [
{ name: "global", type: "bool", required: false },
{ name: "icon", type: "text", required: false },
{ name: "color", type: "text", required: false },
{ name: 'global', type: 'bool', required: false },
{ name: 'icon', type: 'text', required: false },
{ name: 'color', type: 'text', required: false }
]) {
if (!has(f.name)) {
fields.push(f);
changed = true;
}
}
const famId = fields.find((f: any) => f.name === "famId");
const famId = fields.find((f: any) => f.name === 'famId');
if (famId && famId.required) {
famId.required = false;
changed = true;
@@ -559,24 +613,24 @@ async function ensureTemplateFields(): Promise<void> {
}
const GLOBAL_TEMPLATE_ICONS = [
"Bed",
"Sparkles",
"BookOpen",
"Utensils",
"Wallet",
"Trophy",
"Star",
"Moon",
"Sun",
"Apple",
"Car",
"Gamepad2",
"Music",
"Shirt",
"Leaf",
"Heart",
"Gift",
"Timer",
'Bed',
'Sparkles',
'BookOpen',
'Utensils',
'Wallet',
'Trophy',
'Star',
'Moon',
'Sun',
'Apple',
'Car',
'Gamepad2',
'Music',
'Shirt',
'Leaf',
'Heart',
'Gift',
'Timer'
];
async function seedGlobalTemplates(): Promise<void> {
@@ -586,76 +640,104 @@ async function seedGlobalTemplates(): Promise<void> {
`${PB_ENDPOINT}/api/collections/${name}/records?filter=global=true&perPage=1`,
{ headers: { Authorization: `Bearer ${t}` } }
);
return ((await res.json())?.items?.length) || 0;
return (await res.json())?.items?.length || 0;
};
if ((await countGlobal("chore_templates")) > 0 || (await countGlobal("bonus_templates")) > 0) {
console.log("[migrate] Global templates already present — skipping seed.");
if ((await countGlobal('chore_templates')) > 0 || (await countGlobal('bonus_templates')) > 0) {
console.log('[migrate] Global templates already present — skipping seed.');
return;
}
const choreSeeds = [
{ name: "Make Bed", defaultFrequency: "daily", defaultType: "points", defaultValue: 2, icon: "Bed", color: "#6366f1" },
{ name: "Tidy Room", defaultFrequency: "weekly", defaultType: "points", defaultValue: 10, icon: "Sparkles", color: "#8b5cf6" },
{ name: "Homework", defaultFrequency: "daily", defaultType: "points", defaultValue: 5, icon: "BookOpen", color: "#0ea5e9" },
{ name: "Set Table", defaultFrequency: "daily", defaultType: "money", defaultValue: 1, icon: "Utensils", color: "#10b981" },
{
name: 'Make Bed',
defaultFrequency: 'daily',
defaultType: 'points',
defaultValue: 2,
icon: 'Bed',
color: '#6366f1'
},
{
name: 'Tidy Room',
defaultFrequency: 'weekly',
defaultType: 'points',
defaultValue: 10,
icon: 'Sparkles',
color: '#8b5cf6'
},
{
name: 'Homework',
defaultFrequency: 'daily',
defaultType: 'points',
defaultValue: 5,
icon: 'BookOpen',
color: '#0ea5e9'
},
{
name: 'Set Table',
defaultFrequency: 'daily',
defaultType: 'money',
defaultValue: 1,
icon: 'Utensils',
color: '#10b981'
}
];
const bonusSeeds = [
{
name: "Pocket Money",
target: "individual",
type: "threshold",
thresholdType: "percent",
occurrence: "recurring",
rewardType: "cash",
rewardValue: "",
name: 'Pocket Money',
target: 'individual',
type: 'threshold',
thresholdType: 'percent',
occurrence: 'recurring',
rewardType: 'cash',
rewardValue: '',
criteriaValue: 50,
period: "weekly",
period: 'weekly',
isPocketMoney: true,
icon: "Wallet",
color: "#22c55e",
icon: 'Wallet',
color: '#22c55e'
},
{
name: "Reading Bonus",
target: "individual",
type: "count",
occurrence: "recurring",
rewardType: "points",
rewardValue: "50",
name: 'Reading Bonus',
target: 'individual',
type: 'count',
occurrence: 'recurring',
rewardType: 'points',
rewardValue: '50',
criteriaValue: 5,
period: "weekly",
icon: "BookOpen",
color: "#f59e0b",
period: 'weekly',
icon: 'BookOpen',
color: '#f59e0b'
},
{
name: "Star of the Week",
target: "competitive",
type: "threshold",
thresholdType: "points",
occurrence: "weekly",
rewardType: "prize",
rewardValue: "Treat",
name: 'Star of the Week',
target: 'competitive',
type: 'threshold',
thresholdType: 'points',
occurrence: 'weekly',
rewardType: 'prize',
rewardValue: 'Treat',
criteriaValue: 100,
period: "weekly",
icon: "Trophy",
color: "#ef4444",
},
period: 'weekly',
icon: 'Trophy',
color: '#ef4444'
}
];
for (const s of choreSeeds) {
await fetch(`${PB_ENDPOINT}/api/collections/chore_templates/records`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${t}` },
body: JSON.stringify({ ...s, global: true }),
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${t}` },
body: JSON.stringify({ ...s, global: true })
}).catch(() => {});
}
for (const s of bonusSeeds) {
await fetch(`${PB_ENDPOINT}/api/collections/bonus_templates/records`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${t}` },
body: JSON.stringify({ ...s, global: true }),
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${t}` },
body: JSON.stringify({ ...s, global: true })
}).catch(() => {});
}
console.log("[migrate] Seeded global templates.");
console.log('[migrate] Seeded global templates.');
}
export { GLOBAL_TEMPLATE_ICONS };
-59
View File
@@ -1,59 +0,0 @@
import type { Handle } from '@sveltejs/kit';
import { redirect } from '@sveltejs/kit';
import { createPbClient } from '$lib/server/pocketbase';
import { SESSION_COOKIE, setSessionCookie, clearSessionCookie } from '$lib/server/session';
import type { SessionUser } from '$lib/server/types';
const PUBLIC_PATHS = ['/login', '/signup', '/pair'];
const ADMIN_ONLY_PREFIXES = ['/admin'];
export const handle: Handle = async ({ event, resolve }) => {
event.locals.user = null;
event.locals.pbToken = null;
const token = event.cookies.get(SESSION_COOKIE);
if (token) {
const pb = createPbClient(token);
try {
// authRefresh() does two jobs in one call:
// 1. Verifies the token. PocketBase JWTs can't be checked
// offline (the signing secret is per-record and never
// leaves PB), so this round trip IS the verification step.
// 2. Returns the current record — which is the only way to get
// username/role/famId, since PB deliberately doesn't embed
// custom fields in the token itself.
const { record, token: freshToken } = await pb.collection('users').authRefresh();
event.locals.user = {
id: record.id,
name: record.name,
role: record.role,
famId: record.famId
} satisfies SessionUser;
event.locals.pbToken = freshToken;
if (freshToken !== token) {
setSessionCookie(event.cookies, freshToken);
}
} catch {
// Expired, malformed, or revoked (password/deviceToken changed
// since this token was issued) — drop it and treat as logged out.
clearSessionCookie(event.cookies);
}
}
const path = event.url.pathname;
const isPublic = PUBLIC_PATHS.some((p) => path.startsWith(p));
if (!isPublic && !event.locals.user) {
throw redirect(303, '/login');
}
if (ADMIN_ONLY_PREFIXES.some((p) => path.startsWith(p)) && !event.locals.user) {
console.log("rejecting",event.locals.user);
throw redirect(303, '/');
}
return resolve(event);
};
+139 -37
View File
@@ -40,14 +40,19 @@
<span class="pill">For busy families</span>
<h1>Chores done. <span class="accent">Allowance earned.</span></h1>
<p class="lede">
FamChore turns everyday household chores into points and pocket money. Assign the
chores, let your kids see their progress live, and FamChore calculates the allowance
automatically — no spreadsheets, no nagging.
FamChore turns everyday household chores into points and pocket money. Assign the chores,
let your kids see their progress live, and FamChore calculates the allowance automatically
— no spreadsheets, no nagging.
</p>
<ul class="hero-ticks">
<li><span class="tick">{@html checkCircleIcon}</span>Set up in under a minute</li>
<li><span class="tick">{@html checkCircleIcon}</span>Kids join with a simple invite code</li>
<li><span class="tick">{@html checkCircleIcon}</span>Allowance lands on payday, automatically</li>
<li>
<span class="tick">{@html checkCircleIcon}</span>Kids join with a simple invite code
</li>
<li>
<span class="tick">{@html checkCircleIcon}</span>Allowance lands on payday,
automatically
</li>
</ul>
</div>
@@ -56,7 +61,9 @@
<p class="card-sub">Free to get going. Takes about a minute.</p>
<a class="submit" href="/signup">Create my family</a>
<p class="card-alt">
<a href="/pricing">See pricing {@html chevronRightIcon}</a>
<a class="flex gap-2 self-center align-center justify-center" href="/pricing"
>See pricing {@html chevronRightIcon}</a
>
</p>
<p class="card-alt">
Already have a family? <a href="/login">Log in</a>
@@ -72,17 +79,26 @@
<article class="step">
<span class="step-num">1</span>
<h3>Create your family</h3>
<p>Set up chores and how much each one is worth. Your kids join in seconds with an invite code.</p>
<p>
Set up chores and how much each one is worth. Your kids join in seconds with an invite
code.
</p>
</article>
<article class="step">
<span class="step-num">2</span>
<h3>Kids do the work</h3>
<p>They see today's chores as a simple card board and tick them off as they go — points are added instantly.</p>
<p>
They see today's chores as a simple card board and tick them off as they go — points are
added instantly.
</p>
</article>
<article class="step">
<span class="step-num">3</span>
<h3>Allowance pays out</h3>
<p>Points add up, then turn into pocket money on payday. Rewards and monthly bonuses keep it fun.</p>
<p>
Points add up, then turn into pocket money on payday. Rewards and monthly bonuses keep it
fun.
</p>
</article>
</div>
</section>
@@ -97,7 +113,10 @@
</article>
<article class="feature">
<h3>Live progress</h3>
<p>Realtime sync across the whole family — see points and progress update the moment a chore is done.</p>
<p>
Realtime sync across the whole family — see points and progress update the moment a chore
is done.
</p>
</article>
<article class="feature">
<h3>Automatic allowance</h3>
@@ -109,17 +128,27 @@
</article>
<article class="feature">
<h3>Kid-friendly</h3>
<p>A simple, colourful interface kids love — big buttons, clear feedback, their own space.</p>
<p>
A simple, colourful interface kids love — big buttons, clear feedback, their own space.
</p>
</article>
<article class="feature">
<h3>Private by design</h3>
<p>Everything is scoped to your family. Kids join with an invite code and stay in your family.</p>
<p>
Everything is scoped to your family. Kids join with an invite code and stay in your
family.
</p>
</article>
</div>
<div class="cta">
<p>Ready to make chores painless?</p>
<a class="cta-btn" href="/signup">Create your family</a>
<p class="card-alt"><a href="/pricing">or see pricing {@html chevronRightIcon}</a></p>
<p class="card-alt">
<a
class="hover:underline self-center items-center justify-center flex gap-2"
href="/pricing">or see pricing {@html chevronRightIcon}</a
>
</p>
</div>
</section>
</main>
@@ -128,7 +157,10 @@
<style>
.landing {
font-family: system-ui, -apple-system, sans-serif;
font-family:
system-ui,
-apple-system,
sans-serif;
color: #1f2937;
}
@@ -149,7 +181,9 @@
text-decoration: none;
color: #1f2937;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
transition: border-color 0.15s, box-shadow 0.15s;
transition:
border-color 0.15s,
box-shadow 0.15s;
}
.return-chip:hover {
border-color: #c7d2fe;
@@ -214,7 +248,9 @@
line-height: 1.1;
margin: 0 0 1rem;
}
.accent { color: #4338ca; }
.accent {
color: #4338ca;
}
.lede {
font-size: 1.1rem;
line-height: 1.6;
@@ -230,7 +266,7 @@
gap: 0.6rem;
}
.hero-ticks li::before {
content: "";
content: '';
}
.hero-ticks .tick {
display: inline-flex;
@@ -254,9 +290,19 @@
box-shadow: 0 12px 30px rgba(67, 56, 202, 0.08);
padding: 1.75rem;
}
.signup-card h2 { margin: 0 0 0.25rem; font-size: 1.35rem; }
.card-sub { margin: 0 0 1.25rem; color: #6b7280; font-size: 0.9rem; }
.signup-card form { display: grid; gap: 0.9rem; }
.signup-card h2 {
margin: 0 0 0.25rem;
font-size: 1.35rem;
}
.card-sub {
margin: 0 0 1.25rem;
color: #6b7280;
font-size: 0.9rem;
}
.signup-card form {
display: grid;
gap: 0.9rem;
}
.signup-card label {
display: flex;
flex-direction: column;
@@ -299,18 +345,38 @@
font-weight: 600;
cursor: pointer;
}
.submit:hover { background: #3730a3; }
.submit:disabled { opacity: 0.6; cursor: not-allowed; }
.card-alt { margin: 1rem 0 0; font-size: 0.85rem; color: #6b7280; text-align: center; }
.card-alt a { color: #4338ca; text-decoration: none; font-weight: 500; }
.submit:hover {
background: #3730a3;
}
.submit:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.card-alt {
margin: 1rem 0 0;
font-size: 0.85rem;
color: #6b7280;
text-align: center;
}
.card-alt a {
color: #4338ca;
text-decoration: none;
font-weight: 500;
white-space: nowrap;
}
.card-alt a :global(svg) {
vertical-align: -0.2em;
}
/* EXPLAINER */
.explainer, .features {
.explainer,
.features {
max-width: 1100px;
margin: 0 auto;
padding: 4rem 1.5rem;
}
.explainer h2, .features h2 {
.explainer h2,
.features h2 {
text-align: center;
font-size: 1.9rem;
margin: 0 0 2rem;
@@ -338,11 +404,22 @@
font-weight: 700;
margin-bottom: 0.75rem;
}
.step h3 { margin: 0 0 0.5rem; font-size: 1.05rem; }
.step p { margin: 0; color: #4b5563; font-size: 0.92rem; line-height: 1.55; }
.step h3 {
margin: 0 0 0.5rem;
font-size: 1.05rem;
}
.step p {
margin: 0;
color: #4b5563;
font-size: 0.92rem;
line-height: 1.55;
}
/* FEATURES */
.features { background: #f5f6fa; border-radius: 0; }
.features {
background: #f5f6fa;
border-radius: 0;
}
.feature-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
@@ -354,10 +431,26 @@
border-radius: 12px;
padding: 1.25rem;
}
.feature h3 { margin: 0 0 0.4rem; font-size: 1rem; color: #111827; }
.feature p { margin: 0; color: #4b5563; font-size: 0.9rem; line-height: 1.55; }
.cta { text-align: center; margin-top: 2.5rem; }
.cta p { font-size: 1.15rem; font-weight: 600; margin: 0 0 1rem; }
.feature h3 {
margin: 0 0 0.4rem;
font-size: 1rem;
color: #111827;
}
.feature p {
margin: 0;
color: #4b5563;
font-size: 0.9rem;
line-height: 1.55;
}
.cta {
text-align: center;
margin-top: 2.5rem;
}
.cta p {
font-size: 1.15rem;
font-weight: 600;
margin: 0 0 1rem;
}
.cta-btn {
display: inline-block;
background: #4338ca;
@@ -367,12 +460,21 @@
padding: 0.7rem 1.5rem;
border-radius: 8px;
}
.cta-btn:hover { background: #3730a3; }
.cta-btn:hover {
background: #3730a3;
}
/* RESPONSIVE */
@media (max-width: 860px) {
.hero-inner { grid-template-columns: 1fr; }
.steps, .feature-grid { grid-template-columns: 1fr; }
h1 { font-size: 2rem; }
.hero-inner {
grid-template-columns: 1fr;
}
.steps,
.feature-grid {
grid-template-columns: 1fr;
}
h1 {
font-size: 2rem;
}
}
</style>
+1 -1
View File
@@ -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="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,11 +370,9 @@
</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">
<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>
@@ -329,8 +385,93 @@
/>
<Button type="submit" size="sm">Add child</Button>
</form>
</div>
</Card>
<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}>
<option value="">— Select a child —</option>
{#each members as m}
<option value={m.name}>{m.name}</option>
{/each}
</select>
<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
>
</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
>
</form>
</div>
</div>
</Card>
<Card title="All members ({members.length})" cols={1}>
<ul class="members-list">
{#each members as m}
<li>
@@ -341,10 +482,6 @@
<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
@@ -354,87 +491,69 @@
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">
<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) {
@@ -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>
{#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.
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;
+62
View File
@@ -0,0 +1,62 @@
import { json } from '@sveltejs/kit';
import { RESEND_API } from '$app/env/private';
import { createSuperClient } from '$lib/server/pocketbase';
import { Resend } from 'resend';
export async function POST({ request, url }) {
const fd = await request.formData();
const email = (fd.get('email') || '').toString().trim().toLowerCase();
if (!email) {
return json({ ok: true }); // Generic response to avoid enumeration
}
try {
const pb = await createSuperClient();
// Find user by email
let user;
try {
user = await pb.collection('users').getFirstListItem(`email = '${email}'`);
} catch {
// User not found — still return success
return json({ ok: true });
}
// Only allow password reset for parents (they have emails)
if (user.role !== 'parent') {
return json({ ok: true });
}
// Generate token and expiry (1 hour)
const token = globalThis.crypto.randomUUID();
const expiry = new Date(Date.now() + 60 * 60 * 1000).toISOString();
// Store token on user record
await pb.collection('users').update(user.id, {
passwordResetToken: token,
passwordResetExpiry: expiry
});
// Send email via Resend
const resend = new Resend(String(RESEND_API));
const resetUrl = `${url.origin}/login/verify/${token}`;
await resend.emails.send({
from: 'no-reply@famchamp.ai',
to: email,
subject: 'Reset your FamChore password',
html: `
<p>You requested a password reset for your FamChore account.</p>
<p><a href="${resetUrl}">Click here to reset your password</a></p>
<p>This link expires in 1 hour.</p>
<p>If you didn't request this, you can ignore this email.</p>
`
});
} catch {
// Swallow all errors to avoid information leakage
}
// Always return success
return json({ ok: true });
}
+92
View File
@@ -4,9 +4,66 @@
let { form } = $props();
let email = $state('');
let password = $state('');
let showForgot = $state(false);
let forgotEmail = $state('');
let forgotLoading = $state(false);
let forgotSuccess = $state(false);
async function handleForgot() {
if (!forgotEmail || forgotLoading) return;
forgotLoading = true;
try {
const fd = new FormData();
fd.set('email', forgotEmail);
const res = await fetch('/api/email', { method: 'POST', body: fd });
if (res.ok) {
forgotSuccess = true;
}
} catch {
// Silent — generic response
} finally {
forgotLoading = false;
}
}
</script>
<AuthShell title="Log in" subtitle="Welcome back to FamChore.">
{#if showForgot}
{#if forgotSuccess}
<p class="form-ok">
If an account exists for <strong>{forgotEmail}</strong>, a reset link is on its way.
</p>
<button
class="row-btn centered"
type="button"
onclick={() => {
showForgot = false;
forgotSuccess = false;
forgotEmail = '';
}}
>
Back to log in
</button>
{:else}
<form
onsubmit={(e) => {
e.preventDefault();
handleForgot();
}}
>
<label>
Email
<input type="email" bind:value={forgotEmail} placeholder="you@email.com" required />
</label>
<button type="submit" disabled={forgotLoading}>
{forgotLoading ? 'Sending...' : 'Send reset link'}
</button>
</form>
<button class="row-btn centered" type="button" onclick={() => (showForgot = false)}>
Back to log in
</button>
{/if}
{:else}
{#if form?.error}
<p class="form-error">{form.error}</p>
{/if}
@@ -20,12 +77,17 @@
Password
<input type="password" name="password" bind:value={password} required />
</label>
<button class="row-btn" type="button" onclick={() => (showForgot = true)}>
Forgot password?
</button>
<button type="submit">Log in</button>
</form>
<p class="alt">
Don't have a family yet? <a href="/signup">Create one</a>
</p>
<p>Trying to join as a child? Ask your parent to issue your QR code.</p>
{/if}
</AuthShell>
<style>
@@ -66,6 +128,36 @@
button:hover {
background: #3730a3;
}
button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.row-btn {
background: none;
color: #4338ca;
font-size: 0.85rem;
font-weight: 500;
padding: 0;
text-decoration: underline;
cursor: pointer;
}
.row-btn:hover {
background: none;
}
.row-btn.centered {
display: block;
margin: 1.25rem auto 0;
width: fit-content;
}
.form-ok {
background: #f0fdf4;
color: #166534;
border: 1px solid #bbf7d0;
border-radius: 8px;
padding: 0.6rem 0.75rem;
font-size: 0.85rem;
margin: 0 0 1rem;
}
.form-error {
background: #fef2f2;
color: #b91c1c;
@@ -0,0 +1,95 @@
import { fail, redirect } from '@sveltejs/kit';
import { createSuperClient } from '$lib/server/pocketbase';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ params }) => {
const { token } = params;
try {
const pb = await createSuperClient();
// Find user with this reset token
const user = await pb
.collection('users')
.getFirstListItem(`passwordResetToken = '${token}'`)
.catch(() => null);
if (!user || !user.passwordResetExpiry) {
throw redirect(303, '/login');
}
// Check expiry (1 hour)
const expiryDate = new Date(user.passwordResetExpiry);
if (expiryDate < new Date()) {
// Token expired — clear it
await pb.collection('users').update(user.id, {
passwordResetToken: '',
passwordResetExpiry: ''
});
throw redirect(303, '/login');
}
return { valid: true };
} catch (e) {
// Redirect errors propagate; anything else → login
if (e instanceof Response) throw e;
throw redirect(303, '/login');
}
};
export const actions = {
default: async ({ params, request }) => {
const { token } = params;
const fd = await request.formData();
const password = fd.get('password') as string;
const confirmPassword = fd.get('confirmPassword') as string;
if (!password || !confirmPassword) {
return fail(400, { error: 'Both fields are required' });
}
if (password !== confirmPassword) {
return fail(400, { error: 'Passwords do not match' });
}
if (password.length < 10) {
return fail(400, { error: 'Password must be at least 10 characters' });
}
try {
const pb = await createSuperClient();
// Find user with this reset token
const user = await pb
.collection('users')
.getFirstListItem(`passwordResetToken = '${token}'`)
.catch(() => null);
if (!user || !user.passwordResetExpiry) {
return fail(400, { error: 'Invalid or expired reset link' });
}
// Check expiry (1 hour)
const expiryDate = new Date(user.passwordResetExpiry);
if (expiryDate < new Date()) {
// Token expired — clear it
await pb.collection('users').update(user.id, {
passwordResetToken: '',
passwordResetExpiry: ''
});
return fail(400, { error: 'Reset link has expired. Please request a new one.' });
}
// Update password and clear token
await pb.collection('users').update(user.id, {
password,
passwordResetToken: '',
passwordResetExpiry: ''
});
return { success: true };
} catch {
return fail(400, { error: 'Something went wrong. Please try again.' });
}
}
};
@@ -0,0 +1,114 @@
<script lang="ts">
import AuthShell from '$lib/components/AuthShell.svelte';
let { form } = $props();
let password = $state('');
let confirmPassword = $state('');
</script>
<AuthShell title="Reset password" subtitle="Enter your new password below.">
{#if form?.success}
<p class="form-ok">Your password has been reset. You can now log in.</p>
<p class="alt"><a href="/login">Back to log in</a></p>
{:else}
{#if form?.error}
<p class="form-error">{form.error}</p>
{/if}
<form method="POST">
<label>
New password
<input
type="password"
name="password"
bind:value={password}
placeholder="At least 10 characters"
required
minlength="10"
/>
</label>
<label>
Confirm password
<input
type="password"
name="confirmPassword"
bind:value={confirmPassword}
placeholder="Re-enter your password"
required
minlength="10"
/>
</label>
<button type="submit">Reset password</button>
</form>
<p class="alt"><a href="/login">Back to log in</a></p>
{/if}
</AuthShell>
<style>
form {
display: grid;
gap: 0.9rem;
}
label {
display: flex;
flex-direction: column;
gap: 0.3rem;
font-size: 0.85rem;
font-weight: 500;
color: #374151;
}
input {
padding: 0.6rem 0.75rem;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 0.95rem;
}
input:focus {
outline: none;
border-color: #4338ca;
box-shadow: 0 0 0 3px rgba(67, 56, 202, 0.15);
}
button {
margin-top: 0.25rem;
background: #4338ca;
color: #fff;
border: none;
border-radius: 8px;
padding: 0.75rem;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
}
button:hover {
background: #3730a3;
}
.form-ok {
background: #f0fdf4;
color: #166534;
border: 1px solid #bbf7d0;
border-radius: 8px;
padding: 0.6rem 0.75rem;
font-size: 0.85rem;
margin: 0 0 1rem;
}
.form-error {
background: #fef2f2;
color: #b91c1c;
border: 1px solid #fecaca;
border-radius: 8px;
padding: 0.6rem 0.75rem;
font-size: 0.85rem;
margin: 0 0 1rem;
}
.alt {
margin: 1.25rem 0 0;
font-size: 0.85rem;
color: #6b7280;
text-align: center;
}
.alt a {
color: #4338ca;
text-decoration: none;
font-weight: 500;
}
</style>
@@ -3,7 +3,6 @@ import { clearSessionCookie, clearLegacyCookies } from '$lib/server/session';
function signOut(event: { cookies: any }) {
clearSessionCookie(event.cookies);
event.cookies.delete('session', { path: '/' });
clearLegacyCookies(event.cookies);
}
+1 -1
View File
@@ -13,5 +13,5 @@
"esbuild"
]
},
"version": "1.8.6"
"version": "1.8.7"
}
+40
View File
@@ -29,6 +29,9 @@ importers:
qrcode:
specifier: ^1.5.4
version: 1.5.4
resend:
specifier: ^6.25.0
version: 6.25.0
stripe:
specifier: ^22.5.0
version: 22.5.0(@types/node@26.0.0)
@@ -484,6 +487,9 @@ packages:
'@rolldown/pluginutils@1.0.1':
resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==}
'@stablelib/base64@1.0.1':
resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==}
'@standard-schema/spec@1.1.0':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
@@ -726,6 +732,9 @@ packages:
'@typescript-eslint/types':
optional: true
fast-sha256@1.3.0:
resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==}
fdir@6.5.0:
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
engines: {node: '>=12.0.0'}
@@ -893,6 +902,9 @@ packages:
pocketbase@0.27.0:
resolution: {integrity: sha512-K5N6d93UP/BNMbMnlZ6BUfy9VPCIvLyqhJFOsNI8OsZwzvKWEAfyD36boi5K4ECIOl5HMlo0TzuaeGdKpMwizQ==}
postal-mime@2.7.5:
resolution: {integrity: sha512-GNEXKvWFQnbgO5NlrGzVa0FmWzBZ24PersAWErttSg1Hjpf0ATxTwS5DOMGaOpTG6bUh5cTr7xi0jAD942wCJA==}
postcss@8.5.15:
resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==}
engines: {node: ^10 || ^12 || >=14}
@@ -980,6 +992,15 @@ packages:
require-main-filename@2.0.0:
resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==}
resend@6.25.0:
resolution: {integrity: sha512-iptUEycs+6Hu+W8mExK708LrDiMYOuGgnCP+wTD5L4Zrqqd2B86h1oyAmNe0khZWQw0ccI7jz8OgAaplEsyaIA==}
engines: {node: '>=20'}
peerDependencies:
'@react-email/render': '*'
peerDependenciesMeta:
'@react-email/render':
optional: true
rolldown@1.0.3:
resolution: {integrity: sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -1005,6 +1026,9 @@ packages:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
standardwebhooks@1.0.0:
resolution: {integrity: sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==}
string-width@4.2.3:
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
engines: {node: '>=8'}
@@ -1396,6 +1420,8 @@ snapshots:
'@rolldown/pluginutils@1.0.1': {}
'@stablelib/base64@1.0.1': {}
'@standard-schema/spec@1.1.0': {}
'@stripe/stripe-js@9.13.0': {}
@@ -1609,6 +1635,8 @@ snapshots:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
fast-sha256@1.3.0: {}
fdir@6.5.0(picomatch@4.0.4):
optionalDependencies:
picomatch: 4.0.4
@@ -1720,6 +1748,8 @@ snapshots:
pocketbase@0.27.0: {}
postal-mime@2.7.5: {}
postcss@8.5.15:
dependencies:
nanoid: 3.3.15
@@ -1751,6 +1781,11 @@ snapshots:
require-main-filename@2.0.0: {}
resend@6.25.0:
dependencies:
postal-mime: 2.7.5
standardwebhooks: 1.0.0
rolldown@1.0.3:
dependencies:
'@oxc-project/types': 0.133.0
@@ -1807,6 +1842,11 @@ snapshots:
source-map-js@1.2.1: {}
standardwebhooks@1.0.0:
dependencies:
'@stablelib/base64': 1.0.1
fast-sha256: 1.3.0
string-width@4.2.3:
dependencies:
emoji-regex: 8.0.0