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:
martbost
2026-09-14 07:15:43 -05:00
parent 0f030a4943
commit 9316dfc0db
14 changed files with 436 additions and 27 deletions
+76 -2
View File
@@ -1487,21 +1487,95 @@
$('tkUsage').textContent = r.freeLeft + ' free this month · then ' + r.cost + ' credits each · you have ' + r.available.toLocaleString() + ' credits';
$('tkCostLine').textContent = r.freeLeft > 0 ? 'This one is free (' + r.freeLeft + ' left this month).' : 'This one costs ' + r.cost + ' credits from your earned pool.';
if (!$('tkKind').options.length) { $('tkKind').innerHTML = r.kinds.map(k => '<option value="' + k.key + '">' + esc(k.label) + '</option>').join(''); $('tkAngle').innerHTML = r.angles.map(a => '<option value="' + a + '">' + a + '</option>').join(''); }
renderTkTools(r);
$('tkHistory').innerHTML = r.history.length ? '<p class="small muted" style="margin:0 0 4px">Recent</p>' + r.history.map(h => '<div class="tk-hist"><span class="muted">' + esc(h.kind) + ' · ' + new Date(h.ts).toLocaleString() + (h.charged ? ' · ' + h.charged + ' credits' : ' · free') + '</span><br>' + esc(h.text).replace(/\n/g, '<br>') + '</div>').join('') : '';
} catch (e) {}
}
// the rest of the ladder: Spark templates + handout, Circuit Video Maker + split tester, Nexus Leader Ops
const tkEsc = t => String(t == null ? '' : t).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
const tkRank = { free: 0, spark: 1, surge: 2, circuit: 3, nexus: 4 };
async function tkPost(url, body) { const r = await (await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body || {}) })).json(); if (r.error) { IAP.status(r.error, 'bad'); return null; } return r; }
function renderTkTools(r) {
const at = tkRank[r.tier] || 0;
// Spark
const spark = at >= 1; $('tkSparkLocked').hidden = spark; $('tkSparkBody').hidden = !spark;
if (!spark) $('tkSparkLocked').textContent = 'Unlocks at Spark: switch on payouts in the Wallet tab. Then one tap builds a banner or text campaign aimed at your link, and you get a printable handout with your QR.';
else { $('tkHandoutLine').hidden = !r.handoutUrl; if (r.handoutUrl) $('tkHandout').href = r.handoutUrl; }
// Circuit
const circ = at >= 3; $('tkCircuitLocked').hidden = circ; $('tkCircuitBody').hidden = !circ;
if (!circ) $('tkCircuitLocked').textContent = 'Unlocks at Circuit: two qualifying buyers in your line. Then every promo video gets your own end card and QR, and the split tester shows which join angle pulls for you.';
else { loadTkVideos(r); loadTkSplit(); }
// Nexus
const nex = at >= 4; $('tkNexusLocked').hidden = nex; $('tkNexusBody').hidden = !nex;
if (!nex) $('tkNexusLocked').textContent = 'Unlocks at Nexus: five qualifying buyers. Then you get team triage across three levels with one-click nudges, AI-drafted team broadcasts, credit grants to your people, a co-branded join page and your own partner code.';
else { loadTkTeam(); renderTkPartner(r); }
}
async function loadTkVideos(r) {
const box = $('tkVideos');
if (!r.videoMaker) { box.innerHTML = '<p class="muted small">The Video Maker is warming up on the server. Check back shortly.</p>'; return; }
if (!r.username) { box.innerHTML = '<p class="muted small">Pick a username first (Profile); it goes on the end card.</p>'; return; }
try {
const v = await (await fetch('/api/my/toolkit/videos')).json(); if (v.error) return;
box.innerHTML = v.list.map(x => '<div class="tk-vid"><b>' + tkEsc(x.title) + '</b>' + (x.status === 'done' ? '<a class="btn small" href="' + tkEsc(x.url) + '" target="_blank" rel="noopener" download>Download</a> <button type="button" class="btn small sec" data-copyvid="' + tkEsc(x.url) + '">Copy link</button>' : x.status === 'queued' || x.status === 'working' ? '<span class="st">Rendering, about a minute. It appears here when done.</span>' : '<button type="button" class="btn small sec" data-mkvid="' + tkEsc(x.slug) + '">Make mine</button>') + '</div>').join('');
if (v.list.some(x => x.status === 'queued' || x.status === 'working')) setTimeout(() => loadTkVideos(r), 15000);
} catch (e) {}
}
async function loadTkSplit() {
try {
const sp = await (await fetch('/api/my/toolkit/split')).json(); if (sp.error) return;
const rows = sp.rows.filter(x => x.views || x.joins);
$('tkSplit').innerHTML = !rows.length ? '<p class="muted small">No link views yet. Share two different angle links this week and the winner shows up here.</p>'
: '<div style="overflow-x:auto"><table class="tk-table"><tr><th>Angle</th><th class="n">Views</th><th class="n">Last 30d</th><th class="n">Joins</th><th class="n">Buyers</th><th class="n">Join rate</th></tr>' + rows.map(x => '<tr' + (x.angle === sp.best ? ' class="best"' : '') + '><td>' + tkEsc(x.angle) + (x.angle === sp.best ? ' (winner so far)' : '') + '<br><span class="muted" style="font-size:11px">' + tkEsc(x.link) + '</span></td><td class="n">' + x.views + '</td><td class="n">' + x.views30 + '</td><td class="n">' + x.joins + '</td><td class="n">' + x.buyers + '</td><td class="n">' + x.rate + '%</td></tr>').join('') + '</table></div><p class="muted small" style="margin:6px 0 0">Rate = joins per 100 views. A winner needs at least 10 views. Send more traffic to the winner, keep testing the rest.</p>';
} catch (e) {}
}
let tkTeamData = null;
async function loadTkTeam() {
try {
const t = await (await fetch('/api/my/toolkit/team')).json(); if (t.error || !t.unlocked) return; tkTeamData = t;
$('tkTeamSub').textContent = t.total + ' in three levels (' + t.byLevel.join(' / ') + ') · ' + t.recent + ' new this week · ' + t.stalled.length + ' stalled';
const list = t.stalled.length ? t.stalled : t.members.slice(0, 12);
$('tkTeam').innerHTML = (t.stalled.length ? '<p class="small" style="margin:0 0 6px">Stalled first: quiet for a while and not yet past the next rung. One click sends them the right message from you.</p>' : '<p class="small muted" style="margin:0 0 6px">Nobody stalled right now. Your newest people:</p>')
+ list.map(m => '<div class="tk-team' + (m.stalled ? ' stalled' : '') + '"><div class="who"><b>' + tkEsc(m.name) + '</b> <span>L' + m.level + ' · ' + tkEsc(m.label) + (m.quietDays ? ' · quiet ' + m.quietDays + 'd' : '') + '</span><br><span>Next: ' + tkEsc(m.next) + '</span></div><button type="button" class="btn small sec" data-nudge="' + tkEsc(m.email) + '" data-say="' + tkEsc(m.say) + '">Nudge</button></div>').join('');
$('tkGrantTo').innerHTML = t.members.map(m => '<option value="' + tkEsc(m.email) + '">' + tkEsc(m.name) + ' (L' + m.level + ')</option>').join('');
} catch (e) {}
}
function renderTkPartner(r) {
const pk = r.partner;
$('tkPartner').innerHTML = pk
? '<p class="small" style="margin:0 0 6px">Code <b>' + tkEsc(pk.code) + '</b> gives ' + pk.credits + ' welcome credits, funded from your earned pool at each redemption. Redeemed ' + pk.uses + ' times.</p><p class="small" style="margin:0 0 4px">Invite link with the code: <code>' + tkEsc(pk.link) + '</code> <button type="button" class="btn small sec" data-copy="' + tkEsc(pk.link) + '">Copy</button></p><p class="small" style="margin:0 0 8px">Your partner kit page (for list owners and site owners you talk to): <code>' + tkEsc(pk.kit) + '</code> <button type="button" class="btn small sec" data-copy="' + tkEsc(pk.kit) + '">Copy</button></p><div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center"><label class="small">Welcome credits <input type="number" id="tkPcN" value="' + pk.credits + '" min="50" max="500" style="width:80px"></label><button type="button" class="btn small sec" id="tkPcGo" data-code="' + tkEsc(pk.code) + '">Update</button></div>'
: '<p class="small muted" style="margin:0 0 6px">Give your recruits a welcome bonus under your own code. Each redemption moves the credits from your earned pool to theirs, so it only costs you when it works. Then hand partners the kit page, which sends their deals under you.</p><div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center"><input id="tkPcCode" placeholder="CODE (letters, numbers)" maxlength="24" style="width:170px;text-transform:uppercase"><label class="small">Welcome credits <input type="number" id="tkPcN" value="100" min="50" max="500" style="width:80px"></label><button type="button" class="btn small" id="tkPcGo">Create my code</button></div>';
}
document.addEventListener('click', async ev => {
const b = ev.target.closest('[data-tpl],[data-mkvid],[data-copyvid],[data-nudge],[data-copy],#tkPcGo,#tkGrantGo,#tkDraftBc,[data-sendnudge]'); if (!b) return;
if (b.dataset.tpl) { b.disabled = true; const r = await tkPost('/api/my/toolkit/template', { kind: b.dataset.tpl, budget: Number($('tkTplBudget').value) }); b.disabled = false; if (r) { IAP.status('Campaign "' + r.name + '" created with ' + r.budget + ' credits. It is under Campaigns.', 'ok'); loadDashboard(); } return; }
if (b.dataset.mkvid) { b.disabled = true; const r = await tkPost('/api/my/toolkit/video', { slug: b.dataset.mkvid }); if (r) { IAP.status(r.status === 'done' ? 'Already made.' : 'Rendering. It appears here in about a minute.', 'ok'); loadTkVideos(tkState); } else b.disabled = false; return; }
if (b.dataset.copyvid || b.dataset.copy) { try { await navigator.clipboard.writeText(b.dataset.copyvid || b.dataset.copy); IAP.status('Copied.', 'ok'); } catch (e) { IAP.status('Copy failed; select it by hand.', 'bad'); } return; }
if (b.dataset.nudge) { const row = b.closest('.tk-team'); const old = row.querySelector('.tk-nudge'); if (old) { old.remove(); return; } const box = document.createElement('div'); box.className = 'tk-nudge'; box.innerHTML = '<textarea rows="3" style="width:100%" maxlength="600"></textarea><div style="display:flex;gap:8px;margin-top:6px"><button type="button" class="btn small" data-sendnudge="' + tkEsc(b.dataset.nudge) + '">Send from me</button><span class="muted small">Lands in their inbox and their email.</span></div>'; box.querySelector('textarea').value = b.dataset.say; row.appendChild(box); return; }
if (b.dataset.sendnudge) { const box = b.closest('.tk-nudge'); b.disabled = true; const r = await tkPost('/api/my/toolkit/nudge', { email: b.dataset.sendnudge, text: box.querySelector('textarea').value }); b.disabled = false; if (r) { IAP.status('Sent.', 'ok'); box.remove(); } return; }
if (b.id === 'tkGrantGo') { b.disabled = true; const r = await tkPost('/api/my/toolkit/grant', { email: $('tkGrantTo').value, credits: Number($('tkGrantN').value) }); b.disabled = false; if (r) { IAP.status(r.credits + ' credits moved to ' + r.toName + '.', 'ok'); loadToolkit(); loadDashboard(); } return; }
if (b.id === 'tkPcGo') { b.disabled = true; const r = await tkPost('/api/my/toolkit/promo', { code: b.dataset.code || ($('tkPcCode') ? $('tkPcCode').value : ''), credits: Number($('tkPcN').value) }); b.disabled = false; if (r) { IAP.status('Code ' + r.code + ' is live: ' + r.credits + ' welcome credits.', 'ok'); loadToolkit(); } return; }
if (b.id === 'tkDraftBc') { if (!tkState || !tkState.unlocked) return; $('tkKind').value = 'broadcast'; $('tkBrief').placeholder = 'What should the team focus on this week?'; $('tkEngine').scrollIntoView({ behavior: 'smooth', block: 'start' }); $('tkBrief').focus(); IAP.status('Pick a focus, hit Write it, then Send to my team.', 'ok'); return; }
});
$('tkKind') && $('tkKind').addEventListener('change', () => { if ($('tkSendBc')) $('tkSendBc').hidden = $('tkKind').value !== 'broadcast'; });
async function tkSendBroadcast() {
const txt = $('tkText').value || ''; const m = /^\s*Subject:\s*(.+)\n+([\s\S]+)$/.exec(txt);
const subject = m ? m[1].trim() : 'A note from your sponsor', body = (m ? m[2] : txt).trim();
if (!(await IAP.confirmBox('Send this to everyone in your three levels? One broadcast a day.', { title: 'Team broadcast', ok: 'Send' }))) return;
const r = await tkPost('/api/my/broadcast', { subject, body: '<p>' + tkEsc(body).replace(/\n{2,}/g, '</p><p>').replace(/\n/g, '<br>') + '</p>', scope: 'all' });
if (r) IAP.status('Sent to ' + (r.sent || r.count || r.recipients || 'your team') + '.', 'ok');
}
async function tkGenerate() {
const btn = $('tkGo'); if (btn.disabled) return; btn.disabled = true; btn.textContent = 'Writing…';
try {
const r = await (await fetch('/api/my/toolkit/generate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ kind: $('tkKind').value, angle: $('tkAngle').value, brief: $('tkBrief').value }) })).json();
if (r.error) { IAP.status(r.error, 'bad'); return; }
$('tkText').value = r.text; $('tkOut').hidden = false;
$('tkText').value = r.text; $('tkOut').hidden = false; $('tkSendBc').hidden = $('tkKind').value !== 'broadcast';
IAP.status(r.charged ? r.charged + ' credits used.' : 'Written. ' + r.freeLeft + ' free left this month.', 'ok');
loadToolkit(); loadDashboard();
} catch (e) { IAP.status('The engine did not answer. Try again.', 'bad'); }
finally { btn.disabled = false; btn.textContent = 'Write it'; }
}
if ($('tkGo')) { $('tkGo').addEventListener('click', tkGenerate); $('tkAgain').addEventListener('click', tkGenerate); $('tkCopy').addEventListener('click', async () => { try { await navigator.clipboard.writeText($('tkText').value); IAP.status('Copied.', 'ok'); } catch (e) { $('tkText').select(); } }); }
if ($('tkGo')) { $('tkGo').addEventListener('click', tkGenerate); $('tkAgain').addEventListener('click', tkGenerate); $('tkCopy').addEventListener('click', async () => { try { await navigator.clipboard.writeText($('tkText').value); IAP.status('Copied.', 'ok'); } catch (e) { $('tkText').select(); } }); $('tkSendBc').addEventListener('click', tkSendBroadcast); }
// promo tools: pill menu switches between posts / swipes / banners / wall / videos
function setPromoSub(name) {
const ids = ['toolkit', 'posts', 'text', 'swipe', 'banners', 'wall', 'objections', 'videos'];