- 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>
654 lines
25 KiB
HTML
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 <ADMIN_TOKEN></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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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>
|