Skip to content

Commit 18edd87

Browse files
committed
fix: - update reviews section: - centralized everything in one folder; - update swipe animation; - update responsiveness layout for mobile devices to show just one review in a row, instead of a 3 in a column; - implement swiping of one review at a time, instead of a block of reviews.
1 parent 8f9a90f commit 18edd87

8 files changed

Lines changed: 256 additions & 157 deletions

File tree

src/components/Reviews/PageIndicator.jsx

Lines changed: 45 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,53 @@
1-
const PageIndicator = ({ totalPages, currentPage, onSelect }) => {
1+
// Windowed page indicator (iOS-style): never renders more than `maxVisible`
2+
// dots. When there are more positions than fit, the dots at the edge that has
3+
// hidden positions shrink progressively to hint that more content exists.
4+
const PageIndicator = ({ totalPages, currentPage, onSelect, maxVisible = 5 }) => {
25
if (totalPages <= 1) return null;
6+
7+
const windowSize = Math.min(maxVisible, totalPages);
8+
// Center the active dot in the window, clamped to the valid range.
9+
const start = Math.max(
10+
0,
11+
Math.min(currentPage - Math.floor(windowSize / 2), totalPages - windowSize)
12+
);
13+
14+
const hasMoreBefore = start > 0;
15+
const hasMoreAfter = start + windowSize < totalPages;
16+
17+
const getSize = (pos) => {
18+
const isFirst = pos === 0;
19+
const isLast = pos === windowSize - 1;
20+
if ((isFirst && hasMoreBefore) || (isLast && hasMoreAfter)) return 'w-1.5 h-1.5';
21+
if ((pos === 1 && hasMoreBefore) || (pos === windowSize - 2 && hasMoreAfter))
22+
return 'w-2 h-2';
23+
return 'w-2.5 h-2.5';
24+
};
25+
326
return (
427
<nav
5-
className=" reviews-page-viewer w-full mt-2 flex gap-2 items-center justify-center flex-row"
28+
className="reviews-page-viewer mt-2 flex flex-row items-center justify-center gap-2"
629
aria-label="Reviews pages"
730
>
8-
{Array.from({ length: totalPages }).map((_, idx) => (
9-
<button
10-
key={idx}
11-
type="button"
12-
onClick={() => onSelect(idx)}
13-
aria-label={`Go to page ${idx + 1}`}
14-
aria-current={idx === currentPage ? 'page' : undefined}
15-
className={`flex items-center min-w-4 min-h-4 md:min-w-5 md:min-h-5 aspect-square rounded-full ${
16-
idx === currentPage
17-
? 'bg-[#89949F]'
18-
: 'bg-[#B4C2CF] cursor-pointer scale-3d hover:scale-[1.1] transition-all duration-150 ease-in-out'
19-
}`}
20-
/>
21-
))}
31+
{Array.from({ length: windowSize }, (_, pos) => {
32+
const idx = start + pos;
33+
const isActive = idx === currentPage;
34+
return (
35+
<button
36+
key={idx}
37+
type="button"
38+
onClick={() => onSelect(idx)}
39+
aria-label={`Go to review ${idx + 1}`}
40+
aria-current={isActive ? 'true' : undefined}
41+
className={`aspect-square rounded-full transition-all duration-200 ease-in-out ${getSize(
42+
pos
43+
)} ${
44+
isActive
45+
? 'bg-[#89949F]'
46+
: 'bg-[#B4C2CF] cursor-pointer hover:scale-125'
47+
}`}
48+
/>
49+
);
50+
})}
2251
</nav>
2352
);
2453
};

src/components/Reviews/ReviewCard.jsx

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,26 @@
11
import { renderStars } from '../common/ReviewStars';
22

3-
const ReviewCard = ({ product, comment, name, rating }) => {
3+
const ReviewCard = ({ product, comment, name, rating, interactive = true }) => {
44
return (
5-
<li className="review-card min-w-[200px] relative w-full max-w-full h-max gap-6 flex flex-col p-6 bg-white border border-[#DDDDDD] items-center justify-center rounded-lg shadow-md cursor-default hover:shadow-xl scale-3d hover:scale-105 transition-all ease-in-out duration-300 ">
5+
<article
6+
className={`review-card relative flex h-full w-full max-w-full flex-col items-center justify-center gap-6 rounded-lg border border-[#DDDDDD] bg-white p-6 shadow-md ${
7+
interactive
8+
? 'cursor-default transition-all duration-300 ease-in-out hover:scale-105 hover:shadow-xl'
9+
: 'pointer-events-none'
10+
}`}
11+
>
612
<img
7-
src={`/icons/material-icon-theme_verified.svg`}
8-
alt={'verified'}
13+
src="/icons/material-icon-theme_verified.svg"
14+
alt="Verified review"
915
className="verified-badge absolute top-[-10px] right-[-12px]"
1016
/>
11-
<h3 className="review-title font-bold text-lg --font-inter text-[#0D1B2A]">
12-
{product}
13-
</h3>
14-
<p className="review-comment text-sm text-center min-h-[calc(1.5em*3)] line-clamp-3 --font-montserrat text-[#333333]">
17+
<h3 className="review-title text-lg font-bold text-[#0D1B2A]">{product}</h3>
18+
<p className="review-comment min-h-[calc(1.5em*3)] text-center text-sm line-clamp-3 text-[#333333]">
1519
{comment}
1620
</p>
17-
<h3 className="review-name font-bold text-lg --font-inter text-[#0D1B2A]">
18-
{name}
19-
</h3>
21+
<p className="review-name text-lg font-bold text-[#0D1B2A]">{name}</p>
2022
<div className="flex gap-1">{renderStars(rating)}</div>
21-
</li>
23+
</article>
2224
);
2325
};
2426

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
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;

src/components/Reviews/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { default } from './ReviewsSection';

0 commit comments

Comments
 (0)