veza/apps/web/src/components/dashboard/StatCard.tsx
senke 5fe81b9333 feat(ui): animated number counters + navigation progress bar
Animated numbers:
- New useAnimatedCounter hook (requestAnimationFrame, ease-out cubic)
- New AnimatedNumber component with tabular-nums
- Applied to: DashboardPage (4 stats), UserProfilePageHeader (3 stats),
  StatCard, AdminDashboardStatCard (numeric values auto-animate)

Navigation progress bar:
- YouTube/GitHub-style thin bar at top of page
- Simulated progress on route changes (framer-motion)
- Primary color with glow shadow
- Integrated into Layout as first child

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-10 00:19:18 +01:00

137 lines
3.9 KiB
TypeScript

import React from 'react';
import { Card } from '../ui/card';
import { ArrowUp, ArrowDown } from 'lucide-react';
import { AnimatedNumber } from '../ui/AnimatedNumber';
interface StatCardProps {
label: string;
value: string | number;
icon: React.ReactNode;
trend?: string | number; // String like "+12%" or raw number
color?: 'cyan' | 'magenta' | 'lime' | 'gold' | 'red';
sparklineData?: number[];
}
export const StatCard: React.FC<StatCardProps> = ({
label,
value,
icon,
trend,
color = 'cyan',
sparklineData,
}) => {
/* Semantic tokens (audit P3: unify kodo-* → primary/muted/success/warning/destructive) */
const colorMap = {
cyan: 'text-primary',
magenta: 'text-secondary',
lime: 'text-success',
gold: 'text-warning',
red: 'text-destructive',
};
const bgMap = {
cyan: 'bg-primary/10',
magenta: 'bg-secondary/10',
lime: 'bg-success/10',
gold: 'bg-warning/10',
red: 'bg-destructive/10',
};
const renderSparkline = (data: number[]) => {
if (!data || data.length < 2) return null;
const min = Math.min(...data);
const max = Math.max(...data);
const range = max - min || 1;
const width = 100;
const height = 40;
const getPathData = (d: number[]) => {
const points = d.map((val, i) => ({
x: (i / (d.length - 1)) * width,
y: height - ((val - min) / range) * height,
}));
let pathStr = `M ${points[0].x},${points[0].y}`;
for (let i = 0; i < points.length - 1; i++) {
const curr = points[i];
const next = points[i + 1];
const mx = (curr.x + next.x) / 2;
pathStr += ` C ${mx},${curr.y} ${mx},${next.y} ${next.x},${next.y}`;
}
return pathStr;
};
return (
<svg
width="100%"
height={height}
viewBox={`0 0 ${width} ${height}`}
className="opacity-60 overflow-visible"
>
<path
d={getPathData(data)}
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
vectorEffect="non-scaling-stroke"
className="drop-shadow-stat-sparkline"
/>
</svg>
);
};
const isPositive =
typeof trend === 'string' ? !trend.startsWith('-') : (trend || 0) >= 0;
const trendValue = typeof trend === 'number' ? `${Math.abs(trend)}%` : trend;
return (
<Card
variant="surface"
className="flex flex-col justify-between h-full p-5 relative overflow-hidden rounded-xl"
>
<div className="flex justify-between items-start gap-4 relative z-10">
<div className="min-w-0">
<p className="text-xs font-medium text-muted-foreground mb-0.5">
{label}
</p>
<h3 className="text-xl font-semibold text-foreground tracking-tight truncate">
{typeof value === 'number' ? (
<AnimatedNumber value={value} />
) : (
value
)}
</h3>
</div>
<div className={`p-2 rounded-lg shrink-0 ${bgMap[color]} ${colorMap[color]}`}>
{icon}
</div>
</div>
<div className="relative z-10 flex items-center gap-1.5 mt-3">
{trend && (
<div
className={`flex items-center gap-1 text-xs font-medium ${isPositive ? 'text-success' : 'text-destructive'}`}
>
{isPositive ? (
<ArrowUp className="w-3 h-3 shrink-0" />
) : (
<ArrowDown className="w-3 h-3 shrink-0" />
)}
<span>{trendValue}</span>
<span className="text-muted-foreground font-normal">vs last period</span>
</div>
)}
</div>
{sparklineData && (
<div
className={`absolute bottom-0 left-0 right-0 h-12 ${colorMap[color]} opacity-20 pointer-events-none`}
>
{renderSparkline(sparklineData)}
</div>
)}
</Card>
);
};