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:
@@ -1,4 +1,5 @@
|
||||
FROM node:22-alpine
|
||||
RUN apk add --no-cache ffmpeg ttf-dejavu
|
||||
WORKDIR /app
|
||||
COPY package.json ./
|
||||
RUN npm install --omit=dev --no-audit --no-fund
|
||||
|
||||
+1
-1
@@ -82,7 +82,7 @@ FACTS:
|
||||
- DAILY CLAIM STREAK: finishing the daily ad set and claiming pays 5 credits on day 1, 7 on day 2, 10 from day 3, and 25 on every 7th consecutive day; miss a day and it restarts. After the set, verified visits (up to 20 a day, 1 credit each) keep earning, and the credits are meant to be spent on a campaign.
|
||||
- BLOG (public, instantadpay.com/blog): Marty's coaching and teaching articles on building a line, advertising that pays, and daily habits; each article has its own page and can be shared; RSS at /blog/feed.xml. Members who want to write their own articles: not offered today.
|
||||
- ACHIEVEMENT BADGES ON TELEGRAM (2026-09-13): when a member unlocks Spark/Surge/Circuit/Nexus, their personalised badge image (username on the ribbon) is posted automatically to the team's Telegram payments topic and the main group, once per badge; members cannot trigger posts themselves (the 'Post to Telegram' button is admin-only); 'Share' opens a picker (X, Facebook, Telegram, WhatsApp, LinkedIn, Massifly (copies the post and opens the feed composer), the phone's own share menu, copy link, save image) that shares the member's public badge page instantadpay.com/b/<username>/<badge>, which shows the badge and their join link.
|
||||
- PROMO TOOLKIT BY BADGE + AI COPY ENGINE (2026-09-14): Promo tools > AI Copy Engine shows the ladder: Free (links, posts, swipes, banners, wall, objections, shorts, badge pages), Spark (payouts on; campaign templates and QR handout coming), Surge (first qualifying buyer; the AI Copy Engine is live: posts, DMs, follow-ups, objection replies, emails, story posts in the member's name with their link, honesty rules built in; 20 free generations a month), Circuit (60 free; video end cards and split tester coming), Nexus (150 free; leader ops, co-branded pages, team grants, own partner kit coming). After the free allowance each generation costs 10 ad credits from the earned pool. Credits are advertising, never money.
|
||||
- PROMO TOOLKIT BY BADGE + AI COPY ENGINE (2026-09-14): Promo tools > AI Copy Engine shows the ladder: Free (links, posts, swipes, banners, wall, objections, shorts, badge pages), Spark (payouts on: one-tap campaign templates aimed at the member's link, printable handout with their QR at /handout/<username>), Surge (first qualifying buyer; the AI Copy Engine is live: posts, DMs, follow-ups, objection replies, emails, story posts, team broadcasts in the member's name with their link, honesty rules built in; 20 free generations a month), Circuit (60 free; Video Maker renders every promo video and short with the member's own end card and QR, hosted for download; split tester compares join angles by views, joins, buyers), Nexus (150 free; Leader Ops: three-level team triage with stalled flags and one-click nudges, AI-drafted team broadcasts, credit grants from the leader's earned pool to anyone in their line, co-branded join page showing the sponsor's bio, and the member's own partner code: welcome credits funded from the leader's pool at each redemption, plus a partner kit page /partners?ref=<username>&promo=CODE). After the free allowance each generation costs 10 ad credits from the earned pool. Credits are advertising, never money.
|
||||
- GETTING STARTED CARD (2026-09-14): the top of every new member's Overview shows four steps (username, link wallet, switch on payouts, first package) with the current one explained and one button that opens the right tab and highlights the control (Wallet tab > Link wallet; then Activate payouts). Encouragement only: it can be hidden, and members who only want to view ads and earn credits are never blocked. It disappears once all four are done.
|
||||
- LEADERBOARD + REFERRAL CONTEST (2026-09-14): instantadpay.com/leaderboard ranks members by ad packages sold to people they directly sponsor (dollar value, read from the chain; a member's own linked positions and second accounts never count). Periods: this week (Monday to Sunday, Central time), this month, all time. Weekly and monthly winners are recorded automatically at rollover, announced in Telegram, and the credit prizes set by the admin (a ladder: 1st 1,000 / 2nd 500 / 3rd 250 weekly, 5,000 / 2,500 / 1,000 monthly by default) are granted automatically to those positions; the prize text is shown on the page and on the Overview's Leaderboard card, which also shows the member's own rank. Prizes are credits or packages, never cash.
|
||||
- WHAT'S NEW / ROADMAP (2026-09-14): instantadpay.com/whats-new lists release notes (what shipped, dated, tagged new/improved/fixed) and the roadmap (planned / building, with rough ETAs). The Overview has a "What's new" card with the latest three notes and what is being built; a dot marks notes since the member's last look. Written by the admin in Admin > Releases.
|
||||
|
||||
@@ -80,6 +80,7 @@ async function bootstrap() {
|
||||
try { await q(sql); }
|
||||
catch (e) { if (!['ER_DUP_FIELDNAME', 'ER_DUP_KEYNAME'].includes(e.code)) throw e; }
|
||||
};
|
||||
await alterSafe('ALTER TABLE promo_codes ADD COLUMN funder VARCHAR(190) NULL'); // member-funded partner codes (Nexus), 2026-09-14
|
||||
await alterSafe('ALTER TABLE accounts ADD COLUMN username VARCHAR(30) NULL');
|
||||
await alterSafe('ALTER TABLE accounts ADD UNIQUE KEY uq_username (username)');
|
||||
await alterSafe('ALTER TABLE accounts ADD COLUMN member_id INT NULL');
|
||||
|
||||
@@ -27,8 +27,8 @@ 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()]);
|
||||
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); },
|
||||
@@ -36,7 +36,7 @@ const D = {
|
||||
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 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(); }
|
||||
@@ -44,7 +44,7 @@ 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() });
|
||||
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 }; }
|
||||
@@ -63,11 +63,12 @@ async function redeem(code, email, via) {
|
||||
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 };
|
||||
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) };
|
||||
}
|
||||
module.exports = { init, create, setActive, check, redeem, adminView, norm };
|
||||
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 };
|
||||
|
||||
@@ -94,6 +94,7 @@
|
||||
sponsorName = sp.name;
|
||||
$('jnSponName').textContent = sp.name + (sp.own ? ' (this is your own invite page; visitors see your name here)' : '');
|
||||
if (sp.avatarUrl) { $('jnSponImg').src = sp.avatarUrl; $('jnSponImg').hidden = false; }
|
||||
if (sp.cobrand && sp.bio && $('jnSponBio')) { $('jnSponBio').textContent = sp.bio; $('jnSponBio').hidden = false; $('jnSpon').classList.add('cobrand'); }
|
||||
$('jnSpon').hidden = false;
|
||||
}
|
||||
}).catch(() => {});
|
||||
|
||||
+76
-2
@@ -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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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'];
|
||||
|
||||
@@ -5,12 +5,13 @@
|
||||
const q = new URLSearchParams(location.search);
|
||||
const name = String(q.get('name') || '').replace(/[^A-Za-zÀ-ɏ' -]/g, '').trim().slice(0, 30);
|
||||
const promo = String(q.get('promo') || '').toUpperCase().replace(/[^A-Z0-9_-]/g, '').slice(0, 24);
|
||||
const ref = String(q.get('ref') || '').toLowerCase().replace(/[^a-z0-9_]/g, '').slice(0, 20); // a member's own partner kit (Nexus): the deal lands under them
|
||||
const hello = document.getElementById('pkHello');
|
||||
if (hello && name) { hello.textContent = name + ', this page is for you. Your spot directly under the company at the top is reserved; activate with the $20 package to claim it.'; hello.style.display = 'block'; }
|
||||
const cta = document.getElementById('pkCta');
|
||||
if (cta) {
|
||||
const p = new URLSearchParams(); if (name) p.set('name', name); if (promo) p.set('promo', promo);
|
||||
cta.href = '/join/company' + (p.toString() ? '?' + p.toString() : '');
|
||||
cta.href = (ref ? '/join/' + ref : '/join/company') + (p.toString() ? '?' + p.toString() : '');
|
||||
if (name) cta.textContent = 'Claim my spot, ' + name;
|
||||
}
|
||||
const ex = document.getElementById('pkExample');
|
||||
|
||||
@@ -699,3 +699,8 @@ img{max-width:100%}
|
||||
.tk-tier ul{margin:0;padding-left:16px} .tk-tier li{margin:3px 0} .tk-tier li.soon{opacity:.65} .tk-tier li.soon::after{content:' (coming)';font-size:11px;color:var(--muted)}
|
||||
.tk-tier .lock{font-size:11px;letter-spacing:.08em;text-transform:uppercase;color:#ffd15c}
|
||||
.tk-hist{border-top:1px solid var(--line);padding:8px 0;font-size:13px} .tk-hist .muted{font-size:12px}
|
||||
.tk-sec{margin-top:18px;padding-top:14px;border-top:1px solid var(--line)} .tk-grid{display:flex;gap:8px;flex-wrap:wrap}
|
||||
.tk-vids{display:grid;grid-template-columns:repeat(auto-fill,minmax(210px,1fr));gap:8px} .tk-vid{border:1px solid var(--line);border-radius:10px;padding:10px 12px;font-size:13px} .tk-vid b{display:block;font-weight:600;margin-bottom:4px} .tk-vid .st{font-size:12px;color:var(--muted)}
|
||||
.tk-table{width:100%;border-collapse:collapse;font-size:13px} .tk-table th,.tk-table td{text-align:left;padding:6px 8px;border-bottom:1px solid var(--line)} .tk-table th{font-size:11px;letter-spacing:.06em;text-transform:uppercase;color:var(--muted)} .tk-table td.n{font-variant-numeric:tabular-nums;text-align:right} .tk-table th.n{text-align:right} .tk-table tr.best td{color:var(--mint)}
|
||||
.tk-team{border:1px solid var(--line);border-radius:10px;padding:8px 12px;margin:6px 0;font-size:13px;display:flex;gap:10px;flex-wrap:wrap;align-items:center;justify-content:space-between} .tk-team .who b{font-weight:600} .tk-team .who span{color:var(--muted);font-size:12px} .tk-team.stalled{border-color:rgba(255,209,92,.45)}
|
||||
.tk-nudge{width:100%;margin:8px 0 0;padding:8px 12px;border:1px dashed var(--line);border-radius:10px;font-size:13px}
|
||||
|
||||
+3
-2
@@ -69,6 +69,7 @@
|
||||
.jn-pk .n{color:var(--muted);font-size:11.5px;text-transform:uppercase;letter-spacing:.1em}
|
||||
.jn-foot{color:var(--muted);font-size:13px;border-top:1px solid var(--line);margin-top:40px;padding:22px 0 50px}
|
||||
.jn-err{color:var(--bad)}
|
||||
.jn-spon.cobrand{border:1px solid rgba(67,232,195,.4);border-radius:14px;padding:10px 14px;margin-bottom:12px}.jn-spon.cobrand img{width:56px;height:56px;border-radius:50%}.jn-bio{display:block;margin-top:4px;color:var(--ink);font-size:14px;max-width:60ch}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -79,7 +80,7 @@
|
||||
</div>
|
||||
|
||||
<section class="jn-hero">
|
||||
<div class="jn-spon" id="jnSpon" hidden><img id="jnSponImg" alt="" hidden><span class="small muted">Personal invitation from <b id="jnSponName" style="color:var(--mint)"></b></span></div>
|
||||
<div class="jn-spon" id="jnSpon" hidden><img id="jnSponImg" alt="" hidden><span class="small muted">Personal invitation from <b id="jnSponName" style="color:var(--mint)"></b><br><span id="jnSponBio" class="jn-bio" hidden></span></span></div>
|
||||
<p class="jn-brand">Instant<em>AdPay</em> <span id="jnEyebrow">· Advertise and earn on Polygon</span></p>
|
||||
<p class="jn-hello" id="jnHello" hidden style="margin:8px 0 12px;padding:10px 14px;border:1px solid var(--mint);border-radius:12px;color:var(--mint);font-weight:700"></p>
|
||||
<h1 id="jnHead">Advertise and earn.<br><em>Paid on-chain, instantly.</em></h1>
|
||||
@@ -157,6 +158,6 @@
|
||||
</div>
|
||||
</div>
|
||||
<script src="/assets/common.js?v=20260914a"></script>
|
||||
<script src="/assets/join.js?v=20260913a"></script>
|
||||
<script src="/assets/join.js?v=20260914a"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+42
-3
@@ -5,7 +5,7 @@
|
||||
<title>Member area | InstantAdPay</title>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Sora:wght@600;700;800&display=swap">
|
||||
<link rel="icon" type="image/png" href="/logo-icon.png">
|
||||
<link rel="stylesheet" href="/assets/site.css?v=20260914b">
|
||||
<link rel="stylesheet" href="/assets/site.css?v=20260914c">
|
||||
</head>
|
||||
<body class="bo-body">
|
||||
|
||||
@@ -703,10 +703,49 @@
|
||||
<label class="small">Brief (who it is for, what they said, what you want to say; or leave it blank)<textarea id="tkBrief" rows="3" style="width:100%" maxlength="800" placeholder="e.g. a friend who runs a safelist and says he never gets paid on time"></textarea></label>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin:8px 0"><button type="button" class="btn small" id="tkGo">Write it</button><span class="muted small" id="tkCostLine"></span></div>
|
||||
<div id="tkOut" hidden><textarea id="tkText" rows="8" style="width:100%"></textarea>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;margin:8px 0 0"><button type="button" class="btn small sec" id="tkCopy">Copy</button><button type="button" class="btn small sec" id="tkAgain">Another take</button></div></div>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;margin:8px 0 0"><button type="button" class="btn small sec" id="tkCopy">Copy</button><button type="button" class="btn small sec" id="tkAgain">Another take</button><button type="button" class="btn small" id="tkSendBc" hidden>Send to my team</button></div></div>
|
||||
<div id="tkHistory" style="margin-top:14px"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="tkSpark" class="tk-sec">
|
||||
<div class="card-head"><h3>Spark: campaign templates and handout</h3></div>
|
||||
<div id="tkSparkLocked" class="muted small" hidden></div>
|
||||
<div id="tkSparkBody" hidden>
|
||||
<p class="muted small" style="margin:0 0 8px">One tap builds a campaign aimed at your invite link, funded from the credits you already hold. Edit or pause it any time under Campaigns.</p>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin:0 0 8px"><label class="small">Budget <input type="number" id="tkTplBudget" value="200" min="50" max="5000" style="width:90px"></label><span class="muted small">credits</span></div>
|
||||
<div class="tk-grid">
|
||||
<button type="button" class="btn small sec" data-tpl="banner">Banner 300x250</button>
|
||||
<button type="button" class="btn small sec" data-tpl="leaderboard">Banner 728x90</button>
|
||||
<button type="button" class="btn small sec" data-tpl="text">Text ad</button>
|
||||
<button type="button" class="btn small sec" data-tpl="text2">Text ad, free angle</button>
|
||||
</div>
|
||||
<p class="small" style="margin:10px 0 0" id="tkHandoutLine"><a id="tkHandout" target="_blank" rel="noopener">Print your handout</a> <span class="muted">two per sheet, your QR and your link on each</span></p>
|
||||
</div>
|
||||
</div>
|
||||
<div id="tkCircuit" class="tk-sec">
|
||||
<div class="card-head"><h3>Circuit: Video Maker and split tester</h3></div>
|
||||
<div id="tkCircuitLocked" class="muted small" hidden></div>
|
||||
<div id="tkCircuitBody" hidden>
|
||||
<p class="muted small" style="margin:0 0 8px">Every promo video, re-rendered with your own end card: your name, your link and your QR on the last four seconds. Made once, hosted for you, ready to download or post.</p>
|
||||
<div id="tkVideos" class="tk-vids"></div>
|
||||
<h4 style="margin:16px 0 6px">Which angle is pulling for you</h4>
|
||||
<div id="tkSplit"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="tkNexus" class="tk-sec">
|
||||
<div class="card-head"><h3>Nexus: Leader Ops</h3><span class="sub" id="tkTeamSub"></span></div>
|
||||
<div id="tkNexusLocked" class="muted small" hidden></div>
|
||||
<div id="tkNexusBody" hidden>
|
||||
<div id="tkTeam"></div>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;margin:10px 0 0"><button type="button" class="btn small sec" id="tkDraftBc">Draft a team broadcast with the AI</button></div>
|
||||
<h4 style="margin:16px 0 6px">Team credit grant</h4>
|
||||
<p class="muted small" style="margin:0 0 6px">Move ad credits from your earned pool to anyone in your three levels. They get a message from you with it.</p>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center"><select id="tkGrantTo" style="max-width:240px"></select><input type="number" id="tkGrantN" value="100" min="10" max="2000" style="width:90px"><button type="button" class="btn small" id="tkGrantGo">Send credits</button></div>
|
||||
<h4 style="margin:16px 0 6px">Your partner code and kit</h4>
|
||||
<div id="tkPartner"></div>
|
||||
<p class="muted small" style="margin:8px 0 0">Co-branded join page: your bio (Profile) shows under your name on your invite page, so a visitor sees who is inviting them.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card promo-sec" id="promo-posts">
|
||||
<h3>Social posts, personalized with your link</h3>
|
||||
@@ -945,7 +984,7 @@
|
||||
<script src="/assets/common.js?v=20260914a"></script>
|
||||
<script src="/assets/wallet.js?v=20260911a"></script>
|
||||
<script src="/assets/promo.js?v=20260911a"></script>
|
||||
<script src="/assets/my.js?v=20260914d"></script>
|
||||
<script src="/assets/my.js?v=20260914e"></script>
|
||||
<script src="/assets/chat.js?v=20260907l"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -129,6 +129,6 @@
|
||||
</footer>
|
||||
</div>
|
||||
<script src="/assets/common.js?v=20260914a"></script>
|
||||
<script src="/assets/partners.js?v=20260912a"></script>
|
||||
<script src="/assets/partners.js?v=20260914a"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -33,7 +33,8 @@ const adminMember = require('./adminmember');
|
||||
const syndicate = require('./syndicate');
|
||||
const releases = require('./releases');
|
||||
const leaderboard = require('./leaderboard');
|
||||
const toolkit = require('./toolkit'); // badge-gated promo toolkit + AI Copy Engine (Surge and up) (Marty, 2026-09-14) // referral contest: /leaderboard, Overview card, weekly + monthly winners (Marty, 2026-09-14) // release notes + roadmap: /whats-new, Overview card, Admin > Releases (Marty, 2026-09-14) // blog -> Blotato -> X + Instagram on publish (Marty, 2026-09-13) // admin member card: search, drilldown, edits (Marty, 2026-09-13) // admin-written coaching articles, server-rendered public /blog with SEO metadata (Marty, 2026-09-12)
|
||||
const toolkit = require('./toolkit');
|
||||
const videomaker = require('./videomaker'); // Circuit tool: promo videos with the member's own end card (ffmpeg in the image) // badge-gated promo toolkit + AI Copy Engine (Surge and up) (Marty, 2026-09-14) // referral contest: /leaderboard, Overview card, weekly + monthly winners (Marty, 2026-09-14) // release notes + roadmap: /whats-new, Overview card, Admin > Releases (Marty, 2026-09-14) // blog -> Blotato -> X + Instagram on publish (Marty, 2026-09-13) // admin member card: search, drilldown, edits (Marty, 2026-09-13) // admin-written coaching articles, server-rendered public /blog with SEO metadata (Marty, 2026-09-12)
|
||||
const TRAFFIC_PAGES = new Set(['/', '/ledger', '/contract', '/shorts', '/plays', '/wallets', '/launch', '/partners', '/earning', '/blog', '/whats-new', '/leaderboard']);
|
||||
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
|
||||
@@ -369,7 +370,8 @@ async function boot() {
|
||||
loadOpenTokens();
|
||||
syndicate.init({ dataDir: DATA_DIR, publicDir: PUBLIC_DIR, uploadsDir: UPLOADS_DIR });
|
||||
releases.init({ dataDir: DATA_DIR });
|
||||
toolkit.init({ dataDir: DATA_DIR, ads, accounts, siteConfig });
|
||||
toolkit.init({ dataDir: DATA_DIR, ads, accounts, siteConfig, coach, messages, promos, videomaker, chain });
|
||||
videomaker.init({ dataDir: DATA_DIR, spaces, accounts });
|
||||
leaderboard.init({ chain, accounts, ads, dataDir: DATA_DIR, siteConfig, pushFeed, adminEmail: ADMIN_EMAIL,
|
||||
notify: async text => { const sc = siteConfig(); if (!sc.telegramBotToken || !sc.telegramEchoChatId) return; await telegramSend(sc.telegramEchoChatId, text, sc.telegramEchoTopicId); if (String(sc.leaderboardAnnounceGeneral || '1') !== '0') await telegramSend(sc.telegramEchoChatId, text, null); } });
|
||||
setTimeout(() => leaderboard.rolloverTick().catch(e => console.error('leaderboard rollover', e.message)), 90 * 1000);
|
||||
@@ -839,9 +841,9 @@ const server = http.createServer(async (req, res) => {
|
||||
const t = nameTok.toLowerCase();
|
||||
let a = await accounts.byCode(t); if (!a) a = await accounts.byUsername(t);
|
||||
if (!a && /^\d+$/.test(nameTok)) a = await accounts.byMemberId(Number(nameTok));
|
||||
if (a) { name = a.username ? '@' + a.username : (a.memberId ? 'member #' + a.memberId : null); avatarUrl = a.avatarUrl || null; own = !!(acct && a.email === acct.email); }
|
||||
if (a) { name = a.username ? '@' + a.username : (a.memberId ? 'member #' + a.memberId : null); avatarUrl = a.avatarUrl || null; own = !!(acct && a.email === acct.email); var bio = null, cobrand = false; try { cobrand = (await ads.milestonesOf(a.email)).includes('level3'); if (cobrand) bio = a.bio ? String(a.bio).slice(0, 220) : null; } catch (e) {} }
|
||||
}
|
||||
return json(res, 200, { ref: tok, sponsorId, invited: !!(tok || showTok), name, avatarUrl, own });
|
||||
return json(res, 200, { ref: tok, sponsorId, invited: !!(tok || showTok), name, avatarUrl, own, bio: typeof bio === 'undefined' ? null : bio, cobrand: typeof cobrand === 'undefined' ? false : cobrand });
|
||||
}
|
||||
if (p === '/api/stats' && req.method === 'GET') {
|
||||
let members = 0; try { members = await chain.memberCount(); } catch (e) {}
|
||||
@@ -931,7 +933,7 @@ const server = http.createServer(async (req, res) => {
|
||||
// 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); } }
|
||||
if (pc) { const g = await promos.redeem(pc, e, 'link'); if (g.ok) { if (g.funder && g.funder !== e) { if (await ads.spendEarned(g.funder, g.credits)) { await ads.addEarned(e, g.credits); console.log('promo redeemed (member-funded)', g.code, g.credits, e, 'by', g.funder); } else console.log('promo funder short', g.code, g.funder); } else { 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)) {
|
||||
@@ -1238,6 +1240,57 @@ const server = http.createServer(async (req, res) => {
|
||||
const r = await toolkit.generate(s.email, String(b.kind || ''), String(b.brief || ''), String(b.angle || 'plain'));
|
||||
return json(res, r.error ? 400 : 200, r);
|
||||
}
|
||||
if (p === '/api/my/toolkit/template' && req.method === 'POST') { // Spark: one-tap campaign aimed at the member's link
|
||||
const s = await auth.fromRequest(req);
|
||||
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
|
||||
const b = await readBody(req); const memberId = await auth.refreshMemberId(s);
|
||||
const r = await toolkit.template(s.email, memberId, (await myMemberIds(s)).ids, String(b.kind || ''), Number(b.budget) || 0);
|
||||
return json(res, r.error ? 400 : 200, r);
|
||||
}
|
||||
if (p === '/api/my/toolkit/split' && req.method === 'GET') { // Circuit: per-angle views/joins/buyers for the member's link
|
||||
const s = await auth.fromRequest(req);
|
||||
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
|
||||
return json(res, 200, await toolkit.split(s.email));
|
||||
}
|
||||
if (p === '/api/my/toolkit/videos' && req.method === 'GET') { // Circuit: Video Maker list
|
||||
const s = await auth.fromRequest(req);
|
||||
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
|
||||
return json(res, 200, await toolkit.videos(s.email));
|
||||
}
|
||||
if (p === '/api/my/toolkit/video' && 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 r = await toolkit.makeVideo(s.email, String(b.slug || ''));
|
||||
return json(res, r.error ? 400 : 200, r);
|
||||
}
|
||||
if (p === '/api/my/toolkit/team' && req.method === 'GET') { // Nexus: Leader Ops
|
||||
const s = await auth.fromRequest(req);
|
||||
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
|
||||
return json(res, 200, await toolkit.team(s.email));
|
||||
}
|
||||
if (p === '/api/my/toolkit/nudge' && 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 memberId = await auth.refreshMemberId(s);
|
||||
const r = await toolkit.nudge(s.email, memberId, String(b.email || ''), String(b.text || ''));
|
||||
return json(res, r.error ? 400 : 200, r);
|
||||
}
|
||||
if (p === '/api/my/toolkit/grant' && 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 r = await toolkit.grant(s.email, String(b.email || ''), Number(b.credits) || 0);
|
||||
if (r.ok) pushFeed({ type: 'Grant', from: r.fromName, to: r.toName, credits: r.credits, ts: Date.now() });
|
||||
return json(res, r.error ? 400 : 200, r);
|
||||
}
|
||||
if (p === '/api/my/toolkit/promo' && 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 r = await toolkit.partnerCode(s.email, String(b.code || ''), Number(b.credits) || 0);
|
||||
return json(res, r.error ? 400 : 200, r);
|
||||
}
|
||||
if (p === '/api/admin/toolkit' && req.method === 'GET') {
|
||||
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
|
||||
return json(res, 200, await toolkit.adminUsage());
|
||||
@@ -2446,6 +2499,8 @@ const server = http.createServer(async (req, res) => {
|
||||
res.writeHead(200, baseHeaders({ 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'public, max-age=300' }));
|
||||
return res.end(blog.renderIndex(posts, pg, tag));
|
||||
}
|
||||
m = /^\/handout\/([a-z0-9_]{3,20})$/i.exec(p);
|
||||
if (m) { const h = await toolkit.handout(m[1].toLowerCase()); if (!h) { res.writeHead(404, baseHeaders({ 'Content-Type': 'text/plain' })); return res.end('Not found'); } res.writeHead(200, baseHeaders({ 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-cache' })); return res.end(h); }
|
||||
if (p === '/leaderboard') { res.writeHead(200, baseHeaders({ 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'public, max-age=60' })); return res.end(await leaderboard.renderPage()); }
|
||||
if (p === '/whats-new') { res.writeHead(200, baseHeaders({ 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'public, max-age=120' })); return res.end(releases.renderPage()); }
|
||||
if (p === '/blog/feed.xml') { res.writeHead(200, baseHeaders({ 'Content-Type': 'application/rss+xml; charset=utf-8', 'Cache-Control': 'public, max-age=900' })); return res.end(blog.rss(await blog.listPublished())); }
|
||||
|
||||
+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 };
|
||||
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
// Video Maker (Circuit and up, Marty 2026-09-14): the promo videos re-rendered with the member's own end
|
||||
// card (username, invite link, QR) so the last four seconds carry their link, not the company's.
|
||||
// ffmpeg + a DejaVu font in the image (Dockerfile). One job at a time; sources are the hosted promo
|
||||
// videos in the Spaces bucket; output is hosted at promo/made/<username>/<slug>.mp4 and cached forever
|
||||
// (a member's link never changes). Jobs live in DATA_DIR/video-jobs.json.
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const https = require('https');
|
||||
const os = require('os');
|
||||
const { spawn } = require('child_process');
|
||||
let R = null; // { dataDir, spaces, accounts }
|
||||
let QR = null; try { QR = require('qrcode'); } catch (e) {}
|
||||
const BASE = 'https://coolify-saasytop.nyc3.digitaloceanspaces.com/promo/';
|
||||
const SOURCES = [
|
||||
{ slug: 'instant', title: 'Paid before the page reloads (16:9)', url: BASE + 'instant.mp4', kind: 'landscape' },
|
||||
{ slug: 'instant-portrait', title: 'Paid before the page reloads (9:16)', url: BASE + 'instant-portrait.mp4', kind: 'portrait' },
|
||||
{ slug: 'adspend', title: 'You were buying traffic anyway (16:9)', url: BASE + 'adspend.mp4', kind: 'landscape' },
|
||||
{ slug: 'adspend-portrait', title: 'You were buying traffic anyway (9:16)', url: BASE + 'adspend-portrait.mp4', kind: 'portrait' },
|
||||
{ slug: 'free', title: 'Watch first, spend never (16:9)', url: BASE + 'free.mp4', kind: 'landscape' },
|
||||
{ slug: 'free-portrait', title: 'Watch first, spend never (9:16)', url: BASE + 'free-portrait.mp4', kind: 'portrait' },
|
||||
{ slug: 'ledger', title: 'No back office. No payday. (16:9)', url: BASE + 'ledger.mp4', kind: 'landscape' },
|
||||
{ slug: 'ledger-portrait', title: 'No back office. No payday. (9:16)', url: BASE + 'ledger-portrait.mp4', kind: 'portrait' },
|
||||
{ slug: 'two', title: 'Two buyers open level two (16:9)', url: BASE + 'two.mp4', kind: 'landscape' },
|
||||
{ slug: 'two-portrait', title: 'Two buyers open level two (9:16)', url: BASE + 'two-portrait.mp4', kind: 'portrait' },
|
||||
{ slug: 's01-instant', title: 'Short: Paid before the page reloads', url: BASE + 'shorts/s01-instant.mp4', kind: 'portrait' },
|
||||
{ slug: 's02-free', title: 'Short: Try it without spending a dollar', url: BASE + 'shorts/s02-free.mp4', kind: 'portrait' },
|
||||
{ slug: 's03-two', title: 'Short: Two buyers open level two', url: BASE + 'shorts/s03-two.mp4', kind: 'portrait' },
|
||||
{ slug: 's04-ledger', title: 'Short: Just a public ledger', url: BASE + 'shorts/s04-ledger.mp4', kind: 'portrait' },
|
||||
{ slug: 's05-adspend', title: 'Short: The ad spend pays you back', url: BASE + 'shorts/s05-adspend.mp4', kind: 'portrait' },
|
||||
{ slug: 's06-passive', title: 'Short: Is it passive income? The honest answer', url: BASE + 'shorts/s06-passive.mp4', kind: 'portrait' },
|
||||
{ slug: 's07-immutable', title: 'Short: Nobody can change the split', url: BASE + 'shorts/s07-immutable.mp4', kind: 'portrait' },
|
||||
{ slug: 's08-network', title: 'Short: One budget, the whole network', url: BASE + 'shorts/s08-network.mp4', kind: 'portrait' },
|
||||
{ slug: 's09-tank', title: 'Short: The holding tank', url: BASE + 'shorts/s09-tank.mp4', kind: 'portrait' },
|
||||
{ slug: 's10-leader', title: 'Short: The leader play', url: BASE + 'shorts/s10-leader.mp4', kind: 'portrait' }
|
||||
];
|
||||
const FILE = () => path.join(R.dataDir, 'video-jobs.json');
|
||||
function jobs() { try { return JSON.parse(fs.readFileSync(FILE(), 'utf8')); } catch (e) { return []; } }
|
||||
function saveJobs(j) { try { fs.writeFileSync(FILE(), JSON.stringify(j.slice(-500))); } catch (e) {} }
|
||||
function init(refs) { R = refs; setInterval(() => tick().catch(e => console.error('videomaker', e.message)), 5000); }
|
||||
let FONT = undefined; // Alpine puts ttf-dejavu under /usr/share/fonts/ttf-dejavu (older) or /dejavu (newer): search once
|
||||
function font() { if (FONT !== undefined) return FONT; FONT = null; const walk = d => { let ents = []; try { ents = fs.readdirSync(d, { withFileTypes: true }); } catch (e) { return null; } for (const e of ents) { const f = path.join(d, e.name); if (e.isDirectory()) { const r = walk(f); if (r) return r; } else if (e.name === 'DejaVuSans-Bold.ttf') return f; } return null; }; FONT = walk('/usr/share/fonts'); return FONT; }
|
||||
function available() { return !!(font() && R && R.spaces && R.spaces.enabled()); }
|
||||
|
||||
function run(cmd, args, timeoutMs) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const p = spawn(cmd, args, { stdio: ['ignore', 'ignore', 'pipe'] }); let err = '';
|
||||
const t = setTimeout(() => { p.kill('SIGKILL'); reject(new Error(cmd + ' timeout')); }, timeoutMs || 240000);
|
||||
p.stderr.on('data', c => { err += c; if (err.length > 20000) err = err.slice(-10000); });
|
||||
p.on('error', e => { clearTimeout(t); reject(e); });
|
||||
p.on('close', code => { clearTimeout(t); code === 0 ? resolve() : reject(new Error(cmd + ' exit ' + code + ': ' + err.slice(-400))); });
|
||||
});
|
||||
}
|
||||
function download(url, file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const out = fs.createWriteStream(file);
|
||||
https.get(url, res => { if (res.statusCode !== 200) return reject(new Error('download ' + res.statusCode)); res.pipe(out); out.on('finish', () => out.close(resolve)); }).on('error', reject);
|
||||
});
|
||||
}
|
||||
function probe(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const p = spawn('ffprobe', ['-v', 'error', '-select_streams', 'v:0', '-show_entries', 'stream=width,height', '-of', 'csv=p=0', file]);
|
||||
let out = ''; p.stdout.on('data', c => out += c); p.on('close', () => { const m = /(\d+),(\d+)/.exec(out); m ? resolve({ w: Number(m[1]), h: Number(m[2]) }) : reject(new Error('probe failed')); });
|
||||
});
|
||||
}
|
||||
const ffText = s => String(s).replace(/\\/g, '\\\\').replace(/:/g, '\\:').replace(/'/g, "\\'").replace(/%/g, '\\%');
|
||||
|
||||
// list for the UI: every source with the member's finished video, if made
|
||||
function list(username) {
|
||||
const done = {}; for (const j of jobs()) if (j.username === username && j.status === 'done') done[j.slug] = j;
|
||||
const pending = {}; for (const j of jobs()) if (j.username === username && (j.status === 'queued' || j.status === 'working')) pending[j.slug] = j;
|
||||
return SOURCES.map(s => ({ slug: s.slug, title: s.title, kind: s.kind, url: done[s.slug] ? done[s.slug].url : null, madeAt: done[s.slug] ? done[s.slug].doneAt : null, status: pending[s.slug] ? pending[s.slug].status : (done[s.slug] ? 'done' : 'none') }));
|
||||
}
|
||||
function enqueue(email, username, slug) {
|
||||
if (!available()) return { error: 'The Video Maker is not set up on this server yet.' };
|
||||
const src = SOURCES.find(s => s.slug === slug); if (!src) return { error: 'Pick a video.' };
|
||||
const j = jobs();
|
||||
if (j.find(x => x.username === username && x.slug === slug && (x.status === 'queued' || x.status === 'working'))) return { ok: true, status: 'queued' };
|
||||
const d = j.find(x => x.username === username && x.slug === slug && x.status === 'done'); if (d) return { ok: true, status: 'done', url: d.url };
|
||||
if (j.filter(x => x.status === 'queued').length > 30) return { error: 'The render queue is full right now. Try again in a few minutes.' };
|
||||
j.push({ id: Date.now().toString(36) + Math.random().toString(36).slice(2, 6), email, username, slug, status: 'queued', at: Date.now() });
|
||||
saveJobs(j); return { ok: true, status: 'queued', ahead: j.filter(x => x.status === 'queued').length - 1 };
|
||||
}
|
||||
|
||||
let busy = false;
|
||||
async function tick() {
|
||||
if (busy || !R || !available()) return;
|
||||
const j = jobs(); const job = j.find(x => x.status === 'queued'); if (!job) return;
|
||||
busy = true;
|
||||
try {
|
||||
job.status = 'working'; job.startedAt = Date.now(); saveJobs(j);
|
||||
const url = await render(job);
|
||||
const jj = jobs(); const k = jj.find(x => x.id === job.id); if (k) { k.status = 'done'; k.url = url; k.doneAt = Date.now(); saveJobs(jj); }
|
||||
console.log('videomaker done', job.username, job.slug, url);
|
||||
} catch (e) {
|
||||
const jj = jobs(); const k = jj.find(x => x.id === job.id); if (k) { k.status = 'failed'; k.error = String(e.message || e).slice(0, 200); k.doneAt = Date.now(); saveJobs(jj); }
|
||||
console.error('videomaker failed', job.username, job.slug, e.message);
|
||||
} finally { busy = false; }
|
||||
}
|
||||
async function render(job) {
|
||||
const src = SOURCES.find(s => s.slug === job.slug);
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'vm-'));
|
||||
const srcFile = path.join(dir, 'src.mp4'), qrFile = path.join(dir, 'qr.png'), endFile = path.join(dir, 'end.mp4'), outFile = path.join(dir, 'out.mp4');
|
||||
try {
|
||||
await download(src.url, srcFile);
|
||||
const { w, h } = await probe(srcFile);
|
||||
const link = 'instantadpay.com/join/' + job.username;
|
||||
await QR.toFile(qrFile, 'https://' + link, { width: Math.round(Math.min(w, h) * 0.34), margin: 1, color: { dark: '#061c17', light: '#ffffff' } });
|
||||
const F = font(); const portrait = h > w; const big = Math.round(Math.min(w, h) * (portrait ? 0.075 : 0.07)), mid = Math.round(big * 0.62), small = Math.round(big * 0.48);
|
||||
const qrSize = Math.round(Math.min(w, h) * 0.34);
|
||||
const yTitle = Math.round(h * (portrait ? 0.18 : 0.14)), yQr = Math.round(h * (portrait ? 0.30 : 0.30)), yName = yQr + qrSize + Math.round(h * 0.03), yLink = yName + mid + Math.round(h * 0.015), yFoot = h - Math.round(h * 0.08);
|
||||
const filters = [
|
||||
'[0:v][1:v]overlay=(W-w)/2:' + yQr + '[b]',
|
||||
'[b]drawtext=fontfile=' + F + ":text='" + ffText('Join my line on InstantAdPay') + "':fontcolor=0x43e8c3:fontsize=" + big + ':x=(w-text_w)/2:y=' + yTitle + '[c]',
|
||||
'[c]drawtext=fontfile=' + F + ":text='" + ffText('@' + job.username) + "':fontcolor=0xffffff:fontsize=" + mid + ':x=(w-text_w)/2:y=' + yName + '[d]',
|
||||
'[d]drawtext=fontfile=' + F + ":text='" + ffText(link) + "':fontcolor=0xffd15c:fontsize=" + small + ':x=(w-text_w)/2:y=' + yLink + '[e]',
|
||||
'[e]drawtext=fontfile=' + F + ":text='" + ffText('Free to join. Paid in the same transaction. Not investment advice.') + "':fontcolor=0x8fd8c4:fontsize=" + Math.round(small * 0.8) + ':x=(w-text_w)/2:y=' + yFoot + ',format=yuv420p[v]'
|
||||
].join(';');
|
||||
await run('ffmpeg', ['-y', '-loglevel', 'error', '-f', 'lavfi', '-i', 'color=c=0x061c17:s=' + w + 'x' + h + ':r=30:d=4.5', '-i', qrFile, '-f', 'lavfi', '-i', 'anullsrc=r=44100:cl=stereo', '-filter_complex', filters, '-map', '[v]', '-map', '2:a', '-t', '4.5', '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '24', '-c:a', 'aac', '-shortest', endFile], 120000);
|
||||
await run('ffmpeg', ['-y', '-loglevel', 'error', '-i', srcFile, '-i', endFile, '-filter_complex',
|
||||
'[0:v]fps=30,scale=' + w + ':' + h + ',format=yuv420p,setsar=1[v0];[0:a]aformat=sample_rates=44100:channel_layouts=stereo[a0];[1:v]fps=30,scale=' + w + ':' + h + ',format=yuv420p,setsar=1[v1];[1:a]aformat=sample_rates=44100:channel_layouts=stereo[a1];[v0][a0][v1][a1]concat=n=2:v=1:a=1[v][a]',
|
||||
'-map', '[v]', '-map', '[a]', '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '25', '-c:a', 'aac', '-b:a', '128k', '-movflags', '+faststart', outFile], 420000);
|
||||
const key = 'promo/made/' + job.username + '/' + job.slug + '.mp4';
|
||||
return await R.spaces.put(key, fs.readFileSync(outFile), 'video/mp4');
|
||||
} finally { try { fs.rmSync(dir, { recursive: true, force: true }); } catch (e) {} }
|
||||
}
|
||||
module.exports = { init, list, enqueue, available, SOURCES };
|
||||
Reference in New Issue
Block a user