import { useMemo } from "react"; import { Chart as ChartJS, CategoryScale, LinearScale, LineController, LineElement, PointElement, Tooltip, type ChartOptions, type ChartData, } from "chart.js"; import { Chart } from "react-chartjs-2"; import { useThemeVars } from "@/lib/theme-provider"; import { FALLBACK_TICK, FALLBACK_PRIMARY, FALLBACK_BG, ChartLegendItem, formatCount, formatChartDateLabel, formatTooltipDate, } from "./chart-shared"; import { Card, CardContent, CardHeader, CardTitle } from "./card"; import { Empty, EmptyDescription } from "./empty"; import { cn } from "@/lib/utils"; ChartJS.register( CategoryScale, LinearScale, LineController, LineElement, PointElement, Tooltip, ); /** * EmailEngagementChart — WealthX DS * * A three-series daily line chart (Delivered / Opened / Clicked) for the Email * Marketing section — mirrors GoHighLevel's "Engagement Analysis" chart. Used * on the Email Marketing Overview tab and (later) the per-campaign statistics * screen. * * `Delivered` uses the tenant brand-primary colour; `Opened` and `Clicked` use * intentionally-fixed complementary colours so the three series stay legible * regardless of tenant theme (same rationale as the fixed severity colours). */ /** Opened line colour — fixed blue for series distinction. */ const OPENED_COLOR = "#3B82F6"; /** Clicked line colour — fixed amber for series distinction. */ const CLICKED_COLOR = "#F59E0B"; export interface EmailEngagementPoint { /** ISO date string e.g. "2026-07-15" */ x: string; delivered: number; opened: number; clicked: number; } export interface EmailEngagementChartProps { data: EmailEngagementPoint[]; title?: string; /** Chart canvas height in pixels */ height?: number; width?: number | string; className?: string; /** Show or hide the legend */ showLegend?: boolean; } export function EmailEngagementChart({ data: points, title = "Engagement", height = 240, width = "100%", className, showLegend = true, }: EmailEngagementChartProps) { const themeVars = useThemeVars(); const deliveredColor = (themeVars["--theme-primary"] as string | undefined) || FALLBACK_PRIMARY; const fontFamily = (themeVars["--font-sans"] as string | undefined) || "Figtree, sans-serif"; const hasData = Array.isArray(points) && points.length > 0; const series = useMemo( () => [ { key: "delivered" as const, label: "Delivered", color: deliveredColor }, { key: "opened" as const, label: "Opened", color: OPENED_COLOR }, { key: "clicked" as const, label: "Clicked", color: CLICKED_COLOR }, ], [deliveredColor], ); // Indices to show on x-axis (max ~6 ticks) const tickIndices = useMemo>(() => { if (!hasData) return new Set(); const count = points.length; const step = Math.max(1, Math.floor(count / 6)); const indices = new Set(); for (let i = 0; i < count; i += step) indices.add(i); indices.add(count - 1); return indices; }, [hasData, points]); const data = useMemo>(() => { if (!hasData) return { labels: [], datasets: [] }; return { labels: points.map((p) => p.x), datasets: series.map((s) => ({ label: s.label, data: points.map((p) => p[s.key]), fill: false, borderColor: s.color, backgroundColor: "transparent", borderWidth: 2, tension: 0.4, pointRadius: 0, pointHoverRadius: 5, pointHoverBackgroundColor: FALLBACK_BG, pointHoverBorderColor: s.color, pointHoverBorderWidth: 3, pointHitRadius: 10, })), }; }, [hasData, points, series]); const options = useMemo>( () => ({ responsive: true, maintainAspectRatio: false, animation: { duration: 800, easing: "easeOutQuart" }, plugins: { legend: { display: false }, tooltip: { enabled: true, mode: "index", intersect: false, displayColors: true, padding: 12, cornerRadius: 0, titleFont: { size: 11, weight: 600, family: fontFamily }, bodyFont: { size: 12, weight: 500, family: fontFamily }, callbacks: { title: (items) => { const iso = items[0]?.label; return iso ? formatTooltipDate(iso, "daily") : ""; }, label: (ctx) => ` ${ctx.dataset.label}: ${formatCount((ctx.parsed.y ?? 0))}`, }, }, }, interaction: { mode: "index", intersect: false }, scales: { x: { type: "category", grid: { display: false }, border: { display: false }, ticks: { autoSkip: false, maxRotation: 0, minRotation: 0, color: FALLBACK_TICK, font: { size: 12, family: fontFamily }, callback: function (_, index) { if (!tickIndices.has(index) || !hasData) return ""; return formatChartDateLabel(points[index].x); }, }, }, y: { position: "left", grid: { display: false }, border: { display: false }, ticks: { beginAtZero: true, maxTicksLimit: 5, padding: 8, color: FALLBACK_TICK, font: { size: 12, family: fontFamily }, callback: (v) => formatCount(Number(v)), }, }, }, }), [tickIndices, hasData, points, fontFamily], ); return ( {title && ( {title} )} {!hasData ? ( No engagement data yet ) : (
{showLegend && (
{series.map((s) => ( ))}
)}
)}
); }