// My account: email-first join/login, wallet link at purchase time, // free payout activation, invite link, on-chain activity. (async function () { // back-office shell: no marketing nav here; rehearsal notice lives in the top bar const $ = IAP.$; try { const c = await IAP.getConfig(); if (c.rehearsal && $('boRehearsal')) { $('boRehearsal').hidden = false; $('boRehearsal').innerHTML = 'Testnet rehearsal · ' + c.chainName; } if (c.rehearsal && $('faucetCard')) $('faucetCard').hidden = false; } catch (e) {} // if they arrived through a sponsor's link, show who they're joining under (async () => { try { const sp = await (await fetch('/api/sponsor')).json(); if (sp && sp.invited && sp.name && $('sponsorNote')) { $('sponsorNoteName').textContent = sp.name; $('sponsorNote').hidden = false; } } catch (e) {} })(); async function api(path, body) { const r = await (await fetch(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body || {}) })).json(); if (r.error) throw new Error(r.error); return r; } function nextMove(d) { if (!d.address) return 'Grab your link below and start sharing today. Then link your wallet (one free signature) so every payment can lock to you before your people start buying.'; if (!d.memberId) return 'Your wallet is linked. Switch on payouts above (one free transaction) and every purchase in your line pays you the moment it happens.'; if (d.buyerCount === 0) return 'Payouts are on and your link is live. Your next milestone: your first buyer of $20 or more. Every direct pays you 50 percent from their very first package.'; if (d.buyerCount === 1) return 'One qualifying buyer down, one to go. Your next $20+ buyer unlocks level 2: 20 percent of everything your people’s people buy.'; if (d.buyerCount < 5) return 'Level 2 is open. ' + (5 - d.buyerCount) + ' more qualifying buyer(s) unlock level 3 and the full three-level flow.'; return 'Fully qualified. Every level pays you, and you catch the pass-ups that under-qualified positions below you let slip. Keep sharing and keep your campaigns running.'; } // featured rotation strip on the overview let featItems = [], featIdx = 0, featTimer = null; function renderOneFeatured() { if (!featItems.length) return; const i = featItems[featIdx % featItems.length]; $('featStrip').innerHTML = '' + esc(i.title) + '' + (i.by ? '' + esc(i.by) + '' : '') + ''; } async function loadFeatured() { try { const r = await (await fetch('/api/featured')).json(); const card = $('featuredCard'); if (!card) return; if (!r.items || !r.items.length) { card.hidden = true; return; } card.hidden = false; featItems = r.items; featIdx = Math.floor(Math.random() * featItems.length); $('featSub').textContent = r.items.length + ' link' + (r.items.length === 1 ? '' : 's') + ' in rotation'; renderOneFeatured(); // show ONE at a time (true rotation), cycle if more than one clearInterval(featTimer); if (featItems.length > 1) featTimer = setInterval(() => { featIdx++; renderOneFeatured(); }, 6000); // self-sell: today's open slots + a CTA to feature your own link try { const st = await (await fetch('/api/featured/stats')).json(); const today = (st.occupancy || []).find(d => d.offset === 0); const cta = $('featCta'); if (cta) { cta.innerHTML = (today ? '' + today.open + ' of ' + today.cap + ' featured slots open today. ' : '') + 'Feature your link →'; const fb = $('featBuy'); if (fb) fb.addEventListener('click', e => { e.preventDefault(); setPane('campaigns'); const ct = $('cType'); if (ct) { ct.value = 'featured'; ct.dispatchEvent(new Event('change')); } }); } } catch (e) {} } catch (e) {} } // achievement badges: the milestone ladder as unlockable medals + share-to-image const BADGES = [ // ribbonY = vertical center of each badge's name ribbon (art differs per tier) { key: 'payouts', label: 'Spark', sub: 'payouts on', img: '/badges/badge-spark.jpg', ribbonY: 0.728 }, { key: 'firstBuyer', label: 'Surge', sub: 'first qualifying buyer', img: '/badges/badge-surge.jpg?v=2', ribbonY: 0.76 }, { key: 'level2', label: 'Circuit', sub: '2 qualifying buyers', img: '/badges/badge-circuit.jpg?v=2', ribbonY: 0.72 }, { key: 'level3', label: 'Nexus', sub: 'fully qualified', img: '/badges/badge-nexus.jpg?v=2', ribbonY: 0.73 } ]; let lastMilestones = []; function renderBadges(d) { const reached = d.milestonesReached || []; lastMilestones = reached; const strip = $('badgeStrip'); if (!strip) return; $('badgeCard').hidden = false; strip.innerHTML = BADGES.map(b => { const got = reached.includes(b.key); return '
' + '' + b.label + ' badge' + '
' + b.label + '
' + b.sub + '
' + (got ? ' ' : '
🔒 locked
') + '
'; }).join(''); strip.querySelectorAll('[data-badge]').forEach(btn => btn.addEventListener('click', () => shareBadge(btn.dataset.badge, d))); strip.querySelectorAll('[data-badgepost]').forEach(btn => btn.addEventListener('click', () => postBadge(btn.dataset.badgepost, d, btn, true))); fetch('/api/my/badge-posted').then(r => r.json()).then(r => { // the manual button is the admin's only (Marty, 2026-09-13) if (r.canPost) strip.querySelectorAll('[data-badgepost]').forEach(b => { b.hidden = false; }); (r.posted || []).forEach(k => { const b = strip.querySelector('[data-badgepost="' + k + '"]'); if (b) { b.textContent = 'Posted ✓'; b.disabled = true; } }); }).catch(() => {}); // celebrate anything newly granted this load, and post the branded badge to the team channels (Marty, 2026-09-13) 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'); d.milestonesGranted.forEach((g, i) => setTimeout(() => postBadge(g.key, d, strip.querySelector('[data-badgepost="' + g.key + '"]')), 800 + i * 1500)); } } // draw the badge with the member's name on the ribbon; cb(canvas) or cb(null) function composeBadge(key, d, cb) { const b = BADGES.find(x => x.key === key); if (!b) return cb(null); 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) { 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); } cb(c); }; img.onerror = () => cb(null); img.src = b.img; } async function postBadge(key, d, btn, manual) { if (btn && btn.disabled) return; if (btn) { btn.disabled = true; btn.textContent = 'Posting…'; } composeBadge(key, d, c => { if (!c) { if (btn) { btn.disabled = false; btn.textContent = 'Post to Telegram'; } return; } c.toBlob(async bl => { try { const r = await (await fetch('/api/my/badge-post?key=' + encodeURIComponent(key) + (manual ? '&manual=1' : ''), { method: 'POST', headers: { 'Content-Type': 'image/jpeg' }, body: bl })).json(); if (r.error) throw new Error(r.error); if (btn) { btn.textContent = 'Posted ✓'; btn.disabled = true; } if (!r.already) IAP.status('Your badge is posted in the team channels.', 'ok'); } catch (e) { if (btn) { btn.disabled = false; btn.textContent = 'Post to Telegram'; } IAP.status(e.message || 'Could not post the badge.', 'bad'); } }, 'image/jpeg', 0.86); }); } // Share: a picker (Marty, 2026-09-13). The composed badge is stored once so a public page at // /b// carries it as the preview image; every network gets that link. function shareBadge(key, d) { const b = BADGES.find(x => x.key === key); if (!b) return; if (!d.username) { IAP.status('Pick a username first; your share page carries it.', 'bad'); return; } IAP.status('Preparing your ' + b.label + ' badge…', 'ok'); composeBadge(key, d, c => { if (!c) { IAP.status('Could not load the badge art.', 'bad'); return; } c.toBlob(async bl => { let page = null; try { const r = await (await fetch('/api/my/badge-image?key=' + encodeURIComponent(key), { method: 'POST', headers: { 'Content-Type': 'image/jpeg' }, body: bl })).json(); if (r.error) throw new Error(r.error); page = r.page; } catch (e) { IAP.status(e.message || 'Could not prepare the share page.', 'bad'); return; } openShareSheet(b, d, bl, page); }, 'image/jpeg', 0.9); }); } function openShareSheet(b, d, blob, page) { const text = 'I just unlocked the ' + b.label + ' badge on LinkSpin (' + b.sub + '). Advertising that pays you back, on-chain, the same second a package sells.'; const enc = encodeURIComponent; const links = [ ['X', 'https://x.com/intent/tweet?text=' + enc(text) + '&url=' + enc(page)], ['Facebook', 'https://www.facebook.com/sharer/sharer.php?u=' + enc(page)], ['Telegram', 'https://t.me/share/url?url=' + enc(page) + '&text=' + enc(text)], ['WhatsApp', 'https://wa.me/?text=' + enc(text + ' ' + page)], ['LinkedIn', 'https://www.linkedin.com/sharing/share-offsite/?url=' + enc(page)] ]; const back = document.createElement('div'); back.className = 'modal-back'; back.style.zIndex = '200'; back.innerHTML = ''; document.body.appendChild(back); const close = () => back.remove(); back.addEventListener('click', e => { if (e.target === back) close(); }); back.querySelector('[data-sh="close"]').addEventListener('click', close); back.querySelector('[data-sh="copy"]').addEventListener('click', async () => { try { await navigator.clipboard.writeText(page); IAP.status('Link copied.', 'ok'); } catch (e) { IAP.status('Copy this: ' + page, 'ok'); } }); back.querySelector('[data-sh="save"]').addEventListener('click', () => { const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = 'linkspin-' + b.label.toLowerCase() + '-badge.jpg'; a.click(); setTimeout(() => URL.revokeObjectURL(a.href), 5000); IAP.status('Your ' + b.label + ' badge is saved.', 'ok'); }); // Massifly has no share intent: copy the post text + link, then open the feed composer (Marty, 2026-09-13) back.querySelector('[data-sh="massifly"]').addEventListener('click', async () => { try { await navigator.clipboard.writeText(text + ' ' + page); IAP.status('Post copied. Paste it into the Massifly composer; the badge preview appears from the link.', 'ok'); } catch (e) { IAP.status('Copy this into Massifly: ' + text + ' ' + page, 'ok'); } window.open('https://massifly.com/account', '_blank', 'noopener'); }); const nat = back.querySelector('[data-sh="native"]'); if (nat) nat.addEventListener('click', async () => { try { const file = new File([blob], 'linkspin-' + b.label.toLowerCase() + '-badge.jpg', { type: 'image/jpeg' }); if (navigator.canShare && navigator.canShare({ files: [file] })) await navigator.share({ files: [file], title: b.label + ' badge', text: text + ' ' + page }); else await navigator.share({ title: b.label + ' badge', text, url: page }); } catch (e) {} }); } // 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) => '
' + '' + (s.hit ? '✓' : i + 1) + '' + '' + s.label + '' + s.sub + '
').join(''); } // ── overview v3 charts: hand-rolled SVG/CSS, real data only ── const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); const CH = { mint: '#3b9dff', 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 => '
').join('') : '
'.repeat(8); if (xel) xel.innerHTML = data.map(d => '' + esc(d.label) + '').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 = ''; 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 += ''; off -= len; } out += '' + esc(center.big) + '' + '' + esc(center.small) + ''; el.innerHTML = out; } function legend(el, rows) { if (el) el.innerHTML = rows.map(r => '
' + esc(r.label) + ' ' + esc(r.v) + '
').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) { // one rule: a balance is what is NOT committed to a live campaign. Budgets are // set aside when a campaign starts and spend down inside Campaigns, so these // numbers only move when a campaign is created, topped up or paused const purchased = d.credits || 0, earned = d.earnedCredits != null ? d.earnedCredits : (st.earnedAvailable != null ? st.earnedAvailable : (st.earned || 0)), inC = d.inCampaigns || 0; $('dbCredits').textContent = (purchased + earned).toLocaleString(); // the Wallet tab card showed on-chain credits only, so promo/earned credits looked like 0 there (Jim, 2026-09-13) if ($('creditLine')) $('creditLine').innerHTML = (purchased + earned).toLocaleString() + ' ' + purchased.toLocaleString() + ' purchased · ' + earned.toLocaleString() + ' earned'; $('dbCreditsSub').textContent = purchased.toLocaleString() + ' purchased' + (d.creditedCredits ? ' (' + d.creditedCredits.toLocaleString() + ' credited to you)' : '') + ' · ' + earned.toLocaleString() + ' earned' + (inC ? ' · ' + inC.toLocaleString() + ' in campaigns' : ''); donut($('chDonut'), [{ v: purchased, color: CH.mint }, { v: earned, color: CH.cyan }, { v: inC, color: CH.amber }], { big: (purchased + earned).toLocaleString(), small: 'available' }); legend($('chDonutLegend'), [ { color: CH.mint, label: 'Purchased, available', v: purchased.toLocaleString() }, { color: CH.cyan, label: 'Earned, available', v: earned.toLocaleString() }, { color: CH.amber, label: 'In live campaigns', v: inC.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); } // community toasts (Marty, 2026-09-12: keep the dashboard feeling alive): everyone else's joins, // purchases, payouts, qualifications, tank arrivals and adoptions, in a second, quieter toast so // they never replace a personal one. At most one every 4 s; a burst shows the latest. let liveT = 0, liveQ = null; function communityToast(msg) { const now = Date.now(); if (now - liveT < 4000) { liveQ = msg; if (!communityToast._q) communityToast._q = setTimeout(() => { communityToast._q = null; const m = liveQ; liveQ = null; if (m) communityToast(m); }, 4200 - (now - liveT)); return; } liveT = now; let el = document.getElementById('liveToast'); if (!el) { el = document.createElement('div'); el.id = 'liveToast'; el.setAttribute('role', 'status'); el.style.cssText = 'position:fixed;left:16px;bottom:16px;z-index:90;max-width:min(360px,calc(100vw - 32px));background:var(--panel-solid);border:1px solid var(--line-strong);border-left:3px solid var(--mint);border-radius:12px;padding:10px 14px;font-size:14px;box-shadow:0 10px 30px rgba(0,0,0,.4);transition:opacity .3s'; document.body.appendChild(el); } el.textContent = msg; el.hidden = false; el.style.opacity = '1'; clearTimeout(communityToast._t); communityToast._t = setTimeout(() => { el.style.opacity = '0'; setTimeout(() => { el.hidden = true; }, 350); }, 6000); } function handleLiveEvent(ev) { if (!ev || !ev.type) return; const nm = id => (ev.names && ev.names[id]) ? '@' + ev.names[id] : 'member #' + id; // site-wide activity (not about me): joins, tank, adoptions, purchases, payouts, qualifications if (ev.type === 'Joined') { communityToast('👋 ' + ev.name + ' just joined' + (ev.tank ? ' and is waiting for a sponsor in the holding tank' : '')); liveRefresh(); return; } if (ev.type === 'Adopted') { communityToast('🤝 ' + ev.sponsor + ' picked up ' + ev.member + ' from the holding tank'); liveRefresh(); return; } if (ev.type === 'Contest') { communityToast('🏆 ' + ev.winner + ' won the ' + (ev.kind === 'week' ? 'weekly' : 'monthly') + ' referral contest'); return; } if (ev.type === 'Badge') { communityToast('🏆 ' + ev.member + ' unlocked ' + ev.label); return; } if (ev.type === 'Released') { communityToast('🪣 ' + ev.sponsor + ' returned ' + ev.member + ' to the holding tank'); liveRefresh(); return; } const mine = MYID && (ev.recipientId === MYID || ev.toId === MYID || ev.sponsorId === MYID || ev.skippedId === MYID || ev.buyerId === MYID); if (!mine) { if (ev.type === 'Purchase' && ev.buyerId) communityToast('🧾 ' + nm(ev.buyerId) + ' just bought a $' + Math.round((ev.priceCents || 0) / 100) + ' package'); else if (ev.type === 'TierPaid' && ev.recipientId) communityToast('💸 ' + nm(ev.recipientId) + ' just got paid ' + IAP.fmtPol(ev.amountWei) + ' POL'); else if (ev.type === 'BuyerCounted' && ev.sponsorId) communityToast('🎯 ' + nm(ev.sponsorId) + ' now has ' + ev.newCount + ' qualifying buyer' + (ev.newCount === 1 ? '' : 's')); else if (ev.type === 'MemberActivated' && ev.id) communityToast('⚡ ' + nm(ev.id) + ' switched on payouts'); return; } if (!MYID) 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 = '

Training materials are being added. Check back soon.

'; 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 => '').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 = '

' + esc(it.group) + '

'; } 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 ? '' : ''; const links = []; if (it.videoUrl && !isVid) links.push('Watch'); if (it.docUrl) links.push('Open material'); return head + '

' + esc(it.title || 'Lesson') + '

' + (it.desc ? '

' + esc(it.desc) + '

' : '') + (media ? '

' + media + '

' : '') + (links.length ? '

' + links.join(' ') + '

' : '') + '
'; }).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) => '' + esc(String(m.name || 'M').replace(/^@/, '').slice(0, m.own ? 20 : 14)) + (m.buyers ? '' + m.buyers + '' : '') + ''; const rowFor = lvl => { const L = levels.find(x => x.level === lvl); const members = L ? L.members : []; // gold = the contract counted this member as one of your qualifying buyers (not "the first N chips") let html = members.map(m => chip(m, !!(m.qualified || m.bought))).join(''); // gold on every level = made their $20+ buy if (lvl <= 2 && members.length < (lvl === 1 ? 2 : 4)) html += '+ open'; if (!html) html = '+ open'; return '
L' + lvl + '
' + html + '
'; }; el.innerHTML = '
YOU
' + rowFor(1) + rowFor(2) + rowFor(3); } catch (e) {} } // "What's new" card: latest notes + what is being built; a dot marks notes newer than the member's last look (Marty, 2026-09-14) let newsLoaded = false; async function loadNews() { if (newsLoaded || !$('newsCard')) return; newsLoaded = true; try { const r = await (await fetch('/api/releases')).json(); if (!r.notes.length && !r.roadmap.length) return; let seen = ''; try { seen = localStorage.getItem('iap.news.seen') || ''; } catch (e) {} const esc = t => String(t == null ? '' : t).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); const notes = r.notes.slice(0, 3).map(n => '

' + (n.date > seen ? '● ' : '') + '' + esc(n.title) + ' ' + esc(n.date) + '

').join(''); const rm = r.roadmap.filter(x => x.status === 'building').slice(0, 2).map(x => '

Building: ' + esc(x.title) + (x.eta ? ' (' + esc(x.eta) + ')' : '') + '

').join(''); $('newsList').innerHTML = notes + rm + '

All release notes and the roadmap →

'; $('newsCard').hidden = false; $('newsCard').addEventListener('click', () => { try { localStorage.setItem('iap.news.seen', r.latest || ''); } catch (e) {} $('newsList').querySelectorAll('span[style*="mint"]').forEach(s => { if (s.textContent === '●') s.remove(); }); }, { once: true }); } catch (e) {} } // Leaderboard card: top 5 this week + your own rank + the prize (Marty, 2026-09-14) async function loadLeaderboard() { if (!$('lbCard')) return; try { const r = await (await fetch('/api/leaderboard?period=week')).json(); const esc = t => String(t == null ? '' : t).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); let h = r.prize ? '

Prize this week: ' + esc(r.prize) + '

' : ''; h += r.top.length ? '' + r.top.slice(0, 5).map(x => '').join('') + '
' + x.rank + '' + esc(x.name) + '' + x.sales + ' sold · $' + x.usd + '
' : '

No packages sold yet this week. First sale takes the top spot.

'; h += r.me ? '

You: #' + r.me.rank + ' · ' + r.me.sales + ' sold · $' + r.me.usd + '

' : '

You: no sales yet this week. Sales are $20+ packages bought by people you sponsor.

'; h += '

Full leaderboard: week, month, all time →

'; $('lbList').innerHTML = h; $('lbCard').hidden = false; } catch (e) {} } // Getting-started stepper: username -> wallet -> payouts -> first package. The current step gets one big // button that opens the right tab and pulses the control (Jim: "it wasn't obvious", 2026-09-14) function renderSteps(d) { const card = $('gsCard'); if (!card) return; const hasUser = !!d.username, hasWallet = !!d.address, hasPayouts = !!d.memberId, hasPackage = !!(d.memberId && (d.credits || 0) > 0); const steps = [ { k: 'user', t: 'Pick a username', done: hasUser }, { k: 'wallet', t: 'Link your wallet', done: hasWallet }, { k: 'payouts', t: 'Switch on payouts', done: hasPayouts }, { k: 'package', t: 'Your first package', done: hasPackage } ]; if (steps.every(x => x.done)) { card.hidden = true; return; } const cur = steps.find(x => !x.done); let collapsed = false; try { collapsed = localStorage.getItem('iap.gs.collapsed') === '1'; } catch (e) {} $('gsSub').innerHTML = steps.filter(x => x.done).length + ' of 4 done · ' + (collapsed ? 'show' : 'hide') + ''; $('gsToggle').addEventListener('click', e => { e.preventDefault(); try { localStorage.setItem('iap.gs.collapsed', collapsed ? '0' : '1'); } catch (er) {} renderSteps(d); }); $('gsSteps').hidden = collapsed; $('gsNow').hidden = collapsed; $('gsSteps').innerHTML = steps.map((x, i) => '
' + (x.done ? '✓' : i + 1) + '' + x.t + '
').join(''); const copy = { user: ['Pick your username', 'It becomes your invite link and your public page, and it is permanent.', 'Choose a username', () => showOnboard(true)], wallet: ['Link your wallet', 'Your payouts land in a wallet you control, so the site needs to know which one is yours. One free signature, no purchase. MetaMask recommended; Phantom users switch Polygon on first. Never held crypto? The wallet guide walks you through it.', 'Link my wallet', () => jumpTo('wallet', 'linkBtn')], payouts: ['Switch on payouts', 'One small transaction registers your wallet with the contract so commissions can reach it. Buying any package does this automatically.', 'Activate payouts', () => jumpTo('wallet', 'activateBtn')], package: ['Your first package', 'From $5. The $20 Activation package is the one that counts you as a qualifying buyer for your sponsor and unlocks adopting from the holding tank.', 'See packages', () => jumpTo('buy', null)] }[cur.k]; $('gsNow').innerHTML = '

Next: ' + copy[0] + '' + copy[1] + (cur.k === 'wallet' ? ' Wallet guide' : '') + '

'; $('gsGo').addEventListener('click', copy[3]); card.hidden = false; } function jumpTo(pane, id) { setPane(pane); setTimeout(() => { const el = id && $(id); if (!el) return; el.scrollIntoView({ behavior: 'smooth', block: 'center' }); el.classList.remove('pulse'); void el.offsetWidth; el.classList.add('pulse'); try { el.focus({ preventScroll: true }); } catch (e) {} }, 250); } async function loadDashboard() { try { loadNews(); loadLeaderboard(); const d = await (await fetch('/api/my/dashboard')).json(); if (d.error) return; chatSync(d); try { renderSteps(d); } catch (e) {} 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 earnedAv = d.earnedCredits || 0, inCamp = d.inCampaigns || 0; $('dbCredits').textContent = ((d.credits || 0) + earnedAv).toLocaleString(); $('dbCreditsSub').textContent = (d.credits || 0).toLocaleString() + ' purchased' + (d.creditedCredits ? ' (' + d.creditedCredits.toLocaleString() + ' credited to you)' : '') + ' · ' + earnedAv.toLocaleString() + ' earned' + (inCamp ? ' · ' + inCamp.toLocaleString() + ' in campaigns' : ''); $('dbEarned').textContent = IAP.fmtPol(d.earnedWei || '0'); $('dbBuyers').textContent = d.buyerCount || 0; if (d.buyerCountPositions) { const sub = $('dbBuyersSub'); if (sub) sub.textContent = '+' + d.buyerCountPositions + ' on your linked positions (count toward badges; levels pay on your main position)'; } $('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 => '' + String(r.name || r.email || '').replace(/[&<>]/g, '') + '' + '' + new Date(r.joined).toLocaleDateString() + '' + '' + r.status + '').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 = '🤝 ' + esc(r.name || r.email || 'A new member') + ' joined your line' + new Date(r.joined).toLocaleDateString() + ''; 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); // founding-week readiness: shown until every item is done (or the launch moment is a week past) try { const cfg = await IAP.getConfig(); const items = IAP.launchChecks(d); const done = items.filter(i => i.done).length; const at = cfg.launchAt ? new Date(cfg.launchAt).getTime() : 0; const show = done < items.length && !(at && Date.now() > at + 7 * 86400000); const lm = $('launchMark'); if (lm) { lm.hidden = !show; lm.innerHTML = show ? 'Launch ready: ' + done + ' of ' + items.length + '. ' + (at && Date.now() < at ? 'Doors open ' + new Date(at).toLocaleString([], { weekday: 'short', hour: 'numeric', minute: '2-digit' }) + '. ' : '') + 'Open the founding-week checklist' : ''; } } catch (e) {} // people waiting for a sponsor in the holding tank (Marty, 2026-09-12): every Overview sees it try { const tw = d.tankWaiting, tn = $('tankNotice'); if (tn) { tn.hidden = !(tw && tw.count); if (tw && tw.count) tn.innerHTML = '' + tw.count + (tw.count === 1 ? ' person is' : ' people are') + ' waiting for a sponsor in the holding tank \u00b7 ' + tw.names.map(esc).join(', ') + (tw.count > tw.names.length ? ' and more' : '') + '. Adopt them from My line' + (tw.eligible ? '.' : ' (you need your own $20 package first).'); } } catch (e) {} 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', 'pipeline', 'rotator', 'buy', 'campaigns', 'earn', 'earnings', 'promo', 'training', 'wallet', 'profile']; const TITLES = { overview: 'Overview', line: 'My line', pipeline: 'Pipeline', rotator: 'Rotator', 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]; // member ads: a fresh text ad in the strip under the title, and a banner at the foot of the pane IAP.adSlot('text', 'adStripTop'); if ($('adSlotPane-' + name)) IAP.adSlot('banner', 'adSlotPane-' + name); if (name === 'earn') setEarnSub(earnSub); // refresh whichever sub-tab is active else if (vidState.token) stopVideo(); 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(); if (name === 'pipeline') loadPipeline(); if (name === 'rotator') loadRotator(); 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() { // while /api/me answers, show a spinner instead of flashing the sign-in card at a signed-in member let me = null; try { me = await IAP.refreshNavWallet(); } catch (e) { me = null; } if ($('bootSpin')) $('bootSpin').hidden = true; // 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 // REQUIRED first step (Marty, 2026-09-12): no username, nothing else. The modal cannot be // skipped or dismissed; it resolves only when a username is saved, then the area renders. if (!me.username) { if (!render.gating) { render.gating = true; showOnboard(true).then(() => { render.gating = false; render(); }); } return; } // arrived from an invite page: run the welcome tour and login ad once if (/[?&]welcome=1/.test(location.search) && !render.welcomed) { render.welcomed = true; history.replaceState(null, '', '/my' + (location.hash || '')); (async () => { try { if (!(await showGauntlet())) await showLoginAd(); } catch (e) {} await render(); })(); } setPane(location.hash.slice(1) || 'overview'); $('campGate').hidden = !!me.memberId; $('earnGate').hidden = !!me.memberId; if (me.username && !me.address) { let shown = ''; try { shown = sessionStorage.getItem('iap.gs.walletHint'); sessionStorage.setItem('iap.gs.walletHint', '1'); } catch (e) {} if (shown !== '1') setTimeout(() => IAP.status('Next step when you are ready: link your wallet so your payouts have somewhere to land. The Getting started card at the top shows how.', 'ok'), 1200); } loadDashboard(); const who = []; if (me.email) who.push(me.email); // members kept asking whether the wallet was really connected (Marty, 2026-09-12): say it, with a green check if (me.address) who.push('Wallet connected ' + me.address.slice(0, 8) + '…' + me.address.slice(-6) + ''); else who.push('No wallet connected yet (link one below)'); if (me.memberId) who.push('on-chain member #' + me.memberId + '' + (me.onchainSponsorId ? ', sponsored by #' + me.onchainSponsorId : '')); else if (me.sponsorId) who.push('invited by member #' + me.sponsorId); $('posLine').innerHTML = who.join('
'); $('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') + '
Wallet: ' + (me.address ? '' + me.address.slice(0, 10) + '…' + me.address.slice(-6) + '' : 'not linked yet') + '
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 = '' + bc + ' qualifying buyer(s) referred
' + (bc >= 5 ? 'Level 3 unlocked: full three-level earnings' : bc >= 2 ? 'Level 2 unlocked · ' + (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) ' + 'before your people start buying: 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 = '
' + empty + '
'; 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; if ($('cGeoHint') && r.tiers) $('cGeoHint').textContent = 'All three ticked = everyone. Tier 1: ' + r.tiers.t1.join(', ') + '. Tier 2: ' + r.tiers.t2.join(', ') + '. Tier 3: every other country. Geo applies to delivery on this site; a narrowed banner or text ad is kept off the worldwide partner network. ' + (r.geoReady ? '' : 'Country data is still loading, so narrowed campaigns pause until it is ready. ') + 'IP geolocation by DB-IP.'; // 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 => '').join(''); applyType(); // the default type is banner: show its size + image rows now that the sizes exist soloHint(); if ($('spendBanner')) { $('spendBanner').hidden = false; $('spendBig').textContent = r.availableCredits.toLocaleString(); $('spendSub').textContent = '= $' + (r.availableCredits / 100).toLocaleString(undefined, { maximumFractionDigits: 2 }) + ' of ad delivery · ' + r.purchasedCredits.toLocaleString() + ' purchased' + (r.creditedCredits ? ' (' + r.creditedCredits.toLocaleString() + ' of it credited to you, spends on anything)' : '') + ' + ' + (r.earnedCredits || 0).toLocaleString() + ' earned' + (r.positionCount > 1 ? ' · pooled across ' + r.positionCount + ' positions (largest single position ' + (r.largestPosition || 0).toLocaleString() + ')' : ''); if ($('spendNote')) $('spendNote').textContent = r.inCampaigns ? 'Not counting ' + r.inCampaigns.toLocaleString() + ' credits already set aside for your live campaigns. That budget spends down inside each campaign below. This number only moves when you start, top up or pause a campaign.' : 'This is what is not committed to a campaign. When you start one, its budget moves out of here and spends down inside the campaign.'; $('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 => '' + x[0] + ' ' + x[1] + '').join(''); } $('rateLine').textContent = 'Available to spend: ' + r.availableCredits.toLocaleString() + (r.earnedCredits ? ' (' + r.purchasedCredits.toLocaleString() + ' purchased + ' + r.earnedCredits + ' earned)' : '') + (r.inCampaigns ? ' · ' + r.inCampaigns.toLocaleString() + ' set aside in live campaigns' : '') + ' 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 = '

No campaigns yet. Launch your first below.

'; return; } const tbl = document.createElement('div'); tbl.className = 'tablewrap'; tbl.innerHTML = '' + '' + r.campaigns.map(c => '' + '' + '' + '' + '' + '' + '' + '').join('') + '
NameTypeViews hereNetwork viewsClicksSpentBudgetStatus
' + c.name + '' + hourBars(r.hours && r.hours[c.id]) + geoLine(r.geo && r.geo[c.id]) + '' + c.type + (c.type === 'banner' && c.width ? ' ' + c.width + '×' + c.height + '' : '') + (c.dailyCap ? ' cap ' + c.dailyCap + '/day' : '') + (c.geo ? ' tier ' + esc(c.geo.replace(/,/g, '+')) + '' : '') + schedChips(c) + '' + c.imps.toLocaleString() + '' + (['banner', 'text'].includes(c.type) ? (c.impsNas || 0).toLocaleString() : 'n/a') + '' + c.clicks + (r.clickSources && r.clickSources[c.id] ? '
' + Object.entries(r.clickSources[c.id]).sort((a, b) => b[1] - a[1]).map(([k, v]) => '' + esc(k) + ' ' + v + '').join('') + (c.impsNas ? ' · network: see Network views' : '') + '
' : '') + '
' + c.spent + '' + c.budget + '' + (c.status === 'out' ? 'budget spent' : c.status === 'done' ? 'ended' : c.scheduled ? 'scheduled' : c.status) + '' + (c.status === 'active' ? '' : c.status === 'paused' ? '' : '') + ' ' + '
'; 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 = await IAP.ask({ title: 'Add credits', text: 'How many credits to add to this campaign? More credits buy more views.', type: 'number', placeholder: 'e.g. 100', ok: 'Add credits' }); 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', async () => { const sel = window.getSelection(); const range = sel && sel.rangeCount ? sel.getRangeAt(0).cloneRange() : null; // the dialog steals the selection const url = await IAP.ask({ title: 'Insert link', text: 'Link URL (https://…)', placeholder: 'https://', ok: 'Insert' }); if (!url) return; $('cSoloEd').focus(); if (range) { sel.removeAllRanges(); sel.addRange(range); } document.execCommand('createLink', false, url); }); // inline media: upload, then drop the element at the cursor (BV-style) let mediaMode = 'image'; function insertHtmlAtCursor(html) { const ed = $('cSoloEd'); ed.focus(); if (!document.execCommand('insertHTML', false, html)) ed.insertAdjacentHTML('beforeend', html); } $('edImgBtn').addEventListener('click', () => { mediaMode = 'image'; $('cSoloFile').accept = 'image/png,image/jpeg,image/webp,image/gif'; $('cSoloFile').click(); }); $('edVidBtn').addEventListener('click', () => { mediaMode = 'video'; $('cSoloFile').accept = 'video/mp4,video/webm'; $('cSoloFile').click(); }); $('cSoloFile').addEventListener('change', async () => { const f = $('cSoloFile').files[0]; if (!f) return; $('edMediaInfo').textContent = 'Uploading ' + f.name + '…'; try { const r = await (await fetch('/api/my/upload', { method: 'POST', headers: { 'Content-Type': f.type }, body: f })).json(); if (r.error) { $('edMediaInfo').textContent = r.error; $('cSoloFile').value = ''; return; } insertHtmlAtCursor(r.type === 'video' ? '


' : '


'); $('edMediaInfo').textContent = f.name + ' inserted'; } catch (e) { $('edMediaInfo').textContent = 'Upload failed. Try again.'; } $('cSoloFile').value = ''; }); // raw-text toggle: swap the WYSIWYG surface for the underlying HTML and back $('edRawBtn').addEventListener('click', () => { const ed = $('cSoloEd'), raw = $('cSoloRaw'); if (raw.hidden) { raw.value = ed.innerHTML; raw.hidden = false; ed.hidden = true; $('edRawBtn').textContent = 'Visual'; } else { ed.innerHTML = raw.value; ed.hidden = false; raw.hidden = true; $('edRawBtn').textContent = 'Raw text'; } }); const WHERE = { banner: 'Runs in the ad viewer, on the home page, the live ledger, every Overview, the sidebar tile, and out in the Network Ad Space rotation across the wider network.', text: 'Runs in the ad viewer, the live ledger text slot, and out in the Network Ad Space rotation.', login: 'Full screen for every member who signs in, ten seconds, once a day per member. Opens in a fresh tab, so any working page qualifies. Login ads spend purchased credits only.', solo: 'Delivered into member inboxes under Earn credits. Each read is timed and rewarded, so it gets opened.', video: 'Plays in Watch videos and the Shorts feed under Earn credits. You pay only for completed watches.', featured: 'Your headline and link in the Featured strip on every member Overview for the days you book.', visits: 'A distinct member opens your site in a new tab, stays eight seconds and passes a check. Nobody counts twice.' }; const whereHint = () => { const el = $('cWhere'); if (el) el.textContent = WHERE[$('cType').value] || ''; }; whereHint(); // runs on every type change AND once at load, so the default type (banner) shows its size + image rows immediately function applyType() { // hoisted: loadCampaigns may run before this line is reached 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'; if ($('cSchedRow')) { $('cSchedRow').hidden = t === 'featured'; $('cStartLbl').textContent = t === 'solo' ? 'Send from (optional)' : 'Start (optional)'; $('cSchedHint').textContent = t === 'solo' ? 'Deliveries to member inboxes begin at the time you pick, so you can land when people are reading. Leave empty to start now.' : 'Leave both empty to start now and run until the budget is spent. Times are your local time. Anything left when a campaign ends goes back to Available.'; } // fixed-cost types derive their spend (featured = day slots, visits = flat pack), // so hide the free-form Budget field for them to avoid confusion $('cBudgetRow').hidden = (t === 'featured' || t === 'visits'); if ($('cCapRow')) $('cCapRow').hidden = !(t === 'banner' || t === 'text'); // solo ads have a real floor (5cr x 10 deliveries): default to 50 so 10 isn't rejected if (t === 'solo' && (!$('cBudget').value || Number($('cBudget').value) < 50)) $('cBudget').value = (lastRates && lastRates.soloCostPerRecipient ? lastRates.soloCostPerRecipient : 5) * 10; if (t === 'visits') visitHint(); $('cTitle').placeholder = t === 'solo' ? 'Subject line (max 80)' : 'Headline (max 60)'; soloHint(); if (t === 'video') videoHint(); if (t === 'featured') featHint(); } $('cType').addEventListener('change', applyType); let featStartDay = 0; // selected start-day offset (0 = today) async function featHint() { if (!lastRates || !lastRates.featuredDurations) return; if ($('cFeatDays') && !$('cFeatDays').options.length) $('cFeatDays').innerHTML = lastRates.featuredDurations.map(dys => '').join(''); let s = null; try { s = await (await fetch('/api/featured/stats')).json(); } catch (e) {} if (s && s.occupancy) { const grid = $('cFeatDaysGrid'); grid.innerHTML = s.occupancy.map(d => { const label = d.offset === 0 ? 'Today' : d.offset === 1 ? 'Tomorrow' : new Date(d.day + 'T00:00:00Z').toLocaleDateString(undefined, { weekday: 'short', month: 'numeric', day: 'numeric' }); const full = d.open <= 0; return '
' + '
' + label + '
' + d.count + '/' + d.cap + (full ? ' full' : ' left ' + d.open) + '
'; }).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 => '').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 = ''; 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), dailyCap: ($('cDailyCap') && (t === 'banner' || t === 'text')) ? Number($('cDailyCap').value) || 0 : 0, geo: [...document.querySelectorAll('.geoTier:checked')].map(x => x.value).join(','), startsAt: ($('cStartAt') && $('cStartAt').value && !isFeat) ? new Date($('cStartAt').value).getTime() : 0, endsAt: ($('cEndAt') && $('cEndAt').value && !isFeat) ? new Date($('cEndAt').value).getTime() : 0, 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) }); const schedStart = ($('cStartAt') && $('cStartAt').value && !isFeat) ? new Date($('cStartAt').value) : null; IAP.status(schedStart && schedStart.getTime() > Date.now() ? 'Campaign saved. It starts serving ' + schedStart.toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' }) + '.' : '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', 'cDailyCap', 'cStartAt', 'cEndAt'] .forEach(id => { if ($(id)) $(id).value = ''; }); document.querySelectorAll('.geoTier').forEach(x => { x.checked = true; }); $('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 = 'That\'s today\'s visits. Come back tomorrow.'; } 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 = '' + (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.') + ''; return; } visState.token = r.token; visState.id = r.ad.id; visState.dwell = r.status.dwell || 8; $('vsBox').innerHTML = '' + esc(r.ad.title || 'Member site') + '
Open the site and stay ' + visState.dwell + 's.'; 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 = 'That is today\'s video set. Come back tomorrow.'; $('vidStartBtn').hidden = true; return; } $('vidStartBtn').hidden = false; } catch (e) {} } // leaving the player (other sub-tab, other pane, page hidden) stops the clip: nothing plays or earns in the background (Marty, 2026-09-13) function stopVideo() { const p = $('vidPlayer'); if (!p) return; const was = !!vidState.token && !vidState.done; try { p.pause(); p.ontimeupdate = null; p.onseeking = null; p.removeAttribute('src'); p.load(); } catch (e) {} vidState.token = null; vidState.done = false; vidState.credited = false; vidState.maxSeen = 0; if ($('vidWrap')) $('vidWrap').hidden = true; if ($('vidBox')) { $('vidBox').hidden = false; if (was) $('vidBox').innerHTML = 'Video stopped when you left the tab. Tap Load a video to start a fresh one.'; } if ($('vidStartBtn')) { $('vidStartBtn').disabled = false; $('vidStartBtn').textContent = 'Load a video'; } } document.addEventListener('visibilitychange', () => { if (document.hidden && vidState.token && !vidState.done) stopVideo(); }); async function loadVideoAd() { let r = null; try { r = await (await fetch('/api/my/videos?orientation=landscape')).json(); } catch (e) {} if (!r || !r.ad) { // same done screen as Watch ads when the day's videos are finished (Marty, 2026-09-13) const capHit = !!(r && r.status && r.status.left <= 0), allWatched = !!(r && r.allWatched); $('vidBox').innerHTML = (capHit || allWatched) ? '
✓Videos done for today' + (capHit ? 'That is today’s video set (' + r.status.count + ' watched). Fresh videos tomorrow.' : 'You have watched every live video for today; each one pays once a day. New ones appear as members launch video campaigns.') + '
' : 'No member videos are live right now. Check back when a campaign is running.'; if ($('vidStartBtn')) $('vidStartBtn').hidden = capHit || allWatched; $('vidBox').hidden = false; $('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 + (/no longer open|stale/.test(r.error) ? ' Loading a fresh one.' : ''); if (/no longer open|stale/.test(r.error)) setTimeout(() => loadVideoAd(), 1200); } 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.'; if (r.status) $('vidProgress').textContent = 'today: ' + (r.status.count || 0) + ' / ' + r.status.cap + ' 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 ── // ── pay it forward: send POL from the sponsor's own wallet to a downline's linked address ── async function pif(email, name, address) { let suggest = 25; try { const { products } = await (await fetch('/api/catalog')).json(); const p20 = (products || []).find(p => p.priceCents === 2000); if (p20 && p20.costWei) suggest = Math.ceil(Number(p20.costWei) / 1e18) + 3; } catch (e) {} const amt = await IAP.ask({ title: 'Pay it forward', text: 'Send POL from your wallet to ' + name + ' (' + address.slice(0, 6) + '…' + address.slice(-4) + ') for their first package.\nSuggested: the $20 package plus fees. Amount in POL:', type: 'number', value: String(suggest), ok: 'Send POL' }); if (amt === null) return; const pol = Number(amt); if (!(pol > 0)) { IAP.status('Enter an amount in POL.', 'bad'); return; } try { IAP.status('Confirm the transfer in your wallet…', 'ok'); const wei = (BigInt(Math.round(pol * 1e6)) * 10n ** 12n).toString(); const hash = await IAPWallet.sendPol(address, wei); await api('/api/my/gift', { email, tx: hash, pol }); IAP.status('Sent ' + pol + ' POL to ' + name + '. They have been told, with the proof link.', 'ok'); playSound && playSound('chaching'); } catch (e) { IAP.status('Transfer not sent: ' + ((e && e.message) || e), 'bad'); } } // ── holding tank: waiting members, adopt, my open adoptions ── const ago = ts => { if (!ts) return 'never'; const d = Math.floor((Date.now() - ts) / 86400000); return d === 0 ? 'today' : d === 1 ? 'yesterday' : d + ' days ago'; }; async function loadTank() { const el = $('tankList'); if (!el) return; try { const r = await (await fetch('/api/my/tank')).json(); if (r.error) { el.innerHTML = ''; return; } $('tankCap').textContent = r.cap; $('tankTtl').textContent = r.ttlDays; $('tankSub').textContent = r.waiting.length ? r.waiting.length + ' waiting' : 'nobody waiting right now'; const why = $('tankWhy'); why.hidden = r.eligible; why.innerHTML = r.eligible ? '' : 'not yet ' + esc(r.reason); $('tankMine').innerHTML = r.mine.length ? '

Your open adoptions

' + r.mine.map(m => '
' + esc(m.name) + '' + '' + (m.bought ? 'bought' : m.wallet ? 'wallet linked' : 'free, no wallet yet') + ' · last seen ' + ago(m.lastSeen) + '' + '' + Math.max(0, Math.ceil((m.expires - Date.now()) / 86400000)) + ' days left' + '' + (m.address && !m.bought ? ' ' : '') + '
').join('') : ''; $('tankMine').querySelectorAll('[data-tchat]').forEach(b => b.addEventListener('click', () => openConvo(b.dataset.tchat, b.dataset.tname))); $('tankMine').querySelectorAll('[data-pif]').forEach(b => b.addEventListener('click', () => pif(b.dataset.pif, b.dataset.pname, b.dataset.paddr))); if (!r.waiting.length) { el.innerHTML = '

The tank is empty. Anyone who joins from the public site without a sponsor lands here.

'; return; } el.innerHTML = r.waiting.map(w => '
' + esc(w.name) + '' + 'joined ' + ago(w.joined) + '' + 'last sign-in: ' + ago(w.lastSeen) + '' + (r.eligible ? '' : '') + '
').join(''); el.querySelectorAll('[data-adopt]').forEach(b => b.addEventListener('click', async () => { const note = await IAP.ask({ title: 'Adopt ' + b.dataset.aname, text: 'Your first message to ' + b.dataset.aname + ' (sent as a chat and an email):', type: 'textarea', ok: 'Adopt and send', value: 'Hi, I picked you up from the LinkSpin holding tank so you have a sponsor who will actually help. Reply here and I will walk you through the first three steps.' }); if (note === null) return; try { const rr = await api('/api/my/tank/adopt', { who: b.dataset.adopt, note }); IAP.status('You are now the sponsor for ' + rr.name + '. Chat and email sent.', 'ok'); loadTank(); loadCoach(); } catch (e) { IAP.status(e.message, 'bad'); } })); } catch (e) {} } async function loadCoach() { loadTank(); 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 ? '' + d.length + ' direct' + (d.length === 1 ? '' : 's') + ' · ' + r.stalled + ' quiet for 3+ days' + (r.stalled ? ' · start at the top' : '') : ''; if (!d.length) { el.innerHTML = '

No directs yet. When someone joins through your link they show up here with their next step.

'; return; } el.innerHTML = d.map(x => '
' + esc(x.name) + (x.stalled ? ' quiet ' + x.quietDays + 'd' : '') + (x.rescue && x.rescue.unreached ? ' unreached · tank in ' + x.rescue.rescueInDays + 'd' : '') + (x.bound === false ? ' not bound to you on-chain' : '') + '' + '' + esc(x.label) + ' → ' + esc(x.next) + '' + 'rung ' + x.rung + '/6' + '' + (x.buyerCount ? x.buyerCount + ' buyer' + (x.buyerCount === 1 ? '' : 's') : '') + '' + '' + (x.free && x.address ? ' ' : '') + (x.free && x.rescue ? ' ' : '') + (x.free ? ' ' : '') + '
').join(''); el.querySelectorAll('[data-contacted]').forEach(b => b.addEventListener('click', async () => { try { await api('/api/my/tank/contacted', { email: b.dataset.contacted }); IAP.status('Marked: you have contacted ' + b.dataset.cname + '.', 'ok'); loadCoach(); } catch (e) { IAP.status(e.message, 'bad'); } })); el.querySelectorAll('[data-pif]').forEach(b => b.addEventListener('click', () => pif(b.dataset.pif, b.dataset.pname, b.dataset.paddr))); 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(); } })); el.querySelectorAll('[data-release]').forEach(b => b.addEventListener('click', async () => { if (!(await IAP.confirmBox('Release ' + b.dataset.rname + ' to the holding tank? You stop being their sponsor and another member can adopt them.', { title: 'Release to the tank', ok: 'Release' }))) return; try { await api('/api/my/tank/release', { email: b.dataset.release }); IAP.status(b.dataset.rname + ' is in the holding tank.', 'ok'); loadCoach(); } catch (e) { IAP.status(e.message, 'bad'); } })); } catch (e) {} } // schedule chips on the campaign table (local time) + by-hour view bars const fmtWhen = ms => new Date(ms).toLocaleString([], { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }); function schedChips(c) { const now = Date.now(); let s = ''; if (c.starts && c.starts > now) s += ' starts ' + fmtWhen(c.starts) + ''; if (c.expires && c.type !== 'featured') s += ' ' + (c.expires > now ? 'ends ' : 'ended ') + fmtWhen(c.expires) + ''; return s; } function geoLine(rows) { if (!rows || !rows.length) return ''; return '
' + rows.map(r => esc(r.cc) + ' ' + r.n).join(' · ') + '
'; } function hourBars(utc) { if (!utc || !utc.some(n => n)) return ''; // rotate the 24 UTC buckets into the viewer's local hours const local = new Array(24).fill(0); for (let h = 0; h < 24; h++) local[new Date(Date.UTC(2000, 0, 1, h)).getHours()] += utc[h]; const max = Math.max(...local); const lab = h => (h % 12 || 12) + (h < 12 ? 'am' : 'pm'); return '
' + local.map((n, h) => '').join('') + '
views by hour · last 7 days · your time
'; } // ── 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 = 'No views yet. Share your link and the numbers start here.'; return; } t.innerHTML = 'HookViews (30d)Views (all)JoinedQualifying buyers' + rows.map(a => '' + esc(a.angle === 'plain' ? 'plain link' : '?v=' + a.angle) + '' + a.views30 + '' + a.views + '' + a.joins + '' + a.buyers + '').join(''); const st = $('linkSrcTable'); if (st) { const src = (r.sources || []).filter(x => x.views || x.joins); st.innerHTML = src.length ? 'SourceViews (30d)Views (all)JoinedQualifying buyers' + src.map(x => '' + esc(x.source) + '' + x.views30 + '' + x.views + '' + x.joins + '' + x.buyers + '').join('') : 'Sources appear as visits arrive.'; } } catch (e) {} } // ── prospects: the member's own follow-up list ── let PP_STATUSES = ['new', 'contacted', 'interested', 'joined', 'bought', 'not now']; // ── Pipeline: the follow-up board (Marty, 2026-09-15). Stages come from the server; cards never move by hand ── let PIPE = null, PIPE_KEY = null; const pipeAgo = ts => { const d = Math.floor((Date.now() - ts) / 86400000); return d <= 0 ? 'today' : d === 1 ? 'yesterday' : d < 30 ? d + 'd ago' : new Date(ts).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); }; const pipeInvite = () => { const t = ($('inviteLine') && $('inviteLine').textContent) || ''; return /^https?:\/\//.test(t) ? t : ''; }; function pipeCardHtml(c, showStage) { const today = new Date(); today.setHours(23, 59, 59, 999); const due = c.followUp && c.followUp <= today.getTime(); const chips = []; if (c.stalled) chips.push('stalled ' + c.quietDays + 'd'); if (due) chips.push('follow up'); else if (c.followUp) chips.push('' + new Date(c.followUp).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) + ''); if (c.tag) chips.push('' + esc(c.tag) + ''); if (c.kind === 'member' && c.buyers) chips.push('' + c.buyers + ' buyer' + (c.buyers === 1 ? '' : 's') + ''); const stage = showStage ? (PIPE.stages.find(s => s.key === c.stage) || {}).label || '' : ''; return '
' + '
' + esc(c.name) + '' + (c.kind === 'prospect' ? 'prospect' : (c.lastSeen ? 'seen ' + pipeAgo(c.lastSeen) : 'never signed in')) + '
' + '
' + esc(stage ? stage + ' · ' + c.next : c.next) + '
' + (c.note ? '
' + esc(c.note) + '
' : '') + (chips.length ? '
' + chips.join('') + '
' : '') + '
'; } async function loadPipeline() { try { const r = await (await fetch('/api/my/pipeline')).json(); if (r.error) { IAP.status(r.error, 'bad'); return; } if (!r.live) { $('pipeSoon').hidden = false; $('pipeLive').hidden = true; $('pipeSoonEta').textContent = r.eta ? 'Opens ' + r.eta + '. It is on the roadmap so you can see what is coming and when.' : 'It is on the roadmap so you can see what is coming and when.'; return; } PIPE = r; $('pipeSoon').hidden = true; $('pipeLive').hidden = false; const badge = $('pipeBadge'); if (badge) { const n = r.counts.due + r.counts.stalled; badge.hidden = !n; badge.textContent = n > 9 ? '9+' : n; } $('pipeDueCount').hidden = !r.due.length; $('pipeDueCount').textContent = r.due.length; $('pipeDue').innerHTML = r.due.length ? '
' + r.due.map(c => pipeCardHtml(c, true)).join('') + '
' : '

Nothing due. Set a follow-up date on any card and it shows up here.

'; $('pipeSummary').textContent = r.counts.total ? r.counts.total + ' people on your board' + (r.counts.stalled ? ', ' + r.counts.stalled + ' stalled' : '') + '. Cards move on their own when something happens on the ledger; open one to add a note, a follow-up date or a tag.' : 'Nobody on your board yet. Add prospects on My line, and everyone who joins through your link appears here on their own.'; $('pipeBoard').innerHTML = r.columns.map(col => '

' + esc(col.label) + '' + col.cards.length + '

' + esc(col.hint) + '

' + col.cards.map(c => pipeCardHtml(c, false)).join('') + '
').join(''); $('pipeLive').querySelectorAll('[data-pk]').forEach(el => { el.addEventListener('click', () => openPipeCard(el.dataset.pk)); el.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openPipeCard(el.dataset.pk); } }); }); if (PIPE_KEY) { const c = allPipeCards().find(x => x.key === PIPE_KEY); if (c) fillPipeCard(c); else { PIPE_KEY = null; $('pipeCard').hidden = true; } } } catch (e) { IAP.status('Could not load your pipeline.', 'bad'); } } function allPipeCards() { return PIPE ? PIPE.columns.flatMap(c => c.cards) : []; } function fillPipeCard(c) { $('pipeCardName').textContent = c.name; const stage = (PIPE.stages.find(s => s.key === c.stage) || {}).label || ''; $('pipeCardMeta').textContent = stage + (c.kind === 'member' ? ' · joined ' + new Date(c.since).toLocaleDateString() + (c.lastSeen ? ' · last seen ' + pipeAgo(c.lastSeen) : ' · never signed in') + (c.bought ? ' · your qualifying buyer' : '') + (c.buyersPositions ? ' · ' + c.buyers + ' buyers: ' + c.buyersMain + ' on the main wallet, ' + c.buyersPositions + ' on linked positions' : '') : ' · prospect' + (c.contact ? ' · ' + c.contact : '')); $('pipeCardNext').innerHTML = 'Next for them: ' + esc(c.next) + (c.stalled ? ' quiet ' + c.quietDays + ' days' : ''); const say = (c.say || '').replace('{{link}}', pipeInvite()); $('pipeSayBlock').hidden = !say; $('pipeSay').textContent = say; $('pipeSend').hidden = c.kind !== 'member' || !c.email; $('pipeFollow').value = c.followUp ? new Date(c.followUp).toISOString().slice(0, 10) : ''; const sel = $('pipeTag'); sel.innerHTML = PIPE.tags.map(t => '').join(''); $('pipeNote').value = c.note || ''; $('pipeSaved').textContent = ''; $('pipeCard').hidden = false; } function openPipeCard(key) { const c = allPipeCards().find(x => x.key === key); if (!c) return; PIPE_KEY = key; $('pipeLive').querySelectorAll('[data-pk]').forEach(el => el.classList.toggle('on', el.dataset.pk === key)); fillPipeCard(c); $('pipeCard').scrollIntoView({ behavior: 'smooth', block: 'nearest' }); } if ($('pipeCardClose')) $('pipeCardClose').addEventListener('click', () => { PIPE_KEY = null; $('pipeCard').hidden = true; $('pipeLive').querySelectorAll('[data-pk]').forEach(el => el.classList.remove('on')); }); if ($('pipeSave')) $('pipeSave').addEventListener('click', async () => { if (!PIPE_KEY) return; try { await api('/api/my/pipeline/note', { key: PIPE_KEY, note: $('pipeNote').value, followUp: $('pipeFollow').value || null, tag: $('pipeTag').value }); $('pipeSaved').textContent = 'Saved.'; loadPipeline(); } catch (e) { IAP.status(e.message, 'bad'); } }); if ($('pipeCopy')) $('pipeCopy').addEventListener('click', async () => { try { await navigator.clipboard.writeText($('pipeSay').textContent); $('pipeCopy').textContent = 'Copied'; setTimeout(() => { $('pipeCopy').textContent = 'Copy message'; }, 1500); } catch (e) { IAP.status('Copy failed; select the text by hand.', 'bad'); } }); if ($('pipeSend')) $('pipeSend').addEventListener('click', () => { const c = allPipeCards().find(x => x.key === PIPE_KEY); if (!c || !c.email) return; openConvo(c.email, c.name); const ta = $('chatInput'); if (ta) { ta.value = $('pipeSay').textContent; ta.dispatchEvent(new Event('input')); ta.focus(); } }); // ── the rotator (LinkSpin) ── let ROT = null, ROT_HOSTS = [], ROT_ID = null; const rotLink = r => 'https://' + (ROT_HOSTS[0] || location.host) + '/r/' + r.code; async function loadRotator() { try { const r = await (await fetch('/api/my/rotations')).json(); if (r.error) { IAP.status(r.error, 'bad'); return; } ROT = r.rotations || []; ROT_HOSTS = r.hosts || []; const el = $('rotList'); el.innerHTML = ROT.length ? ROT.map(x => '
' + esc(x.name) + (x.paused ? ' paused' : '') + '' + esc(rotLink(x)) + '' + x.destinations.length + ' dest' + x.hits7 + ' hits · 7d' + x.uniques + ' uniques
').join('') : '

No rotations yet. Create one above, add two or more destinations, and share the short link.

'; el.querySelectorAll('[data-rot]').forEach(row => row.addEventListener('click', () => openRotation(Number(row.dataset.rot)))); if (ROT_ID) { const cur = ROT.find(x => x.id === ROT_ID); if (cur) fillRotation(cur); else { ROT_ID = null; $('rotCard').hidden = true; } } } catch (e) { IAP.status('Could not load your rotations.', 'bad'); } } function fillRotation(x) { $('rotCardName').textContent = x.name; $('rotCardLink').textContent = rotLink(x); $('rotPause').textContent = x.paused ? 'Resume' : 'Pause'; $('rotCardSum').textContent = x.hits + ' hits all time, ' + x.hits7 + ' in the last 7 days, ' + x.uniques + ' unique visitors, ' + x.bots + ' bot hits filtered' + (x.fallback ? '. Fallback: ' + x.fallback : '. No fallback set: with every destination paused the link shows a not-active page.'); const t = $('rotDests'); t.innerHTML = 'DestinationWeightShareHits7dUniques' + (x.destinations.length ? x.destinations.map(d => { const total = x.destinations.filter(q => q.active).reduce((n, q) => n + q.weight, 0) || 1; return '' + esc(d.label || 'Destination ' + d.id) + '
' + esc(d.url) + '' + (d.active ? Math.round(d.weight / total * 100) + '%' : 'paused') + '' + d.hits + '' + d.hits7 + '' + d.uniques + ' '; }).join('') : 'No destinations yet. Add at least one below.'); t.querySelectorAll('[data-rw]').forEach(i => i.addEventListener('change', async () => { try { await api('/api/my/rotations/' + x.id + '/dest/' + i.dataset.rw, { weight: i.value }); loadRotator(); } catch (e) { IAP.status(e.message, 'bad'); } })); t.querySelectorAll('[data-rt]').forEach(b => b.addEventListener('click', async () => { const d = x.destinations.find(q => q.id === Number(b.dataset.rt)); try { await api('/api/my/rotations/' + x.id + '/dest/' + b.dataset.rt, { active: !d.active }); loadRotator(); } catch (e) { IAP.status(e.message, 'bad'); } })); t.querySelectorAll('[data-rx]').forEach(b => b.addEventListener('click', async () => { if (!await IAP.confirmBox('Remove this destination? Its hit history stays in the stats.')) return; try { await api('/api/my/rotations/' + x.id + '/dest/' + b.dataset.rx, { remove: true }); loadRotator(); } catch (e) { IAP.status(e.message, 'bad'); } })); $('rotCard').hidden = false; fetch('/api/my/rotations/' + x.id + '/stats').then(r => r.json()).then(st => { if (!st || st.error) return; const lst = (arr, label) => arr.length ? '
' + label + '
' + arr.map(a => esc(a.k) + ' ' + a.n).join(' · ') + '
' : ''; $('rotStats').innerHTML = '
' + lst(st.country, 'By country') + lst(st.source, 'By source') + lst(st.device, 'By device') + '
' + (st.days.length ? '

Last days: ' + st.days.slice(-10).map(d => d.day.slice(5) + ' ' + d.n).join(' · ') + '

' : ''); }).catch(() => {}); } function openRotation(id) { const x = ROT.find(r => r.id === id); if (!x) return; ROT_ID = id; fillRotation(x); $('rotCard').scrollIntoView({ behavior: 'smooth', block: 'nearest' }); } if ($('rotNew')) $('rotNew').addEventListener('submit', async e => { e.preventDefault(); try { const r = await api('/api/my/rotations', { name: $('rotName').value, fallback: $('rotFallback').value }); $('rotName').value = ''; $('rotFallback').value = ''; ROT_ID = r.rotation.id; await loadRotator(); } catch (err) { IAP.status(err.message, 'bad'); } }); if ($('rotAdd')) $('rotAdd').addEventListener('submit', async e => { e.preventDefault(); if (!ROT_ID) return; try { await api('/api/my/rotations/' + ROT_ID + '/dest', { url: $('rotUrl').value, label: $('rotLabel').value, weight: $('rotWeight').value }); $('rotUrl').value = ''; $('rotLabel').value = ''; $('rotWeight').value = 1; loadRotator(); } catch (err) { IAP.status(err.message, 'bad'); } }); if ($('rotCopy')) $('rotCopy').addEventListener('click', async () => { try { await navigator.clipboard.writeText($('rotCardLink').textContent); $('rotCopy').textContent = 'Copied'; setTimeout(() => { $('rotCopy').textContent = 'Copy link'; }, 1500); } catch (e) { IAP.status('Copy failed; select the link by hand.', 'bad'); } }); if ($('rotPause')) $('rotPause').addEventListener('click', async () => { const x = ROT.find(r => r.id === ROT_ID); if (!x) return; try { await api('/api/my/rotations/' + x.id, { paused: !x.paused }); loadRotator(); } catch (e) { IAP.status(e.message, 'bad'); } }); if ($('rotDelete')) $('rotDelete').addEventListener('click', async () => { if (!ROT_ID || !await IAP.confirmBox('Delete this rotation? Its short link stops working immediately.')) return; try { await api('/api/my/rotations/' + ROT_ID, { remove: true }); ROT_ID = null; $('rotCard').hidden = true; loadRotator(); } catch (e) { IAP.status(e.message, 'bad'); } }); if ($('rotClose')) $('rotClose').addEventListener('click', () => { ROT_ID = null; $('rotCard').hidden = true; }); 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 => '').join(''); const list = r.prospects || []; const el = $('prospectList'); if (!el) return; if (!list.length) { el.innerHTML = '

Nobody on the list yet.

'; 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 '
' + esc(p.name) + (due ? ' follow up' : '') + '' + '' + esc(p.contact || '') + (p.note ? ' · ' + esc(p.note) : '') + '' + '' + '' + '
'; }).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: '

Glad you are in. Three things today, in this order:

  1. Pick your username on the Profile tab (it becomes your link).
  2. Wallet tab: Connect and link wallet, then Switch on payouts. Both are free.
  3. Copy your invite link from My line and send it to one person.

Reply here if you get stuck on any of them. That is what I am here for.

' }, { label: 'Switch on payouts', subject: 'One free step so nothing passes you by', html: '

Quick reminder: if payouts are not switched on yet, do it now on the Wallet tab. One free transaction.

The contract locks each buyer to their sponsor at their first purchase, and payouts only route to wallets that are switched on. Ready early and you never miss one.

' }, { label: 'The $5 test', subject: 'See a payout land in real time', html: '

Want to see the whole thing work? Buy the $5 Micro package on Buy packages and watch the live ledger while you do it. You will see your credits mint and the split go out in the same transaction.

When you are ready to count as a qualifying buyer for me, the $20 Activation package is the one.

' }, { label: 'Qualified Start', subject: 'How to open level 2 today with your own positions', html: '

You can be your own first buyers, openly. On Buy packages, the Qualified Start card lets you link a second wallet you own as a position. When it buys a $20 package, it counts as a qualifying buyer, half comes straight back to your main wallet, and the credits pool with yours.

Two positions open level 2 the same day. The three Qualified Start videos in Training show every click.

' }, { label: 'Share your link', subject: 'One conversation a day is the whole job', html: '

Promo tools has posts, texts and emails that already carry your link. Pick one and send it to one person today.

Do not wait for the perfect moment. Nobody who waited ever built a line.

' } ]; (function () { const w = $('bcTemplates'); if (!w) return; w.innerHTML = BC_TEMPLATES.map((t, i) => '').join(''); w.querySelectorAll('[data-bct]').forEach(b => b.addEventListener('click', () => { const t = BC_TEMPLATES[Number(b.dataset.bct)]; $('bcSubject').value = t.subject; $('bcEd').innerHTML = t.html; $('bcEd').focus(); })); })(); // ── Qualified Start calculator ── (function () { const n = $('qcN'), pk = $('qcPkg'), out = $('qcOut'); if (!n || !pk || !out) return; const CR = { 20: 2000, 50: 5500, 100: 12000, 250: 32500 }; const calc = () => { const k = Math.max(1, Math.min(10, Number(n.value) || 1)), usd = Number(pk.value) || 20; const gross = k * usd, back = gross / 2, net = gross - back, credits = k * (CR[usd] || 0); const level = k >= 5 ? 'Level 3 open (and level 2): the Nexus badge, wall position 3, full 50 / 20 / 10' : k >= 2 ? 'Level 2 open: 20% on your directs\' buyers, wall position 2' : 'Counts as one qualifying buyer. One more opens level 2.'; out.innerHTML = '
$' + gross + ' out across ' + k + ' position' + (k === 1 ? '' : 's') + '
$' + back + ' back to your main wallet in the same transactions (the 50% direct-sponsor share)
$' + net + ' net, plus a little POL for gas in each wallet
' + '
' + credits.toLocaleString() + ' credits pooled for your own ads
' + level + '
The 20% and 10% shares go to your upline if they are qualified, otherwise to the platform.
'; }; 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 = '

No one in your downline yet. Share your link and it fills in here.

'; return; } el.innerHTML = r.levels.map(L => !L.members.length ? '' : '
Level ' + L.level + ' · ' + L.members.length + (L.level === 1 ? ' direct' : '') + '
' + L.members.map(m => '
' + esc(m.name) + (m.own ? ' yours' : '') + '' + '' + (m.email ? esc(m.email) : (m.memberId ? '#' + m.memberId + '' : '')) + (m.sponsor ? 'sponsored by ' + esc(m.sponsor) + '' : '') + (m.own ? '' : '' + (m.bought ? '$20+ buy ✓' : 'no $20+ buy yet') + (m.buyers ? ' · ' + m.buyers + ' qualifying buyer' + (m.buyers === 1 ? '' : 's') + ' of their own' : '') + '') + '' + '' + (m.earnedWei && m.earnedWei !== '0' ? '+' + IAP.fmtPol(m.earnedWei) + ' POL' : '0.00 POL') + '' + '' + new Date(m.joined).toLocaleDateString() + '' + (m.email ? '' : '') + (m.ref && !m.own ? '' : '') + '
' + (m.ref && !m.own ? '' : '')).join('') + '
').join(''); el.querySelectorAll('[data-cemail]').forEach(b => b.addEventListener('click', () => openConvo(b.dataset.cemail, b.dataset.cname))); el.querySelectorAll('[data-act]').forEach(b => b.addEventListener('click', () => toggleLineActivity(b))); } catch (e) {} } // who is working: per-member activity drop-down under the row (any of the three levels) const lineActCache = {}; async function toggleLineActivity(btn) { const box = document.querySelector('[data-actbox="' + btn.dataset.act + '"]'); if (!box) return; const open = box.hidden; box.hidden = !open; btn.setAttribute('aria-expanded', String(open)); btn.textContent = open ? 'Activity ▴' : 'Activity ▾'; if (!open) return; if (!lineActCache[btn.dataset.act]) { box.innerHTML = 'Loading…'; try { lineActCache[btn.dataset.act] = await (await fetch('/api/my/line/activity?ref=' + encodeURIComponent(btn.dataset.act))).json(); } catch (e) { lineActCache[btn.dataset.act] = { error: 'Could not load.' }; } } const a = lineActCache[btn.dataset.act]; if (a.error) { box.innerHTML = '' + esc(a.error) + ''; return; } const ago = t => { if (!t) return 'never'; const d = Date.now() - t; if (d < 3600e3) return Math.max(1, Math.round(d / 60e3)) + ' min ago'; if (d < 86400e3) return Math.round(d / 3600e3) + 'h ago'; return Math.round(d / 86400e3) + 'd ago'; }; const chip = (label, val, cls) => '' + esc(label) + '' + esc(val) + ''; const working = (a.viewsToday || 0) > 0 || (a.campaignsActive || 0) > 0 || (a.linkViews30 || 0) > 0 || (a.lastSeen && Date.now() - a.lastSeen < 2 * 86400e3); box.innerHTML = chip('Last seen', ago(a.lastSeen), a.lastSeen && Date.now() - a.lastSeen < 2 * 86400e3 ? 'on' : (a.quietDays >= 3 ? 'warn' : '')) + chip('Joined', new Date(a.joined).toLocaleDateString()) + chip('Stage', (a.rung || 'Joined') + (a.next ? ' · next: ' + a.next : ''), a.stalled ? 'warn' : '') + chip('Ads today', (a.viewsToday || 0) + '/' + (a.target || 5) + (a.claimedToday ? ' claimed' : '') + (a.streakDay ? ' · streak day ' + a.streakDay : ''), (a.viewsToday || 0) > 0 ? 'on' : '') + chip('Campaigns', (a.campaignsActive || 0) + ' active of ' + (a.campaigns || 0) + (a.imps ? ' · ' + a.imps.toLocaleString() + ' views' : ''), (a.campaignsActive || 0) > 0 ? 'on' : '') + chip('Link', (a.linkViews30 || 0) + ' views (30d) · ' + (a.joins || 0) + ' joined', (a.linkViews30 || 0) > 0 ? 'on' : '') + chip('Line', (a.directs || 0) + ' direct' + (a.directs === 1 ? '' : 's') + ' · ' + (a.buyerCount || 0) + ' qualifying', (a.buyerCount || 0) > 0 ? 'on' : '') + chip('Wallet', a.wallet ? (a.badges && a.badges.includes('payouts') ? 'linked · payouts on' : 'linked') : 'not linked', a.wallet ? '' : 'warn') + (a.badges && a.badges.length ? chip('Badges', a.badges.map(b => ({ payouts: 'Spark', firstBuyer: 'Surge', level2: 'Circuit', level3: 'Nexus' }[b] || b)).join(' · ')) : '') + '' + (working ? 'Working' : 'Quiet') + ''; } 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 => '
' + '
' + esc(i.subject) + '' + '' + esc(i.fromName) + ' · ' + new Date(i.sent).toLocaleDateString() + '
' + '
' + (i.body || '') + '
').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', async () => { const sel = window.getSelection(); const range = sel && sel.rangeCount ? sel.getRangeAt(0).cloneRange() : null; const u = await IAP.ask({ title: 'Insert link', text: 'Link URL (https://…)', placeholder: 'https://', ok: 'Insert' }); if (u) { $('bcEd').focus(); if (range) { sel.removeAllRanges(); sel.addRange(range); } 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'; if (earnSub !== 'videos' && vidState.token) stopVideo(); 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 toolkit: badge tiers + the AI Copy Engine (Marty, 2026-09-14) let tkState = null; async function loadToolkit() { try { const r = await (await fetch('/api/my/toolkit')).json(); if (r.error) return; tkState = r; const esc = t => String(t == null ? '' : t).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); const names = { free: 'Free', spark: 'Spark', surge: 'Surge', circuit: 'Circuit', nexus: 'Nexus' }; $('tkTierLine').textContent = 'you are at ' + names[r.tier]; $('tkTiers').innerHTML = r.tiers.map(t => '
' + esc(t.name) + (t.reached ? ' ✓' : '') + '
' + (t.reached ? esc(t.blurb) : 'Unlocks when you ' + esc(t.needText)) + '
    ' + t.items.map(i => '' + esc(i.t) + '').join('') + '
' + (t.reached ? '' : '
locked
') + '
').join(''); $('tkLocked').hidden = r.unlocked; $('tkForm').hidden = !r.unlocked; if (!r.unlocked) { $('tkLocked').textContent = 'Unlocks at Surge: your first qualifying buyer of a $20 or larger package. Then it writes posts, DMs, follow-ups, objection replies, emails and story posts in your name, with your link, inside the honesty rules. ' + (r.tier === 'free' ? 'Step one is switching on payouts.' : 'You are one buyer away.'); $('tkUsage').textContent = 'locked'; renderTkTools(r); return; } $('tkUsage').textContent = r.freeLeft + ' free this month · then ' + r.cost + ' credits each · you have ' + r.available.toLocaleString() + ' credits'; $('tkCostLine').textContent = r.freeLeft > 0 ? 'This one is free (' + r.freeLeft + ' left this month).' : 'This one costs ' + r.cost + ' credits from your earned pool.'; if (!$('tkKind').options.length) { $('tkKind').innerHTML = r.kinds.map(k => '').join(''); $('tkAngle').innerHTML = r.angles.map(a => '').join(''); } renderTkTools(r); $('tkHistory').innerHTML = r.history.length ? '

Recent

' + r.history.map(h => '
' + esc(h.kind) + ' · ' + new Date(h.ts).toLocaleString() + (h.charged ? ' · ' + h.charged + ' credits' : ' · free') + '
' + esc(h.text).replace(/\n/g, '
') + '
').join('') : ''; } catch (e) { console.error('toolkit', e); } } // the rest of the ladder: Spark templates + handout, Circuit Video Maker + split tester, Nexus Leader Ops const tkEsc = t => String(t == null ? '' : t).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); const tkRank = { free: 0, spark: 1, surge: 2, circuit: 3, nexus: 4 }; async function tkPost(url, body) { const r = await (await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body || {}) })).json(); if (r.error) { IAP.status(r.error, 'bad'); return null; } return r; } function renderTkTools(r) { const at = tkRank[r.tier] || 0; // Spark const spark = at >= 1; $('tkSparkLocked').hidden = spark; $('tkSparkBody').hidden = !spark; if (!spark) $('tkSparkLocked').textContent = 'Unlocks at Spark: switch on payouts in the Wallet tab. Then one tap builds a banner or text campaign aimed at your link, and you get a printable handout with your QR.'; else { $('tkHandoutLine').hidden = !r.handoutUrl; if (r.handoutUrl) $('tkHandout').href = r.handoutUrl; } // Circuit const circ = at >= 3; $('tkCircuitLocked').hidden = circ; $('tkCircuitBody').hidden = !circ; if (!circ) $('tkCircuitLocked').textContent = 'Unlocks at Circuit: two qualifying buyers in your line. Then every promo video gets your own end card and QR, and the split tester shows which join angle pulls for you.'; else { loadTkVideos(r); loadTkSplit(); } // Nexus const nex = at >= 4; $('tkNexusLocked').hidden = nex; $('tkNexusBody').hidden = !nex; if (!nex) $('tkNexusLocked').textContent = 'Unlocks at Nexus: five qualifying buyers. Then you get team triage across three levels with one-click nudges, AI-drafted team broadcasts, credit grants to your people, a co-branded join page and your own partner code.'; else { loadTkTeam(); renderTkPartner(r); } } let tkVidTimer = null; async function loadTkVideos(r) { const box = $('tkVideos'); if (!r.videoMaker) { box.innerHTML = '

The Video Maker is warming up on the server. Check back shortly.

'; return; } if (!r.username) { box.innerHTML = '

Pick a username first (Profile); it goes on the end card.

'; return; } try { const v = await (await fetch('/api/my/toolkit/videos')).json(); if (v.error) return; box.innerHTML = v.list.map(x => '
' + tkEsc(x.title) + '' + (x.status === 'done' ? 'Download ' : x.status === 'queued' || x.status === 'working' ? '
' + tkEsc(x.stage || 'Starting') + (x.status === 'working' ? ' · ' + (x.pct || 0) + '%' : '') + '' : '') + '
').join(''); clearTimeout(tkVidTimer); if (v.list.some(x => x.status === 'queued' || x.status === 'working')) tkVidTimer = setTimeout(() => loadTkVideos(r), 2500); } catch (e) {} } async function loadTkSplit() { try { const sp = await (await fetch('/api/my/toolkit/split')).json(); if (sp.error) return; const rows = sp.rows.filter(x => x.views || x.joins); $('tkSplit').innerHTML = !rows.length ? '

No link views yet. Share two different angle links this week and the winner shows up here.

' : '
' + rows.map(x => '').join('') + '
AngleViewsLast 30dJoinsBuyersJoin rate
' + tkEsc(x.angle) + (x.angle === sp.best ? ' (winner so far)' : '') + '
' + tkEsc(x.link) + '
' + x.views + '' + x.views30 + '' + x.joins + '' + x.buyers + '' + x.rate + '%

Rate = joins per 100 views. A winner needs at least 10 views. Send more traffic to the winner, keep testing the rest.

'; } catch (e) {} } let tkTeamData = null; async function loadTkTeam() { try { const t = await (await fetch('/api/my/toolkit/team')).json(); if (t.error || !t.unlocked) return; tkTeamData = t; $('tkTeamSub').textContent = t.total + ' in three levels (' + t.byLevel.join(' / ') + ') · ' + t.recent + ' new this week · ' + t.stalled.length + ' stalled'; const list = t.stalled.length ? t.stalled : t.members.slice(0, 12); $('tkTeam').innerHTML = (t.stalled.length ? '

Stalled first: quiet for a while and not yet past the next rung. One click sends them the right message from you.

' : '

Nobody stalled right now. Your newest people:

') + list.map(m => '
' + tkEsc(m.name) + ' L' + m.level + ' · ' + tkEsc(m.label) + (m.quietDays ? ' · quiet ' + m.quietDays + 'd' : '') + '
Next: ' + tkEsc(m.next) + '
').join(''); $('tkGrantTo').innerHTML = t.members.map(m => '').join(''); } catch (e) {} } function renderTkPartner(r) { const pk = r.partner; $('tkPartner').innerHTML = pk ? '

Code ' + tkEsc(pk.code) + ' gives ' + pk.credits + ' welcome credits, funded from your earned pool at each redemption. Redeemed ' + pk.uses + ' times.

Invite link with the code: ' + tkEsc(pk.link) + '

Your partner kit page (for list owners and site owners you talk to): ' + tkEsc(pk.kit) + '

' : '

Give your recruits a welcome bonus under your own code. Each redemption moves the credits from your earned pool to theirs, so it only costs you when it works. Then hand partners the kit page, which sends their deals under you.

'; } document.addEventListener('click', async ev => { const b = ev.target.closest('[data-tpl],[data-mkvid],[data-copyvid],[data-nudge],[data-copy],#tkPcGo,#tkGrantGo,#tkDraftBc,[data-sendnudge]'); if (!b) return; if (b.dataset.tpl) { b.disabled = true; const r = await tkPost('/api/my/toolkit/template', { kind: b.dataset.tpl, budget: Number($('tkTplBudget').value) }); b.disabled = false; if (r) { IAP.status('Campaign "' + r.name + '" created with ' + r.budget + ' credits. It is under Campaigns.', 'ok'); loadDashboard(); } return; } if (b.dataset.mkvid) { b.disabled = true; const r = await tkPost('/api/my/toolkit/video', { slug: b.dataset.mkvid }); if (r) { IAP.status(r.status === 'done' ? 'Already made.' : 'Rendering. It appears here in about a minute.', 'ok'); loadTkVideos(tkState); } else b.disabled = false; return; } if (b.dataset.copyvid || b.dataset.copy) { try { await navigator.clipboard.writeText(b.dataset.copyvid || b.dataset.copy); IAP.status('Copied.', 'ok'); } catch (e) { IAP.status('Copy failed; select it by hand.', 'bad'); } return; } if (b.dataset.nudge) { const row = b.closest('.tk-team'); const old = row.querySelector('.tk-nudge'); if (old) { old.remove(); return; } const box = document.createElement('div'); box.className = 'tk-nudge'; box.innerHTML = '
Lands in their inbox and their email.
'; box.querySelector('textarea').value = b.dataset.say; row.appendChild(box); return; } if (b.dataset.sendnudge) { const box = b.closest('.tk-nudge'); b.disabled = true; const r = await tkPost('/api/my/toolkit/nudge', { email: b.dataset.sendnudge, text: box.querySelector('textarea').value }); b.disabled = false; if (r) { IAP.status('Sent.', 'ok'); box.remove(); } return; } if (b.id === 'tkGrantGo') { b.disabled = true; const r = await tkPost('/api/my/toolkit/grant', { email: $('tkGrantTo').value, credits: Number($('tkGrantN').value) }); b.disabled = false; if (r) { IAP.status(r.credits + ' credits moved to ' + r.toName + '.', 'ok'); loadToolkit(); loadDashboard(); } return; } if (b.id === 'tkPcGo') { b.disabled = true; const r = await tkPost('/api/my/toolkit/promo', { code: b.dataset.code || ($('tkPcCode') ? $('tkPcCode').value : ''), credits: Number($('tkPcN').value) }); b.disabled = false; if (r) { IAP.status('Code ' + r.code + ' is live: ' + r.credits + ' welcome credits.', 'ok'); loadToolkit(); } return; } if (b.id === 'tkDraftBc') { if (!tkState || !tkState.unlocked) return; $('tkKind').value = 'broadcast'; $('tkBrief').placeholder = 'What should the team focus on this week?'; $('tkEngine').scrollIntoView({ behavior: 'smooth', block: 'start' }); $('tkBrief').focus(); IAP.status('Pick a focus, hit Write it, then Send to my team.', 'ok'); return; } }); $('tkKind') && $('tkKind').addEventListener('change', () => { if ($('tkSendBc')) $('tkSendBc').hidden = $('tkKind').value !== 'broadcast'; }); async function tkSendBroadcast() { const txt = $('tkText').value || ''; const m = /^\s*Subject:\s*(.+)\n+([\s\S]+)$/.exec(txt); const subject = m ? m[1].trim() : 'A note from your sponsor', body = (m ? m[2] : txt).trim(); if (!(await IAP.confirmBox('Send this to everyone in your three levels? One broadcast a day.', { title: 'Team broadcast', ok: 'Send' }))) return; const r = await tkPost('/api/my/broadcast', { subject, body: '

' + tkEsc(body).replace(/\n{2,}/g, '

').replace(/\n/g, '
') + '

', scope: 'all' }); if (r) IAP.status('Sent to ' + (r.sent || r.count || r.recipients || 'your team') + '.', 'ok'); } async function tkGenerate() { const btn = $('tkGo'); if (btn.disabled) return; btn.disabled = true; btn.textContent = 'Writing…'; try { const r = await (await fetch('/api/my/toolkit/generate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ kind: $('tkKind').value, angle: $('tkAngle').value, brief: $('tkBrief').value }) })).json(); if (r.error) { IAP.status(r.error, 'bad'); return; } $('tkText').value = r.text; $('tkOut').hidden = false; $('tkSendBc').hidden = $('tkKind').value !== 'broadcast'; IAP.status(r.charged ? r.charged + ' credits used.' : 'Written. ' + r.freeLeft + ' free left this month.', 'ok'); loadToolkit(); loadDashboard(); } catch (e) { IAP.status('The engine did not answer. Try again.', 'bad'); } finally { btn.disabled = false; btn.textContent = 'Write it'; } } if ($('tkGo')) { $('tkGo').addEventListener('click', tkGenerate); $('tkAgain').addEventListener('click', tkGenerate); $('tkCopy').addEventListener('click', async () => { try { await navigator.clipboard.writeText($('tkText').value); IAP.status('Copied.', 'ok'); } catch (e) { $('tkText').select(); } }); $('tkSendBc').addEventListener('click', tkSendBroadcast); } // promo tools: pill menu switches between posts / swipes / banners / wall / videos function setPromoSub(name) { const ids = ['toolkit', 'posts', 'text', 'swipe', 'banners', 'wall', 'objections', 'videos']; if (name === 'toolkit') loadToolkit(); 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 = '

No solo ads yet. When a member sends one, it lands here — and reading it pays.

'; return; } el.innerHTML = ''; for (const i of r.items) { const d = document.createElement('div'); d.className = 'ib-row' + (i.read ? '' : ' unread'); d.innerHTML = '' + (i.rewarded ? 'claimed' : i.read ? '' : 'new') + '' + new Date(i.delivered).toLocaleDateString() + ''; 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' ? '' : 'attachment'; // 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'; // one collapsed accordion per size / use, so the kit stays scannable as it grows const GROUPS = [ { title: '1200×630 · social posts, link previews, Daily News covers', items: [ { file: 'iap-hero-1200x630.png', size: '1200×630 · hero · advertise and earn' }, { file: 'iap-advertise-earn-1200x630.jpg', size: '1200×630 · advertise and earn instantly, locked in code' }, { file: 'iap-team-build-tiers-1200x630.jpg', size: '1200×630 · team build and instant payments · 50 / 20 / 10 tiers' }, { file: 'iap-team-build-tiers-v2-1200x630.jpg', size: '1200×630 · team build and instant payments · variant 2' }, { file: 'iap-instant-payments-tiers-1200x630.jpg', size: '1200×630 · instant payments, direct commissions · tiers' }, { file: 'iap-instant-payments-tiers-v2-1200x630.jpg', size: '1200×630 · instant payments · variant 2' }, { file: 'iap-multistream-info-1200x630.jpg', size: '1200×630 · multi-stream revenue · "INFO or message me" CTA' }, { file: 'iap-multistream-info-v2-1200x630.jpg', size: '1200×630 · multi-stream revenue · variant 2' }, { file: 'iap-success-path-info-1200x630.jpg', size: '1200×630 · success path, training and coaching · "INFO or message me" CTA' }, { file: 'iap-success-path-link-1200x630.jpg', size: '1200×630 · success path · "link in the description" (feed posts, Daily News)' }, { file: 'iap-success-path-coaching-1200x630.jpg', size: '1200×630 · success path · quality network traffic' } ] }, { title: '1080×1080 and 1080×1920 · Instagram, Facebook, stories, reels', items: [ { file: 'iap-1080x1080.png', size: '1080×1080 · square' }, { file: 'iap-1080x1920.png', size: '1080×1920 · story / reel' } ] }, { title: '1280×720 · Telegram and group posts', items: [ { file: 'iap-1280x720.png', size: '1280×720 · group post' } ] }, { title: 'Leaderboards · 728×90, 468×60, 320×50', items: [ { file: 'iap-728x90.png', size: '728×90 · leaderboard' }, { file: 'iap-advertise-earn-728x90.png', size: '728×90 · advertise and earn instantly · Join free' }, { file: 'iap-ledger-728x90.png', size: '728×90 · advertising that pays you on-chain · See the ledger' }, { file: 'iap-468x60.png', size: '468×60 · banner' }, { file: 'iap-ledger-468x60.png', size: '468×60 · advertising that pays you on-chain · See the ledger' }, { file: 'iap-320x50.svg', size: '320×50 · mobile leaderboard' } ] }, { title: 'Rectangles and buttons · 336×280, 300×250, 125×125', items: [ { file: 'iap-336x280.png', size: '336×280 · large rectangle' }, { file: 'iap-advertise-earn-336x280.png', size: '336×280 · advertise and earn instantly' }, { file: 'iap-advertise-earn-v2-336x280.png', size: '336×280 · advertise and earn · variant 2' }, { file: 'iap-300x250.png', size: '300×250 · rectangle' }, { file: 'iap-advertise-earn-300x250.png', size: '300×250 · advertise and earn instantly' }, { file: 'iap-team-build-link-300x250.jpg', size: '300×250 · team build and instant payments · "link in the description"' }, { file: 'iap-125x125.png', size: '125×125 · square button' } ] }, { title: 'Skyscrapers · 160×600, 120×600', items: [ { file: 'iap-160x600.png', size: '160×600 · wide skyscraper' }, { file: 'iap-120x600.png', size: '120×600 · skyscraper' } ] } ]; bwrap.innerHTML = ''; for (const g of GROUPS) { const det = document.createElement('details'); det.className = 'pb-acc'; det.innerHTML = '' + esc(g.title) + '' + g.items.length + (g.items.length === 1 ? ' banner' : ' banners') + '
'; const grid = det.querySelector('.pb-grid'); for (const b of g.items) { const url = location.origin + '/banners/' + b.file; const d = document.createElement('div'); d.className = 'pb-item'; d.innerHTML = 'LinkSpin banner ' + b.size + '' + '
' + b.size + '
'; 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); grid.appendChild(d); } bwrap.appendChild(det); } } } // ── 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) : ''; let noPayoutAddrs = new Set(); // positions the site refuses to buy from (see siteConfig.noPayoutIds) 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) => '
Position ' + (i + 2) + ' ' + short(p.address) + '' + '' + (p.memberId ? '#' + p.memberId : 'not on-chain yet') + '' + '' + (p.noPayout ? 'linkage only: no purchases from this position (payouts would reach a retired wallet)' : p.counted ? 'counts as a qualifying buyer' : p.memberId ? 'registered, buy $20+ to count' : 'buy a $20+ package to register it') + '' + '' + (p.credits || 0).toLocaleString() + ' credits' + (p.balanceWei != null ? ' · ' + (Number(BigInt(p.balanceWei) / 10n ** 14n) / 10000).toLocaleString(undefined, { maximumFractionDigits: 2 }) + ' POL' : '') + '' + (!p.memberId ? '' : '') + '
').join(''); const mainRow = r.main && r.main.address ? '
Position 1 · main ' + short(r.main.address) + '' + '' + (r.main.memberId ? '#' + r.main.memberId : 'payouts not on yet') + '' + '' + (r.main.buyerCount || 0) + ' qualifying buyer(s)' + '' + (r.main.credits || 0).toLocaleString() + ' credits' + (r.main.balanceWei != null ? ' · ' + (Number(BigInt(r.main.balanceWei) / 10n ** 14n) / 10000).toLocaleString(undefined, { maximumFractionDigits: 2 }) + ' POL' : '') + '
' : ''; const html = mainRow + rows + (list.length ? '

Pooled credits: ' + (r.totalCredits || 0).toLocaleString() + '' + (r.credited ? ' plus ' + r.credited.toLocaleString() + ' credited to your account (spends from any position)' : '') + '. A campaign budget spends from one position at a time.

' : ''); if ($('qsList')) $('qsList').innerHTML = html; // Wallet tab: live POL balance of the linked wallet, and which package it covers const wb = $('walletBal'); if (wb && r.main && r.main.address && r.main.balanceWei != null) { const polN = Number(BigInt(r.main.balanceWei) / 10n ** 14n) / 10000; const usd = r.polUsd ? polN * r.polUsd : 0; const pkgs = [5, 20, 50, 100, 250]; const covers = r.polUsd ? pkgs.filter(p => usd >= p * 1.06 + 0.05) : []; wb.hidden = false; wb.innerHTML = 'Wallet balance: ' + polN.toLocaleString(undefined, { maximumFractionDigits: 2 }) + ' POL' + (usd ? ' (about $' + usd.toLocaleString(undefined, { maximumFractionDigits: 0 }) + ')' : '') + (r.polUsd ? '
' + (covers.length ? 'Enough for the $' + covers[covers.length - 1] + ' package with gas to spare.' : 'Not enough for the $5 package yet. Buy POL with a card on the Buy packages tab, or send POL to this wallet.') + (covers.length && covers.length < pkgs.length ? ' Top up for the $' + pkgs[covers.length] + ' package.' : '') + '' : ''); } if ($('posList')) $('posList').innerHTML = html; if ($('posCard')) $('posCard').hidden = !list.length; // "Buy from" picker: main + every position const sel = $('buyFrom'); if (sel) { const keep = sel.value; sel.innerHTML = '' + list.map((p, i) => '').join(''); noPayoutAddrs = new Set(list.filter(p => p.noPayout).map(p => p.address.toLowerCase())); if (r.main && r.main.noPayout && r.main.address) noPayoutAddrs.add(r.main.address.toLowerCase()); 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 (!(await IAP.confirmBox('Unlink ' + short(b.dataset.unlink) + ' from your account?', { title: 'Unlink position', ok: 'Unlink' }))) 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 (!(await IAP.confirmBox('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?', { title: 'Add a position', ok: 'Ready' }))) 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 = '
' + (PKG[p.id] || 'Package ' + p.id) + '
' + '
$' + Math.round(p.priceCents / 100) + '
' + '
' + p.creditAmount.toLocaleString() + ' credits
' + '
' + (bonus > 0 ? '+' + bonus.toLocaleString() + ' bonus credits' : ' ') + '
' + '
' + (p.costWei ? IAP.fmtPol(p.costWei) + ' POL right now' : 'paused') + '
' + ''; wrap.appendChild(div); } wrap.querySelectorAll('button[data-id]').forEach(b => b.addEventListener('click', async () => { try { b.disabled = true; // the buyer's credits are read by the member id of their LINKED wallet. // If they only connected a wallet (e.g. for the faucet) but never linked // it, link it first (one signature) so the purchase's credits show up. // retry these through transient failures — mobile drops in-flight // fetches when the page returns from the wallet app-switch const jretry = async url => { let err; for (let i = 0; i < 4; i++) { try { return await (await fetch(url)).json(); } catch (e) { err = e; await new Promise(s => setTimeout(s, 500 * (i + 1))); } } throw err; }; const meNow = await jretry('/api/me'); // which of the member's wallets is buying: the main wallet (default) or a // linked position (Qualified Start). A position registers under the main // member id on its first buy, so its sponsor is always this member. const fromSel = $('buyFrom'); const fromPos = (fromSel && !$('buyFromWrap').hidden && fromSel.value && fromSel.value !== 'main') ? fromSel.value.toLowerCase() : null; if (fromPos && !meNow.memberId) { IAP.status('Switch on payouts for your main wallet first (Wallet tab), so this position can register under you.', 'bad'); return; } if (noPayoutAddrs.has(fromPos || String(meNow.address || '').toLowerCase())) { IAP.status('This position is kept for linkage only. A purchase from it would pay a retired wallet. Buy from your main wallet or another position.', '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'); if (!fromPos && !meNow.memberId && spNow.sponsorBlocked) { IAP.status(sponsorHoldText(spNow), 'bad'); return; } // first activation must not hand a sponsored member to the company // 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 && !(await IAP.confirmBox('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?', { title: 'Trust Wallet check', ok: 'Buy anyway', cancel: 'Pause' }))) { IAP.status('Purchase paused. Pick a smaller package, add POL, or connect another wallet, then try again.', 'ok'); return; } } catch (e) { /* balance read failed: let the wallet decide */ } IAP.status('Confirm the purchase in your wallet…'); const r = await IAPWallet.buy(Number(b.dataset.id), spNow.sponsorId || 0, b.dataset.cost); if (r.status !== '0x1' && r.receipt && r.receipt.status !== '0x1') throw new Error('Transaction reverted.'); IAP.status(fromPos ? 'Purchase settled on-chain. That position now counts toward your qualification, and its credits pool with yours.' : 'Purchase settled on-chain. Credits are in your account.', 'ok'); await render(); loadBuyTiles(); } catch (e) { IAP.status('Purchase failed: ' + ((e && e.message) || e), 'bad'); } finally { b.disabled = false; } })); // brand new to crypto? buy POL with a card, sent straight to the wallet const builder = products.find(p => p.priceCents === 5000) || products[products.length - 1]; const needPol = (builder && builder.costWei) ? Math.max(30, Math.ceil(Number(builder.costWei) / 1e18) + 3) : 30; const cta = document.createElement('div'); cta.style.cssText = 'grid-column:1/-1;margin-top:10px;text-align:center'; cta.innerHTML = '

New to crypto? Buy POL with a debit or credit card, Apple Pay, or Google Pay. It lands straight in your own wallet, and this site never touches your money.

' + ' Wallet and MoonPay guide'; wrap.appendChild(cta); const mb = $('moonpayBtn'); if (mb) mb.addEventListener('click', () => openMoonpay(needPol)); } catch (e) {} } loadBuyTiles(); // ── earn-by-viewing: daily ad set with dwell, then claim ── const earnState = { types: ['banner', 'text'], i: 0, timer: null }; async function earnRefresh() { try { const st = await (await fetch('/api/my/earn')).json(); if (st.error) return null; $('earnProgress').textContent = 'today: ' + st.views + ' / ' + st.target + ' ads viewed'; $('earnBalance').textContent = (st.earnedAvailable != null ? st.earnedAvailable : st.earned) + ' earned credits available' + (st.reserved ? ' · ' + st.reserved + ' in live campaigns' : ''); const done = st.views >= st.target; $('earnClaimBtn').hidden = !(done && !st.claimed); if (st.claimed) { $('earnHint').textContent = 'Claimed for today' + (st.streakDay > 1 ? ' (streak day ' + st.streakDay + ')' : '') + '. Tomorrow\'s claim pays ' + st.nextClaim + ' credits if you come back.'; // after the set (Marty, 2026-09-12): keep going with verified visits, and turn the credits into a campaign const box = $('earnAdBox'); if (box) box.innerHTML = '
✓Ads done for todayToday\'s set is viewed and claimed. Fresh ads tomorrow.' + '
' + (st.visitsLeft ? '' : '') + ((st.earnedAvailable != null ? st.earnedAvailable : st.earned) > 0 ? '' : '') + '
'; if (box) box.querySelectorAll('[data-earnnext]').forEach(b => b.addEventListener('click', () => { if (b.dataset.earnnext === 'campaigns') setPane('campaigns'); else setEarnSub('visits'); })); if ($('earnStartBtn')) $('earnStartBtn').hidden = true; } else { if ($('earnStartBtn')) $('earnStartBtn').hidden = done; // set done: the claim button takes over $('earnHint').textContent = done ? 'Set complete. Claim your ' + st.claimCredits + ' credits' + (st.streakDay > 1 ? ' (streak day ' + st.streakDay + ')' : '') + '.' : (st.views ? (st.target - st.views) + ' more to go, then claim ' + st.claimCredits + ' credits' + (st.streakDay > 1 ? ' (streak day ' + st.streakDay + ')' : '') + '.' : 'View ' + st.target + ' ads to unlock the daily claim of ' + st.claimCredits + ' credits' + (st.streakDay > 1 ? ' (streak day ' + st.streakDay + ')' : '') + '.'); } return st; } catch (e) { return null; } } async function earnShowAd() { // each view happens full screen in its own tab: /view/ frames the // advertiser's site, runs the countdown, then a human check credits it const type = earnState.types[earnState.i++ % earnState.types.length]; let r = null; try { r = await (await fetch('/api/my/earnview?type=' + type)).json(); } catch (e) {} const box = $('earnAdBox'); if (!r || !r.ad || !r.viewUrl) { await earnRefresh(); box.innerHTML = '' + (r && r.status && r.status.views >= r.status.target ? 'Set complete for today.' : 'No member ads are live in rotation right now. Views resume the moment a campaign is running.') + ''; return; } openAdOverlay(r.viewUrl); box.innerHTML = 'Watch the countdown and pass the quick check. Your view credits itself and this page updates right away.'; $('earnStartBtn').textContent = 'View next ad'; } // In-page ad overlay: no new tab (mobile and in-app wallet browsers handle tabs // badly), no window.close needed. The /view page runs the dwell + check inside, // messages back when it credits or when the user is done. function openAdOverlay(url) { closeAdOverlay(); const back = document.createElement('div'); back.id = 'adOverlay'; back.style.cssText = 'position:fixed;inset:0;z-index:100000;background:#050b09;display:flex;flex-direction:column'; const x = document.createElement('button'); x.type = 'button'; x.setAttribute('aria-label', 'Close ad'); x.textContent = '✕'; x.style.cssText = 'position:absolute;top:10px;right:12px;z-index:2;width:40px;height:40px;border-radius:50%;border:1px solid rgba(255,255,255,.25);background:rgba(6,10,16,.85);color:#fff;font-size:20px;font-weight:700;cursor:pointer'; x.addEventListener('click', closeAdOverlay); const ifr = document.createElement('iframe'); ifr.src = url; ifr.style.cssText = 'flex:1;width:100%;border:0;background:#050b09'; back.appendChild(ifr); back.appendChild(x); document.body.appendChild(back); document.body.style.overflow = 'hidden'; } function closeAdOverlay() { const m = $('adOverlay'); if (m) m.remove(); document.body.style.overflow = ''; earnRefresh(); loadDashboard(); } window.addEventListener('message', e => { if (e.origin !== location.origin || !e.data) return; if (e.data.t === 'iap-view-close') closeAdOverlay(); else if (e.data.t === 'iap-view-done') { earnRefresh(); loadDashboard(); } }); $('earnStartBtn').addEventListener('click', () => earnShowAd()); // the viewer tab pings localStorage when a view credits; refresh instantly window.addEventListener('storage', e => { if (e.key === 'iap-view-done') { earnRefresh(); loadDashboard(); } }); document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible' && $('pane-earn') && !$('pane-earn').hidden) earnRefresh(); }); $('earnClaimBtn').addEventListener('click', async () => { try { const r = await api('/api/my/claim'); IAP.status('+' + r.credited + ' credits earned' + (r.streakDay > 1 ? ', streak day ' + r.streakDay : '') + '. Tomorrow\'s claim pays ' + r.nextClaim + '. Spend them in Campaigns.', 'ok'); await earnRefresh(); loadDashboard(); } catch (e) { IAP.status(e.message, 'bad'); } }); const busy = (btn, fn) => async () => { try { btn.disabled = true; await fn(); } catch (e) { IAP.status((e && e.message) || String(e), 'bad'); } finally { btn.disabled = false; } }; // passwordless (feature-flagged on config.emailAuth): code replaces passwords (async () => { const cfg = await IAP.getConfig(); if (!cfg.emailAuth) return; $('passCards').hidden = true; $('magicCard').hidden = false; const codeOpts = () => ({ honeypot: $('mcWebsite'), host: $('mcCheck') }); const start = busy($('mcSendBtn'), async () => { const r = await IAP.requestCode($('mcEmail').value, codeOpts()); $('mcCodeRow').hidden = false; $('mcVerifyBtn').hidden = false; $('mcSendBtn').hidden = true; $('mcResend').hidden = false; if (r.devCode) { $('mcCode').value = r.devCode; IAP.status('Dev mode: code filled in for you.', 'ok'); } else IAP.status('Code sent. Check your inbox (and spam, the first time).', 'ok'); $('mcCode').focus(); }); $('mcSendBtn').addEventListener('click', start); $('mcResend').addEventListener('click', busy($('mcResend'), async () => { const r = await IAP.requestCode($('mcEmail').value, codeOpts()); if (r.devCode) $('mcCode').value = r.devCode; IAP.status('Fresh code sent.', 'ok'); })); $('mcVerifyBtn').addEventListener('click', busy($('mcVerifyBtn'), async () => { const r = await api('/api/auth/email/verify', { email: $('mcEmail').value, code: $('mcCode').value, newsletter: !!($('nlOptin') && $('nlOptin').checked) }); IAP.status('You are in.', 'ok'); if (r.created && !(r.account && r.account.username)) await showOnboard(); // pick a username first if (!(await showGauntlet())) await showLoginAd(); // welcome tour outranks the login ad await render(); })); })(); // new-member onboarding: choose a username (required), optionally a bio. // required=true: no skip, no backdrop dismiss, prefilled suggestion, resolves only after a save. function showOnboard(required) { return new Promise(resolve => { const m = $('onboardModal'); if (!m) return resolve(); m.hidden = false; $('obErr').hidden = true; $('obSkip').hidden = !!required; if (required && !$('obUsername').value) { fetch('/api/my/username-suggest').then(r => r.json()).then(r => { if (r.suggest && !$('obUsername').value) { $('obUsername').value = r.suggest; $('obUsername').select(); } }).catch(() => {}); } setTimeout(() => $('obUsername').focus(), 50); const done = () => { m.hidden = true; resolve(); }; $('obSkip').onclick = required ? null : done; $('obUsername').onkeydown = e => { if (e.key === 'Enter') { e.preventDefault(); $('obSave').click(); } }; $('obSave').onclick = async () => { const u = $('obUsername').value.trim(); if (required && !u) { $('obErr').hidden = false; $('obErr').textContent = 'Pick a username to continue.'; return; } try { if (u) await api('/api/my/profile', { username: u }); const bio = $('obBio').value.trim(); if (bio) await api('/api/my/profile-details', { bio }); done(); } catch (e) { $('obErr').hidden = false; $('obErr').textContent = e.message || 'Could not save that. Try a different username.'; } }; }); } $('signupBtn').addEventListener('click', busy($('signupBtn'), async () => { const r = await api('/api/signup', { email: $('suEmail').value, password: $('suPass').value, newsletter: !!($('nlOptin') && $('nlOptin').checked) }); IAP.status('Welcome aboard. You are in.', 'ok'); if (!(r.account && r.account.username)) await showOnboard(); await render(); })); $('loginBtn').addEventListener('click', busy($('loginBtn'), async () => { await api('/api/login', { email: $('liEmail').value, password: $('liPass').value }); IAP.status('Logged in.', 'ok'); if (!(await showGauntlet())) await showLoginAd(); // welcome tour outranks the login ad await render(); })); $('linkBtn').addEventListener('click', busy($('linkBtn'), async () => { IAP.status('Check your wallet for the free link signature…'); await IAPWallet.signIn(); // server binds the wallet to the signed-in email account IAP.status('Wallet linked. Earnings pay there from now on.', 'ok'); await render(); })); if ($('faucetBtn')) $('faucetBtn').addEventListener('click', busy($('faucetBtn'), async () => { IAP.status('Connect your wallet first…'); const addr = await IAPWallet.connect(); let copied = false; try { await navigator.clipboard.writeText(addr); copied = true; } catch (e) {} window.open('https://faucet.polygon.technology/', '_blank', 'noopener'); $('faucetInfo').innerHTML = 'Your address ' + addr.slice(0, 8) + '…' + addr.slice(-6) + '' + (copied ? ' is copied' : '') + '. On the faucet, choose Polygon Amoy, paste your address, and request POL. Then come back and buy.'; IAP.status('Faucet opened in a new tab. Choose Polygon Amoy, paste your address, and request test POL.', 'ok'); })); if ($('wcDisconnect')) $('wcDisconnect').addEventListener('click', busy($('wcDisconnect'), async () => { // fire-and-forget: don't gate the reload on disconnect resolving (it can // stall). The reload drops all in-memory wallet state and the picker returns. IAPWallet.disconnect().catch(() => {}); $('wcDisconnectInfo').textContent = 'Disconnecting — reloading so you can pick a different wallet…'; IAP.status('Disconnecting — reloading…', 'ok'); setTimeout(() => location.reload(), 900); })); // a named sponsor that cannot be paid right now: hold the transaction and say why (never credit the company by default) function sponsorHoldText(sp) { const who = sp.sponsorName || 'your sponsor'; if (sp.sponsorBlocked === 'claim' && sp.claim) return (sp.claim.nobody ? 'Nobody above you on LinkSpin has a wallet yet, so there is no one for the contract to pay. ' : who + ' has not linked a wallet on LinkSpin yet. They have until ' + new Date(sp.claim.deadline).toLocaleString() + ' to do it, and we have told them. ') + 'You can use everything else meanwhile; your purchase waits so it pays the right person.'; if (sp.sponsorBlocked === 'notActivated') return who + ' has not switched on payouts yet, so this purchase would credit the company instead of them. Ask them to switch on payouts (Wallet tab, one free transaction), then try again.'; if (sp.sponsorBlocked === 'rpc') return 'Could not confirm your sponsor on the chain just now. Try again in a minute; nothing was charged.'; return 'Your sponsor link could not be matched to a member. Message support before buying so ' + who + ' gets credit.'; } $('activateBtn').addEventListener('click', busy($('activateBtn'), async () => { const me = await (await fetch('/api/me')).json(); if (me.sponsorBlocked) { IAP.status(sponsorHoldText(me), 'bad'); return; } 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 = 'line banner'; } $('lbCurrent').textContent = a.lineTargetUrl ? 'Live: your next three levels meet ' + a.lineTargetUrl + ' on their welcome tour.' : 'Not set yet. Until you set one, your tour slot is skipped.'; } async function loadLineBanner() { try { const a = await (await fetch('/api/me')).json(); fillLineBanner(a); fillProfileDetails(a); // buyerCount + wallUnlocked live on the dashboard payload (chain read), not on /api/me let d = {}; try { d = await (await fetch('/api/my/dashboard')).json(); } catch (e) {} fillWallOffers(Object.assign({}, a, { buyerCount: d.buyerCount || 0, wallUnlocked: d.wallUnlocked || 1 })); } catch (e) {} } // ── wall positions 2 & 3: the member's own offers, unlocked by qualifying buyers ── function fillWallOffers(a) { if (!a || !$('wallOffersCard')) return; const unlocked = a.wallUnlocked || 1, bc = a.buyerCount || 0; const offers = Array.isArray(a.wallOffers) ? a.wallOffers : []; const NEED = [2, 5]; for (let i = 0; i < 2; i++) { const o = offers[i] || {}; const open = unlocked >= i + 2; $('woTitle' + i).value = o.title || ''; $('woTarget' + i).value = o.targetUrl || ''; $('woBanner' + i).value = o.bannerUrl || ''; $('woPrev' + i).hidden = !o.bannerUrl; $('woPrev' + i).innerHTML = o.bannerUrl ? '' : ''; $('woLock' + i).textContent = open ? 'yours' : 'unlocks at ' + NEED[i] + ' qualifying buyers (' + bc + '/' + NEED[i] + ')'; $('woSlot' + i).classList.toggle('locked', !open); } $('woStatus').textContent = unlocked >= 3 ? 'Fully qualified: all three wall positions are yours.' : unlocked === 2 ? 'Position 2 is yours. ' + (5 - bc) + ' more qualifying buyer' + (5 - bc === 1 ? '' : 's') + ' and position 3 is too.' : (2 - bc) + ' more qualifying buyer' + (2 - bc === 1 ? '' : 's') + ' ($20 or more) opens position 2. You can set your links now; they go live the moment a slot unlocks.'; } document.querySelectorAll('.wo-upload').forEach(b => b.addEventListener('click', () => { const f = document.querySelector('.wo-file[data-slot="' + b.dataset.slot + '"]'); if (f) f.click(); })); document.querySelectorAll('.wo-file').forEach(inp => inp.addEventListener('change', async () => { const i = inp.dataset.slot, f = inp.files[0]; if (!f) return; $('woInfo' + i).textContent = 'Uploading…'; try { const r = await (await fetch('/api/my/upload', { method: 'POST', headers: { 'Content-Type': f.type }, body: f })).json(); if (r.error) { $('woInfo' + i).textContent = r.error; } else { $('woBanner' + i).value = r.url; $('woInfo' + i).textContent = 'Uploaded'; $('woPrev' + i).hidden = false; $('woPrev' + i).innerHTML = ''; } } catch (e) { $('woInfo' + i).textContent = 'Upload failed. Try again.'; } inp.value = ''; })); if ($('woSaveBtn')) $('woSaveBtn').addEventListener('click', busy2($('woSaveBtn'), async () => { const offers = [0, 1].map(i => ({ title: $('woTitle' + i).value, targetUrl: $('woTarget' + i).value, bannerUrl: $('woBanner' + i).value })); const r = await api('/api/my/wall-offers', { offers }); IAP.status('Wall positions saved.', 'ok'); await loadLineBanner(); })); const SOCIALS = ['facebook', 'twitter', 'youtube', 'instagram', 'tiktok', 'telegram', 'linkedin', 'website', 'video']; // video = intro video on the wall, not a social link 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 = 'line banner'; } 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 ? 'sponsor ad' : '' + (ad.title ? String(ad.title).replace(/[&<>]/g, '') : 'Visit today\'s sponsor') + ''; $('lgStatus').textContent = 'Login ad sponsor — click "Open Ad" to begin.'; timer.hidden = true; goBtn.hidden = true; openBtn.disabled = false; gate.hidden = false; await new Promise(done => { openBtn.onclick = () => { window.open(ad.targetUrl, '_blank'); // the click relay counts it openBtn.disabled = true; $('lgStatus').textContent = 'Ad open in a new tab. View it and come back — the timer runs here.'; let left = ad.dwell || 10; timer.hidden = false; timer.textContent = 'Time remaining: ' + left + 's'; const t = setInterval(() => { left -= 1; if (left > 0) { timer.textContent = 'Time remaining: ' + left + 's'; return; } clearInterval(t); timer.textContent = 'Time is up'; timer.classList.add('done'); $('lgStatus').textContent = 'Thanks for the look. Your dashboard is ready.'; goBtn.hidden = false; }, 1000); goBtn.onclick = () => done(); }; }); gate.hidden = true; $('lgTimer').classList.remove('done'); } catch (e) {} } // ── SPONSOR CHAT: two-way threads, presence-aware, with a slide-in drawer ── let CHAT_ME = null, CHAT_OTHER = null, CHAT_LASTID = 0, CHAT_POLL = null, CHAT_CANMUTE = false, CHAT_IMUTE = false, CHAT_SPONSOR = null, CHAT_LAST_UNREAD = 0; function chatSync(d) { CHAT_ME = d.email || CHAT_ME; CHAT_SPONSOR = (d.sponsor && d.sponsor.email) ? d.sponsor : null; const link = $('chatMenuBtn'); if (link && link.closest('.bo-links')) link.closest('.bo-links').hidden = !d.email; setChatBadge(d.chatUnread || 0); CHAT_LAST_UNREAD = d.chatUnread || 0; const tog = $('chatAvailToggle'); if (tog) tog.checked = d.chatAvailable !== false; // Overview quick action + My line card: message-your-sponsor entry points const qs = $('qaMsgSponsor'); if (qs) { if (CHAT_SPONSOR) { qs.hidden = false; qs.dataset.email = CHAT_SPONSOR.email; qs.dataset.name = CHAT_SPONSOR.name || 'your sponsor'; } else qs.hidden = true; } const card = $('sponsorMsgCard'); if (card) { card.hidden = !CHAT_SPONSOR; if (CHAT_SPONSOR && $('sponsorMsgName')) $('sponsorMsgName').textContent = CHAT_SPONSOR.name || 'your sponsor'; } } function setChatBadge(n) { const b = $('chatNavBadge'); if (b) { b.hidden = !n; b.textContent = n > 9 ? '9+' : n; } } function stopPoll() { if (CHAT_POLL) { clearInterval(CHAT_POLL); CHAT_POLL = null; } } function startPoll() { stopPoll(); CHAT_POLL = setInterval(() => pullThread(false), 4000); } // the composer grows with the message up to ~45% of the screen, and keeps any height the member dragged it to (Bradley, 2026-09-13) function autoGrow(el) { const cap = Math.max(160, Math.floor(window.innerHeight * 0.45)); const dragged = Number(el.dataset.dragged || 0); el.style.height = 'auto'; el.style.height = Math.min(cap, Math.max(dragged, el.scrollHeight)) + 'px'; } function closeDrawer() { stopPoll(); if ($('chatDrawer')) $('chatDrawer').hidden = true; CHAT_OTHER = null; loadDashboard(); } async function openThreads() { const dr = $('chatDrawer'); if (!dr) return; dr.hidden = false; $('chatConvo').hidden = true; $('chatThreads').hidden = false; $('chatBack').hidden = true; $('chatMute').hidden = true; $('chatDot').hidden = true; $('chatTitle').textContent = 'Messages'; $('chatStatus').textContent = ''; stopPoll(); CHAT_OTHER = null; $('chatThreads').innerHTML = '

Loading…

'; try { const r = await (await fetch('/api/my/chat/threads')).json(); const list = r.threads || []; // always offer "message your sponsor" up top when they have one and no thread yet const hasSponsorThread = CHAT_SPONSOR && list.some(t => t.email === CHAT_SPONSOR.email); const sponsorRow = (CHAT_SPONSOR && !hasSponsorThread) ? '
' + '' + '
' + esc(CHAT_SPONSOR.name) + '
' + '
Your sponsor · tap to message
' : ''; if (!list.length && !sponsorRow) { $('chatThreads').innerHTML = '
No conversations yet. You can message anyone in your line from “My line”.
'; return; } $('chatThreads').innerHTML = sponsorRow + list.map(t => '
' + '' + '
' + esc(t.name) + '
' + '
' + (t.last.fromMe ? 'You: ' : '') + esc((t.last.body || '').slice(0, 64)) + '
' + (t.unread ? '' + t.unread + '' : '') + '
').join(''); $('chatThreads').querySelectorAll('.chat-thread').forEach(el => el.addEventListener('click', () => openConvo(el.dataset.email, el.dataset.name))); } catch (e) { $('chatThreads').innerHTML = '
Could not load messages.
'; } } 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('mouseup', () => { const h = inp.getBoundingClientRect().height; inp.dataset.dragged = h > 60 ? String(Math.round(h)) : ''; }); 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(); })(); // ── partner promo codes: typed on the Overview (Marty, 2026-09-12) ── (function () { const btn = document.getElementById('promoApply'), inp = document.getElementById('promoCode'), msg = document.getElementById('promoMsg'); if (!btn || !inp) return; const say = (t, ok) => { msg.hidden = false; msg.textContent = t; msg.style.color = ok ? 'var(--mint)' : '#ff8a8a'; }; const go = async () => { const code = inp.value.trim(); if (!code) { say('Enter a promo code.', false); return; } btn.disabled = true; try { const r = await (await fetch('/api/my/promo/redeem', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code }) })).json(); if (r.error) { say(r.error, false); return; } say('Added ' + Number(r.credits).toLocaleString() + ' credits' + (r.partner ? ' from ' + r.partner : '') + '. They are in your balance now.', true); inp.value = ''; if (typeof loadDashboard === 'function') loadDashboard(); } catch (e) { say('Could not apply that code. Try again.', false); } finally { btn.disabled = false; } }; btn.addEventListener('click', go); inp.addEventListener('keydown', e => { if (e.key === 'Enter') go(); }); })();