From dc3a8e046404a84470d2f15cda3aa0601f857ed7 Mon Sep 17 00:00:00 2001 From: martbost Date: Wed, 9 Sep 2026 10:00:27 -0500 Subject: [PATCH] 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 --- public/admin.html | 66 ++++++++++---- public/assets/admin.js | 196 ++++++++++++++++++++++++++++++++++------- 2 files changed, 216 insertions(+), 46 deletions(-) diff --git a/public/admin.html b/public/admin.html index 4b41194..2a0b906 100644 --- a/public/admin.html +++ b/public/admin.html @@ -21,6 +21,35 @@ .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} 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}} @@ -203,30 +232,37 @@ @@ -235,6 +271,6 @@ - + diff --git a/public/assets/admin.js b/public/assets/admin.js index 5498d45..5f6ff50 100644 --- a/public/assets/admin.js +++ b/public/assets/admin.js @@ -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) => '').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) => '
' + + '
EMAIL ' + (i + 1) + '' + + 'send hours after sign-up (' + esc(whenLabel(st.hours)) + ')' + + '' + + '
' + + '' + + '' + + '
').join('') || '

No emails yet. Add one below.

'; } - $('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('
' + (hint ? '' + esc(hint) + '' : '') + '
'); + else if (typeof v === 'boolean') html.push('
'); + else if (Array.isArray(v) && v.every(x => typeof x === 'number')) html.push('
' + (hint ? '' + esc(hint) + '' : '') + '
'); + else if (Array.isArray(v) && v.every(x => x && typeof x === 'object')) { + const cols = [...new Set(v.flatMap(x => Object.keys(x)))]; + html.push('
' + (hint ? '' + esc(hint) + '' : '') + + '' + cols.map(c => '').join('') + '' + + v.map((row, i) => '' + cols.map(c => '').join('') + '').join('') + '
' + esc(c) + '
'); + } else if (v && typeof v === 'object') { + html.push('
' + (hint ? '' + esc(hint) + '' : '') + '
' + + Object.entries(v).map(([sk, sv]) => '').join('') + '
'); + } else html.push('
'); + } + 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]) => '
' + esc(SITE_META[k] || humanize(k)) + '' + + (typeof v === 'boolean' ? '' + : typeof v === 'number' ? '' + : '') + + '
').join('') || '

No settings saved yet.

'; + } + 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(); })();