'use client' import { useState, useMemo } from 'react' import { motion } from 'framer-motion' import { cn, formatNumber } from '@ciphera-net/ui' interface FunnelChartProps { steps: Array<{ name: string visitors: number dropoff: number conversion: number }> className?: string } export default function FunnelChart({ steps, className }: FunnelChartProps) { const [hoveredIndex, setHoveredIndex] = useState(null) 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 (
{/* Background glow */} {/* 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)} /> ) })} {/* 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} ) })}
) }