diff --git a/accounts.js b/accounts.js
index e74ecb9..d2f513b 100644
--- a/accounts.js
+++ b/accounts.js
@@ -159,6 +159,13 @@ const J = {
this.save();
return { ok: true, account: pub(acct) };
},
+ async listAll(limit) { return Object.values(this.db.byEmail).sort((a, b) => (b.created || 0) - (a.created || 0)).slice(0, limit).map(pub); },
+ async setSponsorRef(e, ref) {
+ const acct = this.db.byEmail[e];
+ if (!acct) return { error: 'No such account.' };
+ acct.sponsorRef = ref; this.save();
+ return { ok: true, account: pub(acct) };
+ },
async count() { return Object.keys(this.db.byEmail).length; }
};
@@ -268,6 +275,12 @@ const D = {
}
return { ok: true, account: await this.byEmail(e) };
},
+ async listAll(limit) { const rows = await db.q('SELECT * FROM accounts ORDER BY created DESC LIMIT ?', [Number(limit) || 500]); return rows.map(rowPub); },
+ async setSponsorRef(e, ref) {
+ const r = await db.q('UPDATE accounts SET sponsor_ref=? WHERE email=?', [ref, e]);
+ if (!r.affectedRows) return { error: 'No such account.' };
+ return { ok: true, account: await this.byEmail(e) };
+ },
async count() { const r = await db.q('SELECT COUNT(*) n FROM accounts'); return Number(r[0].n); }
};
@@ -332,6 +345,10 @@ async function linkWallet(email, address) {
return impl().linkWallet(normEmail(email), a);
}
async function count() { return impl().count(); }
+// admin: newest-first account list, and re-pointing a member's sponsor (a
+// username, share code, or numeric member id: the same tokens join links use)
+async function listAll(limit = 500) { return impl().listAll(limit); }
+async function setSponsorRef(email, ref) { return impl().setSponsorRef(normEmail(email), String(ref || '').trim().toLowerCase().slice(0, 40)); }
// resolve a member's DIRECT sponsor account (the token they joined under)
async function sponsorOf(email) {
@@ -355,7 +372,7 @@ async function getChatSettings(email) {
return { available: a ? a.chatAvailable !== false : true, mutes: await impl().getMutes(String(email || '').toLowerCase()) };
}
-module.exports = { init, signup, login, ensure, byEmail, byAddress, byCode, byUsername,
+module.exports = { init, signup, login, ensure, byEmail, byAddress, byCode, byUsername, listAll, setSponsorRef,
setUsername, setMemberId, namesForMembers, listByReferrer, downline, linkWallet, count,
setLineBanner: (e, b, t) => impl().setLineBanner(String(e || '').toLowerCase(), b, t),
setProfile: (e, a, bio, socials) => impl().setProfile(String(e || '').toLowerCase(), a, bio, socials),
diff --git a/ads.js b/ads.js
index 0d14ba5..22324e7 100644
Binary files a/ads.js and b/ads.js differ
diff --git a/db.js b/db.js
index 96af0c5..eca2d31 100644
--- a/db.js
+++ b/db.js
@@ -119,6 +119,7 @@ async function bootstrap() {
await alterSafe('ALTER TABLE campaigns ADD COLUMN nas_served INT NOT NULL DEFAULT 0'); // NAS impressions already reconciled into spend
await alterSafe('ALTER TABLE campaigns ADD COLUMN expires BIGINT NULL'); // featured rotation end time
await alterSafe('ALTER TABLE campaigns ADD COLUMN starts BIGINT NULL'); // featured run start (booked day)
+ await alterSafe('ALTER TABLE campaigns ADD COLUMN house TINYINT NOT NULL DEFAULT 0'); // admin house ad: free, never charged
await q(`CREATE TABLE IF NOT EXISTS visit_seen (
campaign_id INT NOT NULL,
email VARCHAR(190) NOT NULL,
diff --git a/public/admin.html b/public/admin.html
new file mode 100644
index 0000000..db04643
--- /dev/null
+++ b/public/admin.html
@@ -0,0 +1,224 @@
+
+
+
+
+
+Admin | InstantAdPay
+
+
+
+
+
+
+
+
+
+
+
+ InstantAdPay
+ Admin sign in
+ Only the admin address can sign in here. A one-time code goes to that inbox.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Campaigns by format all owners
+
…
+
+
+
+
+
Quick actions
+
+ Place a house ad
+ Review campaigns
+ Members
+
+
+
+
+
+
+
+
House ads running free
+
+
+
+
+
+
+
All campaigns newest first
+
+
+
+
+
+
+
+
Members newest first
+
+
Sponsor = the token the account joined under (username, share code, or member #). Editing it re-points free referrals and future purchases. On-chain sponsorship is permanent once activated.
+
+
+
+
+
+
+
Ad reports members flagging ads
+
+
+
+
Pending burns on-chain credit burns waiting to be executed
+
+
+
+
+
+
+
Rates credit prices and rewards (JSON, merged on save)
+
+
+
Save rates
+
+
+
Site config public: everything here is served by /api/config, never put secrets in it
+
+
+
Save site config
+
+
+
+
+
+
+
+
+
+
+
diff --git a/public/assets/admin.js b/public/assets/admin.js
new file mode 100644
index 0000000..6c8fe60
--- /dev/null
+++ b/public/assets/admin.js
@@ -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]) => '' + esc(t) + ' ' + n + '
').join('') : 'No campaigns yet.';
+ const ch = o.chain || {};
+ $('ovChain').innerHTML = '' + esc(ch.chainName) + ' (chain ' + esc(ch.chainId) + ')
'
+ + '' + esc(ch.contract) + '
'
+ + (ch.explorer ? 'Open in explorer → ' : '');
+ }
+
+ // ── 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 ? ' ' : esc(c.title || c.name);
+ const act = c.status === 'active' ? 'Pause '
+ : c.status === 'paused' ? 'Resume ' : '';
+ return '#' + c.id + (c.house ? 'HOUSE ' : '') + ' '
+ + (showOwner ? '' + esc(c.house ? 'house' : c.owner) + ' ' : '')
+ + '' + esc(c.type) + ' '
+ + '' + esc(c.name) + '' + creative + '
' + esc(c.targetUrl) + ' '
+ + '' + esc(c.status) + ' '
+ + '' + (c.spent || 0).toLocaleString() + ' / ' + (c.budget || 0).toLocaleString() + '' + left.toLocaleString() + ' left
'
+ + '' + (c.imps || 0).toLocaleString() + (c.impsNas ? ' +' + c.impsNas + ' nas' : '') + '' + (c.clicks || 0) + ' clicks
'
+ + '' + when(c.created) + ' '
+ + '' + act + ' ';
+ }
+ function campHead(showOwner) {
+ return 'ID ' + (showOwner ? 'Owner ' : '') + 'Type Campaign Status Spent / cap Delivery Created ';
+ }
+ 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 => '' + esc(s.label || s.id) + ' (' + s.w + '×' + s.h + ') ').join('');
+ if (!$('hWatchSecs').options.length) $('hWatchSecs').innerHTML = (rates.videoTiers || []).map(t => 'Watch ' + t.secs + 's (viewer earns ' + t.reward + ') ').join('');
+ if (!$('hFeatDays').options.length) $('hFeatDays').innerHTML = (rates.featuredDurations || [1, 2, 7]).map(d => '' + d + ' day' + (d > 1 ? 's' : '') + ' ').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('') : 'No house ads yet. Place one above. ';
+ }
+ 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('') : 'Nothing matches. ';
+ }
+ $('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 = 'Email Username Member # Wallet Sponsor Code Joined '
+ + list.map(a => '' + esc(a.email) + ' ' + (a.username ? '@' + esc(a.username) : 'none ') + ' '
+ + '' + (a.memberId ? '#' + a.memberId : 'free ') + ' '
+ + '' + (a.address ? esc(a.address.slice(0, 8) + '…' + a.address.slice(-6)) : 'none ') + ' '
+ + '' + esc(a.sponsorRef || '') + ' ' + esc(a.code || '') + ' '
+ + '' + when(a.created) + ' '
+ + 'Sponsor ').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 ? 'When Campaign Reason Note By '
+ + reps.map(x => '' + when(x.ts) + ' #' + x.campaignId + ' ' + esc(x.reason) + ' ' + esc(x.note || '') + ' ' + esc(x.reporter || 'anon') + ' '
+ + '' + (x.resolved ? 'resolved' : 'Pause ad Resolve ') + ' ').join('')
+ : 'No reports. ';
+ const burns = b.pending || [];
+ $('burnTable').innerHTML = burns.length ? 'When Member Credits Ref Burn id '
+ + burns.map(x => '' + when(x.ts) + ' #' + x.memberId + ' ' + x.amount + ' ' + esc(x.ref) + ' ' + esc(x.id) + ' ').join('')
+ : 'Nothing pending. ';
+ }
+ 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();
+})();
diff --git a/public/assets/common.js b/public/assets/common.js
index e7cc605..a1010bd 100644
--- a/public/assets/common.js
+++ b/public/assets/common.js
@@ -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);
diff --git a/public/assets/my.js b/public/assets/my.js
index b6e5b4d..d1b850e 100644
--- a/public/assets/my.js
+++ b/public/assets/my.js
@@ -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;
diff --git a/public/contract.html b/public/contract.html
index 8dd465c..4e10436 100644
--- a/public/contract.html
+++ b/public/contract.html
@@ -141,7 +141,7 @@
Advertising services with a performance referral program. Not an investment product; no income guarantees. Crypto transactions are irreversible. Never spend what you cannot afford.
-
+