From 3f839bc0fc4fab21addec7efa14b50aa366a4c7b Mon Sep 17 00:00:00 2001 From: caitlin Date: Wed, 17 Jun 2026 17:04:47 +0200 Subject: [PATCH] Replace Recharts charts with custom SVG components - Lead status: custom SVG donut with radial gradients, hover effects, percentage tooltips - Leads per month: custom SVG grouped bar chart with hover dimming, gradient bars - Tooltip colors fixed to use theme variables - Right-panel lead status icon colors updated --- .../dashboard/lead-status-chart.tsx | 180 +++++++++--- .../dashboard/leads-per-month-chart.tsx | 267 ++++++++++++++---- src/data/dashboard.ts | 2 +- 3 files changed, 361 insertions(+), 88 deletions(-) diff --git a/src/components/dashboard/lead-status-chart.tsx b/src/components/dashboard/lead-status-chart.tsx index 568608f..1bd98d4 100644 --- a/src/components/dashboard/lead-status-chart.tsx +++ b/src/components/dashboard/lead-status-chart.tsx @@ -1,8 +1,8 @@ "use client" +import { useState } from "react" import { motion } from "framer-motion" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" -import { PieChart, Pie, Cell, ResponsiveContainer, Legend, Tooltip } from "recharts" interface StatusData { name: string @@ -14,7 +14,38 @@ interface LeadStatusChartProps { data: StatusData[] } +function polar(cx: number, cy: number, r: number, deg: number) { + const rad = ((deg - 90) * Math.PI) / 180 + return { x: cx + r * Math.cos(rad), y: cy + r * Math.sin(rad) } +} + +function arcPath(cx: number, cy: number, oR: number, iR: number, start: number, end: number) { + const gap = 3 + const s = start + gap / 2 + const e = end - gap / 2 + const o1 = polar(cx, cy, oR, s) + const o2 = polar(cx, cy, oR, e) + const i1 = polar(cx, cy, iR, e) + const i2 = polar(cx, cy, iR, s) + const lg = e - s > 180 ? 1 : 0 + return `M${o1.x} ${o1.y} A${oR} ${oR} 0 ${lg} 1 ${o2.x} ${o2.y} L${i1.x} ${i1.y} A${iR} ${iR} 0 ${lg} 0 ${i2.x} ${i2.y}Z` +} + export function LeadStatusChart({ data }: LeadStatusChartProps) { + const [hov, setHov] = useState(null) + + const total = data.reduce((s, d) => s + d.value, 0) + + let cum = 0 + const segs = data.map((d) => { + const start = cum + const deg = (d.value / total) * 360 + cum += deg + return { ...d, start, end: cum, deg, pct: Math.round((d.value / total) * 100) } + }) + + const active = hov !== null ? segs[hov] : null + return ( Lead Status -
- - - - {data.map((entry, index) => ( - - ))} - - + {/* Donut */} + + + {segs.map((s, i) => ( + + + + + ))} + + + {/* Background ring */} + + + {/* Segments */} + {segs.map((s, i) => { + const isH = hov === i + const oR = isH ? 137 : 130 + const iR = isH ? 73 : 80 + return ( + setHov(i)} + onMouseLeave={() => setHov(null)} + /> + ) + })} + + {/* Center circle */} + + + + {/* Center text */} + {active ? ( + <> + + + {active.value} + + + {active.name} + + + {active.pct}% + + + ) : ( + <> + + {total} + + + Total Leads + + + )} + + + {/* Legend */} +
+ {segs.map((s, i) => ( +
setHov(i)} + onMouseLeave={() => setHov(null)} + className="flex cursor-pointer items-center gap-2.5 rounded-xl p-2.5 transition-all duration-200" + style={{ + background: hov === i ? `${s.color}18` : "hsl(var(--muted) / 0.3)", + border: `1px solid ${hov === i ? s.color + "50" : "hsl(var(--border))"}`, + gridColumn: i === segs.length - 1 && segs.length % 2 !== 0 ? "1 / -1" : undefined, + maxWidth: i === segs.length - 1 && segs.length % 2 !== 0 ? "50%" : undefined, + margin: i === segs.length - 1 && segs.length % 2 !== 0 ? "0 auto" : undefined, + width: i === segs.length - 1 && segs.length % 2 !== 0 ? "100%" : undefined, }} - formatter={(value: number, name: string) => [value, name]} - /> - ( - {value} - )} - /> - - + > +
+
+
+ {s.name} +
+
+ {s.value} · {s.pct}% +
+
+
+ {s.pct}% +
+
+ ))} +
) -} +} \ No newline at end of file diff --git a/src/components/dashboard/leads-per-month-chart.tsx b/src/components/dashboard/leads-per-month-chart.tsx index 3dead10..dcdf708 100644 --- a/src/components/dashboard/leads-per-month-chart.tsx +++ b/src/components/dashboard/leads-per-month-chart.tsx @@ -1,8 +1,8 @@ "use client" +import { useState, useMemo } from "react" import { motion } from "framer-motion" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" -import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from "recharts" interface MonthlyData { month: string @@ -14,7 +14,42 @@ interface LeadsPerMonthChartProps { data: MonthlyData[] } +const BAR_GRADIENT_TOP = "#3B6AB8" +const NEW_LEADS = "#1e3c72" +const CLOSED = "#457fca" + export function LeadsPerMonthChart({ data }: LeadsPerMonthChartProps) { + const [hovered, setHovered] = useState(null) + + const width = 880 + const height = 380 + const padding = { top: 28, right: 24, bottom: 44, left: 44 } + const chartW = width - padding.left - padding.right + const chartH = height - padding.top - padding.bottom + + const maxVal = useMemo(() => { + const max = Math.max(...data.map((d) => Math.max(d.leads, d.closed))) + return Math.ceil(max / 7) * 7 + }, [data]) + + const ticks = useMemo(() => { + const steps = 4 + return Array.from({ length: steps + 1 }, (_, i) => Math.round((maxVal / steps) * i)) + }, [maxVal]) + + const groupW = chartW / data.length + const barW = groupW * 0.28 + const gap = groupW * 0.06 + + const yScale = (v: number) => chartH - (v / maxVal) * chartH + + const active = hovered + const activeDatum = active !== null ? data[active] : null + + const totalNew = data.reduce((s, d) => s + d.leads, 0) + const totalClosed = data.reduce((s, d) => s + d.closed, 0) + const closeRate = totalNew > 0 ? Math.round((totalClosed / totalNew) * 100) : 0 + return ( - Leads Per Month +
+
+ Leads Per Month +

+ {totalNew} new · {totalClosed} closed · {closeRate}% close rate +

+
+
+
+ + New Leads +
+
+ + Closed +
+
+
-
- - - - - - + + + + + + + + + + + + + + + + + + + + {/* Grid lines */} + {ticks.map((t, i) => ( + + + + {t} + + + ))} + + {/* Bars */} + {data.map((d, i) => { + const groupX = i * groupW + const isActive = active === i + const newH = chartH - yScale(d.leads) + const closedH = chartH - yScale(d.closed) + + return ( + setHovered(i)} + onMouseLeave={() => setHovered(null)} + style={{ cursor: "pointer" }} + > + {/* Hover highlight band */} + + + {/* New Leads bar */} + + + {/* Closed bar */} + + + {/* Month label */} + + {d.month} + + + ) + })} + + + + {/* Tooltip */} + {activeDatum && ( +
data.length - 2 + ? "translate(-100%, 0)" + : "translate(-12%, 0)", + transition: "left 0.15s ease", + }} + > +
- ( - {value} - )} - /> - - - - + > +
+ {activeDatum.month} +
+
+ + + New Leads + + {activeDatum.leads} +
+
+ + + Closed + + {activeDatum.closed} +
+
+ {activeDatum.leads > 0 + ? `${Math.round((activeDatum.closed / activeDatum.leads) * 100)}% close rate` + : "No leads"} +
+
+
+ )}
) -} +} \ No newline at end of file diff --git a/src/data/dashboard.ts b/src/data/dashboard.ts index 80b674f..a1e0153 100644 --- a/src/data/dashboard.ts +++ b/src/data/dashboard.ts @@ -32,7 +32,7 @@ function getStatusDistribution() { { name: "Contacted", value: statusCounts.contacted, color: "#f59e0b" }, { name: "Pending", value: statusCounts.pending, color: "#8b5cf6" }, { name: "Closed", value: statusCounts.closed, color: "#10b981" }, - { name: "Ignored", value: statusCounts.ignored, color: "#71717a" }, + { name: "Ignored", value: statusCounts.ignored, color: "#6B7280" }, ] }