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:
@@ -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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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) : '') + '">← Newer</a>' : '<span></span>') + (page < pages ? '<a href="/blog/page/' + (page + 1) + '">Older →</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 };
|
||||||
@@ -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.
|
- 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.
|
- 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.
|
- 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).
|
- 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.
|
- 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.
|
- 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.
|
||||||
|
|||||||
@@ -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,
|
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)
|
PRIMARY KEY (day, host, path)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); // admin Traffic tab: public page views by referring domain
|
) 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 (
|
await q(`CREATE TABLE IF NOT EXISTS adoptions (
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
adoptee VARCHAR(190) NOT NULL, adopter VARCHAR(190) NOT NULL,
|
adoptee VARCHAR(190) NOT NULL, adopter VARCHAR(190) NOT NULL,
|
||||||
|
|||||||
+54
-2
@@ -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="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="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="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&L</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>
|
<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>
|
</nav>
|
||||||
@@ -280,6 +281,57 @@
|
|||||||
<div class="tablewrap" style="margin-top:12px"><table class="adm-table" id="pcRecent"></table></div>
|
<div class="tablewrap" style="margin-top:12px"><table class="adm-table" id="pcRecent"></table></div>
|
||||||
</div>
|
</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">• List</button>
|
||||||
|
<button type="button" data-bl="insertOrderedList" title="Numbered list">1. List</button>
|
||||||
|
<button type="button" id="blLinkBtn" title="Insert link">🔗 Link</button>
|
||||||
|
<button type="button" id="blImgBtn" title="Insert image">🖼 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"></></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> · 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="pane" id="pane-pnl" hidden>
|
||||||
<div class="card">
|
<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>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/assets/common.js?v=20260912d"></script>
|
<script src="/assets/common.js?v=20260912e"></script>
|
||||||
<script src="/assets/admin.js?v=20260912b"></script>
|
<script src="/assets/admin.js?v=20260912c"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+91
-2
@@ -44,8 +44,8 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ── panes ──
|
// ── panes ──
|
||||||
const TITLES = { overview: 'Overview', house: 'House ads', campaigns: 'All campaigns', members: 'Members', reports: 'Reports', traffic: 'Traffic', pnl: 'Profit and loss', settings: 'Settings' };
|
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, pnl: loadPnl, settings: loadSettings };
|
const loaders = { overview: loadOverview, house: loadHouse, campaigns: loadCampaigns, members: loadMembers, reports: loadReports, traffic: loadTraffic, blog: loadBlog, pnl: loadPnl, settings: loadSettings };
|
||||||
function setPane(name) {
|
function setPane(name) {
|
||||||
if (!TITLES[name]) name = 'overview';
|
if (!TITLES[name]) name = 'overview';
|
||||||
document.querySelectorAll('.pane').forEach(p => { p.hidden = p.id !== 'pane-' + name; });
|
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>');
|
$('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>');
|
$('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() {
|
async function loadPnl() {
|
||||||
const r = await api('/api/admin/pnl?days=' + pnlDays);
|
const r = await api('/api/admin/pnl?days=' + pnlDays);
|
||||||
const px = r.polUsd || 0;
|
const px = r.polUsd || 0;
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
// public blog pages: the shared nav (with wallet status) and footer, nothing else
|
||||||
|
(function () { try { IAP.renderNav('blog'); } catch (e) {} })();
|
||||||
@@ -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.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>'
|
f.innerHTML = '<div>© ' + new Date().getFullYear() + ' InstantAdPay</div>'
|
||||||
+ '<div style="margin-top:8px;display:flex;gap:16px;justify-content:center;flex-wrap:wrap">'
|
+ '<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>';
|
+ '<a href="/terms">Terms</a><a href="/privacy">Privacy</a><a href="/disclaimer">Disclaimer</a></div>';
|
||||||
document.body.appendChild(f);
|
document.body.appendChild(f);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
<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>
|
</footer>
|
||||||
</div>
|
</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/contract.js?v=20260908p"></script>
|
||||||
<script src="/assets/chat.js?v=20260906m"></script>
|
<script src="/assets/chat.js?v=20260906m"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -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>
|
<p class="muted small">You decide whether, and how much, to spend. Never spend more than you can afford to lose.</p>
|
||||||
</div>
|
</div>
|
||||||
</div></section>
|
</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>
|
<script src="/assets/legal.js?v=20260908a"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+1
-1
@@ -67,6 +67,6 @@
|
|||||||
<div class="small">Advertising services with a performance referral program. Not an investment product; no income guarantees.</div>
|
<div class="small">Advertising services with a performance referral program. Not an investment product; no income guarantees.</div>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
<script src="/assets/common.js?v=20260912d"></script>
|
<script src="/assets/common.js?v=20260912e"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+1
-1
@@ -461,7 +461,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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/wallet.js?v=20260909c"></script>
|
||||||
<script src="/assets/home.js?v=20260912a"></script>
|
<script src="/assets/home.js?v=20260912a"></script>
|
||||||
<script src="/assets/chat.js?v=20260906m"></script>
|
<script src="/assets/chat.js?v=20260906m"></script>
|
||||||
|
|||||||
+1
-1
@@ -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>
|
InstantAdPay · <a href="/contract">Contract</a> · <a href="/terms">Terms</a> · <a href="/privacy">Privacy</a> · <a href="/disclaimer">Disclaimer</a>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
<script src="/assets/join.js?v=20260912d"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+1
-1
@@ -85,7 +85,7 @@
|
|||||||
<div class="small">Advertising services with a performance referral program. Not an investment product; no income guarantees.</div>
|
<div class="small">Advertising services with a performance referral program. Not an investment product; no income guarantees.</div>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</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>
|
<script src="/assets/launch.js?v=20260911b"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+1
-1
@@ -37,7 +37,7 @@
|
|||||||
<div>InstantAdPay · <a href="/">how it works</a> · <a id="contractLink" href="#" target="_blank" rel="noopener">contract source ↗</a></div>
|
<div>InstantAdPay · <a href="/">how it works</a> · <a id="contractLink" href="#" target="_blank" rel="noopener">contract source ↗</a></div>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</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/ledger.js?v=20260906m"></script>
|
||||||
<script src="/assets/chat.js?v=20260906m"></script>
|
<script src="/assets/chat.js?v=20260906m"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
+1
-1
@@ -912,7 +912,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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/wallet.js?v=20260911a"></script>
|
||||||
<script src="/assets/promo.js?v=20260911a"></script>
|
<script src="/assets/promo.js?v=20260911a"></script>
|
||||||
<script src="/assets/my.js?v=20260912i"></script>
|
<script src="/assets/my.js?v=20260912i"></script>
|
||||||
|
|||||||
@@ -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>
|
<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>
|
</footer>
|
||||||
</div>
|
</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>
|
<script src="/assets/partners.js?v=20260912a"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+1
-1
@@ -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>
|
<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>
|
</footer>
|
||||||
</div>
|
</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>
|
<script src="/assets/plays.js?v=20260910b"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+1
-1
@@ -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>
|
<p class="muted small">We use reasonable safeguards, but no system is perfectly secure. Protect your email and your wallet.</p>
|
||||||
</div>
|
</div>
|
||||||
</div></section>
|
</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>
|
<script src="/assets/legal.js?v=20260908a"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+1
-1
@@ -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>
|
<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>
|
||||||
</div></section>
|
</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>
|
<script src="/assets/legal.js?v=20260908a"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+1
-1
@@ -34,7 +34,7 @@
|
|||||||
<p><a href="/ledger">← Back to the live ledger</a> · <a href="/contract">Read the contract review</a></p>
|
<p><a href="/ledger">← Back to the live ledger</a> · <a href="/contract">Read the contract review</a></p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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>
|
<script src="/assets/tx.js?v=20260906m"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+1
-1
@@ -47,7 +47,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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>
|
<script src="/assets/wall.js?v=20260911v"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+1
-1
@@ -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>
|
<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>
|
</footer>
|
||||||
</div>
|
</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>
|
<script src="/assets/wallets.js?v=20260911b"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+1
-1
@@ -121,7 +121,7 @@ if (MODE === 'member' || MODE === 'all') {
|
|||||||
// admin
|
// admin
|
||||||
await page.goto(LOCAL + '/admin', { waitUntil: 'networkidle' });
|
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);
|
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;
|
const label = L + 'admin#' + pn;
|
||||||
await page.click('.bo-menu [data-pane="' + pn + '"]'); await page.waitForTimeout(1200);
|
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);
|
const vis = await page.evaluate(id => { const el = document.getElementById('pane-' + id); return el && !el.hidden && el.offsetHeight > 40; }, pn);
|
||||||
|
|||||||
@@ -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 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 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 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)
|
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 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)
|
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 });
|
legacy.init({ dataDir: DATA_DIR });
|
||||||
traffic.init({ dataDir: DATA_DIR });
|
traffic.init({ dataDir: DATA_DIR });
|
||||||
promos.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
|
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));
|
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
|
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;
|
const p = u.pathname;
|
||||||
|
|
||||||
// -- traffic log: public page views by referring domain (admin > Traffic)
|
// -- 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,
|
// -- 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
|
// 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
|
// 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);
|
await ads.addEarned(s.email, g.credits);
|
||||||
return json(res, 200, { ok: true, credits: g.credits, code: g.code, partner: g.partner });
|
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)
|
// -- admin: promo codes (create/update, switch on/off, redemptions)
|
||||||
if (p === '/api/admin/promos' && req.method === 'GET') {
|
if (p === '/api/admin/promos' && req.method === 'GET') {
|
||||||
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
|
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 === '/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 === '/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)
|
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 === '/wallets') return sendFile(res, path.join(PUBLIC_DIR, 'wallets.html'));
|
||||||
if (p === '/launch') return sendFile(res, path.join(PUBLIC_DIR, 'launch.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'));
|
if (/^\/view\/[a-f0-9]{32}$/.test(p)) return sendFile(res, path.join(PUBLIC_DIR, 'view.html'));
|
||||||
|
|||||||
Reference in New Issue
Block a user