The images to show and the product title for fallback text.
The image box, or null when there are no loadable images.
<ProductImageCarousel images={resolveProductImages(product)} title={product.title} />
export function ProductImageCarousel({
images,
title,
}: ProductImageCarouselProps): ReactElement | null {
const [broken, setBroken] = useState<ReadonlySet<number>>(new Set());
const [modalOpen, setModalOpen] = useState(false);
// URLs already handed to the preloader, so we never fetch the same one twice.
const preloadedRef = useRef<Set<string>>(new Set());
const available = images
.map((image, index) => ({ image, index }))
.filter(({ index }) => !broken.has(index));
const { index: position, next, prev } = useCyclingIndex(available.length, IMAGE_CYCLE_MS);
// The thumbnails the carousel actually renders, in cycle order. The first is shown
// immediately (loaded by the <img> below), so we preload only the rest — ahead of the
// automatic rotation — and the key keeps the effect stable across value-equal renders.
const thumbnails = available.map(({ image }) => image.thumbSrc).filter(Boolean);
const thumbnailKey = thumbnails.join('\n');
useEffect(() => {
const urls = thumbnailKey.length > 0 ? thumbnailKey.split('\n') : [];
const pending = urls.slice(1).filter((url) => !preloadedRef.current.has(url));
if (pending.length === 0) return;
for (const url of pending) preloadedRef.current.add(url);
void preloadImages(pending);
}, [thumbnailKey]);
if (available.length === 0) return null;
const current = available[Math.min(position, available.length - 1)];
const hasMultiple = available.length > 1;
// Cycle without letting the click bubble to the trigger that opens the modal.
const navigate = (event: MouseEvent, step: () => void) => {
event.preventDefault();
event.stopPropagation();
step();
};
return (
<Fragment>
<ProductDetailImageBox>
<ProductImageTrigger
type="button"
aria-label={i18n('product_detail_view_images', [title])}
onClick={() => setModalOpen(true)}
>
<img
src={current.image.thumbSrc}
alt={current.image.altText ?? title}
onError={() => setBroken((brokenSet) => new Set(brokenSet).add(current.index))}
/>
</ProductImageTrigger>
{hasMultiple && (
<>
<ProductImageNavButton
type="button"
className="image-nav prev"
aria-label={i18n('product_detail_prev_image')}
onClick={(event) => navigate(event, prev)}
>
<ArrowBackIosNewIcon fontSize="small" />
</ProductImageNavButton>
<ProductImageNavButton
type="button"
className="image-nav next"
aria-label={i18n('product_detail_next_image')}
onClick={(event) => navigate(event, next)}
>
<ArrowForwardIosIcon fontSize="small" />
</ProductImageNavButton>
</>
)}
</ProductDetailImageBox>
<ProductImageModal
open={modalOpen}
onClose={() => setModalOpen(false)}
images={available.map(({ image }) => image)}
title={title}
initialIndex={position}
/>
</Fragment>
);
}
Renders a product's image in the fixed-size detail box, cycling through the images on a fixed interval (
IMAGE_CYCLE_MS) when there is more than one. Hovering the image reveals semi-transparent prev/next arrows for manual navigation; a click anywhere else opens the full image in a new tab. Images that fail to load (e.g. a CACTUS 404) are dropped from the rotation; the box disappears only once every image has failed.