1198 lines
30 KiB
Svelte
1198 lines
30 KiB
Svelte
<script lang="ts">
|
|
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 FamDone from '$lib/components/FamDone.svelte';
|
|
import TemplateIcon from '$lib/components/TemplateIcon.svelte';
|
|
import { ICON_NAMES, TEMPLATE_COLORS } from '$lib/templateIcons';
|
|
import Footer from '$lib/components/Footer.svelte';
|
|
import { homeIcon, chevronRightIcon } from '$lib/components/icons';
|
|
|
|
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'
|
|
};
|
|
|
|
// ── Platform template management ──
|
|
type TplKind = 'chore' | 'reward';
|
|
let showTplModal = $state(false);
|
|
let editingTpl = $state<{ kind: TplKind; id: string } | null>(null);
|
|
let tplType = $state<TplKind>('chore');
|
|
const blankTpl = {
|
|
name: '',
|
|
description: '',
|
|
icon: 'Star',
|
|
color: '#6366f1',
|
|
defaultFrequency: 'daily',
|
|
defaultType: 'points',
|
|
defaultValue: 0,
|
|
target: 'individual',
|
|
bonusType: 'threshold',
|
|
thresholdType: 'points',
|
|
occurrence: 'recurring',
|
|
rewardType: 'points',
|
|
rewardValue: '',
|
|
criteriaValue: 0,
|
|
period: 'daily'
|
|
};
|
|
let tplVals = $state({ ...blankTpl });
|
|
|
|
function openNewTpl(kind: TplKind) {
|
|
editingTpl = null;
|
|
tplType = kind;
|
|
tplVals = { ...blankTpl };
|
|
showTplModal = true;
|
|
}
|
|
function openEditTpl(kind: TplKind, rec: any) {
|
|
editingTpl = { kind, id: rec.id };
|
|
tplType = kind;
|
|
tplVals = {
|
|
name: rec.name || '',
|
|
description: rec.description || '',
|
|
icon: rec.icon || 'Star',
|
|
color: rec.color || '#6366f1',
|
|
defaultFrequency: rec.defaultFrequency || 'daily',
|
|
defaultType: rec.defaultType || 'points',
|
|
defaultValue: rec.defaultValue ?? 0,
|
|
target: rec.target || 'individual',
|
|
bonusType: rec.type || 'threshold',
|
|
thresholdType: rec.thresholdType || 'points',
|
|
occurrence: rec.occurrence || 'recurring',
|
|
rewardType: rec.rewardType || 'points',
|
|
rewardValue: rec.rewardValue ?? '',
|
|
criteriaValue: rec.criteriaValue ?? 0,
|
|
period: rec.period || ''
|
|
};
|
|
showTplModal = true;
|
|
}
|
|
</script>
|
|
|
|
<div class="shell">
|
|
<header class="topbar">
|
|
<a class="brand" href="/"
|
|
><span class="brand-icon">{@html homeIcon}</span> <strong><FamDone /></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}
|
|
<div class="login-card">
|
|
<h1>Platform Admin</h1>
|
|
<p class="subtitle">Sign in to access the admin dashboard</p>
|
|
|
|
{#if form?.error}
|
|
<p class="error">{form.error}</p>
|
|
{/if}
|
|
{#if data.loadError}
|
|
<p class="error">Data load failed: {data.loadError}</p>
|
|
{/if}
|
|
|
|
<!-- 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">
|
|
<label for="email">Email</label>
|
|
<input type="email" id="email" name="email" required />
|
|
</div>
|
|
<div class="form-group">
|
|
<label for="password">Password</label>
|
|
<input type="password" id="password" name="password" required />
|
|
</div>
|
|
<Button type="submit" variant="primary" size="lg">Sign In</Button>
|
|
</form>
|
|
</div>
|
|
{:else}
|
|
<ViewHeader
|
|
title="Platform Admin"
|
|
subtitle="Families, access codes and platform settings"
|
|
hero
|
|
/>
|
|
|
|
<CardGrid>
|
|
<Card cols={2} title="Overview" accent="#6366f1">
|
|
<div class="stats">
|
|
<div class="stat">
|
|
<span class="stat-value">{data.totalFams}</span>
|
|
<span class="stat-label">Families</span>
|
|
</div>
|
|
<div class="stat">
|
|
<span class="stat-value">{data.totalMembers}</span>
|
|
<span class="stat-label">Members</span>
|
|
</div>
|
|
<div class="stat">
|
|
<span class="stat-value">{data.totalChores}</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>
|
|
</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={page.data.platformFlags?.debug}
|
|
>
|
|
{page.data.platformFlags?.debug ? 'ON' : 'OFF'}
|
|
</button>
|
|
</form>
|
|
</div>
|
|
<div class="flag-row">
|
|
<span class="flag-name">rewardsTargets</span>
|
|
<span class="flag-desc">Multi-target reward rules (Competition / Collaboration)</span>
|
|
<form method="POST" action="?/togglePlatformFlag" use:enhance>
|
|
<input type="hidden" name="flag" value="rewardsTargets" />
|
|
<button
|
|
type="submit"
|
|
class="toggle-btn"
|
|
class:active={page.data.platformFlags?.rewardsTargets}
|
|
>
|
|
{page.data.platformFlags?.rewardsTargets ? 'ON' : 'OFF'}
|
|
</button>
|
|
</form>
|
|
</div>
|
|
</Card>
|
|
</CardGrid>
|
|
|
|
<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">
|
|
<thead>
|
|
<tr>
|
|
<th>Name</th>
|
|
<th>Parent</th>
|
|
<th>Members</th>
|
|
<th>Plan</th>
|
|
<th>Claims</th>
|
|
<th class="th-actions">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{#each data.fams as fam}
|
|
<tr>
|
|
<td data-label="Name">
|
|
<a href="/{fam.slug}" class="fam-link">{fam.name}</a>
|
|
<span class="slug">/{fam.slug}</span>
|
|
</td>
|
|
<td data-label="Parent">{fam.parentEmail || '—'}</td>
|
|
<td data-label="Members">{fam.memberCount}</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}
|
|
<span class="badge pending">{fam.requestedRewards} pending</span>
|
|
{:else}
|
|
<span class="badge none">None</span>
|
|
{/if}
|
|
</td>
|
|
<td class="row-actions" data-label="Actions"
|
|
><a href="/{fam.slug}" class="link">View {@html chevronRightIcon}</a></td
|
|
>
|
|
</tr>
|
|
{/each}
|
|
</tbody>
|
|
</table>
|
|
</div></Card
|
|
>
|
|
</CardGrid>
|
|
|
|
<CardGrid>
|
|
<Card cols={2} title="Chore Templates" accent="#2563eb">
|
|
<div class="tpl-grid">
|
|
{#each data.choreTemplates || [] as t}
|
|
<div class="tpl-card" style="border-left-color:{t.color}">
|
|
<div class="tpl-head">
|
|
<TemplateIcon name={t.icon} size={20} color={t.color} />
|
|
<strong>{t.name}</strong>
|
|
</div>
|
|
<div class="tpl-meta">
|
|
<span class="badge chore">{t.defaultFrequency}</span>
|
|
<span class="badge">{t.defaultType}</span>
|
|
</div>
|
|
<div class="tpl-actions">
|
|
<button class="link-btn" onclick={() => openEditTpl('chore', t)}>Edit</button>
|
|
<form
|
|
method="POST"
|
|
action="?/deleteTemplate"
|
|
use:enhance={() => {
|
|
return async ({ update }) => {
|
|
update();
|
|
};
|
|
}}
|
|
>
|
|
<input type="hidden" name="kind" value="chore" />
|
|
<input type="hidden" name="id" value={t.id} />
|
|
<button
|
|
type="submit"
|
|
class="link-btn danger"
|
|
onclick={(e) => {
|
|
if (!confirm('Delete this template?')) e.preventDefault();
|
|
}}>Delete</button
|
|
>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
{/each}
|
|
{#if (data.choreTemplates || []).length === 0}
|
|
<p class="muted">No chore templates yet.</p>
|
|
{/if}
|
|
</div>
|
|
<div class="tpl-new">
|
|
<Button onclick={() => openNewTpl('chore')}>+ Chore template</Button>
|
|
</div>
|
|
</Card>
|
|
|
|
<Card cols={1} title="Reward Templates" accent="#8b5cf6">
|
|
<div class="tpl-grid">
|
|
{#each data.bonusTemplates || [] as t}
|
|
<div class="tpl-card" style="border-left-color:{t.color}">
|
|
<div class="tpl-head">
|
|
<TemplateIcon name={t.icon} size={20} color={t.color} />
|
|
<strong>{t.name}</strong>
|
|
</div>
|
|
<div class="tpl-meta">
|
|
<span class="badge reward">{t.type}</span>
|
|
<span class="badge">{t.rewardType}</span>
|
|
{#if t.isPocketMoney}<span class="badge pocket">Pocket Money</span>{/if}
|
|
</div>
|
|
<div class="tpl-actions">
|
|
<button class="link-btn" onclick={() => openEditTpl('reward', t)}>Edit</button>
|
|
<form
|
|
method="POST"
|
|
action="?/deleteTemplate"
|
|
use:enhance={() => {
|
|
return async ({ update }) => {
|
|
update();
|
|
};
|
|
}}
|
|
>
|
|
<input type="hidden" name="kind" value="reward" />
|
|
<input type="hidden" name="id" value={t.id} />
|
|
<button
|
|
type="submit"
|
|
class="link-btn danger"
|
|
onclick={(e) => {
|
|
if (!confirm('Delete this template?')) e.preventDefault();
|
|
}}>Delete</button
|
|
>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
{/each}
|
|
{#if (data.bonusTemplates || []).length === 0}
|
|
<p class="muted">No reward templates yet.</p>
|
|
{/if}
|
|
</div>
|
|
<div class="tpl-new">
|
|
<Button onclick={() => openNewTpl('reward')}>+ Reward template</Button>
|
|
</div>
|
|
</Card>
|
|
</CardGrid>
|
|
|
|
{#if showTplModal}
|
|
<div class="overlay" onclick={() => (showTplModal = false)} role="presentation">
|
|
<div class="modal" onclick={(e) => e.stopPropagation()} role="dialog">
|
|
<h3>{editingTpl ? 'Edit' : 'New'} {tplType} template</h3>
|
|
<form
|
|
method="POST"
|
|
action={editingTpl ? '?/updateTemplate' : '?/createTemplate'}
|
|
use:enhance={() => {
|
|
return async ({ result, update }) => {
|
|
if (result.type === 'success') showTplModal = false;
|
|
update();
|
|
};
|
|
}}
|
|
>
|
|
{#if editingTpl}
|
|
<input type="hidden" name="kind" value={editingTpl.kind} />
|
|
<input type="hidden" name="id" value={editingTpl.id} />
|
|
{/if}
|
|
<input type="hidden" name="type" value={tplType} />
|
|
<label>Name <input name="name" bind:value={tplVals.name} required /></label>
|
|
<label
|
|
>Description <input name="description" bind:value={tplVals.description} /></label
|
|
>
|
|
<div class="row">
|
|
<label>
|
|
Icon
|
|
<select name="icon" bind:value={tplVals.icon}>
|
|
{#each ICON_NAMES as n}<option value={n}>{n}</option>{/each}
|
|
</select>
|
|
</label>
|
|
<span class="icon-preview"
|
|
><TemplateIcon name={tplVals.icon} size={22} color={tplVals.color} /></span
|
|
>
|
|
<div class="color-field">
|
|
Colour
|
|
<div class="color-swatches">
|
|
{#each TEMPLATE_COLORS as c}
|
|
<button
|
|
type="button"
|
|
class="swatch"
|
|
class:selected={tplVals.color === c.value}
|
|
style="background:{c.value || 'transparent'}; border-color:{c.chromatic ===
|
|
'transparent'
|
|
? '#e5e7eb'
|
|
: c.value}"
|
|
title={c.label}
|
|
onclick={() => (tplVals.color = c.value)}
|
|
></button>
|
|
{/each}
|
|
</div>
|
|
<input type="hidden" name="color" value={tplVals.color} />
|
|
</div>
|
|
</div>
|
|
|
|
{#if tplType === 'chore'}
|
|
<label>
|
|
Frequency
|
|
<select name="defaultFrequency" bind:value={tplVals.defaultFrequency}>
|
|
<option value="daily">Daily</option>
|
|
<option value="weekly">Weekly</option>
|
|
</select>
|
|
</label>
|
|
<label>
|
|
Type
|
|
<select name="defaultType" bind:value={tplVals.defaultType}>
|
|
<option value="points">Points</option>
|
|
<option value="money">Money</option>
|
|
</select>
|
|
</label>
|
|
<label>
|
|
Default Value
|
|
<input
|
|
type="number"
|
|
step="any"
|
|
name="defaultValue"
|
|
bind:value={tplVals.defaultValue}
|
|
/>
|
|
</label>
|
|
{:else}
|
|
<label>
|
|
Target
|
|
<select name="target" bind:value={tplVals.target}>
|
|
<option value="individual">Individual</option>
|
|
<option value="competitive">Competition</option>
|
|
<option value="collaborative">Collaborative</option>
|
|
</select>
|
|
</label>
|
|
<label>
|
|
Type
|
|
<select name="bonusType" bind:value={tplVals.bonusType}>
|
|
<option value="threshold">Threshold</option>
|
|
<option value="count">Count - (chores)</option>
|
|
<option value="manual">Manual</option>
|
|
</select>
|
|
</label>
|
|
{#if tplVals.bonusType === 'threshold'}
|
|
<label>
|
|
Threshold by
|
|
<select name="thresholdType" bind:value={tplVals.thresholdType}>
|
|
<option value="points">Points</option>
|
|
<option value="percent">% of chores done</option>
|
|
</select>
|
|
</label>
|
|
{/if}
|
|
<label>
|
|
Occurrence
|
|
<select name="occurrence" bind:value={tplVals.occurrence}>
|
|
<option value="recurring">Recurring</option>
|
|
<option value="once">Once</option>
|
|
</select>
|
|
</label>
|
|
<label>
|
|
Reward Type
|
|
<select name="rewardType" bind:value={tplVals.rewardType}>
|
|
<option value="points">Points</option>
|
|
<option value="cash">Cash</option>
|
|
<option value="prize">Prize</option>
|
|
</select>
|
|
</label>
|
|
<label>
|
|
Reward Value
|
|
<input name="rewardValue" bind:value={tplVals.rewardValue} />
|
|
</label>
|
|
{#if tplVals.bonusType !== 'manual'}
|
|
<label>
|
|
Target Value
|
|
<input type="number" name="criteriaValue" bind:value={tplVals.criteriaValue} />
|
|
</label>
|
|
{/if}
|
|
{#if tplVals.bonusType === 'manual'}
|
|
<label>
|
|
Period
|
|
<select name="period" bind:value={tplVals.period}>
|
|
<option value="">Unlimited</option>
|
|
<option value="daily">Daily</option>
|
|
<option value="weekly">Weekly</option>
|
|
<option value="monthly">Monthly</option>
|
|
</select>
|
|
</label>
|
|
{/if}
|
|
|
|
{/if}
|
|
|
|
<div class="modal-actions">
|
|
<button type="button" onclick={() => (showTplModal = false)}>Cancel</button>
|
|
<button type="submit">{editingTpl ? 'Save' : 'Create'}</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
{/if}
|
|
</main>
|
|
|
|
<Footer />
|
|
</div>
|
|
|
|
<style>
|
|
.shell {
|
|
min-height: 100vh;
|
|
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 {
|
|
background: white;
|
|
padding: 2rem;
|
|
border-radius: 12px;
|
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
|
width: 100%;
|
|
max-width: 380px;
|
|
text-align: center;
|
|
}
|
|
.login-card h1 {
|
|
margin: 0 0 0.25rem;
|
|
}
|
|
.subtitle {
|
|
color: #6b7280;
|
|
font-size: 0.85rem;
|
|
margin: 0 0 1.5rem;
|
|
}
|
|
.form-group {
|
|
margin-bottom: 1rem;
|
|
text-align: left;
|
|
}
|
|
.form-group label {
|
|
display: block;
|
|
font-size: 0.8rem;
|
|
font-weight: 500;
|
|
color: #374151;
|
|
margin-bottom: 0.3rem;
|
|
}
|
|
.form-group input {
|
|
width: 100%;
|
|
padding: 0.55rem 0.75rem;
|
|
border: 1px solid #d1d5db;
|
|
border-radius: 8px;
|
|
font-size: 0.9rem;
|
|
}
|
|
.stats {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
|
gap: 0.75rem;
|
|
}
|
|
.stat {
|
|
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 {
|
|
font-size: 1.5rem;
|
|
font-weight: 700;
|
|
}
|
|
.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;
|
|
}
|
|
|
|
/* 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;
|
|
}
|
|
.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 {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
font-size: 0.88rem;
|
|
}
|
|
.fam-table thead th {
|
|
text-align: left;
|
|
font-size: 0.72rem;
|
|
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;
|
|
}
|
|
.fam-table tbody tr:last-child td {
|
|
border-bottom: none;
|
|
}
|
|
.fam-table tbody tr {
|
|
transition: background 0.12s ease;
|
|
}
|
|
.fam-table tbody tr:hover {
|
|
background: #f8faff;
|
|
}
|
|
.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 {
|
|
font-size: 0.72rem;
|
|
padding: 2px 8px;
|
|
border-radius: 10px;
|
|
font-weight: 500;
|
|
}
|
|
.badge.on {
|
|
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;
|
|
}
|
|
.flag-desc {
|
|
font-size: 0.7rem;
|
|
color: #9ca3af;
|
|
text-align: center;
|
|
}
|
|
|
|
.toggle-btn {
|
|
background: #e5e7eb;
|
|
border: none;
|
|
padding: 0.25rem 0.5rem;
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
font-size: 0.75rem;
|
|
font-weight: 600;
|
|
}
|
|
.toggle-btn.active {
|
|
background: #059669;
|
|
color: white;
|
|
}
|
|
|
|
.fam-link {
|
|
color: #111827;
|
|
font-weight: 600;
|
|
text-decoration: none;
|
|
}
|
|
.fam-link:hover {
|
|
color: #4338ca;
|
|
}
|
|
.link {
|
|
color: #4338ca;
|
|
font-weight: 500;
|
|
text-decoration: none;
|
|
margin-right: 0.5rem;
|
|
font-size: 0.85rem;
|
|
}
|
|
.link:hover {
|
|
text-decoration: underline;
|
|
}
|
|
.slug {
|
|
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;
|
|
}
|
|
}
|
|
|
|
/* ── Template management ── */
|
|
.tpl-grid {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 0.6rem;
|
|
margin: 0.75rem 0;
|
|
}
|
|
.tpl-card {
|
|
flex: 0 1 auto;
|
|
min-width: 180px;
|
|
background: #fff;
|
|
border: 1px solid #e5e7eb;
|
|
border-left: 4px solid #8b5cf6;
|
|
border-radius: 10px;
|
|
padding: 0.6rem 0.75rem;
|
|
}
|
|
.tpl-head {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
margin-bottom: 0.35rem;
|
|
}
|
|
.tpl-head strong {
|
|
font-size: 0.9rem;
|
|
}
|
|
.tpl-meta {
|
|
display: flex;
|
|
gap: 0.3rem;
|
|
flex-wrap: wrap;
|
|
margin-bottom: 0.4rem;
|
|
}
|
|
.tpl-actions {
|
|
display: flex;
|
|
gap: 0.75rem;
|
|
}
|
|
.tpl-new {
|
|
display: flex;
|
|
gap: 0.6rem;
|
|
margin-top: 0.5rem;
|
|
}
|
|
.row {
|
|
display: flex;
|
|
align-items: flex-end;
|
|
gap: 0.75rem;
|
|
flex-wrap: wrap;
|
|
}
|
|
.color-field {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.3rem;
|
|
font-size: 0.85rem;
|
|
font-weight: 600;
|
|
color: #6b7280;
|
|
}
|
|
.color-swatches {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 0.4rem;
|
|
}
|
|
.swatch {
|
|
width: 26px;
|
|
height: 26px;
|
|
border-radius: 50%;
|
|
border: 2px solid #e5e7eb;
|
|
cursor: pointer;
|
|
padding: 0;
|
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08);
|
|
}
|
|
.swatch.selected {
|
|
outline: 2px solid #111827;
|
|
outline-offset: 1px;
|
|
}
|
|
.icon-preview {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 40px;
|
|
height: 40px;
|
|
border: 1px solid #e5e7eb;
|
|
border-radius: 8px;
|
|
}
|
|
.badge.chore {
|
|
background: #dbeafe;
|
|
color: #1e40af;
|
|
}
|
|
.badge.reward {
|
|
background: #ede9fe;
|
|
color: #6d28d9;
|
|
}
|
|
.badge.pocket {
|
|
background: #dcfce7;
|
|
color: #166534;
|
|
}
|
|
|
|
/* ── Modal (shared) ── */
|
|
.overlay {
|
|
position: fixed;
|
|
inset: 0;
|
|
background: rgba(15, 23, 42, 0.45);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: 1rem;
|
|
z-index: 50;
|
|
}
|
|
.modal {
|
|
background: #fff;
|
|
border-radius: 14px;
|
|
padding: 1.25rem 1.5rem;
|
|
width: min(440px, 100%);
|
|
max-height: 88vh;
|
|
overflow: auto;
|
|
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.25);
|
|
}
|
|
.modal h3 {
|
|
margin: 0 0 0.75rem;
|
|
}
|
|
.modal label {
|
|
display: block;
|
|
margin-bottom: 0.6rem;
|
|
font-size: 0.85rem;
|
|
color: #374151;
|
|
}
|
|
.modal input,
|
|
.modal select {
|
|
display: block;
|
|
width: 100%;
|
|
margin-top: 0.25rem;
|
|
padding: 0.5rem;
|
|
border: 1px solid #d1d5db;
|
|
border-radius: 6px;
|
|
font-size: 0.9rem;
|
|
box-sizing: border-box;
|
|
}
|
|
.modal-actions {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
justify-content: flex-end;
|
|
margin-top: 0.5rem;
|
|
}
|
|
.modal-actions button {
|
|
padding: 0.5rem 1rem;
|
|
border: none;
|
|
border-radius: 6px;
|
|
cursor: pointer;
|
|
font-size: 0.9rem;
|
|
}
|
|
.modal-actions button[type='button'] {
|
|
background: #e5e7eb;
|
|
color: #374151;
|
|
}
|
|
.modal-actions button[type='submit'] {
|
|
background: #6366f1;
|
|
color: #fff;
|
|
}
|
|
</style>
|