Partner promo codes: reusable codes that add free ad credits, tracked per redemption

promos.js (promo_codes + promo_redemptions, JSON fallback): create/update codes with
credits, partner, optional cap and expiry, on/off. Redeemed once per account either
from a join link ?promo=CODE (cookie, applied at signup) or the Overview box
'Have a promo code?' (/api/my/promo/redeem). Admin > Traffic gets a Partner promo
codes card (create, toggle, uses, recent redemptions). Chatbot line added.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-09-12 10:53:17 -05:00
parent 9b60e6414e
commit fc55714757
8 changed files with 179 additions and 2 deletions
+35
View File
@@ -27,6 +27,7 @@ const coach = require('./coach'); // coaching view, nudges, digest, prospects,
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 promos = require('./promos'); // partner promo codes -> free ad credits (link ?promo=CODE or the dashboard box)
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
@@ -322,6 +323,7 @@ async function boot() {
tank.init({ dataDir: DATA_DIR, chain, accounts, messages, mailer, site: 'https://instantadpay.com' });
legacy.init({ dataDir: DATA_DIR });
traffic.init({ dataDir: DATA_DIR });
promos.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
@@ -635,6 +637,7 @@ const server = http.createServer(async (req, res) => {
const cookieTail = `; Path=/; SameSite=Lax; Max-Age=${30 * 24 * 3600}${IS_PROD ? '; Secure' : ''}`; // 30 days: whoever brings them back gets the credit
const set = [];
set.push('iap.sponsor=' + tok + cookieTail); // last touch wins
const promo = promos.norm(u.searchParams.get('promo')); if (promo) set.push('iap.promo=' + promo + cookieTail); // partner code, redeemed at signup
if (ang) set.push('iap.angle=' + angle + cookieTail);
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);
@@ -839,6 +842,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)
// partner promo code carried on the join link: redeem once per account (ignored if invalid/used)
try {
const pc = parseCookies(req)['iap.promo'];
if (pc) { const g = await promos.redeem(pc, e, 'link'); if (g.ok) { await ads.addEarned(e, g.credits); console.log('promo redeemed', g.code, g.credits, e); } }
} catch (err) { console.error('promo redeem', err.message); }
// 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); } }
@@ -1070,6 +1078,33 @@ const server = http.createServer(async (req, res) => {
}
// -- coaching: every direct's ladder rung, stalled flag, and what to say
// -- holding tank: waiting members, my adoptions, adopt, release (pay it forward)
// -- promo code typed on the dashboard
if (p === '/api/my/promo/redeem' && req.method === 'POST') {
const s = await auth.fromRequest(req);
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
const b = await readBody(req);
const g = await promos.redeem(b.code, s.email, 'dashboard');
if (g.error) return json(res, 400, g);
await ads.addEarned(s.email, g.credits);
return json(res, 200, { ok: true, credits: g.credits, code: g.code, partner: g.partner });
}
// -- admin: promo codes (create/update, switch on/off, redemptions)
if (p === '/api/admin/promos' && req.method === 'GET') {
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
return json(res, 200, await promos.adminView());
}
if (p === '/api/admin/promos' && req.method === 'POST') {
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
const b = await readBody(req);
const r = await promos.create(b);
return json(res, r.error ? 400 : 200, r);
}
if (p === '/api/admin/promos' && req.method === 'PATCH') {
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
const b = await readBody(req);
const r = await promos.setActive(b.code, !!b.active);
return json(res, r.error ? 400 : 200, r);
}
if (p === '/api/my/tank' && req.method === 'GET') {
const s = await auth.fromRequest(req);
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });