first commit
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
import { type LucideIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function formatValue(value: string | number) {
|
||||
if (typeof value !== "number") return value
|
||||
const formatted = Intl.NumberFormat(undefined, {
|
||||
notation: "compact",
|
||||
maximumFractionDigits: 1,
|
||||
}).format(value)
|
||||
|
||||
// Normalize compact suffix casing to a consistent form to avoid
|
||||
// hydration mismatches between server and client environments.
|
||||
return formatted.replace(/(k|K|m|M|b|B)$/u, (s) => s.toUpperCase())
|
||||
}
|
||||
|
||||
function Sparkline({ points }: { points: number[] }) {
|
||||
const width = 48
|
||||
const height = 18
|
||||
const min = Math.min(...points)
|
||||
const max = Math.max(...points)
|
||||
const range = max - min || 1
|
||||
|
||||
const coords = points.map((point, index) => {
|
||||
const x = (index / (points.length - 1)) * width
|
||||
const y = height - ((point - min) / range) * height
|
||||
return [x, y] as const
|
||||
})
|
||||
|
||||
const path = coords.map(([x, y], i) => `${i === 0 ? "M" : "L"}${x},${y}`).join(" ")
|
||||
const [lastX, lastY] = coords[coords.length - 1]
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
className="h-4.5 w-12 shrink-0 overflow-visible"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d={path} fill="none" stroke="#cbd5e1" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
|
||||
<circle cx={lastX} cy={lastY} r={2.5} className="fill-indigo-600" stroke="white" strokeWidth={1.5} />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export interface StatCardProps {
|
||||
label: string
|
||||
value: string | number
|
||||
icon?: LucideIcon
|
||||
delta?: number
|
||||
deltaLabel?: string
|
||||
/** Set true for metrics where an increase is bad (e.g. churn, costs). */
|
||||
invert?: boolean
|
||||
trend?: number[]
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function StatCard({
|
||||
label,
|
||||
value,
|
||||
icon: Icon,
|
||||
delta,
|
||||
deltaLabel,
|
||||
invert = false,
|
||||
trend,
|
||||
className,
|
||||
}: StatCardProps) {
|
||||
const hasDelta = typeof delta === "number" && delta !== 0
|
||||
const isPositive = hasDelta && delta > 0
|
||||
const isGood = hasDelta && (invert ? !isPositive : isPositive)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-3 rounded-2xl bg-white p-5 shadow-sm ring-1 ring-black/5 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-lg hover:ring-indigo-200",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p className="text-base font-medium text-muted-foreground">{label}</p>
|
||||
{Icon && (
|
||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-indigo-50">
|
||||
<Icon className="size-5 text-indigo-600" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-end justify-between gap-2">
|
||||
<p className="text-xl font-bold tracking-tight text-slate-900">{formatValue(value)}</p>
|
||||
{trend && trend.length > 1 && <Sparkline points={trend} />}
|
||||
</div>
|
||||
|
||||
{hasDelta && (
|
||||
<div className="flex items-center gap-1 text-sm">
|
||||
<span
|
||||
className={cn(
|
||||
"font-semibold",
|
||||
isGood ? "text-[#0ca30c]" : "text-[#d03b3b]"
|
||||
)}
|
||||
>
|
||||
{isPositive ? "+" : "-"}
|
||||
{Math.abs(delta).toFixed(1)}%
|
||||
</span>
|
||||
{deltaLabel && <span className="text-muted-foreground">{deltaLabel}</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user