{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "delivery-options",
  "title": "Delivery Options",
  "author": "ecomcn <hello@ecomcn.dev>",
  "description": "How it gets there: one ruled list of delivery methods, each with its arrival dates and price. Arrival is a date range from business days and a dispatch cut-off on the shopper's own clock, worked out after hydration so the server never guesses their time zone, with a countdown to the cut-off on the chosen method. Prices are right-aligned tabular figures and a free method says Free. Unavailable methods give their reason in words; loading and empty states included. The heavy rule slides to the chosen method (Motion; a fade under reduced motion).",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "radio-group",
    "skeleton"
  ],
  "files": [
    {
      "path": "src/registry/ecomcn/delivery-options/delivery-options.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { MotionConfig, motion, useReducedMotion } from \"motion/react\"\n\nimport { RadioGroup, RadioGroupItem } from \"@/components/ui/radio-group\"\nimport { Skeleton } from \"@/components/ui/skeleton\"\nimport { cn } from \"@/lib/utils\"\n\n/*\n * How it gets there: one ruled list of delivery methods, each with the\n * dates it would arrive and what it costs.\n *\n *   <DeliveryOptions options={options} value={method} onValueChange={setMethod} />\n *\n * Arrival is a date range — \"Thu 16 – Mon 20 Oct\" — worked out from\n * business days and a dispatch cut-off on the shopper's own clock, after\n * hydration (the server can't know their time zone); until then each row\n * holds a placeholder of the same height. Prices sit right-aligned in\n * tabular figures, and a free method says \"Free\" in words. The heavy rule\n * slides to the chosen method (Motion; a fade under reduced motion).\n */\n\nexport interface DeliveryOption {\n  id: string\n  /** \"Standard\", \"Express\", \"Collect in store\". */\n  label: string\n  /** 0 shows \"Free\". */\n  price: number\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  /** Skip Saturdays and Sundays. Defaults to true. */\n  businessDaysOnly?: boolean\n  /** \"Tracked, signed for\", or a collection point's address. */\n  description?: React.ReactNode\n  /** False when it can't reach this address. */\n  available?: boolean\n  /** Why, in words: \"Not available for PO boxes\". */\n  unavailableReason?: string\n}\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/** Your locale, or the visitor's once hydrated — so server and client HTML agree. */\nfunction useFormatLocale(locale?: string) {\n  const hydrated = React.useSyncExternalStore(subscribeNothing, () => true, () => false)\n  return locale ?? (hydrated ? undefined : \"en-US\")\n}\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/** When an option would arrive if ordered at `now`, and how long is left to dispatch today. */\nexport function deliveryWindow(option: DeliveryOption, now: Date) {\n  const businessOnly = option.businessDaysOnly ?? true\n  const cutoff = option.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  let minutesLeft: number | null = null\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    minutesLeft = cutoff * 60 - (now.getHours() * 60 + now.getMinutes())\n  }\n  return {\n    first: addDays(dispatch, option.minDays, businessOnly),\n    last: addDays(dispatch, option.maxDays, businessOnly),\n    minutesLeft,\n  }\n}\n\nexport interface DeliveryOptionsProps\n  extends Omit<React.FieldsetHTMLAttributes<HTMLFieldSetElement>, \"onChange\" | \"defaultValue\" | \"name\"> {\n  options: DeliveryOption[]\n  value: string | null\n  onValueChange: (id: string) => void\n  currency?: string\n  /** `undefined` follows the visitor's locale, once hydrated. */\n  locale?: string\n  /** \"Delivery\", by default. */\n  legend?: React.ReactNode\n  /** Keeps the legend for screen readers when your page already has a heading. */\n  hideLegend?: boolean\n  /** The radio group's name, for a plain form post. */\n  name?: string\n  /** The options aren't known yet — rates are still being fetched for the address. */\n  loading?: boolean\n  disabled?: boolean\n}\n\nexport function DeliveryOptions({\n  options,\n  value,\n  onValueChange,\n  currency = \"USD\",\n  locale: localeProp,\n  legend = \"Delivery\",\n  hideLegend = false,\n  name,\n  loading = false,\n  disabled = false,\n  className,\n  ...props\n}: DeliveryOptionsProps) {\n  const locale = useFormatLocale(localeProp)\n  const reduce = useReducedMotion()\n  const uid = React.useId()\n  const minute = React.useSyncExternalStore(subscribeMinute, currentMinute, serverMinute)\n  const money = React.useMemo(() => new Intl.NumberFormat(locale, { style: \"currency\", currency }), [locale, currency])\n  const dates = React.useMemo(\n    () => new Intl.DateTimeFormat(locale, { weekday: \"short\", day: \"numeric\", month: \"short\" }),\n    [locale]\n  )\n\n  return (\n    <MotionConfig reducedMotion=\"user\">\n      <fieldset data-slot=\"delivery-options\" aria-busy={loading || undefined} className={cn(\"min-w-0\", className)} {...props}>\n        <legend id={`${uid}-legend`} className={cn(\"mb-3 text-base font-medium\", hideLegend && \"sr-only\")}>\n          {legend}\n        </legend>\n        {loading ? (\n          <div data-slot=\"delivery-options-skeleton\" className=\"border\">\n            <span className=\"sr-only\" role=\"status\">\n              Loading delivery options\n            </span>\n            {[0, 1, 2].map((row) => (\n              <div key={row} aria-hidden className=\"flex min-h-16 items-center gap-4 border-t px-4 py-3.5 first:border-t-0\">\n                <Skeleton className=\"size-4 rounded-full\" />\n                <span className=\"flex flex-1 flex-col gap-2\">\n                  <Skeleton className=\"h-3.5 w-28 rounded-none\" />\n                  <Skeleton className=\"h-3 w-44 rounded-none\" />\n                </span>\n                <Skeleton className=\"h-3.5 w-12 rounded-none\" />\n              </div>\n            ))}\n          </div>\n        ) : options.length === 0 ? (\n          <p data-slot=\"delivery-options-empty\" className=\"border px-4 py-5 text-sm text-muted-foreground\">\n            Nothing can be delivered to this address yet. Check the address, or contact us.\n          </p>\n        ) : (\n          <RadioGroup\n            value={value ?? \"\"}\n            onValueChange={(next: unknown) => onValueChange(String(next))}\n            name={name}\n            disabled={disabled}\n            aria-labelledby={`${uid}-legend`}\n            className=\"gap-0 border\"\n          >\n            {options.map((option) => {\n              const selected = value === option.id\n              const unavailable = option.available === false\n              const when = minute === null || unavailable ? null : deliveryWindow(option, new Date(minute * 60_000))\n              const arrival = when\n                ? option.minDays === option.maxDays\n                  ? dates.format(when.first)\n                  : dates.formatRange(when.first, when.last)\n                : null\n              const left = selected && when?.minutesLeft != null ? when.minutesLeft : null\n              const noteId = `${uid}-${option.id}-note`\n              return (\n                <label\n                  key={option.id}\n                  data-slot=\"delivery-option\"\n                  data-state={selected ? \"checked\" : \"unchecked\"}\n                  data-unavailable={unavailable || undefined}\n                  className={cn(\n                    \"relative flex min-h-16 cursor-pointer items-center gap-4 border-t px-4 py-3.5 transition-colors first:border-t-0\",\n                    selected ? \"bg-secondary/50\" : \"hover:bg-secondary/30\",\n                    (unavailable || disabled) && \"cursor-not-allowed hover:bg-transparent\"\n                  )}\n                >\n                  {selected ? (\n                    <motion.span\n                      aria-hidden\n                      layoutId={reduce ? undefined : `${uid}-rule`}\n                      transition={MORPH}\n                      className=\"absolute inset-y-0 -left-px w-0.5 bg-foreground\"\n                    />\n                  ) : null}\n                  <RadioGroupItem value={option.id} disabled={unavailable || disabled} aria-describedby={noteId} />\n                  <span className=\"flex min-w-0 flex-1 flex-col gap-0.5\">\n                    <span className={cn(\"text-sm font-medium\", unavailable && \"text-muted-foreground line-through decoration-1\")}>\n                      {option.label}\n                    </span>\n                    <span id={noteId} className=\"flex flex-col text-[13px] leading-snug text-muted-foreground\">\n                      {unavailable ? (\n                        (option.unavailableReason ?? \"Not available for this address\")\n                      ) : arrival ? (\n                        <span>\n                          Arrives <span className=\"text-foreground tabular-nums\">{arrival}</span>\n                        </span>\n                      ) : (\n                        // The shopper's dates arrive with hydration; hold the line's height till then.\n                        <span aria-hidden className=\"my-1 h-3 w-40 animate-pulse bg-muted\" />\n                      )}\n                      {left !== null ? (\n                        <span className=\"tabular-nums\">\n                          Order within {Math.floor(left / 60)}h {String(left % 60).padStart(2, \"0\")}m for dispatch today\n                        </span>\n                      ) : null}\n                      {!unavailable && option.description ? <span>{option.description}</span> : null}\n                    </span>\n                  </span>\n                  <span className={cn(\"shrink-0 text-sm tabular-nums\", unavailable && \"text-muted-foreground\")}>\n                    {option.price === 0 ? \"Free\" : money.format(option.price)}\n                  </span>\n                </label>\n              )\n            })}\n          </RadioGroup>\n        )}\n      </fieldset>\n    </MotionConfig>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/ecomcn/delivery-options.tsx"
    }
  ],
  "categories": [
    "ecommerce",
    "checkout"
  ],
  "type": "registry:block"
}