diff --git a/components/dashboard/FunnelChart.tsx b/components/dashboard/FunnelChart.tsx index 5408c5e..3894361 100644 --- a/components/dashboard/FunnelChart.tsx +++ b/components/dashboard/FunnelChart.tsx @@ -1,6 +1,6 @@ 'use client' -import { useState } from 'react' +import { useState, useMemo } from 'react' import { motion } from 'framer-motion' import { cn, formatNumber } from '@ciphera-net/ui' @@ -17,105 +17,160 @@ interface FunnelChartProps { export default function FunnelChart({ steps, className }: FunnelChartProps) { const [hoveredIndex, setHoveredIndex] = useState(null) - if (!steps.length) return null - - const maxVisitors = steps[0].visitors + const maxVisitors = steps[0]?.visitors ?? 0 const n = steps.length + if (!n || maxVisitors === 0) return null + + // SVG layout + const W = 800 + const H = 400 + const cx = W / 2 + const maxHW = W * 0.28 + const minHW = 28 + const segH = H / n + const k = 0.5 // bezier tension + + // n+1 boundary points: top of each step + bottom of last + const bounds = useMemo(() => { + return Array.from({ length: n + 1 }, (_, i) => { + const y = i * segH + const visitors = i < n ? steps[i].visitors : steps[n - 1].visitors * 0.5 + const hw = Math.max(minHW, (visitors / maxVisitors) * maxHW) + return { y, hw } + }) + }, [steps, n, maxVisitors, segH]) + + // Curved path for one segment + const segPath = (i: number) => { + const t = bounds[i], b = bounds[i + 1] + const dy = b.y - t.y + return [ + `M${cx - t.hw},${t.y}`, + `L${cx + t.hw},${t.y}`, + `C${cx + t.hw},${t.y + dy * k} ${cx + b.hw},${b.y - dy * k} ${cx + b.hw},${b.y}`, + `L${cx - b.hw},${b.y}`, + `C${cx - b.hw},${b.y - dy * k} ${cx - t.hw},${t.y + dy * k} ${cx - t.hw},${t.y}`, + 'Z', + ].join(' ') + } + + // Full outline for background glow + const glowPath = useMemo(() => { + let d = `M${cx - bounds[0].hw},${bounds[0].y} L${cx + bounds[0].hw},${bounds[0].y}` + for (let i = 0; i < n; i++) { + const t = bounds[i], b = bounds[i + 1], dy = b.y - t.y + d += ` C${cx + t.hw},${t.y + dy * k} ${cx + b.hw},${b.y - dy * k} ${cx + b.hw},${b.y}` + } + d += ` L${cx - bounds[n].hw},${bounds[n].y}` + for (let i = n - 1; i >= 0; i--) { + const t = bounds[i], b = bounds[i + 1], dy = b.y - t.y + d += ` C${cx - b.hw},${b.y - dy * k} ${cx - t.hw},${t.y + dy * k} ${cx - t.hw},${t.y}` + } + return d + ' Z' + }, [bounds, n]) + return ( -
- {/* Left labels */} -
- {steps.map((step, i) => ( -
-
- - Step {i + 1} - -

- {step.name} -

-
-
- ))} -
+
+ + {/* Background glow */} + - {/* Funnel segments */} -
- {steps.map((step, i) => { - const topPct = maxVisitors > 0 - ? Math.max(30, (step.visitors / maxVisitors) * 100) - : 100 - const bottomPct = steps[i + 1] - ? Math.max(30, (steps[i + 1].visitors / maxVisitors) * 100) - : topPct * 0.7 - const topInset = (100 - topPct) / 2 - const bottomInset = (100 - bottomPct) / 2 - const opacity = Math.max(0.3, 1 - i * (0.55 / n)) + {/* Segments */} + {steps.map((_, i) => { + const opacity = Math.max(0.45, 1 - i * (0.45 / n)) const isHovered = hoveredIndex === i - return ( - setHoveredIndex(i)} onMouseLeave={() => setHoveredIndex(null)} /> ) })} -
- {/* Right stats */} -
- {steps.map((step, i) => ( -
-
-

- {formatNumber(step.visitors)} -

- {i > 0 ? ( -
- - ↓{Math.round(step.dropoff)}% - - - {Math.round(step.conversion)}% - -
- ) : ( - - baseline - - )} -
-
+ {/* Divider lines */} + {bounds.slice(1, -1).map((b, i) => ( + ))} -
+ + {/* Labels */} + {steps.map((step, i) => { + const midY = bounds[i].y + segH / 2 + const dimmed = hoveredIndex !== null && hoveredIndex !== i + return ( + + {/* Visitor count — left */} + + {formatNumber(step.visitors)} + + + {/* Percentage pill — center */} + + + {Math.round(step.conversion)}% + + + {/* Step name — right */} + + {step.name} + + + ) + })} +
) }