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:
@@ -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, '&').replace(/"/g, '"').replace(/</g, '<');
|
||||
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() });
|
||||
|
||||
Reference in New Issue
Block a user