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", "chart.js": "^4.4.0",
"pocketbase": "^0.27.0", "pocketbase": "^0.27.0",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"resend": "^6.25.0",
"stripe": "^22.5.0" "stripe": "^22.5.0"
} }
} }
+11 -9
View File
@@ -1,16 +1,17 @@
import { defineEnvVars } from '@sveltejs/kit/hooks'; import { defineEnvVars } from '@sveltejs/kit/hooks';
// Default when the env var isn't set, so a missing value never crashes startup. // 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, '~standard': {
vendor: 'famchamp', version: 1,
validate: (v: unknown) => ({ value: typeof v === 'string' && v ? v : value }) vendor: 'famchamp',
} validate: (v: unknown) => ({ value: typeof v === 'string' && v ? v : value })
} as const); }
}) as const;
export const variables = defineEnvVars({ export const variables = defineEnvVars({
SERVER_IP: { public: true, schema: withDefault('192.168.1.225') }, SERVER_IP: { public: true, schema: withDefault('192.168.1.225') },
// PB superuser creds (server-only). // PB superuser creds (server-only).
PB_EMAIL: { public: false, schema: withDefault('debug@famchamp.dev') }, PB_EMAIL: { public: false, schema: withDefault('debug@famchamp.dev') },
PB_PASSWORD: { public: false, schema: withDefault('debug123') }, PB_PASSWORD: { public: false, schema: withDefault('debug123') },
@@ -28,5 +29,6 @@ export const variables = defineEnvVars({
STRIPE_PRICE_MONTHLY: { public: false, schema: withDefault('') }, STRIPE_PRICE_MONTHLY: { public: false, schema: withDefault('') },
STRIPE_PRICE_YEARLY: { public: false, schema: withDefault('') }, STRIPE_PRICE_YEARLY: { public: false, schema: withDefault('') },
// Stripe publishable key (client-side for Checkout redirect). // 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"> <script lang="ts">
import type { Snippet } from 'svelte'; 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> </script>
<div class="accordion-item" class:open> <div class="accordion-item" class:open>
<button <button class="accordion-trigger" onclick={() => (open = !open)} aria-expanded={open}>
class="accordion-trigger" <span class="accordion-title">
onclick={() => (open = !open)} {#if icon}
aria-expanded={open} <span class="accordion-icon">{@render icon()}</span>
> {/if}
<span>{title}</span> <span>{title}</span>
<span class="accordion-arrow">{open ? '▾' : '▸'}</span> </span>
<span class="accordion-arrow" class:rotated={open}><ChevronDown /></span>
</button> </button>
{#if open} {#if open}
<div class="accordion-body"> <div class="accordion-body">
@@ -21,16 +28,58 @@
</div> </div>
<style> <style>
.accordion-item { border-bottom: 1px solid #f3f4f6; } .accordion-item {
.accordion-item:last-child { border-bottom: none; } border-bottom: 1px solid #f3f4f6;
.accordion-trigger { }
display: flex; justify-content: space-between; align-items: center; .accordion-item:last-child {
width: 100%; padding: 0.7rem 1rem; border-bottom: none;
background: #fafafa; border: none; }
font-size: 0.95rem; font-weight: 600; color: #374151; .accordion-trigger {
cursor: pointer; text-align: left; 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> </style>
+84 -17
View File
@@ -1,6 +1,13 @@
<script lang="ts"> <script lang="ts">
let { variant = 'primary', size = 'md', href, onclick, children, ...rest }: { let {
variant?: 'primary' | 'secondary' | 'ghost' | 'danger' | 'success'; variant = 'primary',
size = 'md',
href,
onclick,
children,
...rest
}: {
variant?: 'primary' | 'secondary' | 'ghost' | 'danger' | 'success' | 'purple';
size?: 'sm' | 'md' | 'lg'; size?: 'sm' | 'md' | 'lg';
href?: string; href?: string;
onclick?: () => void; onclick?: () => void;
@@ -10,7 +17,7 @@
</script> </script>
{#if href} {#if href}
<a href={href} class="btn btn-{variant} btn-{size}" {...rest}> <a {href} class="btn btn-{variant} btn-{size}" {...rest}>
{@render children?.()} {@render children?.()}
</a> </a>
{:else} {:else}
@@ -28,25 +35,85 @@
border-radius: 6px; border-radius: 6px;
cursor: pointer; cursor: pointer;
text-decoration: none; text-decoration: none;
transition: background 0.15s, border-color 0.15s; transition:
background 0.15s,
border-color 0.15s;
border: 1px solid transparent; border: 1px solid transparent;
} }
.btn-sm { padding: 0.3rem 0.6rem; font-size: 0.8rem; } .btn-sm {
.btn-md { padding: 0.45rem 0.9rem; font-size: 0.85rem; } padding: 0.3rem 0.6rem;
.btn-lg { padding: 0.6rem 1.2rem; font-size: 0.95rem; } 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 {
.btn-primary:hover { background: #3730a3; } 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 {
.btn-secondary:hover { background: #e5e7eb; } background: #f3f4f6;
color: #374151;
border-color: #d1d5db;
}
.btn-secondary:hover {
background: #e5e7eb;
}
.btn-ghost { background: transparent; color: #6b7280; border-color: transparent; } .btn-ghost {
.btn-ghost:hover { background: #f3f4f6; } background: transparent;
color: #6b7280;
border-color: transparent;
}
.btn-ghost:hover {
background: #f3f4f6;
}
.btn-danger { background: #dc2626; color: #fff; border-color: #dc2626; } .btn-danger {
.btn-danger:hover { background: #b91c1c; } background: #dc2626;
color: #fff;
border-color: #dc2626;
}
.btn-danger:hover {
background: #b91c1c;
}
.btn-success { background: #059669; color: #fff; border-color: #059669; } .btn-success {
.btn-success:hover { background: #047857; } 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> </style>
+1 -1
View File
@@ -80,7 +80,7 @@
position: fixed; position: fixed;
top: 0; top: 0;
left: 0; left: 0;
height: 100vh; height: 100dvh;
width: 220px; width: 220px;
background: #1e1b4b; background: #1e1b4b;
color: #e0e7ff; color: #e0e7ff;
+5 -1
View File
@@ -5,11 +5,15 @@ export const pb = new PocketBase(PB_ENDPOINT);
pb.autoCancellation(false); pb.autoCancellation(false);
// Seed the browser PB singleton with the session token so shared stores can // 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) { export function initRealtimePb(token: string) {
if (token) { if (token) {
pb.authStore.save(token, null); pb.authStore.save(token, null);
return true; return true;
} }
pb.authStore.clear();
return false; 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)}` }; 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, // 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 // 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. // authWithPassword and returns a fresh PB JWT. Throws on any failure.
File diff suppressed because it is too large Load Diff
-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);
};
+153 -51
View File
@@ -40,14 +40,19 @@
<span class="pill">For busy families</span> <span class="pill">For busy families</span>
<h1>Chores done. <span class="accent">Allowance earned.</span></h1> <h1>Chores done. <span class="accent">Allowance earned.</span></h1>
<p class="lede"> <p class="lede">
FamChore turns everyday household chores into points and pocket money. Assign the FamChore turns everyday household chores into points and pocket money. Assign the chores,
chores, let your kids see their progress live, and FamChore calculates the allowance let your kids see their progress live, and FamChore calculates the allowance automatically
automatically — no spreadsheets, no nagging. — no spreadsheets, no nagging.
</p> </p>
<ul class="hero-ticks"> <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>Set up in under a minute</li>
<li><span class="tick">{@html checkCircleIcon}</span>Kids join with a simple invite code</li> <li>
<li><span class="tick">{@html checkCircleIcon}</span>Allowance lands on payday, automatically</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> </ul>
</div> </div>
@@ -56,7 +61,9 @@
<p class="card-sub">Free to get going. Takes about a minute.</p> <p class="card-sub">Free to get going. Takes about a minute.</p>
<a class="submit" href="/signup">Create my family</a> <a class="submit" href="/signup">Create my family</a>
<p class="card-alt"> <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>
<p class="card-alt"> <p class="card-alt">
Already have a family? <a href="/login">Log in</a> Already have a family? <a href="/login">Log in</a>
@@ -72,17 +79,26 @@
<article class="step"> <article class="step">
<span class="step-num">1</span> <span class="step-num">1</span>
<h3>Create your family</h3> <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>
<article class="step"> <article class="step">
<span class="step-num">2</span> <span class="step-num">2</span>
<h3>Kids do the work</h3> <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>
<article class="step"> <article class="step">
<span class="step-num">3</span> <span class="step-num">3</span>
<h3>Allowance pays out</h3> <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> </article>
</div> </div>
</section> </section>
@@ -97,7 +113,10 @@
</article> </article>
<article class="feature"> <article class="feature">
<h3>Live progress</h3> <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>
<article class="feature"> <article class="feature">
<h3>Automatic allowance</h3> <h3>Automatic allowance</h3>
@@ -109,17 +128,27 @@
</article> </article>
<article class="feature"> <article class="feature">
<h3>Kid-friendly</h3> <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>
<article class="feature"> <article class="feature">
<h3>Private by design</h3> <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> </article>
</div> </div>
<div class="cta"> <div class="cta">
<p>Ready to make chores painless?</p> <p>Ready to make chores painless?</p>
<a class="cta-btn" href="/signup">Create your family</a> <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> </div>
</section> </section>
</main> </main>
@@ -128,7 +157,10 @@
<style> <style>
.landing { .landing {
font-family: system-ui, -apple-system, sans-serif; font-family:
system-ui,
-apple-system,
sans-serif;
color: #1f2937; color: #1f2937;
} }
@@ -149,7 +181,9 @@
text-decoration: none; text-decoration: none;
color: #1f2937; color: #1f2937;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06); 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 { .return-chip:hover {
border-color: #c7d2fe; border-color: #c7d2fe;
@@ -214,7 +248,9 @@
line-height: 1.1; line-height: 1.1;
margin: 0 0 1rem; margin: 0 0 1rem;
} }
.accent { color: #4338ca; } .accent {
color: #4338ca;
}
.lede { .lede {
font-size: 1.1rem; font-size: 1.1rem;
line-height: 1.6; line-height: 1.6;
@@ -230,7 +266,7 @@
gap: 0.6rem; gap: 0.6rem;
} }
.hero-ticks li::before { .hero-ticks li::before {
content: ""; content: '';
} }
.hero-ticks .tick { .hero-ticks .tick {
display: inline-flex; display: inline-flex;
@@ -254,9 +290,19 @@
box-shadow: 0 12px 30px rgba(67, 56, 202, 0.08); box-shadow: 0 12px 30px rgba(67, 56, 202, 0.08);
padding: 1.75rem; padding: 1.75rem;
} }
.signup-card h2 { margin: 0 0 0.25rem; font-size: 1.35rem; } .signup-card h2 {
.card-sub { margin: 0 0 1.25rem; color: #6b7280; font-size: 0.9rem; } margin: 0 0 0.25rem;
.signup-card form { display: grid; gap: 0.9rem; } 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 { .signup-card label {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -285,32 +331,52 @@
font-size: 0.85rem; font-size: 0.85rem;
margin: 0; margin: 0;
} }
.submit { .submit {
display: block; display: block;
text-align: center; text-align: center;
margin-top: 0.25rem; margin-top: 0.25rem;
background: #4338ca; background: #4338ca;
color: #fff; color: #fff;
text-decoration: none; text-decoration: none;
border: none; border: none;
border-radius: 8px; border-radius: 8px;
padding: 0.75rem; padding: 0.75rem;
font-size: 1rem; font-size: 1rem;
font-weight: 600; font-weight: 600;
cursor: pointer; cursor: pointer;
} }
.submit:hover { background: #3730a3; } .submit:hover {
.submit:disabled { opacity: 0.6; cursor: not-allowed; } background: #3730a3;
.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: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 */
.explainer, .features { .explainer,
.features {
max-width: 1100px; max-width: 1100px;
margin: 0 auto; margin: 0 auto;
padding: 4rem 1.5rem; padding: 4rem 1.5rem;
} }
.explainer h2, .features h2 { .explainer h2,
.features h2 {
text-align: center; text-align: center;
font-size: 1.9rem; font-size: 1.9rem;
margin: 0 0 2rem; margin: 0 0 2rem;
@@ -338,11 +404,22 @@
font-weight: 700; font-weight: 700;
margin-bottom: 0.75rem; margin-bottom: 0.75rem;
} }
.step h3 { margin: 0 0 0.5rem; font-size: 1.05rem; } .step h3 {
.step p { margin: 0; color: #4b5563; font-size: 0.92rem; line-height: 1.55; } 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 */
.features { background: #f5f6fa; border-radius: 0; } .features {
background: #f5f6fa;
border-radius: 0;
}
.feature-grid { .feature-grid {
display: grid; display: grid;
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(3, 1fr);
@@ -354,10 +431,26 @@
border-radius: 12px; border-radius: 12px;
padding: 1.25rem; padding: 1.25rem;
} }
.feature h3 { margin: 0 0 0.4rem; font-size: 1rem; color: #111827; } .feature h3 {
.feature p { margin: 0; color: #4b5563; font-size: 0.9rem; line-height: 1.55; } margin: 0 0 0.4rem;
.cta { text-align: center; margin-top: 2.5rem; } font-size: 1rem;
.cta p { font-size: 1.15rem; font-weight: 600; margin: 0 0 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 { .cta-btn {
display: inline-block; display: inline-block;
background: #4338ca; background: #4338ca;
@@ -367,12 +460,21 @@
padding: 0.7rem 1.5rem; padding: 0.7rem 1.5rem;
border-radius: 8px; border-radius: 8px;
} }
.cta-btn:hover { background: #3730a3; } .cta-btn:hover {
background: #3730a3;
}
/* RESPONSIVE */ /* RESPONSIVE */
@media (max-width: 860px) { @media (max-width: 860px) {
.hero-inner { grid-template-columns: 1fr; } .hero-inner {
.steps, .feature-grid { grid-template-columns: 1fr; } grid-template-columns: 1fr;
h1 { font-size: 2rem; } }
.steps,
.feature-grid {
grid-template-columns: 1fr;
}
h1 {
font-size: 2rem;
}
} }
</style> </style>
+1 -1
View File
@@ -22,7 +22,7 @@
let accessReason = $derived(data.famAccess?.reason || ''); let accessReason = $derived(data.famAccess?.reason || '');
// Settings stays usable while paused so admins can apply a code / manage billing. // Settings stays usable while paused so admins can apply a code / manage billing.
let hasAuth = $derived(!!data.session); 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) ── // ── Post-checkout activation (event-driven) ──
// Landing with ?checkout=return: if the webhook has already landed we show // Landing with ?checkout=return: if the webhook has already landed we show
@@ -1,12 +1,17 @@
import { redirect, fail } from '@sveltejs/kit'; import { redirect, fail } from '@sveltejs/kit';
import type { RequestEvent } from '@sveltejs/kit'; import type { RequestEvent } from '@sveltejs/kit';
import { pbUser } from '$lib/server/pocketbase'; 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 { servicesFor } from '$lib/server/servicesFor';
import { issueAccess, createChild } from '$lib/server/member-otp'; import { issueAccess, createChild, inviteParent } from '$lib/server/member-otp';
import { slugify } from '@shared/slugify'; import { sendParentInviteEmail } from '$lib/server/email';
import { slugify, handle, famUsername } from '@shared/slugify';
import { applyAccessCode } from '$lib/server/access'; 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 { function famIdOf(event: RequestEvent): string {
if (!event.locals.user) throw redirect(303, '/login'); 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) => { renameFam: async (event: RequestEvent) => {
const famId = famIdOf(event); const famId = famIdOf(event);
const fd = await event.request.formData(); const fd = await event.request.formData();
@@ -144,9 +207,7 @@ export const actions = {
const id = fd.get('id') as string; const id = fd.get('id') as string;
const active = fd.get('active') === '1'; const active = fd.get('active') === '1';
if (!id) return { error: 'Season ID required' }; if (!id) return { error: 'Season ID required' };
await pbUser(event) await pbUser(event).collection('seasons').update(id, { active });
.collection('seasons')
.update(id, { active });
return { ok: true }; return { ok: true };
}, },
@@ -17,6 +17,10 @@
import { handleOf } from '@shared/slugify'; import { handleOf } from '@shared/slugify';
import { addMonthsUTC, formatShortDate } from '$lib/format'; import { addMonthsUTC, formatShortDate } from '$lib/format';
import QRCode from 'qrcode'; 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(); let { data } = $props();
@@ -77,14 +81,25 @@
} }
} }
}); });
let showQR = $state(false);
let qrDataUrl = $state(''); let qrDataUrl = $state('');
let copied = $state(false); let copied = $state(false);
let parentInviteName = $state('');
let parentInviteEmail = $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 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 // Optimistic season active toggle: flip immediately in the shared famStore
// (which drives both this list and the TopNav), then persist via the // (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 invitePath = $derived(issueModal ? `${issueModal.joinUrl}?code=${issueModal.otp}` : '');
let inviteUrl = $derived(issued ? `${page.url.origin}${invitePath}` : '');
function copy(url: string) { function copy(url: string) {
navigator.clipboard.writeText(url); navigator.clipboard.writeText(url);
@@ -117,20 +131,38 @@
setTimeout(() => (copied = false), 2000); 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) { async function generateQR(url: string) {
qrDataUrl = await QRCode.toDataURL(url, { width: 200, margin: 1 }); qrDataUrl = await QRCode.toDataURL(url, { width: 200, margin: 1 });
} }
function toggleQR() {
showQR = !showQR;
if (!showQR) qrDataUrl = '';
else generateQR(inviteUrl);
}
function handleParentInvite() { function handleParentInvite() {
alert('Parent invite coming soon — email would be sent to ' + parentInviteEmail); 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 { function timeUntil(iso: string): string {
const diffMs = new Date(iso).getTime() - Date.now(); const diffMs = new Date(iso).getTime() - Date.now();
if (diffMs <= 0) return 'expired'; if (diffMs <= 0) return 'expired';
@@ -158,9 +190,14 @@
<ViewHeader title="Settings" hero /> <ViewHeader title="Settings" hero />
{#snippet famIcon()}<House />{/snippet}
{#snippet inviteIcon()}<UserPlus />{/snippet}
{#snippet accountIcon()}<Shield />{/snippet}
{#snippet appIcon()}<Smartphone />{/snippet}
<Accordion> <Accordion>
<!-- Family --> <!-- Family -->
<AccordionItem title="Family" open> <AccordionItem title="Family" icon={famIcon} open>
<CardGrid> <CardGrid>
<Card title="Family Name"> <Card title="Family Name">
<p class="hint"> <p class="hint">
@@ -232,35 +269,53 @@
</p> </p>
</Card> </Card>
<Card title="Seasons" cols={1}> <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"> <form method="POST" action="?/createSeason" use:enhance class="season-form">
<label class="field-label" for="season-name">New season</label> <label class="field-label" for="season-name">New season</label>
<input id="season-name" name="name" placeholder="Season name" required /> <input id="season-name" name="name" placeholder="Season name" required />
<div class="color-row"> <div class="season-color-row">
<label for="season-color">Colour</label> <label>Colour</label>
<input <div class="season-swatches">
id="season-color" {#each seasonColors as color}
name="color" <button
type="color" type="button"
value="#6366f1" class="season-swatch"
class="color-input" class:selected={color === seasonColor}
/> style="background:{color}"
aria-label={color}
onclick={() => (seasonColor = color)}
></button>
{/each}
</div>
<input type="hidden" name="color" value={seasonColor} />
</div> </div>
<Button type="submit" size="sm">Add</Button> <Button type="submit" size="sm">Add</Button>
</form> </form>
<ul> <ul class="season-list">
{#each (famStore.initialized ? famStore.seasons : data.seasons) as s} <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> <li>
<span class="dot" style="background:{s.color}"></span> <span class="season-cell">
<span class="season-name">{s.name}</span> <span class="dot" style="background:{s.color}"></span>
<span class="season-name">{s.name}</span>
</span>
<button <button
type="button" type="button"
class="season-check" class="season-check"
class:on={s.active !== false} class:on={s.active !== false}
role="switch" role="switch"
aria-checked={s.active !== false} 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))} onclick={() => toggleSeasonActive(s, !(s.active !== false))}
> >
{#if s.active !== false} {#if s.active !== false}
@@ -272,12 +327,15 @@
stroke="currentColor" stroke="currentColor"
stroke-width="3.5" stroke-width="3.5"
stroke-linecap="round" stroke-linecap="round"
stroke-linejoin="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12" /></svg
><polyline points="20 6 9 17 4 12" /></svg
> >
{/if} {/if}
</button> </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> </li>
{/each} {/each}
</ul> </ul>
@@ -312,129 +370,190 @@
</CardGrid> </CardGrid>
</AccordionItem> </AccordionItem>
<AccordionItem title="Invites"> <AccordionItem title="Invites" icon={inviteIcon}>
<CardGrid> <CardGrid>
<Card title="Members ({members.length})" cols={2}> <Card title="New member" cols={1}>
<div class="members-grid"> <p class="hint">Add a child. They'll pick their own colour after joining.</p>
<div class="members-add"> <form method="POST" action="?/addMember" use:enhance>
<p class="hint">Add a child. They'll pick their own colour after joining.</p> <label class="field-label" for="new-child">New child</label>
<form method="POST" action="?/addMember" use:enhance> <input
<label class="field-label" for="new-child">New child</label> id="new-child"
<input name="name"
id="new-child" bind:value={addName}
name="name" placeholder="Child name"
bind:value={addName} required
placeholder="Child name" />
required <Button type="submit" size="sm">Add child</Button>
/> </form>
<Button type="submit" size="sm">Add child</Button>
</form>
</div>
<ul class="members-list">
{#each members as m}
<li>
<span class="member-left">
<span class="member-color" style="background:{m.color}"></span>
<span class="member-info">
<span class="member-name">{m.name}</span>
<span class="member-handle">/{famSlug}/{handleOf(m.username)}</span>
</span>
</span>
<span class="member-actions">
<Button href="/{famSlug}/{handleOf(m.username)}" variant="secondary" size="sm"
>Preview</Button
>
<form method="POST" action="?/deleteMember" use:enhance class="inline">
<input type="hidden" name="id" value={m.id} />
<Button
type="submit"
variant="danger"
size="sm"
onclick={() => confirm('Remove {m.name}?')}>Remove</Button
>
</form>
</span>
</li>
{/each}
</ul>
</div>
</Card> </Card>
<Card title="Invite Children" cols={1}> <Card title="Child access codes" cols={1} class="access-card">
<form <div class="access-body">
method="POST" <p class="hint">
action="?/issueAccess" Generates a 6-digit code valid for 20 minutes. The child enters it at the join link.
use:enhance={() => { </p>
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> <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> <option value="">— Select a child —</option>
{#each members as m} {#each members as m}
<option value={m.name}>{m.name}</option> <option value={m.name}>{m.name}</option>
{/each} {/each}
</select> </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="access-cta-row">
<div class="mt-3 rounded-lg border border-indigo-200 bg-indigo-50 p-4"> <form
<p class="text-xs text-slate-500">Code for {issued.name} (valid 20 min):</p> method="POST"
<p class="my-2 text-center text-4xl font-bold tracking-[0.3em] text-indigo-700"> action="?/issueAccess"
{issued.otp} use:enhance={() => {
</p> return async ({ formData, result }) => {
<p class="invite-url">{invitePath}</p> if (result.type !== 'success') return;
<div class="actions justify-center"> const d = result.data as
<Button variant="secondary" size="sm" onclick={() => copy(inviteUrl)} | {
>{copied ? 'Copied!' : 'Copy URL'}</Button 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} </form>
>{showQR ? 'Hide QR' : 'Show QR'}</Button
<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> </form>
{#if showQR && qrDataUrl}
<div class="qr-wrap"><img src={qrDataUrl} alt="QR Code" class="qr" /></div>
{/if}
</div> </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>
<Card title="Invite Parent" cols={1}> <Card title="Invite Parent" cols={1}>
<p class="hint">Send an email invitation for another parent to join as an admin.</p> <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> <label class="field-label" for="parent-email">Parent email</label>
<input <input
id="parent-email" id="parent-email"
name="email"
type="email" type="email"
bind:value={parentInviteEmail} bind:value={parentInviteEmail}
placeholder="parent@example.com" placeholder="parent@example.com"
required
/> />
<Button onclick={handleParentInvite} size="sm">Send invite</Button> <Button type="submit" size="sm">Send invite</Button>
</div> </form>
<p class="hint">They will set up their own password on first login.</p> <p class="hint">They'll receive a link with a code to set up their own password.</p>
</Card> </Card>
</CardGrid> </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>
<AccordionItem title="Account"> <AccordionItem title="Account" icon={accountIcon}>
<CardGrid> <CardGrid>
<!-- Access (codes) only relevant when NOT on a subscription --> <!-- Access (codes) only relevant when NOT on a subscription -->
{#if fam?.paymentMode !== 'sub'} {#if fam?.paymentMode !== 'sub'}
@@ -515,8 +634,9 @@
<!-- Cancel requested: no CTA — just the countdown to expiry --> <!-- Cancel requested: no CTA — just the countdown to expiry -->
<p class="hint"> <p class="hint">
<strong>No active subscription.</strong> You have <strong>No active subscription.</strong> You have
<strong>{timeUntil(data.subStatus.endsAt)}</strong> of access left <strong>{timeUntil(data.subStatus.endsAt)}</strong> of access left (until {formatShortDate(
(until {formatShortDate(String(data.subStatus.endsAt))}). No further charges. String(data.subStatus.endsAt)
)}). No further charges.
</p> </p>
{:else if fam?.paymentMode === 'sub'} {:else if fam?.paymentMode === 'sub'}
<div class="actions"> <div class="actions">
@@ -560,7 +680,7 @@
</CardGrid> </CardGrid>
</AccordionItem> </AccordionItem>
<AccordionItem title="App"> <AccordionItem title="App" icon={appIcon}>
<CardGrid> <CardGrid>
{#if page.data.platformFlags?.debug} {#if page.data.platformFlags?.debug}
<Card title="Debug Tools" cols={1} accent="#f59e0b"> <Card title="Debug Tools" cols={1} accent="#f59e0b">
@@ -703,25 +823,40 @@
} }
/* ── Colour rows (seasons) ── */ /* ── Colour rows (seasons) ── */
.color-row { .season-color-row {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.75rem; gap: 0.75rem;
flex-wrap: wrap;
} }
.color-row label { .season-color-row label {
font-size: 0.8rem; font-size: 0.8rem;
font-weight: 500; font-weight: 500;
color: #6b7280; color: #6b7280;
flex-shrink: 0; flex-shrink: 0;
} }
.color-input { .season-swatches {
width: 100%; display: flex;
max-width: 160px; align-items: center;
height: 36px; gap: 0.4rem;
padding: 2px; flex-wrap: wrap;
border: 1px solid #d1d5db; }
border-radius: 8px; .season-swatch {
width: 1.65rem;
height: 1.65rem;
border-radius: 50%;
border: 2px solid transparent;
cursor: pointer; 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 ── */ /* ── Lists ── */
@@ -742,9 +877,45 @@
li:last-child { li:last-child {
border-bottom: none; 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 { .season-name {
flex: 1; flex: 1;
min-width: 80px; min-width: 80px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
.season-check { .season-check {
width: 26px; width: 26px;
@@ -792,25 +963,20 @@
margin-top: 0.75rem; margin-top: 0.75rem;
} }
/* ── Members: two-column (add | list) ── */ /* ── Members ── */
.members-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.25rem;
align-items: start;
}
.members-add form {
margin-bottom: 0;
}
.members-list { .members-list {
border-left: 1px solid #f3f4f6; display: flex;
padding-left: 1.25rem; flex-direction: column;
} }
.member-left, .members-list li {
.member-actions { justify-content: space-between;
gap: 1rem;
}
.member-left {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
min-width: 0;
} }
.member-color { .member-color {
width: 22px; width: 22px;
@@ -834,21 +1000,6 @@
font-size: 0.72rem; font-size: 0.72rem;
color: #9ca3af; 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 ── */ /* ── Action rows ── */
.actions { .actions {
@@ -873,6 +1024,43 @@
border-radius: 8px; 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) ── */ /* ── Narrow cards: stack rows / actions full width (container query) ── */
@container (max-width: 380px) { @container (max-width: 380px) {
.payday-row { .payday-row {
@@ -882,7 +1070,7 @@
.payday-row select { .payday-row select {
width: 100%; width: 100%;
} }
.color-row { .season-color-row {
align-items: stretch; align-items: stretch;
} }
.actions { .actions {
@@ -1,6 +1,30 @@
import { fail, redirect } from '@sveltejs/kit'; 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 { 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 = { export const actions = {
default: async (event) => { default: async (event) => {
@@ -8,11 +32,35 @@ export const actions = {
const username = event.params.username; const username = event.params.username;
const fd = await event.request.formData(); const fd = await event.request.formData();
const otp = (fd.get('otp') || '').toString().trim(); 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.' }); if (!otp) return fail(400, { error: 'Enter the code shown by your parent.' });
try { 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); clearLegacyCookies(event.cookies);
setSessionCookie(event.cookies, token); setSessionCookie(event.cookies, token);
} catch (e) { } catch (e) {
@@ -7,21 +7,36 @@
const famSlug = page.params.fam; const famSlug = page.params.fam;
const username = page.params.username; const username = page.params.username;
let otp = $state(page.url.searchParams.get('code') || ''); 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> </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"> <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"> <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> <span class="home-badge">{@html homeIcon}</span>
</div> </div>
<h1 class="text-xl font-bold text-slate-900">Welcome to {famSlug}!</h1> {#if isParent}
<p class="mt-1 text-sm text-slate-500"> <h1 class="text-xl font-bold text-slate-900">Join {famSlug}</h1>
Hi <span class="font-semibold text-slate-700">{username}</span> — enter the code your parent <p class="mt-1 text-sm text-slate-500">
gave you to get started. Hi <span class="font-semibold text-slate-700">{username}</span> — enter the code from your
</p> 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 <form
class="mt-6 flex flex-col gap-3" class="mt-6 flex flex-col gap-3"
@@ -45,10 +60,32 @@
autocomplete="one-time-code" autocomplete="one-time-code"
class="w-full rounded-lg border border-slate-300 px-4 py-3 text-center text-2xl tracking-[0.5em] text-slate-900 outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-200" 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} {#if form?.error}
<p class="text-sm font-medium text-rose-600">{form.error}</p> <p class="text-sm font-medium text-rose-600">{form.error}</p>
{/if} {/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> </form>
<p class="mt-6 text-xs text-slate-400"> <p class="mt-6 text-xs text-slate-400">
@@ -56,6 +93,7 @@
</p> </p>
</section> </section>
</main> </main>
<style> <style>
.home-badge :global(svg) { .home-badge :global(svg) {
width: 1.75rem; 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 });
}
+110 -18
View File
@@ -4,28 +4,90 @@
let { form } = $props(); let { form } = $props();
let email = $state(''); let email = $state('');
let password = $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> </script>
<AuthShell title="Log in" subtitle="Welcome back to FamChore."> <AuthShell title="Log in" subtitle="Welcome back to FamChore.">
{#if form?.error} {#if showForgot}
<p class="form-error">{form.error}</p> {#if forgotSuccess}
{/if} <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}
<form method="POST"> <form method="POST">
<label> <label>
Email Email
<input type="email" name="email" bind:value={email} placeholder="you@email.com" required /> <input type="email" name="email" bind:value={email} placeholder="you@email.com" required />
</label> </label>
<label> <label>
Password Password
<input type="password" name="password" bind:value={password} required /> <input type="password" name="password" bind:value={password} required />
</label> </label>
<button type="submit">Log in</button> <button class="row-btn" type="button" onclick={() => (showForgot = true)}>
</form> Forgot password?
<p class="alt"> </button>
Don't have a family yet? <a href="/signup">Create one</a> <button type="submit">Log in</button>
</p> </form>
<p>Trying to join as a child? Ask your parent to issue your QR code.</p>
<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> </AuthShell>
<style> <style>
@@ -66,6 +128,36 @@
button:hover { button:hover {
background: #3730a3; 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 { .form-error {
background: #fef2f2; background: #fef2f2;
color: #b91c1c; 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 }) { function signOut(event: { cookies: any }) {
clearSessionCookie(event.cookies); clearSessionCookie(event.cookies);
event.cookies.delete('session', { path: '/' });
clearLegacyCookies(event.cookies); clearLegacyCookies(event.cookies);
} }
+1 -1
View File
@@ -13,5 +13,5 @@
"esbuild" "esbuild"
] ]
}, },
"version": "1.8.6" "version": "1.8.7"
} }
+40
View File
@@ -29,6 +29,9 @@ importers:
qrcode: qrcode:
specifier: ^1.5.4 specifier: ^1.5.4
version: 1.5.4 version: 1.5.4
resend:
specifier: ^6.25.0
version: 6.25.0
stripe: stripe:
specifier: ^22.5.0 specifier: ^22.5.0
version: 22.5.0(@types/node@26.0.0) version: 22.5.0(@types/node@26.0.0)
@@ -484,6 +487,9 @@ packages:
'@rolldown/pluginutils@1.0.1': '@rolldown/pluginutils@1.0.1':
resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==}
'@stablelib/base64@1.0.1':
resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==}
'@standard-schema/spec@1.1.0': '@standard-schema/spec@1.1.0':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
@@ -726,6 +732,9 @@ packages:
'@typescript-eslint/types': '@typescript-eslint/types':
optional: true optional: true
fast-sha256@1.3.0:
resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==}
fdir@6.5.0: fdir@6.5.0:
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
engines: {node: '>=12.0.0'} engines: {node: '>=12.0.0'}
@@ -893,6 +902,9 @@ packages:
pocketbase@0.27.0: pocketbase@0.27.0:
resolution: {integrity: sha512-K5N6d93UP/BNMbMnlZ6BUfy9VPCIvLyqhJFOsNI8OsZwzvKWEAfyD36boi5K4ECIOl5HMlo0TzuaeGdKpMwizQ==} resolution: {integrity: sha512-K5N6d93UP/BNMbMnlZ6BUfy9VPCIvLyqhJFOsNI8OsZwzvKWEAfyD36boi5K4ECIOl5HMlo0TzuaeGdKpMwizQ==}
postal-mime@2.7.5:
resolution: {integrity: sha512-GNEXKvWFQnbgO5NlrGzVa0FmWzBZ24PersAWErttSg1Hjpf0ATxTwS5DOMGaOpTG6bUh5cTr7xi0jAD942wCJA==}
postcss@8.5.15: postcss@8.5.15:
resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==}
engines: {node: ^10 || ^12 || >=14} engines: {node: ^10 || ^12 || >=14}
@@ -980,6 +992,15 @@ packages:
require-main-filename@2.0.0: require-main-filename@2.0.0:
resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} 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: rolldown@1.0.3:
resolution: {integrity: sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==} resolution: {integrity: sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
@@ -1005,6 +1026,9 @@ packages:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
standardwebhooks@1.0.0:
resolution: {integrity: sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==}
string-width@4.2.3: string-width@4.2.3:
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -1396,6 +1420,8 @@ snapshots:
'@rolldown/pluginutils@1.0.1': {} '@rolldown/pluginutils@1.0.1': {}
'@stablelib/base64@1.0.1': {}
'@standard-schema/spec@1.1.0': {} '@standard-schema/spec@1.1.0': {}
'@stripe/stripe-js@9.13.0': {} '@stripe/stripe-js@9.13.0': {}
@@ -1609,6 +1635,8 @@ snapshots:
dependencies: dependencies:
'@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/sourcemap-codec': 1.5.5
fast-sha256@1.3.0: {}
fdir@6.5.0(picomatch@4.0.4): fdir@6.5.0(picomatch@4.0.4):
optionalDependencies: optionalDependencies:
picomatch: 4.0.4 picomatch: 4.0.4
@@ -1720,6 +1748,8 @@ snapshots:
pocketbase@0.27.0: {} pocketbase@0.27.0: {}
postal-mime@2.7.5: {}
postcss@8.5.15: postcss@8.5.15:
dependencies: dependencies:
nanoid: 3.3.15 nanoid: 3.3.15
@@ -1751,6 +1781,11 @@ snapshots:
require-main-filename@2.0.0: {} require-main-filename@2.0.0: {}
resend@6.25.0:
dependencies:
postal-mime: 2.7.5
standardwebhooks: 1.0.0
rolldown@1.0.3: rolldown@1.0.3:
dependencies: dependencies:
'@oxc-project/types': 0.133.0 '@oxc-project/types': 0.133.0
@@ -1807,6 +1842,11 @@ snapshots:
source-map-js@1.2.1: {} 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: string-width@4.2.3:
dependencies: dependencies:
emoji-regex: 8.0.0 emoji-regex: 8.0.0