9316dfc0db
- Spark: one-tap banner/text campaign templates aimed at the member's link (POST /api/my/toolkit/template); printable QR handout at /handout/<username> - Circuit: videomaker.js renders every promo video/short with the member's end card + QR via ffmpeg (Dockerfile adds ffmpeg + ttf-dejavu), hosted in Spaces; split tester compares join angles - Nexus: Leader Ops (three-level triage, stalled flags, one-click nudges), AI team broadcast kind + Send to my team, credit grants from the leader's earned pool, co-branded join page (sponsor bio for level-3 sponsors), member-funded partner code (promo_codes.funder; redemption charges the funder) + /partners?ref=<username> - toolkit ladder all live; chatbot updated; my.js v20260914e, site.css v20260914c, join.js/partners.js v20260914a Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
75 lines
6.1 KiB
JavaScript
75 lines
6.1 KiB
JavaScript
// 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,funder) VALUES (?,?,?,?,?,?,?,?,?) ON DUPLICATE KEY UPDATE funder=VALUES(funder), 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(), c.funder || null]);
|
|
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, funder: r.funder || null, 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, funder: c.funder ? String(c.funder).toLowerCase() : (cur && cur.funder) || null, 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, funder: c.funder || null };
|
|
}
|
|
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) };
|
|
}
|
|
async function byFunder(email) { return (await impl().list()).filter(c => c.funder === String(email || '').toLowerCase()); }
|
|
module.exports = { init, create, setActive, check, redeem, adminView, norm, byFunder };
|