Render admin matrix as a drill-down pyramid with open slots
Matrix View now defaults to a generation-by-generation pyramid (root + 3 generations of position cards, empty placements shown as dashed open slots). Clicking a position re-roots the pyramid there; a breadcrumb navigates back up. The collapsible list stays behind a toggle. Tree payload now carries explicit left/right children so an only-child lands on the correct side. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -332,8 +332,8 @@ function getMatrixTree() {
|
||||
id, tier: m.tier, tierName: tierName(m.tier), level: m.level, levelName: levelName(m.level),
|
||||
directCount: m.directCount || 0, earnedPol: m.earnedPol || 0, referrerId: m.referrerId, joinedAt: m.joinedAt
|
||||
};
|
||||
const l = node(m.l, depth + 1), r = node(m.r, depth + 1);
|
||||
if (l || r) n.children = [l, r].filter(Boolean);
|
||||
n.left = node(m.l, depth + 1);
|
||||
n.right = node(m.r, depth + 1);
|
||||
return n;
|
||||
}
|
||||
const root = node(1, 0);
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
<div class="table-card"><div style="margin-bottom:12px"><h2 style="margin:0">Traffic & Conversions</h2><p style="color:var(--muted);margin:4px 0 0;font-size:13px">First-touch source per visitor session (referring domain or utm_source). Funnel: bridge page → start page → join click.</p></div><div id="funnelStats" class="funnel"></div><div class="table-wrap"><table class="table"><thead><tr><th>Source</th><th>Bridge views</th><th>Start views</th><th>Training views</th><th>Join clicks</th><th>Start → Join</th></tr></thead><tbody id="trafficRows"></tbody></table></div></div>
|
||||
<div class="table-card"><div style="margin-bottom:12px"><h2 style="margin:0">Member ID Submissions</h2><p style="color:var(--muted);margin:4px 0 0;font-size:13px">New members who confirmed their purchase on the start page. Each one was posted to your Hermes Telegram chat — add them to the rotation.</p></div><div class="table-wrap"><table class="table"><thead><tr><th>When</th><th>Name / Handle</th><th>New ID</th><th>Joined under</th><th>Source</th><th>On-chain</th></tr></thead><tbody id="submissionRows"></tbody></table></div></div>
|
||||
<div class="table-card"><div style="margin-bottom:12px"><h2 style="margin:0">On-Chain Member Lookup</h2><p style="color:var(--muted);margin:4px 0 0;font-size:13px">Enter an RM Circle ID to read its registration, lineage, and every payment it has received — live from the smart contract.</p></div><form id="lookupForm" style="display:flex;gap:10px;margin-bottom:14px"><input id="lookupId" class="input" style="max-width:220px" placeholder="Member ID e.g. 46" inputmode="numeric"><button class="btn btn-teal">Look Up</button></form><div id="lookupResult"></div></div>
|
||||
<div class="table-card"><div style="display:flex;justify-content:space-between;gap:12px;align-items:center;margin-bottom:12px"><div><h2 style="margin:0">Matrix Tree</h2><p style="color:var(--muted);margin:4px 0 0;font-size:13px">The entire on-chain matrix from the root down — who landed where, with tier, level, directs, and earnings per position.</p></div><button id="treeLoadBtn" class="btn btn-secondary btn-sm">Load Tree</button></div><div id="matrixTree"></div></div></div>
|
||||
<div class="table-card"><div style="display:flex;justify-content:space-between;gap:12px;align-items:center;margin-bottom:12px;flex-wrap:wrap"><div><h2 style="margin:0">Matrix View</h2><p style="color:var(--muted);margin:4px 0 0;font-size:13px">The entire on-chain matrix — who landed where, with tier, level, directs, and earnings per position. Click a position to drill down.</p></div><div style="display:flex;gap:8px"><button id="treeLoadBtn" class="btn btn-secondary btn-sm">Load Matrix</button><button id="treeToggleBtn" class="btn btn-secondary btn-sm hidden">List view</button></div></div><div id="matrixNav" class="hidden" style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-bottom:12px"></div><div id="matrixTree"></div></div></div>
|
||||
<div class="stack"><div class="table-card"><h2 style="margin-top:0">Add Sponsor</h2><form id="addSponsorForm"><div class="form-grid"><div class="field"><label>ID</label><input name="id" class="input" required></div><div class="field"><label>Name</label><input name="name" class="input" required></div><div class="field"><label>Parent ID</label><input name="parentId" class="input"></div><div class="field"><label>Level</label><select name="level" class="select"><option>Scintilla</option><option>Ascensus</option><option>Fabrica</option><option>Culmen</option><option>Apex</option><option>Fastigium</option><option>Vertex</option><option>Corona</option></select></div></div><div class="field"><label>Notes</label><input name="notes" class="input"></div><button class="btn btn-teal" style="width:100%">Add to Queue</button></form></div>
|
||||
<div class="table-card"><h2 style="margin-top:0">AI Chat</h2><p style="color:var(--muted);font-size:13px;margin:4px 0 12px">Paste an OpenRouter API key to switch the help chat from canned answers to AI (<span id="aiModel"></span>). Clear it to switch back.</p><div id="aiStatus" class="micro" style="margin-bottom:10px"></div><form id="aiKeyForm"><div class="field"><label>OpenRouter API key</label><input name="key" class="input" type="password" placeholder="sk-or-v1-…" autocomplete="off"></div><button class="btn btn-teal" style="width:100%">Save Key</button><button type="button" id="aiKeyClear" class="btn btn-secondary" style="width:100%;margin-top:8px">Clear Key (use canned answers)</button></form></div>
|
||||
<div class="table-card"><h2 style="margin-top:0">Public Page Settings</h2><form id="configForm"><div class="field"><label>Site name</label><input name="siteName" class="input"></div><div class="field"><label>Program name</label><input name="programName" class="input"></div><div class="field"><label>Bridge headline</label><input name="bridgeHeadline" class="input"></div><div class="field"><label>Bridge subheadline</label><textarea name="bridgeSubheadline" class="input" rows="3"></textarea></div><div class="field"><label>Premium entry (POL)</label><input name="premiumEntryPol" class="input" type="number"></div><div class="field"><label>RM dApp referral base URL</label><input name="dappReferralBaseUrl" class="input" placeholder="https://app.thermcircle.com?ref="></div><div class="field"><label>BeMob postback URL (paid traffic)</label><input name="bemobPostbackUrl" class="input" placeholder="https://xxxxx.bemobtrcks.com/postback"></div><div class="field"><label>Telegram bot token (Hermes notifications)</label><input name="telegramBotToken" class="input" type="password" autocomplete="off" placeholder="123456:ABC…"></div><div class="field"><label>Telegram chat ID (group or user)</label><input name="telegramChatId" class="input" placeholder="-1001234567890"></div><div class="field"><label>Telegram topic ID (optional, for forum groups)</label><input name="telegramTopicId" class="input" placeholder="55"></div><div class="field"><label>Telegram/support URL (optional)</label><input name="telegramUrl" class="input"></div><div class="field"><label>Support message</label><textarea name="supportLabel" class="input" rows="3"></textarea></div><label style="text-transform:none;letter-spacing:0;margin:10px 0"><input type="checkbox" name="showSponsorName"> Show sponsor name publicly</label><label style="text-transform:none;letter-spacing:0;margin:10px 0"><input type="checkbox" name="showQueueProgress"> Show number waiting in queue</label><button class="btn btn-primary" style="width:100%;margin-top:8px">Save Settings</button></form></div></div></div></section>
|
||||
|
||||
+59
-13
@@ -66,24 +66,70 @@ document.getElementById('lookupForm').addEventListener('submit',async e=>{
|
||||
out.innerHTML=facts+chain+`<p style="font-size:12px;color:#8498aa;margin:14px 0 0">Payments received by #${d.id} — ${d.income?d.income.length:0} total, newest first:</p>`+income;
|
||||
}catch(x){out.innerHTML=`<div class="empty" style="color:var(--danger)">${esc(x.message)}</div>`}
|
||||
});
|
||||
const matrixUI={data:null,byId:{},parent:{},rootId:1,mode:'pyramid'};
|
||||
function mtIndex(n,par){if(!n)return;matrixUI.byId[n.id]=n;if(par)matrixUI.parent[n.id]=par.id;mtIndex(n.left,n);mtIndex(n.right,n)}
|
||||
function mtBadge(n){return n.tier===2?'<span class="mt-badge mt-prem">P</span>':'<span class="mt-badge">S</span>'}
|
||||
const mtFmt=n=>Number(n||0).toLocaleString(undefined,{maximumFractionDigits:0});
|
||||
function mtCard(n){
|
||||
if(!n)return '<div class="mtp-card mtp-open">open<br>slot</div>';
|
||||
return `<button class="mtp-card${n.id===matrixUI.rootId?' mtp-focus':''}" data-mid="${n.id}">${mtBadge(n)} <span class="mt-id">#${n.id}</span><span class="mtp-lvl">${esc(n.levelName)}</span><span class="mtp-sub">${n.directCount}/2 · ${mtFmt(n.earnedPol)} POL</span></button>`;
|
||||
}
|
||||
function renderPyramid(){
|
||||
const out=document.getElementById('matrixTree'),nav=document.getElementById('matrixNav');
|
||||
const root=matrixUI.byId[matrixUI.rootId];
|
||||
if(!root){out.innerHTML='<div class="empty">Position not found.</div>';return}
|
||||
const GEN=4; // root + 3 generations = up to 15 cards
|
||||
const rows=[[root]];
|
||||
for(let g=1;g<GEN;g++){
|
||||
rows.push(rows[g-1].flatMap(n=>n?[n.left||null,n.right||null]:[null,null]));
|
||||
}
|
||||
out.innerHTML=`<div class="mtp">${rows.map(r=>`<div class="mtp-row">${r.map(mtCard).join('')}</div>`).join('')}</div>`;
|
||||
// breadcrumb: path from tree root down to current focus
|
||||
const path=[];let cur=matrixUI.rootId;
|
||||
while(cur){path.unshift(cur);cur=matrixUI.parent[cur]}
|
||||
nav.classList.remove('hidden');
|
||||
nav.innerHTML=`<span class="micro" style="margin:0">Viewing:</span> `+path.map((id,i)=>i===path.length-1?`<strong class="mt-id">#${id}</strong>`:`<button class="btn btn-secondary btn-sm" data-mid="${id}">#${id}</button>`).join(' <span style="color:var(--muted)">›</span> ')+
|
||||
(matrixUI.parent[matrixUI.rootId]?` <button class="btn btn-secondary btn-sm" data-mid="${matrixUI.parent[matrixUI.rootId]}">↑ Up one</button>`:'');
|
||||
out.querySelectorAll('[data-mid]').forEach(b=>b.addEventListener('click',()=>{matrixUI.rootId=Number(b.dataset.mid);renderPyramid()}));
|
||||
nav.querySelectorAll('[data-mid]').forEach(b=>b.addEventListener('click',()=>{matrixUI.rootId=Number(b.dataset.mid);renderPyramid()}));
|
||||
}
|
||||
function renderList(){
|
||||
const out=document.getElementById('matrixTree'),d=matrixUI.data;
|
||||
document.getElementById('matrixNav').classList.add('hidden');
|
||||
const node=(n,depth)=>{
|
||||
if(!n)return '';
|
||||
if(n.missing)return `<li class="mt-node"><span class="mt-id">#${n.id}</span> <span style="color:var(--danger)">(no data)</span></li>`;
|
||||
const ch=[n.left,n.right].filter(Boolean);
|
||||
const kids=ch.length?`<ul class="mt-kids">${ch.map(c=>node(c,depth+1)).join('')}</ul>`:'';
|
||||
const open=depth<4?' open':'';
|
||||
const label=`${mtBadge(n)} <span class="mt-id">#${n.id}</span> <span class="mt-meta">${esc(n.levelName)} · ${n.directCount}/2 directs · ${mtFmt(n.earnedPol)} POL earned${n.referrerId?` · ref #${n.referrerId}`:''}</span>`;
|
||||
return ch.length?`<li class="mt-node"><details${open}><summary>${label}</summary>${kids}</details></li>`:`<li class="mt-node mt-leaf">${label}</li>`;
|
||||
};
|
||||
const unplaced=d.unplaced?`<p class="micro" style="color:var(--danger)">Not reachable from root: ${d.unplaced.map(i=>'#'+i).join(', ')}</p>`:'';
|
||||
out.innerHTML=`<ul class="mt-tree">${node(d.root,0)}</ul>${unplaced}`;
|
||||
}
|
||||
function renderMatrix(){
|
||||
const d=matrixUI.data;
|
||||
const head=`${d.memberCount} positions · snapshot ${new Date(d.snapshotAt).toISOString().replace('T',' ').slice(0,16)} UTC · P = Premium, S = Standard`;
|
||||
document.getElementById('treeToggleBtn').classList.remove('hidden');
|
||||
document.getElementById('treeToggleBtn').textContent=matrixUI.mode==='pyramid'?'List view':'Pyramid view';
|
||||
if(matrixUI.mode==='pyramid')renderPyramid();else renderList();
|
||||
const out=document.getElementById('matrixTree');
|
||||
out.insertAdjacentHTML('afterbegin',`<p class="micro" style="margin:0 0 10px">${head}</p>`);
|
||||
}
|
||||
document.getElementById('treeLoadBtn').addEventListener('click',async()=>{
|
||||
const out=document.getElementById('matrixTree');
|
||||
out.innerHTML='<div class="empty">Building tree from the on-chain snapshot…</div>';
|
||||
out.innerHTML='<div class="empty">Building matrix from the on-chain snapshot…</div>';
|
||||
try{
|
||||
const d=await api('/api/admin/matrix-tree');
|
||||
if(!d.ready||!d.root){out.innerHTML='<div class="empty">Snapshot not ready yet — try again in a minute.</div>';return}
|
||||
const fmt=n=>Number(n).toLocaleString(undefined,{maximumFractionDigits:0});
|
||||
const node=(n,depth)=>{
|
||||
if(!n)return '';
|
||||
if(n.missing)return `<li class="mt-node"><span class="mt-id">#${n.id}</span> <span style="color:var(--danger)">(no data)</span></li>`;
|
||||
const badge=n.tier===2?'<span class="mt-badge mt-prem">P</span>':'<span class="mt-badge">S</span>';
|
||||
const kids=n.children&&n.children.length?`<ul class="mt-kids">${n.children.map(c=>node(c,depth+1)).join('')}</ul>`:'';
|
||||
const open=depth<4?' open':'';
|
||||
const label=`${badge} <span class="mt-id">#${n.id}</span> <span class="mt-meta">${esc(n.levelName)} · ${n.directCount}/2 directs · ${fmt(n.earnedPol)} POL earned${n.referrerId?` · ref #${n.referrerId}`:''}</span>`;
|
||||
return n.children&&n.children.length?`<li class="mt-node"><details${open}><summary>${label}</summary>${kids}</details></li>`:`<li class="mt-node mt-leaf">${label}</li>`;
|
||||
};
|
||||
const unplaced=d.unplaced?`<p class="micro" style="color:var(--danger)">Not reachable from root: ${d.unplaced.map(i=>'#'+i).join(', ')}</p>`:'';
|
||||
out.innerHTML=`<p class="micro" style="margin:0 0 8px">${d.memberCount} positions · snapshot ${new Date(d.snapshotAt).toISOString().replace('T',' ').slice(0,16)} UTC · P = Premium, S = Standard</p><ul class="mt-tree">${node(d.root,0)}</ul>${unplaced}`;
|
||||
matrixUI.data=d;matrixUI.byId={};matrixUI.parent={};matrixUI.rootId=d.root.id;
|
||||
mtIndex(d.root,null);
|
||||
renderMatrix();
|
||||
}catch(x){out.innerHTML=`<div class="empty" style="color:var(--danger)">${esc(x.message)}</div>`}
|
||||
});
|
||||
document.getElementById('treeToggleBtn').addEventListener('click',()=>{
|
||||
matrixUI.mode=matrixUI.mode==='pyramid'?'list':'pyramid';
|
||||
if(matrixUI.data)renderMatrix();
|
||||
});
|
||||
loadState();
|
||||
|
||||
@@ -82,3 +82,13 @@ a{color:inherit}.wrap{width:min(1160px,calc(100% - 32px));margin:auto}.nav{heigh
|
||||
.mt-meta{color:var(--muted);font-size:12px}
|
||||
.mt-badge{display:inline-block;width:16px;height:16px;line-height:16px;text-align:center;border-radius:5px;background:#183952;color:var(--teal);font-size:10px;font-weight:900}
|
||||
.mt-badge.mt-prem{background:rgba(243,190,67,.15);color:var(--gold)}
|
||||
|
||||
/* Admin matrix pyramid view */
|
||||
.mtp{display:flex;flex-direction:column;gap:14px;overflow-x:auto;padding:6px 2px 10px}
|
||||
.mtp-row{display:flex;justify-content:center;gap:8px;min-width:max-content;margin:0 auto}
|
||||
.mtp-card{appearance:none;font:inherit;cursor:pointer;text-align:center;background:#0d2236;border:1px solid #2a4a66;border-radius:12px;padding:9px 10px;min-width:104px;color:var(--text);display:flex;flex-direction:column;gap:2px;align-items:center;transition:.15s ease}
|
||||
.mtp-card:hover{border-color:var(--teal);transform:translateY(-1px)}
|
||||
.mtp-focus{border-color:var(--gold);box-shadow:0 0 0 1px var(--gold),0 8px 22px rgba(243,190,67,.12)}
|
||||
.mtp-open{background:transparent;border:1px dashed #27445e;color:#5f7891;font-size:11px;justify-content:center;cursor:default;line-height:1.3}
|
||||
.mtp-lvl{font-size:11px;color:var(--teal);text-transform:uppercase;letter-spacing:.5px;font-weight:800}
|
||||
.mtp-sub{font-size:11px;color:var(--muted)}
|
||||
|
||||
Reference in New Issue
Block a user