{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "pdp-back-in-stock",
  "type": "registry:component",
  "title": "Back in stock capture",
  "description": "For a sold out variant the add to cart becomes an email or SMS capture tied to that variant, with a confirmation state.",
  "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/button.json",
    "https://storekit-eight.vercel.app/r/sk-analytics.json"
  ],
  "files": [
    {
      "path": "pdp/pdp-back-in-stock.tsx",
      "content": "\"use client\"\n\nimport { useEffect, useId, useRef, useState } from \"react\"\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\"\nimport { BellRing, Check } from \"lucide-react\"\nimport { cn, money } from \"@/components/storekit/lib/cn\"\nimport { Button } from \"@/components/storekit/ui/button\"\nimport { useSkBlock } from \"@/components/storekit/lib/sk-analytics\"\n\n/* Back-in-stock capture. When the chosen variant is sold out, the add to\n   cart is replaced, in place, by an email or SMS capture tied to that exact\n   variant. Sold out pills stay selectable so the shopper can ask for the one\n   they actually want. Submitting calls `onSubscribe` with the variant id,\n   channel and contact, then shows a confirmation that names the variant. */\n\ntype Variant = { id: string; label: string; price: number; soldOut?: boolean; restock?: string }\ntype Channel = \"email\" | \"sms\"\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\nconst DEFAULT_VARIANTS: Variant[] = [\n  { id: \"30\", label: \"30 servings\", price: 4900 },\n  { id: \"60\", label: \"60 servings\", price: 8900 },\n  { id: \"bundle\", label: \"3-pouch bundle\", price: 12900, soldOut: true, restock: \"Expected early November\" },\n]\n\nconst EMAIL = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]{2,}$/\n\nexport function PdpBackInStock({\n  variants = DEFAULT_VARIANTS,\n  defaultValue,\n  channels = [\"email\", \"sms\"],\n  onSubscribe,\n  onAddToCart,\n  className,\n}: {\n  variants?: Variant[]\n  defaultValue?: string\n  channels?: Channel[]\n  onSubscribe?: (req: { variantId: string; channel: Channel; contact: string }) => void\n  onAddToCart?: (variantId: string) => void\n  className?: string\n}) {\n  const reduce = useReducedMotion()\n  const uid = useId()\n  const sk = useSkBlock(\"pdp\", \"pdp-back-in-stock\")\n  const [value, setValue] = useState(defaultValue ?? variants.find((v) => v.soldOut)?.id ?? variants[0]?.id)\n  const [channel, setChannel] = useState<Channel>(channels[0] ?? \"email\")\n  const [contact, setContact] = useState(\"\")\n  const [error, setError] = useState<string | null>(null)\n  const [done, setDone] = useState<Record<string, Channel>>({})\n  const [status, setStatus] = useState<\"idle\" | \"loading\" | \"added\">(\"idle\")\n  const refs = useRef<(HTMLButtonElement | null)[]>([])\n  const timers = useRef<number[]>([])\n  useEffect(() => () => timers.current.forEach((t) => window.clearTimeout(t)), [])\n\n  const vi = Math.max(0, variants.findIndex((v) => v.id === value))\n  const v = variants[vi]\n  const t = { duration: reduce ? 0 : 0.3, ease: EASE }\n\n  const select = (i: number, focus = false) => {\n    const next = variants[i]\n    if (!next) return\n    if (next.id !== value) sk.emit(\"interact\", \"variant\", undefined)\n    setValue(next.id)\n    setError(null)\n    if (focus) refs.current[i]?.focus()\n  }\n\n  const onKeyDown = (e: React.KeyboardEvent) => {\n    const n = variants.length\n    let next = -1\n    if (e.key === \"ArrowRight\" || e.key === \"ArrowDown\") next = (vi + 1) % n\n    else if (e.key === \"ArrowLeft\" || e.key === \"ArrowUp\") next = (vi - 1 + n) % n\n    if (next < 0) return\n    e.preventDefault()\n    select(next, true)\n  }\n\n  const submit = (e: React.FormEvent) => {\n    e.preventDefault()\n    const c = contact.trim()\n    const ok = channel === \"email\" ? EMAIL.test(c) : c.replace(/\\D/g, \"\").length >= 10\n    if (!ok) return setError(channel === \"email\" ? \"Enter a valid email address.\" : \"Enter a 10 digit mobile number.\")\n    setError(null)\n    sk.emit(\"convert\", \"back_in_stock\", undefined)\n    onSubscribe?.({ variantId: v.id, channel, contact: c })\n    setDone((d) => ({ ...d, [v.id]: channel }))\n    setContact(\"\")\n  }\n\n  const add = () => {\n    if (status !== \"idle\") return\n    setStatus(\"loading\")\n    sk.emit(\"convert\", \"add_to_cart\", v.price)\n    timers.current.push(\n      window.setTimeout(() => {\n        setStatus(\"added\")\n        onAddToCart?.(v.id)\n        timers.current.push(window.setTimeout(() => setStatus(\"idle\"), 1200))\n      }, 700)\n    )\n  }\n\n  const mode = !v.soldOut ? \"atc\" : done[v.id] ? \"done\" : \"form\"\n\n  return (\n    <div {...sk.attrs} ref={sk.ref as React.RefObject<HTMLDivElement>} className={cn(\"flex w-full flex-col gap-4\", className)}>\n      <fieldset className=\"flex flex-col gap-2.5\">\n        <legend className=\"mb-2.5 flex w-full items-center justify-between text-sm\">\n          <span className=\"font-medium text-sk-ink\">Size</span>\n          <span className=\"text-sk-muted\">{v.soldOut ? `${v.label}, sold out` : v.label}</span>\n        </legend>\n        <div role=\"radiogroup\" aria-label=\"Size\" className=\"flex flex-wrap gap-2\">\n          {variants.map((x, i) => {\n            const sel = x.id === value\n            return (\n              <button\n                key={x.id}\n                ref={(el) => {\n                  refs.current[i] = el\n                }}\n                type=\"button\"\n                role=\"radio\"\n                aria-checked={sel}\n                aria-label={x.soldOut ? `${x.label}, sold out` : x.label}\n                tabIndex={sel ? 0 : -1}\n                onClick={() => select(i)}\n                onKeyDown={onKeyDown}\n                className={cn(\n                  \"h-11 min-w-[72px] rounded-sk-btn border px-4 text-sm font-medium tabular-nums transition-[border-color,background-color,color,transform] duration-200 ease-sk-out active:scale-[0.97]\",\n                  sel ? \"border-sk-accent bg-sk-accent/[0.08] text-sk-ink\" : \"border-sk-line text-sk-ink hover:border-sk-ink/40\",\n                  x.soldOut && \"border-dashed text-sk-muted line-through decoration-sk-muted/70\",\n                  RING\n                )}\n              >\n                {x.label}\n              </button>\n            )\n          })}\n        </div>\n      </fieldset>\n\n      <div className=\"relative\" aria-live=\"polite\">\n        <AnimatePresence mode=\"wait\" initial={false}>\n          {mode === \"atc\" ? (\n            <motion.div key=\"atc\" initial={{ opacity: 0, y: reduce ? 0 : 8 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: reduce ? 0 : -8 }} transition={t}>\n              <Button size=\"lg\" className=\"h-12 w-full active:scale-[0.97]\" loading={status === \"loading\"} onClick={add}>\n                {status === \"added\" ? (\n                  <>\n                    <Check size={16} strokeWidth={2.5} /> Added to cart\n                  </>\n                ) : (\n                  <span className=\"tabular-nums\">Add to cart · {money(v.price)}</span>\n                )}\n              </Button>\n            </motion.div>\n          ) : mode === \"done\" ? (\n            <motion.div\n              key={`done-${v.id}`}\n              initial={{ opacity: 0, y: reduce ? 0 : 8 }}\n              animate={{ opacity: 1, y: 0 }}\n              exit={{ opacity: 0, y: reduce ? 0 : -8 }}\n              transition={t}\n              className=\"flex items-start gap-3 rounded-sk-card border border-sk-line bg-sk-card p-4\"\n            >\n              <span className=\"grid h-8 w-8 shrink-0 place-items-center rounded-full bg-sk-savings/[0.12] text-sk-savings\">\n                <Check size={16} strokeWidth={2.5} />\n              </span>\n              <span className=\"flex flex-col gap-0.5 text-sm\">\n                <span className=\"font-medium text-sk-ink\">You are on the list for {v.label}</span>\n                <span className=\"text-sk-muted\">\n                  One {done[v.id] === \"sms\" ? \"text\" : \"email\"} when it is back. Nothing else.\n                </span>\n              </span>\n            </motion.div>\n          ) : (\n            <motion.form\n              key={`form-${v.id}`}\n              onSubmit={submit}\n              noValidate\n              initial={{ opacity: 0, y: reduce ? 0 : 8 }}\n              animate={{ opacity: 1, y: 0 }}\n              exit={{ opacity: 0, y: reduce ? 0 : -8 }}\n              transition={t}\n              className=\"flex flex-col gap-3 rounded-sk-card border border-sk-line bg-sk-card p-4 sm:p-5\"\n            >\n              <div className=\"flex items-start gap-3\">\n                <BellRing size={18} aria-hidden className=\"mt-0.5 shrink-0 text-sk-ink\" />\n                <div className=\"flex min-w-0 flex-col gap-0.5\">\n                  <span className=\"text-sm font-medium text-sk-ink\">{v.label} is sold out</span>\n                  <span className=\"text-[13px] text-sk-muted\">{v.restock ? `${v.restock}. ` : \"\"}Get one message when it is back.</span>\n                </div>\n              </div>\n              {channels.length > 1 ? (\n                <div role=\"radiogroup\" aria-label=\"Notify by\" className=\"grid grid-cols-2 gap-1 rounded-sk-btn bg-sk-surface p-1\">\n                  {channels.map((c) => {\n                    const on = c === channel\n                    return (\n                      <button\n                        key={c}\n                        type=\"button\"\n                        role=\"radio\"\n                        aria-checked={on}\n                        onClick={() => {\n                          setChannel(c)\n                          setError(null)\n                          setContact(\"\")\n                        }}\n                        className={cn(\n                          \"relative h-10 rounded-[calc(var(--sk-radius-btn)*0.8)] text-[13px] font-medium transition-colors duration-200\",\n                          on ? \"text-sk-ink\" : \"text-sk-muted hover:text-sk-ink\",\n                          RING\n                        )}\n                      >\n                        {on ? (\n                          <motion.span layoutId={`${uid}-ch`} transition={t} className=\"absolute inset-0 rounded-[calc(var(--sk-radius-btn)*0.8)] border border-sk-line bg-sk-card\" />\n                        ) : null}\n                        <span className=\"relative\">{c === \"email\" ? \"Email\" : \"Text message\"}</span>\n                      </button>\n                    )\n                  })}\n                </div>\n              ) : null}\n              <div className=\"flex flex-col gap-2 sm:flex-row\">\n                <label htmlFor={`${uid}-contact`} className=\"sr-only\">\n                  {channel === \"email\" ? \"Email address\" : \"Mobile number\"}\n                </label>\n                <input\n                  id={`${uid}-contact`}\n                  type={channel === \"email\" ? \"email\" : \"tel\"}\n                  inputMode={channel === \"email\" ? \"email\" : \"tel\"}\n                  autoComplete={channel === \"email\" ? \"email\" : \"tel\"}\n                  placeholder={channel === \"email\" ? \"you@example.com\" : \"(555) 123 4567\"}\n                  value={contact}\n                  onChange={(e) => setContact(e.target.value)}\n                  aria-invalid={!!error || undefined}\n                  aria-describedby={error ? `${uid}-err` : undefined}\n                  className={cn(\n                    \"h-11 w-full min-w-0 rounded-sk-btn border bg-sk-paper sm:flex-1 px-3.5 text-base 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 type=\"submit\" className=\"h-11 w-full shrink-0 active:scale-[0.97] sm:w-auto\">\n                  Notify me\n                </Button>\n              </div>\n              {error ? (\n                <span id={`${uid}-err`} className=\"text-xs text-sk-sale\">\n                  {error}\n                </span>\n              ) : (\n                <span className=\"text-xs text-sk-muted\">Only about {v.label}. No marketing, no list.</span>\n              )}\n            </motion.form>\n          )}\n        </AnimatePresence>\n      </div>\n    </div>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/storekit/pdp/pdp-back-in-stock.tsx"
    }
  ],
  "docs": "Wire `onSubscribe` to your Klaviyo or Shopify back in stock list with the variant id.",
  "meta": {
    "section": "pdp",
    "variant": "restock",
    "niche": [
      "any"
    ],
    "commerce": [
      "variants",
      "atc",
      "capture"
    ],
    "previewHeight": 420
  }
}