8ad2e01a74
The member card showed the wallet address on its own, directly above "Registered:
no (payouts off)". That reads as "he is set up" when he is not, and it caused a
real misread today on @mcbit1: wallet linked, memberId 0 on chain, every sale in
his line walking up to his sponsor while the row looked healthy.
Linking a wallet is a free signature that tells the site which address is theirs.
Switching on payouts is a separate transaction that creates the position. Only the
second one makes them payable, so the two states now say so:
Main wallet 0x30a7…5703 payouts OFF
Registered no wallet linked, but payouts were never switched on, so no
position exists. Sales in their line walk up to their sponsor and
lock there.
and the no-wallet case says "no wallet linked yet" instead of the same text.
qa/run.sh member: 0 bugs (the 2 warnings are pre-existing and unrelated).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
902 lines
88 KiB
JavaScript
902 lines
88 KiB
JavaScript
// 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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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@instantadpay.com';
|
||
|
||
// ── 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 InstantAdPay 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 InstantAdPay 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(); loadFraud();
|
||
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) + (a.suspended ? ' <span class="badge bad" title="suspended">suspended</span>' : '') + ((a.flags || []).length ? ' <span class="badge amber" title="' + esc((a.flags || []).join(', ')) + '">' + esc((a.flags || []).join(' ')) + '</span>' : '') + '</td>'
|
||
+ '<td class="act"><button class="btn small sec" data-mcopen="' + esc(a.email) + '">Open</button> <button class="btn small sec" data-susp="' + esc(a.email) + '" data-on="' + (a.suspended ? '1' : '0') + '">' + (a.suspended ? 'Unsuspend' : 'Suspend') + '</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-susp]'); if (!b) return;
|
||
const on = b.dataset.on === '1';
|
||
if (on) { if (!await IAP.confirmBox('Unsuspend ' + b.dataset.susp + '? They can sign in again.', { title: 'Unsuspend', ok: 'Unsuspend', cancel: 'Cancel' })) return; await api('/api/admin/members', { email: b.dataset.susp, suspend: false }, 'PATCH'); }
|
||
else { const why = await IAP.ask({ title: 'Suspend ' + b.dataset.susp, text: 'They will be signed out everywhere and cannot sign in. Reason (shown to admins only):', value: 'duplicate account', ok: 'Suspend' }); if (why === null || why === undefined) return; await api('/api/admin/members', { email: b.dataset.susp, suspend: true, reason: why, flags: ['multi-account'] }, 'PATCH'); }
|
||
loadMembers();
|
||
});
|
||
async function loadFraud() {
|
||
const box = $('fraudBox'); if (!box) return;
|
||
try {
|
||
const r = await api('/api/admin/fraud');
|
||
const grp = (title, list) => list.length ? '<p class="small" style="margin:6px 0 2px"><b>' + title + '</b></p>' + list.map(g => '<div class="small">' + esc(g.key) + ': ' + g.emails.map(x => esc(x)).join(', ') + '</div>').join('') : '';
|
||
box.innerHTML = '<h4 style="margin:0 0 4px">Duplicate signals</h4><p class="small muted" style="margin:0 0 6px">' + r.total + ' accounts with sign-in signals recorded (since 2026-09-16). Shared browser = same device cookie; shared IP within 30 days. Households are legal; two buying accounts on one browser are not.</p>'
|
||
+ grp('Shared browser', r.sharedDevice) + grp('Shared IP', r.sharedIp)
|
||
+ (r.flagged.length ? '<p class="small" style="margin:6px 0 2px"><b>Flagged</b></p>' + r.flagged.map(f => '<div class="small">' + esc(f.email) + ' [' + esc(f.flags.join(', ')) + ']' + (f.suspended ? ' suspended' : '') + '</div>').join('') : '')
|
||
+ (r.suspended.length ? '<p class="small" style="margin:6px 0 2px"><b>Suspended</b></p>' + r.suspended.map(x => '<div class="small">' + esc(x.email) + ' (' + esc(x.reason || '') + ', ' + when(x.at) + ')</div>').join('') : '')
|
||
+ (!r.sharedDevice.length && !r.sharedIp.length && !r.flagged.length && !r.suspended.length ? '<p class="small muted">Nothing shared or flagged yet.</p>' : '');
|
||
} catch (e) { box.innerHTML = '<p class="small bad">' + esc(e.message) + '</p>'; }
|
||
}
|
||
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 || '')],
|
||
// A linked wallet is NOT an active position: linking is a free signature, switching on
|
||
// payouts is a separate transaction that creates the position. The address used to sit here
|
||
// on its own, which reads as "he is set up" when he is not (Marty, 2026-09-17, @mcbit1).
|
||
['Main wallet', a.address
|
||
? '<span class="mono small">' + esc(a.address) + '</span>'
|
||
+ (ch && !ch.readError
|
||
? ' <span class="small" style="color:var(--mint)">payouts ON</span>'
|
||
: ' <span class="small" style="color:var(--bad)">payouts OFF</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(' → ') : '<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([
|
||
// Say WHICH of the two "not registered" states this is, and what it costs them, so the row
|
||
// above never gets read as "he is fine".
|
||
['Registered', ch
|
||
? (ch.readError ? 'read error' : 'yes, #' + ch.memberId + ' under ' + (ch.sponsorId ? '#' + ch.sponsorId + (d.names[ch.sponsorId] ? ' @' + esc(d.names[ch.sponsorId]) : '') : 'nobody'))
|
||
: (a.address
|
||
? '<span style="color:var(--bad)">no</span> <span class="muted small">wallet linked, but payouts were never switched on, so no position exists. Sales in their line walk up to their sponsor and lock there.</span>'
|
||
: '<span style="color:var(--bad)">no</span> <span class="muted small">no wallet linked yet.</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');
|
||
loadTrace(d);
|
||
}
|
||
// payment trace under the member card (2026-09-16): per purchase, who was paid / skipped and why
|
||
async function loadTrace(d) {
|
||
const ch = d.chain || {}; const mid = ch.memberId || (d.account && d.account.memberId) || 0;
|
||
const box = document.createElement('div'); box.id = 'mcTrace'; box.innerHTML = '<h4 style="margin:18px 0 6px">Payment trace</h4><p class="small muted">Reading the chain…</p>';
|
||
$('mcBody').appendChild(box);
|
||
if (!mid) { box.innerHTML = '<h4 style="margin:18px 0 6px">Payment trace</h4><p class="small muted">Not activated on the chain, nothing to trace.</p>'; return; }
|
||
try {
|
||
const r = await api('/api/admin/trace?who=' + mid);
|
||
const nm = id => id ? ('#' + id + (r.names[id] ? ' @' + esc(r.names[id]) : '')) : 'nobody';
|
||
let h = '<h4 style="margin:18px 0 6px">Payment trace</h4><p class="small muted" style="margin:0 0 6px">' + r.buyersNow + ' qualifying buyer' + (r.buyersNow === 1 ? '' : 's') + ' · every purchase this member was part of, newest first.</p>';
|
||
if (!r.purchases.length) h += '<p class="small muted">No purchases involving them yet.</p>';
|
||
for (const p of r.purchases) {
|
||
h += '<div style="border:1px solid rgba(255,255,255,.1);border-radius:8px;padding:8px 10px;margin:0 0 6px"><div class="small"><b>' + when(p.ts) + '</b> · ' + nm(p.buyerId) + ' bought $' + (p.priceCents / 100).toFixed(0) + ' (' + polOf(p.paidWei) + ' POL)' + (p.sponsorId ? ' · sponsor ' + nm(p.sponsorId) : '') + ' · <span class="muted">' + esc(p.tx.slice(0, 12)) + '…</span></div>';
|
||
for (const t of p.tiers) {
|
||
let line = '<b>L' + t.tier + ' ' + t.pct + '%</b>: ';
|
||
for (const x of t.skipped) line += '<span style="color:#f0a742">' + nm(x.id) + ' skipped (' + (x.reason === 'unqualified' ? 'had ' + x.buyersThen + ' of ' + (t.tier === 2 ? 2 : 5) + ' buyers then' : esc(x.reason)) + ')</span> → ';
|
||
line += t.paidTo ? '<span style="color:' + (t.paidTo === mid ? '#3ecf7a' : 'inherit') + '">' + nm(t.paidTo) + ' ' + polOf(t.amountWei) + ' POL</span>' : '<span class="muted">no catcher → company</span>';
|
||
h += '<div class="small" style="margin-top:3px">' + line + '</div>';
|
||
}
|
||
h += '<div class="small muted" style="margin-top:3px">platform ' + polOf(p.adminWei) + ' POL</div></div>';
|
||
}
|
||
box.innerHTML = h;
|
||
} catch (e) { box.innerHTML = '<h4 style="margin:18px 0 6px">Payment trace</h4><p class="small bad">' + esc(e.message) + '</p>'; }
|
||
}
|
||
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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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 →<br>signup</th><th>Registered</th><th>Signup →<br>registered</th><th>$20+<br>buyers</th><th>Signup →<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 →<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://instantadpay.com/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 instantadpay.com/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 (!await IAP.confirmBox('Remove email ' + (i + 1) + ' from the sequence?', { title: 'Remove step', ok: 'Remove', cancel: 'Keep' })) 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 (!await IAP.confirmBox('Replace the saved sequence with the built-in defaults?', { title: 'Reset sequence', ok: 'Replace', cancel: 'Cancel' })) 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();
|
||
})();
|