0da7c796b8
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2671 lines
196 KiB
JavaScript
2671 lines
196 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> <button class="share" data-badgepost="' + b.key + '" hidden>Post to Telegram</button>' : '<div class="bs">🔒 locked</div>') + '</div>';
|
||
}).join('');
|
||
strip.querySelectorAll('[data-badge]').forEach(btn =>
|
||
btn.addEventListener('click', () => shareBadge(btn.dataset.badge, d)));
|
||
strip.querySelectorAll('[data-badgepost]').forEach(btn =>
|
||
btn.addEventListener('click', () => postBadge(btn.dataset.badgepost, d, btn, true)));
|
||
fetch('/api/my/badge-posted').then(r => r.json()).then(r => { // the manual button is the admin's only (Marty, 2026-09-13)
|
||
if (r.canPost) strip.querySelectorAll('[data-badgepost]').forEach(b => { b.hidden = false; });
|
||
(r.posted || []).forEach(k => { const b = strip.querySelector('[data-badgepost="' + k + '"]'); if (b) { b.textContent = 'Posted ✓'; b.disabled = true; } });
|
||
}).catch(() => {});
|
||
// celebrate anything newly granted this load, and post the branded badge to the team channels (Marty, 2026-09-13)
|
||
if (d.milestonesGranted && d.milestonesGranted.length) {
|
||
const total = d.milestonesGranted.reduce((s, g) => s + g.credited, 0);
|
||
const names = d.milestonesGranted.map(g => (BADGES.find(b => b.key === g.key) || {}).label).filter(Boolean).join(', ');
|
||
IAP.status('🏆 Achievement unlocked: ' + names + ' — +' + total + ' bonus credits!', 'ok');
|
||
d.milestonesGranted.forEach((g, i) => setTimeout(() => postBadge(g.key, d, strip.querySelector('[data-badgepost="' + g.key + '"]')), 800 + i * 1500));
|
||
}
|
||
}
|
||
// draw the badge with the member's name on the ribbon; cb(canvas) or cb(null)
|
||
function composeBadge(key, d, cb) {
|
||
const b = BADGES.find(x => x.key === key); if (!b) return cb(null);
|
||
const who = d.username ? d.username : d.memberId ? 'member #' + d.memberId : '';
|
||
const img = new Image();
|
||
img.onload = () => {
|
||
const c = document.createElement('canvas'); c.width = img.width; c.height = img.height;
|
||
const x = c.getContext('2d');
|
||
x.drawImage(img, 0, 0);
|
||
if (who) {
|
||
x.textAlign = 'center'; x.textBaseline = 'middle';
|
||
x.font = 'bold ' + Math.round(c.width * 0.055) + 'px Sora, "Segoe UI", sans-serif';
|
||
const y = c.height * (b.ribbonY || 0.75);
|
||
x.lineJoin = 'round'; x.lineWidth = Math.max(3, Math.round(c.width * 0.008));
|
||
x.strokeStyle = 'rgba(4,20,15,.9)'; x.strokeText(who, c.width / 2, y);
|
||
x.fillStyle = '#ffd15c'; x.fillText(who, c.width / 2, y);
|
||
}
|
||
cb(c);
|
||
};
|
||
img.onerror = () => cb(null);
|
||
img.src = b.img;
|
||
}
|
||
async function postBadge(key, d, btn, manual) {
|
||
if (btn && btn.disabled) return;
|
||
if (btn) { btn.disabled = true; btn.textContent = 'Posting…'; }
|
||
composeBadge(key, d, c => {
|
||
if (!c) { if (btn) { btn.disabled = false; btn.textContent = 'Post to Telegram'; } return; }
|
||
c.toBlob(async bl => {
|
||
try {
|
||
const r = await (await fetch('/api/my/badge-post?key=' + encodeURIComponent(key) + (manual ? '&manual=1' : ''), { method: 'POST', headers: { 'Content-Type': 'image/jpeg' }, body: bl })).json();
|
||
if (r.error) throw new Error(r.error);
|
||
if (btn) { btn.textContent = 'Posted ✓'; btn.disabled = true; }
|
||
if (!r.already) IAP.status('Your badge is posted in the team channels.', 'ok');
|
||
} catch (e) { if (btn) { btn.disabled = false; btn.textContent = 'Post to Telegram'; } IAP.status(e.message || 'Could not post the badge.', 'bad'); }
|
||
}, 'image/jpeg', 0.86);
|
||
});
|
||
}
|
||
// Share: a picker (Marty, 2026-09-13). The composed badge is stored once so a public page at
|
||
// /b/<username>/<badge> carries it as the preview image; every network gets that link.
|
||
function shareBadge(key, d) {
|
||
const b = BADGES.find(x => x.key === key); if (!b) return;
|
||
if (!d.username) { IAP.status('Pick a username first; your share page carries it.', 'bad'); return; }
|
||
IAP.status('Preparing your ' + b.label + ' badge…', 'ok');
|
||
composeBadge(key, d, c => {
|
||
if (!c) { IAP.status('Could not load the badge art.', 'bad'); return; }
|
||
c.toBlob(async bl => {
|
||
let page = null;
|
||
try { const r = await (await fetch('/api/my/badge-image?key=' + encodeURIComponent(key), { method: 'POST', headers: { 'Content-Type': 'image/jpeg' }, body: bl })).json(); if (r.error) throw new Error(r.error); page = r.page; }
|
||
catch (e) { IAP.status(e.message || 'Could not prepare the share page.', 'bad'); return; }
|
||
openShareSheet(b, d, bl, page);
|
||
}, 'image/jpeg', 0.9);
|
||
});
|
||
}
|
||
function openShareSheet(b, d, blob, page) {
|
||
const text = 'I just unlocked the ' + b.label + ' badge on InstantAdPay (' + b.sub + '). Advertising that pays you back, on-chain, the same second a package sells.';
|
||
const enc = encodeURIComponent;
|
||
const links = [
|
||
['X', 'https://x.com/intent/tweet?text=' + enc(text) + '&url=' + enc(page)],
|
||
['Facebook', 'https://www.facebook.com/sharer/sharer.php?u=' + enc(page)],
|
||
['Telegram', 'https://t.me/share/url?url=' + enc(page) + '&text=' + enc(text)],
|
||
['WhatsApp', 'https://wa.me/?text=' + enc(text + ' ' + page)],
|
||
['LinkedIn', 'https://www.linkedin.com/sharing/share-offsite/?url=' + enc(page)]
|
||
];
|
||
const back = document.createElement('div'); back.className = 'modal-back'; back.style.zIndex = '200';
|
||
back.innerHTML = '<div class="modal-card" role="dialog" aria-modal="true" style="max-width:420px">'
|
||
+ '<h3 style="margin:0 0 4px">Share your ' + b.label + ' badge</h3><p class="muted small" style="margin:0 0 12px">Each option shares your badge page, which shows the image and your join link.</p>'
|
||
+ '<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px">'
|
||
+ links.map(l => '<a class="btn sec small" target="_blank" rel="noopener" href="' + l[1] + '" style="text-align:center">' + l[0] + '</a>').join('')
|
||
+ '<button type="button" class="btn sec small" data-sh="massifly">Massifly</button>'
|
||
+ (navigator.share ? '<button type="button" class="btn sec small" data-sh="native">More…</button>' : '')
|
||
+ '<button type="button" class="btn sec small" data-sh="copy">Copy link</button>'
|
||
+ '<button type="button" class="btn sec small" data-sh="save">Save image</button>'
|
||
+ '</div><p class="small muted" style="margin:12px 0 0;word-break:break-all">' + page + '</p>'
|
||
+ '<div style="text-align:right;margin-top:12px"><button type="button" class="btn sec small" data-sh="close">Close</button></div></div>';
|
||
document.body.appendChild(back);
|
||
const close = () => back.remove();
|
||
back.addEventListener('click', e => { if (e.target === back) close(); });
|
||
back.querySelector('[data-sh="close"]').addEventListener('click', close);
|
||
back.querySelector('[data-sh="copy"]').addEventListener('click', async () => { try { await navigator.clipboard.writeText(page); IAP.status('Link copied.', 'ok'); } catch (e) { IAP.status('Copy this: ' + page, 'ok'); } });
|
||
back.querySelector('[data-sh="save"]').addEventListener('click', () => {
|
||
const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = 'instantadpay-' + b.label.toLowerCase() + '-badge.jpg'; a.click(); setTimeout(() => URL.revokeObjectURL(a.href), 5000);
|
||
IAP.status('Your ' + b.label + ' badge is saved.', 'ok');
|
||
});
|
||
// Massifly has no share intent: copy the post text + link, then open the feed composer (Marty, 2026-09-13)
|
||
back.querySelector('[data-sh="massifly"]').addEventListener('click', async () => {
|
||
try { await navigator.clipboard.writeText(text + ' ' + page); IAP.status('Post copied. Paste it into the Massifly composer; the badge preview appears from the link.', 'ok'); }
|
||
catch (e) { IAP.status('Copy this into Massifly: ' + text + ' ' + page, 'ok'); }
|
||
window.open('https://massifly.com/account', '_blank', 'noopener');
|
||
});
|
||
const nat = back.querySelector('[data-sh="native"]');
|
||
if (nat) nat.addEventListener('click', async () => {
|
||
try {
|
||
const file = new File([blob], 'instantadpay-' + b.label.toLowerCase() + '-badge.jpg', { type: 'image/jpeg' });
|
||
if (navigator.canShare && navigator.canShare({ files: [file] })) await navigator.share({ files: [file], title: b.label + ' badge', text: text + ' ' + page });
|
||
else await navigator.share({ title: b.label + ' badge', text, url: page });
|
||
} catch (e) {}
|
||
});
|
||
}
|
||
|
||
// milestone stepper under "Your next move": lit nodes for what's done,
|
||
// amber glow on the current target — the same ladder the contract pays
|
||
function renderSteps(d) {
|
||
const el = $('ncSteps');
|
||
if (!el) return;
|
||
const b = d.buyerCount || 0;
|
||
const steps = [
|
||
{ label: 'Joined', sub: 'free account', hit: true },
|
||
{ label: 'Spark', sub: 'payouts on', hit: !!d.memberId },
|
||
{ label: 'Surge', sub: 'first buyer · 50%', hit: b >= 1 },
|
||
{ label: 'Circuit', sub: '2 buyers · +20%', hit: b >= 2 },
|
||
{ label: 'Nexus', sub: '5 buyers · +10%', hit: b >= 5 }
|
||
];
|
||
const cur = steps.findIndex(s => !s.hit);
|
||
el.innerHTML = steps.map((s, i) =>
|
||
'<div class="nc-step' + (s.hit ? ' hit' : i === cur ? ' cur' : '') + '">'
|
||
+ '<span class="dot">' + (s.hit ? '✓' : i + 1) + '</span>'
|
||
+ '<span class="lb">' + s.label + '<i>' + s.sub + '</i></span></div>').join('');
|
||
}
|
||
// ── overview v3 charts: hand-rolled SVG/CSS, real data only ──
|
||
const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
||
const CH = { mint: '#43e8c3', cyan: '#54ccff', violet: '#9d7dff', amber: '#ffb238', track: 'rgba(139,166,156,.18)' };
|
||
function bars(el, xel, data) { // data: [{v,label,tip,alt}]
|
||
if (!el) return;
|
||
const max = Math.max(1, ...data.map(d => d.v));
|
||
el.innerHTML = data.length
|
||
? data.map(d => '<div class="bar' + (d.alt ? ' alt' : '') + (d.v ? '' : ' empty') + '" style="height:'
|
||
+ Math.max(4, Math.round(d.v / max * 100)) + '%" data-v="' + esc(d.tip) + '"></div>').join('')
|
||
: '<div class="bar empty" style="height:4%"></div>'.repeat(8);
|
||
if (xel) xel.innerHTML = data.map(d => '<span>' + esc(d.label) + '</span>').join('');
|
||
}
|
||
function donut(el, segs, center) { // segs sum to the ring; r=15.9155 → circumference 100
|
||
if (!el) return;
|
||
const total = segs.reduce((s, x) => s + x.v, 0);
|
||
let off = 25, out = '<circle cx="21" cy="21" r="15.9155" fill="none" stroke="' + CH.track + '" stroke-width="5"></circle>';
|
||
if (total > 0) for (const s of segs) {
|
||
const len = s.v / total * 100;
|
||
if (len <= 0 || s.color === 'none') { off -= Math.max(0, len); continue; }
|
||
out += '<circle cx="21" cy="21" r="15.9155" fill="none" stroke="' + s.color + '" stroke-width="5" '
|
||
+ 'stroke-dasharray="' + len + ' ' + (100 - len) + '" stroke-dashoffset="' + off + '"></circle>';
|
||
off -= len;
|
||
}
|
||
out += '<text x="21" y="20.5" text-anchor="middle" fill="currentColor" font-size="6.5" font-weight="800" class="donut-center">' + esc(center.big) + '</text>'
|
||
+ '<text x="21" y="26.5" text-anchor="middle" fill="#8ba69c" font-size="3.1">' + esc(center.small) + '</text>';
|
||
el.innerHTML = out;
|
||
}
|
||
function legend(el, rows) {
|
||
if (el) el.innerHTML = rows.map(r => '<div><i style="background:' + r.color + '"></i>'
|
||
+ esc(r.label) + ' <b>' + esc(r.v) + '</b></div>').join('');
|
||
}
|
||
async function loadCharts(d) {
|
||
// credit composition donut + today's viewing ring (live balances)
|
||
try {
|
||
const st = await (await fetch('/api/my/earn')).json();
|
||
if (!st.error) {
|
||
// one rule: a balance is what is NOT committed to a live campaign. Budgets are
|
||
// set aside when a campaign starts and spend down inside Campaigns, so these
|
||
// numbers only move when a campaign is created, topped up or paused
|
||
const purchased = d.credits || 0, earned = d.earnedCredits != null ? d.earnedCredits : (st.earnedAvailable != null ? st.earnedAvailable : (st.earned || 0)), inC = d.inCampaigns || 0;
|
||
$('dbCredits').textContent = (purchased + earned).toLocaleString();
|
||
// the Wallet tab card showed on-chain credits only, so promo/earned credits looked like 0 there (Jim, 2026-09-13)
|
||
if ($('creditLine')) $('creditLine').innerHTML = (purchased + earned).toLocaleString() + ' <span class="muted small" style="font-family:inherit;font-size:13px">' + purchased.toLocaleString() + ' purchased · ' + earned.toLocaleString() + ' earned</span>';
|
||
$('dbCreditsSub').textContent = purchased.toLocaleString() + ' purchased' + (d.creditedCredits ? ' (' + d.creditedCredits.toLocaleString() + ' credited to you)' : '') + ' · ' + earned.toLocaleString() + ' earned' + (inC ? ' · ' + inC.toLocaleString() + ' in campaigns' : '');
|
||
donut($('chDonut'),
|
||
[{ v: purchased, color: CH.mint }, { v: earned, color: CH.cyan }, { v: inC, color: CH.amber }],
|
||
{ big: (purchased + earned).toLocaleString(), small: 'available' });
|
||
legend($('chDonutLegend'), [
|
||
{ color: CH.mint, label: 'Purchased, available', v: purchased.toLocaleString() },
|
||
{ color: CH.cyan, label: 'Earned, available', v: earned.toLocaleString() },
|
||
{ color: CH.amber, label: 'In live campaigns', v: inC.toLocaleString() }]);
|
||
const done = Math.min(st.views || 0, st.target || 5), left = Math.max(0, (st.target || 5) - done);
|
||
donut($('chRing'), [{ v: done, color: st.claimed ? CH.mint : CH.cyan }, { v: left, color: 'none' }],
|
||
{ big: done + '/' + (st.target || 5), small: st.claimed ? 'claimed' : 'ads viewed' });
|
||
legend($('chRingLegend'), [
|
||
{ color: st.claimed ? CH.mint : CH.cyan, label: 'Viewed today', v: done },
|
||
{ color: CH.track, label: 'To go', v: left },
|
||
{ color: CH.amber, label: 'Claim pays', v: '+' + (st.claimCredits || 0) + ' credits' }]);
|
||
}
|
||
} catch (e) {}
|
||
// recent on-chain payouts, sized to scale, oldest→newest
|
||
try {
|
||
let evs = [];
|
||
if (d.memberId) {
|
||
const a = await (await fetch('/api/my/activity')).json();
|
||
evs = (a.earnings || []).filter(e => e.amountWei).sort((x, y) => x.block - y.block).slice(-12);
|
||
}
|
||
bars($('chEarnBars'), $('chEarnX'), evs.map(e => ({
|
||
v: Number(BigInt(e.amountWei) / 1000000000000n) / 1e6,
|
||
label: e.type === 'AwardPaid' ? 'award' : 'L' + (e.tier || 1),
|
||
tip: IAP.fmtPol(e.amountWei) + ' POL', alt: e.type === 'AwardPaid' })));
|
||
if (!evs.length) $('chEarnSub').textContent = d.memberId
|
||
? 'no payouts yet — share your link' : 'activate a package to start earning';
|
||
} catch (e) {}
|
||
// campaign delivery: impressions per campaign
|
||
try {
|
||
const r = await (await fetch('/api/my/campaigns')).json();
|
||
const cs = (r.campaigns || []).slice(0, 8);
|
||
bars($('chCampBars'), $('chCampX'), cs.map(c => ({
|
||
v: c.imps || 0, label: String(c.name || c.type).slice(0, 9),
|
||
tip: (c.name || c.type) + ': ' + (c.imps || 0) + ' imps · ' + (c.clicks || 0) + ' clicks',
|
||
alt: c.type === 'text' })));
|
||
if (!cs.length) $('chCampSub').textContent = 'no campaigns yet — place your first ad';
|
||
} catch (e) {}
|
||
}
|
||
// ── live updates: subscribe to the chain event stream so the members area
|
||
// reacts to on-chain changes (payouts, qualifications) without a refresh ──
|
||
let MYID = 0;
|
||
// synthesized sounds (no asset files; CSP-safe). cha-ching on a payment, pop on a message.
|
||
function playSound(kind) {
|
||
try {
|
||
const AC = window.AudioContext || window.webkitAudioContext; if (!AC) return;
|
||
const ctx = window.__iapAC || (window.__iapAC = new AC());
|
||
if (ctx.state === 'suspended') ctx.resume();
|
||
const now = ctx.currentTime;
|
||
const tone = (freq, at, dur, type, peak) => {
|
||
const o = ctx.createOscillator(), g = ctx.createGain();
|
||
o.type = type || 'sine'; o.frequency.value = freq;
|
||
g.gain.setValueAtTime(0.0001, now + at);
|
||
g.gain.exponentialRampToValueAtTime(peak || 0.22, now + at + 0.02);
|
||
g.gain.exponentialRampToValueAtTime(0.0001, now + at + dur);
|
||
o.connect(g).connect(ctx.destination); o.start(now + at); o.stop(now + at + dur + 0.02);
|
||
};
|
||
if (kind === 'chaching') { tone(1318, 0, 0.34, 'sine', 0.25); tone(1760, 0.09, 0.4, 'sine', 0.25); }
|
||
else { tone(680, 0, 0.16, 'triangle', 0.18); tone(1020, 0.05, 0.16, 'triangle', 0.16); }
|
||
} catch (e) {}
|
||
}
|
||
function startLiveFeed() {
|
||
if (window.__iapFeed || !window.EventSource) return;
|
||
try {
|
||
const es = new EventSource('/api/feed/live');
|
||
window.__iapFeed = es;
|
||
es.onmessage = m => { let ev; try { ev = JSON.parse(m.data); } catch (e) { return; } handleLiveEvent(ev); };
|
||
// browser auto-reconnects on error; nothing to do
|
||
} catch (e) {}
|
||
}
|
||
let liveRefreshT = null;
|
||
function liveRefresh() { // debounce a burst of events into one refresh
|
||
clearTimeout(liveRefreshT);
|
||
liveRefreshT = setTimeout(() => { loadDashboard(); try { loadLineage(); } catch (e) {} }, 600);
|
||
}
|
||
// community toasts (Marty, 2026-09-12: keep the dashboard feeling alive): everyone else's joins,
|
||
// purchases, payouts, qualifications, tank arrivals and adoptions, in a second, quieter toast so
|
||
// they never replace a personal one. At most one every 4 s; a burst shows the latest.
|
||
let liveT = 0, liveQ = null;
|
||
function communityToast(msg) {
|
||
const now = Date.now();
|
||
if (now - liveT < 4000) { liveQ = msg; if (!communityToast._q) communityToast._q = setTimeout(() => { communityToast._q = null; const m = liveQ; liveQ = null; if (m) communityToast(m); }, 4200 - (now - liveT)); return; }
|
||
liveT = now;
|
||
let el = document.getElementById('liveToast');
|
||
if (!el) { el = document.createElement('div'); el.id = 'liveToast'; el.setAttribute('role', 'status'); el.style.cssText = 'position:fixed;left:16px;bottom:16px;z-index:90;max-width:min(360px,calc(100vw - 32px));background:var(--panel-solid);border:1px solid var(--line-strong);border-left:3px solid var(--mint);border-radius:12px;padding:10px 14px;font-size:14px;box-shadow:0 10px 30px rgba(0,0,0,.4);transition:opacity .3s'; document.body.appendChild(el); }
|
||
el.textContent = msg; el.hidden = false; el.style.opacity = '1';
|
||
clearTimeout(communityToast._t); communityToast._t = setTimeout(() => { el.style.opacity = '0'; setTimeout(() => { el.hidden = true; }, 350); }, 6000);
|
||
}
|
||
function handleLiveEvent(ev) {
|
||
if (!ev || !ev.type) return;
|
||
const nm = id => (ev.names && ev.names[id]) ? '@' + ev.names[id] : 'member #' + id;
|
||
// site-wide activity (not about me): joins, tank, adoptions, purchases, payouts, qualifications
|
||
if (ev.type === 'Joined') { communityToast('👋 ' + ev.name + ' just joined' + (ev.tank ? ' and is waiting for a sponsor in the holding tank' : '')); liveRefresh(); return; }
|
||
if (ev.type === 'Adopted') { communityToast('🤝 ' + ev.sponsor + ' picked up ' + ev.member + ' from the holding tank'); liveRefresh(); return; }
|
||
if (ev.type === 'Contest') { communityToast('🏆 ' + ev.winner + ' won the ' + (ev.kind === 'week' ? 'weekly' : 'monthly') + ' referral contest'); return; }
|
||
if (ev.type === 'Badge') { communityToast('🏆 ' + ev.member + ' unlocked ' + ev.label); return; }
|
||
if (ev.type === 'Released') { communityToast('🪣 ' + ev.sponsor + ' returned ' + ev.member + ' to the holding tank'); liveRefresh(); return; }
|
||
const mine = MYID && (ev.recipientId === MYID || ev.toId === MYID || ev.sponsorId === MYID || ev.skippedId === MYID || ev.buyerId === MYID);
|
||
if (!mine) {
|
||
if (ev.type === 'Purchase' && ev.buyerId) communityToast('🧾 ' + nm(ev.buyerId) + ' just bought a $' + Math.round((ev.priceCents || 0) / 100) + ' package');
|
||
else if (ev.type === 'TierPaid' && ev.recipientId) communityToast('💸 ' + nm(ev.recipientId) + ' just got paid ' + IAP.fmtPol(ev.amountWei) + ' POL');
|
||
else if (ev.type === 'BuyerCounted' && ev.sponsorId) communityToast('🎯 ' + nm(ev.sponsorId) + ' now has ' + ev.newCount + ' qualifying buyer' + (ev.newCount === 1 ? '' : 's'));
|
||
else if (ev.type === 'MemberActivated' && ev.id) communityToast('⚡ ' + nm(ev.id) + ' switched on payouts');
|
||
return;
|
||
}
|
||
if (!MYID) return;
|
||
let toast = null, kind = 'ok';
|
||
let sound = null;
|
||
if (ev.type === 'TierPaid' && ev.recipientId === MYID) { toast = '💸 You earned a level-' + ev.tier + ' payout of ' + IAP.fmtPol(ev.amountWei) + ' POL!'; sound = 'chaching'; }
|
||
else if (ev.type === 'AwardPaid' && ev.toId === MYID) { toast = '💸 You received ' + IAP.fmtPol(ev.amountWei) + ' POL!'; sound = 'chaching'; }
|
||
else if (ev.type === 'BuyerCounted' && ev.sponsorId === MYID) toast = '🎯 A referral just qualified — you now have ' + ev.newCount + ' qualifying buyer' + (ev.newCount === 1 ? '' : 's') + '!';
|
||
else if (ev.type === 'PassedUp' && ev.skippedId === MYID) { toast = '⚠️ A level-' + ev.tier + ' payout passed you by. Get qualified to catch these.'; kind = 'bad'; }
|
||
else if (ev.type === 'Purchase' && ev.buyerId === MYID) toast = '✅ Purchase settled on-chain — your credits are updated.';
|
||
else if (ev.type === 'MemberActivated' && ev.sponsorId === MYID) toast = '🤝 A new member just activated in your line!';
|
||
if (toast) { IAP.status(toast, kind); if (sound) playSound(sound); liveRefresh(); }
|
||
}
|
||
|
||
// training center: videos + materials (admin-curated via data/training.json)
|
||
async function loadTraining() {
|
||
try {
|
||
const r = await (await fetch('/api/training')).json();
|
||
const el = $('trainingList'); if (!el) return;
|
||
const items = r.items || [];
|
||
if (!items.length) { el.innerHTML = '<p class="muted small">Training materials are being added. Check back soon.</p>'; return; }
|
||
// section pills at the top: one per group, plus All; the active pill filters the list
|
||
const groups = [...new Set(items.map(it => it.group).filter(Boolean))];
|
||
const pills = $('trainingPills');
|
||
const want = loadTraining.filter || 'all';
|
||
if (pills) {
|
||
pills.hidden = groups.length < 2;
|
||
pills.innerHTML = ['all', ...groups].map(g => '<button type="button" class="chip-t' + ((g === want) ? ' on' : '') + '" data-tg="' + esc(g) + '">' + esc(g === 'all' ? 'All' : g) + '</button>').join('');
|
||
pills.querySelectorAll('[data-tg]').forEach(b => b.addEventListener('click', () => { loadTraining.filter = b.dataset.tg; loadTraining(); }));
|
||
}
|
||
const shown = want === 'all' ? items : items.filter(it => it.group === want);
|
||
let lastGroup = null;
|
||
el.innerHTML = shown.map(it => {
|
||
// optional section header: entries carry a `group`; a header renders when it changes
|
||
let head = '';
|
||
if (it.group && it.group !== lastGroup) { lastGroup = it.group; head = '<h3 class="tr-group" style="margin:26px 0 6px;font-size:13px;letter-spacing:.12em;text-transform:uppercase;color:var(--muted)">' + esc(it.group) + '</h3>'; }
|
||
const isVid = it.videoUrl && /\.(mp4|webm)(\?|$)/i.test(it.videoUrl);
|
||
// poster: the admin list can carry one; otherwise assume a .jpg next to the .mp4 (the pipeline uploads both)
|
||
const poster = it.posterUrl || (isVid ? it.videoUrl.replace(/\.(mp4|webm)(\?.*)?$/i, '.jpg$2') : '');
|
||
const media = isVid ? '<video src="' + esc(it.videoUrl) + '"' + (poster ? ' poster="' + esc(poster) + '"' : '') + ' preload="metadata" controls playsinline style="width:100%;max-width:640px;border-radius:12px;background:#000"></video>' : '';
|
||
const links = [];
|
||
if (it.videoUrl && !isVid) links.push('<a class="btn small sec" href="' + esc(it.videoUrl) + '" target="_blank" rel="noopener">Watch</a>');
|
||
if (it.docUrl) links.push('<a class="btn small sec" href="' + esc(it.docUrl) + '" target="_blank" rel="noopener">Open material</a>');
|
||
return head + '<div class="card"><h3>' + esc(it.title || 'Lesson') + '</h3>'
|
||
+ (it.desc ? '<p class="muted small">' + esc(it.desc) + '</p>' : '')
|
||
+ (media ? '<p style="margin:10px 0">' + media + '</p>' : '')
|
||
+ (links.length ? '<p>' + links.join(' ') + '</p>' : '') + '</div>';
|
||
}).join('');
|
||
} catch (e) {}
|
||
}
|
||
// visual line tree on the Overview: YOU + three levels, qualified directs in gold
|
||
async function loadLineTree(buyerCount) {
|
||
try {
|
||
const r = await (await fetch('/api/my/line')).json();
|
||
const el = $('lineTree'); if (!el) return;
|
||
const levels = r.levels || [];
|
||
const total = levels.reduce((n, L) => n + L.members.length, 0);
|
||
if ($('treeSub')) $('treeSub').textContent = total ? total + ' across 3 levels' : 'share your link to grow';
|
||
const chip = (m, q) => '<span class="lt-node' + (q ? ' qualified' : (m.email ? '' : ' deep')) + '" title="' + esc(m.name || 'member') + (q ? ' · bought a $20+ package (counts for their sponsor)' : ' · no $20+ buy yet') + (m.buyers ? ' · ' + m.buyers + ' qualifying buyer' + (m.buyers === 1 ? '' : 's') + ' of their own' : '') + '">' + esc(String(m.name || 'M').replace(/^@/, '').slice(0, m.own ? 20 : 14)) + (m.buyers ? '<i class="lt-n" title="their own qualifying buyers">' + m.buyers + '</i>' : '') + '</span>';
|
||
const rowFor = lvl => {
|
||
const L = levels.find(x => x.level === lvl); const members = L ? L.members : [];
|
||
// gold = the contract counted this member as one of your qualifying buyers (not "the first N chips")
|
||
let html = members.map(m => chip(m, !!(m.qualified || m.bought))).join(''); // gold on every level = made their $20+ buy
|
||
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) {}
|
||
}
|
||
// "What's new" card: latest notes + what is being built; a dot marks notes newer than the member's last look (Marty, 2026-09-14)
|
||
let newsLoaded = false;
|
||
async function loadNews() {
|
||
if (newsLoaded || !$('newsCard')) return; newsLoaded = true;
|
||
try {
|
||
const r = await (await fetch('/api/releases')).json();
|
||
if (!r.notes.length && !r.roadmap.length) return;
|
||
let seen = ''; try { seen = localStorage.getItem('iap.news.seen') || ''; } catch (e) {}
|
||
const esc = t => String(t == null ? '' : t).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
||
const notes = r.notes.slice(0, 3).map(n => '<p style="margin:0 0 8px">' + (n.date > seen ? '<span style="color:var(--mint)">●</span> ' : '') + '<b>' + esc(n.title) + '</b> <span class="muted">' + esc(n.date) + '</span></p>').join('');
|
||
const rm = r.roadmap.filter(x => x.status === 'building').slice(0, 2).map(x => '<p class="muted" style="margin:0 0 6px">Building: ' + esc(x.title) + (x.eta ? ' (' + esc(x.eta) + ')' : '') + '</p>').join('');
|
||
$('newsList').innerHTML = notes + rm + '<p style="margin:6px 0 0"><a href="/whats-new" target="_blank" rel="noopener" style="color:var(--mint)">All release notes and the roadmap →</a></p>';
|
||
$('newsCard').hidden = false;
|
||
$('newsCard').addEventListener('click', () => { try { localStorage.setItem('iap.news.seen', r.latest || ''); } catch (e) {} $('newsList').querySelectorAll('span[style*="mint"]').forEach(s => { if (s.textContent === '●') s.remove(); }); }, { once: true });
|
||
} catch (e) {}
|
||
}
|
||
// Leaderboard card: top 5 this week + your own rank + the prize (Marty, 2026-09-14)
|
||
async function loadLeaderboard() {
|
||
if (!$('lbCard')) return;
|
||
try {
|
||
const r = await (await fetch('/api/leaderboard?period=week')).json();
|
||
const esc = t => String(t == null ? '' : t).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
||
let h = r.prize ? '<p style="margin:0 0 8px"><b>Prize this week:</b> ' + esc(r.prize) + '</p>' : '';
|
||
h += r.top.length ? '<table style="width:100%;border-collapse:collapse;font-variant-numeric:tabular-nums">' + r.top.slice(0, 5).map(x => '<tr><td style="padding:3px 0;width:26px;color:' + (x.rank === 1 ? '#ffd15c' : 'var(--muted)') + '">' + x.rank + '</td><td style="padding:3px 0"><b>' + esc(x.name) + '</b></td><td style="padding:3px 0;text-align:right">' + x.sales + ' sold · $' + x.usd + '</td></tr>').join('') + '</table>'
|
||
: '<p class="muted" style="margin:0">No packages sold yet this week. First sale takes the top spot.</p>';
|
||
h += r.me ? '<p style="margin:8px 0 0">You: <b>#' + r.me.rank + '</b> · ' + r.me.sales + ' sold · $' + r.me.usd + '</p>' : '<p class="muted" style="margin:8px 0 0">You: no sales yet this week. Sales are $20+ packages bought by people you sponsor.</p>';
|
||
h += '<p style="margin:6px 0 0"><a href="/leaderboard" target="_blank" rel="noopener" style="color:var(--mint)">Full leaderboard: week, month, all time →</a></p>';
|
||
$('lbList').innerHTML = h; $('lbCard').hidden = false;
|
||
} catch (e) {}
|
||
}
|
||
// Getting-started stepper: username -> wallet -> payouts -> first package. The current step gets one big
|
||
// button that opens the right tab and pulses the control (Jim: "it wasn't obvious", 2026-09-14)
|
||
function renderSteps(d) {
|
||
const card = $('gsCard'); if (!card) return;
|
||
const hasUser = !!d.username, hasWallet = !!d.address, hasPayouts = !!d.memberId, hasPackage = !!(d.memberId && (d.credits || 0) > 0);
|
||
const steps = [
|
||
{ k: 'user', t: 'Pick a username', done: hasUser },
|
||
{ k: 'wallet', t: 'Link your wallet', done: hasWallet },
|
||
{ k: 'payouts', t: 'Switch on payouts', done: hasPayouts },
|
||
{ k: 'package', t: 'Your first package', done: hasPackage }
|
||
];
|
||
if (steps.every(x => x.done)) { card.hidden = true; return; }
|
||
const cur = steps.find(x => !x.done);
|
||
let collapsed = false; try { collapsed = localStorage.getItem('iap.gs.collapsed') === '1'; } catch (e) {}
|
||
$('gsSub').innerHTML = steps.filter(x => x.done).length + ' of 4 done · <a href="#" id="gsToggle" style="color:var(--mint);text-decoration:none">' + (collapsed ? 'show' : 'hide') + '</a>';
|
||
$('gsToggle').addEventListener('click', e => { e.preventDefault(); try { localStorage.setItem('iap.gs.collapsed', collapsed ? '0' : '1'); } catch (er) {} renderSteps(d); });
|
||
$('gsSteps').hidden = collapsed; $('gsNow').hidden = collapsed;
|
||
$('gsSteps').innerHTML = steps.map((x, i) => '<div class="gs-step' + (x.done ? ' done' : x === cur ? ' now' : '') + '"><span class="n">' + (x.done ? '✓' : i + 1) + '</span><span>' + x.t + '</span></div>').join('');
|
||
const copy = {
|
||
user: ['Pick your username', 'It becomes your invite link and your public page, and it is permanent.', 'Choose a username', () => showOnboard(true)],
|
||
wallet: ['Link your wallet', 'Your payouts land in a wallet you control, so the site needs to know which one is yours. One free signature, no purchase. MetaMask recommended; Phantom users switch Polygon on first. Never held crypto? The wallet guide walks you through it.', 'Link my wallet', () => jumpTo('wallet', 'linkBtn')],
|
||
payouts: ['Switch on payouts', 'One small transaction registers your wallet with the contract so commissions can reach it. Buying any package does this automatically.', 'Activate payouts', () => jumpTo('wallet', 'activateBtn')],
|
||
package: ['Your first package', 'From $5. The $20 Activation package is the one that counts you as a qualifying buyer for your sponsor and unlocks adopting from the holding tank.', 'See packages', () => jumpTo('buy', null)]
|
||
}[cur.k];
|
||
$('gsNow').innerHTML = '<p><b>Next: ' + copy[0] + '</b>' + copy[1] + (cur.k === 'wallet' ? ' <a href="/wallets" target="_blank" rel="noopener" style="color:var(--mint)">Wallet guide</a>' : '') + '</p><button type="button" class="btn" id="gsGo">' + copy[2] + '</button>';
|
||
$('gsGo').addEventListener('click', copy[3]);
|
||
card.hidden = false;
|
||
}
|
||
function jumpTo(pane, id) {
|
||
setPane(pane);
|
||
setTimeout(() => { const el = id && $(id); if (!el) return; el.scrollIntoView({ behavior: 'smooth', block: 'center' }); el.classList.remove('pulse'); void el.offsetWidth; el.classList.add('pulse'); try { el.focus({ preventScroll: true }); } catch (e) {} }, 250);
|
||
}
|
||
async function loadDashboard() {
|
||
try {
|
||
loadNews(); loadLeaderboard();
|
||
const d = await (await fetch('/api/my/dashboard')).json();
|
||
if (d.error) return;
|
||
chatSync(d);
|
||
try { renderSteps(d); } catch (e) {}
|
||
MYID = d.memberId || MYID;
|
||
startLiveFeed();
|
||
if (!window.__lbChecked) { // daily login bonus, once per session (server guards once/day)
|
||
window.__lbChecked = true;
|
||
api('/api/my/login-bonus').then(r => {
|
||
if (r && r.granted > 0) {
|
||
IAP.status('🎁 Daily login bonus: +' + r.granted + ' credits' + (r.streak > 1 ? ' · ' + r.streak + '-day streak' : '') + '!', 'ok');
|
||
playSound('chaching'); setTimeout(loadDashboard, 900);
|
||
}
|
||
}).catch(() => {});
|
||
}
|
||
const earnedAv = d.earnedCredits || 0, inCamp = d.inCampaigns || 0;
|
||
$('dbCredits').textContent = ((d.credits || 0) + earnedAv).toLocaleString();
|
||
$('dbCreditsSub').textContent = (d.credits || 0).toLocaleString() + ' purchased' + (d.creditedCredits ? ' (' + d.creditedCredits.toLocaleString() + ' credited to you)' : '') + ' · ' + earnedAv.toLocaleString() + ' earned' + (inCamp ? ' · ' + inCamp.toLocaleString() + ' in campaigns' : '');
|
||
$('dbEarned').textContent = IAP.fmtPol(d.earnedWei || '0');
|
||
$('dbBuyers').textContent = d.buyerCount || 0; if (d.buyerCountPositions) { const sub = $('dbBuyersSub'); if (sub) sub.textContent = '+' + d.buyerCountPositions + ' on your linked positions (count toward badges; levels pay on your main position)'; }
|
||
$('dbTeam').textContent = (d.referrals || []).length;
|
||
// trend chips: only real, computable facts
|
||
const ec = $('dbEarnedChip');
|
||
if (ec && d.earnCount) { ec.hidden = false; ec.textContent = d.earnCount + ' instant payout' + (d.earnCount > 1 ? 's' : ''); }
|
||
const bc = $('dbBuyersChip');
|
||
if (bc && d.memberId) {
|
||
bc.hidden = false;
|
||
bc.textContent = (d.buyerCount || 0) >= 5 ? 'level 3 open' : (d.buyerCount || 0) >= 2 ? 'level 2 open' : 'level 2 at 2 buyers';
|
||
}
|
||
const tc = $('dbTeamChip'), wk = (d.referrals || []).filter(r => Date.now() - new Date(r.joined) < 6048e5).length;
|
||
if (tc && wk) { tc.hidden = false; tc.textContent = '+' + wk + ' this week'; }
|
||
setInboxBadge(d.inboxUnread || 0);
|
||
if (d.sponsorMsg) showSponsorModal(d.sponsorMsg);
|
||
renderBadges(d);
|
||
loadFeatured();
|
||
loadLineTree(d.buyerCount || 0);
|
||
loadCharts(d);
|
||
$('nextMove').textContent = nextMove(d);
|
||
renderSteps(d);
|
||
const wrap = $('rosterWrap');
|
||
if ((d.referrals || []).length) {
|
||
$('rosterEmpty').hidden = true;
|
||
let t = wrap.querySelector('table');
|
||
if (t) t.remove();
|
||
t = document.createElement('table');
|
||
t.className = 'roster';
|
||
t.innerHTML = d.referrals.map(r => '<tr><td>' + String(r.name || r.email || '').replace(/[&<>]/g, '') + '</td>'
|
||
+ '<td>' + new Date(r.joined).toLocaleDateString() + '</td>'
|
||
+ '<td><span class="badge' + (r.status === 'joined free' ? ' amber' : '') + '">' + r.status + '</span></td></tr>').join('');
|
||
wrap.appendChild(t);
|
||
}
|
||
// overview recent-activity widget: chain events + line joins, newest first
|
||
try {
|
||
const c = await IAP.getConfig();
|
||
const ov = $('ovFeed');
|
||
const rows = [];
|
||
if (d.memberId) {
|
||
const a = await (await fetch('/api/my/activity')).json();
|
||
for (const ev of [...(a.earnings || []), ...(a.purchases || [])]
|
||
.sort((x, y) => y.block - x.block).slice(0, 5)) rows.push(IAP.feedRow(ev, c));
|
||
}
|
||
for (const r of (d.referrals || []).slice(0, 3)) {
|
||
const div = document.createElement('div');
|
||
div.className = 'row';
|
||
div.innerHTML = '<span>🤝 ' + esc(r.name || r.email || 'A new member') + ' joined your line</span><span class="tx muted small">'
|
||
+ new Date(r.joined).toLocaleDateString() + '</span>';
|
||
rows.push(div);
|
||
}
|
||
if (rows.length) {
|
||
ov.innerHTML = '';
|
||
rows.slice(0, 6).forEach(r => ov.appendChild(r));
|
||
}
|
||
} catch (e) {}
|
||
// ready-to-send share message + promo tools, personalized
|
||
const link = location.origin + '/join/' + (d.username || d.refCode || d.memberId || '');
|
||
fillPromo(link, d);
|
||
// founding-week readiness: shown until every item is done (or the launch moment is a week past)
|
||
try {
|
||
const cfg = await IAP.getConfig(); const items = IAP.launchChecks(d); const done = items.filter(i => i.done).length;
|
||
const at = cfg.launchAt ? new Date(cfg.launchAt).getTime() : 0;
|
||
const show = done < items.length && !(at && Date.now() > at + 7 * 86400000);
|
||
const lm = $('launchMark');
|
||
if (lm) { lm.hidden = !show; lm.innerHTML = show ? '<b>Launch ready: ' + done + ' of ' + items.length + '.</b> ' + (at && Date.now() < at ? 'Doors open ' + new Date(at).toLocaleString([], { weekday: 'short', hour: 'numeric', minute: '2-digit' }) + '. ' : '') + '<a href="/launch">Open the founding-week checklist</a>' : ''; }
|
||
} catch (e) {}
|
||
// people waiting for a sponsor in the holding tank (Marty, 2026-09-12): every Overview sees it
|
||
try {
|
||
const tw = d.tankWaiting, tn = $('tankNotice');
|
||
if (tn) {
|
||
tn.hidden = !(tw && tw.count);
|
||
if (tw && tw.count) tn.innerHTML = '<b>' + tw.count + (tw.count === 1 ? ' person is' : ' people are') + ' waiting for a sponsor in the holding tank</b> \u00b7 '
|
||
+ tw.names.map(esc).join(', ') + (tw.count > tw.names.length ? ' and more' : '') + '. <a href="#line">Adopt them from My line</a>'
|
||
+ (tw.eligible ? '.' : ' (you need your own $20 package first).');
|
||
}
|
||
} catch (e) {}
|
||
if (d.username) { // wall link rides the username
|
||
const wl = location.origin + '/wall/' + d.username;
|
||
$('wallLine').textContent = wl;
|
||
if ($('promoWallStrip')) { // the same wall link at the top of Promo tools, next to the invite link
|
||
$('promoWallStrip').hidden = false; $('promoWallLink').textContent = wl; $('promoWallOpen').href = '/wall/' + d.username;
|
||
$('promoWallCopy').onclick = async () => { try { await navigator.clipboard.writeText(wl); IAP.status('Wall link copied.', 'ok'); } catch (e) { IAP.status('Copy failed. Select the link and copy it.', 'bad'); } };
|
||
}
|
||
$('wallCopy').hidden = false;
|
||
$('wallOpen').hidden = false;
|
||
$('wallOpen').href = '/wall/' + d.username;
|
||
$('wallCopy').onclick = async () => {
|
||
try { await navigator.clipboard.writeText(wl); IAP.status('Wall link copied.', 'ok'); }
|
||
catch (e) { IAP.status('Copy failed. Select the link text instead.', 'bad'); }
|
||
};
|
||
}
|
||
if (d.refCode || d.memberId) {
|
||
const pitch = 'I found an advertising site that pays referrals instantly to your own wallet. '
|
||
+ 'No withdrawals, no waiting, and every payment is public on a blockchain ledger you can check yourself. '
|
||
+ 'Free to join and look around: ' + link;
|
||
$('copyPitch').hidden = false;
|
||
$('pitchPreview').hidden = false;
|
||
$('pitchPreview').textContent = '"' + pitch + '"';
|
||
$('copyPitch').onclick = async () => {
|
||
try { await navigator.clipboard.writeText(pitch); IAP.status('Message copied. Paste it anywhere.', 'ok'); }
|
||
catch (e) { IAP.status('Copy failed. Select the preview text instead.', 'bad'); }
|
||
};
|
||
}
|
||
} catch (e) {}
|
||
}
|
||
|
||
// ── back-office menu: hash-routed panes ───────────────
|
||
const PANES = ['overview', 'line', 'pipeline', 'buy', 'campaigns', 'earn', 'earnings', 'promo', 'training', 'wallet', 'profile'];
|
||
const TITLES = { overview: 'Overview', line: 'My line', pipeline: 'Pipeline', buy: 'Buy packages', campaigns: 'Campaigns',
|
||
earn: 'Earn credits', earnings: 'Earnings', promo: 'Promo tools', training: 'Training',
|
||
wallet: 'Wallet & account', profile: 'Profile' };
|
||
function setPane(name) {
|
||
if (!PANES.includes(name)) name = 'overview';
|
||
for (const p of PANES) {
|
||
const el = $('pane-' + p);
|
||
if (el) el.hidden = p !== name;
|
||
}
|
||
document.querySelectorAll('.bo-menu [data-pane]').forEach(b =>
|
||
b.classList.toggle('on', b.dataset.pane === name));
|
||
if ($('boTitle')) $('boTitle').textContent = TITLES[name];
|
||
// member ads: a fresh text ad in the strip under the title, and a banner at the foot of the pane
|
||
IAP.adSlot('text', 'adStripTop');
|
||
if ($('adSlotPane-' + name)) IAP.adSlot('banner', 'adSlotPane-' + name);
|
||
if (name === 'earn') setEarnSub(earnSub); // refresh whichever sub-tab is active
|
||
else if (vidState.token) stopVideo();
|
||
if (name === 'profile') loadLineBanner();
|
||
if (name === 'line') { loadLineage(); loadUplineMessages(); loadCoach(); loadLinkStats(); loadProspects(); }
|
||
if (name === 'campaigns') ['cTarget', 'cImage', 'cVideoUrl'].forEach(id => { if ($(id)) $(id).value = ''; }); // no residual URL between visits
|
||
if (name === 'training') loadTraining();
|
||
if (name === 'pipeline') loadPipeline();
|
||
document.getElementById('memberArea').classList.remove('side-open'); // close mobile drawer
|
||
if (location.hash !== '#' + name) history.replaceState(null, '', '#' + name);
|
||
}
|
||
document.querySelectorAll('.bo-menu [data-pane]').forEach(b =>
|
||
b.addEventListener('click', () => setPane(b.dataset.pane)));
|
||
document.querySelectorAll('.qa [data-goto]').forEach(b =>
|
||
b.addEventListener('click', () => setPane(b.dataset.goto)));
|
||
const qaCopy = document.getElementById('qaCopyInvite');
|
||
if (qaCopy) qaCopy.addEventListener('click', async () => {
|
||
const link = document.getElementById('inviteLine').textContent;
|
||
if (!link || !link.startsWith('http')) { setPane('line'); return; }
|
||
try { await navigator.clipboard.writeText(link); IAP.status('Invite link copied.', 'ok'); }
|
||
catch (e) { setPane('line'); }
|
||
});
|
||
window.addEventListener('hashchange', () => setPane(location.hash.slice(1)));
|
||
if ($('boBurger')) $('boBurger').addEventListener('click', () =>
|
||
document.getElementById('memberArea').classList.toggle('side-open'));
|
||
|
||
async function render() {
|
||
// while /api/me answers, show a spinner instead of flashing the sign-in card at a signed-in member
|
||
let me = null;
|
||
try { me = await IAP.refreshNavWallet(); } catch (e) { me = null; }
|
||
if ($('bootSpin')) $('bootSpin').hidden = true;
|
||
// one way in: email. A wallet-only session (no account) is sent back to the
|
||
// email card with a finish-setup note; verifying the code links that wallet.
|
||
const walletOnly = !!(me && me.signedIn && !me.email);
|
||
const signedIn = me && me.signedIn && !walletOnly;
|
||
$('authArea').hidden = !!signedIn;
|
||
$('memberArea').hidden = !signedIn;
|
||
if ($('mcFinish')) $('mcFinish').hidden = !walletOnly;
|
||
if (!signedIn) return;
|
||
if ($('adminLink')) $('adminLink').hidden = !me.isAdmin; // admin portal link, only for ADMIN_EMAIL
|
||
// REQUIRED first step (Marty, 2026-09-12): no username, nothing else. The modal cannot be
|
||
// skipped or dismissed; it resolves only when a username is saved, then the area renders.
|
||
if (!me.username) {
|
||
if (!render.gating) { render.gating = true; showOnboard(true).then(() => { render.gating = false; render(); }); }
|
||
return;
|
||
}
|
||
// arrived from an invite page: run the welcome tour and login ad once
|
||
if (/[?&]welcome=1/.test(location.search) && !render.welcomed) {
|
||
render.welcomed = true;
|
||
history.replaceState(null, '', '/my' + (location.hash || ''));
|
||
(async () => {
|
||
try { if (!(await showGauntlet())) await showLoginAd(); } catch (e) {}
|
||
await render();
|
||
})();
|
||
}
|
||
setPane(location.hash.slice(1) || 'overview');
|
||
$('campGate').hidden = !!me.memberId;
|
||
$('earnGate').hidden = !!me.memberId;
|
||
if (me.username && !me.address) { let shown = ''; try { shown = sessionStorage.getItem('iap.gs.walletHint'); sessionStorage.setItem('iap.gs.walletHint', '1'); } catch (e) {} if (shown !== '1') setTimeout(() => IAP.status('Next step when you are ready: link your wallet so your payouts have somewhere to land. The Getting started card at the top shows how.', 'ok'), 1200); }
|
||
loadDashboard();
|
||
|
||
const who = [];
|
||
if (me.email) who.push(me.email);
|
||
// members kept asking whether the wallet was really connected (Marty, 2026-09-12): say it, with a green check
|
||
if (me.address) who.push('<span style="display:inline-flex;align-items:center;gap:8px"><span aria-hidden="true" style="display:inline-flex;align-items:center;justify-content:center;width:20px;height:20px;border-radius:50%;background:#22c55e;color:#04140a;font-weight:900;font-size:13px;line-height:1">✓</span><b style="color:#22c55e">Wallet connected</b> <span class="mono">' + me.address.slice(0, 8) + '…' + me.address.slice(-6) + '</span></span>');
|
||
else who.push('<span style="display:inline-flex;align-items:center;gap:8px"><span aria-hidden="true" style="display:inline-flex;align-items:center;justify-content:center;width:20px;height:20px;border-radius:50%;border:2px solid var(--muted);color:var(--muted);font-weight:900;font-size:12px;line-height:1">!</span><b>No wallet connected yet</b> <span class="muted">(link one below)</span></span>');
|
||
if (me.memberId) who.push('on-chain <b>member #' + me.memberId + '</b>'
|
||
+ (me.onchainSponsorId ? ', sponsored by #' + me.onchainSponsorId : ''));
|
||
else if (me.sponsorId) who.push('invited by member #' + me.sponsorId);
|
||
$('posLine').innerHTML = who.join('<br>');
|
||
|
||
$('creditLine').textContent = (me.credits || 0).toLocaleString();
|
||
loadPositions(me);
|
||
$('linkCard').hidden = !!me.address;
|
||
$('activateCard').hidden = !(me.address && !me.memberId);
|
||
$('activityArea').hidden = !me.memberId;
|
||
$('campGate').hidden = true; // earned credits fund campaigns for everyone
|
||
$('campaignCard').hidden = false;
|
||
loadCampaigns();
|
||
|
||
// profile pane state
|
||
$('pfCurrent').textContent = me.username ? '@' + me.username + ' is your permanent username. Your invite link, your public page and any banners you shared carry it, so it cannot be changed.' : 'No username yet. Members see you as a number until you pick one. Choose carefully: it is permanent once saved.';
|
||
if (!$('pfUsername').value) $('pfUsername').value = me.username || '';
|
||
$('pfUsername').disabled = !!me.username; $('pfSaveBtn').hidden = !!me.username;
|
||
$('pfDetails').innerHTML = 'Email: ' + (me.email || 'none') + '<br>Wallet: '
|
||
+ (me.address ? '<span class="mono">' + me.address.slice(0, 10) + '…' + me.address.slice(-6) + '</span>' : 'not linked yet')
|
||
+ '<br>On-chain member: ' + (me.memberId ? '#' + me.memberId : 'not yet');
|
||
// the share link works from day one; usernames make it a vanity link
|
||
if (me.username || me.refCode || me.memberId) {
|
||
$('inviteLine').textContent = location.origin + '/join/' + (me.username || me.refCode || me.memberId);
|
||
$('copyInvite').hidden = false;
|
||
} else {
|
||
$('inviteLine').textContent = 'Sign in with your email to get your link.';
|
||
$('copyInvite').hidden = true;
|
||
}
|
||
if (me.memberId) {
|
||
const bc = me.buyerCount || 0;
|
||
$('qualLine').innerHTML = '<b>' + bc + '</b> qualifying buyer(s) referred<br>'
|
||
+ (bc >= 5 ? '<span class="badge">Level 3 unlocked: full three-level earnings</span>'
|
||
: bc >= 2 ? '<span class="badge">Level 2 unlocked</span> · ' + (5 - bc) + ' more for level 3'
|
||
: (2 - bc) + ' more buyer(s) of $20+ unlock level 2');
|
||
loadActivity();
|
||
} else {
|
||
$('qualLine').innerHTML = 'Share your link now. Then switch on payouts (free, above) '
|
||
+ '<b>before your people start buying</b>: the contract locks each buyer to their sponsor at '
|
||
+ 'their first purchase, and payments only route to wallets that are switched on.';
|
||
}
|
||
}
|
||
|
||
async function loadActivity() {
|
||
try {
|
||
const c = await IAP.getConfig();
|
||
const a = await (await fetch('/api/my/activity')).json();
|
||
const fill = (id, evs, empty) => {
|
||
const el = $(id);
|
||
el.innerHTML = '';
|
||
if (!evs || !evs.length) { el.innerHTML = '<div class="row muted">' + empty + '</div>'; return; }
|
||
for (const ev of evs) el.appendChild(IAP.feedRow(ev, c));
|
||
};
|
||
fill('earnFeed', a.earnings, 'No payouts yet. They appear here the moment one lands.');
|
||
fill('refFeed', a.referrals, 'No referral activity yet. Share your invite link.');
|
||
fill('buyFeed', a.purchases, 'No purchases from your wallet yet.');
|
||
} catch (e) {}
|
||
}
|
||
|
||
async function loadCampaigns() {
|
||
try {
|
||
const r = await (await fetch('/api/my/campaigns')).json();
|
||
if (r.error) return;
|
||
lastRates = r.rates;
|
||
if ($('cGeoHint') && r.tiers) $('cGeoHint').textContent = 'All three ticked = everyone. Tier 1: ' + r.tiers.t1.join(', ') + '. Tier 2: ' + r.tiers.t2.join(', ') + '. Tier 3: every other country. Geo applies to delivery on this site; a narrowed banner or text ad is kept off the worldwide partner network. ' + (r.geoReady ? '' : 'Country data is still loading, so narrowed campaigns pause until it is ready. ') + 'IP geolocation by DB-IP.';
|
||
// populate the banner-size dropdown once (ids map to NAS width/height)
|
||
if (r.bannerSizes && $('cSize') && !$('cSize').options.length)
|
||
$('cSize').innerHTML = r.bannerSizes.map(s => '<option value="' + s.id + '">' + s.label + '</option>').join('');
|
||
applyType(); // the default type is banner: show its size + image rows now that the sizes exist
|
||
soloHint();
|
||
if ($('spendBanner')) {
|
||
$('spendBanner').hidden = false;
|
||
$('spendBig').textContent = r.availableCredits.toLocaleString();
|
||
$('spendSub').textContent = '= $' + (r.availableCredits / 100).toLocaleString(undefined, { maximumFractionDigits: 2 }) + ' of ad delivery · ' + r.purchasedCredits.toLocaleString() + ' purchased' + (r.creditedCredits ? ' (' + r.creditedCredits.toLocaleString() + ' of it credited to you, spends on anything)' : '') + ' + ' + (r.earnedCredits || 0).toLocaleString() + ' earned' + (r.positionCount > 1 ? ' · pooled across ' + r.positionCount + ' positions (largest single position ' + (r.largestPosition || 0).toLocaleString() + ')' : '');
|
||
if ($('spendNote')) $('spendNote').textContent = r.inCampaigns
|
||
? 'Not counting ' + r.inCampaigns.toLocaleString() + ' credits already set aside for your live campaigns. That budget spends down inside each campaign below. This number only moves when you start, top up or pause a campaign.'
|
||
: 'This is what is not committed to a campaign. When you start one, its budget moves out of here and spends down inside the campaign.';
|
||
$('spendRates').innerHTML = [['Banner', r.rates.bannerCreditsPerBatch + ' cr / ' + r.rates.bannerBatch + ' views'], ['Text', r.rates.textCreditsPerBatch + ' cr / ' + r.rates.textBatch + ' views'], ['Login', r.rates.loginCreditsPerDay + ' cr / day'], ['Solo', r.rates.soloCostPerRecipient + ' cr / delivery'], ['Featured', (r.rates.featuredPerDay || 40) + ' cr / day'], ['Visit', (r.rates.visitCostPerVisit || 3) + ' cr / visit']].map(x => '<span><b>' + x[0] + '</b> ' + x[1] + '</span>').join('');
|
||
}
|
||
$('rateLine').textContent = 'Available to spend: ' + r.availableCredits.toLocaleString()
|
||
+ (r.earnedCredits ? ' (' + r.purchasedCredits.toLocaleString() + ' purchased + ' + r.earnedCredits + ' earned)' : '')
|
||
+ (r.inCampaigns ? ' · ' + r.inCampaigns.toLocaleString() + ' set aside in live campaigns' : '')
|
||
+ ' credits · rates: banner ' + r.rates.bannerCreditsPerBatch + 'cr/' + r.rates.bannerBatch
|
||
+ ' views, text ' + r.rates.textCreditsPerBatch + 'cr/' + r.rates.textBatch
|
||
+ ' views, login ' + r.rates.loginCreditsPerDay + 'cr/day, solo '
|
||
+ (r.rates.soloCostPerRecipient || 5) + 'cr/delivery';
|
||
const el = $('campList');
|
||
el.innerHTML = '';
|
||
if (!r.campaigns.length) { el.innerHTML = '<p class="muted small">No campaigns yet. Launch your first below.</p>'; return; }
|
||
const tbl = document.createElement('div');
|
||
tbl.className = 'tablewrap';
|
||
tbl.innerHTML = '<table class="camp-table"><thead><tr><th>Name</th><th>Type</th><th class="num">Views here</th><th class="num" title="Impressions delivered by Network Ad Space across the wider network (banner and text ads only)">Network views</th><th class="num">Clicks</th>'
|
||
+ '<th class="num">Spent</th><th class="num">Budget</th><th>Status</th><th></th></tr></thead><tbody>'
|
||
+ r.campaigns.map(c => '<tr><td data-l="Campaign"><b>' + c.name + '</b>' + hourBars(r.hours && r.hours[c.id]) + geoLine(r.geo && r.geo[c.id]) + '</td>'
|
||
+ '<td data-l="Type">' + c.type + (c.type === 'banner' && c.width ? ' <span class="muted small">' + c.width + '×' + c.height + '</span>' : '') + (c.dailyCap ? ' <span class="muted small" title="daily cap: ' + c.dailyCap + ' credits, ' + (c.daySpent || 0) + ' spent today">cap ' + c.dailyCap + '/day</span>' : '') + (c.geo ? ' <span class="muted small" title="shown only to viewers in these country tiers">tier ' + esc(c.geo.replace(/,/g, '+')) + '</span>' : '') + schedChips(c) + '</td>'
|
||
+ '<td class="num" data-l="Views here">' + c.imps.toLocaleString() + '</td>'
|
||
+ '<td class="num" data-l="Network views">' + (['banner', 'text'].includes(c.type) ? (c.impsNas || 0).toLocaleString() : '<span class="muted small" title="only banner and text ads syndicate to the network">n/a</span>') + '</td>'
|
||
+ '<td class="num" data-l="Clicks">' + c.clicks + (r.clickSources && r.clickSources[c.id] ? '<div class="muted small clk-src" title="where the clicks happened">' + Object.entries(r.clickSources[c.id]).sort((a, b) => b[1] - a[1]).map(([k, v]) => '<span>' + esc(k) + ' ' + v + '</span>').join('') + (c.impsNas ? ' · network: see Network views' : '') + '</div>' : '') + '</td>'
|
||
+ (c.type === 'visits' ? '<td class="num" data-l="Spent"><span class="muted small" title="visit packs are paid in full when created; nothing more is charged as visits arrive">paid up front</span></td><td class="num" data-l="Delivered"><b>' + c.imps.toLocaleString() + '</b> of ' + Number(c.body || 0).toLocaleString() + ' visits</td>'
|
||
: '<td class="num" data-l="Spent">' + c.spent + '</td><td class="num" data-l="Budget">' + c.budget + '</td>')
|
||
+ '<td data-l="Status">' + (c.status === 'out' ? (c.type === 'visits' ? '<span class="badge">pack delivered</span>' : '<span class="badge amber">budget spent</span>') : c.status === 'done' ? '<span class="muted">ended</span>' : c.scheduled ? '<span class="badge">scheduled</span>' : c.status) + '</td>'
|
||
+ '<td class="act">' + (c.status === 'active' ? '<button class="btn small sec" data-camp="' + c.id + '" data-act="pause">Pause</button>'
|
||
: c.status === 'paused' ? '<button class="btn small sec" data-camp="' + c.id + '" data-act="resume">Resume</button>' : '')
|
||
+ ' <button class="btn small sec" data-topup="' + c.id + '">Buy more views</button>'
|
||
+ '</td></tr>').join('') + '</tbody></table>';
|
||
el.appendChild(tbl);
|
||
el.querySelectorAll('button[data-camp]').forEach(b => b.addEventListener('click', async () => {
|
||
try { await api('/api/my/campaigns/' + b.dataset.camp + '/' + b.dataset.act); await loadCampaigns(); }
|
||
catch (e) { IAP.status(e.message, 'bad'); }
|
||
}));
|
||
el.querySelectorAll('button[data-topup]').forEach(b => b.addEventListener('click', async () => {
|
||
const n = await IAP.ask({ title: 'Add credits', text: 'How many credits to add to this campaign? More credits buy more views.', type: 'number', placeholder: 'e.g. 100', ok: 'Add credits' });
|
||
if (!n) return;
|
||
try { const r = await api('/api/my/campaigns/' + b.dataset.topup + '/topup', { credits: Number(n) });
|
||
IAP.status('Added ' + r.added + ' credits' + (r.reactivated ? ' — campaign is live again.' : '.'), 'ok');
|
||
await loadCampaigns(); loadDashboard();
|
||
} catch (e) { IAP.status(e.message, 'bad'); }
|
||
}));
|
||
} catch (e) {}
|
||
}
|
||
let lastRates = null;
|
||
function soloHint() {
|
||
if (!lastRates || $('cType').value !== 'solo') return;
|
||
const cost = lastRates.soloCostPerRecipient || 5;
|
||
const n = Math.floor((Number($('cBudget').value) || 0) / cost);
|
||
$('cSoloHint').textContent = cost + ' credits per guaranteed inbox delivery'
|
||
+ (n ? ' — this budget reaches ' + n + ' members' : '')
|
||
+ '. Readers earn ' + (lastRates.soloReadCredits || 2) + ' credits for a real read, so your message gets opened.';
|
||
}
|
||
$('cBudget').addEventListener('input', soloHint);
|
||
// rich solo editor: small toolbar over contenteditable (CSP allows no external editor);
|
||
// the server whitelist-sanitizes whatever HTML arrives, this is just authoring comfort
|
||
document.querySelectorAll('.ed-bar [data-cmd]').forEach(btn =>
|
||
btn.addEventListener('click', () => { $('cSoloEd').focus(); document.execCommand(btn.dataset.cmd, false, null); }));
|
||
document.querySelectorAll('.ed-bar [data-block]').forEach(btn =>
|
||
btn.addEventListener('click', () => { $('cSoloEd').focus(); document.execCommand('formatBlock', false, btn.dataset.block); }));
|
||
$('edLinkBtn').addEventListener('click', async () => {
|
||
const sel = window.getSelection(); const range = sel && sel.rangeCount ? sel.getRangeAt(0).cloneRange() : null; // the dialog steals the selection
|
||
const url = await IAP.ask({ title: 'Insert link', text: 'Link URL (https://…)', placeholder: 'https://', ok: 'Insert' });
|
||
if (!url) return;
|
||
$('cSoloEd').focus(); if (range) { sel.removeAllRanges(); sel.addRange(range); }
|
||
document.execCommand('createLink', false, url);
|
||
});
|
||
// inline media: upload, then drop the element at the cursor (BV-style)
|
||
let mediaMode = 'image';
|
||
function insertHtmlAtCursor(html) {
|
||
const ed = $('cSoloEd');
|
||
ed.focus();
|
||
if (!document.execCommand('insertHTML', false, html)) ed.insertAdjacentHTML('beforeend', html);
|
||
}
|
||
$('edImgBtn').addEventListener('click', () => { mediaMode = 'image'; $('cSoloFile').accept = 'image/png,image/jpeg,image/webp,image/gif'; $('cSoloFile').click(); });
|
||
$('edVidBtn').addEventListener('click', () => { mediaMode = 'video'; $('cSoloFile').accept = 'video/mp4,video/webm'; $('cSoloFile').click(); });
|
||
$('cSoloFile').addEventListener('change', async () => {
|
||
const f = $('cSoloFile').files[0];
|
||
if (!f) return;
|
||
$('edMediaInfo').textContent = 'Uploading ' + f.name + '…';
|
||
try {
|
||
const r = await (await fetch('/api/my/upload', { method: 'POST', headers: { 'Content-Type': f.type }, body: f })).json();
|
||
if (r.error) { $('edMediaInfo').textContent = r.error; $('cSoloFile').value = ''; return; }
|
||
insertHtmlAtCursor(r.type === 'video'
|
||
? '<video src="' + r.url + '" controls playsinline></video><p><br></p>'
|
||
: '<img src="' + r.url + '" alt=""><p><br></p>');
|
||
$('edMediaInfo').textContent = f.name + ' inserted';
|
||
} catch (e) { $('edMediaInfo').textContent = 'Upload failed. Try again.'; }
|
||
$('cSoloFile').value = '';
|
||
});
|
||
// raw-text toggle: swap the WYSIWYG surface for the underlying HTML and back
|
||
$('edRawBtn').addEventListener('click', () => {
|
||
const ed = $('cSoloEd'), raw = $('cSoloRaw');
|
||
if (raw.hidden) { raw.value = ed.innerHTML; raw.hidden = false; ed.hidden = true; $('edRawBtn').textContent = 'Visual'; }
|
||
else { ed.innerHTML = raw.value; ed.hidden = false; raw.hidden = true; $('edRawBtn').textContent = 'Raw text'; }
|
||
});
|
||
const WHERE = { banner: 'Runs in the ad viewer, on the home page, the live ledger, every Overview, the sidebar tile, and out in the Network Ad Space rotation across the wider network.',
|
||
text: 'Runs in the ad viewer, the live ledger text slot, and out in the Network Ad Space rotation.',
|
||
login: 'Full screen for every member who signs in, ten seconds, once a day per member. Opens in a fresh tab, so any working page qualifies. Login ads spend purchased credits only.',
|
||
solo: 'Delivered into member inboxes under Earn credits. Each read is timed and rewarded, so it gets opened.',
|
||
video: 'Plays in Watch videos and the Shorts feed under Earn credits. You pay only for completed watches.',
|
||
featured: 'Your headline and link in the Featured strip on every member Overview for the days you book.',
|
||
visits: 'A distinct member opens your site in a new tab, stays eight seconds and passes a check. Nobody counts twice.' };
|
||
const whereHint = () => { const el = $('cWhere'); if (el) el.textContent = WHERE[$('cType').value] || ''; };
|
||
whereHint();
|
||
// runs on every type change AND once at load, so the default type (banner) shows its size + image rows immediately
|
||
function applyType() { // hoisted: loadCampaigns may run before this line is reached
|
||
whereHint();
|
||
const t = $('cType').value;
|
||
$('cImageRow').hidden = t !== 'banner'; // only banners carry a creative; login frames its URL
|
||
$('cSizeRow').hidden = t !== 'banner';
|
||
$('cTitleRow').hidden = t !== 'text' && t !== 'solo';
|
||
$('cBodyRow').hidden = t !== 'text';
|
||
$('cSoloRow').hidden = t !== 'solo';
|
||
$('cSoloHint').hidden = t !== 'solo';
|
||
$('cVideoRow').hidden = t !== 'video';
|
||
$('cFeaturedRow').hidden = t !== 'featured';
|
||
$('cVisitsRow').hidden = t !== 'visits';
|
||
if ($('cSchedRow')) { $('cSchedRow').hidden = t === 'featured'; $('cStartLbl').textContent = t === 'solo' ? 'Send from (optional)' : 'Start (optional)';
|
||
$('cSchedHint').textContent = t === 'solo' ? 'Deliveries to member inboxes begin at the time you pick, so you can land when people are reading. Leave empty to start now.' : 'Leave both empty to start now and run until the budget is spent. Times are your local time. Anything left when a campaign ends goes back to Available.'; }
|
||
// fixed-cost types derive their spend (featured = day slots, visits = flat pack),
|
||
// so hide the free-form Budget field for them to avoid confusion
|
||
$('cBudgetRow').hidden = (t === 'featured' || t === 'visits');
|
||
if ($('cCapRow')) $('cCapRow').hidden = !(t === 'banner' || t === 'text');
|
||
// solo ads have a real floor (5cr x 10 deliveries): default to 50 so 10 isn't rejected
|
||
if (t === 'solo' && (!$('cBudget').value || Number($('cBudget').value) < 50)) $('cBudget').value = (lastRates && lastRates.soloCostPerRecipient ? lastRates.soloCostPerRecipient : 5) * 10;
|
||
if (t === 'visits') visitHint();
|
||
$('cTitle').placeholder = t === 'solo' ? 'Subject line (max 80)' : 'Headline (max 60)';
|
||
soloHint();
|
||
if (t === 'video') videoHint();
|
||
if (t === 'featured') featHint();
|
||
}
|
||
$('cType').addEventListener('change', applyType);
|
||
let featStartDay = 0; // selected start-day offset (0 = today)
|
||
async function featHint() {
|
||
if (!lastRates || !lastRates.featuredDurations) return;
|
||
if ($('cFeatDays') && !$('cFeatDays').options.length)
|
||
$('cFeatDays').innerHTML = lastRates.featuredDurations.map(dys =>
|
||
'<option value="' + dys + '">' + dys + ' day' + (dys > 1 ? 's' : '') + ' — ' + (dys * lastRates.featuredPerDay) + ' credits</option>').join('');
|
||
let s = null;
|
||
try { s = await (await fetch('/api/featured/stats')).json(); } catch (e) {}
|
||
if (s && s.occupancy) {
|
||
const grid = $('cFeatDaysGrid');
|
||
grid.innerHTML = s.occupancy.map(d => {
|
||
const label = d.offset === 0 ? 'Today' : d.offset === 1 ? 'Tomorrow' : new Date(d.day + 'T00:00:00Z').toLocaleDateString(undefined, { weekday: 'short', month: 'numeric', day: 'numeric' });
|
||
const full = d.open <= 0;
|
||
return '<div class="feat-day' + (d.offset === featStartDay ? ' on' : '') + (full ? ' full' : d.open > d.cap / 2 ? ' open2' : '') + '" data-off="' + d.offset + '"' + (full ? '' : '') + '>'
|
||
+ '<div class="fd-day">' + label + '</div><div class="fd-occ">' + d.count + '/' + d.cap + (full ? ' full' : ' left ' + d.open) + '</div></div>';
|
||
}).join('');
|
||
grid.querySelectorAll('.feat-day:not(.full)').forEach(el =>
|
||
el.addEventListener('click', () => { featStartDay = Number(el.dataset.off); featHint(); }));
|
||
}
|
||
const dys = Number($('cFeatDays').value) || (lastRates.featuredDurations[0]);
|
||
const dayLabel = featStartDay === 0 ? 'today' : featStartDay === 1 ? 'tomorrow' : 'in ' + featStartDay + ' days';
|
||
$('cFeatHint').textContent = 'Runs ' + dys + ' day' + (dys > 1 ? 's' : '') + ' starting ' + dayLabel
|
||
+ ' for ' + (dys * lastRates.featuredPerDay) + ' credits. Max ' + (s ? s.slotsPerDay : 10) + ' links share any day.';
|
||
}
|
||
document.addEventListener('change', e => { if (e.target && e.target.id === 'cFeatDays') featHint(); });
|
||
function visitHint() {
|
||
if (!lastRates) return;
|
||
const n = Number($('cVisitCount').value) || 0;
|
||
const cost = n * (lastRates.visitCostPerVisit || 3);
|
||
$('cVisitHint').textContent = (lastRates.visitCostPerVisit || 3) + ' credits per verified visit'
|
||
+ (n >= (lastRates.visitMinPack || 20) ? ' — ' + n + ' visits = ' + cost + ' credits' : ' (min ' + (lastRates.visitMinPack || 20) + ')')
|
||
+ '. Each is a unique member, dwell + human-check verified.';
|
||
}
|
||
$('cVisitCount').addEventListener('input', visitHint);
|
||
// video composer: tier dropdown + upload + price hint
|
||
function videoHint() {
|
||
if (!lastRates || !lastRates.videoTiers) return;
|
||
if ($('cWatchSecs') && !$('cWatchSecs').options.length)
|
||
$('cWatchSecs').innerHTML = lastRates.videoTiers.map(t =>
|
||
'<option value="' + t.secs + '">Watch ' + t.secs + 's — ' + t.cost + ' credits/view (viewer earns ' + t.reward + ')</option>').join('');
|
||
const tier = lastRates.videoTiers.find(t => t.secs === Number($('cWatchSecs').value)) || lastRates.videoTiers[0];
|
||
const n = tier ? Math.floor((Number($('cBudget').value) || 0) / tier.cost) : 0;
|
||
$('cVideoHint').textContent = tier ? (tier.cost + ' credits per completed ' + tier.secs + 's view'
|
||
+ (n ? ' — this budget buys ' + n + ' views' : '') + '. Viewers earn ' + tier.reward + ' credits each, so they watch.') : '';
|
||
}
|
||
document.addEventListener('change', e => { if (e.target && e.target.id === 'cWatchSecs') videoHint(); });
|
||
$('cBudget').addEventListener('input', () => { if ($('cType').value === 'video') videoHint(); });
|
||
$('cVideoUploadBtn').addEventListener('click', () => $('cVideoFile').click());
|
||
$('cVideoUrl').addEventListener('change', async () => {
|
||
const url = $('cVideoUrl').value.trim();
|
||
$('cVideoInfo').textContent = url ? 'checking video…' : '';
|
||
cVidDims = url ? await probeVideoDims(url) : null;
|
||
if (url && !cVidDims) $('cVideoInfo').textContent = 'could not read that video';
|
||
else { $('cVideoInfo').textContent = ''; showVidOrient(); }
|
||
});
|
||
$('cVideoFile').addEventListener('change', async () => {
|
||
const f = $('cVideoFile').files[0];
|
||
if (!f) return;
|
||
$('cVideoInfo').textContent = 'Uploading ' + f.name + '… (large files take a moment)';
|
||
try {
|
||
const r = await (await fetch('/api/my/upload', { method: 'POST', headers: { 'Content-Type': f.type }, body: f })).json();
|
||
if (r.error) { $('cVideoInfo').textContent = r.error; $('cVideoFile').value = ''; return; }
|
||
$('cVideoUrl').value = r.url;
|
||
$('cVideoInfo').textContent = f.name + ' uploaded';
|
||
$('cVideoPrev').hidden = false;
|
||
$('cVideoPrev').innerHTML = '<video src="' + r.url + '" controls style="max-width:320px;border-radius:10px"></video>';
|
||
cVidDims = await probeVideoDims(r.url); showVidOrient();
|
||
} catch (e) { $('cVideoInfo').textContent = 'Upload failed. Try again.'; }
|
||
$('cVideoFile').value = '';
|
||
});
|
||
$('createCampBtn').addEventListener('click', busy2($('createCampBtn'), async () => {
|
||
// in raw mode the source of truth is the textarea; sync it back first
|
||
let soloBody = $('cSoloEd').innerHTML;
|
||
if ($('cSoloRaw') && !$('cSoloRaw').hidden) soloBody = $('cSoloRaw').value;
|
||
const t = $('cType').value;
|
||
const isVideo = t === 'video', isFeat = t === 'featured';
|
||
if (isVideo && !cVidDims && $('cVideoUrl').value) cVidDims = await probeVideoDims($('cVideoUrl').value);
|
||
await api('/api/my/campaigns', { type: t, name: $('cName').value,
|
||
targetUrl: $('cTarget').value, imageUrl: $('cImage').value, size: $('cSize').value,
|
||
title: isVideo ? $('cVideoTitle').value : isFeat ? $('cFeatTitle').value : t === 'visits' ? $('cVisitTitle').value : $('cTitle').value,
|
||
body: t === 'solo' ? soloBody : $('cBody').value,
|
||
videoUrl: $('cVideoUrl').value, watchSecs: Number($('cWatchSecs').value),
|
||
videoW: cVidDims ? cVidDims.w : null, videoH: cVidDims ? cVidDims.h : null,
|
||
days: Number($('cFeatDays').value), startDay: featStartDay,
|
||
count: Number($('cVisitCount').value),
|
||
dailyCap: ($('cDailyCap') && (t === 'banner' || t === 'text')) ? Number($('cDailyCap').value) || 0 : 0,
|
||
geo: [...document.querySelectorAll('.geoTier:checked')].map(x => x.value).join(','),
|
||
startsAt: ($('cStartAt') && $('cStartAt').value && !isFeat) ? new Date($('cStartAt').value).getTime() : 0,
|
||
endsAt: ($('cEndAt') && $('cEndAt').value && !isFeat) ? new Date($('cEndAt').value).getTime() : 0,
|
||
ctaLabel: isVideo ? $('cVideoCta').value : $('cCtaLabel').value,
|
||
budget: isFeat ? (Number($('cFeatDays').value) * (lastRates.featuredPerDay || 40)) : t === 'visits' ? (Number($('cVisitCount').value) * (lastRates.visitCostPerVisit || 3)) : Number($('cBudget').value) });
|
||
const schedStart = ($('cStartAt') && $('cStartAt').value && !isFeat) ? new Date($('cStartAt').value) : null;
|
||
IAP.status(schedStart && schedStart.getTime() > Date.now() ? 'Campaign saved. It starts serving ' + schedStart.toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' }) + '.' : 'Campaign is live. It starts serving right away.', 'ok');
|
||
// clear EVERY field so no target/creative carries into the next campaign
|
||
['cName', 'cBudget', 'cTarget', 'cImage', 'cTitle', 'cBody', 'cCtaLabel',
|
||
'cVideoUrl', 'cVideoTitle', 'cVideoCta', 'cVisitTitle', 'cVisitCount', 'cFeatTitle', 'cDailyCap', 'cStartAt', 'cEndAt']
|
||
.forEach(id => { if ($(id)) $(id).value = ''; });
|
||
document.querySelectorAll('.geoTier').forEach(x => { x.checked = true; });
|
||
$('cSoloEd').innerHTML = ''; if ($('cSoloRaw')) $('cSoloRaw').value = '';
|
||
$('cVideoInfo').textContent = ''; $('cVideoPrev').hidden = true; $('cVideoPrev').innerHTML = '';
|
||
if ($('edMediaInfo')) $('edMediaInfo').textContent = '';
|
||
cVidDims = null;
|
||
await loadCampaigns();
|
||
}));
|
||
// defers the busy() lookup to click time (busy is declared below)
|
||
function busy2(btn, fn) { return (...a) => busy(btn, fn)(...a); }
|
||
|
||
// ── verified visits: open a member's site (new tab), dwell, human-check, earn ──
|
||
const visState = { token: null, id: null, dwell: 8 };
|
||
async function loadVisitStatus() {
|
||
try {
|
||
const st = await (await fetch('/api/my/visits')).json();
|
||
if (st.error) return;
|
||
$('vsProgress').textContent = 'today: ' + (st.status.count || 0) + ' / ' + st.status.cap + ' verified visits';
|
||
$('vsStartBtn').hidden = st.status.count >= st.status.cap;
|
||
if (st.status.count >= st.status.cap) $('vsBox').innerHTML = '<span class="muted small">That\'s today\'s visits. Come back tomorrow.</span>';
|
||
} catch (e) {}
|
||
}
|
||
async function loadVisit() {
|
||
$('vsCheck').hidden = true; $('vsVisit').hidden = true; $('vsHint').textContent = '';
|
||
let r = null;
|
||
try { r = await (await fetch('/api/my/visits')).json(); } catch (e) {}
|
||
if (!r || !r.ad) {
|
||
$('vsBox').innerHTML = '<span class="muted small">' + (r && r.status && r.status.count >= r.status.cap
|
||
? 'That\'s today\'s visits. Come back tomorrow.' : 'No verified-visit packs are running right now. Check back soon.') + '</span>';
|
||
return;
|
||
}
|
||
visState.token = r.token; visState.id = r.ad.id; visState.dwell = r.status.dwell || 8;
|
||
$('vsBox').innerHTML = '<b>' + esc(r.ad.title || 'Member site') + '</b><br><span class="muted small">Open the site and stay ' + visState.dwell + 's.</span>';
|
||
const visit = $('vsVisit');
|
||
visit.href = r.ad.url; visit.hidden = false;
|
||
$('vsStartBtn').textContent = 'Loading…'; $('vsStartBtn').disabled = true;
|
||
// opening the site starts the dwell; then we ask the human-check
|
||
visit.onclick = () => {
|
||
let left = visState.dwell;
|
||
$('vsHint').textContent = 'Counting your visit: ' + left + 's';
|
||
const t = setInterval(async () => {
|
||
left--;
|
||
if (left > 0) { $('vsHint').textContent = 'Counting your visit: ' + left + 's'; return; }
|
||
clearInterval(t);
|
||
$('vsHint').textContent = 'One quick check to count the visit:';
|
||
let c = await (await fetch('/api/my/visitchallenge?token=' + visState.token)).json();
|
||
if (c.early) { await new Promise(r2 => setTimeout(r2, (c.wait || 1) * 1000 + 300)); c = await (await fetch('/api/my/visitchallenge?token=' + visState.token)).json(); }
|
||
if (c.error) { $('vsHint').textContent = c.error; return; }
|
||
renderVisitCheck(c);
|
||
}, 1000);
|
||
};
|
||
$('vsStartBtn').textContent = 'Next visit'; $('vsStartBtn').disabled = false;
|
||
}
|
||
function renderVisitCheck(c) {
|
||
$('vsPrompt').textContent = 'Click the ' + c.prompt + ':';
|
||
const w = $('vsOpts'); w.innerHTML = '';
|
||
c.options.forEach((em, i) => {
|
||
const b = document.createElement('button');
|
||
b.className = 'btn small sec'; b.style.marginRight = '6px'; b.textContent = em;
|
||
b.addEventListener('click', () => answerVisit(i));
|
||
w.appendChild(b);
|
||
});
|
||
$('vsCheck').hidden = false;
|
||
}
|
||
async function answerVisit(i) {
|
||
const r = await (await fetch('/api/my/visitdone', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token: visState.token, answer: i }) })).json();
|
||
if (r.error) {
|
||
if (r.retry) { const c = await (await fetch('/api/my/visitchallenge?token=' + visState.token)).json(); if (!c.error) return renderVisitCheck(c); }
|
||
$('vsHint').textContent = r.error; $('vsCheck').hidden = true; return;
|
||
}
|
||
$('vsCheck').hidden = true;
|
||
$('vsHint').textContent = '+' + r.credited + ' credits — visit counted. Load the next one.';
|
||
$('vsProgress').textContent = 'today: ' + (r.status.count || 0) + ' / ' + (r.status.cap || 0) + ' verified visits';
|
||
IAP.status('+' + r.credited + ' credits for a verified visit.', 'ok');
|
||
loadDashboard();
|
||
}
|
||
$('vsStartBtn').addEventListener('click', () => loadVisit());
|
||
|
||
// ── watch-to-earn videos: escape-proof player, server-clock reward ──
|
||
const vidState = { token: null, secs: 0, maxSeen: 0, done: false, credited: false };
|
||
// detected dimensions of the video being created — portrait goes to Shorts, landscape to Watch videos
|
||
let cVidDims = null;
|
||
function probeVideoDims(url) {
|
||
return new Promise(resolve => {
|
||
if (!url) return resolve(null);
|
||
const v = document.createElement('video'); v.preload = 'metadata'; v.muted = true;
|
||
let done = false; const fin = r => { if (!done) { done = true; resolve(r); } };
|
||
v.onloadedmetadata = () => fin(v.videoWidth && v.videoHeight ? { w: v.videoWidth, h: v.videoHeight } : null);
|
||
v.onerror = () => fin(null); setTimeout(() => fin(null), 8000); v.src = url;
|
||
});
|
||
}
|
||
function showVidOrient() {
|
||
const el = $('cVideoInfo'); if (!el) return;
|
||
if (!cVidDims) return;
|
||
const portrait = cVidDims.h > cVidDims.w;
|
||
el.textContent = (el.textContent ? el.textContent + ' · ' : '')
|
||
+ (portrait ? 'portrait — shows in the Shorts reel' : 'landscape — shows in the Watch videos tab');
|
||
}
|
||
async function loadVideoStatus() {
|
||
try {
|
||
const st = await (await fetch('/api/my/videos?orientation=landscape')).json();
|
||
if (st.error) return;
|
||
$('vidProgress').textContent = 'today: ' + (st.status.count || 0) + ' / ' + st.status.cap + ' videos watched';
|
||
if (st.status.left <= 0) {
|
||
$('vidBox').innerHTML = '<span class="muted small">That is today\'s video set. Come back tomorrow.</span>';
|
||
$('vidStartBtn').hidden = true;
|
||
return;
|
||
}
|
||
$('vidStartBtn').hidden = false;
|
||
} catch (e) {}
|
||
}
|
||
// leaving the player (other sub-tab, other pane, page hidden) stops the clip: nothing plays or earns in the background (Marty, 2026-09-13)
|
||
function stopVideo() {
|
||
const p = $('vidPlayer'); if (!p) return;
|
||
const was = !!vidState.token && !vidState.done;
|
||
try { p.pause(); p.ontimeupdate = null; p.onseeking = null; p.removeAttribute('src'); p.load(); } catch (e) {}
|
||
vidState.token = null; vidState.done = false; vidState.credited = false; vidState.maxSeen = 0;
|
||
if ($('vidWrap')) $('vidWrap').hidden = true;
|
||
if ($('vidBox')) { $('vidBox').hidden = false; if (was) $('vidBox').innerHTML = '<span class="muted small">Video stopped when you left the tab. Tap Load a video to start a fresh one.</span>'; }
|
||
if ($('vidStartBtn')) { $('vidStartBtn').disabled = false; $('vidStartBtn').textContent = 'Load a video'; }
|
||
}
|
||
document.addEventListener('visibilitychange', () => { if (document.hidden && vidState.token && !vidState.done) stopVideo(); });
|
||
async function loadVideoAd() {
|
||
let r = null;
|
||
try { r = await (await fetch('/api/my/videos?orientation=landscape')).json(); } catch (e) {}
|
||
if (!r || !r.ad) {
|
||
// same done screen as Watch ads when the day's videos are finished (Marty, 2026-09-13)
|
||
const capHit = !!(r && r.status && r.status.left <= 0), allWatched = !!(r && r.allWatched);
|
||
$('vidBox').innerHTML = (capHit || allWatched)
|
||
? '<div class="done-big"><span class="tick">✓</span><b>Videos done for today</b><span class="muted small">' + (capHit ? 'That is today’s video set (' + r.status.count + ' watched). Fresh videos tomorrow.' : 'You have watched every live video for today; each one pays once a day. New ones appear as members launch video campaigns.') + '</span></div>'
|
||
: '<span class="muted small">No member videos are live right now. Check back when a campaign is running.</span>';
|
||
if ($('vidStartBtn')) $('vidStartBtn').hidden = capHit || allWatched;
|
||
$('vidBox').hidden = false;
|
||
$('vidWrap').hidden = true;
|
||
return;
|
||
}
|
||
const ad = r.ad;
|
||
vidState.token = r.token; vidState.secs = ad.watchSecs; vidState.maxSeen = 0; vidState.done = false; vidState.credited = false;
|
||
$('vidBox').hidden = true;
|
||
$('vidWrap').hidden = false;
|
||
$('vidCta').hidden = true;
|
||
$('vidHint').textContent = ad.title ? 'Now playing: ' + ad.title : '';
|
||
const p = $('vidPlayer');
|
||
p.src = ad.videoUrl;
|
||
p.currentTime = 0;
|
||
// escape-proof: no seeking past what's been watched; track max reached
|
||
p.onseeking = () => { if (p.currentTime > vidState.maxSeen + 0.5) p.currentTime = vidState.maxSeen; };
|
||
p.ontimeupdate = () => {
|
||
if (p.currentTime > vidState.maxSeen) vidState.maxSeen = p.currentTime;
|
||
const left = Math.max(0, Math.ceil(vidState.secs - vidState.maxSeen));
|
||
$('vidTimer').textContent = left > 0 ? 'Watch ' + left + 's more to earn' : 'Watch time met — finishing…';
|
||
if (!vidState.done && vidState.maxSeen >= vidState.secs) { vidState.done = true; completeVideo(ad); }
|
||
};
|
||
$('vidStartBtn').textContent = 'Playing…';
|
||
$('vidStartBtn').disabled = true;
|
||
try { await p.play(); } catch (e) { $('vidStartBtn').disabled = false; $('vidStartBtn').textContent = 'Tap to play'; }
|
||
$('vidCta').href = ad.ctaUrl;
|
||
$('vidCta').textContent = ad.ctaLabel || 'Learn more';
|
||
$('vidCta').hidden = false;
|
||
}
|
||
async function completeVideo(ad) {
|
||
if (vidState.credited) return;
|
||
vidState.credited = true;
|
||
try {
|
||
const r = await (await fetch('/api/my/videowatch', { method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token: vidState.token }) })).json();
|
||
if (r.error) { IAP.status(r.error, 'bad'); $('vidHint').textContent = r.error + (/no longer open|stale/.test(r.error) ? ' Loading a fresh one.' : ''); if (/no longer open|stale/.test(r.error)) setTimeout(() => loadVideoAd(), 1200); }
|
||
else if (r.credited) { IAP.status('+' + r.credited + ' credits earned for watching.', 'ok'); $('vidHint').textContent = '+' + r.credited + ' credits earned. Load the next one.'; loadDashboard(); }
|
||
else $('vidHint').textContent = 'That video just ran out of budget — load another.';
|
||
if (r.status) $('vidProgress').textContent = 'today: ' + (r.status.count || 0) + ' / ' + r.status.cap + ' videos watched';
|
||
} catch (e) { IAP.status('Could not confirm that watch. Try the next one.', 'bad'); }
|
||
$('vidStartBtn').disabled = false;
|
||
$('vidStartBtn').textContent = 'Next video';
|
||
}
|
||
$('vidStartBtn').addEventListener('click', () => loadVideoAd());
|
||
// presence enforcement: pause the watch-to-earn video when the tab/window loses focus,
|
||
// resume when it comes back — the viewer must stay on the page for the watch to complete
|
||
document.addEventListener('visibilitychange', () => {
|
||
const p = $('vidPlayer'); if (!p || !p.src) return;
|
||
if (document.hidden) p.pause(); else if (!vidState.done) p.play().catch(() => {});
|
||
});
|
||
window.addEventListener('blur', () => { const p = $('vidPlayer'); if (p && p.src) p.pause(); });
|
||
window.addEventListener('focus', () => { const p = $('vidPlayer'); if (p && p.src && !vidState.done) p.play().catch(() => {}); });
|
||
|
||
// ── unmissable sponsor-message modal on sign-in ──
|
||
function showSponsorModal(msg) {
|
||
if (!$('msgModal')) return;
|
||
$('mmFrom').textContent = 'A message from ' + (msg.fromName || 'your sponsor');
|
||
$('mmSubject').textContent = msg.subject || '';
|
||
$('mmBody').innerHTML = msg.body || ''; // server-sanitized
|
||
$('msgModal').hidden = false;
|
||
$('mmAck').onclick = async () => {
|
||
$('msgModal').hidden = true;
|
||
try { await fetch('/api/my/messages/' + msg.id + '/read', { method: 'POST' }); } catch (e) {}
|
||
};
|
||
}
|
||
|
||
// ── coaching: every direct's rung, stalled flag, one-click nudge ──
|
||
// ── pay it forward: send POL from the sponsor's own wallet to a downline's linked address ──
|
||
async function pif(email, name, address) {
|
||
let suggest = 25;
|
||
try { const { products } = await (await fetch('/api/catalog')).json(); const p20 = (products || []).find(p => p.priceCents === 2000); if (p20 && p20.costWei) suggest = Math.ceil(Number(p20.costWei) / 1e18) + 3; } catch (e) {}
|
||
const amt = await IAP.ask({ title: 'Pay it forward', text: 'Send POL from your wallet to ' + name + ' (' + address.slice(0, 6) + '…' + address.slice(-4) + ') for their first package.\nSuggested: the $20 package plus fees. Amount in POL:', type: 'number', value: String(suggest), ok: 'Send POL' });
|
||
if (amt === null) return;
|
||
const pol = Number(amt); if (!(pol > 0)) { IAP.status('Enter an amount in POL.', 'bad'); return; }
|
||
try {
|
||
IAP.status('Confirm the transfer in your wallet…', 'ok');
|
||
const wei = (BigInt(Math.round(pol * 1e6)) * 10n ** 12n).toString();
|
||
const hash = await IAPWallet.sendPol(address, wei);
|
||
await api('/api/my/gift', { email, tx: hash, pol });
|
||
IAP.status('Sent ' + pol + ' POL to ' + name + '. They have been told, with the proof link.', 'ok');
|
||
playSound && playSound('chaching');
|
||
} catch (e) { IAP.status('Transfer not sent: ' + ((e && e.message) || e), 'bad'); }
|
||
}
|
||
// ── holding tank: waiting members, adopt, my open adoptions ──
|
||
const ago = ts => { if (!ts) return 'never'; const d = Math.floor((Date.now() - ts) / 86400000); return d === 0 ? 'today' : d === 1 ? 'yesterday' : d + ' days ago'; };
|
||
async function loadTank() {
|
||
const el = $('tankList'); if (!el) return;
|
||
try {
|
||
const r = await (await fetch('/api/my/tank')).json();
|
||
if (r.error) { el.innerHTML = ''; return; }
|
||
$('tankCap').textContent = r.cap; $('tankTtl').textContent = r.ttlDays;
|
||
$('tankSub').textContent = r.waiting.length ? r.waiting.length + ' waiting' : 'nobody waiting right now';
|
||
const why = $('tankWhy'); why.hidden = r.eligible; why.innerHTML = r.eligible ? '' : '<span class="badge amber">not yet</span> ' + esc(r.reason);
|
||
$('tankMine').innerHTML = r.mine.length ? '<p class="small" style="margin:0 0 6px"><b>Your open adoptions</b></p>' + r.mine.map(m => '<div class="lin-row own"><span class="nm">' + esc(m.name) + '</span>'
|
||
+ '<span class="em">' + (m.bought ? 'bought' : m.wallet ? 'wallet linked' : 'free, no wallet yet') + ' · last seen ' + ago(m.lastSeen) + '</span>'
|
||
+ '<span class="dt">' + Math.max(0, Math.ceil((m.expires - Date.now()) / 86400000)) + ' days left</span>'
|
||
+ '<button class="btn sec small" type="button" data-tchat="' + esc(m.email) + '" data-tname="' + esc(m.name) + '">Chat</button>'
|
||
+ (m.address && !m.bought ? ' <button class="btn small" type="button" title="Pay it forward: send POL from your wallet to theirs for their first package" data-pif="' + esc(m.email) + '" data-pname="' + esc(m.name) + '" data-paddr="' + esc(m.address) + '">PIF</button>' : '') + '</div>').join('') : '';
|
||
$('tankMine').querySelectorAll('[data-tchat]').forEach(b => b.addEventListener('click', () => openConvo(b.dataset.tchat, b.dataset.tname)));
|
||
$('tankMine').querySelectorAll('[data-pif]').forEach(b => b.addEventListener('click', () => pif(b.dataset.pif, b.dataset.pname, b.dataset.paddr)));
|
||
if (!r.waiting.length) { el.innerHTML = '<p class="muted small">The tank is empty. Anyone who joins from the public site without a sponsor lands here.</p>'; return; }
|
||
el.innerHTML = r.waiting.map(w => '<div class="lin-row"><span class="nm">' + esc(w.name) + '</span>'
|
||
+ '<span class="em">joined ' + ago(w.joined) + '</span>'
|
||
+ '<span class="dt">last sign-in: <b>' + ago(w.lastSeen) + '</b></span>'
|
||
+ (r.eligible ? '<button class="btn small" type="button" data-adopt="' + esc(w.username || w.email) + '" data-aname="' + esc(w.name) + '">Adopt</button>' : '') + '</div>').join('');
|
||
el.querySelectorAll('[data-adopt]').forEach(b => b.addEventListener('click', async () => {
|
||
const note = await IAP.ask({ title: 'Adopt ' + b.dataset.aname, text: 'Your first message to ' + b.dataset.aname + ' (sent as a chat and an email):', type: 'textarea', ok: 'Adopt and send', value: 'Hi, I picked you up from the InstantAdPay holding tank so you have a sponsor who will actually help. Reply here and I will walk you through the first three steps.' });
|
||
if (note === null) return;
|
||
try { const rr = await api('/api/my/tank/adopt', { who: b.dataset.adopt, note }); IAP.status('You are now the sponsor for ' + rr.name + '. Chat and email sent.', 'ok'); loadTank(); loadCoach(); }
|
||
catch (e) { IAP.status(e.message, 'bad'); }
|
||
}));
|
||
} catch (e) {}
|
||
}
|
||
async function loadCoach() {
|
||
loadTank();
|
||
try {
|
||
const r = await (await fetch('/api/my/coach')).json();
|
||
const el = $('coachList'); if (!el || r.error) return;
|
||
const d = r.directs || [];
|
||
$('coachSummary').innerHTML = d.length ? '<b>' + d.length + '</b> direct' + (d.length === 1 ? '' : 's') + ' · <b>' + r.stalled + '</b> quiet for 3+ days' + (r.stalled ? ' · start at the top' : '') : '';
|
||
if (!d.length) { el.innerHTML = '<p class="muted small">No directs yet. When someone joins through your link they show up here with their next step.</p>'; return; }
|
||
el.innerHTML = d.map(x => '<div class="lin-row' + (x.stalled ? ' own' : '') + '"><span class="nm">' + esc(x.name) + (x.stalled ? ' <span class="badge amber">quiet ' + x.quietDays + 'd</span>' : '')
|
||
+ (x.rescue && x.rescue.unreached ? ' <span class="badge amber" title="No message from you to this person. Contact them or mark Contacted, or they move to the holding tank.">unreached · tank in ' + x.rescue.rescueInDays + 'd</span>' : '') + (x.bound === false ? ' <span class="badge amber" title="On-chain this member registered under ' + (x.onchainSponsorId ? 'member #' + x.onchainSponsorId : 'no sponsor') + ', so the contract pays their purchases there, not to you. Sponsor binding is permanent at first activation.">not bound to you on-chain</span>' : '') + '</span>'
|
||
+ '<span class="em">' + esc(x.label) + ' → ' + esc(x.next) + '</span>'
|
||
+ '<span class="id">rung ' + x.rung + '/6</span>'
|
||
+ '<span class="dt">' + (x.buyerCount ? x.buyerCount + ' buyer' + (x.buyerCount === 1 ? '' : 's') : '') + '</span>'
|
||
+ '<button class="btn sec small" type="button" data-nudge="' + esc(x.email) + '" data-nname="' + esc(x.name) + '" data-say="' + esc(x.say) + '">Nudge</button>'
|
||
+ (x.free && x.address ? ' <button class="btn small" type="button" title="Pay it forward: send POL from your wallet to theirs for their first package" data-pif="' + esc(x.email) + '" data-pname="' + esc(x.name) + '" data-paddr="' + esc(x.address) + '">PIF</button>' : '')
|
||
+ (x.free && x.rescue ? ' <button class="btn sec small" type="button" title="Reached them by phone, text or in person? Mark it so the rescue clock resets." data-contacted="' + esc(x.email) + '" data-cname="' + esc(x.name) + '">Contacted them</button>' : '')
|
||
+ (x.free ? ' <button class="btn sec small" type="button" title="Pay it forward: give this free member to the holding tank so another member can coach them" data-release="' + esc(x.email) + '" data-rname="' + esc(x.name) + '">Release to tank</button>' : '') + '</div>').join('');
|
||
el.querySelectorAll('[data-contacted]').forEach(b => b.addEventListener('click', async () => {
|
||
try { await api('/api/my/tank/contacted', { email: b.dataset.contacted }); IAP.status('Marked: you have contacted ' + b.dataset.cname + '.', 'ok'); loadCoach(); }
|
||
catch (e) { IAP.status(e.message, 'bad'); }
|
||
}));
|
||
el.querySelectorAll('[data-pif]').forEach(b => b.addEventListener('click', () => pif(b.dataset.pif, b.dataset.pname, b.dataset.paddr)));
|
||
el.querySelectorAll('[data-nudge]').forEach(b => b.addEventListener('click', async () => {
|
||
await openConvo(b.dataset.nudge, b.dataset.nname);
|
||
const inp = $('chatInput'); if (inp) { inp.value = b.dataset.say.replace(/\{\{name\}\}/g, b.dataset.nname.replace(/^@/, '')); inp.focus(); }
|
||
}));
|
||
el.querySelectorAll('[data-release]').forEach(b => b.addEventListener('click', async () => {
|
||
if (!(await IAP.confirmBox('Release ' + b.dataset.rname + ' to the holding tank? You stop being their sponsor and another member can adopt them.', { title: 'Release to the tank', ok: 'Release' }))) return;
|
||
try { await api('/api/my/tank/release', { email: b.dataset.release }); IAP.status(b.dataset.rname + ' is in the holding tank.', 'ok'); loadCoach(); }
|
||
catch (e) { IAP.status(e.message, 'bad'); }
|
||
}));
|
||
} catch (e) {}
|
||
}
|
||
// schedule chips on the campaign table (local time) + by-hour view bars
|
||
const fmtWhen = ms => new Date(ms).toLocaleString([], { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
|
||
function schedChips(c) {
|
||
const now = Date.now(); let s = '';
|
||
if (c.starts && c.starts > now) s += ' <span class="muted small">starts ' + fmtWhen(c.starts) + '</span>';
|
||
if (c.expires && c.type !== 'featured') s += ' <span class="muted small">' + (c.expires > now ? 'ends ' : 'ended ') + fmtWhen(c.expires) + '</span>';
|
||
return s;
|
||
}
|
||
function geoLine(rows) {
|
||
if (!rows || !rows.length) return '';
|
||
return '<div class="muted small" style="font-size:10px" title="on-site serves by viewer country">' + rows.map(r => esc(r.cc) + ' ' + r.n).join(' · ') + '</div>';
|
||
}
|
||
function hourBars(utc) {
|
||
if (!utc || !utc.some(n => n)) return '';
|
||
// rotate the 24 UTC buckets into the viewer's local hours
|
||
const local = new Array(24).fill(0);
|
||
for (let h = 0; h < 24; h++) local[new Date(Date.UTC(2000, 0, 1, h)).getHours()] += utc[h];
|
||
const max = Math.max(...local);
|
||
const lab = h => (h % 12 || 12) + (h < 12 ? 'am' : 'pm');
|
||
return '<div class="hbars" title="on-site views by hour of day, your local time, last 7 days">' + local.map((n, h) =>
|
||
'<i style="height:' + Math.max(2, Math.round(n / max * 18)) + 'px" title="' + lab(h) + ': ' + n + '"></i>').join('')
|
||
+ '</div><div class="muted small" style="font-size:10px">views by hour · last 7 days · your time</div>';
|
||
}
|
||
// ── link stats: views, joins, buyers per angle ──
|
||
async function loadLinkStats() {
|
||
try {
|
||
const r = await (await fetch('/api/my/linkstats')).json();
|
||
const t = $('linkStatsTable'); if (!t || r.error) return;
|
||
const rows = (r.angles || []).filter(a => a.views || a.joins || a.buyers);
|
||
if (!rows.length) { t.innerHTML = '<tr><td class="muted small">No views yet. Share your link and the numbers start here.</td></tr>'; return; }
|
||
t.innerHTML = '<tr><th>Hook</th><th>Views (30d)</th><th>Views (all)</th><th>Joined</th><th>Qualifying buyers</th></tr>'
|
||
+ rows.map(a => '<tr><td>' + esc(a.angle === 'plain' ? 'plain link' : '?v=' + a.angle) + '</td><td class="mono">' + a.views30 + '</td><td class="mono">' + a.views + '</td><td class="mono">' + a.joins + '</td><td class="mono">' + a.buyers + '</td></tr>').join('');
|
||
const st = $('linkSrcTable');
|
||
if (st) {
|
||
const src = (r.sources || []).filter(x => x.views || x.joins);
|
||
st.innerHTML = src.length ? '<tr><th>Source</th><th>Views (30d)</th><th>Views (all)</th><th>Joined</th><th>Qualifying buyers</th></tr>'
|
||
+ src.map(x => '<tr><td>' + esc(x.source) + '</td><td class="mono">' + x.views30 + '</td><td class="mono">' + x.views + '</td><td class="mono">' + x.joins + '</td><td class="mono">' + x.buyers + '</td></tr>').join('')
|
||
: '<tr><td class="muted small">Sources appear as visits arrive.</td></tr>';
|
||
}
|
||
} catch (e) {}
|
||
}
|
||
// ── prospects: the member's own follow-up list ──
|
||
let PP_STATUSES = ['new', 'contacted', 'interested', 'joined', 'bought', 'not now'];
|
||
// ── Pipeline: the follow-up board (Marty, 2026-09-15). Stages come from the server; cards never move by hand ──
|
||
let PIPE = null, PIPE_KEY = null;
|
||
const pipeAgo = ts => { const d = Math.floor((Date.now() - ts) / 86400000); return d <= 0 ? 'today' : d === 1 ? 'yesterday' : d < 30 ? d + 'd ago' : new Date(ts).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); };
|
||
const pipeInvite = () => { const t = ($('inviteLine') && $('inviteLine').textContent) || ''; return /^https?:\/\//.test(t) ? t : ''; };
|
||
function pipeCardHtml(c, showStage) {
|
||
const today = new Date(); today.setHours(23, 59, 59, 999);
|
||
const due = c.followUp && c.followUp <= today.getTime();
|
||
const chips = [];
|
||
if (c.stalled) chips.push('<span class="chip warm">stalled ' + c.quietDays + 'd</span>');
|
||
if (due) chips.push('<span class="chip warm">follow up</span>');
|
||
else if (c.followUp) chips.push('<span class="chip flat">' + new Date(c.followUp).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) + '</span>');
|
||
if (c.tag) chips.push('<span class="chip flat">' + esc(c.tag) + '</span>');
|
||
if (c.kind === 'member' && c.buyers) chips.push('<span class="chip flat">' + c.buyers + ' buyer' + (c.buyers === 1 ? '' : 's') + '</span>');
|
||
const stage = showStage ? (PIPE.stages.find(s => s.key === c.stage) || {}).label || '' : '';
|
||
return '<div class="pipe-card' + (c.key === PIPE_KEY ? ' on' : '') + '" tabindex="0" data-pk="' + esc(c.key) + '">'
|
||
+ '<div class="nm"><span>' + esc(c.name) + '</span><small>' + (c.kind === 'prospect' ? 'prospect' : (c.lastSeen ? 'seen ' + pipeAgo(c.lastSeen) : 'never signed in')) + '</small></div>'
|
||
+ '<div class="sub">' + esc(stage ? stage + ' · ' + c.next : c.next) + '</div>'
|
||
+ (c.note ? '<div class="note" title="' + esc(c.note) + '">' + esc(c.note) + '</div>' : '')
|
||
+ (chips.length ? '<div>' + chips.join('') + '</div>' : '') + '</div>';
|
||
}
|
||
async function loadPipeline() {
|
||
try {
|
||
const r = await (await fetch('/api/my/pipeline')).json();
|
||
if (r.error) { IAP.status(r.error, 'bad'); return; }
|
||
if (!r.live) {
|
||
$('pipeSoon').hidden = false; $('pipeLive').hidden = true;
|
||
$('pipeSoonEta').textContent = r.eta ? 'Opens ' + r.eta + '. It is on the roadmap so you can see what is coming and when.' : 'It is on the roadmap so you can see what is coming and when.';
|
||
return;
|
||
}
|
||
PIPE = r; $('pipeSoon').hidden = true; $('pipeLive').hidden = false;
|
||
const badge = $('pipeBadge'); if (badge) { const n = r.counts.due + r.counts.stalled; badge.hidden = !n; badge.textContent = n > 9 ? '9+' : n; }
|
||
$('pipeDueCount').hidden = !r.due.length; $('pipeDueCount').textContent = r.due.length;
|
||
$('pipeDue').innerHTML = r.due.length ? '<div class="pipe-due">' + r.due.map(c => pipeCardHtml(c, true)).join('') + '</div>' : '<p class="muted small">Nothing due. Set a follow-up date on any card and it shows up here.</p>';
|
||
$('pipeSummary').textContent = r.counts.total ? r.counts.total + ' people on your board' + (r.counts.stalled ? ', ' + r.counts.stalled + ' stalled' : '') + '. Cards move on their own when something happens on the ledger; open one to add a note, a follow-up date or a tag.' : 'Nobody on your board yet. Add prospects on My line, and everyone who joins through your link appears here on their own.';
|
||
$('pipeBoard').innerHTML = r.columns.map(col => '<div class="pipe-col"><h4>' + esc(col.label) + '<span>' + col.cards.length + '</span></h4><p class="hint">' + esc(col.hint) + '</p>' + col.cards.map(c => pipeCardHtml(c, false)).join('') + '</div>').join('');
|
||
$('pipeLive').querySelectorAll('[data-pk]').forEach(el => {
|
||
el.addEventListener('click', () => openPipeCard(el.dataset.pk));
|
||
el.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openPipeCard(el.dataset.pk); } });
|
||
});
|
||
if (PIPE_KEY) { const c = allPipeCards().find(x => x.key === PIPE_KEY); if (c) fillPipeCard(c); else { PIPE_KEY = null; $('pipeCard').hidden = true; } }
|
||
} catch (e) { IAP.status('Could not load your pipeline.', 'bad'); }
|
||
}
|
||
function allPipeCards() { return PIPE ? PIPE.columns.flatMap(c => c.cards) : []; }
|
||
function fillPipeCard(c) {
|
||
$('pipeCardName').textContent = c.name;
|
||
const stage = (PIPE.stages.find(s => s.key === c.stage) || {}).label || '';
|
||
$('pipeCardMeta').textContent = stage + (c.kind === 'member' ? ' · joined ' + new Date(c.since).toLocaleDateString() + (c.lastSeen ? ' · last seen ' + pipeAgo(c.lastSeen) : ' · never signed in') + (c.bought ? ' · your qualifying buyer' : '') + (c.buyersPositions ? ' · ' + c.buyers + ' buyers: ' + c.buyersMain + ' on the main wallet, ' + c.buyersPositions + ' on linked positions' : '') : ' · prospect' + (c.contact ? ' · ' + c.contact : ''));
|
||
$('pipeCardNext').innerHTML = '<b>Next for them:</b> ' + esc(c.next) + (c.stalled ? ' <span class="chip warm">quiet ' + c.quietDays + ' days</span>' : '');
|
||
const say = (c.say || '').replace('{{link}}', pipeInvite());
|
||
$('pipeSayBlock').hidden = !say; $('pipeSay').textContent = say;
|
||
$('pipeSend').hidden = c.kind !== 'member' || !c.email;
|
||
$('pipeFollow').value = c.followUp ? new Date(c.followUp).toISOString().slice(0, 10) : '';
|
||
const sel = $('pipeTag'); sel.innerHTML = PIPE.tags.map(t => '<option value="' + t + '"' + (t === c.tag ? ' selected' : '') + '>' + (t || 'no tag') + '</option>').join('');
|
||
$('pipeNote').value = c.note || ''; $('pipeSaved').textContent = '';
|
||
$('pipeCard').hidden = false;
|
||
}
|
||
function openPipeCard(key) {
|
||
const c = allPipeCards().find(x => x.key === key); if (!c) return;
|
||
PIPE_KEY = key; $('pipeLive').querySelectorAll('[data-pk]').forEach(el => el.classList.toggle('on', el.dataset.pk === key));
|
||
fillPipeCard(c); $('pipeCard').scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||
}
|
||
if ($('pipeCardClose')) $('pipeCardClose').addEventListener('click', () => { PIPE_KEY = null; $('pipeCard').hidden = true; $('pipeLive').querySelectorAll('[data-pk]').forEach(el => el.classList.remove('on')); });
|
||
if ($('pipeSave')) $('pipeSave').addEventListener('click', async () => {
|
||
if (!PIPE_KEY) return;
|
||
try { await api('/api/my/pipeline/note', { key: PIPE_KEY, note: $('pipeNote').value, followUp: $('pipeFollow').value || null, tag: $('pipeTag').value }); $('pipeSaved').textContent = 'Saved.'; loadPipeline(); }
|
||
catch (e) { IAP.status(e.message, 'bad'); }
|
||
});
|
||
if ($('pipeCopy')) $('pipeCopy').addEventListener('click', async () => { try { await navigator.clipboard.writeText($('pipeSay').textContent); $('pipeCopy').textContent = 'Copied'; setTimeout(() => { $('pipeCopy').textContent = 'Copy message'; }, 1500); } catch (e) { IAP.status('Copy failed; select the text by hand.', 'bad'); } });
|
||
if ($('pipeSend')) $('pipeSend').addEventListener('click', () => {
|
||
const c = allPipeCards().find(x => x.key === PIPE_KEY); if (!c || !c.email) return;
|
||
openConvo(c.email, c.name);
|
||
const ta = $('chatInput'); if (ta) { ta.value = $('pipeSay').textContent; ta.dispatchEvent(new Event('input')); ta.focus(); }
|
||
});
|
||
async function loadProspects() {
|
||
try {
|
||
const r = await (await fetch('/api/my/prospects')).json();
|
||
if (r.error) return;
|
||
PP_STATUSES = r.statuses || PP_STATUSES;
|
||
const sel = $('ppStatus'); if (sel && !sel.options.length) sel.innerHTML = PP_STATUSES.map(s => '<option value="' + s + '">' + s + '</option>').join('');
|
||
const list = r.prospects || []; const el = $('prospectList'); if (!el) return;
|
||
if (!list.length) { el.innerHTML = '<p class="muted small">Nobody on the list yet.</p>'; return; }
|
||
const today = new Date(); today.setHours(0, 0, 0, 0);
|
||
el.innerHTML = list.map(p => { const due = p.nextTs && p.nextTs <= today.getTime() + 86399999; return '<div class="lin-row' + (due ? ' own' : '') + '" data-pid="' + p.id + '"><span class="nm">' + esc(p.name) + (due ? ' <span class="badge amber">follow up</span>' : '') + '</span>'
|
||
+ '<span class="em">' + esc(p.contact || '') + (p.note ? ' · ' + esc(p.note) : '') + '</span>'
|
||
+ '<select class="small" data-pstatus="' + p.id + '">' + PP_STATUSES.map(s => '<option' + (s === p.status ? ' selected' : '') + '>' + s + '</option>').join('') + '</select>'
|
||
+ '<input type="date" class="small" data-pnext="' + p.id + '" value="' + (p.nextTs ? new Date(p.nextTs).toISOString().slice(0, 10) : '') + '">'
|
||
+ '<button class="btn sec small" type="button" data-pdel="' + p.id + '">Remove</button></div>'; }).join('');
|
||
const save = async (id, patch) => { const p = list.find(x => x.id === Number(id)); if (!p) return; try { await api('/api/my/prospects', Object.assign({}, p, patch)); } catch (e) { IAP.status(e.message, 'bad'); } };
|
||
el.querySelectorAll('[data-pstatus]').forEach(s => s.addEventListener('change', () => save(s.dataset.pstatus, { status: s.value })));
|
||
el.querySelectorAll('[data-pnext]').forEach(i => i.addEventListener('change', () => save(i.dataset.pnext, { next: i.value, nextTs: i.value ? Date.parse(i.value + 'T12:00:00') : null })));
|
||
el.querySelectorAll('[data-pdel]').forEach(b => b.addEventListener('click', async () => { try { await api('/api/my/prospects/remove', { id: b.dataset.pdel }); loadProspects(); } catch (e) { IAP.status(e.message, 'bad'); } }));
|
||
} catch (e) {}
|
||
}
|
||
if ($('prospectForm')) $('prospectForm').addEventListener('submit', async e => {
|
||
e.preventDefault();
|
||
try {
|
||
await api('/api/my/prospects', { name: $('ppName').value, contact: $('ppContact').value, status: $('ppStatus').value, nextTs: $('ppNext').value ? Date.parse($('ppNext').value + 'T12:00:00') : null });
|
||
$('ppName').value = ''; $('ppContact').value = ''; $('ppNext').value = ''; loadProspects();
|
||
} catch (err) { IAP.status(err.message, 'bad'); }
|
||
});
|
||
// ── broadcast templates ──
|
||
const BC_TEMPLATES = [
|
||
{ label: 'Welcome', subject: 'Welcome to my line: your first three moves', html: '<p>Glad you are in. Three things today, in this order:</p><ol><li>Pick your username on the Profile tab (it becomes your link).</li><li>Wallet tab: Connect and link wallet, then Switch on payouts. Both are free.</li><li>Copy your invite link from My line and send it to one person.</li></ol><p>Reply here if you get stuck on any of them. That is what I am here for.</p>' },
|
||
{ label: 'Switch on payouts', subject: 'One free step so nothing passes you by', html: '<p>Quick reminder: if payouts are not switched on yet, do it now on the Wallet tab. One free transaction.</p><p>The contract locks each buyer to their sponsor at their first purchase, and payouts only route to wallets that are switched on. Ready early and you never miss one.</p>' },
|
||
{ label: 'The $5 test', subject: 'See a payout land in real time', html: '<p>Want to see the whole thing work? Buy the $5 Micro package on Buy packages and watch the live ledger while you do it. You will see your credits mint and the split go out in the same transaction.</p><p>When you are ready to count as a qualifying buyer for me, the $20 Activation package is the one.</p>' },
|
||
{ label: 'Qualified Start', subject: 'How to open level 2 today with your own positions', html: '<p>You can be your own first buyers, openly. On Buy packages, the Qualified Start card lets you link a second wallet you own as a position. When it buys a $20 package, it counts as a qualifying buyer, half comes straight back to your main wallet, and the credits pool with yours.</p><p>Two positions open level 2 the same day. The three Qualified Start videos in Training show every click.</p>' },
|
||
{ label: 'Share your link', subject: 'One conversation a day is the whole job', html: '<p>Promo tools has posts, texts and emails that already carry your link. Pick one and send it to one person today.</p><p>Do not wait for the perfect moment. Nobody who waited ever built a line.</p>' }
|
||
];
|
||
(function () {
|
||
const w = $('bcTemplates'); if (!w) return;
|
||
w.innerHTML = BC_TEMPLATES.map((t, i) => '<button type="button" class="chip-t" data-bct="' + i + '">' + esc(t.label) + '</button>').join('');
|
||
w.querySelectorAll('[data-bct]').forEach(b => b.addEventListener('click', () => { const t = BC_TEMPLATES[Number(b.dataset.bct)]; $('bcSubject').value = t.subject; $('bcEd').innerHTML = t.html; $('bcEd').focus(); }));
|
||
})();
|
||
// ── Qualified Start calculator ──
|
||
(function () {
|
||
const n = $('qcN'), pk = $('qcPkg'), out = $('qcOut'); if (!n || !pk || !out) return;
|
||
const CR = { 20: 2000, 50: 5500, 100: 12000, 250: 32500 };
|
||
const calc = () => {
|
||
const k = Math.max(1, Math.min(10, Number(n.value) || 1)), usd = Number(pk.value) || 20;
|
||
const gross = k * usd, back = gross / 2, net = gross - back, credits = k * (CR[usd] || 0);
|
||
const level = k >= 5 ? 'Level 3 open (and level 2): the Nexus badge, wall position 3, full 50 / 20 / 10' : k >= 2 ? 'Level 2 open: 20% on your directs\' buyers, wall position 2' : 'Counts as one qualifying buyer. One more opens level 2.';
|
||
out.innerHTML = '<div class="grid c2"><div><b>$' + gross + '</b> out across ' + k + ' position' + (k === 1 ? '' : 's') + '<br><b>$' + back + '</b> back to your main wallet in the same transactions (the 50% direct-sponsor share)<br><b>$' + net + '</b> net, plus a little POL for gas in each wallet</div>'
|
||
+ '<div><b>' + credits.toLocaleString() + ' credits</b> pooled for your own ads<br>' + level + '<br><span class="muted">The 20% and 10% shares go to your upline if they are qualified, otherwise to the platform.</span></div></div>';
|
||
};
|
||
n.addEventListener('input', calc); pk.addEventListener('change', calc); calc();
|
||
})();
|
||
|
||
// ── downline lineage + sponsor broadcast + upline messages ──
|
||
async function loadLineage() {
|
||
try {
|
||
const r = await (await fetch('/api/my/line')).json();
|
||
const el = $('lineageWrap');
|
||
if (r.error || !r.levels || !r.levels.every) return;
|
||
if (!r.levels.length || !r.levels.some(L => L.members.length)) {
|
||
el.innerHTML = '<p class="muted small">No one in your downline yet. Share your link and it fills in here.</p>';
|
||
return;
|
||
}
|
||
el.innerHTML = r.levels.map(L => !L.members.length ? '' :
|
||
'<div class="lin-lvl"><div class="cap">Level ' + L.level + ' · ' + L.members.length + (L.level === 1 ? ' direct' : '') + '</div>'
|
||
+ L.members.map(m => '<div class="lin-row' + (m.own ? ' own' : '') + '"><span class="nm">' + esc(m.name) + (m.own ? ' <span class="badge">yours</span>' : '') + '</span>'
|
||
+ '<span class="em">' + (m.email ? esc(m.email) : (m.memberId ? '<span class="id">#' + m.memberId + '</span>' : '')) + (m.sponsor ? '<span class="sp">sponsored by ' + esc(m.sponsor) + '</span>' : '') + (m.own ? '' : '<span class="sp">' + (m.bought ? '$20+ buy ✓' : 'no $20+ buy yet') + (m.buyers ? ' · ' + m.buyers + ' qualifying buyer' + (m.buyers === 1 ? '' : 's') + ' of their own' : '') + '</span>') + '</span>'
|
||
+ '<span class="earn' + (m.earnedWei && m.earnedWei !== '0' ? ' on' : '') + '" title="POL this person has paid you so far">'
|
||
+ (m.earnedWei && m.earnedWei !== '0' ? '+' + IAP.fmtPol(m.earnedWei) + ' POL' : '0.00 POL') + '</span>'
|
||
+ '<span class="dt">' + new Date(m.joined).toLocaleDateString() + '</span>'
|
||
+ (m.email ? '<button class="btn sec small chat-msg-btn" type="button" data-cemail="' + esc(m.email) + '" data-cname="' + esc(m.name) + '">Message</button>' : '')
|
||
+ (m.ref && !m.own ? '<button class="btn sec small lin-act-btn" type="button" data-act="' + esc(m.ref) + '" aria-expanded="false" title="Activity">Activity ▾</button>' : '')
|
||
+ '</div>' + (m.ref && !m.own ? '<div class="lin-act" data-actbox="' + esc(m.ref) + '" hidden></div>' : '')).join('')
|
||
+ '</div>').join('');
|
||
el.querySelectorAll('[data-cemail]').forEach(b =>
|
||
b.addEventListener('click', () => openConvo(b.dataset.cemail, b.dataset.cname)));
|
||
el.querySelectorAll('[data-act]').forEach(b => b.addEventListener('click', () => toggleLineActivity(b)));
|
||
} catch (e) {}
|
||
}
|
||
// who is working: per-member activity drop-down under the row (any of the three levels)
|
||
const lineActCache = {};
|
||
async function toggleLineActivity(btn) {
|
||
const box = document.querySelector('[data-actbox="' + btn.dataset.act + '"]'); if (!box) return;
|
||
const open = box.hidden; box.hidden = !open; btn.setAttribute('aria-expanded', String(open)); btn.textContent = open ? 'Activity ▴' : 'Activity ▾';
|
||
if (!open) return;
|
||
if (!lineActCache[btn.dataset.act]) {
|
||
box.innerHTML = '<span class="muted small">Loading…</span>';
|
||
try { lineActCache[btn.dataset.act] = await (await fetch('/api/my/line/activity?ref=' + encodeURIComponent(btn.dataset.act))).json(); } catch (e) { lineActCache[btn.dataset.act] = { error: 'Could not load.' }; }
|
||
}
|
||
const a = lineActCache[btn.dataset.act];
|
||
if (a.error) { box.innerHTML = '<span class="muted small">' + esc(a.error) + '</span>'; return; }
|
||
const ago = t => { if (!t) return 'never'; const d = Date.now() - t; if (d < 3600e3) return Math.max(1, Math.round(d / 60e3)) + ' min ago'; if (d < 86400e3) return Math.round(d / 3600e3) + 'h ago'; return Math.round(d / 86400e3) + 'd ago'; };
|
||
const chip = (label, val, cls) => '<span class="lin-chip' + (cls ? ' ' + cls : '') + '"><b>' + esc(label) + '</b>' + esc(val) + '</span>';
|
||
const working = (a.viewsToday || 0) > 0 || (a.campaignsActive || 0) > 0 || (a.linkViews30 || 0) > 0 || (a.lastSeen && Date.now() - a.lastSeen < 2 * 86400e3);
|
||
box.innerHTML = chip('Last seen', ago(a.lastSeen), a.lastSeen && Date.now() - a.lastSeen < 2 * 86400e3 ? 'on' : (a.quietDays >= 3 ? 'warn' : ''))
|
||
+ chip('Joined', new Date(a.joined).toLocaleDateString())
|
||
+ chip('Stage', (a.rung || 'Joined') + (a.next ? ' · next: ' + a.next : ''), a.stalled ? 'warn' : '')
|
||
+ chip('Ads today', (a.viewsToday || 0) + '/' + (a.target || 5) + (a.claimedToday ? ' claimed' : '') + (a.streakDay ? ' · streak day ' + a.streakDay : ''), (a.viewsToday || 0) > 0 ? 'on' : '')
|
||
+ chip('Campaigns', (a.campaignsActive || 0) + ' active of ' + (a.campaigns || 0) + (a.imps ? ' · ' + a.imps.toLocaleString() + ' views' : ''), (a.campaignsActive || 0) > 0 ? 'on' : '')
|
||
+ chip('Link', (a.linkViews30 || 0) + ' views (30d) · ' + (a.joins || 0) + ' joined', (a.linkViews30 || 0) > 0 ? 'on' : '')
|
||
+ chip('Line', (a.directs || 0) + ' direct' + (a.directs === 1 ? '' : 's') + ' · ' + (a.buyerCount || 0) + ' qualifying', (a.buyerCount || 0) > 0 ? 'on' : '')
|
||
+ chip('Wallet', a.wallet ? (a.badges && a.badges.includes('payouts') ? 'linked · payouts on' : 'linked') : 'not linked', a.wallet ? '' : 'warn')
|
||
+ (a.badges && a.badges.length ? chip('Badges', a.badges.map(b => ({ payouts: 'Spark', firstBuyer: 'Surge', level2: 'Circuit', level3: 'Nexus' }[b] || b)).join(' · ')) : '')
|
||
+ '<span class="lin-verdict' + (working ? ' on' : '') + '">' + (working ? 'Working' : 'Quiet') + '</span>';
|
||
}
|
||
async function loadUplineMessages() {
|
||
try {
|
||
const r = await (await fetch('/api/my/messages')).json();
|
||
if (r.error) return;
|
||
const card = $('upMsgCard'), list = $('upList');
|
||
if (!r.items || !r.items.length) { card.hidden = true; return; }
|
||
card.hidden = false;
|
||
list.innerHTML = r.items.map(i => '<div class="promo-block" style="margin-bottom:10px">'
|
||
+ '<div style="display:flex;justify-content:space-between;gap:10px"><b>' + esc(i.subject) + '</b>'
|
||
+ '<span class="small muted">' + esc(i.fromName) + ' · ' + new Date(i.sent).toLocaleDateString() + '</span></div>'
|
||
+ '<div class="ib-rich" style="margin-top:8px">' + (i.body || '') + '</div></div>').join('');
|
||
// opening the pane marks them read
|
||
for (const i of r.items) if (!i.read) fetch('/api/my/messages/' + i.id + '/read', { method: 'POST' }).catch(() => {});
|
||
} catch (e) {}
|
||
}
|
||
// broadcast composer editor (its own small rich editor, server sanitizes)
|
||
document.querySelectorAll('[data-bc]').forEach(b =>
|
||
b.addEventListener('click', () => { $('bcEd').focus(); document.execCommand(b.dataset.bc, false, null); }));
|
||
document.querySelectorAll('[data-bcblock]').forEach(b =>
|
||
b.addEventListener('click', () => { $('bcEd').focus(); document.execCommand('formatBlock', false, b.dataset.bcblock); }));
|
||
if ($('bcLinkBtn')) $('bcLinkBtn').addEventListener('click', async () => {
|
||
const sel = window.getSelection(); const range = sel && sel.rangeCount ? sel.getRangeAt(0).cloneRange() : null;
|
||
const u = await IAP.ask({ title: 'Insert link', text: 'Link URL (https://…)', placeholder: 'https://', ok: 'Insert' });
|
||
if (u) { $('bcEd').focus(); if (range) { sel.removeAllRanges(); sel.addRange(range); } document.execCommand('createLink', false, u); }
|
||
});
|
||
if ($('bcSendBtn')) $('bcSendBtn').addEventListener('click', busy2($('bcSendBtn'), async () => {
|
||
const r = await api('/api/my/broadcast', { scope: $('bcScope').value, subject: $('bcSubject').value, body: $('bcEd').innerHTML });
|
||
IAP.status('Broadcast sent to ' + r.sent + ' member' + (r.sent === 1 ? '' : 's') + '.', 'ok');
|
||
$('bcSubject').value = ''; $('bcEd').innerHTML = '';
|
||
$('bcHint').textContent = 'Sent. You can send your next broadcast in 24 hours.';
|
||
}));
|
||
|
||
// ── solo-ads inbox: list, read view, dwell-gated read reward ──
|
||
let ibTimer = null;
|
||
function setInboxBadge(n) {
|
||
for (const id of ['inboxBadge', 'inboxBadge2']) {
|
||
const b = $(id);
|
||
if (b) { b.hidden = !n; b.textContent = n; }
|
||
}
|
||
}
|
||
// Earn credits sub-tabs: Watch ads | Inbox
|
||
let earnSub = 'watch';
|
||
function setEarnSub(which) {
|
||
earnSub = ['inbox', 'videos', 'visits'].includes(which) ? which : 'watch';
|
||
if (earnSub !== 'videos' && vidState.token) stopVideo();
|
||
const w = $('earn-watch'), i = $('earn-inbox'), v = $('earn-videos'), vs = $('earn-visits');
|
||
if (w) w.hidden = earnSub !== 'watch';
|
||
if (i) i.hidden = earnSub !== 'inbox';
|
||
if (v) v.hidden = earnSub !== 'videos';
|
||
if (vs) vs.hidden = earnSub !== 'visits';
|
||
document.querySelectorAll('.subtabs [data-earn]').forEach(b =>
|
||
b.classList.toggle('on', b.dataset.earn === earnSub));
|
||
if (earnSub === 'inbox') loadInbox();
|
||
else if (earnSub === 'videos') loadVideoStatus();
|
||
else if (earnSub === 'visits') loadVisitStatus();
|
||
else earnRefresh();
|
||
}
|
||
document.querySelectorAll('.subtabs [data-earn]').forEach(b =>
|
||
b.addEventListener('click', () => setEarnSub(b.dataset.earn)));
|
||
// promo toolkit: badge tiers + the AI Copy Engine (Marty, 2026-09-14)
|
||
let tkState = null;
|
||
async function loadToolkit() {
|
||
try {
|
||
const r = await (await fetch('/api/my/toolkit')).json(); if (r.error) return; tkState = r;
|
||
const esc = t => String(t == null ? '' : t).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
||
const names = { free: 'Free', spark: 'Spark', surge: 'Surge', circuit: 'Circuit', nexus: 'Nexus' };
|
||
$('tkTierLine').textContent = 'you are at ' + names[r.tier];
|
||
$('tkTiers').innerHTML = r.tiers.map(t => '<div class="tk-tier' + (t.reached ? ' reached' : '') + (t.key === r.tier ? ' current' : '') + '"><b>' + esc(t.name) + (t.reached ? ' ✓' : '') + '</b><div class="need">' + (t.reached ? esc(t.blurb) : 'Unlocks when you ' + esc(t.needText)) + '</div><ul>' + t.items.map(i => '<li' + (i.live ? '' : ' class="soon"') + '>' + esc(i.t) + '</li>').join('') + '</ul>' + (t.reached ? '' : '<div class="lock" style="margin-top:8px">locked</div>') + '</div>').join('');
|
||
$('tkLocked').hidden = r.unlocked; $('tkForm').hidden = !r.unlocked;
|
||
if (!r.unlocked) { $('tkLocked').textContent = 'Unlocks at Surge: your first qualifying buyer of a $20 or larger package. Then it writes posts, DMs, follow-ups, objection replies, emails and story posts in your name, with your link, inside the honesty rules. ' + (r.tier === 'free' ? 'Step one is switching on payouts.' : 'You are one buyer away.'); $('tkUsage').textContent = 'locked'; renderTkTools(r); return; }
|
||
$('tkUsage').textContent = r.freeLeft + ' free this month · then ' + r.cost + ' credits each · you have ' + r.available.toLocaleString() + ' credits';
|
||
$('tkCostLine').textContent = r.freeLeft > 0 ? 'This one is free (' + r.freeLeft + ' left this month).' : 'This one costs ' + r.cost + ' credits from your earned pool.';
|
||
if (!$('tkKind').options.length) { $('tkKind').innerHTML = r.kinds.map(k => '<option value="' + k.key + '">' + esc(k.label) + '</option>').join(''); $('tkAngle').innerHTML = r.angles.map(a => '<option value="' + a + '">' + a + '</option>').join(''); }
|
||
renderTkTools(r);
|
||
$('tkHistory').innerHTML = r.history.length ? '<p class="small muted" style="margin:0 0 4px">Recent</p>' + r.history.map(h => '<div class="tk-hist"><span class="muted">' + esc(h.kind) + ' · ' + new Date(h.ts).toLocaleString() + (h.charged ? ' · ' + h.charged + ' credits' : ' · free') + '</span><br>' + esc(h.text).replace(/\n/g, '<br>') + '</div>').join('') : '';
|
||
} catch (e) { console.error('toolkit', e); }
|
||
}
|
||
// the rest of the ladder: Spark templates + handout, Circuit Video Maker + split tester, Nexus Leader Ops
|
||
const tkEsc = t => String(t == null ? '' : t).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
||
const tkRank = { free: 0, spark: 1, surge: 2, circuit: 3, nexus: 4 };
|
||
async function tkPost(url, body) { const r = await (await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body || {}) })).json(); if (r.error) { IAP.status(r.error, 'bad'); return null; } return r; }
|
||
function renderTkTools(r) {
|
||
const at = tkRank[r.tier] || 0;
|
||
// Spark
|
||
const spark = at >= 1; $('tkSparkLocked').hidden = spark; $('tkSparkBody').hidden = !spark;
|
||
if (!spark) $('tkSparkLocked').textContent = 'Unlocks at Spark: switch on payouts in the Wallet tab. Then one tap builds a banner or text campaign aimed at your link, and you get a printable handout with your QR.';
|
||
else { $('tkHandoutLine').hidden = !r.handoutUrl; if (r.handoutUrl) $('tkHandout').href = r.handoutUrl; }
|
||
// Circuit
|
||
const circ = at >= 3; $('tkCircuitLocked').hidden = circ; $('tkCircuitBody').hidden = !circ;
|
||
if (!circ) $('tkCircuitLocked').textContent = 'Unlocks at Circuit: two qualifying buyers in your line. Then every promo video gets your own end card and QR, and the split tester shows which join angle pulls for you.';
|
||
else { loadTkVideos(r); loadTkSplit(); }
|
||
// Nexus
|
||
const nex = at >= 4; $('tkNexusLocked').hidden = nex; $('tkNexusBody').hidden = !nex;
|
||
if (!nex) $('tkNexusLocked').textContent = 'Unlocks at Nexus: five qualifying buyers. Then you get team triage across three levels with one-click nudges, AI-drafted team broadcasts, credit grants to your people, a co-branded join page and your own partner code.';
|
||
else { loadTkTeam(); renderTkPartner(r); }
|
||
}
|
||
let tkVidTimer = null;
|
||
async function loadTkVideos(r) {
|
||
const box = $('tkVideos');
|
||
if (!r.videoMaker) { box.innerHTML = '<p class="muted small">The Video Maker is warming up on the server. Check back shortly.</p>'; return; }
|
||
if (!r.username) { box.innerHTML = '<p class="muted small">Pick a username first (Profile); it goes on the end card.</p>'; return; }
|
||
try {
|
||
const v = await (await fetch('/api/my/toolkit/videos')).json(); if (v.error) return;
|
||
box.innerHTML = v.list.map(x => '<div class="tk-vid"><b>' + tkEsc(x.title) + '</b>' + (x.status === 'done' ? '<a class="btn small" href="' + tkEsc(x.url) + '" target="_blank" rel="noopener" download>Download</a> <button type="button" class="btn small sec" data-copyvid="' + tkEsc(x.url) + '">Copy link</button>' : x.status === 'queued' || x.status === 'working' ? '<div class="tk-bar"><i style="width:' + (x.pct || 0) + '%"></i></div><span class="st">' + tkEsc(x.stage || 'Starting') + (x.status === 'working' ? ' · ' + (x.pct || 0) + '%' : '') + '</span>' : '<button type="button" class="btn small sec" data-mkvid="' + tkEsc(x.slug) + '">Make mine</button>') + '</div>').join('');
|
||
clearTimeout(tkVidTimer); if (v.list.some(x => x.status === 'queued' || x.status === 'working')) tkVidTimer = setTimeout(() => loadTkVideos(r), 2500);
|
||
} catch (e) {}
|
||
}
|
||
async function loadTkSplit() {
|
||
try {
|
||
const sp = await (await fetch('/api/my/toolkit/split')).json(); if (sp.error) return;
|
||
const rows = sp.rows.filter(x => x.views || x.joins);
|
||
$('tkSplit').innerHTML = !rows.length ? '<p class="muted small">No link views yet. Share two different angle links this week and the winner shows up here.</p>'
|
||
: '<div style="overflow-x:auto"><table class="tk-table"><tr><th>Angle</th><th class="n">Views</th><th class="n">Last 30d</th><th class="n">Joins</th><th class="n">Buyers</th><th class="n">Join rate</th></tr>' + rows.map(x => '<tr' + (x.angle === sp.best ? ' class="best"' : '') + '><td>' + tkEsc(x.angle) + (x.angle === sp.best ? ' (winner so far)' : '') + '<br><span class="muted" style="font-size:11px">' + tkEsc(x.link) + '</span></td><td class="n">' + x.views + '</td><td class="n">' + x.views30 + '</td><td class="n">' + x.joins + '</td><td class="n">' + x.buyers + '</td><td class="n">' + x.rate + '%</td></tr>').join('') + '</table></div><p class="muted small" style="margin:6px 0 0">Rate = joins per 100 views. A winner needs at least 10 views. Send more traffic to the winner, keep testing the rest.</p>';
|
||
} catch (e) {}
|
||
}
|
||
let tkTeamData = null;
|
||
async function loadTkTeam() {
|
||
try {
|
||
const t = await (await fetch('/api/my/toolkit/team')).json(); if (t.error || !t.unlocked) return; tkTeamData = t;
|
||
$('tkTeamSub').textContent = t.total + ' in three levels (' + t.byLevel.join(' / ') + ') · ' + t.recent + ' new this week · ' + t.stalled.length + ' stalled';
|
||
const list = t.stalled.length ? t.stalled : t.members.slice(0, 12);
|
||
$('tkTeam').innerHTML = (t.stalled.length ? '<p class="small" style="margin:0 0 6px">Stalled first: quiet for a while and not yet past the next rung. One click sends them the right message from you.</p>' : '<p class="small muted" style="margin:0 0 6px">Nobody stalled right now. Your newest people:</p>')
|
||
+ list.map(m => '<div class="tk-team' + (m.stalled ? ' stalled' : '') + '"><div class="who"><b>' + tkEsc(m.name) + '</b> <span>L' + m.level + ' · ' + tkEsc(m.label) + (m.quietDays ? ' · quiet ' + m.quietDays + 'd' : '') + '</span><br><span>Next: ' + tkEsc(m.next) + '</span></div><button type="button" class="btn small sec" data-nudge="' + tkEsc(m.email) + '" data-say="' + tkEsc(m.say) + '">Nudge</button></div>').join('');
|
||
$('tkGrantTo').innerHTML = t.members.map(m => '<option value="' + tkEsc(m.email) + '">' + tkEsc(m.name) + ' (L' + m.level + ')</option>').join('');
|
||
} catch (e) {}
|
||
}
|
||
function renderTkPartner(r) {
|
||
const pk = r.partner;
|
||
$('tkPartner').innerHTML = pk
|
||
? '<p class="small" style="margin:0 0 6px">Code <b>' + tkEsc(pk.code) + '</b> gives ' + pk.credits + ' welcome credits, funded from your earned pool at each redemption. Redeemed ' + pk.uses + ' times.</p><p class="small" style="margin:0 0 4px">Invite link with the code: <code>' + tkEsc(pk.link) + '</code> <button type="button" class="btn small sec" data-copy="' + tkEsc(pk.link) + '">Copy</button></p><p class="small" style="margin:0 0 8px">Your partner kit page (for list owners and site owners you talk to): <code>' + tkEsc(pk.kit) + '</code> <button type="button" class="btn small sec" data-copy="' + tkEsc(pk.kit) + '">Copy</button></p><div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center"><label class="small">Welcome credits <input type="number" id="tkPcN" value="' + pk.credits + '" min="50" max="500" style="width:80px"></label><button type="button" class="btn small sec" id="tkPcGo" data-code="' + tkEsc(pk.code) + '">Update</button></div>'
|
||
: '<p class="small muted" style="margin:0 0 6px">Give your recruits a welcome bonus under your own code. Each redemption moves the credits from your earned pool to theirs, so it only costs you when it works. Then hand partners the kit page, which sends their deals under you.</p><div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center"><input id="tkPcCode" placeholder="CODE (letters, numbers)" maxlength="24" style="width:170px;text-transform:uppercase"><label class="small">Welcome credits <input type="number" id="tkPcN" value="100" min="50" max="500" style="width:80px"></label><button type="button" class="btn small" id="tkPcGo">Create my code</button></div>';
|
||
}
|
||
document.addEventListener('click', async ev => {
|
||
const b = ev.target.closest('[data-tpl],[data-mkvid],[data-copyvid],[data-nudge],[data-copy],#tkPcGo,#tkGrantGo,#tkDraftBc,[data-sendnudge]'); if (!b) return;
|
||
if (b.dataset.tpl) { b.disabled = true; const r = await tkPost('/api/my/toolkit/template', { kind: b.dataset.tpl, budget: Number($('tkTplBudget').value) }); b.disabled = false; if (r) { IAP.status('Campaign "' + r.name + '" created with ' + r.budget + ' credits. It is under Campaigns.', 'ok'); loadDashboard(); } return; }
|
||
if (b.dataset.mkvid) { b.disabled = true; const r = await tkPost('/api/my/toolkit/video', { slug: b.dataset.mkvid }); if (r) { IAP.status(r.status === 'done' ? 'Already made.' : 'Rendering. It appears here in about a minute.', 'ok'); loadTkVideos(tkState); } else b.disabled = false; return; }
|
||
if (b.dataset.copyvid || b.dataset.copy) { try { await navigator.clipboard.writeText(b.dataset.copyvid || b.dataset.copy); IAP.status('Copied.', 'ok'); } catch (e) { IAP.status('Copy failed; select it by hand.', 'bad'); } return; }
|
||
if (b.dataset.nudge) { const row = b.closest('.tk-team'); const old = row.querySelector('.tk-nudge'); if (old) { old.remove(); return; } const box = document.createElement('div'); box.className = 'tk-nudge'; box.innerHTML = '<textarea rows="3" style="width:100%" maxlength="600"></textarea><div style="display:flex;gap:8px;margin-top:6px"><button type="button" class="btn small" data-sendnudge="' + tkEsc(b.dataset.nudge) + '">Send from me</button><span class="muted small">Lands in their inbox and their email.</span></div>'; box.querySelector('textarea').value = b.dataset.say; row.appendChild(box); return; }
|
||
if (b.dataset.sendnudge) { const box = b.closest('.tk-nudge'); b.disabled = true; const r = await tkPost('/api/my/toolkit/nudge', { email: b.dataset.sendnudge, text: box.querySelector('textarea').value }); b.disabled = false; if (r) { IAP.status('Sent.', 'ok'); box.remove(); } return; }
|
||
if (b.id === 'tkGrantGo') { b.disabled = true; const r = await tkPost('/api/my/toolkit/grant', { email: $('tkGrantTo').value, credits: Number($('tkGrantN').value) }); b.disabled = false; if (r) { IAP.status(r.credits + ' credits moved to ' + r.toName + '.', 'ok'); loadToolkit(); loadDashboard(); } return; }
|
||
if (b.id === 'tkPcGo') { b.disabled = true; const r = await tkPost('/api/my/toolkit/promo', { code: b.dataset.code || ($('tkPcCode') ? $('tkPcCode').value : ''), credits: Number($('tkPcN').value) }); b.disabled = false; if (r) { IAP.status('Code ' + r.code + ' is live: ' + r.credits + ' welcome credits.', 'ok'); loadToolkit(); } return; }
|
||
if (b.id === 'tkDraftBc') { if (!tkState || !tkState.unlocked) return; $('tkKind').value = 'broadcast'; $('tkBrief').placeholder = 'What should the team focus on this week?'; $('tkEngine').scrollIntoView({ behavior: 'smooth', block: 'start' }); $('tkBrief').focus(); IAP.status('Pick a focus, hit Write it, then Send to my team.', 'ok'); return; }
|
||
});
|
||
$('tkKind') && $('tkKind').addEventListener('change', () => { if ($('tkSendBc')) $('tkSendBc').hidden = $('tkKind').value !== 'broadcast'; });
|
||
async function tkSendBroadcast() {
|
||
const txt = $('tkText').value || ''; const m = /^\s*Subject:\s*(.+)\n+([\s\S]+)$/.exec(txt);
|
||
const subject = m ? m[1].trim() : 'A note from your sponsor', body = (m ? m[2] : txt).trim();
|
||
if (!(await IAP.confirmBox('Send this to everyone in your three levels? One broadcast a day.', { title: 'Team broadcast', ok: 'Send' }))) return;
|
||
const r = await tkPost('/api/my/broadcast', { subject, body: '<p>' + tkEsc(body).replace(/\n{2,}/g, '</p><p>').replace(/\n/g, '<br>') + '</p>', scope: 'all' });
|
||
if (r) IAP.status('Sent to ' + (r.sent || r.count || r.recipients || 'your team') + '.', 'ok');
|
||
}
|
||
async function tkGenerate() {
|
||
const btn = $('tkGo'); if (btn.disabled) return; btn.disabled = true; btn.textContent = 'Writing…';
|
||
try {
|
||
const r = await (await fetch('/api/my/toolkit/generate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ kind: $('tkKind').value, angle: $('tkAngle').value, brief: $('tkBrief').value }) })).json();
|
||
if (r.error) { IAP.status(r.error, 'bad'); return; }
|
||
$('tkText').value = r.text; $('tkOut').hidden = false; $('tkSendBc').hidden = $('tkKind').value !== 'broadcast';
|
||
IAP.status(r.charged ? r.charged + ' credits used.' : 'Written. ' + r.freeLeft + ' free left this month.', 'ok');
|
||
loadToolkit(); loadDashboard();
|
||
} catch (e) { IAP.status('The engine did not answer. Try again.', 'bad'); }
|
||
finally { btn.disabled = false; btn.textContent = 'Write it'; }
|
||
}
|
||
if ($('tkGo')) { $('tkGo').addEventListener('click', tkGenerate); $('tkAgain').addEventListener('click', tkGenerate); $('tkCopy').addEventListener('click', async () => { try { await navigator.clipboard.writeText($('tkText').value); IAP.status('Copied.', 'ok'); } catch (e) { $('tkText').select(); } }); $('tkSendBc').addEventListener('click', tkSendBroadcast); }
|
||
// promo tools: pill menu switches between posts / swipes / banners / wall / videos
|
||
function setPromoSub(name) {
|
||
const ids = ['toolkit', 'posts', 'text', 'swipe', 'banners', 'wall', 'objections', 'videos'];
|
||
if (name === 'toolkit') loadToolkit();
|
||
if (!ids.includes(name)) name = 'posts';
|
||
ids.forEach(id => { const el = $('promo-' + id); if (el) el.hidden = id !== name; });
|
||
document.querySelectorAll('.promo-pills [data-promo]').forEach(b => {
|
||
b.classList.toggle('on', b.dataset.promo === name);
|
||
b.setAttribute('aria-selected', b.dataset.promo === name ? 'true' : 'false');
|
||
});
|
||
try { localStorage.setItem('iap.promoSub', name); } catch (e) {}
|
||
}
|
||
document.querySelectorAll('.promo-pills [data-promo]').forEach(b =>
|
||
b.addEventListener('click', () => setPromoSub(b.dataset.promo)));
|
||
try { setPromoSub(localStorage.getItem('iap.promoSub') || 'posts'); } catch (e) { setPromoSub('posts'); }
|
||
async function loadInbox() {
|
||
try {
|
||
const r = await (await fetch('/api/my/inbox')).json();
|
||
if (r.error) return;
|
||
$('ibRewardNote').textContent = '+' + r.readCredits + ' credits per real read (up to '
|
||
+ r.readCap + ' rewarded reads a day)';
|
||
const el = $('ibList');
|
||
$('inboxReadCard').hidden = true;
|
||
$('inboxListCard').hidden = false;
|
||
setInboxBadge(r.unread);
|
||
if (!r.items.length) {
|
||
el.innerHTML = '<p class="muted small">No solo ads yet. When a member sends one, it lands here — and reading it pays.</p>';
|
||
return;
|
||
}
|
||
el.innerHTML = '';
|
||
for (const i of r.items) {
|
||
const d = document.createElement('div');
|
||
d.className = 'ib-row' + (i.read ? '' : ' unread');
|
||
d.innerHTML = '<span class="sub"></span><span class="from"></span>'
|
||
+ (i.rewarded ? '<span class="badge">claimed</span>' : i.read ? '' : '<span class="badge amber">new</span>')
|
||
+ '<span class="when">' + new Date(i.delivered).toLocaleDateString() + '</span>';
|
||
d.querySelector('.sub').textContent = i.subject || '(no subject)';
|
||
d.querySelector('.from').textContent = 'from ' + (i.fromName || 'a member');
|
||
d.addEventListener('click', () => openInboxItem(i.id));
|
||
el.appendChild(d);
|
||
}
|
||
} catch (e) {}
|
||
}
|
||
async function openInboxItem(id) {
|
||
try {
|
||
const r = await (await fetch('/api/my/inbox/' + id)).json();
|
||
if (r.error) { IAP.status(r.error, 'bad'); return; }
|
||
$('inboxListCard').hidden = true;
|
||
$('inboxReadCard').hidden = false;
|
||
$('ibSubject').textContent = r.subject || '(no subject)';
|
||
$('ibMeta').textContent = 'from ' + (r.fromName || 'a member') + ' · ' + new Date(r.delivered).toLocaleString();
|
||
$('ibBody').innerHTML = r.body || ''; // whitelist-sanitized on the server at submit
|
||
const mv = $('ibMedia');
|
||
mv.hidden = !r.mediaUrl;
|
||
mv.innerHTML = !r.mediaUrl ? ''
|
||
: r.mediaType === 'video'
|
||
? '<video src="' + r.mediaUrl + '" controls style="max-width:100%;border-radius:12px"></video>'
|
||
: '<img src="' + r.mediaUrl + '" alt="attachment" style="max-width:100%;border-radius:12px">';
|
||
// the read reward needs BOTH the dwell AND an actual click-through to the
|
||
// advertiser — the visit is what makes the ad worth the sender's credits
|
||
const visit = $('ibVisit');
|
||
visit.href = r.url;
|
||
visit.textContent = r.ctaLabel || 'Learn more';
|
||
visit.target = '_blank';
|
||
const btn = $('ibClaimBtn');
|
||
clearInterval(ibTimer);
|
||
if (r.rewarded) {
|
||
btn.hidden = true;
|
||
visit.classList.remove('cta-need');
|
||
$('ibHint').textContent = 'Read reward already claimed for this one.';
|
||
return;
|
||
}
|
||
let dwellDone = false;
|
||
let visited = !!r.visited;
|
||
btn.hidden = false;
|
||
btn.disabled = true;
|
||
visit.classList.toggle('cta-need', !visited);
|
||
let left = r.dwell;
|
||
const refresh = () => {
|
||
if (!dwellDone) { btn.textContent = 'Read it — claim in ' + left + 's'; return; }
|
||
if (!visited) { btn.textContent = 'Claim +' + r.reward + ' — visit the ad first'; btn.disabled = true; return; }
|
||
btn.textContent = 'Claim +' + r.reward + ' credits'; btn.disabled = false;
|
||
};
|
||
$('ibHint').textContent = 'Read the message, click through to the advertiser, then claim your credits.';
|
||
refresh();
|
||
// countdown pauses off-tab; the server separately enforces the dwell on its own clock
|
||
ibTimer = setInterval(() => {
|
||
if (document.visibilityState !== 'visible' || !document.hasFocus()) return;
|
||
left -= 1;
|
||
if (left > 0) { refresh(); return; }
|
||
clearInterval(ibTimer);
|
||
dwellDone = true;
|
||
refresh();
|
||
}, 1000);
|
||
// clicking the CTA records the visit (and counts the advertiser's click)
|
||
visit.onclick = async () => {
|
||
visited = true;
|
||
visit.classList.remove('cta-need');
|
||
try { await fetch('/api/my/inbox/' + id + '/visit', { method: 'POST' }); } catch (e2) {}
|
||
refresh();
|
||
};
|
||
btn.onclick = async () => {
|
||
try {
|
||
const c = await api('/api/my/inbox/' + id + '/claim');
|
||
IAP.status('+' + c.credited + ' credits for reading. They spend like any earned credits.', 'ok');
|
||
btn.hidden = true;
|
||
$('ibHint').textContent = 'Claimed. Head back for the next one.';
|
||
loadDashboard();
|
||
} catch (e2) { IAP.status(e2.message, 'bad'); }
|
||
};
|
||
} catch (e) {}
|
||
}
|
||
$('ibBack').addEventListener('click', ev => { ev.preventDefault(); clearInterval(ibTimer); loadInbox(); });
|
||
|
||
// ── promo tools: content + rendering live in promo.js (IAPPromo.fill) ──
|
||
function fillPromo(link, me) {
|
||
if (window.IAPPromo) IAPPromo.fill(link, me || {});
|
||
// banner kit
|
||
const bwrap = $('promoBanners');
|
||
if (bwrap && !bwrap.dataset.filled) {
|
||
bwrap.dataset.filled = '1';
|
||
// one collapsed accordion per size / use, so the kit stays scannable as it grows
|
||
const GROUPS = [
|
||
{ title: '1200×630 · social posts, link previews, Daily News covers', items: [
|
||
{ file: 'iap-hero-1200x630.png', size: '1200×630 · hero · advertise and earn' },
|
||
{ file: 'iap-advertise-earn-1200x630.jpg', size: '1200×630 · advertise and earn instantly, locked in code' },
|
||
{ file: 'iap-team-build-tiers-1200x630.jpg', size: '1200×630 · team build and instant payments · 50 / 20 / 10 tiers' },
|
||
{ file: 'iap-team-build-tiers-v2-1200x630.jpg', size: '1200×630 · team build and instant payments · variant 2' },
|
||
{ file: 'iap-instant-payments-tiers-1200x630.jpg', size: '1200×630 · instant payments, direct commissions · tiers' },
|
||
{ file: 'iap-instant-payments-tiers-v2-1200x630.jpg', size: '1200×630 · instant payments · variant 2' },
|
||
{ file: 'iap-multistream-info-1200x630.jpg', size: '1200×630 · multi-stream revenue · "INFO or message me" CTA' },
|
||
{ file: 'iap-multistream-info-v2-1200x630.jpg', size: '1200×630 · multi-stream revenue · variant 2' },
|
||
{ file: 'iap-success-path-info-1200x630.jpg', size: '1200×630 · success path, training and coaching · "INFO or message me" CTA' },
|
||
{ file: 'iap-success-path-link-1200x630.jpg', size: '1200×630 · success path · "link in the description" (feed posts, Daily News)' },
|
||
{ file: 'iap-success-path-coaching-1200x630.jpg', size: '1200×630 · success path · quality network traffic' } ] },
|
||
{ title: '1080×1080 and 1080×1920 · Instagram, Facebook, stories, reels', items: [
|
||
{ file: 'iap-1080x1080.png', size: '1080×1080 · square' },
|
||
{ file: 'iap-1080x1920.png', size: '1080×1920 · story / reel' } ] },
|
||
{ title: '1280×720 · Telegram and group posts', items: [
|
||
{ file: 'iap-1280x720.png', size: '1280×720 · group post' } ] },
|
||
{ title: 'Leaderboards · 728×90, 468×60, 320×50', items: [
|
||
{ file: 'iap-728x90.png', size: '728×90 · leaderboard' },
|
||
{ file: 'iap-advertise-earn-728x90.png', size: '728×90 · advertise and earn instantly · Join free' },
|
||
{ file: 'iap-ledger-728x90.png', size: '728×90 · advertising that pays you on-chain · See the ledger' },
|
||
{ file: 'iap-468x60.png', size: '468×60 · banner' },
|
||
{ file: 'iap-ledger-468x60.png', size: '468×60 · advertising that pays you on-chain · See the ledger' },
|
||
{ file: 'iap-320x50.svg', size: '320×50 · mobile leaderboard' } ] },
|
||
{ title: 'Rectangles and buttons · 336×280, 300×250, 125×125', items: [
|
||
{ file: 'iap-336x280.png', size: '336×280 · large rectangle' },
|
||
{ file: 'iap-advertise-earn-336x280.png', size: '336×280 · advertise and earn instantly' },
|
||
{ file: 'iap-advertise-earn-v2-336x280.png', size: '336×280 · advertise and earn · variant 2' },
|
||
{ file: 'iap-300x250.png', size: '300×250 · rectangle' },
|
||
{ file: 'iap-advertise-earn-300x250.png', size: '300×250 · advertise and earn instantly' },
|
||
{ file: 'iap-team-build-link-300x250.jpg', size: '300×250 · team build and instant payments · "link in the description"' },
|
||
{ file: 'iap-125x125.png', size: '125×125 · square button' } ] },
|
||
{ title: 'Skyscrapers · 160×600, 120×600', items: [
|
||
{ file: 'iap-160x600.png', size: '160×600 · wide skyscraper' },
|
||
{ file: 'iap-120x600.png', size: '120×600 · skyscraper' } ] }
|
||
];
|
||
bwrap.innerHTML = '';
|
||
for (const g of GROUPS) {
|
||
const det = document.createElement('details');
|
||
det.className = 'pb-acc';
|
||
det.innerHTML = '<summary><span>' + esc(g.title) + '</span><span class="pb-count">' + g.items.length + (g.items.length === 1 ? ' banner' : ' banners') + '</span></summary><div class="pb-grid"></div>';
|
||
const grid = det.querySelector('.pb-grid');
|
||
for (const b of g.items) {
|
||
const url = location.origin + '/banners/' + b.file;
|
||
const d = document.createElement('div');
|
||
d.className = 'pb-item';
|
||
d.innerHTML = '<img src="/banners/' + b.file + '?v=4" alt="InstantAdPay banner ' + b.size + '" loading="lazy">'
|
||
+ '<div class="pb-row"><span class="pb-size">' + b.size + '</span></div>';
|
||
const dl = document.createElement('a');
|
||
dl.className = 'btn small'; dl.textContent = 'Download'; dl.href = '/banners/' + b.file; dl.setAttribute('download', b.file);
|
||
d.querySelector('.pb-row').appendChild(dl);
|
||
const btn = document.createElement('button');
|
||
btn.className = 'btn small sec';
|
||
btn.textContent = 'Copy image URL';
|
||
btn.addEventListener('click', async () => {
|
||
try { await navigator.clipboard.writeText(url); IAP.status('Banner URL copied.', 'ok'); }
|
||
catch (e) { IAP.status('Copy failed.', 'bad'); }
|
||
});
|
||
d.querySelector('.pb-row').appendChild(btn);
|
||
grid.appendChild(d);
|
||
}
|
||
bwrap.appendChild(det);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── profile ── (busy2 defers the busy lookup past its TDZ)
|
||
$('pfSaveBtn').addEventListener('click', busy2($('pfSaveBtn'), async () => {
|
||
const r = await api('/api/my/profile', { username: $('pfUsername').value });
|
||
IAP.status('You are @' + r.account.username + ' now.', 'ok');
|
||
await render();
|
||
}));
|
||
|
||
// ── in-dashboard package buying ──
|
||
const PKG = { 1: 'Micro', 2: 'Activation', 3: 'Builder', 4: 'Growth', 5: 'Leader' };
|
||
// Card on-ramp: buy POL with a card via MoonPay, delivered to the buyer's own
|
||
// wallet. Signed + wallet-prefilled once MoonPay keys are set; generic page
|
||
// otherwise. The site never touches funds — MoonPay is merchant of record.
|
||
async function openMoonpay(pol) {
|
||
try {
|
||
let addr = '';
|
||
try { const me = await (await fetch('/api/me')).json(); addr = me.address || ''; } catch (e) {}
|
||
const q = '/api/moonpay-url?pol=' + encodeURIComponent(pol || '') + (addr ? '&address=' + encodeURIComponent(addr) : '');
|
||
const r = await (await fetch(q)).json();
|
||
if (r && r.url) {
|
||
window.open(r.url, '_blank', 'noopener');
|
||
IAP.status(r.signed
|
||
? 'MoonPay opened in a new tab with your wallet address pre-filled. Choose POL on Polygon, finish the purchase, then come back and buy your package.'
|
||
: 'MoonPay opened in a new tab. Choose POL on the Polygon network and paste your own wallet address as the destination, then come back.', 'ok');
|
||
}
|
||
} catch (e) { IAP.status('Could not open MoonPay: ' + ((e && e.message) || e), 'bad'); }
|
||
}
|
||
// ── linked positions (Qualified Start) ──
|
||
const short = a => a ? a.slice(0, 6) + '…' + a.slice(-4) : '';
|
||
let noPayoutAddrs = new Set(); // positions the site refuses to buy from (see siteConfig.noPayoutIds)
|
||
async function loadPositions(me) {
|
||
try {
|
||
const r = await (await fetch('/api/my/positions')).json();
|
||
if (r.error) return;
|
||
const list = r.positions || [];
|
||
const rows = list.map((p, i) => '<div class="lin-row"><span class="nm">Position ' + (i + 2) + ' <span class="mono">' + short(p.address) + '</span></span>'
|
||
+ '<span class="id">' + (p.memberId ? '#' + p.memberId : 'not on-chain yet') + '</span>'
|
||
+ '<span class="earn' + (p.counted ? ' on' : '') + '">' + (p.noPayout ? 'linkage only: no purchases from this position (payouts would reach a retired wallet)' : p.counted ? 'counts as a qualifying buyer' : p.memberId ? 'registered, buy $20+ to count' : 'buy a $20+ package to register it') + '</span>'
|
||
+ '<span class="dt">' + (p.credits || 0).toLocaleString() + ' credits' + (p.balanceWei != null ? ' · ' + (Number(BigInt(p.balanceWei) / 10n ** 14n) / 10000).toLocaleString(undefined, { maximumFractionDigits: 2 }) + ' POL' : '') + '</span>'
|
||
+ (!p.memberId ? '<button class="btn sec small" type="button" data-unlink="' + p.address + '">Unlink</button>' : '')
|
||
+ '</div>').join('');
|
||
const mainRow = r.main && r.main.address ? '<div class="lin-row"><span class="nm">Position 1 · main <span class="mono">' + short(r.main.address) + '</span></span>'
|
||
+ '<span class="id">' + (r.main.memberId ? '#' + r.main.memberId : 'payouts not on yet') + '</span>'
|
||
+ '<span class="earn on">' + (r.main.buyerCount || 0) + ' qualifying buyer(s)</span>'
|
||
+ '<span class="dt">' + (r.main.credits || 0).toLocaleString() + ' credits' + (r.main.balanceWei != null ? ' · ' + (Number(BigInt(r.main.balanceWei) / 10n ** 14n) / 10000).toLocaleString(undefined, { maximumFractionDigits: 2 }) + ' POL' : '') + '</span></div>' : '';
|
||
const html = mainRow + rows + (list.length ? '<p class="muted small" style="margin:8px 0 0">Pooled credits: <b>' + (r.totalCredits || 0).toLocaleString() + '</b>' + (r.credited ? ' plus <b>' + r.credited.toLocaleString() + '</b> credited to your account (spends from any position)' : '') + '. A campaign budget spends from one position at a time.</p>' : '');
|
||
if ($('qsList')) $('qsList').innerHTML = html;
|
||
// Wallet tab: live POL balance of the linked wallet, and which package it covers
|
||
const wb = $('walletBal');
|
||
if (wb && r.main && r.main.address && r.main.balanceWei != null) {
|
||
const polN = Number(BigInt(r.main.balanceWei) / 10n ** 14n) / 10000;
|
||
const usd = r.polUsd ? polN * r.polUsd : 0;
|
||
const pkgs = [5, 20, 50, 100, 250];
|
||
const covers = r.polUsd ? pkgs.filter(p => usd >= p * 1.06 + 0.05) : [];
|
||
wb.hidden = false;
|
||
wb.innerHTML = 'Wallet balance: <b class="mono">' + polN.toLocaleString(undefined, { maximumFractionDigits: 2 }) + ' POL</b>' + (usd ? ' (about $' + usd.toLocaleString(undefined, { maximumFractionDigits: 0 }) + ')' : '')
|
||
+ (r.polUsd ? '<br><span class="muted">' + (covers.length ? 'Enough for the $' + covers[covers.length - 1] + ' package with gas to spare.' : 'Not enough for the $5 package yet. Buy POL with a card on the Buy packages tab, or send POL to this wallet.') + (covers.length && covers.length < pkgs.length ? ' Top up for the $' + pkgs[covers.length] + ' package.' : '') + '</span>' : '');
|
||
}
|
||
if ($('posList')) $('posList').innerHTML = html;
|
||
if ($('posCard')) $('posCard').hidden = !list.length;
|
||
// "Buy from" picker: main + every position
|
||
const sel = $('buyFrom');
|
||
if (sel) {
|
||
const keep = sel.value;
|
||
sel.innerHTML = '<option value="main">Main wallet ' + (r.main && r.main.address ? short(r.main.address) : '(link it first)') + (r.main && r.main.memberId ? ' · #' + r.main.memberId : '') + '</option>'
|
||
+ list.map((p, i) => '<option value="' + p.address + '"' + (p.noPayout ? ' disabled' : '') + '>Position ' + (i + 2) + ' ' + short(p.address) + (p.memberId ? ' · #' + p.memberId : ' · not registered yet') + (p.noPayout ? ' · linkage only' : p.counted ? ' · counted' : '') + '</option>').join('');
|
||
noPayoutAddrs = new Set(list.filter(p => p.noPayout).map(p => p.address.toLowerCase())); if (r.main && r.main.noPayout && r.main.address) noPayoutAddrs.add(r.main.address.toLowerCase());
|
||
if (keep && [...sel.options].some(o => o.value === keep)) sel.value = keep;
|
||
$('buyFromWrap').hidden = !list.length;
|
||
}
|
||
document.querySelectorAll('[data-unlink]').forEach(b => b.addEventListener('click', async () => {
|
||
if (!(await IAP.confirmBox('Unlink ' + short(b.dataset.unlink) + ' from your account?', { title: 'Unlink position', ok: 'Unlink' }))) return;
|
||
try { await api('/api/my/positions/remove', { address: b.dataset.unlink }); IAP.status('Position unlinked.', 'ok'); loadPositions(); }
|
||
catch (e) { IAP.status(e.message, 'bad'); }
|
||
}));
|
||
} catch (e) {}
|
||
}
|
||
if ($('qsAddBtn')) $('qsAddBtn').addEventListener('click', busy2($('qsAddBtn'), async () => {
|
||
const me = await (await fetch('/api/me')).json();
|
||
if (!me.address) throw new Error('Link your main wallet first (Wallet tab), then add positions under it.');
|
||
if (!me.memberId) throw new Error('Switch on payouts for your main wallet first (Wallet tab). Positions register under your member number.');
|
||
if (!(await IAP.confirmBox('Your wallet will ask which account to connect. Tick ONLY the new account (not ' + short(me.address) + '), then sign once.\n\nIf you have not created the extra account yet: MetaMask, account menu, Add account. Trust or SafePal: switch wallet.\n\nReady?', { title: 'Add a position', ok: 'Ready' }))) return;
|
||
IAP.status('Pick the new account in your wallet, then sign once…');
|
||
const r = await IAPWallet.signIn({ asPosition: true, pick: true });
|
||
$('qsHint').textContent = 'Added ' + short(r.address) + '. Now choose it under "Buy from" and buy a $20 or larger package.';
|
||
IAP.status('Position added: ' + short(r.address) + '. Pick it under "Buy from" above and buy a $20+ package to count it.', 'ok');
|
||
await loadPositions();
|
||
const sel = $('buyFrom'); if (sel) sel.value = r.address.toLowerCase();
|
||
try { $('buyFrom').scrollIntoView({ behavior: 'smooth', block: 'center' }); } catch (e) {}
|
||
}));
|
||
async function loadBuyTiles() {
|
||
try {
|
||
const { products } = await (await fetch('/api/catalog')).json();
|
||
const wrap = $('boTiles');
|
||
wrap.innerHTML = '';
|
||
for (const p of products) {
|
||
const bonus = p.creditAmount - p.priceCents;
|
||
const div = document.createElement('div');
|
||
div.className = 'tile' + (p.priceCents === 5000 ? ' hot' : '');
|
||
div.innerHTML = '<div class="name">' + (PKG[p.id] || 'Package ' + p.id) + '</div>'
|
||
+ '<div class="price">$' + Math.round(p.priceCents / 100) + '</div>'
|
||
+ '<div class="cr">' + p.creditAmount.toLocaleString() + ' credits</div>'
|
||
+ '<div class="bonus">' + (bonus > 0 ? '+' + bonus.toLocaleString() + ' bonus credits' : ' ') + '</div>'
|
||
+ '<div class="pol">' + (p.costWei ? IAP.fmtPol(p.costWei) + ' POL right now' : 'paused') + '</div>'
|
||
+ '<button class="btn small" data-id="' + p.id + '" data-cost="' + (p.costWei || '') + '"'
|
||
+ (p.costWei ? '' : ' disabled') + '>Buy $' + Math.round(p.priceCents / 100) + '</button>';
|
||
wrap.appendChild(div);
|
||
}
|
||
wrap.querySelectorAll('button[data-id]').forEach(b => b.addEventListener('click', async () => {
|
||
try {
|
||
b.disabled = true;
|
||
// the buyer's credits are read by the member id of their LINKED wallet.
|
||
// If they only connected a wallet (e.g. for the faucet) but never linked
|
||
// it, link it first (one signature) so the purchase's credits show up.
|
||
// retry these through transient failures — mobile drops in-flight
|
||
// fetches when the page returns from the wallet app-switch
|
||
const jretry = async url => { let err; for (let i = 0; i < 4; i++) { try { return await (await fetch(url)).json(); } catch (e) { err = e; await new Promise(s => setTimeout(s, 500 * (i + 1))); } } throw err; };
|
||
const meNow = await jretry('/api/me');
|
||
// which of the member's wallets is buying: the main wallet (default) or a
|
||
// linked position (Qualified Start). A position registers under the main
|
||
// member id on its first buy, so its sponsor is always this member.
|
||
const fromSel = $('buyFrom');
|
||
const fromPos = (fromSel && !$('buyFromWrap').hidden && fromSel.value && fromSel.value !== 'main') ? fromSel.value.toLowerCase() : null;
|
||
if (fromPos && !meNow.memberId) { IAP.status('Switch on payouts for your main wallet first (Wallet tab), so this position can register under you.', 'bad'); return; }
|
||
if (noPayoutAddrs.has(fromPos || String(meNow.address || '').toLowerCase())) { IAP.status('This position is kept for linkage only. A purchase from it would pay a retired wallet. Buy from your main wallet or another position.', 'bad'); return; }
|
||
if (!fromPos && !meNow.address) {
|
||
IAP.status('Link your wallet first — one quick signature…');
|
||
await IAPWallet.signIn();
|
||
}
|
||
const wantAddr = fromPos || (meNow.address ? meNow.address.toLowerCase() : null);
|
||
if (wantAddr) { // never buy from a wallet other than the one selected: a stray wallet would register a brand-new member
|
||
await IAPWallet.connect();
|
||
let cur = String(await IAPWallet.activeAddress() || '').toLowerCase();
|
||
if (cur !== wantAddr) {
|
||
IAP.status('Pick ' + wantAddr.slice(0, 6) + '…' + wantAddr.slice(-4) + ' in your wallet\'s account picker…');
|
||
cur = String(await IAPWallet.pickAccount() || '').toLowerCase();
|
||
}
|
||
if (cur !== wantAddr) throw new Error('Your wallet connected as ' + cur.slice(0, 6) + '…' + cur.slice(-4) + ' but you chose ' + wantAddr.slice(0, 6) + '…' + wantAddr.slice(-4) + '. Switch accounts in your wallet app and try again.');
|
||
}
|
||
const spNow = fromPos ? { sponsorId: meNow.memberId } : await jretry('/api/sponsor');
|
||
if (!fromPos && !meNow.memberId && spNow.sponsorBlocked) { IAP.status(sponsorHoldText(spNow), 'bad'); return; } // first activation must not hand a sponsored member to the company
|
||
// pre-flight: stop early if the POL is not there. Trust Wallet also hard-blocks any
|
||
// transaction that spends most of the balance ("drain your wallet"), so Trust users
|
||
// get a heads-up first; other wallets go straight to the confirmation.
|
||
try {
|
||
const need = BigInt(b.dataset.cost) + BigInt(b.dataset.cost) / 50n; // same 2% pad as buy()
|
||
const bal = await IAPWallet.balance(wantAddr || IAPWallet.address() || meNow.address);
|
||
if (bal < need) {
|
||
IAP.status('That wallet holds ' + IAP.fmtPol(bal.toString()) + ' POL, but this package needs about ' + IAP.fmtPol(need.toString()) + ' POL plus a little for gas. Top it up and try again.', 'bad');
|
||
return;
|
||
}
|
||
const pct = Number(need * 100n / bal);
|
||
const isTrust = /trust/i.test(IAPWallet.walletName() || '');
|
||
if (isTrust && pct > 55 && !(await IAP.confirmBox('Heads up for Trust Wallet users: this purchase uses about ' + pct + '% of the POL in your wallet, and Trust Wallet refuses transactions that spend most of the balance (it shows a "drain your wallet" warning with only Stop and go back).' + '\n\n' + 'Options: pick a smaller package first, add some POL, or connect a different wallet (MetaMask, Phantom, SafePal). Extra POL always stays yours.' + '\n\n' + 'Try it anyway?', { title: 'Trust Wallet check', ok: 'Buy anyway', cancel: 'Pause' }))) {
|
||
IAP.status('Purchase paused. Pick a smaller package, add POL, or connect another wallet, then try again.', 'ok');
|
||
return;
|
||
}
|
||
} catch (e) { /* balance read failed: let the wallet decide */ }
|
||
IAP.status('Confirm the purchase in your wallet…');
|
||
const r = await IAPWallet.buy(Number(b.dataset.id), spNow.sponsorId || 0, b.dataset.cost);
|
||
if (r.status !== '0x1' && r.receipt && r.receipt.status !== '0x1') throw new Error('Transaction reverted.');
|
||
IAP.status(fromPos ? 'Purchase settled on-chain. That position now counts toward your qualification, and its credits pool with yours.' : 'Purchase settled on-chain. Credits are in your account.', 'ok');
|
||
await render();
|
||
loadBuyTiles();
|
||
} catch (e) { IAP.status('Purchase failed: ' + ((e && e.message) || e), 'bad'); }
|
||
finally { b.disabled = false; }
|
||
}));
|
||
// brand new to crypto? buy POL with a card, sent straight to the wallet
|
||
const builder = products.find(p => p.priceCents === 5000) || products[products.length - 1];
|
||
const needPol = (builder && builder.costWei) ? Math.max(30, Math.ceil(Number(builder.costWei) / 1e18) + 3) : 30;
|
||
const cta = document.createElement('div');
|
||
cta.style.cssText = 'grid-column:1/-1;margin-top:10px;text-align:center';
|
||
cta.innerHTML = '<p class="muted small" style="margin:0 0 8px">New to crypto? Buy POL with a debit or credit card, Apple Pay, or Google Pay. It lands straight in your own wallet, and this site never touches your money.</p>'
|
||
+ '<button class="btn small sec" id="moonpayBtn" type="button">💳 Buy POL with a card</button> <a class="btn small sec" href="/wallets" target="_blank" rel="noopener">Wallet and MoonPay guide</a>';
|
||
wrap.appendChild(cta);
|
||
const mb = $('moonpayBtn'); if (mb) mb.addEventListener('click', () => openMoonpay(needPol));
|
||
} catch (e) {}
|
||
}
|
||
loadBuyTiles();
|
||
|
||
// ── earn-by-viewing: daily ad set with dwell, then claim ──
|
||
const earnState = { types: ['banner', 'text'], i: 0, timer: null };
|
||
async function earnRefresh() {
|
||
try {
|
||
const st = await (await fetch('/api/my/earn')).json();
|
||
if (st.error) return null;
|
||
$('earnProgress').textContent = 'today: ' + st.views + ' / ' + st.target + ' ads viewed';
|
||
$('earnBalance').textContent = (st.earnedAvailable != null ? st.earnedAvailable : st.earned) + ' earned credits available' + (st.reserved ? ' · ' + st.reserved + ' in live campaigns' : '');
|
||
const done = st.views >= st.target;
|
||
$('earnClaimBtn').hidden = !(done && !st.claimed);
|
||
if (st.claimed) {
|
||
$('earnHint').textContent = 'Claimed for today' + (st.streakDay > 1 ? ' (streak day ' + st.streakDay + ')' : '') + '. Tomorrow\'s claim pays ' + st.nextClaim + ' credits if you come back.';
|
||
// after the set (Marty, 2026-09-12): keep going with verified visits, and turn the credits into a campaign
|
||
const box = $('earnAdBox');
|
||
if (box) box.innerHTML = '<div class="done-big"><span class="tick">✓</span><b>Ads done for today</b><span class="muted small">Today\'s set is viewed and claimed. Fresh ads tomorrow.</span>'
|
||
+ '<div style="display:flex;gap:10px;flex-wrap:wrap;justify-content:center;margin-top:14px">'
|
||
+ (st.visitsLeft ? '<button type="button" class="btn small" data-earnnext="visits">Keep earning: ' + st.visitsLeft + ' verified visit' + (st.visitsLeft === 1 ? '' : 's') + ' left today</button>' : '')
|
||
+ ((st.earnedAvailable != null ? st.earnedAvailable : st.earned) > 0
|
||
? '<button type="button" class="btn sec small" data-earnnext="campaigns">Launch a campaign with your ' + (st.earnedAvailable != null ? st.earnedAvailable : st.earned) + ' credits</button>'
|
||
: '<button type="button" class="btn sec small" data-earnnext="campaigns">All your credits are working in live campaigns</button>') + '</div></div>';
|
||
if (box) box.querySelectorAll('[data-earnnext]').forEach(b => b.addEventListener('click', () => { if (b.dataset.earnnext === 'campaigns') setPane('campaigns'); else setEarnSub('visits'); }));
|
||
if ($('earnStartBtn')) $('earnStartBtn').hidden = true;
|
||
} else {
|
||
if ($('earnStartBtn')) $('earnStartBtn').hidden = done; // set done: the claim button takes over
|
||
$('earnHint').textContent = done
|
||
? 'Set complete. Claim your ' + st.claimCredits + ' credits' + (st.streakDay > 1 ? ' (streak day ' + st.streakDay + ')' : '') + '.'
|
||
: (st.views ? (st.target - st.views) + ' more to go, then claim ' + st.claimCredits + ' credits' + (st.streakDay > 1 ? ' (streak day ' + st.streakDay + ')' : '') + '.' : 'View ' + st.target + ' ads to unlock the daily claim of ' + st.claimCredits + ' credits' + (st.streakDay > 1 ? ' (streak day ' + st.streakDay + ')' : '') + '.');
|
||
}
|
||
return st;
|
||
} catch (e) { return null; }
|
||
}
|
||
async function earnShowAd() {
|
||
// each view happens full screen in its own tab: /view/<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' + (r.streakDay > 1 ? ', streak day ' + r.streakDay : '') + '. Tomorrow\'s claim pays ' + r.nextClaim + '. Spend them in Campaigns.', 'ok');
|
||
await earnRefresh();
|
||
loadDashboard();
|
||
} catch (e) { IAP.status(e.message, 'bad'); }
|
||
});
|
||
|
||
const busy = (btn, fn) => async () => {
|
||
try { btn.disabled = true; await fn(); }
|
||
catch (e) { IAP.status((e && e.message) || String(e), 'bad'); }
|
||
finally { btn.disabled = false; }
|
||
};
|
||
|
||
// passwordless (feature-flagged on config.emailAuth): code replaces passwords
|
||
(async () => {
|
||
const cfg = await IAP.getConfig();
|
||
if (!cfg.emailAuth) return;
|
||
$('passCards').hidden = true;
|
||
$('magicCard').hidden = false;
|
||
const codeOpts = () => ({ honeypot: $('mcWebsite'), host: $('mcCheck') });
|
||
const start = busy($('mcSendBtn'), async () => {
|
||
const r = await IAP.requestCode($('mcEmail').value, codeOpts());
|
||
$('mcCodeRow').hidden = false;
|
||
$('mcVerifyBtn').hidden = false;
|
||
$('mcSendBtn').hidden = true;
|
||
$('mcResend').hidden = false;
|
||
if (r.devCode) { $('mcCode').value = r.devCode; IAP.status('Dev mode: code filled in for you.', 'ok'); }
|
||
else IAP.status('Code sent. Check your inbox (and spam, the first time).', 'ok');
|
||
$('mcCode').focus();
|
||
});
|
||
$('mcSendBtn').addEventListener('click', start);
|
||
$('mcResend').addEventListener('click', busy($('mcResend'), async () => {
|
||
const r = await IAP.requestCode($('mcEmail').value, codeOpts());
|
||
if (r.devCode) $('mcCode').value = r.devCode;
|
||
IAP.status('Fresh code sent.', 'ok');
|
||
}));
|
||
$('mcVerifyBtn').addEventListener('click', busy($('mcVerifyBtn'), async () => {
|
||
const r = await api('/api/auth/email/verify', { email: $('mcEmail').value, code: $('mcCode').value, newsletter: !!($('nlOptin') && $('nlOptin').checked) });
|
||
IAP.status('You are in.', 'ok');
|
||
if (r.created && !(r.account && r.account.username)) await showOnboard(); // pick a username first
|
||
if (!(await showGauntlet())) await showLoginAd(); // welcome tour outranks the login ad
|
||
await render();
|
||
}));
|
||
})();
|
||
|
||
// new-member onboarding: choose a username (required), optionally a bio.
|
||
// required=true: no skip, no backdrop dismiss, prefilled suggestion, resolves only after a save.
|
||
function showOnboard(required) {
|
||
return new Promise(resolve => {
|
||
const m = $('onboardModal'); if (!m) return resolve();
|
||
m.hidden = false; $('obErr').hidden = true;
|
||
$('obSkip').hidden = !!required;
|
||
if (required && !$('obUsername').value) {
|
||
fetch('/api/my/username-suggest').then(r => r.json()).then(r => { if (r.suggest && !$('obUsername').value) { $('obUsername').value = r.suggest; $('obUsername').select(); } }).catch(() => {});
|
||
}
|
||
setTimeout(() => $('obUsername').focus(), 50);
|
||
const done = () => { m.hidden = true; resolve(); };
|
||
$('obSkip').onclick = required ? null : done;
|
||
$('obUsername').onkeydown = e => { if (e.key === 'Enter') { e.preventDefault(); $('obSave').click(); } };
|
||
$('obSave').onclick = async () => {
|
||
const u = $('obUsername').value.trim();
|
||
if (required && !u) { $('obErr').hidden = false; $('obErr').textContent = 'Pick a username to continue.'; return; }
|
||
try {
|
||
if (u) await api('/api/my/profile', { username: u });
|
||
const bio = $('obBio').value.trim();
|
||
if (bio) await api('/api/my/profile-details', { bio });
|
||
done();
|
||
} catch (e) { $('obErr').hidden = false; $('obErr').textContent = e.message || 'Could not save that. Try a different username.'; }
|
||
};
|
||
});
|
||
}
|
||
|
||
$('signupBtn').addEventListener('click', busy($('signupBtn'), async () => {
|
||
const r = await api('/api/signup', { email: $('suEmail').value, password: $('suPass').value, newsletter: !!($('nlOptin') && $('nlOptin').checked) });
|
||
IAP.status('Welcome aboard. You are in.', 'ok');
|
||
if (!(r.account && r.account.username)) await showOnboard();
|
||
await render();
|
||
}));
|
||
$('loginBtn').addEventListener('click', busy($('loginBtn'), async () => {
|
||
await api('/api/login', { email: $('liEmail').value, password: $('liPass').value });
|
||
IAP.status('Logged in.', 'ok');
|
||
if (!(await showGauntlet())) await showLoginAd(); // welcome tour outranks the login ad
|
||
await render();
|
||
}));
|
||
$('linkBtn').addEventListener('click', busy($('linkBtn'), async () => {
|
||
IAP.status('Check your wallet for the free link signature…');
|
||
await IAPWallet.signIn(); // server binds the wallet to the signed-in email account
|
||
IAP.status('Wallet linked. Earnings pay there from now on.', 'ok');
|
||
await render();
|
||
}));
|
||
if ($('faucetBtn')) $('faucetBtn').addEventListener('click', busy($('faucetBtn'), async () => {
|
||
IAP.status('Connect your wallet first…');
|
||
const addr = await IAPWallet.connect();
|
||
let copied = false;
|
||
try { await navigator.clipboard.writeText(addr); copied = true; } catch (e) {}
|
||
window.open('https://faucet.polygon.technology/', '_blank', 'noopener');
|
||
$('faucetInfo').innerHTML = 'Your address <span class="mono">' + addr.slice(0, 8) + '…' + addr.slice(-6) + '</span>'
|
||
+ (copied ? ' is copied' : '') + '. On the faucet, choose <b>Polygon Amoy</b>, paste your address, and request POL. Then come back and buy.';
|
||
IAP.status('Faucet opened in a new tab. Choose Polygon Amoy, paste your address, and request test POL.', 'ok');
|
||
}));
|
||
if ($('wcDisconnect')) $('wcDisconnect').addEventListener('click', busy($('wcDisconnect'), async () => {
|
||
// fire-and-forget: don't gate the reload on disconnect resolving (it can
|
||
// stall). The reload drops all in-memory wallet state and the picker returns.
|
||
IAPWallet.disconnect().catch(() => {});
|
||
$('wcDisconnectInfo').textContent = 'Disconnecting — reloading so you can pick a different wallet…';
|
||
IAP.status('Disconnecting — reloading…', 'ok');
|
||
setTimeout(() => location.reload(), 900);
|
||
}));
|
||
// a named sponsor that cannot be paid right now: hold the transaction and say why (never credit the company by default)
|
||
function sponsorHoldText(sp) {
|
||
const who = sp.sponsorName || 'your sponsor';
|
||
if (sp.sponsorBlocked === 'notActivated') return who + ' has not switched on payouts yet, so this purchase would credit the company instead of them. Ask them to switch on payouts (Wallet tab, one free transaction), then try again.';
|
||
if (sp.sponsorBlocked === 'rpc') return 'Could not confirm your sponsor on the chain just now. Try again in a minute; nothing was charged.';
|
||
return 'Your sponsor link could not be matched to a member. Message support before buying so ' + who + ' gets credit.';
|
||
}
|
||
$('activateBtn').addEventListener('click', busy($('activateBtn'), async () => {
|
||
const me = await (await fetch('/api/me')).json();
|
||
if (me.sponsorBlocked) { IAP.status(sponsorHoldText(me), 'bad'); return; }
|
||
IAP.status('Confirm the free activation in your wallet…');
|
||
const r = await IAPWallet.activate(me.sponsorId || 0);
|
||
if (r.receipt.status !== '0x1') throw new Error('Transaction reverted. Check the explorer.');
|
||
IAP.status('Payouts are on. Your invite link is live.', 'ok');
|
||
await render();
|
||
}));
|
||
$('copyInvite').addEventListener('click', async () => {
|
||
try { await navigator.clipboard.writeText($('inviteLine').textContent); IAP.status('Link copied.', 'ok'); }
|
||
catch (e) { IAP.status('Copy failed. Select and copy the link text.', 'bad'); }
|
||
});
|
||
$('logoutLink').addEventListener('click', async e => {
|
||
e.preventDefault();
|
||
await fetch('/api/auth/logout', { method: 'POST' });
|
||
IAP.status('Logged out.', 'ok');
|
||
await render();
|
||
});
|
||
|
||
// ── welcome tour (viral banner gauntlet): a new member meets their 3-level
|
||
// upline's sites, 10 focus-paused seconds each, then unlocks welcome credits.
|
||
// Same three levels the contract pays — the tour IS the org chart.
|
||
async function showGauntlet() {
|
||
try {
|
||
const g = await (await fetch('/api/my/gauntlet')).json();
|
||
if (!g.pending || !g.slides || !g.slides.length) return false;
|
||
const gate = $('gauntGate');
|
||
gate.hidden = false;
|
||
for (let i = 0; i < g.slides.length; i++) {
|
||
const s = g.slides[i];
|
||
$('ggWho').textContent = 'Position ' + (i + 1) + ': ' + s.name + (i === 0 ? ' — the person who invited you' : '');
|
||
$('ggProgress').textContent = 'Meeting your line: ' + (i + 1) + ' of ' + g.slides.length;
|
||
$('ggFrame').hidden = false;
|
||
$('ggFrame').src = s.targetUrl;
|
||
let left = g.dwell || 10;
|
||
$('ggTimer').textContent = left + 's';
|
||
await new Promise(done => {
|
||
const t = setInterval(() => {
|
||
if (document.visibilityState !== 'visible' || !document.hasFocus()) return;
|
||
left -= 1;
|
||
$('ggTimer').textContent = Math.max(0, left) + 's';
|
||
if (left <= 0) { clearInterval(t); done(); }
|
||
}, 1000);
|
||
});
|
||
}
|
||
$('ggFrame').src = 'about:blank';
|
||
$('ggFrame').hidden = true; // avoid a white about:blank panel once the tour is done
|
||
$('ggTimer').textContent = '✓';
|
||
$('ggWho').textContent = 'That is your line. When you grow, they earn — and yours starts the day you share.';
|
||
$('ggClaim').hidden = false;
|
||
await new Promise(done => {
|
||
$('ggClaim').onclick = async () => {
|
||
try {
|
||
const r = await api('/api/my/gauntlet/complete', { token: g.token });
|
||
IAP.status('+' + r.credited + ' welcome credits unlocked. They spend on real campaigns.', 'ok');
|
||
} catch (e) { IAP.status(e.message, 'bad'); }
|
||
done();
|
||
};
|
||
});
|
||
gate.hidden = true;
|
||
$('ggClaim').hidden = true;
|
||
return true;
|
||
} catch (e) { return false; }
|
||
}
|
||
|
||
// ── line banner (profile): the member's slot on welcome tours + their wall ──
|
||
function fillLineBanner(a) {
|
||
if (!a) return;
|
||
if (a.lineTargetUrl) $('lbTarget').value = a.lineTargetUrl;
|
||
if (a.lineBannerUrl) {
|
||
$('lbBanner').value = a.lineBannerUrl;
|
||
$('lbPreview').hidden = false;
|
||
$('lbPreview').innerHTML = '<img src="' + a.lineBannerUrl + '" alt="line banner" style="max-width:320px;border-radius:10px">';
|
||
}
|
||
$('lbCurrent').textContent = a.lineTargetUrl
|
||
? 'Live: your next three levels meet ' + a.lineTargetUrl + ' on their welcome tour.'
|
||
: 'Not set yet. Until you set one, your tour slot is skipped.';
|
||
}
|
||
async function loadLineBanner() {
|
||
try {
|
||
const a = await (await fetch('/api/me')).json();
|
||
fillLineBanner(a);
|
||
fillProfileDetails(a);
|
||
// buyerCount + wallUnlocked live on the dashboard payload (chain read), not on /api/me
|
||
let d = {}; try { d = await (await fetch('/api/my/dashboard')).json(); } catch (e) {}
|
||
fillWallOffers(Object.assign({}, a, { buyerCount: d.buyerCount || 0, wallUnlocked: d.wallUnlocked || 1 }));
|
||
} catch (e) {}
|
||
}
|
||
// ── wall positions 2 & 3: the member's own offers, unlocked by qualifying buyers ──
|
||
function fillWallOffers(a) {
|
||
if (!a || !$('wallOffersCard')) return;
|
||
const unlocked = a.wallUnlocked || 1, bc = a.buyerCount || 0;
|
||
const offers = Array.isArray(a.wallOffers) ? a.wallOffers : [];
|
||
const NEED = [2, 5];
|
||
for (let i = 0; i < 2; i++) {
|
||
const o = offers[i] || {};
|
||
const open = unlocked >= i + 2;
|
||
$('woTitle' + i).value = o.title || ''; $('woTarget' + i).value = o.targetUrl || ''; $('woBanner' + i).value = o.bannerUrl || '';
|
||
$('woPrev' + i).hidden = !o.bannerUrl; $('woPrev' + i).innerHTML = o.bannerUrl ? '<img src="' + o.bannerUrl + '" alt="">' : '';
|
||
$('woLock' + i).textContent = open ? 'yours' : 'unlocks at ' + NEED[i] + ' qualifying buyers (' + bc + '/' + NEED[i] + ')';
|
||
$('woSlot' + i).classList.toggle('locked', !open);
|
||
}
|
||
$('woStatus').textContent = unlocked >= 3 ? 'Fully qualified: all three wall positions are yours.'
|
||
: unlocked === 2 ? 'Position 2 is yours. ' + (5 - bc) + ' more qualifying buyer' + (5 - bc === 1 ? '' : 's') + ' and position 3 is too.'
|
||
: (2 - bc) + ' more qualifying buyer' + (2 - bc === 1 ? '' : 's') + ' ($20 or more) opens position 2. You can set your links now; they go live the moment a slot unlocks.';
|
||
}
|
||
document.querySelectorAll('.wo-upload').forEach(b => b.addEventListener('click', () => { const f = document.querySelector('.wo-file[data-slot="' + b.dataset.slot + '"]'); if (f) f.click(); }));
|
||
document.querySelectorAll('.wo-file').forEach(inp => inp.addEventListener('change', async () => {
|
||
const i = inp.dataset.slot, f = inp.files[0]; if (!f) return;
|
||
$('woInfo' + i).textContent = 'Uploading…';
|
||
try {
|
||
const r = await (await fetch('/api/my/upload', { method: 'POST', headers: { 'Content-Type': f.type }, body: f })).json();
|
||
if (r.error) { $('woInfo' + i).textContent = r.error; }
|
||
else { $('woBanner' + i).value = r.url; $('woInfo' + i).textContent = 'Uploaded'; $('woPrev' + i).hidden = false; $('woPrev' + i).innerHTML = '<img src="' + r.url + '" alt="">'; }
|
||
} catch (e) { $('woInfo' + i).textContent = 'Upload failed. Try again.'; }
|
||
inp.value = '';
|
||
}));
|
||
if ($('woSaveBtn')) $('woSaveBtn').addEventListener('click', busy2($('woSaveBtn'), async () => {
|
||
const offers = [0, 1].map(i => ({ title: $('woTitle' + i).value, targetUrl: $('woTarget' + i).value, bannerUrl: $('woBanner' + i).value }));
|
||
const r = await api('/api/my/wall-offers', { offers });
|
||
IAP.status('Wall positions saved.', 'ok');
|
||
await loadLineBanner();
|
||
}));
|
||
const SOCIALS = ['facebook', 'twitter', 'youtube', 'instagram', 'tiktok', 'telegram', 'linkedin', 'website', 'video']; // video = intro video on the wall, not a social link
|
||
function fillProfileDetails(a) {
|
||
if (!a) return;
|
||
if (a.bio) $('pfBio').value = a.bio;
|
||
if (a.avatarUrl) { const p = $('pfAvatarPrev'); p.src = a.avatarUrl; p.hidden = false; }
|
||
let soc = {}; try { soc = a.socials ? JSON.parse(a.socials) : {}; } catch (e) {}
|
||
for (const p of SOCIALS) if ($('soc-' + p)) $('soc-' + p).value = soc[p] || '';
|
||
if (a.username) {
|
||
const link = location.origin + '/wall/' + a.username;
|
||
$('pfBioLink').textContent = link;
|
||
$('pfViewBio').hidden = false;
|
||
$('pfViewBio').href = '/wall/' + a.username;
|
||
}
|
||
}
|
||
let pfAvatar; // pending avatar url
|
||
$('pfAvatarBtn').addEventListener('click', () => $('pfAvatarFile').click());
|
||
$('pfAvatarFile').addEventListener('change', async () => {
|
||
const f = $('pfAvatarFile').files[0];
|
||
if (!f) return;
|
||
$('pfAvatarInfo').textContent = 'Uploading…';
|
||
try {
|
||
const r = await (await fetch('/api/my/upload', { method: 'POST', headers: { 'Content-Type': f.type }, body: f })).json();
|
||
if (r.error) { $('pfAvatarInfo').textContent = r.error; $('pfAvatarFile').value = ''; return; }
|
||
pfAvatar = r.url;
|
||
$('pfAvatarInfo').textContent = 'Uploaded — save to apply.';
|
||
const p = $('pfAvatarPrev'); p.src = r.url; p.hidden = false;
|
||
} catch (e) { $('pfAvatarInfo').textContent = 'Upload failed.'; }
|
||
$('pfAvatarFile').value = '';
|
||
});
|
||
$('pfDetailsSave').addEventListener('click', busy2($('pfDetailsSave'), async () => {
|
||
const body = { bio: $('pfBio').value, socials: {} };
|
||
for (const p of SOCIALS) body.socials[p] = ($('soc-' + p) && $('soc-' + p).value.trim()) || '';
|
||
if (pfAvatar) body.avatarUrl = pfAvatar;
|
||
const r = await api('/api/my/profile-details', body);
|
||
IAP.status('Profile saved.', 'ok');
|
||
if (r.account) fillProfileDetails(r.account);
|
||
}));
|
||
$('lbUploadBtn').addEventListener('click', () => $('lbFile').click());
|
||
$('lbFile').addEventListener('change', async () => {
|
||
const f = $('lbFile').files[0];
|
||
if (!f) return;
|
||
$('lbUpInfo').textContent = 'Uploading…';
|
||
try {
|
||
const r = await (await fetch('/api/my/upload', { method: 'POST', headers: { 'Content-Type': f.type }, body: f })).json();
|
||
if (r.error) { $('lbUpInfo').textContent = r.error; $('lbFile').value = ''; return; }
|
||
$('lbBanner').value = r.url;
|
||
$('lbUpInfo').textContent = 'Uploaded.';
|
||
$('lbPreview').hidden = false;
|
||
$('lbPreview').innerHTML = '<img src="' + r.url + '" alt="line banner" style="max-width:320px;border-radius:10px">';
|
||
} catch (e) { $('lbUpInfo').textContent = 'Upload failed. Try again.'; }
|
||
$('lbFile').value = '';
|
||
});
|
||
$('lbSaveBtn').addEventListener('click', busy2($('lbSaveBtn'), async () => {
|
||
const r = await api('/api/my/linebanner', { bannerUrl: $('lbBanner').value, targetUrl: $('lbTarget').value });
|
||
IAP.status('Line banner saved. Your next three levels will meet it.', 'ok');
|
||
if (r.account) fillLineBanner(r.account);
|
||
}));
|
||
|
||
// ── login ad interstitial (ClickBaitPays pattern): after a successful
|
||
// sign-in the sponsor card appears; "Open Ad" opens the CTA link in a NEW
|
||
// tab (a real, counted click) while the timer counts down on THIS page —
|
||
// deliberately not paused, the member is expected to be in the ad tab.
|
||
// At zero, "Go to dashboard" appears.
|
||
async function showLoginAd() {
|
||
try {
|
||
const { ad } = await (await fetch('/api/ads/slot?type=login')).json();
|
||
if (!ad || !ad.targetUrl) return;
|
||
const gate = $('loginGate');
|
||
const openBtn = $('lgOpen');
|
||
const goBtn = $('lgContinue');
|
||
const timer = $('lgTimer');
|
||
$('lgCreative').innerHTML = ad.imageUrl
|
||
? '<img src="' + ad.imageUrl + '" alt="sponsor ad">'
|
||
: '<span class="lg-linkcard">' + (ad.title ? String(ad.title).replace(/[&<>]/g, '') : 'Visit today\'s sponsor') + '</span>';
|
||
$('lgStatus').textContent = 'Login ad sponsor — click "Open Ad" to begin.';
|
||
timer.hidden = true;
|
||
goBtn.hidden = true;
|
||
openBtn.disabled = false;
|
||
gate.hidden = false;
|
||
await new Promise(done => {
|
||
openBtn.onclick = () => {
|
||
window.open(ad.targetUrl, '_blank'); // the click relay counts it
|
||
openBtn.disabled = true;
|
||
$('lgStatus').textContent = 'Ad open in a new tab. View it and come back — the timer runs here.';
|
||
let left = ad.dwell || 10;
|
||
timer.hidden = false;
|
||
timer.textContent = 'Time remaining: ' + left + 's';
|
||
const t = setInterval(() => {
|
||
left -= 1;
|
||
if (left > 0) { timer.textContent = 'Time remaining: ' + left + 's'; return; }
|
||
clearInterval(t);
|
||
timer.textContent = 'Time is up';
|
||
timer.classList.add('done');
|
||
$('lgStatus').textContent = 'Thanks for the look. Your dashboard is ready.';
|
||
goBtn.hidden = false;
|
||
}, 1000);
|
||
goBtn.onclick = () => done();
|
||
};
|
||
});
|
||
gate.hidden = true;
|
||
$('lgTimer').classList.remove('done');
|
||
} catch (e) {}
|
||
}
|
||
|
||
// ── SPONSOR CHAT: two-way threads, presence-aware, with a slide-in drawer ──
|
||
let CHAT_ME = null, CHAT_OTHER = null, CHAT_LASTID = 0, CHAT_POLL = null, CHAT_CANMUTE = false, CHAT_IMUTE = false, CHAT_SPONSOR = null, CHAT_LAST_UNREAD = 0;
|
||
function chatSync(d) {
|
||
CHAT_ME = d.email || CHAT_ME;
|
||
CHAT_SPONSOR = (d.sponsor && d.sponsor.email) ? d.sponsor : null;
|
||
const link = $('chatMenuBtn'); if (link && link.closest('.bo-links')) link.closest('.bo-links').hidden = !d.email;
|
||
setChatBadge(d.chatUnread || 0);
|
||
CHAT_LAST_UNREAD = d.chatUnread || 0;
|
||
const tog = $('chatAvailToggle'); if (tog) tog.checked = d.chatAvailable !== false;
|
||
// Overview quick action + My line card: message-your-sponsor entry points
|
||
const qs = $('qaMsgSponsor');
|
||
if (qs) {
|
||
if (CHAT_SPONSOR) { qs.hidden = false; qs.dataset.email = CHAT_SPONSOR.email; qs.dataset.name = CHAT_SPONSOR.name || 'your sponsor'; }
|
||
else qs.hidden = true;
|
||
}
|
||
const card = $('sponsorMsgCard');
|
||
if (card) {
|
||
card.hidden = !CHAT_SPONSOR;
|
||
if (CHAT_SPONSOR && $('sponsorMsgName')) $('sponsorMsgName').textContent = CHAT_SPONSOR.name || 'your sponsor';
|
||
}
|
||
}
|
||
function setChatBadge(n) { const b = $('chatNavBadge'); if (b) { b.hidden = !n; b.textContent = n > 9 ? '9+' : n; } }
|
||
function stopPoll() { if (CHAT_POLL) { clearInterval(CHAT_POLL); CHAT_POLL = null; } }
|
||
function startPoll() { stopPoll(); CHAT_POLL = setInterval(() => pullThread(false), 4000); }
|
||
// the composer grows with the message up to ~45% of the screen, and keeps any height the member dragged it to (Bradley, 2026-09-13)
|
||
function autoGrow(el) {
|
||
const cap = Math.max(160, Math.floor(window.innerHeight * 0.45));
|
||
const dragged = Number(el.dataset.dragged || 0);
|
||
el.style.height = 'auto';
|
||
el.style.height = Math.min(cap, Math.max(dragged, el.scrollHeight)) + 'px';
|
||
}
|
||
function closeDrawer() { stopPoll(); if ($('chatDrawer')) $('chatDrawer').hidden = true; CHAT_OTHER = null; loadDashboard(); }
|
||
|
||
async function openThreads() {
|
||
const dr = $('chatDrawer'); if (!dr) return;
|
||
dr.hidden = false; $('chatConvo').hidden = true; $('chatThreads').hidden = false;
|
||
$('chatBack').hidden = true; $('chatMute').hidden = true; $('chatDot').hidden = true;
|
||
$('chatTitle').textContent = 'Messages'; $('chatStatus').textContent = '';
|
||
stopPoll(); CHAT_OTHER = null;
|
||
$('chatThreads').innerHTML = '<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('mouseup', () => { const h = inp.getBoundingClientRect().height; inp.dataset.dragged = h > 60 ? String(Math.round(h)) : ''; });
|
||
inp.addEventListener('keydown', e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); chatSend(); } });
|
||
}
|
||
on('qaMsgSponsor', 'click', () => { const q = $('qaMsgSponsor'); if (q.dataset.email) openConvo(q.dataset.email, q.dataset.name || 'your sponsor'); });
|
||
on('lineMsgSponsor', 'click', () => { if (CHAT_SPONSOR) openConvo(CHAT_SPONSOR.email, CHAT_SPONSOR.name || 'your sponsor'); });
|
||
on('chatAvailToggle', 'change', async () => {
|
||
const t = $('chatAvailToggle');
|
||
try { await api('/api/my/chat/available', { available: t.checked }); IAP.status(t.checked ? 'You are available to chat with your line.' : 'Live chat off. People can still leave you a note.', 'ok'); }
|
||
catch (e) { IAP.status(e.message, 'bad'); t.checked = !t.checked; }
|
||
});
|
||
// presence heartbeat + new-message notifier: pop + toast when unread rises
|
||
setInterval(async () => {
|
||
try {
|
||
const r = await (await fetch('/api/my/ping')).json();
|
||
const n = r.chatUnread || 0;
|
||
if (n > CHAT_LAST_UNREAD) { playSound('pop'); IAP.status('💬 New message from your team.', 'ok'); }
|
||
CHAT_LAST_UNREAD = n; setChatBadge(n);
|
||
} catch (e) {}
|
||
}, 20000);
|
||
})();
|
||
|
||
// ad surfaces: a banner greets the sign-in screen; members see live
|
||
// banner + text placements inside the back office (they ARE the audience)
|
||
IAP.adSlot('banner', 'adSlotLogin');
|
||
IAP.adSlot('banner', 'adSlotOverview');
|
||
IAP.adSlot('banner', 'adSlotSide', { width: 125, height: 125 }); // square button ad in the sidebar
|
||
|
||
render();
|
||
})();
|
||
|
||
// ── partner promo codes: typed on the Overview (Marty, 2026-09-12) ──
|
||
(function () {
|
||
const btn = document.getElementById('promoApply'), inp = document.getElementById('promoCode'), msg = document.getElementById('promoMsg');
|
||
if (!btn || !inp) return;
|
||
const say = (t, ok) => { msg.hidden = false; msg.textContent = t; msg.style.color = ok ? 'var(--mint)' : '#ff8a8a'; };
|
||
const go = async () => {
|
||
const code = inp.value.trim(); if (!code) { say('Enter a promo code.', false); return; }
|
||
btn.disabled = true;
|
||
try {
|
||
const r = await (await fetch('/api/my/promo/redeem', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code }) })).json();
|
||
if (r.error) { say(r.error, false); return; }
|
||
say('Added ' + Number(r.credits).toLocaleString() + ' credits' + (r.partner ? ' from ' + r.partner : '') + '. They are in your balance now.', true);
|
||
inp.value = ''; if (typeof loadDashboard === 'function') loadDashboard();
|
||
} catch (e) { say('Could not apply that code. Try again.', false); }
|
||
finally { btn.disabled = false; }
|
||
};
|
||
btn.addEventListener('click', go); inp.addEventListener('keydown', e => { if (e.key === 'Enter') go(); });
|
||
})();
|