{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "pricing-display",
  "type": "registry:block",
  "title": "Pricing display",
  "description": "Pricing section with localized prices, billing interval toggle, and built-in overlay checkout. Supports current-plan highlighting and plan change selection flows via currentPriceIds and selectedPriceId props.",
  "dependencies": [],
  "registryDependencies": [
    "@paddle/paddle-client",
    "@paddle/paddle-helpers",
    "@paddle/pricing-cards",
    "@paddle/billing-interval-toggle"
  ],
  "files": [
    {
      "path": "registry/new-york/blocks/pricing-display/components/pricing-display.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { PricingTierCard } from \"@/registry/new-york/blocks/pricing-cards/components/pricing-tier-card\"\nimport { PricingTierCardGroup } from \"@/registry/new-york/blocks/pricing-cards/components/pricing-tier-card-group\"\nimport { usePaddlePrices } from \"@/registry/new-york/blocks/paddle-client/lib/hooks/use-paddle-prices\"\nimport {\n  getOrCreatePaddle,\n  addPaddleEventListener,\n} from \"@/registry/new-york/blocks/paddle-client/lib/paddle-instance\"\nimport { BillingIntervalToggle } from \"@/registry/new-york/blocks/billing-interval-toggle/components/billing-interval-toggle\"\nimport { cn } from \"@/lib/utils\"\nimport { formatIntervalLabel } from \"@/registry/new-york/blocks/paddle-helpers/lib/paddle-format\"\nimport type {\n  Environments,\n  CheckoutEventsData,\n  PaddleEventData,\n  TimePeriod,\n} from \"@/registry/new-york/blocks/paddle-client/lib/paddle-sdk-types\"\nimport type { CheckoutCompleteData } from \"@/registry/new-york/blocks/paddle-helpers/lib/paddle-types\"\n\n// Local alias for the interval string union from Paddle's TimePeriod.\n// Uses the SDK's source of truth directly rather than a duplicated type.\ntype Interval = TimePeriod[\"interval\"]\n\n/** Plan configuration for the `PricingDisplay` component. */\nexport type PricingDisplayPlan = {\n  priceId?: string | Partial<Record<Interval, string>>\n  name: string\n  description?: string\n  features?: string[]\n  badge?: string\n  badgePosition?: \"left\" | \"center\" | \"right\"\n  icon?: React.ReactNode\n  ctaLabel?: string\n  onSelect?: () => void\n}\n\n/** Props for the `PricingDisplay` component. */\nexport type PricingDisplayProps = {\n  plans: PricingDisplayPlan[]\n  clientToken: string\n  environment?: Environments\n  countryCode?: string\n  discountId?: string\n  showOriginalPrice?: boolean\n  currentPriceIds?: string[]\n  selectedPriceId?: string\n  onPlanSelect?: (priceId: string) => void\n  onCheckoutComplete?: (data: CheckoutCompleteData) => void\n  className?: string\n}\n\nconst INTERVAL_ORDER: Interval[] = [\"day\", \"week\", \"month\", \"year\"]\n\nfunction getAllPriceIds(plans: PricingDisplayPlan[]): string[] {\n  return plans.flatMap((plan) => {\n    if (!plan.priceId) return []\n    if (typeof plan.priceId === \"string\") return [plan.priceId]\n    return Object.values(plan.priceId).filter(Boolean) as string[]\n  })\n}\n\nfunction getIntervals(plans: PricingDisplayPlan[]): Interval[] {\n  const multiIntervalPlans = plans.filter(\n    (p) => p.priceId !== undefined && typeof p.priceId !== \"string\"\n  )\n  if (multiIntervalPlans.length === 0) return []\n  const allKeys = multiIntervalPlans.flatMap(\n    (p) => Object.keys(p.priceId as Partial<Record<Interval, string>>) as Interval[]\n  )\n  const unique = new Set(allKeys)\n  return INTERVAL_ORDER.filter((i) => unique.has(i))\n}\n\nfunction getActivePriceId(plan: PricingDisplayPlan, interval: Interval): string {\n  if (!plan.priceId) return \"\"\n  if (typeof plan.priceId === \"string\") return plan.priceId\n  return (plan.priceId as Partial<Record<Interval, string>>)[interval] ?? \"\"\n}\n\n// True when ANY of the plan's price IDs (across all intervals) appear in\n// currentPriceIds. Used for the plan-tier badge — passing the monthly price ID\n// marks the plan as \"current\" on both the monthly and annual tabs.\nfunction isPlanCurrentTier(plan: PricingDisplayPlan, currentPriceIds: string[]): boolean {\n  if (!plan.priceId) return false\n  const ids =\n    typeof plan.priceId === \"string\"\n      ? [plan.priceId]\n      : (Object.values(plan.priceId).filter(Boolean) as string[])\n  return ids.some((id) => currentPriceIds.includes(id))\n}\n\nexport function PricingDisplay({\n  plans,\n  clientToken,\n  environment = \"production\",\n  countryCode,\n  discountId,\n  showOriginalPrice = true,\n  currentPriceIds,\n  selectedPriceId,\n  onPlanSelect,\n  onCheckoutComplete,\n  className,\n}: PricingDisplayProps) {\n  const intervals = React.useMemo(() => getIntervals(plans), [plans])\n  const hasMultiInterval = intervals.length > 1\n\n  const [selectedInterval, setSelectedInterval] = React.useState<string>(intervals[0] ?? \"\")\n\n  // Reset to first valid interval when plans change\n  React.useEffect(() => {\n    if (selectedInterval && !intervals.includes(selectedInterval as Interval)) {\n      setSelectedInterval(intervals[0] ?? \"\")\n    }\n  }, [intervals, selectedInterval])\n\n  const resolvedInterval = intervals.includes(selectedInterval as Interval)\n    ? selectedInterval\n    : (intervals[0] ?? \"\")\n\n  const allPriceIds = React.useMemo(() => getAllPriceIds(plans), [plans])\n\n  // Set of plans whose price IDs overlap with currentPriceIds (any interval).\n  // Stored by object identity so the render loop avoids index arithmetic.\n  const currentPlanSet = React.useMemo<Set<PricingDisplayPlan>>(() => {\n    if (!currentPriceIds?.length) return new Set()\n    const matches = plans.filter((p) => isPlanCurrentTier(p, currentPriceIds))\n    if (process.env.NODE_ENV !== \"production\" && matches.length > 1) {\n      console.warn(\n        `[PricingDisplay] currentPriceIds matched ${matches.length} plans. ` +\n          \"Each price ID should belong to exactly one plan. Only the first match will be treated as current.\"\n      )\n    }\n    return new Set(matches.length > 1 ? [matches[0]] : matches)\n  }, [plans, currentPriceIds])\n\n  const { prices, loading, error } = usePaddlePrices({\n    clientToken,\n    environment,\n    priceIds: allPriceIds,\n    countryCode,\n    discountId,\n  })\n\n  // Ref avoids stale closure in event listener\n  const onCheckoutCompleteRef = React.useRef(onCheckoutComplete)\n  React.useEffect(() => {\n    onCheckoutCompleteRef.current = onCheckoutComplete\n  }, [onCheckoutComplete])\n\n  // Subscribe to checkout.completed when handler is provided\n  React.useEffect(() => {\n    if (!onCheckoutComplete) return\n\n    const unsubscribe = addPaddleEventListener((event: PaddleEventData) => {\n      if (event.name === \"checkout.completed\" && onCheckoutCompleteRef.current) {\n        const data = event.data as CheckoutEventsData\n        onCheckoutCompleteRef.current({\n          transactionId: data?.transaction_id ?? \"\",\n          customerId: data?.customer?.id ?? \"\",\n          customerEmail: data?.customer?.email ?? \"\",\n        })\n      }\n    })\n\n    return unsubscribe\n  }, [onCheckoutComplete])\n\n  const handleCardSelect = React.useCallback(\n    async (priceId: string) => {\n      if (onPlanSelect) {\n        onPlanSelect(priceId)\n        return\n      }\n\n      // Default: open overlay checkout\n      const paddle = await getOrCreatePaddle(clientToken, environment)\n      paddle?.Checkout.open({\n        items: [{ priceId, quantity: 1 }],\n        ...(discountId && { discountId }),\n      })\n    },\n    [onPlanSelect, clientToken, environment, discountId]\n  )\n\n  if (error) {\n    return (\n      <div className=\"rounded-lg border border-destructive/50 bg-destructive/10 p-4 text-sm text-destructive\">\n        Failed to load pricing: {error.message}\n      </div>\n    )\n  }\n\n  // undefined = checkout mode; onPlanSelect or selectedPriceId = selection mode (CTA labels auto-switch)\n  const isSelectionMode = onPlanSelect !== undefined || selectedPriceId !== undefined\n\n  return (\n    <div className={cn(\"flex flex-col gap-8\", className)}>\n      {hasMultiInterval && (\n        <BillingIntervalToggle\n          intervals={intervals}\n          value={resolvedInterval}\n          onValueChange={setSelectedInterval}\n        />\n      )}\n\n      <PricingTierCardGroup>\n        {plans.map((plan, index) => {\n          const activePriceId = getActivePriceId(plan, resolvedInterval as Interval)\n          const rawPriceData = prices[activePriceId]\n          const priceData =\n            rawPriceData && !showOriginalPrice\n              ? { ...rawPriceData, originalTotal: undefined }\n              : rawPriceData\n\n          const isCurrent = currentPlanSet.has(plan) // any-interval tier match (badge)\n          const isCurrentPrice = !!currentPriceIds?.includes(activePriceId) // exact price match (CTA disabled)\n          // undefined = checkout mode; defined (true/false) = selection mode\n          const isSelected = isSelectionMode ? activePriceId === selectedPriceId : undefined\n\n          // CTA label priority: explicit override > switch-interval (checkout only) > selection-mode > card default\n          const resolvedCtaLabel =\n            plan.ctaLabel ??\n            (isCurrent && !isCurrentPrice && !isSelectionMode\n              ? `Switch to ${formatIntervalLabel(resolvedInterval, \"adjective\")}`\n              : isSelectionMode\n                ? isSelected\n                  ? \"Selected\"\n                  : isCurrentPrice\n                    ? undefined // falls through to card default: \"Current plan\"\n                    : \"Select plan\"\n                : undefined)\n\n          // Custom onSelect (e.g. \"Contact Sales\") stays active on current plan\n          const isDisabled = isCurrentPrice && !plan.onSelect\n\n          const onSelectHandler: (() => void) | undefined = plan.onSelect\n            ? plan.onSelect\n            : activePriceId\n              ? () => handleCardSelect(activePriceId)\n              : undefined\n\n          return (\n            <PricingTierCard\n              key={activePriceId || index}\n              name={plan.name}\n              priceData={priceData}\n              description={plan.description}\n              features={plan.features}\n              badge={plan.badge}\n              badgePosition={plan.badgePosition}\n              icon={plan.icon}\n              ctaLabel={resolvedCtaLabel}\n              loading={loading && !!activePriceId}\n              onSelect={isDisabled ? undefined : onSelectHandler}\n              isSelected={isSelected}\n              isCurrent={isCurrent}\n            />\n          )\n        })}\n      </PricingTierCardGroup>\n    </div>\n  )\n}\n",
      "type": "registry:component"
    }
  ],
  "docs": "Each plan requires a price ID (starts with pri_). For multi-interval plans, pass priceId as an object keyed by interval (e.g. { month: 'pri_monthly', year: 'pri_yearly' }). Create and/or find yours in your Paddle dashboard under Catalog > Prices.",
  "categories": ["pricing"]
}
