Files
rm-circle-team-router/public/admin.js
T
martbost 1e2b03c795 Render admin matrix as a drill-down pyramid with open slots
Matrix View now defaults to a generation-by-generation pyramid (root + 3
generations of position cards, empty placements shown as dashed open
slots). Clicking a position re-roots the pyramid there; a breadcrumb
navigates back up. The collapsible list stays behind a toggle. Tree
payload now carries explicit left/right children so an only-child lands
on the correct side.

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

136 lines
17 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><strong>${esc(s.name)}</strong><div class="micro" style="margin:2px 0 0">ID ${esc(s.id)}</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="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 f=document.getElementById('configForm'),c=state.config;for(const k of ['siteName','programName','bridgeHeadline','bridgeSubheadline','premiumEntryPol','dappReferralBaseUrl','telegramUrl','supportLabel','bemobPostbackUrl','telegramBotToken','telegramChatId','telegramTopicId'])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==='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('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>At level</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.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>';
return `<button class="mtp-card${n.id===matrixUI.rootId?' mtp-focus':''}" data-mid="${n.id}">${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></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.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 · P = Premium, S = Standard`;
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();