Add public member dashboard at /my/:id
Members enter their on-chain ID (or follow a /my/46-style link) and see their own position: tier/level/directs/earnings facts, their subtree pyramid with downline rollups and open slots, classified payment history, and lineage to root — all chain-derived data only, none of the admin operational config. Public endpoint /api/public/member is cached (120s) and rate-limited (20/min/IP). Linked from the public footers. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
(function(){
|
||||
const esc=s=>String(s??'').replace(/[&<>'"]/g,c=>({'&':'&','<':'<','>':'>',"'":''','"':'"'}[c]));
|
||||
const fmt=n=>Number(n||0).toLocaleString(undefined,{maximumFractionDigits:2});
|
||||
const date=ts=>ts?new Date(ts*1000).toLocaleDateString(undefined,{year:'numeric',month:'short',day:'numeric'}):'—';
|
||||
const prompt=document.getElementById('idPrompt'),dash=document.getElementById('dash');
|
||||
|
||||
function pathId(){const m=location.pathname.match(/^\/my\/(\d+)$/);if(m)return m[1];const q=new URLSearchParams(location.search).get('id');if(q&&/^\d+$/.test(q))return q;try{return localStorage.getItem('ctb.myId')||''}catch(e){return ''}}
|
||||
|
||||
function card(n,focus){
|
||||
if(!n)return '<div class="mtp-card mtp-open">open<br>slot</div>';
|
||||
const badge=n.tier===2?'<span class="mt-badge mt-prem">P</span>':'<span class="mt-badge">S</span>';
|
||||
const down=n.downCount?`<span class="mtp-down">⬇ ${n.downCount} below · ${fmt(n.downPol)} POL</span>`:'<span class="mtp-down mtp-down-none">no downline yet</span>';
|
||||
return `<div class="mtp-card${focus?' mtp-focus':''}" style="cursor:default">${badge} <span class="mt-id">#${n.id}</span><span class="mtp-lvl">${esc(n.levelName)}</span><span class="mtp-sub">${n.directCount}/2 · ${fmt(n.earnedPol)} POL</span>${down}</div>`;
|
||||
}
|
||||
function renderTree(root){
|
||||
if(!root){document.getElementById('dTree').innerHTML='<div class="empty">Team view is still indexing — check back in a few minutes.</div>';return}
|
||||
const rows=[[root]];
|
||||
for(let g=1;g<4;g++)rows.push(rows[g-1].flatMap(n=>n?[n.left||null,n.right||null]:[null,null]));
|
||||
if(!rows[3].some(Boolean))rows.pop();
|
||||
document.getElementById('dTree').innerHTML=`<div class="mtp">${rows.map((r,i)=>`<div class="mtp-row">${r.map(n=>card(n,i===0&&n&&true)).join('')}</div>`).join('')}</div>`;
|
||||
}
|
||||
function render(d){
|
||||
prompt.classList.add('hidden');dash.classList.remove('hidden');
|
||||
document.getElementById('dTitle').textContent='#'+d.id;
|
||||
document.getElementById('dFacts').innerHTML=
|
||||
`<div class="fact"><small>Tier</small><strong>${esc(d.tierName)}</strong></div>`+
|
||||
`<div class="fact"><small>Level</small><strong>${esc(d.levelName)}</strong></div>`+
|
||||
`<div class="fact"><small>Directs</small><strong>${d.directCount}/2${d.directCount>=2?' ✓ qualified':''}</strong></div>`+
|
||||
`<div class="fact"><small>Total received</small><strong style="color:var(--ok)">${fmt(d.totalEarnedPol)} POL</strong></div>`+
|
||||
`<div class="fact"><small>Team below you</small><strong>${d.subtree?`${d.subtree.downCount} member${d.subtree.downCount===1?'':'s'}`:'—'}</strong></div>`+
|
||||
`<div class="fact"><small>Member since</small><strong>${date(d.joinedAt)}</strong></div>`;
|
||||
renderTree(d.subtree);
|
||||
const inc=d.income||[];
|
||||
document.getElementById('dIncome').innerHTML=inc.length
|
||||
?`<div class="table-wrap"><table class="table" style="min-width:520px"><thead><tr><th>When</th><th>From member</th><th>For</th><th>Amount</th></tr></thead><tbody>${inc.map(p=>`<tr><td>${date(p.ts)}</td><td>#${p.fromId}</td><td>${esc(p.desc)}</td><td><strong>${fmt(p.pol)} POL</strong></td></tr>`).join('')}</tbody></table></div>`
|
||||
:'<div class="empty">No payments yet — they appear here the moment they land on-chain.</div>';
|
||||
document.getElementById('dLineage').innerHTML=d.uplineChain&&d.uplineChain.length
|
||||
?`<strong style="color:var(--text)">#${d.id}</strong> → ${d.uplineChain.map(i=>'#'+i).join(' → ')} <span style="color:#8498aa">(root)</span>`
|
||||
:'You are at the top of your line.';
|
||||
}
|
||||
async function load(id){
|
||||
try{
|
||||
const r=await fetch('/api/public/member?id='+id);
|
||||
const d=await r.json();
|
||||
if(!r.ok)throw new Error(d.error||'Lookup failed');
|
||||
if(!d.registered)throw new Error(`ID ${id} isn't registered on the smart contract — double-check the number.`);
|
||||
try{localStorage.setItem('ctb.myId',String(id))}catch(e){}
|
||||
if(!/^\/my\/\d+$/.test(location.pathname))history.replaceState(null,'','/my/'+id);
|
||||
render(d);
|
||||
}catch(x){
|
||||
prompt.classList.remove('hidden');dash.classList.add('hidden');
|
||||
document.getElementById('idError').textContent=x.message;
|
||||
}
|
||||
}
|
||||
document.getElementById('idForm').addEventListener('submit',e=>{
|
||||
e.preventDefault();
|
||||
const v=document.getElementById('memberId').value.trim();
|
||||
if(!/^\d{1,15}$/.test(v)){document.getElementById('idError').textContent='Numbers only — the ID from the RM Circle dApp.';return}
|
||||
load(v);
|
||||
});
|
||||
document.getElementById('switchId').addEventListener('click',()=>{
|
||||
try{localStorage.removeItem('ctb.myId')}catch(e){}
|
||||
history.replaceState(null,'','/my');
|
||||
dash.classList.add('hidden');prompt.classList.remove('hidden');
|
||||
document.getElementById('memberId').value='';
|
||||
});
|
||||
const id=pathId();
|
||||
if(id)load(id);
|
||||
})();
|
||||
Reference in New Issue
Block a user