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:
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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.' });
|
||||
|
||||
Reference in New Issue
Block a user