325 lines
20 KiB
HTML
325 lines
20 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>FLEET OPS — CCSite Mission Control</title>
|
|
<style>
|
|
* { margin:0; padding:0; box-sizing:border-box; }
|
|
:root {
|
|
--bg:#07090f; --panel:#0d1117; --panel2:#11161f; --line:#1c2432;
|
|
--txt:#e6edf6; --muted:#7d8aa0; --dim:#4a5668;
|
|
--green:#2ea043; --green2:#3fb950; --red:#f85149; --amber:#d29922;
|
|
--cyan:#39c5cf; --purple:#a371f7; --pink:#f778ba; --orange:#ff9f43;
|
|
}
|
|
body { background:var(--bg); color:var(--txt); font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif; padding:18px 22px 60px; }
|
|
.mono { font-family:'SF Mono',Menlo,monospace; }
|
|
|
|
/* ── header ── */
|
|
header { display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:10px; margin-bottom:18px; }
|
|
.brand { display:flex; align-items:center; gap:12px; }
|
|
.brand .logo { width:38px;height:38px;border-radius:10px;background:linear-gradient(135deg,#1f6feb,#a371f7);display:flex;align-items:center;justify-content:center;font-size:20px; }
|
|
.brand h1 { font-size:1.25rem; letter-spacing:2px; font-weight:800; }
|
|
.brand h1 span { color:var(--cyan); }
|
|
.brand .sub { font-size:0.68rem; color:var(--muted); letter-spacing:1.5px; text-transform:uppercase; }
|
|
.hdr-right { display:flex; align-items:center; gap:14px; font-size:0.75rem; color:var(--muted); }
|
|
.pulse { width:8px;height:8px;border-radius:50%;background:var(--green2);display:inline-block;margin-right:6px;animation:pl 2s infinite; }
|
|
@keyframes pl { 0%,100%{box-shadow:0 0 0 0 rgba(63,185,80,.5)} 50%{box-shadow:0 0 0 6px rgba(63,185,80,0)} }
|
|
.nav { display:flex; gap:8px; margin-left:10px; }
|
|
.nav a { font-size:0.7rem; color:var(--muted); text-decoration:none; padding:4px 10px; border:1px solid var(--line); border-radius:6px; }
|
|
.nav a:hover { color:var(--txt); border-color:var(--cyan); }
|
|
|
|
/* ── KPI strip ── */
|
|
.kpis { display:grid; grid-template-columns:repeat(auto-fit,minmax(140px,1fr)); gap:10px; margin-bottom:14px; }
|
|
.kpi { background:var(--panel); border:1px solid var(--line); border-radius:12px; padding:12px 14px; position:relative; overflow:hidden; }
|
|
.kpi::after { content:''; position:absolute; top:0; left:0; right:0; height:2px; background:var(--kc,var(--cyan)); opacity:.7; }
|
|
.kpi .l { font-size:0.62rem; color:var(--muted); text-transform:uppercase; letter-spacing:1.2px; margin-bottom:5px; }
|
|
.kpi .v { font-size:1.45rem; font-weight:800; font-variant-numeric:tabular-nums; }
|
|
.kpi .d { font-size:0.66rem; color:var(--dim); margin-top:3px; }
|
|
.kpi .d b { color:var(--green2); font-weight:600; }
|
|
|
|
/* ── charts row ── */
|
|
.charts { display:grid; grid-template-columns:2fr 1.4fr 1fr 0.9fr; gap:10px; margin-bottom:14px; }
|
|
.panel { background:var(--panel); border:1px solid var(--line); border-radius:12px; padding:12px 14px; }
|
|
.panel h3 { font-size:0.68rem; color:var(--muted); text-transform:uppercase; letter-spacing:1.4px; margin-bottom:10px; display:flex; justify-content:space-between; }
|
|
.panel h3 .tag { color:var(--dim); font-weight:400; letter-spacing:0; text-transform:none; }
|
|
canvas { display:block; width:100%; }
|
|
|
|
/* ── main grid ── */
|
|
.main { display:grid; grid-template-columns:1fr 340px; gap:12px; margin-bottom:14px; }
|
|
.sites { display:grid; grid-template-columns:repeat(auto-fill,minmax(220px,1fr)); gap:10px; }
|
|
.site { background:var(--panel2); border:1px solid var(--line); border-radius:12px; padding:12px; transition:border-color .2s; position:relative; }
|
|
.site:hover { border-color:#2f3d52; }
|
|
.site.off { opacity:.55; }
|
|
.site .hd { display:flex; align-items:center; gap:8px; margin-bottom:9px; }
|
|
.site .em { font-size:1.3rem; }
|
|
.site .nm { font-weight:700; font-size:0.9rem; }
|
|
.site .ip { font-size:0.62rem; color:var(--dim); font-family:Menlo,monospace; }
|
|
.dot { width:8px;height:8px;border-radius:50%;margin-left:auto; }
|
|
.dot.on { background:var(--green2); box-shadow:0 0 8px var(--green2); }
|
|
.dot.off { background:var(--red); box-shadow:0 0 8px var(--red); }
|
|
.site .nums { display:grid; grid-template-columns:1fr 1fr 1fr; gap:6px; text-align:center; }
|
|
.site .nums .n { font-size:0.95rem; font-weight:700; font-variant-numeric:tabular-nums; }
|
|
.site .nums .t { font-size:0.56rem; color:var(--dim); text-transform:uppercase; letter-spacing:.8px; }
|
|
.site .ft { display:flex; justify-content:space-between; align-items:center; margin-top:9px; font-size:0.64rem; color:var(--muted); }
|
|
.badge-pend { background:rgba(210,153,34,.15); color:var(--amber); border:1px solid rgba(210,153,34,.35); padding:1px 7px; border-radius:10px; font-size:0.62rem; font-weight:600; }
|
|
.badge-none { color:var(--dim); font-size:0.62rem; }
|
|
|
|
/* ── right rail feeds ── */
|
|
.rail { display:flex; flex-direction:column; gap:12px; }
|
|
.feed { max-height:300px; overflow-y:auto; scrollbar-width:thin; scrollbar-color:var(--line) transparent; }
|
|
.fi { display:flex; gap:9px; padding:7px 2px; border-bottom:1px solid var(--line); font-size:0.74rem; align-items:baseline; }
|
|
.fi:last-child { border-bottom:none; }
|
|
.fi .ic { flex-shrink:0; }
|
|
.fi .tt { color:var(--dim); font-size:0.62rem; margin-left:auto; flex-shrink:0; font-family:Menlo,monospace; }
|
|
.fi .amt { color:var(--green2); font-weight:700; font-variant-numeric:tabular-nums; }
|
|
.fi .st-p { color:var(--amber); } .fi .st-d { color:var(--green2); }
|
|
.fi .mb { font-size:0.6rem; padding:1px 6px; border-radius:8px; background:rgba(163,113,247,.15); color:var(--purple); border:1px solid rgba(163,113,247,.3); }
|
|
|
|
/* ── bottom tables ── */
|
|
.bottom { display:grid; grid-template-columns:1.6fr 1fr 1fr; gap:12px; }
|
|
table { width:100%; border-collapse:collapse; font-size:0.74rem; }
|
|
th { text-align:left; color:var(--dim); font-size:0.6rem; text-transform:uppercase; letter-spacing:1px; padding:5px 8px; border-bottom:1px solid var(--line); }
|
|
td { padding:6px 8px; border-bottom:1px solid var(--line); font-variant-numeric:tabular-nums; }
|
|
tr:last-child td { border-bottom:none; }
|
|
td.num, th.num { text-align:right; }
|
|
.rank { color:var(--dim); font-family:Menlo,monospace; width:22px; }
|
|
.bar { height:4px; border-radius:2px; background:linear-gradient(90deg,var(--cyan),var(--purple)); margin-top:4px; }
|
|
.funnel { display:flex; align-items:center; gap:6px; flex-wrap:wrap; }
|
|
.fstep { flex:1; min-width:90px; text-align:center; background:var(--panel2); border:1px solid var(--line); border-radius:10px; padding:10px 6px; }
|
|
.fstep .n { font-size:1.2rem; font-weight:800; }
|
|
.fstep .l { font-size:0.58rem; color:var(--muted); text-transform:uppercase; letter-spacing:1px; margin-top:2px; }
|
|
.farrow { color:var(--dim); font-size:1rem; }
|
|
.stores { display:flex; flex-wrap:wrap; gap:6px; }
|
|
.store-chip { font-size:0.64rem; padding:3px 9px; border-radius:8px; border:1px solid var(--line); background:var(--panel2); }
|
|
.store-chip b { color:var(--green2); }
|
|
.store-chip .nw { color:var(--red); }
|
|
.empty { color:var(--dim); font-size:0.72rem; padding:14px 4px; text-align:center; }
|
|
|
|
@media (max-width:1100px) { .charts { grid-template-columns:1fr 1fr; } .main { grid-template-columns:1fr; } .bottom { grid-template-columns:1fr; } }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<header>
|
|
<div class="brand">
|
|
<div class="logo">🛰️</div>
|
|
<div>
|
|
<h1>FLEET <span>OPS</span></h1>
|
|
<div class="sub">CCSite Mission Control</div>
|
|
</div>
|
|
<div class="nav">
|
|
<a href="/master">Master</a><a href="/btcpay">BTCPay</a><a href="/watchdog">Watchdog</a><a href="/recon">Recon</a>
|
|
</div>
|
|
</div>
|
|
<div class="hdr-right">
|
|
<span><span class="pulse"></span>live · poll <span id="poll-age">—</span></span>
|
|
<span class="mono" id="clock">—</span>
|
|
</div>
|
|
</header>
|
|
|
|
<div class="kpis" id="kpis"></div>
|
|
|
|
<div class="charts">
|
|
<div class="panel"><h3>Revenue — fleet history <span class="tag" id="rev-total"></span></h3><canvas id="ch-rev" height="150"></canvas></div>
|
|
<div class="panel"><h3>Activity — 14 days <span class="tag">orders vs signups</span></h3><canvas id="ch-act" height="150"></canvas></div>
|
|
<div class="panel"><h3>Users by site</h3><canvas id="ch-users" height="150"></canvas></div>
|
|
<div class="panel"><h3>BTC node</h3><canvas id="ch-btc" height="150"></canvas><div id="btc-sub" style="font-size:.64rem;color:var(--muted);text-align:center;margin-top:4px"></div></div>
|
|
</div>
|
|
|
|
<div class="main">
|
|
<div>
|
|
<div class="panel" style="margin-bottom:12px;"><h3>Fleet — 10 storefronts <span class="tag" id="fleet-tag"></span></h3>
|
|
<div class="sites" id="sites"></div>
|
|
</div>
|
|
<div class="panel"><h3>Conversion funnel</h3><div class="funnel" id="funnel"></div></div>
|
|
</div>
|
|
<div class="rail">
|
|
<div class="panel"><h3>💸 Live orders <span class="tag" id="ord-count"></span></h3><div class="feed" id="orders"></div></div>
|
|
<div class="panel"><h3>👤 Signups <span class="tag" id="usr-count"></span></h3><div class="feed" id="signups"></div></div>
|
|
<div class="panel"><h3>⚡ Events</h3><div class="feed" id="events"></div></div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="bottom">
|
|
<div class="panel"><h3>🏆 Site leaderboard — by revenue</h3><table id="board"></table></div>
|
|
<div class="panel"><h3>🔥 Top products</h3><table id="top-prod"></table></div>
|
|
<div class="panel"><h3>₿ BTCPay stores <span class="tag" id="btcpay-tag"></span></h3>
|
|
<div class="stores" id="stores"></div>
|
|
<h3 style="margin-top:14px;">Recent chain activity</h3>
|
|
<div id="btc-recent" style="font-size:.7rem"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
const $ = id => document.getElementById(id);
|
|
const money = n => '$' + Number(n||0).toLocaleString(undefined,{minimumFractionDigits:2,maximumFractionDigits:2});
|
|
const ago = iso => {
|
|
if (!iso) return '—';
|
|
const t = (Date.now() - new Date(iso.replace('T',' ').replace(/\+.*/,'') + (iso.includes('Z')||iso.includes('+')?'':' UTC'))/1000)|0;
|
|
// ISO dates from shops are naive UTC
|
|
const s = isNaN(t) || t < 0 ? 0 : t;
|
|
if (s < 60) return s + 's'; if (s < 3600) return (s/60|0) + 'm';
|
|
if (s < 86400) return (s/3600|0) + 'h'; return (s/86400|0) + 'd';
|
|
};
|
|
const agoTs = ts => ago(new Date(ts*1000).toISOString());
|
|
|
|
setInterval(() => { $('clock').textContent = new Date().toLocaleTimeString(); }, 1000);
|
|
|
|
/* ── tiny canvas chart kit ── */
|
|
function setup(cv){ const dpr = devicePixelRatio||1, r = cv.getBoundingClientRect();
|
|
cv.width = r.width*dpr; cv.height = r.height*dpr;
|
|
const x = cv.getContext('2d'); x.scale(dpr,dpr); return [x, r.width, r.height]; }
|
|
function area(cv, pts, color){
|
|
if(!pts.length){ return; } const [x,W,H] = setup(cv), P=8;
|
|
const max = Math.max(...pts.map(p=>p.v), 1);
|
|
const bw = (W-2*P)/Math.max(pts.length-1,1);
|
|
x.clearRect(0,0,W,H);
|
|
const grad = x.createLinearGradient(0,0,0,H);
|
|
grad.addColorStop(0, color+'55'); grad.addColorStop(1, color+'00');
|
|
x.beginPath(); x.moveTo(P, H-P);
|
|
pts.forEach((p,i)=> x.lineTo(P+i*bw, H-P-(p.v/max)*(H-2*P-14)));
|
|
x.lineTo(P+(pts.length-1)*bw, H-P); x.closePath(); x.fillStyle = grad; x.fill();
|
|
x.beginPath(); pts.forEach((p,i)=> i? x.lineTo(P+i*bw, H-P-(p.v/max)*(H-2*P-14)) : x.moveTo(P, H-P-(p.v/max)*(H-2*P-14)));
|
|
x.strokeStyle = color; x.lineWidth = 1.8; x.stroke();
|
|
const lp = pts[pts.length-1];
|
|
x.fillStyle = color; x.beginPath(); x.arc(P+(pts.length-1)*bw, H-P-(lp.v/max)*(H-2*P-14), 3, 0, 7); x.fill();
|
|
x.fillStyle = '#7d8aa0'; x.font = '10px Menlo'; x.fillText(money(lp.v), P+4, 12);
|
|
}
|
|
function bars(cv, labels, seriesA, seriesB){
|
|
const [x,W,H] = setup(cv), P=8, n = labels.length; if(!n) return;
|
|
const max = Math.max(...seriesA, ...seriesB, 1), bw = (W-2*P)/n;
|
|
x.clearRect(0,0,W,H);
|
|
labels.forEach((l,i)=>{
|
|
const a = seriesA[i], b = seriesB[i];
|
|
x.fillStyle = '#39c5cf';
|
|
const ha = (a/max)*(H-2*P-12); x.fillRect(P+i*bw+1, H-P-ha, bw*0.55, ha);
|
|
x.fillStyle = '#a371f7';
|
|
const hb = (b/max)*(H-2*P-12); x.fillRect(P+i*bw+bw*0.58, H-P-hb, bw*0.35, hb);
|
|
if(i%2===0){ x.fillStyle='#4a5668'; x.font='8px Menlo'; x.fillText(l.slice(5), P+i*bw, H-1); }
|
|
});
|
|
}
|
|
function donut(cv, items){
|
|
const [x,W,H] = setup(cv); x.clearRect(0,0,W,H);
|
|
const total = items.reduce((s,i)=>s+i.v,0); if(!total){ x.fillStyle='#4a5668'; x.font='11px Menlo'; x.fillText('no users yet', 10, H/2); return; }
|
|
const cols = ['#39c5cf','#a371f7','#f778ba','#ff9f43','#3fb950','#d29922','#f85149','#58a6ff','#7ee787','#e3b341'];
|
|
const cx = W*0.32, cy = H/2, r = Math.min(W,H)/2 - 10; let a = -Math.PI/2;
|
|
items.forEach((it,i)=>{ const sw = it.v/total*2*Math.PI;
|
|
x.beginPath(); x.arc(cx,cy,r,a,a+sw); x.strokeStyle = cols[i%cols.length]; x.lineWidth = 13; x.stroke(); a += sw; });
|
|
x.fillStyle = '#e6edf6'; x.font = 'bold 15px Menlo'; x.textAlign='center'; x.fillText(total, cx, cy+4); x.textAlign='left';
|
|
items.slice(0,6).forEach((it,i)=>{ x.fillStyle = cols[i%cols.length]; x.fillRect(W*0.58, 14+i*20, 8, 8);
|
|
x.fillStyle = '#7d8aa0'; x.font = '9.5px Menlo'; x.fillText(it.k+' ('+it.v+')', W*0.58+13, 21+i*20); });
|
|
}
|
|
function gauge(cv, pct, label){
|
|
const [x,W,H] = setup(cv); x.clearRect(0,0,W,H);
|
|
const cx = W/2, cy = H*0.62, r = Math.min(W,H)*0.42;
|
|
x.lineWidth = 11; x.lineCap = 'round';
|
|
x.beginPath(); x.arc(cx,cy,r,Math.PI,2*Math.PI); x.strokeStyle = '#1c2432'; x.stroke();
|
|
x.beginPath(); x.arc(cx,cy,r,Math.PI,Math.PI+Math.PI*Math.min(pct,100)/100);
|
|
x.strokeStyle = pct>=100 ? '#3fb950' : '#d29922'; x.stroke();
|
|
x.fillStyle = '#e6edf6'; x.font = 'bold 17px Menlo'; x.textAlign='center';
|
|
x.fillText(pct.toFixed(0)+'%', cx, cy-6);
|
|
x.fillStyle = '#7d8aa0'; x.font = '9px Menlo'; x.fillText(label, cx, cy+12); x.textAlign='left';
|
|
}
|
|
|
|
/* ── renderers ── */
|
|
async function refresh(){
|
|
const [state, orders, signups, board, top, act, fun] = await Promise.all([
|
|
fetch('/api/state').then(r=>r.json()), fetch('/api/orders?limit=30').then(r=>r.json()),
|
|
fetch('/api/signups?limit=30').then(r=>r.json()), fetch('/api/leaderboard').then(r=>r.json()),
|
|
fetch('/api/top-products').then(r=>r.json()), fetch('/api/activity').then(r=>r.json()),
|
|
fetch('/api/funnel').then(r=>r.json()),
|
|
]);
|
|
const t = state.totals||{}, sites = Object.values(state.sites||{});
|
|
const revToday = sites.reduce((s,x)=>s+(x.revenue_today||0),0);
|
|
const ordToday = sites.reduce((s,x)=>s+(x.orders_today||0),0);
|
|
const sgnToday = sites.reduce((s,x)=>s+(x.signups_today||0),0);
|
|
|
|
/* KPIs */
|
|
const k = [
|
|
['Fleet Revenue', money(fun.revenue), `+${money(revToday)} today`, 'var(--green2)'],
|
|
['Orders', fun.orders, `+${ordToday} today · AOV ${money(fun.aov)}`, 'var(--cyan)'],
|
|
['Pending Delivery', fun.pending, fun.pending?'⚠ needs fulfillment':'all clear', fun.pending?'var(--amber)':'var(--green2)'],
|
|
['Total Users', fun.users, `+${sgnToday} today · ${fun.conversion}% buy`, 'var(--purple)'],
|
|
['Sites Online', (t.online||0)+'/10', t.offline? t.offline+' DOWN':'all green', t.offline?'var(--red)':'var(--green2)'],
|
|
['Products Live', t.products||0, 'across fleet', 'var(--pink)'],
|
|
['Deposits', money(fun.deposits), 'store balances', 'var(--orange)'],
|
|
['Unique Buyers', fun.unique_customers, 'by email', 'var(--cyan)'],
|
|
];
|
|
$('kpis').innerHTML = k.map(x=>`<div class="kpi" style="--kc:${x[3]}"><div class="l">${x[0]}</div><div class="v">${x[1]}</div><div class="d">${x[2]}</div></div>`).join('');
|
|
|
|
/* charts */
|
|
const hist = (state.history||[]).map(h=>({v:h.revenue}));
|
|
area($('ch-rev'), hist, '#39c5cf');
|
|
$('rev-total').textContent = hist.length? 'now '+money(hist[hist.length-1].v) : '';
|
|
const days = [...new Set([...Object.keys(act.daily||{})])].sort();
|
|
bars($('ch-act'), days, days.map(d=>(act.daily[d]||{}).orders||0), days.map(d=>(act.daily[d]||{}).signups||0));
|
|
donut($('ch-users'), Object.entries(signups.per_site||{}).map(([sid,v])=>({k:(state.sites[sid]||{}).name||sid, v})).sort((a,b)=>b.v-a.v));
|
|
const bp = state.btcpay||{};
|
|
gauge($('ch-btc'), bp.sync_pct||0, 'blocks '+(bp.blocks||'?'));
|
|
$('btc-sub').textContent = bp.synced ? 'fully synced · v'+(bp.version||'').split('+')[0] : 'syncing…';
|
|
|
|
/* sites grid */
|
|
$('fleet-tag').textContent = (t.online||0)+' online · '+(t.users||0)+' users';
|
|
$('sites').innerHTML = sites.sort((a,b)=>a.site_id-b.site_id).map(s=>`
|
|
<div class="site ${s.status!=='online'?'off':''}">
|
|
<div class="hd"><span class="em">${s.emoji}</span>
|
|
<div><div class="nm">${s.name}</div><div class="ip">${s.ip} · ${s.response_ms}ms</div></div>
|
|
<span class="dot ${s.status==='online'?'on':'off'}"></span></div>
|
|
<div class="nums">
|
|
<div><div class="n">${s.users}</div><div class="t">users</div></div>
|
|
<div><div class="n">${s.orders}</div><div class="t">orders</div></div>
|
|
<div><div class="n">${money(s.revenue)}</div><div class="t">revenue</div></div>
|
|
</div>
|
|
<div class="ft"><span>${s.products} products</span>
|
|
${s.details&&s.details.pending_orders? `<span class="badge-pend">${s.details.pending_orders} pending</span>` : '<span class="badge-none">✓ clear</span>'}
|
|
</div></div>`).join('');
|
|
|
|
/* funnel */
|
|
const fs = [['Users',fun.users],['Buyers',fun.buyers],['Orders',fun.orders],['Delivered',fun.delivered],['Revenue',money(fun.revenue)]];
|
|
$('funnel').innerHTML = fs.map((f,i)=>`${i?'<span class="farrow">→</span>':''}<div class="fstep"><div class="n">${f[1]}</div><div class="l">${f[0]}</div></div>`).join('');
|
|
|
|
/* feeds */
|
|
$('ord-count').textContent = orders.length? orders.length+' total':'';
|
|
$('orders').innerHTML = orders.length? orders.map(o=>`
|
|
<div class="fi"><span class="ic">${o.emoji}</span>
|
|
<span><b>${o.product.slice(0,34)}</b> <span class="amt">${money(o.amount)}</span><br>
|
|
<span style="color:var(--dim)">${o.site} · <span class="st-${o.status==='delivered'?'d':'p'}">${o.status}</span> · ${o.email.split('@')[0]}@…</span></span>
|
|
<span class="tt">${ago(o.date)}</span></div>`).join('') : '<div class="empty">no orders yet</div>';
|
|
$('usr-count').textContent = Object.values(signups.tiers||{}).map((v,k)=>k+':'+v).join(' · ')||'';
|
|
$('signups').innerHTML = signups.recent.length? signups.recent.map(u=>`
|
|
<div class="fi"><span class="ic">${u.emoji}</span>
|
|
<span><b>${u.username}</b> ${u.membership!=='free'?`<span class="mb">${u.membership}</span>`:''}<br>
|
|
<span style="color:var(--dim)">${u.site} · ${u.orders} orders · ${money(u.balance)} bal</span></span>
|
|
<span class="tt">${ago(u.joined)}</span></div>`).join('') : '<div class="empty">no signups yet</div>';
|
|
$('events').innerHTML = (state.events||[]).slice(0,20).map(e=>`
|
|
<div class="fi"><span>${e.detail}</span><span class="tt">${agoTs(e.timestamp)}</span></div>`).join('') || '<div class="empty">quiet…</div>';
|
|
|
|
/* leaderboard */
|
|
const maxRev = Math.max(...board.map(b=>b.revenue),1);
|
|
$('board').innerHTML = `<tr><th></th><th>Site</th><th class="num">Revenue</th><th class="num">Orders</th><th class="num">24h</th><th class="num">AOV</th><th class="num">Users</th><th class="num">Pend</th></tr>` +
|
|
board.map((b,i)=>`<tr><td class="rank">${i+1}</td><td>${b.emoji} ${b.site}<div class="bar" style="width:${(b.revenue/maxRev*100)|0}%"></div></td>
|
|
<td class="num"><b>${money(b.revenue)}</b></td><td class="num">${b.orders}</td><td class="num">${b.orders_24h}</td>
|
|
<td class="num">${money(b.aov)}</td><td class="num">${b.users}</td>
|
|
<td class="num">${b.pending?`<span class="badge-pend">${b.pending}</span>`:'—'}</td></tr>`).join('');
|
|
$('top-prod').innerHTML = `<tr><th>Product</th><th class="num">Sold</th><th class="num">Revenue</th><th class="num">Sites</th></tr>` +
|
|
(top.length? top.map(p=>`<tr><td>${p.product.slice(0,30)}</td><td class="num">${p.orders}</td><td class="num">${money(p.revenue)}</td><td class="num">${p.sites}</td></tr>`).join('') : '<tr><td class="empty">no sales yet</td></tr>');
|
|
|
|
/* btcpay stores */
|
|
const stores = bp.stores||{};
|
|
$('btcpay-tag').textContent = (bp.stores_with_wallet||0)+'/'+(bp.total_stores||10)+' wallets · '+(bp.total_invoices||0)+' invoices';
|
|
$('stores').innerHTML = Object.entries(stores).map(([sid,st])=>{
|
|
const nm = (state.sites[sid]||{}).emoji||''; const inv = st.invoices_by_status||{};
|
|
return `<span class="store-chip">${nm} ${st.has_wallet?'<b>●</b>':'<span class="nw">○</span>'} ${st.invoice_count||0}inv <span style="color:var(--dim)">${inv.Settled?inv.Settled+'✓ ':''}${inv.New?inv.New+'⏳':''}</span></span>`;
|
|
}).join('');
|
|
const br = (state.btc_stats||{}).recent||[];
|
|
$('btc-recent').innerHTML = br.length? br.map(x=>`<div class="fi"><span>₿ <span class="amt">${(x.btc||0).toLocaleString()}</span> sats-equiv<br><span style="color:var(--dim)" class="mono">${x.txid}</span></span><span class="tt">${agoTs(x.ts)}</span></div>`).join('') : '<div class="empty">no chain activity</div>';
|
|
|
|
$('poll-age').textContent = state.last_poll||'—';
|
|
}
|
|
refresh();
|
|
setInterval(refresh, 20000);
|
|
</script>
|
|
</body>
|
|
</html>
|