Admin > Releases: email an update to members (pick notes, intro, audience, preview, test, send; opt-outs honoured; send log)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-09-14 13:40:01 -05:00
parent 203ebdb4bf
commit 7bb956e896
6 changed files with 142 additions and 3 deletions
+20 -1
View File
@@ -394,6 +394,25 @@
<div class="card"><div class="card-head"><h3>Notes</h3><span class="sub" id="rnSub"></span></div><div class="tablewrap"><table class="adm-table" id="rnTable"></table></div></div>
<div class="card"><div class="card-head"><h3>Roadmap</h3><span class="sub" id="rmSub"></span></div><div class="tablewrap"><table class="adm-table" id="rmTable"></table></div></div>
</div>
<div class="card" id="updCard">
<div class="card-head"><h3>Email an update to members</h3><span class="sub" id="updSub"></span></div>
<p class="small muted" style="margin:0 0 10px">Pick the notes to include, add a line or two in your own words, preview, send yourself a test, then send. Plain text from no-reply@instantadpay.com, one member at a time, opt-outs honoured. Nothing goes out until you press Send.</p>
<div class="grid c2">
<div style="display:grid;gap:8px">
<label class="small">Subject<input id="updSubject" type="text" maxlength="150" placeholder="What is new on InstantAdPay this week"></label>
<label class="small">Intro (optional, plain text)<textarea id="updIntro" rows="4" style="width:100%" placeholder="Quick one. Three things shipped since my last note, and the third one is the one to try today."></textarea></label>
<label class="small">Audience<select id="updAudience"></select></label>
<div style="display:flex;gap:8px;flex-wrap:wrap"><button type="button" class="btn small sec" id="updPreview">Preview</button><button type="button" class="btn small sec" id="updTest">Send test to me</button><button type="button" class="btn small" id="updSend">Send</button></div>
<pre id="updPre" class="small" hidden style="white-space:pre-wrap;background:rgba(255,255,255,.04);border:1px solid var(--line);border-radius:10px;padding:10px;max-height:320px;overflow:auto"></pre>
</div>
<div>
<div class="small muted" style="margin:0 0 6px">Release notes to include (newest first; notes since your last send are pre-ticked)</div>
<div id="updNotes" style="display:grid;gap:4px;max-height:260px;overflow:auto"></div>
<div class="small muted" style="margin:12px 0 6px">Recent sends</div>
<div class="tablewrap"><table class="adm-table" id="updLog"></table></div>
</div>
</div>
</div>
<p class="small muted">Public page: <a href="/whats-new" target="_blank" rel="noopener">instantadpay.com/whats-new</a></p>
</div>
<div class="pane" id="pane-pnl" hidden>
@@ -461,6 +480,6 @@
</div>
<script src="/assets/common.js?v=20260914a"></script>
<script src="/assets/admin.js?v=20260914a"></script>
<script src="/assets/admin.js?v=20260914b"></script>
</body>
</html>
+24
View File
@@ -427,6 +427,7 @@
// ── release notes + roadmap (Marty, 2026-09-14) ──
async function loadReleases() {
loadUpdates();
const d = await api('/api/admin/releases');
$('rnSub').textContent = d.notes.length + ' notes'; $('rmSub').textContent = d.roadmap.length + ' items';
$('rnTable').innerHTML = '<tr><th>Date</th><th>Title</th><th>Tags</th><th></th></tr>' + (d.notes.length ? d.notes.map(n => '<tr><td class="small">' + esc(n.date) + '</td><td><b>' + esc(n.title) + '</b></td><td class="small">' + esc(n.tags.join(', ')) + '</td><td class="act"><button type="button" class="btn small sec" data-rnedit="' + esc(n.id) + '">Edit</button> <button type="button" class="btn small sec" data-rndel="' + esc(n.id) + '">Delete</button></td></tr>').join('') : '<tr><td colspan="4" class="muted">No notes yet.</td></tr>');
@@ -436,6 +437,29 @@
$('rnTable').querySelectorAll('[data-rndel]').forEach(b => b.addEventListener('click', async () => { if (!await IAP.confirmBox('Delete this release note?', { ok: 'Delete', cancel: 'Keep' })) return; await api('/api/admin/releases?kind=notes&id=' + encodeURIComponent(b.dataset.rndel), undefined, 'DELETE'); loadReleases(); }));
$('rmTable').querySelectorAll('[data-rmdel]').forEach(b => b.addEventListener('click', async () => { if (!await IAP.confirmBox('Delete this roadmap item?', { ok: 'Delete', cancel: 'Keep' })) return; await api('/api/admin/releases?kind=roadmap&id=' + encodeURIComponent(b.dataset.rmdel), undefined, 'DELETE'); loadReleases(); }));
}
// ── member update emails (Marty, 2026-09-14) ──
async function loadUpdates() {
if (!$('updCard')) return;
try {
const d = await api('/api/admin/updates');
$('updSub').textContent = (d.mailer ? '' : 'mailer not configured · ') + (d.lastSentAt ? 'last send ' + new Date(d.lastSentAt).toLocaleString() : 'nothing sent yet') + (d.running ? ' · sending now' : '');
$('updAudience').innerHTML = Object.entries(d.audiences).map(([k, v]) => '<option value="' + k + '">' + esc(v) + ' (' + (d.counts[k] || 0) + ')</option>').join('');
$('updNotes').innerHTML = d.notes.length ? d.notes.map(n => '<label class="small" style="display:flex;gap:8px;align-items:flex-start"><input type="checkbox" value="' + esc(n.id) + '"' + (n.fresh ? ' checked' : '') + '><span>' + esc(n.title) + ' <span class="muted">' + esc(n.date) + '</span></span></label>').join('') : '<span class="muted small">No release notes yet.</span>';
$('updLog').innerHTML = '<tr><th>When</th><th>Subject</th><th>Audience</th><th>Sent</th></tr>' + (d.sends.length ? d.sends.map(x => '<tr><td class="small">' + new Date(x.ts).toLocaleString() + '</td><td>' + esc(x.subject) + '</td><td class="small">' + esc(d.audiences[x.audience] || x.audience) + '</td><td class="small">' + x.sent + ' of ' + x.total + (x.skipped ? ' · ' + x.skipped + ' opted out' : '') + (x.failed ? ' · ' + x.failed + ' failed' : '') + (x.status === 'running' ? ' · running' : '') + '</td></tr>').join('') : '<tr><td colspan="4" class="muted small">None yet.</td></tr>');
if (d.running) setTimeout(loadUpdates, 4000);
} catch (e) { $('updSub').textContent = e.message; }
}
const updInput = () => ({ subject: $('updSubject').value, intro: $('updIntro').value, noteIds: [...$('updNotes').querySelectorAll('input:checked')].map(i => i.value), audience: $('updAudience').value });
if ($('updCard')) {
$('updPreview').addEventListener('click', busy($('updPreview'), async () => { const r = await api('/api/admin/updates/preview', updInput()); $('updPre').hidden = false; $('updPre').textContent = 'Subject: ' + r.subject + '\n\n' + r.text; }));
$('updTest').addEventListener('click', busy($('updTest'), async () => { const r = await api('/api/admin/updates/send', Object.assign(updInput(), { test: true })); IAP.status('Test sent to ' + r.to + '.', 'ok'); }));
$('updSend').addEventListener('click', busy($('updSend'), async () => {
const inp = updInput(); if (!inp.noteIds.length) { IAP.status('Pick at least one note.', 'bad'); return; }
const opt = $('updAudience').selectedOptions[0].textContent;
if (!(await IAP.confirmBox('Send this update to ' + opt + '? One email per member, opt-outs skipped.', { title: 'Send member update', ok: 'Send now', cancel: 'Not yet' }))) return;
const r = await api('/api/admin/updates/send', inp); IAP.status('Sending to ' + r.total + ' members in the background.', 'ok'); loadUpdates();
}));
}
$('rnClear').addEventListener('click', () => { ['rnId', 'rnTitle', 'rnDate', 'rnTags', 'rnBody'].forEach(id => { $(id).value = ''; }); });
$('rmClear').addEventListener('click', () => { ['rmId', 'rmTitle', 'rmEta', 'rmOrder', 'rmNote'].forEach(id => { $(id).value = ''; }); $('rmStatus').value = 'planned'; });
$('rnSave').addEventListener('click', busy($('rnSave'), async () => { await api('/api/admin/releases', { kind: 'note', id: $('rnId').value || null, title: $('rnTitle').value, date: $('rnDate').value, tags: $('rnTags').value, body: $('rnBody').value }); IAP.status('Note saved.', 'ok'); $('rnClear').click(); loadReleases(); }));