438b1921e9
- /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>
146 lines
7.7 KiB
JavaScript
146 lines
7.7 KiB
JavaScript
// Shared page runtime: site config, nav, formatting. Zero dependencies.
|
||
window.IAP = (function () {
|
||
let config = null;
|
||
const $ = id => document.getElementById(id);
|
||
|
||
async function getConfig() {
|
||
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 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);
|
||
|
||
function status(msg, cls) {
|
||
let el = $('status');
|
||
if (!el) { el = document.createElement('div'); el.id = 'status'; document.body.appendChild(el); }
|
||
el.textContent = msg; el.className = cls || ''; el.hidden = false;
|
||
clearTimeout(status._t);
|
||
if (cls === 'ok') status._t = setTimeout(() => { el.hidden = true; }, 6000);
|
||
}
|
||
|
||
async function renderNav(active) {
|
||
const c = await getConfig();
|
||
const nav = document.createElement('nav');
|
||
nav.innerHTML = '<div class="wrap">'
|
||
+ '<a class="logo" href="/"><img src="/logo.png" alt="InstantAdPay" style="height:30px;display:block"></a>'
|
||
+ '<span class="links">'
|
||
+ '<a href="/" data-p="home">How it works</a>'
|
||
+ '<a href="/#ladder" data-p="pricing">Ad packages</a>'
|
||
+ '<a href="/ledger" data-p="ledger">Live ledger</a>'
|
||
+ '<a href="/contract" data-p="contract">The contract</a>'
|
||
+ '<a href="/my" data-p="my">Members</a>'
|
||
+ '</span><span id="navWallet" class="muted">…</span></div>';
|
||
document.body.prepend(nav);
|
||
if (c.rehearsal) {
|
||
const b = document.createElement('div');
|
||
b.className = 'rehearsal';
|
||
b.innerHTML = '<b>Testnet rehearsal</b>: running on ' + c.chainName + '. Purchases use valueless test POL while we prove every payout in public.';
|
||
document.body.prepend(b);
|
||
}
|
||
const a = nav.querySelector('[data-p="' + active + '"]');
|
||
if (a) a.className = 'active';
|
||
refreshNavWallet();
|
||
renderFooter();
|
||
}
|
||
function renderFooter() {
|
||
if (document.getElementById('iapFooter')) return;
|
||
const f = document.createElement('footer'); f.id = 'iapFooter';
|
||
f.style.cssText = 'border-top:1px solid var(--line);margin-top:48px;padding:26px 22px;text-align:center;color:var(--muted);font-size:13px';
|
||
f.innerHTML = '<div>© ' + new Date().getFullYear() + ' InstantAdPay</div>'
|
||
+ '<div style="margin-top:8px;display:flex;gap:16px;justify-content:center;flex-wrap:wrap">'
|
||
+ '<a href="/">How it works</a><a href="/ledger">Live ledger</a><a href="/contract">The contract</a>'
|
||
+ '<a href="/terms">Terms</a><a href="/privacy">Privacy</a><a href="/disclaimer">Disclaimer</a></div>';
|
||
document.body.appendChild(f);
|
||
}
|
||
async function refreshNavWallet() {
|
||
try {
|
||
const me = await (await fetch('/api/me')).json();
|
||
const el = $('navWallet');
|
||
if (!el) return;
|
||
if (me.signedIn) {
|
||
// identity order: username, then email, then wallet
|
||
const who = me.username
|
||
? '<b>@' + String(me.username).replace(/[&<>]/g, '') + '</b>'
|
||
: (me.email ? String(me.email).replace(/[&<>]/g, '')
|
||
: (me.address ? '<span class="mono">' + me.address.slice(0, 6) + '…' + me.address.slice(-4) + '</span>' : 'signed in'));
|
||
el.innerHTML = (me.memberId ? '<span class="badge">member #' + me.memberId + '</span> ' : '') + who;
|
||
} else {
|
||
el.innerHTML = '<a href="/my">Sign in</a>';
|
||
}
|
||
return me;
|
||
} catch (e) { return null; }
|
||
}
|
||
function describeEvent(ev, c) {
|
||
const pol = w => fmtPol(w) + ' POL';
|
||
// real people, not numbers: use usernames when the site knows them
|
||
const nm = id => (ev.names && ev.names[id])
|
||
? String(ev.names[id]).replace(/[&<>]/g, '')
|
||
: 'member #' + id;
|
||
switch (ev.type) {
|
||
case 'Purchase': return '🧾 ' + nm(ev.buyerId) + ' bought package #' + ev.productId
|
||
+ ' (' + fmtUsd(ev.priceCents) + ') for ' + pol(ev.paidWei) + ' → +' + ev.creditAmount.toLocaleString() + ' credits';
|
||
case 'TierPaid': return '💸 level ' + ev.tier + ' payout → ' + nm(ev.recipientId) + ': ' + pol(ev.amountWei)
|
||
+ (ev.hops ? ' (passed up ' + ev.hops + ')' : '');
|
||
case 'PassedUp': return '↷ level ' + ev.tier + ' passed over ' + nm(ev.skippedId) + ' (' + ev.reason + ')';
|
||
case 'AdminPaid': return '🏛 platform fee settled: ' + pol(ev.amountWei);
|
||
case 'BuyerCounted': return '⭐ ' + nm(ev.sponsorId) + ' now has ' + ev.newCount + ' qualifying buyer(s)';
|
||
case 'MemberActivated': return '👤 ' + nm(ev.id) + ' activated a payout wallet';
|
||
case 'AwardPaid': return '🎁 award: ' + pol(ev.amountWei) + ' → ' + nm(ev.toId);
|
||
case 'CreditsConsumed': return '📣 ' + nm(ev.memberId) + ' ran ads: −' + ev.amount.toLocaleString() + ' credits';
|
||
case 'PriceCached': return '🔮 oracle price refreshed';
|
||
case 'FallbackPriceUsed': return '🔮 cached price bridged an oracle gap';
|
||
default: return '· ' + ev.type;
|
||
}
|
||
}
|
||
function feedRow(ev, c) {
|
||
const div = document.createElement('div');
|
||
div.className = 'row t-' + ev.type;
|
||
div.innerHTML = '<span>' + describeEvent(ev, c) + '</span>'
|
||
+ (c.explorer
|
||
? '<span class="tx"><a target="_blank" rel="noopener" href="' + c.explorer + '/tx/' + ev.tx + '">verify ↗</a></span>'
|
||
: '<span class="tx"><a href="/tx/' + ev.tx + '">verify ↗</a></span>'); // built-in viewer when the chain has no public explorer
|
||
return div;
|
||
}
|
||
// Render one served ad into #<elId>. Silent if no inventory.
|
||
// report an ad (auto-approved ads need a member-facing flag → admin notified)
|
||
function reportAd(campaignId) {
|
||
if (!campaignId) return;
|
||
const reason = (prompt('Report this ad. Reason: broken, inappropriate, spam, scam, or other', 'broken') || '').trim().toLowerCase();
|
||
if (!reason) return;
|
||
const note = prompt('Anything to add? (optional)') || '';
|
||
fetch('/api/report-ad', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ campaignId, reason, note }) })
|
||
.then(() => status('Thanks — this ad was reported to the admin for review.', 'ok'))
|
||
.catch(() => status('Could not send the report. Try again.', 'bad'));
|
||
}
|
||
const reportTag = ad => ' <a class="ad-report small muted" href="#" data-cid="' + ad.id + '" style="margin-left:8px">⚠ report</a>';
|
||
function wireReport(el) {
|
||
const rl = el.querySelector('.ad-report');
|
||
if (rl) rl.addEventListener('click', e => { e.preventDefault(); reportAd(Number(rl.dataset.cid)); });
|
||
}
|
||
async function adSlot(type, elId, opts) {
|
||
try {
|
||
const q = '/api/ads/slot?type=' + type + (opts && opts.width ? '&w=' + opts.width + '&h=' + opts.height : '');
|
||
const { ad } = await (await fetch(q)).json();
|
||
const el = $(elId);
|
||
if (!ad || !el) return;
|
||
if (ad.imageUrl) {
|
||
el.style.textAlign = 'center';
|
||
el.innerHTML = '<a href="' + ad.targetUrl + '" target="_blank" rel="noopener nofollow">'
|
||
+ '<img src="' + ad.imageUrl + '" alt="advertisement" style="max-width:min(100%,728px);height:auto;display:block;margin:0 auto;border-radius:8px"></a>'
|
||
+ '<div class="small muted">member ad' + reportTag(ad) + '</div>';
|
||
} else {
|
||
el.innerHTML = '<a href="' + ad.targetUrl + '" target="_blank" rel="noopener nofollow"><b>' + ad.title + '</b>'
|
||
+ (ad.body ? ' · ' + ad.body : '') + '</a> <span class="small muted">member ad' + reportTag(ad) + '</span>';
|
||
}
|
||
wireReport(el);
|
||
el.hidden = false;
|
||
} catch (e) {}
|
||
}
|
||
return { getConfig, fmtPol, fmtUsd, status, renderNav, refreshNavWallet, describeEvent, feedRow, adSlot, reportAd, $ };
|
||
})();
|