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
+65 -77
View File
@@ -2,11 +2,10 @@
import React, { useState, useEffect } from 'react';
import { useParams } from 'next/navigation';
import Link from 'next/link';
import { useProduct, type Product } from '@/hooks/use-shopify-products';
import { useShopifyCart } from '@/hooks/use-shopify-cart';
import { useShopifyCart, redirectToCheckout } from '@/hooks/use-shopify-cart';
import ProductDetailGallery from './product-detail-gallery';
import ProductDetailInfo, { type ProductFeature } from './product-detail-info';
import ProductDetailInfo from './product-detail-info';
import { Button } from '@/components/ui/button';
import {
Empty,
@@ -15,14 +14,6 @@ import {
EmptyDescription,
EmptyContent,
} from '@/components/ui/empty';
import {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
} from '@/components/ui/breadcrumb';
interface ProductVariant {
id: string;
@@ -47,17 +38,15 @@ export type { Product };
interface ProductDetailProps {
handle?: string;
addToCartLabel?: string;
features?: ProductFeature[];
}
const ProductDetail: React.FC<ProductDetailProps> = ({
handle: handleProp,
addToCartLabel = 'Add to Cart',
features,
}) => {
const params = useParams();
const handle = handleProp || (params?.handle as string);
const { addItem, openCart } = useShopifyCart();
const { addItem, openCart, checkoutUrl } = useShopifyCart();
const { product, loading, error } = useProduct(handle);
@@ -68,8 +57,8 @@ const ProductDetail: React.FC<ProductDetailProps> = ({
Record<string, string>
>({});
const [quantity, setQuantity] = useState(1);
const [selectedImageIndex, setSelectedImageIndex] = useState(0);
const [addingToCart, setAddingToCart] = useState(false);
const [buyingNow, setBuyingNow] = useState(false);
// Initialize variant when product loads
useEffect(() => {
@@ -102,17 +91,6 @@ const ProductDetail: React.FC<ProductDetailProps> = ({
if (matchingVariant) {
setSelectedVariant(matchingVariant.node);
// Update image if variant has an associated image
if (matchingVariant.node.image && product) {
const variantImageUrl = matchingVariant.node.image.url;
const imageIndex = product.images.edges.findIndex(
(edge) => edge.node.url === variantImageUrl
);
if (imageIndex !== -1) {
setSelectedImageIndex(imageIndex);
}
}
}
};
@@ -130,30 +108,50 @@ const ProductDetail: React.FC<ProductDetailProps> = ({
}
};
// Adds the item, then sends the shopper straight to the Shopify checkout
// (where Shop Pay is offered) rather than opening the cart drawer.
const handleBuyNow = async () => {
if (!selectedVariant || !product) return;
try {
setBuyingNow(true);
const updatedCart = await addItem(selectedVariant.id, quantity);
const url = updatedCart?.checkoutUrl ?? checkoutUrl;
if (url) {
redirectToCheckout(url);
} else {
openCart();
}
} catch (err) {
console.error('Failed to start checkout:', err);
} finally {
setBuyingNow(false);
}
};
if (loading) {
return (
<div className="container mx-auto px-4 py-8">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12">
<div className="max-w-screen-2xl mx-auto px-8 py-8">
<div className="grid grid-cols-1 lg:grid-cols-5 gap-8 lg:gap-10">
{/* Image Gallery Skeleton */}
<div>
<div className="aspect-square bg-gray-200 rounded-lg animate-pulse mb-4"></div>
<div className="grid grid-cols-4 gap-2">
{Array.from({ length: 4 }).map((_, i) => (
<div
key={i}
className="aspect-square bg-gray-200 rounded animate-pulse"
></div>
))}
</div>
<div className="lg:col-span-3 grid grid-cols-1 sm:grid-cols-2 gap-3">
{Array.from({ length: 4 }).map((_, i) => (
<div
key={i}
className="aspect-square bg-zinc-100 animate-pulse"
></div>
))}
</div>
{/* Product Info Skeleton */}
<div>
<div className="h-8 bg-gray-200 rounded mb-4 animate-pulse"></div>
<div className="h-6 bg-gray-200 rounded mb-6 w-1/3 animate-pulse"></div>
<div className="h-24 bg-gray-200 rounded mb-6 animate-pulse"></div>
<div className="h-12 bg-gray-200 rounded mb-4 animate-pulse"></div>
<div className="h-12 bg-gray-200 rounded animate-pulse"></div>
<div className="lg:col-span-2 animate-pulse">
<div className="h-8 bg-zinc-100 w-2/3"></div>
<div className="h-5 bg-zinc-100 w-24 mt-2"></div>
<div className="h-8 bg-zinc-100 w-32 mt-8"></div>
<div className="h-10 bg-zinc-100 mt-8"></div>
<div className="h-12 bg-zinc-100 mt-8"></div>
<div className="h-12 bg-zinc-100 mt-3"></div>
</div>
</div>
</div>
@@ -181,39 +179,29 @@ const ProductDetail: React.FC<ProductDetailProps> = ({
}
return (
<div className="bg-white">
<div className="container mx-auto px-4 py-8">
<Breadcrumb className="mb-6">
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link href="/">Home</Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>{product.title}</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12">
<ProductDetailGallery
images={product.images.edges.map((edge) => edge.node)}
selectedImageIndex={selectedImageIndex}
onImageSelect={setSelectedImageIndex}
/>
<ProductDetailInfo
product={product}
selectedVariant={selectedVariant}
selectedOptions={selectedOptions}
quantity={quantity}
setQuantity={setQuantity}
handleAddToCart={handleAddToCart}
onOptionChange={handleOptionChange}
loading={addingToCart}
addToCartLabel={addToCartLabel}
features={features}
/>
<div className="bg-background">
<div className="max-w-screen-2xl mx-auto px-8 py-8">
<div className="grid grid-cols-1 lg:grid-cols-5 gap-8 lg:gap-10 items-start">
<div className="lg:col-span-3">
<ProductDetailGallery
images={product.images.edges.map((edge) => edge.node)}
/>
</div>
<div className="lg:col-span-2 lg:sticky lg:top-20">
<ProductDetailInfo
product={product}
selectedVariant={selectedVariant}
selectedOptions={selectedOptions}
quantity={quantity}
setQuantity={setQuantity}
handleAddToCart={handleAddToCart}
handleBuyNow={handleBuyNow}
onOptionChange={handleOptionChange}
loading={addingToCart}
buyingNow={buyingNow}
addToCartLabel={addToCartLabel}
/>
</div>
</div>
</div>
</div>