9608d07544
Stops early when the wallet lacks the POL. Only Trust Wallet users see the proportion warning (smaller package / add POL / other wallet); everyone else goes straight to the wallet confirmation. Buy-pane tip + chatbot reworded. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1792 lines
100 KiB
JavaScript
1792 lines
100 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) {
|
||
const purchased = d.credits || 0, earned = st.earned || 0, wc = Math.min(d.welcomeCredits || 0, earned);
|
||
$('dbCredits').textContent = (purchased + earned).toLocaleString();
|
||
$('dbCreditsSub').textContent = purchased.toLocaleString() + ' purchased · ' + earned.toLocaleString() + ' earned';
|
||
donut($('chDonut'),
|
||
[{ v: purchased, color: CH.mint }, { v: wc, color: CH.amber }, { v: earned - wc, color: CH.cyan }],
|
||
{ big: (purchased + earned).toLocaleString(), small: 'spendable' });
|
||
legend($('chDonutLegend'), [
|
||
{ color: CH.mint, label: 'Purchased', v: purchased.toLocaleString() },
|
||
{ color: CH.amber, label: 'Welcome', v: wc.toLocaleString() },
|
||
{ color: CH.cyan, label: 'Earned by viewing', v: Math.max(0, earned - wc).toLocaleString() }]);
|
||
const done = Math.min(st.views || 0, st.target || 5), left = Math.max(0, (st.target || 5) - done);
|
||
donut($('chRing'), [{ v: done, color: st.claimed ? CH.mint : CH.cyan }, { v: left, color: 'none' }],
|
||
{ big: done + '/' + (st.target || 5), small: st.claimed ? 'claimed' : 'ads viewed' });
|
||
legend($('chRingLegend'), [
|
||
{ color: st.claimed ? CH.mint : CH.cyan, label: 'Viewed today', v: done },
|
||
{ color: CH.track, label: 'To go', v: left },
|
||
{ color: CH.amber, label: 'Claim pays', v: '+' + (st.claimCredits || 0) + ' credits' }]);
|
||
}
|
||
} catch (e) {}
|
||
// recent on-chain payouts, sized to scale, oldest→newest
|
||
try {
|
||
let evs = [];
|
||
if (d.memberId) {
|
||
const a = await (await fetch('/api/my/activity')).json();
|
||
evs = (a.earnings || []).filter(e => e.amountWei).sort((x, y) => x.block - y.block).slice(-12);
|
||
}
|
||
bars($('chEarnBars'), $('chEarnX'), evs.map(e => ({
|
||
v: Number(BigInt(e.amountWei) / 1000000000000n) / 1e6,
|
||
label: e.type === 'AwardPaid' ? 'award' : 'L' + (e.tier || 1),
|
||
tip: IAP.fmtPol(e.amountWei) + ' POL', alt: e.type === 'AwardPaid' })));
|
||
if (!evs.length) $('chEarnSub').textContent = d.memberId
|
||
? 'no payouts yet — share your link' : 'activate a package to start earning';
|
||
} catch (e) {}
|
||
// campaign delivery: impressions per campaign
|
||
try {
|
||
const r = await (await fetch('/api/my/campaigns')).json();
|
||
const cs = (r.campaigns || []).slice(0, 8);
|
||
bars($('chCampBars'), $('chCampX'), cs.map(c => ({
|
||
v: c.imps || 0, label: String(c.name || c.type).slice(0, 9),
|
||
tip: (c.name || c.type) + ': ' + (c.imps || 0) + ' imps · ' + (c.clicks || 0) + ' clicks',
|
||
alt: c.type === 'text' })));
|
||
if (!cs.length) $('chCampSub').textContent = 'no campaigns yet — place your first ad';
|
||
} catch (e) {}
|
||
}
|
||
// ── live updates: subscribe to the chain event stream so the members area
|
||
// reacts to on-chain changes (payouts, qualifications) without a refresh ──
|
||
let MYID = 0;
|
||
// synthesized sounds (no asset files; CSP-safe). cha-ching on a payment, pop on a message.
|
||
function playSound(kind) {
|
||
try {
|
||
const AC = window.AudioContext || window.webkitAudioContext; if (!AC) return;
|
||
const ctx = window.__iapAC || (window.__iapAC = new AC());
|
||
if (ctx.state === 'suspended') ctx.resume();
|
||
const now = ctx.currentTime;
|
||
const tone = (freq, at, dur, type, peak) => {
|
||
const o = ctx.createOscillator(), g = ctx.createGain();
|
||
o.type = type || 'sine'; o.frequency.value = freq;
|
||
g.gain.setValueAtTime(0.0001, now + at);
|
||
g.gain.exponentialRampToValueAtTime(peak || 0.22, now + at + 0.02);
|
||
g.gain.exponentialRampToValueAtTime(0.0001, now + at + dur);
|
||
o.connect(g).connect(ctx.destination); o.start(now + at); o.stop(now + at + dur + 0.02);
|
||
};
|
||
if (kind === 'chaching') { tone(1318, 0, 0.34, 'sine', 0.25); tone(1760, 0.09, 0.4, 'sine', 0.25); }
|
||
else { tone(680, 0, 0.16, 'triangle', 0.18); tone(1020, 0.05, 0.16, 'triangle', 0.16); }
|
||
} catch (e) {}
|
||
}
|
||
function startLiveFeed() {
|
||
if (window.__iapFeed || !window.EventSource) return;
|
||
try {
|
||
const es = new EventSource('/api/feed/live');
|
||
window.__iapFeed = es;
|
||
es.onmessage = m => { let ev; try { ev = JSON.parse(m.data); } catch (e) { return; } handleLiveEvent(ev); };
|
||
// browser auto-reconnects on error; nothing to do
|
||
} catch (e) {}
|
||
}
|
||
let liveRefreshT = null;
|
||
function liveRefresh() { // debounce a burst of events into one refresh
|
||
clearTimeout(liveRefreshT);
|
||
liveRefreshT = setTimeout(() => { loadDashboard(); try { loadLineage(); } catch (e) {} }, 600);
|
||
}
|
||
function handleLiveEvent(ev) {
|
||
if (!MYID || !ev || !ev.type) return;
|
||
let toast = null, kind = 'ok';
|
||
let sound = null;
|
||
if (ev.type === 'TierPaid' && ev.recipientId === MYID) { toast = '💸 You earned a level-' + ev.tier + ' payout of ' + IAP.fmtPol(ev.amountWei) + ' POL!'; sound = 'chaching'; }
|
||
else if (ev.type === 'AwardPaid' && ev.toId === MYID) { toast = '💸 You received ' + IAP.fmtPol(ev.amountWei) + ' POL!'; sound = 'chaching'; }
|
||
else if (ev.type === 'BuyerCounted' && ev.sponsorId === MYID) toast = '🎯 A referral just qualified — you now have ' + ev.newCount + ' qualifying buyer' + (ev.newCount === 1 ? '' : 's') + '!';
|
||
else if (ev.type === 'PassedUp' && ev.skippedId === MYID) { toast = '⚠️ A level-' + ev.tier + ' payout passed you by. Get qualified to catch these.'; kind = 'bad'; }
|
||
else if (ev.type === 'Purchase' && ev.buyerId === MYID) toast = '✅ Purchase settled on-chain — your credits are updated.';
|
||
else if (ev.type === 'MemberActivated' && ev.sponsorId === MYID) toast = '🤝 A new member just activated in your line!';
|
||
if (toast) { IAP.status(toast, kind); if (sound) playSound(sound); liveRefresh(); }
|
||
}
|
||
|
||
// training center: videos + materials (admin-curated via data/training.json)
|
||
async function loadTraining() {
|
||
try {
|
||
const r = await (await fetch('/api/training')).json();
|
||
const el = $('trainingList'); if (!el) return;
|
||
const items = r.items || [];
|
||
if (!items.length) { el.innerHTML = '<p class="muted small">Training materials are being added. Check back soon.</p>'; return; }
|
||
el.innerHTML = items.map(it => {
|
||
const isVid = it.videoUrl && /\.(mp4|webm)(\?|$)/i.test(it.videoUrl);
|
||
const media = isVid ? '<video src="' + esc(it.videoUrl) + '" 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 '<div class="card"><h3>' + esc(it.title || 'Lesson') + '</h3>'
|
||
+ (it.desc ? '<p class="muted small">' + esc(it.desc) + '</p>' : '')
|
||
+ (media ? '<p style="margin:10px 0">' + media + '</p>' : '')
|
||
+ (links.length ? '<p>' + links.join(' ') + '</p>' : '') + '</div>';
|
||
}).join('');
|
||
} catch (e) {}
|
||
}
|
||
// visual line tree on the Overview: YOU + three levels, qualified directs in gold
|
||
async function loadLineTree(buyerCount) {
|
||
try {
|
||
const r = await (await fetch('/api/my/line')).json();
|
||
const el = $('lineTree'); if (!el) return;
|
||
const levels = r.levels || [];
|
||
const total = levels.reduce((n, L) => n + L.members.length, 0);
|
||
if ($('treeSub')) $('treeSub').textContent = total ? total + ' across 3 levels' : 'share your link to grow';
|
||
const chip = (m, q) => '<span class="lt-node' + (q ? ' qualified' : (m.email ? '' : ' deep')) + '" title="' + esc(m.name || 'member') + (q ? ' · qualified buyer' : '') + '">' + esc(String(m.name || 'M').replace(/^@/, '').slice(0, 12)) + '</span>';
|
||
const rowFor = lvl => {
|
||
const L = levels.find(x => x.level === lvl); const members = L ? L.members : [];
|
||
const qn = lvl === 1 ? (buyerCount || 0) : 0; // highlight your qualified directs
|
||
let html = members.map((m, i) => chip(m, i < qn)).join('');
|
||
if (lvl <= 2 && members.length < (lvl === 1 ? 2 : 4)) html += '<span class="lt-node open">+ open</span>';
|
||
if (!html) html = '<span class="lt-node open">+ open</span>';
|
||
return '<div class="lt-row"><span class="lt-cap">L' + lvl + '</span><div class="lt-nodes">' + html + '</div></div>';
|
||
};
|
||
el.innerHTML = '<div class="lt-top"><span class="lt-you">YOU</span></div><div class="lt-stem"></div>' + rowFor(1) + rowFor(2) + rowFor(3);
|
||
} catch (e) {}
|
||
}
|
||
async function loadDashboard() {
|
||
try {
|
||
const d = await (await fetch('/api/my/dashboard')).json();
|
||
if (d.error) return;
|
||
chatSync(d);
|
||
MYID = d.memberId || MYID;
|
||
startLiveFeed();
|
||
if (!window.__lbChecked) { // daily login bonus, once per session (server guards once/day)
|
||
window.__lbChecked = true;
|
||
api('/api/my/login-bonus').then(r => {
|
||
if (r && r.granted > 0) {
|
||
IAP.status('🎁 Daily login bonus: +' + r.granted + ' credits' + (r.streak > 1 ? ' · ' + r.streak + '-day streak' : '') + '!', 'ok');
|
||
playSound('chaching'); setTimeout(loadDashboard, 900);
|
||
}
|
||
}).catch(() => {});
|
||
}
|
||
const wc = d.welcomeCredits || 0;
|
||
$('dbCredits').textContent = ((d.credits || 0) + wc).toLocaleString();
|
||
$('dbCreditsSub').textContent = wc ? (d.credits || 0).toLocaleString() + ' purchased · ' + wc + ' welcome' : '';
|
||
$('dbEarned').textContent = IAP.fmtPol(d.earnedWei || '0');
|
||
$('dbBuyers').textContent = d.buyerCount || 0;
|
||
$('dbTeam').textContent = (d.referrals || []).length;
|
||
// trend chips: only real, computable facts
|
||
const ec = $('dbEarnedChip');
|
||
if (ec && d.earnCount) { ec.hidden = false; ec.textContent = d.earnCount + ' instant payout' + (d.earnCount > 1 ? 's' : ''); }
|
||
const bc = $('dbBuyersChip');
|
||
if (bc && d.memberId) {
|
||
bc.hidden = false;
|
||
bc.textContent = (d.buyerCount || 0) >= 5 ? 'level 3 open' : (d.buyerCount || 0) >= 2 ? 'level 2 open' : 'level 2 at 2 buyers';
|
||
}
|
||
const tc = $('dbTeamChip'), wk = (d.referrals || []).filter(r => Date.now() - new Date(r.joined) < 6048e5).length;
|
||
if (tc && wk) { tc.hidden = false; tc.textContent = '+' + wk + ' this week'; }
|
||
setInboxBadge(d.inboxUnread || 0);
|
||
if (d.sponsorMsg) showSponsorModal(d.sponsorMsg);
|
||
renderBadges(d);
|
||
loadFeatured();
|
||
loadLineTree(d.buyerCount || 0);
|
||
loadCharts(d);
|
||
$('nextMove').textContent = nextMove(d);
|
||
renderSteps(d);
|
||
const wrap = $('rosterWrap');
|
||
if ((d.referrals || []).length) {
|
||
$('rosterEmpty').hidden = true;
|
||
let t = wrap.querySelector('table');
|
||
if (t) t.remove();
|
||
t = document.createElement('table');
|
||
t.className = 'roster';
|
||
t.innerHTML = d.referrals.map(r => '<tr><td>' + String(r.name || r.email || '').replace(/[&<>]/g, '') + '</td>'
|
||
+ '<td>' + new Date(r.joined).toLocaleDateString() + '</td>'
|
||
+ '<td><span class="badge' + (r.status === 'joined free' ? ' amber' : '') + '">' + r.status + '</span></td></tr>').join('');
|
||
wrap.appendChild(t);
|
||
}
|
||
// overview recent-activity widget: chain events + line joins, newest first
|
||
try {
|
||
const c = await IAP.getConfig();
|
||
const ov = $('ovFeed');
|
||
const rows = [];
|
||
if (d.memberId) {
|
||
const a = await (await fetch('/api/my/activity')).json();
|
||
for (const ev of [...(a.earnings || []), ...(a.purchases || [])]
|
||
.sort((x, y) => y.block - x.block).slice(0, 5)) rows.push(IAP.feedRow(ev, c));
|
||
}
|
||
for (const r of (d.referrals || []).slice(0, 3)) {
|
||
const div = document.createElement('div');
|
||
div.className = 'row';
|
||
div.innerHTML = '<span>🤝 ' + esc(r.name || r.email || 'A new member') + ' joined your line</span><span class="tx muted small">'
|
||
+ new Date(r.joined).toLocaleDateString() + '</span>';
|
||
rows.push(div);
|
||
}
|
||
if (rows.length) {
|
||
ov.innerHTML = '';
|
||
rows.slice(0, 6).forEach(r => ov.appendChild(r));
|
||
}
|
||
} catch (e) {}
|
||
// ready-to-send share message + promo tools, personalized
|
||
const link = location.origin + '/join/' + (d.username || d.refCode || d.memberId || '');
|
||
fillPromo(link, d);
|
||
if (d.username) { // wall link rides the username
|
||
const wl = location.origin + '/wall/' + d.username;
|
||
$('wallLine').textContent = wl;
|
||
$('wallCopy').hidden = false;
|
||
$('wallOpen').hidden = false;
|
||
$('wallOpen').href = '/wall/' + d.username;
|
||
$('wallCopy').onclick = async () => {
|
||
try { await navigator.clipboard.writeText(wl); IAP.status('Wall link copied.', 'ok'); }
|
||
catch (e) { IAP.status('Copy failed. Select the link text instead.', 'bad'); }
|
||
};
|
||
}
|
||
if (d.refCode || d.memberId) {
|
||
const pitch = 'I found an advertising site that pays referrals instantly to your own wallet. '
|
||
+ 'No withdrawals, no waiting, and every payment is public on a blockchain ledger you can check yourself. '
|
||
+ 'Free to join and look around: ' + link;
|
||
$('copyPitch').hidden = false;
|
||
$('pitchPreview').hidden = false;
|
||
$('pitchPreview').textContent = '"' + pitch + '"';
|
||
$('copyPitch').onclick = async () => {
|
||
try { await navigator.clipboard.writeText(pitch); IAP.status('Message copied. Paste it anywhere.', 'ok'); }
|
||
catch (e) { IAP.status('Copy failed. Select the preview text instead.', 'bad'); }
|
||
};
|
||
}
|
||
} catch (e) {}
|
||
}
|
||
|
||
// ── back-office menu: hash-routed panes ───────────────
|
||
const PANES = ['overview', 'line', 'buy', 'campaigns', 'earn', 'earnings', 'promo', 'training', 'wallet', 'profile'];
|
||
const TITLES = { overview: 'Overview', line: 'My line', buy: 'Buy packages', campaigns: 'Campaigns',
|
||
earn: 'Earn credits', earnings: 'Earnings', promo: 'Promo tools', training: 'Training',
|
||
wallet: 'Wallet & account', profile: 'Profile' };
|
||
function setPane(name) {
|
||
if (!PANES.includes(name)) name = 'overview';
|
||
for (const p of PANES) {
|
||
const el = $('pane-' + p);
|
||
if (el) el.hidden = p !== name;
|
||
}
|
||
document.querySelectorAll('.bo-menu [data-pane]').forEach(b =>
|
||
b.classList.toggle('on', b.dataset.pane === name));
|
||
if ($('boTitle')) $('boTitle').textContent = TITLES[name];
|
||
if (name === 'earn') setEarnSub(earnSub); // refresh whichever sub-tab is active
|
||
if (name === 'profile') loadLineBanner();
|
||
if (name === 'line') { loadLineage(); loadUplineMessages(); }
|
||
if (name === 'campaigns') ['cTarget', 'cImage', 'cVideoUrl'].forEach(id => { if ($(id)) $(id).value = ''; }); // no residual URL between visits
|
||
if (name === 'training') loadTraining();
|
||
document.getElementById('memberArea').classList.remove('side-open'); // close mobile drawer
|
||
if (location.hash !== '#' + name) history.replaceState(null, '', '#' + name);
|
||
}
|
||
document.querySelectorAll('.bo-menu [data-pane]').forEach(b =>
|
||
b.addEventListener('click', () => setPane(b.dataset.pane)));
|
||
document.querySelectorAll('.qa [data-goto]').forEach(b =>
|
||
b.addEventListener('click', () => setPane(b.dataset.goto)));
|
||
const qaCopy = document.getElementById('qaCopyInvite');
|
||
if (qaCopy) qaCopy.addEventListener('click', async () => {
|
||
const link = document.getElementById('inviteLine').textContent;
|
||
if (!link || !link.startsWith('http')) { setPane('line'); return; }
|
||
try { await navigator.clipboard.writeText(link); IAP.status('Invite link copied.', 'ok'); }
|
||
catch (e) { setPane('line'); }
|
||
});
|
||
window.addEventListener('hashchange', () => setPane(location.hash.slice(1)));
|
||
if ($('boBurger')) $('boBurger').addEventListener('click', () =>
|
||
document.getElementById('memberArea').classList.toggle('side-open'));
|
||
|
||
async function render() {
|
||
const me = await IAP.refreshNavWallet();
|
||
// one way in: email. A wallet-only session (no account) is sent back to the
|
||
// email card with a finish-setup note; verifying the code links that wallet.
|
||
const walletOnly = !!(me && me.signedIn && !me.email);
|
||
const signedIn = me && me.signedIn && !walletOnly;
|
||
$('authArea').hidden = !!signedIn;
|
||
$('memberArea').hidden = !signedIn;
|
||
if ($('mcFinish')) $('mcFinish').hidden = !walletOnly;
|
||
if (!signedIn) return;
|
||
if ($('adminLink')) $('adminLink').hidden = !me.isAdmin; // admin portal link, only for ADMIN_EMAIL
|
||
setPane(location.hash.slice(1) || 'overview');
|
||
$('campGate').hidden = !!me.memberId;
|
||
$('earnGate').hidden = !!me.memberId;
|
||
loadDashboard();
|
||
|
||
const who = [];
|
||
if (me.email) who.push(me.email);
|
||
if (me.address) who.push('wallet <span class="mono">' + me.address.slice(0, 8) + '…' + me.address.slice(-6) + '</span>');
|
||
else who.push('no wallet linked yet');
|
||
if (me.memberId) who.push('on-chain <b>member #' + me.memberId + '</b>'
|
||
+ (me.onchainSponsorId ? ', sponsored by #' + me.onchainSponsorId : ''));
|
||
else if (me.sponsorId) who.push('invited by member #' + me.sponsorId);
|
||
$('posLine').innerHTML = who.join('<br>');
|
||
|
||
$('creditLine').textContent = (me.credits || 0).toLocaleString();
|
||
$('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 ? 'Current username: @' + me.username : 'No username yet. Members see you as a number until you pick one.';
|
||
if (!$('pfUsername').value) $('pfUsername').value = me.username || '';
|
||
$('pfDetails').innerHTML = 'Email: ' + (me.email || 'none') + '<br>Wallet: '
|
||
+ (me.address ? '<span class="mono">' + me.address.slice(0, 10) + '…' + me.address.slice(-6) + '</span>' : 'not linked yet')
|
||
+ '<br>On-chain member: ' + (me.memberId ? '#' + me.memberId : 'not yet');
|
||
// the share link works from day one; usernames make it a vanity link
|
||
if (me.username || me.refCode || me.memberId) {
|
||
$('inviteLine').textContent = location.origin + '/join/' + (me.username || me.refCode || me.memberId);
|
||
$('copyInvite').hidden = false;
|
||
} else {
|
||
$('inviteLine').textContent = 'Sign in with your email to get your link.';
|
||
$('copyInvite').hidden = true;
|
||
}
|
||
if (me.memberId) {
|
||
const bc = me.buyerCount || 0;
|
||
$('qualLine').innerHTML = '<b>' + bc + '</b> qualifying buyer(s) referred<br>'
|
||
+ (bc >= 5 ? '<span class="badge">Level 3 unlocked: full three-level earnings</span>'
|
||
: bc >= 2 ? '<span class="badge">Level 2 unlocked</span> · ' + (5 - bc) + ' more for level 3'
|
||
: (2 - bc) + ' more buyer(s) of $20+ unlock level 2');
|
||
loadActivity();
|
||
} else {
|
||
$('qualLine').innerHTML = 'Share your link now. Then switch on payouts (free, above) '
|
||
+ '<b>before your people start buying</b>: the contract locks each buyer to their sponsor at '
|
||
+ 'their first purchase, and payments only route to wallets that are switched on.';
|
||
}
|
||
}
|
||
|
||
async function loadActivity() {
|
||
try {
|
||
const c = await IAP.getConfig();
|
||
const a = await (await fetch('/api/my/activity')).json();
|
||
const fill = (id, evs, empty) => {
|
||
const el = $(id);
|
||
el.innerHTML = '';
|
||
if (!evs || !evs.length) { el.innerHTML = '<div class="row muted">' + empty + '</div>'; return; }
|
||
for (const ev of evs) el.appendChild(IAP.feedRow(ev, c));
|
||
};
|
||
fill('earnFeed', a.earnings, 'No payouts yet. They appear here the moment one lands.');
|
||
fill('refFeed', a.referrals, 'No referral activity yet. Share your invite link.');
|
||
fill('buyFeed', a.purchases, 'No purchases from your wallet yet.');
|
||
} catch (e) {}
|
||
}
|
||
|
||
async function loadCampaigns() {
|
||
try {
|
||
const r = await (await fetch('/api/my/campaigns')).json();
|
||
if (r.error) return;
|
||
lastRates = r.rates;
|
||
// populate the banner-size dropdown once (ids map to NAS width/height)
|
||
if (r.bannerSizes && $('cSize') && !$('cSize').options.length)
|
||
$('cSize').innerHTML = r.bannerSizes.map(s => '<option value="' + s.id + '">' + s.label + '</option>').join('');
|
||
soloHint();
|
||
$('rateLine').textContent = 'Available to spend: ' + r.availableCredits.toLocaleString()
|
||
+ (r.earnedCredits ? ' (' + r.purchasedCredits.toLocaleString() + ' purchased + ' + r.earnedCredits + ' earned)' : '')
|
||
+ ' credits · rates: banner ' + r.rates.bannerCreditsPerBatch + 'cr/' + r.rates.bannerBatch
|
||
+ ' views, text ' + r.rates.textCreditsPerBatch + 'cr/' + r.rates.textBatch
|
||
+ ' views, login ' + r.rates.loginCreditsPerDay + 'cr/day, solo '
|
||
+ (r.rates.soloCostPerRecipient || 5) + 'cr/delivery';
|
||
const el = $('campList');
|
||
el.innerHTML = '';
|
||
if (!r.campaigns.length) { el.innerHTML = '<p class="muted small">No campaigns yet. Launch your first below.</p>'; return; }
|
||
const tbl = document.createElement('div');
|
||
tbl.className = 'tablewrap';
|
||
tbl.innerHTML = '<table><thead><tr><th>Name</th><th>Type</th><th class="num">Views</th><th class="num">Clicks</th>'
|
||
+ '<th class="num">Spent</th><th class="num">Budget</th><th>Status</th><th></th></tr></thead><tbody>'
|
||
+ r.campaigns.map(c => '<tr><td><b>' + c.name + '</b></td>'
|
||
+ '<td>' + c.type + (c.type === 'banner' && c.width ? ' <span class="muted small">' + c.width + '×' + c.height + '</span>' : '') + '</td>'
|
||
+ '<td class="num">' + c.imps.toLocaleString()
|
||
+ (c.impsNas ? ' <span class="muted small" title="views across the network">+' + c.impsNas.toLocaleString() + ' network</span>' : '')
|
||
+ '</td><td class="num">' + c.clicks + '</td>'
|
||
+ '<td class="num">' + c.spent + '</td><td class="num">' + c.budget + '</td>'
|
||
+ '<td>' + (c.status === 'out' ? '<span class="badge amber">budget spent</span>' : c.status) + '</td>'
|
||
+ '<td>' + (c.status === 'active' ? '<button class="btn small sec" data-camp="' + c.id + '" data-act="pause">Pause</button>'
|
||
: c.status === 'paused' ? '<button class="btn small sec" data-camp="' + c.id + '" data-act="resume">Resume</button>' : '')
|
||
+ ' <button class="btn small sec" data-topup="' + c.id + '">Buy more views</button>'
|
||
+ '</td></tr>').join('') + '</tbody></table>';
|
||
el.appendChild(tbl);
|
||
el.querySelectorAll('button[data-camp]').forEach(b => b.addEventListener('click', async () => {
|
||
try { await api('/api/my/campaigns/' + b.dataset.camp + '/' + b.dataset.act); await loadCampaigns(); }
|
||
catch (e) { IAP.status(e.message, 'bad'); }
|
||
}));
|
||
el.querySelectorAll('button[data-topup]').forEach(b => b.addEventListener('click', async () => {
|
||
const n = prompt('How many credits to add to this campaign? (buys more views)');
|
||
if (!n) return;
|
||
try { const r = await api('/api/my/campaigns/' + b.dataset.topup + '/topup', { credits: Number(n) });
|
||
IAP.status('Added ' + r.added + ' credits' + (r.reactivated ? ' — campaign is live again.' : '.'), 'ok');
|
||
await loadCampaigns(); loadDashboard();
|
||
} catch (e) { IAP.status(e.message, 'bad'); }
|
||
}));
|
||
} catch (e) {}
|
||
}
|
||
let lastRates = null;
|
||
function soloHint() {
|
||
if (!lastRates || $('cType').value !== 'solo') return;
|
||
const cost = lastRates.soloCostPerRecipient || 5;
|
||
const n = Math.floor((Number($('cBudget').value) || 0) / cost);
|
||
$('cSoloHint').textContent = cost + ' credits per guaranteed inbox delivery'
|
||
+ (n ? ' — this budget reaches ' + n + ' members' : '')
|
||
+ '. Readers earn ' + (lastRates.soloReadCredits || 2) + ' credits for a real read, so your message gets opened.';
|
||
}
|
||
$('cBudget').addEventListener('input', soloHint);
|
||
// rich solo editor: small toolbar over contenteditable (CSP allows no external editor);
|
||
// the server whitelist-sanitizes whatever HTML arrives, this is just authoring comfort
|
||
document.querySelectorAll('.ed-bar [data-cmd]').forEach(btn =>
|
||
btn.addEventListener('click', () => { $('cSoloEd').focus(); document.execCommand(btn.dataset.cmd, false, null); }));
|
||
document.querySelectorAll('.ed-bar [data-block]').forEach(btn =>
|
||
btn.addEventListener('click', () => { $('cSoloEd').focus(); document.execCommand('formatBlock', false, btn.dataset.block); }));
|
||
$('edLinkBtn').addEventListener('click', () => {
|
||
const url = prompt('Link URL (https://…)');
|
||
if (!url) return;
|
||
$('cSoloEd').focus();
|
||
document.execCommand('createLink', false, url);
|
||
});
|
||
// inline media: upload, then drop the element at the cursor (BV-style)
|
||
let mediaMode = 'image';
|
||
function insertHtmlAtCursor(html) {
|
||
const ed = $('cSoloEd');
|
||
ed.focus();
|
||
if (!document.execCommand('insertHTML', false, html)) ed.insertAdjacentHTML('beforeend', html);
|
||
}
|
||
$('edImgBtn').addEventListener('click', () => { mediaMode = 'image'; $('cSoloFile').accept = 'image/png,image/jpeg,image/webp,image/gif'; $('cSoloFile').click(); });
|
||
$('edVidBtn').addEventListener('click', () => { mediaMode = 'video'; $('cSoloFile').accept = 'video/mp4,video/webm'; $('cSoloFile').click(); });
|
||
$('cSoloFile').addEventListener('change', async () => {
|
||
const f = $('cSoloFile').files[0];
|
||
if (!f) return;
|
||
$('edMediaInfo').textContent = 'Uploading ' + f.name + '…';
|
||
try {
|
||
const r = await (await fetch('/api/my/upload', { method: 'POST', headers: { 'Content-Type': f.type }, body: f })).json();
|
||
if (r.error) { $('edMediaInfo').textContent = r.error; $('cSoloFile').value = ''; return; }
|
||
insertHtmlAtCursor(r.type === 'video'
|
||
? '<video src="' + r.url + '" controls playsinline></video><p><br></p>'
|
||
: '<img src="' + r.url + '" alt=""><p><br></p>');
|
||
$('edMediaInfo').textContent = f.name + ' inserted';
|
||
} catch (e) { $('edMediaInfo').textContent = 'Upload failed. Try again.'; }
|
||
$('cSoloFile').value = '';
|
||
});
|
||
// raw-text toggle: swap the WYSIWYG surface for the underlying HTML and back
|
||
$('edRawBtn').addEventListener('click', () => {
|
||
const ed = $('cSoloEd'), raw = $('cSoloRaw');
|
||
if (raw.hidden) { raw.value = ed.innerHTML; raw.hidden = false; ed.hidden = true; $('edRawBtn').textContent = 'Visual'; }
|
||
else { ed.innerHTML = raw.value; ed.hidden = false; raw.hidden = true; $('edRawBtn').textContent = 'Raw text'; }
|
||
});
|
||
$('cType').addEventListener('change', () => {
|
||
const t = $('cType').value;
|
||
$('cImageRow').hidden = t !== 'banner'; // only banners carry a creative; login frames its URL
|
||
$('cSizeRow').hidden = t !== 'banner';
|
||
$('cTitleRow').hidden = t !== 'text' && t !== 'solo';
|
||
$('cBodyRow').hidden = t !== 'text';
|
||
$('cSoloRow').hidden = t !== 'solo';
|
||
$('cSoloHint').hidden = t !== 'solo';
|
||
$('cVideoRow').hidden = t !== 'video';
|
||
$('cFeaturedRow').hidden = t !== 'featured';
|
||
$('cVisitsRow').hidden = t !== 'visits';
|
||
// fixed-cost types derive their spend (featured = day slots, visits = flat pack),
|
||
// so hide the free-form Budget field for them to avoid confusion
|
||
$('cBudgetRow').hidden = (t === 'featured' || t === 'visits');
|
||
// solo ads have a real floor (5cr x 10 deliveries): default to 50 so 10 isn't rejected
|
||
if (t === 'solo' && (!$('cBudget').value || Number($('cBudget').value) < 50)) $('cBudget').value = (lastRates && lastRates.soloCostPerRecipient ? lastRates.soloCostPerRecipient : 5) * 10;
|
||
if (t === 'visits') visitHint();
|
||
$('cTitle').placeholder = t === 'solo' ? 'Subject line (max 80)' : 'Headline (max 60)';
|
||
soloHint();
|
||
if (t === 'video') videoHint();
|
||
if (t === 'featured') featHint();
|
||
});
|
||
let featStartDay = 0; // selected start-day offset (0 = today)
|
||
async function featHint() {
|
||
if (!lastRates || !lastRates.featuredDurations) return;
|
||
if ($('cFeatDays') && !$('cFeatDays').options.length)
|
||
$('cFeatDays').innerHTML = lastRates.featuredDurations.map(dys =>
|
||
'<option value="' + dys + '">' + dys + ' day' + (dys > 1 ? 's' : '') + ' — ' + (dys * lastRates.featuredPerDay) + ' credits</option>').join('');
|
||
let s = null;
|
||
try { s = await (await fetch('/api/featured/stats')).json(); } catch (e) {}
|
||
if (s && s.occupancy) {
|
||
const grid = $('cFeatDaysGrid');
|
||
grid.innerHTML = s.occupancy.map(d => {
|
||
const label = d.offset === 0 ? 'Today' : d.offset === 1 ? 'Tomorrow' : new Date(d.day + 'T00:00:00Z').toLocaleDateString(undefined, { weekday: 'short', month: 'numeric', day: 'numeric' });
|
||
const full = d.open <= 0;
|
||
return '<div class="feat-day' + (d.offset === featStartDay ? ' on' : '') + (full ? ' full' : d.open > d.cap / 2 ? ' open2' : '') + '" data-off="' + d.offset + '"' + (full ? '' : '') + '>'
|
||
+ '<div class="fd-day">' + label + '</div><div class="fd-occ">' + d.count + '/' + d.cap + (full ? ' full' : ' left ' + d.open) + '</div></div>';
|
||
}).join('');
|
||
grid.querySelectorAll('.feat-day:not(.full)').forEach(el =>
|
||
el.addEventListener('click', () => { featStartDay = Number(el.dataset.off); featHint(); }));
|
||
}
|
||
const dys = Number($('cFeatDays').value) || (lastRates.featuredDurations[0]);
|
||
const dayLabel = featStartDay === 0 ? 'today' : featStartDay === 1 ? 'tomorrow' : 'in ' + featStartDay + ' days';
|
||
$('cFeatHint').textContent = 'Runs ' + dys + ' day' + (dys > 1 ? 's' : '') + ' starting ' + dayLabel
|
||
+ ' for ' + (dys * lastRates.featuredPerDay) + ' credits. Max ' + (s ? s.slotsPerDay : 10) + ' links share any day.';
|
||
}
|
||
document.addEventListener('change', e => { if (e.target && e.target.id === 'cFeatDays') featHint(); });
|
||
function visitHint() {
|
||
if (!lastRates) return;
|
||
const n = Number($('cVisitCount').value) || 0;
|
||
const cost = n * (lastRates.visitCostPerVisit || 3);
|
||
$('cVisitHint').textContent = (lastRates.visitCostPerVisit || 3) + ' credits per verified visit'
|
||
+ (n >= (lastRates.visitMinPack || 20) ? ' — ' + n + ' visits = ' + cost + ' credits' : ' (min ' + (lastRates.visitMinPack || 20) + ')')
|
||
+ '. Each is a unique member, dwell + human-check verified.';
|
||
}
|
||
$('cVisitCount').addEventListener('input', visitHint);
|
||
// video composer: tier dropdown + upload + price hint
|
||
function videoHint() {
|
||
if (!lastRates || !lastRates.videoTiers) return;
|
||
if ($('cWatchSecs') && !$('cWatchSecs').options.length)
|
||
$('cWatchSecs').innerHTML = lastRates.videoTiers.map(t =>
|
||
'<option value="' + t.secs + '">Watch ' + t.secs + 's — ' + t.cost + ' credits/view (viewer earns ' + t.reward + ')</option>').join('');
|
||
const tier = lastRates.videoTiers.find(t => t.secs === Number($('cWatchSecs').value)) || lastRates.videoTiers[0];
|
||
const n = tier ? Math.floor((Number($('cBudget').value) || 0) / tier.cost) : 0;
|
||
$('cVideoHint').textContent = tier ? (tier.cost + ' credits per completed ' + tier.secs + 's view'
|
||
+ (n ? ' — this budget buys ' + n + ' views' : '') + '. Viewers earn ' + tier.reward + ' credits each, so they watch.') : '';
|
||
}
|
||
document.addEventListener('change', e => { if (e.target && e.target.id === 'cWatchSecs') videoHint(); });
|
||
$('cBudget').addEventListener('input', () => { if ($('cType').value === 'video') videoHint(); });
|
||
$('cVideoUploadBtn').addEventListener('click', () => $('cVideoFile').click());
|
||
$('cVideoUrl').addEventListener('change', async () => {
|
||
const url = $('cVideoUrl').value.trim();
|
||
$('cVideoInfo').textContent = url ? 'checking video…' : '';
|
||
cVidDims = url ? await probeVideoDims(url) : null;
|
||
if (url && !cVidDims) $('cVideoInfo').textContent = 'could not read that video';
|
||
else { $('cVideoInfo').textContent = ''; showVidOrient(); }
|
||
});
|
||
$('cVideoFile').addEventListener('change', async () => {
|
||
const f = $('cVideoFile').files[0];
|
||
if (!f) return;
|
||
$('cVideoInfo').textContent = 'Uploading ' + f.name + '… (large files take a moment)';
|
||
try {
|
||
const r = await (await fetch('/api/my/upload', { method: 'POST', headers: { 'Content-Type': f.type }, body: f })).json();
|
||
if (r.error) { $('cVideoInfo').textContent = r.error; $('cVideoFile').value = ''; return; }
|
||
$('cVideoUrl').value = r.url;
|
||
$('cVideoInfo').textContent = f.name + ' uploaded';
|
||
$('cVideoPrev').hidden = false;
|
||
$('cVideoPrev').innerHTML = '<video src="' + r.url + '" controls style="max-width:320px;border-radius:10px"></video>';
|
||
cVidDims = await probeVideoDims(r.url); showVidOrient();
|
||
} catch (e) { $('cVideoInfo').textContent = 'Upload failed. Try again.'; }
|
||
$('cVideoFile').value = '';
|
||
});
|
||
$('createCampBtn').addEventListener('click', busy2($('createCampBtn'), async () => {
|
||
// in raw mode the source of truth is the textarea; sync it back first
|
||
let soloBody = $('cSoloEd').innerHTML;
|
||
if ($('cSoloRaw') && !$('cSoloRaw').hidden) soloBody = $('cSoloRaw').value;
|
||
const t = $('cType').value;
|
||
const isVideo = t === 'video', isFeat = t === 'featured';
|
||
if (isVideo && !cVidDims && $('cVideoUrl').value) cVidDims = await probeVideoDims($('cVideoUrl').value);
|
||
await api('/api/my/campaigns', { type: t, name: $('cName').value,
|
||
targetUrl: $('cTarget').value, imageUrl: $('cImage').value, size: $('cSize').value,
|
||
title: isVideo ? $('cVideoTitle').value : isFeat ? $('cFeatTitle').value : t === 'visits' ? $('cVisitTitle').value : $('cTitle').value,
|
||
body: t === 'solo' ? soloBody : $('cBody').value,
|
||
videoUrl: $('cVideoUrl').value, watchSecs: Number($('cWatchSecs').value),
|
||
videoW: cVidDims ? cVidDims.w : null, videoH: cVidDims ? cVidDims.h : null,
|
||
days: Number($('cFeatDays').value), startDay: featStartDay,
|
||
count: Number($('cVisitCount').value),
|
||
ctaLabel: isVideo ? $('cVideoCta').value : $('cCtaLabel').value,
|
||
budget: isFeat ? (Number($('cFeatDays').value) * (lastRates.featuredPerDay || 40)) : Number($('cBudget').value) });
|
||
IAP.status('Campaign is live. It starts serving right away.', 'ok');
|
||
// clear EVERY field so no target/creative carries into the next campaign
|
||
['cName', 'cBudget', 'cTarget', 'cImage', 'cTitle', 'cBody', 'cCtaLabel',
|
||
'cVideoUrl', 'cVideoTitle', 'cVideoCta', 'cVisitTitle', 'cVisitCount', 'cFeatTitle']
|
||
.forEach(id => { if ($(id)) $(id).value = ''; });
|
||
$('cSoloEd').innerHTML = ''; if ($('cSoloRaw')) $('cSoloRaw').value = '';
|
||
$('cVideoInfo').textContent = ''; $('cVideoPrev').hidden = true; $('cVideoPrev').innerHTML = '';
|
||
if ($('edMediaInfo')) $('edMediaInfo').textContent = '';
|
||
cVidDims = null;
|
||
await loadCampaigns();
|
||
}));
|
||
// defers the busy() lookup to click time (busy is declared below)
|
||
function busy2(btn, fn) { return (...a) => busy(btn, fn)(...a); }
|
||
|
||
// ── verified visits: open a member's site (new tab), dwell, human-check, earn ──
|
||
const visState = { token: null, id: null, dwell: 8 };
|
||
async function loadVisitStatus() {
|
||
try {
|
||
const st = await (await fetch('/api/my/visits')).json();
|
||
if (st.error) return;
|
||
$('vsProgress').textContent = 'today: ' + (st.status.count || 0) + ' / ' + st.status.cap + ' verified visits';
|
||
$('vsStartBtn').hidden = st.status.count >= st.status.cap;
|
||
if (st.status.count >= st.status.cap) $('vsBox').innerHTML = '<span class="muted small">That\'s today\'s visits. Come back tomorrow.</span>';
|
||
} catch (e) {}
|
||
}
|
||
async function loadVisit() {
|
||
$('vsCheck').hidden = true; $('vsVisit').hidden = true; $('vsHint').textContent = '';
|
||
let r = null;
|
||
try { r = await (await fetch('/api/my/visits')).json(); } catch (e) {}
|
||
if (!r || !r.ad) {
|
||
$('vsBox').innerHTML = '<span class="muted small">' + (r && r.status && r.status.count >= r.status.cap
|
||
? 'That\'s today\'s visits. Come back tomorrow.' : 'No verified-visit packs are running right now. Check back soon.') + '</span>';
|
||
return;
|
||
}
|
||
visState.token = r.token; visState.id = r.ad.id; visState.dwell = r.status.dwell || 8;
|
||
$('vsBox').innerHTML = '<b>' + esc(r.ad.title || 'Member site') + '</b><br><span class="muted small">Open the site and stay ' + visState.dwell + 's.</span>';
|
||
const visit = $('vsVisit');
|
||
visit.href = r.ad.url; visit.hidden = false;
|
||
$('vsStartBtn').textContent = 'Loading…'; $('vsStartBtn').disabled = true;
|
||
// opening the site starts the dwell; then we ask the human-check
|
||
visit.onclick = () => {
|
||
let left = visState.dwell;
|
||
$('vsHint').textContent = 'Counting your visit: ' + left + 's';
|
||
const t = setInterval(async () => {
|
||
left--;
|
||
if (left > 0) { $('vsHint').textContent = 'Counting your visit: ' + left + 's'; return; }
|
||
clearInterval(t);
|
||
$('vsHint').textContent = 'One quick check to count the visit:';
|
||
let c = await (await fetch('/api/my/visitchallenge?token=' + visState.token)).json();
|
||
if (c.early) { await new Promise(r2 => setTimeout(r2, (c.wait || 1) * 1000 + 300)); c = await (await fetch('/api/my/visitchallenge?token=' + visState.token)).json(); }
|
||
if (c.error) { $('vsHint').textContent = c.error; return; }
|
||
renderVisitCheck(c);
|
||
}, 1000);
|
||
};
|
||
$('vsStartBtn').textContent = 'Next visit'; $('vsStartBtn').disabled = false;
|
||
}
|
||
function renderVisitCheck(c) {
|
||
$('vsPrompt').textContent = 'Click the ' + c.prompt + ':';
|
||
const w = $('vsOpts'); w.innerHTML = '';
|
||
c.options.forEach((em, i) => {
|
||
const b = document.createElement('button');
|
||
b.className = 'btn small sec'; b.style.marginRight = '6px'; b.textContent = em;
|
||
b.addEventListener('click', () => answerVisit(i));
|
||
w.appendChild(b);
|
||
});
|
||
$('vsCheck').hidden = false;
|
||
}
|
||
async function answerVisit(i) {
|
||
const r = await (await fetch('/api/my/visitdone', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token: visState.token, answer: i }) })).json();
|
||
if (r.error) {
|
||
if (r.retry) { const c = await (await fetch('/api/my/visitchallenge?token=' + visState.token)).json(); if (!c.error) return renderVisitCheck(c); }
|
||
$('vsHint').textContent = r.error; $('vsCheck').hidden = true; return;
|
||
}
|
||
$('vsCheck').hidden = true;
|
||
$('vsHint').textContent = '+' + r.credited + ' credits — visit counted. Load the next one.';
|
||
$('vsProgress').textContent = 'today: ' + (r.status.count || 0) + ' / ' + (r.status.cap || 0) + ' verified visits';
|
||
IAP.status('+' + r.credited + ' credits for a verified visit.', 'ok');
|
||
loadDashboard();
|
||
}
|
||
$('vsStartBtn').addEventListener('click', () => loadVisit());
|
||
|
||
// ── watch-to-earn videos: escape-proof player, server-clock reward ──
|
||
const vidState = { token: null, secs: 0, maxSeen: 0, done: false, credited: false };
|
||
// detected dimensions of the video being created — portrait goes to Shorts, landscape to Watch videos
|
||
let cVidDims = null;
|
||
function probeVideoDims(url) {
|
||
return new Promise(resolve => {
|
||
if (!url) return resolve(null);
|
||
const v = document.createElement('video'); v.preload = 'metadata'; v.muted = true;
|
||
let done = false; const fin = r => { if (!done) { done = true; resolve(r); } };
|
||
v.onloadedmetadata = () => fin(v.videoWidth && v.videoHeight ? { w: v.videoWidth, h: v.videoHeight } : null);
|
||
v.onerror = () => fin(null); setTimeout(() => fin(null), 8000); v.src = url;
|
||
});
|
||
}
|
||
function showVidOrient() {
|
||
const el = $('cVideoInfo'); if (!el) return;
|
||
if (!cVidDims) return;
|
||
const portrait = cVidDims.h > cVidDims.w;
|
||
el.textContent = (el.textContent ? el.textContent + ' · ' : '')
|
||
+ (portrait ? 'portrait — shows in the Shorts reel' : 'landscape — shows in the Watch videos tab');
|
||
}
|
||
async function loadVideoStatus() {
|
||
try {
|
||
const st = await (await fetch('/api/my/videos?orientation=landscape')).json();
|
||
if (st.error) return;
|
||
$('vidProgress').textContent = 'today: ' + (st.status.count || 0) + ' / ' + st.status.cap + ' videos watched';
|
||
if (st.status.left <= 0) {
|
||
$('vidBox').innerHTML = '<span class="muted small">That is today\'s video set. Come back tomorrow.</span>';
|
||
$('vidStartBtn').hidden = true;
|
||
return;
|
||
}
|
||
$('vidStartBtn').hidden = false;
|
||
} catch (e) {}
|
||
}
|
||
async function loadVideoAd() {
|
||
let r = null;
|
||
try { r = await (await fetch('/api/my/videos?orientation=landscape')).json(); } catch (e) {}
|
||
if (!r || !r.ad) {
|
||
$('vidBox').innerHTML = '<span class="muted small">' + (r && r.status && r.status.left <= 0
|
||
? 'That is today\'s video set. Come back tomorrow.'
|
||
: 'No member videos are live right now. Check back when a campaign is running.') + '</span>';
|
||
$('vidWrap').hidden = true;
|
||
return;
|
||
}
|
||
const ad = r.ad;
|
||
vidState.token = r.token; vidState.secs = ad.watchSecs; vidState.maxSeen = 0; vidState.done = false; vidState.credited = false;
|
||
$('vidBox').hidden = true;
|
||
$('vidWrap').hidden = false;
|
||
$('vidCta').hidden = true;
|
||
$('vidHint').textContent = ad.title ? 'Now playing: ' + ad.title : '';
|
||
const p = $('vidPlayer');
|
||
p.src = ad.videoUrl;
|
||
p.currentTime = 0;
|
||
// escape-proof: no seeking past what's been watched; track max reached
|
||
p.onseeking = () => { if (p.currentTime > vidState.maxSeen + 0.5) p.currentTime = vidState.maxSeen; };
|
||
p.ontimeupdate = () => {
|
||
if (p.currentTime > vidState.maxSeen) vidState.maxSeen = p.currentTime;
|
||
const left = Math.max(0, Math.ceil(vidState.secs - vidState.maxSeen));
|
||
$('vidTimer').textContent = left > 0 ? 'Watch ' + left + 's more to earn' : 'Watch time met — finishing…';
|
||
if (!vidState.done && vidState.maxSeen >= vidState.secs) { vidState.done = true; completeVideo(ad); }
|
||
};
|
||
$('vidStartBtn').textContent = 'Playing…';
|
||
$('vidStartBtn').disabled = true;
|
||
try { await p.play(); } catch (e) { $('vidStartBtn').disabled = false; $('vidStartBtn').textContent = 'Tap to play'; }
|
||
$('vidCta').href = ad.ctaUrl;
|
||
$('vidCta').textContent = ad.ctaLabel || 'Learn more';
|
||
$('vidCta').hidden = false;
|
||
}
|
||
async function completeVideo(ad) {
|
||
if (vidState.credited) return;
|
||
vidState.credited = true;
|
||
try {
|
||
const r = await (await fetch('/api/my/videowatch', { method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token: vidState.token }) })).json();
|
||
if (r.error) { IAP.status(r.error, 'bad'); $('vidHint').textContent = r.error; }
|
||
else if (r.credited) { IAP.status('+' + r.credited + ' credits earned for watching.', 'ok'); $('vidHint').textContent = '+' + r.credited + ' credits earned. Load the next one.'; loadDashboard(); }
|
||
else $('vidHint').textContent = 'That video just ran out of budget — load another.';
|
||
$('vidProgress').textContent = 'today: ' + ((r.status && r.status.count) || 0) + ' / ' + ((r.status && r.status.cap) || 0) + ' videos watched';
|
||
} catch (e) { IAP.status('Could not confirm that watch. Try the next one.', 'bad'); }
|
||
$('vidStartBtn').disabled = false;
|
||
$('vidStartBtn').textContent = 'Next video';
|
||
}
|
||
$('vidStartBtn').addEventListener('click', () => loadVideoAd());
|
||
// presence enforcement: pause the watch-to-earn video when the tab/window loses focus,
|
||
// resume when it comes back — the viewer must stay on the page for the watch to complete
|
||
document.addEventListener('visibilitychange', () => {
|
||
const p = $('vidPlayer'); if (!p || !p.src) return;
|
||
if (document.hidden) p.pause(); else if (!vidState.done) p.play().catch(() => {});
|
||
});
|
||
window.addEventListener('blur', () => { const p = $('vidPlayer'); if (p && p.src) p.pause(); });
|
||
window.addEventListener('focus', () => { const p = $('vidPlayer'); if (p && p.src && !vidState.done) p.play().catch(() => {}); });
|
||
|
||
// ── unmissable sponsor-message modal on sign-in ──
|
||
function showSponsorModal(msg) {
|
||
if (!$('msgModal')) return;
|
||
$('mmFrom').textContent = 'A message from ' + (msg.fromName || 'your sponsor');
|
||
$('mmSubject').textContent = msg.subject || '';
|
||
$('mmBody').innerHTML = msg.body || ''; // server-sanitized
|
||
$('msgModal').hidden = false;
|
||
$('mmAck').onclick = async () => {
|
||
$('msgModal').hidden = true;
|
||
try { await fetch('/api/my/messages/' + msg.id + '/read', { method: 'POST' }); } catch (e) {}
|
||
};
|
||
}
|
||
|
||
// ── 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"><span class="nm">' + esc(m.name) + '</span>'
|
||
+ (m.email ? '<span class="em">' + esc(m.email) + '</span>' : '<span class="id">#' + m.memberId + '</span>')
|
||
+ '<span class="dt">' + new Date(m.joined).toLocaleDateString() + '</span>'
|
||
+ (m.email ? '<button class="btn sec small chat-msg-btn" type="button" data-cemail="' + esc(m.email) + '" data-cname="' + esc(m.name) + '">Message</button>' : '')
|
||
+ '</div>').join('')
|
||
+ '</div>').join('');
|
||
el.querySelectorAll('[data-cemail]').forEach(b =>
|
||
b.addEventListener('click', () => openConvo(b.dataset.cemail, b.dataset.cname)));
|
||
} catch (e) {}
|
||
}
|
||
async function loadUplineMessages() {
|
||
try {
|
||
const r = await (await fetch('/api/my/messages')).json();
|
||
if (r.error) return;
|
||
const card = $('upMsgCard'), list = $('upList');
|
||
if (!r.items || !r.items.length) { card.hidden = true; return; }
|
||
card.hidden = false;
|
||
list.innerHTML = r.items.map(i => '<div class="promo-block" style="margin-bottom:10px">'
|
||
+ '<div style="display:flex;justify-content:space-between;gap:10px"><b>' + esc(i.subject) + '</b>'
|
||
+ '<span class="small muted">' + esc(i.fromName) + ' · ' + new Date(i.sent).toLocaleDateString() + '</span></div>'
|
||
+ '<div class="ib-rich" style="margin-top:8px">' + (i.body || '') + '</div></div>').join('');
|
||
// opening the pane marks them read
|
||
for (const i of r.items) if (!i.read) fetch('/api/my/messages/' + i.id + '/read', { method: 'POST' }).catch(() => {});
|
||
} catch (e) {}
|
||
}
|
||
// broadcast composer editor (its own small rich editor, server sanitizes)
|
||
document.querySelectorAll('[data-bc]').forEach(b =>
|
||
b.addEventListener('click', () => { $('bcEd').focus(); document.execCommand(b.dataset.bc, false, null); }));
|
||
document.querySelectorAll('[data-bcblock]').forEach(b =>
|
||
b.addEventListener('click', () => { $('bcEd').focus(); document.execCommand('formatBlock', false, b.dataset.bcblock); }));
|
||
if ($('bcLinkBtn')) $('bcLinkBtn').addEventListener('click', () => { const u = prompt('Link URL (https://…)'); if (u) { $('bcEd').focus(); document.execCommand('createLink', false, u); } });
|
||
if ($('bcSendBtn')) $('bcSendBtn').addEventListener('click', busy2($('bcSendBtn'), async () => {
|
||
const r = await api('/api/my/broadcast', { scope: $('bcScope').value, subject: $('bcSubject').value, body: $('bcEd').innerHTML });
|
||
IAP.status('Broadcast sent to ' + r.sent + ' member' + (r.sent === 1 ? '' : 's') + '.', 'ok');
|
||
$('bcSubject').value = ''; $('bcEd').innerHTML = '';
|
||
$('bcHint').textContent = 'Sent. You can send your next broadcast in 24 hours.';
|
||
}));
|
||
|
||
// ── solo-ads inbox: list, read view, dwell-gated read reward ──
|
||
let ibTimer = null;
|
||
function setInboxBadge(n) {
|
||
for (const id of ['inboxBadge', 'inboxBadge2']) {
|
||
const b = $(id);
|
||
if (b) { b.hidden = !n; b.textContent = n; }
|
||
}
|
||
}
|
||
// Earn credits sub-tabs: Watch ads | Inbox
|
||
let earnSub = 'watch';
|
||
function setEarnSub(which) {
|
||
earnSub = ['inbox', 'videos', 'visits'].includes(which) ? which : 'watch';
|
||
const w = $('earn-watch'), i = $('earn-inbox'), v = $('earn-videos'), vs = $('earn-visits');
|
||
if (w) w.hidden = earnSub !== 'watch';
|
||
if (i) i.hidden = earnSub !== 'inbox';
|
||
if (v) v.hidden = earnSub !== 'videos';
|
||
if (vs) vs.hidden = earnSub !== 'visits';
|
||
document.querySelectorAll('.subtabs [data-earn]').forEach(b =>
|
||
b.classList.toggle('on', b.dataset.earn === earnSub));
|
||
if (earnSub === 'inbox') loadInbox();
|
||
else if (earnSub === 'videos') loadVideoStatus();
|
||
else if (earnSub === 'visits') loadVisitStatus();
|
||
else earnRefresh();
|
||
}
|
||
document.querySelectorAll('.subtabs [data-earn]').forEach(b =>
|
||
b.addEventListener('click', () => setEarnSub(b.dataset.earn)));
|
||
// promo tools: pill menu switches between posts / swipes / banners / wall / videos
|
||
function setPromoSub(name) {
|
||
const ids = ['posts', 'text', 'swipe', 'banners', 'wall', 'objections', 'videos'];
|
||
if (!ids.includes(name)) name = 'posts';
|
||
ids.forEach(id => { const el = $('promo-' + id); if (el) el.hidden = id !== name; });
|
||
document.querySelectorAll('.promo-pills [data-promo]').forEach(b => {
|
||
b.classList.toggle('on', b.dataset.promo === name);
|
||
b.setAttribute('aria-selected', b.dataset.promo === name ? 'true' : 'false');
|
||
});
|
||
try { localStorage.setItem('iap.promoSub', name); } catch (e) {}
|
||
}
|
||
document.querySelectorAll('.promo-pills [data-promo]').forEach(b =>
|
||
b.addEventListener('click', () => setPromoSub(b.dataset.promo)));
|
||
try { setPromoSub(localStorage.getItem('iap.promoSub') || 'posts'); } catch (e) { setPromoSub('posts'); }
|
||
async function loadInbox() {
|
||
try {
|
||
const r = await (await fetch('/api/my/inbox')).json();
|
||
if (r.error) return;
|
||
$('ibRewardNote').textContent = '+' + r.readCredits + ' credits per real read (up to '
|
||
+ r.readCap + ' rewarded reads a day)';
|
||
const el = $('ibList');
|
||
$('inboxReadCard').hidden = true;
|
||
$('inboxListCard').hidden = false;
|
||
setInboxBadge(r.unread);
|
||
if (!r.items.length) {
|
||
el.innerHTML = '<p class="muted small">No solo ads yet. When a member sends one, it lands here — and reading it pays.</p>';
|
||
return;
|
||
}
|
||
el.innerHTML = '';
|
||
for (const i of r.items) {
|
||
const d = document.createElement('div');
|
||
d.className = 'ib-row' + (i.read ? '' : ' unread');
|
||
d.innerHTML = '<span class="sub"></span><span class="from"></span>'
|
||
+ (i.rewarded ? '<span class="badge">claimed</span>' : i.read ? '' : '<span class="badge amber">new</span>')
|
||
+ '<span class="when">' + new Date(i.delivered).toLocaleDateString() + '</span>';
|
||
d.querySelector('.sub').textContent = i.subject || '(no subject)';
|
||
d.querySelector('.from').textContent = 'from ' + (i.fromName || 'a member');
|
||
d.addEventListener('click', () => openInboxItem(i.id));
|
||
el.appendChild(d);
|
||
}
|
||
} catch (e) {}
|
||
}
|
||
async function openInboxItem(id) {
|
||
try {
|
||
const r = await (await fetch('/api/my/inbox/' + id)).json();
|
||
if (r.error) { IAP.status(r.error, 'bad'); return; }
|
||
$('inboxListCard').hidden = true;
|
||
$('inboxReadCard').hidden = false;
|
||
$('ibSubject').textContent = r.subject || '(no subject)';
|
||
$('ibMeta').textContent = 'from ' + (r.fromName || 'a member') + ' · ' + new Date(r.delivered).toLocaleString();
|
||
$('ibBody').innerHTML = r.body || ''; // whitelist-sanitized on the server at submit
|
||
const mv = $('ibMedia');
|
||
mv.hidden = !r.mediaUrl;
|
||
mv.innerHTML = !r.mediaUrl ? ''
|
||
: r.mediaType === 'video'
|
||
? '<video src="' + r.mediaUrl + '" controls style="max-width:100%;border-radius:12px"></video>'
|
||
: '<img src="' + r.mediaUrl + '" alt="attachment" style="max-width:100%;border-radius:12px">';
|
||
// the read reward needs BOTH the dwell AND an actual click-through to the
|
||
// advertiser — the visit is what makes the ad worth the sender's credits
|
||
const visit = $('ibVisit');
|
||
visit.href = r.url;
|
||
visit.textContent = r.ctaLabel || 'Learn more';
|
||
visit.target = '_blank';
|
||
const btn = $('ibClaimBtn');
|
||
clearInterval(ibTimer);
|
||
if (r.rewarded) {
|
||
btn.hidden = true;
|
||
visit.classList.remove('cta-need');
|
||
$('ibHint').textContent = 'Read reward already claimed for this one.';
|
||
return;
|
||
}
|
||
let dwellDone = false;
|
||
let visited = !!r.visited;
|
||
btn.hidden = false;
|
||
btn.disabled = true;
|
||
visit.classList.toggle('cta-need', !visited);
|
||
let left = r.dwell;
|
||
const refresh = () => {
|
||
if (!dwellDone) { btn.textContent = 'Read it — claim in ' + left + 's'; return; }
|
||
if (!visited) { btn.textContent = 'Claim +' + r.reward + ' — visit the ad first'; btn.disabled = true; return; }
|
||
btn.textContent = 'Claim +' + r.reward + ' credits'; btn.disabled = false;
|
||
};
|
||
$('ibHint').textContent = 'Read the message, click through to the advertiser, then claim your credits.';
|
||
refresh();
|
||
// countdown pauses off-tab; the server separately enforces the dwell on its own clock
|
||
ibTimer = setInterval(() => {
|
||
if (document.visibilityState !== 'visible' || !document.hasFocus()) return;
|
||
left -= 1;
|
||
if (left > 0) { refresh(); return; }
|
||
clearInterval(ibTimer);
|
||
dwellDone = true;
|
||
refresh();
|
||
}, 1000);
|
||
// clicking the CTA records the visit (and counts the advertiser's click)
|
||
visit.onclick = async () => {
|
||
visited = true;
|
||
visit.classList.remove('cta-need');
|
||
try { await fetch('/api/my/inbox/' + id + '/visit', { method: 'POST' }); } catch (e2) {}
|
||
refresh();
|
||
};
|
||
btn.onclick = async () => {
|
||
try {
|
||
const c = await api('/api/my/inbox/' + id + '/claim');
|
||
IAP.status('+' + c.credited + ' credits for reading. They spend like any earned credits.', 'ok');
|
||
btn.hidden = true;
|
||
$('ibHint').textContent = 'Claimed. Head back for the next one.';
|
||
loadDashboard();
|
||
} catch (e2) { IAP.status(e2.message, 'bad'); }
|
||
};
|
||
} catch (e) {}
|
||
}
|
||
$('ibBack').addEventListener('click', ev => { ev.preventDefault(); clearInterval(ibTimer); loadInbox(); });
|
||
|
||
// ── promo tools: content + rendering live in promo.js (IAPPromo.fill) ──
|
||
function fillPromo(link, me) {
|
||
if (window.IAPPromo) IAPPromo.fill(link, me || {});
|
||
// banner kit
|
||
const bwrap = $('promoBanners');
|
||
if (bwrap && !bwrap.dataset.filled) {
|
||
bwrap.dataset.filled = '1';
|
||
const BANNERS = [
|
||
{ file: 'iap-hero-1200x630.png', size: '1200×630 (social / hero)' },
|
||
{ file: 'iap-1080x1080.png', size: '1080×1080 (Instagram / Facebook square)' },
|
||
{ file: 'iap-1080x1920.png', size: '1080×1920 (story / reel)' },
|
||
{ file: 'iap-1280x720.png', size: '1280×720 (Telegram group post)' },
|
||
{ file: 'iap-728x90.png', size: '728×90 (leaderboard)' },
|
||
{ file: 'iap-336x280.png', size: '336×280 (large rectangle)' },
|
||
{ file: 'iap-300x250.png', size: '300×250 (rectangle)' },
|
||
{ file: 'iap-125x125.png', size: '125×125 (square button)' },
|
||
{ file: 'iap-468x60.png', size: '468×60 (banner)' },
|
||
{ file: 'iap-160x600.png', size: '160×600 (wide skyscraper)' },
|
||
{ file: 'iap-120x600.png', size: '120×600 (skyscraper)' },
|
||
{ file: 'iap-320x50.svg', size: '320×50 (mobile leaderboard)' }
|
||
];
|
||
bwrap.innerHTML = '';
|
||
for (const b of BANNERS) {
|
||
const url = location.origin + '/banners/' + b.file;
|
||
const d = document.createElement('div');
|
||
d.className = 'pb-item';
|
||
d.innerHTML = '<img src="/banners/' + b.file + '?v=3" alt="InstantAdPay banner ' + b.size + '" loading="lazy">'
|
||
+ '<div class="pb-row"><span class="pb-size">' + b.size + '</span></div>';
|
||
const dl = document.createElement('a');
|
||
dl.className = 'btn small'; dl.textContent = 'Download'; dl.href = '/banners/' + b.file; dl.setAttribute('download', b.file);
|
||
d.querySelector('.pb-row').appendChild(dl);
|
||
const btn = document.createElement('button');
|
||
btn.className = 'btn small sec';
|
||
btn.textContent = 'Copy image URL';
|
||
btn.addEventListener('click', async () => {
|
||
try { await navigator.clipboard.writeText(url); IAP.status('Banner URL copied.', 'ok'); }
|
||
catch (e) { IAP.status('Copy failed.', 'bad'); }
|
||
});
|
||
d.querySelector('.pb-row').appendChild(btn);
|
||
bwrap.appendChild(d);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── profile ── (busy2 defers the busy lookup past its TDZ)
|
||
$('pfSaveBtn').addEventListener('click', busy2($('pfSaveBtn'), async () => {
|
||
const r = await api('/api/my/profile', { username: $('pfUsername').value });
|
||
IAP.status('You are @' + r.account.username + ' now.', 'ok');
|
||
await render();
|
||
}));
|
||
|
||
// ── in-dashboard package buying ──
|
||
const PKG = { 1: 'Micro', 2: 'Activation', 3: 'Builder', 4: 'Growth', 5: 'Leader' };
|
||
// Card on-ramp: buy POL with a card via MoonPay, delivered to the buyer's own
|
||
// wallet. Signed + wallet-prefilled once MoonPay keys are set; generic page
|
||
// otherwise. The site never touches funds — MoonPay is merchant of record.
|
||
async function openMoonpay(pol) {
|
||
try {
|
||
let addr = '';
|
||
try { const me = await (await fetch('/api/me')).json(); addr = me.address || ''; } catch (e) {}
|
||
const q = '/api/moonpay-url?pol=' + encodeURIComponent(pol || '') + (addr ? '&address=' + encodeURIComponent(addr) : '');
|
||
const r = await (await fetch(q)).json();
|
||
if (r && r.url) {
|
||
window.open(r.url, '_blank', 'noopener');
|
||
IAP.status(r.signed
|
||
? 'MoonPay opened in a new tab with your wallet address pre-filled. Choose POL on Polygon, finish the purchase, then come back and buy your package.'
|
||
: 'MoonPay opened in a new tab. Choose POL on the Polygon network and paste your own wallet address as the destination, then come back.', 'ok');
|
||
}
|
||
} catch (e) { IAP.status('Could not open MoonPay: ' + ((e && e.message) || e), 'bad'); }
|
||
}
|
||
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');
|
||
if (!meNow.address) {
|
||
IAP.status('Link your wallet first — one quick signature…');
|
||
await IAPWallet.signIn();
|
||
}
|
||
const spNow = 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(IAPWallet.address() || meNow.address);
|
||
if (bal < need) {
|
||
IAP.status('That wallet holds ' + IAP.fmtPol(bal.toString()) + ' POL, but this package needs about ' + IAP.fmtPol(need.toString()) + ' POL plus a little for gas. Top it up and try again.', 'bad');
|
||
return;
|
||
}
|
||
const pct = Number(need * 100n / bal);
|
||
const isTrust = /trust/i.test(IAPWallet.walletName() || '');
|
||
if (isTrust && pct > 55 && !confirm('Heads up for Trust Wallet users: this purchase uses about ' + pct + '% of the POL in your wallet, and Trust Wallet refuses transactions that spend most of the balance (it shows a "drain your wallet" warning with only Stop and go back).' + '\n\n' + 'Options: pick a smaller package first, add some POL, or connect a different wallet (MetaMask, Phantom, SafePal). Extra POL always stays yours.' + '\n\n' + 'Try it anyway?')) {
|
||
IAP.status('Purchase paused. Pick a smaller package, add POL, or connect another wallet, then try again.', 'ok');
|
||
return;
|
||
}
|
||
} catch (e) { /* balance read failed: let the wallet decide */ }
|
||
IAP.status('Confirm the purchase in your wallet…');
|
||
const r = await IAPWallet.buy(Number(b.dataset.id), spNow.sponsorId || 0, b.dataset.cost);
|
||
if (r.status !== '0x1' && r.receipt && r.receipt.status !== '0x1') throw new Error('Transaction reverted.');
|
||
IAP.status('Purchase settled on-chain. Credits are in your account.', 'ok');
|
||
await render();
|
||
loadBuyTiles();
|
||
} catch (e) { IAP.status('Purchase failed: ' + ((e && e.message) || e), 'bad'); }
|
||
finally { b.disabled = false; }
|
||
}));
|
||
// brand new to crypto? buy POL with a card, sent straight to the wallet
|
||
const builder = products.find(p => p.priceCents === 5000) || products[products.length - 1];
|
||
const needPol = (builder && builder.costWei) ? Math.max(30, Math.ceil(Number(builder.costWei) / 1e18) + 3) : 30;
|
||
const cta = document.createElement('div');
|
||
cta.style.cssText = 'grid-column:1/-1;margin-top:10px;text-align:center';
|
||
cta.innerHTML = '<p class="muted small" style="margin:0 0 8px">New to crypto? Buy POL with a debit or credit card, Apple Pay, or Google Pay. It lands straight in your own wallet, and this site never touches your money.</p>'
|
||
+ '<button class="btn small sec" id="moonpayBtn" type="button">💳 Buy POL with a card</button>';
|
||
wrap.appendChild(cta);
|
||
const mb = $('moonpayBtn'); if (mb) mb.addEventListener('click', () => openMoonpay(needPol));
|
||
} catch (e) {}
|
||
}
|
||
loadBuyTiles();
|
||
|
||
// ── earn-by-viewing: daily ad set with dwell, then claim ──
|
||
const earnState = { types: ['banner', 'text'], i: 0, timer: null };
|
||
async function earnRefresh() {
|
||
try {
|
||
const st = await (await fetch('/api/my/earn')).json();
|
||
if (st.error) return null;
|
||
$('earnProgress').textContent = 'today: ' + st.views + ' / ' + st.target + ' ads viewed';
|
||
$('earnBalance').textContent = st.earned + ' earned credits';
|
||
const done = st.views >= st.target;
|
||
$('earnClaimBtn').hidden = !(done && !st.claimed);
|
||
if (st.claimed) $('earnHint').textContent = 'Claimed for today. Come back tomorrow, or put those credits to work in Campaigns.';
|
||
else if (done) $('earnHint').textContent = 'Set complete. Claim your ' + st.claimCredits + ' credits.';
|
||
return st;
|
||
} catch (e) { return null; }
|
||
}
|
||
async function earnShowAd() {
|
||
// each view happens full screen in its own tab: /view/<token> frames the
|
||
// advertiser's site, runs the countdown, then a human check credits it
|
||
const type = earnState.types[earnState.i++ % earnState.types.length];
|
||
let r = null;
|
||
try { r = await (await fetch('/api/my/earnview?type=' + type)).json(); } catch (e) {}
|
||
const box = $('earnAdBox');
|
||
if (!r || !r.ad || !r.viewUrl) {
|
||
await earnRefresh();
|
||
box.innerHTML = '<span class="muted small">' + (r && r.status && r.status.views >= r.status.target
|
||
? 'Set complete for today.'
|
||
: 'No member ads are live in rotation right now. Views resume the moment a campaign is running.') + '</span>';
|
||
return;
|
||
}
|
||
openAdOverlay(r.viewUrl);
|
||
box.innerHTML = '<span class="muted small">Watch the countdown and pass the quick check. Your view credits itself and this page updates right away.</span>';
|
||
$('earnStartBtn').textContent = 'View next ad';
|
||
}
|
||
// In-page ad overlay: no new tab (mobile and in-app wallet browsers handle tabs
|
||
// badly), no window.close needed. The /view page runs the dwell + check inside,
|
||
// messages back when it credits or when the user is done.
|
||
function openAdOverlay(url) {
|
||
closeAdOverlay();
|
||
const back = document.createElement('div');
|
||
back.id = 'adOverlay';
|
||
back.style.cssText = 'position:fixed;inset:0;z-index:100000;background:#050b09;display:flex;flex-direction:column';
|
||
const x = document.createElement('button');
|
||
x.type = 'button'; x.setAttribute('aria-label', 'Close ad');
|
||
x.textContent = '✕';
|
||
x.style.cssText = 'position:absolute;top:10px;right:12px;z-index:2;width:40px;height:40px;border-radius:50%;border:1px solid rgba(255,255,255,.25);background:rgba(6,10,16,.85);color:#fff;font-size:20px;font-weight:700;cursor:pointer';
|
||
x.addEventListener('click', closeAdOverlay);
|
||
const ifr = document.createElement('iframe');
|
||
ifr.src = url;
|
||
ifr.style.cssText = 'flex:1;width:100%;border:0;background:#050b09';
|
||
back.appendChild(ifr); back.appendChild(x);
|
||
document.body.appendChild(back);
|
||
document.body.style.overflow = 'hidden';
|
||
}
|
||
function closeAdOverlay() {
|
||
const m = $('adOverlay'); if (m) m.remove();
|
||
document.body.style.overflow = '';
|
||
earnRefresh(); loadDashboard();
|
||
}
|
||
window.addEventListener('message', e => {
|
||
if (e.origin !== location.origin || !e.data) return;
|
||
if (e.data.t === 'iap-view-close') closeAdOverlay();
|
||
else if (e.data.t === 'iap-view-done') { earnRefresh(); loadDashboard(); }
|
||
});
|
||
$('earnStartBtn').addEventListener('click', () => earnShowAd());
|
||
// the viewer tab pings localStorage when a view credits; refresh instantly
|
||
window.addEventListener('storage', e => {
|
||
if (e.key === 'iap-view-done') { earnRefresh(); loadDashboard(); }
|
||
});
|
||
document.addEventListener('visibilitychange', () => {
|
||
if (document.visibilityState === 'visible' && $('pane-earn') && !$('pane-earn').hidden) earnRefresh();
|
||
});
|
||
$('earnClaimBtn').addEventListener('click', async () => {
|
||
try {
|
||
const r = await api('/api/my/claim');
|
||
IAP.status('+' + r.credited + ' credits earned. Spend them in Campaigns.', 'ok');
|
||
await earnRefresh();
|
||
loadDashboard();
|
||
} catch (e) { IAP.status(e.message, 'bad'); }
|
||
});
|
||
|
||
const busy = (btn, fn) => async () => {
|
||
try { btn.disabled = true; await fn(); }
|
||
catch (e) { IAP.status((e && e.message) || String(e), 'bad'); }
|
||
finally { btn.disabled = false; }
|
||
};
|
||
|
||
// passwordless (feature-flagged on config.emailAuth): code replaces passwords
|
||
(async () => {
|
||
const cfg = await IAP.getConfig();
|
||
if (!cfg.emailAuth) return;
|
||
$('passCards').hidden = true;
|
||
$('magicCard').hidden = false;
|
||
const start = busy($('mcSendBtn'), async () => {
|
||
const r = await api('/api/auth/email/start', { email: $('mcEmail').value });
|
||
$('mcCodeRow').hidden = false;
|
||
$('mcVerifyBtn').hidden = false;
|
||
$('mcSendBtn').hidden = true;
|
||
$('mcResend').hidden = false;
|
||
if (r.devCode) { $('mcCode').value = r.devCode; IAP.status('Dev mode: code filled in for you.', 'ok'); }
|
||
else IAP.status('Code sent. Check your inbox (and spam, the first time).', 'ok');
|
||
$('mcCode').focus();
|
||
});
|
||
$('mcSendBtn').addEventListener('click', start);
|
||
$('mcResend').addEventListener('click', busy($('mcResend'), async () => {
|
||
const r = await api('/api/auth/email/start', { email: $('mcEmail').value });
|
||
if (r.devCode) $('mcCode').value = r.devCode;
|
||
IAP.status('Fresh code sent.', 'ok');
|
||
}));
|
||
$('mcVerifyBtn').addEventListener('click', busy($('mcVerifyBtn'), async () => {
|
||
const r = await api('/api/auth/email/verify', { email: $('mcEmail').value, code: $('mcCode').value, newsletter: !!($('nlOptin') && $('nlOptin').checked) });
|
||
IAP.status('You are in.', 'ok');
|
||
if (r.created && !(r.account && r.account.username)) await showOnboard(); // pick a username first
|
||
if (!(await showGauntlet())) await showLoginAd(); // welcome tour outranks the login ad
|
||
await render();
|
||
}));
|
||
})();
|
||
|
||
// new-member onboarding: choose a username, optionally a bio (both skippable)
|
||
function showOnboard() {
|
||
return new Promise(resolve => {
|
||
const m = $('onboardModal'); if (!m) return resolve();
|
||
m.hidden = false; $('obErr').hidden = true;
|
||
const done = () => { m.hidden = true; resolve(); };
|
||
$('obSkip').onclick = done;
|
||
$('obSave').onclick = async () => {
|
||
const u = $('obUsername').value.trim();
|
||
try {
|
||
if (u) await api('/api/my/profile', { username: u });
|
||
const bio = $('obBio').value.trim();
|
||
if (bio) await api('/api/my/profile-details', { bio });
|
||
done();
|
||
} catch (e) { $('obErr').hidden = false; $('obErr').textContent = e.message || 'Could not save that. Try a different username.'; }
|
||
};
|
||
});
|
||
}
|
||
|
||
$('signupBtn').addEventListener('click', busy($('signupBtn'), async () => {
|
||
const r = await api('/api/signup', { email: $('suEmail').value, password: $('suPass').value, newsletter: !!($('nlOptin') && $('nlOptin').checked) });
|
||
IAP.status('Welcome aboard. You are in.', 'ok');
|
||
if (!(r.account && r.account.username)) await showOnboard();
|
||
await render();
|
||
}));
|
||
$('loginBtn').addEventListener('click', busy($('loginBtn'), async () => {
|
||
await api('/api/login', { email: $('liEmail').value, password: $('liPass').value });
|
||
IAP.status('Logged in.', 'ok');
|
||
if (!(await showGauntlet())) await showLoginAd(); // welcome tour outranks the login ad
|
||
await render();
|
||
}));
|
||
$('linkBtn').addEventListener('click', busy($('linkBtn'), async () => {
|
||
IAP.status('Check your wallet for the free link signature…');
|
||
await IAPWallet.signIn(); // server binds the wallet to the signed-in email account
|
||
IAP.status('Wallet linked. Earnings pay there from now on.', 'ok');
|
||
await render();
|
||
}));
|
||
if ($('faucetBtn')) $('faucetBtn').addEventListener('click', busy($('faucetBtn'), async () => {
|
||
IAP.status('Connect your wallet first…');
|
||
const addr = await IAPWallet.connect();
|
||
let copied = false;
|
||
try { await navigator.clipboard.writeText(addr); copied = true; } catch (e) {}
|
||
window.open('https://faucet.polygon.technology/', '_blank', 'noopener');
|
||
$('faucetInfo').innerHTML = 'Your address <span class="mono">' + addr.slice(0, 8) + '…' + addr.slice(-6) + '</span>'
|
||
+ (copied ? ' is copied' : '') + '. On the faucet, choose <b>Polygon Amoy</b>, paste your address, and request POL. Then come back and buy.';
|
||
IAP.status('Faucet opened in a new tab. Choose Polygon Amoy, paste your address, and request test POL.', 'ok');
|
||
}));
|
||
if ($('wcDisconnect')) $('wcDisconnect').addEventListener('click', busy($('wcDisconnect'), async () => {
|
||
// fire-and-forget: don't gate the reload on disconnect resolving (it can
|
||
// stall). The reload drops all in-memory wallet state and the picker returns.
|
||
IAPWallet.disconnect().catch(() => {});
|
||
$('wcDisconnectInfo').textContent = 'Disconnecting — reloading so you can pick a different wallet…';
|
||
IAP.status('Disconnecting — reloading…', 'ok');
|
||
setTimeout(() => location.reload(), 900);
|
||
}));
|
||
$('activateBtn').addEventListener('click', busy($('activateBtn'), async () => {
|
||
const me = await (await fetch('/api/me')).json();
|
||
IAP.status('Confirm the free activation in your wallet…');
|
||
const r = await IAPWallet.activate(me.sponsorId || 0);
|
||
if (r.receipt.status !== '0x1') throw new Error('Transaction reverted. Check the explorer.');
|
||
IAP.status('Payouts are on. Your invite link is live.', 'ok');
|
||
await render();
|
||
}));
|
||
$('copyInvite').addEventListener('click', async () => {
|
||
try { await navigator.clipboard.writeText($('inviteLine').textContent); IAP.status('Link copied.', 'ok'); }
|
||
catch (e) { IAP.status('Copy failed. Select and copy the link text.', 'bad'); }
|
||
});
|
||
$('logoutLink').addEventListener('click', async e => {
|
||
e.preventDefault();
|
||
await fetch('/api/auth/logout', { method: 'POST' });
|
||
IAP.status('Logged out.', 'ok');
|
||
await render();
|
||
});
|
||
|
||
// ── welcome tour (viral banner gauntlet): a new member meets their 3-level
|
||
// upline's sites, 10 focus-paused seconds each, then unlocks welcome credits.
|
||
// Same three levels the contract pays — the tour IS the org chart.
|
||
async function showGauntlet() {
|
||
try {
|
||
const g = await (await fetch('/api/my/gauntlet')).json();
|
||
if (!g.pending || !g.slides || !g.slides.length) return false;
|
||
const gate = $('gauntGate');
|
||
gate.hidden = false;
|
||
for (let i = 0; i < g.slides.length; i++) {
|
||
const s = g.slides[i];
|
||
$('ggWho').textContent = 'Position ' + (i + 1) + ': ' + s.name + (i === 0 ? ' — the person who invited you' : '');
|
||
$('ggProgress').textContent = 'Meeting your line: ' + (i + 1) + ' of ' + g.slides.length;
|
||
$('ggFrame').hidden = false;
|
||
$('ggFrame').src = s.targetUrl;
|
||
let left = g.dwell || 10;
|
||
$('ggTimer').textContent = left + 's';
|
||
await new Promise(done => {
|
||
const t = setInterval(() => {
|
||
if (document.visibilityState !== 'visible' || !document.hasFocus()) return;
|
||
left -= 1;
|
||
$('ggTimer').textContent = Math.max(0, left) + 's';
|
||
if (left <= 0) { clearInterval(t); done(); }
|
||
}, 1000);
|
||
});
|
||
}
|
||
$('ggFrame').src = 'about:blank';
|
||
$('ggFrame').hidden = true; // avoid a white about:blank panel once the tour is done
|
||
$('ggTimer').textContent = '✓';
|
||
$('ggWho').textContent = 'That is your line. When you grow, they earn — and yours starts the day you share.';
|
||
$('ggClaim').hidden = false;
|
||
await new Promise(done => {
|
||
$('ggClaim').onclick = async () => {
|
||
try {
|
||
const r = await api('/api/my/gauntlet/complete', { token: g.token });
|
||
IAP.status('+' + r.credited + ' welcome credits unlocked. They spend on real campaigns.', 'ok');
|
||
} catch (e) { IAP.status(e.message, 'bad'); }
|
||
done();
|
||
};
|
||
});
|
||
gate.hidden = true;
|
||
$('ggClaim').hidden = true;
|
||
return true;
|
||
} catch (e) { return false; }
|
||
}
|
||
|
||
// ── line banner (profile): the member's slot on welcome tours + their wall ──
|
||
function fillLineBanner(a) {
|
||
if (!a) return;
|
||
if (a.lineTargetUrl) $('lbTarget').value = a.lineTargetUrl;
|
||
if (a.lineBannerUrl) {
|
||
$('lbBanner').value = a.lineBannerUrl;
|
||
$('lbPreview').hidden = false;
|
||
$('lbPreview').innerHTML = '<img src="' + a.lineBannerUrl + '" alt="line banner" style="max-width:320px;border-radius:10px">';
|
||
}
|
||
$('lbCurrent').textContent = a.lineTargetUrl
|
||
? 'Live: your next three levels meet ' + a.lineTargetUrl + ' on their welcome tour.'
|
||
: 'Not set yet. Until you set one, your tour slot is skipped.';
|
||
}
|
||
async function loadLineBanner() {
|
||
try {
|
||
const a = await (await fetch('/api/me')).json();
|
||
fillLineBanner(a);
|
||
fillProfileDetails(a);
|
||
} catch (e) {}
|
||
}
|
||
const SOCIALS = ['facebook', 'twitter', 'youtube', 'instagram', 'tiktok', 'telegram', 'linkedin', 'website'];
|
||
function fillProfileDetails(a) {
|
||
if (!a) return;
|
||
if (a.bio) $('pfBio').value = a.bio;
|
||
if (a.avatarUrl) { const p = $('pfAvatarPrev'); p.src = a.avatarUrl; p.hidden = false; }
|
||
let soc = {}; try { soc = a.socials ? JSON.parse(a.socials) : {}; } catch (e) {}
|
||
for (const p of SOCIALS) if ($('soc-' + p)) $('soc-' + p).value = soc[p] || '';
|
||
if (a.username) {
|
||
const link = location.origin + '/wall/' + a.username;
|
||
$('pfBioLink').textContent = link;
|
||
$('pfViewBio').hidden = false;
|
||
$('pfViewBio').href = '/wall/' + a.username;
|
||
}
|
||
}
|
||
let pfAvatar; // pending avatar url
|
||
$('pfAvatarBtn').addEventListener('click', () => $('pfAvatarFile').click());
|
||
$('pfAvatarFile').addEventListener('change', async () => {
|
||
const f = $('pfAvatarFile').files[0];
|
||
if (!f) return;
|
||
$('pfAvatarInfo').textContent = 'Uploading…';
|
||
try {
|
||
const r = await (await fetch('/api/my/upload', { method: 'POST', headers: { 'Content-Type': f.type }, body: f })).json();
|
||
if (r.error) { $('pfAvatarInfo').textContent = r.error; $('pfAvatarFile').value = ''; return; }
|
||
pfAvatar = r.url;
|
||
$('pfAvatarInfo').textContent = 'Uploaded — save to apply.';
|
||
const p = $('pfAvatarPrev'); p.src = r.url; p.hidden = false;
|
||
} catch (e) { $('pfAvatarInfo').textContent = 'Upload failed.'; }
|
||
$('pfAvatarFile').value = '';
|
||
});
|
||
$('pfDetailsSave').addEventListener('click', busy2($('pfDetailsSave'), async () => {
|
||
const body = { bio: $('pfBio').value, socials: {} };
|
||
for (const p of SOCIALS) body.socials[p] = ($('soc-' + p) && $('soc-' + p).value.trim()) || '';
|
||
if (pfAvatar) body.avatarUrl = pfAvatar;
|
||
const r = await api('/api/my/profile-details', body);
|
||
IAP.status('Profile saved.', 'ok');
|
||
if (r.account) fillProfileDetails(r.account);
|
||
}));
|
||
$('lbUploadBtn').addEventListener('click', () => $('lbFile').click());
|
||
$('lbFile').addEventListener('change', async () => {
|
||
const f = $('lbFile').files[0];
|
||
if (!f) return;
|
||
$('lbUpInfo').textContent = 'Uploading…';
|
||
try {
|
||
const r = await (await fetch('/api/my/upload', { method: 'POST', headers: { 'Content-Type': f.type }, body: f })).json();
|
||
if (r.error) { $('lbUpInfo').textContent = r.error; $('lbFile').value = ''; return; }
|
||
$('lbBanner').value = r.url;
|
||
$('lbUpInfo').textContent = 'Uploaded.';
|
||
$('lbPreview').hidden = false;
|
||
$('lbPreview').innerHTML = '<img src="' + r.url + '" alt="line banner" style="max-width:320px;border-radius:10px">';
|
||
} catch (e) { $('lbUpInfo').textContent = 'Upload failed. Try again.'; }
|
||
$('lbFile').value = '';
|
||
});
|
||
$('lbSaveBtn').addEventListener('click', busy2($('lbSaveBtn'), async () => {
|
||
const r = await api('/api/my/linebanner', { bannerUrl: $('lbBanner').value, targetUrl: $('lbTarget').value });
|
||
IAP.status('Line banner saved. Your next three levels will meet it.', 'ok');
|
||
if (r.account) fillLineBanner(r.account);
|
||
}));
|
||
|
||
// ── login ad interstitial (ClickBaitPays pattern): after a successful
|
||
// sign-in the sponsor card appears; "Open Ad" opens the CTA link in a NEW
|
||
// tab (a real, counted click) while the timer counts down on THIS page —
|
||
// deliberately not paused, the member is expected to be in the ad tab.
|
||
// At zero, "Go to dashboard" appears.
|
||
async function showLoginAd() {
|
||
try {
|
||
const { ad } = await (await fetch('/api/ads/slot?type=login')).json();
|
||
if (!ad || !ad.targetUrl) return;
|
||
const gate = $('loginGate');
|
||
const openBtn = $('lgOpen');
|
||
const goBtn = $('lgContinue');
|
||
const timer = $('lgTimer');
|
||
$('lgCreative').innerHTML = ad.imageUrl
|
||
? '<img src="' + ad.imageUrl + '" alt="sponsor ad">'
|
||
: '<span class="lg-linkcard">' + (ad.title ? String(ad.title).replace(/[&<>]/g, '') : 'Visit today\'s sponsor') + '</span>';
|
||
$('lgStatus').textContent = 'Login ad sponsor — click "Open Ad" to begin.';
|
||
timer.hidden = true;
|
||
goBtn.hidden = true;
|
||
openBtn.disabled = false;
|
||
gate.hidden = false;
|
||
await new Promise(done => {
|
||
openBtn.onclick = () => {
|
||
window.open(ad.targetUrl, '_blank'); // the click relay counts it
|
||
openBtn.disabled = true;
|
||
$('lgStatus').textContent = 'Ad open in a new tab. View it and come back — the timer runs here.';
|
||
let left = ad.dwell || 10;
|
||
timer.hidden = false;
|
||
timer.textContent = 'Time remaining: ' + left + 's';
|
||
const t = setInterval(() => {
|
||
left -= 1;
|
||
if (left > 0) { timer.textContent = 'Time remaining: ' + left + 's'; return; }
|
||
clearInterval(t);
|
||
timer.textContent = 'Time is up';
|
||
timer.classList.add('done');
|
||
$('lgStatus').textContent = 'Thanks for the look. Your dashboard is ready.';
|
||
goBtn.hidden = false;
|
||
}, 1000);
|
||
goBtn.onclick = () => done();
|
||
};
|
||
});
|
||
gate.hidden = true;
|
||
$('lgTimer').classList.remove('done');
|
||
} catch (e) {}
|
||
}
|
||
|
||
// ── SPONSOR CHAT: two-way threads, presence-aware, with a slide-in drawer ──
|
||
let CHAT_ME = null, CHAT_OTHER = null, CHAT_LASTID = 0, CHAT_POLL = null, CHAT_CANMUTE = false, CHAT_IMUTE = false, CHAT_SPONSOR = null, CHAT_LAST_UNREAD = 0;
|
||
function chatSync(d) {
|
||
CHAT_ME = d.email || CHAT_ME;
|
||
CHAT_SPONSOR = (d.sponsor && d.sponsor.email) ? d.sponsor : null;
|
||
const link = $('chatMenuBtn'); if (link && link.closest('.bo-links')) link.closest('.bo-links').hidden = !d.email;
|
||
setChatBadge(d.chatUnread || 0);
|
||
CHAT_LAST_UNREAD = d.chatUnread || 0;
|
||
const tog = $('chatAvailToggle'); if (tog) tog.checked = d.chatAvailable !== false;
|
||
// Overview quick action + My line card: message-your-sponsor entry points
|
||
const qs = $('qaMsgSponsor');
|
||
if (qs) {
|
||
if (CHAT_SPONSOR) { qs.hidden = false; qs.dataset.email = CHAT_SPONSOR.email; qs.dataset.name = CHAT_SPONSOR.name || 'your sponsor'; }
|
||
else qs.hidden = true;
|
||
}
|
||
const card = $('sponsorMsgCard');
|
||
if (card) {
|
||
card.hidden = !CHAT_SPONSOR;
|
||
if (CHAT_SPONSOR && $('sponsorMsgName')) $('sponsorMsgName').textContent = CHAT_SPONSOR.name || 'your sponsor';
|
||
}
|
||
}
|
||
function setChatBadge(n) { const b = $('chatNavBadge'); if (b) { b.hidden = !n; b.textContent = n > 9 ? '9+' : n; } }
|
||
function stopPoll() { if (CHAT_POLL) { clearInterval(CHAT_POLL); CHAT_POLL = null; } }
|
||
function startPoll() { stopPoll(); CHAT_POLL = setInterval(() => pullThread(false), 4000); }
|
||
function autoGrow(el) { el.style.height = 'auto'; el.style.height = Math.min(120, el.scrollHeight) + 'px'; }
|
||
function closeDrawer() { stopPoll(); if ($('chatDrawer')) $('chatDrawer').hidden = true; CHAT_OTHER = null; loadDashboard(); }
|
||
|
||
async function openThreads() {
|
||
const dr = $('chatDrawer'); if (!dr) return;
|
||
dr.hidden = false; $('chatConvo').hidden = true; $('chatThreads').hidden = false;
|
||
$('chatBack').hidden = true; $('chatMute').hidden = true; $('chatDot').hidden = true;
|
||
$('chatTitle').textContent = 'Messages'; $('chatStatus').textContent = '';
|
||
stopPoll(); CHAT_OTHER = null;
|
||
$('chatThreads').innerHTML = '<p class="muted small" style="padding:14px">Loading…</p>';
|
||
try {
|
||
const r = await (await fetch('/api/my/chat/threads')).json();
|
||
const list = r.threads || [];
|
||
// always offer "message your sponsor" up top when they have one and no thread yet
|
||
const hasSponsorThread = CHAT_SPONSOR && list.some(t => t.email === CHAT_SPONSOR.email);
|
||
const sponsorRow = (CHAT_SPONSOR && !hasSponsorThread)
|
||
? '<div class="chat-thread" data-email="' + esc(CHAT_SPONSOR.email) + '" data-name="' + esc(CHAT_SPONSOR.name) + '">'
|
||
+ '<span class="pres-dot' + (CHAT_SPONSOR.online ? ' on' : '') + '"></span>'
|
||
+ '<div class="ct-main"><div class="ct-name">' + esc(CHAT_SPONSOR.name) + '</div>'
|
||
+ '<div class="ct-last">Your sponsor · tap to message</div></div></div>'
|
||
: '';
|
||
if (!list.length && !sponsorRow) { $('chatThreads').innerHTML = '<div class="chat-empty">No conversations yet. You can message anyone in your line from “My line”.</div>'; return; }
|
||
$('chatThreads').innerHTML = sponsorRow + list.map(t =>
|
||
'<div class="chat-thread" data-email="' + esc(t.email) + '" data-name="' + esc(t.name) + '">'
|
||
+ '<span class="pres-dot' + (t.online ? ' on' : '') + '"></span>'
|
||
+ '<div class="ct-main"><div class="ct-name">' + esc(t.name) + '</div>'
|
||
+ '<div class="ct-last">' + (t.last.fromMe ? 'You: ' : '') + esc((t.last.body || '').slice(0, 64)) + '</div></div>'
|
||
+ (t.unread ? '<span class="ct-un">' + t.unread + '</span>' : '') + '</div>').join('');
|
||
$('chatThreads').querySelectorAll('.chat-thread').forEach(el =>
|
||
el.addEventListener('click', () => openConvo(el.dataset.email, el.dataset.name)));
|
||
} catch (e) { $('chatThreads').innerHTML = '<div class="chat-empty">Could not load messages.</div>'; }
|
||
}
|
||
|
||
async function openConvo(email, name) {
|
||
if (!email) return;
|
||
CHAT_OTHER = email; CHAT_LASTID = 0;
|
||
const dr = $('chatDrawer'); if (!dr) return;
|
||
dr.hidden = false; $('chatThreads').hidden = true; $('chatConvo').hidden = false;
|
||
$('chatBack').hidden = false; $('chatDot').hidden = false;
|
||
$('chatTitle').textContent = name || email; $('chatStatus').textContent = '…';
|
||
$('chatMsgs').innerHTML = ''; $('chatBanner').hidden = true;
|
||
$('chatInput').disabled = false; $('chatSend').disabled = false;
|
||
await pullThread(true); startPoll();
|
||
setTimeout(() => { const i = $('chatInput'); if (i) i.focus(); }, 60);
|
||
}
|
||
|
||
function renderMsgs(msgs) {
|
||
const box = $('chatMsgs'); if (!box) return;
|
||
const atBottom = box.scrollTop + box.clientHeight >= box.scrollHeight - 60;
|
||
for (const m of msgs) {
|
||
if (m.id <= CHAT_LASTID) continue;
|
||
CHAT_LASTID = Math.max(CHAT_LASTID, m.id);
|
||
const d = document.createElement('div');
|
||
d.className = 'cbub ' + (m.fromMe ? 'me' : 'them');
|
||
d.textContent = m.body;
|
||
const t = document.createElement('span'); t.className = 'ct-time';
|
||
t.textContent = new Date(m.sent).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
|
||
d.appendChild(t); box.appendChild(d);
|
||
}
|
||
if (atBottom) box.scrollTop = box.scrollHeight;
|
||
}
|
||
|
||
async function pullThread(reset) {
|
||
if (!CHAT_OTHER) return;
|
||
try {
|
||
const r = await (await fetch('/api/my/chat/thread?with=' + encodeURIComponent(CHAT_OTHER) + '&after=' + (reset ? 0 : CHAT_LASTID))).json();
|
||
if (r.error) { $('chatBanner').hidden = false; $('chatBanner').textContent = r.error; return; }
|
||
if (reset) { $('chatMsgs').innerHTML = ''; CHAT_LASTID = 0; }
|
||
renderMsgs(r.messages || []);
|
||
$('chatDot').className = 'pres-dot' + (r.online ? ' on' : '');
|
||
$('chatStatus').textContent = r.online ? 'active now' : (r.available ? 'away · will get your note' : 'not taking live chats · leave a note');
|
||
CHAT_CANMUTE = !!r.canMute; CHAT_IMUTE = !!r.iMute;
|
||
const mb = $('chatMute'); mb.hidden = !CHAT_CANMUTE; mb.textContent = CHAT_IMUTE ? 'Unmute' : 'Mute';
|
||
const blocked = !!r.blocked;
|
||
$('chatInput').disabled = blocked; $('chatSend').disabled = blocked;
|
||
if (blocked) { $('chatBanner').hidden = false; $('chatBanner').textContent = 'They are not accepting messages from you right now.'; }
|
||
else if (CHAT_IMUTE) { $('chatBanner').hidden = false; $('chatBanner').textContent = 'You muted this member. Unmute to let them message you again.'; }
|
||
else $('chatBanner').hidden = true;
|
||
} catch (e) {}
|
||
}
|
||
|
||
async function chatSend() {
|
||
const inp = $('chatInput'); if (!inp) return;
|
||
const text = inp.value.trim(); if (!text || !CHAT_OTHER) return;
|
||
$('chatSend').disabled = true;
|
||
try {
|
||
const r = await api('/api/my/chat/send', { to: CHAT_OTHER, body: text });
|
||
inp.value = ''; autoGrow(inp); renderMsgs([r.message]);
|
||
const box = $('chatMsgs'); box.scrollTop = box.scrollHeight;
|
||
} catch (e) { IAP.status(e.message || 'Could not send.', 'bad'); }
|
||
finally { $('chatSend').disabled = false; inp.focus(); }
|
||
}
|
||
|
||
async function toggleMute() {
|
||
if (!CHAT_OTHER) return;
|
||
try { const r = await api('/api/my/chat/mute', { email: CHAT_OTHER, muted: !CHAT_IMUTE }); CHAT_IMUTE = r.muted; $('chatMute').textContent = CHAT_IMUTE ? 'Unmute' : 'Mute'; pullThread(false); }
|
||
catch (e) { IAP.status(e.message, 'bad'); }
|
||
}
|
||
|
||
(function chatInit() {
|
||
if (!$('chatDrawer')) return;
|
||
const on = (id, ev, fn) => { const el = $(id); if (el) el.addEventListener(ev, fn); };
|
||
on('chatMenuBtn', 'click', e => { if (e && e.preventDefault) e.preventDefault(); openThreads(); });
|
||
on('chatClose', 'click', closeDrawer);
|
||
on('chatBack', 'click', openThreads);
|
||
on('chatMute', 'click', toggleMute);
|
||
on('chatSend', 'click', chatSend);
|
||
const inp = $('chatInput');
|
||
if (inp) {
|
||
inp.addEventListener('input', () => autoGrow(inp));
|
||
inp.addEventListener('keydown', e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); chatSend(); } });
|
||
}
|
||
on('qaMsgSponsor', 'click', () => { const q = $('qaMsgSponsor'); if (q.dataset.email) openConvo(q.dataset.email, q.dataset.name || 'your sponsor'); });
|
||
on('lineMsgSponsor', 'click', () => { if (CHAT_SPONSOR) openConvo(CHAT_SPONSOR.email, CHAT_SPONSOR.name || 'your sponsor'); });
|
||
on('chatAvailToggle', 'change', async () => {
|
||
const t = $('chatAvailToggle');
|
||
try { await api('/api/my/chat/available', { available: t.checked }); IAP.status(t.checked ? 'You are available to chat with your line.' : 'Live chat off. People can still leave you a note.', 'ok'); }
|
||
catch (e) { IAP.status(e.message, 'bad'); t.checked = !t.checked; }
|
||
});
|
||
// presence heartbeat + new-message notifier: pop + toast when unread rises
|
||
setInterval(async () => {
|
||
try {
|
||
const r = await (await fetch('/api/my/ping')).json();
|
||
const n = r.chatUnread || 0;
|
||
if (n > CHAT_LAST_UNREAD) { playSound('pop'); IAP.status('💬 New message from your team.', 'ok'); }
|
||
CHAT_LAST_UNREAD = n; setChatBadge(n);
|
||
} catch (e) {}
|
||
}, 20000);
|
||
})();
|
||
|
||
// ad surfaces: a banner greets the sign-in screen; members see live
|
||
// banner + text placements inside the back office (they ARE the audience)
|
||
IAP.adSlot('banner', 'adSlotLogin');
|
||
IAP.adSlot('banner', 'adSlotOverview');
|
||
IAP.adSlot('banner', 'adSlotSide', { width: 125, height: 125 }); // square button ad in the sidebar
|
||
|
||
render();
|
||
})();
|