Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 87c28cb6ce | |||
| 40d7bf7398 |
@@ -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
@@ -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;
|
|
||||||
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-item:last-child {
|
||||||
.accordion-arrow { font-size: 0.8rem; color: #9ca3af; }
|
border-bottom: none;
|
||||||
.accordion-body { padding: 1rem; }
|
}
|
||||||
</style>
|
.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;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
`
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -151,4 +239,4 @@ export async function redeemOtp(opts: { famSlug: string; username: string; otp:
|
|||||||
.collection('users')
|
.collection('users')
|
||||||
.authWithPassword(fullUsername, derivePassword(famSlug, handleName));
|
.authWithPassword(fullUsername, derivePassword(famSlug, handleName));
|
||||||
return authPb.authStore.token;
|
return authPb.authStore.token;
|
||||||
}
|
}
|
||||||
|
|||||||
+575
-493
File diff suppressed because it is too large
Load Diff
@@ -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);
|
|
||||||
};
|
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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) {
|
||||||
@@ -21,4 +69,4 @@ export const actions = {
|
|||||||
|
|
||||||
throw redirect(303, `/${fam}/${encodeURIComponent(username)}`);
|
throw redirect(303, `/${fam}/${encodeURIComponent(username)}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -17,4 +16,4 @@ export const actions = {
|
|||||||
signOut(event);
|
signOut(event);
|
||||||
throw redirect(303, '/');
|
throw redirect(303, '/');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
+1
-1
@@ -13,5 +13,5 @@
|
|||||||
"esbuild"
|
"esbuild"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"version": "1.8.6"
|
"version": "1.8.7"
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+40
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user