From 7e390aa48ecfd2f8379d649451411a7976653687 Mon Sep 17 00:00:00 2001 From: martbost Date: Mon, 31 Aug 2026 10:29:55 -0500 Subject: [PATCH] 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/ 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 --- public/admin.html | 2 +- public/admin.js | 2 +- public/join-now.js | 7 ++++++- public/join.js | 7 ++++++- server.js | 12 +++++++++++- 5 files changed, 25 insertions(+), 5 deletions(-) diff --git a/public/admin.html b/public/admin.html index 97d6c89..268a812 100644 --- a/public/admin.html +++ b/public/admin.html @@ -1,5 +1,5 @@ RM Circle Team Build Admin +

Sponsor Queue

Mark a sponsor qualified to automatically activate the next waiting position.

OrderSponsorParentDirectsLevelStatusClicksActions

Add Sponsor

Put a new position into the qualification queue.
Toggle

Coaching Radar

Live triage of your whole org: who to nudge, what to tell them, and how much POL is on the line. Computed fresh from the chain index on every refresh.

Loading…

Award Suite Capacity

Give any position extra Suite allowance as a team bonus — issued by the team, not taken from anyone.
Open
Bonuses awarded this month
ToWhatAmountReasonWhen

Member Messages

Wallet-verified member-to-member messages, newest first. Admin can review for abuse — members are told this in the UI.

WhenFromToMessageRead by
Loading…

Traffic & Conversions

First-touch source per visitor session (referring domain or utm_source). Funnel: bridge page → start page → join click.

SourceBridge viewsStart viewsTraining viewsInvite viewsJoin-now viewsJoin clicksConfirmedStart → Join

Your Organization vs. the Network

How your team — rooted at your top ID — stacks up against the entire RM Circle smart contract. Live on-chain.

Reading the blockchain…

Member ID Submissions

New members who confirmed their purchase on the start page. Each one was posted to your Hermes Telegram chat — add them to the rotation.

WhenName / HandleNew IDJoined underSourceOn-chain

On-Chain Member Lookup

Enter an RM Circle ID to read its registration, lineage, and every payment it has received — live from the smart contract.

Matrix View

The entire on-chain matrix — who landed where, with tier, level, directs, and earnings per position. Click a position to drill down.

My Positions — Income

Every payment received by your own positions, live from the contract. Comma-separated IDs — saved for next time.

AI Chat

Paste an OpenRouter API key to switch the help chat from canned answers to AI (). Clear it to switch back.

Payment Emails (SendGrid)

When a payout hits a member whose sponsor record has a contact email, they get a "you've been paid" email automatically. Paste your SendGrid API key (Branded Voice Coolify app → Environment Variables → SENDGRID_API_KEY).

Public Page Settings

Their /join/<id> links place new joins directly under them instead of rotating to the next member needing directs — so they keep the entry reward. Add ?direct=0 to a link to force rotation for that one share.
diff --git a/public/admin.js b/public/admin.js index 1df5838..76eca1e 100644 --- a/public/admin.js +++ b/public/admin.js @@ -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()}); diff --git a/public/join-now.js b/public/join-now.js index b594983..3d60542 100644 --- a/public/join-now.js +++ b/public/join-now.js @@ -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 directly under #'+ref+' — 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 directly under #'+ref+' — 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 #'+t.id+''+(String(t.id)!==String(ref)?' — the next open spot in #'+ref+'’s team':'')+'.'; return; } }catch(e){} } diff --git a/public/join.js b/public/join.js index 313d3d5..ed06507 100644 --- a/public/join.js +++ b/public/join.js @@ -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'); diff --git a/server.js b/server.js index eee0b65..57578a8 100644 --- a/server.js +++ b/server.js @@ -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/ 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.'});