Files
rm-circle-team-router/public/join.js
T
martbost f0ea4469a4 Self-rotating personal links: qualified member's join routes to next-to-qualify
Fixes the spillover leak where a qualified member promoting their own link had
joins land back on themselves (bonus reward) instead of qualifying their team.

- /api/public/member now returns joinTarget: unqualified member -> self;
  qualified -> next-to-qualify in their leg (reuses rotation-aligned nextInLine);
  whole leg qualified -> global rotation sponsor; else -> spillover (prior behavior)
- /join page: the Join button, "join under" header, sponsor-ID copy, and the
  submitted sponsorId all follow joinTarget; a note explains "invited by #X,
  joining to help #Y qualify" so the new member knows their real sponsor
- Member's SHARE link is unchanged (/join/<id>); resolution happens when a
  visitor opens it, so it self-corrects even for links already in the wild

Dry-run verified: /join/27 -> #34, /join/34 -> #34 (self), /join/21 -> #34.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-16 13:02:34 -05:00

101 lines
7.0 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.
// Personal invite page (/join/<id>): the full bridge-page story, personalized
// to one sponsor. Pulls the sponsor's live position via /api/public/member,
// pins their ID on every CTA, and submits new IDs with sponsorId locked to
// the page. Prospects who land on a bad/unregistered ID get routed to /start
// (the team rotation) instead of a dead end.
(function(){
const esc=s=>String(s??'').replace(/[&<>'"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;',"'":'&#39;','"':'&quot;'}[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'}):'—';
function pathId(){const m=location.pathname.match(/^\/join\/(\d+)$/);return m?m[1]:''}
const id=pathId();
let joinTargetId=id; // where the entry actually lands after moving-link routing
function setIds(idStr){
document.getElementById('invId').textContent='#'+idStr;
document.getElementById('invIdJoin').textContent='#'+idStr;
document.getElementById('invIdBtn').textContent='#'+idStr;
document.querySelectorAll('.inv-id-inline').forEach(el=>el.textContent='#'+idStr);
}
function fallbackToRotation(msg){
const facts=document.getElementById('invFacts');
facts.innerHTML=`<div class="empty" style="grid-column:1/-1">${esc(msg)} <a href="/start" style="color:var(--teal)">Open the team rotation instead →</a></div>`;
const jb=document.getElementById('joinBtn');jb.textContent='Open the Team Sponsor Page →';jb.href='/start';jb.removeAttribute('target');
}
async function load(){
if(!id){fallbackToRotation('This invite link is missing its member ID.');return}
setIds(id);
try{
const r=await fetch('/api/public/member?id='+id);
const d=await r.json();
if(!r.ok||!d.registered)throw new Error('This invite ID isn’t registered on the smart contract.');
render(d);
}catch(x){fallbackToRotation(x.message)}
}
function render(d){
const q=document.getElementById('invQualified');
q.outerHTML=d.directCount>=2
?'<span id="invQualified" class="q-badge q-yes">★ Qualified</span>'
:`<span id="invQualified" class="q-badge q-no">Building — ${d.directCount}/2 directs</span>`;
document.getElementById('invFacts').innerHTML=
`<div class="fact"><small>Level</small><strong>${esc(d.levelName)}</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 them</small><strong>${d.subtree?`${d.subtree.downCount} member${d.subtree.downCount===1?'':'s'}`:'—'}</strong></div>`+
`<div class="fact"><small>Directs</small><strong>${d.directCount}/2</strong></div>`+
`<div class="fact"><small>Tier</small><strong>${esc(d.tierName)}</strong></div>`+
`<div class="fact"><small>Member since</small><strong>${date(d.joinedAt)}</strong></div>`;
// Qualified sponsor: still a valid join (doctrine: late signups pay them
// and spill down), but surface the team wave so nobody's surprised.
// Moving-link routing: the entry lands on whoever the smart link resolves to.
// A qualified inviter's link routes to the next-to-qualify in their leg, so
// the join builds the team down in order instead of spilling onto the inviter.
const t=d.joinTarget||{id:d.id,referralUrl:d.referralUrl,reason:'self'};
joinTargetId=t.id;
const jb=document.getElementById('joinBtn');
if(t.referralUrl)jb.href=t.referralUrl;
// the join CTA + "join under" header reflect the ACTUAL sponsor, not the inviter
document.getElementById('invIdBtn').textContent='#'+t.id;
document.getElementById('invIdJoin').textContent='#'+t.id;
const note=document.getElementById('invQualNote');
if(t.reason==='leg'||t.reason==='global'){
note.innerHTML=`<div class="callout" style="margin-top:14px">You were invited by <strong>Member #${esc(d.id)}</strong>, who's already <strong>qualified</strong>. So your entry goes to <strong>Member #${esc(t.id)}</strong> — the next spot in the team that needs its 2 — and you'll join directly under them. Same team, filling the next open position in order (no spillover). Enter sponsor <strong>#${esc(t.id)}</strong> in the dApp.</div>`;
}else if(d.directCount>=2){
note.innerHTML=`<div class="callout" style="margin-top:14px">This position is already <strong>qualified</strong> — joining here still works: your entry pays them and you're placed in their team leg as depth.</div>`;
}
const cp=document.getElementById('copySponsor');
cp.addEventListener('click',async()=>{try{await navigator.clipboard.writeText(String(joinTargetId));cp.textContent='Copied ✓';setTimeout(()=>cp.textContent='Copy Sponsor ID',1400)}catch(e){}});
}
document.getElementById('joinForm').addEventListener('submit',async e=>{
e.preventDefault();
const form=e.currentTarget,msg=document.getElementById('joinMsg');
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 r=await fetch('/api/public/submit-id',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({newId,memberName,sponsorId:joinTargetId||id||'?',source:'invite-'+(id||'?'),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>${esc(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>${esc(newId)}</strong> is registered under sponsor <strong>#${d.onchain.referrerId}</strong> (${esc(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>${esc(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}
});
// matrix dots (same as bridge)
for(const pid of ['p8','p16']){
const node=document.getElementById(pid);if(!node)continue;
const count=pid==='p8'?8:16;for(let i=0;i<count;i++){const s=document.createElement('span');s.className='person';node.appendChild(s)}
}
load();
})();