diff --git a/app/sites/[id]/funnels/[funnelId]/page.tsx b/app/sites/[id]/funnels/[funnelId]/page.tsx index 43ebae6..c4171f3 100644 --- a/app/sites/[id]/funnels/[funnelId]/page.tsx +++ b/app/sites/[id]/funnels/[funnelId]/page.tsx @@ -7,7 +7,7 @@ import { getFunnel, getFunnelStats, deleteFunnel, type Funnel, type FunnelStats import { toast, Select, DatePicker, ChevronLeftIcon, ArrowRightIcon, TrashIcon, Button } from '@ciphera-net/ui' import { FunnelDetailSkeleton, useMinimumLoading } from '@/components/skeletons' import Link from 'next/link' -import FunnelChart from '@/components/dashboard/FunnelChart' +import { FunnelChart } from '@/components/ui/funnel-chart' import { getDateRange } from '@ciphera-net/ui' export default function FunnelReportPage() { @@ -108,10 +108,8 @@ export default function FunnelReportPage() { } const chartData = stats.steps.map(s => ({ - name: s.step.name, - visitors: s.visitors, - dropoff: s.dropoff, - conversion: s.conversion + label: s.step.name, + value: s.visitors, })) return ( @@ -173,7 +171,12 @@ export default function FunnelReportPage() {

Funnel Visualization

- + {/* Detailed Stats Table */} diff --git a/components/dashboard/FunnelChart.tsx b/components/dashboard/FunnelChart.tsx deleted file mode 100644 index 3894361..0000000 --- a/components/dashboard/FunnelChart.tsx +++ /dev/null @@ -1,176 +0,0 @@ -'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} - - - ) - })} - -
- ) -} diff --git a/components/ui/funnel-chart.tsx b/components/ui/funnel-chart.tsx new file mode 100644 index 0000000..78359da --- /dev/null +++ b/components/ui/funnel-chart.tsx @@ -0,0 +1,935 @@ +"use client"; + +import { motion, useSpring, useTransform } from "motion/react"; +import { + type CSSProperties, + type ReactNode, + useCallback, + useEffect, + useRef, + useState, +} from "react"; +import { type ClassValue, clsx } from "clsx"; +import { twMerge } from "tailwind-merge"; + +// ─── Utils ─────────────────────────────────────────────────────────────────── + +function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} + +// ─── PatternLines ──────────────────────────────────────────────────────────── + +export interface PatternLinesProps { + id: string; + width?: number; + height?: number; + stroke?: string; + strokeWidth?: number; + orientation?: ("diagonal" | "horizontal" | "vertical")[]; + background?: string; +} + +export function PatternLines({ + id, + width = 6, + height = 6, + stroke = "var(--chart-line-primary)", + strokeWidth = 1, + orientation = ["diagonal"], + background, +}: PatternLinesProps) { + const paths: string[] = []; + + for (const o of orientation) { + if (o === "diagonal") { + paths.push(`M0,${height}l${width},${-height}`); + paths.push(`M${-width / 4},${height / 4}l${width / 2},${-height / 2}`); + paths.push( + `M${(3 * width) / 4},${height + height / 4}l${width / 2},${-height / 2}` + ); + } else if (o === "horizontal") { + paths.push(`M0,${height / 2}l${width},0`); + } else if (o === "vertical") { + paths.push(`M${width / 2},0l0,${height}`); + } + } + + return ( + + {background && ( + + )} + + + ); +} + +PatternLines.displayName = "PatternLines"; + +// ─── Types ─────────────────────────────────────────────────────────────────── + +export interface FunnelGradientStop { + offset: string | number; + color: string; +} + +export interface FunnelStage { + label: string; + value: number; + displayValue?: string; + color?: string; + gradient?: FunnelGradientStop[]; +} + +export interface FunnelChartProps { + data: FunnelStage[]; + orientation?: "horizontal" | "vertical"; + color?: string; + layers?: number; + className?: string; + style?: CSSProperties; + showPercentage?: boolean; + showValues?: boolean; + showLabels?: boolean; + hoveredIndex?: number | null; + onHoverChange?: (index: number | null) => void; + formatPercentage?: (pct: number) => string; + formatValue?: (value: number) => string; + staggerDelay?: number; + gap?: number; + renderPattern?: (id: string, color: string) => ReactNode; + edges?: "curved" | "straight"; + labelLayout?: "spread" | "grouped"; + labelOrientation?: "vertical" | "horizontal"; + labelAlign?: "center" | "start" | "end"; + grid?: + | boolean + | { + bands?: boolean; + bandColor?: string; + lines?: boolean; + lineColor?: string; + lineOpacity?: number; + lineWidth?: number; + }; +} + +// ─── Defaults ──────────────────────────────────────────────────────────────── + +const fmtPct = (p: number) => `${Math.round(p)}%`; +const fmtVal = (v: number) => v.toLocaleString("en-US"); + +const springConfig = { stiffness: 120, damping: 20, mass: 1 }; +const hoverSpring = { stiffness: 300, damping: 24 }; + +// ─── SVG Helpers ───────────────────────────────────────────────────────────── + +function hSegmentPath( + normStart: number, + normEnd: number, + segW: number, + H: number, + layerScale: number, + straight = false +) { + const my = H / 2; + const h0 = normStart * H * 0.44 * layerScale; + const h1 = normEnd * H * 0.44 * layerScale; + + if (straight) { + return `M 0 ${my - h0} L ${segW} ${my - h1} L ${segW} ${my + h1} L 0 ${my + h0} Z`; + } + + const cx = segW * 0.55; + const top = `M 0 ${my - h0} C ${cx} ${my - h0}, ${segW - cx} ${my - h1}, ${segW} ${my - h1}`; + const bot = `L ${segW} ${my + h1} C ${segW - cx} ${my + h1}, ${cx} ${my + h0}, 0 ${my + h0}`; + return `${top} ${bot} Z`; +} + +function vSegmentPath( + normStart: number, + normEnd: number, + segH: number, + W: number, + layerScale: number, + straight = false +) { + const mx = W / 2; + const w0 = normStart * W * 0.44 * layerScale; + const w1 = normEnd * W * 0.44 * layerScale; + + if (straight) { + return `M ${mx - w0} 0 L ${mx - w1} ${segH} L ${mx + w1} ${segH} L ${mx + w0} 0 Z`; + } + + const cy = segH * 0.55; + const left = `M ${mx - w0} 0 C ${mx - w0} ${cy}, ${mx - w1} ${segH - cy}, ${mx - w1} ${segH}`; + const right = `L ${mx + w1} ${segH} C ${mx + w1} ${segH - cy}, ${mx + w0} ${cy}, ${mx + w0} 0`; + return `${left} ${right} Z`; +} + +// ─── Animated Ring ─────────────────────────────────────────────────────────── + +function HRing({ + d, + color, + fill, + opacity, + hovered, + ringIndex, + totalRings, +}: { + d: string; + color: string; + fill?: string; + opacity: number; + hovered: boolean; + ringIndex: number; + totalRings: number; +}) { + const extraScale = 1 + (ringIndex / Math.max(totalRings - 1, 1)) * 0.12; + const ringSpring = { + stiffness: 300 - ringIndex * 60, + damping: 24 - ringIndex * 3, + }; + const scaleY = useSpring(1, ringSpring); + + useEffect(() => { + scaleY.set(hovered ? extraScale : 1); + }, [hovered, scaleY, extraScale]); + + return ( + + ); +} + +function VRing({ + d, + color, + fill, + opacity, + hovered, + ringIndex, + totalRings, +}: { + d: string; + color: string; + fill?: string; + opacity: number; + hovered: boolean; + ringIndex: number; + totalRings: number; +}) { + const extraScale = 1 + (ringIndex / Math.max(totalRings - 1, 1)) * 0.12; + const ringSpring = { + stiffness: 300 - ringIndex * 60, + damping: 24 - ringIndex * 3, + }; + const scaleX = useSpring(1, ringSpring); + + useEffect(() => { + scaleX.set(hovered ? extraScale : 1); + }, [hovered, scaleX, extraScale]); + + return ( + + ); +} + +// ─── Animated Segments ─────────────────────────────────────────────────────── + +function HSegment({ + index, + normStart, + normEnd, + segW, + fullH, + color, + layers, + staggerDelay, + hovered, + dimmed, + renderPattern, + straight, + gradientStops, +}: { + index: number; + normStart: number; + normEnd: number; + segW: number; + fullH: number; + color: string; + layers: number; + staggerDelay: number; + hovered: boolean; + dimmed: boolean; + renderPattern?: (id: string, color: string) => ReactNode; + straight: boolean; + gradientStops?: FunnelGradientStop[]; +}) { + const patternId = `funnel-h-pattern-${index}`; + const gradientId = `funnel-h-grad-${index}`; + const growProgress = useSpring(0, springConfig); + const entranceScaleX = useTransform(growProgress, [0, 1], [0, 1]); + const entranceScaleY = useTransform(growProgress, [0, 1], [0, 1]); + const dimOpacity = useSpring(1, hoverSpring); + + useEffect(() => { + dimOpacity.set(dimmed ? 0.4 : 1); + }, [dimmed, dimOpacity]); + + useEffect(() => { + const timeout = setTimeout( + () => growProgress.set(1), + index * staggerDelay * 1000 + ); + return () => clearTimeout(timeout); + }, [growProgress, index, staggerDelay]); + + const rings = Array.from({ length: layers }, (_, l) => { + const scale = 1 - (l / layers) * 0.35; + const opacity = 0.18 + (l / (layers - 1 || 1)) * 0.65; + return { + d: hSegmentPath(normStart, normEnd, segW, fullH, scale, straight), + opacity, + }; + }); + + return ( + + + + + + ); +} + +function VSegment({ + index, + normStart, + normEnd, + segH, + fullW, + color, + layers, + staggerDelay, + hovered, + dimmed, + renderPattern, + straight, + gradientStops, +}: { + index: number; + normStart: number; + normEnd: number; + segH: number; + fullW: number; + color: string; + layers: number; + staggerDelay: number; + hovered: boolean; + dimmed: boolean; + renderPattern?: (id: string, color: string) => ReactNode; + straight: boolean; + gradientStops?: FunnelGradientStop[]; +}) { + const patternId = `funnel-v-pattern-${index}`; + const gradientId = `funnel-v-grad-${index}`; + const growProgress = useSpring(0, springConfig); + const entranceScaleY = useTransform(growProgress, [0, 1], [0, 1]); + const entranceScaleX = useTransform(growProgress, [0, 1], [0, 1]); + const dimOpacity = useSpring(1, hoverSpring); + + useEffect(() => { + dimOpacity.set(dimmed ? 0.4 : 1); + }, [dimmed, dimOpacity]); + + useEffect(() => { + const timeout = setTimeout( + () => growProgress.set(1), + index * staggerDelay * 1000 + ); + return () => clearTimeout(timeout); + }, [growProgress, index, staggerDelay]); + + const rings = Array.from({ length: layers }, (_, l) => { + const scale = 1 - (l / layers) * 0.35; + const opacity = 0.18 + (l / (layers - 1 || 1)) * 0.65; + return { + d: vSegmentPath(normStart, normEnd, segH, fullW, scale, straight), + opacity, + }; + }); + + return ( + + + + + + ); +} + +// ─── Label Overlay ─────────────────────────────────────────────────────────── + +function SegmentLabel({ + stage, + pct, + isHorizontal, + showValues, + showPercentage, + showLabels, + formatPercentage, + formatValue, + index, + staggerDelay, + layout = "spread", + orientation, + align = "center", +}: { + stage: FunnelStage; + pct: number; + isHorizontal: boolean; + showValues: boolean; + showPercentage: boolean; + showLabels: boolean; + formatPercentage: (p: number) => string; + formatValue: (v: number) => string; + index: number; + staggerDelay: number; + layout?: "spread" | "grouped"; + orientation?: "vertical" | "horizontal"; + align?: "center" | "start" | "end"; +}) { + const display = stage.displayValue ?? formatValue(stage.value); + + const valueEl = showValues && ( + + {display} + + ); + const pctEl = showPercentage && ( + + {formatPercentage(pct)} + + ); + const labelEl = showLabels && ( + + {stage.label} + + ); + + if (layout === "spread") { + return ( + + {isHorizontal ? ( + <> +
+ {valueEl} +
+
+ {pctEl} +
+
+ {labelEl} +
+ + ) : ( + <> +
+ {valueEl} +
+
+ {pctEl} +
+
+ {labelEl} +
+ + )} +
+ ); + } + + // Grouped layout + const resolvedOrientation = + orientation ?? (isHorizontal ? "vertical" : "horizontal"); + const isVerticalStack = resolvedOrientation === "vertical"; + + const justifyMap = { + start: "justify-start", + center: "justify-center", + end: "justify-end", + } as const; + const itemsMap = { + start: "items-start", + center: "items-center", + end: "items-end", + } as const; + + return ( + +
+ {valueEl} + {pctEl} + {labelEl} +
+
+ ); +} + +// ─── FunnelChart ───────────────────────────────────────────────────────────── + +export function FunnelChart({ + data, + orientation = "horizontal", + color = "var(--chart-1)", + layers = 3, + className, + style, + showPercentage = true, + showValues = true, + showLabels = true, + hoveredIndex: hoveredIndexProp, + onHoverChange, + formatPercentage = fmtPct, + formatValue = fmtVal, + staggerDelay = 0.12, + gap = 4, + renderPattern, + edges = "curved", + labelLayout = "spread", + labelOrientation, + labelAlign = "center", + grid: gridProp = false, +}: FunnelChartProps) { + const ref = useRef(null); + const [sz, setSz] = useState({ w: 0, h: 0 }); + const [internalHoveredIndex, setInternalHoveredIndex] = useState< + number | null + >(null); + + const isControlled = hoveredIndexProp !== undefined; + const hoveredIndex = isControlled ? hoveredIndexProp : internalHoveredIndex; + const setHoveredIndex = useCallback( + (index: number | null) => { + if (isControlled) { + onHoverChange?.(index); + } else { + setInternalHoveredIndex(index); + } + }, + [isControlled, onHoverChange] + ); + + const measure = useCallback(() => { + if (!ref.current) return; + const { width: w, height: h } = ref.current.getBoundingClientRect(); + if (w > 0 && h > 0) setSz({ w, h }); + }, []); + + useEffect(() => { + measure(); + const ro = new ResizeObserver(measure); + if (ref.current) ro.observe(ref.current); + return () => ro.disconnect(); + }, [measure]); + + if (!data.length) return null; + + const first = data[0]; + if (!first) return null; + + const max = first.value; + const n = data.length; + const norms = data.map((d) => d.value / max); + const horiz = orientation === "horizontal"; + const { w: W, h: H } = sz; + + const totalGap = gap * (n - 1); + const segW = (W - (horiz ? totalGap : 0)) / n; + const segH = (H - (horiz ? 0 : totalGap)) / n; + + // Grid config + const gridEnabled = gridProp !== false; + const gridCfg = typeof gridProp === "object" ? gridProp : {}; + const showBands = gridEnabled && (gridCfg.bands ?? true); + const bandColor = gridCfg.bandColor ?? "var(--color-muted)"; + const showGridLines = gridEnabled && (gridCfg.lines ?? true); + const gridLineColor = gridCfg.lineColor ?? "var(--chart-grid)"; + const gridLineOpacity = gridCfg.lineOpacity ?? 1; + const gridLineWidth = gridCfg.lineWidth ?? 1; + + return ( +
+ {W > 0 && H > 0 && ( + <> + {/* Grid background bands */} + {gridEnabled && ( + + )} + + {/* Segments */} +
+ {data.map((stage, i) => { + const normStart = norms[i] ?? 0; + const normEnd = norms[Math.min(i + 1, n - 1)] ?? 0; + const firstStop = stage.gradient?.[0]; + const segColor = firstStop + ? firstStop.color + : (stage.color ?? color); + + return horiz ? ( + + ) : ( + + ); + })} +
+ + {/* Grid lines */} + {gridEnabled && showGridLines && ( + + )} + + {/* Label overlays — hover triggers */} + {data.map((stage, i) => { + const pct = (stage.value / max) * 100; + const posStyle: CSSProperties = horiz + ? { left: (segW + gap) * i, width: segW, top: 0, height: H } + : { top: (segH + gap) * i, height: segH, left: 0, width: W }; + const isDimmed = hoveredIndex !== null && hoveredIndex !== i; + + return ( + setHoveredIndex(i)} + onMouseLeave={() => setHoveredIndex(null)} + style={{ ...posStyle, zIndex: 20 }} + transition={{ type: "spring", stiffness: 300, damping: 24 }} + > + + + ); + })} + + )} +
+ ); +} + +FunnelChart.displayName = "FunnelChart"; + +export default FunnelChart; diff --git a/package-lock.json b/package-lock.json index 8600b6b..6d307ce 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "iso-3166-2": "^1.0.0", "jspdf": "^4.0.0", "jspdf-autotable": "^5.0.7", + "motion": "^12.35.2", "next": "^16.1.1", "radix-ui": "^1.4.3", "react": "^19.2.3", @@ -9277,12 +9278,12 @@ } }, "node_modules/framer-motion": { - "version": "12.34.1", - "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.34.1.tgz", - "integrity": "sha512-kcZyNaYQfvE2LlH6+AyOaJAQV4rGp5XbzfhsZpiSZcwDMfZUHhuxLWeyRzf5I7jip3qKRpuimPA9pXXfr111kQ==", + "version": "12.35.2", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.35.2.tgz", + "integrity": "sha512-dhfuEMaNo0hc+AEqyHiIfiJRNb9U9UQutE9FoKm5pjf7CMitp9xPEF1iWZihR1q86LBmo6EJ7S8cN8QXEy49AA==", "license": "MIT", "dependencies": { - "motion-dom": "^12.34.1", + "motion-dom": "^12.35.2", "motion-utils": "^12.29.2", "tslib": "^2.4.0" }, @@ -11566,10 +11567,36 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/motion": { + "version": "12.35.2", + "resolved": "https://registry.npmjs.org/motion/-/motion-12.35.2.tgz", + "integrity": "sha512-8zCi1DkNyU6a/tgEHn/GnnXZDcaMpDHbDOGORY1Rg/6lcNMSOuvwDB3i4hMSOvxqMWArc/vrGaw/Xek1OP69/A==", + "license": "MIT", + "dependencies": { + "framer-motion": "^12.35.2", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, "node_modules/motion-dom": { - "version": "12.34.1", - "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.34.1.tgz", - "integrity": "sha512-SC7ZC5dRcGwku2g7EsPvI4q/EzHumUbqsDNumBmZTLFg+goBO5LTJvDu9MAxx+0mtX4IA78B2be/A3aRjY0jnw==", + "version": "12.35.2", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.35.2.tgz", + "integrity": "sha512-pWXFMTwvGDbx1Fe9YL5HZebv2NhvGBzRtiNUv58aoK7+XrsuaydQ0JGRKK2r+bTKlwgSWwWxHbP5249Qr/BNpg==", "license": "MIT", "dependencies": { "motion-utils": "^12.29.2" diff --git a/package.json b/package.json index 7017e87..917e3f4 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "iso-3166-2": "^1.0.0", "jspdf": "^4.0.0", "jspdf-autotable": "^5.0.7", + "motion": "^12.35.2", "next": "^16.1.1", "radix-ui": "^1.4.3", "react": "^19.2.3",