{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "hero-hotspots",
  "type": "registry:block",
  "title": "Shoppable hotspots hero",
  "description": "Lifestyle or flat-lay scene with a dot on each product; dots open a card with price and add to cart, and drive a product rail on mobile.",
  "author": "StoreKit <hello@storekit.dev>",
  "categories": [
    "hero",
    "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": "hero/hero-hotspots.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useId, useRef, useState } from \"react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { Check, Plus, X } from \"lucide-react\";\nimport { cn, money } from \"@/components/storekit/lib/cn\";\nimport { useSkBlock } from \"@/components/storekit/lib/sk-analytics\";\n\n/* Shoppable hotspots hero. A lifestyle or flat-lay scene with a dot on every\n   product in it. Each dot is a real button: it opens a small card with the\n   product image, price, add to cart and a link to the product page. Escape\n   or a click outside closes it. Cards flip to the other side of the dot\n   near the frame edges. Under md the cards would cover too much of the\n   scene, so the dots select a product in a rail of chips under the photo\n   instead, and the rail carries add to cart. */\n\nconst EASE = [0.23, 1, 0.32, 1] as const;\n\nexport type Hotspot = {\n  x: number;\n  y: number;\n  product: {\n    id: string;\n    title: string;\n    price: number;\n    compareAt?: number;\n    image?: string;\n    href: string;\n  };\n};\n\nconst SAMPLE: Hotspot[] = [\n  {\n    x: 28,\n    y: 32,\n    product: { id: \"a\", title: \"Daily Greens\", price: 4900, href: \"#\" },\n  },\n  {\n    x: 64,\n    y: 24,\n    product: { id: \"b\", title: \"Electrolytes\", price: 3200, href: \"#\" },\n  },\n  {\n    x: 52,\n    y: 58,\n    product: { id: \"c\", title: \"Magnesium Night\", price: 2900, href: \"#\" },\n  },\n];\n\nexport function HeroHotspots({\n  image = \"/placeholder.webp\",\n  imageAlt = \"\",\n  aspect = \"1 / 1\",\n  eyebrow = \"Shop the shelf\",\n  headline = \"Everything in this photo, one tap away.\",\n  subcopy = \"Tap a dot to see what it is, what it costs and add it straight to your cart.\",\n  hotspots = SAMPLE,\n  onAdd,\n  className,\n}: {\n  image?: string;\n  imageAlt?: string;\n  /** Frame aspect ratio. Match the photo so the dots land on the products. */\n  aspect?: string;\n  eyebrow?: string;\n  headline?: string;\n  subcopy?: string;\n  /** Dot positions in percent of the frame. */\n  hotspots?: Hotspot[];\n  onAdd?: (productId: string) => void;\n  className?: string;\n}) {\n  const reduce = useReducedMotion();\n  const sk = useSkBlock(\"hero\", \"hero-hotspots\");\n  const [open, setOpen] = useState<number | null>(null);\n  const [added, setAdded] = useState<Record<string, boolean>>({});\n  const frame = useRef<HTMLDivElement>(null);\n  const rail = useRef<HTMLUListElement>(null);\n  const uid = useId();\n\n  // Under md the card is hidden, so bring the matching chip into view.\n  useEffect(() => {\n    if (open === null || !rail.current || rail.current.offsetParent === null)\n      return;\n    const el = rail.current.querySelector<HTMLElement>(`[data-i=\"${open}\"]`);\n    el?.scrollIntoView({\n      behavior: reduce ? \"auto\" : \"smooth\",\n      block: \"nearest\",\n      inline: \"center\",\n    });\n  }, [open, reduce]);\n\n  useEffect(() => {\n    if (open === null) return;\n    const onKey = (e: KeyboardEvent) => e.key === \"Escape\" && setOpen(null);\n    const onDown = (e: PointerEvent) => {\n      const t = e.target as Node;\n      if (!frame.current?.contains(t) && !rail.current?.contains(t))\n        setOpen(null);\n    };\n    window.addEventListener(\"keydown\", onKey);\n    window.addEventListener(\"pointerdown\", onDown);\n    return () => {\n      window.removeEventListener(\"keydown\", onKey);\n      window.removeEventListener(\"pointerdown\", onDown);\n    };\n  }, [open]);\n\n  const add = (id: string, i: number) => {\n    onAdd?.(id);\n    setAdded((a) => ({ ...a, [id]: true }));\n    sk.emit(\"convert\", \"add_to_cart\", i);\n  };\n\n  const toggle = (i: number) => {\n    setOpen((o) => (o === i ? null : i));\n    sk.emit(\"interact\", \"hotspot\", i);\n  };\n\n  const addButton = (h: Hotspot, i: number, full?: boolean) => {\n    const done = added[h.product.id];\n    return (\n      <button\n        type=\"button\"\n        onClick={() => add(h.product.id, i)}\n        className={cn(\n          \"sk-btn inline-flex h-10 items-center justify-center gap-1.5 rounded-sk-btn bg-sk-accent px-4 text-[13px] font-medium text-sk-accent-ink outline-none transition-[filter,transform] duration-200 ease-sk-out hover:brightness-[1.06] focus-visible:ring-2 focus-visible:ring-sk-accent focus-visible:ring-offset-2 focus-visible:ring-offset-sk-card active:scale-[0.97]\",\n          full && \"w-full\",\n        )}\n      >\n        {done ? (\n          <Check size={14} aria-hidden />\n        ) : (\n          <Plus size={14} aria-hidden />\n        )}\n        {done ? \"Added\" : \"Add to cart\"}\n      </button>\n    );\n  };\n\n  return (\n    <section\n      ref={sk.ref}\n      {...sk.attrs}\n      className={cn(\"bg-sk-paper text-sk-ink\", className)}\n    >\n      <div className=\"mx-auto grid w-full max-w-6xl grid-cols-1 gap-8 px-4 py-10 sm:px-8 sm:py-14 lg:grid-cols-[0.75fr_1.25fr] lg:items-center lg:gap-12\">\n        <div className=\"flex flex-col gap-4\">\n          <div>\n            <p className=\"font-sk-mono text-[11px] uppercase tracking-[0.14em] text-sk-muted\">\n              {eyebrow}\n            </p>\n            <h1\n              className=\"mt-3 max-w-xl text-balance font-sk-display font-semibold leading-[1.08] tracking-[-0.02em]\"\n              style={{ fontSize: \"clamp(2rem, 3.8vw, 3rem)\" }}\n            >\n              {headline}\n            </h1>\n          </div>\n          <p className=\"max-w-sm text-[15px] leading-[1.55] text-sk-muted\">\n            {subcopy}\n          </p>\n          <p className=\"hidden items-center gap-2 font-sk-mono text-[11px] uppercase tracking-[0.14em] text-sk-muted md:flex\">\n            <span className=\"grid h-5 w-5 place-items-center rounded-full bg-sk-ink text-sk-paper\">\n              <Plus size={11} strokeWidth={2.5} aria-hidden />\n            </span>\n            {hotspots.length} products in this photo\n          </p>\n        </div>\n\n        <div className=\"mx-auto w-full max-w-[640px] lg:max-w-none\">\n          <div\n            ref={frame}\n            className=\"relative overflow-visible\"\n            style={{ aspectRatio: aspect }}\n          >\n            <div className=\"absolute inset-0 overflow-hidden rounded-sk-card border border-sk-line bg-sk-surface\">\n              <img\n                src={image}\n                alt={imageAlt}\n                className=\"h-full w-full object-cover\"\n              />\n            </div>\n            {hotspots.map((h, i) => {\n              const isOpen = open === i;\n              const right = h.x > 62;\n              const below = h.y < 40;\n              return (\n                <div\n                  key={h.product.id}\n                  className={cn(\"absolute\", isOpen ? \"z-20\" : \"z-10\")}\n                  style={{ left: `${h.x}%`, top: `${h.y}%` }}\n                >\n                  <button\n                    type=\"button\"\n                    onClick={() => toggle(i)}\n                    aria-expanded={isOpen}\n                    aria-label={`${h.product.title}, ${money(h.product.price)}`}\n                    className=\"group relative -ml-[22px] -mt-[22px] grid h-11 w-11 place-items-center rounded-full outline-none focus-visible:ring-2 focus-visible:ring-sk-accent\"\n                  >\n                    {!isOpen ? (\n                      <span\n                        aria-hidden\n                        className=\"absolute h-7 w-7 animate-ping rounded-full bg-sk-paper/60 [animation-duration:2.4s] motion-reduce:hidden\"\n                      />\n                    ) : null}\n                    <motion.span\n                      aria-hidden\n                      animate={{\n                        scale: isOpen ? 1.12 : 1,\n                        rotate: isOpen ? 45 : 0,\n                      }}\n                      transition={{\n                        type: \"spring\",\n                        stiffness: 500,\n                        damping: 28,\n                      }}\n                      className={cn(\n                        \"relative grid h-7 w-7 place-items-center rounded-full border-2 border-sk-paper shadow-[0_6px_18px_-6px_rgb(0_0_0/0.55)] transition-colors duration-200\",\n                        isOpen\n                          ? \"bg-sk-accent text-sk-accent-ink\"\n                          : \"bg-sk-ink text-sk-paper group-hover:bg-sk-accent group-hover:text-sk-accent-ink\",\n                      )}\n                    >\n                      <Plus size={14} strokeWidth={2.5} />\n                    </motion.span>\n                  </button>\n\n                  <AnimatePresence>\n                    {isOpen ? (\n                      <motion.div\n                        role=\"dialog\"\n                        aria-label={h.product.title}\n                        initial={{\n                          opacity: 0,\n                          y: reduce ? 0 : below ? -6 : 6,\n                          scale: reduce ? 1 : 0.96,\n                        }}\n                        animate={{ opacity: 1, y: 0, scale: 1 }}\n                        exit={{ opacity: 0, scale: reduce ? 1 : 0.97 }}\n                        transition={{\n                          type: \"spring\",\n                          stiffness: 420,\n                          damping: 32,\n                        }}\n                        style={{\n                          transformOrigin: `${right ? \"right\" : \"left\"} ${below ? \"top\" : \"bottom\"}`,\n                        }}\n                        className={cn(\n                          \"absolute hidden w-64 rounded-sk-card border border-sk-line bg-sk-card p-3 text-sk-ink shadow-[0_28px_60px_-28px_rgb(0_0_0/0.55)] md:block\",\n                          right ? \"right-3\" : \"left-3\",\n                          below ? \"top-6\" : \"bottom-6\",\n                        )}\n                      >\n                        <div className=\"flex gap-3\">\n                          {h.product.image ? (\n                            <img\n                              src={h.product.image}\n                              alt=\"\"\n                              className=\"h-16 w-16 shrink-0 rounded-sk-chip bg-sk-surface object-cover\"\n                            />\n                          ) : null}\n                          <div className=\"min-w-0 flex-1\">\n                            <a\n                              href={h.product.href}\n                              onClick={() =>\n                                sk.emit(\"interact\", \"hotspot_product\", i)\n                              }\n                              className=\"block truncate text-sm font-medium outline-none hover:underline focus-visible:underline\"\n                            >\n                              {h.product.title}\n                            </a>\n                            <p className=\"mt-1 flex items-baseline gap-2 text-sm tabular-nums\">\n                              <span\n                                className={\n                                  h.product.compareAt\n                                    ? \"text-sk-sale\"\n                                    : \"text-sk-price\"\n                                }\n                              >\n                                {money(h.product.price)}\n                              </span>\n                              {h.product.compareAt ? (\n                                <s className=\"text-[12px] text-sk-muted\">\n                                  {money(h.product.compareAt)}\n                                </s>\n                              ) : null}\n                            </p>\n                          </div>\n                          <button\n                            type=\"button\"\n                            onClick={() => setOpen(null)}\n                            aria-label=\"Close\"\n                            className=\"-mr-1 -mt-1 grid h-8 w-8 shrink-0 place-items-center rounded-full text-sk-muted outline-none hover:bg-sk-ink/[0.05] focus-visible:ring-2 focus-visible:ring-sk-accent\"\n                          >\n                            <X size={14} aria-hidden />\n                          </button>\n                        </div>\n                        <div className=\"mt-3\">{addButton(h, i, true)}</div>\n                      </motion.div>\n                    ) : null}\n                  </AnimatePresence>\n                </div>\n              );\n            })}\n          </div>\n\n          <ul\n            ref={rail}\n            id={`${uid}-rail`}\n            className=\"mt-4 flex snap-x gap-2 overflow-x-auto pb-1 md:hidden\"\n            aria-label=\"Products in this photo\"\n          >\n            {hotspots.map((h, i) => {\n              const active = open === i;\n              return (\n                <li key={h.product.id} data-i={i} className=\"snap-start\">\n                  <div\n                    className={cn(\n                      \"flex w-[260px] items-center gap-3 rounded-sk-card border bg-sk-card p-2.5 transition-colors duration-200\",\n                      active ? \"border-sk-accent\" : \"border-sk-line\",\n                    )}\n                  >\n                    {h.product.image ? (\n                      <img\n                        src={h.product.image}\n                        alt=\"\"\n                        className=\"h-12 w-12 shrink-0 rounded-sk-chip bg-sk-surface object-cover\"\n                      />\n                    ) : null}\n                    <div className=\"min-w-0 flex-1\">\n                      <a\n                        href={h.product.href}\n                        className=\"block truncate text-[13px] font-medium outline-none focus-visible:underline\"\n                      >\n                        {h.product.title}\n                      </a>\n                      <p className=\"text-[13px] tabular-nums text-sk-muted\">\n                        {money(h.product.price)}\n                      </p>\n                    </div>\n                    <button\n                      type=\"button\"\n                      onClick={() => add(h.product.id, i)}\n                      aria-label={\n                        added[h.product.id]\n                          ? `${h.product.title} added`\n                          : `Add ${h.product.title} to cart`\n                      }\n                      className=\"sk-btn grid h-10 w-10 shrink-0 place-items-center rounded-full bg-sk-accent text-sk-accent-ink outline-none transition-transform duration-200 ease-sk-out focus-visible:ring-2 focus-visible:ring-sk-accent focus-visible:ring-offset-2 active:scale-[0.94]\"\n                    >\n                      {added[h.product.id] ? (\n                        <Check size={15} aria-hidden />\n                      ) : (\n                        <Plus size={15} aria-hidden />\n                      )}\n                    </button>\n                  </div>\n                </li>\n              );\n            })}\n          </ul>\n        </div>\n      </div>\n    </section>\n  );\n}\n",
      "type": "registry:block",
      "target": "components/storekit/hero/hero-hotspots.tsx"
    }
  ],
  "docs": "Hotspot `x` and `y` are percentages of the frame. Wire `onAdd` to your cart.",
  "meta": {
    "section": "hero",
    "variant": "hotspots",
    "niche": [
      "any"
    ],
    "commerce": [
      "atc"
    ],
    "previewHeight": 760
  }
}