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

Sponsor Queue

Mark a sponsor qualified to automatically activate the next waiting position.

OrderSponsorParentDirectsLevelStatusClicksActions

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

diff --git a/public/admin.js b/public/admin.js index 0270fb0..e360477 100644 --- a/public/admin.js +++ b/public/admin.js @@ -23,6 +23,7 @@ function esc(s){return String(s??'').replace(/[&<>'"]/g,c=>({'&':'&','<':'&l 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; } 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}}); @@ -30,5 +31,7 @@ document.getElementById('logoutBtn').addEventListener('click',async()=>{await ap 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(); diff --git a/server.js b/server.js index d5813fe..bd1a7f3 100644 --- a/server.js +++ b/server.js @@ -15,8 +15,12 @@ const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'changeme'; const IS_PROD = process.env.NODE_ENV === 'production'; const SESSION_TTL = 8 * 60 * 60 * 1000; const LEVELS = ['Scintilla','Ascensus','Fabrica','Culmen','Apex','Fastigium','Vertex','Corona']; -const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY || ''; const OPENROUTER_MODEL = process.env.OPENROUTER_MODEL || 'deepseek/deepseek-v4-flash:nitro'; +const OPENROUTER_KEY_FILE = path.join(DATA_DIR, 'openrouter.key'); +function getOpenRouterKey() { + if (process.env.OPENROUTER_API_KEY) return process.env.OPENROUTER_API_KEY; + try { return fs.readFileSync(OPENROUTER_KEY_FILE, 'utf8').trim(); } catch (e) { return ''; } +} const chatHits = new Map(); function chatRateLimited(ip) { const now = Date.now(), rec = chatHits.get(ip); @@ -46,7 +50,8 @@ RULES: async function handleChat(req, res) { const ip = String(req.headers['x-forwarded-for']||req.socket.remoteAddress||'').split(',')[0].trim(); if (chatRateLimited(ip)) return json(res, 429, { error: 'Too many messages — give it a minute.' }); - if (!OPENROUTER_API_KEY) return json(res, 200, { fallback: true }); + const apiKey = getOpenRouterKey(); + if (!apiKey) return json(res, 200, { fallback: true }); const b = await bodyJson(req).catch(()=>null); if (!b || !Array.isArray(b.messages)) return json(res, 400, { error: 'Invalid request' }); const msgs = b.messages.slice(-8) @@ -57,7 +62,7 @@ async function handleChat(req, res) { const ctrl = new AbortController(); const timer = setTimeout(()=>ctrl.abort(), 20000); const r = await fetch('https://openrouter.ai/api/v1/chat/completions', { method: 'POST', signal: ctrl.signal, - headers: { 'Authorization': `Bearer ${OPENROUTER_API_KEY}`, 'Content-Type': 'application/json', 'HTTP-Referer': 'https://rmcircle.saasy.top', 'X-Title': 'RM Circle Team Help' }, + headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json', 'HTTP-Referer': 'https://rmcircle.saasy.top', 'X-Title': 'RM Circle Team Help' }, body: JSON.stringify({ model: OPENROUTER_MODEL, max_tokens: 350, temperature: 0.3, messages: [{ role: 'system', content: chatSystemPrompt() }, ...msgs] }) }); clearTimeout(timer); @@ -170,7 +175,15 @@ 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(),analytics:getAnalytics()}); + if(req.method==='GET'&&pathname==='/api/admin/state')return json(res,200,{sponsors:getSponsors(),config:getConfig(),analytics:getAnalytics(),aiChat:{configured:!!getOpenRouterKey(),model:OPENROUTER_MODEL}}); + if(req.method==='POST'&&pathname==='/api/admin/openrouter-key'){ + const b=await bodyJson(req);const key=typeof b.key==='string'?b.key.trim():null; + if(key===null)return json(res,400,{error:'Invalid request.'}); + if(key===''){try{fs.unlinkSync(OPENROUTER_KEY_FILE)}catch(e){}return json(res,200,{configured:!!getOpenRouterKey()});} + if(key.length<20||/\s/.test(key))return json(res,400,{error:'That does not look like a valid API key.'}); + fs.writeFileSync(OPENROUTER_KEY_FILE,key,{mode:0o600}); + return json(res,200,{configured:true}); + } 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});