MicProtocol / apps /web /src /components /ui /StatCard.tsx
Benaah's picture
Upload folder using huggingface_hub
fb12607 verified
Raw History Blame Contribute Delete
4.52 kB
"use client";
import React, { useEffect, useState } from "react";
import { ArrowUp, ArrowDown, Activity } from "lucide-react";
interface StatCardProps {
label: string;
value: number | string;
trend?: "up" | "down" | "neutral";
trendLabels?: {
label: string;
value: string;
};
previous?: number;
delta?: number;
goodWhen?: "up" | "down";
period?: string;
format?: "number" | "currency" | "percent";
color?: string;
animated?: boolean;
className?: string;
}
export function StatCard({
label,
value,
trend = "neutral",
trendLabels,
previous,
delta,
goodWhen = "up",
period,
format = "number",
color,
animated = true,
className,
}: StatCardProps) {
const [displayValue, setDisplayValue] = useState(
typeof value === "number" ? 0 : value
);
// Count-up animation
useEffect(() => {
if (!animated || typeof value !== "number") return;
let start = 0;
const end = value;
const duration = 1000;
const startTime = Date.now();
const animate = () => {
const elapsed = Date.now() - startTime;
const progress = Math.min(elapsed / duration, 1);
const current = start + (end - start) * progress;
setDisplayValue(current);
if (progress < 1) {
requestAnimationFrame(animate);
}
};
animate();
}, [value, animated]);
const formatValue = (val: number | string) => {
if (typeof val === "string") return val;
if (format === "currency")
return new Intl.NumberFormat("en-KE", {
style: "currency",
currency: "KES",
}).format(val);
if (format === "percent") return `${val}%`;
return val.toLocaleString();
};
const isPositive = delta != null ? delta >= 0 : trend === "up";
const isGood =
goodWhen === "up" ? isPositive : !isPositive;
const trendColor = isGood
? "var(--color-accent)"
: "var(--color-error)";
const trendBg = isGood
? "rgba(16, 185, 129, 0.12)"
: "rgba(239, 68, 68, 0.12)";
const showDelta = delta != null || trendLabels?.value;
const deltaText =
delta != null
? `${isPositive ? "+" : ""}${delta.toLocaleString()}`
: trendLabels?.value ?? "";
const sparklineColor = color || "var(--color-primary)";
// Simple sparkline data (mock)
const sparklinePoints = [30, 50, 40, 70, 60, 90, 75, 110];
return (
<div
className={[
"rounded-xl border border-border-color bg-bg-elevated p-5 shadow-sm transition-all",
className,
].filter(Boolean).join(" ")}
style={{ borderColor: "var(--border-color)" }}
>
<div className="flex items-start justify-between mb-3">
<div>
<p className="text-text-tertiary text-xs uppercase tracking-wider font-medium">
{label}
</p>
<p className="text-2xl font-display font-bold text-text-primary">
{formatValue(displayValue)}
</p>
</div>
<div className="w-10 h-10 rounded-md bg-bg-tertiary flex items-center justify-center border border-border-color-subtle">
<Activity size={16} className="text-text-tertiary" style={{ color: sparklineColor }} />
</div>
</div>
{showDelta && (
<div className="flex items-center gap-2 mb-2">
{trend === "up" ? (
<ArrowUp size={12} className={trendColor} />
) : trend === "down" ? (
<ArrowDown size={12} className={trendColor} />
) : (
<span className="w-3 h-3 rounded-full bg-text-tertiary" />
)}
<span
className="text-xs font-semibold"
style={{ color: trendColor }}
>
{deltaText}
</span>
<span className="text-text-tertiary text-xs">
{trendLabels?.label ?? ""}
</span>
</div>
)}
{period && (
<p className="text-text-tertiary text-xs">
{period}
</p>
)}
{/* Sparkline */}
<div className="mt-3 h-10 w-full">
<svg
className="w-full h-full"
viewBox="0 0 100 40"
fill="none"
>
<polyline
points={sparklinePoints
.map((p, i) => `${i * 14.28},${40 - p}`)
.join(" ")}
stroke={sparklineColor}
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
className="opacity-60"
/>
</svg>
</div>
</div>
);
}