LinkSpin test area: InstantAdPay engine fork rebranded, network registry, sponsor carry-over with engine activation and claim window, rotator with /r/ redirects, link-domain mini-sites

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-09-15 16:16:52 -05:00
commit 010e8d7ffc
130 changed files with 20096 additions and 0 deletions
+843
View File
@@ -0,0 +1,843 @@
// Admin portal: email-code sign-in (allowlisted to ADMIN_EMAIL on the server),
// house ads that cost nothing, every campaign, members, reports, settings.
(function () {
const $ = IAP.$;
const esc = s => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
async function api(path, body, method) {
const opts = { method: method || (body === undefined ? 'GET' : 'POST'), headers: {} };
if (body !== undefined) { opts.headers['Content-Type'] = 'application/json'; opts.body = JSON.stringify(body); }
const r = await (await fetch(path, opts)).json();
if (r.error) throw new Error(r.error === 'auth' ? 'Session expired. Sign in again.' : r.error);
return r;
}
function busy(btn, fn) {
return async (...a) => {
if (btn.disabled) return;
btn.disabled = true;
try { await fn(...a); } catch (e) { IAP.status(e.message || 'Something went wrong.', 'bad'); }
finally { btn.disabled = false; }
};
}
const when = ts => ts ? new Date(Number(ts)).toLocaleString([], { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }) : '';
let rates = {}, sizes = [], houseOwner = 'house@linkspin-test.saasy.top';
// ── sign-in ──
$('adSend').addEventListener('click', busy($('adSend'), async () => {
$('adErr').hidden = true;
const r = await api('/api/admin/auth/start', { email: $('adEmail').value });
$('adCodeRow').hidden = false; $('adVerify').hidden = false;
if (r.devCode) $('adCode').value = r.devCode;
IAP.status(r.sent ? 'Code sent. Check your inbox.' : 'Dev mode: code filled in.', 'ok');
$('adCode').focus();
}));
$('adVerify').addEventListener('click', busy($('adVerify'), async () => {
$('adErr').hidden = true;
await api('/api/admin/auth/verify', { email: $('adEmail').value, code: $('adCode').value });
await render();
}));
$('adCode').addEventListener('keydown', e => { if (e.key === 'Enter') $('adVerify').click(); });
$('adEmail').addEventListener('keydown', e => { if (e.key === 'Enter') ($('adVerify').hidden ? $('adSend') : $('adVerify')).click(); });
$('adLogout').addEventListener('click', async e => {
e.preventDefault();
try { await api('/api/admin/auth/logout', {}); } catch (err) {}
location.reload();
});
// ── panes ──
const TITLES = { overview: 'Overview', house: 'House ads', campaigns: 'All campaigns', members: 'Members', reports: 'Reports', traffic: 'Traffic', blog: 'Blog', releases: 'Releases and roadmap', pnl: 'Profit and loss', settings: 'Settings' };
const loaders = { overview: loadOverview, house: loadHouse, campaigns: loadCampaigns, members: loadMembers, reports: loadReports, traffic: loadTraffic, blog: loadBlog, releases: loadReleases, pnl: loadPnl, settings: loadSettings };
function setPane(name) {
if (!TITLES[name]) name = 'overview';
document.querySelectorAll('.pane').forEach(p => { p.hidden = p.id !== 'pane-' + name; });
document.querySelectorAll('.bo-menu [data-pane]').forEach(b => b.classList.toggle('on', b.dataset.pane === name));
$('boTitle').textContent = TITLES[name];
if (location.hash.slice(1) !== name) history.replaceState(null, '', '#' + name);
$('adminArea').classList.remove('side-open');
loaders[name]().catch(e => IAP.status(e.message, 'bad'));
}
document.querySelectorAll('.bo-menu [data-pane]').forEach(b => b.addEventListener('click', () => setPane(b.dataset.pane)));
document.addEventListener('click', e => { const g = e.target.closest('[data-goto]'); if (g) setPane(g.dataset.goto); });
window.addEventListener('hashchange', () => setPane(location.hash.slice(1)));
$('boBurger').addEventListener('click', () => $('adminArea').classList.toggle('side-open'));
async function render() {
let me = { admin: false };
try { me = await api('/api/admin/me'); } catch (e) {}
$('authArea').hidden = !!me.admin;
$('adminArea').hidden = !me.admin;
if (!me.admin) return;
$('adWho').textContent = me.email || 'admin';
try {
const c = await IAP.getConfig();
$('chainLine').textContent = c.chainName + (c.rehearsal ? ' · rehearsal' : '');
} catch (e) {}
setPane(location.hash.slice(1) || 'overview');
}
// ── overview ──
async function loadOverview() {
const o = await api('/api/admin/overview');
rates = o.rates || rates;
$('ovAccounts').textContent = (o.accounts || 0).toLocaleString();
$('ovMembers').textContent = o.memberCount == null ? '?' : Number(o.memberCount).toLocaleString();
$('ovActive').textContent = (o.byStatus && o.byStatus.active) || 0;
$('ovCampSub').textContent = o.campaigns + ' total · ' + o.house + ' house';
$('ovReports').textContent = o.openReports || 0;
$('ovBurnSub').textContent = (o.pendingBurns || 0) + ' pending burns';
$('repBadge').hidden = !o.openReports; $('repBadge').textContent = o.openReports || '';
const f = o.followups || {};
$('ovDrips').textContent = f.active || 0;
$('ovDripSub').textContent = (f.done || 0) + ' finished · ' + (f.unsubscribed || 0) + ' unsubscribed';
const bt = Object.entries(o.byType || {}).sort((a, b) => b[1] - a[1]);
$('ovByType').innerHTML = bt.length ? bt.map(([t, n]) => '<div style="display:flex;justify-content:space-between;padding:4px 0;border-bottom:1px solid var(--line)"><span>' + esc(t) + '</span><b>' + n + '</b></div>').join('') : 'No campaigns yet.';
const ch = o.chain || {};
$('ovChain').innerHTML = '<div>' + esc(ch.chainName) + ' (chain ' + esc(ch.chainId) + ')</div>'
+ '<div class="mono" style="word-break:break-all;margin:6px 0">' + esc(ch.contract) + '</div>'
+ (ch.explorer ? '<a href="' + esc(ch.explorer) + '/address/' + esc(ch.contract) + '" target="_blank" rel="noopener">Open in explorer →</a>' : '');
}
// ── house ads ──
const HROWS = { banner: ['hBannerRow'], text: ['hTextRow'], login: [], solo: ['hSoloRow'], video: ['hVideoRow'], featured: ['hFeatRow'], visits: ['hVisitsRow'] };
function showHouseRows() {
const t = $('hType').value;
['hBannerRow', 'hTextRow', 'hSoloRow', 'hVideoRow', 'hFeatRow', 'hVisitsRow'].forEach(id => { $(id).hidden = !(HROWS[t] || []).includes(id); });
$('hBudget').hidden = t === 'featured' || t === 'visits';
houseHints();
}
function houseHints() {
const r = rates || {};
const soloCost = r.soloCostPerRecipient || 5, soloMin = r.soloMinRecipients || 10;
const cap = Number($('hBudget').value) || 100000;
$('hSoloHint').textContent = 'Delivers to one inbox per ' + soloCost + ' credits of cap (minimum ' + soloMin + ' recipients). A cap of ' + cap.toLocaleString() + ' reaches up to ' + Math.floor(cap / soloCost).toLocaleString() + ' members.';
const days = Number($('hFeatDays').value) || 0;
$('hFeatHint').textContent = days ? days + '-day run in the featured strip (' + (r.featuredPerDay || 40) + ' credits/day, free here). Book up to ' + (r.featuredWindowDays || 7) + ' days ahead.' : '';
const n = Number($('hVisitCount').value) || 0;
$('hVisitHint').textContent = 'Packs start at ' + (r.visitMinPack || 20) + ' visits.' + (n ? ' ' + n + ' verified visits, delivered one per member.' : '');
}
$('hType').addEventListener('change', showHouseRows);
['hBudget', 'hFeatDays', 'hVisitCount'].forEach(id => $(id).addEventListener('input', houseHints));
$('hFeatDays').addEventListener('change', houseHints);
$('hImageUploadBtn').addEventListener('click', () => $('hImageFile').click());
$('hVideoUploadBtn').addEventListener('click', () => $('hVideoFile').click());
async function upload(fileInput, info, target, kind) {
const f = fileInput.files[0]; if (!f) return;
info.textContent = 'Uploading ' + f.name + '…';
try {
const r = await (await fetch('/api/admin/upload', { method: 'POST', headers: { 'Content-Type': f.type }, body: f })).json();
if (r.error) { info.textContent = r.error; }
else { target.value = r.url; info.textContent = f.name + ' uploaded'; }
} catch (e) { info.textContent = 'Upload failed. Try again.'; }
fileInput.value = '';
}
$('hImageFile').addEventListener('change', () => upload($('hImageFile'), $('hImageInfo'), $('hImage')));
$('hVideoFile').addEventListener('change', () => upload($('hVideoFile'), $('hVideoInfo'), $('hVideoUrl')));
let hVidDims = null;
function probeVideoDims(url) {
return new Promise(resolve => {
const v = document.createElement('video'); v.preload = 'metadata'; v.muted = true;
const done = d => { v.src = ''; resolve(d); };
v.onloadedmetadata = () => done(v.videoWidth && v.videoHeight ? { w: v.videoWidth, h: v.videoHeight } : null);
v.onerror = () => done(null);
setTimeout(() => done(null), 12000);
v.src = url;
});
}
$('hCreate').addEventListener('click', busy($('hCreate'), async () => {
$('hErr').hidden = true;
const t = $('hType').value;
if (t === 'video' && $('hVideoUrl').value) hVidDims = await probeVideoDims($('hVideoUrl').value);
const days = Number($('hFeatDays').value), count = Number($('hVisitCount').value);
const body = { type: t, name: $('hName').value, targetUrl: $('hTarget').value,
imageUrl: $('hImage').value, size: $('hSize').value,
title: t === 'video' ? $('hVideoTitle').value : t === 'featured' ? $('hFeatTitle').value : t === 'visits' ? $('hVisitTitle').value : t === 'solo' ? $('hSoloTitle').value : $('hTitle').value,
body: t === 'solo' ? $('hSoloBody').value : $('hBody').value,
ctaLabel: t === 'video' ? $('hVideoCta').value : $('hSoloCta').value,
videoUrl: $('hVideoUrl').value, watchSecs: Number($('hWatchSecs').value),
videoW: hVidDims ? hVidDims.w : null, videoH: hVidDims ? hVidDims.h : null,
days, startDay: Number($('hFeatStart').value) || 0, count,
budget: t === 'featured' ? days * (rates.featuredPerDay || 40)
: t === 'visits' ? count * (rates.visitCostPerVisit || 3)
: (Number($('hBudget').value) || 0) };
try {
await api('/api/admin/campaigns', body);
} catch (e) { $('hErr').textContent = e.message; $('hErr').hidden = false; throw e; }
IAP.status('House ad is live. It serves right away at no cost.', 'ok');
['hName', 'hBudget', 'hTarget', 'hImage', 'hTitle', 'hBody', 'hSoloTitle', 'hSoloBody', 'hSoloCta',
'hVideoUrl', 'hVideoTitle', 'hVideoCta', 'hFeatTitle', 'hVisitTitle', 'hVisitCount'].forEach(id => { $(id).value = ''; });
$('hImageInfo').textContent = ''; $('hVideoInfo').textContent = ''; hVidDims = null;
await loadHouse();
}));
function campRow(c, showOwner) {
const left = Math.max(0, (c.budget || 0) - (c.spent || 0));
const creative = c.type === 'banner' && c.imageUrl ? '<img src="' + esc(c.imageUrl) + '" alt="" style="max-height:34px;max-width:120px;border-radius:4px">' : esc(c.title || c.name);
const act = c.status === 'active' ? '<button class="btn small sec" data-act="pause" data-id="' + c.id + '">Pause</button>'
: c.status === 'paused' ? '<button class="btn small" data-act="resume" data-id="' + c.id + '">Resume</button>' : '';
return '<tr><td>#' + c.id + (c.house ? '<span class="house-tag">HOUSE</span>' : '') + '</td>'
+ (showOwner ? '<td><span class="trunc" title="' + esc(c.owner) + '">' + esc(c.house ? 'house' : c.owner) + '</span></td>' : '')
+ '<td>' + esc(c.type) + '</td>'
+ '<td>' + esc(c.name) + '<div class="small muted">' + creative + '</div><a class="small trunc" href="' + esc(c.targetUrl) + '" target="_blank" rel="noopener">' + esc(c.targetUrl) + '</a></td>'
+ '<td><span class="st ' + esc(c.status) + '">' + esc(c.status) + '</span></td>'
+ '<td class="mono small">' + (c.spent || 0).toLocaleString() + ' / ' + (c.budget || 0).toLocaleString() + '<div class="muted">' + left.toLocaleString() + ' left</div></td>'
+ '<td class="mono small">' + (c.imps || 0).toLocaleString() + (c.impsNas ? ' +' + c.impsNas + ' nas' : '') + '<div class="muted">' + (c.clicks || 0) + ' clicks</div></td>'
+ '<td class="small muted">' + when(c.created) + '</td>'
+ '<td class="act">' + act + '</td></tr>';
}
function campHead(showOwner) {
return '<tr><th>ID</th>' + (showOwner ? '<th>Owner</th>' : '') + '<th>Type</th><th>Campaign</th><th>Status</th><th>Spent / cap</th><th>Delivery</th><th>Created</th><th></th></tr>';
}
async function loadHouse() {
const r = await api('/api/admin/campaigns');
rates = r.rates || rates; sizes = r.bannerSizes || sizes; houseOwner = r.houseOwner || houseOwner;
if (!$('hSize').options.length) $('hSize').innerHTML = sizes.map(s => '<option value="' + esc(s.id) + '">' + esc(s.label || s.id) + ' (' + s.w + '×' + s.h + ')</option>').join('');
if (!$('hWatchSecs').options.length) $('hWatchSecs').innerHTML = (rates.videoTiers || []).map(t => '<option value="' + t.secs + '">Watch ' + t.secs + 's (viewer earns ' + t.reward + ')</option>').join('');
if (!$('hFeatDays').options.length) $('hFeatDays').innerHTML = (rates.featuredDurations || [1, 2, 7]).map(d => '<option value="' + d + '">' + d + ' day' + (d > 1 ? 's' : '') + '</option>').join('');
showHouseRows();
loadWallAds();
const house = (r.campaigns || []).filter(c => c.house);
$('houseSub').textContent = house.filter(c => c.status === 'active').length + ' active · ' + house.length + ' total';
$('houseTable').innerHTML = house.length ? campHead(false) + house.map(c => campRow(c, false)).join('') : '<tr><td class="muted">No house ads yet. Place one above.</td></tr>';
}
// wall fallback ads editor
let wallAds = [];
function drawWallAds() {
const w = $('wallAdsList');
w.innerHTML = wallAds.map((a, i) => '<div class="drip-step" data-i="' + i + '"><div class="ds-head"><span class="ds-n">WALL AD ' + (i + 1) + '</span>'
+ '<span class="ds-tools"><button type="button" class="btn small sec" data-wact="up" ' + (i === 0 ? 'disabled' : '') + '>↑</button><button type="button" class="btn small sec" data-wact="down" ' + (i === wallAds.length - 1 ? 'disabled' : '') + '>↓</button><button type="button" class="btn small sec" data-wact="remove">Remove</button></span></div>'
+ '<div class="grid c3"><p><input class="wa-name" maxlength="60" placeholder="Label shown under the ad" value="' + esc(a.name || '') + '"></p>'
+ '<p><input class="wa-target" placeholder="Link (https://…)" value="' + esc(a.targetUrl || '') + '"></p>'
+ '<p><input class="wa-banner" placeholder="Banner image URL or upload" value="' + esc(a.bannerUrl || '') + '"> <button type="button" class="btn small sec wa-upload">Upload</button><input type="file" class="wa-file" accept="image/png,image/jpeg,image/webp,image/gif" hidden></p></div>'
+ (a.bannerUrl ? '<img src="' + esc(a.bannerUrl) + '" alt="" style="max-height:60px;border-radius:6px">' : '')
+ '</div>').join('') || '<p class="muted small">No wall ads set. Walls fall back to a plain LinkSpin card.</p>';
}
function readWallAds() {
return [...document.querySelectorAll('#wallAdsList .drip-step')].map(c => ({ name: c.querySelector('.wa-name').value.trim(), targetUrl: c.querySelector('.wa-target').value.trim(), bannerUrl: c.querySelector('.wa-banner').value.trim() }));
}
async function loadWallAds() {
try { const r = await api('/api/admin/wall-ads'); wallAds = r.ads || []; $('wallAdsSub').textContent = r.usingDefaults ? 'none set: walls show the default LinkSpin card' : wallAds.length + ' in rotation'; drawWallAds(); } catch (e) {}
}
$('wallAdsList').addEventListener('click', async e => {
const up = e.target.closest('.wa-upload');
if (up) { up.parentElement.querySelector('.wa-file').click(); return; }
const b = e.target.closest('[data-wact]'); if (!b) return;
const i = Number(b.closest('.drip-step').dataset.i); wallAds = readWallAds();
if (b.dataset.wact === 'remove') wallAds.splice(i, 1);
if (b.dataset.wact === 'up' && i > 0) [wallAds[i - 1], wallAds[i]] = [wallAds[i], wallAds[i - 1]];
if (b.dataset.wact === 'down' && i < wallAds.length - 1) [wallAds[i + 1], wallAds[i]] = [wallAds[i], wallAds[i + 1]];
drawWallAds();
});
$('wallAdsList').addEventListener('change', async e => {
const f = e.target.closest('.wa-file'); if (!f || !f.files[0]) return;
const file = f.files[0]; const card = f.closest('.drip-step');
try {
const r = await (await fetch('/api/admin/upload', { method: 'POST', headers: { 'Content-Type': file.type }, body: file })).json();
if (r.error) IAP.status(r.error, 'bad'); else { card.querySelector('.wa-banner').value = r.url; IAP.status('Uploaded.', 'ok'); }
} catch (err) { IAP.status('Upload failed.', 'bad'); }
f.value = '';
});
$('wallAdsAdd').addEventListener('click', () => { wallAds = readWallAds(); wallAds.push({ name: '', targetUrl: '', bannerUrl: '' }); drawWallAds(); });
$('wallAdsSave').addEventListener('click', busy($('wallAdsSave'), async () => {
$('wallAdsErr').hidden = true;
try { const r = await api('/api/admin/wall-ads', { ads: readWallAds() }, 'PATCH'); wallAds = r.ads || []; drawWallAds(); IAP.status('Wall ads saved.', 'ok'); await loadWallAds(); }
catch (e) { $('wallAdsErr').textContent = e.message; $('wallAdsErr').hidden = false; }
}));
document.addEventListener('click', async e => {
const b = e.target.closest('[data-act][data-id]'); if (!b) return;
b.disabled = true;
try {
await api('/api/admin/campaigns/' + b.dataset.id + '/' + b.dataset.act, {});
IAP.status('Campaign #' + b.dataset.id + ' ' + (b.dataset.act === 'pause' ? 'paused' : 'resumed') + '.', 'ok');
await Promise.all([loadHouse(), loadCampaigns()]);
} catch (err) { IAP.status(err.message, 'bad'); b.disabled = false; }
});
// ── all campaigns ──
let allCamps = [];
async function loadCampaigns() {
const r = await api('/api/admin/campaigns');
allCamps = r.campaigns || [];
drawCamps();
}
function drawCamps() {
const q = ($('campFilter').value || '').trim().toLowerCase();
const list = allCamps.filter(c => !q || [c.owner, c.name, c.type, c.status, c.targetUrl, String(c.id)].join(' ').toLowerCase().includes(q));
$('campSub').textContent = list.length + ' of ' + allCamps.length;
$('campTable').innerHTML = list.length ? campHead(true) + list.map(c => campRow(c, true)).join('') : '<tr><td class="muted">Nothing matches.</td></tr>';
}
$('campFilter').addEventListener('input', drawCamps);
// ── members ──
let allMembers = [];
async function loadTank() {
try {
const r = await (await fetch('/api/admin/tank')).json(); if (r.error) return;
$('tankAdmSub').textContent = r.waiting.length + ' waiting · cap ' + r.cap + ' open per adopter · ' + r.ttlDays + '-day window';
$('tankWait').innerHTML = '<tr><th>Waiting</th><th>Email</th><th>Joined</th><th>Last sign-in</th></tr>' + (r.waiting.length ? r.waiting.map(w => '<tr><td>' + esc(w.name) + '</td><td>' + esc(w.email) + '</td><td class="when">' + when(w.joined) + '</td><td class="when">' + (w.lastSeen ? when(w.lastSeen) : '<span class="muted">never</span>') + '</td></tr>').join('') : '<tr><td colspan="4" class="muted">empty</td></tr>');
$('tankAdopt').innerHTML = '<tr><th>Member</th><th>Adopted by</th><th>When</th><th>Window ends</th><th>Status</th></tr>' + (r.adoptions.length ? r.adoptions.map(a => '<tr><td>' + esc(a.adopteeName) + '</td><td>' + esc(a.adopterName) + '</td><td class="when">' + when(a.ts) + '</td><td class="when">' + (a.status === 'released' ? '' : when(a.expires)) + '</td><td>' + esc(a.status) + '</td></tr>').join('') : '<tr><td colspan="5" class="muted">none yet</td></tr>');
} catch (e) {}
}
async function loadMembers() {
loadTank();
const r = await api('/api/admin/members');
allMembers = r.members || [];
drawMembers();
}
function drawMembers() {
const q = ($('memFilter').value || '').trim().toLowerCase();
const list = allMembers.filter(a => !q || [a.email, a.username, a.memberId, a.sponsorRef, a.address, a.code].join(' ').toLowerCase().includes(q));
$('memSub').textContent = list.length + ' of ' + allMembers.length;
$('memTable').innerHTML = '<tr><th>Email</th><th>Username</th><th>Member #</th><th>Wallet</th><th>Sponsor</th><th>Positions</th><th>Via</th><th>Code</th><th>Joined</th><th></th></tr>'
+ list.map(a => '<tr><td>' + esc(a.email) + '</td><td>' + (a.username ? '@' + esc(a.username) : '<span class="muted">none</span>') + '</td>'
+ '<td>' + (a.memberId ? '#' + a.memberId : '<span class="muted">free</span>') + '</td>'
+ '<td class="mono small">' + (a.address ? esc(a.address.slice(0, 8) + '…' + a.address.slice(-6)) : '<span class="muted">none</span>') + '</td>'
+ '<td>' + (a.sponsorName ? esc(a.sponsorName) + (a.sponsorVia === 'code' ? ' <span class="muted small" title="joined through this share code">via code ' + esc(a.sponsorRef) + '</span>' : a.sponsorVia === 'member #' ? ' <span class="muted small">via #' + esc(a.sponsorRef) + '</span>' : '') : a.sponsorRef ? '<span class="badge amber" title="this token points at nobody; the member will move to the holding tank">dead link: ' + esc(a.sponsorRef) + '</span>' : '<span class="muted">none</span>') + '</td><td class="small" title="linked Qualified Start positions' + (a.positionIds && a.positionIds.length ? ': #' + a.positionIds.join(', #') : '') + '">' + (a.positions ? a.positions : '<span class="muted">0</span>') + '</td><td class="small muted">' + esc(a.joinedVia || '') + '</td><td class="mono small">' + esc(a.code || '') + '</td>'
+ '<td class="small muted when">' + when(a.created) + '</td>'
+ '<td class="act"><button class="btn small sec" data-mcopen="' + esc(a.email) + '">Open</button> <button class="btn small sec" data-spon="' + esc(a.email) + '" data-cur="' + esc(a.sponsorRef || '') + '">Sponsor</button></td></tr>').join('');
}
$('memFilter').addEventListener('input', drawMembers);
document.addEventListener('click', async e => {
const b = e.target.closest('[data-spon]'); if (!b) return;
const v = await IAP.ask({ title: 'Sponsor for ' + b.dataset.spon, text: 'Username, share code, or member #. Leave blank to clear.', value: b.dataset.cur, ok: 'Save' });
if (v === null || v === undefined) return;
try {
await api('/api/admin/members', { email: b.dataset.spon, sponsorRef: v.trim() }, 'PATCH');
IAP.status('Sponsor updated.', 'ok');
await loadMembers();
} catch (err) { IAP.status(err.message, 'bad'); }
});
// ── member card: search, drill down, act (Marty, 2026-09-13) ──
let mcCur = null;
const polOf = w => { try { return (Number(BigInt(w || '0') / 10n ** 14n) / 10000).toLocaleString(undefined, { maximumFractionDigits: 2 }); } catch (e) { return '0'; } };
const ago = ts => { if (!ts) return 'never'; const d = Date.now() - Number(ts); const h = Math.floor(d / 3600000); return h < 1 ? Math.max(1, Math.floor(d / 60000)) + ' min ago' : h < 48 ? h + ' h ago' : Math.floor(h / 24) + ' days ago'; };
const memLink = (email, label) => '<a href="#" data-mcopen="' + esc(email) + '">' + esc(label) + '</a>';
async function openMember(q) {
const msg = $('memSearchMsg'); msg.hidden = true;
let d;
try { d = await api('/api/admin/member?q=' + encodeURIComponent(q)); } catch (e) { msg.textContent = e.message; msg.hidden = false; msg.className = 'small bad'; return; }
renderMember(d);
}
function kv(rows) { return '<table class="adm-table kv">' + rows.map(r => '<tr><th style="width:170px">' + r[0] + '</th><td>' + r[1] + '</td></tr>').join('') + '</table>'; }
function renderMember(d) {
mcCur = d; const a = d.account; $('memHits').hidden = true;
$('memCard').hidden = false; document.querySelectorAll('#pane-members > .card').forEach(c => { if (c.id !== 'memCard' && c.id !== 'memSearchCard') c.hidden = true; });
$('mcName').textContent = (a.username ? '@' + a.username : a.email) + (a.memberId ? ' · member #' + a.memberId : ' · free member');
$('mcSub').textContent = 'joined ' + when(a.created) + ' · last seen ' + ago(a.lastSeen);
$('mcWall').hidden = !a.username; if (a.username) $('mcWall').href = '/wall/' + a.username;
const ch = d.chain, cr = d.credits, t = d.totals;
const level = ch && !ch.readError ? (ch.buyerCount >= 5 ? 'level 3 (5+ buyers)' : ch.buyerCount >= 2 ? 'level 2 (2 buyers)' : 'level 1') : '';
let h = '<div class="grid c2">';
h += '<div><h4 style="margin:0 0 6px">Identity</h4>' + kv([
['Email', esc(a.email)], ['Username', a.username ? '@' + esc(a.username) : '<span class="muted">not set</span>'], ['Share code', esc(a.code || '')],
['Main wallet', a.address ? '<span class="mono small">' + esc(a.address) + '</span>' : '<span class="muted">none linked</span>'],
['Extra positions', d.positions.length ? d.positions.map(p => '<span class="mono small">' + esc(p.address.slice(0, 8) + '…' + p.address.slice(-6)) + '</span>' + (p.memberId ? ' = #' + p.memberId : ' (unregistered)')).join('<br>') : '<span class="muted">none</span>'],
['Sponsor (site)', d.upline.length ? memLink(d.upline[0].email, d.upline[0].name) + ' <span class="muted small">token ' + esc(a.sponsorRef || '') + '</span>' : (a.sponsorRef ? '<span class="muted">unresolved: ' + esc(a.sponsorRef) + '</span>' : '<span class="muted">none (company)</span>')],
['Upline chain', d.upline.length > 1 ? d.upline.map(u => memLink(u.email, u.name)).join(' &rarr; ') : '<span class="muted">-</span>'],
['Joined via', esc(a.joinedVia || 'join page') + (a.joinedRef ? ' from ' + esc(a.joinedRef) : '')],
['Line banner', a.lineTargetUrl ? '<a href="' + esc(a.lineTargetUrl) + '" target="_blank" rel="noopener">' + esc(a.lineTargetUrl.slice(0, 50)) + '</a>' : '<span class="muted">not set</span>'],
['Chat', a.chatAvailable ? 'available' : 'switched off']]) + '</div>';
h += '<div><h4 style="margin:0 0 6px">On-chain and money</h4>' + kv([
['Registered', ch ? (ch.readError ? 'read error' : 'yes, #' + ch.memberId + ' under ' + (ch.sponsorId ? '#' + ch.sponsorId + (d.names[ch.sponsorId] ? ' @' + esc(d.names[ch.sponsorId]) : '') : 'nobody')) : '<span class="muted">no (payouts off)</span>'],
['Qualifying buyers', ch && !ch.readError ? ch.buyerCount + ' · ' + level : '-'],
['Packages bought', t.purchases + (t.purchases ? ' · $' + (t.spentCents / 100).toFixed(0) + ' · ' + polOf(t.spentWei) + ' POL' : '')],
['Payouts received', t.payoutsIn + (t.payoutsIn ? ' · ' + polOf(t.receivedWei) + ' POL' : '')],
['Credits', cr ? cr.available.toLocaleString() + ' available · ' + cr.inCampaigns.toLocaleString() + ' in campaigns · ' + cr.total.toLocaleString() + ' total' : '<span class="muted">-</span>'],
['Earned pool', d.earnedSplit ? d.earnedSplit.total.toLocaleString() + ' (' + (d.earnedSplit.grade || 0).toLocaleString() + ' purchased-grade)' : '-'],
['Old-site account', d.legacy ? 'had a ' + (d.legacy.brand === 'both' ? 'Faucet Wave and Tier One Ads' : d.legacy.brand === 'tier1ads' ? 'Tier One Ads' : 'Faucet Wave') + ' account (' + d.legacy.seg + ') · welcome-back credits ' + (d.legacy.grant ? d.legacy.grant.credits + ' issued ' + when(d.legacy.grant.at) : 'not issued (joined outside the legacy bridge)') : '<span class="muted">none on record</span>'],
['Promo codes', d.promos.length ? d.promos.map(p => esc(p.code) + ' (' + p.credits + ', ' + when(p.ts) + ')').join('<br>') : '<span class="muted">none</span>'],
['Drip', d.drip ? (d.drip.stopped ? 'stopped' : 'step ' + d.drip.step + ', next ' + when(d.drip.next_at)) + (d.drip.angle ? ' · ' + esc(d.drip.angle) : '') : '<span class="muted">-</span>'],
['Holding tank', d.tank ? (d.tank.waiting ? '<b>waiting for a sponsor</b>' : 'not in tank') + (d.tank.adoptedBy.length ? ' · adopted by ' + d.tank.adoptedBy.map(x => memLink(x.email, x.name)).join(', ') : '') + (d.tank.adopted.length ? ' · adopted ' + d.tank.adopted.map(x => memLink(x.email, x.name)).join(', ') : '') : '-'],
['Earning', d.earning ? 'today ' + d.earning.today + '/5' + (d.earning.claimed ? ' claimed' : '') + ' · streak day ' + d.earning.streakDay + (d.activeDays14 !== undefined ? ' · active ' + d.activeDays14 + ' of last 14 days, ' + d.claims14 + ' claims' : '') : '-'],
['Visits / videos / chat', (d.visits || 0) + ' verified visits · ' + (d.videos || 0) + ' video watches · ' + (d.messageCount || 0) + ' messages']]) + '</div></div>';
// line
h += '<h4 style="margin:18px 0 6px">Line (' + d.lineCounts.join(' / ') + ')</h4>';
if (!d.line.length) h += '<p class="muted small">Nobody in their line yet.</p>';
for (const L of d.line) {
h += '<p class="small muted" style="margin:8px 0 4px">Level ' + L.level + ' · ' + L.members.length + '</p><div class="tablewrap"><table class="adm-table"><tr><th>Member</th><th>Member #</th><th>Wallet</th><th>Bought</th><th>Qualified</th><th>Joined</th><th>Last seen</th></tr>'
+ L.members.map(m => '<tr><td>' + memLink(m.email, m.name) + (L.level === 1 ? '<br><span class="muted small">' + esc(m.email) + '</span>' : '') + '</td><td>' + (m.memberId ? '#' + m.memberId : '<span class="muted">free</span>') + '</td><td>' + (m.wallet ? 'yes' : '<span class="muted">no</span>') + '</td><td>' + (m.bought ? 'yes' : '<span class="muted">no</span>') + '</td><td>' + (m.qualified ? '<span class="chip-t on">yes</span>' : '') + '</td><td class="small muted">' + when(m.joined) + '</td><td class="small muted">' + ago(m.lastSeen) + '</td></tr>').join('') + '</table></div>';
}
// purchases + payouts + campaigns
h += '<div class="grid c2" style="margin-top:18px"><div><h4 style="margin:0 0 6px">Purchases</h4><div class="tablewrap"><table class="adm-table"><tr><th>When</th><th>Position</th><th>Package</th><th>Paid</th><th>Tx</th></tr>'
+ (d.purchases.length ? d.purchases.map(p => '<tr><td class="small">' + when(p.ts) + '</td><td>#' + p.buyerId + '</td><td>$' + (p.priceCents / 100).toFixed(0) + ' · ' + Number(p.credits || 0).toLocaleString() + ' cr</td><td>' + polOf(p.paidWei) + ' POL</td><td><a href="/tx/' + esc(p.tx) + '" target="_blank" rel="noopener" class="mono small">' + esc(p.tx.slice(0, 10)) + '…</a></td></tr>').join('') : '<tr><td colspan="5" class="muted">No purchases.</td></tr>') + '</table></div></div>';
h += '<div><h4 style="margin:0 0 6px">Payouts received</h4><div class="tablewrap"><table class="adm-table"><tr><th>When</th><th>From</th><th>Tier</th><th>Amount</th></tr>'
+ (d.received.length ? d.received.map(r => '<tr><td class="small">' + when(r.ts) + '</td><td>#' + r.buyerId + (d.names[r.buyerId] ? ' @' + esc(d.names[r.buyerId]) : '') + '</td><td>' + r.tier + '</td><td>' + polOf(r.amountWei) + ' POL</td></tr>').join('') : '<tr><td colspan="4" class="muted">Nothing received yet.</td></tr>') + '</table></div></div></div>';
h += '<h4 style="margin:18px 0 6px">Campaigns (' + d.campaigns.length + ')</h4><div class="tablewrap"><table class="adm-table"><tr><th>#</th><th>Type</th><th>Status</th><th>Budget</th><th>Spent</th><th>Views</th><th>Clicks</th><th>Created</th></tr>'
+ (d.campaigns.length ? d.campaigns.map(c => '<tr><td>' + c.id + '</td><td>' + esc(c.type) + '</td><td>' + esc(c.status) + '</td><td>' + Number(c.budget || 0).toLocaleString() + '</td><td>' + Number(c.spent || 0).toLocaleString() + '</td><td>' + Number(c.views || 0).toLocaleString() + '</td><td>' + Number(c.clicks || 0).toLocaleString() + '</td><td class="small muted">' + when(c.created) + '</td></tr>').join('') : '<tr><td colspan="8" class="muted">No campaigns.</td></tr>') + '</table></div>';
$('mcBody').innerHTML = h;
if (location.hash !== '#members') history.replaceState(null, '', '#members');
}
function closeMember() { $('memCard').hidden = true; document.querySelectorAll('#pane-members > .card').forEach(c => { c.hidden = false; }); }
// live matches while typing: any part of the username, email, member #, share code or wallet
let memHitList = [];
function memMatches(q) {
q = q.toLowerCase();
return allMembers.filter(a => [a.username, a.email, a.memberId ? '#' + a.memberId : '', a.memberId, a.code, a.address, a.sponsorName].filter(Boolean).join(' ').toLowerCase().includes(q)).slice(0, 12);
}
async function memTypeahead() {
const q = $('memSearch').value.trim();
if (!allMembers.length) { try { const r = await api('/api/admin/members'); allMembers = r.members || []; } catch (e) {} }
if (q.length < 2) { $('memHits').hidden = true; memHitList = []; return; }
memHitList = memMatches(q);
$('memHits').innerHTML = memHitList.length ? memHitList.map(a => '<button type="button" data-mcopen="' + esc(a.email) + '" style="display:flex;gap:12px;width:100%;text-align:left;background:transparent;border:0;border-bottom:1px solid var(--line);padding:8px 12px;color:inherit;cursor:pointer;font:inherit"><b style="min-width:140px">' + (a.username ? '@' + esc(a.username) : '<span class="muted">no username</span>') + '</b><span>' + esc(a.email) + '</span><span class="muted">' + (a.memberId ? '#' + a.memberId : 'free') + (a.sponsorName ? ' · under ' + esc(a.sponsorName) : '') + '</span></button>').join('')
: '<p class="muted small" style="margin:0;padding:8px 12px">No member matches that.</p>';
$('memHits').hidden = false;
}
$('memSearch').addEventListener('input', memTypeahead);
$('memSearch').addEventListener('focus', memTypeahead);
$('memOpen').addEventListener('click', () => { const q = $('memSearch').value.trim(); if (!q) return; if (memHitList.length) openMember(memHitList[0].email); else openMember(q); });
$('memSearch').addEventListener('keydown', e => { if (e.key === 'Enter') $('memOpen').click(); if (e.key === 'Escape') $('memHits').hidden = true; });
document.addEventListener('click', e => { if (!e.target.closest('#memSearchCard')) $('memHits').hidden = true; });
$('mcBack').addEventListener('click', closeMember);
document.addEventListener('click', e => { const l = e.target.closest('[data-mcopen]'); if (l) { e.preventDefault(); openMember(l.dataset.mcopen); } });
document.querySelectorAll('[data-mcact]').forEach(b => b.addEventListener('click', busy(b, async () => {
if (!mcCur) return; const a = mcCur.account, act = b.dataset.mcact; let body = null;
if (act === 'username') { const v = await IAP.ask({ title: 'Username for ' + a.email, text: '3-20 letters, numbers or underscore. Changing it breaks any invite links they already handed out.', value: a.username || '', ok: 'Save' }); if (v === null || v === undefined) return; body = { username: v }; }
if (act === 'sponsor') { const v = await IAP.ask({ title: 'Sponsor for ' + (a.username ? '@' + a.username : a.email), text: 'Username, share code or member #. Blank = no sponsor (company). Re-points free referrals and future purchases; on-chain sponsorship never changes.', value: a.sponsorRef || '', ok: 'Save' }); if (v === null || v === undefined) return; body = { sponsorRef: v }; }
if (act === 'wallet') { const v = await IAP.ask({ title: 'Main wallet for ' + a.email, text: 'Paste the 0x address that should be their main wallet (the one that paid, if a purchase came from an unlinked account). The member number is re-read from the chain. Blank = unlink.', value: a.address || '', ok: 'Swap' }); if (v === null || v === undefined) return; if (!await IAP.confirmBox('Swap the main wallet for ' + a.email + ' to ' + (v.trim() || 'nothing') + '?', { title: 'Sure?', ok: 'Swap it', cancel: 'Cancel' })) return; body = { address: v }; }
if (act === 'credits') { const v = await IAP.ask({ title: 'Grant credits to ' + (a.username ? '@' + a.username : a.email), text: 'Whole number of earned-pool credits (1 credit = 1 cent of delivery). They can spend them on campaigns right away.', type: 'number', value: '', placeholder: '250', ok: 'Grant' }); if (!v) return; const note = await IAP.ask({ title: 'Reason (kept in the server log)', value: '', placeholder: 'e.g. refund for broken banner', ok: 'Grant' }); body = { grantCredits: v, note: note || '' }; }
if (act === 'delete') {
if (a.memberId) { IAP.status('Registered members cannot be deleted; their position is on-chain.', 'bad'); return; }
if (!await IAP.confirmBox('Delete the free account ' + a.email + '? Their sign-in, referrals link and credits go away. There is no undo.', { title: 'Delete account', ok: 'Delete', cancel: 'Keep it' })) return;
await api('/api/admin/member?email=' + encodeURIComponent(a.email), undefined, 'DELETE'); IAP.status('Account deleted.', 'ok'); closeMember(); loadMembers().catch(() => {}); return;
}
if (!body) return;
const d = await api('/api/admin/member', Object.assign({ email: a.email }, body), 'PATCH');
renderMember(d); IAP.status('Saved.', 'ok'); loadMembers().catch(() => {});
})));
// ── every admin table: click a header to sort (numbers sort as numbers), inputs with
// class "tfilter" filter the table named in data-for ──
document.addEventListener('click', e => {
const th = e.target.closest('.adm-table th'); if (!th || th.closest('table').classList.contains('kv')) return;
const table = th.closest('table'), hdr = th.parentElement, idx = [...hdr.children].indexOf(th);
const rows = [...table.querySelectorAll('tr')].filter(r => r !== hdr && r.children.length > 1);
const num = s => { const t = String(s).replace(/[$,%\s]/g, '').replace(/…$/, ''); return t !== '' && !isNaN(t) ? Number(t) : null; };
const dir = th.dataset.dir === 'asc' ? 'desc' : 'asc';
hdr.querySelectorAll('th').forEach(x => { delete x.dataset.dir; x.classList.remove('sort-asc', 'sort-desc'); });
th.dataset.dir = dir; th.classList.add('sort-' + dir);
rows.sort((r1, r2) => { const a = (r1.children[idx] || {}).textContent || '', b = (r2.children[idx] || {}).textContent || ''; const na = num(a), nb = num(b); const c = na !== null && nb !== null ? na - nb : a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' }); return dir === 'asc' ? c : -c; });
rows.forEach(r => (hdr.parentElement).appendChild(r));
});
document.addEventListener('input', e => {
const inp = e.target.closest('.tfilter'); if (!inp) return;
const table = $(inp.dataset.for); if (!table) return;
const q = inp.value.trim().toLowerCase(); let shown = 0;
[...table.querySelectorAll('tr')].forEach((r, i) => { if (i === 0 || r.querySelector('th')) return; const hit = !q || r.textContent.toLowerCase().includes(q); r.hidden = !hit; if (hit) shown++; });
const c = inp.parentElement.querySelector('.tfilter-count'); if (c) c.textContent = q ? shown + ' shown' : '';
});
// ── release notes + roadmap (Marty, 2026-09-14) ──
async function loadReleases() {
loadUpdates();
const d = await api('/api/admin/releases');
$('rnSub').textContent = d.notes.length + ' notes'; $('rmSub').textContent = d.roadmap.length + ' items';
$('rnTable').innerHTML = '<tr><th>Date</th><th>Title</th><th>Tags</th><th></th></tr>' + (d.notes.length ? d.notes.map(n => '<tr><td class="small">' + esc(n.date) + '</td><td><b>' + esc(n.title) + '</b></td><td class="small">' + esc(n.tags.join(', ')) + '</td><td class="act"><button type="button" class="btn small sec" data-rnedit="' + esc(n.id) + '">Edit</button> <button type="button" class="btn small sec" data-rndel="' + esc(n.id) + '">Delete</button></td></tr>').join('') : '<tr><td colspan="4" class="muted">No notes yet.</td></tr>');
$('rmTable').innerHTML = '<tr><th>Status</th><th>Title</th><th>ETA</th><th>#</th><th></th></tr>' + (d.roadmap.length ? d.roadmap.map(r => '<tr><td class="small">' + esc(r.status) + '</td><td><b>' + esc(r.title) + '</b>' + (r.note ? '<br><span class="muted small">' + esc(r.note) + '</span>' : '') + '</td><td class="small">' + esc(r.eta || '') + '</td><td class="small">' + (r.order || '') + '</td><td class="act"><button type="button" class="btn small sec" data-rmedit="' + esc(r.id) + '">Edit</button> <button type="button" class="btn small sec" data-rmdel="' + esc(r.id) + '">Delete</button></td></tr>').join('') : '<tr><td colspan="5" class="muted">Nothing on the roadmap yet.</td></tr>');
$('rnTable').querySelectorAll('[data-rnedit]').forEach(b => b.addEventListener('click', () => { const n = d.notes.find(x => x.id === b.dataset.rnedit); if (!n) return; $('rnId').value = n.id; $('rnTitle').value = n.title; $('rnDate').value = n.date; $('rnTags').value = n.tags.join(', '); $('rnBody').value = n.body; $('rnTitle').focus(); }));
$('rmTable').querySelectorAll('[data-rmedit]').forEach(b => b.addEventListener('click', () => { const r = d.roadmap.find(x => x.id === b.dataset.rmedit); if (!r) return; $('rmId').value = r.id; $('rmTitle').value = r.title; $('rmStatus').value = r.status; $('rmEta').value = r.eta || ''; $('rmOrder').value = r.order || ''; $('rmNote').value = r.note || ''; $('rmTitle').focus(); }));
$('rnTable').querySelectorAll('[data-rndel]').forEach(b => b.addEventListener('click', async () => { if (!await IAP.confirmBox('Delete this release note?', { ok: 'Delete', cancel: 'Keep' })) return; await api('/api/admin/releases?kind=notes&id=' + encodeURIComponent(b.dataset.rndel), undefined, 'DELETE'); loadReleases(); }));
$('rmTable').querySelectorAll('[data-rmdel]').forEach(b => b.addEventListener('click', async () => { if (!await IAP.confirmBox('Delete this roadmap item?', { ok: 'Delete', cancel: 'Keep' })) return; await api('/api/admin/releases?kind=roadmap&id=' + encodeURIComponent(b.dataset.rmdel), undefined, 'DELETE'); loadReleases(); }));
}
// ── member update emails (Marty, 2026-09-14) ──
async function loadUpdates() {
if (!$('updCard')) return;
try {
const d = await api('/api/admin/updates');
$('updSub').textContent = (d.mailer ? '' : 'mailer not configured · ') + (d.lastSentAt ? 'last send ' + new Date(d.lastSentAt).toLocaleString() : 'nothing sent yet') + (d.running ? ' · sending now' : '');
$('updAudience').innerHTML = Object.entries(d.audiences).map(([k, v]) => '<option value="' + k + '">' + esc(v) + ' (' + (d.counts[k] || 0) + ')</option>').join('');
$('updNotes').innerHTML = d.notes.length ? d.notes.map(n => '<label class="small" style="display:flex;gap:8px;align-items:flex-start"><input type="checkbox" value="' + esc(n.id) + '"' + (n.fresh ? ' checked' : '') + '><span>' + esc(n.title) + ' <span class="muted">' + esc(n.date) + '</span></span></label>').join('') : '<span class="muted small">No release notes yet.</span>';
$('updLog').innerHTML = '<tr><th>When</th><th>Subject</th><th>Audience</th><th>Sent</th></tr>' + (d.sends.length ? d.sends.map(x => '<tr><td class="small">' + new Date(x.ts).toLocaleString() + '</td><td>' + esc(x.subject) + '</td><td class="small">' + esc(d.audiences[x.audience] || x.audience) + '</td><td class="small">' + x.sent + ' of ' + x.total + (x.skipped ? ' · ' + x.skipped + ' opted out' : '') + (x.failed ? ' · ' + x.failed + ' failed' : '') + (x.unknown ? ' · ' + x.unknown + ' unchecked (Sendy gave no answer)' : '') + (x.status === 'running' ? ' · running' : '') + '</td></tr>').join('') : '<tr><td colspan="4" class="muted small">None yet.</td></tr>');
if (d.draft && !updDraftLoaded) { updDraftLoaded = true; $('updSubject').value = d.draft.subject || ''; $('updIntro').value = d.draft.intro || ''; $('updClosing').value = d.draft.closing || ''; if (d.draft.audience) $('updAudience').value = d.draft.audience; $('updNotes').querySelectorAll('input').forEach(i => { i.checked = (d.draft.noteIds || []).includes(i.value); }); $('updSub').textContent += ' · draft loaded (saved ' + new Date(d.draft.savedAt).toLocaleString() + ')'; }
if (d.running) setTimeout(loadUpdates, 4000);
} catch (e) { $('updSub').textContent = e.message; }
}
let updDraftLoaded = false;
const updInput = () => ({ subject: $('updSubject').value, intro: $('updIntro').value, closing: $('updClosing').value, noteIds: [...$('updNotes').querySelectorAll('input:checked')].map(i => i.value), audience: $('updAudience').value });
if ($('updCard')) {
$('updSaveDraft').addEventListener('click', busy($('updSaveDraft'), async () => { await api('/api/admin/updates/draft', updInput()); IAP.status('Draft saved.', 'ok'); }));
$('updPreview').addEventListener('click', busy($('updPreview'), async () => { const r = await api('/api/admin/updates/preview', updInput()); $('updPre').hidden = false; $('updPre').textContent = 'Subject: ' + r.subject + '\n\n' + r.text; }));
$('updTest').addEventListener('click', busy($('updTest'), async () => { const r = await api('/api/admin/updates/send', Object.assign(updInput(), { test: true })); IAP.status('Test sent to ' + r.to + '.', 'ok'); }));
$('updSend').addEventListener('click', busy($('updSend'), async () => {
const inp = updInput(); if (!inp.noteIds.length) { IAP.status('Pick at least one note.', 'bad'); return; }
const opt = $('updAudience').selectedOptions[0].textContent;
if (!(await IAP.confirmBox('Send this update to ' + opt + '? One email per member, opt-outs skipped.', { title: 'Send member update', ok: 'Send now', cancel: 'Not yet' }))) return;
const r = await api('/api/admin/updates/send', inp); IAP.status('Sending to ' + r.total + ' members in the background.', 'ok'); loadUpdates();
}));
}
$('rnClear').addEventListener('click', () => { ['rnId', 'rnTitle', 'rnDate', 'rnTags', 'rnBody'].forEach(id => { $(id).value = ''; }); });
$('rmClear').addEventListener('click', () => { ['rmId', 'rmTitle', 'rmEta', 'rmOrder', 'rmNote'].forEach(id => { $(id).value = ''; }); $('rmStatus').value = 'planned'; });
$('rnSave').addEventListener('click', busy($('rnSave'), async () => { await api('/api/admin/releases', { kind: 'note', id: $('rnId').value || null, title: $('rnTitle').value, date: $('rnDate').value, tags: $('rnTags').value, body: $('rnBody').value }); IAP.status('Note saved.', 'ok'); $('rnClear').click(); loadReleases(); }));
$('rmSave').addEventListener('click', busy($('rmSave'), async () => { await api('/api/admin/releases', { kind: 'roadmap', id: $('rmId').value || null, title: $('rmTitle').value, status: $('rmStatus').value, eta: $('rmEta').value, order: $('rmOrder').value, note: $('rmNote').value }); IAP.status('Roadmap item saved.', 'ok'); $('rmClear').click(); loadReleases(); }));
// ── reports + burns ──
// ── profit and loss ──
let pnlDays = 30;
const pol = w => { try { return (Number(BigInt(w || '0') / 10n ** 14n) / 10000).toLocaleString(undefined, { maximumFractionDigits: 2 }); } catch (e) { return '0'; } };
const usdOf = (w, px) => { try { return '$' + ((Number(BigInt(w || '0') / 10n ** 14n) / 10000) * px).toLocaleString(undefined, { maximumFractionDigits: 0 }); } catch (e) { return '$0'; } };
// ── traffic: referring domains / sources, landing pages, angles, by day ──
let trfDays = 30;
document.querySelectorAll('#trfRange [data-days]').forEach(b => b.addEventListener('click', () => { trfDays = Number(b.dataset.days); document.querySelectorAll('#trfRange [data-days]').forEach(x => x.classList.toggle('on', x === b)); loadTraffic().catch(e => IAP.status(e.message, 'bad')); }));
async function loadPromos() {
const d = await (await fetch('/api/admin/promos')).json();
if (d.error) throw new Error(d.error);
const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
const when = t => t ? new Date(t).toLocaleDateString() : '';
$('pcTable').innerHTML = '<tr><th>Code</th><th>Credits</th><th>Partner</th><th>Uses</th><th>Max</th><th>Expires</th><th>Status</th><th></th></tr>'
+ (d.codes.length ? d.codes.map(c => '<tr><td><b>' + esc(c.code) + '</b></td><td>' + c.credits.toLocaleString() + '</td><td>' + esc(c.partner) + '</td><td>' + c.uses + '</td><td>' + (c.maxUses || '∞') + '</td><td>' + (c.expires ? when(c.expires) : '') + '</td><td>' + (c.active ? 'active' : 'off') + '</td><td class="act"><button type="button" class="btn small ghost" data-pctoggle="' + esc(c.code) + '" data-on="' + (c.active ? 0 : 1) + '">' + (c.active ? 'Switch off' : 'Switch on') + '</button></td></tr>').join('') : '<tr><td colspan="8" class="muted">No codes yet.</td></tr>');
$('pcRecent').innerHTML = '<tr><th>When</th><th>Code</th><th>Email</th><th>Credits</th><th>Via</th></tr>'
+ (d.recent.length ? d.recent.map(r => '<tr><td>' + new Date(r.ts).toLocaleString() + '</td><td>' + esc(r.code) + '</td><td>' + esc(r.email) + '</td><td>' + r.credits + '</td><td>' + esc(r.via) + '</td></tr>').join('') : '<tr><td colspan="5" class="muted">No redemptions yet.</td></tr>');
document.querySelectorAll('[data-pctoggle]').forEach(b => b.addEventListener('click', async () => {
try { await api('/api/admin/promos', { code: b.dataset.pctoggle, active: b.dataset.on === '1' }, 'PATCH'); loadPromos(); } catch (e) { IAP.status(e.message, 'bad'); }
}));
}
if ($('pcSave')) $('pcSave').addEventListener('click', async () => {
const msg = $('pcMsg'); msg.hidden = false;
try {
const r = await api('/api/admin/promos', { code: $('pcCode').value, credits: $('pcCredits').value, partner: $('pcPartner').value, maxUses: $('pcMax').value, expires: $('pcExpires').value || null, active: true });
msg.textContent = 'Saved ' + r.code.code + ': ' + r.code.credits + ' credits.'; msg.style.color = 'var(--mint)';
$('pcCode').value = ''; $('pcCredits').value = ''; $('pcPartner').value = ''; loadPromos();
} catch (e) { msg.textContent = e.message; msg.style.color = '#ff8a8a'; }
});
async function loadTraffic() {
loadPromos().catch(e => IAP.status(e.message, 'bad'));
const d = await (await fetch('/api/admin/traffic?days=' + trfDays)).json();
if (d.error) throw new Error(d.error);
const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
const n = v => Number(v || 0).toLocaleString();
$('trfSub').textContent = 'last ' + d.days + ' days · ' + n(d.totals.hits) + ' page views · ' + n(d.totals.joinViews) + ' join-page views · ' + n(d.totals.signups) + ' signups · ' + n(d.totals.buyers) + ' buyers';
// conversion columns (Marty, 2026-09-13): visits = page views + join-page views; signup rate is per visit,
// registered and buyer rates are per signup (what happened to the people who did sign up)
const pct = (num, den) => den ? (100 * num / den).toFixed(num && 100 * num / den < 10 ? 1 : 0) + '%' : '<span class="muted">-</span>';
$('trfSources').innerHTML = '<tr><th>Source</th><th>Page<br>views</th><th>Join-page<br>views</th><th>Signups</th><th>Visit &rarr;<br>signup</th><th>Registered</th><th>Signup &rarr;<br>registered</th><th>$20+<br>buyers</th><th>Signup &rarr;<br>buyer</th></tr>'
+ (d.sources.length ? d.sources.map(s => '<tr><td>' + esc(s.source) + '</td><td>' + n(s.hits) + '</td><td>' + n(s.joinViews) + '</td><td>' + n(s.signups) + '</td><td>' + pct(s.signups, s.hits + s.joinViews) + '</td><td>' + n(s.registered) + '</td><td>' + pct(s.registered, s.signups) + '</td><td>' + n(s.buyers) + '</td><td>' + pct(s.buyers, s.signups) + '</td></tr>').join('') : '<tr><td colspan="9" class="muted">Nothing recorded in this range yet.</td></tr>');
$('trfPaths').innerHTML = '<tr><th>Page</th><th>Views</th></tr>' + (d.paths.length ? d.paths.map(p => '<tr><td>' + esc(p.path) + '</td><td>' + n(p.hits) + '</td></tr>').join('') : '<tr><td colspan="2" class="muted">No page views yet.</td></tr>');
$('trfAngles').innerHTML = '<tr><th>Angle</th><th>Join-page<br>views</th><th>Signups</th><th>View &rarr;<br>signup</th></tr>' + (d.angles.length ? d.angles.map(a => '<tr><td>' + esc(a.angle) + '</td><td>' + n(a.views) + '</td><td>' + n(a.signups) + '</td><td>' + pct(a.signups, a.views) + '</td></tr>').join('') : '<tr><td colspan="4" class="muted">No angle data yet.</td></tr>');
$('trfDaily').innerHTML = '<tr><th>Day</th><th>Page views</th><th>Signups</th></tr>' + (d.daily.length ? d.daily.slice().reverse().map(x => '<tr><td>' + esc(x.day) + '</td><td>' + n(x.hits) + '</td><td>' + n(x.signups) + '</td></tr>').join('') : '<tr><td colspan="3" class="muted">Nothing yet.</td></tr>');
}
// ── blog: coaching articles, public at /blog (Marty, 2026-09-12) ──
let blCur = null; // slug being edited, or null for a new one
function blCount() {
const t = $('blTitle').value.length, e = $('blExcerpt').value.length;
$('blTitleCount').textContent = t + '/60' + (t > 60 ? ' (long)' : '');
$('blExcCount').textContent = e + ' chars' + (e && (e < 120 || e > 160) ? ' (aim 120-160)' : '');
const w = $('blBody').textContent.trim().split(/\s+/).filter(Boolean).length;
$('blWords').textContent = w + ' words';
}
['blTitle', 'blExcerpt'].forEach(id => $(id).addEventListener('input', blCount));
$('blBody').addEventListener('input', blCount);
$('blTitle').addEventListener('input', () => { if (!blCur && !$('blSlug').dataset.touched) $('blSlug').value = $('blTitle').value.toLowerCase().replace(/['’]/g, '').replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 80); });
$('blSlug').addEventListener('input', () => { $('blSlug').dataset.touched = '1'; });
document.querySelectorAll('.ed-bar [data-bl]').forEach(b => b.addEventListener('click', () => { $('blBody').focus(); document.execCommand(b.dataset.bl, false, null); }));
document.querySelectorAll('.ed-bar [data-blblock]').forEach(b => b.addEventListener('click', () => { $('blBody').focus(); document.execCommand('formatBlock', false, b.dataset.blblock); }));
$('blLinkBtn').addEventListener('click', async () => {
const sel = window.getSelection(); const range = sel && sel.rangeCount ? sel.getRangeAt(0).cloneRange() : null;
const u = await IAP.ask({ title: 'Link address', label: 'https://', placeholder: 'https://linkspin-test.saasy.top/join/martbost', ok: 'Insert' });
if (u) { $('blBody').focus(); if (range) { sel.removeAllRanges(); sel.addRange(range); } document.execCommand('createLink', false, u); }
});
$('blImgBtn').addEventListener('click', () => $('blImgFile').click());
$('blImgFile').addEventListener('change', async () => {
const f = $('blImgFile').files[0]; if (!f) return;
try {
const r = await (await fetch('/api/admin/upload', { method: 'POST', headers: { 'Content-Type': f.type }, body: f })).json();
if (r.error) throw new Error(r.error);
$('blBody').focus();
const html = '<img src="' + r.url + '" alt="">';
if (!document.execCommand('insertHTML', false, html)) $('blBody').insertAdjacentHTML('beforeend', html);
blCount();
} catch (e) { IAP.status(e.message || 'Upload failed.', 'bad'); }
$('blImgFile').value = '';
});
$('blCoverBtn').addEventListener('click', () => $('blCoverFile').click());
$('blCoverFile').addEventListener('change', () => upload($('blCoverFile'), $('blCoverInfo'), $('blCover')));
$('blHtmlBtn').addEventListener('click', () => {
const raw = !$('blHtml').hidden;
if (raw) { $('blBody').innerHTML = $('blHtml').value; $('blHtml').hidden = true; $('blBody').hidden = false; }
else { $('blHtml').value = $('blBody').innerHTML; $('blBody').hidden = true; $('blHtml').hidden = false; }
blCount();
});
function blBodyHtml() { return $('blHtml').hidden ? $('blBody').innerHTML : $('blHtml').value; }
function blMsg(t, bad) { $('blMsg').textContent = t; $('blMsg').hidden = !t; $('blMsg').className = 'small ' + (bad ? 'bad' : 'ok'); }
function blOpen(post) {
blCur = post ? post.slug : null;
$('blogList').hidden = true; $('blogEditor').hidden = false;
$('blEdTitle').textContent = post ? 'Edit article' : 'New article';
$('blEdSub').textContent = post ? (post.status === 'published' ? 'published ' + when(post.publishedAt) + ' · ' + (post.views || 0) + ' views' : 'draft') : '';
$('blTitle').value = post ? post.title : ''; $('blSlug').value = post ? post.slug : ''; delete $('blSlug').dataset.touched;
$('blTags').value = post ? post.tags.join(', ') : ''; $('blExcerpt').value = post ? post.excerpt : ''; $('blCover').value = post ? post.cover : ''; $('blCoverInfo').textContent = '';
$('blHtml').hidden = true; $('blBody').hidden = false; $('blBody').innerHTML = post ? post.body : '';
$('blUnpublish').hidden = !(post && post.status === 'published'); $('blDelete').hidden = !post;
$('blPreview').hidden = !post; if (post) $('blPreview').href = '/blog/' + post.slug;
$('blPublish').textContent = post && post.status === 'published' ? 'Save and publish' : 'Publish';
blMsg(''); blCount(); $('blTitle').focus();
}
async function blSave(status) {
const body = { existingSlug: blCur, title: $('blTitle').value, slug: $('blSlug').value, tags: $('blTags').value, excerpt: $('blExcerpt').value, cover: $('blCover').value, body: blBodyHtml(), status };
const r = await api('/api/admin/blog', body);
blCur = r.post.slug;
$('blSlug').value = r.post.slug; $('blPreview').hidden = false; $('blPreview').href = '/blog/' + r.post.slug; $('blDelete').hidden = false;
$('blUnpublish').hidden = r.post.status !== 'published'; $('blPublish').textContent = r.post.status === 'published' ? 'Save and publish' : 'Publish';
$('blEdTitle').textContent = 'Edit article';
blMsg(r.post.status === 'published' ? 'Published. Live at linkspin-test.saasy.top/blog/' + r.post.slug + (r.syndicating ? ' · posting to X and Instagram now (see the Social column in the list).' : '') : 'Draft saved.');
IAP.status(r.post.status === 'published' ? 'Published.' : 'Draft saved.', 'ok');
}
$('blSaveDraft').addEventListener('click', busy($('blSaveDraft'), () => blSave('draft')));
$('blPublish').addEventListener('click', busy($('blPublish'), () => blSave('published')));
$('blUnpublish').addEventListener('click', busy($('blUnpublish'), () => blSave('draft')));
$('blClose').addEventListener('click', () => { $('blogEditor').hidden = true; $('blogList').hidden = false; loadBlog().catch(e => IAP.status(e.message, 'bad')); });
$('blDelete').addEventListener('click', busy($('blDelete'), async () => {
if (!blCur) return;
if (!await IAP.confirmBox('The page at /blog/' + blCur + ' stops existing. There is no undo.', { title: 'Delete this article?', ok: 'Delete', cancel: 'Keep it' })) return;
await api('/api/admin/blog?slug=' + encodeURIComponent(blCur), undefined, 'DELETE');
$('blClose').click();
}));
$('blNew').addEventListener('click', () => blOpen(null));
async function loadBlog() {
const d = await api('/api/admin/blog');
const pub = d.posts.filter(p => p.status === 'published').length;
$('blSub').textContent = pub + ' published · ' + (d.posts.length - pub) + ' drafts';
const synd = p => { const s = p.syndicated; if (!s) return p.status === 'published' ? '<span class="muted small">not posted</span>' : ''; const r = s.results || {}; const part = ['x', 'instagram'].map(k => r[k] ? (r[k].ok ? k + ' ✓' : k + ' ✗') : k + ' –').join(' · '); return '<span class="small' + (s.done ? '' : ' bad') + '" title="' + esc(Object.values(r).map(v => v.error || '').filter(Boolean).join(' | ') || (s.error || '')) + '">' + part + '</span>'; };
$('blSyndNote').hidden = false; $('blSyndNote').textContent = d.syndication ? 'Publishing an article posts it to X (@cryptoteambuild) and Instagram (marketingwithmarty) through Blotato, once per article, with the cover image.' : 'Social syndication is off: no Blotato key on the server.';
$('blTable').innerHTML = '<tr><th>Title</th><th>Status</th><th>Social</th><th>Tags</th><th>Views</th><th>Updated</th><th></th></tr>'
+ (d.posts.length ? d.posts.map(p => '<tr><td><b>' + esc(p.title) + '</b><br><span class="muted small">/blog/' + esc(p.slug) + '</span></td><td>' + (p.status === 'published' ? '<span class="chip-t on">published</span>' : '<span class="chip-t">draft</span>') + '</td><td>' + synd(p) + '</td><td>' + esc(p.tags.join(', ')) + '</td><td>' + (p.views || 0) + '</td><td>' + when(p.updated) + '</td><td class="act"><button type="button" class="btn small sec" data-bledit="' + esc(p.slug) + '">Edit</button> <a class="btn small sec" href="/blog/' + esc(p.slug) + '" target="_blank" rel="noopener">View</a>' + (p.status === 'published' && d.syndication && !(p.syndicated && p.syndicated.done) ? ' <button type="button" class="btn small sec" data-blsynd="' + esc(p.slug) + '">Post to X + IG</button>' : '') + '</td></tr>').join('')
: '<tr><td colspan="7" class="muted">No articles yet. Start with "New article".</td></tr>');
$('blTable').querySelectorAll('[data-blsynd]').forEach(b => b.addEventListener('click', busy(b, async () => {
const r = await api('/api/admin/blog/syndicate', { slug: b.dataset.blsynd });
const res = r.syndicated && r.syndicated.results || {}; const bad = Object.entries(res).filter(([, v]) => !v.ok).map(([k, v]) => k + ': ' + v.error);
IAP.status(bad.length ? 'Posted with problems: ' + bad.join(' | ') : 'Posted to X and Instagram.', bad.length ? 'bad' : 'ok'); loadBlog().catch(() => {});
})));
$('blTable').querySelectorAll('[data-bledit]').forEach(b => b.addEventListener('click', async () => {
try { const r = await api('/api/admin/blog?slug=' + encodeURIComponent(b.dataset.bledit)); blOpen(r.post); } catch (e) { IAP.status(e.message, 'bad'); }
}));
}
async function loadPnl() {
const r = await api('/api/admin/pnl?days=' + pnlDays);
const px = r.polUsd || 0;
const platUsd = (Number(BigInt(r.platformWei || '0') / 10n ** 14n) / 10000) * px;
const months = pnlDays ? pnlDays / 30 : Math.max(1, (r.latest - r.fromBlock) / 43200 / 30);
const fixed = (r.fixedMonthlyUsd || 0) * months;
$('pnlTiles').innerHTML = [
['Packages sold', r.purchases.count, Object.entries(r.purchases.byPackage || {}).map(([k, v]) => v + '×' + k).join(' · ') || '—'],
['Gross volume', pol(r.purchases.volumeWei) + ' POL', usdOf(r.purchases.volumeWei, px) + ' at today\'s rate · $' + (r.purchases.usdCents / 100).toLocaleString() + ' at sale'],
['Platform (fees + dust + unclaimed)', pol(r.platformWei) + ' POL', usdOf(r.platformWei, px)],
['Paid to members', pol(r.memberPayoutsWei) + ' POL', usdOf(r.memberPayoutsWei, px)],
['Net after fixed costs', '$' + Math.round(platUsd - fixed).toLocaleString(), 'fixed ' + Math.round(fixed).toLocaleString() + ' over ' + months.toFixed(1) + ' month(s)'],
['Pass-ups', r.passedUp.count, r.passedUp.unqualified + ' unqualified · ' + r.passedUp.sendFailed + ' send-failed']
].map(t => '<div class="statx"><div><div class="nv" style="font-size:22px">' + esc(String(t[1])) + '</div><div class="lb">' + esc(t[0]) + '</div><span class="chip flat">' + esc(t[2]) + '</span></div></div>').join('');
$('pnlSplit').innerHTML = '<tr><th>Line</th><th>POL</th><th>USD now</th></tr>'
+ [['Level 1 (50%)', r.byTier[1]], ['Level 2 (20%)', r.byTier[2]], ['Level 3 (10%)', r.byTier[3]], ['Platform (20% + pass-ups)', r.platformWei]].map(x => '<tr><td>' + x[0] + '</td><td class="mono">' + pol(x[1]) + '</td><td class="mono">' + usdOf(x[1], px) + '</td></tr>').join('');
const W = r.wallets || {}, B = r.balances || {};
$('pnlWallets').innerHTML = '<tr><th>Wallet</th><th>Address</th><th>Balance</th></tr>'
+ [['Owner / fee A (Tangem)', W.feeA, B.feeA], ['Fee B', W.feeB, B.feeB], ['Engine (gas)', W.engine, B.engine]].filter(x => x[1]).map(x => '<tr><td>' + x[0] + '</td><td class="mono small">' + esc(x[1]) + '</td><td class="mono">' + (x[2] == null ? '?' : pol(x[2]) + ' POL') + '</td></tr>').join('');
$('pnlFixed').value = r.fixedMonthlyUsd || 0;
const b = r.burner || {};
$('burnerLine').textContent = !b.hasEthers ? 'ethers is not installed in this build.' : !b.keyPresent ? 'No engine key configured (ENGINE_KEY). Burns stay pending until it is set.' : b.mismatch ? 'ENGINE_KEY does not match the contract engine signer. Disabled.' : 'Engine wallet ' + b.address + ' holds ' + pol(b.balanceWei) + ' POL. That is its gas fund, not a cost: one burn uses about 0.003 POL (roughly 48,000 gas), paid by this wallet, never by the member. ' + b.burned + ' burn' + (b.burned === 1 ? '' : 's') + ' since boot' + (b.lastRun ? ' · last check ' + when(b.lastRun) : '') + (b.lastError ? ' · last error: ' + b.lastError : '') + (b.skipped && Object.keys(b.skipped).length ? ' · skipped (needs review): ' + Object.entries(b.skipped).map(([k, v]) => k + ' (' + v + ')').join(', ') : '');
}
document.querySelectorAll('#pnlPeriods [data-days]').forEach(b => b.addEventListener('click', () => { pnlDays = Number(b.dataset.days); document.querySelectorAll('#pnlPeriods [data-days]').forEach(x => x.classList.toggle('on', x === b)); loadPnl().catch(e => IAP.status(e.message, 'bad')); }));
if ($('pnlFixedSave')) $('pnlFixedSave').addEventListener('click', async () => { try { await api('/api/admin/site', { pnlFixedMonthlyUsd: Number($('pnlFixed').value) || 0 }, 'PATCH'); IAP.status('Saved.', 'ok'); loadPnl(); } catch (e) { IAP.status(e.message, 'bad'); } });
if ($('burnerRun')) $('burnerRun').addEventListener('click', async () => { try { const r = await api('/api/admin/burner/run', {}); IAP.status('Burner ran: ' + (r.burned || 0) + ' burned.', 'ok'); loadPnl(); } catch (e) { IAP.status(e.message, 'bad'); } });
async function loadAudit() {
if (!$('audTable')) return;
try {
$('audSub').textContent = 'checking…';
const a = await api('/api/admin/audit');
const bad = a.checks.filter(c => !c.ok).length;
$('audSub').textContent = (bad ? bad + ' issue' + (bad === 1 ? '' : 's') : 'all counters reconcile') + ' · checked ' + new Date(a.checkedAt).toLocaleTimeString();
$('audTable').innerHTML = '<tr><th>Check</th><th>Status</th><th>Detail</th></tr>' + a.checks.map(c => '<tr><td>' + esc(c.name) + '</td><td>' + (c.ok ? '<span class="badge">ok</span>' : '<span class="badge amber">' + c.issues.length + ' issue' + (c.issues.length === 1 ? '' : 's') + '</span>') + '</td><td class="small">' + esc(c.detail) + (c.issues.length ? '<br>' + c.issues.map(esc).join('<br>') : '') + '</td></tr>').join('');
} catch (e) { $('audSub').textContent = e.message; }
}
if ($('audRun')) $('audRun').addEventListener('click', busy($('audRun'), loadAudit));
async function loadReports() {
loadAudit();
const [r, b] = await Promise.all([api('/api/admin/reports'), api('/api/admin/burns')]);
const reps = r.reports || [];
$('repTable').innerHTML = reps.length ? '<tr><th>When</th><th>Campaign</th><th>Reason</th><th>Note</th><th>By</th><th></th></tr>'
+ reps.map(x => '<tr' + (x.resolved ? ' style="opacity:.5"' : '') + '><td class="small muted">' + when(x.ts) + '</td><td>#' + x.campaignId + '</td><td>' + esc(x.reason) + '</td><td>' + esc(x.note || '') + '</td><td class="small">' + esc(x.reporter || 'anon') + '</td>'
+ '<td class="act">' + (x.resolved ? 'resolved' : '<button class="btn small sec" data-act="pause" data-id="' + x.campaignId + '">Pause ad</button><button class="btn small" data-resolve="' + x.id + '">Resolve</button>') + '</td></tr>').join('')
: '<tr><td class="muted">No reports.</td></tr>';
const burns = b.pending || [];
$('burnTable').innerHTML = burns.length ? '<tr><th>When</th><th>Member</th><th>Credits</th><th>Ref</th><th>Burn id</th></tr>'
+ burns.map(x => '<tr><td class="small muted">' + when(x.ts) + '</td><td>#' + x.memberId + '</td><td class="mono">' + x.amount + '</td><td>' + esc(x.ref) + '</td><td class="mono small">' + esc(x.id) + '</td></tr>').join('')
: '<tr><td class="muted">Nothing pending.</td></tr>';
}
document.addEventListener('click', async e => {
const b = e.target.closest('[data-resolve]'); if (!b) return;
b.disabled = true;
try { await api('/api/admin/reports/' + b.dataset.resolve + '/resolve', {}); IAP.status('Report resolved.', 'ok'); await Promise.all([loadReports(), loadOverview()]); }
catch (err) { IAP.status(err.message, 'bad'); b.disabled = false; }
});
// ── settings: graphical editors (follow-up emails, rates, site settings) ──
let dripSeq = [], ratesObj = {}, siteObj = {};
let lastFocusedField = null;
document.addEventListener('focusin', e => { if (e.target && (e.target.matches('textarea.ds-body') || e.target.matches('input.ds-subject'))) lastFocusedField = e.target; });
document.addEventListener('click', e => {
const c = e.target.closest('[data-ph]'); if (!c) return;
const el = lastFocusedField; if (!el) { IAP.status('Click into a subject or body first, then the chip.', 'bad'); return; }
const ph = c.dataset.ph, st = el.selectionStart || 0, en = el.selectionEnd || st;
el.value = el.value.slice(0, st) + ph + el.value.slice(en);
el.focus(); el.selectionStart = el.selectionEnd = st + ph.length;
el.dispatchEvent(new Event('input'));
});
const whenLabel = h => { h = Number(h) || 0; if (h < 24) return h + ' hour' + (h === 1 ? '' : 's') + ' after sign-up'; const d = h / 24; return (Number.isInteger(d) ? d : d.toFixed(1)) + ' day' + (d === 1 ? '' : 's') + ' after sign-up'; };
function drawDrip() {
const wrap = $('dripSteps');
wrap.innerHTML = dripSeq.map((st, i) => '<div class="drip-step" data-i="' + i + '">'
+ '<div class="ds-head"><span class="ds-n">EMAIL ' + (i + 1) + '</span>'
+ '<span class="ds-when">send <input type="number" min="1" class="ds-hours" value="' + esc(st.hours) + '"> hours after sign-up <b class="ds-whenlbl">(' + esc(whenLabel(st.hours)) + ')</b></span>'
+ '<span class="ds-tools"><button type="button" class="btn small sec" data-act="up" ' + (i === 0 ? 'disabled' : '') + '>↑</button><button type="button" class="btn small sec" data-act="down" ' + (i === dripSeq.length - 1 ? 'disabled' : '') + '>↓</button>'
+ '<button type="button" class="btn small sec" data-act="test">Send to me</button><button type="button" class="btn small sec" data-act="remove">Remove</button></span></div>'
+ '<input class="ds-subject" placeholder="Subject line" maxlength="150" value="' + esc(st.subject) + '">'
+ '<textarea class="ds-body" placeholder="Plain-text email body">' + esc(st.body) + '</textarea>'
+ '</div>').join('') || '<p class="muted small">No emails yet. Add one below.</p>';
}
function readDrip() {
return [...document.querySelectorAll('#dripSteps .drip-step')].map(card => ({
hours: Number(card.querySelector('.ds-hours').value) || 0,
subject: card.querySelector('.ds-subject').value.trim(),
body: card.querySelector('.ds-body').value.trim()
}));
}
$('dripSteps').addEventListener('input', e => {
if (e.target.classList.contains('ds-hours')) { const l = e.target.closest('.ds-when').querySelector('.ds-whenlbl'); if (l) l.textContent = '(' + whenLabel(e.target.value) + ')'; }
});
$('dripSteps').addEventListener('click', async e => {
const b = e.target.closest('[data-act]'); if (!b) return;
const card = b.closest('.drip-step'), i = Number(card.dataset.i);
dripSeq = readDrip();
if (b.dataset.act === 'remove') { if (!confirm('Remove email ' + (i + 1) + '?')) return; dripSeq.splice(i, 1); drawDrip(); return; }
if (b.dataset.act === 'up' && i > 0) { [dripSeq[i - 1], dripSeq[i]] = [dripSeq[i], dripSeq[i - 1]]; drawDrip(); return; }
if (b.dataset.act === 'down' && i < dripSeq.length - 1) { [dripSeq[i + 1], dripSeq[i]] = [dripSeq[i], dripSeq[i + 1]]; drawDrip(); return; }
if (b.dataset.act === 'test') {
b.disabled = true;
try { await saveDrip(); await api('/api/admin/drip/test', { step: i }); IAP.status('Email ' + (i + 1) + ' sent to your inbox.', 'ok'); }
catch (err) { IAP.status(err.message, 'bad'); }
b.disabled = false;
}
});
async function saveDrip() {
$('dripErr').hidden = true;
const seq = readDrip();
const r = await api('/api/admin/drip', { sequence: seq }, 'PATCH').catch(err => { $('dripErr').textContent = err.message; $('dripErr').hidden = false; throw err; });
dripSeq = r.sequence; drawDrip();
return r;
}
$('dripSave').addEventListener('click', busy($('dripSave'), async () => { await saveDrip(); IAP.status('Sequence saved.', 'ok'); await loadSettings(); }));
$('dripAdd').addEventListener('click', () => {
dripSeq = readDrip();
const last = dripSeq[dripSeq.length - 1];
dripSeq.push({ hours: last ? Number(last.hours) + 48 : 24, subject: '', body: '\n\nMarty\n\n{{footer}}' });
drawDrip();
const cards = document.querySelectorAll('#dripSteps .drip-step'); const c = cards[cards.length - 1]; if (c) { c.scrollIntoView({ behavior: 'smooth', block: 'center' }); c.querySelector('.ds-subject').focus(); }
});
$('dripReset').addEventListener('click', busy($('dripReset'), async () => {
if (!confirm('Replace the saved sequence with the built-in defaults?')) return;
const r = await api('/api/admin/drip', { reset: true }, 'PATCH');
dripSeq = r.sequence; drawDrip(); IAP.status('Defaults restored.', 'ok'); await loadSettings();
}));
// rates: labels + hints for the known keys; anything unknown still gets a plain field
const RATE_META = {
bannerBatch: ['Banner: views per batch', 'impressions counted before a banner campaign is charged'],
bannerCreditsPerBatch: ['Banner: credits per batch', 'charged to the advertiser per batch'],
textBatch: ['Text ad: views per batch', ''], textCreditsPerBatch: ['Text ad: credits per batch', ''],
loginCreditsPerDay: ['Login ad: credits per day', 'flat daily charge while active'],
loginDwellSeconds: ['Login ad: seconds shown', 'full-screen interstitial after sign-in'],
burnBatchMin: ['On-chain burn batch (credits)', 'accrued spend is burned once it reaches this'],
welcomeCredits: ['Welcome credits', 'granted after the welcome tour'],
dailyViewTarget: ['Daily view set (ads)', 'ads a member views for the daily claim'],
dailyClaimCredits: ['Daily claim (credits)', 'paid when the set is complete'],
viewDwellSeconds: ['Ad view: seconds per ad', 'the countdown; server-enforced'],
soloCostPerRecipient: ['Solo ad: credits per recipient', ''], soloMinRecipients: ['Solo ad: minimum recipients', ''],
soloReadCredits: ['Solo ad: reader reward (credits)', ''], soloReadCapPerDay: ['Solo ad: rewarded reads per day', ''], soloReadDwellSeconds: ['Solo ad: seconds to read', ''],
videoWatchCapPerDay: ['Video: rewarded watches per day', ''],
featuredPerDay: ['Featured link: credits per day', ''], featuredSlotsPerDay: ['Featured link: slots per day', ''], featuredWindowDays: ['Featured link: booking window (days)', ''],
featuredDurations: ['Featured link: durations offered (days)', 'comma-separated'],
visitCostPerVisit: ['Verified visit: credits per visit', ''], visitMinPack: ['Verified visit: smallest pack', ''], visitReward: ['Verified visit: viewer reward (credits)', ''], visitDwellSeconds: ['Verified visit: seconds on site', ''], visitCapPerDay: ['Verified visit: rewarded visits per day', ''],
videoTiers: ['Video ad tiers', 'watch length → advertiser cost → viewer reward'],
milestoneBonus: ['Milestone bonuses (credits)', 'one-time, when a member reaches each step']
};
const humanize = k => k.replace(/([A-Z])/g, ' $1').replace(/^./, c => c.toUpperCase());
function drawRates() {
const wrap = $('ratesForm'); const html = [];
for (const [k, v] of Object.entries(ratesObj)) {
const [label, hint] = RATE_META[k] || [humanize(k), ''];
if (typeof v === 'number') html.push('<div class="rf"><label>' + esc(label) + '</label><input type="number" step="any" data-rk="' + esc(k) + '" value="' + esc(v) + '">' + (hint ? '<span class="hint">' + esc(hint) + '</span>' : '') + '</div>');
else if (typeof v === 'boolean') html.push('<div class="rf"><label>' + esc(label) + '</label><label class="small"><input type="checkbox" data-rk="' + esc(k) + '"' + (v ? ' checked' : '') + ' style="width:auto"> on</label></div>');
else if (Array.isArray(v) && v.every(x => typeof x === 'number')) html.push('<div class="rf"><label>' + esc(label) + '</label><input data-rk="' + esc(k) + '" data-kind="numlist" value="' + esc(v.join(', ')) + '">' + (hint ? '<span class="hint">' + esc(hint) + '</span>' : '') + '</div>');
else if (Array.isArray(v) && v.every(x => x && typeof x === 'object')) {
const cols = [...new Set(v.flatMap(x => Object.keys(x)))];
html.push('<div class="rf wide"><label>' + esc(label) + '</label>' + (hint ? '<span class="hint">' + esc(hint) + '</span>' : '')
+ '<table class="tiers" data-rk="' + esc(k) + '" data-kind="table"><tr>' + cols.map(c => '<th>' + esc(c) + '</th>').join('') + '</tr>'
+ v.map((row, i) => '<tr>' + cols.map(c => '<td><input type="number" step="any" data-col="' + esc(c) + '" value="' + esc(row[c] == null ? '' : row[c]) + '"></td>').join('') + '</tr>').join('') + '</table></div>');
} else if (v && typeof v === 'object') {
html.push('<div class="rf wide"><label>' + esc(label) + '</label>' + (hint ? '<span class="hint">' + esc(hint) + '</span>' : '') + '<div class="sub-grid" data-rk="' + esc(k) + '" data-kind="object">'
+ Object.entries(v).map(([sk, sv]) => '<label>' + esc(humanize(sk)) + '<input type="number" step="any" data-sub="' + esc(sk) + '" value="' + esc(sv) + '"></label>').join('') + '</div></div>');
} else html.push('<div class="rf"><label>' + esc(label) + '</label><input data-rk="' + esc(k) + '" value="' + esc(v == null ? '' : v) + '"></div>');
}
wrap.innerHTML = html.join('');
}
function readRates() {
const out = {};
document.querySelectorAll('#ratesForm [data-rk]').forEach(el => {
const k = el.dataset.rk, kind = el.dataset.kind;
if (kind === 'numlist') out[k] = el.value.split(/[\s,]+/).filter(Boolean).map(Number).filter(n => !isNaN(n));
else if (kind === 'table') out[k] = [...el.querySelectorAll('tr')].slice(1).map(tr => { const o = {}; tr.querySelectorAll('input[data-col]').forEach(i => { o[i.dataset.col] = Number(i.value); }); return o; });
else if (kind === 'object') { const o = {}; el.querySelectorAll('input[data-sub]').forEach(i => { o[i.dataset.sub] = Number(i.value); }); out[k] = o; }
else if (el.type === 'checkbox') out[k] = !!el.checked;
else if (el.type === 'number') out[k] = Number(el.value);
else out[k] = el.value;
});
return out;
}
$('ratesSave').addEventListener('click', busy($('ratesSave'), async () => {
$('ratesErr').hidden = true;
try { const r = await api('/api/admin/rates', readRates(), 'PATCH'); ratesObj = r.rates || readRates(); drawRates(); IAP.status('Rates saved.', 'ok'); }
catch (e) { $('ratesErr').textContent = e.message; $('ratesErr').hidden = false; }
}));
// site settings: key / value rows; booleans as checkboxes, numbers stay numbers
const SITE_META = { noPayoutIds: 'No-payout positions (member #s, comma): linkage only, no buys from them, no joins routed under them', siteName: 'Site name', tagline: 'Tagline', rehearsal: 'Testnet rehearsal banner', defaultSponsorId: 'Fallback sponsor (member #)', walletConnectProjectId: 'WalletConnect project id', moonpayPublicKey: 'MoonPay public key', telegramBotToken: 'Telegram proof feed: bot token', telegramChatId: 'Telegram proof feed: chat id', telegramTopicId: 'Telegram proof feed: topic id (optional)', telegramEvents: 'Telegram proof feed: events (payouts | payouts+purchases | all)', telegramCtaUrl: 'Telegram proof feed: join link under each post', aiCreditsPerGen: 'AI Copy Engine: credits per generation after the free allowance', aiFreeSurge: 'AI Copy Engine: free generations a month at Surge', aiFreeCircuit: 'AI Copy Engine: free generations a month at Circuit', aiFreeNexus: 'AI Copy Engine: free generations a month at Nexus', snapshotEnabled: 'Daily growth snapshot to Telegram (1/0)', snapshotHourUtc: 'Daily growth snapshot: hour (UTC; 14 = 9 AM Central)', snapshotTargets: 'Daily growth snapshot: targets (feed = proof channel, echo = shared payments topic; comma list)', pipelineMode: 'Pipeline board: off (coming soon card) | preview (admin account only) | on (everyone)', pipelineEta: 'Pipeline: opening date shown on the coming-soon card (e.g. Sep 28)', memberWeeklyEmail: 'Weekly member email to everyone active (1) or only sponsors with a line (0)', leaderboardWeeklyPrize: 'Leaderboard: weekly prize text (optional; blank shows the credit ladder)', leaderboardMonthlyPrize: 'Leaderboard: monthly prize text (optional)', leaderboardWeeklyCredits: 'Leaderboard: weekly credits for 1st,2nd,3rd… (e.g. 1000,500,250; blank = none)', leaderboardMonthlyCredits: 'Leaderboard: monthly credits for 1st,2nd,3rd… (e.g. 5000,2500,1000)', leaderboardAnnounceGeneral: 'Leaderboard: announce winners in the main group too (1/0)', telegramEchoChatId: 'Telegram echo (shared payments topic): chat id', telegramEchoTopicId: 'Telegram echo: topic id', telegramEchoEvents: 'Telegram echo: events (payouts | payouts+purchases | all)', legacyCreditsAdvertiser: 'Legacy welcome credits: former advertisers', legacyCreditsEarner: 'Legacy welcome credits: former earners', pnlFixedMonthlyUsd: 'P&L: fixed monthly cost (USD)' };
function drawSite() {
const wrap = $('siteForm');
wrap.innerHTML = Object.entries(siteObj).map(([k, v]) => '<div class="kv-row"><span class="k" title="' + esc(k) + '">' + esc(SITE_META[k] || humanize(k)) + '</span>'
+ (typeof v === 'boolean' ? '<input type="checkbox" data-sk="' + esc(k) + '"' + (v ? ' checked' : '') + '>'
: typeof v === 'number' ? '<input type="number" step="any" data-sk="' + esc(k) + '" value="' + esc(v) + '">'
: '<input data-sk="' + esc(k) + '" value="' + esc(typeof v === 'object' ? JSON.stringify(v) : (v == null ? '' : v)) + '">')
+ '<button type="button" class="btn small sec" data-sdel="' + esc(k) + '">Clear</button></div>').join('') || '<p class="muted small">No settings saved yet.</p>';
}
function readSite() {
const out = {};
document.querySelectorAll('#siteForm [data-sk]').forEach(el => {
const k = el.dataset.sk;
if (el.type === 'checkbox') out[k] = !!el.checked;
else if (el.type === 'number') out[k] = Number(el.value);
else { const v = el.value; if (/^[\[{]/.test(v)) { try { out[k] = JSON.parse(v); return; } catch (e) {} } out[k] = v; }
});
return out;
}
$('siteForm').addEventListener('click', e => {
const b = e.target.closest('[data-sdel]'); if (!b) return;
siteObj = readSite(); siteObj[b.dataset.sdel] = ''; drawSite();
});
$('siteAddKey').addEventListener('click', () => {
const k = $('siteNewKey').value.trim(); if (!/^[A-Za-z][A-Za-z0-9_]{0,40}$/.test(k)) { IAP.status('Setting names are letters and numbers, no spaces.', 'bad'); return; }
siteObj = readSite(); if (!(k in siteObj)) siteObj[k] = ''; $('siteNewKey').value = ''; drawSite();
const el = document.querySelector('#siteForm [data-sk="' + k + '"]'); if (el) el.focus();
});
$('siteSave').addEventListener('click', busy($('siteSave'), async () => {
$('siteErr').hidden = true;
try { const r = await api('/api/admin/site', readSite(), 'PATCH'); siteObj = r.site || readSite(); drawSite(); IAP.status('Site settings saved.', 'ok'); }
catch (e) { $('siteErr').textContent = e.message; $('siteErr').hidden = false; }
}));
async function loadSettings() {
const [r, s, d] = await Promise.all([api('/api/admin/rates'), api('/api/admin/site'), api('/api/admin/drip')]);
ratesObj = r.rates || {}; drawRates();
siteObj = s.site || {}; drawSite();
dripSeq = d.sequence || []; drawDrip();
const st = d.stats || {};
$('dripSub').textContent = (st.active || 0) + ' in flight · ' + (st.done || 0) + ' finished · ' + (st.unsubscribed || 0) + ' unsubscribed' + (d.mailReady ? '' : ' · NO MAIL KEY: nothing sends');
}
render();
})();
+2
View File
@@ -0,0 +1,2 @@
// public blog pages: the shared nav (with wallet status) and footer, nothing else
(function () { try { IAP.renderNav(location.pathname.indexOf('/leaderboard') === 0 ? 'leaderboard' : 'blog'); } catch (e) {} })();
+58
View File
@@ -0,0 +1,58 @@
// 24/7 assistant widget: floating bubble, slide-up panel, /api/chat.
(function () {
const root = document.createElement('div');
root.id = 'iapChat';
root.innerHTML = '<button id="iapChatBtn" aria-label="Chat with us" type="button">💬</button>'
+ '<div id="iapChatPanel" hidden>'
+ '<div class="ch-head"><b>Ask anything</b><span class="ch-sub">Real answers, around the clock</span>'
+ '<button id="iapChatClose" aria-label="Close chat" type="button">×</button></div>'
+ '<div class="ch-msgs" id="iapChatMsgs">'
+ '<div class="ch-m bot">Hey. Ask me how the payments work, what the packages buy, or anything else. Straight answers only, no income hype.</div>'
+ '</div>'
+ '<div class="ch-input"><input id="iapChatIn" placeholder="Type your question…" maxlength="600">'
+ '<button id="iapChatSend" type="button">Send</button></div>'
+ '</div>';
document.body.appendChild(root);
const $ = id => document.getElementById(id);
const msgs = $('iapChatMsgs');
const input = $('iapChatIn');
let history = [];
const add = (text, who) => {
const d = document.createElement('div');
d.className = 'ch-m ' + who;
// linkify plain URLs
d.innerHTML = String(text).replace(/[&<>]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[c]))
.replace(/(https?:\/\/[^\s]+)/g, '<a href="$1" target="_blank" rel="noopener">$1</a>');
msgs.appendChild(d);
msgs.scrollTop = msgs.scrollHeight;
return d;
};
async function send() {
const q = input.value.trim();
if (!q) return;
input.value = '';
add(q, 'me');
const wait = add('…', 'bot');
try {
const r = await (await fetch('/api/chat', { method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: q, history: history.slice(-4).join(' | ') }) })).json();
wait.remove();
add(r.reply || r.error || 'No answer came back. Try again.', 'bot');
history.push('Q: ' + q, 'A: ' + (r.reply || ''));
} catch (e) {
wait.remove();
add('Connection hiccup. Try that again.', 'bot');
}
}
$('iapChatBtn').addEventListener('click', () => {
const p = $('iapChatPanel');
p.hidden = !p.hidden;
if (!p.hidden) input.focus();
});
$('iapChatClose').addEventListener('click', () => { $('iapChatPanel').hidden = true; });
$('iapChatSend').addEventListener('click', send);
input.addEventListener('keydown', e => { if (e.key === 'Enter') send(); });
})();
+248
View File
@@ -0,0 +1,248 @@
// Shared page runtime: site config, nav, formatting. Zero dependencies.
window.IAP = (function () {
let config = null;
const $ = id => document.getElementById(id);
async function getConfig() {
if (!config) config = await (await fetch('/api/config')).json();
return config;
}
// POL amounts display with two decimals (rounded half-up), e.g. 523.39
function fmtPol(wei) {
const cents = (BigInt(wei) + 5000000000000000n) / 10000000000000000n; // wei -> hundredths of a POL
const s = cents.toString().padStart(3, '0');
return s.slice(0, -2) + '.' + s.slice(-2);
}
const fmtUsd = cents => '$' + (cents / 100).toFixed(2);
function status(msg, cls) {
let el = $('status');
if (!el) { el = document.createElement('div'); el.id = 'status'; document.body.appendChild(el); }
el.textContent = msg; el.className = cls || ''; el.hidden = false;
clearTimeout(status._t);
if (cls === 'ok') status._t = setTimeout(() => { el.hidden = true; }, 6000);
}
async function renderNav(active) {
const c = await getConfig();
const nav = document.createElement('nav');
nav.innerHTML = '<div class="wrap">'
+ '<span class="logo-wrap"><a class="logo" href="/"><img src="/logo.png" alt="LinkSpin" style="height:30px;display:block"></a><span class="byline">Brought to you by the <b>Crypto Team Build Network</b></span></span>'
+ '<span class="links">'
+ '<a href="/#how" data-p="home">How it works</a>'
+ '<a href="/#packages" data-p="pricing">Ad packages</a>'
+ '<a href="/ledger" data-p="ledger">Live ledger</a>'
+ '<a href="/leaderboard" data-p="leaderboard">Leaderboard</a>'
+ '<a href="/contract" data-p="contract">The contract</a>'
+ '<a href="/my" data-p="my">Members</a>'
+ '</span><span id="navWallet" class="muted">…</span></div>';
document.body.prepend(nav);
if (c.rehearsal) {
const b = document.createElement('div');
b.className = 'rehearsal';
b.innerHTML = '<b>Testnet rehearsal</b>: running on ' + c.chainName + '. Purchases use valueless test POL while we prove every payout in public.';
document.body.prepend(b);
}
const a = nav.querySelector('[data-p="' + active + '"]');
if (a) a.className = 'active';
refreshNavWallet();
renderFooter();
}
function renderFooter() {
if (document.getElementById('iapFooter')) return;
const f = document.createElement('footer'); f.id = 'iapFooter';
f.style.cssText = 'border-top:1px solid var(--line);margin-top:48px;padding:26px 22px;text-align:center;color:var(--muted);font-size:13px';
f.innerHTML = '<div>© ' + new Date().getFullYear() + ' LinkSpin</div>'
+ '<div style="margin-top:8px;display:flex;gap:16px;justify-content:center;flex-wrap:wrap">'
+ '<a href="/">How it works</a><a href="/ledger">Live ledger</a><a href="/contract">The contract</a><a href="/blog">Blog</a><a href="/leaderboard">Leaderboard</a><a href="/whats-new">What\'s new</a>'
+ '<a href="/terms">Terms</a><a href="/privacy">Privacy</a><a href="/disclaimer">Disclaimer</a></div>';
document.body.appendChild(f);
}
async function refreshNavWallet() {
try {
const me = await (await fetch('/api/me')).json();
const el = $('navWallet');
if (!el) return;
if (me.signedIn) {
// identity order: username, then email, then wallet
const who = me.username
? '<b>@' + String(me.username).replace(/[&<>]/g, '') + '</b>'
: (me.email ? String(me.email).replace(/[&<>]/g, '')
: (me.address ? '<span class="mono">' + me.address.slice(0, 6) + '…' + me.address.slice(-4) + '</span>' : 'signed in'));
el.innerHTML = (me.memberId ? '<span class="badge">member #' + me.memberId + '</span> ' : '') + who;
} else {
el.innerHTML = '<a href="/my">Sign in</a>';
}
return me;
} catch (e) { return null; }
}
function describeEvent(ev, c) {
const pol = w => fmtPol(w) + ' POL';
// real people, not numbers: use usernames when the site knows them
const nm = id => (ev.names && ev.names[id])
? String(ev.names[id]).replace(/[&<>]/g, '')
: 'member #' + id;
switch (ev.type) {
case 'Purchase': return '🧾 ' + nm(ev.buyerId) + ' bought package #' + ev.productId
+ ' (' + fmtUsd(ev.priceCents) + ') for ' + pol(ev.paidWei) + ' → +' + ev.creditAmount.toLocaleString() + ' credits';
case 'TierPaid': return '💸 level ' + ev.tier + ' payout → ' + nm(ev.recipientId) + ': ' + pol(ev.amountWei)
+ (ev.hops ? ' (passed up ' + ev.hops + ')' : '');
case 'PassedUp': return '↷ level ' + ev.tier + ' passed over ' + nm(ev.skippedId) + ' (' + ev.reason + ')';
case 'AdminPaid': return '🏛 platform fee settled: ' + pol(ev.amountWei);
case 'BuyerCounted': return '⭐ ' + nm(ev.sponsorId) + ' now has ' + ev.newCount + ' qualifying buyer(s)';
case 'MemberActivated': return '👤 ' + nm(ev.id) + ' activated a payout wallet';
case 'AwardPaid': return '🎁 award: ' + pol(ev.amountWei) + ' → ' + nm(ev.toId);
case 'CreditsConsumed': return '📣 ' + nm(ev.memberId) + ' ran ads: −' + ev.amount.toLocaleString() + ' credits';
case 'PriceCached': return '🔮 oracle price refreshed';
case 'FallbackPriceUsed': return '🔮 cached price bridged an oracle gap';
default: return '· ' + ev.type;
}
}
function feedRow(ev, c) {
const div = document.createElement('div');
div.className = 'row t-' + ev.type;
const when = ev.ts ? new Date(ev.ts).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }) : '';
div.innerHTML = (when ? '<span class="when" title="' + new Date(ev.ts).toLocaleString() + '">' + when + '</span>' : '') + '<span>' + describeEvent(ev, c) + '</span>'
+ (c.explorer
? '<span class="tx"><a target="_blank" rel="noopener" href="' + c.explorer + '/tx/' + ev.tx + '">verify ↗</a></span>'
: '<span class="tx"><a href="/tx/' + ev.tx + '">verify ↗</a></span>'); // built-in viewer when the chain has no public explorer
return div;
}
// Render one served ad into #<elId>. Silent if no inventory.
// report an ad (auto-approved ads need a member-facing flag → admin notified)
function reportAd(campaignId) {
if (!campaignId) return;
const reason = (prompt('Report this ad. Reason: broken, inappropriate, spam, scam, or other', 'broken') || '').trim().toLowerCase();
if (!reason) return;
const note = prompt('Anything to add? (optional)') || '';
fetch('/api/report-ad', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ campaignId, reason, note }) })
.then(() => status('Thanks — this ad was reported to the admin for review.', 'ok'))
.catch(() => status('Could not send the report. Try again.', 'bad'));
}
const reportTag = ad => ' <a class="ad-report small muted" href="#" data-cid="' + ad.id + '" style="margin-left:8px">⚠ report</a>';
function wireReport(el) {
const rl = el.querySelector('.ad-report');
if (rl) rl.addEventListener('click', e => { e.preventDefault(); reportAd(Number(rl.dataset.cid)); });
}
async function adSlot(type, elId, opts) {
try {
const q = '/api/ads/slot?type=' + type + (opts && opts.width ? '&w=' + opts.width + '&h=' + opts.height : '');
const { ad } = await (await fetch(q)).json();
const el = $(elId);
if (!ad || !el) return;
el.hidden = false;
if (ad.imageUrl) {
el.style.textAlign = 'center';
el.innerHTML = '<a href="' + ad.targetUrl + '" target="_blank" rel="noopener nofollow">'
+ '<img src="' + ad.imageUrl + '" alt="advertisement" style="max-width:min(100%,728px);height:auto;display:block;margin:0 auto;border-radius:8px"></a>'
+ '<div class="small muted">member ad' + reportTag(ad) + '</div>';
} else {
el.innerHTML = '<a href="' + ad.targetUrl + '" target="_blank" rel="noopener nofollow"><b>' + ad.title + '</b>'
+ (ad.body ? ' · ' + ad.body : '') + '</a> <span class="small muted">member ad' + reportTag(ad) + '</span>';
}
wireReport(el);
el.hidden = false;
} catch (e) {}
}
// ── sign-up code request with the invisible guard fields (form age + honeypot)
// and the icon check the server asks for only after an IP trips a limit ──
const FORM_TS = Date.now();
function iconCheck(host, ch, note) {
return new Promise(resolve => {
host.hidden = false;
host.innerHTML = '<div class="small" style="margin:0 0 8px">' + (note ? esc(note) + ' ' : '') + 'Tap the <b>' + esc(ch.prompt) + '</b>.</div>'
+ '<div class="icon-check">' + ch.options.map(o => '<button type="button" class="ic-btn">' + esc(o) + '</button>').join('') + '</div>';
host.querySelectorAll('.ic-btn').forEach(b => b.addEventListener('click', () => { host.innerHTML = ''; host.hidden = true; resolve(b.textContent); }, { once: true }));
});
}
// honeypot fields (join + sign-in): read-only until a trusted focus, so browser autofill and
// password managers leave them alone; a value that appeared without a trusted event is ignored
function armHoneypots() {
document.querySelectorAll('.hp-field').forEach(el => {
if (el.dataset.armed) return; el.dataset.armed = '1'; el.readOnly = true;
const touch = e => { if (e.isTrusted) { el.readOnly = false; el.dataset.touched = '1'; } };
el.addEventListener('focus', touch); el.addEventListener('input', touch); el.addEventListener('keydown', touch);
});
}
armHoneypots(); document.addEventListener('DOMContentLoaded', armHoneypots);
const hpValue = el => (el && el.dataset.touched === '1') ? (el.value || '') : '';
async function requestCode(email, opts) {
const o = opts || {};
let pick = null;
for (let i = 0; i < 4; i++) {
const r = await (await fetch('/api/auth/email/start', { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, fts: FORM_TS, hp_field_x9: hpValue(o.honeypot), pick }) })).json();
if (r.challenge && o.host) { pick = await iconCheck(o.host, r.challenge, r.error); continue; }
if (r.error) throw new Error(r.error);
return r;
}
throw new Error('Could not verify. Refresh the page and try again.');
}
const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
// ── founding-week checklist, read from the live account. Shared by /launch and
// the dashboard mark. Two items are the member's own call and persist locally.
const LAUNCH_KEY = 'iap.launch.manual';
const manualSet = () => { try { return new Set(JSON.parse(localStorage.getItem(LAUNCH_KEY) || '[]')); } catch (e) { return new Set(); } };
function launchToggle(key) { const s = manualSet(); if (s.has(key)) s.delete(key); else s.add(key); try { localStorage.setItem(LAUNCH_KEY, JSON.stringify([...s])); } catch (e) {} }
function launchChecks(me) {
const m = me || {}, man = manualSet();
const bc = Number(m.buyerCount || 0), refs = (m.referrals || []).length;
return [
{ key: 'username', title: 'Pick your username', done: !!m.username, href: '/my#profile', cta: 'Profile',
how: 'Profile tab. It becomes your invite link and your public page, and it is permanent.',
why: 'Every link, banner and video you hand out this week carries it. Change it later and the links you already sent die.' },
{ key: 'wallet', title: 'Link your wallet', done: !!m.address, href: '/my#wallet', cta: 'Wallet',
how: 'Wallet tab, Connect, sign the free message. MetaMask recommended. Never held crypto? The wallet guide in Training walks through buying POL with a card.',
why: 'Payouts go to this address. No wallet, nowhere to pay you.' },
{ key: 'payouts', title: 'Switch on payouts', done: !!m.memberId, href: '/my#wallet', cta: 'Wallet',
how: 'Wallet tab, one small transaction. It registers your address with the contract.',
why: 'The contract binds each buyer to their sponsor at their first purchase. If payouts are off when your first person buys, that commission is not yours.' },
{ key: 'level2', title: 'Qualify: open level 2', done: bc >= 2, href: '/my#buy', cta: 'Buy packages',
how: 'Two of your people buy a $20 or more package. Or use Qualified Start: add two positions from extra wallets in your own MetaMask and buy a $20 package from each (about $23 of POL in each wallet).',
why: 'Until you have two qualifying buyers, every level 2 payment from your team climbs past you.',
note: 'Qualifying buyers so far: <b>' + bc + '</b> of 2.' },
{ key: 'level3', title: 'The leader play: open all three levels', done: bc >= 5, href: '/my#buy', cta: 'Qualified Start',
how: 'Five qualifying buyers, real or Qualified Start, up to five linked positions. Once qualified, buy from your main wallet so your sponsor is paid in full.',
why: 'A leader whose team goes three deep this week collects level 3 from day one instead of watching those 10% payments pass upward. Optional for members, the play for leaders.',
note: 'Qualifying buyers so far: <b>' + bc + '</b> of 5.' },
{ key: 'banner', title: 'Upload your line banner', done: !!m.lineBannerUrl, href: '/my#profile', cta: 'Profile',
how: 'Profile tab, line banner. It shows on the welcome tour to everyone in your next three levels.',
why: 'Your first advertising to your own team, free, and it is live the moment they join.' },
{ key: 'links', title: 'Copy your links and pick a play', done: man.has('links'), manual: true,
how: 'Promo tools, Your links: the invite link and the five angle links, plus the matching hook videos. Then read the plays page and choose one.',
why: 'On launch day you send links, not explanations. Having them ready is the whole difference between a launch and a scramble.' },
{ key: 'two', title: 'Place your first two', done: refs >= 2, href: '/my#line', cta: 'My line',
how: 'Two people you have actually talked to, joined through your link, walked through items 1 to 3 on their own accounts.',
why: 'Your first two are the shape of your whole line. Choose them, do not wait for them.',
note: 'Joined through you so far: <b>' + refs + '</b>.' }
];
}
// In-page dialogs instead of window.prompt / confirm. Mobile Safari shows a red "Suppress dialogs"
// option on the second native pop-up in a row and, once tapped, swallows every later prompt on the
// site until reload. ask() resolves the typed value (null on cancel); confirmBox() resolves true/false.
function dialog(o) {
return new Promise(resolve => {
const esc = t => String(t == null ? '' : t).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
const back = document.createElement('div'); back.className = 'modal-back'; back.style.zIndex = '200';
const field = o.type === 'none' ? '' : o.type === 'textarea'
? '<textarea id="dlgInput" rows="5" style="width:100%;margin-top:12px">' + esc(o.value) + '</textarea>'
: '<input id="dlgInput" type="' + (o.type === 'number' ? 'number' : 'text') + '" ' + (o.type === 'number' ? 'inputmode="decimal" min="0" step="any" ' : '') + 'value="' + esc(o.value) + '" placeholder="' + esc(o.placeholder) + '" style="width:100%;margin-top:12px" autocomplete="off">';
back.innerHTML = '<div class="modal-card" role="dialog" aria-modal="true">' + (o.title ? '<h3 style="margin:0 0 8px">' + esc(o.title) + '</h3>' : '')
+ (o.text ? '<p class="muted small" style="margin:0;white-space:pre-line">' + esc(o.text) + '</p>' : '') + field
+ '<div style="display:flex;gap:10px;justify-content:flex-end;margin-top:16px;flex-wrap:wrap"><button type="button" class="btn sec small" id="dlgCancel">' + esc(o.cancel || 'Cancel') + '</button><button type="button" class="btn small" id="dlgOk">' + esc(o.ok || 'OK') + '</button></div></div>';
document.body.appendChild(back);
const inp = back.querySelector('#dlgInput');
const done = v => { document.removeEventListener('keydown', onKey); back.remove(); resolve(v); };
const okv = () => done(o.type === 'none' ? true : (inp ? inp.value : ''));
const onKey = e => { if (e.key === 'Escape') { e.preventDefault(); done(o.type === 'none' ? false : null); } else if (e.key === 'Enter' && o.type !== 'textarea') { e.preventDefault(); okv(); } };
document.addEventListener('keydown', onKey);
back.querySelector('#dlgOk').addEventListener('click', okv);
back.querySelector('#dlgCancel').addEventListener('click', () => done(o.type === 'none' ? false : null));
back.addEventListener('click', e => { if (e.target === back) done(o.type === 'none' ? false : null); });
setTimeout(() => { if (inp) { inp.focus(); if (inp.select && o.type !== 'textarea') inp.select(); } else back.querySelector('#dlgOk').focus(); }, 30);
});
}
function ask(o) { return dialog(Object.assign({ type: 'text', value: '', placeholder: '' }, o || {})); }
function confirmBox(text, o) { return dialog(Object.assign({ type: 'none', text, ok: 'Yes', cancel: 'No' }, o || {})); }
return { getConfig, fmtPol, fmtUsd, status, renderNav, refreshNavWallet, describeEvent, feedRow, adSlot, reportAd, requestCode, launchChecks, launchToggle, ask, confirmBox, $ };
})();
+21
View File
@@ -0,0 +1,21 @@
// Contract page: inject live address, chain, explorer + verified-source links.
(async function () {
await IAP.renderNav('contract');
const c = await IAP.getConfig();
const $ = IAP.$;
$('cAddr').textContent = c.contract.slice(0, 10) + '…' + c.contract.slice(-6);
$('cChain').textContent = c.chainName;
$('mockAddr').textContent = c.contract.slice(0, 18) + '…';
if (c.explorer) {
$('lnkExplorer').href = c.explorer + '/address/' + c.contract + '#code';
const src = 'https://repo.sourcify.dev/contracts/full_match/' + c.chainId + '/' + c.contract + '/';
$('lnkSource').href = src;
$('lnkSource2').href = src;
} else {
// fallback if explorer unset: point straight at the Sourcify-verified source
const src = 'https://repo.sourcify.dev/contracts/full_match/137/0xBE1ECA72AFF47d13D8E907e523F55eB9e2d365E0/';
$('lnkExplorer').href = src;
$('lnkSource').href = src;
$('lnkSource2').href = src;
}
})();
+149
View File
@@ -0,0 +1,149 @@
// Landing page: live ladder, buy buttons, sponsor attribution line.
(async function () {
await IAP.renderNav('home');
const c = await IAP.getConfig();
IAP.$('contractLink').href = c.explorer + '/address/' + c.contract;
const sp = await (await fetch('/api/sponsor')).json();
if (sp.invited) {
const el = IAP.$('sponsorLine');
el.hidden = false;
el.textContent = (sp.sponsorId ? 'You were invited by member #' + sp.sponsorId + '.' : 'You arrived through a member’s invite.')
+ ' Your purchases pay their team, and your own link will do the same for you.';
}
async function loadLadder() {
const { products } = await (await fetch('/api/catalog')).json();
const wrap = document.getElementById('tiles');
wrap.innerHTML = '';
const NAMES = { 1: 'Micro', 2: 'Activation', 3: 'Builder', 4: 'Growth', 5: 'Leader' };
for (const p of products) {
const bonus = p.creditAmount - p.priceCents; // credits above 1cr/cent = bulk bonus
const div = document.createElement('div');
div.className = 'tile' + (p.priceCents === 5000 ? ' hot' : '');
div.innerHTML = '<div class="name">' + (NAMES[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' : '&nbsp;') + '</div>'
+ '<div class="pol">' + (p.costWei ? IAP.fmtPol(p.costWei) + ' POL right now' : 'paused') + '</div>'
+ '<button class="btn small" data-id="' + p.id + '" data-cost="' + (p.costWei || '') + '"'
+ (p.costWei ? '' : ' disabled') + '>Buy</button>';
wrap.appendChild(div);
}
wrap.querySelectorAll('button[data-id]').forEach(b => b.addEventListener('click', () => buyPack(b)));
}
async function buyPack(btn) {
try {
btn.disabled = true;
// email members get their wallet linked to the account at buy time
const me = await (await fetch('/api/me')).json();
if (me.signedIn && me.email && !me.address) {
IAP.status('First, a free signature links your wallet to your account…');
await IAPWallet.signIn();
}
IAP.status('Confirm the purchase in your wallet…');
// resolve the sponsor at buy time: a code referrer who activated since
// page load still gets locked in
const spNow = await (await fetch('/api/sponsor')).json();
const r = await IAPWallet.buy(Number(btn.dataset.id), spNow.sponsorId || 0, btn.dataset.cost);
if (r.receipt.status !== '0x1') throw new Error('Transaction reverted. Check the explorer.');
IAP.status('Purchase settled on-chain. Credits are yours, payouts delivered. Watch it on the ledger.', 'ok');
IAP.refreshNavWallet();
} catch (e) {
IAP.status('Purchase failed: ' + (e.message || e), 'bad');
} finally { btn.disabled = false; }
}
async function loadStats() {
try {
const s = await (await fetch('/api/stats')).json();
IAP.$('stMembers').textContent = (s.onchainMembers || 0).toLocaleString();
IAP.$('stPurchases').textContent = (s.purchases || 0).toLocaleString();
IAP.$('stPaid').textContent = IAP.fmtPol(s.paidInWei || '0');
IAP.$('stPayouts').textContent = (s.payouts || 0).toLocaleString();
} catch (e) {}
}
async function loadTicker() {
try {
const { events } = await (await fetch('/api/feed?n=30')).json();
if (!events.length) return;
const inner = IAP.$('tickerInner');
inner.innerHTML = events.map(ev => '<span>' + IAP.describeEvent(ev, c) + '</span>').join('');
IAP.$('ticker').hidden = false;
// readable pace (Marty, 2026-09-12): about 70 px per second no matter how much text is loaded,
// instead of a fixed 42 s for the whole strip; pause while a finger or pointer rests on it
const wrap = IAP.$('ticker');
const secs = Math.max(30, Math.round((inner.scrollWidth + wrap.clientWidth) / 70));
inner.style.animationDuration = secs + 's';
const pause = on => { inner.style.animationPlayState = on ? 'paused' : 'running'; };
wrap.addEventListener('mouseenter', () => pause(true)); wrap.addEventListener('mouseleave', () => pause(false));
wrap.addEventListener('touchstart', () => pause(true), { passive: true }); wrap.addEventListener('touchend', () => pause(false), { passive: true });
} catch (e) {}
}
// level cycler: chips + generation highlighting + auto-advance
const LVL = {
1: { pct: '50%', desc: 'Activate with the $20 starter package and switch on payouts from your wallet. From then on your direct referrals each pay you 50 percent of every package they ever buy, in POL, straight to your wallet. Until you activate, you earn ad credits, not POL.' },
2: { pct: '20%', desc: 'Bring 2 buyers of $20 or more and level 2 unlocks: 20 percent of every package your referrals’ referrals buy, on every purchase, forever.' },
3: { pct: '10%', desc: 'At 5 qualifying buyers, level 3 opens the third generation: 10 percent of everything they buy. Eight positions deep in this picture, and it keeps growing.' }
};
const viz = document.getElementById('genViz');
if (viz) {
const chips = [...document.querySelectorAll('.chips [data-lvl]')];
const setLvl = n => {
viz.dataset.lvl = n;
document.getElementById('vizPct').textContent = LVL[n].pct;
document.getElementById('lvlDesc').textContent = LVL[n].desc;
chips.forEach(ch => ch.classList.toggle('on', ch.dataset.lvl === String(n)));
};
let cur = 1;
let auto = null;
if (!matchMedia('(prefers-reduced-motion: reduce)').matches) {
auto = setInterval(() => { cur = cur % 3 + 1; setLvl(cur); }, 4200);
}
chips.forEach(ch => ch.addEventListener('click', () => {
if (auto) { clearInterval(auto); auto = null; } // a click takes the wheel
cur = Number(ch.dataset.lvl);
setLvl(cur);
}));
}
// what-if calculator: pure arithmetic on the locked constants
const dc = document.getElementById('dcDirects');
if (dc) {
const $id = x => document.getElementById(x);
const usd = n => '$' + n.toLocaleString(undefined, { maximumFractionDigits: 2 });
const recalc = () => {
const d = Number($id('dcDirects').value);
const p = Number($id('dcPkg').value);
const r = Number($id('dcSpread').value);
$id('dcDirectsV').textContent = d;
$id('dcSpreadV').textContent = r;
const qualifies = p >= 20; // sub-$20 packages never count toward qualification
const l2open = qualifies && d >= 2;
const l3open = qualifies && d >= 5;
const g2 = d * r, g3 = g2 * r;
const e1 = d * p * 0.5;
const e2 = l2open ? g2 * p * 0.2 : 0;
const e3 = l3open ? g3 * p * 0.1 : 0;
$id('dcN1').textContent = d; $id('dcN2').textContent = g2; $id('dcN3').textContent = g3;
$id('dcE1').textContent = usd(e1);
$id('dcE2').textContent = l2open ? usd(e2) : 'passes up';
$id('dcE3').textContent = l3open ? usd(e3) : 'passes up';
$id('dcTotal').textContent = usd(e1 + e2 + e3);
const setB = (el, open, need) => { el.textContent = open ? 'open' : 'locked: ' + need; el.className = 'badge' + (open ? '' : ' amber'); };
setB($id('dcB1'), true, '');
setB($id('dcB2'), l2open, qualifies ? (2 - d) + ' more buyer(s)' : 'needs $20+ buyers');
setB($id('dcB3'), l3open, qualifies ? (5 - d) + ' more buyer(s)' : 'needs $20+ buyers');
$id('dcQualNote').textContent = qualifies
? 'Buyers of $20 or more count toward your qualification. 2 unlock level 2, 5 unlock level 3.'
: 'Heads up: $5 packages pay your level 1 but do not qualify buyers, so levels 2 and 3 stay locked in this scenario.';
};
['dcDirects', 'dcPkg', 'dcSpread'].forEach(x => $id(x).addEventListener('input', recalc));
recalc();
}
IAP.adSlot('banner', 'adSlotHome');
loadLadder();
loadStats();
loadTicker();
setInterval(loadStats, 60000);
})();
+112
View File
@@ -0,0 +1,112 @@
// Invite / lead-capture page: /join/<token>[?v=<angle>]
// Email first (code sign-in creates the account), wallet later inside the
// member area. The angle only changes the hook copy; the sponsor cookie was
// set by the server when this page was served.
(function () {
const $ = id => document.getElementById(id);
const LEG = (brand, seg) => seg === 'adv'
? { eyebrow: 'For former ' + brand + ' advertisers', h: 'Your next ad budget <em>pays you back.</em>',
lead: brand + ' is closed. The people who bought ads there are exactly who LinkSpin was built for: real ad packages from $5, seven formats, and every package in your line paid out by a verified contract on Polygon in the same transaction.',
points: ['Welcome-back credits land the moment your account exists: enough to run a real banner or text campaign today, on us.', 'Seven formats: banners, text ads, login ads, solo ads to member inboxes, video, featured links and verified visits. Views are timed on the server, so a real person saw your ad.', 'When anyone in your line buys ads, the contract pays you in that same transaction. Public on Polygonscan, nothing held, nothing to withdraw.'],
cta: 'Claim your welcome-back credits', sub: 'Free account by email. No password, no wallet today. Use the email you had on ' + brand + ': the credits are tied to it.', video: false }
: { eyebrow: 'For former ' + brand + ' members', h: 'Same daily habit. <em>Real payouts on-chain.</em>',
lead: 'You viewed ads on ' + brand + '. Here you view ads to earn credits, run your own campaign with them for free, and when anyone in your line buys ads you are paid in POL to your own wallet in the same transaction.',
points: ['Welcome-back credits on day one, so your first campaign runs before you have viewed a single ad.', 'Join with just an email. No password, no wallet today. Link a wallet later, only when you want payouts switched on.', 'Every payout is a public transaction on Polygon. Nothing is held, so there is nothing to withdraw and nothing to wait for.'],
cta: 'Claim your welcome-back credits', sub: 'Free account by email. Use the email you had on ' + brand + ': the credits are tied to it.', video: false };
const ANGLES = {
'fw-adv': LEG('Faucet Wave', 'adv'), 'fw-earn': LEG('Faucet Wave', 'earn'), 't1-adv': LEG('Tier One Ads', 'adv'), 't1-earn': LEG('Tier One Ads', 'earn'),
instant: { eyebrow: 'Same-transaction payouts', h: 'Paid before the page <em>reloads.</em>', lead: 'What if your commission landed before the thank-you page finished loading? On LinkSpin that is not a metaphor. A smart contract on Polygon splits every ad package the moment it sells.',
points: ['A verified contract splits every package in the same transaction it sells: 50 percent to the sponsor, 20 and 10 up the line, 20 to the platform.', 'It lands in your own wallet in seconds. There is no balance to withdraw because nothing is ever held.', 'Every payment is public on Polygonscan, so you can check the claim before you spend a dollar.'],
cta: 'See a payout land in seconds', sub: 'Free account by email. No password, no wallet today. Your invite link is live the moment you are in.' },
adspend: { eyebrow: 'For marketers who buy traffic', h: 'You were buying <em>ads anyway.</em>', lead: 'Every ad dollar you ever spent went one direction: out. Here the ad spend in your line pays you, in the same transaction, on a public ledger. Seven formats, dwell-timed views, packages from $5.',
points: ['Seven formats: banners, text ads, login ads, solo ads to member inboxes, video, featured links and verified visits.', 'Views are timed on the server, so a real person saw your ad. Banners and text also run across a partner ad network.', 'When anyone in your line buys ads, the contract pays you in that same transaction.'],
cta: 'Put your next ad dollar where it pays you back', sub: 'Free account by email. See every format and the live rates before you buy anything.' },
free: { eyebrow: 'Costs nothing to try', h: 'Watch first. <em>Spend never.</em>', lead: 'Join free, view a few ads, earn credits, and run your first campaign for zero dollars. Upgrade only if you want more reach.',
points: ['Join with just an email. No password, no wallet today.', 'View a few ads each day and earn credits you can spend on your own banner or text campaign.', 'Buy a package only if you want more reach. They start at $5, and every payout on them is public.'],
cta: 'Start for free. No card, no wallet.', sub: 'Type your email and we send a 6-digit code. That is the whole signup.' },
ledger: { eyebrow: 'No back office', h: 'No back office. <em>No payday.</em>', lead: 'Your last program paid you on the 15th, if it paid you. Here every payout is a public transaction on Polygon you can read yourself, and nothing is ever held.',
points: ['Every payout is a public transaction on Polygon. Click it, read it, verify it yourself.', 'The contract holds zero balance. It splits and sends in the same transaction, with no pause switch and no upgrade path.', 'No back office means nobody can delay, reverse or review your commission.'],
cta: 'Check the ledger yourself, then decide', sub: 'Free account by email. The public ledger and the verified contract are one click from your dashboard.' },
two: { eyebrow: 'The referral side, exactly as coded', h: 'Two buyers open <em>level two.</em>', lead: 'Every direct buyer pays you 50 percent from their first package. Two qualifying buyers open level two, five open level three. Written as constants in a verified contract.',
points: ['Every direct buyer pays you 50 percent from their very first package.', 'Two qualifying buyers open level two at 20 percent. Five open level three at 10 percent. Constants in a verified contract.', 'Until a level opens, its share climbs to the next qualified member above, so the plan rewards the people who build.'],
cta: 'Start your line. Two buyers is the target.', sub: 'Free account by email. Your invite link and the team-building plays are waiting inside.' }
};
// ?name=First personalises the page (site-owner links, Marty 2026-09-12): letters, spaces, hyphens,
// apostrophes only, 30 chars. On the company placement links it says the top spot is reserved.
const who = String(new URLSearchParams(location.search).get('name') || '').replace(/[^A-Za-z\u00C0-\u024F' -]/g, '').trim().slice(0, 30);
if (who) {
const top = /^\/join\/(company|top)$/i.test(location.pathname);
const h = $('jnHello');
if (h) { h.textContent = who + (top ? ', your spot directly under the company at the top is reserved for you. Activate with the $20 package to claim it.' : ', this invitation is for you.'); h.hidden = false; }
}
const v = new URLSearchParams(location.search).get('v') || (document.cookie.match(/(?:^|; )iap\.angle=([^;]+)/) || [])[1] || '';
const a = v && ANGLES[v];
if (a) {
document.body.classList.add('squeeze'); // server sets it too; this covers cached HTML
$('jnEyebrow').textContent = '· ' + a.eyebrow; $('jnHead').innerHTML = a.h; $('jnLead').textContent = a.lead;
if (a.cta) { $('jnCapH').textContent = a.cta; $('jnCapSub').textContent = a.sub || ''; }
// the matching hook video + this angle's three points replace the worked example
const VID = 'https://coolify-saasytop.nyc3.digitaloceanspaces.com/promo/';
const vid = $('jnVideo');
if (a.video === false) vid.hidden = true; else { vid.src = VID + v + '.mp4'; vid.poster = VID + v + '.jpg'; }
$('jnPoints').innerHTML = (a.points || []).map(t => '<li>' + t.replace(/[&<>]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[c])) + '</li>').join('');
$('jnMock').hidden = true; $('jnAngle').hidden = false; $('jnPoints').hidden = false;
}
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;
}
const err = m => { const e = $('jnErr'); e.textContent = m || ''; e.hidden = !m; };
function busy(btn, fn) {
return async () => { if (btn.disabled) return; btn.disabled = true; err(''); try { await fn(); } catch (e) { err(e.message || 'Something went wrong.'); } finally { btn.disabled = false; } };
}
const codeOpts = () => ({ honeypot: $('jnWebsite'), host: $('jnCheck') });
const send = busy($('jnSend'), async () => {
const r = await IAP.requestCode($('jnEmail').value, codeOpts());
$('jnCodeRow').hidden = false; $('jnVerify').hidden = false; $('jnSend').hidden = true; $('jnResend').hidden = false;
if (r.devCode) $('jnCode').value = r.devCode;
// the last thing they see before the account is created: who they are joining under
if (sponsorName) { $('jnUnder').textContent = 'Joining under ' + sponsorName + '. Not who invited you? Open their invite link first, then come back for the code.'; $('jnUnder').hidden = false; }
IAP.status(r.sent ? 'Code sent. Check your inbox (and spam, the first time).' : 'Dev mode: code filled in.', 'ok');
$('jnCode').focus();
});
$('jnSend').addEventListener('click', send);
$('jnResend').addEventListener('click', busy($('jnResend'), async () => {
const r = await IAP.requestCode($('jnEmail').value, codeOpts());
if (r.devCode) $('jnCode').value = r.devCode;
IAP.status('Fresh code sent.', 'ok');
}));
$('jnVerify').addEventListener('click', busy($('jnVerify'), async () => {
await api('/api/auth/email/verify', { email: $('jnEmail').value, code: $('jnCode').value, newsletter: !!$('jnNews').checked, followups: !!$('jnNews').checked });
IAP.status('You are in. Taking you to your dashboard…', 'ok');
location.href = '/my?welcome=1'; // dashboard runs the username step + welcome tour on arrival
}));
$('jnEmail').addEventListener('keydown', e => { if (e.key === 'Enter') ($('jnVerify').hidden ? $('jnSend') : $('jnVerify')).click(); });
$('jnCode').addEventListener('keydown', e => { if (e.key === 'Enter') $('jnVerify').click(); });
// sponsor line (the link opened most recently sets the sponsor; it locks at account creation)
let sponsorName = '';
const linkTok = (location.pathname.match(/^\/join\/([^/?#]+)/) || [])[1] || '';
fetch('/api/sponsor' + (linkTok ? '?ref=' + encodeURIComponent(linkTok) : '')).then(r => r.json()).then(sp => {
if (sp && sp.invited && sp.name) {
sponsorName = sp.name;
$('jnSponName').textContent = sp.name + (sp.own ? ' (this is your own invite page; visitors see your name here)' : '');
if (sp.avatarUrl) { $('jnSponImg').src = sp.avatarUrl; $('jnSponImg').hidden = false; }
if (sp.cobrand && sp.bio && $('jnSponBio')) { $('jnSponBio').textContent = sp.bio; $('jnSponBio').hidden = false; $('jnSpon').classList.add('cobrand'); }
$('jnSpon').hidden = false;
}
}).catch(() => {});
// package ladder (dollar constants + live POL quote)
const NAMES = { 1: 'Micro', 2: 'Activation', 3: 'Builder', 4: 'Growth', 5: 'Leader' };
fetch('/api/catalog').then(r => r.json()).then(c => {
const wrap = $('jnLadder'); if (!wrap || !c.products) return;
wrap.innerHTML = c.products.filter(p => p.active !== false).map(p =>
'<div class="jn-pk"><div class="n">' + (NAMES[p.id] || 'Package ' + p.id) + '</div><div class="p">' + IAP.fmtUsd(p.priceCents) + '</div><div class="c">' + Number(p.creditAmount).toLocaleString() + ' credits</div>'
+ (p.costWei ? '<div class="small muted">' + IAP.fmtPol(p.costWei) + ' POL now</div>' : '') + '</div>').join('');
}).catch(() => {});
fetch('/api/stats').then(r => r.json()).then(s => {
if (s && s.onchainMembers) $('jnStats').textContent = s.onchainMembers.toLocaleString() + (s.onchainMembers === 1 ? ' member' : ' members') + ' on-chain so far.';
}).catch(() => {});
})();
+114
View File
@@ -0,0 +1,114 @@
// Founding week checklist: members only; every item reads from the live account
// (IAP.launchChecks in common.js is shared with the dashboard's "launch ready" mark).
(async function () {
const $ = id => document.getElementById(id);
try { await IAP.renderNav('training'); } catch (e) {}
let me = null, cfg = {};
try { me = await (await fetch('/api/me')).json(); } catch (e) {}
// buyerCount and the referral list live on the dashboard endpoint; merge it in
if (me && me.signedIn) { try { const d = await (await fetch('/api/my/dashboard')).json(); if (d && !d.error) me = Object.assign({}, me, d, { signedIn: true, email: me.email }); } catch (e) {} }
try { cfg = await IAP.getConfig(); } catch (e) {}
const signedIn = !!(me && me.signedIn && me.email);
$('gate').style.display = signedIn ? 'none' : 'block';
$('body').style.display = signedIn ? 'block' : 'none';
if (!signedIn) return;
const pb = $('printBtn'); if (pb) pb.addEventListener('click', () => window.print());
const tok = me.username || me.refCode || me.memberId;
if (tok) document.querySelectorAll('[data-link]').forEach(el => { el.textContent = location.origin + '/join/' + tok; });
// launch week swipes: four promoter emails, the member's link + FOUNDER code filled in (Marty, 2026-09-15)
const LINK = location.origin + '/join/' + (tok || '') + '?promo=FOUNDER';
const SW = [
{ when: 'Day 1 · six days out', subject: 'Something opens Monday. I am bringing a few people in early.', body: `Quick one, because I want you positioned before this goes public.
I have been inside a new advertising platform for a week. It pays sponsors in the same transaction a package is bought, wallet to wallet, on the Polygon ledger where anyone can check it. No back office. No payday. I have watched the payouts land.
It opens to everyone Monday, September 21 at 9 AM Central. Founding members are in now, and the people they bring in before then are the ones the launch traffic lands under.
Joining is free with an email. This week the code FOUNDER gives you 500 ad credits to run a campaign on day one:
{link}
No income is promised. It is an ad platform with a referral program. But the payment mechanics are real and public, and that is why I am in.
Look before Monday. I will walk you through the first three steps.`},
{ when: 'Day 2 · five days out', subject: 'Five hundred free credits, and what you can do with them', body: `Yesterday I sent you a link. Here is what is on the other side of it.
You join free with your email. Every day you view a short set of ads and earn credits. Credits run your own ads: banners, text ads, video ads, solo mailings, verified visits, across the whole network.
This week the FOUNDER code adds 500 credits on top the moment you join, enough to launch a real campaign pointed at whatever you are promoting. Watch it deliver before Monday, when everyone else arrives.
The other half is simple. When someone you invite buys an ad package, the contract pays you 50 percent instantly. Level two gets 20 percent, level three 10 percent. Same transaction, on the public ledger.
{link}
Use the code before it expires Monday morning. Then tell me you are in and I will show you the checklist.`},
{ when: 'Day 4 · the weekend', subject: 'The two people you bring in this weekend', body: `The doors open Monday morning and I am spending the weekend getting my first people set up. I want you to be one of them.
Here is why the weekend matters. Everyone who joins now is a founding member. The traffic that arrives Monday lands under whoever is already in and set up. Two people under you before Monday, and your line is built when the wave hits.
Setup takes ten minutes: username, link a wallet, switch on payouts. Then share your link. The site gives you posts, swipes, banners and a leaderboard with weekly credit prizes for the top sponsors.
Join with the FOUNDER code and the 500 credits are yours:
{link}
Message me when you are in and I will go through the checklist with you before Monday.`},
{ when: 'Day 6 · Sunday evening', subject: 'Doors open tomorrow at 9 AM Central', body: `Last note before the launch.
LinkSpin opens to the public tomorrow, Monday, at 9 AM Central. After that the FOUNDER code is gone. Tonight it still gives you 500 free ad credits.
If you join tonight you are in as a founding member with a campaign already funded and your link ready when the first wave arrives. If you wait, you join the wave.
Free to join, real advertising, and every payment lands in your own wallet the second it happens, on a ledger anyone can read. No income promises. Just mechanics you can verify.
{link}
I will be online from 8 AM tomorrow walking people through the first three steps. Get in tonight and you are ahead of them.`}
];
// short posts and a DM for the same days, one per day, link + code included
const POSTS = [
{ when: 'Day 1 · six days out', text: `Something I have been inside for a week opens to the public Monday, September 21 at 9 AM Central. An ad platform that pays sponsors in the same transaction a package is bought, on the Polygon ledger where anyone can check it. Free to join. Code FOUNDER gives you 500 ad credits before then. No income promises, just mechanics you can verify. {link}` },
{ when: 'Day 2 · five days out', text: `500 free ad credits for joining before Monday. View a short daily set of ads, earn more, run banners, text ads, video ads and solo mailings across the network. When someone you invite buys a package, the contract pays you 50 percent instantly, wallet to wallet. Code FOUNDER. {link}` },
{ when: 'Day 4 · the weekend', text: `The doors open Monday morning. Everyone joining this weekend is a founding member, and the launch traffic lands under whoever is already set up. Ten minutes to set up, 500 credits with code FOUNDER, and a leaderboard that pays weekly credit prizes to the top sponsors. {link}` },
{ when: 'Day 6 · Sunday evening', text: `Doors open tomorrow, 9 AM Central. Tonight the FOUNDER code still gives you 500 free ad credits; tomorrow it is gone. Free to join, real advertising, every payment on a public ledger. Get in tonight and you are ahead of the wave. {link}` },
{ when: 'Text or DM · any day', text: `Hey, I am in something that opens publicly Monday and I am bringing a few people in early. Free to join, real advertising, every payment lands in your own wallet the second it happens. Use code FOUNDER for 500 free credits. Ten minutes to set up and I will walk you through it: {link}` }
];
const pl = $('postList');
if (pl) {
pl.innerHTML = POSTS.map((s, i) => '<div class="swipe"><div class="cap"><span>' + s.when + '</span><button type="button" class="btn sec" data-post="' + i + '">Copy post</button></div><pre>' + s.text.replace('{link}', LINK).replace(/&/g, '&amp;').replace(/</g, '&lt;') + '</pre></div>').join('');
pl.querySelectorAll('[data-post]').forEach(b => b.addEventListener('click', async () => {
const t = POSTS[Number(b.dataset.post)].text.replace('{link}', LINK);
try { await navigator.clipboard.writeText(t); b.textContent = 'Copied'; setTimeout(() => { b.textContent = 'Copy post'; }, 1500); } catch (e) { IAP.status('Copy failed; select the text by hand.', 'bad'); }
}));
}
const wrap = $('swipeList');
if (wrap) {
wrap.innerHTML = SW.map((s, i) => '<div class="swipe"><div class="cap"><span>Email ' + (i + 1) + ' · ' + s.when + '</span><button type="button" class="btn sec" data-swipe="' + i + '">Copy email</button></div><div class="subj">Subject: ' + s.subject + '</div><pre>' + s.body.replace('{link}', LINK).replace(/&/g, '&amp;').replace(/</g, '&lt;') + '</pre></div>').join('');
wrap.querySelectorAll('[data-swipe]').forEach(b => b.addEventListener('click', async () => {
const s = SW[Number(b.dataset.swipe)]; const text = 'Subject: ' + s.subject + '\n\n' + s.body.replace('{link}', LINK);
try { await navigator.clipboard.writeText(text); b.textContent = 'Copied'; setTimeout(() => { b.textContent = 'Copy email'; }, 1500); } catch (e) { IAP.status('Copy failed; select the text by hand.', 'bad'); }
}));
}
// launch moment (admin: Settings > launchAt, ISO 8601 with offset)
const at = cfg.launchAt ? new Date(cfg.launchAt) : null;
if (at && !isNaN(at)) {
$('lwWhen').hidden = false;
$('lwWhenAt').textContent = at.toLocaleString([], { weekday: 'short', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
const tick = () => { const ms = at - Date.now(); if (ms <= 0) { $('lwWhenIn').textContent = 'doors are open'; return; }
const d = Math.floor(ms / 86400000), h = Math.floor(ms % 86400000 / 3600000), m = Math.floor(ms % 3600000 / 60000);
$('lwWhenIn').textContent = (d ? d + 'd ' : '') + h + 'h ' + m + 'm to go'; };
tick(); setInterval(tick, 30000);
}
function render() {
const items = IAP.launchChecks(me);
const done = items.filter(i => i.done).length;
$('lwDone').textContent = done; $('lwBar').style.width = Math.round(done / items.length * 100) + '%';
$('chk').innerHTML = items.map((i, n) => '<li class="' + (i.done ? 'done' : '') + '"><div class="box">' + (i.done ? '✓' : (n + 1)) + '</div>'
+ '<div><h3>' + i.title + '</h3><p>' + i.how + '</p><div class="why">' + i.why + '</div>' + (i.note ? '<p class="small" style="margin-top:4px">' + i.note + '</p>' : '') + '</div>'
+ '<div class="act">' + (i.manual ? '<button class="btn small ' + (i.done ? 'sec' : '') + '" type="button" data-manual="' + i.key + '">' + (i.done ? 'Undo' : 'Mark done') + '</button>' : '<a class="btn small sec" href="' + i.href + '">' + i.cta + '</a>') + '</div></li>').join('');
$('chk').querySelectorAll('[data-manual]').forEach(b => b.addEventListener('click', () => { IAP.launchToggle(b.dataset.manual); render(); }));
}
render();
})();
+31
View File
@@ -0,0 +1,31 @@
// Live ledger: recent history + SSE stream of new chain events.
(async function () {
await IAP.renderNav('ledger');
const c = await IAP.getConfig();
IAP.$('contractLink').href = c.explorer ? c.explorer + '/address/' + c.contract : '/contract';
const feed = IAP.$('feed');
const { events } = await (await fetch('/api/feed?n=150')).json();
feed.innerHTML = '';
if (!events.length) feed.innerHTML = '<div class="row muted">No activity yet. The first purchase will appear here the moment it lands.</div>';
for (const ev of events) feed.appendChild(IAP.feedRow(ev, c));
try {
const stats = await (await fetch('/api/stats')).json();
IAP.$('statLine').textContent = stats.onchainMembers + ' on-chain member(s)';
} catch (e) {}
IAP.adSlot('banner', 'adSlotBanner');
IAP.adSlot('text', 'adSlotText');
const es = new EventSource('/api/feed/live');
es.onopen = () => { const b = IAP.$('liveBadge'); b.textContent = '● live'; };
es.onerror = () => { const b = IAP.$('liveBadge'); b.textContent = 'reconnecting…'; };
es.onmessage = m => {
try {
const ev = JSON.parse(m.data);
feed.prepend(IAP.feedRow(ev, c));
while (feed.children.length > 200) feed.removeChild(feed.lastChild);
} catch (e) {}
};
})();
+2
View File
@@ -0,0 +1,2 @@
// legal/static content pages: render the shared nav + footer
(async function () { try { await IAP.renderNav(''); } catch (e) {} })();
+2707
View File
File diff suppressed because it is too large Load Diff
+21
View File
@@ -0,0 +1,21 @@
// Site-owner partner page: ?name=First greets the owner, ?promo=CODE rides along into the
// company placement link and the example builder link. Display only; placement and credits
// come from the join route and the promo code.
(function () {
const q = new URLSearchParams(location.search);
const name = String(q.get('name') || '').replace(/[^A-Za-zÀ-ɏ' -]/g, '').trim().slice(0, 30);
const promo = String(q.get('promo') || '').toUpperCase().replace(/[^A-Z0-9_-]/g, '').slice(0, 24);
const ref = String(q.get('ref') || '').toLowerCase().replace(/[^a-z0-9_]/g, '').slice(0, 20); // a member's own partner kit (Nexus): the deal lands under them
const hello = document.getElementById('pkHello');
if (hello && name) { hello.textContent = name + ', this page is for you. Your spot directly under the company at the top is reserved; activate with the $20 package to claim it.'; hello.style.display = 'block'; }
const cta = document.getElementById('pkCta');
if (cta) {
const p = new URLSearchParams(); if (name) p.set('name', name); if (promo) p.set('promo', promo);
cta.href = (ref ? '/join/' + ref : '/join/company') + (p.toString() ? '?' + p.toString() : '');
if (name) cta.textContent = 'Claim my spot, ' + name;
}
const ex = document.getElementById('pkExample');
if (ex && promo) ex.textContent = 'https://linkspin-test.saasy.top/join/yourname?promo=' + promo;
const sub = document.getElementById('pkCtaSub');
if (sub && promo) sub.textContent = 'Free account by email. No password, no wallet today. The link below places you directly under the company and applies your code ' + promo + ' as well.';
})();
+14
View File
@@ -0,0 +1,14 @@
// Team-building plays page: members only, and the scripts carry the member's own link.
(async function () {
try { await IAP.renderNav('training'); } catch (e) {}
let me = null;
try { me = await (await fetch('/api/me')).json(); } catch (e) {}
const signedIn = !!(me && me.signedIn && me.email);
document.getElementById('gate').style.display = signedIn ? 'none' : 'block';
document.getElementById('body').style.display = signedIn ? 'block' : 'none';
if (!signedIn) return;
try { IAP.adSlot('banner', 'adSlotPlays'); } catch (e) {}
const pb = document.getElementById('printBtn'); if (pb) pb.addEventListener('click', () => window.print());
const tok = me.username || me.refCode || me.memberId;
if (tok) document.querySelectorAll('[data-link]').forEach(el => { el.textContent = location.origin + '/join/' + tok; });
})();
+217
View File
@@ -0,0 +1,217 @@
// Promo tools content + rendering for the member area (Promo tools pane).
// Everything is personalized from the member's join link. Angle links carry
// ?v=<angle> so they keep working when the matched squeeze pages ship.
// Copy rules: no income promises, package prices in dollars are fine, never a
// POL figure, no em dashes.
window.IAPPromo = (function () {
const $ = id => document.getElementById(id);
const esc = s => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
const status = (m, c) => (window.IAP && IAP.status) ? IAP.status(m, c) : console.log(m);
const angleLink = (link, angle) => angle ? link + '?v=' + angle : link;
// ── social posts ──
const POSTS = [
{ net: 'X', label: 'X · instant payout', text: 'No withdraw button. A smart contract on Polygon splits every ad package the second it sells: 50% to the sponsor, 20% to level 2, 10% to level 3. Lands in your own wallet. Watch the ledger move: {{LINK:instant}}' },
{ net: 'X', label: 'X · advertiser', text: 'Marketers: you were buying traffic anyway. Here the ad spend in your line pays you, in the same transaction, on a public ledger. Packages from $5. Join free: {{LINK:adspend}}' },
{ net: 'X', label: 'X · free join', text: 'Join free. View a few ads, earn credits, run your first campaign for zero dollars. Seven ad formats, real dwell-timed attention, every payout public on Polygon. {{LINK:free}}' },
{ net: 'Facebook', label: 'Facebook · story post', text: 'I got tired of "your commission is pending." So I joined a platform where there is no pending. A smart contract on the Polygon blockchain splits every ad package the moment it sells: half to the sponsor, then levels two and three, then the platform. It lands in your own wallet in seconds. No approval queue, no withdrawal button, no 15th of the month.\n\nYou are buying real advertising: banners, text ads, login ads, solo ads to member inboxes, video ads, featured links and verified visits. Members earn credits for their attention, so the ads actually get seen.\n\nJoin free, look around, and check the public ledger before you spend a dollar: {{LINK:instant}}\n\nNo income promises. It is advertising, not investing, and crypto carries risk.' },
{ net: 'Facebook', label: 'Facebook · skeptic angle', text: 'Every online program I ever joined asked me to trust a back office. This one does not. Every payout is a public transaction on Polygon you can look up yourself, with the wallet addresses and amounts right there. The split is written in a verified smart contract nobody can quietly edit.\n\nFree to join with just an email. The wallet comes out only if you buy a package or switch on payouts. Packages run $5 to $250, and every one of them is ad delivery you can watch running.\n\nTake the tour: {{LINK:ledger}}' },
{ net: 'LinkedIn', label: 'LinkedIn · professional', text: 'An experiment in transparent affiliate payouts: LinkSpin sells advertising packages ($5 to $250) whose sponsor commissions are settled by a verified smart contract on Polygon in the same transaction as the purchase. 50 / 20 / 10 across three levels, 20 percent to the platform, every payment public on the chain.\n\nWhat I find interesting is not the commission. It is that "pending payout" stops being a concept. If you buy traffic for a living and want to see how same-transaction settlement works in practice, the tour is free: {{LINK:adspend}}' },
{ net: 'Telegram', label: 'Telegram or WhatsApp group', text: 'Quick one for the group. Ad platform on Polygon where every package splits to sponsor wallets the second it sells. No withdrawals, no pending. Free to join by email, packages from $5, seven ad formats, and you earn credits for viewing. Tour here: {{LINK:instant}}' },
{ net: 'Telegram', label: 'Telegram or WhatsApp group · free angle', text: 'If you want to test an ad network without spending anything: join free, view a handful of ads, earn credits, run your first campaign on the house. Payouts are on the public Polygon ledger. {{LINK:free}}' }
];
// ── text a friend (SMS-sized) ──
const TEXTS = [
{ angle: '', title: '"Thought of you"', text: 'Hey, found an ad platform where the commission lands in your wallet the second someone buys. No pending, no withdraw button. Free to look: {{LINK}}' },
{ angle: 'instant', title: '"Before the page reloads"', text: 'Random thought. What if your commission showed up before the thank-you page finished loading? That is literally how this works: {{LINK:instant}}' },
{ angle: 'adspend', title: '"You buy ads anyway"', text: 'You already buy traffic. This one pays your line every time someone in it buys ads, same transaction, on Polygon. Two-minute look: {{LINK:adspend}}' },
{ angle: 'free', title: '"Costs nothing to try"', text: 'Try this without spending a dollar: join free, view a few ads, earn credits, run your first campaign. {{LINK:free}}' },
{ angle: 'ledger', title: '"No back office"', text: 'Remember waiting on payouts that never came? This one has no back office. Every payment is public on the blockchain. Check it yourself: {{LINK:ledger}}' }
];
// ── email swipes ──
const SWIPES = [
{ tier: 'Short', subject: 'Paid the second it sells', body: 'Quick one.\n\nI joined an ad platform where every package splits to sponsor wallets in the same transaction as the sale. No pending payouts, no withdraw button. It is on the Polygon blockchain, and every payment is public.\n\nJoin free, look at the ledger, decide later: {{LINK:instant}}\n\nNo income promises. Advertising, not investing.' },
{ tier: 'Standard', subject: 'Your wallet gets paid instantly', body: 'You know the usual drill. Someone buys on your link, you wait for a payout. Maybe days. Maybe an approval hold. Maybe a "your account is under review."\n\nLinkSpin does not work like that.\n\nA smart contract on the Polygon blockchain handles every purchase the second it happens. 50% to the sponsor. 20% to the next level. 10% to the one after that. 20% to the platform. Each split lands directly in your own wallet. No withdrawal button. No "request payout." No approval queue.\n\nThe money just shows up.\n\nYou can watch every transaction on the public ledger. Real time. Anyone can verify it.\n\nFree to join. Packages from $5 to $250. No income promises. It is advertising, not investing.\n\n{{LINK}}' },
{ tier: 'Long', subject: 'The ad network with no pending payouts', body: 'Let me tell you what you are actually looking at, because "crypto ad platform" can mean anything.\n\nLinkSpin sells advertising. Five packages, $5 to $250. A package mints ad credits, and one credit is one cent of delivery across seven formats: display banners, text ads, full-screen login ads, solo ads delivered into member inboxes, video ads, featured links, and verified visits. Members earn credits for their attention, with a timer that pauses when they look away, so your ad is seen by a person, not a script.\n\nNow the part that made me join.\n\nWhen anyone in your line buys a package, a verified smart contract on Polygon splits the payment in that same transaction: 50 percent to their direct sponsor, 20 percent to level two, 10 percent to level three, 20 percent to the platform. It lands in real wallets in seconds. There is no balance to withdraw because nothing is ever held.\n\nEvery one of those payments is public. Open the ledger, click a transaction, read it on Polygonscan.\n\nJoining is free and only needs an email. Your wallet comes out when you buy a package or switch on payouts. If you never spend a dollar, you can still view ads, earn credits, and run a small campaign on those.\n\nHave a look: {{LINK:instant}}\n\nOne honest line: nobody is promising you an income. Results depend on your effort, and crypto carries risk of loss.' },
{ tier: 'Follow-up', subject: 'Did you see the ledger?', body: 'Following up on the ad platform I sent over.\n\nIf you only look at one thing, look at the live ledger. Every purchase and every payout, with real wallet addresses, in the order they happened. That is the whole pitch: nothing to take on faith.\n\n{{LINK:ledger}}\n\nIf it is not for you, no worries at all.' }
];
// ── objection bank (truth + say this) ──
const OBJECTIONS = [
{ q: 'Which crypto do I get paid in?', truth: 'One coin, one network: POL, the native coin of Polygon. Packages are dollar-priced and settled in POL at the live Chainlink rate; every payout is sent as POL to the recipient\'s own Polygon wallet in the same transaction. No tokens, no other chains, no stablecoins.', say: 'You get paid in POL, which is Polygon\'s own coin, straight into your wallet the moment a package in your line sells. Any Polygon wallet works, MetaMask, SafePal, Phantom, and if you have never held crypto you can buy POL with a card inside the member area. {{LINK}}' },
{ q: 'Is this a pyramid scheme?', truth: 'You are buying advertising that actually runs: a credit is one cent of delivery across seven live formats, and members earn credits for dwell-timed attention. Sponsor payments are referral commissions written as constants in a verified contract, paid only when a real package sells. Nobody has to recruit to use the ads, and joining costs nothing.', say: 'Fair question. Here is the test I use: is there a real product that people would buy with no referral involved? Here the product is ad delivery, seven formats, one cent per credit, and you can watch your campaign serving. The sponsor split is a referral commission on those sales, fixed in a public contract. Join free and run the ads with zero referrals if you like. {{LINK:free}}' },
{ q: 'I do not have a crypto wallet. I am not technical.', truth: 'Joining needs only an email and a 6-digit code. The wallet comes out only when someone buys a package or switches on payouts, and the site walks them through it. A card on-ramp (MoonPay) delivers POL straight to their own wallet. Email first, wallet second.', say: 'You do not need one to join. It is email only, no password even. The wallet shows up later, only if you decide to buy a package or want payouts, and the site walks you through it step by step. Start here and look around first: {{LINK}}' },
{ q: 'What am I actually buying?', truth: 'Ad credits minted on-chain the moment a package is bought: $5 = 500, $20 = 2,000, $50 = 5,500, $100 = 12,000, $250 = 32,500. Credits spend on banners, text ads, login ads, solo ads to member inboxes, video ads, featured links and verified visits, with live stats per campaign. Only the buyer\'s campaigns can spend them.', say: 'Advertising. A package mints credits, one credit is one cent of delivery, and you spend them on seven ad formats from your own dashboard with live stats. The $5 package is 500 credits, the $250 package is 32,500. You can see the formats before you spend anything: {{LINK:adspend}}' },
{ q: 'Who holds my money?', truth: 'Nobody. A purchase is one Polygon transaction that splits to the sponsor line and the platform wallets immediately. The site never holds balances, and the contract source is verified on Polygonscan and Sourcify. Overpayment refunds itself in the same transaction.', say: 'That is the part I like most: nobody holds it. The purchase is a single blockchain transaction that pays the sponsor line and the platform in the same moment. There is no balance sitting anywhere waiting for a withdrawal. The contract code is public and verified, and every payment is on the ledger: {{LINK:ledger}}' },
{ q: 'What if POL drops?', truth: 'Packages are priced in dollars and settled in POL at the live Chainlink rate at the moment of purchase. Payouts arrive as POL in the recipient\'s own wallet immediately, so what they do with it is their call. This is advertising, not an investment, and POL is volatile like any crypto asset.', say: 'Packages are dollar priced, so $20 is $20 worth of POL at that moment. Payouts land in your wallet right away, and it is your money from that second. Crypto does move, so treat it as advertising you bought, not an investment. {{LINK}}' },
{ q: 'Why would anyone buy ads here?', truth: 'The members are marketers who already buy traffic. Viewers earn credits only after a server-timed dwell, so impressions are real people. Solo ads reach member inboxes with a guaranteed delivery count, verified visits are one unique member per visit, and banner and text ads also syndicate to a partner ad network.', say: 'Because the people on it are marketers, and they get paid to actually look. Every view is dwell-timed on the server, solo ads land in real inboxes with a guaranteed count, and verified visits are one real person each. You can watch your own campaign serve: {{LINK:adspend}}' },
{ q: 'I do not know anyone to refer.', truth: 'Referrals are optional. Members can view ads, earn credits and advertise with no line at all. When they do refer, every direct buyer pays them 50 percent from the very first package, and their line banner is shown to their next three levels during welcome tours.', say: 'You do not have to refer anyone. Join free, earn credits by viewing, run ads. If one person ever joins through you and buys a $5 package, half of it hits your wallet in that transaction. That is the whole referral side, and it is optional. {{LINK:free}}' },
{ q: 'How much can I make?', truth: 'No income is guaranteed or implied. Sponsor commissions are 50 / 20 / 10 across three levels on packages that actually sell; level 2 needs 2 qualifying buyers ($20 or more) and level 3 needs 5. Results depend on the member\'s effort, and crypto carries risk of loss. Never quote a figure.', say: 'Nobody can tell you that, and I would not trust anyone who did. What I can tell you is exactly how it is split: 50 percent to the direct sponsor, 20 and 10 to the next two levels, paid the moment a package sells, all public. What that adds up to depends entirely on what you build. {{LINK:instant}}' }
];
function fillLink(text, link) {
return text.replace(/\{\{LINK(?::([a-z]+))?\}\}/g, (m, a) => angleLink(link, a));
}
function copyBtn(text, label) {
const b = document.createElement('button');
b.className = 'btn small sec'; b.type = 'button'; b.textContent = label || 'Copy';
b.addEventListener('click', async () => {
try { await navigator.clipboard.writeText(text); status('Copied. Paste it anywhere.', 'ok'); }
catch (e) { status('Copy failed. Select the text instead.', 'bad'); }
});
return b;
}
function block(text, head, extraBtns) {
const div = document.createElement('div');
div.className = 'promo-block';
if (head) { const h = document.createElement('div'); h.className = 'pb-head'; h.innerHTML = head; div.appendChild(h); }
const pre = document.createElement('div'); pre.className = 'pb-text'; pre.textContent = text; div.appendChild(pre);
const row = document.createElement('div'); row.className = 'pb-actions';
row.appendChild(copyBtn(text));
(extraBtns || []).forEach(b => row.appendChild(b));
div.appendChild(row);
return div;
}
function linkBtn(href, label, cls) {
const a = document.createElement('a');
a.className = 'btn small ' + (cls || 'sec'); a.href = href; a.textContent = label;
if (!/^sms:/.test(href)) { a.target = '_blank'; a.rel = 'noopener'; }
return a;
}
// hook videos: one per angle; the matched link continues the same hook on the join page
const VID_BASE = 'https://coolify-saasytop.nyc3.digitaloceanspaces.com/promo/';
const VIDEOS = [
{ angle: 'instant', title: 'Paid before the page reloads', hook: 'What if your commission landed before the thank-you page finished loading?', caption: 'What if your commission landed before the thank-you page finished loading? On LinkSpin a smart contract splits every ad package the second it sells, straight to wallets, on a public ledger. Free to join by email: {{LINK:instant}}' },
{ angle: 'adspend', title: 'You were buying traffic anyway', hook: 'Every ad dollar you have ever spent went one direction. Out.', caption: 'Every ad dollar you have ever spent went one direction. Out. Here the ad spend in your line pays you back in the same transaction. Seven formats, packages from $5, free to join: {{LINK:adspend}}' },
{ angle: 'free', title: 'Watch first, spend never', hook: 'You can run your first ad campaign here for exactly zero dollars.', caption: 'Run your first ad campaign for exactly zero dollars. Join free by email, earn credits by viewing ads, launch a real campaign, and watch real payouts land on a public ledger before you spend a cent: {{LINK:free}}' },
{ angle: 'ledger', title: 'No back office. No payday.', hook: 'Your last affiliate program paid you on the fifteenth. If it paid you.', caption: 'Your last affiliate program paid you on the fifteenth. If it paid you. Here the payroll is the blockchain: every payout is a public transaction, nothing is held, nobody can change the split. Open the ledger, then join free: {{LINK:ledger}}' },
{ angle: 'two', title: 'Two buyers open level two', hook: 'Two buyers. Then five. That is the whole ladder.', caption: 'Two buyers. Then five. That is the whole ladder. Level 1 pays from your first buyer, two qualifying buyers open level 2, five open level 3, all in the same transaction, straight to your wallet. Free to join: {{LINK:two}}' }
];
// the six front doors: the plain invite link plus one page per hook angle
const ANGLES = [
{ angle: '', name: 'General', hook: 'The whole picture', use: 'What LinkSpin is, the payment split, join free. Use it when you have not said anything specific yet.' },
{ angle: 'instant', name: 'Instant', hook: 'Paid before the page reloads', use: 'For anyone burned by pending commissions. The page opens on the on-chain payout that lands in seconds.' },
{ angle: 'adspend', name: 'Ad spend', hook: 'You buy ads anyway', use: 'For marketers who already pay for traffic. Frames it as advertising that also pays your line when they buy.' },
{ angle: 'free', name: 'Free', hook: 'Costs nothing to try', use: 'For skeptics and beginners. Join free, earn credits by viewing, run a first campaign without spending.' },
{ angle: 'ledger', name: 'Ledger', hook: 'No back office, no payday', use: 'For people who have waited on a payout. Every payment is public on Polygonscan and nothing is ever held.' },
{ angle: 'two', name: 'Two', hook: 'Two buyers open level two', use: 'For team builders. The 2-then-5 qualification ladder and how a line stacks under you.' }
];
function fill(link, me) {
const token = (me && (me.username || me.refCode || me.memberId)) || '';
// invite strip
if ($('promoLink')) $('promoLink').textContent = link;
if ($('promoLinkCopy')) $('promoLinkCopy').onclick = async () => {
try { await navigator.clipboard.writeText(link); status('Link copied.', 'ok'); } catch (e) { status('Copy failed.', 'bad'); }
};
// angle links: one row per front door, copy + share
const al = $('promoAngles');
if (al && al.dataset.filled !== link) {
al.dataset.filled = link; al.innerHTML = '';
for (const a of ANGLES) {
const url = angleLink(link, a.angle);
const hasVideo = VIDEOS.some(v => v.angle === a.angle);
// one collapsed band per angle, like the banner kit: open the one you want
const row = document.createElement('details');
row.className = 'angle-row pb-acc';
row.innerHTML = '<summary><span><span class="angle-name">' + esc(a.name) + '</span> <span class="angle-hook">' + esc(a.hook) + '</span></span>'
+ (hasVideo ? '<span class="angle-tag">video</span>' : '') + '</summary>'
+ '<div class="angle-body"><div class="angle-txt">'
+ '<div class="muted small">' + esc(a.use) + '</div>'
+ '<a class="mono small angle-url" href="' + esc(url) + '" target="_blank" rel="noopener">' + esc(url) + '</a></div>'
+ '<div class="angle-btns"><a class="btn small sec" href="' + esc(url) + '" target="_blank" rel="noopener">Open</a><button class="btn small" type="button">Copy</button>'
+ (navigator.share ? '<button class="btn small sec" type="button">Share</button>' : '') + '</div></div>'; // Share = the phone's share sheet; desktops have none, so no button
const [copyBtn, shareBtn] = row.querySelectorAll('button');
copyBtn.onclick = async () => { try { await navigator.clipboard.writeText(url); status((a.name === 'General' ? 'Invite' : a.name) + ' link copied.', 'ok'); } catch (e) { status('Copy failed.', 'bad'); } };
if (shareBtn) shareBtn.onclick = async () => { try { await navigator.share({ title: 'LinkSpin', text: a.hook, url }); } catch (e) { if (!(e && e.name === 'AbortError')) status('Could not open the share sheet.', 'bad'); } };
al.appendChild(row);
}
}
// posts
const posts = $('promoPosts');
if (posts && posts.dataset.filled !== link) {
posts.dataset.filled = link; posts.innerHTML = '';
for (const p of POSTS) {
const text = fillLink(p.text, link);
const extras = [];
if (p.net === 'X') extras.push(linkBtn('https://twitter.com/intent/tweet?text=' + encodeURIComponent(text), 'Post on X'));
if (p.net === 'Facebook') extras.push(linkBtn('https://www.facebook.com/sharer/sharer.php?u=' + encodeURIComponent(angleLink(link, (/\{\{LINK:([a-z]+)\}\}/.exec(p.text) || [])[1] || '')), 'Share on Facebook'));
if (p.net === 'LinkedIn') extras.push(linkBtn('https://www.linkedin.com/sharing/share-offsite/?url=' + encodeURIComponent(angleLink(link, 'adspend')), 'Share on LinkedIn'));
if (p.net === 'Telegram') extras.push(linkBtn('https://t.me/share/url?url=' + encodeURIComponent(angleLink(link, (/\{\{LINK:([a-z]+)\}\}/.exec(p.text) || [])[1] || '')) + '&text=' + encodeURIComponent(text.replace(/\s*\{\{LINK[^}]*\}\}\s*$/, '').replace(/https?:\/\/\S+$/, '').trim()), 'Share on Telegram'));
posts.appendChild(block(text, '<span class="pb-net">' + esc(p.label) + '</span>', extras));
}
}
// text a friend
const texts = $('promoTexts');
if (texts && texts.dataset.filled !== link) {
texts.dataset.filled = link; texts.innerHTML = '';
TEXTS.forEach((t, i) => {
const text = fillLink(t.text, link);
const enc = encodeURIComponent(text);
const tgUrl = angleLink(link, t.angle);
const tgLead = text.replace(/https?:\/\/\S+/, '').trim();
const extras = [
linkBtn('sms:?&body=' + enc, 'Text it', ''),
linkBtn('https://wa.me/?text=' + enc, 'WhatsApp'),
linkBtn('https://t.me/share/url?url=' + encodeURIComponent(tgUrl) + '&text=' + encodeURIComponent(tgLead), 'Telegram')
];
texts.appendChild(block(text, '<span class="pb-net">Text ' + (i + 1) + (t.angle ? ' · ' + esc(t.angle) + ' angle' : ' · general') + '</span> <b>' + esc(t.title) + '</b>', extras));
});
}
// swipes
const sw = $('promoSwipeWrap');
if (sw && sw.dataset.filled !== link) {
sw.dataset.filled = link; sw.innerHTML = '';
for (const s of SWIPES) {
const text = 'Subject: ' + s.subject + '\n\n' + fillLink(s.body, link);
sw.appendChild(block(text, '<span class="pb-net">' + esc(s.tier) + '</span> <b>' + esc(s.subject) + '</b>'));
}
}
// hook videos
const vw = $('promoVideos');
if (vw && vw.dataset.filled !== link) {
vw.dataset.filled = link; vw.innerHTML = '';
for (const v of VIDEOS) {
const mlink = angleLink(link, v.angle);
const card = document.createElement('div'); card.className = 'pv';
card.innerHTML = '<div class="pv-head"><span class="pb-net">' + esc(v.angle) + ' angle</span> <b>' + esc(v.title) + '</b><div class="muted small">' + esc(v.hook) + '</div></div>'
+ '<video src="' + VID_BASE + v.angle + '.mp4" poster="' + VID_BASE + v.angle + '.jpg" preload="none" controls playsinline style="width:100%;max-width:640px;border-radius:12px;background:#000;margin:10px 0"></video>'
+ '<p class="small"><span class="muted">Matched link:</span> <span class="mono" style="overflow-wrap:anywhere">' + esc(mlink) + '</span></p>';
const row = document.createElement('p'); row.className = 'pb-actions';
row.appendChild(copyBtn(mlink, 'Copy matched link'));
row.appendChild(linkBtn(VID_BASE + v.angle + '.mp4', 'Download 16:9'));
row.appendChild(linkBtn(VID_BASE + v.angle + '-portrait.mp4', 'Download 9:16'));
card.appendChild(row);
const cap = fillLink(v.caption, link);
const extras = [
linkBtn('https://twitter.com/intent/tweet?text=' + encodeURIComponent(cap), 'Post on X'),
linkBtn('https://www.facebook.com/sharer/sharer.php?u=' + encodeURIComponent(mlink), 'Share on Facebook'),
linkBtn('https://t.me/share/url?url=' + encodeURIComponent(mlink) + '&text=' + encodeURIComponent(cap.replace(/https?:\/\/\S+$/, '').trim()), 'Telegram'),
linkBtn('https://wa.me/?text=' + encodeURIComponent(cap), 'WhatsApp')
];
card.appendChild(block(cap, '<span class="lab">Caption</span>', extras));
vw.appendChild(card);
}
}
// objections
const ob = $('promoObjections');
if (ob && ob.dataset.filled !== link) {
ob.dataset.filled = link; ob.innerHTML = '';
for (const o of OBJECTIONS) {
const d = document.createElement('details'); d.className = 'obj';
const s = document.createElement('summary'); s.textContent = o.q; d.appendChild(s);
const body = document.createElement('div'); body.className = 'obj-body';
const t = document.createElement('div'); t.className = 'obj-truth';
t.innerHTML = '<span class="lab">The truth</span><p>' + esc(o.truth) + '</p>'; body.appendChild(t);
const say = fillLink(o.say, link);
const sayEl = block(say, '<span class="lab lab-say">Say this</span>');
sayEl.classList.add('obj-say'); body.appendChild(sayEl);
d.appendChild(body); ob.appendChild(d);
}
}
}
return { fill, POSTS, TEXTS, SWIPES, OBJECTIONS, VIDEOS };
})();
+75
View File
@@ -0,0 +1,75 @@
// Paid shorts: a full-screen vertical feed over the video-ad inventory. You must
// watch each short's required time (server-clock enforced, no seek) to earn,
// then advance to the next. Reuses /api/my/videos + /api/my/videowatch.
(function () {
const $ = id => document.getElementById(id);
const st = { token: null, secs: 0, maxSeen: 0, credited: false, done: false, skips: 0 };
function j(url, body) {
return fetch(url, body
? { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }
: undefined).then(r => r.json());
}
async function load() {
st.token = null; st.maxSeen = 0; st.credited = false; st.done = false;
$('shOver').hidden = true; $('shCta').hidden = true; $('shNext').hidden = true;
let r = null;
// signed-out visitors get the sign-in note without a 401 round trip
let me = null; try { me = await j('/api/me'); } catch (e) {}
if (me && me.signedIn && me.email) { try { r = await j('/api/my/videos?orientation=portrait'); } catch (e) {} }
if (!r || r.error) { $('shVideo').hidden = true; $('shMsg').hidden = false; $('shMsg').textContent = 'Sign in on the dashboard to watch shorts and earn.'; return; }
$('shStat').textContent = 'today: ' + (r.status.count || 0) + ' / ' + r.status.cap;
if (!r.ad) {
$('shVideo').hidden = true; $('shMsg').hidden = false;
$('shMsg').textContent = r.status.left <= 0 ? 'That\'s today\'s shorts. Come back tomorrow.' : 'No shorts in rotation right now. Check back soon.';
return;
}
st.token = r.token; st.secs = r.ad.watchSecs; st.adId = r.ad.id;
const v = $('shVideo');
$('shMsg').hidden = true; v.hidden = false;
v.src = r.ad.videoUrl; v.currentTime = 0;
// safety net: this reel is portrait-only. If a landscape video slips through, skip to the next.
v.onloadedmetadata = () => {
if (v.videoWidth && v.videoHeight && v.videoWidth > v.videoHeight) {
if (st.skips++ < 4) { load(); return; }
v.hidden = true; $('shMsg').hidden = false; $('shMsg').textContent = 'No shorts in rotation right now. Check back soon.';
} else { st.skips = 0; }
};
$('shTitle').textContent = r.ad.title || '';
$('shCta').href = r.ad.ctaUrl; $('shCta').textContent = r.ad.ctaLabel || 'Learn more';
$('shOver').hidden = false; $('shCta').hidden = false;
v.onseeking = () => { if (v.currentTime > st.maxSeen + 0.5) v.currentTime = st.maxSeen; };
v.ontimeupdate = () => {
if (v.currentTime > st.maxSeen) st.maxSeen = v.currentTime;
const left = Math.max(0, Math.ceil(st.secs - st.maxSeen));
$('shTimer').textContent = left > 0 ? 'Watch ' + left + 's more to earn' : 'Earned — swipe to the next';
if (!st.done && st.maxSeen >= st.secs) { st.done = true; credit(); }
};
v.play().catch(() => {});
}
async function credit() {
if (st.credited) return; st.credited = true;
try {
const r = await j('/api/my/videowatch', { token: st.token });
if (r.credited) { $('shStat').textContent = 'today: ' + (r.status.count || 0) + ' / ' + (r.status.cap || 0) + ' · +' + r.credited; }
} catch (e) {}
$('shNext').hidden = false;
}
$('shNext').addEventListener('click', load);
$('shReport').addEventListener('click', e => {
e.preventDefault(); if (!st.adId) return;
const reason = (prompt('Report this short: broken, inappropriate, spam, scam, or other', 'inappropriate') || '').trim().toLowerCase();
if (!reason) return;
fetch('/api/report-ad', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ campaignId: st.adId, reason }) })
.then(() => { $('shReport').textContent = '✓ reported — thanks'; }).catch(() => {});
});
// presence enforcement: pause when the tab/window loses focus, resume on return
document.addEventListener('visibilitychange', () => {
const v = $('shVideo'); if (!v || !v.src) return;
if (document.hidden) v.pause(); else if (!st.done) v.play().catch(() => {});
});
window.addEventListener('blur', () => { const v = $('shVideo'); if (v && v.src) v.pause(); });
window.addEventListener('focus', () => { const v = $('shVideo'); if (v && v.src && !st.done) v.play().catch(() => {}); });
// tap the video to pause/resume
$('shVideo').addEventListener('click', () => { const v = $('shVideo'); if (v.paused) v.play(); else v.pause(); });
load();
})();
+750
View File
@@ -0,0 +1,750 @@
/* LinkSpin visual system v4 — built to Marty's picked reference
(Behance 243562211 "ChainLock"): near-black ground, ONE neon-mint accent,
sweeping light-trail hero, glowing icon plates, mockup-anchored sections,
ghost wordmark footer. Discipline over decoration. */
:root{
--ground:#040807; --ground2:#071009;
--panel:rgba(16,28,24,.55); --panel-solid:#0b1512;
--line:rgba(84,150,128,.16); --line-strong:rgba(84,150,128,.36);
--ink:#eef7f3; --muted:#8ba69c;
--mint:#43e8c3; --mint-hi:#8ffbe3; --mint-ink:#03211a;
--mint-soft:rgba(67,232,195,.09); --bad:#ff8f7d;
--cyan:#54ccff; --violet:#9d7dff; --amber:#ffb238;
--mono:"Consolas","JetBrains Mono",monospace;
--disp:"Sora","Segoe UI",system-ui,sans-serif;
--radius:18px;
}
*{box-sizing:border-box}
[hidden]{display:none!important} /* beats any display: set by a class (chat panel bug) */
html{scroll-behavior:smooth}
body{margin:0;background:var(--ground);color:var(--ink);font:16px/1.65 "Segoe UI",system-ui,sans-serif;overflow-x:hidden}
/* topo-contour ground texture, very faint */
body::before{content:"";position:fixed;inset:0;z-index:-1;pointer-events:none;opacity:.5;
background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='560' height='560' viewBox='0 0 560 560'%3E%3Cg fill='none' stroke='%2343e8c3' stroke-opacity='.05'%3E%3Cpath d='M60 280c40-90 160-130 220-90s60 150 160 150 120-90 120-90'/%3E%3Cpath d='M40 340c60-110 190-160 260-110s60 170 180 170'/%3E%3Cpath d='M20 400c80-130 220-190 300-130s60 190 200 190'/%3E%3Cpath d='M80 220c30-70 130-100 180-70s50 120 130 120 100-70 100-70'/%3E%3C/g%3E%3C/svg%3E")}
a{color:var(--mint);text-decoration:none}
a:hover{text-decoration:underline}
.wrap{max-width:1100px;margin:0 auto;padding:0 22px}
h1,h2,h3{font-family:var(--disp);letter-spacing:-.015em}
/* ── nav: capsule pill ─────────────────────────────── */
nav{position:sticky;top:0;z-index:10;background:rgba(4,8,7,.7);backdrop-filter:blur(16px);
-webkit-backdrop-filter:blur(16px);border-bottom:1px solid var(--line)}
nav .wrap{display:flex;align-items:center;gap:20px;min-height:66px;flex-wrap:wrap;padding-top:8px;padding-bottom:8px}
.logo{font-family:var(--disp);font-weight:800;font-size:20px;color:var(--ink)}
.logo b{color:var(--mint)}
.logo-wrap{display:flex;flex-direction:column;gap:3px;min-width:0}
.byline{display:block;font-size:10.5px;line-height:1.2;letter-spacing:.05em;color:var(--muted);white-space:nowrap}
.byline b{color:var(--ink);font-weight:600}
.bo-side .byline{padding:4px 8px 10px;white-space:normal}
@media (max-width:560px){nav .byline{font-size:9.5px}}
nav .links{display:flex;gap:4px;flex:1;flex-wrap:wrap;background:rgba(16,28,24,.7);border:1px solid var(--line);
border-radius:999px;padding:5px 8px;width:fit-content;flex:0 1 auto;margin:0 auto}
nav .links a{color:var(--muted);font-size:13.5px;font-weight:600;padding:7px 14px;border-radius:999px}
nav .links a:hover{color:var(--ink);text-decoration:none}
nav .links a.active{color:var(--mint);background:rgba(67,232,195,.1)}
#navWallet{font-size:13px;margin-left:auto}
.rehearsal{background:rgba(67,232,195,.07);border-bottom:1px solid var(--line);color:var(--muted);
text-align:center;font-size:12.5px;padding:7px 12px}
.rehearsal b{color:var(--mint)}
/* ── buttons ───────────────────────────────────────── */
.btn{display:inline-block;background:var(--mint);color:var(--mint-ink);border:0;border-radius:999px;
padding:13px 26px;font-weight:700;font-size:15px;cursor:pointer;font-family:var(--disp);
box-shadow:0 4px 30px rgba(67,232,195,.35);transition:transform .16s ease,box-shadow .16s ease,background .16s ease}
.btn:hover{transform:translateY(-2px);background:var(--mint-hi);box-shadow:0 8px 44px rgba(67,232,195,.5);text-decoration:none}
.btn.sec{background:transparent;color:var(--mint);border:1px solid rgba(67,232,195,.6);box-shadow:none}
.btn.sec:hover{background:rgba(67,232,195,.08);box-shadow:0 4px 26px rgba(67,232,195,.2)}
.btn.small{padding:8px 17px;font-size:13px}
.btn:disabled{opacity:.45;cursor:default;transform:none;box-shadow:none}
/* ── hero: centered, light-trail set-piece ─────────── */
.hero{position:relative;text-align:center;padding:120px 0 84px;overflow:visible}
.hero h1{font-size:clamp(36px,5.4vw,62px);font-weight:700;line-height:1.08;margin:0 auto 18px;max-width:820px;text-wrap:balance}
.hero h1 em{font-style:normal;color:var(--mint)}
.hero p.lead{color:var(--muted);font-size:17.5px;max-width:600px;margin:0 auto 32px}
.hero p.lead b{color:var(--ink)}
.hero .ctas{display:flex;gap:14px;justify-content:center;flex-wrap:wrap}
/* neon arcs behind the hero text */
.arcs{position:absolute;inset:-40px 0 0 0;z-index:-1;pointer-events:none}
.arcs svg{width:100%;height:100%;overflow:visible}
.arc{fill:none;stroke:url(#arcGrad);stroke-width:5;stroke-linecap:round;filter:url(#arcGlow)}
.arc2{fill:none;stroke:url(#arcGrad);stroke-width:3;stroke-linecap:round;filter:url(#arcGlow);opacity:.8}
/* falling glow streaks */
.streak{position:absolute;top:-140px;width:1.5px;height:110px;pointer-events:none;
background:linear-gradient(180deg,transparent,rgba(143,251,227,.8));border-radius:2px;opacity:0}
@media(prefers-reduced-motion:no-preference){
.streak{animation:fall 7s linear infinite}
@keyframes fall{0%{transform:translateY(0);opacity:0}12%{opacity:.7}55%{opacity:.5}75%{transform:translateY(72vh);opacity:0}100%{transform:translateY(72vh);opacity:0}}
}
/* ── counters ──────────────────────────────────────── */
.stats{display:grid;grid-template-columns:repeat(2,1fr);gap:14px;margin:60px auto 0;max-width:900px}
@media(min-width:760px){.stats{grid-template-columns:repeat(4,1fr)}}
.stat{background:var(--panel);border:1px solid var(--line);border-radius:var(--radius);padding:18px 12px;text-align:center}
.stat .n{font-family:var(--disp);font-size:30px;font-weight:700;font-variant-numeric:tabular-nums;color:var(--mint)}
.stat .l{font-size:11.5px;color:var(--muted);text-transform:uppercase;letter-spacing:.1em;margin-top:3px}
/* ── sections ──────────────────────────────────────── */
section{padding:64px 0 8px}
.sectionhead{text-align:center;max-width:640px;margin:0 auto 40px}
.cmp{width:100%;border-collapse:separate;border-spacing:0;font-size:15px;background:var(--panel);border:1px solid var(--line);border-radius:var(--radius);overflow:hidden}
.cmp th,.cmp td{padding:13px 16px;border-bottom:1px solid var(--line);vertical-align:top;text-align:left}
.cmp th{font-family:var(--disp);font-size:13px;letter-spacing:.06em;text-transform:uppercase;color:var(--muted)}
.cmp th:last-child,.cmp td:last-child{color:var(--ink);background:rgba(67,232,195,.06)}
.cmp td:first-child{font-weight:700;white-space:nowrap}
.cmp td:nth-child(2){color:var(--muted)}
.cmp tr:last-child td{border-bottom:0}
@media (max-width:640px){.cmp td:first-child{white-space:normal}.cmp th,.cmp td{padding:10px 10px;font-size:14px}}
h2{font-size:clamp(26px,3.2vw,36px);font-weight:700;margin:0 0 12px;text-wrap:balance}
.sectionhead p{color:var(--muted);font-size:15.5px;margin:0}
h3{font-size:16.5px;margin:0 0 8px;font-weight:700}
.muted{color:var(--muted)}
.small{font-size:13.5px}
.mono{font-family:var(--mono)}
/* icon-plate feature cards */
.plates{display:grid;gap:16px;grid-template-columns:1fr}
@media(min-width:760px){.plates{grid-template-columns:1fr 1fr 1fr}}
.platecard{background:linear-gradient(170deg,rgba(22,38,32,.6),rgba(10,18,15,.7));border:1px solid var(--line);
border-radius:var(--radius);padding:26px 22px;text-align:center;transition:border-color .2s ease,transform .2s ease}
.platecard:hover{border-color:var(--line-strong);transform:translateY(-4px)}
.plate{width:88px;height:88px;margin:0 auto 18px;border-radius:22px;display:grid;place-items:center;
background:linear-gradient(160deg,#12241e,#0a1411);border:1px solid var(--line-strong);
box-shadow:0 0 34px rgba(67,232,195,.15),inset 0 1px 0 rgba(143,251,227,.15)}
.plate svg{width:38px;height:38px;stroke:var(--mint);fill:none;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round}
.platecard p{color:var(--muted);font-size:14px;margin:0}
/* two-col flank layout (icons around a central mockup) */
.flank{display:grid;gap:26px;align-items:center;grid-template-columns:1fr}
@media(min-width:920px){.flank{grid-template-columns:1fr 1.15fr 1fr}}
.flankitem{margin:0 0 26px}
.flankitem .plate{width:52px;height:52px;border-radius:14px;margin:0 0 12px}
.flankitem .plate svg{width:24px;height:24px}
.flankitem p{color:var(--muted);font-size:13.5px;margin:0}
/* ledger mockup frame */
.mockup{background:linear-gradient(170deg,#0e1c17,#080f0c);border:1px solid var(--line-strong);border-radius:20px;
box-shadow:0 30px 80px rgba(0,0,0,.6),0 0 60px rgba(67,232,195,.08);overflow:hidden}
.mockup .bar{display:flex;gap:6px;align-items:center;padding:11px 14px;border-bottom:1px solid var(--line)}
.mockup .bar i{width:9px;height:9px;border-radius:50%;background:#1e3a31;display:inline-block}
.mockup .bar .addr{font-family:var(--mono);font-size:11px;color:var(--muted);margin-left:8px;background:rgba(4,8,7,.6);
border-radius:999px;padding:3px 12px}
.mockup .body{padding:6px 0}
.mockup .mrow{display:flex;justify-content:space-between;gap:10px;padding:9px 16px;font-family:var(--mono);
font-size:12px;border-bottom:1px solid rgba(84,150,128,.08)}
.mockup .mrow span:last-child{color:var(--mint)}
.mockup .mrow.dim span:last-child{color:var(--muted)}
/* 2-col story block: iso illustration + checklist */
.story{display:grid;gap:36px;align-items:center;grid-template-columns:1fr}
@media(min-width:880px){.story{grid-template-columns:1fr 1fr}}
.checks{list-style:none;padding:0;margin:14px 0 0}
.checks li{padding:7px 0 7px 30px;position:relative;color:var(--muted);font-size:14.5px}
.checks li::before{content:"✓";position:absolute;left:0;top:6px;width:20px;height:20px;border-radius:50%;
background:var(--mint-soft);color:var(--mint);font-size:12px;font-weight:700;display:grid;place-items:center;
border:1px solid rgba(67,232,195,.35)}
.chips{display:flex;gap:8px;margin:14px 0 4px;flex-wrap:wrap}
.chip-t{border:1px solid var(--line-strong);border-radius:999px;padding:5px 16px;font-size:12.5px;color:var(--muted);font-family:var(--disp);font-weight:600}
.chip-t.on{background:var(--mint);color:var(--mint-ink);border-color:var(--mint)}
.iso{filter:drop-shadow(0 24px 50px rgba(0,0,0,.5))}
/* level cycler: light the paying generation */
#genViz .edge path{stroke:#22523f;transition:stroke .5s ease}
#genViz .node-o{fill:#122e25;stroke:#2f6b55;stroke-width:1.3;transition:stroke .5s ease,fill .5s ease}
#genViz .node-d{fill:#3f8f74;transition:fill .5s ease}
#genViz[data-lvl="1"] .e1 path,#genViz[data-lvl="2"] .e2 path,#genViz[data-lvl="3"] .e3 path{stroke:#43e8c3}
#genViz[data-lvl="1"] .g1 .node-o,#genViz[data-lvl="2"] .g2 .node-o,#genViz[data-lvl="3"] .g3 .node-o{stroke:#43e8c3;fill:#11362b}
#genViz[data-lvl="1"] .g1 .node-d,#genViz[data-lvl="2"] .g2 .node-d,#genViz[data-lvl="3"] .g3 .node-d{fill:#43e8c3}
.chips button{cursor:pointer;background:transparent;font:inherit}
.chips button.chip-t{border:1px solid var(--line-strong);color:var(--muted)}
.chips button.chip-t.on{background:var(--mint);color:var(--mint-ink);border-color:var(--mint)}
/* pricing tiles: mint discipline */
.tiles{display:grid;gap:16px;grid-template-columns:repeat(auto-fit,minmax(185px,1fr));align-items:stretch}
.tile{position:relative;background:linear-gradient(170deg,rgba(22,38,32,.55),rgba(10,18,15,.65));border:1px solid var(--line);
border-radius:var(--radius);padding:26px 18px;text-align:center;display:flex;flex-direction:column;gap:6px;
transition:transform .2s ease,border-color .2s ease}
.tile:hover{transform:translateY(-5px);border-color:var(--line-strong)}
.tile .name{font-family:var(--disp);font-weight:700;font-size:13px;color:var(--muted);text-transform:uppercase;letter-spacing:.12em}
.tile .price{font-family:var(--disp);font-size:36px;font-weight:700}
.tile .cr{color:var(--mint);font-weight:700;font-size:14.5px}
.tile .bonus{font-size:12px;color:var(--mint-hi)}
.tile .pol{font-family:var(--mono);font-size:12px;color:var(--muted);flex:1}
.tile.hot{border:1px solid rgba(67,232,195,.65);box-shadow:0 0 44px rgba(67,232,195,.14)}
.tile.hot::before{content:"MOST POPULAR";position:absolute;top:-11px;left:50%;transform:translateX(-50%);
background:var(--mint);color:var(--mint-ink);font-family:var(--disp);font-size:10px;font-weight:800;
letter-spacing:.12em;border-radius:999px;padding:4px 14px}
/* split strip: mint opacities */
.split{display:flex;gap:8px;margin:18px 0}
.split div{border-radius:12px;padding:16px 6px;text-align:center;font-size:13px;font-weight:700;font-family:var(--disp);
border:1px solid var(--line-strong);color:var(--mint)}
.split .s50{flex:5;background:rgba(67,232,195,.16);box-shadow:0 0 30px rgba(67,232,195,.12)}
.split .s20{flex:2;background:rgba(67,232,195,.09)}
.split .s10{flex:1;background:rgba(67,232,195,.05)}
.split .sa{flex:2;background:rgba(139,166,156,.08);color:var(--muted)}
section[id]{scroll-margin-top:84px} /* anchored menu targets clear the sticky nav */
/* ticker */
.ticker{overflow:hidden;contain:layout paint;border-top:1px solid var(--line);border-bottom:1px solid var(--line);
background:rgba(67,232,195,.04);white-space:nowrap;padding:10px 0;
-webkit-mask-image:linear-gradient(90deg,transparent,#000 6%,#000 94%,transparent);
mask-image:linear-gradient(90deg,transparent,#000 6%,#000 94%,transparent)}
.ticker .inner{display:inline-block;font-family:var(--mono);font-size:12.5px;padding-left:100%;animation:tick 120s linear infinite}
.ticker .inner span{margin-right:48px;color:var(--muted)}
.ticker .inner span b{color:var(--mint);font-weight:600}
@keyframes tick{to{transform:translateX(-100%)}}
@media(prefers-reduced-motion:reduce){.ticker .inner{animation:none;padding-left:0}}
/* cards / tables / feed (inner pages) */
.card{background:var(--panel);border:1px solid var(--line);border-radius:var(--radius);padding:24px;margin:0 0 16px}
.card:hover{border-color:var(--line-strong)}
.grid{display:grid;gap:16px}
.grid>*,.flank>*,.story>*{min-width:0} /* let grid children shrink so inner tablewraps scroll instead of widening the page */
@media(min-width:760px){.grid.c3{grid-template-columns:1fr 1fr 1fr}.grid.c2{grid-template-columns:1fr 1fr}}
table{width:100%;border-collapse:collapse;font-size:15px}
th,td{text-align:left;padding:12px 14px;border-bottom:1px solid var(--line);vertical-align:middle}
th{color:var(--muted);font-size:11.5px;text-transform:uppercase;letter-spacing:.1em;font-family:var(--disp)}
td.num,th.num{font-variant-numeric:tabular-nums}
tr:last-child td{border-bottom:0}
.tablewrap{overflow-x:auto}
.feed{font-family:var(--mono);font-size:13.5px;line-height:1.7}
.feed .row{padding:9px 14px;border-bottom:1px solid var(--line);display:flex;gap:10px;align-items:baseline;flex-wrap:wrap}
.feed .row:first-child{background:var(--mint-soft)}
.feed .row .when{font-family:var(--mono);font-size:11px;color:var(--muted);white-space:nowrap;min-width:92px}
.feed .t-Purchase,.feed .t-AwardPaid{color:var(--mint-hi)}
.feed .t-TierPaid{color:var(--mint)}
.feed .t-AdminPaid{color:var(--muted)}
.feed .tx a{color:var(--muted);font-size:12px}
.badge{display:inline-block;background:var(--mint-soft);color:var(--mint);border:1px solid rgba(67,232,195,.35);
border-radius:999px;padding:3px 13px;font-size:12px;font-weight:700}
.spend-banner{background:linear-gradient(160deg,rgba(242,201,76,.14),rgba(242,201,76,.04));border-color:rgba(242,201,76,.55);text-align:center;padding:22px 20px}
.spend-banner .lbl{font-family:var(--disp);font-size:13px;letter-spacing:.14em;text-transform:uppercase;color:#f2c94c}
.spend-banner .big{font-family:var(--disp);font-size:clamp(40px,6vw,64px);font-weight:800;color:#f2c94c;line-height:1.05;margin:6px 0 4px;font-variant-numeric:tabular-nums}
.spend-banner .big .unit{font-size:20px;font-weight:700;color:var(--ink)}
.spend-banner .sub{color:var(--ink);font-size:15px}
.spend-banner .rates{display:flex;gap:8px 18px;flex-wrap:wrap;justify-content:center;margin-top:12px;font-size:12.5px;color:var(--muted)}
.spend-banner .rates b{color:var(--ink)}
.boot-spin{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:14px;min-height:60vh}
.boot-spin .ring{width:46px;height:46px;border-radius:50%;border:4px solid rgba(67,232,195,.18);border-top-color:var(--mint);animation:bootspin .9s linear infinite}
@keyframes bootspin{to{transform:rotate(360deg)}}
.bo-top{flex-wrap:wrap}
.ad-strip{flex:1 1 100%;font-size:13.5px;padding:8px 0 2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.ad-strip a{color:var(--ink)}
.ad-strip b{color:var(--mint)}
.ad-foot{text-align:center}
.pv{border:1px solid var(--line);border-radius:14px;padding:16px 18px;margin:0 0 16px;background:rgba(4,8,7,.35)}
.pv-head b{display:block;font-size:18px;margin:4px 0 2px}
.done-big{display:flex;flex-direction:column;align-items:center;gap:6px;padding:26px 16px;text-align:center}
.done-big .tick{width:84px;height:84px;border-radius:50%;display:grid;place-items:center;font-size:48px;font-weight:800;color:var(--mint-ink);background:var(--mint);box-shadow:0 0 0 10px rgba(67,232,195,.15),0 10px 30px rgba(67,232,195,.35)}
.done-big b{font-family:var(--disp);font-size:22px;margin-top:8px}
.badge.amber{background:rgba(139,166,156,.1);border-color:rgba(139,166,156,.4);color:var(--muted)}
/* faq */
details{border:1px solid var(--line);border-radius:14px;margin:0 0 10px;background:var(--panel)}
details[open]{border-color:rgba(67,232,195,.45)}
summary{cursor:pointer;padding:16px 20px;font-weight:700;font-family:var(--disp);font-size:15px;list-style:none}
summary::before{content:"+";color:var(--mint);font-weight:800;margin-right:12px}
details[open] summary::before{content:"−"}
details p{margin:0;padding:0 20px 18px 42px;color:var(--muted);font-size:14.5px}
/* closing cta card */
.closer{position:relative;display:grid;gap:26px;align-items:center;grid-template-columns:1fr;
padding:44px 36px;border-radius:24px;overflow:hidden;margin:80px 0 0;
background:linear-gradient(160deg,#0d1d17,#060c0a);border:1px solid var(--line-strong)}
@media(min-width:880px){.closer{grid-template-columns:1.2fr .8fr}}
.closer::before{content:"";position:absolute;width:460px;height:460px;right:-140px;top:-260px;border-radius:50%;
background:radial-gradient(circle,rgba(67,232,195,.2),transparent 65%);pointer-events:none}
.closer h2{margin:0 0 12px}
/* footer + ghost wordmark */
footer{border-top:1px solid var(--line);margin-top:90px;padding:34px 0 0;color:var(--muted);font-size:13.5px;overflow:hidden}
.ghost{font-family:var(--disp);font-weight:800;font-size:clamp(64px,13vw,170px);line-height:.9;text-align:center;
margin:34px 0 -20px;letter-spacing:-.02em;user-select:none;
background:linear-gradient(180deg,rgba(67,232,195,.14),transparent 78%);
-webkit-background-clip:text;background-clip:text;color:transparent;white-space:nowrap}
/* misc */
#status{position:fixed;left:50%;transform:translateX(-50%);bottom:22px;background:var(--panel-solid);
border:1px solid var(--line-strong);border-radius:14px;padding:13px 22px;font-size:14px;max-width:90vw;z-index:30;
box-shadow:0 12px 40px rgba(0,0,0,.55)}
#status.ok{border-color:var(--mint)}
#status.bad{border-color:var(--bad)}
input,select,textarea{background:rgba(4,8,7,.65);border:1px solid var(--line-strong);color:var(--ink);border-radius:11px;
padding:11px 14px;font-size:14.5px;font-family:inherit}
input[type=range]{padding:0;border:0;background:transparent;accent-color:var(--mint);height:28px;vertical-align:middle}
input:focus,select:focus,textarea:focus{border-color:var(--mint)}
textarea{resize:vertical;font:inherit}
:focus-visible{outline:2px solid var(--mint);outline-offset:2px}
.hero-note{font-family:var(--mono);font-size:12px;color:var(--muted);margin-top:26px}
/* ── member back-office shell ─────────────────────────── */
.bo-body{background:var(--ground)}
.bo{display:grid;grid-template-columns:236px 1fr;min-height:100vh}
.bo-side{position:sticky;top:0;height:100vh;overflow-y:auto;scrollbar-width:none;-ms-overflow-style:none;display:flex;flex-direction:column;gap:22px;
padding:22px 16px;background:rgba(10,18,15,.92);border-right:1px solid var(--line)}
.bo-side .logo{font-size:19px;padding:0 8px}
.bo-menu{display:flex;flex-direction:column;gap:4px}
.bo-menu button{display:flex;align-items:center;gap:11px;background:transparent;border:0;color:var(--muted);
font:600 14px var(--disp);padding:11px 12px;border-radius:11px;cursor:pointer;text-align:left;
border-left:3px solid transparent}
.bo-menu button svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round;flex:0 0 auto}
.bo-menu button:hover{color:var(--ink);background:rgba(67,232,195,.05)}
.bo-menu button.on{color:var(--mint);background:rgba(67,232,195,.09);border-left-color:var(--mint)}
.bo-links{display:flex;flex-direction:column;gap:2px;padding:14px 12px;border-top:1px solid var(--line)}
.bo-cap{font-family:var(--mono);font-size:10.5px;letter-spacing:.18em;text-transform:uppercase;color:var(--muted);margin-bottom:6px}
.bo-links a{color:var(--muted);font-size:13.5px;padding:5px 0}
.bo-links a:hover{color:var(--ink);text-decoration:none}
.bo-foot{margin-top:auto;padding:14px 12px 0;border-top:1px solid var(--line);display:flex;flex-direction:column;gap:6px;overflow-wrap:anywhere}
.bo-main{min-width:0;display:flex;flex-direction:column}
.bo-side::-webkit-scrollbar{width:0;height:0;display:none}
.bo-pagefoot{margin-top:auto;padding:22px 26px;border-top:1px solid var(--line);display:flex;gap:20px;flex-wrap:wrap;justify-content:center;font-size:13px}
.bo-pagefoot a{color:var(--muted)}
.bo-pagefoot a:hover{color:var(--ink);text-decoration:none}
.bo-top{display:flex;align-items:center;gap:16px;padding:16px 26px;border-bottom:1px solid var(--line);
background:rgba(6,10,8,.75);backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);position:sticky;top:0;z-index:5}
.bo-top h2{font-size:20px}
#boBurger{display:none;background:transparent;border:1px solid var(--line-strong);color:var(--ink);
border-radius:9px;font-size:17px;padding:5px 11px;cursor:pointer}
.bo-rehearsal{margin-left:auto;color:var(--muted)}
.bo-rehearsal b{color:var(--mint)}
.bo-content{padding:26px;max-width:1060px;width:100%}
@media(max-width:959px){
.bo{grid-template-columns:1fr}
.bo-side{position:fixed;left:0;top:0;bottom:0;width:250px;z-index:20;transform:translateX(-100%);
transition:transform .22s ease;height:100dvh}
.bo.side-open .bo-side{transform:none;box-shadow:0 0 60px rgba(0,0,0,.6)}
#boBurger{display:block}
.bo-content{padding:18px}
}
/* ── promo banners ── */
.promo-banners{display:flex;flex-direction:column;gap:10px;margin-top:12px}
.pb-acc{margin:0}
.pb-acc>summary{display:flex;justify-content:space-between;align-items:center;gap:10px;padding:13px 16px;font-size:14.5px}
.pb-acc>summary::before{content:none}
.pb-acc>summary>span:first-child::before{content:"+";color:var(--mint);font-weight:800;margin-right:10px}
.pb-acc[open]>summary>span:first-child::before{content:"−"}
.pb-acc .pb-count{font-family:var(--mono);font-size:11px;color:var(--muted);white-space:nowrap}
.pb-acc .pb-grid{display:flex;flex-direction:column;gap:14px;padding:0 16px 16px}
.pb-item img{max-width:100%;border-radius:8px;border:1px solid var(--line-strong);display:block}
.pb-item .pb-row{display:flex;gap:10px;align-items:center;margin-top:6px;flex-wrap:wrap}
.pb-item .pb-size{font-family:var(--mono);font-size:11px;color:var(--muted)}
/* ── featured day-slot picker ── */
.feat-days{display:flex;gap:8px;flex-wrap:wrap}
.feat-day{flex:0 0 auto;min-width:82px;padding:8px 10px;border:1px solid var(--line-strong);border-radius:10px;
background:var(--panel);cursor:pointer;text-align:center}
.feat-day.on{border-color:var(--mint);box-shadow:0 0 0 1px var(--mint)}
.feat-day.full{opacity:.45;cursor:not-allowed}
.feat-day .fd-day{font-weight:700;font-size:12px}
.feat-day .fd-occ{font-family:var(--mono);font-size:11px;color:var(--muted)}
.feat-day.open2 .fd-occ{color:var(--mint)}
/* ── featured rotation strip ── */
.feat-top{max-width:760px;margin:16px auto;border:1px solid rgba(255,177,56,.5)}
.feat-strip{display:flex;justify-content:center;margin-top:10px}
.feat-strip a{max-width:728px;width:100%;display:flex;justify-content:space-between;gap:10px;padding:11px 14px;border-radius:10px;
background:var(--panel);border:1px solid #ffb238;color:var(--ink);text-decoration:none;font-weight:600}
.feat-strip a:hover{border-color:#ffd15c;box-shadow:0 0 0 1px rgba(255,177,56,.4)}
.feat-strip .by{font-size:12px;color:var(--muted);font-weight:500;white-space:nowrap}
.feat-strip .by{font-size:12px;color:var(--muted);font-weight:500;white-space:nowrap}
/* ── achievement badges ── */
.badges{display:flex;gap:18px;flex-wrap:wrap;margin-top:12px}
.badge-a{display:flex;flex-direction:column;align-items:center;gap:6px;width:150px;text-align:center}
.badge-a .badge-img{width:150px;height:150px;border-radius:14px;object-fit:cover;border:1px solid var(--line-strong)}
.badge-a.locked .badge-img{filter:grayscale(.85) brightness(.5);opacity:.7}
.badge-a .medal{width:76px;height:76px;border-radius:50%;display:grid;place-items:center;position:relative;
background:radial-gradient(circle at 50% 35%,rgba(67,232,195,.28),rgba(9,26,19,.9));
border:2px solid var(--mint);box-shadow:0 0 18px rgba(67,232,195,.3)}
.badge-a.locked .medal{background:rgba(139,166,156,.08);border-color:var(--line-strong);box-shadow:none;filter:grayscale(1);opacity:.5}
.badge-a .medal svg{width:38px;height:38px;stroke:var(--mint);fill:none;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round}
.badge-a.locked .medal svg{stroke:var(--muted)}
.badge-a .bl{font-size:12px;font-weight:700;line-height:1.2}
.badge-a .bs{font-size:10.5px;color:var(--muted)}
.badge-a .share{font-size:11px;color:var(--mint);cursor:pointer;background:none;border:0;padding:0;text-decoration:underline}
/* ── "Your next move": milestone stepper card ── */
.next-card{position:relative;overflow:hidden;border-color:rgba(67,232,195,.28)}
.next-card::before{content:"";position:absolute;inset:0;pointer-events:none;background:
radial-gradient(420px 150px at 10% 0%,rgba(67,232,195,.12),transparent 70%),
radial-gradient(360px 130px at 90% 100%,rgba(157,125,255,.09),transparent 70%)}
.nc-head{display:flex;gap:14px;align-items:flex-start;position:relative}
.nc-head .pl{flex:0 0 auto;width:44px;height:44px;border-radius:12px;display:grid;place-items:center;
background:rgba(67,232,195,.12);border:1px solid rgba(67,232,195,.35)}
.nc-head .pl svg{width:20px;height:20px;stroke:var(--mint);fill:none;stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round}
.nc-steps{display:flex;margin-top:20px;position:relative;flex-wrap:wrap;row-gap:14px}
.nc-step{flex:1;min-width:88px;display:flex;flex-direction:column;align-items:center;gap:7px;
position:relative;text-align:center;padding:0 4px}
.nc-step::before{content:"";position:absolute;top:13px;left:-50%;width:100%;height:2px;background:var(--line-strong)}
.nc-step:first-child::before{display:none}
.nc-step.hit::before{background:linear-gradient(90deg,var(--mint),var(--mint-hi))}
.nc-step .dot{width:27px;height:27px;border-radius:50%;display:grid;place-items:center;font-size:12px;
font-weight:800;background:var(--panel-solid);border:2px solid var(--line-strong);color:var(--muted);
position:relative;z-index:1}
.nc-step.hit .dot{background:var(--mint);border-color:var(--mint);color:var(--mint-ink);
box-shadow:0 0 14px rgba(67,232,195,.35)}
.nc-step.cur .dot{border-color:var(--amber);color:var(--amber);box-shadow:0 0 12px rgba(255,178,56,.4)}
.nc-step .lb{font-size:12px;font-weight:700;line-height:1.25}
.nc-step .lb i{display:block;font-style:normal;font-weight:500;font-size:10.5px;color:var(--muted);margin-top:2px}
.nc-step.cur .lb{color:var(--amber)}
/* ── overview v3: stat cards w/ plates+chips, hand-rolled charts ── */
.statx{display:flex;gap:14px;align-items:flex-start;background:var(--panel);border:1px solid var(--line);
border-radius:var(--radius);padding:18px;position:relative;overflow:hidden}
.statx .pl{flex:0 0 auto;width:46px;height:46px;border-radius:13px;display:grid;place-items:center;
background:rgba(67,232,195,.1);border:1px solid rgba(67,232,195,.3)}
.statx .pl svg{width:22px;height:22px;stroke:var(--mint);fill:none;stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round}
.statx.c-cyan .pl{background:rgba(84,204,255,.1);border-color:rgba(84,204,255,.3)}
.statx.c-cyan .pl svg{stroke:var(--cyan)}
.statx.c-violet .pl{background:rgba(157,125,255,.12);border-color:rgba(157,125,255,.32)}
.statx.c-violet .pl svg{stroke:var(--violet)}
.statx.c-amber .pl{background:rgba(255,178,56,.1);border-color:rgba(255,178,56,.3)}
.statx.c-amber .pl svg{stroke:var(--amber)}
.statx .nv{font-family:var(--disp);font-size:26px;font-weight:800;font-variant-numeric:tabular-nums;line-height:1.1}
.statx .lb{font-size:12px;color:var(--muted);text-transform:uppercase;letter-spacing:.08em;margin-top:2px}
.chip{display:inline-block;margin-top:7px;font-size:11.5px;font-weight:700;border-radius:999px;padding:2px 10px;
background:rgba(67,232,195,.1);color:var(--mint)}
.chip.flat{background:rgba(139,166,156,.12);color:var(--muted)}
.chip.warm{background:rgba(255,178,56,.12);color:var(--amber)}
.card-head{display:flex;align-items:baseline;justify-content:space-between;gap:10px}
.card-head .sub{font-size:12px;color:var(--muted)}
/* bar chart (divs, real data) */
.barchart{display:flex;align-items:flex-end;gap:6px;height:120px;margin-top:14px}
.barchart .bar{flex:1;min-width:6px;background:linear-gradient(180deg,var(--mint),rgba(67,232,195,.25));
border-radius:5px 5px 2px 2px;position:relative;transition:height .5s ease}
.barchart .bar.alt{background:linear-gradient(180deg,var(--violet),rgba(157,125,255,.25))}
.barchart .bar:hover::after{content:attr(data-v);position:absolute;bottom:calc(100% + 4px);left:50%;
transform:translateX(-50%);background:var(--panel-solid);border:1px solid var(--line-strong);border-radius:7px;
padding:2px 8px;font-family:var(--mono);font-size:11px;white-space:nowrap;z-index:3}
.barchart .bar.empty{background:rgba(139,166,156,.15);height:4px!important}
.chart-x{display:flex;gap:6px;margin-top:6px}
.chart-x span{flex:1;text-align:center;font-family:var(--mono);font-size:10px;color:var(--muted);overflow:hidden;white-space:nowrap}
/* donut + ring (SVG) */
.donut-wrap{display:flex;gap:22px;align-items:center;margin-top:10px;flex-wrap:wrap}
.donut-legend{display:flex;flex-direction:column;gap:8px;font-size:13px}
.donut-legend i{display:inline-block;width:10px;height:10px;border-radius:3px;margin-right:8px}
.donut-center{font-family:var(--disp);font-weight:800}
/* ── login ad interstitial: sponsor opens in a new tab, timer runs here ── */
.lgate{position:fixed;inset:0;z-index:90;display:flex;flex-direction:column;background:var(--ground)}
.lgate-bar{display:flex;align-items:center;gap:14px;padding:10px 16px;background:var(--panel-solid);
border-bottom:1px solid var(--line);flex-wrap:wrap}
.lg-brand{font-family:var(--disp);font-weight:800;font-size:15px;white-space:nowrap}
.lg-brand em{color:var(--mint);font-style:normal}
.lgate-note{flex:1;min-width:140px}
.lg-timer{font-weight:700;font-size:14px;border:1px solid var(--line-strong);border-radius:999px;
padding:5px 14px;min-width:150px;text-align:center}
.lg-timer.done{color:var(--mint);border-color:var(--mint)}
.lgate-body{flex:1;display:grid;place-items:center;padding:24px;overflow:auto}
.lgate-card{max-width:560px;text-align:center}
.lgate-card img{max-width:100%;max-height:50vh;border-radius:12px;border:1px solid var(--line-strong)}
#lgCreative .lg-linkcard{display:inline-block;background:var(--panel);border:1px solid var(--line-strong);
border-radius:14px;padding:22px 28px;font-family:var(--disp);font-weight:700;font-size:18px;overflow-wrap:anywhere}
/* ── welcome tour (gauntlet): framed line sites + countdown ── */
.lgate-frame{flex:1;border:0;width:100%;background:#fff;min-height:0}
.ggmeta{padding:8px 16px;background:var(--panel-solid);border-bottom:1px solid var(--line)}
/* ── bio header (wall/profile page) ── */
.bio-head{display:flex;gap:22px;align-items:center;flex-wrap:wrap;padding:8px 0 4px}
.bio-avatar{width:96px;height:96px;border-radius:50%;object-fit:cover;border:2px solid var(--mint);flex:0 0 auto}
.bio-badge{width:74px;height:74px;border-radius:12px;object-fit:cover;flex:0 0 auto;border:1px solid var(--line-strong)}
.btn.disabled{opacity:.45;pointer-events:none;filter:grayscale(.3)}
.bio-meta{flex:1;min-width:220px}
.bio-socials{display:flex;gap:8px;flex-wrap:wrap;margin-top:12px}
.bio-video{margin:14px auto 8px;max-width:760px}
@media (max-width:720px){.bio-head{flex-direction:column;align-items:center;text-align:center;gap:14px}.bio-head .bio-avatar,.bio-head .bio-badge{margin:0 auto}.bio-meta{min-width:0;width:100%}.bio-socials{justify-content:center}.bio-qr{margin:4px auto 0}.bio-video .eyebrow{text-align:center}}
.bio-video iframe,.bio-video video{width:100%;aspect-ratio:16/9;border:1px solid var(--line-strong);border-radius:16px;background:#000;display:block}
.bio-socials a{display:inline-flex;align-items:center;gap:6px;font-size:12px;font-weight:700;color:var(--mint);border:1px solid var(--line-strong);
border-radius:999px;padding:4px 12px 4px 9px;text-decoration:none}
.bio-socials a svg{flex:0 0 auto}
.bio-socials a:hover{border-color:var(--mint)}
.bio-qr{flex:0 0 auto;background:var(--panel);border:1px solid var(--line-strong);border-radius:14px;padding:10px}
.bio-qr img{display:block;border-radius:8px;background:#eef7f3}
/* ── wall page ── */
.wall-card{background:var(--panel);border:1px solid var(--line);border-radius:var(--radius);padding:16px;text-align:center}
.wall-card img{max-width:100%;border-radius:10px;border:1px solid var(--line-strong)}
.wall-pos{font-family:var(--mono);font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.08em;margin-bottom:8px}
.wc-creative img{max-width:100%;border-radius:10px;border:1px solid var(--line-strong)}
.wc-action{margin-top:10px;min-height:32px;display:flex;justify-content:center;align-items:center}
.wc-check{color:var(--mint);font-weight:800;border:1px solid var(--mint);border-radius:999px;padding:4px 14px;
box-shadow:0 0 14px rgba(67,232,195,.3)}
.wc-timer{color:var(--amber);font-weight:700;font-family:var(--mono);font-size:13px}
/* ── modal (sponsor message) ── */
.modal-back{position:fixed;inset:0;z-index:100;background:rgba(2,6,5,.72);display:flex;align-items:center;justify-content:center;padding:20px}
.modal-card{background:var(--panel-solid);border:1px solid var(--line-strong);border-radius:var(--radius);
padding:24px;max-width:520px;width:100%;box-shadow:0 20px 60px rgba(0,0,0,.5)}
/* ── downline lineage list ── */
.lin-lvl{margin:10px 0}
.lin-lvl>.cap{display:inline-block;font-size:13px;font-weight:800;text-transform:uppercase;letter-spacing:.06em;
color:var(--mint);background:var(--mint-soft);border:1px solid var(--line-strong);border-radius:8px;padding:4px 10px;margin-bottom:10px}
.lin-row{display:grid;grid-template-columns:minmax(110px,1.2fr) minmax(0,2fr) auto auto auto auto;gap:6px 14px;
align-items:center;padding:9px 0;border-bottom:1px solid var(--line)}
.lin-row .nm{font-weight:700;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.lin-row .em{font-size:12px;color:var(--mint);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.lin-row .id{font-family:var(--mono);font-size:11px;color:var(--muted)}
.lin-row .sp{display:block;font-size:11.5px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .lin-row .em{min-width:0}
.lin-row .dt{font-size:12px;color:var(--muted);text-align:right;font-variant-numeric:tabular-nums;white-space:nowrap}
.lin-row .chat-msg-btn{justify-self:end}
.lin-row .lin-act-btn{justify-self:end;white-space:nowrap}
.lin-act{display:flex;flex-wrap:wrap;gap:6px;align-items:center;padding:8px 10px 10px;margin:-1px 0 6px;border-bottom:1px solid var(--line);background:rgba(255,255,255,.025);border-radius:0 0 10px 10px}
.lin-chip{font-size:12px;color:var(--muted);border:1px solid var(--line);border-radius:999px;padding:3px 10px;white-space:nowrap} .lin-chip b{font-weight:600;color:var(--ink);margin-right:5px} .lin-chip.on{border-color:rgba(67,232,195,.45);color:var(--ink)} .lin-chip.warn{border-color:rgba(255,209,92,.5)}
.lin-verdict{margin-left:auto;font-size:11px;letter-spacing:.08em;text-transform:uppercase;color:#ffd15c} .lin-verdict.on{color:var(--mint)}
@media (max-width:560px){.lin-chip{white-space:normal}.lin-act{padding:8px 4px 10px}}
@media (max-width:560px){.lin-row{grid-template-columns:1fr auto;gap:2px 10px}
.lin-row .em{grid-column:1/2}.lin-row .dt{grid-column:1/2;text-align:left}.lin-row .chat-msg-btn{grid-row:1/3;grid-column:2}
.lin-row .lin-act-btn{grid-column:2;grid-row:3/5}.lin-row:not(:has(.chat-msg-btn)) .lin-act-btn{grid-row:1/3}}
/* ── solo composer: toolbar + contenteditable editor ── */
.ed-bar{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:8px}
.ed-bar button{background:var(--panel);color:var(--ink);border:1px solid var(--line-strong);border-radius:8px;
padding:5px 11px;font-size:12.5px;cursor:pointer}
.ed-bar button:hover,.ed-bar button:focus-visible{border-color:var(--mint);outline:none}
.ed-body{background:rgba(4,8,7,.65);border:1px solid var(--line-strong);border-radius:11px;
min-height:180px;padding:12px 14px;outline:none;overflow-wrap:anywhere}
.ed-body:focus{border-color:var(--mint)}
.ed-body:empty::before{content:attr(data-ph);color:var(--muted)}
.ed-body img,.ed-body video,.ib-rich img,.ib-rich video{max-width:100%;border-radius:10px;margin:6px 0;display:block}
.ed-sep{width:1px;align-self:stretch;background:var(--line-strong);margin:2px 3px}
.ed-bar sub{font-size:9px}
textarea.ed-body{width:100%;min-height:180px;resize:vertical}
.ed-body a,.ib-rich a{color:var(--mint)}
.ed-body h3,.ib-rich h3,.ed-body h4,.ib-rich h4{margin:.5em 0 .3em}
.ed-media{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin:10px 0 0}
/* ── earn sub-tabs (Watch ads | Inbox) ── */
.subtabs{display:flex;gap:6px;margin:0 0 16px;border-bottom:1px solid var(--line);padding-bottom:0}
.subtab{background:none;border:0;border-bottom:2px solid transparent;color:var(--muted);
font:inherit;font-weight:700;font-size:14px;padding:8px 14px;cursor:pointer;margin-bottom:-1px;
display:inline-flex;align-items:center;gap:7px}
.subtab:hover{color:var(--ink)}
.subtab.on{color:var(--mint);border-bottom-color:var(--mint)}
.subtab .pill{position:static}
/* ── solo-ads inbox ── */
.bo-menu .pill{margin-left:auto;background:var(--amber);color:#1a1206;font-size:11px;font-weight:800;
border-radius:999px;padding:1px 8px;line-height:1.5}
.ib-row{display:flex;gap:10px;align-items:baseline;padding:11px 6px;border-bottom:1px solid var(--line);
cursor:pointer;flex-wrap:wrap}
.ib-row:hover{background:rgba(67,232,195,.05)}
.ib-row .sub{font-weight:700;flex:1;min-width:160px;overflow-wrap:anywhere}
.ib-row.unread .sub{color:var(--mint)}
.ib-row .from,.ib-row .when{font-size:12px;color:var(--muted);white-space:nowrap}
.cta-need{box-shadow:0 0 0 2px var(--amber),0 0 16px rgba(255,178,56,.4)!important;animation:ctapulse 1.6s ease-in-out infinite}
@keyframes ctapulse{50%{box-shadow:0 0 0 2px var(--amber),0 0 24px rgba(255,178,56,.65)!important}}
@media (prefers-reduced-motion:reduce){.cta-need{animation:none}}
/* ── back-office accent family: green leads, cyan/violet/amber season the cards ── */
.bo .stats .stat:nth-child(2) .n{color:var(--cyan)}
.bo .stats .stat:nth-child(2)::before{background:linear-gradient(90deg,transparent,var(--cyan),transparent)}
.bo .stats .stat:nth-child(3) .n{color:var(--violet)}
.bo .stats .stat:nth-child(3)::before{background:linear-gradient(90deg,transparent,var(--violet),transparent)}
.bo .stats .stat:nth-child(4) .n{color:var(--amber)}
.bo .stats .stat:nth-child(4)::before{background:linear-gradient(90deg,transparent,var(--amber),transparent)}
.bo .card h3::before{content:"";display:inline-block;width:9px;height:9px;border-radius:2.5px;
background:var(--mint);margin-right:10px;transform:rotate(45deg);vertical-align:1px}
#pane-line .card h3::before{background:var(--cyan)}
#pane-buy .card h3::before,#pane-campaigns .card h3::before{background:var(--amber)}
#pane-earn .card h3::before,#pane-earnings .card h3::before{background:var(--violet)}
#pane-promo .card h3::before{background:var(--cyan)}
#pane-wallet .card h3::before,#pane-profile .card h3::before{background:var(--mint)}
.bo .card{background:linear-gradient(165deg,rgba(24,36,31,.6),rgba(13,19,17,.66))}
#pane-line .qualbar,#nextCard{border-left:3px solid rgba(67,232,195,.4)}
.promo-block{background:rgba(4,8,7,.55);border:1px solid var(--line);border-radius:12px;
padding:14px 16px;margin:0 0 12px;font-size:13.5px;line-height:1.6;white-space:pre-wrap}
.promo-block .btn{margin-top:10px}
/* back-office polish: pane transitions, quick actions */
@media(prefers-reduced-motion:no-preference){
.pane:not([hidden]){animation:panein .25s ease}
@keyframes panein{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:none}}
}
.qa{display:flex;flex-direction:column;gap:8px}
.qa button{display:flex;align-items:center;gap:10px;background:rgba(67,232,195,.05);border:1px solid var(--line);
color:var(--ink);font:600 14px var(--disp);padding:11px 14px;border-radius:11px;cursor:pointer;text-align:left;
transition:border-color .15s ease,background .15s ease}
.qa button:hover{border-color:var(--mint);background:rgba(67,232,195,.1)}
/* member sub-navigation */
.subnav{display:flex;gap:6px;flex-wrap:wrap;background:rgba(16,28,24,.7);border:1px solid var(--line);
border-radius:999px;padding:6px 8px;width:fit-content;margin:0 0 22px}
.subnav button{background:transparent;border:0;color:var(--muted);font:600 13.5px var(--disp);
padding:8px 16px;border-radius:999px;cursor:pointer}
.subnav button:hover{color:var(--ink)}
.subnav button.on{color:var(--mint-ink);background:var(--mint)}
/* member dashboard: qualification progress + roster */
.qualbar{position:relative;height:12px;border-radius:999px;background:rgba(4,8,7,.7);border:1px solid var(--line-strong);
margin:26px 0 30px;max-width:520px}
.qualbar #qualFill{height:100%;border-radius:999px;background:linear-gradient(90deg,var(--mint),var(--mint-hi));
box-shadow:0 0 18px rgba(67,232,195,.4);width:0;transition:width .6s ease}
.qb-mark{position:absolute;top:16px;transform:translateX(-50%);font-family:var(--mono);font-size:11px;color:var(--muted);white-space:nowrap}
.qb-mark.end{left:auto!important;right:0;transform:none} /* keeps the last label inside the card on mobile */
#inviteLine,#pitchPreview{overflow-wrap:anywhere}
img{max-width:100%}
.roster{width:100%;border-collapse:collapse;font-size:13.5px}
.roster td{padding:8px 10px;border-bottom:1px solid var(--line)}
.roster td:first-child{font-family:var(--mono)}
.roster tr:last-child td{border-bottom:0}
/* chat widget */
#iapChatBtn{position:fixed;right:20px;bottom:20px;z-index:40;width:56px;height:56px;border-radius:50%;
border:0;background:var(--mint);color:var(--mint-ink);font-size:24px;cursor:pointer;
box-shadow:0 8px 30px rgba(67,232,195,.4)}
#iapChatBtn:hover{transform:scale(1.06)}
#iapChatPanel{position:fixed;right:20px;bottom:88px;z-index:40;width:min(360px,calc(100vw - 40px));
background:var(--panel-solid);border:1px solid var(--line-strong);border-radius:18px;overflow:hidden;
box-shadow:0 24px 70px rgba(0,0,0,.65);display:flex;flex-direction:column}
.ch-head{padding:14px 16px;border-bottom:1px solid var(--line);font-family:var(--disp);position:relative}
.ch-head .ch-sub{display:block;font-size:12px;color:var(--muted);font-family:"Segoe UI",system-ui,sans-serif}
#iapChatClose{position:absolute;right:10px;top:10px;background:transparent;border:0;color:var(--muted);
font-size:22px;cursor:pointer;line-height:1}
.ch-msgs{padding:14px;overflow-y:auto;max-height:340px;display:flex;flex-direction:column;gap:10px}
.ch-m{border-radius:12px;padding:9px 13px;font-size:13.5px;line-height:1.5;max-width:86%}
.ch-m.bot{background:rgba(67,232,195,.08);border:1px solid rgba(67,232,195,.2);align-self:flex-start}
.ch-m.me{background:rgba(130,148,196,.12);border:1px solid var(--line);align-self:flex-end}
.ch-m a{word-break:break-all}
.ch-input{display:flex;gap:8px;padding:11px;border-top:1px solid var(--line)}
.ch-input input{flex:1;min-width:0}
.ch-input button{background:var(--mint);color:var(--mint-ink);border:0;border-radius:10px;padding:0 16px;
font-weight:700;cursor:pointer;font-family:var(--disp)}
@media(prefers-reduced-motion:no-preference){
.feed .row:first-child{animation:landed .9s ease}
@keyframes landed{from{background:rgba(67,232,195,.3)}to{background:var(--mint-soft)}}
}
/* ── Sponsor chat: FAB, slide-in drawer, threads, bubbles, presence ── */
.bo-chat{display:flex;align-items:center}
.bo-chat .pill{margin-left:auto;min-width:20px;height:20px;padding:0 6px;border-radius:10px;background:var(--mint);
color:var(--mint-ink);font:700 12px/20px var(--disp);text-align:center}
.chat-drawer{position:fixed;right:0;top:0;bottom:0;width:380px;max-width:92vw;z-index:70;display:flex;flex-direction:column;
background:var(--panel-solid);border-left:1px solid var(--line-strong);box-shadow:-16px 0 40px rgba(0,0,0,.45);
transform:translateX(0);animation:chatIn .16s ease}
@keyframes chatIn{from{transform:translateX(24px);opacity:.4}to{transform:translateX(0);opacity:1}}
@media (prefers-reduced-motion:reduce){.chat-drawer{animation:none}.chat-fab{transition:none}}
.chat-head{display:flex;align-items:center;gap:8px;padding:12px 12px;border-bottom:1px solid var(--line)}
.chat-who{display:flex;flex-direction:column;line-height:1.15;min-width:0;flex:1}
.chat-who b{font:700 15px var(--disp);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.chat-icon{background:none;border:0;color:var(--muted);font-size:26px;line-height:1;cursor:pointer;padding:0 6px}
.chat-icon:hover{color:var(--ink)}
.pres-dot{width:9px;height:9px;border-radius:50%;background:var(--muted);flex:0 0 auto}
.pres-dot.on{background:var(--mint);box-shadow:0 0 8px var(--mint)}
.chat-threads{flex:1;overflow:auto}
.chat-thread{display:flex;align-items:center;gap:10px;padding:12px 14px;border-bottom:1px solid var(--line);cursor:pointer}
.chat-thread:hover{background:var(--mint-soft)}
.chat-thread .ct-main{flex:1;min-width:0}
.chat-thread .ct-name{font:700 14px var(--disp)}
.chat-thread .ct-last{color:var(--muted);font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.chat-thread .ct-un{min-width:20px;height:20px;padding:0 5px;border-radius:10px;background:var(--mint);color:var(--mint-ink);
font:700 12px/20px var(--disp);text-align:center}
.chat-empty{padding:22px 16px;color:var(--muted);font-size:14px}
.chat-convo{flex:1;display:flex;flex-direction:column;min-height:0}
.chat-msgs{flex:1;overflow:auto;padding:14px;display:flex;flex-direction:column;gap:8px}
.cbub{max-width:78%;padding:9px 12px;border-radius:14px;font-size:14px;line-height:1.4;white-space:pre-wrap;word-wrap:break-word}
.cbub.them{align-self:flex-start;background:var(--panel);border:1px solid var(--line);border-bottom-left-radius:4px}
.cbub.me{align-self:flex-end;background:var(--mint);color:var(--mint-ink);border-bottom-right-radius:4px}
.cbub .ct-time{display:block;margin-top:4px;font-size:10.5px;opacity:.6}
.chat-day{align-self:center;color:var(--muted);font-size:11px;text-transform:uppercase;letter-spacing:.05em;margin:6px 0}
.chat-banner{padding:8px 14px;border-top:1px solid var(--line);color:var(--muted);font-size:12.5px;background:var(--ground2)}
.chat-compose{display:flex;gap:8px;padding:10px;border-top:1px solid var(--line);align-items:flex-end}
.chat-compose textarea{flex:1;resize:vertical;min-height:44px;max-height:45vh;line-height:1.45;background:var(--ground2);border:1px solid var(--line);
border-radius:12px;color:var(--ink);padding:9px 11px;font:400 14px var(--disp)}
.chat-compose .btn{padding:9px 16px}
.chat-msg-btn{margin-left:auto;font-size:12px;padding:3px 10px}
.switch{display:flex;align-items:center;gap:10px;cursor:pointer;font-size:14px}
.switch input{width:18px;height:18px;accent-color:var(--mint)}
/* ── Overview line tree ── */
.line-tree{margin-top:6px}
.lt-top{margin-left:26px}
.lt-you{display:inline-block;background:var(--mint);color:var(--mint-ink);font-weight:800;padding:6px 18px;border-radius:999px;font-size:13px}
.lt-stem{height:12px;border-left:2px solid var(--line-strong);width:0;margin:0 0 6px 30px}
.lt-row{display:flex;align-items:center;gap:10px;margin:7px 0}
.lt-cap{font:700 11px var(--mono);color:var(--muted);width:22px;flex:0 0 auto}
.lt-nodes{display:flex;gap:8px;flex-wrap:wrap}
.lt-node{background:var(--panel);border:1px solid var(--line-strong);border-radius:10px;padding:6px 12px;font-size:13px;font-weight:600;white-space:nowrap}
.lt-node.deep{color:var(--muted)}
.lt-node.qualified{border-color:#ffb238;color:#ffd15c;box-shadow:0 0 0 1px rgba(255,177,56,.35)}
.lt-node.open{border-style:dashed;color:var(--muted);background:transparent}
.lt-node .lt-n{display:inline-block;font-style:normal;font-size:10px;line-height:16px;min-width:16px;text-align:center;border-radius:8px;background:rgba(255,177,56,.18);color:#ffd15c;margin-left:6px;padding:0 4px;font-variant-numeric:tabular-nums}
.sh-report{display:block;text-align:center;margin-top:10px;color:rgba(255,255,255,.45);font-size:12px}
.sh-report:hover{color:rgba(255,255,255,.8)}
/* ── promo tools (2026-09-09) ── */
.promo-strip{display:flex;justify-content:space-between;align-items:center;gap:14px;flex-wrap:wrap;padding:16px 20px}
.angle-list{display:flex;flex-direction:column;gap:8px}
.hp-field{position:absolute;left:-9999px;top:auto;width:1px;height:1px;opacity:0;overflow:hidden}
.hbars{display:flex;align-items:flex-end;gap:2px;height:20px;margin-top:6px;width:120px}
.hbars i{flex:1 1 0;background:var(--mint);opacity:.75;border-radius:1px 1px 0 0;min-width:2px}
.hbars i:hover{opacity:1}
.icon-check{display:flex;gap:8px;flex-wrap:wrap;margin:0 0 12px}
.icon-check .ic-btn{font-size:26px;line-height:1;padding:10px 14px;border-radius:12px;border:1px solid var(--line-strong);background:var(--panel);cursor:pointer}
.icon-check .ic-btn:hover{border-color:var(--mint)}
.angle-row.pb-acc>summary{padding:12px 14px;font-size:14.5px}
.angle-row .angle-body{display:flex;justify-content:space-between;align-items:center;gap:14px;flex-wrap:wrap;padding:0 14px 14px}
.angle-row .angle-txt{flex:1 1 320px;min-width:0}
.angle-name{font-family:var(--disp);font-weight:700;font-size:15px}
.angle-hook{color:var(--mint);font-size:13px;font-weight:500}
.angle-tag{font-family:var(--mono);font-size:10px;letter-spacing:.08em;text-transform:uppercase;color:var(--muted);border:1px solid var(--line-strong);border-radius:999px;padding:1px 7px}
.angle-url{overflow-wrap:anywhere;margin-top:4px;display:block;color:var(--mint);text-decoration:none}
.angle-url:hover{text-decoration:underline}
.angle-btns{display:flex;gap:8px;flex-wrap:wrap}
.angle-btns .btn{margin-top:0}
.promo-pills{margin:4px 0 14px}
.promo-block .pb-head{display:flex;gap:8px;align-items:center;margin-bottom:8px;font-size:13.5px}
.promo-block .pb-net{font-family:var(--mono);font-size:11px;letter-spacing:.08em;text-transform:uppercase;color:var(--mint)}
.promo-block .pb-text{white-space:pre-wrap;font-size:14.5px;line-height:1.5}
.promo-block .pb-actions{display:flex;gap:8px;flex-wrap:wrap;margin-top:10px}
.promo-block .pb-actions .btn{margin-top:0}
.promo-bv{display:flex;gap:12px;align-items:center;flex-wrap:wrap;margin-top:16px;padding:14px 16px;border:1px dashed var(--line-strong);border-radius:12px}
.promo-bv b{font-family:var(--disp)}
.obj-wrap{display:flex;flex-direction:column;gap:8px}
.obj{border:1px solid var(--line);border-radius:12px;background:rgba(4,8,7,.4)}
.obj summary{cursor:pointer;padding:12px 16px;font-family:var(--disp);font-weight:700;font-size:15px;list-style:none;display:flex;justify-content:space-between;gap:10px}
.obj summary::after{content:"+";color:var(--mint);font-weight:800}
.obj[open] summary::after{content:"–"}
.obj summary::-webkit-details-marker{display:none}
.obj summary::marker{content:""}
.obj summary{text-align:left}
.obj-body{padding:0 16px 14px;display:grid;gap:12px}
.obj .lab{display:inline-block;font-family:var(--mono);font-size:11px;letter-spacing:.1em;text-transform:uppercase;color:var(--mint);margin-bottom:4px}
.obj .lab-say{color:var(--amber)}
.obj-truth p{margin:0;font-size:14.5px;color:var(--ink)}
.obj-say{margin:0}
.lin-row .earn{font-family:var(--mono);font-size:12px;color:var(--muted);white-space:nowrap;font-variant-numeric:tabular-nums}
.lin-row .earn.on{color:var(--mint);font-weight:700}
.wo-slot{border:1px solid var(--line);border-radius:12px;padding:12px 14px}
.wo-slot.locked{opacity:.6}
.wo-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:6px}
.wo-slot img{max-width:100%;border-radius:8px}
/* getting-started stepper on the Overview (Jim could not find the wallet step, 2026-09-14) */
.gs{border-color:rgba(67,232,195,.45)}
.gs-steps{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:8px;margin:4px 0 12px}
.gs-step{display:flex;gap:10px;align-items:center;border:1px solid var(--line);border-radius:12px;padding:10px 12px;color:var(--muted);font-size:14px}
.gs-step .n{flex:0 0 26px;width:26px;height:26px;border-radius:50%;display:inline-flex;align-items:center;justify-content:center;font-weight:700;border:1px solid var(--line);font-size:13px}
.gs-step.done{color:var(--ink)} .gs-step.done .n{background:var(--mint);color:var(--mint-ink);border-color:var(--mint)}
.gs-step.now{color:var(--ink);border-color:var(--mint);background:rgba(67,232,195,.07)} .gs-step.now .n{border-color:var(--mint);color:var(--mint)}
.gs-now{display:flex;gap:14px;align-items:center;flex-wrap:wrap;border-top:1px solid var(--line);padding-top:12px}
.gs-now p{margin:0;flex:1;min-width:240px;max-width:60ch} .gs-now p b{display:block;font-family:var(--disp);font-size:16px;margin-bottom:2px}
@keyframes gsPulse{0%{box-shadow:0 0 0 0 rgba(67,232,195,.7)}70%{box-shadow:0 0 0 14px rgba(67,232,195,0)}100%{box-shadow:0 0 0 0 rgba(67,232,195,0)}}
.pulse{animation:gsPulse 1.2s ease-out 4}
/* promo toolkit tiers */
.tk-tiers{display:grid;grid-template-columns:repeat(auto-fit,minmax(190px,1fr));gap:10px}
.tk-tier{border:1px solid var(--line);border-radius:12px;padding:12px 14px;font-size:13.5px;color:var(--muted)}
.tk-tier.reached{border-color:rgba(67,232,195,.5);color:var(--ink)} .tk-tier.current{background:rgba(67,232,195,.07)}
.tk-tier b{display:block;font-family:var(--disp);font-size:15px;color:var(--ink)} .tk-tier .need{font-size:12px;margin:2px 0 8px;color:var(--muted)}
.tk-tier ul{margin:0;padding-left:16px} .tk-tier li{margin:3px 0} .tk-tier li.soon{opacity:.65} .tk-tier li.soon::after{content:' (coming)';font-size:11px;color:var(--muted)}
.tk-tier .lock{font-size:11px;letter-spacing:.08em;text-transform:uppercase;color:#ffd15c}
.tk-hist{border-top:1px solid var(--line);padding:8px 0;font-size:13px} .tk-hist .muted{font-size:12px}
.tk-sec{margin-top:18px;padding-top:14px;border-top:1px solid var(--line)} .tk-grid{display:flex;gap:8px;flex-wrap:wrap}
.tk-vids{display:grid;grid-template-columns:repeat(auto-fill,minmax(210px,1fr));gap:8px} .tk-vid{border:1px solid var(--line);border-radius:10px;padding:10px 12px;font-size:13px} .tk-vid b{display:block;font-weight:600;margin-bottom:4px} .tk-vid .st{font-size:12px;color:var(--muted)}
.tk-bar{height:8px;border-radius:99px;background:rgba(255,255,255,.08);overflow:hidden;margin:6px 0 4px} .tk-bar i{display:block;height:100%;border-radius:99px;background:linear-gradient(90deg,#1fb894,#43e8c3);transition:width .6s ease;min-width:4px}
.tk-table{width:100%;border-collapse:collapse;font-size:13px} .tk-table th,.tk-table td{text-align:left;padding:6px 8px;border-bottom:1px solid var(--line)} .tk-table th{font-size:11px;letter-spacing:.06em;text-transform:uppercase;color:var(--muted)} .tk-table td.n{font-variant-numeric:tabular-nums;text-align:right} .tk-table th.n{text-align:right} .tk-table tr.best td{color:var(--mint)}
.tk-team{border:1px solid var(--line);border-radius:10px;padding:8px 12px;margin:6px 0;font-size:13px;display:flex;gap:10px;flex-wrap:wrap;align-items:center;justify-content:space-between} .tk-team .who b{font-weight:600} .tk-team .who span{color:var(--muted);font-size:12px} .tk-team.stalled{border-color:rgba(255,209,92,.45)}
.tk-nudge{width:100%;margin:8px 0 0;padding:8px 12px;border:1px dashed var(--line);border-radius:10px;font-size:13px}
/* click sources stack one per line so the Clicks column stays narrow (Marty, 2026-09-15) */
.clk-src span{display:block;white-space:nowrap} .camp-table td{white-space:normal} .camp-table td:first-child{max-width:260px}
/* campaigns list on phones and tablets: each row becomes a card, buttons at the bottom, no sideways scroll (Jim, 2026-09-15) */
@media (max-width:900px){
.tablewrap:has(.camp-table){overflow:visible}
.camp-table{display:block;width:100%;border-collapse:separate}
.camp-table thead{display:none}
.camp-table tbody{display:block}
.camp-table tr{display:block;border:1px solid var(--line);border-radius:12px;padding:10px 12px 12px;margin:0 0 10px;background:var(--panel)}
.camp-table td{display:flex;justify-content:space-between;align-items:baseline;gap:12px;padding:5px 0;border:0;text-align:left;max-width:100%;overflow:hidden}
.camp-table td.num{text-align:right;font-variant-numeric:tabular-nums}
.camp-table td::before{content:attr(data-l);color:var(--muted);font-size:11px;letter-spacing:.06em;text-transform:uppercase;flex:0 0 auto}
.camp-table td:first-child{display:block;padding:0 0 6px;font-size:16px} .camp-table td:first-child::before{display:none}
.camp-table td:first-child > *{max-width:100%}
.camp-table td.act{display:flex;flex-wrap:wrap;justify-content:flex-start;gap:8px;margin-top:8px;padding-top:10px;border-top:1px solid var(--line)} .camp-table td.act::before{display:none}
.camp-table td.act .btn{flex:1 1 auto;text-align:center}
}
/* Pipeline board (Marty, 2026-09-15): columns scroll sideways inside their own box, never the page */
.pipe-board{display:flex;gap:10px;overflow-x:auto;padding:4px 2px 10px;scroll-snap-type:x proximity}
.pipe-col{flex:0 0 220px;scroll-snap-align:start;background:rgba(4,8,7,.45);border:1px solid var(--line);border-radius:12px;padding:10px;min-height:120px}
.pipe-col h4{margin:0 0 2px;font-size:13px;letter-spacing:.06em;text-transform:uppercase;color:var(--mint);display:flex;justify-content:space-between;gap:8px}
.pipe-col h4 span{color:var(--muted);font-variant-numeric:tabular-nums}
.pipe-col .hint{font-size:11.5px;color:var(--muted);margin:0 0 8px;line-height:1.35}
.pipe-card{background:rgba(255,255,255,.04);border:1px solid var(--line);border-radius:10px;padding:8px 10px;margin:0 0 8px;cursor:pointer;transition:border-color .15s}
.pipe-card:hover,.pipe-card:focus-visible{border-color:var(--mint);outline:none}
.pipe-card.on{border-color:var(--gold,#e6c15a)}
.pipe-card .nm{font-weight:700;font-size:14px;display:flex;justify-content:space-between;gap:6px;align-items:baseline}
.pipe-card .nm small{font-weight:400;font-size:11px;color:var(--muted);white-space:nowrap}
.pipe-card .sub{font-size:12px;color:var(--muted);margin-top:3px;line-height:1.35}
.pipe-card .note{font-size:12px;color:var(--ink);margin-top:4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.pipe-card .chip{margin-top:5px;margin-right:4px}
.pipe-due{display:grid;gap:6px}
.pipe-due .pipe-card{margin:0}
@media (max-width:640px){.pipe-form{grid-template-columns:1fr !important}.pipe-col{flex-basis:200px}}
+42
View File
@@ -0,0 +1,42 @@
// Transaction viewer: renders one tx from /api/tx/<hash> (the server-side RPC
// relay, so the browser never talks to the chain directly and CSP stays 'self').
// This is the "verify it yourself" page for chains without a public explorer;
// when the config carries an explorer URL, feed links point there instead.
(async function () {
await IAP.renderNav('ledger');
const c = await IAP.getConfig();
const hash = location.pathname.split('/').pop();
IAP.$('txHash').textContent = hash;
IAP.$('txChain').textContent = 'on ' + (c.chainName || 'the settlement chain');
let r = null;
try { r = await (await fetch('/api/tx/' + hash)).json(); } catch (e) {}
const stat = IAP.$('txStatus');
if (!r || !r.found) {
stat.textContent = 'not found';
stat.className = 'badge amber';
IAP.$('txHint').hidden = false;
return;
}
const ok = r.status === '0x1';
stat.textContent = ok ? '✓ confirmed' : '✗ reverted';
stat.className = 'badge' + (ok ? '' : ' amber');
const hx = v => { try { return parseInt(v, 16); } catch (e) { return 0; } };
const rows = [
['Block', '#' + hx(r.blockNumber).toLocaleString()],
['Time', r.ts ? new Date(hx(r.ts) * 1000).toLocaleString() : 'pending'],
['From', r.from || ''],
['To (contract)', r.to || ''],
['Value sent', IAP.fmtPol(r.valueWei || '0') + ' POL'],
['Gas used', hx(r.gasUsed).toLocaleString()]
];
const tb = IAP.$('txTable');
tb.hidden = false;
tb.querySelector('tbody').innerHTML = rows.map(x =>
'<tr><td class="muted small" style="white-space:nowrap">' + x[0] + '</td>'
+ '<td class="mono small" style="overflow-wrap:anywhere">' + String(x[1]).replace(/[&<>]/g, '') + '</td></tr>').join('');
if (r.events && r.events.length) {
IAP.$('evCard').hidden = false;
const feed = IAP.$('evFeed');
for (const ev of r.events) feed.appendChild(IAP.feedRow(ev, c));
}
})();
+122
View File
@@ -0,0 +1,122 @@
// Full-screen ad viewer: the advertiser URL fills the tab, a countdown runs in
// the top bar (paused whenever this tab loses focus), and once the dwell is
// done the server hands out a human check. Solve it and the view credits.
// The dwell floor is enforced on the SERVER clock; this UI cannot cheat it.
(() => {
const $ = id => document.getElementById(id);
const token = location.pathname.split('/').pop();
const framed = window.self !== window.top; // shown inside the dashboard's in-page ad overlay
let left = 5, timer = null, credited = false, asking = false;
const setMsg = t => { $('vMsg').textContent = t; };
async function j(url, body) {
const r = await fetch(url, body
? { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }
: undefined);
return r.json();
}
function tick() {
if (credited || asking) return;
// In the in-page overlay the iframe often doesn't hold focus even though it's
// fully on screen, so gate on tab visibility only when framed.
if (document.visibilityState !== 'visible' || (!framed && !document.hasFocus())) {
$('vTimer').classList.add('paused');
$('vTimer').textContent = 'paused';
setMsg(framed ? 'Keep this ad on screen to finish the countdown.' : 'Come back to this tab to keep the countdown moving.');
return;
}
$('vTimer').classList.remove('paused');
left = Math.max(0, left - 0.25);
$('vTimer').textContent = Math.ceil(left) + 's left';
if (left <= 0) { clearInterval(timer); askChallenge(); }
}
async function askChallenge() {
asking = true;
$('vTimer').textContent = 'check';
$('vTimer').classList.add('done');
setMsg('One quick check to count the view:');
let c = await j('/api/my/viewchallenge?token=' + token);
if (c.early) { // server clock says not quite yet: wait it out and re-ask
await new Promise(r => setTimeout(r, (c.wait || 1) * 1000 + 300));
c = await j('/api/my/viewchallenge?token=' + token);
}
if (c.error) return fail(c.error);
renderChallenge(c);
}
function renderChallenge(c) {
$('vPrompt').textContent = 'Click the ' + c.prompt + ':';
const w = $('vOpts');
w.innerHTML = '';
c.options.forEach((em, i) => {
const b = document.createElement('button');
b.type = 'button';
b.textContent = em;
b.addEventListener('click', () => answer(i));
w.appendChild(b);
});
$('vCheck').classList.add('on');
}
async function answer(i) {
const r = await j('/api/my/adview', { token, answer: i });
if (r.error) {
if (r.retry) {
setMsg('Not that one — try again.');
const c = await j('/api/my/viewchallenge?token=' + token);
if (!c.error) return renderChallenge(c);
return fail(c.error);
}
return fail(r.error);
}
credited = true;
$('vCheck').classList.remove('on');
$('vTimer').textContent = '✓ credited';
const st = r.status || r;
setMsg('View ' + st.views + ' of ' + st.target + ' counted for today.'
+ (st.views >= st.target && !st.claimed ? ' Head back and claim your credits.' : ''));
$('vDone').classList.add('on');
try { localStorage.setItem('iap-view-done', String(Date.now())); } catch (e) {}
try { if (framed) window.parent.postMessage({ t: 'iap-view-done' }, location.origin); } catch (e) {}
}
function fail(msg) {
credited = true; // stop the loop; this view is over either way
if (timer) clearInterval(timer);
$('vCheck').classList.remove('on');
$('vTimer').textContent = '—';
setMsg(msg);
$('vDone').classList.add('on');
}
$('vClose').addEventListener('click', () => {
window.close();
// window.close() is blocked in mobile and in-app (wallet dApp) browsers. If
// the tab is still here a moment later, take them back to the dashboard so
// they are never stuck on a tab they can't close.
setTimeout(() => {
if (!window.closed) { setMsg('Taking you back to your dashboard…'); location.href = '/my#earn'; }
}, 400);
});
// When shown inside the dashboard's in-page overlay there is no tab to close:
// hide "Close tab" and turn "Back to dashboard" into a close-the-overlay signal.
if (framed) {
const cb = $('vClose'); if (cb) cb.style.display = 'none';
const back = document.querySelector('#vDone a[href="/my#earn"]');
if (back) back.addEventListener('click', e => { e.preventDefault(); try { window.parent.postMessage({ t: 'iap-view-close' }, location.origin); } catch (x) {} });
}
(async () => {
const info = await j('/api/my/viewinfo?token=' + token);
if (info.error) {
$('vFrame').remove();
const d = document.createElement('div');
d.className = 'vfail';
d.textContent = info.error;
document.querySelector('.vw').appendChild(d);
$('vDone').classList.add('on');
$('vTimer').textContent = '—';
setMsg('');
return;
}
left = info.dwell || 5;
$('vFrame').src = info.targetUrl;
setMsg('Watching: ' + (info.adName || 'member ad') + ' — stay on this tab.');
$('vTimer').textContent = left + 's left';
timer = setInterval(tick, 250);
})();
})();
+104
View File
@@ -0,0 +1,104 @@
// Public banner wall: a member's line banner plus their upline ladder, with
// their join link. The viral surface: members send traffic here, every visit
// puts eyes on the whole line.
(async function () {
await IAP.renderNav('');
const name = location.pathname.split('/').pop();
let w = null;
try { w = await (await fetch('/api/wall/' + encodeURIComponent(name))).json(); } catch (e) {}
const title = IAP.$('wallTitle');
const hostEl = IAP.$('wallHost');
if (!w || w.error) {
title.textContent = 'No wall under that name.';
IAP.$('wallJoin').href = '/my';
return;
}
const safeName = String(w.name).replace(/[&<>]/g, '');
title.innerHTML = safeName;
if (hostEl) hostEl.innerHTML = safeName;
IAP.$('bioHead').hidden = false;
if (w.avatarUrl) { const av = IAP.$('bioAvatar'); av.src = w.avatarUrl; av.hidden = false; }
if (w.badge) { const bb = IAP.$('bioBadge'); if (bb) { bb.src = w.badge.img; bb.title = w.badge.label + ' badge'; bb.hidden = false; } }
IAP.$('bioText').textContent = w.bio || 'Building a team on LinkSpin — join through this page and you\'re in my line.';
if (w.qrUrl) IAP.$('bioQr').src = w.qrUrl;
// social links
const SOC = { facebook: 'Facebook', twitter: 'X', youtube: 'YouTube', instagram: 'Instagram', tiktok: 'TikTok', telegram: 'Telegram', linkedin: 'LinkedIn', website: 'Website' };
// small line icons, one per platform (currentColor so they follow the pill color)
const svg = inner => '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' + inner + '</svg>';
const ICON = {
facebook: svg('<path d="M14 8h3V4h-3a4 4 0 0 0-4 4v2H7v4h3v7h4v-7h3l1-4h-4V8.5c0-.3.2-.5.5-.5z" fill="currentColor" stroke="none"/>'),
twitter: svg('<path d="M4 4l16 16M20 4L4 20"/>'),
youtube: svg('<rect x="2.5" y="6" width="19" height="12" rx="4"/><path d="M10 9.5v5l4.5-2.5z" fill="currentColor" stroke="none"/>'),
instagram: svg('<rect x="3" y="3" width="18" height="18" rx="5"/><circle cx="12" cy="12" r="4"/><circle cx="17.3" cy="6.7" r="1" fill="currentColor" stroke="none"/>'),
tiktok: svg('<path d="M14 4c.3 2.3 1.9 3.8 4.3 4v3.1c-1.6 0-3.1-.5-4.3-1.4v5.8a5 5 0 1 1-5-5v3.2a1.9 1.9 0 1 0 1.9 1.9V4z" fill="currentColor" stroke="none"/>'),
telegram: svg('<path d="M21 4L3 11.5l5.5 2L11 20l3-4.5L19 19z" fill="currentColor" stroke="none"/>'),
linkedin: svg('<circle cx="6" cy="5.5" r="1.6" fill="currentColor" stroke="none"/><path d="M4.5 9.5h3V20h-3z" fill="currentColor" stroke="none"/><path d="M11 9.5h3v1.6c.7-1.1 1.9-1.9 3.5-1.9 2.8 0 3.5 1.9 3.5 4.5V20h-3v-5.6c0-1.3-.3-2.3-1.6-2.3s-2.4 1-2.4 2.4V20h-3z" fill="currentColor" stroke="none"/>'),
website: svg('<circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3c3 3.5 3 14.5 0 18M12 3c-3 3.5-3 14.5 0 18"/>')
};
const sc = IAP.$('bioSocials');
if (sc && w.socials && typeof w.socials === 'object') {
const links = Object.keys(SOC).filter(k => w.socials[k]).map(k =>
'<a href="' + String(w.socials[k]).replace(/"/g, '%22') + '" target="_blank" rel="noopener nofollow me">' + (ICON[k] || '') + '<span>' + SOC[k] + '</span></a>');
sc.innerHTML = links.join('');
}
// intro video under the bio: YouTube / Vimeo embed, or a direct file
const bv = IAP.$('bioVideo');
const vurl = w.socials && typeof w.socials === 'object' ? String(w.socials.video || '') : '';
if (bv && vurl) {
let yt = /(?:youtube\.com\/(?:watch\?(?:.*&)?v=|shorts\/|embed\/)|youtu\.be\/)([A-Za-z0-9_-]{6,})/.exec(vurl);
let vm = /vimeo\.com\/(?:video\/)?(\d+)/.exec(vurl);
let inner = '';
if (yt) inner = '<iframe src="https://www.youtube-nocookie.com/embed/' + yt[1] + '?rel=0" title="Intro video" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen loading="lazy"></iframe>';
else if (vm) inner = '<iframe src="https://player.vimeo.com/video/' + vm[1] + '" title="Intro video" allow="autoplay; fullscreen; picture-in-picture" allowfullscreen loading="lazy"></iframe>';
else if (/\.(mp4|webm)(\?|$)/i.test(vurl)) inner = '<video src="' + vurl.replace(/"/g, '%22') + '" controls playsinline preload="metadata"></video>';
if (inner) { bv.innerHTML = '<p class="eyebrow" style="margin:0 0 8px">A word from ' + String(w.name).replace(/[&<>]/g, '') + '</p>' + inner; bv.hidden = false; }
}
IAP.$('wallCtaHead').textContent = 'Join ' + w.name + '’s line';
// per-wall viewed-position memory (survives revisits; anonymous-friendly)
const VKEY = 'iap-wall-' + name;
let viewed = {};
try { viewed = JSON.parse(localStorage.getItem(VKEY) || '{}'); } catch (e) {}
const DWELL = 10;
const grid = IAP.$('wallGrid');
IAP.adSlot('text', 'adSlotWallText'); IAP.adSlot('banner', 'adSlotWallBanner');
grid.innerHTML = '';
// join is gated: you must view every ad on the wall before you can join
const joinBtn = IAP.$('wallJoin'), gate = IAP.$('wallGate');
function updateGate() {
const viewable = w.ladder.filter(m => m.targetUrl).length;
const seen = w.ladder.filter((m, i) => m.targetUrl && viewed[i]).length;
if (viewable > 0 && seen < viewable) {
joinBtn.classList.add('disabled'); joinBtn.removeAttribute('href');
if (gate) { gate.hidden = false; gate.textContent = 'View all ' + viewable + ' ads above to unlock joining (' + seen + '/' + viewable + ' viewed).'; }
} else { joinBtn.href = w.joinUrl; joinBtn.classList.remove('disabled'); if (gate) gate.hidden = true; }
}
w.ladder.forEach((m, i) => {
const d = document.createElement('div');
d.className = 'wall-card';
const safe = String(m.name || 'member').replace(/[&<>]/g, '');
const creative = m.bannerUrl
? '<img src="' + m.bannerUrl + '" alt="' + safe + ' banner">'
: '<b>' + safe + '</b><br><span class="muted small">' + (m.targetUrl ? 'visit their site' : 'banner slot open') + '</span>';
d.innerHTML = '<div class="wall-pos">Position ' + (i + 1) + (m.own ? ' · this wall' : m.admin ? ' · LinkSpin' : ' · their line') + '</div>'
+ '<div class="wc-creative">' + creative + '</div>'
+ '<div class="wc-action"></div>'
+ '<div class="small muted" style="margin-top:8px">' + safe + '</div>';
const act = d.querySelector('.wc-action');
const done = () => { act.innerHTML = '<span class="wc-check">✓ viewed</span>'; };
if (!m.targetUrl) { act.innerHTML = '<span class="muted small">no ad yet</span>'; }
else if (viewed[i]) { done(); }
else {
const btn = document.createElement('button');
btn.className = 'btn small'; btn.textContent = 'View this ad';
btn.addEventListener('click', () => {
window.open(m.targetUrl, '_blank'); // real visit to the advertiser (no countdown)
viewed[i] = 1;
try { localStorage.setItem(VKEY, JSON.stringify(viewed)); } catch (e) {}
done(); updateGate();
});
act.appendChild(btn);
}
grid.appendChild(d);
});
updateGate();
})();
+284
View File
@@ -0,0 +1,284 @@
// Wallet plumbing via Reown AppKit — the universal connector every wallet is
// built for (all wallets, QR + mobile deep-links, working icons). AppKit is
// lazy-loaded from the CDN on first use; once connected we drive the raw
// EIP-1193 provider for chain switch, SIWE sign-in, and contract transactions.
window.IAPWallet = (function () {
const SEL_BUY = '0xfd095e97'; // buy(uint32,uint32)
const SEL_ACTIVATE = '0x1a93ec95'; // activate(uint32)
const pad = v => BigInt(v).toString(16).padStart(64, '0');
// Normalize a chainId to a decimal number. eth_chainId is meant to return a
// hex string, but some wallets return a number or a decimal string — compare
// numerically so a wallet's shape never crashes the flow.
const chainNum = v => {
if (v == null) return NaN;
if (typeof v === 'number') return v;
const s = String(v).trim();
return /^0x/i.test(s) ? parseInt(s, 16) : parseInt(s, 10);
};
const APPKIT_URL = 'https://cdn.jsdelivr.net/npm/@reown/appkit-cdn@1.8.23/dist/appkit.js';
let modal = null, akPromise = null, provider = null;
async function initAppKit(c) {
if (modal) return modal;
if (akPromise) return akPromise;
akPromise = (async () => {
const mod = await import(APPKIT_URL);
const { createAppKit, WagmiAdapter, networks } = mod;
const netMap = { 80002: networks.polygonAmoy, 137: networks.polygon };
const base = netMap[Number(c.chainId)] || networks.polygonAmoy;
// Override the chain's RPC with our clean public endpoint. AppKit's built-in
// networks advertise the WalletConnect RPC proxy
// (rpc.walletconnect.org/v1/?chainId=…&projectId=…) as the chain RPC, and
// wallets reject that query-string URL as "Invalid URL" when adding/switching
// the network — the cause of Trust's "Invalid URL", MetaMask's switch loop,
// and the failed mobile buy (the chain switch never completed).
const rpc = String(c.rpc || '').trim();
const net = rpc ? Object.assign({}, base, { rpcUrls: { default: { http: [rpc] }, public: { http: [rpc] } } }) : base;
// Accept the wallet's usual networks too, so AppKit doesn't trap the user in
// its own "Switch Network" modal — that modal loops on a testnet the wallet
// can't auto-add. We switch to `net` ourselves (wallet_addEthereumChain adds
// + switches in one step) and sendTx hard-guards the chain before signing.
const allNets = [net];
for (const k of ['polygon', 'mainnet']) {
try { const n = networks[k]; if (n && n.id !== net.id) allNets.push(n); } catch (e) {}
}
const projectId = String(c.walletConnectProjectId || '').trim();
const wagmiAdapter = new WagmiAdapter({ networks: allNets, projectId });
modal = createAppKit({
adapters: [wagmiAdapter], networks: allNets, projectId, defaultNetwork: net,
metadata: { name: c.siteName || 'LinkSpin', description: c.tagline || 'Advertise and earn, paid on-chain.',
url: location.origin, icons: [location.origin + '/logo-icon.png'] },
features: { analytics: false, email: false, socials: [] },
// Picker order: wallets without Trust's balance-proportion block go first.
// Trust Wallet is NOT excluded; it just drops out of the featured row into
// "All wallets" (Marty, 2026-09-09: move Trust to the bottom, not off).
featuredWalletIds: [
'c57ca95b47569778a828d19178114f4db188b89b763c899ba0be274e97267d96', // MetaMask
'a797aa35c0fadbfc1a53e7f675162ed5226968b44a19ee3d24385c64d1d3c393', // Phantom
'0b415a746fb9ee99cce155c2ceca0c6f6061b1dbca2d722b3ba16381d0562150', // SafePal
'fd20dc426fb37566d803205b19bbc1d4096b248ac04548e3cfb6b3a38bd033aa' // Coinbase Wallet
]
});
return modal;
})();
return akPromise;
}
function currentAddress() { try { return (modal && modal.getAddress && modal.getAddress()) || null; } catch (e) { return null; } }
function waitForConnection(timeoutMs) {
if (currentAddress()) return Promise.resolve(currentAddress());
return new Promise((resolve, reject) => {
let done = false, unsub = null;
const finish = (addr, err) => { if (done) return; done = true; try { unsub && unsub(); } catch (e) {} err ? reject(err) : resolve(addr); };
try { unsub = modal.subscribeAccount(acc => { if (acc && acc.isConnected && acc.address) finish(acc.address); }); } catch (e) {}
const t0 = Date.now();
(function poll() {
if (done) return;
const a = currentAddress();
if (a) return finish(a);
if (Date.now() - t0 > (timeoutMs || 180000)) return finish(null, new Error('Wallet connection timed out. Tap Connect and try again.'));
setTimeout(poll, 400);
})();
});
}
async function resolveProvider() {
for (let i = 0; i < 20; i++) {
try { const p = modal.getWalletProvider ? await Promise.resolve(modal.getWalletProvider()) : null; if (p && p.request) return p; } catch (e) {}
await new Promise(r => setTimeout(r, 300));
}
throw new Error('Could not reach your wallet. Try connecting again.');
}
function eth() { if (!provider) throw new Error('Connect your wallet first.'); return provider; }
const isInjected = () => !!(provider && window.ethereum && (provider === window.ethereum || provider.isMetaMask));
// the account the wallet will actually sign with: for an injected wallet (MetaMask
// extension) that is its active account, which can differ from AppKit's cached one
async function activeAddress(fallback) {
if (isInjected()) { try { const a = await provider.request({ method: 'eth_accounts' }); if (a && a[0]) return a[0]; } catch (e) {} }
return fallback || currentAddress();
}
// Force the wallet's own account picker. Injected wallets stay connected to the
// site, so a plain disconnect/reconnect never shows one: asking for permissions
// again makes MetaMask open its account-selection prompt, and whatever the user
// ticks becomes the active account. WalletConnect wallets fall back to a fresh
// session (the picker + the wallet app's own account choice).
async function pickAccount() {
const c = await IAP.getConfig();
await initAppKit(c);
const inj = window.ethereum;
if (inj && inj.request) {
try {
await inj.request({ method: 'wallet_requestPermissions', params: [{ eth_accounts: {} }] });
const accs = await inj.request({ method: 'eth_accounts' });
if (accs && accs[0]) { provider = inj; await ensureChain(c).catch(() => {}); return accs[0]; }
} catch (e) {
if (e && (e.code === 4001 || /reject|denied/i.test(String(e.message || '')))) throw new Error('You closed the account picker. Pick the account you want and try again.');
}
}
return freshConnect(c);
}
// A WalletConnect session can die underneath AppKit's cached "connected"
// state: the wallet app rejects or kills it (Trust does this after its own
// security stop), the phone sleeps, the relay drops. AppKit still reports an
// address, so the next request fails with a "disconnected" style error.
// Detect that, wipe the stale session, and re-open the picker for a fresh one.
const DEAD_RE = /disconnect|not connected|no matching key|session (topic|expired|deleted|not found)|call connect|please call connect|missing or invalid|relay/i;
const isDead = e => DEAD_RE.test(String((e && e.message) || e || ''));
async function freshConnect(c) {
try { IAP.status('Your wallet session dropped. Reconnect in the picker…'); } catch (e) {}
await disconnect();
try { await modal.open(); } catch (e) {}
const addr = await waitForConnection(180000);
try { if (modal && modal.close) await modal.close(); } catch (e) {}
provider = await resolveProvider();
await ensureChain(c).catch(() => {});
return addr;
}
async function connect() {
const c = await IAP.getConfig();
await initAppKit(c);
let addr = currentAddress();
if (!addr) {
// give AppKit a moment to rehydrate an existing session before popping the
// picker — otherwise an already-connected wallet still gets the modal
for (let i = 0; i < 8 && !addr; i++) { await new Promise(r => setTimeout(r, 150)); addr = currentAddress(); }
}
if (!addr) { try { await modal.open(); } catch (e) {} addr = await waitForConnection(180000); }
try { if (modal && modal.close) await modal.close(); } catch (e) {} // dismiss the picker once we're connected
provider = await resolveProvider();
// probe the session: a dead WalletConnect session answers with a disconnect error
try { await provider.request({ method: 'eth_chainId' }); }
catch (e) { if (isDead(e)) return freshConnect(c); }
await ensureChain(c).catch(() => {}); // AppKit already connects on the right network; switch is best-effort
return addr;
}
async function ensureChain(c) {
const want = '0x' + Number(c.chainId).toString(16);
let cur; try { cur = await eth().request({ method: 'eth_chainId' }); } catch (e) { return; }
if (chainNum(cur) === Number(c.chainId)) return;
try {
await eth().request({ method: 'wallet_switchEthereumChain', params: [{ chainId: want }] });
} catch (e) {
// any failure (not just 4902): try to add the chain — wallet_addEthereumChain
// adds AND switches in one step, which is what unblocks wallets that can't
// otherwise reach a chain they don't already have (e.g. a testnet).
const addParams = { chainId: want, chainName: c.chainName, nativeCurrency: { name: 'POL', symbol: 'POL', decimals: 18 }, rpcUrls: [c.rpc] };
if (c.explorer && /^https?:\/\//i.test(c.explorer)) addParams.blockExplorerUrls = [c.explorer];
try { await eth().request({ method: 'wallet_addEthereumChain', params: [addParams] }); } catch (e2) {}
}
}
// SIWE: challenge -> personal_sign -> verify (server sets the session cookie)
async function signIn(opts) {
const addr = (opts && opts.pick) ? await pickAccount() : await activeAddress(await connect());
const ch = await (await fetch('/api/auth/challenge', { method: 'POST',
headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ address: addr }) })).json();
if (ch.error) throw new Error(ch.error);
// hex-encode the message (Trust and others require hex for personal_sign)
const hexMsg = '0x' + Array.from(new TextEncoder().encode(ch.message)).map(b => b.toString(16).padStart(2, '0')).join('');
const sig = await eth().request({ method: 'personal_sign', params: [hexMsg, addr] });
const r = await (await fetch('/api/auth/verify', { method: 'POST',
headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ address: addr, signature: sig, asPosition: !!(opts && opts.asPosition) }) })).json();
if (r.error) throw new Error(r.error);
return r;
}
async function sendTx(data, valueWei) {
const c = await IAP.getConfig();
const addr = await connect();
// Hard chain guard: connect()'s switch is best-effort and some wallets (or
// AppKit's own modal) don't complete it. Never sign on the wrong chain —
// a value tx to a contract that doesn't exist on that chain would look like
// it "succeeded" while doing nothing.
const wantNum = Number(c.chainId);
let cur; try { cur = await eth().request({ method: 'eth_chainId' }); } catch (e) {}
if (!isNaN(chainNum(cur)) && chainNum(cur) !== wantNum) {
await ensureChain(c);
try { cur = await eth().request({ method: 'eth_chainId' }); } catch (e) {}
if (!isNaN(chainNum(cur)) && chainNum(cur) !== wantNum)
throw new Error('Your wallet is on the wrong network. Switch it to ' + (c.chainName || 'the correct network') + ', then try again.');
}
const tx = { from: await activeAddress(addr), to: c.contract, data };
if (valueWei) tx.value = '0x' + BigInt(valueWei).toString(16);
// Amoy's Bor nodes enforce a ~25 gwei minimum priority fee that MetaMask's
// own estimate misses ("gas tip below minimum"). Pull the network's correct
// fees from the server and set them so the tx clears the floor.
try {
const g = await (await fetch('/api/gas')).json();
if (g && g.maxPriorityFeePerGas && g.maxFeePerGas) {
tx.maxPriorityFeePerGas = g.maxPriorityFeePerGas;
tx.maxFeePerGas = g.maxFeePerGas;
}
} catch (e) {}
try {
return await eth().request({ method: 'eth_sendTransaction', params: [tx] });
} catch (e) {
if (!isDead(e)) throw e;
// session died between connect and send: reconnect once and resend
tx.from = await freshConnect(c);
return eth().request({ method: 'eth_sendTransaction', params: [tx] });
}
}
// pay it forward: send POL straight from the sponsor's wallet to a downline member's
// linked address. A native transfer, no contract, no site custody: the wallet app
// shows the prefilled recipient and amount and the sponsor confirms there.
async function sendPol(toAddress, valueWei) {
if (!/^0x[0-9a-fA-F]{40}$/.test(String(toAddress || ''))) throw new Error('That member has no wallet address on file yet.');
const c = await IAP.getConfig();
const addr = await connect();
const wantNum = chainNum(c.chainId);
let cur; try { cur = await eth().request({ method: 'eth_chainId' }); } catch (e) {}
if (!isNaN(chainNum(cur)) && chainNum(cur) !== wantNum) { await ensureChain(c); }
const tx = { from: await activeAddress(addr), to: toAddress, value: '0x' + BigInt(valueWei).toString(16) };
try { const g = await (await fetch('/api/gas')).json(); if (g && g.maxPriorityFeePerGas && g.maxFeePerGas) { tx.maxPriorityFeePerGas = g.maxPriorityFeePerGas; tx.maxFeePerGas = g.maxFeePerGas; } } catch (e) {}
try { return await eth().request({ method: 'eth_sendTransaction', params: [tx] }); }
catch (e) { if (!isDead(e)) throw e; tx.from = await freshConnect(c); return eth().request({ method: 'eth_sendTransaction', params: [tx] }); }
}
async function waitTx(hash) {
for (let i = 0; i < 90; i++) {
try {
const r = await (await fetch('/api/tx/' + hash)).json();
if (r.found) return { status: r.status, blockNumber: r.blockNumber };
} catch (e) { /* transient fetch failure (e.g. mobile app-switch) — keep polling */ }
await new Promise(res => setTimeout(res, 2500));
}
throw new Error('Timed out waiting for the transaction. Check the explorer.');
}
async function buy(productId, sponsorId, costWei) {
const value = BigInt(costWei) + BigInt(costWei) / 50n; // 2% oracle-drift pad; contract refunds excess
const data = SEL_BUY + pad(productId) + pad(sponsorId || 0);
const hash = await sendTx(data, value);
return { hash, receipt: await waitTx(hash) };
}
async function activate(sponsorId) {
const data = SEL_ACTIVATE + pad(sponsorId || 0);
const hash = await sendTx(data, null);
return { hash, receipt: await waitTx(hash) };
}
async function disconnect() {
// AppKit's disconnect can hang on the WalletConnect relay (esp. mobile) —
// never block on it, so the UI can't get stuck "disconnecting".
try { if (modal && modal.disconnect) await Promise.race([modal.disconnect(), new Promise(r => setTimeout(r, 1200))]); } catch (e) {}
provider = null;
try { Object.keys(localStorage).forEach(k => { if (/wc@2|walletconnect|w3m|wcm|reown|wagmi|appkit/i.test(k)) localStorage.removeItem(k); }); } catch (e) {}
}
// native balance of the connected wallet (pre-flight check before a buy)
async function balance(addr) {
await connect();
const h = await eth().request({ method: 'eth_getBalance', params: [addr || currentAddress(), 'latest'] });
return BigInt(h);
}
function walletName() { try { const w = modal && modal.getWalletInfo && modal.getWalletInfo(); return (w && w.name) || ''; } catch (e) { return ''; } }
return { connect, signIn, buy, activate, sendPol, waitTx, disconnect, balance, address: currentAddress, activeAddress, pickAccount, walletName };
})();
+23
View File
@@ -0,0 +1,23 @@
// Wallets + buying POL guide: members only.
(async function () {
try { await IAP.renderNav('training'); } catch (e) {}
let me = null;
try { me = await (await fetch('/api/me')).json(); } catch (e) {}
const signedIn = !!(me && me.signedIn && me.email);
document.getElementById('gate').style.display = signedIn ? 'none' : 'block';
document.getElementById('body').style.display = signedIn ? 'block' : 'none';
if (!signedIn) return;
// MoonPay: same signed link the Buy pane uses, prefilled with this member's wallet when one is linked
const mb = document.getElementById('wlMoonpay'), note = document.getElementById('wlMoonpayNote');
if (note && !me.address) note.textContent = 'Link your wallet first (Wallet tab) and MoonPay opens with your address already filled in. Without it you paste the address yourself.';
if (mb) mb.addEventListener('click', async () => {
let pol = 30;
try { const { products } = await (await fetch('/api/catalog')).json(); const p20 = (products || []).find(p => p.priceCents === 2000); if (p20 && p20.costWei) pol = Math.max(30, Math.ceil(Number(p20.costWei) / 1e18) + 3); } catch (e) {}
try {
const r = await (await fetch('/api/moonpay-url?pol=' + pol + (me.address ? '&address=' + encodeURIComponent(me.address) : ''))).json();
if (!r.url) throw new Error('no url');
window.open(r.url, '_blank', 'noopener');
IAP.status(r.signed ? 'MoonPay opened with your wallet address pre-filled. Choose the amount, pay, and the POL lands in your wallet.' : 'MoonPay opened. Choose POL on the Polygon network and paste your own wallet address as the destination.', 'ok');
} catch (e) { IAP.status('Could not open MoonPay. Try again in a minute.', 'bad'); }
});
})();