discount/public/admin.html
jmcqueen 086edd5fe0 Add admin token-protected REST API and web UI for managing codes/members
- Require ADMIN_TOKEN bearer auth on /members and new /schedule CRUD routes
- Validate JSON body shape and reject empty/malformed writes so a
  bad multipart POST can no longer wipe authorized.json
- Make saveJSON atomic (tmp -> rename) with a one-generation .bak file
- Serve a self-contained admin SPA at /admin (public/admin.html) for
  editing the discount schedule and authorized members without hand-
  editing JSON, with a /preview endpoint that renders the bot's
  fallback text for draft entries
- Dockerfile now copies public/ into the image

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 11:50:21 -04:00

654 lines
25 KiB
HTML

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="robots" content="noindex, nofollow" />
<title>DiscountBot Admin</title>
<style>
:root {
--bg: #0f1115;
--panel: #171a21;
--panel-2: #1f2430;
--border: #2a3040;
--text: #e8ecf3;
--muted: #8a94a7;
--accent: #4f8cff;
--accent-hov: #6ba0ff;
--danger: #ef4b5b;
--success: #35c47a;
--warn: #f0b429;
--dirty: #f0b429;
--code: #a3f0c8;
--shadow: 0 6px 24px rgba(0,0,0,0.4);
}
@media (prefers-color-scheme: light) {
:root {
--bg: #f6f7fb;
--panel: #ffffff;
--panel-2: #f0f2f7;
--border: #dfe3ec;
--text: #1c2330;
--muted: #5b6577;
--accent: #2f6fe0;
--accent-hov: #1f57bc;
--danger: #d0313f;
--success: #1f9d5b;
--warn: #b57408;
--code: #0a6d3e;
--shadow: 0 4px 20px rgba(20,30,60,0.08);
}
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; background: var(--bg); color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
font-size: 14px; line-height: 1.45; }
a { color: var(--accent); text-decoration: none; }
a:hover { color: var(--accent-hov); text-decoration: underline; }
header {
position: sticky; top: 0; z-index: 5;
background: var(--panel); border-bottom: 1px solid var(--border);
padding: 12px 20px; display: flex; align-items: center; gap: 16px; box-shadow: var(--shadow);
}
header h1 { margin: 0; font-size: 16px; font-weight: 600; letter-spacing: 0.2px; }
header .grow { flex: 1; }
header .tabs { display: flex; gap: 4px; }
header .tabs button {
background: transparent; border: 1px solid transparent; color: var(--muted);
padding: 6px 12px; border-radius: 8px; cursor: pointer; font-size: 13px;
}
header .tabs button.active { color: var(--text); background: var(--panel-2); border-color: var(--border); }
header .tabs button:hover { color: var(--text); }
main { padding: 20px; max-width: 1100px; margin: 0 auto; }
section { display: none; }
section.active { display: block; }
.toolbar { display: flex; gap: 8px; margin-bottom: 16px; align-items: center; flex-wrap: wrap; }
.toolbar .grow { flex: 1; }
button {
background: var(--accent); color: white; border: none; padding: 8px 14px;
border-radius: 8px; cursor: pointer; font-size: 13px; font-weight: 500;
transition: background 0.15s ease, transform 0.05s ease;
}
button:hover { background: var(--accent-hov); }
button:active { transform: translateY(1px); }
button.ghost { background: transparent; color: var(--text); border: 1px solid var(--border); }
button.ghost:hover { background: var(--panel-2); }
button.danger { background: var(--danger); }
button.danger:hover { background: #ff6070; }
button.dirty { background: var(--dirty); color: #1c1c1c; }
button:disabled { opacity: 0.5; cursor: not-allowed; }
input, textarea, select {
background: var(--panel-2); color: var(--text); border: 1px solid var(--border);
border-radius: 6px; padding: 6px 10px; font-size: 13px; font-family: inherit; width: 100%;
}
input:focus, textarea:focus, select:focus { outline: none; border-color: var(--accent); }
textarea { resize: vertical; min-height: 60px; }
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.card {
background: var(--panel); border: 1px solid var(--border); border-radius: 12px;
padding: 16px; margin-bottom: 16px; box-shadow: var(--shadow);
}
.card.dirty { border-color: var(--dirty); box-shadow: 0 0 0 2px rgba(240,180,41,0.15), var(--shadow); }
.card-header {
display: flex; align-items: center; gap: 12px; margin-bottom: 12px;
padding-bottom: 12px; border-bottom: 1px solid var(--border);
}
.card-header .title { font-size: 15px; font-weight: 600; flex: 1; }
.card-header .meta { color: var(--muted); font-size: 12px; }
.card-header .actions { display: flex; gap: 6px; }
.badge {
display: inline-block; padding: 2px 8px; border-radius: 999px;
font-size: 11px; font-weight: 600; letter-spacing: 0.3px;
}
.badge.now { background: rgba(53,196,122,0.15); color: var(--success); border: 1px solid var(--success); }
.badge.past { background: rgba(138,148,167,0.15); color: var(--muted); border: 1px solid var(--muted); }
.badge.future { background: rgba(79,140,255,0.15); color: var(--accent); border: 1px solid var(--accent); }
.grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; margin-bottom: 12px; }
.grid label { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: var(--muted); }
fieldset {
border: 1px solid var(--border); border-radius: 8px; padding: 10px 12px 12px; margin: 8px 0;
background: var(--panel-2);
}
fieldset > legend { font-size: 12px; font-weight: 600; color: var(--muted); padding: 0 6px;
text-transform: uppercase; letter-spacing: 0.4px; }
.field { display: flex; flex-direction: column; gap: 4px; margin-bottom: 8px; }
.field label { font-size: 11px; color: var(--muted); font-weight: 500; }
.field input.code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--code); }
.row2 { display: grid; grid-template-columns: 1fr 2fr; gap: 8px; }
.row2-code-only { display: grid; grid-template-columns: 1fr; gap: 8px; }
/* Login */
.login-wrap { min-height: 60vh; display: flex; align-items: center; justify-content: center; }
.login {
background: var(--panel); border: 1px solid var(--border); border-radius: 12px;
padding: 28px; width: 100%; max-width: 380px; box-shadow: var(--shadow);
}
.login h2 { margin: 0 0 6px; font-size: 18px; }
.login p { margin: 0 0 16px; color: var(--muted); font-size: 13px; }
.login label { display: block; margin-bottom: 6px; font-size: 12px; color: var(--muted); }
.login .row { display: flex; gap: 8px; margin-top: 12px; }
.login .row button { flex: 1; }
/* Toast */
#toast {
position: fixed; bottom: 20px; right: 20px; display: flex; flex-direction: column; gap: 8px; z-index: 10;
}
.toast {
background: var(--panel); border: 1px solid var(--border); border-left-width: 3px;
padding: 10px 14px; border-radius: 8px; min-width: 220px; max-width: 400px;
box-shadow: var(--shadow); font-size: 13px; animation: slideIn 0.15s ease-out;
}
.toast.error { border-left-color: var(--danger); }
.toast.success { border-left-color: var(--success); }
.toast.info { border-left-color: var(--accent); }
@keyframes slideIn { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
.empty { color: var(--muted); text-align: center; padding: 40px 20px; }
.preview {
background: var(--panel-2); border: 1px solid var(--border); border-radius: 8px;
padding: 12px 14px; margin-top: 10px; font-size: 13px; white-space: pre-wrap;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; max-height: 300px; overflow: auto;
}
.preview-title { font-size: 12px; color: var(--muted); margin: 8px 0 4px; font-weight: 600;
text-transform: uppercase; letter-spacing: 0.4px; }
details { margin-top: 8px; }
details summary { cursor: pointer; color: var(--muted); font-size: 12px; user-select: none; }
details summary:hover { color: var(--text); }
.muted { color: var(--muted); font-size: 12px; }
.hint { color: var(--muted); font-size: 12px; margin-bottom: 8px; }
@media (max-width: 720px) {
.grid { grid-template-columns: 1fr; }
.row2 { grid-template-columns: 1fr; }
header { flex-wrap: wrap; }
}
</style>
</head>
<body>
<div id="login-view" class="login-wrap">
<form id="login-form" class="login" autocomplete="off">
<h2>DiscountBot Admin</h2>
<p>Enter the admin token to manage discount codes and authorized members.</p>
<label for="token">Admin token</label>
<input id="token" type="password" required autocomplete="current-password" />
<label style="margin-top:12px; display:flex; align-items:center; gap:8px; font-size:12px; color: var(--muted);">
<input id="remember" type="checkbox" style="width:auto;" /> Remember for this browser
</label>
<div class="row">
<button type="submit">Sign in</button>
</div>
</form>
</div>
<div id="app-view" style="display:none;">
<header>
<h1>DiscountBot Admin</h1>
<div class="tabs">
<button data-tab="schedule" class="active">Schedule</button>
<button data-tab="members">Members</button>
<button data-tab="status">Status</button>
</div>
<div class="grow"></div>
<span id="server-info" class="muted"></span>
<button class="ghost" id="signout">Sign out</button>
</header>
<main>
<section id="tab-schedule" class="active">
<div class="toolbar">
<div class="grow"></div>
<button id="btn-refresh-schedule" class="ghost">Refresh</button>
<button id="btn-add-blank">Add new month</button>
<button id="btn-add-duplicate" class="ghost">Duplicate latest</button>
</div>
<div class="hint">Click any field to edit. The <b>Save</b> button lights up when changes are pending. All writes are validated and back up the previous file to <code>schedule.json.bak</code>.</div>
<div id="schedule-list"></div>
</section>
<section id="tab-members">
<div class="toolbar">
<div class="grow"></div>
<button id="btn-refresh-members" class="ghost">Refresh</button>
<button id="btn-save-members">Save members</button>
</div>
<div class="hint">One username or email per line. Duplicates and empty lines are removed; everything is lowercased on save.</div>
<textarea id="members-text" rows="20" class="mono"></textarea>
<div class="muted" style="margin-top:8px;">
<span id="members-count">0</span> entries.
</div>
</section>
<section id="tab-status">
<div class="card">
<div class="card-header"><div class="title">Server status</div></div>
<pre id="status-json" class="preview">Loading…</pre>
<div style="margin-top:8px;">
<button class="ghost" id="btn-refresh-status">Refresh</button>
</div>
</div>
<div class="card">
<div class="card-header"><div class="title">About</div></div>
<p class="muted" style="margin:0;">
This page talks to the bot's REST API using the <code>Authorization: Bearer &lt;ADMIN_TOKEN&gt;</code> header.
All schedule/member writes are validated server-side and write atomically with a <code>.bak</code> file
so a bad edit can be rolled back on the host.
</p>
</div>
</section>
</main>
</div>
<div id="toast"></div>
<script>
(() => {
const TOKEN_KEY = 'discountbot_admin_token';
let token = sessionStorage.getItem(TOKEN_KEY) || localStorage.getItem(TOKEN_KEY) || '';
let schedule = [];
const dirty = new Set();
const $ = (sel, root=document) => root.querySelector(sel);
const $$ = (sel, root=document) => Array.from(root.querySelectorAll(sel));
// ---------- toast ----------
function toast(message, type='info', timeout=3500) {
const box = document.createElement('div');
box.className = `toast ${type}`;
box.textContent = message;
$('#toast').appendChild(box);
setTimeout(() => { box.style.opacity = '0'; box.style.transform = 'translateY(6px)'; box.style.transition = 'opacity .2s, transform .2s'; }, timeout - 200);
setTimeout(() => box.remove(), timeout);
}
// ---------- api ----------
async function api(method, path, body) {
const headers = { 'Authorization': `Bearer ${token}` };
let bodyStr;
if (body !== undefined) {
headers['Content-Type'] = 'application/json';
bodyStr = JSON.stringify(body);
}
const res = await fetch(path, { method, headers, body: bodyStr, credentials: 'same-origin' });
const isJson = (res.headers.get('content-type') || '').includes('application/json');
const payload = isJson ? await res.json().catch(() => null) : await res.text().catch(() => '');
if (!res.ok) {
const err = new Error((payload && payload.error) || `HTTP ${res.status}`);
err.status = res.status;
err.payload = payload;
throw err;
}
return payload;
}
// ---------- auth flow ----------
function showLogin() {
$('#login-view').style.display = '';
$('#app-view').style.display = 'none';
setTimeout(() => $('#token').focus(), 50);
}
function showApp() {
$('#login-view').style.display = 'none';
$('#app-view').style.display = '';
}
$('#login-form').addEventListener('submit', async (e) => {
e.preventDefault();
const t = $('#token').value.trim();
if (!t) return;
token = t;
try {
// Quick check by calling /status (public) + /members (auth) to verify the token
await api('GET', './members');
if ($('#remember').checked) localStorage.setItem(TOKEN_KEY, token);
else localStorage.removeItem(TOKEN_KEY);
sessionStorage.setItem(TOKEN_KEY, token);
$('#token').value = '';
showApp();
loadAll();
} catch (err) {
token = '';
if (err.status === 401) toast('Wrong token.', 'error');
else if (err.status === 503) toast('Server has no ADMIN_TOKEN configured.', 'error', 6000);
else toast(err.message || 'Login failed.', 'error');
}
});
$('#signout').addEventListener('click', () => {
token = '';
sessionStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(TOKEN_KEY);
schedule = [];
dirty.clear();
showLogin();
});
// ---------- tabs ----------
$$('.tabs button').forEach(btn => {
btn.addEventListener('click', () => {
$$('.tabs button').forEach(b => b.classList.toggle('active', b === btn));
const name = btn.dataset.tab;
$$('main section').forEach(s => s.classList.toggle('active', s.id === `tab-${name}`));
if (name === 'status') loadStatus();
});
});
// ---------- schedule rendering ----------
const FIELDS = [
{ path: ['aeAerie', 'usrow'], label: 'AE / Aerie — US, Canada, ROW' },
{ path: ['aeAerie', 'mexico'], label: 'AE / Aerie — Mexico' },
{ path: ['toddSnyder', 'usrow', 'tstc'], label: 'Todd Snyder — TS Collections' },
{ path: ['toddSnyder', 'usrow', 'thirdparty'], label: 'Todd Snyder — 3rd Party' },
{ path: ['unsubscribed', 'usrow'], label: 'Unsubscribed — Main' },
{ path: ['unsubscribed', 'thirdparty'], label: 'Unsubscribed — 3rd Party' },
{ path: ['unsubscribed', 'giftcard'], label: 'Unsubscribed — Gift Card' },
];
function getAt(obj, p) { return p.reduce((a, k) => (a == null ? a : a[k]), obj); }
function setAt(obj, p, val) {
let cur = obj;
for (let i = 0; i < p.length - 1; i++) {
if (cur[p[i]] == null || typeof cur[p[i]] !== 'object') cur[p[i]] = {};
cur = cur[p[i]];
}
cur[p[p.length - 1]] = val;
}
function periodBadge(entry) {
const now = Date.now();
const s = Date.parse(entry.start);
const e = Date.parse(entry.end);
if (isNaN(s) || isNaN(e)) return { cls: 'past', label: 'invalid dates' };
if (now >= s && now <= e) return { cls: 'now', label: 'ACTIVE NOW' };
if (now < s) return { cls: 'future', label: 'upcoming' };
return { cls: 'past', label: 'past' };
}
function renderSchedule() {
const list = $('#schedule-list');
list.innerHTML = '';
if (!schedule.length) {
list.innerHTML = '<div class="empty">No schedule entries. Click <b>Add new month</b> to create one.</div>';
return;
}
schedule.forEach((entry, idx) => list.appendChild(renderCard(entry, idx)));
}
function renderCard(entry, idx) {
const card = document.createElement('div');
card.className = 'card';
card.dataset.idx = String(idx);
if (dirty.has(idx)) card.classList.add('dirty');
const badge = periodBadge(entry);
const header = document.createElement('div');
header.className = 'card-header';
header.innerHTML = `
<div class="title">
${escapeHTML(entry.month || '(untitled)')}
<span class="badge ${badge.cls}" style="margin-left:8px;">${badge.label}</span>
</div>
<div class="meta">${escapeHTML(entry.start || '?')}${escapeHTML(entry.end || '?')}</div>
`;
const actions = document.createElement('div');
actions.className = 'actions';
const saveBtn = document.createElement('button');
saveBtn.textContent = 'Save';
saveBtn.className = dirty.has(idx) ? 'dirty' : '';
saveBtn.disabled = !dirty.has(idx);
saveBtn.addEventListener('click', () => saveEntry(idx));
const previewBtn = document.createElement('button');
previewBtn.className = 'ghost';
previewBtn.textContent = 'Preview';
previewBtn.addEventListener('click', () => previewEntry(idx));
const delBtn = document.createElement('button');
delBtn.className = 'ghost';
delBtn.textContent = 'Delete';
delBtn.addEventListener('click', () => deleteEntry(idx));
actions.append(saveBtn, previewBtn, delBtn);
header.appendChild(actions);
card.appendChild(header);
// Top row: month/start/end
const topGrid = document.createElement('div');
topGrid.className = 'grid';
topGrid.appendChild(makeInput(idx, ['month'], 'Month label', entry.month || ''));
topGrid.appendChild(makeInput(idx, ['start'], 'Start date', entry.start || '', 'e.g. 8/1/26'));
topGrid.appendChild(makeInput(idx, ['end'], 'End date', entry.end || '', 'e.g. 9/1/26'));
card.appendChild(topGrid);
// Brand fieldsets
FIELDS.forEach(({ path, label }) => {
const fs = document.createElement('fieldset');
const legend = document.createElement('legend');
legend.textContent = label;
fs.appendChild(legend);
const codePath = [...path, 'code'];
const msgPath = [...path, 'message'];
const row = document.createElement('div');
row.className = 'row2';
row.appendChild(makeInput(idx, codePath, 'Code', getAt(entry, codePath) || '', '', 'code'));
row.appendChild(makeTextarea(idx, msgPath, 'Message', getAt(entry, msgPath) || ''));
fs.appendChild(row);
card.appendChild(fs);
});
const details = document.createElement('details');
details.innerHTML = `<summary>Preview output</summary><div class="preview-title">Fallback text</div><pre class="preview" data-preview-text>Click Preview to render.</pre>`;
card.appendChild(details);
return card;
}
function makeInput(idx, p, label, value, placeholder='', extraClass='') {
const wrap = document.createElement('div');
wrap.className = 'field';
const lab = document.createElement('label');
lab.textContent = label;
const inp = document.createElement('input');
inp.type = 'text';
inp.value = value == null ? '' : String(value);
if (placeholder) inp.placeholder = placeholder;
if (extraClass) inp.classList.add(extraClass);
inp.addEventListener('input', () => {
setAt(schedule[idx], p, inp.value);
markDirty(idx);
});
wrap.append(lab, inp);
return wrap;
}
function makeTextarea(idx, p, label, value) {
const wrap = document.createElement('div');
wrap.className = 'field';
const lab = document.createElement('label');
lab.textContent = label;
const ta = document.createElement('textarea');
ta.value = value == null ? '' : String(value);
ta.rows = 2;
ta.addEventListener('input', () => {
setAt(schedule[idx], p, ta.value);
markDirty(idx);
});
wrap.append(lab, ta);
return wrap;
}
function markDirty(idx) {
dirty.add(idx);
const card = document.querySelector(`.card[data-idx="${idx}"]`);
if (!card) return;
card.classList.add('dirty');
const btn = card.querySelector('.card-header .actions button:first-child');
if (btn) { btn.classList.add('dirty'); btn.disabled = false; }
}
function clearDirty(idx) {
dirty.delete(idx);
const card = document.querySelector(`.card[data-idx="${idx}"]`);
if (!card) return;
card.classList.remove('dirty');
const btn = card.querySelector('.card-header .actions button:first-child');
if (btn) { btn.classList.remove('dirty'); btn.disabled = true; }
}
function escapeHTML(s) {
return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
// ---------- schedule actions ----------
async function saveEntry(idx) {
try {
await api('PUT', `./schedule/${idx}`, schedule[idx]);
clearDirty(idx);
toast(`Saved "${schedule[idx].month}".`, 'success');
} catch (err) {
toast(err.message, 'error', 6000);
}
}
async function deleteEntry(idx) {
if (!confirm(`Delete "${schedule[idx].month}"? A backup is still kept as schedule.json.bak.`)) return;
try {
await api('DELETE', `./schedule/${idx}`);
toast('Entry deleted.', 'success');
loadSchedule();
} catch (err) {
toast(err.message, 'error', 6000);
}
}
async function previewEntry(idx) {
try {
const p = await api('POST', './preview', schedule[idx]);
const card = document.querySelector(`.card[data-idx="${idx}"]`);
const pre = card.querySelector('[data-preview-text]');
pre.textContent = p.text || '(no preview)';
card.querySelector('details').open = true;
} catch (err) {
toast(err.message, 'error', 6000);
}
}
function makeBlankEntry() {
const stub = (code = '', message = '') => ({ code, message });
return {
month: '', start: '', end: '',
aeAerie: { usrow: stub(), mexico: stub() },
toddSnyder: { usrow: { tstc: stub(), thirdparty: stub() } },
unsubscribed: { usrow: stub(), thirdparty: stub(), giftcard: stub() }
};
}
$('#btn-add-blank').addEventListener('click', () => {
schedule.push(makeBlankEntry());
dirty.add(schedule.length - 1);
renderSchedule();
toast('New month added. Fill it in and click Save.', 'info');
document.querySelector(`.card[data-idx="${schedule.length - 1}"]`)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
});
$('#btn-add-duplicate').addEventListener('click', () => {
if (!schedule.length) { toast('Nothing to duplicate.', 'error'); return; }
const last = JSON.parse(JSON.stringify(schedule[schedule.length - 1]));
last.month = last.month + ' (copy)';
schedule.push(last);
dirty.add(schedule.length - 1);
renderSchedule();
toast('Duplicated. Adjust dates + code, then Save.', 'info');
document.querySelector(`.card[data-idx="${schedule.length - 1}"]`)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
});
$('#btn-refresh-schedule').addEventListener('click', () => loadSchedule());
async function loadSchedule() {
try {
const data = await api('GET', './schedule');
schedule = Array.isArray(data) ? data : [];
dirty.clear();
renderSchedule();
} catch (err) {
handleLoadErr(err, 'schedule');
}
}
// ---------- members ----------
async function loadMembers() {
try {
const list = await api('GET', './members');
$('#members-text').value = (list || []).join('\n');
$('#members-count').textContent = String((list || []).length);
} catch (err) {
handleLoadErr(err, 'members');
}
}
$('#btn-refresh-members').addEventListener('click', loadMembers);
$('#members-text').addEventListener('input', () => {
const lines = $('#members-text').value.split(/\r?\n/).map(s => s.trim()).filter(Boolean);
$('#members-count').textContent = String(new Set(lines.map(s => s.toLowerCase())).size);
});
$('#btn-save-members').addEventListener('click', async () => {
const raw = $('#members-text').value.split(/\r?\n/).map(s => s.trim()).filter(Boolean);
if (!raw.length) { toast('Refusing to save an empty list.', 'error'); return; }
if (!confirm(`Save ${new Set(raw.map(s => s.toLowerCase())).size} authorized members?`)) return;
try {
const resp = await api('POST', './members', raw);
toast(`Saved ${resp.count} members.`, 'success');
loadMembers();
} catch (err) {
toast(err.message, 'error', 6000);
}
});
// ---------- status ----------
async function loadStatus() {
try {
const r = await fetch('./status');
const j = await r.json();
$('#status-json').textContent = JSON.stringify(j, null, 2);
} catch (err) {
$('#status-json').textContent = `Failed: ${err.message}`;
}
}
$('#btn-refresh-status').addEventListener('click', loadStatus);
// ---------- boot ----------
function handleLoadErr(err, what) {
if (err.status === 401) {
toast('Session expired — please sign in again.', 'error');
$('#signout').click();
} else {
toast(`Failed to load ${what}: ${err.message}`, 'error', 6000);
}
}
async function loadAll() {
try {
const s = await fetch('./status').then(r => r.json());
$('#server-info').textContent = s.status || '';
} catch { /* ignore */ }
await loadSchedule();
await loadMembers();
}
if (token) {
// Verify existing token
api('GET', './members').then(() => { showApp(); loadAll(); })
.catch((err) => {
token = '';
sessionStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(TOKEN_KEY);
showLogin();
if (err.status && err.status !== 401) toast(err.message, 'error');
});
} else {
showLogin();
}
})();
</script>
</body>
</html>