// 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', pnl: 'Profit and loss', settings: 'Settings' }; const loaders = { overview: loadOverview, house: loadHouse, campaigns: loadCampaigns, members: loadMembers, reports: loadReports, 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]) => '
' + 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(); 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('') : 'No house ads yet. Place one above.'; } // wall fallback ads editor let wallAds = []; function drawWallAds() { const w = $('wallAdsList'); w.innerHTML = wallAds.map((a, i) => '
WALL AD ' + (i + 1) + '' + '
' + '

' + '

' + '

' + (a.bannerUrl ? '' : '') + '
').join('') || '

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('') : '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 #WalletSponsorViaCodeJoined' + 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.joinedVia || '') + '' + 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 ── // ── 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'; } }; 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 => '
' + esc(String(t[1])) + '
' + esc(t[0]) + '
' + esc(t[2]) + '
').join(''); $('pnlSplit').innerHTML = 'LinePOLUSD now' + [['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 => '' + x[0] + '' + pol(x[1]) + '' + usdOf(x[1], px) + '').join(''); const W = r.wallets || {}, B = r.balances || {}; $('pnlWallets').innerHTML = 'WalletAddressBalance' + [['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 => '' + x[0] + '' + esc(x[1]) + '' + (x[2] == null ? '?' : pol(x[2]) + ' POL') + '').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 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: 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) => '
' + '
EMAIL ' + (i + 1) + '' + 'send hours after sign-up (' + esc(whenLabel(st.hours)) + ')' + '' + '
' + '' + '' + '
').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('
' + (hint ? '' + esc(hint) + '' : '') + '
'); else if (typeof v === 'boolean') html.push('
'); else if (Array.isArray(v) && v.every(x => typeof x === 'number')) html.push('
' + (hint ? '' + esc(hint) + '' : '') + '
'); else if (Array.isArray(v) && v.every(x => x && typeof x === 'object')) { const cols = [...new Set(v.flatMap(x => Object.keys(x)))]; html.push('
' + (hint ? '' + esc(hint) + '' : '') + '' + cols.map(c => '').join('') + '' + v.map((row, i) => '' + cols.map(c => '').join('') + '').join('') + '
' + esc(c) + '
'); } else if (v && typeof v === 'object') { html.push('
' + (hint ? '' + esc(hint) + '' : '') + '
' + Object.entries(v).map(([sk, sv]) => '').join('') + '
'); } else html.push('
'); } 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 = { 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', pnlFixedMonthlyUsd: 'P&L: fixed monthly cost (USD)' }; function drawSite() { const wrap = $('siteForm'); wrap.innerHTML = Object.entries(siteObj).map(([k, v]) => '
' + esc(SITE_META[k] || humanize(k)) + '' + (typeof v === 'boolean' ? '' : typeof v === 'number' ? '' : '') + '
').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(); })();