02f742f90a
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
844 lines
82 KiB
JavaScript
844 lines
82 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();
|
||
const r = await api('/api/admin/members');
|
||
allMembers = r.members || [];
|
||
drawMembers();
|
||
}
|
||
function drawMembers() {
|
||
const q = ($('memFilter').value || '').trim().toLowerCase();
|
||
const list = allMembers.filter(a => !q || [a.email, a.username, a.memberId, a.sponsorRef, a.address, a.code].join(' ').toLowerCase().includes(q));
|
||
$('memSub').textContent = list.length + ' of ' + allMembers.length;
|
||
$('memTable').innerHTML = '<tr><th>Email</th><th>Username</th><th>Member #</th><th>Wallet</th><th>Sponsor</th><th>Positions</th><th>Via</th><th>Code</th><th>Joined</th><th></th></tr>'
|
||
+ list.map(a => '<tr><td>' + esc(a.email) + '</td><td>' + (a.username ? '@' + esc(a.username) : '<span class="muted">none</span>') + '</td>'
|
||
+ '<td>' + (a.memberId ? '#' + a.memberId : '<span class="muted">free</span>') + '</td>'
|
||
+ '<td class="mono small">' + (a.address ? esc(a.address.slice(0, 8) + '…' + a.address.slice(-6)) : '<span class="muted">none</span>') + '</td>'
|
||
+ '<td>' + (a.sponsorName ? esc(a.sponsorName) + (a.sponsorVia === 'code' ? ' <span class="muted small" title="joined through this share code">via code ' + esc(a.sponsorRef) + '</span>' : a.sponsorVia === 'member #' ? ' <span class="muted small">via #' + esc(a.sponsorRef) + '</span>' : '') : a.sponsorRef ? '<span class="badge amber" title="this token points at nobody; the member will move to the holding tank">dead link: ' + esc(a.sponsorRef) + '</span>' : '<span class="muted">none</span>') + '</td><td class="small" title="linked Qualified Start positions' + (a.positionIds && a.positionIds.length ? ': #' + a.positionIds.join(', #') : '') + '">' + (a.positions ? a.positions : '<span class="muted">0</span>') + '</td><td class="small muted">' + esc(a.joinedVia || '') + '</td><td class="mono small">' + esc(a.code || '') + '</td>'
|
||
+ '<td class="small muted when">' + when(a.created) + '</td>'
|
||
+ '<td class="act"><button class="btn small sec" data-mcopen="' + esc(a.email) + '">Open</button> <button class="btn small sec" data-spon="' + esc(a.email) + '" data-cur="' + esc(a.sponsorRef || '') + '">Sponsor</button></td></tr>').join('');
|
||
}
|
||
$('memFilter').addEventListener('input', drawMembers);
|
||
document.addEventListener('click', async e => {
|
||
const b = e.target.closest('[data-spon]'); if (!b) return;
|
||
const v = await IAP.ask({ title: 'Sponsor for ' + b.dataset.spon, text: 'Username, share code, or member #. Leave blank to clear.', value: b.dataset.cur, ok: 'Save' });
|
||
if (v === null || v === undefined) return;
|
||
try {
|
||
await api('/api/admin/members', { email: b.dataset.spon, sponsorRef: v.trim() }, 'PATCH');
|
||
IAP.status('Sponsor updated.', 'ok');
|
||
await loadMembers();
|
||
} catch (err) { IAP.status(err.message, 'bad'); }
|
||
});
|
||
|
||
// ── member card: search, drill down, act (Marty, 2026-09-13) ──
|
||
let mcCur = null;
|
||
const polOf = w => { try { return (Number(BigInt(w || '0') / 10n ** 14n) / 10000).toLocaleString(undefined, { maximumFractionDigits: 2 }); } catch (e) { return '0'; } };
|
||
const ago = ts => { if (!ts) return 'never'; const d = Date.now() - Number(ts); const h = Math.floor(d / 3600000); return h < 1 ? Math.max(1, Math.floor(d / 60000)) + ' min ago' : h < 48 ? h + ' h ago' : Math.floor(h / 24) + ' days ago'; };
|
||
const memLink = (email, label) => '<a href="#" data-mcopen="' + esc(email) + '">' + esc(label) + '</a>';
|
||
async function openMember(q) {
|
||
const msg = $('memSearchMsg'); msg.hidden = true;
|
||
let d;
|
||
try { d = await api('/api/admin/member?q=' + encodeURIComponent(q)); } catch (e) { msg.textContent = e.message; msg.hidden = false; msg.className = 'small bad'; return; }
|
||
renderMember(d);
|
||
}
|
||
function kv(rows) { return '<table class="adm-table kv">' + rows.map(r => '<tr><th style="width:170px">' + r[0] + '</th><td>' + r[1] + '</td></tr>').join('') + '</table>'; }
|
||
function renderMember(d) {
|
||
mcCur = d; const a = d.account; $('memHits').hidden = true;
|
||
$('memCard').hidden = false; document.querySelectorAll('#pane-members > .card').forEach(c => { if (c.id !== 'memCard' && c.id !== 'memSearchCard') c.hidden = true; });
|
||
$('mcName').textContent = (a.username ? '@' + a.username : a.email) + (a.memberId ? ' · member #' + a.memberId : ' · free member');
|
||
$('mcSub').textContent = 'joined ' + when(a.created) + ' · last seen ' + ago(a.lastSeen);
|
||
$('mcWall').hidden = !a.username; if (a.username) $('mcWall').href = '/wall/' + a.username;
|
||
const ch = d.chain, cr = d.credits, t = d.totals;
|
||
const level = ch && !ch.readError ? (ch.buyerCount >= 5 ? 'level 3 (5+ buyers)' : ch.buyerCount >= 2 ? 'level 2 (2 buyers)' : 'level 1') : '';
|
||
let h = '<div class="grid c2">';
|
||
h += '<div><h4 style="margin:0 0 6px">Identity</h4>' + kv([
|
||
['Email', esc(a.email)], ['Username', a.username ? '@' + esc(a.username) : '<span class="muted">not set</span>'], ['Share code', esc(a.code || '')],
|
||
['Main wallet', a.address ? '<span class="mono small">' + esc(a.address) + '</span>' : '<span class="muted">none linked</span>'],
|
||
['Extra positions', d.positions.length ? d.positions.map(p => '<span class="mono small">' + esc(p.address.slice(0, 8) + '…' + p.address.slice(-6)) + '</span>' + (p.memberId ? ' = #' + p.memberId : ' (unregistered)')).join('<br>') : '<span class="muted">none</span>'],
|
||
['Sponsor (site)', d.upline.length ? memLink(d.upline[0].email, d.upline[0].name) + ' <span class="muted small">token ' + esc(a.sponsorRef || '') + '</span>' : (a.sponsorRef ? '<span class="muted">unresolved: ' + esc(a.sponsorRef) + '</span>' : '<span class="muted">none (company)</span>')],
|
||
['Upline chain', d.upline.length > 1 ? d.upline.map(u => memLink(u.email, u.name)).join(' → ') : '<span class="muted">-</span>'],
|
||
['Joined via', esc(a.joinedVia || 'join page') + (a.joinedRef ? ' from ' + esc(a.joinedRef) : '')],
|
||
['Line banner', a.lineTargetUrl ? '<a href="' + esc(a.lineTargetUrl) + '" target="_blank" rel="noopener">' + esc(a.lineTargetUrl.slice(0, 50)) + '</a>' : '<span class="muted">not set</span>'],
|
||
['Chat', a.chatAvailable ? 'available' : 'switched off']]) + '</div>';
|
||
h += '<div><h4 style="margin:0 0 6px">On-chain and money</h4>' + kv([
|
||
['Registered', ch ? (ch.readError ? 'read error' : 'yes, #' + ch.memberId + ' under ' + (ch.sponsorId ? '#' + ch.sponsorId + (d.names[ch.sponsorId] ? ' @' + esc(d.names[ch.sponsorId]) : '') : 'nobody')) : '<span class="muted">no (payouts off)</span>'],
|
||
['Qualifying buyers', ch && !ch.readError ? ch.buyerCount + ' · ' + level : '-'],
|
||
['Packages bought', t.purchases + (t.purchases ? ' · $' + (t.spentCents / 100).toFixed(0) + ' · ' + polOf(t.spentWei) + ' POL' : '')],
|
||
['Payouts received', t.payoutsIn + (t.payoutsIn ? ' · ' + polOf(t.receivedWei) + ' POL' : '')],
|
||
['Credits', cr ? cr.available.toLocaleString() + ' available · ' + cr.inCampaigns.toLocaleString() + ' in campaigns · ' + cr.total.toLocaleString() + ' total' : '<span class="muted">-</span>'],
|
||
['Earned pool', d.earnedSplit ? d.earnedSplit.total.toLocaleString() + ' (' + (d.earnedSplit.grade || 0).toLocaleString() + ' purchased-grade)' : '-'],
|
||
['Old-site account', d.legacy ? 'had a ' + (d.legacy.brand === 'both' ? 'Faucet Wave and Tier One Ads' : d.legacy.brand === 'tier1ads' ? 'Tier One Ads' : 'Faucet Wave') + ' account (' + d.legacy.seg + ') · welcome-back credits ' + (d.legacy.grant ? d.legacy.grant.credits + ' issued ' + when(d.legacy.grant.at) : 'not issued (joined outside the legacy bridge)') : '<span class="muted">none on record</span>'],
|
||
['Promo codes', d.promos.length ? d.promos.map(p => esc(p.code) + ' (' + p.credits + ', ' + when(p.ts) + ')').join('<br>') : '<span class="muted">none</span>'],
|
||
['Drip', d.drip ? (d.drip.stopped ? 'stopped' : 'step ' + d.drip.step + ', next ' + when(d.drip.next_at)) + (d.drip.angle ? ' · ' + esc(d.drip.angle) : '') : '<span class="muted">-</span>'],
|
||
['Holding tank', d.tank ? (d.tank.waiting ? '<b>waiting for a sponsor</b>' : 'not in tank') + (d.tank.adoptedBy.length ? ' · adopted by ' + d.tank.adoptedBy.map(x => memLink(x.email, x.name)).join(', ') : '') + (d.tank.adopted.length ? ' · adopted ' + d.tank.adopted.map(x => memLink(x.email, x.name)).join(', ') : '') : '-'],
|
||
['Earning', d.earning ? 'today ' + d.earning.today + '/5' + (d.earning.claimed ? ' claimed' : '') + ' · streak day ' + d.earning.streakDay + (d.activeDays14 !== undefined ? ' · active ' + d.activeDays14 + ' of last 14 days, ' + d.claims14 + ' claims' : '') : '-'],
|
||
['Visits / videos / chat', (d.visits || 0) + ' verified visits · ' + (d.videos || 0) + ' video watches · ' + (d.messageCount || 0) + ' messages']]) + '</div></div>';
|
||
// line
|
||
h += '<h4 style="margin:18px 0 6px">Line (' + d.lineCounts.join(' / ') + ')</h4>';
|
||
if (!d.line.length) h += '<p class="muted small">Nobody in their line yet.</p>';
|
||
for (const L of d.line) {
|
||
h += '<p class="small muted" style="margin:8px 0 4px">Level ' + L.level + ' · ' + L.members.length + '</p><div class="tablewrap"><table class="adm-table"><tr><th>Member</th><th>Member #</th><th>Wallet</th><th>Bought</th><th>Qualified</th><th>Joined</th><th>Last seen</th></tr>'
|
||
+ L.members.map(m => '<tr><td>' + memLink(m.email, m.name) + (L.level === 1 ? '<br><span class="muted small">' + esc(m.email) + '</span>' : '') + '</td><td>' + (m.memberId ? '#' + m.memberId : '<span class="muted">free</span>') + '</td><td>' + (m.wallet ? 'yes' : '<span class="muted">no</span>') + '</td><td>' + (m.bought ? 'yes' : '<span class="muted">no</span>') + '</td><td>' + (m.qualified ? '<span class="chip-t on">yes</span>' : '') + '</td><td class="small muted">' + when(m.joined) + '</td><td class="small muted">' + ago(m.lastSeen) + '</td></tr>').join('') + '</table></div>';
|
||
}
|
||
// purchases + payouts + campaigns
|
||
h += '<div class="grid c2" style="margin-top:18px"><div><h4 style="margin:0 0 6px">Purchases</h4><div class="tablewrap"><table class="adm-table"><tr><th>When</th><th>Position</th><th>Package</th><th>Paid</th><th>Tx</th></tr>'
|
||
+ (d.purchases.length ? d.purchases.map(p => '<tr><td class="small">' + when(p.ts) + '</td><td>#' + p.buyerId + '</td><td>$' + (p.priceCents / 100).toFixed(0) + ' · ' + Number(p.credits || 0).toLocaleString() + ' cr</td><td>' + polOf(p.paidWei) + ' POL</td><td><a href="/tx/' + esc(p.tx) + '" target="_blank" rel="noopener" class="mono small">' + esc(p.tx.slice(0, 10)) + '…</a></td></tr>').join('') : '<tr><td colspan="5" class="muted">No purchases.</td></tr>') + '</table></div></div>';
|
||
h += '<div><h4 style="margin:0 0 6px">Payouts received</h4><div class="tablewrap"><table class="adm-table"><tr><th>When</th><th>From</th><th>Tier</th><th>Amount</th></tr>'
|
||
+ (d.received.length ? d.received.map(r => '<tr><td class="small">' + when(r.ts) + '</td><td>#' + r.buyerId + (d.names[r.buyerId] ? ' @' + esc(d.names[r.buyerId]) : '') + '</td><td>' + r.tier + '</td><td>' + polOf(r.amountWei) + ' POL</td></tr>').join('') : '<tr><td colspan="4" class="muted">Nothing received yet.</td></tr>') + '</table></div></div></div>';
|
||
h += '<h4 style="margin:18px 0 6px">Campaigns (' + d.campaigns.length + ')</h4><div class="tablewrap"><table class="adm-table"><tr><th>#</th><th>Type</th><th>Status</th><th>Budget</th><th>Spent</th><th>Views</th><th>Clicks</th><th>Created</th></tr>'
|
||
+ (d.campaigns.length ? d.campaigns.map(c => '<tr><td>' + c.id + '</td><td>' + esc(c.type) + '</td><td>' + esc(c.status) + '</td><td>' + Number(c.budget || 0).toLocaleString() + '</td><td>' + Number(c.spent || 0).toLocaleString() + '</td><td>' + Number(c.views || 0).toLocaleString() + '</td><td>' + Number(c.clicks || 0).toLocaleString() + '</td><td class="small muted">' + when(c.created) + '</td></tr>').join('') : '<tr><td colspan="8" class="muted">No campaigns.</td></tr>') + '</table></div>';
|
||
$('mcBody').innerHTML = h;
|
||
if (location.hash !== '#members') history.replaceState(null, '', '#members');
|
||
}
|
||
function closeMember() { $('memCard').hidden = true; document.querySelectorAll('#pane-members > .card').forEach(c => { c.hidden = false; }); }
|
||
// live matches while typing: any part of the username, email, member #, share code or wallet
|
||
let memHitList = [];
|
||
function memMatches(q) {
|
||
q = q.toLowerCase();
|
||
return allMembers.filter(a => [a.username, a.email, a.memberId ? '#' + a.memberId : '', a.memberId, a.code, a.address, a.sponsorName].filter(Boolean).join(' ').toLowerCase().includes(q)).slice(0, 12);
|
||
}
|
||
async function memTypeahead() {
|
||
const q = $('memSearch').value.trim();
|
||
if (!allMembers.length) { try { const r = await api('/api/admin/members'); allMembers = r.members || []; } catch (e) {} }
|
||
if (q.length < 2) { $('memHits').hidden = true; memHitList = []; return; }
|
||
memHitList = memMatches(q);
|
||
$('memHits').innerHTML = memHitList.length ? memHitList.map(a => '<button type="button" data-mcopen="' + esc(a.email) + '" style="display:flex;gap:12px;width:100%;text-align:left;background:transparent;border:0;border-bottom:1px solid var(--line);padding:8px 12px;color:inherit;cursor:pointer;font:inherit"><b style="min-width:140px">' + (a.username ? '@' + esc(a.username) : '<span class="muted">no username</span>') + '</b><span>' + esc(a.email) + '</span><span class="muted">' + (a.memberId ? '#' + a.memberId : 'free') + (a.sponsorName ? ' · under ' + esc(a.sponsorName) : '') + '</span></button>').join('')
|
||
: '<p class="muted small" style="margin:0;padding:8px 12px">No member matches that.</p>';
|
||
$('memHits').hidden = false;
|
||
}
|
||
$('memSearch').addEventListener('input', memTypeahead);
|
||
$('memSearch').addEventListener('focus', memTypeahead);
|
||
$('memOpen').addEventListener('click', () => { const q = $('memSearch').value.trim(); if (!q) return; if (memHitList.length) openMember(memHitList[0].email); else openMember(q); });
|
||
$('memSearch').addEventListener('keydown', e => { if (e.key === 'Enter') $('memOpen').click(); if (e.key === 'Escape') $('memHits').hidden = true; });
|
||
document.addEventListener('click', e => { if (!e.target.closest('#memSearchCard')) $('memHits').hidden = true; });
|
||
$('mcBack').addEventListener('click', closeMember);
|
||
document.addEventListener('click', e => { const l = e.target.closest('[data-mcopen]'); if (l) { e.preventDefault(); openMember(l.dataset.mcopen); } });
|
||
document.querySelectorAll('[data-mcact]').forEach(b => b.addEventListener('click', busy(b, async () => {
|
||
if (!mcCur) return; const a = mcCur.account, act = b.dataset.mcact; let body = null;
|
||
if (act === 'username') { const v = await IAP.ask({ title: 'Username for ' + a.email, text: '3-20 letters, numbers or underscore. Changing it breaks any invite links they already handed out.', value: a.username || '', ok: 'Save' }); if (v === null || v === undefined) return; body = { username: v }; }
|
||
if (act === 'sponsor') { const v = await IAP.ask({ title: 'Sponsor for ' + (a.username ? '@' + a.username : a.email), text: 'Username, share code or member #. Blank = no sponsor (company). Re-points free referrals and future purchases; on-chain sponsorship never changes.', value: a.sponsorRef || '', ok: 'Save' }); if (v === null || v === undefined) return; body = { sponsorRef: v }; }
|
||
if (act === 'wallet') { const v = await IAP.ask({ title: 'Main wallet for ' + a.email, text: 'Paste the 0x address that should be their main wallet (the one that paid, if a purchase came from an unlinked account). The member number is re-read from the chain. Blank = unlink.', value: a.address || '', ok: 'Swap' }); if (v === null || v === undefined) return; if (!await IAP.confirmBox('Swap the main wallet for ' + a.email + ' to ' + (v.trim() || 'nothing') + '?', { title: 'Sure?', ok: 'Swap it', cancel: 'Cancel' })) return; body = { address: v }; }
|
||
if (act === 'credits') { const v = await IAP.ask({ title: 'Grant credits to ' + (a.username ? '@' + a.username : a.email), text: 'Whole number of earned-pool credits (1 credit = 1 cent of delivery). They can spend them on campaigns right away.', type: 'number', value: '', placeholder: '250', ok: 'Grant' }); if (!v) return; const note = await IAP.ask({ title: 'Reason (kept in the server log)', value: '', placeholder: 'e.g. refund for broken banner', ok: 'Grant' }); body = { grantCredits: v, note: note || '' }; }
|
||
if (act === 'delete') {
|
||
if (a.memberId) { IAP.status('Registered members cannot be deleted; their position is on-chain.', 'bad'); return; }
|
||
if (!await IAP.confirmBox('Delete the free account ' + a.email + '? Their sign-in, referrals link and credits go away. There is no undo.', { title: 'Delete account', ok: 'Delete', cancel: 'Keep it' })) return;
|
||
await api('/api/admin/member?email=' + encodeURIComponent(a.email), undefined, 'DELETE'); IAP.status('Account deleted.', 'ok'); closeMember(); loadMembers().catch(() => {}); return;
|
||
}
|
||
if (!body) return;
|
||
const d = await api('/api/admin/member', Object.assign({ email: a.email }, body), 'PATCH');
|
||
renderMember(d); IAP.status('Saved.', 'ok'); loadMembers().catch(() => {});
|
||
})));
|
||
|
||
// ── every admin table: click a header to sort (numbers sort as numbers), inputs with
|
||
// class "tfilter" filter the table named in data-for ──
|
||
document.addEventListener('click', e => {
|
||
const th = e.target.closest('.adm-table th'); if (!th || th.closest('table').classList.contains('kv')) return;
|
||
const table = th.closest('table'), hdr = th.parentElement, idx = [...hdr.children].indexOf(th);
|
||
const rows = [...table.querySelectorAll('tr')].filter(r => r !== hdr && r.children.length > 1);
|
||
const num = s => { const t = String(s).replace(/[$,%\s]/g, '').replace(/…$/, ''); return t !== '' && !isNaN(t) ? Number(t) : null; };
|
||
const dir = th.dataset.dir === 'asc' ? 'desc' : 'asc';
|
||
hdr.querySelectorAll('th').forEach(x => { delete x.dataset.dir; x.classList.remove('sort-asc', 'sort-desc'); });
|
||
th.dataset.dir = dir; th.classList.add('sort-' + dir);
|
||
rows.sort((r1, r2) => { const a = (r1.children[idx] || {}).textContent || '', b = (r2.children[idx] || {}).textContent || ''; const na = num(a), nb = num(b); const c = na !== null && nb !== null ? na - nb : a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' }); return dir === 'asc' ? c : -c; });
|
||
rows.forEach(r => (hdr.parentElement).appendChild(r));
|
||
});
|
||
document.addEventListener('input', e => {
|
||
const inp = e.target.closest('.tfilter'); if (!inp) return;
|
||
const table = $(inp.dataset.for); if (!table) return;
|
||
const q = inp.value.trim().toLowerCase(); let shown = 0;
|
||
[...table.querySelectorAll('tr')].forEach((r, i) => { if (i === 0 || r.querySelector('th')) return; const hit = !q || r.textContent.toLowerCase().includes(q); r.hidden = !hit; if (hit) shown++; });
|
||
const c = inp.parentElement.querySelector('.tfilter-count'); if (c) c.textContent = q ? shown + ' shown' : '';
|
||
});
|
||
|
||
// ── release notes + roadmap (Marty, 2026-09-14) ──
|
||
async function loadReleases() {
|
||
loadUpdates();
|
||
const d = await api('/api/admin/releases');
|
||
$('rnSub').textContent = d.notes.length + ' notes'; $('rmSub').textContent = d.roadmap.length + ' items';
|
||
$('rnTable').innerHTML = '<tr><th>Date</th><th>Title</th><th>Tags</th><th></th></tr>' + (d.notes.length ? d.notes.map(n => '<tr><td class="small">' + esc(n.date) + '</td><td><b>' + esc(n.title) + '</b></td><td class="small">' + esc(n.tags.join(', ')) + '</td><td class="act"><button type="button" class="btn small sec" data-rnedit="' + esc(n.id) + '">Edit</button> <button type="button" class="btn small sec" data-rndel="' + esc(n.id) + '">Delete</button></td></tr>').join('') : '<tr><td colspan="4" class="muted">No notes yet.</td></tr>');
|
||
$('rmTable').innerHTML = '<tr><th>Status</th><th>Title</th><th>ETA</th><th>#</th><th></th></tr>' + (d.roadmap.length ? d.roadmap.map(r => '<tr><td class="small">' + esc(r.status) + '</td><td><b>' + esc(r.title) + '</b>' + (r.note ? '<br><span class="muted small">' + esc(r.note) + '</span>' : '') + '</td><td class="small">' + esc(r.eta || '') + '</td><td class="small">' + (r.order || '') + '</td><td class="act"><button type="button" class="btn small sec" data-rmedit="' + esc(r.id) + '">Edit</button> <button type="button" class="btn small sec" data-rmdel="' + esc(r.id) + '">Delete</button></td></tr>').join('') : '<tr><td colspan="5" class="muted">Nothing on the roadmap yet.</td></tr>');
|
||
$('rnTable').querySelectorAll('[data-rnedit]').forEach(b => b.addEventListener('click', () => { const n = d.notes.find(x => x.id === b.dataset.rnedit); if (!n) return; $('rnId').value = n.id; $('rnTitle').value = n.title; $('rnDate').value = n.date; $('rnTags').value = n.tags.join(', '); $('rnBody').value = n.body; $('rnTitle').focus(); }));
|
||
$('rmTable').querySelectorAll('[data-rmedit]').forEach(b => b.addEventListener('click', () => { const r = d.roadmap.find(x => x.id === b.dataset.rmedit); if (!r) return; $('rmId').value = r.id; $('rmTitle').value = r.title; $('rmStatus').value = r.status; $('rmEta').value = r.eta || ''; $('rmOrder').value = r.order || ''; $('rmNote').value = r.note || ''; $('rmTitle').focus(); }));
|
||
$('rnTable').querySelectorAll('[data-rndel]').forEach(b => b.addEventListener('click', async () => { if (!await IAP.confirmBox('Delete this release note?', { ok: 'Delete', cancel: 'Keep' })) return; await api('/api/admin/releases?kind=notes&id=' + encodeURIComponent(b.dataset.rndel), undefined, 'DELETE'); loadReleases(); }));
|
||
$('rmTable').querySelectorAll('[data-rmdel]').forEach(b => b.addEventListener('click', async () => { if (!await IAP.confirmBox('Delete this roadmap item?', { ok: 'Delete', cancel: 'Keep' })) return; await api('/api/admin/releases?kind=roadmap&id=' + encodeURIComponent(b.dataset.rmdel), undefined, 'DELETE'); loadReleases(); }));
|
||
}
|
||
// ── member update emails (Marty, 2026-09-14) ──
|
||
async function loadUpdates() {
|
||
if (!$('updCard')) return;
|
||
try {
|
||
const d = await api('/api/admin/updates');
|
||
$('updSub').textContent = (d.mailer ? '' : 'mailer not configured · ') + (d.lastSentAt ? 'last send ' + new Date(d.lastSentAt).toLocaleString() : 'nothing sent yet') + (d.running ? ' · sending now' : '');
|
||
$('updAudience').innerHTML = Object.entries(d.audiences).map(([k, v]) => '<option value="' + k + '">' + esc(v) + ' (' + (d.counts[k] || 0) + ')</option>').join('');
|
||
$('updNotes').innerHTML = d.notes.length ? d.notes.map(n => '<label class="small" style="display:flex;gap:8px;align-items:flex-start"><input type="checkbox" value="' + esc(n.id) + '"' + (n.fresh ? ' checked' : '') + '><span>' + esc(n.title) + ' <span class="muted">' + esc(n.date) + '</span></span></label>').join('') : '<span class="muted small">No release notes yet.</span>';
|
||
$('updLog').innerHTML = '<tr><th>When</th><th>Subject</th><th>Audience</th><th>Sent</th></tr>' + (d.sends.length ? d.sends.map(x => '<tr><td class="small">' + new Date(x.ts).toLocaleString() + '</td><td>' + esc(x.subject) + '</td><td class="small">' + esc(d.audiences[x.audience] || x.audience) + '</td><td class="small">' + x.sent + ' of ' + x.total + (x.skipped ? ' · ' + x.skipped + ' opted out' : '') + (x.failed ? ' · ' + x.failed + ' failed' : '') + (x.unknown ? ' · ' + x.unknown + ' unchecked (Sendy gave no answer)' : '') + (x.status === 'running' ? ' · running' : '') + '</td></tr>').join('') : '<tr><td colspan="4" class="muted small">None yet.</td></tr>');
|
||
if (d.draft && !updDraftLoaded) { updDraftLoaded = true; $('updSubject').value = d.draft.subject || ''; $('updIntro').value = d.draft.intro || ''; $('updClosing').value = d.draft.closing || ''; if (d.draft.audience) $('updAudience').value = d.draft.audience; $('updNotes').querySelectorAll('input').forEach(i => { i.checked = (d.draft.noteIds || []).includes(i.value); }); $('updSub').textContent += ' · draft loaded (saved ' + new Date(d.draft.savedAt).toLocaleString() + ')'; }
|
||
if (d.running) setTimeout(loadUpdates, 4000);
|
||
} catch (e) { $('updSub').textContent = e.message; }
|
||
}
|
||
let updDraftLoaded = false;
|
||
const updInput = () => ({ subject: $('updSubject').value, intro: $('updIntro').value, closing: $('updClosing').value, noteIds: [...$('updNotes').querySelectorAll('input:checked')].map(i => i.value), audience: $('updAudience').value });
|
||
if ($('updCard')) {
|
||
$('updSaveDraft').addEventListener('click', busy($('updSaveDraft'), async () => { await api('/api/admin/updates/draft', updInput()); IAP.status('Draft saved.', 'ok'); }));
|
||
$('updPreview').addEventListener('click', busy($('updPreview'), async () => { const r = await api('/api/admin/updates/preview', updInput()); $('updPre').hidden = false; $('updPre').textContent = 'Subject: ' + r.subject + '\n\n' + r.text; }));
|
||
$('updTest').addEventListener('click', busy($('updTest'), async () => { const r = await api('/api/admin/updates/send', Object.assign(updInput(), { test: true })); IAP.status('Test sent to ' + r.to + '.', 'ok'); }));
|
||
$('updSend').addEventListener('click', busy($('updSend'), async () => {
|
||
const inp = updInput(); if (!inp.noteIds.length) { IAP.status('Pick at least one note.', 'bad'); return; }
|
||
const opt = $('updAudience').selectedOptions[0].textContent;
|
||
if (!(await IAP.confirmBox('Send this update to ' + opt + '? One email per member, opt-outs skipped.', { title: 'Send member update', ok: 'Send now', cancel: 'Not yet' }))) return;
|
||
const r = await api('/api/admin/updates/send', inp); IAP.status('Sending to ' + r.total + ' members in the background.', 'ok'); loadUpdates();
|
||
}));
|
||
}
|
||
$('rnClear').addEventListener('click', () => { ['rnId', 'rnTitle', 'rnDate', 'rnTags', 'rnBody'].forEach(id => { $(id).value = ''; }); });
|
||
$('rmClear').addEventListener('click', () => { ['rmId', 'rmTitle', 'rmEta', 'rmOrder', 'rmNote'].forEach(id => { $(id).value = ''; }); $('rmStatus').value = 'planned'; });
|
||
$('rnSave').addEventListener('click', busy($('rnSave'), async () => { await api('/api/admin/releases', { kind: 'note', id: $('rnId').value || null, title: $('rnTitle').value, date: $('rnDate').value, tags: $('rnTags').value, body: $('rnBody').value }); IAP.status('Note saved.', 'ok'); $('rnClear').click(); loadReleases(); }));
|
||
$('rmSave').addEventListener('click', busy($('rmSave'), async () => { await api('/api/admin/releases', { kind: 'roadmap', id: $('rmId').value || null, title: $('rmTitle').value, status: $('rmStatus').value, eta: $('rmEta').value, order: $('rmOrder').value, note: $('rmNote').value }); IAP.status('Roadmap item saved.', 'ok'); $('rmClear').click(); loadReleases(); }));
|
||
|
||
// ── reports + burns ──
|
||
// ── profit and loss ──
|
||
let pnlDays = 30;
|
||
const pol = w => { try { return (Number(BigInt(w || '0') / 10n ** 14n) / 10000).toLocaleString(undefined, { maximumFractionDigits: 2 }); } catch (e) { return '0'; } };
|
||
const usdOf = (w, px) => { try { return '$' + ((Number(BigInt(w || '0') / 10n ** 14n) / 10000) * px).toLocaleString(undefined, { maximumFractionDigits: 0 }); } catch (e) { return '$0'; } };
|
||
// ── traffic: referring domains / sources, landing pages, angles, by day ──
|
||
let trfDays = 30;
|
||
document.querySelectorAll('#trfRange [data-days]').forEach(b => b.addEventListener('click', () => { trfDays = Number(b.dataset.days); document.querySelectorAll('#trfRange [data-days]').forEach(x => x.classList.toggle('on', x === b)); loadTraffic().catch(e => IAP.status(e.message, 'bad')); }));
|
||
async function loadPromos() {
|
||
const d = await (await fetch('/api/admin/promos')).json();
|
||
if (d.error) throw new Error(d.error);
|
||
const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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 (!confirm('Remove email ' + (i + 1) + '?')) return; dripSeq.splice(i, 1); drawDrip(); return; }
|
||
if (b.dataset.act === 'up' && i > 0) { [dripSeq[i - 1], dripSeq[i]] = [dripSeq[i], dripSeq[i - 1]]; drawDrip(); return; }
|
||
if (b.dataset.act === 'down' && i < dripSeq.length - 1) { [dripSeq[i + 1], dripSeq[i]] = [dripSeq[i], dripSeq[i + 1]]; drawDrip(); return; }
|
||
if (b.dataset.act === 'test') {
|
||
b.disabled = true;
|
||
try { await saveDrip(); await api('/api/admin/drip/test', { step: i }); IAP.status('Email ' + (i + 1) + ' sent to your inbox.', 'ok'); }
|
||
catch (err) { IAP.status(err.message, 'bad'); }
|
||
b.disabled = false;
|
||
}
|
||
});
|
||
async function saveDrip() {
|
||
$('dripErr').hidden = true;
|
||
const seq = readDrip();
|
||
const r = await api('/api/admin/drip', { sequence: seq }, 'PATCH').catch(err => { $('dripErr').textContent = err.message; $('dripErr').hidden = false; throw err; });
|
||
dripSeq = r.sequence; drawDrip();
|
||
return r;
|
||
}
|
||
$('dripSave').addEventListener('click', busy($('dripSave'), async () => { await saveDrip(); IAP.status('Sequence saved.', 'ok'); await loadSettings(); }));
|
||
$('dripAdd').addEventListener('click', () => {
|
||
dripSeq = readDrip();
|
||
const last = dripSeq[dripSeq.length - 1];
|
||
dripSeq.push({ hours: last ? Number(last.hours) + 48 : 24, subject: '', body: '\n\nMarty\n\n{{footer}}' });
|
||
drawDrip();
|
||
const cards = document.querySelectorAll('#dripSteps .drip-step'); const c = cards[cards.length - 1]; if (c) { c.scrollIntoView({ behavior: 'smooth', block: 'center' }); c.querySelector('.ds-subject').focus(); }
|
||
});
|
||
$('dripReset').addEventListener('click', busy($('dripReset'), async () => {
|
||
if (!confirm('Replace the saved sequence with the built-in defaults?')) return;
|
||
const r = await api('/api/admin/drip', { reset: true }, 'PATCH');
|
||
dripSeq = r.sequence; drawDrip(); IAP.status('Defaults restored.', 'ok'); await loadSettings();
|
||
}));
|
||
|
||
// rates: labels + hints for the known keys; anything unknown still gets a plain field
|
||
const RATE_META = {
|
||
bannerBatch: ['Banner: views per batch', 'impressions counted before a banner campaign is charged'],
|
||
bannerCreditsPerBatch: ['Banner: credits per batch', 'charged to the advertiser per batch'],
|
||
textBatch: ['Text ad: views per batch', ''], textCreditsPerBatch: ['Text ad: credits per batch', ''],
|
||
loginCreditsPerDay: ['Login ad: credits per day', 'flat daily charge while active'],
|
||
loginDwellSeconds: ['Login ad: seconds shown', 'full-screen interstitial after sign-in'],
|
||
burnBatchMin: ['On-chain burn batch (credits)', 'accrued spend is burned once it reaches this'],
|
||
welcomeCredits: ['Welcome credits', 'granted after the welcome tour'],
|
||
dailyViewTarget: ['Daily view set (ads)', 'ads a member views for the daily claim'],
|
||
dailyClaimCredits: ['Daily claim (credits)', 'paid when the set is complete'],
|
||
viewDwellSeconds: ['Ad view: seconds per ad', 'the countdown; server-enforced'],
|
||
soloCostPerRecipient: ['Solo ad: credits per recipient', ''], soloMinRecipients: ['Solo ad: minimum recipients', ''],
|
||
soloReadCredits: ['Solo ad: reader reward (credits)', ''], soloReadCapPerDay: ['Solo ad: rewarded reads per day', ''], soloReadDwellSeconds: ['Solo ad: seconds to read', ''],
|
||
videoWatchCapPerDay: ['Video: rewarded watches per day', ''],
|
||
featuredPerDay: ['Featured link: credits per day', ''], featuredSlotsPerDay: ['Featured link: slots per day', ''], featuredWindowDays: ['Featured link: booking window (days)', ''],
|
||
featuredDurations: ['Featured link: durations offered (days)', 'comma-separated'],
|
||
visitCostPerVisit: ['Verified visit: credits per visit', ''], visitMinPack: ['Verified visit: smallest pack', ''], visitReward: ['Verified visit: viewer reward (credits)', ''], visitDwellSeconds: ['Verified visit: seconds on site', ''], visitCapPerDay: ['Verified visit: rewarded visits per day', ''],
|
||
videoTiers: ['Video ad tiers', 'watch length → advertiser cost → viewer reward'],
|
||
milestoneBonus: ['Milestone bonuses (credits)', 'one-time, when a member reaches each step']
|
||
};
|
||
const humanize = k => k.replace(/([A-Z])/g, ' $1').replace(/^./, c => c.toUpperCase());
|
||
function drawRates() {
|
||
const wrap = $('ratesForm'); const html = [];
|
||
for (const [k, v] of Object.entries(ratesObj)) {
|
||
const [label, hint] = RATE_META[k] || [humanize(k), ''];
|
||
if (typeof v === 'number') html.push('<div class="rf"><label>' + esc(label) + '</label><input type="number" step="any" data-rk="' + esc(k) + '" value="' + esc(v) + '">' + (hint ? '<span class="hint">' + esc(hint) + '</span>' : '') + '</div>');
|
||
else if (typeof v === 'boolean') html.push('<div class="rf"><label>' + esc(label) + '</label><label class="small"><input type="checkbox" data-rk="' + esc(k) + '"' + (v ? ' checked' : '') + ' style="width:auto"> on</label></div>');
|
||
else if (Array.isArray(v) && v.every(x => typeof x === 'number')) html.push('<div class="rf"><label>' + esc(label) + '</label><input data-rk="' + esc(k) + '" data-kind="numlist" value="' + esc(v.join(', ')) + '">' + (hint ? '<span class="hint">' + esc(hint) + '</span>' : '') + '</div>');
|
||
else if (Array.isArray(v) && v.every(x => x && typeof x === 'object')) {
|
||
const cols = [...new Set(v.flatMap(x => Object.keys(x)))];
|
||
html.push('<div class="rf wide"><label>' + esc(label) + '</label>' + (hint ? '<span class="hint">' + esc(hint) + '</span>' : '')
|
||
+ '<table class="tiers" data-rk="' + esc(k) + '" data-kind="table"><tr>' + cols.map(c => '<th>' + esc(c) + '</th>').join('') + '</tr>'
|
||
+ v.map((row, i) => '<tr>' + cols.map(c => '<td><input type="number" step="any" data-col="' + esc(c) + '" value="' + esc(row[c] == null ? '' : row[c]) + '"></td>').join('') + '</tr>').join('') + '</table></div>');
|
||
} else if (v && typeof v === 'object') {
|
||
html.push('<div class="rf wide"><label>' + esc(label) + '</label>' + (hint ? '<span class="hint">' + esc(hint) + '</span>' : '') + '<div class="sub-grid" data-rk="' + esc(k) + '" data-kind="object">'
|
||
+ Object.entries(v).map(([sk, sv]) => '<label>' + esc(humanize(sk)) + '<input type="number" step="any" data-sub="' + esc(sk) + '" value="' + esc(sv) + '"></label>').join('') + '</div></div>');
|
||
} else html.push('<div class="rf"><label>' + esc(label) + '</label><input data-rk="' + esc(k) + '" value="' + esc(v == null ? '' : v) + '"></div>');
|
||
}
|
||
wrap.innerHTML = html.join('');
|
||
}
|
||
function readRates() {
|
||
const out = {};
|
||
document.querySelectorAll('#ratesForm [data-rk]').forEach(el => {
|
||
const k = el.dataset.rk, kind = el.dataset.kind;
|
||
if (kind === 'numlist') out[k] = el.value.split(/[\s,]+/).filter(Boolean).map(Number).filter(n => !isNaN(n));
|
||
else if (kind === 'table') out[k] = [...el.querySelectorAll('tr')].slice(1).map(tr => { const o = {}; tr.querySelectorAll('input[data-col]').forEach(i => { o[i.dataset.col] = Number(i.value); }); return o; });
|
||
else if (kind === 'object') { const o = {}; el.querySelectorAll('input[data-sub]').forEach(i => { o[i.dataset.sub] = Number(i.value); }); out[k] = o; }
|
||
else if (el.type === 'checkbox') out[k] = !!el.checked;
|
||
else if (el.type === 'number') out[k] = Number(el.value);
|
||
else out[k] = el.value;
|
||
});
|
||
return out;
|
||
}
|
||
$('ratesSave').addEventListener('click', busy($('ratesSave'), async () => {
|
||
$('ratesErr').hidden = true;
|
||
try { const r = await api('/api/admin/rates', readRates(), 'PATCH'); ratesObj = r.rates || readRates(); drawRates(); IAP.status('Rates saved.', 'ok'); }
|
||
catch (e) { $('ratesErr').textContent = e.message; $('ratesErr').hidden = false; }
|
||
}));
|
||
|
||
// site settings: key / value rows; booleans as checkboxes, numbers stay numbers
|
||
const SITE_META = { noPayoutIds: 'No-payout positions (member #s, comma): linkage only, no buys from them, no joins routed under them', siteName: 'Site name', tagline: 'Tagline', rehearsal: 'Testnet rehearsal banner', defaultSponsorId: 'Fallback sponsor (member #)', walletConnectProjectId: 'WalletConnect project id', moonpayPublicKey: 'MoonPay public key', telegramBotToken: 'Telegram proof feed: bot token', telegramChatId: 'Telegram proof feed: chat id', telegramTopicId: 'Telegram proof feed: topic id (optional)', telegramEvents: 'Telegram proof feed: events (payouts | payouts+purchases | all)', telegramCtaUrl: 'Telegram proof feed: join link under each post', aiCreditsPerGen: 'AI Copy Engine: credits per generation after the free allowance', aiFreeSurge: 'AI Copy Engine: free generations a month at Surge', aiFreeCircuit: 'AI Copy Engine: free generations a month at Circuit', aiFreeNexus: 'AI Copy Engine: free generations a month at Nexus', 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();
|
||
})();
|