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 <noreply@anthropic.com>
This commit is contained in:
@@ -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});
|
||||
|
||||
Reference in New Issue
Block a user