Add paid-traffic tracking: clickid capture and BeMob conversion postback

- track.js stores ?clickid= (or ?cid=) for the session; rides through
  bridge -> start navigation alongside the existing source attribution.
- Join click (the conversion) sends the clickid; server fires a
  fire-and-forget S2S postback to the admin-configured BeMob postback
  URL (cid + txid=join-<clickid>, payout 0) and counts successes per
  source in analytics.
- New admin setting: BeMob postback URL (server-side only, not exposed
  in public config).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-08-12 11:26:34 -05:00
parent c1ec687768
commit 63e8b4cdd0
5 changed files with 23 additions and 8 deletions
+5 -4
View File
@@ -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=
`<div class="fact"><small>Bridge views</small><strong>${tot.bridge}</strong></div>`+
`<div class="fact"><small>Start views</small><strong>${tot.start}</strong></div>`+
`<div class="fact"><small>Join clicks</small><strong>${tot.click}</strong></div>`+
`<div class="fact"><small>Bridge → Start</small><strong>${pct(tot.start,tot.bridge)}</strong></div>`+
`<div class="fact"><small>Start → Join</small><strong>${pct(tot.click,tot.start)}</strong></div>`;
`<div class="fact"><small>Start → Join</small><strong>${pct(tot.click,tot.start)}</strong></div>`+
(tot.postback?`<div class="fact"><small>BeMob postbacks</small><strong>${tot.postback}</strong></div>`:'');
document.getElementById('trafficRows').innerHTML=list.map(r=>`<tr><td><strong>${esc(r.name)}</strong></td><td>${r.bridge}</td><td>${r.start}</td><td>${r.training}</td><td>${r.click}</td><td>${pct(r.click,r.start)}</td></tr>`).join('')||'<tr><td colspan="6" class="empty">No traffic recorded yet.</td></tr>';
}
function esc(s){return String(s??'').replace(/[&<>'"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;',"'":'&#39;','"':'&quot;'}[c]))}
@@ -24,7 +25,7 @@ function render(){
const sponsors=[...state.sponsors].sort((a,b)=>a.sortOrder-b.sortOrder);rows.innerHTML=sponsors.map((s,i)=>`<tr><td>${i+1}</td><td><strong>${esc(s.name)}</strong><div class="micro" style="margin:2px 0 0">ID ${esc(s.id)}</div></td><td>${esc(s.parentId||'—')}</td><td><strong>${s.directs}/2</strong></td><td>${levelSelect(s)}</td><td><span class="${statusClass(s.status)}">${esc(s.status)}</span></td><td>${s.clicks||0}</td><td><div class="actions">${s.status!=='qualified'?`<button class="btn btn-secondary btn-sm" data-action="inc" data-id="${esc(s.id)}">+1 Direct</button><button class="btn btn-teal btn-sm" data-action="qualify" data-id="${esc(s.id)}">Qualified</button>`:`<button class="btn btn-secondary btn-sm" data-action="reset" data-id="${esc(s.id)}">Reset</button>`}${s.status==='waiting'?`<button class="btn btn-secondary btn-sm" data-action="activate" data-id="${esc(s.id)}">Make Active</button>`:''}<button class="btn btn-secondary btn-sm" data-action="up" data-id="${esc(s.id)}">↑</button><button class="btn btn-secondary btn-sm" data-action="down" data-id="${esc(s.id)}">↓</button><button class="btn btn-danger btn-sm" data-action="delete" data-id="${esc(s.id)}">×</button></div></td></tr>`).join('')||'<tr><td colspan="8" class="empty">No sponsors in the queue.</td></tr>';
renderAnalytics();
const ai=state.aiChat||{};document.getElementById('aiModel').textContent=ai.model||'OpenRouter';document.getElementById('aiStatus').innerHTML=ai.configured?'<span style="color:var(--ok)">● AI chat is ON</span> — key is set':'<span style="color:var(--muted)">○ AI chat is OFF</span> — 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()});