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:
+51
-15
@@ -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
@@ -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() {
|
||||||
|
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;
|
$('dripErr').hidden = true;
|
||||||
let seq;
|
const seq = readDrip();
|
||||||
try { seq = JSON.parse($('dripJson').value); } catch (e) { $('dripErr').textContent = 'That is not valid JSON: ' + e.message; $('dripErr').hidden = false; return; }
|
const r = await api('/api/admin/drip', { sequence: seq }, 'PATCH').catch(err => { $('dripErr').textContent = err.message; $('dripErr').hidden = false; throw err; });
|
||||||
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(); }
|
dripSeq = r.sequence; drawDrip();
|
||||||
catch (e) { $('dripErr').textContent = e.message; $('dripErr').hidden = false; }
|
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 });
|
// rates: labels + hints for the known keys; anything unknown still gets a plain field
|
||||||
IAP.status('Sent to your inbox.', 'ok');
|
const RATE_META = {
|
||||||
}));
|
bannerBatch: ['Banner: views per batch', 'impressions counted before a banner campaign is charged'],
|
||||||
function saveJson(btnId, taId, errId, path, key) {
|
bannerCreditsPerBatch: ['Banner: credits per batch', 'charged to the advertiser per batch'],
|
||||||
$(btnId).addEventListener('click', busy($(btnId), async () => {
|
textBatch: ['Text ad: views per batch', ''], textCreditsPerBatch: ['Text ad: credits per batch', ''],
|
||||||
$(errId).hidden = true;
|
loginCreditsPerDay: ['Login ad: credits per day', 'flat daily charge while active'],
|
||||||
let obj;
|
loginDwellSeconds: ['Login ad: seconds shown', 'full-screen interstitial after sign-in'],
|
||||||
try { obj = JSON.parse($(taId).value); } catch (e) { $(errId).textContent = 'That is not valid JSON: ' + e.message; $(errId).hidden = false; return; }
|
burnBatchMin: ['On-chain burn batch (credits)', 'accrued spend is burned once it reaches this'],
|
||||||
const r = await api(path, obj, 'PATCH');
|
welcomeCredits: ['Welcome credits', 'granted after the welcome tour'],
|
||||||
$(taId).value = JSON.stringify(r[key] || obj, null, 2);
|
dailyViewTarget: ['Daily view set (ads)', 'ads a member views for the daily claim'],
|
||||||
IAP.status('Saved.', 'ok');
|
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();
|
||||||
})();
|
})();
|
||||||
|
|||||||
Reference in New Issue
Block a user