Lead capture pages + follow-up email sequence

- /join/<token>[?v=angle] now serves a capture page (email first, wallet
  later) with angle-matched hook copy, sponsor line, worked-example ledger,
  how-it-works, live package ladder, and per-angle og tags (og:url keeps ?v=).
  The sponsor cookie is set exactly as before; ?v= is remembered and stored
  on the account as joined_via (shown in admin Members).
- drip.js: 4-step getting-started sequence (24h/48h/96h/168h) queued when a
  free account is created with the pre-checked opt-in; ticker every 10 min;
  signed /unsubscribe link in every email; MySQL + JSON storage.
- Admin > Settings: edit the sequence as JSON, reset to defaults, send any
  step to the admin inbox; Overview shows follow-ups in flight.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-09-09 06:21:01 -05:00
parent f717acad97
commit ce49dbbe4d
9 changed files with 509 additions and 20 deletions
+70 -7
View File
@@ -19,6 +19,7 @@ const ads = require('./ads');
const mailer = require('./mailer');
const messages = require('./messages');
const reports = require('./reports');
const drip = require('./drip');
const spaces = require('./spaces'); // DO Spaces video storage (inert unless DO_SPACES_* set)
let QR = null; try { QR = require('qrcode'); } catch (e) { /* optional */ }
const chatbot = require('./chatbot');
@@ -170,6 +171,33 @@ async function handleUpload(req, res, who) {
fs.writeFileSync(path.join(UPLOADS_DIR, name), buf);
return json(res, 200, { url: '/uploads/' + name, type: isVideo ? 'video' : 'image' });
}
// lead-capture page hooks (og tags + copy live in public/assets/join.js too)
const JOIN_ANGLES = {
instant: { t: 'Paid before the page reloads.', d: 'A smart contract on Polygon splits every ad package the moment it sells. Same transaction, real wallets, public ledger. Join free by email.' },
adspend: { t: 'You were buying ads anyway.', d: 'Here the ad spend in your line pays you, in the same transaction, on a public ledger. Seven formats, packages from $5. Join free.' },
free: { t: 'Watch first. Spend never.', d: 'Join free, view a few ads, earn credits, run your first campaign for zero dollars. Every payout public on Polygon.' },
ledger: { t: 'No back office. No payday.', d: 'Every payout is a public transaction on Polygon you can read yourself. Nothing is ever held. Join free by email.' },
two: { t: 'Two buyers open level two.', d: 'Every direct buyer pays you 50 percent from their first package. Two qualifying buyers open level two, five open level three. Written in a verified contract.' }
};
function serveJoinPage(res, tok, angle, ang, setCookies) {
let html;
try { html = fs.readFileSync(path.join(PUBLIC_DIR, 'join.html'), 'utf8'); } catch (e) { res.writeHead(404, baseHeaders({ 'Content-Type': 'text/plain' })); return res.end('Not found'); }
const base = 'https://instantadpay.com';
const url = base + '/join/' + tok + (angle ? '?v=' + angle : '');
const title = ang ? ang.t : 'Advertise and earn. Paid on-chain, instantly.';
const desc = ang ? ang.d : 'You are invited to InstantAdPay: real ad packages with same-transaction payouts on Polygon, every payment public. Join free by email.';
const escA = t => String(t).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;');
const og = '<meta property="og:type" content="website"><meta property="og:site_name" content="InstantAdPay">'
+ '<meta property="og:title" content="' + escA(title) + '"><meta property="og:description" content="' + escA(desc) + '">'
+ '<meta property="og:url" content="' + escA(url) + '">' // keeps ?v= so shares stay on the angle
+ '<meta property="og:image" content="' + base + '/banners/iap-hero-1200x630.png"><meta property="og:image:width" content="1200"><meta property="og:image:height" content="630">'
+ '<meta name="twitter:card" content="summary_large_image"><meta name="twitter:title" content="' + escA(title) + '"><meta name="twitter:description" content="' + escA(desc) + '"><meta name="twitter:image" content="' + base + '/banners/iap-hero-1200x630.png">';
html = html.replace(/<title>[^<]*<\/title>/, '<title>' + escA(title) + ' | InstantAdPay</title>' + og);
const headers = { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store, must-revalidate' };
if (setCookies && setCookies.length) headers['Set-Cookie'] = setCookies;
res.writeHead(200, baseHeaders(headers));
res.end(html);
}
async function frameCheck(url) {
const h = await frameFetch(url, 0);
if (h.error) return { ok: false, reason: 'We checked your URL and ' + h.error + '. Fix the URL and try again.' };
@@ -193,9 +221,13 @@ async function boot() {
mailer.init({ dataDir: DATA_DIR });
messages.init({ dataDir: DATA_DIR });
reports.init({ dataDir: DATA_DIR });
drip.init({ dataDir: DATA_DIR, mailer, accounts, site: 'https://instantadpay.com' });
chatbot.init({ dataDir: DATA_DIR, chain });
setTimeout(() => ads.dailySweep().catch(e => console.error('sweep', e.message)), 60 * 1000);
setInterval(() => ads.dailySweep().catch(e => console.error('sweep', e.message)), 60 * 60 * 1000);
// follow-up email sequence: send whatever came due (every 10 min, first pass shortly after boot)
setTimeout(() => drip.tick().catch(e => console.error('drip', e.message)), 30 * 1000);
setInterval(() => drip.tick().catch(e => console.error('drip', e.message)), 10 * 60 * 1000);
// NAS reconcile: pull syndicated delivery into the unified credit pool
// (inert unless NAS_DB_* is set). Every 5 min after a short warm-up.
if (ads.nasEnabled()) {
@@ -404,14 +436,23 @@ const server = http.createServer(async (req, res) => {
// has by then, so free members refer from day one.
let m = /^\/join\/([A-Za-z0-9_]{1,20})$/.exec(p);
if (m && req.method === 'GET') {
// lead-capture page: email first, wallet later. ?v=<angle> picks the hook
// copy and is remembered so the account records which angle converted.
const tok = m[1].toLowerCase();
const cookies = parseCookies(req);
const headers = { Location: '/' };
if (!cookies['iap.sponsor']) {
headers['Set-Cookie'] = `iap.sponsor=${tok}; Path=/; SameSite=Lax; Max-Age=${180 * 24 * 3600}${IS_PROD ? '; Secure' : ''}`;
}
res.writeHead(302, baseHeaders(headers));
return res.end();
const angle = String(u.searchParams.get('v') || '').toLowerCase();
const ang = JOIN_ANGLES[angle] || null;
const cookieTail = `; Path=/; SameSite=Lax; Max-Age=${180 * 24 * 3600}${IS_PROD ? '; Secure' : ''}`;
const set = [];
if (!cookies['iap.sponsor']) set.push('iap.sponsor=' + tok + cookieTail);
if (ang) set.push('iap.angle=' + angle + cookieTail);
return serveJoinPage(res, tok, ang ? angle : '', ang, set);
}
if (p === '/unsubscribe' && req.method === 'GET') {
const r = await drip.unsubscribe(u.searchParams.get('e'), u.searchParams.get('t'));
const msg = r.error ? r.error : 'Done. You will not get any more follow-up emails from InstantAdPay. Your account is unchanged.';
res.writeHead(r.error ? 400 : 200, baseHeaders({ 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' }));
return res.end('<!doctype html><html><head><meta charset="utf-8"><title>InstantAdPay</title><link rel="stylesheet" href="/assets/site.css?v=20260909a"></head><body><div class="wrap" style="max-width:560px;padding:80px 22px"><a href="/"><img src="/logo.png" alt="InstantAdPay" style="height:34px"></a><h1 style="font-size:30px;margin:26px 0 12px">' + (r.error ? 'Hmm.' : 'Unsubscribed.') + '</h1><p>' + msg + '</p><p><a class="btn small sec" href="/my">Member area</a></p></div></body></html>');
}
// -- public API
@@ -566,8 +607,11 @@ const server = http.createServer(async (req, res) => {
if (String(b.code || '').trim() !== rec.code) return json(res, 400, { error: 'That code does not match.' });
emailCodes.delete(e);
const ref = parseCookies(req)['iap.sponsor'] || '';
const r = await accounts.ensure(e, ref); // first touch wins; existing accounts unchanged
const via = parseCookies(req)['iap.angle'] || '';
const r = await accounts.ensure(e, ref, via); // first touch wins; existing accounts unchanged
if (r.error) return json(res, 400, r);
// the lead is in the door: queue the getting-started sequence (opt-in box is pre-checked on both forms)
if (r.created && (b.followups || b.newsletter)) drip.enqueue(e, ref, via).catch(() => {});
// a wallet-only session (signed with a wallet, no account) finishing setup:
// adopt that wallet into the email account so member #, purchases and
// payouts stay attached, then retire the wallet-only session
@@ -1384,6 +1428,7 @@ const server = http.createServer(async (req, res) => {
return json(res, 200, { accounts: await accounts.count(), memberCount, campaigns: camps.length,
house: camps.filter(c => c.house).length, byStatus, byType,
openReports: await reports.openCount(), pendingBurns: (await ads.pendingBurns()).length,
followups: await drip.stats(),
chain: { contract: cc.contract, chainId: cc.chainId, chainName: cc.chainName, explorer: cc.explorer },
site: siteConfig(), rates: ads.rates() });
}
@@ -1435,6 +1480,24 @@ const server = http.createServer(async (req, res) => {
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
return json(res, 200, { rates: ads.rates() });
}
if (p === '/api/admin/drip' && req.method === 'GET') {
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
return json(res, 200, { sequence: drip.sequence(), defaults: drip.DEFAULT_SEQUENCE, stats: await drip.stats(), mailReady: mailer.hasKey() });
}
if (p === '/api/admin/drip' && req.method === 'PATCH') {
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
const b = await readBody(req);
const r = b.reset ? drip.resetSequence() : drip.setSequence(b.sequence);
return json(res, r.error ? 400 : 200, r);
}
if (p === '/api/admin/drip/test' && req.method === 'POST') { // send one step to the admin inbox
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
const b = await readBody(req);
if (!ADMIN_EMAIL) return json(res, 400, { error: 'ADMIN_EMAIL is not set.' });
if (!mailer.hasKey()) return json(res, 400, { error: 'No mail key on the server.' });
try { const r = await drip.sendStep(ADMIN_EMAIL, Number(b.step) || 0, ADMIN_EMAIL); return json(res, r.error ? 400 : 200, r); }
catch (e) { return json(res, 502, { error: 'Send failed: ' + e.message }); }
}
if (p === '/api/admin/site' && req.method === 'GET') {
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
return json(res, 200, { site: siteConfig() });