Files
rm-circle-team-router/public/admin.js
T
martbost 9a1833f2b0 Wallet-verified member messaging (matrix-lines permissions, admin visibility)
- messages.js: challenge/personal_sign/recover auth (vendored pinned
  js-sha3 0.9.3 + noble-secp256k1 1.7.1, server-side only; self-tested
  positive + tamper cases), 30d HttpOnly sessions, message store on the
  volume, matrix-line permissions (your downline direct or broadcast, your
  upline chain - nothing else, so spam is impossible by construction),
  daily rate limits (30 direct / 3 broadcasts), 1500-char plain text
- chain.js: memberIdByAccount (wallet -> position for sign-in)
- API: msg-challenge/-verify/-me/-inbox/-send/-read public + msg-unread
  (count only, no auth) + admin/messages (full visibility, disclosed to
  members in the UI)
- Dashboard: Messages card with unread bell, one-tap wallet sign-in,
  inbox with auto-read, compose with to-ID or whole-team broadcast
- Admin: Member Messages review table
- Chatbot canned answer + AI system prompt updated

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 13:31:31 -05:00

264 lines
35 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const loginView=document.getElementById('loginView'),adminView=document.getElementById('adminView'),rows=document.getElementById('sponsorRows'),toast=document.getElementById('toast');let state=null;
const LEVELS=['Scintilla','Ascensus','Fabrica','Culmen','Apex','Fastigium','Vertex','Corona'];
function levelSelect(s){const opts=(LEVELS.includes(s.level)?LEVELS:[s.level,...LEVELS]).map(l=>`<option${l===s.level?' selected':''}>${esc(l)}</option>`).join('');return `<select class="select select-inline" data-action="level" data-id="${esc(s.id)}">${opts}</select>`}
function showToast(msg){toast.textContent=msg;toast.classList.add('show');setTimeout(()=>toast.classList.remove('show'),1800)}
async function api(url,opts={}){const r=await fetch(url,{headers:{'Content-Type':'application/json',...(opts.headers||{})},...opts});const d=await r.json().catch(()=>({}));if(!r.ok)throw new Error(d.error||'Request failed');return d}
async function loadState(){try{state=await api('/api/admin/state');loginView.classList.add('hidden');adminView.classList.remove('hidden');render()}catch(e){loginView.classList.remove('hidden');adminView.classList.add('hidden')}}
function statusClass(s){return `status status-${s}`}
function pct(n,d){return d?`${Math.round((n/d)*100)}%`:'—'}
function renderAnalytics(){
const src=(state.analytics&&state.analytics.sources)||{};
const tot={bridge:0,start:0,click:0,training:0,postback:0,purchase:0,join:0,joinnow:0};
const list=Object.entries(src).map(([name,r])=>{tot.bridge+=r.bridge||0;tot.start+=r.start||0;tot.click+=r.click||0;tot.training+=r.training||0;tot.postback+=r.postback||0;tot.purchase+=r.purchase||0;tot.join+=r.join||0;tot.joinnow+=r.joinnow||0;return{name,bridge:r.bridge||0,start:r.start||0,click:r.click||0,training:r.training||0,join:r.join||0,joinnow:r.joinnow||0,purchase:r.purchase||0}})
.sort((a,b)=>b.click-a.click||b.start-a.start||b.bridge-a.bridge);
document.getElementById('funnelStats').innerHTML=
`<div class="fact"><small>Bridge views</small><strong>${tot.bridge}</strong></div>`+
`<div class="fact"><small>Start views</small><strong>${tot.start}</strong></div>`+
`<div class="fact"><small>Join clicks</small><strong>${tot.click}</strong></div>`+
`<div class="fact"><small>Bridge → Start</small><strong>${pct(tot.start,tot.bridge)}</strong></div>`+
`<div class="fact"><small>Start → Join</small><strong>${pct(tot.click,tot.start)}</strong></div>`+
(tot.join?`<div class="fact"><small>Invite-page views</small><strong>${tot.join}</strong></div>`:'')+
(tot.joinnow?`<div class="fact"><small>Join-now views</small><strong>${tot.joinnow}</strong></div>`:'')+
(tot.purchase?`<div class="fact"><small>Confirmed joins</small><strong>${tot.purchase}</strong></div>`:'')+
(tot.joinnow&&tot.purchase?`<div class="fact"><small>Join-now → Confirmed</small><strong>${pct(tot.purchase,tot.joinnow)}</strong></div>`:'')+
(tot.postback?`<div class="fact"><small>BeMob postbacks</small><strong>${tot.postback}</strong></div>`:'');
const subs=state.submissions||[];
document.getElementById('submissionRows').innerHTML=subs.map(s=>{
const path=s.path==='rotation'?'<span class="status status-active">rotation</span> ':s.path==='leg'?'<span class="status status-qualified">leg</span> ':'';
const oc=s.onchain?(s.onchain.registered?`${path}<span style="color:var(--ok)">✓ ${esc(s.onchain.tier||'')} · ref #${esc(String(s.onchain.referrerId??'?'))}</span>`:'<span style="color:var(--danger)">✗ not found</span>'):'—';
return `<tr><td>${esc((s.ts||'').replace('T',' ').slice(0,16))}</td><td>${esc(s.memberName||'—')}</td><td><strong>${esc(s.newId)}</strong></td><td>ID ${esc(s.sponsorId)}</td><td>${esc(s.source)}</td><td>${oc}</td></tr>`;
}).join('')||'<tr><td colspan="6" class="empty">No submissions yet.</td></tr>';
document.getElementById('trafficRows').innerHTML=list.map(r=>`<tr><td><strong>${esc(r.name)}</strong></td><td>${r.bridge}</td><td>${r.start}</td><td>${r.training}</td><td>${r.join}</td><td>${r.joinnow}</td><td>${r.click}</td><td>${r.purchase?`<strong style="color:var(--ok)">${r.purchase}</strong>`:0}</td><td>${pct(r.click,r.start)}</td></tr>`).join('')||'<tr><td colspan="9" class="empty">No traffic recorded yet.</td></tr>';
}
function esc(s){return String(s??'').replace(/[&<>'"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;',"'":'&#39;','"':'&quot;'}[c]))}
function render(){
const sponsors=[...state.sponsors].sort((a,b)=>a.sortOrder-b.sortOrder);rows.innerHTML=sponsors.map((s,i)=>`<tr><td>${i+1}</td><td><button class="name-edit" data-action="rename" data-id="${esc(s.id)}" title="Click to rename"><strong>${esc(s.name)}</strong> <span class="name-pencil">✎</span></button><div class="micro" style="margin:2px 0 0">ID ${esc(s.id)}</div>${s.email?`<div class="micro" style="margin:2px 0 0">✉ ${esc(s.email)}</div>`:''}</td><td>${esc(s.parentId||'—')}</td><td><strong>${s.directs}/2</strong></td><td>${levelSelect(s)}</td><td><span class="${statusClass(s.status)}">${esc(s.status)}</span></td><td>${s.clicks||0}</td><td><div class="actions">${s.status!=='qualified'?`<button class="btn btn-secondary btn-sm" data-action="inc" data-id="${esc(s.id)}">+1 Direct</button><button class="btn btn-teal btn-sm" data-action="qualify" data-id="${esc(s.id)}">Qualified</button>`:`<button class="btn btn-secondary btn-sm" data-action="reset" data-id="${esc(s.id)}">Reset</button>`}${s.status==='waiting'?`<button class="btn btn-secondary btn-sm" data-action="activate" data-id="${esc(s.id)}">Make Active</button>`:''}<button class="btn btn-secondary btn-sm" data-action="email" data-id="${esc(s.id)}" title="Set contact email">✉</button><button class="btn btn-secondary btn-sm" data-action="up" data-id="${esc(s.id)}">↑</button><button class="btn btn-secondary btn-sm" data-action="down" data-id="${esc(s.id)}">↓</button><button class="btn btn-danger btn-sm" data-action="delete" data-id="${esc(s.id)}">×</button></div></td></tr>`).join('')||'<tr><td colspan="8" class="empty">No sponsors in the queue.</td></tr>';
renderAnalytics();
const ai=state.aiChat||{};document.getElementById('aiModel').textContent=ai.model||'OpenRouter';document.getElementById('aiStatus').innerHTML=ai.configured?'<span style="color:var(--ok)">● AI chat is ON</span> — key is set':'<span style="color:var(--muted)">○ AI chat is OFF</span> — using built-in answers';
const em=state.email||{};document.getElementById('emailStatus').innerHTML=em.configured?'<span style="color:var(--ok)">● Payment emails are ON</span> — key is set':'<span style="color:var(--muted)">○ Payment emails are OFF</span> — no SendGrid key';
const incEl=document.getElementById('incomeIds');if(incEl&&!incEl.value)incEl.value=state.config.ownerIds||'21,24,25';
if(!incomeAutoLoaded&&incEl&&incEl.value){incomeAutoLoaded=true;loadIncome();}
const orgEl=document.getElementById('orgRoot');if(orgEl&&!orgEl.value)orgEl.value=state.config.orgRootId||'21';
if(!orgShareAutoLoaded&&orgEl&&orgEl.value){orgShareAutoLoaded=true;loadOrgShare();}
if(!coachAutoLoaded){coachAutoLoaded=true;loadCoaching();}
if(!msgsAutoLoaded){msgsAutoLoaded=true;loadAdminMsgs();}
const efi=document.getElementById('emailFromInput');if(efi&&!efi.value)efi.value=state.config.emailFrom||em.from||'';
const f=document.getElementById('configForm'),c=state.config;for(const k of ['siteName','programName','bridgeHeadline','bridgeSubheadline','premiumEntryPol','dappReferralBaseUrl','telegramUrl','supportLabel','bemobPostbackUrl','telegramBotToken','telegramChatId','telegramTopicId','teamRootId','teamAlertEmail','ownerAlertEmail'])if(f.elements[k])f.elements[k].value=c[k]??'';f.elements.showSponsorName.checked=!!c.showSponsorName;f.elements.showQueueProgress.checked=!!c.showQueueProgress;
}
document.getElementById('loginForm').addEventListener('submit',async e=>{e.preventDefault();const err=document.getElementById('loginError');err.textContent='';try{await api('/api/admin/login',{method:'POST',body:JSON.stringify({password:document.getElementById('password').value})});document.getElementById('password').value='';await loadState()}catch(x){err.textContent=x.message}});
document.getElementById('logoutBtn').addEventListener('click',async()=>{await api('/api/admin/logout',{method:'POST'});location.reload()});
document.getElementById('addSponsorForm').addEventListener('submit',async e=>{e.preventDefault();const form=e.currentTarget,obj=Object.fromEntries(new FormData(form));try{const d=await api('/api/admin/sponsors',{method:'POST',body:JSON.stringify(obj)});state.sponsors=d.sponsors;form.reset();render();showToast('Sponsor added')}catch(x){showToast(x.message)}});
document.getElementById('configForm').addEventListener('submit',async e=>{e.preventDefault();const f=e.currentTarget,obj=Object.fromEntries(new FormData(f));obj.showSponsorName=f.elements.showSponsorName.checked;obj.showQueueProgress=f.elements.showQueueProgress.checked;obj.premiumEntryPol=Number(obj.premiumEntryPol);try{const d=await api('/api/admin/config',{method:'PATCH',body:JSON.stringify(obj)});state.config=d.config;render();showToast('Settings saved')}catch(x){showToast(x.message)}});
rows.addEventListener('click',async e=>{const b=e.target.closest('button[data-action]');if(!b)return;const id=b.dataset.id,a=b.dataset.action;try{let d;if(a==='rename'){const cur=(state.sponsors.find(s=>s.id===id)||{}).name||'';const nm=prompt(`Name for sponsor ID ${id}:`,cur);if(nm===null||!nm.trim())return;d=await api(`/api/admin/sponsors/${id}`,{method:'PATCH',body:JSON.stringify({name:nm.trim()})});}else if(a==='email'){const cur=(state.sponsors.find(s=>s.id===id)||{}).email||'';const em=prompt(`Contact email for sponsor ID ${id} (leave empty to clear):`,cur);if(em===null)return;d=await api(`/api/admin/sponsors/${id}`,{method:'PATCH',body:JSON.stringify({email:em.trim()})});}else if(a==='delete'){if(!confirm(`Delete sponsor ID ${id}?`))return;d=await api(`/api/admin/sponsors/${id}`,{method:'DELETE'})}else if(a==='inc')d=await api(`/api/admin/sponsors/${id}/increment`,{method:'POST'});else if(a==='qualify')d=await api(`/api/admin/sponsors/${id}/qualify`,{method:'POST'});else if(a==='activate')d=await api(`/api/admin/sponsors/${id}/activate`,{method:'POST'});else if(a==='reset')d=await api(`/api/admin/sponsors/${id}/reset`,{method:'POST'});else if(a==='up'||a==='down')d=await api(`/api/admin/sponsors/${id}/move`,{method:'POST',body:JSON.stringify({direction:a})});if(d&&d.sponsors){state.sponsors=d.sponsors;render();showToast('Updated')}}catch(x){showToast(x.message)}});
document.getElementById('aiKeyForm').addEventListener('submit',async e=>{e.preventDefault();const inp=e.currentTarget.elements.key,key=inp.value.trim();if(!key){showToast('Paste a key first');return}try{const d=await api('/api/admin/openrouter-key',{method:'POST',body:JSON.stringify({key})});state.aiChat={...(state.aiChat||{}),configured:d.configured};inp.value='';render();showToast('AI chat enabled')}catch(x){showToast(x.message)}});
document.getElementById('aiKeyClear').addEventListener('click',async()=>{if(!confirm('Turn off AI chat and go back to built-in answers?'))return;try{const d=await api('/api/admin/openrouter-key',{method:'POST',body:JSON.stringify({key:''})});state.aiChat={...(state.aiChat||{}),configured:d.configured};render();showToast('AI chat disabled')}catch(x){showToast(x.message)}});
rows.addEventListener('change',async e=>{const sel=e.target.closest('select[data-action="level"]');if(!sel)return;try{const d=await api(`/api/admin/sponsors/${sel.dataset.id}`,{method:'PATCH',body:JSON.stringify({level:sel.value})});state.sponsors=d.sponsors;render();showToast('Level updated')}catch(x){showToast(x.message);render()}});
document.getElementById('sgKeyForm').addEventListener('submit',async e=>{e.preventDefault();const inp=e.currentTarget.elements.key,key=inp.value.trim();if(!key){showToast('Paste a key first');return}try{const d=await api('/api/admin/sendgrid-key',{method:'POST',body:JSON.stringify({key})});state.email={...(state.email||{}),configured:d.configured};inp.value='';render();showToast('Payment emails enabled')}catch(x){showToast(x.message)}});
document.getElementById('sgKeyClear').addEventListener('click',async()=>{if(!confirm('Disable "you\'ve been paid" emails?'))return;try{const d=await api('/api/admin/sendgrid-key',{method:'POST',body:JSON.stringify({key:''})});state.email={...(state.email||{}),configured:d.configured};render();showToast('Payment emails disabled')}catch(x){showToast(x.message)}});
document.getElementById('emailFromSave').addEventListener('click',async()=>{const v=document.getElementById('emailFromInput').value.trim();try{const d=await api('/api/admin/config',{method:'PATCH',body:JSON.stringify({emailFrom:v})});state.config=d.config;showToast('From address saved')}catch(x){showToast(x.message)}});
let orgShareAutoLoaded=false;
async function loadOrgShare(){
const root=(document.getElementById('orgRoot').value||'').trim();
const out=document.getElementById('orgShare');
if(!root||!/^\d+$/.test(root)){out.innerHTML='<div class="empty">Enter a numeric top ID (e.g. 21).</div>';return}
out.innerHTML='<div class="empty">Reading the blockchain…</div>';
try{
try{const c=await api('/api/admin/config',{method:'PATCH',body:JSON.stringify({orgRootId:root})});state.config=c.config;}catch(_){}
const d=await api('/api/admin/org-share?root='+encodeURIComponent(root));
if(!d.ready){out.innerHTML='<div class="empty">Snapshot not ready yet — try again in a minute.</div>';return}
if(!d.found){out.innerHTML=`<div class="empty" style="color:var(--danger)">ID ${esc(root)} isn't in the indexed matrix yet.</div>`;return}
const fmt=n=>Number(n).toLocaleString(undefined,{maximumFractionDigits:0});
out.innerHTML=
`<div style="display:flex;gap:20px;flex-wrap:wrap;align-items:center;margin-bottom:14px">`+
`<div style="text-align:center"><div style="font-size:44px;font-weight:900;color:var(--gold);line-height:1">${d.memberPct}%</div><div class="micro" style="margin-top:2px">of all members</div></div>`+
`<div style="text-align:center"><div style="font-size:44px;font-weight:900;color:var(--ok);line-height:1">${d.polPct}%</div><div class="micro" style="margin-top:2px">of all POL paid</div></div>`+
`</div>`+
`<div class="progress-line" style="height:16px;margin:0 0 6px"><span style="width:${Math.min(100,d.memberPct)}%"></span></div>`+
`<p style="color:var(--muted);margin:0 0 14px;font-size:14px">Your organization rooted at <strong class="mt-id">#${d.rootId}</strong> is <strong style="color:var(--text)">${d.orgMembers} of ${d.totalMembers}</strong> members in the whole contract (${d.orgBelow} below you + your position), and has earned <strong style="color:var(--text)">${fmt(d.orgPol)} of ${fmt(d.totalPol)} POL</strong> ever paid.</p>`+
`<div class="facts" style="grid-template-columns:repeat(4,1fr)"><div class="fact"><small>Your org members</small><strong>${d.orgMembers}</strong></div><div class="fact"><small>Whole network</small><strong>${d.totalMembers}</strong></div><div class="fact"><small>Your org earned</small><strong>${fmt(d.orgPol)} POL</strong></div><div class="fact"><small>Network paid</small><strong>${fmt(d.totalPol)} POL</strong></div></div>`+
genBreakdownHtml(d.genCounts,d.rootId);
}catch(x){out.innerHTML=`<div class="empty" style="color:var(--danger)">${esc(x.message)}</div>`}
}
function genBreakdownHtml(gc,rootId){
if(!gc||!gc.length)return '';
const rows=gc.map((c,i)=>{
const cap=Math.pow(2,i+1), pctFill=Math.min(100,Math.round(100*c/cap));
const capLabel=cap<=1024?` of ${cap} slots`:'';
return `<div style="display:flex;align-items:center;gap:10px;margin:4px 0;font-size:13px"><span style="width:52px;color:var(--muted)">Gen ${i+1}</span><div class="progress-line" style="flex:1;height:10px"><span style="width:${pctFill}%"></span></div><span style="width:120px;text-align:right"><strong>${c}</strong><span style="color:var(--muted)">${capLabel}</span></span></div>`;
}).join('');
return `<div style="margin-top:16px"><small style="text-transform:uppercase;letter-spacing:.08em;color:var(--muted);font-size:11px">Depth — members per generation below #${rootId}</small><div style="margin-top:8px">${rows}</div></div>`;
}
document.getElementById('orgShareForm').addEventListener('submit',e=>{e.preventDefault();loadOrgShare()});
async function loadCoaching(){
const out=document.getElementById('coachOut');if(!out)return;
const fmt=n=>Number(n).toLocaleString(undefined,{maximumFractionDigits:0});
out.innerHTML='<div class="empty">Scanning the org…</div>';
try{
const root=(document.getElementById('orgRoot')&&document.getElementById('orgRoot').value.trim())||(state&&state.config&&state.config.orgRootId)||'21';
const d=await api('/api/admin/coaching?root='+encodeURIComponent(root));
if(!d.ready){out.innerHTML='<div class="empty">Chain snapshot not ready — try again in a minute.</div>';return}
const nm=r=>r.name?`${esc(r.name)} (#${r.id})`:`#${r.id}`;
const link=r=>`<a href="/my/${r.id}" target="_blank" rel="noopener" style="color:var(--teal)">${nm(r)}</a>`;
const mineRisk=d.atRisk.filter(r=>r.owned),mineRoll=d.rollForward.filter(r=>r.owned);
d.atRisk=d.atRisk.filter(r=>!r.owned);d.rollForward=d.rollForward.filter(r=>!r.owned);d.oneAway=d.oneAway.filter(r=>!r.owned);
const mineHtml=(mineRisk.length||mineRoll.length)?
`<h3 style="margin:12px 0 6px;font-size:14px">👑 Your positions — your own action items</h3>`+
mineRisk.map(r=>`<div class="pp-row" style="padding:8px 12px;border-color:rgba(240,197,109,.4)"><div class="pp-icon">👑</div><div class="pp-body">${link(r)} — ${esc(r.levelName)}, <strong style="color:var(--gold)">${fmt(r.atRiskPol)} POL</strong> forming; needs <strong>${esc(r.neededLevelName||'qualification')}</strong> to catch it${r.earnedPol>=(r.upgradeCost||0)&&r.upgradeCost?' — <span style="color:var(--ok)">next step covered by its earnings</span>':r.upgradeCost?` (earned ${fmt(r.earnedPol)} vs ${fmt(r.upgradeCost)} next step)`:''}.</div></div>`).join('')+
mineRoll.map(r=>`<div class="pp-row" style="padding:8px 12px;border-color:rgba(240,197,109,.4)"><div class="pp-icon">👑</div><div class="pp-body">${link(r)} — qualified at Scintilla, Ascensus (${fmt(r.ascensusCost)} POL) covered by its ${fmt(r.earnedPol)} POL earned, whenever you choose.</div></div>`).join('')
:'';
const t1=d.rollForward.map(r=>`<div class="pp-row" style="padding:8px 12px"><div class="pp-icon">💬</div><div class="pp-body">${link(r)} — qualified, still Scintilla, sitting on <strong>${fmt(r.earnedPol)} POL</strong> (Ascensus costs ${fmt(r.ascensusCost)}). One message: “your entry rewards already cover the upgrade that catches your team's first payments.”</div></div>`).join('')||'<div class="empty">Nobody stuck at qualified-Scintilla. 🎉</div>';
const t2=d.atRisk.filter(r=>r.qualified&&r.level>1).map(r=>`<div class="pp-row" style="padding:8px 12px"><div class="pp-icon">⚠️</div><div class="pp-body">${link(r)} — ${esc(r.levelName)}, needs <strong>${esc(r.neededLevelName)}</strong> to catch <strong style="color:var(--gold)">${fmt(r.atRiskPol)} POL</strong> forming under them (from ${r.fromIds.map(f=>'#'+f).join(', ')}).</div></div>`).join('')||'<div class="empty">Everyone leveled is ahead of their wave. 🎉</div>';
const t3=d.oneAway.map(r=>`<div class="pp-row" style="padding:8px 12px"><div class="pp-icon">🎯</div><div class="pp-body">${link(r)} — 1/2 directs, one placement from qualifying.</div></div>`).join('')||'<div class="empty">Nobody stranded at 1/2.</div>';
const unq=d.atRisk.filter(r=>!r.qualified).map(r=>`<div class="pp-row" style="padding:8px 12px"><div class="pp-icon">⏰</div><div class="pp-body">${link(r)} — NOT qualified (${r.directCount}/2) with <strong style="color:var(--gold)">${fmt(r.atRiskPol)} POL</strong> already forming below. Qualification is urgent for them.</div></div>`).join('');
out.innerHTML=
`<div class="facts" style="grid-template-columns:repeat(4,1fr);margin-bottom:14px"><div class="fact"><small>Members scanned</small><strong>${d.scanned}</strong></div><div class="fact"><small>POL at risk</small><strong style="color:var(--gold)">${fmt(d.totals.atRiskPol)}</strong></div><div class="fact"><small>Easy wins</small><strong>${d.totals.rollForwardCount}</strong></div><div class="fact"><small>One direct away</small><strong>${d.totals.oneAwayCount}</strong></div></div>`+
mineHtml+
`<h3 style="margin:12px 0 6px;font-size:14px">Tier 1 · Easy wins — one message each</h3>${t1}`+
`<h3 style="margin:16px 0 6px;font-size:14px">Tier 2 · Money at risk — coach the upgrade</h3>${t2}`+
(unq?`<h3 style="margin:16px 0 6px;font-size:14px">Tier 2b · Money forming, not yet qualified</h3>${unq}`:'')+
`<h3 style="margin:16px 0 6px;font-size:14px">Tier 3 · One placement from qualified</h3>${t3}`+
`<p class="micro" style="margin:14px 0 0;padding-top:10px;border-top:1px solid var(--line)"><strong>Teach-forward rule:</strong> every nudge should end with "now open YOUR dashboard's Coach Your Team panel and run this same play for your two." You're not coaching members — you're training coaches.</p>`;
}catch(x){out.innerHTML=`<div class="empty" style="color:var(--danger)">${esc(x.message)}</div>`}
}
const cr=document.getElementById('coachRefresh');if(cr)cr.addEventListener('click',loadCoaching);
let coachAutoLoaded=false;
async function loadAdminMsgs(){
const tb=document.getElementById('msgRows');if(!tb)return;
try{
const d=await api('/api/admin/messages');
tb.innerHTML=(d.messages||[]).map(m=>`<tr><td>${esc(new Date(m.ts).toLocaleString())}</td><td><strong>#${m.fromId}</strong></td><td>${m.org?'📣 whole team':'#'+m.toId}</td><td style="white-space:pre-wrap;max-width:460px">${esc(m.body)}</td><td>${m.readCount}</td></tr>`).join('')||'<tr><td colspan="5" class="empty">No messages yet.</td></tr>';
}catch(x){tb.innerHTML=`<tr><td colspan="5" class="empty">${esc(x.message)}</td></tr>`}
}
const mr=document.getElementById('msgRefresh');if(mr)mr.addEventListener('click',loadAdminMsgs);
let msgsAutoLoaded=false;
let incomeAutoLoaded=false;
async function loadIncome(){
const ids=(document.getElementById('incomeIds').value||'').trim();
const out=document.getElementById('incomeResult'),sum=document.getElementById('incomeSummary');
if(!ids||!/^[\d,\s]+$/.test(ids)){out.innerHTML='<div class="empty">Enter comma-separated numeric IDs (e.g. 21,24,25).</div>';return}
out.innerHTML='<div class="empty">Reading the blockchain…</div>';sum.innerHTML='';
try{
try{const c=await api('/api/admin/config',{method:'PATCH',body:JSON.stringify({ownerIds:ids})});state.config=c.config;}catch(_){}
const d=await api('/api/admin/income?ids='+encodeURIComponent(ids));
const fmt=n=>Number(n).toLocaleString(undefined,{maximumFractionDigits:2});
const date=ts=>ts?new Date(ts*1000).toISOString().replace('T',' ').slice(0,16):'—';
const needs=d.upgradeNeeds||[];
document.getElementById('incomeAlert').innerHTML=needs.length?needs.map(n=>`<div class="callout warning" style="margin-bottom:10px">⏫ <strong>Upgrade #${n.id} to ${esc(n.neededLevelName)}${n.reason==='qualify'?' (and get 2 directs)':''}</strong> — ${n.members.map(m=>'#'+m).join(', ')} ${n.members.length===1?'is':'are'} one upgrade from paying you ~${fmt(n.amountAtRisk)} POL, but #${n.id} (${esc(n.levelName)}) can't catch it yet. Upgrade before they do or it passes up.</div>`).join(''):'';
const rt=d.routing;
document.getElementById('incomeRouting').innerHTML=(rt&&rt.ready)?(
`<div class="facts" style="grid-template-columns:repeat(4,1fr);margin-bottom:8px">`+
`<div class="fact" style="border-color:rgba(123,224,161,.4)"><small>Your positions catch</small><strong style="color:var(--ok)">${fmt(rt.ownedPol)} POL</strong></div>`+
`<div class="fact"><small>Team (in-org) catches</small><strong style="color:var(--teal)">${fmt(rt.teamPol)} POL</strong><div class="micro" style="margin-top:2px">healthy</div></div>`+
`<div class="fact" style="border-color:${rt.leakPol>0?'rgba(255,126,107,.5)':'rgba(255,255,255,.12)'}"><small>Leaking above #${rt.rootId}</small><strong style="color:${rt.leakPol>0?'#ff7e6b':'var(--muted)'}">${fmt(rt.leakPol)} POL</strong></div>`+
`<div class="fact"><small>To fees</small><strong>${fmt(rt.feePol)} POL</strong></div>`+
`</div>`+
`<p class="micro" style="margin:0 0 8px">Next-upgrade routing for ${rt.members} members below #${rt.rootId} — where each imminent upgrade payment lands. "Team" = teammates earning (good). "Leaking" = escaping to outsiders above you (often structural, not fixable by upgrading).</p>`+
(rt.leaks&&rt.leaks.length?
`<details><summary style="cursor:pointer;color:#ff7e6b;font-weight:700">⚠ ${rt.leaks.length} payment${rt.leaks.length===1?'':'s'} escaping your org — show</summary><div class="table-wrap" style="margin-top:8px"><table class="table" style="min-width:420px"><thead><tr><th>Member's next upgrade</th><th>Escapes to (outside)</th><th>Amount</th></tr></thead><tbody>`+
rt.leaks.map(l=>`<tr><td>#${l.from}</td><td><strong style="color:#ff7e6b">#${l.to}</strong> (L${l.toLevel})</td><td>${fmt(l.amt)} POL</td></tr>`).join('')+
`</tbody></table></div></details>`
:`<div class="callout" style="border-color:rgba(123,224,161,.3)">✓ Nothing is escaping your org right now — every next-upgrade payment stays with you or your team.</div>`)
):'';
sum.innerHTML=`<div class="fact" style="border-color:rgba(123,224,161,.4)"><small>Total received (all)</small><strong style="color:var(--ok);font-size:18px">${fmt(d.grandEarnedPol)} POL</strong></div>`+
d.ids.map(id=>{const p=d.perId[id]||{};return `<div class="fact"><small>#${id}${p.registered&&p.levelName?' · '+esc(p.levelName):''}</small><strong>${p.registered?fmt(p.totalEarnedPol)+' POL':'<span style=\"color:var(--danger)\">not registered</span>'}</strong>${p.registered?`<div class="micro" style="margin-top:2px">${p.count} payment${p.count===1?'':'s'}</div>`:''}</div>`}).join('');
out.innerHTML=d.rows.length?`<div class="table-wrap"><table class="table" style="min-width:640px"><thead><tr><th>When (UTC)</th><th>To</th><th>From</th><th>For</th><th>Amount</th></tr></thead><tbody>${d.rows.map(r=>`<tr><td>${date(r.ts)}</td><td><strong class="mt-id">#${r.toId}</strong></td><td>#${r.fromId}</td><td>${esc(r.desc||'')}</td><td><strong>${fmt(r.pol)} POL</strong></td></tr>`).join('')}</tbody></table></div><p class="micro" style="margin:8px 0 0">${d.rows.length} payment${d.rows.length===1?'':'s'} across your positions, newest first.</p>`:'<div class="empty">No payments recorded to these positions yet.</div>';
}catch(x){out.innerHTML=`<div class="empty" style="color:var(--danger)">${esc(x.message)}</div>`}
}
document.getElementById('incomeForm').addEventListener('submit',e=>{e.preventDefault();loadIncome()});
document.getElementById('lookupForm').addEventListener('submit',async e=>{
e.preventDefault();
const id=document.getElementById('lookupId').value.trim(),out=document.getElementById('lookupResult');
if(!/^\d+$/.test(id)){out.innerHTML='<div class="empty">Enter a numeric ID.</div>';return}
out.innerHTML='<div class="empty">Reading the blockchain…</div>';
try{
const d=await api('/api/admin/member-lookup?id='+id);
if(!d.registered){out.innerHTML=`<div class="empty" style="color:var(--danger)">ID ${esc(id)} is NOT registered on the contract.</div>`;return}
const fmt=n=>Number(n).toLocaleString(undefined,{maximumFractionDigits:2});
const date=ts=>ts?new Date(ts*1000).toISOString().replace('T',' ').slice(0,16):'—';
const facts=`<div class="facts" style="grid-template-columns:repeat(3,1fr)">`+
`<div class="fact"><small>Wallet</small><strong style="font-size:12px"><a style="color:var(--teal)" target="_blank" rel="noopener noreferrer" href="https://polygonscan.com/address/${esc(d.account)}">${esc(d.account.slice(0,10))}…${esc(d.account.slice(-6))} ↗</a></strong></div>`+
`<div class="fact"><small>Joined (UTC)</small><strong>${date(d.joinedAt)}</strong></div>`+
`<div class="fact"><small>Tier · Level</small><strong>${esc(d.tierName)} · ${esc(d.levelName)}</strong></div>`+
`<div class="fact"><small>Referred by</small><strong>${d.referrerId?'#'+d.referrerId:'—'}</strong></div>`+
`<div class="fact"><small>Matrix upline</small><strong>${d.uplineId?'#'+d.uplineId:'— (root)'}</strong></div>`+
`<div class="fact"><small>Matrix children</small><strong>${d.matrix?`${d.matrix.left?'#'+d.matrix.left:'open'} · ${d.matrix.right?'#'+d.matrix.right:'open'}`:'—'}</strong></div>`+
`<div class="fact"><small>Directs (${d.directCount})</small><strong style="font-size:13px">${d.directs&&d.directs.length?d.directs.map(x=>'#'+x).join(', '):'none yet'}</strong></div>`+
`<div class="fact"><small>Total earned</small><strong style="color:var(--ok)">${fmt(d.totalEarnedPol)} POL</strong></div>`+
`<div class="fact"><small>Total paid in</small><strong>${fmt(d.totalPaidPol)} POL</strong></div></div>`;
const chain=d.uplineChain&&d.uplineChain.length?`<p style="font-size:13px;color:var(--muted);margin:12px 0 0;line-height:1.7"><strong style="color:var(--text)">Lineage up to root:</strong> #${d.id} → ${d.uplineChain.map(u=>`#${u.id} <span style="color:#8498aa">(${esc(u.levelName)}, ${u.directCount} directs)</span>`).join(' → ')}</p>`:'';
const income=d.income&&d.income.length?`<div class="table-wrap" style="margin-top:8px"><table class="table" style="min-width:560px"><thead><tr><th>When (UTC)</th><th>From member</th><th>For</th><th>Tier</th><th>Amount</th></tr></thead><tbody>${d.income.map(p=>`<tr><td>${date(p.ts)}</td><td>#${p.fromId}</td><td>${esc(p.desc||p.levelName)}</td><td>${p.fromTier===2?'Premium':'Standard'}</td><td><strong>${fmt(p.pol)} POL</strong></td></tr>`).join('')}</tbody></table></div>`:'<div class="empty" style="margin-top:8px">No payments received yet.</div>';
out.innerHTML=facts+chain+`<p style="font-size:12px;color:#8498aa;margin:14px 0 0">Payments received by #${d.id} — ${d.income?d.income.length:0} total, newest first:</p>`+income;
}catch(x){out.innerHTML=`<div class="empty" style="color:var(--danger)">${esc(x.message)}</div>`}
});
const matrixUI={data:null,byId:{},parent:{},rootId:1,mode:'pyramid'};
function mtIndex(n,par){if(!n)return;matrixUI.byId[n.id]=n;if(par)matrixUI.parent[n.id]=par.id;mtIndex(n.left,n);mtIndex(n.right,n)}
function mtBadge(n){return n.tier===2?'<span class="mt-badge mt-prem">P</span>':'<span class="mt-badge">S</span>'}
const mtFmt=n=>Number(n||0).toLocaleString(undefined,{maximumFractionDigits:0});
function mtCard(n){
if(!n)return '<div class="mtp-card mtp-open">open<br>slot</div>';
const down=n.downCount?`<span class="mtp-down">⬇ ${n.downCount} below · ${mtFmt(n.downPol)} POL</span>`:'<span class="mtp-down mtp-down-none">no downline yet</span>';
const qmark=n.directCount>=2?'<span class="mtp-qmark" title="Qualified — 2/2 directs">✓</span>':'';
const par=matrixUI.parent[n.id];
const spill=par&&n.referrerId&&n.referrerId!==par?`<span class="mtp-spill" title="Placed here by spillover — referred by #${n.referrerId}">↧ spillover · ref #${n.referrerId}</span>`:'';
return `<button class="mtp-card${n.id===matrixUI.rootId?' mtp-focus':''}" data-mid="${n.id}">${qmark}${mtBadge(n)} <span class="mt-id">#${n.id}</span><span class="mtp-lvl">${esc(n.levelName)}</span><span class="mtp-sub">${n.directCount}/2 · ${mtFmt(n.earnedPol)} POL</span>${spill}${down}</button>`;
}
function renderPyramid(){
const out=document.getElementById('matrixTree'),nav=document.getElementById('matrixNav');
const root=matrixUI.byId[matrixUI.rootId];
if(!root){out.innerHTML='<div class="empty">Position not found.</div>';return}
const GEN=4; // root + 3 generations = up to 15 cards
const rows=[[root]];
for(let g=1;g<GEN;g++){
rows.push(rows[g-1].flatMap(n=>n?[n.left||null,n.right||null]:[null,null]));
}
out.innerHTML=`<div class="mtp">${rows.map(r=>`<div class="mtp-row">${r.map(mtCard).join('')}</div>`).join('')}</div>`;
// breadcrumb: path from tree root down to current focus
const path=[];let cur=matrixUI.rootId;
while(cur){path.unshift(cur);cur=matrixUI.parent[cur]}
nav.classList.remove('hidden');
nav.innerHTML=`<span class="micro" style="margin:0">Viewing:</span> `+path.map((id,i)=>i===path.length-1?`<strong class="mt-id">#${id}</strong>`:`<button class="btn btn-secondary btn-sm" data-mid="${id}">#${id}</button>`).join(' <span style="color:var(--muted)">›</span> ')+
(matrixUI.parent[matrixUI.rootId]?` <button class="btn btn-secondary btn-sm" data-mid="${matrixUI.parent[matrixUI.rootId]}">↑ Up one</button>`:'');
out.querySelectorAll('[data-mid]').forEach(b=>b.addEventListener('click',()=>{matrixUI.rootId=Number(b.dataset.mid);renderPyramid()}));
nav.querySelectorAll('[data-mid]').forEach(b=>b.addEventListener('click',()=>{matrixUI.rootId=Number(b.dataset.mid);renderPyramid()}));
}
function renderList(){
const out=document.getElementById('matrixTree'),d=matrixUI.data;
document.getElementById('matrixNav').classList.add('hidden');
const node=(n,depth)=>{
if(!n)return '';
if(n.missing)return `<li class="mt-node"><span class="mt-id">#${n.id}</span> <span style="color:var(--danger)">(no data)</span></li>`;
const ch=[n.left,n.right].filter(Boolean);
const kids=ch.length?`<ul class="mt-kids">${ch.map(c=>node(c,depth+1)).join('')}</ul>`:'';
const open=depth<4?' open':'';
const label=`${mtBadge(n)} <span class="mt-id">#${n.id}</span> <span class="mt-meta">${esc(n.levelName)} · ${n.directCount}/2 directs · ${mtFmt(n.earnedPol)} POL earned${n.downCount?` · ⬇ ${n.downCount} below (${mtFmt(n.downPol)} POL)`:''}${n.referrerId?` · ref #${n.referrerId}`:''}</span>`;
return ch.length?`<li class="mt-node"><details${open}><summary>${label}</summary>${kids}</details></li>`:`<li class="mt-node mt-leaf">${label}</li>`;
};
const unplaced=d.unplaced?`<p class="micro" style="color:var(--danger)">Not reachable from root: ${d.unplaced.map(i=>'#'+i).join(', ')}</p>`:'';
out.innerHTML=`<ul class="mt-tree">${node(d.root,0)}</ul>${unplaced}`;
}
function renderMatrix(){
const d=matrixUI.data;
const head=`${d.memberCount} positions · snapshot ${new Date(d.snapshotAt).toISOString().replace('T',' ').slice(0,16)} UTC · <span class="mtp-qmark" style="position:static;display:inline-grid;vertical-align:middle">✓</span> qualified (2/2 directs) · <span class="mt-badge mt-prem">P</span> Premium · <span class="mt-badge">S</span> Standard · ⬇ full downline below that position`;
document.getElementById('treeToggleBtn').classList.remove('hidden');
document.getElementById('treeToggleBtn').textContent=matrixUI.mode==='pyramid'?'List view':'Pyramid view';
if(matrixUI.mode==='pyramid')renderPyramid();else renderList();
const out=document.getElementById('matrixTree');
out.insertAdjacentHTML('afterbegin',`<p class="micro" style="margin:0 0 10px">${head}</p>`);
}
document.getElementById('treeLoadBtn').addEventListener('click',async()=>{
const out=document.getElementById('matrixTree');
out.innerHTML='<div class="empty">Building matrix from the on-chain snapshot…</div>';
try{
const d=await api('/api/admin/matrix-tree');
if(!d.ready||!d.root){out.innerHTML='<div class="empty">Snapshot not ready yet — try again in a minute.</div>';return}
matrixUI.data=d;matrixUI.byId={};matrixUI.parent={};matrixUI.rootId=d.root.id;
mtIndex(d.root,null);
renderMatrix();
}catch(x){out.innerHTML=`<div class="empty" style="color:var(--danger)">${esc(x.message)}</div>`}
});
document.getElementById('treeToggleBtn').addEventListener('click',()=>{
matrixUI.mode=matrixUI.mode==='pyramid'?'list':'pyramid';
if(matrixUI.data)renderMatrix();
});
loadState();