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
+5 -3
View File
@@ -17,6 +17,7 @@ import {
RiImageLine,
RiSubtractLine,
RiAddLine,
RiShoppingBagLine,
} from '@remixicon/react';
import {
Empty,
@@ -65,8 +66,9 @@ const CartDrawer: React.FC = () => {
{/* Header */}
<SheetHeader className="h-14 min-h-0 px-4 py-3 flex items-center">
<div className="flex items-center justify-between w-full">
<SheetTitle className="text-base">
Shopping Cart ({itemCount})
<SheetTitle className="text-base flex items-center gap-x-2">
<RiShoppingBagLine size={18} />
Bag ({itemCount})
</SheetTitle>
<Button onClick={closeCart} variant="ghost" size="icon-sm">
<RiCloseLine size={20} />
@@ -83,7 +85,7 @@ const CartDrawer: React.FC = () => {
) : items.length === 0 ? (
<Empty>
<EmptyHeader>
<EmptyTitle>Your cart is empty</EmptyTitle>
<EmptyTitle>Your bag is empty</EmptyTitle>
<EmptyDescription>
Add some products to get started!
</EmptyDescription>
+47 -89
View File
@@ -1,4 +1,9 @@
import React from 'react';
import {
RiInstagramLine,
RiTiktokLine,
RiFacebookFill,
} from '@remixicon/react';
export interface FooterLink {
label: string;
@@ -7,110 +12,63 @@ export interface FooterLink {
interface FooterProps {
storeName?: string;
logoUrl?: string;
tagline?: string;
copyright?: string;
links?: FooterLink[];
instagramUrl?: string;
tiktokUrl?: string;
facebookUrl?: string;
}
const Footer: React.FC<FooterProps> = ({
storeName = 'Stride',
logoUrl,
tagline = 'Performance in every stride.',
copyright = '© 2026 Stride. All rights reserved.',
storeName = 'Shop',
copyright,
links = [
{ label: 'About', url: '#' },
{ label: 'Athletes', url: '#' },
{ label: 'Technology', url: '#' },
{ label: 'Contact', url: '#' },
{ label: 'Terms of Service', url: '/policies/terms-of-service' },
{ label: 'Privacy Policy', url: '/policies/privacy-policy' },
{ label: 'Refund Policy', url: '/policies/refund-policy' },
{ label: 'Shipping Policy', url: '/policies/shipping-policy' },
{ label: 'Subscription Policy', url: '/policies/subscription-policy' },
],
instagramUrl = '#',
tiktokUrl = '#',
facebookUrl = '#',
}) => {
const socials = [
{ label: 'Instagram', url: instagramUrl, Icon: RiInstagramLine },
{ label: 'TikTok', url: tiktokUrl, Icon: RiTiktokLine },
{ label: 'Facebook', url: facebookUrl, Icon: RiFacebookFill },
];
return (
<footer className="bg-zinc-50 border-t border-border py-16 text-sm">
<div className="max-w-screen-2xl mx-auto px-8">
<div className="grid grid-cols-1 md:grid-cols-12 gap-y-12">
{/* Brand Column */}
<div className="md:col-span-5">
<div className="flex items-baseline mb-4">
{logoUrl ? (
<img
src={logoUrl}
alt={storeName}
className="h-8 w-auto object-contain"
/>
) : (
<span className="text-4xl font-semibold tracking-tighter font-poppins text-foreground">
{storeName}
</span>
)}
</div>
<p className="max-w-xs text-muted-foreground text-[15px] leading-relaxed">
{tagline}
<footer className="bg-background">
<div className="max-w-screen-2xl mx-auto px-8 py-10">
<div className="flex flex-col sm:flex-row items-center justify-between gap-5">
<div className="flex flex-wrap items-center justify-center sm:justify-start gap-x-5 gap-y-2">
<p className="text-sm text-muted-foreground leading-5">
{copyright || `© ${storeName}. All rights reserved.`}
</p>
<div className="mt-8 text-xs font-mono text-muted-foreground tracking-[1px]">
ENGINEERED FOR MOTION
</div>
{links.map((link) => (
<a
key={link.label}
href={link.url}
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
{link.label}
</a>
))}
</div>
{/* Links */}
<div className="md:col-span-3">
<div className="font-semibold text-foreground mb-5 text-xs tracking-widest">
SHOP
</div>
<div className="flex flex-col gap-y-3 text-muted-foreground">
{links.map((link, index) => (
<a
key={index}
href={link.url}
className="hover:text-foreground transition-colors w-fit"
>
{link.label}
</a>
))}
</div>
</div>
<div className="md:col-span-4">
<div className="font-semibold text-foreground mb-5 text-xs tracking-widest">
CONNECT
</div>
<div className="flex flex-col gap-y-3 text-muted-foreground">
<div className="flex items-center gap-x-4">
{socials.map(({ label, url, Icon }) => (
<a
href="#"
className="hover:text-foreground transition-colors w-fit"
key={label}
href={url}
aria-label={label}
className="text-muted-foreground hover:text-foreground transition-colors"
>
Instagram
<Icon size={18} />
</a>
<a
href="#"
className="hover:text-foreground transition-colors w-fit"
>
Pinterest
</a>
<a
href="#"
className="hover:text-foreground transition-colors w-fit"
>
Newsletter
</a>
</div>
<div className="mt-auto pt-12 text-[10px] text-muted-foreground font-mono leading-loose">
CRAFTED WITH PRECISION
<br />
IN A NEUTRAL PALETTE
</div>
</div>
</div>
{/* Bottom Bar */}
<div className="mt-16 pt-8 border-t border-border text-xs text-muted-foreground flex flex-col md:flex-row justify-between items-center gap-4 font-mono tracking-widest">
<div>{copyright}</div>
<div className="flex gap-x-6">
<span>Privacy</span>
<span>Terms</span>
))}
</div>
</div>
</div>
+18 -20
View File
@@ -4,7 +4,7 @@ import React, { useState } from 'react';
import Link from 'next/link';
import { useCartStore } from '@/hooks/use-shopify-cart';
import CartDrawer from '@/components/shopify/cart-drawer';
import { RiShoppingCartLine, RiCloseLine, RiMenu3Line } from '@remixicon/react';
import { RiShoppingBagLine, RiCloseLine, RiMenu3Line } from '@remixicon/react';
const CartIcon: React.FC = () => {
const toggleCart = useCartStore((s) => s.toggleCart);
@@ -15,11 +15,12 @@ const CartIcon: React.FC = () => {
return (
<button
onClick={toggleCart}
className="relative p-2.5 text-foreground hover:text-primary transition-all duration-200 rounded-full hover:bg-secondary"
aria-label={`Open bag (${itemCount})`}
className="relative p-2 text-foreground hover:text-muted-foreground transition-colors"
>
<RiShoppingCartLine size={20} />
<RiShoppingBagLine size={20} />
{itemCount > 0 && (
<span className="absolute -top-0.5 -right-0.5 bg-primary text-primary-foreground text-[10px] font-mono rounded-full w-5 h-5 flex items-center justify-center font-semibold shadow">
<span className="absolute top-0 right-0 bg-primary text-primary-foreground text-[10px] font-mono rounded-full w-4 h-4 flex items-center justify-center">
{itemCount > 99 ? '99+' : itemCount}
</span>
)}
@@ -49,31 +50,31 @@ const Header: React.FC<HeaderProps> = ({
const [menuOpen, setMenuOpen] = useState(false);
return (
<nav className="bg-white/95 backdrop-blur-md border-b border-border sticky top-0 z-50">
<nav className="bg-background/95 backdrop-blur-md sticky top-0 z-50">
<div className="max-w-screen-2xl mx-auto px-8">
<div className="flex justify-between items-center h-20">
<div className="flex justify-between items-center h-14">
{/* Logo */}
<Link href="/" className="flex items-center group">
<Link href="/" className="flex items-center">
{logoUrl ? (
<img
src={logoUrl}
alt={storeName}
className="h-9 w-auto object-contain"
className="h-6 w-auto object-contain"
/>
) : (
<span className="text-4xl font-semibold tracking-tighter font-poppins text-foreground group-hover:text-primary transition-colors">
<span className="text-base font-medium tracking-tight text-foreground">
{storeName}
</span>
)}
</Link>
{/* Desktop Navigation */}
<div className="hidden md:flex items-center gap-x-10 text-sm font-medium">
<div className="hidden md:flex items-center gap-x-8 text-sm">
{links.map((link, index) => (
<Link
key={index}
href={link.url}
className="text-foreground hover:text-primary relative after:absolute after:bottom-[-2px] after:left-0 after:h-[2px] after:w-0 after:bg-primary after:transition-all hover:after:w-full"
className="text-foreground relative after:absolute after:bottom-[-4px] after:left-1/2 after:-translate-x-1/2 after:h-px after:w-0 after:bg-foreground after:transition-[width] after:duration-300 hover:after:w-full"
>
{link.label}
</Link>
@@ -81,16 +82,16 @@ const Header: React.FC<HeaderProps> = ({
</div>
{/* Actions */}
<div className="flex items-center gap-x-2">
<div className="flex items-center gap-x-1">
<CartIcon />
{/* Mobile hamburger */}
<button
onClick={() => setMenuOpen(!menuOpen)}
className="md:hidden p-2.5 text-foreground hover:text-primary transition-all rounded-full hover:bg-secondary"
className="md:hidden p-2 text-foreground hover:text-muted-foreground transition-colors"
aria-label="Toggle menu"
>
{menuOpen ? <RiCloseLine size={28} /> : <RiMenu3Line size={28} />}
{menuOpen ? <RiCloseLine size={22} /> : <RiMenu3Line size={22} />}
</button>
</div>
</div>
@@ -98,21 +99,18 @@ const Header: React.FC<HeaderProps> = ({
{/* Mobile Menu */}
{menuOpen && (
<div className="md:hidden border-t bg-white">
<div className="max-w-screen-2xl mx-auto px-8 py-8 flex flex-col gap-y-6 text-lg font-medium">
<div className="md:hidden bg-background">
<div className="max-w-screen-2xl mx-auto px-8 pb-6 flex flex-col gap-y-4 text-sm">
{links.map((link, index) => (
<Link
key={index}
href={link.url}
onClick={() => setMenuOpen(false)}
className="text-foreground hover:text-primary transition-colors py-1"
className="text-foreground hover:text-muted-foreground transition-colors"
>
{link.label}
</Link>
))}
<div className="pt-4 border-t text-xs text-muted-foreground font-mono tracking-widest">
PROFESSIONAL CLEAN MODERN
</div>
</div>
</div>
)}
+35 -98
View File
@@ -1,12 +1,6 @@
'use client';
import React, { useState } from 'react';
import React from 'react';
import Link from 'next/link';
import { useShopifyCart } from '@/hooks/use-shopify-cart';
import { truncate } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Loader } from '@/app/components/ui/loader';
interface ProductImage {
url: string;
@@ -50,13 +44,9 @@ interface Product {
interface ProductCardProps {
product: Product;
onAddToCart?: (product: Product) => void;
}
const ProductCard: React.FC<ProductCardProps> = ({ product, onAddToCart }) => {
const { addItem, openCart } = useShopifyCart();
const [isAdding, setIsAdding] = useState(false);
const ProductCard: React.FC<ProductCardProps> = ({ product }) => {
const firstImage = product.images.edges[0]?.node;
const price = product.priceRange.minVariantPrice;
const compareAtPrice = product.compareAtPriceRange?.minVariantPrice;
@@ -70,109 +60,56 @@ const ProductCard: React.FC<ProductCardProps> = ({ product, onAddToCart }) => {
return `$${parseFloat(amount).toFixed(2)}`;
};
const handleAddToCart = async (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (!firstVariant || !isAvailable || isAdding) return;
try {
setIsAdding(true);
await addItem(firstVariant.id, 1);
openCart();
if (onAddToCart) onAddToCart(product);
} catch (err) {
console.error('Failed to add item to cart:', err);
} finally {
setIsAdding(false);
}
};
return (
<div className="card-modern group bg-card rounded-2xl overflow-hidden h-full flex flex-col">
<Link
href={`/products/${product.handle}`}
className="group block h-full"
>
{/* Product Image */}
<div className="relative aspect-[4/3.1] bg-zinc-100 overflow-hidden">
<Link href={`/products/${product.handle}`} className="block h-full">
{firstImage ? (
<img
src={firstImage.url}
alt={firstImage.altText || product.title}
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-[1.08]"
/>
) : (
<div className="absolute inset-0 flex items-center justify-center text-zinc-300">
<i className="ri-image-line text-8xl"></i>
</div>
)}
</Link>
<div className="relative aspect-square overflow-hidden bg-white">
{firstImage ? (
<img
src={firstImage.url}
alt={firstImage.altText || product.title}
className="w-full h-full object-contain transition-transform duration-500 group-hover:scale-[1.04]"
/>
) : (
<div className="absolute inset-0 flex items-center justify-center text-zinc-300">
<i className="ri-image-line text-8xl"></i>
</div>
)}
{/* Badges */}
{hasDiscount && compareAtPrice && (
<Badge className="absolute top-4 left-4 bg-rose-600 hover:bg-rose-600 text-white text-xs font-medium px-3 py-1 shadow-sm">
<span className="absolute top-0 left-0 text-[11px] font-mono tracking-widest text-rose-600">
SALE
</Badge>
</span>
)}
{!isAvailable && (
<div className="absolute top-4 right-4 bg-white/90 text-xs font-medium px-3 py-1 rounded-full border text-foreground/70">
<span className="absolute top-0 right-0 text-[11px] font-mono tracking-widest text-muted-foreground">
SOLD OUT
</div>
</span>
)}
</div>
{/* Product Info */}
<div className="flex-1 p-6 flex flex-col">
<Link
href={`/products/${product.handle}`}
className="group-hover:text-primary transition-colors"
>
<h3 className="font-heading text-xl font-semibold tracking-tight text-foreground mb-2 line-clamp-2 min-h-[3.2em]">
{truncate(product.title, 65)}
</h3>
</Link>
<div className="flex flex-col flex-1 py-2.5">
<h3 className="text-sm font-medium text-foreground line-clamp-1">
{truncate(product.title, 65)}
</h3>
<div className="mt-auto">
<div className="price-display flex items-baseline gap-x-3 mb-6">
<span className="text-2xl font-semibold text-foreground tracking-tighter">
{formatPrice(price.amount)}
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span className="font-mono tabular-nums tracking-tight text-sm text-foreground">
{formatPrice(price.amount)}
</span>
{hasDiscount && compareAtPrice && (
<span className="font-mono tabular-nums tracking-tight text-sm line-through text-muted-foreground">
{formatPrice(compareAtPrice.amount)}
</span>
{hasDiscount && compareAtPrice && (
<span className="text-base line-through text-muted-foreground">
{formatPrice(compareAtPrice.amount)}
</span>
)}
</div>
<div className="flex gap-3">
<Link href={`/products/${product.handle}`} className="flex-1">
<Button
variant="outline"
className="w-full btn-modern text-sm font-medium"
>
DETAILS
</Button>
</Link>
{isAvailable && (
<Button
onClick={handleAddToCart}
className="flex-1 btn-modern bg-primary hover:bg-primary/90 text-sm font-medium"
disabled={isAdding || !isAvailable}
>
{isAdding ? (
<>
<Loader size={16} className="mr-2" />
ADDING...
</>
) : (
'ADD TO BAG'
)}
</Button>
)}
</div>
)}
</div>
</div>
</div>
</Link>
);
};
+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>
@@ -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>
)}
</>
);
};
@@ -1,8 +1,9 @@
import React from 'react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Loader } from '@/app/components/ui/loader';
import RemixIcon from '@/components/ui/remix-icon';
import { RiSubtractLine, RiAddLine } from '@remixicon/react';
import ShopPayLogo from '@/components/shopify/shop-pay-logo';
import type { ProductOption, ProductOptionValue } from '@/hooks/use-shopify-products';
import { isSwatchOptionName, swatchColorForName } from '@/config/swatches';
interface ProductPrice {
amount: string;
@@ -20,12 +21,6 @@ interface ProductVariant {
}>;
}
interface ProductOption {
id: string;
name: string;
values: string[];
}
interface Product {
id: string;
title: string;
@@ -53,12 +48,29 @@ interface ProductDetailInfoProps {
quantity: number;
setQuantity: (quantity: number) => void;
handleAddToCart: () => void;
handleBuyNow?: () => void;
onOptionChange: (optionName: string, value: string) => void;
loading?: boolean;
buyingNow?: boolean;
addToCartLabel?: string;
features?: ProductFeature[];
}
// A swatch comes from the option value's own swatch (colour or image) or the
// colour its name implies (see config/swatches) — never a variant photo.
const swatchStyle = (
value: ProductOptionValue
): { background?: string; image?: string } => {
if (value.swatch?.color) return { background: value.swatch.color };
const swatchImage = value.swatch?.image?.previewImage?.url;
if (swatchImage) return { image: swatchImage };
const namedColor = swatchColorForName(value.name);
if (namedColor) return { background: namedColor };
return {};
};
const ProductDetailInfo: React.FC<ProductDetailInfoProps> = ({
product,
selectedVariant,
@@ -66,14 +78,11 @@ const ProductDetailInfo: React.FC<ProductDetailInfoProps> = ({
quantity,
setQuantity,
handleAddToCart,
handleBuyNow,
onOptionChange,
loading = false,
buyingNow = false,
addToCartLabel = 'Add to Cart',
features = [
{ icon: 'RiTruckLine', label: 'Free shipping on orders over $100' },
{ icon: 'RiArrowGoBackLine', label: '30-day return policy' },
{ icon: 'RiSecurePaymentLine', label: 'Secure payment' },
],
}) => {
const formatPrice = (amount: string) => {
return `$${parseFloat(amount).toFixed(2)}`;
@@ -84,48 +93,152 @@ const ProductDetailInfo: React.FC<ProductDetailInfoProps> = ({
const hasDiscount =
compareAtPrice &&
parseFloat(compareAtPrice.amount) > parseFloat(price.amount);
const isAvailable = selectedVariant?.availableForSale ?? false;
const isSwatchOption = (option: ProductOption) =>
isSwatchOptionName(option.name);
// Some products return Size before Color; show the swatches first either way.
const orderedOptions = [...(product.options ?? [])].sort(
(a, b) => Number(isSwatchOption(b)) - Number(isSwatchOption(a))
);
// `optionValues` carries the swatch data; fall back to plain `values`.
const optionValuesFor = (option: ProductOption): ProductOptionValue[] =>
option.optionValues?.length
? option.optionValues
: option.values.map((value) => ({ id: value, name: value }));
return (
<div className="pt-2">
<div className="mb-2">
<div className="uppercase tracking-[3px] text-xs font-mono text-muted-foreground mb-1">
LUMINA COLLECTION
</div>
<h1 className="text-5xl md:text-6xl font-bold tracking-tighter font-heading text-foreground leading-none mb-6">
{product.title}
</h1>
</div>
{/* Price */}
<div className="flex items-center gap-x-4 mb-10">
<span className="text-4xl font-semibold tracking-tighter text-foreground price-display">
<div>
<h1 className="text-2xl md:text-3xl font-normal text-foreground">
{product.title}
</h1>
<div className="flex items-baseline gap-x-3 mt-1">
<span className="font-mono tabular-nums tracking-tight text-base text-foreground">
{formatPrice(price.amount)}
</span>
{hasDiscount && compareAtPrice && (
<>
<span className="text-2xl text-muted-foreground line-through tracking-tight">
{formatPrice(compareAtPrice.amount)}
</span>
<Badge
variant="destructive"
className="text-xs font-mono px-4 py-1"
>
SAVE{' '}
{Math.round(
((parseFloat(compareAtPrice.amount) -
parseFloat(price.amount)) /
parseFloat(compareAtPrice.amount)) *
100
)}
%
</Badge>
</>
<span className="font-mono tabular-nums tracking-tight text-sm line-through text-muted-foreground">
{formatPrice(compareAtPrice.amount)}
</span>
)}
</div>
{/* Product Options — colour swatches lead, whatever order the API returns */}
{orderedOptions.map((option) => {
const isSwatch = isSwatchOption(option);
const selected = selectedOptions[option.name];
return (
<div key={option.id} className="mt-8">
<div className="text-sm text-muted-foreground mb-2">
{option.name}
{isSwatch && selected && (
<span className="text-foreground font-medium">: {selected}</span>
)}
</div>
<div className="flex flex-wrap gap-2">
{optionValuesFor(option).map((value) => {
const isSelected = selected === value.name;
if (isSwatch) {
const { background, image } = swatchStyle(value);
return (
<button
key={value.id}
onClick={() => onOptionChange(option.name, value.name)}
title={value.name}
aria-label={value.name}
aria-pressed={isSelected}
className={`h-8 w-8 rounded-full bg-cover bg-center transition-shadow ${
isSelected
? 'ring-2 ring-foreground ring-offset-2'
: 'ring-1 ring-border hover:ring-foreground/40'
}`}
style={{
backgroundColor: background,
backgroundImage: image ? `url(${image})` : undefined,
}}
>
{!background && !image && (
<span className="text-[10px]">{value.name.at(0)}</span>
)}
</button>
);
}
return (
<button
key={value.id}
onClick={() => onOptionChange(option.name, value.name)}
aria-pressed={isSelected}
className={`min-w-14 px-5 py-2 rounded-md border text-sm text-center transition-colors ${
isSelected
? 'border-foreground text-foreground'
: 'border-border text-muted-foreground hover:border-foreground hover:text-foreground'
}`}
>
{value.name}
</button>
);
})}
</div>
</div>
);
})}
{/* Quantity + Add to Cart */}
<div className="mt-8 flex items-stretch gap-3">
<div className="flex items-center rounded-md border border-border h-11">
<button
onClick={() => setQuantity(Math.max(1, quantity - 1))}
disabled={quantity <= 1}
aria-label="Decrease quantity"
className="h-full px-3 text-foreground disabled:text-muted-foreground/50 transition-colors"
>
<RiSubtractLine size={16} />
</button>
<span className="w-8 text-center text-sm tabular-nums">
{quantity}
</span>
<button
onClick={() => setQuantity(quantity + 1)}
aria-label="Increase quantity"
className="h-full px-3 text-foreground transition-colors"
>
<RiAddLine size={16} />
</button>
</div>
<button
onClick={handleAddToCart}
disabled={!isAvailable || loading}
className="flex-1 h-11 rounded-md bg-foreground text-background text-sm font-medium hover:bg-foreground/90 disabled:opacity-50 transition-colors flex items-center justify-center gap-x-2"
>
{loading && <Loader size={16} />}
{isAvailable ? addToCartLabel : 'Out of Stock'}
</button>
</div>
{/* Sends the shopper to the Shopify checkout, where Shop Pay is offered. */}
{handleBuyNow && (
<button
type="button"
onClick={handleBuyNow}
disabled={!isAvailable || buyingNow}
className="mt-3 flex h-11 w-full cursor-pointer items-center justify-center rounded-md bg-shop px-4 text-white transition-colors hover:bg-shop/85 disabled:cursor-not-allowed disabled:opacity-50"
>
<span className="sr-only">Buy with</span>
{buyingNow ? <Loader size={16} /> : <ShopPayLogo />}
</button>
)}
{/* Description */}
{product.description && (
<div className="prose text-muted-foreground mb-10 text-[15px] leading-relaxed max-w-prose">
{(product.descriptionHtml || product.description) && (
<div className="mt-10 text-sm leading-6 text-foreground product-description">
{product.descriptionHtml ? (
<div
dangerouslySetInnerHTML={{ __html: product.descriptionHtml }}
@@ -135,104 +248,6 @@ const ProductDetailInfo: React.FC<ProductDetailInfoProps> = ({
)}
</div>
)}
{/* Product Options */}
{product.options &&
product.options.map((option) => (
<div key={option.id} className="mb-8">
<div className="text-xs uppercase font-mono tracking-widest text-muted-foreground mb-3">
{option.name}
</div>
<div className="flex flex-wrap gap-2">
{option.values.map((value) => (
<Button
key={value}
onClick={() => onOptionChange(option.name, value)}
variant={
selectedOptions[option.name] === value
? 'default'
: 'outline'
}
className={`rounded-2xl px-6 py-2.5 text-sm font-medium transition-all ${
selectedOptions[option.name] === value ? 'shadow-md' : ''
}`}
>
{value}
</Button>
))}
</div>
</div>
))}
{/* Quantity Selector */}
<div className="mb-8">
<div className="text-xs uppercase font-mono tracking-widest text-muted-foreground mb-3">
QUANTITY
</div>
<div className="inline-flex items-center border border-border rounded-2xl">
<Button
onClick={() => setQuantity(Math.max(1, quantity - 1))}
variant="ghost"
size="icon-sm"
className="rounded-l-2xl h-12 w-12"
disabled={quantity <= 1}
>
</Button>
<div className="px-8 font-mono text-lg font-semibold tabular-nums">
{quantity}
</div>
<Button
onClick={() => setQuantity(quantity + 1)}
variant="ghost"
size="icon-sm"
className="rounded-r-2xl h-12 w-12"
>
+
</Button>
</div>
</div>
{/* Add to Cart Button */}
<Button
onClick={handleAddToCart}
disabled={!selectedVariant?.availableForSale || loading}
size="lg"
className="w-full h-16 text-base font-medium tracking-wider rounded-3xl btn-modern"
>
{loading ? (
<>
<Loader size={18} className="mr-3" />
ADDING TO BAG...
</>
) : selectedVariant?.availableForSale ? (
String(addToCartLabel ?? 'Add to Cart').toUpperCase()
) : (
'OUT OF STOCK'
)}
</Button>
{/* Features */}
{features.length > 0 && (
<div className="mt-12 pt-10 border-t border-border">
<div className="space-y-6">
{features.map((feature, index) => (
<div key={index} className="flex gap-x-4 text-sm">
<div className="mt-0.5 text-primary">
<RemixIcon name={feature.icon} size={18} />
</div>
<div className="text-muted-foreground leading-snug">
{feature.label}
</div>
</div>
))}
</div>
</div>
)}
<div className="mt-12 text-[10px] font-mono text-center text-muted-foreground tracking-widest">
FREE SHIPPING EASY RETURNS SECURE CHECKOUT
</div>
</div>
);
};
+30 -33
View File
@@ -11,11 +11,13 @@ import ProductCard from './product-card';
interface ProductRecommendationsProps {
productId?: string;
title?: string;
limit?: number;
}
const ProductRecommendations: React.FC<ProductRecommendationsProps> = ({
productId: productIdProp,
title = 'You Might Also Like These Products 2',
title = 'You May Also Like',
limit = 4,
}) => {
const params = useParams();
const handle = params?.handle as string | undefined;
@@ -31,45 +33,40 @@ const ProductRecommendations: React.FC<ProductRecommendationsProps> = ({
}
return (
<div className="bg-gray-50 py-16">
<div className="container mx-auto px-4">
<h2 className="text-4xl font-bold text-center mb-12 text-gray-900 font-heading">
<section className="bg-background py-16">
<div className="max-w-screen-2xl mx-auto px-8">
<h2 className="text-2xl md:text-3xl font-normal text-foreground mb-8">
{title}
</h2>
{loading ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-8">
{Array.from({ length: 4 }).map((_, index) => (
<div
key={index}
className="bg-white rounded-lg shadow-md overflow-hidden animate-pulse"
>
<div className="aspect-square bg-gray-200"></div>
<div className="p-6">
<div className="h-6 bg-gray-200 rounded mb-2"></div>
<div className="h-4 bg-gray-200 rounded mb-4"></div>
<div className="h-8 bg-gray-200 rounded mb-4"></div>
<div className="h-12 bg-gray-200 rounded"></div>
</div>
</div>
))}
</div>
) : error ? (
<div className="text-center py-8">
<p className="text-gray-500">Recommendations could not be loaded</p>
</div>
{error ? (
<p className="text-sm text-muted-foreground">
Recommendations could not be loaded
</p>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-8">
{recommendations.slice(0, 4).map((recommendedProduct) => (
<ProductCard
key={recommendedProduct.id}
product={recommendedProduct}
/>
))}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-x-8 gap-y-12">
{loading
? Array.from({ length: limit }).map((_, index) => (
<div key={index} className="animate-pulse">
<div className="aspect-square bg-zinc-100"></div>
<div className="pt-4 space-y-2">
<div className="h-4 bg-zinc-200 w-4/5"></div>
<div className="h-4 bg-zinc-200 w-1/4"></div>
</div>
</div>
))
: recommendations
.slice(0, limit)
.map((recommendedProduct) => (
<ProductCard
key={recommendedProduct.id}
product={recommendedProduct}
/>
))}
</div>
)}
</div>
</div>
</section>
);
};
+19 -40
View File
@@ -48,12 +48,14 @@ interface Product {
interface ProductsProps {
title?: string;
subtitle?: string;
limit?: number;
showLoadMore?: boolean;
}
const Products: React.FC<ProductsProps> = ({
title = 'Our Products',
title = 'Shopify Hydrogen Storefront',
subtitle = 'An agent-friendly Shopify storefront built with Next.js and Hydrogen.',
limit = 12,
showLoadMore = true,
}) => {
@@ -109,10 +111,6 @@ const Products: React.FC<ProductsProps> = ({
fetchProducts();
}, [limit]);
const handleAddToCart = async (product: Product) => {
console.log('Adding to cart:', product);
};
const handleLoadMore = () => {
if (!loadingMore && hasMoreProducts) {
fetchProducts(products, true);
@@ -123,32 +121,20 @@ const Products: React.FC<ProductsProps> = ({
return (
<div className="py-20">
<div className="max-w-screen-2xl mx-auto px-8">
<div className="flex justify-center mb-6">
<div className="inline px-6 py-2 bg-white border rounded-3xl text-xs font-mono tracking-widest text-muted-foreground">
DISCOVER
</div>
</div>
<h2 className="text-center text-6xl font-bold tracking-tighter font-heading mb-5 text-foreground">
<h2 className="text-center text-2xl md:text-3xl font-normal max-w-3xl mx-auto text-foreground">
{title}
</h2>
<p className="text-center text-muted-foreground max-w-md mx-auto mb-16">
Loading our finest selection...
<p className="text-center text-sm font-normal max-w-xl mx-auto text-muted-foreground mt-2.5 mb-16">
{subtitle}
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-8">
{Array.from({ length: 8 }).map((_, index) => (
<div
key={index}
className="bg-white rounded-3xl overflow-hidden animate-pulse border border-border h-[460px]"
>
<div className="h-[280px] bg-zinc-100"></div>
<div className="p-8 space-y-6">
<div className="h-6 bg-zinc-200 rounded-full w-4/5"></div>
<div className="h-4 bg-zinc-200 rounded w-1/3"></div>
<div className="pt-4 flex gap-3">
<div className="h-11 flex-1 bg-zinc-200 rounded-2xl"></div>
<div className="h-11 flex-1 bg-zinc-200 rounded-2xl"></div>
</div>
<div key={index} className="animate-pulse">
<div className="aspect-square bg-zinc-100"></div>
<div className="pt-4 space-y-2">
<div className="h-4 bg-zinc-200 w-4/5"></div>
<div className="h-4 bg-zinc-200 w-1/4"></div>
</div>
</div>
))}
@@ -162,9 +148,12 @@ const Products: React.FC<ProductsProps> = ({
return (
<div className="py-20 bg-zinc-50">
<div className="max-w-screen-2xl mx-auto px-8 text-center">
<h2 className="text-6xl font-bold tracking-tighter font-heading mb-8 text-foreground">
<h2 className="text-2xl md:text-3xl font-normal max-w-3xl mx-auto text-foreground">
{title}
</h2>
<p className="text-sm font-normal max-w-xl mx-auto text-muted-foreground mt-2.5 mb-12">
{subtitle}
</p>
<div className="mx-auto max-w-sm bg-white border border-border rounded-3xl p-12">
<i className="ri-inbox-line text-6xl text-muted-foreground mb-6 block"></i>
<h3 className="font-semibold text-2xl mb-3 tracking-tight">
@@ -188,26 +177,16 @@ const Products: React.FC<ProductsProps> = ({
return (
<div className="py-20 bg-white">
<div className="max-w-screen-2xl mx-auto px-8">
<div className="flex justify-center mb-6">
<div className="inline px-6 py-2 bg-zinc-100 rounded-3xl text-xs font-mono tracking-[1.5px] text-muted-foreground">
CURATED SELECTION
</div>
</div>
<h2 className="text-center text-6xl font-bold tracking-tighter font-heading mb-5 text-foreground">
<h2 className="text-center text-2xl md:text-3xl font-normal max-w-3xl mx-auto text-foreground">
{title}
</h2>
<p className="text-center text-muted-foreground max-w-md mx-auto mb-16 text-lg">
Beautifully designed objects for everyday life
<p className="text-center text-sm font-normal max-w-xl mx-auto text-muted-foreground mt-2.5 mb-16">
{subtitle}
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-x-8 gap-y-16 mb-20">
{products.map((product) => (
<ProductCard
key={product.id}
product={product}
onAddToCart={handleAddToCart}
/>
<ProductCard key={product.id} product={product} />
))}
</div>
+150
View File
@@ -0,0 +1,150 @@
'use client';
import React, { useEffect, useRef, useState } from 'react';
import { SHOPIFY_STORE_DOMAIN } from '@/services/shopify/client';
const SHOP_JS_URL =
'https://cdn.shopify.com/shopifycloud/shop-js/modules/v2/loader.pay-button.esm.js';
const TAG_NAME = 'shop-pay-button';
// The hosted element renders at ~43px; reserve it so nothing shifts while shop-js loads.
const MIN_HEIGHT = '43px';
const LOAD_TIMEOUT_MS = 10000;
export interface ShopPayVariant {
id: string;
quantity?: number;
}
interface ShopPayButtonProps {
variants: ShopPayVariant[];
disabled?: boolean;
className?: string;
width?: string;
borderRadius?: string;
fallback?: React.ReactNode;
}
let shopJsPromise: Promise<void> | null = null;
function loadShopJs(): Promise<void> {
if (shopJsPromise) return shopJsPromise;
shopJsPromise = new Promise<void>((resolve, reject) => {
if (!document.querySelector(`script[src="${SHOP_JS_URL}"]`)) {
const script = document.createElement('script');
script.src = SHOP_JS_URL;
script.type = 'module';
script.addEventListener('error', () =>
reject(new Error('Failed to load the Shop Pay script.'))
);
document.head.appendChild(script);
}
// Element registration, not script load, is the real readiness signal.
const timeout = setTimeout(
() => reject(new Error('Timed out waiting for the Shop Pay button.')),
LOAD_TIMEOUT_MS
);
customElements.whenDefined(TAG_NAME).then(() => {
clearTimeout(timeout);
resolve();
}, reject);
});
return shopJsPromise;
}
// Storefront API IDs arrive as GIDs; the web component wants the bare numeric ID.
function toNumericVariantId(id: string): string | null {
const trimmed = id.trim();
const gid = trimmed.match(/^gid:\/\/shopify\/ProductVariant\/(\d+)/);
if (gid) return gid[1];
return /^\d+$/.test(trimmed) ? trimmed : null;
}
function toVariantsAttribute(variants: ShopPayVariant[]): string | null {
if (variants.length === 0) return null;
const parts: string[] = [];
for (const { id, quantity = 1 } of variants) {
const numericId = toNumericVariantId(id);
if (!numericId || !Number.isInteger(quantity) || quantity < 1) return null;
parts.push(`${numericId}:${quantity}`);
}
return parts.join(',');
}
function toStoreUrl(domain?: string): string | null {
if (!domain) return null;
try {
return new URL(domain.startsWith('http') ? domain : `https://${domain}`)
.origin;
} catch {
return null;
}
}
const ShopPayButton: React.FC<ShopPayButtonProps> = ({
variants,
disabled = false,
className,
width = '100%',
borderRadius = '8px',
fallback = null,
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const [status, setStatus] = useState<'loading' | 'ready' | 'error'>(
'loading'
);
const storeUrl = toStoreUrl(SHOPIFY_STORE_DOMAIN);
const variantsAttribute = toVariantsAttribute(variants);
useEffect(() => {
if (!storeUrl || !variantsAttribute) return;
let cancelled = false;
loadShopJs().then(
() => !cancelled && setStatus('ready'),
() => !cancelled && setStatus('error')
);
return () => {
cancelled = true;
};
}, [storeUrl, variantsAttribute]);
useEffect(() => {
const container = containerRef.current;
if (status !== 'ready' || !container || !storeUrl || !variantsAttribute) {
return;
}
const button = document.createElement(TAG_NAME);
button.setAttribute('store-url', storeUrl);
button.setAttribute('variants', variantsAttribute);
button.setAttribute('channel', 'headless');
if (disabled) button.setAttribute('disabled', '');
button.style.setProperty('--shop-pay-button-width', width);
button.style.setProperty('--shop-pay-button-border-radius', borderRadius);
container.appendChild(button);
return () => {
button.remove();
};
}, [status, storeUrl, variantsAttribute, disabled, width, borderRadius]);
if (!storeUrl || !variantsAttribute || status === 'error') {
return <>{fallback}</>;
}
return (
<div
ref={containerRef}
className={className}
style={{ minHeight: MIN_HEIGHT }}
/>
);
};
export default ShopPayButton;
+50
View File
@@ -0,0 +1,50 @@
import React from 'react';
// "Buy with shop" lockup — the wordmark and the Shop mark are both in the path
// data, so the button needs no additional text beyond a screen-reader label.
const ShopPayLogo: React.FC<{ className?: string }> = ({
className = 'h-auto w-[98px]',
}) => (
<svg
fill="none"
viewBox="0 0 10885 2079"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
className={className}
>
<path
d="M158.355 1621V448.811H637.207C856.681 448.811 994.683 565.198 994.683 748.093C994.683 874.457 923.188 967.567 800.15 1004.15V1010.8C943.14 1039.06 1024.61 1145.47 1024.61 1296.78C1024.61 1494.64 884.946 1621 665.473 1621H158.355ZM630.556 1459.72C745.281 1459.72 813.451 1391.55 813.451 1278.49C813.451 1162.1 743.619 1093.93 630.556 1093.93H362.865V1459.72H630.556ZM605.616 939.301C713.69 939.301 781.86 874.457 781.86 774.696C781.86 671.61 713.69 610.091 605.616 610.091H362.865V939.301H605.616ZM1486.02 1645.94C1328.06 1645.94 1200.04 1547.84 1200.04 1323.38V764.72H1394.57V1290.13C1394.57 1411.5 1456.09 1479.67 1557.51 1479.67C1675.56 1479.67 1742.07 1394.88 1742.07 1268.51V764.72H1938.27V1621H1750.38V1501.29H1742.07C1693.85 1599.39 1604.07 1645.94 1486.02 1645.94ZM2229.79 1895.34L2392.74 1537.87L2053.55 764.72H2271.36L2430.98 1167.09C2455.92 1233.6 2474.21 1288.46 2492.5 1356.63H2499.15C2515.78 1290.13 2534.06 1231.93 2557.34 1167.09L2716.96 764.72H2934.77L2445.94 1895.34H2229.79ZM3535.97 1621L3266.62 764.72H3472.79L3585.85 1185.38C3607.47 1266.85 3624.09 1345 3639.06 1429.79H3645.71C3662.34 1343.33 3677.3 1271.84 3702.24 1185.38L3820.29 764.72H4021.47L4139.53 1185.38C4164.47 1273.5 4181.09 1348.32 4196.06 1429.79H4204.37C4217.67 1346.66 4234.3 1270.17 4255.91 1185.38L4368.97 764.72H4576.81L4305.79 1621H4104.61L3984.9 1195.35C3958.29 1102.24 3941.67 1029.09 3925.04 942.627H3918.39C3900.1 1030.75 3883.47 1103.91 3856.87 1195.35L3738.82 1621H3535.97ZM4696.08 1621V764.72H4890.61V1621H4696.08ZM4794.18 633.368C4724.35 633.368 4672.8 578.5 4672.8 510.33C4672.8 442.16 4726.01 388.954 4794.18 388.954C4864.01 388.954 4917.22 442.16 4917.22 510.33C4917.22 580.162 4864.01 633.368 4794.18 633.368ZM5389.5 1637.63C5249.83 1637.63 5163.37 1572.78 5163.37 1426.47V926H5025.37V776.359H5111.83C5160.05 776.359 5176.67 758.069 5176.67 709.851V560.21H5359.57V764.72H5520.85V926H5359.57V1363.28C5359.57 1433.12 5384.51 1461.38 5441.04 1461.38C5465.98 1461.38 5489.26 1458.06 5520.85 1451.41V1616.01C5474.29 1630.98 5437.71 1637.63 5389.5 1637.63ZM5694.66 1621V418.883H5890.86V877.782H5897.51C5947.39 784.672 6040.5 739.78 6153.56 739.78C6316.51 739.78 6444.53 837.878 6444.53 1062.34V1621H6248.34V1095.59C6248.34 974.218 6186.82 906.048 6080.4 906.048C5959.03 906.048 5889.2 990.844 5889.2 1117.21V1621H5694.66Z"
fill="currentColor"
/>
<g clipPath="url(#shop-pay-logo-clip)">
<path
d="M7406 1027.33C7247.3 992.071 7176.6 978.274 7176.6 915.639C7176.6 856.727 7224.44 827.38 7320.13 827.38C7404.29 827.38 7465.8 865.049 7511.08 938.853C7514.5 944.547 7521.55 946.518 7527.32 943.452L7705.88 851.032C7712.29 847.747 7714.64 839.425 7711 833.074C7636.89 701.453 7499.98 629.4 7319.71 629.4C7082.83 629.4 6935.67 748.976 6935.67 939.072C6935.67 1140.99 7114.87 1192.02 7273.78 1227.28C7432.7 1262.54 7503.61 1276.34 7503.61 1338.97C7503.61 1401.61 7451.92 1431.17 7348.75 1431.17C7253.49 1431.17 7182.79 1386.5 7140.08 1299.77C7136.87 1293.42 7129.4 1290.79 7123.2 1294.08L6945.07 1384.53C6938.87 1387.81 6936.31 1395.48 6939.51 1402.05C7010.21 1547.68 7155.24 1629.59 7348.97 1629.59C7595.67 1629.59 7744.75 1511.99 7744.75 1315.98C7744.75 1119.97 7564.69 1063.03 7406 1027.77V1027.33Z"
fill="currentColor"
/>
<path
d="M8362.88 629.4C8261.64 629.4 8172.15 666.193 8107.86 731.675C8103.8 735.617 8097.18 732.77 8097.18 727.076V308.997C8097.18 301.77 8091.62 296.076 8084.57 296.076H7861.16C7854.11 296.076 7848.56 301.77 7848.56 308.997V1606.6C7848.56 1613.82 7854.11 1619.52 7861.16 1619.52H8084.57C8091.62 1619.52 8097.18 1613.82 8097.18 1606.6V1037.41C8097.18 927.465 8179.41 843.148 8290.26 843.148C8401.12 843.148 8481.43 925.713 8481.43 1037.41V1606.6C8481.43 1613.82 8486.98 1619.52 8494.03 1619.52H8717.44C8724.49 1619.52 8730.05 1613.82 8730.05 1606.6V1037.41C8730.05 798.253 8577.11 629.4 8362.88 629.4Z"
fill="currentColor"
/>
<path
d="M9183.28 592.17C9061.97 592.17 8948.34 630.276 8866.74 685.246C8861.19 688.969 8859.27 696.634 8862.69 702.548L8961.15 874.904C8964.78 881.036 8972.47 883.226 8978.45 879.503C9040.39 841.177 9111.3 821.248 9183.71 821.686C9378.72 821.686 9522.04 962.725 9522.04 1149.1C9522.04 1307.88 9407.34 1425.48 9261.89 1425.48C9143.34 1425.48 9061.11 1354.74 9061.11 1254.88C9061.11 1197.72 9084.82 1150.85 9146.55 1117.78C9152.95 1114.28 9155.3 1106.17 9151.46 1099.82L9058.55 938.634C9055.56 933.378 9049.15 930.969 9043.38 933.159C8918.86 980.464 8831.5 1094.35 8831.5 1247.21C8831.5 1478.48 9011.13 1651.05 9261.67 1651.05C9554.29 1651.05 9764.68 1443.22 9764.68 1145.16C9764.68 825.628 9519.9 592.17 9183.28 592.17Z"
fill="currentColor"
/>
<path
d="M10418.3 627.429C10305.3 627.429 10204.2 670.354 10130.6 745.692C10126.5 749.853 10119.9 746.787 10119.9 741.092V650.425C10119.9 643.198 10114.3 637.504 10107.3 637.504H9889.63C9882.58 637.504 9877.03 643.198 9877.03 650.425V1946.05C9877.03 1953.28 9882.58 1958.97 9889.63 1958.97H10113C10120.1 1958.97 10125.6 1953.28 10125.6 1946.05V1521.19C10125.6 1515.49 10132.3 1512.64 10136.3 1516.37C10209.8 1586.45 10307 1627.4 10418.3 1627.4C10680.3 1627.4 10884.7 1409.93 10884.7 1127.42C10884.7 844.9 10680.1 627.429 10418.3 627.429ZM10376 1407.96C10226.9 1407.96 10113.9 1286.41 10113.9 1125.66C10113.9 964.915 10226.7 843.367 10376 843.367C10525.3 843.367 10637.8 962.944 10637.8 1125.66C10637.8 1288.38 10526.8 1407.96 10375.8 1407.96H10376Z"
fill="currentColor"
/>
</g>
<defs>
<clipPath id="shop-pay-logo-clip">
<rect
width="3948.86"
height="1662.68"
fill="white"
transform="translate(6935.67 296.076)"
/>
</clipPath>
</defs>
</svg>
);
export default ShopPayLogo;