feature plus ux rearrangements

This commit is contained in:
JCEEE
2026-08-22 19:49:46 +01:00
parent f14f4e2ac1
commit 42b9a27ce6
44 changed files with 2020 additions and 538 deletions
+66
View File
@@ -260,3 +260,69 @@
- **Username convention (composite + handle):** PB `users.username` is the composite `{famSlug}:{handle}` for BOTH parents and children — globally unique (PB auth-identity needs a single-column unique index) even though the URL segment is per-family. `handle(name)` = lowercase, strips all non-`[a-z0-9]` (`"Jakey Boy"` → `jakeyboy`); `slugify()` (hyphenated) is kept only for fam slugs. URL segment = `handleOf(username)` (part after the last `:`) → `/{famSlug}/{handle}`. `name` keeps the raw display name, read from DB via `authRefresh` (not plucked into the cookie). Parent's handle captured at signup step 1 (`yourName`) → `username = famUsername(famSlug, handle(yourName))`; parents authenticate email+password and land on the fam dashboard `/{famSlug}` (not username-routed). Children authenticate via OTP → `authWithPassword(famUsername(...), derivePassword(famSlug, handle))`. Redirects in `login/+page.server.ts`, `[fam]/[username]/+page.server.ts` (parent + child branches) and `preferences/+page.server.ts` use `session.username` (the handle). Member-list URLs in `settings`, `[fam]/+page.svelte`, `[fam]/[username]/+page.svelte` build `/{famSlug}/{handleOf(m.username)}`. `handle`/`handleOf`/`famUsername` live in `shared/slugify.ts` (`@shared/slugify`).
- **Restored intended multi-step signup** (from the guide, adapted to current OTP model): `/signup` steps — (1) `?/signup` familyName/yourName/email/password → create fam + parent (name=yourName, username=famUsername(famSlug, handle(yourName))) + settings, set `pb_token`; (2) `?/child` optional child → `issueAccess` returns `{ code, joinUrl }`; (3) show OTP join code + "Go to dashboard" link. Uses named actions + `use:enhance` (callback typed `any` to avoid the pre-existing canary `$types` SubmitFunction error).
- **Typecheck:** frontend `svelte-check` stays at 20 pre-existing errors (no new in edited files).
### 2026-08-21 — Stripe embedded checkout live + access-code gating (`fams.paymentMode`)
- **Embedded Checkout fixes** (`frontend/src/lib/server/stripe.ts`): `ui_mode: 'embedded'` → `'embedded_page'` (Stripe deprecated `'embedded'`); removed `customer_creation: 'always'` — only valid in `payment` mode; subscription mode auto-creates the customer from `customer_email`. Client side already used `createEmbeddedCheckoutPage({ clientSecret })`.
- **Secure-context gotcha:** embedded Checkout requires HTTPS or localhost. Dev host is reached over Tailscale IP via plain HTTP → checkout hangs silently (the `muid/guid/sid` JSON from `m.stripe.com` is Radar device fingerprinting, not an error; Stripe CLI websocket errors are benign). Fix: SSH port-forward `ssh -L 2080:localhost:2080` and use `http://localhost:2080`. Webhook listener is now a root script: `pnpm stripe:listen` (= `stripe listen -e customer.subscription.updated,customer.subscription.deleted,checkout.session.completed --forward-to http://127.0.0.1:2080/account/webhook`).
- **Gating model:** the platform is gated. `fams.paymentMode` = `none | code | sub | canceled`; `fams.active` (bool) is the derived "usable now" flag, re-persisted by `ensureFamAccess()` when it drifts. New superuser-only `accesscodes` collection (all rules null like `otp`, so it lives in `migrate.ts` not `SCHEMA_PLAN`): `value` (unique, required), `name`, `duration` (months from entry date; 0=continuous), `expiry` (months after the code's own `createdAt`; 0=never), `active` failsafe, `createdAt`. Idempotently seeded with `dev123` / developer / 0 / 0.
- **Core module** `frontend/src/lib/server/access.ts`: `addMonthsUTC`, `codeIsValid`, `computeFamAccess` (mode → `{disabled, reason}`; reasons `no_access|code_expired|code_disabled|subscription_inactive|canceled`), `ensureFamAccess(famId)` (reads fam+code, persists drifted `active`, returns `{fam, access}`), `applyAccessCode(famId, value)` (sets mode=code + accessCodeId + accessCodeEnteredAt). Wired into `[fam]/+layout.server.ts` load → `data.famAccess` (both roles).
- **UI gating:** `[fam]/+layout.svelte` blurs `.page-content.locked` behind a non-blocking overlay card + admin TopNav announcement (sidebar/chat stay usable). Member kanban gate is **frontend-only by decision**: `[fam]/[username]/+page.svelte` derives `accessDisabled` from `page.data.famAccess?.disabled`, early-returns in `toggle()`, and renders three empty locked columns instead of the board. Signup takes an optional code (blank → gated fam; invalid → 400); settings has an Access card (`?/applyCode`). Webhooks maintain `paymentMode`: checkout completed / subscription sync → `sub`; subscription deleted → `canceled`.
- **Bug found while verifying:** the live `fams` collection was missing the `active` bool entirely (schema.ts declared it; this PB predated it) → `active` writes were silently dropped. Fixed by adding it to `ensureFamFields()` (idempotent; runs outside `ensureSchema`'s early-return alongside `ensureAccessCodes`) and patching the live collection. Verified end-to-end against PB: fam with valid `dev123` → `active=true`; fam with empty mode → `active=false` (gated).
- **PB curl gotcha:** single-record endpoints are `/api/collections/{name}/records/{id}` — omitting `/records/` returns PB's `"File not found."` 404 which masquerades as a missing record. The JS SDK always builds the correct path (an earlier "fams by-id 404" scare was a bad curl URL, not an app bug).
### 2026-08-22 — Routes reshuffle: `[famSlug]`→`[fam]` merge, `/pricing` public, signup wizard with inline checkout
- **Join route merged:** `[famSlug]/join/{username}` → `[fam]/join/{username}` (same URL shape, single `fam` param). Fixed `params.famSlug`→`params.fam` in join page files.
- **Public pricing page:** `/subscriptions` → `/pricing` (untracked dir renamed). New `PricingPlans.svelte` component (reusable tier cards; props: `action`, `hideTrial`, `selected`, `error`, `onsubmit` handler). `/pricing` is public: logged-out "Choose monthly" → redirect `/signup?plan=monthly`; logged-in → existing embedded checkout.
- **Signup wizard rewritten** as state machine (`fam → child → code → plan → done`):
- Step 1 (fam): family + parent creation (access code field REMOVED from here)
- Step 2 (child): add child or skip (unchanged)
- Step 3 (code): "Have an access code?" Apply (→ done) or Skip (→ plan)
- Step 4 (plan): `PricingPlans` embedded (`hideTrial=true`); selecting mounts embedded checkout INLINE (user authenticated); `?plan=X` from /pricing pre-highlights tier
- Step 5 (done): "Go to dashboard" — webhook flips `paymentMode=sub`, overlay lifts
- Server actions: `signup` (no code), `child` (unchanged), `access` (reuses `applyAccessCode`), `choose` (embedded checkout session)
- **Webhook moved** to `/api/webhooks/stripe` (machine-to-machine endpoint belongs in `/api/*` namespace). `pnpm stripe:listen` forward URL updated.
- **Links updated:** account "Change plan", settings "Plans", `stripe.ts` cancel_url → `/pricing`.
- **Docs updated:** AGENTS.md routes, ARCHITECTURE.md (routes, Stripe flow, architecture diagram, project structure), MEMORY.md this entry.
### 2026-08-21 — Platform feature flags (`platform` collection) + debug-gated revoke CTA
- **`fams.featureFlags` deprecated** (removed from SCHEMA_PLAN, `Fam` type, live PB; field dropped). Replaced by a global **`platform`** collection: single record `label='global'`, json `flags`. Rules: list/view = `""` (public read — the one rule shape `col()` CAN express), create/update/delete = null (superuser-only) → created in `migrate.ts` (`ensurePlatform` + idempotent `seedPlatform`, like otp/accesscodes).
- **Public load:** new root `frontend/src/routes/+layout.server.ts` exposes `page.data.platformFlags` on every page via `getPlatformFlags()` (`lib/server/platform.ts`, 10s TTL cache; `setPlatformFlag` for superuser writes).
- **`debug` flag gates dev-only UI**: settings "Revoke code" CTA (`?/revokeCode`) — clears an applied code (paymentMode→none, accessCodeId/EnteredAt→'', active=false, fam re-gates). Server action checks the flag itself (hidden CTA is not the boundary). Settings' old per-fam `featureFlags.debugMode` Debug Tools card now keys off `page.data.platformFlags.debug`.
- **`/admin` Platform Flags card** replaces the per-fam Debug column: `?/togglePlatformFlag` toggles any flag on the global record. Dev PB seeded with `debug: true`.
- Also: `[fam]/+layout.svelte` exempts `/settings` from the paused blur overlay (admins can apply a code while gated) and `disabled`/`accessReason` are `$derived` so applying/revoking updates the overlay without a refresh.
### 2026-08-22 — Settings reorg: Accordion groups, paymentMode-only billing, notices system
- **Settings grouped into 4 Accordions** (Family / App / Invites / Billing). `Accordion.svelte` rewritten as a styled snippet wrapper + new self-contained `AccordionItem` (own open state, `$bindable`, `{@render children()}`) — no items-array API.
- **Gating model simplified (user decision):** `fams.paymentMode` alone drives the FE (`none` = gated/paused; `code` valid = active; `sub` follows webhooks; `canceled` = gated). No `paused` field added; the local pause toggle was removed entirely. `fams.active` remains an internal derived flag maintained by `ensureFamAccess`/webhooks only.
- **Access card:** shows countdown from `accessCodeEnteredAt` + code `duration` months (days when <1 month, "never expires" when duration=0) — settings load now fetches the `accesscodes` record (`data.accessCode`). Once a code is applied the input/Apply are hidden and **Revoke is always visible** (debug-flag requirement dropped); revoke just sets `paymentMode:'none'` + clears code fields (fam-scoped only — global code management is a platform-admin concern).
- **Billing card** replaces `/account` (route deleted): sub → Change plan (/pricing) + Open billing portal (Stripe Customer Portal; dummy mode opens returned URL); code → Switch to subscription; none/canceled → Choose plan. Portal + checkout both return to `/{fam}?checkout=return`.
- **Checkout-return welcome notice:** `[fam]/+page.svelte` `$effect` watches `?checkout=return` → fires a success notice via the new **notices store** (`lib/stores/notices.ts`: typed add/success/info/warning/error + auto-dismiss helper) rendered by global `<NoticeDialog />` in the root layout; query param scrubbed via `history.replaceState` so refresh doesn't re-fire.
- Gotchas fixed along the way: duplicate NoticeDialog export; Svelte 5 forbids `class:` directives on components unless declared (Card got `selected` prop instead); second `<script>` block in a component is invalid.
### 2026-08-22 — famSlug single source of truth + notices store to runes
- **famSlug convention:** the URL param surfaced by `[fam]/+layout.server.ts` as top-level `data.famSlug` is canonical. Client code reads `page.data.famSlug` (fallback `?? page.params.fam` acceptable); server loads/actions under `[fam]` read `event.params.fam`. **Never copy it into local `$state`** — settings had a frozen-snapshot bug doing exactly that (now `$derived(page.data.famSlug ...)`). Nested `session.famSlug` removed (no consumers). Only exception with no URL param: signup `child` action resolves via DB (`fam?.slug || famId`) with a comment.
- Sweep results: removed dead/mislabeled `const famId = $derived(page.params.fam)` in bonuses page; zero `params.famSlug` references remain post `[famSlug]→[fam]` merge.
- **Notices store converted to Svelte 5 runes**: `lib/stores/notices.svelte.ts` (class with `$state<Notice[]>` list, add/remove/clear/success/info/warning/error + `addAutoDismissNotice`). Consumers: `notices.list` in `NoticeDialog.svelte`; no more svelte-store `writable`/`$notices` auto-subscription.
### 2026-08-22 — famSlug single source of truth + docs Hono purge
- **famSlug convention:** `[fam]/+layout.server.ts` returns top-level `data.famSlug` (from the URL param) — canonical. Client: `page.data.famSlug`; server under `[fam]`: `event.params.fam`. Never copy into local `$state` (settings had that frozen-snapshot bug). Nested `session.famSlug` removed; signup `child` action is the only DB-fallback case. Fixed platform-admin links pointing at nonexistent `/[slug]/admin`.
- **Docs:** purged all stale Hono-proxy references from AGENTS.md + ARCHITECTURE.md (proxy deleted 2026-08-17); data-flow sections now describe SvelteKit services/`/api/*` routes. Remaining "Hono" mentions are struck-through historical build phases.
### 2026-08-22 — Trial codes in PB, pause=cancel decision, settings reorder
- **"Pause" = cancel (decision):** no separate pause concept. Pausing a plan means cancelling the card subscription via the billing portal; data is kept, resubscribing restores access (`paymentMode` webhook-driven). Copy lives in settings Account card.
- **Trial codes now PB-backed:** `accesscodes.trialDays` (number). `resolveTrialDays()` (`stripe.ts`) is async — queries `accesscodes` for an active record with `value` match and `trialDays > 0`; static `TRIAL_CODES` map deleted. Seeded: `FAM3MONTHS` = 90 days. `ensureAccessCodeFields()` hardens existing installs; seeds unified in `seedAccessCodes()`.
- **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.
### 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.
- Mechanics: `pollToken` guards against stale loops; `history.replaceState` scrubs the query cosmetically + `returnHandled` flag prevents double-handling. Webhook remains the sole source of truth for `paymentMode`/`active`.
- **Upgrade (same day): activation is event-driven, not polled.** `startActivating` subscribes the browser PB client to its own `fams` record (`pb.collection('fams').subscribe(famId)` — allowed by viewRule `id = @request.auth.famId`). Webhook (superuser) writes → PB SSE push → single `invalidateAll()`; unlock `$effect` stops the subscription + fires success. 20s timer kept purely as a degrade-gracefully fallback. Rejected: onComplete-as-source-of-truth (untrusted); optional future hardening = server-side session verification on return.