diff --git a/public/admin.html b/public/admin.html index fb0fcfc..f6a5128 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.

- +
diff --git a/public/admin.js b/public/admin.js index f8c78a8..afe6033 100644 --- a/public/admin.js +++ b/public/admin.js @@ -34,7 +34,7 @@ function renderAnalytics(){ } function esc(s){return String(s??'').replace(/[&<>'"]/g,c=>({'&':'&','<':'<','>':'>',"'":''','"':'"'}[c]))} function render(){ - const sponsors=[...state.sponsors].sort((a,b)=>a.sortOrder-b.sortOrder);rows.innerHTML=sponsors.map((s,i)=>`${i+1}
ID ${esc(s.id)}
${s.email?`
✉ ${esc(s.email)}
`:''}${esc(s.parentId||'—')}${s.directs}/2${levelSelect(s)}${esc(s.status)}${s.clicks||0}
${s.status!=='qualified'?``:``}${s.status==='waiting'?``:''}
`).join('')||'No sponsors in the queue.'; + const sponsors=[...state.sponsors].sort((a,b)=>a.sortOrder-b.sortOrder);rows.innerHTML=sponsors.map((s,i)=>`${i+1}
ID ${esc(s.id)}
${s.email?`
✉ ${esc(s.email)}
`:''}${esc(s.parentId||'—')}${levelSelect(s)}${esc(s.notes||'')}
`).join('')||'No members in the directory.'; renderAnalytics(); const ai=state.aiChat||{};document.getElementById('aiModel').textContent=ai.model||'OpenRouter';document.getElementById('aiStatus').innerHTML=ai.configured?'● AI chat is ON — key is set':'○ AI chat is OFF — using built-in answers'; const em=state.email||{};document.getElementById('emailStatus').innerHTML=em.configured?'● Payment emails are ON — key is set':'○ Payment emails are OFF — no SendGrid key'; @@ -50,7 +50,7 @@ function render(){ } 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()}); -document.getElementById('addSponsorForm').addEventListener('submit',async e=>{e.preventDefault();const form=e.currentTarget,obj=Object.fromEntries(new FormData(form));try{const d=await api('/api/admin/sponsors',{method:'POST',body:JSON.stringify(obj)});state.sponsors=d.sponsors;form.reset();render();showToast('Sponsor added')}catch(x){showToast(x.message)}}); +document.getElementById('addSponsorForm').addEventListener('submit',async e=>{e.preventDefault();const form=e.currentTarget,obj=Object.fromEntries(new FormData(form));try{const d=await api('/api/admin/sponsors',{method:'POST',body:JSON.stringify(obj)});state.sponsors=d.sponsors;form.reset();render();showToast('Member added')}catch(x){showToast(x.message)}}); document.getElementById('configForm').addEventListener('submit',async e=>{e.preventDefault();const f=e.currentTarget,obj=Object.fromEntries(new FormData(f));obj.showSponsorName=f.elements.showSponsorName.checked;obj.showQueueProgress=f.elements.showQueueProgress.checked;obj.announceEnabled=!!(f.elements.announceEnabled&&f.elements.announceEnabled.checked);obj.premiumEntryPol=Number(obj.premiumEntryPol);try{const d=await api('/api/admin/config',{method:'PATCH',body:JSON.stringify(obj)});state.config=d.config;render();showToast('Settings saved')}catch(x){showToast(x.message)}}); rows.addEventListener('click',async e=>{const b=e.target.closest('button[data-action]');if(!b)return;const id=b.dataset.id,a=b.dataset.action;try{let d;if(a==='rename'){const cur=(state.sponsors.find(s=>s.id===id)||{}).name||'';const nm=prompt(`Name for sponsor ID ${id}:`,cur);if(nm===null||!nm.trim())return;d=await api(`/api/admin/sponsors/${id}`,{method:'PATCH',body:JSON.stringify({name:nm.trim()})});}else if(a==='email'){const cur=(state.sponsors.find(s=>s.id===id)||{}).email||'';const em=prompt(`Contact email for sponsor ID ${id} (leave empty to clear):`,cur);if(em===null)return;d=await api(`/api/admin/sponsors/${id}`,{method:'PATCH',body:JSON.stringify({email:em.trim()})});}else if(a==='delete'){if(!confirm(`Delete sponsor ID ${id}?`))return;d=await api(`/api/admin/sponsors/${id}`,{method:'DELETE'})}else if(a==='inc')d=await api(`/api/admin/sponsors/${id}/increment`,{method:'POST'});else if(a==='qualify')d=await api(`/api/admin/sponsors/${id}/qualify`,{method:'POST'});else if(a==='activate')d=await api(`/api/admin/sponsors/${id}/activate`,{method:'POST'});else if(a==='reset')d=await api(`/api/admin/sponsors/${id}/reset`,{method:'POST'});else if(a==='up'||a==='down')d=await api(`/api/admin/sponsors/${id}/move`,{method:'POST',body:JSON.stringify({direction:a})});if(d&&d.sponsors){state.sponsors=d.sponsors;render();showToast('Updated')}}catch(x){showToast(x.message)}}); document.getElementById('aiKeyForm').addEventListener('submit',async e=>{e.preventDefault();const inp=e.currentTarget.elements.key,key=inp.value.trim();if(!key){showToast('Paste a key first');return}try{const d=await api('/api/admin/openrouter-key',{method:'POST',body:JSON.stringify({key})});state.aiChat={...(state.aiChat||{}),configured:d.configured};inp.value='';render();showToast('AI chat enabled')}catch(x){showToast(x.message)}}); diff --git a/public/chat.js b/public/chat.js index 284694b..304d7f8 100644 --- a/public/chat.js +++ b/public/chat.js @@ -106,7 +106,7 @@ {k:['after join','next','what happens','confirmed','joined','i bought','i am in'], a:()=>`Once your position is confirmed: tell the team your new RM Circle ID and your sponsor ID (in the Telegram group: ${tgLink('here')}) so your placement gets mapped. Then your job is simple — get your 2 directs, and when you're qualified, help your 2 get their 2.`}, {k:['waiting','queue','line','how long','when my turn'], - a:()=>{const w=sponsorInfo&&typeof sponsorInfo.waitingCount==='number'?sponsorInfo.waitingCount:null;return `${w!==null?`Right now there ${w===1?'is':'are'} ${w} team placement${w===1?'':'s'} waiting behind the current sponsor. `:''}The queue moves every time a position qualifies with its 2 directs — the more the team helps each other, the faster it rotates.`}}, + a:()=>`The company rotation always offers the next open position in the team tree, read live from the blockchain. It moves the moment that position gets its 2 directs. A member's own invite link is different: it places people inside that member's own team.`}, ]; function answer(q){ diff --git a/public/start.js b/public/start.js index 3bdb2d1..ce9ee9c 100644 --- a/public/start.js +++ b/public/start.js @@ -76,8 +76,7 @@ async function load(){ {const dl=document.getElementById('dappJoinLink');if(dl&¤tSponsor.referralUrl){dl.href=currentSponsor.referralUrl;dl.classList.remove('hidden');}} // the company-queue line only means anything on the company-rotation path // only on the company-rotation path — the invited path already wrote its own line here - if(!ref)document.getElementById('queueText').textContent=(c.showQueueProgress&&s.waitingCount!==undefined) - ?`${s.waitingCount} team placement${s.waitingCount===1?'':'s'} waiting behind the current sponsor.`:''; + if(!ref)document.getElementById('queueText').textContent='The company rotation offers the next open position in the tree; it moves the moment that spot fills.'; document.getElementById('supportBox').textContent=c.supportLabel||'Contact your team sponsor if you need help before joining.'; if(c.telegramUrl){const w=document.getElementById('supportLinkWrap'),a=document.getElementById('supportLink');a.href=c.telegramUrl;w.classList.remove('hidden')} }catch(e){ diff --git a/server.js b/server.js index 2577174..c6ac120 100644 --- a/server.js +++ b/server.js @@ -63,8 +63,8 @@ function chatRateLimited(ip) { rec.count++; return rec.count > 10; } function chatSystemPrompt() { - const c = getConfig(), sponsors = getSponsors(), a = activeSponsor(sponsors); - const waiting = sponsors.filter(s=>s.status==='waiting').length; + const c = getConfig(); + let a = null; try { const ex = new Set(String(c.rotationExcludeIds||'').split(',').map(n=>Number(n.trim())).filter(n=>n>0)); a = chain.nextOpenPosition(Number(c.publicRotationRootId)||2, ex); } catch (e) {} return `You are "Team Help", the assistant on ${c.siteName || 'RM Circle Team Build'} (https://rmcircle.team), the team site for the RM Circle Premium team build. FACTS: @@ -79,7 +79,7 @@ FACTS: - TIERS: Premium (the tier the whole team builds at, ${c.premiumEntryPol || 362} POL entry) pays and earns FULL amounts; Standard costs about half and pays/earns HALF at every level. A smaller-than-expected payment almost always came from a Standard-tier position below. Tier is fixed at registration and CANNOT be changed later (upgrading advances your LEVEL, not your tier). Always recommend joining Premium and having recruits do the same. Amount comparison at /how-pay-works. - MEMBER DASHBOARD & ALERTS: each member has a live dashboard at https://rmcircle.team/my (enter your ID) showing position, team, payments, pipeline (incoming money forming below), a team-depth summary (members per generation below you and which level's upgrade each generation pays you at), a "Coach Your Team" panel (who in YOUR leg needs a nudge — qualified-but-not-upgraded members sitting on entry rewards, members about to miss forming payments, members one direct from qualifying — each recommendation has a one-tap "Send this nudge" button that sends a ready-written teach-forward message over the wallet-verified Messages system, delivered on-site and to Telegram if the member linked it), spillover tags, and qualification badges. Members can turn on opt-in EMAIL ALERTS there (notified when paid, and when they need to upgrade to catch incoming pay). A member's personal invite page to share is https://rmcircle.team/join/. - RESILIENCE ("what if the creators disappear / owner loses keys / it falls apart over time"): the contract is autonomous and immutable — NO admin action, heartbeat, or living operator is required for joins, upgrades, matrix placement, or payouts; there is no pause switch and no expiry. Verified on-chain that the founder, development, and fee-receiver wallets are ordinary wallets (EOAs), NOT smart contracts — an ordinary wallet always accepts incoming POL even if its key is lost forever, so a dead or abandoned admin wallet cannot block any member payment (only the project's OWN uncollected fee would sit idle). The contract stores no balance (every payment is delivered in the same transaction). If the owner's key were lost, only the four limited admin powers freeze in place; members are unaffected. Details in section 6 of https://rmcircle.team/contract. -- Current team sponsor: ${a ? `ID ${a.id}${c.showSponsorName && a.name ? ` (${a.name})` : ''}, ${a.directs}/2 directs` : 'shown on the start page'}. ${waiting} placement(s) waiting. Placements rotate as positions qualify — always verify on https://rmcircle.team/start right before joining. +- Current company-rotation sponsor: ${a ? `ID ${a.id}, ${a.directCount}/2 directs` : 'shown on the start page'} — the next open position in the company tree, read live from the chain. It moves the moment that position fills, so always verify on https://rmcircle.team/start right before joining. A member's own invite link is different: it places people inside that member's own team. - Site pages: https://rmcircle.team/ (strategy overview + roadmap + live team stats), https://rmcircle.team/start (current sponsor + join steps), https://rmcircle.team/training (THE CIRCLE METHOD — the team's 10-lesson course in 3 modules; it is the MEMBERS-AREA product: Lesson 1 is the free public preview, Lessons 2-10 unlock by signing in with the wallet that owns a position (one free signature, right on the training page) or automatically inside the Telegram Mini App; every lesson's title and description stays visible so prospects can see what's included. M1 Get Your Two: L1 mindset, L2 warm list, L3 the conversation, L4 objections. M2 Help Your Two: L5 dashboard-as-coaching-desk, L6 first 48 hours, L7 stalled people & pass-ups, L8 timing upgrades to catches. M3 Teach the Teachers: L9 run the same play, L10 the 20-minute weekly rhythm. ROUTING RULE — for MEMBERS answer with the lesson: how do I find people→L2 (/training#lesson-2); what do I say→L3; pyramid objection→L4; new member just joined→L6; someone stalled→L7; should I upgrade→L8; overwhelmed→L10. For someone NOT yet a member, answer the substance directly yourself and mention that the full lesson is included with their position (never send a prospect a locked link as the answer). Deep links: /training#lesson-N — plus 10 how-to videos (incl. "The Textbook Play" — 111s: a top team builder's own position (#21, one of the builders near the top — NOT a program founder) shown as a ledger, every upgrade funded by a prior catch, this week's Apex catch arriving and funding his Apex the same hour; route "does this actually work / has anyone done this" questions here at /training#textbook-play, always with the no-income-promise framing; and Video 7 "Your level is your reach" — 97s on catch eligibility: qualified members catch their two's first upgrades without owning the level, each owned level extends reach one generation deeper, uncatchable payments pass over) — team overview, wallet setup, funding, the new connect-wallet join flow on the site, the dApp backup method, how payments work, a full 14-min Member Dashboard walkthrough, and an 8-min REAL e-gift-card cash-out walkthrough (/training#egift-video, MEMBERS-ONLY like the Method lessons: POL → CWallet → dollar swap → gift card, ending with the virtual card in a mobile wallet ready to tap-to-pay) — + spillover article; the join-funnel and transparency videos are always public), https://rmcircle.team/how-pay-works (the two income streams shown as a pay-flow diagram + Premium/Standard tier comparison + a "What each level opens up" scaling table at /how-pay-works#level-scale showing how each level lets you catch a deeper, wider, higher-paying generation of pass-ups — explicitly framed as the contract's mechanical maximum at full fill, NOT a prediction or income promise; READING THE TABLE (2026-09-16, a leader read it one rung off): each row names the LEVEL YOU HOLD (your reach) and, since the update, also the LEVEL THAT GENERATION BUYS, which is one rung higher — holding Fabrica catches your 3rd generation (8 people) when they buy Culmen at 2,486 each; if someone labels rows by the level being bought their rows shift down one but every number still matches; the right-hand column is one generation only, never a running total: through the Fastigium row the sum is 1,696,429 POL, the Vertex row alone is generation 7 (128 people buying Corona) = 5,090,529 POL; plus a "Does it cap out at 8 levels?" explainer at /how-pay-works#cap, and a contract-exact price table at /how-pay-works#exact-costs read live from getAllCosts(): Premium entry base 343.41 POL — member sends 360.58 incl the 5% admin charge on entries only (public charts round to ~362 as a send buffer), sponsor receives 326.24 (95% of base); upgrades are a separate ladder paid 100% member-to-member up the matrix with zero admin fee — 621.40 to reach level 2, doubling EXACTLY each level (2×621.40=1,242.80 to the penny) so two same-level pass-ups always fund the member's own next upgrade. USE THIS to answer "why is entry a different price than upgrade / is the income chart inflated": they are two different purchases paid to two different people (entry→sponsor, upgrade→upline), income illustrations are built only from the upgrade stream, and every price is a fixed contract constant verifiable on Polygonscan. IMPORTANT: levels can NEVER be skipped — register() has no level parameter (every position enters at Scintilla) and upgrade() takes no target (exactly one rung per call); company charts listing an "entry price" for levels 2-8 show vestigial contract data rows that no function sells — total to hold Culmen = 360.58+621.40+1,242.80+2,485.61 = 4,710.39 POL, climbed one level at a time. CATCH-ELIGIBILITY NUANCE (verified in source): the payout loop pays the first non-skipped upline with directCount≥2 AND level GREATER THAN the buyer's pre-upgrade level minus one — i.e. the catcher's level must be AT OR ABOVE the level the buyer is LEAVING, not the level being bought. So a qualified member still at Scintilla DOES catch their two matrix children's Ascensus purchases in full; owning higher levels extends REACH (Fabrica catch from 2 generations down needs Ascensus, etc. — one structural upline skip per level of depth). Depth pays fixed counts (2/4/8… one payment per person) but per-person size doubles with width (full-generation value quadruples per level), generations keep filling over time, and pass-ups add catches), https://rmcircle.team/contract (plain-language security review of the verified smart contract — code can't change, no pooled funds, locked rules, honest list of operator powers), https://rmcircle.team/weekly-rhythm (printable 20-minute Weekly Rhythm routine + 4-week habit tracker from Method Lesson 10, personalized like the Fast Start sheet), https://rmcircle.team/suite (THE CIRCLE SUITE — LIVE AND OPEN TO EVERY POSITION. Promote it freely, and lead with it whenever anyone asks "what is the actual product" or "what do I actually get" — it is the strongest answer we have: most programs hand you a referral link, this one hands you working software too. The team's marketing-toolkit portal: every paid position is a LICENSE to the toolkit; members connect their position's wallet (one free signature, same sign-in as the training gate and Mini App bridge) and their live contract level lights up their tools on a visible tool wall — Level 1 today includes the Promo Center, Printable Handouts, Fast Start + Weekly Rhythm, Generation Pay chart, Circle Method, Live Dashboard, and the AI coach; higher-level tiles (Copy Engine L2, Page Builder L2, Email Engine + Video Maker L3, Voice Profile + Funnels L4, Traffic Desk L5 (syndicated network display advertising — banner/text placements on the team's own ad network, monthly ad credits, rotator priority, AI campaign packs), Funnel Factory L6 (hosted funnels + Replay Funnels — an evergreen registration page around a recorded team webinar with a timed CTA, NOT live video conferencing/Zoom — + lead CRM), Leader Ops L7, Founder Desk L8) are shown honestly as IN DEVELOPMENT and unlock automatically as members upgrade once shipped; the Suite is included with membership, never sold), https://rmcircle.team/generation-pay (printable Generation Pay chart — the full when-does-each-generation-pay-me table with exact per-person POL amounts; enter a member ID and it personalizes from the live chain: shows the member's tier/level/qualification and marks each generation "catching now" vs "needs Level N"; Gen 8 pays the member's Gen 1, pass-overs only; Standard positions pay half; ROUTE members here for any generation-pay / who-pays-me-when question), https://rmcircle.team/fast-start (printable 48-Hour Fast Start checklist — personalized with the member's invite link and a scannable QR code when opened from their dashboard; prints clean black-on-white, and prints in whatever language the member selected with the 🌐 button), REQUIRED MEMBER PROFILE (2026-09-16): the first time someone proves they own a position (wallet personal_sign, or automatically in the Telegram Mini App) they must set a USERNAME and confirm an EMAIL with a 6-digit code before the member area opens; it cannot be skipped. Reason given to members: their leader can reach them, and they get an email the moment a payout lands. The email is never shown to other members and never sold, is changeable, and the 40 positions that already gave an email for payout alerts have it pre-filled to confirm. One email may hold several positions (Triple Play). The PUBLIC page rmcircle.team/my/ is unchanged and never asks for anything, and no profile can be written from it. https://rmcircle.team/my (member dashboard — its "Your team" panel opens with an organization bar: total members in your org, generations deep, qualified count below you, POL earned below you, and its approximate USD value at an hourly-cached POL price; the matrix under it drills leg by leg), https://rmcircle.team/tools (for existing team members who want to promote — share-ready promo videos (including the “Pocket Change” curiosity hook video — 25 ways people flush pocket change weekly with nothing to show for it, then the side-hustle flip; it deliberately shows no URL so the poster's invite link in the caption/description carries the credit, and matching pocket-change post copy sits in the Social posts section), copy-paste social posts, short/long email swipes, a downloadable banner kit in every standard size (incl. a 1280×720 Telegram group-ad image with a tap-the-link-below CTA — members pair it with their own Telegram-native invite link in the caption), a Printable Handouts maker at /flyers (linked from /tools#flyers and the dashboard) — five bold full-color half-sheet handout designs with detailed artwork (one per angle: pocket change, two people, phone, side-hustle graveyard, stop waiting) that print two copies per letter page with a cut line so members can print, cut, and hand out stacks, for offline/belly-to-belly promotion (coffee shops, gyms, community & church bulletin boards, laundromats); enter the member ID once and every handout personalizes with the member's own QR code and invite link overlaid on the artwork (angle-matched ?v= links so the landing page continues the hook); print in color for impact and the QR scans in black-and-white too, and an Official RM Circle Media library (13 vertical social videos + 15 graphics from the creators — pair them with your own invite link in the caption; each curiosity video also has a MATCHED invite link (adds ?v= to the member's /join link) that makes the landing page continue that video's hook — recommend it when members ask which link to use with a video); open it from the gold Promo Tools button on your dashboard and every post/swipe arrives pre-personalized with YOUR invite link; NEW Promote-on-Telegram section at /tools#telegram — the member's Telegram-native Mini App invite links (t.me links that open the whole tour INSIDE Telegram; tapping your own link previews the prospect view), paste-ready Telegram group posts + DMs, and the setup message to forward to their team; Text-a-friend section at /tools#text-a-friend — 5 SMS-sized messages (general + one per angle video, matched to that video's landing page) with one-tap share buttons: Text it (opens the phone's messaging app pre-filled), WhatsApp (wa.me pre-fill), Telegram (shares the member's Mini App invite), Copy for Messenger/Instagram DMs; to write promos in their own voice, mybrandedvoice.com; plus an Objection Handling bank at the bottom — truth + ready-to-send reply per objection, incl. the what's-the-product / members-area answer), https://rmcircle.team/disclaimer (affiliate/earnings/risk disclosures). - UPGRADING FROM THE DASHBOARD: a qualified member can upgrade their level directly on their dashboard (rmcircle.team/my/) — an "Upgrade" card appears with the exact next-level cost read live from the contract; they connect the wallet that OWNS the position, confirm one transaction, done. The site never touches the funds (wallet pays the contract directly). If the wallet doesn't cover the cost, the card offers the MoonPay card-buy option. On phones, open the page inside the wallet app's browser. - TELEGRAM COMPANION BOT: members can link their position (dashboard → Messages → "Connect Telegram", wallet-verified) to get instant payout DMs, native Telegram delivery of team messages (reply in Telegram to answer — matrix-line rules still apply), joined-on-your-link pings, and their invite/angle links via the "links" command. This finally lets members reach their downline as real people instead of just IDs — while handles stay private (the bot relays). Linked members can also tap the bot's ☰ menu button to open the MINI APP — the full live dashboard, promo tools, and Circle Method training right inside Telegram with zero login (Telegram itself proves who they are). Prospects can JOIN from the Mini App too: a member's Telegram-native invite link (the "links" command in the bot shows it) opens the sponsor's invite page right inside Telegram, and the join itself finishes in the prospect's own wallet app's secure browser — same zero-custody flow as the website. The website stays fully available too; the Mini App is a convenience door, not a replacement. @@ -374,20 +374,14 @@ async function handleSubmitId(req, res) { let joinPath = 'unknown'; if (onchain && onchain.registered) { const R = Number(onchain.referrerId); - if ((cfgNow.publicRotationMode || 'queue') === 'chain') { - if (/^invite-/.test(source)) joinPath = 'leg'; - else { - const root = Number(cfgNow.publicRotationRootId) || 2; - const exclude = new Set(String(cfgNow.rotationExcludeIds || '').split(',').map(n => Number(n.trim())).filter(n => n > 0)); - exclude.add(R); - const dR = chain.depthFrom(root, R); - const other = chain.nextOpenPosition(root, exclude); - joinPath = (dR != null && (!other || other.depth >= dR)) ? 'rotation' : 'leg'; - } - } else { - const sponsorsNow = getSponsors(); - const refSp = sponsorsNow.find(s => String(s.id) === String(R)); - joinPath = (refSp && (refSp.status === 'active' || refSp.status === 'qualified')) ? 'rotation' : 'leg'; + if (/^invite-/.test(source)) joinPath = 'leg'; + else { + const root = Number(cfgNow.publicRotationRootId) || 2; + const exclude = new Set(String(cfgNow.rotationExcludeIds || '').split(',').map(n => Number(n.trim())).filter(n => n > 0)); + exclude.add(R); + const dR = chain.depthFrom(root, R); + const other = chain.nextOpenPosition(root, exclude); + joinPath = (dR != null && (!other || other.depth >= dR)) ? 'rotation' : 'leg'; } } else if (onchain && !onchain.registered) joinPath = 'notfound'; subs.push({ newId, memberName, sponsorId, source: source||'(direct)', clickid, ts: new Date().toISOString(), path: joinPath, @@ -503,7 +497,6 @@ function seedAnnounceDefaults() { } catch (e) {} } seedAnnounceDefaults(); -function activeSponsor(sponsors) { return sponsors.find(s=>s.status==='active') || sponsors.find(s=>s.status==='waiting') || null; } function getAnalytics() { try { return readJson(ANALYTICS_FILE); } catch (e) { return { sources: {} }; } } function recordEvent(event, source) { if (!['bridge','start','click','training','postback','purchase','join','joinnow','engaged'].includes(event) && !String(event).startsWith('ctb-offer-')) return; @@ -513,30 +506,6 @@ function recordEvent(event, source) { a.sources[s][event] = (a.sources[s][event]||0) + 1; writeJson(ANALYTICS_FILE, a); } -function normalizeStatuses(sponsors, preferredActiveId=null) { - // Chain reconciliation: queue members can qualify through leg activity long - // BEFORE their rotation turn, and the event-driven auto-counter never sees - // it (bit us twice on 2026-08-19: #41, then #46 activated while already - // 2/2 on-chain). Never activate someone the chain says is qualified. - sponsors=sponsors.map(s=>{ - if(s.status==='qualified')return s; - const dc=chain.liveDirects(Number(s.id)); - if(dc!=null&&dc>=2)return {...s,directs:2,status:'qualified'}; - if(dc!=null&&dc>(Number(s.directs)||0))return {...s,directs:dc}; - return s; - }); - const eligible=sponsors.filter(s=>s.status!=='qualified'); - let activeId=preferredActiveId; - if(!activeId || !eligible.some(s=>s.id===activeId)){ - const existing=eligible.find(s=>s.status==='active'); - activeId=existing?existing.id:(eligible[0]?.id||null); - } - return sponsors.map(s=>s.status==='qualified'?s:{...s,status:s.id===activeId?'active':'waiting'}); -} -function publicSponsorPayload(sponsor, config) { - if(!sponsor)return null; - return {id:sponsor.id,name:config.showSponsorName?sponsor.name:null,directs:sponsor.directs,goal:2,level:sponsor.level,referralUrl:`${config.dappReferralBaseUrl}${encodeURIComponent(sponsor.id)}`}; -} // --- on-demand UI translation: strings cached forever on the volume, misses // filled by the same OpenRouter model the chatbot uses. Public site text only. @@ -1617,8 +1586,7 @@ async function handleApi(req,res,pathname){ const pick=chain.nextOpenPosition(root,exclude); if(pick)return json(res,200,{sponsor:{id:String(pick.id),name:null,directs:pick.directCount,goal:2,level:pick.level,referralUrl:`${c.dappReferralBaseUrl}${encodeURIComponent(pick.id)}`},waitingCount:0,mode:'chain',message:'Company rotation — you join under the next open team position. It advances automatically as positions qualify.'}); } - const sponsors=getSponsors(),a=activeSponsor(sponsors);if(!a)return json(res,404,{error:'No active sponsor is currently assigned.'}); - return json(res,200,{sponsor:publicSponsorPayload(a,c),waitingCount:sponsors.filter(s=>s.status==='waiting').length,message:'Always use the current sponsor shown on this page. Team placement rotates as members qualify.'}); + return json(res,404,{error:'Company rotation is not configured (publicRotationMode must be chain).'}); } if(req.method==='POST'&&pathname==='/api/public/join-click'){ const b=await bodyJson(req).catch(()=>({}));recordEvent('click',b.source); @@ -1753,19 +1721,15 @@ async function handleApi(req,res,pathname){ } if(req.method==='POST'&&pathname==='/api/admin/sponsors'){ const b=await bodyJson(req);const {id,name,parentId='',level='Scintilla',notes='',email=''}=b;if(!id||!name)return json(res,400,{error:'ID and name are required.'});if(!LEVELS.includes(level))return json(res,400,{error:'Invalid level.'});if(email&&!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(String(email).trim()))return json(res,400,{error:'Invalid email address.'});let sponsors=getSponsors();if(sponsors.some(s=>String(s.id)===String(id)))return json(res,409,{error:'That sponsor ID already exists.'}); - 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}); + 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(),level,sortOrder:maxOrder+10,notes:String(notes||'').trim(),email:String(email||'').trim().slice(0,120)});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','telegramProofChatId','telegramProofTopicId','telegramProofEvents','telegramProofCtaUrl','telegramEchoChatId','telegramEchoTopicId','telegramEchoEvents','telegramTeamEvents','teamRootId','emailFrom','teamAlertEmail','ownerIds','ownerAlertEmail','orgRootId','ctbOfferPostbackUrl','ctbOfferSecret','recruitCtaUrl','walletNotice','tweetEnabled','tweetCtaUrl','tweetHashtags','blotatoTwitterId','dappFallbackPublic','moonpayPublicKey','moonpaySecretKey','publicRotationMode','publicRotationRootId','rotationExcludeIds','suiteAllowlist','suiteToolsInAlerts','suiteLevelOverride','directDefaultIds','announceEnabled','announceId','announceImg','announceMeetUrl','announceEyebrow','announceDateLabel','announceTimes','announceExpiresUTC','announceStartUTC','announceDurationMin','promoEnabled','promoId','promoImg','promoEyebrow','promoCtaText','promoCtaUrl','promoPages','promoExpiresUTC'])if(Object.prototype.hasOwnProperty.call(b,k))next[k]=b[k];if('announceDurationMin' in next)next.announceDurationMin=Math.max(15,Number(next.announceDurationMin)||60);next.premiumEntryPol=Number(next.premiumEntryPol)||362;if('announceEnabled' in next)next.announceEnabled=!!next.announceEnabled;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))?$/); + const m=pathname.match(/^\/api\/admin\/sponsors\/([^/]+)(?:\/(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.'}); - if(req.method==='PATCH'&&!action){const b=await bodyJson(req);if(Object.prototype.hasOwnProperty.call(b,'level')&&!LEVELS.includes(b.level))return json(res,400,{error:'Invalid level.'});if(Object.prototype.hasOwnProperty.call(b,'email')&&b.email&&!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(String(b.email).trim()))return json(res,400,{error:'Invalid email address.'});for(const k of ['name','parentId','directs','level','notes','email'])if(Object.prototype.hasOwnProperty.call(b,k))sponsors[idx][k]=k==='email'?String(b[k]||'').trim().slice(0,120):b[k];sponsors[idx].name=String(sponsors[idx].name||'').trim().slice(0,80)||sponsors[idx].name;sponsors[idx].directs=Math.max(0,Math.min(2,Number(sponsors[idx].directs)||0));saveSponsors(sponsors);return json(res,200,{sponsors});} - if(req.method==='DELETE'&&!action){const wasActive=sponsors[idx].status==='active';sponsors.splice(idx,1);if(wasActive)sponsors=normalizeStatuses(sponsors);saveSponsors(sponsors);return json(res,200,{sponsors});} - if(req.method==='POST'&&action==='increment'){sponsors[idx].directs=Math.min(2,(Number(sponsors[idx].directs)||0)+1);saveSponsors(sponsors);return json(res,200,{sponsors});} - if(req.method==='POST'&&action==='activate'){if(sponsors[idx].status==='qualified')return json(res,400,{error:'Qualified sponsors cannot be activated until reset.'});sponsors=normalizeStatuses(sponsors,id);saveSponsors(sponsors);return json(res,200,{sponsors});} - if(req.method==='POST'&&action==='qualify'){sponsors[idx]={...sponsors[idx],directs:2,status:'qualified'};sponsors=normalizeStatuses(sponsors);saveSponsors(sponsors);return json(res,200,{sponsors,active:activeSponsor(sponsors)});} - if(req.method==='POST'&&action==='reset'){sponsors[idx]={...sponsors[idx],directs:0,status:'waiting'};sponsors=normalizeStatuses(sponsors);saveSponsors(sponsors);return json(res,200,{sponsors});} + if(req.method==='PATCH'&&!action){const b=await bodyJson(req);if(Object.prototype.hasOwnProperty.call(b,'level')&&!LEVELS.includes(b.level))return json(res,400,{error:'Invalid level.'});if(Object.prototype.hasOwnProperty.call(b,'email')&&b.email&&!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(String(b.email).trim()))return json(res,400,{error:'Invalid email address.'});for(const k of ['name','parentId','level','notes','email'])if(Object.prototype.hasOwnProperty.call(b,k))sponsors[idx][k]=k==='email'?String(b[k]||'').trim().slice(0,120):b[k];sponsors[idx].name=String(sponsors[idx].name||'').trim().slice(0,80)||sponsors[idx].name;saveSponsors(sponsors);return json(res,200,{sponsors});} + if(req.method==='DELETE'&&!action){sponsors.splice(idx,1);saveSponsors(sponsors);return json(res,200,{sponsors});} if(req.method==='POST'&&action==='move'){const b=await bodyJson(req);const swap=b.direction==='up'?idx-1:idx+1;if(swap>=0&&swap{ if(chain.isInTeam(evt.toId,orgRoot))tweet.queuePayoutTweet(evt,getConfig()); } }catch(e){console.error('tweet hook error',e.message)} - // Auto-count directs + AUTO-ADVANCE (Marty 2026-08-15): a new registration - // whose referrer sits in the rotation queue increments that sponsor's directs. - // When it reaches 2/2 the sponsor is auto-qualified and the next waiting - // position activates — no manual Qualify click (changed from the earlier - // manual design so the rotation never sits stuck at a 2/2 sponsor). - try{ - if(evt.type==='registered'&&evt.referrerId!=null){ - let sponsors=getSponsors(); - const idx=sponsors.findIndex(x=>String(x.id)===String(evt.referrerId)); - if(idx>=0&&sponsors[idx].status!=='qualified'&&(Number(sponsors[idx].directs)||0)<2){ - const newDirects=Math.min(2,(Number(sponsors[idx].directs)||0)+1); - const s=sponsors[idx]; - if(newDirects>=2){ - sponsors[idx]={...sponsors[idx],directs:2,status:'qualified'}; - sponsors=normalizeStatuses(sponsors); - saveSponsors(sponsors); - const next=activeSponsor(sponsors); - }else{ - sponsors[idx].directs=newDirects; - saveSponsors(sponsors); - } - } - } - }catch(e){console.error('auto-direct error',e.message)} - // Queue level sync: when a queue member upgrades on-chain, their Level in the - // rotation queue follows automatically (same op as the admin level dropdown). - try{ - if(evt.type==='upgraded'&&evt.id!=null){ - const sponsors=getSponsors(); - const idx=sponsors.findIndex(x=>String(x.id)===String(evt.id)); - if(idx>=0&&LEVELS.includes(evt.levelName)&&sponsors[idx].level!==evt.levelName){ - sponsors[idx].level=evt.levelName; - saveSponsors(sponsors); - } - } - }catch(e){console.error('auto-level error',e.message)} });