diff --git a/public/admin.html b/public/admin.html index b3e25f9..e731bc6 100644 --- a/public/admin.html +++ b/public/admin.html @@ -5,5 +5,5 @@

Traffic & Conversions

First-touch source per visitor session (referring domain or utm_source). Funnel: bridge page → start page → join click.

SourceBridge viewsStart viewsTraining viewsJoin clicksStart → Join

Add Sponsor

AI Chat

Paste an OpenRouter API key to switch the help chat from canned answers to AI (). Clear it to switch back.

-

Public Page Settings

+

Public Page Settings

diff --git a/public/admin.js b/public/admin.js index e360477..d527cf0 100644 --- a/public/admin.js +++ b/public/admin.js @@ -8,15 +8,16 @@ 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}; - 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;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}; + 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}}) .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)}
`; + `
Start → Join${pct(tot.click,tot.start)}
`+ + (tot.postback?`
BeMob postbacks${tot.postback}
`:''); 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]))} @@ -24,7 +25,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'])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'])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.js b/public/start.js index 24cf15d..0abdb03 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',headers:{'Content-Type':'application/json'},body:JSON.stringify({source:window.ctbGetSource?window.ctbGetSource():'(direct)'})}).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)',clickid:window.ctbGetClickId?window.ctbGetClickId():''})}).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/track.js b/public/track.js index 4f497d8..4a785f7 100644 --- a/public/track.js +++ b/public/track.js @@ -12,6 +12,12 @@ }catch(e){return '(direct)'} } window.ctbGetSource=getSource; + try{ + const p2=new URLSearchParams(location.search); + const cid=p2.get('clickid')||p2.get('cid'); + if(cid&&!sessionStorage.getItem('ctb.clickid'))sessionStorage.setItem('ctb.clickid',cid.slice(0,80)); + }catch(e){} + window.ctbGetClickId=function(){try{return sessionStorage.getItem('ctb.clickid')||''}catch(e){return ''}}; const path=location.pathname.replace(/\/$/,'')||'/'; const page=path==='/'?'bridge':(path==='/start'?'start':(path==='/training'?'training':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 bd1a7f3..d041a38 100644 --- a/server.js +++ b/server.js @@ -97,7 +97,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'].includes(event)) return; + if (!['bridge','start','click','training','postback'].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 }; } @@ -161,6 +161,14 @@ 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)); + } 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); @@ -189,7 +197,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'])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'])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.'});