Files
instantadpay/public/assets/my.js
T
2026-09-10 07:39:27 -05:00

2046 lines
125 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 = '<b>Testnet rehearsal</b> · ' + 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 = '<a href="' + i.url + '" target="_blank" rel="noopener nofollow"><span>' + esc(i.title) + '</span>'
+ (i.by ? '<span class="by">' + esc(i.by) + '</span>' : '') + '</a>';
}
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 ? '<span class="muted">' + today.open + ' of ' + today.cap + ' featured slots open today. </span>' : '')
+ '<a href="#campaigns" id="featBuy" style="color:var(--mint);font-weight:700;text-decoration:none">Feature your link →</a>';
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 '<div class="badge-a' + (got ? '' : ' locked') + '">'
+ '<img class="badge-img" src="' + b.img + '" alt="' + b.label + ' badge" loading="lazy">'
+ '<div class="bl">' + b.label + '</div><div class="bs">' + b.sub + '</div>'
+ (got ? '<button class="share" data-badge="' + b.key + '">Share</button>' : '<div class="bs">🔒 locked</div>') + '</div>';
}).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) =>
'<div class="nc-step' + (s.hit ? ' hit' : i === cur ? ' cur' : '') + '">'
+ '<span class="dot">' + (s.hit ? '✓' : i + 1) + '</span>'
+ '<span class="lb">' + s.label + '<i>' + s.sub + '</i></span></div>').join('');
}
// ── overview v3 charts: hand-rolled SVG/CSS, real data only ──
const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
const CH = { mint: '#43e8c3', cyan: '#54ccff', violet: '#9d7dff', amber: '#ffb238', track: 'rgba(139,166,156,.18)' };
function bars(el, xel, data) { // data: [{v,label,tip,alt}]
if (!el) return;
const max = Math.max(1, ...data.map(d => d.v));
el.innerHTML = data.length
? data.map(d => '<div class="bar' + (d.alt ? ' alt' : '') + (d.v ? '' : ' empty') + '" style="height:'
+ Math.max(4, Math.round(d.v / max * 100)) + '%" data-v="' + esc(d.tip) + '"></div>').join('')
: '<div class="bar empty" style="height:4%"></div>'.repeat(8);
if (xel) xel.innerHTML = data.map(d => '<span>' + esc(d.label) + '</span>').join('');
}
function donut(el, segs, center) { // segs sum to the ring; r=15.9155 → circumference 100
if (!el) return;
const total = segs.reduce((s, x) => s + x.v, 0);
let off = 25, out = '<circle cx="21" cy="21" r="15.9155" fill="none" stroke="' + CH.track + '" stroke-width="5"></circle>';
if (total > 0) for (const s of segs) {
const len = s.v / total * 100;
if (len <= 0 || s.color === 'none') { off -= Math.max(0, len); continue; }
out += '<circle cx="21" cy="21" r="15.9155" fill="none" stroke="' + s.color + '" stroke-width="5" '
+ 'stroke-dasharray="' + len + ' ' + (100 - len) + '" stroke-dashoffset="' + off + '"></circle>';
off -= len;
}
out += '<text x="21" y="20.5" text-anchor="middle" fill="currentColor" font-size="6.5" font-weight="800" class="donut-center">' + esc(center.big) + '</text>'
+ '<text x="21" y="26.5" text-anchor="middle" fill="#8ba69c" font-size="3.1">' + esc(center.small) + '</text>';
el.innerHTML = out;
}
function legend(el, rows) {
if (el) el.innerHTML = rows.map(r => '<div><i style="background:' + r.color + '"></i>'
+ esc(r.label) + ' <b>' + esc(r.v) + '</b></div>').join('');
}
async function loadCharts(d) {
// credit composition donut + today's viewing ring (live balances)
try {
const st = await (await fetch('/api/my/earn')).json();
if (!st.error) {
const purchased = d.credits || 0, earned = st.earned || 0, wc = Math.min(d.welcomeCredits || 0, earned);
$('dbCredits').textContent = (purchased + earned).toLocaleString();
$('dbCreditsSub').textContent = purchased.toLocaleString() + ' purchased · ' + earned.toLocaleString() + ' earned';
donut($('chDonut'),
[{ v: purchased, color: CH.mint }, { v: wc, color: CH.amber }, { v: earned - wc, color: CH.cyan }],
{ big: (purchased + earned).toLocaleString(), small: 'spendable' });
legend($('chDonutLegend'), [
{ color: CH.mint, label: 'Purchased', v: purchased.toLocaleString() },
{ color: CH.amber, label: 'Welcome', v: wc.toLocaleString() },
{ color: CH.cyan, label: 'Earned by viewing', v: Math.max(0, earned - wc).toLocaleString() }]);
const done = Math.min(st.views || 0, st.target || 5), left = Math.max(0, (st.target || 5) - done);
donut($('chRing'), [{ v: done, color: st.claimed ? CH.mint : CH.cyan }, { v: left, color: 'none' }],
{ big: done + '/' + (st.target || 5), small: st.claimed ? 'claimed' : 'ads viewed' });
legend($('chRingLegend'), [
{ color: st.claimed ? CH.mint : CH.cyan, label: 'Viewed today', v: done },
{ color: CH.track, label: 'To go', v: left },
{ color: CH.amber, label: 'Claim pays', v: '+' + (st.claimCredits || 0) + ' credits' }]);
}
} catch (e) {}
// recent on-chain payouts, sized to scale, oldest→newest
try {
let evs = [];
if (d.memberId) {
const a = await (await fetch('/api/my/activity')).json();
evs = (a.earnings || []).filter(e => e.amountWei).sort((x, y) => x.block - y.block).slice(-12);
}
bars($('chEarnBars'), $('chEarnX'), evs.map(e => ({
v: Number(BigInt(e.amountWei) / 1000000000000n) / 1e6,
label: e.type === 'AwardPaid' ? 'award' : 'L' + (e.tier || 1),
tip: IAP.fmtPol(e.amountWei) + ' POL', alt: e.type === 'AwardPaid' })));
if (!evs.length) $('chEarnSub').textContent = d.memberId
? 'no payouts yet — share your link' : 'activate a package to start earning';
} catch (e) {}
// campaign delivery: impressions per campaign
try {
const r = await (await fetch('/api/my/campaigns')).json();
const cs = (r.campaigns || []).slice(0, 8);
bars($('chCampBars'), $('chCampX'), cs.map(c => ({
v: c.imps || 0, label: String(c.name || c.type).slice(0, 9),
tip: (c.name || c.type) + ': ' + (c.imps || 0) + ' imps · ' + (c.clicks || 0) + ' clicks',
alt: c.type === 'text' })));
if (!cs.length) $('chCampSub').textContent = 'no campaigns yet — place your first ad';
} catch (e) {}
}
// ── live updates: subscribe to the chain event stream so the members area
// reacts to on-chain changes (payouts, qualifications) without a refresh ──
let MYID = 0;
// synthesized sounds (no asset files; CSP-safe). cha-ching on a payment, pop on a message.
function playSound(kind) {
try {
const AC = window.AudioContext || window.webkitAudioContext; if (!AC) return;
const ctx = window.__iapAC || (window.__iapAC = new AC());
if (ctx.state === 'suspended') ctx.resume();
const now = ctx.currentTime;
const tone = (freq, at, dur, type, peak) => {
const o = ctx.createOscillator(), g = ctx.createGain();
o.type = type || 'sine'; o.frequency.value = freq;
g.gain.setValueAtTime(0.0001, now + at);
g.gain.exponentialRampToValueAtTime(peak || 0.22, now + at + 0.02);
g.gain.exponentialRampToValueAtTime(0.0001, now + at + dur);
o.connect(g).connect(ctx.destination); o.start(now + at); o.stop(now + at + dur + 0.02);
};
if (kind === 'chaching') { tone(1318, 0, 0.34, 'sine', 0.25); tone(1760, 0.09, 0.4, 'sine', 0.25); }
else { tone(680, 0, 0.16, 'triangle', 0.18); tone(1020, 0.05, 0.16, 'triangle', 0.16); }
} catch (e) {}
}
function startLiveFeed() {
if (window.__iapFeed || !window.EventSource) return;
try {
const es = new EventSource('/api/feed/live');
window.__iapFeed = es;
es.onmessage = m => { let ev; try { ev = JSON.parse(m.data); } catch (e) { return; } handleLiveEvent(ev); };
// browser auto-reconnects on error; nothing to do
} catch (e) {}
}
let liveRefreshT = null;
function liveRefresh() { // debounce a burst of events into one refresh
clearTimeout(liveRefreshT);
liveRefreshT = setTimeout(() => { loadDashboard(); try { loadLineage(); } catch (e) {} }, 600);
}
function handleLiveEvent(ev) {
if (!MYID || !ev || !ev.type) return;
let toast = null, kind = 'ok';
let sound = null;
if (ev.type === 'TierPaid' && ev.recipientId === MYID) { toast = '💸 You earned a level-' + ev.tier + ' payout of ' + IAP.fmtPol(ev.amountWei) + ' POL!'; sound = 'chaching'; }
else if (ev.type === 'AwardPaid' && ev.toId === MYID) { toast = '💸 You received ' + IAP.fmtPol(ev.amountWei) + ' POL!'; sound = 'chaching'; }
else if (ev.type === 'BuyerCounted' && ev.sponsorId === MYID) toast = '🎯 A referral just qualified — you now have ' + ev.newCount + ' qualifying buyer' + (ev.newCount === 1 ? '' : 's') + '!';
else if (ev.type === 'PassedUp' && ev.skippedId === MYID) { toast = '⚠️ A level-' + ev.tier + ' payout passed you by. Get qualified to catch these.'; kind = 'bad'; }
else if (ev.type === 'Purchase' && ev.buyerId === MYID) toast = '✅ Purchase settled on-chain — your credits are updated.';
else if (ev.type === 'MemberActivated' && ev.sponsorId === MYID) toast = '🤝 A new member just activated in your line!';
if (toast) { IAP.status(toast, kind); if (sound) playSound(sound); liveRefresh(); }
}
// training center: videos + materials (admin-curated via data/training.json)
async function loadTraining() {
try {
const r = await (await fetch('/api/training')).json();
const el = $('trainingList'); if (!el) return;
const items = r.items || [];
if (!items.length) { el.innerHTML = '<p class="muted small">Training materials are being added. Check back soon.</p>'; return; }
// section pills at the top: one per group, plus All; the active pill filters the list
const groups = [...new Set(items.map(it => it.group).filter(Boolean))];
const pills = $('trainingPills');
const want = loadTraining.filter || 'all';
if (pills) {
pills.hidden = groups.length < 2;
pills.innerHTML = ['all', ...groups].map(g => '<button type="button" class="chip-t' + ((g === want) ? ' on' : '') + '" data-tg="' + esc(g) + '">' + esc(g === 'all' ? 'All' : g) + '</button>').join('');
pills.querySelectorAll('[data-tg]').forEach(b => b.addEventListener('click', () => { loadTraining.filter = b.dataset.tg; loadTraining(); }));
}
const shown = want === 'all' ? items : items.filter(it => it.group === want);
let lastGroup = null;
el.innerHTML = shown.map(it => {
// optional section header: entries carry a `group`; a header renders when it changes
let head = '';
if (it.group && it.group !== lastGroup) { lastGroup = it.group; head = '<h3 class="tr-group" style="margin:26px 0 6px;font-size:13px;letter-spacing:.12em;text-transform:uppercase;color:var(--muted)">' + esc(it.group) + '</h3>'; }
const isVid = it.videoUrl && /\.(mp4|webm)(\?|$)/i.test(it.videoUrl);
// poster: the admin list can carry one; otherwise assume a .jpg next to the .mp4 (the pipeline uploads both)
const poster = it.posterUrl || (isVid ? it.videoUrl.replace(/\.(mp4|webm)(\?.*)?$/i, '.jpg$2') : '');
const media = isVid ? '<video src="' + esc(it.videoUrl) + '"' + (poster ? ' poster="' + esc(poster) + '"' : '') + ' preload="metadata" controls playsinline style="width:100%;max-width:640px;border-radius:12px;background:#000"></video>' : '';
const links = [];
if (it.videoUrl && !isVid) links.push('<a class="btn small sec" href="' + esc(it.videoUrl) + '" target="_blank" rel="noopener">Watch</a>');
if (it.docUrl) links.push('<a class="btn small sec" href="' + esc(it.docUrl) + '" target="_blank" rel="noopener">Open material</a>');
return head + '<div class="card"><h3>' + esc(it.title || 'Lesson') + '</h3>'
+ (it.desc ? '<p class="muted small">' + esc(it.desc) + '</p>' : '')
+ (media ? '<p style="margin:10px 0">' + media + '</p>' : '')
+ (links.length ? '<p>' + links.join(' ') + '</p>' : '') + '</div>';
}).join('');
} catch (e) {}
}
// visual line tree on the Overview: YOU + three levels, qualified directs in gold
async function loadLineTree(buyerCount) {
try {
const r = await (await fetch('/api/my/line')).json();
const el = $('lineTree'); if (!el) return;
const levels = r.levels || [];
const total = levels.reduce((n, L) => n + L.members.length, 0);
if ($('treeSub')) $('treeSub').textContent = total ? total + ' across 3 levels' : 'share your link to grow';
const chip = (m, q) => '<span class="lt-node' + (q ? ' qualified' : (m.email ? '' : ' deep')) + '" title="' + esc(m.name || 'member') + (q ? ' · qualified buyer' : '') + '">' + esc(String(m.name || 'M').replace(/^@/, '').slice(0, 12)) + '</span>';
const rowFor = lvl => {
const L = levels.find(x => x.level === lvl); const members = L ? L.members : [];
const qn = lvl === 1 ? (buyerCount || 0) : 0; // highlight your qualified directs
let html = members.map((m, i) => chip(m, i < qn)).join('');
if (lvl <= 2 && members.length < (lvl === 1 ? 2 : 4)) html += '<span class="lt-node open">+ open</span>';
if (!html) html = '<span class="lt-node open">+ open</span>';
return '<div class="lt-row"><span class="lt-cap">L' + lvl + '</span><div class="lt-nodes">' + html + '</div></div>';
};
el.innerHTML = '<div class="lt-top"><span class="lt-you">YOU</span></div><div class="lt-stem"></div>' + rowFor(1) + rowFor(2) + rowFor(3);
} catch (e) {}
}
async function loadDashboard() {
try {
const d = await (await fetch('/api/my/dashboard')).json();
if (d.error) return;
chatSync(d);
MYID = d.memberId || MYID;
startLiveFeed();
if (!window.__lbChecked) { // daily login bonus, once per session (server guards once/day)
window.__lbChecked = true;
api('/api/my/login-bonus').then(r => {
if (r && r.granted > 0) {
IAP.status('🎁 Daily login bonus: +' + r.granted + ' credits' + (r.streak > 1 ? ' · ' + r.streak + '-day streak' : '') + '!', 'ok');
playSound('chaching'); setTimeout(loadDashboard, 900);
}
}).catch(() => {});
}
const wc = d.welcomeCredits || 0;
$('dbCredits').textContent = ((d.credits || 0) + wc).toLocaleString();
$('dbCreditsSub').textContent = wc ? (d.credits || 0).toLocaleString() + ' purchased · ' + wc + ' welcome' : '';
$('dbEarned').textContent = IAP.fmtPol(d.earnedWei || '0');
$('dbBuyers').textContent = d.buyerCount || 0;
$('dbTeam').textContent = (d.referrals || []).length;
// trend chips: only real, computable facts
const ec = $('dbEarnedChip');
if (ec && d.earnCount) { ec.hidden = false; ec.textContent = d.earnCount + ' instant payout' + (d.earnCount > 1 ? 's' : ''); }
const bc = $('dbBuyersChip');
if (bc && d.memberId) {
bc.hidden = false;
bc.textContent = (d.buyerCount || 0) >= 5 ? 'level 3 open' : (d.buyerCount || 0) >= 2 ? 'level 2 open' : 'level 2 at 2 buyers';
}
const tc = $('dbTeamChip'), wk = (d.referrals || []).filter(r => Date.now() - new Date(r.joined) < 6048e5).length;
if (tc && wk) { tc.hidden = false; tc.textContent = '+' + wk + ' this week'; }
setInboxBadge(d.inboxUnread || 0);
if (d.sponsorMsg) showSponsorModal(d.sponsorMsg);
renderBadges(d);
loadFeatured();
loadLineTree(d.buyerCount || 0);
loadCharts(d);
$('nextMove').textContent = nextMove(d);
renderSteps(d);
const wrap = $('rosterWrap');
if ((d.referrals || []).length) {
$('rosterEmpty').hidden = true;
let t = wrap.querySelector('table');
if (t) t.remove();
t = document.createElement('table');
t.className = 'roster';
t.innerHTML = d.referrals.map(r => '<tr><td>' + String(r.name || r.email || '').replace(/[&<>]/g, '') + '</td>'
+ '<td>' + new Date(r.joined).toLocaleDateString() + '</td>'
+ '<td><span class="badge' + (r.status === 'joined free' ? ' amber' : '') + '">' + r.status + '</span></td></tr>').join('');
wrap.appendChild(t);
}
// overview recent-activity widget: chain events + line joins, newest first
try {
const c = await IAP.getConfig();
const ov = $('ovFeed');
const rows = [];
if (d.memberId) {
const a = await (await fetch('/api/my/activity')).json();
for (const ev of [...(a.earnings || []), ...(a.purchases || [])]
.sort((x, y) => y.block - x.block).slice(0, 5)) rows.push(IAP.feedRow(ev, c));
}
for (const r of (d.referrals || []).slice(0, 3)) {
const div = document.createElement('div');
div.className = 'row';
div.innerHTML = '<span>🤝 ' + esc(r.name || r.email || 'A new member') + ' joined your line</span><span class="tx muted small">'
+ new Date(r.joined).toLocaleDateString() + '</span>';
rows.push(div);
}
if (rows.length) {
ov.innerHTML = '';
rows.slice(0, 6).forEach(r => ov.appendChild(r));
}
} catch (e) {}
// ready-to-send share message + promo tools, personalized
const link = location.origin + '/join/' + (d.username || d.refCode || d.memberId || '');
fillPromo(link, d);
if (d.username) { // wall link rides the username
const wl = location.origin + '/wall/' + d.username;
$('wallLine').textContent = wl;
if ($('promoWallStrip')) { // the same wall link at the top of Promo tools, next to the invite link
$('promoWallStrip').hidden = false; $('promoWallLink').textContent = wl; $('promoWallOpen').href = '/wall/' + d.username;
$('promoWallCopy').onclick = async () => { try { await navigator.clipboard.writeText(wl); IAP.status('Wall link copied.', 'ok'); } catch (e) { IAP.status('Copy failed. Select the link and copy it.', 'bad'); } };
}
$('wallCopy').hidden = false;
$('wallOpen').hidden = false;
$('wallOpen').href = '/wall/' + d.username;
$('wallCopy').onclick = async () => {
try { await navigator.clipboard.writeText(wl); IAP.status('Wall link copied.', 'ok'); }
catch (e) { IAP.status('Copy failed. Select the link text instead.', 'bad'); }
};
}
if (d.refCode || d.memberId) {
const pitch = 'I found an advertising site that pays referrals instantly to your own wallet. '
+ 'No withdrawals, no waiting, and every payment is public on a blockchain ledger you can check yourself. '
+ 'Free to join and look around: ' + link;
$('copyPitch').hidden = false;
$('pitchPreview').hidden = false;
$('pitchPreview').textContent = '"' + pitch + '"';
$('copyPitch').onclick = async () => {
try { await navigator.clipboard.writeText(pitch); IAP.status('Message copied. Paste it anywhere.', 'ok'); }
catch (e) { IAP.status('Copy failed. Select the preview text instead.', 'bad'); }
};
}
} catch (e) {}
}
// ── back-office menu: hash-routed panes ───────────────
const PANES = ['overview', 'line', 'buy', 'campaigns', 'earn', 'earnings', 'promo', 'training', 'wallet', 'profile'];
const TITLES = { overview: 'Overview', line: 'My line', buy: 'Buy packages', campaigns: 'Campaigns',
earn: 'Earn credits', earnings: 'Earnings', promo: 'Promo tools', training: 'Training',
wallet: 'Wallet & account', profile: 'Profile' };
function setPane(name) {
if (!PANES.includes(name)) name = 'overview';
for (const p of PANES) {
const el = $('pane-' + p);
if (el) el.hidden = p !== name;
}
document.querySelectorAll('.bo-menu [data-pane]').forEach(b =>
b.classList.toggle('on', b.dataset.pane === name));
if ($('boTitle')) $('boTitle').textContent = TITLES[name];
if (name === 'earn') setEarnSub(earnSub); // refresh whichever sub-tab is active
if (name === 'profile') loadLineBanner();
if (name === 'line') { loadLineage(); loadUplineMessages(); loadCoach(); loadLinkStats(); loadProspects(); }
if (name === 'campaigns') ['cTarget', 'cImage', 'cVideoUrl'].forEach(id => { if ($(id)) $(id).value = ''; }); // no residual URL between visits
if (name === 'training') loadTraining();
document.getElementById('memberArea').classList.remove('side-open'); // close mobile drawer
if (location.hash !== '#' + name) history.replaceState(null, '', '#' + name);
}
document.querySelectorAll('.bo-menu [data-pane]').forEach(b =>
b.addEventListener('click', () => setPane(b.dataset.pane)));
document.querySelectorAll('.qa [data-goto]').forEach(b =>
b.addEventListener('click', () => setPane(b.dataset.goto)));
const qaCopy = document.getElementById('qaCopyInvite');
if (qaCopy) qaCopy.addEventListener('click', async () => {
const link = document.getElementById('inviteLine').textContent;
if (!link || !link.startsWith('http')) { setPane('line'); return; }
try { await navigator.clipboard.writeText(link); IAP.status('Invite link copied.', 'ok'); }
catch (e) { setPane('line'); }
});
window.addEventListener('hashchange', () => setPane(location.hash.slice(1)));
if ($('boBurger')) $('boBurger').addEventListener('click', () =>
document.getElementById('memberArea').classList.toggle('side-open'));
async function render() {
const me = await IAP.refreshNavWallet();
// one way in: email. A wallet-only session (no account) is sent back to the
// email card with a finish-setup note; verifying the code links that wallet.
const walletOnly = !!(me && me.signedIn && !me.email);
const signedIn = me && me.signedIn && !walletOnly;
$('authArea').hidden = !!signedIn;
$('memberArea').hidden = !signedIn;
if ($('mcFinish')) $('mcFinish').hidden = !walletOnly;
if (!signedIn) return;
if ($('adminLink')) $('adminLink').hidden = !me.isAdmin; // admin portal link, only for ADMIN_EMAIL
// arrived from an invite page: run onboarding once (username, welcome tour, login ad)
if (/[?&]welcome=1/.test(location.search) && !render.welcomed) {
render.welcomed = true;
history.replaceState(null, '', '/my' + (location.hash || ''));
(async () => {
try { if (!me.username) await showOnboard(); if (!(await showGauntlet())) await showLoginAd(); } catch (e) {}
await render();
})();
}
setPane(location.hash.slice(1) || 'overview');
$('campGate').hidden = !!me.memberId;
$('earnGate').hidden = !!me.memberId;
loadDashboard();
const who = [];
if (me.email) who.push(me.email);
if (me.address) who.push('wallet <span class="mono">' + me.address.slice(0, 8) + '…' + me.address.slice(-6) + '</span>');
else who.push('no wallet linked yet');
if (me.memberId) who.push('on-chain <b>member #' + me.memberId + '</b>'
+ (me.onchainSponsorId ? ', sponsored by #' + me.onchainSponsorId : ''));
else if (me.sponsorId) who.push('invited by member #' + me.sponsorId);
$('posLine').innerHTML = who.join('<br>');
$('creditLine').textContent = (me.credits || 0).toLocaleString();
loadPositions(me);
$('linkCard').hidden = !!me.address;
$('activateCard').hidden = !(me.address && !me.memberId);
$('activityArea').hidden = !me.memberId;
$('campGate').hidden = true; // earned credits fund campaigns for everyone
$('campaignCard').hidden = false;
loadCampaigns();
// profile pane state
$('pfCurrent').textContent = me.username ? '@' + me.username + ' is your permanent username. Your invite link, your public page and any banners you shared carry it, so it cannot be changed.' : 'No username yet. Members see you as a number until you pick one. Choose carefully: it is permanent once saved.';
if (!$('pfUsername').value) $('pfUsername').value = me.username || '';
$('pfUsername').disabled = !!me.username; $('pfSaveBtn').hidden = !!me.username;
$('pfDetails').innerHTML = 'Email: ' + (me.email || 'none') + '<br>Wallet: '
+ (me.address ? '<span class="mono">' + me.address.slice(0, 10) + '…' + me.address.slice(-6) + '</span>' : 'not linked yet')
+ '<br>On-chain member: ' + (me.memberId ? '#' + me.memberId : 'not yet');
// the share link works from day one; usernames make it a vanity link
if (me.username || me.refCode || me.memberId) {
$('inviteLine').textContent = location.origin + '/join/' + (me.username || me.refCode || me.memberId);
$('copyInvite').hidden = false;
} else {
$('inviteLine').textContent = 'Sign in with your email to get your link.';
$('copyInvite').hidden = true;
}
if (me.memberId) {
const bc = me.buyerCount || 0;
$('qualLine').innerHTML = '<b>' + bc + '</b> qualifying buyer(s) referred<br>'
+ (bc >= 5 ? '<span class="badge">Level 3 unlocked: full three-level earnings</span>'
: bc >= 2 ? '<span class="badge">Level 2 unlocked</span> · ' + (5 - bc) + ' more for level 3'
: (2 - bc) + ' more buyer(s) of $20+ unlock level 2');
loadActivity();
} else {
$('qualLine').innerHTML = 'Share your link now. Then switch on payouts (free, above) '
+ '<b>before your people start buying</b>: the contract locks each buyer to their sponsor at '
+ 'their first purchase, and payments only route to wallets that are switched on.';
}
}
async function loadActivity() {
try {
const c = await IAP.getConfig();
const a = await (await fetch('/api/my/activity')).json();
const fill = (id, evs, empty) => {
const el = $(id);
el.innerHTML = '';
if (!evs || !evs.length) { el.innerHTML = '<div class="row muted">' + empty + '</div>'; return; }
for (const ev of evs) el.appendChild(IAP.feedRow(ev, c));
};
fill('earnFeed', a.earnings, 'No payouts yet. They appear here the moment one lands.');
fill('refFeed', a.referrals, 'No referral activity yet. Share your invite link.');
fill('buyFeed', a.purchases, 'No purchases from your wallet yet.');
} catch (e) {}
}
async function loadCampaigns() {
try {
const r = await (await fetch('/api/my/campaigns')).json();
if (r.error) return;
lastRates = r.rates;
// populate the banner-size dropdown once (ids map to NAS width/height)
if (r.bannerSizes && $('cSize') && !$('cSize').options.length)
$('cSize').innerHTML = r.bannerSizes.map(s => '<option value="' + s.id + '">' + s.label + '</option>').join('');
soloHint();
if ($('spendBanner')) {
$('spendBanner').hidden = false;
$('spendBig').textContent = r.availableCredits.toLocaleString();
$('spendSub').textContent = '= $' + (r.availableCredits / 100).toLocaleString(undefined, { maximumFractionDigits: 2 }) + ' of ad delivery' + (r.earnedCredits ? ' · ' + r.purchasedCredits.toLocaleString() + ' purchased + ' + r.earnedCredits.toLocaleString() + ' earned by viewing' : '') + (r.positionCount > 1 ? ' · pooled across ' + r.positionCount + ' positions (largest single position ' + (r.largestPosition || 0).toLocaleString() + ')' : '');
$('spendRates').innerHTML = [['Banner', r.rates.bannerCreditsPerBatch + ' cr / ' + r.rates.bannerBatch + ' views'], ['Text', r.rates.textCreditsPerBatch + ' cr / ' + r.rates.textBatch + ' views'], ['Login', r.rates.loginCreditsPerDay + ' cr / day'], ['Solo', r.rates.soloCostPerRecipient + ' cr / delivery'], ['Featured', (r.rates.featuredPerDay || 40) + ' cr / day'], ['Visit', (r.rates.visitCostPerVisit || 3) + ' cr / visit']].map(x => '<span><b>' + x[0] + '</b> ' + x[1] + '</span>').join('');
}
$('rateLine').textContent = 'Available to spend: ' + r.availableCredits.toLocaleString()
+ (r.earnedCredits ? ' (' + r.purchasedCredits.toLocaleString() + ' purchased + ' + r.earnedCredits + ' earned)' : '')
+ ' credits · rates: banner ' + r.rates.bannerCreditsPerBatch + 'cr/' + r.rates.bannerBatch
+ ' views, text ' + r.rates.textCreditsPerBatch + 'cr/' + r.rates.textBatch
+ ' views, login ' + r.rates.loginCreditsPerDay + 'cr/day, solo '
+ (r.rates.soloCostPerRecipient || 5) + 'cr/delivery';
const el = $('campList');
el.innerHTML = '';
if (!r.campaigns.length) { el.innerHTML = '<p class="muted small">No campaigns yet. Launch your first below.</p>'; return; }
const tbl = document.createElement('div');
tbl.className = 'tablewrap';
tbl.innerHTML = '<table><thead><tr><th>Name</th><th>Type</th><th class="num">Views</th><th class="num">Clicks</th>'
+ '<th class="num">Spent</th><th class="num">Budget</th><th>Status</th><th></th></tr></thead><tbody>'
+ r.campaigns.map(c => '<tr><td><b>' + c.name + '</b></td>'
+ '<td>' + c.type + (c.type === 'banner' && c.width ? ' <span class="muted small">' + c.width + '×' + c.height + '</span>' : '') + '</td>'
+ '<td class="num">' + c.imps.toLocaleString()
+ (c.impsNas ? ' <span class="muted small" title="views across the network">+' + c.impsNas.toLocaleString() + ' network</span>' : '')
+ '</td><td class="num">' + c.clicks + '</td>'
+ '<td class="num">' + c.spent + '</td><td class="num">' + c.budget + '</td>'
+ '<td>' + (c.status === 'out' ? '<span class="badge amber">budget spent</span>' : c.status) + '</td>'
+ '<td>' + (c.status === 'active' ? '<button class="btn small sec" data-camp="' + c.id + '" data-act="pause">Pause</button>'
: c.status === 'paused' ? '<button class="btn small sec" data-camp="' + c.id + '" data-act="resume">Resume</button>' : '')
+ ' <button class="btn small sec" data-topup="' + c.id + '">Buy more views</button>'
+ '</td></tr>').join('') + '</tbody></table>';
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'
? '<video src="' + r.url + '" controls playsinline></video><p><br></p>'
: '<img src="' + r.url + '" alt=""><p><br></p>');
$('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.',
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');
// 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 =>
'<option value="' + dys + '">' + dys + ' day' + (dys > 1 ? 's' : '') + ' — ' + (dys * lastRates.featuredPerDay) + ' credits</option>').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 '<div class="feat-day' + (d.offset === featStartDay ? ' on' : '') + (full ? ' full' : d.open > d.cap / 2 ? ' open2' : '') + '" data-off="' + d.offset + '"' + (full ? '' : '') + '>'
+ '<div class="fd-day">' + label + '</div><div class="fd-occ">' + d.count + '/' + d.cap + (full ? ' full' : ' left ' + d.open) + '</div></div>';
}).join('');
grid.querySelectorAll('.feat-day:not(.full)').forEach(el =>
el.addEventListener('click', () => { featStartDay = Number(el.dataset.off); featHint(); }));
}
const dys = Number($('cFeatDays').value) || (lastRates.featuredDurations[0]);
const dayLabel = featStartDay === 0 ? 'today' : featStartDay === 1 ? 'tomorrow' : 'in ' + featStartDay + ' days';
$('cFeatHint').textContent = 'Runs ' + dys + ' day' + (dys > 1 ? 's' : '') + ' starting ' + dayLabel
+ ' for ' + (dys * lastRates.featuredPerDay) + ' credits. Max ' + (s ? s.slotsPerDay : 10) + ' links share any day.';
}
document.addEventListener('change', e => { if (e.target && e.target.id === 'cFeatDays') featHint(); });
function visitHint() {
if (!lastRates) return;
const n = Number($('cVisitCount').value) || 0;
const cost = n * (lastRates.visitCostPerVisit || 3);
$('cVisitHint').textContent = (lastRates.visitCostPerVisit || 3) + ' credits per verified visit'
+ (n >= (lastRates.visitMinPack || 20) ? ' — ' + n + ' visits = ' + cost + ' credits' : ' (min ' + (lastRates.visitMinPack || 20) + ')')
+ '. Each is a unique member, dwell + human-check verified.';
}
$('cVisitCount').addEventListener('input', visitHint);
// video composer: tier dropdown + upload + price hint
function videoHint() {
if (!lastRates || !lastRates.videoTiers) return;
if ($('cWatchSecs') && !$('cWatchSecs').options.length)
$('cWatchSecs').innerHTML = lastRates.videoTiers.map(t =>
'<option value="' + t.secs + '">Watch ' + t.secs + 's — ' + t.cost + ' credits/view (viewer earns ' + t.reward + ')</option>').join('');
const tier = lastRates.videoTiers.find(t => t.secs === Number($('cWatchSecs').value)) || lastRates.videoTiers[0];
const n = tier ? Math.floor((Number($('cBudget').value) || 0) / tier.cost) : 0;
$('cVideoHint').textContent = tier ? (tier.cost + ' credits per completed ' + tier.secs + 's view'
+ (n ? ' — this budget buys ' + n + ' views' : '') + '. Viewers earn ' + tier.reward + ' credits each, so they watch.') : '';
}
document.addEventListener('change', e => { if (e.target && e.target.id === 'cWatchSecs') videoHint(); });
$('cBudget').addEventListener('input', () => { if ($('cType').value === 'video') videoHint(); });
$('cVideoUploadBtn').addEventListener('click', () => $('cVideoFile').click());
$('cVideoUrl').addEventListener('change', async () => {
const url = $('cVideoUrl').value.trim();
$('cVideoInfo').textContent = url ? 'checking video…' : '';
cVidDims = url ? await probeVideoDims(url) : null;
if (url && !cVidDims) $('cVideoInfo').textContent = 'could not read that video';
else { $('cVideoInfo').textContent = ''; showVidOrient(); }
});
$('cVideoFile').addEventListener('change', async () => {
const f = $('cVideoFile').files[0];
if (!f) return;
$('cVideoInfo').textContent = 'Uploading ' + f.name + '… (large files take a moment)';
try {
const r = await (await fetch('/api/my/upload', { method: 'POST', headers: { 'Content-Type': f.type }, body: f })).json();
if (r.error) { $('cVideoInfo').textContent = r.error; $('cVideoFile').value = ''; return; }
$('cVideoUrl').value = r.url;
$('cVideoInfo').textContent = f.name + ' uploaded';
$('cVideoPrev').hidden = false;
$('cVideoPrev').innerHTML = '<video src="' + r.url + '" controls style="max-width:320px;border-radius:10px"></video>';
cVidDims = await probeVideoDims(r.url); showVidOrient();
} catch (e) { $('cVideoInfo').textContent = 'Upload failed. Try again.'; }
$('cVideoFile').value = '';
});
$('createCampBtn').addEventListener('click', busy2($('createCampBtn'), async () => {
// in raw mode the source of truth is the textarea; sync it back first
let soloBody = $('cSoloEd').innerHTML;
if ($('cSoloRaw') && !$('cSoloRaw').hidden) soloBody = $('cSoloRaw').value;
const t = $('cType').value;
const isVideo = t === 'video', isFeat = t === 'featured';
if (isVideo && !cVidDims && $('cVideoUrl').value) cVidDims = await probeVideoDims($('cVideoUrl').value);
await api('/api/my/campaigns', { type: t, name: $('cName').value,
targetUrl: $('cTarget').value, imageUrl: $('cImage').value, size: $('cSize').value,
title: isVideo ? $('cVideoTitle').value : isFeat ? $('cFeatTitle').value : t === 'visits' ? $('cVisitTitle').value : $('cTitle').value,
body: t === 'solo' ? soloBody : $('cBody').value,
videoUrl: $('cVideoUrl').value, watchSecs: Number($('cWatchSecs').value),
videoW: cVidDims ? cVidDims.w : null, videoH: cVidDims ? cVidDims.h : null,
days: Number($('cFeatDays').value), startDay: featStartDay,
count: Number($('cVisitCount').value),
ctaLabel: isVideo ? $('cVideoCta').value : $('cCtaLabel').value,
budget: isFeat ? (Number($('cFeatDays').value) * (lastRates.featuredPerDay || 40)) : t === 'visits' ? (Number($('cVisitCount').value) * (lastRates.visitCostPerVisit || 3)) : Number($('cBudget').value) });
IAP.status('Campaign is live. It starts serving right away.', 'ok');
// clear EVERY field so no target/creative carries into the next campaign
['cName', 'cBudget', 'cTarget', 'cImage', 'cTitle', 'cBody', 'cCtaLabel',
'cVideoUrl', 'cVideoTitle', 'cVideoCta', 'cVisitTitle', 'cVisitCount', 'cFeatTitle']
.forEach(id => { if ($(id)) $(id).value = ''; });
$('cSoloEd').innerHTML = ''; if ($('cSoloRaw')) $('cSoloRaw').value = '';
$('cVideoInfo').textContent = ''; $('cVideoPrev').hidden = true; $('cVideoPrev').innerHTML = '';
if ($('edMediaInfo')) $('edMediaInfo').textContent = '';
cVidDims = null;
await loadCampaigns();
}));
// defers the busy() lookup to click time (busy is declared below)
function busy2(btn, fn) { return (...a) => busy(btn, fn)(...a); }
// ── verified visits: open a member's site (new tab), dwell, human-check, earn ──
const visState = { token: null, id: null, dwell: 8 };
async function loadVisitStatus() {
try {
const st = await (await fetch('/api/my/visits')).json();
if (st.error) return;
$('vsProgress').textContent = 'today: ' + (st.status.count || 0) + ' / ' + st.status.cap + ' verified visits';
$('vsStartBtn').hidden = st.status.count >= st.status.cap;
if (st.status.count >= st.status.cap) $('vsBox').innerHTML = '<span class="muted small">That\'s today\'s visits. Come back tomorrow.</span>';
} catch (e) {}
}
async function loadVisit() {
$('vsCheck').hidden = true; $('vsVisit').hidden = true; $('vsHint').textContent = '';
let r = null;
try { r = await (await fetch('/api/my/visits')).json(); } catch (e) {}
if (!r || !r.ad) {
$('vsBox').innerHTML = '<span class="muted small">' + (r && r.status && r.status.count >= r.status.cap
? 'That\'s today\'s visits. Come back tomorrow.' : 'No verified-visit packs are running right now. Check back soon.') + '</span>';
return;
}
visState.token = r.token; visState.id = r.ad.id; visState.dwell = r.status.dwell || 8;
$('vsBox').innerHTML = '<b>' + esc(r.ad.title || 'Member site') + '</b><br><span class="muted small">Open the site and stay ' + visState.dwell + 's.</span>';
const visit = $('vsVisit');
visit.href = r.ad.url; visit.hidden = false;
$('vsStartBtn').textContent = 'Loading…'; $('vsStartBtn').disabled = true;
// opening the site starts the dwell; then we ask the human-check
visit.onclick = () => {
let left = visState.dwell;
$('vsHint').textContent = 'Counting your visit: ' + left + 's';
const t = setInterval(async () => {
left--;
if (left > 0) { $('vsHint').textContent = 'Counting your visit: ' + left + 's'; return; }
clearInterval(t);
$('vsHint').textContent = 'One quick check to count the visit:';
let c = await (await fetch('/api/my/visitchallenge?token=' + visState.token)).json();
if (c.early) { await new Promise(r2 => setTimeout(r2, (c.wait || 1) * 1000 + 300)); c = await (await fetch('/api/my/visitchallenge?token=' + visState.token)).json(); }
if (c.error) { $('vsHint').textContent = c.error; return; }
renderVisitCheck(c);
}, 1000);
};
$('vsStartBtn').textContent = 'Next visit'; $('vsStartBtn').disabled = false;
}
function renderVisitCheck(c) {
$('vsPrompt').textContent = 'Click the ' + c.prompt + ':';
const w = $('vsOpts'); w.innerHTML = '';
c.options.forEach((em, i) => {
const b = document.createElement('button');
b.className = 'btn small sec'; b.style.marginRight = '6px'; b.textContent = em;
b.addEventListener('click', () => answerVisit(i));
w.appendChild(b);
});
$('vsCheck').hidden = false;
}
async function answerVisit(i) {
const r = await (await fetch('/api/my/visitdone', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token: visState.token, answer: i }) })).json();
if (r.error) {
if (r.retry) { const c = await (await fetch('/api/my/visitchallenge?token=' + visState.token)).json(); if (!c.error) return renderVisitCheck(c); }
$('vsHint').textContent = r.error; $('vsCheck').hidden = true; return;
}
$('vsCheck').hidden = true;
$('vsHint').textContent = '+' + r.credited + ' credits — visit counted. Load the next one.';
$('vsProgress').textContent = 'today: ' + (r.status.count || 0) + ' / ' + (r.status.cap || 0) + ' verified visits';
IAP.status('+' + r.credited + ' credits for a verified visit.', 'ok');
loadDashboard();
}
$('vsStartBtn').addEventListener('click', () => loadVisit());
// ── watch-to-earn videos: escape-proof player, server-clock reward ──
const vidState = { token: null, secs: 0, maxSeen: 0, done: false, credited: false };
// detected dimensions of the video being created — portrait goes to Shorts, landscape to Watch videos
let cVidDims = null;
function probeVideoDims(url) {
return new Promise(resolve => {
if (!url) return resolve(null);
const v = document.createElement('video'); v.preload = 'metadata'; v.muted = true;
let done = false; const fin = r => { if (!done) { done = true; resolve(r); } };
v.onloadedmetadata = () => fin(v.videoWidth && v.videoHeight ? { w: v.videoWidth, h: v.videoHeight } : null);
v.onerror = () => fin(null); setTimeout(() => fin(null), 8000); v.src = url;
});
}
function showVidOrient() {
const el = $('cVideoInfo'); if (!el) return;
if (!cVidDims) return;
const portrait = cVidDims.h > cVidDims.w;
el.textContent = (el.textContent ? el.textContent + ' · ' : '')
+ (portrait ? 'portrait — shows in the Shorts reel' : 'landscape — shows in the Watch videos tab');
}
async function loadVideoStatus() {
try {
const st = await (await fetch('/api/my/videos?orientation=landscape')).json();
if (st.error) return;
$('vidProgress').textContent = 'today: ' + (st.status.count || 0) + ' / ' + st.status.cap + ' videos watched';
if (st.status.left <= 0) {
$('vidBox').innerHTML = '<span class="muted small">That is today\'s video set. Come back tomorrow.</span>';
$('vidStartBtn').hidden = true;
return;
}
$('vidStartBtn').hidden = false;
} catch (e) {}
}
async function loadVideoAd() {
let r = null;
try { r = await (await fetch('/api/my/videos?orientation=landscape')).json(); } catch (e) {}
if (!r || !r.ad) {
$('vidBox').innerHTML = '<span class="muted small">' + (r && r.status && r.status.left <= 0
? 'That is today\'s video set. Come back tomorrow.'
: 'No member videos are live right now. Check back when a campaign is running.') + '</span>';
$('vidWrap').hidden = true;
return;
}
const ad = r.ad;
vidState.token = r.token; vidState.secs = ad.watchSecs; vidState.maxSeen = 0; vidState.done = false; vidState.credited = false;
$('vidBox').hidden = true;
$('vidWrap').hidden = false;
$('vidCta').hidden = true;
$('vidHint').textContent = ad.title ? 'Now playing: ' + ad.title : '';
const p = $('vidPlayer');
p.src = ad.videoUrl;
p.currentTime = 0;
// escape-proof: no seeking past what's been watched; track max reached
p.onseeking = () => { if (p.currentTime > vidState.maxSeen + 0.5) p.currentTime = vidState.maxSeen; };
p.ontimeupdate = () => {
if (p.currentTime > vidState.maxSeen) vidState.maxSeen = p.currentTime;
const left = Math.max(0, Math.ceil(vidState.secs - vidState.maxSeen));
$('vidTimer').textContent = left > 0 ? 'Watch ' + left + 's more to earn' : 'Watch time met — finishing…';
if (!vidState.done && vidState.maxSeen >= vidState.secs) { vidState.done = true; completeVideo(ad); }
};
$('vidStartBtn').textContent = 'Playing…';
$('vidStartBtn').disabled = true;
try { await p.play(); } catch (e) { $('vidStartBtn').disabled = false; $('vidStartBtn').textContent = 'Tap to play'; }
$('vidCta').href = ad.ctaUrl;
$('vidCta').textContent = ad.ctaLabel || 'Learn more';
$('vidCta').hidden = false;
}
async function completeVideo(ad) {
if (vidState.credited) return;
vidState.credited = true;
try {
const r = await (await fetch('/api/my/videowatch', { method: 'POST',
headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token: vidState.token }) })).json();
if (r.error) { IAP.status(r.error, 'bad'); $('vidHint').textContent = r.error; }
else if (r.credited) { IAP.status('+' + r.credited + ' credits earned for watching.', 'ok'); $('vidHint').textContent = '+' + r.credited + ' credits earned. Load the next one.'; loadDashboard(); }
else $('vidHint').textContent = 'That video just ran out of budget — load another.';
$('vidProgress').textContent = 'today: ' + ((r.status && r.status.count) || 0) + ' / ' + ((r.status && r.status.cap) || 0) + ' videos watched';
} catch (e) { IAP.status('Could not confirm that watch. Try the next one.', 'bad'); }
$('vidStartBtn').disabled = false;
$('vidStartBtn').textContent = 'Next video';
}
$('vidStartBtn').addEventListener('click', () => loadVideoAd());
// presence enforcement: pause the watch-to-earn video when the tab/window loses focus,
// resume when it comes back — the viewer must stay on the page for the watch to complete
document.addEventListener('visibilitychange', () => {
const p = $('vidPlayer'); if (!p || !p.src) return;
if (document.hidden) p.pause(); else if (!vidState.done) p.play().catch(() => {});
});
window.addEventListener('blur', () => { const p = $('vidPlayer'); if (p && p.src) p.pause(); });
window.addEventListener('focus', () => { const p = $('vidPlayer'); if (p && p.src && !vidState.done) p.play().catch(() => {}); });
// ── unmissable sponsor-message modal on sign-in ──
function showSponsorModal(msg) {
if (!$('msgModal')) return;
$('mmFrom').textContent = 'A message from ' + (msg.fromName || 'your sponsor');
$('mmSubject').textContent = msg.subject || '';
$('mmBody').innerHTML = msg.body || ''; // server-sanitized
$('msgModal').hidden = false;
$('mmAck').onclick = async () => {
$('msgModal').hidden = true;
try { await fetch('/api/my/messages/' + msg.id + '/read', { method: 'POST' }); } catch (e) {}
};
}
// ── coaching: every direct's rung, stalled flag, one-click nudge ──
async function loadCoach() {
try {
const r = await (await fetch('/api/my/coach')).json();
const el = $('coachList'); if (!el || r.error) return;
const d = r.directs || [];
$('coachSummary').innerHTML = d.length ? '<b>' + d.length + '</b> direct' + (d.length === 1 ? '' : 's') + ' · <b>' + r.stalled + '</b> quiet for 3+ days' + (r.stalled ? ' · start at the top' : '') : '';
if (!d.length) { el.innerHTML = '<p class="muted small">No directs yet. When someone joins through your link they show up here with their next step.</p>'; return; }
el.innerHTML = d.map(x => '<div class="lin-row' + (x.stalled ? ' own' : '') + '"><span class="nm">' + esc(x.name) + (x.stalled ? ' <span class="badge amber">quiet ' + x.quietDays + 'd</span>' : '') + '</span>'
+ '<span class="em">' + esc(x.label) + ' → ' + esc(x.next) + '</span>'
+ '<span class="id">rung ' + x.rung + '/6</span>'
+ '<span class="dt">' + (x.buyerCount ? x.buyerCount + ' buyer' + (x.buyerCount === 1 ? '' : 's') : '') + '</span>'
+ '<button class="btn sec small" type="button" data-nudge="' + esc(x.email) + '" data-nname="' + esc(x.name) + '" data-say="' + esc(x.say) + '">Nudge</button></div>').join('');
el.querySelectorAll('[data-nudge]').forEach(b => b.addEventListener('click', async () => {
await openConvo(b.dataset.nudge, b.dataset.nname);
const inp = $('chatInput'); if (inp) { inp.value = b.dataset.say.replace(/\{\{name\}\}/g, b.dataset.nname.replace(/^@/, '')); inp.focus(); }
}));
} catch (e) {}
}
// ── link stats: views, joins, buyers per angle ──
async function loadLinkStats() {
try {
const r = await (await fetch('/api/my/linkstats')).json();
const t = $('linkStatsTable'); if (!t || r.error) return;
const rows = (r.angles || []).filter(a => a.views || a.joins || a.buyers);
if (!rows.length) { t.innerHTML = '<tr><td class="muted small">No views yet. Share your link and the numbers start here.</td></tr>'; return; }
t.innerHTML = '<tr><th>Hook</th><th>Views (30d)</th><th>Views (all)</th><th>Joined</th><th>Qualifying buyers</th></tr>'
+ rows.map(a => '<tr><td>' + esc(a.angle === 'plain' ? 'plain link' : '?v=' + a.angle) + '</td><td class="mono">' + a.views30 + '</td><td class="mono">' + a.views + '</td><td class="mono">' + a.joins + '</td><td class="mono">' + a.buyers + '</td></tr>').join('');
} catch (e) {}
}
// ── prospects: the member's own follow-up list ──
let PP_STATUSES = ['new', 'contacted', 'interested', 'joined', 'bought', 'not now'];
async function loadProspects() {
try {
const r = await (await fetch('/api/my/prospects')).json();
if (r.error) return;
PP_STATUSES = r.statuses || PP_STATUSES;
const sel = $('ppStatus'); if (sel && !sel.options.length) sel.innerHTML = PP_STATUSES.map(s => '<option value="' + s + '">' + s + '</option>').join('');
const list = r.prospects || []; const el = $('prospectList'); if (!el) return;
if (!list.length) { el.innerHTML = '<p class="muted small">Nobody on the list yet.</p>'; return; }
const today = new Date(); today.setHours(0, 0, 0, 0);
el.innerHTML = list.map(p => { const due = p.nextTs && p.nextTs <= today.getTime() + 86399999; return '<div class="lin-row' + (due ? ' own' : '') + '" data-pid="' + p.id + '"><span class="nm">' + esc(p.name) + (due ? ' <span class="badge amber">follow up</span>' : '') + '</span>'
+ '<span class="em">' + esc(p.contact || '') + (p.note ? ' · ' + esc(p.note) : '') + '</span>'
+ '<select class="small" data-pstatus="' + p.id + '">' + PP_STATUSES.map(s => '<option' + (s === p.status ? ' selected' : '') + '>' + s + '</option>').join('') + '</select>'
+ '<input type="date" class="small" data-pnext="' + p.id + '" value="' + (p.nextTs ? new Date(p.nextTs).toISOString().slice(0, 10) : '') + '">'
+ '<button class="btn sec small" type="button" data-pdel="' + p.id + '">Remove</button></div>'; }).join('');
const save = async (id, patch) => { const p = list.find(x => x.id === Number(id)); if (!p) return; try { await api('/api/my/prospects', Object.assign({}, p, patch)); } catch (e) { IAP.status(e.message, 'bad'); } };
el.querySelectorAll('[data-pstatus]').forEach(s => s.addEventListener('change', () => save(s.dataset.pstatus, { status: s.value })));
el.querySelectorAll('[data-pnext]').forEach(i => i.addEventListener('change', () => save(i.dataset.pnext, { next: i.value, nextTs: i.value ? Date.parse(i.value + 'T12:00:00') : null })));
el.querySelectorAll('[data-pdel]').forEach(b => b.addEventListener('click', async () => { try { await api('/api/my/prospects/remove', { id: b.dataset.pdel }); loadProspects(); } catch (e) { IAP.status(e.message, 'bad'); } }));
} catch (e) {}
}
if ($('prospectForm')) $('prospectForm').addEventListener('submit', async e => {
e.preventDefault();
try {
await api('/api/my/prospects', { name: $('ppName').value, contact: $('ppContact').value, status: $('ppStatus').value, nextTs: $('ppNext').value ? Date.parse($('ppNext').value + 'T12:00:00') : null });
$('ppName').value = ''; $('ppContact').value = ''; $('ppNext').value = ''; loadProspects();
} catch (err) { IAP.status(err.message, 'bad'); }
});
// ── broadcast templates ──
const BC_TEMPLATES = [
{ label: 'Welcome', subject: 'Welcome to my line: your first three moves', html: '<p>Glad you are in. Three things today, in this order:</p><ol><li>Pick your username on the Profile tab (it becomes your link).</li><li>Wallet tab: Connect and link wallet, then Switch on payouts. Both are free.</li><li>Copy your invite link from My line and send it to one person.</li></ol><p>Reply here if you get stuck on any of them. That is what I am here for.</p>' },
{ label: 'Switch on payouts', subject: 'One free step so nothing passes you by', html: '<p>Quick reminder: if payouts are not switched on yet, do it now on the Wallet tab. One free transaction.</p><p>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.</p>' },
{ label: 'The $5 test', subject: 'See a payout land in real time', html: '<p>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.</p><p>When you are ready to count as a qualifying buyer for me, the $20 Activation package is the one.</p>' },
{ label: 'Qualified Start', subject: 'How to open level 2 today with your own positions', html: '<p>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.</p><p>Two positions open level 2 the same day. The three Qualified Start videos in Training show every click.</p>' },
{ label: 'Share your link', subject: 'One conversation a day is the whole job', html: '<p>Promo tools has posts, texts and emails that already carry your link. Pick one and send it to one person today.</p><p>Do not wait for the perfect moment. Nobody who waited ever built a line.</p>' }
];
(function () {
const w = $('bcTemplates'); if (!w) return;
w.innerHTML = BC_TEMPLATES.map((t, i) => '<button type="button" class="chip-t" data-bct="' + i + '">' + esc(t.label) + '</button>').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 = '<div class="grid c2"><div><b>$' + gross + '</b> out across ' + k + ' position' + (k === 1 ? '' : 's') + '<br><b>$' + back + '</b> back to your main wallet in the same transactions (the 50% direct-sponsor share)<br><b>$' + net + '</b> net, plus a little POL for gas in each wallet</div>'
+ '<div><b>' + credits.toLocaleString() + ' credits</b> pooled for your own ads<br>' + level + '<br><span class="muted">The 20% and 10% shares go to your upline if they are qualified, otherwise to the platform.</span></div></div>';
};
n.addEventListener('input', calc); pk.addEventListener('change', calc); calc();
})();
// ── downline lineage + sponsor broadcast + upline messages ──
async function loadLineage() {
try {
const r = await (await fetch('/api/my/line')).json();
const el = $('lineageWrap');
if (r.error || !r.levels || !r.levels.every) return;
if (!r.levels.length || !r.levels.some(L => L.members.length)) {
el.innerHTML = '<p class="muted small">No one in your downline yet. Share your link and it fills in here.</p>';
return;
}
el.innerHTML = r.levels.map(L => !L.members.length ? '' :
'<div class="lin-lvl"><div class="cap">Level ' + L.level + ' · ' + L.members.length + (L.level === 1 ? ' direct' : '') + '</div>'
+ L.members.map(m => '<div class="lin-row' + (m.own ? ' own' : '') + '"><span class="nm">' + esc(m.name) + (m.own ? ' <span class="badge">yours</span>' : '') + '</span>'
+ (m.email ? '<span class="em">' + esc(m.email) + '</span>' : '<span class="id">#' + m.memberId + '</span>')
+ '<span class="earn' + (m.earnedWei && m.earnedWei !== '0' ? ' on' : '') + '" title="POL this person has paid you so far">'
+ (m.earnedWei && m.earnedWei !== '0' ? '+' + IAP.fmtPol(m.earnedWei) + ' POL' : '0.00 POL') + '</span>'
+ '<span class="dt">' + new Date(m.joined).toLocaleDateString() + '</span>'
+ (m.email ? '<button class="btn sec small chat-msg-btn" type="button" data-cemail="' + esc(m.email) + '" data-cname="' + esc(m.name) + '">Message</button>' : '')
+ '</div>').join('')
+ '</div>').join('');
el.querySelectorAll('[data-cemail]').forEach(b =>
b.addEventListener('click', () => openConvo(b.dataset.cemail, b.dataset.cname)));
} catch (e) {}
}
async function loadUplineMessages() {
try {
const r = await (await fetch('/api/my/messages')).json();
if (r.error) return;
const card = $('upMsgCard'), list = $('upList');
if (!r.items || !r.items.length) { card.hidden = true; return; }
card.hidden = false;
list.innerHTML = r.items.map(i => '<div class="promo-block" style="margin-bottom:10px">'
+ '<div style="display:flex;justify-content:space-between;gap:10px"><b>' + esc(i.subject) + '</b>'
+ '<span class="small muted">' + esc(i.fromName) + ' · ' + new Date(i.sent).toLocaleDateString() + '</span></div>'
+ '<div class="ib-rich" style="margin-top:8px">' + (i.body || '') + '</div></div>').join('');
// opening the pane marks them read
for (const i of r.items) if (!i.read) fetch('/api/my/messages/' + i.id + '/read', { method: 'POST' }).catch(() => {});
} catch (e) {}
}
// broadcast composer editor (its own small rich editor, server sanitizes)
document.querySelectorAll('[data-bc]').forEach(b =>
b.addEventListener('click', () => { $('bcEd').focus(); document.execCommand(b.dataset.bc, false, null); }));
document.querySelectorAll('[data-bcblock]').forEach(b =>
b.addEventListener('click', () => { $('bcEd').focus(); document.execCommand('formatBlock', false, b.dataset.bcblock); }));
if ($('bcLinkBtn')) $('bcLinkBtn').addEventListener('click', () => { const u = prompt('Link URL (https://…)'); if (u) { $('bcEd').focus(); document.execCommand('createLink', false, u); } });
if ($('bcSendBtn')) $('bcSendBtn').addEventListener('click', busy2($('bcSendBtn'), async () => {
const r = await api('/api/my/broadcast', { scope: $('bcScope').value, subject: $('bcSubject').value, body: $('bcEd').innerHTML });
IAP.status('Broadcast sent to ' + r.sent + ' member' + (r.sent === 1 ? '' : 's') + '.', 'ok');
$('bcSubject').value = ''; $('bcEd').innerHTML = '';
$('bcHint').textContent = 'Sent. You can send your next broadcast in 24 hours.';
}));
// ── solo-ads inbox: list, read view, dwell-gated read reward ──
let ibTimer = null;
function setInboxBadge(n) {
for (const id of ['inboxBadge', 'inboxBadge2']) {
const b = $(id);
if (b) { b.hidden = !n; b.textContent = n; }
}
}
// Earn credits sub-tabs: Watch ads | Inbox
let earnSub = 'watch';
function setEarnSub(which) {
earnSub = ['inbox', 'videos', 'visits'].includes(which) ? which : 'watch';
const w = $('earn-watch'), i = $('earn-inbox'), v = $('earn-videos'), vs = $('earn-visits');
if (w) w.hidden = earnSub !== 'watch';
if (i) i.hidden = earnSub !== 'inbox';
if (v) v.hidden = earnSub !== 'videos';
if (vs) vs.hidden = earnSub !== 'visits';
document.querySelectorAll('.subtabs [data-earn]').forEach(b =>
b.classList.toggle('on', b.dataset.earn === earnSub));
if (earnSub === 'inbox') loadInbox();
else if (earnSub === 'videos') loadVideoStatus();
else if (earnSub === 'visits') loadVisitStatus();
else earnRefresh();
}
document.querySelectorAll('.subtabs [data-earn]').forEach(b =>
b.addEventListener('click', () => setEarnSub(b.dataset.earn)));
// promo tools: pill menu switches between posts / swipes / banners / wall / videos
function setPromoSub(name) {
const ids = ['posts', 'text', 'swipe', 'banners', 'wall', 'objections', 'videos'];
if (!ids.includes(name)) name = 'posts';
ids.forEach(id => { const el = $('promo-' + id); if (el) el.hidden = id !== name; });
document.querySelectorAll('.promo-pills [data-promo]').forEach(b => {
b.classList.toggle('on', b.dataset.promo === name);
b.setAttribute('aria-selected', b.dataset.promo === name ? 'true' : 'false');
});
try { localStorage.setItem('iap.promoSub', name); } catch (e) {}
}
document.querySelectorAll('.promo-pills [data-promo]').forEach(b =>
b.addEventListener('click', () => setPromoSub(b.dataset.promo)));
try { setPromoSub(localStorage.getItem('iap.promoSub') || 'posts'); } catch (e) { setPromoSub('posts'); }
async function loadInbox() {
try {
const r = await (await fetch('/api/my/inbox')).json();
if (r.error) return;
$('ibRewardNote').textContent = '+' + r.readCredits + ' credits per real read (up to '
+ r.readCap + ' rewarded reads a day)';
const el = $('ibList');
$('inboxReadCard').hidden = true;
$('inboxListCard').hidden = false;
setInboxBadge(r.unread);
if (!r.items.length) {
el.innerHTML = '<p class="muted small">No solo ads yet. When a member sends one, it lands here — and reading it pays.</p>';
return;
}
el.innerHTML = '';
for (const i of r.items) {
const d = document.createElement('div');
d.className = 'ib-row' + (i.read ? '' : ' unread');
d.innerHTML = '<span class="sub"></span><span class="from"></span>'
+ (i.rewarded ? '<span class="badge">claimed</span>' : i.read ? '' : '<span class="badge amber">new</span>')
+ '<span class="when">' + new Date(i.delivered).toLocaleDateString() + '</span>';
d.querySelector('.sub').textContent = i.subject || '(no subject)';
d.querySelector('.from').textContent = 'from ' + (i.fromName || 'a member');
d.addEventListener('click', () => openInboxItem(i.id));
el.appendChild(d);
}
} catch (e) {}
}
async function openInboxItem(id) {
try {
const r = await (await fetch('/api/my/inbox/' + id)).json();
if (r.error) { IAP.status(r.error, 'bad'); return; }
$('inboxListCard').hidden = true;
$('inboxReadCard').hidden = false;
$('ibSubject').textContent = r.subject || '(no subject)';
$('ibMeta').textContent = 'from ' + (r.fromName || 'a member') + ' · ' + new Date(r.delivered).toLocaleString();
$('ibBody').innerHTML = r.body || ''; // whitelist-sanitized on the server at submit
const mv = $('ibMedia');
mv.hidden = !r.mediaUrl;
mv.innerHTML = !r.mediaUrl ? ''
: r.mediaType === 'video'
? '<video src="' + r.mediaUrl + '" controls style="max-width:100%;border-radius:12px"></video>'
: '<img src="' + r.mediaUrl + '" alt="attachment" style="max-width:100%;border-radius:12px">';
// the read reward needs BOTH the dwell AND an actual click-through to the
// advertiser — the visit is what makes the ad worth the sender's credits
const visit = $('ibVisit');
visit.href = r.url;
visit.textContent = r.ctaLabel || 'Learn more';
visit.target = '_blank';
const btn = $('ibClaimBtn');
clearInterval(ibTimer);
if (r.rewarded) {
btn.hidden = true;
visit.classList.remove('cta-need');
$('ibHint').textContent = 'Read reward already claimed for this one.';
return;
}
let dwellDone = false;
let visited = !!r.visited;
btn.hidden = false;
btn.disabled = true;
visit.classList.toggle('cta-need', !visited);
let left = r.dwell;
const refresh = () => {
if (!dwellDone) { btn.textContent = 'Read it — claim in ' + left + 's'; return; }
if (!visited) { btn.textContent = 'Claim +' + r.reward + ' — visit the ad first'; btn.disabled = true; return; }
btn.textContent = 'Claim +' + r.reward + ' credits'; btn.disabled = false;
};
$('ibHint').textContent = 'Read the message, click through to the advertiser, then claim your credits.';
refresh();
// countdown pauses off-tab; the server separately enforces the dwell on its own clock
ibTimer = setInterval(() => {
if (document.visibilityState !== 'visible' || !document.hasFocus()) return;
left -= 1;
if (left > 0) { refresh(); return; }
clearInterval(ibTimer);
dwellDone = true;
refresh();
}, 1000);
// clicking the CTA records the visit (and counts the advertiser's click)
visit.onclick = async () => {
visited = true;
visit.classList.remove('cta-need');
try { await fetch('/api/my/inbox/' + id + '/visit', { method: 'POST' }); } catch (e2) {}
refresh();
};
btn.onclick = async () => {
try {
const c = await api('/api/my/inbox/' + id + '/claim');
IAP.status('+' + c.credited + ' credits for reading. They spend like any earned credits.', 'ok');
btn.hidden = true;
$('ibHint').textContent = 'Claimed. Head back for the next one.';
loadDashboard();
} catch (e2) { IAP.status(e2.message, 'bad'); }
};
} catch (e) {}
}
$('ibBack').addEventListener('click', ev => { ev.preventDefault(); clearInterval(ibTimer); loadInbox(); });
// ── promo tools: content + rendering live in promo.js (IAPPromo.fill) ──
function fillPromo(link, me) {
if (window.IAPPromo) IAPPromo.fill(link, me || {});
// banner kit
const bwrap = $('promoBanners');
if (bwrap && !bwrap.dataset.filled) {
bwrap.dataset.filled = '1';
const BANNERS = [
{ file: 'iap-hero-1200x630.png', size: '1200×630 (social / hero)' },
{ file: 'iap-1080x1080.png', size: '1080×1080 (Instagram / Facebook square)' },
{ file: 'iap-1080x1920.png', size: '1080×1920 (story / reel)' },
{ file: 'iap-1280x720.png', size: '1280×720 (Telegram group post)' },
{ file: 'iap-728x90.png', size: '728×90 (leaderboard)' },
{ file: 'iap-336x280.png', size: '336×280 (large rectangle)' },
{ file: 'iap-300x250.png', size: '300×250 (rectangle)' },
{ file: 'iap-125x125.png', size: '125×125 (square button)' },
{ file: 'iap-468x60.png', size: '468×60 (banner)' },
{ file: 'iap-160x600.png', size: '160×600 (wide skyscraper)' },
{ file: 'iap-120x600.png', size: '120×600 (skyscraper)' },
{ file: 'iap-320x50.svg', size: '320×50 (mobile leaderboard)' }
];
bwrap.innerHTML = '';
for (const b of BANNERS) {
const url = location.origin + '/banners/' + b.file;
const d = document.createElement('div');
d.className = 'pb-item';
d.innerHTML = '<img src="/banners/' + b.file + '?v=3" alt="InstantAdPay banner ' + b.size + '" loading="lazy">'
+ '<div class="pb-row"><span class="pb-size">' + b.size + '</span></div>';
const dl = document.createElement('a');
dl.className = 'btn small'; dl.textContent = 'Download'; dl.href = '/banners/' + b.file; dl.setAttribute('download', b.file);
d.querySelector('.pb-row').appendChild(dl);
const btn = document.createElement('button');
btn.className = 'btn small sec';
btn.textContent = 'Copy image URL';
btn.addEventListener('click', async () => {
try { await navigator.clipboard.writeText(url); IAP.status('Banner URL copied.', 'ok'); }
catch (e) { IAP.status('Copy failed.', 'bad'); }
});
d.querySelector('.pb-row').appendChild(btn);
bwrap.appendChild(d);
}
}
}
// ── profile ── (busy2 defers the busy lookup past its TDZ)
$('pfSaveBtn').addEventListener('click', busy2($('pfSaveBtn'), async () => {
const r = await api('/api/my/profile', { username: $('pfUsername').value });
IAP.status('You are @' + r.account.username + ' now.', 'ok');
await render();
}));
// ── in-dashboard package buying ──
const PKG = { 1: 'Micro', 2: 'Activation', 3: 'Builder', 4: 'Growth', 5: 'Leader' };
// Card on-ramp: buy POL with a card via MoonPay, delivered to the buyer's own
// wallet. Signed + wallet-prefilled once MoonPay keys are set; generic page
// otherwise. The site never touches funds — MoonPay is merchant of record.
async function openMoonpay(pol) {
try {
let addr = '';
try { const me = await (await fetch('/api/me')).json(); addr = me.address || ''; } catch (e) {}
const q = '/api/moonpay-url?pol=' + encodeURIComponent(pol || '') + (addr ? '&address=' + encodeURIComponent(addr) : '');
const r = await (await fetch(q)).json();
if (r && r.url) {
window.open(r.url, '_blank', 'noopener');
IAP.status(r.signed
? 'MoonPay opened in a new tab with your wallet address pre-filled. Choose POL on Polygon, finish the purchase, then come back and buy your package.'
: 'MoonPay opened in a new tab. Choose POL on the Polygon network and paste your own wallet address as the destination, then come back.', 'ok');
}
} catch (e) { IAP.status('Could not open MoonPay: ' + ((e && e.message) || e), 'bad'); }
}
// ── linked positions (Qualified Start) ──
const short = a => a ? a.slice(0, 6) + '…' + a.slice(-4) : '';
async function loadPositions(me) {
try {
const r = await (await fetch('/api/my/positions')).json();
if (r.error) return;
const list = r.positions || [];
const rows = list.map((p, i) => '<div class="lin-row"><span class="nm">Position ' + (i + 2) + ' <span class="mono">' + short(p.address) + '</span></span>'
+ '<span class="id">' + (p.memberId ? '#' + p.memberId : 'not on-chain yet') + '</span>'
+ '<span class="earn' + (p.counted ? ' on' : '') + '">' + (p.counted ? 'counts as a qualifying buyer' : p.memberId ? 'registered, buy $20+ to count' : 'buy a $20+ package to register it') + '</span>'
+ '<span class="dt">' + (p.credits || 0).toLocaleString() + ' credits' + (p.balanceWei != null ? ' · ' + (Number(BigInt(p.balanceWei) / 10n ** 14n) / 10000).toLocaleString(undefined, { maximumFractionDigits: 2 }) + ' POL' : '') + '</span>'
+ (!p.memberId ? '<button class="btn sec small" type="button" data-unlink="' + p.address + '">Unlink</button>' : '')
+ '</div>').join('');
const mainRow = r.main && r.main.address ? '<div class="lin-row"><span class="nm">Position 1 · main <span class="mono">' + short(r.main.address) + '</span></span>'
+ '<span class="id">' + (r.main.memberId ? '#' + r.main.memberId : 'payouts not on yet') + '</span>'
+ '<span class="earn on">' + (r.main.buyerCount || 0) + ' qualifying buyer(s)</span>'
+ '<span class="dt">' + (r.main.credits || 0).toLocaleString() + ' credits' + (r.main.balanceWei != null ? ' · ' + (Number(BigInt(r.main.balanceWei) / 10n ** 14n) / 10000).toLocaleString(undefined, { maximumFractionDigits: 2 }) + ' POL' : '') + '</span></div>' : '';
const html = mainRow + rows + (list.length ? '<p class="muted small" style="margin:8px 0 0">Pooled credits: <b>' + (r.totalCredits || 0).toLocaleString() + '</b>. A campaign budget spends from one position at a time.</p>' : '');
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: <b class="mono">' + polN.toLocaleString(undefined, { maximumFractionDigits: 2 }) + ' POL</b>' + (usd ? ' (about $' + usd.toLocaleString(undefined, { maximumFractionDigits: 0 }) + ')' : '')
+ (r.polUsd ? '<br><span class="muted">' + (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.' : '') + '</span>' : '');
}
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 = '<option value="main">Main wallet ' + (r.main && r.main.address ? short(r.main.address) : '(link it first)') + (r.main && r.main.memberId ? ' · #' + r.main.memberId : '') + '</option>'
+ list.map((p, i) => '<option value="' + p.address + '">Position ' + (i + 2) + ' ' + short(p.address) + (p.memberId ? ' · #' + p.memberId : ' · not registered yet') + (p.counted ? ' · counted' : '') + '</option>').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 = '<div class="name">' + (PKG[p.id] || 'Package ' + p.id) + '</div>'
+ '<div class="price">$' + Math.round(p.priceCents / 100) + '</div>'
+ '<div class="cr">' + p.creditAmount.toLocaleString() + ' credits</div>'
+ '<div class="bonus">' + (bonus > 0 ? '+' + bonus.toLocaleString() + ' bonus credits' : '&nbsp;') + '</div>'
+ '<div class="pol">' + (p.costWei ? IAP.fmtPol(p.costWei) + ' POL right now' : 'paused') + '</div>'
+ '<button class="btn small" data-id="' + p.id + '" data-cost="' + (p.costWei || '') + '"'
+ (p.costWei ? '' : ' disabled') + '>Buy $' + Math.round(p.priceCents / 100) + '</button>';
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 = '<p class="muted small" style="margin:0 0 8px">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.</p>'
+ '<button class="btn small sec" id="moonpayBtn" type="button">💳 Buy POL with a card</button>';
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.earned + ' earned credits';
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 = '<div class="done-big"><span class="tick">✓</span><b>Ads done for today</b><span class="muted small">Today\'s set is viewed and claimed. Fresh ads tomorrow.</span></div>';
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/<token> 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 = '<span class="muted small">' + (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.') + '</span>';
return;
}
openAdOverlay(r.viewUrl);
box.innerHTML = '<span class="muted small">Watch the countdown and pass the quick check. Your view credits itself and this page updates right away.</span>';
$('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 start = busy($('mcSendBtn'), async () => {
const r = await api('/api/auth/email/start', { email: $('mcEmail').value });
$('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 api('/api/auth/email/start', { email: $('mcEmail').value });
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 <span class="mono">' + addr.slice(0, 8) + '…' + addr.slice(-6) + '</span>'
+ (copied ? ' is copied' : '') + '. On the faucet, choose <b>Polygon Amoy</b>, 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 = '<img src="' + a.lineBannerUrl + '" alt="line banner" style="max-width:320px;border-radius:10px">';
}
$('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 ? '<img src="' + o.bannerUrl + '" alt="">' : '';
$('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 = '<img src="' + r.url + '" alt="">'; }
} 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 = '<img src="' + r.url + '" alt="line banner" style="max-width:320px;border-radius:10px">';
} 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
? '<img src="' + ad.imageUrl + '" alt="sponsor ad">'
: '<span class="lg-linkcard">' + (ad.title ? String(ad.title).replace(/[&<>]/g, '') : 'Visit today\'s sponsor') + '</span>';
$('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 = '<p class="muted small" style="padding:14px">Loading…</p>';
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)
? '<div class="chat-thread" data-email="' + esc(CHAT_SPONSOR.email) + '" data-name="' + esc(CHAT_SPONSOR.name) + '">'
+ '<span class="pres-dot' + (CHAT_SPONSOR.online ? ' on' : '') + '"></span>'
+ '<div class="ct-main"><div class="ct-name">' + esc(CHAT_SPONSOR.name) + '</div>'
+ '<div class="ct-last">Your sponsor · tap to message</div></div></div>'
: '';
if (!list.length && !sponsorRow) { $('chatThreads').innerHTML = '<div class="chat-empty">No conversations yet. You can message anyone in your line from “My line”.</div>'; return; }
$('chatThreads').innerHTML = sponsorRow + list.map(t =>
'<div class="chat-thread" data-email="' + esc(t.email) + '" data-name="' + esc(t.name) + '">'
+ '<span class="pres-dot' + (t.online ? ' on' : '') + '"></span>'
+ '<div class="ct-main"><div class="ct-name">' + esc(t.name) + '</div>'
+ '<div class="ct-last">' + (t.last.fromMe ? 'You: ' : '') + esc((t.last.body || '').slice(0, 64)) + '</div></div>'
+ (t.unread ? '<span class="ct-un">' + t.unread + '</span>' : '') + '</div>').join('');
$('chatThreads').querySelectorAll('.chat-thread').forEach(el =>
el.addEventListener('click', () => openConvo(el.dataset.email, el.dataset.name)));
} catch (e) { $('chatThreads').innerHTML = '<div class="chat-empty">Could not load messages.</div>'; }
}
async function openConvo(email, name) {
if (!email) return;
CHAT_OTHER = email; CHAT_LASTID = 0;
const dr = $('chatDrawer'); if (!dr) return;
dr.hidden = false; $('chatThreads').hidden = true; $('chatConvo').hidden = false;
$('chatBack').hidden = false; $('chatDot').hidden = false;
$('chatTitle').textContent = name || email; $('chatStatus').textContent = '…';
$('chatMsgs').innerHTML = ''; $('chatBanner').hidden = true;
$('chatInput').disabled = false; $('chatSend').disabled = false;
await pullThread(true); startPoll();
setTimeout(() => { const i = $('chatInput'); if (i) i.focus(); }, 60);
}
function renderMsgs(msgs) {
const box = $('chatMsgs'); if (!box) return;
const atBottom = box.scrollTop + box.clientHeight >= box.scrollHeight - 60;
for (const m of msgs) {
if (m.id <= CHAT_LASTID) continue;
CHAT_LASTID = Math.max(CHAT_LASTID, m.id);
const d = document.createElement('div');
d.className = 'cbub ' + (m.fromMe ? 'me' : 'them');
d.textContent = m.body;
const t = document.createElement('span'); t.className = 'ct-time';
t.textContent = new Date(m.sent).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
d.appendChild(t); box.appendChild(d);
}
if (atBottom) box.scrollTop = box.scrollHeight;
}
async function pullThread(reset) {
if (!CHAT_OTHER) return;
try {
const r = await (await fetch('/api/my/chat/thread?with=' + encodeURIComponent(CHAT_OTHER) + '&after=' + (reset ? 0 : CHAT_LASTID))).json();
if (r.error) { $('chatBanner').hidden = false; $('chatBanner').textContent = r.error; return; }
if (reset) { $('chatMsgs').innerHTML = ''; CHAT_LASTID = 0; }
renderMsgs(r.messages || []);
$('chatDot').className = 'pres-dot' + (r.online ? ' on' : '');
$('chatStatus').textContent = r.online ? 'active now' : (r.available ? 'away · will get your note' : 'not taking live chats · leave a note');
CHAT_CANMUTE = !!r.canMute; CHAT_IMUTE = !!r.iMute;
const mb = $('chatMute'); mb.hidden = !CHAT_CANMUTE; mb.textContent = CHAT_IMUTE ? 'Unmute' : 'Mute';
const blocked = !!r.blocked;
$('chatInput').disabled = blocked; $('chatSend').disabled = blocked;
if (blocked) { $('chatBanner').hidden = false; $('chatBanner').textContent = 'They are not accepting messages from you right now.'; }
else if (CHAT_IMUTE) { $('chatBanner').hidden = false; $('chatBanner').textContent = 'You muted this member. Unmute to let them message you again.'; }
else $('chatBanner').hidden = true;
} catch (e) {}
}
async function chatSend() {
const inp = $('chatInput'); if (!inp) return;
const text = inp.value.trim(); if (!text || !CHAT_OTHER) return;
$('chatSend').disabled = true;
try {
const r = await api('/api/my/chat/send', { to: CHAT_OTHER, body: text });
inp.value = ''; autoGrow(inp); renderMsgs([r.message]);
const box = $('chatMsgs'); box.scrollTop = box.scrollHeight;
} catch (e) { IAP.status(e.message || 'Could not send.', 'bad'); }
finally { $('chatSend').disabled = false; inp.focus(); }
}
async function toggleMute() {
if (!CHAT_OTHER) return;
try { const r = await api('/api/my/chat/mute', { email: CHAT_OTHER, muted: !CHAT_IMUTE }); CHAT_IMUTE = r.muted; $('chatMute').textContent = CHAT_IMUTE ? 'Unmute' : 'Mute'; pullThread(false); }
catch (e) { IAP.status(e.message, 'bad'); }
}
(function chatInit() {
if (!$('chatDrawer')) return;
const on = (id, ev, fn) => { const el = $(id); if (el) el.addEventListener(ev, fn); };
on('chatMenuBtn', 'click', e => { if (e && e.preventDefault) e.preventDefault(); openThreads(); });
on('chatClose', 'click', closeDrawer);
on('chatBack', 'click', openThreads);
on('chatMute', 'click', toggleMute);
on('chatSend', 'click', chatSend);
const inp = $('chatInput');
if (inp) {
inp.addEventListener('input', () => autoGrow(inp));
inp.addEventListener('keydown', e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); chatSend(); } });
}
on('qaMsgSponsor', 'click', () => { const q = $('qaMsgSponsor'); if (q.dataset.email) openConvo(q.dataset.email, q.dataset.name || 'your sponsor'); });
on('lineMsgSponsor', 'click', () => { if (CHAT_SPONSOR) openConvo(CHAT_SPONSOR.email, CHAT_SPONSOR.name || 'your sponsor'); });
on('chatAvailToggle', 'change', async () => {
const t = $('chatAvailToggle');
try { await api('/api/my/chat/available', { available: t.checked }); IAP.status(t.checked ? 'You are available to chat with your line.' : 'Live chat off. People can still leave you a note.', 'ok'); }
catch (e) { IAP.status(e.message, 'bad'); t.checked = !t.checked; }
});
// presence heartbeat + new-message notifier: pop + toast when unread rises
setInterval(async () => {
try {
const r = await (await fetch('/api/my/ping')).json();
const n = r.chatUnread || 0;
if (n > CHAT_LAST_UNREAD) { playSound('pop'); IAP.status('💬 New message from your team.', 'ok'); }
CHAT_LAST_UNREAD = n; setChatBadge(n);
} catch (e) {}
}, 20000);
})();
// ad surfaces: a banner greets the sign-in screen; members see live
// banner + text placements inside the back office (they ARE the audience)
IAP.adSlot('banner', 'adSlotLogin');
IAP.adSlot('banner', 'adSlotOverview');
IAP.adSlot('banner', 'adSlotSide', { width: 125, height: 125 }); // square button ad in the sidebar
render();
})();