diff --git a/blog.js b/blog.js new file mode 100644 index 0000000..f7e2e0b --- /dev/null +++ b/blog.js @@ -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/, /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(//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 ''; + 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 ''; + } + 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) ? '' + esc(alt) + '' : ''; + } + 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 '' + + '' + esc(o.title) + '' + + '' + + '' + + (o.article ? '' + (o.article.tags || []).map(t => '').join('') : '') + + '' + + '' + + '' + + '' + + '
'; +} +function tail() { + return '
'; +} +const authorBox = () => '
InstantAdPay
' + AUTHOR + 'Founder of InstantAdPay and the Crypto Team Build Network. Twenty-plus years of internet marketing, and a habit of writing down what actually worked.
'; +const cardHtml = p => '
' + (p.cover ? '' : '') + '

' + esc(p.title) + '

' + esc(p.excerpt) + '

' + fmtDate(p.publishedAt || p.created) + ' · ' + readMinutes(p.body) + ' min read' + (p.tags.length ? ' · ' + p.tags.map(esc).join(', ') : '') + '

'; + +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 += '

' + (tag ? 'Tag: ' + esc(tag) : 'The InstantAdPay blog') + '

Notes on building a line, one honest day at a time.

' + esc(desc) + '

'; + h += slice.length ? slice.map(cardHtml).join('') : '

Nothing published yet. Check back soon.

'; + if (pages > 1) h += '
' + (page > 1 ? '← Newer' : '') + (page < pages ? 'Older →' : '') + '
'; + h += ''; + 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 += '

Blog' + (p.tags[0] ? ' · ' + esc(p.tags[0]) + '' : '') + '

'; + h += '

' + esc(p.title) + '

By ' + esc(AUTHOR) + ' · ' + fmtDate(p.publishedAt || p.created) + ' · ' + readMinutes(p.body) + ' min read

'; + if (p.cover) h += '' + esc(p.title) + ''; + h += '
' + p.body + '
'; + if (p.tags.length) h += '

' + p.tags.map(t => '' + esc(t) + '').join('') + '

'; + h += '

Share on XShare on FacebookTelegram

'; + h += authorBox(); + if (related.length) h += ''; + h += '

InstantAdPay sells advertising. Nothing here is investment advice, no income is guaranteed, and cryptocurrency involves risk of loss.

'; + h += ''; + h += ''; + 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 => '' + esc(p.title) + '' + SITE + '/blog/' + p.slug + '' + SITE + '/blog/' + p.slug + '' + new Date(p.publishedAt || p.created).toUTCString() + '' + esc(p.excerpt) + '').join(''); + return 'InstantAdPay blog' + SITE + '/blogCoaching and teaching articles from Marty Bostick.' + items + ''; +} +function sitemap(posts) { + const pages = ['/', '/blog', '/ledger', '/contract', '/plays', '/wallets', '/earning', '/partners']; + const u = pages.map(p => '' + SITE + p + 'weekly').join('') + + posts.map(p => '' + SITE + '/blog/' + p.slug + '' + new Date(p.updated).toISOString().slice(0, 10) + 'monthly').join(''); + return '' + u + ''; +} +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 }; diff --git a/chatbot.js b/chatbot.js index b16072b..f672c85 100644 --- a/chatbot.js +++ b/chatbot.js @@ -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/) 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. diff --git a/db.js b/db.js index 3144a39..1f0875b 100644 --- a/db.js +++ b/db.js @@ -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, diff --git a/public/admin.html b/public/admin.html index e476a6e..4471d34 100644 --- a/public/admin.html +++ b/public/admin.html @@ -91,6 +91,7 @@ + @@ -280,6 +281,57 @@
+ - - + + diff --git a/public/assets/admin.js b/public/assets/admin.js index ae650c6..712dd09 100644 --- a/public/assets/admin.js +++ b/public/assets/admin.js @@ -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 = 'AngleJoin-page viewsSignups' + (d.angles.length ? d.angles.map(a => '' + esc(a.angle) + '' + n(a.views) + '' + n(a.signups) + '').join('') : 'No angle data yet.'); $('trfDaily').innerHTML = 'DayPage viewsSignups' + (d.daily.length ? d.daily.slice().reverse().map(x => '' + esc(x.day) + '' + n(x.hits) + '' + n(x.signups) + '').join('') : 'Nothing yet.'); } + // ── 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 = ''; + 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 = 'TitleStatusTagsViewsUpdated' + + (d.posts.length ? d.posts.map(p => '' + esc(p.title) + '
/blog/' + esc(p.slug) + '' + (p.status === 'published' ? 'published' : 'draft') + '' + esc(p.tags.join(', ')) + '' + (p.views || 0) + '' + when(p.updated) + ' View').join('') + : 'No articles yet. Start with "New article".'); + $('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; diff --git a/public/assets/blog-page.js b/public/assets/blog-page.js new file mode 100644 index 0000000..0b121bb --- /dev/null +++ b/public/assets/blog-page.js @@ -0,0 +1,2 @@ +// public blog pages: the shared nav (with wallet status) and footer, nothing else +(function () { try { IAP.renderNav('blog'); } catch (e) {} })(); diff --git a/public/assets/common.js b/public/assets/common.js index ae4a5cc..7dedf91 100644 --- a/public/assets/common.js +++ b/public/assets/common.js @@ -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 = '
© ' + new Date().getFullYear() + ' InstantAdPay
' + '
' - + 'How it worksLive ledgerThe contract' + + 'How it worksLive ledgerThe contractBlog' + 'TermsPrivacyDisclaimer
'; document.body.appendChild(f); } diff --git a/public/contract.html b/public/contract.html index a21dba7..eecd2c3 100644 --- a/public/contract.html +++ b/public/contract.html @@ -141,7 +141,7 @@
Advertising services with a performance referral program. Not an investment product; no income guarantees. Crypto transactions are irreversible. Never spend what you cannot afford.
- + diff --git a/public/disclaimer.html b/public/disclaimer.html index 8621d3d..094e790 100644 --- a/public/disclaimer.html +++ b/public/disclaimer.html @@ -26,7 +26,7 @@

You decide whether, and how much, to spend. Never spend more than you can afford to lose.

- + diff --git a/public/earning.html b/public/earning.html index cf758b7..7a9cf68 100644 --- a/public/earning.html +++ b/public/earning.html @@ -67,6 +67,6 @@
Advertising services with a performance referral program. Not an investment product; no income guarantees.
- + diff --git a/public/index.html b/public/index.html index 43c5280..d399420 100644 --- a/public/index.html +++ b/public/index.html @@ -461,7 +461,7 @@ - + diff --git a/public/join.html b/public/join.html index e6be454..bdb5f28 100644 --- a/public/join.html +++ b/public/join.html @@ -156,7 +156,7 @@ InstantAdPay · Contract · Terms · Privacy · Disclaimer - + diff --git a/public/launch.html b/public/launch.html index 51ec441..a430aea 100644 --- a/public/launch.html +++ b/public/launch.html @@ -85,7 +85,7 @@
Advertising services with a performance referral program. Not an investment product; no income guarantees.
- + diff --git a/public/ledger.html b/public/ledger.html index fc831a0..64abdca 100644 --- a/public/ledger.html +++ b/public/ledger.html @@ -37,7 +37,7 @@
InstantAdPay · how it works · contract source ↗
- + diff --git a/public/my.html b/public/my.html index 27d8c0f..199e0d7 100644 --- a/public/my.html +++ b/public/my.html @@ -912,7 +912,7 @@ - + diff --git a/public/partners.html b/public/partners.html index 874ab1b..e48fc1c 100644 --- a/public/partners.html +++ b/public/partners.html @@ -128,7 +128,7 @@
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.
- + diff --git a/public/plays.html b/public/plays.html index a7893a1..0f18252 100644 --- a/public/plays.html +++ b/public/plays.html @@ -192,7 +192,7 @@
Advertising services with a performance referral program. Not an investment product; no income guarantees. Crypto transactions are irreversible. Never spend what you cannot afford.
- + diff --git a/public/privacy.html b/public/privacy.html index 71e989d..bc154a6 100644 --- a/public/privacy.html +++ b/public/privacy.html @@ -28,7 +28,7 @@

We use reasonable safeguards, but no system is perfectly secure. Protect your email and your wallet.

- + diff --git a/public/terms.html b/public/terms.html index 2170df3..8490863 100644 --- a/public/terms.html +++ b/public/terms.html @@ -36,7 +36,7 @@

See also the Disclaimer and Privacy Policy.

- + diff --git a/public/tx.html b/public/tx.html index ef46cf4..e5d5647 100644 --- a/public/tx.html +++ b/public/tx.html @@ -34,7 +34,7 @@

← Back to the live ledger · Read the contract review

- + diff --git a/public/wall.html b/public/wall.html index fba4367..771590b 100644 --- a/public/wall.html +++ b/public/wall.html @@ -47,7 +47,7 @@ - + diff --git a/public/wallets.html b/public/wallets.html index 123c8dd..06c149a 100644 --- a/public/wallets.html +++ b/public/wallets.html @@ -121,7 +121,7 @@
Advertising services with a performance referral program. Not an investment product; no income guarantees. Crypto transactions are irreversible. Never share your recovery phrase.
- + diff --git a/qa/walk.mjs b/qa/walk.mjs index d37791e..022e49d 100644 --- a/qa/walk.mjs +++ b/qa/walk.mjs @@ -121,7 +121,7 @@ if (MODE === 'member' || MODE === 'all') { // admin await page.goto(LOCAL + '/admin', { waitUntil: 'networkidle' }); await page.fill('#adEmail', process.env.ADMIN_EMAIL || 'martybostick@gmail.com'); await page.click('#adSend'); await page.waitForSelector('#adVerify:not([hidden])'); await page.click('#adVerify'); await page.waitForTimeout(1200); - for (const pn of ['overview', 'house', 'campaigns', 'members', 'reports', 'traffic', '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); diff --git a/server.js b/server.js index f8c1480..1cb809f 100644 --- a/server.js +++ b/server.js @@ -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/ — 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>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'));