adbcc4b9af
feat: implement conversations API with message retrieval and posting feat: add avatar URL handling for users and update user role definitions feat: create chat system tables in the database with initial seed data fix: update user roles from "sales_user" to "sales" for consistency chore: add middleware for system API route access fixed fuckups on john's side, added a benchmark Made Graphs actualy load from database and realtime data, graphs will update and percentages will be mathematically made, and made realtime added chatcart edits, made graphs glow. added in some little small home feeling fuctions added in a slide show, in login with qoutes from creators because i want to leave a print on it saying we made this shit, we built it brick for brick login page added qoutes some random, and made them shuffle from left to right one dissapears other come in adding shape to the textbox for the qoutes Fixed my chat fuckups when it comes to chats
197 lines
8.2 KiB
TypeScript
197 lines
8.2 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-cyan-500/10", text: "text-cyan-600 dark:text-cyan-400", glow: "via-[rgba(6,182,212,0.25)]", accent: "#06b6d4" },
|
|
"Open Leads": { bg: "bg-blue-500/10", text: "text-blue-600 dark:text-blue-400", glow: "via-[rgba(59,130,246,0.25)]", accent: "#3b82f6" },
|
|
"Contacted": { bg: "bg-amber-500/10", text: "text-amber-600 dark:text-amber-400", glow: "via-[rgba(245,158,11,0.25)]", accent: "#f59e0b" },
|
|
"Pending": { bg: "bg-violet-500/10", text: "text-violet-600 dark:text-violet-400", glow: "via-[rgba(139,92,246,0.25)]", accent: "#8b5cf6" },
|
|
"Closed": { bg: "bg-yellow-500/10", text: "text-yellow-600 dark:text-yellow-400", glow: "via-[rgba(234,179,8,0.25)]", accent: "#eab308" },
|
|
"Conversion Rate": { bg: "bg-rose-500/10", text: "text-rose-600 dark:text-rose-400", glow: "via-[rgba(244,63,94,0.25)]", accent: "#f43f5e" },
|
|
}
|
|
|
|
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
|
|
|
|
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-md transition-all duration-200">
|
|
<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">
|
|
{isNumeric ? display : value}
|
|
</p>
|
|
{description && (
|
|
<p className="text-xs text-muted-foreground">{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={cn("flex h-12 w-12 items-center justify-center rounded-xl shrink-0", color.bg)}>
|
|
<Icon className={cn("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-[#f43f5e] to-[#fda4af]" style={{ width: `${Math.min(conversionRate, 100)}%` }} />
|
|
</div>
|
|
<p className="text-xs text-muted-foreground 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>
|
|
)
|
|
}
|