48dcd71290
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
210 lines
13 KiB
JavaScript
210 lines
13 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">'
|
||
+ '<span class="logo-wrap"><a class="logo" href="/"><img src="/logo.png" alt="InstantAdPay" style="height:30px;display:block"></a><span class="byline">Brought to you by the <b>Crypto Team Build Network</b></span></span>'
|
||
+ '<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;
|
||
const when = ev.ts ? new Date(ev.ts).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }) : '';
|
||
div.innerHTML = (when ? '<span class="when" title="' + new Date(ev.ts).toLocaleString() + '">' + when + '</span>' : '') + '<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;
|
||
el.hidden = false;
|
||
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) {}
|
||
}
|
||
// ── sign-up code request with the invisible guard fields (form age + honeypot)
|
||
// and the icon check the server asks for only after an IP trips a limit ──
|
||
const FORM_TS = Date.now();
|
||
function iconCheck(host, ch, note) {
|
||
return new Promise(resolve => {
|
||
host.hidden = false;
|
||
host.innerHTML = '<div class="small" style="margin:0 0 8px">' + (note ? esc(note) + ' ' : '') + 'Tap the <b>' + esc(ch.prompt) + '</b>.</div>'
|
||
+ '<div class="icon-check">' + ch.options.map(o => '<button type="button" class="ic-btn">' + esc(o) + '</button>').join('') + '</div>';
|
||
host.querySelectorAll('.ic-btn').forEach(b => b.addEventListener('click', () => { host.innerHTML = ''; host.hidden = true; resolve(b.textContent); }, { once: true }));
|
||
});
|
||
}
|
||
async function requestCode(email, opts) {
|
||
const o = opts || {};
|
||
let pick = null;
|
||
for (let i = 0; i < 4; i++) {
|
||
const r = await (await fetch('/api/auth/email/start', { method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ email, fts: FORM_TS, website: (o.honeypot && o.honeypot.value) || '', pick }) })).json();
|
||
if (r.challenge && o.host) { pick = await iconCheck(o.host, r.challenge, r.error); continue; }
|
||
if (r.error) throw new Error(r.error);
|
||
return r;
|
||
}
|
||
throw new Error('Could not verify. Refresh the page and try again.');
|
||
}
|
||
const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
||
// ── founding-week checklist, read from the live account. Shared by /launch and
|
||
// the dashboard mark. Two items are the member's own call and persist locally.
|
||
const LAUNCH_KEY = 'iap.launch.manual';
|
||
const manualSet = () => { try { return new Set(JSON.parse(localStorage.getItem(LAUNCH_KEY) || '[]')); } catch (e) { return new Set(); } };
|
||
function launchToggle(key) { const s = manualSet(); if (s.has(key)) s.delete(key); else s.add(key); try { localStorage.setItem(LAUNCH_KEY, JSON.stringify([...s])); } catch (e) {} }
|
||
function launchChecks(me) {
|
||
const m = me || {}, man = manualSet();
|
||
const bc = Number(m.buyerCount || 0), refs = (m.referrals || []).length;
|
||
return [
|
||
{ key: 'username', title: 'Pick your username', done: !!m.username, href: '/my#profile', cta: 'Profile',
|
||
how: 'Profile tab. It becomes your invite link and your public page, and it is permanent.',
|
||
why: 'Every link, banner and video you hand out this week carries it. Change it later and the links you already sent die.' },
|
||
{ key: 'wallet', title: 'Link your wallet', done: !!m.address, href: '/my#wallet', cta: 'Wallet',
|
||
how: 'Wallet tab, Connect, sign the free message. MetaMask recommended. Never held crypto? The wallet guide in Training walks through buying POL with a card.',
|
||
why: 'Payouts go to this address. No wallet, nowhere to pay you.' },
|
||
{ key: 'payouts', title: 'Switch on payouts', done: !!m.memberId, href: '/my#wallet', cta: 'Wallet',
|
||
how: 'Wallet tab, one small transaction. It registers your address with the contract.',
|
||
why: 'The contract binds each buyer to their sponsor at their first purchase. If payouts are off when your first person buys, that commission is not yours.' },
|
||
{ key: 'level2', title: 'Qualify: open level 2', done: bc >= 2, href: '/my#buy', cta: 'Buy packages',
|
||
how: 'Two of your people buy a $20 or more package. Or use Qualified Start: add two positions from extra wallets in your own MetaMask and buy a $20 package from each (about $23 of POL in each wallet).',
|
||
why: 'Until you have two qualifying buyers, every level 2 payment from your team climbs past you.',
|
||
note: 'Qualifying buyers so far: <b>' + bc + '</b> of 2.' },
|
||
{ key: 'level3', title: 'The leader play: open all three levels', done: bc >= 5, href: '/my#buy', cta: 'Qualified Start',
|
||
how: 'Five qualifying buyers, real or Qualified Start, up to five linked positions. Once qualified, buy from your main wallet so your sponsor is paid in full.',
|
||
why: 'A leader whose team goes three deep this week collects level 3 from day one instead of watching those 10% payments pass upward. Optional for members, the play for leaders.',
|
||
note: 'Qualifying buyers so far: <b>' + bc + '</b> of 5.' },
|
||
{ key: 'banner', title: 'Upload your line banner', done: !!m.lineBannerUrl, href: '/my#profile', cta: 'Profile',
|
||
how: 'Profile tab, line banner. It shows on the welcome tour to everyone in your next three levels.',
|
||
why: 'Your first advertising to your own team, free, and it is live the moment they join.' },
|
||
{ key: 'links', title: 'Copy your links and pick a play', done: man.has('links'), manual: true,
|
||
how: 'Promo tools, Your links: the invite link and the five angle links, plus the matching hook videos. Then read the plays page and choose one.',
|
||
why: 'On launch day you send links, not explanations. Having them ready is the whole difference between a launch and a scramble.' },
|
||
{ key: 'two', title: 'Place your first two', done: refs >= 2, href: '/my#line', cta: 'My line',
|
||
how: 'Two people you have actually talked to, joined through your link, walked through items 1 to 3 on their own accounts.',
|
||
why: 'Your first two are the shape of your whole line. Choose them, do not wait for them.',
|
||
note: 'Joined through you so far: <b>' + refs + '</b>.' }
|
||
];
|
||
}
|
||
return { getConfig, fmtPol, fmtUsd, status, renderNav, refreshNavWallet, describeEvent, feedRow, adSlot, reportAd, requestCode, launchChecks, launchToggle, $ };
|
||
})();
|