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:
@@ -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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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');
|
||||
|
||||
Reference in New Issue
Block a user