Template
Product cards, header, footer, PDP, and typography reworked toward a leaner layout; adds shop policy pages backed by the Storefront API. - Type: switch to Geist Sans/Mono, regular-weight headings - Product cards: drop borders, rounded corners, and action buttons - Header: shorter bar, no bottom border, center-out hover underline, bag icon replacing the cart icon (drawer wording updated to match) - Footer: single line with policy links and social icons on bg-background - Policies: /policies/[handle] renders shop.privacyPolicy, termsOfService, refundPolicy, shippingPolicy, subscriptionPolicy (SSG, hourly revalidation) - PDP: image grid with mobile carousel + dots and click-to-zoom, sticky info column, colour swatches from option optionValues with a configurable name-to-colour fallback in config/swatches.ts, Shop-purple checkout button - Recommendations: left-aligned heading on bg-background - Ignore .env*.local and tsconfig.tsbuildinfo Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016jWbNNJLksC1QG8z8845FX
159 lines
5.0 KiB
TypeScript
159 lines
5.0 KiB
TypeScript
'use client';
|
|
|
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
|
import { RiCloseLine } from '@remixicon/react';
|
|
|
|
interface ProductImage {
|
|
url: string;
|
|
altText?: string;
|
|
}
|
|
|
|
interface ProductDetailGalleryProps {
|
|
images: ProductImage[];
|
|
}
|
|
|
|
const ProductDetailGallery: React.FC<ProductDetailGalleryProps> = ({
|
|
images,
|
|
}) => {
|
|
const [zoomedIndex, setZoomedIndex] = useState<number | null>(null);
|
|
const [activeIndex, setActiveIndex] = useState(0);
|
|
const scrollerRef = useRef<HTMLDivElement>(null);
|
|
const isZoomed = zoomedIndex !== null;
|
|
|
|
const close = useCallback(() => setZoomedIndex(null), []);
|
|
|
|
// The slide whose left edge sits closest to the scroller's left edge is the
|
|
// one in view. Measuring rects keeps this correct whatever the gap or width.
|
|
const handleScroll = useCallback(() => {
|
|
const scroller = scrollerRef.current;
|
|
if (!scroller) return;
|
|
|
|
const scrollerLeft = scroller.getBoundingClientRect().left;
|
|
let nearest = 0;
|
|
let smallestOffset = Infinity;
|
|
|
|
Array.from(scroller.children).forEach((child, index) => {
|
|
const offset = Math.abs(child.getBoundingClientRect().left - scrollerLeft);
|
|
if (offset < smallestOffset) {
|
|
smallestOffset = offset;
|
|
nearest = index;
|
|
}
|
|
});
|
|
|
|
setActiveIndex(nearest);
|
|
}, []);
|
|
|
|
const scrollToIndex = (index: number) => {
|
|
const scroller = scrollerRef.current;
|
|
const slide = scroller?.children[index];
|
|
if (!scroller || !slide) return;
|
|
|
|
const offset =
|
|
slide.getBoundingClientRect().left - scroller.getBoundingClientRect().left;
|
|
scroller.scrollTo({ left: scroller.scrollLeft + offset, behavior: 'smooth' });
|
|
};
|
|
|
|
// Close on Escape, and keep the page behind the overlay from scrolling.
|
|
useEffect(() => {
|
|
if (!isZoomed) return;
|
|
|
|
const onKeyDown = (event: KeyboardEvent) => {
|
|
if (event.key === 'Escape') close();
|
|
};
|
|
|
|
document.addEventListener('keydown', onKeyDown);
|
|
const previousOverflow = document.body.style.overflow;
|
|
document.body.style.overflow = 'hidden';
|
|
|
|
return () => {
|
|
document.removeEventListener('keydown', onKeyDown);
|
|
document.body.style.overflow = previousOverflow;
|
|
};
|
|
}, [isZoomed, close]);
|
|
|
|
if (images.length === 0) {
|
|
return (
|
|
<div className="aspect-square bg-zinc-50 flex items-center justify-center text-zinc-300">
|
|
<i className="ri-image-line text-[120px]"></i>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const isSingle = images.length === 1;
|
|
const zoomedImage = zoomedIndex !== null ? images[zoomedIndex] : null;
|
|
|
|
return (
|
|
<>
|
|
{/* Swipeable carousel on mobile, grid from sm up */}
|
|
<div
|
|
ref={scrollerRef}
|
|
onScroll={handleScroll}
|
|
className="flex snap-x snap-mandatory gap-3 overflow-x-auto no-scrollbar sm:grid sm:grid-cols-2 sm:overflow-visible"
|
|
>
|
|
{images.map((image, index) => (
|
|
<button
|
|
key={index}
|
|
onClick={() => setZoomedIndex(index)}
|
|
aria-label={`Zoom ${image.altText || 'product image'}`}
|
|
className={`relative aspect-square w-full shrink-0 snap-center overflow-hidden bg-white cursor-zoom-in sm:shrink ${
|
|
isSingle ? 'sm:col-span-2' : ''
|
|
}`}
|
|
>
|
|
<img
|
|
src={image.url}
|
|
alt={image.altText || 'Product image'}
|
|
className="w-full h-full object-cover"
|
|
/>
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Carousel pagination — the grid needs no dots, so mobile only */}
|
|
{!isSingle && (
|
|
<div className="flex justify-center gap-2 pt-4 sm:hidden">
|
|
{images.map((_, index) => (
|
|
<button
|
|
key={index}
|
|
onClick={() => scrollToIndex(index)}
|
|
aria-label={`Go to image ${index + 1}`}
|
|
aria-current={index === activeIndex}
|
|
className={`h-1.5 rounded-full transition-all ${
|
|
index === activeIndex
|
|
? 'w-5 bg-foreground'
|
|
: 'w-1.5 bg-border hover:bg-foreground/40'
|
|
}`}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{zoomedImage && (
|
|
<div
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-label={zoomedImage.altText || 'Product image'}
|
|
onClick={close}
|
|
className="fixed inset-0 z-100 flex items-center justify-center bg-foreground/20 backdrop-blur-md p-6 md:p-10"
|
|
>
|
|
<img
|
|
src={zoomedImage.url}
|
|
alt={zoomedImage.altText || 'Product image'}
|
|
onClick={(event) => event.stopPropagation()}
|
|
className="max-h-full max-w-full object-contain bg-white"
|
|
/>
|
|
|
|
<button
|
|
onClick={close}
|
|
aria-label="Close"
|
|
className="absolute top-4 right-4 h-10 w-10 rounded-full bg-background text-foreground flex items-center justify-center shadow-sm hover:bg-secondary transition-colors"
|
|
>
|
|
<RiCloseLine size={20} />
|
|
</button>
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
};
|
|
|
|
export default ProductDetailGallery;
|