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:
+26
-3
@@ -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;
|
||||
|
||||
@@ -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(() => {});
|
||||
})();
|
||||
Reference in New Issue
Block a user