Current state
This commit is contained in:
@@ -0,0 +1,203 @@
|
||||
"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"
|
||||
>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user