Compare commits
102 Commits
84b3cd48f4
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 7de3c8ca62 | |||
| d14a154dcb | |||
| 89ab955efc | |||
| edc8568cc4 | |||
| c070cc8053 | |||
| 8388868bee | |||
| 2a3f3836c0 | |||
| 6288a3d3f2 | |||
| adcb400f41 | |||
| a710e22f35 | |||
| 31da825e93 | |||
| fa1bf1e09d | |||
| 66ed9750e9 | |||
| 0fed19461e | |||
| 67b5eff06b | |||
| a8c674f8b3 | |||
| 8e4eef06f5 | |||
| 1609e11c48 | |||
| 18511b6546 | |||
| 9e6d835061 | |||
| 0613fc4f46 | |||
| 6b6d2df945 | |||
| 6d05721629 | |||
| 3a6ba556e6 | |||
| 9566506d30 | |||
| 812f6a4f88 | |||
| 234b2cd8ee | |||
| 78f456bf77 | |||
| 833a34049e | |||
| 39e432e52a | |||
| 29d50985df | |||
| f9fa99f5ff | |||
| 49658bd791 | |||
| b1dd359f5d | |||
| e0dd4c0721 | |||
| 6aab203bbe | |||
| de61a6368b | |||
| 8bb120e8eb | |||
| da6a6e372c | |||
| e441c3d2a7 | |||
| 158a60f30c | |||
| 7003609afe | |||
| 70a38ee95b | |||
| 404d4221ee | |||
| 70b9c30c63 | |||
| d0856648fa | |||
| 5d47ade845 | |||
| 75ce42dc94 | |||
| 0db74b2787 | |||
| 65afb5a721 | |||
| 486f239fb1 | |||
| a5e3b80ff6 | |||
| 41033914b4 | |||
| 4730ab3eb9 | |||
| a270d965fe | |||
| 27492b1558 | |||
| 897e203dab | |||
| f802113cd1 | |||
| 097106fa69 | |||
| 8a02640cdc | |||
| 3f95d50d2e | |||
| 9d7915eb60 | |||
| 7ce127f482 | |||
| 268c0157cb | |||
| 6ed1805884 | |||
| de6047f406 | |||
| 10d8b3d29d | |||
| 02df79968e | |||
| afc463a0f0 | |||
| 9117c0ec70 | |||
| 48279be756 | |||
| a5e60b5486 | |||
| 45433c7c60 | |||
| 2abc9788a2 | |||
| f2d6f8368c | |||
| 7ced424a36 | |||
| a959f204bd | |||
| 44524de4b9 | |||
| 0767fa4423 | |||
| 2fbf249bfb | |||
| b13de60b52 | |||
| ed31a6e25d | |||
| 68a0851467 | |||
| 8cec99715a | |||
| 1360799b60 | |||
| eaa5104817 | |||
| f288df5b9c | |||
| 1d4d6be20b | |||
| 87c28cb6ce | |||
| 40d7bf7398 | |||
| f491dbfbf1 | |||
| fdacfc7e5c | |||
| a521edaabf | |||
| 11a09d65ff | |||
| 3bf78d777c | |||
| cda2cbfcab | |||
| 852f9edb96 | |||
| 035dbb829a | |||
| 5165c59494 | |||
| d303eda038 | |||
| bcdd3805d5 | |||
| 9f335db7b2 |
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# FamChore v2 — AI Agent Reference
|
# FamDone v2 — AI Agent Reference
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
|
|
||||||
@@ -20,13 +20,13 @@
|
|||||||
## Auth
|
## Auth
|
||||||
|
|
||||||
| Role | Auth | Session | Record in |
|
| Role | Auth | Session | Record in |
|
||||||
| -------------- | --------------------------------------------- | -------------------------- | ------------------------- |
|
| -------------- | ----------------------------------------------- | ------------------------------------------------ | ----------------------- |
|
||||||
| Admin (parent) | PB email+pass | 24hr JWT `pb_token` cookie | `users` (role `parent`) |
|
| Admin (parent) | PB email+pass | 24hr JWT `pb_token` cookie | `users` (role `parent`) |
|
||||||
| Member (child) | Invite OTP + server-derived password | httpOnly `pb_token` cookie | `users` (role `child`) |
|
| Member (child) | Invite OTP + server-derived password | Shared-device: `pb_token_<userId>` + `pb_active` | `users` (role `child`) |
|
||||||
| Superuser | PB `_superusers` (server-side only, `pb-admin`) | — | — |
|
| Superuser | PB `_superusers` (server-side only, `pb-admin`) | — | — |
|
||||||
|
|
||||||
- **Admins** (parents) are `users` records (role `parent`). They authenticate via email/password login, get an httpOnly `pb_token` cookie with `{ id, name, username, role: "parent", famId, color }`.
|
- **Admins** (parents) are `users` records (role `parent`). They authenticate via email/password login, get an httpOnly `pb_token` cookie with `{ id, name, username, role: "parent", famId, color }`.
|
||||||
- **Members** (children) are `users` records (role `child`); PB `username` = `{famSlug}:{handle}` (globally-unique auth identity; `handle` = whitespace-free lowercase name), URL segment = `handleOf(username)`, `name` = display name. Their PB password is **derived** server-side (`MEMBER_SECRET + famSlug + handle`); access is gated by a 20-min OTP in `otp`, then `authWithPassword`. They get the same httpOnly `pb_token` cookie. There is **no `members` collection**.
|
- **Members** (children) are `users` records (role `child`); PB `username` = `{famSlug}:{handle}` (globally-unique auth identity; `handle` = whitespace-free lowercase name), URL segment = `handleOf(username)`, `name` = display name. Their PB password is **derived** server-side (`MEMBER_SECRET + famSlug + handle`); access is gated by a 20-min OTP in `otp`, then `authWithPassword`. **Shared-computer sessions:** children keep ONE httpOnly cookie per account (`pb_token_<userId>`, set at join) + a `pb_active` cookie naming the current session; a 3-digit PIN _selects_ among those device sessions (see `shared-device.md`) — it is NOT a login and mints nothing on a fresh device. Parents stay on the single `pb_token`. Resolution order in hooks: `pb_active` → `pb_token`. There is **no `members` collection**.
|
||||||
- **Platform superuser** (`_superusers`) used only server-side by `pb-admin.ts` for cross-family queries (e.g. `/admin` stats dashboard) and OTP/signup writes. Not an app role.
|
- **Platform superuser** (`_superusers`) used only server-side by `pb-admin.ts` for cross-family queries (e.g. `/admin` stats dashboard) and OTP/signup writes. Not an app role.
|
||||||
- The layout (`[fam]/+layout.server.ts`) derives `isParent` and `role` centrally from the session — child pages use `page.data.isParent` or `page.data.role` from `$app/state`.
|
- The layout (`[fam]/+layout.server.ts`) derives `isParent` and `role` centrally from the session — child pages use `page.data.isParent` or `page.data.role` from `$app/state`.
|
||||||
- Because `pb_token` is httpOnly, the browser PB SDK is seeded from `page.data.pbToken` via `initPb(token)` in the layout `onMount` (not `document.cookie`).
|
- Because `pb_token` is httpOnly, the browser PB SDK is seeded from `page.data.pbToken` via `initPb(token)` in the layout `onMount` (not `document.cookie`).
|
||||||
@@ -35,6 +35,7 @@
|
|||||||
|
|
||||||
- `users` — auth collection; famId, role (`parent`|`child`), username (`{famSlug}:{handle}`), name, color, email (admin only)
|
- `users` — auth collection; famId, role (`parent`|`child`), username (`{famSlug}:{handle}`), name, color, email (admin only)
|
||||||
- `otp` — famId, userId, otp, updatedAt (OTP gate for child join; display colour lives on `users.color`)
|
- `otp` — famId, userId, otp, updatedAt (OTP gate for child join; display colour lives on `users.color`)
|
||||||
|
- `pins` — famId, userId, pin (superuser-only shared-device PINs, plaintext so parents can read them out; all access via server endpoints)
|
||||||
- `accesscodes` — value (unique), name, duration, expiry, active, createdAt (superuser-only; platform access codes)
|
- `accesscodes` — value (unique), name, duration, expiry, active, createdAt (superuser-only; platform access codes)
|
||||||
- `platform` — label (`global` singleton), flags (json) — platform feature flags; **public read** (empty list/view rules), superuser-only writes. Loaded on every page via root `+layout.server.ts` as `page.data.platformFlags`; toggle via `/admin` Platform Flags card. The `debug` flag gates dev-only CTAs (e.g. settings "Revoke code").
|
- `platform` — label (`global` singleton), flags (json) — platform feature flags; **public read** (empty list/view rules), superuser-only writes. Loaded on every page via root `+layout.server.ts` as `page.data.platformFlags`; toggle via `/admin` Platform Flags card. The `debug` flag gates dev-only CTAs (e.g. settings "Revoke code").
|
||||||
- `fams` — name, slug, stripeCustomerId, paymentMode (`none|code|sub|canceled`), active, accessCodeId, accessCodeEnteredAt
|
- `fams` — name, slug, stripeCustomerId, paymentMode (`none|code|sub|canceled`), active, accessCodeId, accessCodeEnteredAt
|
||||||
@@ -46,7 +47,7 @@
|
|||||||
- `monthly_bonuses` — famId, month, prizeType, prizeValue, winnerUserId
|
- `monthly_bonuses` — famId, month, prizeType, prizeValue, winnerUserId
|
||||||
- `settings` — famId, pointsThreshold, weeklyBonus, webhookUrl
|
- `settings` — famId, pointsThreshold, weeklyBonus, webhookUrl
|
||||||
|
|
||||||
> **Schema/migrations:** `shared/pb/schema.ts` (`SCHEMA_PLAN`) is the single source of truth for base collections. `frontend/src/lib/server/migrate.ts` only **bootstraps** a fresh/wiped PB (idempotent, skips if `fams` exists) — it has no incremental history. The native `users` auth fields/rules and the superuser-only `otp` collection are applied in `migrate.ts` (`ensureUsers`/`ensureOtp`), not `SCHEMA_PLAN`. Data is disposable (app not live), so a schema change = update `SCHEMA_PLAN` + wipe PB + reboot.
|
> **Schema/migrations:** `shared/pb/schema.ts` (`SCHEMA_PLAN`) is the single source of truth for base collections. `frontend/src/lib/server/migrate.ts` only **bootstraps** a fresh/wiped PB (idempotent, skips if `fams` exists) — it has no incremental history. The native `users` auth fields/rules and the superuser-only `otp`/`pins` collections are applied in `migrate.ts` (`ensureUsers`/`ensureOtp`/`ensurePins`), not `SCHEMA_PLAN`. Data is disposable (app not live), so a schema change = update `SCHEMA_PLAN` + wipe PB + reboot.
|
||||||
|
|
||||||
## Routes
|
## Routes
|
||||||
|
|
||||||
@@ -56,6 +57,7 @@
|
|||||||
/login · /logout Parent email/password login / logout
|
/login · /logout Parent email/password login / logout
|
||||||
/signup Parent + family signup (wizard: fam → child → code → plan)
|
/signup Parent + family signup (wizard: fam → child → code → plan)
|
||||||
/{fam}/join/{username} Member invite (OTP join), auto-fills from ?code=
|
/{fam}/join/{username} Member invite (OTP join), auto-fills from ?code=
|
||||||
|
/{fam}/switch Shared-device profile picker (standalone landing when child sessions exist but none active)
|
||||||
/{fam} Fam dashboard
|
/{fam} Fam dashboard
|
||||||
/{fam}/{username} Parent → admin overview, Child → member kanban (role from session)
|
/{fam}/{username} Parent → admin overview, Child → member kanban (role from session)
|
||||||
/{fam}/{username}/chores Chore templates & assignment grid
|
/{fam}/{username}/chores Chore templates & assignment grid
|
||||||
@@ -158,7 +160,7 @@ let configs = $derived(
|
|||||||
Two patterns based on who's acting:
|
Two patterns based on who's acting:
|
||||||
|
|
||||||
| Pattern | Who | Frequency | Sensitivity | Optimistic? | Auth |
|
| Pattern | Who | Frequency | Sensitivity | Optimistic? | Auth |
|
||||||
| ---------------------------- | ------ | -------------------- | ------------------------ | --------------------------------------------- | ------------------------- |
|
| ---------------------------- | ------ | -------------------- | ------------------------ | --------------------------------------------- | ---------------------------------- |
|
||||||
| Direct `fetch` + `memberApi` | Member | High (chore toggles) | None | Yes (instant UI, reconcile on response) | `Authorization: Bearer <pb_token>` |
|
| Direct `fetch` + `memberApi` | Member | High (chore toggles) | None | Yes (instant UI, reconcile on response) | `Authorization: Bearer <pb_token>` |
|
||||||
| Form action | Admin | Low (CRUD) | High (settings, members) | No — form is server-side, wait for round trip | httpOnly `pb_token` cookie |
|
| Form action | Admin | Low (CRUD) | High (settings, members) | No — form is server-side, wait for round trip | httpOnly `pb_token` cookie |
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
# FamChore v2 — Architecture & Developer Reference
|
# FamDone v2 — Architecture & Developer Reference
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# FamChore v2 — Development Memory
|
# FamDone v2 — Development Memory
|
||||||
|
|
||||||
## 2026-08-17 — Schema collapse to single source of truth (Option 1)
|
## 2026-08-17 — Schema collapse to single source of truth (Option 1)
|
||||||
|
|
||||||
@@ -20,6 +20,7 @@
|
|||||||
## UI Component Architecture (Jul 2026)
|
## UI Component Architecture (Jul 2026)
|
||||||
|
|
||||||
### Layout Hierarchy
|
### Layout Hierarchy
|
||||||
|
|
||||||
```
|
```
|
||||||
+layout.svelte ← global styles, meta, favicon
|
+layout.svelte ← global styles, meta, favicon
|
||||||
├── /login, /signup, /join/* ← auth pages (no shell)
|
├── /login, /signup, /join/* ← auth pages (no shell)
|
||||||
@@ -34,17 +35,20 @@
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Sidebar (collapsible to mini-mode)
|
### Sidebar (collapsible to mini-mode)
|
||||||
- Header: app name (FamChore)
|
|
||||||
|
- Header: app name (FamDone)
|
||||||
- Admin CTAs: Dashboard, Chores, Rewards (badge count), Bonuses
|
- Admin CTAs: Dashboard, Chores, Rewards (badge count), Bonuses
|
||||||
- Member CTAs: Dashboard, Preferences
|
- Member CTAs: Dashboard, Preferences
|
||||||
- Footer: family name, Settings (admin only), Log out
|
- Footer: family name, Settings (admin only), Log out
|
||||||
- Role-aware: items differ based on admin vs member route
|
- Role-aware: items differ based on admin vs member route
|
||||||
|
|
||||||
### TopNav
|
### TopNav
|
||||||
|
|
||||||
- Slot `announcement` (center) — system/family messages
|
- Slot `announcement` (center) — system/family messages
|
||||||
- Slot `actions` (right) — user status, claim/message
|
- Slot `actions` (right) — user status, claim/message
|
||||||
|
|
||||||
### Page Content
|
### Page Content
|
||||||
|
|
||||||
- `ViewHeader` — title + subtitle + tool bar (tabs, weeknav, sort)
|
- `ViewHeader` — title + subtitle + tool bar (tabs, weeknav, sort)
|
||||||
- `CardGrid` — 3-column grid, Cards span columns via `cols` prop
|
- `CardGrid` — 3-column grid, Cards span columns via `cols` prop
|
||||||
- `Card` — 1/2/3 col span, micro-layout per page
|
- `Card` — 1/2/3 col span, micro-layout per page
|
||||||
@@ -52,6 +56,7 @@
|
|||||||
- `Button` — consistent CTAs with `variant` (primary/secondary/ghost/danger) and `size` (sm/md/lg)
|
- `Button` — consistent CTAs with `variant` (primary/secondary/ghost/danger) and `size` (sm/md/lg)
|
||||||
|
|
||||||
### Components (frontend/src/lib/components/)
|
### Components (frontend/src/lib/components/)
|
||||||
|
|
||||||
- `Sidebar.svelte`, `TopNav.svelte`, `Footer.svelte`
|
- `Sidebar.svelte`, `TopNav.svelte`, `Footer.svelte`
|
||||||
- `ViewHeader.svelte`, `Card.svelte`, `CardGrid.svelte`
|
- `ViewHeader.svelte`, `Card.svelte`, `CardGrid.svelte`
|
||||||
- `Button.svelte`, `Accordion.svelte`
|
- `Button.svelte`, `Accordion.svelte`
|
||||||
@@ -177,14 +182,12 @@
|
|||||||
- **Admin dashboard stat tiles**: added 4 gradient tiles (members / points / cash / chores done) reusing the child `.tiles`/`.tile` pattern + new `.tile-members`/`.tile-chores` colors, from a new `adminTiles` derived summing `summary.summaries`. Also fixed admin subtitle `Week of {YYYY-MM-DD}` → `Week of {DDMMYY}`.
|
- **Admin dashboard stat tiles**: added 4 gradient tiles (members / points / cash / chores done) reusing the child `.tiles`/`.tile` pattern + new `.tile-members`/`.tile-chores` colors, from a new `adminTiles` derived summing `summary.summaries`. Also fixed admin subtitle `Week of {YYYY-MM-DD}` → `Week of {DDMMYY}`.
|
||||||
- **Check**: frontend `svelte-check` stays at 12 baseline errors. Note: frontend dev server on :2080 was not running when verified (proxy :3456 up).
|
- **Check**: frontend `svelte-check` stays at 12 baseline errors. Note: frontend dev server on :2080 was not running when verified (proxy :3456 up).
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
### 2026-08-06 — Env Consolidation: `SERVER_IP`, `PROXY_URL`, and SvelteKit env only
|
### 2026-08-06 — Env Consolidation: `SERVER_IP`, `PROXY_URL`, and SvelteKit env only
|
||||||
|
|
||||||
- **`config.ts` is proxy-only.** It now holds just the three ports (`FRONTEND_PORT`/`PROXY_PORT`/`PB_PORT` = `2080`/`3456`/`8090`). SvelteKit **never imports `config.ts`** — SvelteKit env vars are declared in `frontend/src/env.ts` and read via `$app/env/*`. Deleted the stale `config.js/.d.ts/.map` artifacts.
|
- **`config.ts` is proxy-only.** It now holds just the three ports (`FRONTEND_PORT`/`PROXY_PORT`/`PB_PORT` = `2080`/`3456`/`8090`). SvelteKit **never imports `config.ts`** — SvelteKit env vars are declared in `frontend/src/env.ts` and read via `$app/env/*`. Deleted the stale `config.js/.d.ts/.map` artifacts.
|
||||||
- **`frontend/src/env.ts`** declares: `PROXY_URL` (public, default `http://127.0.0.1:3456`), `SERVER_IP` (public, default `192.168.1.225`), `PB_EMAIL`/`PB_PASSWORD` (private, defaults). `PUBLIC_PB_URL` removed (was the source of a startup crash when unset).
|
- **`frontend/src/env.ts`** declares: `PROXY_URL` (public, default `http://127.0.0.1:3456`), `SERVER_IP` (public, default `192.168.1.225`), `PB_EMAIL`/`PB_PASSWORD` (private, defaults). `PUBLIC_PB_URL` removed (was the source of a startup crash when unset).
|
||||||
- **Deleted `frontend/src/lib/server/env.ts`** (untracked). All server modules now `import { PROXY_URL } from '$app/env/public'` (`hono.ts`, `auth.ts`, `+layout.server.ts`, `+page.server.ts`, `preferences`, `join/[code]/[member]`). `admin/+page.server.ts` imports creds from `$app/env/private`.
|
- **Deleted `frontend/src/lib/server/env.ts`** (untracked). All server modules now `import { PROXY_URL } from '$app/env/public'` (`hono.ts`, `auth.ts`, `+layout.server.ts`, `+page.server.ts`, `preferences`, `join/[code]/[member]`). `admin/+page.server.ts` imports creds from `$app/env/private`.
|
||||||
- **`frontend/src/lib/pocketbase.ts` (browser) + `pb-admin.ts`**: `PB_ENDPOINT = import.meta.env.PROD ? '/pb' : \`http://${SERVER_IP}:8090\``. (Fixed a bug where `pocketbase.ts` used `import.meta.env.SERVER_IP` → undefined.)
|
- **`frontend/src/lib/pocketbase.ts` (browser) + `pb-admin.ts`**: `PB_ENDPOINT = import.meta.env.PROD ? '/pb' : \`http://${SERVER_IP}:8090\``. (Fixed a bug where `pocketbase.ts`used`import.meta.env.SERVER_IP` → undefined.)
|
||||||
- **`proxy/src/env.ts`** (new): `PB_ENDPOINT = SERVER_IP ? \`http://${SERVER_IP}:8090\` : \`http://127.0.0.1:8090\``. Dev env is loaded by the proxy's `dev`/`seed` scripts via `tsx --env-file-if-exists=../.env` (pnpm has no `--env-file`; `NODE_OPTIONS='--env-file=…'` is rejected by Node). No `loadEnvFile` hack in code.
|
- **`proxy/src/env.ts`** (new): `PB_ENDPOINT = SERVER_IP ? \`http://${SERVER_IP}:8090\` : \`http://127.0.0.1:8090\``. Dev env is loaded by the proxy's `dev`/`seed` scripts via `tsx --env-file-if-exists=../.env` (pnpm has no `--env-file`; `NODE_OPTIONS='--env-file=…'` is rejected by Node). No `loadEnvFile` hack in code.
|
||||||
- **Docker**: removed dead `ENV PB_ENDPOINT` from `Dockerfile`; `EXPOSE 3001` (was `3005 8090`); compose public port is `${PORT:-3001}:3001`, creds default to the code fallback, redundant `FRONTEND_PORT`/`PROXY_PORT` passthrough dropped; `entrypoint.sh` simplified (`PB_DATA=/app/pb_data`, `PORT=$FRONTEND_PORT`, no `:-` fallbacks).
|
- **Docker**: removed dead `ENV PB_ENDPOINT` from `Dockerfile`; `EXPOSE 3001` (was `3005 8090`); compose public port is `${PORT:-3001}:3001`, creds default to the code fallback, redundant `FRONTEND_PORT`/`PROXY_PORT` passthrough dropped; `entrypoint.sh` simplified (`PB_DATA=/app/pb_data`, `PORT=$FRONTEND_PORT`, no `:-` fallbacks).
|
||||||
- **Frontend deps added** (were missing imports): `chart.js`, `qrcode`, `@hiseb/confetti`.
|
- **Frontend deps added** (were missing imports): `chart.js`, `qrcode`, `@hiseb/confetti`.
|
||||||
@@ -198,12 +201,14 @@
|
|||||||
- **Watch-out**: a careless `docker run` with a **fresh volume** (my first attempt, aborted in time) would have wiped the permanent PB data. Restore command is in RULES.md. Two containers (`pb-dev` :8090 and the docker app's internal PB :8091) currently **share the same host `./pb_data`** — be careful with both.
|
- **Watch-out**: a careless `docker run` with a **fresh volume** (my first attempt, aborted in time) would have wiped the permanent PB data. Restore command is in RULES.md. Two containers (`pb-dev` :8090 and the docker app's internal PB :8091) currently **share the same host `./pb_data`** — be careful with both.
|
||||||
|
|
||||||
### 2026-08-06 — Added root `shared/` for cross-package code
|
### 2026-08-06 — Added root `shared/` for cross-package code
|
||||||
|
|
||||||
- Created `shared/timezone.ts` (moved from root `timezone.ts`). Imported by `frontend/src/routes/[fam]/[username]/+page.svelte`, `.../settings/+page.svelte`, and `proxy/src/index.ts`. Deleted the root `timezone.ts`.
|
- Created `shared/timezone.ts` (moved from root `timezone.ts`). Imported by `frontend/src/routes/[fam]/[username]/+page.svelte`, `.../settings/+page.svelte`, and `proxy/src/index.ts`. Deleted the root `timezone.ts`.
|
||||||
- Created `shared/pb/schema.ts` — single source of truth for the PocketBase schema + field builders (`SCHEMA_PLAN` ordered collection plan + `text/select/rel/...` helpers). Both `proxy/src/migrate.ts` (`ensureSchema`) and `proxy/scripts/seed.ts` now iterate `SCHEMA_PLAN`; kills the previous duplicated schema/field-helper definitions in both files.
|
- Created `shared/pb/schema.ts` — single source of truth for the PocketBase schema + field builders (`SCHEMA_PLAN` ordered collection plan + `text/select/rel/...` helpers). Both `proxy/src/migrate.ts` (`ensureSchema`) and `proxy/scripts/seed.ts` now iterate `SCHEMA_PLAN`; kills the previous duplicated schema/field-helper definitions in both files.
|
||||||
- Reason: `timezone.ts` and the PB schema are consumed by more than one package; `shared/` is the root location both can reach. Rule added to RULES.md: shared code lives in `shared/`, never inside `frontend/` or `proxy/`.
|
- Reason: `timezone.ts` and the PB schema are consumed by more than one package; `shared/` is the root location both can reach. Rule added to RULES.md: shared code lives in `shared/`, never inside `frontend/` or `proxy/`.
|
||||||
- Note: proxy `tsc --noEmit` already errors on `.ts`-extension imports (`allowImportingTsExtensions` unset) — pre-existing, not from this change. Runtime uses esbuild (build) + tsx (dev), both of which bundle the `shared/` imports correctly. Verified `pnpm build` clean for both packages.
|
- Note: proxy `tsc --noEmit` already errors on `.ts`-extension imports (`allowImportingTsExtensions` unset) — pre-existing, not from this change. Runtime uses esbuild (build) + tsx (dev), both of which bundle the `shared/` imports correctly. Verified `pnpm build` clean for both packages.
|
||||||
|
|
||||||
### 2026-08-07 — `@shared/*` import alias (path alias, not a pnpm package)
|
### 2026-08-07 — `@shared/*` import alias (path alias, not a pnpm package)
|
||||||
|
|
||||||
- Moved `config.ts` → `shared/config.ts`. All `shared/` code is now imported as `@shared/*` instead of relative `../../shared/...`.
|
- Moved `config.ts` → `shared/config.ts`. All `shared/` code is now imported as `@shared/*` instead of relative `../../shared/...`.
|
||||||
- This is a **path alias**, not a pnpm workspace package (`@shared` alone isn't a valid npm package name; a real package would need `@scope/name`).
|
- This is a **path alias**, not a pnpm workspace package (`@shared` alone isn't a valid npm package name; a real package would need `@scope/name`).
|
||||||
- Proxy: `tsconfig.json` sets `paths: { "@shared/*": ["../shared/*"] }`; esbuild build adds `--alias:@shared=../shared`; tsx resolves via tsconfig paths. Proxy keeps `.ts` extensions (`@shared/config.ts`).
|
- Proxy: `tsconfig.json` sets `paths: { "@shared/*": ["../shared/*"] }`; esbuild build adds `--alias:@shared=../shared`; tsx resolves via tsconfig paths. Proxy keeps `.ts` extensions (`@shared/config.ts`).
|
||||||
@@ -212,6 +217,7 @@
|
|||||||
- Docker note: runtime image only copies `frontend/build` + `proxy/dist` (both already bundle `shared/`), so `shared/` needn't be copied into the image.
|
- Docker note: runtime image only copies `frontend/build` + `proxy/dist` (both already bundle `shared/`), so `shared/` needn't be copied into the image.
|
||||||
|
|
||||||
### 2026-08-10 — Dev runtime cleanup (PB instances / containers)
|
### 2026-08-10 — Dev runtime cleanup (PB instances / containers)
|
||||||
|
|
||||||
- **Removed** test container `31e74178b3f0` (`famdone-service-app-1`, host :3010 + :8092). It ran **PB 0.39.10** and bound the **same host `pb_data`** as pb-dev → two PBs (v0.25 + v0.39) writing one SQLite DB = corruption/lock risk (likely source of dev instability/login lag).
|
- **Removed** test container `31e74178b3f0` (`famdone-service-app-1`, host :3010 + :8092). It ran **PB 0.39.10** and bound the **same host `pb_data`** as pb-dev → two PBs (v0.25 + v0.39) writing one SQLite DB = corruption/lock risk (likely source of dev instability/login lag).
|
||||||
- **Killed** 7 stale host `tsx watch` dev-proxy processes (Jul 28–Aug 5) + my throwaway 0.39 PBs.
|
- **Killed** 7 stale host `tsx watch` dev-proxy processes (Jul 28–Aug 5) + my throwaway 0.39 PBs.
|
||||||
- **Reset pb_data**: stopped pb-dev, wiped `/home/threejjjs/development/famchamp/pb_data`, **recreated** pb-dev container (fresh v0.25 store) with `--automigrate=false`, recreated dev superuser `debug@famchamp.dev`/`debug123`.
|
- **Reset pb_data**: stopped pb-dev, wiped `/home/threejjjs/development/famchamp/pb_data`, **recreated** pb-dev container (fresh v0.25 store) with `--automigrate=false`, recreated dev superuser `debug@famchamp.dev`/`debug123`.
|
||||||
@@ -219,6 +225,7 @@
|
|||||||
- **Desired end state (confirmed)**: `8090` = pb-dev (v0.25, single instance, automigrate off); `3001`+`8091` = PROD app `cffd636cc772` (kept, not live); PROD pb_data at `/data/coolify/.../pb_data` (separate from dev).
|
- **Desired end state (confirmed)**: `8090` = pb-dev (v0.25, single instance, automigrate off); `3001`+`8091` = PROD app `cffd636cc772` (kept, not live); PROD pb_data at `/data/coolify/.../pb_data` (separate from dev).
|
||||||
|
|
||||||
### 2026-08-10 — PB 0.25 → 0.39 migration (branch `feature/migrate-pocketbase`)
|
### 2026-08-10 — PB 0.25 → 0.39 migration (branch `feature/migrate-pocketbase`)
|
||||||
|
|
||||||
- **Decision**: keep our own `migrate.ts` schema-as-code (API-driven, does data migrations + rule locking), NOT PocketBase's built-in automigrate (schema-only, generates version-specific migration files, and generates conflicting snapshots on upgraded stores). Disable PB automigrate in the runtime.
|
- **Decision**: keep our own `migrate.ts` schema-as-code (API-driven, does data migrations + rule locking), NOT PocketBase's built-in automigrate (schema-only, generates version-specific migration files, and generates conflicting snapshots on upgraded stores). Disable PB automigrate in the runtime.
|
||||||
- **Verified against 0.39.10** (throwaway binaries on `127.0.0.1:8098/8099`, temp data dirs):
|
- **Verified against 0.39.10** (throwaway binaries on `127.0.0.1:8098/8099`, temp data dirs):
|
||||||
1. Fresh store: `migrate()` bootstraps all 14 `SCHEMA_PLAN` collections + every field migration cleanly (schema field builders are 0.39-compatible).
|
1. Fresh store: `migrate()` bootstraps all 14 `SCHEMA_PLAN` collections + every field migration cleanly (schema field builders are 0.39-compatible).
|
||||||
@@ -236,6 +243,7 @@
|
|||||||
- **Prod upgrade steps**: backup `pb_data` → run the 0.39 image (automigrate off) → run `migrate()` → verify no drift (`messages.createdAt`, `id.autogeneratePattern`).
|
- **Prod upgrade steps**: backup `pb_data` → run the 0.39 image (automigrate off) → run `migrate()` → verify no drift (`messages.createdAt`, `id.autogeneratePattern`).
|
||||||
|
|
||||||
### 2026-08-10 — Revert PB to 0.25.8 (backtrack from 0.39)
|
### 2026-08-10 — Revert PB to 0.25.8 (backtrack from 0.39)
|
||||||
|
|
||||||
- **Decision**: backtrack off the PocketBase 0.39 bump (introduced in commit `3725c54` via `ARG POCKETBASE_VERSION=0.39.10`) and work from a **0.25.8 baseline** in BOTH dev and prod, then migrate to 0.39 deliberately later.
|
- **Decision**: backtrack off the PocketBase 0.39 bump (introduced in commit `3725c54` via `ARG POCKETBASE_VERSION=0.39.10`) and work from a **0.25.8 baseline** in BOTH dev and prod, then migrate to 0.39 deliberately later.
|
||||||
- Reverted `docker/Dockerfile` `POCKETBASE_VERSION` back to `0.25.8` (matches `docker/Dockerfile.dev`). Dev `pb-dev` and the docker app internal PB `:8091` share host `./pb_data`.
|
- Reverted `docker/Dockerfile` `POCKETBASE_VERSION` back to `0.25.8` (matches `docker/Dockerfile.dev`). Dev `pb-dev` and the docker app internal PB `:8091` share host `./pb_data`.
|
||||||
- **Migration schema scripts (DO NOT FORGET)**: the schema single source of truth is `shared/pb/schema.ts` (`SCHEMA_PLAN`), iterated by `proxy/src/migrate.ts` (`ensureSchema`) and `proxy/scripts/seed.ts`. The chat `messages` / `chat_typing` collections are defined there **without** an explicit `createdAt` — they rely on PB auto-adding it on first create.
|
- **Migration schema scripts (DO NOT FORGET)**: the schema single source of truth is `shared/pb/schema.ts` (`SCHEMA_PLAN`), iterated by `proxy/src/migrate.ts` (`ensureSchema`) and `proxy/scripts/seed.ts`. The chat `messages` / `chat_typing` collections are defined there **without** an explicit `createdAt` — they rely on PB auto-adding it on first create.
|
||||||
@@ -321,6 +329,29 @@
|
|||||||
- **Settings accordion order:** Family (name/payday/**seasons**, opens by default via `<AccordionItem open>`) → Invites → **Account** (Access + Subscription) → App last.
|
- **Settings accordion order:** Family (name/payday/**seasons**, opens by default via `<AccordionItem open>`) → Invites → **Account** (Access + Subscription) → App last.
|
||||||
- **`clearLegacyCookies(cookies)`** added to `$lib/server/session.ts`; auth/join/logout use it instead of inline `device_token` deletes.
|
- **`clearLegacyCookies(cookies)`** added to `$lib/server/session.ts`; auth/join/logout use it instead of inline `device_token` deletes.
|
||||||
|
|
||||||
|
### 2026-08-31 — All-time earnings + weekly trend chart (family root), lifetime wallet (member)
|
||||||
|
|
||||||
|
- **New server stats service** `frontend/src/lib/server/services/stats.ts` (`familyStats(pb, famId)`, exposed as `s.stats.family(famId)`): scans full-history `completions` + `rewards` once, filtered/summed **on the server**, returns fixed-size `{ allTimePts, allTimeCash, allTimeChores, series }` (per-member weekly buckets `{weekStart, points, cash, chores}`). Server-side aggregation keeps the client payload constant-size regardless of history depth — this is the chosen scaling win for all-time totals + the retrospective graph.
|
||||||
|
- **Totals definition (matches the app's own accounting, no double count):** lifetime points = points-type completions' chore values + **claimed** points rewards; lifetime cash = money-type completions' chore values + **claimed** cash rewards; chores = count of all completions. Unclaimed/requested rewards stay out of totals (they're the "to chase" list).
|
||||||
|
- **Reading `weekly_history` NOT needed** — totals derive from live `completions`+`rewards` (both have `listRule: ""` in SCHEMA_PLAN = open to any authenticated session, which is why parent/child token reads already work today).
|
||||||
|
- **Member wallet** (`[fam]/[username]/+page.svelte`): the existing `allTimeCash`/`allTimePoints` deriveds now include chore completion earnings (previously claimed-rewards only). Wallet UI restyled: big lifetime cells (cash "earned all time" + points "accrued") on top, then a "To collect from parent" divider + the existing pendingRewards list (unclaimed/requested/payday-locked) so members still see what to chase.
|
||||||
|
- **Family root** (`[fam]/+page.svelte` + `+page.server.ts`): load now fetches `familyStats` (+ drops the removed templates). ViewHeader gained an optional default slot (`children?: Snippet`); the fam hero renders large all-time points | cash inside the header. Chores card shows "this week | total all-time" with `justify-content: space-between`. Templates card removed. Chart.js line graph "Over time" plots per-member lines with a Points/Cash/Chores tab; window starts at the current month and expands a month at a time up to 3 months based on data span (≤31d→1, ≤62d→2, else 3).
|
||||||
|
- **Typecheck/build:** still 6 pre-existing canary errors only (none in edited files); `pnpm build` (adapter-node) clean. New code is picked up by HMR; no schema/migration change so no restart required.
|
||||||
|
|
||||||
|
### 2026-08-31 — Bonus progress window: `completeBy` + `startDate` implemented
|
||||||
|
|
||||||
|
- **Problem**: a standalone cash bonus (core reward with a points threshold) displayed progress differently on the child dashboard (current week → "0/500") vs the admin dashboard (cumulative since records began). Both were "correct" because `bonus_configs` had no way to scope tracking — with no `period` set, progress()/evaluateFam totalled _all_ completions, and the child dashboard forced `cfg.period || 'weekly'`.
|
||||||
|
- **Fix**: added to `bonus_configs` (schema + `ensureBonusFields` idempotent field-add in migrate.ts, so existing installs upgrade without wiping): `completeBy` (select `unlimited|week|custom`), `startDate` (text), `completeByDate` (text). New shared helpers in `shared/timezone.ts`: `bonusWindow(cfg, payday, tz)` + `completionInWindow(c, {from,to})` ('' = unbounded side).
|
||||||
|
- **Window semantics** (unified across server `progress()`/`evaluateFam()` and the child `thresholdGoals`): recurring configs (period set) keep their period window; standalone configs use `completeBy`:
|
||||||
|
- `unlimited` (default) → `[startDate, ∞]` cumulative
|
||||||
|
- `week` → current week `[weekStart, weekEnd]`
|
||||||
|
- `custom` → `[startDate, completeByDate]`
|
||||||
|
- No `startDate`/`completeBy` (legacy) → unbounded both sides (== the pre-fix admin behaviour, so existing cash bonuses stay correct).
|
||||||
|
- **UI**: rewards modal gained a "Progress counts" group (shown when no period / not manual): `completeBy` radios (Unlimited | Until a date) + optional `completeByDate` date input + "Progress starts on" `startDate` date input. Creating from a template's `startMode` (Today/Next week) now sets `startDate` (Today = fam-tz today; Next week = +7 days). `openEditConfig`/`openCreateFromTemplate` default `startDate` to today, so saving an existing cash bonus re-scopes it to count from today (progress since the reward begins), not since records started.
|
||||||
|
- **Server actions** (`rewards/+page.server.ts`): `createConfig`/`updateConfig`/`createFromTemplate` now persist `completeBy`/`startDate`/`completeByDate`.
|
||||||
|
- **Typecheck/build**: `svelte-check` still at the 6 pre-existing canary errors (chores RewardType/Frequency, qrcode decl, settings unknown→string) — none in edited files; `pnpm build` (adapter-node) clean.
|
||||||
|
- **Ops note**: the schema fields are added to live PB by `ensureBonusFields()` on next `migrateOnBoot` (dev server restart). A restart of the dev frontend is needed to apply the migration + load the new code.
|
||||||
|
|
||||||
### 2026-08-22 — Graceful post-checkout activation (webhook-lag UX)
|
### 2026-08-22 — Graceful post-checkout activation (webhook-lag UX)
|
||||||
|
|
||||||
- `[fam]/+layout.svelte` owns the `?checkout=return` flow (moved out of the fam page). On landing: if unlocked → welcome notice. If still gated (webhook lag) → `activating` state: paused overlay swaps to a spinner card ("Activating your subscription…"), TopNav paused announcement suppressed, and `invalidateAll()` revalidates every 1.5s (max 12 tries). The `$effect` watching `activating && !disabled` cancels polling and fires "Subscription active!" the instant the gate lifts; exhaustion degrades to a refresh-hint warning.
|
- `[fam]/+layout.svelte` owns the `?checkout=return` flow (moved out of the fam page). On landing: if unlocked → welcome notice. If still gated (webhook lag) → `activating` state: paused overlay swaps to a spinner card ("Activating your subscription…"), TopNav paused announcement suppressed, and `invalidateAll()` revalidates every 1.5s (max 12 tries). The `$effect` watching `activating && !disabled` cancels polling and fires "Subscription active!" the instant the gate lifts; exhaustion degrades to a refresh-hint warning.
|
||||||
@@ -339,3 +370,22 @@
|
|||||||
- **`isDummyStripe` removed:** real test keys made every dummy branch dead code — and the webhook's `|| !signature` fallback accepted UNSIGNED events (forgeable access grants). Signature is now mandatory (400 without it); settings' simulated endSubscription/portal branches deleted. Dev verification stays via stripe-cli signed events (`pnpm stripe:listen`).
|
- **`isDummyStripe` removed:** real test keys made every dummy branch dead code — and the webhook's `|| !signature` fallback accepted UNSIGNED events (forgeable access grants). Signature is now mandatory (400 without it); settings' simulated endSubscription/portal branches deleted. Dev verification stays via stripe-cli signed events (`pnpm stripe:listen`).
|
||||||
- **Platform-admin auth hardened (supersedes the constant-cookie version):** `/admin` login now does a real `_superusers.authWithPassword` via PB; the minted superuser JWT goes in `platform_session` (`setPlatformSession` in session.ts). `hooks.server.ts` deviates on `/admin`: verifies the token with `_superusers.authRefresh` → `locals.platformAdmin` (forged values fail authRefresh and get cleared); fam-user pb_token flow skipped on that route. FINAL login shape (user-amended, working): action returns `{success:true}` (no redirect); form's enhance callback does `goto('/admin', { invalidateAll: true })` on success — forcing the load re-run with the fresh cookie; view branches on `data.authenticated`. Verified: real token renders dashboard, forged cookie gets login.
|
- **Platform-admin auth hardened (supersedes the constant-cookie version):** `/admin` login now does a real `_superusers.authWithPassword` via PB; the minted superuser JWT goes in `platform_session` (`setPlatformSession` in session.ts). `hooks.server.ts` deviates on `/admin`: verifies the token with `_superusers.authRefresh` → `locals.platformAdmin` (forged values fail authRefresh and get cleared); fam-user pb_token flow skipped on that route. FINAL login shape (user-amended, working): action returns `{success:true}` (no redirect); form's enhance callback does `goto('/admin', { invalidateAll: true })` on success — forcing the load re-run with the fresh cookie; view branches on `data.authenticated`. Verified: real token renders dashboard, forged cookie gets login.
|
||||||
- **use:enhance redirect gotcha (session-wide lesson):** action-thrown redirects surface as `result.type === 'redirect'` in the RESOLVE callback — handlers checking only `'success'` silently drop them (bit /admin login, pricing choose, and settings billingPortal). Shared fix: `lib/forms.ts` → `handleResult(handler?)` factory follows redirects via `goto`, delegates the rest to the handler or default `update()`. Use it for any form whose action can throw a redirect. Also: enhance is two-stage — `{result}` only exists in the resolve fn, not the submit params (was mis-handled in pricing/signup handlers).
|
- **use:enhance redirect gotcha (session-wide lesson):** action-thrown redirects surface as `result.type === 'redirect'` in the RESOLVE callback — handlers checking only `'success'` silently drop them (bit /admin login, pricing choose, and settings billingPortal). Shared fix: `lib/forms.ts` → `handleResult(handler?)` factory follows redirects via `goto`, delegates the rest to the handler or default `update()`. Use it for any form whose action can throw a redirect. Also: enhance is two-stage — `{result}` only exists in the resolve fn, not the submit params (was mis-handled in pricing/signup handlers).
|
||||||
|
|
||||||
|
### 2026-09-02 — Count-type reward: Target Chore + TODO (period semantics)
|
||||||
|
|
||||||
|
- **Feature:** Count-type rewards now support pinning to a single assigned chore (`bonus_configs.targetChoreId`). On the fam-admin Rewards modal (Step 2), when Type = "Count - (chores)" a **Target Chore** dropdown appears after the Target Value field — options are the selected member's assigned (non-todo) chores, default "All Chores". Evaluation (`bonuses.evaluateFam`) + display (`bonuses.progress`, dashboard `thresholdGoals`) now filter Count progress to only that chore's completions when set. Platform-level template form only needed the relabeled "Count - (chores)" — no target chore at template level. Pocket-money checkbox removed from the platform template form (only the auto-created per-child droplet needs it).
|
||||||
|
- **TODO (logic, not yet fixed):** Count rewards with a `period` (e.g. weekly) reset + re-earn each period like a points/cash threshold. Once a Count reward is pinned to a target (or all chores), the intended semantics are ambiguous: should it be **continuous** (unlimited, measured once since startDate until reached) or **limited to the period window** (re-earn each week)? Currently it follows the periodic-reset behavior. Decide whether Count rewards should ignore `period` (behave as standalone/unlimited since startDate) and adjust `bonusWindow`/evaluation accordingly. Not attempted in this change.
|
||||||
|
|
||||||
|
### 2026-09-11 — Shared-device PIN switching (a computer shared by siblings)
|
||||||
|
|
||||||
|
- **Problem:** a single `pb_token` cookie meant joining kid B on the family computer silently logged out kid A; every hand-off needed a fresh parent-issued OTP. Design note: `shared-device.md` (decisions + flows).
|
||||||
|
- **Model — the PIN is NOT a login:** it _selects_ among sessions that already exist on the device (`pb_token_<childId>` per child + `pb_active` naming the current one). PIN alone mints nothing on a fresh device; a stolen cookie alone selects nothing. Threat model = sibling mischief; devtools-level bypass explicitly accepted (data is family-scoped, toggles reversible).
|
||||||
|
- **Sessions (`session.ts` + `hooks.server.ts`):** children store one httpOnly cookie per account (`pb_token_<id>`, 5d TTL) + `pb_active`. Resolution order: `pb_active` first, then legacy `pb_token` (parents + pre-feature children). Parent login/join clears `pb_active` → supersedes kid mode; kid switches shadow (don't delete) a parent session; `/logout` clears everything. Per-profile removal = picker ✕ → `POST /api/device/remove` (device-local cookie surgery, no session required).
|
||||||
|
- **`pins` collection** (superuser-only rules like `otp`): plaintext 3-digit PIN, deliberately parent-recoverable (Q1: View). All access server-side with session-role checks — kids can never read siblings' pins via PB rules. Created on boot via `ensurePins({})` in the always-run migrate path (existing installs — no DB wipe), plus `settings.lockMins` via `SCHEMA_PLAN` + `ensureSettingsFields`.
|
||||||
|
- **Endpoints:** `POST /api/switch-user {userId, pin}` — cookie-presence gate, superuser PIN verify, in-memory rate limit (5 fails → 30s), **self-healing**: expired device tokens re-mint via the derived password (server-only) instead of forcing a re-join. `POST /api/pins` — `set` (first-time), `change` (needs current), `ensure` (set-if-missing, used by the join wizard so joining a _second_ shared device doesn't clash with an existing PIN).
|
||||||
|
- **Picker (`SharedPicker` component)** — one UI everywhere: top-nav colour-dot (any session), idle-lock overlay, and standalone `/{fam}/switch` landing (fam layout redirects there when kid cookies exist but none is active; join pages excluded from the redirect; `+page.server.ts` sends active sessions home). PIN required on every pick incl. "resume" (deliberate-select property). Standalone footer links: join with a code + parent login.
|
||||||
|
- **Join wizard (`JoinPinFlow`):** after OTP redeem → "shared with siblings?" → PIN setup (skipped on "no"), **forced when the device already has other kid sessions**; then straight to the kid's dashboard. All three child join routes updated (root `/join`, `/{fam}/join`, `/{fam}/join/{username}`); parents keep the single-session flow.
|
||||||
|
- **Idle lock (client, `lib/client/lock.ts`):** `localStorage[fam_last_active]` written on click/key/touch (throttled 10s) + force-written on `pagehide` → survives browser close/sleep/restart (next launch compares elapsed; live 15s interval mid-session). Default **10 min** (user-set: default 10, not 2-3); parent-adjustable `Off / 2 / 10` in Settings → Family → **Shared computer**. Children only; tab-switches don't lock; two tabs share the timestamp so the active tab arbitrates.
|
||||||
|
- **Parent/child PIN UI:** Settings → Invites → All members → **PIN** per member (reveal + give-a-new-PIN modal); child Preferences → **My PIN** (set, or change with current PIN; "forgot? parent can read it out").
|
||||||
|
- **Typecheck/build:** `svelte-check` still 4 pre-existing canary errors only (chores RewardType/Frequency ×3, qrcode decl — files untouched by this change); prettier run over touched files. Migration applies on next dev-server boot (migrateOnBoot) — no DB wipe; hooks changes need the dev server restart (auto).
|
||||||
|
- **Caveats:** legacy single-cookie child sessions work but don't appear in the picker until re-join; multi-tab concurrency accepted (single-tab-norm on family desktops); `shared-device.md` records the settled open questions (view-not-reset, optional-at-join, no parent switcher, straight-to-dashboard, no cross-family).
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# FamChore v2 — Rules
|
# FamDone v2 — Rules
|
||||||
|
|
||||||
## Auth
|
## Auth
|
||||||
- Every collection query includes `famId = @request.auth.famId` filter
|
- Every collection query includes `famId = @request.auth.famId` filter
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# FamChore — TODO
|
# FamDone — TODO
|
||||||
|
|
||||||
(Completed work is tracked in MEMORY.md / git history.)
|
(Completed work is tracked in MEMORY.md / git history.)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-check-circle"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path><polyline points="22 4 12 14.01 9 11.01"></polyline></svg>
|
||||||
|
After Width: | Height: | Size: 328 B |
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-3
@@ -1,13 +1,14 @@
|
|||||||
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': {
|
'~standard': {
|
||||||
version: 1,
|
version: 1,
|
||||||
vendor: 'famchamp',
|
vendor: 'famchamp',
|
||||||
validate: (v: unknown) => ({ value: typeof v === 'string' && v ? v : value })
|
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') },
|
||||||
@@ -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('') }
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,8 +2,13 @@ import type { Handle } from '@sveltejs/kit';
|
|||||||
import { createPbClient } from '$lib/server/pocketbase';
|
import { createPbClient } from '$lib/server/pocketbase';
|
||||||
import {
|
import {
|
||||||
SESSION_COOKIE,
|
SESSION_COOKIE,
|
||||||
|
ACTIVE_COOKIE,
|
||||||
|
childSessionCookie,
|
||||||
setSessionCookie,
|
setSessionCookie,
|
||||||
clearSessionCookie,
|
clearSessionCookie,
|
||||||
|
setChildSessionCookie,
|
||||||
|
setActiveChild,
|
||||||
|
clearActiveChild,
|
||||||
PLATFORM_SESSION_COOKIE,
|
PLATFORM_SESSION_COOKIE,
|
||||||
clearPlatformSession
|
clearPlatformSession
|
||||||
} from '$lib/server/session';
|
} from '$lib/server/session';
|
||||||
@@ -14,6 +19,22 @@ import { migrateOnBoot } from '$lib/server/migrate-boot';
|
|||||||
// Run the PB schema migration once at server boot (idempotent).
|
// Run the PB schema migration once at server boot (idempotent).
|
||||||
void migrateOnBoot();
|
void migrateOnBoot();
|
||||||
|
|
||||||
|
function sessionFrom(record: any, freshToken: string) {
|
||||||
|
return {
|
||||||
|
id: record.id,
|
||||||
|
name: record.name || record.username || '',
|
||||||
|
username: handleOf(record.username || ''),
|
||||||
|
role: record.role || 'parent',
|
||||||
|
famId: record.famId,
|
||||||
|
color: record.color || '',
|
||||||
|
pattern: record.pattern || '',
|
||||||
|
themeSize: record.themeSize || '',
|
||||||
|
themeOpacity: record.themeOpacity || '',
|
||||||
|
partyEmoji: record.partyEmoji || '',
|
||||||
|
token: freshToken
|
||||||
|
} satisfies SessionUser & { token: string };
|
||||||
|
}
|
||||||
|
|
||||||
export const handle: Handle = async ({ event, resolve }) => {
|
export const handle: Handle = async ({ event, resolve }) => {
|
||||||
event.locals.user = null;
|
event.locals.user = null;
|
||||||
event.locals.pbToken = null;
|
event.locals.pbToken = null;
|
||||||
@@ -36,36 +57,69 @@ export const handle: Handle = async ({ event, resolve }) => {
|
|||||||
return resolve(event);
|
return resolve(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fam-user session: pb_token JWT → authRefresh → locals.user.
|
// Shared-device sessions: `pb_active` names the child whose cookie is the
|
||||||
const token = event.cookies.get(SESSION_COOKIE);
|
// current session. Resolved FIRST so a kid switch on a family computer
|
||||||
|
// supersedes any shadowed single pb_token (parent) session.
|
||||||
|
const activeId = event.cookies.get(ACTIVE_COOKIE);
|
||||||
|
if (activeId) {
|
||||||
|
const childToken = event.cookies.get(childSessionCookie(activeId));
|
||||||
|
if (!childToken) {
|
||||||
|
// Stale active pointer (cookie removed) — clean it up and fall through.
|
||||||
|
clearActiveChild(event.cookies);
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const pb = createPbClient(childToken);
|
||||||
|
// authRefresh() does two jobs in one call:
|
||||||
|
// 1. Verifies the token (PB JWTs can't be checked offline — the
|
||||||
|
// signing secret is per-record and never leaves PB).
|
||||||
|
// 2. Returns the current record — the only way to get
|
||||||
|
// name/role/famId, since PB doesn't embed custom fields.
|
||||||
|
const { record, token: freshToken } = await pb.collection('users').authRefresh();
|
||||||
|
if (record.role === 'child') {
|
||||||
|
const session = sessionFrom(record, freshToken);
|
||||||
|
event.locals.user = session;
|
||||||
|
event.locals.pbToken = session.token;
|
||||||
|
if (freshToken !== childToken) {
|
||||||
|
setChildSessionCookie(event.cookies, activeId, freshToken);
|
||||||
|
}
|
||||||
|
// Sliding expiry: pb_active has the same 5-day maxAge as the
|
||||||
|
// token cookies but was previously never re-set, so it aged
|
||||||
|
// out 5 days after join/switch despite daily use. Re-set it
|
||||||
|
// on every authenticated request to keep it alive.
|
||||||
|
setActiveChild(event.cookies, activeId);
|
||||||
|
return resolve(event);
|
||||||
|
}
|
||||||
|
console.error(
|
||||||
|
`[diag] hooks child wrong-role activeId=${activeId} role=${record.role} path=${event.url.pathname}`
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
// Expired/revoked/malformed — leave the cookie; the picker switch
|
||||||
|
// re-mints it server-side. Fall through to the single session.
|
||||||
|
console.error(
|
||||||
|
`[diag] hooks child authRefresh-fail activeId=${activeId} path=${event.url.pathname} err=${e instanceof Error ? e.message : e}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single session: pb_token JWT (parents, or children from before the
|
||||||
|
// multi-session scheme) → authRefresh → locals.user.
|
||||||
|
const token = event.cookies.get(SESSION_COOKIE);
|
||||||
if (token) {
|
if (token) {
|
||||||
const pb = createPbClient(token);
|
const pb = createPbClient(token);
|
||||||
try {
|
try {
|
||||||
// authRefresh() does two jobs in one call:
|
|
||||||
// 1. Verifies the token (PB 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 — the only way to get
|
|
||||||
// name/role/famId, since PB doesn't embed custom fields in the
|
|
||||||
// token itself.
|
|
||||||
const { record, token: freshToken } = await pb.collection('users').authRefresh();
|
const { record, token: freshToken } = await pb.collection('users').authRefresh();
|
||||||
|
const session = sessionFrom(record, freshToken);
|
||||||
event.locals.user = {
|
event.locals.user = session;
|
||||||
id: record.id,
|
event.locals.pbToken = session.token;
|
||||||
name: record.name || record.username || '',
|
|
||||||
username: handleOf(record.username || ''),
|
|
||||||
role: record.role || 'parent',
|
|
||||||
famId: record.famId,
|
|
||||||
color: record.color || ''
|
|
||||||
} satisfies SessionUser;
|
|
||||||
event.locals.pbToken = freshToken;
|
|
||||||
|
|
||||||
if (freshToken !== token) {
|
if (freshToken !== token) {
|
||||||
setSessionCookie(event.cookies, freshToken);
|
setSessionCookie(event.cookies, freshToken);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (e) {
|
||||||
// Expired, malformed, or revoked — drop it and treat as logged out.
|
// Expired, malformed, or revoked — drop it and treat as logged out.
|
||||||
|
console.error(
|
||||||
|
`[diag] hooks single authRefresh-fail path=${event.url.pathname} err=${e instanceof Error ? e.message : e}`
|
||||||
|
);
|
||||||
clearSessionCookie(event.cookies);
|
clearSessionCookie(event.cookies);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-check-circle"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path><polyline points="22 4 12 14.01 9 11.01"></polyline></svg>
|
||||||
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 328 B |
@@ -20,8 +20,11 @@ async function memberFetch<T = unknown>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const memberApi = {
|
export const memberApi = {
|
||||||
async toggleCompletion(famId: string, assignedChoreId: string, date: string) {
|
async toggleCompletion(famId: string, assignedChoreId: string, date: string, completedAt?: string) {
|
||||||
return memberFetch('POST', '/api/completions/toggle', { assignedChoreId, date });
|
return memberFetch('POST', '/api/completions/toggle', { assignedChoreId, date, completedAt });
|
||||||
|
},
|
||||||
|
async createTodo(name: string) {
|
||||||
|
return memberFetch<{ record: unknown }>('POST', '/api/todos', { name });
|
||||||
},
|
},
|
||||||
async claimReward(famId: string, rewardId: string) {
|
async claimReward(famId: string, rewardId: string) {
|
||||||
return memberFetch('POST', `/api/members/rewards/${rewardId}/claim`);
|
return memberFetch('POST', `/api/members/rewards/${rewardId}/claim`);
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
// Shared-device idle lock (client-side). Tracks the last interaction
|
||||||
|
// timestamp in localStorage and exposes "should the app return to the profile
|
||||||
|
// picker?" checks. localStorage (not cookies/beacons) so the timestamp
|
||||||
|
// survives browser close, sleeps and restarts — the pagehide write is
|
||||||
|
// synchronous and can't be lost on tab close.
|
||||||
|
|
||||||
|
const KEY = 'fam_last_active';
|
||||||
|
const WRITE_THROTTLE_MS = 10_000;
|
||||||
|
|
||||||
|
let installed = false;
|
||||||
|
let lastWrite = 0;
|
||||||
|
|
||||||
|
export function recordActivity(force = false) {
|
||||||
|
if (typeof localStorage === 'undefined') return;
|
||||||
|
const now = Date.now();
|
||||||
|
if (!force && now - lastWrite < WRITE_THROTTLE_MS) return;
|
||||||
|
try {
|
||||||
|
localStorage.setItem(KEY, String(now));
|
||||||
|
lastWrite = now;
|
||||||
|
} catch {
|
||||||
|
/* private mode etc. — the lock simply has no data yet */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function lastActiveAt(): number {
|
||||||
|
if (typeof localStorage === 'undefined') return 0;
|
||||||
|
const raw = localStorage.getItem(KEY);
|
||||||
|
const n = raw ? Number(raw) : 0;
|
||||||
|
return Number.isFinite(n) && n > 0 ? n : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function lockDue(lockMins: number): boolean {
|
||||||
|
if (!lockMins || lockMins <= 0) return false;
|
||||||
|
const last = lastActiveAt();
|
||||||
|
if (!last) return false;
|
||||||
|
return Date.now() - last > lockMins * 60_000;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function installLockTracking() {
|
||||||
|
if (installed || typeof document === 'undefined') return;
|
||||||
|
installed = true;
|
||||||
|
const on = () => recordActivity();
|
||||||
|
document.addEventListener('click', on);
|
||||||
|
document.addEventListener('keydown', on);
|
||||||
|
document.addEventListener('touchstart', on);
|
||||||
|
window.addEventListener('pagehide', () => recordActivity(true));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fresh activation (join wizard, picker switch) — write now so the just-opened
|
||||||
|
// session doesn't instantly trip the mount-time lock check.
|
||||||
|
export function resetClock() {
|
||||||
|
recordActivity(true);
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
// Per-device "this is a shared computer" flag. localStorage is the source of
|
||||||
|
// truth (shared-ness is a property of THIS browser, not the family — a DB
|
||||||
|
// flag would force PIN mode on every device including a parent's phone).
|
||||||
|
// A plain cookie mirror lets server loads see it too (localStorage never
|
||||||
|
// reaches the server). Neither is a security boundary: the PIN + device
|
||||||
|
// session cookies remain the actual gate (see shared-device.md).
|
||||||
|
const LS_KEY = 'fam_shared_device';
|
||||||
|
const COOKIE = 'fam_shared_device';
|
||||||
|
|
||||||
|
export function isSharedDevice(): boolean {
|
||||||
|
if (typeof localStorage === 'undefined') return false;
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(LS_KEY) === '1';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setSharedDevice(on: boolean) {
|
||||||
|
try {
|
||||||
|
if (on) localStorage.setItem(LS_KEY, '1');
|
||||||
|
else localStorage.removeItem(LS_KEY);
|
||||||
|
} catch {
|
||||||
|
/* private mode etc. — flag simply doesn't persist */
|
||||||
|
}
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
document.cookie =
|
||||||
|
on
|
||||||
|
? `${COOKIE}=1; path=/; max-age=31536000; SameSite=Lax`
|
||||||
|
: `${COOKIE}=; path=/; max-age=0; SameSite=Lax`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,17 +1,24 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
|
import ChevronDown from '@lucide/svelte/icons/chevron-down';
|
||||||
|
|
||||||
let { title, open = $bindable(false), children }: { title: string; open?: boolean; children: Snippet } = $props();
|
let {
|
||||||
|
title,
|
||||||
|
icon,
|
||||||
|
open = $bindable(false),
|
||||||
|
children
|
||||||
|
}: { title: string; icon?: Snippet; open?: boolean; children: Snippet } = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="accordion-item" class:open>
|
<div class="accordion-item" class:open>
|
||||||
<button
|
<button class="accordion-trigger" onclick={() => (open = !open)} aria-expanded={open}>
|
||||||
class="accordion-trigger"
|
<span class="accordion-title">
|
||||||
onclick={() => (open = !open)}
|
{#if icon}
|
||||||
aria-expanded={open}
|
<span class="accordion-icon">{@render icon()}</span>
|
||||||
>
|
{/if}
|
||||||
<span>{title}</span>
|
<span>{title}</span>
|
||||||
<span class="accordion-arrow">{open ? '▾' : '▸'}</span>
|
</span>
|
||||||
|
<span class="accordion-arrow" class:rotated={open}><ChevronDown /></span>
|
||||||
</button>
|
</button>
|
||||||
{#if open}
|
{#if open}
|
||||||
<div class="accordion-body">
|
<div class="accordion-body">
|
||||||
@@ -21,16 +28,58 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.accordion-item { border-bottom: 1px solid #f3f4f6; }
|
.accordion-item {
|
||||||
.accordion-item:last-child { border-bottom: none; }
|
border-bottom: 1px solid #f3f4f6;
|
||||||
.accordion-trigger {
|
}
|
||||||
display: flex; justify-content: space-between; align-items: center;
|
.accordion-item:last-child {
|
||||||
width: 100%; padding: 0.7rem 1rem;
|
border-bottom: none;
|
||||||
background: #fafafa; border: none;
|
}
|
||||||
font-size: 0.95rem; font-weight: 600; color: #374151;
|
.accordion-trigger {
|
||||||
cursor: pointer; text-align: left;
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.7rem 1rem;
|
||||||
|
background: #fafafa;
|
||||||
|
border: none;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #374151;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.accordion-trigger:hover {
|
||||||
|
background: #f3f4f6;
|
||||||
|
}
|
||||||
|
.accordion-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.accordion-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
color: #6366f1;
|
||||||
|
}
|
||||||
|
.accordion-icon :global(svg) {
|
||||||
|
width: 1.05em;
|
||||||
|
height: 1.05em;
|
||||||
|
}
|
||||||
|
.accordion-arrow {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
color: #9ca3af;
|
||||||
|
transition: transform 0.15s ease;
|
||||||
|
transform: rotate(-90deg);
|
||||||
|
}
|
||||||
|
.accordion-arrow.rotated {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
.accordion-arrow :global(svg) {
|
||||||
|
width: 1.1em;
|
||||||
|
height: 1.1em;
|
||||||
|
}
|
||||||
|
.accordion-body {
|
||||||
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
.accordion-trigger:hover { background: #f3f4f6; }
|
|
||||||
.accordion-arrow { font-size: 0.8rem; color: #9ca3af; }
|
|
||||||
.accordion-body { padding: 1rem; }
|
|
||||||
</style>
|
</style>
|
||||||
@@ -1,13 +1,21 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
let { title, subtitle, children }: {
|
import FamDone from './FamDone.svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
children,
|
||||||
|
secondary
|
||||||
|
}: {
|
||||||
title: string;
|
title: string;
|
||||||
subtitle?: string;
|
subtitle?: string;
|
||||||
children?: any;
|
children?: any;
|
||||||
|
secondary?: any;
|
||||||
} = $props();
|
} = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<main class="auth">
|
<main class="auth">
|
||||||
<a class="brand" href="/">FamChore</a>
|
<a class="brand" href="/"><FamDone /></a>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h1>{title}</h1>
|
<h1>{title}</h1>
|
||||||
{#if subtitle}
|
{#if subtitle}
|
||||||
@@ -15,6 +23,9 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
</div>
|
</div>
|
||||||
|
{#if secondary}
|
||||||
|
<div class="card secondary">{@render secondary?.()}</div>
|
||||||
|
{/if}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -26,7 +37,10 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 2rem 1rem;
|
padding: 2rem 1rem;
|
||||||
background: linear-gradient(135deg, #eef2ff 0%, #ffffff 55%, #eef2ff 100%);
|
background: linear-gradient(135deg, #eef2ff 0%, #ffffff 55%, #eef2ff 100%);
|
||||||
font-family: system-ui, -apple-system, sans-serif;
|
font-family:
|
||||||
|
system-ui,
|
||||||
|
-apple-system,
|
||||||
|
sans-serif;
|
||||||
color: #1f2937;
|
color: #1f2937;
|
||||||
}
|
}
|
||||||
.brand {
|
.brand {
|
||||||
@@ -55,7 +69,12 @@
|
|||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
.card.secondary {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
.card { padding: 1.5rem; }
|
.card {
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</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';
|
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,22 +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: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>
|
||||||
|
|||||||
@@ -7,7 +7,15 @@
|
|||||||
children,
|
children,
|
||||||
class: className,
|
class: className,
|
||||||
selected = false
|
selected = false
|
||||||
}: { cols?: 1 | 2 | 3 | 4 | 5 | 6; title?: string; accent?: string; scrollX?: boolean; children?: any; class?: string; selected?: boolean } = $props();
|
}: {
|
||||||
|
cols?: 1 | 2 | 3 | 4 | 5 | 6;
|
||||||
|
title?: string;
|
||||||
|
accent?: string;
|
||||||
|
scrollX?: boolean;
|
||||||
|
children?: any;
|
||||||
|
class?: string;
|
||||||
|
selected?: boolean;
|
||||||
|
} = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -44,6 +52,14 @@
|
|||||||
.card.has-accent {
|
.card.has-accent {
|
||||||
border-top: 3px solid var(--card-accent, #6366f1);
|
border-top: 3px solid var(--card-accent, #6366f1);
|
||||||
}
|
}
|
||||||
|
/* Background pattern active (has-pattern on the layout root): cards go
|
||||||
|
translucent so the pattern shows through. Desktop/tablet only — mobile
|
||||||
|
cards are already fully transparent. */
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
:global(.has-pattern) .card {
|
||||||
|
background: #ffffffd9;
|
||||||
|
}
|
||||||
|
}
|
||||||
/* Tablet (2-col grid): anything spanning 3+ collapses to a full row (span 2) */
|
/* Tablet (2-col grid): anything spanning 3+ collapses to a full row (span 2) */
|
||||||
@media (min-width: 640px) and (max-width: 1023px) {
|
@media (min-width: 640px) and (max-width: 1023px) {
|
||||||
.card[data-cols='3'],
|
.card[data-cols='3'],
|
||||||
@@ -53,12 +69,6 @@
|
|||||||
grid-column: span 2;
|
grid-column: span 2;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/* Mobile (1-col grid): every card is a full row */
|
|
||||||
@media (max-width: 639px) {
|
|
||||||
.card {
|
|
||||||
grid-column: span 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.card-header {
|
.card-header {
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.75rem 1rem;
|
||||||
border-bottom: 1px solid #f3f4f6;
|
border-bottom: 1px solid #f3f4f6;
|
||||||
@@ -76,4 +86,17 @@
|
|||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
}
|
}
|
||||||
|
/* Mobile (1-col grid): every card is a full row — declared last so it
|
||||||
|
overrides the base .card/.card-body rules (same specificity). */
|
||||||
|
@media (max-width: 639px) {
|
||||||
|
.card {
|
||||||
|
grid-column: span 1;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
.card-body {
|
||||||
|
background: transparent;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
let { cols = 3, children }: { cols?: number; children?: any } = $props();
|
let { stackTablet = false, children }: { stackTablet?: boolean; children?: any } = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="card-grid" style="--grid-cols: {cols}">
|
<div class="card-grid" class:stack-tablet={stackTablet}>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -10,18 +10,22 @@
|
|||||||
.card-grid {
|
.card-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
grid-template-columns: repeat(var(--grid-cols, 3), 1fr);
|
grid-template-columns: repeat(3, 1fr);
|
||||||
}
|
}
|
||||||
/* Tablet: settle to 2 columns */
|
/* Tablet: two columns */
|
||||||
@media (min-width: 640px) and (max-width: 1023px) {
|
@media (min-width: 640px) and (max-width: 1023px) {
|
||||||
.card-grid {
|
.card-grid {
|
||||||
--grid-cols: 2;
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
/* Opt-in: stack to a single column on tablet */
|
||||||
|
.card-grid.stack-tablet {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/* Mobile: single column */
|
/* Mobile: single column */
|
||||||
@media (max-width: 639px) {
|
@media (max-width: 639px) {
|
||||||
.card-grid {
|
.card-grid {
|
||||||
--grid-cols: 1;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -12,6 +12,67 @@
|
|||||||
let container: HTMLDivElement | undefined = $state();
|
let container: HTMLDivElement | undefined = $state();
|
||||||
let idleTimer: ReturnType<typeof setTimeout> | undefined;
|
let idleTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
|
// @-mention autocomplete — active while the draft ends with `@query` at the
|
||||||
|
// start of the message or after whitespace.
|
||||||
|
let token = $state<{ at: number; query: string } | null>(null);
|
||||||
|
let mentionIndex = $state(0);
|
||||||
|
|
||||||
|
function mentionTokenOf(text: string): { at: number; query: string } | null {
|
||||||
|
const atIdx = text.lastIndexOf('@');
|
||||||
|
if (atIdx === -1) return null;
|
||||||
|
if (atIdx > 0 && text[atIdx - 1] !== ' ' && text[atIdx - 1] !== '\n') return null;
|
||||||
|
return { at: atIdx, query: text.slice(atIdx + 1) };
|
||||||
|
}
|
||||||
|
|
||||||
|
let mentionList = $derived.by(() => {
|
||||||
|
if (!token) return [];
|
||||||
|
const q = token.query.toLowerCase();
|
||||||
|
const list = chatStore.members.filter((m) => m.id !== chatStore.actorId);
|
||||||
|
if (!q) return list;
|
||||||
|
return list.filter((m) => m.name.toLowerCase().startsWith(q));
|
||||||
|
});
|
||||||
|
|
||||||
|
function selectMention(m: { name: string }) {
|
||||||
|
if (!token) return;
|
||||||
|
draft = draft.slice(0, token.at) + '@' + m.name + ' ';
|
||||||
|
token = null;
|
||||||
|
mentionIndex = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onComposerInput(e: Event) {
|
||||||
|
token = mentionTokenOf((e.currentTarget as HTMLInputElement).value);
|
||||||
|
mentionIndex = 0;
|
||||||
|
onInput();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onComposerKeydown(e: KeyboardEvent) {
|
||||||
|
if (token && mentionList.length > 0) {
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
mentionIndex = (mentionIndex + 1) % mentionList.length;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
mentionIndex = (mentionIndex - 1 + mentionList.length) % mentionList.length;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === 'Tab') {
|
||||||
|
e.preventDefault();
|
||||||
|
selectMention(mentionList[mentionIndex]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
token = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
send();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const isOwn = (msg: any) => msg.authorId === chatStore.actorId;
|
const isOwn = (msg: any) => msg.authorId === chatStore.actorId;
|
||||||
|
|
||||||
// Colour comes from the sender's user record (loaded with the family on
|
// Colour comes from the sender's user record (loaded with the family on
|
||||||
@@ -39,6 +100,19 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Close the panel with Escape while it's open.
|
||||||
|
$effect(() => {
|
||||||
|
if (!chatStore.open) return;
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
|
});
|
||||||
|
|
||||||
async function send() {
|
async function send() {
|
||||||
const text = draft.trim();
|
const text = draft.trim();
|
||||||
if (!text || sending) return;
|
if (!text || sending) return;
|
||||||
@@ -82,18 +156,28 @@
|
|||||||
draft = (draft ? draft + ' ' : '') + '@' + name + ' ';
|
draft = (draft ? draft + ' ' : '') + '@' + name + ' ';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render content, wrapping @mentions in a styled token.
|
// Render content, wrapping @mentions in a styled token and https:// URLs in
|
||||||
|
// clickable links (opened in a new window). Mentions may be multi-word names
|
||||||
|
// (space-separated words after the @).
|
||||||
function renderContent(text: string) {
|
function renderContent(text: string) {
|
||||||
const segs: { text: string; mention: boolean }[] = [];
|
const segs: { text: string; mention: boolean; url: string | null }[] = [];
|
||||||
const re = /(@[A-Za-z0-9_.-]+)/g;
|
const re = /(@[A-Za-z0-9_.-]+(?:[\s]+[A-Za-z0-9_.-]+)*|https?:\/\/[^\s]+)/g;
|
||||||
let last = 0;
|
let last = 0;
|
||||||
let m: RegExpExecArray | null;
|
let m: RegExpExecArray | null;
|
||||||
while ((m = re.exec(text)) !== null) {
|
while ((m = re.exec(text)) !== null) {
|
||||||
if (m.index > last) segs.push({ text: text.slice(last, m.index), mention: false });
|
if (m.index > last) segs.push({ text: text.slice(last, m.index), mention: false, url: null });
|
||||||
segs.push({ text: m[1], mention: true });
|
const tok = m[1];
|
||||||
|
if (tok.startsWith('@')) {
|
||||||
|
segs.push({ text: tok, mention: true, url: null });
|
||||||
|
} else {
|
||||||
|
const url = tok.replace(/[.,;:!?]+$/, '');
|
||||||
|
if (url) segs.push({ text: url, mention: false, url });
|
||||||
|
const rest = tok.slice(url.length);
|
||||||
|
if (rest) segs.push({ text: rest, mention: false, url: null });
|
||||||
|
}
|
||||||
last = m.index + m[0].length;
|
last = m.index + m[0].length;
|
||||||
}
|
}
|
||||||
if (last < text.length) segs.push({ text: text.slice(last), mention: false });
|
if (last < text.length) segs.push({ text: text.slice(last), mention: false, url: null });
|
||||||
return segs;
|
return segs;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,15 +207,16 @@
|
|||||||
{#if !isOwn(msg)}
|
{#if !isOwn(msg)}
|
||||||
<span class="msg-author" style="color:{colorOf(msg)}">{msg.authorName}</span>
|
<span class="msg-author" style="color:{colorOf(msg)}">{msg.authorName}</span>
|
||||||
{/if}
|
{/if}
|
||||||
<div
|
<div class="bubble" style="background:{colorOf(msg) + '33'}; color:{colorOf(msg)}">
|
||||||
class="bubble"
|
{#each renderContent(msg.content) as seg, i (msg.id + ':' + i)}
|
||||||
style="background:{colorOf(msg) + '33'}; color:{colorOf(msg)}"
|
|
||||||
>
|
|
||||||
{#each renderContent(msg.content) as seg (msg.id + ':' + seg.text)}
|
|
||||||
{#if seg.mention}
|
{#if seg.mention}
|
||||||
<button class="mention" onclick={() => insertMention(seg.text.slice(1))}
|
<button class="mention" onclick={() => insertMention(seg.text.slice(1))}
|
||||||
>{seg.text}</button
|
>{seg.text}</button
|
||||||
>
|
>
|
||||||
|
{:else if seg.url}
|
||||||
|
<a class="msg-link" href={seg.url} target="_blank" rel="noopener noreferrer"
|
||||||
|
>{seg.url}</a
|
||||||
|
>
|
||||||
{:else}
|
{:else}
|
||||||
{seg.text}
|
{seg.text}
|
||||||
{/if}
|
{/if}
|
||||||
@@ -157,20 +242,34 @@
|
|||||||
|
|
||||||
<div class="chat-composer">
|
<div class="chat-composer">
|
||||||
{#if error}<div class="chat-error">{error}</div>{/if}
|
{#if error}<div class="chat-error">{error}</div>{/if}
|
||||||
|
<div class="composer-wrap">
|
||||||
|
{#if token && mentionList.length > 0}
|
||||||
|
<div class="mention-pop">
|
||||||
|
{#each mentionList as m, i}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="mention-opt"
|
||||||
|
class:active={i === mentionIndex}
|
||||||
|
onclick={() => selectMention(m)}
|
||||||
|
onmouseenter={() => (mentionIndex = i)}
|
||||||
|
>
|
||||||
|
<span class="m-dot" style="background:{m.color}"></span>
|
||||||
|
<span class="m-name">{m.name}</span>
|
||||||
|
<span class="m-role">{m.role === 'parent' ? 'Parent' : ''}</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
class="chat-input"
|
class="chat-input"
|
||||||
placeholder="Message the family…"
|
placeholder="Message the family…"
|
||||||
bind:value={draft}
|
bind:value={draft}
|
||||||
oninput={onInput}
|
oninput={onComposerInput}
|
||||||
onkeydown={(e) => {
|
onkeydown={onComposerKeydown}
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
|
||||||
e.preventDefault();
|
|
||||||
send();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={sending}
|
disabled={sending}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
<button class="chat-send" onclick={send} disabled={sending || !draft.trim()} aria-label="Send">
|
<button class="chat-send" onclick={send} disabled={sending || !draft.trim()} aria-label="Send">
|
||||||
{@html sendIcon}
|
{@html sendIcon}
|
||||||
</button>
|
</button>
|
||||||
@@ -199,14 +298,15 @@
|
|||||||
/* Mobile: full-screen fixed layer. */
|
/* Mobile: full-screen fixed layer. */
|
||||||
@media (max-width: 767.98px) {
|
@media (max-width: 767.98px) {
|
||||||
.chat-panel {
|
.chat-panel {
|
||||||
width: 100vw;
|
width: 100dvw;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.chat-head {
|
.chat-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 0.85rem 1.25rem;
|
height: 52px;
|
||||||
|
padding: 0 1.25rem;
|
||||||
border-bottom: 1px solid #e5e7eb;
|
border-bottom: 1px solid #e5e7eb;
|
||||||
background: #4338ca;
|
background: #4338ca;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
@@ -309,6 +409,11 @@
|
|||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
.msg-link {
|
||||||
|
color: #6366f1;
|
||||||
|
text-decoration: underline;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
.chat-typing {
|
.chat-typing {
|
||||||
min-height: 1.5rem;
|
min-height: 1.5rem;
|
||||||
padding: 0 1.25rem;
|
padding: 0 1.25rem;
|
||||||
@@ -325,6 +430,65 @@
|
|||||||
border-top: 1px solid #e5e7eb;
|
border-top: 1px solid #e5e7eb;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
.composer-wrap {
|
||||||
|
position: relative;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.composer-wrap .chat-input {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.mention-pop {
|
||||||
|
position: absolute;
|
||||||
|
bottom: calc(100% + 6px);
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.14);
|
||||||
|
padding: 0.25rem;
|
||||||
|
max-height: 220px;
|
||||||
|
overflow-y: auto;
|
||||||
|
z-index: 120;
|
||||||
|
}
|
||||||
|
.mention-opt {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.45rem 0.6rem;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.mention-opt:hover,
|
||||||
|
.mention-opt.active {
|
||||||
|
background: #eef2ff;
|
||||||
|
}
|
||||||
|
.m-dot {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
.m-name {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1f2937;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.m-role {
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #9ca3af;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
.chat-error {
|
.chat-error {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 4.5rem;
|
bottom: 4.5rem;
|
||||||
|
|||||||
@@ -0,0 +1,217 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
|
const {
|
||||||
|
cellSize = 32,
|
||||||
|
gap = 8,
|
||||||
|
color = 'rgba(200, 210, 255, 0.15)',
|
||||||
|
tickColor = 'rgba(200, 210, 255, 0.55)',
|
||||||
|
rotation = -8,
|
||||||
|
waveSpeed = 1.6
|
||||||
|
}: {
|
||||||
|
cellSize?: number;
|
||||||
|
gap?: number;
|
||||||
|
color?: string;
|
||||||
|
tickColor?: string;
|
||||||
|
rotation?: number;
|
||||||
|
waveSpeed?: number;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let containerEl: HTMLDivElement;
|
||||||
|
let columns = $state(0);
|
||||||
|
let rows = $state(0);
|
||||||
|
let mounted = $state(false);
|
||||||
|
let phase = $state(0);
|
||||||
|
let rafId = 0;
|
||||||
|
let lastTime = 0;
|
||||||
|
|
||||||
|
const seed = Math.random() * 1000;
|
||||||
|
|
||||||
|
function seededRandom(i: number, j: number): number {
|
||||||
|
const x = Math.sin(seed + i * 127.1 + j * 311.7) * 43758.5453;
|
||||||
|
return x - Math.floor(x);
|
||||||
|
}
|
||||||
|
|
||||||
|
function calcGrid() {
|
||||||
|
if (!containerEl) return;
|
||||||
|
const rect = containerEl.getBoundingClientRect();
|
||||||
|
const w = rect.width;
|
||||||
|
const h = rect.height;
|
||||||
|
columns = Math.ceil(w / (cellSize + gap)) + 1;
|
||||||
|
rows = Math.ceil(h / (cellSize + gap)) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function gridRows(): number {
|
||||||
|
return rows + 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
function gridStart(): number {
|
||||||
|
return -4;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCellRotation(i: number, j: number): number {
|
||||||
|
const r = seededRandom(i, j);
|
||||||
|
return (r - 0.5) * 16;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRowOffset(i: number): number {
|
||||||
|
return i % 2 === 1 ? (cellSize + gap) / 2 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCellOpacity(i: number, j: number, p: number): number {
|
||||||
|
const centerJ = (j + 0.5) / columns;
|
||||||
|
const wave = Math.sin((centerJ * columns * 0.3) - i * 0.35 + p);
|
||||||
|
const threshold = (wave + 1) / 2;
|
||||||
|
const cellRand = seededRandom(i, j);
|
||||||
|
return cellRand < threshold ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isGreenCell(i: number, j: number): boolean {
|
||||||
|
return seededRandom(i + 500, j + 500) < 0.15;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTickColor(i: number, j: number): string {
|
||||||
|
return isGreenCell(i, j) ? 'rgba(16, 185, 129, 0.6)' : tickColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBoxColor(i: number, j: number): string {
|
||||||
|
return isGreenCell(i, j) ? 'rgba(16, 185, 129, 0.35)' : color;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBoxRadius(i: number, j: number): string {
|
||||||
|
return isGreenCell(i, j) ? '50%' : '4';
|
||||||
|
}
|
||||||
|
|
||||||
|
function animate(time: number) {
|
||||||
|
if (!lastTime) lastTime = time;
|
||||||
|
const dt = (time - lastTime) / 1000;
|
||||||
|
lastTime = time;
|
||||||
|
phase += dt * waveSpeed;
|
||||||
|
rafId = requestAnimationFrame(animate);
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
mounted = true;
|
||||||
|
calcGrid();
|
||||||
|
|
||||||
|
const ro = new ResizeObserver(() => calcGrid());
|
||||||
|
ro.observe(containerEl);
|
||||||
|
|
||||||
|
rafId = requestAnimationFrame(animate);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
ro.disconnect();
|
||||||
|
cancelAnimationFrame(rafId);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="checkbox-grid-container" bind:this={containerEl}>
|
||||||
|
{#if mounted}
|
||||||
|
<div
|
||||||
|
class="checkbox-grid"
|
||||||
|
style="
|
||||||
|
--grid-cols: {columns};
|
||||||
|
--cell-size: {cellSize}px;
|
||||||
|
--cell-gap: {gap}px;
|
||||||
|
--container-rotation: {rotation}deg;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{#each Array(gridRows()) as _, i}
|
||||||
|
{#each Array(columns) as _, j}
|
||||||
|
{@const di = i + gridStart()}
|
||||||
|
{@const cellRotation = getCellRotation(di, j)}
|
||||||
|
{@const rowOffset = getRowOffset(di)}
|
||||||
|
{@const opacity = getCellOpacity(di, j, phase)}
|
||||||
|
{@const cellTickColor = getTickColor(di, j)}
|
||||||
|
{@const cellBoxColor = getBoxColor(di, j)}
|
||||||
|
{@const boxRadius = getBoxRadius(di, j)}
|
||||||
|
<div
|
||||||
|
class="checkbox-cell"
|
||||||
|
style="
|
||||||
|
--cell-rotation: {cellRotation}deg;
|
||||||
|
--row-offset: {rowOffset}px;
|
||||||
|
opacity: {opacity};
|
||||||
|
transition: opacity 0.8s ease;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width={cellSize}
|
||||||
|
height={cellSize}
|
||||||
|
class="checkbox-svg"
|
||||||
|
>
|
||||||
|
<rect
|
||||||
|
x="1" y="1"
|
||||||
|
width="22" height="22"
|
||||||
|
rx={boxRadius} ry={boxRadius}
|
||||||
|
fill="none"
|
||||||
|
stroke={cellBoxColor}
|
||||||
|
stroke-width="1.5"
|
||||||
|
/>
|
||||||
|
{#if opacity > 0.5}
|
||||||
|
<path
|
||||||
|
d="M6 12.5 L10.5 17 L18 7.5"
|
||||||
|
fill="none"
|
||||||
|
stroke={cellTickColor}
|
||||||
|
stroke-width="2.5"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
class="tick-path"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.checkbox-grid-container {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-grid {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%) rotate(var(--container-rotation));
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(var(--grid-cols), var(--cell-size));
|
||||||
|
gap: var(--cell-gap);
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-cell {
|
||||||
|
width: var(--cell-size);
|
||||||
|
height: var(--cell-size);
|
||||||
|
transform: translateX(var(--row-offset, 0px)) rotate(var(--cell-rotation));
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-svg {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tick-path {
|
||||||
|
animation: tick-draw 0.3s ease forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes tick-draw {
|
||||||
|
from {
|
||||||
|
stroke-dasharray: 40;
|
||||||
|
stroke-dashoffset: 40;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
stroke-dasharray: 40;
|
||||||
|
stroke-dashoffset: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
let { tag = 'span' }: { tag?: 'span' | 'h1' | 'h2' | 'h3' | 'a' | 'strong' | 'p' } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:element this={tag} class="fam-done">
|
||||||
|
Fam<span class="text-green-500">Done</span><svg
|
||||||
|
class="fam-done-check"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="1em"
|
||||||
|
height="1em"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="#10b981"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" /><polyline points="22 4 12 14.01 9 11.01" /></svg
|
||||||
|
></svelte:element
|
||||||
|
>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.fam-done {
|
||||||
|
font-weight: inherit;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
.fam-done-check {
|
||||||
|
font-size: 0.6em;
|
||||||
|
vertical-align: super;
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,13 +1,113 @@
|
|||||||
<footer class="app-footer">
|
<script lang="ts">
|
||||||
<span class="footer-text">FamChore</span>
|
import FamDone from './FamDone.svelte';
|
||||||
|
|
||||||
|
// Offset the footer content past the fixed sidebar on fam-scoped pages
|
||||||
|
// (220px desktop / 56px mobile). Standalone pages (landing, pricing, admin)
|
||||||
|
// have no sidebar and leave this false.
|
||||||
|
let { sidebar = false } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<footer class="bg-slate-100 text-[#4c1d95] border-t border-[#4338ca]">
|
||||||
|
<div class="footer-inner px-8 py-16" class:sidebar>
|
||||||
|
<div class="footer-row flex items-center justify-between gap-2">
|
||||||
|
<span class="footer-star">✦</span>
|
||||||
|
<div class="footer-center">
|
||||||
|
<span class="footer-copy"
|
||||||
|
>© {new Date().getFullYear()}
|
||||||
|
<span class="footer-text font-bold text-xl"><FamDone /></span> All rights reserved.</span
|
||||||
|
>
|
||||||
|
<span class="footer-links">
|
||||||
|
<a href="/privacy">Privacy Policy</a>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<aside
|
||||||
|
class="footer-aside text-text-200 text-wash-300 flex flex-col justify-end text-right text-sm"
|
||||||
|
>
|
||||||
|
<span>Built by humans</span>
|
||||||
|
<a href="https://threejjjs.xyz">
|
||||||
|
<h5 class="m-0 text-right font-tertiary text-xl md:text-3xl">
|
||||||
|
three<span class="opacity-55">jjj</span>s
|
||||||
|
<br />
|
||||||
|
</h5>
|
||||||
|
</a>
|
||||||
|
<span class="text-xs">Interactive Tech</span>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.app-footer {
|
/* Offset the footer content past the fixed sidebar so it aligns with the
|
||||||
padding: 1rem 1.5rem;
|
app-main content column (220px desktop / 56px mobile). */
|
||||||
border-top: 1px solid #e5e7eb;
|
.footer-inner.sidebar {
|
||||||
|
margin-left: 220px;
|
||||||
|
}
|
||||||
|
@media (max-width: 1023px) {
|
||||||
|
.footer-inner.sidebar {
|
||||||
|
margin-left: 56px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.footer-star {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
line-height: 1;
|
||||||
|
color: #4c1d95;
|
||||||
|
flex-shrink: 0;
|
||||||
|
position: absolute;
|
||||||
|
left: 2rem;
|
||||||
|
}
|
||||||
|
.footer-copy {
|
||||||
|
flex: 1;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
padding: 0 1rem;
|
||||||
|
}
|
||||||
|
.footer-center {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.footer-links {
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: #9ca3af;
|
}
|
||||||
|
.footer-links a {
|
||||||
|
color: #4c1d95;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.footer-links a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.footer-row {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.footer-aside {
|
||||||
|
position: absolute;
|
||||||
|
right: 2rem;
|
||||||
|
}
|
||||||
|
@media (max-width: 639px) {
|
||||||
|
.footer-star {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
left: 1rem;
|
||||||
|
}
|
||||||
|
.footer-copy {
|
||||||
|
padding: 0 0.5rem;
|
||||||
|
}
|
||||||
|
.footer-aside {
|
||||||
|
right: 1rem;
|
||||||
|
}
|
||||||
|
.footer-row {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.footer-star {
|
||||||
|
position: static;
|
||||||
|
}
|
||||||
|
.footer-aside {
|
||||||
|
position: static;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import PinPad from './PinPad.svelte';
|
||||||
|
import { resetClock } from '$lib/client/lock';
|
||||||
|
|
||||||
|
// Post-join wizard for children on a shared device:
|
||||||
|
// 1. "Is this computer shared?" (skipped when the device already has
|
||||||
|
// other child sessions — evidence of sharing, PIN is required).
|
||||||
|
// 2. Pick a 3-digit PIN (optional on the question path).
|
||||||
|
// 3. Navigate to the child's dashboard.
|
||||||
|
let {
|
||||||
|
targetUrl = '',
|
||||||
|
force = false
|
||||||
|
}: {
|
||||||
|
targetUrl: string;
|
||||||
|
force?: boolean;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
// force: the device already had other kids' sessions → PIN required now.
|
||||||
|
let step = $state<'q' | 'p1' | 'p2'>(force ? 'p1' : 'q');
|
||||||
|
let pin1 = $state('');
|
||||||
|
let padKey = $state(0);
|
||||||
|
let status = $state('');
|
||||||
|
|
||||||
|
function go() {
|
||||||
|
resetClock();
|
||||||
|
window.location.assign(targetUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function savePin(pin: string) {
|
||||||
|
status = '';
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/pins', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ action: 'ensure', pin })
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) throw new Error(data.error || 'Could not save PIN');
|
||||||
|
go();
|
||||||
|
} catch (e) {
|
||||||
|
status = e instanceof Error ? e.message : 'Could not save PIN';
|
||||||
|
step = 'p1';
|
||||||
|
padKey += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if step === 'q'}
|
||||||
|
<div class="wizard">
|
||||||
|
<h2 class="wizard-title">Is this computer shared?</h2>
|
||||||
|
<p class="wizard-sub">
|
||||||
|
Will your brothers or sisters use this computer too? A 3-digit PIN lets you switch to your
|
||||||
|
chores in a tap.
|
||||||
|
</p>
|
||||||
|
<div class="wizard-actions">
|
||||||
|
<button type="button" class="wizard-btn primary" onclick={() => (step = 'p1')}>Yes</button>
|
||||||
|
<button type="button" class="wizard-btn" onclick={go}>No — just me</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if step === 'p1'}
|
||||||
|
<div class="wizard">
|
||||||
|
<h2 class="wizard-title">Pick a PIN</h2>
|
||||||
|
<p class="wizard-sub">3 numbers that are easy for you to remember — you'll use it to switch.</p>
|
||||||
|
{#key padKey}
|
||||||
|
<PinPad
|
||||||
|
label="New PIN"
|
||||||
|
oncomplete={(pin) => {
|
||||||
|
pin1 = pin;
|
||||||
|
step = 'p2';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/key}
|
||||||
|
{#if status}<p class="wizard-error">{status}</p>{/if}
|
||||||
|
<button type="button" class="wizard-link" onclick={go}>Skip for now</button>
|
||||||
|
</div>
|
||||||
|
{:else if step === 'p2'}
|
||||||
|
<div class="wizard">
|
||||||
|
<h2 class="wizard-title">Confirm your PIN</h2>
|
||||||
|
<p class="wizard-sub">
|
||||||
|
Enter it once more — {pin1 ? `it starts with ${pin1[0]}·` : ''}your parent can always read it
|
||||||
|
out if you forget.
|
||||||
|
</p>
|
||||||
|
{#key padKey}
|
||||||
|
<PinPad label="Confirm PIN" oncomplete={savePin} />
|
||||||
|
{/key}
|
||||||
|
{#if status}<p class="wizard-error">{status}</p>{/if}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="wizard-link"
|
||||||
|
onclick={() => {
|
||||||
|
step = 'p1';
|
||||||
|
padKey += 1;
|
||||||
|
}}>Back</button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.wizard {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.wizard-title {
|
||||||
|
margin: 0 0 0.25rem;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
.wizard-sub {
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #64748b;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.wizard-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
.wizard-btn {
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 0.7rem 1rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #334155;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.wizard-btn.primary {
|
||||||
|
background: #6366f1;
|
||||||
|
border-color: #6366f1;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.wizard-btn:hover {
|
||||||
|
filter: brightness(0.97);
|
||||||
|
}
|
||||||
|
.wizard-link {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: #6366f1;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.4rem 0.75rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.wizard-link:hover {
|
||||||
|
background: #eef2ff;
|
||||||
|
}
|
||||||
|
.wizard-error {
|
||||||
|
margin: 0.75rem 0 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,12 +1,18 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { notices, type NoticeType } from '$lib/stores/notices.svelte';
|
import { notices, type NoticeType } from '$lib/stores/notices.svelte';
|
||||||
|
import {
|
||||||
|
checkCircleIcon,
|
||||||
|
alertTriangleIcon,
|
||||||
|
xCircleIcon,
|
||||||
|
infoIcon
|
||||||
|
} from '$lib/components/icons';
|
||||||
|
|
||||||
function icon(type: NoticeType) {
|
function icon(type: NoticeType) {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'success': return '✅';
|
case 'success': return checkCircleIcon;
|
||||||
case 'warning': return '⚠️';
|
case 'warning': return alertTriangleIcon;
|
||||||
case 'error': return '❌';
|
case 'error': return xCircleIcon;
|
||||||
default: return 'ℹ️';
|
default: return infoIcon;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -16,7 +22,7 @@
|
|||||||
{#each notices.list as notice (notice.id)}
|
{#each notices.list as notice (notice.id)}
|
||||||
<div class="notice notice-{notice.type}">
|
<div class="notice notice-{notice.type}">
|
||||||
<div class="notice-content">
|
<div class="notice-content">
|
||||||
<div class="notice-icon">{icon(notice.type)}</div>
|
<div class="notice-icon">{@html icon(notice.type)}</div>
|
||||||
<div class="notice-text">
|
<div class="notice-text">
|
||||||
<h4>{notice.title}</h4>
|
<h4>{notice.title}</h4>
|
||||||
{#if notice.message}
|
{#if notice.message}
|
||||||
@@ -73,6 +79,14 @@
|
|||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
.notice-icon :global(svg) {
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
}
|
||||||
|
.notice-success .notice-icon { color: #059669; }
|
||||||
|
.notice-warning .notice-icon { color: #d97706; }
|
||||||
|
.notice-error .notice-icon { color: #dc2626; }
|
||||||
|
.notice-info .notice-icon { color: #6366f1; }
|
||||||
.notice-text h4 {
|
.notice-text h4 {
|
||||||
margin: 0 0 0.25rem;
|
margin: 0 0 0.25rem;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import '../theme-patterns.css';
|
||||||
|
import {
|
||||||
|
themeShades,
|
||||||
|
PATTERN_COUNT,
|
||||||
|
patternId,
|
||||||
|
THEME_SIZE_MIN,
|
||||||
|
THEME_SIZE_MAX,
|
||||||
|
THEME_SIZE_DEFAULT,
|
||||||
|
THEME_OPACITY_MIN,
|
||||||
|
THEME_OPACITY_MAX,
|
||||||
|
THEME_OPACITY_STEP,
|
||||||
|
THEME_OPACITY_DEFAULT
|
||||||
|
} from '$lib/theme';
|
||||||
|
|
||||||
|
let {
|
||||||
|
value = '',
|
||||||
|
color = '#6366f1',
|
||||||
|
size = THEME_SIZE_DEFAULT,
|
||||||
|
opacity = THEME_OPACITY_DEFAULT,
|
||||||
|
onchange,
|
||||||
|
onsize,
|
||||||
|
onopacity
|
||||||
|
}: {
|
||||||
|
value?: string;
|
||||||
|
color?: string;
|
||||||
|
size?: number;
|
||||||
|
opacity?: number;
|
||||||
|
onchange: (v: string) => void;
|
||||||
|
onsize?: (v: number) => void;
|
||||||
|
onopacity?: (v: number) => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let shades = $derived(themeShades(color));
|
||||||
|
// Inline vars beat the pattern class defaults; --s shrinks the pattern so
|
||||||
|
// thumbnails show a sample of the tiling.
|
||||||
|
let vars = $derived(
|
||||||
|
`--c1:${shades.c1};--c2:${shades.c2};--c3:${shades.c3};--c4:${shades.c4};--s:26px`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Local slider state — bound directly to the inputs (no controlled-value
|
||||||
|
// re-render mid-drag), then emitted on input for the live preview.
|
||||||
|
let sizeVal = $state(size);
|
||||||
|
let opacityVal = $state(opacity);
|
||||||
|
// Keep local state in sync if the props ever change (e.g. after a save).
|
||||||
|
$effect(() => {
|
||||||
|
sizeVal = size;
|
||||||
|
});
|
||||||
|
$effect(() => {
|
||||||
|
opacityVal = opacity;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="pattern-grid">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pattern-tile none"
|
||||||
|
class:selected={value === ''}
|
||||||
|
title="No background pattern"
|
||||||
|
onclick={() => onchange('')}
|
||||||
|
>
|
||||||
|
<span class="none-dot" style="background:{color}"></span>
|
||||||
|
</button>
|
||||||
|
{#each Array(PATTERN_COUNT) as _, i}
|
||||||
|
{@const id = patternId(i + 1)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pattern-tile pattern-{id}"
|
||||||
|
class:selected={value === id}
|
||||||
|
title={`Pattern ${i + 1}`}
|
||||||
|
style={vars}
|
||||||
|
onclick={() => onchange(id)}
|
||||||
|
></button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if value !== ''}
|
||||||
|
{#if onsize}
|
||||||
|
<div class="slider-row">
|
||||||
|
<div class="slider-head">
|
||||||
|
<label for="theme-size">Pattern size</label>
|
||||||
|
<span class="slider-val">{sizeVal}</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
id="theme-size"
|
||||||
|
type="range"
|
||||||
|
min={THEME_SIZE_MIN}
|
||||||
|
max={THEME_SIZE_MAX}
|
||||||
|
step="0.5"
|
||||||
|
bind:value={sizeVal}
|
||||||
|
oninput={() => onsize?.(sizeVal)}
|
||||||
|
/>
|
||||||
|
<span class="slider-hint">scales with screen width</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if onopacity}
|
||||||
|
<div class="slider-row">
|
||||||
|
<div class="slider-head">
|
||||||
|
<label for="theme-opacity">Opacity</label>
|
||||||
|
<span class="slider-val">{Math.round(opacityVal * 100)}%</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
id="theme-opacity"
|
||||||
|
type="range"
|
||||||
|
min={THEME_OPACITY_MIN}
|
||||||
|
max={THEME_OPACITY_MAX}
|
||||||
|
step={THEME_OPACITY_STEP}
|
||||||
|
bind:value={opacityVal}
|
||||||
|
oninput={() => onopacity?.(opacityVal)}
|
||||||
|
/>
|
||||||
|
<span class="slider-hint">max = current look</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.pattern-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(56px, 1fr));
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
/* Static grid — currently 19 tiles fit without scrolling. Padding keeps
|
||||||
|
the selected tile's outline from clipping at the top-left corner. */
|
||||||
|
padding: 0.4rem;
|
||||||
|
}
|
||||||
|
.pattern-tile {
|
||||||
|
aspect-ratio: 1;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
/* No background here — the imported .pattern-pN classes supply it.
|
||||||
|
A local background (0,2,0) would beat .pattern-pN (0,1,0) and hide
|
||||||
|
every preview. */
|
||||||
|
transition: outline 0.12s ease;
|
||||||
|
}
|
||||||
|
.pattern-tile:hover {
|
||||||
|
border-color: #9ca3af;
|
||||||
|
}
|
||||||
|
.pattern-tile.selected {
|
||||||
|
outline: 3px solid #111827;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
.pattern-tile.none {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.none-dot {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
.slider-row {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
.slider-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 0.3rem;
|
||||||
|
}
|
||||||
|
.slider-head label {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.slider-row input[type='range'] {
|
||||||
|
width: 100%;
|
||||||
|
display: block;
|
||||||
|
accent-color: #6366f1;
|
||||||
|
}
|
||||||
|
.slider-val {
|
||||||
|
font-weight: 700;
|
||||||
|
color: #4338ca;
|
||||||
|
}
|
||||||
|
.slider-hint {
|
||||||
|
color: #9ca3af;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
display: block;
|
||||||
|
margin-top: 0.15rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
let {
|
||||||
|
label = 'Enter your PIN',
|
||||||
|
busy = false,
|
||||||
|
oncomplete
|
||||||
|
}: {
|
||||||
|
label?: string;
|
||||||
|
busy?: boolean;
|
||||||
|
oncomplete: (pin: string) => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let digits = $state<string[]>([]);
|
||||||
|
|
||||||
|
function press(d: string) {
|
||||||
|
if (busy || digits.length >= 3) return;
|
||||||
|
digits = [...digits, d];
|
||||||
|
if (digits.length === 3) oncomplete(digits.join(''));
|
||||||
|
}
|
||||||
|
|
||||||
|
function back() {
|
||||||
|
digits = digits.slice(0, -1);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="pin-pad">
|
||||||
|
<p class="pin-label">{label}</p>
|
||||||
|
<div class="pin-dots" aria-hidden="true">
|
||||||
|
{#each [0, 1, 2] as i}
|
||||||
|
<span class="dot" class:filled={digits.length > i}></span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<div class="pin-keys">
|
||||||
|
{#each ['1', '2', '3', '4', '5', '6', '7', '8', '9'] as d}
|
||||||
|
<button type="button" class="key" disabled={busy} onclick={() => press(d)}>{d}</button>
|
||||||
|
{/each}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="key ghost"
|
||||||
|
disabled={busy || digits.length === 0}
|
||||||
|
onclick={() => back()}>⌫</button
|
||||||
|
>
|
||||||
|
<button type="button" class="key" disabled={busy} onclick={() => press('0')}>0</button>
|
||||||
|
<span class="key ghost"> </span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.pin-pad {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.pin-label {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #6b7280;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.pin-dots {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.dot {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid #cbd5e1;
|
||||||
|
transition: background 0.1s;
|
||||||
|
}
|
||||||
|
.dot.filled {
|
||||||
|
background: #6366f1;
|
||||||
|
border-color: #6366f1;
|
||||||
|
}
|
||||||
|
.pin-keys {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 64px);
|
||||||
|
gap: 0.5rem;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.key {
|
||||||
|
height: 56px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #fff;
|
||||||
|
color: #1e293b;
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
background 0.1s,
|
||||||
|
transform 0.05s;
|
||||||
|
}
|
||||||
|
.key:hover:not(:disabled) {
|
||||||
|
background: #f1f5f9;
|
||||||
|
}
|
||||||
|
.key:active:not(:disabled) {
|
||||||
|
transform: scale(0.96);
|
||||||
|
}
|
||||||
|
.key:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
.key.ghost {
|
||||||
|
background: transparent;
|
||||||
|
border-color: transparent;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
|
import { checkCircleIcon } from '$lib/components/icons';
|
||||||
|
|
||||||
interface Tier {
|
interface Tier {
|
||||||
id: 'trial' | 'monthly' | 'yearly';
|
id: 'trial' | 'monthly' | 'yearly';
|
||||||
@@ -84,7 +85,7 @@
|
|||||||
|
|
||||||
<ul>
|
<ul>
|
||||||
{#each tier.features as f}
|
{#each tier.features as f}
|
||||||
<li><span class="tick">✓</span>{f}</li>
|
<li><span class="tick">{@html checkCircleIcon}</span>{f}</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
</article>
|
</article>
|
||||||
@@ -146,6 +147,9 @@
|
|||||||
}
|
}
|
||||||
.price { margin: 0.55rem 0 0.15rem; line-height: 1; }
|
.price { margin: 0.55rem 0 0.15rem; line-height: 1; }
|
||||||
.amount { font-size: 2.4rem; font-weight: 800; color: #111827; }
|
.amount { font-size: 2.4rem; font-weight: 800; color: #111827; }
|
||||||
|
@media (max-width: 639px) {
|
||||||
|
.amount { font-size: 1.5em; }
|
||||||
|
}
|
||||||
.period { font-size: 0.85rem; color: #9ca3af; margin-left: 0.3rem; }
|
.period { font-size: 0.85rem; color: #9ca3af; margin-left: 0.3rem; }
|
||||||
.blurb { margin: 0 0 0.9rem; font-size: 0.85rem; color: #6b7280; }
|
.blurb { margin: 0 0 0.9rem; font-size: 0.85rem; color: #6b7280; }
|
||||||
|
|
||||||
@@ -202,6 +206,7 @@
|
|||||||
gap: 0.45rem;
|
gap: 0.45rem;
|
||||||
}
|
}
|
||||||
.tick { color: #059669; font-weight: 700; flex-shrink: 0; }
|
.tick { color: #059669; font-weight: 700; flex-shrink: 0; }
|
||||||
|
.tick :global(svg) { width: 1.05em; height: 1.05em; vertical-align: -0.15em; }
|
||||||
|
|
||||||
.form-error {
|
.form-error {
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Button from './Button.svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
label,
|
||||||
|
successLabel = 'Saved ✓',
|
||||||
|
variant = 'primary',
|
||||||
|
size = 'sm',
|
||||||
|
...rest
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
successLabel?: string;
|
||||||
|
variant?: 'primary' | 'secondary' | 'ghost' | 'danger' | 'success' | 'purple';
|
||||||
|
size?: 'sm' | 'md' | 'lg';
|
||||||
|
[key: string]: unknown;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let saved = $state(false);
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
|
// Parent forms call this (via bind:this) from their use:enhance callback
|
||||||
|
// on result.type === 'success'. Green for 4s, then back to normal.
|
||||||
|
export function flash() {
|
||||||
|
saved = true;
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
timer = setTimeout(() => (saved = false), 4000);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Button type="submit" {size} variant={saved ? 'success' : variant} {...rest}>
|
||||||
|
{saved ? successLabel : label}
|
||||||
|
</Button>
|
||||||
@@ -0,0 +1,305 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import PinPad from './PinPad.svelte';
|
||||||
|
import { resetClock } from '$lib/client/lock';
|
||||||
|
|
||||||
|
export type QuickProfile = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
username: string; // `{famSlug}:{handle}`
|
||||||
|
};
|
||||||
|
|
||||||
|
let {
|
||||||
|
profiles = [],
|
||||||
|
famSlug = '',
|
||||||
|
famName = '',
|
||||||
|
activeId = '',
|
||||||
|
standalone = false,
|
||||||
|
oncancel = () => {}
|
||||||
|
}: {
|
||||||
|
profiles?: QuickProfile[];
|
||||||
|
famSlug?: string;
|
||||||
|
famName?: string;
|
||||||
|
activeId?: string;
|
||||||
|
standalone?: boolean;
|
||||||
|
oncancel?: () => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let selectedId = $state('');
|
||||||
|
let padKey = $state(0);
|
||||||
|
let status = $state('');
|
||||||
|
let busy = $state(false);
|
||||||
|
|
||||||
|
const selected = $derived(profiles.find((p) => p.id === selectedId) || null);
|
||||||
|
|
||||||
|
function initial(name: string) {
|
||||||
|
return (name || '?').trim().charAt(0).toUpperCase() || '?';
|
||||||
|
}
|
||||||
|
|
||||||
|
function targetUrl(p: QuickProfile) {
|
||||||
|
const handle = (p.username || '').split(':').pop() || p.id;
|
||||||
|
return `/${famSlug}/${encodeURIComponent(handle)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function switchTo(id: string, pin: string) {
|
||||||
|
busy = true;
|
||||||
|
status = '';
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/switch-user', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ userId: id, pin })
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) throw new Error(data.error || 'Could not switch');
|
||||||
|
// Fresh activation — the mount-time lock check must not trip.
|
||||||
|
resetClock();
|
||||||
|
const target = profiles.find((p) => p.id === id);
|
||||||
|
window.location.assign(targetUrl(target || ({ id, username: '' } as QuickProfile)));
|
||||||
|
} catch (e) {
|
||||||
|
status = e instanceof Error ? e.message : 'Could not switch';
|
||||||
|
busy = false;
|
||||||
|
padKey += 1;
|
||||||
|
selectedId = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeProfile(id: string) {
|
||||||
|
const p = profiles.find((x) => x.id === id);
|
||||||
|
if (!p || !confirm(`Remove ${p.name} from this computer?`)) return;
|
||||||
|
await fetch('/api/device/remove', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ userId: id })
|
||||||
|
}).catch(() => {});
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="picker-backdrop" class:standalone>
|
||||||
|
<div class="picker-card">
|
||||||
|
{#if selected}
|
||||||
|
<h2 class="picker-title">Hi {selected.name}!</h2>
|
||||||
|
<p class="picker-sub">Enter your 3-digit PIN to switch.</p>
|
||||||
|
{#key padKey}
|
||||||
|
<PinPad {busy} oncomplete={(pin) => switchTo(selected.id, pin)} />
|
||||||
|
{/key}
|
||||||
|
{#if status}
|
||||||
|
<p class="picker-error">{status}</p>
|
||||||
|
{/if}
|
||||||
|
<div class="picker-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="link-btn"
|
||||||
|
onclick={() => {
|
||||||
|
selectedId = '';
|
||||||
|
status = '';
|
||||||
|
padKey += 1;
|
||||||
|
}}>Back</button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<h2 class="picker-title">
|
||||||
|
{standalone ? famName || 'Welcome' : "Who's using the computer?"}
|
||||||
|
</h2>
|
||||||
|
<p class="picker-sub">
|
||||||
|
{standalone
|
||||||
|
? 'Pick your profile to get to your chores.'
|
||||||
|
: 'Pick a profile and enter its PIN.'}
|
||||||
|
</p>
|
||||||
|
<div class="picker-grid">
|
||||||
|
{#each profiles as p (p.id)}
|
||||||
|
<div
|
||||||
|
class="profile"
|
||||||
|
class:active={p.id === activeId}
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
style={`--dot:${p.color}`}
|
||||||
|
onclick={() => (selectedId = p.id)}
|
||||||
|
onkeydown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') selectedId = p.id;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span class="avatar" style="background:{p.color}">{initial(p.name)}</span>
|
||||||
|
<span class="pname">{p.name}</span>
|
||||||
|
{#if p.id === activeId}<span class="pill">current</span>{/if}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="remove-btn"
|
||||||
|
aria-label={`Remove ${p.name} from this computer`}
|
||||||
|
onclick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
removeProfile(p.id);
|
||||||
|
}}>✕</button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{#if standalone}
|
||||||
|
<div class="picker-footer">
|
||||||
|
<a href={`/${famSlug}/join`}>Add a child with a code</a>
|
||||||
|
<span class="dot-sep">·</span>
|
||||||
|
<a href="/login">Parent login</a>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="picker-actions">
|
||||||
|
<button type="button" class="link-btn" onclick={oncancel}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.picker-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 120;
|
||||||
|
background: rgba(15, 23, 42, 0.55);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
.picker-card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 2rem;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 460px;
|
||||||
|
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.3);
|
||||||
|
text-align: center;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.picker-title {
|
||||||
|
margin: 0 0 0.25rem;
|
||||||
|
font-size: 1.3rem;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
.picker-sub {
|
||||||
|
margin: 0 0 1.25rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
.picker-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
.profile {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
padding: 1rem 0.5rem;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: #f8fafc;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
border-color 0.12s,
|
||||||
|
transform 0.05s;
|
||||||
|
}
|
||||||
|
.profile:hover {
|
||||||
|
border-color: #c7d2fe;
|
||||||
|
}
|
||||||
|
.profile:active {
|
||||||
|
transform: scale(0.97);
|
||||||
|
}
|
||||||
|
.profile.active {
|
||||||
|
border-color: #6366f1;
|
||||||
|
background: #eef2ff;
|
||||||
|
}
|
||||||
|
.avatar {
|
||||||
|
width: 52px;
|
||||||
|
height: 52px;
|
||||||
|
border-radius: 50%;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.pname {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1e293b;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.pill {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
background: #6366f1;
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.1rem 0.45rem;
|
||||||
|
}
|
||||||
|
.remove-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 6px;
|
||||||
|
right: 6px;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #e2e8f0;
|
||||||
|
color: #475569;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.remove-btn:hover {
|
||||||
|
background: #fecaca;
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
|
.picker-error {
|
||||||
|
margin: 0.75rem 0 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
.picker-actions {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
.link-btn {
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: #6366f1;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.4rem 0.75rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.link-btn:hover {
|
||||||
|
background: #eef2ff;
|
||||||
|
}
|
||||||
|
.picker-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.picker-footer a {
|
||||||
|
color: #6366f1;
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.picker-footer a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.dot-sep {
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -3,25 +3,26 @@
|
|||||||
import {
|
import {
|
||||||
dashboardIcon,
|
dashboardIcon,
|
||||||
choresIcon,
|
choresIcon,
|
||||||
rewardsIcon,
|
archiveIcon,
|
||||||
bonusesIcon,
|
gemIcon,
|
||||||
homeIcon,
|
homeIcon,
|
||||||
settingsIcon,
|
settingsIcon,
|
||||||
logoutIcon,
|
logoutIcon,
|
||||||
prefsIcon
|
prefsIcon
|
||||||
} from './icons';
|
} from './icons';
|
||||||
|
import FamDone from './FamDone.svelte';
|
||||||
|
import { famStore } from '$lib/stores/fam.svelte';
|
||||||
|
|
||||||
let { famName = '', session = null, isParent = false, role = 'child' } = $props();
|
// Outstanding claim requests (children asked → parent still to issue).
|
||||||
|
let requestedClaims = $derived(
|
||||||
|
famStore.initialized ? famStore.rewards.filter((r: any) => r.status === 'requested').length : 0
|
||||||
|
);
|
||||||
|
|
||||||
let collapsed = $state(false);
|
let { famName = '', session = null, isParent = false, role = 'child', isDemo = false } = $props();
|
||||||
|
|
||||||
let famSlug = $derived(page.data.famSlug ?? page.params.fam);
|
let famSlug = $derived(page.data.famSlug ?? page.params.fam);
|
||||||
let memberName = $derived(session?.memberName || page.params.username || '');
|
let memberName = $derived(session?.memberName || page.params.username || '');
|
||||||
|
|
||||||
function toggle() {
|
|
||||||
collapsed = !collapsed;
|
|
||||||
}
|
|
||||||
|
|
||||||
let showChildItems = $derived(!!memberName);
|
let showChildItems = $derived(!!memberName);
|
||||||
|
|
||||||
let navItems = $derived(
|
let navItems = $derived(
|
||||||
@@ -30,8 +31,8 @@
|
|||||||
{ href: `/${famSlug}`, label: famName, icon: homeIcon },
|
{ href: `/${famSlug}`, label: famName, icon: homeIcon },
|
||||||
{ href: `/${famSlug}/${memberName}`, label: 'Dashboard', icon: dashboardIcon },
|
{ href: `/${famSlug}/${memberName}`, label: 'Dashboard', icon: dashboardIcon },
|
||||||
{ href: `/${famSlug}/${memberName}/chores`, label: 'Chores', icon: choresIcon },
|
{ href: `/${famSlug}/${memberName}/chores`, label: 'Chores', icon: choresIcon },
|
||||||
{ href: `/${famSlug}/${memberName}/ledger`, label: 'Ledger', icon: rewardsIcon },
|
{ href: `/${famSlug}/${memberName}/rewards`, label: 'Rewards', icon: gemIcon },
|
||||||
{ href: `/${famSlug}/${memberName}/bonuses`, label: 'Bonuses', icon: bonusesIcon }
|
{ href: `/${famSlug}/${memberName}/ledger`, label: 'Ledger', icon: archiveIcon }
|
||||||
]
|
]
|
||||||
: showChildItems
|
: showChildItems
|
||||||
? [
|
? [
|
||||||
@@ -42,10 +43,10 @@
|
|||||||
);
|
);
|
||||||
|
|
||||||
let footerItems = $derived([
|
let footerItems = $derived([
|
||||||
...(memberName
|
...(memberName && !isDemo
|
||||||
? [{ href: `/${famSlug}/${memberName}/preferences`, label: 'Preferences', icon: prefsIcon }]
|
? [{ href: `/${famSlug}/${memberName}/preferences`, label: 'Preferences', icon: prefsIcon }]
|
||||||
: []),
|
: []),
|
||||||
...(isParent && memberName
|
...(isParent && memberName && !isDemo
|
||||||
? [{ href: `/${famSlug}/${memberName}/settings`, label: 'Settings', icon: settingsIcon }]
|
? [{ href: `/${famSlug}/${memberName}/settings`, label: 'Settings', icon: settingsIcon }]
|
||||||
: []),
|
: []),
|
||||||
{
|
{
|
||||||
@@ -56,31 +57,39 @@
|
|||||||
]);
|
]);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<aside class="sidebar" class:collapsed>
|
<aside class="sidebar">
|
||||||
<button class="toggle-btn" onclick={toggle}>
|
<a class="sidebar-header" href="/" aria-label="Go to homepage">
|
||||||
{collapsed ? '☰' : '✕'}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div class="sidebar-header">
|
|
||||||
<span class="app-icon">✦</span>
|
<span class="app-icon">✦</span>
|
||||||
{#if !collapsed}<span class="app-name">FamChore</span>{/if}
|
<span class="app-name"><FamDone /></span>
|
||||||
</div>
|
</a>
|
||||||
|
|
||||||
<nav class="sidebar-nav">
|
<nav class="sidebar-nav">
|
||||||
{#each navItems as item}
|
{#each navItems as item}
|
||||||
<a href={item.href} class="nav-item" class:active={page.url.pathname === item.href}>
|
<a href={item.href} class="nav-item" class:active={page.url.pathname === item.href}>
|
||||||
{@html item.icon}
|
{@html item.icon}
|
||||||
{#if !collapsed}<span class="nav-label">{item.label}</span>{/if}
|
<span class="nav-label">{item.label}</span>
|
||||||
|
{#if isParent && item.label === 'Dashboard' && requestedClaims > 0}
|
||||||
|
<span class="nav-badge">{requestedClaims}</span>
|
||||||
|
{/if}
|
||||||
</a>
|
</a>
|
||||||
{/each}
|
{/each}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="sidebar-footer">
|
<div class="sidebar-footer">
|
||||||
{#each footerItems as item}
|
{#each footerItems as item}
|
||||||
|
{#if item.href === '/logout'}
|
||||||
|
<form method="POST" action="/logout" class="nav-item-form">
|
||||||
|
<button type="submit" class="nav-item nav-item-button" aria-label="Log out">
|
||||||
|
{@html item.icon}
|
||||||
|
<span class="nav-label">{item.label}</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{:else}
|
||||||
<a href={item.href} class="nav-item">
|
<a href={item.href} class="nav-item">
|
||||||
{@html item.icon}
|
{@html item.icon}
|
||||||
{#if !collapsed}<span class="nav-label">{item.label}</span>{/if}
|
<span class="nav-label">{item.label}</span>
|
||||||
</a>
|
</a>
|
||||||
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
@@ -90,7 +99,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;
|
||||||
@@ -100,28 +109,17 @@
|
|||||||
z-index: 100;
|
z-index: 100;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.sidebar.collapsed {
|
|
||||||
width: 56px;
|
|
||||||
}
|
|
||||||
.toggle-btn {
|
|
||||||
position: absolute;
|
|
||||||
top: 0.5rem;
|
|
||||||
right: 0.5rem;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: #a5b4fc;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0.25rem;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
.sidebar-header {
|
.sidebar-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
padding: 1rem 0.75rem;
|
padding: 0 0.75rem;
|
||||||
border-bottom: 1px solid #3730a3;
|
border-bottom: 1px solid #3730a3;
|
||||||
min-height: 52px;
|
height: 52px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
}
|
}
|
||||||
.app-icon {
|
.app-icon {
|
||||||
font-size: 1.3rem;
|
font-size: 1.3rem;
|
||||||
@@ -158,11 +156,36 @@
|
|||||||
margin: 0 0.3rem;
|
margin: 0 0.3rem;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
transition: background 0.15s;
|
transition: background 0.15s;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.nav-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: 4px;
|
||||||
|
right: 6px;
|
||||||
|
background: #f59e0b;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 999px;
|
||||||
}
|
}
|
||||||
.nav-item:hover {
|
.nav-item:hover {
|
||||||
background: #3730a3;
|
background: #3730a3;
|
||||||
color: #e0e7ff;
|
color: #e0e7ff;
|
||||||
}
|
}
|
||||||
|
.nav-item-form {
|
||||||
|
margin: 0 0.3rem;
|
||||||
|
}
|
||||||
|
.nav-item-button {
|
||||||
|
font-family: inherit;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
box-sizing: border-box;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
.nav-item.active {
|
.nav-item.active {
|
||||||
background: #4338ca;
|
background: #4338ca;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
@@ -171,4 +194,25 @@
|
|||||||
.nav-label {
|
.nav-label {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1023px) {
|
||||||
|
.sidebar {
|
||||||
|
width: 56px;
|
||||||
|
}
|
||||||
|
.sidebar-header {
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.app-name {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.nav-item {
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0.6rem 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.nav-label {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -3,11 +3,13 @@
|
|||||||
announcement = '',
|
announcement = '',
|
||||||
role = '',
|
role = '',
|
||||||
seasons = [],
|
seasons = [],
|
||||||
children,
|
connected = true,
|
||||||
|
children
|
||||||
}: {
|
}: {
|
||||||
announcement?: string
|
announcement?: string
|
||||||
role?: string
|
role?: string
|
||||||
seasons?: { id: string; name: string; color: string; active: boolean }[]
|
seasons?: { id: string; name: string; color: string; active: boolean }[]
|
||||||
|
connected?: boolean
|
||||||
children?: any
|
children?: any
|
||||||
} = $props();
|
} = $props();
|
||||||
</script>
|
</script>
|
||||||
@@ -17,6 +19,14 @@
|
|||||||
{#if role}
|
{#if role}
|
||||||
<span class="role-pill {role}">{role}</span>
|
<span class="role-pill {role}">{role}</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
<span
|
||||||
|
class="conn-dot"
|
||||||
|
class:up={connected}
|
||||||
|
class:down={!connected}
|
||||||
|
title={connected ? 'Live — realtime connected' : 'Reconnecting — live updates paused'}
|
||||||
|
role="status"
|
||||||
|
aria-label={connected ? 'Realtime connected' : 'Realtime reconnecting'}
|
||||||
|
></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="topnav-announcement">
|
<div class="topnav-announcement">
|
||||||
{#if announcement}<span class="announcement-text">{announcement}</span>{/if}
|
{#if announcement}<span class="announcement-text">{announcement}</span>{/if}
|
||||||
@@ -39,7 +49,7 @@
|
|||||||
.topnav {
|
.topnav {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0; left: 220px; right: 0;
|
top: 0; left: 220px; right: 0;
|
||||||
height: 48px;
|
height: 52px;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border-bottom: 1px solid #e5e7eb;
|
border-bottom: 1px solid #e5e7eb;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -49,6 +59,11 @@
|
|||||||
transition: left 0.2s;
|
transition: left 0.2s;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
}
|
}
|
||||||
|
@media (max-width: 1023px) {
|
||||||
|
.topnav {
|
||||||
|
left: 56px;
|
||||||
|
}
|
||||||
|
}
|
||||||
.topnav-left { display: flex; align-items: center; gap: 0.5rem; }
|
.topnav-left { display: flex; align-items: center; gap: 0.5rem; }
|
||||||
.topnav-announcement { flex: 1; text-align: center; }
|
.topnav-announcement { flex: 1; text-align: center; }
|
||||||
.announcement-text { font-size: 0.85rem; color: #6b7280; }
|
.announcement-text { font-size: 0.85rem; color: #6b7280; }
|
||||||
@@ -63,6 +78,18 @@
|
|||||||
}
|
}
|
||||||
.role-pill.parent { background: #fef3c7; color: #b45309; }
|
.role-pill.parent { background: #fef3c7; color: #b45309; }
|
||||||
.role-pill.child { background: #dbeafe; color: #1d4ed8; }
|
.role-pill.child { background: #dbeafe; color: #1d4ed8; }
|
||||||
|
.conn-dot {
|
||||||
|
width: 9px;
|
||||||
|
height: 9px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.conn-dot.up { background: #22c55e; box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.18); }
|
||||||
|
.conn-dot.down { background: #f59e0b; box-shadow: 0 0 0 3px rgba(245, 158, 11, 0.2); animation: conn-pulse 1.2s infinite; }
|
||||||
|
@keyframes conn-pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.35; }
|
||||||
|
}
|
||||||
.season-pill {
|
.season-pill {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
padding: 0.2rem 0.6rem;
|
padding: 0.2rem 0.6rem;
|
||||||
|
|||||||
@@ -1,20 +1,55 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
let { title, subtitle, hero = false, tabs, weeknav, sort }: {
|
import type { Snippet } from 'svelte';
|
||||||
|
let {
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
hero = false,
|
||||||
|
tabs,
|
||||||
|
weeknav,
|
||||||
|
sort,
|
||||||
|
children
|
||||||
|
}: {
|
||||||
title: string;
|
title: string;
|
||||||
subtitle?: string;
|
subtitle?: string;
|
||||||
hero?: boolean;
|
hero?: boolean;
|
||||||
tabs?: { items: { label: string; value: string }[]; active: string; onchange: (v: string) => void };
|
tabs?: {
|
||||||
weeknav?: { current: string; onPrev: () => void; onNext: () => void };
|
items: { label: string; value: string }[];
|
||||||
sort?: { options: { label: string; value: string }[]; active: string; onchange: (v: string) => void };
|
active: string;
|
||||||
|
onchange: (v: string) => void;
|
||||||
|
};
|
||||||
|
weeknav?: {
|
||||||
|
current: string;
|
||||||
|
onPrev: () => void;
|
||||||
|
onNext: () => void;
|
||||||
|
onCurrent?: () => void;
|
||||||
|
isCurrent?: boolean;
|
||||||
|
};
|
||||||
|
sort?: {
|
||||||
|
options: { label: string; value: string }[];
|
||||||
|
active: string;
|
||||||
|
onchange: (v: string) => void;
|
||||||
|
};
|
||||||
|
children?: Snippet;
|
||||||
} = $props();
|
} = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="view-header" class:hero>
|
<div class="view-header flex justify-between" class:hero>
|
||||||
<div class="view-title-group">
|
<div class="view-title-group">
|
||||||
<h1 class="view-title">{title}</h1>
|
<h1 class="view-title text-3xl">{title}</h1>
|
||||||
{#if subtitle}<p class="view-subtitle">{subtitle}</p>{/if}
|
{#if subtitle}<p class="view-subtitle">{subtitle}</p>{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{@render children?.()}
|
||||||
|
|
||||||
|
{#if weeknav?.onCurrent}
|
||||||
|
<button
|
||||||
|
class="now-btn header-now"
|
||||||
|
class:now-hidden={weeknav.isCurrent !== false}
|
||||||
|
onclick={weeknav.onCurrent}
|
||||||
|
tabindex={weeknav.isCurrent === false ? 0 : -1}
|
||||||
|
><span>← Back to this week</span></button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="view-tools">
|
<div class="view-tools">
|
||||||
{#if tabs}
|
{#if tabs}
|
||||||
<div class="tab-bar">
|
<div class="tab-bar">
|
||||||
@@ -22,8 +57,8 @@
|
|||||||
<button
|
<button
|
||||||
class="tab-btn"
|
class="tab-btn"
|
||||||
class:active={tab.value === tabs.active}
|
class:active={tab.value === tabs.active}
|
||||||
onclick={() => tabs.onchange(tab.value)}
|
onclick={() => tabs.onchange(tab.value)}>{tab.label}</button
|
||||||
>{tab.label}</button>
|
>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -37,7 +72,11 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if sort}
|
{#if sort}
|
||||||
<select class="sort-select" value={sort.active} onchange={(e) => sort.onchange(e.currentTarget.value)}>
|
<select
|
||||||
|
class="sort-select"
|
||||||
|
value={sort.active}
|
||||||
|
onchange={(e) => sort.onchange(e.currentTarget.value)}
|
||||||
|
>
|
||||||
{#each sort.options as opt}
|
{#each sort.options as opt}
|
||||||
<option value={opt.value}>{opt.label}</option>
|
<option value={opt.value}>{opt.label}</option>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -47,27 +86,144 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.view-header { margin-bottom: 1.5rem; }
|
.view-header {
|
||||||
.view-title-group { margin-bottom: 0.5rem; }
|
margin-bottom: 1.5rem;
|
||||||
.view-title { font-size: 1.4rem; font-weight: 700; color: #111827; margin: 0; }
|
flex-wrap: wrap;
|
||||||
.view-subtitle { font-size: 0.9rem; color: #6b7280; margin: 0.25rem 0 0 0; }
|
}
|
||||||
.view-tools { display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; }
|
@media (max-width: 639px) {
|
||||||
.tab-bar { display: flex; gap: 2px; background: #f3f4f6; border-radius: 6px; padding: 2px; }
|
/* Stack title above tools so the header doesn't squish on phones */
|
||||||
|
.view-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.view-title-group {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.view-title {
|
||||||
|
font-weight: 700;
|
||||||
|
color: #111827;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
@media (max-width: 639px) {
|
||||||
|
.view-title {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.view-subtitle {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #6b7280;
|
||||||
|
margin: 0.25rem 0 0 0;
|
||||||
|
}
|
||||||
|
.view-tools {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1rem;
|
||||||
|
/* Tools row takes the full header width so the title never gets squeezed */
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
/* Immediate tools (tab bar / week nav / sort) grow to span the row; they
|
||||||
|
wrap to their own full-width line only when they don't fit side by side */
|
||||||
|
.view-tools > * {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
.tab-bar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 2px;
|
||||||
|
background: #f3f4f6;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 2px;
|
||||||
|
}
|
||||||
.tab-btn {
|
.tab-btn {
|
||||||
padding: 0.35rem 0.85rem; border: none; background: transparent; border-radius: 5px;
|
padding: 0.35rem 0.85rem;
|
||||||
font-size: 0.85rem; color: #6b7280; cursor: pointer;
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
border-radius: 5px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #6b7280;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.tab-btn.active {
|
||||||
|
background: #fff;
|
||||||
|
color: #4338ca;
|
||||||
|
font-weight: 600;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
.week-nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
/* Back-to-this-week CTA: lives outside .view-tools on the header's top
|
||||||
|
row, pushed right. Always rendered (visibility-toggled) so the header
|
||||||
|
keeps its height when it appears/disappears. */
|
||||||
|
.header-now {
|
||||||
|
margin-left: auto;
|
||||||
|
align-self: flex-end;
|
||||||
}
|
}
|
||||||
.tab-btn.active { background: #fff; color: #4338ca; font-weight: 600; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
|
|
||||||
.week-nav { display: flex; align-items: center; gap: 0.5rem; }
|
|
||||||
.nav-btn {
|
.nav-btn {
|
||||||
padding: 0.2rem 0.5rem; border: 1px solid #d1d5db; background: #fff; border-radius: 4px;
|
padding: 0.2rem 0.5rem;
|
||||||
cursor: pointer; font-size: 1rem; line-height: 1;
|
border: 1px solid #d1d5db;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.nav-btn:hover {
|
||||||
|
background: #f3f4f6;
|
||||||
|
}
|
||||||
|
.nav-btn.now-btn {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 700;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
/* Same pill CTA as the member hero. Always rendered (visibility-toggled)
|
||||||
|
so the ‹ › buttons never jump when it appears/disappears. */
|
||||||
|
.now-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.now-btn span {
|
||||||
|
display: inline-block;
|
||||||
|
background: rgba(255, 255, 255, 0.92);
|
||||||
|
color: #4338ca;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.35rem 0.9rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 800;
|
||||||
|
white-space: nowrap;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
.view-header.hero .now-btn span {
|
||||||
|
background: rgba(255, 255, 255, 0.92);
|
||||||
|
color: #4338ca;
|
||||||
|
}
|
||||||
|
.now-btn:hover span {
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.now-btn.now-hidden {
|
||||||
|
visibility: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.nav-label {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #374151;
|
||||||
|
font-weight: 500;
|
||||||
|
min-width: 140px;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
.nav-btn:hover { background: #f3f4f6; }
|
|
||||||
.nav-label { font-size: 0.85rem; color: #374151; font-weight: 500; min-width: 140px; text-align: center; }
|
|
||||||
.sort-select {
|
.sort-select {
|
||||||
padding: 0.3rem 0.6rem; border: 1px solid #d1d5db; border-radius: 5px;
|
padding: 0.3rem 0.6rem;
|
||||||
font-size: 0.85rem; background: #fff;
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 5px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
background: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Hero lead (child-dashboard style) ── */
|
/* ── Hero lead (child-dashboard style) ── */
|
||||||
@@ -78,15 +234,41 @@
|
|||||||
color: #fff;
|
color: #fff;
|
||||||
box-shadow: 0 10px 30px rgba(99, 102, 241, 0.35);
|
box-shadow: 0 10px 30px rgba(99, 102, 241, 0.35);
|
||||||
}
|
}
|
||||||
.view-header.hero .view-title-group { margin-bottom: 0.25rem; }
|
.view-header.hero .view-title-group {
|
||||||
.view-header.hero .view-title { color: #fff; }
|
margin-bottom: 0.25rem;
|
||||||
.view-header.hero .view-subtitle { color: rgba(255, 255, 255, 0.85); }
|
}
|
||||||
.view-header.hero .view-tools { margin-top: 0.75rem; }
|
.view-header.hero .view-title {
|
||||||
.view-header.hero .tab-bar { background: rgba(255, 255, 255, 0.16); }
|
color: #fff;
|
||||||
.view-header.hero .tab-btn { color: rgba(255, 255, 255, 0.9); }
|
}
|
||||||
.view-header.hero .tab-btn:hover { color: #fff; }
|
.view-header.hero .view-subtitle {
|
||||||
.view-header.hero .tab-btn.active { background: #fff; color: #4338ca; }
|
color: rgba(255, 255, 255, 0.85);
|
||||||
.view-header.hero .nav-btn { background: rgba(255, 255, 255, 0.92); border-color: transparent; color: #4338ca; }
|
}
|
||||||
.view-header.hero .nav-label { color: #fff; }
|
.view-header.hero .view-tools {
|
||||||
.view-header.hero .sort-select { background: rgba(255, 255, 255, 0.92); color: #1e1b4b; }
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
.view-header.hero .tab-bar {
|
||||||
|
background: rgba(255, 255, 255, 0.16);
|
||||||
|
}
|
||||||
|
.view-header.hero .tab-btn {
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
}
|
||||||
|
.view-header.hero .tab-btn:hover {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.view-header.hero .tab-btn.active {
|
||||||
|
background: #fff;
|
||||||
|
color: #4338ca;
|
||||||
|
}
|
||||||
|
.view-header.hero .nav-btn {
|
||||||
|
background: rgba(255, 255, 255, 0.92);
|
||||||
|
border-color: transparent;
|
||||||
|
color: #4338ca;
|
||||||
|
}
|
||||||
|
.view-header.hero .nav-label {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.view-header.hero .sort-select {
|
||||||
|
background: rgba(255, 255, 255, 0.92);
|
||||||
|
color: #1e1b4b;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,13 +1,72 @@
|
|||||||
export const dashboardIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="9" rx="1"/><rect x="14" y="3" width="7" height="5" rx="1"/><rect x="14" y="12" width="7" height="9" rx="1"/><rect x="3" y="16" width="7" height="5" rx="1"/></svg>'
|
export const dashboardIcon =
|
||||||
export const choresIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11"/></svg>'
|
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="9" rx="1"/><rect x="14" y="3" width="7" height="5" rx="1"/><rect x="14" y="12" width="7" height="9" rx="1"/><rect x="3" y="16" width="7" height="5" rx="1"/></svg>';
|
||||||
export const rewardsIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="8" r="6"/><path d="M15.477 12.89L17 22l-5-3-5 3 1.523-9.11"/></svg>'
|
export const choresIcon =
|
||||||
export const bonusesIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>'
|
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11"/></svg>';
|
||||||
export const homeIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 9l9-7 9 7v11a2 2 0 01-2 2H5a2 2 0 01-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>'
|
export const rewardsIcon =
|
||||||
export const settingsIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-2 2 2 2 0 01-2-2v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83 0 2 2 0 010-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 01-2-2 2 2 0 012-2h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 010-2.83 2 2 0 012.83 0l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 012-2 2 2 0 012 2v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 0 2 2 0 010 2.83l-.06.06a1.65 1.65 0 00-.33 1.82V9a1.65 1.65 0 001.51 1H21a2 2 0 012 2 2 2 0 01-2 2h-.09a1.65 1.65 0 00-1.51 1z"/></svg>'
|
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="8" r="6"/><path d="M15.477 12.89L17 22l-5-3-5 3 1.523-9.11"/></svg>';
|
||||||
export const logoutIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>'
|
export const moneyIcon =
|
||||||
export const prefsIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>'
|
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/></svg>';
|
||||||
export const chevronLeft = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 18 9 12 15 6"/></svg>'
|
export const coinsIcon =
|
||||||
export const chevronRight = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>'
|
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><ellipse cx="12" cy="6" rx="8" ry="3"/><path d="M4 6v6c0 1.66 3.58 3 8 3s8-1.34 8-3V6"/><path d="M4 12v6c0 1.66 3.58 3 8 3s8-1.34 8-3v-6"/></svg>';
|
||||||
export const bellIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 8A6 6 0 006 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 01-3.46 0"/></svg>'
|
export const walletIcon =
|
||||||
export const chatIcon = '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/></svg>'
|
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12V7H5a2 2 0 0 1 0-4h14v4"/><path d="M3 5v14a2 2 0 0 0 2 2h16v-5"/><path d="M18 12a2 2 0 0 0 0 4h4v-4Z"/></svg>';
|
||||||
export const sendIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>'
|
export const circleIcon =
|
||||||
|
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/></svg>';
|
||||||
|
export const handCoinsIcon =
|
||||||
|
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 15h2a2 2 0 1 0 0-4h-3c-.6 0-1.1.2-1.4.6L3 17"/><path d="m7 21 1.6-1.4c.3-.4.8-.6 1.4-.6h4c1.1 0 2.1-.4 2.8-1.2l4.6-4.4a2 2 0 0 0-2.75-2.91l-4.2 3.9"/><path d="M2 16l1 1h2"/><path d="M4 12l1 1h2"/><path d="M6 8l1 1h2"/><path d="M2 20l1.5-.5"/><circle cx="16" cy="8" r="2"/></svg>';
|
||||||
|
export const gemIcon =
|
||||||
|
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 3h12l4 6-10 13L2 9Z"/><path d="M11 3 8 9l4 13 4-13-3-6"/><path d="M2 9h20"/></svg>';
|
||||||
|
export const archiveIcon =
|
||||||
|
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect width="20" height="5" x="2" y="3" rx="1"/><path d="M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8"/><path d="M10 12h4"/></svg>';
|
||||||
|
export const todoIcon =
|
||||||
|
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 6h13"/><path d="M8 12h13"/><path d="M8 18h13"/><path d="M3 6h.01"/><path d="M3 12h.01"/><path d="M3 18h.01"/></svg>';
|
||||||
|
export const homeIcon =
|
||||||
|
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 9l9-7 9 7v11a2 2 0 01-2 2H5a2 2 0 01-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>';
|
||||||
|
export const settingsIcon =
|
||||||
|
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-2 2 2 2 0 01-2-2v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83 0 2 2 0 010-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 01-2-2 2 2 0 012-2h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 010-2.83 2 2 0 012.83 0l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 012-2 2 2 0 012 2v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 0 2 2 0 010 2.83l-.06.06a1.65 1.65 0 00-.33 1.82V9a1.65 1.65 0 001.51 1H21a2 2 0 012 2 2 2 0 01-2 2h-.09a1.65 1.65 0 00-1.51 1z"/></svg>';
|
||||||
|
export const logoutIcon =
|
||||||
|
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>';
|
||||||
|
export const prefsIcon =
|
||||||
|
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>';
|
||||||
|
export const chevronLeft =
|
||||||
|
'<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 18 9 12 15 6"/></svg>';
|
||||||
|
export const chevronRight =
|
||||||
|
'<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>';
|
||||||
|
export const bellIcon =
|
||||||
|
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 8A6 6 0 006 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 01-3.46 0"/></svg>';
|
||||||
|
export const chatIcon =
|
||||||
|
'<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/></svg>';
|
||||||
|
export const monitorIcon =
|
||||||
|
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>';
|
||||||
|
export const sendIcon =
|
||||||
|
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>';
|
||||||
|
export const checkCircleIcon =
|
||||||
|
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 11.08V12a10 10 0 11-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>';
|
||||||
|
export const revokeIcon =
|
||||||
|
'<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg>';
|
||||||
|
export const targetIcon =
|
||||||
|
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="5"/><circle cx="12" cy="12" r="1.5" fill="currentColor"/></svg>';
|
||||||
|
export const calendarIcon =
|
||||||
|
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>';
|
||||||
|
export const sunIcon =
|
||||||
|
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>';
|
||||||
|
export const lockIcon =
|
||||||
|
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>';
|
||||||
|
export const clockIcon =
|
||||||
|
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>';
|
||||||
|
export const giftIcon =
|
||||||
|
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="8" width="18" height="4"/><path d="M12 8v13M12 8C12 8 9 3 6 5s0 3 3 3M12 8c0 0 3-5 6-3s0 3-3 3"/><path d="M5 12v9h14v-9"/></svg>';
|
||||||
|
export const chevronLeftIcon =
|
||||||
|
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m15 6 -6 6 6 6"/></svg>';
|
||||||
|
export const chevronRightIcon =
|
||||||
|
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m9 6 6 6 -6 6"/></svg>';
|
||||||
|
export const alertTriangleIcon =
|
||||||
|
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><path d="M12 9v4M12 17h.01"/></svg>';
|
||||||
|
export const xCircleIcon =
|
||||||
|
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="m15 9-6 6M9 9l6 6"/></svg>';
|
||||||
|
export const infoIcon =
|
||||||
|
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="M12 16v-4M12 8h.01"/></svg>';
|
||||||
|
export const shieldIcon =
|
||||||
|
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/><path d="m9 11.5 2 2 4-4"/></svg>';
|
||||||
|
export const laptopIcon =
|
||||||
|
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 16V7a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v9"/><path d="M4 16H2.9a1 1 0 0 0-.95 1.33l.6 1.5a1 1 0 0 0 .95.67h17a1 1 0 0 0 .95-.67l.6-1.5A1 1 0 0 0 21.1 16H20"/></svg>';
|
||||||
|
|||||||
@@ -1,13 +1,20 @@
|
|||||||
export { default as Sidebar } from './Sidebar.svelte';
|
export { default as Sidebar } from './Sidebar.svelte';
|
||||||
|
export { default as FamDone } from './FamDone.svelte';
|
||||||
export { default as TopNav } from './TopNav.svelte';
|
export { default as TopNav } from './TopNav.svelte';
|
||||||
export { default as Footer } from './Footer.svelte';
|
export { default as Footer } from './Footer.svelte';
|
||||||
export { default as ViewHeader } from './ViewHeader.svelte';
|
export { default as ViewHeader } from './ViewHeader.svelte';
|
||||||
export { default as Card } from './Card.svelte';
|
export { default as Card } from './Card.svelte';
|
||||||
export { default as CardGrid } from './CardGrid.svelte';
|
export { default as CardGrid } from './CardGrid.svelte';
|
||||||
export { default as Button } from './Button.svelte';
|
export { default as Button } from './Button.svelte';
|
||||||
|
export { default as SaveButton } from './SaveButton.svelte';
|
||||||
export { default as Accordion } from './Accordion.svelte';
|
export { default as Accordion } from './Accordion.svelte';
|
||||||
export { default as AccordionItem } from './AccordionItem.svelte';
|
export { default as AccordionItem } from './AccordionItem.svelte';
|
||||||
export { default as Chat } from './Chat.svelte';
|
export { default as Chat } from './Chat.svelte';
|
||||||
export { default as AuthShell } from './AuthShell.svelte';
|
export { default as AuthShell } from './AuthShell.svelte';
|
||||||
export { default as NoticeDialog } from './NoticeDialog.svelte';
|
export { default as NoticeDialog } from './NoticeDialog.svelte';
|
||||||
export { default as PricingPlans } from './PricingPlans.svelte';
|
export { default as PricingPlans } from './PricingPlans.svelte';
|
||||||
|
export { default as CheckboxGrid } from './CheckboxGrid.svelte';
|
||||||
|
export { default as PatternPicker } from './PatternPicker.svelte';
|
||||||
|
export { default as PinPad } from './PinPad.svelte';
|
||||||
|
export { default as SharedPicker } from './SharedPicker.svelte';
|
||||||
|
export { default as JoinPinFlow } from './JoinPinFlow.svelte';
|
||||||
|
|||||||
@@ -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,46 @@
|
|||||||
|
// Code-specific email templates for successful access-code redemption.
|
||||||
|
// Key = the raw access-code value a family enters (e.g. "copppermill03").
|
||||||
|
// Add an entry here and its custom email is sent when that code is used.
|
||||||
|
// If no entry matches, `defaultAccessEmailTemplate` is used.
|
||||||
|
|
||||||
|
export interface AccessEmailVars {
|
||||||
|
famName: string;
|
||||||
|
codeName: string;
|
||||||
|
dashboardUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AccessEmailTemplate {
|
||||||
|
subject: (vars: AccessEmailVars) => string;
|
||||||
|
html: (vars: AccessEmailVars) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_TEMPLATE: AccessEmailTemplate = {
|
||||||
|
subject: ({ famName }) => `Your ${famName} access is unlocked`,
|
||||||
|
html: ({ famName, codeName, dashboardUrl }) => `
|
||||||
|
<p>Hi${famName ? ` ${famName}` : ' there'}! 🎉</p>
|
||||||
|
<p>Your access code (<strong>${codeName}</strong>) has been applied successfully.</p>
|
||||||
|
<p>Your <strong>${famName}</strong> family on <strong>FamDone</strong> is now unlocked.</p>
|
||||||
|
<p><a href="${dashboardUrl}">Open your family dashboard</a> to add kids, set up chores, and get going.</p>
|
||||||
|
<p>If you have any questions, just reply to this email.</p>
|
||||||
|
<p>Talk soon,<br />The FamDone team</p>
|
||||||
|
`
|
||||||
|
};
|
||||||
|
|
||||||
|
export const accessEmailTemplates: Record<string, AccessEmailTemplate> = {
|
||||||
|
copppermill03: {
|
||||||
|
subject: ({ famName }) => `Save a spot for ${famName} — access unlocked`,
|
||||||
|
html: ({ famName, codeName, dashboardUrl }) => `
|
||||||
|
<p>Hi${famName ? ` ${famName}` : ' there'}! 🎉</p>
|
||||||
|
<p>Your access code (<strong>${codeName}</strong>) has been applied successfully.</p>
|
||||||
|
<p>Your <strong>${famName}</strong> family on <strong>FamDone</strong> is now unlocked.</p>
|
||||||
|
<p><a href="${dashboardUrl}">Open your family dashboard</a> to add kids, set up chores, and get going.</p>
|
||||||
|
<p>If you have any questions, just reply to this email.</p>
|
||||||
|
<p>Talk soon,<br />The FamDone team</p>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Resolve the template for a redeemed code, falling back to the default.
|
||||||
|
export function getAccessEmailTemplate(codeValue: string): AccessEmailTemplate {
|
||||||
|
return accessEmailTemplates[codeValue] || DEFAULT_TEMPLATE;
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
import { Resend } from 'resend';
|
||||||
|
import { RESEND_API } from '$app/env/private';
|
||||||
|
import { getAccessEmailTemplate } from './email-templates';
|
||||||
|
import { getPlatformFlags } from '$lib/server/platform';
|
||||||
|
|
||||||
|
const resend = new Resend(String(RESEND_API));
|
||||||
|
|
||||||
|
let demoCache: { value: boolean; at: number } | null = null;
|
||||||
|
const DEMO_TTL_MS = 10_000;
|
||||||
|
|
||||||
|
async function isDemoMode(): Promise<boolean> {
|
||||||
|
if (demoCache && Date.now() - demoCache.at < DEMO_TTL_MS) return demoCache.value;
|
||||||
|
try {
|
||||||
|
const flags = await getPlatformFlags();
|
||||||
|
demoCache = { value: flags.demo === true, at: Date.now() };
|
||||||
|
return flags.demo === true;
|
||||||
|
} catch {
|
||||||
|
demoCache = { value: false, at: Date.now() };
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendPasswordResetEmail(opts: {
|
||||||
|
to: string;
|
||||||
|
resetLink: string;
|
||||||
|
famName: string;
|
||||||
|
}) {
|
||||||
|
if (await isDemoMode()) return;
|
||||||
|
const { to, resetLink, famName } = opts;
|
||||||
|
|
||||||
|
await resend.emails.send({
|
||||||
|
from: 'no-reply@walthamstow.xyz',
|
||||||
|
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;
|
||||||
|
}) {
|
||||||
|
if (await isDemoMode()) return;
|
||||||
|
const { to, inviteLink, famName, otp } = opts;
|
||||||
|
|
||||||
|
await resend.emails.send({
|
||||||
|
from: 'no-reply@walthamstow.xyz',
|
||||||
|
to,
|
||||||
|
subject: `You're invited to ${famName} on FamDone`,
|
||||||
|
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>
|
||||||
|
`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendAccessUnlockedEmail(opts: {
|
||||||
|
to: string;
|
||||||
|
famName: string;
|
||||||
|
codeName: string;
|
||||||
|
codeValue: string;
|
||||||
|
dashboardUrl: string;
|
||||||
|
}) {
|
||||||
|
if (await isDemoMode()) return;
|
||||||
|
const { to, famName, codeName, codeValue, dashboardUrl } = opts;
|
||||||
|
|
||||||
|
const template = getAccessEmailTemplate(codeValue);
|
||||||
|
const vars = { famName, codeName, dashboardUrl };
|
||||||
|
|
||||||
|
await resend.emails.send({
|
||||||
|
from: 'no-reply@walthamstow.xyz',
|
||||||
|
to,
|
||||||
|
subject: template.subject(vars),
|
||||||
|
html: template.html(vars)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendWelcomeEmail(opts: {
|
||||||
|
to: string;
|
||||||
|
famName: string;
|
||||||
|
dashboardUrl: string;
|
||||||
|
}) {
|
||||||
|
if (await isDemoMode()) return;
|
||||||
|
const { to, famName, dashboardUrl } = opts;
|
||||||
|
|
||||||
|
await resend.emails.send({
|
||||||
|
from: 'no-reply@walthamstow.xyz',
|
||||||
|
to,
|
||||||
|
subject: `Welcome to FamDone${famName ? `, ${famName}` : ''}!`,
|
||||||
|
html: `
|
||||||
|
<p>Hi${famName ? ` ${famName}` : ' there'}! 👋</p>
|
||||||
|
<p>Welcome to <strong>FamDone</strong> — your family chore and pocket-money app.</p>
|
||||||
|
<p>Your family is all set up. Here's how to get going:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Open your <a href="${dashboardUrl}">family dashboard</a>.</li>
|
||||||
|
<li>Add your kids and create join codes from <strong>Family Settings</strong>.</li>
|
||||||
|
<li>Set up chores, rewards, and allowances.</li>
|
||||||
|
</ul>
|
||||||
|
<p>
|
||||||
|
If you haven't already, you can pick a plan and pay from your dashboard to
|
||||||
|
unlock your family.
|
||||||
|
</p>
|
||||||
|
<p>If you have any questions, just reply to this email — we're happy to help.</p>
|
||||||
|
<p>Talk soon,<br />The FamDone team</p>
|
||||||
|
`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// A parent was @mentioned in family chat — tell them about it with a link
|
||||||
|
// straight back to the family site. Message text is HTML-escaped.
|
||||||
|
function escHtml(s: string): string {
|
||||||
|
return s
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendMentionEmail(opts: {
|
||||||
|
to: string;
|
||||||
|
parentName: string;
|
||||||
|
authorName: string;
|
||||||
|
message: string;
|
||||||
|
dashboardUrl: string;
|
||||||
|
}) {
|
||||||
|
const { to, parentName, authorName, message, dashboardUrl } = opts;
|
||||||
|
|
||||||
|
await resend.emails.send({
|
||||||
|
from: 'no-reply@walthamstow.xyz',
|
||||||
|
to,
|
||||||
|
subject: `${authorName} mentioned you in family chat`,
|
||||||
|
html: `
|
||||||
|
<p>Hi ${escHtml(parentName)},</p>
|
||||||
|
<p><strong>${escHtml(authorName)}</strong> mentioned you in your family chat:</p>
|
||||||
|
<blockquote style="margin:1rem 0;padding:0.6rem 1rem;border-left:3px solid #6366f1;background:#f5f5ff;color:#374151;border-radius:6px">${escHtml(message)}</blockquote>
|
||||||
|
<p><a href="${dashboardUrl}">Open your family dashboard</a> to read and reply.</p>
|
||||||
|
<p>Talk soon,<br />The FamDone team</p>
|
||||||
|
`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// A child asked to collect a reward — ping the parent(s) with the ask and a
|
||||||
|
// link straight to the Claims card on the admin dashboard.
|
||||||
|
export async function sendClaimRequestEmail(opts: {
|
||||||
|
to: string;
|
||||||
|
parentName: string;
|
||||||
|
childName: string;
|
||||||
|
rewardName: string;
|
||||||
|
rewardValue: string;
|
||||||
|
dashboardUrl: string;
|
||||||
|
}) {
|
||||||
|
if (await isDemoMode()) return;
|
||||||
|
const { to, parentName, childName, rewardName, rewardValue, dashboardUrl } = opts;
|
||||||
|
|
||||||
|
await resend.emails.send({
|
||||||
|
from: 'no-reply@walthamstow.xyz',
|
||||||
|
to,
|
||||||
|
subject: `${childName} asked to claim ${rewardName}`,
|
||||||
|
html: `
|
||||||
|
<p>Hi ${escHtml(parentName)},</p>
|
||||||
|
<p><strong>${escHtml(childName)}</strong> asked to collect a reward:</p>
|
||||||
|
<p>
|
||||||
|
<strong>${escHtml(rewardName)}</strong>
|
||||||
|
${rewardValue ? `— ${escHtml(rewardValue)}` : ''}
|
||||||
|
</p>
|
||||||
|
<p><a href="${dashboardUrl}">Open the Claims card on your dashboard</a> to review and issue it.</p>
|
||||||
|
<p>Talk soon,<br />The FamDone team</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.
|
||||||
@@ -147,8 +235,8 @@ export async function redeemOtp(opts: { famSlug: string; username: string; otp:
|
|||||||
if (!issued || Date.now() - issued > OTP_TTL_MS) throw new Error('Code expired');
|
if (!issued || Date.now() - issued > OTP_TTL_MS) throw new Error('Code expired');
|
||||||
|
|
||||||
const authPb = createPbClient();
|
const authPb = createPbClient();
|
||||||
await authPb
|
const { record } = await authPb
|
||||||
.collection('users')
|
.collection('users')
|
||||||
.authWithPassword(fullUsername, derivePassword(famSlug, handleName));
|
.authWithPassword(fullUsername, derivePassword(famSlug, handleName));
|
||||||
return authPb.authStore.token;
|
return { token: authPb.authStore.token, userId: record.id };
|
||||||
}
|
}
|
||||||
+877
-229
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,50 @@
|
|||||||
|
import { randomBytes } from 'node:crypto';
|
||||||
|
import { createSuperClient } from '$lib/server/pocketbase';
|
||||||
|
|
||||||
|
// Child shared-device PINs. Superuser-only collection (like `otp`): every
|
||||||
|
// read/write goes through server endpoints with session-role checks, so a
|
||||||
|
// child can never read a sibling's PIN via PB rules.
|
||||||
|
export const PIN_RE = /^\d{3}$/;
|
||||||
|
|
||||||
|
export function generatePin(): string {
|
||||||
|
const n = randomBytes(2).readUIntBE(0, 2) % 1000;
|
||||||
|
return n.toString().padStart(3, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getPinRow(userId: string) {
|
||||||
|
const pb = await createSuperClient();
|
||||||
|
return pb
|
||||||
|
.collection('pins')
|
||||||
|
.getFirstListItem(`userId='${userId}'`)
|
||||||
|
.catch(() => null);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPin(userId: string): Promise<string | null> {
|
||||||
|
const row = await getPinRow(userId);
|
||||||
|
return row?.pin || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function hasPin(userId: string): Promise<boolean> {
|
||||||
|
return (await getPin(userId)) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setPin(famId: string, userId: string, pin: string): Promise<void> {
|
||||||
|
const pb = await createSuperClient();
|
||||||
|
const row = await getPinRow(userId);
|
||||||
|
if (row) {
|
||||||
|
await pb.collection('pins').update(row.id, { pin });
|
||||||
|
} else {
|
||||||
|
await pb.collection('pins').create({ famId, userId, pin });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resetPin(famId: string, userId: string): Promise<string> {
|
||||||
|
const pin = generatePin();
|
||||||
|
await setPin(famId, userId, pin);
|
||||||
|
return pin;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyPin(userId: string, pin: string): Promise<boolean> {
|
||||||
|
const row = await getPinRow(userId);
|
||||||
|
return !!row && row.pin === pin;
|
||||||
|
}
|
||||||
@@ -30,9 +30,16 @@ export function pbUser(event: RequestEvent) {
|
|||||||
// Superuser PB client (memoized). Reserved for server-only privileged
|
// Superuser PB client (memoized). Reserved for server-only privileged
|
||||||
// operations that must bypass collection rules: creating child users, minting
|
// operations that must bypass collection rules: creating child users, minting
|
||||||
// OTP-login tokens, and verifying OTPs against the superuser-only otp.
|
// OTP-login tokens, and verifying OTPs against the superuser-only otp.
|
||||||
|
//
|
||||||
|
// NOTE: the auth token expires (prod PB showed ~36h). A stale memoized client
|
||||||
|
// goes out UNAUTHENTICATED, and PocketBase answers unauthenticated getOne()
|
||||||
|
// calls with 404 "resource wasn't found" — which the toggle path misreads as
|
||||||
|
// CHORE_GONE for every chore. So always re-auth when the stored token is no
|
||||||
|
// longer valid instead of reusing a dead client.
|
||||||
let superClient: PocketBase | null = null;
|
let superClient: PocketBase | null = null;
|
||||||
export async function createSuperClient() {
|
export async function createSuperClient() {
|
||||||
if (superClient) return superClient;
|
if (superClient?.authStore.isValid) return superClient;
|
||||||
|
superClient = null;
|
||||||
const pb = new PocketBase(PB_ENDPOINT);
|
const pb = new PocketBase(PB_ENDPOINT);
|
||||||
pb.autoCancellation(false);
|
pb.autoCancellation(false);
|
||||||
await pb.collection('_superusers').authWithPassword(String(PB_EMAIL), String(PB_PASSWORD));
|
await pb.collection('_superusers').authWithPassword(String(PB_EMAIL), String(PB_PASSWORD));
|
||||||
@@ -40,29 +47,43 @@ export async function createSuperClient() {
|
|||||||
return pb;
|
return pb;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Run a superuser op, retrying once with a freshly-authenticated client when
|
||||||
|
// the first attempt hits an auth-shaped failure. A locally-valid token can
|
||||||
|
// still be dead server-side (restart/revoked secret rotation), and PB
|
||||||
|
// surfaces that as 401/403 — or as 404 when a viewRule then denies access.
|
||||||
|
async function withSuperRetry<T>(op: (pb: PocketBase) => Promise<T>): Promise<T> {
|
||||||
|
try {
|
||||||
|
return await op(await createSuperClient());
|
||||||
|
} catch (e: any) {
|
||||||
|
const status = e?.status;
|
||||||
|
if (status === 401 || status === 403 || status === 404) {
|
||||||
|
superClient = null;
|
||||||
|
return await op(await createSuperClient());
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Superuser CRUD facade, built on the memoized SDK superuser client. All
|
// Superuser CRUD facade, built on the memoized SDK superuser client. All
|
||||||
// server PB access (authenticated user + superuser) lives in this one module.
|
// server PB access (authenticated user + superuser) lives in this one module.
|
||||||
export const pbAdmin = {
|
export const pbAdmin = {
|
||||||
async getList(collection: string, filter = '') {
|
async getList(collection: string, filter = '') {
|
||||||
const pb = await createSuperClient();
|
return withSuperRetry((pb) => {
|
||||||
const options: { filter?: string } = {};
|
const options: { filter?: string } = {};
|
||||||
if (filter) options.filter = filter;
|
if (filter) options.filter = filter;
|
||||||
return pb.collection(collection).getFullList(options);
|
return pb.collection(collection).getFullList(options);
|
||||||
|
});
|
||||||
},
|
},
|
||||||
async getOne(collection: string, id: string) {
|
async getOne(collection: string, id: string) {
|
||||||
const pb = await createSuperClient();
|
return withSuperRetry((pb) => pb.collection(collection).getOne(id));
|
||||||
return pb.collection(collection).getOne(id);
|
|
||||||
},
|
},
|
||||||
async create(collection: string, data: Record<string, unknown>) {
|
async create(collection: string, data: Record<string, unknown>) {
|
||||||
const pb = await createSuperClient();
|
return withSuperRetry((pb) => pb.collection(collection).create(data));
|
||||||
return pb.collection(collection).create(data);
|
|
||||||
},
|
},
|
||||||
async update(collection: string, id: string, data: Record<string, unknown>) {
|
async update(collection: string, id: string, data: Record<string, unknown>) {
|
||||||
const pb = await createSuperClient();
|
return withSuperRetry((pb) => pb.collection(collection).update(id, data));
|
||||||
return pb.collection(collection).update(id, data);
|
|
||||||
},
|
},
|
||||||
async remove(collection: string, id: string) {
|
async remove(collection: string, id: string) {
|
||||||
const pb = await createSuperClient();
|
return withSuperRetry((pb) => pb.collection(collection).delete(id));
|
||||||
return pb.collection(collection).delete(id);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,14 +4,43 @@ import {
|
|||||||
periodStart,
|
periodStart,
|
||||||
periodEnd,
|
periodEnd,
|
||||||
nextPaydayAfter,
|
nextPaydayAfter,
|
||||||
weekStart
|
weekStart,
|
||||||
|
weekdayInTz,
|
||||||
|
bonusWindow,
|
||||||
|
completionInWindow
|
||||||
} from '@shared/timezone';
|
} from '@shared/timezone';
|
||||||
|
import { computeBonusProgress } from '@shared/bonus-progress';
|
||||||
import { famMeta } from './fam';
|
import { famMeta } from './fam';
|
||||||
|
import { pbAdmin } from '$lib/server/pocketbase';
|
||||||
|
|
||||||
|
// `fams` reads via superuser: the acting token may predate RULE_OWN_FAM
|
||||||
|
// (migrate only bootstraps fresh stores), which otherwise logs a 404 and
|
||||||
|
// aborts evaluation. Fam-scoped by record id.
|
||||||
|
async function famMetaSU(famId: string) {
|
||||||
|
try {
|
||||||
|
const fam: any = await pbAdmin.getOne('fams', famId);
|
||||||
|
return {
|
||||||
|
payday: fam.payday !== undefined && fam.payday !== null ? Number(fam.payday) : 1,
|
||||||
|
paydayTime: fam.paydayTime || '18:00',
|
||||||
|
tz: resolveTz(fam.timezone || 'auto')
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { payday: 1, paydayTime: '18:00', tz: resolveTz('auto') };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function resolveServerTz(tz?: string): string {
|
function resolveServerTz(tz?: string): string {
|
||||||
return resolveTz(tz || 'auto');
|
return resolveTz(tz || 'auto');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getRewardValue = (cfg: any): number => {
|
||||||
|
const n = Number(cfg.rewardValue);
|
||||||
|
// Prize rewards carry the payout in `label`/rewardValue text ("fluffy toy"),
|
||||||
|
// not a number — but PB rejects value 0 on the required number field, so
|
||||||
|
// fall back to 1 and let the label carry the meaning.
|
||||||
|
return Number.isFinite(n) && n > 0 ? n : 1;
|
||||||
|
};
|
||||||
|
|
||||||
function claimableStamp(cfg: any, payday: number, tz: string) {
|
function claimableStamp(cfg: any, payday: number, tz: string) {
|
||||||
if (cfg.period !== 'weekly' && cfg.period !== 'monthly') {
|
if (cfg.period !== 'weekly' && cfg.period !== 'monthly') {
|
||||||
return { claimable: 'immediate', settleDate: '' };
|
return { claimable: 'immediate', settleDate: '' };
|
||||||
@@ -19,7 +48,24 @@ function claimableStamp(cfg: any, payday: number, tz: string) {
|
|||||||
const now = todayInTz(resolveServerTz(tz));
|
const now = todayInTz(resolveServerTz(tz));
|
||||||
const start = cfg.period === 'monthly' ? `${now.slice(0, 7)}-01` : weekStart(payday, tz);
|
const start = cfg.period === 'monthly' ? `${now.slice(0, 7)}-01` : weekStart(payday, tz);
|
||||||
const end = periodEnd(cfg.period, start);
|
const end = periodEnd(cfg.period, start);
|
||||||
return { claimable: 'payday', settleDate: nextPaydayAfter(end, payday, tz) };
|
// Weeks close on payday, so a weekly window's end IS the settle date —
|
||||||
|
// nextPaydayAfter(end) would skip a whole extra week.
|
||||||
|
const settle =
|
||||||
|
weekdayInTz(new Date(end + 'T12:00:00Z'), tz) === payday
|
||||||
|
? end
|
||||||
|
: nextPaydayAfter(end, payday, tz);
|
||||||
|
return { claimable: 'payday', settleDate: settle };
|
||||||
|
}
|
||||||
|
|
||||||
|
function targetChoreFor(cfg: any, memberId: string): string | undefined {
|
||||||
|
if (
|
||||||
|
cfg.targetChoreIds &&
|
||||||
|
typeof cfg.targetChoreIds === 'object' &&
|
||||||
|
cfg.targetChoreIds[memberId]
|
||||||
|
) {
|
||||||
|
return cfg.targetChoreIds[memberId];
|
||||||
|
}
|
||||||
|
return cfg.targetChoreId || undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function evaluateFam(pb: any, famId: string) {
|
export async function evaluateFam(pb: any, famId: string) {
|
||||||
@@ -42,28 +88,33 @@ export async function evaluateFam(pb: any, famId: string) {
|
|||||||
try {
|
try {
|
||||||
allRewards = await pb.collection('rewards').getFullList({ filter: `famId = '${famId}'` });
|
allRewards = await pb.collection('rewards').getFullList({ filter: `famId = '${famId}'` });
|
||||||
} catch {}
|
} catch {}
|
||||||
const { payday: paydayEval, tz: tzEval } = await famMeta(pb, famId);
|
const { payday: paydayEval, tz: tzEval } = await famMetaSU(famId);
|
||||||
|
|
||||||
// Chores "due" this week per member (daily = 7, otherwise 1). Used by the
|
// Chores "due" this week per member (daily = 7, otherwise 1). Used by the
|
||||||
// `percent` threshold type to compute % of chores completed.
|
// `percent` threshold type to compute % of chores completed.
|
||||||
const dueByMember: Record<string, number> = {};
|
const dueByMember: Record<string, number> = {};
|
||||||
for (const a of allAssigned) {
|
for (const a of allAssigned) {
|
||||||
if (a.memberId) dueByMember[a.memberId] = (dueByMember[a.memberId] || 0) + (a.frequency === 'daily' ? 7 : 1);
|
if (a.memberId)
|
||||||
|
dueByMember[a.memberId] = (dueByMember[a.memberId] || 0) + (a.frequency === 'daily' ? 7 : 1);
|
||||||
}
|
}
|
||||||
const totalDue = Object.values(dueByMember).reduce((s, n) => s + n, 0) || 1;
|
const totalDue = Object.values(dueByMember).reduce((s, n) => s + n, 0) || 1;
|
||||||
|
|
||||||
for (const cfg of configs) {
|
for (const cfg of configs) {
|
||||||
// Pocket money pauses until the parent sets an amount.
|
// Pocket money pauses until the parent sets an amount.
|
||||||
if (cfg.isPocketMoney && !cfg.rewardValue) continue;
|
if (cfg.isPocketMoney && !cfg.rewardValue) continue;
|
||||||
const pStart2 = cfg.period ? periodStart(cfg.period, paydayEval, tzEval) : '';
|
const win = bonusWindow(cfg, paydayEval, tzEval);
|
||||||
const pEnd = cfg.period ? periodEnd(cfg.period, pStart2) : '';
|
const periodCompletions = allCompletions.filter((c: any) => completionInWindow(c, win));
|
||||||
const periodCompletions = cfg.period
|
|
||||||
? allCompletions.filter(
|
|
||||||
(c: any) => (c.date || '').slice(0, 10) >= pStart2 && (c.date || '').slice(0, 10) <= pEnd
|
|
||||||
)
|
|
||||||
: allCompletions;
|
|
||||||
|
|
||||||
const existingRewards = allRewards.filter((r: any) => r.bonusConfigId === cfg.id);
|
const existingRewards = allRewards.filter((r: any) => r.bonusConfigId === cfg.id);
|
||||||
|
// Uniform rule, one reward row per config per member per window:
|
||||||
|
// achieved + no window reward → create; unachieved → delete the window
|
||||||
|
// reward unless it is claimed, requested, or payday-gated (those persist
|
||||||
|
// by design until the parent issues them). Only rewards dated inside the
|
||||||
|
// CURRENT window count as "already rewarded" — last week's row must never
|
||||||
|
// block this week's payout.
|
||||||
|
const windowRewards = existingRewards.filter((r: any) => completionInWindow(r, win));
|
||||||
|
const removable = (r: any) =>
|
||||||
|
r.status !== 'claimed' && r.status !== 'requested' && r.claimable !== 'payday';
|
||||||
let createdReward = false;
|
let createdReward = false;
|
||||||
|
|
||||||
const rewardData = (memberId: string) => {
|
const rewardData = (memberId: string) => {
|
||||||
@@ -77,7 +128,7 @@ export async function evaluateFam(pb: any, famId: string) {
|
|||||||
memberId,
|
memberId,
|
||||||
bonusConfigId: cfg.id,
|
bonusConfigId: cfg.id,
|
||||||
label,
|
label,
|
||||||
value: Number(cfg.rewardValue) || 0,
|
value: getRewardValue(cfg),
|
||||||
rewardType: cfg.rewardType,
|
rewardType: cfg.rewardType,
|
||||||
status: cfg.rewardType === 'points' ? 'claimed' : 'unclaimed',
|
status: cfg.rewardType === 'points' ? 'claimed' : 'unclaimed',
|
||||||
claimedAt: cfg.rewardType === 'points' ? now : null,
|
claimedAt: cfg.rewardType === 'points' ? now : null,
|
||||||
@@ -86,121 +137,140 @@ export async function evaluateFam(pb: any, famId: string) {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
if (cfg.target === 'individual') {
|
// Completions counting toward one member (target-chore scoped when set).
|
||||||
const targetMembers = cfg.memberId ? allMembers.filter((m: any) => m.id === cfg.memberId) : allMembers;
|
const completionsFor = (memberId: string) => {
|
||||||
for (const m of targetMembers) {
|
const memberTarget = targetChoreFor(cfg, memberId);
|
||||||
const memberCompletions = periodCompletions.filter((c: any) => c.memberId === m.id);
|
let list = periodCompletions.filter((c: any) => c.memberId === memberId);
|
||||||
let current = 0;
|
if (memberTarget) list = list.filter((c: any) => c.assignedChoreId === memberTarget);
|
||||||
|
// Collaborative configs only honour the shared targetChoreId.
|
||||||
|
if (cfg.target === 'collaborative' && cfg.targetChoreId)
|
||||||
|
list = list.filter((c: any) => c.assignedChoreId === cfg.targetChoreId);
|
||||||
|
return list;
|
||||||
|
};
|
||||||
|
// Score for one member: percent of due chores, points value, or raw count.
|
||||||
|
const scoreFor = (memberId: string): number => {
|
||||||
|
const list = completionsFor(memberId);
|
||||||
if (cfg.type === 'threshold') {
|
if (cfg.type === 'threshold') {
|
||||||
if (cfg.thresholdType === 'percent') {
|
if (cfg.thresholdType === 'percent') {
|
||||||
const due = dueByMember[m.id] || 0;
|
const due = dueByMember[memberId] || 0;
|
||||||
current = due > 0 ? Math.round((memberCompletions.length / due) * 100) : 0;
|
return due > 0 ? Math.round((list.length / due) * 100) : 0;
|
||||||
} else {
|
}
|
||||||
current = memberCompletions.reduce((sum: number, c: any) => {
|
return list.reduce((sum: number, c: any) => {
|
||||||
const chore = allAssigned.find((a: any) => a.id === c.assignedChoreId);
|
const chore = allAssigned.find((a: any) => a.id === c.assignedChoreId);
|
||||||
return sum + (chore?.type === 'points' ? Number(chore.value) : 0);
|
return sum + (chore?.type === 'points' ? Number(chore.value) : 0);
|
||||||
}, 0);
|
}, 0);
|
||||||
}
|
}
|
||||||
} else if (cfg.type === 'count') {
|
if (cfg.type === 'count') return list.length;
|
||||||
current = memberCompletions.length;
|
return 0;
|
||||||
}
|
};
|
||||||
const achieved = cfg.criteriaValue > 0 && current >= Number(cfg.criteriaValue);
|
const met = (current: number) => cfg.criteriaValue > 0 && current >= Number(cfg.criteriaValue);
|
||||||
|
// Reconcile one member's window reward against their achieved state.
|
||||||
const memberReward = existingRewards.find((r: any) => r.memberId === m.id);
|
// Returns true when a live window reward exists afterwards.
|
||||||
|
const reconcile = async (memberId: string, achieved: boolean): Promise<boolean> => {
|
||||||
if (memberReward && !achieved) {
|
const live = windowRewards.filter((r: any) => r.memberId === memberId);
|
||||||
if (memberReward.status !== 'claimed') {
|
if (!achieved) {
|
||||||
|
for (const r of live) {
|
||||||
|
if (!removable(r)) continue;
|
||||||
try {
|
try {
|
||||||
await pb.collection('rewards').delete(memberReward.id);
|
await pb.collection('rewards').delete(r.id);
|
||||||
} catch {}
|
} catch {}
|
||||||
|
windowRewards.splice(windowRewards.indexOf(r), 1);
|
||||||
}
|
}
|
||||||
continue;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (live.length > 0) return true;
|
||||||
if (memberReward) continue;
|
await pb.collection('rewards').create(rewardData(memberId));
|
||||||
|
|
||||||
if (achieved) {
|
|
||||||
await pb.collection('rewards').create(rewardData(m.id));
|
|
||||||
createdReward = true;
|
createdReward = true;
|
||||||
}
|
return true;
|
||||||
}
|
};
|
||||||
} else if (cfg.target === 'collaborative') {
|
// Drop stale prior-window placeholders (unclaimed, non-gated) so they
|
||||||
const allMemberIds = allMembers.map((m: any) => m.id);
|
// can't pile up unseen; gated/requested/claimed rows persist by design.
|
||||||
const teamCompletions = periodCompletions.filter((c: any) => allMemberIds.includes(c.memberId));
|
const purgeStale = async (memberId: string) => {
|
||||||
let total = 0;
|
const stale = existingRewards.filter(
|
||||||
if (cfg.type === 'threshold') {
|
(r: any) => r.memberId === memberId && !completionInWindow(r, win) && removable(r)
|
||||||
if (cfg.thresholdType === 'percent') {
|
);
|
||||||
total = Math.round((teamCompletions.length / totalDue) * 100);
|
for (const r of stale) {
|
||||||
} else {
|
|
||||||
total = teamCompletions.reduce((sum: number, c: any) => {
|
|
||||||
const chore = allAssigned.find((a: any) => a.id === c.assignedChoreId);
|
|
||||||
return sum + (chore?.type === 'points' ? Number(chore.value) : 0);
|
|
||||||
}, 0);
|
|
||||||
}
|
|
||||||
} else if (cfg.type === 'count') {
|
|
||||||
total = teamCompletions.length;
|
|
||||||
}
|
|
||||||
const achieved = cfg.criteriaValue > 0 && total >= Number(cfg.criteriaValue);
|
|
||||||
|
|
||||||
if (!achieved && existingRewards.length > 0) {
|
|
||||||
for (const r of existingRewards) {
|
|
||||||
if (r.status !== 'claimed') {
|
|
||||||
try {
|
try {
|
||||||
await pb.collection('rewards').delete(r.id);
|
await pb.collection('rewards').delete(r.id);
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (achieved && existingRewards.length === 0) {
|
if (cfg.target === 'competitive') {
|
||||||
for (const m of allMembers) {
|
// Winner-takes-all: a single window reward held by the top scorer.
|
||||||
await pb.collection('rewards').create(rewardData(m.id));
|
const scored = allMembers.map((m: any) => ({ memberId: m.id, current: scoreFor(m.id) }));
|
||||||
|
const qualified = scored.filter((s: any) => met(s.current));
|
||||||
|
const eligible = qualified.length > 0 ? qualified : scored.filter((s: any) => s.current > 0);
|
||||||
|
const winner = eligible.sort((a: any, b: any) => b.current - a.current)[0];
|
||||||
|
const holder = windowRewards[0];
|
||||||
|
if (!winner) {
|
||||||
|
if (holder && removable(holder)) {
|
||||||
|
try {
|
||||||
|
await pb.collection('rewards').delete(holder.id);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
} else if (!holder) {
|
||||||
|
await purgeStale(winner.memberId);
|
||||||
|
await pb.collection('rewards').create(rewardData(winner.memberId));
|
||||||
|
createdReward = true;
|
||||||
|
} else if (holder.memberId !== winner.memberId) {
|
||||||
|
if (removable(holder)) {
|
||||||
|
try {
|
||||||
|
await pb.collection('rewards').delete(holder.id);
|
||||||
|
} catch {}
|
||||||
|
await purgeStale(winner.memberId);
|
||||||
|
await pb.collection('rewards').create(rewardData(winner.memberId));
|
||||||
createdReward = true;
|
createdReward = true;
|
||||||
}
|
}
|
||||||
|
// A locked (claimed/requested/gated) holder keeps the crown until issued.
|
||||||
}
|
}
|
||||||
} else if (cfg.target === 'competitive') {
|
|
||||||
const scored = allMembers.map((m: any) => {
|
|
||||||
const memberCompletions = periodCompletions.filter((c: any) => c.memberId === m.id);
|
|
||||||
let current = 0;
|
|
||||||
if (cfg.type === 'threshold') {
|
|
||||||
if (cfg.thresholdType === 'percent') {
|
|
||||||
const due = dueByMember[m.id] || 0;
|
|
||||||
current = due > 0 ? Math.round((memberCompletions.length / due) * 100) : 0;
|
|
||||||
} else {
|
} else {
|
||||||
current = memberCompletions.reduce((sum: number, c: any) => {
|
// Individual: each member against their own score. Collaborative: the
|
||||||
|
// whole team against the team total, rewarded per member.
|
||||||
|
let teamTotal = 0;
|
||||||
|
if (cfg.target === 'collaborative') {
|
||||||
|
const ids = new Set(allMembers.map((m: any) => m.id));
|
||||||
|
let team = periodCompletions.filter((c: any) => ids.has(c.memberId));
|
||||||
|
if (cfg.targetChoreId)
|
||||||
|
team = team.filter((c: any) => c.assignedChoreId === cfg.targetChoreId);
|
||||||
|
if (cfg.type === 'threshold' && cfg.thresholdType === 'percent') {
|
||||||
|
teamTotal = Math.round((team.length / totalDue) * 100);
|
||||||
|
} else if (cfg.type === 'threshold') {
|
||||||
|
teamTotal = team.reduce((sum: number, c: any) => {
|
||||||
const chore = allAssigned.find((a: any) => a.id === c.assignedChoreId);
|
const chore = allAssigned.find((a: any) => a.id === c.assignedChoreId);
|
||||||
return sum + (chore?.type === 'points' ? Number(chore.value) : 0);
|
return sum + (chore?.type === 'points' ? Number(chore.value) : 0);
|
||||||
}, 0);
|
}, 0);
|
||||||
}
|
|
||||||
} else if (cfg.type === 'count') {
|
} else if (cfg.type === 'count') {
|
||||||
current = memberCompletions.length;
|
teamTotal = team.length;
|
||||||
}
|
|
||||||
return { memberId: m.id, name: m.name, current };
|
|
||||||
});
|
|
||||||
const qualified = scored.filter((s: any) => cfg.criteriaValue > 0 && s.current >= Number(cfg.criteriaValue));
|
|
||||||
const eligible = qualified.length > 0 ? qualified : scored.filter((s: any) => s.current > 0);
|
|
||||||
const winner = eligible.sort((a: any, b: any) => b.current - a.current)[0];
|
|
||||||
|
|
||||||
if (existingRewards.length > 0) {
|
|
||||||
const existing = existingRewards[0];
|
|
||||||
const stillValid = winner && existing.memberId === winner.memberId && winner.current > 0;
|
|
||||||
if (!stillValid && existing.status !== 'claimed') {
|
|
||||||
try {
|
|
||||||
await pb.collection('rewards').delete(existing.id);
|
|
||||||
} catch {}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const members =
|
||||||
if (winner && existingRewards.length === 0) {
|
cfg.target === 'individual' && cfg.memberId
|
||||||
await pb.collection('rewards').create(rewardData(winner.memberId));
|
? allMembers.filter((m: any) => m.id === cfg.memberId)
|
||||||
createdReward = true;
|
: allMembers;
|
||||||
|
for (const m of members) {
|
||||||
|
const achieved =
|
||||||
|
cfg.target === 'collaborative' ? met(teamTotal) : met(scoreFor(m.id));
|
||||||
|
await reconcile(m.id, achieved);
|
||||||
|
await purgeStale(m.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cfg.occurrence === 'once' && (existingRewards.length > 0 || createdReward)) {
|
if (cfg.occurrence === 'once' && (existingRewards.length > 0 || createdReward)) {
|
||||||
|
// Status write via superuser: evaluate often runs as a child token
|
||||||
|
// (fire-and-forget after a chore toggle) and bonus_configs updates
|
||||||
|
// are parent-only — the user-token write 403s and the config stays
|
||||||
|
// `active` forever. Stamp who earned it + when.
|
||||||
|
const earner =
|
||||||
|
existingRewards.find((r: any) => r.memberId)?.memberId ||
|
||||||
|
allMembers[0]?.id ||
|
||||||
|
'auto';
|
||||||
try {
|
try {
|
||||||
await pb.collection('bonus_configs').update(cfg.id, { status: 'completed' });
|
await pbAdmin.update('bonus_configs', cfg.id, {
|
||||||
|
status: 'completed',
|
||||||
|
completedAt: new Date().toISOString().slice(0, 10),
|
||||||
|
completedBy: earner
|
||||||
|
});
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -224,104 +294,13 @@ export async function progress(pb: any, famId: string) {
|
|||||||
pb.collection('assigned_chores').getFullList({ filter: `famId = '${famId}'` }),
|
pb.collection('assigned_chores').getFullList({ filter: `famId = '${famId}'` }),
|
||||||
pb.collection('completions').getFullList({ filter: `famId = '${famId}'` })
|
pb.collection('completions').getFullList({ filter: `famId = '${famId}'` })
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const assignedList = assigned;
|
|
||||||
const completionsList = completions;
|
|
||||||
const dueByMember: Record<string, number> = {};
|
|
||||||
for (const a of assignedList) {
|
|
||||||
if (a.memberId) dueByMember[a.memberId] = (dueByMember[a.memberId] || 0) + (a.frequency === 'daily' ? 7 : 1);
|
|
||||||
}
|
|
||||||
const totalDue = Object.values(dueByMember).reduce((s, n) => s + n, 0) || 1;
|
|
||||||
let allRewards: any[] = [];
|
let allRewards: any[] = [];
|
||||||
try {
|
try {
|
||||||
allRewards = await pb.collection('rewards').getFullList({ filter: `famId = '${famId}'` });
|
allRewards = await pb.collection('rewards').getFullList({ filter: `famId = '${famId}'` });
|
||||||
} catch {}
|
} catch {}
|
||||||
|
// Shared with the Rewards page client-side realtime recompute (famStore + SSE),
|
||||||
const result: any[] = [];
|
// so the admin kanban and member dashboard always show identical progress.
|
||||||
|
return computeBonusProgress(configsData, members, assigned, completions, allRewards, payday, tz);
|
||||||
for (const cfg of configsData) {
|
|
||||||
const cfgRewards = allRewards.filter((r: any) => r.bonusConfigId === cfg.id);
|
|
||||||
const pStart = cfg.period ? periodStart(cfg.period, payday, tz) : '';
|
|
||||||
const pEnd = cfg.period ? periodEnd(cfg.period, pStart) : '';
|
|
||||||
const periodCompletions = cfg.period
|
|
||||||
? completionsList.filter((c: any) => c.date >= pStart && c.date <= pEnd)
|
|
||||||
: completionsList;
|
|
||||||
|
|
||||||
const progressRows: any[] = [];
|
|
||||||
|
|
||||||
if (cfg.target === 'collaborative') {
|
|
||||||
const teamCompletions = periodCompletions.filter((c: any) =>
|
|
||||||
members.some((m: any) => m.id === c.memberId)
|
|
||||||
);
|
|
||||||
let teamCurrent = 0;
|
|
||||||
if (cfg.type === 'threshold') {
|
|
||||||
if (cfg.thresholdType === 'percent') {
|
|
||||||
teamCurrent = Math.round((teamCompletions.length / totalDue) * 100);
|
|
||||||
} else {
|
|
||||||
teamCurrent = teamCompletions.reduce((sum: number, c: any) => {
|
|
||||||
const chore = assignedList.find((a: any) => a.id === c.assignedChoreId);
|
|
||||||
return sum + (chore?.type === 'points' ? Number(chore.value) : 0);
|
|
||||||
}, 0);
|
|
||||||
}
|
|
||||||
} else if (cfg.type === 'count') {
|
|
||||||
teamCurrent = teamCompletions.length;
|
|
||||||
}
|
|
||||||
const teamReward = cfgRewards[0];
|
|
||||||
progressRows.push({
|
|
||||||
memberId: '__team__',
|
|
||||||
memberName: 'Team Total',
|
|
||||||
memberColor: '#8b5cf6',
|
|
||||||
current: teamCurrent,
|
|
||||||
criteriaValue: cfg.criteriaValue || 0,
|
|
||||||
reward: teamReward ? { id: teamReward.id, status: teamReward.status } : null,
|
|
||||||
state: teamReward ? teamReward.status : 'pending',
|
|
||||||
achieved: teamReward ? true : false
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cfg.target !== 'collaborative') {
|
|
||||||
const progressMembers =
|
|
||||||
cfg.target === 'individual' && cfg.memberId
|
|
||||||
? members.filter((m: any) => m.id === cfg.memberId)
|
|
||||||
: members;
|
|
||||||
for (const m of progressMembers) {
|
|
||||||
const memberCompletions = periodCompletions.filter((c: any) => c.memberId === m.id);
|
|
||||||
const memberReward = cfgRewards.find((r: any) => r.memberId === m.id);
|
|
||||||
|
|
||||||
let current = 0;
|
|
||||||
if (cfg.type === 'threshold') {
|
|
||||||
if (cfg.thresholdType === 'percent') {
|
|
||||||
const due = dueByMember[m.id] || 0;
|
|
||||||
current = due > 0 ? Math.round((memberCompletions.length / due) * 100) : 0;
|
|
||||||
} else {
|
|
||||||
current = memberCompletions.reduce((sum: number, c: any) => {
|
|
||||||
const chore = assignedList.find((a: any) => a.id === c.assignedChoreId);
|
|
||||||
return sum + (chore?.type === 'points' ? Number(chore.value) : 0);
|
|
||||||
}, 0);
|
|
||||||
}
|
|
||||||
} else if (cfg.type === 'count') {
|
|
||||||
current = memberCompletions.length;
|
|
||||||
} else if (cfg.type === 'manual') {
|
|
||||||
current = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
progressRows.push({
|
|
||||||
memberId: m.id,
|
|
||||||
memberName: m.name,
|
|
||||||
memberColor: m.color,
|
|
||||||
current,
|
|
||||||
criteriaValue: cfg.criteriaValue || 0,
|
|
||||||
reward: memberReward ? { id: memberReward.id, status: memberReward.status } : null,
|
|
||||||
state: memberReward ? memberReward.status : 'pending',
|
|
||||||
achieved: memberReward ? true : false
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
result.push({ config: cfg, progress: progressRows, periodStart: pStart, periodEnd: pEnd });
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function trigger(pb: any, famId: string, configId: string, memberId?: string) {
|
export async function trigger(pb: any, famId: string, configId: string, memberId?: string) {
|
||||||
@@ -362,9 +341,7 @@ export async function trigger(pb: any, famId: string, configId: string, memberId
|
|||||||
const pEnd = periodEnd(cfg.period, pStart);
|
const pEnd = periodEnd(cfg.period, pStart);
|
||||||
const periodRewards = memberRewards.filter((r: any) => r.date >= pStart && r.date <= pEnd);
|
const periodRewards = memberRewards.filter((r: any) => r.date >= pStart && r.date <= pEnd);
|
||||||
if (periodRewards.length > 0) {
|
if (periodRewards.length > 0) {
|
||||||
throw new Error(
|
throw new Error(`Already issued ${periodRewards.length}x this ${cfg.period} to ${m.name}`);
|
||||||
`Already issued ${periodRewards.length}x this ${cfg.period} to ${m.name}`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -381,7 +358,7 @@ export async function trigger(pb: any, famId: string, configId: string, memberId
|
|||||||
memberId: m.id,
|
memberId: m.id,
|
||||||
bonusConfigId: cfg.id,
|
bonusConfigId: cfg.id,
|
||||||
label,
|
label,
|
||||||
value: Number(cfg.rewardValue) || 0,
|
value: getRewardValue(cfg),
|
||||||
rewardType: cfg.rewardType,
|
rewardType: cfg.rewardType,
|
||||||
status: cfg.rewardType === 'points' ? 'claimed' : 'unclaimed',
|
status: cfg.rewardType === 'points' ? 'claimed' : 'unclaimed',
|
||||||
claimedAt: cfg.rewardType === 'points' ? now : null,
|
claimedAt: cfg.rewardType === 'points' ? now : null,
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import { sendMentionEmail } from '../email';
|
||||||
|
import { pbAdmin } from '$lib/server/pocketbase';
|
||||||
|
|
||||||
export type ChatActor = {
|
export type ChatActor = {
|
||||||
id: string;
|
id: string;
|
||||||
type: 'admin' | 'member';
|
type: 'admin' | 'member';
|
||||||
@@ -5,19 +8,33 @@ export type ChatActor = {
|
|||||||
color: string;
|
color: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Everyone in the family (children + parents) for the @mention picker.
|
||||||
export async function chatMe(pb: any, famId: string, actor: ChatActor) {
|
export async function chatMe(pb: any, famId: string, actor: ChatActor) {
|
||||||
return { famId, actor };
|
const users = await pb
|
||||||
|
.collection('users')
|
||||||
|
.getFullList({ filter: `famId = '${famId}'` })
|
||||||
|
.catch(() => []);
|
||||||
|
return {
|
||||||
|
famId,
|
||||||
|
actor,
|
||||||
|
members: users.map((m: any) => ({
|
||||||
|
id: m.id,
|
||||||
|
name: m.name || '',
|
||||||
|
color: m.color || '#6366f1',
|
||||||
|
role: m.role === 'parent' ? 'parent' : 'child'
|
||||||
|
}))
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function send(
|
export async function send(
|
||||||
pb: any,
|
pb: any,
|
||||||
famId: string,
|
famId: string,
|
||||||
actor: ChatActor,
|
actor: ChatActor,
|
||||||
body: { content?: string; clientId?: string }
|
body: { content?: string; clientId?: string; siteUrl?: string }
|
||||||
) {
|
) {
|
||||||
const content = (body.content || '').trim();
|
const content = (body.content || '').trim();
|
||||||
if (!content) throw new Error('content required');
|
if (!content) throw new Error('content required');
|
||||||
return pb.collection('messages').create({
|
const record = await pb.collection('messages').create({
|
||||||
famId,
|
famId,
|
||||||
authorType: actor.type,
|
authorType: actor.type,
|
||||||
authorId: actor.id,
|
authorId: actor.id,
|
||||||
@@ -27,14 +44,67 @@ export async function send(
|
|||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
clientId: body.clientId ? String(body.clientId).slice(0, 64) : ''
|
clientId: body.clientId ? String(body.clientId).slice(0, 64) : ''
|
||||||
});
|
});
|
||||||
|
// Email any @mentioned parents (their in-app toast is client-side, via the
|
||||||
|
// same mention tokens). Fire-and-forget — never fail the message itself.
|
||||||
|
notifyMentionedParents(pb, famId, actor, record, body.siteUrl || '').catch((err: any) => {
|
||||||
|
console.error('[chat] notifyMentionedParents top-level catch:', err);
|
||||||
|
});
|
||||||
|
return record;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function typing(
|
// Scan the message for `@Parent Name` tokens (case-insensitive) and email every
|
||||||
|
// mentioned parent who isn't the sender, with a link back to the family site.
|
||||||
|
async function notifyMentionedParents(
|
||||||
pb: any,
|
pb: any,
|
||||||
famId: string,
|
famId: string,
|
||||||
actor: ChatActor,
|
actor: ChatActor,
|
||||||
body: { typing?: boolean }
|
message: any,
|
||||||
|
siteUrl: string
|
||||||
) {
|
) {
|
||||||
|
const content = String(message.content || '');
|
||||||
|
// Find parents whose name appears in the message content.
|
||||||
|
// getFullList for auth users may not return email, so we fetch
|
||||||
|
// each matched parent individually via pbAdmin to get the address.
|
||||||
|
const parents = await pb
|
||||||
|
.collection('users')
|
||||||
|
.getFullList({ filter: `famId = '${famId}' && role = 'parent'` })
|
||||||
|
.catch((err: any) => {
|
||||||
|
console.error('[chat] Failed to fetch parents:', err);
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
const mentioned = parents.filter((u: any) => {
|
||||||
|
if (u.id === actor.id) return false;
|
||||||
|
const name = (u.name || '').trim().toLowerCase();
|
||||||
|
return !!name && content.toLowerCase().includes('@' + name);
|
||||||
|
});
|
||||||
|
if (!mentioned.length) return;
|
||||||
|
|
||||||
|
const fam = await pb
|
||||||
|
.collection('fams')
|
||||||
|
.getOne(famId)
|
||||||
|
.catch(() => null);
|
||||||
|
// Don't send emails for the demo family.
|
||||||
|
if (fam?.slug === 'showboaters') return;
|
||||||
|
const dashboardUrl = `${siteUrl}/${fam?.slug || famId}`;
|
||||||
|
for (const parent of mentioned) {
|
||||||
|
try {
|
||||||
|
const parentRec = await pbAdmin.getOne('users', parent.id);
|
||||||
|
const parentEmail = parentRec.email;
|
||||||
|
if (!parentEmail) continue;
|
||||||
|
await sendMentionEmail({
|
||||||
|
to: parentEmail,
|
||||||
|
parentName: parent.name || 'there',
|
||||||
|
authorName: message.authorName || 'Family member',
|
||||||
|
message: content,
|
||||||
|
dashboardUrl
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[chat] Failed to send mention email to ${parent.name}:`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function typing(pb: any, famId: string, actor: ChatActor, body: { typing?: boolean }) {
|
||||||
const existing = await pb.collection('chat_typing').getFullList({
|
const existing = await pb.collection('chat_typing').getFullList({
|
||||||
filter: `famId = '${famId}' && actorId = '${actor.id}' && actorType = '${actor.type}'`
|
filter: `famId = '${famId}' && actorId = '${actor.id}' && actorType = '${actor.type}'`
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,22 +1,18 @@
|
|||||||
import { famMeta } from './fam';
|
import { famMeta } from './fam';
|
||||||
|
import { weekStart } from '@shared/timezone';
|
||||||
|
|
||||||
// Aggregated kanban payload for a single member (child session).
|
// Aggregated kanban payload for a single member (child session).
|
||||||
export async function myChores(pb: any, famId: string, memberId: string) {
|
export async function myChores(pb: any, famId: string, memberId: string) {
|
||||||
const [templates, assigned, completions, rewards, bonusConfigs] = await Promise.all([
|
const [templates, assigned, completions, rewards, bonusConfigs] = await Promise.all([
|
||||||
pb.collection('chore_templates').getFullList({ filter: `famId = '${famId}'` }),
|
pb.collection('chore_templates').getFullList({ filter: `famId = '${famId}'` }),
|
||||||
pb.collection('assigned_chores').getFullList({ filter: `famId = '${famId}' && memberId = '${memberId}'` }),
|
pb.collection('assigned_chores').getFullList({
|
||||||
|
filter: `famId = '${famId}' && (memberId = '${memberId}' || memberId = '')`
|
||||||
|
}),
|
||||||
pb.collection('completions').getFullList({ filter: `famId = '${famId}' && memberId = '${memberId}'` }),
|
pb.collection('completions').getFullList({ filter: `famId = '${famId}' && memberId = '${memberId}'` }),
|
||||||
pb.collection('rewards').getFullList({ filter: `famId = '${famId}' && memberId = '${memberId}'` }),
|
pb.collection('rewards').getFullList({ filter: `famId = '${famId}' && memberId = '${memberId}'` }),
|
||||||
pb.collection('bonus_configs').getFullList({ filter: `famId = '${famId}' && status = 'active'` })
|
pb.collection('bonus_configs').getFullList({ filter: `famId = '${famId}' && status = 'active'` })
|
||||||
]);
|
]);
|
||||||
const { payday, paydayTime, tz } = await famMeta(pb, famId);
|
const { payday, paydayTime, tz } = await famMeta(pb, famId);
|
||||||
let settings: any = {};
|
|
||||||
try {
|
|
||||||
const s = await pb
|
|
||||||
.collection('settings')
|
|
||||||
.getFullList({ filter: `famId = '${famId}'` });
|
|
||||||
settings = s?.[0] || {};
|
|
||||||
} catch {}
|
|
||||||
return {
|
return {
|
||||||
templates,
|
templates,
|
||||||
assigned,
|
assigned,
|
||||||
@@ -25,7 +21,37 @@ export async function myChores(pb: any, famId: string, memberId: string) {
|
|||||||
bonusConfigs,
|
bonusConfigs,
|
||||||
payday,
|
payday,
|
||||||
paydayTime,
|
paydayTime,
|
||||||
timezone: tz,
|
timezone: tz
|
||||||
simulateEow: !!settings.simulateEow
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Purge stale todos. A todo is a one-off item with a completeBy deadline; if it
|
||||||
|
// is never completed it lingers in `assigned_chores` forever (shown as EXPIRED).
|
||||||
|
// Once a new week has started past the deadline's week we delete it so it
|
||||||
|
// doesn't accumulate. Must be called with a client that can delete (parent /
|
||||||
|
// superuser) — child clients can't delete assigned_chores.
|
||||||
|
export async function cleanupExpiredTodos(pb: any, famId: string): Promise<number> {
|
||||||
|
const { payday, tz } = await famMeta(pb, famId);
|
||||||
|
const ws = weekStart(payday, tz);
|
||||||
|
|
||||||
|
const todos = await pb.collection('assigned_chores').getFullList({
|
||||||
|
filter: `famId = '${famId}' && isTodo = true && completeBy != ''`
|
||||||
|
});
|
||||||
|
|
||||||
|
const completions = await pb
|
||||||
|
.collection('completions')
|
||||||
|
.getFullList({ filter: `famId = '${famId}'` })
|
||||||
|
.catch(() => []);
|
||||||
|
const doneIds = new Set(completions.map((c: any) => c.assignedChoreId));
|
||||||
|
|
||||||
|
let removed = 0;
|
||||||
|
for (const todo of todos) {
|
||||||
|
// Only consider uncompleted todos whose deadline fell before this week.
|
||||||
|
if (doneIds.has(todo.id)) continue;
|
||||||
|
if (!todo.completeBy || todo.completeBy >= ws) continue;
|
||||||
|
await pb.collection('assigned_chores').delete(todo.id).catch(() => {});
|
||||||
|
removed++;
|
||||||
|
}
|
||||||
|
if (removed) console.log(`[chores] Purged ${removed} expired todo(s) for fam ${famId}`);
|
||||||
|
return removed;
|
||||||
|
}
|
||||||
@@ -1,23 +1,38 @@
|
|||||||
import { periodWindow } from '@shared/timezone';
|
import { periodWindow } from '@shared/timezone';
|
||||||
import { famMeta } from './fam';
|
import { pbAdmin } from '$lib/server/pocketbase';
|
||||||
import { evaluateFam } from './bonuses';
|
import { evaluateFam } from './bonuses';
|
||||||
|
|
||||||
|
// Reads/writes the acting user may not be permitted by PB rules:
|
||||||
|
// - `fams` view (prod may predate RULE_OWN_FAM; migrate only bootstraps fresh)
|
||||||
|
// - `assigned_chores` update (parent-only rule, but children claim shared chores)
|
||||||
|
// Both stay fam-scoped: the fam record id and the chore's own famId are checked.
|
||||||
|
async function famMetaSU(famId: string) {
|
||||||
|
const fam: any = await pbAdmin.getOne('fams', famId);
|
||||||
|
const { resolveTz } = await import('@shared/timezone');
|
||||||
|
return {
|
||||||
|
payday: fam.payday !== undefined && fam.payday !== null ? Number(fam.payday) : 1,
|
||||||
|
paydayTime: fam.paydayTime || '18:00',
|
||||||
|
tz: resolveTz(fam.timezone || 'auto')
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function claimChore(assignedChoreId: string, famId: string, memberId: string | '') {
|
||||||
|
const chore: any = await pbAdmin.getOne('assigned_chores', assignedChoreId);
|
||||||
|
if (!chore || chore.famId !== famId) throw new Error('Chore not found');
|
||||||
|
await pbAdmin.update('assigned_chores', assignedChoreId, { memberId });
|
||||||
|
}
|
||||||
|
|
||||||
export async function myChores(pb: any, famId: string, memberId: string) {
|
export async function myChores(pb: any, famId: string, memberId: string) {
|
||||||
const [templates, assigned, completions, rewards, bonusConfigs] = await Promise.all([
|
const [templates, assigned, completions, rewards, bonusConfigs] = await Promise.all([
|
||||||
pb.collection('chore_templates').getFullList({ filter: `famId = '${famId}'` }),
|
pb.collection('chore_templates').getFullList({ filter: `famId = '${famId}'` }),
|
||||||
pb.collection('assigned_chores').getFullList({ filter: `famId = '${famId}' && memberId = '${memberId}'` }),
|
pb.collection('assigned_chores').getFullList({
|
||||||
|
filter: `famId = '${famId}' && (memberId = '${memberId}' || memberId = '')`
|
||||||
|
}),
|
||||||
pb.collection('completions').getFullList({ filter: `famId = '${famId}' && memberId = '${memberId}'` }),
|
pb.collection('completions').getFullList({ filter: `famId = '${famId}' && memberId = '${memberId}'` }),
|
||||||
pb.collection('rewards').getFullList({ filter: `famId = '${famId}' && memberId = '${memberId}'` }),
|
pb.collection('rewards').getFullList({ filter: `famId = '${famId}' && memberId = '${memberId}'` }),
|
||||||
pb.collection('bonus_configs').getFullList({ filter: `famId = '${famId}' && status = 'active'` })
|
pb.collection('bonus_configs').getFullList({ filter: `famId = '${famId}' && status = 'active'` })
|
||||||
]);
|
]);
|
||||||
const { payday, paydayTime, tz } = await famMeta(pb, famId);
|
const { payday, paydayTime, tz } = await famMetaSU(famId);
|
||||||
let settings: any = {};
|
|
||||||
try {
|
|
||||||
const s = await pb
|
|
||||||
.collection('settings')
|
|
||||||
.getFullList({ filter: `famId = '${famId}'` });
|
|
||||||
settings = s?.[0] || {};
|
|
||||||
} catch {}
|
|
||||||
return {
|
return {
|
||||||
templates,
|
templates,
|
||||||
assigned,
|
assigned,
|
||||||
@@ -26,25 +41,56 @@ export async function myChores(pb: any, famId: string, memberId: string) {
|
|||||||
bonusConfigs,
|
bonusConfigs,
|
||||||
payday,
|
payday,
|
||||||
paydayTime,
|
paydayTime,
|
||||||
timezone: tz,
|
timezone: tz
|
||||||
simulateEow: !!settings.simulateEow
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function toggle(pb: any, famId: string, memberId: string, body: { assignedChoreId: string; date: string }) {
|
export async function toggle(pb: any, famId: string, memberId: string, body: { assignedChoreId: string; date: string; completedAt?: string }) {
|
||||||
const { assignedChoreId, date } = body;
|
const { assignedChoreId, date } = body;
|
||||||
if (!assignedChoreId || !date) throw new Error('assignedChoreId and date required');
|
if (!assignedChoreId || !date) throw new Error('assignedChoreId and date required');
|
||||||
|
// Backdated catch-up (yesterday mode) stamps the completion for the viewed
|
||||||
|
// day, never "now" — the logical `date` always comes from the client, and
|
||||||
|
// an explicit `completedAt` is honoured when it parses (validated ISO,
|
||||||
|
// defaulting to now). Server-side period windows are always computed from
|
||||||
|
// the real current day, so a yesterday stamp can't shift window logic.
|
||||||
|
let completedAt = new Date().toISOString();
|
||||||
|
if (typeof body.completedAt === 'string') {
|
||||||
|
const t = new Date(body.completedAt).getTime();
|
||||||
|
if (Number.isFinite(t)) completedAt = new Date(t).toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
const choreList = await pb
|
// Chore existence is checked as superuser so a stale client ID (parent
|
||||||
.collection('assigned_chores')
|
// re-saved the chores grid → old assigned rows deleted, new ids issued)
|
||||||
.getFullList({ filter: `famId = '${famId}' && id = '${assignedChoreId}'` });
|
// surfaces as a clear "gone" error instead of PB's generic
|
||||||
const chore = choreList?.[0];
|
// "resource cannot be found" relation-validation 400. The child's own
|
||||||
|
// token is still used for the completion writes below (PB rules enforce).
|
||||||
|
let chore: any;
|
||||||
|
try {
|
||||||
|
chore = await pbAdmin.getOne('assigned_chores', assignedChoreId);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(
|
||||||
|
`[diag] toggle CHORE_GONE(getOne-fail) member=${memberId} fam=${famId} chore=${assignedChoreId} date=${date} err=${e instanceof Error ? e.message : e}`
|
||||||
|
);
|
||||||
|
throw new Error('CHORE_GONE: this chore was changed — refresh to get the latest list');
|
||||||
|
}
|
||||||
|
if (!chore || chore.famId !== famId) {
|
||||||
|
console.error(
|
||||||
|
`[diag] toggle CHORE_GONE(fam-mismatch) member=${memberId} sessionFam=${famId} chore=${assignedChoreId} choreFam=${chore?.famId} choreMember=${chore?.memberId} date=${date}`
|
||||||
|
);
|
||||||
|
throw new Error('CHORE_GONE: this chore was changed — refresh to get the latest list');
|
||||||
|
}
|
||||||
const isTodo = chore?.isTodo;
|
const isTodo = chore?.isTodo;
|
||||||
|
const isShared = chore?.shared === true || !chore?.memberId;
|
||||||
|
// Only one-off shared todos are claimed (winner takes it). Recurring
|
||||||
|
// shared chores stay unassigned so every member completes independently
|
||||||
|
// each period — claiming them permanently hides the chore from siblings.
|
||||||
|
const isClaimable = isShared && !!isTodo;
|
||||||
|
|
||||||
let filter: string;
|
let filter: string;
|
||||||
if (isTodo) {
|
if (isTodo) {
|
||||||
filter = `assignedChoreId = '${assignedChoreId}' && memberId = '${memberId}'`;
|
filter = `assignedChoreId = '${assignedChoreId}' && memberId = '${memberId}'`;
|
||||||
} else {
|
} else {
|
||||||
const { payday, tz } = await famMeta(pb, famId);
|
const { payday, tz } = await famMetaSU(famId);
|
||||||
const { from, to } = periodWindow(chore?.frequency, payday, tz);
|
const { from, to } = periodWindow(chore?.frequency, payday, tz);
|
||||||
filter = `assignedChoreId = '${assignedChoreId}' && memberId = '${memberId}' && date >= '${from}' && date < '${to}'`;
|
filter = `assignedChoreId = '${assignedChoreId}' && memberId = '${memberId}' && date >= '${from}' && date < '${to}'`;
|
||||||
}
|
}
|
||||||
@@ -57,7 +103,18 @@ export async function toggle(pb: any, famId: string, memberId: string, body: { a
|
|||||||
if (isTodo && existing[0].rewardId) {
|
if (isTodo && existing[0].rewardId) {
|
||||||
await pb.collection('rewards').delete(existing[0].rewardId).catch(() => {});
|
await pb.collection('rewards').delete(existing[0].rewardId).catch(() => {});
|
||||||
}
|
}
|
||||||
evaluateFam(pb, famId).catch(() => {});
|
// If this was a claimed one-off todo, release it back to shared.
|
||||||
|
if (isClaimable && existing[0].memberId === memberId) {
|
||||||
|
await claimChore(assignedChoreId, famId, '');
|
||||||
|
}
|
||||||
|
// Bonus evaluation is awaited (not fire-and-forget) so failures are
|
||||||
|
// visible in the server log instead of silently swallowing missed payouts.
|
||||||
|
// The client already updates optimistically, so this doesn't block the UI.
|
||||||
|
try {
|
||||||
|
await evaluateFam(pb, famId);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[evaluateFam] after un-complete:', e);
|
||||||
|
}
|
||||||
return { completed: false };
|
return { completed: false };
|
||||||
}
|
}
|
||||||
let rewardId: string | undefined;
|
let rewardId: string | undefined;
|
||||||
@@ -79,10 +136,19 @@ export async function toggle(pb: any, famId: string, memberId: string, body: { a
|
|||||||
memberId,
|
memberId,
|
||||||
assignedChoreId,
|
assignedChoreId,
|
||||||
date,
|
date,
|
||||||
completedAt: new Date().toISOString(),
|
completedAt,
|
||||||
...(rewardId ? { rewardId } : {})
|
...(rewardId ? { rewardId } : {})
|
||||||
});
|
});
|
||||||
evaluateFam(pb, famId).catch(() => {});
|
// Claim a shared one-off todo by setting memberId (recurring shared
|
||||||
|
// chores stay unassigned — see isClaimable above).
|
||||||
|
if (isClaimable) {
|
||||||
|
await claimChore(assignedChoreId, famId, memberId);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await evaluateFam(pb, famId);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[evaluateFam] after toggle:', e);
|
||||||
|
}
|
||||||
return { completed: true, record };
|
return { completed: true, record };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,7 +157,19 @@ export async function revoke(pb: any, famId: string, completionId: string) {
|
|||||||
.collection('completions')
|
.collection('completions')
|
||||||
.getFullList({ filter: `famId = '${famId}' && id = '${completionId}'` });
|
.getFullList({ filter: `famId = '${famId}' && id = '${completionId}'` });
|
||||||
if (!completions?.length) throw new Error('Completion not found');
|
if (!completions?.length) throw new Error('Completion not found');
|
||||||
|
const completion = completions[0];
|
||||||
await pb.collection('completions').delete(completionId);
|
await pb.collection('completions').delete(completionId);
|
||||||
evaluateFam(pb, famId).catch(() => {});
|
// If this was a shared chore, clear memberId to make it available again
|
||||||
|
const choreList = await pb.collection('assigned_chores')
|
||||||
|
.getFullList({ filter: `famId = '${famId}' && id = '${completion.assignedChoreId}'` });
|
||||||
|
const chore = choreList?.[0];
|
||||||
|
if (chore?.shared === true) {
|
||||||
|
await claimChore(chore.id, famId, '');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await evaluateFam(pb, famId);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[evaluateFam] after revoke:', e);
|
||||||
|
}
|
||||||
return { revoked: true };
|
return { revoked: true };
|
||||||
}
|
}
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
} from '@shared/timezone';
|
} from '@shared/timezone';
|
||||||
import { slugify } from '@shared/slugify';
|
import { slugify } from '@shared/slugify';
|
||||||
import { evaluateFam } from './bonuses';
|
import { evaluateFam } from './bonuses';
|
||||||
|
import { cleanupExpiredTodos } from './chores';
|
||||||
|
|
||||||
function resolveServerTz(tz?: string): string {
|
function resolveServerTz(tz?: string): string {
|
||||||
return resolveTz(tz || 'auto');
|
return resolveTz(tz || 'auto');
|
||||||
@@ -68,7 +69,16 @@ export async function patchFam(pb: any, famId: string, body: Record<string, unkn
|
|||||||
|
|
||||||
export async function getProfile(pb: any, famId: string, userId: string) {
|
export async function getProfile(pb: any, famId: string, userId: string) {
|
||||||
const rec = await pb.collection('users').getOne(userId);
|
const rec = await pb.collection('users').getOne(userId);
|
||||||
return { id: rec.id, name: rec.name || '', color: rec.color || '#6366f1', email: rec.email || '' };
|
return {
|
||||||
|
id: rec.id,
|
||||||
|
name: rec.name || '',
|
||||||
|
color: rec.color || '#6366f1',
|
||||||
|
pattern: rec.pattern || '',
|
||||||
|
themeSize: rec.themeSize || '',
|
||||||
|
themeOpacity: rec.themeOpacity || '',
|
||||||
|
partyEmoji: rec.partyEmoji || '',
|
||||||
|
email: rec.email || ''
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateProfile(
|
export async function updateProfile(
|
||||||
@@ -80,9 +90,23 @@ export async function updateProfile(
|
|||||||
const patch: Record<string, unknown> = {};
|
const patch: Record<string, unknown> = {};
|
||||||
if (data.name) patch.name = data.name;
|
if (data.name) patch.name = data.name;
|
||||||
if (data.color) patch.color = data.color;
|
if (data.color) patch.color = data.color;
|
||||||
|
if (data.pattern !== undefined) patch.pattern = data.pattern;
|
||||||
|
if (data.themeSize !== undefined) patch.themeSize = data.themeSize;
|
||||||
|
if (data.themeOpacity !== undefined) patch.themeOpacity = data.themeOpacity;
|
||||||
|
if (data.partyEmoji !== undefined)
|
||||||
|
patch.partyEmoji = String(data.partyEmoji).trim().slice(0, 8) || '🐖';
|
||||||
if (data.email !== undefined) patch.email = data.email;
|
if (data.email !== undefined) patch.email = data.email;
|
||||||
const rec = await pb.collection('users').update(userId, patch);
|
const rec = await pb.collection('users').update(userId, patch);
|
||||||
return { id: rec.id, name: rec.name || '', color: rec.color || '#6366f1', email: rec.email || '' };
|
return {
|
||||||
|
id: rec.id,
|
||||||
|
name: rec.name || '',
|
||||||
|
color: rec.color || '#6366f1',
|
||||||
|
pattern: rec.pattern || '',
|
||||||
|
themeSize: rec.themeSize || '',
|
||||||
|
themeOpacity: rec.themeOpacity || '',
|
||||||
|
partyEmoji: rec.partyEmoji || '',
|
||||||
|
email: rec.email || ''
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function weeklySummary(pb: any, famId: string) {
|
export async function weeklySummary(pb: any, famId: string) {
|
||||||
@@ -149,8 +173,11 @@ export async function weeklySummary(pb: any, famId: string) {
|
|||||||
|
|
||||||
let bonusMoney = 0;
|
let bonusMoney = 0;
|
||||||
try {
|
try {
|
||||||
|
// Incoming cash counts too (unclaimed/requested, e.g. achieved but
|
||||||
|
// not yet approved pocket money) — this is the "earned this week"
|
||||||
|
// figure, not just banked cash.
|
||||||
const cashRewards = await pb.collection('rewards').getFullList({
|
const cashRewards = await pb.collection('rewards').getFullList({
|
||||||
filter: `famId = '${famId}' && memberId = '${m.id}' && rewardType = 'cash' && status = 'claimed' && date >= '${ws}'`
|
filter: `famId = '${famId}' && memberId = '${m.id}' && rewardType = 'cash' && date >= '${ws}'`
|
||||||
});
|
});
|
||||||
bonusMoney = cashRewards.reduce((sum: number, r: any) => sum + Number(r.value), 0);
|
bonusMoney = cashRewards.reduce((sum: number, r: any) => sum + Number(r.value), 0);
|
||||||
} catch {}
|
} catch {}
|
||||||
@@ -172,149 +199,6 @@ export async function weeklySummary(pb: any, famId: string) {
|
|||||||
return { weekStart: ws, daysInWeek, summaries };
|
return { weekStart: ws, daysInWeek, summaries };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function eowPreview(pb: any, famId: string) {
|
|
||||||
const { payday, tz } = await famMeta(pb, famId);
|
|
||||||
const ws = weekStart(payday, tz);
|
|
||||||
const we = periodEnd('weekly', ws);
|
|
||||||
|
|
||||||
const [members, assigned, completions, configs, rewards] = await Promise.all([
|
|
||||||
pb.collection('users').getFullList({ filter: `famId = '${famId}' && role = 'child'` }),
|
|
||||||
pb.collection('assigned_chores').getFullList({ filter: `famId = '${famId}'` }),
|
|
||||||
pb.collection('completions').getFullList({ filter: `famId = '${famId}' && date >= '${ws}'` }),
|
|
||||||
pb
|
|
||||||
.collection('bonus_configs')
|
|
||||||
.getFullList({ filter: `famId = '${famId}' && status = 'active'` })
|
|
||||||
.catch(() => []),
|
|
||||||
pb.collection('rewards').getFullList({ filter: `famId = '${famId}'` }).catch(() => [])
|
|
||||||
]);
|
|
||||||
|
|
||||||
let rewardPointsList: any[] = [];
|
|
||||||
let rewardCashList: any[] = [];
|
|
||||||
try {
|
|
||||||
[rewardPointsList, rewardCashList] = await Promise.all([
|
|
||||||
pb.collection('rewards').getFullList({
|
|
||||||
filter: `famId = '${famId}' && rewardType = 'points' && status = 'claimed' && date >= '${ws}'`
|
|
||||||
}),
|
|
||||||
pb.collection('rewards').getFullList({
|
|
||||||
filter: `famId = '${famId}' && rewardType = 'cash' && status = 'claimed' && date >= '${ws}'`
|
|
||||||
})
|
|
||||||
]);
|
|
||||||
} catch {}
|
|
||||||
|
|
||||||
const assignedList = assigned;
|
|
||||||
const completionsList = completions;
|
|
||||||
|
|
||||||
const summaries = members.map((m: any) => {
|
|
||||||
const mc = completionsList.filter((c: any) => c.memberId === m.id);
|
|
||||||
const weekPoints = mc.reduce((sum: number, c: any) => {
|
|
||||||
const ch = assignedList.find((a: any) => a.id === c.assignedChoreId);
|
|
||||||
return sum + (ch?.type === 'points' ? Number(ch.value) : 0);
|
|
||||||
}, 0);
|
|
||||||
const weekMoney = mc.reduce((sum: number, c: any) => {
|
|
||||||
const ch = assignedList.find((a: any) => a.id === c.assignedChoreId);
|
|
||||||
return sum + (ch?.type === 'money' ? Number(ch.value) : 0);
|
|
||||||
}, 0);
|
|
||||||
const bonusPoints = rewardPointsList
|
|
||||||
.filter((r: any) => r.memberId === m.id)
|
|
||||||
.reduce((sum: number, r: any) => sum + Number(r.value), 0);
|
|
||||||
const bonusMoney = rewardCashList
|
|
||||||
.filter((r: any) => r.memberId === m.id)
|
|
||||||
.reduce((sum: number, r: any) => sum + Number(r.value), 0);
|
|
||||||
return {
|
|
||||||
memberId: m.id,
|
|
||||||
memberName: m.name || m.username || m.id.slice(0, 6),
|
|
||||||
memberColor: m.color,
|
|
||||||
pointsEarned: weekPoints + bonusPoints,
|
|
||||||
moneyEarned: weekMoney + bonusMoney,
|
|
||||||
choresCompleted: mc.length,
|
|
||||||
bonusEarned: bonusPoints
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const predictedRewards: any[] = [];
|
|
||||||
const existingRewards = rewards;
|
|
||||||
for (const cfg of configs) {
|
|
||||||
if (cfg.type === 'manual') continue;
|
|
||||||
const pStart = cfg.period ? periodStart(cfg.period, payday, tz) : '';
|
|
||||||
const pEnd = cfg.period ? periodEnd(cfg.period, pStart) : '';
|
|
||||||
const periodCompletions = cfg.period
|
|
||||||
? completionsList.filter(
|
|
||||||
(c: any) => (c.date || '').slice(0, 10) >= pStart && (c.date || '').slice(0, 10) <= pEnd
|
|
||||||
)
|
|
||||||
: completionsList;
|
|
||||||
const cfgRewards = existingRewards.filter((r: any) => r.bonusConfigId === cfg.id);
|
|
||||||
|
|
||||||
const tryEval = (sourceComps: any[]) => {
|
|
||||||
if (cfg.type === 'threshold')
|
|
||||||
return sourceComps.reduce((sum: number, c: any) => {
|
|
||||||
const ch = assignedList.find((a: any) => a.id === c.assignedChoreId);
|
|
||||||
return sum + (ch?.type === 'points' ? Number(ch.value) : 0);
|
|
||||||
}, 0);
|
|
||||||
if (cfg.type === 'count') return sourceComps.length;
|
|
||||||
return 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (cfg.target === 'individual') {
|
|
||||||
const targets = cfg.memberId ? members.filter((m: any) => m.id === cfg.memberId) : members;
|
|
||||||
for (const m of targets) {
|
|
||||||
if (cfgRewards.some((r: any) => r.memberId === m.id)) continue;
|
|
||||||
const current = tryEval(periodCompletions.filter((c: any) => c.memberId === m.id));
|
|
||||||
if (cfg.criteriaValue > 0 && current >= Number(cfg.criteriaValue))
|
|
||||||
predictedRewards.push({
|
|
||||||
config: cfg.name,
|
|
||||||
memberName: m.name || m.id.slice(0, 6),
|
|
||||||
type: cfg.rewardType,
|
|
||||||
value: Number(cfg.rewardValue) || 0,
|
|
||||||
detail: `${cfg.type} ${current}/${cfg.criteriaValue}`
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else if (cfg.target === 'collaborative') {
|
|
||||||
if (cfgRewards.length) continue;
|
|
||||||
const allIds = members.map((m: any) => m.id);
|
|
||||||
const teamComps = periodCompletions.filter((c: any) => allIds.includes(c.memberId));
|
|
||||||
const current = tryEval(teamComps);
|
|
||||||
if (cfg.criteriaValue > 0 && current >= Number(cfg.criteriaValue))
|
|
||||||
predictedRewards.push({
|
|
||||||
config: cfg.name,
|
|
||||||
memberName: 'Everyone',
|
|
||||||
type: cfg.rewardType,
|
|
||||||
value: Number(cfg.rewardValue) || 0,
|
|
||||||
detail: `${cfg.type} ${current}/${cfg.criteriaValue}`
|
|
||||||
});
|
|
||||||
} else if (cfg.target === 'competitive') {
|
|
||||||
if (cfgRewards.length) continue;
|
|
||||||
const scored = members.map((m: any) => ({
|
|
||||||
memberId: m.id,
|
|
||||||
name: m.name,
|
|
||||||
current: tryEval(periodCompletions.filter((c: any) => c.memberId === m.id))
|
|
||||||
}));
|
|
||||||
const qualified = scored.filter((st: any) => st.current >= Number(cfg.criteriaValue));
|
|
||||||
const eligible = qualified.length ? qualified : scored.filter((st: any) => st.current > 0);
|
|
||||||
const winner = eligible.sort((aa: any, bb: any) => bb.current - aa.current)[0];
|
|
||||||
if (winner)
|
|
||||||
predictedRewards.push({
|
|
||||||
config: cfg.name,
|
|
||||||
memberName: winner.name,
|
|
||||||
type: cfg.rewardType,
|
|
||||||
value: Number(cfg.rewardValue) || 0,
|
|
||||||
detail: `winner ${winner.current} pts`
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextWeekStart = addDaysStr(ws, 7);
|
|
||||||
|
|
||||||
return {
|
|
||||||
simulateEow: true,
|
|
||||||
weekStart: ws,
|
|
||||||
weekEnd: we,
|
|
||||||
nextWeekStart,
|
|
||||||
summaries,
|
|
||||||
predictedRewards,
|
|
||||||
completionsThisWeek: completionsList.length
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function releaseWeek(pb: any, famId: string) {
|
export async function releaseWeek(pb: any, famId: string) {
|
||||||
const { payday, paydayTime, tz } = await famMeta(pb, famId);
|
const { payday, paydayTime, tz } = await famMeta(pb, famId);
|
||||||
const fams = await pb.collection('fams').getFullList({ filter: `id = '${famId}'` });
|
const fams = await pb.collection('fams').getFullList({ filter: `id = '${famId}'` });
|
||||||
@@ -322,7 +206,10 @@ export async function releaseWeek(pb: any, famId: string) {
|
|||||||
if (!fam) throw new Error('Fam not found');
|
if (!fam) throw new Error('Fam not found');
|
||||||
|
|
||||||
const wsToday = weekStart(payday, tz);
|
const wsToday = weekStart(payday, tz);
|
||||||
const target = new Date(wallClockToUtc(wsToday, paydayTime || '18:00', tz));
|
// Settlement lands on payday itself — the SIXTH day of the wsToday week
|
||||||
|
// (weeks close on payday), not on weekStart.
|
||||||
|
const paydayDate = addDaysStr(wsToday, 6);
|
||||||
|
const target = new Date(wallClockToUtc(paydayDate, paydayTime || '18:00', tz));
|
||||||
if (Date.now() < target.getTime()) {
|
if (Date.now() < target.getTime()) {
|
||||||
return {
|
return {
|
||||||
settled: false,
|
settled: false,
|
||||||
@@ -379,6 +266,8 @@ export async function completeWeek(pb: any, famId: string) {
|
|||||||
const ws = weekStart(payday, tz);
|
const ws = weekStart(payday, tz);
|
||||||
|
|
||||||
await evaluateFam(pb, famId);
|
await evaluateFam(pb, famId);
|
||||||
|
// Start of a new week → purge todos whose deadline was in a prior week.
|
||||||
|
await cleanupExpiredTodos(pb, famId).catch(() => {});
|
||||||
|
|
||||||
const [members, assigned, completions] = await Promise.all([
|
const [members, assigned, completions] = await Promise.all([
|
||||||
pb.collection('users').getFullList({ filter: `famId = '${famId}' && role = 'child'` }),
|
pb.collection('users').getFullList({ filter: `famId = '${famId}' && role = 'child'` }),
|
||||||
@@ -394,7 +283,7 @@ export async function completeWeek(pb: any, famId: string) {
|
|||||||
filter: `famId = '${famId}' && rewardType = 'points' && status = 'claimed' && date >= '${ws}'`
|
filter: `famId = '${famId}' && rewardType = 'points' && status = 'claimed' && date >= '${ws}'`
|
||||||
}),
|
}),
|
||||||
pb.collection('rewards').getFullList({
|
pb.collection('rewards').getFullList({
|
||||||
filter: `famId = '${famId}' && rewardType = 'cash' && status = 'claimed' && date >= '${ws}'`
|
filter: `famId = '${famId}' && rewardType = 'cash' && date >= '${ws}'`
|
||||||
})
|
})
|
||||||
]);
|
]);
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import * as chatSvc from './chat';
|
|||||||
import * as settingsSvc from './settings';
|
import * as settingsSvc from './settings';
|
||||||
import * as crudSvc from './crud';
|
import * as crudSvc from './crud';
|
||||||
import * as debugSvc from './debug';
|
import * as debugSvc from './debug';
|
||||||
|
import * as statsSvc from './stats';
|
||||||
|
|
||||||
export * from './chat';
|
export * from './chat';
|
||||||
export { famMeta } from './fam';
|
export { famMeta } from './fam';
|
||||||
@@ -35,7 +36,6 @@ export function createServices(
|
|||||||
...(timezone !== undefined ? { timezone } : {})
|
...(timezone !== undefined ? { timezone } : {})
|
||||||
}),
|
}),
|
||||||
weeklySummary: (famId: string) => famSvc.weeklySummary(pb, famId),
|
weeklySummary: (famId: string) => famSvc.weeklySummary(pb, famId),
|
||||||
eowPreview: (famId: string) => famSvc.eowPreview(pb, famId),
|
|
||||||
payday: (famId: string) => famSvc.releaseWeek(pb, famId),
|
payday: (famId: string) => famSvc.releaseWeek(pb, famId),
|
||||||
completeWeek: (famId: string) => famSvc.completeWeek(pb, famId),
|
completeWeek: (famId: string) => famSvc.completeWeek(pb, famId),
|
||||||
getProfile: (famId: string) => famSvc.getProfile(pb, famId, uid),
|
getProfile: (famId: string) => famSvc.getProfile(pb, famId, uid),
|
||||||
@@ -48,15 +48,18 @@ export function createServices(
|
|||||||
crudSvc.create(pb, resource, famId, data),
|
crudSvc.create(pb, resource, famId, data),
|
||||||
update: (resource: string, famId: string, id: string, data: Record<string, unknown>) =>
|
update: (resource: string, famId: string, id: string, data: Record<string, unknown>) =>
|
||||||
crudSvc.update(pb, resource, famId, id, data),
|
crudSvc.update(pb, resource, famId, id, data),
|
||||||
remove: (resource: string, famId: string, id: string) => crudSvc.remove(pb, resource, famId, id)
|
remove: (resource: string, famId: string, id: string) =>
|
||||||
|
crudSvc.remove(pb, resource, famId, id)
|
||||||
},
|
},
|
||||||
chores: {
|
chores: {
|
||||||
myChores: (famId: string) => choresSvc.myChores(pb, famId, uid)
|
myChores: (famId: string) => choresSvc.myChores(pb, famId, uid),
|
||||||
|
cleanupExpiredTodos: (famId: string) => choresSvc.cleanupExpiredTodos(pb, famId)
|
||||||
},
|
},
|
||||||
completions: {
|
completions: {
|
||||||
toggle: (famId: string, body: { assignedChoreId: string; date: string }) =>
|
toggle: (famId: string, body: { assignedChoreId: string; date: string; completedAt?: string }) =>
|
||||||
completionsSvc.toggle(pb, famId, uid, body),
|
completionsSvc.toggle(pb, famId, uid, body),
|
||||||
revoke: (famId: string, completionId: string) => completionsSvc.revoke(pb, famId, completionId)
|
revoke: (famId: string, completionId: string) =>
|
||||||
|
completionsSvc.revoke(pb, famId, completionId)
|
||||||
},
|
},
|
||||||
rewards: {
|
rewards: {
|
||||||
claim: (famId: string, id: string) => rewardsSvc.claim(pb, famId, id),
|
claim: (famId: string, id: string) => rewardsSvc.claim(pb, famId, id),
|
||||||
@@ -76,9 +79,12 @@ export function createServices(
|
|||||||
return pb.collection('bonus_configs').update(configId, updates);
|
return pb.collection('bonus_configs').update(configId, updates);
|
||||||
},
|
},
|
||||||
complete: (famId: string, configId: string) =>
|
complete: (famId: string, configId: string) =>
|
||||||
pb.collection('bonus_configs').update(configId, { status: 'completed' }),
|
pb.collection('bonus_configs').update(configId, {
|
||||||
destroy: (famId: string, configId: string) =>
|
status: 'completed',
|
||||||
pb.collection('bonus_configs').delete(configId)
|
completedAt: new Date().toISOString().slice(0, 10),
|
||||||
|
completedBy: 'admin'
|
||||||
|
}),
|
||||||
|
destroy: (famId: string, configId: string) => pb.collection('bonus_configs').delete(configId)
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
get: (famId: string) => settingsSvc.getSettings(pb, famId),
|
get: (famId: string) => settingsSvc.getSettings(pb, famId),
|
||||||
@@ -87,13 +93,19 @@ export function createServices(
|
|||||||
},
|
},
|
||||||
chat: {
|
chat: {
|
||||||
me: (famId: string, actor: chatSvc.ChatActor) => chatSvc.chatMe(pb, famId, actor),
|
me: (famId: string, actor: chatSvc.ChatActor) => chatSvc.chatMe(pb, famId, actor),
|
||||||
send: (famId: string, actor: chatSvc.ChatActor, body: { content?: string; clientId?: string }) =>
|
send: (
|
||||||
chatSvc.send(pb, famId, actor, body),
|
famId: string,
|
||||||
|
actor: chatSvc.ChatActor,
|
||||||
|
body: { content?: string; clientId?: string; siteUrl?: string }
|
||||||
|
) => chatSvc.send(pb, famId, actor, body),
|
||||||
typing: (famId: string, actor: chatSvc.ChatActor, body: { typing?: boolean }) =>
|
typing: (famId: string, actor: chatSvc.ChatActor, body: { typing?: boolean }) =>
|
||||||
chatSvc.typing(pb, famId, actor, body)
|
chatSvc.typing(pb, famId, actor, body)
|
||||||
},
|
},
|
||||||
debug: {
|
debug: {
|
||||||
generateData: (famId: string, days?: number) => debugSvc.generateData(pb, famId, days)
|
generateData: (famId: string, days?: number) => debugSvc.generateData(pb, famId, days)
|
||||||
|
},
|
||||||
|
stats: {
|
||||||
|
family: (famId: string) => statsSvc.familyStats(pb, famId)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,49 +1,83 @@
|
|||||||
import { todayInTz, resolveTz } from '@shared/timezone';
|
import { todayInTz, resolveTz, wallClockToUtc } from '@shared/timezone';
|
||||||
import { famMeta } from './fam';
|
import { famMeta } from './fam';
|
||||||
|
import { pbAdmin } from '$lib/server/pocketbase';
|
||||||
|
|
||||||
function resolveServerTz(tz?: string): string {
|
function resolveServerTz(tz?: string): string {
|
||||||
return resolveTz(tz || 'auto');
|
return resolveTz(tz || 'auto');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function assertPaydayUnlocked(reward: any, tz?: string) {
|
// A `once` goal is finished the moment its reward is approved — complete the
|
||||||
|
// config here (not just in evaluateFam, which runs on toggles/revoke/payday) so
|
||||||
|
// the child dashboard stops offering it even before the next toggle.
|
||||||
|
// Config write via superuser: bonus_configs updates are parent-only.
|
||||||
|
// Outstanding (unclaimed/requested) rewards are untouched — they stay visible
|
||||||
|
// and collectible; only the approval moment completes the goal.
|
||||||
|
async function completeOnceConfig(famId: string, reward: any) {
|
||||||
|
if (!reward?.bonusConfigId) return;
|
||||||
|
try {
|
||||||
|
const cfg: any = await pbAdmin.getOne('bonus_configs', reward.bonusConfigId);
|
||||||
|
if (!cfg || cfg.famId !== famId) return;
|
||||||
|
if (cfg.occurrence !== 'once' || cfg.status === 'completed') return;
|
||||||
|
await pbAdmin.update('bonus_configs', cfg.id, {
|
||||||
|
status: 'completed',
|
||||||
|
completedAt: new Date().toISOString().slice(0, 10),
|
||||||
|
completedBy: reward.memberId || 'admin'
|
||||||
|
});
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Payday-gated rewards (claimable 'payday', e.g. pocket money) unlock on the
|
||||||
|
// settle DATE at the fam's configured payday TIME — not midnight. Anything
|
||||||
|
// else (claimable 'immediate'/unset, e.g. manual triggers) is always claimable.
|
||||||
|
export function assertPaydayUnlocked(reward: any, tz?: string, paydayTime?: string) {
|
||||||
if (!reward || reward.claimable !== 'payday' || !reward.settleDate) return;
|
if (!reward || reward.claimable !== 'payday' || !reward.settleDate) return;
|
||||||
const today = todayInTz(resolveServerTz(tz));
|
const resolvedTz = resolveServerTz(tz);
|
||||||
if (today < reward.settleDate) {
|
const settle = (reward.settleDate as string).slice(0, 10);
|
||||||
throw new Error(`This bonus pays out on payday (${reward.settleDate}) — hang tight!`);
|
const today = todayInTz(resolvedTz);
|
||||||
|
if (today < settle) {
|
||||||
|
throw new Error(`This bonus pays out on payday (${settle}) — hang tight!`);
|
||||||
|
}
|
||||||
|
if (today === settle && paydayTime && /^\d{2}:\d{2}$/.test(paydayTime)) {
|
||||||
|
const unlock = wallClockToUtc(settle, paydayTime, resolvedTz);
|
||||||
|
if (Date.now() < unlock) {
|
||||||
|
throw new Error(`This bonus unlocks at ${paydayTime} on payday — hang tight!`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Member claim → status 'requested' (pending parent approval).
|
// Member claim → status 'requested' (pending parent approval).
|
||||||
export async function claim(pb: any, famId: string, id: string) {
|
export async function claim(pb: any, famId: string, id: string) {
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const { tz } = await famMeta(pb, famId);
|
const { tz, paydayTime } = await famMeta(pb, famId);
|
||||||
const found = await pb
|
const found = await pb
|
||||||
.collection('rewards')
|
.collection('rewards')
|
||||||
.getFullList({ filter: `famId = '${famId}' && id = '${id}'` });
|
.getFullList({ filter: `famId = '${famId}' && id = '${id}'` });
|
||||||
const reward = found?.[0];
|
const reward = found?.[0];
|
||||||
if (!reward) throw new Error('Reward not found');
|
if (!reward) throw new Error('Reward not found');
|
||||||
assertPaydayUnlocked(reward, tz);
|
assertPaydayUnlocked(reward, tz, paydayTime);
|
||||||
return pb.collection('rewards').update(id, { status: 'requested', requestedAt: now });
|
return pb.collection('rewards').update(id, { status: 'requested', requestedAt: now });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Admin approval → status 'claimed'.
|
// Admin approval → status 'claimed'.
|
||||||
export async function approve(pb: any, famId: string, id: string) {
|
export async function approve(pb: any, famId: string, id: string) {
|
||||||
return pb.collection('rewards').update(id, {
|
const record = await pb.collection('rewards').update(id, {
|
||||||
status: 'claimed',
|
status: 'claimed',
|
||||||
claimedAt: new Date().toISOString()
|
claimedAt: new Date().toISOString()
|
||||||
});
|
});
|
||||||
|
await completeOnceConfig(famId, record);
|
||||||
|
return record;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function requestAll(pb: any, famId: string, memberId: string) {
|
export async function requestAll(pb: any, famId: string, memberId: string) {
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const { tz } = await famMeta(pb, famId);
|
const { tz, paydayTime } = await famMeta(pb, famId);
|
||||||
const rewards = await pb.collection('rewards').getFullList({
|
const rewards = await pb.collection('rewards').getFullList({
|
||||||
filter: `famId = '${famId}' && memberId = '${memberId}' && status = 'unclaimed'`
|
filter: `famId = '${famId}' && memberId = '${memberId}' && status = 'unclaimed'`
|
||||||
});
|
});
|
||||||
let count = 0;
|
let count = 0;
|
||||||
for (const r of rewards) {
|
for (const r of rewards) {
|
||||||
try {
|
try {
|
||||||
assertPaydayUnlocked(r, tz);
|
assertPaydayUnlocked(r, tz, paydayTime);
|
||||||
} catch {
|
} catch {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -61,7 +95,8 @@ export async function issueAll(pb: any, famId: string, memberId: string) {
|
|||||||
});
|
});
|
||||||
let count = 0;
|
let count = 0;
|
||||||
for (const r of rewards) {
|
for (const r of rewards) {
|
||||||
await pb.collection('rewards').update(r.id, { status: 'claimed', claimedAt: now });
|
const record = await pb.collection('rewards').update(r.id, { status: 'claimed', claimedAt: now });
|
||||||
|
await completeOnceConfig(famId, record);
|
||||||
count++;
|
count++;
|
||||||
}
|
}
|
||||||
return { count };
|
return { count };
|
||||||
|
|||||||
@@ -8,14 +8,13 @@ function resolveServerTz(tz?: string): string {
|
|||||||
export async function getSettings(pb: any, famId: string) {
|
export async function getSettings(pb: any, famId: string) {
|
||||||
const list = await pb.collection('settings').getFullList({ filter: `famId = '${famId}'` });
|
const list = await pb.collection('settings').getFullList({ filter: `famId = '${famId}'` });
|
||||||
const s = list?.[0] || {};
|
const s = list?.[0] || {};
|
||||||
return { simulateEow: !!s.simulateEow, webhookUrl: s.webhookUrl || '' };
|
return { webhookUrl: s.webhookUrl || '' };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateSettings(pb: any, famId: string, data: Record<string, unknown>) {
|
export async function updateSettings(pb: any, famId: string, data: Record<string, unknown>) {
|
||||||
const list = await pb.collection('settings').getFullList({ filter: `famId = '${famId}'` });
|
const list = await pb.collection('settings').getFullList({ filter: `famId = '${famId}'` });
|
||||||
const existing = list?.[0];
|
const existing = list?.[0];
|
||||||
const patch: Record<string, unknown> = {};
|
const patch: Record<string, unknown> = {};
|
||||||
if (data.simulateEow !== undefined) patch.simulateEow = !!data.simulateEow;
|
|
||||||
if (data.webhookUrl !== undefined) patch.webhookUrl = String(data.webhookUrl);
|
if (data.webhookUrl !== undefined) patch.webhookUrl = String(data.webhookUrl);
|
||||||
let s;
|
let s;
|
||||||
if (existing) {
|
if (existing) {
|
||||||
@@ -23,5 +22,5 @@ export async function updateSettings(pb: any, famId: string, data: Record<string
|
|||||||
} else {
|
} else {
|
||||||
s = await pb.collection('settings').create({ famId, ...patch });
|
s = await pb.collection('settings').create({ famId, ...patch });
|
||||||
}
|
}
|
||||||
return { simulateEow: !!s.simulateEow, webhookUrl: s.webhookUrl || '' };
|
return { webhookUrl: s.webhookUrl || '' };
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import { addDaysStr, weekdayInTz, resolveTz } from '@shared/timezone';
|
||||||
|
import { famMeta } from './fam';
|
||||||
|
|
||||||
|
// Week start (payday-anchored) for an arbitrary YYYY-MM-DD date — a
|
||||||
|
// parametric form of `weekStart()` so we can bucket historical weeks, not
|
||||||
|
// just the current one. Weeks close on payday (open the day after).
|
||||||
|
function weekStartFor(dateStr: string, payday: number, tz: string): string {
|
||||||
|
const wd = weekdayInTz(new Date(dateStr + 'T12:00:00Z'), tz);
|
||||||
|
const sincePayday = (((wd - payday) % 7) + 7) % 7;
|
||||||
|
const back = sincePayday === 0 ? 6 : sincePayday - 1;
|
||||||
|
return addDaysStr(dateStr, -back);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WeeklyPoint = {
|
||||||
|
weekStart: string;
|
||||||
|
points: number;
|
||||||
|
cash: number;
|
||||||
|
chores: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MemberSeries = {
|
||||||
|
memberId: string;
|
||||||
|
memberName: string;
|
||||||
|
memberColor: string;
|
||||||
|
weeks: WeeklyPoint[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FamilyStats = {
|
||||||
|
allTimePts: number;
|
||||||
|
allTimeCash: number;
|
||||||
|
allTimeChores: number;
|
||||||
|
series: MemberSeries[];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Server-side lifetime + weekly aggregates. Scans full-history `completions`
|
||||||
|
// and `rewards` once here (filtered and summed on the server) so the client
|
||||||
|
// receives fixed-size pre-computed numbers instead of every historical row —
|
||||||
|
// this is what keeps the all-time totals + retrospective graph scalable.
|
||||||
|
export async function familyStats(pb: any, famId: string): Promise<FamilyStats> {
|
||||||
|
const { payday, tz } = await famMeta(pb, famId);
|
||||||
|
|
||||||
|
const [members, assigned, completions, rewards] = await Promise.all([
|
||||||
|
pb.collection('users').getFullList({ filter: `famId = '${famId}' && role = 'child'` }),
|
||||||
|
pb.collection('assigned_chores').getFullList({ filter: `famId = '${famId}'` }),
|
||||||
|
pb.collection('completions').getFullList({ filter: `famId = '${famId}'` }),
|
||||||
|
pb.collection('rewards').getFullList({ filter: `famId = '${famId}'` })
|
||||||
|
]);
|
||||||
|
|
||||||
|
const assignedMap = new Map<string, any>(assigned.map((a: any) => [a.id, a]));
|
||||||
|
|
||||||
|
const memberPts: Record<string, number> = {};
|
||||||
|
const memberCash: Record<string, number> = {};
|
||||||
|
const memberChores: Record<string, number> = {};
|
||||||
|
for (const m of members) {
|
||||||
|
memberPts[m.id] = 0;
|
||||||
|
memberCash[m.id] = 0;
|
||||||
|
memberChores[m.id] = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-member weekly buckets (points/cash/chores), keyed by `memberId:weekStart`.
|
||||||
|
const weekMap = new Map<string, WeeklyPoint>();
|
||||||
|
|
||||||
|
function addWeek(memberId: string, weekStart: string, pts: number, cash: number, chores: number) {
|
||||||
|
const key = `${memberId}:${weekStart}`;
|
||||||
|
const w = weekMap.get(key);
|
||||||
|
if (w) {
|
||||||
|
w.points += pts;
|
||||||
|
w.cash += cash;
|
||||||
|
w.chores += chores;
|
||||||
|
} else {
|
||||||
|
weekMap.set(key, { weekStart, points: pts, cash, chores });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chore completions contribute their chore value (points/money) to the
|
||||||
|
// member's lifetime totals and their weekly bucket. Every completion counts
|
||||||
|
// as a chore done (regardless of type).
|
||||||
|
for (const c of completions) {
|
||||||
|
const chore = assignedMap.get(c.assignedChoreId);
|
||||||
|
const pts = chore?.type === 'points' ? Number(chore.value) || 0 : 0;
|
||||||
|
const cash = chore?.type === 'money' ? Number(chore.value) || 0 : 0;
|
||||||
|
const date = (c.date || '').slice(0, 10);
|
||||||
|
memberPts[c.memberId] = (memberPts[c.memberId] || 0) + pts;
|
||||||
|
memberCash[c.memberId] = (memberCash[c.memberId] || 0) + cash;
|
||||||
|
memberChores[c.memberId] = (memberChores[c.memberId] || 0) + 1;
|
||||||
|
if (date) addWeek(c.memberId, weekStartFor(date, payday, tz), pts, cash, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Money-type todos auto-create a matching cash reward when the todo is
|
||||||
|
// completed (see completions.toggle). That cash is already counted in the
|
||||||
|
// completion loop above, so skip those completion-linked rewards here to
|
||||||
|
// avoid double-counting the same earning event.
|
||||||
|
const autoRewardIds = new Set(
|
||||||
|
completions.map((c: any) => c.rewardId).filter((id?: string) => !!id)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Claimed rewards (bonuses/triggers/pocket money) count toward lifetime
|
||||||
|
// earnings once issued. Unclaimed/requested stay "to chase" client-side.
|
||||||
|
for (const r of rewards) {
|
||||||
|
if (r.status !== 'claimed') continue;
|
||||||
|
if (autoRewardIds.has(r.id)) continue;
|
||||||
|
const date = ((r.claimedAt as string) || (r.date as string) || '').slice(0, 10);
|
||||||
|
if (r.rewardType === 'points') {
|
||||||
|
const v = Number(r.value) || 0;
|
||||||
|
memberPts[r.memberId] = (memberPts[r.memberId] || 0) + v;
|
||||||
|
if (date) addWeek(r.memberId, weekStartFor(date, payday, tz), v, 0, 0);
|
||||||
|
} else if (r.rewardType === 'cash') {
|
||||||
|
const v = Number(r.value) || 0;
|
||||||
|
memberCash[r.memberId] = (memberCash[r.memberId] || 0) + v;
|
||||||
|
if (date) addWeek(r.memberId, weekStartFor(date, payday, tz), 0, v, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const series: MemberSeries[] = members.map((m: any) => ({
|
||||||
|
memberId: m.id,
|
||||||
|
memberName: m.name,
|
||||||
|
memberColor: m.color || '#6366f1',
|
||||||
|
weeks: [...weekMap.entries()]
|
||||||
|
.filter(([key]) => key.startsWith(m.id + ':'))
|
||||||
|
.map(([, w]) => w)
|
||||||
|
.sort((a, b) => a.weekStart.localeCompare(b.weekStart))
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
allTimePts: Object.values(memberPts).reduce((s, n) => s + n, 0),
|
||||||
|
allTimeCash: Object.values(memberCash).reduce((s, n) => s + n, 0),
|
||||||
|
allTimeChores: Object.values(memberChores).reduce((s, n) => s + n, 0),
|
||||||
|
series
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,22 +1,66 @@
|
|||||||
import type { Cookies } from '@sveltejs/kit';
|
import type { Cookies } from '@sveltejs/kit';
|
||||||
|
|
||||||
// The PocketBase JWT lives in a single cookie shared by:
|
// PocketBase JWTs live in cookies shared by:
|
||||||
// - the server hooks (authRefresh -> locals.user)
|
// - the server hooks (authRefresh -> locals.user)
|
||||||
// - the client SDK (seeded from page.data.pbToken -> authenticated famStore reads/subscribe)
|
// - the client SDK (seeded from page.data.pbToken -> authenticated famStore reads/subscribe)
|
||||||
// httpOnly keeps the token out of reach of browser JS/XSS; the client receives
|
// httpOnly keeps tokens out of reach of browser JS/XSS; the client receives
|
||||||
// the token server-side via the layout load (pbToken) and seeds pb.authStore.
|
// the token server-side via the layout load (pbToken) and seeds pb.authStore.
|
||||||
// Secure flag is set in prod so it's only sent over HTTPS.
|
// Secure flag is set in prod so it's only sent over HTTPS.
|
||||||
export const SESSION_COOKIE = 'pb_token';
|
export const SESSION_COOKIE = 'pb_token';
|
||||||
const MAX_AGE = 60 * 60 * 24; // 24h, PB token exp is the real ceiling
|
// Shared-device multi-session: children hold ONE cookie per account
|
||||||
|
// (`pb_token_<userId>`); `pb_active` names which one is the current session.
|
||||||
|
// Parents stay on the single `pb_token`.
|
||||||
|
export const CHILD_COOKIE_PREFIX = 'pb_token_';
|
||||||
|
export const ACTIVE_COOKIE = 'pb_active';
|
||||||
|
const MAX_AGE = 60 * 60 * 24 * 5; // 5 days — matches the PB users auth token duration
|
||||||
|
|
||||||
export function setSessionCookie(cookies: Cookies, token: string) {
|
function cookieOpts() {
|
||||||
cookies.set(SESSION_COOKIE, token, {
|
return {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
sameSite: 'lax',
|
sameSite: 'lax' as const,
|
||||||
path: '/',
|
path: '/',
|
||||||
maxAge: MAX_AGE,
|
maxAge: MAX_AGE,
|
||||||
secure: import.meta.env.PROD
|
secure: import.meta.env.PROD
|
||||||
});
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setSessionCookie(cookies: Cookies, token: string) {
|
||||||
|
cookies.set(SESSION_COOKIE, token, cookieOpts());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function childSessionCookie(userId: string) {
|
||||||
|
return `${CHILD_COOKIE_PREFIX}${userId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setChildSessionCookie(cookies: Cookies, userId: string, token: string) {
|
||||||
|
cookies.set(childSessionCookie(userId), token, cookieOpts());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearChildSession(cookies: Cookies, userId: string) {
|
||||||
|
cookies.delete(childSessionCookie(userId), { path: '/' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setActiveChild(cookies: Cookies, userId: string) {
|
||||||
|
cookies.set(ACTIVE_COOKIE, userId, cookieOpts());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearActiveChild(cookies: Cookies) {
|
||||||
|
cookies.delete(ACTIVE_COOKIE, { path: '/' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ids of every child that has a session cookie on this device.
|
||||||
|
export function scanChildSessions(cookies: Cookies): string[] {
|
||||||
|
return cookies
|
||||||
|
.getAll()
|
||||||
|
.filter((c) => c.name.startsWith(CHILD_COOKIE_PREFIX))
|
||||||
|
.map((c) => c.name.slice(CHILD_COOKIE_PREFIX.length))
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove every child session on this device (logout-all).
|
||||||
|
export function clearDeviceSessions(cookies: Cookies) {
|
||||||
|
for (const id of scanChildSessions(cookies)) clearChildSession(cookies, id);
|
||||||
|
clearActiveChild(cookies);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clearSessionCookie(cookies: Cookies) {
|
export function clearSessionCookie(cookies: Cookies) {
|
||||||
|
|||||||
@@ -5,4 +5,8 @@ export interface SessionUser {
|
|||||||
role: 'parent' | 'child';
|
role: 'parent' | 'child';
|
||||||
famId: string;
|
famId: string;
|
||||||
color?: string;
|
color?: string;
|
||||||
|
pattern?: string;
|
||||||
|
themeSize?: string;
|
||||||
|
themeOpacity?: string;
|
||||||
|
partyEmoji?: string;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
export type Shortcut = {
|
||||||
|
famSlug: string;
|
||||||
|
famName: string;
|
||||||
|
userName: string; // '' for parents (no handle in URL)
|
||||||
|
};
|
||||||
|
|
||||||
|
const KEY = 'fam_shortcut';
|
||||||
|
|
||||||
|
export function recordShortcut(entry: Shortcut) {
|
||||||
|
if (typeof localStorage === 'undefined') return;
|
||||||
|
localStorage.setItem(KEY, JSON.stringify(entry));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readShortcut(): Shortcut | null {
|
||||||
|
if (typeof localStorage === 'undefined') return null;
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(KEY);
|
||||||
|
if (!raw) return null;
|
||||||
|
const s = JSON.parse(raw) as Shortcut;
|
||||||
|
// Self-heal the old poisoned entry: a write that fell back to
|
||||||
|
// page.params.fam while page.data.fam was null stored the PB id as
|
||||||
|
// both slug and name ({famSlug: id, famName: id}). A real slug is
|
||||||
|
// never identical to the display name AND a 15-char PB id.
|
||||||
|
if (s && s.famSlug === s.famName && /^[a-z0-9]{15}$/.test(s.famSlug || '')) {
|
||||||
|
localStorage.removeItem(KEY);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shortcutTarget(s: Shortcut): string {
|
||||||
|
return s.userName ? `/${s.famSlug}/${s.userName}` : `/${s.famSlug}`;
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import { pb } from '$lib/pocketbase';
|
import { pb } from '$lib/pocketbase';
|
||||||
import type { ChatMessage, TypingRow } from '$lib/types';
|
import type { ChatMessage, TypingRow, MentionMember } from '$lib/types';
|
||||||
|
import { addAutoDismissNotice } from './notices.svelte';
|
||||||
|
import { famStore } from './fam.svelte';
|
||||||
|
|
||||||
interface ChatInit {
|
interface ChatInit {
|
||||||
famId: string;
|
famId: string;
|
||||||
@@ -8,7 +10,7 @@ interface ChatInit {
|
|||||||
actorName: string;
|
actorName: string;
|
||||||
actorColor: string;
|
actorColor: string;
|
||||||
pbToken?: string;
|
pbToken?: string;
|
||||||
|
members?: MentionMember[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Client id for optimistic chat messages. `crypto.randomUUID()` requires a
|
// Client id for optimistic chat messages. `crypto.randomUUID()` requires a
|
||||||
@@ -34,12 +36,14 @@ class ChatStore {
|
|||||||
actorName = $state('');
|
actorName = $state('');
|
||||||
actorColor = $state('');
|
actorColor = $state('');
|
||||||
pbToken = $state('');
|
pbToken = $state('');
|
||||||
|
members = $state<MentionMember[]>([]);
|
||||||
|
|
||||||
private unsubs: (() => void)[] = [];
|
private unsubs: (() => void)[] = [];
|
||||||
private destroyed = false;
|
private destroyed = false;
|
||||||
private initPromise: Promise<void> | null = null;
|
private initPromise: Promise<void> | null = null;
|
||||||
private lastSeenAt = 0;
|
private lastSeenAt = 0;
|
||||||
private typingTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
private typingTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||||
|
private audioCtx: AudioContext | null = null;
|
||||||
|
|
||||||
typingNames = $derived.by(() => {
|
typingNames = $derived.by(() => {
|
||||||
const names: string[] = [];
|
const names: string[] = [];
|
||||||
@@ -79,6 +83,7 @@ class ChatStore {
|
|||||||
this.actorName = opts.actorName;
|
this.actorName = opts.actorName;
|
||||||
this.actorColor = opts.actorColor;
|
this.actorColor = opts.actorColor;
|
||||||
this.pbToken = opts.pbToken || '';
|
this.pbToken = opts.pbToken || '';
|
||||||
|
this.members = opts.members || [];
|
||||||
this.messages = [];
|
this.messages = [];
|
||||||
this.typing = {};
|
this.typing = {};
|
||||||
this.unread = 0;
|
this.unread = 0;
|
||||||
@@ -115,7 +120,10 @@ class ChatStore {
|
|||||||
this.onMessage(data.action, data.record);
|
this.onMessage(data.action, data.record);
|
||||||
})
|
})
|
||||||
.then((unsub) => this.unsubs.push(unsub))
|
.then((unsub) => this.unsubs.push(unsub))
|
||||||
.catch((err: Error) => console.error('[chatStore] messages subscribe failed:', err));
|
.catch((err: Error) => {
|
||||||
|
console.error('[chatStore] messages subscribe failed:', err);
|
||||||
|
famStore.connectionDown = true;
|
||||||
|
});
|
||||||
|
|
||||||
const typingSub = pb
|
const typingSub = pb
|
||||||
.collection('chat_typing')
|
.collection('chat_typing')
|
||||||
@@ -124,7 +132,10 @@ class ChatStore {
|
|||||||
this.onTyping(data.action, data.record);
|
this.onTyping(data.action, data.record);
|
||||||
})
|
})
|
||||||
.then((unsub) => this.unsubs.push(unsub))
|
.then((unsub) => this.unsubs.push(unsub))
|
||||||
.catch((err: Error) => console.error('[chatStore] typing subscribe failed:', err));
|
.catch((err: Error) => {
|
||||||
|
console.error('[chatStore] typing subscribe failed:', err);
|
||||||
|
famStore.connectionDown = true;
|
||||||
|
});
|
||||||
|
|
||||||
await Promise.allSettled([msgSub, typingSub]);
|
await Promise.allSettled([msgSub, typingSub]);
|
||||||
}
|
}
|
||||||
@@ -156,11 +167,29 @@ class ChatStore {
|
|||||||
}
|
}
|
||||||
// Stop showing their typing indicator once the message lands.
|
// Stop showing their typing indicator once the message lands.
|
||||||
this.clearTyping(record.authorId);
|
this.clearTyping(record.authorId);
|
||||||
if (record.authorId !== this.actorId && !this.open) {
|
if (record.authorId !== this.actorId) {
|
||||||
this.unread++;
|
if (!this.open) this.unread++;
|
||||||
|
this.playDunk();
|
||||||
|
if (this.notifyIfMentioned(record) && this.open) this.unread++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Toast the current user when a message @mentions them by name.
|
||||||
|
// Returns true if the user was mentioned.
|
||||||
|
private notifyIfMentioned(record: ChatMessage): boolean {
|
||||||
|
const lower = (record.content || '').toLowerCase();
|
||||||
|
const me = this.members.find((m) => m.id === this.actorId);
|
||||||
|
if (!me || !me.name) return false;
|
||||||
|
if (!lower.includes('@' + me.name.toLowerCase())) return false;
|
||||||
|
addAutoDismissNotice({
|
||||||
|
type: 'info',
|
||||||
|
title: 'You were mentioned 👋',
|
||||||
|
message: `${record.authorName || 'Someone'}: ${record.content}`,
|
||||||
|
dismissible: true
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
private onTyping(action: string, record: TypingRow) {
|
private onTyping(action: string, record: TypingRow) {
|
||||||
if (record.famId !== this.famId) return;
|
if (record.famId !== this.famId) return;
|
||||||
if (action === 'delete') {
|
if (action === 'delete') {
|
||||||
@@ -184,6 +213,39 @@ class ChatStore {
|
|||||||
if (this.typing[actorId]) this.removeTyping(actorId);
|
if (this.typing[actorId]) this.removeTyping(actorId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Short, low-volume "dunk" blip played when a message from someone else is
|
||||||
|
// broadcast in. Synthesised with the Web Audio API so there's no asset to
|
||||||
|
// ship. Browsers require a user gesture before audio can start, but chat
|
||||||
|
// messages only arrive after the user has interacted, so the context
|
||||||
|
// resumes fine. Failures are silently ignored.
|
||||||
|
private playDunk() {
|
||||||
|
if (typeof window === 'undefined' || typeof AudioContext === 'undefined') return;
|
||||||
|
try {
|
||||||
|
const Ctor = window.AudioContext || (window as any).webkitAudioContext;
|
||||||
|
if (!Ctor) return;
|
||||||
|
if (!this.audioCtx) this.audioCtx = new Ctor();
|
||||||
|
const ctx = this.audioCtx;
|
||||||
|
if (ctx.state === 'suspended') ctx.resume();
|
||||||
|
|
||||||
|
const now = ctx.currentTime;
|
||||||
|
const osc = ctx.createOscillator();
|
||||||
|
const gain = ctx.createGain();
|
||||||
|
osc.type = 'sine';
|
||||||
|
// Quick downward pitch sweep gives the soft "dun-nk" thud.
|
||||||
|
osc.frequency.setValueAtTime(190, now);
|
||||||
|
osc.frequency.exponentialRampToValueAtTime(70, now + 0.18);
|
||||||
|
// Low volume with a fast attack/decay envelope.
|
||||||
|
gain.gain.setValueAtTime(0.0001, now);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.12, now + 0.01);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.0001, now + 0.22);
|
||||||
|
osc.connect(gain).connect(ctx.destination);
|
||||||
|
osc.start(now);
|
||||||
|
osc.stop(now + 0.24);
|
||||||
|
} catch {
|
||||||
|
/* audio is best-effort */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private removeTyping(actorId: string) {
|
private removeTyping(actorId: string) {
|
||||||
if (!this.typing[actorId]) return;
|
if (!this.typing[actorId]) return;
|
||||||
const next = { ...this.typing };
|
const next = { ...this.typing };
|
||||||
@@ -275,6 +337,7 @@ class ChatStore {
|
|||||||
for (const t of this.typingTimers.values()) clearTimeout(t);
|
for (const t of this.typingTimers.values()) clearTimeout(t);
|
||||||
this.typingTimers.clear();
|
this.typingTimers.clear();
|
||||||
this.initialized = false;
|
this.initialized = false;
|
||||||
|
this.members = [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -35,9 +35,15 @@ class FamStore {
|
|||||||
seasons = $state<Season[]>([]);
|
seasons = $state<Season[]>([]);
|
||||||
initialized = $state(false);
|
initialized = $state(false);
|
||||||
famId = $state('');
|
famId = $state('');
|
||||||
|
// True while the realtime SSE stream is down (subscriptions exist but the
|
||||||
|
// socket dropped, e.g. ERR_QUIC_PROTOCOL_ERROR). The SDK reconnects on its
|
||||||
|
// own; we resync missed events on PB_CONNECT (below) + browser signals.
|
||||||
|
connectionDown = $state(false);
|
||||||
|
|
||||||
private unsubs: (() => void)[] = [];
|
private unsubs: (() => void)[] = [];
|
||||||
private destroyed = false;
|
private destroyed = false;
|
||||||
|
private seenConnect = false;
|
||||||
|
private resyncing = false;
|
||||||
|
|
||||||
memberMap(): Map<string, Member> {
|
memberMap(): Map<string, Member> {
|
||||||
return new Map(this.members.map((m) => [m.id, m]));
|
return new Map(this.members.map((m) => [m.id, m]));
|
||||||
@@ -137,12 +143,139 @@ class FamStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await this.subscribe();
|
await this.subscribe();
|
||||||
|
this.watchConnection();
|
||||||
this.initPromise = null;
|
this.initPromise = null;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
return this.initPromise!;
|
return this.initPromise!;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Refetch every list and replace local state. Used after a realtime
|
||||||
|
// reconnect (the SDK re-establishes SSE itself but never replays events
|
||||||
|
// missed during the outage) and on browser online/visible signals.
|
||||||
|
// Acting on stale state is what produced the phantom "resource not found"
|
||||||
|
// 400s after a tab silently disconnected.
|
||||||
|
async resync() {
|
||||||
|
if (!this.initialized || this.destroyed || this.resyncing) return;
|
||||||
|
this.resyncing = true;
|
||||||
|
// Retry with backoff: a resync fired at the leading edge of a blip
|
||||||
|
// (QUIC drop, Tailscale relay flap) usually succeeds a second later.
|
||||||
|
// The dot stays amber until one attempt fully lands.
|
||||||
|
let lastErr: unknown = null;
|
||||||
|
for (let attempt = 0; attempt < 3; attempt++) {
|
||||||
|
if (attempt > 0) await new Promise((r) => setTimeout(r, 1200 * attempt));
|
||||||
|
if (this.destroyed) break;
|
||||||
|
try {
|
||||||
|
await this.fetchAll();
|
||||||
|
if (this.destroyed) return;
|
||||||
|
// A completed resync proves the pipe is healthy again.
|
||||||
|
this.connectionDown = false;
|
||||||
|
lastErr = null;
|
||||||
|
break;
|
||||||
|
} catch (e) {
|
||||||
|
lastErr = e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (lastErr) {
|
||||||
|
console.error('FamStore.resync failed:', lastErr);
|
||||||
|
if (!this.destroyed) this.connectionDown = true;
|
||||||
|
}
|
||||||
|
this.resyncing = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchAll() {
|
||||||
|
const [
|
||||||
|
membersRes,
|
||||||
|
templatesRes,
|
||||||
|
assignedRes,
|
||||||
|
completionsRes,
|
||||||
|
bonusConfigsRes,
|
||||||
|
bonusTemplatesRes,
|
||||||
|
rewardsRes,
|
||||||
|
seasonsRes
|
||||||
|
] = await Promise.all([
|
||||||
|
pb.collection('users').getFullList({
|
||||||
|
filter: `famId = '${this.famId}' && role = 'child'`
|
||||||
|
}) as Promise<Member[]>,
|
||||||
|
pb.collection('chore_templates').getFullList({ filter: `(famId = '${this.famId}' || global = true)` }) as Promise<
|
||||||
|
ChoreTemplate[]
|
||||||
|
>,
|
||||||
|
pb.collection('assigned_chores').getFullList({ filter: `famId = '${this.famId}'` }) as Promise<
|
||||||
|
AssignedChore[]
|
||||||
|
>,
|
||||||
|
pb.collection('completions').getFullList({ filter: `famId = '${this.famId}'` }) as Promise<
|
||||||
|
Completion[]
|
||||||
|
>,
|
||||||
|
pb.collection('bonus_configs').getFullList({ filter: `famId = '${this.famId}'` }) as Promise<
|
||||||
|
BonusConfig[]
|
||||||
|
>,
|
||||||
|
pb.collection('bonus_templates').getFullList({ filter: `(famId = '${this.famId}' || global = true)` }) as Promise<
|
||||||
|
BonusTemplate[]
|
||||||
|
>,
|
||||||
|
pb.collection('rewards').getFullList({ filter: `famId = '${this.famId}'` }) as Promise<
|
||||||
|
Reward[]
|
||||||
|
>,
|
||||||
|
pb.collection('seasons').getFullList({ filter: `famId = '${this.famId}'` }) as Promise<
|
||||||
|
Season[]
|
||||||
|
>
|
||||||
|
]);
|
||||||
|
if (this.destroyed) return;
|
||||||
|
this.members = membersRes;
|
||||||
|
this.templates = templatesRes;
|
||||||
|
this.assigned = assignedRes;
|
||||||
|
this.completions = completionsRes;
|
||||||
|
this.bonusConfigs = bonusConfigsRes;
|
||||||
|
this.bonusTemplates = bonusTemplatesRes;
|
||||||
|
this.rewards = rewardsRes;
|
||||||
|
this.seasons = seasonsRes;
|
||||||
|
}
|
||||||
|
|
||||||
|
private connWatchers: (() => void)[] = [];
|
||||||
|
|
||||||
|
private watchConnection() {
|
||||||
|
if (typeof window === 'undefined' || this.connWatchers.length) return;
|
||||||
|
// SDK-level: PB_CONNECT fires on every (re)connect, including the first.
|
||||||
|
pb.realtime
|
||||||
|
.subscribe('PB_CONNECT', () => {
|
||||||
|
if (this.destroyed) return;
|
||||||
|
this.connectionDown = false;
|
||||||
|
if (this.seenConnect) {
|
||||||
|
// Reconnect after an outage — replay what we missed.
|
||||||
|
this.resync().catch(() => {});
|
||||||
|
}
|
||||||
|
this.seenConnect = true;
|
||||||
|
})
|
||||||
|
.then((unsub) => {
|
||||||
|
this.connWatchers.push(unsub);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// We never even got the connect signal — surface it.
|
||||||
|
if (!this.destroyed) this.connectionDown = true;
|
||||||
|
});
|
||||||
|
const onDisc = pb.realtime.onDisconnect;
|
||||||
|
pb.realtime.onDisconnect = (active: string[]) => {
|
||||||
|
try {
|
||||||
|
onDisc?.(active);
|
||||||
|
} catch {}
|
||||||
|
// Only flag drops with live subscriptions (not our own cleanup).
|
||||||
|
if (active.length > 0 && !this.destroyed) this.connectionDown = true;
|
||||||
|
};
|
||||||
|
// Browser-level: sleep/wake and offline/online can kill SSE without the
|
||||||
|
// SDK noticing promptly. Don't touch the flag here — resync() sets it
|
||||||
|
// from the actual outcome (clearing it early is what left the dot
|
||||||
|
// green while the console showed errors).
|
||||||
|
const onOnline = () => {
|
||||||
|
this.resync().catch(() => {});
|
||||||
|
};
|
||||||
|
const onVisible = () => {
|
||||||
|
if (document.visibilityState === 'visible') this.resync().catch(() => {});
|
||||||
|
};
|
||||||
|
window.addEventListener('online', onOnline);
|
||||||
|
document.addEventListener('visibilitychange', onVisible);
|
||||||
|
this.connWatchers.push(() => window.removeEventListener('online', onOnline));
|
||||||
|
this.connWatchers.push(() => document.removeEventListener('visibilitychange', onVisible));
|
||||||
|
}
|
||||||
|
|
||||||
private async subscribe() {
|
private async subscribe() {
|
||||||
const subs: { collection: CollectionName; filter: string }[] = [
|
const subs: { collection: CollectionName; filter: string }[] = [
|
||||||
{ collection: 'users', filter: `famId = '${this.famId}' && role = 'child'` },
|
{ collection: 'users', filter: `famId = '${this.famId}' && role = 'child'` },
|
||||||
@@ -175,6 +308,9 @@ class FamStore {
|
|||||||
})
|
})
|
||||||
.catch((err: Error) => {
|
.catch((err: Error) => {
|
||||||
console.error(`[famStore] subscribe failed for ${collection}:`, err);
|
console.error(`[famStore] subscribe failed for ${collection}:`, err);
|
||||||
|
// A failed subscribe IS the outage the console shows while
|
||||||
|
// the dot stayed green — flag it; PB_CONNECT/resync clears it.
|
||||||
|
if (!this.destroyed) this.connectionDown = true;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -185,7 +321,12 @@ class FamStore {
|
|||||||
// and from PB subscribe SSE for multi-user realtime.
|
// and from PB subscribe SSE for multi-user realtime.
|
||||||
applyRecord(collection: CollectionName, record: any, action: 'create' | 'update' | 'delete') {
|
applyRecord(collection: CollectionName, record: any, action: 'create' | 'update' | 'delete') {
|
||||||
const apply = <T extends { id: string }>(list: T[]): T[] => {
|
const apply = <T extends { id: string }>(list: T[]): T[] => {
|
||||||
if (action === 'create') return [record, ...list];
|
// A create for an id we already hold (optimistic add + SSE echo) is
|
||||||
|
// an update, not a second row.
|
||||||
|
if (action === 'create')
|
||||||
|
return list.some((x) => x.id === record.id)
|
||||||
|
? list.map((x) => (x.id === record.id ? { ...x, ...record } : x))
|
||||||
|
: [record, ...list];
|
||||||
if (action === 'update')
|
if (action === 'update')
|
||||||
return list.map((x) => (x.id === record.id ? { ...x, ...record } : x));
|
return list.map((x) => (x.id === record.id ? { ...x, ...record } : x));
|
||||||
if (action === 'delete') return list.filter((x) => x.id !== record.id);
|
if (action === 'delete') return list.filter((x) => x.id !== record.id);
|
||||||
@@ -233,6 +374,10 @@ class FamStore {
|
|||||||
this.destroyed = true;
|
this.destroyed = true;
|
||||||
for (const unsub of this.unsubs) unsub();
|
for (const unsub of this.unsubs) unsub();
|
||||||
this.unsubs = [];
|
this.unsubs = [];
|
||||||
|
for (const stop of this.connWatchers) stop();
|
||||||
|
this.connWatchers = [];
|
||||||
|
this.seenConnect = false;
|
||||||
|
this.connectionDown = false;
|
||||||
this.initialized = false;
|
this.initialized = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { isSharedDevice, setSharedDevice } from '$lib/client/shared-device';
|
||||||
|
import { notices } from './notices.svelte';
|
||||||
|
|
||||||
|
// Per-device shared-computer mode (NOT a fam setting — see
|
||||||
|
// lib/client/shared-device.ts). One reactive home for the flag so the fam
|
||||||
|
// layout (switcher + idle-lock gating) and the dashboard hero toggle stay in
|
||||||
|
// sync. Anyone holding the device can flip it; the server sees the cookie
|
||||||
|
// mirror as data.sharedDevice.
|
||||||
|
class SharedDeviceStore {
|
||||||
|
on = $state(
|
||||||
|
typeof localStorage !== 'undefined' ? isSharedDevice() : false
|
||||||
|
);
|
||||||
|
|
||||||
|
async toggle() {
|
||||||
|
this.on = !this.on;
|
||||||
|
setSharedDevice(this.on);
|
||||||
|
if (!this.on) return;
|
||||||
|
// Just enabled — remind about PIN state. Kids without a PIN can't be
|
||||||
|
// picked until one is set (picker shows "ask a parent"); kids with one
|
||||||
|
// get a nudge to remember it. Values never exposed — only set/unset.
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/pins/status');
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
const s = await res.json();
|
||||||
|
if (Array.isArray(s.children)) {
|
||||||
|
// Parent view: roster of who still needs a PIN.
|
||||||
|
const missing = s.children.filter((c: any) => !c.hasPin).map((c: any) => c.name);
|
||||||
|
const ready = s.children.filter((c: any) => c.hasPin).map((c: any) => c.name);
|
||||||
|
if (missing.length > 0) {
|
||||||
|
notices.warning(
|
||||||
|
'Shared mode on',
|
||||||
|
`Still need a PIN: ${missing.join(', ')} (set in Settings → Members).` +
|
||||||
|
(ready.length > 0 ? ` Ready: ${ready.join(', ')}.` : '')
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
notices.success(
|
||||||
|
'Shared mode on',
|
||||||
|
`PINs ready for ${ready.length > 0 ? ready.join(', ') : 'everyone'}. Remind the kids!`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (typeof s.hasPin === 'boolean') {
|
||||||
|
// Child view: only their own state.
|
||||||
|
if (s.hasPin) {
|
||||||
|
notices.success('Shared mode on', 'Remember your 3-digit PIN to switch back in!');
|
||||||
|
} else {
|
||||||
|
notices.warning(
|
||||||
|
'Shared mode on',
|
||||||
|
'You need a PIN first. Ask a parent to set one up.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
notices.info('Shared mode on', 'This computer now uses profile PINs.');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
notices.info('Shared mode on', 'This computer now uses profile PINs.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sharedDevice = new SharedDeviceStore();
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
// Live theme draft — the PatternPicker writes here the moment a tile (or a
|
||||||
|
// colour swatch) is clicked, so the fam layout can preview the background
|
||||||
|
// instantly. `null` means "no draft yet — use the saved value from session".
|
||||||
|
export const themeDraft = $state<{
|
||||||
|
pattern: string | null;
|
||||||
|
color: string | null;
|
||||||
|
size: number | null;
|
||||||
|
opacity: number | null;
|
||||||
|
}>({
|
||||||
|
pattern: null,
|
||||||
|
color: null,
|
||||||
|
size: null,
|
||||||
|
opacity: null
|
||||||
|
});
|
||||||
@@ -16,7 +16,15 @@ import {
|
|||||||
Leaf,
|
Leaf,
|
||||||
Heart,
|
Heart,
|
||||||
Gift,
|
Gift,
|
||||||
Timer
|
Timer,
|
||||||
|
PawPrint,
|
||||||
|
Guitar,
|
||||||
|
Medal,
|
||||||
|
Wrench,
|
||||||
|
PenLine,
|
||||||
|
SprayCan,
|
||||||
|
Droplets,
|
||||||
|
CookingPot
|
||||||
} from '@lucide/svelte';
|
} from '@lucide/svelte';
|
||||||
|
|
||||||
export const ICON_MAP: Record<string, any> = {
|
export const ICON_MAP: Record<string, any> = {
|
||||||
@@ -37,7 +45,15 @@ export const ICON_MAP: Record<string, any> = {
|
|||||||
Leaf,
|
Leaf,
|
||||||
Heart,
|
Heart,
|
||||||
Gift,
|
Gift,
|
||||||
Timer
|
Timer,
|
||||||
|
PawPrint,
|
||||||
|
Guitar,
|
||||||
|
Medal,
|
||||||
|
Wrench,
|
||||||
|
PenLine,
|
||||||
|
SprayCan,
|
||||||
|
Droplets,
|
||||||
|
CookingPot
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ICON_NAMES = Object.keys(ICON_MAP);
|
export const ICON_NAMES = Object.keys(ICON_MAP);
|
||||||
|
|||||||
@@ -0,0 +1,991 @@
|
|||||||
|
/* Generated from css-patterns.css (see scripts/gen-theme-patterns.mjs).
|
||||||
|
57 unique patterns (3 duplicates removed).
|
||||||
|
Colors are overridden per-user via --c1..--c4 set inline on the element
|
||||||
|
(hsl shades of the chosen swatch). */
|
||||||
|
|
||||||
|
html .app-shell.pattern-p1,
|
||||||
|
.pattern-p1 {
|
||||||
|
--s: 84px; /* control the size*/
|
||||||
|
--c1: #f2f2f2;
|
||||||
|
--c2: #cdcbcc;
|
||||||
|
--c3: #999999;
|
||||||
|
|
||||||
|
--_g: 0 120deg,#0000 0;
|
||||||
|
background:
|
||||||
|
conic-gradient( at calc(250%/3) calc(100%/3),var(--c3) var(--_g)),
|
||||||
|
conic-gradient(from -120deg at calc( 50%/3) calc(100%/3),var(--c2) var(--_g)),
|
||||||
|
conic-gradient(from 120deg at calc(100%/3) calc(250%/3),var(--c1) var(--_g)),
|
||||||
|
conic-gradient(from 120deg at calc(200%/3) calc(250%/3),var(--c1) var(--_g)),
|
||||||
|
conic-gradient(from -180deg at calc(100%/3) 50%,var(--c2) 60deg,var(--c1) var(--_g)),
|
||||||
|
conic-gradient(from 60deg at calc(200%/3) 50%,var(--c1) 60deg,var(--c3) var(--_g)),
|
||||||
|
conic-gradient(from -60deg at 50% calc(100%/3),var(--c1) 120deg,var(--c2) 0 240deg,var(--c3) 0);
|
||||||
|
background-size: calc(var(--s)*sqrt(3)) var(--s);
|
||||||
|
}
|
||||||
|
|
||||||
|
html .app-shell.pattern-p2,
|
||||||
|
.pattern-p2 {
|
||||||
|
--s: 200px; /* control the size*/
|
||||||
|
--c1: #1d1d1d;
|
||||||
|
--c2: #4e4f51;
|
||||||
|
--c3: #3c3c3c;
|
||||||
|
|
||||||
|
background:
|
||||||
|
repeating-conic-gradient(from 30deg,#0000 0 120deg,var(--c3) 0 50%)
|
||||||
|
calc(var(--s)/2) calc(var(--s)*tan(30deg)/2),
|
||||||
|
repeating-conic-gradient(from 30deg,var(--c1) 0 60deg,var(--c2) 0 120deg,var(--c3) 0 50%);
|
||||||
|
background-size: var(--s) calc(var(--s)*tan(30deg));
|
||||||
|
}
|
||||||
|
|
||||||
|
html .app-shell.pattern-p3,
|
||||||
|
.pattern-p3 {
|
||||||
|
--s: 64px; /* control the size*/
|
||||||
|
--c1: #c02942;
|
||||||
|
--c2: #53777a;
|
||||||
|
--c3: #ecd078;
|
||||||
|
--c4: #d95b43;
|
||||||
|
|
||||||
|
background:
|
||||||
|
radial-gradient(var(--c1) 24%,#0000 25%),
|
||||||
|
radial-gradient(var(--c2) 30%,#0000 32%) calc(var(--s)/2) calc(var(--s)/2),
|
||||||
|
repeating-conic-gradient(from 30deg,var(--c3) 0 30deg,var(--c4) 0 25%);
|
||||||
|
background-size: var(--s) var(--s);
|
||||||
|
}
|
||||||
|
|
||||||
|
html .app-shell.pattern-p4,
|
||||||
|
.pattern-p4 {
|
||||||
|
--s: 100px; /* control the size*/
|
||||||
|
--c1: #fff0e5;
|
||||||
|
--c2: #025d8c;
|
||||||
|
--c3: #e1642a;
|
||||||
|
|
||||||
|
--_g: 50%,#0000 37%,var(--c1) 39% 70%,#0000 72%;
|
||||||
|
--_t: 50%,var(--c2) 40deg,var(--c3) 0 140deg,var(--c2) 0 180deg,#0000 0;
|
||||||
|
--_s: 47% 50% at;
|
||||||
|
background:
|
||||||
|
radial-gradient(var(--_s) -10% var(--_g)) 0 calc(var(--s)/2),
|
||||||
|
radial-gradient(var(--_s) -10% var(--_g)) calc(var(--s)/2) 0,
|
||||||
|
radial-gradient(var(--_s) 110% var(--_g)),
|
||||||
|
radial-gradient(var(--_s) 110% var(--_g)) calc(var(--s)/2) calc(var(--s)/2),
|
||||||
|
conic-gradient(from 0deg at 55% var(--_t)) calc(var(--s)/4) 0,
|
||||||
|
conic-gradient(from 180deg at 45% var(--_t)) calc(var(--s)/4) 0,
|
||||||
|
var(--c2);
|
||||||
|
background-size: var(--s) var(--s);
|
||||||
|
}
|
||||||
|
|
||||||
|
html .app-shell.pattern-p5,
|
||||||
|
.pattern-p5 {
|
||||||
|
--s: 150px; /* control the size*/
|
||||||
|
--c1: #f7d2a1;
|
||||||
|
--c2: #05057e;
|
||||||
|
|
||||||
|
--_g:
|
||||||
|
var(--c1) 0% 5% ,var(--c2) 6% 15%,var(--c1) 16% 25%,var(--c2) 26% 35%,var(--c1) 36% 45%,
|
||||||
|
var(--c2) 46% 55%,var(--c1) 56% 65%,var(--c2) 66% 75%,var(--c1) 76% 85%,var(--c2) 86% 95%,
|
||||||
|
#0000 96%;
|
||||||
|
background:
|
||||||
|
radial-gradient(50% 50% at 100% 0,var(--_g)),
|
||||||
|
radial-gradient(50% 50% at 0 100%,var(--_g)),
|
||||||
|
radial-gradient(50% 50%,var(--_g)),
|
||||||
|
radial-gradient(50% 50%,var(--_g)) calc(var(--s)/2) calc(var(--s)/2)
|
||||||
|
var(--c1);
|
||||||
|
background-size: var(--s) var(--s);
|
||||||
|
}
|
||||||
|
|
||||||
|
html .app-shell.pattern-p6,
|
||||||
|
.pattern-p6 {
|
||||||
|
--s: 120px; /* control the size*/
|
||||||
|
--c1: #fa6900;
|
||||||
|
--c2: #d95b43;
|
||||||
|
--c3: #ecd078;
|
||||||
|
|
||||||
|
background:
|
||||||
|
conic-gradient(from 150deg at 50% 33%,#0000,var(--c1) .5deg 60deg,#0000 60.5deg)
|
||||||
|
calc(var(--s)/2) calc(var(--s)/sqrt(2)),
|
||||||
|
conic-gradient(from -30deg at 50% 66%,#0000,var(--c2) .5deg 60deg,var(--c3) 60.5deg);
|
||||||
|
background-size: var(--s) calc(.5*var(--s)/tan(30deg));
|
||||||
|
}
|
||||||
|
|
||||||
|
html .app-shell.pattern-p7,
|
||||||
|
.pattern-p7 {
|
||||||
|
--s: 100px; /* control the size*/
|
||||||
|
--c1: #4ecdc4;
|
||||||
|
--c2: #556270;
|
||||||
|
|
||||||
|
background:
|
||||||
|
linear-gradient(atan(-.5),var(--c1) 33%,var(--c2) 33.5% 66.5%,var(--c1) 67%)
|
||||||
|
0/var(--s) var(--s);
|
||||||
|
}
|
||||||
|
|
||||||
|
html .app-shell.pattern-p8,
|
||||||
|
.pattern-p8 {
|
||||||
|
--s: 100px; /* control the size*/
|
||||||
|
--c1: #eceddc;
|
||||||
|
--c2: #29ab87;
|
||||||
|
|
||||||
|
--_g: var(--c1) 90deg,#0000 90.5deg;
|
||||||
|
background:
|
||||||
|
conic-gradient(from -45deg,var(--_g)),
|
||||||
|
conic-gradient(from 135deg,var(--_g)) calc(var(--s)/2) 0,
|
||||||
|
var(--c2);
|
||||||
|
background-size: var(--s) var(--s)
|
||||||
|
}
|
||||||
|
|
||||||
|
html .app-shell.pattern-p9,
|
||||||
|
.pattern-p9 {
|
||||||
|
--s: 150px; /* control the size*/
|
||||||
|
--c1: #ff847c;
|
||||||
|
--c2: #e84a5f;
|
||||||
|
--c3: #fecea8;
|
||||||
|
--c4: #99b898;
|
||||||
|
|
||||||
|
background:
|
||||||
|
conic-gradient(from 45deg at 75% 75%, var(--c3) 90deg,var(--c1) 0 180deg,#0000 0),
|
||||||
|
conic-gradient(from -45deg at 25% 25%, var(--c3) 90deg,#0000 0),
|
||||||
|
conic-gradient(from -45deg at 50% 100%,#0000 180deg,var(--c3) 0),
|
||||||
|
conic-gradient(from -45deg,var(--c1) 90deg, var(--c2) 0 225deg,var(--c4) 0);
|
||||||
|
background-size: var(--s) var(--s);
|
||||||
|
}
|
||||||
|
|
||||||
|
html .app-shell.pattern-p10,
|
||||||
|
.pattern-p10 {
|
||||||
|
--s: 222px; /* control the size*/
|
||||||
|
--c1: #7f727b;
|
||||||
|
--c2: #d6b4c2;
|
||||||
|
--c3: #baa0ab;
|
||||||
|
|
||||||
|
--_g: var(--c1) 10%,var(--c2) 10.5% 19%,#0000 19.5% 80.5%,var(--c2) 81% 89.5%,var(--c3) 90%;
|
||||||
|
--_c: from -90deg at 37.5% 50%,#0000 75%;
|
||||||
|
--_l1: linear-gradient(145deg,var(--_g));
|
||||||
|
--_l2: linear-gradient( 35deg,var(--_g));
|
||||||
|
background:
|
||||||
|
var(--_l1), var(--_l1) calc(var(--s)/2) var(--s),
|
||||||
|
var(--_l2), var(--_l2) calc(var(--s)/2) var(--s),
|
||||||
|
conic-gradient(var(--_c),var(--c1) 0) calc(var(--s)/8) 0,
|
||||||
|
conic-gradient(var(--_c),var(--c3) 0) calc(var(--s)/2) 0,
|
||||||
|
linear-gradient(90deg,var(--c3) 38%,var(--c1) 0 50%,var(--c3) 0 62%,var(--c1) 0);
|
||||||
|
background-size: var(--s) calc(2*var(--s)/3);
|
||||||
|
}
|
||||||
|
|
||||||
|
html .app-shell.pattern-p11,
|
||||||
|
.pattern-p11 {
|
||||||
|
--s: 20px; /* control the size*/
|
||||||
|
--c1: #c02942;
|
||||||
|
--c2: #53777a;
|
||||||
|
--c3: #ecd078;
|
||||||
|
|
||||||
|
--g: var(--s); /* gap between lines */
|
||||||
|
background:
|
||||||
|
conic-gradient(at var(--s) calc(100% - var(--s)),#0000 270deg,var(--c1) 0) calc(var(--s) + var(--g)) 0,
|
||||||
|
linear-gradient(var(--c2) var(--s),#0000 0) 0 var(--g),
|
||||||
|
conic-gradient(at var(--s) calc(100% - var(--s)),#0000 90deg,var(--c2) 0 180deg, var(--c1) 0),
|
||||||
|
var(--c3);
|
||||||
|
background-size: calc(2*(var(--s) + var(--g))) calc(2*(var(--s) + var(--g)));
|
||||||
|
}
|
||||||
|
|
||||||
|
html .app-shell.pattern-p12,
|
||||||
|
.pattern-p12 {
|
||||||
|
--s: 200px; /* control the size*/
|
||||||
|
--c1: #ffffff;
|
||||||
|
--c2: #1095c1;
|
||||||
|
|
||||||
|
--_g: #0000 8%,var(--c1) 0 17%,#0000 0 58%;
|
||||||
|
background:
|
||||||
|
linear-gradient(135deg,#0000 20.5%,var(--c1) 0 29.5%,#0000 0) 0 calc(var(--s)/4),
|
||||||
|
linear-gradient( 45deg,var(--_g)) calc(var(--s)/2) 0,
|
||||||
|
linear-gradient(135deg,var(--_g),var(--c1) 0 67%,#0000 0),
|
||||||
|
linear-gradient( 45deg,var(--_g),var(--c1) 0 67%,#0000 0 83%,var(--c1) 0 92%,#0000 0),
|
||||||
|
var(--c2);
|
||||||
|
background-size: var(--s) var(--s);
|
||||||
|
}
|
||||||
|
|
||||||
|
html .app-shell.pattern-p13,
|
||||||
|
.pattern-p13 {
|
||||||
|
--s: 64px; /* control the size*/
|
||||||
|
--c1: #e0e4cc;
|
||||||
|
--c2: #69d2e7;
|
||||||
|
|
||||||
|
--_g: 90deg,#0000 0;
|
||||||
|
background:
|
||||||
|
conic-gradient(from 135deg,var(--c1) var(--_g)) var(--s) calc(var(--s)/2),
|
||||||
|
conic-gradient(from 135deg,var(--c2) var(--_g)),
|
||||||
|
conic-gradient(from 135deg at 50% 0,var(--c1) var(--_g)) var(--c2);
|
||||||
|
background-size: calc(2*var(--s)) var(--s);
|
||||||
|
}
|
||||||
|
|
||||||
|
html .app-shell.pattern-p14,
|
||||||
|
.pattern-p14 {
|
||||||
|
--s: 64px; /* control the size*/
|
||||||
|
--c1: #eb6841;
|
||||||
|
--c2: #edc951;
|
||||||
|
|
||||||
|
background:
|
||||||
|
conic-gradient(from -45deg,var(--c1) 90deg,#0000 0 180deg,var(--c2) 0 270deg,#0000 0)
|
||||||
|
0 calc(var(--s)/2)/var(--s) var(--s),
|
||||||
|
conic-gradient(from 135deg at 50% 0,var(--c1) 90deg,var(--c2) 0)
|
||||||
|
0 0/calc(2*var(--s)) var(--s);
|
||||||
|
}
|
||||||
|
|
||||||
|
html .app-shell.pattern-p15,
|
||||||
|
.pattern-p15 {
|
||||||
|
--s: 36px; /* control the size*/
|
||||||
|
--c1: #b62f31;
|
||||||
|
--c2: #ecdacb;
|
||||||
|
--c3: #8e1f08;
|
||||||
|
|
||||||
|
--_g: calc(2*var(--s)*1.732) calc(2*var(--s))
|
||||||
|
conic-gradient(from 60deg at 62.5% 50%,var(--c3) 60deg,#0000 0);
|
||||||
|
background:
|
||||||
|
calc( 2.598*var(--s)/2) calc(var(--s)/ 2)/var(--_g),
|
||||||
|
calc(-0.866*var(--s)/2) calc(var(--s)/-2)/var(--_g),
|
||||||
|
repeating-conic-gradient(var(--c2) 0 90deg,#0000 0 180deg)
|
||||||
|
0 0/calc(2*var(--s)*1.732) calc(2*var(--s)),
|
||||||
|
repeating-conic-gradient(from 60deg,var(--c1) 0 60deg,var(--c2) 0 180deg)
|
||||||
|
0 0/calc(var(--s)*1.732) var(--s);
|
||||||
|
}
|
||||||
|
|
||||||
|
html .app-shell.pattern-p16,
|
||||||
|
.pattern-p16 {
|
||||||
|
--s: 125px; /* control the size*/
|
||||||
|
--c1: #f8ca00;
|
||||||
|
--c2: #bd1550;
|
||||||
|
|
||||||
|
--_g: var(--c1) 90deg,var(--c2) 0 135deg,#0000 0;
|
||||||
|
background:
|
||||||
|
conic-gradient(from -45deg at calc(100%/3) calc(100%/3) ,var(--c1) 90deg,#0000 0 ),
|
||||||
|
conic-gradient(from -135deg at calc(100%/3) calc(2*100%/3),var(--_g)),
|
||||||
|
conic-gradient(from 135deg at calc(2*100%/3) calc(2*100%/3),var(--_g)),
|
||||||
|
conic-gradient(from 45deg at calc(2*100%/3) calc(100%/3) ,var(--_g),var(--c1) 0 225deg,var(--c2) 0);
|
||||||
|
background-size: var(--s) var(--s);
|
||||||
|
}
|
||||||
|
|
||||||
|
html .app-shell.pattern-p17,
|
||||||
|
.pattern-p17 {
|
||||||
|
--s: 120px; /* control the size*/
|
||||||
|
--c1: #72e21f;
|
||||||
|
--c2: #044012;
|
||||||
|
|
||||||
|
--_c: var(--c1) 25%,#0000 0;
|
||||||
|
--_g1: conic-gradient(at 62.5% 12.5%,var(--_c));
|
||||||
|
--_g2: conic-gradient(at 87.5% 62.5%,var(--_c));
|
||||||
|
--_g3: conic-gradient(at 25% 12.5%,var(--_c));
|
||||||
|
background:
|
||||||
|
var(--_g1) calc( var(--s)/-8) calc(var(--s)/2),var(--_g1) calc(-3*var(--s)/8) calc(var(--s)/4),
|
||||||
|
var(--_g2) calc(3*var(--s)/8) calc(var(--s)/4),var(--_g2) calc( var(--s)/-8) 0,
|
||||||
|
var(--_g3) 0 calc(var(--s)/-4),var(--_g3) calc(var(--s)/-4) 0,
|
||||||
|
conic-gradient(at 87.5% 87.5%,var(--_c)) calc(var(--s)/8) 0
|
||||||
|
var(--c2);
|
||||||
|
background-size: var(--s) var(--s);
|
||||||
|
}
|
||||||
|
|
||||||
|
html .app-shell.pattern-p18,
|
||||||
|
.pattern-p18 {
|
||||||
|
--s: 170px; /* control the size*/
|
||||||
|
--c1: #3b8686;
|
||||||
|
--c2: #cff09e;
|
||||||
|
|
||||||
|
--_c: #0000 75%,var(--c1) 0;
|
||||||
|
--_g1: conic-gradient(at 20.71% 50%,var(--_c));
|
||||||
|
--_g2: conic-gradient(at 50% 20.71%,var(--_c));
|
||||||
|
background:
|
||||||
|
var(--_g1) calc(var(--s)/ 6.828) calc(var(--s)/2),var(--_g1) calc(var(--s)/-2.828) 0,
|
||||||
|
conic-gradient(from 45deg at 29.29% 29.29%,var(--_c)) calc(var(--s)/-6.828) 0,
|
||||||
|
var(--_g2) calc(var(--s)/2) calc(var(--s)/-2.828),var(--_g2) 0 calc(var(--s)/ 6.828),
|
||||||
|
conic-gradient(from -135deg at 29.29% 70.71%,var(--_c)) calc(var(--s)/-6.828) 0,
|
||||||
|
var(--c2);
|
||||||
|
background-size: var(--s) var(--s), var(--s) var(--s), calc(var(--s)/2) calc(var(--s)/2);
|
||||||
|
}
|
||||||
|
|
||||||
|
html .app-shell.pattern-p19,
|
||||||
|
.pattern-p19 {
|
||||||
|
--s: 76px; /* control the size*/
|
||||||
|
--c1: #f6d86b;
|
||||||
|
--c2: #f10c49;
|
||||||
|
|
||||||
|
background:
|
||||||
|
conic-gradient(
|
||||||
|
var(--c2) atan(2), var(--c1) 0 calc(180deg - atan(2)),
|
||||||
|
var(--c2) 0 180deg,var(--c1) 0 calc(180deg + atan(2)),
|
||||||
|
var(--c2) 0 calc(360deg - atan(2)),var(--c1) 0);
|
||||||
|
background-size: calc(2*var(--s)) var(--s);
|
||||||
|
}
|
||||||
|
|
||||||
|
html .app-shell.pattern-p20,
|
||||||
|
.pattern-p20 {
|
||||||
|
--s: 40px; /* control the size*/
|
||||||
|
--c1: #c0d860;
|
||||||
|
--c2: #604848;
|
||||||
|
|
||||||
|
--_c: #0000 75%,var(--c1) 0;
|
||||||
|
--_g1: conic-gradient(at 10% 50%,var(--_c));
|
||||||
|
--_g2: conic-gradient(at 50% 10%,var(--_c));
|
||||||
|
background:
|
||||||
|
var(--_g1),
|
||||||
|
var(--_g1) calc(1*var(--s)) calc(3*var(--s)),
|
||||||
|
var(--_g1) calc(2*var(--s)) calc(1*var(--s)),
|
||||||
|
var(--_g1) calc(3*var(--s)) calc(4*var(--s)),
|
||||||
|
var(--_g1) calc(4*var(--s)) calc(2*var(--s)),
|
||||||
|
var(--_g2) 0 calc(4*var(--s)),
|
||||||
|
var(--_g2) calc(1*var(--s)) calc(2*var(--s)),
|
||||||
|
var(--_g2) calc(2*var(--s)) 0,
|
||||||
|
var(--_g2) calc(3*var(--s)) calc(3*var(--s)),
|
||||||
|
var(--_g2) calc(4*var(--s)) calc(1*var(--s)),
|
||||||
|
var(--c2);
|
||||||
|
background-size: calc(5*var(--s)) calc(5*var(--s));
|
||||||
|
}
|
||||||
|
|
||||||
|
html .app-shell.pattern-p21,
|
||||||
|
.pattern-p21 {
|
||||||
|
--s: 65px; /* control the size*/
|
||||||
|
--c1: #dadee1;
|
||||||
|
--c2: #4a99b4;
|
||||||
|
--c3: #9cceb5;
|
||||||
|
|
||||||
|
--_c: 75%,var(--c3) 52.72deg,#0000 0;
|
||||||
|
--_g1: conic-gradient(from -116.36deg at 25% var(--_c));
|
||||||
|
--_g2: conic-gradient(from 63.43deg at 75% var(--_c));
|
||||||
|
background:
|
||||||
|
var(--_g1), var(--_g1) calc(3*var(--s)) calc(var(--s)/2),
|
||||||
|
var(--_g2), var(--_g2) calc(3*var(--s)) calc(var(--s)/2),
|
||||||
|
conic-gradient(
|
||||||
|
var(--c2) 63.43deg ,var(--c1) 0 116.36deg,
|
||||||
|
var(--c2) 0 180deg ,var(--c1) 0 243.43deg,
|
||||||
|
var(--c2) 0 296.15deg,var(--c1) 0);
|
||||||
|
background-size: calc(2*var(--s)) var(--s);
|
||||||
|
}
|
||||||
|
|
||||||
|
html .app-shell.pattern-p22,
|
||||||
|
.pattern-p22 {
|
||||||
|
--s: 82px; /* control the size*/
|
||||||
|
--c1: #b2b2b2;
|
||||||
|
--c2: #ffffff;
|
||||||
|
--c3: #d9d9d9;
|
||||||
|
|
||||||
|
--_g: var(--c3) 0 120deg,#0000 0;
|
||||||
|
background:
|
||||||
|
conic-gradient(from -60deg at 50% calc(100%/3),var(--_g)),
|
||||||
|
conic-gradient(from 120deg at 50% calc(200%/3),var(--_g)),
|
||||||
|
conic-gradient(from 60deg at calc(200%/3),var(--c3) 60deg,var(--c2) 0 120deg,#0000 0),
|
||||||
|
conic-gradient(from 180deg at calc(100%/3),var(--c1) 60deg,var(--_g)),
|
||||||
|
linear-gradient(90deg,var(--c1) calc(100%/6),var(--c2) 0 50%,
|
||||||
|
var(--c1) 0 calc(500%/6),var(--c2) 0);
|
||||||
|
background-size: calc(1.732*var(--s)) var(--s);
|
||||||
|
}
|
||||||
|
|
||||||
|
html .app-shell.pattern-p23,
|
||||||
|
.pattern-p23 {
|
||||||
|
--s: 50px; /* control the size*/
|
||||||
|
--c1: #0b2e59;
|
||||||
|
--c2: #0d6759;
|
||||||
|
|
||||||
|
background:
|
||||||
|
conic-gradient(at 25% 25%,#0000 75%,var(--c1) 0) var(--s) var(--s),
|
||||||
|
repeating-conic-gradient(at 25% 25%,var(--c1) 0 25%,var(--c2) 0 50%);
|
||||||
|
background-size: calc(2*var(--s)) calc(2*var(--s));
|
||||||
|
}
|
||||||
|
|
||||||
|
html .app-shell.pattern-p24,
|
||||||
|
.pattern-p24 {
|
||||||
|
--s: 100px; /* control the size*/
|
||||||
|
--c1: #1bb0ce;
|
||||||
|
--c2: #dad6ca;
|
||||||
|
|
||||||
|
--_g1:
|
||||||
|
var(--c1) calc(25%/3) ,#0000 0 calc(50%/3),
|
||||||
|
var(--c1) 0 25% ,#0000 0 75%,
|
||||||
|
var(--c1) 0 calc(250%/3),#0000 0 calc(275%/3),
|
||||||
|
var(--c1) 0;
|
||||||
|
--_g2:
|
||||||
|
#0000 calc(25%/3) ,var(--c1) 0 calc(50%/3),
|
||||||
|
#0000 0 calc(250%/3),var(--c1) 0 calc(275%/3),
|
||||||
|
#0000 0;
|
||||||
|
background:
|
||||||
|
linear-gradient( 45deg,var(--_g2)),linear-gradient( 45deg,var(--_g1)),
|
||||||
|
linear-gradient(-45deg,var(--_g2)),linear-gradient(-45deg,var(--_g1))
|
||||||
|
var(--c2); /* second color */
|
||||||
|
background-position: 0 0,var(--s) var(--s);
|
||||||
|
background-size: calc(2*var(--s)) calc(2*var(--s));
|
||||||
|
}
|
||||||
|
|
||||||
|
html .app-shell.pattern-p25,
|
||||||
|
.pattern-p25 {
|
||||||
|
--s: 100px; /* control the size*/
|
||||||
|
--c1: #c3ccaf;
|
||||||
|
--c2: #67434f;
|
||||||
|
|
||||||
|
--_s: calc(2*var(--s)) calc(2*var(--s));
|
||||||
|
--_g: var(--_s) conic-gradient(at 40% 40%,#0000 75%,var(--c1) 0);
|
||||||
|
--_p: var(--_s) conic-gradient(at 20% 20%,#0000 75%,var(--c2) 0);
|
||||||
|
background:
|
||||||
|
calc( .9*var(--s)) calc( .9*var(--s))/var(--_p),
|
||||||
|
calc(-.1*var(--s)) calc(-.1*var(--s))/var(--_p),
|
||||||
|
calc( .7*var(--s)) calc( .7*var(--s))/var(--_g),
|
||||||
|
calc(-.3*var(--s)) calc(-.3*var(--s))/var(--_g),
|
||||||
|
conic-gradient(from 90deg at 20% 20%,var(--c2) 25%,var(--c1) 0)
|
||||||
|
0 0/var(--s) var(--s);
|
||||||
|
}
|
||||||
|
|
||||||
|
html .app-shell.pattern-p26,
|
||||||
|
.pattern-p26 {
|
||||||
|
--s: 106px; /* control the size*/
|
||||||
|
--c1: #eafde6;
|
||||||
|
--c2: #519548;
|
||||||
|
|
||||||
|
background:
|
||||||
|
conic-gradient(from 30deg at 87.5% 75%,var(--c1) 60deg,var(--c2) 0 120deg,#0000 0) 0 calc(.2165*var(--s)),
|
||||||
|
conic-gradient(from -90deg at 50% 25%,var(--c2) 60deg,var(--c1) 0 180deg,#0000 0),
|
||||||
|
conic-gradient(from 90deg at 50% 75%,var(--c2) 120deg,var(--c1) 0 180deg,#0000 0),
|
||||||
|
conic-gradient(from -30deg at 87.5% 50%,var(--c2) 120deg,var(--c1) 0 240deg,#0000 0),
|
||||||
|
conic-gradient(from 90deg at 37.5% 50%,var(--c2) 120deg,var(--c1) 0 180deg,var(--c2) 0 240deg,var(--c1) 0);
|
||||||
|
background-size: var(--s) calc(.866*var(--s));
|
||||||
|
}
|
||||||
|
|
||||||
|
html .app-shell.pattern-p27,
|
||||||
|
.pattern-p27 {
|
||||||
|
--s: 60px; /* control the size*/
|
||||||
|
--c1: #b09f79;
|
||||||
|
--c2: #476074;
|
||||||
|
|
||||||
|
--_g: #0000 83%,var(--c1) 85% 99%,#0000 101%;
|
||||||
|
background:
|
||||||
|
radial-gradient(27% 29% at right ,var(--_g)) calc(var(--s)/ 2) var(--s),
|
||||||
|
radial-gradient(27% 29% at left ,var(--_g)) calc(var(--s)/-2) var(--s),
|
||||||
|
radial-gradient(29% 27% at top ,var(--_g)) 0 calc(var(--s)/ 2),
|
||||||
|
radial-gradient(29% 27% at bottom,var(--_g)) 0 calc(var(--s)/-2)
|
||||||
|
var(--c2);
|
||||||
|
background-size: calc(2*var(--s)) calc(2*var(--s));
|
||||||
|
}
|
||||||
|
|
||||||
|
html .app-shell.pattern-p28,
|
||||||
|
.pattern-p28 {
|
||||||
|
--s: 80px; /* control the size*/
|
||||||
|
--c1: #5e8c6a;
|
||||||
|
--c2: #bfb35a;
|
||||||
|
|
||||||
|
--_s: calc(2*var(--s)) calc(2*var(--s));
|
||||||
|
--_g: 35.36% 35.36% at;
|
||||||
|
--_c: #0000 66%,var(--c2) 68% 70%,#0000 72%;
|
||||||
|
background:
|
||||||
|
radial-gradient(var(--_g) 100% 25%,var(--_c)) var(--s) var(--s)/var(--_s),
|
||||||
|
radial-gradient(var(--_g) 0 75%,var(--_c)) var(--s) var(--s)/var(--_s),
|
||||||
|
radial-gradient(var(--_g) 100% 25%,var(--_c)) 0 0/var(--_s),
|
||||||
|
radial-gradient(var(--_g) 0 75%,var(--_c)) 0 0/var(--_s),
|
||||||
|
repeating-conic-gradient(var(--c1) 0 25%,#0000 0 50%) 0 0/var(--_s),
|
||||||
|
radial-gradient(var(--_c)) 0 calc(var(--s)/2)/var(--s) var(--s)
|
||||||
|
var(--c1);
|
||||||
|
}
|
||||||
|
|
||||||
|
html .app-shell.pattern-p29,
|
||||||
|
.pattern-p29 {
|
||||||
|
--s: 150px; /* control the size*/
|
||||||
|
--c1: #ccbf82;
|
||||||
|
--c2: #604848;
|
||||||
|
|
||||||
|
--_g: var(--c1) 6.1%,var(--c2) 6.4% 18.6%,var(--c1) 18.9% 31.1%,
|
||||||
|
var(--c2) 31.4% 43.6%,var(--c1) 43.9% 56.1%,var(--c2) 56.4% 68.6%,#0000 68.9%;
|
||||||
|
background:
|
||||||
|
radial-gradient(var(--s) at 100% 0 ,var(--_g)),
|
||||||
|
radial-gradient(var(--s) at 0 0 ,var(--_g)),
|
||||||
|
radial-gradient(var(--s) at 0 100%,var(--_g)),
|
||||||
|
radial-gradient(var(--s) at 100% 100%,var(--_g)) var(--c1);
|
||||||
|
background-size: var(--s) var(--s);
|
||||||
|
}
|
||||||
|
|
||||||
|
html .app-shell.pattern-p30,
|
||||||
|
.pattern-p30 {
|
||||||
|
--s: 150px; /* control the size*/
|
||||||
|
--c1: #046d8b;
|
||||||
|
--c2: #2fb8ac;
|
||||||
|
|
||||||
|
--_g: #0000 90deg,var(--c1) 0;
|
||||||
|
background:
|
||||||
|
conic-gradient(from 116.56deg at calc(100%/3) 0 ,var(--_g)),
|
||||||
|
conic-gradient(from -63.44deg at calc(200%/3) 100%,var(--_g))
|
||||||
|
var(--c2);
|
||||||
|
background-size: var(--s) var(--s);
|
||||||
|
}
|
||||||
|
|
||||||
|
html .app-shell.pattern-p31,
|
||||||
|
.pattern-p31 {
|
||||||
|
--s: 60px; /* control the size*/
|
||||||
|
--c1: #ffdc56;
|
||||||
|
--c2: #fe6601;
|
||||||
|
--c3: #803201;
|
||||||
|
|
||||||
|
--_s: calc(2*var(--s)) calc(2*var(--s));
|
||||||
|
--_g1: var(--_s) conic-gradient(at calc(500%/6) 50%,var(--c3) 25%,#0000 0);
|
||||||
|
--_g2: var(--_s) conic-gradient(at calc(200%/3) 50%,var(--c2) 25%,#0000 0);
|
||||||
|
background:
|
||||||
|
var(--s) var(--s)/var(--_g1),0 0/var(--_g1),
|
||||||
|
var(--s) var(--s)/var(--_g2),0 0/var(--_g2),
|
||||||
|
repeating-conic-gradient(var(--c1) 0 25%,#0000 0 50%) 0 0/var(--_s),
|
||||||
|
linear-gradient(var(--c1) calc(100%/3),var(--c2) 0 calc(200%/3),var(--c3) 0)
|
||||||
|
0 0/var(--s) var(--s);
|
||||||
|
}
|
||||||
|
|
||||||
|
html .app-shell.pattern-p32,
|
||||||
|
.pattern-p32 {
|
||||||
|
--s: 50px; /* control the size*/
|
||||||
|
--c1: #6963bb;
|
||||||
|
--c2: #99ffc0;
|
||||||
|
--c3: #ffd6d6;
|
||||||
|
|
||||||
|
--_g: conic-gradient(at 50% 25%,#0000 75%,var(--c1) 0);
|
||||||
|
background:
|
||||||
|
var(--_g),var(--_g) var(--s) var(--s),
|
||||||
|
var(--_g) calc(2*var(--s)) calc(2*var(--s)),
|
||||||
|
var(--_g) calc(3*var(--s)) calc(3*var(--s)),
|
||||||
|
repeating-linear-gradient(135deg,var(--c2) 0 12.5%,var(--c3) 0 25%);
|
||||||
|
background-size: calc(4*var(--s)) calc(4*var(--s))
|
||||||
|
}
|
||||||
|
|
||||||
|
html .app-shell.pattern-p33,
|
||||||
|
.pattern-p33 {
|
||||||
|
--s: 100px; /* control the size*/
|
||||||
|
--c1: #668284;
|
||||||
|
--c2: #b6d8c0;
|
||||||
|
--c3: #b9d7d9;
|
||||||
|
|
||||||
|
--_g: #0000, var(--c1) 1deg 30deg,var(--c2) 31deg 89deg,var(--c1) 90deg 119deg,#0000 120deg;
|
||||||
|
background:
|
||||||
|
conic-gradient(from -60deg at 50% 28.86%,var(--_g)),
|
||||||
|
conic-gradient(from 30deg at 71.14% 50%,var(--_g)),
|
||||||
|
conic-gradient(from 120deg at 50% 71.14%,var(--_g)),
|
||||||
|
conic-gradient(from 210deg at 28.86% 50%,var(--_g))
|
||||||
|
var(--c3);
|
||||||
|
background-size: var(--s) var(--s);
|
||||||
|
}
|
||||||
|
|
||||||
|
html .app-shell.pattern-p34,
|
||||||
|
.pattern-p34 {
|
||||||
|
--s: 100px; /* control the size*/
|
||||||
|
--c1: #fcd036;
|
||||||
|
--c2: #987f69;
|
||||||
|
|
||||||
|
--_g: var(--c1) 0 100%,#0000 102%;
|
||||||
|
background:
|
||||||
|
conic-gradient(#0000 75%,var(--_g)) calc(var(--s)/4) calc(var(--s)/4),
|
||||||
|
radial-gradient(65% 65% at 50% -50%,var(--_g)),
|
||||||
|
radial-gradient(65% 65% at -50% 50%,var(--_g)),
|
||||||
|
radial-gradient(65% 65% at 50% 150%,var(--_g)),
|
||||||
|
radial-gradient(65% 65% at 150% 50%,var(--_g))
|
||||||
|
var(--c2);
|
||||||
|
background-size: var(--s) var(--s);
|
||||||
|
}
|
||||||
|
|
||||||
|
html .app-shell.pattern-p35,
|
||||||
|
.pattern-p35 {
|
||||||
|
--s: 76px; /* control the size*/
|
||||||
|
--c1: #1a144a;
|
||||||
|
--c2: #2eb044;
|
||||||
|
|
||||||
|
--_g: conic-gradient(var(--c1) 25%,#0000 0) 0 0;
|
||||||
|
background:
|
||||||
|
var(--_g)/calc(2*var(--s)) calc(var(--s)/9.5),
|
||||||
|
var(--_g)/calc(var(--s)/9.5) calc(2*var(--s)),
|
||||||
|
repeating-conic-gradient(#0000 0 25%,var(--c1) 0 50%)
|
||||||
|
var(--s) 0 /calc(2*var(--s)) calc(2*var(--s)),
|
||||||
|
radial-gradient(50% 50%,var(--c2) 98%,var(--c1))
|
||||||
|
0 0/var(--s) var(--s);
|
||||||
|
}
|
||||||
|
|
||||||
|
html .app-shell.pattern-p36,
|
||||||
|
.pattern-p36 {
|
||||||
|
--s: 70px; /* control the size*/
|
||||||
|
--c1: #6b5344;
|
||||||
|
--c2: #f8ecc9;
|
||||||
|
|
||||||
|
--_l: #0000 46%,var(--c1) 47% 53%,#0000 54%;
|
||||||
|
background:
|
||||||
|
radial-gradient(100% 100% at 100% 100%,var(--_l)) var(--s) var(--s),
|
||||||
|
radial-gradient(100% 100% at 0 0 ,var(--_l)) var(--s) var(--s),
|
||||||
|
radial-gradient(100% 100%,#0000 22%,var(--c1) 23% 29%, #0000 30% 34%,var(--c1) 35% 41%,#0000 42%),
|
||||||
|
var(--c2);
|
||||||
|
background-size: calc(var(--s)*2) calc(var(--s)*2);
|
||||||
|
}
|
||||||
|
|
||||||
|
html .app-shell.pattern-p37,
|
||||||
|
.pattern-p37 {
|
||||||
|
--s: 80px; /* control the size*/
|
||||||
|
--c1: #542437;
|
||||||
|
--c2: #c02942;
|
||||||
|
|
||||||
|
--_g:
|
||||||
|
#0000 calc(-650%/13) calc(50%/13),var(--c1) 0 calc(100%/13),
|
||||||
|
#0000 0 calc(150%/13),var(--c1) 0 calc(200%/13),
|
||||||
|
#0000 0 calc(250%/13),var(--c1) 0 calc(300%/13);
|
||||||
|
--_g0: repeating-linear-gradient( 45deg,var(--_g));
|
||||||
|
--_g1: repeating-linear-gradient(-45deg,var(--_g));
|
||||||
|
background:
|
||||||
|
var(--_g0),var(--_g0) var(--s) var(--s),
|
||||||
|
var(--_g1),var(--_g1) var(--s) var(--s) var(--c2);
|
||||||
|
background-size: calc(2*var(--s)) calc(2*var(--s));
|
||||||
|
}
|
||||||
|
|
||||||
|
html .app-shell.pattern-p38,
|
||||||
|
.pattern-p38 {
|
||||||
|
--s: 56px; /* control the size*/
|
||||||
|
--c1: #3fb8af;
|
||||||
|
--c2: #ff9e9d;
|
||||||
|
|
||||||
|
--_c1: var(--c1) 99%,#0000 101%;
|
||||||
|
--_c2: var(--c2) 99%,#0000 101%;
|
||||||
|
|
||||||
|
--r:calc(var(--s)*.866);
|
||||||
|
--g0:radial-gradient(var(--s),var(--_c1));
|
||||||
|
--g1:radial-gradient(var(--s),var(--_c2));
|
||||||
|
--f:radial-gradient(var(--s) at calc(100% + var(--r)) 50%,var(--_c1));
|
||||||
|
--p:radial-gradient(var(--s) at 100% 50%,var(--_c2));
|
||||||
|
background:
|
||||||
|
var(--f) 0 calc(-5*var(--s)/2),
|
||||||
|
var(--f) calc(-2*var(--r)) calc(var(--s)/2),
|
||||||
|
var(--p) 0 calc(-2*var(--s)),
|
||||||
|
var(--g0) var(--r) calc(-5*var(--s)/2),
|
||||||
|
var(--g1) var(--r) calc( 5*var(--s)/2),
|
||||||
|
radial-gradient(var(--s) at 100% 100%,var(--_c1)) 0 calc(-1*var(--s)),
|
||||||
|
radial-gradient(var(--s) at 0% 50% ,var(--_c1)) 0 calc(-4*var(--s)),
|
||||||
|
var(--g1) calc(-1*var(--r)) calc(-7*var(--s)/2),
|
||||||
|
var(--g0) calc(-1*var(--r)) calc(-5*var(--s)/2),
|
||||||
|
var(--p) calc(-2*var(--r)) var(--s),
|
||||||
|
var(--g0) calc(-1*var(--r)) calc(var(--s)/ 2),
|
||||||
|
var(--g1) calc(-1*var(--r)) calc(var(--s)/-2),
|
||||||
|
var(--g0) 0 calc(-1*var(--s)),
|
||||||
|
var(--g1) var(--r) calc(var(--s)/-2),
|
||||||
|
var(--g0) var(--r) calc(var(--s)/ 2)
|
||||||
|
var(--c2);
|
||||||
|
background-size: calc(4*var(--r)) calc(6*var(--s));
|
||||||
|
}
|
||||||
|
|
||||||
|
html .app-shell.pattern-p39,
|
||||||
|
.pattern-p39 {
|
||||||
|
--s: 50px; /* control the size*/
|
||||||
|
--c1: #cff09e;
|
||||||
|
--c2: #0b486b;
|
||||||
|
|
||||||
|
--_g: radial-gradient(var(--c2) 49%,#0000 50%);
|
||||||
|
background:
|
||||||
|
var(--_g) calc(var(--s)/-2) calc(var(--s)/2),
|
||||||
|
repeating-conic-gradient(from 45deg,var(--c1) 0 25%,#0000 0 50%)
|
||||||
|
calc(var(--s)/2) calc(var(--s)/2),
|
||||||
|
var(--_g),var(--_g) var(--s) var(--s) var(--c1);
|
||||||
|
background-size: calc(2*var(--s)) calc(2*var(--s));
|
||||||
|
}
|
||||||
|
|
||||||
|
html .app-shell.pattern-p40,
|
||||||
|
.pattern-p40 {
|
||||||
|
--s: 75px; /* control the size*/
|
||||||
|
--c1: #1fffa9;
|
||||||
|
--c2: #a31e39;
|
||||||
|
--c3: #31570e;
|
||||||
|
|
||||||
|
--_c:#0000,var(--c1) 1deg 79deg,#0000 81deg;
|
||||||
|
--g0:conic-gradient(from 140deg at 50% 87.5% ,var(--_c));
|
||||||
|
--g1:conic-gradient(from 140deg at 50% 81.25%,var(--_c));
|
||||||
|
--g2:conic-gradient(from 140deg at 50% 75% ,var(--_c));
|
||||||
|
--g3:conic-gradient(at 10% 20%,#0000 75%,var(--c1) 0);
|
||||||
|
background:
|
||||||
|
var(--g0) 0 calc(var(--s)/-4),var(--g0) var(--s) calc(3*var(--s)/4),
|
||||||
|
var(--g1) ,var(--g1) var(--s) var(--s),
|
||||||
|
var(--g2) 0 calc(var(--s)/ 4),var(--g2) var(--s) calc(5*var(--s)/4),
|
||||||
|
var(--g3) calc( var(--s)/-10) var(--s),
|
||||||
|
var(--g3) calc(9*var(--s)/10) calc(2*var(--s)),
|
||||||
|
repeating-conic-gradient(from 45deg,var(--c2) 0 25%,var(--c3) 0 50%);
|
||||||
|
background-size: calc(2*var(--s)) calc(2*var(--s));
|
||||||
|
}
|
||||||
|
|
||||||
|
html .app-shell.pattern-p41,
|
||||||
|
.pattern-p41 {
|
||||||
|
--s: 150px; /* control the size*/
|
||||||
|
--c1: #5e412f;
|
||||||
|
--c2: #fcebb6;
|
||||||
|
|
||||||
|
background:
|
||||||
|
linear-gradient(135deg,#0000 18.75%,var(--c1) 0 31.25%,#0000 0),
|
||||||
|
repeating-linear-gradient(45deg,var(--c1) -6.25% 6.25%,var(--c2) 0 18.75%);
|
||||||
|
background-size: var(--s) var(--s);
|
||||||
|
}
|
||||||
|
|
||||||
|
html .app-shell.pattern-p42,
|
||||||
|
.pattern-p42 {
|
||||||
|
--s: 120px; /* control the size*/
|
||||||
|
--c1: #1a2030;
|
||||||
|
--c2: #0f9177;
|
||||||
|
--c3: #fdebad;
|
||||||
|
--c4: #d34434;
|
||||||
|
--c5: #b5d999;
|
||||||
|
|
||||||
|
--_g: radial-gradient(#0000 70%,var(--c1) 71%);
|
||||||
|
background:
|
||||||
|
var(--_g),var(--_g) calc(var(--s)/2) calc(var(--s)/2),
|
||||||
|
conic-gradient(var(--c2) 25%,var(--c3) 0 50%,var(--c4) 0 75%,var(--c5) 0);
|
||||||
|
background-size: var(--s) var(--s);
|
||||||
|
}
|
||||||
|
|
||||||
|
html .app-shell.pattern-p43,
|
||||||
|
.pattern-p43 {
|
||||||
|
--s: 34px; /* control the size*/
|
||||||
|
--c1: #ecbe13;
|
||||||
|
--c2: #309292;
|
||||||
|
|
||||||
|
--_g: radial-gradient(calc(var(--s)/2),var(--c1) 97%,#0000);
|
||||||
|
background:
|
||||||
|
var(--_g),var(--_g) calc(2*var(--s)) calc(2*var(--s)),
|
||||||
|
repeating-conic-gradient(from 45deg,#0000 0 25%,var(--c2) 0 50%) calc(-.707*var(--s)) calc(-.707*var(--s)),
|
||||||
|
repeating-linear-gradient(135deg,var(--c1) calc(var(--s)/-2) calc(var(--s)/2),var(--c2) 0 calc(2.328*var(--s)));
|
||||||
|
background-size: calc(4*var(--s)) calc(4*var(--s));
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
html .app-shell.pattern-p44,
|
||||||
|
.pattern-p44 {
|
||||||
|
--s: 60px; /* control the size*/
|
||||||
|
--c1: #7bb0a8;
|
||||||
|
--c2: #a7dbab;
|
||||||
|
|
||||||
|
--_c: var(--c1) calc(100% - var(--s)/2) 99%,#0000;
|
||||||
|
--_g: var(--s),#0000 calc(99% - var(--s)/2),var(--_c);
|
||||||
|
background:
|
||||||
|
radial-gradient(var(--s) at 100% var(--_g)),
|
||||||
|
radial-gradient(calc(var(--s)/4) at 50% calc(100%/3),var(--_c)) var(--s) 0,
|
||||||
|
radial-gradient(var(--s) at 0% var(--_g)) 0 calc(3*var(--s))
|
||||||
|
var(--c2);
|
||||||
|
background-size:
|
||||||
|
calc(2*var(--s)) calc(9*var(--s)/4),
|
||||||
|
calc(2*var(--s)) calc(3*var(--s)/4);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
html .app-shell.pattern-p45,
|
||||||
|
.pattern-p45 {
|
||||||
|
--s: 30px; /* control the size*/
|
||||||
|
--c1: #e5fcc2;
|
||||||
|
--c2: #45ada8;
|
||||||
|
|
||||||
|
--_s:37.5% 12.5% at 62.5%;
|
||||||
|
--_g:34% 99%,#0000 101%;
|
||||||
|
--g1:radial-gradient(var(--_s) 100%,#0000 32%,var(--c1) var(--_g));
|
||||||
|
--g2:radial-gradient(var(--_s) 0 ,#0000 32%,var(--c1) var(--_g));
|
||||||
|
--g3:radial-gradient(var(--_s) 100%,#0000 32%,var(--c2) var(--_g));
|
||||||
|
--g4:radial-gradient(var(--_s) 0 ,#0000 32%,var(--c2) var(--_g));
|
||||||
|
background:
|
||||||
|
var(--g1),
|
||||||
|
var(--g2) 0 calc(3*var(--s)),
|
||||||
|
var(--g3) var(--s) calc(3*var(--s)),
|
||||||
|
var(--g4) var(--s) calc(6*var(--s)),
|
||||||
|
var(--g1) calc(2*var(--s)) calc(6*var(--s)),
|
||||||
|
var(--g2) calc(2*var(--s)) calc(9*var(--s)),
|
||||||
|
var(--g3) calc(3*var(--s)) calc(9*var(--s)),
|
||||||
|
var(--g4) calc(3*var(--s)) 0,
|
||||||
|
repeating-linear-gradient(var(--c1) 0 25%,var(--c2) 0 50%);
|
||||||
|
background-size: calc(4*var(--s)) calc(12*var(--s));
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
html .app-shell.pattern-p46,
|
||||||
|
.pattern-p46 {
|
||||||
|
--s: 65px; /* control the size*/
|
||||||
|
--c1: #ecd078;
|
||||||
|
--c2: #0b486b;
|
||||||
|
|
||||||
|
--g: calc(var(--s)/5); /* control the gap */
|
||||||
|
--_l: #0000 calc(33% - .866*var(--g)),var(--c1) calc(33.2% - .866*var(--g)) 33%,#0000 34%;
|
||||||
|
background:
|
||||||
|
repeating-linear-gradient(var(--c1) 0 var(--g), #0000 0 50%)
|
||||||
|
0 calc(.866*var(--s) - var(--g)/2),
|
||||||
|
conic-gradient(from -150deg at var(--g) 50%,var(--c1) 120deg,#0000 0),
|
||||||
|
linear-gradient(-120deg,var(--_l)),linear-gradient( -60deg,var(--_l))
|
||||||
|
var(--c2);
|
||||||
|
background-size: var(--s) calc(3.466*var(--s))
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
html .app-shell.pattern-p47,
|
||||||
|
.pattern-p47 {
|
||||||
|
--s: 100px; /* control the size*/
|
||||||
|
--c1: #f2e9e1;
|
||||||
|
--c2: #99b2b7;
|
||||||
|
|
||||||
|
background:
|
||||||
|
repeating-conic-gradient(var(--c1) 0 45deg,var(--c2) 0 90deg)
|
||||||
|
0/var(--s) var(--s);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
html .app-shell.pattern-p48,
|
||||||
|
.pattern-p48 {
|
||||||
|
--s: 50px; /* control the size*/
|
||||||
|
--c1: #d9ceb2;
|
||||||
|
--c2: #948c75;
|
||||||
|
|
||||||
|
--_g: calc(2*var(--s)) calc(2*var(--s))
|
||||||
|
radial-gradient(25% 25%,var(--c1) 99%,#0000 101%);
|
||||||
|
background:
|
||||||
|
0 var(--s)/var(--_g),var(--s) 0/var(--_g),
|
||||||
|
radial-gradient(50% 50%,var(--c2) 97%,#0000)
|
||||||
|
calc(var(--s)/2) calc(var(--s)/2)/var(--s) var(--s),
|
||||||
|
linear-gradient(90deg,var(--c1) 50%,var(--c2) 0)
|
||||||
|
0 0/calc(2*var(--s));
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
html .app-shell.pattern-p49,
|
||||||
|
.pattern-p49 {
|
||||||
|
--s: 100px; /* control the size*/
|
||||||
|
--c1: #5a3d31;
|
||||||
|
--c2: #e5edb8;
|
||||||
|
|
||||||
|
background:
|
||||||
|
radial-gradient(#0000 50%,var(--c1) 52% 55%,var(--c2) 57%)
|
||||||
|
0 0/var(--s) var(--s),
|
||||||
|
repeating-linear-gradient(45deg,var(--c1) 0 25%,var(--c2) 0 50%)
|
||||||
|
0 0/calc(2*var(--s)) calc(2*var(--s))
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
html .app-shell.pattern-p50,
|
||||||
|
.pattern-p50 {
|
||||||
|
--s: 50px; /* control the size*/
|
||||||
|
--c1: #e08e79;
|
||||||
|
--c2: #774f38;
|
||||||
|
|
||||||
|
--c: var(--c1) 0 25%,var(--c2) 0 50%,#0000 0;
|
||||||
|
background:
|
||||||
|
conic-gradient(from 180deg,var(--c))
|
||||||
|
0/var(--s) var(--s),
|
||||||
|
repeating-conic-gradient(from 90deg,var(--c))
|
||||||
|
0/calc(3*var(--s)) calc(3*var(--s));
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
html .app-shell.pattern-p51,
|
||||||
|
.pattern-p51 {
|
||||||
|
--s: 100px; /* control the size*/
|
||||||
|
--c1: #cc2a41;
|
||||||
|
--c2: #351330;
|
||||||
|
|
||||||
|
--_g: #0000 24%,
|
||||||
|
var(--c2) 26% 34%,var(--c1) 36% 44%,
|
||||||
|
var(--c2) 46% 54%,var(--c1) 56% 64%,
|
||||||
|
var(--c2) 66% 74%,#0000 76%;
|
||||||
|
background:
|
||||||
|
radial-gradient(100% 100% at 100% 0,var(--_g)),
|
||||||
|
radial-gradient(100% 100% at 0 100%,var(--_g)),
|
||||||
|
radial-gradient(var(--c2) 14%,var(--c1) 16%)
|
||||||
|
calc(var(--s)/2) calc(var(--s)/2);
|
||||||
|
background-size: var(--s) var(--s);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
html .app-shell.pattern-p52,
|
||||||
|
.pattern-p52 {
|
||||||
|
--s: 29px; /* control the size*/
|
||||||
|
--c1: #615375;
|
||||||
|
--c2: #8eb2c5;
|
||||||
|
|
||||||
|
--g:#0000 calc(100% - var(--s)/3 - 1px),var(--c1)
|
||||||
|
calc(100% - var(--s)/3) calc(100% - 1px),#0000;
|
||||||
|
--r:calc(2.414*var(--s));
|
||||||
|
--p:calc(1.414*var(--s));
|
||||||
|
background:
|
||||||
|
radial-gradient(var(--r) at 0 0 ,var(--g)) var(--r) var(--r),
|
||||||
|
radial-gradient(var(--s) at 0 0 ,var(--g)) var(--p) var(--p),
|
||||||
|
radial-gradient(var(--r) at 0 100%,var(--g)) 0 var(--p),
|
||||||
|
radial-gradient(var(--s) at 0 100%,var(--g)) calc(-1*var(--s)) var(--r),
|
||||||
|
radial-gradient(var(--r) at 100% 0 ,var(--g)) var(--p) 0,
|
||||||
|
radial-gradient(var(--s) at 100% 0 ,var(--g)) var(--r) calc(-1*var(--s)),
|
||||||
|
radial-gradient(var(--r) at 100% 100%,var(--g)) calc(-1*var(--s)) calc(-1*var(--s)),
|
||||||
|
radial-gradient(var(--s) at 100% 100%,var(--g)) var(--c2);
|
||||||
|
background-size: calc(2*var(--r)) calc(2*var(--r));
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
html .app-shell.pattern-p53,
|
||||||
|
.pattern-p53 {
|
||||||
|
--s: 160px; /* control the size*/
|
||||||
|
--c1: #a14016;
|
||||||
|
--c2: #cfc89a;
|
||||||
|
|
||||||
|
--_g: var(--s) var(--s)
|
||||||
|
radial-gradient(var(--c1) 17%,var(--c2) 18% 35%,#0000 36.5%);
|
||||||
|
background:
|
||||||
|
calc(var(--s)/-4) calc(var(--s)/-4)/var(--_g),
|
||||||
|
calc(var(--s)/ 4) calc(var(--s)/ 4)/var(--_g),
|
||||||
|
radial-gradient(var(--c2) 34%,var(--c1) 36% 68%,#0000 70%)
|
||||||
|
0 0/calc(var(--s)/2) calc(var(--s)/2),
|
||||||
|
repeating-linear-gradient(45deg,var(--c1) -12.5% 12.5%,var(--c2) 0 37.5%)
|
||||||
|
0 0/var(--s) var(--s);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
html .app-shell.pattern-p54,
|
||||||
|
.pattern-p54 {
|
||||||
|
--s: 100px; /* control the size*/
|
||||||
|
--c1: #ffd500;
|
||||||
|
--c2: #0341ae;
|
||||||
|
|
||||||
|
background:
|
||||||
|
conic-gradient(at 50% 25%,var(--c2) 25%,#0000 0)
|
||||||
|
calc(var(--s)/-4) calc(var(--s)/-2),
|
||||||
|
conic-gradient(at 25% 25%,var(--c1) 25%,#0000 0 75%,var(--c2) 0)
|
||||||
|
calc(var(--s)/4) calc(var(--s)/-4),
|
||||||
|
conic-gradient(from -90deg at 75% 25%,var(--c2) 75%,var(--c1) 0);
|
||||||
|
background-size: var(--s) var(--s);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
html .app-shell.pattern-p55,
|
||||||
|
.pattern-p55 {
|
||||||
|
--s: 50px; /* control the size*/
|
||||||
|
--c1: #292522;
|
||||||
|
--c2: #efd9b4;
|
||||||
|
|
||||||
|
--c: #0000 75%,var(--c1) 0;
|
||||||
|
--g1: conic-gradient(at 75% 25%,var(--c));
|
||||||
|
--g2: conic-gradient(at 25% 75%,var(--c));
|
||||||
|
background:
|
||||||
|
var(--g1),var(--g1) var(--s) var(--s),
|
||||||
|
var(--g2),var(--g2) var(--s) var(--s) var(--c2);
|
||||||
|
background-size: calc(2*var(--s)) calc(2*var(--s));
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
html .app-shell.pattern-p56,
|
||||||
|
.pattern-p56 {
|
||||||
|
--s: 6px; /* control the size*/
|
||||||
|
--c1: #3b2d38;
|
||||||
|
--c2: #f02475;
|
||||||
|
|
||||||
|
--g:#0000 66%,var(--c1) 68% 98%,#0000;
|
||||||
|
background:
|
||||||
|
radial-gradient(30% 30% at 0% 30%,var(--g))
|
||||||
|
var(--s) calc(9*var(--s)),
|
||||||
|
radial-gradient(30% 30% at 100% 30%,var(--g))
|
||||||
|
var(--s) calc(-1*var(--s)),
|
||||||
|
radial-gradient(30% 30% at 30% 100%,var(--g))
|
||||||
|
calc(10*var(--s)) 0,
|
||||||
|
radial-gradient(30% 30% at 30% 0% ,var(--g))
|
||||||
|
var(--c2);
|
||||||
|
background-size: calc(20*var(--s)) calc(20*var(--s));
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
html .app-shell.pattern-p57,
|
||||||
|
.pattern-p57 {
|
||||||
|
--s: 194px; /* control the size*/
|
||||||
|
--c1: #f6edb3;
|
||||||
|
--c2: #acc4a3;
|
||||||
|
--c3: #55897c;
|
||||||
|
|
||||||
|
--_l:#0000 calc(25%/3),var(--c1) 0 25%,#0000 0;
|
||||||
|
--_g:conic-gradient(from 120deg at 50% 87.5%,var(--c1) 120deg,#0000 0);
|
||||||
|
background:
|
||||||
|
var(--_g),var(--_g) 0 calc(var(--s)/2),
|
||||||
|
conic-gradient(from 180deg at 75%,var(--c2) 60deg,#0000 0),
|
||||||
|
conic-gradient(from 60deg at 75% 75%,var(--c1) 0 60deg,#0000 0),
|
||||||
|
linear-gradient(150deg,var(--_l)) 0 calc(var(--s)/2),
|
||||||
|
conic-gradient(at 25% 25%,#0000 50%,var(--c2) 0 240deg,var(--c1) 0 300deg,var(--c2) 0),
|
||||||
|
linear-gradient(-150deg,var(--_l)) var(--c3);
|
||||||
|
background-size: calc(0.866*var(--s)) var(--s);
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
// Theme helpers — derive subtle pattern shades from a user's chosen color
|
||||||
|
// swatch. Patterns in theme-patterns.css read --c1..--c4; we override them
|
||||||
|
// with hsl variations of the user's hue so the background stays subtle.
|
||||||
|
|
||||||
|
export function hexToHsl(hex: string): { h: number; s: number; l: number } {
|
||||||
|
const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim());
|
||||||
|
if (!m) return { h: 243, s: 75, l: 66 }; // indigo fallback
|
||||||
|
const n = parseInt(m[1], 16);
|
||||||
|
const r = ((n >> 16) & 255) / 255;
|
||||||
|
const g = ((n >> 8) & 255) / 255;
|
||||||
|
const b = (n & 255) / 255;
|
||||||
|
const max = Math.max(r, g, b);
|
||||||
|
const min = Math.min(r, g, b);
|
||||||
|
const l = (max + min) / 2;
|
||||||
|
if (max === min) return { h: 0, s: 0, l: Math.round(l * 100) };
|
||||||
|
const d = max - min;
|
||||||
|
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||||
|
let h = 0;
|
||||||
|
if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
|
||||||
|
else if (max === g) h = ((b - r) / d + 2) / 6;
|
||||||
|
else h = ((r - g) / d + 4) / 6;
|
||||||
|
return { h: Math.round(h * 360), s: Math.round(s * 100), l: Math.round(l * 100) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Four subtle shades built on the swatch's hue — pastel tints distinct
|
||||||
|
// enough that the pattern motif reads against the near-white app shell,
|
||||||
|
// but soft enough to stay a background wash rather than a loud print.
|
||||||
|
// `alpha` (0..1) fades the whole motif toward the shell background — the
|
||||||
|
// app-shell keeps `background: whitesmoke` underneath, so lowering it
|
||||||
|
// blends the pattern out.
|
||||||
|
export function themeShades(
|
||||||
|
hex: string,
|
||||||
|
alpha = 1
|
||||||
|
): { c1: string; c2: string; c3: string; c4: string } {
|
||||||
|
const { h } = hexToHsl(hex);
|
||||||
|
return {
|
||||||
|
c1: `hsl(${h} 45% 93% / ${alpha})`,
|
||||||
|
c2: `hsl(${h} 50% 85% / ${alpha})`,
|
||||||
|
c3: `hsl(${h} 55% 74% / ${alpha})`,
|
||||||
|
c4: `hsl(${(h + 40) % 360} 50% 87% / ${alpha})`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pattern ids: 'p1'..'p60', '' = none. Currently showing the first 19;
|
||||||
|
// bump this to include more (the CSS file still ships all 60).
|
||||||
|
export const PATTERN_COUNT = 19;
|
||||||
|
export function patternId(n: number): string {
|
||||||
|
return `p${n}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pattern size slider — the value is the pattern's `--s` in vw units, so the
|
||||||
|
// motif scales with the viewport. E.g. 7 at a 1400px screen ≈ 98px tiles.
|
||||||
|
export const THEME_SIZE_MIN = 2;
|
||||||
|
export const THEME_SIZE_MAX = 16;
|
||||||
|
export const THEME_SIZE_DEFAULT = 7;
|
||||||
|
|
||||||
|
// Pattern opacity slider — 1 = current full visibility, down to a faint hint.
|
||||||
|
export const THEME_OPACITY_MIN = 0.05;
|
||||||
|
export const THEME_OPACITY_MAX = 1;
|
||||||
|
export const THEME_OPACITY_STEP = 0.05;
|
||||||
|
export const THEME_OPACITY_DEFAULT = 1;
|
||||||
@@ -66,6 +66,14 @@ export interface Member {
|
|||||||
updated: string;
|
updated: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Family member entry for chat @-mentions (children + parents).
|
||||||
|
export interface MentionMember {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
role: 'parent' | 'child';
|
||||||
|
}
|
||||||
|
|
||||||
export interface ChoreTemplate {
|
export interface ChoreTemplate {
|
||||||
id: string;
|
id: string;
|
||||||
famId: string;
|
famId: string;
|
||||||
@@ -84,7 +92,8 @@ export interface ChoreTemplate {
|
|||||||
export interface AssignedChore {
|
export interface AssignedChore {
|
||||||
id: string;
|
id: string;
|
||||||
famId: string;
|
famId: string;
|
||||||
memberId: string;
|
memberId: string | null; // null = shared with all members
|
||||||
|
shared?: boolean; // true = initially created as shared chore
|
||||||
templateId?: string;
|
templateId?: string;
|
||||||
frequency: Frequency;
|
frequency: Frequency;
|
||||||
type: RewardType;
|
type: RewardType;
|
||||||
@@ -137,7 +146,14 @@ export interface BonusConfig {
|
|||||||
criteriaValue?: number;
|
criteriaValue?: number;
|
||||||
period?: BonusPeriod;
|
period?: BonusPeriod;
|
||||||
status: BonusStatus;
|
status: BonusStatus;
|
||||||
|
completedBy?: string;
|
||||||
|
completedAt?: string;
|
||||||
|
targetChoreId?: string;
|
||||||
|
targetChoreIds?: Record<string, string>;
|
||||||
isPocketMoney?: boolean;
|
isPocketMoney?: boolean;
|
||||||
|
completeBy?: 'unlimited' | 'week' | 'custom';
|
||||||
|
startDate?: string;
|
||||||
|
completeByDate?: string;
|
||||||
created: string;
|
created: string;
|
||||||
updated: string;
|
updated: string;
|
||||||
}
|
}
|
||||||
@@ -182,7 +198,6 @@ export interface Settings {
|
|||||||
id: string;
|
id: string;
|
||||||
famId: string;
|
famId: string;
|
||||||
webhookUrl?: string;
|
webhookUrl?: string;
|
||||||
simulateEow?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChatMessage {
|
export interface ChatMessage {
|
||||||
@@ -211,5 +226,6 @@ export interface Session {
|
|||||||
famId: string;
|
famId: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
memberName?: string;
|
memberName?: string;
|
||||||
|
username?: string;
|
||||||
role?: string;
|
role?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
|
||||||
};
|
|
||||||
@@ -1,8 +1,13 @@
|
|||||||
import type { LayoutServerLoad } from './$types';
|
import type { LayoutServerLoad } from './$types';
|
||||||
import { getPlatformFlags } from '$lib/server/platform';
|
import { getPlatformFlags } from '$lib/server/platform';
|
||||||
|
import { scanChildSessions } from '$lib/server/session';
|
||||||
|
|
||||||
// Platform settings are public (read-only): feature flags ride along with
|
// Platform settings are public (read-only): feature flags ride along with
|
||||||
// every page's data so any component can deduce them via page.data.platformFlags.
|
// every page's data so any component can deduce them via page.data.platformFlags.
|
||||||
export const load: LayoutServerLoad = async () => {
|
export const load: LayoutServerLoad = async (event) => {
|
||||||
return { platformFlags: await getPlatformFlags() };
|
return {
|
||||||
|
platformFlags: await getPlatformFlags(),
|
||||||
|
// Children with session cookies on THIS device (shared-computer picker).
|
||||||
|
deviceChildIds: scanChildSessions(event.cookies)
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,8 +2,26 @@
|
|||||||
import './layout.css';
|
import './layout.css';
|
||||||
import favicon from '$lib/assets/favicon.svg';
|
import favicon from '$lib/assets/favicon.svg';
|
||||||
import NoticeDialog from '$lib/components/NoticeDialog.svelte';
|
import NoticeDialog from '$lib/components/NoticeDialog.svelte';
|
||||||
|
import { famStore } from '$lib/stores/fam.svelte';
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
|
|
||||||
|
// Last-resort net: PocketBase SDK fetch failures (the minified `send()`
|
||||||
|
// frame in prod stacks) otherwise surface as unhandled rejections with no
|
||||||
|
// UI signal. Network-shaped ones flip the TopNav status dot; the fam
|
||||||
|
// layout's resync clears it once the pipe is healthy again.
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.addEventListener('unhandledrejection', (e: PromiseRejectionEvent) => {
|
||||||
|
const msg = String((e.reason as any)?.message || e.reason || '');
|
||||||
|
if (
|
||||||
|
/TypeError|Failed to fetch|Load failed|NetworkError|network|offline|ERR_QUIC|ERR_NETWORK|ERR_INTERNET/i.test(
|
||||||
|
msg
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
famStore.connectionDown = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head><link rel="icon" href={favicon} /></svelte:head>
|
<svelte:head><link rel="icon" href={favicon} /></svelte:head>
|
||||||
|
|||||||
@@ -1,9 +1,32 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Footer from '$lib/components/Footer.svelte';
|
import Footer from '$lib/components/Footer.svelte';
|
||||||
|
import FamDone from '$lib/components/FamDone.svelte';
|
||||||
|
import {
|
||||||
|
homeIcon,
|
||||||
|
choresIcon,
|
||||||
|
checkCircleIcon,
|
||||||
|
chevronRightIcon,
|
||||||
|
dashboardIcon,
|
||||||
|
chatIcon,
|
||||||
|
gemIcon,
|
||||||
|
lockIcon,
|
||||||
|
coinsIcon,
|
||||||
|
shieldIcon,
|
||||||
|
laptopIcon
|
||||||
|
} from '$lib/components/icons';
|
||||||
|
import { readShortcut, shortcutTarget, type Shortcut } from '$lib/shortcut';
|
||||||
|
import CheckboxGrid from '$lib/components/CheckboxGrid.svelte';
|
||||||
|
|
||||||
|
let shortcut = $state<(Shortcut & { target: string }) | null>(null);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const s = readShortcut();
|
||||||
|
if (s) shortcut = { ...s, target: shortcutTarget(s) };
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>FamChore — Chores done. Allowance earned.</title>
|
<title>FamDone — Chores done. Allowance earned.</title>
|
||||||
<meta
|
<meta
|
||||||
name="description"
|
name="description"
|
||||||
content="Turn family chores into points and pocket money. Assign chores, kids complete them, allowances are calculated automatically."
|
content="Turn family chores into points and pocket money. Assign chores, kids complete them, allowances are calculated automatically."
|
||||||
@@ -11,21 +34,55 @@
|
|||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<main class="landing">
|
<main class="landing">
|
||||||
|
{#if shortcut}
|
||||||
|
<a class="return-chip" href={shortcut.target} title="Jump back into {shortcut.famName}">
|
||||||
|
<span class="return-icon">
|
||||||
|
{@html homeIcon}
|
||||||
|
<span class="return-dot"></span>
|
||||||
|
</span>
|
||||||
|
<span class="return-text">
|
||||||
|
<span class="return-fam">{shortcut.famName}</span>
|
||||||
|
<span class="return-sub">Jump back in</span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
<!-- HERO -->
|
<!-- HERO -->
|
||||||
<section class="hero">
|
<section class="hero">
|
||||||
<div class="hero-inner">
|
<CheckboxGrid />
|
||||||
<div class="hero-copy">
|
<div class="hero-content">
|
||||||
|
<span class="hero-kicker">The family chore app</span>
|
||||||
|
<h1 class="hero-title"><FamDone /></h1>
|
||||||
|
<p class="hero-sub">Building <span class="text-green-400">healthy habbits</span> together!</p>
|
||||||
|
<div class="hero-actions">
|
||||||
|
<a class="hero-cta" href="/signup">Create my family</a>
|
||||||
|
<a class="hero-cta-ghost" href="/pricing">See pricing</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section class="intro">
|
||||||
|
<div class="intro-inner">
|
||||||
|
<div class="intro-copy">
|
||||||
<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">Get rewarded.</span></h1>
|
||||||
<p class="lede">
|
<p class="lede">
|
||||||
FamChore turns everyday household chores into points and pocket money. Assign the
|
<FamDone /> lets you choose chores and rewards with your kids.
|
||||||
chores, let your kids see their progress live, and FamChore calculates the allowance
|
|
||||||
automatically — no spreadsheets, no nagging.
|
|
||||||
</p>
|
</p>
|
||||||
|
<p class="lede">
|
||||||
|
Because life is fun, <FamDone /> makes it easy for the tricky things become part of your daily
|
||||||
|
routine.
|
||||||
|
</p>
|
||||||
|
<!-- <p> - letting them become
|
||||||
|
empowered and take ownership of the things they forget about - You might learn a thing or two too!</p> -->
|
||||||
<ul class="hero-ticks">
|
<ul class="hero-ticks">
|
||||||
<li>Set up in under a minute</li>
|
<li><span class="tick">{@html checkCircleIcon}</span>Set up in under a minute</li>
|
||||||
<li>Kids join with a simple invite code</li>
|
<li>
|
||||||
<li>Allowance lands on payday, automatically</li>
|
<span class="tick">{@html checkCircleIcon}</span>Kids join with their own device or
|
||||||
|
family computer
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="tick">{@html checkCircleIcon}</span>Get a free trial and see if it's right
|
||||||
|
for you
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -33,71 +90,117 @@
|
|||||||
<h2>Start your family</h2>
|
<h2>Start your family</h2>
|
||||||
<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">
|
|
||||||
<a href="/pricing">See pricing →</a>
|
|
||||||
</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>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
<div class="explainer">
|
||||||
|
|
||||||
<!-- EXPLAINER -->
|
|
||||||
<section class="explainer">
|
|
||||||
<h2>How it works</h2>
|
|
||||||
<div class="steps">
|
<div class="steps">
|
||||||
<article class="step">
|
<article class="step">
|
||||||
|
<div class="step-head">
|
||||||
|
<span class="step-media">
|
||||||
|
<span class="step-icon">{@html homeIcon}</span>
|
||||||
<span class="step-num">1</span>
|
<span class="step-num">1</span>
|
||||||
|
</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>
|
</div>
|
||||||
|
<p>
|
||||||
|
Setting up chores isn't a bore! Your kids join in seconds with an <strong
|
||||||
|
>emailess</strong
|
||||||
|
> invite code.
|
||||||
|
</p>
|
||||||
</article>
|
</article>
|
||||||
<article class="step">
|
<article class="step">
|
||||||
|
<div class="step-head">
|
||||||
|
<span class="step-media">
|
||||||
|
<span class="step-icon">{@html choresIcon}</span>
|
||||||
<span class="step-num">2</span>
|
<span class="step-num">2</span>
|
||||||
|
</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>
|
</div>
|
||||||
|
<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">
|
||||||
|
<div class="step-head">
|
||||||
|
<span class="step-media">
|
||||||
|
<span class="step-icon">{@html coinsIcon}</span>
|
||||||
<span class="step-num">3</span>
|
<span class="step-num">3</span>
|
||||||
<h3>Allowance pays out</h3>
|
</span>
|
||||||
<p>Points add up, then turn into pocket money on payday. Rewards and monthly bonuses keep it fun.</p>
|
<h3>Payday!</h3>
|
||||||
|
</div>
|
||||||
|
<p>
|
||||||
|
If a kid meets their criteria - they can claim their pocket money by annoying you like a
|
||||||
|
noisey flying unicorn.
|
||||||
|
</p>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- FEATURES -->
|
|
||||||
<section class="features">
|
<section class="features">
|
||||||
<h2>Everything a family needs</h2>
|
<div class="features-inner">
|
||||||
|
<h2>The BIG Idea</h2>
|
||||||
<div class="feature-grid">
|
<div class="feature-grid">
|
||||||
<article class="feature">
|
<article class="feature" style="--ic-bg:#e0e7ff; --ic-fg:#4338ca">
|
||||||
|
<div class="feature-head">
|
||||||
|
<span class="feature-icon">{@html dashboardIcon}</span>
|
||||||
<h3>Chore board</h3>
|
<h3>Chore board</h3>
|
||||||
<p>Reusable chore templates and a simple assignment grid. Build once, reuse every week.</p>
|
</div>
|
||||||
|
<p>Simple and intuitive chore board for parents to assign tasks to their kids.</p>
|
||||||
</article>
|
</article>
|
||||||
<article class="feature">
|
<article class="feature" style="--ic-bg:#ccfbf1; --ic-fg:#0d9488">
|
||||||
<h3>Live progress</h3>
|
<div class="feature-head">
|
||||||
<p>Realtime sync across the whole family — see points and progress update the moment a chore is done.</p>
|
<span class="feature-icon">{@html chatIcon}</span>
|
||||||
|
<h3>Chat</h3>
|
||||||
|
</div>
|
||||||
|
<p>
|
||||||
|
Real-time chat for parents to communicate with their kids - so you can congratulate
|
||||||
|
them!
|
||||||
|
</p>
|
||||||
</article>
|
</article>
|
||||||
<article class="feature">
|
<article class="feature" style="--ic-bg:#ede9fe; --ic-fg:#7c3aed">
|
||||||
<h3>Automatic allowance</h3>
|
<div class="feature-head">
|
||||||
<p>Points convert to money using your family's own rules. No mental maths, no arguments.</p>
|
<span class="feature-icon">{@html shieldIcon}</span>
|
||||||
|
<h3>Trust!</h3>
|
||||||
|
</div>
|
||||||
|
<p>The whole experience is about exercising trust together.</p>
|
||||||
</article>
|
</article>
|
||||||
<article class="feature">
|
<article class="feature" style="--ic-bg:#fef3c7; --ic-fg:#d97706">
|
||||||
|
<div class="feature-head">
|
||||||
|
<span class="feature-icon">{@html gemIcon}</span>
|
||||||
<h3>Rewards & bonuses</h3>
|
<h3>Rewards & bonuses</h3>
|
||||||
<p>Kids can spend points on rewards, and you can run a monthly bonus for the top earner.</p>
|
</div>
|
||||||
|
<p>
|
||||||
|
As a parent you can dish out behaviour rewards or pocket money boosters - in a place
|
||||||
|
which is yours to create together.
|
||||||
|
</p>
|
||||||
</article>
|
</article>
|
||||||
<article class="feature">
|
<article class="feature" style="--ic-bg:#e0f2fe; --ic-fg:#0284c7">
|
||||||
<h3>Kid-friendly</h3>
|
<div class="feature-head">
|
||||||
<p>A simple, colourful interface kids love — big buttons, clear feedback, their own space.</p>
|
<span class="feature-icon">{@html laptopIcon}</span>
|
||||||
|
<h3>Phone free friendly</h3>
|
||||||
|
</div>
|
||||||
|
<p>You dont need a mobile or an app - just a browser.</p>
|
||||||
</article>
|
</article>
|
||||||
<article class="feature">
|
<article class="feature" style="--ic-bg:#e2e8f0; --ic-fg:#475569">
|
||||||
|
<div class="feature-head">
|
||||||
|
<span class="feature-icon">{@html lockIcon}</span>
|
||||||
<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>
|
</div>
|
||||||
|
<p>
|
||||||
|
We don't read your data, your messages, or sell anything - your family and rules is your
|
||||||
|
property.
|
||||||
|
</p>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
<div class="cta">
|
<div class="cta">
|
||||||
<p>Ready to make chores painless?</p>
|
<p>Ready to get the kids involved?</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 →</a></p>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
@@ -106,16 +209,182 @@
|
|||||||
|
|
||||||
<style>
|
<style>
|
||||||
.landing {
|
.landing {
|
||||||
font-family: system-ui, -apple-system, sans-serif;
|
font-family:
|
||||||
|
system-ui,
|
||||||
|
-apple-system,
|
||||||
|
sans-serif;
|
||||||
color: #1f2937;
|
color: #1f2937;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* RETURNING-USER SHORTCUT */
|
||||||
|
.return-chip {
|
||||||
|
position: absolute;
|
||||||
|
top: 1rem;
|
||||||
|
right: 1rem;
|
||||||
|
z-index: 20;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
width: fit-content;
|
||||||
|
padding: 0.45rem 0.9rem 0.45rem 0.55rem;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 999px;
|
||||||
|
text-decoration: none;
|
||||||
|
color: #1f2937;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
|
||||||
|
transition:
|
||||||
|
border-color 0.15s,
|
||||||
|
box-shadow 0.15s;
|
||||||
|
}
|
||||||
|
.return-chip:hover {
|
||||||
|
border-color: #c7d2fe;
|
||||||
|
box-shadow: 0 2px 10px rgba(67, 56, 202, 0.14);
|
||||||
|
}
|
||||||
|
.return-icon {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
color: #4338ca;
|
||||||
|
}
|
||||||
|
.return-dot {
|
||||||
|
position: absolute;
|
||||||
|
right: -3px;
|
||||||
|
bottom: -3px;
|
||||||
|
width: 11px;
|
||||||
|
height: 11px;
|
||||||
|
background: #22c55e;
|
||||||
|
border: 2px solid #fff;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
.return-text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
line-height: 1.15;
|
||||||
|
}
|
||||||
|
.return-fam {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
}
|
||||||
|
.return-sub {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
/* HERO */
|
/* HERO */
|
||||||
.hero {
|
.hero {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #1e1b4b;
|
||||||
|
color: #eef2ff;
|
||||||
|
padding: 6rem 1.5rem;
|
||||||
|
text-align: center;
|
||||||
|
clip-path: inset(0);
|
||||||
|
}
|
||||||
|
.hero::before,
|
||||||
|
.hero::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
border-radius: 50%;
|
||||||
|
filter: blur(60px);
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
.hero::before {
|
||||||
|
width: 480px;
|
||||||
|
height: 480px;
|
||||||
|
top: -180px;
|
||||||
|
left: -120px;
|
||||||
|
background: midnightblue;
|
||||||
|
}
|
||||||
|
.hero::after {
|
||||||
|
width: 420px;
|
||||||
|
height: 420px;
|
||||||
|
bottom: -160px;
|
||||||
|
right: -120px;
|
||||||
|
background: rgba(245, 158, 11, 0.25);
|
||||||
|
}
|
||||||
|
.hero-content {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
max-width: 48rem;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.hero-kicker {
|
||||||
|
display: inline-block;
|
||||||
|
background: rgba(224, 231, 255, 0.12);
|
||||||
|
border: 1px solid rgba(224, 231, 255, 0.25);
|
||||||
|
color: #c7d2fe;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
padding: 0.4rem 0.95rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
.hero-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: clamp(3rem, 8vw, 5.5rem);
|
||||||
|
line-height: 1;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
.hero-sub {
|
||||||
|
margin: 0;
|
||||||
|
max-width: 34rem;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #c7d2fe;
|
||||||
|
}
|
||||||
|
.hero-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
.hero-cta {
|
||||||
|
display: inline-block;
|
||||||
|
background: springgreen;
|
||||||
|
color: #1e1b4b;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1rem;
|
||||||
|
padding: 0.8rem 1.75rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
transition:
|
||||||
|
transform 0.15s,
|
||||||
|
box-shadow 0.15s;
|
||||||
|
}
|
||||||
|
.hero-cta:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
.hero-cta-ghost {
|
||||||
|
display: inline-block;
|
||||||
|
color: #eef2ff;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1rem;
|
||||||
|
padding: 0.8rem 1.5rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid rgba(224, 231, 255, 0.35);
|
||||||
|
transition:
|
||||||
|
background 0.15s,
|
||||||
|
border-color 0.15s;
|
||||||
|
}
|
||||||
|
.hero-cta-ghost:hover {
|
||||||
|
background: rgba(224, 231, 255, 0.1);
|
||||||
|
border-color: rgba(224, 231, 255, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* INTRO (was the original hero) */
|
||||||
|
.intro {
|
||||||
background: linear-gradient(135deg, #eef2ff 0%, #ffffff 55%, #eef2ff 100%);
|
background: linear-gradient(135deg, #eef2ff 0%, #ffffff 55%, #eef2ff 100%);
|
||||||
padding: 4.5rem 1.5rem;
|
padding: 4.5rem 1.5rem;
|
||||||
}
|
}
|
||||||
.hero-inner {
|
.intro-inner {
|
||||||
max-width: 1100px;
|
max-width: 1100px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -140,7 +409,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;
|
||||||
@@ -156,10 +427,20 @@
|
|||||||
gap: 0.6rem;
|
gap: 0.6rem;
|
||||||
}
|
}
|
||||||
.hero-ticks li::before {
|
.hero-ticks li::before {
|
||||||
content: "✓";
|
content: '';
|
||||||
color: #4338ca;
|
}
|
||||||
font-weight: 700;
|
.hero-ticks .tick {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 1.15rem;
|
||||||
|
height: 1.15rem;
|
||||||
margin-right: 0.5rem;
|
margin-right: 0.5rem;
|
||||||
|
color: #4338ca;
|
||||||
|
}
|
||||||
|
.hero-ticks .tick :global(svg) {
|
||||||
|
width: 1.1em;
|
||||||
|
height: 1.1em;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* SIGNUP CARD */
|
/* SIGNUP CARD */
|
||||||
@@ -170,9 +451,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;
|
||||||
@@ -201,7 +492,7 @@
|
|||||||
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;
|
||||||
@@ -214,19 +505,46 @@
|
|||||||
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 {
|
||||||
max-width: 1100px;
|
max-width: 1100px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 4rem 1.5rem;
|
padding: 4rem 1.5rem;
|
||||||
|
padding-top: 6em;
|
||||||
}
|
}
|
||||||
.explainer h2, .features h2 {
|
.features {
|
||||||
|
padding: 4rem 1.5rem;
|
||||||
|
}
|
||||||
|
.features-inner {
|
||||||
|
max-width: 1100px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
.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;
|
||||||
@@ -241,24 +559,87 @@
|
|||||||
border: 1px solid #e5e7eb;
|
border: 1px solid #e5e7eb;
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
|
transition:
|
||||||
|
transform 0.15s,
|
||||||
|
box-shadow 0.15s,
|
||||||
|
border-color 0.15s;
|
||||||
}
|
}
|
||||||
.step-num {
|
.step:hover {
|
||||||
|
transform: translateY(-3px);
|
||||||
|
border-color: #c7d2fe;
|
||||||
|
box-shadow: 0 12px 24px rgba(67, 56, 202, 0.1);
|
||||||
|
}
|
||||||
|
.step-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 0.6rem;
|
||||||
|
}
|
||||||
|
.step-media {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.step-icon {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 2rem;
|
width: 3rem;
|
||||||
height: 2rem;
|
height: 3rem;
|
||||||
border-radius: 50%;
|
border-radius: 12px;
|
||||||
background: #4338ca;
|
background: linear-gradient(135deg, #4338ca, #6d28d9);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-weight: 700;
|
box-shadow: 0 6px 14px rgba(67, 56, 202, 0.25);
|
||||||
margin-bottom: 0.75rem;
|
}
|
||||||
|
.step-icon :global(svg) {
|
||||||
|
width: 1.5em;
|
||||||
|
height: 1.5em;
|
||||||
|
}
|
||||||
|
.step-num {
|
||||||
|
position: absolute;
|
||||||
|
top: -0.4rem;
|
||||||
|
right: -0.4rem;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 1.3rem;
|
||||||
|
height: 1.3rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #fbbf24;
|
||||||
|
color: #1e1b4b;
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
border: 2px solid #fff;
|
||||||
|
}
|
||||||
|
.step h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.step h3::after {
|
||||||
|
content: '';
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 3px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: #4338ca;
|
||||||
|
margin-top: 0.45rem;
|
||||||
|
}
|
||||||
|
.step p {
|
||||||
|
margin: 0;
|
||||||
|
color: #4b5563;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
line-height: 1.55;
|
||||||
}
|
}
|
||||||
.step h3 { margin: 0 0 0.5rem; font-size: 1.05rem; }
|
|
||||||
.step p { margin: 0; color: #4b5563; font-size: 0.92rem; line-height: 1.55; }
|
|
||||||
|
|
||||||
/* FEATURES */
|
/* FEATURES */
|
||||||
.features { background: #f5f6fa; border-radius: 0; }
|
.features {
|
||||||
|
background: linear-gradient(170deg, #8fb6ee 0%, #dfe9fb 45%, #f5f6fa 100%);
|
||||||
|
}
|
||||||
|
.features h2 {
|
||||||
|
color: #4338ca;
|
||||||
|
}
|
||||||
.feature-grid {
|
.feature-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, 1fr);
|
grid-template-columns: repeat(3, 1fr);
|
||||||
@@ -269,11 +650,68 @@
|
|||||||
border: 1px solid #e5e7eb;
|
border: 1px solid #e5e7eb;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 1.25rem;
|
padding: 1.25rem;
|
||||||
|
transition:
|
||||||
|
transform 0.15s,
|
||||||
|
box-shadow 0.15s,
|
||||||
|
border-color 0.15s;
|
||||||
|
}
|
||||||
|
.feature:hover {
|
||||||
|
transform: translateY(-3px);
|
||||||
|
border-color: #d1d5db;
|
||||||
|
box-shadow: 0 14px 28px rgba(67, 56, 202, 0.12);
|
||||||
|
}
|
||||||
|
.feature-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 0.6rem;
|
||||||
|
}
|
||||||
|
.feature-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 2.6rem;
|
||||||
|
height: 2.6rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--ic-bg);
|
||||||
|
color: var(--ic-fg);
|
||||||
|
}
|
||||||
|
.feature-icon :global(svg) {
|
||||||
|
width: 1.35em;
|
||||||
|
height: 1.35em;
|
||||||
|
}
|
||||||
|
.feature h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: #111827;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.feature h3::after {
|
||||||
|
content: '';
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 3px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: var(--ic-fg);
|
||||||
|
margin-top: 0.45rem;
|
||||||
|
}
|
||||||
|
.feature p {
|
||||||
|
margin: 0;
|
||||||
|
color: #4b5563;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
.cta {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 2.5rem;
|
||||||
|
}
|
||||||
|
.cta p {
|
||||||
|
font-size: 1.15rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0 0 1rem;
|
||||||
}
|
}
|
||||||
.feature h3 { margin: 0 0 0.4rem; font-size: 1rem; color: #111827; }
|
|
||||||
.feature p { margin: 0; color: #4b5563; font-size: 0.9rem; line-height: 1.55; }
|
|
||||||
.cta { text-align: center; margin-top: 2.5rem; }
|
|
||||||
.cta p { font-size: 1.15rem; font-weight: 600; margin: 0 0 1rem; }
|
|
||||||
.cta-btn {
|
.cta-btn {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
background: #4338ca;
|
background: #4338ca;
|
||||||
@@ -283,12 +721,26 @@
|
|||||||
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; }
|
.intro-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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (max-width: 639px) {
|
||||||
|
h1 {
|
||||||
|
font-size: 1.5em;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -6,13 +6,44 @@
|
|||||||
<div class="error-page">
|
<div class="error-page">
|
||||||
<h1>Something went wrong</h1>
|
<h1>Something went wrong</h1>
|
||||||
<p>{error?.message || 'An unexpected error occurred.'}</p>
|
<p>{error?.message || 'An unexpected error occurred.'}</p>
|
||||||
<p class="hint">Try <a href="/logout">logging out</a> and signing in again.</p>
|
<div class="hint">
|
||||||
|
Try
|
||||||
|
<form method="POST" action="/logout" class="inline-form">
|
||||||
|
<button type="submit" class="link-button">logging out</button>
|
||||||
|
</form>
|
||||||
|
and signing in again.
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.error-page { max-width: 480px; margin: 4rem auto; text-align: center; padding: 2rem; }
|
.error-page {
|
||||||
h1 { font-size: 1.5rem; color: #111827; margin-bottom: 0.5rem; }
|
max-width: 480px;
|
||||||
p { color: #6b7280; }
|
margin: 4rem auto;
|
||||||
.hint { margin-top: 1.5rem; font-size: 0.9rem; }
|
text-align: center;
|
||||||
a { color: #6366f1; }
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: #111827;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
.hint {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.inline-form {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
.link-button {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
font: inherit;
|
||||||
|
color: #6366f1;
|
||||||
|
text-decoration: underline;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { createPbClient } from '$lib/server/pocketbase';
|
import { redirect } from '@sveltejs/kit';
|
||||||
|
import { createPbClient, createSuperClient } from '$lib/server/pocketbase';
|
||||||
import { createServices, type ChatActor } from '$lib/server/services';
|
import { createServices, type ChatActor } from '$lib/server/services';
|
||||||
import { ensureFamAccess } from '$lib/server/access';
|
import { ensureFamAccess } from '$lib/server/access';
|
||||||
|
import { seedDemoCompletions } from '$lib/server/migrate';
|
||||||
|
import { getPlatformFlags } from '$lib/server/platform';
|
||||||
|
import { scanChildSessions } from '$lib/server/session';
|
||||||
|
|
||||||
async function paydayCheck(famId: string, pbToken: string) {
|
async function paydayCheck(famId: string, pbToken: string) {
|
||||||
try {
|
try {
|
||||||
@@ -25,13 +29,16 @@ function actorFrom(session: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolveChatIdentity(session: {
|
async function resolveChatIdentity(
|
||||||
|
session: {
|
||||||
famId: string;
|
famId: string;
|
||||||
id: string;
|
id: string;
|
||||||
role: string;
|
role: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
color?: string;
|
color?: string;
|
||||||
}, pbToken: string) {
|
},
|
||||||
|
pbToken: string
|
||||||
|
) {
|
||||||
try {
|
try {
|
||||||
const s = createServices(createPbClient(pbToken));
|
const s = createServices(createPbClient(pbToken));
|
||||||
const actor = actorFrom(session);
|
const actor = actorFrom(session);
|
||||||
@@ -42,15 +49,84 @@ async function resolveChatIdentity(session: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function load(event) {
|
export async function load(event) {
|
||||||
|
// Demo family: rotate 3-week completion data on every visit when demo mode is on (idempotent).
|
||||||
|
if (event.params.fam === 'showboaters' && (await getPlatformFlags()).demo) {
|
||||||
|
seedDemoCompletions().catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
const session = event.locals.user;
|
const session = event.locals.user;
|
||||||
const role = session?.role || 'child';
|
const role = session?.role || 'child';
|
||||||
const isParent = role === 'parent';
|
const isParent = role === 'parent';
|
||||||
const pbToken = event.cookies.get('pb_token') || '';
|
const pbToken = event.locals.pbToken || '';
|
||||||
|
const deviceChildIds = scanChildSessions(event.cookies);
|
||||||
|
|
||||||
|
// ── Shared-device picker mode ──
|
||||||
|
// The device holds child sessions but none is active (per-profile logout).
|
||||||
|
// Anything except the join flow lands on the standalone picker.
|
||||||
|
const paramFam = event.params.fam;
|
||||||
|
const isJoinPage = (event.url.pathname || '').split('/').includes('join');
|
||||||
|
if (!session && deviceChildIds.length > 0 && !isJoinPage) {
|
||||||
|
if (!(event.url.pathname || '').endsWith('/switch')) {
|
||||||
|
throw redirect(303, `/${encodeURIComponent(paramFam)}/switch`);
|
||||||
|
}
|
||||||
|
let pickerFamName = paramFam || '';
|
||||||
|
let pickerChildren: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
username: string;
|
||||||
|
}[] = [];
|
||||||
|
try {
|
||||||
|
const pb = await createSuperClient();
|
||||||
|
const fam = await pb
|
||||||
|
.collection('fams')
|
||||||
|
.getFirstListItem(`slug='${paramFam}'`)
|
||||||
|
.catch(() => null);
|
||||||
|
if (fam) {
|
||||||
|
pickerFamName = fam.name || fam.slug;
|
||||||
|
const users = await pb.collection('users').getFullList({
|
||||||
|
filter: `id in ('${deviceChildIds.join("','")}') && role='child'`
|
||||||
|
});
|
||||||
|
pickerChildren = (users || []).map((u: any) => ({
|
||||||
|
id: u.id,
|
||||||
|
name: u.name || u.username || '',
|
||||||
|
color: u.color || '#6366f1',
|
||||||
|
username: u.username || ''
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
return {
|
||||||
|
famSlug: paramFam || '',
|
||||||
|
session: null,
|
||||||
|
isParent,
|
||||||
|
role,
|
||||||
|
famId: '',
|
||||||
|
chat: null,
|
||||||
|
pbToken: '',
|
||||||
|
fam: null,
|
||||||
|
famAccess: { disabled: false, mode: 'none', reason: '' },
|
||||||
|
demoMode: (await getPlatformFlags()).demo,
|
||||||
|
picker: true,
|
||||||
|
pickerFamName,
|
||||||
|
pickerChildren,
|
||||||
|
deviceChildIds,
|
||||||
|
lockMins: 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
let famId = '';
|
let famId = '';
|
||||||
let chat: { famId: string; actor: ChatActor } | null = null;
|
let chat: {
|
||||||
|
famId: string;
|
||||||
|
actor: ChatActor;
|
||||||
|
members?: { id: string; name: string; color: string; role: 'parent' | 'child' }[];
|
||||||
|
} | null = null;
|
||||||
let fam: any = null;
|
let fam: any = null;
|
||||||
let famAccess = { disabled: false, mode: 'none' as 'none' | 'code' | 'sub' | 'canceled', reason: '' };
|
let famAccess = {
|
||||||
|
disabled: false,
|
||||||
|
mode: 'none' as 'none' | 'code' | 'sub' | 'canceled',
|
||||||
|
reason: ''
|
||||||
|
};
|
||||||
|
let lockMins = 0;
|
||||||
|
|
||||||
if (session && pbToken) {
|
if (session && pbToken) {
|
||||||
famId = session.famId;
|
famId = session.famId;
|
||||||
@@ -58,23 +134,58 @@ export async function load(event) {
|
|||||||
chat = await resolveChatIdentity(session, pbToken);
|
chat = await resolveChatIdentity(session, pbToken);
|
||||||
// fams is superadmin-only (non-realtime). Fetched server-side for both
|
// fams is superadmin-only (non-realtime). Fetched server-side for both
|
||||||
// roles; also recomputes + persists the derived `active` flag.
|
// roles; also recomputes + persists the derived `active` flag.
|
||||||
const res = await ensureFamAccess(famId).catch(() => null);
|
// Retried: a post-deploy PB blip here blanks `fam`, which both hides
|
||||||
|
// data AND disables the canonical slug redirect below (letting /{id}
|
||||||
|
// URLs persist).
|
||||||
|
let res: any = null;
|
||||||
|
for (let i = 0; i < 3 && !res; i++) {
|
||||||
|
if (i > 0) await new Promise((r) => setTimeout(r, 400 * i));
|
||||||
|
res = await ensureFamAccess(famId).catch(() => null);
|
||||||
|
}
|
||||||
if (res) {
|
if (res) {
|
||||||
fam = res.fam;
|
fam = res.fam;
|
||||||
famAccess = res.access;
|
famAccess = res.access;
|
||||||
}
|
}
|
||||||
|
// Shared-device idle lock setting (0 = off; missing row defaults to 10
|
||||||
|
// min). Superuser read — the settings rules are parent-oriented and
|
||||||
|
// children must see it too.
|
||||||
|
try {
|
||||||
|
const pb = await createSuperClient();
|
||||||
|
const settings = await pb
|
||||||
|
.collection('settings')
|
||||||
|
.getFullList({ filter: `famId='${famId}'` })
|
||||||
|
.catch(() => []);
|
||||||
|
const row = (settings as any[])?.[0];
|
||||||
|
lockMins = row && row.lockMins != null ? Number(row.lockMins) : 10;
|
||||||
|
} catch {}
|
||||||
|
// Canonical URL: the [fam] segment must be the family SLUG, never the PB
|
||||||
|
// id. If someone lands on /{famId}/... (a stale shortcut, bookmark, or a
|
||||||
|
// login that fell back to the id), rewrite the first path segment to the
|
||||||
|
// slug so the id is replaced everywhere it'd otherwise persist.
|
||||||
|
const canonicalSlug = fam?.slug;
|
||||||
|
if (canonicalSlug && paramFam && paramFam !== canonicalSlug) {
|
||||||
|
const rest = event.url.pathname.replace(`/${paramFam}`, '') || '/';
|
||||||
|
const qs = event.url.search;
|
||||||
|
throw redirect(303, `/${encodeURIComponent(canonicalSlug)}${rest}${qs}`);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const demoMode = (await getPlatformFlags()).demo;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// Canonical fam slug — from the URL param ([fam] routes). Client code
|
// Canonical fam slug — from the URL param ([fam] routes). Client code
|
||||||
// reads page.data.famSlug; never copy it into local $state.
|
// reads page.data.famSlug; never copy it into local $state.
|
||||||
famSlug: event.params.fam || '',
|
famSlug: paramFam || '',
|
||||||
session: session
|
session: session
|
||||||
? {
|
? {
|
||||||
famId: session.famId,
|
famId: session.famId,
|
||||||
userId: session.id,
|
userId: session.id,
|
||||||
memberName: session.name,
|
memberName: session.name,
|
||||||
memberColor: session.color || '',
|
memberColor: session.color || '',
|
||||||
|
memberPattern: session.pattern || '',
|
||||||
|
memberThemeSize: session.themeSize || '',
|
||||||
|
memberThemeOpacity: session.themeOpacity || '',
|
||||||
|
username: session.username || '',
|
||||||
role: session.role
|
role: session.role
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
@@ -84,6 +195,16 @@ export async function load(event) {
|
|||||||
chat,
|
chat,
|
||||||
pbToken,
|
pbToken,
|
||||||
fam,
|
fam,
|
||||||
famAccess
|
famAccess,
|
||||||
|
demoMode,
|
||||||
|
picker: false,
|
||||||
|
pickerFamName: '',
|
||||||
|
pickerChildren: [],
|
||||||
|
deviceChildIds,
|
||||||
|
lockMins,
|
||||||
|
// Per-device shared-computer flag (cookie mirror of the localStorage
|
||||||
|
// flag the TopNav toggle writes). Server can only hint — the client
|
||||||
|
// re-reads localStorage on hydration.
|
||||||
|
sharedDevice: event.cookies.get('fam_shared_device') === '1'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1,26 +1,96 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { invalidateAll } from '$app/navigation';
|
import { invalidateAll } from '$app/navigation';
|
||||||
import { onDestroy, onMount } from 'svelte';
|
import { onDestroy } from 'svelte';
|
||||||
import { initRealtimePb, pb } from '$lib/pocketbase';
|
import { initRealtimePb, pb } from '$lib/pocketbase';
|
||||||
import { famStore } from '$lib/stores/fam.svelte';
|
import { famStore } from '$lib/stores/fam.svelte';
|
||||||
import { chatStore } from '$lib/stores/chat.svelte';
|
import { chatStore } from '$lib/stores/chat.svelte';
|
||||||
import { notices } from '$lib/stores/notices.svelte';
|
import { notices } from '$lib/stores/notices.svelte';
|
||||||
import { Sidebar, TopNav, Footer, Chat } from '$lib/components';
|
import { Sidebar, TopNav, Footer, Chat, SharedPicker } from '$lib/components';
|
||||||
import { chatIcon } from '$lib/components/icons';
|
import { chatIcon } from '$lib/components/icons';
|
||||||
|
import { recordShortcut } from '$lib/shortcut';
|
||||||
|
import { installLockTracking, lockDue } from '$lib/client/lock';
|
||||||
|
import { sharedDevice } from '$lib/stores/shared-device.svelte';
|
||||||
|
import { themeShades } from '$lib/theme';
|
||||||
|
import '$lib/theme-patterns.css';
|
||||||
|
import { themeDraft } from '$lib/stores/theme.svelte';
|
||||||
import type { Session } from '$lib/types';
|
import type { Session } from '$lib/types';
|
||||||
|
|
||||||
let { children, data } = $props();
|
let { children, data } = $props();
|
||||||
|
|
||||||
|
// Background pattern ('' = none) tinted with the user's colour. A live
|
||||||
|
// draft (clicked in the picker, not yet saved) previews immediately;
|
||||||
|
// otherwise the saved value from the session applies.
|
||||||
|
let bgPattern = $derived(themeDraft.pattern ?? (data.session?.memberPattern || ''));
|
||||||
|
let bgColor = $derived(themeDraft.color ?? (data.session?.memberColor || '#6366f1'));
|
||||||
|
let bgOpacity = $derived(
|
||||||
|
themeDraft.opacity ??
|
||||||
|
(Number(data.session?.memberThemeOpacity) > 0 ? data.session?.memberThemeOpacity : 1)
|
||||||
|
);
|
||||||
|
let bgShades = $derived(themeShades(bgColor, Number(bgOpacity) || 1));
|
||||||
|
// Pattern size in vw units ('' = untouched → pattern class default).
|
||||||
|
let bgSize = $derived(themeDraft.size ?? (data.session?.memberThemeSize || ''));
|
||||||
|
let bgSizeVw = $derived(Number(bgSize) > 0 ? bgSize : '');
|
||||||
|
// Shared-device picker: standalone when the device has child sessions but
|
||||||
|
// none is active (layout load redirects here); overlay when the idle lock
|
||||||
|
// fires or the top-nav switcher is opened.
|
||||||
|
const pickerMode = $derived(!!data.picker);
|
||||||
|
let pickerOpen = $state(false);
|
||||||
|
let lockTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
let deviceProfiles = $derived(
|
||||||
|
(data.deviceChildIds || [])
|
||||||
|
.map((id) => famStore.members.find((m) => m.id === id))
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((m: any) => ({ id: m.id, name: m.name, color: m.color, username: m.username }))
|
||||||
|
);
|
||||||
|
|
||||||
|
// Idle lock (children on shared devices only): return to the picker after
|
||||||
|
// `lockMins` of no interaction. localStorage-backed (see lib/client/lock.ts)
|
||||||
|
// so a closed browser still trips the lock on next launch.
|
||||||
|
$effect(() => {
|
||||||
|
const isChild = data.session?.role === 'child';
|
||||||
|
const lockMins = Number(data.lockMins) || 0;
|
||||||
|
if (!isChild || !sharedDevice.on || lockMins <= 0 || data.picker) return;
|
||||||
|
installLockTracking();
|
||||||
|
if (lockDue(lockMins)) pickerOpen = true;
|
||||||
|
if (!lockTimer) {
|
||||||
|
lockTimer = setInterval(() => {
|
||||||
|
if (lockDue(lockMins)) pickerOpen = true;
|
||||||
|
}, 15_000);
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
if (lockTimer) {
|
||||||
|
clearInterval(lockTimer);
|
||||||
|
lockTimer = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
let session = $state<Session | null>(data.session);
|
let session = $state<Session | null>(data.session);
|
||||||
let isParent = $state(data.isParent);
|
let isParent = $state(data.isParent);
|
||||||
let role = $state(data.role);
|
let role = $state(data.role);
|
||||||
|
// Demo UI restrictions apply only inside the demo family itself — the
|
||||||
|
// global `demo` flag must never degrade real families (setting it in
|
||||||
|
// prod once hid every fam's dashboard + settings links).
|
||||||
|
let isDemo = $derived(
|
||||||
|
!!data.demoMode && (page.data.famSlug ?? page.params.fam) === 'showboaters'
|
||||||
|
);
|
||||||
let famName = $derived(
|
let famName = $derived(
|
||||||
famStore.initialized ? (famStore.fam as any)?.name || page.params.fam : page.params.fam
|
famStore.initialized ? (famStore.fam as any)?.name || page.params.fam : page.params.fam
|
||||||
);
|
);
|
||||||
let disabled = $derived(!!data.famAccess?.disabled);
|
let disabled = $derived(!!data.famAccess?.disabled);
|
||||||
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 locked = $derived(disabled && !page.url.pathname.endsWith('/settings'));
|
let locked = $derived(disabled && !page.url.pathname.endsWith('/settings'));
|
||||||
|
// Expired-token zombie state: public PB rules still let logged-out users
|
||||||
|
// READ everything, so the dashboard looks alive while every write fails.
|
||||||
|
// Say so explicitly (join/switch/picker flows render their own UI).
|
||||||
|
let showLoggedOut = $derived(
|
||||||
|
!data.session &&
|
||||||
|
!pickerMode &&
|
||||||
|
!['join', 'switch'].includes(page.url.pathname.split('/')[2] || '')
|
||||||
|
);
|
||||||
|
|
||||||
// ── 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
|
||||||
@@ -37,7 +107,7 @@
|
|||||||
if (returnHandled || page.url.searchParams.get('checkout') !== 'return') return;
|
if (returnHandled || page.url.searchParams.get('checkout') !== 'return') return;
|
||||||
returnHandled = true;
|
returnHandled = true;
|
||||||
if (!disabled) {
|
if (!disabled) {
|
||||||
notices.success('Welcome to FamChore!', 'Your subscription is active.');
|
notices.success('Welcome to FamDone!', 'Your subscription is active.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
startActivating();
|
startActivating();
|
||||||
@@ -88,10 +158,16 @@
|
|||||||
|
|
||||||
function accessMessage(reason: string, parent: boolean) {
|
function accessMessage(reason: string, parent: boolean) {
|
||||||
const map: Record<string, string> = {
|
const map: Record<string, string> = {
|
||||||
code_expired: parent ? 'Access paused — your code has expired. Add a new one.' : 'Access paused.',
|
code_expired: parent
|
||||||
|
? 'Access paused — your code has expired. Add a new one.'
|
||||||
|
: 'Access paused.',
|
||||||
code_disabled: parent ? 'Access paused — your access code was disabled.' : 'Access paused.',
|
code_disabled: parent ? 'Access paused — your access code was disabled.' : 'Access paused.',
|
||||||
subscription_inactive: parent ? 'Access paused — check your subscription payment.' : 'Access paused.',
|
subscription_inactive: parent
|
||||||
canceled: parent ? 'Access paused — renew your subscription or add a code.' : 'Access paused.',
|
? 'Access paused — check your subscription payment.'
|
||||||
|
: 'Access paused.',
|
||||||
|
canceled: parent
|
||||||
|
? 'Access paused — renew your subscription or add a code.'
|
||||||
|
: 'Access paused.',
|
||||||
no_access: parent ? 'Access paused — add an access code to get started.' : 'Access paused.'
|
no_access: parent ? 'Access paused — add an access code to get started.' : 'Access paused.'
|
||||||
};
|
};
|
||||||
return map[reason] || 'Access paused.';
|
return map[reason] || 'Access paused.';
|
||||||
@@ -102,8 +178,28 @@
|
|||||||
let prevClaimedIds = new Set<string>();
|
let prevClaimedIds = new Set<string>();
|
||||||
let mounted = false;
|
let mounted = false;
|
||||||
|
|
||||||
|
// Persist a returning-user shortcut (localStorage) whenever an authenticated
|
||||||
|
// session exists. $effect (not onMount) so it also fires when the session
|
||||||
|
// appears after the layout was already mounted — e.g. a member redeeming
|
||||||
|
// their OTP on /{fam}/join/{username} then landing on /{fam}/{username}.
|
||||||
|
// The handle comes from the session (not page.params) so jump-back-in lands
|
||||||
|
// on the user's own dashboard even from the bare /{fam} page.
|
||||||
|
// The slug MUST come from the fam record — page.params.fam may be the PB
|
||||||
|
// id (login ID-fallback / stale link) when page.data.fam failed to load,
|
||||||
|
// and recording that poisons the shortcut (famSlug === famName === id).
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!session || !famStore.initialized) return;
|
if (!page.data.session) return;
|
||||||
|
const slug = page.data.fam?.slug as string;
|
||||||
|
if (!slug) return;
|
||||||
|
recordShortcut({
|
||||||
|
famSlug: slug,
|
||||||
|
famName: (page.data.fam?.name as string) || slug,
|
||||||
|
userName: page.data.session.username || page.params.username || ''
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!isParent || !session || !famStore.initialized) return;
|
||||||
const rewards = famStore.rewards;
|
const rewards = famStore.rewards;
|
||||||
if (!mounted) {
|
if (!mounted) {
|
||||||
mounted = true;
|
mounted = true;
|
||||||
@@ -125,30 +221,57 @@
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
onMount(() => {
|
// Client-only realtime wiring. Must be reactive (not onMount): after a
|
||||||
|
// member redeems their OTP on /{fam}/join/{username}, the 303 redirect is a
|
||||||
|
// client-side navigation that REUSES this layout instance, so onMount would
|
||||||
|
// never re-fire. Keying on data.famId ensures the PB client is re-seeded with
|
||||||
|
// the fresh token and famStore inits + subscribes the moment a session lands.
|
||||||
|
$effect(() => {
|
||||||
|
const famId = page.data.famId;
|
||||||
|
if (!famId) return;
|
||||||
initRealtimePb(page.data.pbToken || '');
|
initRealtimePb(page.data.pbToken || '');
|
||||||
if (page.data.famId) famStore.init(page.data.famId, page.data.fam);
|
famStore.init(famId, page.data.fam as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
const chat = page.data.chat;
|
const chat = page.data.chat;
|
||||||
if (chat?.famId && chat?.actor) {
|
if (!chat?.famId || !chat?.actor) return;
|
||||||
|
initRealtimePb(page.data.pbToken || '');
|
||||||
chatStore.init({
|
chatStore.init({
|
||||||
famId: chat.famId,
|
famId: chat.famId,
|
||||||
actorId: chat.actor.id,
|
actorId: chat.actor.id,
|
||||||
actorType: chat.actor.type,
|
actorType: chat.actor.type,
|
||||||
actorName: chat.actor.name,
|
actorName: chat.actor.name,
|
||||||
actorColor: chat.actor.color || '#6366f1',
|
actorColor: chat.actor.color || '#6366f1',
|
||||||
pbToken: page.data.pbToken || ''
|
pbToken: page.data.pbToken || '',
|
||||||
|
members: chat.members || []
|
||||||
});
|
});
|
||||||
}
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="layout-stage" class:chat-open={chatStore.open}>
|
<!-- layout.svelte picker-mode branch -->
|
||||||
<div class="app-shell">
|
{#if pickerMode}
|
||||||
<Sidebar {famName} session={data.session} {isParent} {role} />
|
<main class="picker-stage">
|
||||||
|
<SharedPicker
|
||||||
|
standalone
|
||||||
|
famSlug={data.famSlug}
|
||||||
|
famName={data.pickerFamName}
|
||||||
|
profiles={data.pickerChildren || []}
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
{:else}
|
||||||
|
<div class="layout-stage" class:chat-open={chatStore.open} class:has-pattern={!!bgPattern}>
|
||||||
|
<div
|
||||||
|
class="app-shell {bgPattern ? `pattern-${bgPattern}` : ''}"
|
||||||
|
style={`${bgSizeVw ? `--s:calc(${bgSizeVw} * 1vw);` : ''}--c1:${bgShades.c1};--c2:${bgShades.c2};--c3:${bgShades.c3};--c4:${bgShades.c4}`}
|
||||||
|
>
|
||||||
|
>
|
||||||
|
<Sidebar {famName} session={data.session} {isParent} {role} {isDemo} />
|
||||||
<TopNav
|
<TopNav
|
||||||
{role}
|
role={data.session ? role : ''}
|
||||||
seasons={famStore.seasons.filter((s) => s.active !== false)}
|
seasons={famStore.seasons.filter((s) => s.active !== false)}
|
||||||
announcement={disabled && !activating ? accessMessage(accessReason, isParent) : ''}
|
announcement={disabled && !activating ? accessMessage(accessReason, isParent) : ''}
|
||||||
|
connected={!famStore.connectionDown}
|
||||||
>
|
>
|
||||||
<button class="chat-toggle" onclick={() => chatStore.toggle()} aria-label="Open chat">
|
<button class="chat-toggle" onclick={() => chatStore.toggle()} aria-label="Open chat">
|
||||||
{@html chatIcon}
|
{@html chatIcon}
|
||||||
@@ -156,14 +279,50 @@
|
|||||||
<span class="chat-badge">{chatStore.unread > 9 ? '9+' : chatStore.unread}</span>
|
<span class="chat-badge">{chatStore.unread > 9 ? '9+' : chatStore.unread}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
|
{#if data.session && sharedDevice.on && (data.deviceChildIds?.length || 0) > 0}
|
||||||
|
<button
|
||||||
|
class="kid-switch"
|
||||||
|
onclick={() => (pickerOpen = true)}
|
||||||
|
aria-label="Switch user (shared device)"
|
||||||
|
title="Switch user"
|
||||||
|
>
|
||||||
|
<span class="kid-switch-dot" style="background:{data.session.memberColor || '#6366f1'}"
|
||||||
|
></span>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
</TopNav>
|
</TopNav>
|
||||||
<main class="app-main">
|
<main class="app-main" class:join-page={page.url.pathname.split('/')[2] === 'join'}>
|
||||||
<div class="page-wrap">
|
<div class="page-wrap">
|
||||||
<div class="page-content" class:locked>{@render children()}</div>
|
<div class="page-content" class:locked>{@render children()}</div>
|
||||||
|
|
||||||
|
{#if showLoggedOut}
|
||||||
|
<div class="disabled-overlay loggedout-overlay">
|
||||||
|
<div class="disabled-card">
|
||||||
|
<strong>You're logged out</strong>
|
||||||
|
<span>
|
||||||
|
Your session expired, so everything below is read-only — toggles and
|
||||||
|
claims won't work until you log back in.
|
||||||
|
</span>
|
||||||
|
<span class="loggedout-actions">
|
||||||
|
<a class="btn-primary" href="/login">Parent log in</a>
|
||||||
|
{#if page.params.username}
|
||||||
|
<a
|
||||||
|
class="btn-secondary"
|
||||||
|
href={`/${data.famSlug || page.params.fam}/join/${page.params.username}`}
|
||||||
|
>Rejoin as {page.params.username}</a
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
<span class="hint">Kids: re-open your invite link, or ask a parent to send it again.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
{#if locked}
|
{#if locked}
|
||||||
<div class="disabled-overlay">
|
<div class="disabled-overlay">
|
||||||
<div class="disabled-card">
|
<div class="disabled-card">
|
||||||
{#if activating}
|
{#if !hasAuth}
|
||||||
|
<h2>You need to be logged in to view this page</h2>
|
||||||
|
{:else if activating}
|
||||||
<div class="spinner" aria-hidden="true"></div>
|
<div class="spinner" aria-hidden="true"></div>
|
||||||
<strong>Activating your subscription…</strong>
|
<strong>Activating your subscription…</strong>
|
||||||
<span>Payment received — this usually only takes a few seconds.</span>
|
<span>Payment received — this usually only takes a few seconds.</span>
|
||||||
@@ -171,7 +330,7 @@
|
|||||||
<strong>Access paused</strong>
|
<strong>Access paused</strong>
|
||||||
<span>
|
<span>
|
||||||
{isParent
|
{isParent
|
||||||
? 'Add an access code or resume your subscription to keep using FamChore.'
|
? 'Add an access code or resume your subscription to keep using FamDone.'
|
||||||
: 'Your family access is paused.'}
|
: 'Your family access is paused.'}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -181,31 +340,79 @@
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
{#if claimToast}
|
{#if claimToast}
|
||||||
<div class="claim-toast">{claimToast}</div>
|
<a
|
||||||
|
class="claim-toast"
|
||||||
|
href={`/${page.params.fam}/${data.session?.username || page.params.username || ''}`}
|
||||||
|
>
|
||||||
|
{claimToast} — view dashboard →
|
||||||
|
</a>
|
||||||
{/if}
|
{/if}
|
||||||
<Footer />
|
<Footer sidebar />
|
||||||
</div>
|
</div>
|
||||||
{#if chatStore.open}
|
{#if chatStore.open}
|
||||||
<button class="chat-backdrop" onclick={() => chatStore.closeChat()} aria-label="Close chat"
|
<button class="chat-backdrop" onclick={() => chatStore.closeChat()} aria-label="Close chat"
|
||||||
></button>
|
></button>
|
||||||
{/if}
|
{/if}
|
||||||
<Chat {role} />
|
<Chat {role} />
|
||||||
</div>
|
{#if pickerOpen && deviceProfiles.length > 0}
|
||||||
|
<SharedPicker
|
||||||
|
profiles={deviceProfiles}
|
||||||
|
activeId={data.session?.userId}
|
||||||
|
famSlug={data.famSlug}
|
||||||
|
oncancel={() => (pickerOpen = false)}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
{#if famStore.connectionDown}
|
||||||
|
<div class="conn-toast" role="status">Reconnecting… live updates paused</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.claim-toast {
|
.claim-toast {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 3.5rem;
|
top: 52px;
|
||||||
right: 1.5rem;
|
left: 220px;
|
||||||
background: #059669;
|
right: 0;
|
||||||
color: white;
|
background: #f59e0b;
|
||||||
padding: 0.6rem 1rem;
|
color: #fff;
|
||||||
border-radius: 8px;
|
padding: 0.5rem 1.5rem;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
z-index: 999;
|
font-weight: 600;
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
z-index: 95;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.18);
|
||||||
|
text-decoration: none;
|
||||||
animation: fadein 0.2s;
|
animation: fadein 0.2s;
|
||||||
}
|
}
|
||||||
|
.claim-toast:hover {
|
||||||
|
background: #d97706;
|
||||||
|
}
|
||||||
|
.conn-toast {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 1rem;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background: #1f2937;
|
||||||
|
color: #fff;
|
||||||
|
padding: 0.5rem 1.1rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
z-index: 95;
|
||||||
|
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.3);
|
||||||
|
animation: fadein 0.2s;
|
||||||
|
}
|
||||||
|
@media (max-width: 1023px) {
|
||||||
|
.claim-toast {
|
||||||
|
left: 56px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (max-width: 639px) {
|
||||||
|
.claim-toast {
|
||||||
|
left: 0;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
.app-shell {
|
.app-shell {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -236,11 +443,26 @@
|
|||||||
}
|
}
|
||||||
.app-main {
|
.app-main {
|
||||||
margin-left: 220px;
|
margin-left: 220px;
|
||||||
margin-top: 48px;
|
margin-top: 52px;
|
||||||
padding: 1.5rem 2rem;
|
padding: 1.5rem 2rem;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
transition: margin-left 0.2s;
|
transition: margin-left 0.2s;
|
||||||
}
|
}
|
||||||
|
@media (max-width: 1023px) {
|
||||||
|
.app-main {
|
||||||
|
margin-left: 56px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (max-width: 639px) {
|
||||||
|
.app-main {
|
||||||
|
padding-left: 1rem;
|
||||||
|
padding-right: 1rem;
|
||||||
|
}
|
||||||
|
/* Join pages center their own content — no shell padding needed */
|
||||||
|
.app-main.join-page {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
.page-wrap {
|
.page-wrap {
|
||||||
position: relative;
|
position: relative;
|
||||||
min-height: 70vh;
|
min-height: 70vh;
|
||||||
@@ -295,6 +517,34 @@
|
|||||||
color: #6b7280;
|
color: #6b7280;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
/* Logged-out card must be clickable (the paused overlay is display-only). */
|
||||||
|
.loggedout-overlay {
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
.loggedout-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.6rem;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 0.4rem;
|
||||||
|
}
|
||||||
|
.loggedout-actions a {
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.5rem 1.1rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.loggedout-actions .btn-primary {
|
||||||
|
background: #6366f1;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.loggedout-actions .btn-secondary {
|
||||||
|
background: #eef2ff;
|
||||||
|
color: #4338ca;
|
||||||
|
}
|
||||||
|
.disabled-card .hint {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
.chat-toggle {
|
.chat-toggle {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 40px;
|
width: 40px;
|
||||||
@@ -308,6 +558,33 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
.kid-switch {
|
||||||
|
position: relative;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #f3f4f6;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.kid-switch:hover {
|
||||||
|
background: #e5e7eb;
|
||||||
|
}
|
||||||
|
.kid-switch-dot {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid #fff;
|
||||||
|
box-shadow: 0 0 0 1px #e2e8f0;
|
||||||
|
}
|
||||||
|
.picker-stage {
|
||||||
|
min-height: 100vh;
|
||||||
|
width: 100%;
|
||||||
|
background: whitesmoke;
|
||||||
|
}
|
||||||
.chat-toggle:hover {
|
.chat-toggle:hover {
|
||||||
background: #e5e7eb;
|
background: #e5e7eb;
|
||||||
color: #4338ca;
|
color: #4338ca;
|
||||||
|
|||||||
@@ -9,13 +9,16 @@ export async function load(event) {
|
|||||||
const s = createServices(pbUser(event), session);
|
const s = createServices(pbUser(event), session);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [members, templates, assigned, summary] = await Promise.all([
|
const [members, assigned, summary, fam, stats, rewards, bonusConfigs] = await Promise.all([
|
||||||
s.crud.list('members', famId),
|
s.crud.list('members', famId),
|
||||||
s.crud.list('chore-templates', famId),
|
|
||||||
s.crud.list('assigned-chores', famId),
|
s.crud.list('assigned-chores', famId),
|
||||||
s.fam.weeklySummary(famId),
|
s.fam.weeklySummary(famId),
|
||||||
|
s.fam.get(famId),
|
||||||
|
s.stats.family(famId),
|
||||||
|
s.crud.list('rewards', famId),
|
||||||
|
s.crud.list('bonus-configs', famId)
|
||||||
]);
|
]);
|
||||||
return { members, templates, assigned, summary };
|
return { members, assigned, summary, fam, stats, rewards, bonusConfigs };
|
||||||
} catch {
|
} catch {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,29 +4,58 @@
|
|||||||
import { Chart, registerables } from 'chart.js';
|
import { Chart, registerables } from 'chart.js';
|
||||||
import { ViewHeader, CardGrid, Card } from '$lib/components';
|
import { ViewHeader, CardGrid, Card } from '$lib/components';
|
||||||
import { formatDDMMYY } from '$lib/format';
|
import { formatDDMMYY } from '$lib/format';
|
||||||
|
import { famStore } from '$lib/stores/fam.svelte';
|
||||||
|
import { sharedDevice } from '$lib/stores/shared-device.svelte';
|
||||||
|
import { monitorIcon } from '$lib/components/icons';
|
||||||
import { handleOf } from '@shared/slugify';
|
import { handleOf } from '@shared/slugify';
|
||||||
|
import { addDaysStr, resolveTz, todayInTz } from '@shared/timezone';
|
||||||
|
|
||||||
Chart.register(...registerables);
|
Chart.register(...registerables);
|
||||||
|
|
||||||
|
// Time-of-day greeting, picked once per load (not a ticking clock).
|
||||||
|
const GREETINGS: { from: number; to: number; lines: string[] }[] = [
|
||||||
|
{ from: 5, to: 12, lines: ['Good morning', 'Rise and shine', 'Morning, crew'] },
|
||||||
|
{ from: 12, to: 17, lines: ['Good afternoon', 'Back on it', 'Afternoon push'] },
|
||||||
|
{ from: 17, to: 22, lines: ['Good evening', 'Welcome back', 'Evening shift'] },
|
||||||
|
{ from: 22, to: 29, lines: ['Night owls', 'Still at it', 'Burning the midnight oil'] }
|
||||||
|
];
|
||||||
|
let greeting = $state(
|
||||||
|
(() => {
|
||||||
|
const h = new Date().getHours();
|
||||||
|
const band = GREETINGS.find((g) => (h >= g.from && h < g.to) || (g.to > 24 && h < g.to - 24)) || GREETINGS[0];
|
||||||
|
return band.lines[Math.floor(Math.random() * band.lines.length)];
|
||||||
|
})()
|
||||||
|
);
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
let famSlug = $derived(page.data.famSlug ?? page.params.fam);
|
let famSlug = $derived(page.data.famSlug ?? page.params.fam);
|
||||||
|
|
||||||
|
let famTz = $derived(resolveTz(data.fam?.timezone || (page.data.fam as any)?.timezone || 'auto'));
|
||||||
|
|
||||||
let summary = $state(data.summary);
|
let summary = $state(data.summary);
|
||||||
let members = $state(data.members || []);
|
let members = $state(data.members || []);
|
||||||
let templates = $state(data.templates || []);
|
|
||||||
let assigned = $state(data.assigned || []);
|
let assigned = $state(data.assigned || []);
|
||||||
|
let stats = $state(data.stats || { allTimePts: 0, allTimeCash: 0, allTimeChores: 0, series: [] });
|
||||||
|
|
||||||
let donutCanvases = $state<Record<string, HTMLCanvasElement | undefined>>({});
|
let donutCanvases = $state<Record<string, HTMLCanvasElement | undefined>>({});
|
||||||
let donutCharts: Record<string, Chart> = {};
|
let donutCharts: Record<string, Chart> = {};
|
||||||
let choresCanvas: HTMLCanvasElement | undefined;
|
let choresCanvas: HTMLCanvasElement | undefined;
|
||||||
let choresChart: Chart | undefined;
|
let choresChart: Chart | undefined;
|
||||||
|
let trendCanvas = $state<HTMLCanvasElement | undefined>();
|
||||||
|
let trendChart: Chart | undefined;
|
||||||
|
// Line-graph metric view: cash | points | chores
|
||||||
|
let metric = $state<'cash' | 'points' | 'chores'>('points');
|
||||||
|
|
||||||
function captureCanvas(node: HTMLCanvasElement, memberId: string) {
|
function captureCanvas(node: HTMLCanvasElement, memberId: string) {
|
||||||
donutCanvases[memberId] = node;
|
donutCanvases[memberId] = node;
|
||||||
return { destroy() { delete donutCanvases[memberId]; } };
|
return {
|
||||||
|
destroy() {
|
||||||
|
delete donutCanvases[memberId];
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Derived tile stats ───
|
// ─── Derived tile stats (this week) ───
|
||||||
let tileMembers = $derived(members.length);
|
let tileMembers = $derived(members.length);
|
||||||
let tilePts = $derived(
|
let tilePts = $derived(
|
||||||
summary?.summaries?.reduce((s: number, m: any) => s + (m.pointsEarned || 0), 0) ?? 0
|
summary?.summaries?.reduce((s: number, m: any) => s + (m.pointsEarned || 0), 0) ?? 0
|
||||||
@@ -37,15 +66,186 @@
|
|||||||
let tileChores = $derived(
|
let tileChores = $derived(
|
||||||
summary?.summaries?.reduce((s: number, m: any) => s + (m.choresCompleted || 0), 0) ?? 0
|
summary?.summaries?.reduce((s: number, m: any) => s + (m.choresCompleted || 0), 0) ?? 0
|
||||||
);
|
);
|
||||||
|
// ─── All-time header stats (server-computed) ───
|
||||||
|
let allTimePts = $derived(Number(stats.allTimePts) || 0);
|
||||||
|
let allTimeCash = $derived(Number(stats.allTimeCash) || 0);
|
||||||
|
let allTimeChores = $derived(Number(stats.allTimeChores) || 0);
|
||||||
|
|
||||||
|
// ─── Goals completed (rewards from bonus configs, pocket money excluded) ───
|
||||||
|
// Derived from famStore so realtime updates (a goal met / revoked) flow in.
|
||||||
|
let rewards = $derived(famStore.initialized ? famStore.rewards : data.rewards || []);
|
||||||
|
// All-time goals achieved — every materialized bonus reward (prize / points /
|
||||||
|
// cash), including pocket money configs.
|
||||||
|
let tileGoals = $derived(rewards.filter((r: any) => r.bonusConfigId).length);
|
||||||
|
let bonusConfigs = $derived(
|
||||||
|
famStore.initialized ? famStore.bonusConfigs : data.bonusConfigs || []
|
||||||
|
);
|
||||||
|
let goalsByMember = $derived.by(() => {
|
||||||
|
const ws = (summary?.weekStart as string) || todayInTz(famTz);
|
||||||
|
const pmIds = new Set(bonusConfigs.filter((c: any) => c.isPocketMoney).map((c: any) => c.id));
|
||||||
|
const map = new Map<string, { count: number; names: string[] }>();
|
||||||
|
for (const r of rewards as any[]) {
|
||||||
|
if (!r.bonusConfigId || pmIds.has(r.bonusConfigId)) continue;
|
||||||
|
if ((r.date || '').slice(0, 10) < ws) continue;
|
||||||
|
const cfg = bonusConfigs.find((c: any) => c.id === r.bonusConfigId);
|
||||||
|
const entry = map.get(r.memberId) || { count: 0, names: [] };
|
||||||
|
entry.count++;
|
||||||
|
if (cfg?.name && !entry.names.includes(cfg.name)) entry.names.push(cfg.name);
|
||||||
|
map.set(r.memberId, entry);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
});
|
||||||
|
let pocketMoneyByMember = $derived.by(() => {
|
||||||
|
const ws = (summary?.weekStart as string) || todayInTz(famTz);
|
||||||
|
const pmIds = new Set(bonusConfigs.filter((c: any) => c.isPocketMoney).map((c: any) => c.id));
|
||||||
|
const map = new Map<string, number>();
|
||||||
|
for (const r of rewards as any[]) {
|
||||||
|
if (!r.bonusConfigId || !pmIds.has(r.bonusConfigId)) continue;
|
||||||
|
if ((r.date || '').slice(0, 10) < ws) continue;
|
||||||
|
const entry = map.get(r.memberId) || 0;
|
||||||
|
map.set(r.memberId, entry + 1);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
});
|
||||||
|
// ─── All-time per-member stats ───
|
||||||
|
let allTimeByMember = $derived.by(() => {
|
||||||
|
const map = new Map<string, { points: number; cash: number; chores: number }>();
|
||||||
|
for (const m of trend as any[]) {
|
||||||
|
const pts = (m.weeks || []).reduce((s: number, w: any) => s + (Number(w.points) || 0), 0);
|
||||||
|
const cash = (m.weeks || []).reduce((s: number, w: any) => s + (Number(w.cash) || 0), 0);
|
||||||
|
const chores = (m.weeks || []).reduce((s: number, w: any) => s + (Number(w.chores) || 0), 0);
|
||||||
|
map.set(m.memberId, { points: pts, cash, chores });
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
});
|
||||||
|
let goalsAllTimeByMember = $derived.by(() => {
|
||||||
|
const pmIds = new Set(bonusConfigs.filter((c: any) => c.isPocketMoney).map((c: any) => c.id));
|
||||||
|
const map = new Map<string, { count: number; names: string[] }>();
|
||||||
|
for (const r of rewards as any[]) {
|
||||||
|
if (!r.bonusConfigId || pmIds.has(r.bonusConfigId)) continue;
|
||||||
|
const cfg = bonusConfigs.find((c: any) => c.id === r.bonusConfigId);
|
||||||
|
const entry = map.get(r.memberId) || { count: 0, names: [] };
|
||||||
|
entry.count++;
|
||||||
|
if (cfg?.name && !entry.names.includes(cfg.name)) entry.names.push(cfg.name);
|
||||||
|
map.set(r.memberId, entry);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
});
|
||||||
|
let pocketMoneyAllTimeByMember = $derived.by(() => {
|
||||||
|
const pmIds = new Set(bonusConfigs.filter((c: any) => c.isPocketMoney).map((c: any) => c.id));
|
||||||
|
const map = new Map<string, number>();
|
||||||
|
for (const r of rewards as any[]) {
|
||||||
|
if (!r.bonusConfigId || !pmIds.has(r.bonusConfigId)) continue;
|
||||||
|
map.set(r.memberId, (map.get(r.memberId) || 0) + 1);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Trend chart window: show current month, expand a month at a time up to 3. ───
|
||||||
|
let trend = $derived.by(() => {
|
||||||
|
const series = Array.isArray(stats.series) ? stats.series : [];
|
||||||
|
const memberLines = series.filter((m: any) => m.memberId);
|
||||||
|
return memberLines;
|
||||||
|
});
|
||||||
|
|
||||||
|
function computeTrend(): { labels: string[]; datasets: any[] } {
|
||||||
|
const allWeeks = new Set<string>();
|
||||||
|
for (const m of trend) {
|
||||||
|
for (const w of m.weeks || []) allWeeks.add(w.weekStart);
|
||||||
|
}
|
||||||
|
const weeks = [...allWeeks].sort();
|
||||||
|
if (weeks.length === 0) return { labels: [], datasets: [] };
|
||||||
|
|
||||||
|
const latest = weeks[weeks.length - 1];
|
||||||
|
const earliest = weeks[0];
|
||||||
|
const spanDays = Math.max(
|
||||||
|
0,
|
||||||
|
Math.round((new Date(latest).getTime() - new Date(earliest).getTime()) / 86400000)
|
||||||
|
);
|
||||||
|
// 1 month if data spans <= ~1 month; grow a month at a time up to 3.
|
||||||
|
const months = spanDays <= 31 ? 1 : spanDays <= 62 ? 2 : 3;
|
||||||
|
const windowStart = latest ? addDaysStr(latest, -(months * 31)) : earliest;
|
||||||
|
const labels = weeks.filter((w) => w >= windowStart);
|
||||||
|
|
||||||
|
const valueFor = (m: any, w: string, met: string): number => {
|
||||||
|
const wk = (m.weeks || []).find((x: any) => x.weekStart === w);
|
||||||
|
if (!wk) return 0;
|
||||||
|
return Number(wk[met]) || 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
labels,
|
||||||
|
datasets: trend.map((m: any) => ({
|
||||||
|
label: m.memberName,
|
||||||
|
data: labels.map((w) => valueFor(m, w, metric)),
|
||||||
|
borderColor: m.memberColor || '#6366f1',
|
||||||
|
backgroundColor: m.memberColor || '#6366f1',
|
||||||
|
tension: 0.35,
|
||||||
|
fill: false,
|
||||||
|
pointRadius: 3,
|
||||||
|
borderWidth: 2
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function totalChoresFor(memberId: string) {
|
function totalChoresFor(memberId: string) {
|
||||||
return assigned.filter((a: any) => a.memberId === memberId).length;
|
return assigned.filter((a: any) => a.memberId === memberId).length;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Weekly completion potential for a member: each daily chore = 7 instances,
|
||||||
|
// each weekly/todo chore = 1. (The raw `s.totalChores` is a template count,
|
||||||
|
// which is why the old donut could read >100%.)
|
||||||
|
function weeklyPotentialFor(memberId: string) {
|
||||||
|
return assigned
|
||||||
|
.filter((a: any) => a.memberId === memberId)
|
||||||
|
.reduce((sum: number, a: any) => sum + (a.frequency === 'daily' ? 7 : 1), 0);
|
||||||
|
}
|
||||||
|
// Chores expected "today" = the member's daily chores (each due once today).
|
||||||
|
function todayPotentialFor(memberId: string) {
|
||||||
|
return assigned.filter((a: any) => a.memberId === memberId && a.frequency === 'daily').length;
|
||||||
|
}
|
||||||
|
// All-time completion rate for a member: all chores ever done ÷ (current
|
||||||
|
// weekly potential × weeks active). Bounded and meaningful, unlike the old
|
||||||
|
// points-per-day average (bonus points + weeklies done in one day could
|
||||||
|
// push it past a "perfect day" — e.g. Jethro's 100%).
|
||||||
|
function overallRateFor(memberId: string): { done: number; total: number; pct: number } {
|
||||||
|
const line = trend.find((m: any) => m.memberId === memberId);
|
||||||
|
const weeks = line?.weeks || [];
|
||||||
|
const done = weeks.reduce((sum: number, w: any) => sum + (Number(w.chores) || 0), 0);
|
||||||
|
const potential = weeklyPotentialFor(memberId);
|
||||||
|
if (weeks.length === 0 || potential <= 0) return { done, total: 0, pct: 0 };
|
||||||
|
const earliest = weeks.map((w: any) => w.weekStart).sort()[0];
|
||||||
|
const today = todayInTz(famTz);
|
||||||
|
const weeksActive = Math.max(
|
||||||
|
1,
|
||||||
|
Math.floor(
|
||||||
|
(new Date(today + 'T00:00:00').getTime() - new Date(earliest + 'T00:00:00').getTime()) /
|
||||||
|
(7 * 86400000)
|
||||||
|
) + 1
|
||||||
|
);
|
||||||
|
const total = potential * weeksActive;
|
||||||
|
return { done, total, pct: Math.min(100, Math.round((done / total) * 100)) };
|
||||||
|
}
|
||||||
|
// Today's completion count — tolerant of UTC vs fam-local date keys.
|
||||||
|
function todayDone(s: any): number {
|
||||||
|
if (!s.dayCompletions) return 0;
|
||||||
|
const utc = new Date().toISOString().slice(0, 10);
|
||||||
|
if (s.dayCompletions[utc] != null) return s.dayCompletions[utc];
|
||||||
|
const local = new Date().toLocaleDateString('en-CA');
|
||||||
|
return s.dayCompletions[local] || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function distinctTrendWeeks(): number {
|
||||||
|
const set = new Set<string>();
|
||||||
|
for (const m of trend) for (const w of m.weeks || []) set.add(w.weekStart);
|
||||||
|
return set.size;
|
||||||
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
if (!summary?.summaries?.length) return;
|
if (!summary?.summaries?.length) return;
|
||||||
|
|
||||||
if (choresCanvas && summary.daysInWeek?.length) {
|
if (choresCanvas && summary.daysInWeek?.length) {
|
||||||
|
// daysInWeek runs payday+1..payday (weeks close on payday).
|
||||||
const days = summary.daysInWeek;
|
const days = summary.daysInWeek;
|
||||||
const labels = days.map((d: string) =>
|
const labels = days.map((d: string) =>
|
||||||
new Date(d + 'T12:00:00').toLocaleDateString('en-GB', { weekday: 'short' })
|
new Date(d + 'T12:00:00').toLocaleDateString('en-GB', { weekday: 'short' })
|
||||||
@@ -54,7 +254,7 @@
|
|||||||
label: s.memberName,
|
label: s.memberName,
|
||||||
data: days.map((d: string) => s.dayCompletions?.[d] || 0),
|
data: days.map((d: string) => s.dayCompletions?.[d] || 0),
|
||||||
backgroundColor: s.memberColor || '#6366f1',
|
backgroundColor: s.memberColor || '#6366f1',
|
||||||
borderRadius: 4,
|
borderRadius: 4
|
||||||
}));
|
}));
|
||||||
choresChart = new Chart(choresCanvas, {
|
choresChart = new Chart(choresCanvas, {
|
||||||
type: 'bar',
|
type: 'bar',
|
||||||
@@ -62,45 +262,135 @@
|
|||||||
options: {
|
options: {
|
||||||
responsive: true,
|
responsive: true,
|
||||||
plugins: {
|
plugins: {
|
||||||
legend: { display: summary.summaries.length > 1, position: 'bottom' },
|
legend: { display: summary.summaries.length > 1, position: 'bottom' }
|
||||||
},
|
},
|
||||||
scales: { y: { beginAtZero: true, ticks: { stepSize: 1 } } },
|
scales: { y: { beginAtZero: true, ticks: { stepSize: 1 } } }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeDonut(canvas: HTMLCanvasElement, done: number, total: number, color: string) {
|
||||||
|
const remaining = Math.max(0, total - done);
|
||||||
|
return new Chart(canvas, {
|
||||||
|
type: 'doughnut',
|
||||||
|
data: {
|
||||||
|
labels: ['Done', 'Remaining'],
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
data: [done, remaining],
|
||||||
|
backgroundColor: [color || '#22c55e', '#e5e7eb'],
|
||||||
|
borderWidth: 0
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
|
options: { responsive: true, cutout: '70%', plugins: { legend: { display: false } } }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const s of summary.summaries) {
|
for (const s of summary.summaries) {
|
||||||
const canvas = donutCanvases[s.memberId];
|
const todayCanvas = donutCanvases[`${s.memberId}:today`];
|
||||||
if (!canvas) continue;
|
if (todayCanvas) {
|
||||||
const done = s.choresCompleted || 0;
|
donutCharts[`${s.memberId}:today`] = makeDonut(
|
||||||
const total = s.totalChores || 0;
|
todayCanvas,
|
||||||
const remaining = Math.max(0, total - done);
|
todayDone(s),
|
||||||
donutCharts[s.memberId] = new Chart(canvas, {
|
todayPotentialFor(s.memberId),
|
||||||
type: 'doughnut',
|
s.memberColor
|
||||||
data: {
|
);
|
||||||
labels: ['Done', 'Remaining'],
|
}
|
||||||
datasets: [{
|
const weekCanvas = donutCanvases[`${s.memberId}:week`];
|
||||||
data: [done, remaining],
|
if (weekCanvas) {
|
||||||
backgroundColor: [s.memberColor || '#22c55e', '#e5e7eb'],
|
donutCharts[`${s.memberId}:week`] = makeDonut(
|
||||||
borderWidth: 0,
|
weekCanvas,
|
||||||
}],
|
s.choresCompleted || 0,
|
||||||
|
weeklyPotentialFor(s.memberId),
|
||||||
|
s.memberColor
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const scoreCanvas = donutCanvases[`${s.memberId}:score`];
|
||||||
|
if (scoreCanvas) {
|
||||||
|
const overall = overallRateFor(s.memberId);
|
||||||
|
donutCharts[`${s.memberId}:score`] = makeDonut(
|
||||||
|
scoreCanvas,
|
||||||
|
overall.done,
|
||||||
|
overall.total,
|
||||||
|
s.memberColor
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Re-render the trend line chart when the metric view changes.
|
||||||
|
$effect(() => {
|
||||||
|
metric;
|
||||||
|
trend;
|
||||||
|
if (!trendCanvas) return;
|
||||||
|
const { labels, datasets } = computeTrend();
|
||||||
|
if (trendChart) {
|
||||||
|
trendChart.data.labels = labels;
|
||||||
|
trendChart.data.datasets = datasets;
|
||||||
|
trendChart.update();
|
||||||
|
} else if (datasets.length > 0) {
|
||||||
|
trendChart = new Chart(trendCanvas, {
|
||||||
|
type: 'line',
|
||||||
|
data: { labels, datasets },
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
interaction: { mode: 'index', intersect: false },
|
||||||
|
plugins: {
|
||||||
|
legend: { display: trend.length > 1, position: 'bottom' },
|
||||||
|
tooltip: {
|
||||||
|
callbacks: {
|
||||||
|
label: (ctx: any) => {
|
||||||
|
const v = ctx.parsed.y;
|
||||||
|
if (metric === 'cash') return ` ${ctx.dataset.label}: £${Number(v).toFixed(2)}`;
|
||||||
|
if (metric === 'chores') return ` ${ctx.dataset.label}: ${v} chores`;
|
||||||
|
return ` ${ctx.dataset.label}: ${v} pts`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
options: { responsive: true, cutout: '70%', plugins: { legend: { display: false } } },
|
scales: { y: { beginAtZero: true } }
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
choresChart?.destroy();
|
choresChart?.destroy();
|
||||||
|
trendChart?.destroy();
|
||||||
for (const c of Object.values(donutCharts)) c?.destroy();
|
for (const c of Object.values(donutCharts)) c?.destroy();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<ViewHeader
|
<ViewHeader
|
||||||
title={famSlug}
|
title={greeting}
|
||||||
subtitle={summary?.weekStart ? `Week of ${formatDDMMYY(summary.weekStart)}` : 'Dashboard'}
|
subtitle={`${page.data.fam?.name || famSlug}${summary?.weekStart ? ` · Week of ${formatDDMMYY(summary.weekStart)}` : ''}`}
|
||||||
hero
|
hero
|
||||||
/>
|
>
|
||||||
|
<!-- Shared-computer toggle (left) + all-time totals (right), one line -->
|
||||||
|
<div class="vh-toprow">
|
||||||
|
<button
|
||||||
|
class="shared-hero-toggle"
|
||||||
|
class:on={sharedDevice.on}
|
||||||
|
onclick={() => sharedDevice.toggle()}
|
||||||
|
aria-pressed={sharedDevice.on}
|
||||||
|
title={sharedDevice.on ? 'Shared computer mode is ON' : 'Mark this as a shared computer'}
|
||||||
|
>
|
||||||
|
{@html monitorIcon}
|
||||||
|
<span>Shared computer {sharedDevice.on ? '· On' : '· Off'}</span>
|
||||||
|
</button>
|
||||||
|
<div class="vh-stats text-right">
|
||||||
|
<span class="vh-stat">
|
||||||
|
<span class="vh-num pts">{allTimePts.toLocaleString()}</span>
|
||||||
|
<span class="vh-lbl">points all time</span>
|
||||||
|
</span>
|
||||||
|
<span class="vh-stat">
|
||||||
|
<span class="vh-num cash">£{allTimeCash.toFixed(2)}</span>
|
||||||
|
<span class="vh-lbl">cash earned all time</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ViewHeader>
|
||||||
|
|
||||||
<!-- STAT TILES -->
|
<!-- STAT TILES -->
|
||||||
<div class="tiles">
|
<div class="tiles">
|
||||||
@@ -116,25 +406,49 @@
|
|||||||
<span class="tile-val">£{tileMoney.toFixed(2)}</span>
|
<span class="tile-val">£{tileMoney.toFixed(2)}</span>
|
||||||
<span class="tile-lbl">cash this week</span>
|
<span class="tile-lbl">cash this week</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="tile tile-chores">
|
<div class="tile tile-goals">
|
||||||
<span class="tile-val">{tileChores}</span>
|
<span class="tile-val">{tileGoals}</span>
|
||||||
<span class="tile-lbl">chores done</span>
|
<span class="tile-lbl">goals achieved</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CardGrid>
|
<CardGrid>
|
||||||
|
<!-- CHORES: this week | total -->
|
||||||
|
<Card cols={1} title="Chores">
|
||||||
|
<div class="chores-totals">
|
||||||
|
<div class="chore-total">
|
||||||
|
<span class="ct-val">{tileChores}</span>
|
||||||
|
<span class="ct-lbl">this week</span>
|
||||||
|
</div>
|
||||||
|
<div class="chore-total">
|
||||||
|
<span class="ct-val">{allTimeChores}</span>
|
||||||
|
<span class="ct-lbl">total all time</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="chart-wrap">
|
||||||
|
<canvas bind:this={choresCanvas} height="200"></canvas>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<!-- MEMBERS KANBAN -->
|
<!-- MEMBERS KANBAN -->
|
||||||
<Card cols={3} scrollX title="Members">
|
<Card cols={2} scrollX title="Members">
|
||||||
{#if !summary?.summaries?.length}
|
{#if !summary?.summaries?.length}
|
||||||
<p class="empty">No data yet this week</p>
|
<p class="empty">No data yet this week</p>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="kanban">
|
<div class="kanban">
|
||||||
{#each summary.summaries as s}
|
{#each summary.summaries as s}
|
||||||
{@const m = members.find((m2: any) => m2.id === s.memberId)}
|
{@const m = members.find((m2: any) => m2.id === s.memberId)}
|
||||||
{@const total = totalChoresFor(s.memberId)}
|
{@const wkTotal = weeklyPotentialFor(s.memberId)}
|
||||||
{@const pct = total > 0 ? Math.round((s.choresCompleted / total) * 100) : 0}
|
{@const wkDone = s.choresCompleted || 0}
|
||||||
<div class="column col-member">
|
{@const wkPct = wkTotal > 0 ? Math.min(100, Math.round((wkDone / wkTotal) * 100)) : 0}
|
||||||
<div class="member-hero">
|
{@const tdTotal = todayPotentialFor(s.memberId)}
|
||||||
|
{@const tdDone = todayDone(s)}
|
||||||
|
{@const tdPct = tdTotal > 0 ? Math.min(100, Math.round((tdDone / tdTotal) * 100)) : 0}
|
||||||
|
{@const sc = overallRateFor(s.memberId)}
|
||||||
|
{@const goals = goalsByMember.get(s.memberId)}
|
||||||
|
{@const goalsAll = goalsAllTimeByMember.get(s.memberId)}
|
||||||
|
{@const at = allTimeByMember.get(s.memberId)}
|
||||||
|
<div class="column col-member" style="--mc: {s.memberColor}">
|
||||||
<div class="member-head">
|
<div class="member-head">
|
||||||
<span class="dot" style="background:{s.memberColor}"></span>
|
<span class="dot" style="background:{s.memberColor}"></span>
|
||||||
<span class="member-name">{s.memberName}</span>
|
<span class="member-name">{s.memberName}</span>
|
||||||
@@ -142,28 +456,33 @@
|
|||||||
<a href="/{famSlug}/{handleOf(m.username)}" class="kanban-link">Kanban →</a>
|
<a href="/{famSlug}/{handleOf(m.username)}" class="kanban-link">Kanban →</a>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
<div class="donuts">
|
||||||
<div class="donut-wrap">
|
<div class="donut-wrap">
|
||||||
<canvas use:captureCanvas={s.memberId} height="100" width="100"></canvas>
|
<canvas use:captureCanvas={`${s.memberId}:today`} height="100" width="100"></canvas>
|
||||||
<div class="donut-center" style="color:{s.memberColor}">{pct}%</div>
|
<div class="donut-center" style="color:{s.memberColor}">{tdPct}%</div>
|
||||||
|
<span class="donut-label">Today</span>
|
||||||
|
</div>
|
||||||
|
<div class="donut-wrap">
|
||||||
|
<canvas use:captureCanvas={`${s.memberId}:week`} height="100" width="100"></canvas>
|
||||||
|
<div class="donut-center" style="color:{s.memberColor}">{wkPct}%</div>
|
||||||
|
<span class="donut-label">Week</span>
|
||||||
|
</div>
|
||||||
|
<div class="donut-wrap">
|
||||||
|
<canvas use:captureCanvas={`${s.memberId}:score`} height="100" width="100"></canvas>
|
||||||
|
<div class="donut-center" style="color:{s.memberColor}">{sc.pct}%</div>
|
||||||
|
<span class="donut-label">Overall</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="member-stats">
|
<div class="member-stats compact">
|
||||||
<div class="stat-row">
|
<div class="mstat" title="Points this week: {s.pointsEarned} · Cash: £{Number(s.moneyEarned || 0).toFixed(2)} · Pocket money: {pocketMoneyByMember.get(s.memberId) ?? 0}">
|
||||||
<span class="stat-lbl">Points</span>
|
<span class="mstat-tag">Week</span>
|
||||||
<span class="stat-val pts">{s.pointsEarned}</span>
|
<span class="mstat-main"><b class="pts">{s.pointsEarned}</b> pts · <b class="cash">£{Number(s.moneyEarned || 0).toFixed(2)}</b> · {wkDone}/{wkTotal}</span>
|
||||||
|
{#if (goals?.count ?? 0) > 0}<span class="mstat-goals" title={goals?.names.join(' · ')}>🎉{goals?.count}</span>{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-row">
|
<div class="mstat dim" title="Points all time: {at?.points ?? s.pointsEarned} · Cash: £{Number(at?.cash ?? s.moneyEarned ?? 0).toFixed(2)} · Pocket money: {pocketMoneyAllTimeByMember.get(s.memberId) ?? 0}">
|
||||||
<span class="stat-lbl">Cash</span>
|
<span class="mstat-tag">All time</span>
|
||||||
<span class="stat-val cash">£{Number(s.moneyEarned || 0).toFixed(2)}</span>
|
<span class="mstat-main"><b>{at?.points ?? s.pointsEarned}</b> pts · <b>£{Number(at?.cash ?? s.moneyEarned ?? 0).toFixed(2)}</b> · {sc.done}/{sc.total}</span>
|
||||||
</div>
|
{#if (goalsAll?.count ?? 0) > 0}<span class="mstat-goals" title={goalsAll?.names.join(' · ')}>🎉{goalsAll?.count}</span>{/if}
|
||||||
<div class="stat-row">
|
|
||||||
<span class="stat-lbl">Done</span>
|
|
||||||
<span class="stat-val">{s.choresCompleted}/{total}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="progress-row">
|
|
||||||
<div class="bar-wrap">
|
|
||||||
<div class="bar-fill" style="width:{pct}%"></div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -172,44 +491,162 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<!-- CHORES PER DAY CHART -->
|
<!-- WEEKLY TREND (cash / points / chores per member) -->
|
||||||
<Card cols={2} title="Chores per day">
|
<Card cols={3} title="Over time">
|
||||||
{#if summary?.summaries?.length}
|
<div class="trend-tabs">
|
||||||
|
<button
|
||||||
|
class="trend-tab"
|
||||||
|
class:active={metric === 'points'}
|
||||||
|
onclick={() => (metric = 'points')}>Points</button
|
||||||
|
>
|
||||||
|
<button class="trend-tab" class:active={metric === 'cash'} onclick={() => (metric = 'cash')}
|
||||||
|
>Cash</button
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="trend-tab"
|
||||||
|
class:active={metric === 'chores'}
|
||||||
|
onclick={() => (metric = 'chores')}>Chores</button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{#if distinctTrendWeeks() === 0}
|
||||||
|
<p class="empty">No weekly data yet</p>
|
||||||
|
{:else}
|
||||||
<div class="chart-wrap">
|
<div class="chart-wrap">
|
||||||
<canvas bind:this={choresCanvas} height="200"></canvas>
|
<canvas bind:this={trendCanvas} height="220"></canvas>
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<p class="empty">No data yet</p>
|
|
||||||
{/if}
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<!-- TEMPLATES -->
|
|
||||||
<Card cols={1} title="Templates ({templates.length})">
|
|
||||||
{#if templates.length === 0}
|
|
||||||
<p class="empty">No templates created</p>
|
|
||||||
{:else}
|
|
||||||
<div class="template-list">
|
|
||||||
{#each templates as t}
|
|
||||||
<div class="template-chip">
|
|
||||||
<span class="tpl-name">{t.name}</span>
|
|
||||||
<span class="tpl-meta">{t.defaultFrequency} · {t.defaultValue} {t.defaultType}</span>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</Card>
|
</Card>
|
||||||
</CardGrid>
|
</CardGrid>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
/* ── All-time header stats ── */
|
||||||
|
.vh-stats {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.vh-stat {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.1rem;
|
||||||
|
}
|
||||||
|
.vh-num {
|
||||||
|
font-size: 2.4rem;
|
||||||
|
font-weight: 900;
|
||||||
|
line-height: 1;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
.vh-num.pts {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vh-lbl {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
/* Shared-computer toggle (left) + all-time totals (right), one line */
|
||||||
|
.vh-toprow {
|
||||||
|
flex: 1 1 100%;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
.shared-hero-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
background: rgba(255, 255, 255, 0.16);
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.35);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.4rem 0.95rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.shared-hero-toggle:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.28);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.shared-hero-toggle.on {
|
||||||
|
background: #fff;
|
||||||
|
color: #4338ca;
|
||||||
|
border-color: transparent;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Chores card totals ── */
|
||||||
|
.chores-totals {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
background: linear-gradient(135deg, #0284c7, #38bdf8);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 0.9rem 1.1rem;
|
||||||
|
margin-bottom: 0.85rem;
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12);
|
||||||
|
}
|
||||||
|
.chore-total {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.ct-val {
|
||||||
|
font-size: 1.6rem;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.ct-lbl {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: rgba(255, 255, 255, 0.85);
|
||||||
|
}
|
||||||
|
|
||||||
|
.trend-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
background: #f3f4f6;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 2px;
|
||||||
|
margin-bottom: 0.85rem;
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
.trend-tab {
|
||||||
|
padding: 0.35rem 0.85rem;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
border-radius: 5px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #6b7280;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.trend-tab.active {
|
||||||
|
background: #fff;
|
||||||
|
color: #4338ca;
|
||||||
|
font-weight: 600;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Tiles (matching child dashboard) ── */
|
/* ── Tiles (matching child dashboard) ── */
|
||||||
.tiles {
|
.tiles {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, 1fr);
|
/* members 1 unit wide; goals / points / cash 2 units each (1+2+2+2 on one row) */
|
||||||
|
grid-template-columns: 1fr 2fr 2fr 2fr;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.tiles { grid-template-columns: repeat(2, 1fr); }
|
.tiles {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
.vh-num {
|
||||||
|
font-size: 1.5em;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.tile {
|
.tile {
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
@@ -220,11 +657,25 @@
|
|||||||
gap: 0.15rem;
|
gap: 0.15rem;
|
||||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12);
|
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12);
|
||||||
}
|
}
|
||||||
.tile-members { background: linear-gradient(135deg, #6366f1, #8b5cf6); }
|
.tile-members {
|
||||||
.tile-pts { background: linear-gradient(135deg, #7c3aed, #a855f7); }
|
background: linear-gradient(135deg, #6366f1, #8b5cf6);
|
||||||
.tile-cash { background: linear-gradient(135deg, #059669, #10b981); }
|
}
|
||||||
.tile-chores { background: linear-gradient(135deg, #0284c7, #38bdf8); }
|
.tile-pts {
|
||||||
.tile-val { font-size: 1.6rem; font-weight: 800; }
|
background: linear-gradient(135deg, #7c3aed, #a855f7);
|
||||||
|
}
|
||||||
|
.tile-cash {
|
||||||
|
background: linear-gradient(135deg, #059669, #10b981);
|
||||||
|
}
|
||||||
|
.tile-chores {
|
||||||
|
background: linear-gradient(135deg, #0284c7, #38bdf8);
|
||||||
|
}
|
||||||
|
.tile-goals {
|
||||||
|
background: linear-gradient(135deg, #f59e0b, #f97316);
|
||||||
|
}
|
||||||
|
.tile-val {
|
||||||
|
font-size: 1.6rem;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
.tile-lbl {
|
.tile-lbl {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -243,27 +694,27 @@
|
|||||||
border: 1px solid #e5e7eb;
|
border: 1px solid #e5e7eb;
|
||||||
border-top: 4px solid #6366f1;
|
border-top: 4px solid #6366f1;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 0.75rem;
|
padding: 0.9rem;
|
||||||
background: #fafbff;
|
background: #fafbff;
|
||||||
}
|
}
|
||||||
.col-member { border-top-color: #6366f1; background: #eef2ff; }
|
.col-member {
|
||||||
|
border-top-color: #6366f1;
|
||||||
.member-hero {
|
/* Translucent wash of the member's own colour (set via --mc inline) */
|
||||||
display: flex;
|
background: color-mix(in srgb, var(--mc, #6366f1) 12%, transparent);
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.member-head {
|
.member-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
}
|
}
|
||||||
.dot {
|
.dot {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
width: 10px; height: 10px;
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
@@ -273,6 +724,8 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
.kanban-link {
|
.kanban-link {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
@@ -281,15 +734,26 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.kanban-link:hover { text-decoration: underline; }
|
.kanban-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.donuts {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.7rem;
|
||||||
|
margin: 1.1rem 0 1.2rem;
|
||||||
|
}
|
||||||
.donut-wrap {
|
.donut-wrap {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 70px; height: 70px;
|
width: 54px;
|
||||||
|
height: 54px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.donut-wrap canvas {
|
.donut-wrap canvas {
|
||||||
max-width: 70px; max-height: 70px;
|
max-width: 54px;
|
||||||
|
max-height: 54px;
|
||||||
}
|
}
|
||||||
.donut-center {
|
.donut-center {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -297,67 +761,117 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-size: 0.85rem;
|
font-size: 0.8rem;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
.donut-label {
|
||||||
|
position: absolute;
|
||||||
|
bottom: -15px;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.6rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #6b7280;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
.member-stats {
|
.member-stats {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.3rem;
|
gap: 0.45rem;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
|
.member-stats.compact .mstat {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #374151;
|
||||||
|
background: rgba(255, 255, 255, 0.65);
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 0.45rem 0.65rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.member-stats.compact .mstat.dim {
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
.mstat-tag {
|
||||||
|
font-size: 0.6rem;
|
||||||
|
font-weight: 800;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: #9ca3af;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.mstat-main {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.mstat-main b {
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
.mstat-main b.pts {
|
||||||
|
color: #7c3aed;
|
||||||
|
}
|
||||||
|
.mstat-main b.cash {
|
||||||
|
color: #059669;
|
||||||
|
}
|
||||||
|
.mstat-goals {
|
||||||
|
margin-left: auto;
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #059669;
|
||||||
|
}
|
||||||
.stat-row {
|
.stat-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
}
|
}
|
||||||
.stat-lbl { color: #6b7280; font-weight: 500; }
|
.stat-group-lbl {
|
||||||
.stat-val { font-weight: 700; }
|
font-size: 0.65rem;
|
||||||
.stat-val.pts { color: #7c3aed; }
|
font-weight: 800;
|
||||||
.stat-val.cash { color: #059669; }
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.07em;
|
||||||
.progress-row {
|
color: #9ca3af;
|
||||||
margin-top: 0.2rem;
|
margin-top: 0.35rem;
|
||||||
}
|
}
|
||||||
.bar-wrap {
|
.stat-group-lbl:first-child {
|
||||||
height: 8px;
|
margin-top: 0;
|
||||||
background: #e5e7eb;
|
|
||||||
border-radius: 4px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
.bar-fill {
|
.stat-lbl {
|
||||||
height: 100%;
|
color: #6b7280;
|
||||||
background: linear-gradient(90deg, #6366f1, #8b5cf6);
|
font-weight: 500;
|
||||||
border-radius: 4px;
|
}
|
||||||
transition: width 0.3s ease;
|
.stat-val {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.stat-val.pts {
|
||||||
|
color: #7c3aed;
|
||||||
|
}
|
||||||
|
.stat-val.cash {
|
||||||
|
color: #059669;
|
||||||
|
}
|
||||||
|
.stat-val.goals {
|
||||||
|
color: #059669;
|
||||||
|
}
|
||||||
|
.stat-val.pm {
|
||||||
|
color: #d97706;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Chart ── */
|
/* ── Chart ── */
|
||||||
.chart-wrap { min-height: 200px; }
|
.chart-wrap {
|
||||||
.chart-wrap canvas { max-height: 300px; }
|
min-height: 200px;
|
||||||
|
|
||||||
/* ── Templates ── */
|
|
||||||
.template-list {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.4rem;
|
|
||||||
}
|
}
|
||||||
.template-chip {
|
.chart-wrap canvas {
|
||||||
display: flex;
|
max-height: 300px;
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 0.5rem 0.75rem;
|
|
||||||
border: 1px solid #e5e7eb;
|
|
||||||
border-left: 4px solid #6366f1;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: #fafbff;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
}
|
||||||
.tpl-name { font-weight: 600; font-size: 0.85rem; }
|
|
||||||
.tpl-meta { font-size: 0.75rem; color: #6b7280; white-space: nowrap; }
|
|
||||||
|
|
||||||
/* ── Shared ── */
|
/* ── Shared ── */
|
||||||
.empty {
|
.empty {
|
||||||
@@ -366,4 +880,13 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
}
|
}
|
||||||
|
/* Mobile: member columns stack full width (same convention as the chores
|
||||||
|
kanban) instead of the 450px horizontal scroll. Declared last so it wins
|
||||||
|
over the base .kanban rule. */
|
||||||
|
@media (max-width: 639px) {
|
||||||
|
.kanban {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,20 +1,28 @@
|
|||||||
import { fail, redirect } from '@sveltejs/kit';
|
import { fail, redirect } from '@sveltejs/kit';
|
||||||
import { pbUser, createPbClient } from '$lib/server/pocketbase';
|
import { pbUser, createPbClient } from '$lib/server/pocketbase';
|
||||||
import { createServices } from '$lib/server/services';
|
import { createServices } from '$lib/server/services';
|
||||||
|
import { getPlatformFlags } from '$lib/server/platform';
|
||||||
|
import { pbAdmin } from '$lib/server/pocketbase';
|
||||||
|
|
||||||
export async function load(event) {
|
export async function load(event) {
|
||||||
const session = event.locals.user;
|
const session = event.locals.user;
|
||||||
|
const demoMode = (await getPlatformFlags()).demo;
|
||||||
|
const isDemoParent = session?.username === 'demo';
|
||||||
|
const famSlug = event.params.fam;
|
||||||
|
const username = event.params.username;
|
||||||
|
|
||||||
|
// Demo parent visiting a child's route → load child kanban view.
|
||||||
|
const demoParentVisitingChild = demoMode && isDemoParent && session?.username !== username;
|
||||||
|
|
||||||
// Parent (role=parent, session auth) → admin overview.
|
// Parent (role=parent, session auth) → admin overview.
|
||||||
// Branch on role, NOT presence: a child who OTP-logged-in also has a
|
// Branch on role, NOT presence: a child who OTP-logged-in also has a
|
||||||
// `users` session (locals.user) and must not hit the parent path.
|
// `users` session (locals.user) and must not hit the parent path.
|
||||||
if (session && session.role === 'parent') {
|
if (session && session.role === 'parent' && !demoParentVisitingChild) {
|
||||||
const famId = session.famId;
|
if (!(demoMode && isDemoParent)) {
|
||||||
const famSlug = event.params.fam;
|
|
||||||
const username = event.params.username;
|
|
||||||
if (session.name && session.username && session.username !== username) {
|
if (session.name && session.username && session.username !== username) {
|
||||||
throw redirect(303, `/${famSlug}/${session.username}`);
|
throw redirect(303, `/${famSlug}/${session.username}`);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
const s = createServices(pbUser(event), session);
|
const s = createServices(pbUser(event), session);
|
||||||
const [
|
const [
|
||||||
members,
|
members,
|
||||||
@@ -27,15 +35,15 @@ export async function load(event) {
|
|||||||
completions,
|
completions,
|
||||||
settings
|
settings
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
s.crud.list('members', famId),
|
s.crud.list('members', session.famId),
|
||||||
s.crud.list('chore-templates', famId),
|
s.crud.list('chore-templates', session.famId),
|
||||||
s.crud.list('assigned-chores', famId),
|
s.crud.list('assigned-chores', session.famId),
|
||||||
s.fam.weeklySummary(famId),
|
s.fam.weeklySummary(session.famId),
|
||||||
s.fam.get(famId),
|
s.fam.get(session.famId),
|
||||||
s.crud.list('rewards', famId),
|
s.crud.list('rewards', session.famId),
|
||||||
s.crud.list('bonus-configs', famId),
|
s.crud.list('bonus-configs', session.famId),
|
||||||
s.crud.list('completions', famId),
|
s.crud.list('completions', session.famId),
|
||||||
s.settings.get(famId).catch(() => ({ simulateEow: false, webhookUrl: '' }))
|
s.settings.get(session.famId)
|
||||||
]);
|
]);
|
||||||
return {
|
return {
|
||||||
role: 'parent',
|
role: 'parent',
|
||||||
@@ -52,18 +60,24 @@ export async function load(event) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Child (users auth, role=child) → kanban
|
// Child (users auth, role=child) → kanban.
|
||||||
const famSlug = event.params.fam;
|
// Also demo parent visiting a child's route.
|
||||||
const username = event.params.username;
|
|
||||||
|
|
||||||
// A child should always land on their own kanban route.
|
// A child should always land on their own kanban route.
|
||||||
|
if (!(demoMode && isDemoParent)) {
|
||||||
if (session?.username && session.username !== username) {
|
if (session?.username && session.username !== username) {
|
||||||
throw redirect(303, `/${famSlug}/${session.username}`);
|
throw redirect(303, `/${famSlug}/${session.username}`);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const pbToken = event.cookies.get('pb_token') || '';
|
const pbToken = event.locals.pbToken || '';
|
||||||
const famId = session?.famId || '';
|
const famId = session?.famId || '';
|
||||||
const childId = session?.id || '';
|
// When a demo parent visits a child's route, session.id is the parent's PB
|
||||||
|
// record. Look up the actual child user by username so chores load correctly.
|
||||||
|
const childId = demoParentVisitingChild
|
||||||
|
? (await pbAdmin.getList('users', `famId='${famId}' && username='${famSlug}:${username}'`))?.[0]
|
||||||
|
?.id || ''
|
||||||
|
: session?.id || '';
|
||||||
|
|
||||||
const empty = {
|
const empty = {
|
||||||
role: 'child' as const,
|
role: 'child' as const,
|
||||||
@@ -73,6 +87,7 @@ export async function load(event) {
|
|||||||
verified: false,
|
verified: false,
|
||||||
memberName: session?.name || '',
|
memberName: session?.name || '',
|
||||||
memberColor: session?.color || '',
|
memberColor: session?.color || '',
|
||||||
|
partyEmoji: (session as any)?.partyEmoji || '',
|
||||||
templates: [] as never[],
|
templates: [] as never[],
|
||||||
assigned: [] as never[],
|
assigned: [] as never[],
|
||||||
completions: [] as never[],
|
completions: [] as never[],
|
||||||
@@ -97,6 +112,7 @@ export async function load(event) {
|
|||||||
verified: true,
|
verified: true,
|
||||||
memberName: session?.name || '',
|
memberName: session?.name || '',
|
||||||
memberColor: session?.color || '',
|
memberColor: session?.color || '',
|
||||||
|
partyEmoji: (session as any)?.partyEmoji || '',
|
||||||
templates: chores.templates || [],
|
templates: chores.templates || [],
|
||||||
assigned: chores.assigned || [],
|
assigned: chores.assigned || [],
|
||||||
completions: chores.completions || [],
|
completions: chores.completions || [],
|
||||||
@@ -105,8 +121,7 @@ export async function load(event) {
|
|||||||
tallies: {} as Record<string, unknown>,
|
tallies: {} as Record<string, unknown>,
|
||||||
payday: chores.payday,
|
payday: chores.payday,
|
||||||
paydayTime: chores.paydayTime || '18:00',
|
paydayTime: chores.paydayTime || '18:00',
|
||||||
timezone: chores.timezone || 'auto',
|
timezone: chores.timezone || 'auto'
|
||||||
simulateEow: !!chores.simulateEow
|
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return empty;
|
return empty;
|
||||||
@@ -114,33 +129,6 @@ export async function load(event) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const actions = {
|
export const actions = {
|
||||||
setEow: async (event) => {
|
|
||||||
if (!event.locals.user) throw redirect(303, '/login');
|
|
||||||
const famId = event.locals.user.famId;
|
|
||||||
const fd = await event.request.formData();
|
|
||||||
const on = fd.get('on') === 'true';
|
|
||||||
try {
|
|
||||||
const s = createServices(pbUser(event), event.locals.user);
|
|
||||||
const result = await s.settings.update(famId, { simulateEow: on });
|
|
||||||
return { simulateEow: result.simulateEow };
|
|
||||||
} catch (e) {
|
|
||||||
return { error: e instanceof Error ? e.message : 'Failed to update settings' };
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
previewEow: async (event) => {
|
|
||||||
if (!event.locals.user) throw redirect(303, '/login');
|
|
||||||
const famId = event.locals.user.famId;
|
|
||||||
try {
|
|
||||||
const s = createServices(pbUser(event), event.locals.user);
|
|
||||||
const preview = await s.fam.eowPreview(famId);
|
|
||||||
await s.settings.update(famId, { simulateEow: true });
|
|
||||||
return { preview, simulateEow: true };
|
|
||||||
} catch (e) {
|
|
||||||
return { error: e instanceof Error ? e.message : 'Failed to preview payday' };
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
claim: async (event) => {
|
claim: async (event) => {
|
||||||
if (!event.locals.user) throw redirect(303, '/login');
|
if (!event.locals.user) throw redirect(303, '/login');
|
||||||
const famId = event.locals.user.famId;
|
const famId = event.locals.user.famId;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,15 @@
|
|||||||
import { fail, redirect } from '@sveltejs/kit';
|
import { fail, redirect } from '@sveltejs/kit';
|
||||||
import { pbUser } from '$lib/server/pocketbase';
|
import { pbUser } from '$lib/server/pocketbase';
|
||||||
|
import { cleanupExpiredTodos } from '$lib/server/services/chores';
|
||||||
import type { ChoreTemplate, Member, AssignedChore, Completion, Season } from '$lib/types';
|
import type { ChoreTemplate, Member, AssignedChore, Completion, Season } from '$lib/types';
|
||||||
|
|
||||||
export async function load(event) {
|
export async function load(event) {
|
||||||
if (!event.locals.user) throw redirect(303, '/login');
|
if (!event.locals.user) throw redirect(303, '/login');
|
||||||
const famId = event.locals.user.famId;
|
const famId = event.locals.user.famId;
|
||||||
const pb = pbUser(event);
|
const pb = pbUser(event);
|
||||||
|
// Defensive sweep: purge todos whose deadline was in a prior week. Runs as
|
||||||
|
// the parent (delete permission), no-ops if there's nothing to remove.
|
||||||
|
await cleanupExpiredTodos(pb, famId).catch(() => {});
|
||||||
const [templates, members, assigned, seasons, completions] = await Promise.all([
|
const [templates, members, assigned, seasons, completions] = await Promise.all([
|
||||||
pb.collection('chore_templates').getFullList({ filter: `(famId = '${famId}' || global = true)` }),
|
pb.collection('chore_templates').getFullList({ filter: `(famId = '${famId}' || global = true)` }),
|
||||||
pb.collection('users').getFullList({
|
pb.collection('users').getFullList({
|
||||||
@@ -112,7 +116,8 @@ export const actions = {
|
|||||||
const startDate = fd.get('startDate') as string;
|
const startDate = fd.get('startDate') as string;
|
||||||
if (type) data.type = type;
|
if (type) data.type = type;
|
||||||
if (type === 'emoji') {
|
if (type === 'emoji') {
|
||||||
data.value = 0;
|
// 1, not 0: PB treats 0 as blank on the required `value` field.
|
||||||
|
data.value = 1;
|
||||||
data.emoji = (fd.get('emoji') as string) || '🎉';
|
data.emoji = (fd.get('emoji') as string) || '🎉';
|
||||||
} else if (fd.get('value')) data.value = parseFloat(fd.get('value') as string) || 0;
|
} else if (fd.get('value')) data.value = parseFloat(fd.get('value') as string) || 0;
|
||||||
data.customName = customName || undefined;
|
data.customName = customName || undefined;
|
||||||
@@ -156,7 +161,7 @@ export const actions = {
|
|||||||
const memberId = fd.get('memberId') as string;
|
const memberId = fd.get('memberId') as string;
|
||||||
const name = fd.get('name') as string;
|
const name = fd.get('name') as string;
|
||||||
const type = fd.get('type') as string;
|
const type = fd.get('type') as string;
|
||||||
const value = type === 'emoji' ? 0 : (parseFloat(fd.get('value') as string) || 0);
|
const value = type === 'emoji' ? 1 : (parseFloat(fd.get('value') as string) || 0);
|
||||||
const emoji = type === 'emoji' ? ((fd.get('emoji') as string) || '🎉') : '';
|
const emoji = type === 'emoji' ? ((fd.get('emoji') as string) || '🎉') : '';
|
||||||
const todoStart = fd.get('todoStart') as string;
|
const todoStart = fd.get('todoStart') as string;
|
||||||
const todoCompleteBy = fd.get('todoCompleteBy') as string;
|
const todoCompleteBy = fd.get('todoCompleteBy') as string;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -5,14 +5,15 @@ export async function load(event) {
|
|||||||
if (!event.locals.user) throw redirect(303, '/login');
|
if (!event.locals.user) throw redirect(303, '/login');
|
||||||
const famId = event.locals.user.famId;
|
const famId = event.locals.user.famId;
|
||||||
const s = servicesFor(event);
|
const s = servicesFor(event);
|
||||||
const [rewards, members, assigned, templates, completions] = await Promise.all([
|
const [rewards, members, assigned, templates, completions, fam] = await Promise.all([
|
||||||
s.crud.list('rewards', famId),
|
s.crud.list('rewards', famId),
|
||||||
s.crud.list('members', famId),
|
s.crud.list('members', famId),
|
||||||
s.crud.list('assigned-chores', famId),
|
s.crud.list('assigned-chores', famId),
|
||||||
s.crud.list('chore-templates', famId),
|
s.crud.list('chore-templates', famId),
|
||||||
s.crud.list('completions', famId)
|
s.crud.list('completions', famId),
|
||||||
|
s.fam.get(famId)
|
||||||
]);
|
]);
|
||||||
return { rewards, members, assigned, templates, completions };
|
return { rewards, members, assigned, templates, completions, fam };
|
||||||
}
|
}
|
||||||
|
|
||||||
export const actions = {
|
export const actions = {
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
import { famStore } from '$lib/stores/fam.svelte';
|
import { famStore } from '$lib/stores/fam.svelte';
|
||||||
import { ViewHeader, CardGrid, Card, Button } from '$lib/components';
|
import { ViewHeader, CardGrid, Card, Button } from '$lib/components';
|
||||||
import { formatDDMMYY } from '$lib/format';
|
import { formatDDMMYY, formatShortDate } from '$lib/format';
|
||||||
|
import { resolveTz, weekStart as tzWeekStart, addDaysStr } from '@shared/timezone';
|
||||||
import type { Member, AssignedChore, ChoreTemplate, Completion, Reward } from '$lib/types';
|
import type { Member, AssignedChore, ChoreTemplate, Completion, Reward } from '$lib/types';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
@@ -24,6 +25,28 @@
|
|||||||
famStore.initialized ? famStore.templates : ((data.templates || []) as ChoreTemplate[])
|
famStore.initialized ? famStore.templates : ((data.templates || []) as ChoreTemplate[])
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ── Week navigation (mirrors the member dashboard: payday-anchored weeks) ──
|
||||||
|
// Anchor comes from data.fam at SSR; famStore takes over after init so a
|
||||||
|
// payday/timezone change in settings flows through too.
|
||||||
|
let fam = $derived(
|
||||||
|
famStore.initialized ? (famStore.fam as any) : ((data as any).fam as any) || {}
|
||||||
|
);
|
||||||
|
let famPayday = $derived(fam?.payday != null ? Number(fam.payday) : 1);
|
||||||
|
let famTz = $derived(resolveTz(fam?.timezone || 'auto'));
|
||||||
|
let currentWeek = $derived.by(() => tzWeekStart(famPayday, famTz));
|
||||||
|
let weekStart = $state(tzWeekStart(famPayday, famTz));
|
||||||
|
let weekEnd = $derived(addDaysStr(weekStart, 6));
|
||||||
|
let isCurrentWeek = $derived(weekStart === currentWeek);
|
||||||
|
|
||||||
|
function inWeek(dateStr: string | undefined): boolean {
|
||||||
|
const d = (dateStr || '').slice(0, 10);
|
||||||
|
return !!d && d >= weekStart && d <= weekEnd;
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigateWeek(amount: number) {
|
||||||
|
weekStart = addDaysStr(weekStart, amount * 7);
|
||||||
|
}
|
||||||
|
|
||||||
let activeTab = $state('rewards');
|
let activeTab = $state('rewards');
|
||||||
|
|
||||||
let memberMap = $derived.by(() => new Map(members.map((m: any) => [m.id, m])));
|
let memberMap = $derived.by(() => new Map(members.map((m: any) => [m.id, m])));
|
||||||
@@ -43,10 +66,22 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Rewards tab ──
|
// ── Rewards tab ──
|
||||||
|
// Rewards store the label as "Bonus name – prize" (built in bonuses.ts:
|
||||||
|
// cash → "Reading Bonus 3 – £5.00", points → "Reading Bonus 3 – 50",
|
||||||
|
// items → "Reading Bonus 3 – fluffy toy"). Split it so the description
|
||||||
|
// column shows the bonus name and the amount column shows the prize;
|
||||||
|
// labels without a separator keep the full text in both columns.
|
||||||
|
function rewardLabelParts(r: any): { name: string; prize: string } {
|
||||||
|
const label = r.label || '';
|
||||||
|
const idx = label.indexOf(' – ');
|
||||||
|
if (idx === -1) return { name: label, prize: label };
|
||||||
|
return { name: label.slice(0, idx), prize: label.slice(idx + 3) };
|
||||||
|
}
|
||||||
|
|
||||||
function rewardAmount(r: any): string {
|
function rewardAmount(r: any): string {
|
||||||
if (r.rewardType === 'cash') return `£${Number(r.value).toFixed(2)}`;
|
if (r.rewardType === 'cash') return `£${Number(r.value).toFixed(2)}`;
|
||||||
if (r.rewardType === 'points') return `${r.value} pts`;
|
if (r.rewardType === 'points') return `${r.value} pts`;
|
||||||
return r.label;
|
return rewardLabelParts(r).prize;
|
||||||
}
|
}
|
||||||
|
|
||||||
function rewardStatus(r: any): string {
|
function rewardStatus(r: any): string {
|
||||||
@@ -71,8 +106,11 @@
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let weekRewards = $derived(sortedRewards.filter((r: any) => inWeek(r.date)));
|
||||||
|
|
||||||
|
// Footer total matches the visible week, not all-time history.
|
||||||
let totalOutstanding = $derived(
|
let totalOutstanding = $derived(
|
||||||
rewards
|
weekRewards
|
||||||
.filter((r: any) => r.status !== 'claimed')
|
.filter((r: any) => r.status !== 'claimed')
|
||||||
.filter((r: any) => r.rewardType === 'cash')
|
.filter((r: any) => r.rewardType === 'cash')
|
||||||
.reduce((sum: number, r: any) => sum + Number(r.value), 0)
|
.reduce((sum: number, r: any) => sum + Number(r.value), 0)
|
||||||
@@ -93,6 +131,9 @@
|
|||||||
[...todoCompletions].sort((a: any, b: any) => (b.date || '').localeCompare(a.date || ''))
|
[...todoCompletions].sort((a: any, b: any) => (b.date || '').localeCompare(a.date || ''))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let weekChoreCompletions = $derived(sortedChoreCompletions.filter((c: any) => inWeek(c.date)));
|
||||||
|
let weekTodoCompletions = $derived(sortedTodoCompletions.filter((c: any) => inWeek(c.date)));
|
||||||
|
|
||||||
let toast = $state('');
|
let toast = $state('');
|
||||||
|
|
||||||
async function fire() {
|
async function fire() {
|
||||||
@@ -125,19 +166,27 @@
|
|||||||
active: activeTab,
|
active: activeTab,
|
||||||
onchange: (v: string) => (activeTab = v)
|
onchange: (v: string) => (activeTab = v)
|
||||||
}}
|
}}
|
||||||
|
weeknav={{
|
||||||
|
current: isCurrentWeek ? 'This week' : formatShortDate(weekStart),
|
||||||
|
onPrev: () => navigateWeek(-1),
|
||||||
|
onNext: () => navigateWeek(1),
|
||||||
|
onCurrent: () => (weekStart = currentWeek),
|
||||||
|
isCurrent: isCurrentWeek
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{#if toast}
|
{#if toast}
|
||||||
<div class="toast">{toast}</div>
|
<div class="toast">{toast}</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<CardGrid>
|
<CardGrid stackTablet>
|
||||||
{#if activeTab === 'rewards'}
|
{#if activeTab === 'rewards'}
|
||||||
<Card cols={3}>
|
<Card cols={3}>
|
||||||
{#if rewards.length === 0}
|
{#if weekRewards.length === 0}
|
||||||
<p class="empty">
|
<p class="empty">
|
||||||
No rewards yet. Points earned will automatically create bonus rewards when thresholds are
|
{isCurrentWeek
|
||||||
met.
|
? 'No rewards yet. Points earned will automatically create bonus rewards when thresholds are met.'
|
||||||
|
: `No rewards for the week of ${formatShortDate(weekStart)}.`}
|
||||||
</p>
|
</p>
|
||||||
{:else}
|
{:else}
|
||||||
<table>
|
<table>
|
||||||
@@ -152,15 +201,19 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{#each sortedRewards as r}
|
{#each weekRewards as r}
|
||||||
{@const outstanding = isOutstanding(r)}
|
{@const outstanding = isOutstanding(r)}
|
||||||
<tr class:claimed={!outstanding && !isRequested(r)} class:requested={isRequested(r)}>
|
<tr
|
||||||
|
class:claimed={!outstanding && !isRequested(r)}
|
||||||
|
class:requested={isRequested(r)}
|
||||||
|
style="--mc:{memberColor(r.memberId)}"
|
||||||
|
>
|
||||||
<td class="date">{r.date ? formatDDMMYY(r.date) : '—'}</td>
|
<td class="date">{r.date ? formatDDMMYY(r.date) : '—'}</td>
|
||||||
<td>
|
<td>
|
||||||
<span class="dot" style="background:{memberColor(r.memberId)}"></span>
|
<span class="dot" style="background:{memberColor(r.memberId)}"></span>
|
||||||
{memberName(r.memberId)}
|
{memberName(r.memberId)}
|
||||||
</td>
|
</td>
|
||||||
<td>{r.label}</td>
|
<td>{rewardLabelParts(r).name}</td>
|
||||||
<td class="num">{rewardAmount(r)}</td>
|
<td class="num">{rewardAmount(r)}</td>
|
||||||
<td>
|
<td>
|
||||||
<span
|
<span
|
||||||
@@ -208,8 +261,12 @@
|
|||||||
</Card>
|
</Card>
|
||||||
{:else if activeTab === 'chores'}
|
{:else if activeTab === 'chores'}
|
||||||
<Card cols={3} scrollX title="Chore completions">
|
<Card cols={3} scrollX title="Chore completions">
|
||||||
{#if sortedChoreCompletions.length === 0}
|
{#if weekChoreCompletions.length === 0}
|
||||||
<p class="empty">No chore completions yet.</p>
|
<p class="empty">
|
||||||
|
{isCurrentWeek
|
||||||
|
? 'No chore completions yet.'
|
||||||
|
: `No chore completions for the week of ${formatShortDate(weekStart)}.`}
|
||||||
|
</p>
|
||||||
{:else}
|
{:else}
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
@@ -221,9 +278,9 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{#each sortedChoreCompletions as c}
|
{#each weekChoreCompletions as c}
|
||||||
{@const a = assignedMap.get(c.assignedChoreId)}
|
{@const a = assignedMap.get(c.assignedChoreId)}
|
||||||
<tr>
|
<tr style="--mc:{memberColor(c.memberId)}">
|
||||||
<td class="date">{formatDDMMYY(c.date)}</td>
|
<td class="date">{formatDDMMYY(c.date)}</td>
|
||||||
<td>
|
<td>
|
||||||
<span class="dot" style="background:{memberColor(c.memberId)}"></span>
|
<span class="dot" style="background:{memberColor(c.memberId)}"></span>
|
||||||
@@ -242,8 +299,12 @@
|
|||||||
</Card>
|
</Card>
|
||||||
{:else}
|
{:else}
|
||||||
<Card cols={3} scrollX title="Todo completions">
|
<Card cols={3} scrollX title="Todo completions">
|
||||||
{#if sortedTodoCompletions.length === 0}
|
{#if weekTodoCompletions.length === 0}
|
||||||
<p class="empty">No todos completed yet.</p>
|
<p class="empty">
|
||||||
|
{isCurrentWeek
|
||||||
|
? 'No todos completed yet.'
|
||||||
|
: `No todos completed for the week of ${formatShortDate(weekStart)}.`}
|
||||||
|
</p>
|
||||||
{:else}
|
{:else}
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
@@ -255,9 +316,9 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{#each sortedTodoCompletions as c}
|
{#each weekTodoCompletions as c}
|
||||||
{@const a = assignedMap.get(c.assignedChoreId)}
|
{@const a = assignedMap.get(c.assignedChoreId)}
|
||||||
<tr>
|
<tr style="--mc:{memberColor(c.memberId)}">
|
||||||
<td class="date">{formatDDMMYY(c.date)}</td>
|
<td class="date">{formatDDMMYY(c.date)}</td>
|
||||||
<td>
|
<td>
|
||||||
<span class="dot" style="background:{memberColor(c.memberId)}"></span>
|
<span class="dot" style="background:{memberColor(c.memberId)}"></span>
|
||||||
@@ -356,4 +417,20 @@
|
|||||||
border-top: 2px solid #d1d5db;
|
border-top: 2px solid #d1d5db;
|
||||||
padding-top: 0.6rem;
|
padding-top: 0.6rem;
|
||||||
}
|
}
|
||||||
|
@media (max-width: 639px) {
|
||||||
|
/* Hide Member (2nd) and Status (5th, rewards only) columns */
|
||||||
|
th:nth-child(2),
|
||||||
|
td:nth-child(2) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
th:nth-child(5),
|
||||||
|
td:nth-child(5) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
/* Member colour as the entry background instead of the member column */
|
||||||
|
tbody tr,
|
||||||
|
tbody tr.requested {
|
||||||
|
background: color-mix(in srgb, var(--mc, #6366f1) 10%, transparent);
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,13 +1,45 @@
|
|||||||
import { redirect } from '@sveltejs/kit';
|
import { redirect } from '@sveltejs/kit';
|
||||||
import { servicesFor } from '$lib/server/servicesFor';
|
import { servicesFor } from '$lib/server/servicesFor';
|
||||||
|
import { getPlatformFlags } from '$lib/server/platform';
|
||||||
|
import { hasPin } from '$lib/server/pins';
|
||||||
|
|
||||||
|
async function isDemoMode(): Promise<boolean> {
|
||||||
|
return (await getPlatformFlags()).demo;
|
||||||
|
}
|
||||||
|
|
||||||
export async function load(event) {
|
export async function load(event) {
|
||||||
|
if (await isDemoMode()) {
|
||||||
|
return {
|
||||||
|
verified: false,
|
||||||
|
role: '',
|
||||||
|
token: '',
|
||||||
|
memberId: '',
|
||||||
|
famId: '',
|
||||||
|
memberName: '',
|
||||||
|
memberColor: '',
|
||||||
|
pattern: '',
|
||||||
|
themeSize: '',
|
||||||
|
themeOpacity: '',
|
||||||
|
email: '',
|
||||||
|
demoMode: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const session = event.locals.user;
|
const session = event.locals.user;
|
||||||
const famSlug = event.params.fam;
|
const famSlug = event.params.fam;
|
||||||
const username = event.params.username;
|
const username = event.params.username;
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
return { verified: false, token: '', memberId: '', famId: '', memberName: '', memberColor: '', email: '' };
|
return {
|
||||||
|
verified: false,
|
||||||
|
role: '',
|
||||||
|
token: '',
|
||||||
|
memberId: '',
|
||||||
|
famId: '',
|
||||||
|
memberName: '',
|
||||||
|
memberColor: '',
|
||||||
|
email: ''
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const famId = session.famId;
|
const famId = session.famId;
|
||||||
@@ -21,20 +53,52 @@ export async function load(event) {
|
|||||||
try {
|
try {
|
||||||
const me = await s.fam.getProfile(famId);
|
const me = await s.fam.getProfile(famId);
|
||||||
return {
|
return {
|
||||||
verified: true, token: '', memberId: me.id, famId,
|
verified: true,
|
||||||
memberName: me.name, memberColor: me.color, email: me.email || '',
|
role: 'parent',
|
||||||
session: true,
|
token: '',
|
||||||
|
memberId: me.id,
|
||||||
|
famId,
|
||||||
|
memberName: me.name,
|
||||||
|
memberColor: me.color,
|
||||||
|
pattern: me.pattern || '',
|
||||||
|
themeSize: me.themeSize || '',
|
||||||
|
themeOpacity: me.themeOpacity || '',
|
||||||
|
partyEmoji: me.partyEmoji || '',
|
||||||
|
email: me.email || '',
|
||||||
|
hasPin: false,
|
||||||
|
session: true
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return { verified: false, token: '', memberId: '', famId: '', memberName: '', memberColor: '', email: '' };
|
return {
|
||||||
|
verified: false,
|
||||||
|
role: '',
|
||||||
|
token: '',
|
||||||
|
memberId: '',
|
||||||
|
famId: '',
|
||||||
|
memberName: '',
|
||||||
|
memberColor: '',
|
||||||
|
email: '',
|
||||||
|
hasPin: false
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Child — profile lives on the users record, read from the session.
|
// Child — profile lives on the users record, read from the session.
|
||||||
return {
|
return {
|
||||||
verified: true, token: event.cookies.get('pb_token') || '', memberId: session.id, famId,
|
verified: true,
|
||||||
memberName: session.name || '', memberColor: session.color || '', email: '',
|
role: 'child',
|
||||||
session: true,
|
token: event.locals.pbToken || '',
|
||||||
|
memberId: session.id,
|
||||||
|
famId,
|
||||||
|
memberName: session.name || '',
|
||||||
|
memberColor: session.color || '',
|
||||||
|
pattern: session.pattern || '',
|
||||||
|
themeSize: session.themeSize || '',
|
||||||
|
themeOpacity: session.themeOpacity || '',
|
||||||
|
partyEmoji: session.partyEmoji || '',
|
||||||
|
email: '',
|
||||||
|
hasPin: await hasPin(session.id).catch(() => false),
|
||||||
|
session: true
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,6 +111,10 @@ export const actions = {
|
|||||||
const name = fd.get('name') as string;
|
const name = fd.get('name') as string;
|
||||||
const color = fd.get('color') as string;
|
const color = fd.get('color') as string;
|
||||||
const email = fd.get('email') as string;
|
const email = fd.get('email') as string;
|
||||||
|
const pattern = (fd.get('pattern') as string) || '';
|
||||||
|
const themeSize = (fd.get('themeSize') as string) || '';
|
||||||
|
const themeOpacity = (fd.get('themeOpacity') as string) || '';
|
||||||
|
const partyEmoji = String(fd.get('partyEmoji') || '').trim().slice(0, 8);
|
||||||
|
|
||||||
const s = servicesFor(event);
|
const s = servicesFor(event);
|
||||||
const famId = session.famId;
|
const famId = session.famId;
|
||||||
@@ -56,9 +124,22 @@ export const actions = {
|
|||||||
const data: Record<string, string> = {};
|
const data: Record<string, string> = {};
|
||||||
if (name) data.name = name;
|
if (name) data.name = name;
|
||||||
if (color) data.color = color;
|
if (color) data.color = color;
|
||||||
|
data.pattern = pattern;
|
||||||
|
data.themeSize = themeSize;
|
||||||
|
data.themeOpacity = themeOpacity;
|
||||||
|
if (partyEmoji) data.partyEmoji = partyEmoji;
|
||||||
data.email = email || '';
|
data.email = email || '';
|
||||||
const me = await s.fam.updateProfile(famId, data);
|
const me = await s.fam.updateProfile(famId, data);
|
||||||
return { success: true, name: me.name, color: me.color, email: me.email };
|
return {
|
||||||
|
success: true,
|
||||||
|
name: me.name,
|
||||||
|
color: me.color,
|
||||||
|
pattern: me.pattern,
|
||||||
|
themeSize: me.themeSize,
|
||||||
|
themeOpacity: me.themeOpacity,
|
||||||
|
partyEmoji: me.partyEmoji,
|
||||||
|
email: me.email
|
||||||
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return { error: e instanceof Error ? e.message : 'Update failed' };
|
return { error: e instanceof Error ? e.message : 'Update failed' };
|
||||||
}
|
}
|
||||||
@@ -69,10 +150,23 @@ export const actions = {
|
|||||||
const data: Record<string, string> = {};
|
const data: Record<string, string> = {};
|
||||||
if (name) data.name = name;
|
if (name) data.name = name;
|
||||||
if (color) data.color = color;
|
if (color) data.color = color;
|
||||||
|
data.pattern = pattern;
|
||||||
|
data.themeSize = themeSize;
|
||||||
|
data.themeOpacity = themeOpacity;
|
||||||
|
if (partyEmoji) data.partyEmoji = partyEmoji;
|
||||||
const me = await s.fam.updateProfile(famId, data);
|
const me = await s.fam.updateProfile(famId, data);
|
||||||
return { success: true, name: me.name, color: me.color, email: '' };
|
return {
|
||||||
|
success: true,
|
||||||
|
name: me.name,
|
||||||
|
color: me.color,
|
||||||
|
pattern: me.pattern,
|
||||||
|
themeSize: me.themeSize,
|
||||||
|
themeOpacity: me.themeOpacity,
|
||||||
|
partyEmoji: me.partyEmoji,
|
||||||
|
email: ''
|
||||||
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return { error: e instanceof Error ? e.message : 'Update failed' };
|
return { error: e instanceof Error ? e.message : 'Update failed' };
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
};
|
};
|
||||||
@@ -1,14 +1,83 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { enhance, applyAction } from '$app/forms';
|
import { enhance, applyAction } from '$app/forms';
|
||||||
import { ViewHeader, CardGrid, Card, Button } from '$lib/components';
|
import { invalidateAll } from '$app/navigation';
|
||||||
|
import { ViewHeader, CardGrid, Card, Button, PatternPicker } from '$lib/components';
|
||||||
|
import { themeDraft } from '$lib/stores/theme.svelte';
|
||||||
|
import { THEME_SIZE_DEFAULT, THEME_OPACITY_DEFAULT } from '$lib/theme';
|
||||||
|
|
||||||
let { data, form } = $props();
|
let { data, form } = $props();
|
||||||
|
|
||||||
|
let pinMsg = $state('');
|
||||||
|
|
||||||
|
async function pinRequest(action: 'set' | 'change', pin: string, currentPin = '') {
|
||||||
|
pinMsg = '';
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/pins', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ action, pin, currentPin })
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) throw new Error(data.error || 'Could not save PIN');
|
||||||
|
pinMsg = 'Saved — try it on the shared computer!';
|
||||||
|
await invalidateAll();
|
||||||
|
} catch (e) {
|
||||||
|
pinMsg = e instanceof Error ? e.message : 'Could not save PIN';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPin(e: SubmitEvent) {
|
||||||
|
const fd = new FormData(e.currentTarget as HTMLFormElement);
|
||||||
|
const pin = String(fd.get('pin') || '').trim();
|
||||||
|
const confirm = String(fd.get('confirm') || '').trim();
|
||||||
|
e.preventDefault();
|
||||||
|
if (pin !== confirm) {
|
||||||
|
pinMsg = 'PINs do not match';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pinRequest('set', pin);
|
||||||
|
}
|
||||||
|
|
||||||
|
function changePin(e: SubmitEvent) {
|
||||||
|
const fd = new FormData(e.currentTarget as HTMLFormElement);
|
||||||
|
const current = String(fd.get('current') || '').trim();
|
||||||
|
const pin = String(fd.get('pin') || '').trim();
|
||||||
|
const confirm = String(fd.get('confirm') || '').trim();
|
||||||
|
e.preventDefault();
|
||||||
|
if (pin !== confirm) {
|
||||||
|
pinMsg = 'PINs do not match';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pinRequest('change', pin, current);
|
||||||
|
}
|
||||||
|
|
||||||
let memberName = $state(data.memberName || '');
|
let memberName = $state(data.memberName || '');
|
||||||
let memberColor = $state(data.memberColor || '#6366f1');
|
let memberColor = $state(data.memberColor || '#6366f1');
|
||||||
let memberEmail = $state(data.email || '');
|
let memberEmail = $state(data.email || '');
|
||||||
|
let memberPattern = $state(data.pattern || '');
|
||||||
|
let memberSize = $state(Number(data.themeSize) > 0 ? Number(data.themeSize) : THEME_SIZE_DEFAULT);
|
||||||
|
let memberOpacity = $state(
|
||||||
|
Number(data.themeOpacity) > 0 ? Number(data.themeOpacity) : THEME_OPACITY_DEFAULT
|
||||||
|
);
|
||||||
|
|
||||||
const colors = ['#6366f1', '#ec4899', '#f59e0b', '#10b981', '#3b82f6', '#ef4444', '#8b5cf6', '#14b8a6'];
|
const colors = [
|
||||||
|
'#6366f1',
|
||||||
|
'#ec4899',
|
||||||
|
'#f59e0b',
|
||||||
|
'#10b981',
|
||||||
|
'#3b82f6',
|
||||||
|
'#ef4444',
|
||||||
|
'#8b5cf6',
|
||||||
|
'#14b8a6'
|
||||||
|
];
|
||||||
|
|
||||||
|
// Payday-party mascot — the emoji that leads the child's payday celebration.
|
||||||
|
let memberEmoji = $state(data.partyEmoji || '🐖');
|
||||||
|
const partyEmojis = [
|
||||||
|
'🐖', '🐱', '🐶', '🦄', '🐸', '🐵', '🦊', '🐼',
|
||||||
|
'🦁', '🐯', '🐰', '🐨', '🐷', '🐮', '🦖', '🐙',
|
||||||
|
'🚀', '🌈', '⭐', '🎮', '⚽', '🍕', '🍦', '🎉'
|
||||||
|
];
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<ViewHeader title="Preferences" subtitle={data.verified ? data.memberName : ''} hero />
|
<ViewHeader title="Preferences" subtitle={data.verified ? data.memberName : ''} hero />
|
||||||
@@ -23,8 +92,10 @@
|
|||||||
</CardGrid>
|
</CardGrid>
|
||||||
{:else}
|
{:else}
|
||||||
<CardGrid>
|
<CardGrid>
|
||||||
<Card cols={2} title="Appearance">
|
<!-- One form for appearance + theme; display:contents keeps the
|
||||||
|
cards as grid items. -->
|
||||||
<form
|
<form
|
||||||
|
style="display:contents"
|
||||||
method="POST"
|
method="POST"
|
||||||
action="?/update"
|
action="?/update"
|
||||||
use:enhance={() => {
|
use:enhance={() => {
|
||||||
@@ -35,9 +106,19 @@
|
|||||||
if (result.type === 'success' || result.type === 'failure') {
|
if (result.type === 'success' || result.type === 'failure') {
|
||||||
await applyAction(result);
|
await applyAction(result);
|
||||||
}
|
}
|
||||||
|
// Refresh session.pattern so the app background updates, and drop
|
||||||
|
// the live draft — the saved value is authoritative from here on.
|
||||||
|
if (result.type === 'success') {
|
||||||
|
themeDraft.pattern = null;
|
||||||
|
themeDraft.color = null;
|
||||||
|
themeDraft.size = null;
|
||||||
|
themeDraft.opacity = null;
|
||||||
|
await invalidateAll();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<Card cols={2} title="Appearance">
|
||||||
<div style="margin-bottom:1rem">
|
<div style="margin-bottom:1rem">
|
||||||
<label style="display:block;font-size:0.85rem;color:#374151;margin-bottom:0.25rem">
|
<label style="display:block;font-size:0.85rem;color:#374151;margin-bottom:0.25rem">
|
||||||
Display Name
|
Display Name
|
||||||
@@ -59,35 +140,54 @@
|
|||||||
{#each colors as c}
|
{#each colors as c}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
style="width:2rem;height:2rem;border-radius:50%;border:{c === memberColor ? '3px solid #111827' : '2px solid transparent'};background:{c};cursor:pointer"
|
style="width:2rem;height:2rem;border-radius:50%;border:{c === memberColor
|
||||||
onclick={() => memberColor = c}
|
? '3px solid #111827'
|
||||||
|
: '2px solid transparent'};background:{c};cursor:pointer"
|
||||||
|
onclick={() => {
|
||||||
|
memberColor = c;
|
||||||
|
themeDraft.color = c;
|
||||||
|
}}
|
||||||
></button>
|
></button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
<input type="hidden" name="color" value={memberColor} />
|
<input type="hidden" name="color" value={memberColor} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-bottom:1rem">
|
||||||
|
<label style="display:block;font-size:0.85rem;color:#374151;margin-bottom:0.25rem">
|
||||||
|
Party emoji — leads your payday celebration 🎉
|
||||||
|
</label>
|
||||||
|
<div style="display:flex;gap:0.3rem;flex-wrap:wrap">
|
||||||
|
{#each partyEmojis as e}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => (memberEmoji = e)}
|
||||||
|
aria-label="Pick {e}"
|
||||||
|
style="font-size:1.5rem;line-height:1;padding:0.25rem;border-radius:8px;border:{e ===
|
||||||
|
memberEmoji
|
||||||
|
? '2px solid #111827'
|
||||||
|
: '2px solid transparent'};background:{e === memberEmoji
|
||||||
|
? '#f3f4f6'
|
||||||
|
: 'transparent'};cursor:pointer"
|
||||||
|
>{e}</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<input type="hidden" name="partyEmoji" value={memberEmoji} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if data.role === 'parent'}
|
||||||
<div style="margin-bottom:1rem">
|
<div style="margin-bottom:1rem">
|
||||||
<label style="display:block;font-size:0.85rem;color:#374151;margin-bottom:0.25rem">
|
<label style="display:block;font-size:0.85rem;color:#374151;margin-bottom:0.25rem">
|
||||||
Email
|
Email
|
||||||
</label>
|
</label>
|
||||||
{#if data.session}
|
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
name="email"
|
name="email"
|
||||||
bind:value={memberEmail}
|
bind:value={memberEmail}
|
||||||
style="padding:0.5rem;border:1px solid #d1d5db;border-radius:6px;font-size:0.9rem;width:100%;box-sizing:border-box"
|
style="padding:0.5rem;border:1px solid #d1d5db;border-radius:6px;font-size:0.9rem;width:100%;box-sizing:border-box"
|
||||||
/>
|
/>
|
||||||
{:else}
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
name="email"
|
|
||||||
value={memberEmail}
|
|
||||||
disabled
|
|
||||||
style="padding:0.5rem;border:1px solid #d1d5db;border-radius:6px;font-size:0.9rem;width:100%;box-sizing:border-box;background:#f3f4f6;color:#9ca3af"
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if form?.error}
|
{#if form?.error}
|
||||||
<p style="color:#dc2626;font-size:0.85rem;margin-bottom:0.5rem">{form.error}</p>
|
<p style="color:#dc2626;font-size:0.85rem;margin-bottom:0.5rem">{form.error}</p>
|
||||||
@@ -99,9 +199,105 @@
|
|||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<Button type="submit" variant="primary">Update preferences</Button>
|
<Button type="submit" variant="primary">Update preferences</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card cols={1} title="Theme">
|
||||||
|
<p class="theme-hint">
|
||||||
|
Pick a background pattern — it's coloured with your chosen colour. None keeps the plain
|
||||||
|
background.
|
||||||
|
</p>
|
||||||
|
<PatternPicker
|
||||||
|
value={themeDraft.pattern ?? memberPattern}
|
||||||
|
color={memberColor}
|
||||||
|
size={themeDraft.size ?? memberSize}
|
||||||
|
opacity={themeDraft.opacity ?? memberOpacity}
|
||||||
|
onchange={(v) => {
|
||||||
|
memberPattern = v;
|
||||||
|
// Live preview on the app background before saving.
|
||||||
|
themeDraft.pattern = v;
|
||||||
|
themeDraft.color = memberColor;
|
||||||
|
}}
|
||||||
|
onsize={(v) => {
|
||||||
|
memberSize = v;
|
||||||
|
// Live scale preview before saving.
|
||||||
|
themeDraft.size = v;
|
||||||
|
}}
|
||||||
|
onopacity={(v) => {
|
||||||
|
memberOpacity = v;
|
||||||
|
// Live fade preview before saving.
|
||||||
|
themeDraft.opacity = v;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<input type="hidden" name="pattern" value={themeDraft.pattern ?? memberPattern} />
|
||||||
|
<input type="hidden" name="themeSize" value={themeDraft.size ?? memberSize} />
|
||||||
|
<input type="hidden" name="themeOpacity" value={themeDraft.opacity ?? memberOpacity} />
|
||||||
|
</Card>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{#if data.role === 'child'}
|
||||||
|
<Card cols={1} title="My PIN">
|
||||||
|
{#if data.hasPin}
|
||||||
|
<p class="theme-hint">
|
||||||
|
Your 3-digit PIN switches you back to your chores on a shared computer. Forgot it? Your
|
||||||
|
parent can read it out — Settings → Invites → PIN.
|
||||||
|
</p>
|
||||||
|
<form class="pin-form" onsubmit={changePin}>
|
||||||
|
<input
|
||||||
|
name="current"
|
||||||
|
inputmode="numeric"
|
||||||
|
maxlength="3"
|
||||||
|
required
|
||||||
|
placeholder="Current PIN"
|
||||||
|
class="pin-input"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
name="pin"
|
||||||
|
inputmode="numeric"
|
||||||
|
maxlength="3"
|
||||||
|
required
|
||||||
|
placeholder="New PIN"
|
||||||
|
class="pin-input"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
name="confirm"
|
||||||
|
inputmode="numeric"
|
||||||
|
maxlength="3"
|
||||||
|
required
|
||||||
|
placeholder="Confirm"
|
||||||
|
class="pin-input"
|
||||||
|
/>
|
||||||
|
<Button type="submit" size="sm">Change PIN</Button>
|
||||||
|
</form>
|
||||||
|
{:else}
|
||||||
|
<p class="theme-hint">
|
||||||
|
Set a 3-digit PIN so you can switch back to your chores quickly on a shared computer.
|
||||||
|
</p>
|
||||||
|
<form class="pin-form" onsubmit={setPin}>
|
||||||
|
<input
|
||||||
|
name="pin"
|
||||||
|
inputmode="numeric"
|
||||||
|
maxlength="3"
|
||||||
|
required
|
||||||
|
placeholder="Your PIN"
|
||||||
|
class="pin-input"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
name="confirm"
|
||||||
|
inputmode="numeric"
|
||||||
|
maxlength="3"
|
||||||
|
required
|
||||||
|
placeholder="Confirm"
|
||||||
|
class="pin-input"
|
||||||
|
/>
|
||||||
|
<Button type="submit" size="sm">Set PIN</Button>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
{#if pinMsg}
|
||||||
|
<p class="pin-msg">{pinMsg}</p>
|
||||||
|
{/if}
|
||||||
|
</Card>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<Card cols={1} title="Account">
|
<Card cols={1} title="Account">
|
||||||
<p style="font-size:0.85rem;color:#6b7280">
|
<p style="font-size:0.85rem;color:#6b7280">
|
||||||
To change your device or sign in on a new device, use your invite link.
|
To change your device or sign in on a new device, use your invite link.
|
||||||
@@ -119,4 +315,30 @@
|
|||||||
padding-top: 1.25rem;
|
padding-top: 1.25rem;
|
||||||
border-top: 1px solid #e5e7eb;
|
border-top: 1px solid #e5e7eb;
|
||||||
}
|
}
|
||||||
|
.theme-hint {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #6b7280;
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
}
|
||||||
|
.pin-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.pin-input {
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
text-align: center;
|
||||||
|
letter-spacing: 0.3em;
|
||||||
|
}
|
||||||
|
.pin-msg {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #059669;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
+48
-1
@@ -1,6 +1,24 @@
|
|||||||
import { fail, redirect } from '@sveltejs/kit';
|
import { fail, redirect } from '@sveltejs/kit';
|
||||||
import { servicesFor } from '$lib/server/servicesFor';
|
import { servicesFor } from '$lib/server/servicesFor';
|
||||||
|
|
||||||
|
// Per-member target-chore picks come in as `targetChore_<memberId>` form fields.
|
||||||
|
// Build the memberId → assignedChoreId map (empty values = count all of that
|
||||||
|
// member's chores, so they're dropped). `used` tells callers whether the
|
||||||
|
// per-member select UI was rendered at all (i.e. an individual-target reward),
|
||||||
|
// so the old scalar targetChoreId can be cleared on save.
|
||||||
|
function parseTargetChoreIds(fd: FormData): { map: Record<string, string>; used: boolean } {
|
||||||
|
const map: Record<string, string> = {};
|
||||||
|
let used = false;
|
||||||
|
for (const [k, v] of fd.entries()) {
|
||||||
|
if (k.startsWith('targetChore_')) {
|
||||||
|
used = true;
|
||||||
|
const memberId = k.slice('targetChore_'.length);
|
||||||
|
if (v) map[memberId] = String(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { map, used };
|
||||||
|
}
|
||||||
|
|
||||||
export async function load(event) {
|
export async function load(event) {
|
||||||
if (!event.locals.user) throw redirect(303, '/login');
|
if (!event.locals.user) throw redirect(303, '/login');
|
||||||
const famId = event.locals.user.famId;
|
const famId = event.locals.user.famId;
|
||||||
@@ -115,10 +133,21 @@ export const actions = {
|
|||||||
const period = fd.get('period');
|
const period = fd.get('period');
|
||||||
if (period !== null) data.period = period;
|
if (period !== null) data.period = period;
|
||||||
if (data.occurrence === 'once') data.period = '';
|
if (data.occurrence === 'once') data.period = '';
|
||||||
|
const targetChoreId = fd.get('targetChoreId');
|
||||||
|
if (targetChoreId !== null) data.targetChoreId = targetChoreId || '';
|
||||||
|
const { map: targetChoreIds, used: perMemberTargets } = parseTargetChoreIds(fd);
|
||||||
|
if (Object.keys(targetChoreIds).length) data.targetChoreIds = targetChoreIds;
|
||||||
|
if (perMemberTargets) data.targetChoreId = '';
|
||||||
const description = fd.get('description');
|
const description = fd.get('description');
|
||||||
if (description) data.description = description;
|
if (description) data.description = description;
|
||||||
const memberId = fd.get('memberId');
|
const memberId = fd.get('memberId');
|
||||||
if (memberId) data.memberId = memberId;
|
if (memberId) data.memberId = memberId;
|
||||||
|
const completeBy = fd.get('completeBy');
|
||||||
|
if (completeBy) data.completeBy = completeBy;
|
||||||
|
const startDate = fd.get('startDate');
|
||||||
|
if (startDate) data.startDate = startDate;
|
||||||
|
const completeByDate = fd.get('completeByDate');
|
||||||
|
if (completeByDate) data.completeByDate = completeByDate;
|
||||||
try {
|
try {
|
||||||
const s = servicesFor(event);
|
const s = servicesFor(event);
|
||||||
const record = await s.crud.create('bonus-configs', famId, data);
|
const record = await s.crud.create('bonus-configs', famId, data);
|
||||||
@@ -153,10 +182,21 @@ export const actions = {
|
|||||||
const period = fd.get('period');
|
const period = fd.get('period');
|
||||||
if (period !== null) data.period = period;
|
if (period !== null) data.period = period;
|
||||||
if (occurrence === 'once') data.period = '';
|
if (occurrence === 'once') data.period = '';
|
||||||
|
const targetChoreId = fd.get('targetChoreId');
|
||||||
|
if (targetChoreId !== null) data.targetChoreId = targetChoreId || '';
|
||||||
|
const { map: targetChoreIds, used: perMemberTargets } = parseTargetChoreIds(fd);
|
||||||
|
if (Object.keys(targetChoreIds).length) data.targetChoreIds = targetChoreIds;
|
||||||
|
if (perMemberTargets) data.targetChoreId = '';
|
||||||
const description = fd.get('description');
|
const description = fd.get('description');
|
||||||
if (description) data.description = description;
|
if (description) data.description = description;
|
||||||
const memberId = fd.get('memberId');
|
const memberId = fd.get('memberId');
|
||||||
if (memberId !== null) data.memberId = memberId || null;
|
if (memberId !== null) data.memberId = memberId || null;
|
||||||
|
const completeBy = fd.get('completeBy');
|
||||||
|
if (completeBy) data.completeBy = completeBy;
|
||||||
|
const startDate = fd.get('startDate');
|
||||||
|
if (startDate !== null) data.startDate = startDate || '';
|
||||||
|
const completeByDate = fd.get('completeByDate');
|
||||||
|
if (completeByDate !== null) data.completeByDate = completeByDate || '';
|
||||||
try {
|
try {
|
||||||
const s = servicesFor(event);
|
const s = servicesFor(event);
|
||||||
const record = await s.crud.update('bonus-configs', famId, id, data);
|
const record = await s.crud.update('bonus-configs', famId, id, data);
|
||||||
@@ -199,8 +239,15 @@ export const actions = {
|
|||||||
period: fd.get('period') || '',
|
period: fd.get('period') || '',
|
||||||
memberId: fd.get('memberId') || '',
|
memberId: fd.get('memberId') || '',
|
||||||
status,
|
status,
|
||||||
isPocketMoney: !!fd.get('isPocketMoney')
|
isPocketMoney: !!fd.get('isPocketMoney'),
|
||||||
|
targetChoreId: fd.get('targetChoreId') || '',
|
||||||
|
completeBy: fd.get('completeBy') || 'unlimited',
|
||||||
|
startDate: fd.get('startDate') || '',
|
||||||
|
completeByDate: fd.get('completeByDate') || ''
|
||||||
};
|
};
|
||||||
|
const { map: targetChoreIds, used: perMemberTargets } = parseTargetChoreIds(fd);
|
||||||
|
if (Object.keys(targetChoreIds).length) data.targetChoreIds = targetChoreIds;
|
||||||
|
if (perMemberTargets) data.targetChoreId = '';
|
||||||
if (data.occurrence === 'once') data.period = '';
|
if (data.occurrence === 'once') data.period = '';
|
||||||
try {
|
try {
|
||||||
const s = servicesFor(event);
|
const s = servicesFor(event);
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,19 +1,42 @@
|
|||||||
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 { getPlatformFlags } from '$lib/server/platform';
|
||||||
|
import { getPin, resetPin as resetChildPin } from '$lib/server/pins';
|
||||||
|
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');
|
||||||
return event.locals.user.famId;
|
return event.locals.user.famId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// In demo mode, settings is disabled for all users.
|
||||||
|
async function isDemoMode(): Promise<boolean> {
|
||||||
|
return (await getPlatformFlags()).demo;
|
||||||
|
}
|
||||||
|
|
||||||
export async function load(event: RequestEvent) {
|
export async function load(event: RequestEvent) {
|
||||||
|
if (await isDemoMode()) {
|
||||||
|
return {
|
||||||
|
members: [],
|
||||||
|
fam: null,
|
||||||
|
seasons: [],
|
||||||
|
accessCode: null,
|
||||||
|
subStatus: null,
|
||||||
|
demoMode: true,
|
||||||
|
lockMins: 10
|
||||||
|
};
|
||||||
|
}
|
||||||
const famId = famIdOf(event);
|
const famId = famIdOf(event);
|
||||||
const pb = pbUser(event);
|
const pb = pbUser(event);
|
||||||
const [members, fam, seasons] = await Promise.all([
|
const [members, fam, seasons] = await Promise.all([
|
||||||
@@ -34,7 +57,13 @@ export async function load(event: RequestEvent) {
|
|||||||
if (fam?.paymentMode === 'sub' && fam?.stripeCustomerId) {
|
if (fam?.paymentMode === 'sub' && fam?.stripeCustomerId) {
|
||||||
subStatus = await getSubscriptionStatus(fam.stripeCustomerId).catch(() => null);
|
subStatus = await getSubscriptionStatus(fam.stripeCustomerId).catch(() => null);
|
||||||
}
|
}
|
||||||
return { members, fam, seasons, accessCode, subStatus };
|
// Shared-device idle lock (0 = off). Missing row → default 10 min.
|
||||||
|
const settingsRow = await pbAdmin
|
||||||
|
.getList('settings', `famId='${famId}'`)
|
||||||
|
.then((rows: any[]) => rows?.[0] || null)
|
||||||
|
.catch(() => null);
|
||||||
|
const lockMins = settingsRow && settingsRow.lockMins != null ? Number(settingsRow.lockMins) : 10;
|
||||||
|
return { members, fam, seasons, accessCode, subStatus, lockMins };
|
||||||
}
|
}
|
||||||
|
|
||||||
export const actions = {
|
export const actions = {
|
||||||
@@ -67,6 +96,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();
|
||||||
@@ -87,6 +174,59 @@ export const actions = {
|
|||||||
return { ok: true };
|
return { ok: true };
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Parent reads a child's shared-device PIN (children forget theirs often).
|
||||||
|
revealPin: async (event: RequestEvent) => {
|
||||||
|
const famId = famIdOf(event);
|
||||||
|
const fd = await event.request.formData();
|
||||||
|
const id = (fd.get('id') || '').toString();
|
||||||
|
if (!id) return { error: 'Member ID required' };
|
||||||
|
try {
|
||||||
|
const member = await pbAdmin.getOne('users', id).catch(() => null);
|
||||||
|
if (!member || member.famId !== famId) return { error: 'No such member' };
|
||||||
|
const pin = await getPin(id);
|
||||||
|
if (!pin) return { ok: true, pin: '', name: member.name || '', unset: true };
|
||||||
|
return { ok: true, pin, name: member.name || '' };
|
||||||
|
} catch (e) {
|
||||||
|
return { error: e instanceof Error ? e.message : 'Failed to reveal PIN' };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Parent hands out a fresh PIN when a child forgets theirs.
|
||||||
|
resetPin: async (event: RequestEvent) => {
|
||||||
|
const famId = famIdOf(event);
|
||||||
|
const fd = await event.request.formData();
|
||||||
|
const id = (fd.get('id') || '').toString();
|
||||||
|
if (!id) return { error: 'Member ID required' };
|
||||||
|
try {
|
||||||
|
const member = await pbAdmin.getOne('users', id).catch(() => null);
|
||||||
|
if (!member || member.famId !== famId) return { error: 'No such member' };
|
||||||
|
const pin = await resetChildPin(famId, id);
|
||||||
|
return { ok: true, pin, name: member.name || '' };
|
||||||
|
} catch (e) {
|
||||||
|
return { error: e instanceof Error ? e.message : 'Failed to reset PIN' };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Shared-computer idle lock: off / 2 / 10 minutes before returning to the
|
||||||
|
// profile picker (children only). Missing settings row → created.
|
||||||
|
updateLock: async (event: RequestEvent) => {
|
||||||
|
const famId = famIdOf(event);
|
||||||
|
const fd = await event.request.formData();
|
||||||
|
const mins = parseInt((fd.get('mins') || '').toString(), 10);
|
||||||
|
if (![0, 2, 10].includes(mins)) return { error: 'Invalid value' };
|
||||||
|
const pb = await createSuperClient();
|
||||||
|
let row = await pb
|
||||||
|
.collection('settings')
|
||||||
|
.getFirstListItem(`famId='${famId}'`)
|
||||||
|
.catch(() => null);
|
||||||
|
if (row) {
|
||||||
|
await pb.collection('settings').update(row.id, { lockMins: mins });
|
||||||
|
} else {
|
||||||
|
await pb.collection('settings').create({ famId, lockMins: mins });
|
||||||
|
}
|
||||||
|
return { ok: true, lockMins: mins };
|
||||||
|
},
|
||||||
|
|
||||||
updatePayday: async (event: RequestEvent) => {
|
updatePayday: 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 +284,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 };
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
CardGrid,
|
CardGrid,
|
||||||
Card,
|
Card,
|
||||||
Button,
|
Button,
|
||||||
|
SaveButton,
|
||||||
Accordion,
|
Accordion,
|
||||||
AccordionItem,
|
AccordionItem,
|
||||||
NoticeDialog
|
NoticeDialog
|
||||||
@@ -17,6 +18,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();
|
||||||
|
|
||||||
@@ -76,15 +81,45 @@
|
|||||||
detectedTz = '';
|
detectedTz = '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 'auto' resolves to the SERVER's timezone (UTC in prod), not the
|
||||||
|
// family's — so during BST everything lands an hour late. Pre-select
|
||||||
|
// the admin's own zone so saving stores a real IANA zone (DST-aware).
|
||||||
|
if ((timezone === 'auto' || !timezone) && detectedTz && timezoneOptions.includes(detectedTz)) {
|
||||||
|
timezone = detectedTz;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
let showQR = $state(false);
|
|
||||||
let qrDataUrl = $state('');
|
let qrDataUrl = $state('');
|
||||||
let copied = $state(false);
|
let copied = $state(false);
|
||||||
|
|
||||||
|
// Save-button success feedback: each save form holds a SaveButton ref and
|
||||||
|
// flashes it green for 4s from its use:enhance callback on success.
|
||||||
|
type Flasher = { flash: () => void } | null;
|
||||||
|
let renameBtn: Flasher = $state(null);
|
||||||
|
let paydayBtn: Flasher = $state(null);
|
||||||
|
let seasonBtn: Flasher = $state(null);
|
||||||
|
let lockBtn: Flasher = $state(null);
|
||||||
|
let addMemberBtn: Flasher = $state(null);
|
||||||
|
let inviteBtn: Flasher = $state(null);
|
||||||
|
let applyCodeBtn: Flasher = $state(null);
|
||||||
|
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 pinModal = $state<{ id: string; name: string; pin: string; unset?: boolean } | null>(null);
|
||||||
|
let lockMins = $state(String(data.lockMins ?? 10));
|
||||||
|
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 +143,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,18 +151,33 @@
|
|||||||
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() {
|
function handleInviteResult(result: any) {
|
||||||
showQR = !showQR;
|
if (result.type === 'success') {
|
||||||
if (!showQR) qrDataUrl = '';
|
const d = result.data as { ok?: boolean; error?: string } | undefined;
|
||||||
else generateQR(inviteUrl);
|
if (d?.ok) {
|
||||||
|
inviteBtn?.flash();
|
||||||
|
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 handleParentInvite() {
|
|
||||||
alert('Parent invite coming soon — email would be sent to ' + parentInviteEmail);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function timeUntil(iso: string): string {
|
function timeUntil(iso: string): string {
|
||||||
@@ -158,9 +207,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">
|
||||||
@@ -168,7 +222,16 @@
|
|||||||
<code class="slug-inline">/{famSlug}</code> even if you rename it — links you've shared keep
|
<code class="slug-inline">/{famSlug}</code> even if you rename it — links you've shared keep
|
||||||
working.
|
working.
|
||||||
</p>
|
</p>
|
||||||
<form method="POST" action="?/renameFam" use:enhance>
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/renameFam"
|
||||||
|
use:enhance={() => {
|
||||||
|
return async ({ result, update }) => {
|
||||||
|
await update();
|
||||||
|
if (result.type === 'success') renameBtn?.flash();
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
>
|
||||||
<label class="field-label" for="fam-name">Display name</label>
|
<label class="field-label" for="fam-name">Display name</label>
|
||||||
<input
|
<input
|
||||||
id="fam-name"
|
id="fam-name"
|
||||||
@@ -177,7 +240,7 @@
|
|||||||
placeholder={fam?.name || 'Family name'}
|
placeholder={fam?.name || 'Family name'}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<Button type="submit" size="sm">Rename</Button>
|
<SaveButton bind:this={renameBtn} label="Rename" />
|
||||||
</form>
|
</form>
|
||||||
{#if fam?.slug}
|
{#if fam?.slug}
|
||||||
<p class="hint slug-line">
|
<p class="hint slug-line">
|
||||||
@@ -193,6 +256,7 @@
|
|||||||
use:enhance={() => {
|
use:enhance={() => {
|
||||||
return async ({ result }) => {
|
return async ({ result }) => {
|
||||||
if (result.type === 'error') alert(result.error || 'Failed to update payday');
|
if (result.type === 'error') alert(result.error || 'Failed to update payday');
|
||||||
|
else if (result.type === 'success') paydayBtn?.flash();
|
||||||
};
|
};
|
||||||
}}
|
}}
|
||||||
class="payday-form"
|
class="payday-form"
|
||||||
@@ -224,43 +288,72 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<Button type="submit" size="sm">Set payday</Button>
|
<SaveButton bind:this={paydayBtn} label="Set payday" />
|
||||||
</form>
|
</form>
|
||||||
<p class="hint">
|
<p class="hint">
|
||||||
Payday: the week starts on this day and weekly earnings are settled at this time. Auto
|
Payday: the week starts on this day and weekly earnings are settled at this time. Pick
|
||||||
timezone follows each device.
|
your city so payday lands at the right local time — Auto uses the server's timezone
|
||||||
|
(UTC), which is an hour out during daylight saving.
|
||||||
</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">
|
||||||
<form method="POST" action="?/createSeason" use:enhance class="season-form">
|
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={() => {
|
||||||
|
return async ({ result, update }) => {
|
||||||
|
await update();
|
||||||
|
if (result.type === 'success') seasonBtn?.flash();
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
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>
|
</div>
|
||||||
<Button type="submit" size="sm">Add</Button>
|
<input type="hidden" name="color" value={seasonColor} />
|
||||||
|
</div>
|
||||||
|
<SaveButton bind:this={seasonBtn} label="Add" />
|
||||||
</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="season-cell">
|
||||||
<span class="dot" style="background:{s.color}"></span>
|
<span class="dot" style="background:{s.color}"></span>
|
||||||
<span class="season-name">{s.name}</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 +365,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>
|
||||||
@@ -309,16 +405,51 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<Card title="Shared computer" cols={1}>
|
||||||
|
<p class="hint">
|
||||||
|
If kids share this computer, FamDone returns to the profile picker after a break so nobody
|
||||||
|
lands on the wrong chores. Children pick a 3-digit PIN when they join.
|
||||||
|
</p>
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/updateLock"
|
||||||
|
use:enhance={() => {
|
||||||
|
return async ({ result }) => {
|
||||||
|
if (result.type !== 'success') return;
|
||||||
|
const d = result.data as any;
|
||||||
|
if (d?.ok) {
|
||||||
|
lockMins = String(d.lockMins);
|
||||||
|
lockBtn?.flash();
|
||||||
|
} else if (d?.error) alert(d.error);
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<select name="mins" bind:value={lockMins}>
|
||||||
|
<option value="0">Off — keep the last profile</option>
|
||||||
|
<option value="2">After 2 minutes of no use</option>
|
||||||
|
<option value="10">After 10 minutes of no use</option>
|
||||||
|
</select>
|
||||||
|
<SaveButton bind:this={lockBtn} label="Save" />
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
</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">
|
|
||||||
<div class="members-add">
|
|
||||||
<p class="hint">Add a child. They'll pick their own colour after joining.</p>
|
<p class="hint">Add a child. They'll pick their own colour after joining.</p>
|
||||||
<form method="POST" action="?/addMember" use:enhance>
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/addMember"
|
||||||
|
use:enhance={() => {
|
||||||
|
return async ({ result, update }) => {
|
||||||
|
await update();
|
||||||
|
if (result.type === 'success') addMemberBtn?.flash();
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
>
|
||||||
<label class="field-label" for="new-child">New child</label>
|
<label class="field-label" for="new-child">New child</label>
|
||||||
<input
|
<input
|
||||||
id="new-child"
|
id="new-child"
|
||||||
@@ -327,10 +458,95 @@
|
|||||||
placeholder="Child name"
|
placeholder="Child name"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<Button type="submit" size="sm">Add child</Button>
|
<SaveButton bind:this={addMemberBtn} label="Add child" />
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="Child access codes" cols={1} class="access-card">
|
||||||
|
<div class="access-body">
|
||||||
|
<p class="hint">
|
||||||
|
Generates a 6-digit code valid for 20 minutes. The child enters it at the join link.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<label class="field-label" for="invite-child">Child</label>
|
||||||
|
<select id="invite-child" bind:value={inviteChild}>
|
||||||
|
<option value="">— Select a child —</option>
|
||||||
|
{#each members as m}
|
||||||
|
<option value={m.name}>{m.name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<div class="access-cta-row">
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/issueAccess"
|
||||||
|
use:enhance={() => {
|
||||||
|
return async ({ formData, result }) => {
|
||||||
|
if (result.type !== 'success') return;
|
||||||
|
const d = result.data as
|
||||||
|
| {
|
||||||
|
ok?: boolean;
|
||||||
|
otp?: string;
|
||||||
|
joinUrl?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
|
if (d?.ok && d.otp && d.joinUrl) {
|
||||||
|
openIssueModal({
|
||||||
|
otp: d.otp,
|
||||||
|
joinUrl: d.joinUrl,
|
||||||
|
name: String(formData.get('name') || '')
|
||||||
|
});
|
||||||
|
} else if (d?.error) {
|
||||||
|
alert(d.error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
class="inline"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="name" value={inviteChild} />
|
||||||
|
<Button type="submit" size="sm" variant="primary" disabled={!inviteChild}
|
||||||
|
>Issue new code</Button
|
||||||
|
>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/currentAccess"
|
||||||
|
use:enhance={() => {
|
||||||
|
return async ({ formData, result }) => {
|
||||||
|
if (result.type !== 'success') return;
|
||||||
|
const d = result.data as
|
||||||
|
| {
|
||||||
|
ok?: boolean;
|
||||||
|
otp?: string;
|
||||||
|
joinUrl?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
|
if (d?.ok && d.otp && d.joinUrl) {
|
||||||
|
openIssueModal({
|
||||||
|
otp: d.otp,
|
||||||
|
joinUrl: d.joinUrl,
|
||||||
|
name: String(formData.get('name') || '')
|
||||||
|
});
|
||||||
|
} else if (d?.error) {
|
||||||
|
alert(d.error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
class="inline"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="name" value={inviteChild} />
|
||||||
|
<Button type="submit" size="sm" variant="purple" disabled={!inviteChild}
|
||||||
|
>Reveal current</Button
|
||||||
|
>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="All members ({members.length})" cols={1}>
|
||||||
<ul class="members-list">
|
<ul class="members-list">
|
||||||
{#each members as m}
|
{#each members as m}
|
||||||
<li>
|
<li>
|
||||||
@@ -341,10 +557,32 @@
|
|||||||
<span class="member-handle">/{famSlug}/{handleOf(m.username)}</span>
|
<span class="member-handle">/{famSlug}/{handleOf(m.username)}</span>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span class="member-actions">
|
<form
|
||||||
<Button href="/{famSlug}/{handleOf(m.username)}" variant="secondary" size="sm"
|
method="POST"
|
||||||
>Preview</Button
|
action="?/revealPin"
|
||||||
|
use:enhance={() => {
|
||||||
|
return async ({ formData, result }) => {
|
||||||
|
if (result.type !== 'success') return;
|
||||||
|
const d = result.data as any;
|
||||||
|
if (d?.ok) {
|
||||||
|
pinModal = {
|
||||||
|
id: String(formData.get('id') || ''),
|
||||||
|
name: d.name || m.name,
|
||||||
|
pin: d.pin || '',
|
||||||
|
unset: !!d.unset
|
||||||
|
};
|
||||||
|
} else if (d?.error) {
|
||||||
|
alert(d.error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
class="inline"
|
||||||
>
|
>
|
||||||
|
<input type="hidden" name="id" value={m.id} />
|
||||||
|
<Button type="submit" variant="ghost" size="sm" title="Shared-computer PIN"
|
||||||
|
>PIN</Button
|
||||||
|
>
|
||||||
|
</form>
|
||||||
<form method="POST" action="?/deleteMember" use:enhance class="inline">
|
<form method="POST" action="?/deleteMember" use:enhance class="inline">
|
||||||
<input type="hidden" name="id" value={m.id} />
|
<input type="hidden" name="id" value={m.id} />
|
||||||
<Button
|
<Button
|
||||||
@@ -354,87 +592,121 @@
|
|||||||
onclick={() => confirm('Remove {m.name}?')}>Remove</Button
|
onclick={() => confirm('Remove {m.name}?')}>Remove</Button
|
||||||
>
|
>
|
||||||
</form>
|
</form>
|
||||||
</span>
|
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card title="Invite Children" cols={1}>
|
{#if pinModal}
|
||||||
|
<div class="overlay" onclick={() => (pinModal = null)} role="presentation">
|
||||||
|
<div class="modal" onclick={(e) => e.stopPropagation()} role="dialog">
|
||||||
|
<h3>{pinModal.name}'s PIN</h3>
|
||||||
|
{#if pinModal.unset}
|
||||||
|
<p class="hint">
|
||||||
|
No PIN set yet. Set one so {pinModal.name} can pick their profile on a shared computer.
|
||||||
|
</p>
|
||||||
<form
|
<form
|
||||||
method="POST"
|
method="POST"
|
||||||
action="?/issueAccess"
|
action="?/resetPin"
|
||||||
use:enhance={() => {
|
use:enhance={() => {
|
||||||
return async ({ formData, result }) => {
|
return async ({ result }) => {
|
||||||
if (result.type === 'success' && result.data?.ok) {
|
if (result.type !== 'success') return;
|
||||||
showQR = false;
|
const d = result.data as any;
|
||||||
qrDataUrl = '';
|
if (d?.ok) pinModal = { ...pinModal!, pin: d.pin, unset: false };
|
||||||
issued = {
|
else if (d?.error) alert(d.error);
|
||||||
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>
|
<input type="hidden" name="id" value={pinModal.id} />
|
||||||
<select id="invite-child" bind:value={inviteChild} name="name" required>
|
<Button type="submit" size="sm" variant="primary">Set a new PIN</Button>
|
||||||
<option value="">— Select a child —</option>
|
|
||||||
{#each members as m}
|
|
||||||
<option value={m.name}>{m.name}</option>
|
|
||||||
{/each}
|
|
||||||
</select>
|
|
||||||
<Button type="submit" size="sm" disabled={!inviteChild}>Issue code</Button>
|
|
||||||
</form>
|
</form>
|
||||||
|
{:else}
|
||||||
|
<p class="code-display">{pinModal.pin}</p>
|
||||||
<p class="hint">
|
<p class="hint">
|
||||||
Generates a 6-digit code valid for 20 minutes. The child enters it at the join link.
|
Read it out if they've forgotten. They can change it themselves in Preferences.
|
||||||
</p>
|
</p>
|
||||||
|
<form
|
||||||
{#if issued?.otp}
|
method="POST"
|
||||||
<div class="mt-3 rounded-lg border border-indigo-200 bg-indigo-50 p-4">
|
action="?/resetPin"
|
||||||
<p class="text-xs text-slate-500">Code for {issued.name} (valid 20 min):</p>
|
use:enhance={() => {
|
||||||
<p class="my-2 text-center text-4xl font-bold tracking-[0.3em] text-indigo-700">
|
return async ({ result }) => {
|
||||||
{issued.otp}
|
if (result.type !== 'success') return;
|
||||||
</p>
|
const d = result.data as any;
|
||||||
<p class="invite-url">{invitePath}</p>
|
if (d?.ok) pinModal = { ...pinModal!, pin: d.pin, unset: false };
|
||||||
<div class="actions justify-center">
|
else if (d?.error) alert(d.error);
|
||||||
<Button variant="secondary" size="sm" onclick={() => copy(inviteUrl)}
|
};
|
||||||
>{copied ? 'Copied!' : 'Copy URL'}</Button
|
}}
|
||||||
|
class="inline"
|
||||||
>
|
>
|
||||||
<Button variant="secondary" size="sm" onclick={toggleQR}
|
<input type="hidden" name="id" value={pinModal.id} />
|
||||||
>{showQR ? 'Hide QR' : 'Show QR'}</Button
|
<Button type="submit" size="sm" variant="secondary">Give a new PIN</Button>
|
||||||
>
|
</form>
|
||||||
</div>
|
|
||||||
{#if showQR && qrDataUrl}
|
|
||||||
<div class="qr-wrap"><img src={qrDataUrl} alt="QR Code" class="qr" /></div>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button type="button" onclick={() => (pinModal = null)}>Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</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>
|
<SaveButton bind:this={inviteBtn} label="Send invite" />
|
||||||
</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'}
|
||||||
@@ -476,6 +748,7 @@
|
|||||||
const d = result.data as { error?: string; ok?: boolean };
|
const d = result.data as { error?: string; ok?: boolean };
|
||||||
accessMsg = d.error || 'Code applied — access enabled.';
|
accessMsg = d.error || 'Code applied — access enabled.';
|
||||||
accessCodeInput = '';
|
accessCodeInput = '';
|
||||||
|
if (d.ok && !d.error) applyCodeBtn?.flash();
|
||||||
}
|
}
|
||||||
await update();
|
await update();
|
||||||
};
|
};
|
||||||
@@ -487,7 +760,7 @@
|
|||||||
placeholder="Enter access code"
|
placeholder="Enter access code"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
/>
|
/>
|
||||||
<Button type="submit" size="sm" variant="primary">Apply code</Button>
|
<SaveButton bind:this={applyCodeBtn} label="Apply code" />
|
||||||
</form>
|
</form>
|
||||||
{/if}
|
{/if}
|
||||||
{#if accessMsg}
|
{#if accessMsg}
|
||||||
@@ -515,8 +788,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 +834,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 +977,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 +1031,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 +1117,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 +1154,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 +1178,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 +1224,7 @@
|
|||||||
.payday-row select {
|
.payday-row select {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
.color-row {
|
.season-color-row {
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
.actions {
|
.actions {
|
||||||
@@ -977,4 +1319,9 @@
|
|||||||
.resume-hint {
|
.resume-hint {
|
||||||
margin-top: 0.5rem;
|
margin-top: 0.5rem;
|
||||||
}
|
}
|
||||||
|
@media (max-width: 639px) {
|
||||||
|
.code-display {
|
||||||
|
font-size: 1.5em;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import { fail, redirect } from '@sveltejs/kit';
|
import { fail } from '@sveltejs/kit';
|
||||||
import { redeemOtp } from '$lib/server/member-otp';
|
import { redeemOtp } from '$lib/server/member-otp';
|
||||||
import { handle } from '@shared/slugify';
|
import { handle } from '@shared/slugify';
|
||||||
import { setSessionCookie, clearLegacyCookies } from '$lib/server/session';
|
import {
|
||||||
|
clearLegacyCookies,
|
||||||
|
setChildSessionCookie,
|
||||||
|
setActiveChild,
|
||||||
|
scanChildSessions
|
||||||
|
} from '$lib/server/session';
|
||||||
|
|
||||||
export const actions = {
|
export const actions = {
|
||||||
default: async (event) => {
|
default: async (event) => {
|
||||||
@@ -14,11 +19,14 @@ export const actions = {
|
|||||||
if (!otp) return fail(400, { error: 'Enter the code shown by your parent.', name, otp });
|
if (!otp) return fail(400, { error: 'Enter the code shown by your parent.', name, otp });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// redeemOtp derives the username from the handle internally; pass the
|
// Children join onto a shared-device session (pb_token_<id> +
|
||||||
// raw name so it resolves the same {famSlug}:{handle} identity.
|
// pb_active) so siblings' sessions on this computer survive.
|
||||||
const token = await redeemOtp({ famSlug: fam, username: name, otp });
|
const { token, userId } = await redeemOtp({ famSlug: fam, username: name, otp });
|
||||||
clearLegacyCookies(event.cookies);
|
clearLegacyCookies(event.cookies);
|
||||||
setSessionCookie(event.cookies, token);
|
setChildSessionCookie(event.cookies, userId, token);
|
||||||
|
setActiveChild(event.cookies, userId);
|
||||||
|
const hasOtherKids = scanChildSessions(event.cookies).length > 1;
|
||||||
|
return { joined: true, famSlug: fam, username: handle(name), hasOtherKids };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return fail(400, {
|
return fail(400, {
|
||||||
error: e instanceof Error ? e.message : 'Join failed',
|
error: e instanceof Error ? e.message : 'Join failed',
|
||||||
@@ -26,8 +34,5 @@ export const actions = {
|
|||||||
otp
|
otp
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleName = handle(name);
|
|
||||||
throw redirect(303, `/${fam}/${encodeURIComponent(handleName)}`);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
import { Button } from '$lib/components';
|
import { Button, JoinPinFlow } from '$lib/components';
|
||||||
|
import { homeIcon } from '$lib/components/icons';
|
||||||
|
|
||||||
const famSlug = page.params.fam;
|
const famSlug = page.params.fam;
|
||||||
let name = $state('');
|
let name = $state('');
|
||||||
let otp = $state(page.url.searchParams.get('code') || '');
|
let otp = $state(page.url.searchParams.get('code') || '');
|
||||||
|
let joined = $state<{ username: string; hasOtherKids: boolean } | null>(null);
|
||||||
let { form } = $props();
|
let { form } = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -13,9 +15,18 @@
|
|||||||
|
|
||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if joined}
|
||||||
|
<JoinPinFlow
|
||||||
|
targetUrl={`/${famSlug}/${encodeURIComponent(joined.username)}`}
|
||||||
|
force={joined.hasOtherKids}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
<h1 class="text-xl font-bold text-slate-900">Join {famSlug}</h1>
|
<h1 class="text-xl font-bold text-slate-900">Join {famSlug}</h1>
|
||||||
<p class="mt-1 text-sm text-slate-500">
|
<p class="mt-1 text-sm text-slate-500">
|
||||||
Enter your name and the code your parent gave you to get started.
|
Enter your name and the code your parent gave you to get started.
|
||||||
@@ -29,6 +40,13 @@
|
|||||||
if (result.type === 'failure') {
|
if (result.type === 'failure') {
|
||||||
name = (result.data as any)?.name || '';
|
name = (result.data as any)?.name || '';
|
||||||
otp = (result.data as any)?.otp || '';
|
otp = (result.data as any)?.otp || '';
|
||||||
|
await update();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const d = (result as any).data;
|
||||||
|
if (d?.joined) {
|
||||||
|
joined = { username: d.username, hasOtherKids: !!d.hasOtherKids };
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
await update();
|
await update();
|
||||||
};
|
};
|
||||||
@@ -50,7 +68,7 @@
|
|||||||
maxlength="6"
|
maxlength="6"
|
||||||
placeholder="6-digit code"
|
placeholder="6-digit code"
|
||||||
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-lg tracking-[0.5em] text-slate-900 outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-200"
|
||||||
/>
|
/>
|
||||||
{#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>
|
||||||
@@ -61,5 +79,13 @@
|
|||||||
<p class="mt-6 text-xs text-slate-400">
|
<p class="mt-6 text-xs text-slate-400">
|
||||||
Code is valid for 20 minutes. Ask your parent for a new one if it expires.
|
Code is valid for 20 minutes. Ask your parent for a new one if it expires.
|
||||||
</p>
|
</p>
|
||||||
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.home-badge :global(svg) {
|
||||||
|
width: 1.75rem;
|
||||||
|
height: 1.75rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,6 +1,37 @@
|
|||||||
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 { setSessionCookie, clearLegacyCookies } from '$lib/server/session';
|
import { createSuperClient } from '$lib/server/pocketbase';
|
||||||
|
import {
|
||||||
|
setSessionCookie,
|
||||||
|
clearLegacyCookies,
|
||||||
|
setChildSessionCookie,
|
||||||
|
setActiveChild,
|
||||||
|
scanChildSessions,
|
||||||
|
clearActiveChild
|
||||||
|
} 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,17 +39,51 @@ 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' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isParent) {
|
||||||
|
const token = await redeemParentOtp({ famSlug: fam, username, otp, password });
|
||||||
clearLegacyCookies(event.cookies);
|
clearLegacyCookies(event.cookies);
|
||||||
setSessionCookie(event.cookies, token);
|
setSessionCookie(event.cookies, token);
|
||||||
|
// A parent join supersedes kid mode on a shared device.
|
||||||
|
clearActiveChild(event.cookies);
|
||||||
|
throw redirect(303, `/${fam}/${encodeURIComponent(username)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Child: shared-device session (pb_token_<id> + pb_active) so
|
||||||
|
// siblings' sessions on this computer survive.
|
||||||
|
const { token, userId } = await redeemOtp({ famSlug: fam, username, otp });
|
||||||
|
clearLegacyCookies(event.cookies);
|
||||||
|
setChildSessionCookie(event.cookies, userId, token);
|
||||||
|
setActiveChild(event.cookies, userId);
|
||||||
|
const hasOtherKids = scanChildSessions(event.cookies).length > 1;
|
||||||
|
return { joined: true, famSlug: fam, username, hasOtherKids };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return fail(400, { error: e instanceof Error ? e.message : 'Join failed' });
|
return fail(400, { error: e instanceof Error ? e.message : 'Join failed' });
|
||||||
}
|
}
|
||||||
|
|
||||||
throw redirect(303, `/${fam}/${encodeURIComponent(username)}`);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -1,26 +1,49 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
import { Button } from '$lib/components';
|
import { Button, JoinPinFlow } from '$lib/components';
|
||||||
|
import { homeIcon } from '$lib/components/icons';
|
||||||
|
|
||||||
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 joined = $state<{ hasOtherKids: boolean } | null>(null);
|
||||||
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
{#if joined}
|
||||||
|
<JoinPinFlow
|
||||||
|
targetUrl={`/${famSlug}/${encodeURIComponent(username || '')}`}
|
||||||
|
force={joined.hasOtherKids}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
{#if isParent}
|
||||||
|
<h1 class="text-xl font-bold text-slate-900">Join {famSlug}</h1>
|
||||||
|
<p class="mt-1 text-sm text-slate-500">
|
||||||
|
Hi <span class="font-semibold text-slate-700">{username}</span> — enter the code from your
|
||||||
|
email and create a password to join <strong>{famName}</strong> as a parent.
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
<h1 class="text-xl font-bold text-slate-900">Welcome to {famSlug}!</h1>
|
<h1 class="text-xl font-bold text-slate-900">Welcome to {famSlug}!</h1>
|
||||||
<p class="mt-1 text-sm text-slate-500">
|
<p class="mt-1 text-sm text-slate-500">
|
||||||
Hi <span class="font-semibold text-slate-700">{username}</span> — enter the code your parent
|
Hi <span class="font-semibold text-slate-700">{username}</span> — enter the code your parent
|
||||||
gave you to get started.
|
gave you to get started.
|
||||||
</p>
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<form
|
<form
|
||||||
class="mt-6 flex flex-col gap-3"
|
class="mt-6 flex flex-col gap-3"
|
||||||
@@ -28,8 +51,16 @@
|
|||||||
use:enhance={() => {
|
use:enhance={() => {
|
||||||
return async ({ result, update }) => {
|
return async ({ result, update }) => {
|
||||||
if (result.type === 'failure') {
|
if (result.type === 'failure') {
|
||||||
otp = (result.data as any)?.otp || '';
|
otp = (result as any).data?.otp || '';
|
||||||
|
await update();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
const d = (result as any).data;
|
||||||
|
if (d?.joined) {
|
||||||
|
joined = { hasOtherKids: !!d.hasOtherKids };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Parent joins redirect server-side (303) — nothing to do here.
|
||||||
await update();
|
await update();
|
||||||
};
|
};
|
||||||
}}
|
}}
|
||||||
@@ -42,16 +73,46 @@
|
|||||||
maxlength="6"
|
maxlength="6"
|
||||||
placeholder="6-digit code"
|
placeholder="6-digit code"
|
||||||
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-lg 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">
|
||||||
Code is valid for 20 minutes. Ask your parent for a new one if it expires.
|
Code is valid for 20 minutes. Ask your parent for a new one if it expires.
|
||||||
</p>
|
</p>
|
||||||
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.home-badge :global(svg) {
|
||||||
|
width: 1.75rem;
|
||||||
|
height: 1.75rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { redirect } from '@sveltejs/kit';
|
||||||
|
|
||||||
|
// The fam layout renders the shared-device picker when child sessions exist
|
||||||
|
// but none is active. With an active session this route just sends you home.
|
||||||
|
export async function load(event) {
|
||||||
|
const session = event.locals.user;
|
||||||
|
if (session) {
|
||||||
|
const fam = event.params.fam;
|
||||||
|
if (session.role === 'parent') throw redirect(303, `/${fam}`);
|
||||||
|
throw redirect(303, `/${fam}/${encodeURIComponent(session.username || '')}`);
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<!-- Shared-device profile picker. The fam layout renders the picker itself in
|
||||||
|
picker mode (no active session, child sessions present) — this page only
|
||||||
|
exists so /{fam}/switch resolves as a route. -->
|
||||||
|
<div></div>
|
||||||
@@ -89,12 +89,15 @@ export const load: PageServerLoad = async (event) => {
|
|||||||
pbAdmin.getList('bonus_templates', 'global = true')
|
pbAdmin.getList('bonus_templates', 'global = true')
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const platformFlags = await getPlatformFlags();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
authenticated: true,
|
authenticated: true,
|
||||||
fams: famsWithStats,
|
fams: famsWithStats,
|
||||||
codes: codeList,
|
codes: codeList,
|
||||||
choreTemplates: choreTemplates as any[],
|
choreTemplates: choreTemplates as any[],
|
||||||
bonusTemplates: bonusTemplates as any[],
|
bonusTemplates: bonusTemplates as any[],
|
||||||
|
platformFlags,
|
||||||
totalFams: fams.length,
|
totalFams: fams.length,
|
||||||
totalMembers: famsWithStats.reduce((s, f) => s + f.memberCount, 0),
|
totalMembers: famsWithStats.reduce((s, f) => s + f.memberCount, 0),
|
||||||
totalRewards: famsWithStats.reduce((s, f) => s + f.totalRewards, 0),
|
totalRewards: famsWithStats.reduce((s, f) => s + f.totalRewards, 0),
|
||||||
@@ -121,6 +124,7 @@ export const load: PageServerLoad = async (event) => {
|
|||||||
codeCount: 0,
|
codeCount: 0,
|
||||||
choreTemplates: [],
|
choreTemplates: [],
|
||||||
bonusTemplates: [],
|
bonusTemplates: [],
|
||||||
|
platformFlags: {},
|
||||||
loadError: e instanceof Error ? e.message : 'Failed to load platform data'
|
loadError: e instanceof Error ? e.message : 'Failed to load platform data'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -277,8 +281,7 @@ export const actions: Actions = {
|
|||||||
rewardType: (fd.get('rewardType') as string) || 'points',
|
rewardType: (fd.get('rewardType') as string) || 'points',
|
||||||
rewardValue: (fd.get('rewardValue') as string) || '',
|
rewardValue: (fd.get('rewardValue') as string) || '',
|
||||||
criteriaValue: Number(fd.get('criteriaValue') || 0),
|
criteriaValue: Number(fd.get('criteriaValue') || 0),
|
||||||
period: (fd.get('period') as string) || 'weekly',
|
period: (fd.get('period') as string) || ''
|
||||||
isPocketMoney: fd.get('isPocketMoney') === 'true'
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return { success: true };
|
return { success: true };
|
||||||
@@ -322,8 +325,7 @@ export const actions: Actions = {
|
|||||||
rewardType: (fd.get('rewardType') as string) || 'points',
|
rewardType: (fd.get('rewardType') as string) || 'points',
|
||||||
rewardValue: (fd.get('rewardValue') as string) || '',
|
rewardValue: (fd.get('rewardValue') as string) || '',
|
||||||
criteriaValue: Number(fd.get('criteriaValue') || 0),
|
criteriaValue: Number(fd.get('criteriaValue') || 0),
|
||||||
period: (fd.get('period') as string) || 'weekly',
|
period: (fd.get('period') as string) || ''
|
||||||
isPocketMoney: fd.get('isPocketMoney') === 'true'
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return { success: true };
|
return { success: true };
|
||||||
|
|||||||
@@ -3,9 +3,11 @@
|
|||||||
import { goto, invalidateAll } from '$app/navigation';
|
import { goto, invalidateAll } from '$app/navigation';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { ViewHeader, CardGrid, Card, Button } from '$lib/components';
|
import { ViewHeader, CardGrid, Card, Button } from '$lib/components';
|
||||||
|
import FamDone from '$lib/components/FamDone.svelte';
|
||||||
import TemplateIcon from '$lib/components/TemplateIcon.svelte';
|
import TemplateIcon from '$lib/components/TemplateIcon.svelte';
|
||||||
import { ICON_NAMES, TEMPLATE_COLORS } from '$lib/templateIcons';
|
import { ICON_NAMES, TEMPLATE_COLORS } from '$lib/templateIcons';
|
||||||
import Footer from '$lib/components/Footer.svelte';
|
import Footer from '$lib/components/Footer.svelte';
|
||||||
|
import { homeIcon, chevronRightIcon } from '$lib/components/icons';
|
||||||
|
|
||||||
let { data, form } = $props();
|
let { data, form } = $props();
|
||||||
|
|
||||||
@@ -43,8 +45,7 @@
|
|||||||
rewardType: 'points',
|
rewardType: 'points',
|
||||||
rewardValue: '',
|
rewardValue: '',
|
||||||
criteriaValue: 0,
|
criteriaValue: 0,
|
||||||
period: 'weekly',
|
period: 'daily'
|
||||||
isPocketMoney: false
|
|
||||||
};
|
};
|
||||||
let tplVals = $state({ ...blankTpl });
|
let tplVals = $state({ ...blankTpl });
|
||||||
|
|
||||||
@@ -72,20 +73,17 @@
|
|||||||
rewardType: rec.rewardType || 'points',
|
rewardType: rec.rewardType || 'points',
|
||||||
rewardValue: rec.rewardValue ?? '',
|
rewardValue: rec.rewardValue ?? '',
|
||||||
criteriaValue: rec.criteriaValue ?? 0,
|
criteriaValue: rec.criteriaValue ?? 0,
|
||||||
period: rec.period || 'weekly',
|
period: rec.period || ''
|
||||||
isPocketMoney: !!rec.isPocketMoney
|
|
||||||
};
|
};
|
||||||
showTplModal = true;
|
showTplModal = true;
|
||||||
}
|
}
|
||||||
const allTemplates = $derived([
|
|
||||||
...(data.choreTemplates || []).map((t: any) => ({ ...t, kind: 'chore' as TplKind })),
|
|
||||||
...(data.bonusTemplates || []).map((t: any) => ({ ...t, kind: 'reward' as TplKind }))
|
|
||||||
]);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="shell">
|
<div class="shell">
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<a class="brand" href="/"><span class="brand-icon">🏠</span> <strong>FamChore</strong></a>
|
<a class="brand" href="/"
|
||||||
|
><span class="brand-icon">{@html homeIcon}</span> <strong><FamDone /></strong></a
|
||||||
|
>
|
||||||
<nav>
|
<nav>
|
||||||
{#if data.authenticated}
|
{#if data.authenticated}
|
||||||
<form method="POST" action="?/logout" use:enhance>
|
<form method="POST" action="?/logout" use:enhance>
|
||||||
@@ -98,7 +96,7 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
{#if !data.authenticated}
|
{#if !data.authenticated}
|
||||||
<div class="login-card">
|
<div class="login-card">
|
||||||
<h1>Platform Admin</h1>
|
<h1>Platform Admin</h1>
|
||||||
<p class="subtitle">Sign in to access the admin dashboard</p>
|
<p class="subtitle">Sign in to access the admin dashboard</p>
|
||||||
@@ -138,8 +136,12 @@
|
|||||||
<Button type="submit" variant="primary" size="lg">Sign In</Button>
|
<Button type="submit" variant="primary" size="lg">Sign In</Button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<ViewHeader title="Platform Admin" subtitle="Families, access codes and platform settings" hero />
|
<ViewHeader
|
||||||
|
title="Platform Admin"
|
||||||
|
subtitle="Families, access codes and platform settings"
|
||||||
|
hero
|
||||||
|
/>
|
||||||
|
|
||||||
<CardGrid>
|
<CardGrid>
|
||||||
<Card cols={2} title="Overview" accent="#6366f1">
|
<Card cols={2} title="Overview" accent="#6366f1">
|
||||||
@@ -177,11 +179,43 @@
|
|||||||
<span class="flag-name">debug</span>
|
<span class="flag-name">debug</span>
|
||||||
<form method="POST" action="?/togglePlatformFlag" use:enhance>
|
<form method="POST" action="?/togglePlatformFlag" use:enhance>
|
||||||
<input type="hidden" name="flag" value="debug" />
|
<input type="hidden" name="flag" value="debug" />
|
||||||
<button type="submit" class="toggle-btn" class:active={page.data.platformFlags?.debug}>
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="toggle-btn"
|
||||||
|
class:active={page.data.platformFlags?.debug}
|
||||||
|
>
|
||||||
{page.data.platformFlags?.debug ? 'ON' : 'OFF'}
|
{page.data.platformFlags?.debug ? 'ON' : 'OFF'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flag-row">
|
||||||
|
<span class="flag-name">demo</span>
|
||||||
|
<span class="flag-desc">Showboaters demo family with sample data for prospectus viewers</span>
|
||||||
|
<form method="POST" action="?/togglePlatformFlag" use:enhance>
|
||||||
|
<input type="hidden" name="flag" value="demo" />
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="toggle-btn"
|
||||||
|
class:active={page.data.platformFlags?.demo}
|
||||||
|
>
|
||||||
|
{page.data.platformFlags?.demo ? 'ON' : 'OFF'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="flag-row">
|
||||||
|
<span class="flag-name">rewardsTargets</span>
|
||||||
|
<span class="flag-desc">Multi-target reward rules (Competition / Collaboration)</span>
|
||||||
|
<form method="POST" action="?/togglePlatformFlag" use:enhance>
|
||||||
|
<input type="hidden" name="flag" value="rewardsTargets" />
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="toggle-btn"
|
||||||
|
class:active={page.data.platformFlags?.rewardsTargets}
|
||||||
|
>
|
||||||
|
{page.data.platformFlags?.rewardsTargets ? 'ON' : 'OFF'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</CardGrid>
|
</CardGrid>
|
||||||
|
|
||||||
@@ -257,7 +291,9 @@
|
|||||||
<td class="row-actions" data-label="Actions">
|
<td class="row-actions" data-label="Actions">
|
||||||
<form method="POST" action="?/toggleCode" use:enhance>
|
<form method="POST" action="?/toggleCode" use:enhance>
|
||||||
<input type="hidden" name="id" value={c.id} />
|
<input type="hidden" name="id" value={c.id} />
|
||||||
<button type="submit" class="link-btn">{c.active ? 'Disable' : 'Enable'}</button>
|
<button type="submit" class="link-btn"
|
||||||
|
>{c.active ? 'Disable' : 'Enable'}</button
|
||||||
|
>
|
||||||
</form>
|
</form>
|
||||||
{#if !c.usedBy.length}
|
{#if !c.usedBy.length}
|
||||||
<form method="POST" action="?/deleteCode" use:enhance>
|
<form method="POST" action="?/deleteCode" use:enhance>
|
||||||
@@ -300,8 +336,16 @@
|
|||||||
<td data-label="Parent">{fam.parentEmail || '—'}</td>
|
<td data-label="Parent">{fam.parentEmail || '—'}</td>
|
||||||
<td data-label="Members">{fam.memberCount}</td>
|
<td data-label="Members">{fam.memberCount}</td>
|
||||||
<td data-label="Plan">
|
<td data-label="Plan">
|
||||||
<span class="badge {fam.paymentMode === 'sub' ? 'on' : fam.active ? 'dur' : 'off'}">
|
<span
|
||||||
{modeLabel[fam.paymentMode] || fam.paymentMode}{fam.active ? '' : ' · paused'}
|
class="badge {fam.paymentMode === 'sub'
|
||||||
|
? 'on'
|
||||||
|
: fam.active
|
||||||
|
? 'dur'
|
||||||
|
: 'off'}"
|
||||||
|
>
|
||||||
|
{modeLabel[fam.paymentMode] || fam.paymentMode}{fam.active
|
||||||
|
? ''
|
||||||
|
: ' · paused'}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td data-label="Claims">
|
<td data-label="Claims">
|
||||||
@@ -311,43 +355,42 @@
|
|||||||
<span class="badge none">None</span>
|
<span class="badge none">None</span>
|
||||||
{/if}
|
{/if}
|
||||||
</td>
|
</td>
|
||||||
<td class="row-actions" data-label="Actions"><a href="/{fam.slug}" class="link">View →</a></td>
|
<td class="row-actions" data-label="Actions"
|
||||||
|
><a href="/{fam.slug}" class="link">View {@html chevronRightIcon}</a></td
|
||||||
|
>
|
||||||
</tr>
|
</tr>
|
||||||
{/each}
|
{/each}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</Card>
|
</div></Card
|
||||||
|
>
|
||||||
</CardGrid>
|
</CardGrid>
|
||||||
|
|
||||||
<CardGrid>
|
<CardGrid>
|
||||||
<Card cols={3} title="Templates" accent="#8b5cf6">
|
<Card cols={2} title="Chore Templates" accent="#2563eb">
|
||||||
<p class="muted">
|
|
||||||
Platform-owned chore & reward droplets. Families assign these as read-only sources — they
|
|
||||||
can't create their own.
|
|
||||||
</p>
|
|
||||||
<div class="tpl-grid">
|
<div class="tpl-grid">
|
||||||
{#each allTemplates as t}
|
{#each data.choreTemplates || [] as t}
|
||||||
<div class="tpl-card" style="border-left-color:{t.color}">
|
<div class="tpl-card" style="border-left-color:{t.color}">
|
||||||
<div class="tpl-head">
|
<div class="tpl-head">
|
||||||
<TemplateIcon name={t.icon} size={20} color={t.color} />
|
<TemplateIcon name={t.icon} size={20} color={t.color} />
|
||||||
<strong>{t.name}</strong>
|
<strong>{t.name}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="tpl-meta">
|
<div class="tpl-meta">
|
||||||
<span class="badge">{t.kind}</span>
|
<span class="badge chore">{t.defaultFrequency}</span>
|
||||||
{#if t.isPocketMoney}<span class="badge pocket">Pocket Money</span>{/if}
|
<span class="badge">{t.defaultType}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="tpl-actions">
|
<div class="tpl-actions">
|
||||||
<button class="link-btn" onclick={() => openEditTpl(t.kind, t)}>Edit</button>
|
<button class="link-btn" onclick={() => openEditTpl('chore', t)}>Edit</button>
|
||||||
<form
|
<form
|
||||||
method="POST"
|
method="POST"
|
||||||
action="?/deleteTemplate"
|
action="?/deleteTemplate"
|
||||||
use:enhance={() => {
|
use:enhance={() => {
|
||||||
return async ({ result, update }) => {
|
return async ({ update }) => {
|
||||||
update();
|
update();
|
||||||
};
|
};
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<input type="hidden" name="kind" value={t.kind} />
|
<input type="hidden" name="kind" value="chore" />
|
||||||
<input type="hidden" name="id" value={t.id} />
|
<input type="hidden" name="id" value={t.id} />
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
@@ -360,12 +403,57 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
{#if allTemplates.length === 0}
|
{#if (data.choreTemplates || []).length === 0}
|
||||||
<p class="muted">No templates yet.</p>
|
<p class="muted">No chore templates yet.</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="tpl-new">
|
<div class="tpl-new">
|
||||||
<Button onclick={() => openNewTpl('chore')}>+ Chore template</Button>
|
<Button onclick={() => openNewTpl('chore')}>+ Chore template</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card cols={1} title="Reward Templates" accent="#8b5cf6">
|
||||||
|
<div class="tpl-grid">
|
||||||
|
{#each data.bonusTemplates || [] as t}
|
||||||
|
<div class="tpl-card" style="border-left-color:{t.color}">
|
||||||
|
<div class="tpl-head">
|
||||||
|
<TemplateIcon name={t.icon} size={20} color={t.color} />
|
||||||
|
<strong>{t.name}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="tpl-meta">
|
||||||
|
<span class="badge reward">{t.type}</span>
|
||||||
|
<span class="badge">{t.rewardType}</span>
|
||||||
|
{#if t.isPocketMoney}<span class="badge pocket">Pocket Money</span>{/if}
|
||||||
|
</div>
|
||||||
|
<div class="tpl-actions">
|
||||||
|
<button class="link-btn" onclick={() => openEditTpl('reward', t)}>Edit</button>
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/deleteTemplate"
|
||||||
|
use:enhance={() => {
|
||||||
|
return async ({ update }) => {
|
||||||
|
update();
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input type="hidden" name="kind" value="reward" />
|
||||||
|
<input type="hidden" name="id" value={t.id} />
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="link-btn danger"
|
||||||
|
onclick={(e) => {
|
||||||
|
if (!confirm('Delete this template?')) e.preventDefault();
|
||||||
|
}}>Delete</button
|
||||||
|
>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{#if (data.bonusTemplates || []).length === 0}
|
||||||
|
<p class="muted">No reward templates yet.</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="tpl-new">
|
||||||
<Button onclick={() => openNewTpl('reward')}>+ Reward template</Button>
|
<Button onclick={() => openNewTpl('reward')}>+ Reward template</Button>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -391,7 +479,9 @@
|
|||||||
{/if}
|
{/if}
|
||||||
<input type="hidden" name="type" value={tplType} />
|
<input type="hidden" name="type" value={tplType} />
|
||||||
<label>Name <input name="name" bind:value={tplVals.name} required /></label>
|
<label>Name <input name="name" bind:value={tplVals.name} required /></label>
|
||||||
<label>Description <input name="description" bind:value={tplVals.description} /></label>
|
<label
|
||||||
|
>Description <input name="description" bind:value={tplVals.description} /></label
|
||||||
|
>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<label>
|
<label>
|
||||||
Icon
|
Icon
|
||||||
@@ -399,7 +489,9 @@
|
|||||||
{#each ICON_NAMES as n}<option value={n}>{n}</option>{/each}
|
{#each ICON_NAMES as n}<option value={n}>{n}</option>{/each}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<span class="icon-preview"><TemplateIcon name={tplVals.icon} size={22} color={tplVals.color} /></span>
|
<span class="icon-preview"
|
||||||
|
><TemplateIcon name={tplVals.icon} size={22} color={tplVals.color} /></span
|
||||||
|
>
|
||||||
<div class="color-field">
|
<div class="color-field">
|
||||||
Colour
|
Colour
|
||||||
<div class="color-swatches">
|
<div class="color-swatches">
|
||||||
@@ -413,7 +505,8 @@
|
|||||||
? '#e5e7eb'
|
? '#e5e7eb'
|
||||||
: c.value}"
|
: c.value}"
|
||||||
title={c.label}
|
title={c.label}
|
||||||
onclick={() => (tplVals.color = c.value)}></button>
|
onclick={() => (tplVals.color = c.value)}
|
||||||
|
></button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
<input type="hidden" name="color" value={tplVals.color} />
|
<input type="hidden" name="color" value={tplVals.color} />
|
||||||
@@ -437,7 +530,12 @@
|
|||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
Default Value
|
Default Value
|
||||||
<input type="number" step="any" name="defaultValue" bind:value={tplVals.defaultValue} />
|
<input
|
||||||
|
type="number"
|
||||||
|
step="any"
|
||||||
|
name="defaultValue"
|
||||||
|
bind:value={tplVals.defaultValue}
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
{:else}
|
{:else}
|
||||||
<label>
|
<label>
|
||||||
@@ -452,7 +550,7 @@
|
|||||||
Type
|
Type
|
||||||
<select name="bonusType" bind:value={tplVals.bonusType}>
|
<select name="bonusType" bind:value={tplVals.bonusType}>
|
||||||
<option value="threshold">Threshold</option>
|
<option value="threshold">Threshold</option>
|
||||||
<option value="count">Count</option>
|
<option value="count">Count - (chores)</option>
|
||||||
<option value="manual">Manual</option>
|
<option value="manual">Manual</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
@@ -484,22 +582,24 @@
|
|||||||
Reward Value
|
Reward Value
|
||||||
<input name="rewardValue" bind:value={tplVals.rewardValue} />
|
<input name="rewardValue" bind:value={tplVals.rewardValue} />
|
||||||
</label>
|
</label>
|
||||||
|
{#if tplVals.bonusType !== 'manual'}
|
||||||
<label>
|
<label>
|
||||||
Target Value
|
Target Value
|
||||||
<input type="number" name="criteriaValue" bind:value={tplVals.criteriaValue} />
|
<input type="number" name="criteriaValue" bind:value={tplVals.criteriaValue} />
|
||||||
</label>
|
</label>
|
||||||
|
{/if}
|
||||||
|
{#if tplVals.bonusType === 'manual'}
|
||||||
<label>
|
<label>
|
||||||
Period
|
Period
|
||||||
<select name="period" bind:value={tplVals.period}>
|
<select name="period" bind:value={tplVals.period}>
|
||||||
|
<option value="">Unlimited</option>
|
||||||
<option value="daily">Daily</option>
|
<option value="daily">Daily</option>
|
||||||
<option value="weekly">Weekly</option>
|
<option value="weekly">Weekly</option>
|
||||||
<option value="monthly">Monthly</option>
|
<option value="monthly">Monthly</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label class="check">
|
{/if}
|
||||||
<input type="checkbox" name="isPocketMoney" value="true" bind:checked={tplVals.isPocketMoney} />
|
|
||||||
Pocket Money
|
|
||||||
</label>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
@@ -510,8 +610,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
{/if}
|
||||||
{/if}
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<Footer />
|
<Footer />
|
||||||
@@ -522,9 +621,7 @@
|
|||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-rows: auto 1fr auto;
|
grid-template-rows: auto 1fr auto;
|
||||||
background:
|
background: radial-gradient(60rem 30rem at 50% -10rem, #eef2ff 0%, transparent 65%), #fafafa;
|
||||||
radial-gradient(60rem 30rem at 50% -10rem, #eef2ff 0%, transparent 65%),
|
|
||||||
#fafafa;
|
|
||||||
}
|
}
|
||||||
.topbar {
|
.topbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -543,7 +640,11 @@
|
|||||||
color: #111827;
|
color: #111827;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
.topbar nav { display: flex; align-items: center; gap: 1rem; }
|
.topbar nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
.nav-link {
|
.nav-link {
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
color: #4b5563;
|
color: #4b5563;
|
||||||
@@ -556,7 +657,9 @@
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
.link-btn-nav:hover { color: #111827; }
|
.link-btn-nav:hover {
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
main {
|
main {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -570,8 +673,12 @@
|
|||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
main :global(.card-grid),
|
main :global(.card-grid),
|
||||||
main :global(.hero) { width: 100%; }
|
main :global(.hero) {
|
||||||
.head { text-align: center; }
|
width: 100%;
|
||||||
|
}
|
||||||
|
.head {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
.head h1 {
|
.head h1 {
|
||||||
margin: 0 0 0.35rem;
|
margin: 0 0 0.35rem;
|
||||||
font-size: clamp(1.5rem, 3vw, 2rem);
|
font-size: clamp(1.5rem, 3vw, 2rem);
|
||||||
@@ -737,10 +844,18 @@
|
|||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
color: #374151;
|
color: #374151;
|
||||||
}
|
}
|
||||||
.fam-table tbody tr:last-child td { border-bottom: none; }
|
.fam-table tbody tr:last-child td {
|
||||||
.fam-table tbody tr { transition: background 0.12s ease; }
|
border-bottom: none;
|
||||||
.fam-table tbody tr:hover { background: #f8faff; }
|
}
|
||||||
.th-actions { text-align: right; }
|
.fam-table tbody tr {
|
||||||
|
transition: background 0.12s ease;
|
||||||
|
}
|
||||||
|
.fam-table tbody tr:hover {
|
||||||
|
background: #f8faff;
|
||||||
|
}
|
||||||
|
.th-actions {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
.code-value {
|
.code-value {
|
||||||
font-family: ui-monospace, monospace;
|
font-family: ui-monospace, monospace;
|
||||||
@@ -836,6 +951,11 @@
|
|||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: #374151;
|
color: #374151;
|
||||||
}
|
}
|
||||||
|
.flag-desc {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: #9ca3af;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
.toggle-btn {
|
.toggle-btn {
|
||||||
background: #e5e7eb;
|
background: #e5e7eb;
|
||||||
@@ -856,7 +976,9 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
.fam-link:hover { color: #4338ca; }
|
.fam-link:hover {
|
||||||
|
color: #4338ca;
|
||||||
|
}
|
||||||
.link {
|
.link {
|
||||||
color: #4338ca;
|
color: #4338ca;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
@@ -881,7 +1003,11 @@
|
|||||||
|
|
||||||
/* ── Responsive: stack rows into cards with labels ── */
|
/* ── Responsive: stack rows into cards with labels ── */
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
.table-wrap { border: none; overflow: visible; background: transparent; }
|
.table-wrap {
|
||||||
|
border: none;
|
||||||
|
overflow: visible;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
.fam-table,
|
.fam-table,
|
||||||
.fam-table tbody,
|
.fam-table tbody,
|
||||||
.fam-table tr,
|
.fam-table tr,
|
||||||
@@ -889,7 +1015,9 @@
|
|||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
.fam-table thead { display: none; }
|
.fam-table thead {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
.fam-table tbody tr {
|
.fam-table tbody tr {
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border: 1px solid #e5e7eb;
|
border: 1px solid #e5e7eb;
|
||||||
@@ -916,7 +1044,9 @@
|
|||||||
text-align: left;
|
text-align: left;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.fam-table tbody td:empty { display: none; }
|
.fam-table tbody td:empty {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
.row-actions {
|
.row-actions {
|
||||||
justify-content: flex-end !important;
|
justify-content: flex-end !important;
|
||||||
border-bottom: none !important;
|
border-bottom: none !important;
|
||||||
@@ -945,11 +1075,30 @@
|
|||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
margin-bottom: 0.35rem;
|
margin-bottom: 0.35rem;
|
||||||
}
|
}
|
||||||
.tpl-head strong { font-size: 0.9rem; }
|
.tpl-head strong {
|
||||||
.tpl-meta { display: flex; gap: 0.3rem; flex-wrap: wrap; margin-bottom: 0.4rem; }
|
font-size: 0.9rem;
|
||||||
.tpl-actions { display: flex; gap: 0.75rem; }
|
}
|
||||||
.tpl-new { display: flex; gap: 0.6rem; margin-top: 0.5rem; }
|
.tpl-meta {
|
||||||
.row { display: flex; align-items: flex-end; gap: 0.75rem; flex-wrap: wrap; }
|
display: flex;
|
||||||
|
gap: 0.3rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
}
|
||||||
|
.tpl-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
.tpl-new {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.6rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 0.75rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
.color-field {
|
.color-field {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -958,7 +1107,11 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #6b7280;
|
color: #6b7280;
|
||||||
}
|
}
|
||||||
.color-swatches { display: flex; flex-wrap: wrap; gap: 0.4rem; }
|
.color-swatches {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
.swatch {
|
.swatch {
|
||||||
width: 26px;
|
width: 26px;
|
||||||
height: 26px;
|
height: 26px;
|
||||||
@@ -968,7 +1121,10 @@
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08);
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08);
|
||||||
}
|
}
|
||||||
.swatch.selected { outline: 2px solid #111827; outline-offset: 1px; }
|
.swatch.selected {
|
||||||
|
outline: 2px solid #111827;
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
.icon-preview {
|
.icon-preview {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -978,8 +1134,18 @@
|
|||||||
border: 1px solid #e5e7eb;
|
border: 1px solid #e5e7eb;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
.badge.pocket { background: #dcfce7; color: #166534; }
|
.badge.chore {
|
||||||
.check { display: flex; align-items: center; gap: 0.4rem; }
|
background: #dbeafe;
|
||||||
|
color: #1e40af;
|
||||||
|
}
|
||||||
|
.badge.reward {
|
||||||
|
background: #ede9fe;
|
||||||
|
color: #6d28d9;
|
||||||
|
}
|
||||||
|
.badge.pocket {
|
||||||
|
background: #dcfce7;
|
||||||
|
color: #166534;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Modal (shared) ── */
|
/* ── Modal (shared) ── */
|
||||||
.overlay {
|
.overlay {
|
||||||
@@ -1001,7 +1167,9 @@
|
|||||||
overflow: auto;
|
overflow: auto;
|
||||||
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.25);
|
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.25);
|
||||||
}
|
}
|
||||||
.modal h3 { margin: 0 0 0.75rem; }
|
.modal h3 {
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
}
|
||||||
.modal label {
|
.modal label {
|
||||||
display: block;
|
display: block;
|
||||||
margin-bottom: 0.6rem;
|
margin-bottom: 0.6rem;
|
||||||
@@ -1032,6 +1200,12 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
.modal-actions button[type='button'] { background: #e5e7eb; color: #374151; }
|
.modal-actions button[type='button'] {
|
||||||
.modal-actions button[type='submit'] { background: #6366f1; color: #fff; }
|
background: #e5e7eb;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
.modal-actions button[type='submit'] {
|
||||||
|
background: #6366f1;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -34,7 +34,8 @@ export async function POST(event: RequestEvent) {
|
|||||||
? await s.chat.typing(famId, actor, { typing: Boolean(body.typing) })
|
? await s.chat.typing(famId, actor, { typing: Boolean(body.typing) })
|
||||||
: await s.chat.send(famId, actor, {
|
: await s.chat.send(famId, actor, {
|
||||||
content: body.content || '',
|
content: body.content || '',
|
||||||
clientId: body.clientId || ''
|
clientId: body.clientId || '',
|
||||||
|
siteUrl: event.url.origin
|
||||||
});
|
});
|
||||||
return json(data);
|
return json(data);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -5,13 +5,23 @@ import { createServices } from '$lib/server/services';
|
|||||||
|
|
||||||
export async function POST(event: RequestEvent) {
|
export async function POST(event: RequestEvent) {
|
||||||
const u = event.locals.user;
|
const u = event.locals.user;
|
||||||
if (!u || !event.locals.pbToken) throw error(401, 'Unauthorized');
|
if (!u || !event.locals.pbToken) {
|
||||||
|
console.error(`[diag] toggle 401 role=${u?.role} hasToken=${!!event.locals.pbToken}`);
|
||||||
|
throw error(401, 'Unauthorized');
|
||||||
|
}
|
||||||
const pb = createPbClient(event.locals.pbToken);
|
const pb = createPbClient(event.locals.pbToken);
|
||||||
const body = await event.request.json().catch(() => ({}));
|
const body = await event.request.json().catch(() => ({}));
|
||||||
try {
|
try {
|
||||||
const s = createServices(pb, { id: u.id, role: u.role });
|
const s = createServices(pb, { id: u.id, role: u.role });
|
||||||
return json(await s.completions.toggle(u.famId, body));
|
const out = await s.completions.toggle(u.famId, body);
|
||||||
|
console.log(
|
||||||
|
`[diag] toggle ok member=${u.id} chore=${body.assignedChoreId} date=${body.date} completed=${(out as any)?.completed}`
|
||||||
|
);
|
||||||
|
return json(out);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
console.error(
|
||||||
|
`[diag] toggle 400 member=${u.id} role=${u.role} fam=${u.famId} chore=${body.assignedChoreId} date=${body.date} err=${e instanceof Error ? e.message : e}`
|
||||||
|
);
|
||||||
return json({ error: e instanceof Error ? e.message : 'toggle failed' }, { status: 400 });
|
return json({ error: e instanceof Error ? e.message : 'toggle failed' }, { status: 400 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import type { RequestEvent } from '@sveltejs/kit';
|
||||||
|
import { createPbClient, pbAdmin } from '$lib/server/pocketbase';
|
||||||
|
|
||||||
|
// TEMP diagnostic for the recurring prod CHORE_GONE. Compares what the client
|
||||||
|
// claims against server truth from both angles (superuser getOne — the toggle
|
||||||
|
// path — and the user's own list read — the kanban path). Authenticated,
|
||||||
|
// own-fam only, no tokens ever logged or returned.
|
||||||
|
export async function GET(event: RequestEvent) {
|
||||||
|
const u = event.locals.user;
|
||||||
|
if (!u || !event.locals.pbToken) throw error(401, 'Unauthorized');
|
||||||
|
const assignedChoreId = event.url.searchParams.get('assignedChoreId') || '';
|
||||||
|
|
||||||
|
let suChore: any = null;
|
||||||
|
let suError = '';
|
||||||
|
if (assignedChoreId) {
|
||||||
|
try {
|
||||||
|
suChore = await pbAdmin.getOne('assigned_chores', assignedChoreId);
|
||||||
|
} catch (e) {
|
||||||
|
suError = e instanceof Error ? e.message : String(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let userSeesIt: boolean | null = null;
|
||||||
|
let userListError = '';
|
||||||
|
try {
|
||||||
|
const pb = createPbClient(event.locals.pbToken);
|
||||||
|
const list = await pb
|
||||||
|
.collection('assigned_chores')
|
||||||
|
.getFullList({ filter: `famId = '${u.famId}'` });
|
||||||
|
if (assignedChoreId) userSeesIt = list.some((a: any) => a.id === assignedChoreId);
|
||||||
|
} catch (e) {
|
||||||
|
userListError = e instanceof Error ? e.message : String(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
const out = {
|
||||||
|
session: { id: u.id, role: u.role, famId: u.famId },
|
||||||
|
asked: assignedChoreId,
|
||||||
|
superuser: suChore
|
||||||
|
? {
|
||||||
|
found: true,
|
||||||
|
famId: suChore.famId,
|
||||||
|
memberId: suChore.memberId,
|
||||||
|
isTodo: !!suChore.isTodo,
|
||||||
|
famMatch: suChore.famId === u.famId
|
||||||
|
}
|
||||||
|
: { found: false, error: suError },
|
||||||
|
userList: { seesIt: userSeesIt, error: userListError }
|
||||||
|
};
|
||||||
|
console.log(`[diag] debug-chore ${JSON.stringify(out)}`);
|
||||||
|
return json(out);
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import type { RequestEvent } from '@sveltejs/kit';
|
||||||
|
import {
|
||||||
|
ACTIVE_COOKIE,
|
||||||
|
childSessionCookie,
|
||||||
|
clearChildSession,
|
||||||
|
clearActiveChild
|
||||||
|
} from '$lib/server/session';
|
||||||
|
|
||||||
|
// Remove ONE child profile from this device (picker ✕). Device-local cookie
|
||||||
|
// surgery only — no PB writes, no session required (the picker is reachable
|
||||||
|
// with no active user).
|
||||||
|
export async function POST(event: RequestEvent) {
|
||||||
|
const body = await event.request.json().catch(() => ({}));
|
||||||
|
const { userId } = body as { userId?: string };
|
||||||
|
if (!userId) throw error(400, 'Missing userId');
|
||||||
|
if (!event.cookies.get(childSessionCookie(userId))) {
|
||||||
|
throw error(400, 'No session on this device');
|
||||||
|
}
|
||||||
|
clearChildSession(event.cookies, userId);
|
||||||
|
if (event.cookies.get(ACTIVE_COOKIE) === userId) clearActiveChild(event.cookies);
|
||||||
|
return json({ ok: true });
|
||||||
|
}
|
||||||
@@ -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@walthamstow.xyz',
|
||||||
|
to: email,
|
||||||
|
subject: 'Reset your FamDone password',
|
||||||
|
html: `
|
||||||
|
<p>You requested a password reset for your FamDone 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 });
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import { json, error } from '@sveltejs/kit';
|
import { json, error } from '@sveltejs/kit';
|
||||||
import type { RequestEvent } from '@sveltejs/kit';
|
import type { RequestEvent } from '@sveltejs/kit';
|
||||||
import { createPbClient } from '$lib/server/pocketbase';
|
import { createPbClient, pbAdmin } from '$lib/server/pocketbase';
|
||||||
import { createServices } from '$lib/server/services';
|
import { createServices } from '$lib/server/services';
|
||||||
|
import { sendClaimRequestEmail } from '$lib/server/email';
|
||||||
|
import { handleOf } from '@shared/slugify';
|
||||||
|
|
||||||
export async function POST(event: RequestEvent) {
|
export async function POST(event: RequestEvent) {
|
||||||
const u = event.locals.user;
|
const u = event.locals.user;
|
||||||
@@ -10,7 +12,35 @@ export async function POST(event: RequestEvent) {
|
|||||||
const id = event.params.id!;
|
const id = event.params.id!;
|
||||||
try {
|
try {
|
||||||
const s = createServices(pb, { id: u.id, role: u.role });
|
const s = createServices(pb, { id: u.id, role: u.role });
|
||||||
return json(await s.rewards.claim(u.famId, id));
|
const record = await s.rewards.claim(u.famId, id);
|
||||||
|
|
||||||
|
// Tell the parent(s) a claim was requested — best-effort, so a mail
|
||||||
|
// outage can never fail the claim itself.
|
||||||
|
try {
|
||||||
|
const [parents, fam] = await Promise.all([
|
||||||
|
pbAdmin.getList('users', `famId = '${u.famId}' && role = 'parent'`),
|
||||||
|
pbAdmin.getOne('fams', u.famId)
|
||||||
|
]);
|
||||||
|
const label = record?.label || 'a reward';
|
||||||
|
const idx = label.indexOf(' – ');
|
||||||
|
const rewardName = idx === -1 ? label : label.slice(0, idx);
|
||||||
|
const rewardValue = idx === -1 ? '' : label.slice(idx + 3);
|
||||||
|
for (const parent of parents) {
|
||||||
|
if (!parent.email) continue;
|
||||||
|
await sendClaimRequestEmail({
|
||||||
|
to: parent.email,
|
||||||
|
parentName: parent.name || 'there',
|
||||||
|
childName: u.name || 'Someone',
|
||||||
|
rewardName,
|
||||||
|
rewardValue,
|
||||||
|
dashboardUrl: `${event.url.origin}/${fam?.slug || u.famId}/${handleOf(parent.username)}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Best-effort only — the claim already succeeded.
|
||||||
|
}
|
||||||
|
|
||||||
|
return json(record);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return json({ error: e instanceof Error ? e.message : 'claim failed' }, { status: 400 });
|
return json({ error: e instanceof Error ? e.message : 'claim failed' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import type { RequestEvent } from '@sveltejs/kit';
|
||||||
|
import { getPin, setPin, verifyPin, hasPin, PIN_RE } from '$lib/server/pins';
|
||||||
|
import { createPbClient } from '$lib/server/pocketbase';
|
||||||
|
|
||||||
|
// PIN status for the shared-device toggle reminders. Never reveals values:
|
||||||
|
// - child → whether THEIR OWN pin is set (about self only);
|
||||||
|
// - parent → per-child set/unset roster (names + booleans, no PIN values;
|
||||||
|
// actual values stay behind the settings revealPin action).
|
||||||
|
export async function GET(event: RequestEvent) {
|
||||||
|
const u = event.locals.user;
|
||||||
|
if (!u || !event.locals.pbToken) throw error(401, 'Unauthorized');
|
||||||
|
if (u.role === 'child') {
|
||||||
|
return json({ hasPin: await hasPin(u.id) });
|
||||||
|
}
|
||||||
|
if (u.role !== 'parent') throw error(403, 'Forbidden');
|
||||||
|
const pb = createPbClient(event.locals.pbToken);
|
||||||
|
const kids: any[] = await pb
|
||||||
|
.collection('users')
|
||||||
|
.getFullList({ filter: `famId = '${u.famId}' && role = 'child'` })
|
||||||
|
.catch(() => []);
|
||||||
|
const children = await Promise.all(
|
||||||
|
kids.map(async (k: any) => ({
|
||||||
|
userId: k.id,
|
||||||
|
name: k.name || '?',
|
||||||
|
hasPin: await hasPin(k.id)
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
return json({ children });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Child PIN management. Session-role checked here (PB rules are superuser-only
|
||||||
|
// on `pins`): only the child themselves can set/change their own PIN.
|
||||||
|
export async function POST(event: RequestEvent) {
|
||||||
|
const u = event.locals.user;
|
||||||
|
if (!u) throw error(401, 'Unauthorized');
|
||||||
|
if (u.role !== 'child') throw error(403, 'Only children use PINs');
|
||||||
|
|
||||||
|
const body = await event.request.json().catch(() => ({}));
|
||||||
|
const { action, pin, currentPin } = body as {
|
||||||
|
action?: string;
|
||||||
|
pin?: string;
|
||||||
|
currentPin?: string;
|
||||||
|
};
|
||||||
|
if (!action || !pin || !PIN_RE.test(pin)) {
|
||||||
|
throw error(400, 'PIN must be exactly 3 digits');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'set') {
|
||||||
|
if (await getPin(u.id)) throw error(400, 'PIN already set');
|
||||||
|
await setPin(u.famId, u.id, pin);
|
||||||
|
return json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Join wizard: assign the PIN on THIS device without touching an existing
|
||||||
|
// one (a kid joining a second shared device already has a PIN — their pin
|
||||||
|
// works everywhere; nobody can silently reassign it).
|
||||||
|
if (action === 'ensure') {
|
||||||
|
if (!(await getPin(u.id))) await setPin(u.famId, u.id, pin);
|
||||||
|
return json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'change') {
|
||||||
|
const existing = await getPin(u.id);
|
||||||
|
if (!existing) throw error(400, 'No PIN set yet');
|
||||||
|
if (!currentPin || !(await verifyPin(u.id, currentPin))) {
|
||||||
|
throw error(401, 'Current PIN is wrong');
|
||||||
|
}
|
||||||
|
await setPin(u.famId, u.id, pin);
|
||||||
|
return json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error(400, 'Unknown action');
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import type { RequestEvent } from '@sveltejs/kit';
|
||||||
|
import { createPbClient, createSuperClient } from '$lib/server/pocketbase';
|
||||||
|
import { childSessionCookie, setChildSessionCookie, setActiveChild } from '$lib/server/session';
|
||||||
|
import { verifyPin } from '$lib/server/pins';
|
||||||
|
import { derivePassword } from '$lib/server/member-otp';
|
||||||
|
import { famUsername } from '@shared/slugify';
|
||||||
|
|
||||||
|
// Shared-device PIN switch. The PIN *selects* among sessions that already
|
||||||
|
// exist on this device — it is not a credential that mints anything on a
|
||||||
|
// fresh device. The target must have a pb_token_<userId> cookie; the PIN is
|
||||||
|
// verified server-side via the superuser client (children must never read
|
||||||
|
// siblings' pins), with a small per-user rate limit on the 3-digit space.
|
||||||
|
|
||||||
|
const MAX_TRIES = 5;
|
||||||
|
const LOCK_MS = 30_000;
|
||||||
|
const tries = new Map<string, { fails: number; until: number }>();
|
||||||
|
|
||||||
|
function checkRateLimit(userId: string) {
|
||||||
|
const rec = tries.get(userId);
|
||||||
|
if (rec && rec.until > Date.now()) {
|
||||||
|
throw error(429, 'Too many attempts — try again in a few seconds');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function noteFailure(userId: string) {
|
||||||
|
const rec = tries.get(userId) || { fails: 0, until: 0 };
|
||||||
|
rec.fails += 1;
|
||||||
|
if (rec.fails >= MAX_TRIES) {
|
||||||
|
rec.until = Date.now() + LOCK_MS;
|
||||||
|
rec.fails = 0;
|
||||||
|
}
|
||||||
|
tries.set(userId, rec);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(event: RequestEvent) {
|
||||||
|
const body = await event.request.json().catch(() => ({}));
|
||||||
|
const { userId, pin } = body as { userId?: string; pin?: string };
|
||||||
|
if (!userId || !pin) throw error(400, 'Missing userId or pin');
|
||||||
|
if (!event.cookies.get(childSessionCookie(userId))) {
|
||||||
|
throw error(400, 'That profile has no session on this device');
|
||||||
|
}
|
||||||
|
|
||||||
|
checkRateLimit(userId);
|
||||||
|
if (!(await verifyPin(userId, String(pin)))) {
|
||||||
|
noteFailure(userId);
|
||||||
|
throw error(401, 'Wrong PIN — try again');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure a usable token: refresh the device cookie; if it has expired,
|
||||||
|
// re-mint via the derived password (server-side only — the child never
|
||||||
|
// knows it, and a stale session heals itself on switch).
|
||||||
|
const pb = await createSuperClient();
|
||||||
|
const user = await pb
|
||||||
|
.collection('users')
|
||||||
|
.getOne(userId)
|
||||||
|
.catch(() => null);
|
||||||
|
if (!user || user.role !== 'child') throw error(400, 'Not a child account');
|
||||||
|
|
||||||
|
let freshToken = '';
|
||||||
|
try {
|
||||||
|
const cookieToken = event.cookies.get(childSessionCookie(userId));
|
||||||
|
if (cookieToken) {
|
||||||
|
const { token } = await createPbClient(cookieToken).collection('users').authRefresh();
|
||||||
|
freshToken = token;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* expired — re-mint below */
|
||||||
|
}
|
||||||
|
if (!freshToken) {
|
||||||
|
// username = `{famSlug}:{handle}` — the server can always re-mint.
|
||||||
|
const [famSlug, handleName] = (user.username || '').split(':');
|
||||||
|
if (!famSlug || !handleName) throw error(400, 'Cannot restore session');
|
||||||
|
const auth = createPbClient();
|
||||||
|
const { token } = await auth
|
||||||
|
.collection('users')
|
||||||
|
.authWithPassword(famUsername(famSlug, handleName), derivePassword(famSlug, handleName));
|
||||||
|
freshToken = token;
|
||||||
|
}
|
||||||
|
|
||||||
|
setChildSessionCookie(event.cookies, userId, freshToken);
|
||||||
|
setActiveChild(event.cookies, userId);
|
||||||
|
return json({ ok: true });
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user