LinkSpin test area: InstantAdPay engine fork rebranded, network registry, sponsor carry-over with engine activation and claim window, rotator with /r/ redirects, link-domain mini-sites

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-09-15 16:16:52 -05:00
commit 010e8d7ffc
130 changed files with 20096 additions and 0 deletions
+292
View File
@@ -0,0 +1,292 @@
// Coaching layer: where each member sits on the ladder, what to say to them,
// automatic nudges to stalled members, a weekly digest to their sponsor, a
// prospect list per member, and per-angle link stats. Dual-mode store like the
// other modules (MySQL when DATABASE_URL is set, JSON files on the volume otherwise).
const fs = require('fs');
const path = require('path');
const db = require('./db');
let DATA_DIR = null, chain = null, accounts = null, mailer = null;
const DAY = 86400000;
// ---- the ladder (same rungs the Overview stepper and achievements use) ----
const RUNGS = [
{ n: 0, key: 'joined', label: 'Joined', next: 'Link a wallet',
say: 'Hey {{name}}, quick one: link your wallet on the Wallet tab (one free signature, it cannot move funds). That is what lets payouts reach you. Two minutes, and I am here if you get stuck.',
mail: ['Your LinkSpin wallet is not linked yet', 'You joined LinkSpin but no wallet is linked yet, so nothing can pay you. Open the Wallet tab and press Connect and link wallet. One free signature, it cannot move funds. Then switch on payouts and you are set for good.'] },
{ n: 1, key: 'wallet', label: 'Wallet linked', next: 'Switch on payouts',
say: 'Wallet is linked, nice. Next is Switch on payouts (Wallet tab, one free transaction). Do it before anyone under you buys, so nothing passes you by.',
mail: ['One free step left: switch on payouts', 'Your wallet is linked. One free transaction on the Wallet tab switches on payouts, and from then on every purchase in your line can pay you in the same transaction it happens. Do it before your people start buying: the contract locks each buyer to their sponsor at their first purchase.'] },
{ n: 2, key: 'payouts', label: 'Payouts on', next: 'Buy a first package ($20+ counts)',
say: 'You are set to earn. Fastest way to see it work: the $20 Activation package. It counts as a qualifying buy for your sponsor and gives you 2,000 credits to run your first ad.',
mail: ['See a payout land: your first package', 'Payouts are on. The fastest way to see the whole thing work is the $20 Activation package on Buy packages: 2,000 credits for your own ads, and your sponsor gets paid the second it settles, on a public ledger you can open yourself.'] },
{ n: 3, key: 'bought', label: 'Bought $20+', next: 'Share the link with one person',
say: 'Everything is on. Now send your invite link to one person today. Promo tools has a ready-to-send message. Who is the first person you thought of?',
mail: ['Send your link to one person today', 'Everything on your account is switched on. Nobody has joined your link yet. Open Promo tools, copy the ready-to-send message, and send it to one person today. One conversation a day is the whole job.'] },
{ n: 4, key: 'first', label: 'First buyer', next: 'One more buyer opens level 2',
say: 'First buyer, congrats. One more $20+ buyer opens level 2 for you. Want a shortcut? Qualified Start under Buy packages lets you be your own second buyer.',
mail: ['One more buyer opens level 2', 'You have your first qualifying buyer. One more $20+ buyer opens level 2 (20% on every package your directs\' people buy). If you would rather not wait, Qualified Start on Buy packages lets you be your own second buyer with a wallet you own.'] },
{ n: 5, key: 'level2', label: 'Level 2 open', next: 'Three more buyers open level 3',
say: 'Level 2 is open. Three more qualifying buyers open level 3 and the Nexus badge. Which of your people is closest?',
mail: ['Three more buyers open level 3', 'Level 2 is open on your account. Five qualifying buyers open level 3 and the Nexus badge, and every package on all three levels pays you. Which of your people is closest? Message them from My line.'] },
{ n: 6, key: 'level3', label: 'Level 3 open', next: 'Coach your directs to their two',
say: 'Fully qualified. Now the multiplier: teach your directs the same three steps. Their buyers are your level 2 and 3.',
mail: null }
];
function rungFor(acct, mm) {
if (!acct || !acct.address) return 0;
if (!acct.memberId) return 1;
if (!mm || !mm.countedAsBuyer) return 2;
const bc = mm.buyerCount || 0;
if (bc === 0) return 3;
if (bc === 1) return 4;
if (bc < 5) return 5;
return 6;
}
const memberCache = new Map();
async function member(id) {
if (!id) return null;
const c = memberCache.get(id);
if (c && Date.now() - c.t < 60000) return c.v;
let v = null; try { v = await chain.member(id); } catch (e) {}
memberCache.set(id, { t: Date.now(), v });
return v;
}
const STALL_DAYS = 3;
async function describe(acct, now = Date.now()) {
const mm = await member(acct.memberId);
const rung = rungFor(acct, mm);
const last = Math.max(acct.created || 0, acct.lastSeen || 0);
const quietDays = Math.floor((now - last) / DAY);
const stalled = rung < 6 && quietDays >= STALL_DAYS;
const R = RUNGS[rung];
return { rung, label: R.label, next: R.next, say: R.say, quietDays, stalled, buyerCount: mm ? mm.buyerCount : 0,
counted: !!(mm && mm.countedAsBuyer), joined: acct.created, lastSeen: acct.lastSeen || 0, onchainSponsorId: mm ? mm.sponsorId : null };
}
// coaching view for a sponsor: every direct, ladder rung, stalled flag, what to say
async function coachView(email) {
const levels = await accounts.downline(email, 1);
const directs = levels.length ? levels[0].members : [];
const me = await accounts.byEmail(email);
const myId = me ? (me.memberId || 0) : 0;
const now = Date.now();
const out = [];
for (const d of directs) {
const c = await describe(d, now);
// bound: the contract pays whoever the member registered under. A direct who activated with
// no sponsor (or a different one) is in this line on the site but pays this member nothing.
c.bound = c.onchainSponsorId == null ? null : (myId > 0 && c.onchainSponsorId === myId);
c.free = !d.memberId; // still free: can be released to the holding tank (pay it forward)
c.address = (!d.memberId && d.address) ? d.address : null; // linked wallet of a free direct: the PIF gift target
out.push(Object.assign({ email: d.email, name: d.username ? '@' + d.username : (d.memberId ? 'member #' + d.memberId : d.email.replace(/^(.).*(@.*)$/, '$1***$2')), memberId: d.memberId || 0 }, c));
}
// most actionable first: stalled lowest rung, then quiet days
out.sort((a, b) => (Number(b.stalled) - Number(a.stalled)) || (a.rung - b.rung) || (b.quietDays - a.quietDays));
return { directs: out, stalled: out.filter(x => x.stalled).length, rungs: RUNGS.map(r => ({ n: r.n, label: r.label, next: r.next })) };
}
// ---- stores ----
const J = {
db: { v: 1, nudges: {}, digests: {}, prospects: [], nextId: 1, views: [] },
FILE: () => path.join(DATA_DIR, 'coach.json'),
load() { try { this.db = Object.assign(this.db, JSON.parse(fs.readFileSync(this.FILE(), 'utf8'))); } catch (e) {} },
save() { try { fs.writeFileSync(this.FILE(), JSON.stringify(this.db)); } catch (e) {} },
async lastNudge(email) { return this.db.nudges[email] || null; },
async setNudge(email, rung, ts) { this.db.nudges[email] = { rung, ts }; this.save(); },
async lastDigest(email) { return this.db.digests[email] || 0; },
async setDigest(email, ts) { this.db.digests[email] = ts; this.save(); },
async prospects(email) { return this.db.prospects.filter(p => p.owner === email).sort((a, b) => (a.nextTs || 9e15) - (b.nextTs || 9e15)); },
async saveProspect(email, p) {
if (p.id) { const cur = this.db.prospects.find(x => x.id === p.id && x.owner === email); if (!cur) return null; Object.assign(cur, p, { updated: Date.now() }); this.save(); return cur; }
const row = Object.assign({}, p, { id: this.db.nextId++, owner: email, created: Date.now(), updated: Date.now() }); this.db.prospects.push(row); this.save(); return row;
},
async removeProspect(email, id) { const n = this.db.prospects.length; this.db.prospects = this.db.prospects.filter(x => !(x.id === Number(id) && x.owner === email)); this.save(); return n !== this.db.prospects.length; },
async addView(token, angle, ts, ref) { this.db.views.push({ token, angle, ts, ref: ref || '' }); if (this.db.views.length > 50000) this.db.views = this.db.views.slice(-40000); this.save(); },
async addClick(campaignId, src, ts) { this.db.clicks = this.db.clicks || []; this.db.clicks.push({ campaignId, src, ts }); if (this.db.clicks.length > 50000) this.db.clicks = this.db.clicks.slice(-40000); this.save(); },
async clicksFor(ids) { return (this.db.clicks || []).filter(c => ids.includes(c.campaignId)); },
async views(tokens, since) { return this.db.views.filter(v => tokens.includes(v.token) && v.ts >= since); },
async viewsSince(since) { return this.db.views.filter(v => v.ts >= since); }
};
const D = {
async lastNudge(email) { const r = await db.q('SELECT rung, ts FROM nudges WHERE email=?', [email]); return r[0] ? { rung: r[0].rung, ts: Number(r[0].ts) } : null; },
async setNudge(email, rung, ts) { await db.q('INSERT INTO nudges (email,rung,ts) VALUES (?,?,?) ON DUPLICATE KEY UPDATE rung=VALUES(rung), ts=VALUES(ts)', [email, rung, ts]); },
async lastDigest(email) { const r = await db.q('SELECT ts FROM digests WHERE email=?', [email]); return r[0] ? Number(r[0].ts) : 0; },
async setDigest(email, ts) { await db.q('INSERT INTO digests (email,ts) VALUES (?,?) ON DUPLICATE KEY UPDATE ts=VALUES(ts)', [email, ts]); },
async prospects(email) {
const rows = await db.q('SELECT * FROM prospects WHERE owner_email=? ORDER BY COALESCE(next_ts, 9e15), created', [email]);
return rows.map(r => ({ id: r.id, owner: r.owner_email, name: r.name, contact: r.contact, status: r.status, note: r.note, nextTs: r.next_ts ? Number(r.next_ts) : null, created: Number(r.created), updated: Number(r.updated) }));
},
async saveProspect(email, p) {
if (p.id) {
await db.q('UPDATE prospects SET name=?, contact=?, status=?, note=?, next_ts=?, updated=? WHERE id=? AND owner_email=?', [p.name, p.contact, p.status, p.note, p.nextTs || null, Date.now(), Number(p.id), email]);
return (await this.prospects(email)).find(x => x.id === Number(p.id)) || null;
}
const r = await db.q('INSERT INTO prospects (owner_email,name,contact,status,note,next_ts,created,updated) VALUES (?,?,?,?,?,?,?,?)', [email, p.name, p.contact, p.status, p.note, p.nextTs || null, Date.now(), Date.now()]);
return (await this.prospects(email)).find(x => x.id === r.insertId) || null;
},
async removeProspect(email, id) { const r = await db.q('DELETE FROM prospects WHERE id=? AND owner_email=?', [Number(id), email]); return r.affectedRows > 0; },
async addView(token, angle, ts, ref) { await db.q('INSERT INTO join_views (token,angle,ts,ref) VALUES (?,?,?,?)', [token, angle, ts, ref || null]); },
async addClick(campaignId, src, ts) { await db.q('INSERT INTO click_sources (campaign_id,src,ts) VALUES (?,?,?)', [Number(campaignId), src, ts]); },
async clicksFor(ids) { if (!ids.length) return []; const rows = await db.q('SELECT campaign_id, src, ts FROM click_sources WHERE campaign_id IN (' + ids.map(() => '?').join(',') + ')', ids); return rows.map(r => ({ campaignId: r.campaign_id, src: r.src, ts: Number(r.ts) })); },
async views(tokens, since) {
if (!tokens.length) return [];
const rows = await db.q('SELECT token, angle, ts, ref FROM join_views WHERE ts>=? AND token IN (' + tokens.map(() => '?').join(',') + ')', [since, ...tokens]);
return rows.map(r => ({ token: r.token, angle: r.angle, ts: Number(r.ts), ref: r.ref || '' }));
},
async viewsSince(since) {
const rows = await db.q('SELECT token, angle, ts, ref FROM join_views WHERE ts>=?', [since]);
return rows.map(r => ({ token: r.token, angle: r.angle, ts: Number(r.ts), ref: r.ref || '' }));
}
};
const impl = () => db.enabled() ? D : J;
// ---- prospects (a member's own "people I've talked to" list) ----
const STATUSES = ['new', 'contacted', 'interested', 'joined', 'bought', 'not now'];
function cleanProspect(b) {
const s = v => String(v || '').trim().slice(0, 120);
const status = STATUSES.includes(String(b.status || '').toLowerCase()) ? String(b.status).toLowerCase() : 'new';
const nextTs = b.nextTs ? Number(b.nextTs) : (b.next ? Date.parse(b.next) : null);
return { id: b.id ? Number(b.id) : 0, name: s(b.name), contact: s(b.contact), status, note: String(b.note || '').trim().slice(0, 400), nextTs: nextTs && !isNaN(nextTs) ? nextTs : null };
}
async function prospects(email) { return impl().prospects(String(email).toLowerCase()); }
async function saveProspect(email, body) {
const p = cleanProspect(body || {});
if (!p.name) return { error: 'Give the prospect a name.' };
if (!p.id) { const n = (await prospects(email)).length; if (n >= 500) return { error: 'That is a lot of prospects. Archive some first.' }; }
const row = await impl().saveProspect(String(email).toLowerCase(), p);
return row ? { ok: true, prospect: row } : { error: 'No such prospect.' };
}
async function removeProspect(email, id) { return (await impl().removeProspect(String(email).toLowerCase(), id)) ? { ok: true } : { error: 'No such prospect.' }; }
// ---- link stats: views per angle (join page loads), joins and buyers per angle ----
// referring domain from a Referer header: our own pages count as 'direct', empty is 'direct'
function refHost(referer) {
try { const h = new URL(String(referer || '')).hostname.replace(/^www\./, '').toLowerCase(); return (!h || /linkspin\.com$/.test(h)) ? 'direct' : h.slice(0, 80); } catch (e) { return 'direct'; }
}
async function recordView(token, angle, referer) { try { await impl().addView(String(token || '').toLowerCase().slice(0, 40), String(angle || '').toLowerCase().slice(0, 20), Date.now(), refHost(referer)); } catch (e) {} }
// where an ad click happened: our own surface (by page path) or an outside host
function clickSource(referer) {
try {
const u = new URL(String(referer || '')); const h = u.hostname.replace(/^www\./, '').toLowerCase();
if (!/linkspin\.com$/.test(h)) return h.slice(0, 80) || 'unknown';
const p = u.pathname;
if (p.startsWith('/view')) return 'ad viewer'; if (p.startsWith('/my')) return 'member area'; if (p.startsWith('/ledger')) return 'live ledger';
if (p.startsWith('/wall/')) return 'member walls'; if (p.startsWith('/shorts')) return 'shorts'; if (p.startsWith('/plays')) return 'plays page'; if (p === '/' || p === '') return 'home page';
return 'site';
} catch (e) { return 'unknown'; }
}
async function recordClick(campaignId, referer) { try { await impl().addClick(Number(campaignId), clickSource(referer), Date.now()); } catch (e) {} }
async function clickSources(campaignIds) {
const rows = await impl().clicksFor(campaignIds.map(Number));
const out = {};
for (const r of rows) { out[r.campaignId] = out[r.campaignId] || {}; out[r.campaignId][r.src] = (out[r.campaignId][r.src] || 0) + 1; }
return out;
}
async function linkStats(email) {
const a = await accounts.byEmail(email);
if (!a) return { angles: [] };
const tokens = [a.code, a.username, a.memberId ? String(a.memberId) : null].filter(Boolean).map(t => String(t).toLowerCase());
const now = Date.now();
const views = await impl().views(tokens, 0);
const joined = await accounts.listByReferrer(tokens);
const ANG = ['', 'instant', 'adspend', 'free', 'ledger', 'two'];
const rows = {};
for (const k of ANG) rows[k] = { angle: k || 'plain', views30: 0, views: 0, joins: 0, buyers: 0 };
const src = {};
const srcRow = k => (src[k] = src[k] || { source: k, views30: 0, views: 0, joins: 0, buyers: 0 });
for (const v of views) { const r = rows[v.angle] || rows['']; r.views += 1; if (now - v.ts < 30 * DAY) r.views30 += 1; const s = srcRow(v.ref || 'direct'); s.views += 1; if (now - v.ts < 30 * DAY) s.views30 += 1; }
for (const j of joined.slice(0, 300)) {
const r = rows[String(j.joinedVia || '').toLowerCase()] || rows[''];
r.joins += 1;
const s = srcRow(j.joinedRef || 'direct'); s.joins += 1;
if (j.memberId) { const mm = await member(j.memberId); if (mm && mm.countedAsBuyer) { r.buyers += 1; s.buyers += 1; } }
}
const sources = Object.values(src).sort((a, b) => (b.views + b.joins * 5) - (a.views + a.joins * 5));
return { angles: Object.values(rows), sources, totalViews: views.length, totalJoins: joined.length };
}
// ---- automatic nudges to stalled members + weekly digest to sponsors ----
let ticking = false;
const NUDGE_GAP = 5 * DAY;
async function nudgeTick() {
if (ticking || !mailer || !mailer.hasKey()) return { nudges: 0, digests: 0 };
ticking = true;
let nudges = 0, digests = 0;
try {
const now = Date.now();
const all = await accounts.listAll(5000);
for (const acct of all) {
if (!acct.email || !acct.created || now - acct.created < STALL_DAYS * DAY) continue;
const c = await describe(acct, now);
if (!c.stalled) continue;
const R = RUNGS[c.rung]; if (!R.mail) continue;
const last = await impl().lastNudge(acct.email);
if (last && (last.rung === c.rung || now - last.ts < NUDGE_GAP)) continue; // one email per rung, never more than one every 5 days
const spon = await accounts.sponsorOf(acct.email);
const who = spon ? (spon.username ? '@' + spon.username : 'your sponsor') : null;
const body = R.mail[1] + '\n\nOpen your dashboard: https://linkspin-test.saasy.top/my' + (who ? '\n\nStuck? Message ' + who + ' from My line, that is what they are there for.' : '') + '\n\nLinkSpin';
try { await mailer.send(acct.email, R.mail[0], body); await impl().setNudge(acct.email, c.rung, now); nudges += 1; }
catch (e) { console.error('nudge', acct.email, e.message); }
if (nudges >= 40) break; // spread the load across ticks
}
// weekly member email (Marty, 2026-09-14, modelled on the mailer.gold weekly): credits sitting unspent,
// the streak, the contest standings with the member's own rank, the tank, and the line for sponsors
let lbWeek = null; try { lbWeek = X.lb ? await X.lb().view('week') : null; } catch (e) {}
let tankN = 0; try { tankN = X.tank ? (await X.tank.waiting()).length : 0; } catch (e) {}
for (const acct of all) {
if (!acct.email) continue;
const lastD = await impl().lastDigest(acct.email);
if (now - lastD < 7 * DAY) continue;
if (now - (acct.lastSeen || acct.created || 0) > 45 * DAY) { await impl().setDigest(acct.email, now); continue; } // gone quiet for six weeks: leave them to the nudges
const view = await coachView(acct.email);
// audience: sponsors with directs (as before) unless the admin switched the weekly on for every member
const everyone = X.siteConfig && String(X.siteConfig().memberWeeklyEmail || '0') === '1';
if (!everyone && !view.directs.length) { await impl().setDigest(acct.email, now); continue; }
const name = acct.username ? '@' + acct.username : 'there';
const parts = ['Hi ' + name + ', your week on LinkSpin:'];
// credits
try {
const ids = [acct.memberId, ...(await accounts.positions(acct.email)).map(p => p.memberId)].filter(Boolean);
const bal = await X.ads.balances(ids, acct.email);
if (bal.available > 0) parts.push('- You have ' + bal.available.toLocaleString() + ' ad credits sitting unspent. That is ' + bal.available.toLocaleString() + ' cents of delivery doing nothing. Campaigns > New campaign, point it at your invite link: https://linkspin-test.saasy.top/my#campaigns');
else if (bal.inCampaigns > 0) parts.push('- All ' + bal.inCampaigns.toLocaleString() + ' of your credits are working in live campaigns. Good.');
} catch (e) {}
// streak
try {
const st = await X.ads.viewStatus(acct.email);
if (st.streakDay > 1) parts.push('- Your claim streak is on day ' + st.streakDay + '. Today\'s claim pays ' + (st.claimCredits || 0) + ' credits; miss a day and it restarts at 5.');
else parts.push('- Five ads and a claim a day is the free way in: 5, 7, 10, then 25 credits every seventh day in a row: https://linkspin-test.saasy.top/my#earn');
} catch (e) {}
// contest
if (lbWeek && lbWeek.top) {
const me = lbWeek.top.find(r => r.name === '@' + acct.username) || null;
let mine = me; if (!mine) { try { mine = (await X.lb().view('week', acct.email)).me; } catch (e) {} }
parts.push('- Referral contest this week (' + (lbWeek.prize || 'credits to the top 3') + '): ' + (lbWeek.top.length ? lbWeek.top.slice(0, 3).map(r => r.rank + '. ' + r.name + ' (' + r.sales + ' sold)').join(', ') : 'no sales yet, first sale takes the top spot') + '.'
+ (mine ? ' You are #' + mine.rank + ' with ' + mine.sales + ' sold.' : ' You are not on the board yet; one $20 package bought by someone you sponsor puts you there.') + ' https://linkspin-test.saasy.top/leaderboard');
}
// tank
if (tankN > 0 && acct.memberId) parts.push('- ' + tankN + ' member' + (tankN === 1 ? ' is' : 's are') + ' waiting for a sponsor in the holding tank. Adopt one from My line: https://linkspin-test.saasy.top/my#line');
// line (sponsors only)
if (view.directs.length) {
const week = view.directs.filter(d => now - d.joined < 7 * DAY).length;
parts.push('- Your line: ' + view.directs.length + ' direct' + (view.directs.length === 1 ? '' : 's') + ', ' + week + ' joined this week, ' + view.stalled + ' gone quiet.');
const actions = view.directs.filter(d => d.stalled).slice(0, 3).map(d => ' Message ' + d.name + ': "' + d.say.replace('{{name}}', d.name.replace(/^@/, '')) + '"');
if (actions.length) parts.push(actions.join('\n'));
}
parts.push('\nTwenty minutes, in order: the set, one message to your line, the tank, one conversation outward. https://linkspin-test.saasy.top/blog/the-twenty-minute-day\n\nLinkSpin · https://linkspin-test.saasy.top/my\nNo income is guaranteed. Credits are advertising, not money.');
const subject = lbWeek && lbWeek.top && lbWeek.top.length && lbWeek.top[0].name === '@' + acct.username ? 'You are #1 this week on LinkSpin' : 'Your week on LinkSpin: credits, streak, contest';
try { await mailer.send(acct.email, subject, parts.join('\n')); digests += 1; } catch (e) { console.error('digest', acct.email, e.message); }
await impl().setDigest(acct.email, now);
if (digests >= 30) break;
}
} finally { ticking = false; }
return { nudges, digests };
}
let X = {}; // extra refs for the weekly member email (ads, tank, lb getter), 2026-09-14
function init(opts) { X = arguments[0] || {};
DATA_DIR = opts.dataDir; chain = opts.chain; accounts = opts.accounts; mailer = opts.mailer;
J.load();
}
async function viewsSince(since) { return impl().viewsSince(since); }
module.exports = { init, RUNGS, rungFor, describe, coachView, prospects, saveProspect, removeProspect, STATUSES, recordView, linkStats, nudgeTick, recordClick, clickSources, refHost, viewsSince };