"use client"; import { createContext, useContext, useMemo, type ReactNode } from "react"; import { motion, useReducedMotion } from "motion/react"; import { NumberTicker } from "@/components/motion/number-ticker"; import { Tooltip } from "@/components/motion/tooltip"; import { EASE_OUT } from "@/lib/ease"; import { cn } from "@/lib/utils"; import { buildFunnel, funnelPath, type FunnelStage } from "./funnel-chart/model"; const defaultFormat = (value: number) => value.toLocaleString("en-US", { maximumFractionDigits: 2 }); const percentage = (value: number | null) => (value === null ? "—" : `${value.toFixed(1)}%`); const colors = ["#8b5cf6", "#7774ef", "#548ee4", "#2ca6bc", "#14b8a6"]; export interface FunnelChartProps { /** Ordered stages with finite, nonnegative counts; duplicate IDs are omitted. */ stages: readonly FunnelStage[]; direction?: "vertical" | "horizontal"; unit?: string; label?: string; formatValue?: (value: number) => string; className?: string; children?: ReactNode; } const Context = createContext< | (ReturnType & { direction: "vertical" | "horizontal"; unit: string; formatValue: (value: number) => string; }) | null >(null); export function useFunnelChart() { const context = useContext(Context); if (!context) throw new Error("Funnel chart parts must be inside FunnelChart"); return context; } export function FunnelChart({ stages, direction = "vertical", unit = "people", label = "Conversion funnel", formatValue = defaultFormat, className, children, }: FunnelChartProps) { const model = useMemo(() => buildFunnel(stages), [stages]); return (
{children === undefined ? ( <> ) : ( children )}
); } export function FunnelChartPlot({ className }: { className?: string }) { const { rows, direction, unit, formatValue } = useFunnelChart(); const reduced = useReducedMotion(); if (!rows.length) return (

No funnel data

); const horizontal = direction === "horizontal"; const proportions = rows.map((stage) => stage.proportion); return (
    {rows.map((stage, index) => (
  1. {stage.label} formatValue(stage.value)} suffix={` ${unit}`} duration={0.35} startOnView={false} className="font-mono" /> {index > 0 && ( <> {percentage(stage.stepConversion)} from previous stage {formatValue(Math.abs(stage.change ?? 0))}{" "} {(stage.change ?? 0) > 0 ? "gained" : "dropped"} )} {percentage(stage.conversion)} of starting total } >
  2. ))}
{rows.map((stage, index) => ( ))}
); } export function FunnelChartSummary({ className }: { className?: string }) { const { rows, first, last, conversion, formatValue, unit } = useFunnelChart(); if (!rows.length) return null; return (
{formatValue(first)} → {formatValue(last)} {unit} Overall conversion {percentage(conversion)}
); } export type { FunnelStage };