Files
CRM_ENVR/src/components/dashboard/stat-card.tsx
T
2026-06-29 15:26:44 +02:00

218 lines
10 KiB
TypeScript

"use client"
import { useEffect, useState } from "react"
import { motion } from "framer-motion"
import { cn } from "@/lib/utils"
import { useWebsiteTheme } from "@/providers/website-theme-provider"
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-[#C84B4B]/10", text: "text-[#C84B4B] dark:text-[#FF1111]", glow: "via-[rgba(200,75,75,0.25)]", accent: "#C84B4B" },
"Open Leads": { bg: "bg-[#5A8FC4]/10", text: "text-[#5A8FC4] dark:text-[#1144FF]", glow: "via-[rgba(90,143,196,0.25)]", accent: "#5A8FC4" },
"Contacted": { bg: "bg-[#C84B4B]/10", text: "text-[#C84B4B] dark:text-[#FF1111]", glow: "via-[rgba(200,75,75,0.25)]", accent: "#C84B4B" },
"Pending": { bg: "bg-[#5A8FC4]/10", text: "text-[#5A8FC4] dark:text-[#1144FF]", glow: "via-[rgba(90,143,196,0.25)]", accent: "#5A8FC4" },
"Closed": { bg: "bg-[#C84B4B]/10", text: "text-[#C84B4B] dark:text-[#FF1111]", glow: "via-[rgba(200,75,75,0.25)]", accent: "#C84B4B" },
"Conversion Rate": { bg: "bg-[#5A8FC4]/10", text: "text-[#5A8FC4] dark:text-[#1144FF]", glow: "via-[rgba(90,143,196,0.25)]", accent: "#5A8FC4" },
}
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 { websiteTheme } = useWebsiteTheme()
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-[#C84B4B] via-[#C84B4B] to-[#C84B4B]" : "from-[#5A8FC4] via-[#5A8FC4] to-[#5A8FC4]"
const stripGlow = isRed ? "shadow-[#C84B4B]/20" : "shadow-[#5A8FC4]/20"
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: index * 0.05 }}
className="relative"
>
<Card className="group h-full hover:shadow-xl transition-all duration-200 relative z-10 overflow-hidden">
{websiteTheme === "spidey" && (
<div className={`h-1 w-full bg-gradient-to-r ${stripColor} ${stripGlow} shadow-sm`} />
)}
{websiteTheme === "spidey" && (
<div
className="absolute inset-0 pointer-events-none opacity-0 group-hover:opacity-100 transition-opacity duration-300 z-0"
style={{
backgroundImage: `url("data:image/svg+xml,%3Csvg width='200' height='200' viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cg stroke='%23CC0000' stroke-width='0.4' opacity='0.15'%3E%3Cline x1='100' y1='100' x2='0' y2='0'/%3E%3Cline x1='100' y1='100' x2='50' y2='0'/%3E%3Cline x1='100' y1='100' x2='100' y2='0'/%3E%3Cline x1='100' y1='100' x2='150' y2='0'/%3E%3Cline x1='100' y1='100' x2='200' y2='0'/%3E%3Cline x1='100' y1='100' x2='200' y2='50'/%3E%3Cline x1='100' y1='100' x2='200' y2='100'/%3E%3Cline x1='100' y1='100' x2='200' y2='150'/%3E%3Cline x1='100' y1='100' x2='200' y2='200'/%3E%3Cline x1='100' y1='100' x2='150' y2='200'/%3E%3Cline x1='100' y1='100' x2='100' y2='200'/%3E%3Cline x1='100' y1='100' x2='50' y2='200'/%3E%3Cline x1='100' y1='100' x2='0' y2='200'/%3E%3Cline x1='100' y1='100' x2='0' y2='150'/%3E%3Cline x1='100' y1='100' x2='0' y2='100'/%3E%3Cline x1='100' y1='100' x2='0' y2='50'/%3E%3C/g%3E%3C/svg%3E")`,
backgroundSize: "cover",
backgroundPosition: "center",
}}
/>
)}
<CardContent className="p-6 flex flex-col relative z-[1]">
<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-[#2D3020] dark:text-white">
{isNumeric ? display : value}
</p>
{description && (
<p className="text-xs text-[#8A9078] 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-[#C84B4B] to-[#5A8FC4]" style={{ width: `${Math.min(conversionRate, 100)}%` }} />
</div>
<p className="text-xs text-[#8A9078] 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>
)
}