Files
instantadpay/public/assets/admin.js
T
martbost ce49dbbe4d 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>
2026-09-09 06:21:01 -05:00

316 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Admin portal: email-code sign-in (allowlisted to ADMIN_EMAIL on the server),
// house ads that cost nothing, every campaign, members, reports, settings.
(function () {
const $ = IAP.$;
const esc = s => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
async function api(path, body, method) {
const opts = { method: method || (body === undefined ? 'GET' : 'POST'), headers: {} };
if (body !== undefined) { opts.headers['Content-Type'] = 'application/json'; opts.body = JSON.stringify(body); }
const r = await (await fetch(path, opts)).json();
if (r.error) throw new Error(r.error === 'auth' ? 'Session expired. Sign in again.' : r.error);
return r;
}
function busy(btn, fn) {
return async (...a) => {
if (btn.disabled) return;
btn.disabled = true;
try { await fn(...a); } catch (e) { IAP.status(e.message || 'Something went wrong.', 'bad'); }
finally { btn.disabled = false; }
};
}
const when = ts => ts ? new Date(Number(ts)).toLocaleString() : '';
let rates = {}, sizes = [], houseOwner = 'house@instantadpay.com';
// ── sign-in ──
$('adSend').addEventListener('click', busy($('adSend'), async () => {
$('adErr').hidden = true;
const r = await api('/api/admin/auth/start', { email: $('adEmail').value });
$('adCodeRow').hidden = false; $('adVerify').hidden = false;
if (r.devCode) $('adCode').value = r.devCode;
IAP.status(r.sent ? 'Code sent. Check your inbox.' : 'Dev mode: code filled in.', 'ok');
$('adCode').focus();
}));
$('adVerify').addEventListener('click', busy($('adVerify'), async () => {
$('adErr').hidden = true;
await api('/api/admin/auth/verify', { email: $('adEmail').value, code: $('adCode').value });
await render();
}));
$('adCode').addEventListener('keydown', e => { if (e.key === 'Enter') $('adVerify').click(); });
$('adEmail').addEventListener('keydown', e => { if (e.key === 'Enter') ($('adVerify').hidden ? $('adSend') : $('adVerify')).click(); });
$('adLogout').addEventListener('click', async e => {
e.preventDefault();
try { await api('/api/admin/auth/logout', {}); } catch (err) {}
location.reload();
});
// ── panes ──
const TITLES = { overview: 'Overview', house: 'House ads', campaigns: 'All campaigns', members: 'Members', reports: 'Reports', settings: 'Settings' };
const loaders = { overview: loadOverview, house: loadHouse, campaigns: loadCampaigns, members: loadMembers, reports: loadReports, settings: loadSettings };
function setPane(name) {
if (!TITLES[name]) name = 'overview';
document.querySelectorAll('.pane').forEach(p => { p.hidden = p.id !== 'pane-' + name; });
document.querySelectorAll('.bo-menu [data-pane]').forEach(b => b.classList.toggle('on', b.dataset.pane === name));
$('boTitle').textContent = TITLES[name];
if (location.hash.slice(1) !== name) history.replaceState(null, '', '#' + name);
$('adminArea').classList.remove('side-open');
loaders[name]().catch(e => IAP.status(e.message, 'bad'));
}
document.querySelectorAll('.bo-menu [data-pane]').forEach(b => b.addEventListener('click', () => setPane(b.dataset.pane)));
document.addEventListener('click', e => { const g = e.target.closest('[data-goto]'); if (g) setPane(g.dataset.goto); });
window.addEventListener('hashchange', () => setPane(location.hash.slice(1)));
$('boBurger').addEventListener('click', () => $('adminArea').classList.toggle('side-open'));
async function render() {
let me = { admin: false };
try { me = await api('/api/admin/me'); } catch (e) {}
$('authArea').hidden = !!me.admin;
$('adminArea').hidden = !me.admin;
if (!me.admin) return;
$('adWho').textContent = me.email || 'admin';
try {
const c = await IAP.getConfig();
$('chainLine').textContent = c.chainName + (c.rehearsal ? ' · rehearsal' : '');
} catch (e) {}
setPane(location.hash.slice(1) || 'overview');
}
// ── overview ──
async function loadOverview() {
const o = await api('/api/admin/overview');
rates = o.rates || rates;
$('ovAccounts').textContent = (o.accounts || 0).toLocaleString();
$('ovMembers').textContent = o.memberCount == null ? '?' : Number(o.memberCount).toLocaleString();
$('ovActive').textContent = (o.byStatus && o.byStatus.active) || 0;
$('ovCampSub').textContent = o.campaigns + ' total · ' + o.house + ' house';
$('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 || {};
$('ovChain').innerHTML = '<div>' + esc(ch.chainName) + ' (chain ' + esc(ch.chainId) + ')</div>'
+ '<div class="mono" style="word-break:break-all;margin:6px 0">' + esc(ch.contract) + '</div>'
+ (ch.explorer ? '<a href="' + esc(ch.explorer) + '/address/' + esc(ch.contract) + '" target="_blank" rel="noopener">Open in explorer →</a>' : '');
}
// ── house ads ──
const HROWS = { banner: ['hBannerRow'], text: ['hTextRow'], login: [], solo: ['hSoloRow'], video: ['hVideoRow'], featured: ['hFeatRow'], visits: ['hVisitsRow'] };
function showHouseRows() {
const t = $('hType').value;
['hBannerRow', 'hTextRow', 'hSoloRow', 'hVideoRow', 'hFeatRow', 'hVisitsRow'].forEach(id => { $(id).hidden = !(HROWS[t] || []).includes(id); });
$('hBudget').hidden = t === 'featured' || t === 'visits';
houseHints();
}
function houseHints() {
const r = rates || {};
const soloCost = r.soloCostPerRecipient || 5, soloMin = r.soloMinRecipients || 10;
const cap = Number($('hBudget').value) || 100000;
$('hSoloHint').textContent = 'Delivers to one inbox per ' + soloCost + ' credits of cap (minimum ' + soloMin + ' recipients). A cap of ' + cap.toLocaleString() + ' reaches up to ' + Math.floor(cap / soloCost).toLocaleString() + ' members.';
const days = Number($('hFeatDays').value) || 0;
$('hFeatHint').textContent = days ? days + '-day run in the featured strip (' + (r.featuredPerDay || 40) + ' credits/day, free here). Book up to ' + (r.featuredWindowDays || 7) + ' days ahead.' : '';
const n = Number($('hVisitCount').value) || 0;
$('hVisitHint').textContent = 'Packs start at ' + (r.visitMinPack || 20) + ' visits.' + (n ? ' ' + n + ' verified visits, delivered one per member.' : '');
}
$('hType').addEventListener('change', showHouseRows);
['hBudget', 'hFeatDays', 'hVisitCount'].forEach(id => $(id).addEventListener('input', houseHints));
$('hFeatDays').addEventListener('change', houseHints);
$('hImageUploadBtn').addEventListener('click', () => $('hImageFile').click());
$('hVideoUploadBtn').addEventListener('click', () => $('hVideoFile').click());
async function upload(fileInput, info, target, kind) {
const f = fileInput.files[0]; if (!f) return;
info.textContent = 'Uploading ' + f.name + '…';
try {
const r = await (await fetch('/api/admin/upload', { method: 'POST', headers: { 'Content-Type': f.type }, body: f })).json();
if (r.error) { info.textContent = r.error; }
else { target.value = r.url; info.textContent = f.name + ' uploaded'; }
} catch (e) { info.textContent = 'Upload failed. Try again.'; }
fileInput.value = '';
}
$('hImageFile').addEventListener('change', () => upload($('hImageFile'), $('hImageInfo'), $('hImage')));
$('hVideoFile').addEventListener('change', () => upload($('hVideoFile'), $('hVideoInfo'), $('hVideoUrl')));
let hVidDims = null;
function probeVideoDims(url) {
return new Promise(resolve => {
const v = document.createElement('video'); v.preload = 'metadata'; v.muted = true;
const done = d => { v.src = ''; resolve(d); };
v.onloadedmetadata = () => done(v.videoWidth && v.videoHeight ? { w: v.videoWidth, h: v.videoHeight } : null);
v.onerror = () => done(null);
setTimeout(() => done(null), 12000);
v.src = url;
});
}
$('hCreate').addEventListener('click', busy($('hCreate'), async () => {
$('hErr').hidden = true;
const t = $('hType').value;
if (t === 'video' && $('hVideoUrl').value) hVidDims = await probeVideoDims($('hVideoUrl').value);
const days = Number($('hFeatDays').value), count = Number($('hVisitCount').value);
const body = { type: t, name: $('hName').value, targetUrl: $('hTarget').value,
imageUrl: $('hImage').value, size: $('hSize').value,
title: t === 'video' ? $('hVideoTitle').value : t === 'featured' ? $('hFeatTitle').value : t === 'visits' ? $('hVisitTitle').value : t === 'solo' ? $('hSoloTitle').value : $('hTitle').value,
body: t === 'solo' ? $('hSoloBody').value : $('hBody').value,
ctaLabel: t === 'video' ? $('hVideoCta').value : $('hSoloCta').value,
videoUrl: $('hVideoUrl').value, watchSecs: Number($('hWatchSecs').value),
videoW: hVidDims ? hVidDims.w : null, videoH: hVidDims ? hVidDims.h : null,
days, startDay: Number($('hFeatStart').value) || 0, count,
budget: t === 'featured' ? days * (rates.featuredPerDay || 40)
: t === 'visits' ? count * (rates.visitCostPerVisit || 3)
: (Number($('hBudget').value) || 0) };
try {
await api('/api/admin/campaigns', body);
} catch (e) { $('hErr').textContent = e.message; $('hErr').hidden = false; throw e; }
IAP.status('House ad is live. It serves right away at no cost.', 'ok');
['hName', 'hBudget', 'hTarget', 'hImage', 'hTitle', 'hBody', 'hSoloTitle', 'hSoloBody', 'hSoloCta',
'hVideoUrl', 'hVideoTitle', 'hVideoCta', 'hFeatTitle', 'hVisitTitle', 'hVisitCount'].forEach(id => { $(id).value = ''; });
$('hImageInfo').textContent = ''; $('hVideoInfo').textContent = ''; hVidDims = null;
await loadHouse();
}));
function campRow(c, showOwner) {
const left = Math.max(0, (c.budget || 0) - (c.spent || 0));
const creative = c.type === 'banner' && c.imageUrl ? '<img src="' + esc(c.imageUrl) + '" alt="" style="max-height:34px;max-width:120px;border-radius:4px">' : esc(c.title || c.name);
const act = c.status === 'active' ? '<button class="btn small sec" data-act="pause" data-id="' + c.id + '">Pause</button>'
: c.status === 'paused' ? '<button class="btn small" data-act="resume" data-id="' + c.id + '">Resume</button>' : '';
return '<tr><td>#' + c.id + (c.house ? '<span class="house-tag">HOUSE</span>' : '') + '</td>'
+ (showOwner ? '<td><span class="trunc" title="' + esc(c.owner) + '">' + esc(c.house ? 'house' : c.owner) + '</span></td>' : '')
+ '<td>' + esc(c.type) + '</td>'
+ '<td>' + esc(c.name) + '<div class="small muted">' + creative + '</div><a class="small trunc" href="' + esc(c.targetUrl) + '" target="_blank" rel="noopener">' + esc(c.targetUrl) + '</a></td>'
+ '<td><span class="st ' + esc(c.status) + '">' + esc(c.status) + '</span></td>'
+ '<td class="mono small">' + (c.spent || 0).toLocaleString() + ' / ' + (c.budget || 0).toLocaleString() + '<div class="muted">' + left.toLocaleString() + ' left</div></td>'
+ '<td class="mono small">' + (c.imps || 0).toLocaleString() + (c.impsNas ? ' +' + c.impsNas + ' nas' : '') + '<div class="muted">' + (c.clicks || 0) + ' clicks</div></td>'
+ '<td class="small muted">' + when(c.created) + '</td>'
+ '<td class="act">' + act + '</td></tr>';
}
function campHead(showOwner) {
return '<tr><th>ID</th>' + (showOwner ? '<th>Owner</th>' : '') + '<th>Type</th><th>Campaign</th><th>Status</th><th>Spent / cap</th><th>Delivery</th><th>Created</th><th></th></tr>';
}
async function loadHouse() {
const r = await api('/api/admin/campaigns');
rates = r.rates || rates; sizes = r.bannerSizes || sizes; houseOwner = r.houseOwner || houseOwner;
if (!$('hSize').options.length) $('hSize').innerHTML = sizes.map(s => '<option value="' + esc(s.id) + '">' + esc(s.label || s.id) + ' (' + s.w + '×' + s.h + ')</option>').join('');
if (!$('hWatchSecs').options.length) $('hWatchSecs').innerHTML = (rates.videoTiers || []).map(t => '<option value="' + t.secs + '">Watch ' + t.secs + 's (viewer earns ' + t.reward + ')</option>').join('');
if (!$('hFeatDays').options.length) $('hFeatDays').innerHTML = (rates.featuredDurations || [1, 2, 7]).map(d => '<option value="' + d + '">' + d + ' day' + (d > 1 ? 's' : '') + '</option>').join('');
showHouseRows();
const house = (r.campaigns || []).filter(c => c.house);
$('houseSub').textContent = house.filter(c => c.status === 'active').length + ' active · ' + house.length + ' total';
$('houseTable').innerHTML = house.length ? campHead(false) + house.map(c => campRow(c, false)).join('') : '<tr><td class="muted">No house ads yet. Place one above.</td></tr>';
}
document.addEventListener('click', async e => {
const b = e.target.closest('[data-act][data-id]'); if (!b) return;
b.disabled = true;
try {
await api('/api/admin/campaigns/' + b.dataset.id + '/' + b.dataset.act, {});
IAP.status('Campaign #' + b.dataset.id + ' ' + (b.dataset.act === 'pause' ? 'paused' : 'resumed') + '.', 'ok');
await Promise.all([loadHouse(), loadCampaigns()]);
} catch (err) { IAP.status(err.message, 'bad'); b.disabled = false; }
});
// ── all campaigns ──
let allCamps = [];
async function loadCampaigns() {
const r = await api('/api/admin/campaigns');
allCamps = r.campaigns || [];
drawCamps();
}
function drawCamps() {
const q = ($('campFilter').value || '').trim().toLowerCase();
const list = allCamps.filter(c => !q || [c.owner, c.name, c.type, c.status, c.targetUrl, String(c.id)].join(' ').toLowerCase().includes(q));
$('campSub').textContent = list.length + ' of ' + allCamps.length;
$('campTable').innerHTML = list.length ? campHead(true) + list.map(c => campRow(c, true)).join('') : '<tr><td class="muted">Nothing matches.</td></tr>';
}
$('campFilter').addEventListener('input', drawCamps);
// ── members ──
let allMembers = [];
async function loadMembers() {
const r = await api('/api/admin/members');
allMembers = r.members || [];
drawMembers();
}
function drawMembers() {
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>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="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('');
}
$('memFilter').addEventListener('input', drawMembers);
document.addEventListener('click', async e => {
const b = e.target.closest('[data-spon]'); if (!b) return;
const v = prompt('Sponsor for ' + b.dataset.spon + ' (username, share code, or member #). Leave blank to clear.', b.dataset.cur);
if (v === null) return;
try {
await api('/api/admin/members', { email: b.dataset.spon, sponsorRef: v.trim() }, 'PATCH');
IAP.status('Sponsor updated.', 'ok');
await loadMembers();
} catch (err) { IAP.status(err.message, 'bad'); }
});
// ── reports + burns ──
async function loadReports() {
const [r, b] = await Promise.all([api('/api/admin/reports'), api('/api/admin/burns')]);
const reps = r.reports || [];
$('repTable').innerHTML = reps.length ? '<tr><th>When</th><th>Campaign</th><th>Reason</th><th>Note</th><th>By</th><th></th></tr>'
+ reps.map(x => '<tr' + (x.resolved ? ' style="opacity:.5"' : '') + '><td class="small muted">' + when(x.ts) + '</td><td>#' + x.campaignId + '</td><td>' + esc(x.reason) + '</td><td>' + esc(x.note || '') + '</td><td class="small">' + esc(x.reporter || 'anon') + '</td>'
+ '<td class="act">' + (x.resolved ? 'resolved' : '<button class="btn small sec" data-act="pause" data-id="' + x.campaignId + '">Pause ad</button><button class="btn small" data-resolve="' + x.id + '">Resolve</button>') + '</td></tr>').join('')
: '<tr><td class="muted">No reports.</td></tr>';
const burns = b.pending || [];
$('burnTable').innerHTML = burns.length ? '<tr><th>When</th><th>Member</th><th>Credits</th><th>Ref</th><th>Burn id</th></tr>'
+ burns.map(x => '<tr><td class="small muted">' + when(x.ts) + '</td><td>#' + x.memberId + '</td><td class="mono">' + x.amount + '</td><td>' + esc(x.ref) + '</td><td class="mono small">' + esc(x.id) + '</td></tr>').join('')
: '<tr><td class="muted">Nothing pending.</td></tr>';
}
document.addEventListener('click', async e => {
const b = e.target.closest('[data-resolve]'); if (!b) return;
b.disabled = true;
try { await api('/api/admin/reports/' + b.dataset.resolve + '/resolve', {}); IAP.status('Report resolved.', 'ok'); await Promise.all([loadReports(), loadOverview()]); }
catch (err) { IAP.status(err.message, 'bad'); b.disabled = false; }
});
// ── settings ──
async function loadSettings() {
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;
let obj;
try { obj = JSON.parse($(taId).value); } catch (e) { $(errId).textContent = 'That is not valid JSON: ' + e.message; $(errId).hidden = false; return; }
const r = await api(path, obj, 'PATCH');
$(taId).value = JSON.stringify(r[key] || obj, null, 2);
IAP.status('Saved.', 'ok');
}));
}
saveJson('ratesSave', 'ratesJson', 'ratesErr', '/api/admin/rates', 'rates');
saveJson('siteSave', 'siteJson', 'siteErr', '/api/admin/site', 'site');
render();
})();