diff --git a/ads.js b/ads.js index 2289771..b5615ee 100644 Binary files a/ads.js and b/ads.js differ diff --git a/chatbot.js b/chatbot.js index 4c7ce75..718e6a6 100644 --- a/chatbot.js +++ b/chatbot.js @@ -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. 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 = '
| Name | Type | Views here | Network views | Clicks | ' + 'Spent | Budget | Status | |
|---|---|---|---|---|---|---|---|---|
| ' + 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) + ' | ' + + r.campaigns.map(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' : '') + ' ' : '') + ' | '
@@ -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 '