Overview v3: plated stat cards, trend chips, payout bars, credit donut, view ring, delivery chart

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-09-05 06:41:01 -05:00
parent e22021c266
commit fee86d296a
7 changed files with 193 additions and 24 deletions
+91
View File
@@ -26,6 +26,86 @@
if (d.buyerCount < 5) return 'Level 2 is open. ' + (5 - d.buyerCount) + ' more qualifying buyer(s) unlock level 3 and the full three-level flow.';
return 'Fully qualified. Every level pays you, and you catch the pass-ups that under-qualified positions below you let slip. Keep sharing and keep your campaigns running.';
}
// ── overview v3 charts: hand-rolled SVG/CSS, real data only ──
const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
const CH = { mint: '#43e8c3', cyan: '#54ccff', violet: '#9d7dff', amber: '#ffb238', track: 'rgba(139,166,156,.18)' };
function bars(el, xel, data) { // data: [{v,label,tip,alt}]
if (!el) return;
const max = Math.max(1, ...data.map(d => d.v));
el.innerHTML = data.length
? data.map(d => '<div class="bar' + (d.alt ? ' alt' : '') + (d.v ? '' : ' empty') + '" style="height:'
+ Math.max(4, Math.round(d.v / max * 100)) + '%" data-v="' + esc(d.tip) + '"></div>').join('')
: '<div class="bar empty" style="height:4%"></div>'.repeat(8);
if (xel) xel.innerHTML = data.map(d => '<span>' + esc(d.label) + '</span>').join('');
}
function donut(el, segs, center) { // segs sum to the ring; r=15.9155 → circumference 100
if (!el) return;
const total = segs.reduce((s, x) => s + x.v, 0);
let off = 25, out = '<circle cx="21" cy="21" r="15.9155" fill="none" stroke="' + CH.track + '" stroke-width="5"></circle>';
if (total > 0) for (const s of segs) {
const len = s.v / total * 100;
if (len <= 0 || s.color === 'none') { off -= Math.max(0, len); continue; }
out += '<circle cx="21" cy="21" r="15.9155" fill="none" stroke="' + s.color + '" stroke-width="5" '
+ 'stroke-dasharray="' + len + ' ' + (100 - len) + '" stroke-dashoffset="' + off + '"></circle>';
off -= len;
}
out += '<text x="21" y="20.5" text-anchor="middle" fill="currentColor" font-size="6.5" font-weight="800" class="donut-center">' + esc(center.big) + '</text>'
+ '<text x="21" y="26.5" text-anchor="middle" fill="#8ba69c" font-size="3.1">' + esc(center.small) + '</text>';
el.innerHTML = out;
}
function legend(el, rows) {
if (el) el.innerHTML = rows.map(r => '<div><i style="background:' + r.color + '"></i>'
+ esc(r.label) + ' <b>' + esc(r.v) + '</b></div>').join('');
}
async function loadCharts(d) {
// credit composition donut + today's viewing ring (live balances)
try {
const st = await (await fetch('/api/my/earn')).json();
if (!st.error) {
const purchased = d.credits || 0, earned = st.earned || 0, wc = Math.min(d.welcomeCredits || 0, earned);
$('dbCredits').textContent = (purchased + earned).toLocaleString();
$('dbCreditsSub').textContent = purchased.toLocaleString() + ' purchased · ' + earned.toLocaleString() + ' earned';
donut($('chDonut'),
[{ v: purchased, color: CH.mint }, { v: wc, color: CH.amber }, { v: earned - wc, color: CH.cyan }],
{ big: (purchased + earned).toLocaleString(), small: 'spendable' });
legend($('chDonutLegend'), [
{ color: CH.mint, label: 'Purchased', v: purchased.toLocaleString() },
{ color: CH.amber, label: 'Welcome', v: wc.toLocaleString() },
{ color: CH.cyan, label: 'Earned by viewing', v: Math.max(0, earned - wc).toLocaleString() }]);
const done = Math.min(st.views || 0, st.target || 5), left = Math.max(0, (st.target || 5) - done);
donut($('chRing'), [{ v: done, color: st.claimed ? CH.mint : CH.cyan }, { v: left, color: 'none' }],
{ big: done + '/' + (st.target || 5), small: st.claimed ? 'claimed' : 'ads viewed' });
legend($('chRingLegend'), [
{ color: st.claimed ? CH.mint : CH.cyan, label: 'Viewed today', v: done },
{ color: CH.track, label: 'To go', v: left },
{ color: CH.amber, label: 'Claim pays', v: '+' + (st.claimCredits || 0) + ' credits' }]);
}
} catch (e) {}
// recent on-chain payouts, sized to scale, oldest→newest
try {
let evs = [];
if (d.memberId) {
const a = await (await fetch('/api/my/activity')).json();
evs = (a.earnings || []).filter(e => e.amountWei).sort((x, y) => x.block - y.block).slice(-12);
}
bars($('chEarnBars'), $('chEarnX'), evs.map(e => ({
v: Number(BigInt(e.amountWei) / 1000000000000n) / 1e6,
label: e.type === 'AwardPaid' ? 'award' : 'L' + (e.tier || 1),
tip: IAP.fmtPol(e.amountWei) + ' POL', alt: e.type === 'AwardPaid' })));
if (!evs.length) $('chEarnSub').textContent = d.memberId
? 'no payouts yet — share your link' : 'activate a package to start earning';
} catch (e) {}
// campaign delivery: impressions per campaign
try {
const r = await (await fetch('/api/my/campaigns')).json();
const cs = (r.campaigns || []).slice(0, 8);
bars($('chCampBars'), $('chCampX'), cs.map(c => ({
v: c.imps || 0, label: String(c.name || c.type).slice(0, 9),
tip: (c.name || c.type) + ': ' + (c.imps || 0) + ' imps · ' + (c.clicks || 0) + ' clicks',
alt: c.type === 'text' })));
if (!cs.length) $('chCampSub').textContent = 'no campaigns yet — place your first ad';
} catch (e) {}
}
async function loadDashboard() {
try {
const d = await (await fetch('/api/my/dashboard')).json();
@@ -36,6 +116,17 @@
$('dbEarned').textContent = IAP.fmtPol(d.earnedWei || '0');
$('dbBuyers').textContent = d.buyerCount || 0;
$('dbTeam').textContent = (d.referrals || []).length;
// trend chips: only real, computable facts
const ec = $('dbEarnedChip');
if (ec && d.earnCount) { ec.hidden = false; ec.textContent = d.earnCount + ' instant payout' + (d.earnCount > 1 ? 's' : ''); }
const bc = $('dbBuyersChip');
if (bc && d.memberId) {
bc.hidden = false;
bc.textContent = (d.buyerCount || 0) >= 5 ? 'level 3 open' : (d.buyerCount || 0) >= 2 ? 'level 2 open' : 'level 2 at 2 buyers';
}
const tc = $('dbTeamChip'), wk = (d.referrals || []).filter(r => Date.now() - new Date(r.joined) < 6048e5).length;
if (tc && wk) { tc.hidden = false; tc.textContent = '+' + wk + ' this week'; }
loadCharts(d);
$('nextMove').textContent = nextMove(d);
$('qualFill').style.width = Math.min(100, (d.buyerCount || 0) * 20) + '%';
const wrap = $('rosterWrap');
+37 -1
View File
@@ -258,7 +258,43 @@ input:focus,select:focus{border-color:var(--mint)}
#boBurger{display:block}
.bo-content{padding:18px}
}
/* back-office accent family: green leads, cyan/violet/amber season the cards */
/* ── overview v3: stat cards w/ plates+chips, hand-rolled charts ── */
.statx{display:flex;gap:14px;align-items:flex-start;background:var(--panel);border:1px solid var(--line);
border-radius:var(--radius);padding:18px;position:relative;overflow:hidden}
.statx .pl{flex:0 0 auto;width:46px;height:46px;border-radius:13px;display:grid;place-items:center;
background:rgba(67,232,195,.1);border:1px solid rgba(67,232,195,.3)}
.statx .pl svg{width:22px;height:22px;stroke:var(--mint);fill:none;stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round}
.statx.c-cyan .pl{background:rgba(84,204,255,.1);border-color:rgba(84,204,255,.3)}
.statx.c-cyan .pl svg{stroke:var(--cyan)}
.statx.c-violet .pl{background:rgba(157,125,255,.12);border-color:rgba(157,125,255,.32)}
.statx.c-violet .pl svg{stroke:var(--violet)}
.statx.c-amber .pl{background:rgba(255,178,56,.1);border-color:rgba(255,178,56,.3)}
.statx.c-amber .pl svg{stroke:var(--amber)}
.statx .nv{font-family:var(--disp);font-size:26px;font-weight:800;font-variant-numeric:tabular-nums;line-height:1.1}
.statx .lb{font-size:12px;color:var(--muted);text-transform:uppercase;letter-spacing:.08em;margin-top:2px}
.chip{display:inline-block;margin-top:7px;font-size:11.5px;font-weight:700;border-radius:999px;padding:2px 10px;
background:rgba(67,232,195,.1);color:var(--mint)}
.chip.flat{background:rgba(139,166,156,.12);color:var(--muted)}
.chip.warm{background:rgba(255,178,56,.12);color:var(--amber)}
.card-head{display:flex;align-items:baseline;justify-content:space-between;gap:10px}
.card-head .sub{font-size:12px;color:var(--muted)}
/* bar chart (divs, real data) */
.barchart{display:flex;align-items:flex-end;gap:6px;height:120px;margin-top:14px}
.barchart .bar{flex:1;min-width:6px;background:linear-gradient(180deg,var(--mint),rgba(67,232,195,.25));
border-radius:5px 5px 2px 2px;position:relative;transition:height .5s ease}
.barchart .bar.alt{background:linear-gradient(180deg,var(--violet),rgba(157,125,255,.25))}
.barchart .bar:hover::after{content:attr(data-v);position:absolute;bottom:calc(100% + 4px);left:50%;
transform:translateX(-50%);background:var(--panel-solid);border:1px solid var(--line-strong);border-radius:7px;
padding:2px 8px;font-family:var(--mono);font-size:11px;white-space:nowrap;z-index:3}
.barchart .bar.empty{background:rgba(139,166,156,.15);height:4px!important}
.chart-x{display:flex;gap:6px;margin-top:6px}
.chart-x span{flex:1;text-align:center;font-family:var(--mono);font-size:10px;color:var(--muted);overflow:hidden;white-space:nowrap}
/* donut + ring (SVG) */
.donut-wrap{display:flex;gap:22px;align-items:center;margin-top:10px;flex-wrap:wrap}
.donut-legend{display:flex;flex-direction:column;gap:8px;font-size:13px}
.donut-legend i{display:inline-block;width:10px;height:10px;border-radius:3px;margin-right:8px}
.donut-center{font-family:var(--disp);font-weight:800}
/* ── back-office accent family: green leads, cyan/violet/amber season the cards ── */
.bo .stats .stat:nth-child(2) .n{color:var(--cyan)}
.bo .stats .stat:nth-child(2)::before{background:linear-gradient(90deg,transparent,var(--cyan),transparent)}
.bo .stats .stat:nth-child(3) .n{color:var(--violet)}