Toolkit: ship the rest of the ladder (Spark templates + handout, Circuit Video Maker + split tester, Nexus Leader Ops, grants, co-brand, partner code)
- 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>
This commit is contained in:
+110
-6
@@ -7,7 +7,7 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const https = require('https');
|
||||
let R = null; // { dataDir, ads, accounts, siteConfig }
|
||||
let R = null; // { dataDir, ads, accounts, siteConfig, coach, messages, promos, videomaker }
|
||||
const MODEL = process.env.OPENROUTER_MODEL || 'deepseek/deepseek-v4-flash:nitro';
|
||||
function init(refs) { R = refs; }
|
||||
function key() { if (process.env.OPENROUTER_API_KEY) return process.env.OPENROUTER_API_KEY.trim(); try { return fs.readFileSync(path.join(R.dataDir, 'openrouter.key'), 'utf8').trim(); } catch (e) { return ''; } }
|
||||
@@ -21,13 +21,13 @@ const TIERS = [
|
||||
{ key: 'free', name: 'Free', need: null, blurb: 'Everything you need to start sharing.', items: [
|
||||
{ t: 'Invite link with six angle front doors', live: true }, { t: 'Social posts, text-a-friend, email swipes, banners', live: true }, { t: 'Banner wall and line banner', live: true }, { t: 'Objection handling and the shorts', live: true }, { t: 'Badge share pages', live: true }] },
|
||||
{ key: 'spark', name: 'Spark', need: 'payouts', needText: 'switch on payouts', blurb: 'Your wallet is registered; the site can pay you.', items: [
|
||||
{ t: 'Campaign templates: one-tap banner and text campaigns aimed at your link', live: false }, { t: 'Printable handout with your QR', live: false }] },
|
||||
{ t: 'Campaign templates: one-tap banner and text campaigns aimed at your link', live: true }, { t: 'Printable handout with your QR', live: true }] },
|
||||
{ key: 'surge', name: 'Surge', need: 'firstBuyer', needText: 'your first qualifying buyer ($20+)', blurb: 'The first AI tool.', items: [
|
||||
{ t: 'AI Copy Engine: posts, DMs, follow-ups, objection replies, emails and stories in the InstantAdPay voice', live: true }, { t: 'Monthly free generations, then ad credits', live: true }] },
|
||||
{ key: 'circuit', name: 'Circuit', need: 'level2', needText: 'two qualifying buyers', blurb: 'Creative and traffic.', items: [
|
||||
{ t: 'Three times the free AI generations', live: true }, { t: 'Video Maker: the shorts and the explainer with your own end card and QR', live: false }, { t: 'Split tester for your join angles', live: false }] },
|
||||
{ t: 'Three times the free AI generations', live: true }, { t: 'Video Maker: the shorts and the explainer with your own end card and QR', live: true }, { t: 'Split tester for your join angles', live: true }] },
|
||||
{ key: 'nexus', name: 'Nexus', need: 'level3', needText: 'five qualifying buyers', blurb: 'Leader tools.', items: [
|
||||
{ t: 'Seven times the free AI generations', live: true }, { t: 'Leader Ops: team triage, one-click nudges, AI-drafted team broadcasts', live: false }, { t: 'Co-branded pages and team credit grants', live: false }, { t: 'Your own partner kit and promo code', live: false }] }
|
||||
{ t: 'Seven times the free AI generations', live: true }, { t: 'Leader Ops: team triage, one-click nudges, AI-drafted team broadcasts', live: true }, { t: 'Co-branded pages and team credit grants', live: true }, { t: 'Your own partner kit and promo code', live: true }] }
|
||||
];
|
||||
function tierFor(badges) {
|
||||
const has = k => badges.includes(k);
|
||||
@@ -55,6 +55,7 @@ const KINDS = {
|
||||
followup: { label: 'Follow-up', shape: 'Write ONE follow-up message (30-70 words) to someone who looked but has not joined or bought. No guilt, no pressure, no fake urgency. Give them one useful reason to look again. Link on the last line.' },
|
||||
objection: { label: 'Objection reply', shape: 'The brief is what the prospect said. Write ONE reply of 60-120 words: concede what is true first, give the honest answer, then invite them to check the ledger themselves. No link unless it helps.' },
|
||||
email: { label: 'Email', shape: 'Write ONE short email: first line "Subject: ..." then a blank line, then a 90-160 word plain-text body ending with a sign-off and the link on its own line.' },
|
||||
broadcast: { label: 'Team broadcast (leaders)', shape: 'Write ONE message from a team leader to their whole team: first line "Subject: ..." then a blank line, then an 80-150 word body. One clear focus for this week (from the brief, or pick: share your link daily, view your ads, message two people), one useful tip, warm and direct, no pressure, no invented numbers. Sign off as the member. No link needed.' },
|
||||
story: { label: 'Story post', shape: 'Write ONE first-person post of 80-140 words telling a small, believable personal story from the brief about advertising, building a line, or the daily routine. No invented numbers. Link on the last line.' }
|
||||
};
|
||||
const ANGLES = {
|
||||
@@ -92,7 +93,9 @@ async function status(email) {
|
||||
const allowance = allowanceFor(tier, sc); const cost = Number(sc.aiCreditsPerGen) || 10;
|
||||
let available = 0; try { const st = await R.ads.viewStatus(email); available = st.earnedAvailable != null ? st.earnedAvailable : (st.earned || 0); } catch (e) {}
|
||||
const link = 'https://instantadpay.com/join/' + (acct.username || acct.code);
|
||||
return { tier, badges, unlocked: ['surge', 'circuit', 'nexus'].includes(tier), engine: !!key(), allowance, used, freeLeft: Math.max(0, allowance - used), cost, available, link,
|
||||
const partner = tier === 'nexus' ? await partnerStatus(email) : null;
|
||||
const handoutUrl = rank[tier] >= rank.spark && acct.username ? 'https://instantadpay.com/handout/' + acct.username : null;
|
||||
return { tier, badges, username: acct.username || null, partner, handoutUrl, videoMaker: !!(R.videomaker && R.videomaker.available()), unlocked: ['surge', 'circuit', 'nexus'].includes(tier), engine: !!key(), allowance, used, freeLeft: Math.max(0, allowance - used), cost, available, link,
|
||||
kinds: Object.entries(KINDS).map(([k, v]) => ({ key: k, label: v.label })), angles: Object.keys(ANGLES),
|
||||
tiers: TIERS.map(t => ({ key: t.key, name: t.name, needText: t.needText || '', blurb: t.blurb, items: t.items, reached: t.key === 'free' || badges.includes(t.need) })),
|
||||
history: (u.history || []).slice(-12).reverse() };
|
||||
@@ -123,4 +126,105 @@ async function adminUsage() {
|
||||
for (const [email, v] of Object.entries(u)) rows.push({ email, month: (v.months || {})[m] || 0, total: Object.values(v.months || {}).reduce((a, b) => a + b, 0), last: (v.history || []).slice(-1)[0] || null });
|
||||
return { month: m, rows: rows.sort((a, b) => b.month - a.month) };
|
||||
}
|
||||
module.exports = { init, status, generate, adminUsage, TIERS, KINDS };
|
||||
// ---- the rest of the ladder (Marty, 2026-09-14): Spark templates + handout, Circuit split tester + Video
|
||||
// Maker, Nexus Leader Ops + team grants + a member-funded partner code ----
|
||||
const KIT = 'https://instantadpay.com';
|
||||
async function tierOf(email) { return tierFor(await R.ads.milestonesOf(email)); }
|
||||
const rank = { free: 0, spark: 1, surge: 2, circuit: 3, nexus: 4 };
|
||||
const atLeast = (tier, need) => rank[tier] >= rank[need];
|
||||
|
||||
// Spark: one-tap campaigns aimed at the member's link, funded from what they already hold
|
||||
async function template(email, memberId, ids, kind, budget) {
|
||||
const tier = await tierOf(email); if (!atLeast(tier, 'spark')) return { error: 'Campaign templates unlock at Spark: switch on payouts first.' };
|
||||
const acct = await R.accounts.byEmail(email); const link = KIT + '/join/' + (acct.username || acct.code);
|
||||
const b = Math.max(50, Math.min(5000, Math.floor(budget) || 200));
|
||||
const T = {
|
||||
banner: { type: 'banner', name: 'My invite: banner 300x250', targetUrl: link, imageUrl: KIT + '/banners/iap-300x250.png', size: '300x250', budget: b },
|
||||
leaderboard: { type: 'banner', name: 'My invite: banner 728x90', targetUrl: link, imageUrl: KIT + '/banners/iap-728x90.png', size: '728x90', budget: b },
|
||||
text: { type: 'text', name: 'My invite: text ad', targetUrl: link, title: 'Paid the second someone buys', body: 'An ad platform that pays sponsors in the same transaction, on-chain. Free to join, credits for showing up.', budget: b },
|
||||
text2: { type: 'text', name: 'My invite: text ad (free angle)', targetUrl: link + '?v=free', title: 'Run your first ad campaign for $0', body: 'Join free, view a few ads, earn credits, launch a campaign. Every payout is public on the ledger.', budget: b }
|
||||
}[kind];
|
||||
if (!T) return { error: 'Pick a template.' };
|
||||
const r = await R.ads.createCampaign(email, memberId || 0, T, ids);
|
||||
if (r.error) return r;
|
||||
return { ok: true, campaign: r.campaign || r, name: T.name, budget: b };
|
||||
}
|
||||
|
||||
// Spark: printable handout with the member's QR (public page so it can be shared or printed anywhere)
|
||||
async function handout(username) {
|
||||
const a = await R.accounts.byUsername(username); if (!a) return null;
|
||||
if (!atLeast(await tierOf(a.email), 'spark')) return null;
|
||||
const link = KIT + '/join/' + username; const qr = '/api/qr?d=' + encodeURIComponent(link);
|
||||
const esc = t => String(t || '').replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
||||
return '<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>InstantAdPay handout for @' + esc(username) + '</title><meta name="robots" content="noindex">'
|
||||
+ '<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Sora:wght@600;700;800&display=swap">'
|
||||
+ '<style>@page{size:letter;margin:.5in}body{margin:0;background:#fff;color:#0c1a15;font-family:"Segoe UI",system-ui,sans-serif}.sheet{width:7.5in;margin:24px auto;padding:0;box-sizing:border-box}.card{border:2px solid #0c1a15;border-radius:18px;overflow:hidden;page-break-inside:avoid;margin-bottom:.35in}.art{display:block;width:100%;height:auto}.body{display:flex;gap:20px;padding:18px 22px;align-items:center}.body h1{font-family:Sora,sans-serif;font-size:26px;margin:0 0 6px;line-height:1.15}.body p{margin:0 0 6px;font-size:14.5px;line-height:1.45;max-width:52ch}.qr{flex:0 0 150px;text-align:center}.qr img{width:150px;height:150px;display:block}.qr small{display:block;font-size:11px;margin-top:4px;word-break:break-all}.foot{padding:8px 22px 14px;font-size:10.5px;color:#556}.bar{display:flex;justify-content:space-between;gap:12px;padding:10px 22px;background:#062a22;color:#43e8c3;font-family:Sora,sans-serif;font-weight:700;font-size:15px}.bar span{color:#fff;font-weight:400;font-size:13px}.print{text-align:center;margin:10px 0 20px}.print button{font:inherit;padding:10px 18px;border-radius:10px;border:1px solid #0c1a15;background:#0c1a15;color:#fff;cursor:pointer}@media print{.print{display:none}.sheet{margin:0}}</style></head><body><div class="sheet">'
|
||||
+ '<p class="print"><button onclick="window.print()">Print this page</button> Two handouts per sheet. Cut along the gap.</p>'
|
||||
+ [1, 2].map(() => '<div class="card"><img class="art" src="/banners/iap-advertise-earn-1200x630.jpg" alt="InstantAdPay"><div class="bar">InstantAdPay <span>advertise and earn, paid on-chain</span></div><div class="body"><div><h1>Your ads run. Your line pays you. Same transaction.</h1><p>Join free with your email. View a few ads a day and earn credits for your own campaign. When anyone you invite buys an ad package, the contract pays you instantly, wallet to wallet, on a public ledger.</p><p><b>Scan the code or go to</b><br>' + esc(link) + '</p><p>Your sponsor: <b>@' + esc(username) + '</b></p></div><div class="qr"><img src="' + qr + '" alt="QR code"><small>' + esc(link.replace('https://', '')) + '</small></div></div><div class="foot">InstantAdPay sells advertising with a performance referral program. Not an investment. No income is guaranteed; results depend on effort. Cryptocurrency carries risk.</div></div>').join('')
|
||||
+ '</div></body></html>';
|
||||
}
|
||||
|
||||
// Circuit: which join angle is pulling for this member
|
||||
async function split(email) {
|
||||
const tier = await tierOf(email);
|
||||
const st = await R.coach.linkStats(email);
|
||||
const acct = await R.accounts.byEmail(email); const base = KIT + '/join/' + (acct.username || acct.code);
|
||||
const rows = (st.angles || []).map(a => ({ angle: a.angle, link: base + (a.angle && a.angle !== 'plain' ? '?v=' + a.angle : ''), views: a.views || 0, views30: a.views30 || 0, joins: a.joins || 0, buyers: a.buyers || 0, rate: a.views ? Math.round(1000 * (a.joins || 0) / a.views) / 10 : 0 }));
|
||||
const withData = rows.filter(r => r.views >= 10); const best = withData.length ? withData.slice().sort((x, y) => y.rate - x.rate || y.joins - x.joins)[0] : null;
|
||||
return { unlocked: atLeast(tier, 'circuit'), rows, best: best ? best.angle : null, sources: st.sources || [] };
|
||||
}
|
||||
async function videos(email) {
|
||||
const tier = await tierOf(email); const acct = await R.accounts.byEmail(email);
|
||||
return { unlocked: atLeast(tier, 'circuit'), available: R.videomaker.available(), username: acct.username || null, list: acct.username ? R.videomaker.list(acct.username) : [] };
|
||||
}
|
||||
async function makeVideo(email, slug) {
|
||||
if (!atLeast(await tierOf(email), 'circuit')) return { error: 'The Video Maker unlocks at Circuit: two qualifying buyers.' };
|
||||
const acct = await R.accounts.byEmail(email); if (!acct.username) return { error: 'Pick a username first; it goes on the end card.' };
|
||||
return R.videomaker.enqueue(email, acct.username, slug);
|
||||
}
|
||||
|
||||
// Nexus: Leader Ops (three levels, stalled flags, one-click nudge), grants, partner code
|
||||
async function team(email) {
|
||||
const tier = await tierOf(email); if (!atLeast(tier, 'nexus')) return { unlocked: false };
|
||||
const levels = await R.accounts.downline(email, 3); const now = Date.now(); const out = [];
|
||||
for (const L of levels) for (const m of L.members.slice(0, 80)) { const c = await R.coach.describe(m, now); out.push({ level: L.level, email: m.email, name: m.username ? '@' + m.username : m.email.replace(/@.*/, '') + '@', rung: c.rung, label: c.label, next: c.next, say: c.say, quietDays: c.quietDays, stalled: c.stalled, joined: m.created, lastSeen: m.lastSeen || 0 }); }
|
||||
const stalled = out.filter(x => x.stalled).sort((a, b) => b.quietDays - a.quietDays);
|
||||
return { unlocked: true, total: out.length, byLevel: [1, 2, 3].map(l => out.filter(x => x.level === l).length), stalled: stalled.slice(0, 40), recent: out.filter(x => now - x.joined < 7 * 86400000).length, members: out.slice(0, 200) };
|
||||
}
|
||||
async function nudge(fromEmail, fromMember, toEmail, text) {
|
||||
if (!atLeast(await tierOf(fromEmail), 'nexus')) return { error: 'Leader Ops unlocks at Nexus.' };
|
||||
const to = String(toEmail || '').toLowerCase(); if (!(await R.accounts.isDownlineOf(fromEmail, to, 3))) return { error: 'That member is not in your line.' };
|
||||
const body = String(text || '').trim().slice(0, 600); if (body.length < 5) return { error: 'Write the message first.' };
|
||||
await R.messages.sendChat(fromMember || 0, fromEmail, to, body); return { ok: true };
|
||||
}
|
||||
async function grant(fromEmail, toEmail, credits) {
|
||||
if (!atLeast(await tierOf(fromEmail), 'nexus')) return { error: 'Team grants unlock at Nexus.' };
|
||||
const to = String(toEmail || '').toLowerCase(); const n = Math.floor(credits);
|
||||
if (!(n >= 10 && n <= 2000)) return { error: 'Grant between 10 and 2,000 credits at a time.' };
|
||||
if (!(await R.accounts.isDownlineOf(fromEmail, to, 3))) return { error: 'That member is not in your line.' };
|
||||
let avail = 0; try { const st = await R.ads.viewStatus(fromEmail); avail = st.earnedAvailable != null ? st.earnedAvailable : (st.earned || 0); } catch (e) {}
|
||||
if (avail < n) return { error: 'You have ' + avail + ' credits available to give; free some up or earn more first.' };
|
||||
if (!(await R.ads.spendEarned(fromEmail, n))) return { error: 'Could not move the credits. Try again.' };
|
||||
await R.ads.addEarned(to, n);
|
||||
const a = await R.accounts.byEmail(fromEmail), b = await R.accounts.byEmail(to);
|
||||
const fromName = a.username ? '@' + a.username : 'your sponsor', toName = b.username ? '@' + b.username : to.replace(/@.*/, '') + '@';
|
||||
try { await R.messages.sendChat(a.memberId || 0, fromEmail, to, 'I just moved ' + n + ' ad credits to your account. Spend them on a campaign pointed at your invite link: Campaigns > New campaign.'); } catch (e) {}
|
||||
return { ok: true, credits: n, fromName, toName };
|
||||
}
|
||||
async function partnerCode(email, code, credits) {
|
||||
if (!atLeast(await tierOf(email), 'nexus')) return { error: 'Your own partner code unlocks at Nexus.' };
|
||||
const mine = await R.promos.byFunder(email);
|
||||
const n = Math.floor(credits); if (!(n >= 50 && n <= 500)) return { error: 'Set the welcome between 50 and 500 credits; they come out of your earned pool each time someone redeems.' };
|
||||
if (mine.length && mine[0].code !== R.promos.norm(code)) return { error: 'You already have a code: ' + mine[0].code + '. Change its amount here or leave it.' };
|
||||
const r = await R.promos.create({ code, credits: n, funder: email, partner: 'member: ' + email, note: 'member-funded partner code', maxUses: 0, expires: null, active: true });
|
||||
if (r.error) return r;
|
||||
const acct = await R.accounts.byEmail(email);
|
||||
return { ok: true, code: r.code.code, credits: n, link: KIT + '/join/' + (acct.username || acct.code) + '?promo=' + r.code.code, kit: KIT + '/partners?ref=' + (acct.username || acct.code) + '&promo=' + r.code.code };
|
||||
}
|
||||
async function partnerStatus(email) {
|
||||
const mine = await R.promos.byFunder(email); const acct = await R.accounts.byEmail(email);
|
||||
if (!mine.length) return null; const c = mine[0];
|
||||
return { code: c.code, credits: c.credits, uses: (await R.promos.adminView()).codes.find(x => x.code === c.code)?.uses || 0, link: KIT + '/join/' + (acct.username || acct.code) + '?promo=' + c.code, kit: KIT + '/partners?ref=' + (acct.username || acct.code) + '&promo=' + c.code };
|
||||
}
|
||||
|
||||
module.exports = { init, status, generate, adminUsage, TIERS, KINDS, template, handout, split, videos, makeVideo, team, nudge, grant, partnerCode, partnerStatus };
|
||||
|
||||
Reference in New Issue
Block a user