Legacy bridge pages, welcome-back credits, holding-tank alerts

- /from/faucetwave and /from/tieroneads (?seg=advertiser): the squeeze page with
  brand copy and no video, sponsor cookie cleared so arrivals land in the holding
  tank, angle + a legacy:<brand> source recorded on the account.
- legacy.js: private list DATA_DIR/legacy.json (email -> brand, segment); listed
  emails get a one-time credit grant at account creation (legacyCreditsAdvertiser
  500 / legacyCreditsEarner 150, admin-editable), recorded in legacy-grants.json.
- Holding-tank alerts: dashboard payload carries who is waiting (Overview notice
  with usernames + adopt link), and a 15-minute tick posts new arrivals to the
  shared Telegram payments topic.
- Chatbot prompt lines + admin setting labels.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-09-12 07:25:32 -05:00
parent b844a227d3
commit eae2a04dc0
7 changed files with 113 additions and 5 deletions
+53 -2
View File
@@ -25,6 +25,8 @@ 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 legacy = require('./legacy'); // Faucet Wave / Tier One Ads bridge: welcome-back credits for listed emails
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)
@@ -254,6 +256,11 @@ async function handleUpload(req, res, who) {
}
// lead-capture page hooks (og tags + copy live in public/assets/join.js too)
const JOIN_ANGLES = {
// legacy bridge pages (/from/<brand>): former members of Marty's closed EvolutionScript sites
'fw-adv': { t: 'Your next ad budget pays you back.', d: 'Faucet Wave closed. InstantAdPay was built for the people who bought ads there: packages from $5, seven formats, every package in your line paid out on Polygon in the same transaction. Welcome-back credits waiting.', url: 'https://instantadpay.com/from/faucetwave?seg=advertiser' },
'fw-earn': { t: 'Same daily habit. Real payouts on-chain.', d: 'You viewed ads on Faucet Wave. Here you view ads to earn credits, run your own campaign free, and get paid in POL to your own wallet when your line buys ads. Welcome-back credits waiting.', url: 'https://instantadpay.com/from/faucetwave' },
't1-adv': { t: 'Your next ad budget pays you back.', d: 'Tier One Ads closed. InstantAdPay was built for the people who bought ads there: packages from $5, seven formats, every package in your line paid out on Polygon in the same transaction. Welcome-back credits waiting.', url: 'https://instantadpay.com/from/tieroneads?seg=advertiser' },
't1-earn': { t: 'Same daily habit. Real payouts on-chain.', d: 'You viewed ads on Tier One Ads. Here you view ads to earn credits, run your own campaign free, and get paid in POL to your own wallet when your line buys ads. Welcome-back credits waiting.', url: 'https://instantadpay.com/from/tieroneads' },
instant: { t: 'Paid before the page reloads.', d: 'A smart contract on Polygon splits every ad package the moment it sells. Same transaction, real wallets, public ledger. Join free by email.' },
adspend: { t: 'You were buying ads anyway.', d: 'Here the ad spend in your line pays you, in the same transaction, on a public ledger. Seven formats, packages from $5. Join free.' },
free: { t: 'Watch first. Spend never.', d: 'Join free, view a few ads, earn credits, run your first campaign for zero dollars. Every payout public on Polygon.' },
@@ -264,7 +271,7 @@ function serveJoinPage(res, tok, angle, ang, setCookies) {
let html;
try { html = fs.readFileSync(path.join(PUBLIC_DIR, 'join.html'), 'utf8'); } catch (e) { res.writeHead(404, baseHeaders({ 'Content-Type': 'text/plain' })); return res.end('Not found'); }
const base = 'https://instantadpay.com';
const url = base + '/join/' + tok + (angle ? '?v=' + angle : '');
const url = (ang && ang.url) || (base + '/join/' + tok + (angle ? '?v=' + angle : ''));
const title = ang ? ang.t : 'Advertise and earn. Paid on-chain, instantly.';
const desc = ang ? ang.d : 'You are invited to InstantAdPay: real ad packages with same-transaction payouts on Polygon, every payment public. Join free by email.';
const escA = t => String(t).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;');
@@ -311,6 +318,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' });
legacy.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
setInterval(() => tank.sweep().catch(e => console.error('tank sweep', e.message)), 60 * 60 * 1000); // adoptions past their 7-day window
@@ -342,6 +351,7 @@ function siteConfig() {
telegramEchoChatId: '', telegramEchoTopicId: '', telegramEchoEvents: 'payouts', // shared cross-program payments topic
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
legacyCreditsAdvertiser: 500, legacyCreditsEarner: 150, // welcome-back credits for listed Faucet Wave / Tier One Ads emails arriving via /from/<brand>
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
@@ -551,6 +561,24 @@ async function telegramOnEvent(ev) {
if (sc.telegramChatId) { const t = build(String(sc.telegramEvents || 'payouts')); if (t) await telegramSend(sc.telegramChatId, t, sc.telegramTopicId); }
if (sc.telegramEchoChatId) { const t = build(String(sc.telegramEchoEvents || 'payouts')); if (t) await telegramSend(sc.telegramEchoChatId, '\u{1F7E0} <b>InstantAdPay</b> \u00b7 ' + t, sc.telegramEchoTopicId); }
}
// holding-tank arrivals -> one digest line in the shared payments topic (Marty, 2026-09-12): who is
// waiting for a sponsor, by username, so builders go adopt them. Runs every 15 min, posts only
// when someone new landed since the last check.
async function tankNotifyTick() {
const sc = siteConfig();
if (!sc.telegramBotToken || !sc.telegramEchoChatId) return;
const f = path.join(DATA_DIR, 'tank-notify.json');
let st = { last: 0 }; try { st = JSON.parse(fs.readFileSync(f, 'utf8')); } catch (e) {}
const since = st.last || (Date.now() - 24 * 3600 * 1000);
const fresh = (await tank.waiting()).filter(w => (w.joined || 0) > since);
st.last = Date.now(); fs.writeFileSync(f, JSON.stringify(st));
if (!fresh.length) return;
const named = fresh.filter(w => w.username).map(w => '@' + w.username);
const who = named.length ? ': ' + named.slice(0, 8).join(', ') + (named.length > 8 ? ' and ' + (named.length - 8) + ' more' : '') : '';
const text = '\u{1FAA3} <b>InstantAdPay</b> \u00b7 ' + fresh.length + ' new member' + (fresh.length === 1 ? '' : 's') + ' waiting for a sponsor in the holding tank' + who
+ '\nAdopt from My line \u203a Holding tank: <a href="https://instantadpay.com/my#line">instantadpay.com/my</a>';
await telegramSend(sc.telegramEchoChatId, text, sc.telegramEchoTopicId);
}
// one sendMessage call; never throws, never logs the token
async function telegramSend(chatId, text, threadId) {
const sc = siteConfig();
@@ -597,6 +625,18 @@ const server = http.createServer(async (req, res) => {
if (!cookies['iap.ref']) set.push('iap.ref=' + encodeURIComponent(coach.refHost(req.headers.referer)) + cookieTail); // first-touch source
return serveJoinPage(res, tok, ang ? angle : '', ang, set);
}
// -- legacy bridge: /from/faucetwave | /from/tieroneads [?seg=advertiser]. Same squeeze page
// with brand copy, NO sponsor (the sponsor cookie is cleared so they land in the holding
// tank for adoption), the angle remembered so the welcome-back grant fires at signup, and
// a forced first-touch source so link stats / admin can see the legacy arrivals.
m = /^\/from\/(faucetwave|tieroneads)$/.exec(p);
if (m && (req.method === 'GET' || req.method === 'HEAD')) {
const brand = m[1];
const key = (brand === 'faucetwave' ? 'fw' : 't1') + (String(u.searchParams.get('seg') || '').toLowerCase().startsWith('adv') ? '-adv' : '-earn');
const cookieTail = `; Path=/; SameSite=Lax; Max-Age=${30 * 24 * 3600}${IS_PROD ? '; Secure' : ''}`;
const set = ['iap.sponsor=; Path=/; SameSite=Lax; Max-Age=0' + (IS_PROD ? '; Secure' : ''), 'iap.angle=' + key + cookieTail, 'iap.ref=' + encodeURIComponent('legacy:' + brand) + cookieTail];
return serveJoinPage(res, '', key, JOIN_ANGLES[key], set);
}
if (p === '/unsubscribe' && req.method === 'GET') {
const r = await drip.unsubscribe(u.searchParams.get('e'), u.searchParams.get('t'));
const msg = r.error ? r.error : 'Done. You will not get any more follow-up emails from InstantAdPay. Your account is unchanged.';
@@ -784,6 +824,11 @@ const server = http.createServer(async (req, res) => {
await auth.logout(req);
}
if (r.created) { sendWelcome(e, ref).catch(() => {}); } // sponsor notified at username set (/api/my/profile)
// legacy bridge: a listed former Faucet Wave / Tier One Ads member gets welcome-back credits once
if (r.created && /^(fw|t1)-(adv|earn)$/.test(via)) {
try { const g = legacy.grant(e, siteConfig()); if (g) { await ads.addEarned(e, g.credits); console.log('legacy grant', g.brand, g.seg, g.credits, e); } }
catch (err) { console.error('legacy grant', err.message); }
}
if (r.created && b.newsletter) sendy.subscribe(r.account.email, r.account.username || '').catch(() => {}); // pre-checked opt-in, silent, new joins only
let memberId = 0;
if (r.account.address) { try { memberId = await chain.memberIdByAccount(r.account.address); } catch (err) {} }
@@ -886,7 +931,13 @@ const server = http.createServer(async (req, res) => {
const memberId = await auth.refreshMemberId(s);
const acct = (s.email && await accounts.byEmail(s.email)) || (s.address && await accounts.byAddress(s.address)) || null;
if (memberId && acct && acct.memberId !== memberId) accounts.setMemberId(acct.email, memberId).catch(() => {});
const out = { memberId, email: s.email || (acct && acct.email) || null,
let tankWaiting = null; // top of every Overview: people waiting for a sponsor (Marty, 2026-09-12)
try {
if (!tankWaitCache || Date.now() - tankWaitCache.ts > 60000) tankWaitCache = { ts: Date.now(), list: await tank.waiting() };
const tw = tankWaitCache.list;
tankWaiting = { count: tw.length, names: tw.slice(0, 6).map(w => w.name), eligible: !!(await tank.eligibility(s.email)).ok };
} catch (e) {}
const out = { memberId, tankWaiting, email: s.email || (acct && acct.email) || null,
address: s.address || (acct && acct.address) || null,
username: (acct && acct.username) || null,
refCode: (acct && acct.code) || null, credits: 0, buyerCount: 0,