Blog: admin-written coaching articles, server-rendered public /blog with SEO metadata, sitemap, RSS, robots

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-09-12 18:42:56 -05:00
parent 1a83f4f2ff
commit 49a98e7ee3
24 changed files with 375 additions and 23 deletions
+162
View File
@@ -0,0 +1,162 @@
// Public blog (Marty, 2026-09-12): coaching and teaching articles on instantadpay.com, written in
// Admin > Blog, server-rendered so crawlers see real HTML with real metadata. Storage: MySQL
// blog_posts, or DATA_DIR/blog.json. Public: /blog (paged index), /blog/<slug>, /blog/feed.xml,
// /sitemap.xml, /robots.txt. SEO per post: title, description, canonical, Open Graph, Twitter
// card, article dates, BlogPosting JSON-LD, breadcrumb JSON-LD, internal links, related posts.
const fs = require('fs');
const path = require('path');
const db = require('./db');
let DATA_DIR = null;
const SITE = 'https://instantadpay.com';
const AUTHOR = 'Marty Bostick';
const PER_PAGE = 10;
const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
const slugify = s => String(s || '').toLowerCase().replace(/['’]/g, '').replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 80);
const words = html => String(html || '').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
const readMinutes = html => Math.max(1, Math.round(words(html).split(' ').length / 220));
const fmtDate = ts => new Date(ts).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric', timeZone: 'America/Chicago' });
// article HTML from the admin editor: keep a small whitelist of tags, http(s) links and safe images
function sanitize(html) {
const src = String(html || '').replace(/ /g, ' ').replace(/<(script|style|iframe|object|embed|form)\b[\s\S]*?<\/\1\s*>/gi, '').replace(/<(script|style|iframe|object|embed|form|input)\b[^>]*>/gi, '').replace(/<!--[\s\S]*?-->/g, '').replace(/\son[a-z]+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, '');
const ALLOW = new Set(['p', 'br', 'hr', 'h2', 'h3', 'h4', 'ul', 'ol', 'li', 'b', 'strong', 'i', 'em', 'u', 'a', 'img', 'blockquote', 'pre', 'code', 'figure', 'figcaption', 'table', 'thead', 'tbody', 'tr', 'th', 'td']);
const safeSrc = s => /^(\/uploads\/[a-z0-9]{24}\.(png|jpg|webp|gif)|\/banners\/[a-z0-9._-]+\.(png|jpg|webp|gif)|https:\/\/[^\s"'<>]+)$/i.test(s);
return src.replace(/<\s*(\/?)\s*([a-zA-Z0-9]+)((?:[^>"']|"[^"]*"|'[^']*')*)>/g, (m, close, tag, attrs) => {
tag = tag.toLowerCase();
if (!ALLOW.has(tag)) return '';
if (close) return '</' + tag + '>';
if (tag === 'a') {
const hm = /href\s*=\s*(?:"([^"]*)"|'([^']*)')/i.exec(attrs || ''); const href = (hm && (hm[1] || hm[2])) || '';
if (!/^(https?:\/\/|\/)[^\s"'<>]*$/i.test(href)) return '';
const internal = href.startsWith('/') || href.startsWith(SITE);
return '<a href="' + href.replace(/"/g, '%22') + '"' + (internal ? '' : ' target="_blank" rel="noopener"') + '>';
}
if (tag === 'img') {
const sm = /src\s*=\s*(?:"([^"]*)"|'([^']*)')/i.exec(attrs || ''); const s = (sm && (sm[1] || sm[2])) || '';
const am = /alt\s*=\s*(?:"([^"]*)"|'([^']*)')/i.exec(attrs || ''); const alt = (am && (am[1] || am[2])) || '';
return safeSrc(s) ? '<img src="' + s.replace(/"/g, '%22') + '" alt="' + esc(alt) + '" loading="lazy">' : '';
}
if (tag === 'br' || tag === 'hr') return '<' + tag + '>';
return '<' + tag + '>';
});
}
// ---- storage ----
const J = {
db: { v: 1, posts: {} },
FILE() { return path.join(DATA_DIR, 'blog.json'); },
load() { try { this.db = Object.assign(this.db, JSON.parse(fs.readFileSync(this.FILE(), 'utf8'))); } catch (e) {} },
save() { try { fs.writeFileSync(this.FILE(), JSON.stringify(this.db)); } catch (e) {} },
async all() { return Object.values(this.db.posts); },
async get(slug) { return this.db.posts[slug] || null; },
async put(p) { this.db.posts[p.slug] = p; this.save(); return p; },
async remove(slug) { delete this.db.posts[slug]; this.save(); },
async bumpViews(slug) { const p = this.db.posts[slug]; if (p) { p.views = (p.views || 0) + 1; this.save(); } }
};
const D = {
async all() { return (await db.q('SELECT * FROM blog_posts ORDER BY COALESCE(published_at, created) DESC')).map(row); },
async get(slug) { const r = await db.q('SELECT * FROM blog_posts WHERE slug=?', [slug]); return r[0] ? row(r[0]) : null; },
async put(p) {
await db.q(`INSERT INTO blog_posts (slug,title,excerpt,body,cover,tags,status,author,created,updated,published_at,views) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)
ON DUPLICATE KEY UPDATE title=VALUES(title), excerpt=VALUES(excerpt), body=VALUES(body), cover=VALUES(cover), tags=VALUES(tags), status=VALUES(status), author=VALUES(author), updated=VALUES(updated), published_at=VALUES(published_at)`,
[p.slug, p.title, p.excerpt, p.body, p.cover || null, (p.tags || []).join(','), p.status, p.author, p.created, p.updated, p.publishedAt || null, p.views || 0]);
return this.get(p.slug);
},
async remove(slug) { await db.q('DELETE FROM blog_posts WHERE slug=?', [slug]); },
async bumpViews(slug) { await db.q('UPDATE blog_posts SET views=views+1 WHERE slug=?', [slug]); }
};
const row = r => ({ slug: r.slug, title: r.title, excerpt: r.excerpt || '', body: r.body || '', cover: r.cover || '', tags: r.tags ? String(r.tags).split(',').filter(Boolean) : [], status: r.status, author: r.author || AUTHOR, created: Number(r.created), updated: Number(r.updated), publishedAt: r.published_at ? Number(r.published_at) : null, views: Number(r.views) || 0 });
const impl = () => db.enabled() ? D : J;
function init(opts) { DATA_DIR = opts.dataDir; if (!db.enabled()) J.load(); }
async function listAll() { return (await impl().all()).sort((a, b) => (b.publishedAt || b.created) - (a.publishedAt || a.created)); }
async function listPublished() { return (await listAll()).filter(p => p.status === 'published' && (p.publishedAt || 0) <= Date.now()); }
async function get(slug) { return impl().get(slugify(slug)); }
async function save(input, existingSlug) {
const title = String(input.title || '').trim().slice(0, 140);
if (!title) return { error: 'Give the post a title.' };
let slug = slugify(input.slug || title); if (!slug) return { error: 'Slug needs letters or numbers.' };
const prev = existingSlug ? await impl().get(existingSlug) : null;
if (!prev && await impl().get(slug)) return { error: 'That slug is already used. Pick another.' };
if (prev && prev.slug !== slug) { if (await impl().get(slug)) return { error: 'That slug is already used.' }; await impl().remove(prev.slug); }
const now = Date.now();
const status = input.status === 'published' ? 'published' : 'draft';
const body = sanitize(input.body);
const excerpt = String(input.excerpt || '').trim().slice(0, 300) || words(body).slice(0, 200);
const post = { slug, title, excerpt, body, cover: /^(\/uploads\/|\/banners\/|https:\/\/)/.test(String(input.cover || '')) ? String(input.cover).slice(0, 300) : '', tags: String(input.tags || '').split(',').map(t => t.trim().toLowerCase()).filter(Boolean).slice(0, 8),
status, author: AUTHOR, created: prev ? prev.created : now, updated: now,
publishedAt: status === 'published' ? (prev && prev.publishedAt ? prev.publishedAt : (input.publishedAt ? Number(new Date(input.publishedAt)) || now : now)) : (prev ? prev.publishedAt : null), views: prev ? prev.views : 0 };
return { ok: true, post: await impl().put(post) };
}
async function remove(slug) { await impl().remove(slugify(slug)); return { ok: true }; }
async function bumpViews(slug) { try { await impl().bumpViews(slug); } catch (e) {} }
// ---- rendering ----
function head(o) {
const url = SITE + o.path;
const img = o.image ? (o.image.startsWith('http') ? o.image : SITE + o.image) : SITE + '/banners/iap-hero-1200x630.png';
return '<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">'
+ '<title>' + esc(o.title) + '</title><meta name="description" content="' + esc(o.desc) + '"><link rel="canonical" href="' + esc(url) + '">'
+ '<meta name="robots" content="index,follow,max-image-preview:large"><meta name="theme-color" content="#043b2f">'
+ '<meta property="og:type" content="' + (o.article ? 'article' : 'website') + '"><meta property="og:site_name" content="InstantAdPay"><meta property="og:title" content="' + esc(o.title) + '"><meta property="og:description" content="' + esc(o.desc) + '"><meta property="og:url" content="' + esc(url) + '"><meta property="og:image" content="' + esc(img) + '">'
+ (o.article ? '<meta property="article:published_time" content="' + new Date(o.article.publishedAt || o.article.created).toISOString() + '"><meta property="article:modified_time" content="' + new Date(o.article.updated).toISOString() + '"><meta property="article:author" content="' + esc(AUTHOR) + '">' + (o.article.tags || []).map(t => '<meta property="article:tag" content="' + esc(t) + '">').join('') : '')
+ '<meta name="twitter:card" content="summary_large_image"><meta name="twitter:title" content="' + esc(o.title) + '"><meta name="twitter:description" content="' + esc(o.desc) + '"><meta name="twitter:image" content="' + esc(img) + '">'
+ '<link rel="alternate" type="application/rss+xml" title="InstantAdPay blog" href="' + SITE + '/blog/feed.xml"><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=20260912b">'
+ '<style>.bl{max-width:760px}.bl h1{font-size:clamp(30px,4.6vw,44px);line-height:1.12;margin:10px 0 12px}.bl .meta{color:var(--muted);font-size:14px;margin:0 0 22px}.bl .cover{width:100%;border-radius:14px;border:1px solid var(--line);margin:0 0 26px;display:block}.bl article{font-size:17.5px;line-height:1.7}.bl article p{margin:0 0 18px;max-width:68ch}.bl article h2{font-size:26px;margin:36px 0 12px}.bl article h3{font-size:20px;margin:28px 0 10px}.bl article ul,.bl article ol{margin:0 0 18px 22px;max-width:66ch}.bl article li{margin:6px 0}.bl article blockquote{border-left:4px solid var(--mint);margin:0 0 18px;padding:8px 18px;color:var(--muted);font-style:italic}.bl article img{max-width:100%;border-radius:12px;border:1px solid var(--line)}.bl article a{color:var(--mint)}.bl article pre{background:rgba(4,8,7,.6);border:1px solid var(--line);border-radius:10px;padding:14px;overflow:auto;font-size:14px}.bl article table{border-collapse:collapse;width:100%;font-size:15px}.bl article th,.bl article td{border-bottom:1px solid var(--line);padding:8px 10px;text-align:left}.tags a{display:inline-block;font-size:12px;letter-spacing:.06em;text-transform:uppercase;color:var(--mint);border:1px solid rgba(67,232,195,.4);border-radius:999px;padding:3px 10px;margin:0 6px 6px 0;text-decoration:none}.post-card{display:block;background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:20px 22px;margin:0 0 14px;color:inherit;text-decoration:none}.post-card:hover{border-color:var(--mint)}.post-card h2{font-size:22px;margin:0 0 6px}.post-card p{margin:0;color:var(--muted);font-size:15px;max-width:70ch}.post-card .meta{margin:8px 0 0;font-size:13px}.author{display:flex;gap:14px;align-items:center;border:1px solid var(--line);border-radius:14px;padding:16px 18px;margin:36px 0 0;background:var(--panel)}.author img{width:56px;height:56px;border-radius:50%;object-fit:cover}.author b{display:block}.author span{color:var(--muted);font-size:14px}.share a{margin-right:14px;font-size:14px}.pager{display:flex;justify-content:space-between;margin:26px 0}.related{margin-top:40px}.related h3{margin-bottom:10px}</style>'
+ '</head><body><div class="wrap bl">';
}
function tail() {
return '</div><script src="/assets/common.js?v=20260912e"></script><script src="/assets/blog-page.js?v=20260912a"></script></body></html>';
}
const authorBox = () => '<div class="author"><img src="/logo-icon.png" alt="InstantAdPay"><div><b>' + AUTHOR + '</b><span>Founder of InstantAdPay and the Crypto Team Build Network. Twenty-plus years of internet marketing, and a habit of writing down what actually worked.</span></div></div>';
const cardHtml = p => '<a class="post-card" href="/blog/' + esc(p.slug) + '">' + (p.cover ? '<img src="' + esc(p.cover) + '" alt="" loading="lazy" style="width:100%;border-radius:10px;margin:0 0 12px">' : '') + '<h2>' + esc(p.title) + '</h2><p>' + esc(p.excerpt) + '</p><p class="meta">' + fmtDate(p.publishedAt || p.created) + ' · ' + readMinutes(p.body) + ' min read' + (p.tags.length ? ' · ' + p.tags.map(esc).join(', ') : '') + '</p></a>';
function renderIndex(posts, page, tag) {
const all = tag ? posts.filter(p => p.tags.includes(tag)) : posts;
const pages = Math.max(1, Math.ceil(all.length / PER_PAGE)); page = Math.min(Math.max(1, page || 1), pages);
const slice = all.slice((page - 1) * PER_PAGE, page * PER_PAGE);
const title = (tag ? esc(tag) + ' · ' : '') + 'Blog | InstantAdPay';
const desc = 'Coaching and teaching articles from Marty Bostick on building a line, advertising that pays, and doing the simple work every day.';
const p = tag ? '/blog/tag/' + encodeURIComponent(tag) : '/blog' + (page > 1 ? '/page/' + page : '');
let h = head({ title, desc, path: p });
h += '<section class="hero" style="padding:56px 0 8px"><p class="eyebrow">' + (tag ? 'Tag: ' + esc(tag) : 'The InstantAdPay blog') + '</p><h1>Notes on building a line, <em>one honest day at a time</em>.</h1><p class="lead">' + esc(desc) + '</p></section>';
h += slice.length ? slice.map(cardHtml).join('') : '<p class="muted">Nothing published yet. Check back soon.</p>';
if (pages > 1) h += '<div class="pager">' + (page > 1 ? '<a href="/blog' + (page - 1 > 1 ? '/page/' + (page - 1) : '') + '">&larr; Newer</a>' : '<span></span>') + (page < pages ? '<a href="/blog/page/' + (page + 1) + '">Older &rarr;</a>' : '<span></span>') + '</div>';
h += '<script type="application/ld+json">' + JSON.stringify({ '@context': 'https://schema.org', '@type': 'Blog', name: 'InstantAdPay blog', url: SITE + '/blog', publisher: { '@type': 'Organization', name: 'InstantAdPay', logo: SITE + '/logo.png' } }) + '</script>';
return h + tail();
}
function renderPost(p, related) {
const url = SITE + '/blog/' + p.slug;
let h = head({ title: p.title + ' | InstantAdPay', desc: p.excerpt, path: '/blog/' + p.slug, image: p.cover, article: p });
h += '<p class="eyebrow" style="margin-top:44px"><a href="/blog" style="color:var(--mint);text-decoration:none">Blog</a>' + (p.tags[0] ? ' · <a href="/blog/tag/' + encodeURIComponent(p.tags[0]) + '" style="color:var(--mint);text-decoration:none">' + esc(p.tags[0]) + '</a>' : '') + '</p>';
h += '<h1>' + esc(p.title) + '</h1><p class="meta">By ' + esc(AUTHOR) + ' · ' + fmtDate(p.publishedAt || p.created) + ' · ' + readMinutes(p.body) + ' min read</p>';
if (p.cover) h += '<img class="cover" src="' + esc(p.cover) + '" alt="' + esc(p.title) + '">';
h += '<article>' + p.body + '</article>';
if (p.tags.length) h += '<p class="tags" style="margin-top:22px">' + p.tags.map(t => '<a href="/blog/tag/' + encodeURIComponent(t) + '">' + esc(t) + '</a>').join('') + '</p>';
h += '<p class="share" style="margin-top:18px"><a href="https://x.com/intent/tweet?url=' + encodeURIComponent(url) + '&text=' + encodeURIComponent(p.title) + '" target="_blank" rel="noopener">Share on X</a><a href="https://www.facebook.com/sharer/sharer.php?u=' + encodeURIComponent(url) + '" target="_blank" rel="noopener">Share on Facebook</a><a href="https://t.me/share/url?url=' + encodeURIComponent(url) + '&text=' + encodeURIComponent(p.title) + '" target="_blank" rel="noopener">Telegram</a></p>';
h += authorBox();
if (related.length) h += '<div class="related"><h3>Keep reading</h3>' + related.map(cardHtml).join('') + '</div>';
h += '<p class="muted small" style="margin-top:34px">InstantAdPay sells advertising. Nothing here is investment advice, no income is guaranteed, and cryptocurrency involves risk of loss.</p>';
h += '<script type="application/ld+json">' + JSON.stringify({ '@context': 'https://schema.org', '@type': 'BlogPosting', headline: p.title, description: p.excerpt, image: p.cover ? (p.cover.startsWith('http') ? p.cover : SITE + p.cover) : SITE + '/banners/iap-hero-1200x630.png', datePublished: new Date(p.publishedAt || p.created).toISOString(), dateModified: new Date(p.updated).toISOString(), wordCount: words(p.body).split(' ').length, keywords: p.tags.join(', '), author: { '@type': 'Person', name: AUTHOR, url: SITE + '/blog' }, publisher: { '@type': 'Organization', name: 'InstantAdPay', logo: { '@type': 'ImageObject', url: SITE + '/logo.png' } }, mainEntityOfPage: { '@type': 'WebPage', '@id': url } }) + '</script>';
h += '<script type="application/ld+json">' + JSON.stringify({ '@context': 'https://schema.org', '@type': 'BreadcrumbList', itemListElement: [{ '@type': 'ListItem', position: 1, name: 'Blog', item: SITE + '/blog' }, { '@type': 'ListItem', position: 2, name: p.title, item: url }] }) + '</script>';
return h + tail();
}
function relatedFor(p, posts) {
const scored = posts.filter(x => x.slug !== p.slug).map(x => ({ x, s: x.tags.filter(t => p.tags.includes(t)).length }));
return scored.sort((a, b) => b.s - a.s || (b.x.publishedAt || 0) - (a.x.publishedAt || 0)).slice(0, 3).map(r => r.x);
}
function rss(posts) {
const items = posts.slice(0, 30).map(p => '<item><title>' + esc(p.title) + '</title><link>' + SITE + '/blog/' + p.slug + '</link><guid>' + SITE + '/blog/' + p.slug + '</guid><pubDate>' + new Date(p.publishedAt || p.created).toUTCString() + '</pubDate><description>' + esc(p.excerpt) + '</description></item>').join('');
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 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>';
}
const robots = () => 'User-agent: *\nAllow: /\nDisallow: /my\nDisallow: /admin\nDisallow: /api/\nDisallow: /view/\nSitemap: ' + SITE + '/sitemap.xml\n';
module.exports = { init, listAll, listPublished, get, save, remove, bumpViews, renderIndex, renderPost, relatedFor, rss, sitemap, robots, slugify, sanitize };
+1
View File
@@ -80,6 +80,7 @@ FACTS:
- INTRO VIDEO ON THE WALL: Profile > social links has an "Intro video" field (YouTube, Vimeo or direct .mp4 link). It embeds on the member's public wall page (/wall/<username>) right under their bio, above the three-level line and the join button.
- HOLDING TANK (Members > My line > Holding tank card): free members who joined with no sponsor wait there; a member who has switched on payouts AND bought their own $20+ package can Adopt one (first come, max 2 open adoptions, 7-day window; if the person never links a wallet or buys, they fall back into the tank; a person can be adopted twice at most). Adopting sets the sponsor, opens a chat and emails the member; their first purchase then binds to the adopter on-chain. Members can also "Release to tank" one of their own free referrals (pay it forward). Admin sees the tank under Members.
- 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.
- 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.
+5
View File
@@ -156,6 +156,11 @@ async function bootstrap() {
day CHAR(10) NOT NULL, host VARCHAR(80) NOT NULL, path VARCHAR(40) NOT NULL, n INT NOT NULL DEFAULT 0,
PRIMARY KEY (day, host, path)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); // admin Traffic tab: public page views by referring domain
await q(`CREATE TABLE IF NOT EXISTS blog_posts (
slug VARCHAR(80) NOT NULL PRIMARY KEY, title VARCHAR(140) NOT NULL, excerpt VARCHAR(300) NULL, body MEDIUMTEXT NULL, cover VARCHAR(300) NULL,
tags VARCHAR(200) NULL, status VARCHAR(12) NOT NULL DEFAULT 'draft', author VARCHAR(80) NULL, created BIGINT NOT NULL, updated BIGINT NOT NULL,
published_at BIGINT NULL, views INT NOT NULL DEFAULT 0
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); // public blog articles written in Admin > Blog
await q(`CREATE TABLE IF NOT EXISTS adoptions (
id INT AUTO_INCREMENT PRIMARY KEY,
adoptee VARCHAR(190) NOT NULL, adopter VARCHAR(190) NOT NULL,
+54 -2
View File
@@ -91,6 +91,7 @@
<button data-pane="members" type="button"><svg viewBox="0 0 24 24"><circle cx="12" cy="7" r="3.2"/><circle cx="5" cy="17" r="2.6"/><circle cx="19" cy="17" r="2.6"/><path d="M12 10v3M12 13l-5 2M12 13l5 2"/></svg>Members</button>
<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="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>
@@ -280,6 +281,57 @@
<div class="tablewrap" style="margin-top:12px"><table class="adm-table" id="pcRecent"></table></div>
</div>
</div>
<div class="pane" id="pane-blog" hidden>
<div class="card" id="blogList">
<div class="card-head"><h3>Articles</h3><span class="sub" id="blSub"></span></div>
<p class="muted small" style="margin:0 0 10px">Coaching and teaching stories, published at <a href="/blog" target="_blank" rel="noopener">instantadpay.com/blog</a>. Every published post gets its own page with a title tag, description, canonical link, social preview card, structured data, and a spot in the sitemap and RSS feed. Drafts are visible only to you (open one from its row to preview the real page).</p>
<p><button type="button" class="btn small" id="blNew">New article</button></p>
<div class="tablewrap"><table class="adm-table" id="blTable"></table></div>
</div>
<div class="card" id="blogEditor" hidden>
<div class="card-head"><h3 id="blEdTitle">New article</h3><span class="sub" id="blEdSub"></span></div>
<div style="display:grid;gap:10px">
<label class="small">Title (the headline and the browser title; under 60 characters shows whole in search results) <span id="blTitleCount" class="muted"></span><input id="blTitle" type="text" maxlength="140" placeholder="The thimble and the bucket"></label>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px">
<label class="small">URL slug (letters, numbers, dashes)<input id="blSlug" type="text" maxlength="80" placeholder="auto from the title"></label>
<label class="small">Tags (comma separated; the first one shows as the category)<input id="blTags" type="text" maxlength="200" placeholder="mindset, team building"></label>
</div>
<label class="small">Excerpt (the meta description and the card text; 120 to 160 characters is ideal) <span id="blExcCount" class="muted"></span><textarea id="blExcerpt" maxlength="300" rows="2" style="width:100%"></textarea></label>
<div style="display:grid;grid-template-columns:1fr auto;gap:8px;align-items:end">
<label class="small">Cover image (1200x630 works best; it becomes the social preview card)<input id="blCover" type="text" placeholder="/uploads/... or https://..."></label>
<span><button type="button" class="btn small sec" id="blCoverBtn">Upload</button><input type="file" id="blCoverFile" accept="image/png,image/jpeg,image/webp" hidden></span>
</div>
<p class="small muted" id="blCoverInfo" style="margin:-4px 0 0"></p>
<div>
<div class="ed-bar" aria-label="Formatting">
<button type="button" data-bl="bold" title="Bold"><b>B</b></button>
<button type="button" data-bl="italic" title="Italic"><i>I</i></button>
<button type="button" data-blblock="h2" title="Section heading">H2</button>
<button type="button" data-blblock="h3" title="Sub heading">H3</button>
<button type="button" data-blblock="p" title="Paragraph">P</button>
<button type="button" data-blblock="blockquote" title="Quote">Quote</button>
<button type="button" data-bl="insertUnorderedList" title="Bullet list">&bull; List</button>
<button type="button" data-bl="insertOrderedList" title="Numbered list">1. List</button>
<button type="button" id="blLinkBtn" title="Insert link">&#128279; Link</button>
<button type="button" id="blImgBtn" title="Insert image">&#128444; Image</button><input type="file" id="blImgFile" accept="image/png,image/jpeg,image/webp,image/gif" hidden>
<button type="button" id="blHtmlBtn" title="Edit the HTML directly">&lt;/&gt;</button>
</div>
<div id="blBody" class="ed-body" contenteditable="true" data-ph="Write the article. Headings break it up; short paragraphs read better on phones." style="min-height:360px;line-height:1.6"></div>
<textarea id="blHtml" class="ed-body" hidden style="min-height:360px;font-family:var(--mono);font-size:13px"></textarea>
<p class="small muted" style="margin:6px 0 0"><span id="blWords">0 words</span> &middot; 600 or more gives a page something to rank on; one idea per section.</p>
</div>
<div class="chips" style="gap:8px;flex-wrap:wrap">
<button type="button" class="btn small" id="blSaveDraft">Save draft</button>
<button type="button" class="btn small" id="blPublish">Publish</button>
<button type="button" class="btn small sec" id="blUnpublish" hidden>Back to draft</button>
<a class="btn small sec" id="blPreview" href="#" target="_blank" rel="noopener" hidden>Open page</a>
<button type="button" class="btn small sec" id="blClose">Close</button>
<button type="button" class="btn small sec" id="blDelete" hidden style="margin-left:auto">Delete</button>
</div>
<p class="small" id="blMsg" hidden></p>
</div>
</div>
</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>
@@ -344,7 +396,7 @@
</div>
</div>
<script src="/assets/common.js?v=20260912d"></script>
<script src="/assets/admin.js?v=20260912b"></script>
<script src="/assets/common.js?v=20260912e"></script>
<script src="/assets/admin.js?v=20260912c"></script>
</body>
</html>
+91 -2
View File
@@ -44,8 +44,8 @@
});
// ── panes ──
const TITLES = { overview: 'Overview', house: 'House ads', campaigns: 'All campaigns', members: 'Members', reports: 'Reports', traffic: 'Traffic', pnl: 'Profit and loss', settings: 'Settings' };
const loaders = { overview: loadOverview, house: loadHouse, campaigns: loadCampaigns, members: loadMembers, reports: loadReports, traffic: loadTraffic, pnl: loadPnl, settings: loadSettings };
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 };
function setPane(name) {
if (!TITLES[name]) name = 'overview';
document.querySelectorAll('.pane').forEach(p => { p.hidden = p.id !== 'pane-' + name; });
@@ -347,6 +347,95 @@
$('trfAngles').innerHTML = '<tr><th>Angle</th><th>Join-page views</th><th>Signups</th></tr>' + (d.angles.length ? d.angles.map(a => '<tr><td>' + esc(a.angle) + '</td><td>' + n(a.views) + '</td><td>' + n(a.signups) + '</td></tr>').join('') : '<tr><td colspan="3" class="muted">No angle data yet.</td></tr>');
$('trfDaily').innerHTML = '<tr><th>Day</th><th>Page views</th><th>Signups</th></tr>' + (d.daily.length ? d.daily.slice().reverse().map(x => '<tr><td>' + esc(x.day) + '</td><td>' + n(x.hits) + '</td><td>' + n(x.signups) + '</td></tr>').join('') : '<tr><td colspan="3" class="muted">Nothing yet.</td></tr>');
}
// ── blog: coaching articles, public at /blog (Marty, 2026-09-12) ──
let blCur = null; // slug being edited, or null for a new one
function blCount() {
const t = $('blTitle').value.length, e = $('blExcerpt').value.length;
$('blTitleCount').textContent = t + '/60' + (t > 60 ? ' (long)' : '');
$('blExcCount').textContent = e + ' chars' + (e && (e < 120 || e > 160) ? ' (aim 120-160)' : '');
const w = $('blBody').textContent.trim().split(/\s+/).filter(Boolean).length;
$('blWords').textContent = w + ' words';
}
['blTitle', 'blExcerpt'].forEach(id => $(id).addEventListener('input', blCount));
$('blBody').addEventListener('input', blCount);
$('blTitle').addEventListener('input', () => { if (!blCur && !$('blSlug').dataset.touched) $('blSlug').value = $('blTitle').value.toLowerCase().replace(/['’]/g, '').replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 80); });
$('blSlug').addEventListener('input', () => { $('blSlug').dataset.touched = '1'; });
document.querySelectorAll('.ed-bar [data-bl]').forEach(b => b.addEventListener('click', () => { $('blBody').focus(); document.execCommand(b.dataset.bl, false, null); }));
document.querySelectorAll('.ed-bar [data-blblock]').forEach(b => b.addEventListener('click', () => { $('blBody').focus(); document.execCommand('formatBlock', false, b.dataset.blblock); }));
$('blLinkBtn').addEventListener('click', async () => {
const sel = window.getSelection(); const range = sel && sel.rangeCount ? sel.getRangeAt(0).cloneRange() : null;
const u = await IAP.ask({ title: 'Link address', label: 'https://', placeholder: 'https://instantadpay.com/join/martbost', ok: 'Insert' });
if (u) { $('blBody').focus(); if (range) { sel.removeAllRanges(); sel.addRange(range); } document.execCommand('createLink', false, u); }
});
$('blImgBtn').addEventListener('click', () => $('blImgFile').click());
$('blImgFile').addEventListener('change', async () => {
const f = $('blImgFile').files[0]; if (!f) return;
try {
const r = await (await fetch('/api/admin/upload', { method: 'POST', headers: { 'Content-Type': f.type }, body: f })).json();
if (r.error) throw new Error(r.error);
$('blBody').focus();
const html = '<img src="' + r.url + '" alt="">';
if (!document.execCommand('insertHTML', false, html)) $('blBody').insertAdjacentHTML('beforeend', html);
blCount();
} catch (e) { IAP.status(e.message || 'Upload failed.', 'bad'); }
$('blImgFile').value = '';
});
$('blCoverBtn').addEventListener('click', () => $('blCoverFile').click());
$('blCoverFile').addEventListener('change', () => upload($('blCoverFile'), $('blCoverInfo'), $('blCover')));
$('blHtmlBtn').addEventListener('click', () => {
const raw = !$('blHtml').hidden;
if (raw) { $('blBody').innerHTML = $('blHtml').value; $('blHtml').hidden = true; $('blBody').hidden = false; }
else { $('blHtml').value = $('blBody').innerHTML; $('blBody').hidden = true; $('blHtml').hidden = false; }
blCount();
});
function blBodyHtml() { return $('blHtml').hidden ? $('blBody').innerHTML : $('blHtml').value; }
function blMsg(t, bad) { $('blMsg').textContent = t; $('blMsg').hidden = !t; $('blMsg').className = 'small ' + (bad ? 'bad' : 'ok'); }
function blOpen(post) {
blCur = post ? post.slug : null;
$('blogList').hidden = true; $('blogEditor').hidden = false;
$('blEdTitle').textContent = post ? 'Edit article' : 'New article';
$('blEdSub').textContent = post ? (post.status === 'published' ? 'published ' + when(post.publishedAt) + ' · ' + (post.views || 0) + ' views' : 'draft') : '';
$('blTitle').value = post ? post.title : ''; $('blSlug').value = post ? post.slug : ''; delete $('blSlug').dataset.touched;
$('blTags').value = post ? post.tags.join(', ') : ''; $('blExcerpt').value = post ? post.excerpt : ''; $('blCover').value = post ? post.cover : ''; $('blCoverInfo').textContent = '';
$('blHtml').hidden = true; $('blBody').hidden = false; $('blBody').innerHTML = post ? post.body : '';
$('blUnpublish').hidden = !(post && post.status === 'published'); $('blDelete').hidden = !post;
$('blPreview').hidden = !post; if (post) $('blPreview').href = '/blog/' + post.slug;
$('blPublish').textContent = post && post.status === 'published' ? 'Save and publish' : 'Publish';
blMsg(''); blCount(); $('blTitle').focus();
}
async function blSave(status) {
const body = { existingSlug: blCur, title: $('blTitle').value, slug: $('blSlug').value, tags: $('blTags').value, excerpt: $('blExcerpt').value, cover: $('blCover').value, body: blBodyHtml(), status };
const r = await api('/api/admin/blog', body);
blCur = r.post.slug;
$('blSlug').value = r.post.slug; $('blPreview').hidden = false; $('blPreview').href = '/blog/' + r.post.slug; $('blDelete').hidden = false;
$('blUnpublish').hidden = r.post.status !== 'published'; $('blPublish').textContent = r.post.status === 'published' ? 'Save and publish' : 'Publish';
$('blEdTitle').textContent = 'Edit article';
blMsg(r.post.status === 'published' ? 'Published. Live at instantadpay.com/blog/' + r.post.slug : 'Draft saved.');
IAP.status(r.post.status === 'published' ? 'Published.' : 'Draft saved.', 'ok');
}
$('blSaveDraft').addEventListener('click', busy($('blSaveDraft'), () => blSave('draft')));
$('blPublish').addEventListener('click', busy($('blPublish'), () => blSave('published')));
$('blUnpublish').addEventListener('click', busy($('blUnpublish'), () => blSave('draft')));
$('blClose').addEventListener('click', () => { $('blogEditor').hidden = true; $('blogList').hidden = false; loadBlog().catch(e => IAP.status(e.message, 'bad')); });
$('blDelete').addEventListener('click', busy($('blDelete'), async () => {
if (!blCur) return;
if (!await IAP.confirmBox('The page at /blog/' + blCur + ' stops existing. There is no undo.', { title: 'Delete this article?', ok: 'Delete', cancel: 'Keep it' })) return;
await api('/api/admin/blog?slug=' + encodeURIComponent(blCur), undefined, 'DELETE');
$('blClose').click();
}));
$('blNew').addEventListener('click', () => blOpen(null));
async function loadBlog() {
const d = await api('/api/admin/blog');
const pub = d.posts.filter(p => p.status === 'published').length;
$('blSub').textContent = pub + ' published · ' + (d.posts.length - pub) + ' drafts';
$('blTable').innerHTML = '<tr><th>Title</th><th>Status</th><th>Tags</th><th>Views</th><th>Updated</th><th></th></tr>'
+ (d.posts.length ? d.posts.map(p => '<tr><td><b>' + esc(p.title) + '</b><br><span class="muted small">/blog/' + esc(p.slug) + '</span></td><td>' + (p.status === 'published' ? '<span class="chip-t on">published</span>' : '<span class="chip-t">draft</span>') + '</td><td>' + esc(p.tags.join(', ')) + '</td><td>' + (p.views || 0) + '</td><td>' + when(p.updated) + '</td><td class="act"><button type="button" class="btn small sec" data-bledit="' + esc(p.slug) + '">Edit</button> <a class="btn small sec" href="/blog/' + esc(p.slug) + '" target="_blank" rel="noopener">View</a></td></tr>').join('')
: '<tr><td colspan="6" class="muted">No articles yet. Start with "New article".</td></tr>');
$('blTable').querySelectorAll('[data-bledit]').forEach(b => b.addEventListener('click', async () => {
try { const r = await api('/api/admin/blog?slug=' + encodeURIComponent(b.dataset.bledit)); blOpen(r.post); } catch (e) { IAP.status(e.message, 'bad'); }
}));
}
async function loadPnl() {
const r = await api('/api/admin/pnl?days=' + pnlDays);
const px = r.polUsd || 0;
+2
View File
@@ -0,0 +1,2 @@
// public blog pages: the shared nav (with wallet status) and footer, nothing else
(function () { try { IAP.renderNav('blog'); } catch (e) {} })();
+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="/">How it works</a><a href="/ledger">Live ledger</a><a href="/contract">The contract</a><a href="/blog">Blog</a>'
+ '<a href="/terms">Terms</a><a href="/privacy">Privacy</a><a href="/disclaimer">Disclaimer</a></div>';
document.body.appendChild(f);
}
+1 -1
View File
@@ -141,7 +141,7 @@
<div class="small">Advertising services with a performance referral program. Not an investment product; no income guarantees. Crypto transactions are irreversible. Never spend what you cannot afford.</div>
</footer>
</div>
<script src="/assets/common.js?v=20260912d"></script>
<script src="/assets/common.js?v=20260912e"></script>
<script src="/assets/contract.js?v=20260908p"></script>
<script src="/assets/chat.js?v=20260906m"></script>
</body>
+1 -1
View File
@@ -26,7 +26,7 @@
<p class="muted small">You decide whether, and how much, to spend. Never spend more than you can afford to lose.</p>
</div>
</div></section>
<script src="/assets/common.js?v=20260912d"></script>
<script src="/assets/common.js?v=20260912e"></script>
<script src="/assets/legal.js?v=20260908a"></script>
</body>
</html>
+1 -1
View File
@@ -67,6 +67,6 @@
<div class="small">Advertising services with a performance referral program. Not an investment product; no income guarantees.</div>
</footer>
</div>
<script src="/assets/common.js?v=20260912d"></script>
<script src="/assets/common.js?v=20260912e"></script>
</body>
</html>
+1 -1
View File
@@ -461,7 +461,7 @@
</div>
</section>
<script src="/assets/common.js?v=20260912d"></script>
<script src="/assets/common.js?v=20260912e"></script>
<script src="/assets/wallet.js?v=20260909c"></script>
<script src="/assets/home.js?v=20260912a"></script>
<script src="/assets/chat.js?v=20260906m"></script>
+1 -1
View File
@@ -156,7 +156,7 @@
InstantAdPay · <a href="/contract">Contract</a> · <a href="/terms">Terms</a> · <a href="/privacy">Privacy</a> · <a href="/disclaimer">Disclaimer</a>
</div>
</div>
<script src="/assets/common.js?v=20260912d"></script>
<script src="/assets/common.js?v=20260912e"></script>
<script src="/assets/join.js?v=20260912d"></script>
</body>
</html>
+1 -1
View File
@@ -85,7 +85,7 @@
<div class="small">Advertising services with a performance referral program. Not an investment product; no income guarantees.</div>
</footer>
</div>
<script src="/assets/common.js?v=20260912d"></script>
<script src="/assets/common.js?v=20260912e"></script>
<script src="/assets/launch.js?v=20260911b"></script>
</body>
</html>
+1 -1
View File
@@ -37,7 +37,7 @@
<div>InstantAdPay · <a href="/">how it works</a> · <a id="contractLink" href="#" target="_blank" rel="noopener">contract source ↗</a></div>
</footer>
</div>
<script src="/assets/common.js?v=20260912d"></script>
<script src="/assets/common.js?v=20260912e"></script>
<script src="/assets/ledger.js?v=20260906m"></script>
<script src="/assets/chat.js?v=20260906m"></script>
</body>
+1 -1
View File
@@ -912,7 +912,7 @@
</div>
</div>
</div>
<script src="/assets/common.js?v=20260912d"></script>
<script src="/assets/common.js?v=20260912e"></script>
<script src="/assets/wallet.js?v=20260911a"></script>
<script src="/assets/promo.js?v=20260911a"></script>
<script src="/assets/my.js?v=20260912i"></script>
+1 -1
View File
@@ -128,7 +128,7 @@
<div class="small">Advertising services with a performance referral program. Not an investment product; no income guarantees. Crypto transactions are irreversible. Never spend what you cannot afford to lose.</div>
</footer>
</div>
<script src="/assets/common.js?v=20260912d"></script>
<script src="/assets/common.js?v=20260912e"></script>
<script src="/assets/partners.js?v=20260912a"></script>
</body>
</html>
+1 -1
View File
@@ -192,7 +192,7 @@
<div class="small">Advertising services with a performance referral program. Not an investment product; no income guarantees. Crypto transactions are irreversible. Never spend what you cannot afford.</div>
</footer>
</div>
<script src="/assets/common.js?v=20260912d"></script>
<script src="/assets/common.js?v=20260912e"></script>
<script src="/assets/plays.js?v=20260910b"></script>
</body>
</html>
+1 -1
View File
@@ -28,7 +28,7 @@
<p class="muted small">We use reasonable safeguards, but no system is perfectly secure. Protect your email and your wallet.</p>
</div>
</div></section>
<script src="/assets/common.js?v=20260912d"></script>
<script src="/assets/common.js?v=20260912e"></script>
<script src="/assets/legal.js?v=20260908a"></script>
</body>
</html>
+1 -1
View File
@@ -36,7 +36,7 @@
<p class="muted small" style="margin-top:18px">See also the <a href="/disclaimer">Disclaimer</a> and <a href="/privacy">Privacy Policy</a>.</p>
</div>
</div></section>
<script src="/assets/common.js?v=20260912d"></script>
<script src="/assets/common.js?v=20260912e"></script>
<script src="/assets/legal.js?v=20260908a"></script>
</body>
</html>
+1 -1
View File
@@ -34,7 +34,7 @@
<p><a href="/ledger">← Back to the live ledger</a> · <a href="/contract">Read the contract review</a></p>
</div>
</section>
<script src="/assets/common.js?v=20260912d"></script>
<script src="/assets/common.js?v=20260912e"></script>
<script src="/assets/tx.js?v=20260906m"></script>
</body>
</html>
+1 -1
View File
@@ -47,7 +47,7 @@
</div>
</div>
</section>
<script src="/assets/common.js?v=20260912d"></script>
<script src="/assets/common.js?v=20260912e"></script>
<script src="/assets/wall.js?v=20260911v"></script>
</body>
</html>
+1 -1
View File
@@ -121,7 +121,7 @@
<div class="small">Advertising services with a performance referral program. Not an investment product; no income guarantees. Crypto transactions are irreversible. Never share your recovery phrase.</div>
</footer>
</div>
<script src="/assets/common.js?v=20260912d"></script>
<script src="/assets/common.js?v=20260912e"></script>
<script src="/assets/wallets.js?v=20260911b"></script>
</body>
</html>
+1 -1
View File
@@ -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', 'pnl', 'settings']) {
for (const pn of ['overview', 'house', 'campaigns', 'members', 'reports', 'traffic', 'blog', '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);
+43 -2
View File
@@ -28,7 +28,8 @@ const tank = require('./tank'); // holding tank: unsponsored free members, a
const legacy = require('./legacy'); // Faucet Wave / Tier One Ads bridge: welcome-back credits for listed emails
const traffic = require('./traffic'); // public page views by referring domain (admin Traffic tab)
const promos = require('./promos'); // partner promo codes -> free ad credits (link ?promo=CODE or the dashboard box)
const TRAFFIC_PAGES = new Set(['/', '/ledger', '/contract', '/shorts', '/plays', '/wallets', '/launch', '/partners', '/earning']);
const blog = require('./blog'); // 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']);
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)
@@ -325,6 +326,7 @@ async function boot() {
legacy.init({ dataDir: DATA_DIR });
traffic.init({ dataDir: DATA_DIR });
promos.init({ dataDir: DATA_DIR });
blog.init({ dataDir: DATA_DIR });
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));
setInterval(() => geo.refresh().catch(e => console.error('geo refresh', e.message)), 24 * 3600 * 1000); // monthly file, checked daily
@@ -619,7 +621,7 @@ const server = http.createServer(async (req, res) => {
const p = u.pathname;
// -- traffic log: public page views by referring domain (admin > Traffic)
if (req.method === 'GET' && (TRAFFIC_PAGES.has(p) || /^\/(join|from|wall)\/[^/]+$/.test(p))) traffic.hit(p, req.headers.referer, req.headers['user-agent']);
if (req.method === 'GET' && (TRAFFIC_PAGES.has(p) || /^\/(join|from|wall|blog)\/[^/]+$/.test(p))) traffic.hit(p.startsWith('/blog/') ? '/blog/*' : p, req.headers.referer, req.headers['user-agent']);
// -- join links: /join/<memberId or share code> — LAST-touch cookie (Marty,
// 2026-09-10): the link a visitor opened most recently is the sponsor shown
// and used, and it locks the moment the account is created (accounts.ensure
@@ -1091,6 +1093,24 @@ const server = http.createServer(async (req, res) => {
await ads.addEarned(s.email, g.credits);
return json(res, 200, { ok: true, credits: g.credits, code: g.code, partner: g.partner });
}
// -- 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' });
const slug = u.searchParams.get('slug');
if (slug) { const post = await blog.get(slug); return post ? json(res, 200, { post }) : json(res, 404, { error: 'No such post.' }); }
return json(res, 200, { posts: (await blog.listAll()).map(x => ({ slug: x.slug, title: x.title, status: x.status, tags: x.tags, publishedAt: x.publishedAt, updated: x.updated, views: x.views, excerpt: x.excerpt, cover: x.cover })) });
}
if (p === '/api/admin/blog' && req.method === 'POST') {
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
const b = await readBody(req);
const r = await blog.save(b, b.existingSlug || null);
return json(res, r.error ? 400 : 200, r);
}
if (p === '/api/admin/blog' && req.method === 'DELETE') {
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
const slug = u.searchParams.get('slug'); if (!slug) return json(res, 400, { error: 'slug' });
return json(res, 200, await blog.remove(slug));
}
// -- admin: promo codes (create/update, switch on/off, redemptions)
if (p === '/api/admin/promos' && req.method === 'GET') {
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
@@ -2145,6 +2165,27 @@ const server = http.createServer(async (req, res) => {
if (p === '/plays') return sendFile(res, path.join(PUBLIC_DIR, 'plays.html'));
if (p === '/partners') return sendFile(res, path.join(PUBLIC_DIR, 'partners.html')); // site-owner kit (Marty, 2026-09-12)
if (p === '/earning') return sendFile(res, path.join(PUBLIC_DIR, 'earning.html')); // member guide: how earning works (2026-09-12)
// -- blog: server-rendered so crawlers get real HTML + metadata (Marty, 2026-09-12)
if (p === '/blog' || p === '/blog/' || /^\/blog\/page\/\d+$/.test(p) || /^\/blog\/tag\/[^/]+$/.test(p)) {
const posts = await blog.listPublished();
const pg = (m = /^\/blog\/page\/(\d+)$/.exec(p)) ? Number(m[1]) : 1;
const tag = (m = /^\/blog\/tag\/([^/]+)$/.exec(p)) ? decodeURIComponent(m[1]).toLowerCase() : null;
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 === '/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()); }
m = /^\/blog\/([a-z0-9-]{1,80})$/.exec(p);
if (m) {
const post = await blog.get(m[1]);
const preview = !!(post && post.status !== 'published' && isAdmin(req)); // admins can open a draft at its real URL
if (!post || (post.status !== 'published' && !preview)) { res.writeHead(404, baseHeaders({ 'Content-Type': 'text/html; charset=utf-8' })); return res.end(blog.renderIndex(await blog.listPublished(), 1, null).replace('<title>', '<title>Not found | ')); }
if (!preview && req.method === 'GET') blog.bumpViews(post.slug);
const related = blog.relatedFor(post, await blog.listPublished());
res.writeHead(200, baseHeaders({ 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': preview ? 'no-store' : 'public, max-age=300' }));
return res.end(blog.renderPost(post, related));
}
if (p === '/wallets') return sendFile(res, path.join(PUBLIC_DIR, 'wallets.html'));
if (p === '/launch') return sendFile(res, path.join(PUBLIC_DIR, 'launch.html'));
if (/^\/view\/[a-f0-9]{32}$/.test(p)) return sendFile(res, path.join(PUBLIC_DIR, 'view.html'));