add platform pages

This commit is contained in:
JCEEE
2026-08-23 15:12:19 +01:00
parent 762fbab4b6
commit bb2b1759dc
11 changed files with 1044 additions and 245 deletions
+132 -76
View File
@@ -1,13 +1,13 @@
<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;
blurb: string;
features: string[];
cta: string;
featured?: boolean;
}
@@ -17,16 +17,18 @@
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'
period: 'for the trial period',
blurb: 'Kick the tires on us.',
features: ['Full family access', 'No card required', 'Code from us or a partner'],
cta: 'Start free trial'
},
{
id: 'monthly',
name: 'Monthly',
price: '£3',
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',
featured: true
},
@@ -35,7 +37,8 @@
name: 'Yearly',
price: '£30',
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'
}
];
@@ -49,104 +52,157 @@
} = $props();
</script>
<CardGrid>
<div class="tiers">
{#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">
<span class="amount">{tier.price}</span>
<span class="period">{tier.period}</span>
</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}
>
<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}
<form method="POST" action={action} use:enhance={onsubmit ?? undefined}>
<input type="hidden" name="plan" value={tier.id} />
{#if tier.id === 'trial'}
<input name="code" placeholder="Trial code" autocomplete="off" />
{/if}
<button type="submit" class="cta" class:primary={tier.featured}>{tier.cta}</button>
</form>
{#if error}
<p class="error">{error}</p>
{/if}
</Card>
<ul>
{#each tier.features as f}
<li><span class="tick">✓</span>{f}</li>
{/each}
</ul>
</article>
{/each}
</CardGrid>
</div>
{#if error}
<p class="form-error">{error}</p>
{/if}
<style>
.hint {
font-size: 0.85rem;
color: #9ca3af;
line-height: 1.4;
.tiers {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
gap: 1.25rem;
align-items: stretch;
width: 100%;
}
.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 {
.tier {
position: relative;
display: flex;
flex-direction: column;
gap: 0.6rem;
margin-top: 0.75rem;
background: #fff;
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 {
font-size: 0.8rem;
font-weight: 500;
.tier.featured {
border: 2px solid #6366f1;
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;
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 {
padding: 0.55rem 0.75rem;
padding: 0.5rem 0.65rem;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 0.9rem;
font-size: 0.85rem;
text-transform: uppercase;
}
input:focus {
outline: 2px solid #6366f1;
outline-offset: -1px;
}
form :global(.btn) {
.cta {
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;
background: #fef2f2;
padding: 0.5rem;
border-radius: 6px;
padding: 0.5rem 0.75rem;
border-radius: 8px;
font-size: 0.85rem;
margin-top: 0.5rem;
}
:global(.selected) {
border: 2px solid #6366f1;
margin-top: 0.75rem;
}
</style>
+21
View File
@@ -29,4 +29,25 @@ const LEGACY_DEVICE_COOKIE = 'device_token';
export function clearLegacyCookies(cookies: Cookies) {
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: '/' });
}
-1
View File
@@ -195,7 +195,6 @@ export interface TypingRow {
export interface Session {
famId: string;
userId: string;
famSlug: string;
memberName?: string;
role?: string;
}