From cd26161db5df7b1bdd0ff63a1dfb76b05aa3bf3d Mon Sep 17 00:00:00 2001 From: martbost Date: Fri, 11 Sep 2026 12:02:40 -0500 Subject: [PATCH] Country-tier targeting for campaigns: DB-IP lite lookup, tier lists in Settings, Show-to tiers on every format, geo-restricted ads kept off the network, per-country serve stats Co-Authored-By: Claude Fable 5.1 --- ads.js | Bin 82541 -> 85390 bytes chatbot.js | 1 + db.js | 5 ++ geo.js | 120 ++++++++++++++++++++++++++++++++++++++++++++ public/assets/my.js | 11 +++- public/my.html | 9 +++- server.js | 21 +++++--- 7 files changed, 158 insertions(+), 9 deletions(-) create mode 100644 geo.js diff --git a/ads.js b/ads.js index 22897717f3a779df1f90f7b6823445cb0aab1801..b5615eebf6874e1b908c9e22146a22a708e06efd 100644 GIT binary patch delta 2588 zcma)7TWl0%6wXj6q!vQ$9YT8++U^Vv(;Eog?NUf+OQE((g#^}Gx3hn{)1A&NGqc^) zQdbibUIf9PxJ1NDd@&?OV2zD0h>3|WCi-f8AU+Ti0|Z}qGSPEpcDK8*1UK28+5d7b z-}jw!?!UY4{FQart0Ao!iKL^WBs-BE8TXZBznpEsM({e;9==#)VK=e_-{Myy6ytTD*&Hx56347qx>=cO#mwJCjTv9^Y+VKyz`w6d{h6% zTJ#783>_PP$<@IWo$2cjtw%D5YW{WqwzP<<3tLK2pk%wuB1$eiDJhAh9TS%@^8ny2 zau9&AxElR30X z>8~2yiLZ)Hy+Dk}FfCE*m{ttf1rf|xv2{&lBC2|i6wDlG5bClY@AGYHBH0m=3CAYA z(o=E!$Bm(jHMD|RkH_;u=Ld&>YJutGd}0{2r7Dzt$yHU&Rvx~v@j0&xSR{|^x#M># z*2?(C^^@KF%E|ldQIhV?+o%4lXqWTtSS+#R)&rr0{a6ZqptbYsryBW%_&R>=^)D;1 zJowS6-=7f+uROdr8-C-ed{oZ$60Z-%#@2WLM!pzLM%c zRS8X{ew#2&JwJ;EGHIWnoP>ZoVrXDdd!vI=0R;M2&bwRuUK0e@^Z?k*T42!=S`>Bd z8p%{|7q;;9qsr{jAAa0fo$oxPTV8c9F%Tq)?<$&MIAHDE%&`}ZsHR371|kg*gE=YF ziqllVryfnAm+a!b`xW(NyLxu&;hRFGH%>B}D+GV`pT83wm7Y5JCl4U=UX#$aUyn+5HJ1+WYtv$Y`-7qi!&9RZV?RuZ!xoe%=ELZ2}KlgEQKldzx^v(KQ@B?2is zlMbOJlkCQY0br9JXBd-|#}Jd3auu`m$FKne3LqdLlTA(^ljg}&A#!DOb#iVXaBpvH zE^K9PXLM*FKOl2ua&~28R%v8qZzz)vPc5_A%H=AP&-H7UKRy9Pv%mJI1G9qqa{-q+ z4FMywBK&YBlb-k>m$)JUB$oms0Z_MOBLUw8lYx5|mtrUZDwC@C7`Npp0T}_82r2 Founding week checklist, /launch, members only): eight items read live from the account: username, wallet linked, payouts on, level 2 qualified (2 buyers of $20+, or Qualified Start with 2 linked positions), the leader play = level 3 (5 qualifying buyers, up to 5 linked positions; then buy from the main wallet), line banner, links + play chosen (self-marked), first two placed. Reason: unqualified levels pass up, so leaders qualify BEFORE their teams' teams buy. Countdown shows when admin sets launchAt. Never call the site 'pre-launch' publicly: it is live and paying. +- COUNTRY TARGETING (2026-09-11): New campaign form has "Show to" tier checkboxes (Tier 1 = US, CA, GB, AU, NZ, IE, DE, FR, NL, SE, NO, DK, FI, CH, AT, BE by default; Tier 2 = rest of Western/Central Europe, JP, KR, SG, HK, TW, IL, Gulf, ZA, BR, MX, AR, CL, CO, etc.; Tier 3 = everyone else; lists editable in Admin > Settings geoTier1/geoTier2). Country comes from the viewer's IP (DB-IP lite). Applies to on-site delivery of every format; a narrowed banner/text ad is NOT sent to the partner network (which is worldwide). Unknown country never matches a narrowed campaign. Campaign rows show 'tier 1+2' chips and top viewer countries. - SCHEDULING (2026-09-11): any campaign except featured can take an optional start and end time (local time) in the New campaign form; solo ads label it "Send from" (inbox deliveries begin then). A scheduled campaign shows "scheduled" until it starts; at the end it shows "ended" and the unspent budget returns to Available. Banner/text scheduled campaigns join the partner network at their start time. There is NO dayparting (hours-of-day targeting) by design; the daily cap paces budgets. Each campaign row shows a small views-by-hour chart (on-site views, viewer's local time, last 7 days). - BALANCE RULE (members ask this a lot): a balance is what is NOT committed to a live campaign. Starting a campaign sets aside its whole budget at once (earned pool first, then purchased), so Available drops once and stays still while ads serve; the budget spends down inside Members > Campaigns. Dashboard shows Purchased available, Earned available, In live campaigns. A paused campaign keeps its unspent budget set aside so it can resume. A low balance with a live campaign is not lost credits. Refunds/comps of purchased money are credited off-chain as purchased-grade credits: shown under Purchased as "credited to you", fund anything incl. login ads. Viewing-earned credits never fund login ads. - Members EARN credits by attention: in the Earn credits section of Members, each ad in the daily set opens FULL SCREEN in its own tab, showing the advertiser's real website. A countdown runs while you watch (it pauses if you leave the tab), then a quick human check (click the named icon) must be passed before the view counts. Finish the daily set, claim a small daily credit batch. Earned credits spend on banner and text campaigns; attention earns advertising, referrals earn money, and viewer rewards are never cash. Advertisers get real, verified visits to their site. diff --git a/db.js b/db.js index 987a77c..0001f9d 100644 --- a/db.js +++ b/db.js @@ -126,6 +126,11 @@ async function bootstrap() { ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); // on-site views per UTC hour, for the by-hour chart await alterSafe('ALTER TABLE campaigns ADD COLUMN house TINYINT NOT NULL DEFAULT 0'); // admin house ad: free, never charged await alterSafe('ALTER TABLE campaigns ADD COLUMN daily_cap INT NULL'); // optional credits/day pacing (banner + text) + await alterSafe('ALTER TABLE campaigns ADD COLUMN geo VARCHAR(16) NULL'); // country tiers the campaign shows to ('1,2'); NULL = everyone + await q(`CREATE TABLE IF NOT EXISTS camp_geo ( + campaign_id INT NOT NULL, cc CHAR(2) NOT NULL, n INT NOT NULL DEFAULT 0, + PRIMARY KEY (campaign_id, cc) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); // on-site serves per viewer country await alterSafe('ALTER TABLE campaigns ADD COLUMN day_spent INT NOT NULL DEFAULT 0'); await alterSafe("ALTER TABLE campaigns ADD COLUMN day_key CHAR(10) NULL"); // linked positions: extra wallets owned by one email account (Qualified Start). diff --git a/geo.js b/geo.js new file mode 100644 index 0000000..706789c --- /dev/null +++ b/geo.js @@ -0,0 +1,120 @@ +'use strict'; +// Country lookup for ad targeting, zero dependencies. Data: DB-IP "IP to Country +// Lite" (CC BY 4.0, attribution "IP Geolocation by DB-IP" is shown on the site). +// The server downloads the current month's CSV once, keeps it in DATA_DIR/geo/, +// loads it into sorted range tables (IPv4 as uint32, IPv6 as BigInt) and answers +// countryOf(ip) by binary search. Refreshes monthly. +const fs = require('fs'); +const path = require('path'); +const https = require('https'); +const zlib = require('zlib'); + +let DATA_DIR = '.'; +let v4 = { start: [], end: [], cc: [] }, v6 = { start: [], end: [], cc: [] }; +let loaded = false, loadedFile = ''; + +// Tier lists (affiliate-marketing convention). Editable in Admin > Settings as +// comma-separated ISO codes; tier 3 = everything else. +const DEFAULT_TIER1 = 'US,CA,GB,AU,NZ,IE,DE,FR,NL,SE,NO,DK,FI,CH,AT,BE'; +const DEFAULT_TIER2 = 'IT,ES,PT,PL,CZ,HU,GR,RO,SK,SI,HR,BG,EE,LV,LT,LU,IS,MT,CY,JP,KR,SG,HK,TW,IL,AE,SA,QA,KW,BH,OM,ZA,BR,MX,AR,CL,CO,UY,CR,PA,MY,TH,TR,RU,UA,KZ'; + +function ip4(s) { const p = s.split('.'); if (p.length !== 4) return null; let n = 0; for (const x of p) { const v = Number(x); if (!/^\d{1,3}$/.test(x) || v > 255) return null; n = n * 256 + v; } return n; } +function ip6(s) { + try { + let [head, tail] = s.split('::'); + const h = head ? head.split(':') : [], t = tail ? tail.split(':') : []; + if (s.includes('::')) { while (h.length + t.length < 8) h.push('0'); } + const parts = h.concat(t); if (parts.length !== 8) return null; + let n = 0n; for (const p of parts) { if (!/^[0-9a-f]{0,4}$/i.test(p)) return null; n = (n << 16n) + BigInt(parseInt(p || '0', 16)); } + return n; + } catch (e) { return null; } +} +function find(tbl, x) { + let lo = 0, hi = tbl.start.length - 1; + while (lo <= hi) { const mid = (lo + hi) >> 1; if (tbl.start[mid] <= x) { if (x <= tbl.end[mid]) return tbl.cc[mid]; lo = mid + 1; } else hi = mid - 1; } + return null; +} +// ISO-2 country code or null (private ranges, unknown, no data yet) +function countryOf(ip) { + if (!loaded || !ip) return null; + let s = String(ip).trim().replace(/^::ffff:/i, ''); + let cc = null; + if (s.includes('.')) { const n = ip4(s); cc = n == null ? null : find(v4, n); } + else { const n = ip6(s); cc = n == null ? null : find(v6, n); } + return cc && cc !== 'ZZ' ? cc : null; // ZZ = private/reserved: unknown +} + +function tierLists(cfg) { + const parse = (v, def) => new Set(String(v || def).toUpperCase().split(/[\s,]+/).filter(Boolean)); + return { t1: parse(cfg && cfg.geoTier1, DEFAULT_TIER1), t2: parse(cfg && cfg.geoTier2, DEFAULT_TIER2) }; +} +// '1' | '2' | '3' | null (unknown country never matches a restricted campaign) +function tierOf(cc, cfg) { + if (!cc) return null; + const { t1, t2 } = tierLists(cfg); + return t1.has(cc) ? '1' : t2.has(cc) ? '2' : '3'; +} + +// ── data file: DATA_DIR/geo/dbip-country-lite-YYYY-MM.csv.gz ── +function monthKey(d) { d = d || new Date(); return d.getUTCFullYear() + '-' + String(d.getUTCMonth() + 1).padStart(2, '0'); } +function fileFor(mk) { return path.join(DATA_DIR, 'geo', 'dbip-country-lite-' + mk + '.csv.gz'); } +function download(mk) { + return new Promise((resolve, reject) => { + fs.mkdirSync(path.join(DATA_DIR, 'geo'), { recursive: true }); + const tmp = fileFor(mk) + '.part'; + const req = https.get('https://download.db-ip.com/free/dbip-country-lite-' + mk + '.csv.gz', { headers: { 'User-Agent': 'InstantAdPay/1.0 (+https://instantadpay.com)' }, timeout: 60000 }, res => { + if (res.statusCode !== 200) { res.resume(); return reject(new Error('HTTP ' + res.statusCode)); } + const out = fs.createWriteStream(tmp); + res.pipe(out); out.on('finish', () => { fs.renameSync(tmp, fileFor(mk)); resolve(fileFor(mk)); }); out.on('error', reject); + }); + req.on('error', reject); req.on('timeout', () => { req.destroy(new Error('timeout')); }); + }); +} +function loadFile(file) { + const raw = zlib.gunzipSync(fs.readFileSync(file)).toString('utf8'); + const a4 = { start: [], end: [], cc: [] }, a6 = { start: [], end: [], cc: [] }; + for (const line of raw.split('\n')) { + if (!line) continue; + const c = line.indexOf(','), c2 = line.indexOf(',', c + 1); + if (c < 0 || c2 < 0) continue; + const s = line.slice(0, c), e = line.slice(c + 1, c2), cc = line.slice(c2 + 1).trim().toUpperCase(); + if (cc.length !== 2) continue; + if (s.includes('.')) { const a = ip4(s), b = ip4(e); if (a != null && b != null) { a4.start.push(a); a4.end.push(b); a4.cc.push(cc); } } + else { const a = ip6(s), b = ip6(e); if (a != null && b != null) { a6.start.push(a); a6.end.push(b); a6.cc.push(cc); } } + } + // DB-IP ships sorted; guard anyway + const order = t => { const idx = t.start.map((_, i) => i).sort((i, j) => (t.start[i] < t.start[j] ? -1 : t.start[i] > t.start[j] ? 1 : 0)); return { start: idx.map(i => t.start[i]), end: idx.map(i => t.end[i]), cc: idx.map(i => t.cc[i]) }; }; + v4 = order(a4); v6 = order(a6); loaded = true; loadedFile = file; + return { v4: v4.start.length, v6: v6.start.length }; +} +// load the newest local file now; fetch this month's if missing (async, non-blocking) +async function init(opts) { + DATA_DIR = opts.dataDir; + try { + const dir = path.join(DATA_DIR, 'geo'); + const files = fs.existsSync(dir) ? fs.readdirSync(dir).filter(f => /^dbip-country-lite-\d{4}-\d{2}\.csv\.gz$/.test(f)).sort() : []; + if (files.length) { const n = loadFile(path.join(dir, files[files.length - 1])); console.log('geo: loaded', files[files.length - 1], n.v4, 'v4 +', n.v6, 'v6 ranges'); } + } catch (e) { console.error('geo: load failed', e.message); } + refresh().catch(e => console.error('geo: refresh failed', e.message)); +} +// this month's file: download if missing, then (re)load it; safe to call daily +async function refresh() { + const mk = monthKey(); + const f = fileFor(mk); + if (!fs.existsSync(f)) { + try { await download(mk); } catch (e) { + // early in the month DB-IP may not have published yet: fall back to last month + const d = new Date(); d.setUTCMonth(d.getUTCMonth() - 1); const prev = monthKey(d); + if (!fs.existsSync(fileFor(prev))) await download(prev); + } + } + const dir = path.join(DATA_DIR, 'geo'); + const files = fs.readdirSync(dir).filter(x => /^dbip-country-lite-\d{4}-\d{2}\.csv\.gz$/.test(x)).sort(); + const newest = path.join(dir, files[files.length - 1]); + if (newest !== loadedFile) { const n = loadFile(newest); console.log('geo: loaded', files[files.length - 1], n.v4, 'v4 +', n.v6, 'v6 ranges'); } + for (const old of files.slice(0, -2)) { try { fs.unlinkSync(path.join(dir, old)); } catch (e) {} } + return { file: files[files.length - 1], ranges: v4.start.length + v6.start.length }; +} +function status() { return { loaded, file: path.basename(loadedFile || ''), v4: v4.start.length, v6: v6.start.length }; } + +module.exports = { init, refresh, countryOf, tierOf, tierLists, status, DEFAULT_TIER1, DEFAULT_TIER2 }; diff --git a/public/assets/my.js b/public/assets/my.js index 3097557..ce1f701 100644 --- a/public/assets/my.js +++ b/public/assets/my.js @@ -595,6 +595,7 @@ const r = await (await fetch('/api/my/campaigns')).json(); if (r.error) return; lastRates = r.rates; + if ($('cGeoHint') && r.tiers) $('cGeoHint').textContent = 'All three ticked = everyone. Tier 1: ' + r.tiers.t1.join(', ') + '. Tier 2: ' + r.tiers.t2.join(', ') + '. Tier 3: every other country. Geo applies to delivery on this site; a narrowed banner or text ad is kept off the worldwide partner network. ' + (r.geoReady ? '' : 'Country data is still loading, so narrowed campaigns pause until it is ready. ') + 'IP geolocation by DB-IP.'; // populate the banner-size dropdown once (ids map to NAS width/height) if (r.bannerSizes && $('cSize') && !$('cSize').options.length) $('cSize').innerHTML = r.bannerSizes.map(s => '').join(''); @@ -622,8 +623,8 @@ tbl.className = 'tablewrap'; tbl.innerHTML = '' + '' - + r.campaigns.map(c => '' - + '' + + r.campaigns.map(c => '' + + '' + '' + '' + '' @@ -820,6 +821,7 @@ days: Number($('cFeatDays').value), startDay: featStartDay, count: Number($('cVisitCount').value), dailyCap: ($('cDailyCap') && (t === 'banner' || t === 'text')) ? Number($('cDailyCap').value) || 0 : 0, + geo: [...document.querySelectorAll('.geoTier:checked')].map(x => x.value).join(','), startsAt: ($('cStartAt') && $('cStartAt').value && !isFeat) ? new Date($('cStartAt').value).getTime() : 0, endsAt: ($('cEndAt') && $('cEndAt').value && !isFeat) ? new Date($('cEndAt').value).getTime() : 0, ctaLabel: isVideo ? $('cVideoCta').value : $('cCtaLabel').value, @@ -830,6 +832,7 @@ ['cName', 'cBudget', 'cTarget', 'cImage', 'cTitle', 'cBody', 'cCtaLabel', 'cVideoUrl', 'cVideoTitle', 'cVideoCta', 'cVisitTitle', 'cVisitCount', 'cFeatTitle', 'cDailyCap', 'cStartAt', 'cEndAt'] .forEach(id => { if ($(id)) $(id).value = ''; }); + document.querySelectorAll('.geoTier').forEach(x => { x.checked = true; }); $('cSoloEd').innerHTML = ''; if ($('cSoloRaw')) $('cSoloRaw').value = ''; $('cVideoInfo').textContent = ''; $('cVideoPrev').hidden = true; $('cVideoPrev').innerHTML = ''; if ($('edMediaInfo')) $('edMediaInfo').textContent = ''; @@ -1098,6 +1101,10 @@ if (c.expires && c.type !== 'featured') s += ' ' + (c.expires > now ? 'ends ' : 'ended ') + fmtWhen(c.expires) + ''; return s; } + function geoLine(rows) { + if (!rows || !rows.length) return ''; + return '
' + rows.map(r => esc(r.cc) + ' ' + r.n).join(' · ') + '
'; + } function hourBars(utc) { if (!utc || !utc.some(n => n)) return ''; // rotate the 24 UTC buckets into the viewer's local hours diff --git a/public/my.html b/public/my.html index 3bce48b..40fa80e 100644 --- a/public/my.html +++ b/public/my.html @@ -447,6 +447,13 @@

Network Ad Space can deliver a lot of impressions fast. A daily cap spreads a banner or text budget over days instead of hours.

+
+ Show to: + + + + All three ticked = everyone. Untick tiers to narrow it. Geo applies to delivery on this site; a narrowed banner or text ad is kept off the worldwide partner network. IP geolocation by DB-IP. +
@@ -902,7 +909,7 @@ - + diff --git a/server.js b/server.js index f72d814..9a541f3 100644 --- a/server.js +++ b/server.js @@ -25,6 +25,7 @@ let QR = null; try { QR = require('qrcode'); } catch (e) { /* optional */ } 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 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) const PORT = Number(process.env.PORT || 3000); @@ -126,6 +127,8 @@ const codeHits = new Map(); // ip -> { t: [send timestamps, 24h], passUntil, cha const codeGlobal = { minute: 0, n: 0 }; const codeAlert = { last: 0, trips: 0, ips: new Set() }; function clientIp(req) { return String(req.headers['x-forwarded-for'] || req.socket.remoteAddress || '').split(',')[0].trim() || 'unknown'; } +// the viewer's country and tier for ad targeting (null tier = unknown: never matches a restricted campaign) +function viewerGeo(req) { const cc = geo.countryOf(clientIp(req)); return { cc, tier: geo.tierOf(cc, siteConfig()) }; } function codeChallenge(rec) { const pick = CAPTCHA.slice().sort(() => Math.random() - 0.5).slice(0, 5); const answer = Math.floor(Math.random() * pick.length); @@ -307,6 +310,8 @@ async function boot() { // follow-up email sequence: send whatever came due (every 10 min, first pass shortly after boot) coach.init({ dataDir: DATA_DIR, chain, accounts, mailer }); tank.init({ dataDir: DATA_DIR, chain, accounts, messages, mailer, site: 'https://instantadpay.com' }); + 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 setInterval(() => tank.sweep().catch(e => console.error('tank sweep', e.message)), 60 * 60 * 1000); // adoptions past their 7-day window burner.init({ chain, ads }); setTimeout(() => coach.nudgeTick().catch(e => console.error('coach', e.message)), 90 * 1000); @@ -335,6 +340,8 @@ function siteConfig() { telegramBotToken: '', telegramChatId: '', telegramTopicId: '', telegramEvents: 'payouts', telegramCtaUrl: 'https://instantadpay.com/', telegramAdminChatId: '', // private chat for admin alerts (sign-up guard bursts); falls back to ADMIN_EMAIL launchAt: '', // public launch moment, ISO 8601 with offset (e.g. 2026-09-18T19:00:00-05:00): countdown on /launch + dashboard mark + geoTier1: '', // comma-separated ISO country codes; empty = built-in default (US, CA, GB, AU, NZ, IE, DE, FR, NL, SE, NO, DK, FI, CH, AT, BE) + geoTier2: '', // empty = built-in default (rest of Western/Central Europe, JP, KR, SG, HK, TW, IL, Gulf, ZA, BR, MX, AR, CL, CO ...); tier 3 = everything else pnlFixedMonthlyUsd: 0 }, saved); } @@ -1093,7 +1100,7 @@ const server = http.createServer(async (req, res) => { if (status.views >= status.target || status.claimed) return json(res, 200, { ad: null, status }); const type = String(u.searchParams.get('type') || 'banner'); // members never see (or earn from) their own campaigns in the viewer - const ad = await ads.serve(type === 'text' ? 'text' : 'banner', { excludeEmail: s.email }); + const ad = await ads.serve(type === 'text' ? 'text' : 'banner', Object.assign({ excludeEmail: s.email }, viewerGeo(req))); if (!ad) return json(res, 200, { ad: null, status }); const token = crypto.randomBytes(16).toString('hex'); // the viewer tab frames the advertiser's REAL url (no click counted for a paid view) @@ -1382,7 +1389,7 @@ const server = http.createServer(async (req, res) => { } // -- featured rotation: the live featured links + dilution stats if (p === '/api/featured' && req.method === 'GET') { - const items = await ads.serveFeatured(); + const items = await ads.serveFeatured(viewerGeo(req)); const names = await accounts.namesForMembers([...new Set(items.map(i => i.memberId).filter(Boolean))]); for (const i of items) i.by = (i.memberId && names[i.memberId]) ? '@' + names[i.memberId] : (i.memberId ? 'member #' + i.memberId : null); return json(res, 200, { items }); @@ -1530,7 +1537,7 @@ const server = http.createServer(async (req, res) => { const status = await ads.videoStatus(s.email); if (status.left <= 0) return json(res, 200, { ad: null, status }); const orientation = String(u.searchParams.get('orientation') || ''); // 'portrait' = Shorts reel, 'landscape' = Watch videos tab - const ad = await ads.serveVideo({ excludeEmail: s.email, orientation }); // never your own video + const ad = await ads.serveVideo(Object.assign({ excludeEmail: s.email, orientation }, viewerGeo(req))); // never your own video if (!ad) return json(res, 200, { ad: null, status }); const token = crypto.randomBytes(16).toString('hex'); videoTokens.set(s.email, { token, ts: Date.now(), id: ad.id, secs: ad.watchSecs }); @@ -1562,7 +1569,7 @@ const server = http.createServer(async (req, res) => { if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' }); const status = await ads.visitStatus(s.email); if (status.count >= status.cap) return json(res, 200, { ad: null, status }); - const ad = await ads.serveVisit(s.email); + const ad = await ads.serveVisit(s.email, viewerGeo(req)); if (!ad) return json(res, 200, { ad: null, status }); const token = crypto.randomBytes(16).toString('hex'); visitTokens.set(s.email, { token, ts: Date.now(), id: ad.id }); @@ -1601,7 +1608,7 @@ const server = http.createServer(async (req, res) => { if (p === '/api/my/inbox' && req.method === 'GET') { const s = await auth.fromRequest(req); if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' }); - const r = await ads.inboxList(s.email); + const r = await ads.inboxList(s.email, viewerGeo(req)); const names = await accounts.namesForMembers([...new Set(r.items.map(i => i.fromMemberId).filter(Boolean))]); for (const i of r.items) i.fromName = (i.fromMemberId && names[i.fromMemberId]) ? '@' + names[i.fromMemberId] : i.fromMemberId ? 'member #' + i.fromMemberId : 'a member'; @@ -1656,7 +1663,7 @@ const server = http.createServer(async (req, res) => { // -- ad engine (spec §8b v1: banners, text, login ads) if (p === '/api/ads/slot' && req.method === 'GET') { const t = String(u.searchParams.get('type') || 'banner'); - const ad = await ads.serve(t, { width: Number(u.searchParams.get('w')) || 0, height: Number(u.searchParams.get('h')) || 0 }); + const ad = await ads.serve(t, Object.assign({ width: Number(u.searchParams.get('w')) || 0, height: Number(u.searchParams.get('h')) || 0 }, viewerGeo(req))); // login ads: the member clicks "Open Ad" (a real, counted click into a // new tab) while the countdown runs on our interstitial page if (ad && t === 'login') ad.dwell = ads.rates().loginDwellSeconds || 10; @@ -1677,6 +1684,8 @@ const server = http.createServer(async (req, res) => { const out = { campaigns: await ads.listCampaigns(s.email), rates: ads.rates(), bannerSizes: ads.bannerSizes() }; out.clickSources = await coach.clickSources(out.campaigns.map(c => c.id)); out.hours = await ads.hoursFor(out.campaigns.map(c => c.id)); // on-site views per UTC hour, last 7 days + out.geo = await ads.geoFor(out.campaigns.map(c => c.id)); // on-site serves per viewer country + { const t = geo.tierLists(siteConfig()); out.tiers = { t1: [...t.t1], t2: [...t.t2] }; out.geoReady = geo.status().loaded; } const pool = await ads.balances((await myMemberIds(s)).ids, s.email); out.purchasedCredits = pool.total; out.largestPosition = pool.best.avail; // a single campaign budget has to fit one position
NameTypeViews hereNetwork viewsClicksSpentBudgetStatus
' + c.name + '' + hourBars(r.hours && r.hours[c.id]) + '' + c.type + (c.type === 'banner' && c.width ? ' ' + c.width + '×' + c.height + '' : '') + (c.dailyCap ? ' cap ' + c.dailyCap + '/day' : '') + schedChips(c) + '
' + c.name + '' + hourBars(r.hours && r.hours[c.id]) + geoLine(r.geo && r.geo[c.id]) + '' + c.type + (c.type === 'banner' && c.width ? ' ' + c.width + '×' + c.height + '' : '') + (c.dailyCap ? ' cap ' + c.dailyCap + '/day' : '') + (c.geo ? ' tier ' + esc(c.geo.replace(/,/g, '+')) + '' : '') + schedChips(c) + '' + c.imps.toLocaleString() + '' + (['banner', 'text'].includes(c.type) ? (c.impsNas || 0).toLocaleString() : 'n/a') + '' + c.clicks + (r.clickSources && r.clickSources[c.id] ? '
' + Object.entries(r.clickSources[c.id]).sort((a, b) => b[1] - a[1]).map(([k, v]) => esc(k) + ' ' + v).join(' · ') + (c.impsNas ? ' · network: see Network views' : '') + '
' : '') + '