// My account: email-first join/login, wallet link at purchase time,
// free payout activation, invite link, on-chain activity.
(async function () {
// back-office shell: no marketing nav here; rehearsal notice lives in the top bar
const $ = IAP.$;
try {
const c = await IAP.getConfig();
if (c.rehearsal && $('boRehearsal')) {
$('boRehearsal').hidden = false;
$('boRehearsal').innerHTML = 'Testnet rehearsal · ' + c.chainName;
}
if (c.rehearsal && $('faucetCard')) $('faucetCard').hidden = false;
} catch (e) {}
// if they arrived through a sponsor's link, show who they're joining under
(async () => {
try {
const sp = await (await fetch('/api/sponsor')).json();
if (sp && sp.invited && sp.name && $('sponsorNote')) { $('sponsorNoteName').textContent = sp.name; $('sponsorNote').hidden = false; }
} catch (e) {}
})();
async function api(path, body) {
const r = await (await fetch(path, { method: 'POST',
headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body || {}) })).json();
if (r.error) throw new Error(r.error);
return r;
}
function nextMove(d) {
if (!d.address) return 'Grab your link below and start sharing today. Then link your wallet (one free signature) so every payment can lock to you before your people start buying.';
if (!d.memberId) return 'Your wallet is linked. Switch on payouts above (one free transaction) and every purchase in your line pays you the moment it happens.';
if (d.buyerCount === 0) return 'Payouts are on and your link is live. Your next milestone: your first buyer of $20 or more. Every direct pays you 50 percent from their very first package.';
if (d.buyerCount === 1) return 'One qualifying buyer down, one to go. Your next $20+ buyer unlocks level 2: 20 percent of everything your people’s people buy.';
if (d.buyerCount < 5) return 'Level 2 is open. ' + (5 - d.buyerCount) + ' more qualifying buyer(s) unlock level 3 and the full three-level flow.';
return 'Fully qualified. Every level pays you, and you catch the pass-ups that under-qualified positions below you let slip. Keep sharing and keep your campaigns running.';
}
// featured rotation strip on the overview
let featItems = [], featIdx = 0, featTimer = null;
function renderOneFeatured() {
if (!featItems.length) return;
const i = featItems[featIdx % featItems.length];
$('featStrip').innerHTML = '' + esc(i.title) + ''
+ (i.by ? '' + esc(i.by) + '' : '') + '';
}
async function loadFeatured() {
try {
const r = await (await fetch('/api/featured')).json();
const card = $('featuredCard'); if (!card) return;
if (!r.items || !r.items.length) { card.hidden = true; return; }
card.hidden = false;
featItems = r.items; featIdx = Math.floor(Math.random() * featItems.length);
$('featSub').textContent = r.items.length + ' link' + (r.items.length === 1 ? '' : 's') + ' in rotation';
renderOneFeatured(); // show ONE at a time (true rotation), cycle if more than one
clearInterval(featTimer);
if (featItems.length > 1) featTimer = setInterval(() => { featIdx++; renderOneFeatured(); }, 6000);
// self-sell: today's open slots + a CTA to feature your own link
try {
const st = await (await fetch('/api/featured/stats')).json();
const today = (st.occupancy || []).find(d => d.offset === 0);
const cta = $('featCta');
if (cta) {
cta.innerHTML = (today ? '' + today.open + ' of ' + today.cap + ' featured slots open today. ' : '')
+ 'Feature your link →';
const fb = $('featBuy');
if (fb) fb.addEventListener('click', e => { e.preventDefault(); setPane('campaigns'); const ct = $('cType'); if (ct) { ct.value = 'featured'; ct.dispatchEvent(new Event('change')); } });
}
} catch (e) {}
} catch (e) {}
}
// achievement badges: the milestone ladder as unlockable medals + share-to-image
const BADGES = [ // ribbonY = vertical center of each badge's name ribbon (art differs per tier)
{ key: 'payouts', label: 'Spark', sub: 'payouts on', img: '/badges/badge-spark.jpg', ribbonY: 0.728 },
{ key: 'firstBuyer', label: 'Surge', sub: 'first qualifying buyer', img: '/badges/badge-surge.jpg?v=2', ribbonY: 0.76 },
{ key: 'level2', label: 'Circuit', sub: '2 qualifying buyers', img: '/badges/badge-circuit.jpg?v=2', ribbonY: 0.72 },
{ key: 'level3', label: 'Nexus', sub: 'fully qualified', img: '/badges/badge-nexus.jpg?v=2', ribbonY: 0.73 }
];
let lastMilestones = [];
function renderBadges(d) {
const reached = d.milestonesReached || [];
lastMilestones = reached;
const strip = $('badgeStrip');
if (!strip) return;
$('badgeCard').hidden = false;
strip.innerHTML = BADGES.map(b => {
const got = reached.includes(b.key);
return '
'
+ ''
+ '
' + b.label + '
' + b.sub + '
'
+ (got ? '' : '
🔒 locked
') + '
';
}).join('');
strip.querySelectorAll('[data-badge]').forEach(btn =>
btn.addEventListener('click', () => shareBadge(btn.dataset.badge, d)));
// celebrate anything newly granted this load
if (d.milestonesGranted && d.milestonesGranted.length) {
const total = d.milestonesGranted.reduce((s, g) => s + g.credited, 0);
const names = d.milestonesGranted.map(g => (BADGES.find(b => b.key === g.key) || {}).label).filter(Boolean).join(', ');
IAP.status('🏆 Achievement unlocked: ' + names + ' — +' + total + ' bonus credits!', 'ok');
}
}
// compose a shareable badge image on a canvas (zero-dep) and download it
// composite the ornate badge template with the member's @username on the ribbon
function shareBadge(key, d) {
const b = BADGES.find(x => x.key === key); if (!b) return;
const who = d.username ? d.username : d.memberId ? 'member #' + d.memberId : '';
const img = new Image();
img.onload = () => {
const c = document.createElement('canvas'); c.width = img.width; c.height = img.height;
const x = c.getContext('2d');
x.drawImage(img, 0, 0);
if (who) { // name it on the blank ribbon: gold with a dark outline so it reads on any badge color
x.textAlign = 'center';
x.textBaseline = 'middle';
x.font = 'bold ' + Math.round(c.width * 0.055) + 'px Sora, "Segoe UI", sans-serif';
const y = c.height * (b.ribbonY || 0.75);
x.lineJoin = 'round';
x.lineWidth = Math.max(3, Math.round(c.width * 0.008));
x.strokeStyle = 'rgba(4,20,15,.9)';
x.strokeText(who, c.width / 2, y);
x.fillStyle = '#ffd15c';
x.fillText(who, c.width / 2, y);
x.textAlign = 'left';
x.textBaseline = 'alphabetic';
}
try {
c.toBlob(bl => {
const a = document.createElement('a');
a.href = URL.createObjectURL(bl);
a.download = 'instantadpay-' + b.label.toLowerCase() + '-badge.jpg';
a.click();
setTimeout(() => URL.revokeObjectURL(a.href), 5000);
IAP.status('Your ' + b.label + ' badge is saved — share it anywhere.', 'ok');
}, 'image/jpeg', 0.9);
} catch (e) { IAP.status('Could not generate the image.', 'bad'); }
};
img.onerror = () => IAP.status('Could not load the badge art.', 'bad');
img.src = b.img; // same-origin, canvas stays untainted
}
// milestone stepper under "Your next move": lit nodes for what's done,
// amber glow on the current target — the same ladder the contract pays
function renderSteps(d) {
const el = $('ncSteps');
if (!el) return;
const b = d.buyerCount || 0;
const steps = [
{ label: 'Joined', sub: 'free account', hit: true },
{ label: 'Spark', sub: 'payouts on', hit: !!d.memberId },
{ label: 'Surge', sub: 'first buyer · 50%', hit: b >= 1 },
{ label: 'Circuit', sub: '2 buyers · +20%', hit: b >= 2 },
{ label: 'Nexus', sub: '5 buyers · +10%', hit: b >= 5 }
];
const cur = steps.findIndex(s => !s.hit);
el.innerHTML = steps.map((s, i) =>
'
';
el.appendChild(tbl);
el.querySelectorAll('button[data-camp]').forEach(b => b.addEventListener('click', async () => {
try { await api('/api/my/campaigns/' + b.dataset.camp + '/' + b.dataset.act); await loadCampaigns(); }
catch (e) { IAP.status(e.message, 'bad'); }
}));
el.querySelectorAll('button[data-topup]').forEach(b => b.addEventListener('click', async () => {
const n = prompt('How many credits to add to this campaign? (buys more views)');
if (!n) return;
try { const r = await api('/api/my/campaigns/' + b.dataset.topup + '/topup', { credits: Number(n) });
IAP.status('Added ' + r.added + ' credits' + (r.reactivated ? ' — campaign is live again.' : '.'), 'ok');
await loadCampaigns(); loadDashboard();
} catch (e) { IAP.status(e.message, 'bad'); }
}));
} catch (e) {}
}
let lastRates = null;
function soloHint() {
if (!lastRates || $('cType').value !== 'solo') return;
const cost = lastRates.soloCostPerRecipient || 5;
const n = Math.floor((Number($('cBudget').value) || 0) / cost);
$('cSoloHint').textContent = cost + ' credits per guaranteed inbox delivery'
+ (n ? ' — this budget reaches ' + n + ' members' : '')
+ '. Readers earn ' + (lastRates.soloReadCredits || 2) + ' credits for a real read, so your message gets opened.';
}
$('cBudget').addEventListener('input', soloHint);
// rich solo editor: small toolbar over contenteditable (CSP allows no external editor);
// the server whitelist-sanitizes whatever HTML arrives, this is just authoring comfort
document.querySelectorAll('.ed-bar [data-cmd]').forEach(btn =>
btn.addEventListener('click', () => { $('cSoloEd').focus(); document.execCommand(btn.dataset.cmd, false, null); }));
document.querySelectorAll('.ed-bar [data-block]').forEach(btn =>
btn.addEventListener('click', () => { $('cSoloEd').focus(); document.execCommand('formatBlock', false, btn.dataset.block); }));
$('edLinkBtn').addEventListener('click', () => {
const url = prompt('Link URL (https://…)');
if (!url) return;
$('cSoloEd').focus();
document.execCommand('createLink', false, url);
});
// inline media: upload, then drop the element at the cursor (BV-style)
let mediaMode = 'image';
function insertHtmlAtCursor(html) {
const ed = $('cSoloEd');
ed.focus();
if (!document.execCommand('insertHTML', false, html)) ed.insertAdjacentHTML('beforeend', html);
}
$('edImgBtn').addEventListener('click', () => { mediaMode = 'image'; $('cSoloFile').accept = 'image/png,image/jpeg,image/webp,image/gif'; $('cSoloFile').click(); });
$('edVidBtn').addEventListener('click', () => { mediaMode = 'video'; $('cSoloFile').accept = 'video/mp4,video/webm'; $('cSoloFile').click(); });
$('cSoloFile').addEventListener('change', async () => {
const f = $('cSoloFile').files[0];
if (!f) return;
$('edMediaInfo').textContent = 'Uploading ' + f.name + '…';
try {
const r = await (await fetch('/api/my/upload', { method: 'POST', headers: { 'Content-Type': f.type }, body: f })).json();
if (r.error) { $('edMediaInfo').textContent = r.error; $('cSoloFile').value = ''; return; }
insertHtmlAtCursor(r.type === 'video'
? '
'
: '
');
$('edMediaInfo').textContent = f.name + ' inserted';
} catch (e) { $('edMediaInfo').textContent = 'Upload failed. Try again.'; }
$('cSoloFile').value = '';
});
// raw-text toggle: swap the WYSIWYG surface for the underlying HTML and back
$('edRawBtn').addEventListener('click', () => {
const ed = $('cSoloEd'), raw = $('cSoloRaw');
if (raw.hidden) { raw.value = ed.innerHTML; raw.hidden = false; ed.hidden = true; $('edRawBtn').textContent = 'Visual'; }
else { ed.innerHTML = raw.value; ed.hidden = false; raw.hidden = true; $('edRawBtn').textContent = 'Raw text'; }
});
const WHERE = { banner: 'Runs in the ad viewer, on the home page, the live ledger, every Overview, the sidebar tile, and out in the Network Ad Space rotation across the wider network.',
text: 'Runs in the ad viewer, the live ledger text slot, and out in the Network Ad Space rotation.',
login: 'Full screen for every member who signs in, ten seconds, once a day per member. Opens in a fresh tab, so any working page qualifies. Login ads spend purchased credits only.',
solo: 'Delivered into member inboxes under Earn credits. Each read is timed and rewarded, so it gets opened.',
video: 'Plays in Watch videos and the Shorts feed under Earn credits. You pay only for completed watches.',
featured: 'Your headline and link in the Featured strip on every member Overview for the days you book.',
visits: 'A distinct member opens your site in a new tab, stays eight seconds and passes a check. Nobody counts twice.' };
const whereHint = () => { const el = $('cWhere'); if (el) el.textContent = WHERE[$('cType').value] || ''; };
whereHint();
$('cType').addEventListener('change', () => {
whereHint();
const t = $('cType').value;
$('cImageRow').hidden = t !== 'banner'; // only banners carry a creative; login frames its URL
$('cSizeRow').hidden = t !== 'banner';
$('cTitleRow').hidden = t !== 'text' && t !== 'solo';
$('cBodyRow').hidden = t !== 'text';
$('cSoloRow').hidden = t !== 'solo';
$('cSoloHint').hidden = t !== 'solo';
$('cVideoRow').hidden = t !== 'video';
$('cFeaturedRow').hidden = t !== 'featured';
$('cVisitsRow').hidden = t !== 'visits';
// fixed-cost types derive their spend (featured = day slots, visits = flat pack),
// so hide the free-form Budget field for them to avoid confusion
$('cBudgetRow').hidden = (t === 'featured' || t === 'visits');
if ($('cCapRow')) $('cCapRow').hidden = !(t === 'banner' || t === 'text');
// solo ads have a real floor (5cr x 10 deliveries): default to 50 so 10 isn't rejected
if (t === 'solo' && (!$('cBudget').value || Number($('cBudget').value) < 50)) $('cBudget').value = (lastRates && lastRates.soloCostPerRecipient ? lastRates.soloCostPerRecipient : 5) * 10;
if (t === 'visits') visitHint();
$('cTitle').placeholder = t === 'solo' ? 'Subject line (max 80)' : 'Headline (max 60)';
soloHint();
if (t === 'video') videoHint();
if (t === 'featured') featHint();
});
let featStartDay = 0; // selected start-day offset (0 = today)
async function featHint() {
if (!lastRates || !lastRates.featuredDurations) return;
if ($('cFeatDays') && !$('cFeatDays').options.length)
$('cFeatDays').innerHTML = lastRates.featuredDurations.map(dys =>
'').join('');
let s = null;
try { s = await (await fetch('/api/featured/stats')).json(); } catch (e) {}
if (s && s.occupancy) {
const grid = $('cFeatDaysGrid');
grid.innerHTML = s.occupancy.map(d => {
const label = d.offset === 0 ? 'Today' : d.offset === 1 ? 'Tomorrow' : new Date(d.day + 'T00:00:00Z').toLocaleDateString(undefined, { weekday: 'short', month: 'numeric', day: 'numeric' });
const full = d.open <= 0;
return '
Glad you are in. Three things today, in this order:
Pick your username on the Profile tab (it becomes your link).
Wallet tab: Connect and link wallet, then Switch on payouts. Both are free.
Copy your invite link from My line and send it to one person.
Reply here if you get stuck on any of them. That is what I am here for.
' },
{ label: 'Switch on payouts', subject: 'One free step so nothing passes you by', html: '
Quick reminder: if payouts are not switched on yet, do it now on the Wallet tab. One free transaction.
The contract locks each buyer to their sponsor at their first purchase, and payouts only route to wallets that are switched on. Ready early and you never miss one.
' },
{ label: 'The $5 test', subject: 'See a payout land in real time', html: '
Want to see the whole thing work? Buy the $5 Micro package on Buy packages and watch the live ledger while you do it. You will see your credits mint and the split go out in the same transaction.
When you are ready to count as a qualifying buyer for me, the $20 Activation package is the one.
' },
{ label: 'Qualified Start', subject: 'How to open level 2 today with your own positions', html: '
You can be your own first buyers, openly. On Buy packages, the Qualified Start card lets you link a second wallet you own as a position. When it buys a $20 package, it counts as a qualifying buyer, half comes straight back to your main wallet, and the credits pool with yours.
Two positions open level 2 the same day. The three Qualified Start videos in Training show every click.
' },
{ label: 'Share your link', subject: 'One conversation a day is the whole job', html: '
Promo tools has posts, texts and emails that already carry your link. Pick one and send it to one person today.
Do not wait for the perfect moment. Nobody who waited ever built a line.
' }
];
(function () {
const w = $('bcTemplates'); if (!w) return;
w.innerHTML = BC_TEMPLATES.map((t, i) => '').join('');
w.querySelectorAll('[data-bct]').forEach(b => b.addEventListener('click', () => { const t = BC_TEMPLATES[Number(b.dataset.bct)]; $('bcSubject').value = t.subject; $('bcEd').innerHTML = t.html; $('bcEd').focus(); }));
})();
// ── Qualified Start calculator ──
(function () {
const n = $('qcN'), pk = $('qcPkg'), out = $('qcOut'); if (!n || !pk || !out) return;
const CR = { 20: 2000, 50: 5500, 100: 12000, 250: 32500 };
const calc = () => {
const k = Math.max(1, Math.min(10, Number(n.value) || 1)), usd = Number(pk.value) || 20;
const gross = k * usd, back = gross / 2, net = gross - back, credits = k * (CR[usd] || 0);
const level = k >= 5 ? 'Level 3 open (and level 2): the Nexus badge, wall position 3, full 50 / 20 / 10' : k >= 2 ? 'Level 2 open: 20% on your directs\' buyers, wall position 2' : 'Counts as one qualifying buyer. One more opens level 2.';
out.innerHTML = '
$' + gross + ' out across ' + k + ' position' + (k === 1 ? '' : 's') + ' $' + back + ' back to your main wallet in the same transactions (the 50% direct-sponsor share) $' + net + ' net, plus a little POL for gas in each wallet
'
+ '
' + credits.toLocaleString() + ' credits pooled for your own ads ' + level + ' The 20% and 10% shares go to your upline if they are qualified, otherwise to the platform.
Pooled credits: ' + (r.totalCredits || 0).toLocaleString() + '' + (r.credited ? ' plus ' + r.credited.toLocaleString() + ' credited to your account (spends from any position)' : '') + '. A campaign budget spends from one position at a time.
' : '');
if ($('qsList')) $('qsList').innerHTML = html;
// Wallet tab: live POL balance of the linked wallet, and which package it covers
const wb = $('walletBal');
if (wb && r.main && r.main.address && r.main.balanceWei != null) {
const polN = Number(BigInt(r.main.balanceWei) / 10n ** 14n) / 10000;
const usd = r.polUsd ? polN * r.polUsd : 0;
const pkgs = [5, 20, 50, 100, 250];
const covers = r.polUsd ? pkgs.filter(p => usd >= p * 1.06 + 0.05) : [];
wb.hidden = false;
wb.innerHTML = 'Wallet balance: ' + polN.toLocaleString(undefined, { maximumFractionDigits: 2 }) + ' POL' + (usd ? ' (about $' + usd.toLocaleString(undefined, { maximumFractionDigits: 0 }) + ')' : '')
+ (r.polUsd ? ' ' + (covers.length ? 'Enough for the $' + covers[covers.length - 1] + ' package with gas to spare.' : 'Not enough for the $5 package yet. Buy POL with a card on the Buy packages tab, or send POL to this wallet.') + (covers.length && covers.length < pkgs.length ? ' Top up for the $' + pkgs[covers.length] + ' package.' : '') + '' : '');
}
if ($('posList')) $('posList').innerHTML = html;
if ($('posCard')) $('posCard').hidden = !list.length;
// "Buy from" picker: main + every position
const sel = $('buyFrom');
if (sel) {
const keep = sel.value;
sel.innerHTML = ''
+ list.map((p, i) => '').join('');
if (keep && [...sel.options].some(o => o.value === keep)) sel.value = keep;
$('buyFromWrap').hidden = !list.length;
}
document.querySelectorAll('[data-unlink]').forEach(b => b.addEventListener('click', async () => {
if (!confirm('Unlink ' + short(b.dataset.unlink) + ' from your account?')) return;
try { await api('/api/my/positions/remove', { address: b.dataset.unlink }); IAP.status('Position unlinked.', 'ok'); loadPositions(); }
catch (e) { IAP.status(e.message, 'bad'); }
}));
} catch (e) {}
}
if ($('qsAddBtn')) $('qsAddBtn').addEventListener('click', busy2($('qsAddBtn'), async () => {
const me = await (await fetch('/api/me')).json();
if (!me.address) throw new Error('Link your main wallet first (Wallet tab), then add positions under it.');
if (!me.memberId) throw new Error('Switch on payouts for your main wallet first (Wallet tab). Positions register under your member number.');
if (!confirm('Your wallet will ask which account to connect. Tick ONLY the new account (not ' + short(me.address) + '), then sign once.\n\nIf you have not created the extra account yet: MetaMask, account menu, Add account. Trust or SafePal: switch wallet.\n\nReady?')) return;
IAP.status('Pick the new account in your wallet, then sign once…');
const r = await IAPWallet.signIn({ asPosition: true, pick: true });
$('qsHint').textContent = 'Added ' + short(r.address) + '. Now choose it under "Buy from" and buy a $20 or larger package.';
IAP.status('Position added: ' + short(r.address) + '. Pick it under "Buy from" above and buy a $20+ package to count it.', 'ok');
await loadPositions();
const sel = $('buyFrom'); if (sel) sel.value = r.address.toLowerCase();
try { $('buyFrom').scrollIntoView({ behavior: 'smooth', block: 'center' }); } catch (e) {}
}));
async function loadBuyTiles() {
try {
const { products } = await (await fetch('/api/catalog')).json();
const wrap = $('boTiles');
wrap.innerHTML = '';
for (const p of products) {
const bonus = p.creditAmount - p.priceCents;
const div = document.createElement('div');
div.className = 'tile' + (p.priceCents === 5000 ? ' hot' : '');
div.innerHTML = '
' + (p.costWei ? IAP.fmtPol(p.costWei) + ' POL right now' : 'paused') + '
'
+ '';
wrap.appendChild(div);
}
wrap.querySelectorAll('button[data-id]').forEach(b => b.addEventListener('click', async () => {
try {
b.disabled = true;
// the buyer's credits are read by the member id of their LINKED wallet.
// If they only connected a wallet (e.g. for the faucet) but never linked
// it, link it first (one signature) so the purchase's credits show up.
// retry these through transient failures — mobile drops in-flight
// fetches when the page returns from the wallet app-switch
const jretry = async url => { let err; for (let i = 0; i < 4; i++) { try { return await (await fetch(url)).json(); } catch (e) { err = e; await new Promise(s => setTimeout(s, 500 * (i + 1))); } } throw err; };
const meNow = await jretry('/api/me');
// which of the member's wallets is buying: the main wallet (default) or a
// linked position (Qualified Start). A position registers under the main
// member id on its first buy, so its sponsor is always this member.
const fromSel = $('buyFrom');
const fromPos = (fromSel && !$('buyFromWrap').hidden && fromSel.value && fromSel.value !== 'main') ? fromSel.value.toLowerCase() : null;
if (fromPos && !meNow.memberId) { IAP.status('Switch on payouts for your main wallet first (Wallet tab), so this position can register under you.', 'bad'); return; }
if (!fromPos && !meNow.address) {
IAP.status('Link your wallet first — one quick signature…');
await IAPWallet.signIn();
}
const wantAddr = fromPos || (meNow.address ? meNow.address.toLowerCase() : null);
if (wantAddr) { // never buy from a wallet other than the one selected: a stray wallet would register a brand-new member
await IAPWallet.connect();
let cur = String(await IAPWallet.activeAddress() || '').toLowerCase();
if (cur !== wantAddr) {
IAP.status('Pick ' + wantAddr.slice(0, 6) + '…' + wantAddr.slice(-4) + ' in your wallet\'s account picker…');
cur = String(await IAPWallet.pickAccount() || '').toLowerCase();
}
if (cur !== wantAddr) throw new Error('Your wallet connected as ' + cur.slice(0, 6) + '…' + cur.slice(-4) + ' but you chose ' + wantAddr.slice(0, 6) + '…' + wantAddr.slice(-4) + '. Switch accounts in your wallet app and try again.');
}
const spNow = fromPos ? { sponsorId: meNow.memberId } : await jretry('/api/sponsor');
// pre-flight: stop early if the POL is not there. Trust Wallet also hard-blocks any
// transaction that spends most of the balance ("drain your wallet"), so Trust users
// get a heads-up first; other wallets go straight to the confirmation.
try {
const need = BigInt(b.dataset.cost) + BigInt(b.dataset.cost) / 50n; // same 2% pad as buy()
const bal = await IAPWallet.balance(wantAddr || IAPWallet.address() || meNow.address);
if (bal < need) {
IAP.status('That wallet holds ' + IAP.fmtPol(bal.toString()) + ' POL, but this package needs about ' + IAP.fmtPol(need.toString()) + ' POL plus a little for gas. Top it up and try again.', 'bad');
return;
}
const pct = Number(need * 100n / bal);
const isTrust = /trust/i.test(IAPWallet.walletName() || '');
if (isTrust && pct > 55 && !confirm('Heads up for Trust Wallet users: this purchase uses about ' + pct + '% of the POL in your wallet, and Trust Wallet refuses transactions that spend most of the balance (it shows a "drain your wallet" warning with only Stop and go back).' + '\n\n' + 'Options: pick a smaller package first, add some POL, or connect a different wallet (MetaMask, Phantom, SafePal). Extra POL always stays yours.' + '\n\n' + 'Try it anyway?')) {
IAP.status('Purchase paused. Pick a smaller package, add POL, or connect another wallet, then try again.', 'ok');
return;
}
} catch (e) { /* balance read failed: let the wallet decide */ }
IAP.status('Confirm the purchase in your wallet…');
const r = await IAPWallet.buy(Number(b.dataset.id), spNow.sponsorId || 0, b.dataset.cost);
if (r.status !== '0x1' && r.receipt && r.receipt.status !== '0x1') throw new Error('Transaction reverted.');
IAP.status(fromPos ? 'Purchase settled on-chain. That position now counts toward your qualification, and its credits pool with yours.' : 'Purchase settled on-chain. Credits are in your account.', 'ok');
await render();
loadBuyTiles();
} catch (e) { IAP.status('Purchase failed: ' + ((e && e.message) || e), 'bad'); }
finally { b.disabled = false; }
}));
// brand new to crypto? buy POL with a card, sent straight to the wallet
const builder = products.find(p => p.priceCents === 5000) || products[products.length - 1];
const needPol = (builder && builder.costWei) ? Math.max(30, Math.ceil(Number(builder.costWei) / 1e18) + 3) : 30;
const cta = document.createElement('div');
cta.style.cssText = 'grid-column:1/-1;margin-top:10px;text-align:center';
cta.innerHTML = '
New to crypto? Buy POL with a debit or credit card, Apple Pay, or Google Pay. It lands straight in your own wallet, and this site never touches your money.
'
+ ' Wallet and MoonPay guide';
wrap.appendChild(cta);
const mb = $('moonpayBtn'); if (mb) mb.addEventListener('click', () => openMoonpay(needPol));
} catch (e) {}
}
loadBuyTiles();
// ── earn-by-viewing: daily ad set with dwell, then claim ──
const earnState = { types: ['banner', 'text'], i: 0, timer: null };
async function earnRefresh() {
try {
const st = await (await fetch('/api/my/earn')).json();
if (st.error) return null;
$('earnProgress').textContent = 'today: ' + st.views + ' / ' + st.target + ' ads viewed';
$('earnBalance').textContent = (st.earnedAvailable != null ? st.earnedAvailable : st.earned) + ' earned credits available' + (st.reserved ? ' · ' + st.reserved + ' in live campaigns' : '');
const done = st.views >= st.target;
$('earnClaimBtn').hidden = !(done && !st.claimed);
if (st.claimed) {
$('earnHint').textContent = 'Claimed for today. Come back tomorrow, or put those credits to work in Campaigns.';
const box = $('earnAdBox'); if (box && !box.querySelector('.done-big')) box.innerHTML = '
✓Ads done for todayToday\'s set is viewed and claimed. Fresh ads tomorrow.
';
if ($('earnStartBtn')) $('earnStartBtn').hidden = true;
} else if ($('earnStartBtn')) $('earnStartBtn').hidden = false;
else if (done) $('earnHint').textContent = 'Set complete. Claim your ' + st.claimCredits + ' credits.';
return st;
} catch (e) { return null; }
}
async function earnShowAd() {
// each view happens full screen in its own tab: /view/ frames the
// advertiser's site, runs the countdown, then a human check credits it
const type = earnState.types[earnState.i++ % earnState.types.length];
let r = null;
try { r = await (await fetch('/api/my/earnview?type=' + type)).json(); } catch (e) {}
const box = $('earnAdBox');
if (!r || !r.ad || !r.viewUrl) {
await earnRefresh();
box.innerHTML = '' + (r && r.status && r.status.views >= r.status.target
? 'Set complete for today.'
: 'No member ads are live in rotation right now. Views resume the moment a campaign is running.') + '';
return;
}
openAdOverlay(r.viewUrl);
box.innerHTML = 'Watch the countdown and pass the quick check. Your view credits itself and this page updates right away.';
$('earnStartBtn').textContent = 'View next ad';
}
// In-page ad overlay: no new tab (mobile and in-app wallet browsers handle tabs
// badly), no window.close needed. The /view page runs the dwell + check inside,
// messages back when it credits or when the user is done.
function openAdOverlay(url) {
closeAdOverlay();
const back = document.createElement('div');
back.id = 'adOverlay';
back.style.cssText = 'position:fixed;inset:0;z-index:100000;background:#050b09;display:flex;flex-direction:column';
const x = document.createElement('button');
x.type = 'button'; x.setAttribute('aria-label', 'Close ad');
x.textContent = '✕';
x.style.cssText = 'position:absolute;top:10px;right:12px;z-index:2;width:40px;height:40px;border-radius:50%;border:1px solid rgba(255,255,255,.25);background:rgba(6,10,16,.85);color:#fff;font-size:20px;font-weight:700;cursor:pointer';
x.addEventListener('click', closeAdOverlay);
const ifr = document.createElement('iframe');
ifr.src = url;
ifr.style.cssText = 'flex:1;width:100%;border:0;background:#050b09';
back.appendChild(ifr); back.appendChild(x);
document.body.appendChild(back);
document.body.style.overflow = 'hidden';
}
function closeAdOverlay() {
const m = $('adOverlay'); if (m) m.remove();
document.body.style.overflow = '';
earnRefresh(); loadDashboard();
}
window.addEventListener('message', e => {
if (e.origin !== location.origin || !e.data) return;
if (e.data.t === 'iap-view-close') closeAdOverlay();
else if (e.data.t === 'iap-view-done') { earnRefresh(); loadDashboard(); }
});
$('earnStartBtn').addEventListener('click', () => earnShowAd());
// the viewer tab pings localStorage when a view credits; refresh instantly
window.addEventListener('storage', e => {
if (e.key === 'iap-view-done') { earnRefresh(); loadDashboard(); }
});
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible' && $('pane-earn') && !$('pane-earn').hidden) earnRefresh();
});
$('earnClaimBtn').addEventListener('click', async () => {
try {
const r = await api('/api/my/claim');
IAP.status('+' + r.credited + ' credits earned. Spend them in Campaigns.', 'ok');
await earnRefresh();
loadDashboard();
} catch (e) { IAP.status(e.message, 'bad'); }
});
const busy = (btn, fn) => async () => {
try { btn.disabled = true; await fn(); }
catch (e) { IAP.status((e && e.message) || String(e), 'bad'); }
finally { btn.disabled = false; }
};
// passwordless (feature-flagged on config.emailAuth): code replaces passwords
(async () => {
const cfg = await IAP.getConfig();
if (!cfg.emailAuth) return;
$('passCards').hidden = true;
$('magicCard').hidden = false;
const codeOpts = () => ({ honeypot: $('mcWebsite'), host: $('mcCheck') });
const start = busy($('mcSendBtn'), async () => {
const r = await IAP.requestCode($('mcEmail').value, codeOpts());
$('mcCodeRow').hidden = false;
$('mcVerifyBtn').hidden = false;
$('mcSendBtn').hidden = true;
$('mcResend').hidden = false;
if (r.devCode) { $('mcCode').value = r.devCode; IAP.status('Dev mode: code filled in for you.', 'ok'); }
else IAP.status('Code sent. Check your inbox (and spam, the first time).', 'ok');
$('mcCode').focus();
});
$('mcSendBtn').addEventListener('click', start);
$('mcResend').addEventListener('click', busy($('mcResend'), async () => {
const r = await IAP.requestCode($('mcEmail').value, codeOpts());
if (r.devCode) $('mcCode').value = r.devCode;
IAP.status('Fresh code sent.', 'ok');
}));
$('mcVerifyBtn').addEventListener('click', busy($('mcVerifyBtn'), async () => {
const r = await api('/api/auth/email/verify', { email: $('mcEmail').value, code: $('mcCode').value, newsletter: !!($('nlOptin') && $('nlOptin').checked) });
IAP.status('You are in.', 'ok');
if (r.created && !(r.account && r.account.username)) await showOnboard(); // pick a username first
if (!(await showGauntlet())) await showLoginAd(); // welcome tour outranks the login ad
await render();
}));
})();
// new-member onboarding: choose a username, optionally a bio (both skippable)
function showOnboard() {
return new Promise(resolve => {
const m = $('onboardModal'); if (!m) return resolve();
m.hidden = false; $('obErr').hidden = true;
const done = () => { m.hidden = true; resolve(); };
$('obSkip').onclick = done;
$('obSave').onclick = async () => {
const u = $('obUsername').value.trim();
try {
if (u) await api('/api/my/profile', { username: u });
const bio = $('obBio').value.trim();
if (bio) await api('/api/my/profile-details', { bio });
done();
} catch (e) { $('obErr').hidden = false; $('obErr').textContent = e.message || 'Could not save that. Try a different username.'; }
};
});
}
$('signupBtn').addEventListener('click', busy($('signupBtn'), async () => {
const r = await api('/api/signup', { email: $('suEmail').value, password: $('suPass').value, newsletter: !!($('nlOptin') && $('nlOptin').checked) });
IAP.status('Welcome aboard. You are in.', 'ok');
if (!(r.account && r.account.username)) await showOnboard();
await render();
}));
$('loginBtn').addEventListener('click', busy($('loginBtn'), async () => {
await api('/api/login', { email: $('liEmail').value, password: $('liPass').value });
IAP.status('Logged in.', 'ok');
if (!(await showGauntlet())) await showLoginAd(); // welcome tour outranks the login ad
await render();
}));
$('linkBtn').addEventListener('click', busy($('linkBtn'), async () => {
IAP.status('Check your wallet for the free link signature…');
await IAPWallet.signIn(); // server binds the wallet to the signed-in email account
IAP.status('Wallet linked. Earnings pay there from now on.', 'ok');
await render();
}));
if ($('faucetBtn')) $('faucetBtn').addEventListener('click', busy($('faucetBtn'), async () => {
IAP.status('Connect your wallet first…');
const addr = await IAPWallet.connect();
let copied = false;
try { await navigator.clipboard.writeText(addr); copied = true; } catch (e) {}
window.open('https://faucet.polygon.technology/', '_blank', 'noopener');
$('faucetInfo').innerHTML = 'Your address ' + addr.slice(0, 8) + '…' + addr.slice(-6) + ''
+ (copied ? ' is copied' : '') + '. On the faucet, choose Polygon Amoy, paste your address, and request POL. Then come back and buy.';
IAP.status('Faucet opened in a new tab. Choose Polygon Amoy, paste your address, and request test POL.', 'ok');
}));
if ($('wcDisconnect')) $('wcDisconnect').addEventListener('click', busy($('wcDisconnect'), async () => {
// fire-and-forget: don't gate the reload on disconnect resolving (it can
// stall). The reload drops all in-memory wallet state and the picker returns.
IAPWallet.disconnect().catch(() => {});
$('wcDisconnectInfo').textContent = 'Disconnecting — reloading so you can pick a different wallet…';
IAP.status('Disconnecting — reloading…', 'ok');
setTimeout(() => location.reload(), 900);
}));
$('activateBtn').addEventListener('click', busy($('activateBtn'), async () => {
const me = await (await fetch('/api/me')).json();
IAP.status('Confirm the free activation in your wallet…');
const r = await IAPWallet.activate(me.sponsorId || 0);
if (r.receipt.status !== '0x1') throw new Error('Transaction reverted. Check the explorer.');
IAP.status('Payouts are on. Your invite link is live.', 'ok');
await render();
}));
$('copyInvite').addEventListener('click', async () => {
try { await navigator.clipboard.writeText($('inviteLine').textContent); IAP.status('Link copied.', 'ok'); }
catch (e) { IAP.status('Copy failed. Select and copy the link text.', 'bad'); }
});
$('logoutLink').addEventListener('click', async e => {
e.preventDefault();
await fetch('/api/auth/logout', { method: 'POST' });
IAP.status('Logged out.', 'ok');
await render();
});
// ── welcome tour (viral banner gauntlet): a new member meets their 3-level
// upline's sites, 10 focus-paused seconds each, then unlocks welcome credits.
// Same three levels the contract pays — the tour IS the org chart.
async function showGauntlet() {
try {
const g = await (await fetch('/api/my/gauntlet')).json();
if (!g.pending || !g.slides || !g.slides.length) return false;
const gate = $('gauntGate');
gate.hidden = false;
for (let i = 0; i < g.slides.length; i++) {
const s = g.slides[i];
$('ggWho').textContent = 'Position ' + (i + 1) + ': ' + s.name + (i === 0 ? ' — the person who invited you' : '');
$('ggProgress').textContent = 'Meeting your line: ' + (i + 1) + ' of ' + g.slides.length;
$('ggFrame').hidden = false;
$('ggFrame').src = s.targetUrl;
let left = g.dwell || 10;
$('ggTimer').textContent = left + 's';
await new Promise(done => {
const t = setInterval(() => {
if (document.visibilityState !== 'visible' || !document.hasFocus()) return;
left -= 1;
$('ggTimer').textContent = Math.max(0, left) + 's';
if (left <= 0) { clearInterval(t); done(); }
}, 1000);
});
}
$('ggFrame').src = 'about:blank';
$('ggFrame').hidden = true; // avoid a white about:blank panel once the tour is done
$('ggTimer').textContent = '✓';
$('ggWho').textContent = 'That is your line. When you grow, they earn — and yours starts the day you share.';
$('ggClaim').hidden = false;
await new Promise(done => {
$('ggClaim').onclick = async () => {
try {
const r = await api('/api/my/gauntlet/complete', { token: g.token });
IAP.status('+' + r.credited + ' welcome credits unlocked. They spend on real campaigns.', 'ok');
} catch (e) { IAP.status(e.message, 'bad'); }
done();
};
});
gate.hidden = true;
$('ggClaim').hidden = true;
return true;
} catch (e) { return false; }
}
// ── line banner (profile): the member's slot on welcome tours + their wall ──
function fillLineBanner(a) {
if (!a) return;
if (a.lineTargetUrl) $('lbTarget').value = a.lineTargetUrl;
if (a.lineBannerUrl) {
$('lbBanner').value = a.lineBannerUrl;
$('lbPreview').hidden = false;
$('lbPreview').innerHTML = '';
}
$('lbCurrent').textContent = a.lineTargetUrl
? 'Live: your next three levels meet ' + a.lineTargetUrl + ' on their welcome tour.'
: 'Not set yet. Until you set one, your tour slot is skipped.';
}
async function loadLineBanner() {
try {
const a = await (await fetch('/api/me')).json();
fillLineBanner(a);
fillProfileDetails(a);
// buyerCount + wallUnlocked live on the dashboard payload (chain read), not on /api/me
let d = {}; try { d = await (await fetch('/api/my/dashboard')).json(); } catch (e) {}
fillWallOffers(Object.assign({}, a, { buyerCount: d.buyerCount || 0, wallUnlocked: d.wallUnlocked || 1 }));
} catch (e) {}
}
// ── wall positions 2 & 3: the member's own offers, unlocked by qualifying buyers ──
function fillWallOffers(a) {
if (!a || !$('wallOffersCard')) return;
const unlocked = a.wallUnlocked || 1, bc = a.buyerCount || 0;
const offers = Array.isArray(a.wallOffers) ? a.wallOffers : [];
const NEED = [2, 5];
for (let i = 0; i < 2; i++) {
const o = offers[i] || {};
const open = unlocked >= i + 2;
$('woTitle' + i).value = o.title || ''; $('woTarget' + i).value = o.targetUrl || ''; $('woBanner' + i).value = o.bannerUrl || '';
$('woPrev' + i).hidden = !o.bannerUrl; $('woPrev' + i).innerHTML = o.bannerUrl ? '' : '';
$('woLock' + i).textContent = open ? 'yours' : 'unlocks at ' + NEED[i] + ' qualifying buyers (' + bc + '/' + NEED[i] + ')';
$('woSlot' + i).classList.toggle('locked', !open);
}
$('woStatus').textContent = unlocked >= 3 ? 'Fully qualified: all three wall positions are yours.'
: unlocked === 2 ? 'Position 2 is yours. ' + (5 - bc) + ' more qualifying buyer' + (5 - bc === 1 ? '' : 's') + ' and position 3 is too.'
: (2 - bc) + ' more qualifying buyer' + (2 - bc === 1 ? '' : 's') + ' ($20 or more) opens position 2. You can set your links now; they go live the moment a slot unlocks.';
}
document.querySelectorAll('.wo-upload').forEach(b => b.addEventListener('click', () => { const f = document.querySelector('.wo-file[data-slot="' + b.dataset.slot + '"]'); if (f) f.click(); }));
document.querySelectorAll('.wo-file').forEach(inp => inp.addEventListener('change', async () => {
const i = inp.dataset.slot, f = inp.files[0]; if (!f) return;
$('woInfo' + i).textContent = 'Uploading…';
try {
const r = await (await fetch('/api/my/upload', { method: 'POST', headers: { 'Content-Type': f.type }, body: f })).json();
if (r.error) { $('woInfo' + i).textContent = r.error; }
else { $('woBanner' + i).value = r.url; $('woInfo' + i).textContent = 'Uploaded'; $('woPrev' + i).hidden = false; $('woPrev' + i).innerHTML = ''; }
} catch (e) { $('woInfo' + i).textContent = 'Upload failed. Try again.'; }
inp.value = '';
}));
if ($('woSaveBtn')) $('woSaveBtn').addEventListener('click', busy2($('woSaveBtn'), async () => {
const offers = [0, 1].map(i => ({ title: $('woTitle' + i).value, targetUrl: $('woTarget' + i).value, bannerUrl: $('woBanner' + i).value }));
const r = await api('/api/my/wall-offers', { offers });
IAP.status('Wall positions saved.', 'ok');
await loadLineBanner();
}));
const SOCIALS = ['facebook', 'twitter', 'youtube', 'instagram', 'tiktok', 'telegram', 'linkedin', 'website'];
function fillProfileDetails(a) {
if (!a) return;
if (a.bio) $('pfBio').value = a.bio;
if (a.avatarUrl) { const p = $('pfAvatarPrev'); p.src = a.avatarUrl; p.hidden = false; }
let soc = {}; try { soc = a.socials ? JSON.parse(a.socials) : {}; } catch (e) {}
for (const p of SOCIALS) if ($('soc-' + p)) $('soc-' + p).value = soc[p] || '';
if (a.username) {
const link = location.origin + '/wall/' + a.username;
$('pfBioLink').textContent = link;
$('pfViewBio').hidden = false;
$('pfViewBio').href = '/wall/' + a.username;
}
}
let pfAvatar; // pending avatar url
$('pfAvatarBtn').addEventListener('click', () => $('pfAvatarFile').click());
$('pfAvatarFile').addEventListener('change', async () => {
const f = $('pfAvatarFile').files[0];
if (!f) return;
$('pfAvatarInfo').textContent = 'Uploading…';
try {
const r = await (await fetch('/api/my/upload', { method: 'POST', headers: { 'Content-Type': f.type }, body: f })).json();
if (r.error) { $('pfAvatarInfo').textContent = r.error; $('pfAvatarFile').value = ''; return; }
pfAvatar = r.url;
$('pfAvatarInfo').textContent = 'Uploaded — save to apply.';
const p = $('pfAvatarPrev'); p.src = r.url; p.hidden = false;
} catch (e) { $('pfAvatarInfo').textContent = 'Upload failed.'; }
$('pfAvatarFile').value = '';
});
$('pfDetailsSave').addEventListener('click', busy2($('pfDetailsSave'), async () => {
const body = { bio: $('pfBio').value, socials: {} };
for (const p of SOCIALS) body.socials[p] = ($('soc-' + p) && $('soc-' + p).value.trim()) || '';
if (pfAvatar) body.avatarUrl = pfAvatar;
const r = await api('/api/my/profile-details', body);
IAP.status('Profile saved.', 'ok');
if (r.account) fillProfileDetails(r.account);
}));
$('lbUploadBtn').addEventListener('click', () => $('lbFile').click());
$('lbFile').addEventListener('change', async () => {
const f = $('lbFile').files[0];
if (!f) return;
$('lbUpInfo').textContent = 'Uploading…';
try {
const r = await (await fetch('/api/my/upload', { method: 'POST', headers: { 'Content-Type': f.type }, body: f })).json();
if (r.error) { $('lbUpInfo').textContent = r.error; $('lbFile').value = ''; return; }
$('lbBanner').value = r.url;
$('lbUpInfo').textContent = 'Uploaded.';
$('lbPreview').hidden = false;
$('lbPreview').innerHTML = '';
} catch (e) { $('lbUpInfo').textContent = 'Upload failed. Try again.'; }
$('lbFile').value = '';
});
$('lbSaveBtn').addEventListener('click', busy2($('lbSaveBtn'), async () => {
const r = await api('/api/my/linebanner', { bannerUrl: $('lbBanner').value, targetUrl: $('lbTarget').value });
IAP.status('Line banner saved. Your next three levels will meet it.', 'ok');
if (r.account) fillLineBanner(r.account);
}));
// ── login ad interstitial (ClickBaitPays pattern): after a successful
// sign-in the sponsor card appears; "Open Ad" opens the CTA link in a NEW
// tab (a real, counted click) while the timer counts down on THIS page —
// deliberately not paused, the member is expected to be in the ad tab.
// At zero, "Go to dashboard" appears.
async function showLoginAd() {
try {
const { ad } = await (await fetch('/api/ads/slot?type=login')).json();
if (!ad || !ad.targetUrl) return;
const gate = $('loginGate');
const openBtn = $('lgOpen');
const goBtn = $('lgContinue');
const timer = $('lgTimer');
$('lgCreative').innerHTML = ad.imageUrl
? ''
: '' + (ad.title ? String(ad.title).replace(/[&<>]/g, '') : 'Visit today\'s sponsor') + '';
$('lgStatus').textContent = 'Login ad sponsor — click "Open Ad" to begin.';
timer.hidden = true;
goBtn.hidden = true;
openBtn.disabled = false;
gate.hidden = false;
await new Promise(done => {
openBtn.onclick = () => {
window.open(ad.targetUrl, '_blank'); // the click relay counts it
openBtn.disabled = true;
$('lgStatus').textContent = 'Ad open in a new tab. View it and come back — the timer runs here.';
let left = ad.dwell || 10;
timer.hidden = false;
timer.textContent = 'Time remaining: ' + left + 's';
const t = setInterval(() => {
left -= 1;
if (left > 0) { timer.textContent = 'Time remaining: ' + left + 's'; return; }
clearInterval(t);
timer.textContent = 'Time is up';
timer.classList.add('done');
$('lgStatus').textContent = 'Thanks for the look. Your dashboard is ready.';
goBtn.hidden = false;
}, 1000);
goBtn.onclick = () => done();
};
});
gate.hidden = true;
$('lgTimer').classList.remove('done');
} catch (e) {}
}
// ── SPONSOR CHAT: two-way threads, presence-aware, with a slide-in drawer ──
let CHAT_ME = null, CHAT_OTHER = null, CHAT_LASTID = 0, CHAT_POLL = null, CHAT_CANMUTE = false, CHAT_IMUTE = false, CHAT_SPONSOR = null, CHAT_LAST_UNREAD = 0;
function chatSync(d) {
CHAT_ME = d.email || CHAT_ME;
CHAT_SPONSOR = (d.sponsor && d.sponsor.email) ? d.sponsor : null;
const link = $('chatMenuBtn'); if (link && link.closest('.bo-links')) link.closest('.bo-links').hidden = !d.email;
setChatBadge(d.chatUnread || 0);
CHAT_LAST_UNREAD = d.chatUnread || 0;
const tog = $('chatAvailToggle'); if (tog) tog.checked = d.chatAvailable !== false;
// Overview quick action + My line card: message-your-sponsor entry points
const qs = $('qaMsgSponsor');
if (qs) {
if (CHAT_SPONSOR) { qs.hidden = false; qs.dataset.email = CHAT_SPONSOR.email; qs.dataset.name = CHAT_SPONSOR.name || 'your sponsor'; }
else qs.hidden = true;
}
const card = $('sponsorMsgCard');
if (card) {
card.hidden = !CHAT_SPONSOR;
if (CHAT_SPONSOR && $('sponsorMsgName')) $('sponsorMsgName').textContent = CHAT_SPONSOR.name || 'your sponsor';
}
}
function setChatBadge(n) { const b = $('chatNavBadge'); if (b) { b.hidden = !n; b.textContent = n > 9 ? '9+' : n; } }
function stopPoll() { if (CHAT_POLL) { clearInterval(CHAT_POLL); CHAT_POLL = null; } }
function startPoll() { stopPoll(); CHAT_POLL = setInterval(() => pullThread(false), 4000); }
function autoGrow(el) { el.style.height = 'auto'; el.style.height = Math.min(120, el.scrollHeight) + 'px'; }
function closeDrawer() { stopPoll(); if ($('chatDrawer')) $('chatDrawer').hidden = true; CHAT_OTHER = null; loadDashboard(); }
async function openThreads() {
const dr = $('chatDrawer'); if (!dr) return;
dr.hidden = false; $('chatConvo').hidden = true; $('chatThreads').hidden = false;
$('chatBack').hidden = true; $('chatMute').hidden = true; $('chatDot').hidden = true;
$('chatTitle').textContent = 'Messages'; $('chatStatus').textContent = '';
stopPoll(); CHAT_OTHER = null;
$('chatThreads').innerHTML = '
Loading…
';
try {
const r = await (await fetch('/api/my/chat/threads')).json();
const list = r.threads || [];
// always offer "message your sponsor" up top when they have one and no thread yet
const hasSponsorThread = CHAT_SPONSOR && list.some(t => t.email === CHAT_SPONSOR.email);
const sponsorRow = (CHAT_SPONSOR && !hasSponsorThread)
? '