{
  "name": "product-card-default",
  "type": "registry:block",
  "tier": "free",
  "dependencies": [
    "motion",
    "react-use-measure",
    "@hugeicons/react",
    "@hugeicons/core-free-icons"
  ],
  "registryDependencies": [
    "https://www.craftui.space/r/craftui-base.json"
  ],
  "files": [
    {
      "path": "components/craftui/blocks/product-card/default.tsx",
      "content": "\"use client\";\n\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { useState, type ComponentProps } from \"react\";\nimport useMeasure from \"react-use-measure\";\nimport { HugeiconsIcon } from \"@hugeicons/react\";\nimport { GridViewIcon, ShoppingBag01Icon } from \"@hugeicons/core-free-icons\";\n\ntype HugeIcon = ComponentProps<typeof HugeiconsIcon>[\"icon\"];\n\ntype Stage = 1 | 2;\n\nconst CARD_W = 320;\n\ninterface ImageFrame {\n  top: number;\n  left: number;\n  width: number;\n  height: number;\n  rotate: number;\n  radius: number;\n  zIndex: number;\n}\n\nconst frameConfig: Record<Stage, ImageFrame[]> = {\n  1: [\n    {\n      top: 70,\n      left: 46,\n      width: 180,\n      height: 240,\n      rotate: -8,\n      radius: 8,\n      zIndex: 1,\n    },\n    {\n      top: 56,\n      left: 66,\n      width: 190,\n      height: 250,\n      rotate: 0,\n      radius: 8,\n      zIndex: 2,\n    },\n    {\n      top: 70,\n      left: 94,\n      width: 180,\n      height: 240,\n      rotate: 8,\n      radius: 8,\n      zIndex: 3,\n    },\n  ],\n  2: [\n    {\n      top: 24,\n      left: 20,\n      width: 88,\n      height: 150,\n      rotate: 0,\n      radius: 0,\n      zIndex: 1,\n    },\n    {\n      top: 24,\n      left: 116,\n      width: 88,\n      height: 150,\n      rotate: 0,\n      radius: 0,\n      zIndex: 1,\n    },\n    {\n      top: 24,\n      left: 212,\n      width: 88,\n      height: 150,\n      rotate: 0,\n      radius: 0,\n      zIndex: 1,\n    },\n  ],\n};\n\nconst DEFAULT_IMAGES = [\n  \"https://images.unsplash.com/photo-1611892440504-42a792e24d32?w=600&auto=format&fit=crop&q=80\",\n  \"https://images.unsplash.com/photo-1618773928121-c32242e63f39?w=600&auto=format&fit=crop&q=80\",\n  \"https://images.unsplash.com/photo-1590490360182-c33d57733427?w=600&auto=format&fit=crop&q=80\",\n];\n\nexport interface ProductCardProps {\n  images?: [string, string, string];\n  imageAlt?: string;\n  title?: string;\n  description?: string;\n  price?: string;\n  tags?: string[];\n  viewLabel?: string;\n  actionLabel?: string;\n  viewIcon?: HugeIcon;\n  actionIcon?: HugeIcon;\n  onAction?: () => void;\n  className?: string;\n}\n\nexport default function ProductCard({\n  images = DEFAULT_IMAGES as [string, string, string],\n  imageAlt = \"Product photo\",\n  title = \"Aurora Bay Resort\",\n  description = \"A quiet stretch of coastline, three rooms curated for slow mornings and long dinners.\",\n  price = \"$189/night\",\n  tags = [\"Free Cancellation\", \"Ocean View\"],\n  viewLabel = \"View all rooms\",\n  actionLabel = \"Book Now\",\n  viewIcon = GridViewIcon,\n  actionIcon = ShoppingBag01Icon,\n  onAction,\n  className,\n}: ProductCardProps = {}) {\n  const [stage, setStage] = useState<Stage>(1);\n  const shouldReduceMotion = useReducedMotion();\n  const expanded = stage === 2;\n  const frames = frameConfig[stage];\n\n  // Hidden mirrors of each stage's real content, laid out in normal flow so\n  // the card's width/height are derived from what's actually inside it\n  // (title/description length, tag count) instead of fixed constants.\n  const [stage1Ref, stage1Bounds] = useMeasure();\n  const [stage2Ref, stage2Bounds] = useMeasure();\n\n  const width = (expanded ? stage2Bounds.width : stage1Bounds.width) || CARD_W;\n  const height = (expanded ? stage2Bounds.height : stage1Bounds.height) || 560;\n\n  // Stage 2's CTA is bottom-anchored (76px = 56px button + 20px margin) and\n  // full-width, so it tracks the measured height/width rather than a constant.\n  const cta =\n    stage === 1\n      ? { top: 268, left: 228, width: 60, height: 60, radius: 0 }\n      : {\n          top: height - 76,\n          left: 20,\n          width: width - 40,\n          height: 56,\n          radius: 0,\n        };\n\n  const toggle = () => {\n    if (stage === 2) onAction?.();\n    setStage((s) => (s === 1 ? 2 : 1));\n  };\n\n  const morphTransition = shouldReduceMotion\n    ? { duration: 0 }\n    : {\n        duration: expanded ? 0.22 : 0.18,\n        ease: [0.645, 0.045, 0.355, 1] as const,\n      };\n\n  const ctaTransition = shouldReduceMotion\n    ? { duration: 0 }\n    : {\n        duration: expanded ? 0.22 : 0.18,\n        ease: [0.645, 0.045, 0.355, 1] as const,\n      };\n\n  const detailTransition = shouldReduceMotion\n    ? { duration: 0 }\n    : { duration: expanded ? 0.1 : 0.12, ease: [0.165, 0.84, 0.44, 1] as const };\n\n  // Texts-reveal: staggered blurred rise on entrance, decoupled quiet fade on exit\n  // (no stagger, no y/blur) so dismissing doesn't replay the reveal in reverse.\n  const detailContainerVariants = {\n    hidden: {},\n    visible: {\n      transition: shouldReduceMotion\n        ? { duration: 0 }\n        : { staggerChildren: 0.03, delayChildren: 0.18 },\n    },\n    exit: {\n      transition: shouldReduceMotion ? { duration: 0 } : { staggerChildren: 0 },\n    },\n  };\n\n  const detailLineVariants = {\n    hidden: {\n      opacity: 0,\n      y: shouldReduceMotion ? 0 : 12,\n      filter: shouldReduceMotion ? \"blur(0px)\" : \"blur(3px)\",\n    },\n    visible: {\n      opacity: 1,\n      y: 0,\n      filter: \"blur(0px)\",\n      transition: shouldReduceMotion\n        ? { duration: 0 }\n        : { duration: 0.28, ease: [0.165, 0.84, 0.44, 1] as const },\n    },\n    exit: {\n      opacity: 0,\n      y: 0,\n      filter: \"blur(0px)\",\n      transition: shouldReduceMotion\n        ? { duration: 0 }\n        : { duration: 0.2, ease: \"easeOut\" as const },\n    },\n  };\n\n  // Icon-swap: instant cross-fade, no tween — the CTA's own morph carries the motion.\n  const iconSwapTransition = { duration: 0 };\n  const iconSwapHidden = { opacity: 0 };\n  const iconSwapVisible = { opacity: 1 };\n\n  return (\n    <div className={`relative select-none ${className ?? \"\"}`}>\n      <div\n        ref={stage1Ref}\n        aria-hidden\n        className=\"invisible absolute top-0 left-0 -z-10\"\n        style={{ width: CARD_W }}\n      >\n        <div style={{ paddingTop: 56, paddingBottom: 24 }}>\n          <div style={{ height: 250 }} />\n          <div className=\"mt-5 flex justify-center gap-1.5\">\n            <span className=\"h-1.5 w-4\" />\n            <span className=\"h-1.5 w-1.5\" />\n            <span className=\"h-1.5 w-1.5\" />\n          </div>\n        </div>\n      </div>\n\n      <div\n        ref={stage2Ref}\n        aria-hidden\n        className=\"invisible absolute top-0 left-0 -z-10 px-5\"\n        style={{ width: CARD_W }}\n      >\n        <div style={{ height: 24 + 150 }} />\n        <div style={{ marginTop: 16 }}>\n          <div className=\"flex items-start justify-between gap-3\">\n            <h3 className=\"text-lg font-semibold tracking-tight\">{title}</h3>\n            <span className=\"shrink-0 px-3 py-1 text-sm font-medium\">\n              {price}\n            </span>\n          </div>\n          <p className=\"mt-2 text-sm leading-relaxed\">{description}</p>\n          <div className=\"mt-3 flex flex-wrap gap-2\">\n            {tags.map((tag) => (\n              <span key={tag} className=\"px-3 py-1 text-xs font-medium\">\n                {tag}\n              </span>\n            ))}\n          </div>\n        </div>\n        <div style={{ marginTop: 24, height: 56 }} />\n        <div style={{ height: 20 }} />\n      </div>\n\n      <motion.div\n        animate={{ width, height }}\n        transition={{ width: morphTransition, height: morphTransition }}\n        className=\"relative overflow-hidden rounded-[8px] bg-white shadow-xl\"\n      >\n        {images.map((src, i) => {\n          const f = frames[i];\n          return (\n            <motion.div\n              key={src}\n              animate={{\n                top: f.top,\n                left: f.left,\n                width: f.width,\n                height: f.height,\n                rotate: f.rotate,\n                borderRadius: f.radius,\n              }}\n              style={{ zIndex: f.zIndex }}\n              transition={{\n                ...morphTransition,\n                delay: shouldReduceMotion\n                  ? 0\n                  : expanded\n                    ? i * 0.05\n                    : (2 - i) * 0.05,\n              }}\n              className=\"absolute overflow-hidden shadow-md\"\n            >\n              <img\n                src={src}\n                alt={`${imageAlt} ${i + 1}`}\n                className=\"h-full w-full object-cover\"\n              />\n            </motion.div>\n          );\n        })}\n\n        <motion.div\n          animate={{ opacity: expanded ? 0 : 1, y: expanded ? 8 : 0 }}\n          transition={detailTransition}\n          className=\"absolute left-1/2 top-[326px] flex -translate-x-1/2 items-center gap-1.5\"\n        >\n          <span className=\"h-1.5 w-4 rounded-[4px] bg-[oklch(0.215_0.029_243.425)]\" />\n          <span className=\"h-1.5 w-1.5 rounded-[4px] bg-[oklch(0.215_0.029_243.425)]/30\" />\n          <span className=\"h-1.5 w-1.5 rounded-[4px] bg-[oklch(0.215_0.029_243.425)]/30\" />\n        </motion.div>\n\n        <div className=\"absolute inset-x-0 top-[190px] px-5\">\n          <AnimatePresence>\n            {expanded && (\n              <motion.div\n                variants={detailContainerVariants}\n                initial=\"hidden\"\n                animate=\"visible\"\n                exit=\"exit\"\n              >\n                <motion.div\n                  variants={detailLineVariants}\n                  className=\"flex items-start justify-between gap-3\"\n                >\n                  <h3 className=\"min-w-0 text-lg font-semibold tracking-tight text-balance text-[oklch(0.215_0.029_243.425)]\">\n                    {title}\n                  </h3>\n                  <span className=\"shrink-0 rounded-[4px] bg-[oklch(0.956_0.004_236.498)] px-3 py-1 text-sm font-medium whitespace-nowrap text-[oklch(0.215_0.029_243.425)] tabular-nums\">\n                    {price}\n                  </span>\n                </motion.div>\n\n                <motion.p\n                  variants={detailLineVariants}\n                  className=\"mt-2 text-sm leading-relaxed text-pretty text-[oklch(0.502_0.021_250.776)]\"\n                >\n                  {description}\n                </motion.p>\n\n                <motion.div\n                  variants={detailLineVariants}\n                  className=\"mt-3 flex flex-wrap gap-2\"\n                >\n                  {tags.map((tag) => (\n                    <span\n                      key={tag}\n                      className=\"rounded-[4px] bg-[oklch(0.956_0.004_236.498)] px-3 py-1 text-xs font-medium whitespace-nowrap text-[oklch(0.215_0.029_243.425)]\"\n                    >\n                      {tag}\n                    </span>\n                  ))}\n                </motion.div>\n              </motion.div>\n            )}\n          </AnimatePresence>\n        </div>\n\n        <motion.button\n          type=\"button\"\n          onClick={toggle}\n          aria-label={expanded ? actionLabel : viewLabel}\n          animate={{\n            top: cta.top,\n            left: cta.left,\n            width: cta.width,\n            height: cta.height,\n            borderRadius: cta.radius,\n          }}\n          transition={ctaTransition}\n          whileTap={{ scale: 0.96 }}\n          style={{ zIndex: 10 }}\n          className=\"absolute flex items-center justify-center gap-2 bg-[oklch(0.215_0.029_243.425)] text-white shadow-lg\"\n        >\n          <AnimatePresence mode=\"wait\" initial={false}>\n            {expanded ? (\n              <motion.span\n                key=\"label\"\n                initial={iconSwapHidden}\n                animate={iconSwapVisible}\n                exit={iconSwapHidden}\n                transition={iconSwapTransition}\n                className=\"flex items-center gap-2 text-sm font-semibold whitespace-nowrap select-none\"\n              >\n                <HugeiconsIcon\n                  icon={actionIcon}\n                  size={16}\n                  strokeWidth={2}\n                  color=\"white\"\n                />\n                {actionLabel}\n              </motion.span>\n            ) : (\n              <motion.span\n                key=\"icon\"\n                initial={iconSwapHidden}\n                animate={iconSwapVisible}\n                exit={iconSwapHidden}\n                transition={iconSwapTransition}\n              >\n                <HugeiconsIcon\n                  icon={viewIcon}\n                  size={20}\n                  strokeWidth={1.7}\n                  color=\"white\"\n                />\n              </motion.span>\n            )}\n          </AnimatePresence>\n        </motion.button>\n      </motion.div>\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "~/components/craftui/blocks/product-card/default.tsx"
    }
  ],
  "meta": {
    "displayName": "Product Card - Product Card",
    "description": "Three photos morph from an overlapping stacked deck into an evenly spaced fan, while the CTA morphs from a small gallery icon into a full-width action button and the price/description/tags fade in.",
    "category": "card",
    "tags": [
      "card",
      "product",
      "gallery",
      "morph",
      "click",
      "animation"
    ],
    "animationType": "click"
  }
}