diff --git a/chatbot.js b/chatbot.js index d1cdea4..e8c48de 100644 --- a/chatbot.js +++ b/chatbot.js @@ -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. diff --git a/db.js b/db.js index f8ca8b1..3144a39 100644 --- a/db.js +++ b/db.js @@ -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) diff --git a/promos.js b/promos.js new file mode 100644 index 0000000..008f7dd --- /dev/null +++ b/promos.js @@ -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 }; diff --git a/public/admin.html b/public/admin.html index 66d8174..e04dbea 100644 --- a/public/admin.html +++ b/public/admin.html @@ -264,6 +264,21 @@

Angles

join-page hook copy

By day

page views, signups
+
+

Partner promo codes

free ad credits for members who redeem a partner's code
+

Give a site owner a code. Their members redeem it on a join link (instantadpay.com/join/martbost?promo=CODE) or in the "Have a promo code?" box on the Overview. One use per account; uses and the last redemptions are listed below.

+
+ + + + + + +
+ +
+
+

Earning levels

…

+

Have a promo code?

+

Codes from partner sites add free ad credits to your account. One use per code.

+
+
@@ -911,7 +915,7 @@ - + diff --git a/server.js b/server.js index 476e8fb..2a2509a 100644 --- a/server.js +++ b/server.js @@ -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.' });