147 lines
4.7 KiB
TypeScript
147 lines
4.7 KiB
TypeScript
import { cn } from "@/lib/utils"
|
|||
|
|
import type { LucideIcon } from "lucide-react"
|
||
|
|
import { TrendingUp, TrendingDown, Minus } from "lucide-react"
|
||
|
|
import { AnimatedNumber } from "@/components/ui/animated-number"
|
||
|
|
|
||
|
|
interface StatsCardProps {
|
||
|
|
label: string
|
||
|
|
value: string | number
|
||
|
|
sub?: string
|
||
|
|
icon: LucideIcon
|
||
|
|
trend?: { value: number; label: string }
|
||
|
|
variant?: "default" | "success" | "warning" | "danger"
|
||
|
|
progress?: number
|
||
|
|
className?: string
|
||
|
|
}
|
||
|
|
|
||
|
|
export function StatsCard({
|
||
|
|
label,
|
||
|
|
value,
|
||
|
|
sub,
|
||
|
|
icon: Icon,
|
||
|
|
trend,
|
||
|
|
variant = "default",
|
||
|
|
progress,
|
||
|
|
className,
|
||
|
|
}: StatsCardProps) {
|
||
|
|
const themes = {
|
||
|
|
default: {
|
||
|
|
icon: "text-indigo-400 bg-indigo-500/10 border-indigo-500/20",
|
||
|
|
glow: "group-hover:shadow-indigo-500/10",
|
||
|
|
bar: "from-indigo-500 to-violet-500",
|
||
|
|
gradient: "group-hover:from-indigo-500/[0.04]",
|
||
|
|
},
|
||
|
|
success: {
|
||
|
|
icon: "text-emerald-400 bg-emerald-500/10 border-emerald-500/20",
|
||
|
|
glow: "group-hover:shadow-emerald-500/10",
|
||
|
|
bar: "from-emerald-500 to-teal-500",
|
||
|
|
gradient: "group-hover:from-emerald-500/[0.04]",
|
||
|
|
},
|
||
|
|
warning: {
|
||
|
|
icon: "text-amber-400 bg-amber-500/10 border-amber-500/20",
|
||
|
|
glow: "group-hover:shadow-amber-500/10",
|
||
|
|
bar: "from-amber-500 to-orange-500",
|
||
|
|
gradient: "group-hover:from-amber-500/[0.04]",
|
||
|
|
},
|
||
|
|
danger: {
|
||
|
|
icon: "text-red-400 bg-red-500/10 border-red-500/20",
|
||
|
|
glow: "group-hover:shadow-red-500/10",
|
||
|
|
bar: "from-red-500 to-rose-500",
|
||
|
|
gradient: "group-hover:from-red-500/[0.04]",
|
||
|
|
},
|
||
|
|
}
|
||
|
|
|
||
|
|
const theme = themes[variant]
|
||
|
|
|
||
|
|
const trendColor =
|
||
|
|
!trend ? "" :
|
||
|
|
trend.value > 0 ? "text-emerald-400" :
|
||
|
|
trend.value < 0 ? "text-red-400" : "text-white/40"
|
||
|
|
|
||
|
|
const TrendIcon = !trend ? Minus : trend.value > 0 ? TrendingUp : trend.value < 0 ? TrendingDown : Minus
|
||
|
|
|
||
|
|
// Detect if value is a plain number (animate) or a formatted string
|
||
|
|
const isNumeric = typeof value === "number"
|
||
|
|
// For currency strings like "$4,200" — extract the number to animate
|
||
|
|
const isCurrencyString = typeof value === "string" && value.startsWith("$")
|
||
|
|
const numericVal = isNumeric
|
||
|
|
? value
|
||
|
|
: isCurrencyString
|
||
|
|
? parseFloat(value.replace(/[$,]/g, ""))
|
||
|
|
: null
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div
|
||
|
|
className={cn(
|
||
|
|
"group relative overflow-hidden rounded-2xl border border-white/[0.06] bg-[#16161f] p-5 transition-all duration-300",
|
||
|
|
"hover:border-white/[0.12] hover:shadow-xl hover:shadow-black/30",
|
||
|
|
theme.glow,
|
||
|
|
className
|
||
|
|
)}
|
||
|
|
>
|
||
|
|
{/* Hover gradient */}
|
||
|
|
<div className={cn(
|
||
|
|
"absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity duration-500",
|
||
|
|
"bg-gradient-to-br to-transparent from-transparent",
|
||
|
|
theme.gradient
|
||
|
|
)} />
|
||
|
|
|
||
|
|
{/* Top accent line */}
|
||
|
|
<div className={cn(
|
||
|
|
"absolute top-0 left-0 right-0 h-[1px] opacity-0 group-hover:opacity-100 transition-opacity duration-500",
|
||
|
|
"bg-gradient-to-r", theme.bar
|
||
|
|
)} />
|
||
|
|
|
||
|
|
<div className="relative">
|
||
|
|
{/* Label + icon */}
|
||
|
|
<div className="flex items-start justify-between">
|
||
|
|
<p className="text-xs font-medium uppercase tracking-wider text-white/40">{label}</p>
|
||
|
|
<div className={cn("flex h-9 w-9 shrink-0 items-center justify-center rounded-xl border", theme.icon)}>
|
||
|
|
<Icon className="h-4 w-4" />
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Value — animated if numeric */}
|
||
|
|
<p className="mt-4 text-3xl font-bold tracking-tight text-white tabular-nums">
|
||
|
|
{numericVal !== null ? (
|
||
|
|
<AnimatedNumber
|
||
|
|
value={numericVal}
|
||
|
|
format={isCurrencyString ? "currency" : "number"}
|
||
|
|
/>
|
||
|
|
) : (
|
||
|
|
value
|
||
|
|
)}
|
||
|
|
</p>
|
||
|
|
|
||
|
|
{/* Trend */}
|
||
|
|
{trend && (
|
||
|
|
<div className="mt-1.5 flex items-center gap-1.5">
|
||
|
|
<TrendIcon className={cn("h-3.5 w-3.5", trendColor)} />
|
||
|
|
<span className={cn("text-xs font-semibold tabular-nums", trendColor)}>
|
||
|
|
{trend.value > 0 ? "+" : ""}{trend.value}%
|
||
|
|
</span>
|
||
|
|
<span className="text-xs text-white/30">{trend.label}</span>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* Progress bar */}
|
||
|
|
{progress !== undefined && (
|
||
|
|
<div className="mt-4">
|
||
|
|
<div className="h-1.5 overflow-hidden rounded-full bg-white/[0.06]">
|
||
|
|
<div
|
||
|
|
className={cn("h-full rounded-full bg-gradient-to-r transition-all duration-700", theme.bar)}
|
||
|
|
style={{ width: `${Math.min(100, Math.max(0, progress))}%` }}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* Sub */}
|
||
|
|
{sub && (
|
||
|
|
<p className="mt-2.5 text-xs text-white/40 leading-snug">{sub}</p>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
}
|