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:
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user