93912fa621
Mobile and in-app wallet (dApp) browsers handle tabs badly, so opening each ad in a new tab made the earn loop hard to navigate and impossible to close. Ads now open in a full-screen in-page overlay that reuses the /view dwell + human-check + credit logic in an iframe: an always-working close control, no window.close, no tab-switching. The framed /view relaxes its focus-pause to visibility only and messages the dashboard when it credits or the user is done. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1777 lines
99 KiB
JavaScript
1777 lines
99 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);
|
||
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();
|
||
const signedIn = me && me.signedIn;
|
||
$('authArea').hidden = !!signedIn;
|
||
$('memberArea').hidden = !signedIn;
|
||
if (!signedIn) return;
|
||
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)));
|
||
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: Branded Voice copy, personalized with the member link ──
|
||
const PROMO_POSTS = [
|
||
'A membership site where money is handled by code, not people. Every purchase splits instantly to sponsor wallets on the Polygon blockchain. Nothing to withdraw. The money just lands in your wallet. Plus you earn ad credits for viewing ads while you\'re there. {{LINK}}',
|
||
'No withdrawal button. Think about that. A smart contract on Polygon splits every payment the second it hits. 50% to the sponsor. 20% to the next level. 10% to the next. Lands straight in your own wallet. No button to push. No waiting. Just money where it belongs. See how it works: {{LINK}}',
|
||
'Every payment is public on the blockchain. You can watch the ledger move in real time. Every split, every wallet, every transaction. Nothing hidden. Nothing you have to take on faith. That\'s the difference between a platform that asks for trust and one where trust isn\'t needed. See for yourself: {{LINK}}'
|
||
];
|
||
const PROMO_SWIPE = {
|
||
subject: 'Your Wallet Gets Paid Instantly..',
|
||
body: 'You know the usual drill. Someone buys on your link, you wait for a payout. Maybe days. Maybe an approval hold. Maybe a "your account is under review."\n\nInstantAdPay doesn\'t work like that.\n\nA smart contract on the Polygon blockchain handles every purchase the second it happens. 50% to the sponsor. 20% to the next level. 10% to the one after that. 20% to the platform. Each split lands directly in your own wallet. No withdrawal button. No "request payout." No approval queue.\n\nThe money just shows up.\n\nYou can watch every transaction on the public ledger. Real time. Anyone can verify it.\n\nFree to join. Packages from $5 to $250. No income promises. It\'s advertising, not investing.\n\n{{LINK}}'
|
||
};
|
||
function promoBlock(text) {
|
||
const div = document.createElement('div');
|
||
div.className = 'promo-block';
|
||
div.textContent = text;
|
||
const btn = document.createElement('button');
|
||
btn.className = 'btn small sec';
|
||
btn.textContent = 'Copy';
|
||
btn.addEventListener('click', async () => {
|
||
try { await navigator.clipboard.writeText(text); IAP.status('Copied. Paste it anywhere.', 'ok'); }
|
||
catch (e) { IAP.status('Copy failed. Select the text instead.', 'bad'); }
|
||
});
|
||
div.appendChild(btn);
|
||
return div;
|
||
}
|
||
function fillPromo(link) {
|
||
const posts = $('promoPosts');
|
||
if (!posts || posts.dataset.filled === link) return;
|
||
posts.dataset.filled = link;
|
||
posts.innerHTML = '';
|
||
for (const p of PROMO_POSTS) posts.appendChild(promoBlock(p.replace('{{LINK}}', link)));
|
||
const sw = $('promoSwipeWrap');
|
||
sw.innerHTML = '';
|
||
sw.appendChild(promoBlock('Subject: ' + PROMO_SWIPE.subject + '\n\n' + PROMO_SWIPE.body.replace('{{LINK}}', link)));
|
||
// 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-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 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</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.
|
||
const meNow = await (await fetch('/api/me')).json();
|
||
if (!meNow.address) {
|
||
IAP.status('Link your wallet first — one quick signature…');
|
||
await IAPWallet.signIn();
|
||
}
|
||
const spNow = await (await fetch('/api/sponsor')).json();
|
||
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();
|
||
}));
|
||
$('walletSigninLink').addEventListener('click', async e => {
|
||
e.preventDefault();
|
||
try {
|
||
IAP.status('Check your wallet for the free sign-in signature…');
|
||
await IAPWallet.signIn();
|
||
IAP.status('Signed in with your wallet.', 'ok');
|
||
if (!(await showGauntlet())) await showLoginAd(); // welcome tour outranks the login ad
|
||
await render();
|
||
} catch (err) { IAP.status((err && err.message) || String(err), 'bad'); }
|
||
});
|
||
$('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 to receive test POL…');
|
||
const addr = await IAPWallet.connect();
|
||
const r = await api('/api/my/faucet', { address: addr });
|
||
const pol = (Number(BigInt(r.balanceWei) / (10n ** 15n)) / 1000).toFixed(3);
|
||
$('faucetInfo').textContent = 'Funded — balance ' + pol + ' test-POL. Head to Buy packages.';
|
||
IAP.status('Your wallet now holds test POL. Go buy a package.', 'ok');
|
||
}));
|
||
$('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();
|
||
})();
|