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
+2
View File
@@ -3,3 +3,5 @@ node_modules/*
.next .next
next-env.d.ts next-env.d.ts
.yarn/install-state.gz .yarn/install-state.gz
.env*.local
tsconfig.tsbuildinfo
-3
View File
@@ -16,10 +16,7 @@ export default function Page() {
<CollectionDetail /> <CollectionDetail />
<Footer <Footer
storeName="Stride" storeName="Stride"
logoUrl=""
tagline="Your premium shopping destination"
copyright="© 2026 Stride. All rights reserved." copyright="© 2026 Stride. All rights reserved."
links={[]}
/> />
</> </>
); );
-3
View File
@@ -16,10 +16,7 @@ export default function Page() {
<Collections title="Our Collections" /> <Collections title="Our Collections" />
<Footer <Footer
storeName="Stride" storeName="Stride"
logoUrl=""
tagline="Your premium shopping destination"
copyright="© 2026 Stride. All rights reserved." copyright="© 2026 Stride. All rights reserved."
links={[]}
/> />
</> </>
); );
+70 -7
View File
@@ -33,6 +33,9 @@
--color-destructive: hsl(0 84.2% 60.2%); --color-destructive: hsl(0 84.2% 60.2%);
--color-destructive-foreground: hsl(0 0% 98%); --color-destructive-foreground: hsl(0 0% 98%);
/* Shop (accelerated checkout) purple */
--color-shop: #5a31f4;
/* Border */ /* Border */
--color-border: hsl(240 5.9% 90%); --color-border: hsl(240 5.9% 90%);
--color-input: hsl(240 5.9% 90%); --color-input: hsl(240 5.9% 90%);
@@ -44,10 +47,12 @@
--radius-lg: 1rem; --radius-lg: 1rem;
--radius-xl: 1.25rem; --radius-xl: 1.25rem;
/* Enhanced typography scale */ /* Typography — Geist Sans / Geist Mono */
--font-heading: 'Space Grotesk', system-ui, sans-serif; --font-sans: var(--font-geist-sans), system-ui, sans-serif;
--font-body: 'Inter', system-ui, sans-serif; --font-mono: var(--font-geist-mono), ui-monospace, SFMono-Regular, monospace;
--font-poppins: 'Poppins', system-ui, sans-serif; --font-heading: var(--font-geist-sans), system-ui, sans-serif;
--font-body: var(--font-geist-sans), system-ui, sans-serif;
--font-poppins: var(--font-geist-sans), system-ui, sans-serif;
} }
/* Base styles for light, modern Shopify storefront */ /* Base styles for light, modern Shopify storefront */
@@ -71,12 +76,70 @@ body {
border-color: hsl(240 5.9% 85%); border-color: hsl(240 5.9% 85%);
} }
/* Bold heading styles */ /* Heading styles — regular weight, slightly tightened like the Geist reference */
h1, h1,
h2, h2,
.font-heading { .font-heading {
font-weight: 700; font-weight: 400;
letter-spacing: -0.025em; letter-spacing: -0.02em;
}
/* Swipeable rows (mobile galleries) without a visible scrollbar */
.no-scrollbar {
scrollbar-width: none;
-ms-overflow-style: none;
}
.no-scrollbar::-webkit-scrollbar {
display: none;
}
/* Product description (HTML returned by the Storefront API) */
.product-description p {
margin-bottom: 1rem;
}
.product-description > :last-child {
margin-bottom: 0;
}
/* Shop policy body (HTML returned by the Storefront API) */
.policy-body p,
.policy-body ul,
.policy-body ol {
margin-bottom: 1.5rem;
}
.policy-body ul,
.policy-body ol {
padding-left: 1.25rem;
list-style: revert;
}
.policy-body li {
margin-bottom: 0.5rem;
}
.policy-body h2,
.policy-body h3,
.policy-body h4 {
margin-top: 2.5rem;
margin-bottom: 1rem;
font-size: 1.25rem;
font-weight: 500;
}
.policy-body a {
text-decoration: underline;
text-underline-offset: 2px;
}
.policy-body strong {
font-weight: 500;
}
.policy-body > :last-child {
margin-bottom: 0;
} }
/* Enhanced button styles */ /* Enhanced button styles */
+6 -17
View File
@@ -2,24 +2,16 @@
import React from 'react'; import React from 'react';
import './globals.css'; import './globals.css';
import { Space_Grotesk, Inter, Poppins } from 'next/font/google'; import { Geist, Geist_Mono } from 'next/font/google';
const poppins = Poppins({ const geist = Geist({
subsets: ['latin'], subsets: ['latin'],
weight: ['400', '500', '600', '700'], variable: '--font-geist-sans',
variable: '--font-poppins',
}); });
const spaceGrotesk = Space_Grotesk({ const geistMono = Geist_Mono({
subsets: ['latin'], subsets: ['latin'],
weight: ['400', '500', '600', '700'], variable: '--font-geist-mono',
variable: '--font-heading',
});
const inter = Inter({
subsets: ['latin'],
weight: ['400', '500', '600'],
variable: '--font-body',
}); });
export default function RootLayout({ export default function RootLayout({
@@ -28,10 +20,7 @@ export default function RootLayout({
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( return (
<html <html lang="en" className={`${geist.variable} ${geistMono.variable}`}>
lang="en"
className={`${poppins.variable} ${spaceGrotesk.variable} ${inter.variable}`}
>
<body className="font-body antialiased bg-background text-foreground m-0 p-0"> <body className="font-body antialiased bg-background text-foreground m-0 p-0">
{children} {children}
</body> </body>
+4 -4
View File
@@ -13,13 +13,13 @@ export default function Page() {
{ label: 'Collections', url: '/collections' }, { label: 'Collections', url: '/collections' },
]} ]}
/> />
<Products title="Shop All" /> <Products
title="Shopify Hydrogen Storefront"
subtitle="An agent-friendly Shopify storefront built with Next.js and Hydrogen."
/>
<Footer <Footer
storeName="Stride" storeName="Stride"
logoUrl=""
tagline="Your premium shopping destination"
copyright="© 2026 Stride. All rights reserved." copyright="© 2026 Stride. All rights reserved."
links={[]}
/> />
</> </>
); );
+65
View File
@@ -0,0 +1,65 @@
import { notFound } from 'next/navigation';
import Header from '@/components/shopify/header';
import Footer from '@/components/shopify/footer';
import { getShopPolicy, POLICY_HANDLES } from '@/hooks/use-shopify-policies';
export function generateStaticParams() {
return POLICY_HANDLES.map((handle) => ({ handle }));
}
export async function generateMetadata({
params,
}: {
params: Promise<{ handle: string }>;
}) {
const { handle } = await params;
const policy = await getShopPolicy(handle);
return {
title: policy ? `${policy.title} — Stride` : 'Policy — Stride',
};
}
export default async function PolicyPage({
params,
}: {
params: Promise<{ handle: string }>;
}) {
const { handle } = await params;
const policy = await getShopPolicy(handle);
if (!policy) {
notFound();
}
return (
<>
<Header
storeName="Stride"
logoUrl=""
links={[
{ label: 'Products', url: '/' },
{ label: 'Collections', url: '/collections' },
]}
/>
<main className="max-w-screen-2xl mx-auto w-full px-5 lg:px-10 py-16">
<div className="max-w-2xl mx-auto">
<h1 className="text-3xl md:text-4xl font-normal text-foreground">
{policy.title}
</h1>
<div
className="policy-body mt-8 text-[15px] leading-7 text-foreground"
dangerouslySetInnerHTML={{ __html: policy.body }}
/>
</div>
</main>
<Footer
storeName="Stride"
copyright="© 2026 Stride. All rights reserved."
/>
</>
);
}
+2 -12
View File
@@ -14,21 +14,11 @@ export default function Page() {
{ label: 'Collections', url: '/collections' }, { label: 'Collections', url: '/collections' },
]} ]}
/> />
<ProductDetail <ProductDetail addToCartLabel="Add to Cart" />
addToCartLabel="Add to Cart" <ProductRecommendations title="You May Also Like" />
features={[
{ icon: 'RiTruckLine', label: 'Free shipping on orders over $100' },
{ icon: 'RiArrowGoBackLine', label: '30-day return policy' },
{ icon: 'RiSecurePaymentLine', label: 'Secure payment' },
]}
/>
<ProductRecommendations title="You Might Also Like" />
<Footer <Footer
storeName="Stride" storeName="Stride"
logoUrl=""
tagline="Your premium shopping destination"
copyright="© 2026 Stride. All rights reserved." copyright="© 2026 Stride. All rights reserved."
links={[]}
/> />
</> </>
); );
-3
View File
@@ -16,10 +16,7 @@ export default function Page() {
<Collections title="Our Collections" /> <Collections title="Our Collections" />
<Footer <Footer
storeName="Stride" storeName="Stride"
logoUrl=""
tagline="Your premium shopping destination"
copyright="© 2026 Stride. All rights reserved." copyright="© 2026 Stride. All rights reserved."
links={[]}
/> />
</> </>
); );
+5 -3
View File
@@ -17,6 +17,7 @@ import {
RiImageLine, RiImageLine,
RiSubtractLine, RiSubtractLine,
RiAddLine, RiAddLine,
RiShoppingBagLine,
} from '@remixicon/react'; } from '@remixicon/react';
import { import {
Empty, Empty,
@@ -65,8 +66,9 @@ const CartDrawer: React.FC = () => {
{/* Header */} {/* Header */}
<SheetHeader className="h-14 min-h-0 px-4 py-3 flex items-center"> <SheetHeader className="h-14 min-h-0 px-4 py-3 flex items-center">
<div className="flex items-center justify-between w-full"> <div className="flex items-center justify-between w-full">
<SheetTitle className="text-base"> <SheetTitle className="text-base flex items-center gap-x-2">
Shopping Cart ({itemCount}) <RiShoppingBagLine size={18} />
Bag ({itemCount})
</SheetTitle> </SheetTitle>
<Button onClick={closeCart} variant="ghost" size="icon-sm"> <Button onClick={closeCart} variant="ghost" size="icon-sm">
<RiCloseLine size={20} /> <RiCloseLine size={20} />
@@ -83,7 +85,7 @@ const CartDrawer: React.FC = () => {
) : items.length === 0 ? ( ) : items.length === 0 ? (
<Empty> <Empty>
<EmptyHeader> <EmptyHeader>
<EmptyTitle>Your cart is empty</EmptyTitle> <EmptyTitle>Your bag is empty</EmptyTitle>
<EmptyDescription> <EmptyDescription>
Add some products to get started! Add some products to get started!
</EmptyDescription> </EmptyDescription>
+41 -83
View File
@@ -1,4 +1,9 @@
import React from 'react'; import React from 'react';
import {
RiInstagramLine,
RiTiktokLine,
RiFacebookFill,
} from '@remixicon/react';
export interface FooterLink { export interface FooterLink {
label: string; label: string;
@@ -7,110 +12,63 @@ export interface FooterLink {
interface FooterProps { interface FooterProps {
storeName?: string; storeName?: string;
logoUrl?: string;
tagline?: string;
copyright?: string; copyright?: string;
links?: FooterLink[]; links?: FooterLink[];
instagramUrl?: string;
tiktokUrl?: string;
facebookUrl?: string;
} }
const Footer: React.FC<FooterProps> = ({ const Footer: React.FC<FooterProps> = ({
storeName = 'Stride', storeName = 'Shop',
logoUrl, copyright,
tagline = 'Performance in every stride.',
copyright = '© 2026 Stride. All rights reserved.',
links = [ links = [
{ label: 'About', url: '#' }, { label: 'Terms of Service', url: '/policies/terms-of-service' },
{ label: 'Athletes', url: '#' }, { label: 'Privacy Policy', url: '/policies/privacy-policy' },
{ label: 'Technology', url: '#' }, { label: 'Refund Policy', url: '/policies/refund-policy' },
{ label: 'Contact', url: '#' }, { 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 ( return (
<footer className="bg-zinc-50 border-t border-border py-16 text-sm"> <footer className="bg-background">
<div className="max-w-screen-2xl mx-auto px-8"> <div className="max-w-screen-2xl mx-auto px-8 py-10">
<div className="grid grid-cols-1 md:grid-cols-12 gap-y-12"> <div className="flex flex-col sm:flex-row items-center justify-between gap-5">
{/* Brand Column */} <div className="flex flex-wrap items-center justify-center sm:justify-start gap-x-5 gap-y-2">
<div className="md:col-span-5"> <p className="text-sm text-muted-foreground leading-5">
<div className="flex items-baseline mb-4"> {copyright || `© ${storeName}. All rights reserved.`}
{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}
</p> </p>
{links.map((link) => (
<div className="mt-8 text-xs font-mono text-muted-foreground tracking-[1px]">
ENGINEERED FOR MOTION
</div>
</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 <a
key={index} key={link.label}
href={link.url} href={link.url}
className="hover:text-foreground transition-colors w-fit" className="text-sm text-muted-foreground hover:text-foreground transition-colors"
> >
{link.label} {link.label}
</a> </a>
))} ))}
</div> </div>
</div>
<div className="md:col-span-4"> <div className="flex items-center gap-x-4">
<div className="font-semibold text-foreground mb-5 text-xs tracking-widest"> {socials.map(({ label, url, Icon }) => (
CONNECT
</div>
<div className="flex flex-col gap-y-3 text-muted-foreground">
<a <a
href="#" key={label}
className="hover:text-foreground transition-colors w-fit" href={url}
aria-label={label}
className="text-muted-foreground hover:text-foreground transition-colors"
> >
Instagram <Icon size={18} />
</a> </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> </div>
</div> </div>
+18 -20
View File
@@ -4,7 +4,7 @@ import React, { useState } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { useCartStore } from '@/hooks/use-shopify-cart'; import { useCartStore } from '@/hooks/use-shopify-cart';
import CartDrawer from '@/components/shopify/cart-drawer'; 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 CartIcon: React.FC = () => {
const toggleCart = useCartStore((s) => s.toggleCart); const toggleCart = useCartStore((s) => s.toggleCart);
@@ -15,11 +15,12 @@ const CartIcon: React.FC = () => {
return ( return (
<button <button
onClick={toggleCart} 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 && ( {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} {itemCount > 99 ? '99+' : itemCount}
</span> </span>
)} )}
@@ -49,31 +50,31 @@ const Header: React.FC<HeaderProps> = ({
const [menuOpen, setMenuOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false);
return ( 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="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 */} {/* Logo */}
<Link href="/" className="flex items-center group"> <Link href="/" className="flex items-center">
{logoUrl ? ( {logoUrl ? (
<img <img
src={logoUrl} src={logoUrl}
alt={storeName} 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} {storeName}
</span> </span>
)} )}
</Link> </Link>
{/* Desktop Navigation */} {/* 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) => ( {links.map((link, index) => (
<Link <Link
key={index} key={index}
href={link.url} 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.label}
</Link> </Link>
@@ -81,16 +82,16 @@ const Header: React.FC<HeaderProps> = ({
</div> </div>
{/* Actions */} {/* Actions */}
<div className="flex items-center gap-x-2"> <div className="flex items-center gap-x-1">
<CartIcon /> <CartIcon />
{/* Mobile hamburger */} {/* Mobile hamburger */}
<button <button
onClick={() => setMenuOpen(!menuOpen)} 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" aria-label="Toggle menu"
> >
{menuOpen ? <RiCloseLine size={28} /> : <RiMenu3Line size={28} />} {menuOpen ? <RiCloseLine size={22} /> : <RiMenu3Line size={22} />}
</button> </button>
</div> </div>
</div> </div>
@@ -98,21 +99,18 @@ const Header: React.FC<HeaderProps> = ({
{/* Mobile Menu */} {/* Mobile Menu */}
{menuOpen && ( {menuOpen && (
<div className="md:hidden border-t bg-white"> <div className="md:hidden bg-background">
<div className="max-w-screen-2xl mx-auto px-8 py-8 flex flex-col gap-y-6 text-lg font-medium"> <div className="max-w-screen-2xl mx-auto px-8 pb-6 flex flex-col gap-y-4 text-sm">
{links.map((link, index) => ( {links.map((link, index) => (
<Link <Link
key={index} key={index}
href={link.url} href={link.url}
onClick={() => setMenuOpen(false)} 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.label}
</Link> </Link>
))} ))}
<div className="pt-4 border-t text-xs text-muted-foreground font-mono tracking-widest">
PROFESSIONAL CLEAN MODERN
</div>
</div> </div>
</div> </div>
)} )}
+18 -81
View File
@@ -1,12 +1,6 @@
'use client'; import React from 'react';
import React, { useState } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { useShopifyCart } from '@/hooks/use-shopify-cart';
import { truncate } from '@/lib/utils'; 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 { interface ProductImage {
url: string; url: string;
@@ -50,13 +44,9 @@ interface Product {
interface ProductCardProps { interface ProductCardProps {
product: Product; product: Product;
onAddToCart?: (product: Product) => void;
} }
const ProductCard: React.FC<ProductCardProps> = ({ product, onAddToCart }) => { const ProductCard: React.FC<ProductCardProps> = ({ product }) => {
const { addItem, openCart } = useShopifyCart();
const [isAdding, setIsAdding] = useState(false);
const firstImage = product.images.edges[0]?.node; const firstImage = product.images.edges[0]?.node;
const price = product.priceRange.minVariantPrice; const price = product.priceRange.minVariantPrice;
const compareAtPrice = product.compareAtPriceRange?.minVariantPrice; const compareAtPrice = product.compareAtPriceRange?.minVariantPrice;
@@ -70,109 +60,56 @@ const ProductCard: React.FC<ProductCardProps> = ({ product, onAddToCart }) => {
return `$${parseFloat(amount).toFixed(2)}`; 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 ( 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 */} {/* Product Image */}
<div className="relative aspect-[4/3.1] bg-zinc-100 overflow-hidden"> <div className="relative aspect-square overflow-hidden bg-white">
<Link href={`/products/${product.handle}`} className="block h-full">
{firstImage ? ( {firstImage ? (
<img <img
src={firstImage.url} src={firstImage.url}
alt={firstImage.altText || product.title} alt={firstImage.altText || product.title}
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-[1.08]" 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"> <div className="absolute inset-0 flex items-center justify-center text-zinc-300">
<i className="ri-image-line text-8xl"></i> <i className="ri-image-line text-8xl"></i>
</div> </div>
)} )}
</Link>
{/* Badges */}
{hasDiscount && compareAtPrice && ( {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 SALE
</Badge> </span>
)} )}
{!isAvailable && ( {!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 SOLD OUT
</div> </span>
)} )}
</div> </div>
{/* Product Info */} {/* Product Info */}
<div className="flex-1 p-6 flex flex-col"> <div className="flex flex-col flex-1 py-2.5">
<Link <h3 className="text-sm font-medium text-foreground line-clamp-1">
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)} {truncate(product.title, 65)}
</h3> </h3>
</Link>
<div className="mt-auto"> <div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<div className="price-display flex items-baseline gap-x-3 mb-6"> <span className="font-mono tabular-nums tracking-tight text-sm text-foreground">
<span className="text-2xl font-semibold text-foreground tracking-tighter">
{formatPrice(price.amount)} {formatPrice(price.amount)}
</span> </span>
{hasDiscount && compareAtPrice && ( {hasDiscount && compareAtPrice && (
<span className="text-base line-through text-muted-foreground"> <span className="font-mono tabular-nums tracking-tight text-sm line-through text-muted-foreground">
{formatPrice(compareAtPrice.amount)} {formatPrice(compareAtPrice.amount)}
</span> </span>
)} )}
</div> </div>
</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> </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>
); );
}; };
+46 -58
View File
@@ -2,11 +2,10 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { useParams } from 'next/navigation'; import { useParams } from 'next/navigation';
import Link from 'next/link';
import { useProduct, type Product } from '@/hooks/use-shopify-products'; 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 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 { Button } from '@/components/ui/button';
import { import {
Empty, Empty,
@@ -15,14 +14,6 @@ import {
EmptyDescription, EmptyDescription,
EmptyContent, EmptyContent,
} from '@/components/ui/empty'; } from '@/components/ui/empty';
import {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
} from '@/components/ui/breadcrumb';
interface ProductVariant { interface ProductVariant {
id: string; id: string;
@@ -47,17 +38,15 @@ export type { Product };
interface ProductDetailProps { interface ProductDetailProps {
handle?: string; handle?: string;
addToCartLabel?: string; addToCartLabel?: string;
features?: ProductFeature[];
} }
const ProductDetail: React.FC<ProductDetailProps> = ({ const ProductDetail: React.FC<ProductDetailProps> = ({
handle: handleProp, handle: handleProp,
addToCartLabel = 'Add to Cart', addToCartLabel = 'Add to Cart',
features,
}) => { }) => {
const params = useParams(); const params = useParams();
const handle = handleProp || (params?.handle as string); const handle = handleProp || (params?.handle as string);
const { addItem, openCart } = useShopifyCart(); const { addItem, openCart, checkoutUrl } = useShopifyCart();
const { product, loading, error } = useProduct(handle); const { product, loading, error } = useProduct(handle);
@@ -68,8 +57,8 @@ const ProductDetail: React.FC<ProductDetailProps> = ({
Record<string, string> Record<string, string>
>({}); >({});
const [quantity, setQuantity] = useState(1); const [quantity, setQuantity] = useState(1);
const [selectedImageIndex, setSelectedImageIndex] = useState(0);
const [addingToCart, setAddingToCart] = useState(false); const [addingToCart, setAddingToCart] = useState(false);
const [buyingNow, setBuyingNow] = useState(false);
// Initialize variant when product loads // Initialize variant when product loads
useEffect(() => { useEffect(() => {
@@ -102,17 +91,6 @@ const ProductDetail: React.FC<ProductDetailProps> = ({
if (matchingVariant) { if (matchingVariant) {
setSelectedVariant(matchingVariant.node); 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) { if (loading) {
return ( return (
<div className="container mx-auto px-4 py-8"> <div className="max-w-screen-2xl mx-auto px-8 py-8">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12"> <div className="grid grid-cols-1 lg:grid-cols-5 gap-8 lg:gap-10">
{/* Image Gallery Skeleton */} {/* Image Gallery Skeleton */}
<div> <div className="lg:col-span-3 grid grid-cols-1 sm:grid-cols-2 gap-3">
<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) => ( {Array.from({ length: 4 }).map((_, i) => (
<div <div
key={i} key={i}
className="aspect-square bg-gray-200 rounded animate-pulse" className="aspect-square bg-zinc-100 animate-pulse"
></div> ></div>
))} ))}
</div> </div>
</div>
{/* Product Info Skeleton */} {/* Product Info Skeleton */}
<div> <div className="lg:col-span-2 animate-pulse">
<div className="h-8 bg-gray-200 rounded mb-4 animate-pulse"></div> <div className="h-8 bg-zinc-100 w-2/3"></div>
<div className="h-6 bg-gray-200 rounded mb-6 w-1/3 animate-pulse"></div> <div className="h-5 bg-zinc-100 w-24 mt-2"></div>
<div className="h-24 bg-gray-200 rounded mb-6 animate-pulse"></div> <div className="h-8 bg-zinc-100 w-32 mt-8"></div>
<div className="h-12 bg-gray-200 rounded mb-4 animate-pulse"></div> <div className="h-10 bg-zinc-100 mt-8"></div>
<div className="h-12 bg-gray-200 rounded animate-pulse"></div> <div className="h-12 bg-zinc-100 mt-8"></div>
<div className="h-12 bg-zinc-100 mt-3"></div>
</div> </div>
</div> </div>
</div> </div>
@@ -181,27 +179,15 @@ const ProductDetail: React.FC<ProductDetailProps> = ({
} }
return ( return (
<div className="bg-white"> <div className="bg-background">
<div className="container mx-auto px-4 py-8"> <div className="max-w-screen-2xl mx-auto px-8 py-8">
<Breadcrumb className="mb-6"> <div className="grid grid-cols-1 lg:grid-cols-5 gap-8 lg:gap-10 items-start">
<BreadcrumbList> <div className="lg:col-span-3">
<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 <ProductDetailGallery
images={product.images.edges.map((edge) => edge.node)} images={product.images.edges.map((edge) => edge.node)}
selectedImageIndex={selectedImageIndex}
onImageSelect={setSelectedImageIndex}
/> />
</div>
<div className="lg:col-span-2 lg:sticky lg:top-20">
<ProductDetailInfo <ProductDetailInfo
product={product} product={product}
selectedVariant={selectedVariant} selectedVariant={selectedVariant}
@@ -209,14 +195,16 @@ const ProductDetail: React.FC<ProductDetailProps> = ({
quantity={quantity} quantity={quantity}
setQuantity={setQuantity} setQuantity={setQuantity}
handleAddToCart={handleAddToCart} handleAddToCart={handleAddToCart}
handleBuyNow={handleBuyNow}
onOptionChange={handleOptionChange} onOptionChange={handleOptionChange}
loading={addingToCart} loading={addingToCart}
buyingNow={buyingNow}
addToCartLabel={addToCartLabel} addToCartLabel={addToCartLabel}
features={features}
/> />
</div> </div>
</div> </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 { interface ProductImage {
url: string; url: string;
@@ -7,57 +10,148 @@ interface ProductImage {
interface ProductDetailGalleryProps { interface ProductDetailGalleryProps {
images: ProductImage[]; images: ProductImage[];
selectedImageIndex?: number;
onImageSelect?: (index: number) => void;
} }
const ProductDetailGallery: React.FC<ProductDetailGalleryProps> = ({ const ProductDetailGallery: React.FC<ProductDetailGalleryProps> = ({
images, 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 ( return (
<div className="space-y-4"> <div className="aspect-square bg-zinc-50 flex items-center justify-center text-zinc-300">
{/* 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> <i className="ri-image-line text-[120px]"></i>
</div> </div>
)} );
</div> }
{/* Image Thumbnails */} const isSingle = images.length === 1;
{images.length > 1 && ( const zoomedImage = zoomedIndex !== null ? images[zoomedIndex] : null;
<div className="grid grid-cols-4 gap-3">
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) => ( {images.map((image, index) => (
<button <button
key={index} key={index}
onClick={() => setSelectedImage(index)} onClick={() => setZoomedIndex(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 ${ aria-label={`Zoom ${image.altText || 'product image'}`}
selectedImageIndex === index className={`relative aspect-square w-full shrink-0 snap-center overflow-hidden bg-white cursor-zoom-in sm:shrink ${
? 'border-primary shadow-md scale-[1.03]' isSingle ? 'sm:col-span-2' : ''
: 'border-border hover:border-zinc-300'
}`} }`}
> >
<img <img
src={image.url} src={image.url}
alt={image.altText || 'Product thumbnail'} alt={image.altText || 'Product image'}
className="w-full h-full object-cover" className="w-full h-full object-cover"
/> />
</button> </button>
))} ))}
</div> </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> </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 React from 'react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Loader } from '@/app/components/ui/loader'; 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 { interface ProductPrice {
amount: string; amount: string;
@@ -20,12 +21,6 @@ interface ProductVariant {
}>; }>;
} }
interface ProductOption {
id: string;
name: string;
values: string[];
}
interface Product { interface Product {
id: string; id: string;
title: string; title: string;
@@ -53,12 +48,29 @@ interface ProductDetailInfoProps {
quantity: number; quantity: number;
setQuantity: (quantity: number) => void; setQuantity: (quantity: number) => void;
handleAddToCart: () => void; handleAddToCart: () => void;
handleBuyNow?: () => void;
onOptionChange: (optionName: string, value: string) => void; onOptionChange: (optionName: string, value: string) => void;
loading?: boolean; loading?: boolean;
buyingNow?: boolean;
addToCartLabel?: string; 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> = ({ const ProductDetailInfo: React.FC<ProductDetailInfoProps> = ({
product, product,
selectedVariant, selectedVariant,
@@ -66,14 +78,11 @@ const ProductDetailInfo: React.FC<ProductDetailInfoProps> = ({
quantity, quantity,
setQuantity, setQuantity,
handleAddToCart, handleAddToCart,
handleBuyNow,
onOptionChange, onOptionChange,
loading = false, loading = false,
buyingNow = false,
addToCartLabel = 'Add to Cart', 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) => { const formatPrice = (amount: string) => {
return `$${parseFloat(amount).toFixed(2)}`; return `$${parseFloat(amount).toFixed(2)}`;
@@ -84,48 +93,152 @@ const ProductDetailInfo: React.FC<ProductDetailInfoProps> = ({
const hasDiscount = const hasDiscount =
compareAtPrice && compareAtPrice &&
parseFloat(compareAtPrice.amount) > parseFloat(price.amount); 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 ( return (
<div className="pt-2"> <div>
<div className="mb-2"> <h1 className="text-2xl md:text-3xl font-normal text-foreground">
<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} {product.title}
</h1> </h1>
</div> <div className="flex items-baseline gap-x-3 mt-1">
<span className="font-mono tabular-nums tracking-tight text-base text-foreground">
{/* Price */}
<div className="flex items-center gap-x-4 mb-10">
<span className="text-4xl font-semibold tracking-tighter text-foreground price-display">
{formatPrice(price.amount)} {formatPrice(price.amount)}
</span> </span>
{hasDiscount && compareAtPrice && ( {hasDiscount && compareAtPrice && (
<> <span className="font-mono tabular-nums tracking-tight text-sm line-through text-muted-foreground">
<span className="text-2xl text-muted-foreground line-through tracking-tight">
{formatPrice(compareAtPrice.amount)} {formatPrice(compareAtPrice.amount)}
</span> </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>
</>
)} )}
</div> </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 */} {/* Description */}
{product.description && ( {(product.descriptionHtml || product.description) && (
<div className="prose text-muted-foreground mb-10 text-[15px] leading-relaxed max-w-prose"> <div className="mt-10 text-sm leading-6 text-foreground product-description">
{product.descriptionHtml ? ( {product.descriptionHtml ? (
<div <div
dangerouslySetInnerHTML={{ __html: product.descriptionHtml }} dangerouslySetInnerHTML={{ __html: product.descriptionHtml }}
@@ -135,104 +248,6 @@ const ProductDetailInfo: React.FC<ProductDetailInfoProps> = ({
)} )}
</div> </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> </div>
); );
}; };
+25 -28
View File
@@ -11,11 +11,13 @@ import ProductCard from './product-card';
interface ProductRecommendationsProps { interface ProductRecommendationsProps {
productId?: string; productId?: string;
title?: string; title?: string;
limit?: number;
} }
const ProductRecommendations: React.FC<ProductRecommendationsProps> = ({ const ProductRecommendations: React.FC<ProductRecommendationsProps> = ({
productId: productIdProp, productId: productIdProp,
title = 'You Might Also Like These Products 2', title = 'You May Also Like',
limit = 4,
}) => { }) => {
const params = useParams(); const params = useParams();
const handle = params?.handle as string | undefined; const handle = params?.handle as string | undefined;
@@ -31,36 +33,31 @@ const ProductRecommendations: React.FC<ProductRecommendationsProps> = ({
} }
return ( return (
<div className="bg-gray-50 py-16"> <section className="bg-background py-16">
<div className="container mx-auto px-4"> <div className="max-w-screen-2xl mx-auto px-8">
<h2 className="text-4xl font-bold text-center mb-12 text-gray-900 font-heading"> <h2 className="text-2xl md:text-3xl font-normal text-foreground mb-8">
{title} {title}
</h2> </h2>
{loading ? ( {error ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-8"> <p className="text-sm text-muted-foreground">
{Array.from({ length: 4 }).map((_, index) => ( Recommendations could not be loaded
<div </p>
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>
) : ( ) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-8"> <div className="grid grid-cols-2 lg:grid-cols-4 gap-x-8 gap-y-12">
{recommendations.slice(0, 4).map((recommendedProduct) => ( {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 <ProductCard
key={recommendedProduct.id} key={recommendedProduct.id}
product={recommendedProduct} product={recommendedProduct}
@@ -69,7 +66,7 @@ const ProductRecommendations: React.FC<ProductRecommendationsProps> = ({
</div> </div>
)} )}
</div> </div>
</div> </section>
); );
}; };
+19 -40
View File
@@ -48,12 +48,14 @@ interface Product {
interface ProductsProps { interface ProductsProps {
title?: string; title?: string;
subtitle?: string;
limit?: number; limit?: number;
showLoadMore?: boolean; showLoadMore?: boolean;
} }
const Products: React.FC<ProductsProps> = ({ 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, limit = 12,
showLoadMore = true, showLoadMore = true,
}) => { }) => {
@@ -109,10 +111,6 @@ const Products: React.FC<ProductsProps> = ({
fetchProducts(); fetchProducts();
}, [limit]); }, [limit]);
const handleAddToCart = async (product: Product) => {
console.log('Adding to cart:', product);
};
const handleLoadMore = () => { const handleLoadMore = () => {
if (!loadingMore && hasMoreProducts) { if (!loadingMore && hasMoreProducts) {
fetchProducts(products, true); fetchProducts(products, true);
@@ -123,32 +121,20 @@ const Products: React.FC<ProductsProps> = ({
return ( return (
<div className="py-20"> <div className="py-20">
<div className="max-w-screen-2xl mx-auto px-8"> <div className="max-w-screen-2xl mx-auto px-8">
<div className="flex justify-center mb-6"> <h2 className="text-center text-2xl md:text-3xl font-normal max-w-3xl mx-auto text-foreground">
<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">
{title} {title}
</h2> </h2>
<p className="text-center text-muted-foreground max-w-md mx-auto mb-16"> <p className="text-center text-sm font-normal max-w-xl mx-auto text-muted-foreground mt-2.5 mb-16">
Loading our finest selection... {subtitle}
</p> </p>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-8"> <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) => ( {Array.from({ length: 8 }).map((_, index) => (
<div <div key={index} className="animate-pulse">
key={index} <div className="aspect-square bg-zinc-100"></div>
className="bg-white rounded-3xl overflow-hidden animate-pulse border border-border h-[460px]" <div className="pt-4 space-y-2">
> <div className="h-4 bg-zinc-200 w-4/5"></div>
<div className="h-[280px] bg-zinc-100"></div> <div className="h-4 bg-zinc-200 w-1/4"></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> </div>
</div> </div>
))} ))}
@@ -162,9 +148,12 @@ const Products: React.FC<ProductsProps> = ({
return ( return (
<div className="py-20 bg-zinc-50"> <div className="py-20 bg-zinc-50">
<div className="max-w-screen-2xl mx-auto px-8 text-center"> <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} {title}
</h2> </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"> <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> <i className="ri-inbox-line text-6xl text-muted-foreground mb-6 block"></i>
<h3 className="font-semibold text-2xl mb-3 tracking-tight"> <h3 className="font-semibold text-2xl mb-3 tracking-tight">
@@ -188,26 +177,16 @@ const Products: React.FC<ProductsProps> = ({
return ( return (
<div className="py-20 bg-white"> <div className="py-20 bg-white">
<div className="max-w-screen-2xl mx-auto px-8"> <div className="max-w-screen-2xl mx-auto px-8">
<div className="flex justify-center mb-6"> <h2 className="text-center text-2xl md:text-3xl font-normal max-w-3xl mx-auto text-foreground">
<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">
{title} {title}
</h2> </h2>
<p className="text-center text-muted-foreground max-w-md mx-auto mb-16 text-lg"> <p className="text-center text-sm font-normal max-w-xl mx-auto text-muted-foreground mt-2.5 mb-16">
Beautifully designed objects for everyday life {subtitle}
</p> </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"> <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) => ( {products.map((product) => (
<ProductCard <ProductCard key={product.id} product={product} />
key={product.id}
product={product}
onAddToCart={handleAddToCart}
/>
))} ))}
</div> </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;
+51
View File
@@ -0,0 +1,51 @@
// Swatch configuration for product option pickers.
//
// Shopify exposes a `swatch` on each option value, but plenty of products ship
// without one configured. These settings decide which options render as colour
// circles and what colour to use when the API doesn't supply one.
// Option names (case-insensitive) rendered as colour circles instead of pills.
export const SWATCH_OPTION_NAMES = ['color', 'colour'];
// Fallback colours, keyed by lowercased option value name. The first group
// mirrors hexes the store defines on other products, so a product missing its
// swatches still matches the ones that have them. Add your own names here.
export const SWATCH_COLORS: Record<string, string> = {
clay: '#5a4b3c',
green: '#81a69b',
ocean: '#768da0',
olive: '#7f8060',
purple: '#766589',
red: '#a88084',
beige: '#e8dcc8',
black: '#000000',
blue: '#2563eb',
brown: '#7c5c40',
burgundy: '#6d2532',
charcoal: '#36393d',
cream: '#f3ead8',
gold: '#c9a227',
gray: '#8a8d91',
grey: '#8a8d91',
ivory: '#f6f2e6',
khaki: '#a89d78',
lavender: '#b9a8d0',
mint: '#a8d5ba',
navy: '#1f2a44',
orange: '#e2733a',
pink: '#e3a7b5',
sand: '#d9c9a8',
silver: '#c4c6c8',
stone: '#a9a294',
tan: '#c8a882',
teal: '#3f8f8b',
white: '#ffffff',
yellow: '#e5c34a',
};
export const swatchColorForName = (name: string): string | undefined =>
SWATCH_COLORS[name.trim().toLowerCase()];
export const isSwatchOptionName = (name: string): boolean =>
SWATCH_OPTION_NAMES.includes(name.trim().toLowerCase());
+43
View File
@@ -0,0 +1,43 @@
// Shop policies are exposed on the `shop` object of the Storefront API.
// There is no lookup-by-handle field, so we fetch all of them and match.
export const GET_SHOP_POLICIES_QUERY = `
query GetShopPolicies {
shop {
privacyPolicy {
id
title
handle
body
url
}
termsOfService {
id
title
handle
body
url
}
refundPolicy {
id
title
handle
body
url
}
shippingPolicy {
id
title
handle
body
url
}
subscriptionPolicy {
id
title
handle
body
url
}
}
}
`;
+22
View File
@@ -74,6 +74,28 @@ export const ProductFragment = `
id id
name name
values values
optionValues {
id
name
swatch {
color
image {
previewImage {
url
}
}
}
firstSelectableVariant {
id
image {
id
url
altText
width
height
}
}
}
} }
} }
`; `;
+60
View File
@@ -0,0 +1,60 @@
import { SHOPIFY_STOREFRONT_API_URL } from '@/services/shopify/client';
import { GET_SHOP_POLICIES_QUERY } from '@/graphql/policies';
export interface ShopPolicy {
id: string;
title: string;
handle: string;
body: string;
url: string;
}
interface ShopPoliciesResponse {
data?: {
shop?: Record<string, ShopPolicy | null>;
};
}
// Handles Shopify uses for each policy — also the routes under /policies/[handle].
export const POLICY_HANDLES = [
'terms-of-service',
'privacy-policy',
'refund-policy',
'shipping-policy',
'subscription-policy',
] as const;
// Policies change rarely, so this uses its own fetch (revalidated hourly)
// rather than the no-store `shopifyFetch` used for carts and products.
export async function getShopPolicies(): Promise<ShopPolicy[]> {
const token = process.env.NEXT_PUBLIC_SHOPIFY_STOREFRONT_ACCESS_TOKEN;
const response = await fetch(SHOPIFY_STOREFRONT_API_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(token ? { 'X-Shopify-Storefront-Access-Token': token } : {}),
},
body: JSON.stringify({ query: GET_SHOP_POLICIES_QUERY }),
next: { revalidate: 3600 },
});
if (!response.ok) {
console.error('Failed to load shop policies:', response.status);
return [];
}
const json: ShopPoliciesResponse = await response.json();
const shop = json.data?.shop ?? {};
return Object.values(shop).filter((policy): policy is ShopPolicy =>
Boolean(policy?.handle)
);
}
export async function getShopPolicy(
handle: string
): Promise<ShopPolicy | null> {
const policies = await getShopPolicies();
return policies.find((policy) => policy.handle === handle) ?? null;
}
+19 -1
View File
@@ -30,10 +30,28 @@ interface ProductVariant {
image?: ProductImage; image?: ProductImage;
} }
interface ProductOption { export interface ProductOptionValue {
id: string;
name: string;
swatch?: {
color?: string | null;
image?: {
previewImage?: {
url: string;
} | null;
} | null;
} | null;
firstSelectableVariant?: {
id: string;
image?: ProductImage | null;
} | null;
}
export interface ProductOption {
id: string; id: string;
name: string; name: string;
values: string[]; values: string[];
optionValues?: ProductOptionValue[];
} }
export interface Product { export interface Product {