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
+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(() => {});
})();