910 lines
24 KiB
Svelte
910 lines
24 KiB
Svelte
<script lang="ts">
|
||
import { page } from '$app/state';
|
||
import { enhance } from '$app/forms';
|
||
import { handleResult } from '$lib/forms';
|
||
import { notices } from '$lib/stores/notices.svelte';
|
||
import { famStore } from '$lib/stores/fam.svelte';
|
||
import {
|
||
ViewHeader,
|
||
CardGrid,
|
||
Card,
|
||
Button,
|
||
Accordion,
|
||
AccordionItem,
|
||
NoticeDialog
|
||
} from '$lib/components';
|
||
import { COMMON_TIMEZONES } from '@shared/timezone';
|
||
import { handleOf } from '@shared/slugify';
|
||
import { addMonthsUTC, formatShortDate } from '$lib/format';
|
||
import QRCode from 'qrcode';
|
||
|
||
let { data } = $props();
|
||
|
||
// fam is sensitive (stripeCustomerId, access fields) — never in the
|
||
// public famStore stream. It is superadmin-only, fetched server-side by the
|
||
// layout load. Writes go through form actions; no live fam subscription.
|
||
// Derived so apply/revoke reflect immediately after the action round trip.
|
||
let fam = $derived(data.fam);
|
||
// Canonical slug from layout load — reactive, never copied into $state.
|
||
let famSlug = $derived(page.data.famSlug || page.params.fam);
|
||
|
||
let accessCodeInput = $state('');
|
||
let accessMsg = $state('');
|
||
|
||
let hasCode = $derived(fam?.paymentMode === 'code' && !!fam?.accessCodeId);
|
||
let codeEntryDate = $derived(fam?.accessCodeEnteredAt ? new Date(fam.accessCodeEnteredAt) : null);
|
||
|
||
const modeLabel: Record<string, string> = {
|
||
code: 'Access code',
|
||
sub: 'Subscription',
|
||
canceled: 'Canceled',
|
||
none: 'No plan yet'
|
||
};
|
||
|
||
let addName = $state('');
|
||
let rename = $state('');
|
||
let inviteChild = $state('');
|
||
let payday = $state(fam?.payday != null ? Number(fam.payday) : 1);
|
||
let paydayTime = $state(fam?.paydayTime || '18:00');
|
||
let paydayTimes = $state([
|
||
'06:00',
|
||
'07:00',
|
||
'08:00',
|
||
'09:00',
|
||
'10:00',
|
||
'11:00',
|
||
'12:00',
|
||
'13:00',
|
||
'14:00',
|
||
'15:00',
|
||
'16:00',
|
||
'17:00',
|
||
'18:00',
|
||
'19:00',
|
||
'20:00',
|
||
'21:00',
|
||
'22:00'
|
||
]);
|
||
let timezone = $state(fam?.timezone || 'auto');
|
||
let timezoneOptions = $state(COMMON_TIMEZONES);
|
||
let detectedTz = $state('');
|
||
$effect(() => {
|
||
if (typeof Intl !== 'undefined') {
|
||
try {
|
||
detectedTz = Intl.DateTimeFormat().resolvedOptions().timeZone || '';
|
||
} catch {
|
||
detectedTz = '';
|
||
}
|
||
}
|
||
});
|
||
let showQR = $state(false);
|
||
let qrDataUrl = $state('');
|
||
let copied = $state(false);
|
||
let parentInviteEmail = $state('');
|
||
|
||
let members = $derived(famStore.initialized ? famStore.members : (data.members || []));
|
||
let deletingSeason = $state<any>(null);
|
||
let issued = $state<{ otp: string; joinUrl: string; name: string } | null>(null);
|
||
|
||
let invitePath = $derived(issued ? `${issued.joinUrl}?code=${issued.otp}` : '');
|
||
let inviteUrl = $derived(issued ? `${page.url.origin}${invitePath}` : '');
|
||
|
||
function copy(url: string) {
|
||
navigator.clipboard.writeText(url);
|
||
copied = true;
|
||
setTimeout(() => (copied = false), 2000);
|
||
}
|
||
|
||
async function generateQR(url: string) {
|
||
qrDataUrl = await QRCode.toDataURL(url, { width: 200, margin: 1 });
|
||
}
|
||
|
||
function toggleQR() {
|
||
showQR = !showQR;
|
||
if (!showQR) qrDataUrl = '';
|
||
else generateQR(inviteUrl);
|
||
}
|
||
|
||
function handleParentInvite() {
|
||
alert('Parent invite coming soon — email would be sent to ' + parentInviteEmail);
|
||
}
|
||
|
||
function timeUntil(iso: string): string {
|
||
const diffMs = new Date(iso).getTime() - Date.now();
|
||
if (diffMs <= 0) return 'expired';
|
||
const days = Math.floor(diffMs / 864e5);
|
||
if (days < 1) return 'less than a day';
|
||
if (days < 31) return `${days} day${days === 1 ? '' : 's'}`;
|
||
const months = Math.floor(days / 30.44);
|
||
return `about ${months} month${months === 1 ? '' : 's'}`;
|
||
}
|
||
|
||
function formatCountdown(entryDate: Date | null, durationMonths: number): string {
|
||
if (!entryDate) return '';
|
||
if (durationMonths === 0) return 'Never expires';
|
||
const expiry = addMonthsUTC(entryDate, durationMonths);
|
||
const now = new Date();
|
||
const diffMs = expiry.getTime() - now.getTime();
|
||
if (diffMs <= 0) return 'Expired';
|
||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||
if (diffDays < 30) return `${diffDays} day${diffDays === 1 ? '' : 's'} left`;
|
||
const months = Math.floor(diffDays / 30);
|
||
const remDays = diffDays % 30;
|
||
return `${months} month${months === 1 ? '' : 's'}${remDays ? ` ${remDays} day${remDays === 1 ? '' : 's'}` : ''} left`;
|
||
}
|
||
</script>
|
||
|
||
<ViewHeader title="Settings" hero />
|
||
|
||
<Accordion>
|
||
<!-- Family -->
|
||
<AccordionItem title="Family" open>
|
||
<CardGrid>
|
||
<Card title="Family Name">
|
||
<p class="hint">
|
||
This is the name shown to your family. The address stays at
|
||
<code class="slug-inline">/{famSlug}</code> even if you rename it — links you've shared keep
|
||
working.
|
||
</p>
|
||
<form method="POST" action="?/renameFam" use:enhance>
|
||
<label class="field-label" for="fam-name">Display name</label>
|
||
<input
|
||
id="fam-name"
|
||
name="name"
|
||
bind:value={rename}
|
||
placeholder={fam?.name || 'Family name'}
|
||
required
|
||
/>
|
||
<Button type="submit" size="sm">Rename</Button>
|
||
</form>
|
||
{#if fam?.slug}
|
||
<p class="hint slug-line">
|
||
Family page: <code class="slug-inline">/{fam.slug}</code>
|
||
</p>
|
||
{/if}
|
||
</Card>
|
||
|
||
<Card title="Payday" cols={1}>
|
||
<form
|
||
method="POST"
|
||
action="?/updatePayday"
|
||
use:enhance={() => {
|
||
return async ({ result }) => {
|
||
if (result.type === 'error') alert(result.error || 'Failed to update payday');
|
||
};
|
||
}}
|
||
class="payday-form"
|
||
>
|
||
<div class="payday-row">
|
||
<label>Day</label>
|
||
<select name="payday" bind:value={payday}>
|
||
<option value={0}>Sunday</option>
|
||
<option value={1}>Monday</option>
|
||
<option value={2}>Tuesday</option>
|
||
<option value={3}>Wednesday</option>
|
||
<option value={4}>Thursday</option>
|
||
<option value={5}>Friday</option>
|
||
<option value={6}>Saturday</option>
|
||
</select>
|
||
<label>Time</label>
|
||
<select name="paydayTime" bind:value={paydayTime}>
|
||
{#each paydayTimes as t}
|
||
<option value={t}>{t}</option>
|
||
{/each}
|
||
</select>
|
||
</div>
|
||
<div class="payday-row">
|
||
<label>Timezone</label>
|
||
<select name="timezone" bind:value={timezone}>
|
||
<option value="auto">{detectedTz ? `Auto (${detectedTz})` : 'Auto'}</option>
|
||
{#each timezoneOptions as tz}
|
||
<option value={tz}>{tz}</option>
|
||
{/each}
|
||
</select>
|
||
</div>
|
||
<Button type="submit" size="sm">Set payday</Button>
|
||
</form>
|
||
<p class="hint">
|
||
Payday: the week starts on this day and weekly earnings are settled at this time. Auto
|
||
timezone follows each device.
|
||
</p>
|
||
</Card>
|
||
<Card title="Seasons" cols={1}>
|
||
<p class="hint">Group chores into seasons. Toggle seasons on/off from the top nav.</p>
|
||
<form method="POST" action="?/createSeason" use:enhance class="season-form">
|
||
<label class="field-label" for="season-name">New season</label>
|
||
<input id="season-name" name="name" placeholder="Season name" required />
|
||
<div class="color-row">
|
||
<label for="season-color">Colour</label>
|
||
<input
|
||
id="season-color"
|
||
name="color"
|
||
type="color"
|
||
value="#6366f1"
|
||
class="color-input"
|
||
/>
|
||
</div>
|
||
<Button type="submit" size="sm">Add</Button>
|
||
</form>
|
||
|
||
<ul>
|
||
{#each data.seasons as s}
|
||
<li>
|
||
<span class="dot" style="background:{s.color}"></span>
|
||
{s.name}
|
||
<Button variant="danger" size="sm" onclick={() => (deletingSeason = s)}>Remove</Button
|
||
>
|
||
</li>
|
||
{/each}
|
||
</ul>
|
||
</Card>
|
||
|
||
<!-- Delete Season Modal -->
|
||
{#if deletingSeason}
|
||
<div class="overlay" onclick={() => (deletingSeason = null)} role="presentation">
|
||
<div class="modal" onclick={(e) => e.stopPropagation()} role="dialog">
|
||
<h3>Delete "{deletingSeason.name}"?</h3>
|
||
<p class="warning">
|
||
All chores assigned to this season will also be removed. This cannot be undone.
|
||
</p>
|
||
<form
|
||
method="POST"
|
||
action="?/deleteSeason"
|
||
use:enhance={() => {
|
||
return async ({ result }) => {
|
||
if (result.type === 'success') deletingSeason = null;
|
||
};
|
||
}}
|
||
>
|
||
<input type="hidden" name="id" value={deletingSeason.id} />
|
||
<div class="modal-actions">
|
||
<button type="button" onclick={() => (deletingSeason = null)}>Cancel</button>
|
||
<button type="submit" class="danger">Delete Season</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
{/if}
|
||
</CardGrid>
|
||
</AccordionItem>
|
||
|
||
<AccordionItem title="Invites">
|
||
<CardGrid>
|
||
<Card title="Members ({members.length})" cols={2}>
|
||
<div class="members-grid">
|
||
<div class="members-add">
|
||
<p class="hint">Add a child. They'll pick their own colour after joining.</p>
|
||
<form method="POST" action="?/addMember" use:enhance>
|
||
<label class="field-label" for="new-child">New child</label>
|
||
<input
|
||
id="new-child"
|
||
name="name"
|
||
bind:value={addName}
|
||
placeholder="Child name"
|
||
required
|
||
/>
|
||
<Button type="submit" size="sm">Add child</Button>
|
||
</form>
|
||
</div>
|
||
|
||
<ul class="members-list">
|
||
{#each members as m}
|
||
<li>
|
||
<span class="member-left">
|
||
<span class="member-color" style="background:{m.color}"></span>
|
||
<span class="member-info">
|
||
<span class="member-name">{m.name}</span>
|
||
<span class="member-handle">/{famSlug}/{handleOf(m.username)}</span>
|
||
</span>
|
||
</span>
|
||
<span class="member-actions">
|
||
<Button href="/{famSlug}/{handleOf(m.username)}" variant="secondary" size="sm"
|
||
>Preview</Button
|
||
>
|
||
<form method="POST" action="?/deleteMember" use:enhance class="inline">
|
||
<input type="hidden" name="id" value={m.id} />
|
||
<Button
|
||
type="submit"
|
||
variant="danger"
|
||
size="sm"
|
||
onclick={() => confirm('Remove {m.name}?')}>Remove</Button
|
||
>
|
||
</form>
|
||
</span>
|
||
</li>
|
||
{/each}
|
||
</ul>
|
||
</div>
|
||
</Card>
|
||
|
||
<Card title="Invite Children" cols={1}>
|
||
<form
|
||
method="POST"
|
||
action="?/issueAccess"
|
||
use:enhance={() => {
|
||
return async ({ formData, result }) => {
|
||
if (result.type === 'success' && result.data?.ok) {
|
||
showQR = false;
|
||
qrDataUrl = '';
|
||
issued = {
|
||
otp: result.data.otp,
|
||
joinUrl: result.data.joinUrl,
|
||
name: String(formData.get('name') || '')
|
||
};
|
||
} else if (result.type === 'success' && result.data?.error) {
|
||
alert(result.data.error);
|
||
}
|
||
};
|
||
}}
|
||
class="invite-form"
|
||
>
|
||
<label class="field-label" for="invite-child">Child</label>
|
||
<select id="invite-child" bind:value={inviteChild} name="name" required>
|
||
<option value="">— Select a child —</option>
|
||
{#each members as m}
|
||
<option value={m.name}>{m.name}</option>
|
||
{/each}
|
||
</select>
|
||
<Button type="submit" size="sm" disabled={!inviteChild}>Issue code</Button>
|
||
</form>
|
||
<p class="hint">
|
||
Generates a 6-digit code valid for 20 minutes. The child enters it at the join link.
|
||
</p>
|
||
|
||
{#if issued?.otp}
|
||
<div class="mt-3 rounded-lg border border-indigo-200 bg-indigo-50 p-4">
|
||
<p class="text-xs text-slate-500">Code for {issued.name} (valid 20 min):</p>
|
||
<p class="my-2 text-center text-4xl font-bold tracking-[0.3em] text-indigo-700">
|
||
{issued.otp}
|
||
</p>
|
||
<p class="invite-url">{invitePath}</p>
|
||
<div class="actions justify-center">
|
||
<Button variant="secondary" size="sm" onclick={() => copy(inviteUrl)}
|
||
>{copied ? 'Copied!' : 'Copy URL'}</Button
|
||
>
|
||
<Button variant="secondary" size="sm" onclick={toggleQR}
|
||
>{showQR ? 'Hide QR' : 'Show QR'}</Button
|
||
>
|
||
</div>
|
||
{#if showQR && qrDataUrl}
|
||
<div class="qr-wrap"><img src={qrDataUrl} alt="QR Code" class="qr" /></div>
|
||
{/if}
|
||
</div>
|
||
{/if}
|
||
</Card>
|
||
|
||
<Card title="Invite Parent" cols={1}>
|
||
<p class="hint">Send an email invitation for another parent to join as an admin.</p>
|
||
<div class="invite-form">
|
||
<label class="field-label" for="parent-email">Parent email</label>
|
||
<input
|
||
id="parent-email"
|
||
type="email"
|
||
bind:value={parentInviteEmail}
|
||
placeholder="parent@example.com"
|
||
/>
|
||
<Button onclick={handleParentInvite} size="sm">Send invite</Button>
|
||
</div>
|
||
<p class="hint">They will set up their own password on first login.</p>
|
||
</Card>
|
||
</CardGrid>
|
||
</AccordionItem>
|
||
|
||
<AccordionItem title="Account">
|
||
<CardGrid>
|
||
<!-- Access (codes) only relevant when NOT on a subscription -->
|
||
{#if fam?.paymentMode !== 'sub'}
|
||
<!-- Access -->
|
||
<Card title="Access" cols={1} accent={hasCode ? '#059669' : '#dc2626'}>
|
||
{#if hasCode}
|
||
<p class="hint">
|
||
Access active via access code
|
||
{#if data.accessCode?.duration}
|
||
— <strong>{formatCountdown(codeEntryDate, data.accessCode.duration)}</strong>
|
||
(expires {formatShortDate(addMonthsUTC(codeEntryDate!, data.accessCode.duration))})
|
||
{:else}
|
||
— <strong>never expires</strong>
|
||
{/if}.
|
||
</p>
|
||
<form
|
||
class="revoke-form"
|
||
method="POST"
|
||
action="?/revokeCode"
|
||
use:enhance={() => {
|
||
return async ({ result, update }) => {
|
||
if (result.type === 'success') accessMsg = 'Access code revoked.';
|
||
else if (result.type === 'failure')
|
||
accessMsg = (result.data as any)?.error || 'Revoke failed.';
|
||
await update();
|
||
};
|
||
}}
|
||
>
|
||
<Button type="submit" size="sm" variant="danger">Revoke code</Button>
|
||
</form>
|
||
{:else}
|
||
<p class="hint">No access applied. Enter a valid access code to enable your family.</p>
|
||
<form
|
||
method="POST"
|
||
action="?/applyCode"
|
||
use:enhance={() => {
|
||
return async ({ result, update }) => {
|
||
if (result.type === 'success' && result.data) {
|
||
const d = result.data as { error?: string; ok?: boolean };
|
||
accessMsg = d.error || 'Code applied — access enabled.';
|
||
accessCodeInput = '';
|
||
}
|
||
await update();
|
||
};
|
||
}}
|
||
>
|
||
<input
|
||
name="code"
|
||
bind:value={accessCodeInput}
|
||
placeholder="Enter access code"
|
||
autocomplete="off"
|
||
/>
|
||
<Button type="submit" size="sm" variant="primary">Apply code</Button>
|
||
</form>
|
||
{/if}
|
||
{#if accessMsg}
|
||
<p class="access-msg">{accessMsg}</p>
|
||
{/if}
|
||
</Card>
|
||
{/if}
|
||
|
||
<Card
|
||
title="Subscription"
|
||
cols={1}
|
||
accent={fam?.paymentMode === 'sub' ? '#059669' : '#f59e0b'}
|
||
>
|
||
<p class="hint">
|
||
{fam?.paymentMode === 'sub'
|
||
? 'Current plan: Subscription.'
|
||
: fam?.paymentMode === 'code'
|
||
? `Current plan: Access code${hasCode ? '' : ' (invalid)'}.`
|
||
: fam?.paymentMode === 'canceled'
|
||
? 'Your subscription was canceled.'
|
||
: 'No active plan.'}
|
||
</p>
|
||
|
||
{#if fam?.paymentMode === 'sub' && data.subStatus?.cancelAtPeriodEnd}
|
||
<!-- Cancel requested: no CTA — just the countdown to expiry -->
|
||
<p class="hint">
|
||
<strong>No active subscription.</strong> You have
|
||
<strong>{timeUntil(data.subStatus.endsAt)}</strong> of access left
|
||
(until {formatShortDate(String(data.subStatus.endsAt))}). No further charges.
|
||
</p>
|
||
{:else if fam?.paymentMode === 'sub'}
|
||
<div class="actions">
|
||
<form
|
||
method="POST"
|
||
action="?/endSubscription"
|
||
use:enhance={() => {
|
||
return async ({ result }) => {
|
||
if (result.type === 'success' && (result.data as any)?.endsAt) {
|
||
notices.success(
|
||
'Subscription ending',
|
||
`Ends ${formatShortDate(String((result.data as any).endsAt))} — access continues until then, no further charges.`
|
||
);
|
||
} else if (result.type === 'failure') {
|
||
notices.error(
|
||
'Couldn’t end subscription',
|
||
(result.data as any)?.error || 'Please try again.'
|
||
);
|
||
}
|
||
};
|
||
}}
|
||
>
|
||
<Button type="submit" variant="danger" size="sm">End subscription</Button>
|
||
</form>
|
||
</div>
|
||
<p class="hint resume-hint">
|
||
Ending stops future charges at the close of the paid period — your data is kept and you
|
||
can resubscribe anytime. To switch plans, end this subscription first, then pick a new
|
||
one from Plans.
|
||
</p>
|
||
{:else if fam?.paymentMode === 'code'}
|
||
<div class="actions">
|
||
<Button href="/pricing" variant="primary" size="sm">Switch to subscription</Button>
|
||
</div>
|
||
{:else}
|
||
<div class="actions">
|
||
<Button href="/pricing" variant="primary" size="sm">Choose a plan</Button>
|
||
</div>
|
||
{/if}
|
||
</Card>
|
||
</CardGrid>
|
||
</AccordionItem>
|
||
|
||
<AccordionItem title="App">
|
||
<CardGrid>
|
||
{#if page.data.platformFlags?.debug}
|
||
<Card title="Debug Tools" cols={1} accent="#f59e0b">
|
||
<p class="hint">
|
||
Debug mode is enabled. These tools are for development and testing only.
|
||
</p>
|
||
<div class="actions">
|
||
<form
|
||
method="POST"
|
||
action="?/completeWeek"
|
||
use:enhance={() => {
|
||
return async ({ result, update }) => {
|
||
if (result.type === 'success') alert('Week completed!');
|
||
await update();
|
||
};
|
||
}}
|
||
>
|
||
<Button type="submit" size="sm" variant="secondary">Complete Week</Button>
|
||
</form>
|
||
<form
|
||
method="POST"
|
||
action="?/generateData"
|
||
use:enhance={() => {
|
||
return async ({ result, update }) => {
|
||
if (result.type === 'success') alert('Test data generated!');
|
||
await update();
|
||
};
|
||
}}
|
||
>
|
||
<input type="hidden" name="days" value="7" />
|
||
<Button type="submit" size="sm" variant="secondary"
|
||
>Generate Test Data (7 days)</Button
|
||
>
|
||
</form>
|
||
</div>
|
||
</Card>
|
||
{/if}
|
||
<Card title="Data" cols={1}>
|
||
<div class="actions">
|
||
<Button variant="ghost" size="sm" disabled>Download CSV (coming soon)</Button>
|
||
<Button variant="danger" size="sm" disabled>Delete Family (coming soon)</Button>
|
||
</div>
|
||
</Card>
|
||
</CardGrid>
|
||
</AccordionItem>
|
||
|
||
<!-- Invites -->
|
||
</Accordion>
|
||
|
||
<NoticeDialog />
|
||
|
||
<style>
|
||
.hint {
|
||
font-size: 0.85rem;
|
||
color: #9ca3af;
|
||
line-height: 1.4;
|
||
}
|
||
.invite-url {
|
||
font-family: monospace;
|
||
font-size: 0.85rem;
|
||
word-break: break-all;
|
||
background: #fff;
|
||
border: 1px solid #e5e7eb;
|
||
padding: 0.5rem 0.6rem;
|
||
border-radius: 8px;
|
||
}
|
||
|
||
/* ── Forms: full-width, balanced fields ── */
|
||
form {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.6rem;
|
||
margin-bottom: 1rem;
|
||
}
|
||
form.inline {
|
||
display: inline-flex;
|
||
flex-direction: row;
|
||
align-items: center;
|
||
gap: 0.5rem;
|
||
margin: 0;
|
||
}
|
||
.field-label {
|
||
font-size: 0.8rem;
|
||
font-weight: 500;
|
||
color: #6b7280;
|
||
}
|
||
input,
|
||
select {
|
||
width: 100%;
|
||
padding: 0.55rem 0.75rem;
|
||
border: 1px solid #d1d5db;
|
||
border-radius: 8px;
|
||
background: #fff;
|
||
font-size: 0.9rem;
|
||
}
|
||
input:focus,
|
||
select:focus {
|
||
outline: 2px solid #6366f1;
|
||
outline-offset: -1px;
|
||
border-color: #6366f1;
|
||
}
|
||
/* Buttons are rendered by <Button> (scoped in its own component) — reach
|
||
them with :global so primary CTAs stretch to full width. */
|
||
form :global(.btn) {
|
||
width: 100%;
|
||
}
|
||
form.inline :global(.btn) {
|
||
width: auto;
|
||
}
|
||
.invite-form {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.6rem;
|
||
margin-bottom: 1rem;
|
||
}
|
||
.invite-form :global(.btn) {
|
||
width: 100%;
|
||
}
|
||
|
||
/* ── Payday ── */
|
||
.payday-row {
|
||
display: flex;
|
||
gap: 0.5rem;
|
||
align-items: center;
|
||
flex-wrap: wrap;
|
||
}
|
||
.payday-row select {
|
||
flex: 1;
|
||
min-width: 0;
|
||
max-width: 100%;
|
||
}
|
||
.payday-row label {
|
||
font-size: 0.8rem;
|
||
color: #6b7280;
|
||
flex-shrink: 0;
|
||
font-weight: 500;
|
||
}
|
||
.payday-row + .payday-row {
|
||
margin-top: 0.5rem;
|
||
}
|
||
|
||
/* ── Colour rows (seasons) ── */
|
||
.color-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.75rem;
|
||
}
|
||
.color-row label {
|
||
font-size: 0.8rem;
|
||
font-weight: 500;
|
||
color: #6b7280;
|
||
flex-shrink: 0;
|
||
}
|
||
.color-input {
|
||
width: 100%;
|
||
max-width: 160px;
|
||
height: 36px;
|
||
padding: 2px;
|
||
border: 1px solid #d1d5db;
|
||
border-radius: 8px;
|
||
cursor: pointer;
|
||
}
|
||
|
||
/* ── Lists ── */
|
||
ul {
|
||
list-style: none;
|
||
padding: 0;
|
||
margin: 0;
|
||
}
|
||
li {
|
||
padding: 0.45rem 0;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.5rem;
|
||
flex-wrap: wrap;
|
||
justify-content: space-between;
|
||
border-bottom: 1px solid #f3f4f6;
|
||
}
|
||
li:last-child {
|
||
border-bottom: none;
|
||
}
|
||
.dot {
|
||
display: inline-block;
|
||
width: 10px;
|
||
height: 10px;
|
||
border-radius: 50%;
|
||
flex-shrink: 0;
|
||
}
|
||
.link {
|
||
font-size: 0.85rem;
|
||
color: #6366f1;
|
||
}
|
||
.slug-inline {
|
||
font-family: var(--font-mono, ui-monospace, monospace);
|
||
font-size: 0.85em;
|
||
background: #f3f4f6;
|
||
border-radius: 4px;
|
||
padding: 0.1em 0.35em;
|
||
color: #374151;
|
||
}
|
||
.slug-line {
|
||
margin-top: 0.75rem;
|
||
}
|
||
|
||
/* ── Members: two-column (add | list) ── */
|
||
.members-grid {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 1.25rem;
|
||
align-items: start;
|
||
}
|
||
.members-add form {
|
||
margin-bottom: 0;
|
||
}
|
||
.members-list {
|
||
border-left: 1px solid #f3f4f6;
|
||
padding-left: 1.25rem;
|
||
}
|
||
.member-left,
|
||
.member-actions {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.5rem;
|
||
}
|
||
.member-color {
|
||
width: 22px;
|
||
height: 22px;
|
||
border-radius: 50%;
|
||
flex-shrink: 0;
|
||
border: 2px solid #fff;
|
||
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.12);
|
||
}
|
||
.member-name {
|
||
font-weight: 500;
|
||
color: #374151;
|
||
}
|
||
.member-info {
|
||
display: flex;
|
||
flex-direction: column;
|
||
line-height: 1.25;
|
||
}
|
||
.member-handle {
|
||
font-family: var(--font-mono, ui-monospace, monospace);
|
||
font-size: 0.72rem;
|
||
color: #9ca3af;
|
||
}
|
||
.member-actions :global(.btn) {
|
||
flex: none;
|
||
width: auto;
|
||
}
|
||
@container (max-width: 380px) {
|
||
.members-grid {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
.members-list {
|
||
border-left: none;
|
||
padding-left: 0;
|
||
border-top: 1px solid #f3f4f6;
|
||
padding-top: 0.5rem;
|
||
}
|
||
}
|
||
|
||
/* ── Action rows ── */
|
||
.actions {
|
||
display: flex;
|
||
gap: 0.5rem;
|
||
margin-top: 0.75rem;
|
||
flex-wrap: wrap;
|
||
}
|
||
.actions.justify-center {
|
||
justify-content: center;
|
||
}
|
||
.actions :global(.btn) {
|
||
flex: 1;
|
||
}
|
||
.qr-wrap {
|
||
display: flex;
|
||
justify-content: center;
|
||
margin-top: 0.5rem;
|
||
}
|
||
.qr {
|
||
border: 1px solid #e5e7eb;
|
||
border-radius: 8px;
|
||
}
|
||
|
||
/* ── Narrow cards: stack rows / actions full width (container query) ── */
|
||
@container (max-width: 380px) {
|
||
.payday-row {
|
||
flex-direction: column;
|
||
align-items: stretch;
|
||
}
|
||
.payday-row select {
|
||
width: 100%;
|
||
}
|
||
.color-row {
|
||
align-items: stretch;
|
||
}
|
||
.actions {
|
||
flex-direction: column;
|
||
}
|
||
.actions :global(.btn) {
|
||
flex: none;
|
||
width: 100%;
|
||
}
|
||
}
|
||
|
||
/* ── Overlay / modal (plain buttons live in this template) ── */
|
||
button {
|
||
background: #6366f1;
|
||
color: white;
|
||
border: none;
|
||
cursor: pointer;
|
||
}
|
||
button:disabled {
|
||
opacity: 0.5;
|
||
cursor: not-allowed;
|
||
}
|
||
.danger {
|
||
background: #dc2626;
|
||
font-size: 0.8rem;
|
||
padding: 0.2rem 0.5rem;
|
||
}
|
||
.overlay {
|
||
position: fixed;
|
||
inset: 0;
|
||
background: rgba(0, 0, 0, 0.4);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
z-index: 100;
|
||
}
|
||
.modal {
|
||
background: white;
|
||
border-radius: 12px;
|
||
padding: 1.5rem;
|
||
width: 100%;
|
||
max-width: 440px;
|
||
margin: 0 1rem;
|
||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.15);
|
||
}
|
||
.modal h3 {
|
||
margin: 0 0 0.75rem;
|
||
}
|
||
.modal-actions {
|
||
display: flex;
|
||
gap: 0.5rem;
|
||
justify-content: flex-end;
|
||
margin-top: 1rem;
|
||
}
|
||
.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: #dc2626;
|
||
color: white;
|
||
}
|
||
.warning {
|
||
color: #dc2626;
|
||
font-size: 0.9rem;
|
||
background: #fef2f2;
|
||
padding: 0.6rem;
|
||
border-radius: 6px;
|
||
margin-bottom: 0.5rem;
|
||
}
|
||
.access-msg {
|
||
margin-top: 0.5rem;
|
||
font-size: 0.85rem;
|
||
color: #059669;
|
||
}
|
||
.revoke-form {
|
||
margin-top: 0.6rem;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.6rem;
|
||
}
|
||
.revoke-form .hint {
|
||
font-size: 0.78rem;
|
||
}
|
||
.resume-hint {
|
||
margin-top: 0.5rem;
|
||
}
|
||
</style>
|