4156b1f815
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
216 lines
16 KiB
JavaScript
216 lines
16 KiB
JavaScript
// 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 InstantAdPay wallet is not linked yet', 'You joined InstantAdPay 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 };
|
|
}
|
|
// 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 now = Date.now();
|
|
const out = [];
|
|
for (const d of directs) {
|
|
const c = await describe(d, now);
|
|
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) { this.db.views.push({ token, angle, ts }); if (this.db.views.length > 50000) this.db.views = this.db.views.slice(-40000); this.save(); },
|
|
async views(tokens, since) { return this.db.views.filter(v => tokens.includes(v.token) && 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) { await db.q('INSERT INTO join_views (token,angle,ts) VALUES (?,?,?)', [token, angle, ts]); },
|
|
async views(tokens, since) {
|
|
if (!tokens.length) return [];
|
|
const rows = await db.q('SELECT token, angle, ts 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) }));
|
|
}
|
|
};
|
|
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 ----
|
|
async function recordView(token, angle) { try { await impl().addView(String(token || '').toLowerCase().slice(0, 40), String(angle || '').toLowerCase().slice(0, 20), Date.now()); } catch (e) {} }
|
|
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 };
|
|
for (const v of views) { const r = rows[v.angle] || rows['']; r.views += 1; if (now - v.ts < 30 * DAY) r.views30 += 1; }
|
|
for (const j of joined.slice(0, 300)) {
|
|
const r = rows[String(j.joinedVia || '').toLowerCase()] || rows[''];
|
|
r.joins += 1;
|
|
if (j.memberId) { const mm = await member(j.memberId); if (mm && mm.countedAsBuyer) r.buyers += 1; }
|
|
}
|
|
return { angles: Object.values(rows), 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://instantadpay.com/my' + (who ? '\n\nStuck? Message ' + who + ' from My line, that is what they are there for.' : '') + '\n\nInstantAdPay';
|
|
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 digest: every sponsor with at least one direct
|
|
for (const acct of all) {
|
|
if (!acct.email) continue;
|
|
const lastD = await impl().lastDigest(acct.email);
|
|
if (now - lastD < 7 * DAY) continue;
|
|
const view = await coachView(acct.email);
|
|
if (!view.directs.length) { await impl().setDigest(acct.email, now); continue; }
|
|
const week = view.directs.filter(d => now - d.joined < 7 * DAY).length;
|
|
const lines = view.directs.slice(0, 25).map(d => '- ' + d.name + ': ' + d.label + (d.stalled ? ' (quiet ' + d.quietDays + ' days)' : '') + '. Next: ' + d.next);
|
|
const actions = view.directs.filter(d => d.stalled).slice(0, 3).map(d => '- Message ' + d.name + ': "' + d.say.replace('{{name}}', d.name.replace(/^@/, '')) + '"');
|
|
const body = 'Your line this week:\n' + week + ' joined in the last 7 days. ' + view.stalled + ' of your ' + view.directs.length + ' directs have gone quiet.\n\n'
|
|
+ lines.join('\n') + '\n\n' + (actions.length ? 'Three things to do today:\n' + actions.join('\n') + '\n\n' : '')
|
|
+ 'Open My line to message anyone in one click: https://instantadpay.com/my#line\n\nInstantAdPay';
|
|
try { await mailer.send(acct.email, 'Your line this week: ' + view.stalled + ' to nudge', body); 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 };
|
|
}
|
|
|
|
function init(opts) {
|
|
DATA_DIR = opts.dataDir; chain = opts.chain; accounts = opts.accounts; mailer = opts.mailer;
|
|
J.load();
|
|
}
|
|
module.exports = { init, RUNGS, rungFor, describe, coachView, prospects, saveProspect, removeProspect, STATUSES, recordView, linkStats, nudgeTick };
|