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 <noreply@anthropic.com>
This commit is contained in:
@@ -82,6 +82,7 @@ FACTS:
|
||||
- DORMANT-LEAD RESCUE: a FREE referral (no wallet, no purchase) with no message from their sponsor for 10 days triggers a warning email + dashboard flag to the sponsor ("unreached, tank in N days"); at 14 days (warning at least 4 days old) the lead moves to the holding tank and the sponsor is told. Sponsor resets the clock with a chat, a Nudge, or the "Contacted them" button (for phone/text contact). Leads whose sponsor link resolves to nobody go to the tank after a day. Nothing on-chain moves; anyone bound by a purchase never moves.
|
||||
- PIF (pay it forward) button: on a free direct or an adopted member who has linked a wallet, the sponsor taps PIF, enters an amount (suggested: the $20 package plus fees), and their OWN wallet app opens with the member's address prefilled; the POL goes wallet to wallet. The site never touches the funds; it only logs the transaction and tells the recipient with a Polygonscan link. The gift is theirs; nothing forces a purchase.
|
||||
- FOUNDING WEEK / PRE-LAUNCH (Training > 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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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 };
|
||||
+9
-2
@@ -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 => '<option value="' + s.id + '">' + s.label + '</option>').join('');
|
||||
@@ -622,8 +623,8 @@
|
||||
tbl.className = 'tablewrap';
|
||||
tbl.innerHTML = '<table><thead><tr><th>Name</th><th>Type</th><th class="num">Views here</th><th class="num" title="Impressions delivered by Network Ad Space across the wider network (banner and text ads only)">Network views</th><th class="num">Clicks</th>'
|
||||
+ '<th class="num">Spent</th><th class="num">Budget</th><th>Status</th><th></th></tr></thead><tbody>'
|
||||
+ r.campaigns.map(c => '<tr><td><b>' + c.name + '</b>' + hourBars(r.hours && r.hours[c.id]) + '</td>'
|
||||
+ '<td>' + c.type + (c.type === 'banner' && c.width ? ' <span class="muted small">' + c.width + '×' + c.height + '</span>' : '') + (c.dailyCap ? ' <span class="muted small" title="daily cap: ' + c.dailyCap + ' credits, ' + (c.daySpent || 0) + ' spent today">cap ' + c.dailyCap + '/day</span>' : '') + schedChips(c) + '</td>'
|
||||
+ r.campaigns.map(c => '<tr><td><b>' + c.name + '</b>' + hourBars(r.hours && r.hours[c.id]) + geoLine(r.geo && r.geo[c.id]) + '</td>'
|
||||
+ '<td>' + c.type + (c.type === 'banner' && c.width ? ' <span class="muted small">' + c.width + '×' + c.height + '</span>' : '') + (c.dailyCap ? ' <span class="muted small" title="daily cap: ' + c.dailyCap + ' credits, ' + (c.daySpent || 0) + ' spent today">cap ' + c.dailyCap + '/day</span>' : '') + (c.geo ? ' <span class="muted small" title="shown only to viewers in these country tiers">tier ' + esc(c.geo.replace(/,/g, '+')) + '</span>' : '') + schedChips(c) + '</td>'
|
||||
+ '<td class="num">' + c.imps.toLocaleString() + '</td>'
|
||||
+ '<td class="num">' + (['banner', 'text'].includes(c.type) ? (c.impsNas || 0).toLocaleString() : '<span class="muted small" title="only banner and text ads syndicate to the network">n/a</span>') + '</td>'
|
||||
+ '<td class="num">' + c.clicks + (r.clickSources && r.clickSources[c.id] ? '<div class="muted small" style="white-space:nowrap" title="where the clicks happened">' + 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' : '') + '</div>' : '') + '</td>'
|
||||
@@ -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 += ' <span class="muted small">' + (c.expires > now ? 'ends ' : 'ended ') + fmtWhen(c.expires) + '</span>';
|
||||
return s;
|
||||
}
|
||||
function geoLine(rows) {
|
||||
if (!rows || !rows.length) return '';
|
||||
return '<div class="muted small" style="font-size:10px" title="on-site serves by viewer country">' + rows.map(r => esc(r.cc) + ' ' + r.n).join(' · ') + '</div>';
|
||||
}
|
||||
function hourBars(utc) {
|
||||
if (!utc || !utc.some(n => n)) return '';
|
||||
// rotate the 24 UTC buckets into the viewer's local hours
|
||||
|
||||
+8
-1
@@ -447,6 +447,13 @@
|
||||
<p id="cBudgetRow"><input id="cBudget" type="number" placeholder="Budget (credits)" min="10" style="width:100%"></p>
|
||||
<p id="cCapRow" style="grid-column:1/-1"><input id="cDailyCap" type="number" placeholder="Daily cap in credits (optional) — paces the budget across days" min="10" style="width:100%">
|
||||
<span class="small muted">Network Ad Space can deliver a lot of impressions fast. A daily cap spreads a banner or text budget over days instead of hours.</span></p>
|
||||
<div id="cGeoRow" style="grid-column:1/-1">
|
||||
<span class="small muted">Show to:</span>
|
||||
<label class="small" style="margin-left:10px"><input type="checkbox" class="geoTier" value="1" checked style="width:auto"> Tier 1 countries</label>
|
||||
<label class="small" style="margin-left:10px"><input type="checkbox" class="geoTier" value="2" checked style="width:auto"> Tier 2</label>
|
||||
<label class="small" style="margin-left:10px"><input type="checkbox" class="geoTier" value="3" checked style="width:auto"> Tier 3 (everyone else)</label>
|
||||
<span class="small muted" id="cGeoHint" style="display:block;margin-top:4px">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.</span>
|
||||
</div>
|
||||
<div id="cSchedRow" style="grid-column:1/-1">
|
||||
<div style="display:flex;gap:10px;flex-wrap:wrap">
|
||||
<label class="small muted" style="flex:1 1 200px;display:block"><span id="cStartLbl">Start (optional)</span><input id="cStartAt" type="datetime-local" style="width:100%;margin-top:4px"></label>
|
||||
@@ -902,7 +909,7 @@
|
||||
<script src="/assets/common.js?v=20260911c"></script>
|
||||
<script src="/assets/wallet.js?v=20260911a"></script>
|
||||
<script src="/assets/promo.js?v=20260911a"></script>
|
||||
<script src="/assets/my.js?v=20260911v"></script>
|
||||
<script src="/assets/my.js?v=20260911w"></script>
|
||||
<script src="/assets/chat.js?v=20260907l"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user