"use client"; import { Check, ChevronDown, Minus } from "lucide-react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { type CSSProperties, type ReactNode, useCallback, useMemo, useState } from "react"; import { Button } from "@/components/motion/button"; import { Collapsible, CollapsibleContent, CollapsibleTrigger, useCollapsible, } from "@/components/motion/collapsible"; import { NumberTicker } from "@/components/motion/number-ticker"; import { SegmentedControl } from "@/components/motion/segmented-control"; import { EASE_OUT, SPRING_LAYOUT } from "@/lib/ease"; import { cn } from "@/lib/utils"; export type ComparisonBilling = "monthly" | "yearly"; /** true = included, false = not included, "partial" = limited, anything else renders as text. */ export type ComparisonValue = boolean | "partial" | ReactNode; export interface ComparisonPlan { id: string; name: string; description?: string; /** Price per month for each billing period (yearly = monthly equivalent). */ price: { monthly: number; yearly: number }; cta?: string; /** Highlights the whole column. */ recommended?: boolean; } export interface ComparisonFeature { id: string; label: ReactNode; /** Secondary line under the label. */ hint?: ReactNode; /** Value per plan id. Missing ids read as not included. */ values: Record; } export interface ComparisonGroup { id: string; label: string; features: ComparisonFeature[]; } export interface ComparisonTableProps { plans: ComparisonPlan[]; groups: ComparisonGroup[]; billing?: ComparisonBilling; defaultBilling?: ComparisonBilling; onBillingChange?: (billing: ComparisonBilling) => void; /** Groups open initially. Default: all. */ defaultOpenGroups?: string[]; /** Plan shown in the narrow (single-column) layout. */ mobilePlan?: string; defaultMobilePlan?: string; onMobilePlanChange?: (planId: string) => void; onSelect?: (plan: ComparisonPlan, billing: ComparisonBilling) => void; /** ISO 4217 code. Default "USD". */ currency?: string; /** Default "en-US" so server and client agree. */ locale?: string; /** Badge beside "Yearly". Default "−20%"; null hides it. */ yearlyBadge?: ReactNode; /** Label on the recommended column. Default "Recommended". */ recommendedLabel?: ReactNode; /** Classes for the scrolling body (set a max height here). Default `max-h-[22rem]`. */ bodyClassName?: string; className?: string; } function useControllable( value: T | undefined, defaultValue: T, onChange: ((next: T) => void) | undefined, ) { const [internal, setInternal] = useState(defaultValue); const controlled = value !== undefined; const current = controlled ? value : internal; const set = useCallback( (next: T) => { if (!controlled) setInternal(next); onChange?.(next); }, [controlled, onChange], ); return [current, set] as const; } /** * Plan comparison grid: a monthly/yearly switch rolls every price, plan * columns sit in a sticky header while feature rows scroll beneath, feature * groups collapse with a height morph, cells read check / dash / partial or * text, and the recommended plan's column is tinted top to bottom. In a * narrow container the columns give way to a plan switcher with one value * column that cross-fades between plans. Complements `PricingTable` (cards) * as the detailed "compare all features" section. */ export function ComparisonTable({ plans, groups, billing: billingProp, defaultBilling = "monthly", onBillingChange, defaultOpenGroups, mobilePlan: mobilePlanProp, defaultMobilePlan, onMobilePlanChange, onSelect, currency = "USD", locale = "en-US", yearlyBadge = "−20%", recommendedLabel = "Recommended", bodyClassName, className, }: ComparisonTableProps) { const [billing, setBilling] = useControllable(billingProp, defaultBilling, onBillingChange); const [mobilePlan, setMobilePlan] = useControllable( mobilePlanProp, defaultMobilePlan ?? plans.find((p) => p.recommended)?.id ?? plans[0]?.id ?? "", onMobilePlanChange, ); const openSet = useMemo( () => new Set(defaultOpenGroups ?? groups.map((g) => g.id)), [defaultOpenGroups, groups], ); const fractional = plans.some( (p) => !Number.isInteger(p.price.monthly) || !Number.isInteger(p.price.yearly), ); const money = useMemo( () => new Intl.NumberFormat(locale, { style: "currency", currency, minimumFractionDigits: fractional ? 2 : 0, maximumFractionDigits: fractional ? 2 : 0, }), [locale, currency, fractional], ); const columns: CSSProperties = { gridTemplateColumns: `minmax(8.5rem, 1.35fr) repeat(${plans.length}, minmax(0, 1fr))`, }; const current = plans.find((p) => p.id === mobilePlan) ?? plans[0]; return (
setBilling(v as ComparisonBilling)} options={[ { value: "monthly", label: "Monthly" }, { value: "yearly", label: ( Yearly {yearlyBadge ? ( {yearlyBadge} ) : null} ), }, ]} />
{/* Wide layout: all plan columns */}
Compare plans
{plans.map((plan) => (
{plan.recommended ? ( <> {recommendedLabel} ) : ( )} {plan.name}
))}
{groups.map((group) => ( {group.features.map((feature) => (
{plans.map((plan) => (
))}
))}
))}
{/* Narrow layout: plan switcher + one value column */}
({ value: p.id, label: p.name }))} /> {current ? (

{current.name} {current.recommended ? ( {recommendedLabel} ) : null}

) : null}
{groups.map((group) => ( {group.features.map((feature) => (
))}
))}
); } function PlanPrice({ plan, billing, money, }: { plan: ComparisonPlan; billing: ComparisonBilling; money: Intl.NumberFormat; }) { const amount = plan.price[billing]; const free = plan.price.monthly === 0 && plan.price.yearly === 0; return ( money.format(cents / 100)} duration={0.6} stagger={0.03} startOnView={false} className="text-lg font-semibold tracking-tight" /> {free ? null : /mo} ); } function GroupTrigger({ label, count }: { label: string; count: number }) { const { open } = useCollapsible("GroupTrigger"); const reduce = useReducedMotion(); return ( {label} {count} {count === 1 ? "feature" : "features"} ); } function FeatureLabel({ feature }: { feature: ComparisonFeature }) { return (

{feature.label}

{feature.hint ?

{feature.hint}

: null}
); } function Cell({ value, planName }: { value: ComparisonValue | undefined; planName: string }) { if (value === true) { return ( {planName}: included ); } if (value === false || value === undefined || value === null) { return ( {planName}: not included ); } if (value === "partial") { return ( {planName}: partial ); } return ( {planName}: {value} ); }