Add Sponsor
diff --git a/public/admin.js b/public/admin.js
index 6641874..9e76008 100644
--- a/public/admin.js
+++ b/public/admin.js
@@ -34,6 +34,8 @@ function render(){
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 em=state.email||{};document.getElementById('emailStatus').innerHTML=em.configured?'
● Payment emails are ON — key is set':'
○ Payment emails are OFF — no SendGrid key';
+ const incEl=document.getElementById('incomeIds');if(incEl&&!incEl.value)incEl.value=state.config.ownerIds||'21,24,25';
+ if(!incomeAutoLoaded&&incEl&&incEl.value){incomeAutoLoaded=true;loadIncome();}
const efi=document.getElementById('emailFromInput');if(efi&&!efi.value)efi.value=state.config.emailFrom||em.from||'';
const f=document.getElementById('configForm'),c=state.config;for(const k of ['siteName','programName','bridgeHeadline','bridgeSubheadline','premiumEntryPol','dappReferralBaseUrl','telegramUrl','supportLabel','bemobPostbackUrl','telegramBotToken','telegramChatId','telegramTopicId','teamRootId','teamAlertEmail'])if(f.elements[k])f.elements[k].value=c[k]??'';f.elements.showSponsorName.checked=!!c.showSponsorName;f.elements.showQueueProgress.checked=!!c.showQueueProgress;
}
@@ -48,6 +50,23 @@ rows.addEventListener('change',async e=>{const sel=e.target.closest('select[data
document.getElementById('sgKeyForm').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/sendgrid-key',{method:'POST',body:JSON.stringify({key})});state.email={...(state.email||{}),configured:d.configured};inp.value='';render();showToast('Payment emails enabled')}catch(x){showToast(x.message)}});
document.getElementById('sgKeyClear').addEventListener('click',async()=>{if(!confirm('Disable "you\'ve been paid" emails?'))return;try{const d=await api('/api/admin/sendgrid-key',{method:'POST',body:JSON.stringify({key:''})});state.email={...(state.email||{}),configured:d.configured};render();showToast('Payment emails disabled')}catch(x){showToast(x.message)}});
document.getElementById('emailFromSave').addEventListener('click',async()=>{const v=document.getElementById('emailFromInput').value.trim();try{const d=await api('/api/admin/config',{method:'PATCH',body:JSON.stringify({emailFrom:v})});state.config=d.config;showToast('From address saved')}catch(x){showToast(x.message)}});
+let incomeAutoLoaded=false;
+async function loadIncome(){
+ const ids=(document.getElementById('incomeIds').value||'').trim();
+ const out=document.getElementById('incomeResult'),sum=document.getElementById('incomeSummary');
+ if(!ids||!/^[\d,\s]+$/.test(ids)){out.innerHTML='
Enter comma-separated numeric IDs (e.g. 21,24,25).
';return}
+ out.innerHTML='
Reading the blockchain…
';sum.innerHTML='';
+ try{
+ try{const c=await api('/api/admin/config',{method:'PATCH',body:JSON.stringify({ownerIds:ids})});state.config=c.config;}catch(_){}
+ const d=await api('/api/admin/income?ids='+encodeURIComponent(ids));
+ const fmt=n=>Number(n).toLocaleString(undefined,{maximumFractionDigits:2});
+ const date=ts=>ts?new Date(ts*1000).toISOString().replace('T',' ').slice(0,16):'—';
+ sum.innerHTML=`
Total received (all)${fmt(d.grandEarnedPol)} POL
`+
+ d.ids.map(id=>{const p=d.perId[id]||{};return `
#${id}${p.registered&&p.levelName?' · '+esc(p.levelName):''}${p.registered?fmt(p.totalEarnedPol)+' POL':'not registered'}${p.registered?`
${p.count} payment${p.count===1?'':'s'}
`:''}
`}).join('');
+ out.innerHTML=d.rows.length?`
| When (UTC) | To | From | For | Amount |
${d.rows.map(r=>`| ${date(r.ts)} | #${r.toId} | #${r.fromId} | ${esc(r.desc||'')} | ${fmt(r.pol)} POL |
`).join('')}
${d.rows.length} payment${d.rows.length===1?'':'s'} across your positions, newest first.
`:'
No payments recorded to these positions yet.
';
+ }catch(x){out.innerHTML=`
${esc(x.message)}
`}
+}
+document.getElementById('incomeForm').addEventListener('submit',e=>{e.preventDefault();loadIncome()});
document.getElementById('lookupForm').addEventListener('submit',async e=>{
e.preventDefault();
const id=document.getElementById('lookupId').value.trim(),out=document.getElementById('lookupResult');
diff --git a/server.js b/server.js
index 443c2d3..ebe9655 100644
--- a/server.js
+++ b/server.js
@@ -363,6 +363,26 @@ async function handleApi(req,res,pathname){
if(req.method==='GET'&&pathname==='/api/admin/matrix-tree'){
return json(res,200,chain.getMatrixTree());
}
+ if(req.method==='GET'&&pathname==='/api/admin/income'){
+ const raw=new URL(req.url,'http://x').searchParams.get('ids')||'';
+ const ids=[...new Set(raw.split(',').map(s=>parseInt(String(s).trim(),10)).filter(n=>Number.isInteger(n)&&n>0&&n<=281474976710655))].slice(0,12);
+ if(!ids.length)return json(res,400,{error:'Enter one or more numeric IDs (comma-separated).'});
+ try{
+ const results=await Promise.race([
+ Promise.all(ids.map(id=>chain.getIncome(id).catch(()=>({registered:false,id})))),
+ new Promise((_,rej)=>setTimeout(()=>rej(new Error('Blockchain lookup timed out — try again.')),25000))
+ ]);
+ const rows=[],perId={};let grand=0,grandListed=0;
+ for(const r of results){
+ if(!r.registered){perId[r.id]={registered:false};continue;}
+ perId[r.id]={registered:true,levelName:r.levelName,tierName:r.tierName,totalEarnedPol:r.totalEarnedPol,count:r.income.length};
+ grand+=r.totalEarnedPol;
+ for(const p of r.income){rows.push({toId:r.id,fromId:p.fromId,pol:p.pol,ts:p.ts,desc:p.desc});grandListed+=p.pol;}
+ }
+ rows.sort((a,b)=>(b.ts||0)-(a.ts||0));
+ return json(res,200,{ids,perId,rows:rows.slice(0,500),grandEarnedPol:+grand.toFixed(2),grandListedPol:+grandListed.toFixed(2)});
+ }catch(e){return json(res,502,{error:e.message||'Lookup failed'})}
+ }
if(req.method==='GET'&&pathname==='/api/admin/member-lookup'){
const id=Number(new URL(req.url,'http://x').searchParams.get('id')||0);
if(!Number.isInteger(id)||id<1||id>281474976710655)return json(res,400,{error:'Enter a numeric member ID.'});
@@ -393,7 +413,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(),email:String(email||'').trim().slice(0,120)});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','bemobPostbackUrl','telegramBotToken','telegramChatId','telegramTopicId','teamRootId','emailFrom','teamAlertEmail'])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','telegramBotToken','telegramChatId','telegramTopicId','teamRootId','emailFrom','teamAlertEmail','ownerIds'])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.'});