{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "pdp-delivery-estimate",
  "type": "registry:component",
  "title": "Delivery estimate",
  "description": "ZIP input and an arrival window computed from today in business days, with a pickup option row.",
  "author": "StoreKit <hello@storekit.dev>",
  "categories": [
    "pdp",
    "any"
  ],
  "dependencies": [
    "motion",
    "lucide-react"
  ],
  "registryDependencies": [
    "https://storekit-eight.vercel.app/r/cn.json",
    "https://storekit-eight.vercel.app/r/sk-analytics.json"
  ],
  "files": [
    {
      "path": "pdp/pdp-delivery-estimate.tsx",
      "content": "\"use client\"\n\nimport { useEffect, useId, useRef, useState } from \"react\"\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\"\nimport { MapPin, Store, Truck } from \"lucide-react\"\nimport { cn } from \"@/components/storekit/lib/cn\"\nimport { useSkBlock } from \"@/components/storekit/lib/sk-analytics\"\n\n/* Delivery date and pickup, next to the buy button. The arrival window is\n   computed from today in business days: processing (plus one if past the\n   dispatch cutoff) then transit, skipping weekends. Dates are computed after\n   mount, so server HTML never disagrees with the shopper's clock. Pass\n   `transitFor` to plug in your carrier's real estimate for a ZIP. */\n\ntype Method = \"ship\" | \"pickup\"\ntype Transit = { min: number; max: number }\n\nconst RING = \"outline-none focus-visible:ring-2 focus-visible:ring-sk-accent focus-visible:ring-offset-2 focus-visible:ring-offset-sk-paper\"\nconst EASE = [0.16, 1, 0.3, 1] as const\n\nfunction addBusinessDays(from: Date, days: number) {\n  const d = new Date(from)\n  let left = days\n  while (left > 0) {\n    d.setDate(d.getDate() + 1)\n    const wd = d.getDay()\n    if (wd !== 0 && wd !== 6) left--\n  }\n  return d\n}\n\nfunction nextBusinessDay(from: Date) {\n  const d = new Date(from)\n  while (d.getDay() === 0 || d.getDay() === 6) d.setDate(d.getDate() + 1)\n  return d\n}\n\nconst fmt = (d: Date) => d.toLocaleDateString(\"en-US\", { weekday: \"short\", month: \"short\", day: \"numeric\" })\n\n/** Demo heuristic: distance between ZIP regions. Replace with a carrier call. */\nfunction defaultTransit(zip: string, originZip: string): Transit {\n  const hop = Math.abs(Number(zip[0] ?? 0) - Number(originZip[0] ?? 0))\n  const base = hop <= 1 ? 1 : hop <= 4 ? 2 : 3\n  return { min: base, max: base + 2 }\n}\n\nexport function PdpDeliveryEstimate({\n  defaultZip = \"\",\n  originZip = \"14201\",\n  processingDays = 0,\n  cutoffHour = 14,\n  pickup = { name: \"Buffalo, Elmwood Ave\", readyIn: \"Ready in 2 hours\" },\n  transitFor,\n  onChange,\n  className,\n}: {\n  defaultZip?: string\n  originZip?: string\n  /** Business days before an order leaves the warehouse. */\n  processingDays?: number\n  /** Local hour after which today's orders count from tomorrow. */\n  cutoffHour?: number\n  pickup?: { name: string; readyIn: string } | null\n  transitFor?: (zip: string) => Transit\n  onChange?: (sel: { method: Method; zip: string }) => void\n  className?: string\n}) {\n  const reduce = useReducedMotion()\n  const uid = useId()\n  const sk = useSkBlock(\"pdp\", \"pdp-delivery-estimate\")\n  const [zip, setZip] = useState(defaultZip)\n  const [draft, setDraft] = useState(defaultZip)\n  const [editing, setEditing] = useState(!defaultZip)\n  const [error, setError] = useState<string | null>(null)\n  const [method, setMethod] = useState<Method>(\"ship\")\n  const [now, setNow] = useState<Date | null>(null)\n  const inputRef = useRef<HTMLInputElement>(null)\n  const methodRefs = useRef<(HTMLButtonElement | null)[]>([])\n\n  useEffect(() => setNow(new Date()), [])\n\n  let arrival: string | null = null\n  let shipsLine: string | null = null\n  if (now) {\n    const pastCutoff = now.getHours() >= cutoffHour\n    const start = nextBusinessDay(pastCutoff || now.getDay() === 0 || now.getDay() === 6 ? addBusinessDays(now, 1) : now)\n    const dispatch = processingDays > 0 ? addBusinessDays(start, processingDays) : start\n    const w = zip ? (transitFor ?? ((z: string) => defaultTransit(z, originZip)))(zip) : { min: 2, max: 5 }\n    arrival = `${fmt(addBusinessDays(dispatch, w.min))} to ${fmt(addBusinessDays(dispatch, w.max))}`\n    const sameDay = dispatch.toDateString() === now.toDateString()\n    shipsLine = sameDay ? `Ships today if ordered by ${cutoffHour > 12 ? cutoffHour - 12 : cutoffHour}${cutoffHour >= 12 ? \"pm\" : \"am\"}` : `Ships ${fmt(dispatch)}`\n  }\n\n  const submit = (e: React.FormEvent) => {\n    e.preventDefault()\n    const z = draft.trim()\n    if (!/^\\d{5}$/.test(z)) {\n      setError(\"Enter a 5 digit ZIP code.\")\n      return\n    }\n    setError(null)\n    setZip(z)\n    setEditing(false)\n    sk.emit(\"interact\", \"zip\", undefined)\n    onChange?.({ method, zip: z })\n  }\n\n  const choose = (m: Method, focus = false) => {\n    if (m !== method) sk.emit(\"interact\", m, undefined)\n    setMethod(m)\n    onChange?.({ method: m, zip })\n    if (focus) methodRefs.current[m === \"ship\" ? 0 : 1]?.focus()\n  }\n\n  const opts: Method[] = pickup ? [\"ship\", \"pickup\"] : [\"ship\"]\n  const t = { duration: reduce ? 0 : 0.25, ease: EASE }\n\n  return (\n    <div {...sk.attrs} ref={sk.ref as React.RefObject<HTMLDivElement>} className={cn(\"flex w-full flex-col gap-3\", className)}>\n      <div className=\"flex min-h-[44px] flex-wrap items-center justify-between gap-x-3 gap-y-2\">\n        <span className=\"font-sk-mono text-[11px] uppercase tracking-[0.14em] text-sk-muted\">Delivery</span>\n        {editing ? (\n          <form onSubmit={submit} noValidate className=\"flex items-center gap-2\">\n            <label htmlFor={`${uid}-zip`} className=\"sr-only\">\n              ZIP code\n            </label>\n            <input\n              ref={inputRef}\n              id={`${uid}-zip`}\n              inputMode=\"numeric\"\n              autoComplete=\"postal-code\"\n              maxLength={5}\n              placeholder=\"ZIP code\"\n              value={draft}\n              onChange={(e) => setDraft(e.target.value.replace(/\\D/g, \"\"))}\n              aria-invalid={!!error || undefined}\n              aria-describedby={error ? `${uid}-err` : undefined}\n              className={cn(\n                \"h-11 w-28 rounded-sk-btn border bg-sk-card px-3 text-base tabular-nums text-sk-ink outline-none sm:text-sm transition-[border-color,box-shadow] duration-200 placeholder:text-sk-muted/70\",\n                \"focus:border-sk-accent focus:ring-[3px] focus:ring-sk-accent/15\",\n                error ? \"border-sk-sale\" : \"border-sk-line\"\n              )}\n            />\n            <button\n              type=\"submit\"\n              className={cn(\"h-11 rounded-sk-btn bg-sk-ink px-4 text-[13px] font-medium text-sk-paper transition-[opacity,transform] duration-200 hover:opacity-90 active:scale-[0.97]\", RING)}\n            >\n              Check\n            </button>\n          </form>\n        ) : (\n          <button\n            type=\"button\"\n            onClick={() => {\n              setEditing(true)\n              setDraft(zip)\n              requestAnimationFrame(() => inputRef.current?.focus())\n            }}\n            className={cn(\"inline-flex h-11 items-center gap-1.5 rounded-sk-btn px-2 text-sm text-sk-ink transition-[background-color,transform] duration-200 hover:bg-sk-ink/[0.05] active:scale-[0.97]\", RING)}\n          >\n            <MapPin size={14} aria-hidden className=\"text-sk-muted\" />\n            <span className=\"tabular-nums\">to {zip}</span>\n            <span className=\"text-sk-muted underline decoration-sk-line underline-offset-4\">Change</span>\n          </button>\n        )}\n      </div>\n      {error ? (\n        <span id={`${uid}-err`} className=\"-mt-1 text-right text-xs text-sk-sale\">\n          {error}\n        </span>\n      ) : null}\n\n      <div role=\"radiogroup\" aria-label=\"Delivery method\" className=\"flex flex-col divide-y divide-sk-line overflow-hidden rounded-sk-card border border-sk-line bg-sk-card\">\n        {opts.map((m, i) => {\n          const on = m === method\n          const Icon = m === \"ship\" ? Truck : Store\n          return (\n            <button\n              key={m}\n              ref={(el) => {\n                methodRefs.current[i] = el\n              }}\n              type=\"button\"\n              role=\"radio\"\n              aria-checked={on}\n              tabIndex={on ? 0 : -1}\n              onClick={() => choose(m)}\n              onKeyDown={(e) => {\n                if ([\"ArrowDown\", \"ArrowUp\", \"ArrowLeft\", \"ArrowRight\"].includes(e.key) && opts.length > 1) {\n                  e.preventDefault()\n                  choose(opts[(i + 1) % opts.length], true)\n                }\n              }}\n              className={cn(\"flex min-h-[64px] w-full items-center gap-3 px-4 py-3 text-left transition-colors duration-200\", on ? \"bg-sk-accent/[0.04]\" : \"hover:bg-sk-ink/[0.02]\", RING, \"focus-visible:ring-inset focus-visible:ring-offset-0\")}\n            >\n              <span className={cn(\"grid h-[18px] w-[18px] shrink-0 place-items-center rounded-full border transition-colors duration-200\", on ? \"border-sk-accent\" : \"border-sk-line\")}>\n                <motion.span initial={false} animate={{ scale: on ? 1 : 0 }} transition={t} className=\"h-2.5 w-2.5 rounded-full bg-sk-accent\" />\n              </span>\n              <Icon size={16} aria-hidden className=\"shrink-0 text-sk-muted\" />\n              <span className=\"flex min-w-0 flex-1 flex-col gap-0.5\">\n                <span className=\"text-sm font-medium text-sk-ink\">{m === \"ship\" ? \"Ship to me\" : `Pick up at ${pickup?.name}`}</span>\n                <span className=\"relative block min-h-5 text-[13px] leading-5 text-sk-muted\" aria-live=\"polite\">\n                  {m === \"ship\" ? (\n                    now ? (\n                      <AnimatePresence mode=\"popLayout\" initial={false}>\n                        <motion.span\n                          key={arrival}\n                          initial={{ opacity: 0, y: reduce ? 0 : 6 }}\n                          animate={{ opacity: 1, y: 0 }}\n                          exit={{ opacity: 0, y: reduce ? 0 : -6 }}\n                          transition={t}\n                          className=\"block tabular-nums\"\n                        >\n                          Arrives <span className=\"font-medium text-sk-ink\">{arrival}</span>\n                        </motion.span>\n                      </AnimatePresence>\n                    ) : (\n                      <span aria-hidden className=\"mt-1 block h-3 w-40 rounded-sk-chip bg-sk-ink/[0.06]\" />\n                    )\n                  ) : (\n                    <span className=\"block\">{pickup?.readyIn}</span>\n                  )}\n                </span>\n              </span>\n              <span className=\"shrink-0 text-[13px] font-medium text-sk-ink\">Free</span>\n            </button>\n          )\n        })}\n      </div>\n      <p className=\"min-h-[20px] text-[13px] text-sk-muted\">{shipsLine ?? \"\"}</p>\n    </div>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/storekit/pdp/pdp-delivery-estimate.tsx"
    }
  ],
  "docs": "Pass `transitFor(zip)` to use your carrier's real transit days. Dates compute after mount.",
  "meta": {
    "section": "pdp",
    "variant": "delivery",
    "niche": [
      "any"
    ],
    "commerce": [
      "shipping",
      "pickup"
    ],
    "previewHeight": 340
  }
}