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
+9 -9
View File
@@ -33,7 +33,7 @@ function newCode(taken) {
return c;
}
const pub = a => a ? { email: a.email, sponsorRef: a.sponsorRef || '', code: a.code || null,
username: a.username || null, memberId: a.memberId || 0,
username: a.username || null, memberId: a.memberId || 0, joinedVia: a.joinedVia || null,
lineBannerUrl: a.lineBannerUrl || null, lineTargetUrl: a.lineTargetUrl || null,
avatarUrl: a.avatarUrl || null, bio: a.bio || null, socials: a.socials || null,
chatAvailable: a.chatAvailable === false ? false : true, lastSeen: a.lastSeen || 0,
@@ -74,11 +74,11 @@ const J = {
if (!a || !a.pass || !checkPassword(password, a.pass)) return { error: 'Wrong email or password.' };
return { ok: true, account: pub(a) };
},
async ensure(e, ref) {
async ensure(e, ref, via) {
let created = false;
if (!this.db.byEmail[e]) {
const code = newCode(c => this.db.byCode[c]);
this.db.byEmail[e] = { email: e, pass: null, sponsorRef: ref, code, address: null, created: Date.now() };
this.db.byEmail[e] = { email: e, pass: null, sponsorRef: ref, code, address: null, created: Date.now(), joinedVia: via || null };
this.db.byCode[code] = e;
created = true;
this.save();
@@ -171,7 +171,7 @@ const J = {
// ---- MySQL mode ----
const rowPub = r => r ? pub({ email: r.email, sponsorRef: r.sponsor_ref, code: r.code,
username: r.username, memberId: r.member_id || 0,
username: r.username, memberId: r.member_id || 0, joinedVia: r.joined_via || null,
lineBannerUrl: r.line_banner_url, lineTargetUrl: r.line_target_url,
avatarUrl: r.avatar_url, bio: r.bio, socials: r.socials,
chatAvailable: r.chat_available === 0 ? false : true, lastSeen: Number(r.last_seen || 0),
@@ -195,12 +195,12 @@ const D = {
if (!rows.length || !rows[0].pass || !checkPassword(password, rows[0].pass)) return { error: 'Wrong email or password.' };
return { ok: true, account: rowPub(rows[0]) };
},
async ensure(e, ref) {
async ensure(e, ref, via) {
const code = newCode();
let created = false;
try {
await db.q('INSERT INTO accounts (email,pass,sponsor_ref,code,address,created) VALUES (?,NULL,?,?,NULL,?)',
[e, ref, code, Date.now()]);
await db.q('INSERT INTO accounts (email,pass,sponsor_ref,code,address,created,joined_via) VALUES (?,NULL,?,?,NULL,?,?)',
[e, ref, code, Date.now(), via || null]);
created = true;
} catch (err) {
if (err.code !== 'ER_DUP_ENTRY') throw err;
@@ -294,10 +294,10 @@ async function signup(email, password, sponsorRef) {
return impl().signup(e, String(password), String(sponsorRef || ''));
}
async function login(email, password) { return impl().login(normEmail(email), String(password || '')); }
async function ensure(email, sponsorRef) {
async function ensure(email, sponsorRef, via) {
const e = normEmail(email);
if (!EMAIL_RE.test(e)) return { error: 'That email address does not look right.' };
return impl().ensure(e, String(sponsorRef || ''));
return impl().ensure(e, String(sponsorRef || ''), String(via || '').toLowerCase().slice(0, 20) || null);
}
async function byEmail(email) { return impl().byEmail(normEmail(email)); }
async function byAddress(address) { return impl().byAddress(normAddr(address)); }
+1
View File
@@ -56,6 +56,7 @@ function systemPrompt() {
FACTS:
- Free to join with email only (6-digit code sign-in, no passwords). Wallet appears only at purchase or payout activation. Every new member gets a small welcome batch of ad credits — unlocked by the WELCOME TOUR on first sign-in: they visit their upline's line-banner sites (up to 3, 10 seconds each — the same 3 levels the contract pays), then claim the credits. Members with no upline banners get the credits instantly.
- PROMO TOOLS (Members > Promo tools, pill menu): Social posts for X/Facebook/LinkedIn/Telegram-WhatsApp with post/share buttons; Text a friend (5 SMS-sized messages with Text it / WhatsApp / Telegram / Copy); Email swipes (short, standard, long, follow-up); Banners in every standard ad size plus 1080x1080, 1080x1920 and 1280x720 (download or copy URL); the member's Banner wall link; an Objection handling bank (truth + ready-to-send reply); a Videos tab (hook videos in production). Every piece carries the member's invite link; angle links add ?v=instant|adspend|free|ledger. Members who want copy in their own voice can use mybrandedvoice.com.
- INVITE PAGES: a member's link instantadpay.com/join/<username> opens a lead-capture page (email first, wallet later); add ?v=instant|adspend|free|ledger|two for an angle-matched headline. New free members get a short getting-started email series over the first week (unsubscribe link in every email; the admin edits the sequence in /admin > Settings).
- LINE BANNER (free, set in Profile): every member can set a destination URL (must allow framing) plus an optional banner image. It is shown to their next THREE levels of new members during welcome tours (position 1 for directs, 2, 3 below), and on their public BANNER WALL at /wall/<username> — a shareable page showing their line ladder with their join link. Free viral traffic that compounds as the team grows; no credits spent.
- Ad packages: Micro $5/500 credits, Activation $20/2,000, Builder $50/5,500, Growth $100/12,000, Leader $250/32,500. Dollar-priced, settled in POL (Polygon) at the live Chainlink rate. 1 credit = 1 cent of ad delivery.
- Live formats: display banners (per impression), text ads (per impression), full-screen LOGIN ADS (per day: right after a member signs in they land on a sponsor interstitial — they click "Open Ad", the advertiser's page opens in a NEW tab, a countdown runs on the interstitial, and at zero a "Go to dashboard" button appears. Just a CTA link is enough; an optional banner image can be the clickable creative. No framing requirement since it opens in its own tab), WATCH-TO-EARN VIDEO ADS (advertiser uploads an MP4/WebM or gives a direct https .mp4/.webm link and picks a required watch length — 10s/30s/60s — which sets the per-view price; viewers watch in an escape-proof player under Earn credits > Watch videos, the watch time is enforced on the server clock, and they earn credits per completed watch; you never see your own videos), and solo ads. Banner ads also require a size (standard IAB sizes like 728x90, 300x250). Coming: featured rotation with disclosed rotation size, verified-visit packs.
+12
View File
@@ -173,6 +173,18 @@ async function bootstrap() {
burned_at BIGINT NULL,
INDEX (burned_tx)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`);
// follow-up email sequence queue (one row per free account)
await q(`CREATE TABLE IF NOT EXISTS drips (
email VARCHAR(190) PRIMARY KEY,
step INT NOT NULL DEFAULT 0,
next_at BIGINT NOT NULL,
started BIGINT NOT NULL,
stopped TINYINT NOT NULL DEFAULT 0,
ref VARCHAR(64) NULL,
angle VARCHAR(20) NULL,
INDEX (stopped, next_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`);
await alterSafe('ALTER TABLE accounts ADD COLUMN joined_via VARCHAR(20) NULL'); // ?v= angle the lead came in on
}
// one-time import: only when the tables are empty and JSON files exist
+197
View File
@@ -0,0 +1,197 @@
// Follow-up email sequence for new free members ("the lead came in the door
// with their email"). A row is queued when an account is created; a ticker
// sends each step when it comes due; a signed unsubscribe link stops it.
// Sequence copy lives in DATA_DIR/drip.json (admin-editable) with the
// defaults below. Dual-mode storage like the rest of the site (MySQL / JSON).
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const db = require('./db');
let DATA_DIR = null, mailer = null, accounts = null, SITE = 'https://instantadpay.com';
// hours = time after the account was created
const DEFAULT_SEQUENCE = [
{ hours: 24, subject: 'Your first five minutes on InstantAdPay',
body: 'Yesterday you created a free InstantAdPay account. Here is the shortest useful thing you can do with it today.\n\n'
+ '1. Pick a username. It becomes your invite link, and it is how your sponsor and your line see you.\n'
+ '2. Take the welcome tour. It is three short stops on your upline\'s pages, and it unlocks your welcome credits. Those credits spend on real ads.\n'
+ '3. Copy your invite link and put it somewhere: a bio, a group, a text to one person.\n\n'
+ 'Your link right now: {{link}}\n\n'
+ 'That is the whole first day. Nothing to buy, nothing to connect.\n\n'
+ 'Sign in: {{site}}/my\n\n{{footer}}' },
{ hours: 48, subject: 'Where the money actually goes',
body: 'Most programs ask you to trust a back office. Here is what happens instead, in plain terms.\n\n'
+ 'When anyone buys an ad package, a verified smart contract on Polygon splits that payment in the same transaction: 50 percent to their direct sponsor, 20 percent to level two, 10 percent to level three, 20 percent to the platform. It lands in real wallets in seconds. Nothing is held, so there is nothing to withdraw.\n\n'
+ 'Every one of those payments is public. Open the live ledger and read it yourself:\n{{site}}/ledger\n\n'
+ 'You do not need a wallet to look. You only need one when you buy a package or switch on payouts, and the site walks you through it.\n\n'
+ 'Your invite link: {{link}}\n\n{{footer}}' },
{ hours: 96, subject: 'The five dollar test',
body: 'If you want to see the whole machine work without much at stake, the $5 package is the test.\n\n'
+ 'It mints 500 ad credits. One credit is one cent of delivery, and you spend them on seven formats: banners, text ads, login ads, solo ads into member inboxes, video ads, featured links and verified visits. Every view is dwell-timed on the server, so a real person saw it.\n\n'
+ 'The packages: $5 = 500 credits, $20 = 2,000, $50 = 5,500, $100 = 12,000, $250 = 32,500.\n\n'
+ 'Prefer to spend nothing? Keep viewing ads in Earn credits and run your first campaign on those. Both paths are real.\n\n'
+ 'Buy packages: {{site}}/my#buy\n\n{{footer}}' },
{ hours: 168, subject: 'Two buyers open level two',
body: 'One week in. Here is the referral side, with the numbers exactly as the contract has them.\n\n'
+ 'Every person who joins through your link and buys a package pays you 50 percent of that package, from their very first purchase, in the same transaction.\n\n'
+ 'Two qualifying buyers ($20 or more) open level two: 20 percent of everything their people buy. Five open level three: 10 percent of the level after that.\n\n'
+ 'Promo tools in your dashboard has posts, texts, email swipes and banners already carrying your link, plus honest answers to the objections you will hear. Your sponsor {{sponsor}} can be messaged from the dashboard any time.\n\n'
+ 'Your invite link: {{link}}\n\n'
+ 'No income is promised. Results depend on your effort, and crypto carries risk of loss.\n\n{{footer}}' }
];
// ---- storage ----
const J = {
db: null,
FILE: () => path.join(DATA_DIR, 'drips.json'),
load() { try { this.db = JSON.parse(fs.readFileSync(this.FILE(), 'utf8')); } catch (e) { this.db = {}; } },
save() { try { fs.writeFileSync(this.FILE(), JSON.stringify(this.db)); } catch (e) {} },
async enqueue(rec) { if (!this.db) this.load(); if (this.db[rec.email]) return false; this.db[rec.email] = rec; this.save(); return true; },
async due(now, limit) { if (!this.db) this.load(); return Object.values(this.db).filter(r => !r.stopped && r.nextAt <= now).sort((a, b) => a.nextAt - b.nextAt).slice(0, limit); },
async update(email, fields) { if (!this.db) this.load(); if (this.db[email]) { Object.assign(this.db[email], fields); this.save(); } },
async stats() { if (!this.db) this.load(); const v = Object.values(this.db); return { active: v.filter(r => !r.stopped).length, unsubscribed: v.filter(r => r.stopped === 2).length, done: v.filter(r => r.stopped === 1).length, total: v.length }; },
async get(email) { if (!this.db) this.load(); return this.db[email] || null; }
};
const rowR = r => ({ email: r.email, step: r.step, nextAt: Number(r.next_at), started: Number(r.started), stopped: r.stopped, ref: r.ref, angle: r.angle });
const D = {
async enqueue(rec) {
try {
await db.q('INSERT INTO drips (email,step,next_at,started,stopped,ref,angle) VALUES (?,?,?,?,0,?,?)',
[rec.email, rec.step, rec.nextAt, rec.started, rec.ref || null, rec.angle || null]);
return true;
} catch (e) { if (e.code === 'ER_DUP_ENTRY') return false; throw e; }
},
async due(now, limit) { return (await db.q('SELECT * FROM drips WHERE stopped=0 AND next_at<=? ORDER BY next_at LIMIT ?', [now, limit])).map(rowR); },
async update(email, fields) {
const sets = [], vals = [];
if ('step' in fields) { sets.push('step=?'); vals.push(fields.step); }
if ('nextAt' in fields) { sets.push('next_at=?'); vals.push(fields.nextAt); }
if ('stopped' in fields) { sets.push('stopped=?'); vals.push(fields.stopped); }
if (!sets.length) return;
vals.push(email);
await db.q('UPDATE drips SET ' + sets.join(',') + ' WHERE email=?', vals);
},
async stats() {
const r = await db.q('SELECT SUM(stopped=0) active, SUM(stopped=2) unsubscribed, SUM(stopped=1) done, COUNT(*) total FROM drips');
return { active: Number(r[0].active || 0), unsubscribed: Number(r[0].unsubscribed || 0), done: Number(r[0].done || 0), total: Number(r[0].total || 0) };
},
async get(email) { const r = await db.q('SELECT * FROM drips WHERE email=?', [email]); return r.length ? rowR(r[0]) : null; }
};
const impl = () => db.enabled() ? D : J;
// ---- sequence config ----
function seqFile() { return path.join(DATA_DIR, 'drip.json'); }
function sequence() {
try {
const saved = JSON.parse(fs.readFileSync(seqFile(), 'utf8'));
if (Array.isArray(saved) && saved.length) return saved.map(normStep).filter(Boolean);
} catch (e) {}
return DEFAULT_SEQUENCE;
}
function normStep(s) {
if (!s || typeof s !== 'object') return null;
const hours = Number(s.hours);
const subject = String(s.subject || '').trim().slice(0, 150);
const body = String(s.body || '').trim().slice(0, 8000);
if (!(hours >= 1) || !subject || !body) return null;
return { hours, subject, body };
}
function setSequence(arr) {
if (!Array.isArray(arr)) return { error: 'Send a list of steps.' };
const steps = arr.map(normStep);
if (steps.some(s => !s)) return { error: 'Every step needs hours (>= 1), a subject and a body.' };
if (steps.length > 20) return { error: 'Keep it to 20 steps or fewer.' };
for (let i = 1; i < steps.length; i++) if (steps[i].hours <= steps[i - 1].hours) return { error: 'Steps must be in increasing hours.' };
fs.writeFileSync(seqFile(), JSON.stringify(steps, null, 2));
return { ok: true, sequence: steps };
}
function resetSequence() { try { fs.unlinkSync(seqFile()); } catch (e) {} return { ok: true, sequence: DEFAULT_SEQUENCE }; }
// ---- unsubscribe signing ----
function secret() {
const f = path.join(DATA_DIR, 'drip.secret');
try { return fs.readFileSync(f, 'utf8').trim(); } catch (e) {}
const s = crypto.randomBytes(24).toString('hex');
try { fs.writeFileSync(f, s, { mode: 0o600 }); } catch (e) {}
return s;
}
function token(email) { return crypto.createHmac('sha256', secret()).update(String(email).toLowerCase()).digest('hex').slice(0, 32); }
function unsubUrl(email) { return SITE + '/unsubscribe?e=' + encodeURIComponent(email) + '&t=' + token(email); }
async function unsubscribe(email, t) {
const e = String(email || '').trim().toLowerCase();
if (!e || !t || t !== token(e)) return { error: 'That link is not valid.' };
const cur = await impl().get(e);
if (!cur) return { ok: true, already: true };
await impl().update(e, { stopped: 2 });
return { ok: true };
}
// ---- rendering ----
async function vars(email) {
const a = accounts ? await accounts.byEmail(email) : null;
const tok = a ? (a.username || a.code || (a.memberId ? String(a.memberId) : '')) : '';
let sponsor = 'your sponsor';
try {
const sp = accounts && await accounts.sponsorOf(email);
if (sp) sponsor = sp.username ? '@' + sp.username : (sp.memberId ? 'member #' + sp.memberId : 'your sponsor');
} catch (e) {}
return {
link: tok ? SITE + '/join/' + tok : SITE + '/my',
sponsor, site: SITE, email,
footer: 'You are getting these follow-ups because you created a free InstantAdPay account. Stop them here: ' + unsubUrl(email)
+ '\n\nInstantAdPay · Advertising, not investing. No income is guaranteed; results depend on your effort. Crypto carries risk of loss.'
};
}
function render(text, v) { return String(text).replace(/\{\{(\w+)\}\}/g, (m, k) => (k in v ? v[k] : m)); }
// ---- lifecycle ----
function init(opts) {
DATA_DIR = opts.dataDir; mailer = opts.mailer; accounts = opts.accounts;
if (opts.site) SITE = opts.site;
}
// queue a fresh account; step 0 of the sequence is due `hours` after creation
async function enqueue(email, ref, angle) {
const e = String(email || '').trim().toLowerCase();
if (!e) return false;
const seq = sequence();
const now = Date.now();
return impl().enqueue({ email: e, step: 0, nextAt: now + seq[0].hours * 3600000, started: now, stopped: 0,
ref: String(ref || '').slice(0, 40) || null, angle: String(angle || '').slice(0, 20) || null });
}
async function sendStep(email, stepIdx, to) {
const seq = sequence();
const s = seq[stepIdx];
if (!s) return { error: 'No such step.' };
const v = await vars(email);
await mailer.send(to || email, render(s.subject, v), render(s.body, v));
return { ok: true };
}
let ticking = false;
async function tick() {
if (ticking || !mailer || !mailer.hasKey()) return 0;
ticking = true;
let sent = 0;
try {
const seq = sequence();
const now = Date.now();
const rows = await impl().due(now, 50);
for (const r of rows) {
try {
if (r.step >= seq.length) { await impl().update(r.email, { stopped: 1 }); continue; }
await sendStep(r.email, r.step);
sent += 1;
const next = r.step + 1;
if (next >= seq.length) await impl().update(r.email, { step: next, stopped: 1 });
else await impl().update(r.email, { step: next, nextAt: Math.max(now + 60000, r.started + seq[next].hours * 3600000) });
} catch (e) {
console.error('drip send', r.email, e.message);
await impl().update(r.email, { nextAt: now + 6 * 3600000 }); // retry later, do not spin
}
}
} finally { ticking = false; }
return sent;
}
async function stats() { return impl().stats(); }
module.exports = { init, enqueue, tick, stats, sequence, setSequence, resetSequence, sendStep, unsubscribe, unsubUrl, DEFAULT_SEQUENCE };
+17 -1
View File
@@ -84,6 +84,7 @@
<div class="statx c-cyan"><div><div class="nv" id="ovMembers">0</div><div class="lb">On-chain members</div></div></div>
<div class="statx c-violet"><div><div class="nv" id="ovActive">0</div><div class="lb">Active campaigns</div><span class="chip flat" id="ovCampSub"></span></div></div>
<div class="statx c-amber"><div><div class="nv" id="ovReports">0</div><div class="lb">Open reports</div><span class="chip flat" id="ovBurnSub"></span></div></div>
<div class="statx"><div><div class="nv" id="ovDrips">0</div><div class="lb">Follow-ups in flight</div><span class="chip flat" id="ovDripSub"></span></div></div>
</div>
<div class="grid c2" style="margin-top:16px">
<div class="card">
@@ -200,6 +201,21 @@
</div>
<div class="pane" id="pane-settings" hidden>
<div class="card">
<div class="card-head"><h3>Follow-up emails</h3><span class="sub" id="dripSub">sent to every new free member, hours after sign-up</span></div>
<p class="small muted">Each step: <code>hours</code> after the account was created, a <code>subject</code>, and a plain-text <code>body</code>.
Placeholders: <code>{{link}}</code> the member's invite link, <code>{{sponsor}}</code>, <code>{{site}}</code>, <code>{{footer}}</code> (unsubscribe line + disclaimer, keep it).
Steps must be in increasing hours.</p>
<p><textarea id="dripJson" class="json" style="min-height:360px"></textarea></p>
<p id="dripErr" class="small" style="color:var(--bad)" hidden></p>
<p style="display:flex;gap:10px;flex-wrap:wrap;align-items:center;margin:0">
<button class="btn small" id="dripSave" type="button">Save sequence</button>
<button class="btn small sec" id="dripReset" type="button">Reset to defaults</button>
<span style="flex:1"></span>
<select id="dripTestStep" style="width:auto"></select>
<button class="btn small sec" id="dripTest" type="button">Send that step to me</button>
</p>
</div>
<div class="card">
<div class="card-head"><h3>Rates</h3><span class="sub">credit prices and rewards (JSON, merged on save)</span></div>
<p><textarea id="ratesJson" class="json"></textarea></p>
@@ -219,6 +235,6 @@
</div>
<script src="/assets/common.js?v=20260909a"></script>
<script src="/assets/admin.js?v=20260909a"></script>
<script src="/assets/admin.js?v=20260909b"></script>
</body>
</html>
+26 -3
View File
@@ -85,6 +85,9 @@
$('ovReports').textContent = o.openReports || 0;
$('ovBurnSub').textContent = (o.pendingBurns || 0) + ' pending burns';
$('repBadge').hidden = !o.openReports; $('repBadge').textContent = o.openReports || '';
const f = o.followups || {};
$('ovDrips').textContent = f.active || 0;
$('ovDripSub').textContent = (f.done || 0) + ' finished · ' + (f.unsubscribed || 0) + ' unsubscribed';
const bt = Object.entries(o.byType || {}).sort((a, b) => b[1] - a[1]);
$('ovByType').innerHTML = bt.length ? bt.map(([t, n]) => '<div style="display:flex;justify-content:space-between;padding:4px 0;border-bottom:1px solid var(--line)"><span>' + esc(t) + '</span><b>' + n + '</b></div>').join('') : 'No campaigns yet.';
const ch = o.chain || {};
@@ -229,11 +232,11 @@
const q = ($('memFilter').value || '').trim().toLowerCase();
const list = allMembers.filter(a => !q || [a.email, a.username, a.memberId, a.sponsorRef, a.address, a.code].join(' ').toLowerCase().includes(q));
$('memSub').textContent = list.length + ' of ' + allMembers.length;
$('memTable').innerHTML = '<tr><th>Email</th><th>Username</th><th>Member #</th><th>Wallet</th><th>Sponsor</th><th>Code</th><th>Joined</th><th></th></tr>'
$('memTable').innerHTML = '<tr><th>Email</th><th>Username</th><th>Member #</th><th>Wallet</th><th>Sponsor</th><th>Via</th><th>Code</th><th>Joined</th><th></th></tr>'
+ list.map(a => '<tr><td>' + esc(a.email) + '</td><td>' + (a.username ? '@' + esc(a.username) : '<span class="muted">none</span>') + '</td>'
+ '<td>' + (a.memberId ? '#' + a.memberId : '<span class="muted">free</span>') + '</td>'
+ '<td class="mono small">' + (a.address ? esc(a.address.slice(0, 8) + '…' + a.address.slice(-6)) : '<span class="muted">none</span>') + '</td>'
+ '<td>' + esc(a.sponsorRef || '') + '</td><td class="mono small">' + esc(a.code || '') + '</td>'
+ '<td>' + esc(a.sponsorRef || '') + '</td><td class="small muted">' + esc(a.joinedVia || '') + '</td><td class="mono small">' + esc(a.code || '') + '</td>'
+ '<td class="small muted">' + when(a.created) + '</td>'
+ '<td class="act"><button class="btn small sec" data-spon="' + esc(a.email) + '" data-cur="' + esc(a.sponsorRef || '') + '">Sponsor</button></td></tr>').join('');
}
@@ -271,10 +274,30 @@
// ── settings ──
async function loadSettings() {
const [r, s] = await Promise.all([api('/api/admin/rates'), api('/api/admin/site')]);
const [r, s, d] = await Promise.all([api('/api/admin/rates'), api('/api/admin/site'), api('/api/admin/drip')]);
$('ratesJson').value = JSON.stringify(r.rates || {}, null, 2);
$('siteJson').value = JSON.stringify(s.site || {}, null, 2);
$('dripJson').value = JSON.stringify(d.sequence || [], null, 2);
const st = d.stats || {};
$('dripSub').textContent = (st.active || 0) + ' in flight · ' + (st.done || 0) + ' finished · ' + (st.unsubscribed || 0) + ' unsubscribed' + (d.mailReady ? '' : ' · NO MAIL KEY: nothing sends');
$('dripTestStep').innerHTML = (d.sequence || []).map((s, i) => '<option value="' + i + '">Step ' + (i + 1) + ' · ' + s.hours + 'h · ' + esc(s.subject) + '</option>').join('');
}
$('dripSave').addEventListener('click', busy($('dripSave'), async () => {
$('dripErr').hidden = true;
let seq;
try { seq = JSON.parse($('dripJson').value); } catch (e) { $('dripErr').textContent = 'That is not valid JSON: ' + e.message; $('dripErr').hidden = false; return; }
try { const r = await api('/api/admin/drip', { sequence: seq }, 'PATCH'); $('dripJson').value = JSON.stringify(r.sequence, null, 2); IAP.status('Sequence saved.', 'ok'); await loadSettings(); }
catch (e) { $('dripErr').textContent = e.message; $('dripErr').hidden = false; }
}));
$('dripReset').addEventListener('click', busy($('dripReset'), async () => {
if (!confirm('Replace the saved sequence with the built-in defaults?')) return;
const r = await api('/api/admin/drip', { reset: true }, 'PATCH');
$('dripJson').value = JSON.stringify(r.sequence, null, 2); IAP.status('Defaults restored.', 'ok'); await loadSettings();
}));
$('dripTest').addEventListener('click', busy($('dripTest'), async () => {
await api('/api/admin/drip/test', { step: Number($('dripTestStep').value) || 0 });
IAP.status('Sent to your inbox.', 'ok');
}));
function saveJson(btnId, taId, errId, path, key) {
$(btnId).addEventListener('click', busy($(btnId), async () => {
$(errId).hidden = true;
+67
View File
@@ -0,0 +1,67 @@
// Invite / lead-capture page: /join/<token>[?v=<angle>]
// Email first (code sign-in creates the account), wallet later inside the
// member area. The angle only changes the hook copy; the sponsor cookie was
// set by the server when this page was served.
(function () {
const $ = id => document.getElementById(id);
const ANGLES = {
instant: { eyebrow: 'Same-transaction payouts', h: 'Paid before the page <em>reloads.</em>', lead: 'What if your commission landed before the thank-you page finished loading? On InstantAdPay that is not a metaphor. A smart contract on Polygon splits every ad package the moment it sells.' },
adspend: { eyebrow: 'For marketers who buy traffic', h: 'You were buying <em>ads anyway.</em>', lead: 'Every ad dollar you ever spent went one direction: out. Here the ad spend in your line pays you, in the same transaction, on a public ledger. Seven formats, dwell-timed views, packages from $5.' },
free: { eyebrow: 'Costs nothing to try', h: 'Watch first. <em>Spend never.</em>', lead: 'Join free, view a few ads, earn credits, and run your first campaign for zero dollars. Upgrade only if you want more reach.' },
ledger: { eyebrow: 'No back office', h: 'No back office. <em>No payday.</em>', lead: 'Your last program paid you on the 15th, if it paid you. Here every payout is a public transaction on Polygon you can read yourself, and nothing is ever held.' },
two: { eyebrow: 'The referral side, exactly as coded', h: 'Two buyers open <em>level two.</em>', lead: 'Every direct buyer pays you 50 percent from their first package. Two qualifying buyers open level two, five open level three. Written as constants in a verified contract.' }
};
const v = new URLSearchParams(location.search).get('v');
const a = v && ANGLES[v];
if (a) { $('jnEyebrow').textContent = a.eyebrow; $('jnHead').innerHTML = a.h; $('jnLead').textContent = a.lead; }
async function api(path, body) {
const r = await (await fetch(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body || {}) })).json();
if (r.error) throw new Error(r.error);
return r;
}
const err = m => { const e = $('jnErr'); e.textContent = m || ''; e.hidden = !m; };
function busy(btn, fn) {
return async () => { if (btn.disabled) return; btn.disabled = true; err(''); try { await fn(); } catch (e) { err(e.message || 'Something went wrong.'); } finally { btn.disabled = false; } };
}
const send = busy($('jnSend'), async () => {
const r = await api('/api/auth/email/start', { email: $('jnEmail').value });
$('jnCodeRow').hidden = false; $('jnVerify').hidden = false; $('jnSend').hidden = true; $('jnResend').hidden = false;
if (r.devCode) $('jnCode').value = r.devCode;
IAP.status(r.sent ? 'Code sent. Check your inbox (and spam, the first time).' : 'Dev mode: code filled in.', 'ok');
$('jnCode').focus();
});
$('jnSend').addEventListener('click', send);
$('jnResend').addEventListener('click', busy($('jnResend'), async () => {
const r = await api('/api/auth/email/start', { email: $('jnEmail').value });
if (r.devCode) $('jnCode').value = r.devCode;
IAP.status('Fresh code sent.', 'ok');
}));
$('jnVerify').addEventListener('click', busy($('jnVerify'), async () => {
await api('/api/auth/email/verify', { email: $('jnEmail').value, code: $('jnCode').value, newsletter: !!$('jnNews').checked, followups: !!$('jnNews').checked });
IAP.status('You are in. Taking you to your dashboard…', 'ok');
location.href = '/my';
}));
$('jnEmail').addEventListener('keydown', e => { if (e.key === 'Enter') ($('jnVerify').hidden ? $('jnSend') : $('jnVerify')).click(); });
$('jnCode').addEventListener('keydown', e => { if (e.key === 'Enter') $('jnVerify').click(); });
// sponsor line
fetch('/api/sponsor').then(r => r.json()).then(sp => {
if (sp && sp.invited && sp.name) {
$('jnSponName').textContent = sp.name;
if (sp.avatarUrl) { $('jnSponImg').src = sp.avatarUrl; $('jnSponImg').hidden = false; }
$('jnSpon').hidden = false;
}
}).catch(() => {});
// package ladder (dollar constants + live POL quote)
const NAMES = { 1: 'Micro', 2: 'Activation', 3: 'Builder', 4: 'Growth', 5: 'Leader' };
fetch('/api/catalog').then(r => r.json()).then(c => {
const wrap = $('jnLadder'); if (!wrap || !c.products) return;
wrap.innerHTML = c.products.filter(p => p.active !== false).map(p =>
'<div class="jn-pk"><div class="n">' + (NAMES[p.id] || 'Package ' + p.id) + '</div><div class="p">' + IAP.fmtUsd(p.priceCents) + '</div><div class="c">' + Number(p.creditAmount).toLocaleString() + ' credits</div>'
+ (p.costWei ? '<div class="small muted">' + IAP.fmtPol(p.costWei) + ' POL now</div>' : '') + '</div>').join('');
}).catch(() => {});
fetch('/api/stats').then(r => r.json()).then(s => {
if (s && s.onchainMembers) $('jnStats').textContent = s.onchainMembers.toLocaleString() + (s.onchainMembers === 1 ? ' member' : ' members') + ' on-chain so far.';
}).catch(() => {});
})();
+110
View File
@@ -0,0 +1,110 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="robots" content="noindex,follow">
<title>You're invited | InstantAdPay</title>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Sora:wght@600;700;800&display=swap">
<link rel="icon" type="image/png" href="/logo-icon.png">
<link rel="stylesheet" href="/assets/site.css?v=20260909a">
<style>
.jn-nav{display:flex;justify-content:space-between;align-items:center;gap:12px;padding:16px 0}
.jn-hero{padding:34px 0 10px;text-align:left}
.jn-hero h1{font-size:clamp(32px,5vw,54px);line-height:1.08;margin:10px 0 14px;max-width:760px;text-align:left;letter-spacing:-.015em}
.jn-hero h1 em{font-style:normal;color:var(--mint)}
.jn-hero p.lead{margin:0 0 20px;text-align:left;max-width:640px}
.jn-grid{display:grid;grid-template-columns:1.15fr .85fr;gap:26px;align-items:start;margin-top:8px}
@media(max-width:860px){.jn-grid{grid-template-columns:1fr}}
.jn-cap{border-color:var(--mint);box-shadow:0 0 60px rgba(67,232,195,.12)}
.jn-cap h3{margin:0 0 6px}
.jn-cap input{width:100%}
.jn-cap .btn{width:100%;text-align:center}
.jn-chips{display:flex;gap:8px;flex-wrap:wrap;margin:0 0 18px}
.jn-chips span{border:1px solid var(--line-strong);border-radius:999px;padding:6px 14px;font-size:13px;color:var(--muted)}
.jn-chips span b{color:var(--mint);font-weight:700}
.jn-spon{display:flex;align-items:center;gap:10px;margin:0 0 6px}
.jn-spon img{width:34px;height:34px;border-radius:50%;object-fit:cover;border:1px solid var(--line-strong)}
.jn-steps{display:grid;grid-template-columns:repeat(3,1fr);gap:14px;margin-top:14px}
@media(max-width:700px){.jn-steps{grid-template-columns:1fr}}
.jn-steps .card{margin:0}
.jn-steps .n{font-family:var(--mono);color:var(--mint);font-size:12px;letter-spacing:.1em}
.jn-ladder{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:10px;margin-top:14px}
.jn-pk{border:1px solid var(--line);border-radius:12px;padding:14px;text-align:center;background:var(--panel)}
.jn-pk .p{font-family:var(--disp);font-size:26px;font-weight:800}
.jn-pk .c{color:var(--mint);font-weight:700;font-size:13.5px}
.jn-pk .n{color:var(--muted);font-size:11.5px;text-transform:uppercase;letter-spacing:.1em}
.jn-foot{color:var(--muted);font-size:13px;border-top:1px solid var(--line);margin-top:40px;padding:22px 0 50px}
.jn-err{color:var(--bad)}
</style>
</head>
<body>
<div class="wrap">
<div class="jn-nav">
<a href="/"><img src="/logo.png" alt="InstantAdPay" style="height:34px;display:block"></a>
<a class="small muted" href="/my">Already a member? Sign in</a>
</div>
<section class="jn-hero">
<div class="jn-spon" id="jnSpon" hidden><img id="jnSponImg" alt="" hidden><span class="small muted">Personal invitation from <b id="jnSponName" style="color:var(--mint)"></b></span></div>
<p class="eyebrow" id="jnEyebrow">Advertise and earn on Polygon</p>
<h1 id="jnHead">Advertise and earn.<br><em>Paid on-chain, instantly.</em></h1>
<p class="lead" id="jnLead">Every ad package splits to real wallets in the same transaction it sells. No pending payouts, no withdraw button, and every payment is public.</p>
<div class="jn-chips">
<span><b>Same transaction</b> payouts</span>
<span><b>Public ledger</b> on Polygon</span>
<span><b>Free</b> to join by email</span>
</div>
</section>
<div class="jn-grid">
<div>
<div class="mockup" aria-label="worked example of one purchase">
<div class="bar"><i></i><i></i><i></i><span class="addr">instantadpay.com/ledger · worked example</span></div>
<div class="body">
<div class="mrow"><span>🧾 member #7 bought package #2 ($20.00)</span><span>paid</span></div>
<div class="mrow"><span>💸 level 1 payout → member #3 (50%)</span><span>same block</span></div>
<div class="mrow"><span>💸 level 2 payout → member #2 (20%)</span><span>same block</span></div>
<div class="mrow"><span>💸 level 3 payout → member #1 (10%)</span><span>same block</span></div>
<div class="mrow dim"><span>🏛 platform fee settled (20%)</span><span>same block</span></div>
<div class="mrow dim"><span>⭐ member #3 now has 2 qualifying buyers</span><span>level 2 ✓</span></div>
</div>
</div>
<p class="small muted" style="margin:10px 0 0">One purchase, one transaction, four payments. <a href="/ledger" target="_blank" rel="noopener">Open the real ledger →</a> <span id="jnStats"></span></p>
<h2 style="font-size:24px;margin:30px 0 4px">How it works</h2>
<div class="jn-steps">
<div class="card"><div class="n">STEP 1</div><h3>Join free by email</h3><p class="muted small">A 6-digit code, no password. You get an invite link and welcome credits to try real ads.</p></div>
<div class="card"><div class="n">STEP 2</div><h3>Advertise or earn</h3><p class="muted small">Seven ad formats. View ads to earn credits, or buy a package from $5 when you want reach.</p></div>
<div class="card"><div class="n">STEP 3</div><h3>Get paid in the same transaction</h3><p class="muted small">Anyone who buys through your link pays you 50 percent, on-chain, the moment it happens.</p></div>
</div>
<h2 style="font-size:24px;margin:30px 0 4px">The packages</h2>
<p class="muted small" style="margin:0">Priced in dollars, settled in POL at the live rate. One credit is one cent of ad delivery.</p>
<div class="jn-ladder" id="jnLadder"></div>
</div>
<div class="card jn-cap" id="jnCap">
<h3>Join free</h3>
<p class="muted small" style="margin:0 0 12px">Type your email and we send a 6-digit code. No password, no wallet needed today.</p>
<p><input id="jnEmail" type="email" placeholder="Your email" autocomplete="email"></p>
<p id="jnCodeRow" hidden><input id="jnCode" inputmode="numeric" placeholder="6-digit code from your inbox"></p>
<label class="small muted" style="display:flex;gap:8px;align-items:flex-start;margin:0 0 12px;cursor:pointer">
<input type="checkbox" id="jnNews" checked style="margin-top:3px;width:auto">
<span>Send me the getting-started emails (a few short ones over the first week). Unsubscribe any time.</span>
</label>
<p id="jnErr" class="small jn-err" hidden></p>
<button class="btn" id="jnSend" type="button">Email me a code</button>
<button class="btn" id="jnVerify" type="button" hidden>Create my free account</button>
<button class="btn sec small" id="jnResend" type="button" hidden style="margin-top:8px">Send a fresh code</button>
<p class="small muted" style="margin:14px 0 0">Advertising, not investing. No income is guaranteed; results depend on your effort. Crypto carries risk of loss.</p>
</div>
</div>
<div class="jn-foot">
InstantAdPay · <a href="/contract">Contract</a> · <a href="/terms">Terms</a> · <a href="/privacy">Privacy</a> · <a href="/disclaimer">Disclaimer</a>
</div>
</div>
<script src="/assets/common.js?v=20260909a"></script>
<script src="/assets/join.js?v=20260909a"></script>
</body>
</html>
+69 -6
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' : ''}`;
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);
}
res.writeHead(302, baseHeaders(headers));
return res.end();
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() });