6395ad3a7e
A second, quieter toast (bottom-left, one per 4 s) shows site-wide activity from the live feed for every signed-in member; personal payout toasts are unchanged. The server pushes Joined (at first username, with tank flag) and Adopted events onto the feed. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2278 lines
150 KiB
JavaScript
2278 lines
150 KiB
JavaScript
// My account: email-first join/login, wallet link at purchase time,
|
||
// free payout activation, invite link, on-chain activity.
|
||
(async function () {
|
||
// back-office shell: no marketing nav here; rehearsal notice lives in the top bar
|
||
const $ = IAP.$;
|
||
try {
|
||
const c = await IAP.getConfig();
|
||
if (c.rehearsal && $('boRehearsal')) {
|
||
$('boRehearsal').hidden = false;
|
||
$('boRehearsal').innerHTML = '<b>Testnet rehearsal</b> · ' + c.chainName;
|
||
}
|
||
if (c.rehearsal && $('faucetCard')) $('faucetCard').hidden = false;
|
||
} catch (e) {}
|
||
// if they arrived through a sponsor's link, show who they're joining under
|
||
(async () => {
|
||
try {
|
||
const sp = await (await fetch('/api/sponsor')).json();
|
||
if (sp && sp.invited && sp.name && $('sponsorNote')) { $('sponsorNoteName').textContent = sp.name; $('sponsorNote').hidden = false; }
|
||
} catch (e) {}
|
||
})();
|
||
|
||
async function api(path, body) {
|
||
const r = await (await fetch(path, { method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body || {}) })).json();
|
||
if (r.error) throw new Error(r.error);
|
||
return r;
|
||
}
|
||
|
||
function nextMove(d) {
|
||
if (!d.address) return 'Grab your link below and start sharing today. Then link your wallet (one free signature) so every payment can lock to you before your people start buying.';
|
||
if (!d.memberId) return 'Your wallet is linked. Switch on payouts above (one free transaction) and every purchase in your line pays you the moment it happens.';
|
||
if (d.buyerCount === 0) return 'Payouts are on and your link is live. Your next milestone: your first buyer of $20 or more. Every direct pays you 50 percent from their very first package.';
|
||
if (d.buyerCount === 1) return 'One qualifying buyer down, one to go. Your next $20+ buyer unlocks level 2: 20 percent of everything your people’s people buy.';
|
||
if (d.buyerCount < 5) return 'Level 2 is open. ' + (5 - d.buyerCount) + ' more qualifying buyer(s) unlock level 3 and the full three-level flow.';
|
||
return 'Fully qualified. Every level pays you, and you catch the pass-ups that under-qualified positions below you let slip. Keep sharing and keep your campaigns running.';
|
||
}
|
||
// featured rotation strip on the overview
|
||
let featItems = [], featIdx = 0, featTimer = null;
|
||
function renderOneFeatured() {
|
||
if (!featItems.length) return;
|
||
const i = featItems[featIdx % featItems.length];
|
||
$('featStrip').innerHTML = '<a href="' + i.url + '" target="_blank" rel="noopener nofollow"><span>' + esc(i.title) + '</span>'
|
||
+ (i.by ? '<span class="by">' + esc(i.by) + '</span>' : '') + '</a>';
|
||
}
|
||
async function loadFeatured() {
|
||
try {
|
||
const r = await (await fetch('/api/featured')).json();
|
||
const card = $('featuredCard'); if (!card) return;
|
||
if (!r.items || !r.items.length) { card.hidden = true; return; }
|
||
card.hidden = false;
|
||
featItems = r.items; featIdx = Math.floor(Math.random() * featItems.length);
|
||
$('featSub').textContent = r.items.length + ' link' + (r.items.length === 1 ? '' : 's') + ' in rotation';
|
||
renderOneFeatured(); // show ONE at a time (true rotation), cycle if more than one
|
||
clearInterval(featTimer);
|
||
if (featItems.length > 1) featTimer = setInterval(() => { featIdx++; renderOneFeatured(); }, 6000);
|
||
// self-sell: today's open slots + a CTA to feature your own link
|
||
try {
|
||
const st = await (await fetch('/api/featured/stats')).json();
|
||
const today = (st.occupancy || []).find(d => d.offset === 0);
|
||
const cta = $('featCta');
|
||
if (cta) {
|
||
cta.innerHTML = (today ? '<span class="muted">' + today.open + ' of ' + today.cap + ' featured slots open today. </span>' : '')
|
||
+ '<a href="#campaigns" id="featBuy" style="color:var(--mint);font-weight:700;text-decoration:none">Feature your link →</a>';
|
||
const fb = $('featBuy');
|
||
if (fb) fb.addEventListener('click', e => { e.preventDefault(); setPane('campaigns'); const ct = $('cType'); if (ct) { ct.value = 'featured'; ct.dispatchEvent(new Event('change')); } });
|
||
}
|
||
} catch (e) {}
|
||
} catch (e) {}
|
||
}
|
||
|
||
// achievement badges: the milestone ladder as unlockable medals + share-to-image
|
||
const BADGES = [ // ribbonY = vertical center of each badge's name ribbon (art differs per tier)
|
||
{ key: 'payouts', label: 'Spark', sub: 'payouts on', img: '/badges/badge-spark.jpg', ribbonY: 0.728 },
|
||
{ key: 'firstBuyer', label: 'Surge', sub: 'first qualifying buyer', img: '/badges/badge-surge.jpg?v=2', ribbonY: 0.76 },
|
||
{ key: 'level2', label: 'Circuit', sub: '2 qualifying buyers', img: '/badges/badge-circuit.jpg?v=2', ribbonY: 0.72 },
|
||
{ key: 'level3', label: 'Nexus', sub: 'fully qualified', img: '/badges/badge-nexus.jpg?v=2', ribbonY: 0.73 }
|
||
];
|
||
let lastMilestones = [];
|
||
function renderBadges(d) {
|
||
const reached = d.milestonesReached || [];
|
||
lastMilestones = reached;
|
||
const strip = $('badgeStrip');
|
||
if (!strip) return;
|
||
$('badgeCard').hidden = false;
|
||
strip.innerHTML = BADGES.map(b => {
|
||
const got = reached.includes(b.key);
|
||
return '<div class="badge-a' + (got ? '' : ' locked') + '">'
|
||
+ '<img class="badge-img" src="' + b.img + '" alt="' + b.label + ' badge" loading="lazy">'
|
||
+ '<div class="bl">' + b.label + '</div><div class="bs">' + b.sub + '</div>'
|
||
+ (got ? '<button class="share" data-badge="' + b.key + '">Share</button>' : '<div class="bs">🔒 locked</div>') + '</div>';
|
||
}).join('');
|
||
strip.querySelectorAll('[data-badge]').forEach(btn =>
|
||
btn.addEventListener('click', () => shareBadge(btn.dataset.badge, d)));
|
||
// celebrate anything newly granted this load
|
||
if (d.milestonesGranted && d.milestonesGranted.length) {
|
||
const total = d.milestonesGranted.reduce((s, g) => s + g.credited, 0);
|
||
const names = d.milestonesGranted.map(g => (BADGES.find(b => b.key === g.key) || {}).label).filter(Boolean).join(', ');
|
||
IAP.status('🏆 Achievement unlocked: ' + names + ' — +' + total + ' bonus credits!', 'ok');
|
||
}
|
||
}
|
||
// compose a shareable badge image on a canvas (zero-dep) and download it
|
||
// composite the ornate badge template with the member's @username on the ribbon
|
||
function shareBadge(key, d) {
|
||
const b = BADGES.find(x => x.key === key); if (!b) return;
|
||
const who = d.username ? d.username : d.memberId ? 'member #' + d.memberId : '';
|
||
const img = new Image();
|
||
img.onload = () => {
|
||
const c = document.createElement('canvas'); c.width = img.width; c.height = img.height;
|
||
const x = c.getContext('2d');
|
||
x.drawImage(img, 0, 0);
|
||
if (who) { // name it on the blank ribbon: gold with a dark outline so it reads on any badge color
|
||
x.textAlign = 'center';
|
||
x.textBaseline = 'middle';
|
||
x.font = 'bold ' + Math.round(c.width * 0.055) + 'px Sora, "Segoe UI", sans-serif';
|
||
const y = c.height * (b.ribbonY || 0.75);
|
||
x.lineJoin = 'round';
|
||
x.lineWidth = Math.max(3, Math.round(c.width * 0.008));
|
||
x.strokeStyle = 'rgba(4,20,15,.9)';
|
||
x.strokeText(who, c.width / 2, y);
|
||
x.fillStyle = '#ffd15c';
|
||
x.fillText(who, c.width / 2, y);
|
||
x.textAlign = 'left';
|
||
x.textBaseline = 'alphabetic';
|
||
}
|
||
try {
|
||
c.toBlob(bl => {
|
||
const a = document.createElement('a');
|
||
a.href = URL.createObjectURL(bl);
|
||
a.download = 'instantadpay-' + b.label.toLowerCase() + '-badge.jpg';
|
||
a.click();
|
||
setTimeout(() => URL.revokeObjectURL(a.href), 5000);
|
||
IAP.status('Your ' + b.label + ' badge is saved — share it anywhere.', 'ok');
|
||
}, 'image/jpeg', 0.9);
|
||
} catch (e) { IAP.status('Could not generate the image.', 'bad'); }
|
||
};
|
||
img.onerror = () => IAP.status('Could not load the badge art.', 'bad');
|
||
img.src = b.img; // same-origin, canvas stays untainted
|
||
}
|
||
|
||
// milestone stepper under "Your next move": lit nodes for what's done,
|
||
// amber glow on the current target — the same ladder the contract pays
|
||
function renderSteps(d) {
|
||
const el = $('ncSteps');
|
||
if (!el) return;
|
||
const b = d.buyerCount || 0;
|
||
const steps = [
|
||
{ label: 'Joined', sub: 'free account', hit: true },
|
||
{ label: 'Spark', sub: 'payouts on', hit: !!d.memberId },
|
||
{ label: 'Surge', sub: 'first buyer · 50%', hit: b >= 1 },
|
||
{ label: 'Circuit', sub: '2 buyers · +20%', hit: b >= 2 },
|
||
{ label: 'Nexus', sub: '5 buyers · +10%', hit: b >= 5 }
|
||
];
|
||
const cur = steps.findIndex(s => !s.hit);
|
||
el.innerHTML = steps.map((s, i) =>
|
||
'<div class="nc-step' + (s.hit ? ' hit' : i === cur ? ' cur' : '') + '">'
|
||
+ '<span class="dot">' + (s.hit ? '✓' : i + 1) + '</span>'
|
||
+ '<span class="lb">' + s.label + '<i>' + s.sub + '</i></span></div>').join('');
|
||
}
|
||
// ── overview v3 charts: hand-rolled SVG/CSS, real data only ──
|
||
const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
||
const CH = { mint: '#43e8c3', cyan: '#54ccff', violet: '#9d7dff', amber: '#ffb238', track: 'rgba(139,166,156,.18)' };
|
||
function bars(el, xel, data) { // data: [{v,label,tip,alt}]
|
||
if (!el) return;
|
||
const max = Math.max(1, ...data.map(d => d.v));
|
||
el.innerHTML = data.length
|
||
? data.map(d => '<div class="bar' + (d.alt ? ' alt' : '') + (d.v ? '' : ' empty') + '" style="height:'
|
||
+ Math.max(4, Math.round(d.v / max * 100)) + '%" data-v="' + esc(d.tip) + '"></div>').join('')
|
||
: '<div class="bar empty" style="height:4%"></div>'.repeat(8);
|
||
if (xel) xel.innerHTML = data.map(d => '<span>' + esc(d.label) + '</span>').join('');
|
||
}
|
||
function donut(el, segs, center) { // segs sum to the ring; r=15.9155 → circumference 100
|
||
if (!el) return;
|
||
const total = segs.reduce((s, x) => s + x.v, 0);
|
||
let off = 25, out = '<circle cx="21" cy="21" r="15.9155" fill="none" stroke="' + CH.track + '" stroke-width="5"></circle>';
|
||
if (total > 0) for (const s of segs) {
|
||
const len = s.v / total * 100;
|
||
if (len <= 0 || s.color === 'none') { off -= Math.max(0, len); continue; }
|
||
out += '<circle cx="21" cy="21" r="15.9155" fill="none" stroke="' + s.color + '" stroke-width="5" '
|
||
+ 'stroke-dasharray="' + len + ' ' + (100 - len) + '" stroke-dashoffset="' + off + '"></circle>';
|
||
off -= len;
|
||
}
|
||
out += '<text x="21" y="20.5" text-anchor="middle" fill="currentColor" font-size="6.5" font-weight="800" class="donut-center">' + esc(center.big) + '</text>'
|
||
+ '<text x="21" y="26.5" text-anchor="middle" fill="#8ba69c" font-size="3.1">' + esc(center.small) + '</text>';
|
||
el.innerHTML = out;
|
||
}
|
||
function legend(el, rows) {
|
||
if (el) el.innerHTML = rows.map(r => '<div><i style="background:' + r.color + '"></i>'
|
||
+ esc(r.label) + ' <b>' + esc(r.v) + '</b></div>').join('');
|
||
}
|
||
async function loadCharts(d) {
|
||
// credit composition donut + today's viewing ring (live balances)
|
||
try {
|
||
const st = await (await fetch('/api/my/earn')).json();
|
||
if (!st.error) {
|
||
// 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();
|
||
$('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; }
|
||
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 = '<p class="muted small">Training materials are being added. Check back soon.</p>'; return; }
|
||
// section pills at the top: one per group, plus All; the active pill filters the list
|
||
const groups = [...new Set(items.map(it => it.group).filter(Boolean))];
|
||
const pills = $('trainingPills');
|
||
const want = loadTraining.filter || 'all';
|
||
if (pills) {
|
||
pills.hidden = groups.length < 2;
|
||
pills.innerHTML = ['all', ...groups].map(g => '<button type="button" class="chip-t' + ((g === want) ? ' on' : '') + '" data-tg="' + esc(g) + '">' + esc(g === 'all' ? 'All' : g) + '</button>').join('');
|
||
pills.querySelectorAll('[data-tg]').forEach(b => b.addEventListener('click', () => { loadTraining.filter = b.dataset.tg; loadTraining(); }));
|
||
}
|
||
const shown = want === 'all' ? items : items.filter(it => it.group === want);
|
||
let lastGroup = null;
|
||
el.innerHTML = shown.map(it => {
|
||
// optional section header: entries carry a `group`; a header renders when it changes
|
||
let head = '';
|
||
if (it.group && it.group !== lastGroup) { lastGroup = it.group; head = '<h3 class="tr-group" style="margin:26px 0 6px;font-size:13px;letter-spacing:.12em;text-transform:uppercase;color:var(--muted)">' + esc(it.group) + '</h3>'; }
|
||
const isVid = it.videoUrl && /\.(mp4|webm)(\?|$)/i.test(it.videoUrl);
|
||
// poster: the admin list can carry one; otherwise assume a .jpg next to the .mp4 (the pipeline uploads both)
|
||
const poster = it.posterUrl || (isVid ? it.videoUrl.replace(/\.(mp4|webm)(\?.*)?$/i, '.jpg$2') : '');
|
||
const media = isVid ? '<video src="' + esc(it.videoUrl) + '"' + (poster ? ' poster="' + esc(poster) + '"' : '') + ' preload="metadata" controls playsinline style="width:100%;max-width:640px;border-radius:12px;background:#000"></video>' : '';
|
||
const links = [];
|
||
if (it.videoUrl && !isVid) links.push('<a class="btn small sec" href="' + esc(it.videoUrl) + '" target="_blank" rel="noopener">Watch</a>');
|
||
if (it.docUrl) links.push('<a class="btn small sec" href="' + esc(it.docUrl) + '" target="_blank" rel="noopener">Open material</a>');
|
||
return head + '<div class="card"><h3>' + esc(it.title || 'Lesson') + '</h3>'
|
||
+ (it.desc ? '<p class="muted small">' + esc(it.desc) + '</p>' : '')
|
||
+ (media ? '<p style="margin:10px 0">' + media + '</p>' : '')
|
||
+ (links.length ? '<p>' + links.join(' ') + '</p>' : '') + '</div>';
|
||
}).join('');
|
||
} catch (e) {}
|
||
}
|
||
// visual line tree on the Overview: YOU + three levels, qualified directs in gold
|
||
async function loadLineTree(buyerCount) {
|
||
try {
|
||
const r = await (await fetch('/api/my/line')).json();
|
||
const el = $('lineTree'); if (!el) return;
|
||
const levels = r.levels || [];
|
||
const total = levels.reduce((n, L) => n + L.members.length, 0);
|
||
if ($('treeSub')) $('treeSub').textContent = total ? total + ' across 3 levels' : 'share your link to grow';
|
||
const chip = (m, q) => '<span class="lt-node' + (q ? ' qualified' : (m.email ? '' : ' deep')) + '" title="' + esc(m.name || 'member') + (q ? ' · qualified buyer' : '') + '">' + esc(String(m.name || 'M').replace(/^@/, '').slice(0, m.own ? 20 : 14)) + '</span>';
|
||
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)).join('');
|
||
if (lvl <= 2 && members.length < (lvl === 1 ? 2 : 4)) html += '<span class="lt-node open">+ open</span>';
|
||
if (!html) html = '<span class="lt-node open">+ open</span>';
|
||
return '<div class="lt-row"><span class="lt-cap">L' + lvl + '</span><div class="lt-nodes">' + html + '</div></div>';
|
||
};
|
||
el.innerHTML = '<div class="lt-top"><span class="lt-you">YOU</span></div><div class="lt-stem"></div>' + rowFor(1) + rowFor(2) + rowFor(3);
|
||
} catch (e) {}
|
||
}
|
||
async function loadDashboard() {
|
||
try {
|
||
const d = await (await fetch('/api/my/dashboard')).json();
|
||
if (d.error) return;
|
||
chatSync(d);
|
||
MYID = d.memberId || MYID;
|
||
startLiveFeed();
|
||
if (!window.__lbChecked) { // daily login bonus, once per session (server guards once/day)
|
||
window.__lbChecked = true;
|
||
api('/api/my/login-bonus').then(r => {
|
||
if (r && r.granted > 0) {
|
||
IAP.status('🎁 Daily login bonus: +' + r.granted + ' credits' + (r.streak > 1 ? ' · ' + r.streak + '-day streak' : '') + '!', 'ok');
|
||
playSound('chaching'); setTimeout(loadDashboard, 900);
|
||
}
|
||
}).catch(() => {});
|
||
}
|
||
const 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;
|
||
$('dbTeam').textContent = (d.referrals || []).length;
|
||
// trend chips: only real, computable facts
|
||
const ec = $('dbEarnedChip');
|
||
if (ec && d.earnCount) { ec.hidden = false; ec.textContent = d.earnCount + ' instant payout' + (d.earnCount > 1 ? 's' : ''); }
|
||
const bc = $('dbBuyersChip');
|
||
if (bc && d.memberId) {
|
||
bc.hidden = false;
|
||
bc.textContent = (d.buyerCount || 0) >= 5 ? 'level 3 open' : (d.buyerCount || 0) >= 2 ? 'level 2 open' : 'level 2 at 2 buyers';
|
||
}
|
||
const tc = $('dbTeamChip'), wk = (d.referrals || []).filter(r => Date.now() - new Date(r.joined) < 6048e5).length;
|
||
if (tc && wk) { tc.hidden = false; tc.textContent = '+' + wk + ' this week'; }
|
||
setInboxBadge(d.inboxUnread || 0);
|
||
if (d.sponsorMsg) showSponsorModal(d.sponsorMsg);
|
||
renderBadges(d);
|
||
loadFeatured();
|
||
loadLineTree(d.buyerCount || 0);
|
||
loadCharts(d);
|
||
$('nextMove').textContent = nextMove(d);
|
||
renderSteps(d);
|
||
const wrap = $('rosterWrap');
|
||
if ((d.referrals || []).length) {
|
||
$('rosterEmpty').hidden = true;
|
||
let t = wrap.querySelector('table');
|
||
if (t) t.remove();
|
||
t = document.createElement('table');
|
||
t.className = 'roster';
|
||
t.innerHTML = d.referrals.map(r => '<tr><td>' + String(r.name || r.email || '').replace(/[&<>]/g, '') + '</td>'
|
||
+ '<td>' + new Date(r.joined).toLocaleDateString() + '</td>'
|
||
+ '<td><span class="badge' + (r.status === 'joined free' ? ' amber' : '') + '">' + r.status + '</span></td></tr>').join('');
|
||
wrap.appendChild(t);
|
||
}
|
||
// overview recent-activity widget: chain events + line joins, newest first
|
||
try {
|
||
const c = await IAP.getConfig();
|
||
const ov = $('ovFeed');
|
||
const rows = [];
|
||
if (d.memberId) {
|
||
const a = await (await fetch('/api/my/activity')).json();
|
||
for (const ev of [...(a.earnings || []), ...(a.purchases || [])]
|
||
.sort((x, y) => y.block - x.block).slice(0, 5)) rows.push(IAP.feedRow(ev, c));
|
||
}
|
||
for (const r of (d.referrals || []).slice(0, 3)) {
|
||
const div = document.createElement('div');
|
||
div.className = 'row';
|
||
div.innerHTML = '<span>🤝 ' + esc(r.name || r.email || 'A new member') + ' joined your line</span><span class="tx muted small">'
|
||
+ new Date(r.joined).toLocaleDateString() + '</span>';
|
||
rows.push(div);
|
||
}
|
||
if (rows.length) {
|
||
ov.innerHTML = '';
|
||
rows.slice(0, 6).forEach(r => ov.appendChild(r));
|
||
}
|
||
} catch (e) {}
|
||
// ready-to-send share message + promo tools, personalized
|
||
const link = location.origin + '/join/' + (d.username || d.refCode || d.memberId || '');
|
||
fillPromo(link, d);
|
||
// 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 ? '<b>Launch ready: ' + done + ' of ' + items.length + '.</b> ' + (at && Date.now() < at ? 'Doors open ' + new Date(at).toLocaleString([], { weekday: 'short', hour: 'numeric', minute: '2-digit' }) + '. ' : '') + '<a href="/launch">Open the founding-week checklist</a>' : ''; }
|
||
} 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 = '<b>' + tw.count + (tw.count === 1 ? ' person is' : ' people are') + ' waiting for a sponsor in the holding tank</b> \u00b7 '
|
||
+ tw.names.map(esc).join(', ') + (tw.count > tw.names.length ? ' and more' : '') + '. <a href="#line">Adopt them from My line</a>'
|
||
+ (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', 'buy', 'campaigns', 'earn', 'earnings', 'promo', 'training', 'wallet', 'profile'];
|
||
const TITLES = { overview: 'Overview', line: 'My line', buy: 'Buy packages', campaigns: 'Campaigns',
|
||
earn: 'Earn credits', earnings: 'Earnings', promo: 'Promo tools', training: 'Training',
|
||
wallet: 'Wallet & account', profile: 'Profile' };
|
||
function setPane(name) {
|
||
if (!PANES.includes(name)) name = 'overview';
|
||
for (const p of PANES) {
|
||
const el = $('pane-' + p);
|
||
if (el) el.hidden = p !== name;
|
||
}
|
||
document.querySelectorAll('.bo-menu [data-pane]').forEach(b =>
|
||
b.classList.toggle('on', b.dataset.pane === name));
|
||
if ($('boTitle')) $('boTitle').textContent = TITLES[name];
|
||
// 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
|
||
if (name === 'profile') loadLineBanner();
|
||
if (name === 'line') { loadLineage(); loadUplineMessages(); loadCoach(); loadLinkStats(); loadProspects(); }
|
||
if (name === 'campaigns') ['cTarget', 'cImage', 'cVideoUrl'].forEach(id => { if ($(id)) $(id).value = ''; }); // no residual URL between visits
|
||
if (name === 'training') loadTraining();
|
||
document.getElementById('memberArea').classList.remove('side-open'); // close mobile drawer
|
||
if (location.hash !== '#' + name) history.replaceState(null, '', '#' + name);
|
||
}
|
||
document.querySelectorAll('.bo-menu [data-pane]').forEach(b =>
|
||
b.addEventListener('click', () => setPane(b.dataset.pane)));
|
||
document.querySelectorAll('.qa [data-goto]').forEach(b =>
|
||
b.addEventListener('click', () => setPane(b.dataset.goto)));
|
||
const qaCopy = document.getElementById('qaCopyInvite');
|
||
if (qaCopy) qaCopy.addEventListener('click', async () => {
|
||
const link = document.getElementById('inviteLine').textContent;
|
||
if (!link || !link.startsWith('http')) { setPane('line'); return; }
|
||
try { await navigator.clipboard.writeText(link); IAP.status('Invite link copied.', 'ok'); }
|
||
catch (e) { setPane('line'); }
|
||
});
|
||
window.addEventListener('hashchange', () => setPane(location.hash.slice(1)));
|
||
if ($('boBurger')) $('boBurger').addEventListener('click', () =>
|
||
document.getElementById('memberArea').classList.toggle('side-open'));
|
||
|
||
async function render() {
|
||
// 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;
|
||
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('<span style="display:inline-flex;align-items:center;gap:8px"><span aria-hidden="true" style="display:inline-flex;align-items:center;justify-content:center;width:20px;height:20px;border-radius:50%;background:#22c55e;color:#04140a;font-weight:900;font-size:13px;line-height:1">✓</span><b style="color:#22c55e">Wallet connected</b> <span class="mono">' + me.address.slice(0, 8) + '…' + me.address.slice(-6) + '</span></span>');
|
||
else who.push('<span style="display:inline-flex;align-items:center;gap:8px"><span aria-hidden="true" style="display:inline-flex;align-items:center;justify-content:center;width:20px;height:20px;border-radius:50%;border:2px solid var(--muted);color:var(--muted);font-weight:900;font-size:12px;line-height:1">!</span><b>No wallet connected yet</b> <span class="muted">(link one below)</span></span>');
|
||
if (me.memberId) who.push('on-chain <b>member #' + me.memberId + '</b>'
|
||
+ (me.onchainSponsorId ? ', sponsored by #' + me.onchainSponsorId : ''));
|
||
else if (me.sponsorId) who.push('invited by member #' + me.sponsorId);
|
||
$('posLine').innerHTML = who.join('<br>');
|
||
|
||
$('creditLine').textContent = (me.credits || 0).toLocaleString();
|
||
loadPositions(me);
|
||
$('linkCard').hidden = !!me.address;
|
||
$('activateCard').hidden = !(me.address && !me.memberId);
|
||
$('activityArea').hidden = !me.memberId;
|
||
$('campGate').hidden = true; // earned credits fund campaigns for everyone
|
||
$('campaignCard').hidden = false;
|
||
loadCampaigns();
|
||
|
||
// profile pane state
|
||
$('pfCurrent').textContent = me.username ? '@' + me.username + ' is your permanent username. Your invite link, your public page and any banners you shared carry it, so it cannot be changed.' : 'No username yet. Members see you as a number until you pick one. Choose carefully: it is permanent once saved.';
|
||
if (!$('pfUsername').value) $('pfUsername').value = me.username || '';
|
||
$('pfUsername').disabled = !!me.username; $('pfSaveBtn').hidden = !!me.username;
|
||
$('pfDetails').innerHTML = 'Email: ' + (me.email || 'none') + '<br>Wallet: '
|
||
+ (me.address ? '<span class="mono">' + me.address.slice(0, 10) + '…' + me.address.slice(-6) + '</span>' : 'not linked yet')
|
||
+ '<br>On-chain member: ' + (me.memberId ? '#' + me.memberId : 'not yet');
|
||
// the share link works from day one; usernames make it a vanity link
|
||
if (me.username || me.refCode || me.memberId) {
|
||
$('inviteLine').textContent = location.origin + '/join/' + (me.username || me.refCode || me.memberId);
|
||
$('copyInvite').hidden = false;
|
||
} else {
|
||
$('inviteLine').textContent = 'Sign in with your email to get your link.';
|
||
$('copyInvite').hidden = true;
|
||
}
|
||
if (me.memberId) {
|
||
const bc = me.buyerCount || 0;
|
||
$('qualLine').innerHTML = '<b>' + bc + '</b> qualifying buyer(s) referred<br>'
|
||
+ (bc >= 5 ? '<span class="badge">Level 3 unlocked: full three-level earnings</span>'
|
||
: bc >= 2 ? '<span class="badge">Level 2 unlocked</span> · ' + (5 - bc) + ' more for level 3'
|
||
: (2 - bc) + ' more buyer(s) of $20+ unlock level 2');
|
||
loadActivity();
|
||
} else {
|
||
$('qualLine').innerHTML = 'Share your link now. Then switch on payouts (free, above) '
|
||
+ '<b>before your people start buying</b>: the contract locks each buyer to their sponsor at '
|
||
+ 'their first purchase, and payments only route to wallets that are switched on.';
|
||
}
|
||
}
|
||
|
||
async function loadActivity() {
|
||
try {
|
||
const c = await IAP.getConfig();
|
||
const a = await (await fetch('/api/my/activity')).json();
|
||
const fill = (id, evs, empty) => {
|
||
const el = $(id);
|
||
el.innerHTML = '';
|
||
if (!evs || !evs.length) { el.innerHTML = '<div class="row muted">' + empty + '</div>'; return; }
|
||
for (const ev of evs) el.appendChild(IAP.feedRow(ev, c));
|
||
};
|
||
fill('earnFeed', a.earnings, 'No payouts yet. They appear here the moment one lands.');
|
||
fill('refFeed', a.referrals, 'No referral activity yet. Share your invite link.');
|
||
fill('buyFeed', a.purchases, 'No purchases from your wallet yet.');
|
||
} catch (e) {}
|
||
}
|
||
|
||
async function loadCampaigns() {
|
||
try {
|
||
const r = await (await fetch('/api/my/campaigns')).json();
|
||
if (r.error) return;
|
||
lastRates = r.rates;
|
||
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 => '<option value="' + s.id + '">' + s.label + '</option>').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 => '<span><b>' + x[0] + '</b> ' + x[1] + '</span>').join('');
|
||
}
|
||
$('rateLine').textContent = 'Available to spend: ' + r.availableCredits.toLocaleString()
|
||
+ (r.earnedCredits ? ' (' + r.purchasedCredits.toLocaleString() + ' purchased + ' + r.earnedCredits + ' earned)' : '')
|
||
+ (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 = '<p class="muted small">No campaigns yet. Launch your first below.</p>'; return; }
|
||
const tbl = document.createElement('div');
|
||
tbl.className = 'tablewrap';
|
||
tbl.innerHTML = '<table><thead><tr><th>Name</th><th>Type</th><th class="num">Views here</th><th class="num" title="Impressions delivered by Network Ad Space across the wider network (banner and text ads only)">Network views</th><th class="num">Clicks</th>'
|
||
+ '<th class="num">Spent</th><th class="num">Budget</th><th>Status</th><th></th></tr></thead><tbody>'
|
||
+ r.campaigns.map(c => '<tr><td><b>' + c.name + '</b>' + hourBars(r.hours && r.hours[c.id]) + geoLine(r.geo && r.geo[c.id]) + '</td>'
|
||
+ '<td>' + c.type + (c.type === 'banner' && c.width ? ' <span class="muted small">' + c.width + '×' + c.height + '</span>' : '') + (c.dailyCap ? ' <span class="muted small" title="daily cap: ' + c.dailyCap + ' credits, ' + (c.daySpent || 0) + ' spent today">cap ' + c.dailyCap + '/day</span>' : '') + (c.geo ? ' <span class="muted small" title="shown only to viewers in these country tiers">tier ' + esc(c.geo.replace(/,/g, '+')) + '</span>' : '') + schedChips(c) + '</td>'
|
||
+ '<td class="num">' + c.imps.toLocaleString() + '</td>'
|
||
+ '<td class="num">' + (['banner', 'text'].includes(c.type) ? (c.impsNas || 0).toLocaleString() : '<span class="muted small" title="only banner and text ads syndicate to the network">n/a</span>') + '</td>'
|
||
+ '<td class="num">' + c.clicks + (r.clickSources && r.clickSources[c.id] ? '<div class="muted small" style="white-space:nowrap" title="where the clicks happened">' + 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' : '') + '</div>' : '') + '</td>'
|
||
+ '<td class="num">' + c.spent + '</td><td class="num">' + c.budget + '</td>'
|
||
+ '<td>' + (c.status === 'out' ? '<span class="badge amber">budget spent</span>' : c.status === 'done' ? '<span class="muted">ended</span>' : c.scheduled ? '<span class="badge">scheduled</span>' : c.status) + '</td>'
|
||
+ '<td>' + (c.status === 'active' ? '<button class="btn small sec" data-camp="' + c.id + '" data-act="pause">Pause</button>'
|
||
: c.status === 'paused' ? '<button class="btn small sec" data-camp="' + c.id + '" data-act="resume">Resume</button>' : '')
|
||
+ ' <button class="btn small sec" data-topup="' + c.id + '">Buy more views</button>'
|
||
+ '</td></tr>').join('') + '</tbody></table>';
|
||
el.appendChild(tbl);
|
||
el.querySelectorAll('button[data-camp]').forEach(b => b.addEventListener('click', async () => {
|
||
try { await api('/api/my/campaigns/' + b.dataset.camp + '/' + b.dataset.act); await loadCampaigns(); }
|
||
catch (e) { IAP.status(e.message, 'bad'); }
|
||
}));
|
||
el.querySelectorAll('button[data-topup]').forEach(b => b.addEventListener('click', async () => {
|
||
const n = 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'
|
||
? '<video src="' + r.url + '" controls playsinline></video><p><br></p>'
|
||
: '<img src="' + r.url + '" alt=""><p><br></p>');
|
||
$('edMediaInfo').textContent = f.name + ' inserted';
|
||
} catch (e) { $('edMediaInfo').textContent = 'Upload failed. Try again.'; }
|
||
$('cSoloFile').value = '';
|
||
});
|
||
// raw-text toggle: swap the WYSIWYG surface for the underlying HTML and back
|
||
$('edRawBtn').addEventListener('click', () => {
|
||
const ed = $('cSoloEd'), raw = $('cSoloRaw');
|
||
if (raw.hidden) { raw.value = ed.innerHTML; raw.hidden = false; ed.hidden = true; $('edRawBtn').textContent = 'Visual'; }
|
||
else { ed.innerHTML = raw.value; ed.hidden = false; raw.hidden = true; $('edRawBtn').textContent = 'Raw text'; }
|
||
});
|
||
const WHERE = { banner: 'Runs in the ad viewer, on the home page, the live ledger, every Overview, the sidebar tile, and out in the Network Ad Space rotation across the wider network.',
|
||
text: 'Runs in the ad viewer, the live ledger text slot, and out in the Network Ad Space rotation.',
|
||
login: 'Full screen for every member who signs in, ten seconds, once a day per member. Opens in a fresh tab, so any working page qualifies. 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 =>
|
||
'<option value="' + dys + '">' + dys + ' day' + (dys > 1 ? 's' : '') + ' — ' + (dys * lastRates.featuredPerDay) + ' credits</option>').join('');
|
||
let s = null;
|
||
try { s = await (await fetch('/api/featured/stats')).json(); } catch (e) {}
|
||
if (s && s.occupancy) {
|
||
const grid = $('cFeatDaysGrid');
|
||
grid.innerHTML = s.occupancy.map(d => {
|
||
const label = d.offset === 0 ? 'Today' : d.offset === 1 ? 'Tomorrow' : new Date(d.day + 'T00:00:00Z').toLocaleDateString(undefined, { weekday: 'short', month: 'numeric', day: 'numeric' });
|
||
const full = d.open <= 0;
|
||
return '<div class="feat-day' + (d.offset === featStartDay ? ' on' : '') + (full ? ' full' : d.open > d.cap / 2 ? ' open2' : '') + '" data-off="' + d.offset + '"' + (full ? '' : '') + '>'
|
||
+ '<div class="fd-day">' + label + '</div><div class="fd-occ">' + d.count + '/' + d.cap + (full ? ' full' : ' left ' + d.open) + '</div></div>';
|
||
}).join('');
|
||
grid.querySelectorAll('.feat-day:not(.full)').forEach(el =>
|
||
el.addEventListener('click', () => { featStartDay = Number(el.dataset.off); featHint(); }));
|
||
}
|
||
const dys = Number($('cFeatDays').value) || (lastRates.featuredDurations[0]);
|
||
const dayLabel = featStartDay === 0 ? 'today' : featStartDay === 1 ? 'tomorrow' : 'in ' + featStartDay + ' days';
|
||
$('cFeatHint').textContent = 'Runs ' + dys + ' day' + (dys > 1 ? 's' : '') + ' starting ' + dayLabel
|
||
+ ' for ' + (dys * lastRates.featuredPerDay) + ' credits. Max ' + (s ? s.slotsPerDay : 10) + ' links share any day.';
|
||
}
|
||
document.addEventListener('change', e => { if (e.target && e.target.id === 'cFeatDays') featHint(); });
|
||
function visitHint() {
|
||
if (!lastRates) return;
|
||
const n = Number($('cVisitCount').value) || 0;
|
||
const cost = n * (lastRates.visitCostPerVisit || 3);
|
||
$('cVisitHint').textContent = (lastRates.visitCostPerVisit || 3) + ' credits per verified visit'
|
||
+ (n >= (lastRates.visitMinPack || 20) ? ' — ' + n + ' visits = ' + cost + ' credits' : ' (min ' + (lastRates.visitMinPack || 20) + ')')
|
||
+ '. Each is a unique member, dwell + human-check verified.';
|
||
}
|
||
$('cVisitCount').addEventListener('input', visitHint);
|
||
// video composer: tier dropdown + upload + price hint
|
||
function videoHint() {
|
||
if (!lastRates || !lastRates.videoTiers) return;
|
||
if ($('cWatchSecs') && !$('cWatchSecs').options.length)
|
||
$('cWatchSecs').innerHTML = lastRates.videoTiers.map(t =>
|
||
'<option value="' + t.secs + '">Watch ' + t.secs + 's — ' + t.cost + ' credits/view (viewer earns ' + t.reward + ')</option>').join('');
|
||
const tier = lastRates.videoTiers.find(t => t.secs === Number($('cWatchSecs').value)) || lastRates.videoTiers[0];
|
||
const n = tier ? Math.floor((Number($('cBudget').value) || 0) / tier.cost) : 0;
|
||
$('cVideoHint').textContent = tier ? (tier.cost + ' credits per completed ' + tier.secs + 's view'
|
||
+ (n ? ' — this budget buys ' + n + ' views' : '') + '. Viewers earn ' + tier.reward + ' credits each, so they watch.') : '';
|
||
}
|
||
document.addEventListener('change', e => { if (e.target && e.target.id === 'cWatchSecs') videoHint(); });
|
||
$('cBudget').addEventListener('input', () => { if ($('cType').value === 'video') videoHint(); });
|
||
$('cVideoUploadBtn').addEventListener('click', () => $('cVideoFile').click());
|
||
$('cVideoUrl').addEventListener('change', async () => {
|
||
const url = $('cVideoUrl').value.trim();
|
||
$('cVideoInfo').textContent = url ? 'checking video…' : '';
|
||
cVidDims = url ? await probeVideoDims(url) : null;
|
||
if (url && !cVidDims) $('cVideoInfo').textContent = 'could not read that video';
|
||
else { $('cVideoInfo').textContent = ''; showVidOrient(); }
|
||
});
|
||
$('cVideoFile').addEventListener('change', async () => {
|
||
const f = $('cVideoFile').files[0];
|
||
if (!f) return;
|
||
$('cVideoInfo').textContent = 'Uploading ' + f.name + '… (large files take a moment)';
|
||
try {
|
||
const r = await (await fetch('/api/my/upload', { method: 'POST', headers: { 'Content-Type': f.type }, body: f })).json();
|
||
if (r.error) { $('cVideoInfo').textContent = r.error; $('cVideoFile').value = ''; return; }
|
||
$('cVideoUrl').value = r.url;
|
||
$('cVideoInfo').textContent = f.name + ' uploaded';
|
||
$('cVideoPrev').hidden = false;
|
||
$('cVideoPrev').innerHTML = '<video src="' + r.url + '" controls style="max-width:320px;border-radius:10px"></video>';
|
||
cVidDims = await probeVideoDims(r.url); showVidOrient();
|
||
} catch (e) { $('cVideoInfo').textContent = 'Upload failed. Try again.'; }
|
||
$('cVideoFile').value = '';
|
||
});
|
||
$('createCampBtn').addEventListener('click', busy2($('createCampBtn'), async () => {
|
||
// in raw mode the source of truth is the textarea; sync it back first
|
||
let soloBody = $('cSoloEd').innerHTML;
|
||
if ($('cSoloRaw') && !$('cSoloRaw').hidden) soloBody = $('cSoloRaw').value;
|
||
const t = $('cType').value;
|
||
const isVideo = t === 'video', isFeat = t === 'featured';
|
||
if (isVideo && !cVidDims && $('cVideoUrl').value) cVidDims = await probeVideoDims($('cVideoUrl').value);
|
||
await api('/api/my/campaigns', { type: t, name: $('cName').value,
|
||
targetUrl: $('cTarget').value, imageUrl: $('cImage').value, size: $('cSize').value,
|
||
title: isVideo ? $('cVideoTitle').value : isFeat ? $('cFeatTitle').value : t === 'visits' ? $('cVisitTitle').value : $('cTitle').value,
|
||
body: t === 'solo' ? soloBody : $('cBody').value,
|
||
videoUrl: $('cVideoUrl').value, watchSecs: Number($('cWatchSecs').value),
|
||
videoW: cVidDims ? cVidDims.w : null, videoH: cVidDims ? cVidDims.h : null,
|
||
days: Number($('cFeatDays').value), startDay: featStartDay,
|
||
count: Number($('cVisitCount').value),
|
||
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 = '<span class="muted small">That\'s today\'s visits. Come back tomorrow.</span>';
|
||
} catch (e) {}
|
||
}
|
||
async function loadVisit() {
|
||
$('vsCheck').hidden = true; $('vsVisit').hidden = true; $('vsHint').textContent = '';
|
||
let r = null;
|
||
try { r = await (await fetch('/api/my/visits')).json(); } catch (e) {}
|
||
if (!r || !r.ad) {
|
||
$('vsBox').innerHTML = '<span class="muted small">' + (r && r.status && r.status.count >= r.status.cap
|
||
? 'That\'s today\'s visits. Come back tomorrow.' : 'No verified-visit packs are running right now. Check back soon.') + '</span>';
|
||
return;
|
||
}
|
||
visState.token = r.token; visState.id = r.ad.id; visState.dwell = r.status.dwell || 8;
|
||
$('vsBox').innerHTML = '<b>' + esc(r.ad.title || 'Member site') + '</b><br><span class="muted small">Open the site and stay ' + visState.dwell + 's.</span>';
|
||
const visit = $('vsVisit');
|
||
visit.href = r.ad.url; visit.hidden = false;
|
||
$('vsStartBtn').textContent = 'Loading…'; $('vsStartBtn').disabled = true;
|
||
// opening the site starts the dwell; then we ask the human-check
|
||
visit.onclick = () => {
|
||
let left = visState.dwell;
|
||
$('vsHint').textContent = 'Counting your visit: ' + left + 's';
|
||
const t = setInterval(async () => {
|
||
left--;
|
||
if (left > 0) { $('vsHint').textContent = 'Counting your visit: ' + left + 's'; return; }
|
||
clearInterval(t);
|
||
$('vsHint').textContent = 'One quick check to count the visit:';
|
||
let c = await (await fetch('/api/my/visitchallenge?token=' + visState.token)).json();
|
||
if (c.early) { await new Promise(r2 => setTimeout(r2, (c.wait || 1) * 1000 + 300)); c = await (await fetch('/api/my/visitchallenge?token=' + visState.token)).json(); }
|
||
if (c.error) { $('vsHint').textContent = c.error; return; }
|
||
renderVisitCheck(c);
|
||
}, 1000);
|
||
};
|
||
$('vsStartBtn').textContent = 'Next visit'; $('vsStartBtn').disabled = false;
|
||
}
|
||
function renderVisitCheck(c) {
|
||
$('vsPrompt').textContent = 'Click the ' + c.prompt + ':';
|
||
const w = $('vsOpts'); w.innerHTML = '';
|
||
c.options.forEach((em, i) => {
|
||
const b = document.createElement('button');
|
||
b.className = 'btn small sec'; b.style.marginRight = '6px'; b.textContent = em;
|
||
b.addEventListener('click', () => answerVisit(i));
|
||
w.appendChild(b);
|
||
});
|
||
$('vsCheck').hidden = false;
|
||
}
|
||
async function answerVisit(i) {
|
||
const r = await (await fetch('/api/my/visitdone', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token: visState.token, answer: i }) })).json();
|
||
if (r.error) {
|
||
if (r.retry) { const c = await (await fetch('/api/my/visitchallenge?token=' + visState.token)).json(); if (!c.error) return renderVisitCheck(c); }
|
||
$('vsHint').textContent = r.error; $('vsCheck').hidden = true; return;
|
||
}
|
||
$('vsCheck').hidden = true;
|
||
$('vsHint').textContent = '+' + r.credited + ' credits — visit counted. Load the next one.';
|
||
$('vsProgress').textContent = 'today: ' + (r.status.count || 0) + ' / ' + (r.status.cap || 0) + ' verified visits';
|
||
IAP.status('+' + r.credited + ' credits for a verified visit.', 'ok');
|
||
loadDashboard();
|
||
}
|
||
$('vsStartBtn').addEventListener('click', () => loadVisit());
|
||
|
||
// ── watch-to-earn videos: escape-proof player, server-clock reward ──
|
||
const vidState = { token: null, secs: 0, maxSeen: 0, done: false, credited: false };
|
||
// detected dimensions of the video being created — portrait goes to Shorts, landscape to Watch videos
|
||
let cVidDims = null;
|
||
function probeVideoDims(url) {
|
||
return new Promise(resolve => {
|
||
if (!url) return resolve(null);
|
||
const v = document.createElement('video'); v.preload = 'metadata'; v.muted = true;
|
||
let done = false; const fin = r => { if (!done) { done = true; resolve(r); } };
|
||
v.onloadedmetadata = () => fin(v.videoWidth && v.videoHeight ? { w: v.videoWidth, h: v.videoHeight } : null);
|
||
v.onerror = () => fin(null); setTimeout(() => fin(null), 8000); v.src = url;
|
||
});
|
||
}
|
||
function showVidOrient() {
|
||
const el = $('cVideoInfo'); if (!el) return;
|
||
if (!cVidDims) return;
|
||
const portrait = cVidDims.h > cVidDims.w;
|
||
el.textContent = (el.textContent ? el.textContent + ' · ' : '')
|
||
+ (portrait ? 'portrait — shows in the Shorts reel' : 'landscape — shows in the Watch videos tab');
|
||
}
|
||
async function loadVideoStatus() {
|
||
try {
|
||
const st = await (await fetch('/api/my/videos?orientation=landscape')).json();
|
||
if (st.error) return;
|
||
$('vidProgress').textContent = 'today: ' + (st.status.count || 0) + ' / ' + st.status.cap + ' videos watched';
|
||
if (st.status.left <= 0) {
|
||
$('vidBox').innerHTML = '<span class="muted small">That is today\'s video set. Come back tomorrow.</span>';
|
||
$('vidStartBtn').hidden = true;
|
||
return;
|
||
}
|
||
$('vidStartBtn').hidden = false;
|
||
} catch (e) {}
|
||
}
|
||
async function loadVideoAd() {
|
||
let r = null;
|
||
try { r = await (await fetch('/api/my/videos?orientation=landscape')).json(); } catch (e) {}
|
||
if (!r || !r.ad) {
|
||
$('vidBox').innerHTML = '<span class="muted small">' + (r && r.status && r.status.left <= 0
|
||
? 'That is today\'s video set. Come back tomorrow.'
|
||
: 'No member videos are live right now. Check back when a campaign is running.') + '</span>';
|
||
$('vidWrap').hidden = true;
|
||
return;
|
||
}
|
||
const ad = r.ad;
|
||
vidState.token = r.token; vidState.secs = ad.watchSecs; vidState.maxSeen = 0; vidState.done = false; vidState.credited = false;
|
||
$('vidBox').hidden = true;
|
||
$('vidWrap').hidden = false;
|
||
$('vidCta').hidden = true;
|
||
$('vidHint').textContent = ad.title ? 'Now playing: ' + ad.title : '';
|
||
const p = $('vidPlayer');
|
||
p.src = ad.videoUrl;
|
||
p.currentTime = 0;
|
||
// escape-proof: no seeking past what's been watched; track max reached
|
||
p.onseeking = () => { if (p.currentTime > vidState.maxSeen + 0.5) p.currentTime = vidState.maxSeen; };
|
||
p.ontimeupdate = () => {
|
||
if (p.currentTime > vidState.maxSeen) vidState.maxSeen = p.currentTime;
|
||
const left = Math.max(0, Math.ceil(vidState.secs - vidState.maxSeen));
|
||
$('vidTimer').textContent = left > 0 ? 'Watch ' + left + 's more to earn' : 'Watch time met — finishing…';
|
||
if (!vidState.done && vidState.maxSeen >= vidState.secs) { vidState.done = true; completeVideo(ad); }
|
||
};
|
||
$('vidStartBtn').textContent = 'Playing…';
|
||
$('vidStartBtn').disabled = true;
|
||
try { await p.play(); } catch (e) { $('vidStartBtn').disabled = false; $('vidStartBtn').textContent = 'Tap to play'; }
|
||
$('vidCta').href = ad.ctaUrl;
|
||
$('vidCta').textContent = ad.ctaLabel || 'Learn more';
|
||
$('vidCta').hidden = false;
|
||
}
|
||
async function completeVideo(ad) {
|
||
if (vidState.credited) return;
|
||
vidState.credited = true;
|
||
try {
|
||
const r = await (await fetch('/api/my/videowatch', { method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token: vidState.token }) })).json();
|
||
if (r.error) { IAP.status(r.error, 'bad'); $('vidHint').textContent = r.error; }
|
||
else if (r.credited) { IAP.status('+' + r.credited + ' credits earned for watching.', 'ok'); $('vidHint').textContent = '+' + r.credited + ' credits earned. Load the next one.'; loadDashboard(); }
|
||
else $('vidHint').textContent = 'That video just ran out of budget — load another.';
|
||
$('vidProgress').textContent = 'today: ' + ((r.status && r.status.count) || 0) + ' / ' + ((r.status && r.status.cap) || 0) + ' videos watched';
|
||
} catch (e) { IAP.status('Could not confirm that watch. Try the next one.', 'bad'); }
|
||
$('vidStartBtn').disabled = false;
|
||
$('vidStartBtn').textContent = 'Next video';
|
||
}
|
||
$('vidStartBtn').addEventListener('click', () => loadVideoAd());
|
||
// presence enforcement: pause the watch-to-earn video when the tab/window loses focus,
|
||
// resume when it comes back — the viewer must stay on the page for the watch to complete
|
||
document.addEventListener('visibilitychange', () => {
|
||
const p = $('vidPlayer'); if (!p || !p.src) return;
|
||
if (document.hidden) p.pause(); else if (!vidState.done) p.play().catch(() => {});
|
||
});
|
||
window.addEventListener('blur', () => { const p = $('vidPlayer'); if (p && p.src) p.pause(); });
|
||
window.addEventListener('focus', () => { const p = $('vidPlayer'); if (p && p.src && !vidState.done) p.play().catch(() => {}); });
|
||
|
||
// ── unmissable sponsor-message modal on sign-in ──
|
||
function showSponsorModal(msg) {
|
||
if (!$('msgModal')) return;
|
||
$('mmFrom').textContent = 'A message from ' + (msg.fromName || 'your sponsor');
|
||
$('mmSubject').textContent = msg.subject || '';
|
||
$('mmBody').innerHTML = msg.body || ''; // server-sanitized
|
||
$('msgModal').hidden = false;
|
||
$('mmAck').onclick = async () => {
|
||
$('msgModal').hidden = true;
|
||
try { await fetch('/api/my/messages/' + msg.id + '/read', { method: 'POST' }); } catch (e) {}
|
||
};
|
||
}
|
||
|
||
// ── coaching: every direct's rung, stalled flag, one-click nudge ──
|
||
// ── 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 ? '' : '<span class="badge amber">not yet</span> ' + esc(r.reason);
|
||
$('tankMine').innerHTML = r.mine.length ? '<p class="small" style="margin:0 0 6px"><b>Your open adoptions</b></p>' + r.mine.map(m => '<div class="lin-row own"><span class="nm">' + esc(m.name) + '</span>'
|
||
+ '<span class="em">' + (m.bought ? 'bought' : m.wallet ? 'wallet linked' : 'free, no wallet yet') + ' · last seen ' + ago(m.lastSeen) + '</span>'
|
||
+ '<span class="dt">' + Math.max(0, Math.ceil((m.expires - Date.now()) / 86400000)) + ' days left</span>'
|
||
+ '<button class="btn sec small" type="button" data-tchat="' + esc(m.email) + '" data-tname="' + esc(m.name) + '">Chat</button>'
|
||
+ (m.address && !m.bought ? ' <button class="btn small" type="button" title="Pay it forward: send POL from your wallet to theirs for their first package" data-pif="' + esc(m.email) + '" data-pname="' + esc(m.name) + '" data-paddr="' + esc(m.address) + '">PIF</button>' : '') + '</div>').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 = '<p class="muted small">The tank is empty. Anyone who joins from the public site without a sponsor lands here.</p>'; return; }
|
||
el.innerHTML = r.waiting.map(w => '<div class="lin-row"><span class="nm">' + esc(w.name) + '</span>'
|
||
+ '<span class="em">joined ' + ago(w.joined) + '</span>'
|
||
+ '<span class="dt">last sign-in: <b>' + ago(w.lastSeen) + '</b></span>'
|
||
+ (r.eligible ? '<button class="btn small" type="button" data-adopt="' + esc(w.username || w.email) + '" data-aname="' + esc(w.name) + '">Adopt</button>' : '') + '</div>').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 InstantAdPay 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 ? '<b>' + d.length + '</b> direct' + (d.length === 1 ? '' : 's') + ' · <b>' + r.stalled + '</b> quiet for 3+ days' + (r.stalled ? ' · start at the top' : '') : '';
|
||
if (!d.length) { el.innerHTML = '<p class="muted small">No directs yet. When someone joins through your link they show up here with their next step.</p>'; return; }
|
||
el.innerHTML = d.map(x => '<div class="lin-row' + (x.stalled ? ' own' : '') + '"><span class="nm">' + esc(x.name) + (x.stalled ? ' <span class="badge amber">quiet ' + x.quietDays + 'd</span>' : '')
|
||
+ (x.rescue && x.rescue.unreached ? ' <span class="badge amber" title="No message from you to this person. Contact them or mark Contacted, or they move to the holding tank.">unreached · tank in ' + x.rescue.rescueInDays + 'd</span>' : '') + (x.bound === false ? ' <span class="badge amber" title="On-chain this member registered under ' + (x.onchainSponsorId ? 'member #' + x.onchainSponsorId : 'no sponsor') + ', so the contract pays their purchases there, not to you. Sponsor binding is permanent at first activation.">not bound to you on-chain</span>' : '') + '</span>'
|
||
+ '<span class="em">' + esc(x.label) + ' → ' + esc(x.next) + '</span>'
|
||
+ '<span class="id">rung ' + x.rung + '/6</span>'
|
||
+ '<span class="dt">' + (x.buyerCount ? x.buyerCount + ' buyer' + (x.buyerCount === 1 ? '' : 's') : '') + '</span>'
|
||
+ '<button class="btn sec small" type="button" data-nudge="' + esc(x.email) + '" data-nname="' + esc(x.name) + '" data-say="' + esc(x.say) + '">Nudge</button>'
|
||
+ (x.free && x.address ? ' <button class="btn small" type="button" title="Pay it forward: send POL from your wallet to theirs for their first package" data-pif="' + esc(x.email) + '" data-pname="' + esc(x.name) + '" data-paddr="' + esc(x.address) + '">PIF</button>' : '')
|
||
+ (x.free && x.rescue ? ' <button class="btn sec small" type="button" title="Reached them by phone, text or in person? Mark it so the rescue clock resets." data-contacted="' + esc(x.email) + '" data-cname="' + esc(x.name) + '">Contacted them</button>' : '')
|
||
+ (x.free ? ' <button class="btn sec small" type="button" title="Pay it forward: give this free member to the holding tank so another member can coach them" data-release="' + esc(x.email) + '" data-rname="' + esc(x.name) + '">Release to tank</button>' : '') + '</div>').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 += ' <span class="muted small">starts ' + fmtWhen(c.starts) + '</span>';
|
||
if (c.expires && c.type !== 'featured') s += ' <span class="muted small">' + (c.expires > now ? 'ends ' : 'ended ') + fmtWhen(c.expires) + '</span>';
|
||
return s;
|
||
}
|
||
function geoLine(rows) {
|
||
if (!rows || !rows.length) return '';
|
||
return '<div class="muted small" style="font-size:10px" title="on-site serves by viewer country">' + rows.map(r => esc(r.cc) + ' ' + r.n).join(' · ') + '</div>';
|
||
}
|
||
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 '<div class="hbars" title="on-site views by hour of day, your local time, last 7 days">' + local.map((n, h) =>
|
||
'<i style="height:' + Math.max(2, Math.round(n / max * 18)) + 'px" title="' + lab(h) + ': ' + n + '"></i>').join('')
|
||
+ '</div><div class="muted small" style="font-size:10px">views by hour · last 7 days · your time</div>';
|
||
}
|
||
// ── link stats: views, joins, buyers per angle ──
|
||
async function loadLinkStats() {
|
||
try {
|
||
const r = await (await fetch('/api/my/linkstats')).json();
|
||
const t = $('linkStatsTable'); if (!t || r.error) return;
|
||
const rows = (r.angles || []).filter(a => a.views || a.joins || a.buyers);
|
||
if (!rows.length) { t.innerHTML = '<tr><td class="muted small">No views yet. Share your link and the numbers start here.</td></tr>'; return; }
|
||
t.innerHTML = '<tr><th>Hook</th><th>Views (30d)</th><th>Views (all)</th><th>Joined</th><th>Qualifying buyers</th></tr>'
|
||
+ rows.map(a => '<tr><td>' + esc(a.angle === 'plain' ? 'plain link' : '?v=' + a.angle) + '</td><td class="mono">' + a.views30 + '</td><td class="mono">' + a.views + '</td><td class="mono">' + a.joins + '</td><td class="mono">' + a.buyers + '</td></tr>').join('');
|
||
const st = $('linkSrcTable');
|
||
if (st) {
|
||
const src = (r.sources || []).filter(x => x.views || x.joins);
|
||
st.innerHTML = src.length ? '<tr><th>Source</th><th>Views (30d)</th><th>Views (all)</th><th>Joined</th><th>Qualifying buyers</th></tr>'
|
||
+ src.map(x => '<tr><td>' + esc(x.source) + '</td><td class="mono">' + x.views30 + '</td><td class="mono">' + x.views + '</td><td class="mono">' + x.joins + '</td><td class="mono">' + x.buyers + '</td></tr>').join('')
|
||
: '<tr><td class="muted small">Sources appear as visits arrive.</td></tr>';
|
||
}
|
||
} catch (e) {}
|
||
}
|
||
// ── prospects: the member's own follow-up list ──
|
||
let PP_STATUSES = ['new', 'contacted', 'interested', 'joined', 'bought', 'not now'];
|
||
async function loadProspects() {
|
||
try {
|
||
const r = await (await fetch('/api/my/prospects')).json();
|
||
if (r.error) return;
|
||
PP_STATUSES = r.statuses || PP_STATUSES;
|
||
const sel = $('ppStatus'); if (sel && !sel.options.length) sel.innerHTML = PP_STATUSES.map(s => '<option value="' + s + '">' + s + '</option>').join('');
|
||
const list = r.prospects || []; const el = $('prospectList'); if (!el) return;
|
||
if (!list.length) { el.innerHTML = '<p class="muted small">Nobody on the list yet.</p>'; return; }
|
||
const today = new Date(); today.setHours(0, 0, 0, 0);
|
||
el.innerHTML = list.map(p => { const due = p.nextTs && p.nextTs <= today.getTime() + 86399999; return '<div class="lin-row' + (due ? ' own' : '') + '" data-pid="' + p.id + '"><span class="nm">' + esc(p.name) + (due ? ' <span class="badge amber">follow up</span>' : '') + '</span>'
|
||
+ '<span class="em">' + esc(p.contact || '') + (p.note ? ' · ' + esc(p.note) : '') + '</span>'
|
||
+ '<select class="small" data-pstatus="' + p.id + '">' + PP_STATUSES.map(s => '<option' + (s === p.status ? ' selected' : '') + '>' + s + '</option>').join('') + '</select>'
|
||
+ '<input type="date" class="small" data-pnext="' + p.id + '" value="' + (p.nextTs ? new Date(p.nextTs).toISOString().slice(0, 10) : '') + '">'
|
||
+ '<button class="btn sec small" type="button" data-pdel="' + p.id + '">Remove</button></div>'; }).join('');
|
||
const save = async (id, patch) => { const p = list.find(x => x.id === Number(id)); if (!p) return; try { await api('/api/my/prospects', Object.assign({}, p, patch)); } catch (e) { IAP.status(e.message, 'bad'); } };
|
||
el.querySelectorAll('[data-pstatus]').forEach(s => s.addEventListener('change', () => save(s.dataset.pstatus, { status: s.value })));
|
||
el.querySelectorAll('[data-pnext]').forEach(i => i.addEventListener('change', () => save(i.dataset.pnext, { next: i.value, nextTs: i.value ? Date.parse(i.value + 'T12:00:00') : null })));
|
||
el.querySelectorAll('[data-pdel]').forEach(b => b.addEventListener('click', async () => { try { await api('/api/my/prospects/remove', { id: b.dataset.pdel }); loadProspects(); } catch (e) { IAP.status(e.message, 'bad'); } }));
|
||
} catch (e) {}
|
||
}
|
||
if ($('prospectForm')) $('prospectForm').addEventListener('submit', async e => {
|
||
e.preventDefault();
|
||
try {
|
||
await api('/api/my/prospects', { name: $('ppName').value, contact: $('ppContact').value, status: $('ppStatus').value, nextTs: $('ppNext').value ? Date.parse($('ppNext').value + 'T12:00:00') : null });
|
||
$('ppName').value = ''; $('ppContact').value = ''; $('ppNext').value = ''; loadProspects();
|
||
} catch (err) { IAP.status(err.message, 'bad'); }
|
||
});
|
||
// ── broadcast templates ──
|
||
const BC_TEMPLATES = [
|
||
{ label: 'Welcome', subject: 'Welcome to my line: your first three moves', html: '<p>Glad you are in. Three things today, in this order:</p><ol><li>Pick your username on the Profile tab (it becomes your link).</li><li>Wallet tab: Connect and link wallet, then Switch on payouts. Both are free.</li><li>Copy your invite link from My line and send it to one person.</li></ol><p>Reply here if you get stuck on any of them. That is what I am here for.</p>' },
|
||
{ label: 'Switch on payouts', subject: 'One free step so nothing passes you by', html: '<p>Quick reminder: if payouts are not switched on yet, do it now on the Wallet tab. One free transaction.</p><p>The contract locks each buyer to their sponsor at their first purchase, and payouts only route to wallets that are switched on. Ready early and you never miss one.</p>' },
|
||
{ label: 'The $5 test', subject: 'See a payout land in real time', html: '<p>Want to see the whole thing work? Buy the $5 Micro package on Buy packages and watch the live ledger while you do it. You will see your credits mint and the split go out in the same transaction.</p><p>When you are ready to count as a qualifying buyer for me, the $20 Activation package is the one.</p>' },
|
||
{ label: 'Qualified Start', subject: 'How to open level 2 today with your own positions', html: '<p>You can be your own first buyers, openly. On Buy packages, the Qualified Start card lets you link a second wallet you own as a position. When it buys a $20 package, it counts as a qualifying buyer, half comes straight back to your main wallet, and the credits pool with yours.</p><p>Two positions open level 2 the same day. The three Qualified Start videos in Training show every click.</p>' },
|
||
{ label: 'Share your link', subject: 'One conversation a day is the whole job', html: '<p>Promo tools has posts, texts and emails that already carry your link. Pick one and send it to one person today.</p><p>Do not wait for the perfect moment. Nobody who waited ever built a line.</p>' }
|
||
];
|
||
(function () {
|
||
const w = $('bcTemplates'); if (!w) return;
|
||
w.innerHTML = BC_TEMPLATES.map((t, i) => '<button type="button" class="chip-t" data-bct="' + i + '">' + esc(t.label) + '</button>').join('');
|
||
w.querySelectorAll('[data-bct]').forEach(b => b.addEventListener('click', () => { const t = BC_TEMPLATES[Number(b.dataset.bct)]; $('bcSubject').value = t.subject; $('bcEd').innerHTML = t.html; $('bcEd').focus(); }));
|
||
})();
|
||
// ── Qualified Start calculator ──
|
||
(function () {
|
||
const n = $('qcN'), pk = $('qcPkg'), out = $('qcOut'); if (!n || !pk || !out) return;
|
||
const CR = { 20: 2000, 50: 5500, 100: 12000, 250: 32500 };
|
||
const calc = () => {
|
||
const k = Math.max(1, Math.min(10, Number(n.value) || 1)), usd = Number(pk.value) || 20;
|
||
const gross = k * usd, back = gross / 2, net = gross - back, credits = k * (CR[usd] || 0);
|
||
const level = k >= 5 ? 'Level 3 open (and level 2): the Nexus badge, wall position 3, full 50 / 20 / 10' : k >= 2 ? 'Level 2 open: 20% on your directs\' buyers, wall position 2' : 'Counts as one qualifying buyer. One more opens level 2.';
|
||
out.innerHTML = '<div class="grid c2"><div><b>$' + gross + '</b> out across ' + k + ' position' + (k === 1 ? '' : 's') + '<br><b>$' + back + '</b> back to your main wallet in the same transactions (the 50% direct-sponsor share)<br><b>$' + net + '</b> net, plus a little POL for gas in each wallet</div>'
|
||
+ '<div><b>' + credits.toLocaleString() + ' credits</b> pooled for your own ads<br>' + level + '<br><span class="muted">The 20% and 10% shares go to your upline if they are qualified, otherwise to the platform.</span></div></div>';
|
||
};
|
||
n.addEventListener('input', calc); pk.addEventListener('change', calc); calc();
|
||
})();
|
||
|
||
// ── downline lineage + sponsor broadcast + upline messages ──
|
||
async function loadLineage() {
|
||
try {
|
||
const r = await (await fetch('/api/my/line')).json();
|
||
const el = $('lineageWrap');
|
||
if (r.error || !r.levels || !r.levels.every) return;
|
||
if (!r.levels.length || !r.levels.some(L => L.members.length)) {
|
||
el.innerHTML = '<p class="muted small">No one in your downline yet. Share your link and it fills in here.</p>';
|
||
return;
|
||
}
|
||
el.innerHTML = r.levels.map(L => !L.members.length ? '' :
|
||
'<div class="lin-lvl"><div class="cap">Level ' + L.level + ' · ' + L.members.length + (L.level === 1 ? ' direct' : '') + '</div>'
|
||
+ L.members.map(m => '<div class="lin-row' + (m.own ? ' own' : '') + '"><span class="nm">' + esc(m.name) + (m.own ? ' <span class="badge">yours</span>' : '') + '</span>'
|
||
+ (m.email ? '<span class="em">' + esc(m.email) + '</span>' : '<span class="id">#' + m.memberId + '</span>')
|
||
+ '<span class="earn' + (m.earnedWei && m.earnedWei !== '0' ? ' on' : '') + '" title="POL this person has paid you so far">'
|
||
+ (m.earnedWei && m.earnedWei !== '0' ? '+' + IAP.fmtPol(m.earnedWei) + ' POL' : '0.00 POL') + '</span>'
|
||
+ '<span class="dt">' + new Date(m.joined).toLocaleDateString() + '</span>'
|
||
+ (m.email ? '<button class="btn sec small chat-msg-btn" type="button" data-cemail="' + esc(m.email) + '" data-cname="' + esc(m.name) + '">Message</button>' : '')
|
||
+ '</div>').join('')
|
||
+ '</div>').join('');
|
||
el.querySelectorAll('[data-cemail]').forEach(b =>
|
||
b.addEventListener('click', () => openConvo(b.dataset.cemail, b.dataset.cname)));
|
||
} catch (e) {}
|
||
}
|
||
async function loadUplineMessages() {
|
||
try {
|
||
const r = await (await fetch('/api/my/messages')).json();
|
||
if (r.error) return;
|
||
const card = $('upMsgCard'), list = $('upList');
|
||
if (!r.items || !r.items.length) { card.hidden = true; return; }
|
||
card.hidden = false;
|
||
list.innerHTML = r.items.map(i => '<div class="promo-block" style="margin-bottom:10px">'
|
||
+ '<div style="display:flex;justify-content:space-between;gap:10px"><b>' + esc(i.subject) + '</b>'
|
||
+ '<span class="small muted">' + esc(i.fromName) + ' · ' + new Date(i.sent).toLocaleDateString() + '</span></div>'
|
||
+ '<div class="ib-rich" style="margin-top:8px">' + (i.body || '') + '</div></div>').join('');
|
||
// opening the pane marks them read
|
||
for (const i of r.items) if (!i.read) fetch('/api/my/messages/' + i.id + '/read', { method: 'POST' }).catch(() => {});
|
||
} catch (e) {}
|
||
}
|
||
// broadcast composer editor (its own small rich editor, server sanitizes)
|
||
document.querySelectorAll('[data-bc]').forEach(b =>
|
||
b.addEventListener('click', () => { $('bcEd').focus(); document.execCommand(b.dataset.bc, false, null); }));
|
||
document.querySelectorAll('[data-bcblock]').forEach(b =>
|
||
b.addEventListener('click', () => { $('bcEd').focus(); document.execCommand('formatBlock', false, b.dataset.bcblock); }));
|
||
if ($('bcLinkBtn')) $('bcLinkBtn').addEventListener('click', 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';
|
||
const w = $('earn-watch'), i = $('earn-inbox'), v = $('earn-videos'), vs = $('earn-visits');
|
||
if (w) w.hidden = earnSub !== 'watch';
|
||
if (i) i.hidden = earnSub !== 'inbox';
|
||
if (v) v.hidden = earnSub !== 'videos';
|
||
if (vs) vs.hidden = earnSub !== 'visits';
|
||
document.querySelectorAll('.subtabs [data-earn]').forEach(b =>
|
||
b.classList.toggle('on', b.dataset.earn === earnSub));
|
||
if (earnSub === 'inbox') loadInbox();
|
||
else if (earnSub === 'videos') loadVideoStatus();
|
||
else if (earnSub === 'visits') loadVisitStatus();
|
||
else earnRefresh();
|
||
}
|
||
document.querySelectorAll('.subtabs [data-earn]').forEach(b =>
|
||
b.addEventListener('click', () => setEarnSub(b.dataset.earn)));
|
||
// promo tools: pill menu switches between posts / swipes / banners / wall / videos
|
||
function setPromoSub(name) {
|
||
const ids = ['posts', 'text', 'swipe', 'banners', 'wall', 'objections', 'videos'];
|
||
if (!ids.includes(name)) name = 'posts';
|
||
ids.forEach(id => { const el = $('promo-' + id); if (el) el.hidden = id !== name; });
|
||
document.querySelectorAll('.promo-pills [data-promo]').forEach(b => {
|
||
b.classList.toggle('on', b.dataset.promo === name);
|
||
b.setAttribute('aria-selected', b.dataset.promo === name ? 'true' : 'false');
|
||
});
|
||
try { localStorage.setItem('iap.promoSub', name); } catch (e) {}
|
||
}
|
||
document.querySelectorAll('.promo-pills [data-promo]').forEach(b =>
|
||
b.addEventListener('click', () => setPromoSub(b.dataset.promo)));
|
||
try { setPromoSub(localStorage.getItem('iap.promoSub') || 'posts'); } catch (e) { setPromoSub('posts'); }
|
||
async function loadInbox() {
|
||
try {
|
||
const r = await (await fetch('/api/my/inbox')).json();
|
||
if (r.error) return;
|
||
$('ibRewardNote').textContent = '+' + r.readCredits + ' credits per real read (up to '
|
||
+ r.readCap + ' rewarded reads a day)';
|
||
const el = $('ibList');
|
||
$('inboxReadCard').hidden = true;
|
||
$('inboxListCard').hidden = false;
|
||
setInboxBadge(r.unread);
|
||
if (!r.items.length) {
|
||
el.innerHTML = '<p class="muted small">No solo ads yet. When a member sends one, it lands here — and reading it pays.</p>';
|
||
return;
|
||
}
|
||
el.innerHTML = '';
|
||
for (const i of r.items) {
|
||
const d = document.createElement('div');
|
||
d.className = 'ib-row' + (i.read ? '' : ' unread');
|
||
d.innerHTML = '<span class="sub"></span><span class="from"></span>'
|
||
+ (i.rewarded ? '<span class="badge">claimed</span>' : i.read ? '' : '<span class="badge amber">new</span>')
|
||
+ '<span class="when">' + new Date(i.delivered).toLocaleDateString() + '</span>';
|
||
d.querySelector('.sub').textContent = i.subject || '(no subject)';
|
||
d.querySelector('.from').textContent = 'from ' + (i.fromName || 'a member');
|
||
d.addEventListener('click', () => openInboxItem(i.id));
|
||
el.appendChild(d);
|
||
}
|
||
} catch (e) {}
|
||
}
|
||
async function openInboxItem(id) {
|
||
try {
|
||
const r = await (await fetch('/api/my/inbox/' + id)).json();
|
||
if (r.error) { IAP.status(r.error, 'bad'); return; }
|
||
$('inboxListCard').hidden = true;
|
||
$('inboxReadCard').hidden = false;
|
||
$('ibSubject').textContent = r.subject || '(no subject)';
|
||
$('ibMeta').textContent = 'from ' + (r.fromName || 'a member') + ' · ' + new Date(r.delivered).toLocaleString();
|
||
$('ibBody').innerHTML = r.body || ''; // whitelist-sanitized on the server at submit
|
||
const mv = $('ibMedia');
|
||
mv.hidden = !r.mediaUrl;
|
||
mv.innerHTML = !r.mediaUrl ? ''
|
||
: r.mediaType === 'video'
|
||
? '<video src="' + r.mediaUrl + '" controls style="max-width:100%;border-radius:12px"></video>'
|
||
: '<img src="' + r.mediaUrl + '" alt="attachment" style="max-width:100%;border-radius:12px">';
|
||
// the read reward needs BOTH the dwell AND an actual click-through to the
|
||
// advertiser — the visit is what makes the ad worth the sender's credits
|
||
const visit = $('ibVisit');
|
||
visit.href = r.url;
|
||
visit.textContent = r.ctaLabel || 'Learn more';
|
||
visit.target = '_blank';
|
||
const btn = $('ibClaimBtn');
|
||
clearInterval(ibTimer);
|
||
if (r.rewarded) {
|
||
btn.hidden = true;
|
||
visit.classList.remove('cta-need');
|
||
$('ibHint').textContent = 'Read reward already claimed for this one.';
|
||
return;
|
||
}
|
||
let dwellDone = false;
|
||
let visited = !!r.visited;
|
||
btn.hidden = false;
|
||
btn.disabled = true;
|
||
visit.classList.toggle('cta-need', !visited);
|
||
let left = r.dwell;
|
||
const refresh = () => {
|
||
if (!dwellDone) { btn.textContent = 'Read it — claim in ' + left + 's'; return; }
|
||
if (!visited) { btn.textContent = 'Claim +' + r.reward + ' — visit the ad first'; btn.disabled = true; return; }
|
||
btn.textContent = 'Claim +' + r.reward + ' credits'; btn.disabled = false;
|
||
};
|
||
$('ibHint').textContent = 'Read the message, click through to the advertiser, then claim your credits.';
|
||
refresh();
|
||
// countdown pauses off-tab; the server separately enforces the dwell on its own clock
|
||
ibTimer = setInterval(() => {
|
||
if (document.visibilityState !== 'visible' || !document.hasFocus()) return;
|
||
left -= 1;
|
||
if (left > 0) { refresh(); return; }
|
||
clearInterval(ibTimer);
|
||
dwellDone = true;
|
||
refresh();
|
||
}, 1000);
|
||
// clicking the CTA records the visit (and counts the advertiser's click)
|
||
visit.onclick = async () => {
|
||
visited = true;
|
||
visit.classList.remove('cta-need');
|
||
try { await fetch('/api/my/inbox/' + id + '/visit', { method: 'POST' }); } catch (e2) {}
|
||
refresh();
|
||
};
|
||
btn.onclick = async () => {
|
||
try {
|
||
const c = await api('/api/my/inbox/' + id + '/claim');
|
||
IAP.status('+' + c.credited + ' credits for reading. They spend like any earned credits.', 'ok');
|
||
btn.hidden = true;
|
||
$('ibHint').textContent = 'Claimed. Head back for the next one.';
|
||
loadDashboard();
|
||
} catch (e2) { IAP.status(e2.message, 'bad'); }
|
||
};
|
||
} catch (e) {}
|
||
}
|
||
$('ibBack').addEventListener('click', ev => { ev.preventDefault(); clearInterval(ibTimer); loadInbox(); });
|
||
|
||
// ── promo tools: content + rendering live in promo.js (IAPPromo.fill) ──
|
||
function fillPromo(link, me) {
|
||
if (window.IAPPromo) IAPPromo.fill(link, me || {});
|
||
// banner kit
|
||
const bwrap = $('promoBanners');
|
||
if (bwrap && !bwrap.dataset.filled) {
|
||
bwrap.dataset.filled = '1';
|
||
// 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 = '<summary><span>' + esc(g.title) + '</span><span class="pb-count">' + g.items.length + (g.items.length === 1 ? ' banner' : ' banners') + '</span></summary><div class="pb-grid"></div>';
|
||
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 = '<img src="/banners/' + b.file + '?v=4" alt="InstantAdPay banner ' + b.size + '" loading="lazy">'
|
||
+ '<div class="pb-row"><span class="pb-size">' + b.size + '</span></div>';
|
||
const dl = document.createElement('a');
|
||
dl.className = 'btn small'; dl.textContent = 'Download'; dl.href = '/banners/' + b.file; dl.setAttribute('download', b.file);
|
||
d.querySelector('.pb-row').appendChild(dl);
|
||
const btn = document.createElement('button');
|
||
btn.className = 'btn small sec';
|
||
btn.textContent = 'Copy image URL';
|
||
btn.addEventListener('click', async () => {
|
||
try { await navigator.clipboard.writeText(url); IAP.status('Banner URL copied.', 'ok'); }
|
||
catch (e) { IAP.status('Copy failed.', 'bad'); }
|
||
});
|
||
d.querySelector('.pb-row').appendChild(btn);
|
||
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) : '';
|
||
async function loadPositions(me) {
|
||
try {
|
||
const r = await (await fetch('/api/my/positions')).json();
|
||
if (r.error) return;
|
||
const list = r.positions || [];
|
||
const rows = list.map((p, i) => '<div class="lin-row"><span class="nm">Position ' + (i + 2) + ' <span class="mono">' + short(p.address) + '</span></span>'
|
||
+ '<span class="id">' + (p.memberId ? '#' + p.memberId : 'not on-chain yet') + '</span>'
|
||
+ '<span class="earn' + (p.counted ? ' on' : '') + '">' + (p.counted ? 'counts as a qualifying buyer' : p.memberId ? 'registered, buy $20+ to count' : 'buy a $20+ package to register it') + '</span>'
|
||
+ '<span class="dt">' + (p.credits || 0).toLocaleString() + ' credits' + (p.balanceWei != null ? ' · ' + (Number(BigInt(p.balanceWei) / 10n ** 14n) / 10000).toLocaleString(undefined, { maximumFractionDigits: 2 }) + ' POL' : '') + '</span>'
|
||
+ (!p.memberId ? '<button class="btn sec small" type="button" data-unlink="' + p.address + '">Unlink</button>' : '')
|
||
+ '</div>').join('');
|
||
const mainRow = r.main && r.main.address ? '<div class="lin-row"><span class="nm">Position 1 · main <span class="mono">' + short(r.main.address) + '</span></span>'
|
||
+ '<span class="id">' + (r.main.memberId ? '#' + r.main.memberId : 'payouts not on yet') + '</span>'
|
||
+ '<span class="earn on">' + (r.main.buyerCount || 0) + ' qualifying buyer(s)</span>'
|
||
+ '<span class="dt">' + (r.main.credits || 0).toLocaleString() + ' credits' + (r.main.balanceWei != null ? ' · ' + (Number(BigInt(r.main.balanceWei) / 10n ** 14n) / 10000).toLocaleString(undefined, { maximumFractionDigits: 2 }) + ' POL' : '') + '</span></div>' : '';
|
||
const html = mainRow + rows + (list.length ? '<p class="muted small" style="margin:8px 0 0">Pooled credits: <b>' + (r.totalCredits || 0).toLocaleString() + '</b>' + (r.credited ? ' plus <b>' + r.credited.toLocaleString() + '</b> credited to your account (spends from any position)' : '') + '. A campaign budget spends from one position at a time.</p>' : '');
|
||
if ($('qsList')) $('qsList').innerHTML = html;
|
||
// Wallet tab: live POL balance of the linked wallet, and which package it covers
|
||
const wb = $('walletBal');
|
||
if (wb && r.main && r.main.address && r.main.balanceWei != null) {
|
||
const polN = Number(BigInt(r.main.balanceWei) / 10n ** 14n) / 10000;
|
||
const usd = r.polUsd ? polN * r.polUsd : 0;
|
||
const pkgs = [5, 20, 50, 100, 250];
|
||
const covers = r.polUsd ? pkgs.filter(p => usd >= p * 1.06 + 0.05) : [];
|
||
wb.hidden = false;
|
||
wb.innerHTML = 'Wallet balance: <b class="mono">' + polN.toLocaleString(undefined, { maximumFractionDigits: 2 }) + ' POL</b>' + (usd ? ' (about $' + usd.toLocaleString(undefined, { maximumFractionDigits: 0 }) + ')' : '')
|
||
+ (r.polUsd ? '<br><span class="muted">' + (covers.length ? 'Enough for the $' + covers[covers.length - 1] + ' package with gas to spare.' : 'Not enough for the $5 package yet. Buy POL with a card on the Buy packages tab, or send POL to this wallet.') + (covers.length && covers.length < pkgs.length ? ' Top up for the $' + pkgs[covers.length] + ' package.' : '') + '</span>' : '');
|
||
}
|
||
if ($('posList')) $('posList').innerHTML = html;
|
||
if ($('posCard')) $('posCard').hidden = !list.length;
|
||
// "Buy from" picker: main + every position
|
||
const sel = $('buyFrom');
|
||
if (sel) {
|
||
const keep = sel.value;
|
||
sel.innerHTML = '<option value="main">Main wallet ' + (r.main && r.main.address ? short(r.main.address) : '(link it first)') + (r.main && r.main.memberId ? ' · #' + r.main.memberId : '') + '</option>'
|
||
+ list.map((p, i) => '<option value="' + p.address + '">Position ' + (i + 2) + ' ' + short(p.address) + (p.memberId ? ' · #' + p.memberId : ' · not registered yet') + (p.counted ? ' · counted' : '') + '</option>').join('');
|
||
if (keep && [...sel.options].some(o => o.value === keep)) sel.value = keep;
|
||
$('buyFromWrap').hidden = !list.length;
|
||
}
|
||
document.querySelectorAll('[data-unlink]').forEach(b => b.addEventListener('click', async () => {
|
||
if (!(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 = '<div class="name">' + (PKG[p.id] || 'Package ' + p.id) + '</div>'
|
||
+ '<div class="price">$' + Math.round(p.priceCents / 100) + '</div>'
|
||
+ '<div class="cr">' + p.creditAmount.toLocaleString() + ' credits</div>'
|
||
+ '<div class="bonus">' + (bonus > 0 ? '+' + bonus.toLocaleString() + ' bonus credits' : ' ') + '</div>'
|
||
+ '<div class="pol">' + (p.costWei ? IAP.fmtPol(p.costWei) + ' POL right now' : 'paused') + '</div>'
|
||
+ '<button class="btn small" data-id="' + p.id + '" data-cost="' + (p.costWei || '') + '"'
|
||
+ (p.costWei ? '' : ' disabled') + '>Buy $' + Math.round(p.priceCents / 100) + '</button>';
|
||
wrap.appendChild(div);
|
||
}
|
||
wrap.querySelectorAll('button[data-id]').forEach(b => b.addEventListener('click', async () => {
|
||
try {
|
||
b.disabled = true;
|
||
// the buyer's credits are read by the member id of their LINKED wallet.
|
||
// If they only connected a wallet (e.g. for the faucet) but never linked
|
||
// it, link it first (one signature) so the purchase's credits show up.
|
||
// retry these through transient failures — mobile drops in-flight
|
||
// fetches when the page returns from the wallet app-switch
|
||
const jretry = async url => { let err; for (let i = 0; i < 4; i++) { try { return await (await fetch(url)).json(); } catch (e) { err = e; await new Promise(s => setTimeout(s, 500 * (i + 1))); } } throw err; };
|
||
const meNow = await jretry('/api/me');
|
||
// which of the member's wallets is buying: the main wallet (default) or a
|
||
// linked position (Qualified Start). A position registers under the main
|
||
// member id on its first buy, so its sponsor is always this member.
|
||
const fromSel = $('buyFrom');
|
||
const fromPos = (fromSel && !$('buyFromWrap').hidden && fromSel.value && fromSel.value !== 'main') ? fromSel.value.toLowerCase() : null;
|
||
if (fromPos && !meNow.memberId) { IAP.status('Switch on payouts for your main wallet first (Wallet tab), so this position can register under you.', 'bad'); return; }
|
||
if (!fromPos && !meNow.address) {
|
||
IAP.status('Link your wallet first — one quick signature…');
|
||
await IAPWallet.signIn();
|
||
}
|
||
const wantAddr = fromPos || (meNow.address ? meNow.address.toLowerCase() : null);
|
||
if (wantAddr) { // never buy from a wallet other than the one selected: a stray wallet would register a brand-new member
|
||
await IAPWallet.connect();
|
||
let cur = String(await IAPWallet.activeAddress() || '').toLowerCase();
|
||
if (cur !== wantAddr) {
|
||
IAP.status('Pick ' + wantAddr.slice(0, 6) + '…' + wantAddr.slice(-4) + ' in your wallet\'s account picker…');
|
||
cur = String(await IAPWallet.pickAccount() || '').toLowerCase();
|
||
}
|
||
if (cur !== wantAddr) throw new Error('Your wallet connected as ' + cur.slice(0, 6) + '…' + cur.slice(-4) + ' but you chose ' + wantAddr.slice(0, 6) + '…' + wantAddr.slice(-4) + '. Switch accounts in your wallet app and try again.');
|
||
}
|
||
const spNow = fromPos ? { sponsorId: meNow.memberId } : await jretry('/api/sponsor');
|
||
// pre-flight: stop early if the POL is not there. Trust Wallet also hard-blocks any
|
||
// transaction that spends most of the balance ("drain your wallet"), so Trust users
|
||
// get a heads-up first; other wallets go straight to the confirmation.
|
||
try {
|
||
const need = BigInt(b.dataset.cost) + BigInt(b.dataset.cost) / 50n; // same 2% pad as buy()
|
||
const bal = await IAPWallet.balance(wantAddr || IAPWallet.address() || meNow.address);
|
||
if (bal < need) {
|
||
IAP.status('That wallet holds ' + IAP.fmtPol(bal.toString()) + ' POL, but this package needs about ' + IAP.fmtPol(need.toString()) + ' POL plus a little for gas. Top it up and try again.', 'bad');
|
||
return;
|
||
}
|
||
const pct = Number(need * 100n / bal);
|
||
const isTrust = /trust/i.test(IAPWallet.walletName() || '');
|
||
if (isTrust && pct > 55 && !(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 = '<p class="muted small" style="margin:0 0 8px">New to crypto? Buy POL with a debit or credit card, Apple Pay, or Google Pay. It lands straight in your own wallet, and this site never touches your money.</p>'
|
||
+ '<button class="btn small sec" id="moonpayBtn" type="button">💳 Buy POL with a card</button> <a class="btn small sec" href="/wallets" target="_blank" rel="noopener">Wallet and MoonPay guide</a>';
|
||
wrap.appendChild(cta);
|
||
const mb = $('moonpayBtn'); if (mb) mb.addEventListener('click', () => openMoonpay(needPol));
|
||
} catch (e) {}
|
||
}
|
||
loadBuyTiles();
|
||
|
||
// ── earn-by-viewing: daily ad set with dwell, then claim ──
|
||
const earnState = { types: ['banner', 'text'], i: 0, timer: null };
|
||
async function earnRefresh() {
|
||
try {
|
||
const st = await (await fetch('/api/my/earn')).json();
|
||
if (st.error) return null;
|
||
$('earnProgress').textContent = 'today: ' + st.views + ' / ' + st.target + ' ads viewed';
|
||
$('earnBalance').textContent = (st.earnedAvailable != null ? st.earnedAvailable : st.earned) + ' earned credits available' + (st.reserved ? ' · ' + st.reserved + ' in live campaigns' : '');
|
||
const done = st.views >= st.target;
|
||
$('earnClaimBtn').hidden = !(done && !st.claimed);
|
||
if (st.claimed) {
|
||
$('earnHint').textContent = 'Claimed for today. Come back tomorrow, or put those credits to work in Campaigns.';
|
||
const box = $('earnAdBox'); if (box && !box.querySelector('.done-big')) box.innerHTML = '<div class="done-big"><span class="tick">✓</span><b>Ads done for today</b><span class="muted small">Today\'s set is viewed and claimed. Fresh ads tomorrow.</span></div>';
|
||
if ($('earnStartBtn')) $('earnStartBtn').hidden = true;
|
||
} else if ($('earnStartBtn')) $('earnStartBtn').hidden = false;
|
||
else if (done) $('earnHint').textContent = 'Set complete. Claim your ' + st.claimCredits + ' credits.';
|
||
return st;
|
||
} catch (e) { return null; }
|
||
}
|
||
async function earnShowAd() {
|
||
// each view happens full screen in its own tab: /view/<token> frames the
|
||
// advertiser's site, runs the countdown, then a human check credits it
|
||
const type = earnState.types[earnState.i++ % earnState.types.length];
|
||
let r = null;
|
||
try { r = await (await fetch('/api/my/earnview?type=' + type)).json(); } catch (e) {}
|
||
const box = $('earnAdBox');
|
||
if (!r || !r.ad || !r.viewUrl) {
|
||
await earnRefresh();
|
||
box.innerHTML = '<span class="muted small">' + (r && r.status && r.status.views >= r.status.target
|
||
? 'Set complete for today.'
|
||
: 'No member ads are live in rotation right now. Views resume the moment a campaign is running.') + '</span>';
|
||
return;
|
||
}
|
||
openAdOverlay(r.viewUrl);
|
||
box.innerHTML = '<span class="muted small">Watch the countdown and pass the quick check. Your view credits itself and this page updates right away.</span>';
|
||
$('earnStartBtn').textContent = 'View next ad';
|
||
}
|
||
// In-page ad overlay: no new tab (mobile and in-app wallet browsers handle tabs
|
||
// badly), no window.close needed. The /view page runs the dwell + check inside,
|
||
// messages back when it credits or when the user is done.
|
||
function openAdOverlay(url) {
|
||
closeAdOverlay();
|
||
const back = document.createElement('div');
|
||
back.id = 'adOverlay';
|
||
back.style.cssText = 'position:fixed;inset:0;z-index:100000;background:#050b09;display:flex;flex-direction:column';
|
||
const x = document.createElement('button');
|
||
x.type = 'button'; x.setAttribute('aria-label', 'Close ad');
|
||
x.textContent = '✕';
|
||
x.style.cssText = 'position:absolute;top:10px;right:12px;z-index:2;width:40px;height:40px;border-radius:50%;border:1px solid rgba(255,255,255,.25);background:rgba(6,10,16,.85);color:#fff;font-size:20px;font-weight:700;cursor:pointer';
|
||
x.addEventListener('click', closeAdOverlay);
|
||
const ifr = document.createElement('iframe');
|
||
ifr.src = url;
|
||
ifr.style.cssText = 'flex:1;width:100%;border:0;background:#050b09';
|
||
back.appendChild(ifr); back.appendChild(x);
|
||
document.body.appendChild(back);
|
||
document.body.style.overflow = 'hidden';
|
||
}
|
||
function closeAdOverlay() {
|
||
const m = $('adOverlay'); if (m) m.remove();
|
||
document.body.style.overflow = '';
|
||
earnRefresh(); loadDashboard();
|
||
}
|
||
window.addEventListener('message', e => {
|
||
if (e.origin !== location.origin || !e.data) return;
|
||
if (e.data.t === 'iap-view-close') closeAdOverlay();
|
||
else if (e.data.t === 'iap-view-done') { earnRefresh(); loadDashboard(); }
|
||
});
|
||
$('earnStartBtn').addEventListener('click', () => earnShowAd());
|
||
// the viewer tab pings localStorage when a view credits; refresh instantly
|
||
window.addEventListener('storage', e => {
|
||
if (e.key === 'iap-view-done') { earnRefresh(); loadDashboard(); }
|
||
});
|
||
document.addEventListener('visibilitychange', () => {
|
||
if (document.visibilityState === 'visible' && $('pane-earn') && !$('pane-earn').hidden) earnRefresh();
|
||
});
|
||
$('earnClaimBtn').addEventListener('click', async () => {
|
||
try {
|
||
const r = await api('/api/my/claim');
|
||
IAP.status('+' + r.credited + ' credits earned. Spend them in Campaigns.', 'ok');
|
||
await earnRefresh();
|
||
loadDashboard();
|
||
} catch (e) { IAP.status(e.message, 'bad'); }
|
||
});
|
||
|
||
const busy = (btn, fn) => async () => {
|
||
try { btn.disabled = true; await fn(); }
|
||
catch (e) { IAP.status((e && e.message) || String(e), 'bad'); }
|
||
finally { btn.disabled = false; }
|
||
};
|
||
|
||
// passwordless (feature-flagged on config.emailAuth): code replaces passwords
|
||
(async () => {
|
||
const cfg = await IAP.getConfig();
|
||
if (!cfg.emailAuth) return;
|
||
$('passCards').hidden = true;
|
||
$('magicCard').hidden = false;
|
||
const 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 <span class="mono">' + addr.slice(0, 8) + '…' + addr.slice(-6) + '</span>'
|
||
+ (copied ? ' is copied' : '') + '. On the faucet, choose <b>Polygon Amoy</b>, paste your address, and request POL. Then come back and buy.';
|
||
IAP.status('Faucet opened in a new tab. Choose Polygon Amoy, paste your address, and request test POL.', 'ok');
|
||
}));
|
||
if ($('wcDisconnect')) $('wcDisconnect').addEventListener('click', busy($('wcDisconnect'), async () => {
|
||
// fire-and-forget: don't gate the reload on disconnect resolving (it can
|
||
// stall). The reload drops all in-memory wallet state and the picker returns.
|
||
IAPWallet.disconnect().catch(() => {});
|
||
$('wcDisconnectInfo').textContent = 'Disconnecting — reloading so you can pick a different wallet…';
|
||
IAP.status('Disconnecting — reloading…', 'ok');
|
||
setTimeout(() => location.reload(), 900);
|
||
}));
|
||
$('activateBtn').addEventListener('click', busy($('activateBtn'), async () => {
|
||
const me = await (await fetch('/api/me')).json();
|
||
IAP.status('Confirm the free activation in your wallet…');
|
||
const r = await IAPWallet.activate(me.sponsorId || 0);
|
||
if (r.receipt.status !== '0x1') throw new Error('Transaction reverted. Check the explorer.');
|
||
IAP.status('Payouts are on. Your invite link is live.', 'ok');
|
||
await render();
|
||
}));
|
||
$('copyInvite').addEventListener('click', async () => {
|
||
try { await navigator.clipboard.writeText($('inviteLine').textContent); IAP.status('Link copied.', 'ok'); }
|
||
catch (e) { IAP.status('Copy failed. Select and copy the link text.', 'bad'); }
|
||
});
|
||
$('logoutLink').addEventListener('click', async e => {
|
||
e.preventDefault();
|
||
await fetch('/api/auth/logout', { method: 'POST' });
|
||
IAP.status('Logged out.', 'ok');
|
||
await render();
|
||
});
|
||
|
||
// ── welcome tour (viral banner gauntlet): a new member meets their 3-level
|
||
// upline's sites, 10 focus-paused seconds each, then unlocks welcome credits.
|
||
// Same three levels the contract pays — the tour IS the org chart.
|
||
async function showGauntlet() {
|
||
try {
|
||
const g = await (await fetch('/api/my/gauntlet')).json();
|
||
if (!g.pending || !g.slides || !g.slides.length) return false;
|
||
const gate = $('gauntGate');
|
||
gate.hidden = false;
|
||
for (let i = 0; i < g.slides.length; i++) {
|
||
const s = g.slides[i];
|
||
$('ggWho').textContent = 'Position ' + (i + 1) + ': ' + s.name + (i === 0 ? ' — the person who invited you' : '');
|
||
$('ggProgress').textContent = 'Meeting your line: ' + (i + 1) + ' of ' + g.slides.length;
|
||
$('ggFrame').hidden = false;
|
||
$('ggFrame').src = s.targetUrl;
|
||
let left = g.dwell || 10;
|
||
$('ggTimer').textContent = left + 's';
|
||
await new Promise(done => {
|
||
const t = setInterval(() => {
|
||
if (document.visibilityState !== 'visible' || !document.hasFocus()) return;
|
||
left -= 1;
|
||
$('ggTimer').textContent = Math.max(0, left) + 's';
|
||
if (left <= 0) { clearInterval(t); done(); }
|
||
}, 1000);
|
||
});
|
||
}
|
||
$('ggFrame').src = 'about:blank';
|
||
$('ggFrame').hidden = true; // avoid a white about:blank panel once the tour is done
|
||
$('ggTimer').textContent = '✓';
|
||
$('ggWho').textContent = 'That is your line. When you grow, they earn — and yours starts the day you share.';
|
||
$('ggClaim').hidden = false;
|
||
await new Promise(done => {
|
||
$('ggClaim').onclick = async () => {
|
||
try {
|
||
const r = await api('/api/my/gauntlet/complete', { token: g.token });
|
||
IAP.status('+' + r.credited + ' welcome credits unlocked. They spend on real campaigns.', 'ok');
|
||
} catch (e) { IAP.status(e.message, 'bad'); }
|
||
done();
|
||
};
|
||
});
|
||
gate.hidden = true;
|
||
$('ggClaim').hidden = true;
|
||
return true;
|
||
} catch (e) { return false; }
|
||
}
|
||
|
||
// ── line banner (profile): the member's slot on welcome tours + their wall ──
|
||
function fillLineBanner(a) {
|
||
if (!a) return;
|
||
if (a.lineTargetUrl) $('lbTarget').value = a.lineTargetUrl;
|
||
if (a.lineBannerUrl) {
|
||
$('lbBanner').value = a.lineBannerUrl;
|
||
$('lbPreview').hidden = false;
|
||
$('lbPreview').innerHTML = '<img src="' + a.lineBannerUrl + '" alt="line banner" style="max-width:320px;border-radius:10px">';
|
||
}
|
||
$('lbCurrent').textContent = a.lineTargetUrl
|
||
? 'Live: your next three levels meet ' + a.lineTargetUrl + ' on their welcome tour.'
|
||
: 'Not set yet. Until you set one, your tour slot is skipped.';
|
||
}
|
||
async function loadLineBanner() {
|
||
try {
|
||
const a = await (await fetch('/api/me')).json();
|
||
fillLineBanner(a);
|
||
fillProfileDetails(a);
|
||
// buyerCount + wallUnlocked live on the dashboard payload (chain read), not on /api/me
|
||
let d = {}; try { d = await (await fetch('/api/my/dashboard')).json(); } catch (e) {}
|
||
fillWallOffers(Object.assign({}, a, { buyerCount: d.buyerCount || 0, wallUnlocked: d.wallUnlocked || 1 }));
|
||
} catch (e) {}
|
||
}
|
||
// ── wall positions 2 & 3: the member's own offers, unlocked by qualifying buyers ──
|
||
function fillWallOffers(a) {
|
||
if (!a || !$('wallOffersCard')) return;
|
||
const unlocked = a.wallUnlocked || 1, bc = a.buyerCount || 0;
|
||
const offers = Array.isArray(a.wallOffers) ? a.wallOffers : [];
|
||
const NEED = [2, 5];
|
||
for (let i = 0; i < 2; i++) {
|
||
const o = offers[i] || {};
|
||
const open = unlocked >= i + 2;
|
||
$('woTitle' + i).value = o.title || ''; $('woTarget' + i).value = o.targetUrl || ''; $('woBanner' + i).value = o.bannerUrl || '';
|
||
$('woPrev' + i).hidden = !o.bannerUrl; $('woPrev' + i).innerHTML = o.bannerUrl ? '<img src="' + o.bannerUrl + '" alt="">' : '';
|
||
$('woLock' + i).textContent = open ? 'yours' : 'unlocks at ' + NEED[i] + ' qualifying buyers (' + bc + '/' + NEED[i] + ')';
|
||
$('woSlot' + i).classList.toggle('locked', !open);
|
||
}
|
||
$('woStatus').textContent = unlocked >= 3 ? 'Fully qualified: all three wall positions are yours.'
|
||
: unlocked === 2 ? 'Position 2 is yours. ' + (5 - bc) + ' more qualifying buyer' + (5 - bc === 1 ? '' : 's') + ' and position 3 is too.'
|
||
: (2 - bc) + ' more qualifying buyer' + (2 - bc === 1 ? '' : 's') + ' ($20 or more) opens position 2. You can set your links now; they go live the moment a slot unlocks.';
|
||
}
|
||
document.querySelectorAll('.wo-upload').forEach(b => b.addEventListener('click', () => { const f = document.querySelector('.wo-file[data-slot="' + b.dataset.slot + '"]'); if (f) f.click(); }));
|
||
document.querySelectorAll('.wo-file').forEach(inp => inp.addEventListener('change', async () => {
|
||
const i = inp.dataset.slot, f = inp.files[0]; if (!f) return;
|
||
$('woInfo' + i).textContent = 'Uploading…';
|
||
try {
|
||
const r = await (await fetch('/api/my/upload', { method: 'POST', headers: { 'Content-Type': f.type }, body: f })).json();
|
||
if (r.error) { $('woInfo' + i).textContent = r.error; }
|
||
else { $('woBanner' + i).value = r.url; $('woInfo' + i).textContent = 'Uploaded'; $('woPrev' + i).hidden = false; $('woPrev' + i).innerHTML = '<img src="' + r.url + '" alt="">'; }
|
||
} catch (e) { $('woInfo' + i).textContent = 'Upload failed. Try again.'; }
|
||
inp.value = '';
|
||
}));
|
||
if ($('woSaveBtn')) $('woSaveBtn').addEventListener('click', busy2($('woSaveBtn'), async () => {
|
||
const offers = [0, 1].map(i => ({ title: $('woTitle' + i).value, targetUrl: $('woTarget' + i).value, bannerUrl: $('woBanner' + i).value }));
|
||
const r = await api('/api/my/wall-offers', { offers });
|
||
IAP.status('Wall positions saved.', 'ok');
|
||
await loadLineBanner();
|
||
}));
|
||
const SOCIALS = ['facebook', 'twitter', 'youtube', 'instagram', 'tiktok', 'telegram', 'linkedin', 'website', '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 = '<img src="' + r.url + '" alt="line banner" style="max-width:320px;border-radius:10px">';
|
||
} catch (e) { $('lbUpInfo').textContent = 'Upload failed. Try again.'; }
|
||
$('lbFile').value = '';
|
||
});
|
||
$('lbSaveBtn').addEventListener('click', busy2($('lbSaveBtn'), async () => {
|
||
const r = await api('/api/my/linebanner', { bannerUrl: $('lbBanner').value, targetUrl: $('lbTarget').value });
|
||
IAP.status('Line banner saved. Your next three levels will meet it.', 'ok');
|
||
if (r.account) fillLineBanner(r.account);
|
||
}));
|
||
|
||
// ── login ad interstitial (ClickBaitPays pattern): after a successful
|
||
// sign-in the sponsor card appears; "Open Ad" opens the CTA link in a NEW
|
||
// tab (a real, counted click) while the timer counts down on THIS page —
|
||
// deliberately not paused, the member is expected to be in the ad tab.
|
||
// At zero, "Go to dashboard" appears.
|
||
async function showLoginAd() {
|
||
try {
|
||
const { ad } = await (await fetch('/api/ads/slot?type=login')).json();
|
||
if (!ad || !ad.targetUrl) return;
|
||
const gate = $('loginGate');
|
||
const openBtn = $('lgOpen');
|
||
const goBtn = $('lgContinue');
|
||
const timer = $('lgTimer');
|
||
$('lgCreative').innerHTML = ad.imageUrl
|
||
? '<img src="' + ad.imageUrl + '" alt="sponsor ad">'
|
||
: '<span class="lg-linkcard">' + (ad.title ? String(ad.title).replace(/[&<>]/g, '') : 'Visit today\'s sponsor') + '</span>';
|
||
$('lgStatus').textContent = 'Login ad sponsor — click "Open Ad" to begin.';
|
||
timer.hidden = true;
|
||
goBtn.hidden = true;
|
||
openBtn.disabled = false;
|
||
gate.hidden = false;
|
||
await new Promise(done => {
|
||
openBtn.onclick = () => {
|
||
window.open(ad.targetUrl, '_blank'); // the click relay counts it
|
||
openBtn.disabled = true;
|
||
$('lgStatus').textContent = 'Ad open in a new tab. View it and come back — the timer runs here.';
|
||
let left = ad.dwell || 10;
|
||
timer.hidden = false;
|
||
timer.textContent = 'Time remaining: ' + left + 's';
|
||
const t = setInterval(() => {
|
||
left -= 1;
|
||
if (left > 0) { timer.textContent = 'Time remaining: ' + left + 's'; return; }
|
||
clearInterval(t);
|
||
timer.textContent = 'Time is up';
|
||
timer.classList.add('done');
|
||
$('lgStatus').textContent = 'Thanks for the look. Your dashboard is ready.';
|
||
goBtn.hidden = false;
|
||
}, 1000);
|
||
goBtn.onclick = () => done();
|
||
};
|
||
});
|
||
gate.hidden = true;
|
||
$('lgTimer').classList.remove('done');
|
||
} catch (e) {}
|
||
}
|
||
|
||
// ── SPONSOR CHAT: two-way threads, presence-aware, with a slide-in drawer ──
|
||
let CHAT_ME = null, CHAT_OTHER = null, CHAT_LASTID = 0, CHAT_POLL = null, CHAT_CANMUTE = false, CHAT_IMUTE = false, CHAT_SPONSOR = null, CHAT_LAST_UNREAD = 0;
|
||
function chatSync(d) {
|
||
CHAT_ME = d.email || CHAT_ME;
|
||
CHAT_SPONSOR = (d.sponsor && d.sponsor.email) ? d.sponsor : null;
|
||
const link = $('chatMenuBtn'); if (link && link.closest('.bo-links')) link.closest('.bo-links').hidden = !d.email;
|
||
setChatBadge(d.chatUnread || 0);
|
||
CHAT_LAST_UNREAD = d.chatUnread || 0;
|
||
const tog = $('chatAvailToggle'); if (tog) tog.checked = d.chatAvailable !== false;
|
||
// Overview quick action + My line card: message-your-sponsor entry points
|
||
const qs = $('qaMsgSponsor');
|
||
if (qs) {
|
||
if (CHAT_SPONSOR) { qs.hidden = false; qs.dataset.email = CHAT_SPONSOR.email; qs.dataset.name = CHAT_SPONSOR.name || 'your sponsor'; }
|
||
else qs.hidden = true;
|
||
}
|
||
const card = $('sponsorMsgCard');
|
||
if (card) {
|
||
card.hidden = !CHAT_SPONSOR;
|
||
if (CHAT_SPONSOR && $('sponsorMsgName')) $('sponsorMsgName').textContent = CHAT_SPONSOR.name || 'your sponsor';
|
||
}
|
||
}
|
||
function setChatBadge(n) { const b = $('chatNavBadge'); if (b) { b.hidden = !n; b.textContent = n > 9 ? '9+' : n; } }
|
||
function stopPoll() { if (CHAT_POLL) { clearInterval(CHAT_POLL); CHAT_POLL = null; } }
|
||
function startPoll() { stopPoll(); CHAT_POLL = setInterval(() => pullThread(false), 4000); }
|
||
function autoGrow(el) { el.style.height = 'auto'; el.style.height = Math.min(120, el.scrollHeight) + 'px'; }
|
||
function closeDrawer() { stopPoll(); if ($('chatDrawer')) $('chatDrawer').hidden = true; CHAT_OTHER = null; loadDashboard(); }
|
||
|
||
async function openThreads() {
|
||
const dr = $('chatDrawer'); if (!dr) return;
|
||
dr.hidden = false; $('chatConvo').hidden = true; $('chatThreads').hidden = false;
|
||
$('chatBack').hidden = true; $('chatMute').hidden = true; $('chatDot').hidden = true;
|
||
$('chatTitle').textContent = 'Messages'; $('chatStatus').textContent = '';
|
||
stopPoll(); CHAT_OTHER = null;
|
||
$('chatThreads').innerHTML = '<p class="muted small" style="padding:14px">Loading…</p>';
|
||
try {
|
||
const r = await (await fetch('/api/my/chat/threads')).json();
|
||
const list = r.threads || [];
|
||
// always offer "message your sponsor" up top when they have one and no thread yet
|
||
const hasSponsorThread = CHAT_SPONSOR && list.some(t => t.email === CHAT_SPONSOR.email);
|
||
const sponsorRow = (CHAT_SPONSOR && !hasSponsorThread)
|
||
? '<div class="chat-thread" data-email="' + esc(CHAT_SPONSOR.email) + '" data-name="' + esc(CHAT_SPONSOR.name) + '">'
|
||
+ '<span class="pres-dot' + (CHAT_SPONSOR.online ? ' on' : '') + '"></span>'
|
||
+ '<div class="ct-main"><div class="ct-name">' + esc(CHAT_SPONSOR.name) + '</div>'
|
||
+ '<div class="ct-last">Your sponsor · tap to message</div></div></div>'
|
||
: '';
|
||
if (!list.length && !sponsorRow) { $('chatThreads').innerHTML = '<div class="chat-empty">No conversations yet. You can message anyone in your line from “My line”.</div>'; return; }
|
||
$('chatThreads').innerHTML = sponsorRow + list.map(t =>
|
||
'<div class="chat-thread" data-email="' + esc(t.email) + '" data-name="' + esc(t.name) + '">'
|
||
+ '<span class="pres-dot' + (t.online ? ' on' : '') + '"></span>'
|
||
+ '<div class="ct-main"><div class="ct-name">' + esc(t.name) + '</div>'
|
||
+ '<div class="ct-last">' + (t.last.fromMe ? 'You: ' : '') + esc((t.last.body || '').slice(0, 64)) + '</div></div>'
|
||
+ (t.unread ? '<span class="ct-un">' + t.unread + '</span>' : '') + '</div>').join('');
|
||
$('chatThreads').querySelectorAll('.chat-thread').forEach(el =>
|
||
el.addEventListener('click', () => openConvo(el.dataset.email, el.dataset.name)));
|
||
} catch (e) { $('chatThreads').innerHTML = '<div class="chat-empty">Could not load messages.</div>'; }
|
||
}
|
||
|
||
async function openConvo(email, name) {
|
||
if (!email) return;
|
||
CHAT_OTHER = email; CHAT_LASTID = 0;
|
||
const dr = $('chatDrawer'); if (!dr) return;
|
||
dr.hidden = false; $('chatThreads').hidden = true; $('chatConvo').hidden = false;
|
||
$('chatBack').hidden = false; $('chatDot').hidden = false;
|
||
$('chatTitle').textContent = name || email; $('chatStatus').textContent = '…';
|
||
$('chatMsgs').innerHTML = ''; $('chatBanner').hidden = true;
|
||
$('chatInput').disabled = false; $('chatSend').disabled = false;
|
||
await pullThread(true); startPoll();
|
||
setTimeout(() => { const i = $('chatInput'); if (i) i.focus(); }, 60);
|
||
}
|
||
|
||
function renderMsgs(msgs) {
|
||
const box = $('chatMsgs'); if (!box) return;
|
||
const atBottom = box.scrollTop + box.clientHeight >= box.scrollHeight - 60;
|
||
for (const m of msgs) {
|
||
if (m.id <= CHAT_LASTID) continue;
|
||
CHAT_LASTID = Math.max(CHAT_LASTID, m.id);
|
||
const d = document.createElement('div');
|
||
d.className = 'cbub ' + (m.fromMe ? 'me' : 'them');
|
||
d.textContent = m.body;
|
||
const t = document.createElement('span'); t.className = 'ct-time';
|
||
t.textContent = new Date(m.sent).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
|
||
d.appendChild(t); box.appendChild(d);
|
||
}
|
||
if (atBottom) box.scrollTop = box.scrollHeight;
|
||
}
|
||
|
||
async function pullThread(reset) {
|
||
if (!CHAT_OTHER) return;
|
||
try {
|
||
const r = await (await fetch('/api/my/chat/thread?with=' + encodeURIComponent(CHAT_OTHER) + '&after=' + (reset ? 0 : CHAT_LASTID))).json();
|
||
if (r.error) { $('chatBanner').hidden = false; $('chatBanner').textContent = r.error; return; }
|
||
if (reset) { $('chatMsgs').innerHTML = ''; CHAT_LASTID = 0; }
|
||
renderMsgs(r.messages || []);
|
||
$('chatDot').className = 'pres-dot' + (r.online ? ' on' : '');
|
||
$('chatStatus').textContent = r.online ? 'active now' : (r.available ? 'away · will get your note' : 'not taking live chats · leave a note');
|
||
CHAT_CANMUTE = !!r.canMute; CHAT_IMUTE = !!r.iMute;
|
||
const mb = $('chatMute'); mb.hidden = !CHAT_CANMUTE; mb.textContent = CHAT_IMUTE ? 'Unmute' : 'Mute';
|
||
const blocked = !!r.blocked;
|
||
$('chatInput').disabled = blocked; $('chatSend').disabled = blocked;
|
||
if (blocked) { $('chatBanner').hidden = false; $('chatBanner').textContent = 'They are not accepting messages from you right now.'; }
|
||
else if (CHAT_IMUTE) { $('chatBanner').hidden = false; $('chatBanner').textContent = 'You muted this member. Unmute to let them message you again.'; }
|
||
else $('chatBanner').hidden = true;
|
||
} catch (e) {}
|
||
}
|
||
|
||
async function chatSend() {
|
||
const inp = $('chatInput'); if (!inp) return;
|
||
const text = inp.value.trim(); if (!text || !CHAT_OTHER) return;
|
||
$('chatSend').disabled = true;
|
||
try {
|
||
const r = await api('/api/my/chat/send', { to: CHAT_OTHER, body: text });
|
||
inp.value = ''; autoGrow(inp); renderMsgs([r.message]);
|
||
const box = $('chatMsgs'); box.scrollTop = box.scrollHeight;
|
||
} catch (e) { IAP.status(e.message || 'Could not send.', 'bad'); }
|
||
finally { $('chatSend').disabled = false; inp.focus(); }
|
||
}
|
||
|
||
async function toggleMute() {
|
||
if (!CHAT_OTHER) return;
|
||
try { const r = await api('/api/my/chat/mute', { email: CHAT_OTHER, muted: !CHAT_IMUTE }); CHAT_IMUTE = r.muted; $('chatMute').textContent = CHAT_IMUTE ? 'Unmute' : 'Mute'; pullThread(false); }
|
||
catch (e) { IAP.status(e.message, 'bad'); }
|
||
}
|
||
|
||
(function chatInit() {
|
||
if (!$('chatDrawer')) return;
|
||
const on = (id, ev, fn) => { const el = $(id); if (el) el.addEventListener(ev, fn); };
|
||
on('chatMenuBtn', 'click', e => { if (e && e.preventDefault) e.preventDefault(); openThreads(); });
|
||
on('chatClose', 'click', closeDrawer);
|
||
on('chatBack', 'click', openThreads);
|
||
on('chatMute', 'click', toggleMute);
|
||
on('chatSend', 'click', chatSend);
|
||
const inp = $('chatInput');
|
||
if (inp) {
|
||
inp.addEventListener('input', () => autoGrow(inp));
|
||
inp.addEventListener('keydown', e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); chatSend(); } });
|
||
}
|
||
on('qaMsgSponsor', 'click', () => { const q = $('qaMsgSponsor'); if (q.dataset.email) openConvo(q.dataset.email, q.dataset.name || 'your sponsor'); });
|
||
on('lineMsgSponsor', 'click', () => { if (CHAT_SPONSOR) openConvo(CHAT_SPONSOR.email, CHAT_SPONSOR.name || 'your sponsor'); });
|
||
on('chatAvailToggle', 'change', async () => {
|
||
const t = $('chatAvailToggle');
|
||
try { await api('/api/my/chat/available', { available: t.checked }); IAP.status(t.checked ? 'You are available to chat with your line.' : 'Live chat off. People can still leave you a note.', 'ok'); }
|
||
catch (e) { IAP.status(e.message, 'bad'); t.checked = !t.checked; }
|
||
});
|
||
// presence heartbeat + new-message notifier: pop + toast when unread rises
|
||
setInterval(async () => {
|
||
try {
|
||
const r = await (await fetch('/api/my/ping')).json();
|
||
const n = r.chatUnread || 0;
|
||
if (n > CHAT_LAST_UNREAD) { playSound('pop'); IAP.status('💬 New message from your team.', 'ok'); }
|
||
CHAT_LAST_UNREAD = n; setChatBadge(n);
|
||
} catch (e) {}
|
||
}, 20000);
|
||
})();
|
||
|
||
// ad surfaces: a banner greets the sign-in screen; members see live
|
||
// banner + text placements inside the back office (they ARE the audience)
|
||
IAP.adSlot('banner', 'adSlotLogin');
|
||
IAP.adSlot('banner', 'adSlotOverview');
|
||
IAP.adSlot('banner', 'adSlotSide', { width: 125, height: 125 }); // square button ad in the sidebar
|
||
|
||
render();
|
||
})();
|
||
|
||
// ── 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(); });
|
||
})();
|