From b3523e9dc4802bf652c6faf4fe0835a22d9f26ae Mon Sep 17 00:00:00 2001 From: martbost Date: Mon, 14 Sep 2026 04:54:43 -0500 Subject: [PATCH] Release notes + roadmap: /whats-new page, Overview What's new card, Admin > Releases editor Co-Authored-By: Claude Fable 5.1 --- blog.js | 2 +- chatbot.js | 1 + public/admin.html | 39 ++++++++++++++++++++++- public/assets/admin.js | 20 ++++++++++-- public/assets/common.js | 2 +- public/assets/my.js | 17 ++++++++++ public/my.html | 4 ++- qa/walk.mjs | 2 +- releases.js | 70 +++++++++++++++++++++++++++++++++++++++++ server.js | 23 ++++++++++++-- 10 files changed, 171 insertions(+), 9 deletions(-) create mode 100644 releases.js diff --git a/blog.js b/blog.js index 168b05e..a11ee2c 100644 --- a/blog.js +++ b/blog.js @@ -152,7 +152,7 @@ function rss(posts) { return 'InstantAdPay blog' + SITE + '/blogCoaching and teaching articles from Marty Bostick.' + items + ''; } 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 => '' + SITE + p + 'weekly').join('') + posts.map(p => '' + SITE + '/blog/' + p.slug + '' + new Date(p.updated).toISOString().slice(0, 10) + 'monthly').join(''); return '' + u + ''; diff --git a/chatbot.js b/chatbot.js index 400f95a..b9366c7 100644 --- a/chatbot.js +++ b/chatbot.js @@ -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//, 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. diff --git a/public/admin.html b/public/admin.html index 12f5336..51888f3 100644 --- a/public/admin.html +++ b/public/admin.html @@ -99,6 +99,7 @@ + @@ -359,6 +360,42 @@ +

Earning levels

…

+

Have a promo code?

Codes from partner sites add free ad credits to your account. One use per code.

@@ -915,7 +917,7 @@ - + diff --git a/qa/walk.mjs b/qa/walk.mjs index 022e49d..d0deaef 100644 --- a/qa/walk.mjs +++ b/qa/walk.mjs @@ -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); diff --git a/releases.js b/releases.js new file mode 100644 index 0000000..0a1d38f --- /dev/null +++ b/releases.js @@ -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 += '

' + esc(para.join(' ')) + '

'; para = []; } }; + for (const l of lines) { + if (/^\s*-\s+/.test(l)) { flush(); if (!list) { out += '
    '; list = true; } out += '
  • ' + esc(l.replace(/^\s*-\s+/, '')) + '
  • '; continue; } + if (list) { out += '
'; list = false; } + if (!l.trim()) { flush(); continue; } + para.push(l.trim()); + } + if (list) out += ''; 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 => '' + t + ''; +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 = 'What\'s new | InstantAdPay' + + '' + + '' + + '
' + + '

Release notes and roadmap

What\'s new, and what\'s next.

' + esc(desc) + '

'; + h += '

On the roadmap

'; + h += v.roadmap.length ? '
' + v.roadmap.map(r => '
' + r.status + (r.eta ? ' · ' + esc(r.eta) : '') + '
' + esc(r.title) + '' + (r.note ? '' + esc(r.note) + '' : '') + '
').join('') + '
' : '

Nothing queued right now.

'; + h += '

Release notes

'; + h += v.notes.length ? v.notes.map(n => '

' + esc(n.title) + n.tags.map(tagChip).join('') + '

' + fmt(n.date) + '

' + bodyHtml(n.body) + '
').join('') : '

No notes yet.

'; + h += '

Suggestions and bug reports: the chat bubble on any page, or the InstantAdPay Telegram group.

'; + h += '
'; + return h; +} +module.exports = { init, notes, roadmap, saveNote, saveRoadmap, remove, publicView, renderPage, bodyHtml, TAGS, STATUSES }; diff --git a/server.js b/server.js index 79aa1da..66f4d18 100644 --- a/server.js +++ b/server.js @@ -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()); }