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 @@
RM
Crypto Team BuildSponsor Router Admin

Team Admin

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

+

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