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>
This commit is contained in:
martbost
2026-09-09 05:15:19 -05:00
parent b6a0729335
commit 438b1921e9
18 changed files with 725 additions and 47 deletions
+224
View File
@@ -0,0 +1,224 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="robots" content="noindex,nofollow">
<title>Admin | InstantAdPay</title>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Sora:wght@600;700;800&display=swap">
<link rel="icon" type="image/png" href="/logo-icon.png">
<link rel="stylesheet" href="/assets/site.css?v=20260908f">
<style>
.adm-table th,.adm-table td{padding:8px 10px;text-align:left;vertical-align:top;border-bottom:1px solid var(--line);font-size:13.5px}
.adm-table th{color:var(--muted);font-size:11.5px;text-transform:uppercase;letter-spacing:.08em}
.adm-table .act{white-space:nowrap}
.adm-table .act button{margin-right:6px}
.st{display:inline-block;border-radius:999px;padding:2px 9px;font-size:11.5px;font-weight:700;letter-spacing:.04em;text-transform:uppercase}
.st.active{background:rgba(67,232,195,.14);color:var(--mint)}
.st.paused{background:rgba(255,178,56,.14);color:var(--amber)}
.st.out{background:rgba(255,255,255,.08);color:var(--muted)}
.house-tag{display:inline-block;border:1px solid rgba(157,125,255,.5);color:var(--violet);border-radius:6px;padding:1px 6px;font-size:10.5px;font-weight:700;margin-left:6px;vertical-align:middle}
.adm-form p{margin:0 0 10px}
.adm-form label.small{display:block;margin-bottom:4px}
.trunc{max-width:260px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:inline-block;vertical-align:bottom}
textarea.json{font-family:var(--mono);font-size:12.5px;width:100%;min-height:260px}
</style>
</head>
<body class="bo-body">
<!-- signed-out: admin sign-in -->
<div id="authArea">
<div class="wrap" style="max-width:520px">
<section class="hero" style="padding:64px 0 10px;text-align:left">
<p class="eyebrow">InstantAdPay</p>
<h1 style="font-size:34px">Admin <em>sign in</em></h1>
<p class="lead" style="font-size:16px">Only the admin address can sign in here. A one-time code goes to that inbox.</p>
</section>
<div class="card">
<p><input id="adEmail" type="email" placeholder="Admin email" autocomplete="email" style="width:100%"></p>
<p id="adCodeRow" hidden><input id="adCode" inputmode="numeric" placeholder="6-digit code from your email" style="width:100%"></p>
<p id="adErr" class="small" style="color:var(--bad)" hidden></p>
<p style="margin:0;display:flex;gap:10px">
<button class="btn" id="adSend" type="button">Send code</button>
<button class="btn" id="adVerify" type="button" hidden>Sign in</button>
</p>
<p class="small muted" style="margin:14px 0 0"><a href="/my">Back to the member area</a></p>
</div>
</div>
</div>
<!-- signed-in: admin shell -->
<div id="adminArea" class="bo" hidden>
<aside class="bo-side" id="boSide">
<a class="logo" href="/"><img src="/logo.png" alt="InstantAdPay" style="height:32px;display:block"></a>
<nav class="bo-menu" aria-label="Admin menu">
<button data-pane="overview" class="on" type="button"><svg viewBox="0 0 24 24"><rect x="3" y="3" width="8" height="8" rx="2"/><rect x="13" y="3" width="8" height="8" rx="2"/><rect x="3" y="13" width="8" height="8" rx="2"/><rect x="13" y="13" width="8" height="8" rx="2"/></svg>Overview</button>
<button data-pane="house" type="button"><svg viewBox="0 0 24 24"><path d="M3 11l14-5v12L3 13v-2z"/><path d="M17 8a4 4 0 0 1 0 8M7 13v5a2 2 0 0 0 4 0v-3"/></svg>House ads</button>
<button data-pane="campaigns" type="button"><svg viewBox="0 0 24 24"><path d="M4 6h16M4 12h16M4 18h10"/></svg>All campaigns</button>
<button data-pane="members" type="button"><svg viewBox="0 0 24 24"><circle cx="12" cy="7" r="3.2"/><circle cx="5" cy="17" r="2.6"/><circle cx="19" cy="17" r="2.6"/><path d="M12 10v3M12 13l-5 2M12 13l5 2"/></svg>Members</button>
<button data-pane="reports" type="button"><svg viewBox="0 0 24 24"><path d="M12 3l9 16H3z"/><path d="M12 10v4M12 17v.5"/></svg>Reports<span class="pill" id="repBadge" hidden></span></button>
<button data-pane="settings" type="button"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z"/></svg>Settings</button>
</nav>
<div class="bo-links">
<span class="bo-cap">Site</span>
<a href="/my">Member area</a>
<a href="/ledger" target="_blank" rel="noopener">Live ledger</a>
<a href="/" target="_blank" rel="noopener">Home page</a>
</div>
<div class="bo-foot">
<span id="adWho" class="small muted">…</span>
<a href="#" id="adLogout" class="small">Log out</a>
</div>
</aside>
<div class="bo-main">
<header class="bo-top">
<button id="boBurger" aria-label="Menu" type="button">☰</button>
<h2 id="boTitle" style="margin:0">Overview</h2>
<span class="small muted" id="chainLine"></span>
</header>
<main class="bo-content">
<div class="pane" id="pane-overview">
<div class="grid" style="grid-template-columns:repeat(auto-fit,minmax(200px,1fr));margin-top:0">
<div class="statx"><div><div class="nv" id="ovAccounts">0</div><div class="lb">Accounts</div></div></div>
<div class="statx c-cyan"><div><div class="nv" id="ovMembers">0</div><div class="lb">On-chain members</div></div></div>
<div class="statx c-violet"><div><div class="nv" id="ovActive">0</div><div class="lb">Active campaigns</div><span class="chip flat" id="ovCampSub"></span></div></div>
<div class="statx c-amber"><div><div class="nv" id="ovReports">0</div><div class="lb">Open reports</div><span class="chip flat" id="ovBurnSub"></span></div></div>
</div>
<div class="grid c2" style="margin-top:16px">
<div class="card">
<div class="card-head"><h3>Campaigns by format</h3><span class="sub">all owners</span></div>
<div id="ovByType" class="small muted">…</div>
</div>
<div class="card">
<div class="card-head"><h3>Chain</h3><span class="sub">live config</span></div>
<div id="ovChain" class="small">…</div>
</div>
</div>
<div class="card">
<div class="card-head"><h3>Quick actions</h3></div>
<p style="display:flex;gap:10px;flex-wrap:wrap;margin:0">
<button class="btn small" type="button" data-goto="house">Place a house ad</button>
<button class="btn small sec" type="button" data-goto="campaigns">Review campaigns</button>
<button class="btn small sec" type="button" data-goto="members">Members</button>
</p>
</div>
</div>
<div class="pane" id="pane-house" hidden>
<div class="card adm-form">
<div class="card-head"><h3>New house ad</h3><span class="sub">free: nothing is charged, the budget is only a delivery cap</span></div>
<div class="grid c3">
<p><select id="hType" style="width:100%">
<option value="banner">Banner (per impression)</option>
<option value="text">Text ad (per impression)</option>
<option value="login">Login ad (full-screen after sign-in)</option>
<option value="solo">Solo ad (inbox delivery)</option>
<option value="video">Video ad (watch to earn)</option>
<option value="featured">Featured link (rotation)</option>
<option value="visits">Verified visits</option>
</select></p>
<p><input id="hName" placeholder="Campaign name" style="width:100%"></p>
<p><input id="hBudget" type="number" min="10" placeholder="Delivery cap in credits (default 100,000)" style="width:100%"></p>
</div>
<p><input id="hTarget" placeholder="Target URL (https://…)" autocomplete="off" style="width:100%"></p>
<p class="small muted" style="margin-top:-4px">Banner and text targets are framed in the ad viewer, so they must allow framing (checked on submit).</p>
<div id="hBannerRow">
<p><input id="hImage" placeholder="Image URL (https://… or /uploads/…)" autocomplete="off" style="width:100%"></p>
<p><input type="file" id="hImageFile" accept="image/png,image/jpeg,image/webp,image/gif" hidden>
<button type="button" class="btn small sec" id="hImageUploadBtn">Upload image</button>
<span id="hImageInfo" class="small muted"></span></p>
<p><select id="hSize" style="width:100%"></select></p>
</div>
<div id="hTextRow" hidden>
<p><input id="hTitle" maxlength="60" placeholder="Headline (max 60)" style="width:100%"></p>
<p><input id="hBody" maxlength="140" placeholder="Ad text (max 140)" style="width:100%"></p>
</div>
<div id="hSoloRow" hidden>
<p><input id="hSoloTitle" maxlength="80" placeholder="Subject line (max 80)" style="width:100%"></p>
<p><textarea id="hSoloBody" rows="8" placeholder="Message body. Simple HTML is fine (bold, lists, links, images)." style="width:100%"></textarea></p>
<p><input id="hSoloCta" maxlength="30" placeholder="Call-to-action label (default: Learn more)" style="width:100%"></p>
<p class="small muted" id="hSoloHint"></p>
</div>
<div id="hVideoRow" hidden>
<p><input id="hVideoUrl" placeholder="Video URL (direct https link ending .mp4 or .webm, or /uploads/…)" autocomplete="off" style="width:100%"></p>
<p><input type="file" id="hVideoFile" accept="video/mp4,video/webm" hidden>
<button type="button" class="btn small sec" id="hVideoUploadBtn">Upload video</button>
<span id="hVideoInfo" class="small muted"></span></p>
<p><input id="hVideoTitle" maxlength="80" placeholder="Video title (optional)" style="width:100%"></p>
<p><select id="hWatchSecs" style="width:100%"></select></p>
<p><input id="hVideoCta" maxlength="30" placeholder="Call-to-action label (default: Learn more)" style="width:100%"></p>
</div>
<div id="hFeatRow" hidden>
<p><input id="hFeatTitle" maxlength="70" placeholder="Headline for the featured link" style="width:100%"></p>
<div class="grid c2">
<p><select id="hFeatDays" style="width:100%"></select></p>
<p><input id="hFeatStart" type="number" min="0" value="0" placeholder="Start in N days (0 = today)" style="width:100%"></p>
</div>
<p class="small muted" id="hFeatHint"></p>
</div>
<div id="hVisitsRow" hidden>
<p><input id="hVisitTitle" maxlength="80" placeholder="Headline (what members see before visiting)" style="width:100%"></p>
<p><input id="hVisitCount" type="number" min="20" placeholder="How many verified visits?" style="width:100%"></p>
<p class="small muted" id="hVisitHint"></p>
</div>
<p id="hErr" class="small" style="color:var(--bad)" hidden></p>
<button class="btn" id="hCreate" type="button">Place house ad</button>
</div>
<div class="card">
<div class="card-head"><h3>House ads</h3><span class="sub" id="houseSub">running free</span></div>
<div class="tablewrap"><table class="adm-table" id="houseTable"></table></div>
</div>
</div>
<div class="pane" id="pane-campaigns" hidden>
<div class="card">
<div class="card-head"><h3>All campaigns</h3><span class="sub" id="campSub">newest first</span></div>
<p style="margin:0 0 10px"><input id="campFilter" placeholder="Filter by owner, name, type or status" style="width:100%"></p>
<div class="tablewrap"><table class="adm-table" id="campTable"></table></div>
</div>
</div>
<div class="pane" id="pane-members" hidden>
<div class="card">
<div class="card-head"><h3>Members</h3><span class="sub" id="memSub">newest first</span></div>
<p style="margin:0 0 10px"><input id="memFilter" placeholder="Filter by email, username, member # or sponsor" style="width:100%"></p>
<p class="small muted">Sponsor = the token the account joined under (username, share code, or member #). Editing it re-points free referrals and future purchases. On-chain sponsorship is permanent once activated.</p>
<div class="tablewrap"><table class="adm-table" id="memTable"></table></div>
</div>
</div>
<div class="pane" id="pane-reports" hidden>
<div class="card">
<div class="card-head"><h3>Ad reports</h3><span class="sub">members flagging ads</span></div>
<div class="tablewrap"><table class="adm-table" id="repTable"></table></div>
</div>
<div class="card">
<div class="card-head"><h3>Pending burns</h3><span class="sub">on-chain credit burns waiting to be executed</span></div>
<div class="tablewrap"><table class="adm-table" id="burnTable"></table></div>
</div>
</div>
<div class="pane" id="pane-settings" hidden>
<div class="card">
<div class="card-head"><h3>Rates</h3><span class="sub">credit prices and rewards (JSON, merged on save)</span></div>
<p><textarea id="ratesJson" class="json"></textarea></p>
<p id="ratesErr" class="small" style="color:var(--bad)" hidden></p>
<button class="btn small" id="ratesSave" type="button">Save rates</button>
</div>
<div class="card">
<div class="card-head"><h3>Site config</h3><span class="sub">public: everything here is served by /api/config, never put secrets in it</span></div>
<p><textarea id="siteJson" class="json"></textarea></p>
<p id="siteErr" class="small" style="color:var(--bad)" hidden></p>
<button class="btn small" id="siteSave" type="button">Save site config</button>
</div>
</div>
</main>
</div>
</div>
<script src="/assets/common.js?v=20260909a"></script>
<script src="/assets/admin.js?v=20260909a"></script>
</body>
</html>
+292
View File
@@ -0,0 +1,292 @@
// 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();
})();
+4 -4
View File
@@ -7,11 +7,11 @@ window.IAP = (function () {
if (!config) config = await (await fetch('/api/config')).json();
return config;
}
// POL amounts display with two decimals (rounded half-up), e.g. 523.39
function fmtPol(wei) {
const s = BigInt(wei).toString().padStart(19, '0');
const whole = s.slice(0, -18) || '0';
const frac = s.slice(-18, -12).replace(/0+$/, '');
return whole + (frac ? '.' + frac : '');
const cents = (BigInt(wei) + 5000000000000000n) / 10000000000000000n; // wei -> hundredths of a POL
const s = cents.toString().padStart(3, '0');
return s.slice(0, -2) + '.' + s.slice(-2);
}
const fmtUsd = cents => '$' + (cents / 100).toFixed(2);
+1
View File
@@ -471,6 +471,7 @@
$('authArea').hidden = !!signedIn;
$('memberArea').hidden = !signedIn;
if (!signedIn) return;
if ($('adminLink')) $('adminLink').hidden = !me.isAdmin; // admin portal link, only for ADMIN_EMAIL
setPane(location.hash.slice(1) || 'overview');
$('campGate').hidden = !!me.memberId;
$('earnGate').hidden = !!me.memberId;
+1 -1
View File
@@ -141,7 +141,7 @@
<div class="small">Advertising services with a performance referral program. Not an investment product; no income guarantees. Crypto transactions are irreversible. Never spend what you cannot afford.</div>
</footer>
</div>
<script src="/assets/common.js?v=20260908c"></script>
<script src="/assets/common.js?v=20260909a"></script>
<script src="/assets/contract.js?v=20260908p"></script>
<script src="/assets/chat.js?v=20260906m"></script>
</body>
+1 -1
View File
@@ -26,7 +26,7 @@
<p class="muted small">You decide whether, and how much, to spend. Never spend more than you can afford to lose.</p>
</div>
</div></section>
<script src="/assets/common.js?v=20260908c"></script>
<script src="/assets/common.js?v=20260909a"></script>
<script src="/assets/legal.js?v=20260908a"></script>
</body>
</html>
+1 -1
View File
@@ -438,7 +438,7 @@
</div>
</section>
<script src="/assets/common.js?v=20260908c"></script>
<script src="/assets/common.js?v=20260909a"></script>
<script src="/assets/wallet.js?v=20260908t"></script>
<script src="/assets/home.js?v=20260906m"></script>
<script src="/assets/chat.js?v=20260906m"></script>
+1 -1
View File
@@ -37,7 +37,7 @@
<div>InstantAdPay · <a href="/">how it works</a> · <a id="contractLink" href="#" target="_blank" rel="noopener">contract source ↗</a></div>
</footer>
</div>
<script src="/assets/common.js?v=20260908c"></script>
<script src="/assets/common.js?v=20260909a"></script>
<script src="/assets/ledger.js?v=20260906m"></script>
<script src="/assets/chat.js?v=20260906m"></script>
</body>
+3 -2
View File
@@ -139,6 +139,7 @@
<div class="bo-links">
<span class="bo-cap">Team</span>
<a href="#" id="chatMenuBtn" class="bo-chat"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align:-2px;margin-right:7px"><path d="M5 5h14a1 1 0 0 1 1 1v9a1 1 0 0 1-1 1H9l-4 4V6a1 1 0 0 1 1-1z"/></svg>Messages<span class="pill" id="chatNavBadge" hidden></span></a>
<a href="/admin" id="adminLink" hidden>Admin</a>
</div>
<div class="bo-links">
<span class="bo-cap">Legal</span>
@@ -694,9 +695,9 @@
</div>
</div>
</div>
<script src="/assets/common.js?v=20260908c"></script>
<script src="/assets/common.js?v=20260909a"></script>
<script src="/assets/wallet.js?v=20260908t"></script>
<script src="/assets/my.js?v=20260908t"></script>
<script src="/assets/my.js?v=20260909a"></script>
<script src="/assets/chat.js?v=20260907l"></script>
</body>
</html>
+1 -1
View File
@@ -28,7 +28,7 @@
<p class="muted small">We use reasonable safeguards, but no system is perfectly secure. Protect your email and your wallet.</p>
</div>
</div></section>
<script src="/assets/common.js?v=20260908c"></script>
<script src="/assets/common.js?v=20260909a"></script>
<script src="/assets/legal.js?v=20260908a"></script>
</body>
</html>
+1
View File
@@ -1,5 +1,6 @@
User-agent: *
Allow: /
Disallow: /my
Disallow: /admin
Disallow: /api/
Sitemap: https://instantadpay.com/sitemap.xml
+1 -1
View File
@@ -36,7 +36,7 @@
<p class="muted small" style="margin-top:18px">See also the <a href="/disclaimer">Disclaimer</a> and <a href="/privacy">Privacy Policy</a>.</p>
</div>
</div></section>
<script src="/assets/common.js?v=20260908c"></script>
<script src="/assets/common.js?v=20260909a"></script>
<script src="/assets/legal.js?v=20260908a"></script>
</body>
</html>
+1 -1
View File
@@ -34,7 +34,7 @@
<p><a href="/ledger">← Back to the live ledger</a> · <a href="/contract">Read the contract review</a></p>
</div>
</section>
<script src="/assets/common.js?v=20260908c"></script>
<script src="/assets/common.js?v=20260909a"></script>
<script src="/assets/tx.js?v=20260906m"></script>
</body>
</html>
+1 -1
View File
@@ -39,7 +39,7 @@
</div>
</div>
</section>
<script src="/assets/common.js?v=20260908c"></script>
<script src="/assets/common.js?v=20260909a"></script>
<script src="/assets/wall.js?v=20260908d"></script>
</body>
</html>