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:
+165
-31
@@ -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();
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user