{"$schema":"https://ui.shadcn.com/schema/registry-item.json","author":"Eduardo Calvo <educlopez93@gmail.com>","css":{},"dependencies":["motion"],"description":"Animated product card component for ecommerce","devDependencies":[],"files":[{"content":"\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport SmoothButton from \"@/components/smoothui/smooth-button\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { useEffect, useId, useState } from \"react\";\n\nexport interface ProductCardProps {\n  badge?: string;\n  className?: string;\n  currency?: string;\n  image: string;\n  onAddToCart?: () => void;\n  onWishlist?: () => void;\n  originalPrice?: number;\n  price: number;\n  rating?: number;\n  title: string;\n}\n\n/* ─────────────────────────────────────────────────────────\n * ANIMATION STORYBOARD\n *\n *    0ms   card enters viewport → fade up + scale\n *  250ms   badge pops in with spring\n *  hover   image zooms 1.05, wishlist heart appears\n *  click   button scale bounce + icon morph to checkmark\n * ───────────────────────────────────────────────────────── */\n\nconst SPRING = {\n  bounce: 0.1,\n  duration: 0.25,\n  type: \"spring\" as const,\n};\n\nconst SPRING_BOUNCY = {\n  bounce: 0.2,\n  duration: 0.3,\n  type: \"spring\" as const,\n};\n\nconst STAR_PATH =\n  \"M11.48 3.499a.562.562 0 011.04 0l2.125 5.111a.563.563 0 00.475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 00-.182.557l1.285 5.385a.562.562 0 01-.84.61l-4.725-2.885a.562.562 0 00-.586 0L6.982 20.54a.562.562 0 01-.84-.61l1.285-5.386a.562.562 0 00-.182-.557l-4.204-3.602a.562.562 0 01.321-.988l5.518-.442a.563.563 0 00.475-.345L11.48 3.5z\";\n\nfunction StarIcon({ filled, half }: { filled: boolean; half?: boolean }) {\n  const id = useId();\n\n  if (half) {\n    const gradientId = `half-star-${id}`;\n    return (\n      <svg\n        aria-hidden=\"true\"\n        className=\"h-3.5 w-3.5 text-amber-400\"\n        viewBox=\"0 0 24 24\"\n      >\n        <defs>\n          <linearGradient id={gradientId}>\n            <stop offset=\"50%\" stopColor=\"currentColor\" />\n            <stop offset=\"50%\" stopColor=\"transparent\" />\n          </linearGradient>\n        </defs>\n        <path\n          d={STAR_PATH}\n          fill={`url(#${gradientId})`}\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth={1.5}\n        />\n      </svg>\n    );\n  }\n\n  return (\n    <svg\n      aria-hidden=\"true\"\n      className={cn(\n        \"h-3.5 w-3.5\",\n        filled ? \"text-amber-400\" : \"text-muted-foreground/30\"\n      )}\n      fill={filled ? \"currentColor\" : \"none\"}\n      stroke=\"currentColor\"\n      strokeWidth={filled ? 0 : 1.5}\n      viewBox=\"0 0 24 24\"\n    >\n      <path d={STAR_PATH} strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n    </svg>\n  );\n}\n\nfunction CheckIcon() {\n  return (\n    <svg\n      aria-hidden=\"true\"\n      className=\"h-4 w-4\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth={2.5}\n      viewBox=\"0 0 24 24\"\n    >\n      <path d=\"M5 13l4 4L19 7\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n    </svg>\n  );\n}\n\nfunction CartIcon() {\n  return (\n    <svg\n      aria-hidden=\"true\"\n      className=\"h-4 w-4\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth={2}\n      viewBox=\"0 0 24 24\"\n    >\n      <path\n        d=\"M15.75 10.5V6a3.75 3.75 0 10-7.5 0v4.5m11.356-1.993l1.263 12c.07.665-.45 1.243-1.119 1.243H4.25a1.125 1.125 0 01-1.12-1.243l1.264-12A1.125 1.125 0 015.513 7.5h12.974c.576 0 1.059.435 1.119 1.007zM8.625 10.5a.375.375 0 11-.75 0 .375.375 0 01.75 0zm7.5 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n      />\n    </svg>\n  );\n}\n\nfunction HeartIcon({ filled }: { filled: boolean }) {\n  return (\n    <svg\n      aria-hidden=\"true\"\n      className={cn(\"h-4 w-4\", filled ? \"text-red-500\" : \"text-foreground\")}\n      fill={filled ? \"currentColor\" : \"none\"}\n      stroke=\"currentColor\"\n      strokeWidth={filled ? 0 : 2}\n      viewBox=\"0 0 24 24\"\n    >\n      <path\n        d=\"M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12z\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n      />\n    </svg>\n  );\n}\n\nfunction RatingStars({ rating, title }: { rating: number; title: string }) {\n  const fullStars = Math.floor(rating);\n  const hasHalf = rating - fullStars >= 0.25 && rating - fullStars < 0.75;\n  const roundedUp = rating - fullStars >= 0.75;\n\n  return (\n    <div\n      aria-label={`${rating} out of 5 stars`}\n      className=\"flex items-center gap-0.5\"\n    >\n      {Array.from({ length: 5 }, (_, i) => {\n        const isFilled = i < fullStars || (roundedUp && i === fullStars);\n        const isHalf = hasHalf && i === fullStars;\n        return (\n          <StarIcon\n            filled={isFilled}\n            half={isHalf}\n            key={`star-${title}-${i}`}\n          />\n        );\n      })}\n      <span className=\"ml-1 font-medium text-muted-foreground text-xs\">\n        {rating}\n      </span>\n    </div>\n  );\n}\n\nexport default function ProductCard({\n  image,\n  title,\n  price,\n  originalPrice,\n  currency = \"$\",\n  rating,\n  badge,\n  onAddToCart,\n  onWishlist,\n  className,\n}: ProductCardProps) {\n  const shouldReduceMotion = useReducedMotion();\n  const [isHoverDevice, setIsHoverDevice] = useState(false);\n  const [isAdded, setIsAdded] = useState(false);\n  const [isWishlisted, setIsWishlisted] = useState(false);\n\n  useEffect(() => {\n    const mediaQuery = window.matchMedia(\"(hover: hover) and (pointer: fine)\");\n    setIsHoverDevice(mediaQuery.matches);\n    const handler = (e: MediaQueryListEvent) => setIsHoverDevice(e.matches);\n    mediaQuery.addEventListener(\"change\", handler);\n    return () => mediaQuery.removeEventListener(\"change\", handler);\n  }, []);\n\n  const handleAddToCart = () => {\n    setIsAdded(true);\n    onAddToCart?.();\n    setTimeout(() => setIsAdded(false), 2000);\n  };\n\n  const handleWishlist = () => {\n    setIsWishlisted((prev) => !prev);\n    onWishlist?.();\n  };\n\n  const hasDiscount = originalPrice !== undefined && originalPrice > price;\n  const discountPercent = hasDiscount\n    ? Math.round(((originalPrice - price) / originalPrice) * 100)\n    : 0;\n\n  return (\n    <motion.div\n      aria-label={`${title} - ${currency}${price}`}\n      className={cn(\n        \"group relative flex w-full flex-col overflow-hidden rounded-2xl border bg-card shadow-sm\",\n        \"transition-shadow duration-300\",\n        isHoverDevice && \"hover:shadow-black/5 hover:shadow-xl\",\n        className\n      )}\n      initial={\n        shouldReduceMotion\n          ? { opacity: 1 }\n          : { opacity: 0, transform: \"translateY(20px) scale(0.97)\" }\n      }\n      role=\"article\"\n      transition={shouldReduceMotion ? { duration: 0 } : SPRING}\n      viewport={{ margin: \"-50px\", once: true }}\n      whileInView={\n        shouldReduceMotion\n          ? { opacity: 1 }\n          : { opacity: 1, transform: \"translateY(0px) scale(1)\" }\n      }\n    >\n      {/* Image — full bleed */}\n      <div className=\"relative aspect-square overflow-hidden bg-muted\">\n        <img\n          alt={title}\n          className={cn(\n            \"h-full w-full object-cover\",\n            !shouldReduceMotion &&\n              \"transition-transform duration-500 ease-[cubic-bezier(0.23,1,0.32,1)]\",\n            isHoverDevice && !shouldReduceMotion && \"group-hover:scale-105\"\n          )}\n          draggable={false}\n          src={image}\n        />\n\n        {/* Hover overlay gradient */}\n        <div\n          className={cn(\n            \"pointer-events-none absolute inset-0 bg-gradient-to-t from-black/10 via-transparent to-transparent opacity-0 transition-opacity duration-300\",\n            isHoverDevice && \"group-hover:opacity-100\"\n          )}\n        />\n\n        {/* Badge */}\n        {badge ? (\n          <motion.span\n            animate={\n              shouldReduceMotion\n                ? { opacity: 1 }\n                : { opacity: 1, transform: \"scale(1)\" }\n            }\n            className={cn(\n              \"absolute top-3 left-3 rounded-full px-2.5 py-1 font-semibold text-xs shadow-sm\",\n              badge.toLowerCase() === \"sale\"\n                ? \"bg-red-500 text-white\"\n                : badge.toLowerCase() === \"new\"\n                  ? \"bg-emerald-500 text-white\"\n                  : \"bg-primary text-primary-foreground\"\n            )}\n            initial={\n              shouldReduceMotion\n                ? { opacity: 1 }\n                : { opacity: 0, transform: \"scale(0.6)\" }\n            }\n            role=\"status\"\n            transition={shouldReduceMotion ? { duration: 0 } : SPRING_BOUNCY}\n          >\n            {badge}\n          </motion.span>\n        ) : null}\n\n        {/* Wishlist button */}\n        <motion.button\n          aria-label={\n            isWishlisted\n              ? `Remove ${title} from wishlist`\n              : `Add ${title} to wishlist`\n          }\n          className={cn(\n            \"absolute top-3 right-3 flex h-8 w-8 cursor-pointer items-center justify-center rounded-full bg-background/80 backdrop-blur-sm\",\n            \"transition-colors duration-150\",\n            \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\",\n            isHoverDevice ? \"opacity-0 group-hover:opacity-100\" : \"opacity-100\"\n          )}\n          onClick={handleWishlist}\n          type=\"button\"\n          whileTap={\n            shouldReduceMotion\n              ? undefined\n              : { scale: 0.85, transition: { duration: 0.1 } }\n          }\n        >\n          <HeartIcon filled={isWishlisted} />\n        </motion.button>\n      </div>\n\n      {/* Content */}\n      <div className=\"flex flex-1 flex-col gap-2 p-4\">\n        <h3 className=\"line-clamp-1 font-semibold text-foreground text-sm tracking-tight\">\n          {title}\n        </h3>\n\n        {rating !== undefined && <RatingStars rating={rating} title={title} />}\n\n        <div className=\"flex items-baseline gap-2\">\n          <span className=\"font-bold text-foreground text-xl tracking-tight\">\n            {currency}\n            {price}\n          </span>\n          {hasDiscount ? (\n            <>\n              <span className=\"text-muted-foreground text-sm line-through\">\n                {currency}\n                {originalPrice}\n              </span>\n              <span className=\"rounded-md bg-red-50 px-1.5 py-0.5 font-semibold text-red-600 text-xs dark:bg-red-950/40 dark:text-red-400\">\n                -{discountPercent}%\n              </span>\n            </>\n          ) : null}\n        </div>\n\n        <div className=\"mt-auto pt-2\">\n          <SmoothButton\n            aria-label={`Add ${title} to cart`}\n            className={cn(\n              \"w-full gap-2\",\n              isAdded && \"bg-emerald-600 text-white hover:bg-emerald-600\"\n            )}\n            disabled={isAdded}\n            onClick={handleAddToCart}\n            size=\"default\"\n            variant=\"candy\"\n          >\n            <AnimatePresence initial={false} mode=\"wait\">\n              {isAdded ? (\n                <motion.span\n                  animate={{ opacity: 1, transform: \"scale(1)\" }}\n                  className=\"flex items-center gap-2\"\n                  exit={{ opacity: 0, transform: \"scale(0.8)\" }}\n                  initial={{ opacity: 0, transform: \"scale(0.8)\" }}\n                  key=\"added\"\n                  transition={\n                    shouldReduceMotion ? { duration: 0 } : { duration: 0.15 }\n                  }\n                >\n                  <CheckIcon /> Added\n                </motion.span>\n              ) : (\n                <motion.span\n                  animate={{ opacity: 1, transform: \"scale(1)\" }}\n                  className=\"flex items-center gap-2\"\n                  exit={{ opacity: 0, transform: \"scale(0.8)\" }}\n                  initial={{ opacity: 0, transform: \"scale(0.8)\" }}\n                  key=\"cart\"\n                  transition={\n                    shouldReduceMotion ? { duration: 0 } : { duration: 0.15 }\n                  }\n                >\n                  <CartIcon /> Add to Cart\n                </motion.span>\n              )}\n            </AnimatePresence>\n          </SmoothButton>\n        </div>\n      </div>\n    </motion.div>\n  );\n}\n","path":"index.tsx","target":"components/smoothui/product-card/index.tsx","type":"registry:ui"}],"name":"product-card","registryDependencies":["https://smoothui.dev/r/smooth-button.json"],"title":"Product Card","type":"registry:ui"}