From 4115d3a9201713cd511a45eb0edf3b80b73a8cfe Mon Sep 17 00:00:00 2001 From: martbost Date: Tue, 11 Aug 2026 08:07:41 -0500 Subject: [PATCH] Add simple traffic analytics with source attribution and conversion funnel - track.js captures first-touch source per session (utm_source/src param or external referrer domain) and logs bridge/start page views. - Join clicks now carry the source; all events aggregate per-source in data/analytics.json on the persistent volume. - Admin: new Traffic & Conversions card with funnel totals (bridge -> start -> join click rates) and a per-source breakdown table. Co-Authored-By: Claude Fable 5 --- public/admin.html | 3 ++- public/admin.js | 15 +++++++++++++++ public/index.html | 2 +- public/start.html | 2 +- public/start.js | 2 +- public/styles.css | 2 ++ public/track.js | 18 ++++++++++++++++++ server.js | 17 ++++++++++++++++- 8 files changed, 56 insertions(+), 5 deletions(-) create mode 100644 public/track.js 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
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 →
This independent team page is educational and is not an earnings guarantee or investment advice. Cryptocurrency and smart-contract participation involve risk, including possible loss of funds. Never use funds you cannot afford to lose. Results depend on actual participation, qualification, upgrades, smart-contract behavior, and the market value of POL.
- + 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.
This is an independent Crypto Team Build onboarding resource, not an owner/principal page. Always confirm transaction details in your wallet before signing. Never disclose your Secret Recovery Phrase.
- + 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});