Admin portal (/admin) + free house ads; POL amounts show two decimals
- /admin: email magic-code sign-in allowlisted to ADMIN_EMAIL, 12h admin session (cookie iap.adm, persisted in the volume). Bearer ADMIN_PASSWORD API access still works. Member area shows an Admin link for that email. - House ads: admin places banner/text/login/solo/video/featured/visits campaigns owned by house@instantadpay.com that cost nothing; budget is only a delivery cap, spend is never charged or burned. - Admin APIs: overview, all campaigns (+pause/resume any), members (+re-point sponsor), reports (+resolve), pending burns, rates/site config get+patch, creative upload. - fmtPol rounds to two decimals everywhere (dashboard, toasts, prices). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,292 @@
|
||||
// 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() : '';
|
||||
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', settings: 'Settings' };
|
||||
const loaders = { overview: loadOverview, house: loadHouse, campaigns: loadCampaigns, members: loadMembers, reports: loadReports, 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 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();
|
||||
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>';
|
||||
}
|
||||
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 loadMembers() {
|
||||
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>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>' + esc(a.sponsorRef || '') + '</td><td class="mono small">' + esc(a.code || '') + '</td>'
|
||||
+ '<td class="small muted">' + when(a.created) + '</td>'
|
||||
+ '<td class="act"><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 = prompt('Sponsor for ' + b.dataset.spon + ' (username, share code, or member #). Leave blank to clear.', b.dataset.cur);
|
||||
if (v === null) 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'); }
|
||||
});
|
||||
|
||||
// ── reports + burns ──
|
||||
async function loadReports() {
|
||||
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 ──
|
||||
async function loadSettings() {
|
||||
const [r, s] = await Promise.all([api('/api/admin/rates'), api('/api/admin/site')]);
|
||||
$('ratesJson').value = JSON.stringify(r.rates || {}, null, 2);
|
||||
$('siteJson').value = JSON.stringify(s.site || {}, null, 2);
|
||||
}
|
||||
function saveJson(btnId, taId, errId, path, key) {
|
||||
$(btnId).addEventListener('click', busy($(btnId), async () => {
|
||||
$(errId).hidden = true;
|
||||
let obj;
|
||||
try { obj = JSON.parse($(taId).value); } catch (e) { $(errId).textContent = 'That is not valid JSON: ' + e.message; $(errId).hidden = false; return; }
|
||||
const r = await api(path, obj, 'PATCH');
|
||||
$(taId).value = JSON.stringify(r[key] || obj, null, 2);
|
||||
IAP.status('Saved.', 'ok');
|
||||
}));
|
||||
}
|
||||
saveJson('ratesSave', 'ratesJson', 'ratesErr', '/api/admin/rates', 'rates');
|
||||
saveJson('siteSave', 'siteJson', 'siteErr', '/api/admin/site', 'site');
|
||||
|
||||
render();
|
||||
})();
|
||||
@@ -7,11 +7,11 @@ window.IAP = (function () {
|
||||
if (!config) config = await (await fetch('/api/config')).json();
|
||||
return config;
|
||||
}
|
||||
// POL amounts display with two decimals (rounded half-up), e.g. 523.39
|
||||
function fmtPol(wei) {
|
||||
const s = BigInt(wei).toString().padStart(19, '0');
|
||||
const whole = s.slice(0, -18) || '0';
|
||||
const frac = s.slice(-18, -12).replace(/0+$/, '');
|
||||
return whole + (frac ? '.' + frac : '');
|
||||
const cents = (BigInt(wei) + 5000000000000000n) / 10000000000000000n; // wei -> hundredths of a POL
|
||||
const s = cents.toString().padStart(3, '0');
|
||||
return s.slice(0, -2) + '.' + s.slice(-2);
|
||||
}
|
||||
const fmtUsd = cents => '$' + (cents / 100).toFixed(2);
|
||||
|
||||
|
||||
@@ -471,6 +471,7 @@
|
||||
$('authArea').hidden = !!signedIn;
|
||||
$('memberArea').hidden = !signedIn;
|
||||
if (!signedIn) return;
|
||||
if ($('adminLink')) $('adminLink').hidden = !me.isAdmin; // admin portal link, only for ADMIN_EMAIL
|
||||
setPane(location.hash.slice(1) || 'overview');
|
||||
$('campGate').hidden = !!me.memberId;
|
||||
$('earnGate').hidden = !!me.memberId;
|
||||
|
||||
Reference in New Issue
Block a user