From 6e8f1ccf9d664fd59b9b74c7cef1d58766e72aad Mon Sep 17 00:00:00 2001 From: martbost Date: Mon, 14 Sep 2026 14:21:13 -0500 Subject: [PATCH] Member updates: closing text after the notes (sign-off placement) Co-Authored-By: Claude Fable 5.1 --- public/admin.html | 3 ++- public/assets/admin.js | 4 ++-- server.js | 4 ++-- updates.js | 5 +++-- 4 files changed, 9 insertions(+), 7 deletions(-) diff --git a/public/admin.html b/public/admin.html index 762e4aa..27aad39 100644 --- a/public/admin.html +++ b/public/admin.html @@ -401,6 +401,7 @@
+
@@ -480,6 +481,6 @@
- + diff --git a/public/assets/admin.js b/public/assets/admin.js index f5965de..1ad652e 100644 --- a/public/assets/admin.js +++ b/public/assets/admin.js @@ -446,12 +446,12 @@ $('updAudience').innerHTML = Object.entries(d.audiences).map(([k, v]) => '').join(''); $('updNotes').innerHTML = d.notes.length ? d.notes.map(n => '').join('') : 'No release notes yet.'; $('updLog').innerHTML = 'WhenSubjectAudienceSent' + (d.sends.length ? d.sends.map(x => '' + new Date(x.ts).toLocaleString() + '' + esc(x.subject) + '' + esc(d.audiences[x.audience] || x.audience) + '' + x.sent + ' of ' + x.total + (x.skipped ? ' · ' + x.skipped + ' opted out' : '') + (x.failed ? ' · ' + x.failed + ' failed' : '') + (x.status === 'running' ? ' · running' : '') + '').join('') : 'None yet.'); - if (d.draft && !updDraftLoaded) { updDraftLoaded = true; $('updSubject').value = d.draft.subject || ''; $('updIntro').value = d.draft.intro || ''; if (d.draft.audience) $('updAudience').value = d.draft.audience; $('updNotes').querySelectorAll('input').forEach(i => { i.checked = (d.draft.noteIds || []).includes(i.value); }); $('updSub').textContent += ' · draft loaded (saved ' + new Date(d.draft.savedAt).toLocaleString() + ')'; } + if (d.draft && !updDraftLoaded) { updDraftLoaded = true; $('updSubject').value = d.draft.subject || ''; $('updIntro').value = d.draft.intro || ''; $('updClosing').value = d.draft.closing || ''; if (d.draft.audience) $('updAudience').value = d.draft.audience; $('updNotes').querySelectorAll('input').forEach(i => { i.checked = (d.draft.noteIds || []).includes(i.value); }); $('updSub').textContent += ' · draft loaded (saved ' + new Date(d.draft.savedAt).toLocaleString() + ')'; } if (d.running) setTimeout(loadUpdates, 4000); } catch (e) { $('updSub').textContent = e.message; } } let updDraftLoaded = false; - const updInput = () => ({ subject: $('updSubject').value, intro: $('updIntro').value, noteIds: [...$('updNotes').querySelectorAll('input:checked')].map(i => i.value), audience: $('updAudience').value }); + const updInput = () => ({ subject: $('updSubject').value, intro: $('updIntro').value, closing: $('updClosing').value, noteIds: [...$('updNotes').querySelectorAll('input:checked')].map(i => i.value), audience: $('updAudience').value }); if ($('updCard')) { $('updSaveDraft').addEventListener('click', busy($('updSaveDraft'), async () => { await api('/api/admin/updates/draft', updInput()); IAP.status('Draft saved.', 'ok'); })); $('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; })); diff --git a/server.js b/server.js index 730bc3e..ef9fec4 100644 --- a/server.js +++ b/server.js @@ -1342,12 +1342,12 @@ const server = http.createServer(async (req, res) => { if (p === '/api/admin/updates/preview' && req.method === 'POST') { if (!isAdmin(req)) return json(res, 401, { error: 'auth' }); const b = await readBody(req); - return json(res, 200, updates.compose({ subject: b.subject, intro: b.intro, noteIds: (b.noteIds || []).map(String) }, { email: ADMIN_EMAIL, username: 'you' })); + return json(res, 200, updates.compose({ subject: b.subject, intro: b.intro, closing: b.closing, noteIds: (b.noteIds || []).map(String) }, { email: ADMIN_EMAIL, username: 'you' })); } if (p === '/api/admin/updates/send' && req.method === 'POST') { if (!isAdmin(req)) return json(res, 401, { error: 'auth' }); const b = await readBody(req); - const r = await updates.send({ subject: b.subject, intro: b.intro, noteIds: (b.noteIds || []).map(String), audience: b.audience }, { test: !!b.test }); + const r = await updates.send({ subject: b.subject, intro: b.intro, closing: b.closing, noteIds: (b.noteIds || []).map(String), audience: b.audience }, { test: !!b.test }); return json(res, r.error ? 400 : 200, r); } if (p === '/api/admin/releases' && req.method === 'GET') { diff --git a/updates.js b/updates.js index 1608e52..17c9f54 100644 --- a/updates.js +++ b/updates.js @@ -33,12 +33,13 @@ async function recipients(kind) { } async function counts() { const o = {}; for (const k of Object.keys(AUDIENCES)) o[k] = (await recipients(k)).length; return o; } -function compose({ subject, intro, noteIds }, acct) { +function compose({ subject, intro, noteIds, closing }, acct) { const notes = R.releases.notes().filter(n => noteIds.includes(n.id)); const name = acct && acct.username ? '@' + acct.username : 'there'; const parts = ['Hi ' + name + ',']; if (intro && intro.trim()) parts.push(intro.trim()); for (const n of notes) parts.push(n.title.toUpperCase() + (n.date ? ' (' + n.date + ')' : '') + '\n' + String(n.body || '').trim()); + if (closing && closing.trim()) parts.push(closing.trim()); // sign-off AFTER the notes (Marty, 2026-09-14) parts.push('Every release note and what is being built next: ' + SITE + '/whats-new'); parts.push('Sign in: ' + SITE + '/my'); parts.push('InstantAdPay\nAdvertise and earn instantly. Locked in code, not promises.\nNo income is guaranteed; results depend on your effort. Cryptocurrency carries risk.'); @@ -82,7 +83,7 @@ async function send(input, opts) { // one saved draft (subject, intro, noteIds, audience): the admin card loads it, Save draft writes it const DRAFT = () => path.join(R.dataDir, 'updates-draft.json'); function draft() { try { return JSON.parse(fs.readFileSync(DRAFT(), 'utf8')); } catch (e) { return null; } } -function saveDraft(d) { const v = { subject: String(d.subject || '').slice(0, 150), intro: String(d.intro || '').slice(0, 8000), noteIds: (d.noteIds || []).map(String).slice(0, 40), audience: AUDIENCES[d.audience] ? d.audience : 'optin', savedAt: Date.now() }; try { fs.writeFileSync(DRAFT(), JSON.stringify(v)); } catch (e) {} return v; } +function saveDraft(d) { const v = { subject: String(d.subject || '').slice(0, 150), intro: String(d.intro || '').slice(0, 8000), closing: String(d.closing || '').slice(0, 2000), noteIds: (d.noteIds || []).map(String).slice(0, 40), audience: AUDIENCES[d.audience] ? d.audience : 'optin', savedAt: Date.now() }; try { fs.writeFileSync(DRAFT(), JSON.stringify(v)); } catch (e) {} return v; } function status() { const l = log(); if (running) { const k = l.sends.find(x => x.id === running.id); if (k) Object.assign(k, running); } return { sends: l.sends.slice(0, 12), running: !!running }; } function lastSentAt() { const l = log(); const d = l.sends.find(x => x.status === 'done'); return d ? d.ts : 0; } module.exports = { init, AUDIENCES, counts, compose, send, status, lastSentAt, draft, saveDraft };