import { useRef, useState } from 'react';
interface ProductGalleryProps {
images: string[];
alt: string;
}
const Chevron = ({ direction }: { direction: 'left' | 'right' }) => (
);
/**
* The square photo on a product card, with arrows and dots when there's more than one shot.
*
* Replaces react-awesome-slider, which hasn't been published since 2020 and pins peer deps to
* React 16 — the same job in a fraction of the code, and one less unmaintained dependency in a
* build we gate on CVEs. Behaviour is what the old cards did: one image at a time, square crop,
* arrows only when they'd do something.
*/
/** Past this many pixels a horizontal drag counts as a swipe rather than a tap or a page scroll. */
const SWIPE_THRESHOLD = 40;
const ProductGallery: React.FC = ({ images, alt }) => {
const [index, setIndex] = useState(0);
// Filtering swaps the product under a reused component instance, so a stale index can point
// past the new list — every frame then renders at opacity-0 and the card goes blank. Reset
// during render (the React-sanctioned way to derive state from props) rather than in an effect,
// so the correct frame paints on the first pass instead of flashing an empty square.
const [renderedFor, setRenderedFor] = useState(images);
if (renderedFor !== images) {
setRenderedFor(images);
setIndex(0);
}
const many = images.length > 1;
const active = index < images.length ? index : 0;
const step = (delta: number) => setIndex((i) => (i + delta + images.length) % images.length);
// Swipe on touch devices and arrow keys — the react-awesome-slider this replaced had swipe, and
// the products page is browsed mostly on phones. Vertical drags are left alone so the page still
// scrolls through the card.
const touchStart = useRef<{ x: number; y: number } | null>(null);
const onTouchStart = (e: React.TouchEvent) => {
const t = e.touches[0];
touchStart.current = { x: t.clientX, y: t.clientY };
};
const onTouchEnd = (e: React.TouchEvent) => {
const start = touchStart.current;
touchStart.current = null;
if (!start || !many) return;
const t = e.changedTouches[0];
const dx = t.clientX - start.x;
const dy = t.clientY - start.y;
if (Math.abs(dx) < SWIPE_THRESHOLD || Math.abs(dx) <= Math.abs(dy)) return;
step(dx < 0 ? 1 : -1);
};
const arrowClass =
'absolute top-1/2 -translate-y-1/2 grid place-items-center h-10 w-10 rounded-full bg-bakery-900/40 text-white backdrop-blur-sm transition hover:bg-bakery-900/60 focus:outline-none focus-visible:ring-2 focus-visible:ring-white';
return (
{
if (e.key === 'ArrowLeft') { e.preventDefault(); step(-1); }
if (e.key === 'ArrowRight') { e.preventDefault(); step(1); }
} : undefined}
tabIndex={many ? 0 : undefined}
role={many ? 'group' : undefined}
aria-roledescription={many ? 'carousel' : undefined}
aria-label={many ? `${alt} — ${images.length} photos` : undefined}
>
{images.map((src, i) => (
))}
{many && (
<>
{/* Dots: how many photos there are, and which one you're on. */}