{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "product-buy-box",
  "title": "Product Buy Box",
  "author": "ecomcn <hello@ecomcn.dev>",
  "description": "The buy box: brand, name, a rating that links to the reviews, the price (the variant's own when it has one), stock-aware variant swatches, a quantity stepper, and an add-to-bag button that carries the live total — \"Add to bag — $840\". Adding is optimistic and rolls back with an inline message if your handler rejects. A missing size is asked for, not greyed out; a sold-out pick becomes \"Notify me\". Delivery is a date range from business days and a dispatch cut-off, never \"ships soon\". Labels, price and notices morph with Motion (a fade under reduced motion). Compound: the selected variant lives in one context, read through useProductBuyBox().",
  "dependencies": [
    "motion",
    "lucide-react"
  ],
  "registryDependencies": [
    "button",
    "input",
    "@ecomcn/price-tag",
    "@ecomcn/variant-swatches"
  ],
  "files": [
    {
      "path": "src/registry/ecomcn/product-buy-box/product-buy-box.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { Check, Minus, Plus, Star, Truck } from \"lucide-react\"\nimport { AnimatePresence, MotionConfig, motion, useReducedMotion } from \"motion/react\"\n\nimport { Button } from \"@/components/ui/button\"\nimport { Input } from \"@/components/ui/input\"\nimport { PriceTag } from \"@/components/ecomcn/price-tag\"\nimport {\n  VariantSwatches,\n  type Variant,\n  type VariantOption,\n  type VariantSelection,\n  type VariantSwatchesProps,\n} from \"@/components/ecomcn/variant-swatches\"\nimport { cn } from \"@/lib/utils\"\n\n/*\n * The buy box: name, price, rating, variants, quantity, and an add-to-bag\n * button that carries the live total — \"Add to bag — $840\".\n *\n *   <ProductBuyBox product={product} onAddToBag={addToBag} delivery={{ minDays: 2, maxDays: 4 }} />\n *\n *   <ProductBuyBox product={product} onAddToBag={addToBag}>          or composed\n *     <ProductBuyBoxHeader />\n *     <ProductBuyBoxPrice />\n *     <ProductBuyBoxVariants sizeGuide={<SizeGuideDialog … />} />\n *     <ProductBuyBoxActions />\n *     <ProductBuyBoxDelivery />\n *   </ProductBuyBox>\n *\n * Adding is optimistic: the button says \"Added\" the moment it is pressed,\n * and if your `onAddToBag` rejects, it rolls back — the button returns, and a\n * line under it says what went wrong. (The wishlist heart lives on the\n * product card, where it has room; the buy box gives its width to the button.)\n * Delivery is a date range worked out from business days and a cut-off\n * (\"Arrives Thu 9 – Mon 13 Oct\"), never \"ships soon\". The selected variant\n * lives in one context, so your own parts — a gallery, a sticky bar — read\n * the same pick through `useProductBuyBox()`.\n */\n\nexport interface BuyBoxVariant extends Variant {\n  /** This variant's price, when it differs from the product's. */\n  price?: number\n  compareAt?: number\n}\n\nexport interface BuyBoxProduct {\n  id: string\n  name: string\n  brand?: string\n  price: number\n  compareAt?: number\n  rating?: number\n  reviewCount?: number\n  options?: VariantOption[]\n  variants?: BuyBoxVariant[]\n}\n\nexport interface BuyBoxLine {\n  product: BuyBoxProduct\n  /** Undefined for a product with no options. */\n  variant: BuyBoxVariant | undefined\n  quantity: number\n}\n\nexport interface BuyBoxDelivery {\n  /** Business days from dispatch to the door, at the soonest and the latest. */\n  minDays: number\n  maxDays: number\n  /** Orders before this hour (0–23, the shopper's clock) dispatch the same day. */\n  cutoffHour?: number\n  /** Before the dates: \"Free delivery\", \"Express\". */\n  label?: React.ReactNode\n  /** Skip Saturdays and Sundays. Defaults to true. */\n  businessDaysOnly?: boolean\n}\n\ntype AddState = \"idle\" | \"adding\" | \"added\" | \"error\"\n\n/** The same settle curve as the other ecomcn morphs: no bounce, an editorial ease. */\nconst MORPH = { duration: 0.45, ease: [0.2, 0, 0, 1] } as const\n\nconst subscribeNothing = () => () => {}\n\n/**\n * The locale to format with. Yours when you pass one. Otherwise the\n * visitor's — but only once hydrated: the server cannot know it, and its HTML\n * (\"Sep 21, 2026\") must match the first client render, not the browser's\n * \"21 Sept 2026\". Until then, en-US.\n */\nfunction useFormatLocale(locale?: string) {\n  const hydrated = React.useSyncExternalStore(subscribeNothing, () => true, () => false)\n  return locale ?? (hydrated ? undefined : \"en-US\")\n}\n\nfunction useControllableState<T>(\n  value: T | undefined,\n  defaultValue: T,\n  onChange?: (next: T) => void\n) {\n  const [uncontrolled, setUncontrolled] = React.useState(defaultValue)\n  const controlled = value !== undefined\n  const set = React.useCallback(\n    (next: T) => {\n      if (!controlled) setUncontrolled(next)\n      onChange?.(next)\n    },\n    [controlled, onChange]\n  )\n  return [controlled ? value : uncontrolled, set] as const\n}\n\n/* ─── context ──────────────────────────────────────────────────────────── */\n\nexport interface ProductBuyBoxContextValue {\n  product: BuyBoxProduct\n  selection: VariantSelection\n  setSelection: (selection: VariantSelection) => void\n  /** The variant the picks point at; undefined until every option has one. */\n  variant: BuyBoxVariant | undefined\n  /** The first option still to pick — \"Size\" — or undefined. */\n  missing: string | undefined\n  soldOut: boolean\n  quantity: number\n  setQuantity: (quantity: number) => void\n  /** The most the shopper can add: the variant's stock, capped at `maxQuantity`. */\n  maxQuantity: number\n  unitPrice: number\n  compareAt?: number\n  /** Unit price × quantity. */\n  total: number\n  /** The price in the store's currency and the shopper's locale. */\n  formatMoney: (amount: number) => string\n  currency: string\n  /** The locale formatting uses: yours, or the visitor's once hydrated. */\n  locale?: string\n  addState: AddState\n  /** Adds, optimistically; or asks for the missing option; or notifies for a sold-out one. */\n  addToBag: () => void\n  /** What the line under the button says, or null. */\n  notice: { tone: \"hint\" | \"error\"; text: string } | null\n  canNotify: boolean\n  delivery?: BuyBoxDelivery\n  reviewsHref: string\n  loading: boolean\n}\n\nconst ProductBuyBoxContext = React.createContext<ProductBuyBoxContextValue | null>(null)\n\nexport function useProductBuyBox() {\n  const context = React.useContext(ProductBuyBoxContext)\n  if (!context) {\n    throw new Error(\"useProductBuyBox must be used within <ProductBuyBox>.\")\n  }\n  return context\n}\n\n/* ─── root ─────────────────────────────────────────────────────────────── */\n\nexport interface ProductBuyBoxProps\n  extends Omit<React.HTMLAttributes<HTMLDivElement>, \"defaultValue\" | \"onChange\"> {\n  product: BuyBoxProduct\n  currency?: string\n  locale?: string\n  /** The picks, by option name: `{ Colour: \"Tan\", Size: \"41\" }`. */\n  value?: VariantSelection\n  defaultValue?: VariantSelection\n  onValueChange?: (value: VariantSelection) => void\n  quantity?: number\n  defaultQuantity?: number\n  onQuantityChange?: (quantity: number) => void\n  /** The most one line can hold, before stock. Defaults to 10. */\n  maxQuantity?: number\n  /**\n   * Add the line to your bag. The button shows \"Added\" at once; return a\n   * promise, and if it rejects the button rolls back and says so.\n   */\n  onAddToBag?: (line: BuyBoxLine) => void | Promise<void>\n  /** A sold-out pick turns the button into \"Notify me\", which calls this. */\n  onNotify?: (line: BuyBoxLine) => void\n  delivery?: BuyBoxDelivery\n  /** Where the rating links to. Defaults to \"#reviews\". */\n  reviewsHref?: string\n  /** The product name's heading level. A product page's name is its h1. */\n  headingLevel?: \"h1\" | \"h2\"\n  loading?: boolean\n}\n\nexport function ProductBuyBox({\n  product,\n  currency = \"USD\",\n  locale: localeProp,\n  value: valueProp,\n  defaultValue = {},\n  onValueChange,\n  quantity: quantityProp,\n  defaultQuantity = 1,\n  onQuantityChange,\n  maxQuantity: maxQuantityProp = 10,\n  onAddToBag,\n  onNotify,\n  delivery,\n  reviewsHref = \"#reviews\",\n  headingLevel = \"h1\",\n  loading = false,\n  className,\n  children,\n  ...props\n}: ProductBuyBoxProps) {\n  const locale = useFormatLocale(localeProp)\n  const root = React.useRef<HTMLDivElement>(null)\n  const [selection, setSelectionState] = useControllableState(valueProp, defaultValue, onValueChange)\n  const [quantity, setQuantityState] = useControllableState(quantityProp, defaultQuantity, onQuantityChange)\n  const [addState, setAddState] = React.useState<AddState>(\"idle\")\n  const [notice, setNotice] = React.useState<ProductBuyBoxContextValue[\"notice\"]>(null)\n  const resetTimer = React.useRef<number | undefined>(undefined)\n  React.useEffect(() => () => window.clearTimeout(resetTimer.current), [])\n\n  const options = React.useMemo(() => product.options ?? [], [product.options])\n  // A one-value option (\"One size\") counts as picked.\n  const resolved = React.useMemo(() => {\n    const filled: VariantSelection = {}\n    for (const option of options) {\n      if (option.values.length === 1) {\n        const only = option.values[0]\n        filled[option.name] = typeof only === \"string\" ? only : only.value\n      }\n    }\n    return { ...filled, ...selection }\n  }, [options, selection])\n\n  const missing = options.find((o) => resolved[o.name] === undefined)?.name\n  const variant = missing\n    ? undefined\n    : product.variants?.find((v) => options.every((o) => v.options[o.name] === resolved[o.name]))\n  const soldOut =\n    !missing && options.length > 0 && (product.variants ? !variant || variant.stock === 0 : false)\n  const stockCap = variant?.stock && variant.stock > 0 ? variant.stock : Infinity\n  const maxQuantity = Math.max(1, Math.min(maxQuantityProp, stockCap))\n  const clampedQuantity = Math.min(Math.max(1, quantity), maxQuantity)\n  const unitPrice = variant?.price ?? product.price\n  const compareAt = variant?.price !== undefined ? variant.compareAt : (variant?.compareAt ?? product.compareAt)\n\n  const formatMoney = React.useMemo(() => {\n    const whole = new Intl.NumberFormat(locale, { style: \"currency\", currency, maximumFractionDigits: 0 })\n    const cents = new Intl.NumberFormat(locale, { style: \"currency\", currency })\n    return (amount: number) => (Number.isInteger(amount) ? whole : cents).format(amount)\n  }, [locale, currency])\n\n  const setSelection = React.useCallback(\n    (next: VariantSelection) => {\n      setNotice(null)\n      setAddState((state) => (state === \"error\" ? \"idle\" : state))\n      setSelectionState(next)\n    },\n    [setSelectionState]\n  )\n\n  const addToBag = React.useCallback(() => {\n    if (addState === \"adding\") return\n    if (missing) {\n      setNotice({ tone: \"hint\", text: `Choose a ${missing.toLowerCase()} first.` })\n      // Take the shopper to the option they skipped.\n      root.current\n        ?.querySelector<HTMLElement>(\n          `[data-slot=\"variant-swatches-option\"][data-option=\"${CSS.escape(missing)}\"] [role=\"radio\"][tabindex=\"0\"]`\n        )\n        ?.focus()\n      return\n    }\n    const line: BuyBoxLine = { product, variant, quantity: clampedQuantity }\n    if (soldOut) {\n      onNotify?.(line)\n      return\n    }\n    window.clearTimeout(resetTimer.current)\n    setNotice(null)\n    // Optimistic: \"Added\" now, settled or rolled back when the promise is.\n    setAddState(\"adding\")\n    Promise.resolve()\n      .then(() => onAddToBag?.(line))\n      .then(\n        () => {\n          setAddState(\"added\")\n          resetTimer.current = window.setTimeout(() => setAddState(\"idle\"), 2400)\n        },\n        () => {\n          setAddState(\"error\")\n          setNotice({ tone: \"error\", text: \"That didn't reach your bag. Try again.\" })\n        }\n      )\n  }, [addState, missing, product, variant, clampedQuantity, soldOut, onNotify, onAddToBag])\n\n  const context = React.useMemo<ProductBuyBoxContextValue>(\n    () => ({\n      product,\n      selection,\n      setSelection,\n      variant,\n      missing,\n      soldOut,\n      quantity: clampedQuantity,\n      setQuantity: (n) => setQuantityState(Math.min(Math.max(1, Math.round(n) || 1), maxQuantity)),\n      maxQuantity,\n      unitPrice,\n      compareAt,\n      total: unitPrice * clampedQuantity,\n      formatMoney,\n      currency,\n      locale,\n      addState,\n      addToBag,\n      notice,\n      canNotify: Boolean(onNotify),\n      delivery,\n      reviewsHref,\n      loading,\n    }),\n    [product, selection, setSelection, variant, missing, soldOut, clampedQuantity, setQuantityState, maxQuantity, unitPrice, compareAt, formatMoney, currency, locale, addState, addToBag, notice, onNotify, delivery, reviewsHref, loading]\n  )\n\n  return (\n    <ProductBuyBoxContext.Provider value={context}>\n      <MotionConfig reducedMotion=\"user\">\n        <div\n          ref={root}\n          data-slot=\"product-buy-box\"\n          aria-busy={loading || undefined}\n          className={cn(\"flex flex-col gap-8\", className)}\n          {...props}\n        >\n          {children ?? (\n            <>\n              <div className=\"flex flex-col gap-4\">\n                <ProductBuyBoxHeader headingLevel={headingLevel} />\n                <ProductBuyBoxPrice />\n              </div>\n              <ProductBuyBoxVariants />\n              <ProductBuyBoxActions />\n              <ProductBuyBoxDelivery />\n            </>\n          )}\n        </div>\n      </MotionConfig>\n    </ProductBuyBoxContext.Provider>\n  )\n}\n\n/* ─── parts ────────────────────────────────────────────────────────────── */\n\n/** Brand, name, and the rating — a link down to the reviews. */\nexport function ProductBuyBoxHeader({\n  headingLevel = \"h1\",\n  className,\n  ...props\n}: { headingLevel?: \"h1\" | \"h2\" } & React.HTMLAttributes<HTMLDivElement>) {\n  const { product, reviewsHref, locale, loading } = useProductBuyBox()\n  const Heading = headingLevel\n  if (loading) {\n    return (\n      <div aria-hidden className={cn(\"flex flex-col gap-3\", className)}>\n        <span className=\"h-3 w-20 animate-pulse bg-muted\" />\n        <span className=\"h-9 w-3/4 animate-pulse bg-muted\" />\n        <span className=\"h-3 w-28 animate-pulse bg-muted\" />\n      </div>\n    )\n  }\n  const score = product.rating\n  return (\n    <div data-slot=\"product-buy-box-header\" className={cn(\"flex flex-col gap-3\", className)} {...props}>\n      {product.brand ? (\n        <p className=\"text-xs font-medium tracking-[0.14em] text-muted-foreground uppercase\">{product.brand}</p>\n      ) : null}\n      <Heading className=\"text-4xl leading-[1.05] tracking-tight text-balance sm:text-5xl\">{product.name}</Heading>\n      {score !== undefined ? (\n        <a\n          href={reviewsHref}\n          className=\"flex w-fit items-center gap-2 text-sm text-muted-foreground underline decoration-border underline-offset-4 outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring\"\n        >\n          <span aria-hidden className=\"relative inline-flex\">\n            <span className=\"flex gap-0.5 text-border\">\n              {Array.from({ length: 5 }, (_, i) => (\n                <Star key={i} className=\"size-3.5 fill-current\" />\n              ))}\n            </span>\n            <span className=\"absolute inset-y-0 left-0 flex gap-0.5 overflow-hidden text-foreground\" style={{ width: `${(score / 5) * 100}%` }}>\n              {Array.from({ length: 5 }, (_, i) => (\n                <Star key={i} className=\"size-3.5 shrink-0 fill-current\" />\n              ))}\n            </span>\n          </span>\n          <span className=\"tabular-nums\">\n            {new Intl.NumberFormat(locale, { minimumFractionDigits: 1, maximumFractionDigits: 1 }).format(score)}\n            {product.reviewCount !== undefined ? (\n              <> ({new Intl.NumberFormat(locale).format(product.reviewCount)} reviews)</>\n            ) : null}\n          </span>\n          <span className=\"sr-only\">, rated out of 5. Read the reviews</span>\n        </a>\n      ) : null}\n    </div>\n  )\n}\n\n/** The price — the variant's, when it has its own — fading over when it changes. */\nexport function ProductBuyBoxPrice({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {\n  const { unitPrice, compareAt, currency, locale, loading } = useProductBuyBox()\n  const reduce = useReducedMotion()\n  if (loading) return <span aria-hidden className={cn(\"block h-8 w-24 animate-pulse bg-muted\", className)} />\n  return (\n    <div data-slot=\"product-buy-box-price\" className={cn(\"relative\", className)} {...props}>\n      <AnimatePresence initial={false} mode=\"popLayout\">\n        <motion.div\n          key={`${unitPrice}-${compareAt ?? \"\"}`}\n          initial={{ opacity: 0, y: reduce ? 0 : 6 }}\n          animate={{ opacity: 1, y: 0 }}\n          exit={{ opacity: 0, y: reduce ? 0 : -6 }}\n          transition={reduce ? { duration: 0.15 } : MORPH}\n        >\n          <PriceTag price={unitPrice} compareAt={compareAt} currency={currency} locale={locale} />\n        </motion.div>\n      </AnimatePresence>\n    </div>\n  )\n}\n\n/**\n * The product's options, on variant-swatches: stock-aware swatches and size\n * grid, controlled by the buy box. Any other VariantSwatches prop passes\n * through — `sizeGuide`, `messages`, `lowStockThreshold`.\n */\nexport function ProductBuyBoxVariants(\n  props: Omit<VariantSwatchesProps, \"options\" | \"variants\" | \"value\" | \"defaultValue\" | \"onValueChange\">\n) {\n  const { product, selection, setSelection, loading } = useProductBuyBox()\n  if (!product.options?.length) return null\n  return (\n    <VariantSwatches\n      options={product.options}\n      variants={product.variants}\n      value={selection}\n      onValueChange={setSelection}\n      loading={loading}\n      {...props}\n    />\n  )\n}\n\n/** Quantity and add to bag on one line; the notice under them. */\nexport function ProductBuyBoxActions({ className, children, ...props }: React.HTMLAttributes<HTMLDivElement>) {\n  return (\n    <div data-slot=\"product-buy-box-actions\" className={cn(\"flex flex-col\", className)} {...props}>\n      <div className=\"flex gap-2\">\n        {children ?? (\n          <>\n            <ProductBuyBoxQuantity />\n            <ProductBuyBoxAddToBag className=\"min-w-0 flex-1\" />\n          </>\n        )}\n      </div>\n      <ProductBuyBoxNotice />\n    </div>\n  )\n}\n\n/** − [ 2 ] + as one ruled block. Typing works too; it settles on blur or Enter. */\nexport function ProductBuyBoxQuantity({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {\n  const { quantity, setQuantity, maxQuantity, loading, soldOut } = useProductBuyBox()\n  const [draft, setDraft] = React.useState<string | null>(null)\n  const commit = () => {\n    if (draft !== null) setQuantity(Number.parseInt(draft, 10))\n    setDraft(null)\n  }\n  const off = loading || soldOut\n\n  return (\n    <div\n      role=\"group\"\n      aria-label=\"Quantity\"\n      data-slot=\"product-buy-box-quantity\"\n      className={cn(\"flex h-12 shrink-0 items-stretch border\", className)}\n      {...props}\n    >\n      <Button\n        type=\"button\"\n        variant=\"ghost\"\n        aria-label=\"One fewer\"\n        disabled={off || quantity <= 1}\n        onClick={() => setQuantity(quantity - 1)}\n        className=\"h-full w-10 rounded-none\"\n      >\n        <Minus aria-hidden />\n      </Button>\n      <Input\n        aria-label=\"Quantity\"\n        inputMode=\"numeric\"\n        pattern=\"[0-9]*\"\n        disabled={off}\n        value={draft ?? String(quantity)}\n        onChange={(event) => setDraft(event.target.value.replace(/\\D/g, \"\").slice(0, 3))}\n        onBlur={commit}\n        onKeyDown={(event) => {\n          if (event.key === \"Enter\") commit()\n        }}\n        className=\"h-full w-10 rounded-none border-0 px-0 text-center tabular-nums shadow-none focus-visible:ring-2 focus-visible:ring-inset\"\n      />\n      <Button\n        type=\"button\"\n        variant=\"ghost\"\n        aria-label=\"One more\"\n        disabled={off || quantity >= maxQuantity}\n        onClick={() => setQuantity(quantity + 1)}\n        className=\"h-full w-10 rounded-none\"\n      >\n        <Plus aria-hidden />\n      </Button>\n    </div>\n  )\n}\n\n/**\n * The button carries the total: \"Add to bag — $840\". It asks for a missing\n * size rather than greying out, becomes \"Notify me\" when the pick is sold\n * out, and morphs its label between states with Motion.\n */\n// className only: the Button is your primitive, and its other props differ\n// between Radix and Base UI.\nexport function ProductBuyBoxAddToBag({ className }: { className?: string }) {\n  const { total, formatMoney, missing, soldOut, canNotify, addState, addToBag, loading } = useProductBuyBox()\n  const reduce = useReducedMotion()\n\n  const label =\n    addState === \"adding\" || addState === \"added\"\n      ? { key: \"added\", text: \"Added to bag\", icon: true }\n      : missing\n        ? { key: \"missing\", text: `Select a ${missing.toLowerCase()}`, icon: false }\n        : soldOut\n          ? { key: \"sold-out\", text: canNotify ? \"Notify me\" : \"Sold out\", icon: false }\n          : { key: `add-${total}`, text: `Add to bag — ${formatMoney(total)}`, icon: false }\n\n  return (\n    <Button\n      type=\"button\"\n      size=\"lg\"\n      variant={soldOut ? \"outline\" : \"default\"}\n      data-state={addState}\n      // Not disabled while a size is missing — pressing it says which — but\n      // a sold-out pick with nobody to notify can only be explained.\n      disabled={loading || (soldOut && !canNotify)}\n      onClick={addToBag}\n      className={cn(\n        \"relative h-auto min-h-12 overflow-hidden rounded-none py-2 text-xs font-medium tracking-[0.14em] whitespace-normal uppercase shadow-none\",\n        className\n      )}\n    >\n      <AnimatePresence initial={false} mode=\"popLayout\">\n        <motion.span\n          key={label.key}\n          initial={{ opacity: 0, y: reduce ? 0 : 14 }}\n          animate={{ opacity: 1, y: 0 }}\n          exit={{ opacity: 0, y: reduce ? 0 : -14 }}\n          transition={reduce ? { duration: 0.15 } : MORPH}\n          // Wraps to a second line before it would ever overflow.\n          className=\"flex min-w-0 items-center justify-center gap-2 text-center leading-snug text-balance tabular-nums\"\n        >\n          {label.icon ? <Check aria-hidden className=\"size-4\" /> : null}\n          {label.text}\n        </motion.span>\n      </AnimatePresence>\n    </Button>\n  )\n}\n\n/**\n * The line under the button: which option to pick, or what failed. A status\n * region that is always in the page, so each new line is read out; the line\n * grows open to its height.\n */\nexport function ProductBuyBoxNotice({ className, ...props }: Omit<React.HTMLAttributes<HTMLDivElement>, \"children\">) {\n  const { notice } = useProductBuyBox()\n  const reduce = useReducedMotion()\n  return (\n    <div\n      role=\"status\"\n      data-slot=\"product-buy-box-notice\"\n      className={className}\n      {...props}\n    >\n      <AnimatePresence initial={false}>\n        {notice ? (\n          <motion.div\n            key={notice.text}\n            initial={reduce ? { opacity: 0 } : { height: 0, opacity: 0 }}\n            animate={reduce ? { opacity: 1 } : { height: \"auto\", opacity: 1 }}\n            exit={reduce ? { opacity: 0 } : { height: 0, opacity: 0 }}\n            transition={reduce ? { duration: 0.15 } : MORPH}\n            className=\"overflow-hidden\"\n          >\n            <p\n              className={cn(\n                \"flex items-baseline gap-2.5 pt-3 text-sm\",\n                notice.tone === \"error\" ? \"text-[var(--sale,var(--destructive))]\" : \"text-foreground\"\n              )}\n            >\n              <span aria-hidden className=\"h-px w-4 shrink-0 translate-y-[-0.3em] bg-current\" />\n              {notice.text}\n            </p>\n          </motion.div>\n        ) : null}\n      </AnimatePresence>\n    </div>\n  )\n}\n\n/* ─── delivery ─────────────────────────────────────────────────────────── */\n\nconst subscribeMinute = (onChange: () => void) => {\n  const timer = window.setInterval(onChange, 30_000)\n  return () => window.clearInterval(timer)\n}\nconst currentMinute = () => Math.floor(Date.now() / 60_000)\nconst serverMinute = () => null\n\n/** `days` business (or calendar) days after `from`. */\nfunction addDays(from: Date, days: number, businessOnly: boolean) {\n  const date = new Date(from)\n  let left = days\n  while (left > 0) {\n    date.setDate(date.getDate() + 1)\n    const day = date.getDay()\n    if (!businessOnly || (day !== 0 && day !== 6)) left -= 1\n  }\n  return date\n}\n\n/**\n * When it arrives, as a date range — \"Arrives Thu 9 – Mon 13 Oct\" — with a\n * countdown to the dispatch cut-off. Worked out on the shopper's clock after\n * hydration (the server cannot know their time zone), with a placeholder of\n * the same height until then.\n */\nexport function ProductBuyBoxDelivery({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {\n  const { delivery, locale, soldOut } = useProductBuyBox()\n  const minute = React.useSyncExternalStore(subscribeMinute, currentMinute, serverMinute)\n  if (!delivery || soldOut) return null\n\n  let text: React.ReactNode = null\n  let countdown: string | null = null\n  if (minute !== null) {\n    const now = new Date(minute * 60_000)\n    const businessOnly = delivery.businessDaysOnly ?? true\n    const cutoff = delivery.cutoffHour\n    // Past the cut-off, or on a day nothing dispatches: it goes out next working day.\n    let dispatch = new Date(now)\n    const closed = businessOnly && (now.getDay() === 0 || now.getDay() === 6)\n    if (closed || (cutoff !== undefined && now.getHours() >= cutoff)) {\n      dispatch = addDays(now, 1, businessOnly)\n    } else if (cutoff !== undefined) {\n      const left = cutoff * 60 - (now.getHours() * 60 + now.getMinutes())\n      countdown = `${Math.floor(left / 60)}h ${String(left % 60).padStart(2, \"0\")}m`\n    }\n    const first = addDays(dispatch, delivery.minDays, businessOnly)\n    const last = addDays(dispatch, delivery.maxDays, businessOnly)\n    const format = new Intl.DateTimeFormat(locale, { weekday: \"short\", day: \"numeric\", month: \"short\" })\n    text = delivery.minDays === delivery.maxDays ? format.format(first) : format.formatRange(first, last)\n  }\n\n  return (\n    <div\n      data-slot=\"product-buy-box-delivery\"\n      className={cn(\"flex items-start gap-3 border-t pt-5 text-sm\", className)}\n      {...props}\n    >\n      <Truck aria-hidden className=\"mt-0.5 size-4 shrink-0 text-muted-foreground\" />\n      {text === null ? (\n        <span aria-hidden className=\"flex flex-col gap-2 py-0.5\">\n          <span className=\"h-3.5 w-48 animate-pulse bg-muted\" />\n          <span className=\"h-3 w-36 animate-pulse bg-muted\" />\n        </span>\n      ) : (\n        <p className=\"flex flex-col gap-1\">\n          <span>\n            {delivery.label ? <>{delivery.label} · </> : null}\n            Arrives <span className=\"font-medium tabular-nums\">{text}</span>\n          </span>\n          {countdown ? (\n            <span className=\"text-muted-foreground tabular-nums\">\n              Order within {countdown} for dispatch today\n            </span>\n          ) : null}\n        </p>\n      )}\n    </div>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/ecomcn/product-buy-box.tsx"
    }
  ],
  "categories": [
    "ecommerce",
    "product",
    "pdp",
    "cart"
  ],
  "type": "registry:block"
}