// 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 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]) => '
No wall ads set. Walls fall back to a plain InstantAdPay card.
'; } 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('') : 'No emails yet. Add one below.
'; } 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('| ' + esc(c) + ' | ').join('') + '
|---|
| ').join('') + ' |
No settings saved yet.
'; } 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(); })();