// 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' ? '' : c.status === 'paused' ? '' : ''; 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' : '') + 'TypeCampaignStatusSpent / capDeliveryCreated'; } 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 => '').join(''); if (!$('hWatchSecs').options.length) $('hWatchSecs').innerHTML = (rates.videoTiers || []).map(t => '').join(''); if (!$('hFeatDays').options.length) $('hFeatDays').innerHTML = (rates.featuredDurations || [1, 2, 7]).map(d => '').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 = 'EmailUsernameMember #WalletSponsorCodeJoined' + 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) + '' + '').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 ? 'WhenCampaignReasonNoteBy' + reps.map(x => '' + when(x.ts) + '#' + x.campaignId + '' + esc(x.reason) + '' + esc(x.note || '') + '' + esc(x.reporter || 'anon') + '' + '' + (x.resolved ? 'resolved' : '') + '').join('') : 'No reports.'; const burns = b.pending || []; $('burnTable').innerHTML = burns.length ? 'WhenMemberCreditsRefBurn 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(); })();