Files
rm-circle-team-router/public/admin.js
T
martbost 350d50798f Member dashboard: drill-down tree, list view, legend, and strategy-aware referral tool
- "Your team" now covers the member's FULL leg with click-to-drill
  pyramid, breadcrumb, and a list-view toggle (mirrors the admin matrix).
- Legend under both the member tree and admin matrix explaining the
  qualified checkmark, tier badges, and downline rollup.
- "Share this position" card: while under 2/2 it's a personal recruiting
  tool — per-member QR (vendored qrcode-generator, CSP-safe), copy link,
  and a join CTA carrying the member's ID as sponsor. At 2/2 the page
  automatically retires the link and redirects sharing to the team
  rotator, so personal referrals can't undermine the moving-link
  strategy.

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

143 lines
20 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};
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;return{name,bridge:r.bridge||0,start:r.start||0,click:r.click||0,training:r.training||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.purchase?`<div class="fact"><small>Confirmed joins</small><strong>${tot.purchase}</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 oc=s.onchain?(s.onchain.registered?`<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.click}</td><td>${pct(r.click,r.start)}</td></tr>`).join('')||'<tr><td colspan="6" 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 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'])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)}});
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>':'';
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>${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();