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:
@@ -152,7 +152,7 @@ function rss(posts) {
|
||||
return '<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"><channel><title>InstantAdPay blog</title><link>' + SITE + '/blog</link><description>Coaching and teaching articles from Marty Bostick.</description>' + items + '</channel></rss>';
|
||||
}
|
||||
function sitemap(posts) {
|
||||
const pages = ['/', '/blog', '/ledger', '/contract', '/plays', '/wallets', '/earning', '/partners'];
|
||||
const pages = ['/', '/blog', '/whats-new', '/ledger', '/contract', '/plays', '/wallets', '/earning', '/partners'];
|
||||
const u = pages.map(p => '<url><loc>' + SITE + p + '</loc><changefreq>weekly</changefreq></url>').join('')
|
||||
+ posts.map(p => '<url><loc>' + SITE + '/blog/' + p.slug + '</loc><lastmod>' + new Date(p.updated).toISOString().slice(0, 10) + '</lastmod><changefreq>monthly</changefreq></url>').join('');
|
||||
return '<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' + u + '</urlset>';
|
||||
|
||||
@@ -82,6 +82,7 @@ FACTS:
|
||||
- DAILY CLAIM STREAK: finishing the daily ad set and claiming pays 5 credits on day 1, 7 on day 2, 10 from day 3, and 25 on every 7th consecutive day; miss a day and it restarts. After the set, verified visits (up to 20 a day, 1 credit each) keep earning, and the credits are meant to be spent on a campaign.
|
||||
- BLOG (public, instantadpay.com/blog): Marty's coaching and teaching articles on building a line, advertising that pays, and daily habits; each article has its own page and can be shared; RSS at /blog/feed.xml. Members who want to write their own articles: not offered today.
|
||||
- ACHIEVEMENT BADGES ON TELEGRAM (2026-09-13): when a member unlocks Spark/Surge/Circuit/Nexus, their personalised badge image (username on the ribbon) is posted automatically to the team's Telegram payments topic and the main group, once per badge; members cannot trigger posts themselves (the 'Post to Telegram' button is admin-only); 'Share' opens a picker (X, Facebook, Telegram, WhatsApp, LinkedIn, Massifly (copies the post and opens the feed composer), the phone's own share menu, copy link, save image) that shares the member's public badge page instantadpay.com/b/<username>/<badge>, which shows the badge and their join link.
|
||||
- WHAT'S NEW / ROADMAP (2026-09-14): instantadpay.com/whats-new lists release notes (what shipped, dated, tagged new/improved/fixed) and the roadmap (planned / building, with rough ETAs). The Overview has a "What's new" card with the latest three notes and what is being built; a dot marks notes since the member's last look. Written by the admin in Admin > Releases.
|
||||
- HOLDING TANK ALERTS: when new members land in the tank, a note at the top of every member's Overview names them (usernames) and a post goes to the team's Telegram payments topic; adopt from My line > Holding tank (your own $20 package required).
|
||||
- LEGACY WELCOME (former Faucet Wave / Tier One Ads members): they join through instantadpay.com/from/faucetwave or instantadpay.com/from/tieroneads and, if their email is on the legacy list, welcome-back credits are added automatically at signup (former advertisers 500, former earners 150; once per person; credits, not POL). They land in the holding tank like any member who joins without a sponsor.
|
||||
- PROMO CODES: partner site owners get a reusable code; a member redeems it on a join link (?promo=CODE) or in the Overview box "Have a promo code?" and receives free ad credits (amount set per code by the admin, one use per account; codes can cap uses or expire). Credits, not POL.
|
||||
|
||||
+38
-1
@@ -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&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
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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
@@ -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>
|
||||
|
||||
+1
-1
@@ -121,7 +121,7 @@ if (MODE === 'member' || MODE === 'all') {
|
||||
// admin
|
||||
await page.goto(LOCAL + '/admin', { waitUntil: 'networkidle' });
|
||||
await page.fill('#adEmail', process.env.ADMIN_EMAIL || 'martybostick@gmail.com'); await page.click('#adSend'); await page.waitForSelector('#adVerify:not([hidden])'); await page.click('#adVerify'); await page.waitForTimeout(1200);
|
||||
for (const pn of ['overview', 'house', 'campaigns', 'members', 'reports', 'traffic', 'blog', 'pnl', 'settings']) {
|
||||
for (const pn of ['overview', 'house', 'campaigns', 'members', 'reports', 'traffic', 'blog', 'releases', 'pnl', 'settings']) {
|
||||
const label = L + 'admin#' + pn;
|
||||
await page.click('.bo-menu [data-pane="' + pn + '"]'); await page.waitForTimeout(1200);
|
||||
const vis = await page.evaluate(id => { const el = document.getElementById('pane-' + id); return el && !el.hidden && el.offsetHeight > 40; }, pn);
|
||||
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
// Release notes + roadmap (Marty, 2026-09-14): what shipped and what is coming, written in Admin > Releases,
|
||||
// shown on the public /whats-new page and in a "What's new" card on every member's Overview.
|
||||
// Storage: DATA_DIR/releases.json { notes: [{id, date, title, body, tags[]}], roadmap: [{id, title, note, status, eta}] }
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
let FILE = null;
|
||||
const SITE = 'https://instantadpay.com';
|
||||
const TAGS = ['new', 'improved', 'fixed'];
|
||||
const STATUSES = ['planned', 'building', 'done'];
|
||||
const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
||||
|
||||
function init(opts) { FILE = path.join(opts.dataDir, 'releases.json'); }
|
||||
function load() { try { return JSON.parse(fs.readFileSync(FILE, 'utf8')); } catch (e) { return { notes: [], roadmap: [] }; } }
|
||||
function save(d) { fs.writeFileSync(FILE, JSON.stringify(d, null, 1)); }
|
||||
const newId = () => Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
|
||||
|
||||
// body text: plain lines; a line starting with "- " becomes a bullet; blank line = paragraph break
|
||||
function bodyHtml(text) {
|
||||
const lines = String(text || '').split(/\r?\n/);
|
||||
let out = '', list = false, para = [];
|
||||
const flush = () => { if (para.length) { out += '<p>' + esc(para.join(' ')) + '</p>'; para = []; } };
|
||||
for (const l of lines) {
|
||||
if (/^\s*-\s+/.test(l)) { flush(); if (!list) { out += '<ul>'; list = true; } out += '<li>' + esc(l.replace(/^\s*-\s+/, '')) + '</li>'; continue; }
|
||||
if (list) { out += '</ul>'; list = false; }
|
||||
if (!l.trim()) { flush(); continue; }
|
||||
para.push(l.trim());
|
||||
}
|
||||
if (list) out += '</ul>'; flush();
|
||||
return out;
|
||||
}
|
||||
|
||||
function notes() { return load().notes.slice().sort((a, b) => (b.date || '').localeCompare(a.date || '') || (b.ts || 0) - (a.ts || 0)); }
|
||||
function roadmap() { const order = { building: 0, planned: 1, done: 2 }; return load().roadmap.slice().sort((a, b) => (order[a.status] - order[b.status]) || (a.order || 0) - (b.order || 0)); }
|
||||
function saveNote(input) {
|
||||
const d = load(); const title = String(input.title || '').trim().slice(0, 120); if (!title) return { error: 'Give the note a title.' };
|
||||
const date = /^\d{4}-\d{2}-\d{2}$/.test(String(input.date || '')) ? input.date : new Date().toISOString().slice(0, 10);
|
||||
const tags = String(input.tags || '').split(',').map(t => t.trim().toLowerCase()).filter(t => TAGS.includes(t));
|
||||
const n = { id: input.id && d.notes.find(x => x.id === input.id) ? input.id : newId(), date, title, body: String(input.body || '').slice(0, 4000), tags: tags.length ? tags : ['new'], ts: Date.now() };
|
||||
const i = d.notes.findIndex(x => x.id === n.id); if (i >= 0) d.notes[i] = Object.assign({}, d.notes[i], n); else d.notes.push(n);
|
||||
save(d); return { ok: true, note: n };
|
||||
}
|
||||
function saveRoadmap(input) {
|
||||
const d = load(); const title = String(input.title || '').trim().slice(0, 120); if (!title) return { error: 'Give the item a title.' };
|
||||
const status = STATUSES.includes(input.status) ? input.status : 'planned';
|
||||
const r = { id: input.id && d.roadmap.find(x => x.id === input.id) ? input.id : newId(), title, note: String(input.note || '').slice(0, 600), status, eta: String(input.eta || '').slice(0, 40), order: Number(input.order) || d.roadmap.length + 1, ts: Date.now() };
|
||||
const i = d.roadmap.findIndex(x => x.id === r.id); if (i >= 0) d.roadmap[i] = Object.assign({}, d.roadmap[i], r); else d.roadmap.push(r);
|
||||
save(d); return { ok: true, item: r };
|
||||
}
|
||||
function remove(kind, id) { const d = load(); const k = kind === 'roadmap' ? 'roadmap' : 'notes'; d[k] = d[k].filter(x => x.id !== id); save(d); return { ok: true }; }
|
||||
function publicView() { return { notes: notes().slice(0, 30), roadmap: roadmap().filter(r => r.status !== 'done').slice(0, 12), latest: (notes()[0] || {}).date || null }; }
|
||||
|
||||
function fmt(d) { try { return new Date(d + 'T12:00:00Z').toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }); } catch (e) { return d; } }
|
||||
const tagChip = t => '<span class="tagc tag-' + t + '">' + t + '</span>';
|
||||
function renderPage() {
|
||||
const v = publicView();
|
||||
const desc = 'What changed on InstantAdPay and what is coming next: release notes and the roadmap, updated as things ship.';
|
||||
let h = '<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>What\'s new | InstantAdPay</title>'
|
||||
+ '<meta name="description" content="' + esc(desc) + '"><link rel="canonical" href="' + SITE + '/whats-new"><meta property="og:title" content="What\'s new on InstantAdPay"><meta property="og:description" content="' + esc(desc) + '"><meta property="og:image" content="' + SITE + '/banners/iap-hero-1200x630.png"><meta name="twitter:card" content="summary_large_image">'
|
||||
+ '<link rel="icon" type="image/png" href="/logo-icon.png"><link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Sora:wght@600;700;800&display=swap"><link rel="stylesheet" href="/assets/site.css?v=20260913a">'
|
||||
+ '<style>.wn{max-width:820px}.wn h1{margin:10px 0 6px}.wn .lead{margin:0 0 26px}.note{border-left:3px solid var(--mint);padding:4px 0 4px 18px;margin:0 0 26px}.note h2{font-size:21px;margin:0 0 4px}.note .when{color:var(--muted);font-size:13px;margin:0 0 8px}.note p,.note li{max-width:68ch}.note ul{margin:6px 0 0 20px}.tagc{display:inline-block;font-size:11px;letter-spacing:.08em;text-transform:uppercase;border-radius:999px;padding:2px 9px;margin-left:8px;vertical-align:middle;border:1px solid var(--line);color:var(--muted)}.tag-new{color:var(--mint);border-color:rgba(67,232,195,.5)}.tag-improved{color:#8fd8f0;border-color:rgba(143,216,240,.5)}.tag-fixed{color:#ffd15c;border-color:rgba(255,209,92,.5)}.rm{display:grid;gap:10px;margin:0 0 30px}.rmi{display:flex;gap:14px;align-items:flex-start;background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:12px 16px}.rmi b{display:block}.rmi span{color:var(--muted);font-size:14px}.st{flex:0 0 auto;font-size:11px;letter-spacing:.08em;text-transform:uppercase;border-radius:999px;padding:3px 10px;border:1px solid var(--line);color:var(--muted);margin-top:3px}.st-building{color:var(--mint);border-color:rgba(67,232,195,.5)}.st-planned{color:#8fd8f0;border-color:rgba(143,216,240,.5)}</style></head><body><div class="wrap wn">'
|
||||
+ '<section class="hero" style="padding:56px 0 8px"><p class="eyebrow">Release notes and roadmap</p><h1>What\'s new, <em>and what\'s next</em>.</h1><p class="lead">' + esc(desc) + '</p></section>';
|
||||
h += '<h2 style="font-size:24px;margin:0 0 12px">On the roadmap</h2>';
|
||||
h += v.roadmap.length ? '<div class="rm">' + v.roadmap.map(r => '<div class="rmi"><span class="st st-' + r.status + '">' + r.status + (r.eta ? ' · ' + esc(r.eta) : '') + '</span><div><b>' + esc(r.title) + '</b>' + (r.note ? '<span>' + esc(r.note) + '</span>' : '') + '</div></div>').join('') + '</div>' : '<p class="muted">Nothing queued right now.</p>';
|
||||
h += '<h2 style="font-size:24px;margin:10px 0 16px">Release notes</h2>';
|
||||
h += v.notes.length ? v.notes.map(n => '<article class="note" id="' + esc(n.id) + '"><h2>' + esc(n.title) + n.tags.map(tagChip).join('') + '</h2><p class="when">' + fmt(n.date) + '</p>' + bodyHtml(n.body) + '</article>').join('') : '<p class="muted">No notes yet.</p>';
|
||||
h += '<p class="muted small" style="margin-top:30px">Suggestions and bug reports: the chat bubble on any page, or the InstantAdPay Telegram group.</p>';
|
||||
h += '</div><script src="/assets/common.js?v=20260913a"></script><script src="/assets/blog-page.js?v=20260912a"></script></body></html>';
|
||||
return h;
|
||||
}
|
||||
module.exports = { init, notes, roadmap, saveNote, saveRoadmap, remove, publicView, renderPage, bodyHtml, TAGS, STATUSES };
|
||||
@@ -30,8 +30,9 @@ const traffic = require('./traffic'); // public page views by referring domain (
|
||||
const promos = require('./promos'); // partner promo codes -> free ad credits (link ?promo=CODE or the dashboard box)
|
||||
const blog = require('./blog');
|
||||
const adminMember = require('./adminmember');
|
||||
const syndicate = require('./syndicate'); // blog -> Blotato -> X + Instagram on publish (Marty, 2026-09-13) // admin member card: search, drilldown, edits (Marty, 2026-09-13) // admin-written coaching articles, server-rendered public /blog with SEO metadata (Marty, 2026-09-12)
|
||||
const TRAFFIC_PAGES = new Set(['/', '/ledger', '/contract', '/shorts', '/plays', '/wallets', '/launch', '/partners', '/earning', '/blog']);
|
||||
const syndicate = require('./syndicate');
|
||||
const releases = require('./releases'); // release notes + roadmap: /whats-new, Overview card, Admin > Releases (Marty, 2026-09-14) // blog -> Blotato -> X + Instagram on publish (Marty, 2026-09-13) // admin member card: search, drilldown, edits (Marty, 2026-09-13) // admin-written coaching articles, server-rendered public /blog with SEO metadata (Marty, 2026-09-12)
|
||||
const TRAFFIC_PAGES = new Set(['/', '/ledger', '/contract', '/shorts', '/plays', '/wallets', '/launch', '/partners', '/earning', '/blog', '/whats-new']);
|
||||
let tankWaitCache = null; // dashboard: who is waiting for a sponsor (refreshed every minute)
|
||||
const geo = require('./geo'); // viewer country -> tier (DB-IP lite), for campaign targeting
|
||||
const burner = require('./burner'); // automatic on-chain credit burns (inert without ENGINE_KEY)
|
||||
@@ -365,6 +366,7 @@ async function boot() {
|
||||
adminMember.init({ accounts, ads, chain, tank, legacy, promos, messages, dataDir: DATA_DIR });
|
||||
loadOpenTokens();
|
||||
syndicate.init({ dataDir: DATA_DIR, publicDir: PUBLIC_DIR, uploadsDir: UPLOADS_DIR });
|
||||
releases.init({ dataDir: DATA_DIR });
|
||||
console.log('blog syndication:', syndicate.enabled() ? 'on (X + Instagram via Blotato)' : 'off (no blotato.key)');
|
||||
setInterval(() => tankNotifyTick().catch(e => console.error('tank notify', e.message)), 15 * 60 * 1000); // new tank arrivals -> Telegram
|
||||
geo.init({ dataDir: DATA_DIR }).catch(e => console.error('geo init', e.message));
|
||||
@@ -1211,6 +1213,22 @@ const server = http.createServer(async (req, res) => {
|
||||
console.log('admin removed account', e);
|
||||
return json(res, 200, { ok: true });
|
||||
}
|
||||
// -- release notes + roadmap (public read; admin write) (Marty, 2026-09-14)
|
||||
if (p === '/api/releases' && req.method === 'GET') return json(res, 200, releases.publicView());
|
||||
if (p === '/api/admin/releases' && req.method === 'GET') {
|
||||
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
|
||||
return json(res, 200, { notes: releases.notes(), roadmap: releases.roadmap(), tags: releases.TAGS, statuses: releases.STATUSES });
|
||||
}
|
||||
if (p === '/api/admin/releases' && req.method === 'POST') {
|
||||
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
|
||||
const b = await readBody(req);
|
||||
const r = b.kind === 'roadmap' ? releases.saveRoadmap(b) : releases.saveNote(b);
|
||||
return json(res, r.error ? 400 : 200, r);
|
||||
}
|
||||
if (p === '/api/admin/releases' && req.method === 'DELETE') {
|
||||
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
|
||||
return json(res, 200, releases.remove(u.searchParams.get('kind'), String(u.searchParams.get('id') || '')));
|
||||
}
|
||||
// -- admin: blog (list all incl. drafts, save/create, delete) (Marty, 2026-09-12)
|
||||
if (p === '/api/admin/blog' && req.method === 'GET') {
|
||||
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
|
||||
@@ -2396,6 +2414,7 @@ const server = http.createServer(async (req, res) => {
|
||||
res.writeHead(200, baseHeaders({ 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'public, max-age=300' }));
|
||||
return res.end(blog.renderIndex(posts, pg, tag));
|
||||
}
|
||||
if (p === '/whats-new') { res.writeHead(200, baseHeaders({ 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'public, max-age=120' })); return res.end(releases.renderPage()); }
|
||||
if (p === '/blog/feed.xml') { res.writeHead(200, baseHeaders({ 'Content-Type': 'application/rss+xml; charset=utf-8', 'Cache-Control': 'public, max-age=900' })); return res.end(blog.rss(await blog.listPublished())); }
|
||||
if (p === '/sitemap.xml') { res.writeHead(200, baseHeaders({ 'Content-Type': 'application/xml; charset=utf-8', 'Cache-Control': 'public, max-age=3600' })); return res.end(blog.sitemap(await blog.listPublished())); }
|
||||
if (p === '/robots.txt') { res.writeHead(200, baseHeaders({ 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'public, max-age=3600' })); return res.end(blog.robots()); }
|
||||
|
||||
Reference in New Issue
Block a user