|
| 1 | +import { useEffect, useRef, useState } from 'react'; |
| 2 | +import { motion as Motion, useMotionValue, animate } from 'framer-motion'; |
| 3 | +import { ArrowLeft, ArrowRight } from 'lucide-react'; |
| 4 | + |
| 5 | +import ReviewCard from './ReviewCard'; |
| 6 | +import PageIndicator from './PageIndicator'; |
| 7 | +import reviews from './reviewsData.json'; |
| 8 | + |
| 9 | +// How many cards are visible at once, per breakpoint. |
| 10 | +const getVisibleCount = (width) => { |
| 11 | + if (width >= 1024) return 4; // lg |
| 12 | + if (width >= 768) return 2; // md |
| 13 | + return 1; // mobile |
| 14 | +}; |
| 15 | + |
| 16 | +const slide = { type: 'tween', duration: 0.4, ease: 'easeInOut' }; |
| 17 | + |
| 18 | +const ReviewsSection = () => { |
| 19 | + const [visibleCount, setVisibleCount] = useState(() => |
| 20 | + getVisibleCount(typeof window === 'undefined' ? 1024 : window.innerWidth) |
| 21 | + ); |
| 22 | + const [currentIndex, setCurrentIndex] = useState(0); |
| 23 | + |
| 24 | + // Measured pixel width of the viewport, used for swipe math + the track shift. |
| 25 | + const viewportRef = useRef(null); |
| 26 | + const [viewportWidth, setViewportWidth] = useState(0); |
| 27 | + |
| 28 | + // The track's horizontal offset is driven imperatively so that dragging and |
| 29 | + // programmatic snapping share a single source of truth (avoids the framer |
| 30 | + // "stuck after a sub-threshold drag" issue). |
| 31 | + const x = useMotionValue(0); |
| 32 | + |
| 33 | + const total = reviews.length; |
| 34 | + // Furthest index we can scroll to while keeping the track full. |
| 35 | + const maxIndex = Math.max(0, total - visibleCount); |
| 36 | + const cardWidth = viewportWidth / visibleCount; |
| 37 | + |
| 38 | + // Track viewport width + visibleCount on mount and on resize. |
| 39 | + useEffect(() => { |
| 40 | + const el = viewportRef.current; |
| 41 | + if (!el) return undefined; |
| 42 | + const update = () => { |
| 43 | + setViewportWidth(el.clientWidth); |
| 44 | + setVisibleCount(getVisibleCount(window.innerWidth)); |
| 45 | + }; |
| 46 | + update(); |
| 47 | + const observer = new ResizeObserver(update); |
| 48 | + observer.observe(el); |
| 49 | + return () => observer.disconnect(); |
| 50 | + }, []); |
| 51 | + |
| 52 | + // Clamp the active index whenever the bounds shrink (e.g. on resize). |
| 53 | + useEffect(() => { |
| 54 | + setCurrentIndex((idx) => Math.min(idx, maxIndex)); |
| 55 | + }, [maxIndex]); |
| 56 | + |
| 57 | + // Snap the track to the active card whenever index or card size changes. |
| 58 | + useEffect(() => { |
| 59 | + const controls = animate(x, -currentIndex * cardWidth, slide); |
| 60 | + return controls.stop; |
| 61 | + }, [currentIndex, cardWidth, x]); |
| 62 | + |
| 63 | + const goToIndex = (idx) => |
| 64 | + setCurrentIndex(Math.min(Math.max(0, idx), maxIndex)); |
| 65 | + const handlePrev = () => goToIndex(currentIndex - 1); |
| 66 | + const handleNext = () => goToIndex(currentIndex + 1); |
| 67 | + |
| 68 | + // Snap to the nearest card based on drag distance + flick velocity. |
| 69 | + const handleDragEnd = (_event, info) => { |
| 70 | + if (!cardWidth) return; |
| 71 | + const projected = info.offset.x + info.velocity.x * 0.2; |
| 72 | + let steps = Math.round(-projected / cardWidth); |
| 73 | + if (steps === 0 && Math.abs(projected) > cardWidth * 0.25) { |
| 74 | + steps = projected < 0 ? 1 : -1; |
| 75 | + } |
| 76 | + const target = Math.min(Math.max(currentIndex + steps, 0), maxIndex); |
| 77 | + if (target === currentIndex) { |
| 78 | + // Index unchanged → the snap effect won't fire, so re-center here. |
| 79 | + animate(x, -currentIndex * cardWidth, slide); |
| 80 | + } else { |
| 81 | + setCurrentIndex(target); |
| 82 | + } |
| 83 | + }; |
| 84 | + |
| 85 | + const atStart = currentIndex === 0; |
| 86 | + const atEnd = currentIndex >= maxIndex; |
| 87 | + const firstVisible = currentIndex + 1; |
| 88 | + const lastVisible = Math.min(currentIndex + visibleCount, total); |
| 89 | + |
| 90 | + return ( |
| 91 | + <section |
| 92 | + className="reviews-section w-full max-w-7xl mx-auto flex flex-col gap-[64px] px-4 md:px-[85px] lg:px-20 pb-24" |
| 93 | + aria-labelledby="reviews" |
| 94 | + > |
| 95 | + <h2 |
| 96 | + id="reviews" |
| 97 | + className="text-[32px] lg:text-[48px] uppercase section-title px-5 md:px-0 text-center" |
| 98 | + > |
| 99 | + <span className="text-black">THOUSANDS</span> |
| 100 | + <span className="stroke-title">{' '} LOVE</span> |
| 101 | + <span className="capitalize text-black"> Core</span> |
| 102 | + <span className="text-red-500">X</span> |
| 103 | + <span className="text-black"> Nutrition</span> |
| 104 | + </h2> |
| 105 | + |
| 106 | + <div |
| 107 | + className="carousel-container flex flex-col items-center gap-6" |
| 108 | + role="group" |
| 109 | + aria-roledescription="carousel" |
| 110 | + aria-label="Customer reviews" |
| 111 | + > |
| 112 | + {/* Arrows are desktop-only; mobile navigates by swiping. */} |
| 113 | + <nav |
| 114 | + className="hidden md:flex flex-row gap-2 w-full items-center justify-end" |
| 115 | + aria-label="Reviews carousel controls" |
| 116 | + > |
| 117 | + <button |
| 118 | + type="button" |
| 119 | + onClick={handlePrev} |
| 120 | + disabled={atStart} |
| 121 | + aria-label="Previous review" |
| 122 | + className="cursor-pointer rounded disabled:opacity-40 disabled:cursor-not-allowed hover:scale-110 active:scale-100 transition-transform duration-150 ease-in-out" |
| 123 | + > |
| 124 | + <ArrowLeft aria-hidden="true" /> |
| 125 | + </button> |
| 126 | + <button |
| 127 | + type="button" |
| 128 | + onClick={handleNext} |
| 129 | + disabled={atEnd} |
| 130 | + aria-label="Next review" |
| 131 | + className="cursor-pointer rounded disabled:opacity-40 disabled:cursor-not-allowed hover:scale-110 active:scale-100 transition-transform duration-150 ease-in-out" |
| 132 | + > |
| 133 | + <ArrowRight aria-hidden="true" /> |
| 134 | + </button> |
| 135 | + </nav> |
| 136 | + |
| 137 | + {/* Viewport clips the track horizontally; vertical padding leaves room |
| 138 | + for the verified badge and the card hover-scale. */} |
| 139 | + <div ref={viewportRef} className="w-full overflow-hidden py-6"> |
| 140 | + <Motion.ul |
| 141 | + className="flex list-none p-0 m-0 cursor-grab active:cursor-grabbing select-none" |
| 142 | + role="list" |
| 143 | + style={{ x }} |
| 144 | + drag="x" |
| 145 | + dragConstraints={{ left: -(maxIndex * cardWidth), right: 0 }} |
| 146 | + dragElastic={0.15} |
| 147 | + dragMomentum={false} |
| 148 | + onDragEnd={handleDragEnd} |
| 149 | + > |
| 150 | + {reviews.map((review, index) => { |
| 151 | + const isVisible = |
| 152 | + index >= currentIndex && index < currentIndex + visibleCount; |
| 153 | + return ( |
| 154 | + <li |
| 155 | + key={`${review.name}-${index}`} |
| 156 | + className="shrink-0 px-2" |
| 157 | + style={{ width: `${100 / visibleCount}%` }} |
| 158 | + aria-hidden={!isVisible} |
| 159 | + > |
| 160 | + <ReviewCard |
| 161 | + product={review.product} |
| 162 | + comment={review.comment} |
| 163 | + name={review.name} |
| 164 | + rating={review.rating} |
| 165 | + interactive={isVisible} |
| 166 | + /> |
| 167 | + </li> |
| 168 | + ); |
| 169 | + })} |
| 170 | + </Motion.ul> |
| 171 | + </div> |
| 172 | + |
| 173 | + <span className="sr-only" aria-live="polite" aria-atomic="true"> |
| 174 | + Showing reviews {firstVisible} to {lastVisible} of {total} |
| 175 | + </span> |
| 176 | + |
| 177 | + <PageIndicator |
| 178 | + totalPages={maxIndex + 1} |
| 179 | + currentPage={currentIndex} |
| 180 | + onSelect={goToIndex} |
| 181 | + maxVisible={5} |
| 182 | + /> |
| 183 | + </div> |
| 184 | + </section> |
| 185 | + ); |
| 186 | +}; |
| 187 | + |
| 188 | +export default ReviewsSection; |
0 commit comments