{"$schema":"https://ui.shadcn.com/schema/registry-item.json","author":"Eduardo Calvo <educlopez93@gmail.com>","css":{},"dependencies":["motion","lucide-react"],"description":"A ReviewsCarousel component for SmoothUI.","devDependencies":[],"files":[{"content":"\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport { ChevronLeft, ChevronRight } from \"lucide-react\";\nimport { motion, useReducedMotion } from \"motion/react\";\nimport { useEffect, useMemo, useState } from \"react\";\n\nconst FRAME_OFFSET = -30;\nconst FRAMES_VISIBLE_LENGTH = 3;\n\nfunction clamp(val: number, [min, max]: [number, number]): number {\n  return Math.min(Math.max(val, min), max);\n}\n\nexport interface Review {\n  author: string;\n  body: string;\n  id: string | number;\n  title: string;\n}\n\ninterface ReviewCardProps {\n  activeIndex: number;\n  index: number;\n  review: Review;\n  totalCards: number;\n}\n\nfunction ReviewCard({\n  review,\n  index,\n  activeIndex,\n  totalCards,\n}: ReviewCardProps) {\n  const shouldReduceMotion = useReducedMotion();\n  const offsetIndex = index - activeIndex;\n\n  // Same logic as time-machine\n  const blur = activeIndex > index ? 2 : 0;\n  const opacity = activeIndex > index ? 0 : 1;\n  const scale = shouldReduceMotion\n    ? 1\n    : clamp(1 - offsetIndex * 0.08, [0.08, 2]);\n  const y = shouldReduceMotion\n    ? 0\n    : clamp(offsetIndex * FRAME_OFFSET, [\n        FRAME_OFFSET * FRAMES_VISIBLE_LENGTH,\n        Number.POSITIVE_INFINITY,\n      ]);\n\n  const isActive = index === activeIndex;\n\n  return (\n    <motion.figure\n      animate={{\n        scale,\n        transition: {\n          damping: 20,\n          duration: 0.25,\n          mass: 0.5,\n          stiffness: 250,\n          type: \"spring\" as const,\n        },\n        y,\n      }}\n      className={cn(\n        \"absolute left-1/2 w-[calc(100%-2rem)] max-w-[600px] -translate-x-1/2 -translate-y-1/2 rounded-2xl border border-foreground/10 bg-background/80 p-4 shadow-lg backdrop-blur-md sm:p-6\"\n      )}\n      initial={false}\n      style={{\n        borderWidth: 1 / scale,\n        filter: `blur(${blur}px)`,\n        opacity,\n        pointerEvents: isActive ? \"auto\" : \"none\",\n        top: \"50%\", // Centrar verticalmente\n        transitionDuration: shouldReduceMotion ? \"0ms\" : \"250ms\",\n        transitionProperty: \"opacity, filter\",\n        transitionTimingFunction: \"cubic-bezier(0.4, 0, 0.2, 1)\",\n        willChange: \"opacity, filter, transform\",\n        zIndex: totalCards - index,\n      }}\n    >\n      <blockquote className=\"relative\">\n        <div className=\"absolute -top-1 -left-2 text-4xl text-foreground/10 leading-none dark:text-foreground/5\">\n          \"\n        </div>\n        <p className=\"relative text-foreground/80 text-sm leading-relaxed\">\n          {review.body}\n        </p>\n      </blockquote>\n      <figcaption className=\"mt-4 flex items-center gap-2 border-foreground/5 border-t pt-4\">\n        <div className=\"flex flex-col\">\n          <span className=\"font-semibold text-foreground text-xs\">\n            {review.author}\n          </span>\n          <span className=\"text-foreground/50 text-xs\">{review.title}</span>\n        </div>\n      </figcaption>\n    </motion.figure>\n  );\n}\n\ninterface NavigationButtonProps {\n  direction: \"prev\" | \"next\";\n  disabled: boolean;\n  onClick: () => void;\n}\n\nfunction NavigationButton({\n  direction,\n  onClick,\n  disabled,\n}: NavigationButtonProps) {\n  const Icon = direction === \"prev\" ? ChevronLeft : ChevronRight;\n\n  return (\n    <button\n      aria-label={direction === \"prev\" ? \"Anterior\" : \"Siguiente\"}\n      className={cn(\n        \"box-gen group relative z-0 flex h-7 w-7 items-center justify-center rounded-full border-[0.5px] border-foreground/10 bg-background/50 backdrop-blur-sm transition-all duration-200\",\n        disabled\n          ? \"cursor-not-allowed opacity-30\"\n          : \"cursor-pointer hover:border-foreground/20 hover:bg-background/70 hover:shadow-lg\",\n        \"dark:border-foreground/5 dark:bg-foreground/5 dark:hover:border-foreground/10 dark:hover:bg-foreground/10\"\n      )}\n      disabled={disabled}\n      onClick={onClick}\n      type=\"button\"\n    >\n      <Icon\n        className={cn(\n          \"h-3.5 w-3.5 text-foreground/60 transition-colors\",\n          \"group-hover:text-foreground group-disabled:text-foreground/20\"\n        )}\n      />\n    </button>\n  );\n}\n\nexport interface ReviewsCarouselProps {\n  autoPlay?: boolean;\n  autoPlayInterval?: number;\n  className?: string;\n  excludeIds?: (string | number)[];\n  height?: string;\n  reviews: Review[];\n  showIndicators?: boolean;\n  showNavigation?: boolean;\n}\n\nexport default function ReviewsCarousel({\n  reviews,\n  className = \"\",\n  height = \"300px\",\n  excludeIds = [],\n  showIndicators = true,\n  showNavigation = true,\n  autoPlay = false,\n  autoPlayInterval = 5000,\n}: ReviewsCarouselProps) {\n  // Filter out excluded reviews - use Set for O(1) lookups\n  const filteredReviews = useMemo(() => {\n    if (excludeIds.length === 0) {\n      return reviews;\n    }\n\n    const excludeSet = new Set(excludeIds);\n    const reviewsLength = reviews.length;\n    const results: typeof reviews = [];\n\n    // Use for loop for better performance\n    for (let i = 0; i < reviewsLength; i++) {\n      const review = reviews[i];\n      if (!excludeSet.has(review.id)) {\n        results.push(review);\n      }\n    }\n\n    return results;\n  }, [reviews, excludeIds]);\n\n  const maxIndex = filteredReviews.length - 1;\n  const [activeIndex, setActiveIndex] = useState(0);\n\n  // Auto-play functionality\n  useEffect(() => {\n    if (!autoPlay || maxIndex < 0) {\n      return;\n    }\n\n    const interval = setInterval(() => {\n      setActiveIndex((prevIndex) => {\n        if (prevIndex >= maxIndex) {\n          return 0;\n        }\n        return prevIndex + 1;\n      });\n    }, autoPlayInterval);\n\n    return () => {\n      clearInterval(interval);\n    };\n  }, [autoPlay, autoPlayInterval, maxIndex]);\n\n  // Keyboard navigation\n  useEffect(() => {\n    function handleKeyDown(event: KeyboardEvent) {\n      if (event.key === \"ArrowLeft\") {\n        setActiveIndex((i) => clamp(i - 1, [0, maxIndex]));\n      } else if (event.key === \"ArrowRight\") {\n        setActiveIndex((i) => clamp(i + 1, [0, maxIndex]));\n      }\n    }\n\n    window.addEventListener(\"keydown\", handleKeyDown);\n    return () => {\n      window.removeEventListener(\"keydown\", handleKeyDown);\n    };\n  }, [maxIndex]);\n\n  const goToPrevious = () => {\n    setActiveIndex((prevIndex) => {\n      if (prevIndex > 0) {\n        return prevIndex - 1;\n      }\n      return prevIndex;\n    });\n  };\n\n  const goToNext = () => {\n    setActiveIndex((prevIndex) => {\n      const newIndex = prevIndex + 1;\n      return newIndex <= maxIndex ? newIndex : prevIndex;\n    });\n  };\n\n  if (filteredReviews.length === 0) {\n    return null;\n  }\n\n  return (\n    <div\n      className={cn(\"relative mx-auto w-full max-w-4xl\", className)}\n      style={{ height }}\n    >\n      {/* Stack of cards - using grid-stack pattern */}\n      <div className=\"relative h-full w-full py-8\">\n        <div className=\"grid h-full w-full place-items-center\">\n          {filteredReviews.map((review: Review, index: number) => (\n            <ReviewCard\n              activeIndex={activeIndex}\n              index={index}\n              key={review.id}\n              review={review}\n              totalCards={filteredReviews.length}\n            />\n          ))}\n        </div>\n      </div>\n\n      {/* Navigation buttons */}\n      {showNavigation || showIndicators ? (\n        <div className=\"absolute bottom-4 left-1/2 z-50 flex -translate-x-1/2 items-center gap-2\">\n          {showNavigation ? (\n            <NavigationButton\n              direction=\"prev\"\n              disabled={activeIndex <= 0}\n              onClick={goToPrevious}\n            />\n          ) : null}\n          {showIndicators ? (\n            <div className=\"flex items-center gap-2\">\n              {filteredReviews.map((review: Review, index: number) => (\n                <button\n                  aria-label={`Ir al testimonio ${index + 1}`}\n                  className={cn(\n                    \"h-2 rounded-full transition-all duration-200\",\n                    index === activeIndex\n                      ? \"w-8 bg-brand\"\n                      : \"w-2 bg-brand/30 hover:bg-brand/50\"\n                  )}\n                  key={review.id}\n                  onClick={() => {\n                    setActiveIndex(index);\n                  }}\n                  type=\"button\"\n                />\n              ))}\n            </div>\n          ) : null}\n          {showNavigation ? (\n            <NavigationButton\n              direction=\"next\"\n              disabled={activeIndex === maxIndex}\n              onClick={goToNext}\n            />\n          ) : null}\n        </div>\n      ) : null}\n    </div>\n  );\n}\n","path":"index.tsx","target":"components/smoothui/reviews-carousel/index.tsx","type":"registry:ui"}],"name":"reviews-carousel","registryDependencies":["https://smoothui.dev/r/tokens.json"],"title":"Reviews Carousel","type":"registry:ui"}