Profit and loss
read from the chain index; periods are by block (about 43,200 Polygon blocks a day)
diff --git a/public/assets/admin.js b/public/assets/admin.js
index 1793a9f..2f009ab 100644
--- a/public/assets/admin.js
+++ b/public/assets/admin.js
@@ -45,7 +45,7 @@
// ── 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 };
+ const loaders = { overview: loadOverview, house: loadHouse, campaigns: loadCampaigns, members: loadMembers, reports: loadReports, traffic: loadTraffic, pnl: loadPnl, settings: loadSettings };
function setPane(name) {
if (!TITLES[name]) name = 'overview';
document.querySelectorAll('.pane').forEach(p => { p.hidden = p.id !== 'pane-' + name; });
@@ -310,6 +310,21 @@
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'; } };
+ // ── traffic: referring domains / sources, landing pages, angles, by day ──
+ let trfDays = 30;
+ document.querySelectorAll('#trfRange [data-days]').forEach(b => b.addEventListener('click', () => { trfDays = Number(b.dataset.days); document.querySelectorAll('#trfRange [data-days]').forEach(x => x.classList.toggle('on', x === b)); loadTraffic().catch(e => IAP.status(e.message, 'bad')); }));
+ async function loadTraffic() {
+ const d = await (await fetch('/api/admin/traffic?days=' + trfDays)).json();
+ if (d.error) throw new Error(d.error);
+ const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
+ const n = v => Number(v || 0).toLocaleString();
+ $('trfSub').textContent = 'last ' + d.days + ' days · ' + n(d.totals.hits) + ' page views · ' + n(d.totals.joinViews) + ' join-page views · ' + n(d.totals.signups) + ' signups · ' + n(d.totals.buyers) + ' buyers';
+ $('trfSources').innerHTML = '
| Source | Page views | Join-page views | Signups | Registered | $20+ buyers |
|---|
'
+ + (d.sources.length ? d.sources.map(s => '
| ' + esc(s.source) + ' | ' + n(s.hits) + ' | ' + n(s.joinViews) + ' | ' + n(s.signups) + ' | ' + n(s.registered) + ' | ' + n(s.buyers) + ' |
').join('') : '
| Nothing recorded in this range yet. |
');
+ $('trfPaths').innerHTML = '
| Page | Views |
|---|
' + (d.paths.length ? d.paths.map(p => '
| ' + esc(p.path) + ' | ' + n(p.hits) + ' |
').join('') : '
| No page views yet. |
');
+ $('trfAngles').innerHTML = '
| Angle | Join-page views | Signups |
|---|
' + (d.angles.length ? d.angles.map(a => '
| ' + esc(a.angle) + ' | ' + n(a.views) + ' | ' + n(a.signups) + ' |
').join('') : '
| No angle data yet. |
');
+ $('trfDaily').innerHTML = '
| Day | Page views | Signups |
|---|
' + (d.daily.length ? d.daily.slice().reverse().map(x => '
| ' + esc(x.day) + ' | ' + n(x.hits) + ' | ' + n(x.signups) + ' |
').join('') : '
| Nothing yet. |
');
+ }
async function loadPnl() {
const r = await api('/api/admin/pnl?days=' + pnlDays);
const px = r.polUsd || 0;
diff --git a/public/assets/my.js b/public/assets/my.js
index a0e8cfa..1f5e05a 100644
--- a/public/assets/my.js
+++ b/public/assets/my.js
@@ -332,7 +332,7 @@
const levels = r.levels || [];
const total = levels.reduce((n, L) => n + L.members.length, 0);
if ($('treeSub')) $('treeSub').textContent = total ? total + ' across 3 levels' : 'share your link to grow';
- const chip = (m, q) => '
' + esc(String(m.name || 'M').replace(/^@/, '').slice(0, 12)) + '';
+ const chip = (m, q) => '
' + esc(String(m.name || 'M').replace(/^@/, '').slice(0, m.own ? 20 : 14)) + '';
const rowFor = lvl => {
const L = levels.find(x => x.level === lvl); const members = L ? L.members : [];
// gold = the contract counted this member as one of your qualifying buyers (not "the first N chips")
diff --git a/server.js b/server.js
index a576e0d..de0e8cc 100644
--- a/server.js
+++ b/server.js
@@ -26,6 +26,8 @@ const chatbot = require('./chatbot');
const coach = require('./coach'); // coaching view, nudges, digest, prospects, link stats
const tank = require('./tank'); // holding tank: unsponsored free members, adoptions, pay-it-forward
const legacy = require('./legacy'); // Faucet Wave / Tier One Ads bridge: welcome-back credits for listed emails
+const traffic = require('./traffic'); // public page views by referring domain (admin Traffic tab)
+const TRAFFIC_PAGES = new Set(['/', '/ledger', '/contract', '/shorts', '/plays', '/wallets', '/launch']);
let tankWaitCache = null; // dashboard: who is waiting for a sponsor (refreshed every minute)
const geo = require('./geo'); // viewer country -> tier (DB-IP lite), for campaign targeting
const burner = require('./burner'); // automatic on-chain credit burns (inert without ENGINE_KEY)
@@ -319,6 +321,7 @@ async function boot() {
coach.init({ dataDir: DATA_DIR, chain, accounts, mailer });
tank.init({ dataDir: DATA_DIR, chain, accounts, messages, mailer, site: 'https://instantadpay.com' });
legacy.init({ dataDir: DATA_DIR });
+ traffic.init({ dataDir: DATA_DIR });
setInterval(() => tankNotifyTick().catch(e => console.error('tank notify', e.message)), 15 * 60 * 1000); // new tank arrivals -> Telegram
geo.init({ dataDir: DATA_DIR }).catch(e => console.error('geo init', e.message));
setInterval(() => geo.refresh().catch(e => console.error('geo refresh', e.message)), 24 * 3600 * 1000); // monthly file, checked daily
@@ -603,6 +606,8 @@ const server = http.createServer(async (req, res) => {
const u = new URL(req.url, 'http://x');
const p = u.pathname;
+ // -- traffic log: public page views by referring domain (admin > Traffic)
+ if (req.method === 'GET' && (TRAFFIC_PAGES.has(p) || /^\/(join|from|wall)\/[^/]+$/.test(p))) traffic.hit(p, req.headers.referer, req.headers['user-agent']);
// -- join links: /join/
— LAST-touch cookie (Marty,
// 2026-09-10): the link a visitor opened most recently is the sponsor shown
// and used, and it locks the moment the account is created (accounts.ensure
@@ -1093,6 +1098,29 @@ const server = http.createServer(async (req, res) => {
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
return json(res, 200, await tank.adminView());
}
+ // -- admin Traffic tab: page views + join-page views + signups + $20 buyers, by referring
+ // domain / first-touch source, by landing page, by angle and by day (Marty, 2026-09-12)
+ if (p === '/api/admin/traffic' && req.method === 'GET') {
+ if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
+ const days = Math.min(365, Math.max(1, Number(u.searchParams.get('days')) || 30));
+ const since = Date.now() - days * 86400000, sinceDay = new Date(since).toISOString().slice(0, 10);
+ const hosts = {}, paths = {}, daily = {}, angles = {};
+ const H = k => (hosts[k] = hosts[k] || { source: k, hits: 0, joinViews: 0, signups: 0, registered: 0, buyers: 0 });
+ const Dy = k => (daily[k] = daily[k] || { day: k, hits: 0, signups: 0 });
+ for (const r of await traffic.rows(sinceDay)) { H(r.host).hits += r.n; paths[r.path] = (paths[r.path] || 0) + r.n; Dy(r.day).hits += r.n; }
+ for (const v of await coach.viewsSince(since)) { H(v.ref || 'direct').joinViews += 1; const a = angles[v.angle || 'plain'] = angles[v.angle || 'plain'] || { angle: v.angle || 'plain', views: 0, signups: 0 }; a.views += 1; }
+ const buyerIds = new Set(); for (const ev of chain.recentEvents(1e9)) if (ev.type === 'BuyerCounted' && ev.newBuyerId) buyerIds.add(ev.newBuyerId);
+ for (const a of await accounts.listAll(5000)) {
+ if (!a.created || a.created < since) continue;
+ const h = H(a.joinedRef || 'direct'); h.signups += 1; if (a.memberId) h.registered += 1; if (a.memberId && buyerIds.has(a.memberId)) h.buyers += 1;
+ Dy(new Date(a.created).toISOString().slice(0, 10)).signups += 1;
+ const k = a.joinedVia || 'plain'; angles[k] = angles[k] || { angle: k, views: 0, signups: 0 }; angles[k].signups += 1;
+ }
+ const sources = Object.values(hosts).sort((x, y) => (y.hits + y.joinViews + y.signups * 10) - (x.hits + x.joinViews + x.signups * 10));
+ return json(res, 200, { days, sources, paths: Object.entries(paths).map(([path, hits]) => ({ path, hits })).sort((x, y) => y.hits - x.hits),
+ angles: Object.values(angles).sort((x, y) => y.views - x.views), daily: Object.values(daily).sort((x, y) => x.day < y.day ? -1 : 1),
+ totals: { hits: sources.reduce((n, s) => n + s.hits, 0), joinViews: sources.reduce((n, s) => n + s.joinViews, 0), signups: sources.reduce((n, s) => n + s.signups, 0), buyers: sources.reduce((n, s) => n + s.buyers, 0) } });
+ }
if (p === '/api/my/coach' && req.method === 'GET') {
const s = await auth.fromRequest(req);
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
diff --git a/traffic.js b/traffic.js
new file mode 100644
index 0000000..580c57b
--- /dev/null
+++ b/traffic.js
@@ -0,0 +1,60 @@
+// Page-hit log for the admin Traffic tab (Marty, 2026-09-12): which referring domains send
+// visitors to the public pages, per day and per landing page. Counters are buffered in memory
+// and flushed every 30 s: MySQL table page_hits (day, host, path, n) when the DB is on, else
+// DATA_DIR/traffic.json. Obvious crawlers are skipped. Our own pages as referrer = 'direct'.
+const fs = require('fs');
+const path = require('path');
+const db = require('./db');
+let DATA_DIR = null, buf = {}, jsonStore = null, timer = null;
+const BOT = /bot|crawl|spider|slurp|facebookexternalhit|preview|monitor|curl\/|wget|python-requests|headless/i;
+
+function init(opts) {
+ DATA_DIR = opts.dataDir;
+ if (!timer) { timer = setInterval(() => flush().catch(() => {}), 30 * 1000); timer.unref(); }
+}
+function host(referer) {
+ try { const h = new URL(String(referer || '')).hostname.replace(/^www\./, '').toLowerCase(); return (!h || /instantadpay\.com$/.test(h)) ? 'direct' : h.slice(0, 80); } catch (e) { return 'direct'; }
+}
+function family(p) {
+ if (p === '/' || p === '') return 'home';
+ if (p.startsWith('/join/')) return 'join';
+ if (p.startsWith('/from/')) return p.slice(1, 40); // from/faucetwave, from/tieroneads
+ if (p.startsWith('/wall/')) return 'wall';
+ return p.replace(/^\//, '').slice(0, 40) || 'home'; // ledger, contract, shorts, plays ...
+}
+const day = ts => new Date(ts).toISOString().slice(0, 10);
+function hit(p, referer, ua) {
+ if (BOT.test(String(ua || ''))) return;
+ const k = day(Date.now()) + '|' + host(referer) + '|' + family(p);
+ buf[k] = (buf[k] || 0) + 1;
+}
+function loadJson() {
+ if (jsonStore) return jsonStore;
+ try { jsonStore = JSON.parse(fs.readFileSync(path.join(DATA_DIR, 'traffic.json'), 'utf8')); } catch (e) { jsonStore = {}; }
+ return jsonStore;
+}
+async function flush() {
+ const pending = buf; buf = {};
+ const keys = Object.keys(pending); if (!keys.length) return;
+ if (db.enabled()) {
+ for (const k of keys) { const [d, h, f] = k.split('|'); await db.q('INSERT INTO page_hits (day,host,path,n) VALUES (?,?,?,?) ON DUPLICATE KEY UPDATE n=n+VALUES(n)', [d, h, f, pending[k]]); }
+ return;
+ }
+ const J = loadJson();
+ for (const k of keys) J[k] = (J[k] || 0) + pending[k];
+ // keep the JSON store bounded: drop days older than 400
+ const cutoff = day(Date.now() - 400 * 86400000);
+ for (const k of Object.keys(J)) if (k.slice(0, 10) < cutoff) delete J[k];
+ fs.writeFileSync(path.join(DATA_DIR, 'traffic.json'), JSON.stringify(J));
+}
+// rows since a day (inclusive): [{day, host, path, n}]
+async function rows(sinceDay) {
+ await flush();
+ if (db.enabled()) {
+ const r = await db.q('SELECT day, host, path, n FROM page_hits WHERE day>=?', [sinceDay]);
+ return r.map(x => ({ day: x.day, host: x.host, path: x.path, n: Number(x.n) }));
+ }
+ const J = loadJson();
+ return Object.keys(J).filter(k => k.slice(0, 10) >= sinceDay).map(k => { const [d, h, p] = k.split('|'); return { day: d, host: h, path: p, n: J[k] }; });
+}
+module.exports = { init, hit, flush, rows, host, family };