{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "express-checkout",
  "type": "registry:block",
  "title": "Express checkout",
  "description": "Mobile-first checkout experience with pricing cards and sheet-style checkout drawer. Automatically resizes based on the height of the checkout.",
  "dependencies": ["vaul"],
  "registryDependencies": [
    "@paddle/paddle-client",
    "@paddle/pricing-select-cards",
    "drawer",
    "button"
  ],
  "files": [
    {
      "path": "registry/new-york/blocks/express-checkout/components/express-checkout.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { PricingSelectCardStacked } from \"@/registry/new-york/blocks/pricing-select-cards/components/pricing-select-card-stacked\"\nimport { PricingSelectCardGrid } from \"@/registry/new-york/blocks/pricing-select-cards/components/pricing-select-card-grid\"\nimport { PricingSelectCardGroup } from \"@/registry/new-york/blocks/pricing-select-cards/components/pricing-select-card-group\"\nimport { usePaddlePrices } from \"@/registry/new-york/blocks/paddle-client/lib/hooks/use-paddle-prices\"\nimport { ExpressCheckoutDrawer } from \"./express-checkout-drawer\"\nimport { Button } from \"@/registry/new-york/ui/button\"\nimport type {\n  CheckoutCustomer,\n  Environments,\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\"\nimport type { PricingSelectPlan } from \"@/registry/new-york/blocks/pricing-select-cards/components/pricing-select-card\"\nimport { cn } from \"@/lib/utils\"\n\n/** Props for the `ExpressCheckout` component. */\nexport type ExpressCheckoutProps = {\n  clientToken: string\n  environment?: Environments\n  plans: PricingSelectPlan[]\n  defaultPriceId?: string\n  layout?: \"stacked\" | \"grid\"\n  customer?: CheckoutCustomer\n  discountCode?: string\n  discountId?: string\n  customData?: Record<string, unknown>\n  theme?: \"light\" | \"dark\"\n  locale?: string\n  showNonExpressPaymentMethods?: boolean\n  onComplete?: (data: CheckoutCompleteData) => void\n  onError?: (error: Error) => void\n  submitLabel?: string\n  className?: string\n}\n\nexport function ExpressCheckout({\n  clientToken,\n  environment = \"production\",\n  plans,\n  defaultPriceId,\n  layout = \"stacked\",\n  customer,\n  discountCode,\n  discountId,\n  customData,\n  theme,\n  locale,\n  showNonExpressPaymentMethods,\n  onComplete,\n  onError,\n  submitLabel = \"Subscribe now\",\n  className,\n}: ExpressCheckoutProps) {\n  const [selectedPlan, setSelectedPlan] = React.useState(defaultPriceId || plans[0]?.priceId || \"\")\n  const [drawerOpen, setDrawerOpen] = React.useState(false)\n  const selectedPlanName = plans.find((p) => p.priceId === selectedPlan)?.name\n\n  const { prices, loading, error } = usePaddlePrices({\n    clientToken,\n    environment,\n    priceIds: plans.map((plan) => plan.priceId),\n    countryCode: customer?.address?.countryCode,\n  })\n\n  if (error) {\n    return (\n      <div className=\"text-destructive rounded-lg border border-destructive/50 bg-destructive/10 p-4 text-sm\">\n        Failed to load pricing: {error.message}\n      </div>\n    )\n  }\n\n  return (\n    <div className={cn(\"w-full max-w-md space-y-6\", className)}>\n      <PricingSelectCardGroup value={selectedPlan} onValueChange={setSelectedPlan} layout={layout}>\n        {plans.map((plan) =>\n          layout === \"grid\" ? (\n            <PricingSelectCardGrid\n              key={plan.priceId}\n              priceId={plan.priceId}\n              name={plan.name}\n              priceData={prices[plan.priceId]}\n              badge={plan.badge}\n              loading={loading}\n            />\n          ) : (\n            <PricingSelectCardStacked\n              key={plan.priceId}\n              priceId={plan.priceId}\n              name={plan.name}\n              priceData={prices[plan.priceId]}\n              description={plan.description}\n              badge={plan.badge}\n              icon={plan.icon}\n              loading={loading}\n            />\n          )\n        )}\n      </PricingSelectCardGroup>\n\n      <Button\n        className=\"w-full\"\n        size=\"lg\"\n        onClick={() => setDrawerOpen(true)}\n        disabled={loading || !selectedPlan}\n        aria-label={selectedPlanName ? `${submitLabel} - ${selectedPlanName}` : submitLabel}\n      >\n        {submitLabel}\n      </Button>\n\n      <ExpressCheckoutDrawer\n        open={drawerOpen}\n        onOpenChange={setDrawerOpen}\n        priceId={selectedPlan}\n        clientToken={clientToken}\n        environment={environment}\n        customer={customer}\n        discountCode={discountCode}\n        discountId={discountId}\n        customData={customData}\n        theme={theme}\n        locale={locale}\n        showNonExpressPaymentMethods={showNonExpressPaymentMethods}\n        onComplete={onComplete}\n        onError={onError}\n      />\n    </div>\n  )\n}\n",
      "type": "registry:component"
    },
    {
      "path": "registry/new-york/blocks/express-checkout/components/express-checkout-drawer.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport {\n  Drawer,\n  DrawerContent,\n  DrawerDescription,\n  DrawerTitle,\n} from \"@/registry/new-york/ui/drawer\"\nimport { Skeleton } from \"@/registry/new-york/ui/skeleton\"\nimport { useCheckoutDrawer } from \"@/registry/new-york/blocks/express-checkout/hooks/use-checkout-drawer\"\nimport type {\n  OpenCheckoutOptions,\n  CheckoutCustomer,\n  Environments,\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/** Props for the `ExpressCheckoutDrawer` component. */\nexport type ExpressCheckoutDrawerProps = {\n  open: boolean\n  onOpenChange: (open: boolean) => void\n  priceId: string\n  clientToken: string\n  environment?: Environments\n  customer?: CheckoutCustomer\n  discountCode?: string\n  discountId?: string\n  customData?: Record<string, unknown>\n  theme?: \"light\" | \"dark\"\n  locale?: string\n  showNonExpressPaymentMethods?: boolean\n  onComplete?: (data: CheckoutCompleteData) => void\n  onError?: (error: Error) => void\n}\n\nexport function ExpressCheckoutDrawer({\n  open,\n  onOpenChange,\n  priceId,\n  clientToken,\n  environment = \"production\",\n  customer,\n  discountCode,\n  discountId,\n  customData,\n  theme,\n  locale,\n  showNonExpressPaymentMethods,\n  onComplete,\n  onError,\n}: ExpressCheckoutDrawerProps) {\n  const { openCheckout, closeCheckout, checkoutHeight, isReady } = useCheckoutDrawer({\n    clientToken,\n    environment,\n    theme,\n    locale,\n    showNonExpressPaymentMethods,\n    onComplete,\n    onError,\n  })\n\n  const [checkoutLoaded, setCheckoutLoaded] = React.useState(false)\n\n  // Stable ref — effects re-run only when actual values change\n  const stableCustomer = React.useMemo(\n    () => customer,\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n    [\n      customer?.id,\n      customer?.email,\n      customer?.address?.id,\n      customer?.address?.countryCode,\n      customer?.address?.postalCode,\n      customer?.address?.region,\n      customer?.address?.city,\n      customer?.address?.firstLine,\n      customer?.business?.id,\n      customer?.business?.name,\n      customer?.business?.taxIdentifier,\n    ]\n  )\n\n  const stableCustomData = React.useMemo(\n    () => customData,\n    // JSON.stringify is the most pragmatic deep-compare for Record<string, unknown>\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n    [JSON.stringify(customData)]\n  )\n\n  React.useEffect(() => {\n    if (open && isReady && priceId) {\n      setCheckoutLoaded(false)\n      // Delay until drawer is fully mounted and visible\n      const timeoutId = setTimeout(() => {\n        const options: OpenCheckoutOptions = {\n          priceId,\n          ...(stableCustomer && { customer: stableCustomer }),\n          ...(discountCode ? { discountCode } : discountId ? { discountId } : {}),\n          ...(stableCustomData && { customData: stableCustomData }),\n        }\n        openCheckout(options)\n        // Brief delay for Paddle to render before showing content\n        setTimeout(() => setCheckoutLoaded(true), 500)\n      }, 300)\n\n      return () => clearTimeout(timeoutId)\n    }\n  }, [\n    open,\n    isReady,\n    priceId,\n    stableCustomer,\n    discountCode,\n    discountId,\n    stableCustomData,\n    openCheckout,\n  ])\n\n  React.useEffect(() => {\n    if (!open) {\n      closeCheckout()\n      setCheckoutLoaded(false)\n    }\n  }, [open, closeCheckout])\n\n  return (\n    <Drawer open={open} onOpenChange={onOpenChange} shouldScaleBackground={false}>\n      <DrawerContent className=\"max-h-[80vh]\">\n        {/* Hidden title and description for accessibility */}\n        <DrawerTitle className=\"sr-only\">Checkout</DrawerTitle>\n        <DrawerDescription className=\"sr-only\">\n          Complete your purchase using the checkout form below.\n        </DrawerDescription>\n        {/* Inner container handles height transitions separately from Vaul's drawer animation */}\n        <div\n          style={{ height: checkoutHeight }}\n          className=\"overflow-hidden transition-[height] duration-300 ease-out motion-reduce:transition-none motion-reduce:duration-0\"\n        >\n          <div className=\"h-full overflow-y-auto p-4\">\n            {!checkoutLoaded && (\n              <div className=\"flex flex-col items-center gap-4 p-4\">\n                <Skeleton className=\"h-10 w-full rounded-lg\" />\n                <Skeleton className=\"h-10 w-full rounded-lg\" />\n                <Skeleton className=\"h-10 w-3/4 rounded-lg\" />\n              </div>\n            )}\n            <div className=\"checkout-container w-full min-h-[180px]\" />\n          </div>\n        </div>\n      </DrawerContent>\n    </Drawer>\n  )\n}\n",
      "type": "registry:component"
    },
    {
      "path": "registry/new-york/blocks/express-checkout/hooks/use-checkout-drawer.tsx",
      "content": "\"use client\"\n\nimport { useEffect, useCallback, useRef, useState } from \"react\"\nimport type {\n  PaddleEventData,\n  CheckoutSettings,\n} from \"@/registry/new-york/blocks/paddle-client/lib/paddle-sdk-types\"\nimport { addPaddleEventListener } from \"@/registry/new-york/blocks/paddle-client/lib/paddle-instance\"\nimport {\n  useCheckout,\n  type UseCheckoutArgs,\n} from \"@/registry/new-york/blocks/paddle-client/lib/hooks/use-checkout\"\n\nconst INITIAL_HEIGHT = 220\nconst PADDING = 95 // Inflated due to Paddle iframe sizing bug, will be reduced to ~40 once fixed\n\n// checkout.ping.size carries a height field not yet in @paddle/paddle-js types\ntype PaddlePingSizeEvent = PaddleEventData & { height?: number }\n\n// Express checkout settings — passed explicitly to useCheckout to match the\n// checkout-container target div rendered in ExpressCheckoutDrawer.\nconst EXPRESS_SETTINGS: CheckoutSettings = {\n  displayMode: \"inline\",\n  variant: \"express\",\n  frameTarget: \"checkout-container\",\n  frameInitialHeight: 180,\n  frameStyle: \"width: 100%; border: 0;\",\n}\n\nexport type UseCheckoutDrawerArgs = Omit<UseCheckoutArgs, \"checkoutSettings\">\n\nexport function useCheckoutDrawer(args: UseCheckoutDrawerArgs) {\n  const { openCheckout, closeCheckout, isReady } = useCheckout({\n    ...args,\n    checkoutSettings: EXPRESS_SETTINGS,\n  })\n\n  const [checkoutHeight, setCheckoutHeight] = useState(INITIAL_HEIGHT)\n  const heightTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)\n\n  // Debounce height updates for smooth drawer transitions\n  const setHeightDebounced = useCallback((height: number) => {\n    if (heightTimeoutRef.current) {\n      clearTimeout(heightTimeoutRef.current)\n    }\n    heightTimeoutRef.current = setTimeout(() => {\n      const maxHeight = window.innerHeight * 0.8\n      setCheckoutHeight(Math.min(height + PADDING, maxHeight))\n    }, 50)\n  }, [])\n\n  // Track drawer height via checkout.ping.size events\n  useEffect(() => {\n    const unsubscribe = addPaddleEventListener((event) => {\n      const { type, height } = event as PaddlePingSizeEvent\n      if (type === \"checkout.ping.size\" && height) {\n        setHeightDebounced(height)\n      }\n    })\n\n    return unsubscribe\n  }, [setHeightDebounced])\n\n  // Cleanup debounce on unmount\n  useEffect(() => {\n    return () => {\n      if (heightTimeoutRef.current) {\n        clearTimeout(heightTimeoutRef.current)\n      }\n    }\n  }, [])\n\n  return {\n    openCheckout,\n    closeCheckout,\n    checkoutHeight,\n    isReady,\n  }\n}\n",
      "type": "registry:hook"
    }
  ],
  "docs": "Each plan requires a price ID (starts with pri_). Create and/or find yours in your Paddle dashboard under Catalog > Prices. Use sandbox price IDs while NEXT_PUBLIC_PADDLE_ENV is 'sandbox'.",
  "categories": ["checkout"]
}
