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
+1
View File
@@ -81,6 +81,7 @@ FACTS:
- HOLDING TANK (Members > My line > Holding tank card): free members who joined with no sponsor wait there; a member who has switched on payouts AND bought their own $20+ package can Adopt one (first come, max 2 open adoptions, 7-day window; if the person never links a wallet or buys, they fall back into the tank; a person can be adopted twice at most). Adopting sets the sponsor, opens a chat and emails the member; their first purchase then binds to the adopter on-chain. Members can also "Release to tank" one of their own free referrals (pay it forward). Admin sees the tank under Members.
- HOLDING TANK ALERTS: when new members land in the tank, a note at the top of every member's Overview names them (usernames) and a post goes to the team's Telegram payments topic; adopt from My line > Holding tank (your own $20 package required).
- LEGACY WELCOME (former Faucet Wave / Tier One Ads members): they join through instantadpay.com/from/faucetwave or instantadpay.com/from/tieroneads and, if their email is on the legacy list, welcome-back credits are added automatically at signup (former advertisers 500, former earners 150; once per person; credits, not POL). They land in the holding tank like any member who joins without a sponsor.
- PROMO CODES: partner site owners get a reusable code; a member redeems it on a join link (?promo=CODE) or in the Overview box "Have a promo code?" and receives free ad credits (amount set per code by the admin, one use per account; codes can cap uses or expire). Credits, not POL.
- 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.
+8
View File
@@ -144,6 +144,14 @@ async function bootstrap() {
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`);
await q(`CREATE TABLE IF NOT EXISTS nudges (email VARCHAR(190) PRIMARY KEY, rung INT NOT NULL, ts BIGINT NOT NULL) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`);
await q(`CREATE TABLE IF NOT EXISTS digests (email VARCHAR(190) PRIMARY KEY, ts BIGINT NOT NULL) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`);
await q(`CREATE TABLE IF NOT EXISTS promo_codes (
code VARCHAR(24) NOT NULL PRIMARY KEY, credits INT NOT NULL, partner VARCHAR(80) NULL, note VARCHAR(200) NULL,
max_uses INT NOT NULL DEFAULT 0, expires BIGINT NOT NULL DEFAULT 0, active TINYINT NOT NULL DEFAULT 1, created BIGINT NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); // partner promo codes -> free ad credits
await q(`CREATE TABLE IF NOT EXISTS promo_redemptions (
code VARCHAR(24) NOT NULL, email VARCHAR(190) NOT NULL, credits INT NOT NULL, via VARCHAR(12) NOT NULL, ts BIGINT NOT NULL,
PRIMARY KEY (code, email)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`);
await q(`CREATE TABLE IF NOT EXISTS page_hits (
day CHAR(10) NOT NULL, host VARCHAR(80) NOT NULL, path VARCHAR(40) NOT NULL, n INT NOT NULL DEFAULT 0,
PRIMARY KEY (day, host, path)
+73
View File
@@ -0,0 +1,73 @@
// Partner promo codes (Marty, 2026-09-12): a reusable code per partner site owner that gives
// members who redeem it free ad credits (earned-grade) on top of whatever they already get.
// Redeemed automatically when someone joins through a link carrying ?promo=CODE, or typed into
// the dashboard. One redemption per code per account; every redemption is logged; optional
// cap (max uses) and expiry; codes can be switched off. Storage: MySQL promo_codes +
// promo_redemptions, or DATA_DIR/promos.json.
const fs = require('fs');
const path = require('path');
const db = require('./db');
let DATA_DIR = null;
const norm = c => String(c || '').trim().toUpperCase().replace(/[^A-Z0-9_-]/g, '').slice(0, 24);
const J = {
db: { v: 1, codes: {}, redemptions: [] },
FILE() { return path.join(DATA_DIR, 'promos.json'); },
load() { try { this.db = Object.assign(this.db, JSON.parse(fs.readFileSync(this.FILE(), 'utf8'))); } catch (e) {} },
save() { try { fs.writeFileSync(this.FILE(), JSON.stringify(this.db)); } catch (e) {} },
async get(code) { return this.db.codes[code] || null; },
async list() { return Object.values(this.db.codes).sort((a, b) => b.created - a.created); },
async put(c) { this.db.codes[c.code] = Object.assign(this.db.codes[c.code] || {}, c); this.save(); return this.db.codes[c.code]; },
async uses(code) { return this.db.redemptions.filter(r => r.code === code).length; },
async redeemed(code, email) { return this.db.redemptions.some(r => r.code === code && r.email === email); },
async addRedemption(r) { this.db.redemptions.push(r); this.save(); },
async redemptions(code, n) { return this.db.redemptions.filter(r => !code || r.code === code).slice(-(n || 200)).reverse(); }
};
const D = {
async get(code) { const r = await db.q('SELECT * FROM promo_codes WHERE code=?', [code]); return r[0] ? row(r[0]) : null; },
async list() { return (await db.q('SELECT * FROM promo_codes ORDER BY created DESC')).map(row); },
async put(c) {
await db.q('INSERT INTO promo_codes (code,credits,partner,note,max_uses,expires,active,created) VALUES (?,?,?,?,?,?,?,?) ON DUPLICATE KEY UPDATE credits=VALUES(credits), partner=VALUES(partner), note=VALUES(note), max_uses=VALUES(max_uses), expires=VALUES(expires), active=VALUES(active)',
[c.code, c.credits, c.partner || null, c.note || null, c.maxUses || 0, c.expires || 0, c.active ? 1 : 0, c.created || Date.now()]);
return this.get(c.code);
},
async uses(code) { const r = await db.q('SELECT COUNT(*) n FROM promo_redemptions WHERE code=?', [code]); return Number(r[0].n); },
async redeemed(code, email) { const r = await db.q('SELECT 1 FROM promo_redemptions WHERE code=? AND email=?', [code, email]); return r.length > 0; },
async addRedemption(r) { await db.q('INSERT IGNORE INTO promo_redemptions (code,email,credits,via,ts) VALUES (?,?,?,?,?)', [r.code, r.email, r.credits, r.via, r.ts]); },
async redemptions(code, n) { const rows = code ? await db.q('SELECT * FROM promo_redemptions WHERE code=? ORDER BY ts DESC LIMIT ?', [code, Number(n) || 200]) : await db.q('SELECT * FROM promo_redemptions ORDER BY ts DESC LIMIT ?', [Number(n) || 200]); return rows.map(r => ({ code: r.code, email: r.email, credits: Number(r.credits), via: r.via, ts: Number(r.ts) })); }
};
const row = r => ({ code: r.code, credits: Number(r.credits), partner: r.partner || '', note: r.note || '', maxUses: Number(r.max_uses) || 0, expires: Number(r.expires) || 0, active: !!Number(r.active), created: Number(r.created) });
const impl = () => db.enabled() ? D : J;
function init(opts) { DATA_DIR = opts.dataDir; if (!db.enabled()) J.load(); }
async function create(c) {
const code = norm(c.code); if (!code || code.length < 3) return { error: 'Code must be 3 to 24 letters or numbers.' };
const credits = Math.floor(Number(c.credits)); if (!(credits > 0) || credits > 100000) return { error: 'Credits must be between 1 and 100,000.' };
const cur = await impl().get(code);
const saved = await impl().put({ code, credits, partner: String(c.partner || '').slice(0, 80), note: String(c.note || '').slice(0, 200), maxUses: Math.max(0, Math.floor(Number(c.maxUses) || 0)), expires: c.expires ? Number(new Date(c.expires)) || 0 : 0, active: c.active !== false && c.active !== 0 && c.active !== '0', created: cur ? cur.created : Date.now() });
return { ok: true, code: saved };
}
async function setActive(code, active) { const c = await impl().get(norm(code)); if (!c) return { error: 'No such code.' }; await impl().put(Object.assign(c, { active: !!active })); return { ok: true }; }
// why a code cannot be used right now, or null when it can (email optional: skips the per-account check)
async function check(code, email) {
const k = norm(code); if (!k) return { error: 'Enter a promo code.' };
const c = await impl().get(k); if (!c || !c.active) return { error: 'That promo code is not valid.' };
if (c.expires && Date.now() > c.expires) return { error: 'That promo code has expired.' };
if (email && await impl().redeemed(k, email)) return { error: 'You already used that promo code.' };
if (c.maxUses && (await impl().uses(k)) >= c.maxUses) return { error: 'That promo code has been fully redeemed.' };
return null;
}
// redeem for an account; the caller adds the credits. via = 'link' | 'dashboard'
async function redeem(code, email, via) {
const k = norm(code); const e = String(email || '').toLowerCase();
const bad = await check(k, e); if (bad) return bad;
const c = await impl().get(k);
await impl().addRedemption({ code: k, email: e, credits: c.credits, via: via || 'dashboard', ts: Date.now() });
return { ok: true, credits: c.credits, code: k, partner: c.partner };
}
async function adminView() {
const codes = await impl().list();
for (const c of codes) c.uses = await impl().uses(c.code);
return { codes, recent: await impl().redemptions(null, 100) };
}
module.exports = { init, create, setActive, check, redeem, adminView, norm };
+16 -1
View File
@@ -264,6 +264,21 @@
<div class="card"><div class="card-head"><h3>Angles</h3><span class="sub">join-page hook copy</span></div><div class="tablewrap"><table class="adm-table" id="trfAngles"></table></div></div>
</div>
<div class="card"><div class="card-head"><h3>By day</h3><span class="sub">page views, signups</span></div><div class="tablewrap"><table class="adm-table" id="trfDaily"></table></div></div>
<div class="card" id="promoAdmin">
<div class="card-head"><h3>Partner promo codes</h3><span class="sub">free ad credits for members who redeem a partner's code</span></div>
<p class="muted small" style="margin:0 0 10px">Give a site owner a code. Their members redeem it on a join link (<code>instantadpay.com/join/martbost?promo=CODE</code>) or in the "Have a promo code?" box on the Overview. One use per account; uses and the last redemptions are listed below.</p>
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:8px;align-items:end">
<label class="small">Code<input id="pcCode" type="text" placeholder="TRAFFICWAVE" style="text-transform:uppercase"></label>
<label class="small">Credits<input id="pcCredits" type="number" min="1" placeholder="250"></label>
<label class="small">Partner<input id="pcPartner" type="text" placeholder="site or owner"></label>
<label class="small">Max uses (0 = unlimited)<input id="pcMax" type="number" min="0" value="0"></label>
<label class="small">Expires (optional)<input id="pcExpires" type="date"></label>
<button type="button" class="btn small" id="pcSave">Save code</button>
</div>
<p class="small" id="pcMsg" hidden style="margin:8px 0 0"></p>
<div class="tablewrap" style="margin-top:12px"><table class="adm-table" id="pcTable"></table></div>
<div class="tablewrap" style="margin-top:12px"><table class="adm-table" id="pcRecent"></table></div>
</div>
</div>
<div class="pane" id="pane-pnl" hidden>
<div class="card">
@@ -330,6 +345,6 @@
</div>
<script src="/assets/common.js?v=20260912b"></script>
<script src="/assets/admin.js?v=20260912a"></script>
<script src="/assets/admin.js?v=20260912b"></script>
</body>
</html>
+22
View File
@@ -313,7 +313,29 @@
// ── traffic: referring domains / sources, landing pages, angles, by day ──
let trfDays = 30;
document.querySelectorAll('#trfRange [data-days]').forEach(b => b.addEventListener('click', () => { trfDays = Number(b.dataset.days); document.querySelectorAll('#trfRange [data-days]').forEach(x => x.classList.toggle('on', x === b)); loadTraffic().catch(e => IAP.status(e.message, 'bad')); }));
async function loadPromos() {
const d = await (await fetch('/api/admin/promos')).json();
if (d.error) throw new Error(d.error);
const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
const when = t => t ? new Date(t).toLocaleDateString() : '';
$('pcTable').innerHTML = '<tr><th>Code</th><th>Credits</th><th>Partner</th><th>Uses</th><th>Max</th><th>Expires</th><th>Status</th><th></th></tr>'
+ (d.codes.length ? d.codes.map(c => '<tr><td><b>' + esc(c.code) + '</b></td><td>' + c.credits.toLocaleString() + '</td><td>' + esc(c.partner) + '</td><td>' + c.uses + '</td><td>' + (c.maxUses || '∞') + '</td><td>' + (c.expires ? when(c.expires) : '') + '</td><td>' + (c.active ? 'active' : 'off') + '</td><td class="act"><button type="button" class="btn small ghost" data-pctoggle="' + esc(c.code) + '" data-on="' + (c.active ? 0 : 1) + '">' + (c.active ? 'Switch off' : 'Switch on') + '</button></td></tr>').join('') : '<tr><td colspan="8" class="muted">No codes yet.</td></tr>');
$('pcRecent').innerHTML = '<tr><th>When</th><th>Code</th><th>Email</th><th>Credits</th><th>Via</th></tr>'
+ (d.recent.length ? d.recent.map(r => '<tr><td>' + new Date(r.ts).toLocaleString() + '</td><td>' + esc(r.code) + '</td><td>' + esc(r.email) + '</td><td>' + r.credits + '</td><td>' + esc(r.via) + '</td></tr>').join('') : '<tr><td colspan="5" class="muted">No redemptions yet.</td></tr>');
document.querySelectorAll('[data-pctoggle]').forEach(b => b.addEventListener('click', async () => {
try { await api('/api/admin/promos', { code: b.dataset.pctoggle, active: b.dataset.on === '1' }, 'PATCH'); loadPromos(); } catch (e) { IAP.status(e.message, 'bad'); }
}));
}
if ($('pcSave')) $('pcSave').addEventListener('click', async () => {
const msg = $('pcMsg'); msg.hidden = false;
try {
const r = await api('/api/admin/promos', { code: $('pcCode').value, credits: $('pcCredits').value, partner: $('pcPartner').value, maxUses: $('pcMax').value, expires: $('pcExpires').value || null, active: true });
msg.textContent = 'Saved ' + r.code.code + ': ' + r.code.credits + ' credits.'; msg.style.color = 'var(--mint)';
$('pcCode').value = ''; $('pcCredits').value = ''; $('pcPartner').value = ''; loadPromos();
} catch (e) { msg.textContent = e.message; msg.style.color = '#ff8a8a'; }
});
async function loadTraffic() {
loadPromos().catch(e => IAP.status(e.message, 'bad'));
const d = await (await fetch('/api/admin/traffic?days=' + trfDays)).json();
if (d.error) throw new Error(d.error);
const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
+19
View File
@@ -2215,3 +2215,22 @@
render();
})();
// ── partner promo codes: typed on the Overview (Marty, 2026-09-12) ──
(function () {
const btn = document.getElementById('promoApply'), inp = document.getElementById('promoCode'), msg = document.getElementById('promoMsg');
if (!btn || !inp) return;
const say = (t, ok) => { msg.hidden = false; msg.textContent = t; msg.style.color = ok ? 'var(--mint)' : '#ff8a8a'; };
const go = async () => {
const code = inp.value.trim(); if (!code) { say('Enter a promo code.', false); return; }
btn.disabled = true;
try {
const r = await (await fetch('/api/my/promo/redeem', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code }) })).json();
if (r.error) { say(r.error, false); return; }
say('Added ' + Number(r.credits).toLocaleString() + ' credits' + (r.partner ? ' from ' + r.partner : '') + '. They are in your balance now.', true);
inp.value = ''; if (typeof loadDashboard === 'function') loadDashboard();
} catch (e) { say('Could not apply that code. Try again.', false); }
finally { btn.disabled = false; }
};
btn.addEventListener('click', go); inp.addEventListener('keydown', e => { if (e.key === 'Enter') go(); });
})();
+5 -1
View File
@@ -258,6 +258,10 @@
</div>
<div class="card"><h3>Earning levels</h3>
<p id="qualLine" class="muted small">…</p></div>
<div class="card" id="promoCard"><h3>Have a promo code?</h3>
<p class="muted small" style="margin:6px 0 10px">Codes from partner sites add free ad credits to your account. One use per code.</p>
<div style="display:flex;gap:8px;flex-wrap:wrap"><input id="promoCode" type="text" placeholder="CODE" autocomplete="off" style="flex:1;min-width:140px;text-transform:uppercase"><button type="button" class="btn small" id="promoApply">Apply</button></div>
<p class="small" id="promoMsg" hidden style="margin:8px 0 0"></p></div>
</div>
</div>
<div class="card" id="adSlotOverview" hidden></div>
@@ -911,7 +915,7 @@
<script src="/assets/common.js?v=20260912b"></script>
<script src="/assets/wallet.js?v=20260911a"></script>
<script src="/assets/promo.js?v=20260911a"></script>
<script src="/assets/my.js?v=20260912b"></script>
<script src="/assets/my.js?v=20260912c"></script>
<script src="/assets/chat.js?v=20260907l"></script>
</body>
</html>
+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.' });