add platform pages
This commit is contained in:
@@ -326,3 +326,14 @@
|
|||||||
- `[fam]/+layout.svelte` owns the `?checkout=return` flow (moved out of the fam page). On landing: if unlocked → welcome notice. If still gated (webhook lag) → `activating` state: paused overlay swaps to a spinner card ("Activating your subscription…"), TopNav paused announcement suppressed, and `invalidateAll()` revalidates every 1.5s (max 12 tries). The `$effect` watching `activating && !disabled` cancels polling and fires "Subscription active!" the instant the gate lifts; exhaustion degrades to a refresh-hint warning.
|
- `[fam]/+layout.svelte` owns the `?checkout=return` flow (moved out of the fam page). On landing: if unlocked → welcome notice. If still gated (webhook lag) → `activating` state: paused overlay swaps to a spinner card ("Activating your subscription…"), TopNav paused announcement suppressed, and `invalidateAll()` revalidates every 1.5s (max 12 tries). The `$effect` watching `activating && !disabled` cancels polling and fires "Subscription active!" the instant the gate lifts; exhaustion degrades to a refresh-hint warning.
|
||||||
- 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`.
|
- 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.
|
- **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.
|
||||||
|
|
||||||
|
### 2026-08-22 — Platform admin: access-code management + richer stats
|
||||||
|
|
||||||
|
- `/admin` extended (same route/embedded login — env-var check + `platform_session` cookie, no PB auth or hooks involved):
|
||||||
|
- **Access Codes card**: issue codes via `?/createCode` — blank value auto-generates `XXXX-XXXX` (unambiguous charset); fields name/duration/expiry/trialDays (`trialDays > 0` = Stripe trial code). Table shows type badge, active/disabled, **used-by count** (from fams.accessCodeId map), copy-to-clipboard. `?/toggleCode` flips active; `?/deleteCode` **blocked while in use** (must disable).
|
||||||
|
- **Overview**: added chores completed (completions length), active subs (`paymentMode='sub'`), paused/gated (`!active || none/canceled`), active codes. Families table gained plan+paused badges.
|
||||||
|
- All actions guarded by `requirePlatform(cookies)`; data still via `pbAdmin` superuser facade.
|
||||||
|
- Note: `svelte-kit sync` needed after changing load return shapes or `$types` staleness doubles the error count.
|
||||||
|
- **/admin login pattern:** action sets `platform_session` cookie + returns `{success:true}`; the form's `use:enhance` callback flips a local `authed` view state — no redirect, no reliance on inline invalidation or fetch-time Set-Cookie behavior (which proved flaky in-browser despite curl proving both response paths carried it). Cookie still covers subsequent loads; load errors surface as `data.loadError` on the login card instead of silently masquerading as logged-out.
|
||||||
|
- **Platform-admin auth via hooks:** `hooks.server.ts` resolves `locals.platformAdmin` from the `platform_session` cookie (=== 'authenticated') on every request; `/admin` load/actions consume `event.locals.platformAdmin` (`requirePlatform(event)`) instead of raw cookie reads. Same central pattern as `pb_token` → `locals.user`.
|
||||||
|
- **Platform-admin auth hardened (supersedes the constant-cookie version):** `/admin` login now does a real `_superusers.authWithPassword` via PB; the minted superuser JWT goes in `platform_session` (`setPlatformSession` in session.ts). `hooks.server.ts` deviates on `/admin`: verifies the token with `_superusers.authRefresh` → `locals.platformAdmin` (forged values fail authRefresh and get cleared); fam-user pb_token flow skipped on that route. FINAL login shape (user-amended, working): action returns `{success:true}` (no redirect); form's enhance callback does `goto('/admin', { invalidateAll: true })` on success — forcing the load re-run with the fresh cookie; view branches on `data.authenticated`. Verified: real token renders dashboard, forged cookie gets login.
|
||||||
|
|||||||
@@ -9,8 +9,8 @@
|
|||||||
- [ ] 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)
|
- [ ] 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
|
||||||
- [ ] 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).
|
- [x] Platform-admin UI shipped on `/admin`: issue codes (auto-gen XXXX-XXXX values), enable/disable/delete (delete blocked while in use), usage counts, trial vs access typing; stats now include chores completed / active subs / paused-gated / active codes; families table shows plan+paused state. Remaining polish: table link duplication.
|
||||||
- [ ] `/admin` Families table: name link and "Dashboard"/"View" are duplicates after the broken-link fix — tidy up.
|
- [ ] `/admin` Families table: name link and "View" are duplicates after the broken-link fix — tidy up.
|
||||||
|
|
||||||
### App / UX
|
### App / UX
|
||||||
- [ ] Signup wizard: `?plan=` param only pre-highlights the tier at step 4 — confirm whether it should auto-scroll/pulse instead
|
- [ ] Signup wizard: `?plan=` param only pre-highlights the tier at step 4 — confirm whether it should auto-scroll/pulse instead
|
||||||
|
|||||||
Vendored
+4
@@ -5,6 +5,10 @@ declare global {
|
|||||||
interface Locals {
|
interface Locals {
|
||||||
user: SessionUser | null;
|
user: SessionUser | null;
|
||||||
pbToken: string | null;
|
pbToken: string | null;
|
||||||
|
// Platform-admin (superuser dashboard) session — set from the
|
||||||
|
// platform_session cookie in hooks.server.ts. Separate from the
|
||||||
|
// fam user session; only /admin consumes it.
|
||||||
|
platformAdmin: boolean;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import type { Handle } from '@sveltejs/kit';
|
import type { Handle } from '@sveltejs/kit';
|
||||||
import { createPbClient } from '$lib/server/pocketbase';
|
import { createPbClient } from '$lib/server/pocketbase';
|
||||||
import { SESSION_COOKIE, setSessionCookie, clearSessionCookie } from '$lib/server/session';
|
import {
|
||||||
|
SESSION_COOKIE,
|
||||||
|
setSessionCookie,
|
||||||
|
clearSessionCookie,
|
||||||
|
PLATFORM_SESSION_COOKIE,
|
||||||
|
clearPlatformSession
|
||||||
|
} from '$lib/server/session';
|
||||||
import type { SessionUser } from '$lib/server/types';
|
import type { SessionUser } from '$lib/server/types';
|
||||||
import { handleOf } from '@shared/slugify';
|
import { handleOf } from '@shared/slugify';
|
||||||
import { migrateOnBoot } from '$lib/server/migrate-boot';
|
import { migrateOnBoot } from '$lib/server/migrate-boot';
|
||||||
@@ -11,7 +17,26 @@ void migrateOnBoot();
|
|||||||
export const handle: Handle = async ({ event, resolve }) => {
|
export const handle: Handle = async ({ event, resolve }) => {
|
||||||
event.locals.user = null;
|
event.locals.user = null;
|
||||||
event.locals.pbToken = null;
|
event.locals.pbToken = null;
|
||||||
|
event.locals.platformAdmin = false;
|
||||||
|
|
||||||
|
// Platform-admin routes authenticate via the superuser JWT in
|
||||||
|
// platform_session, verified against PB (authRefresh) — the cookie value
|
||||||
|
// is a real signed token, so forging it gains nothing.
|
||||||
|
if (event.url.pathname.startsWith('/admin')) {
|
||||||
|
const suToken = event.cookies.get(PLATFORM_SESSION_COOKIE);
|
||||||
|
if (suToken) {
|
||||||
|
try {
|
||||||
|
await createPbClient(suToken).collection('_superusers').authRefresh();
|
||||||
|
event.locals.platformAdmin = true;
|
||||||
|
} catch {
|
||||||
|
// Expired/revoked/forged token — drop it and treat as logged out.
|
||||||
|
clearPlatformSession(event.cookies);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return resolve(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fam-user session: pb_token JWT → authRefresh → locals.user.
|
||||||
const token = event.cookies.get(SESSION_COOKIE);
|
const token = event.cookies.get(SESSION_COOKIE);
|
||||||
|
|
||||||
if (token) {
|
if (token) {
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
import { Button, CardGrid, Card } from '$lib/components';
|
|
||||||
|
|
||||||
interface Tier {
|
interface Tier {
|
||||||
id: 'trial' | 'monthly' | 'yearly';
|
id: 'trial' | 'monthly' | 'yearly';
|
||||||
name: string;
|
name: string;
|
||||||
price: string;
|
price: string;
|
||||||
period: string;
|
period: string;
|
||||||
desc: string;
|
blurb: string;
|
||||||
|
features: string[];
|
||||||
cta: string;
|
cta: string;
|
||||||
featured?: boolean;
|
featured?: boolean;
|
||||||
}
|
}
|
||||||
@@ -17,16 +17,18 @@
|
|||||||
id: 'trial',
|
id: 'trial',
|
||||||
name: 'Trial',
|
name: 'Trial',
|
||||||
price: 'Free',
|
price: 'Free',
|
||||||
period: 'for a set period',
|
period: 'for the trial period',
|
||||||
desc: 'Try FamChore free for a period (e.g. 3 months) with a code.',
|
blurb: 'Kick the tires on us.',
|
||||||
cta: 'Start trial'
|
features: ['Full family access', 'No card required', 'Code from us or a partner'],
|
||||||
|
cta: 'Start free trial'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'monthly',
|
id: 'monthly',
|
||||||
name: 'Monthly',
|
name: 'Monthly',
|
||||||
price: '£3',
|
price: '£3',
|
||||||
period: '/month',
|
period: '/month',
|
||||||
desc: 'Full access for one family. Cancel anytime.',
|
blurb: 'Everything, month to month.',
|
||||||
|
features: ['Unlimited kids & chores', 'Allowances calculated automatically', 'Bonuses & seasons', 'Cancel anytime'],
|
||||||
cta: 'Choose monthly',
|
cta: 'Choose monthly',
|
||||||
featured: true
|
featured: true
|
||||||
},
|
},
|
||||||
@@ -35,7 +37,8 @@
|
|||||||
name: 'Yearly',
|
name: 'Yearly',
|
||||||
price: '£30',
|
price: '£30',
|
||||||
period: '/year',
|
period: '/year',
|
||||||
desc: 'Two months free vs monthly. Best value for committed families.',
|
blurb: 'Two months free vs monthly.',
|
||||||
|
features: ['Everything in Monthly', 'Two months free', 'Best for committed families'],
|
||||||
cta: 'Choose yearly'
|
cta: 'Choose yearly'
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
@@ -49,104 +52,157 @@
|
|||||||
} = $props();
|
} = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<CardGrid>
|
<div class="tiers">
|
||||||
{#each tiers.filter((t) => !(hideTrial && t.id === 'trial')) as tier}
|
{#each tiers.filter((t) => !(hideTrial && t.id === 'trial')) as tier}
|
||||||
<Card title={tier.name} accent={tier.featured ? '#6366f1' : undefined} selected={selected === tier.id}>
|
<article
|
||||||
|
class="tier"
|
||||||
|
class:featured={tier.featured}
|
||||||
|
class:picked={selected === tier.id}
|
||||||
|
aria-label="{tier.name} plan"
|
||||||
|
>
|
||||||
|
{#if tier.featured}
|
||||||
|
<span class="pop">Most popular</span>
|
||||||
|
{/if}
|
||||||
|
<h3>{tier.name}</h3>
|
||||||
<p class="price">
|
<p class="price">
|
||||||
<span class="amount">{tier.price}</span>
|
<span class="amount">{tier.price}</span>
|
||||||
<span class="period">{tier.period}</span>
|
<span class="period">{tier.period}</span>
|
||||||
</p>
|
</p>
|
||||||
<p class="hint">{tier.desc}</p>
|
<p class="blurb">{tier.blurb}</p>
|
||||||
|
|
||||||
{#if tier.id === 'trial'}
|
<form method="POST" action={action} use:enhance={onsubmit ?? undefined}>
|
||||||
<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} />
|
<input type="hidden" name="plan" value={tier.id} />
|
||||||
<Button type="submit" size="md" variant={tier.featured ? 'primary' : 'secondary'}>
|
{#if tier.id === 'trial'}
|
||||||
{tier.cta}
|
<input name="code" placeholder="Trial code" autocomplete="off" />
|
||||||
</Button>
|
{/if}
|
||||||
|
<button type="submit" class="cta" class:primary={tier.featured}>{tier.cta}</button>
|
||||||
</form>
|
</form>
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if error}
|
<ul>
|
||||||
<p class="error">{error}</p>
|
{#each tier.features as f}
|
||||||
{/if}
|
<li><span class="tick">✓</span>{f}</li>
|
||||||
</Card>
|
|
||||||
{/each}
|
{/each}
|
||||||
</CardGrid>
|
</ul>
|
||||||
|
</article>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<p class="form-error">{error}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.hint {
|
.tiers {
|
||||||
font-size: 0.85rem;
|
display: grid;
|
||||||
color: #9ca3af;
|
grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
|
||||||
line-height: 1.4;
|
gap: 1.25rem;
|
||||||
|
align-items: stretch;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
.price {
|
.tier {
|
||||||
margin: 0 0 0.5rem;
|
position: relative;
|
||||||
}
|
|
||||||
.amount {
|
|
||||||
font-size: 2rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #1f2937;
|
|
||||||
}
|
|
||||||
.period {
|
|
||||||
color: #9ca3af;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
margin-left: 0.25rem;
|
|
||||||
}
|
|
||||||
form {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.6rem;
|
background: #fff;
|
||||||
margin-top: 0.75rem;
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 1.5rem 1.4rem 1.4rem;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||||
}
|
}
|
||||||
.field-label {
|
.tier.featured {
|
||||||
font-size: 0.8rem;
|
border: 2px solid #6366f1;
|
||||||
font-weight: 500;
|
box-shadow: 0 10px 28px rgba(99, 102, 241, 0.18);
|
||||||
|
}
|
||||||
|
.tier.picked {
|
||||||
|
outline: 2px solid #a5b4fc;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
.pop {
|
||||||
|
position: absolute;
|
||||||
|
top: -0.7rem;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background: linear-gradient(135deg, #6366f1, #8b5cf6);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
padding: 0.22rem 0.7rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
color: #6b7280;
|
color: #6b7280;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
}
|
}
|
||||||
|
.price { margin: 0.55rem 0 0.15rem; line-height: 1; }
|
||||||
|
.amount { font-size: 2.4rem; font-weight: 800; color: #111827; }
|
||||||
|
.period { font-size: 0.85rem; color: #9ca3af; margin-left: 0.3rem; }
|
||||||
|
.blurb { margin: 0 0 0.9rem; font-size: 0.85rem; color: #6b7280; }
|
||||||
|
|
||||||
|
form { display: flex; flex-direction: column; gap: 0.55rem; margin-top: auto; }
|
||||||
input {
|
input {
|
||||||
padding: 0.55rem 0.75rem;
|
padding: 0.5rem 0.65rem;
|
||||||
border: 1px solid #d1d5db;
|
border: 1px solid #d1d5db;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
font-size: 0.9rem;
|
font-size: 0.85rem;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
input:focus {
|
input:focus {
|
||||||
outline: 2px solid #6366f1;
|
outline: 2px solid #6366f1;
|
||||||
outline-offset: -1px;
|
outline-offset: -1px;
|
||||||
}
|
}
|
||||||
form :global(.btn) {
|
.cta {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
padding: 0.65rem 1rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1.5px solid #6366f1;
|
||||||
|
background: #fff;
|
||||||
|
color: #4338ca;
|
||||||
|
transition: background 0.15s ease, transform 0.05s ease;
|
||||||
}
|
}
|
||||||
.error {
|
.cta:hover { background: #eef2ff; }
|
||||||
|
.cta:active { transform: scale(0.98); }
|
||||||
|
.cta.primary {
|
||||||
|
background: linear-gradient(135deg, #4338ca, #6366f1);
|
||||||
|
color: #fff;
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
.cta.primary:hover { background: linear-gradient(135deg, #3730a3, #4f46e5); }
|
||||||
|
|
||||||
|
ul {
|
||||||
|
list-style: none;
|
||||||
|
margin: 1.1rem 0 0;
|
||||||
|
padding: 0.9rem 0 0;
|
||||||
|
border-top: 1px solid #f3f4f6;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.45rem;
|
||||||
|
}
|
||||||
|
li {
|
||||||
|
font-size: 0.84rem;
|
||||||
|
color: #4b5563;
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.45rem;
|
||||||
|
}
|
||||||
|
.tick { color: #059669; font-weight: 700; flex-shrink: 0; }
|
||||||
|
|
||||||
|
.form-error {
|
||||||
|
grid-column: 1 / -1;
|
||||||
color: #dc2626;
|
color: #dc2626;
|
||||||
background: #fef2f2;
|
background: #fef2f2;
|
||||||
padding: 0.5rem;
|
padding: 0.5rem 0.75rem;
|
||||||
border-radius: 6px;
|
border-radius: 8px;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
margin-top: 0.5rem;
|
margin-top: 0.75rem;
|
||||||
}
|
|
||||||
:global(.selected) {
|
|
||||||
border: 2px solid #6366f1;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -30,3 +30,24 @@ const LEGACY_DEVICE_COOKIE = 'device_token';
|
|||||||
export function clearLegacyCookies(cookies: Cookies) {
|
export function clearLegacyCookies(cookies: Cookies) {
|
||||||
cookies.delete(LEGACY_DEVICE_COOKIE, { path: '/' });
|
cookies.delete(LEGACY_DEVICE_COOKIE, { path: '/' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Platform-admin session (/admin) ──
|
||||||
|
// Holds a REAL PocketBase superuser JWT (minted by _superusers.authWithPassword
|
||||||
|
// at login) — verified per-request in hooks via authRefresh, so forging the
|
||||||
|
// cookie value gains nothing. Separate from the fam-user pb_token.
|
||||||
|
export const PLATFORM_SESSION_COOKIE = 'platform_session';
|
||||||
|
const PLATFORM_MAX_AGE = 60 * 60 * 24; // 24h; PB token expiry is the ceiling
|
||||||
|
|
||||||
|
export function setPlatformSession(cookies: Cookies, token: string) {
|
||||||
|
cookies.set(PLATFORM_SESSION_COOKIE, token, {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'lax',
|
||||||
|
path: '/',
|
||||||
|
maxAge: PLATFORM_MAX_AGE,
|
||||||
|
secure: import.meta.env.PROD
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearPlatformSession(cookies: Cookies) {
|
||||||
|
cookies.delete(PLATFORM_SESSION_COOKIE, { path: '/' });
|
||||||
|
}
|
||||||
@@ -195,7 +195,6 @@ export interface TypingRow {
|
|||||||
export interface Session {
|
export interface Session {
|
||||||
famId: string;
|
famId: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
famSlug: string;
|
|
||||||
memberName?: string;
|
memberName?: string;
|
||||||
role?: string;
|
role?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,9 @@
|
|||||||
<h2>Start your family</h2>
|
<h2>Start your family</h2>
|
||||||
<p class="card-sub">Free to get going. Takes about a minute.</p>
|
<p class="card-sub">Free to get going. Takes about a minute.</p>
|
||||||
<a class="submit" href="/signup">Create my family</a>
|
<a class="submit" href="/signup">Create my family</a>
|
||||||
|
<p class="card-alt">
|
||||||
|
<a href="/pricing">See pricing →</a>
|
||||||
|
</p>
|
||||||
<p class="card-alt">
|
<p class="card-alt">
|
||||||
Already have a family? <a href="/login">Log in</a>
|
Already have a family? <a href="/login">Log in</a>
|
||||||
</p>
|
</p>
|
||||||
@@ -94,6 +97,7 @@
|
|||||||
<div class="cta">
|
<div class="cta">
|
||||||
<p>Ready to make chores painless?</p>
|
<p>Ready to make chores painless?</p>
|
||||||
<a class="cta-btn" href="/signup">Create your family</a>
|
<a class="cta-btn" href="/signup">Create your family</a>
|
||||||
|
<p class="card-alt"><a href="/pricing">or see pricing →</a></p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -1,39 +1,117 @@
|
|||||||
import { pbAdmin } from '$lib/server/pocketbase';
|
import { pbAdmin, createPbClient } 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 type { RequestEvent } from '@sveltejs/kit';
|
||||||
|
import { setPlatformSession, clearPlatformSession } from '$lib/server/session';
|
||||||
import { getPlatformFlags, setPlatformFlag } from '$lib/server/platform';
|
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 }) => {
|
function requirePlatform(event: RequestEvent) {
|
||||||
const session = cookies.get('platform_session');
|
if (!event.locals.platformAdmin) throw redirect(303, '/admin');
|
||||||
if (!session) {
|
}
|
||||||
return { authenticated: false, fams: [], totalFams: 0, totalMembers: 0, totalRewards: 0, platformFlags: {} };
|
|
||||||
|
// Random human-friendly code value: XXXX-XXXX (unambiguous charset).
|
||||||
|
function genCodeValue(): string {
|
||||||
|
const chars = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789';
|
||||||
|
const pick = () => chars[Math.floor(Math.random() * chars.length)];
|
||||||
|
return `${Array.from({ length: 4 }, pick).join('')}-${Array.from({ length: 4 }, pick).join('')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async (event) => {
|
||||||
|
const { cookies } = event;
|
||||||
|
if (!event.locals.platformAdmin) {
|
||||||
|
return {
|
||||||
|
authenticated: false,
|
||||||
|
fams: [],
|
||||||
|
codes: [],
|
||||||
|
platformFlags: {},
|
||||||
|
totalFams: 0,
|
||||||
|
totalMembers: 0,
|
||||||
|
totalRewards: 0,
|
||||||
|
totalChores: 0,
|
||||||
|
subCount: 0,
|
||||||
|
gatedCount: 0,
|
||||||
|
codeCount: 0,
|
||||||
|
loadError: undefined
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [fams, platformFlags] = await Promise.all([pbAdmin.getList('fams'), getPlatformFlags()]);
|
const [fams, codes, completions] = await Promise.all([
|
||||||
const famsWithStats = await Promise.all(fams.map(async (fam: any) => {
|
pbAdmin.getList('fams'),
|
||||||
|
pbAdmin.getList('accesscodes'),
|
||||||
|
pbAdmin.getList('completions')
|
||||||
|
]);
|
||||||
|
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'`),
|
||||||
pbAdmin.getList('rewards', `famId = '${fam.id}'`),
|
pbAdmin.getList('rewards', `famId = '${fam.id}'`),
|
||||||
pbAdmin.getList('users', `famId = '${fam.id}' && role = 'parent'`),
|
pbAdmin.getList('users', `famId = '${fam.id}' && role = 'parent'`)
|
||||||
]);
|
]);
|
||||||
return {
|
return {
|
||||||
id: fam.id, name: fam.name, slug: fam.slug,
|
id: fam.id,
|
||||||
|
name: fam.name,
|
||||||
|
slug: fam.slug,
|
||||||
memberCount: members.length,
|
memberCount: members.length,
|
||||||
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 || '',
|
||||||
|
paymentMode: fam.paymentMode || 'none',
|
||||||
|
active: fam.active !== false
|
||||||
};
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Usage map — which fams applied each code.
|
||||||
|
const usage: Record<string, string[]> = {};
|
||||||
|
for (const fam of fams as any[]) {
|
||||||
|
if (fam.paymentMode === 'code' && fam.accessCodeId) {
|
||||||
|
(usage[fam.accessCodeId] ||= []).push(fam.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const codeList = (codes as any[])
|
||||||
|
.sort((a, b) => (b.createdAt || '').localeCompare(a.createdAt || ''))
|
||||||
|
.map((c) => ({
|
||||||
|
id: c.id,
|
||||||
|
value: c.value,
|
||||||
|
name: c.name,
|
||||||
|
duration: Number(c.duration) || 0,
|
||||||
|
expiry: Number(c.expiry) || 0,
|
||||||
|
trialDays: Number(c.trialDays) || 0,
|
||||||
|
active: c.active !== false,
|
||||||
|
usedBy: usage[c.id] || []
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const totalFams = fams.length;
|
return {
|
||||||
const totalMembers = famsWithStats.reduce((s: number, f: any) => s + f.memberCount, 0);
|
authenticated: true,
|
||||||
const totalRewards = famsWithStats.reduce((s: number, f: any) => s + f.totalRewards, 0);
|
fams: famsWithStats,
|
||||||
|
codes: codeList,
|
||||||
return { authenticated: true, fams: famsWithStats, totalFams, totalMembers, totalRewards, platformFlags };
|
totalFams: fams.length,
|
||||||
} catch {
|
totalMembers: famsWithStats.reduce((s, f) => s + f.memberCount, 0),
|
||||||
return { authenticated: false, fams: [], totalFams: 0, totalMembers: 0, totalRewards: 0, platformFlags: {} };
|
totalRewards: famsWithStats.reduce((s, f) => s + f.totalRewards, 0),
|
||||||
|
totalChores: completions.length,
|
||||||
|
subCount: famsWithStats.filter((f) => f.paymentMode === 'sub').length,
|
||||||
|
gatedCount: famsWithStats.filter(
|
||||||
|
(f) => !f.active || f.paymentMode === 'none' || f.paymentMode === 'canceled'
|
||||||
|
).length,
|
||||||
|
codeCount: codeList.filter((c) => c.active).length
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
// Never swallow silently — a failed load must not masquerade as logged-out.
|
||||||
|
console.error('[admin] load failed:', e);
|
||||||
|
return {
|
||||||
|
authenticated: false,
|
||||||
|
fams: [],
|
||||||
|
codes: [],
|
||||||
|
totalFams: 0,
|
||||||
|
totalMembers: 0,
|
||||||
|
totalRewards: 0,
|
||||||
|
totalChores: 0,
|
||||||
|
subCount: 0,
|
||||||
|
gatedCount: 0,
|
||||||
|
codeCount: 0,
|
||||||
|
loadError: e instanceof Error ? e.message : 'Failed to load platform data'
|
||||||
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -43,29 +121,32 @@ export const actions: Actions = {
|
|||||||
const email = fd.get('email') as string;
|
const email = fd.get('email') as string;
|
||||||
const password = fd.get('password') as string;
|
const password = fd.get('password') as string;
|
||||||
|
|
||||||
if (email === PB_EMAIL && password === PB_PASSWORD) {
|
if (!email || !password) return fail(400, { error: 'Email and password required' });
|
||||||
cookies.set('platform_session', 'authenticated', {
|
|
||||||
path: '/',
|
// Real PB superuser auth — the minted JWT goes in the cookie and is
|
||||||
httpOnly: true,
|
// verified per-request in hooks (authRefresh). Forging the cookie
|
||||||
sameSite: 'lax',
|
// value gains nothing.
|
||||||
maxAge: 60 * 60 * 24, // 24 hours
|
try {
|
||||||
});
|
const auth = await createPbClient()
|
||||||
return { success: true };
|
.collection('_superusers')
|
||||||
}
|
.authWithPassword(email, password);
|
||||||
|
setPlatformSession(cookies, auth.token);
|
||||||
|
} catch {
|
||||||
return fail(400, { error: 'Invalid credentials' });
|
return fail(400, { error: 'Invalid credentials' });
|
||||||
|
}
|
||||||
|
return { success: true };
|
||||||
},
|
},
|
||||||
|
|
||||||
logout: async ({ cookies }) => {
|
logout: async ({ cookies }) => {
|
||||||
cookies.delete('platform_session', { path: '/' });
|
clearPlatformSession(cookies);
|
||||||
throw redirect(303, '/admin');
|
throw redirect(303, '/admin');
|
||||||
},
|
},
|
||||||
|
|
||||||
// Toggles a platform-level feature flag on the singleton platform record.
|
// Toggles a platform-level feature flag on the singleton platform record.
|
||||||
togglePlatformFlag: async ({ request, cookies }) => {
|
togglePlatformFlag: async (event) => {
|
||||||
const session = cookies.get('platform_session');
|
requirePlatform(event);
|
||||||
if (!session) return fail(401, { error: 'Not authenticated' });
|
|
||||||
|
|
||||||
const fd = await request.formData();
|
const fd = await event.request.formData();
|
||||||
const flag = fd.get('flag') as string;
|
const flag = fd.get('flag') as string;
|
||||||
if (!flag) return fail(400, { error: 'Flag required' });
|
if (!flag) return fail(400, { error: 'Flag required' });
|
||||||
|
|
||||||
@@ -77,4 +158,72 @@ export const actions: Actions = {
|
|||||||
return fail(500, { error: e instanceof Error ? e.message : 'Failed to update' });
|
return fail(500, { error: e instanceof Error ? e.message : 'Failed to update' });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Issue a new access/trial code. Blank value → auto-generated. trialDays > 0
|
||||||
|
// makes it a trial code (maps to Stripe trial_period_days at checkout);
|
||||||
|
// otherwise it's a platform-access code (duration months, 0 = continuous).
|
||||||
|
createCode: async (event) => {
|
||||||
|
requirePlatform(event);
|
||||||
|
|
||||||
|
const fd = await event.request.formData();
|
||||||
|
const name = ((fd.get('name') as string) || '').trim();
|
||||||
|
const value = ((fd.get('value') as string) || '').trim().toUpperCase() || genCodeValue();
|
||||||
|
const duration = Math.max(0, parseInt(fd.get('duration') as string, 10) || 0);
|
||||||
|
const expiry = Math.max(0, parseInt(fd.get('expiry') as string, 10) || 0);
|
||||||
|
const trialDays = Math.max(0, parseInt(fd.get('trialDays') as string, 10) || 0);
|
||||||
|
|
||||||
|
if (!name) return fail(400, { error: 'Name required' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const existing = await pbAdmin.getList('accesscodes', `value = '${value}'`);
|
||||||
|
if (existing.length) return fail(400, { error: `Code "${value}" already exists` });
|
||||||
|
await pbAdmin.create('accesscodes', {
|
||||||
|
value,
|
||||||
|
name,
|
||||||
|
duration,
|
||||||
|
expiry,
|
||||||
|
trialDays,
|
||||||
|
active: true,
|
||||||
|
createdAt: new Date().toISOString()
|
||||||
|
});
|
||||||
|
return { success: true, createdValue: value };
|
||||||
|
} catch (e) {
|
||||||
|
return fail(500, { error: e instanceof Error ? e.message : 'Failed to create code' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleCode: async (event) => {
|
||||||
|
requirePlatform(event);
|
||||||
|
|
||||||
|
const fd = await event.request.formData();
|
||||||
|
const id = fd.get('id') as string;
|
||||||
|
try {
|
||||||
|
const rec = (await pbAdmin.getOne('accesscodes', id)) as any;
|
||||||
|
await pbAdmin.update('accesscodes', id, { active: rec.active === false });
|
||||||
|
return { success: true };
|
||||||
|
} catch (e) {
|
||||||
|
return fail(500, { error: e instanceof Error ? e.message : 'Failed to update code' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteCode: async (event) => {
|
||||||
|
requirePlatform(event);
|
||||||
|
|
||||||
|
const fd = await event.request.formData();
|
||||||
|
const id = fd.get('id') as string;
|
||||||
|
|
||||||
|
// Guard: a code still applied to a fam must be disabled, not deleted.
|
||||||
|
const inUse = await pbAdmin.getList('fams', `accessCodeId = '${id}'`);
|
||||||
|
if (inUse.length) {
|
||||||
|
return fail(400, {
|
||||||
|
error: `In use by ${inUse.length} fam${inUse.length > 1 ? 's' : ''} — disable it instead.`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await pbAdmin.remove('accesscodes', id);
|
||||||
|
return { success: true };
|
||||||
|
} catch (e) {
|
||||||
|
return fail(500, { error: e instanceof Error ? e.message : 'Failed to delete code' });
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,12 +1,43 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
|
import { goto, invalidateAll } from '$app/navigation';
|
||||||
|
import { page } from '$app/state';
|
||||||
import { ViewHeader, CardGrid, Card, Button } from '$lib/components';
|
import { ViewHeader, CardGrid, Card, Button } from '$lib/components';
|
||||||
|
import Footer from '$lib/components/Footer.svelte';
|
||||||
|
|
||||||
let { data, form } = $props();
|
let { data, form } = $props();
|
||||||
|
|
||||||
|
let copied = $state('');
|
||||||
|
function copy(v: string) {
|
||||||
|
navigator.clipboard.writeText(v);
|
||||||
|
copied = v;
|
||||||
|
setTimeout(() => (copied = ''), 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
const modeLabel: Record<string, string> = {
|
||||||
|
sub: 'Sub',
|
||||||
|
code: 'Code',
|
||||||
|
canceled: 'Canceled',
|
||||||
|
none: 'None'
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<div class="shell">
|
||||||
|
<header class="topbar">
|
||||||
|
<a class="brand" href="/"><span class="brand-icon">🏠</span> <strong>FamChore</strong></a>
|
||||||
|
<nav>
|
||||||
|
{#if data.authenticated}
|
||||||
|
<form method="POST" action="?/logout" use:enhance>
|
||||||
|
<button type="submit" class="nav-link link-btn-nav">Sign Out</button>
|
||||||
|
</form>
|
||||||
|
{:else}
|
||||||
|
<a href="/" class="nav-link">Back to site</a>
|
||||||
|
{/if}
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
{#if !data.authenticated}
|
{#if !data.authenticated}
|
||||||
<div class="login-container">
|
|
||||||
<div class="login-card">
|
<div class="login-card">
|
||||||
<h1>Platform Admin</h1>
|
<h1>Platform Admin</h1>
|
||||||
<p class="subtitle">Sign in to access the admin dashboard</p>
|
<p class="subtitle">Sign in to access the admin dashboard</p>
|
||||||
@@ -14,8 +45,27 @@
|
|||||||
{#if form?.error}
|
{#if form?.error}
|
||||||
<p class="error">{form.error}</p>
|
<p class="error">{form.error}</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if data.loadError}
|
||||||
|
<p class="error">Data load failed: {data.loadError}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<form method="POST" action="?/login" use:enhance>
|
<!-- Native document POST (no JS enhancement): 303 → full page load.
|
||||||
|
Browsers reliably store Set-Cookie from navigations, which sidesteps
|
||||||
|
fetch/HMR cookie weirdness entirely. -->
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/login"
|
||||||
|
use:enhance={() => {
|
||||||
|
return async ({ result }) => {
|
||||||
|
// Handles both action shapes: current code throws a redirect;
|
||||||
|
// stale modules may return { success }. Either way, force the
|
||||||
|
// load to re-run with the freshly-set cookie.
|
||||||
|
if (result.type === 'success') {
|
||||||
|
await goto('/admin', { invalidateAll: true });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="email">Email</label>
|
<label for="email">Email</label>
|
||||||
<input type="email" id="email" name="email" required />
|
<input type="email" id="email" name="email" required />
|
||||||
@@ -27,12 +77,11 @@
|
|||||||
<Button type="submit" variant="primary" size="lg">Sign In</Button>
|
<Button type="submit" variant="primary" size="lg">Sign In</Button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
{:else}
|
{:else}
|
||||||
<ViewHeader title="Platform Admin" subtitle="Manage families and platform settings" hero />
|
<ViewHeader title="Platform Admin" subtitle="Families, access codes and platform settings" hero />
|
||||||
|
|
||||||
<CardGrid>
|
<CardGrid>
|
||||||
<Card cols={1} title="Overview" accent="#6366f1">
|
<Card cols={2} title="Overview" accent="#6366f1">
|
||||||
<div class="stats">
|
<div class="stats">
|
||||||
<div class="stat">
|
<div class="stat">
|
||||||
<span class="stat-value">{data.totalFams}</span>
|
<span class="stat-value">{data.totalFams}</span>
|
||||||
@@ -43,8 +92,20 @@
|
|||||||
<span class="stat-label">Members</span>
|
<span class="stat-label">Members</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat">
|
<div class="stat">
|
||||||
<span class="stat-value">{data.totalRewards}</span>
|
<span class="stat-value">{data.totalChores}</span>
|
||||||
<span class="stat-label">Rewards</span>
|
<span class="stat-label">Chores completed</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<span class="stat-value">{data.subCount}</span>
|
||||||
|
<span class="stat-label">Active subs</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<span class="stat-value">{data.gatedCount}</span>
|
||||||
|
<span class="stat-label">Paused / gated</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<span class="stat-value">{data.codeCount}</span>
|
||||||
|
<span class="stat-label">Active codes</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -55,44 +116,141 @@
|
|||||||
<span class="flag-name">debug</span>
|
<span class="flag-name">debug</span>
|
||||||
<form method="POST" action="?/togglePlatformFlag" use:enhance>
|
<form method="POST" action="?/togglePlatformFlag" use:enhance>
|
||||||
<input type="hidden" name="flag" value="debug" />
|
<input type="hidden" name="flag" value="debug" />
|
||||||
<button type="submit" class="toggle-btn" class:active={data.platformFlags?.debug}>
|
<button type="submit" class="toggle-btn" class:active={page.data.platformFlags?.debug}>
|
||||||
{data.platformFlags?.debug ? 'ON' : 'OFF'}
|
{page.data.platformFlags?.debug ? 'ON' : 'OFF'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
</CardGrid>
|
||||||
|
|
||||||
<Card cols={2} title="Families" accent="#059669">
|
<CardGrid>
|
||||||
|
<Card cols={3} title="Access Codes" accent="#059669">
|
||||||
|
{#if form?.error}
|
||||||
|
<p class="error">{form.error}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Issue a code -->
|
||||||
|
<form method="POST" action="?/createCode" use:enhance class="code-form">
|
||||||
|
<div class="code-fields">
|
||||||
|
<label>Name <input name="name" placeholder="e.g. Launch promo" required /></label>
|
||||||
|
<label>Value <input name="value" placeholder="Blank = auto" class="mono" /></label>
|
||||||
|
<label>Duration (mo) <input name="duration" type="number" min="0" value="0" /></label>
|
||||||
|
<label>Expiry (mo) <input name="expiry" type="number" min="0" value="0" /></label>
|
||||||
|
<label>Trial days <input name="trialDays" type="number" min="0" value="0" /></label>
|
||||||
|
</div>
|
||||||
|
<div class="code-form-foot">
|
||||||
|
<span class="hint">
|
||||||
|
Duration 0 = continuous. Trial days > 0 makes it a Stripe trial code instead of
|
||||||
|
platform access.
|
||||||
|
</span>
|
||||||
|
<Button type="submit" variant="primary" size="sm">Issue code</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="fam-table code-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Code</th>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Used by</th>
|
||||||
|
<th class="th-actions">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each data.codes as c}
|
||||||
|
<tr class:inactive={!c.active}>
|
||||||
|
<td data-label="Code">
|
||||||
|
<button class="code-value" onclick={() => copy(c.value)} title="Copy"
|
||||||
|
>{c.value}</button
|
||||||
|
>
|
||||||
|
{#if copied === c.value}<span class="copied">copied!</span>{/if}
|
||||||
|
</td>
|
||||||
|
<td data-label="Name">{c.name}</td>
|
||||||
|
<td data-label="Type">
|
||||||
|
{#if c.trialDays}
|
||||||
|
<span class="badge trial">{c.trialDays}-day trial</span>
|
||||||
|
{:else if c.duration}
|
||||||
|
<span class="badge dur">{c.duration} mo</span>
|
||||||
|
{:else}
|
||||||
|
<span class="badge cont">continuous</span>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td data-label="Status">
|
||||||
|
<span class="badge {c.active ? 'on' : 'off'}"
|
||||||
|
>{c.active ? 'active' : 'disabled'}</span
|
||||||
|
>
|
||||||
|
</td>
|
||||||
|
<td data-label="Used by">
|
||||||
|
{#if c.usedBy.length}
|
||||||
|
<span class="used-by" title={c.usedBy.join(', ')}
|
||||||
|
>{c.usedBy.length} fam{c.usedBy.length > 1 ? 's' : ''}</span
|
||||||
|
>
|
||||||
|
{:else}
|
||||||
|
<span class="muted">—</span>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td class="row-actions" data-label="Actions">
|
||||||
|
<form method="POST" action="?/toggleCode" use:enhance>
|
||||||
|
<input type="hidden" name="id" value={c.id} />
|
||||||
|
<button type="submit" class="link-btn">{c.active ? 'Disable' : 'Enable'}</button>
|
||||||
|
</form>
|
||||||
|
{#if !c.usedBy.length}
|
||||||
|
<form method="POST" action="?/deleteCode" use:enhance>
|
||||||
|
<input type="hidden" name="id" value={c.id} />
|
||||||
|
<button type="submit" class="link-btn danger">Delete</button>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{:else}
|
||||||
|
<tr><td colspan="6" class="muted">No codes yet — issue one above.</td></tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</CardGrid>
|
||||||
|
|
||||||
|
<CardGrid>
|
||||||
|
<Card cols={3} title="Families" accent="#6366f1">
|
||||||
|
<div class="table-wrap">
|
||||||
<table class="fam-table">
|
<table class="fam-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Name</th>
|
<th>Name</th>
|
||||||
<th>Parent</th>
|
<th>Parent</th>
|
||||||
<th>Members</th>
|
<th>Members</th>
|
||||||
|
<th>Plan</th>
|
||||||
<th>Claims</th>
|
<th>Claims</th>
|
||||||
<th>Actions</th>
|
<th class="th-actions">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{#each data.fams as fam}
|
{#each data.fams as fam}
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td data-label="Name">
|
||||||
<a href="/{fam.slug}">{fam.name}</a>
|
<a href="/{fam.slug}" class="fam-link">{fam.name}</a>
|
||||||
<span class="slug">/{fam.slug}</span>
|
<span class="slug">/{fam.slug}</span>
|
||||||
</td>
|
</td>
|
||||||
<td>{fam.parentEmail || '—'}</td>
|
<td data-label="Parent">{fam.parentEmail || '—'}</td>
|
||||||
<td>{fam.memberCount}</td>
|
<td data-label="Members">{fam.memberCount}</td>
|
||||||
<td>
|
<td data-label="Plan">
|
||||||
|
<span class="badge {fam.paymentMode === 'sub' ? 'on' : fam.active ? 'dur' : 'off'}">
|
||||||
|
{modeLabel[fam.paymentMode] || fam.paymentMode}{fam.active ? '' : ' · paused'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td data-label="Claims">
|
||||||
{#if fam.requestedRewards > 0}
|
{#if fam.requestedRewards > 0}
|
||||||
<span class="badge pending">{fam.requestedRewards} pending</span>
|
<span class="badge pending">{fam.requestedRewards} pending</span>
|
||||||
{:else}
|
{:else}
|
||||||
<span class="badge none">None</span>
|
<span class="badge none">None</span>
|
||||||
{/if}
|
{/if}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td class="row-actions" data-label="Actions"><a href="/{fam.slug}" class="link">View →</a></td>
|
||||||
<a href="/{fam.slug}" class="link">Dashboard</a>
|
|
||||||
<a href="/{fam.slug}" class="link">View</a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
{/each}
|
{/each}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -100,103 +258,332 @@
|
|||||||
</Card>
|
</Card>
|
||||||
</CardGrid>
|
</CardGrid>
|
||||||
|
|
||||||
<form method="POST" action="?/logout" class="logout-form">
|
|
||||||
<Button type="submit" variant="ghost" size="sm">Sign Out</Button>
|
|
||||||
</form>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.login-container {
|
.shell {
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background: #f3f4f6;
|
display: grid;
|
||||||
|
grid-template-rows: auto 1fr auto;
|
||||||
|
background:
|
||||||
|
radial-gradient(60rem 30rem at 50% -10rem, #eef2ff 0%, transparent 65%),
|
||||||
|
#fafafa;
|
||||||
|
}
|
||||||
|
.topbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.8rem 2rem;
|
||||||
|
border-bottom: 1px solid #f3f4f6;
|
||||||
|
background: rgba(255, 255, 255, 0.85);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
}
|
||||||
|
.brand {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
color: #111827;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.topbar nav { display: flex; align-items: center; gap: 1rem; }
|
||||||
|
.nav-link {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #4b5563;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.link-btn-nav {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.link-btn-nav:hover { color: #111827; }
|
||||||
|
|
||||||
|
main {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 1.5rem;
|
||||||
|
padding: 1.5rem 2rem;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1100px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
main :global(.card-grid),
|
||||||
|
main :global(.hero) { width: 100%; }
|
||||||
|
.head { text-align: center; }
|
||||||
|
.head h1 {
|
||||||
|
margin: 0 0 0.35rem;
|
||||||
|
font-size: clamp(1.5rem, 3vw, 2rem);
|
||||||
|
font-weight: 800;
|
||||||
|
color: #111827;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
.head p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: #6b7280;
|
||||||
}
|
}
|
||||||
.login-card {
|
.login-card {
|
||||||
background: white;
|
background: white;
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 400px;
|
max-width: 380px;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
.login-card h1 {
|
.login-card h1 {
|
||||||
margin: 0 0 0.5rem;
|
margin: 0 0 0.25rem;
|
||||||
font-size: 1.5rem;
|
|
||||||
}
|
}
|
||||||
.subtitle {
|
.subtitle {
|
||||||
color: #6b7280;
|
color: #6b7280;
|
||||||
|
font-size: 0.85rem;
|
||||||
margin: 0 0 1.5rem;
|
margin: 0 0 1.5rem;
|
||||||
}
|
}
|
||||||
.form-group {
|
.form-group {
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
|
text-align: left;
|
||||||
}
|
}
|
||||||
.form-group label {
|
.form-group label {
|
||||||
display: block;
|
display: block;
|
||||||
margin-bottom: 0.25rem;
|
font-size: 0.8rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
font-size: 0.9rem;
|
color: #374151;
|
||||||
|
margin-bottom: 0.3rem;
|
||||||
}
|
}
|
||||||
.form-group input {
|
.form-group input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.5rem;
|
padding: 0.55rem 0.75rem;
|
||||||
border: 1px solid #d1d5db;
|
border: 1px solid #d1d5db;
|
||||||
border-radius: 6px;
|
border-radius: 8px;
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
.error {
|
|
||||||
color: #dc2626;
|
|
||||||
background: #fef2f2;
|
|
||||||
padding: 0.5rem;
|
|
||||||
border-radius: 6px;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
.stats {
|
.stats {
|
||||||
display: flex;
|
display: grid;
|
||||||
gap: 2rem;
|
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
}
|
}
|
||||||
.stat {
|
.stat {
|
||||||
text-align: center;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.75rem 0.5rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
color: white;
|
||||||
|
background: linear-gradient(135deg, #6366f1, #8b5cf6);
|
||||||
|
}
|
||||||
|
.stat:nth-child(2) {
|
||||||
|
background: linear-gradient(135deg, #059669, #10b981);
|
||||||
|
}
|
||||||
|
.stat:nth-child(3) {
|
||||||
|
background: linear-gradient(135deg, #d97706, #f59e0b);
|
||||||
|
}
|
||||||
|
.stat:nth-child(4) {
|
||||||
|
background: linear-gradient(135deg, #2563eb, #3b82f6);
|
||||||
|
}
|
||||||
|
.stat:nth-child(5) {
|
||||||
|
background: linear-gradient(135deg, #dc2626, #ef4444);
|
||||||
|
}
|
||||||
|
.stat:nth-child(6) {
|
||||||
|
background: linear-gradient(135deg, #7c3aed, #a855f7);
|
||||||
}
|
}
|
||||||
.stat-value {
|
.stat-value {
|
||||||
display: block;
|
font-size: 1.5rem;
|
||||||
font-size: 2rem;
|
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #1f2937;
|
|
||||||
}
|
}
|
||||||
.stat-label {
|
.stat-label {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #9ca3af;
|
||||||
|
line-height: 1.4;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.muted {
|
||||||
|
color: #9ca3af;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Codes */
|
||||||
|
.code-form {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
border-bottom: 1px solid #f3f4f6;
|
||||||
|
}
|
||||||
|
.code-fields {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 2fr 1.2fr 1fr 1fr 1fr;
|
||||||
|
gap: 0.6rem;
|
||||||
|
margin-bottom: 0.6rem;
|
||||||
|
}
|
||||||
|
.code-fields label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
color: #6b7280;
|
color: #6b7280;
|
||||||
}
|
}
|
||||||
|
.code-fields input {
|
||||||
|
padding: 0.45rem 0.6rem;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.mono {
|
||||||
|
font-family: ui-monospace, monospace;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.code-form-foot {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Tables: clean rows, hover, responsive stack ── */
|
||||||
|
.table-wrap {
|
||||||
|
width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
border: 1px solid #eef0f3;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
.fam-table {
|
.fam-table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
font-size: 0.9rem;
|
font-size: 0.88rem;
|
||||||
}
|
}
|
||||||
.fam-table th, .fam-table td {
|
.fam-table thead th {
|
||||||
padding: 0.5rem;
|
|
||||||
border-bottom: 1px solid #e5e7eb;
|
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
font-size: 0.72rem;
|
||||||
.fam-table th {
|
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: #9ca3af;
|
||||||
|
padding: 0.7rem 1rem;
|
||||||
|
border-bottom: 1px solid #f0f1f4;
|
||||||
|
background: #fafbfc;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.fam-table tbody td {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-bottom: 1px solid #f6f7f8;
|
||||||
|
vertical-align: middle;
|
||||||
color: #374151;
|
color: #374151;
|
||||||
}
|
}
|
||||||
.slug {
|
.fam-table tbody tr:last-child td { border-bottom: none; }
|
||||||
color: #9ca3af;
|
.fam-table tbody tr { transition: background 0.12s ease; }
|
||||||
font-size: 0.8rem;
|
.fam-table tbody tr:hover { background: #f8faff; }
|
||||||
margin-left: 0.25rem;
|
.th-actions { text-align: right; }
|
||||||
|
|
||||||
|
.code-value {
|
||||||
|
font-family: ui-monospace, monospace;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
background: #eef2ff;
|
||||||
|
border: 1px dashed #a5b4fc;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.15rem 0.45rem;
|
||||||
|
cursor: copy;
|
||||||
}
|
}
|
||||||
|
.copied {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: #059669;
|
||||||
|
margin-left: 0.35rem;
|
||||||
|
}
|
||||||
|
tr.inactive td {
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
.badge {
|
.badge {
|
||||||
font-size: 0.75rem;
|
font-size: 0.72rem;
|
||||||
padding: 2px 8px;
|
padding: 2px 8px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
.badge.pending { background: #fef3c7; color: #92400e; }
|
.badge.on {
|
||||||
.badge.none { background: #f3f4f6; color: #6b7280; }
|
background: #d1fae5;
|
||||||
|
color: #065f46;
|
||||||
|
}
|
||||||
|
.badge.off {
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #991b1b;
|
||||||
|
}
|
||||||
|
.badge.trial {
|
||||||
|
background: #fef3c7;
|
||||||
|
color: #92400e;
|
||||||
|
}
|
||||||
|
.badge.dur {
|
||||||
|
background: #e0e7ff;
|
||||||
|
color: #3730a3;
|
||||||
|
}
|
||||||
|
.badge.cont {
|
||||||
|
background: #f3f4f6;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
.badge.pending {
|
||||||
|
background: #fef3c7;
|
||||||
|
color: #92400e;
|
||||||
|
}
|
||||||
|
.badge.none {
|
||||||
|
background: #f3f4f6;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.row-actions form {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.link-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #4338ca;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.link-btn:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.link-btn.danger {
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
.used-by {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flag-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.35rem 0;
|
||||||
|
}
|
||||||
|
.flag-name {
|
||||||
|
font-family: ui-monospace, monospace;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
.toggle-btn {
|
.toggle-btn {
|
||||||
background: #e5e7eb;
|
background: #e5e7eb;
|
||||||
border: none;
|
border: none;
|
||||||
@@ -210,28 +597,76 @@
|
|||||||
background: #059669;
|
background: #059669;
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
.flag-row {
|
|
||||||
display: flex;
|
.fam-link {
|
||||||
align-items: center;
|
color: #111827;
|
||||||
justify-content: space-between;
|
font-weight: 600;
|
||||||
gap: 0.5rem;
|
text-decoration: none;
|
||||||
padding: 0.35rem 0;
|
|
||||||
}
|
|
||||||
.flag-name {
|
|
||||||
font-family: monospace;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: #374151;
|
|
||||||
}
|
}
|
||||||
|
.fam-link:hover { color: #4338ca; }
|
||||||
.link {
|
.link {
|
||||||
color: #2563eb;
|
color: #4338ca;
|
||||||
|
font-weight: 500;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
margin-right: 0.5rem;
|
margin-right: 0.5rem;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
.link:hover { text-decoration: underline; }
|
.link:hover {
|
||||||
.logout-form {
|
text-decoration: underline;
|
||||||
position: fixed;
|
}
|
||||||
top: 1rem;
|
.slug {
|
||||||
right: 1rem;
|
font-size: 0.75rem;
|
||||||
|
color: #9ca3af;
|
||||||
|
margin-left: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.code-fields {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Responsive: stack rows into cards with labels ── */
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.table-wrap { border: none; overflow: visible; background: transparent; }
|
||||||
|
.fam-table,
|
||||||
|
.fam-table tbody,
|
||||||
|
.fam-table tr,
|
||||||
|
.fam-table td {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.fam-table thead { display: none; }
|
||||||
|
.fam-table tbody tr {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 12px;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
padding: 0.35rem 0.25rem;
|
||||||
|
}
|
||||||
|
.fam-table tbody td {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border-bottom: 1px dashed #f0f1f4;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.fam-table tbody td::before {
|
||||||
|
content: attr(data-label);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: #9ca3af;
|
||||||
|
text-align: left;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.fam-table tbody td:empty { display: none; }
|
||||||
|
.row-actions {
|
||||||
|
justify-content: flex-end !important;
|
||||||
|
border-bottom: none !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
import { PUBLIC_STRIPE_PUBLISHABLE_KEY } from '$app/env/public';
|
import { PUBLIC_STRIPE_PUBLISHABLE_KEY } from '$app/env/public';
|
||||||
import { loadStripe, type StripeEmbeddedCheckout } from '@stripe/stripe-js';
|
import { loadStripe, type StripeEmbeddedCheckout } from '@stripe/stripe-js';
|
||||||
import { ViewHeader, CardGrid, Card, Button, PricingPlans } from '$lib/components';
|
import { Button, PricingPlans } from '$lib/components';
|
||||||
|
import Footer from '$lib/components/Footer.svelte';
|
||||||
|
|
||||||
let { data, form } = $props();
|
let { data, form } = $props();
|
||||||
|
|
||||||
@@ -45,55 +46,149 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<ViewHeader title="Pricing" subtitle="Choose a plan for your family" hero />
|
<svelte:head><title>Pricing — FamChore</title></svelte:head>
|
||||||
|
|
||||||
{#if !showCheckout}
|
<div class="shell">
|
||||||
<PricingPlans
|
<header class="topbar">
|
||||||
action="?/choose"
|
<a class="brand" href="/"><span class="brand-icon">🏠</span> <strong>FamChore</strong></a>
|
||||||
selected=""
|
<nav>
|
||||||
error={form?.error}
|
<a href="/login" class="nav-link">Log in</a>
|
||||||
onsubmit={handleChoose}
|
<a href="/signup" class="nav-cta">Get started</a>
|
||||||
/>
|
</nav>
|
||||||
{:else}
|
</header>
|
||||||
<CardGrid>
|
|
||||||
<Card title={`Checkout — ${checkoutTitle}`} accent="#059669">
|
<main>
|
||||||
<div class="checkout-actions">
|
{#if !showCheckout}
|
||||||
|
<div class="head">
|
||||||
|
<h1>Simple pricing for every family</h1>
|
||||||
|
<p>One price, the whole family. Start free — upgrade whenever you're ready.</p>
|
||||||
|
</div>
|
||||||
|
<PricingPlans action="?/choose" selected="" error={form?.error} onsubmit={handleChoose} />
|
||||||
|
{:else}
|
||||||
|
<div class="checkout-wrap">
|
||||||
|
<div class="checkout-card">
|
||||||
|
<div class="checkout-head">
|
||||||
|
<h2>Checkout — {checkoutTitle}</h2>
|
||||||
<Button variant="ghost" size="sm" onclick={resetCheckout}>← Back to plans</Button>
|
<Button variant="ghost" size="sm" onclick={resetCheckout}>← Back to plans</Button>
|
||||||
</div>
|
</div>
|
||||||
<div bind:this={checkoutEl} class="checkout-host"></div>
|
<div bind:this={checkoutEl} class="checkout-host"></div>
|
||||||
<p class="hint" style="margin-top: 1rem;">
|
<p class="hint">
|
||||||
You can close and go to your dashboard any time — access unlocks once payment completes.
|
You can close and go to your dashboard any time — access unlocks once payment completes.
|
||||||
</p>
|
</p>
|
||||||
<a href={data.famSlug ? `/${data.famSlug}` : '/'} class="btn-primary" style="margin-top: 1rem; display: inline-block;">Go to dashboard</a>
|
<a href={data.famSlug ? `/${data.famSlug}` : '/'} class="dash-link">Go to dashboard →</a>
|
||||||
</Card>
|
</div>
|
||||||
</CardGrid>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.hint {
|
/* Full-height shell: topbar / centered content / footer — no scroll on desktop. */
|
||||||
font-size: 0.85rem;
|
.shell {
|
||||||
color: #9ca3af;
|
min-height: 100vh;
|
||||||
line-height: 1.4;
|
display: grid;
|
||||||
|
grid-template-rows: auto 1fr auto;
|
||||||
|
background:
|
||||||
|
radial-gradient(60rem 30rem at 50% -10rem, #eef2ff 0%, transparent 65%),
|
||||||
|
#fafafa;
|
||||||
}
|
}
|
||||||
.checkout-actions {
|
.topbar {
|
||||||
margin-bottom: 0.75rem;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.8rem 2rem;
|
||||||
|
border-bottom: 1px solid #f3f4f6;
|
||||||
|
background: rgba(255, 255, 255, 0.85);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
}
|
}
|
||||||
.checkout-host {
|
.brand {
|
||||||
min-height: 480px;
|
display: inline-flex;
|
||||||
}
|
align-items: center;
|
||||||
.checkout-host :global(iframe) {
|
gap: 0.45rem;
|
||||||
width: 100%;
|
font-size: 1.05rem;
|
||||||
}
|
color: #111827;
|
||||||
.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;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
.btn-primary:hover { background: #3730a3; }
|
.nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.topbar nav { display: flex; align-items: center; gap: 1rem; }
|
||||||
|
.nav-link {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #4b5563;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.nav-link:hover { color: #111827; }
|
||||||
|
.nav-cta {
|
||||||
|
font-size: 0.88rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #fff;
|
||||||
|
text-decoration: none;
|
||||||
|
background: linear-gradient(135deg, #4338ca, #6366f1);
|
||||||
|
padding: 0.45rem 1rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
.nav-cta:hover { background: linear-gradient(135deg, #3730a3, #4f46e5); }
|
||||||
|
|
||||||
|
main {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 1.75rem;
|
||||||
|
padding: 1.5rem 2rem;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1040px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
.head { text-align: center; }
|
||||||
|
.head h1 {
|
||||||
|
margin: 0 0 0.35rem;
|
||||||
|
font-size: clamp(1.5rem, 3vw, 2.1rem);
|
||||||
|
font-weight: 800;
|
||||||
|
color: #111827;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
.head p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkout-wrap { width: 100%; max-width: 720px; }
|
||||||
|
.checkout-card {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 1.25rem 1.5rem 1.5rem;
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
.checkout-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
.checkout-head h2 { margin: 0; font-size: 1.05rem; color: #111827; }
|
||||||
|
.checkout-host { min-height: 420px; }
|
||||||
|
.checkout-host :global(iframe) { width: 100%; }
|
||||||
|
.hint { font-size: 0.82rem; color: #9ca3af; margin: 0.75rem 0 0; }
|
||||||
|
.dash-link {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
color: #4338ca;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.dash-link:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
@media (max-height: 760px) {
|
||||||
|
main { gap: 1rem; padding-top: 0.75rem; padding-bottom: 0.75rem; }
|
||||||
|
.head h1 { font-size: 1.35rem; }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
Reference in New Issue
Block a user