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>
This commit is contained in:
martbost
2026-08-31 10:29:55 -05:00
parent 34480d444e
commit 7e390aa48e
5 changed files with 25 additions and 5 deletions
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -46,7 +46,7 @@ function render(){
if(!grantAutoLoaded){grantAutoLoaded=true;if(window.agBoot)window.agBoot();}
if(!msgsAutoLoaded){msgsAutoLoaded=true;loadAdminMsgs();}
const efi=document.getElementById('emailFromInput');if(efi&&!efi.value)efi.value=state.config.emailFrom||em.from||'';
const f=document.getElementById('configForm'),c=state.config;for(const k of ['siteName','programName','bridgeHeadline','bridgeSubheadline','premiumEntryPol','dappReferralBaseUrl','telegramUrl','supportLabel','bemobPostbackUrl','telegramBotToken','telegramChatId','telegramTopicId','teamRootId','teamAlertEmail','ownerAlertEmail'])if(f.elements[k])f.elements[k].value=c[k]??'';f.elements.showSponsorName.checked=!!c.showSponsorName;f.elements.showQueueProgress.checked=!!c.showQueueProgress;
const f=document.getElementById('configForm'),c=state.config;for(const k of ['siteName','programName','bridgeHeadline','bridgeSubheadline','premiumEntryPol','dappReferralBaseUrl','telegramUrl','supportLabel','bemobPostbackUrl','telegramBotToken','telegramChatId','telegramTopicId','teamRootId','teamAlertEmail','ownerAlertEmail','directDefaultIds'])if(f.elements[k])f.elements[k].value=c[k]??'';f.elements.showSponsorName.checked=!!c.showSponsorName;f.elements.showQueueProgress.checked=!!c.showQueueProgress;
}
document.getElementById('loginForm').addEventListener('submit',async e=>{e.preventDefault();const err=document.getElementById('loginError');err.textContent='';try{await api('/api/admin/login',{method:'POST',body:JSON.stringify({password:document.getElementById('password').value})});document.getElementById('password').value='';await loadState()}catch(x){err.textContent=x.message}});
document.getElementById('logoutBtn').addEventListener('click',async()=>{await api('/api/admin/logout',{method:'POST'});location.reload()});
+6 -1
View File
@@ -61,7 +61,12 @@ fetch('/api/public/config').then(function(r){return r.json()}).then(function(c){
sponsorId=String(ref); $('sponsorLine').innerHTML='You’ll join <strong>directly under #'+ref+'</strong> — a spillover placement in their team.'; return;
}
if(ref&&/^\d{1,15}$/.test(ref)){
try{ var rm=await (await fetch('/api/public/member?id='+ref)).json(); var t=rm&&rm.joinTarget;
try{ var rm=await (await fetch('/api/public/member?id='+ref)).json();
// position configured to default to direct, not explicitly overridden
if(rm&&rm.directDefault&&qp.get('direct')!=='0'){
sponsorId=String(ref); $('sponsorLine').innerHTML='You’ll join <strong>directly under #'+ref+'</strong> — a spillover placement in their team.'; return;
}
var t=rm&&rm.joinTarget;
if(t&&t.id){ sponsorId=String(t.id); $('sponsorLine').innerHTML='You’ll join under <strong>#'+t.id+'</strong>'+(String(t.id)!==String(ref)?' — the next open spot in #'+ref+'’s team':'')+'.'; return; }
}catch(e){}
}
+6 -1
View File
@@ -13,7 +13,11 @@
// 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 direct=new URLSearchParams(location.search).get('direct')==='1';
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){
@@ -57,6 +61,7 @@
// 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');
+11 -1
View File
@@ -579,6 +579,16 @@ async function handleApi(req,res,pathname){
}
}
}
// Positions that default to DIRECT placement. A moving link is team-first:
// it routes new joins to whoever needs directs next, so the position whose
// link was actually clicked earns nothing. For a position being deliberately
// built out that is backwards - this flips the default so /join/<id> behaves
// as ?direct=1 unless ?direct=0 is passed explicitly.
try{
const dd=String((getConfig().directDefaultIds)||'').split(',').map(x=>x.trim()).filter(Boolean);
r.directDefault = dd.includes(String(id));
}catch(e){ r.directDefault = false; }
// Next-step plan + funded badge. Wallet balance is checked server-side and
// ONLY the boolean (covers next upgrade?) is exposed — never the raw amount.
if(r.registered){
@@ -1468,7 +1478,7 @@ async function handleApi(req,res,pathname){
const maxOrder=sponsors.reduce((m,s)=>Math.max(m,s.sortOrder||0),0);sponsors.push({id:String(id).trim(),name:String(name).trim(),parentId:String(parentId||'').trim(),directs:0,level,status:sponsors.some(s=>s.status==='active')?'waiting':'active',sortOrder:maxOrder+10,clicks:0,notes:String(notes||'').trim(),email:String(email||'').trim().slice(0,120)});sponsors=normalizeStatuses(sponsors);saveSponsors(sponsors);return json(res,201,{sponsors});
}
if(req.method==='PATCH'&&pathname==='/api/admin/config'){
const b=await bodyJson(req),cur=getConfig(),next={...cur};for(const k of ['siteName','programName','bridgeHeadline','bridgeSubheadline','premiumEntryPol','dappReferralBaseUrl','telegramUrl','supportLabel','showSponsorName','showQueueProgress','bemobPostbackUrl','telegramBotToken','companionBotToken','miniAppShortName','telegramChatId','telegramTopicId','telegramRecruitTopicId','teamRootId','emailFrom','teamAlertEmail','ownerIds','ownerAlertEmail','orgRootId','ctbOfferPostbackUrl','ctbOfferSecret','recruitCtaUrl','walletNotice','tweetEnabled','tweetCtaUrl','tweetHashtags','blotatoTwitterId','dappFallbackPublic','moonpayPublicKey','moonpaySecretKey','publicRotationMode','publicRotationRootId','rotationExcludeIds','suiteAllowlist','suiteToolsInAlerts','suiteLevelOverride'])if(Object.prototype.hasOwnProperty.call(b,k))next[k]=b[k];next.premiumEntryPol=Number(next.premiumEntryPol)||362;next.updatedAt=new Date().toISOString();writeJson(CONFIG_FILE,next);return json(res,200,{config:next});
const b=await bodyJson(req),cur=getConfig(),next={...cur};for(const k of ['siteName','programName','bridgeHeadline','bridgeSubheadline','premiumEntryPol','dappReferralBaseUrl','telegramUrl','supportLabel','showSponsorName','showQueueProgress','bemobPostbackUrl','telegramBotToken','companionBotToken','miniAppShortName','telegramChatId','telegramTopicId','telegramRecruitTopicId','teamRootId','emailFrom','teamAlertEmail','ownerIds','ownerAlertEmail','orgRootId','ctbOfferPostbackUrl','ctbOfferSecret','recruitCtaUrl','walletNotice','tweetEnabled','tweetCtaUrl','tweetHashtags','blotatoTwitterId','dappFallbackPublic','moonpayPublicKey','moonpaySecretKey','publicRotationMode','publicRotationRootId','rotationExcludeIds','suiteAllowlist','suiteToolsInAlerts','suiteLevelOverride','directDefaultIds'])if(Object.prototype.hasOwnProperty.call(b,k))next[k]=b[k];next.premiumEntryPol=Number(next.premiumEntryPol)||362;next.updatedAt=new Date().toISOString();writeJson(CONFIG_FILE,next);return json(res,200,{config:next});
}
const m=pathname.match(/^\/api\/admin\/sponsors\/([^/]+)(?:\/(increment|activate|qualify|reset|move))?$/);
if(m){const id=decodeURIComponent(m[1]),action=m[2]||null;let sponsors=getSponsors(),idx=sponsors.findIndex(s=>s.id===id);if(idx<0)return json(res,404,{error:'Sponsor not found.'});