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>
This commit is contained in:
martbost
2026-08-13 14:53:06 -05:00
parent 5a916bf12d
commit 350d50798f
7 changed files with 2369 additions and 9 deletions
+62 -5
View File
@@ -6,20 +6,54 @@
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 ''}}
const treeUI={byId:{},parent:{},homeId:0,rootId:0,mode:'pyramid'};
function indexTree(n,par){if(!n)return;treeUI.byId[n.id]=n;if(par)treeUI.parent[n.id]=par.id;indexTree(n.left,n);indexTree(n.right,n)}
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 qmark=n.directCount>=2?'<span class="mtp-qmark" title="Qualified — 2/2 directs">✓</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">${qmark}${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>`;
return `<button class="mtp-card${focus?' mtp-focus':''}" data-tid="${n.id}">${qmark}${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}</button>`;
}
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}
function renderPyramid(){
const out=document.getElementById('dTree'),nav=document.getElementById('dTreeNav');
const root=treeUI.byId[treeUI.rootId];
if(!root){out.innerHTML='<div class="empty">Team view is still indexing — check back in a few minutes.</div>';nav.innerHTML='';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>`;
out.innerHTML=`<div class="mtp">${rows.map((r,i)=>`<div class="mtp-row">${r.map(n=>card(n,i===0)).join('')}</div>`).join('')}</div>`;
const path=[];let cur=treeUI.rootId;
while(cur){path.unshift(cur);if(cur===treeUI.homeId)break;cur=treeUI.parent[cur]}
nav.innerHTML=path.length>1?`<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-tid="${id}">#${id}</button>`).join(' <span style="color:var(--muted)">›</span> ')+` <button class="btn btn-secondary btn-sm" data-tid="${treeUI.parent[treeUI.rootId]}">↑ Up one</button>`:'';
const wire=el=>el.querySelectorAll('[data-tid]').forEach(b=>b.addEventListener('click',()=>{const t=Number(b.dataset.tid);if(t&&treeUI.byId[t]){treeUI.rootId=t;renderTree()}}));
wire(out);wire(nav);
}
function renderList(){
const out=document.getElementById('dTree');
document.getElementById('dTreeNav').innerHTML='';
const home=treeUI.byId[treeUI.homeId];
if(!home){out.innerHTML='<div class="empty">Team view is still indexing — check back in a few minutes.</div>';return}
const node=(n,depth)=>{
if(!n)return '';
const ch=[n.left,n.right].filter(Boolean);
const qmark=n.directCount>=2?'<span class="mtp-qmark" style="position:static;display:inline-grid;width:16px;height:16px;font-size:10px;vertical-align:middle">✓</span> ':'';
const badge=n.tier===2?'<span class="mt-badge mt-prem">P</span>':'<span class="mt-badge">S</span>';
const label=`${qmark}${badge} <span class="mt-id">#${n.id}</span> <span class="mt-meta">${esc(n.levelName)} · ${n.directCount}/2 directs · ${fmt(n.earnedPol)} POL${n.downCount?` · ⬇ ${n.downCount} below (${fmt(n.downPol)} POL)`:''}</span>`;
const kids=ch.length?`<ul class="mt-kids">${ch.map(c=>node(c,depth+1)).join('')}</ul>`:'';
return ch.length?`<li class="mt-node"><details${depth<3?' open':''}><summary>${label}</summary>${kids}</details></li>`:`<li class="mt-node mt-leaf">${label}</li>`;
};
out.innerHTML=`<ul class="mt-tree">${node(home,0)}</ul>`;
}
function renderTree(){
const tb=document.getElementById('dTreeToggle');
if(tb)tb.textContent=treeUI.mode==='pyramid'?'List view':'Pyramid view';
if(treeUI.mode==='pyramid')renderPyramid();else renderList();
}
document.getElementById('dTreeToggle').addEventListener('click',()=>{
treeUI.mode=treeUI.mode==='pyramid'?'list':'pyramid';
renderTree();
});
function render(d){
prompt.classList.add('hidden');dash.classList.remove('hidden');
document.getElementById('dTitle').textContent='#'+d.id;
@@ -34,15 +68,38 @@
`<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);
treeUI.byId={};treeUI.parent={};treeUI.homeId=d.id;treeUI.rootId=d.id;
indexTree(d.subtree,null);
renderTree();
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>';
renderShare(d);
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.';
}
function qrSvg(url){
try{
const q=qrcode(0,'M');q.addData(url);q.make();
return q.createSvgTag({cellSize:4,margin:3,scalable:true});
}catch(e){return ''}
}
function renderShare(d){
const el=document.getElementById('dShare');
if(!el)return;
const dashUrl=`https://rmcircle.saasy.top/my/${d.id}`;
if(d.directCount>=2){
el.innerHTML=`<p style="color:var(--muted);font-size:14px;line-height:1.6;margin:6px 0 14px"><span class="q-badge q-yes" style="margin-right:6px">★ Qualified</span> This position has its 2 directs, so its referral link is retired under the team strategy. Send new members through the team rotation — the current sponsor is always live on the start page.</p><div style="display:flex;gap:16px;align-items:center;flex-wrap:wrap"><img src="/qr-start.svg" alt="Team QR code" class="qr-img"><div><a class="btn btn-primary" href="/start">Open the Team Sponsor Page →</a><p class="micro" style="margin:10px 0 0">This QR sends prospects to the team rotation — show it or print it.</p></div></div>`;
return;
}
const need=2-d.directCount;
el.innerHTML=`<p style="color:var(--muted);font-size:14px;line-height:1.6;margin:6px 0 14px">You need <strong style="color:var(--text)">${need} more direct${need===1?'':'s'}</strong> to qualify. Until then this is your personal recruiting page — share the link or let a prospect scan the code. They'll see this position's live results, and the join button below carries <strong style="color:var(--gold)">your ID #${d.id}</strong> as sponsor.</p><div style="display:flex;gap:16px;align-items:center;flex-wrap:wrap"><span id="dQr" class="qr-img"></span><div style="min-width:220px"><div style="display:flex;gap:8px;flex-wrap:wrap"><button id="copyShare" class="btn btn-secondary btn-sm">Copy my page link</button><a class="btn btn-primary btn-sm" href="${esc(d.referralUrl||'#')}" target="_blank" rel="noopener noreferrer">Join under #${d.id} →</a></div><p class="micro" style="margin:10px 0 0;word-break:break-all">${esc(dashUrl)}</p><p class="micro" style="margin:6px 0 0">When you reach 2/2 this page automatically retires your link and points new members to the team rotation.</p></div></div>`;
const qr=document.getElementById('dQr');if(qr)qr.innerHTML=qrSvg(dashUrl);
const cp=document.getElementById('copyShare');
if(cp)cp.addEventListener('click',async()=>{try{await navigator.clipboard.writeText(dashUrl);cp.textContent='Copied ✓';setTimeout(()=>cp.textContent='Copy my page link',1400)}catch(e){}});
}
async function load(id){
try{
const r=await fetch('/api/public/member?id='+id);