diff --git a/chain.js b/chain.js index 9e3e58f..d17cd24 100644 --- a/chain.js +++ b/chain.js @@ -477,4 +477,18 @@ async function memberPublic(id) { return out; } -module.exports = { startIndexer, getPayoutsPublic, verifyMember, memberLookup, memberPublic, getMatrixTree, isInTeam, CONTRACT }; +// focused income read for one position — for the admin "my positions" income view +async function getIncome(id) { + const m = await fetchMember(id); + if (!m) return { registered: false, id }; + await fetchCosts().catch(() => {}); + let inc = []; + try { inc = await fetchIncome(id); } catch (e) { inc = []; } + return { + registered: true, id, levelName: levelName(m.level), tierName: tierName(m.tier), + totalEarnedPol: m.totalEarnedPol, directCount: m.directCount, + income: inc.map(p => ({ fromId: p.fromId, pol: p.pol, ts: p.ts, desc: describeIncome(p.fromTier, p.level, p.pol) })).reverse() + }; +} + +module.exports = { startIndexer, getPayoutsPublic, verifyMember, memberLookup, memberPublic, getIncome, getMatrixTree, isInTeam, CONTRACT }; diff --git a/public/admin.html b/public/admin.html index 9277ad0..855d5b8 100644 --- a/public/admin.html +++ b/public/admin.html @@ -4,6 +4,7 @@

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 viewsInvite viewsJoin clicksStart → Join

Member ID Submissions

New members who confirmed their purchase on the start page. Each one was posted to your Hermes Telegram chat — add them to the rotation.

WhenName / HandleNew IDJoined underSourceOn-chain
+

My Positions — Income

Every payment received by your own positions, live from the contract. Comma-separated IDs — saved for next time.

On-Chain Member Lookup

Enter an RM Circle ID to read its registration, lineage, and every payment it has received — live from the smart contract.

Matrix View

The entire on-chain matrix — who landed where, with tier, level, directs, and earnings per position. Click a position to drill down.

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?`
${d.rows.map(r=>``).join('')}
When (UTC)ToFromForAmount
${date(r.ts)}#${r.toId}#${r.fromId}${esc(r.desc||'')}${fmt(r.pol)} POL

${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.'});