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:
+33
-3
@@ -44,8 +44,8 @@
|
||||
});
|
||||
|
||||
// ── panes ──
|
||||
const TITLES = { overview: 'Overview', house: 'House ads', campaigns: 'All campaigns', members: 'Members', reports: 'Reports', settings: 'Settings' };
|
||||
const loaders = { overview: loadOverview, house: loadHouse, campaigns: loadCampaigns, members: loadMembers, reports: loadReports, settings: loadSettings };
|
||||
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, pnl: loadPnl, settings: loadSettings };
|
||||
function setPane(name) {
|
||||
if (!TITLES[name]) name = 'overview';
|
||||
document.querySelectorAll('.pane').forEach(p => { p.hidden = p.id !== 'pane-' + name; });
|
||||
@@ -297,6 +297,36 @@
|
||||
});
|
||||
|
||||
// ── 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() {
|
||||
const [r, b] = await Promise.all([api('/api/admin/reports'), api('/api/admin/burns')]);
|
||||
const reps = r.reports || [];
|
||||
@@ -446,7 +476,7 @@
|
||||
}));
|
||||
|
||||
// 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() {
|
||||
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>'
|
||||
|
||||
+93
-2
@@ -421,6 +421,10 @@
|
||||
if (d.username) { // wall link rides the username
|
||||
const wl = location.origin + '/wall/' + d.username;
|
||||
$('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;
|
||||
$('wallOpen').hidden = false;
|
||||
$('wallOpen').href = '/wall/' + d.username;
|
||||
@@ -460,7 +464,7 @@
|
||||
if ($('boTitle')) $('boTitle').textContent = TITLES[name];
|
||||
if (name === 'earn') setEarnSub(earnSub); // refresh whichever sub-tab is active
|
||||
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 === 'training') loadTraining();
|
||||
document.getElementById('memberArea').classList.remove('side-open'); // close mobile drawer
|
||||
@@ -525,8 +529,9 @@
|
||||
loadCampaigns();
|
||||
|
||||
// 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 || '';
|
||||
$('pfUsername').disabled = !!me.username; $('pfSaveBtn').hidden = !!me.username;
|
||||
$('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')
|
||||
+ '<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 ──
|
||||
async function loadLineage() {
|
||||
try {
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
document.getElementById('gate').style.display = signedIn ? 'none' : 'block';
|
||||
document.getElementById('body').style.display = signedIn ? 'block' : 'none';
|
||||
if (!signedIn) return;
|
||||
const pb = document.getElementById('printBtn'); if (pb) pb.addEventListener('click', () => window.print());
|
||||
const tok = me.username || me.refCode || me.memberId;
|
||||
if (tok) document.querySelectorAll('[data-link]').forEach(el => { el.textContent = location.origin + '/join/' + tok; });
|
||||
})();
|
||||
|
||||
@@ -82,6 +82,14 @@ nav .links a.active{color:var(--mint);background:rgba(67,232,195,.1)}
|
||||
/* ── sections ──────────────────────────────────────── */
|
||||
section{padding:64px 0 8px}
|
||||
.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}
|
||||
.sectionhead p{color:var(--muted);font-size:15.5px;margin:0}
|
||||
h3{font-size:16.5px;margin:0 0 8px;font-weight:700}
|
||||
|
||||
Reference in New Issue
Block a user