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;
|
||||
|
||||
Reference in New Issue
Block a user