Files
instantadpay/public/assets/admin.js
T
martbost 438b1921e9 Admin portal (/admin) + free house ads; POL amounts show two decimals
- /admin: email magic-code sign-in allowlisted to ADMIN_EMAIL, 12h admin
  session (cookie iap.adm, persisted in the volume). Bearer ADMIN_PASSWORD
  API access still works. Member area shows an Admin link for that email.
- House ads: admin places banner/text/login/solo/video/featured/visits
  campaigns owned by house@instantadpay.com that cost nothing; budget is
  only a delivery cap, spend is never charged or burned.
- Admin APIs: overview, all campaigns (+pause/resume any), members
  (+re-point sponsor), reports (+resolve), pending burns, rates/site
  config get+patch, creative upload.
- fmtPol rounds to two decimals everywhere (dashboard, toasts, prices).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-09 05:15:19 -05:00

293 lines
19 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 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>Code</th><th>Joined</th><th></th></tr>'
+ list.map(a => '<tr><td>' + esc(a.email) + '</td><td>' + (a.username ? '@' + esc(a.username) : '<span class="muted">none</span>') + '</td>'
+ '<td>' + (a.memberId ? '#' + a.memberId : '<span class="muted">free</span>') + '</td>'
+ '<td class="mono small">' + (a.address ? esc(a.address.slice(0, 8) + '…' + a.address.slice(-6)) : '<span class="muted">none</span>') + '</td>'
+ '<td>' + esc(a.sponsorRef || '') + '</td><td class="mono small">' + esc(a.code || '') + '</td>'
+ '<td 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] = await Promise.all([api('/api/admin/rates'), api('/api/admin/site')]);
$('ratesJson').value = JSON.stringify(r.rates || {}, null, 2);
$('siteJson').value = JSON.stringify(s.site || {}, null, 2);
}
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();
})();