feat(leaderboard): animate podium numbers (count-up) and staggered bottom-up bar growth

Podium values now count up via AnimatedNumber with per-metric formatting
(currency/percent/deals). Podium bars grow from a fixed baseline at constant
speed so bronze settles first, then silver, then gold last; reserve row height
so the baseline doesn't shift mid-animation. Remove unused formatSales/formatCv.
This commit is contained in:
Satyam Rastogi
2026-05-29 01:54:33 +05:30
parent 2e481e13ad
commit c68d11dd98
+29 -23
View File
@@ -1,4 +1,5 @@
import React, { useState, useMemo } from 'react'; import React, { useState, useMemo } from 'react';
import { motion } from 'framer-motion';
import { useMockStore } from '../data/mockStore'; import { useMockStore } from '../data/mockStore';
import { import {
Trophy, TrendingUp, DollarSign, Target, Crown, ShieldOff, Trophy, TrendingUp, DollarSign, Target, Crown, ShieldOff,
@@ -16,10 +17,12 @@ const rankBadge = (i) =>
'text-zinc-500'; 'text-zinc-500';
// ── Podium (shared) ────────────────────────────────────────────────────────── // ── Podium (shared) ──────────────────────────────────────────────────────────
const TopPodium = ({ data, metricKey, formatVal, accentColor = 'border-yellow-400' }) => { const TopPodium = ({ data, metricKey, numberFormat = {}, accentColor = 'border-yellow-400' }) => {
if (data.length < 3) return null; if (data.length < 3) return null;
const [first, second, third] = data; const [first, second, third] = data;
const Step = ({ agent, rank, height, color, glow }) => ( // Bars grow from a shared baseline at a constant speed, so the shortest (bronze)
// finishes first, then silver, and the tallest (gold) lands last.
const Step = ({ agent, rank, heightPx, color, glow }) => (
<div className="flex flex-col items-center z-10 mx-2 md:mx-4"> <div className="flex flex-col items-center z-10 mx-2 md:mx-4">
<div className="mb-4 text-center"> <div className="mb-4 text-center">
<div className="relative inline-block"> <div className="relative inline-block">
@@ -33,20 +36,27 @@ const TopPodium = ({ data, metricKey, formatVal, accentColor = 'border-yellow-40
</div> </div>
<h3 className="font-bold text-sm md:text-base text-zinc-900 dark:text-white truncate max-w-[100px]">{agent.name}</h3> <h3 className="font-bold text-sm md:text-base text-zinc-900 dark:text-white truncate max-w-[100px]">{agent.name}</h3>
<p className={`font-mono font-bold text-sm md:text-lg ${color.replace('border-', 'text-')}`}> <p className={`font-mono font-bold text-sm md:text-lg ${color.replace('border-', 'text-')}`}>
{formatVal(agent[metricKey])} <AnimatedNumber value={agent[metricKey]} duration={1.4} {...numberFormat} />
</p> </p>
</div> </div>
<div className={`w-24 md:w-32 ${height} rounded-t-lg bg-gradient-to-b from-white/80 to-white/20 dark:from-white/10 dark:to-white/5 border-t border-x ${color} backdrop-blur-md relative overflow-hidden hover:brightness-110 transition-all duration-500`}> <motion.div
initial={{ height: 0 }}
animate={{ height: heightPx }}
transition={{ duration: heightPx / 100, ease: 'easeOut' }}
className={`w-24 md:w-32 rounded-t-lg bg-gradient-to-b from-white/80 to-white/20 dark:from-white/10 dark:to-white/5 border-t border-x ${color} backdrop-blur-md relative overflow-hidden hover:brightness-110`}
>
<div className="absolute inset-x-0 top-0 h-[1px] bg-white/50" /> <div className="absolute inset-x-0 top-0 h-[1px] bg-white/50" />
<div className="w-full h-full flex items-end justify-center pb-4 text-4xl font-black text-black/5 dark:text-white/5 select-none">{rank}</div> <div className="w-full h-full flex items-end justify-center pb-4 text-4xl font-black text-black/5 dark:text-white/5 select-none">{rank}</div>
</div> </motion.div>
</div> </div>
); );
return ( return (
<div className="flex items-end justify-center mb-12 pt-8"> // Fixed min-height reserves the full podium space so the shared baseline
<Step agent={second} rank={2} height="h-32" color="border-zinc-300" glow="bg-zinc-400" /> // never shifts while the bars animate — they grow upward from the floor.
<Step agent={first} rank={1} height="h-44" color={accentColor} glow="bg-yellow-500" /> <div className="flex items-end justify-center mb-12 pt-8 min-h-[360px]">
<Step agent={third} rank={3} height="h-24" color="border-orange-400" glow="bg-orange-500" /> <Step agent={second} rank={2} heightPx={128} color="border-zinc-300" glow="bg-zinc-400" />
<Step agent={first} rank={1} heightPx={176} color={accentColor} glow="bg-yellow-500" />
<Step agent={third} rank={3} heightPx={96} color="border-orange-400" glow="bg-orange-500" />
</div> </div>
); );
}; };
@@ -137,13 +147,6 @@ const LeaderboardPage = () => {
}).sort((a, b) => b[metric] - a[metric]); }).sort((a, b) => b[metric] - a[metric]);
}, [agents, salesHistory, timeframe, metric]); }, [agents, salesHistory, timeframe, metric]);
const formatSales = (val, m) => {
if (m === 'revenue') return `$${val.toLocaleString('en-US')}`;
if (m === 'volume') return `${val} Deals`;
if (m === 'winRate') return `${val.toFixed(1)}%`;
return val;
};
// ── Canvasser leaderboard data ─────────────────────────────────────────── // ── Canvasser leaderboard data ───────────────────────────────────────────
// Single source of truth: canvasserHistory records, filtered by date window. // Single source of truth: canvasserHistory records, filtered by date window.
// verified = records with status 'verified' OR 'converted' (converted is also verified) // verified = records with status 'verified' OR 'converted' (converted is also verified)
@@ -170,11 +173,6 @@ const LeaderboardPage = () => {
}).sort((a, b) => b[cvMetric] - a[cvMetric]); }).sort((a, b) => b[cvMetric] - a[cvMetric]);
}, [agents, canvasserHistory, cvTimeframe, cvMetric]); }, [agents, canvasserHistory, cvTimeframe, cvMetric]);
const formatCv = (val, m) => {
if (m === 'convRate') return `${val.toFixed(1)}%`;
return `${val}`;
};
// ── Permission guard ───────────────────────────────────────────────────── // ── Permission guard ─────────────────────────────────────────────────────
if (!can('leaderboard', 'view')) { if (!can('leaderboard', 'view')) {
return ( return (
@@ -259,7 +257,11 @@ const LeaderboardPage = () => {
<TopPodium <TopPodium
data={salesData} data={salesData}
metricKey={metric} metricKey={metric}
formatVal={v => formatSales(v, metric)} numberFormat={{
revenue: { prefix: '$', useLocaleString: true },
volume: { suffix: ' Deals' },
winRate: { suffix: '%', decimals: 1 },
}[metric]}
accentColor="border-yellow-400" accentColor="border-yellow-400"
/> />
</div> </div>
@@ -350,7 +352,11 @@ const LeaderboardPage = () => {
<TopPodium <TopPodium
data={canvasserData} data={canvasserData}
metricKey={cvMetric} metricKey={cvMetric}
formatVal={v => formatCv(v, cvMetric)} numberFormat={{
verified: {},
total: {},
convRate: { suffix: '%', decimals: 1 },
}[cvMetric]}
accentColor="border-emerald-400" accentColor="border-emerald-400"
/> />
</div> </div>