Files
rm-circle-team-router/public/admin.js
T
martbost 63e8b4cdd0 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>
2026-08-12 11:26:34 -05:00

39 lines
8.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const loginView=document.getElementById('loginView'),adminView=document.getElementById('adminView'),rows=document.getElementById('sponsorRows'),toast=document.getElementById('toast');let state=null;
const LEVELS=['Scintilla','Ascensus','Fabrica','Culmen','Apex','Fastigium','Vertex','Corona'];
function levelSelect(s){const opts=(LEVELS.includes(s.level)?LEVELS:[s.level,...LEVELS]).map(l=>`<option${l===s.level?' selected':''}>${esc(l)}</option>`).join('');return `<select class="select select-inline" data-action="level" data-id="${esc(s.id)}">${opts}</select>`}
function showToast(msg){toast.textContent=msg;toast.classList.add('show');setTimeout(()=>toast.classList.remove('show'),1800)}
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,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>`+
(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]))}
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','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()});
document.getElementById('addSponsorForm').addEventListener('submit',async e=>{e.preventDefault();const form=e.currentTarget,obj=Object.fromEntries(new FormData(form));try{const d=await api('/api/admin/sponsors',{method:'POST',body:JSON.stringify(obj)});state.sponsors=d.sponsors;form.reset();render();showToast('Sponsor added')}catch(x){showToast(x.message)}});
document.getElementById('configForm').addEventListener('submit',async e=>{e.preventDefault();const f=e.currentTarget,obj=Object.fromEntries(new FormData(f));obj.showSponsorName=f.elements.showSponsorName.checked;obj.showQueueProgress=f.elements.showQueueProgress.checked;obj.premiumEntryPol=Number(obj.premiumEntryPol);try{const d=await api('/api/admin/config',{method:'PATCH',body:JSON.stringify(obj)});state.config=d.config;render();showToast('Settings saved')}catch(x){showToast(x.message)}});
rows.addEventListener('click',async e=>{const b=e.target.closest('button[data-action]');if(!b)return;const id=b.dataset.id,a=b.dataset.action;try{let d;if(a==='delete'){if(!confirm(`Delete sponsor ID ${id}?`))return;d=await api(`/api/admin/sponsors/${id}`,{method:'DELETE'})}else if(a==='inc')d=await api(`/api/admin/sponsors/${id}/increment`,{method:'POST'});else if(a==='qualify')d=await api(`/api/admin/sponsors/${id}/qualify`,{method:'POST'});else if(a==='activate')d=await api(`/api/admin/sponsors/${id}/activate`,{method:'POST'});else if(a==='reset')d=await api(`/api/admin/sponsors/${id}/reset`,{method:'POST'});else if(a==='up'||a==='down')d=await api(`/api/admin/sponsors/${id}/move`,{method:'POST',body:JSON.stringify({direction:a})});if(d&&d.sponsors){state.sponsors=d.sponsors;render();showToast('Updated')}}catch(x){showToast(x.message)}});
document.getElementById('aiKeyForm').addEventListener('submit',async e=>{e.preventDefault();const inp=e.currentTarget.elements.key,key=inp.value.trim();if(!key){showToast('Paste a key first');return}try{const d=await api('/api/admin/openrouter-key',{method:'POST',body:JSON.stringify({key})});state.aiChat={...(state.aiChat||{}),configured:d.configured};inp.value='';render();showToast('AI chat enabled')}catch(x){showToast(x.message)}});
document.getElementById('aiKeyClear').addEventListener('click',async()=>{if(!confirm('Turn off AI chat and go back to built-in answers?'))return;try{const d=await api('/api/admin/openrouter-key',{method:'POST',body:JSON.stringify({key:''})});state.aiChat={...(state.aiChat||{}),configured:d.configured};render();showToast('AI chat disabled')}catch(x){showToast(x.message)}});
rows.addEventListener('change',async e=>{const sel=e.target.closest('select[data-action="level"]');if(!sel)return;try{const d=await api(`/api/admin/sponsors/${sel.dataset.id}`,{method:'PATCH',body:JSON.stringify({level:sel.value})});state.sponsors=d.sponsors;render();showToast('Level updated')}catch(x){showToast(x.message);render()}});
loadState();