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
The RM CircleRM CircleSponsor Router Admin

Team Admin

Manage the current sponsor, qualification queue, and onboarding settings.

+

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.'});