1e2b03c795
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>
136 lines
17 KiB
JavaScript
136 lines
17 KiB
JavaScript
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=>({'&':'&','<':'<','>':'>',"'":''','"':'"'}[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();
|