Dashboard 'My Next Step' panel + funded badge

Adds a synthesized next-step panel to /my: the level ladder (you-are-here →
upgrade-next), the single clearest action (qualify vs upgrade), and a funded
badge that checks the member's wallet against their next upgrade cost.

Privacy: the wallet balance is checked server-side (chain.balanceOf via
eth_getBalance) and ONLY a funded true/false is returned — the raw balance is
never exposed on the ID-addressable dashboard.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-08-17 06:33:59 -05:00
parent f4481a390b
commit a3e9d05f5d
5 changed files with 71 additions and 1 deletions
+9 -1
View File
@@ -101,6 +101,14 @@ const pol = wei => Number(wei / 1000000000000n) / 1e6; // 6-decimal POL
const encU = v => BigInt(v).toString(16).padStart(64, '0'); const encU = v => BigInt(v).toString(16).padStart(64, '0');
async function ethCall(data) { return await rpc('eth_call', [{ to: CONTRACT, data }, 'latest']); } async function ethCall(data) { return await rpc('eth_call', [{ to: CONTRACT, data }, 'latest']); }
// Native POL balance of a wallet (public on-chain data). Used ONLY to derive a
// funded / not-funded flag for a member's next upgrade — the API never returns
// the raw balance, only the boolean, to keep members' holdings private.
async function balanceOf(address) {
if (!/^0x[0-9a-fA-F]{40}$/.test(address || '')) return null;
try { const hex = await rpc('eth_getBalance', [address, 'latest'], 6000); return Number(BigInt(hex)) / 1e18; }
catch (e) { return null; }
}
function decodeMemberStruct(r) { function decodeMemberStruct(r) {
// members(uint48): account, joinedAt, referrerId, uplineId, tier, level, directCount, // members(uint48): account, joinedAt, referrerId, uplineId, tier, level, directCount,
@@ -617,4 +625,4 @@ async function getIncome(id) {
}; };
} }
module.exports = { startIndexer, getPayoutsPublic, verifyMember, memberLookup, memberPublic, getIncome, getOwnerUpgradeNeeds, getOrgRouting, getOrgShare, getMatrixTree, isInTeam, CONTRACT }; module.exports = { startIndexer, getPayoutsPublic, verifyMember, memberLookup, memberPublic, getIncome, getOwnerUpgradeNeeds, getOrgRouting, getOrgShare, getMatrixTree, isInTeam, balanceOf, CONTRACT };
+1
View File
@@ -7,6 +7,7 @@
<div id="coldIntro" class="hidden cold-intro"><button id="coldIntroClose" class="cold-intro-x" title="Dismiss">×</button><div class="eyebrow">New here? What you're looking at</div><p style="margin:8px 0 12px;line-height:1.6">This is a live member dashboard for the <strong>RM Circle Premium team build</strong> — a team-first strategy on the Polygon blockchain where every payment happens on a public smart contract that nobody can fake or edit. The plan is simple: <strong>get your 2, then help your 2 get their 2.</strong> Every number on this page is this member's real position, read straight from the blockchain.</p><div style="display:flex;gap:10px;flex-wrap:wrap"><a id="coldIntroJoin" class="btn btn-primary btn-sm" href="/join">Read your invitation from this member →</a><a class="btn btn-secondary btn-sm" href="/">How the strategy works</a></div><p class="micro" style="margin:10px 0 0">Independent team training resource • Participation involves risk • No income is guaranteed</p></div> <div id="coldIntro" class="hidden cold-intro"><button id="coldIntroClose" class="cold-intro-x" title="Dismiss">×</button><div class="eyebrow">New here? What you're looking at</div><p style="margin:8px 0 12px;line-height:1.6">This is a live member dashboard for the <strong>RM Circle Premium team build</strong> — a team-first strategy on the Polygon blockchain where every payment happens on a public smart contract that nobody can fake or edit. The plan is simple: <strong>get your 2, then help your 2 get their 2.</strong> Every number on this page is this member's real position, read straight from the blockchain.</p><div style="display:flex;gap:10px;flex-wrap:wrap"><a id="coldIntroJoin" class="btn btn-primary btn-sm" href="/join">Read your invitation from this member →</a><a class="btn btn-secondary btn-sm" href="/">How the strategy works</a></div><p class="micro" style="margin:10px 0 0">Independent team training resource • Participation involves risk • No income is guaranteed</p></div>
<div style="display:flex;justify-content:space-between;align-items:center;gap:14px;flex-wrap:wrap;margin-bottom:6px"><div><div class="eyebrow">Member position</div><h1 style="margin:6px 0 0">Position <span class="gold" id="dTitle">#—</span> <span id="dVerified" class="live-badge" style="vertical-align:middle;margin-left:8px"><span class="dot"></span> Verified on-chain</span> <span id="dQualified"></span></h1></div><button id="switchId" class="btn btn-secondary btn-sm">Look up another ID</button></div> <div style="display:flex;justify-content:space-between;align-items:center;gap:14px;flex-wrap:wrap;margin-bottom:6px"><div><div class="eyebrow">Member position</div><h1 style="margin:6px 0 0">Position <span class="gold" id="dTitle">#—</span> <span id="dVerified" class="live-badge" style="vertical-align:middle;margin-left:8px"><span class="dot"></span> Verified on-chain</span> <span id="dQualified"></span></h1></div><button id="switchId" class="btn btn-secondary btn-sm">Look up another ID</button></div>
<div id="dFacts" class="facts" style="grid-template-columns:repeat(3,1fr);margin:18px 0"></div> <div id="dFacts" class="facts" style="grid-template-columns:repeat(3,1fr);margin:18px 0"></div>
<div id="dNextStep"></div>
<div class="table-card" style="margin-bottom:18px"><div style="display:flex;justify-content:space-between;gap:12px;align-items:flex-start;flex-wrap:wrap"><div><h2 style="margin:0 0 4px">Your team</h2><p style="color:var(--muted);font-size:13px;margin:0 0 14px">Your position's matrix — the rollup line on each card counts everyone underneath, all the way down. Click a position to drill into that leg. Open slots are where the next placements land.</p></div><button id="dTreeToggle" class="btn btn-secondary btn-sm">List view</button></div><div id="dTreeNav" style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-bottom:12px"></div><div id="dTree"></div><p class="micro" style="margin:14px 0 0"><span class="mtp-qmark" style="position:static;display:inline-grid;vertical-align:middle">✓</span> qualified (2/2 directs) · <span class="mt-badge mt-prem">P</span> Premium · <span class="mt-badge">S</span> Standard · ⬇ everyone below that position (all generations) and the POL they've earned · <span style="color:var(--teal)">↧ spillover</span> = placed there by upline activity; only members who join with a position's own ID count toward its 2/2</p><div id="dSpillNote" class="hidden"></div></div> <div class="table-card" style="margin-bottom:18px"><div style="display:flex;justify-content:space-between;gap:12px;align-items:flex-start;flex-wrap:wrap"><div><h2 style="margin:0 0 4px">Your team</h2><p style="color:var(--muted);font-size:13px;margin:0 0 14px">Your position's matrix — the rollup line on each card counts everyone underneath, all the way down. Click a position to drill into that leg. Open slots are where the next placements land.</p></div><button id="dTreeToggle" class="btn btn-secondary btn-sm">List view</button></div><div id="dTreeNav" style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-bottom:12px"></div><div id="dTree"></div><p class="micro" style="margin:14px 0 0"><span class="mtp-qmark" style="position:static;display:inline-grid;vertical-align:middle">✓</span> qualified (2/2 directs) · <span class="mt-badge mt-prem">P</span> Premium · <span class="mt-badge">S</span> Standard · ⬇ everyone below that position (all generations) and the POL they've earned · <span style="color:var(--teal)">↧ spillover</span> = placed there by upline activity; only members who join with a position's own ID count toward its 2/2</p><div id="dSpillNote" class="hidden"></div></div>
<div class="table-card" style="margin-bottom:18px;border-color:rgba(123,224,161,.4)"><h2 style="margin:0 0 4px">Your pipeline</h2><p style="color:var(--muted);font-size:13px;margin:0 0 12px">Money forming below you. Each generation in your leg pays your position at exactly one level — when a member's level catches up to their depth, their <em>next</em> upgrade comes to you.</p><div id="dPipeline"></div></div> <div class="table-card" style="margin-bottom:18px;border-color:rgba(123,224,161,.4)"><h2 style="margin:0 0 4px">Your pipeline</h2><p style="color:var(--muted);font-size:13px;margin:0 0 12px">Money forming below you. Each generation in your leg pays your position at exactly one level — when a member's level catches up to their depth, their <em>next</em> upgrade comes to you.</p><div id="dPipeline"></div></div>
<div class="table-card" style="margin-bottom:18px;border-color:rgba(78,214,203,.35)"><h2 style="margin:0 0 4px">Email me my alerts</h2><p style="color:var(--muted);font-size:13px;margin:0 0 12px">Get an email the moment this position is <strong>paid</strong>, and when it <strong>needs an upgrade</strong> to catch incoming pay — so you never miss one. Opt in with your email; unsubscribe anytime.</p><div id="dAlerts"></div></div> <div class="table-card" style="margin-bottom:18px;border-color:rgba(78,214,203,.35)"><h2 style="margin:0 0 4px">Email me my alerts</h2><p style="color:var(--muted);font-size:13px;margin:0 0 12px">Get an email the moment this position is <strong>paid</strong>, and when it <strong>needs an upgrade</strong> to catch incoming pay — so you never miss one. Opt in with your email; unsubscribe anytime.</p><div id="dAlerts"></div></div>
+28
View File
@@ -99,6 +99,7 @@
document.getElementById('dIncome').innerHTML=inc.length document.getElementById('dIncome').innerHTML=inc.length
?`<div class="table-wrap"><table class="table" style="min-width:520px"><thead><tr><th>When</th><th>From member</th><th>For</th><th>Amount</th></tr></thead><tbody>${inc.map(p=>`<tr><td>${date(p.ts)}</td><td>#${p.fromId}</td><td>${esc(p.desc)}</td><td><strong>${fmt(p.pol)} POL</strong></td></tr>`).join('')}</tbody></table></div>` ?`<div class="table-wrap"><table class="table" style="min-width:520px"><thead><tr><th>When</th><th>From member</th><th>For</th><th>Amount</th></tr></thead><tbody>${inc.map(p=>`<tr><td>${date(p.ts)}</td><td>#${p.fromId}</td><td>${esc(p.desc)}</td><td><strong>${fmt(p.pol)} POL</strong></td></tr>`).join('')}</tbody></table></div>`
:'<div class="empty">No payments yet — they appear here the moment they land on-chain.</div>'; :'<div class="empty">No payments yet — they appear here the moment they land on-chain.</div>';
renderNextStep(d);
renderPipeline(d); renderPipeline(d);
renderAlerts(d); renderAlerts(d);
renderShare(d); renderShare(d);
@@ -113,6 +114,33 @@
}catch(e){return ''} }catch(e){return ''}
} }
const LEVELS=['Scintilla','Ascensus','Fabrica','Culmen','Apex','Fastigium','Vertex','Corona']; const LEVELS=['Scintilla','Ascensus','Fabrica','Culmen','Apex','Fastigium','Vertex','Corona'];
// "My Next Step" — the single clearest action + the level ladder + a funded
// badge (server tells us funded true/false; it never sends the raw balance).
function renderNextStep(d){
const el=document.getElementById('dNextStep');if(!el)return;
const ns=d.nextStep||{},lvl=d.level||1;
const ladder=LEVELS.map((nm,i)=>{
const L=i+1;let cls='nlv';
if(L<lvl)cls+=' done';else if(L===lvl)cls+=' you';else if(ns.kind==='upgrade'&&L===ns.nextLevel)cls+=' next';
const tag=L===lvl?'<span class="nlvt">YOU</span>':(ns.kind==='upgrade'&&L===ns.nextLevel?'<span class="nlvt">NEXT</span>':'');
return `<div class="${cls}">${tag}<span class="nlvn">${nm}</span></div>`;
}).join('');
let head='',body='',badge='';
if(ns.kind==='qualify'){
head='Your next step — get qualified';
body=`You need <strong>${ns.need} more direct${ns.need===1?'':'s'}</strong> (people who join on <em>your</em> link) to unlock upgrade payments. Grab your link from <a href="#dShare" style="color:var(--teal)">Share this position</a> below and go get them.`;
}else if(ns.kind==='upgrade'){
const nm=LEVELS[ns.nextLevel-1]||('Level '+ns.nextLevel);
head=`Your next step — upgrade to ${esc(nm)}`;
body=`Stay one level ahead of your deepest active team so the pass-ups from below land on <em>you</em> instead of skipping past. Upgrade with your earned POL when you can.`;
if(ns.funded===true)badge='<span class="ns-badge ok">✓ Funded — you can upgrade now</span>';
else if(ns.funded===false)badge='<span class="ns-badge no">Keep building — not funded yet</span>';
}else if(ns.kind==='max'){
head="You're at the top level 🏆";
body='Keep helping your team duplicate — every level they climb still pays up to you.';
}else{el.innerHTML='';return;}
el.innerHTML=`<div class="ns-card"><div class="ns-top"><div class="ns-eyebrow">Your plan</div>${badge}</div><div class="ns-h">${head}</div><p class="ns-p">${body}</p><div class="nladder">${ladder}</div></div>`;
}
function renderPipeline(d){ function renderPipeline(d){
const el=document.getElementById('dPipeline'); const el=document.getElementById('dPipeline');
if(!el)return; if(!el)return;
+18
View File
@@ -74,6 +74,24 @@ a{color:inherit}.wrap{width:min(1160px,calc(100% - 32px));margin:auto}.nav{heigh
.banner-cell .meta{display:flex;justify-content:space-between;align-items:baseline;gap:8px;margin:12px 2px 0} .banner-cell .meta{display:flex;justify-content:space-between;align-items:baseline;gap:8px;margin:12px 2px 0}
.banner-cell .sz{font-weight:800;color:var(--text);font-size:15px}.banner-cell .use{color:var(--muted);font-size:12px;text-align:right} .banner-cell .sz{font-weight:800;color:var(--text);font-size:15px}.banner-cell .use{color:var(--muted);font-size:12px;text-align:right}
.banner-cell .acts{display:flex;gap:8px;margin-top:12px}.banner-cell .acts .btn{flex:1} .banner-cell .acts{display:flex;gap:8px;margin-top:12px}.banner-cell .acts .btn{flex:1}
/* dashboard "My Next Step" plan panel + mini level ladder */
.ns-card{background:linear-gradient(150deg,#12293f,#0b1b2b);border:1px solid #2a4a63;border-radius:16px;padding:18px 20px;margin:0 0 22px}
.ns-top{display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:8px}
.ns-eyebrow{color:var(--teal);text-transform:uppercase;letter-spacing:1.6px;font-weight:800;font-size:12px}
.ns-badge{font-size:12.5px;font-weight:800;padding:5px 11px;border-radius:999px;white-space:nowrap}
.ns-badge.ok{background:rgba(123,224,161,.12);color:var(--ok);border:1px solid rgba(123,224,161,.35)}
.ns-badge.no{background:rgba(243,190,67,.1);color:#f2c768;border:1px solid rgba(243,190,67,.3)}
.ns-h{font-size:20px;font-weight:900;letter-spacing:-.3px;margin:2px 0 6px}
.ns-p{color:var(--muted);font-size:14.5px;line-height:1.55;margin:0 0 15px}.ns-p strong{color:var(--text)}
.nladder{display:flex;gap:6px}
.nlv{flex:1;text-align:center;background:#0d2135;border:1px solid #203c53;border-radius:9px;padding:9px 3px;position:relative}
.nlv .nlvn{font-size:11.5px;font-weight:700;color:#b7c6d5;line-height:1.1;display:block}
.nlv.done{opacity:.5}
.nlv.you{background:linear-gradient(160deg,#f3be43,#f7d074);border-color:#f3be43}.nlv.you .nlvn{color:#14202e}
.nlv.next{background:rgba(78,214,203,.12);border:1.5px solid var(--teal)}.nlv.next .nlvn{color:#7fe9df}
.nlvt{position:absolute;top:-9px;left:50%;transform:translateX(-50%);font-size:8.5px;font-weight:900;letter-spacing:.4px;padding:1.5px 6px;border-radius:99px;white-space:nowrap}
.nlv.you .nlvt{background:#14202e;color:#f3be43}.nlv.next .nlvt{background:var(--teal);color:#08222b}
@media(max-width:560px){.nladder{flex-wrap:wrap}.nlv{flex:0 0 calc(25% - 5px)}}
.pp-passed{color:#f2c768;font-size:12px;margin-top:3px} .pp-passed{color:#f2c768;font-size:12px;margin-top:3px}
.pp-verify{flex:0 0 auto;font-size:13px;color:var(--teal);text-decoration:none;border:1px solid rgba(78,214,203,.35);border-radius:9px;padding:7px 10px;white-space:nowrap} .pp-verify{flex:0 0 auto;font-size:13px;color:var(--teal);text-decoration:none;border:1px solid rgba(78,214,203,.35);border-radius:9px;padding:7px 10px;white-space:nowrap}
.pp-verify:hover{background:rgba(78,214,203,.1)} .pp-verify:hover{background:rgba(78,214,203,.1)}
+15
View File
@@ -385,6 +385,21 @@ async function handleApi(req,res,pathname){
} }
} }
} }
// Next-step plan + funded badge. Wallet balance is checked server-side and
// ONLY the boolean (covers next upgrade?) is exposed — never the raw amount.
if(r.registered){
const lvl=r.level||1, tier=r.tier===2?2:1, qualified=(r.directCount||0)>=2;
if(!qualified){
r.nextStep={kind:'qualify',need:2-(r.directCount||0)};
}else if(lvl<8){
const cost=(r.upgradeCosts&&r.upgradeCosts[tier]&&r.upgradeCosts[tier][lvl-1])||0;
let funded=null;
try{ if(r.account&&cost){ const bal=await chain.balanceOf(r.account); if(bal!=null) funded=bal>=cost; } }catch(e){}
r.nextStep={kind:'upgrade',nextLevel:lvl+1,cost,funded};
}else{
r.nextStep={kind:'max'};
}
}
memberCache.set(id,{data:r,ts:Date.now()}); memberCache.set(id,{data:r,ts:Date.now()});
if(memberCache.size>500)memberCache.delete(memberCache.keys().next().value); if(memberCache.size>500)memberCache.delete(memberCache.keys().next().value);
return json(res,200,r,{'Cache-Control':'public, max-age=60'}); return json(res,200,r,{'Cache-Control':'public, max-age=60'});