235 lines
11 KiB
TypeScript
235 lines
11 KiB
TypeScript
"use client"
|
|
|
|
import { useEffect, useState } from "react"
|
|
import { motion } from "framer-motion"
|
|
import { cn } from "@/lib/utils"
|
|
import { Card, CardContent } from "@/components/ui/card"
|
|
import { LucideIcon } from "lucide-react"
|
|
|
|
interface StatCardProps {
|
|
title: string
|
|
value: string | number
|
|
icon: LucideIcon
|
|
description?: string
|
|
index?: number
|
|
trend?: { pct: number; up: boolean }
|
|
sparklineField?: "total" | "open" | "contacted" | "pending" | "closed" | "ignored"
|
|
monthlyBreakdown?: { label: string; total: number; open: number; contacted: number; pending: number; closed: number; ignored: number }[]
|
|
conversionRate?: number
|
|
}
|
|
|
|
const cardColors: Record<string, { bg: string; text: string; glow: string; accent: string }> = {
|
|
"Total Leads": { bg: "bg-[#CC0000]/10", text: "text-[#CC0000] dark:text-[#FF1111]", glow: "via-[rgba(204,0,0,0.25)]", accent: "#CC0000" },
|
|
"Open Leads": { bg: "bg-[#0033CC]/10", text: "text-[#0033CC] dark:text-[#1144FF]", glow: "via-[rgba(0,51,204,0.25)]", accent: "#0033CC" },
|
|
"Contacted": { bg: "bg-[#CC0000]/10", text: "text-[#CC0000] dark:text-[#FF1111]", glow: "via-[rgba(204,0,0,0.25)]", accent: "#CC0000" },
|
|
"Pending": { bg: "bg-[#0033CC]/10", text: "text-[#0033CC] dark:text-[#1144FF]", glow: "via-[rgba(0,51,204,0.25)]", accent: "#0033CC" },
|
|
"Closed": { bg: "bg-[#CC0000]/10", text: "text-[#CC0000] dark:text-[#FF1111]", glow: "via-[rgba(204,0,0,0.25)]", accent: "#CC0000" },
|
|
"Conversion Rate": { bg: "bg-[#0033CC]/10", text: "text-[#0033CC] dark:text-[#1144FF]", glow: "via-[rgba(0,51,204,0.25)]", accent: "#0033CC" },
|
|
}
|
|
|
|
function computeGoal(max: number): number {
|
|
if (max <= 0) return 100
|
|
return Math.ceil(max / 100) * 100 || 100
|
|
}
|
|
|
|
function smoothPath(points: { x: number; y: number }[]): string {
|
|
if (points.length === 0) return ""
|
|
if (points.length === 1) return `M${points[0].x.toFixed(1)},${points[0].y.toFixed(1)}`
|
|
let d = `M${points[0].x.toFixed(1)},${points[0].y.toFixed(1)}`
|
|
for (let i = 1; i < points.length - 1; i++) {
|
|
const p0 = points[i - 1]
|
|
const p1 = points[i]
|
|
const p2 = points[i + 1]
|
|
const cp1x = p1.x - (p2.x - p0.x) * 0.15
|
|
const cp1y = p1.y - (p2.y - p0.y) * 0.15
|
|
const cp2x = p1.x + (p2.x - p0.x) * 0.15
|
|
const cp2y = p1.y + (p2.y - p0.y) * 0.15
|
|
d += `C${cp1x.toFixed(1)},${cp1y.toFixed(1)} ${cp2x.toFixed(1)},${cp2y.toFixed(1)} ${p2.x.toFixed(1)},${p2.y.toFixed(1)}`
|
|
}
|
|
return d
|
|
}
|
|
|
|
export function StatCard({ title, value, icon: Icon, description, index = 0, trend, sparklineField, monthlyBreakdown, conversionRate }: StatCardProps) {
|
|
const color = cardColors[title] ?? cardColors["Total Leads"]
|
|
|
|
const isNumeric = typeof value === "number"
|
|
const [display, setDisplay] = useState(0)
|
|
const target = typeof value === "number" ? value : 0
|
|
|
|
useEffect(() => {
|
|
if (!isNumeric) return
|
|
const duration = 1000
|
|
const steps = 40
|
|
const increment = target / steps
|
|
let current = 0
|
|
const timer = setInterval(() => {
|
|
current += increment
|
|
if (current >= target) {
|
|
setDisplay(target)
|
|
clearInterval(timer)
|
|
} else {
|
|
setDisplay(Math.floor(current))
|
|
}
|
|
}, duration / steps)
|
|
return () => clearInterval(timer)
|
|
}, [target, isNumeric])
|
|
|
|
function buildSparklineSvg(): { path: string; area: string; goal: number; gridLines: { y: number }[] } {
|
|
if (!monthlyBreakdown || !sparklineField) return { path: "", area: "", goal: 100, gridLines: [] }
|
|
const values = monthlyBreakdown.map((m) => m[sparklineField]).reverse()
|
|
const max = Math.max(...values, 0)
|
|
const goal = computeGoal(max)
|
|
const dataRange = Math.max(max, 1)
|
|
const range = goal <= 10 ? Math.max(dataRange * 2, 10) : Math.min(goal, Math.max(dataRange * 3, 10))
|
|
const w = values.length - 1 || 1
|
|
const pad = 4
|
|
const graphW = 60 - pad * 2
|
|
const graphH = 28
|
|
|
|
const points = values.map((v, i) => ({
|
|
x: pad + (i / w) * graphW,
|
|
y: graphH - (v / range) * (graphH - 3) - 1,
|
|
}))
|
|
|
|
const smooth = smoothPath(points)
|
|
const area = points.length > 0
|
|
? `${smooth} L${points[points.length - 1].x.toFixed(1)},${graphH} L${points[0].x.toFixed(1)},${graphH} Z`
|
|
: ""
|
|
|
|
const steps = 4
|
|
const gridLines = Array.from({ length: steps - 1 }, (_, i) => ({
|
|
y: graphH - ((i + 1) / steps) * (graphH - 3) - 1,
|
|
}))
|
|
|
|
return { path: smooth, area, goal, gridLines }
|
|
}
|
|
|
|
const { path: sparkPath, area: sparkArea, goal, gridLines } = buildSparklineSvg()
|
|
const sparkColor = color.accent
|
|
|
|
const isRed = index % 2 === 0
|
|
const stripColor = isRed ? "from-[#CC0000] via-[#FF1111] to-[#CC0000]" : "from-[#0033CC] via-[#1144FF] to-[#0033CC]"
|
|
const stripGlow = isRed ? "shadow-[#CC0000]/20" : "shadow-[#0033CC]/20"
|
|
|
|
return (
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 20 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ duration: 0.3, delay: index * 0.05 }}
|
|
className="relative"
|
|
>
|
|
{/* Web overlay decorative */}
|
|
<div className="absolute inset-0 pointer-events-none z-0 opacity-[0.03] dark:opacity-[0.06]">
|
|
<svg width="100%" height="100%" viewBox="0 0 180 180" preserveAspectRatio="none" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
{Array.from({ length: 6 }).map((_, i) => (
|
|
<line key={`wl-${i}`} x1="0" y1={i * 36} x2="180" y2={i * 36} stroke="#CC0000" strokeWidth="0.5" />
|
|
))}
|
|
{Array.from({ length: 6 }).map((_, i) => (
|
|
<line key={`wc-${i}`} x1={i * 36} y1="0" x2={i * 36} y2="180" stroke="#CC0000" strokeWidth="0.5" />
|
|
))}
|
|
{Array.from({ length: 6 }).map((_, i) => (
|
|
<line key={`wd-${i}`} x1="0" y1={i * 36} x2={180 - i * 36} y2="180" stroke="#0033CC" strokeWidth="0.5" />
|
|
))}
|
|
</svg>
|
|
</div>
|
|
|
|
{/* Comic action lines on hover */}
|
|
<div className="absolute -inset-2 pointer-events-none z-0 opacity-0 group-hover:opacity-100 transition-opacity duration-300">
|
|
<svg width="100%" height="100%" viewBox="0 0 200 200" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
<line x1="100" y1="0" x2="100" y2="20" stroke="#CC0000" strokeWidth="2" strokeLinecap="round" />
|
|
<line x1="100" y1="180" x2="100" y2="200" stroke="#CC0000" strokeWidth="2" strokeLinecap="round" />
|
|
<line x1="0" y1="100" x2="20" y2="100" stroke="#CC0000" strokeWidth="2" strokeLinecap="round" />
|
|
<line x1="180" y1="100" x2="200" y2="100" stroke="#CC0000" strokeWidth="2" strokeLinecap="round" />
|
|
<line x1="30" y1="30" x2="44" y2="44" stroke="#0033CC" strokeWidth="1.5" strokeLinecap="round" />
|
|
<line x1="170" y1="30" x2="156" y2="44" stroke="#0033CC" strokeWidth="1.5" strokeLinecap="round" />
|
|
<line x1="30" y1="170" x2="44" y2="156" stroke="#0033CC" strokeWidth="1.5" strokeLinecap="round" />
|
|
<line x1="170" y1="170" x2="156" y2="156" stroke="#0033CC" strokeWidth="1.5" strokeLinecap="round" />
|
|
<circle cx="100" cy="100" r="90" stroke="#CC0000" strokeWidth="0.5" strokeDasharray="4 4" opacity="0.4" />
|
|
<circle cx="100" cy="100" r="80" stroke="#0033CC" strokeWidth="0.5" strokeDasharray="3 5" opacity="0.3" />
|
|
</svg>
|
|
</div>
|
|
|
|
<Card className="group h-full hover:shadow-xl transition-all duration-200 relative z-10 overflow-hidden">
|
|
{/* Red/Blue gradient top strip */}
|
|
<div className={`h-1 w-full bg-gradient-to-r ${stripColor} ${stripGlow} shadow-sm`} />
|
|
|
|
<CardContent className="p-6 flex flex-col">
|
|
<div className="flex items-center justify-between">
|
|
<div className="space-y-1">
|
|
<p className="text-sm font-medium text-muted-foreground">{title}</p>
|
|
<p className="text-3xl font-bold tracking-tight text-[#111111] dark:text-white">
|
|
{isNumeric ? display : value}
|
|
</p>
|
|
{description && (
|
|
<p className="text-xs text-[#888888] dark:text-[#666666]">{description}</p>
|
|
)}
|
|
{trend && (
|
|
<span className={cn(
|
|
"inline-flex items-center gap-0.5 text-xs font-semibold",
|
|
trend.up ? "text-emerald-500" : "text-rose-500"
|
|
)}>
|
|
{trend.up ? "↑" : "↓"} {trend.pct}%
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className={`flex h-12 w-12 items-center justify-center rounded-xl shrink-0 ${color.bg}`}>
|
|
<Icon className={`h-6 w-6 ${color.text}`} />
|
|
</div>
|
|
</div>
|
|
|
|
{sparkPath && (
|
|
<div className="mt-auto relative">
|
|
<svg width="60" height="28" viewBox="0 0 60 28" className="opacity-60 group-hover:opacity-100 transition-opacity duration-200">
|
|
<defs>
|
|
<linearGradient id={`spark-fill-${title.replace(/\s/g, "")}`} x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="0%" stopColor={sparkColor} stopOpacity="0.2" />
|
|
<stop offset="100%" stopColor={sparkColor} stopOpacity="0" />
|
|
</linearGradient>
|
|
<filter id={`glow-${title.replace(/\s/g, "")}`}>
|
|
<feGaussianBlur stdDeviation="2" result="blur" />
|
|
<feMerge>
|
|
<feMergeNode in="blur" />
|
|
<feMergeNode in="SourceGraphic" />
|
|
</feMerge>
|
|
</filter>
|
|
</defs>
|
|
{gridLines.map((gl, i) => (
|
|
<line key={i} x1="0" y1={gl.y} x2="60" y2={gl.y} stroke="currentColor" strokeOpacity="0.08" strokeWidth="1" strokeDasharray="2,2" />
|
|
))}
|
|
<path d={sparkArea} fill={`url(#spark-fill-${title.replace(/\s/g, "")})`} />
|
|
<path
|
|
d={sparkPath}
|
|
fill="none"
|
|
stroke={sparkColor}
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
className="animate-pulse"
|
|
filter={`url(#glow-${title.replace(/\s/g, "")})`}
|
|
/>
|
|
</svg>
|
|
<span className="absolute -bottom-0.5 right-0 text-[9px] font-medium text-muted-foreground/60">
|
|
/{goal}
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
{title === "Conversion Rate" && conversionRate !== undefined && (
|
|
<div className="mt-3">
|
|
<div className="w-full h-1.5 bg-muted rounded-full overflow-hidden">
|
|
<div className="h-full rounded-full bg-gradient-to-r from-[#CC0000] to-[#0033CC]" style={{ width: `${Math.min(conversionRate, 100)}%` }} />
|
|
</div>
|
|
<p className="text-xs text-[#888888] dark:text-[#666666] mt-1">{conversionRate}% conversion rate</p>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
|
|
<div className={cn(
|
|
"absolute bottom-0 left-0 right-0 h-1 bg-gradient-to-r from-transparent to-transparent",
|
|
color.glow
|
|
)} />
|
|
</Card>
|
|
</motion.div>
|
|
)
|
|
}
|