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');