From 89f27b89a28bde44a36c2a4f4ab277587bdc4560 Mon Sep 17 00:00:00 2001 From: martbost Date: Wed, 12 Aug 2026 13:30:53 -0500 Subject: [PATCH] Close the conversion loop: member ID submission with Hermes Telegram notify - Start page step 6 is now a bold gold-highlighted "Submit your NEW RM Circle ID" form with larger step numbers throughout; captures the sponsor the visitor was shown at join time. - POST /api/public/submit-id: validates numeric ID, dedupes, stores on the volume, records a per-source "purchase" analytics event, fires a BeMob postback (txid=purchase-) to close paid campaigns, and posts the new ID + sponsor + source to the configured Telegram chat (MB Hermes pattern). Rate-limited. - Admin: Member ID Submissions table, "Confirmed joins" tile, and Telegram bot token / chat ID settings. Co-Authored-By: Claude Fable 5 --- public/admin.html | 5 ++-- public/admin.js | 9 +++++--- public/start.html | 2 +- public/start.js | 19 +++++++++++++++- public/styles.css | 3 +++ server.js | 58 +++++++++++++++++++++++++++++++++++++++-------- 6 files changed, 79 insertions(+), 17 deletions(-) diff --git a/public/admin.html b/public/admin.html index e731bc6..4169fd7 100644 --- a/public/admin.html +++ b/public/admin.html @@ -2,8 +2,9 @@ +

Public Page Settings

diff --git a/public/admin.js b/public/admin.js index d527cf0..3ad0f4a 100644 --- a/public/admin.js +++ b/public/admin.js @@ -8,8 +8,8 @@ function statusClass(s){return `status status-${s}`} function pct(n,d){return d?`${Math.round((n/d)*100)}%`:'—'} function renderAnalytics(){ const src=(state.analytics&&state.analytics.sources)||{}; - const tot={bridge:0,start:0,click:0,training:0,postback:0}; - const list=Object.entries(src).map(([name,r])=>{tot.bridge+=r.bridge||0;tot.start+=r.start||0;tot.click+=r.click||0;tot.training+=r.training||0;tot.postback+=r.postback||0;return{name,bridge:r.bridge||0,start:r.start||0,click:r.click||0,training:r.training||0}}) + const tot={bridge:0,start:0,click:0,training:0,postback:0,purchase:0}; + const list=Object.entries(src).map(([name,r])=>{tot.bridge+=r.bridge||0;tot.start+=r.start||0;tot.click+=r.click||0;tot.training+=r.training||0;tot.postback+=r.postback||0;tot.purchase+=r.purchase||0;return{name,bridge:r.bridge||0,start:r.start||0,click:r.click||0,training:r.training||0}}) .sort((a,b)=>b.click-a.click||b.start-a.start||b.bridge-a.bridge); document.getElementById('funnelStats').innerHTML= `
Bridge views${tot.bridge}
`+ @@ -17,7 +17,10 @@ function renderAnalytics(){ `
Join clicks${tot.click}
`+ `
Bridge → Start${pct(tot.start,tot.bridge)}
`+ `
Start → Join${pct(tot.click,tot.start)}
`+ + (tot.purchase?`
Confirmed joins${tot.purchase}
`:'')+ (tot.postback?`
BeMob postbacks${tot.postback}
`:''); + const subs=state.submissions||[]; + document.getElementById('submissionRows').innerHTML=subs.map(s=>`${esc((s.ts||'').replace('T',' ').slice(0,16))}${esc(s.newId)}ID ${esc(s.sponsorId)}${esc(s.source)}`).join('')||'No submissions yet.'; document.getElementById('trafficRows').innerHTML=list.map(r=>`${esc(r.name)}${r.bridge}${r.start}${r.training}${r.click}${pct(r.click,r.start)}`).join('')||'No traffic recorded yet.'; } function esc(s){return String(s??'').replace(/[&<>'"]/g,c=>({'&':'&','<':'<','>':'>',"'":''','"':'"'}[c]))} @@ -25,7 +28,7 @@ function render(){ const sponsors=[...state.sponsors].sort((a,b)=>a.sortOrder-b.sortOrder);rows.innerHTML=sponsors.map((s,i)=>`${i+1}${esc(s.name)}
ID ${esc(s.id)}
${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.'; 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 f=document.getElementById('configForm'),c=state.config;for(const k of ['siteName','programName','bridgeHeadline','bridgeSubheadline','premiumEntryPol','dappReferralBaseUrl','telegramUrl','supportLabel','bemobPostbackUrl'])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'])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/start.html b/public/start.html index 54a06c7..f0471dc 100644 --- a/public/start.html +++ b/public/start.html @@ -11,7 +11,7 @@
3

Fund your wallet with POL

Premium entry is 362 POL. Keep a small additional POL balance available for network gas. POL's USD value changes with the market.

4

Use the current team sponsor

Confirm that the sponsor ID shown by the RM Circle dApp matches ID — before completing the transaction.

5

Select Premium and review the transaction

Read the wallet transaction before signing. Never share your Secret Recovery Phrase or seed words with this site, a sponsor, or a dApp.

-
6

Tell the team when you're in

Once your position is confirmed, let the team know your new RM Circle ID and sponsor ID so your placement can be mapped correctly.

+
6

Submit your NEW RM Circle ID here

After your purchase confirms, the RM Circle dApp shows your new member ID. Type it below and hit submit — this tells the team you're in, maps your placement, and puts you in line for the rotation that gets your 2.

What happens next: get exactly 2 directs, retire your qualified referral link, help your 2 get their 2, and upgrade with earned POL when practical. The goal is depth and duplication—not endless directs on one link.
Risk reminder: participation involves cryptocurrency and smart-contract risk. No income is guaranteed. Use only funds you can afford to lose.
RM Circle Premium Team Build Roadmap — core strategy, step-by-step guide, premium levels, and duplication formula
The RM Circle is a team build project of the Crypto Team Build Network. This roadmap is the plan every member follows — tap to view full size.
diff --git a/public/start.js b/public/start.js index 0abdb03..8271aaa 100644 --- a/public/start.js +++ b/public/start.js @@ -23,7 +23,24 @@ async function load(){ document.getElementById('copyButton').classList.add('hidden'); } } -document.getElementById('joinButton').addEventListener('click',()=>{fetch('/api/public/join-click',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({source:window.ctbGetSource?window.ctbGetSource():'(direct)',clickid:window.ctbGetClickId?window.ctbGetClickId():''})}).catch(()=>{})}); +document.getElementById('joinButton').addEventListener('click',()=>{try{if(currentSponsor)sessionStorage.setItem('ctb.joinedSponsor',currentSponsor.id)}catch(e){};fetch('/api/public/join-click',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({source:window.ctbGetSource?window.ctbGetSource():'(direct)',clickid:window.ctbGetClickId?window.ctbGetClickId():''})}).catch(()=>{})}); +document.getElementById('idSubmitForm').addEventListener('submit',async e=>{ + e.preventDefault(); + const form=e.currentTarget,input=form.elements.newId,msg=document.getElementById('idSubmitMsg'); + const newId=input.value.trim(); + if(!/^[0-9]{1,10}$/.test(newId)){msg.style.color='var(--danger)';msg.textContent='Numbers only — the ID shown by the RM Circle dApp.';return} + let joinedSponsor='';try{joinedSponsor=sessionStorage.getItem('ctb.joinedSponsor')||''}catch(e){} + const sponsorId=joinedSponsor||(currentSponsor?currentSponsor.id:'?'); + msg.style.color='var(--muted)';msg.textContent='Submitting…'; + try{ + const r=await fetch('/api/public/submit-id',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({newId,sponsorId,source:window.ctbGetSource?window.ctbGetSource():'(direct)',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)'; + msg.innerHTML=d.duplicate?`✓ ID ${newId} was already submitted — you're on the list.`:`✓ Got it! ID ${newId} is submitted under sponsor ID ${sponsorId}. The team has been notified and you'll be added to the rotation.`; + }catch(x){msg.style.color='var(--danger)';msg.textContent=x.message} +}); document.getElementById('copyButton').addEventListener('click',async()=>{ if(!currentSponsor)return; await navigator.clipboard.writeText(currentSponsor.id); const b=document.getElementById('copyButton'); const old=b.textContent;b.textContent='Copied ✓';setTimeout(()=>b.textContent=old,1400) }); diff --git a/public/styles.css b/public/styles.css index 8b139b0..72a51ca 100644 --- a/public/styles.css +++ b/public/styles.css @@ -46,3 +46,6 @@ a{color:inherit}.wrap{width:min(1160px,calc(100% - 32px));margin:auto}.nav{heigh .chat-hidden{display:none} .chat-typing{letter-spacing:3px;animation:chatpulse 1s infinite} @keyframes chatpulse{50%{opacity:.35}} + +.instruction{grid-template-columns:56px 1fr} +.num{width:56px;height:56px;font-size:28px;border-radius:14px} diff --git a/server.js b/server.js index d041a38..e4a1117 100644 --- a/server.js +++ b/server.js @@ -47,6 +47,49 @@ RULES: - Only answer questions about this project, the site, wallets/POL as they relate to joining, and the team process. For anything else, or anything you are not sure about, say you're not sure and point them to the Telegram group: ${c.telegramUrl || 'https://t.me/cryptoteambuild'} - Never give financial, legal, or tax advice.`; } +const SUBMISSIONS_FILE = path.join(DATA_DIR, 'submissions.json'); +if (!fs.existsSync(SUBMISSIONS_FILE)) fs.writeFileSync(SUBMISSIONS_FILE, '[]'); +const submitHits = new Map(); +function submitRateLimited(ip) { + const now = Date.now(), rec = submitHits.get(ip); + if (!rec || now > rec.reset) { submitHits.set(ip, { count: 1, reset: now + 600000 }); return false; } + rec.count++; return rec.count > 5; +} +function sendTelegram(text) { + const c = getConfig(); + if (!c.telegramBotToken || !c.telegramChatId) return; + fetch(`https://api.telegram.org/bot${c.telegramBotToken}/sendMessage`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ chat_id: c.telegramChatId, text }) + }).then(r=>{ if(!r.ok) console.error('telegram sendMessage status', r.status); }) + .catch(e=>console.error('telegram error', e.message)); +} +function firePostback(clickid, txid, source) { + const pb = getConfig().bemobPostbackUrl; + if (!clickid || !pb || !/^https:\/\/[a-z0-9.-]+\/postback/i.test(pb)) return; + fetch(`${pb}${pb.includes('?')?'&':'?'}cid=${encodeURIComponent(clickid)}&payout=0&txid=${encodeURIComponent(txid)}`) + .then(r=>{ if(r.ok) recordEvent('postback', source); else console.error('bemob postback status', r.status); }) + .catch(e=>console.error('bemob postback error', e.message)); +} +async function handleSubmitId(req, res) { + const ip = String(req.headers['x-forwarded-for']||req.socket.remoteAddress||'').split(',')[0].trim(); + if (submitRateLimited(ip)) return json(res, 429, { error: 'Too many submissions — please wait a few minutes.' }); + const b = await bodyJson(req).catch(()=>null); + if (!b) return json(res, 400, { error: 'Invalid request.' }); + const newId = String(b.newId||'').trim(); + if (!/^[0-9]{1,10}$/.test(newId)) return json(res, 400, { error: 'Enter your numeric RM Circle ID (numbers only).' }); + const sponsorId = String(b.sponsorId||'').trim().slice(0, 20).replace(/[^0-9A-Za-z._-]/g,'') || '?'; + const source = typeof b.source==='string' ? b.source : ''; + const clickid = typeof b.clickid==='string' ? b.clickid.trim().slice(0,80).replace(/[^A-Za-z0-9._-]/g,'') : ''; + let subs = []; try { subs = readJson(SUBMISSIONS_FILE); } catch(e) {} + if (subs.some(s=>s.newId===newId)) return json(res, 200, { ok: true, duplicate: true }); + subs.push({ newId, sponsorId, source: source||'(direct)', clickid, ts: new Date().toISOString() }); + writeJson(SUBMISSIONS_FILE, subs.slice(-1000)); + recordEvent('purchase', source); + firePostback(clickid, `purchase-${clickid}`, source); + sendTelegram(`🔔 RM Circle: NEW MEMBER CONFIRMED\nNew ID: ${newId}\nJoined under sponsor: ${sponsorId}\nSource: ${source||'(direct)'}\n→ Add ID ${newId} to the rotation queue.`); + return json(res, 200, { ok: true }); +} async function handleChat(req, res) { const ip = String(req.headers['x-forwarded-for']||req.socket.remoteAddress||'').split(',')[0].trim(); if (chatRateLimited(ip)) return json(res, 429, { error: 'Too many messages — give it a minute.' }); @@ -97,7 +140,7 @@ function getConfig() { return readJson(CONFIG_FILE); } 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'].includes(event)) return; + if (!['bridge','start','click','training','postback','purchase'].includes(event)) return; const s = String(source||'').toLowerCase().trim().replace(/[^a-z0-9.()\-_:/ ]/g,'').slice(0,80) || '(direct)'; const a = getAnalytics(); if (!a.sources) a.sources = {}; if (!a.sources[s]) { if (Object.keys(a.sources).length >= 500) return; a.sources[s] = { bridge:0, start:0, click:0 }; } @@ -162,16 +205,11 @@ async function handleApi(req,res,pathname){ if(req.method==='POST'&&pathname==='/api/public/join-click'){ const b=await bodyJson(req).catch(()=>({}));recordEvent('click',b.source); const clickid=typeof b.clickid==='string'?b.clickid.trim().slice(0,80).replace(/[^A-Za-z0-9._-]/g,''):''; - const pb=getConfig().bemobPostbackUrl; - if(clickid&&pb&&/^https:\/\/[a-z0-9.-]+\/postback/i.test(pb)){ - const src=b.source; - fetch(`${pb}${pb.includes('?')?'&':'?'}cid=${encodeURIComponent(clickid)}&payout=0&txid=${encodeURIComponent('join-'+clickid)}`) - .then(r=>{if(r.ok)recordEvent('postback',src);else console.error('bemob postback status',r.status)}) - .catch(e=>console.error('bemob postback error',e.message)); - } + firePostback(clickid,`join-${clickid}`,b.source); let sponsors=getSponsors();const a=activeSponsor(sponsors);if(a){sponsors=sponsors.map(s=>s.id===a.id?{...s,clicks:(s.clicks||0)+1}:s);saveSponsors(sponsors)}return json(res,200,{ok:true}); } if(req.method==='POST'&&pathname==='/api/public/chat')return await handleChat(req,res); + if(req.method==='POST'&&pathname==='/api/public/submit-id')return await handleSubmitId(req,res); if(req.method==='POST'&&pathname==='/api/public/track'){ const b=await bodyJson(req).catch(()=>({}));recordEvent(b.event,b.source);return json(res,200,{ok:true}); } @@ -183,7 +221,7 @@ async function handleApi(req,res,pathname){ const s=getSession(req);if(s)sessions.delete(s.token);return json(res,200,{ok:true},{'Set-Cookie':'ctb.sid=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0'}); } if(pathname.startsWith('/api/admin/')&&!requireAdmin(req,res))return; - if(req.method==='GET'&&pathname==='/api/admin/state')return json(res,200,{sponsors:getSponsors(),config:getConfig(),analytics:getAnalytics(),aiChat:{configured:!!getOpenRouterKey(),model:OPENROUTER_MODEL}}); + if(req.method==='GET'&&pathname==='/api/admin/state'){let subs=[];try{subs=readJson(SUBMISSIONS_FILE).slice(-50).reverse()}catch(e){}return json(res,200,{sponsors:getSponsors(),config:getConfig(),analytics:getAnalytics(),submissions:subs,aiChat:{configured:!!getOpenRouterKey(),model:OPENROUTER_MODEL}});} if(req.method==='POST'&&pathname==='/api/admin/openrouter-key'){ const b=await bodyJson(req);const key=typeof b.key==='string'?b.key.trim():null; if(key===null)return json(res,400,{error:'Invalid request.'}); @@ -197,7 +235,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()});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'])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','telegramChatId'])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.'});