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
+165 -31
View File
@@ -272,44 +272,178 @@
catch (err) { IAP.status(err.message, 'bad'); b.disabled = false; }
});
// ── settings ──
async function loadSettings() {
const [r, s, d] = await Promise.all([api('/api/admin/rates'), api('/api/admin/site'), api('/api/admin/drip')]);
$('ratesJson').value = JSON.stringify(r.rates || {}, null, 2);
$('siteJson').value = JSON.stringify(s.site || {}, null, 2);
$('dripJson').value = JSON.stringify(d.sequence || [], null, 2);
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');
$('dripTestStep').innerHTML = (d.sequence || []).map((s, i) => '<option value="' + i + '">Step ' + (i + 1) + ' · ' + s.hours + 'h · ' + esc(s.subject) + '</option>').join('');
// ── settings: graphical editors (follow-up emails, rates, site settings) ──
let dripSeq = [], ratesObj = {}, siteObj = {};
let lastFocusedField = null;
document.addEventListener('focusin', e => { if (e.target && (e.target.matches('textarea.ds-body') || e.target.matches('input.ds-subject'))) lastFocusedField = e.target; });
document.addEventListener('click', e => {
const c = e.target.closest('[data-ph]'); if (!c) return;
const el = lastFocusedField; if (!el) { IAP.status('Click into a subject or body first, then the chip.', 'bad'); return; }
const ph = c.dataset.ph, st = el.selectionStart || 0, en = el.selectionEnd || st;
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() {
return [...document.querySelectorAll('#dripSteps .drip-step')].map(card => ({
hours: Number(card.querySelector('.ds-hours').value) || 0,
subject: card.querySelector('.ds-subject').value.trim(),
body: card.querySelector('.ds-body').value.trim()
}));
}
$('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;
let seq;
try { seq = JSON.parse($('dripJson').value); } catch (e) { $('dripErr').textContent = 'That is not valid JSON: ' + e.message; $('dripErr').hidden = false; return; }
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(); }
catch (e) { $('dripErr').textContent = e.message; $('dripErr').hidden = false; }
}));
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 () => {
if (!confirm('Replace the saved sequence with the built-in defaults?')) return;
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();
})();