532cb18b68
Two leg-build members submitted the /start form thinking it was required, polluting rotation bookkeeping. Rather than two forms, the server now classifies every submission by on-chain truth: referrer == active rotation sponsor -> "rotation" (Telegram says +1 direct, add to queue); anyone else -> "leg" (correct sponsor credited, "no rotation action needed"). The member dashboard gains its own "Just joined under this position?" form so leg builders have a proper landing spot; /start feedback explains each path to the submitter; admin submissions table shows a rotation/leg chip. Verified against live data: ID 65 claiming sponsor 36 correctly classified leg under #62. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
175 lines
16 KiB
JavaScript
175 lines
16 KiB
JavaScript
(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 ''}}
|
||
|
||
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 isSpill(n){return !!(n&&treeUI.parent[n.id]&&n.referrerId&&n.referrerId!==treeUI.parent[n.id])}
|
||
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 spill=isSpill(n)?`<span class="mtp-spill" title="Placed here by spillover — referred by #${n.referrerId}">↧ spillover · ref #${n.referrerId}</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 `<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>${spill}${down}</button>`;
|
||
}
|
||
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();
|
||
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)`:''}${isSpill(n)?` · <span style="color:var(--teal)">↧ spillover (ref #${n.referrerId})</span>`:''}</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;
|
||
const qb=document.getElementById('dQualified');
|
||
if(qb)qb.outerHTML=d.directCount>=2
|
||
?'<span id="dQualified" class="q-badge q-yes">★ Qualified</span>'
|
||
:`<span id="dQualified" class="q-badge q-no">${2-d.directCount} more direct${2-d.directCount===1?'':'s'} to qualify</span>`;
|
||
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>`;
|
||
treeUI.byId={};treeUI.parent={};treeUI.homeId=d.id;treeUI.rootId=d.id;
|
||
indexTree(d.subtree,null);
|
||
renderTree();
|
||
// spillover explainer — shown exactly when the confusing state exists:
|
||
// positions sitting under you while you're not yet qualified
|
||
const note=document.getElementById('dSpillNote');
|
||
if(note){
|
||
const kids=d.subtree?[d.subtree.left,d.subtree.right].filter(Boolean):[];
|
||
const spills=kids.filter(k=>k.referrerId&&k.referrerId!==d.id).length;
|
||
if(d.directCount<2&&kids.length&&spills){
|
||
note.innerHTML=`<div class="callout" style="margin-top:14px"><strong>Why am I not qualified when people sit under me?</strong> ${spills===kids.length?'The positions':'Some positions'} under you arrived by <strong>spillover</strong> — when your upline recruits, the contract fills the next open slot downward, which can land members in your matrix. Spillover grows your team and your future level payments, but qualification only counts your <strong>directs</strong> — people who join using <em>your</em> ID. You have ${d.directCount}/2 directs; share your link above to get ${2-d.directCount===1?'your last one':'your 2'}.</div>`;
|
||
note.classList.remove('hidden');
|
||
}else{note.classList.add('hidden');note.innerHTML='';}
|
||
}
|
||
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){
|
||
const nx=d.nextInLine;
|
||
if(nx){
|
||
const nxDash=`https://rmcircle.saasy.top/my/${nx.id}`;
|
||
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> The team play now moves down. <strong style="color:var(--text)">Next in line: #${nx.id}</strong> (${nx.directCount}/2 directs). Help them get their 2 — share their page or send prospects the join button, which carries <strong style="color:var(--gold)">#${nx.id}</strong> as sponsor. (If someone still joins through your own link, that's a bonus — the entry reward is yours and they spill down your leg — but only #${nx.id}'s own directs can qualify them.)</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 #${nx.id}'s page link</button><a class="btn btn-primary btn-sm" href="${esc(nx.referralUrl||'#')}" target="_blank" rel="noopener noreferrer">Join under #${nx.id} →</a></div><p class="micro" style="margin:10px 0 0;word-break:break-all">${esc(nxDash)}</p><p class="micro" style="margin:6px 0 0">When #${nx.id} reaches 2/2 this rotates to the next position in your leg — the qualification wave keeps moving down. General traffic can still go through the <a href="/start" style="color:var(--teal)">team rotation</a>.</p></div></div>`;
|
||
const qr=document.getElementById('dQr');if(qr)qr.innerHTML=qrSvg(nxDash);
|
||
const cp=document.getElementById('copyShare');
|
||
if(cp)cp.addEventListener('click',async()=>{try{await navigator.clipboard.writeText(nxDash);cp.textContent='Copied ✓';setTimeout(()=>cp.textContent=`Copy #${nx.id}'s page link`,1400)}catch(e){}});
|
||
return;
|
||
}
|
||
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 and everyone in your leg is qualified too — outstanding. 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 from the team's focus and promotes the next position in your leg. Any late signup on your own link is a bonus — it pays you and spills down as depth.</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);
|
||
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('dJoinForm').addEventListener('submit',async e=>{
|
||
e.preventDefault();
|
||
const form=e.currentTarget,msg=document.getElementById('dJoinMsg');
|
||
const newId=form.elements.newId.value.trim(),memberName=form.elements.memberName.value.trim();
|
||
if(!/^\d{1,10}$/.test(newId)){msg.style.color='var(--danger)';msg.textContent='Numbers only — the ID shown by the RM Circle dApp.';return}
|
||
if(!memberName){msg.style.color='var(--danger)';msg.textContent='Add your name or Telegram handle.';return}
|
||
msg.style.color='var(--muted)';msg.textContent='Verifying on the blockchain…';
|
||
try{
|
||
const pageId=(location.pathname.match(/^\/my\/(\d+)$/)||[])[1]||'?';
|
||
const r=await fetch('/api/public/submit-id',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({newId,memberName,sponsorId:pageId,source:'dashboard-'+pageId,clickid:window.ctbGetClickId?window.ctbGetClickId():''})});
|
||
const d=await r.json();
|
||
if(!r.ok)throw new Error(d.error||'Submission failed');
|
||
form.classList.add('hidden');
|
||
msg.style.color='var(--ok)';
|
||
const dash=`<br><a href="/my/${newId}" style="color:var(--gold)">Open your own dashboard →</a>`;
|
||
if(d.duplicate)msg.innerHTML=`✓ ID <strong>${newId}</strong> was already submitted — you're on the list.${dash}`;
|
||
else if(d.onchain&&d.onchain.registered)msg.innerHTML=`✓ Verified on the blockchain! ID <strong>${newId}</strong> is registered under sponsor <strong>#${d.onchain.referrerId}</strong> (${d.onchain.tier}). The team has been notified — welcome aboard.${dash}`;
|
||
else if(d.path==='notfound'){msg.style.color='var(--danger)';msg.innerHTML=`⚠ ID <strong>${newId}</strong> isn't on the smart contract yet — double-check the number from the RM Circle dApp. Your submission is saved and the team will verify it manually.`;}
|
||
else msg.innerHTML=`✓ Got it — submitted. The team will verify your placement.${dash}`;
|
||
}catch(x){msg.style.color='var(--danger)';msg.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);
|
||
})();
|