Coaching layer + tools: coach your directs (rungs, stalled, one-click nudges), automatic member nudges + weekly sponsor digest, prospects list, per-angle link stats, broadcast templates, Qualified Start calculator, Telegram proof feed, send-failed alerts, admin P&L pane, automatic credit burner (ethers), username lock, home-page comparison, printable checklist, wall link in Promo tools

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-09-10 07:30:16 -05:00
parent 0c30e831eb
commit 4156b1f815
24 changed files with 1264 additions and 489 deletions
+68
View File
@@ -0,0 +1,68 @@
// Automatic credit burner: settles pending campaign spend on-chain by calling
// consume() from the engine signer. Inert unless ENGINE_KEY (hex private key)
// or ENGINE_KEY_FILE is set. Purchased credits only ever go DOWN via this path,
// and members never sign or pay gas for it: the engine wallet pays.
let ethers = null; try { ethers = require('ethers'); } catch (e) { /* optional dependency */ }
const fs = require('fs');
let chain = null, ads = null;
const ABI = ['function consume(uint32 memberId_, uint8 creditType, uint256 amount, bytes32 campaignRef)', 'function engineSigner() view returns (address)'];
const state = { enabled: false, address: null, signer: null, balanceWei: '0', lastRun: 0, lastError: null, burned: 0, lastTx: null, mismatch: false };
let running = false;
function keyHex() {
let k = String(process.env.ENGINE_KEY || '').trim();
if (!k && process.env.ENGINE_KEY_FILE) { try { k = fs.readFileSync(process.env.ENGINE_KEY_FILE, 'utf8').trim(); } catch (e) {} }
if (!k) return null;
if (!k.startsWith('0x')) k = '0x' + k;
return /^0x[0-9a-fA-F]{64}$/.test(k) ? k : null;
}
function provider() {
const c = chain.getConfig();
const url = (c.rpcs && c.rpcs[0]) || c.rpc;
return new ethers.JsonRpcProvider(url, Number(c.chainId));
}
async function setup() {
const k = keyHex();
if (!k || !ethers) { state.enabled = false; return; }
const w = new ethers.Wallet(k, provider());
state.address = w.address; state.enabled = true;
try {
const ctr = new ethers.Contract(chain.getConfig().contract, ABI, w);
const es = await ctr.engineSigner();
state.mismatch = String(es).toLowerCase() !== w.address.toLowerCase();
if (state.mismatch) console.error('burner: ENGINE_KEY address', w.address, 'is not the contract engineSigner', es, '- burns will revert; disabled');
} catch (e) { state.lastError = 'engineSigner read: ' + e.message; }
}
function refToBytes32(ref) { return ethers.zeroPadBytes(ethers.toUtf8Bytes(String(ref || '').slice(0, 32)), 32); }
async function tick() {
if (!state.enabled || state.mismatch || running || !ethers) return { burned: 0 };
running = true;
let burned = 0;
try {
const w = new ethers.Wallet(keyHex(), provider());
const ctr = new ethers.Contract(chain.getConfig().contract, ABI, w);
state.balanceWei = (await w.provider.getBalance(w.address)).toString();
const pending = await ads.pendingBurns();
state.lastRun = Date.now();
if (BigInt(state.balanceWei) < ethers.parseEther('0.05')) { state.lastError = 'engine wallet low on POL for gas'; return { burned: 0 }; }
for (const b of pending.slice(0, 20)) {
try {
const tx = await ctr.consume(Number(b.memberId), 0, BigInt(b.amount), refToBytes32(b.ref));
const rc = await tx.wait(1);
if (rc && rc.status === 1) { await ads.markBurned(b.id, tx.hash); burned += 1; state.burned += 1; state.lastTx = tx.hash; state.lastError = null; }
else { state.lastError = 'consume reverted for burn ' + b.id; break; }
} catch (e) {
state.lastError = 'burn ' + b.id + ': ' + String(e.shortMessage || e.message).slice(0, 160);
// an "Insufficient credits" revert means the member's on-chain balance is
// already lower than the engine thinks; leave it pending for the admin to review
break;
}
}
} finally { running = false; }
return { burned };
}
function status() { return Object.assign({}, state, { hasEthers: !!ethers, keyPresent: !!keyHex() }); }
function init(opts) { chain = opts.chain; ads = opts.ads; setup().catch(e => { state.lastError = e.message; }); }
module.exports = { init, tick, status };
+215
View File
@@ -0,0 +1,215 @@
// 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 };
+21
View File
@@ -129,6 +129,27 @@ async function bootstrap() {
created BIGINT NOT NULL, created BIGINT NOT NULL,
INDEX (email) INDEX (email)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`);
await q(`CREATE TABLE IF NOT EXISTS nudges (email VARCHAR(190) PRIMARY KEY, rung INT NOT NULL, ts BIGINT NOT NULL) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`);
await q(`CREATE TABLE IF NOT EXISTS digests (email VARCHAR(190) PRIMARY KEY, ts BIGINT NOT NULL) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`);
await q(`CREATE TABLE IF NOT EXISTS prospects (
id INT AUTO_INCREMENT PRIMARY KEY,
owner_email VARCHAR(190) NOT NULL,
name VARCHAR(120) NOT NULL,
contact VARCHAR(120) NULL,
status VARCHAR(20) NOT NULL DEFAULT 'new',
note VARCHAR(400) NULL,
next_ts BIGINT NULL,
created BIGINT NOT NULL,
updated BIGINT NOT NULL,
INDEX (owner_email)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`);
await q(`CREATE TABLE IF NOT EXISTS join_views (
id INT AUTO_INCREMENT PRIMARY KEY,
token VARCHAR(40) NOT NULL,
angle VARCHAR(20) NOT NULL DEFAULT '',
ts BIGINT NOT NULL,
INDEX (token, ts)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`);
await q(`CREATE TABLE IF NOT EXISTS visit_seen ( await q(`CREATE TABLE IF NOT EXISTS visit_seen (
campaign_id INT NOT NULL, campaign_id INT NOT NULL,
email VARCHAR(190) NOT NULL, email VARCHAR(190) NOT NULL,
+563 -456
View File
File diff suppressed because it is too large Load Diff
+23 -10
View File
@@ -1,10 +1,23 @@
{ {
"name": "instantadpay-site", "name": "instantadpay-site",
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"description": "InstantAdPay membership advertising site - immutable on-chain settlement, transparent ledger.", "description": "InstantAdPay membership advertising site - immutable on-chain settlement, transparent ledger.",
"main": "server.js", "main": "server.js",
"scripts": {"start": "node server.js", "dev": "node --watch server.js", "qa": "bash qa/run.sh all", "qa:public": "bash qa/run.sh public", "qa:member": "bash qa/run.sh member", "qa:earn": "bash qa/run.sh earn"}, "scripts": {
"engines": {"node": ">=20"}, "start": "node server.js",
"dependencies": {"mysql2": "^3.11.0", "qrcode": "^1.5.4"} "dev": "node --watch server.js",
} "qa": "bash qa/run.sh all",
"qa:public": "bash qa/run.sh public",
"qa:member": "bash qa/run.sh member",
"qa:earn": "bash qa/run.sh earn"
},
"engines": {
"node": ">=20"
},
"dependencies": {
"mysql2": "^3.11.0",
"qrcode": "^1.5.4",
"ethers": "^6.13.0"
}
}
+26 -2
View File
@@ -6,7 +6,7 @@
<title>Admin | InstantAdPay</title> <title>Admin | InstantAdPay</title>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Sora:wght@600;700;800&display=swap"> <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="icon" type="image/png" href="/logo-icon.png">
<link rel="stylesheet" href="/assets/site.css?v=20260910a"> <link rel="stylesheet" href="/assets/site.css?v=20260910b">
<style> <style>
.adm-table th,.adm-table td{padding:8px 10px;text-align:left;vertical-align:top;border-bottom:1px solid var(--line);font-size:13.5px} .adm-table th,.adm-table td{padding:8px 10px;text-align:left;vertical-align:top;border-bottom:1px solid var(--line);font-size:13.5px}
.adm-table th{color:var(--muted);font-size:11.5px;text-transform:uppercase;letter-spacing:.08em} .adm-table th{color:var(--muted);font-size:11.5px;text-transform:uppercase;letter-spacing:.08em}
@@ -86,6 +86,7 @@
<button data-pane="campaigns" type="button"><svg viewBox="0 0 24 24"><path d="M4 6h16M4 12h16M4 18h10"/></svg>All campaigns</button> <button data-pane="campaigns" type="button"><svg viewBox="0 0 24 24"><path d="M4 6h16M4 12h16M4 18h10"/></svg>All campaigns</button>
<button data-pane="members" type="button"><svg viewBox="0 0 24 24"><circle cx="12" cy="7" r="3.2"/><circle cx="5" cy="17" r="2.6"/><circle cx="19" cy="17" r="2.6"/><path d="M12 10v3M12 13l-5 2M12 13l5 2"/></svg>Members</button> <button data-pane="members" type="button"><svg viewBox="0 0 24 24"><circle cx="12" cy="7" r="3.2"/><circle cx="5" cy="17" r="2.6"/><circle cx="19" cy="17" r="2.6"/><path d="M12 10v3M12 13l-5 2M12 13l5 2"/></svg>Members</button>
<button data-pane="reports" type="button"><svg viewBox="0 0 24 24"><path d="M12 3l9 16H3z"/><path d="M12 10v4M12 17v.5"/></svg>Reports<span class="pill" id="repBadge" hidden></span></button> <button data-pane="reports" type="button"><svg viewBox="0 0 24 24"><path d="M12 3l9 16H3z"/><path d="M12 10v4M12 17v.5"/></svg>Reports<span class="pill" id="repBadge" hidden></span></button>
<button data-pane="pnl" type="button"><svg viewBox="0 0 24 24"><path d="M4 19V5M4 19h16"/><path d="M8 15l3-4 3 2 5-6"/></svg>P&amp;L</button>
<button data-pane="settings" type="button"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z"/></svg>Settings</button> <button data-pane="settings" type="button"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z"/></svg>Settings</button>
</nav> </nav>
<div class="bo-links"> <div class="bo-links">
@@ -240,6 +241,29 @@
</div> </div>
</div> </div>
<div class="pane" id="pane-pnl" hidden>
<div class="card">
<div class="card-head"><h3>Profit and loss</h3><span class="sub">read from the chain index; periods are by block (about 43,200 Polygon blocks a day)</span></div>
<div class="chips" id="pnlPeriods"><button type="button" class="chip-t" data-days="7">7 days</button><button type="button" class="chip-t on" data-days="30">30 days</button><button type="button" class="chip-t" data-days="90">90 days</button><button type="button" class="chip-t" data-days="0">All time</button></div>
<div class="grid" id="pnlTiles" style="grid-template-columns:repeat(auto-fit,minmax(200px,1fr));margin-top:12px"></div>
</div>
<div class="grid c2">
<div class="card">
<div class="card-head"><h3>Where the money went</h3><span class="sub">per period</span></div>
<div class="tablewrap"><table class="adm-table" id="pnlSplit"></table></div>
</div>
<div class="card">
<div class="card-head"><h3>Wallets</h3><span class="sub">live balances</span></div>
<div class="tablewrap"><table class="adm-table" id="pnlWallets"></table></div>
<p class="small muted" style="margin:10px 0 0">Fixed monthly cost (USD) for the net line: <input type="number" id="pnlFixed" min="0" step="1" style="width:110px"> <button class="btn small sec" type="button" id="pnlFixedSave">Save</button></p>
</div>
</div>
<div class="card">
<div class="card-head"><h3>Automatic credit burner</h3><span class="sub">settles pending campaign spend on-chain from the engine wallet</span></div>
<p class="small" id="burnerLine">…</p>
<p><button class="btn small sec" type="button" id="burnerRun">Run now</button></p>
</div>
</div>
<div class="pane" id="pane-settings" hidden> <div class="pane" id="pane-settings" hidden>
<div class="card"> <div class="card">
<div class="card-head"><h3>Follow-up emails</h3><span class="sub" id="dripSub">sent to every new free member, hours after sign-up</span></div> <div class="card-head"><h3>Follow-up emails</h3><span class="sub" id="dripSub">sent to every new free member, hours after sign-up</span></div>
@@ -282,6 +306,6 @@
</div> </div>
<script src="/assets/common.js?v=20260910a"></script> <script src="/assets/common.js?v=20260910a"></script>
<script src="/assets/admin.js?v=20260909d"></script> <script src="/assets/admin.js?v=20260910a"></script>
</body> </body>
</html> </html>
+33 -3
View File
@@ -44,8 +44,8 @@
}); });
// ── panes ── // ── panes ──
const TITLES = { overview: 'Overview', house: 'House ads', campaigns: 'All campaigns', members: 'Members', reports: 'Reports', settings: 'Settings' }; const TITLES = { overview: 'Overview', house: 'House ads', campaigns: 'All campaigns', members: 'Members', reports: 'Reports', pnl: 'Profit and loss', settings: 'Settings' };
const loaders = { overview: loadOverview, house: loadHouse, campaigns: loadCampaigns, members: loadMembers, reports: loadReports, settings: loadSettings }; const loaders = { overview: loadOverview, house: loadHouse, campaigns: loadCampaigns, members: loadMembers, reports: loadReports, pnl: loadPnl, settings: loadSettings };
function setPane(name) { function setPane(name) {
if (!TITLES[name]) name = 'overview'; if (!TITLES[name]) name = 'overview';
document.querySelectorAll('.pane').forEach(p => { p.hidden = p.id !== 'pane-' + name; }); document.querySelectorAll('.pane').forEach(p => { p.hidden = p.id !== 'pane-' + name; });
@@ -297,6 +297,36 @@
}); });
// ── reports + burns ── // ── reports + burns ──
// ── profit and loss ──
let pnlDays = 30;
const pol = w => { try { return (Number(BigInt(w || '0') / 10n ** 14n) / 10000).toLocaleString(undefined, { maximumFractionDigits: 2 }); } catch (e) { return '0'; } };
const usdOf = (w, px) => { try { return '$' + ((Number(BigInt(w || '0') / 10n ** 14n) / 10000) * px).toLocaleString(undefined, { maximumFractionDigits: 0 }); } catch (e) { return '$0'; } };
async function loadPnl() {
const r = await api('/api/admin/pnl?days=' + pnlDays);
const px = r.polUsd || 0;
const platUsd = (Number(BigInt(r.platformWei || '0') / 10n ** 14n) / 10000) * px;
const months = pnlDays ? pnlDays / 30 : Math.max(1, (r.latest - r.fromBlock) / 43200 / 30);
const fixed = (r.fixedMonthlyUsd || 0) * months;
$('pnlTiles').innerHTML = [
['Packages sold', r.purchases.count, Object.entries(r.purchases.byPackage || {}).map(([k, v]) => v + '×' + k).join(' · ') || '—'],
['Gross volume', pol(r.purchases.volumeWei) + ' POL', usdOf(r.purchases.volumeWei, px) + ' at today\'s rate · $' + (r.purchases.usdCents / 100).toLocaleString() + ' at sale'],
['Platform (fees + dust + unclaimed)', pol(r.platformWei) + ' POL', usdOf(r.platformWei, px)],
['Paid to members', pol(r.memberPayoutsWei) + ' POL', usdOf(r.memberPayoutsWei, px)],
['Net after fixed costs', '$' + Math.round(platUsd - fixed).toLocaleString(), 'fixed ' + Math.round(fixed).toLocaleString() + ' over ' + months.toFixed(1) + ' month(s)'],
['Pass-ups', r.passedUp.count, r.passedUp.unqualified + ' unqualified · ' + r.passedUp.sendFailed + ' send-failed']
].map(t => '<div class="statx"><div><div class="nv" style="font-size:22px">' + esc(String(t[1])) + '</div><div class="lb">' + esc(t[0]) + '</div><span class="chip flat">' + esc(t[2]) + '</span></div></div>').join('');
$('pnlSplit').innerHTML = '<tr><th>Line</th><th>POL</th><th>USD now</th></tr>'
+ [['Level 1 (50%)', r.byTier[1]], ['Level 2 (20%)', r.byTier[2]], ['Level 3 (10%)', r.byTier[3]], ['Platform (20% + pass-ups)', r.platformWei]].map(x => '<tr><td>' + x[0] + '</td><td class="mono">' + pol(x[1]) + '</td><td class="mono">' + usdOf(x[1], px) + '</td></tr>').join('');
const W = r.wallets || {}, B = r.balances || {};
$('pnlWallets').innerHTML = '<tr><th>Wallet</th><th>Address</th><th>Balance</th></tr>'
+ [['Owner / fee A (Tangem)', W.feeA, B.feeA], ['Fee B', W.feeB, B.feeB], ['Engine (gas)', W.engine, B.engine]].filter(x => x[1]).map(x => '<tr><td>' + x[0] + '</td><td class="mono small">' + esc(x[1]) + '</td><td class="mono">' + (x[2] == null ? '?' : pol(x[2]) + ' POL') + '</td></tr>').join('');
$('pnlFixed').value = r.fixedMonthlyUsd || 0;
const b = r.burner || {};
$('burnerLine').textContent = !b.hasEthers ? 'ethers is not installed in this build.' : !b.keyPresent ? 'No engine key configured (ENGINE_KEY). Burns stay pending until it is set.' : b.mismatch ? 'ENGINE_KEY does not match the contract engine signer. Disabled.' : 'Engine ' + b.address + ' · ' + pol(b.balanceWei) + ' POL for gas · burned ' + b.burned + ' since boot' + (b.lastRun ? ' · last run ' + when(b.lastRun) : '') + (b.lastError ? ' · last error: ' + b.lastError : '');
}
document.querySelectorAll('#pnlPeriods [data-days]').forEach(b => b.addEventListener('click', () => { pnlDays = Number(b.dataset.days); document.querySelectorAll('#pnlPeriods [data-days]').forEach(x => x.classList.toggle('on', x === b)); loadPnl().catch(e => IAP.status(e.message, 'bad')); }));
if ($('pnlFixedSave')) $('pnlFixedSave').addEventListener('click', async () => { try { await api('/api/admin/site', { pnlFixedMonthlyUsd: Number($('pnlFixed').value) || 0 }, 'PATCH'); IAP.status('Saved.', 'ok'); loadPnl(); } catch (e) { IAP.status(e.message, 'bad'); } });
if ($('burnerRun')) $('burnerRun').addEventListener('click', async () => { try { const r = await api('/api/admin/burner/run', {}); IAP.status('Burner ran: ' + (r.burned || 0) + ' burned.', 'ok'); loadPnl(); } catch (e) { IAP.status(e.message, 'bad'); } });
async function loadReports() { async function loadReports() {
const [r, b] = await Promise.all([api('/api/admin/reports'), api('/api/admin/burns')]); const [r, b] = await Promise.all([api('/api/admin/reports'), api('/api/admin/burns')]);
const reps = r.reports || []; const reps = r.reports || [];
@@ -446,7 +476,7 @@
})); }));
// site settings: key / value rows; booleans as checkboxes, numbers stay numbers // site settings: key / value rows; booleans as checkboxes, numbers stay numbers
const SITE_META = { siteName: 'Site name', tagline: 'Tagline', rehearsal: 'Testnet rehearsal banner', defaultSponsorId: 'Fallback sponsor (member #)', walletConnectProjectId: 'WalletConnect project id', moonpayPublicKey: 'MoonPay public key' }; const SITE_META = { siteName: 'Site name', tagline: 'Tagline', rehearsal: 'Testnet rehearsal banner', defaultSponsorId: 'Fallback sponsor (member #)', walletConnectProjectId: 'WalletConnect project id', moonpayPublicKey: 'MoonPay public key', telegramBotToken: 'Telegram proof feed: bot token', telegramChatId: 'Telegram proof feed: chat id', telegramTopicId: 'Telegram proof feed: topic id (optional)', telegramEvents: 'Telegram proof feed: events (payouts | payouts+purchases | all)', telegramCtaUrl: 'Telegram proof feed: join link under each post', pnlFixedMonthlyUsd: 'P&L: fixed monthly cost (USD)' };
function drawSite() { function drawSite() {
const wrap = $('siteForm'); const wrap = $('siteForm');
wrap.innerHTML = Object.entries(siteObj).map(([k, v]) => '<div class="kv-row"><span class="k" title="' + esc(k) + '">' + esc(SITE_META[k] || humanize(k)) + '</span>' wrap.innerHTML = Object.entries(siteObj).map(([k, v]) => '<div class="kv-row"><span class="k" title="' + esc(k) + '">' + esc(SITE_META[k] || humanize(k)) + '</span>'
+93 -2
View File
@@ -421,6 +421,10 @@
if (d.username) { // wall link rides the username if (d.username) { // wall link rides the username
const wl = location.origin + '/wall/' + d.username; const wl = location.origin + '/wall/' + d.username;
$('wallLine').textContent = wl; $('wallLine').textContent = wl;
if ($('promoWallStrip')) { // the same wall link at the top of Promo tools, next to the invite link
$('promoWallStrip').hidden = false; $('promoWallLink').textContent = wl; $('promoWallOpen').href = '/wall/' + d.username;
$('promoWallCopy').onclick = async () => { try { await navigator.clipboard.writeText(wl); IAP.status('Wall link copied.', 'ok'); } catch (e) { IAP.status('Copy failed. Select the link and copy it.', 'bad'); } };
}
$('wallCopy').hidden = false; $('wallCopy').hidden = false;
$('wallOpen').hidden = false; $('wallOpen').hidden = false;
$('wallOpen').href = '/wall/' + d.username; $('wallOpen').href = '/wall/' + d.username;
@@ -460,7 +464,7 @@
if ($('boTitle')) $('boTitle').textContent = TITLES[name]; if ($('boTitle')) $('boTitle').textContent = TITLES[name];
if (name === 'earn') setEarnSub(earnSub); // refresh whichever sub-tab is active if (name === 'earn') setEarnSub(earnSub); // refresh whichever sub-tab is active
if (name === 'profile') loadLineBanner(); if (name === 'profile') loadLineBanner();
if (name === 'line') { loadLineage(); loadUplineMessages(); } if (name === 'line') { loadLineage(); loadUplineMessages(); loadCoach(); loadLinkStats(); loadProspects(); }
if (name === 'campaigns') ['cTarget', 'cImage', 'cVideoUrl'].forEach(id => { if ($(id)) $(id).value = ''; }); // no residual URL between visits if (name === 'campaigns') ['cTarget', 'cImage', 'cVideoUrl'].forEach(id => { if ($(id)) $(id).value = ''; }); // no residual URL between visits
if (name === 'training') loadTraining(); if (name === 'training') loadTraining();
document.getElementById('memberArea').classList.remove('side-open'); // close mobile drawer document.getElementById('memberArea').classList.remove('side-open'); // close mobile drawer
@@ -525,8 +529,9 @@
loadCampaigns(); loadCampaigns();
// profile pane state // profile pane state
$('pfCurrent').textContent = me.username ? 'Current username: @' + me.username : 'No username yet. Members see you as a number until you pick one.'; $('pfCurrent').textContent = me.username ? '@' + me.username + ' is your permanent username. Your invite link, your public page and any banners you shared carry it, so it cannot be changed.' : 'No username yet. Members see you as a number until you pick one. Choose carefully: it is permanent once saved.';
if (!$('pfUsername').value) $('pfUsername').value = me.username || ''; if (!$('pfUsername').value) $('pfUsername').value = me.username || '';
$('pfUsername').disabled = !!me.username; $('pfSaveBtn').hidden = !!me.username;
$('pfDetails').innerHTML = 'Email: ' + (me.email || 'none') + '<br>Wallet: ' $('pfDetails').innerHTML = 'Email: ' + (me.email || 'none') + '<br>Wallet: '
+ (me.address ? '<span class="mono">' + me.address.slice(0, 10) + '…' + me.address.slice(-6) + '</span>' : 'not linked yet') + (me.address ? '<span class="mono">' + me.address.slice(0, 10) + '…' + me.address.slice(-6) + '</span>' : 'not linked yet')
+ '<br>On-chain member: ' + (me.memberId ? '#' + me.memberId : 'not yet'); + '<br>On-chain member: ' + (me.memberId ? '#' + me.memberId : 'not yet');
@@ -961,6 +966,92 @@
}; };
} }
// ── coaching: every direct's rung, stalled flag, one-click nudge ──
async function loadCoach() {
try {
const r = await (await fetch('/api/my/coach')).json();
const el = $('coachList'); if (!el || r.error) return;
const d = r.directs || [];
$('coachSummary').innerHTML = d.length ? '<b>' + d.length + '</b> direct' + (d.length === 1 ? '' : 's') + ' · <b>' + r.stalled + '</b> quiet for 3+ days' + (r.stalled ? ' · start at the top' : '') : '';
if (!d.length) { el.innerHTML = '<p class="muted small">No directs yet. When someone joins through your link they show up here with their next step.</p>'; return; }
el.innerHTML = d.map(x => '<div class="lin-row' + (x.stalled ? ' own' : '') + '"><span class="nm">' + esc(x.name) + (x.stalled ? ' <span class="badge amber">quiet ' + x.quietDays + 'd</span>' : '') + '</span>'
+ '<span class="em">' + esc(x.label) + ' → ' + esc(x.next) + '</span>'
+ '<span class="id">rung ' + x.rung + '/6</span>'
+ '<span class="dt">' + (x.buyerCount ? x.buyerCount + ' buyer' + (x.buyerCount === 1 ? '' : 's') : '') + '</span>'
+ '<button class="btn sec small" type="button" data-nudge="' + esc(x.email) + '" data-nname="' + esc(x.name) + '" data-say="' + esc(x.say) + '">Nudge</button></div>').join('');
el.querySelectorAll('[data-nudge]').forEach(b => b.addEventListener('click', async () => {
await openConvo(b.dataset.nudge, b.dataset.nname);
const inp = $('chatInput'); if (inp) { inp.value = b.dataset.say.replace(/\{\{name\}\}/g, b.dataset.nname.replace(/^@/, '')); inp.focus(); }
}));
} catch (e) {}
}
// ── link stats: views, joins, buyers per angle ──
async function loadLinkStats() {
try {
const r = await (await fetch('/api/my/linkstats')).json();
const t = $('linkStatsTable'); if (!t || r.error) return;
const rows = (r.angles || []).filter(a => a.views || a.joins || a.buyers);
if (!rows.length) { t.innerHTML = '<tr><td class="muted small">No views yet. Share your link and the numbers start here.</td></tr>'; return; }
t.innerHTML = '<tr><th>Hook</th><th>Views (30d)</th><th>Views (all)</th><th>Joined</th><th>Qualifying buyers</th></tr>'
+ rows.map(a => '<tr><td>' + esc(a.angle === 'plain' ? 'plain link' : '?v=' + a.angle) + '</td><td class="mono">' + a.views30 + '</td><td class="mono">' + a.views + '</td><td class="mono">' + a.joins + '</td><td class="mono">' + a.buyers + '</td></tr>').join('');
} catch (e) {}
}
// ── prospects: the member's own follow-up list ──
let PP_STATUSES = ['new', 'contacted', 'interested', 'joined', 'bought', 'not now'];
async function loadProspects() {
try {
const r = await (await fetch('/api/my/prospects')).json();
if (r.error) return;
PP_STATUSES = r.statuses || PP_STATUSES;
const sel = $('ppStatus'); if (sel && !sel.options.length) sel.innerHTML = PP_STATUSES.map(s => '<option value="' + s + '">' + s + '</option>').join('');
const list = r.prospects || []; const el = $('prospectList'); if (!el) return;
if (!list.length) { el.innerHTML = '<p class="muted small">Nobody on the list yet.</p>'; return; }
const today = new Date(); today.setHours(0, 0, 0, 0);
el.innerHTML = list.map(p => { const due = p.nextTs && p.nextTs <= today.getTime() + 86399999; return '<div class="lin-row' + (due ? ' own' : '') + '" data-pid="' + p.id + '"><span class="nm">' + esc(p.name) + (due ? ' <span class="badge amber">follow up</span>' : '') + '</span>'
+ '<span class="em">' + esc(p.contact || '') + (p.note ? ' · ' + esc(p.note) : '') + '</span>'
+ '<select class="small" data-pstatus="' + p.id + '">' + PP_STATUSES.map(s => '<option' + (s === p.status ? ' selected' : '') + '>' + s + '</option>').join('') + '</select>'
+ '<input type="date" class="small" data-pnext="' + p.id + '" value="' + (p.nextTs ? new Date(p.nextTs).toISOString().slice(0, 10) : '') + '">'
+ '<button class="btn sec small" type="button" data-pdel="' + p.id + '">Remove</button></div>'; }).join('');
const save = async (id, patch) => { const p = list.find(x => x.id === Number(id)); if (!p) return; try { await api('/api/my/prospects', Object.assign({}, p, patch)); } catch (e) { IAP.status(e.message, 'bad'); } };
el.querySelectorAll('[data-pstatus]').forEach(s => s.addEventListener('change', () => save(s.dataset.pstatus, { status: s.value })));
el.querySelectorAll('[data-pnext]').forEach(i => i.addEventListener('change', () => save(i.dataset.pnext, { next: i.value, nextTs: i.value ? Date.parse(i.value + 'T12:00:00') : null })));
el.querySelectorAll('[data-pdel]').forEach(b => b.addEventListener('click', async () => { try { await api('/api/my/prospects/remove', { id: b.dataset.pdel }); loadProspects(); } catch (e) { IAP.status(e.message, 'bad'); } }));
} catch (e) {}
}
if ($('prospectForm')) $('prospectForm').addEventListener('submit', async e => {
e.preventDefault();
try {
await api('/api/my/prospects', { name: $('ppName').value, contact: $('ppContact').value, status: $('ppStatus').value, nextTs: $('ppNext').value ? Date.parse($('ppNext').value + 'T12:00:00') : null });
$('ppName').value = ''; $('ppContact').value = ''; $('ppNext').value = ''; loadProspects();
} catch (err) { IAP.status(err.message, 'bad'); }
});
// ── broadcast templates ──
const BC_TEMPLATES = [
{ label: 'Welcome', subject: 'Welcome to my line: your first three moves', html: '<p>Glad you are in. Three things today, in this order:</p><ol><li>Pick your username on the Profile tab (it becomes your link).</li><li>Wallet tab: Connect and link wallet, then Switch on payouts. Both are free.</li><li>Copy your invite link from My line and send it to one person.</li></ol><p>Reply here if you get stuck on any of them. That is what I am here for.</p>' },
{ label: 'Switch on payouts', subject: 'One free step so nothing passes you by', html: '<p>Quick reminder: if payouts are not switched on yet, do it now on the Wallet tab. One free transaction.</p><p>The contract locks each buyer to their sponsor at their first purchase, and payouts only route to wallets that are switched on. Ready early and you never miss one.</p>' },
{ label: 'The $5 test', subject: 'See a payout land in real time', html: '<p>Want to see the whole thing work? Buy the $5 Micro package on Buy packages and watch the live ledger while you do it. You will see your credits mint and the split go out in the same transaction.</p><p>When you are ready to count as a qualifying buyer for me, the $20 Activation package is the one.</p>' },
{ label: 'Qualified Start', subject: 'How to open level 2 today with your own positions', html: '<p>You can be your own first buyers, openly. On Buy packages, the Qualified Start card lets you link a second wallet you own as a position. When it buys a $20 package, it counts as a qualifying buyer, half comes straight back to your main wallet, and the credits pool with yours.</p><p>Two positions open level 2 the same day. The three Qualified Start videos in Training show every click.</p>' },
{ label: 'Share your link', subject: 'One conversation a day is the whole job', html: '<p>Promo tools has posts, texts and emails that already carry your link. Pick one and send it to one person today.</p><p>Do not wait for the perfect moment. Nobody who waited ever built a line.</p>' }
];
(function () {
const w = $('bcTemplates'); if (!w) return;
w.innerHTML = BC_TEMPLATES.map((t, i) => '<button type="button" class="chip-t" data-bct="' + i + '">' + esc(t.label) + '</button>').join('');
w.querySelectorAll('[data-bct]').forEach(b => b.addEventListener('click', () => { const t = BC_TEMPLATES[Number(b.dataset.bct)]; $('bcSubject').value = t.subject; $('bcEd').innerHTML = t.html; $('bcEd').focus(); }));
})();
// ── Qualified Start calculator ──
(function () {
const n = $('qcN'), pk = $('qcPkg'), out = $('qcOut'); if (!n || !pk || !out) return;
const CR = { 20: 2000, 50: 5500, 100: 12000, 250: 32500 };
const calc = () => {
const k = Math.max(1, Math.min(10, Number(n.value) || 1)), usd = Number(pk.value) || 20;
const gross = k * usd, back = gross / 2, net = gross - back, credits = k * (CR[usd] || 0);
const level = k >= 5 ? 'Level 3 open (and level 2): the Nexus badge, wall position 3, full 50 / 20 / 10' : k >= 2 ? 'Level 2 open: 20% on your directs\' buyers, wall position 2' : 'Counts as one qualifying buyer. One more opens level 2.';
out.innerHTML = '<div class="grid c2"><div><b>$' + gross + '</b> out across ' + k + ' position' + (k === 1 ? '' : 's') + '<br><b>$' + back + '</b> back to your main wallet in the same transactions (the 50% direct-sponsor share)<br><b>$' + net + '</b> net, plus a little POL for gas in each wallet</div>'
+ '<div><b>' + credits.toLocaleString() + ' credits</b> pooled for your own ads<br>' + level + '<br><span class="muted">The 20% and 10% shares go to your upline if they are qualified, otherwise to the platform.</span></div></div>';
};
n.addEventListener('input', calc); pk.addEventListener('change', calc); calc();
})();
// ── downline lineage + sponsor broadcast + upline messages ── // ── downline lineage + sponsor broadcast + upline messages ──
async function loadLineage() { async function loadLineage() {
try { try {
+1
View File
@@ -7,6 +7,7 @@
document.getElementById('gate').style.display = signedIn ? 'none' : 'block'; document.getElementById('gate').style.display = signedIn ? 'none' : 'block';
document.getElementById('body').style.display = signedIn ? 'block' : 'none'; document.getElementById('body').style.display = signedIn ? 'block' : 'none';
if (!signedIn) return; if (!signedIn) return;
const pb = document.getElementById('printBtn'); if (pb) pb.addEventListener('click', () => window.print());
const tok = me.username || me.refCode || me.memberId; const tok = me.username || me.refCode || me.memberId;
if (tok) document.querySelectorAll('[data-link]').forEach(el => { el.textContent = location.origin + '/join/' + tok; }); if (tok) document.querySelectorAll('[data-link]').forEach(el => { el.textContent = location.origin + '/join/' + tok; });
})(); })();
+8
View File
@@ -82,6 +82,14 @@ nav .links a.active{color:var(--mint);background:rgba(67,232,195,.1)}
/* ── sections ──────────────────────────────────────── */ /* ── sections ──────────────────────────────────────── */
section{padding:64px 0 8px} section{padding:64px 0 8px}
.sectionhead{text-align:center;max-width:640px;margin:0 auto 40px} .sectionhead{text-align:center;max-width:640px;margin:0 auto 40px}
.cmp{width:100%;border-collapse:separate;border-spacing:0;font-size:15px;background:var(--panel);border:1px solid var(--line);border-radius:var(--radius);overflow:hidden}
.cmp th,.cmp td{padding:13px 16px;border-bottom:1px solid var(--line);vertical-align:top;text-align:left}
.cmp th{font-family:var(--disp);font-size:13px;letter-spacing:.06em;text-transform:uppercase;color:var(--muted)}
.cmp th:last-child,.cmp td:last-child{color:var(--ink);background:rgba(67,232,195,.06)}
.cmp td:first-child{font-weight:700;white-space:nowrap}
.cmp td:nth-child(2){color:var(--muted)}
.cmp tr:last-child td{border-bottom:0}
@media (max-width:640px){.cmp td:first-child{white-space:normal}.cmp th,.cmp td{padding:10px 10px;font-size:14px}}
h2{font-size:clamp(26px,3.2vw,36px);font-weight:700;margin:0 0 12px;text-wrap:balance} h2{font-size:clamp(26px,3.2vw,36px);font-weight:700;margin:0 0 12px;text-wrap:balance}
.sectionhead p{color:var(--muted);font-size:15.5px;margin:0} .sectionhead p{color:var(--muted);font-size:15.5px;margin:0}
h3{font-size:16.5px;margin:0 0 8px;font-weight:700} h3{font-size:16.5px;margin:0 0 8px;font-weight:700}
+1 -1
View File
@@ -17,7 +17,7 @@
<meta name="theme-color" content="#043b2f"> <meta name="theme-color" content="#043b2f">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Sora:wght@600;700;800&display=swap"> <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="icon" type="image/png" href="/logo-icon.png">
<link rel="stylesheet" href="/assets/site.css?v=20260910a"> <link rel="stylesheet" href="/assets/site.css?v=20260910b">
</head> </head>
<body> <body>
<div class="wrap"> <div class="wrap">
+1 -1
View File
@@ -5,7 +5,7 @@
<title>Disclaimer | InstantAdPay</title> <title>Disclaimer | InstantAdPay</title>
<link rel="icon" type="image/png" href="/logo-icon.png"> <link rel="icon" type="image/png" href="/logo-icon.png">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Sora:wght@600;700;800&display=swap"> <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Sora:wght@600;700;800&display=swap">
<link rel="stylesheet" href="/assets/site.css?v=20260910a"> <link rel="stylesheet" href="/assets/site.css?v=20260910b">
</head> </head>
<body> <body>
<div id="nav"></div> <div id="nav"></div>
+23 -1
View File
@@ -20,7 +20,7 @@
<meta name="theme-color" content="#043b2f"> <meta name="theme-color" content="#043b2f">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Sora:wght@600;700;800&display=swap"> <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="icon" type="image/png" href="/logo-icon.png">
<link rel="stylesheet" href="/assets/site.css?v=20260910a"> <link rel="stylesheet" href="/assets/site.css?v=20260910b">
</head> </head>
<body> <body>
@@ -101,6 +101,28 @@
</div> </div>
</section> </section>
<section id="different">
<div class="wrap">
<div class="sectionhead">
<h2>Why this is not another ad site</h2>
<p>You have seen traffic exchanges, click-to-earn sites and solo-ad sellers. Here is the honest side by side.</p>
</div>
<div class="tablewrap"><table class="cmp">
<tr><th></th><th>Typical advertising site</th><th>InstantAdPay</th></tr>
<tr><td>Referral commission</td><td>5 to 15 percent, often only after a minimum balance</td><td><b>50 percent</b> to the direct sponsor, then 20 and 10 on the next two levels</td></tr>
<tr><td>When you get paid</td><td>Request a withdrawal, wait for approval, hope</td><td>In the <b>same transaction</b> the package sells</td></tr>
<tr><td>Who holds the money</td><td>The site's balance, at the owner's discretion</td><td><b>Nobody.</b> The contract splits it to real wallets. There is no balance to hold</td></tr>
<tr><td>Can the rules change</td><td>Whenever the owner edits a setting</td><td><b>Never.</b> 50 / 20 / 10 / 20 are constants in a verified, immutable contract</td></tr>
<tr><td>Proof</td><td>A number on a dashboard</td><td>Every payout is a <b>public transaction</b> you can open yourself</td></tr>
<tr><td>Ad views</td><td>Timers that run while nobody looks</td><td>A 10-second dwell, a human check, and a <b>server-side clock</b></td></tr>
<tr><td>Credits</td><td>Points that expire or get devalued</td><td><b>1 credit = 1 cent</b> of delivery, recorded on-chain, spent only by your campaigns</td></tr>
<tr><td>Joining</td><td>Install an app, connect a wallet, then maybe read</td><td><b>Email first.</b> The wallet comes when you are ready to be paid</td></tr>
<tr><td>Advertising</td><td>Pay to be seen by people who are paid to click</td><td>Seven formats delivered to <b>members who buy ads themselves</b>, with verified visits and finished video views</td></tr>
</table></div>
<p class="small muted" style="text-align:center;margin-top:14px">No income is guaranteed. Results depend on your effort. Cryptocurrency involves risk of loss. InstantAdPay sells advertising; it is not an investment.</p>
</div>
</section>
<section> <section>
<div class="wrap"> <div class="wrap">
<div class="sectionhead"> <div class="sectionhead">
+1 -1
View File
@@ -6,7 +6,7 @@
<title>You're invited | InstantAdPay</title> <title>You're invited | InstantAdPay</title>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Sora:wght@600;700;800&display=swap"> <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="icon" type="image/png" href="/logo-icon.png">
<link rel="stylesheet" href="/assets/site.css?v=20260910a"> <link rel="stylesheet" href="/assets/site.css?v=20260910b">
<style> <style>
.jn-nav{display:flex;justify-content:space-between;align-items:center;gap:12px;padding:16px 0} .jn-nav{display:flex;justify-content:space-between;align-items:center;gap:12px;padding:16px 0}
.jn-hero{padding:34px 0 10px;text-align:left} .jn-hero{padding:34px 0 10px;text-align:left}
+1 -1
View File
@@ -17,7 +17,7 @@
<meta name="theme-color" content="#043b2f"> <meta name="theme-color" content="#043b2f">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Sora:wght@600;700;800&display=swap"> <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="icon" type="image/png" href="/logo-icon.png">
<link rel="stylesheet" href="/assets/site.css?v=20260910a"> <link rel="stylesheet" href="/assets/site.css?v=20260910b">
</head> </head>
<body> <body>
<div class="wrap"> <div class="wrap">
+40 -2
View File
@@ -5,7 +5,7 @@
<title>Member area | InstantAdPay</title> <title>Member area | InstantAdPay</title>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Sora:wght@600;700;800&display=swap"> <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="icon" type="image/png" href="/logo-icon.png">
<link rel="stylesheet" href="/assets/site.css?v=20260910a"> <link rel="stylesheet" href="/assets/site.css?v=20260910b">
</head> </head>
<body class="bo-body"> <body class="bo-body">
@@ -275,12 +275,35 @@
</p> </p>
<p class="muted small" id="pitchPreview" hidden></p> <p class="muted small" id="pitchPreview" hidden></p>
</div> </div>
<div class="card" id="linkStatsCard">
<h3>Your links: what is working</h3>
<p class="muted small">Views, joins and qualifying buyers for each hook. Add <span class="mono">?v=instant</span>, <span class="mono">?v=adspend</span>, <span class="mono">?v=free</span>, <span class="mono">?v=ledger</span> or <span class="mono">?v=two</span> to the end of your link to pick the hook the page opens with.</p>
<div class="tablewrap"><table id="linkStatsTable" class="small"></table></div>
</div>
<div class="card"> <div class="card">
<h3>Your line</h3> <h3>Your line</h3>
<p class="muted small">Everyone who joined through your link, newest first. When they buy their <p class="muted small">Everyone who joined through your link, newest first. When they buy their
first package of $20 or more, they count toward your qualification.</p> first package of $20 or more, they count toward your qualification.</p>
<div id="rosterWrap"><p class="muted small" id="rosterEmpty">Nobody yet. Your link is ready above; share it and this list starts filling.</p></div> <div id="rosterWrap"><p class="muted small" id="rosterEmpty">Nobody yet. Your link is ready above; share it and this list starts filling.</p></div>
</div> </div>
<div class="card" id="coachCard">
<h3>Coach your directs</h3>
<p class="muted small">Where each person you referred sits on the ladder, who has gone quiet, and what to say next. <b>Nudge</b> opens a chat with the message already written; edit it if you like, then send. Quiet members also get one automatic reminder email per step from InstantAdPay, so your nudge is the personal one.</p>
<p class="small" id="coachSummary"></p>
<div id="coachList"><p class="muted small">Loading…</p></div>
</div>
<div class="card" id="prospectCard">
<h3>People you have talked to</h3>
<p class="muted small">Your own follow-up list. Add anyone you mentioned InstantAdPay to, give them a status and a follow-up day, and the next conversation stays in front of you. Only you can see this.</p>
<form id="prospectForm" style="display:flex;gap:8px;flex-wrap:wrap;align-items:flex-end;margin:0 0 12px">
<input id="ppName" placeholder="Name" maxlength="120" required style="flex:1;min-width:140px">
<input id="ppContact" placeholder="Phone, handle or email" maxlength="120" style="flex:1;min-width:160px">
<select id="ppStatus" style="min-width:130px"></select>
<input id="ppNext" type="date" title="Follow up on" style="min-width:150px">
<button class="btn small" type="submit">Add</button>
</form>
<div id="prospectList"><p class="muted small">Nobody on the list yet.</p></div>
</div>
<div class="card"> <div class="card">
<h3>Your downline, three levels deep</h3> <h3>Your downline, three levels deep</h3>
@@ -298,6 +321,8 @@
<option value="direct">My direct referrals only (level 1)</option> <option value="direct">My direct referrals only (level 1)</option>
<option value="all">My whole downline (levels 1–3)</option> <option value="all">My whole downline (levels 1–3)</option>
</select></p> </select></p>
<p class="small muted" style="margin:10px 0 4px">Start from a template:</p>
<div class="chips" id="bcTemplates" style="margin:0 0 10px"></div>
<p><input id="bcSubject" maxlength="160" placeholder="Subject" style="width:100%"></p> <p><input id="bcSubject" maxlength="160" placeholder="Subject" style="width:100%"></p>
<div class="ed-bar" aria-label="Formatting"> <div class="ed-bar" aria-label="Formatting">
<button type="button" data-bc="bold" title="Bold"><b>B</b></button> <button type="button" data-bc="bold" title="Bold"><b>B</b></button>
@@ -346,6 +371,15 @@
<div id="qsList" class="small"></div> <div id="qsList" class="small"></div>
<p class="muted small" style="margin:12px 0 0">Your own money, your own wallets, a faster start. It is not an income promise: qualification only pays on future purchases in your line, and every purchase here is a real ad package you can spend.</p> <p class="muted small" style="margin:12px 0 0">Your own money, your own wallets, a faster start. It is not an income promise: qualification only pays on future purchases in your line, and every purchase here is a real ad package you can spend.</p>
</div> </div>
<div class="card" id="qsCalcCard">
<h3>Qualified Start calculator</h3>
<p class="muted small">What it costs to open each level with your own positions, what comes straight back to your main wallet, and the ad credits you end up holding.</p>
<p style="display:flex;gap:12px;flex-wrap:wrap;align-items:center">
<label class="small">Positions <input type="number" id="qcN" min="1" max="10" value="2" style="width:80px;margin-left:6px"></label>
<label class="small">Package each <select id="qcPkg" style="margin-left:6px"><option value="20">$20 · 2,000 credits</option><option value="50">$50 · 5,500 credits</option><option value="100">$100 · 12,000 credits</option><option value="250">$250 · 32,500 credits</option></select></label>
</p>
<div id="qcOut" class="small"></div>
</div>
</div> </div>
<div class="pane" id="pane-campaigns" hidden> <div class="pane" id="pane-campaigns" hidden>
@@ -560,6 +594,10 @@
<div><span class="eyebrow" style="margin:0 0 4px">Your invite link</span><div class="mono small" id="promoLink" style="overflow-wrap:anywhere">…</div></div> <div><span class="eyebrow" style="margin:0 0 4px">Your invite link</span><div class="mono small" id="promoLink" style="overflow-wrap:anywhere">…</div></div>
<button class="btn small" id="promoLinkCopy" type="button">Copy link</button> <button class="btn small" id="promoLinkCopy" type="button">Copy link</button>
</div> </div>
<div class="card promo-strip" id="promoWallStrip" hidden>
<div><span class="eyebrow" style="margin:0 0 4px">Your banner wall</span><div class="mono small" id="promoWallLink" style="overflow-wrap:anywhere">…</div></div>
<span><button class="btn small sec" id="promoWallCopy" type="button">Copy wall link</button> <a class="btn small sec" id="promoWallOpen" href="#" target="_blank" rel="noopener">Open</a></span>
</div>
<div class="chips promo-pills" role="tablist" aria-label="Promo tools sections"> <div class="chips promo-pills" role="tablist" aria-label="Promo tools sections">
<button class="chip-t on" data-promo="posts" type="button" role="tab">Social posts</button> <button class="chip-t on" data-promo="posts" type="button" role="tab">Social posts</button>
<button class="chip-t" data-promo="text" type="button" role="tab">Text a friend</button> <button class="chip-t" data-promo="text" type="button" role="tab">Text a friend</button>
@@ -797,7 +835,7 @@
<script src="/assets/common.js?v=20260910a"></script> <script src="/assets/common.js?v=20260910a"></script>
<script src="/assets/wallet.js?v=20260910a"></script> <script src="/assets/wallet.js?v=20260910a"></script>
<script src="/assets/promo.js?v=20260909b"></script> <script src="/assets/promo.js?v=20260909b"></script>
<script src="/assets/my.js?v=20260910c"></script> <script src="/assets/my.js?v=20260910d"></script>
<script src="/assets/chat.js?v=20260907l"></script> <script src="/assets/chat.js?v=20260907l"></script>
</body> </body>
</html> </html>
+25 -1
View File
@@ -8,7 +8,7 @@
<meta name="theme-color" content="#043b2f"> <meta name="theme-color" content="#043b2f">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Sora:wght@600;700;800&display=swap"> <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="icon" type="image/png" href="/logo-icon.png">
<link rel="stylesheet" href="/assets/site.css?v=20260910a"> <link rel="stylesheet" href="/assets/site.css?v=20260910b">
<style> <style>
.pl-rule{border-left:4px solid #f2c94c;background:rgba(242,201,76,.08);padding:16px 20px;border-radius:0 12px 12px 0;margin:0 0 28px} .pl-rule{border-left:4px solid #f2c94c;background:rgba(242,201,76,.08);padding:16px 20px;border-radius:0 12px 12px 0;margin:0 0 28px}
.pl-rule b{font-family:var(--disp);font-size:17px;display:block;margin-bottom:6px} .pl-rule b{font-family:var(--disp);font-size:17px;display:block;margin-bottom:6px}
@@ -39,6 +39,8 @@
.fit-table th{text-align:left;color:var(--muted);font-family:var(--mono);font-size:11px;letter-spacing:.08em;text-transform:uppercase;font-weight:500} .fit-table th{text-align:left;color:var(--muted);font-family:var(--mono);font-size:11px;letter-spacing:.08em;text-transform:uppercase;font-weight:500}
.fit-table td,.fit-table th{padding:9px 10px;border-bottom:1px solid var(--line);vertical-align:top} .fit-table td,.fit-table th{padding:9px 10px;border-bottom:1px solid var(--line);vertical-align:top}
#gate{display:none} #gate{display:none}
#printable{display:none}
@media print{ body{background:#fff;color:#000} .wrap>*{display:none!important} #printable{display:block!important;color:#000} #printable h2,#printable h3{color:#000;font-family:Georgia,serif} #printable li{margin:6px 0} }
@media (max-width:720px){.pl-split{grid-template-columns:repeat(2,1fr)}.pl-ladder{grid-template-columns:1fr}.pl-ladder>div{border-right:0;border-bottom:1px solid var(--line)}.meta,.cal{grid-template-columns:1fr}} @media (max-width:720px){.pl-split{grid-template-columns:repeat(2,1fr)}.pl-ladder{grid-template-columns:1fr}.pl-ladder>div{border-right:0;border-bottom:1px solid var(--line)}.meta,.cal{grid-template-columns:1fr}}
</style> </style>
</head> </head>
@@ -160,6 +162,28 @@
</div> </div>
<p class="muted small">No income is guaranteed. Results depend on your effort. Cryptocurrency involves risk of loss. InstantAdPay sells advertising; it is not an investment.</p> <p class="muted small">No income is guaranteed. Results depend on your effort. Cryptocurrency involves risk of loss. InstantAdPay sells advertising; it is not an investment.</p>
<p><button class="btn sec" type="button" id="printBtn">Print the Qualified Start checklist and 30-day calendar</button></p>
</div>
<div id="printable">
<h2>InstantAdPay: Qualified Start checklist</h2>
<ol>
<li>Username chosen (Profile tab). It is permanent: it becomes your invite link.</li>
<li>Main wallet linked (Wallet tab, one free signature).</li>
<li>Payouts switched on (Wallet tab, one free transaction).</li>
<li>Extra wallet accounts created in your wallet app: IAP Position 2, 3, 4, 5.</li>
<li>Each extra account funded with enough POL for a $20 package plus gas.</li>
<li>Buy packages: Add a position, tick only the new account, sign once.</li>
<li>Buy from: choose the position, Buy $20, confirm in the wallet.</li>
<li>Repeat. Two positions open level 2. Five open level 3 and the Nexus badge.</li>
</ol>
<h2>First 30 days, any play</h2>
<h3>Day 1</h3><ul><li>Username, wallet linked, payouts on, welcome tour done.</li><li>Link sent to one person.</li></ul>
<h3>Days 2 to 7</h3><ul><li>One conversation a day.</li><li>Claim the daily 5 credits.</li><li>First buyer.</li></ul>
<h3>Days 8 to 14</h3><ul><li>Second qualifying buyer. Level 2 open.</li><li>Line banner set. First broadcast sent.</li></ul>
<h3>Days 15 to 30</h3><ul><li>Coach the two to their two.</li><li>Add directs three, four, five. Level 3 open by day 30 is the stretch goal.</li></ul>
<p>My invite link: <span data-link>__________________________</span></p>
<p style="font-size:12px">No income is guaranteed. Results depend on your effort. Cryptocurrency involves risk of loss. InstantAdPay sells advertising; it is not an investment.</p>
</div> </div>
<footer> <footer>
+1 -1
View File
@@ -5,7 +5,7 @@
<title>Privacy Policy | InstantAdPay</title> <title>Privacy Policy | InstantAdPay</title>
<link rel="icon" type="image/png" href="/logo-icon.png"> <link rel="icon" type="image/png" href="/logo-icon.png">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Sora:wght@600;700;800&display=swap"> <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Sora:wght@600;700;800&display=swap">
<link rel="stylesheet" href="/assets/site.css?v=20260910a"> <link rel="stylesheet" href="/assets/site.css?v=20260910b">
</head> </head>
<body> <body>
<div id="nav"></div> <div id="nav"></div>
+1 -1
View File
@@ -5,7 +5,7 @@
<title>Shorts | InstantAdPay</title> <title>Shorts | InstantAdPay</title>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Sora:wght@600;700;800&display=swap"> <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="icon" type="image/png" href="/logo-icon.png">
<link rel="stylesheet" href="/assets/site.css?v=20260910a"> <link rel="stylesheet" href="/assets/site.css?v=20260910b">
<style> <style>
html,body{height:100%;margin:0;overflow:hidden;background:#000} html,body{height:100%;margin:0;overflow:hidden;background:#000}
.sh{position:fixed;inset:0;display:flex;flex-direction:column;background:#000;color:var(--ink,#e8fff7)} .sh{position:fixed;inset:0;display:flex;flex-direction:column;background:#000;color:var(--ink,#e8fff7)}
+1 -1
View File
@@ -5,7 +5,7 @@
<title>Terms of Service | InstantAdPay</title> <title>Terms of Service | InstantAdPay</title>
<link rel="icon" type="image/png" href="/logo-icon.png"> <link rel="icon" type="image/png" href="/logo-icon.png">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Sora:wght@600;700;800&display=swap"> <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Sora:wght@600;700;800&display=swap">
<link rel="stylesheet" href="/assets/site.css?v=20260910a"> <link rel="stylesheet" href="/assets/site.css?v=20260910b">
</head> </head>
<body> <body>
<div id="nav"></div> <div id="nav"></div>
+1 -1
View File
@@ -5,7 +5,7 @@
<title>Transaction | InstantAdPay</title> <title>Transaction | InstantAdPay</title>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Sora:wght@600;700;800&display=swap"> <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="icon" type="image/png" href="/logo-icon.png">
<link rel="stylesheet" href="/assets/site.css?v=20260910a"> <link rel="stylesheet" href="/assets/site.css?v=20260910b">
</head> </head>
<body> <body>
<div id="nav"></div> <div id="nav"></div>
+1 -1
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width,initial-scale=1"> <meta name="viewport" content="width=device-width,initial-scale=1">
<title>Viewing ad — InstantAdPay</title> <title>Viewing ad — InstantAdPay</title>
<link rel="icon" type="image/png" href="/logo-icon.png"> <link rel="icon" type="image/png" href="/logo-icon.png">
<link rel="stylesheet" href="/assets/site.css?v=20260910a"> <link rel="stylesheet" href="/assets/site.css?v=20260910b">
<style> <style>
html,body{height:100%;margin:0;overflow:hidden} html,body{height:100%;margin:0;overflow:hidden}
.vw{display:flex;flex-direction:column;height:100vh;height:100dvh;background:var(--bg,#04110c);color:var(--ink,#e8fff7)} .vw{display:flex;flex-direction:column;height:100vh;height:100dvh;background:var(--bg,#04110c);color:var(--ink,#e8fff7)}
+1 -1
View File
@@ -6,7 +6,7 @@
<!--OG--> <!--OG-->
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Sora:wght@600;700;800&display=swap"> <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="icon" type="image/png" href="/logo-icon.png">
<link rel="stylesheet" href="/assets/site.css?v=20260910a"> <link rel="stylesheet" href="/assets/site.css?v=20260910b">
</head> </head>
<body> <body>
<div id="nav"></div> <div id="nav"></div>
+115 -2
View File
@@ -23,6 +23,8 @@ const drip = require('./drip');
const spaces = require('./spaces'); // DO Spaces video storage (inert unless DO_SPACES_* set) const spaces = require('./spaces'); // DO Spaces video storage (inert unless DO_SPACES_* set)
let QR = null; try { QR = require('qrcode'); } catch (e) { /* optional */ } let QR = null; try { QR = require('qrcode'); } catch (e) { /* optional */ }
const chatbot = require('./chatbot'); const chatbot = require('./chatbot');
const coach = require('./coach'); // coaching view, nudges, digest, prospects, link stats
const burner = require('./burner'); // automatic on-chain credit burns (inert without ENGINE_KEY)
const PORT = Number(process.env.PORT || 3000); const PORT = Number(process.env.PORT || 3000);
const ROOT = __dirname; const ROOT = __dirname;
@@ -230,7 +232,7 @@ async function frameCheck(url) {
} }
async function boot() { async function boot() {
await db.init({ dataDir: DATA_DIR }); // no-op without DATABASE_URL (JSON mode) await db.init({ dataDir: DATA_DIR }); // no-op without DATABASE_URL (JSON mode)
chain.init({ onEvent: ev => { attachNames([ev]).then(a => pushFeed(a[0])).catch(() => pushFeed(ev)); emailOnEvent(ev).catch(() => {}); } }); chain.init({ onEvent: ev => { attachNames([ev]).then(a => pushFeed(a[0])).catch(() => pushFeed(ev)); emailOnEvent(ev).catch(() => {}); telegramOnEvent(ev).catch(() => {}); } });
auth.init({ dataDir: DATA_DIR, chain, isProd: IS_PROD, site: 'instantadpay.com' }); auth.init({ dataDir: DATA_DIR, chain, isProd: IS_PROD, site: 'instantadpay.com' });
accounts.init({ dataDir: DATA_DIR }); accounts.init({ dataDir: DATA_DIR });
ads.init({ dataDir: DATA_DIR, chain }); ads.init({ dataDir: DATA_DIR, chain });
@@ -242,6 +244,12 @@ async function boot() {
setTimeout(() => ads.dailySweep().catch(e => console.error('sweep', e.message)), 60 * 1000); setTimeout(() => ads.dailySweep().catch(e => console.error('sweep', e.message)), 60 * 1000);
setInterval(() => ads.dailySweep().catch(e => console.error('sweep', e.message)), 60 * 60 * 1000); setInterval(() => ads.dailySweep().catch(e => console.error('sweep', e.message)), 60 * 60 * 1000);
// follow-up email sequence: send whatever came due (every 10 min, first pass shortly after boot) // follow-up email sequence: send whatever came due (every 10 min, first pass shortly after boot)
coach.init({ dataDir: DATA_DIR, chain, accounts, mailer });
burner.init({ chain, ads });
setTimeout(() => coach.nudgeTick().catch(e => console.error('coach', e.message)), 90 * 1000);
setInterval(() => coach.nudgeTick().catch(e => console.error('coach', e.message)), 60 * 60 * 1000);
setTimeout(() => burner.tick().catch(e => console.error('burner', e.message)), 45 * 1000);
setInterval(() => burner.tick().catch(e => console.error('burner', e.message)), 5 * 60 * 1000);
setTimeout(() => drip.tick().catch(e => console.error('drip', e.message)), 30 * 1000); setTimeout(() => drip.tick().catch(e => console.error('drip', e.message)), 30 * 1000);
setInterval(() => drip.tick().catch(e => console.error('drip', e.message)), 10 * 60 * 1000); setInterval(() => drip.tick().catch(e => console.error('drip', e.message)), 10 * 60 * 1000);
// NAS reconcile: pull syndicated delivery into the unified credit pool // NAS reconcile: pull syndicated delivery into the unified credit pool
@@ -259,7 +267,10 @@ function siteConfig() {
return Object.assign({ return Object.assign({
siteName: 'InstantAdPay', siteName: 'InstantAdPay',
tagline: 'Advertise and earn. Locked in code, not promises.', tagline: 'Advertise and earn. Locked in code, not promises.',
rehearsal: true // shows the testnet banner; flipped off at mainnet launch rehearsal: true, // shows the testnet banner; flipped off at mainnet launch
// payment-proof Telegram feed (blank = off) and the P&L pane's fixed monthly cost
telegramBotToken: '', telegramChatId: '', telegramTopicId: '', telegramEvents: 'payouts', telegramCtaUrl: 'https://instantadpay.com/',
pnlFixedMonthlyUsd: 0
}, saved); }, saved);
} }
@@ -431,8 +442,38 @@ async function emailOnEvent(ev) {
} }
else if (ev.type === 'TierPaid') await notify(ev.recipientId, 'You just got paid on InstantAdPay', 'A level-' + ev.tier + ' payout of ' + weiToPol(ev.amountWei) + ' POL just landed in your wallet.'); else if (ev.type === 'TierPaid') await notify(ev.recipientId, 'You just got paid on InstantAdPay', 'A level-' + ev.tier + ' payout of ' + weiToPol(ev.amountWei) + ' POL just landed in your wallet.');
else if (ev.type === 'AwardPaid') await notify(ev.toId, 'You just got paid on InstantAdPay', weiToPol(ev.amountWei) + ' POL just landed in your wallet.'); else if (ev.type === 'AwardPaid') await notify(ev.toId, 'You just got paid on InstantAdPay', weiToPol(ev.amountWei) + ' POL just landed in your wallet.');
else if (ev.type === 'PassedUp' && ev.reason === 'send-failed') {
// the member WAS qualified but their wallet rejected the POL (usually a smart-contract
// wallet that needs more than the capped gas): tell them and the admin, loudly
await notify(ev.skippedId, 'Your wallet rejected a payout on InstantAdPay', 'A level-' + ev.tier + ' payout tried to reach your linked wallet and the wallet refused the transfer, so it passed to the next qualified member. This happens with some smart-contract wallets. Link a regular wallet address (MetaMask, SafePal, Phantom) on the Wallet tab so the next payout lands.');
if (ADMIN_EMAIL) mailer.send(ADMIN_EMAIL, 'InstantAdPay: payout send-failed for member #' + ev.skippedId, 'A level-' + ev.tier + ' payout to member #' + ev.skippedId + ' failed at the wallet (send-failed) and passed up. Tx: ' + ev.tx + '\n\nThe member has been emailed to link a regular wallet.').catch(() => {});
}
else if (ev.type === 'PassedUp') await notify(ev.skippedId, 'A payout passed you by on InstantAdPay', 'A level-' + ev.tier + ' payout passed you by because you were not qualified yet. Get qualified so you catch the next one.'); else if (ev.type === 'PassedUp') await notify(ev.skippedId, 'A payout passed you by on InstantAdPay', 'A level-' + ev.tier + ' payout passed you by because you were not qualified yet. Get qualified so you catch the next one.');
} }
// payment-proof Telegram feed (same pattern as the RM Circle proof channel): one compact
// line per event, admin-configured under Settings > Site (telegramBotToken, telegramChatId,
// optional telegramTopicId, telegramEvents = payouts | payouts+purchases | all, telegramCtaUrl)
async function telegramOnEvent(ev) {
const sc = siteConfig();
if (!sc.telegramBotToken || !sc.telegramChatId || !ev) return;
const mode = String(sc.telegramEvents || 'payouts');
const names = await accounts.namesForMembers([ev.recipientId, ev.buyerId, ev.sponsorId, ev.newBuyerId, ev.id].filter(Boolean)).catch(() => ({}));
const who = id => '#' + id + (names[id] ? ' @' + names[id] : '');
const cc = chain.getConfig();
const tx = (cc.explorer ? cc.explorer.replace(/\/+$/, '') : 'https://polygonscan.com') + '/tx/' + ev.tx;
let line = null;
if (ev.type === 'TierPaid') line = '\u{1F4B8} Level ' + ev.tier + ' payout: <b>' + weiToPol(ev.amountWei) + ' POL</b> \u2192 ' + who(ev.recipientId);
else if (ev.type === 'BuyerCounted' && mode !== 'payouts') line = '\u2B50 ' + who(ev.sponsorId) + ' now has <b>' + ev.newCount + '</b> qualifying buyer' + (ev.newCount === 1 ? '' : 's') + (ev.newCount === 2 ? ' \u00b7 level 2 open' : ev.newCount === 5 ? ' \u00b7 level 3 open' : '');
else if (ev.type === 'Purchase' && mode !== 'payouts') line = '\u{1F9FE} ' + who(ev.buyerId) + ' bought a $' + Math.round(ev.priceCents / 100) + ' package';
else if (ev.type === 'MemberActivated' && mode === 'all') line = '\u{1F91D} ' + who(ev.id) + ' switched on payouts';
if (!line) return;
const text = line + ' \u00b7 <a href="' + tx + '">verify</a>' + (sc.telegramCtaUrl ? '\n<a href="' + sc.telegramCtaUrl + '">Join free</a>' : '');
const body = JSON.stringify(Object.assign({ chat_id: sc.telegramChatId, text, parse_mode: 'HTML', disable_web_page_preview: true }, sc.telegramTopicId ? { message_thread_id: Number(sc.telegramTopicId) } : {}));
await new Promise((resolve) => {
const rq = https.request({ hostname: 'api.telegram.org', path: '/bot' + sc.telegramBotToken + '/sendMessage', method: 'POST', headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }, timeout: 10000 }, r => { r.resume(); r.on('end', resolve); });
rq.on('error', () => resolve()); rq.on('timeout', () => { rq.destroy(); resolve(); }); rq.end(body);
});
}
// ---- live feed (SSE) ---- // ---- live feed (SSE) ----
const feedClients = new Set(); const feedClients = new Set();
@@ -458,6 +499,7 @@ const server = http.createServer(async (req, res) => {
const cookies = parseCookies(req); const cookies = parseCookies(req);
const angle = String(u.searchParams.get('v') || '').toLowerCase(); const angle = String(u.searchParams.get('v') || '').toLowerCase();
const ang = JOIN_ANGLES[angle] || null; const ang = JOIN_ANGLES[angle] || null;
if (req.method === 'GET') coach.recordView(tok, ang ? angle : ''); // link stats per angle
const cookieTail = `; Path=/; SameSite=Lax; Max-Age=${180 * 24 * 3600}${IS_PROD ? '; Secure' : ''}`; const cookieTail = `; Path=/; SameSite=Lax; Max-Age=${180 * 24 * 3600}${IS_PROD ? '; Secure' : ''}`;
const set = []; const set = [];
if (!cookies['iap.sponsor']) set.push('iap.sponsor=' + tok + cookieTail); if (!cookies['iap.sponsor']) set.push('iap.sponsor=' + tok + cookieTail);
@@ -835,11 +877,46 @@ const server = http.createServer(async (req, res) => {
const r = await accounts.removePosition(s.email, b.address); const r = await accounts.removePosition(s.email, b.address);
return json(res, r.error ? 400 : 200, r); return json(res, r.error ? 400 : 200, r);
} }
// -- coaching: every direct's ladder rung, stalled flag, and what to say
if (p === '/api/my/coach' && req.method === 'GET') {
const s = await auth.fromRequest(req);
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
return json(res, 200, await coach.coachView(s.email));
}
// -- link stats: views, joins and buyers per angle link
if (p === '/api/my/linkstats' && req.method === 'GET') {
const s = await auth.fromRequest(req);
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
return json(res, 200, await coach.linkStats(s.email));
}
// -- prospects: the member's own follow-up list
if (p === '/api/my/prospects' && req.method === 'GET') {
const s = await auth.fromRequest(req);
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
return json(res, 200, { prospects: await coach.prospects(s.email), statuses: coach.STATUSES });
}
if (p === '/api/my/prospects' && req.method === 'POST') {
const s = await auth.fromRequest(req);
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
const r = await coach.saveProspect(s.email, await readBody(req));
return json(res, r.error ? 400 : 200, r);
}
if (p === '/api/my/prospects/remove' && 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 coach.removeProspect(s.email, b.id);
return json(res, r.error ? 400 : 200, r);
}
if (p === '/api/my/profile' && req.method === 'POST') { if (p === '/api/my/profile' && req.method === 'POST') {
const s = await auth.fromRequest(req); const s = await auth.fromRequest(req);
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' }); if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
const b = await readBody(req); const b = await readBody(req);
const before = await accounts.byEmail(s.email); const before = await accounts.byEmail(s.email);
// a username is permanent once set: the invite link, the public wall and every
// banner already printed carry it (Marty, 2026-09-10)
if (before && before.username && String(b.username || '').trim().toLowerCase() !== String(before.username).toLowerCase())
return json(res, 400, { error: 'Your username is locked. Your invite link, your public page and any banners you shared all carry @' + before.username + '. Contact support if it truly has to change.' });
const r = await accounts.setUsername(s.email, b.username); const r = await accounts.setUsername(s.email, b.username);
// First time a username is set (onboarding): now there's a real name to // First time a username is set (onboarding): now there's a real name to
// show, so notify the sponsor here rather than at signup (where it'd just // show, so notify the sponsor here rather than at signup (where it'd just
@@ -1641,6 +1718,42 @@ const server = http.createServer(async (req, res) => {
if (!isAdmin(req)) return json(res, 401, { error: 'auth' }); if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
return json(res, 200, { site: siteConfig() }); return json(res, 200, { site: siteConfig() });
} }
// -- profit and loss from the chain index: volume, platform fees, member payouts,
// pass-ups, per period (by block: ~43,200 Polygon blocks a day), plus the fee
// wallets' live balances and an admin-entered fixed monthly cost
if (p === '/api/admin/pnl' && req.method === 'GET') {
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
const days = Math.max(0, Number(u.searchParams.get('days') || 30));
let latest = 0; try { latest = parseInt(await chain.rpc('eth_blockNumber', []), 16); } catch (e) {}
const fromBlock = days ? latest - Math.round(days * 43200) : 0;
const evs = chain.recentEvents(1e9).filter(e => !days || e.block >= fromBlock);
const sum = (list, f) => list.reduce((n, e) => n + BigInt(f(e) || '0'), 0n);
const purchases = evs.filter(e => e.type === 'Purchase');
const tier = evs.filter(e => e.type === 'TierPaid');
const admin = evs.filter(e => e.type === 'AdminPaid');
const passed = evs.filter(e => e.type === 'PassedUp');
const byTier = {};
for (const t of [1, 2, 3]) byTier[t] = sum(tier.filter(e => e.tier === t), e => e.amountWei).toString();
const byPkg = {};
for (const e of purchases) { const k = '$' + Math.round(e.priceCents / 100); byPkg[k] = (byPkg[k] || 0) + 1; }
let polUsd = 0; try { const cat = await chain.catalog(); const pk = (cat.products || cat).find(x => x.costWei); if (pk) polUsd = (pk.priceCents / 100) / (Number(BigInt(pk.costWei)) / 1e18); } catch (e) {}
const wallets = { feeA: '0x7627fc78876948ac9d95c1c9eb061e7d6d647b70', feeB: '0x8b7d33849a2c4d92c985be31e46dc564ba901ad2', engine: burner.status().address || null };
const balances = {};
for (const [k, a] of Object.entries(wallets)) { if (!a) continue; try { balances[k] = BigInt(await chain.rpc('eth_getBalance', [a, 'latest'])).toString(); } catch (e) { balances[k] = null; } }
return json(res, 200, { days, fromBlock, latest, polUsd,
purchases: { count: purchases.length, volumeWei: sum(purchases, e => e.paidWei).toString(), usdCents: purchases.reduce((n, e) => n + (e.priceCents || 0), 0), byPackage: byPkg },
platformWei: sum(admin, e => e.amountWei).toString(), memberPayoutsWei: sum(tier, e => e.amountWei).toString(), byTier,
passedUp: { count: passed.length, unqualified: passed.filter(e => e.reason === 'unqualified').length, sendFailed: passed.filter(e => e.reason === 'send-failed').length },
wallets, balances, fixedMonthlyUsd: Number(siteConfig().pnlFixedMonthlyUsd) || 0, burner: burner.status() });
}
if (p === '/api/admin/burner' && req.method === 'GET') {
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
return json(res, 200, burner.status());
}
if (p === '/api/admin/burner/run' && req.method === 'POST') {
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
return json(res, 200, await burner.tick());
}
// -- admin (Bearer ADMIN_PASSWORD, or the /admin portal session) // -- admin (Bearer ADMIN_PASSWORD, or the /admin portal session)
if (p === '/api/admin/burns' && req.method === 'GET') { if (p === '/api/admin/burns' && req.method === 'GET') {