diff --git a/chain.js b/chain.js index 6ba9699..798df92 100644 --- a/chain.js +++ b/chain.js @@ -514,6 +514,28 @@ function getOwnerUpgradeNeeds(ids) { return { ready: true, needs }; } +// How an organization rooted at `rootId` stacks up against the whole contract — +// share of members and share of all POL paid. Computed from in-memory state. +function getOrgShare(rootId) { + if (!state || !state.snapshotAt) return { ready: false }; + const totalMembers = Object.keys(state.members).length; + let totalPol = 0; for (const m of Object.values(state.members)) totalPol += m.earnedPol || 0; + const root = getSubtree(rootId, 99); + const rm = state.members[rootId]; + const base = { ready: true, rootId, totalMembers, totalPol: +totalPol.toFixed(2) }; + if (!root || !rm) return { ...base, found: false }; + const orgBelow = root.downCount || 0; + const orgMembers = orgBelow + 1; + const orgPol = (rm.earnedPol || 0) + (root.downPol || 0); + return { + ...base, found: true, orgBelow, orgMembers, + memberPct: +(100 * orgMembers / totalMembers).toFixed(1), + belowPct: +(100 * orgBelow / totalMembers).toFixed(1), + orgPol: +orgPol.toFixed(2), + polPct: totalPol ? +(100 * orgPol / totalPol).toFixed(1) : 0 + }; +} + // focused income read for one position — for the admin "my positions" income view async function getIncome(id) { const m = await fetchMember(id); @@ -528,4 +550,4 @@ async function getIncome(id) { }; } -module.exports = { startIndexer, getPayoutsPublic, verifyMember, memberLookup, memberPublic, getIncome, getOwnerUpgradeNeeds, getMatrixTree, isInTeam, CONTRACT }; +module.exports = { startIndexer, getPayoutsPublic, verifyMember, memberLookup, memberPublic, getIncome, getOwnerUpgradeNeeds, getOrgShare, getMatrixTree, isInTeam, CONTRACT }; diff --git a/public/admin.html b/public/admin.html index 97f9193..37a4923 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
+

Your Organization vs. the Network

How your team — rooted at your top ID — stacks up against the entire Crypto Team Build Network smart contract. Live on-chain.

Reading the blockchain…

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.

diff --git a/public/admin.js b/public/admin.js index 9989319..d1d381a 100644 --- a/public/admin.js +++ b/public/admin.js @@ -36,6 +36,8 @@ function render(){ 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 orgEl=document.getElementById('orgRoot');if(orgEl&&!orgEl.value)orgEl.value=state.config.orgRootId||'21'; + if(!orgShareAutoLoaded&&orgEl&&orgEl.value){orgShareAutoLoaded=true;loadOrgShare();} 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','ownerAlertEmail'])if(f.elements[k])f.elements[k].value=c[k]??'';f.elements.showSponsorName.checked=!!c.showSponsorName;f.elements.showQueueProgress.checked=!!c.showQueueProgress; } @@ -50,6 +52,29 @@ 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 orgShareAutoLoaded=false; +async function loadOrgShare(){ + const root=(document.getElementById('orgRoot').value||'').trim(); + const out=document.getElementById('orgShare'); + if(!root||!/^\d+$/.test(root)){out.innerHTML='
Enter a numeric top ID (e.g. 21).
';return} + out.innerHTML='
Reading the blockchain…
'; + try{ + try{const c=await api('/api/admin/config',{method:'PATCH',body:JSON.stringify({orgRootId:root})});state.config=c.config;}catch(_){} + const d=await api('/api/admin/org-share?root='+encodeURIComponent(root)); + if(!d.ready){out.innerHTML='
Snapshot not ready yet — try again in a minute.
';return} + if(!d.found){out.innerHTML=`
ID ${esc(root)} isn't in the indexed matrix yet.
`;return} + const fmt=n=>Number(n).toLocaleString(undefined,{maximumFractionDigits:0}); + out.innerHTML= + `
`+ + `
${d.memberPct}%
of all members
`+ + `
${d.polPct}%
of all POL paid
`+ + `
`+ + `
`+ + `

Your organization rooted at #${d.rootId} is ${d.orgMembers} of ${d.totalMembers} members in the whole contract (${d.orgBelow} below you + your position), and has earned ${fmt(d.orgPol)} of ${fmt(d.totalPol)} POL ever paid.

`+ + `
Your org members${d.orgMembers}
Whole network${d.totalMembers}
Your org earned${fmt(d.orgPol)} POL
Network paid${fmt(d.totalPol)} POL
`; + }catch(x){out.innerHTML=`
${esc(x.message)}
`} +} +document.getElementById('orgShareForm').addEventListener('submit',e=>{e.preventDefault();loadOrgShare()}); let incomeAutoLoaded=false; async function loadIncome(){ const ids=(document.getElementById('incomeIds').value||'').trim(); diff --git a/server.js b/server.js index a6a406f..d92d3b5 100644 --- a/server.js +++ b/server.js @@ -419,6 +419,12 @@ 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/org-share'){ + const raw=new URL(req.url,'http://x').searchParams.get('root'); + const root=Number(raw||parseOwnerIds()[0]||21); + if(!Number.isInteger(root)||root<1||root>281474976710655)return json(res,400,{error:'Enter a numeric root ID.'}); + return json(res,200,chain.getOrgShare(root)); + } 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); @@ -470,7 +476,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','ownerIds','ownerAlertEmail'])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','ownerAlertEmail','orgRootId'])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.'});