Redesign storefront to match minimal reference design

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
This commit is contained in:
Rami Bitar
2026-08-01 11:11:25 -04:00
co-authored by Claude Opus 5
parent e3d5e75299
commit 69f7435d6e
25 changed files with 1055 additions and 593 deletions
@@ -1,4 +1,7 @@
import React from 'react';
'use client';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { RiCloseLine } from '@remixicon/react';
interface ProductImage {
url: string;
@@ -7,57 +10,148 @@ interface ProductImage {
interface ProductDetailGalleryProps {
images: ProductImage[];
selectedImageIndex?: number;
onImageSelect?: (index: number) => void;
}
const ProductDetailGallery: React.FC<ProductDetailGalleryProps> = ({
images,
selectedImageIndex = 0,
onImageSelect,
}) => {
const setSelectedImage = onImageSelect || (() => {});
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 (
<div className="space-y-4">
{/* Main Image */}
<div className="product-gallery-main aspect-square bg-zinc-50 border border-border rounded-3xl overflow-hidden shadow-inner relative">
{images.length > 0 ? (
<img
src={images[selectedImageIndex].url}
alt={images[selectedImageIndex].altText || 'Product image'}
className="w-full h-full object-cover transition-all hover:scale-105 duration-700"
/>
) : (
<div className="absolute inset-0 flex items-center justify-center text-zinc-300">
<i className="ri-image-line text-[120px]"></i>
</div>
)}
<>
{/* 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>
{/* Image Thumbnails */}
{images.length > 1 && (
<div className="grid grid-cols-4 gap-3">
{images.map((image, index) => (
{/* 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={() => setSelectedImage(index)}
className={`aspect-square rounded-2xl overflow-hidden border transition-all duration-200 hover:shadow-md focus:outline-none focus:ring-2 focus:ring-primary/30 ${
selectedImageIndex === index
? 'border-primary shadow-md scale-[1.03]'
: 'border-border hover:border-zinc-300'
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'
}`}
>
<img
src={image.url}
alt={image.altText || 'Product thumbnail'}
className="w-full h-full object-cover"
/>
</button>
/>
))}
</div>
)}
</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>
)}
</>
);
};