diff --git a/public/admin.html b/public/admin.html index 52ca58f..12b61b7 100644 --- a/public/admin.html +++ b/public/admin.html @@ -1,7 +1,8 @@ Crypto Team Build Admin
RM
Crypto Team BuildSponsor Router Admin

Team Admin

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

diff --git a/public/admin.js b/public/admin.js index 6eaec7e..5b9afa5 100644 --- a/public/admin.js +++ b/public/admin.js @@ -5,9 +5,24 @@ function showToast(msg){toast.textContent=msg;toast.classList.add('show');setTim async function api(url,opts={}){const r=await fetch(url,{headers:{'Content-Type':'application/json',...(opts.headers||{})},...opts});const d=await r.json().catch(()=>({}));if(!r.ok)throw new Error(d.error||'Request failed');return d} async function loadState(){try{state=await api('/api/admin/state');loginView.classList.add('hidden');adminView.classList.remove('hidden');render()}catch(e){loginView.classList.remove('hidden');adminView.classList.add('hidden')}} 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}; + const list=Object.entries(src).map(([name,r])=>{tot.bridge+=r.bridge||0;tot.start+=r.start||0;tot.click+=r.click||0;return{name,bridge:r.bridge||0,start:r.start||0,click:r.click||0}}) + .sort((a,b)=>b.click-a.click||b.start-a.start||b.bridge-a.bridge); + document.getElementById('funnelStats').innerHTML= + `
Bridge views${tot.bridge}
`+ + `
Start views${tot.start}
`+ + `
Join clicks${tot.click}
`+ + `
Bridge → Start${pct(tot.start,tot.bridge)}
`+ + `
Start → Join${pct(tot.click,tot.start)}
`; + document.getElementById('trafficRows').innerHTML=list.map(r=>`${esc(r.name)}${r.bridge}${r.start}${r.click}${pct(r.click,r.start)}`).join('')||'No traffic recorded yet.'; +} 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}${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 f=document.getElementById('configForm'),c=state.config;for(const k of ['siteName','programName','bridgeHeadline','bridgeSubheadline','premiumEntryPol','dappReferralBaseUrl','telegramUrl','supportLabel'])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}}); diff --git a/public/index.html b/public/index.html index 3f691a9..a0c3736 100644 --- a/public/index.html +++ b/public/index.html @@ -12,4 +12,4 @@
Depth over width

2 → 4 → 8 → 16 → 32

The first major team milestone is 30 correctly placed positions across the first four generations: 2 + 4 + 8 + 16.

2
4
8
16
Team principle: once your two are in place, do not keep adding more directs to the same qualified link. Help the next positions become qualified so the matrix develops depth instead of extra shallow legs.
Ready to start?

See the current team placement.

The onboarding page automatically shows the sponsor position the team is currently helping. Always use the sponsor shown there instead of an old screenshot or saved link.

Open Getting Started Instructions →
- + diff --git a/public/start.html b/public/start.html index 8d99576..dfa7111 100644 --- a/public/start.html +++ b/public/start.html @@ -16,4 +16,4 @@
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 cd37217..24cf15d 100644 --- a/public/start.js +++ b/public/start.js @@ -23,7 +23,7 @@ async function load(){ document.getElementById('copyButton').classList.add('hidden'); } } -document.getElementById('joinButton').addEventListener('click',()=>{fetch('/api/public/join-click',{method:'POST'}).catch(()=>{})}); +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)'})}).catch(()=>{})}); 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 0165dc0..7d28bda 100644 --- a/public/styles.css +++ b/public/styles.css @@ -15,3 +15,5 @@ a{color:inherit}.wrap{width:min(1160px,calc(100% - 32px));margin:auto}.nav{heigh .roadmap-figure img{display:block;width:100%;height:auto;border-radius:12px} .roadmap-figure figcaption{padding:12px 6px;color:var(--muted);font-size:14px;line-height:1.5;text-align:center} .roadmap-figure figcaption strong{color:var(--teal)} + +.funnel{display:grid;grid-template-columns:repeat(auto-fit,minmax(130px,1fr));gap:10px;margin:14px 0} diff --git a/public/track.js b/public/track.js new file mode 100644 index 0000000..f910d15 --- /dev/null +++ b/public/track.js @@ -0,0 +1,18 @@ +(function(){ + function getSource(){ + try{ + let s=sessionStorage.getItem('ctb.src'); + if(s)return s; + const p=new URLSearchParams(location.search); + s=p.get('utm_source')||p.get('src')||''; + if(!s&&document.referrer){try{const h=new URL(document.referrer).hostname;if(h&&h!==location.hostname)s=h}catch(e){}} + s=s||'(direct)'; + sessionStorage.setItem('ctb.src',s); + return s; + }catch(e){return '(direct)'} + } + window.ctbGetSource=getSource; + const path=location.pathname.replace(/\/$/,'')||'/'; + const page=path==='/'?'bridge':(path==='/start'?'start':null); + if(page)fetch('/api/public/track',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({event:page,source:getSource()})}).catch(()=>{}); +})(); diff --git a/server.js b/server.js index 6b63bac..5afe879 100644 --- a/server.js +++ b/server.js @@ -24,6 +24,8 @@ function ensureDataFile(name) { } ensureDataFile('sponsors.json'); ensureDataFile('config.json'); +const ANALYTICS_FILE = path.join(DATA_DIR, 'analytics.json'); +if (!fs.existsSync(ANALYTICS_FILE)) fs.writeFileSync(ANALYTICS_FILE, JSON.stringify({ sources: {} }, null, 2)); function readJson(file) { return JSON.parse(fs.readFileSync(file, 'utf8')); } function writeJson(file, data) { @@ -35,6 +37,15 @@ function getSponsors() { return readJson(SPONSORS_FILE).sort((a,b)=>(a.sortOrder function saveSponsors(s) { writeJson(SPONSORS_FILE, s); } 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'].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 }; } + a.sources[s][event] = (a.sources[s][event]||0) + 1; + writeJson(ANALYTICS_FILE, a); +} function normalizeStatuses(sponsors, preferredActiveId=null) { const eligible=sponsors.filter(s=>s.status!=='qualified'); let activeId=preferredActiveId; @@ -75,8 +86,12 @@ async function handleApi(req,res,pathname){ 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.'}); } if(req.method==='POST'&&pathname==='/api/public/join-click'){ + const b=await bodyJson(req).catch(()=>({}));recordEvent('click',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/track'){ + const b=await bodyJson(req).catch(()=>({}));recordEvent(b.event,b.source);return json(res,200,{ok:true}); + } if(req.method==='POST'&&pathname==='/api/admin/login'){ const b=await bodyJson(req).catch(e=>null);if(!b)return json(res,400,{error:'Invalid request'});if(typeof b.password!=='string'||b.password!==ADMIN_PASSWORD)return json(res,401,{error:'Invalid password'}); const token=crypto.randomBytes(32).toString('hex');sessions.set(token,{expires:Date.now()+SESSION_TTL});const cookie=`ctb.sid=${encodeURIComponent(token)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${SESSION_TTL/1000}${IS_PROD?'; Secure':''}`;return json(res,200,{ok:true},{'Set-Cookie':cookie}); @@ -85,7 +100,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()}); + if(req.method==='GET'&&pathname==='/api/admin/state')return json(res,200,{sponsors:getSponsors(),config:getConfig(),analytics:getAnalytics()}); if(req.method==='POST'&&pathname==='/api/admin/sponsors'){ const b=await bodyJson(req);const {id,name,parentId='',level='Scintilla',notes=''}=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.'});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()});sponsors=normalizeStatuses(sponsors);saveSponsors(sponsors);return json(res,201,{sponsors});