Files
instantadpay/public/assets/admin.js
T

805 lines
77 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([], { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }) : '';
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', traffic: 'Traffic', blog: 'Blog', releases: 'Releases and roadmap', pnl: 'Profit and loss', settings: 'Settings' };
const loaders = { overview: loadOverview, house: loadHouse, campaigns: loadCampaigns, members: loadMembers, reports: loadReports, traffic: loadTraffic, blog: loadBlog, releases: loadReleases, pnl: loadPnl, 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();
loadWallAds();
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>';
}
// wall fallback ads editor
let wallAds = [];
function drawWallAds() {
const w = $('wallAdsList');
w.innerHTML = wallAds.map((a, i) => '<div class="drip-step" data-i="' + i + '"><div class="ds-head"><span class="ds-n">WALL AD ' + (i + 1) + '</span>'
+ '<span class="ds-tools"><button type="button" class="btn small sec" data-wact="up" ' + (i === 0 ? 'disabled' : '') + '>↑</button><button type="button" class="btn small sec" data-wact="down" ' + (i === wallAds.length - 1 ? 'disabled' : '') + '>↓</button><button type="button" class="btn small sec" data-wact="remove">Remove</button></span></div>'
+ '<div class="grid c3"><p><input class="wa-name" maxlength="60" placeholder="Label shown under the ad" value="' + esc(a.name || '') + '"></p>'
+ '<p><input class="wa-target" placeholder="Link (https://…)" value="' + esc(a.targetUrl || '') + '"></p>'
+ '<p><input class="wa-banner" placeholder="Banner image URL or upload" value="' + esc(a.bannerUrl || '') + '"> <button type="button" class="btn small sec wa-upload">Upload</button><input type="file" class="wa-file" accept="image/png,image/jpeg,image/webp,image/gif" hidden></p></div>'
+ (a.bannerUrl ? '<img src="' + esc(a.bannerUrl) + '" alt="" style="max-height:60px;border-radius:6px">' : '')
+ '</div>').join('') || '<p class="muted small">No wall ads set. Walls fall back to a plain InstantAdPay card.</p>';
}
function readWallAds() {
return [...document.querySelectorAll('#wallAdsList .drip-step')].map(c => ({ name: c.querySelector('.wa-name').value.trim(), targetUrl: c.querySelector('.wa-target').value.trim(), bannerUrl: c.querySelector('.wa-banner').value.trim() }));
}
async function loadWallAds() {
try { const r = await api('/api/admin/wall-ads'); wallAds = r.ads || []; $('wallAdsSub').textContent = r.usingDefaults ? 'none set: walls show the default InstantAdPay card' : wallAds.length + ' in rotation'; drawWallAds(); } catch (e) {}
}
$('wallAdsList').addEventListener('click', async e => {
const up = e.target.closest('.wa-upload');
if (up) { up.parentElement.querySelector('.wa-file').click(); return; }
const b = e.target.closest('[data-wact]'); if (!b) return;
const i = Number(b.closest('.drip-step').dataset.i); wallAds = readWallAds();
if (b.dataset.wact === 'remove') wallAds.splice(i, 1);
if (b.dataset.wact === 'up' && i > 0) [wallAds[i - 1], wallAds[i]] = [wallAds[i], wallAds[i - 1]];
if (b.dataset.wact === 'down' && i < wallAds.length - 1) [wallAds[i + 1], wallAds[i]] = [wallAds[i], wallAds[i + 1]];
drawWallAds();
});
$('wallAdsList').addEventListener('change', async e => {
const f = e.target.closest('.wa-file'); if (!f || !f.files[0]) return;
const file = f.files[0]; const card = f.closest('.drip-step');
try {
const r = await (await fetch('/api/admin/upload', { method: 'POST', headers: { 'Content-Type': file.type }, body: file })).json();
if (r.error) IAP.status(r.error, 'bad'); else { card.querySelector('.wa-banner').value = r.url; IAP.status('Uploaded.', 'ok'); }
} catch (err) { IAP.status('Upload failed.', 'bad'); }
f.value = '';
});
$('wallAdsAdd').addEventListener('click', () => { wallAds = readWallAds(); wallAds.push({ name: '', targetUrl: '', bannerUrl: '' }); drawWallAds(); });
$('wallAdsSave').addEventListener('click', busy($('wallAdsSave'), async () => {
$('wallAdsErr').hidden = true;
try { const r = await api('/api/admin/wall-ads', { ads: readWallAds() }, 'PATCH'); wallAds = r.ads || []; drawWallAds(); IAP.status('Wall ads saved.', 'ok'); await loadWallAds(); }
catch (e) { $('wallAdsErr').textContent = e.message; $('wallAdsErr').hidden = false; }
}));
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 loadTank() {
try {
const r = await (await fetch('/api/admin/tank')).json(); if (r.error) return;
$('tankAdmSub').textContent = r.waiting.length + ' waiting · cap ' + r.cap + ' open per adopter · ' + r.ttlDays + '-day window';
$('tankWait').innerHTML = '<tr><th>Waiting</th><th>Email</th><th>Joined</th><th>Last sign-in</th></tr>' + (r.waiting.length ? r.waiting.map(w => '<tr><td>' + esc(w.name) + '</td><td>' + esc(w.email) + '</td><td class="when">' + when(w.joined) + '</td><td class="when">' + (w.lastSeen ? when(w.lastSeen) : '<span class="muted">never</span>') + '</td></tr>').join('') : '<tr><td colspan="4" class="muted">empty</td></tr>');
$('tankAdopt').innerHTML = '<tr><th>Member</th><th>Adopted by</th><th>When</th><th>Window ends</th><th>Status</th></tr>' + (r.adoptions.length ? r.adoptions.map(a => '<tr><td>' + esc(a.adopteeName) + '</td><td>' + esc(a.adopterName) + '</td><td class="when">' + when(a.ts) + '</td><td class="when">' + (a.status === 'released' ? '' : when(a.expires)) + '</td><td>' + esc(a.status) + '</td></tr>').join('') : '<tr><td colspan="5" class="muted">none yet</td></tr>');
} catch (e) {}
}
async function loadMembers() {
loadTank();
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>Positions</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>' + (a.sponsorName ? esc(a.sponsorName) + (a.sponsorVia === 'code' ? ' <span class="muted small" title="joined through this share code">via code ' + esc(a.sponsorRef) + '</span>' : a.sponsorVia === 'member #' ? ' <span class="muted small">via #' + esc(a.sponsorRef) + '</span>' : '') : a.sponsorRef ? '<span class="badge amber" title="this token points at nobody; the member will move to the holding tank">dead link: ' + esc(a.sponsorRef) + '</span>' : '<span class="muted">none</span>') + '</td><td class="small" title="linked Qualified Start positions' + (a.positionIds && a.positionIds.length ? ': #' + a.positionIds.join(', #') : '') + '">' + (a.positions ? a.positions : '<span class="muted">0</span>') + '</td><td class="small muted">' + esc(a.joinedVia || '') + '</td><td class="mono small">' + esc(a.code || '') + '</td>'
+ '<td class="small muted when">' + when(a.created) + '</td>'
+ '<td class="act"><button class="btn small sec" data-mcopen="' + esc(a.email) + '">Open</button> <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 = await IAP.ask({ title: 'Sponsor for ' + b.dataset.spon, text: 'Username, share code, or member #. Leave blank to clear.', value: b.dataset.cur, ok: 'Save' });
if (v === null || v === undefined) 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'); }
});
// ── member card: search, drill down, act (Marty, 2026-09-13) ──
let mcCur = null;
const polOf = w => { try { return (Number(BigInt(w || '0') / 10n ** 14n) / 10000).toLocaleString(undefined, { maximumFractionDigits: 2 }); } catch (e) { return '0'; } };
const ago = ts => { if (!ts) return 'never'; const d = Date.now() - Number(ts); const h = Math.floor(d / 3600000); return h < 1 ? Math.max(1, Math.floor(d / 60000)) + ' min ago' : h < 48 ? h + ' h ago' : Math.floor(h / 24) + ' days ago'; };
const memLink = (email, label) => '<a href="#" data-mcopen="' + esc(email) + '">' + esc(label) + '</a>';
async function openMember(q) {
const msg = $('memSearchMsg'); msg.hidden = true;
let d;
try { d = await api('/api/admin/member?q=' + encodeURIComponent(q)); } catch (e) { msg.textContent = e.message; msg.hidden = false; msg.className = 'small bad'; return; }
renderMember(d);
}
function kv(rows) { return '<table class="adm-table kv">' + rows.map(r => '<tr><th style="width:170px">' + r[0] + '</th><td>' + r[1] + '</td></tr>').join('') + '</table>'; }
function renderMember(d) {
mcCur = d; const a = d.account; $('memHits').hidden = true;
$('memCard').hidden = false; document.querySelectorAll('#pane-members > .card').forEach(c => { if (c.id !== 'memCard' && c.id !== 'memSearchCard') c.hidden = true; });
$('mcName').textContent = (a.username ? '@' + a.username : a.email) + (a.memberId ? ' · member #' + a.memberId : ' · free member');
$('mcSub').textContent = 'joined ' + when(a.created) + ' · last seen ' + ago(a.lastSeen);
$('mcWall').hidden = !a.username; if (a.username) $('mcWall').href = '/wall/' + a.username;
const ch = d.chain, cr = d.credits, t = d.totals;
const level = ch && !ch.readError ? (ch.buyerCount >= 5 ? 'level 3 (5+ buyers)' : ch.buyerCount >= 2 ? 'level 2 (2 buyers)' : 'level 1') : '';
let h = '<div class="grid c2">';
h += '<div><h4 style="margin:0 0 6px">Identity</h4>' + kv([
['Email', esc(a.email)], ['Username', a.username ? '@' + esc(a.username) : '<span class="muted">not set</span>'], ['Share code', esc(a.code || '')],
['Main wallet', a.address ? '<span class="mono small">' + esc(a.address) + '</span>' : '<span class="muted">none linked</span>'],
['Extra positions', d.positions.length ? d.positions.map(p => '<span class="mono small">' + esc(p.address.slice(0, 8) + '…' + p.address.slice(-6)) + '</span>' + (p.memberId ? ' = #' + p.memberId : ' (unregistered)')).join('<br>') : '<span class="muted">none</span>'],
['Sponsor (site)', d.upline.length ? memLink(d.upline[0].email, d.upline[0].name) + ' <span class="muted small">token ' + esc(a.sponsorRef || '') + '</span>' : (a.sponsorRef ? '<span class="muted">unresolved: ' + esc(a.sponsorRef) + '</span>' : '<span class="muted">none (company)</span>')],
['Upline chain', d.upline.length > 1 ? d.upline.map(u => memLink(u.email, u.name)).join(' &rarr; ') : '<span class="muted">-</span>'],
['Joined via', esc(a.joinedVia || 'join page') + (a.joinedRef ? ' from ' + esc(a.joinedRef) : '')],
['Line banner', a.lineTargetUrl ? '<a href="' + esc(a.lineTargetUrl) + '" target="_blank" rel="noopener">' + esc(a.lineTargetUrl.slice(0, 50)) + '</a>' : '<span class="muted">not set</span>'],
['Chat', a.chatAvailable ? 'available' : 'switched off']]) + '</div>';
h += '<div><h4 style="margin:0 0 6px">On-chain and money</h4>' + kv([
['Registered', ch ? (ch.readError ? 'read error' : 'yes, #' + ch.memberId + ' under ' + (ch.sponsorId ? '#' + ch.sponsorId + (d.names[ch.sponsorId] ? ' @' + esc(d.names[ch.sponsorId]) : '') : 'nobody')) : '<span class="muted">no (payouts off)</span>'],
['Qualifying buyers', ch && !ch.readError ? ch.buyerCount + ' · ' + level : '-'],
['Packages bought', t.purchases + (t.purchases ? ' · $' + (t.spentCents / 100).toFixed(0) + ' · ' + polOf(t.spentWei) + ' POL' : '')],
['Payouts received', t.payoutsIn + (t.payoutsIn ? ' · ' + polOf(t.receivedWei) + ' POL' : '')],
['Credits', cr ? cr.available.toLocaleString() + ' available · ' + cr.inCampaigns.toLocaleString() + ' in campaigns · ' + cr.total.toLocaleString() + ' total' : '<span class="muted">-</span>'],
['Earned pool', d.earnedSplit ? d.earnedSplit.total.toLocaleString() + ' (' + (d.earnedSplit.grade || 0).toLocaleString() + ' purchased-grade)' : '-'],
['Old-site account', d.legacy ? 'had a ' + (d.legacy.brand === 'both' ? 'Faucet Wave and Tier One Ads' : d.legacy.brand === 'tier1ads' ? 'Tier One Ads' : 'Faucet Wave') + ' account (' + d.legacy.seg + ') · welcome-back credits ' + (d.legacy.grant ? d.legacy.grant.credits + ' issued ' + when(d.legacy.grant.at) : 'not issued (joined outside the legacy bridge)') : '<span class="muted">none on record</span>'],
['Promo codes', d.promos.length ? d.promos.map(p => esc(p.code) + ' (' + p.credits + ', ' + when(p.ts) + ')').join('<br>') : '<span class="muted">none</span>'],
['Drip', d.drip ? (d.drip.stopped ? 'stopped' : 'step ' + d.drip.step + ', next ' + when(d.drip.next_at)) + (d.drip.angle ? ' · ' + esc(d.drip.angle) : '') : '<span class="muted">-</span>'],
['Holding tank', d.tank ? (d.tank.waiting ? '<b>waiting for a sponsor</b>' : 'not in tank') + (d.tank.adoptedBy.length ? ' · adopted by ' + d.tank.adoptedBy.map(x => memLink(x.email, x.name)).join(', ') : '') + (d.tank.adopted.length ? ' · adopted ' + d.tank.adopted.map(x => memLink(x.email, x.name)).join(', ') : '') : '-'],
['Earning', d.earning ? 'today ' + d.earning.today + '/5' + (d.earning.claimed ? ' claimed' : '') + ' · streak day ' + d.earning.streakDay + (d.activeDays14 !== undefined ? ' · active ' + d.activeDays14 + ' of last 14 days, ' + d.claims14 + ' claims' : '') : '-'],
['Visits / videos / chat', (d.visits || 0) + ' verified visits · ' + (d.videos || 0) + ' video watches · ' + (d.messageCount || 0) + ' messages']]) + '</div></div>';
// line
h += '<h4 style="margin:18px 0 6px">Line (' + d.lineCounts.join(' / ') + ')</h4>';
if (!d.line.length) h += '<p class="muted small">Nobody in their line yet.</p>';
for (const L of d.line) {
h += '<p class="small muted" style="margin:8px 0 4px">Level ' + L.level + ' · ' + L.members.length + '</p><div class="tablewrap"><table class="adm-table"><tr><th>Member</th><th>Member #</th><th>Wallet</th><th>Bought</th><th>Qualified</th><th>Joined</th><th>Last seen</th></tr>'
+ L.members.map(m => '<tr><td>' + memLink(m.email, m.name) + (L.level === 1 ? '<br><span class="muted small">' + esc(m.email) + '</span>' : '') + '</td><td>' + (m.memberId ? '#' + m.memberId : '<span class="muted">free</span>') + '</td><td>' + (m.wallet ? 'yes' : '<span class="muted">no</span>') + '</td><td>' + (m.bought ? 'yes' : '<span class="muted">no</span>') + '</td><td>' + (m.qualified ? '<span class="chip-t on">yes</span>' : '') + '</td><td class="small muted">' + when(m.joined) + '</td><td class="small muted">' + ago(m.lastSeen) + '</td></tr>').join('') + '</table></div>';
}
// purchases + payouts + campaigns
h += '<div class="grid c2" style="margin-top:18px"><div><h4 style="margin:0 0 6px">Purchases</h4><div class="tablewrap"><table class="adm-table"><tr><th>When</th><th>Position</th><th>Package</th><th>Paid</th><th>Tx</th></tr>'
+ (d.purchases.length ? d.purchases.map(p => '<tr><td class="small">' + when(p.ts) + '</td><td>#' + p.buyerId + '</td><td>$' + (p.priceCents / 100).toFixed(0) + ' · ' + Number(p.credits || 0).toLocaleString() + ' cr</td><td>' + polOf(p.paidWei) + ' POL</td><td><a href="/tx/' + esc(p.tx) + '" target="_blank" rel="noopener" class="mono small">' + esc(p.tx.slice(0, 10)) + '…</a></td></tr>').join('') : '<tr><td colspan="5" class="muted">No purchases.</td></tr>') + '</table></div></div>';
h += '<div><h4 style="margin:0 0 6px">Payouts received</h4><div class="tablewrap"><table class="adm-table"><tr><th>When</th><th>From</th><th>Tier</th><th>Amount</th></tr>'
+ (d.received.length ? d.received.map(r => '<tr><td class="small">' + when(r.ts) + '</td><td>#' + r.buyerId + (d.names[r.buyerId] ? ' @' + esc(d.names[r.buyerId]) : '') + '</td><td>' + r.tier + '</td><td>' + polOf(r.amountWei) + ' POL</td></tr>').join('') : '<tr><td colspan="4" class="muted">Nothing received yet.</td></tr>') + '</table></div></div></div>';
h += '<h4 style="margin:18px 0 6px">Campaigns (' + d.campaigns.length + ')</h4><div class="tablewrap"><table class="adm-table"><tr><th>#</th><th>Type</th><th>Status</th><th>Budget</th><th>Spent</th><th>Views</th><th>Clicks</th><th>Created</th></tr>'
+ (d.campaigns.length ? d.campaigns.map(c => '<tr><td>' + c.id + '</td><td>' + esc(c.type) + '</td><td>' + esc(c.status) + '</td><td>' + Number(c.budget || 0).toLocaleString() + '</td><td>' + Number(c.spent || 0).toLocaleString() + '</td><td>' + Number(c.views || 0).toLocaleString() + '</td><td>' + Number(c.clicks || 0).toLocaleString() + '</td><td class="small muted">' + when(c.created) + '</td></tr>').join('') : '<tr><td colspan="8" class="muted">No campaigns.</td></tr>') + '</table></div>';
$('mcBody').innerHTML = h;
if (location.hash !== '#members') history.replaceState(null, '', '#members');
}
function closeMember() { $('memCard').hidden = true; document.querySelectorAll('#pane-members > .card').forEach(c => { c.hidden = false; }); }
// live matches while typing: any part of the username, email, member #, share code or wallet
let memHitList = [];
function memMatches(q) {
q = q.toLowerCase();
return allMembers.filter(a => [a.username, a.email, a.memberId ? '#' + a.memberId : '', a.memberId, a.code, a.address, a.sponsorName].filter(Boolean).join(' ').toLowerCase().includes(q)).slice(0, 12);
}
async function memTypeahead() {
const q = $('memSearch').value.trim();
if (!allMembers.length) { try { const r = await api('/api/admin/members'); allMembers = r.members || []; } catch (e) {} }
if (q.length < 2) { $('memHits').hidden = true; memHitList = []; return; }
memHitList = memMatches(q);
$('memHits').innerHTML = memHitList.length ? memHitList.map(a => '<button type="button" data-mcopen="' + esc(a.email) + '" style="display:flex;gap:12px;width:100%;text-align:left;background:transparent;border:0;border-bottom:1px solid var(--line);padding:8px 12px;color:inherit;cursor:pointer;font:inherit"><b style="min-width:140px">' + (a.username ? '@' + esc(a.username) : '<span class="muted">no username</span>') + '</b><span>' + esc(a.email) + '</span><span class="muted">' + (a.memberId ? '#' + a.memberId : 'free') + (a.sponsorName ? ' · under ' + esc(a.sponsorName) : '') + '</span></button>').join('')
: '<p class="muted small" style="margin:0;padding:8px 12px">No member matches that.</p>';
$('memHits').hidden = false;
}
$('memSearch').addEventListener('input', memTypeahead);
$('memSearch').addEventListener('focus', memTypeahead);
$('memOpen').addEventListener('click', () => { const q = $('memSearch').value.trim(); if (!q) return; if (memHitList.length) openMember(memHitList[0].email); else openMember(q); });
$('memSearch').addEventListener('keydown', e => { if (e.key === 'Enter') $('memOpen').click(); if (e.key === 'Escape') $('memHits').hidden = true; });
document.addEventListener('click', e => { if (!e.target.closest('#memSearchCard')) $('memHits').hidden = true; });
$('mcBack').addEventListener('click', closeMember);
document.addEventListener('click', e => { const l = e.target.closest('[data-mcopen]'); if (l) { e.preventDefault(); openMember(l.dataset.mcopen); } });
document.querySelectorAll('[data-mcact]').forEach(b => b.addEventListener('click', busy(b, async () => {
if (!mcCur) return; const a = mcCur.account, act = b.dataset.mcact; let body = null;
if (act === 'username') { const v = await IAP.ask({ title: 'Username for ' + a.email, text: '3-20 letters, numbers or underscore. Changing it breaks any invite links they already handed out.', value: a.username || '', ok: 'Save' }); if (v === null || v === undefined) return; body = { username: v }; }
if (act === 'sponsor') { const v = await IAP.ask({ title: 'Sponsor for ' + (a.username ? '@' + a.username : a.email), text: 'Username, share code or member #. Blank = no sponsor (company). Re-points free referrals and future purchases; on-chain sponsorship never changes.', value: a.sponsorRef || '', ok: 'Save' }); if (v === null || v === undefined) return; body = { sponsorRef: v }; }
if (act === 'wallet') { const v = await IAP.ask({ title: 'Main wallet for ' + a.email, text: 'Paste the 0x address that should be their main wallet (the one that paid, if a purchase came from an unlinked account). The member number is re-read from the chain. Blank = unlink.', value: a.address || '', ok: 'Swap' }); if (v === null || v === undefined) return; if (!await IAP.confirmBox('Swap the main wallet for ' + a.email + ' to ' + (v.trim() || 'nothing') + '?', { title: 'Sure?', ok: 'Swap it', cancel: 'Cancel' })) return; body = { address: v }; }
if (act === 'credits') { const v = await IAP.ask({ title: 'Grant credits to ' + (a.username ? '@' + a.username : a.email), text: 'Whole number of earned-pool credits (1 credit = 1 cent of delivery). They can spend them on campaigns right away.', type: 'number', value: '', placeholder: '250', ok: 'Grant' }); if (!v) return; const note = await IAP.ask({ title: 'Reason (kept in the server log)', value: '', placeholder: 'e.g. refund for broken banner', ok: 'Grant' }); body = { grantCredits: v, note: note || '' }; }
if (act === 'delete') {
if (a.memberId) { IAP.status('Registered members cannot be deleted; their position is on-chain.', 'bad'); return; }
if (!await IAP.confirmBox('Delete the free account ' + a.email + '? Their sign-in, referrals link and credits go away. There is no undo.', { title: 'Delete account', ok: 'Delete', cancel: 'Keep it' })) return;
await api('/api/admin/member?email=' + encodeURIComponent(a.email), undefined, 'DELETE'); IAP.status('Account deleted.', 'ok'); closeMember(); loadMembers().catch(() => {}); return;
}
if (!body) return;
const d = await api('/api/admin/member', Object.assign({ email: a.email }, body), 'PATCH');
renderMember(d); IAP.status('Saved.', 'ok'); loadMembers().catch(() => {});
})));
// ── every admin table: click a header to sort (numbers sort as numbers), inputs with
// class "tfilter" filter the table named in data-for ──
document.addEventListener('click', e => {
const th = e.target.closest('.adm-table th'); if (!th || th.closest('table').classList.contains('kv')) return;
const table = th.closest('table'), hdr = th.parentElement, idx = [...hdr.children].indexOf(th);
const rows = [...table.querySelectorAll('tr')].filter(r => r !== hdr && r.children.length > 1);
const num = s => { const t = String(s).replace(/[$,%\s]/g, '').replace(/…$/, ''); return t !== '' && !isNaN(t) ? Number(t) : null; };
const dir = th.dataset.dir === 'asc' ? 'desc' : 'asc';
hdr.querySelectorAll('th').forEach(x => { delete x.dataset.dir; x.classList.remove('sort-asc', 'sort-desc'); });
th.dataset.dir = dir; th.classList.add('sort-' + dir);
rows.sort((r1, r2) => { const a = (r1.children[idx] || {}).textContent || '', b = (r2.children[idx] || {}).textContent || ''; const na = num(a), nb = num(b); const c = na !== null && nb !== null ? na - nb : a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' }); return dir === 'asc' ? c : -c; });
rows.forEach(r => (hdr.parentElement).appendChild(r));
});
document.addEventListener('input', e => {
const inp = e.target.closest('.tfilter'); if (!inp) return;
const table = $(inp.dataset.for); if (!table) return;
const q = inp.value.trim().toLowerCase(); let shown = 0;
[...table.querySelectorAll('tr')].forEach((r, i) => { if (i === 0 || r.querySelector('th')) return; const hit = !q || r.textContent.toLowerCase().includes(q); r.hidden = !hit; if (hit) shown++; });
const c = inp.parentElement.querySelector('.tfilter-count'); if (c) c.textContent = q ? shown + ' shown' : '';
});
// ── release notes + roadmap (Marty, 2026-09-14) ──
async function loadReleases() {
const d = await api('/api/admin/releases');
$('rnSub').textContent = d.notes.length + ' notes'; $('rmSub').textContent = d.roadmap.length + ' items';
$('rnTable').innerHTML = '<tr><th>Date</th><th>Title</th><th>Tags</th><th></th></tr>' + (d.notes.length ? d.notes.map(n => '<tr><td class="small">' + esc(n.date) + '</td><td><b>' + esc(n.title) + '</b></td><td class="small">' + esc(n.tags.join(', ')) + '</td><td class="act"><button type="button" class="btn small sec" data-rnedit="' + esc(n.id) + '">Edit</button> <button type="button" class="btn small sec" data-rndel="' + esc(n.id) + '">Delete</button></td></tr>').join('') : '<tr><td colspan="4" class="muted">No notes yet.</td></tr>');
$('rmTable').innerHTML = '<tr><th>Status</th><th>Title</th><th>ETA</th><th>#</th><th></th></tr>' + (d.roadmap.length ? d.roadmap.map(r => '<tr><td class="small">' + esc(r.status) + '</td><td><b>' + esc(r.title) + '</b>' + (r.note ? '<br><span class="muted small">' + esc(r.note) + '</span>' : '') + '</td><td class="small">' + esc(r.eta || '') + '</td><td class="small">' + (r.order || '') + '</td><td class="act"><button type="button" class="btn small sec" data-rmedit="' + esc(r.id) + '">Edit</button> <button type="button" class="btn small sec" data-rmdel="' + esc(r.id) + '">Delete</button></td></tr>').join('') : '<tr><td colspan="5" class="muted">Nothing on the roadmap yet.</td></tr>');
$('rnTable').querySelectorAll('[data-rnedit]').forEach(b => b.addEventListener('click', () => { const n = d.notes.find(x => x.id === b.dataset.rnedit); if (!n) return; $('rnId').value = n.id; $('rnTitle').value = n.title; $('rnDate').value = n.date; $('rnTags').value = n.tags.join(', '); $('rnBody').value = n.body; $('rnTitle').focus(); }));
$('rmTable').querySelectorAll('[data-rmedit]').forEach(b => b.addEventListener('click', () => { const r = d.roadmap.find(x => x.id === b.dataset.rmedit); if (!r) return; $('rmId').value = r.id; $('rmTitle').value = r.title; $('rmStatus').value = r.status; $('rmEta').value = r.eta || ''; $('rmOrder').value = r.order || ''; $('rmNote').value = r.note || ''; $('rmTitle').focus(); }));
$('rnTable').querySelectorAll('[data-rndel]').forEach(b => b.addEventListener('click', async () => { if (!await IAP.confirmBox('Delete this release note?', { ok: 'Delete', cancel: 'Keep' })) return; await api('/api/admin/releases?kind=notes&id=' + encodeURIComponent(b.dataset.rndel), undefined, 'DELETE'); loadReleases(); }));
$('rmTable').querySelectorAll('[data-rmdel]').forEach(b => b.addEventListener('click', async () => { if (!await IAP.confirmBox('Delete this roadmap item?', { ok: 'Delete', cancel: 'Keep' })) return; await api('/api/admin/releases?kind=roadmap&id=' + encodeURIComponent(b.dataset.rmdel), undefined, 'DELETE'); loadReleases(); }));
}
$('rnClear').addEventListener('click', () => { ['rnId', 'rnTitle', 'rnDate', 'rnTags', 'rnBody'].forEach(id => { $(id).value = ''; }); });
$('rmClear').addEventListener('click', () => { ['rmId', 'rmTitle', 'rmEta', 'rmOrder', 'rmNote'].forEach(id => { $(id).value = ''; }); $('rmStatus').value = 'planned'; });
$('rnSave').addEventListener('click', busy($('rnSave'), async () => { await api('/api/admin/releases', { kind: 'note', id: $('rnId').value || null, title: $('rnTitle').value, date: $('rnDate').value, tags: $('rnTags').value, body: $('rnBody').value }); IAP.status('Note saved.', 'ok'); $('rnClear').click(); loadReleases(); }));
$('rmSave').addEventListener('click', busy($('rmSave'), async () => { await api('/api/admin/releases', { kind: 'roadmap', id: $('rmId').value || null, title: $('rmTitle').value, status: $('rmStatus').value, eta: $('rmEta').value, order: $('rmOrder').value, note: $('rmNote').value }); IAP.status('Roadmap item saved.', 'ok'); $('rmClear').click(); loadReleases(); }));
// ── reports + burns ──
// ── profit and loss ──
let pnlDays = 30;
const pol = w => { try { return (Number(BigInt(w || '0') / 10n ** 14n) / 10000).toLocaleString(undefined, { maximumFractionDigits: 2 }); } catch (e) { return '0'; } };
const usdOf = (w, px) => { try { return '$' + ((Number(BigInt(w || '0') / 10n ** 14n) / 10000) * px).toLocaleString(undefined, { maximumFractionDigits: 0 }); } catch (e) { return '$0'; } };
// ── traffic: referring domains / sources, landing pages, angles, by day ──
let trfDays = 30;
document.querySelectorAll('#trfRange [data-days]').forEach(b => b.addEventListener('click', () => { trfDays = Number(b.dataset.days); document.querySelectorAll('#trfRange [data-days]').forEach(x => x.classList.toggle('on', x === b)); loadTraffic().catch(e => IAP.status(e.message, 'bad')); }));
async function loadPromos() {
const d = await (await fetch('/api/admin/promos')).json();
if (d.error) throw new Error(d.error);
const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
const when = t => t ? new Date(t).toLocaleDateString() : '';
$('pcTable').innerHTML = '<tr><th>Code</th><th>Credits</th><th>Partner</th><th>Uses</th><th>Max</th><th>Expires</th><th>Status</th><th></th></tr>'
+ (d.codes.length ? d.codes.map(c => '<tr><td><b>' + esc(c.code) + '</b></td><td>' + c.credits.toLocaleString() + '</td><td>' + esc(c.partner) + '</td><td>' + c.uses + '</td><td>' + (c.maxUses || '∞') + '</td><td>' + (c.expires ? when(c.expires) : '') + '</td><td>' + (c.active ? 'active' : 'off') + '</td><td class="act"><button type="button" class="btn small ghost" data-pctoggle="' + esc(c.code) + '" data-on="' + (c.active ? 0 : 1) + '">' + (c.active ? 'Switch off' : 'Switch on') + '</button></td></tr>').join('') : '<tr><td colspan="8" class="muted">No codes yet.</td></tr>');
$('pcRecent').innerHTML = '<tr><th>When</th><th>Code</th><th>Email</th><th>Credits</th><th>Via</th></tr>'
+ (d.recent.length ? d.recent.map(r => '<tr><td>' + new Date(r.ts).toLocaleString() + '</td><td>' + esc(r.code) + '</td><td>' + esc(r.email) + '</td><td>' + r.credits + '</td><td>' + esc(r.via) + '</td></tr>').join('') : '<tr><td colspan="5" class="muted">No redemptions yet.</td></tr>');
document.querySelectorAll('[data-pctoggle]').forEach(b => b.addEventListener('click', async () => {
try { await api('/api/admin/promos', { code: b.dataset.pctoggle, active: b.dataset.on === '1' }, 'PATCH'); loadPromos(); } catch (e) { IAP.status(e.message, 'bad'); }
}));
}
if ($('pcSave')) $('pcSave').addEventListener('click', async () => {
const msg = $('pcMsg'); msg.hidden = false;
try {
const r = await api('/api/admin/promos', { code: $('pcCode').value, credits: $('pcCredits').value, partner: $('pcPartner').value, maxUses: $('pcMax').value, expires: $('pcExpires').value || null, active: true });
msg.textContent = 'Saved ' + r.code.code + ': ' + r.code.credits + ' credits.'; msg.style.color = 'var(--mint)';
$('pcCode').value = ''; $('pcCredits').value = ''; $('pcPartner').value = ''; loadPromos();
} catch (e) { msg.textContent = e.message; msg.style.color = '#ff8a8a'; }
});
async function loadTraffic() {
loadPromos().catch(e => IAP.status(e.message, 'bad'));
const d = await (await fetch('/api/admin/traffic?days=' + trfDays)).json();
if (d.error) throw new Error(d.error);
const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
const n = v => Number(v || 0).toLocaleString();
$('trfSub').textContent = 'last ' + d.days + ' days · ' + n(d.totals.hits) + ' page views · ' + n(d.totals.joinViews) + ' join-page views · ' + n(d.totals.signups) + ' signups · ' + n(d.totals.buyers) + ' buyers';
// conversion columns (Marty, 2026-09-13): visits = page views + join-page views; signup rate is per visit,
// registered and buyer rates are per signup (what happened to the people who did sign up)
const pct = (num, den) => den ? (100 * num / den).toFixed(num && 100 * num / den < 10 ? 1 : 0) + '%' : '<span class="muted">-</span>';
$('trfSources').innerHTML = '<tr><th>Source</th><th>Page<br>views</th><th>Join-page<br>views</th><th>Signups</th><th>Visit &rarr;<br>signup</th><th>Registered</th><th>Signup &rarr;<br>registered</th><th>$20+<br>buyers</th><th>Signup &rarr;<br>buyer</th></tr>'
+ (d.sources.length ? d.sources.map(s => '<tr><td>' + esc(s.source) + '</td><td>' + n(s.hits) + '</td><td>' + n(s.joinViews) + '</td><td>' + n(s.signups) + '</td><td>' + pct(s.signups, s.hits + s.joinViews) + '</td><td>' + n(s.registered) + '</td><td>' + pct(s.registered, s.signups) + '</td><td>' + n(s.buyers) + '</td><td>' + pct(s.buyers, s.signups) + '</td></tr>').join('') : '<tr><td colspan="9" class="muted">Nothing recorded in this range yet.</td></tr>');
$('trfPaths').innerHTML = '<tr><th>Page</th><th>Views</th></tr>' + (d.paths.length ? d.paths.map(p => '<tr><td>' + esc(p.path) + '</td><td>' + n(p.hits) + '</td></tr>').join('') : '<tr><td colspan="2" class="muted">No page views yet.</td></tr>');
$('trfAngles').innerHTML = '<tr><th>Angle</th><th>Join-page<br>views</th><th>Signups</th><th>View &rarr;<br>signup</th></tr>' + (d.angles.length ? d.angles.map(a => '<tr><td>' + esc(a.angle) + '</td><td>' + n(a.views) + '</td><td>' + n(a.signups) + '</td><td>' + pct(a.signups, a.views) + '</td></tr>').join('') : '<tr><td colspan="4" class="muted">No angle data yet.</td></tr>');
$('trfDaily').innerHTML = '<tr><th>Day</th><th>Page views</th><th>Signups</th></tr>' + (d.daily.length ? d.daily.slice().reverse().map(x => '<tr><td>' + esc(x.day) + '</td><td>' + n(x.hits) + '</td><td>' + n(x.signups) + '</td></tr>').join('') : '<tr><td colspan="3" class="muted">Nothing yet.</td></tr>');
}
// ── blog: coaching articles, public at /blog (Marty, 2026-09-12) ──
let blCur = null; // slug being edited, or null for a new one
function blCount() {
const t = $('blTitle').value.length, e = $('blExcerpt').value.length;
$('blTitleCount').textContent = t + '/60' + (t > 60 ? ' (long)' : '');
$('blExcCount').textContent = e + ' chars' + (e && (e < 120 || e > 160) ? ' (aim 120-160)' : '');
const w = $('blBody').textContent.trim().split(/\s+/).filter(Boolean).length;
$('blWords').textContent = w + ' words';
}
['blTitle', 'blExcerpt'].forEach(id => $(id).addEventListener('input', blCount));
$('blBody').addEventListener('input', blCount);
$('blTitle').addEventListener('input', () => { if (!blCur && !$('blSlug').dataset.touched) $('blSlug').value = $('blTitle').value.toLowerCase().replace(/['’]/g, '').replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 80); });
$('blSlug').addEventListener('input', () => { $('blSlug').dataset.touched = '1'; });
document.querySelectorAll('.ed-bar [data-bl]').forEach(b => b.addEventListener('click', () => { $('blBody').focus(); document.execCommand(b.dataset.bl, false, null); }));
document.querySelectorAll('.ed-bar [data-blblock]').forEach(b => b.addEventListener('click', () => { $('blBody').focus(); document.execCommand('formatBlock', false, b.dataset.blblock); }));
$('blLinkBtn').addEventListener('click', async () => {
const sel = window.getSelection(); const range = sel && sel.rangeCount ? sel.getRangeAt(0).cloneRange() : null;
const u = await IAP.ask({ title: 'Link address', label: 'https://', placeholder: 'https://instantadpay.com/join/martbost', ok: 'Insert' });
if (u) { $('blBody').focus(); if (range) { sel.removeAllRanges(); sel.addRange(range); } document.execCommand('createLink', false, u); }
});
$('blImgBtn').addEventListener('click', () => $('blImgFile').click());
$('blImgFile').addEventListener('change', async () => {
const f = $('blImgFile').files[0]; if (!f) return;
try {
const r = await (await fetch('/api/admin/upload', { method: 'POST', headers: { 'Content-Type': f.type }, body: f })).json();
if (r.error) throw new Error(r.error);
$('blBody').focus();
const html = '<img src="' + r.url + '" alt="">';
if (!document.execCommand('insertHTML', false, html)) $('blBody').insertAdjacentHTML('beforeend', html);
blCount();
} catch (e) { IAP.status(e.message || 'Upload failed.', 'bad'); }
$('blImgFile').value = '';
});
$('blCoverBtn').addEventListener('click', () => $('blCoverFile').click());
$('blCoverFile').addEventListener('change', () => upload($('blCoverFile'), $('blCoverInfo'), $('blCover')));
$('blHtmlBtn').addEventListener('click', () => {
const raw = !$('blHtml').hidden;
if (raw) { $('blBody').innerHTML = $('blHtml').value; $('blHtml').hidden = true; $('blBody').hidden = false; }
else { $('blHtml').value = $('blBody').innerHTML; $('blBody').hidden = true; $('blHtml').hidden = false; }
blCount();
});
function blBodyHtml() { return $('blHtml').hidden ? $('blBody').innerHTML : $('blHtml').value; }
function blMsg(t, bad) { $('blMsg').textContent = t; $('blMsg').hidden = !t; $('blMsg').className = 'small ' + (bad ? 'bad' : 'ok'); }
function blOpen(post) {
blCur = post ? post.slug : null;
$('blogList').hidden = true; $('blogEditor').hidden = false;
$('blEdTitle').textContent = post ? 'Edit article' : 'New article';
$('blEdSub').textContent = post ? (post.status === 'published' ? 'published ' + when(post.publishedAt) + ' · ' + (post.views || 0) + ' views' : 'draft') : '';
$('blTitle').value = post ? post.title : ''; $('blSlug').value = post ? post.slug : ''; delete $('blSlug').dataset.touched;
$('blTags').value = post ? post.tags.join(', ') : ''; $('blExcerpt').value = post ? post.excerpt : ''; $('blCover').value = post ? post.cover : ''; $('blCoverInfo').textContent = '';
$('blHtml').hidden = true; $('blBody').hidden = false; $('blBody').innerHTML = post ? post.body : '';
$('blUnpublish').hidden = !(post && post.status === 'published'); $('blDelete').hidden = !post;
$('blPreview').hidden = !post; if (post) $('blPreview').href = '/blog/' + post.slug;
$('blPublish').textContent = post && post.status === 'published' ? 'Save and publish' : 'Publish';
blMsg(''); blCount(); $('blTitle').focus();
}
async function blSave(status) {
const body = { existingSlug: blCur, title: $('blTitle').value, slug: $('blSlug').value, tags: $('blTags').value, excerpt: $('blExcerpt').value, cover: $('blCover').value, body: blBodyHtml(), status };
const r = await api('/api/admin/blog', body);
blCur = r.post.slug;
$('blSlug').value = r.post.slug; $('blPreview').hidden = false; $('blPreview').href = '/blog/' + r.post.slug; $('blDelete').hidden = false;
$('blUnpublish').hidden = r.post.status !== 'published'; $('blPublish').textContent = r.post.status === 'published' ? 'Save and publish' : 'Publish';
$('blEdTitle').textContent = 'Edit article';
blMsg(r.post.status === 'published' ? 'Published. Live at instantadpay.com/blog/' + r.post.slug + (r.syndicating ? ' · posting to X and Instagram now (see the Social column in the list).' : '') : 'Draft saved.');
IAP.status(r.post.status === 'published' ? 'Published.' : 'Draft saved.', 'ok');
}
$('blSaveDraft').addEventListener('click', busy($('blSaveDraft'), () => blSave('draft')));
$('blPublish').addEventListener('click', busy($('blPublish'), () => blSave('published')));
$('blUnpublish').addEventListener('click', busy($('blUnpublish'), () => blSave('draft')));
$('blClose').addEventListener('click', () => { $('blogEditor').hidden = true; $('blogList').hidden = false; loadBlog().catch(e => IAP.status(e.message, 'bad')); });
$('blDelete').addEventListener('click', busy($('blDelete'), async () => {
if (!blCur) return;
if (!await IAP.confirmBox('The page at /blog/' + blCur + ' stops existing. There is no undo.', { title: 'Delete this article?', ok: 'Delete', cancel: 'Keep it' })) return;
await api('/api/admin/blog?slug=' + encodeURIComponent(blCur), undefined, 'DELETE');
$('blClose').click();
}));
$('blNew').addEventListener('click', () => blOpen(null));
async function loadBlog() {
const d = await api('/api/admin/blog');
const pub = d.posts.filter(p => p.status === 'published').length;
$('blSub').textContent = pub + ' published · ' + (d.posts.length - pub) + ' drafts';
const synd = p => { const s = p.syndicated; if (!s) return p.status === 'published' ? '<span class="muted small">not posted</span>' : ''; const r = s.results || {}; const part = ['x', 'instagram'].map(k => r[k] ? (r[k].ok ? k + ' ✓' : k + ' ✗') : k + ' –').join(' · '); return '<span class="small' + (s.done ? '' : ' bad') + '" title="' + esc(Object.values(r).map(v => v.error || '').filter(Boolean).join(' | ') || (s.error || '')) + '">' + part + '</span>'; };
$('blSyndNote').hidden = false; $('blSyndNote').textContent = d.syndication ? 'Publishing an article posts it to X (@cryptoteambuild) and Instagram (marketingwithmarty) through Blotato, once per article, with the cover image.' : 'Social syndication is off: no Blotato key on the server.';
$('blTable').innerHTML = '<tr><th>Title</th><th>Status</th><th>Social</th><th>Tags</th><th>Views</th><th>Updated</th><th></th></tr>'
+ (d.posts.length ? d.posts.map(p => '<tr><td><b>' + esc(p.title) + '</b><br><span class="muted small">/blog/' + esc(p.slug) + '</span></td><td>' + (p.status === 'published' ? '<span class="chip-t on">published</span>' : '<span class="chip-t">draft</span>') + '</td><td>' + synd(p) + '</td><td>' + esc(p.tags.join(', ')) + '</td><td>' + (p.views || 0) + '</td><td>' + when(p.updated) + '</td><td class="act"><button type="button" class="btn small sec" data-bledit="' + esc(p.slug) + '">Edit</button> <a class="btn small sec" href="/blog/' + esc(p.slug) + '" target="_blank" rel="noopener">View</a>' + (p.status === 'published' && d.syndication && !(p.syndicated && p.syndicated.done) ? ' <button type="button" class="btn small sec" data-blsynd="' + esc(p.slug) + '">Post to X + IG</button>' : '') + '</td></tr>').join('')
: '<tr><td colspan="7" class="muted">No articles yet. Start with "New article".</td></tr>');
$('blTable').querySelectorAll('[data-blsynd]').forEach(b => b.addEventListener('click', busy(b, async () => {
const r = await api('/api/admin/blog/syndicate', { slug: b.dataset.blsynd });
const res = r.syndicated && r.syndicated.results || {}; const bad = Object.entries(res).filter(([, v]) => !v.ok).map(([k, v]) => k + ': ' + v.error);
IAP.status(bad.length ? 'Posted with problems: ' + bad.join(' | ') : 'Posted to X and Instagram.', bad.length ? 'bad' : 'ok'); loadBlog().catch(() => {});
})));
$('blTable').querySelectorAll('[data-bledit]').forEach(b => b.addEventListener('click', async () => {
try { const r = await api('/api/admin/blog?slug=' + encodeURIComponent(b.dataset.bledit)); blOpen(r.post); } catch (e) { IAP.status(e.message, 'bad'); }
}));
}
async function loadPnl() {
const r = await api('/api/admin/pnl?days=' + pnlDays);
const px = r.polUsd || 0;
const platUsd = (Number(BigInt(r.platformWei || '0') / 10n ** 14n) / 10000) * px;
const months = pnlDays ? pnlDays / 30 : Math.max(1, (r.latest - r.fromBlock) / 43200 / 30);
const fixed = (r.fixedMonthlyUsd || 0) * months;
$('pnlTiles').innerHTML = [
['Packages sold', r.purchases.count, Object.entries(r.purchases.byPackage || {}).map(([k, v]) => v + '×' + k).join(' · ') || '—'],
['Gross volume', pol(r.purchases.volumeWei) + ' POL', usdOf(r.purchases.volumeWei, px) + ' at today\'s rate · $' + (r.purchases.usdCents / 100).toLocaleString() + ' at sale'],
['Platform (fees + dust + unclaimed)', pol(r.platformWei) + ' POL', usdOf(r.platformWei, px)],
['Paid to members', pol(r.memberPayoutsWei) + ' POL', usdOf(r.memberPayoutsWei, px)],
['Net after fixed costs', '$' + Math.round(platUsd - fixed).toLocaleString(), 'fixed ' + Math.round(fixed).toLocaleString() + ' over ' + months.toFixed(1) + ' month(s)'],
['Pass-ups', r.passedUp.count, r.passedUp.unqualified + ' unqualified · ' + r.passedUp.sendFailed + ' send-failed']
].map(t => '<div class="statx"><div><div class="nv" style="font-size:22px">' + esc(String(t[1])) + '</div><div class="lb">' + esc(t[0]) + '</div><span class="chip flat">' + esc(t[2]) + '</span></div></div>').join('');
$('pnlSplit').innerHTML = '<tr><th>Line</th><th>POL</th><th>USD now</th></tr>'
+ [['Level 1 (50%)', r.byTier[1]], ['Level 2 (20%)', r.byTier[2]], ['Level 3 (10%)', r.byTier[3]], ['Platform (20% + pass-ups)', r.platformWei]].map(x => '<tr><td>' + x[0] + '</td><td class="mono">' + pol(x[1]) + '</td><td class="mono">' + usdOf(x[1], px) + '</td></tr>').join('');
const W = r.wallets || {}, B = r.balances || {};
$('pnlWallets').innerHTML = '<tr><th>Wallet</th><th>Address</th><th>Balance</th></tr>'
+ [['Owner / fee A (Tangem)', W.feeA, B.feeA], ['Fee B', W.feeB, B.feeB], ['Engine (gas)', W.engine, B.engine]].filter(x => x[1]).map(x => '<tr><td>' + x[0] + '</td><td class="mono small">' + esc(x[1]) + '</td><td class="mono">' + (x[2] == null ? '?' : pol(x[2]) + ' POL') + '</td></tr>').join('');
$('pnlFixed').value = r.fixedMonthlyUsd || 0;
const b = r.burner || {};
$('burnerLine').textContent = !b.hasEthers ? 'ethers is not installed in this build.' : !b.keyPresent ? 'No engine key configured (ENGINE_KEY). Burns stay pending until it is set.' : b.mismatch ? 'ENGINE_KEY does not match the contract engine signer. Disabled.' : 'Engine wallet ' + b.address + ' holds ' + pol(b.balanceWei) + ' POL. That is its gas fund, not a cost: one burn uses about 0.003 POL (roughly 48,000 gas), paid by this wallet, never by the member. ' + b.burned + ' burn' + (b.burned === 1 ? '' : 's') + ' since boot' + (b.lastRun ? ' · last check ' + when(b.lastRun) : '') + (b.lastError ? ' · last error: ' + b.lastError : '') + (b.skipped && Object.keys(b.skipped).length ? ' · skipped (needs review): ' + Object.entries(b.skipped).map(([k, v]) => k + ' (' + v + ')').join(', ') : '');
}
document.querySelectorAll('#pnlPeriods [data-days]').forEach(b => b.addEventListener('click', () => { pnlDays = Number(b.dataset.days); document.querySelectorAll('#pnlPeriods [data-days]').forEach(x => x.classList.toggle('on', x === b)); loadPnl().catch(e => IAP.status(e.message, 'bad')); }));
if ($('pnlFixedSave')) $('pnlFixedSave').addEventListener('click', async () => { try { await api('/api/admin/site', { pnlFixedMonthlyUsd: Number($('pnlFixed').value) || 0 }, 'PATCH'); IAP.status('Saved.', 'ok'); loadPnl(); } catch (e) { IAP.status(e.message, 'bad'); } });
if ($('burnerRun')) $('burnerRun').addEventListener('click', async () => { try { const r = await api('/api/admin/burner/run', {}); IAP.status('Burner ran: ' + (r.burned || 0) + ' burned.', 'ok'); loadPnl(); } catch (e) { IAP.status(e.message, 'bad'); } });
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: graphical editors (follow-up emails, rates, site settings) ──
let dripSeq = [], ratesObj = {}, siteObj = {};
let lastFocusedField = null;
document.addEventListener('focusin', e => { if (e.target && (e.target.matches('textarea.ds-body') || e.target.matches('input.ds-subject'))) lastFocusedField = e.target; });
document.addEventListener('click', e => {
const c = e.target.closest('[data-ph]'); if (!c) return;
const el = lastFocusedField; if (!el) { IAP.status('Click into a subject or body first, then the chip.', 'bad'); return; }
const ph = c.dataset.ph, st = el.selectionStart || 0, en = el.selectionEnd || st;
el.value = el.value.slice(0, st) + ph + el.value.slice(en);
el.focus(); el.selectionStart = el.selectionEnd = st + ph.length;
el.dispatchEvent(new Event('input'));
});
const whenLabel = h => { h = Number(h) || 0; if (h < 24) return h + ' hour' + (h === 1 ? '' : 's') + ' after sign-up'; const d = h / 24; return (Number.isInteger(d) ? d : d.toFixed(1)) + ' day' + (d === 1 ? '' : 's') + ' after sign-up'; };
function drawDrip() {
const wrap = $('dripSteps');
wrap.innerHTML = dripSeq.map((st, i) => '<div class="drip-step" data-i="' + i + '">'
+ '<div class="ds-head"><span class="ds-n">EMAIL ' + (i + 1) + '</span>'
+ '<span class="ds-when">send <input type="number" min="1" class="ds-hours" value="' + esc(st.hours) + '"> hours after sign-up <b class="ds-whenlbl">(' + esc(whenLabel(st.hours)) + ')</b></span>'
+ '<span class="ds-tools"><button type="button" class="btn small sec" data-act="up" ' + (i === 0 ? 'disabled' : '') + '>↑</button><button type="button" class="btn small sec" data-act="down" ' + (i === dripSeq.length - 1 ? 'disabled' : '') + '>↓</button>'
+ '<button type="button" class="btn small sec" data-act="test">Send to me</button><button type="button" class="btn small sec" data-act="remove">Remove</button></span></div>'
+ '<input class="ds-subject" placeholder="Subject line" maxlength="150" value="' + esc(st.subject) + '">'
+ '<textarea class="ds-body" placeholder="Plain-text email body">' + esc(st.body) + '</textarea>'
+ '</div>').join('') || '<p class="muted small">No emails yet. Add one below.</p>';
}
function readDrip() {
return [...document.querySelectorAll('#dripSteps .drip-step')].map(card => ({
hours: Number(card.querySelector('.ds-hours').value) || 0,
subject: card.querySelector('.ds-subject').value.trim(),
body: card.querySelector('.ds-body').value.trim()
}));
}
$('dripSteps').addEventListener('input', e => {
if (e.target.classList.contains('ds-hours')) { const l = e.target.closest('.ds-when').querySelector('.ds-whenlbl'); if (l) l.textContent = '(' + whenLabel(e.target.value) + ')'; }
});
$('dripSteps').addEventListener('click', async e => {
const b = e.target.closest('[data-act]'); if (!b) return;
const card = b.closest('.drip-step'), i = Number(card.dataset.i);
dripSeq = readDrip();
if (b.dataset.act === 'remove') { if (!confirm('Remove email ' + (i + 1) + '?')) return; dripSeq.splice(i, 1); drawDrip(); return; }
if (b.dataset.act === 'up' && i > 0) { [dripSeq[i - 1], dripSeq[i]] = [dripSeq[i], dripSeq[i - 1]]; drawDrip(); return; }
if (b.dataset.act === 'down' && i < dripSeq.length - 1) { [dripSeq[i + 1], dripSeq[i]] = [dripSeq[i], dripSeq[i + 1]]; drawDrip(); return; }
if (b.dataset.act === 'test') {
b.disabled = true;
try { await saveDrip(); await api('/api/admin/drip/test', { step: i }); IAP.status('Email ' + (i + 1) + ' sent to your inbox.', 'ok'); }
catch (err) { IAP.status(err.message, 'bad'); }
b.disabled = false;
}
});
async function saveDrip() {
$('dripErr').hidden = true;
const seq = readDrip();
const r = await api('/api/admin/drip', { sequence: seq }, 'PATCH').catch(err => { $('dripErr').textContent = err.message; $('dripErr').hidden = false; throw err; });
dripSeq = r.sequence; drawDrip();
return r;
}
$('dripSave').addEventListener('click', busy($('dripSave'), async () => { await saveDrip(); IAP.status('Sequence saved.', 'ok'); await loadSettings(); }));
$('dripAdd').addEventListener('click', () => {
dripSeq = readDrip();
const last = dripSeq[dripSeq.length - 1];
dripSeq.push({ hours: last ? Number(last.hours) + 48 : 24, subject: '', body: '\n\nMarty\n\n{{footer}}' });
drawDrip();
const cards = document.querySelectorAll('#dripSteps .drip-step'); const c = cards[cards.length - 1]; if (c) { c.scrollIntoView({ behavior: 'smooth', block: 'center' }); c.querySelector('.ds-subject').focus(); }
});
$('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');
dripSeq = r.sequence; drawDrip(); IAP.status('Defaults restored.', 'ok'); await loadSettings();
}));
// rates: labels + hints for the known keys; anything unknown still gets a plain field
const RATE_META = {
bannerBatch: ['Banner: views per batch', 'impressions counted before a banner campaign is charged'],
bannerCreditsPerBatch: ['Banner: credits per batch', 'charged to the advertiser per batch'],
textBatch: ['Text ad: views per batch', ''], textCreditsPerBatch: ['Text ad: credits per batch', ''],
loginCreditsPerDay: ['Login ad: credits per day', 'flat daily charge while active'],
loginDwellSeconds: ['Login ad: seconds shown', 'full-screen interstitial after sign-in'],
burnBatchMin: ['On-chain burn batch (credits)', 'accrued spend is burned once it reaches this'],
welcomeCredits: ['Welcome credits', 'granted after the welcome tour'],
dailyViewTarget: ['Daily view set (ads)', 'ads a member views for the daily claim'],
dailyClaimCredits: ['Daily claim (credits)', 'paid when the set is complete'],
viewDwellSeconds: ['Ad view: seconds per ad', 'the countdown; server-enforced'],
soloCostPerRecipient: ['Solo ad: credits per recipient', ''], soloMinRecipients: ['Solo ad: minimum recipients', ''],
soloReadCredits: ['Solo ad: reader reward (credits)', ''], soloReadCapPerDay: ['Solo ad: rewarded reads per day', ''], soloReadDwellSeconds: ['Solo ad: seconds to read', ''],
videoWatchCapPerDay: ['Video: rewarded watches per day', ''],
featuredPerDay: ['Featured link: credits per day', ''], featuredSlotsPerDay: ['Featured link: slots per day', ''], featuredWindowDays: ['Featured link: booking window (days)', ''],
featuredDurations: ['Featured link: durations offered (days)', 'comma-separated'],
visitCostPerVisit: ['Verified visit: credits per visit', ''], visitMinPack: ['Verified visit: smallest pack', ''], visitReward: ['Verified visit: viewer reward (credits)', ''], visitDwellSeconds: ['Verified visit: seconds on site', ''], visitCapPerDay: ['Verified visit: rewarded visits per day', ''],
videoTiers: ['Video ad tiers', 'watch length → advertiser cost → viewer reward'],
milestoneBonus: ['Milestone bonuses (credits)', 'one-time, when a member reaches each step']
};
const humanize = k => k.replace(/([A-Z])/g, ' $1').replace(/^./, c => c.toUpperCase());
function drawRates() {
const wrap = $('ratesForm'); const html = [];
for (const [k, v] of Object.entries(ratesObj)) {
const [label, hint] = RATE_META[k] || [humanize(k), ''];
if (typeof v === 'number') html.push('<div class="rf"><label>' + esc(label) + '</label><input type="number" step="any" data-rk="' + esc(k) + '" value="' + esc(v) + '">' + (hint ? '<span class="hint">' + esc(hint) + '</span>' : '') + '</div>');
else if (typeof v === 'boolean') html.push('<div class="rf"><label>' + esc(label) + '</label><label class="small"><input type="checkbox" data-rk="' + esc(k) + '"' + (v ? ' checked' : '') + ' style="width:auto"> on</label></div>');
else if (Array.isArray(v) && v.every(x => typeof x === 'number')) html.push('<div class="rf"><label>' + esc(label) + '</label><input data-rk="' + esc(k) + '" data-kind="numlist" value="' + esc(v.join(', ')) + '">' + (hint ? '<span class="hint">' + esc(hint) + '</span>' : '') + '</div>');
else if (Array.isArray(v) && v.every(x => x && typeof x === 'object')) {
const cols = [...new Set(v.flatMap(x => Object.keys(x)))];
html.push('<div class="rf wide"><label>' + esc(label) + '</label>' + (hint ? '<span class="hint">' + esc(hint) + '</span>' : '')
+ '<table class="tiers" data-rk="' + esc(k) + '" data-kind="table"><tr>' + cols.map(c => '<th>' + esc(c) + '</th>').join('') + '</tr>'
+ v.map((row, i) => '<tr>' + cols.map(c => '<td><input type="number" step="any" data-col="' + esc(c) + '" value="' + esc(row[c] == null ? '' : row[c]) + '"></td>').join('') + '</tr>').join('') + '</table></div>');
} else if (v && typeof v === 'object') {
html.push('<div class="rf wide"><label>' + esc(label) + '</label>' + (hint ? '<span class="hint">' + esc(hint) + '</span>' : '') + '<div class="sub-grid" data-rk="' + esc(k) + '" data-kind="object">'
+ Object.entries(v).map(([sk, sv]) => '<label>' + esc(humanize(sk)) + '<input type="number" step="any" data-sub="' + esc(sk) + '" value="' + esc(sv) + '"></label>').join('') + '</div></div>');
} else html.push('<div class="rf"><label>' + esc(label) + '</label><input data-rk="' + esc(k) + '" value="' + esc(v == null ? '' : v) + '"></div>');
}
wrap.innerHTML = html.join('');
}
function readRates() {
const out = {};
document.querySelectorAll('#ratesForm [data-rk]').forEach(el => {
const k = el.dataset.rk, kind = el.dataset.kind;
if (kind === 'numlist') out[k] = el.value.split(/[\s,]+/).filter(Boolean).map(Number).filter(n => !isNaN(n));
else if (kind === 'table') out[k] = [...el.querySelectorAll('tr')].slice(1).map(tr => { const o = {}; tr.querySelectorAll('input[data-col]').forEach(i => { o[i.dataset.col] = Number(i.value); }); return o; });
else if (kind === 'object') { const o = {}; el.querySelectorAll('input[data-sub]').forEach(i => { o[i.dataset.sub] = Number(i.value); }); out[k] = o; }
else if (el.type === 'checkbox') out[k] = !!el.checked;
else if (el.type === 'number') out[k] = Number(el.value);
else out[k] = el.value;
});
return out;
}
$('ratesSave').addEventListener('click', busy($('ratesSave'), async () => {
$('ratesErr').hidden = true;
try { const r = await api('/api/admin/rates', readRates(), 'PATCH'); ratesObj = r.rates || readRates(); drawRates(); IAP.status('Rates saved.', 'ok'); }
catch (e) { $('ratesErr').textContent = e.message; $('ratesErr').hidden = false; }
}));
// site settings: key / value rows; booleans as checkboxes, numbers stay numbers
const SITE_META = { siteName: 'Site name', tagline: 'Tagline', rehearsal: 'Testnet rehearsal banner', defaultSponsorId: 'Fallback sponsor (member #)', walletConnectProjectId: 'WalletConnect project id', moonpayPublicKey: 'MoonPay public key', telegramBotToken: 'Telegram proof feed: bot token', telegramChatId: 'Telegram proof feed: chat id', telegramTopicId: 'Telegram proof feed: topic id (optional)', telegramEvents: 'Telegram proof feed: events (payouts | payouts+purchases | all)', telegramCtaUrl: 'Telegram proof feed: join link under each post', memberWeeklyEmail: 'Weekly member email to everyone active (1) or only sponsors with a line (0)', leaderboardWeeklyPrize: 'Leaderboard: weekly prize text (optional; blank shows the credit ladder)', leaderboardMonthlyPrize: 'Leaderboard: monthly prize text (optional)', leaderboardWeeklyCredits: 'Leaderboard: weekly credits for 1st,2nd,3rd… (e.g. 1000,500,250; blank = none)', leaderboardMonthlyCredits: 'Leaderboard: monthly credits for 1st,2nd,3rd… (e.g. 5000,2500,1000)', leaderboardAnnounceGeneral: 'Leaderboard: announce winners in the main group too (1/0)', telegramEchoChatId: 'Telegram echo (shared payments topic): chat id', telegramEchoTopicId: 'Telegram echo: topic id', telegramEchoEvents: 'Telegram echo: events (payouts | payouts+purchases | all)', legacyCreditsAdvertiser: 'Legacy welcome credits: former advertisers', legacyCreditsEarner: 'Legacy welcome credits: former earners', pnlFixedMonthlyUsd: 'P&L: fixed monthly cost (USD)' };
function drawSite() {
const wrap = $('siteForm');
wrap.innerHTML = Object.entries(siteObj).map(([k, v]) => '<div class="kv-row"><span class="k" title="' + esc(k) + '">' + esc(SITE_META[k] || humanize(k)) + '</span>'
+ (typeof v === 'boolean' ? '<input type="checkbox" data-sk="' + esc(k) + '"' + (v ? ' checked' : '') + '>'
: typeof v === 'number' ? '<input type="number" step="any" data-sk="' + esc(k) + '" value="' + esc(v) + '">'
: '<input data-sk="' + esc(k) + '" value="' + esc(typeof v === 'object' ? JSON.stringify(v) : (v == null ? '' : v)) + '">')
+ '<button type="button" class="btn small sec" data-sdel="' + esc(k) + '">Clear</button></div>').join('') || '<p class="muted small">No settings saved yet.</p>';
}
function readSite() {
const out = {};
document.querySelectorAll('#siteForm [data-sk]').forEach(el => {
const k = el.dataset.sk;
if (el.type === 'checkbox') out[k] = !!el.checked;
else if (el.type === 'number') out[k] = Number(el.value);
else { const v = el.value; if (/^[\[{]/.test(v)) { try { out[k] = JSON.parse(v); return; } catch (e) {} } out[k] = v; }
});
return out;
}
$('siteForm').addEventListener('click', e => {
const b = e.target.closest('[data-sdel]'); if (!b) return;
siteObj = readSite(); siteObj[b.dataset.sdel] = ''; drawSite();
});
$('siteAddKey').addEventListener('click', () => {
const k = $('siteNewKey').value.trim(); if (!/^[A-Za-z][A-Za-z0-9_]{0,40}$/.test(k)) { IAP.status('Setting names are letters and numbers, no spaces.', 'bad'); return; }
siteObj = readSite(); if (!(k in siteObj)) siteObj[k] = ''; $('siteNewKey').value = ''; drawSite();
const el = document.querySelector('#siteForm [data-sk="' + k + '"]'); if (el) el.focus();
});
$('siteSave').addEventListener('click', busy($('siteSave'), async () => {
$('siteErr').hidden = true;
try { const r = await api('/api/admin/site', readSite(), 'PATCH'); siteObj = r.site || readSite(); drawSite(); IAP.status('Site settings saved.', 'ok'); }
catch (e) { $('siteErr').textContent = e.message; $('siteErr').hidden = false; }
}));
async function loadSettings() {
const [r, s, d] = await Promise.all([api('/api/admin/rates'), api('/api/admin/site'), api('/api/admin/drip')]);
ratesObj = r.rates || {}; drawRates();
siteObj = s.site || {}; drawSite();
dripSeq = d.sequence || []; drawDrip();
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');
}
render();
})();