Admin settings: graphical editors for follow-up emails, rates and site settings

Follow-ups: one card per email (hours, subject, body), placeholder chips,
add / reorder / remove / send-to-me. Rates: labeled fields with hints, tier
table, milestone sub-fields. Site settings: key/value rows. No more JSON.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-09-09 10:00:27 -05:00
parent 9b32d40874
commit dc3a8e0464
2 changed files with 216 additions and 46 deletions
+51 -15
View File
@@ -21,6 +21,35 @@
.adm-form label.small{display:block;margin-bottom:4px} .adm-form label.small{display:block;margin-bottom:4px}
.trunc{max-width:260px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:inline-block;vertical-align:bottom} .trunc{max-width:260px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:inline-block;vertical-align:bottom}
textarea.json{font-family:var(--mono);font-size:12.5px;width:100%;min-height:260px} textarea.json{font-family:var(--mono);font-size:12.5px;width:100%;min-height:260px}
.ph-chips{display:inline-flex;gap:6px;flex-wrap:wrap;margin-left:6px;vertical-align:middle}
.ph-chips .chip-t{padding:2px 10px;font-size:11.5px;font-family:var(--mono);text-transform:none;letter-spacing:0;cursor:pointer;background:transparent}
.drip-steps{display:grid;gap:12px}
.drip-step{border:1px solid var(--line);border-radius:12px;padding:14px 16px;background:rgba(4,8,7,.4)}
.drip-step .ds-head{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:8px}
.drip-step .ds-n{font-family:var(--mono);font-size:12px;color:var(--mint);letter-spacing:.08em}
.drip-step .ds-when{display:flex;align-items:center;gap:6px;font-size:13px;color:var(--muted)}
.drip-step .ds-when input{width:80px;text-align:right}
.drip-step .ds-when b{color:var(--ink)}
.drip-step .ds-tools{margin-left:auto;display:flex;gap:6px;flex-wrap:wrap}
.drip-step .ds-tools .btn{padding:6px 12px;font-size:12px}
.drip-step input.ds-subject{width:100%;margin-bottom:8px;font-weight:700}
.drip-step textarea.ds-body{width:100%;min-height:200px;font-size:13.5px;line-height:1.5}
.rates-form{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:12px 22px}
.rates-form .rf{display:flex;flex-direction:column;gap:4px}
.rates-form .rf label{font-size:13px;font-weight:700}
.rates-form .rf .hint{font-size:11.5px;color:var(--muted)}
.rates-form .rf input[type=number]{max-width:160px}
.rates-form .rf.wide{grid-column:1/-1}
.rates-form .sub-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:8px}
.rates-form .sub-grid label{font-size:12px;font-weight:600;color:var(--muted);display:flex;flex-direction:column;gap:3px}
.rates-form table.tiers{width:auto;font-size:13px}
.rates-form table.tiers td,.rates-form table.tiers th{padding:4px 8px;border:0}
.rates-form table.tiers input{width:90px}
.kv-form{display:grid;gap:8px}
.kv-row{display:grid;grid-template-columns:220px 1fr auto;gap:10px;align-items:center}
.kv-row .k{font-family:var(--mono);font-size:12.5px;overflow-wrap:anywhere}
.kv-row input[type=checkbox]{width:auto;justify-self:start}
@media(max-width:700px){.kv-row{grid-template-columns:1fr}}
</style> </style>
</head> </head>
<body class="bo-body"> <body class="bo-body">
@@ -203,30 +232,37 @@
<div class="pane" id="pane-settings" hidden> <div class="pane" id="pane-settings" hidden>
<div class="card"> <div class="card">
<div class="card-head"><h3>Follow-up emails</h3><span class="sub" id="dripSub">sent to every new free member, hours after sign-up</span></div> <div class="card-head"><h3>Follow-up emails</h3><span class="sub" id="dripSub">sent to every new free member, hours after sign-up</span></div>
<p class="small muted">Each step: <code>hours</code> after the account was created, a <code>subject</code>, and a plain-text <code>body</code>. <p class="small muted">One card per email. Placeholders fill in per reader: click a chip to drop it where your cursor is.
Placeholders: <code>{{link}}</code> the member's invite link, <code>{{sponsor}}</code>, <code>{{site}}</code>, <code>{{footer}}</code> (unsubscribe line + disclaimer, keep it). <span class="ph-chips" id="phChips">
Steps must be in increasing hours.</p> <button type="button" class="chip-t" data-ph="{{link}}" title="the reader's own invite link">{{link}}</button>
<p><textarea id="dripJson" class="json" style="min-height:360px"></textarea></p> <button type="button" class="chip-t" data-ph="{{sponsor}}" title="their sponsor's name">{{sponsor}}</button>
<button type="button" class="chip-t" data-ph="{{site}}" title="https://instantadpay.com">{{site}}</button>
<button type="button" class="chip-t" data-ph="{{paid:words if they bought|words if they have not}}" title="two versions: bought / not yet">{{paid:…|…}}</button>
<button type="button" class="chip-t" data-ph="{{footer}}" title="unsubscribe line + disclaimer (keep it at the end)">{{footer}}</button>
</span></p>
<div id="dripSteps" class="drip-steps"></div>
<p id="dripErr" class="small" style="color:var(--bad)" hidden></p> <p id="dripErr" class="small" style="color:var(--bad)" hidden></p>
<p style="display:flex;gap:10px;flex-wrap:wrap;align-items:center;margin:0"> <p style="display:flex;gap:10px;flex-wrap:wrap;align-items:center;margin:14px 0 0">
<button class="btn small" id="dripSave" type="button">Save sequence</button> <button class="btn small" id="dripSave" type="button">Save sequence</button>
<button class="btn small sec" id="dripAdd" type="button">+ Add an email</button>
<button class="btn small sec" id="dripReset" type="button">Reset to defaults</button> <button class="btn small sec" id="dripReset" type="button">Reset to defaults</button>
<span style="flex:1"></span>
<select id="dripTestStep" style="width:auto"></select>
<button class="btn small sec" id="dripTest" type="button">Send that step to me</button>
</p> </p>
</div> </div>
<div class="card"> <div class="card">
<div class="card-head"><h3>Rates</h3><span class="sub">credit prices and rewards (JSON, merged on save)</span></div> <div class="card-head"><h3>Rates</h3><span class="sub">credit prices, rewards and limits</span></div>
<p><textarea id="ratesJson" class="json"></textarea></p> <div id="ratesForm" class="rates-form"></div>
<p id="ratesErr" class="small" style="color:var(--bad)" hidden></p> <p id="ratesErr" class="small" style="color:var(--bad)" hidden></p>
<button class="btn small" id="ratesSave" type="button">Save rates</button> <p style="margin:14px 0 0"><button class="btn small" id="ratesSave" type="button">Save rates</button></p>
</div> </div>
<div class="card"> <div class="card">
<div class="card-head"><h3>Site config</h3><span class="sub">public: everything here is served by /api/config, never put secrets in it</span></div> <div class="card-head"><h3>Site settings</h3><span class="sub">public: everything here is served to the browser, never put a secret in it</span></div>
<p><textarea id="siteJson" class="json"></textarea></p> <div id="siteForm" class="kv-form"></div>
<p style="display:flex;gap:10px;flex-wrap:wrap;align-items:center;margin:12px 0 0">
<input id="siteNewKey" placeholder="new setting name" style="max-width:220px">
<button class="btn small sec" id="siteAddKey" type="button">+ Add setting</button>
</p>
<p id="siteErr" class="small" style="color:var(--bad)" hidden></p> <p id="siteErr" class="small" style="color:var(--bad)" hidden></p>
<button class="btn small" id="siteSave" type="button">Save site config</button> <p style="margin:14px 0 0"><button class="btn small" id="siteSave" type="button">Save site settings</button></p>
</div> </div>
</div> </div>
@@ -235,6 +271,6 @@
</div> </div>
<script src="/assets/common.js?v=20260909a"></script> <script src="/assets/common.js?v=20260909a"></script>
<script src="/assets/admin.js?v=20260909b"></script> <script src="/assets/admin.js?v=20260909c"></script>
</body> </body>
</html> </html>
+165 -31
View File
@@ -272,44 +272,178 @@
catch (err) { IAP.status(err.message, 'bad'); b.disabled = false; } catch (err) { IAP.status(err.message, 'bad'); b.disabled = false; }
}); });
// ── settings ── // ── settings: graphical editors (follow-up emails, rates, site settings) ──
async function loadSettings() { let dripSeq = [], ratesObj = {}, siteObj = {};
const [r, s, d] = await Promise.all([api('/api/admin/rates'), api('/api/admin/site'), api('/api/admin/drip')]); let lastFocusedField = null;
$('ratesJson').value = JSON.stringify(r.rates || {}, null, 2); document.addEventListener('focusin', e => { if (e.target && (e.target.matches('textarea.ds-body') || e.target.matches('input.ds-subject'))) lastFocusedField = e.target; });
$('siteJson').value = JSON.stringify(s.site || {}, null, 2); document.addEventListener('click', e => {
$('dripJson').value = JSON.stringify(d.sequence || [], null, 2); const c = e.target.closest('[data-ph]'); if (!c) return;
const st = d.stats || {}; const el = lastFocusedField; if (!el) { IAP.status('Click into a subject or body first, then the chip.', 'bad'); return; }
$('dripSub').textContent = (st.active || 0) + ' in flight · ' + (st.done || 0) + ' finished · ' + (st.unsubscribed || 0) + ' unsubscribed' + (d.mailReady ? '' : ' · NO MAIL KEY: nothing sends'); const ph = c.dataset.ph, st = el.selectionStart || 0, en = el.selectionEnd || st;
$('dripTestStep').innerHTML = (d.sequence || []).map((s, i) => '<option value="' + i + '">Step ' + (i + 1) + ' · ' + s.hours + 'h · ' + esc(s.subject) + '</option>').join(''); el.value = el.value.slice(0, st) + ph + el.value.slice(en);
el.focus(); el.selectionStart = el.selectionEnd = st + ph.length;
el.dispatchEvent(new Event('input'));
});
const whenLabel = h => { h = Number(h) || 0; if (h < 24) return h + ' hour' + (h === 1 ? '' : 's') + ' after sign-up'; const d = h / 24; return (Number.isInteger(d) ? d : d.toFixed(1)) + ' day' + (d === 1 ? '' : 's') + ' after sign-up'; };
function drawDrip() {
const wrap = $('dripSteps');
wrap.innerHTML = dripSeq.map((st, i) => '<div class="drip-step" data-i="' + i + '">'
+ '<div class="ds-head"><span class="ds-n">EMAIL ' + (i + 1) + '</span>'
+ '<span class="ds-when">send <input type="number" min="1" class="ds-hours" value="' + esc(st.hours) + '"> hours after sign-up <b class="ds-whenlbl">(' + esc(whenLabel(st.hours)) + ')</b></span>'
+ '<span class="ds-tools"><button type="button" class="btn small sec" data-act="up" ' + (i === 0 ? 'disabled' : '') + '>↑</button><button type="button" class="btn small sec" data-act="down" ' + (i === dripSeq.length - 1 ? 'disabled' : '') + '>↓</button>'
+ '<button type="button" class="btn small sec" data-act="test">Send to me</button><button type="button" class="btn small sec" data-act="remove">Remove</button></span></div>'
+ '<input class="ds-subject" placeholder="Subject line" maxlength="150" value="' + esc(st.subject) + '">'
+ '<textarea class="ds-body" placeholder="Plain-text email body">' + esc(st.body) + '</textarea>'
+ '</div>').join('') || '<p class="muted small">No emails yet. Add one below.</p>';
} }
$('dripSave').addEventListener('click', busy($('dripSave'), async () => { function readDrip() {
$('dripErr').hidden = true; return [...document.querySelectorAll('#dripSteps .drip-step')].map(card => ({
let seq; hours: Number(card.querySelector('.ds-hours').value) || 0,
try { seq = JSON.parse($('dripJson').value); } catch (e) { $('dripErr').textContent = 'That is not valid JSON: ' + e.message; $('dripErr').hidden = false; return; } subject: card.querySelector('.ds-subject').value.trim(),
try { const r = await api('/api/admin/drip', { sequence: seq }, 'PATCH'); $('dripJson').value = JSON.stringify(r.sequence, null, 2); IAP.status('Sequence saved.', 'ok'); await loadSettings(); } body: card.querySelector('.ds-body').value.trim()
catch (e) { $('dripErr').textContent = e.message; $('dripErr').hidden = false; }
})); }));
}
$('dripSteps').addEventListener('input', e => {
if (e.target.classList.contains('ds-hours')) { const l = e.target.closest('.ds-when').querySelector('.ds-whenlbl'); if (l) l.textContent = '(' + whenLabel(e.target.value) + ')'; }
});
$('dripSteps').addEventListener('click', async e => {
const b = e.target.closest('[data-act]'); if (!b) return;
const card = b.closest('.drip-step'), i = Number(card.dataset.i);
dripSeq = readDrip();
if (b.dataset.act === 'remove') { if (!confirm('Remove email ' + (i + 1) + '?')) return; dripSeq.splice(i, 1); drawDrip(); return; }
if (b.dataset.act === 'up' && i > 0) { [dripSeq[i - 1], dripSeq[i]] = [dripSeq[i], dripSeq[i - 1]]; drawDrip(); return; }
if (b.dataset.act === 'down' && i < dripSeq.length - 1) { [dripSeq[i + 1], dripSeq[i]] = [dripSeq[i], dripSeq[i + 1]]; drawDrip(); return; }
if (b.dataset.act === 'test') {
b.disabled = true;
try { await saveDrip(); await api('/api/admin/drip/test', { step: i }); IAP.status('Email ' + (i + 1) + ' sent to your inbox.', 'ok'); }
catch (err) { IAP.status(err.message, 'bad'); }
b.disabled = false;
}
});
async function saveDrip() {
$('dripErr').hidden = true;
const seq = readDrip();
const r = await api('/api/admin/drip', { sequence: seq }, 'PATCH').catch(err => { $('dripErr').textContent = err.message; $('dripErr').hidden = false; throw err; });
dripSeq = r.sequence; drawDrip();
return r;
}
$('dripSave').addEventListener('click', busy($('dripSave'), async () => { await saveDrip(); IAP.status('Sequence saved.', 'ok'); await loadSettings(); }));
$('dripAdd').addEventListener('click', () => {
dripSeq = readDrip();
const last = dripSeq[dripSeq.length - 1];
dripSeq.push({ hours: last ? Number(last.hours) + 48 : 24, subject: '', body: '\n\nMarty\n\n{{footer}}' });
drawDrip();
const cards = document.querySelectorAll('#dripSteps .drip-step'); const c = cards[cards.length - 1]; if (c) { c.scrollIntoView({ behavior: 'smooth', block: 'center' }); c.querySelector('.ds-subject').focus(); }
});
$('dripReset').addEventListener('click', busy($('dripReset'), async () => { $('dripReset').addEventListener('click', busy($('dripReset'), async () => {
if (!confirm('Replace the saved sequence with the built-in defaults?')) return; if (!confirm('Replace the saved sequence with the built-in defaults?')) return;
const r = await api('/api/admin/drip', { reset: true }, 'PATCH'); const r = await api('/api/admin/drip', { reset: true }, 'PATCH');
$('dripJson').value = JSON.stringify(r.sequence, null, 2); IAP.status('Defaults restored.', 'ok'); await loadSettings(); dripSeq = r.sequence; drawDrip(); IAP.status('Defaults restored.', 'ok'); await loadSettings();
}));
$('dripTest').addEventListener('click', busy($('dripTest'), async () => {
await api('/api/admin/drip/test', { step: Number($('dripTestStep').value) || 0 });
IAP.status('Sent to your inbox.', 'ok');
}));
function saveJson(btnId, taId, errId, path, key) {
$(btnId).addEventListener('click', busy($(btnId), async () => {
$(errId).hidden = true;
let obj;
try { obj = JSON.parse($(taId).value); } catch (e) { $(errId).textContent = 'That is not valid JSON: ' + e.message; $(errId).hidden = false; return; }
const r = await api(path, obj, 'PATCH');
$(taId).value = JSON.stringify(r[key] || obj, null, 2);
IAP.status('Saved.', 'ok');
})); }));
// rates: labels + hints for the known keys; anything unknown still gets a plain field
const RATE_META = {
bannerBatch: ['Banner: views per batch', 'impressions counted before a banner campaign is charged'],
bannerCreditsPerBatch: ['Banner: credits per batch', 'charged to the advertiser per batch'],
textBatch: ['Text ad: views per batch', ''], textCreditsPerBatch: ['Text ad: credits per batch', ''],
loginCreditsPerDay: ['Login ad: credits per day', 'flat daily charge while active'],
loginDwellSeconds: ['Login ad: seconds shown', 'full-screen interstitial after sign-in'],
burnBatchMin: ['On-chain burn batch (credits)', 'accrued spend is burned once it reaches this'],
welcomeCredits: ['Welcome credits', 'granted after the welcome tour'],
dailyViewTarget: ['Daily view set (ads)', 'ads a member views for the daily claim'],
dailyClaimCredits: ['Daily claim (credits)', 'paid when the set is complete'],
viewDwellSeconds: ['Ad view: seconds per ad', 'the countdown; server-enforced'],
soloCostPerRecipient: ['Solo ad: credits per recipient', ''], soloMinRecipients: ['Solo ad: minimum recipients', ''],
soloReadCredits: ['Solo ad: reader reward (credits)', ''], soloReadCapPerDay: ['Solo ad: rewarded reads per day', ''], soloReadDwellSeconds: ['Solo ad: seconds to read', ''],
videoWatchCapPerDay: ['Video: rewarded watches per day', ''],
featuredPerDay: ['Featured link: credits per day', ''], featuredSlotsPerDay: ['Featured link: slots per day', ''], featuredWindowDays: ['Featured link: booking window (days)', ''],
featuredDurations: ['Featured link: durations offered (days)', 'comma-separated'],
visitCostPerVisit: ['Verified visit: credits per visit', ''], visitMinPack: ['Verified visit: smallest pack', ''], visitReward: ['Verified visit: viewer reward (credits)', ''], visitDwellSeconds: ['Verified visit: seconds on site', ''], visitCapPerDay: ['Verified visit: rewarded visits per day', ''],
videoTiers: ['Video ad tiers', 'watch length → advertiser cost → viewer reward'],
milestoneBonus: ['Milestone bonuses (credits)', 'one-time, when a member reaches each step']
};
const humanize = k => k.replace(/([A-Z])/g, ' $1').replace(/^./, c => c.toUpperCase());
function drawRates() {
const wrap = $('ratesForm'); const html = [];
for (const [k, v] of Object.entries(ratesObj)) {
const [label, hint] = RATE_META[k] || [humanize(k), ''];
if (typeof v === 'number') html.push('<div class="rf"><label>' + esc(label) + '</label><input type="number" step="any" data-rk="' + esc(k) + '" value="' + esc(v) + '">' + (hint ? '<span class="hint">' + esc(hint) + '</span>' : '') + '</div>');
else if (typeof v === 'boolean') html.push('<div class="rf"><label>' + esc(label) + '</label><label class="small"><input type="checkbox" data-rk="' + esc(k) + '"' + (v ? ' checked' : '') + ' style="width:auto"> on</label></div>');
else if (Array.isArray(v) && v.every(x => typeof x === 'number')) html.push('<div class="rf"><label>' + esc(label) + '</label><input data-rk="' + esc(k) + '" data-kind="numlist" value="' + esc(v.join(', ')) + '">' + (hint ? '<span class="hint">' + esc(hint) + '</span>' : '') + '</div>');
else if (Array.isArray(v) && v.every(x => x && typeof x === 'object')) {
const cols = [...new Set(v.flatMap(x => Object.keys(x)))];
html.push('<div class="rf wide"><label>' + esc(label) + '</label>' + (hint ? '<span class="hint">' + esc(hint) + '</span>' : '')
+ '<table class="tiers" data-rk="' + esc(k) + '" data-kind="table"><tr>' + cols.map(c => '<th>' + esc(c) + '</th>').join('') + '</tr>'
+ v.map((row, i) => '<tr>' + cols.map(c => '<td><input type="number" step="any" data-col="' + esc(c) + '" value="' + esc(row[c] == null ? '' : row[c]) + '"></td>').join('') + '</tr>').join('') + '</table></div>');
} else if (v && typeof v === 'object') {
html.push('<div class="rf wide"><label>' + esc(label) + '</label>' + (hint ? '<span class="hint">' + esc(hint) + '</span>' : '') + '<div class="sub-grid" data-rk="' + esc(k) + '" data-kind="object">'
+ Object.entries(v).map(([sk, sv]) => '<label>' + esc(humanize(sk)) + '<input type="number" step="any" data-sub="' + esc(sk) + '" value="' + esc(sv) + '"></label>').join('') + '</div></div>');
} else html.push('<div class="rf"><label>' + esc(label) + '</label><input data-rk="' + esc(k) + '" value="' + esc(v == null ? '' : v) + '"></div>');
}
wrap.innerHTML = html.join('');
}
function readRates() {
const out = {};
document.querySelectorAll('#ratesForm [data-rk]').forEach(el => {
const k = el.dataset.rk, kind = el.dataset.kind;
if (kind === 'numlist') out[k] = el.value.split(/[\s,]+/).filter(Boolean).map(Number).filter(n => !isNaN(n));
else if (kind === 'table') out[k] = [...el.querySelectorAll('tr')].slice(1).map(tr => { const o = {}; tr.querySelectorAll('input[data-col]').forEach(i => { o[i.dataset.col] = Number(i.value); }); return o; });
else if (kind === 'object') { const o = {}; el.querySelectorAll('input[data-sub]').forEach(i => { o[i.dataset.sub] = Number(i.value); }); out[k] = o; }
else if (el.type === 'checkbox') out[k] = !!el.checked;
else if (el.type === 'number') out[k] = Number(el.value);
else out[k] = el.value;
});
return out;
}
$('ratesSave').addEventListener('click', busy($('ratesSave'), async () => {
$('ratesErr').hidden = true;
try { const r = await api('/api/admin/rates', readRates(), 'PATCH'); ratesObj = r.rates || readRates(); drawRates(); IAP.status('Rates saved.', 'ok'); }
catch (e) { $('ratesErr').textContent = e.message; $('ratesErr').hidden = false; }
}));
// site settings: key / value rows; booleans as checkboxes, numbers stay numbers
const SITE_META = { siteName: 'Site name', tagline: 'Tagline', rehearsal: 'Testnet rehearsal banner', defaultSponsorId: 'Fallback sponsor (member #)', walletConnectProjectId: 'WalletConnect project id', moonpayPublicKey: 'MoonPay public key' };
function drawSite() {
const wrap = $('siteForm');
wrap.innerHTML = Object.entries(siteObj).map(([k, v]) => '<div class="kv-row"><span class="k" title="' + esc(k) + '">' + esc(SITE_META[k] || humanize(k)) + '</span>'
+ (typeof v === 'boolean' ? '<input type="checkbox" data-sk="' + esc(k) + '"' + (v ? ' checked' : '') + '>'
: typeof v === 'number' ? '<input type="number" step="any" data-sk="' + esc(k) + '" value="' + esc(v) + '">'
: '<input data-sk="' + esc(k) + '" value="' + esc(typeof v === 'object' ? JSON.stringify(v) : (v == null ? '' : v)) + '">')
+ '<button type="button" class="btn small sec" data-sdel="' + esc(k) + '">Clear</button></div>').join('') || '<p class="muted small">No settings saved yet.</p>';
}
function readSite() {
const out = {};
document.querySelectorAll('#siteForm [data-sk]').forEach(el => {
const k = el.dataset.sk;
if (el.type === 'checkbox') out[k] = !!el.checked;
else if (el.type === 'number') out[k] = Number(el.value);
else { const v = el.value; if (/^[\[{]/.test(v)) { try { out[k] = JSON.parse(v); return; } catch (e) {} } out[k] = v; }
});
return out;
}
$('siteForm').addEventListener('click', e => {
const b = e.target.closest('[data-sdel]'); if (!b) return;
siteObj = readSite(); siteObj[b.dataset.sdel] = ''; drawSite();
});
$('siteAddKey').addEventListener('click', () => {
const k = $('siteNewKey').value.trim(); if (!/^[A-Za-z][A-Za-z0-9_]{0,40}$/.test(k)) { IAP.status('Setting names are letters and numbers, no spaces.', 'bad'); return; }
siteObj = readSite(); if (!(k in siteObj)) siteObj[k] = ''; $('siteNewKey').value = ''; drawSite();
const el = document.querySelector('#siteForm [data-sk="' + k + '"]'); if (el) el.focus();
});
$('siteSave').addEventListener('click', busy($('siteSave'), async () => {
$('siteErr').hidden = true;
try { const r = await api('/api/admin/site', readSite(), 'PATCH'); siteObj = r.site || readSite(); drawSite(); IAP.status('Site settings saved.', 'ok'); }
catch (e) { $('siteErr').textContent = e.message; $('siteErr').hidden = false; }
}));
async function loadSettings() {
const [r, s, d] = await Promise.all([api('/api/admin/rates'), api('/api/admin/site'), api('/api/admin/drip')]);
ratesObj = r.rates || {}; drawRates();
siteObj = s.site || {}; drawSite();
dripSeq = d.sequence || []; drawDrip();
const st = d.stats || {};
$('dripSub').textContent = (st.active || 0) + ' in flight · ' + (st.done || 0) + ' finished · ' + (st.unsubscribed || 0) + ' unsubscribed' + (d.mailReady ? '' : ' · NO MAIL KEY: nothing sends');
} }
saveJson('ratesSave', 'ratesJson', 'ratesErr', '/api/admin/rates', 'rates');
saveJson('siteSave', 'siteJson', 'siteErr', '/api/admin/site', 'site');
render(); render();
})(); })();