Release notes + roadmap: /whats-new page, Overview What's new card, Admin > Releases editor

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-09-14 04:54:43 -05:00
parent f7b911bab6
commit b3523e9dc4
10 changed files with 171 additions and 9 deletions
+38 -1
View File
@@ -99,6 +99,7 @@
<button data-pane="reports" type="button"><svg viewBox="0 0 24 24"><path d="M12 3l9 16H3z"/><path d="M12 10v4M12 17v.5"/></svg>Reports<span class="pill" id="repBadge" hidden></span></button>
<button data-pane="traffic" type="button"><svg viewBox="0 0 24 24"><path d="M3 17l6-6 4 4 8-8"/><path d="M14 7h7v7"/></svg>Traffic</button>
<button data-pane="blog" type="button"><svg viewBox="0 0 24 24"><path d="M4 4h12l4 4v12H4z"/><path d="M8 12h8M8 16h8M8 8h4"/></svg>Blog</button>
<button data-pane="releases" type="button"><svg viewBox="0 0 24 24"><path d="M4 6h16M4 12h10M4 18h7"/><circle cx="18" cy="17" r="3"/></svg>Releases</button>
<button data-pane="pnl" type="button"><svg viewBox="0 0 24 24"><path d="M4 19V5M4 19h16"/><path d="M8 15l3-4 3 2 5-6"/></svg>P&amp;L</button>
<button data-pane="settings" type="button"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z"/></svg>Settings</button>
</nav>
@@ -359,6 +360,42 @@
</div>
</div>
</div>
<div class="pane" id="pane-releases" hidden>
<div class="grid c2">
<div class="card">
<div class="card-head"><h3>Release note</h3><span class="sub">what shipped; shows on /whats-new and every Overview</span></div>
<div style="display:grid;gap:8px">
<input type="hidden" id="rnId">
<label class="small">Title<input id="rnTitle" type="text" maxlength="120" placeholder="Achievement badges post to Telegram"></label>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px">
<label class="small">Date<input id="rnDate" type="date"></label>
<label class="small">Tags (new, improved, fixed)<input id="rnTags" type="text" placeholder="new, improved"></label>
</div>
<label class="small">Details (plain text; start a line with "- " for a bullet)<textarea id="rnBody" rows="6" style="width:100%"></textarea></label>
<div style="display:flex;gap:8px"><button type="button" class="btn small" id="rnSave">Save note</button><button type="button" class="btn small sec" id="rnClear">Clear</button></div>
</div>
</div>
<div class="card">
<div class="card-head"><h3>Roadmap item</h3><span class="sub">what is coming; "done" hides it from the public list</span></div>
<div style="display:grid;gap:8px">
<input type="hidden" id="rmId">
<label class="small">Title<input id="rmTitle" type="text" maxlength="120" placeholder="Second daily ad set"></label>
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:8px">
<label class="small">Status<select id="rmStatus"><option value="planned">planned</option><option value="building">building</option><option value="done">done</option></select></label>
<label class="small">ETA (free text)<input id="rmEta" type="text" placeholder="this week"></label>
<label class="small">Order<input id="rmOrder" type="number" min="1" placeholder="1"></label>
</div>
<label class="small">One-line note<input id="rmNote" type="text" maxlength="600" placeholder="why it matters"></label>
<div style="display:flex;gap:8px"><button type="button" class="btn small" id="rmSave">Save item</button><button type="button" class="btn small sec" id="rmClear">Clear</button></div>
</div>
</div>
</div>
<div class="grid c2">
<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>
<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>
<div class="card">
<div class="card-head"><h3>Profit and loss</h3><span class="sub">read from the chain index; periods are by block (about 43,200 Polygon blocks a day)</span></div>
@@ -424,6 +461,6 @@
</div>
<script src="/assets/common.js?v=20260913a"></script>
<script src="/assets/admin.js?v=20260913d"></script>
<script src="/assets/admin.js?v=20260914a"></script>
</body>
</html>
+18 -2
View File
@@ -44,8 +44,8 @@
});
// ── panes ──
const TITLES = { overview: 'Overview', house: 'House ads', campaigns: 'All campaigns', members: 'Members', reports: 'Reports', traffic: 'Traffic', blog: 'Blog', pnl: 'Profit and loss', settings: 'Settings' };
const loaders = { overview: loadOverview, house: loadHouse, campaigns: loadCampaigns, members: loadMembers, reports: loadReports, traffic: loadTraffic, blog: loadBlog, pnl: loadPnl, settings: loadSettings };
const TITLES = { overview: 'Overview', house: 'House ads', campaigns: 'All campaigns', members: 'Members', reports: 'Reports', traffic: 'Traffic', blog: 'Blog', releases: 'Releases and roadmap', pnl: 'Profit and loss', settings: 'Settings' };
const loaders = { overview: loadOverview, house: loadHouse, campaigns: loadCampaigns, members: loadMembers, reports: loadReports, traffic: loadTraffic, blog: loadBlog, releases: loadReleases, pnl: loadPnl, settings: loadSettings };
function setPane(name) {
if (!TITLES[name]) name = 'overview';
document.querySelectorAll('.pane').forEach(p => { p.hidden = p.id !== 'pane-' + name; });
@@ -425,6 +425,22 @@
const c = inp.parentElement.querySelector('.tfilter-count'); if (c) c.textContent = q ? shown + ' shown' : '';
});
// ── release notes + roadmap (Marty, 2026-09-14) ──
async function loadReleases() {
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>');
$('rmTable').innerHTML = '<tr><th>Status</th><th>Title</th><th>ETA</th><th>#</th><th></th></tr>' + (d.roadmap.length ? d.roadmap.map(r => '<tr><td class="small">' + esc(r.status) + '</td><td><b>' + esc(r.title) + '</b>' + (r.note ? '<br><span class="muted small">' + esc(r.note) + '</span>' : '') + '</td><td class="small">' + esc(r.eta || '') + '</td><td class="small">' + (r.order || '') + '</td><td class="act"><button type="button" class="btn small sec" data-rmedit="' + esc(r.id) + '">Edit</button> <button type="button" class="btn small sec" data-rmdel="' + esc(r.id) + '">Delete</button></td></tr>').join('') : '<tr><td colspan="5" class="muted">Nothing on the roadmap yet.</td></tr>');
$('rnTable').querySelectorAll('[data-rnedit]').forEach(b => b.addEventListener('click', () => { const n = d.notes.find(x => x.id === b.dataset.rnedit); if (!n) return; $('rnId').value = n.id; $('rnTitle').value = n.title; $('rnDate').value = n.date; $('rnTags').value = n.tags.join(', '); $('rnBody').value = n.body; $('rnTitle').focus(); }));
$('rmTable').querySelectorAll('[data-rmedit]').forEach(b => b.addEventListener('click', () => { const r = d.roadmap.find(x => x.id === b.dataset.rmedit); if (!r) return; $('rmId').value = r.id; $('rmTitle').value = r.title; $('rmStatus').value = r.status; $('rmEta').value = r.eta || ''; $('rmOrder').value = r.order || ''; $('rmNote').value = r.note || ''; $('rmTitle').focus(); }));
$('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(); }));
}
$('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(); }));
$('rmSave').addEventListener('click', busy($('rmSave'), async () => { await api('/api/admin/releases', { kind: 'roadmap', id: $('rmId').value || null, title: $('rmTitle').value, status: $('rmStatus').value, eta: $('rmEta').value, order: $('rmOrder').value, note: $('rmNote').value }); IAP.status('Roadmap item saved.', 'ok'); $('rmClear').click(); loadReleases(); }));
// ── reports + burns ──
// ── profit and loss ──
let pnlDays = 30;
+1 -1
View File
@@ -53,7 +53,7 @@ window.IAP = (function () {
f.style.cssText = 'border-top:1px solid var(--line);margin-top:48px;padding:26px 22px;text-align:center;color:var(--muted);font-size:13px';
f.innerHTML = '<div>© ' + new Date().getFullYear() + ' InstantAdPay</div>'
+ '<div style="margin-top:8px;display:flex;gap:16px;justify-content:center;flex-wrap:wrap">'
+ '<a href="/">How it works</a><a href="/ledger">Live ledger</a><a href="/contract">The contract</a><a href="/blog">Blog</a>'
+ '<a href="/">How it works</a><a href="/ledger">Live ledger</a><a href="/contract">The contract</a><a href="/blog">Blog</a><a href="/whats-new">What\'s new</a>'
+ '<a href="/terms">Terms</a><a href="/privacy">Privacy</a><a href="/disclaimer">Disclaimer</a></div>';
document.body.appendChild(f);
}
+17
View File
@@ -441,8 +441,25 @@
el.innerHTML = '<div class="lt-top"><span class="lt-you">YOU</span></div><div class="lt-stem"></div>' + rowFor(1) + rowFor(2) + rowFor(3);
} catch (e) {}
}
// "What's new" card: latest notes + what is being built; a dot marks notes newer than the member's last look (Marty, 2026-09-14)
let newsLoaded = false;
async function loadNews() {
if (newsLoaded || !$('newsCard')) return; newsLoaded = true;
try {
const r = await (await fetch('/api/releases')).json();
if (!r.notes.length && !r.roadmap.length) return;
let seen = ''; try { seen = localStorage.getItem('iap.news.seen') || ''; } catch (e) {}
const esc = t => String(t == null ? '' : t).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
const notes = r.notes.slice(0, 3).map(n => '<p style="margin:0 0 8px">' + (n.date > seen ? '<span style="color:var(--mint)">●</span> ' : '') + '<b>' + esc(n.title) + '</b> <span class="muted">' + esc(n.date) + '</span></p>').join('');
const rm = r.roadmap.filter(x => x.status === 'building').slice(0, 2).map(x => '<p class="muted" style="margin:0 0 6px">Building: ' + esc(x.title) + (x.eta ? ' (' + esc(x.eta) + ')' : '') + '</p>').join('');
$('newsList').innerHTML = notes + rm + '<p style="margin:6px 0 0"><a href="/whats-new" target="_blank" rel="noopener" style="color:var(--mint)">All release notes and the roadmap →</a></p>';
$('newsCard').hidden = false;
$('newsCard').addEventListener('click', () => { try { localStorage.setItem('iap.news.seen', r.latest || ''); } catch (e) {} $('newsList').querySelectorAll('span[style*="mint"]').forEach(s => { if (s.textContent === '●') s.remove(); }); }, { once: true });
} catch (e) {}
}
async function loadDashboard() {
try {
loadNews();
const d = await (await fetch('/api/my/dashboard')).json();
if (d.error) return;
chatSync(d);
+3 -1
View File
@@ -258,6 +258,8 @@
</div>
<div class="card"><h3>Earning levels</h3>
<p id="qualLine" class="muted small">…</p></div>
<div class="card" id="newsCard" hidden><div class="card-head"><h3>What's new</h3><span class="sub"><a href="/whats-new" target="_blank" rel="noopener" style="color:var(--mint);text-decoration:none">release notes and roadmap</a></span></div>
<div id="newsList" class="small"></div></div>
<div class="card" id="promoCard"><h3>Have a promo code?</h3>
<p class="muted small" style="margin:6px 0 10px">Codes from partner sites add free ad credits to your account. One use per code.</p>
<div style="display:flex;gap:8px;flex-wrap:wrap"><input id="promoCode" type="text" placeholder="CODE" autocomplete="off" style="flex:1;min-width:140px;text-transform:uppercase"><button type="button" class="btn small" id="promoApply">Apply</button></div>
@@ -915,7 +917,7 @@
<script src="/assets/common.js?v=20260913a"></script>
<script src="/assets/wallet.js?v=20260911a"></script>
<script src="/assets/promo.js?v=20260911a"></script>
<script src="/assets/my.js?v=20260913j"></script>
<script src="/assets/my.js?v=20260914a"></script>
<script src="/assets/chat.js?v=20260907l"></script>
</body>
</html>