feature plus ux rearrangements
This commit is contained in:
+1
-1
@@ -28,4 +28,4 @@ PUBLIC_STRIPE_PUBLISHABLE_KEY=
|
|||||||
|
|
||||||
STRIPE_PRICE_TRIAL=
|
STRIPE_PRICE_TRIAL=
|
||||||
|
|
||||||
# Dev-only: gate for /account/webhook/simulate. Leave unset in prod.
|
# Dev-only: gate for /api/webhooks/stripe/simulate. Leave unset in prod.
|
||||||
|
|||||||
@@ -10,9 +10,10 @@
|
|||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
|
|
||||||
- SvelteKit (SSR frontend, internal :2080) + Hono proxy (internal :3456) + nginx (container :3001)
|
- SvelteKit monolith (SSR frontend + all services, internal :2080; nginx in prod container :3001). The Hono proxy was deleted — everything lives in SvelteKit server routes/services.
|
||||||
- PocketBase (separate Coolify service at `pb.chores.app.com`, :8090)
|
- PocketBase (separate Coolify service at `pb.chores.app.com`, :8090)
|
||||||
- Stripe payments (subscriptions) — **planned in SvelteKit server routes** (`/account` + `/account/webhook`), NOT the Hono proxy. Not yet implemented (only `settings.webhookUrl` + `fams.stripeCustomerId` exist).
|
- Stripe payments (subscriptions) — implemented in **SvelteKit server routes** (public `/pricing` + inline signup checkout via `PricingPlans`, billing portal from settings Billing section, `/api/webhooks/stripe`). Dev webhook listener: `pnpm stripe:listen` (root script). Embedded Checkout needs a secure context (HTTPS/localhost) — over Tailscale/LAN HTTP use `ssh -L 2080:localhost:2080`.
|
||||||
|
- Access gating — `fams.paymentMode` (`none|code|sub|canceled`) + `fams.active`; codes in superuser-only `accesscodes` (seeded `dev123`). Core logic in `frontend/src/lib/server/access.ts`, exposed as `data.famAccess` from `[fam]/+layout.server.ts`; disabled fams get a blurred overlay + locked member kanban (frontend-only); `/settings` stays unlocked so admins can apply a code.
|
||||||
- Coolify CRON → `GET /api/weekly-cron` — **not implemented** (weekly settlement is manual via `complete-week`/`simulateEow`)
|
- Coolify CRON → `GET /api/weekly-cron` — **not implemented** (weekly settlement is manual via `complete-week`/`simulateEow`)
|
||||||
- Deployment: Coolify, Cloudflare DNS
|
- Deployment: Coolify, Cloudflare DNS
|
||||||
|
|
||||||
@@ -34,7 +35,9 @@
|
|||||||
|
|
||||||
- `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`)
|
||||||
- `fams` — name, slug, stripeCustomerId, featureFlags
|
- `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").
|
||||||
|
- `fams` — name, slug, stripeCustomerId, paymentMode (`none|code|sub|canceled`), active, accessCodeId, accessCodeEnteredAt
|
||||||
- `chore_templates` — famId, name, defaultValue, defaultFrequency
|
- `chore_templates` — famId, name, defaultValue, defaultFrequency
|
||||||
- `assigned_chores` — famId, userId, templateId, frequency, value
|
- `assigned_chores` — famId, userId, templateId, frequency, value
|
||||||
- `completions` — famId, userId, assignedChoreId, date
|
- `completions` — famId, userId, assignedChoreId, date
|
||||||
@@ -51,8 +54,8 @@
|
|||||||
/ Landing (SaaS marketing)
|
/ Landing (SaaS marketing)
|
||||||
/admin Platform super-admin stats dashboard (and any donations)
|
/admin Platform super-admin stats dashboard (and any donations)
|
||||||
/login · /logout Parent email/password login / logout
|
/login · /logout Parent email/password login / logout
|
||||||
/signup Parent + family signup
|
/signup Parent + family signup (wizard: fam → child → code → plan)
|
||||||
/{famSlug}/join/{username} Member invite (OTP join), auto-fills from ?code=
|
/{fam}/join/{username} Member invite (OTP join), auto-fills from ?code=
|
||||||
/{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
|
||||||
@@ -60,10 +63,9 @@
|
|||||||
/{fam}/{username}/bonuses Bonus configs & evaluation
|
/{fam}/{username}/bonuses Bonus configs & evaluation
|
||||||
/{fam}/{username}/preferences User preferences (parent→users, member→users)
|
/{fam}/{username}/preferences User preferences (parent→users, member→users)
|
||||||
/{fam}/{username}/settings Family admin settings (parent only) — includes Stripe connect/manage + pause
|
/{fam}/{username}/settings Family admin settings (parent only) — includes Stripe connect/manage + pause
|
||||||
/account Account/billing — payment setup & subscription management (Stripe)
|
/pricing 3-tier public plan page (trial | monthly | yearly), entry via settings or logged-out
|
||||||
/subscriptions 3-tier plan page (trial | monthly | yearly), access via settings
|
/api/webhooks/stripe Stripe webhook handler (server route)
|
||||||
/account/webhook Stripe webhook handler (server route)
|
/api/* SvelteKit API endpoints (data layer; CRON not implemented)
|
||||||
/api/* Hono proxy (data layer; CRON not implemented)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Data Flow
|
## Data Flow
|
||||||
@@ -76,12 +78,12 @@
|
|||||||
|
|
||||||
### Writes
|
### Writes
|
||||||
|
|
||||||
- **Chore toggle:** Browser → Hono proxy → PB (member auth via `Authorization: Bearer <pb_token>`)
|
- **Chore toggle:** Browser → SvelteKit `/api/completions/toggle` → PB (session cookie auth)
|
||||||
- **Admin CRUD:** Form actions / `hono.admin.*` → Hono proxy → PB (admin JWT via `sessionHeaders`)
|
- **Admin CRUD:** Form actions / `/api/admin/*` endpoints → PB via services (`servicesFor(event)`); PB collection rules are the security boundary
|
||||||
- **Member updates:** Browser → Hono proxy → PB (auth via `Bearer <pb_token>`)
|
- **Member updates:** Browser → SvelteKit `/api/*` routes → PB
|
||||||
- **Reward creation:** After completion toggle, Hono proxy creates reward if threshold met
|
- **Reward creation:** After completion toggle, service layer creates reward if threshold met
|
||||||
- **Weekly settlement:** NOT via CRON — manual `complete-week` action or `simulateEow` preview in settings. `/api/weekly-cron` (Coolify) is not implemented.
|
- **Weekly settlement:** NOT via CRON — manual `complete-week` action or `simulateEow` preview in settings. `/api/weekly-cron` (Coolify) is not implemented.
|
||||||
- **Stripe:** implemented in SvelteKit server routes — `/account` (setup/manage subscription) + `/account/webhook`. Lives in the frontend app, NOT the Hono proxy. **WhatsApp:** not implemented.
|
- **Stripe:** implemented in SvelteKit server routes — `/pricing` (public plan picker; logged-in users checkout inline) + settings Billing section (billing portal) + `/api/webhooks/stripe`. **WhatsApp:** not implemented.
|
||||||
|
|
||||||
### UI reactivity
|
### UI reactivity
|
||||||
|
|
||||||
@@ -203,20 +205,19 @@ All admin and member pages use the following pattern:
|
|||||||
- Every collection query includes `famId = @request.auth.famId` filter
|
- Every collection query includes `famId = @request.auth.famId` filter
|
||||||
- Super admin bypasses famId filter (access via PB admin API)
|
- Super admin bypasses famId filter (access via PB admin API)
|
||||||
- Child PB passwords are derived (`MEMBER_SECRET + famSlug + username`); the child join gate is a transient OTP in `otp`. No device tokens. Never log raw tokens/secrets.
|
- Child PB passwords are derived (`MEMBER_SECRET + famSlug + username`); the child join gate is a transient OTP in `otp`. No device tokens. Never log raw tokens/secrets.
|
||||||
- **Admin → Proxy**: `hono.admin.*` in `$lib/server/hono.ts` — uses `sessionHeaders(event)` (server-side only, requires `RequestEvent`)
|
- **Server data access**: `servicesFor(event)` / `createServices(pb)` in `$lib/server/services/`; superuser ops via `pbAdmin` facade (`$lib/server/pocketbase.ts`)
|
||||||
- **Member → Proxy (server)**: `memberApi.*` in `$lib/client/api.ts` — use inside `+page.server.ts` load/actions; `BASE_URL` resolves to Hono port on server
|
- **Browser data access**: fetch to same-origin `/api/*` SvelteKit endpoints; httpOnly `pb_token` cookie is the auth
|
||||||
- **Member → Proxy (browser)**: `memberApi.*` in `$lib/client/api.ts` — use inside `+page.svelte`; `BASE_URL` is empty, Vite proxies `/api/*` to Hono
|
|
||||||
- **`$page`**: import `{ page }` from `$app/state` (NOT `$app/stores` — that's the old Svelte 4 API). Reference as `page.params.fam`, `page.url.pathname` etc. without `$` prefix
|
- **`$page`**: import `{ page }` from `$app/state` (NOT `$app/stores` — that's the old Svelte 4 API). Reference as `page.params.fam`, `page.url.pathname` etc. without `$` prefix
|
||||||
- **Dates**: all user-facing dates are DDMMYY (compact, e.g. `040826` for 4 Aug 2026). Use the shared `formatDDMMYY()` helper in `frontend/src/lib/format.ts`. Never render raw `YYYY-MM-DD` to users. Exception: single human-readable dates like todo **due dates** should use `formatShortDate()` (also in `format.ts`, renders `5 Aug` / `5 Aug 26`) — the compact DDMMYY code is ambiguous and bad UI for those.
|
- **Dates**: all user-facing dates are DDMMYY (compact, e.g. `040826` for 4 Aug 2026). Use the shared `formatDDMMYY()` helper in `frontend/src/lib/format.ts`. Never render raw `YYYY-MM-DD` to users. Exception: single human-readable dates like todo **due dates** should use `formatShortDate()` (also in `format.ts`, renders `5 Aug` / `5 Aug 26`) — the compact DDMMYY code is ambiguous and bad UI for those.
|
||||||
- `config.ts` at root for dev/build-time shared config (e.g. `PROXY_PORT`); runtime config via env vars
|
- `config.ts` at root for dev/build-time shared config (e.g. `PROXY_PORT`); runtime config via env vars
|
||||||
- `.env` at root tracks port values (`PROXY_PORT`, `PORT`); `.env.example` committed as template
|
- `.env` at root tracks port values (`PROXY_PORT`, `PORT`); `.env.example` committed as template
|
||||||
- Docker: `docker/Dockerfile` (prod, multi-stage + nginx) + `docker/Dockerfile.dev` (PocketBase)
|
- Docker: `docker/Dockerfile` (prod, multi-stage + nginx) + `docker/Dockerfile.dev` (PocketBase)
|
||||||
- Nginx routes in prod: `/api/*` → Hono (`:3456`), `/*` → SvelteKit (`:2080`)
|
- Nginx routes in prod: `/*` → SvelteKit (`:2080`), `/pb/*` → PocketBase
|
||||||
- Ports: frontend `2080`, proxy `3456`, container ext `3001` (port `3000` is reserved)
|
- Ports: frontend `2080`, proxy `3456`, container ext `3001` (port `3000` is reserved)
|
||||||
- **Dev servers: NEVER start your own.** Always reuse the running dev servers — proxy `192.168.1.225:3456` (tsx watch, reloads on edit), frontend `localhost:2080` (vite HMR). Don't spawn `nohup pnpm dev` / `tsx watch` / extra vite instances. Only restart when the user explicitly asks.
|
- **Dev servers: NEVER start your own.** Always reuse the running dev servers — proxy `192.168.1.225:3456` (tsx watch, reloads on edit), frontend `localhost:2080` (vite HMR). Don't spawn `nohup pnpm dev` / `tsx watch` / extra vite instances. Only restart when the user explicitly asks.
|
||||||
- Environment: `FRONTEND_PORT`, `PROXY_PORT`, `PB_PORT`, `PB_EMAIL`, `PB_PASSWORD`, `DEBUG_RECORD_ID`, `STRIPE_SECRET_KEY`, `DONATION_MODAL_INTERVAL`
|
- Environment: `FRONTEND_PORT`, `PROXY_PORT`, `PB_PORT`, `PB_EMAIL`, `PB_PASSWORD`, `DEBUG_RECORD_ID`, `STRIPE_SECRET_KEY`, `DONATION_MODAL_INTERVAL`
|
||||||
- Seed via JSON dump (portable for dev)
|
- Seed via JSON dump (portable for dev)
|
||||||
- Monorepo: SvelteKit in `frontend/`, Hono in `proxy/`, two Dockerfiles
|
- Monorepo: SvelteKit in `frontend/` (+ root `shared/`), single app Dockerfile + PB Dockerfile.dev
|
||||||
- Decisions tracked in `MEMORY.md`
|
- Decisions tracked in `MEMORY.md`
|
||||||
|
|
||||||
## Build Phases (must validate each before next)
|
## Build Phases (must validate each before next)
|
||||||
@@ -247,7 +248,7 @@ All admin and member pages use the following pattern:
|
|||||||
3.5 Reward claim flow + admin CRUD
|
3.5 Reward claim flow + admin CRUD
|
||||||
3.6 Monthly bonus evaluation
|
3.6 Monthly bonus evaluation
|
||||||
3.7 CRON handler (Coolify → Hono)
|
3.7 CRON handler (Coolify → Hono)
|
||||||
3.8 Stripe checkout + webhook (SvelteKit `/account` server routes, not Hono)
|
3.8 Stripe checkout + webhook (SvelteKit server routes, not Hono)
|
||||||
3.9 Notification interface (WhatsApp deferred)
|
3.9 Notification interface (WhatsApp deferred)
|
||||||
|
|
||||||
### Phase 4 — Frontend App
|
### Phase 4 — Frontend App
|
||||||
|
|||||||
+97
-55
@@ -12,23 +12,22 @@ Multi-tenant chore tracking SaaS. Families ("fams") are isolated tenant groups.
|
|||||||
|
|
||||||
| Component | Role | Deploy | Port |
|
| Component | Role | Deploy | Port |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| SvelteKit | SSR frontend, all UI + Stripe server routes | Coolify Docker (nginx) | :2080 internal, :3001 external |
|
| SvelteKit | SSR frontend + all services + Stripe server routes (`/api/*`) | Coolify Docker (nginx) | :2080 internal, :3001 external |
|
||||||
| Hono proxy | data layer (`/api/*`); CRON | Same container, proxied via nginx `/api/*` | :3456 internal |
|
|
||||||
| PocketBase | DB, auth, realtime, storage, Admin UI | Coolify service (pb.chores.app.com) | :8090 |
|
| PocketBase | DB, auth, realtime, storage, Admin UI | Coolify service (pb.chores.app.com) | :8090 |
|
||||||
| Stripe | subscriptions (in SvelteKit, NOT Hono) | — | — |
|
| Stripe | subscriptions (SvelteKit server routes) | — | — |
|
||||||
|
|
||||||
### Deployment Topology
|
### Deployment Topology
|
||||||
|
|
||||||
```
|
```
|
||||||
chores.app.com ────┬──► nginx (:3001)
|
chores.app.com ────┬──► nginx (:3001)
|
||||||
│ ├── /* ──► SvelteKit (:2080)
|
│ ├── /* ──► SvelteKit (:2080)
|
||||||
│ └── /api/* ──► Hono proxy (:3456)
|
│ └── /api/* ──► SvelteKit (:2080)
|
||||||
│
|
│
|
||||||
pb.chores.app.com ──► PocketBase (:8090)
|
pb.chores.app.com ──► PocketBase (:8090)
|
||||||
│ Admin UI at /_
|
│ Admin UI at /_
|
||||||
│ Volume: /pb_data (persistence + backups)
|
│ Volume: /pb_data (persistence + backups)
|
||||||
│
|
│
|
||||||
stripe.com ─────────► SvelteKit /account/webhook
|
stripe.com ─────────► SvelteKit /api/webhooks/stripe
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -60,7 +59,9 @@ Every tenant-scoped collection has `famId` and enforces `famId = @request.auth.f
|
|||||||
|
|
||||||
- `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 on `users.color`)
|
- `otp` — famId, userId, otp, updatedAt (OTP gate for child join; display colour on `users.color`)
|
||||||
- `fams` — name, slug, stripeCustomerId, featureFlags
|
- `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`; toggled from the `/admin` Platform Flags card (`debug` gates dev-only CTAs like settings "Revoke code"). Replaces the deprecated per-fam `fams.featureFlags`.
|
||||||
|
- `fams` — name, slug, stripeCustomerId, paymentMode (`none`|`code`|`sub`|`canceled`), active, accessCodeId, accessCodeEnteredAt
|
||||||
- `chore_templates` — famId, name, defaultValue, defaultFrequency
|
- `chore_templates` — famId, name, defaultValue, defaultFrequency
|
||||||
- `assigned_chores` — famId, userId, templateId, frequency, value
|
- `assigned_chores` — famId, userId, templateId, frequency, value
|
||||||
- `completions` — famId, userId, assignedChoreId, date
|
- `completions` — famId, userId, assignedChoreId, date
|
||||||
@@ -69,7 +70,7 @@ Every tenant-scoped collection has `famId` and enforces `famId = @request.auth.f
|
|||||||
- `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` bootstraps a fresh/wiped PB (idempotent). The native `users` auth fields/rules + superuser-only `otp` are applied in `migrate.ts` (`ensureUsers`/`ensureOtp`). Data is disposable — 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` bootstraps a fresh/wiped PB (idempotent). The native `users` auth fields/rules + the superuser-only `otp`/`accesscodes` and public-read `platform` collections are applied in `migrate.ts` (`ensureUsers`/`ensureOtp`/`ensureAccessCodes`/`ensurePlatform`); `ensureFamFields()` hardens existing installs with newer `fams` fields. Data is disposable — schema change = update `SCHEMA_PLAN` + wipe PB + reboot.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -81,8 +82,8 @@ Every tenant-scoped collection has `famId` and enforces `famId = @request.auth.f
|
|||||||
/ Landing page (SaaS marketing)
|
/ Landing page (SaaS marketing)
|
||||||
/admin Platform super-admin stats dashboard
|
/admin Platform super-admin stats dashboard
|
||||||
/login · /logout Parent login / logout
|
/login · /logout Parent login / logout
|
||||||
/signup Parent + family signup
|
/signup Parent + family signup (wizard: fam → child → code → plan)
|
||||||
/{famSlug}/join/{username} Member invite (OTP join), auto-fills from ?code=
|
/{fam}/join/{username} Member invite (OTP join), auto-fills from ?code=
|
||||||
/{fam} Fam dashboard
|
/{fam} Fam dashboard
|
||||||
/{fam}/{username} Parent → admin overview, Child → member kanban
|
/{fam}/{username} Parent → admin overview, Child → member kanban
|
||||||
/{fam}/{username}/chores Chore templates & assignment grid
|
/{fam}/{username}/chores Chore templates & assignment grid
|
||||||
@@ -90,10 +91,10 @@ Every tenant-scoped collection has `famId` and enforces `famId = @request.auth.f
|
|||||||
/{fam}/{username}/bonuses Bonus configs & evaluation
|
/{fam}/{username}/bonuses Bonus configs & evaluation
|
||||||
/{fam}/{username}/preferences User preferences
|
/{fam}/{username}/preferences User preferences
|
||||||
/{fam}/{username}/settings Family admin settings (parent only) — Stripe connect/manage + pause
|
/{fam}/{username}/settings Family admin settings (parent only) — Stripe connect/manage + pause
|
||||||
/account Account/billing — payment setup & subscription management
|
/settings (Billing group) Subscription status, change plan, open billing portal
|
||||||
/subscriptions 3-tier plan page (trial | monthly | yearly), access via settings
|
/pricing 3-tier public plan page (trial | monthly | yearly), entry via settings or logged-out
|
||||||
/account/webhook Stripe webhook handler (server route)
|
/api/webhooks/stripe Stripe webhook handler (server route)
|
||||||
/api/* Hono proxy (data layer; CRON not implemented)
|
/api/* SvelteKit API endpoints (data layer; CRON not implemented)
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -107,30 +108,36 @@ Every tenant-scoped collection has `famId` and enforces `famId = @request.auth.f
|
|||||||
|
|
||||||
### 5.2 Writes
|
### 5.2 Writes
|
||||||
|
|
||||||
- **Chore toggle:** Browser → Hono proxy → PB (member auth via `Authorization: Bearer <pb_token>`).
|
- **Chore toggle:** Browser → SvelteKit `/api/completions/toggle` → PB (session cookie auth).
|
||||||
- **Admin CRUD:** Form actions / `hono.admin.*` → Hono proxy → PB (admin JWT via `sessionHeaders`).
|
- **Admin CRUD:** Form actions / `/api/admin/*` endpoints → PB via services; PB collection rules are the security boundary.
|
||||||
- **Member updates:** Browser → Hono proxy → PB (`Bearer <pb_token>`).
|
- **Member updates:** Browser → SvelteKit `/api/*` routes → PB.
|
||||||
- **Reward creation:** after completion toggle, Hono proxy creates reward if threshold met.
|
- **Reward creation:** after completion toggle, service layer creates reward if threshold met.
|
||||||
- **Weekly settlement:** NOT via CRON — manual `complete-week` action or `simulateEow` preview in settings. `/api/weekly-cron` (Coolify) not implemented.
|
- **Weekly settlement:** NOT via CRON — manual `complete-week` action or `simulateEow` preview in settings. `/api/weekly-cron` (Coolify) not implemented.
|
||||||
- **Stripe:** SvelteKit server routes `/account` + `/account/webhook` (frontend app, NOT Hono).
|
- **Stripe:** SvelteKit server routes `/pricing` + settings Billing actions + `/api/webhooks/stripe`.
|
||||||
- **WhatsApp:** not implemented.
|
- **WhatsApp:** not implemented.
|
||||||
|
|
||||||
### 5.3 Stripe Subscription (embedded Checkout)
|
### 5.3 Stripe Subscription (embedded Checkout)
|
||||||
|
|
||||||
```
|
```
|
||||||
Parent picks a tier on /subscriptions (trial | monthly | yearly)
|
PUBLIC PRICING SIGNUP WIZARD AFTER
|
||||||
→ SvelteKit server action (subscriptions) creates Embedded Checkout Session
|
───────────── ───────────── ─────
|
||||||
createEmbeddedCheckoutSession() → ui_mode: "embedded" → client_secret
|
/pricing ── logged out ──► /signup?plan=X
|
||||||
→ returns { clientSecret } to the browser
|
└─ logged in ──► embedded checkout (existing behavior)
|
||||||
→ @stripe/stripe-js createEmbeddedCheckoutPage({ clientSecret }) mounts in-page
|
|
||||||
→ Parent completes payment inside the embedded Stripe page
|
1. fam create family + parent
|
||||||
→ Stripe sends checkout.session.completed → SvelteKit /account/webhook
|
2. child add child / skip
|
||||||
handleStripeEvent() → pbAdmin.update fams.stripeCustomerId + active = true
|
3. code "Have an access code?"
|
||||||
→ Subsequent customer.subscription.* webhooks keep fams.active in sync
|
├─ apply valid ──► 5. done (fam active)
|
||||||
→ Parent returns to /account?checkout=return
|
└─ skip ─────────► 4. plan
|
||||||
|
4. plan PricingPlans component
|
||||||
|
├─ ?plan=X pre-highlights that tier
|
||||||
|
├─ pick tier ──► embedded checkout mounts INLINE
|
||||||
|
└─ trial tier hidden (codes live at step 3)
|
||||||
|
5. done "Go to dashboard"
|
||||||
|
webhook sets paymentMode=sub → overlay lifts
|
||||||
```
|
```
|
||||||
|
|
||||||
Pause/stop via Stripe Customer Portal (from `/account`) or the pause toggle (writes `fams.active` directly).
|
Webhook events (`/api/webhooks/stripe`) update `fams.stripeCustomerId`, `fams.active`, `fams.paymentMode` from subscription lifecycle.
|
||||||
|
|
||||||
### 5.3.1 Payments architecture
|
### 5.3.1 Payments architecture
|
||||||
|
|
||||||
@@ -138,26 +145,27 @@ Pause/stop via Stripe Customer Portal (from `/account`) or the pause toggle (wri
|
|||||||
┌────────────────────────────────────────── SVELTEKIT APP ──────────────────────────────────────────┐
|
┌────────────────────────────────────────── SVELTEKIT APP ──────────────────────────────────────────┐
|
||||||
│ │
|
│ │
|
||||||
│ Browser │
|
│ Browser │
|
||||||
│ ┌──────────────────────────────┐ POST ?/checkout ┌─────────────────────────────────────────┐ │
|
│ ┌──────────────────────────────┐ POST ?/choose ┌─────────────────────────────────────────┐ │
|
||||||
│ │ /subscriptions (+page.svelte)│ ───────────────────► │ subscriptions/+page.server.ts (action) │ │
|
│ │ /pricing (+page.svelte) │ ───────────────────► │ pricing/+page.server.ts (action) │ │
|
||||||
│ │ • tier cards │ │ • resolves famId + parent email (PB) │ │
|
│ │ • PricingPlans component │ │ • logged-out: redirect /signup?plan=X │ │
|
||||||
│ │ • createEmbeddedCheckoutPage│ ◄─── clientSecret ─── │ • createEmbeddedCheckoutSession() │ │
|
│ │ • createEmbeddedCheckoutPage │ ◄─── clientSecret ─── │ • logged-in: createEmbeddedCheckout... │ │
|
||||||
│ │ • mounts embedded Stripe UI │ └───────────────┬─────────────────────────┘ │
|
│ │ • mounts embedded Stripe UI │ └───────────────┬─────────────────────────┘ │
|
||||||
│ └──────────────┬───────────────┘ │ stripe SDK (secret) │
|
│ └──────────────┬───────────────┘ │ stripe SDK (secret) │
|
||||||
│ │ createEmbeddedCheckoutPage(clientSecret) ▼ │
|
│ │ createEmbeddedCheckoutPage(clientSecret) ▼ │
|
||||||
│ ▼ ┌─────────────────────────────┐ │
|
│ ▼ ┌─────────────────────────────┐ │
|
||||||
│ ┌──────────────────────────────┐ │ STRIPE API │ │
|
│ ┌──────────────────────────────┐ │ STRIPE API │ │
|
||||||
│ │ Embedded Checkout (Stripe │ card + pay │ checkout.sessions.create │ │
|
│ │ Embedded Checkout (Stripe │ card + pay │ checkout.sessions.create │ │
|
||||||
│ │ hosted iframe, in-page) │ ───────────────────► │ (ui_mode: embedded) │ │
|
│ │ hosted iframe, in-page) │ ───────────────────► │ (ui_mode: embedded_page) │ │
|
||||||
│ └──────────────────────────────┘ └──────────────┬──────────────┘ │
|
│ └──────────────────────────────┘ └──────────────┬──────────────┘ │
|
||||||
│ │ webhook events │
|
│ │ webhook events │
|
||||||
│ ▼ │
|
│ ▼ │
|
||||||
│ ┌──────────────────────────────────────────────────────────────────────────────────────────────┐ │
|
│ ┌──────────────────────────────────────────────────────────────────────────────────────────────┐ │
|
||||||
│ │ /account/webhook (+server.ts) │ │
|
│ │ /api/webhooks/stripe (+server.ts) │ │
|
||||||
│ │ • verify stripe-signature (CLI secret in dev, dashboard in prod) │ │
|
│ │ • verify stripe-signature (CLI secret in dev, dashboard in prod) │ │
|
||||||
│ │ • handleStripeEvent() → stripe-events.ts │ │
|
│ │ • handleStripeEvent() → stripe-events.ts │ │
|
||||||
│ │ └ checkout.session.completed → fams.stripeCustomerId + active = true │ │
|
│ │ └ checkout.session.completed → fams.stripeCustomerId + active + paymentMode='sub' │ │
|
||||||
│ │ └ customer.subscription.* → fams.active (sync by customer id) │ │
|
│ │ └ customer.subscription.* → fams.active + paymentMode (sync by customer id) │ │
|
||||||
|
│ │ └ customer.subscription.deleted → active=false + paymentMode='canceled' │ │
|
||||||
│ └──────────────────────────────────────────────────────┬─────────────────────────────────────┘ │
|
│ └──────────────────────────────────────────────────────┬─────────────────────────────────────┘ │
|
||||||
│ │ pbAdmin (superuser) │
|
│ │ pbAdmin (superuser) │
|
||||||
└─────────────────────────────────────────────────────────┼─────────────────────────────────────────┘
|
└─────────────────────────────────────────────────────────┼─────────────────────────────────────────┘
|
||||||
@@ -166,25 +174,60 @@ Pause/stop via Stripe Customer Portal (from `/account`) or the pause toggle (wri
|
|||||||
│ POCKETBASE │
|
│ POCKETBASE │
|
||||||
│ fams.stripeCustomerId │
|
│ fams.stripeCustomerId │
|
||||||
│ fams.active (bool) │
|
│ fams.active (bool) │
|
||||||
|
│ fams.paymentMode │
|
||||||
└────────────────────┘
|
└────────────────────┘
|
||||||
|
|
||||||
|
SIGNUP WIZARD (inline checkout at step 4):
|
||||||
|
/signup?plan=X
|
||||||
|
1. fam create family + parent (no code field)
|
||||||
|
2. child add child / skip
|
||||||
|
3. code "Have an access code?" → apply or skip
|
||||||
|
4. plan PricingPlans component (hideTrial), selecting a plan
|
||||||
|
→ ?/choose action → createEmbeddedCheckoutSession → mount embedded inline
|
||||||
|
5. done "Go to dashboard" — webhook flips paymentMode=sub, overlay lifts
|
||||||
|
|
||||||
Management:
|
Management:
|
||||||
/account (+page.server.ts)
|
Settings → Billing group (+page.server.ts ?/billingPortal)
|
||||||
• billing action → createBillingPortalSession(customerId) → Stripe Customer Portal
|
• createBillingPortalSession(customerId) → Stripe Customer Portal
|
||||||
(update card, cancel / reactivate subscription)
|
(update card, cancel / reactivate subscription; returns to /{fam}?checkout=return)
|
||||||
• togglePause action → pbAdmin.update fams.active (hard pause, independent of Stripe)
|
Gating derives from fams.paymentMode alone (none = gated). No local pause flag.
|
||||||
|
|
||||||
Dev-only:
|
Dev-only:
|
||||||
stripe CLI: stripe listen -e ... --forward-to http://127.0.0.1:2080/account/webhook
|
pnpm stripe:listen (root script)
|
||||||
(sets STRIPE_CLI_WEBHOOK_SECRET for local signature verification)
|
= stripe listen -e customer.subscription.updated,customer.subscription.deleted,checkout.session.completed
|
||||||
|
--forward-to http://127.0.0.1:2080/api/webhooks/stripe
|
||||||
|
(sets STRIPE_CLI_WEBHOOK_SECRET for local signature verification)
|
||||||
|
|
||||||
|
Embedded Checkout needs a secure context (HTTPS or localhost). Over Tailscale/LAN HTTP the
|
||||||
|
checkout iframe hangs silently — port-forward instead: ssh -L 2080:localhost:2080
|
||||||
```
|
```
|
||||||
|
|
||||||
**Key decisions**
|
**Key decisions**
|
||||||
- **Payments live in SvelteKit, not Hono** — the app already owns SSR + server actions; Hono stays a pure data layer. Stripe secret never reaches the client.
|
- **Payments live in SvelteKit server routes** — the app owns SSR + server actions end-to-end. Stripe secret never reaches the client.
|
||||||
- **`fams.active`** is the single app-level gate: webhooks (subscription lifecycle) and the pause toggle both write it. It disables interactions + payments when `false`.
|
- **`fams.paymentMode` + `fams.active`** gate the platform (see 5.3.2). Webhooks write `paymentMode`; `ensureFamAccess()` recomputes and persists `active` on every `[fam]` layout load.
|
||||||
- **Embedded Checkout** (in-page, no redirect) via `createEmbeddedCheckoutPage` — needs a same-origin `return_url`; subscriptions require a `customer` (created with `customer_creation: 'always'` + `customer_email` if the fam has none yet).
|
- **Embedded Checkout** (in-page, no redirect) via `createEmbeddedCheckoutPage` with `ui_mode: 'embedded_page'` (`'embedded'` is deprecated) — needs a same-origin `return_url`. No `customer_creation` (subscription mode only; Stripe auto-creates the customer from `customer_email`).
|
||||||
- **Trial** is app-side: a code maps to `trial_period_days` on the subscription; the trial Stripe price is a `$0` plan. Real-world codes should move to a PB collection.
|
- **Access codes are a real PB collection** (`accesscodes`, superuser-only) — entered at signup or via settings; replaces the earlier app-side trial-code idea.
|
||||||
- **Webhook secrets** — `STRIPE_CLI_WEBHOOK_SECRET` (dev) overrides `STRIPE_WEBHOOK_SECRET` (prod/dashboard); `verifyStripeEvent` picks the CLI one when set. Dev testing uses the Stripe CLI (`stripe listen --forward-to http://127.0.0.1:2080/account/webhook`) which forwards real signed events; a real checkout carries the `famId` and drives the DB write end-to-end.
|
- **Webhook secrets** — `STRIPE_CLI_WEBHOOK_SECRET` (dev) overrides `STRIPE_WEBHOOK_SECRET` (prod/dashboard); `verifyStripeEvent` picks the CLI one when set. Dev testing uses the Stripe CLI (`pnpm stripe:listen`) which forwards real signed events; a real checkout carries the `famId` and drives the DB write end-to-end.
|
||||||
|
|
||||||
|
### 5.3.2 Access gating (`fams.paymentMode` / `accesscodes`)
|
||||||
|
|
||||||
|
```
|
||||||
|
computeFamAccess(fam, code?) → { disabled, reason } lib/server/access.ts
|
||||||
|
none → disabled ("no_access") fresh signup, no code/sub
|
||||||
|
code → valid while accesscodes.active && !expired && !durationExhausted
|
||||||
|
(duration/expiry 0 = continuous/never; months measured from
|
||||||
|
fam.accessCodeEnteredAt / code.createdAt)
|
||||||
|
sub → follows webhook-maintained fams.active
|
||||||
|
canceled → disabled ("canceled")
|
||||||
|
ensureFamAccess(famId): reads fam+code, persists drifted fams.active, returns {fam, access}
|
||||||
|
applyAccessCode(famId, value): validates + sets paymentMode='code' + entry stamp
|
||||||
|
```
|
||||||
|
|
||||||
|
- Exposed to all fam pages as `data.famAccess` from `[fam]/+layout.server.ts`.
|
||||||
|
- Disabled UX: layout blurs page content behind an overlay card + admin TopNav announcement (`/settings` is exempt so admins can apply a code / manage billing); member kanban renders empty locked columns and `toggle()` early-returns (frontend-only by decision).
|
||||||
|
- Entry points: optional code field at signup, Access card in settings (`?/applyCode`). Webhooks flip `paymentMode` to `sub`/`canceled`.
|
||||||
|
- Debug revoke: with the platform `debug` flag ON, settings shows a "Revoke code" CTA (`?/revokeCode`) that clears the applied code (back to `none`/gated). Server-side flag check is the boundary.
|
||||||
|
- Seeded dev code: `dev123` (developer, duration 0, expiry 0).
|
||||||
|
|
||||||
### 5.4 UI reactivity
|
### 5.4 UI reactivity
|
||||||
|
|
||||||
@@ -202,11 +245,10 @@ Dev-only:
|
|||||||
/shared/pb/schema.ts SCHEMA_PLAN — source of truth for base collections
|
/shared/pb/schema.ts SCHEMA_PLAN — source of truth for base collections
|
||||||
/frontend SvelteKit app (:2080)
|
/frontend SvelteKit app (:2080)
|
||||||
/src/env.ts declareEnvVars — client/server env
|
/src/env.ts declareEnvVars — client/server env
|
||||||
/src/lib/server pb-admin, migrate.ts, services, hono.ts (sessionHeaders)
|
/src/lib/server pocketbase.ts (pbAdmin), migrate.ts, access.ts, platform.ts, services/
|
||||||
/src/lib/client api.ts (memberApi), stores (famStore)
|
/src/lib/client api.ts (memberApi), stores (famStore)
|
||||||
/src/lib/components UI components (re-exported from index.ts)
|
/src/lib/components UI components (re-exported from index.ts)
|
||||||
/src/routes SvelteKit file-based routing (incl /account, /subscriptions)
|
/src/routes SvelteKit file-based routing (incl /pricing, /signup wizard, /api/webhooks/stripe)
|
||||||
/proxy Hono proxy (:3456)
|
|
||||||
/docker Dockerfile (prod multi-stage + nginx), Dockerfile.dev (PB)
|
/docker Dockerfile (prod multi-stage + nginx), Dockerfile.dev (PB)
|
||||||
/config.ts dev/build-time shared config (ports)
|
/config.ts dev/build-time shared config (ports)
|
||||||
/MEMORY.md decisions log
|
/MEMORY.md decisions log
|
||||||
@@ -234,8 +276,8 @@ Values come from root `.env` (symlinked at `frontend/.env -> ../.env`). `.env.ex
|
|||||||
## 8. Key Conventions
|
## 8. Key Conventions
|
||||||
|
|
||||||
- **`famId` on every query** — PB auth rules enforce `famId = @request.auth.famId`; superuser bypasses.
|
- **`famId` on every query** — PB auth rules enforce `famId = @request.auth.famId`; superuser bypasses.
|
||||||
- **Member → Proxy (server/browser):** `memberApi.*` in `$lib/client/api.ts`; `BASE_URL` resolves to Hono port on server, empty in browser (Vite proxies `/api/*`).
|
- **Server data access:** `servicesFor(event)` / `createServices(pb)` in `$lib/server/services/`; superuser ops via `pbAdmin` facade.
|
||||||
- **Admin → Proxy:** `hono.admin.*` in `$lib/server/hono.ts` — `sessionHeaders(event)` (server-only, requires `RequestEvent`).
|
- **Browser data access:** same-origin fetch to `/api/*` SvelteKit endpoints; httpOnly `pb_token` cookie is the auth.
|
||||||
- **Child passwords derived** — `MEMBER_SECRET + famSlug + username`; join gate is a transient OTP. Never log raw tokens/secrets.
|
- **Child passwords derived** — `MEMBER_SECRET + famSlug + username`; join gate is a transient OTP. Never log raw tokens/secrets.
|
||||||
- **`$page`** — from `$app/state` (not `$app/stores`); no `$` prefix.
|
- **`$page`** — from `$app/state` (not `$app/stores`); no `$` prefix.
|
||||||
- **Dates** — user-facing via `formatDDMMYY()` (compact `040826`); human-readable due dates use `formatShortDate()`. Never render raw `YYYY-MM-DD`.
|
- **Dates** — user-facing via `formatDDMMYY()` (compact `040826`); human-readable due dates use `formatShortDate()`. Never render raw `YYYY-MM-DD`.
|
||||||
@@ -248,5 +290,5 @@ Values come from root `.env` (symlinked at `frontend/.env -> ../.env`). `.env.ex
|
|||||||
## 9. Open / Deferred
|
## 9. Open / Deferred
|
||||||
|
|
||||||
- **WhatsApp notifications** — `NotificationService` plugin for the weekly CRON handler. Deferred.
|
- **WhatsApp notifications** — `NotificationService` plugin for the weekly CRON handler. Deferred.
|
||||||
- **Stripe payments** — flow not yet implemented. Only `fams.stripeCustomerId` + `settings.webhookUrl` exist. Building in SvelteKit `/account` + `/account/webhook`. Trial via codes (app-side validation + `trial_period_days`) — TBD.
|
- **Stripe payments** — implemented in SvelteKit (`/pricing` public picker + inline signup checkout, settings Billing group with billing portal, `/api/webhooks/stripe` → `stripe-events.ts`; `?checkout=return` lands on the fam dashboard with a welcome notice). Remaining: platform-admin UI for managing `accesscodes`, prod webhook secret wiring, optional Stripe-level pause (see TODO.md).
|
||||||
- **Weekly CRON** (`/api/weekly-cron`, Coolify) — not implemented; settlement is manual via `complete-week`/`simulateEow`.
|
- **Weekly CRON** (`/api/weekly-cron`, Coolify) — not implemented; settlement is manual via `complete-week`/`simulateEow`.
|
||||||
@@ -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`).
|
- **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).
|
- **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).
|
- **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.
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# FamChore — TODO
|
||||||
|
|
||||||
|
(Completed work is tracked in MEMORY.md / git history.)
|
||||||
|
|
||||||
|
## Open / next features
|
||||||
|
|
||||||
|
### Payments / billing
|
||||||
|
- [ ] Prod webhook secret wiring in Stripe Dashboard (dev uses CLI secret)
|
||||||
|
- [ ] Optional hardening: server-side session verification on checkout return (`checkout.sessions.retrieve` reusing webhook apply logic) — only if SSE/webhook lag ever becomes a real problem (currently event-driven via PB realtime, see MEMORY 2026-08-22)
|
||||||
|
|
||||||
|
### Platform admin
|
||||||
|
- [ ] Platform-admin UI for managing `accesscodes` globally (create/disable/delete access codes AND trial codes) — currently superuser/DB only. Natural home: `/admin` (has Platform Flags card already).
|
||||||
|
- [ ] `/admin` Families table: name link and "Dashboard"/"View" are duplicates after the broken-link fix — tidy up.
|
||||||
|
|
||||||
|
### App / UX
|
||||||
|
- [ ] Signup wizard: `?plan=` param only pre-highlights the tier at step 4 — confirm whether it should auto-scroll/pulse instead
|
||||||
|
- [ ] Data card placeholder ("Download CSV / Delete Family coming soon")
|
||||||
|
- [ ] Parent invite ("Invite Parent") is an alert stub
|
||||||
|
|
||||||
|
### Infra / deferred
|
||||||
|
- [ ] WhatsApp notifications
|
||||||
|
- [ ] Weekly CRON (`/api/weekly-cron`, Coolify) — settlement stays manual via complete-week/simulateEow
|
||||||
|
|||||||
@@ -1,36 +1,19 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
let { items }: { items: { title: string; content: any }[] } = $props();
|
import type { Snippet } from 'svelte';
|
||||||
let openIndex = $state<number | null>(null);
|
|
||||||
|
// Styled wrapper — group AccordionItem children inside.
|
||||||
|
let { children, defaultOpen = false }: { children: Snippet; defaultOpen?: boolean } = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="accordion">
|
<div class="accordion" class:default-open={defaultOpen}>
|
||||||
{#each items as item, i}
|
{@render children()}
|
||||||
<div class="accordion-item" class:open={openIndex === i}>
|
|
||||||
<button class="accordion-trigger" onclick={() => openIndex = openIndex === i ? null : i}>
|
|
||||||
<span>{item.title}</span>
|
|
||||||
<span class="accordion-arrow">{openIndex === i ? '▾' : '▸'}</span>
|
|
||||||
</button>
|
|
||||||
{#if openIndex === i}
|
|
||||||
<div class="accordion-body">
|
|
||||||
{@render item.content()}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.accordion { border: 1px solid #e5e7eb; border-radius: 8px; overflow: hidden; }
|
.accordion {
|
||||||
.accordion-item { border-bottom: 1px solid #f3f4f6; }
|
border: 1px solid #e5e7eb;
|
||||||
.accordion-item:last-child { border-bottom: none; }
|
border-radius: 8px;
|
||||||
.accordion-trigger {
|
overflow: hidden;
|
||||||
display: flex; justify-content: space-between; align-items: center;
|
background: #fff;
|
||||||
width: 100%; padding: 0.7rem 1rem;
|
|
||||||
background: #fafafa; border: none;
|
|
||||||
font-size: 0.9rem; font-weight: 500; color: #374151;
|
|
||||||
cursor: pointer; text-align: left;
|
|
||||||
}
|
}
|
||||||
.accordion-trigger:hover { background: #f3f4f6; }
|
</style>
|
||||||
.accordion-arrow { font-size: 0.8rem; color: #9ca3af; }
|
|
||||||
.accordion-body { padding: 1rem; }
|
|
||||||
</style>
|
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
|
let { title, open = $bindable(false), children }: { title: string; open?: boolean; children: Snippet } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="accordion-item" class:open>
|
||||||
|
<button
|
||||||
|
class="accordion-trigger"
|
||||||
|
onclick={() => (open = !open)}
|
||||||
|
aria-expanded={open}
|
||||||
|
>
|
||||||
|
<span>{title}</span>
|
||||||
|
<span class="accordion-arrow">{open ? '▾' : '▸'}</span>
|
||||||
|
</button>
|
||||||
|
{#if open}
|
||||||
|
<div class="accordion-body">
|
||||||
|
{@render children()}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.accordion-item { border-bottom: 1px solid #f3f4f6; }
|
||||||
|
.accordion-item:last-child { border-bottom: none; }
|
||||||
|
.accordion-trigger {
|
||||||
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
|
width: 100%; padding: 0.7rem 1rem;
|
||||||
|
background: #fafafa; border: none;
|
||||||
|
font-size: 0.95rem; font-weight: 600; color: #374151;
|
||||||
|
cursor: pointer; text-align: left;
|
||||||
|
}
|
||||||
|
.accordion-trigger:hover { background: #f3f4f6; }
|
||||||
|
.accordion-arrow { font-size: 0.8rem; color: #9ca3af; }
|
||||||
|
.accordion-body { padding: 1rem; }
|
||||||
|
</style>
|
||||||
@@ -4,14 +4,16 @@
|
|||||||
title,
|
title,
|
||||||
accent,
|
accent,
|
||||||
scrollX = false,
|
scrollX = false,
|
||||||
children
|
children,
|
||||||
}: { cols?: 1 | 2 | 3 | 4 | 5 | 6; title?: string; accent?: string; scrollX?: boolean; children?: any } = $props();
|
class: className,
|
||||||
|
selected = false
|
||||||
|
}: { cols?: 1 | 2 | 3 | 4 | 5 | 6; title?: string; accent?: string; scrollX?: boolean; children?: any; class?: string; selected?: boolean } = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="card"
|
|
||||||
data-cols={cols}
|
data-cols={cols}
|
||||||
style="--card-cols: {cols}; {accent ? `--card-accent: ${accent}` : ''}"
|
style="--card-cols: {cols}; {accent ? `--card-accent: ${accent}` : ''}"
|
||||||
|
class="card {className || ''} {selected ? 'selected' : ''}"
|
||||||
class:has-accent={!!accent}
|
class:has-accent={!!accent}
|
||||||
class:scroll-x={scrollX}
|
class:scroll-x={scrollX}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { notices, type NoticeType } from '$lib/stores/notices.svelte';
|
||||||
|
|
||||||
|
function icon(type: NoticeType) {
|
||||||
|
switch (type) {
|
||||||
|
case 'success': return '✅';
|
||||||
|
case 'warning': return '⚠️';
|
||||||
|
case 'error': return '❌';
|
||||||
|
default: return 'ℹ️';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if notices.list.length > 0}
|
||||||
|
<div class="notice-container" role="region" aria-label="Notifications">
|
||||||
|
{#each notices.list as notice (notice.id)}
|
||||||
|
<div class="notice notice-{notice.type}">
|
||||||
|
<div class="notice-content">
|
||||||
|
<div class="notice-icon">{icon(notice.type)}</div>
|
||||||
|
<div class="notice-text">
|
||||||
|
<h4>{notice.title}</h4>
|
||||||
|
{#if notice.message}
|
||||||
|
<p>{notice.message}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{#if notice.action}
|
||||||
|
<a href={notice.action.href} class="notice-action">{notice.action.label}</a>
|
||||||
|
{/if}
|
||||||
|
{#if notice.dismissible}
|
||||||
|
<button class="notice-dismiss" onclick={() => notices.remove(notice.id)} aria-label="Dismiss">✕</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.notice-container {
|
||||||
|
position: fixed;
|
||||||
|
top: 5rem;
|
||||||
|
right: 1.5rem;
|
||||||
|
z-index: 1000;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
max-width: 380px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.notice {
|
||||||
|
background: white;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.75rem;
|
||||||
|
border-left: 4px solid #6366f1;
|
||||||
|
animation: slidein 0.25s ease;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
.notice-success { border-left-color: #059669; }
|
||||||
|
.notice-warning { border-left-color: #d97706; }
|
||||||
|
.notice-error { border-left-color: #dc2626; }
|
||||||
|
.notice-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.6rem;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.notice-icon {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.notice-text h4 {
|
||||||
|
margin: 0 0 0.25rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
.notice-text p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #6b7280;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.notice-action {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #4338ca;
|
||||||
|
font-weight: 500;
|
||||||
|
text-decoration: none;
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
.notice-action:hover { text-decoration: underline; }
|
||||||
|
.notice-dismiss {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: #9ca3af;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.2rem;
|
||||||
|
line-height: 1;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.notice-dismiss:hover { color: #374151; }
|
||||||
|
@keyframes slidein {
|
||||||
|
from { opacity: 0; transform: translateX(20px); }
|
||||||
|
to { opacity: 1; transform: translateX(0); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import { Button, CardGrid, Card } from '$lib/components';
|
||||||
|
|
||||||
|
interface Tier {
|
||||||
|
id: 'trial' | 'monthly' | 'yearly';
|
||||||
|
name: string;
|
||||||
|
price: string;
|
||||||
|
period: string;
|
||||||
|
desc: string;
|
||||||
|
cta: string;
|
||||||
|
featured?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tiers: Tier[] = [
|
||||||
|
{
|
||||||
|
id: 'trial',
|
||||||
|
name: 'Trial',
|
||||||
|
price: 'Free',
|
||||||
|
period: 'for a set period',
|
||||||
|
desc: 'Try FamChore free for a period (e.g. 3 months) with a code.',
|
||||||
|
cta: 'Start trial'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'monthly',
|
||||||
|
name: 'Monthly',
|
||||||
|
price: '£3',
|
||||||
|
period: '/month',
|
||||||
|
desc: 'Full access for one family. Cancel anytime.',
|
||||||
|
cta: 'Choose monthly',
|
||||||
|
featured: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'yearly',
|
||||||
|
name: 'Yearly',
|
||||||
|
price: '£30',
|
||||||
|
period: '/year',
|
||||||
|
desc: 'Two months free vs monthly. Best value for committed families.',
|
||||||
|
cta: 'Choose yearly'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
let { action, hideTrial = false, selected = '', error = '', onsubmit }: {
|
||||||
|
action: string;
|
||||||
|
hideTrial?: boolean;
|
||||||
|
selected?: string;
|
||||||
|
error?: string;
|
||||||
|
onsubmit?: any;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<CardGrid>
|
||||||
|
{#each tiers.filter((t) => !(hideTrial && t.id === 'trial')) as tier}
|
||||||
|
<Card title={tier.name} accent={tier.featured ? '#6366f1' : undefined} selected={selected === tier.id}>
|
||||||
|
<p class="price">
|
||||||
|
<span class="amount">{tier.price}</span>
|
||||||
|
<span class="period">{tier.period}</span>
|
||||||
|
</p>
|
||||||
|
<p class="hint">{tier.desc}</p>
|
||||||
|
|
||||||
|
{#if tier.id === 'trial'}
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action={action}
|
||||||
|
use:enhance={onsubmit ?? undefined}
|
||||||
|
>
|
||||||
|
<input type="hidden" name="plan" value="trial" />
|
||||||
|
<label class="field-label" for="trial-code">Trial code</label>
|
||||||
|
<input
|
||||||
|
id="trial-code"
|
||||||
|
name="code"
|
||||||
|
placeholder="e.g. FAM3MONTHS"
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
<Button type="submit" size="md" variant="secondary">Start trial</Button>
|
||||||
|
</form>
|
||||||
|
{:else}
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action={action}
|
||||||
|
use:enhance={onsubmit ?? undefined}
|
||||||
|
>
|
||||||
|
<input type="hidden" name="plan" value={tier.id} />
|
||||||
|
<Button type="submit" size="md" variant={tier.featured ? 'primary' : 'secondary'}>
|
||||||
|
{tier.cta}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<p class="error">{error}</p>
|
||||||
|
{/if}
|
||||||
|
</Card>
|
||||||
|
{/each}
|
||||||
|
</CardGrid>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.hint {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #9ca3af;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.price {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
}
|
||||||
|
.amount {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
.period {
|
||||||
|
color: #9ca3af;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-left: 0.25rem;
|
||||||
|
}
|
||||||
|
form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.6rem;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
.field-label {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
input {
|
||||||
|
padding: 0.55rem 0.75rem;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
input:focus {
|
||||||
|
outline: 2px solid #6366f1;
|
||||||
|
outline-offset: -1px;
|
||||||
|
}
|
||||||
|
form :global(.btn) {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.error {
|
||||||
|
color: #dc2626;
|
||||||
|
background: #fef2f2;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
:global(.selected) {
|
||||||
|
border: 2px solid #6366f1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
|
|
||||||
let collapsed = $state(false);
|
let collapsed = $state(false);
|
||||||
|
|
||||||
let famSlug = $derived(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() {
|
function toggle() {
|
||||||
|
|||||||
@@ -6,5 +6,8 @@ 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 Accordion } from './Accordion.svelte';
|
export { default as Accordion } from './Accordion.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 PricingPlans } from './PricingPlans.svelte';
|
||||||
|
|||||||
@@ -32,3 +32,19 @@ export function formatHumanDate(dateStr: string | undefined): string {
|
|||||||
const sameYear = d.getFullYear() === new Date().getFullYear();
|
const sameYear = d.getFullYear() === new Date().getFullYear();
|
||||||
return `${weekday} ${d.getDate()} ${mon}${sameYear ? '' : ' ' + String(d.getFullYear()).slice(2)}`;
|
return `${weekday} ${d.getDate()} ${mon}${sameYear ? '' : ' ' + String(d.getFullYear()).slice(2)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add months to a date (UTC), handling month overflow correctly.
|
||||||
|
export function addMonthsUTC(date: Date, months: number): Date {
|
||||||
|
const d = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + months, date.getUTCDate()));
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Short human date: "5 Aug" / "5 Aug 26"
|
||||||
|
export function formatShortDate(dateStr: string | Date | undefined): string {
|
||||||
|
if (!dateStr) return '';
|
||||||
|
const d = dateStr instanceof Date ? dateStr : new Date(dateStr);
|
||||||
|
if (Number.isNaN(d.getTime())) return '';
|
||||||
|
const mon = d.toLocaleDateString('en-GB', { month: 'short' });
|
||||||
|
const sameYear = d.getFullYear() === new Date().getFullYear();
|
||||||
|
return `${d.getDate()} ${mon}${sameYear ? '' : ' ' + String(d.getFullYear()).slice(2)}`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { pbAdmin } from '$lib/server/pocketbase';
|
||||||
|
|
||||||
|
// How a family has access. 'none' = signed up with no code/sub yet (gated).
|
||||||
|
export type PaymentMode = 'none' | 'code' | 'sub' | 'canceled';
|
||||||
|
|
||||||
|
export interface FamAccess {
|
||||||
|
disabled: boolean;
|
||||||
|
mode: PaymentMode;
|
||||||
|
reason: '' | 'no_access' | 'canceled' | 'subscription_inactive' | 'code_disabled' | 'code_expired';
|
||||||
|
}
|
||||||
|
|
||||||
|
// UTC timestamp `months` months after `iso`. Used for the code's global expiry
|
||||||
|
// (from the code's createdAt) and the duration clock (from entry date).
|
||||||
|
export function addMonthsUTC(iso: string | Date, months: number): number {
|
||||||
|
const d = new Date(iso);
|
||||||
|
d.setUTCMonth(d.getUTCMonth() + months);
|
||||||
|
return d.getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
// A code is usable iff it exists, is not globally disabled, is not past its own
|
||||||
|
// createdAt+expiry window (expiry 0 = never), and the duration clock from the
|
||||||
|
// fam's entry date hasn't run out (duration 0 = continuous).
|
||||||
|
export function codeIsValid(code: any, enteredAt?: string): boolean {
|
||||||
|
if (!code) return false;
|
||||||
|
if (code.active === false) return false;
|
||||||
|
const now = Date.now();
|
||||||
|
const expiryMonths = Number(code.expiry) || 0;
|
||||||
|
if (expiryMonths > 0 && now >= addMonthsUTC(code.createdAt, expiryMonths)) return false;
|
||||||
|
const durationMonths = Number(code.duration) || 0;
|
||||||
|
if (durationMonths > 0 && enteredAt) {
|
||||||
|
if (now >= addMonthsUTC(enteredAt, durationMonths)) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deterministic decision from a fam + its linked code (no I/O). `active` on the
|
||||||
|
// fam is the single source of truth for "usable right now".
|
||||||
|
export function computeFamAccess(fam: any, code: any): FamAccess {
|
||||||
|
const mode: PaymentMode = fam.paymentMode || 'none';
|
||||||
|
switch (mode) {
|
||||||
|
case 'code': {
|
||||||
|
if (code?.active === false) return { disabled: true, mode, reason: 'code_disabled' };
|
||||||
|
return codeIsValid(code, fam.accessCodeEnteredAt)
|
||||||
|
? { disabled: false, mode, reason: '' }
|
||||||
|
: { disabled: true, mode, reason: 'code_expired' };
|
||||||
|
}
|
||||||
|
case 'sub':
|
||||||
|
return fam.active === false
|
||||||
|
? { disabled: true, mode, reason: 'subscription_inactive' }
|
||||||
|
: { disabled: false, mode, reason: '' };
|
||||||
|
case 'canceled':
|
||||||
|
return { disabled: true, mode, reason: 'canceled' };
|
||||||
|
case 'none':
|
||||||
|
default:
|
||||||
|
return { disabled: true, mode, reason: 'no_access' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read the fam + linked code and persist `active` if it drifted. Called on every
|
||||||
|
// [fam] layout load (both roles) and wherever access state must be re-evaluated.
|
||||||
|
export async function ensureFamAccess(famId: string): Promise<{ fam: any; access: FamAccess }> {
|
||||||
|
const fam = await pbAdmin.getOne('fams', famId);
|
||||||
|
if (!fam) return { fam: null, access: { disabled: true, mode: 'none', reason: 'no_access' } };
|
||||||
|
let code: any = null;
|
||||||
|
if (fam.accessCodeId) {
|
||||||
|
try {
|
||||||
|
code = await pbAdmin.getOne('accesscodes', fam.accessCodeId);
|
||||||
|
} catch {
|
||||||
|
code = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const access = computeFamAccess(fam, code);
|
||||||
|
if (fam.active !== !access.disabled) {
|
||||||
|
await pbAdmin.update('fams', famId, { active: !access.disabled });
|
||||||
|
}
|
||||||
|
return { fam: { ...fam, active: !access.disabled }, access };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply an access code value to a fam. Automatic (no approval) — validates
|
||||||
|
// against the accesscodes collection and sets paymentMode='code' on success.
|
||||||
|
export async function applyAccessCode(famId: string, value: string) {
|
||||||
|
const val = String(value || '').trim();
|
||||||
|
if (!val) return { error: 'Enter an access code' };
|
||||||
|
const list = await pbAdmin.getList('accesscodes', `value = '${val}'`);
|
||||||
|
const code = list?.[0];
|
||||||
|
if (!code) return { error: 'That access code is not recognised' };
|
||||||
|
if (code.active === false) return { error: 'That access code is disabled' };
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const valid = codeIsValid(code, now);
|
||||||
|
await pbAdmin.update('fams', famId, {
|
||||||
|
paymentMode: 'code',
|
||||||
|
accessCodeId: code.id,
|
||||||
|
accessCodeEnteredAt: now,
|
||||||
|
active: valid
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
active: valid,
|
||||||
|
code: { name: code.name, value: code.value, duration: code.duration, expiry: code.expiry }
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { redirect } from '@sveltejs/kit';
|
import { redirect } from '@sveltejs/kit';
|
||||||
|
import { clearLegacyCookies } from '$lib/server/session';
|
||||||
import type { RequestEvent } from '@sveltejs/kit';
|
import type { RequestEvent } from '@sveltejs/kit';
|
||||||
import { pbAdmin } from '$lib/server/pocketbase';
|
import { pbAdmin } from '$lib/server/pocketbase';
|
||||||
|
|
||||||
@@ -17,7 +18,7 @@ export function requireAuth(event: RequestEvent) {
|
|||||||
export function clearSession(event: RequestEvent) {
|
export function clearSession(event: RequestEvent) {
|
||||||
event.cookies.delete('session', { path: '/' });
|
event.cookies.delete('session', { path: '/' });
|
||||||
event.cookies.delete('pb_token', { path: '/' });
|
event.cookies.delete('pb_token', { path: '/' });
|
||||||
event.cookies.delete('device_token', { path: '/' });
|
clearLegacyCookies(event.cookies);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve the fam slug + admin display name used for the post-login redirect.
|
// Resolve the fam slug + admin display name used for the post-login redirect.
|
||||||
|
|||||||
@@ -154,6 +154,122 @@ async function ensureOtp(ids: Record<string, string>): Promise<void> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Platform access codes — the codes that enable access to the platform. They're
|
||||||
|
// global (not fam-scoped) and managed via the platform admin page (superuser
|
||||||
|
// only), so all rules are null like `otp`. A code grants a family a subscription
|
||||||
|
// for `duration` months (0 = continuous); `expiry` is months-after-createdAt
|
||||||
|
// (0 = never expires); `active` is a failsafe toggle. Entered at create-family
|
||||||
|
// and in admin settings.
|
||||||
|
async function ensureAccessCodes(): Promise<void> {
|
||||||
|
if (await getCollection("accesscodes")) {
|
||||||
|
await ensureAccessCodeFields();
|
||||||
|
await seedAccessCodes();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await createCollection({
|
||||||
|
name: "accesscodes",
|
||||||
|
type: "base",
|
||||||
|
listRule: null,
|
||||||
|
viewRule: null,
|
||||||
|
createRule: null,
|
||||||
|
updateRule: null,
|
||||||
|
deleteRule: null,
|
||||||
|
fields: [
|
||||||
|
{ name: "value", type: "text", required: true, unique: true },
|
||||||
|
{ name: "name", type: "text", required: true },
|
||||||
|
{ name: "duration", type: "number", required: false },
|
||||||
|
{ name: "expiry", type: "number", required: false },
|
||||||
|
{ name: "active", type: "bool", required: false },
|
||||||
|
// When set (>0) this code is a TRIAL code: maps to Stripe
|
||||||
|
// trial_period_days at checkout instead of platform access.
|
||||||
|
{ name: "trialDays", type: "number", required: false },
|
||||||
|
{ name: "createdAt", type: "date", required: false },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
await seedAccessCodes();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Idempotent field-add for installs where accesscodes predates a field.
|
||||||
|
async function ensureAccessCodeFields(): Promise<void> {
|
||||||
|
const col = await getCollection("accesscodes");
|
||||||
|
if (!col) return;
|
||||||
|
const has = (n: string) => col.fields.some((f: any) => f.name === n);
|
||||||
|
if (!has("trialDays")) {
|
||||||
|
await updateCollection(col.id, {
|
||||||
|
...col,
|
||||||
|
fields: [...col.fields, { name: "trialDays", type: "number", required: false }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Idempotent seeds — developer code + an example trial code.
|
||||||
|
async function seedAccessCodes(): Promise<void> {
|
||||||
|
const t = await auth();
|
||||||
|
const seeds = [
|
||||||
|
{ value: "dev123", name: "developer", duration: 0, expiry: 0, active: true, trialDays: null },
|
||||||
|
{ value: "FAM3MONTHS", name: "3-month trial", duration: null, expiry: null, active: true, trialDays: 90 },
|
||||||
|
];
|
||||||
|
for (const seed of seeds) {
|
||||||
|
const res = await fetch(
|
||||||
|
`${PB_ENDPOINT}/api/collections/accesscodes/records?filter=value='${seed.value}'`,
|
||||||
|
{ headers: { Authorization: `Bearer ${t}` } },
|
||||||
|
);
|
||||||
|
const data = await res.json();
|
||||||
|
if (data?.items?.length) continue;
|
||||||
|
const created = await fetch(`${PB_ENDPOINT}/api/collections/accesscodes/records`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", Authorization: `Bearer ${t}` },
|
||||||
|
body: JSON.stringify({ ...seed, createdAt: new Date().toISOString() }),
|
||||||
|
});
|
||||||
|
const c = await created.json();
|
||||||
|
if (!created.ok) throw new Error(`Seed accesscode failed: ${JSON.stringify(c)}`);
|
||||||
|
console.log(` ✓ Seeded access code: ${seed.name} (${seed.value})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Platform settings — a single global record holding the platform feature
|
||||||
|
// flags (replaces the per-fam fams.featureFlags). Publicly readable (empty
|
||||||
|
// list/view rules) so every client can deduce flags on app load; writes stay
|
||||||
|
// superuser-only (null rules), so like otp/accesscodes this lives in
|
||||||
|
// migrate.ts rather than SCHEMA_PLAN.
|
||||||
|
async function ensurePlatform(): Promise<void> {
|
||||||
|
if (!(await getCollection("platform"))) {
|
||||||
|
await createCollection({
|
||||||
|
name: "platform",
|
||||||
|
type: "base",
|
||||||
|
listRule: "",
|
||||||
|
viewRule: "",
|
||||||
|
createRule: null,
|
||||||
|
updateRule: null,
|
||||||
|
deleteRule: null,
|
||||||
|
fields: [
|
||||||
|
{ name: "label", type: "text", required: true },
|
||||||
|
{ name: "flags", type: "json", required: false },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await seedPlatform();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Idempotent seed — create the singleton 'global' settings record if missing.
|
||||||
|
async function seedPlatform(): Promise<void> {
|
||||||
|
const t = await auth();
|
||||||
|
const res = await fetch(
|
||||||
|
`${PB_ENDPOINT}/api/collections/platform/records?filter=label='global'`,
|
||||||
|
{ headers: { Authorization: `Bearer ${t}` } },
|
||||||
|
);
|
||||||
|
const data = await res.json();
|
||||||
|
if (data?.items?.length) return;
|
||||||
|
const created = await fetch(`${PB_ENDPOINT}/api/collections/platform/records`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", Authorization: `Bearer ${t}` },
|
||||||
|
body: JSON.stringify({ label: "global", flags: { debug: false } }),
|
||||||
|
});
|
||||||
|
const c = await created.json();
|
||||||
|
if (!created.ok) throw new Error(`Seed platform failed: ${JSON.stringify(c)}`);
|
||||||
|
console.log(" ✓ Seeded platform settings (global)");
|
||||||
|
}
|
||||||
|
|
||||||
// Bootstrap the full schema on a fresh/wiped PocketBase. Idempotent — skips if
|
// Bootstrap the full schema on a fresh/wiped PocketBase. Idempotent — skips if
|
||||||
// `fams` already exists (data is disposable; there is no incremental migration
|
// `fams` already exists (data is disposable; there is no incremental migration
|
||||||
// history).
|
// history).
|
||||||
@@ -177,8 +293,38 @@ async function ensureSchema(): Promise<void> {
|
|||||||
console.log("[migrate] Schema bootstrapped.");
|
console.log("[migrate] Schema bootstrapped.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add the access-gating fields to `fams` on installs where it already exists
|
||||||
|
// (fresh installs get them via SCHEMA_PLAN). Idempotent — only adds missing
|
||||||
|
// fields.
|
||||||
|
async function ensureFamFields(): Promise<void> {
|
||||||
|
const famsCol = await getCollection("fams");
|
||||||
|
if (!famsCol) return;
|
||||||
|
const has = (n: string) => famsCol.fields.some((f: any) => f.name === n);
|
||||||
|
const needed: any[] = [];
|
||||||
|
if (!has("active")) {
|
||||||
|
needed.push({ name: "active", type: "bool", required: false });
|
||||||
|
}
|
||||||
|
if (!has("paymentMode")) {
|
||||||
|
needed.push({ name: "paymentMode", type: "select", required: false, values: ["none", "code", "sub", "canceled"], maxSelect: 1 });
|
||||||
|
}
|
||||||
|
if (!has("accessCodeId")) {
|
||||||
|
needed.push({ name: "accessCodeId", type: "text", required: false });
|
||||||
|
}
|
||||||
|
if (!has("accessCodeEnteredAt")) {
|
||||||
|
needed.push({ name: "accessCodeEnteredAt", type: "date", required: false });
|
||||||
|
}
|
||||||
|
if (needed.length) {
|
||||||
|
await updateCollection(famsCol.id, { ...famsCol, fields: [...famsCol.fields, ...needed] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function migrate(): Promise<void> {
|
export async function migrate(): Promise<void> {
|
||||||
console.log("[migrate] Checking PB collection schemas...");
|
console.log("[migrate] Checking PB collection schemas...");
|
||||||
await ensureSchema();
|
await ensureSchema();
|
||||||
|
// Runs even when the schema already exists (unlike ensureSchema's early
|
||||||
|
// return) so new platform collections/fields/seed land on existing installs.
|
||||||
|
await ensureFamFields();
|
||||||
|
await ensureAccessCodes();
|
||||||
|
await ensurePlatform();
|
||||||
console.log("[migrate] Done");
|
console.log("[migrate] Done");
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { pbAdmin } from '$lib/server/pocketbase';
|
||||||
|
|
||||||
|
// Platform-level feature flags, stored on the singleton `platform` record
|
||||||
|
// (label='global'). Replaces the deprecated per-fam fams.featureFlags.
|
||||||
|
export type PlatformFlags = Record<string, boolean>;
|
||||||
|
|
||||||
|
let cache: { flags: PlatformFlags; at: number } | null = null;
|
||||||
|
const TTL_MS = 10_000;
|
||||||
|
|
||||||
|
async function findGlobal(): Promise<any | null> {
|
||||||
|
const recs = (await pbAdmin.getList('platform', `label = 'global'`)) as any[];
|
||||||
|
return recs[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Public read used by loads. Cached briefly so per-request layout loads don't
|
||||||
|
// hammer PB; flag changes propagate within the TTL.
|
||||||
|
export async function getPlatformFlags(): Promise<PlatformFlags> {
|
||||||
|
if (cache && Date.now() - cache.at < TTL_MS) return cache.flags;
|
||||||
|
try {
|
||||||
|
const rec = await findGlobal();
|
||||||
|
cache = { flags: rec?.flags || {}, at: Date.now() };
|
||||||
|
} catch {
|
||||||
|
if (!cache) cache = { flags: {}, at: Date.now() };
|
||||||
|
}
|
||||||
|
return cache.flags;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Superuser write (platform admin dashboard / server-side only).
|
||||||
|
export async function setPlatformFlag(key: string, value: boolean): Promise<PlatformFlags> {
|
||||||
|
const rec = await findGlobal();
|
||||||
|
if (!rec) throw new Error('platform settings record missing');
|
||||||
|
const flags: PlatformFlags = { ...(rec.flags || {}), [key]: value };
|
||||||
|
await pbAdmin.update('platform', rec.id, { flags });
|
||||||
|
cache = { flags, at: Date.now() };
|
||||||
|
return flags;
|
||||||
|
}
|
||||||
@@ -21,4 +21,12 @@ export function setSessionCookie(cookies: Cookies, token: string) {
|
|||||||
|
|
||||||
export function clearSessionCookie(cookies: Cookies) {
|
export function clearSessionCookie(cookies: Cookies) {
|
||||||
cookies.delete(SESSION_COOKIE, { path: '/' });
|
cookies.delete(SESSION_COOKIE, { path: '/' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy pre-PB-auth child cookie. No longer issued anywhere; these deletes
|
||||||
|
// exist only to scrub it from browsers that still carry one.
|
||||||
|
const LEGACY_DEVICE_COOKIE = 'device_token';
|
||||||
|
|
||||||
|
export function clearLegacyCookies(cookies: Cookies) {
|
||||||
|
cookies.delete(LEGACY_DEVICE_COOKIE, { path: '/' });
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
import { pbAdmin } from '$lib/server/pocketbase';
|
import { pbAdmin } from '$lib/server/pocketbase';
|
||||||
import type Stripe from 'stripe';
|
import type Stripe from 'stripe';
|
||||||
|
|
||||||
// Shared Stripe event handling. Both the real webhook (/account/webhook) and
|
// Shared Stripe event handling. The real webhook (/api/webhooks/stripe) routes
|
||||||
// the dev-only simulator (/account/webhook/simulate) route through here so the
|
// through here so the DB effects are identical.
|
||||||
// DB effects are identical.
|
|
||||||
export async function handleStripeEvent(event: Stripe.Event): Promise<void> {
|
export async function handleStripeEvent(event: Stripe.Event): Promise<void> {
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case 'checkout.session.completed': {
|
case 'checkout.session.completed': {
|
||||||
@@ -12,19 +11,28 @@ export async function handleStripeEvent(event: Stripe.Event): Promise<void> {
|
|||||||
if (famId && session.customer) {
|
if (famId && session.customer) {
|
||||||
await pbAdmin.update('fams', famId, {
|
await pbAdmin.update('fams', famId, {
|
||||||
stripeCustomerId: String(session.customer),
|
stripeCustomerId: String(session.customer),
|
||||||
active: true
|
active: true,
|
||||||
|
paymentMode: 'sub'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'customer.subscription.created':
|
case 'customer.subscription.created':
|
||||||
case 'customer.subscription.updated':
|
case 'customer.subscription.updated':
|
||||||
case 'customer.subscription.deleted':
|
|
||||||
case 'customer.subscription.paused': {
|
case 'customer.subscription.paused': {
|
||||||
const sub = event.data.object as Stripe.Subscription;
|
const sub = event.data.object as Stripe.Subscription;
|
||||||
await setActiveFromSubscription(sub);
|
await setActiveFromSubscription(sub);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case 'customer.subscription.deleted': {
|
||||||
|
const sub = event.data.object as Stripe.Subscription;
|
||||||
|
const fams = await pbAdmin.getList('fams', `stripeCustomerId = '${sub.customer}'`);
|
||||||
|
const fam = fams[0];
|
||||||
|
if (fam) {
|
||||||
|
await pbAdmin.update('fams', fam.id, { active: false, paymentMode: 'canceled' });
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,5 +45,5 @@ async function setActiveFromSubscription(sub: Stripe.Subscription) {
|
|||||||
// Active only while the sub is trialing/active (not past_due/canceled/paused).
|
// Active only while the sub is trialing/active (not past_due/canceled/paused).
|
||||||
const active =
|
const active =
|
||||||
sub.status === 'trialing' || sub.status === 'active' || sub.status === 'past_due';
|
sub.status === 'trialing' || sub.status === 'active' || sub.status === 'past_due';
|
||||||
await pbAdmin.update('fams', fam.id, { active });
|
await pbAdmin.update('fams', fam.id, { active, paymentMode: 'sub' });
|
||||||
}
|
}
|
||||||
@@ -28,17 +28,18 @@ export const PLAN_IDS: Record<'trial' | 'monthly' | 'yearly', string> = {
|
|||||||
yearly: String(STRIPE_PRICE_YEARLY) || 'price_dummy_yearly'
|
yearly: String(STRIPE_PRICE_YEARLY) || 'price_dummy_yearly'
|
||||||
};
|
};
|
||||||
|
|
||||||
// Trial codes (app-side). In practice these should live in a PB collection;
|
// Trial codes live in PB (`accesscodes` with trialDays > 0, active) so the
|
||||||
// for the dummy flow a static map is enough. Maps code -> trial days.
|
// platform admin can manage them. A matching active code maps to Stripe
|
||||||
export const TRIAL_CODES: Record<string, number> = {
|
// trial_period_days at checkout; anything else is not a trial code.
|
||||||
FAM3MONTHS: 90,
|
export async function resolveTrialDays(code?: string): Promise<number | null> {
|
||||||
FAMTRIAL: 30
|
|
||||||
};
|
|
||||||
|
|
||||||
export function resolveTrialDays(code?: string): number | null {
|
|
||||||
if (!code) return null;
|
if (!code) return null;
|
||||||
const days = TRIAL_CODES[code.trim().toUpperCase()];
|
const { pbAdmin } = await import('$lib/server/pocketbase');
|
||||||
return typeof days === 'number' ? days : null;
|
const recs = (await pbAdmin.getList(
|
||||||
|
'accesscodes',
|
||||||
|
`value = '${code.trim().toUpperCase()}' && active = true`
|
||||||
|
)) as any[];
|
||||||
|
const days = Number(recs?.[0]?.trialDays) || 0;
|
||||||
|
return days > 0 ? days : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function verifyStripeEvent(rawBody: string, signature: string): Stripe.Event {
|
export function verifyStripeEvent(rawBody: string, signature: string): Stripe.Event {
|
||||||
@@ -68,7 +69,7 @@ export async function createCheckoutSession(opts: {
|
|||||||
mode: 'subscription',
|
mode: 'subscription',
|
||||||
metadata: { famId: opts.famId, plan: opts.plan },
|
metadata: { famId: opts.famId, plan: opts.plan },
|
||||||
success_url: `${opts.origin}/account?checkout=success`,
|
success_url: `${opts.origin}/account?checkout=success`,
|
||||||
cancel_url: `${opts.origin}/subscriptions?checkout=cancelled`
|
cancel_url: `${opts.origin}/pricing?checkout=cancelled`
|
||||||
};
|
};
|
||||||
|
|
||||||
// Attach customer if we already have a Stripe customer id for this fam.
|
// Attach customer if we already have a Stripe customer id for this fam.
|
||||||
@@ -93,10 +94,10 @@ export async function createCheckoutSession(opts: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Stripe Billing portal session for managing/cancelling the subscription.
|
// Stripe Billing portal session for managing/cancelling the subscription.
|
||||||
export async function createBillingPortalSession(customerId: string, origin: string) {
|
export async function createBillingPortalSession(customerId: string, origin: string, famSlug = '') {
|
||||||
return stripe.billingPortal.sessions.create({
|
return stripe.billingPortal.sessions.create({
|
||||||
customer: customerId,
|
customer: customerId,
|
||||||
return_url: `${origin}/account`
|
return_url: `${origin}/${famSlug}?checkout=return`
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,6 +109,7 @@ export async function createEmbeddedCheckoutSession(opts: {
|
|||||||
plan: PlanId | 'price' | 'trial';
|
plan: PlanId | 'price' | 'trial';
|
||||||
priceId?: string;
|
priceId?: string;
|
||||||
famId: string;
|
famId: string;
|
||||||
|
famSlug: string;
|
||||||
email?: string | null;
|
email?: string | null;
|
||||||
customerId?: string | null;
|
customerId?: string | null;
|
||||||
trialDays?: number | null;
|
trialDays?: number | null;
|
||||||
@@ -124,7 +126,7 @@ export async function createEmbeddedCheckoutSession(opts: {
|
|||||||
mode: 'subscription',
|
mode: 'subscription',
|
||||||
ui_mode: 'embedded_page',
|
ui_mode: 'embedded_page',
|
||||||
metadata: { famId: opts.famId, plan: opts.plan },
|
metadata: { famId: opts.famId, plan: opts.plan },
|
||||||
return_url: `${opts.origin}/account?checkout=return`
|
return_url: `${opts.origin}/${opts.famSlug}?checkout=return`
|
||||||
};
|
};
|
||||||
|
|
||||||
if (opts.customerId) {
|
if (opts.customerId) {
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
// App-wide notices — Svelte 5 rune store (.svelte.ts). Rendered globally by
|
||||||
|
// <NoticeDialog /> in the root layout.
|
||||||
|
export type NoticeType = 'info' | 'success' | 'warning' | 'error';
|
||||||
|
|
||||||
|
export interface Notice {
|
||||||
|
id: string;
|
||||||
|
type: NoticeType;
|
||||||
|
title: string;
|
||||||
|
message?: string;
|
||||||
|
action?: { label: string; href: string };
|
||||||
|
dismissible: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let idCounter = 0;
|
||||||
|
|
||||||
|
class NoticeStore {
|
||||||
|
list = $state<Notice[]>([]);
|
||||||
|
|
||||||
|
add(notice: Omit<Notice, 'id'>): string {
|
||||||
|
const id = `notice-${Date.now()}-${idCounter++}`;
|
||||||
|
this.list.push({ ...notice, id });
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
remove(id: string) {
|
||||||
|
this.list = this.list.filter((n) => n.id !== id);
|
||||||
|
}
|
||||||
|
|
||||||
|
clear() {
|
||||||
|
this.list = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
success(title: string, message?: string, action?: Notice['action']) {
|
||||||
|
return this.add({ type: 'success', title, message, action, dismissible: true });
|
||||||
|
}
|
||||||
|
info(title: string, message?: string, action?: Notice['action']) {
|
||||||
|
return this.add({ type: 'info', title, message, action, dismissible: true });
|
||||||
|
}
|
||||||
|
warning(title: string, message?: string, action?: Notice['action']) {
|
||||||
|
return this.add({ type: 'warning', title, message, action, dismissible: true });
|
||||||
|
}
|
||||||
|
error(title: string, message?: string, action?: Notice['action']) {
|
||||||
|
return this.add({ type: 'error', title, message, action, dismissible: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const notices = new NoticeStore();
|
||||||
|
|
||||||
|
// Fire-and-forget helper — auto-dismisses after `duration` ms.
|
||||||
|
export function addAutoDismissNotice(notice: Omit<Notice, 'id'>, duration = 5000): string {
|
||||||
|
const id = notices.add({ ...notice, dismissible: true });
|
||||||
|
setTimeout(() => notices.remove(id), duration);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
@@ -41,7 +41,6 @@ export interface Fam {
|
|||||||
name: string;
|
name: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
stripeCustomerId?: string;
|
stripeCustomerId?: string;
|
||||||
featureFlags: Record<string, boolean>;
|
|
||||||
payday?: number;
|
payday?: number;
|
||||||
paydayTime?: string;
|
paydayTime?: string;
|
||||||
timezone?: string;
|
timezone?: string;
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import type { LayoutServerLoad } from './$types';
|
||||||
|
import { getPlatformFlags } from '$lib/server/platform';
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
export const load: LayoutServerLoad = async () => {
|
||||||
|
return { platformFlags: await getPlatformFlags() };
|
||||||
|
};
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
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';
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head><link rel="icon" href={favicon} /></svelte:head>
|
<svelte:head><link rel="icon" href={favicon} /></svelte:head>
|
||||||
{@render children()}
|
{@render children()}
|
||||||
|
<NoticeDialog />
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { pbAdmin, createPbClient } from '$lib/server/pocketbase';
|
import { createPbClient } 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';
|
||||||
|
|
||||||
async function paydayCheck(famId: string, pbToken: string) {
|
async function paydayCheck(famId: string, pbToken: string) {
|
||||||
try {
|
try {
|
||||||
@@ -48,19 +49,30 @@ export async function load(event) {
|
|||||||
|
|
||||||
let famId = '';
|
let famId = '';
|
||||||
let chat: { famId: string; actor: ChatActor } | null = null;
|
let chat: { famId: string; actor: ChatActor } | null = null;
|
||||||
|
let fam: any = null;
|
||||||
|
let famAccess = { disabled: false, mode: 'none' as 'none' | 'code' | 'sub' | 'canceled', reason: '' };
|
||||||
|
|
||||||
if (session && pbToken) {
|
if (session && pbToken) {
|
||||||
famId = session.famId;
|
famId = session.famId;
|
||||||
await paydayCheck(famId, pbToken);
|
await paydayCheck(famId, pbToken);
|
||||||
chat = await resolveChatIdentity(session, pbToken);
|
chat = await resolveChatIdentity(session, pbToken);
|
||||||
|
// fams is superadmin-only (non-realtime). Fetched server-side for both
|
||||||
|
// roles; also recomputes + persists the derived `active` flag.
|
||||||
|
const res = await ensureFamAccess(famId).catch(() => null);
|
||||||
|
if (res) {
|
||||||
|
fam = res.fam;
|
||||||
|
famAccess = res.access;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
// Canonical fam slug — from the URL param ([fam] routes). Client code
|
||||||
|
// reads page.data.famSlug; never copy it into local $state.
|
||||||
|
famSlug: event.params.fam || '',
|
||||||
session: session
|
session: session
|
||||||
? {
|
? {
|
||||||
famId: session.famId,
|
famId: session.famId,
|
||||||
userId: session.id,
|
userId: session.id,
|
||||||
famSlug: event.params.fam,
|
|
||||||
memberName: session.name,
|
memberName: session.name,
|
||||||
memberColor: session.color || '',
|
memberColor: session.color || '',
|
||||||
role: session.role
|
role: session.role
|
||||||
@@ -71,9 +83,7 @@ export async function load(event) {
|
|||||||
famId,
|
famId,
|
||||||
chat,
|
chat,
|
||||||
pbToken,
|
pbToken,
|
||||||
// fams is superadmin-only (non-realtime). Fetched server-side for both roles.
|
fam,
|
||||||
fam: famId
|
famAccess
|
||||||
? await pbAdmin.getOne('fams', famId).catch(() => null)
|
|
||||||
: null
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { onMount } from 'svelte';
|
import { invalidateAll } from '$app/navigation';
|
||||||
import { initRealtimePb } from '$lib/pocketbase';
|
import { onDestroy, onMount } from 'svelte';
|
||||||
|
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 { Sidebar, TopNav, Footer, Chat } from '$lib/components';
|
import { Sidebar, TopNav, Footer, Chat } from '$lib/components';
|
||||||
import { chatIcon } from '$lib/components/icons';
|
import { chatIcon } from '$lib/components/icons';
|
||||||
import type { Session } from '$lib/types';
|
import type { Session } from '$lib/types';
|
||||||
@@ -15,6 +17,82 @@
|
|||||||
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 accessReason = $derived(data.famAccess?.reason || '');
|
||||||
|
// Settings stays usable while paused so admins can apply a code / manage billing.
|
||||||
|
let locked = $derived(disabled && !page.url.pathname.endsWith('/settings'));
|
||||||
|
|
||||||
|
// ── Post-checkout activation (event-driven) ──
|
||||||
|
// Landing with ?checkout=return: if the webhook has already landed we show
|
||||||
|
// the welcome notice; otherwise show an "activating" state and wait for the
|
||||||
|
// EVENT — `fams` has viewRule id=@request.auth.famId, so the fam can
|
||||||
|
// realtime-subscribe to its own record. When the Stripe webhook (superuser)
|
||||||
|
// writes paymentMode/active, PB pushes over SSE → single revalidation.
|
||||||
|
let activating = $state(false);
|
||||||
|
let returnHandled = false;
|
||||||
|
let unsubFam: (() => void) | null = null;
|
||||||
|
const ACTIVATION_TIMEOUT_MS = 20_000;
|
||||||
|
|
||||||
|
function handleCheckoutReturn() {
|
||||||
|
if (returnHandled || page.url.searchParams.get('checkout') !== 'return') return;
|
||||||
|
returnHandled = true;
|
||||||
|
history.replaceState({}, '', page.url.pathname); // cosmetic only
|
||||||
|
if (!disabled) {
|
||||||
|
notices.success('Welcome to FamChore!', 'Your subscription is active.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
startActivating();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startActivating() {
|
||||||
|
activating = true;
|
||||||
|
initRealtimePb(data.pbToken || '');
|
||||||
|
try {
|
||||||
|
unsubFam = await pb.collection('fams').subscribe(data.famId, () => {
|
||||||
|
invalidateAll().catch(() => {});
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
/* SSE unavailable — the timeout below still degrades gracefully */
|
||||||
|
}
|
||||||
|
// Safety net only: the event should land within seconds of payment.
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!activating) return;
|
||||||
|
stopActivating();
|
||||||
|
notices.warning(
|
||||||
|
'Still activating',
|
||||||
|
'Payment received — unlocking usually takes a few seconds. Refresh if this persists.'
|
||||||
|
);
|
||||||
|
}, ACTIVATION_TIMEOUT_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopActivating() {
|
||||||
|
activating = false;
|
||||||
|
unsubFam?.();
|
||||||
|
unsubFam = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flip to success the moment the gate lifts (SSE → invalidateAll → data).
|
||||||
|
$effect(() => {
|
||||||
|
if (activating && !disabled) {
|
||||||
|
stopActivating();
|
||||||
|
notices.success('Subscription active!', 'Your family is unlocked.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(stopActivating);
|
||||||
|
|
||||||
|
handleCheckoutReturn();
|
||||||
|
|
||||||
|
function accessMessage(reason: string, parent: boolean) {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
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.',
|
||||||
|
subscription_inactive: parent ? '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.'
|
||||||
|
};
|
||||||
|
return map[reason] || 'Access paused.';
|
||||||
|
}
|
||||||
|
|
||||||
// Claim toast watcher (admin only)
|
// Claim toast watcher (admin only)
|
||||||
let claimToast = $state('');
|
let claimToast = $state('');
|
||||||
@@ -64,7 +142,11 @@
|
|||||||
<div class="layout-stage" class:chat-open={chatStore.open}>
|
<div class="layout-stage" class:chat-open={chatStore.open}>
|
||||||
<div class="app-shell">
|
<div class="app-shell">
|
||||||
<Sidebar {famName} session={data.session} {isParent} {role} />
|
<Sidebar {famName} session={data.session} {isParent} {role} />
|
||||||
<TopNav {role} seasons={famStore.seasons}>
|
<TopNav
|
||||||
|
{role}
|
||||||
|
seasons={famStore.seasons}
|
||||||
|
announcement={disabled && !activating ? accessMessage(accessReason, isParent) : ''}
|
||||||
|
>
|
||||||
<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}
|
||||||
{#if chatStore.unread > 0}
|
{#if chatStore.unread > 0}
|
||||||
@@ -73,7 +155,27 @@
|
|||||||
</button>
|
</button>
|
||||||
</TopNav>
|
</TopNav>
|
||||||
<main class="app-main">
|
<main class="app-main">
|
||||||
{@render children()}
|
<div class="page-wrap">
|
||||||
|
<div class="page-content" class:locked>{@render children()}</div>
|
||||||
|
{#if locked}
|
||||||
|
<div class="disabled-overlay">
|
||||||
|
<div class="disabled-card">
|
||||||
|
{#if activating}
|
||||||
|
<div class="spinner" aria-hidden="true"></div>
|
||||||
|
<strong>Activating your subscription…</strong>
|
||||||
|
<span>Payment received — this usually only takes a few seconds.</span>
|
||||||
|
{:else}
|
||||||
|
<strong>Access paused</strong>
|
||||||
|
<span>
|
||||||
|
{isParent
|
||||||
|
? 'Add an access code or resume your subscription to keep using FamChore.'
|
||||||
|
: 'Your family access is paused.'}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
{#if claimToast}
|
{#if claimToast}
|
||||||
<div class="claim-toast">{claimToast}</div>
|
<div class="claim-toast">{claimToast}</div>
|
||||||
@@ -136,6 +238,60 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
transition: margin-left 0.2s;
|
transition: margin-left 0.2s;
|
||||||
}
|
}
|
||||||
|
.page-wrap {
|
||||||
|
position: relative;
|
||||||
|
min-height: 70vh;
|
||||||
|
}
|
||||||
|
.page-content.locked {
|
||||||
|
filter: blur(3px);
|
||||||
|
pointer-events: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.disabled-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
.disabled-card {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #fca5a5;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1.25rem 1.75rem;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
.disabled-card strong {
|
||||||
|
color: #b91c1c;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
.disabled-card .spinner {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
margin: 0 auto;
|
||||||
|
border: 3px solid #e0e7ff;
|
||||||
|
border-top-color: #6366f1;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
.disabled-card:has(.spinner) strong {
|
||||||
|
color: #4338ca;
|
||||||
|
}
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled-card span {
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
.chat-toggle {
|
.chat-toggle {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 40px;
|
width: 40px;
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
Chart.register(...registerables);
|
Chart.register(...registerables);
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
let famSlug = $derived(page.params.fam);
|
let famSlug = $derived(page.data.famSlug ?? page.params.fam);
|
||||||
|
|
||||||
let summary = $state(data.summary);
|
let summary = $state(data.summary);
|
||||||
let members = $state(data.members || []);
|
let members = $state(data.members || []);
|
||||||
|
|||||||
@@ -23,9 +23,13 @@
|
|||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
|
|
||||||
let role = $state(data.role || 'child');
|
let role = $state(data.role || 'child');
|
||||||
let famSlug = $derived(page.params.fam);
|
let famSlug = $derived(page.data.famSlug ?? page.params.fam);
|
||||||
let username = $derived(page.params.username);
|
let username = $derived(page.params.username);
|
||||||
|
|
||||||
|
// Access gate (frontend-only). When the fam is paused/disabled the member
|
||||||
|
// kanban interactions are locked — toggles no-op and the board renders empty.
|
||||||
|
let accessDisabled = $derived(!!page.data.famAccess?.disabled);
|
||||||
|
|
||||||
// ─── Family timezone (resolved) ───
|
// ─── Family timezone (resolved) ───
|
||||||
const rawFamTz = $derived(data.timezone || data.fam?.timezone || 'auto');
|
const rawFamTz = $derived(data.timezone || data.fam?.timezone || 'auto');
|
||||||
const famTz = $derived(resolveTz(rawFamTz));
|
const famTz = $derived(resolveTz(rawFamTz));
|
||||||
@@ -521,6 +525,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function toggle(chore: AssignedChore) {
|
async function toggle(chore: AssignedChore) {
|
||||||
|
if (accessDisabled) return;
|
||||||
if (togglingIds) return;
|
if (togglingIds) return;
|
||||||
togglingIds = chore.id;
|
togglingIds = chore.id;
|
||||||
|
|
||||||
@@ -1122,6 +1127,22 @@ const res = await fetch('/api/members', {
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- KANBAN -->
|
<!-- KANBAN -->
|
||||||
|
{#if accessDisabled}
|
||||||
|
<div class="kanban locked">
|
||||||
|
<div class="column col-daily">
|
||||||
|
<h2>🎯 Daily</h2>
|
||||||
|
<p class="empty">🔒 Access paused</p>
|
||||||
|
</div>
|
||||||
|
<div class="column col-weekly">
|
||||||
|
<h2>📅 Weekly</h2>
|
||||||
|
<p class="empty">🔒</p>
|
||||||
|
</div>
|
||||||
|
<div class="column col-done">
|
||||||
|
<h2>✅ Done</h2>
|
||||||
|
<p class="empty">🔒</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
<div class="kanban">
|
<div class="kanban">
|
||||||
<div class="column col-daily">
|
<div class="column col-daily">
|
||||||
<h2>🎯 Daily ({dailyPending.length})</h2>
|
<h2>🎯 Daily ({dailyPending.length})</h2>
|
||||||
@@ -1205,6 +1226,7 @@ const res = await fetch('/api/members', {
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- WALLET / CLAIMS -->
|
<!-- WALLET / CLAIMS -->
|
||||||
<div class="wallet">
|
<div class="wallet">
|
||||||
|
|||||||
@@ -86,7 +86,6 @@
|
|||||||
return map;
|
return map;
|
||||||
});
|
});
|
||||||
|
|
||||||
const famId = $derived(page.params.fam);
|
|
||||||
const username = $derived(page.params.username);
|
const username = $derived(page.params.username);
|
||||||
|
|
||||||
function memberName(id: string): string {
|
function memberName(id: string): string {
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { redirect } 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 } 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 } from '$lib/server/member-otp';
|
||||||
import { slugify } from '@shared/slugify';
|
import { slugify } from '@shared/slugify';
|
||||||
|
import { applyAccessCode } from '$lib/server/access';
|
||||||
|
import { createBillingPortalSession, isDummyStripe } 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');
|
||||||
@@ -21,7 +23,12 @@ export async function load(event: RequestEvent) {
|
|||||||
pbAdmin.getOne('fams', famId),
|
pbAdmin.getOne('fams', famId),
|
||||||
pb.collection('seasons').getFullList({ filter: `famId = '${famId}'` })
|
pb.collection('seasons').getFullList({ filter: `famId = '${famId}'` })
|
||||||
]);
|
]);
|
||||||
return { members, fam, seasons };
|
// The applied code's duration backs the Access-card countdown (0 = never).
|
||||||
|
let accessCode: any = null;
|
||||||
|
if (fam?.paymentMode === 'code' && fam?.accessCodeId) {
|
||||||
|
accessCode = await pbAdmin.getOne('accesscodes', fam.accessCodeId).catch(() => null);
|
||||||
|
}
|
||||||
|
return { members, fam, seasons, accessCode };
|
||||||
}
|
}
|
||||||
|
|
||||||
export const actions = {
|
export const actions = {
|
||||||
@@ -147,5 +154,44 @@ export const actions = {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
return { error: e instanceof Error ? e.message : 'Failed to generate data' };
|
return { error: e instanceof Error ? e.message : 'Failed to generate data' };
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
applyCode: async (event: RequestEvent) => {
|
||||||
|
const famId = famIdOf(event);
|
||||||
|
const fd = await event.request.formData();
|
||||||
|
const code = (fd.get('code') as string) || '';
|
||||||
|
const result = await applyAccessCode(famId, code);
|
||||||
|
return result.error ? { error: result.error } : { ok: true, ...result };
|
||||||
|
},
|
||||||
|
|
||||||
|
// Revoke the applied access code — sets paymentMode back to 'none'.
|
||||||
|
// No debug flag gate; the button is only visible when a code is applied.
|
||||||
|
revokeCode: async (event: RequestEvent) => {
|
||||||
|
const famId = famIdOf(event);
|
||||||
|
await pbAdmin.update('fams', famId, {
|
||||||
|
paymentMode: 'none',
|
||||||
|
accessCodeId: '',
|
||||||
|
accessCodeEnteredAt: ''
|
||||||
|
});
|
||||||
|
return { ok: true, revoked: true };
|
||||||
|
},
|
||||||
|
|
||||||
|
// Open Stripe Customer Portal (cancel subscription, update payment method,
|
||||||
|
// invoices). Real mode redirects; dummy mode returns the URL for the client.
|
||||||
|
billingPortal: async (event: RequestEvent) => {
|
||||||
|
const famId = famIdOf(event);
|
||||||
|
const fam = await pbAdmin.getOne('fams', famId);
|
||||||
|
if (!fam.stripeCustomerId) {
|
||||||
|
return fail(400, { error: 'No Stripe customer linked yet. Start with a plan first.' });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const session = await createBillingPortalSession(fam.stripeCustomerId, event.url.origin, event.params.fam as string);
|
||||||
|
if (isDummyStripe) return { success: true, portalUrl: session.url };
|
||||||
|
if (!session.url) return fail(500, { error: 'Billing portal session has no URL' });
|
||||||
|
throw redirect(303, session.url);
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof redirect) throw e;
|
||||||
|
return fail(500, { error: e instanceof Error ? e.message : 'Failed to open billing portal' });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -2,18 +2,34 @@
|
|||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
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, Accordion, AccordionItem, NoticeDialog } from '$lib/components';
|
||||||
import { COMMON_TIMEZONES } from '@shared/timezone';
|
import { COMMON_TIMEZONES } from '@shared/timezone';
|
||||||
import { handleOf } from '@shared/slugify';
|
import { handleOf } from '@shared/slugify';
|
||||||
|
import { addMonthsUTC, formatShortDate } from '$lib/format';
|
||||||
import QRCode from 'qrcode';
|
import QRCode from 'qrcode';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
|
|
||||||
// fam is sensitive (stripeCustomerId, featureFlags) — never in the
|
// fam is sensitive (stripeCustomerId, access fields) — never in the
|
||||||
// public famStore stream. It is superadmin-only, fetched server-side by the
|
// public famStore stream. It is superadmin-only, fetched server-side by the
|
||||||
// layout load. Writes go through form actions; no live fam subscription.
|
// layout load. Writes go through form actions; no live fam subscription.
|
||||||
let fam = $state(data.fam);
|
// Derived so apply/revoke reflect immediately after the action round trip.
|
||||||
let famSlug = $state(page.params.fam);
|
let fam = $derived(data.fam);
|
||||||
|
// Canonical slug from layout load — reactive, never copied into $state.
|
||||||
|
let famSlug = $derived(page.data.famSlug || page.params.fam);
|
||||||
|
|
||||||
|
let accessCodeInput = $state('');
|
||||||
|
let accessMsg = $state('');
|
||||||
|
|
||||||
|
let hasCode = $derived(fam?.paymentMode === 'code' && !!fam?.accessCodeId);
|
||||||
|
let codeEntryDate = $derived(fam?.accessCodeEnteredAt ? new Date(fam.accessCodeEnteredAt) : null);
|
||||||
|
|
||||||
|
const modeLabel: Record<string, string> = {
|
||||||
|
code: 'Access code',
|
||||||
|
sub: 'Subscription',
|
||||||
|
canceled: 'Canceled',
|
||||||
|
none: 'No plan yet'
|
||||||
|
};
|
||||||
|
|
||||||
let addName = $state('');
|
let addName = $state('');
|
||||||
let rename = $state('');
|
let rename = $state('');
|
||||||
@@ -21,23 +37,9 @@
|
|||||||
let payday = $state(fam?.payday != null ? Number(fam.payday) : 1);
|
let payday = $state(fam?.payday != null ? Number(fam.payday) : 1);
|
||||||
let paydayTime = $state(fam?.paydayTime || '18:00');
|
let paydayTime = $state(fam?.paydayTime || '18:00');
|
||||||
let paydayTimes = $state([
|
let paydayTimes = $state([
|
||||||
'06:00',
|
'06:00', '07:00', '08:00', '09:00', '10:00', '11:00', '12:00',
|
||||||
'07:00',
|
'13:00', '14:00', '15:00', '16:00', '17:00', '18:00', '19:00',
|
||||||
'08:00',
|
'20:00', '21:00', '22:00'
|
||||||
'09:00',
|
|
||||||
'10:00',
|
|
||||||
'11:00',
|
|
||||||
'12:00',
|
|
||||||
'13:00',
|
|
||||||
'14:00',
|
|
||||||
'15:00',
|
|
||||||
'16:00',
|
|
||||||
'17:00',
|
|
||||||
'18:00',
|
|
||||||
'19:00',
|
|
||||||
'20:00',
|
|
||||||
'21:00',
|
|
||||||
'22:00'
|
|
||||||
]);
|
]);
|
||||||
let timezone = $state(fam?.timezone || 'auto');
|
let timezone = $state(fam?.timezone || 'auto');
|
||||||
let timezoneOptions = $state(COMMON_TIMEZONES);
|
let timezoneOptions = $state(COMMON_TIMEZONES);
|
||||||
@@ -46,9 +48,7 @@
|
|||||||
if (typeof Intl !== 'undefined') {
|
if (typeof Intl !== 'undefined') {
|
||||||
try {
|
try {
|
||||||
detectedTz = Intl.DateTimeFormat().resolvedOptions().timeZone || '';
|
detectedTz = Intl.DateTimeFormat().resolvedOptions().timeZone || '';
|
||||||
} catch {
|
} catch { detectedTz = ''; }
|
||||||
detectedTz = '';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
let showQR = $state(false);
|
let showQR = $state(false);
|
||||||
@@ -60,12 +60,8 @@
|
|||||||
let deletingSeason = $state<any>(null);
|
let deletingSeason = $state<any>(null);
|
||||||
let issued = $state<{ otp: string; joinUrl: string; name: string } | null>(null);
|
let issued = $state<{ otp: string; joinUrl: string; name: string } | null>(null);
|
||||||
|
|
||||||
let invitePath = $derived(
|
let invitePath = $derived(issued ? `${issued.joinUrl}?code=${issued.otp}` : '');
|
||||||
issued ? `${issued.joinUrl}?code=${issued.otp}` : ''
|
let inviteUrl = $derived(issued ? `${page.url.origin}${invitePath}` : '');
|
||||||
);
|
|
||||||
let inviteUrl = $derived(
|
|
||||||
issued ? `${page.url.origin}${invitePath}` : ''
|
|
||||||
);
|
|
||||||
|
|
||||||
function copy(url: string) {
|
function copy(url: string) {
|
||||||
navigator.clipboard.writeText(url);
|
navigator.clipboard.writeText(url);
|
||||||
@@ -79,304 +75,368 @@
|
|||||||
|
|
||||||
function toggleQR() {
|
function toggleQR() {
|
||||||
showQR = !showQR;
|
showQR = !showQR;
|
||||||
if (!showQR) {
|
if (!showQR) qrDataUrl = '';
|
||||||
qrDataUrl = '';
|
else generateQR(inviteUrl);
|
||||||
} else {
|
|
||||||
generateQR(inviteUrl);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleParentInvite() {
|
function handleParentInvite() {
|
||||||
alert('Parent invite coming soon — email would be sent to ' + parentInviteEmail);
|
alert('Parent invite coming soon — email would be sent to ' + parentInviteEmail);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatCountdown(entryDate: Date | null, durationMonths: number): string {
|
||||||
|
if (!entryDate) return '';
|
||||||
|
if (durationMonths === 0) return 'Never expires';
|
||||||
|
const expiry = addMonthsUTC(entryDate, durationMonths);
|
||||||
|
const now = new Date();
|
||||||
|
const diffMs = expiry.getTime() - now.getTime();
|
||||||
|
if (diffMs <= 0) return 'Expired';
|
||||||
|
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||||
|
if (diffDays < 30) return `${diffDays} day${diffDays === 1 ? '' : 's'} left`;
|
||||||
|
const months = Math.floor(diffDays / 30);
|
||||||
|
const remDays = diffDays % 30;
|
||||||
|
return `${months} month${months === 1 ? '' : 's'}${remDays ? ` ${remDays} day${remDays === 1 ? '' : 's'}` : ''} left`;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<ViewHeader title="Settings" hero />
|
<ViewHeader title="Settings" hero />
|
||||||
|
|
||||||
<CardGrid>
|
<Accordion>
|
||||||
<Card title="Family Name">
|
<!-- Family -->
|
||||||
<p class="hint">
|
<AccordionItem title="Family" open>
|
||||||
This is the name shown to your family. The address stays at
|
<CardGrid>
|
||||||
<code class="slug-inline">/{famSlug}</code> even if you rename it — links you've shared keep
|
<Card title="Family Name">
|
||||||
working.
|
<p class="hint">
|
||||||
</p>
|
This is the name shown to your family. The address stays at
|
||||||
<form method="POST" action="?/renameFam" use:enhance>
|
<code class="slug-inline">/{famSlug}</code> even if you rename it — links you've shared keep
|
||||||
<label class="field-label" for="fam-name">Display name</label>
|
working.
|
||||||
<input id="fam-name" name="name" bind:value={rename} placeholder={fam?.name || 'Family name'} required />
|
</p>
|
||||||
<Button type="submit" size="sm">Rename</Button>
|
<form method="POST" action="?/renameFam" use:enhance>
|
||||||
</form>
|
<label class="field-label" for="fam-name">Display name</label>
|
||||||
{#if fam?.slug}
|
<input id="fam-name" name="name" bind:value={rename} placeholder={fam?.name || 'Family name'} required />
|
||||||
<p class="hint slug-line">
|
<Button type="submit" size="sm">Rename</Button>
|
||||||
Family page: <code class="slug-inline">/{fam.slug}</code>
|
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card title="Members ({members.length})" cols={2}>
|
|
||||||
<div class="members-grid">
|
|
||||||
<div class="members-add">
|
|
||||||
<p class="hint">Add a child. They'll pick their own colour after joining.</p>
|
|
||||||
<form method="POST" action="?/addMember" use:enhance>
|
|
||||||
<label class="field-label" for="new-child">New child</label>
|
|
||||||
<input id="new-child" name="name" bind:value={addName} placeholder="Child name" required />
|
|
||||||
<Button type="submit" size="sm">Add child</Button>
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
{#if fam?.slug}
|
||||||
|
<p class="hint slug-line">
|
||||||
<ul class="members-list">
|
Family page: <code class="slug-inline">/{fam.slug}</code>
|
||||||
{#each members as m}
|
</p>
|
||||||
<li>
|
|
||||||
<span class="member-left">
|
|
||||||
<span class="member-color" style="background:{m.color}"></span>
|
|
||||||
<span class="member-info">
|
|
||||||
<span class="member-name">{m.name}</span>
|
|
||||||
<span class="member-handle">/{famSlug}/{handleOf(m.username)}</span>
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<span class="member-actions">
|
|
||||||
<Button href="/{famSlug}/{handleOf(m.username)}" variant="secondary" size="sm">Preview</Button>
|
|
||||||
<form method="POST" action="?/deleteMember" use:enhance class="inline">
|
|
||||||
<input type="hidden" name="id" value={m.id} />
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
variant="danger"
|
|
||||||
size="sm"
|
|
||||||
onclick={() => confirm('Remove {m.name}?')}>Remove</Button
|
|
||||||
>
|
|
||||||
</form>
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card title="Payday" cols={1}>
|
|
||||||
<form
|
|
||||||
method="POST"
|
|
||||||
action="?/updatePayday"
|
|
||||||
use:enhance={() => {
|
|
||||||
return async ({ result }) => {
|
|
||||||
if (result.type === 'error') {
|
|
||||||
alert(result.error || 'Failed to update payday');
|
|
||||||
}
|
|
||||||
// One-way: no invalidation — local state is already correct,
|
|
||||||
// and the PB subscription handles cross-device sync.
|
|
||||||
};
|
|
||||||
}}
|
|
||||||
class="payday-form"
|
|
||||||
>
|
|
||||||
<div class="payday-row">
|
|
||||||
<label>Day</label>
|
|
||||||
<select name="payday" bind:value={payday}>
|
|
||||||
<option value={0}>Sunday</option>
|
|
||||||
<option value={1}>Monday</option>
|
|
||||||
<option value={2}>Tuesday</option>
|
|
||||||
<option value={3}>Wednesday</option>
|
|
||||||
<option value={4}>Thursday</option>
|
|
||||||
<option value={5}>Friday</option>
|
|
||||||
<option value={6}>Saturday</option>
|
|
||||||
</select>
|
|
||||||
<label>Time</label>
|
|
||||||
<select name="paydayTime" bind:value={paydayTime}>
|
|
||||||
{#each paydayTimes as t}
|
|
||||||
<option value={t}>{t}</option>
|
|
||||||
{/each}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="payday-row">
|
|
||||||
<label>Timezone</label>
|
|
||||||
<select name="timezone" bind:value={timezone}>
|
|
||||||
<option value="auto">{detectedTz ? `Auto (${detectedTz})` : 'Auto'}</option>
|
|
||||||
{#each timezoneOptions as tz}
|
|
||||||
<option value={tz}>{tz}</option>
|
|
||||||
{/each}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<Button type="submit" size="sm">Set payday</Button>
|
|
||||||
</form>
|
|
||||||
<p class="hint">
|
|
||||||
Payday: the week starts on this day and weekly earnings are settled at this time. Auto timezone
|
|
||||||
follows each device.
|
|
||||||
</p>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card title="Invite Children" cols={1}>
|
|
||||||
<form
|
|
||||||
method="POST"
|
|
||||||
action="?/issueAccess"
|
|
||||||
use:enhance={() => {
|
|
||||||
return async ({ formData, result }) => {
|
|
||||||
if (result.type === 'success' && result.data?.ok) {
|
|
||||||
showQR = false;
|
|
||||||
qrDataUrl = '';
|
|
||||||
issued = {
|
|
||||||
otp: result.data.otp,
|
|
||||||
joinUrl: result.data.joinUrl,
|
|
||||||
name: String(formData.get('name') || '')
|
|
||||||
};
|
|
||||||
} else if (result.type === 'success' && result.data?.error) {
|
|
||||||
alert(result.data.error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}}
|
|
||||||
class="invite-form"
|
|
||||||
>
|
|
||||||
<label class="field-label" for="invite-child">Child</label>
|
|
||||||
<select id="invite-child" bind:value={inviteChild} name="name" required>
|
|
||||||
<option value="">— Select a child —</option>
|
|
||||||
{#each members as m}
|
|
||||||
<option value={m.name}>{m.name}</option>
|
|
||||||
{/each}
|
|
||||||
</select>
|
|
||||||
<Button type="submit" size="sm" disabled={!inviteChild}>Issue code</Button>
|
|
||||||
</form>
|
|
||||||
<p class="hint">
|
|
||||||
Generates a 6-digit code valid for 20 minutes. The child enters it at the join link.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{#if issued?.otp}
|
|
||||||
<div class="mt-3 rounded-lg border border-indigo-200 bg-indigo-50 p-4">
|
|
||||||
<p class="text-xs text-slate-500">
|
|
||||||
Code for {issued.name} (valid 20 min):
|
|
||||||
</p>
|
|
||||||
<p class="my-2 text-center text-4xl font-bold tracking-[0.3em] text-indigo-700">
|
|
||||||
{issued.otp}
|
|
||||||
</p>
|
|
||||||
<p class="invite-url">{invitePath}</p>
|
|
||||||
<div class="actions justify-center">
|
|
||||||
<Button variant="secondary" size="sm" onclick={() => copy(inviteUrl)}>
|
|
||||||
{copied ? 'Copied!' : 'Copy URL'}
|
|
||||||
</Button>
|
|
||||||
<Button variant="secondary" size="sm" onclick={toggleQR}>
|
|
||||||
{showQR ? 'Hide QR' : 'Show QR'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{#if showQR && qrDataUrl}
|
|
||||||
<div class="qr-wrap">
|
|
||||||
<img src={qrDataUrl} alt="QR Code" class="qr" />
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</Card>
|
||||||
{/if}
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card title="Invite Parent" cols={1}>
|
<Card title="Payday" cols={1}>
|
||||||
<p class="hint">Send an email invitation for another parent to join as an admin.</p>
|
|
||||||
<div class="invite-form">
|
|
||||||
<label class="field-label" for="parent-email">Parent email</label>
|
|
||||||
<input id="parent-email" type="email" bind:value={parentInviteEmail} placeholder="parent@example.com" />
|
|
||||||
<Button onclick={handleParentInvite} size="sm">Send invite</Button>
|
|
||||||
</div>
|
|
||||||
<p class="hint">They will set up their own password on first login.</p>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card title="Seasons" cols={1}>
|
|
||||||
<p class="hint">Group chores into seasons. Toggle seasons on/off from the top nav.</p>
|
|
||||||
|
|
||||||
<form method="POST" action="?/createSeason" use:enhance class="season-form">
|
|
||||||
<label class="field-label" for="season-name">New season</label>
|
|
||||||
<input id="season-name" name="name" placeholder="Season name" required />
|
|
||||||
<div class="color-row">
|
|
||||||
<label for="season-color">Colour</label>
|
|
||||||
<input id="season-color" name="color" type="color" value="#6366f1" class="color-input" />
|
|
||||||
</div>
|
|
||||||
<Button type="submit" size="sm">Add</Button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<ul>
|
|
||||||
{#each data.seasons as s}
|
|
||||||
<li>
|
|
||||||
<span class="dot" style="background:{s.color}"></span>
|
|
||||||
{s.name}
|
|
||||||
<Button variant="danger" size="sm" onclick={() => (deletingSeason = s)}>Remove</Button>
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<!-- Delete Season Modal -->
|
|
||||||
{#if deletingSeason}
|
|
||||||
<div class="overlay" onclick={() => (deletingSeason = null)} role="presentation">
|
|
||||||
<div class="modal" onclick={(e) => e.stopPropagation()} role="dialog">
|
|
||||||
<h3>Delete "{deletingSeason.name}"?</h3>
|
|
||||||
<p class="warning">
|
|
||||||
All chores assigned to this season will also be removed. This cannot be undone.
|
|
||||||
</p>
|
|
||||||
<form
|
<form
|
||||||
method="POST"
|
method="POST"
|
||||||
action="?/deleteSeason"
|
action="?/updatePayday"
|
||||||
use:enhance={() => {
|
use:enhance={() => {
|
||||||
return async ({ result }) => {
|
return async ({ result }) => {
|
||||||
if (result.type === 'success') {
|
if (result.type === 'error') alert(result.error || 'Failed to update payday');
|
||||||
deletingSeason = null;
|
};
|
||||||
|
}}
|
||||||
|
class="payday-form"
|
||||||
|
>
|
||||||
|
<div class="payday-row">
|
||||||
|
<label>Day</label>
|
||||||
|
<select name="payday" bind:value={payday}>
|
||||||
|
<option value={0}>Sunday</option>
|
||||||
|
<option value={1}>Monday</option>
|
||||||
|
<option value={2}>Tuesday</option>
|
||||||
|
<option value={3}>Wednesday</option>
|
||||||
|
<option value={4}>Thursday</option>
|
||||||
|
<option value={5}>Friday</option>
|
||||||
|
<option value={6}>Saturday</option>
|
||||||
|
</select>
|
||||||
|
<label>Time</label>
|
||||||
|
<select name="paydayTime" bind:value={paydayTime}>
|
||||||
|
{#each paydayTimes as t}
|
||||||
|
<option value={t}>{t}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="payday-row">
|
||||||
|
<label>Timezone</label>
|
||||||
|
<select name="timezone" bind:value={timezone}>
|
||||||
|
<option value="auto">{detectedTz ? `Auto (${detectedTz})` : 'Auto'}</option>
|
||||||
|
{#each timezoneOptions as tz}
|
||||||
|
<option value={tz}>{tz}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" size="sm">Set payday</Button>
|
||||||
|
</form>
|
||||||
|
<p class="hint">
|
||||||
|
Payday: the week starts on this day and weekly earnings are settled at this time. Auto timezone
|
||||||
|
follows each device.
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
<Card title="Seasons" cols={1}>
|
||||||
|
<p class="hint">Group chores into seasons. Toggle seasons on/off from the top nav.</p>
|
||||||
|
<form method="POST" action="?/createSeason" use:enhance class="season-form">
|
||||||
|
<label class="field-label" for="season-name">New season</label>
|
||||||
|
<input id="season-name" name="name" placeholder="Season name" required />
|
||||||
|
<div class="color-row">
|
||||||
|
<label for="season-color">Colour</label>
|
||||||
|
<input id="season-color" name="color" type="color" value="#6366f1" class="color-input" />
|
||||||
|
</div>
|
||||||
|
<Button type="submit" size="sm">Add</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
{#each data.seasons as s}
|
||||||
|
<li>
|
||||||
|
<span class="dot" style="background:{s.color}"></span>
|
||||||
|
{s.name}
|
||||||
|
<Button variant="danger" size="sm" onclick={() => (deletingSeason = s)}>Remove</Button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- Delete Season Modal -->
|
||||||
|
{#if deletingSeason}
|
||||||
|
<div class="overlay" onclick={() => (deletingSeason = null)} role="presentation">
|
||||||
|
<div class="modal" onclick={(e) => e.stopPropagation()} role="dialog">
|
||||||
|
<h3>Delete "{deletingSeason.name}"?</h3>
|
||||||
|
<p class="warning">All chores assigned to this season will also be removed. This cannot be undone.</p>
|
||||||
|
<form method="POST" action="?/deleteSeason" use:enhance={() => {
|
||||||
|
return async ({ result }) => { if (result.type === 'success') deletingSeason = null; };
|
||||||
|
}}>
|
||||||
|
<input type="hidden" name="id" value={deletingSeason.id} />
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button type="button" onclick={() => (deletingSeason = null)}>Cancel</button>
|
||||||
|
<button type="submit" class="danger">Delete Season</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</CardGrid>
|
||||||
|
</AccordionItem>
|
||||||
|
|
||||||
|
<AccordionItem title="Invites">
|
||||||
|
<CardGrid>
|
||||||
|
<Card title="Members ({members.length})" cols={2}>
|
||||||
|
<div class="members-grid">
|
||||||
|
<div class="members-add">
|
||||||
|
<p class="hint">Add a child. They'll pick their own colour after joining.</p>
|
||||||
|
<form method="POST" action="?/addMember" use:enhance>
|
||||||
|
<label class="field-label" for="new-child">New child</label>
|
||||||
|
<input id="new-child" name="name" bind:value={addName} placeholder="Child name" required />
|
||||||
|
<Button type="submit" size="sm">Add child</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul class="members-list">
|
||||||
|
{#each members as m}
|
||||||
|
<li>
|
||||||
|
<span class="member-left">
|
||||||
|
<span class="member-color" style="background:{m.color}"></span>
|
||||||
|
<span class="member-info">
|
||||||
|
<span class="member-name">{m.name}</span>
|
||||||
|
<span class="member-handle">/{famSlug}/{handleOf(m.username)}</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span class="member-actions">
|
||||||
|
<Button href="/{famSlug}/{handleOf(m.username)}" variant="secondary" size="sm">Preview</Button>
|
||||||
|
<form method="POST" action="?/deleteMember" use:enhance class="inline">
|
||||||
|
<input type="hidden" name="id" value={m.id} />
|
||||||
|
<Button type="submit" variant="danger" size="sm" onclick={() => confirm('Remove {m.name}?')}>Remove</Button>
|
||||||
|
</form>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="Invite Children" cols={1}>
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/issueAccess"
|
||||||
|
use:enhance={() => {
|
||||||
|
return async ({ formData, result }) => {
|
||||||
|
if (result.type === 'success' && result.data?.ok) {
|
||||||
|
showQR = false;
|
||||||
|
qrDataUrl = '';
|
||||||
|
issued = {
|
||||||
|
otp: result.data.otp,
|
||||||
|
joinUrl: result.data.joinUrl,
|
||||||
|
name: String(formData.get('name') || '')
|
||||||
|
};
|
||||||
|
} else if (result.type === 'success' && result.data?.error) {
|
||||||
|
alert(result.data.error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}}
|
}}
|
||||||
|
class="invite-form"
|
||||||
>
|
>
|
||||||
<input type="hidden" name="id" value={deletingSeason.id} />
|
<label class="field-label" for="invite-child">Child</label>
|
||||||
<div class="modal-actions">
|
<select id="invite-child" bind:value={inviteChild} name="name" required>
|
||||||
<button type="button" onclick={() => (deletingSeason = null)}>Cancel</button>
|
<option value="">— Select a child —</option>
|
||||||
<button type="submit" class="danger">Delete Season</button>
|
{#each members as m}
|
||||||
|
<option value={m.name}>{m.name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
<Button type="submit" size="sm" disabled={!inviteChild}>Issue code</Button>
|
||||||
|
</form>
|
||||||
|
<p class="hint">
|
||||||
|
Generates a 6-digit code valid for 20 minutes. The child enters it at the join link.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{#if issued?.otp}
|
||||||
|
<div class="mt-3 rounded-lg border border-indigo-200 bg-indigo-50 p-4">
|
||||||
|
<p class="text-xs text-slate-500">Code for {issued.name} (valid 20 min):</p>
|
||||||
|
<p class="my-2 text-center text-4xl font-bold tracking-[0.3em] text-indigo-700">{issued.otp}</p>
|
||||||
|
<p class="invite-url">{invitePath}</p>
|
||||||
|
<div class="actions justify-center">
|
||||||
|
<Button variant="secondary" size="sm" onclick={() => copy(inviteUrl)}>{copied ? 'Copied!' : 'Copy URL'}</Button>
|
||||||
|
<Button variant="secondary" size="sm" onclick={toggleQR}>{showQR ? 'Hide QR' : 'Show QR'}</Button>
|
||||||
|
</div>
|
||||||
|
{#if showQR && qrDataUrl}
|
||||||
|
<div class="qr-wrap"><img src={qrDataUrl} alt="QR Code" class="qr" /></div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
{/if}
|
||||||
</div>
|
</Card>
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<Card title="Billing & Subscription" cols={1}>
|
<Card title="Invite Parent" cols={1}>
|
||||||
<p class="hint">
|
<p class="hint">Send an email invitation for another parent to join as an admin.</p>
|
||||||
{data.fam?.active === false
|
<div class="invite-form">
|
||||||
? 'Your subscription is paused — interactions and payments are disabled.'
|
<label class="field-label" for="parent-email">Parent email</label>
|
||||||
: 'Manage your plan, billing details, or pause your subscription.'}
|
<input id="parent-email" type="email" bind:value={parentInviteEmail} placeholder="parent@example.com" />
|
||||||
</p>
|
<Button onclick={handleParentInvite} size="sm">Send invite</Button>
|
||||||
<div class="actions">
|
</div>
|
||||||
<Button href="/account" variant="primary" size="sm">Account & Billing</Button>
|
<p class="hint">They will set up their own password on first login.</p>
|
||||||
<Button href="/subscriptions" variant="secondary" size="sm">Plans</Button>
|
</Card>
|
||||||
</div>
|
</CardGrid>
|
||||||
</Card>
|
</AccordionItem>
|
||||||
|
|
||||||
<Card title="Data" cols={1}>
|
<AccordionItem title="Account">
|
||||||
<div class="actions">
|
<CardGrid>
|
||||||
<Button variant="ghost" size="sm" disabled>Download CSV (coming soon)</Button>
|
<!-- Access -->
|
||||||
<Button variant="danger" size="sm" disabled>Delete Family (coming soon)</Button>
|
<Card title="Access" cols={1} accent={hasCode ? '#059669' : '#dc2626'}>
|
||||||
</div>
|
{#if hasCode}
|
||||||
</Card>
|
<p class="hint">
|
||||||
|
Access active via access code
|
||||||
{#if data.fam?.featureFlags?.debugMode}
|
{#if data.accessCode?.duration}
|
||||||
<Card title="Debug Tools" cols={1} accent="#f59e0b">
|
— <strong>{formatCountdown(codeEntryDate, data.accessCode.duration)}</strong>
|
||||||
<p class="hint">Debug mode is enabled. These tools are for development and testing only.</p>
|
(expires {formatShortDate(addMonthsUTC(codeEntryDate!, data.accessCode.duration))})
|
||||||
<div class="actions">
|
{:else}
|
||||||
<form
|
— <strong>never expires</strong>
|
||||||
method="POST"
|
{/if}.
|
||||||
action="?/completeWeek"
|
</p>
|
||||||
use:enhance={() => {
|
<form
|
||||||
|
class="revoke-form"
|
||||||
|
method="POST"
|
||||||
|
action="?/revokeCode"
|
||||||
|
use:enhance={() => {
|
||||||
|
return async ({ result, update }) => {
|
||||||
|
if (result.type === 'success') accessMsg = 'Access code revoked.';
|
||||||
|
else if (result.type === 'failure') accessMsg = (result.data as any)?.error || 'Revoke failed.';
|
||||||
|
await update();
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button type="submit" size="sm" variant="danger">Revoke code</Button>
|
||||||
|
</form>
|
||||||
|
{:else}
|
||||||
|
<p class="hint">No access applied. Enter a valid access code to enable your family.</p>
|
||||||
|
<form method="POST" action="?/applyCode" use:enhance={() => {
|
||||||
return async ({ result, update }) => {
|
return async ({ result, update }) => {
|
||||||
if (result.type === 'success') alert('Week completed!');
|
if (result.type === 'success' && result.data) {
|
||||||
|
const d = result.data as { error?: string; ok?: boolean };
|
||||||
|
accessMsg = d.error || 'Code applied — access enabled.';
|
||||||
|
accessCodeInput = '';
|
||||||
|
}
|
||||||
await update();
|
await update();
|
||||||
};
|
};
|
||||||
}}
|
}}>
|
||||||
>
|
<input name="code" bind:value={accessCodeInput} placeholder="Enter access code" autocomplete="off" />
|
||||||
<Button type="submit" size="sm" variant="secondary">Complete Week</Button>
|
<Button type="submit" size="sm" variant="primary">Apply code</Button>
|
||||||
</form>
|
</form>
|
||||||
<form
|
{/if}
|
||||||
method="POST"
|
{#if accessMsg}
|
||||||
action="?/generateData"
|
<p class="access-msg">{accessMsg}</p>
|
||||||
use:enhance={() => {
|
{/if}
|
||||||
return async ({ result, update }) => {
|
</Card>
|
||||||
if (result.type === 'success') alert('Test data generated!');
|
|
||||||
await update();
|
|
||||||
};
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<input type="hidden" name="days" value="7" />
|
|
||||||
<Button type="submit" size="sm" variant="secondary">Generate Test Data (7 days)</Button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
{/if}
|
|
||||||
</CardGrid>
|
|
||||||
|
|
||||||
|
<Card title="Subscription" cols={1} accent={fam?.paymentMode === 'sub' ? '#059669' : '#f59e0b'}>
|
||||||
|
<p class="hint">
|
||||||
|
{fam?.paymentMode === 'sub' ? 'Current plan: Subscription.' :
|
||||||
|
fam?.paymentMode === 'code' ? `Current plan: Access code${hasCode ? '' : ' (invalid)'}.` :
|
||||||
|
fam?.paymentMode === 'canceled' ? 'Your subscription was canceled.' : 'No active plan.'}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{#if fam?.paymentMode === 'sub'}
|
||||||
|
<div class="actions">
|
||||||
|
<Button href="/pricing" variant="primary" size="sm">Change plan</Button>
|
||||||
|
<form method="POST" action="?/billingPortal" use:enhance={() => {
|
||||||
|
return async ({ result, update }) => {
|
||||||
|
if (result.type === 'success' && (result.data as any)?.portalUrl) {
|
||||||
|
window.open((result.data as any).portalUrl, '_blank');
|
||||||
|
} else if (result.type === 'failure') {
|
||||||
|
accessMsg = (result.data as any)?.error || 'Failed to open billing portal';
|
||||||
|
}
|
||||||
|
await update();
|
||||||
|
};
|
||||||
|
}}>
|
||||||
|
<Button type="submit" variant="secondary" size="sm" disabled={!fam?.stripeCustomerId}>Open billing portal</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<p class="hint resume-hint">
|
||||||
|
To pause your plan, cancel in the billing portal — you keep your data and can resubscribe
|
||||||
|
anytime. Cancelling takes effect at period end.
|
||||||
|
</p>
|
||||||
|
{:else if fam?.paymentMode === 'code'}
|
||||||
|
<div class="actions">
|
||||||
|
<Button href="/pricing" variant="primary" size="sm">Switch to subscription</Button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="actions">
|
||||||
|
<Button href="/pricing" variant="primary" size="sm">Choose a plan</Button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</Card>
|
||||||
|
</CardGrid>
|
||||||
|
</AccordionItem>
|
||||||
|
|
||||||
|
<AccordionItem title="App">
|
||||||
|
<CardGrid>
|
||||||
|
{#if page.data.platformFlags?.debug}
|
||||||
|
<Card title="Debug Tools" cols={1} accent="#f59e0b">
|
||||||
|
<p class="hint">Debug mode is enabled. These tools are for development and testing only.</p>
|
||||||
|
<div class="actions">
|
||||||
|
<form method="POST" action="?/completeWeek" use:enhance={() => {
|
||||||
|
return async ({ result, update }) => {
|
||||||
|
if (result.type === 'success') alert('Week completed!');
|
||||||
|
await update();
|
||||||
|
};
|
||||||
|
}}>
|
||||||
|
<Button type="submit" size="sm" variant="secondary">Complete Week</Button>
|
||||||
|
</form>
|
||||||
|
<form method="POST" action="?/generateData" use:enhance={() => {
|
||||||
|
return async ({ result, update }) => {
|
||||||
|
if (result.type === 'success') alert('Test data generated!');
|
||||||
|
await update();
|
||||||
|
};
|
||||||
|
}}>
|
||||||
|
<input type="hidden" name="days" value="7" />
|
||||||
|
<Button type="submit" size="sm" variant="secondary">Generate Test Data (7 days)</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
{/if}
|
||||||
|
<Card title="Data" cols={1}>
|
||||||
|
<div class="actions">
|
||||||
|
<Button variant="ghost" size="sm" disabled>Download CSV (coming soon)</Button>
|
||||||
|
<Button variant="danger" size="sm" disabled>Delete Family (coming soon)</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</CardGrid>
|
||||||
|
</AccordionItem>
|
||||||
|
|
||||||
|
<!-- Invites -->
|
||||||
|
</Accordion>
|
||||||
|
|
||||||
|
<NoticeDialog />
|
||||||
<style>
|
<style>
|
||||||
.hint {
|
.hint {
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
@@ -698,4 +758,21 @@
|
|||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
|
.access-msg {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #059669;
|
||||||
|
}
|
||||||
|
.revoke-form {
|
||||||
|
margin-top: 0.6rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
.revoke-form .hint {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
.resume-hint {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
+5
-5
@@ -1,10 +1,10 @@
|
|||||||
import { fail, redirect } from '@sveltejs/kit';
|
import { fail, redirect } from '@sveltejs/kit';
|
||||||
import { redeemOtp } from '$lib/server/member-otp';
|
import { redeemOtp } from '$lib/server/member-otp';
|
||||||
import { setSessionCookie } from '$lib/server/session';
|
import { setSessionCookie, clearLegacyCookies } from '$lib/server/session';
|
||||||
|
|
||||||
export const actions = {
|
export const actions = {
|
||||||
default: async (event) => {
|
default: async (event) => {
|
||||||
const famSlug = event.params.famSlug;
|
const fam = event.params.fam;
|
||||||
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();
|
||||||
@@ -12,13 +12,13 @@ export const actions = {
|
|||||||
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, username, otp });
|
const token = await redeemOtp({ famSlug: fam, username, otp });
|
||||||
event.cookies.delete('device_token', { path: '/' });
|
clearLegacyCookies(event.cookies);
|
||||||
setSessionCookie(event.cookies, token);
|
setSessionCookie(event.cookies, token);
|
||||||
} 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, `/${famSlug}/${encodeURIComponent(username)}`);
|
throw redirect(303, `/${fam}/${encodeURIComponent(username)}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
+1
-1
@@ -3,7 +3,7 @@
|
|||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
import { Button } from '$lib/components';
|
import { Button } from '$lib/components';
|
||||||
|
|
||||||
const famSlug = page.params.famSlug;
|
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 { form } = $props();
|
||||||
@@ -1,16 +1,17 @@
|
|||||||
import { pbAdmin } from '$lib/server/pocketbase';
|
import { pbAdmin } from '$lib/server/pocketbase';
|
||||||
import { redirect, fail } from '@sveltejs/kit';
|
import { redirect, fail } from '@sveltejs/kit';
|
||||||
import { PB_EMAIL, PB_PASSWORD } from '$app/env/private';
|
import { PB_EMAIL, PB_PASSWORD } from '$app/env/private';
|
||||||
|
import { getPlatformFlags, setPlatformFlag } from '$lib/server/platform';
|
||||||
import type { Actions, PageServerLoad } from './$types';
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ cookies }) => {
|
export const load: PageServerLoad = async ({ cookies }) => {
|
||||||
const session = cookies.get('platform_session');
|
const session = cookies.get('platform_session');
|
||||||
if (!session) {
|
if (!session) {
|
||||||
return { authenticated: false, fams: [], totalFams: 0, totalMembers: 0, totalRewards: 0 };
|
return { authenticated: false, fams: [], totalFams: 0, totalMembers: 0, totalRewards: 0, platformFlags: {} };
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const fams = await pbAdmin.getList('fams');
|
const [fams, platformFlags] = await Promise.all([pbAdmin.getList('fams'), getPlatformFlags()]);
|
||||||
const famsWithStats = await Promise.all(fams.map(async (fam: any) => {
|
const famsWithStats = await Promise.all(fams.map(async (fam: any) => {
|
||||||
const [members, rewards, parents] = await Promise.all([
|
const [members, rewards, parents] = await Promise.all([
|
||||||
pbAdmin.getList('users', `famId = '${fam.id}' && role = 'child'`),
|
pbAdmin.getList('users', `famId = '${fam.id}' && role = 'child'`),
|
||||||
@@ -23,7 +24,6 @@ export const load: PageServerLoad = async ({ cookies }) => {
|
|||||||
requestedRewards: (rewards as any[]).filter((r: any) => r.status === 'requested').length,
|
requestedRewards: (rewards as any[]).filter((r: any) => r.status === 'requested').length,
|
||||||
totalRewards: rewards.length,
|
totalRewards: rewards.length,
|
||||||
parentEmail: (parents as any[])?.[0]?.email || '',
|
parentEmail: (parents as any[])?.[0]?.email || '',
|
||||||
featureFlags: fam.featureFlags || {},
|
|
||||||
};
|
};
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -31,9 +31,9 @@ export const load: PageServerLoad = async ({ cookies }) => {
|
|||||||
const totalMembers = famsWithStats.reduce((s: number, f: any) => s + f.memberCount, 0);
|
const totalMembers = famsWithStats.reduce((s: number, f: any) => s + f.memberCount, 0);
|
||||||
const totalRewards = famsWithStats.reduce((s: number, f: any) => s + f.totalRewards, 0);
|
const totalRewards = famsWithStats.reduce((s: number, f: any) => s + f.totalRewards, 0);
|
||||||
|
|
||||||
return { authenticated: true, fams: famsWithStats, totalFams, totalMembers, totalRewards };
|
return { authenticated: true, fams: famsWithStats, totalFams, totalMembers, totalRewards, platformFlags };
|
||||||
} catch {
|
} catch {
|
||||||
return { authenticated: false, fams: [], totalFams: 0, totalMembers: 0, totalRewards: 0 };
|
return { authenticated: false, fams: [], totalFams: 0, totalMembers: 0, totalRewards: 0, platformFlags: {} };
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -60,19 +60,18 @@ export const actions: Actions = {
|
|||||||
throw redirect(303, '/admin');
|
throw redirect(303, '/admin');
|
||||||
},
|
},
|
||||||
|
|
||||||
toggleFeatureFlag: async ({ request, cookies }) => {
|
// Toggles a platform-level feature flag on the singleton platform record.
|
||||||
|
togglePlatformFlag: async ({ request, cookies }) => {
|
||||||
const session = cookies.get('platform_session');
|
const session = cookies.get('platform_session');
|
||||||
if (!session) return fail(401, { error: 'Not authenticated' });
|
if (!session) return fail(401, { error: 'Not authenticated' });
|
||||||
|
|
||||||
const fd = await request.formData();
|
const fd = await request.formData();
|
||||||
const famId = fd.get('famId') as string;
|
|
||||||
const flag = fd.get('flag') as string;
|
const flag = fd.get('flag') as string;
|
||||||
|
if (!flag) return fail(400, { error: 'Flag required' });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const fam = await pbAdmin.getOne('fams', famId);
|
const flags = await getPlatformFlags();
|
||||||
const flags = fam.featureFlags || {};
|
await setPlatformFlag(flag, !flags[flag]);
|
||||||
flags[flag] = !flags[flag];
|
|
||||||
await pbAdmin.update('fams', famId, { featureFlags: flags });
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return fail(500, { error: e instanceof Error ? e.message : 'Failed to update' });
|
return fail(500, { error: e instanceof Error ? e.message : 'Failed to update' });
|
||||||
|
|||||||
@@ -49,6 +49,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card cols={1} title="Platform Flags" accent="#f59e0b">
|
||||||
|
<p class="hint">Global feature flags (public read-only for fams; toggled here).</p>
|
||||||
|
<div class="flag-row">
|
||||||
|
<span class="flag-name">debug</span>
|
||||||
|
<form method="POST" action="?/togglePlatformFlag" use:enhance>
|
||||||
|
<input type="hidden" name="flag" value="debug" />
|
||||||
|
<button type="submit" class="toggle-btn" class:active={data.platformFlags?.debug}>
|
||||||
|
{data.platformFlags?.debug ? 'ON' : 'OFF'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Card cols={2} title="Families" accent="#059669">
|
<Card cols={2} title="Families" accent="#059669">
|
||||||
<table class="fam-table">
|
<table class="fam-table">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -57,7 +70,6 @@
|
|||||||
<th>Parent</th>
|
<th>Parent</th>
|
||||||
<th>Members</th>
|
<th>Members</th>
|
||||||
<th>Claims</th>
|
<th>Claims</th>
|
||||||
<th>Debug</th>
|
|
||||||
<th>Actions</th>
|
<th>Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -65,7 +77,7 @@
|
|||||||
{#each data.fams as fam}
|
{#each data.fams as fam}
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<a href="/{fam.slug}/admin">{fam.name}</a>
|
<a href="/{fam.slug}">{fam.name}</a>
|
||||||
<span class="slug">/{fam.slug}</span>
|
<span class="slug">/{fam.slug}</span>
|
||||||
</td>
|
</td>
|
||||||
<td>{fam.parentEmail || '—'}</td>
|
<td>{fam.parentEmail || '—'}</td>
|
||||||
@@ -78,20 +90,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<form method="POST" action="?/toggleFeatureFlag" use:enhance>
|
<a href="/{fam.slug}" class="link">Dashboard</a>
|
||||||
<input type="hidden" name="famId" value={fam.id} />
|
|
||||||
<input type="hidden" name="flag" value="debugMode" />
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="toggle-btn"
|
|
||||||
class:active={fam.featureFlags?.debugMode}
|
|
||||||
>
|
|
||||||
{fam.featureFlags?.debugMode ? 'ON' : 'OFF'}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<a href="/{fam.slug}/admin" class="link">Dashboard</a>
|
|
||||||
<a href="/{fam.slug}" class="link">View</a>
|
<a href="/{fam.slug}" class="link">View</a>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -211,6 +210,18 @@
|
|||||||
background: #059669;
|
background: #059669;
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
.flag-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.35rem 0;
|
||||||
|
}
|
||||||
|
.flag-name {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
.link {
|
.link {
|
||||||
color: #2563eb;
|
color: #2563eb;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { json, type RequestHandler } from '@sveltejs/kit';
|
||||||
|
import { verifyStripeEvent, isDummyStripe } from '$lib/server/stripe';
|
||||||
|
import { handleStripeEvent } from '$lib/server/stripe-events';
|
||||||
|
|
||||||
|
// Stripe webhook: updates fams.stripeCustomerId + fams.active + fams.paymentMode
|
||||||
|
// from subscription lifecycle events. Lives under /api/webhooks/stripe per the
|
||||||
|
// architecture — machine-to-machine endpoints live in /api/*, not under UI routes.
|
||||||
|
export const POST: RequestHandler = async ({ request }) => {
|
||||||
|
const rawBody = await request.text();
|
||||||
|
const signature = request.headers.get('stripe-signature');
|
||||||
|
|
||||||
|
// Dummy mode: no webhook secret configured — accept the event without
|
||||||
|
// verification so the flow is testable before the account is connected.
|
||||||
|
if (isDummyStripe || !signature) {
|
||||||
|
return json({ received: true, dummy: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
let event;
|
||||||
|
try {
|
||||||
|
event = verifyStripeEvent(rawBody, signature);
|
||||||
|
} catch (e) {
|
||||||
|
return json(
|
||||||
|
{ error: e instanceof Error ? e.message : 'Webhook signature verification failed' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await handleStripeEvent(event);
|
||||||
|
return json({ received: true });
|
||||||
|
};
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
import { redirect } from '@sveltejs/kit';
|
import { redirect } from '@sveltejs/kit';
|
||||||
import { clearSessionCookie } from '$lib/server/session';
|
import { clearSessionCookie, clearLegacyCookies } from '$lib/server/session';
|
||||||
|
|
||||||
function signOut(event: { cookies: any }) {
|
function signOut(event: { cookies: any }) {
|
||||||
clearSessionCookie(event.cookies);
|
clearSessionCookie(event.cookies);
|
||||||
event.cookies.delete('session', { path: '/' });
|
event.cookies.delete('session', { path: '/' });
|
||||||
event.cookies.delete('device_token', { path: '/' });
|
clearLegacyCookies(event.cookies);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function load(event) {
|
export function load(event) {
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { redirect, fail } from '@sveltejs/kit';
|
||||||
|
import type { RequestEvent } from '@sveltejs/kit';
|
||||||
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
|
import { pbAdmin } from '$lib/server/pocketbase';
|
||||||
|
import { createEmbeddedCheckoutSession, resolveTrialDays, PLAN_IDS } from '$lib/server/stripe';
|
||||||
|
import type { PlanId } from '$lib/server/stripe';
|
||||||
|
|
||||||
|
function famOf(event: RequestEvent) {
|
||||||
|
if (!event.locals.user) throw redirect(303, '/login');
|
||||||
|
return event.locals.user.famId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async (event) => {
|
||||||
|
const authenticated = !!event.locals.user;
|
||||||
|
let famSlug: string | null = null;
|
||||||
|
if (authenticated) {
|
||||||
|
const fam = await pbAdmin.getOne('fams', event.locals.user!.famId).catch(() => null);
|
||||||
|
famSlug = fam?.slug || null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
authenticated,
|
||||||
|
famSlug,
|
||||||
|
plans: { monthly: PLAN_IDS.monthly, yearly: PLAN_IDS.yearly }
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
choose: async (event) => {
|
||||||
|
const fd = await event.request.formData();
|
||||||
|
const plan = fd.get('plan') as PlanId;
|
||||||
|
const code = (fd.get('code') as string) || '';
|
||||||
|
|
||||||
|
if (!['trial', 'monthly', 'yearly'].includes(plan)) {
|
||||||
|
return fail(400, { error: 'Unknown plan' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trial requires a valid app-side code (which maps to trial days).
|
||||||
|
let trialDays: number | null = null;
|
||||||
|
if (plan === 'trial') {
|
||||||
|
trialDays = await resolveTrialDays(code);
|
||||||
|
if (!trialDays) return fail(400, { error: 'Invalid trial code' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not logged in → redirect to signup with plan preselected.
|
||||||
|
if (!event.locals.user) {
|
||||||
|
throw redirect(303, `/signup?plan=${plan}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const famId = famOf(event);
|
||||||
|
const fam = await pbAdmin.getOne('fams', famId);
|
||||||
|
const parents = await pbAdmin.getList('users', `famId = '${famId}' && role = 'parent'`);
|
||||||
|
const email = (parents[0] as { email?: string } | undefined)?.email || null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { clientSecret, sessionId } = await createEmbeddedCheckoutSession({
|
||||||
|
plan,
|
||||||
|
famId,
|
||||||
|
famSlug: fam.slug,
|
||||||
|
email,
|
||||||
|
customerId: fam.stripeCustomerId || null,
|
||||||
|
trialDays,
|
||||||
|
origin: event.url.origin
|
||||||
|
});
|
||||||
|
return { success: true, clientSecret, sessionId, plan };
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof redirect) throw e;
|
||||||
|
return fail(500, { error: e instanceof Error ? e.message : 'Failed to start checkout' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { tick } from 'svelte';
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import { PUBLIC_STRIPE_PUBLISHABLE_KEY } from '$app/env/public';
|
||||||
|
import { loadStripe, type StripeEmbeddedCheckout } from '@stripe/stripe-js';
|
||||||
|
import { ViewHeader, CardGrid, Card, Button, PricingPlans } from '$lib/components';
|
||||||
|
|
||||||
|
let { data, form } = $props();
|
||||||
|
|
||||||
|
let showCheckout = $state(false);
|
||||||
|
let checkoutTitle = $state('');
|
||||||
|
let checkoutEl = $state<HTMLDivElement | null>(null);
|
||||||
|
let checkout: StripeEmbeddedCheckout | null = null;
|
||||||
|
|
||||||
|
async function mountEmbedded(clientSecret: string, planName: string) {
|
||||||
|
const stripe = await loadStripe(String(PUBLIC_STRIPE_PUBLISHABLE_KEY));
|
||||||
|
if (!stripe) {
|
||||||
|
alert('Stripe failed to load');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
checkout?.unmount();
|
||||||
|
checkout = await stripe.createEmbeddedCheckoutPage({ clientSecret });
|
||||||
|
checkoutTitle = planName;
|
||||||
|
showCheckout = true;
|
||||||
|
await tick();
|
||||||
|
if (checkoutEl) checkout.mount(checkoutEl);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleChoose({ result, formData }: { result: any; formData?: FormData }) {
|
||||||
|
if (result.type === 'success' && result.data) {
|
||||||
|
const d = result.data as { clientSecret?: string };
|
||||||
|
if (d.clientSecret) {
|
||||||
|
const planName = String(formData?.get('plan') || '');
|
||||||
|
const label = { trial: 'Trial', monthly: 'Monthly', yearly: 'Yearly' }[planName] || planName;
|
||||||
|
mountEmbedded(d.clientSecret, label);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetCheckout() {
|
||||||
|
checkout?.unmount();
|
||||||
|
checkout = null;
|
||||||
|
showCheckout = false;
|
||||||
|
checkoutTitle = '';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ViewHeader title="Pricing" subtitle="Choose a plan for your family" hero />
|
||||||
|
|
||||||
|
{#if !showCheckout}
|
||||||
|
<PricingPlans
|
||||||
|
action="?/choose"
|
||||||
|
selected=""
|
||||||
|
error={form?.error}
|
||||||
|
onsubmit={handleChoose}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<CardGrid>
|
||||||
|
<Card title={`Checkout — ${checkoutTitle}`} accent="#059669">
|
||||||
|
<div class="checkout-actions">
|
||||||
|
<Button variant="ghost" size="sm" onclick={resetCheckout}>← Back to plans</Button>
|
||||||
|
</div>
|
||||||
|
<div bind:this={checkoutEl} class="checkout-host"></div>
|
||||||
|
<p class="hint" style="margin-top: 1rem;">
|
||||||
|
You can close and go to your dashboard any time — access unlocks once payment completes.
|
||||||
|
</p>
|
||||||
|
<a href={data.famSlug ? `/${data.famSlug}` : '/'} class="btn-primary" style="margin-top: 1rem; display: inline-block;">Go to dashboard</a>
|
||||||
|
</Card>
|
||||||
|
</CardGrid>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.hint {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #9ca3af;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.checkout-actions {
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
.checkout-host {
|
||||||
|
min-height: 480px;
|
||||||
|
}
|
||||||
|
.checkout-host :global(iframe) {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.btn-primary {
|
||||||
|
display: inline-block;
|
||||||
|
text-align: center;
|
||||||
|
background: #4338ca;
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.btn-primary:hover { background: #3730a3; }
|
||||||
|
</style>
|
||||||
@@ -5,6 +5,9 @@ import { createPbClient } from '$lib/server/pocketbase';
|
|||||||
import { setSessionCookie } from '$lib/server/session';
|
import { setSessionCookie } from '$lib/server/session';
|
||||||
import { issueAccess } from '$lib/server/member-otp';
|
import { issueAccess } from '$lib/server/member-otp';
|
||||||
import { slugify, handle, famUsername, handleOf } from '@shared/slugify';
|
import { slugify, handle, famUsername, handleOf } from '@shared/slugify';
|
||||||
|
import { applyAccessCode } from '$lib/server/access';
|
||||||
|
import { createEmbeddedCheckoutSession, PLAN_IDS } from '$lib/server/stripe';
|
||||||
|
import type { PlanId } from '$lib/server/stripe';
|
||||||
|
|
||||||
class SignupError extends Error {}
|
class SignupError extends Error {}
|
||||||
|
|
||||||
@@ -32,11 +35,14 @@ export const actions = {
|
|||||||
const username = famUsername(slug, handleName);
|
const username = famUsername(slug, handleName);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const fam = await pbAdmin.create('fams', {
|
const famData: Record<string, unknown> = {
|
||||||
name: famName,
|
name: famName,
|
||||||
slug,
|
slug,
|
||||||
timezone: 'auto'
|
timezone: 'auto',
|
||||||
});
|
paymentMode: 'none',
|
||||||
|
active: false
|
||||||
|
};
|
||||||
|
const fam = await pbAdmin.create('fams', famData);
|
||||||
const user = await pbAdmin.create('users', {
|
const user = await pbAdmin.create('users', {
|
||||||
username,
|
username,
|
||||||
name: parentName,
|
name: parentName,
|
||||||
@@ -61,7 +67,6 @@ export const actions = {
|
|||||||
.catch(() => null);
|
.catch(() => null);
|
||||||
if (authResult?.token) setSessionCookie(event.cookies, authResult.token);
|
if (authResult?.token) setSessionCookie(event.cookies, authResult.token);
|
||||||
|
|
||||||
// `username` here is the handle (URL segment), not the composite.
|
|
||||||
return { success: true, famSlug: slug, username: handleName };
|
return { success: true, famSlug: slug, username: handleName };
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -71,11 +76,14 @@ export const actions = {
|
|||||||
const fd = await event.request.formData();
|
const fd = await event.request.formData();
|
||||||
const name = ((fd.get('member') as string) || '').trim();
|
const name = ((fd.get('member') as string) || '').trim();
|
||||||
|
|
||||||
|
// No [fam] URL param here (signup isn't fam-scoped) — resolve the slug
|
||||||
|
// from DB. Everywhere else, the slug comes from event.params.fam /
|
||||||
|
// page.data.famSlug ([fam] layout load) — never copy it into state.
|
||||||
const fam = await pbAdmin.getOne('fams', user.famId);
|
const fam = await pbAdmin.getOne('fams', user.famId);
|
||||||
const famSlug = fam?.slug || user.famId;
|
const famSlug = fam?.slug || user.famId;
|
||||||
|
|
||||||
if (!name) {
|
if (!name) {
|
||||||
return { success: true, famSlug, username: handleOf(user.username) };
|
return { success: true, famSlug, username: handleOf(user.username || '') };
|
||||||
}
|
}
|
||||||
|
|
||||||
const { otp, joinUrl } = await issueAccess({
|
const { otp, joinUrl } = await issueAccess({
|
||||||
@@ -84,11 +92,57 @@ export const actions = {
|
|||||||
name
|
name
|
||||||
});
|
});
|
||||||
|
|
||||||
return { success: true, code: otp, joinUrl, famSlug, username: handleOf(user.username) };
|
return { success: true, code: otp, joinUrl, famSlug, username: handleOf(user.username || '') };
|
||||||
|
},
|
||||||
|
|
||||||
|
// Step 3 — apply an access code (or skip via client-side navigation).
|
||||||
|
access: async (event: RequestEvent) => {
|
||||||
|
const user = requireUser(event);
|
||||||
|
const fd = await event.request.formData();
|
||||||
|
const code = ((fd.get('code') as string) || '').trim();
|
||||||
|
|
||||||
|
if (!code) {
|
||||||
|
return { ok: true, skipped: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await applyAccessCode(user.famId, code);
|
||||||
|
if (result.error) return fail(400, { error: result.error });
|
||||||
|
|
||||||
|
return { ok: true, ...result };
|
||||||
|
},
|
||||||
|
|
||||||
|
// Step 4 — choose a plan, start embedded checkout.
|
||||||
|
choose: async (event: RequestEvent) => {
|
||||||
|
const user = requireUser(event);
|
||||||
|
const fd = await event.request.formData();
|
||||||
|
const plan = fd.get('plan') as PlanId;
|
||||||
|
|
||||||
|
if (!['monthly', 'yearly'].includes(plan)) {
|
||||||
|
return fail(400, { error: 'Unknown plan' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const fam = await pbAdmin.getOne('fams', user.famId);
|
||||||
|
const parents = await pbAdmin.getList('users', `famId = '${user.famId}' && role = 'parent'`);
|
||||||
|
const email = (parents[0] as { email?: string } | undefined)?.email || null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { clientSecret, sessionId } = await createEmbeddedCheckoutSession({
|
||||||
|
plan,
|
||||||
|
famId: user.famId,
|
||||||
|
famSlug: fam.slug,
|
||||||
|
email,
|
||||||
|
customerId: fam.stripeCustomerId || null,
|
||||||
|
origin: event.url.origin
|
||||||
|
});
|
||||||
|
return { success: true, clientSecret, sessionId, plan };
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof redirect) throw e;
|
||||||
|
return fail(500, { error: e instanceof Error ? e.message : 'Failed to start checkout' });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
function requireUser(event: RequestEvent) {
|
function requireUser(event: RequestEvent) {
|
||||||
if (!event.locals.user) throw redirect(303, '/signup');
|
if (!event.locals.user) throw redirect(303, '/signup');
|
||||||
return event.locals.user;
|
return event.locals.user;
|
||||||
}
|
}
|
||||||
@@ -1,40 +1,116 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
import AuthShell from '$lib/components/AuthShell.svelte';
|
import { tick } from 'svelte';
|
||||||
import { slugify, handle } from '@shared/slugify';
|
import { PUBLIC_STRIPE_PUBLISHABLE_KEY } from '$app/env/public';
|
||||||
|
import { loadStripe, type StripeEmbeddedCheckout } from '@stripe/stripe-js';
|
||||||
|
import AuthShell from '$lib/components/AuthShell.svelte';
|
||||||
|
import { ViewHeader, CardGrid, Card, Button, PricingPlans } from '$lib/components';
|
||||||
|
import { slugify, handle } from '@shared/slugify';
|
||||||
|
import { page } from '$app/state';
|
||||||
|
|
||||||
let { form } = $props();
|
let { form } = $props();
|
||||||
|
|
||||||
let step = $state(1);
|
// State machine: 'fam' | 'child' | 'code' | 'plan' | 'done'
|
||||||
|
let step = $state<'fam' | 'child' | 'code' | 'plan' | 'done'>('fam');
|
||||||
|
// Determine initial step from URL param (plan preselection)
|
||||||
|
$effect(() => {
|
||||||
|
const plan = page.url.searchParams.get('plan');
|
||||||
|
if (plan && (plan === 'monthly' || plan === 'yearly') && form?.type !== 'failure') {
|
||||||
|
step = 'plan';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
let famName = $state('');
|
let famName = $state('');
|
||||||
let yourName = $state('');
|
let yourName = $state('');
|
||||||
let email = $state('');
|
let email = $state('');
|
||||||
let password = $state('');
|
let password = $state('');
|
||||||
let childName = $state('');
|
let childName = $state('');
|
||||||
|
let accessCode = $state('');
|
||||||
|
let accessMsg = $state('');
|
||||||
|
let submittedFamSlug = $state('');
|
||||||
let submitting = $state(false);
|
let submitting = $state(false);
|
||||||
let localError = $state('');
|
let localError = $state('');
|
||||||
|
|
||||||
let famSlugPreview = $derived(slugify(famName) || 'your-family');
|
let famSlugPreview = $derived(slugify(famName) || 'your-family');
|
||||||
let handlePreview = $derived(handle(yourName) || 'your-name');
|
let handlePreview = $derived(handle(yourName) || 'your-name');
|
||||||
|
let selectedPlan = $derived(page.url.searchParams.get('plan') || '');
|
||||||
|
let showCheckout = $state(false);
|
||||||
|
let checkoutTitle = $state('');
|
||||||
|
let checkoutEl = $state<HTMLDivElement | null>(null);
|
||||||
|
let checkout: StripeEmbeddedCheckout | null = null;
|
||||||
|
|
||||||
const enhanceForm = () => {
|
const enhanceForm = () => {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- canary $types lacks SubmitFunction
|
|
||||||
return () =>
|
return () =>
|
||||||
async ({ update, result }: any) => {
|
async ({ update, result }: any) => {
|
||||||
submitting = true;
|
submitting = true;
|
||||||
localError = '';
|
localError = '';
|
||||||
|
accessMsg = '';
|
||||||
try {
|
try {
|
||||||
await update();
|
await update();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
localError = e instanceof Error ? e.message : 'Something went wrong. Please try again.';
|
localError = e instanceof Error ? e.message : 'Something went wrong. Please try again.';
|
||||||
}
|
}
|
||||||
submitting = false;
|
submitting = false;
|
||||||
if (result.type !== 'failure' && result.type !== 'error') step++;
|
if (result.type !== 'failure' && result.type !== 'error') {
|
||||||
|
if (step === 'fam') {
|
||||||
|
// Capture famSlug from the signup action result
|
||||||
|
if (result.data?.famSlug) submittedFamSlug = result.data.famSlug;
|
||||||
|
step = 'child';
|
||||||
|
} else if (step === 'child') step = 'code';
|
||||||
|
}
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
async function mountEmbedded(clientSecret: string, planName: string) {
|
||||||
|
const stripe = await loadStripe(String(PUBLIC_STRIPE_PUBLISHABLE_KEY));
|
||||||
|
if (!stripe) {
|
||||||
|
alert('Stripe failed to load');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
checkout?.unmount();
|
||||||
|
checkout = await stripe.createEmbeddedCheckoutPage({ clientSecret });
|
||||||
|
checkoutTitle = planName;
|
||||||
|
showCheckout = true;
|
||||||
|
await tick();
|
||||||
|
if (checkoutEl) checkout.mount(checkoutEl);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAccess({ result, formData }: { result: any; formData?: FormData }) {
|
||||||
|
if (result.type === 'success' && result.data) {
|
||||||
|
if (result.data.skipped) {
|
||||||
|
step = 'plan';
|
||||||
|
} else {
|
||||||
|
accessMsg = 'Access code applied — you\'re all set!';
|
||||||
|
step = 'done';
|
||||||
|
}
|
||||||
|
} else if (result.type === 'failure') {
|
||||||
|
accessMsg = result.data?.error || 'Invalid code.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleChoose({ result, formData }: { result: any; formData?: FormData }) {
|
||||||
|
if (result.type === 'success' && result.data) {
|
||||||
|
const d = result.data as { clientSecret?: string };
|
||||||
|
if (d.clientSecret) {
|
||||||
|
const planName = String(formData?.get('plan') || '');
|
||||||
|
const label = { monthly: 'Monthly', yearly: 'Yearly' }[planName] || planName;
|
||||||
|
mountEmbedded(d.clientSecret, label);
|
||||||
|
}
|
||||||
|
} else if (result.type === 'failure') {
|
||||||
|
localError = result.data?.error || 'Failed to start checkout.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetCheckout() {
|
||||||
|
checkout?.unmount();
|
||||||
|
checkout = null;
|
||||||
|
showCheckout = false;
|
||||||
|
checkoutTitle = '';
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<AuthShell title="Create your family" subtitle="Set up in about a minute. Free to get going.">
|
<AuthShell title="Create your family" subtitle="Set up in about a minute. Free to get going.">
|
||||||
|
|
||||||
{#if form?.message}
|
{#if form?.message}
|
||||||
<p class="form-error">{form.message}</p>
|
<p class="form-error">{form.message}</p>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -42,7 +118,8 @@
|
|||||||
<p class="form-error">{localError}</p>
|
<p class="form-error">{localError}</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if step === 1}
|
<!-- Step 1: Family + Parent -->
|
||||||
|
{#if step === 'fam'}
|
||||||
<form method="POST" action="?/signup" use:enhance={enhanceForm()}>
|
<form method="POST" action="?/signup" use:enhance={enhanceForm()}>
|
||||||
<label>
|
<label>
|
||||||
Family name
|
Family name
|
||||||
@@ -81,9 +158,8 @@
|
|||||||
<button type="submit" disabled={submitting}>Create my family</button>
|
<button type="submit" disabled={submitting}>Create my family</button>
|
||||||
</form>
|
</form>
|
||||||
<p class="alt">Already have a family? <a href="/login">Log in</a></p>
|
<p class="alt">Already have a family? <a href="/login">Log in</a></p>
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if step === 2}
|
{:else if step === 'child'}
|
||||||
<h3 class="step-title">Add a child now?</h3>
|
<h3 class="step-title">Add a child now?</h3>
|
||||||
<p class="step-note">We'll create a shareable join code so they can jump in on any device.</p>
|
<p class="step-note">We'll create a shareable join code so they can jump in on any device.</p>
|
||||||
<form method="POST" action="?/child" use:enhance={enhanceForm()}>
|
<form method="POST" action="?/child" use:enhance={enhanceForm()}>
|
||||||
@@ -99,24 +175,53 @@
|
|||||||
<button type="submit" disabled={submitting}>Create join code</button>
|
<button type="submit" disabled={submitting}>Create join code</button>
|
||||||
</form>
|
</form>
|
||||||
<p class="alt">
|
<p class="alt">
|
||||||
<a href="/{form?.famSlug}">Skip for now →</a>
|
<a href="/{submittedFamSlug}" onclick={() => (step = 'code')}>Skip for now →</a>
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if step === 3}
|
{:else if step === 'code'}
|
||||||
<h3 class="step-title">{childName ? `Nice — share this code with ${childName}:` : 'Your family is ready!'}</h3>
|
<h3 class="step-title">Have an access code?</h3>
|
||||||
{#if form?.code}
|
<p class="step-note">If you have a code (e.g. from your employer or a gift), enter it here. Otherwise skip to choose a plan.</p>
|
||||||
<div class="code">
|
<form method="POST" action="?/access" use:enhance={handleAccess}>
|
||||||
<span class="code-text">{form.code}</span>
|
<input name="code" bind:value={accessCode} placeholder="Enter access code" autocomplete="off" />
|
||||||
</div>
|
<Button type="submit" size="md" variant="primary" disabled={submitting}>Apply code</Button>
|
||||||
<p class="step-note">
|
</form>
|
||||||
They open <code class="inline-code">{form?.joinUrl}</code> and enter this code.
|
{#if accessMsg}
|
||||||
</p>
|
<p class="access-msg">{accessMsg}</p>
|
||||||
{:else}
|
|
||||||
<p class="step-note">You can add kids and share join codes any time from Family Settings.</p>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
<p class="alt">
|
||||||
|
<button onclick={() => (step = 'plan')} class="skip-link">Skip — choose a plan instead →</button>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{:else if step === 'plan'}
|
||||||
|
<h3 class="step-title">Choose a plan</h3>
|
||||||
|
<p class="step-note">Pick the plan that works for your family. Your trial or subscription starts immediately.</p>
|
||||||
|
<PricingPlans
|
||||||
|
action="?/choose"
|
||||||
|
hideTrial={true}
|
||||||
|
selected={selectedPlan}
|
||||||
|
error={form?.error}
|
||||||
|
onsubmit={handleChoose}
|
||||||
|
/>
|
||||||
|
{#if showCheckout}
|
||||||
|
<CardGrid>
|
||||||
|
<Card title={`Checkout — ${checkoutTitle}`} accent="#059669">
|
||||||
|
<div class="checkout-actions">
|
||||||
|
<Button variant="ghost" size="sm" onclick={resetCheckout}>← Back to plans</Button>
|
||||||
|
</div>
|
||||||
|
<div bind:this={checkoutEl} class="checkout-host"></div>
|
||||||
|
<p class="hint" style="margin-top: 1rem;">
|
||||||
|
You can close and go to your dashboard any time — access unlocks once payment completes.
|
||||||
|
</p>
|
||||||
|
<a href={submittedFamSlug ? `/${submittedFamSlug}` : '/'} class="btn-primary" style="margin-top: 1rem; display: inline-block;">Go to dashboard</a>
|
||||||
|
</Card>
|
||||||
|
</CardGrid>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{:else if step === 'done'}
|
||||||
|
<h3 class="step-title">Your family is ready!</h3>
|
||||||
|
<p class="step-note">You can add kids and share join codes any time from Family Settings.</p>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<a href="/{form?.famSlug}" class="btn-primary">Go to dashboard</a>
|
<a href="/{submittedFamSlug}" class="btn-primary">Go to dashboard</a>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</AuthShell>
|
</AuthShell>
|
||||||
@@ -201,28 +306,20 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
.alt a { color: #4338ca; text-decoration: none; font-weight: 500; }
|
.alt a { color: #4338ca; text-decoration: none; font-weight: 500; }
|
||||||
.code {
|
.skip-link {
|
||||||
background: #eef2ff;
|
background: none;
|
||||||
border: 1px dashed #a5b4fc;
|
border: none;
|
||||||
border-radius: 10px;
|
|
||||||
padding: 1rem;
|
|
||||||
text-align: center;
|
|
||||||
margin: 0 0 0.75rem;
|
|
||||||
}
|
|
||||||
.code-text {
|
|
||||||
font-family: ui-monospace, monospace;
|
|
||||||
font-size: 1.6rem;
|
|
||||||
letter-spacing: 0.35em;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #4338ca;
|
color: #4338ca;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
}
|
}
|
||||||
.inline-code {
|
.skip-link:hover { text-decoration: underline; }
|
||||||
font-family: ui-monospace, monospace;
|
.access-msg {
|
||||||
font-size: 0.85em;
|
margin-top: 0.5rem;
|
||||||
background: #f3f4f6;
|
font-size: 0.85rem;
|
||||||
border-radius: 4px;
|
color: #059669;
|
||||||
padding: 0.1em 0.35em;
|
|
||||||
color: #374151;
|
|
||||||
}
|
}
|
||||||
.actions {
|
.actions {
|
||||||
margin-top: 1.25rem;
|
margin-top: 1.25rem;
|
||||||
|
|||||||
+2
-1
@@ -5,7 +5,8 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "pnpm --filter frontend dev",
|
"dev": "pnpm --filter frontend dev",
|
||||||
"start": "pnpm dev",
|
"start": "pnpm dev",
|
||||||
"build": "pnpm --filter frontend build"
|
"build": "pnpm --filter frontend build",
|
||||||
|
"stripe:listen": "stripe listen -e customer.subscription.updated,customer.subscription.deleted,checkout.session.completed --forward-to http://127.0.0.1:2080/api/webhooks/stripe"
|
||||||
},
|
},
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
"onlyBuiltDependencies": [
|
"onlyBuiltDependencies": [
|
||||||
|
|||||||
+15
-5
@@ -5,10 +5,12 @@
|
|||||||
// Relations reference collections by name; the `ids` map maps collection
|
// Relations reference collections by name; the `ids` map maps collection
|
||||||
// name -> runtime id (filled as each collection is created).
|
// name -> runtime id (filled as each collection is created).
|
||||||
//
|
//
|
||||||
// NOTE: the native `users` auth collection and the superuser-only `otp`
|
// NOTE: the native `users` auth collection and the superuser-only `otp` +
|
||||||
// collection are NOT in SCHEMA_PLAN — they're applied separately in
|
// `accesscodes` collections are NOT in SCHEMA_PLAN — they're applied separately
|
||||||
// migrate.ts (users is PB's built-in auth model; `otp` needs null rules,
|
// in migrate.ts (users is PB's built-in auth model; `otp`/`accesscodes` need
|
||||||
// which the `col()` builder can't express). Everything else lives here.
|
// null rules, which the `col()` builder can't express). The public-read
|
||||||
|
// `platform` settings collection is also applied there (needs null write
|
||||||
|
// rules). Everything else lives here.
|
||||||
|
|
||||||
export interface FieldDef {
|
export interface FieldDef {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -133,11 +135,19 @@ export const SCHEMA_PLAN: CollectionPlanEntry[] = [
|
|||||||
uniqueText("slug"),
|
uniqueText("slug"),
|
||||||
text("stripeCustomerId"),
|
text("stripeCustomerId"),
|
||||||
bool("active"),
|
bool("active"),
|
||||||
jsonField("featureFlags"),
|
|
||||||
number("payday"),
|
number("payday"),
|
||||||
text("lastIssued"),
|
text("lastIssued"),
|
||||||
text("paydayTime"),
|
text("paydayTime"),
|
||||||
text("timezone"),
|
text("timezone"),
|
||||||
|
// Access gating. paymentMode: how this fam has access — a code, a
|
||||||
|
// Stripe subscription, canceled, or none (no access). `active` is the
|
||||||
|
// derived "usable right now" flag recomputed by the access check on
|
||||||
|
// every layout load (and by the Stripe webhook for subs). `accessCodeId`
|
||||||
|
// + `accessCodeEnteredAt` back the 'code' mode (the duration clock
|
||||||
|
// starts at entry; global expiry is the code createdAt + expiry months).
|
||||||
|
select("paymentMode", ["none", "code", "sub", "canceled"]),
|
||||||
|
text("accessCodeId"),
|
||||||
|
date("accessCodeEnteredAt"),
|
||||||
],
|
],
|
||||||
{ listRule: RULE_OWN_FAM, viewRule: RULE_OWN_FAM, updateRule: RULE_OWN_FAM },
|
{ listRule: RULE_OWN_FAM, viewRule: RULE_OWN_FAM, updateRule: RULE_OWN_FAM },
|
||||||
)(ids),
|
)(ids),
|
||||||
|
|||||||
Symlink
+1
@@ -0,0 +1 @@
|
|||||||
|
/home/threejjjs/development/stripe-next
|
||||||
Reference in New Issue
Block a user