Files
rm-circle-team-router/public/join.js
T
martbost 7e390aa48e Join: let chosen positions default to direct placement
The moving link is team-first by design: it routes a new join to whoever needs
directs next, so the position whose link was actually clicked earns no entry
reward. Measured on #21 that came to 80 joins in seven days and zero entry
rewards, while the traffic paying for those joins was the owner's.

For a position being deliberately built out, that default is backwards. Any ID
listed in the new directDefaultIds config now behaves as ?direct=1 on its own
/join/<id> link - including every promo tool, flyer QR and downline-builder
entry that resolves through it - without needing the parameter appended.

?direct=0 still forces rotation for a single share, so the team-first behaviour
stays available rather than being replaced.

Config rather than hard-coded, and editable from the admin settings form, so
which positions get this can change without a deploy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 10:29:55 -05:00

154 lines
11 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();
// Direct link (?direct=1): opt out of moving-link rotation — new joins land
// directly under THIS position (spillover in their team), instead of routing
// to the next-to-qualify member. Default (no flag) = the Team/moving link.
const _dq=new URLSearchParams(location.search).get('direct');
// ?direct=1 forces direct placement, ?direct=0 forces rotation. With
// neither, the position's own configured default decides (applied once
// the member data arrives, below).
let direct=_dq==='1';
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.
if(_dq!=='1'&&_dq!=='0'&&d.directDefault) direct=true;
const t=direct?{id:id,reason:'direct'}:(d.joinTarget||{id:d.id,referralUrl:d.referralUrl,reason:'self'});
joinTargetId=t.id;
const jb=document.getElementById('joinBtn');
jb.href='/join-now?ref='+id+(direct?'&direct=1':''); jb.removeAttribute('target'); // self-enroll page, placed in this leg
// the join CTA + "join under" header + dApp-sponsor mentions reflect the
// ACTUAL sponsor the entry lands on, not the inviter (only the top
// "invited by #X" line keeps the inviter's id)
document.getElementById('invIdBtn').textContent='#'+t.id;
document.getElementById('invIdJoin').textContent='#'+t.id;
document.querySelectorAll('.inv-id-inline').forEach(el=>el.textContent='#'+t.id);
const note=document.getElementById('invQualNote');
if(direct){
note.innerHTML=`<div class="callout" style="margin-top:14px">You're joining <strong>directly under Member #${esc(id)}</strong> — a spillover placement in their team. Enter sponsor <strong>#${esc(id)}</strong> in the dApp.</div>`;
}else 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();
})();
// Angle-matched landing: ?v=<hook> renders a NON-DISTRACTING squeeze step —
// matched headline, the hook video, one proceed button. Nothing else. Clicking
// through loads the full invite page (same path, no query); attribution
// (angle:<hook>) is already stamped in sessionStorage by track.js.
(function(){
var ANGLES={
pocket:{h:'You already <span class="gold">found the money</span>.',
v:'/v/rmc-pocket-change-b403fb2f75.mp4',p:'/v/rmc-pocket-change-poster.jpg'},
phone:{h:'Time your phone started <span class="gold">paying YOU</span>.',
v:'/v/rmc-phone-32ac648844.mp4',p:'/v/rmc-phone-poster.jpg'},
two:{h:'You know two people.<br><span class="gold">That’s all this takes.</span>',
v:'/v/rmc-two-people-4102f2d719.mp4',p:'/v/rmc-two-people-poster.jpg'},
graveyard:{h:'This one runs on <span class="gold">teamwork</span> — not your transmission.',
v:'/v/rmc-graveyard-290fabee9d.mp4',p:'/v/rmc-graveyard-poster.jpg'},
stopwaiting:{h:'Stop <span class="gold">waiting</span>. Start <span class="gold">receiving</span>.',
v:'/v/rmc-combined-16284edbab.mp4',p:'/v/rmc-combined-poster.jpg'}
};
var a=ANGLES[new URLSearchParams(location.search).get('v')];
if(!a)return;
var main=document.querySelector('main');
if(!main)return;
// clear the stage: hide everything, including the nav's competing buttons
Array.prototype.forEach.call(main.children,function(el){el.style.display='none';});
var nav=document.querySelector('.nav-actions'); if(nav)nav.style.display='none';
var s=document.createElement('section');
s.className='hero wrap';
s.innerHTML='<h1 style="margin-top:10px">'+a.h+'</h1>'+
'<div class="video-card" style="max-width:860px;margin:22px auto 0">'+
'<video id="hookPlayer" class="video-frame" style="display:block;width:100%;height:auto;aspect-ratio:16/9" controls preload="metadata" poster="'+a.p+'" src="'+a.v+'"></video></div>'+
'<div class="hero-actions" style="margin-top:26px"><a id="hookCta" class="btn btn-primary" style="font-size:19px;padding:16px 34px;font-weight:800" href="'+location.pathname+(new URLSearchParams(location.search).get('direct')==='1'?'?direct=1':'')+'">Show me how it works →</a></div>'+
'<div class="micro" style="margin-top:14px">Independent team training resource • Participation involves risk • No income is guaranteed</div>';
main.appendChild(s);
var hp=document.getElementById('hookPlayer'),cta=document.getElementById('hookCta');
if(hp&&cta)hp.addEventListener('ended',function(){
cta.style.boxShadow='0 0 0 4px rgba(243,190,67,.45)';
cta.scrollIntoView({behavior:'smooth',block:'center'});
});
})();