Redesign cart and collections, fix product pagination

- Cart drawer: single-column layout matching the site — no bag icon,
  no dividers, square images, tighter type, discount code form,
  estimated total, and a Go to Checkout button
- Cart: per-line loading state so one row's update no longer disables
  every other row or the checkout button
- Discounts: cartDiscountCodesUpdate mutation plus discountCodes on the
  cart fragment, wired to an applyDiscountCode store action
- Products: fix "load more" returning the same first page — the query
  now takes an `after` cursor and getProductsPage exposes pageInfo
- Collections: cards match the product cards (no border, radius, blurb,
  or CTA row) at 4-up on desktop and 2-up on mobile; section headers
  match the products section
- Shop Pay: cart permalink with payment=shop_pay instead of the hosted
  shop-js element
- Buttons converted to the shadcn Button component throughout
- PDP: swipeable image carousel with dots on mobile, sticky info column,
  colour swatches never fall back to variant photos
- Rename the store from Stride to Shop; larger header wordmark

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 12:05:57 -04:00
co-authored by Claude Opus 5
parent 69f7435d6e
commit e04f1e0405
20 changed files with 546 additions and 466 deletions
+3 -3
View File
@@ -6,7 +6,7 @@ export default function Page() {
return ( return (
<> <>
<Header <Header
storeName="Stride" storeName="Shop"
logoUrl="" logoUrl=""
links={[ links={[
{ label: 'Products', url: '/' }, { label: 'Products', url: '/' },
@@ -15,8 +15,8 @@ export default function Page() {
/> />
<CollectionDetail /> <CollectionDetail />
<Footer <Footer
storeName="Stride" storeName="Shop"
copyright="© 2026 Stride. All rights reserved." copyright="© 2026 Shop. All rights reserved."
/> />
</> </>
); );
+3 -3
View File
@@ -6,7 +6,7 @@ export default function Page() {
return ( return (
<> <>
<Header <Header
storeName="Stride" storeName="Shop"
logoUrl="" logoUrl=""
links={[ links={[
{ label: 'Products', url: '/' }, { label: 'Products', url: '/' },
@@ -15,8 +15,8 @@ export default function Page() {
/> />
<Collections title="Our Collections" /> <Collections title="Our Collections" />
<Footer <Footer
storeName="Stride" storeName="Shop"
copyright="© 2026 Stride. All rights reserved." copyright="© 2026 Shop. All rights reserved."
/> />
</> </>
); );
+3 -3
View File
@@ -6,7 +6,7 @@ export default function Page() {
return ( return (
<> <>
<Header <Header
storeName="Stride" storeName="Shop"
logoUrl="" logoUrl=""
links={[ links={[
{ label: 'Products', url: '/' }, { label: 'Products', url: '/' },
@@ -18,8 +18,8 @@ export default function Page() {
subtitle="An agent-friendly Shopify storefront built with Next.js and Hydrogen." subtitle="An agent-friendly Shopify storefront built with Next.js and Hydrogen."
/> />
<Footer <Footer
storeName="Stride" storeName="Shop"
copyright="© 2026 Stride. All rights reserved." copyright="© 2026 Shop. All rights reserved."
/> />
</> </>
); );
+4 -4
View File
@@ -16,7 +16,7 @@ export async function generateMetadata({
const policy = await getShopPolicy(handle); const policy = await getShopPolicy(handle);
return { return {
title: policy ? `${policy.title} — Stride` : 'Policy — Stride', title: policy ? `${policy.title} — Shop` : 'Policy — Shop',
}; };
} }
@@ -35,7 +35,7 @@ export default async function PolicyPage({
return ( return (
<> <>
<Header <Header
storeName="Stride" storeName="Shop"
logoUrl="" logoUrl=""
links={[ links={[
{ label: 'Products', url: '/' }, { label: 'Products', url: '/' },
@@ -57,8 +57,8 @@ export default async function PolicyPage({
</main> </main>
<Footer <Footer
storeName="Stride" storeName="Shop"
copyright="© 2026 Stride. All rights reserved." copyright="© 2026 Shop. All rights reserved."
/> />
</> </>
); );
+3 -3
View File
@@ -7,7 +7,7 @@ export default function Page() {
return ( return (
<> <>
<Header <Header
storeName="Stride" storeName="Shop"
logoUrl="" logoUrl=""
links={[ links={[
{ label: 'Products', url: '/' }, { label: 'Products', url: '/' },
@@ -17,8 +17,8 @@ export default function Page() {
<ProductDetail addToCartLabel="Add to Cart" /> <ProductDetail addToCartLabel="Add to Cart" />
<ProductRecommendations title="You May Also Like" /> <ProductRecommendations title="You May Also Like" />
<Footer <Footer
storeName="Stride" storeName="Shop"
copyright="© 2026 Stride. All rights reserved." copyright="© 2026 Shop. All rights reserved."
/> />
</> </>
); );
+3 -3
View File
@@ -6,7 +6,7 @@ export default function Page() {
return ( return (
<> <>
<Header <Header
storeName="Stride" storeName="Shop"
logoUrl="" logoUrl=""
links={[ links={[
{ label: 'Products', url: '/' }, { label: 'Products', url: '/' },
@@ -15,8 +15,8 @@ export default function Page() {
/> />
<Collections title="Our Collections" /> <Collections title="Our Collections" />
<Footer <Footer
storeName="Stride" storeName="Shop"
copyright="© 2026 Stride. All rights reserved." copyright="© 2026 Shop. All rights reserved."
/> />
</> </>
); );
+175 -84
View File
@@ -1,6 +1,6 @@
'use client'; 'use client';
import React from 'react'; import React, { useState } from 'react';
import { useCartStore, redirectToCheckout } from '@/hooks/use-shopify-cart'; import { useCartStore, redirectToCheckout } from '@/hooks/use-shopify-cart';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Loader } from '@/app/components/ui/loader'; import { Loader } from '@/app/components/ui/loader';
@@ -17,7 +17,6 @@ import {
RiImageLine, RiImageLine,
RiSubtractLine, RiSubtractLine,
RiAddLine, RiAddLine,
RiShoppingBagLine,
} from '@remixicon/react'; } from '@remixicon/react';
import { import {
Empty, Empty,
@@ -34,11 +33,34 @@ const CartDrawer: React.FC = () => {
const cart = useCartStore((s) => s.cart); const cart = useCartStore((s) => s.cart);
const removeItem = useCartStore((s) => s.removeItem); const removeItem = useCartStore((s) => s.removeItem);
const updateItemQuantity = useCartStore((s) => s.updateItemQuantity); const updateItemQuantity = useCartStore((s) => s.updateItemQuantity);
const applyDiscountCode = useCartStore((s) => s.applyDiscountCode);
const [discountCode, setDiscountCode] = useState('');
const [discountError, setDiscountError] = useState<string | null>(null);
const [applyingDiscount, setApplyingDiscount] = useState(false);
// The store's `loading` flag is global, so track the specific line being
// changed to keep the other rows interactive.
const [pendingLineId, setPendingLineId] = useState<string | null>(null);
const runLineAction = async (lineId: string, action: () => Promise<unknown>) => {
if (pendingLineId) return;
try {
setPendingLineId(lineId);
await action();
} catch (err) {
console.error('Cart line update failed:', err);
} finally {
setPendingLineId(null);
}
};
const items = cart?.lines?.edges?.map((edge) => edge.node) ?? []; const items = cart?.lines?.edges?.map((edge) => edge.node) ?? [];
const itemCount = items.reduce((sum, item) => sum + item.quantity, 0); const itemCount = items.reduce((sum, item) => sum + item.quantity, 0);
const totalAmount = parseFloat(cart?.cost?.totalAmount?.amount ?? '0'); const totalAmount = parseFloat(cart?.cost?.totalAmount?.amount ?? '0');
const checkoutUrl = cart?.checkoutUrl ?? null; const checkoutUrl = cart?.checkoutUrl ?? null;
const appliedDiscounts =
cart?.discountCodes?.filter((discount) => discount.applicable) ?? [];
const handleCheckout = () => { const handleCheckout = () => {
if (checkoutUrl) { if (checkoutUrl) {
@@ -46,6 +68,35 @@ const CartDrawer: React.FC = () => {
} }
}; };
const handleApplyDiscount = async (event: React.FormEvent) => {
event.preventDefault();
const code = discountCode.trim();
if (!code || applyingDiscount) return;
try {
setApplyingDiscount(true);
setDiscountError(null);
const updatedCart = await applyDiscountCode(code);
// Shopify accepts unknown codes silently, flagging them as inapplicable.
const accepted = updatedCart.discountCodes?.some(
(discount) =>
discount.applicable &&
discount.code.toLowerCase() === code.toLowerCase()
);
if (accepted) {
setDiscountCode('');
} else {
setDiscountError('That code is not valid for this cart.');
}
} catch {
setDiscountError('Could not apply that code. Please try again.');
} finally {
setApplyingDiscount(false);
}
};
const getItemImage = (item: (typeof items)[0]) => { const getItemImage = (item: (typeof items)[0]) => {
return item.merchandise.image?.url; return item.merchandise.image?.url;
}; };
@@ -64,20 +115,27 @@ const CartDrawer: React.FC = () => {
{isOpen && ( {isOpen && (
<SheetContent className="w-full max-w-md" showCloseButton={false}> <SheetContent className="w-full max-w-md" showCloseButton={false}>
{/* Header */} {/* Header */}
<SheetHeader className="h-14 min-h-0 px-4 py-3 flex items-center"> <SheetHeader className="min-h-0 px-5 py-4 border-b-0">
<div className="flex items-center justify-between w-full"> <div className="flex items-center justify-between w-full">
<SheetTitle className="text-base flex items-center gap-x-2"> <SheetTitle className="text-base font-medium flex items-center gap-x-2">
<RiShoppingBagLine size={18} /> Cart
Bag ({itemCount}) <span className="flex h-5 min-w-5 items-center justify-center rounded-full bg-foreground px-1.5 text-[11px] font-medium text-background">
{itemCount}
</span>
</SheetTitle> </SheetTitle>
<Button onClick={closeCart} variant="ghost" size="icon-sm"> <Button
onClick={closeCart}
variant="ghost"
size="icon-sm"
aria-label="Close cart"
>
<RiCloseLine size={20} /> <RiCloseLine size={20} />
</Button> </Button>
</div> </div>
</SheetHeader> </SheetHeader>
{/* Cart Items */} {/* Cart Items */}
<SheetBody> <SheetBody className="px-5">
{loading && items.length === 0 ? ( {loading && items.length === 0 ? (
<div className="flex items-center justify-center py-12"> <div className="flex items-center justify-center py-12">
<Loader size={32} /> <Loader size={32} />
@@ -85,7 +143,7 @@ const CartDrawer: React.FC = () => {
) : items.length === 0 ? ( ) : items.length === 0 ? (
<Empty> <Empty>
<EmptyHeader> <EmptyHeader>
<EmptyTitle>Your bag is empty</EmptyTitle> <EmptyTitle>Your cart is empty</EmptyTitle>
<EmptyDescription> <EmptyDescription>
Add some products to get started! Add some products to get started!
</EmptyDescription> </EmptyDescription>
@@ -97,18 +155,16 @@ const CartDrawer: React.FC = () => {
</EmptyContent> </EmptyContent>
</Empty> </Empty>
) : ( ) : (
<div className="space-y-6"> <div className="space-y-5">
{items.map((item) => { {items.map((item) => {
const image = getItemImage(item); const image = getItemImage(item);
const selectedOptions = getSelectedOptions(item); const selectedOptions = getSelectedOptions(item);
const isPending = pendingLineId === item.id;
return ( return (
<div <div key={item.id} className="flex items-start gap-x-3">
key={item.id}
className="flex items-start space-x-4 pb-6 border-b border-gray-200 last:border-b-0"
>
{/* Product Image */} {/* Product Image */}
<div className="w-20 h-20 bg-gray-100 rounded-lg overflow-hidden flex-shrink-0"> <div className="w-16 h-16 bg-zinc-100 overflow-hidden shrink-0">
{image ? ( {image ? (
<img <img
src={image} src={image}
@@ -116,131 +172,166 @@ const CartDrawer: React.FC = () => {
className="w-full h-full object-cover" className="w-full h-full object-cover"
/> />
) : ( ) : (
<div className="w-full h-full flex items-center justify-center text-gray-400"> <div className="w-full h-full flex items-center justify-center text-zinc-400">
<RiImageLine size={24} /> <RiImageLine size={20} />
</div> </div>
)} )}
</div> </div>
{/* Product Details */} {/* Product Details */}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<h4 className="font-semibold text-gray-900 mb-1 line-clamp-2"> <div className="flex items-start justify-between gap-x-3">
<h4 className="text-sm font-medium text-foreground line-clamp-2">
{item.merchandise.product.title} {item.merchandise.product.title}
</h4> </h4>
<span className="shrink-0 font-mono tabular-nums tracking-tight text-sm text-foreground">
{/* Variant Info */} $
{selectedOptions.length > 0 && ( {parseFloat(
<div className="text-sm text-gray-500 mb-2"> item.cost?.totalAmount?.amount ??
{selectedOptions.map((option, index) => ( item.merchandise.price.amount
<span key={option.name}> ).toFixed(2)}
{option.value}
{index < selectedOptions.length - 1
? ' / '
: ''}
</span> </span>
))} </div>
{selectedOptions.length > 0 && (
<div className="text-xs text-muted-foreground mt-0.5">
{selectedOptions
.map((option) => option.value)
.join(' / ')}
</div> </div>
)} )}
{/* Quantity Controls */} {/* Quantity Controls */}
<div className="flex items-center mt-3"> <div className="flex items-center gap-x-2 mt-2">
<div className="flex items-center border border-gray-300 rounded-lg"> <div className="inline-flex items-center rounded-full bg-secondary">
<Button <Button
onClick={() => onClick={() =>
updateItemQuantity(item.id, item.quantity - 1) runLineAction(item.id, () =>
updateItemQuantity(
item.id,
item.quantity - 1
)
)
} }
disabled={item.quantity <= 1 || isPending}
variant="ghost" variant="ghost"
size="icon-sm" size="icon-sm"
disabled={item.quantity <= 1 || loading} aria-label="Decrease quantity"
className="h-7 w-7" className="size-7 rounded-full"
> >
<RiSubtractLine size={14} /> <RiSubtractLine size={14} />
</Button> </Button>
<span className="px-2 py-1 font-semibold min-w-[30px] text-center text-sm"> <span className="min-w-8 px-1 text-sm tabular-nums text-center">
{item.quantity} {isPending ? (
<Loader size={12} className="mx-auto" />
) : (
item.quantity
)}
</span> </span>
<Button <Button
onClick={() => onClick={() =>
updateItemQuantity(item.id, item.quantity + 1) runLineAction(item.id, () =>
updateItemQuantity(
item.id,
item.quantity + 1
)
)
} }
disabled={isPending}
variant="ghost" variant="ghost"
size="icon-sm" size="icon-sm"
disabled={loading} aria-label="Increase quantity"
className="h-7 w-7" className="size-7 rounded-full"
> >
<RiAddLine size={14} /> <RiAddLine size={14} />
</Button> </Button>
</div> </div>
</div>
</div>
{/* Price */}
<div className="flex-shrink-0">
<span className="text-sm font-semibold text-gray-900">
$
{parseFloat(item.merchandise.price.amount).toFixed(
2
)}
</span>
</div>
{/* Remove Button */}
<div className="flex-shrink-0">
<Button <Button
onClick={() => removeItem(item.id)} onClick={() =>
variant="ghost" runLineAction(item.id, () => removeItem(item.id))
size="icon-sm" }
disabled={loading} disabled={isPending}
className="text-gray-400 hover:text-red-500" variant="link"
aria-label={`Remove ${item.merchandise.product.title}`}
className="ml-auto h-7 self-center px-0 text-xs font-normal leading-none text-muted-foreground underline decoration-dashed underline-offset-2 hover:text-foreground hover:no-underline"
> >
<RiCloseLine size={18} /> remove
</Button> </Button>
</div> </div>
</div> </div>
</div>
); );
})} })}
</div> </div>
)} )}
</SheetBody> </SheetBody>
{/* Footer - Checkout Section */} {/* Footer — discount, total, checkout */}
{items.length > 0 && ( {items.length > 0 && (
<div className="border-t border-border p-6"> <div className="px-5 py-5 space-y-4">
{/* Subtotal */} {/* Discount Code */}
<div className="flex items-center justify-between mb-4"> <form onSubmit={handleApplyDiscount} className="flex gap-x-2">
<span className="text-base font-semibold">Subtotal</span> <input
<span className="text-lg font-bold"> type="text"
value={discountCode}
onChange={(event) => {
setDiscountCode(event.target.value);
setDiscountError(null);
}}
placeholder="Discount code"
aria-label="Discount code"
className="flex-1 h-10 rounded-md border border-border px-3 text-sm placeholder:text-muted-foreground focus:outline-none focus:border-foreground transition-colors"
/>
<Button
type="submit"
disabled={!discountCode.trim() || applyingDiscount}
className="h-10 bg-muted-foreground px-5 hover:bg-foreground"
>
{applyingDiscount ? <Loader size={16} /> : 'Apply'}
</Button>
</form>
{discountError && (
<p className="text-xs text-destructive">{discountError}</p>
)}
{appliedDiscounts.length > 0 && (
<p className="text-xs text-muted-foreground">
Applied: {appliedDiscounts.map((d) => d.code).join(', ')}
</p>
)}
{/* Estimated total */}
<div>
<div className="flex items-baseline justify-between">
<span className="text-base text-foreground">
Estimated total
</span>
<span className="font-mono tabular-nums tracking-tight text-lg text-foreground">
${totalAmount.toFixed(2)} ${totalAmount.toFixed(2)}
</span> </span>
</div> </div>
<p className="text-xs text-muted-foreground mt-1">
<div className="text-sm text-gray-500 mb-4"> Taxes and shipping calculated at checkout.
Shipping and taxes calculated at checkout </p>
</div> </div>
{/* Action Buttons */}
<div className="space-y-3">
<Button <Button
onClick={handleCheckout} onClick={handleCheckout}
disabled={loading || !checkoutUrl} disabled={!checkoutUrl || pendingLineId !== null}
className="w-full" className="h-12 w-full"
size="lg"
> >
{loading ? ( Go to Checkout
<span className="flex items-center justify-center space-x-2">
<Loader size={16} />
<span>Processing...</span>
</span>
) : (
'Checkout'
)}
</Button> </Button>
<Button onClick={closeCart} variant="link" className="w-full"> <Button
onClick={closeCart}
variant="ghost"
className="w-full font-normal text-muted-foreground hover:text-foreground"
>
Continue Shopping Continue Shopping
</Button> </Button>
</div> </div>
</div>
)} )}
</SheetContent> </SheetContent>
)} )}
+6 -25
View File
@@ -1,6 +1,5 @@
import React from 'react'; import React from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { Card, CardContent } from '@/components/ui/card';
interface CollectionImage { interface CollectionImage {
url: string; url: string;
@@ -25,44 +24,26 @@ const CollectionCard: React.FC<CollectionCardProps> = ({ collection }) => {
href={`/collections/${collection.handle}`} href={`/collections/${collection.handle}`}
className="group block h-full" className="group block h-full"
> >
<div className="card-modern bg-card rounded-3xl overflow-hidden h-full flex flex-col shadow-sm">
{/* Collection Image */} {/* Collection Image */}
<div className="aspect-[16/10] relative overflow-hidden bg-zinc-100"> <div className="relative aspect-square overflow-hidden bg-white">
{collection.image ? ( {collection.image ? (
<img <img
src={collection.image.url} src={collection.image.url}
alt={collection.image.altText || collection.title} alt={collection.image.altText || collection.title}
className="w-full h-full object-cover transition-transform duration-700 group-hover:scale-110" className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-[1.04]"
/> />
) : ( ) : (
<div className="absolute inset-0 flex flex-col items-center justify-center text-zinc-300"> <div className="absolute inset-0 flex items-center justify-center text-zinc-300">
<i className="ri-folder-line text-8xl mb-4"></i> <i className="ri-folder-line text-8xl"></i>
<span className="text-xs tracking-widest font-mono">
COLLECTION
</span>
</div> </div>
)} )}
<div className="absolute inset-0 bg-gradient-to-b from-black/10 via-transparent to-black/30"></div>
</div> </div>
{/* Collection Info */} {/* Collection Info */}
<div className="p-8 flex flex-col flex-1"> <div className="flex flex-col flex-1 py-2.5">
<h3 className="font-heading text-3xl font-semibold tracking-tighter text-foreground mb-4 group-hover:text-primary transition-colors"> <h3 className="text-sm font-medium text-foreground line-clamp-1">
{collection.title} {collection.title}
</h3> </h3>
{collection.description && (
<p className="text-muted-foreground text-[15px] leading-relaxed line-clamp-3 flex-1">
{collection.description}
</p>
)}
<div className="mt-8 flex items-center text-sm font-semibold text-primary group-hover:gap-x-2 transition-all">
EXPLORE COLLECTION
<i className="ri-arrow-right-line ml-2 text-base transition-transform group-hover:translate-x-0.5"></i>
</div>
</div>
</div> </div>
</Link> </Link>
); );
+30 -50
View File
@@ -4,11 +4,20 @@ import React from 'react';
import { useParams } from 'next/navigation'; import { useParams } from 'next/navigation';
import { useCollectionProducts } from '@/hooks/use-shopify-collections'; import { useCollectionProducts } from '@/hooks/use-shopify-collections';
import ProductCard from './product-card'; import ProductCard from './product-card';
import { Button } from '@/components/ui/button';
const GRID_CLASSES =
'grid grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-x-8 gap-y-16';
const CollectionTitle: React.FC<{ title: string }> = ({ title }) => (
<h2 className="text-center text-2xl md:text-3xl font-normal max-w-3xl mx-auto text-foreground mb-16">
{title}
</h2>
);
const CollectionDetail: React.FC = () => { const CollectionDetail: React.FC = () => {
const params = useParams(); const params = useParams();
const handle = params?.handle as string; const handle = params?.handle as string;
console.log('[CollectionDetail] params:', params, 'handle:', handle);
const { collection, loading, error, refetch } = useCollectionProducts(handle); const { collection, loading, error, refetch } = useCollectionProducts(handle);
@@ -19,25 +28,18 @@ const CollectionDetail: React.FC = () => {
if (loading) { if (loading) {
return ( return (
<div className="py-16"> <div className="py-16 bg-background">
<div className="container mx-auto px-4"> <div className="max-w-screen-2xl mx-auto px-8">
<h2 className="text-5xl font-bold text-center mb-16 text-gray-900 font-heading"> <CollectionTitle title={formattedTitle} />
{formattedTitle}
</h2>
{/* Loading Skeleton */} {/* Loading Skeleton */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-8"> <div className={GRID_CLASSES}>
{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-lg shadow-md overflow-hidden animate-pulse" <div className="pt-4 space-y-2">
> <div className="h-4 bg-zinc-200 w-4/5"></div>
<div className="aspect-square bg-gray-200"></div> <div className="h-4 bg-zinc-200 w-1/4"></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> </div>
))} ))}
@@ -49,25 +51,13 @@ const CollectionDetail: React.FC = () => {
if (error) { if (error) {
return ( return (
<div className="py-16"> <div className="py-16 bg-background">
<div className="container mx-auto px-4 text-center"> <div className="max-w-screen-2xl mx-auto px-8 text-center">
<h2 className="text-5xl font-bold mb-8 font-heading"> <CollectionTitle title={formattedTitle} />
{formattedTitle} <p className="text-sm text-muted-foreground mb-6">{error}</p>
</h2> <Button onClick={() => refetch()} variant="outline">
<div className="bg-red-50 border border-red-200 rounded-lg p-8 max-w-md mx-auto">
<i className="ri-error-warning-line text-4xl text-red-500 mb-4"></i>
<h3 className="text-lg font-semibold text-red-800 mb-2">
Failed to Load Collection
</h3>
<p className="text-red-600 mb-4">{error}</p>
<button
onClick={() => refetch()}
className="bg-red-600 text-white px-6 py-2 rounded-lg hover:bg-red-700 transition-colors"
>
Try Again Try Again
</button> </Button>
</div>
</div> </div>
</div> </div>
); );
@@ -77,26 +67,16 @@ const CollectionDetail: React.FC = () => {
const title = collection?.title || formattedTitle; const title = collection?.title || formattedTitle;
return ( return (
<div className="py-16"> <div className="py-16 bg-background">
<div className="container mx-auto px-4"> <div className="max-w-screen-2xl mx-auto px-8">
<h2 className="text-5xl font-bold text-center mb-16 text-gray-900 font-heading"> <CollectionTitle title={title} />
{title}
</h2>
{products.length === 0 ? ( {products.length === 0 ? (
<div className="text-center py-12"> <p className="text-center text-sm text-muted-foreground">
<div className="bg-gray-50 border border-gray-200 rounded-lg p-8 max-w-md mx-auto">
<i className="ri-shopping-bag-line text-4xl text-gray-400 mb-4"></i>
<h3 className="text-lg font-semibold text-gray-600 mb-2">
No Products in Collection
</h3>
<p className="text-gray-500">
This collection doesn&apos;t have any products yet. This collection doesn&apos;t have any products yet.
</p> </p>
</div>
</div>
) : ( ) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-8"> <div className={GRID_CLASSES}>
{products.map((product) => ( {products.map((product) => (
<ProductCard key={product.id} product={product} /> <ProductCard key={product.id} product={product} />
))} ))}
+38 -59
View File
@@ -7,40 +7,45 @@ import { Button } from '@/components/ui/button';
interface CollectionsProps { interface CollectionsProps {
title?: string; title?: string;
subtitle?: string;
} }
const SectionHeader: React.FC<{ title: string; subtitle?: string }> = ({
title,
subtitle,
}) => (
<>
<h2 className="text-center text-2xl md:text-3xl font-normal max-w-3xl mx-auto text-foreground">
{title}
</h2>
{subtitle && (
<p className="text-center text-sm font-normal max-w-xl mx-auto text-muted-foreground mt-2.5 mb-16">
{subtitle}
</p>
)}
</>
);
const GRID_CLASSES = 'grid grid-cols-2 lg:grid-cols-4 gap-x-8 gap-y-12';
const Collections: React.FC<CollectionsProps> = ({ const Collections: React.FC<CollectionsProps> = ({
title = 'Our Collections', title = 'Our Collections',
subtitle = 'Discover our carefully crafted worlds',
}) => { }) => {
const { collections, loading, error, refetch } = useCollections(12); const { collections, loading, error, refetch } = useCollections(12);
if (loading) { if (loading) {
return ( return (
<div className="py-20"> <div className="py-20 bg-background">
<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"> <SectionHeader title={title} subtitle={subtitle} />
<div className="inline px-6 py-2 bg-zinc-100 rounded-3xl text-xs font-mono tracking-widest text-muted-foreground">
EXPLORE
</div>
</div>
<h2 className="text-center text-6xl font-bold tracking-tighter font-heading mb-6 text-foreground">
{title}
</h2>
<p className="text-center text-muted-foreground max-w-md mx-auto mb-16">
Handpicked stories and themes
</p>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8"> <div className={GRID_CLASSES}>
{Array.from({ length: 6 }).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-[520px]" <div className="pt-4">
> <div className="h-4 bg-zinc-200 w-3/5"></div>
<div className="h-80 bg-zinc-100"></div>
<div className="p-10 space-y-4">
<div className="h-8 bg-zinc-200 rounded-xl w-3/4"></div>
<div className="h-4 bg-zinc-200 rounded w-full"></div>
<div className="h-4 bg-zinc-200 rounded w-5/6"></div>
</div> </div>
</div> </div>
))} ))}
@@ -52,63 +57,37 @@ const Collections: React.FC<CollectionsProps> = ({
if (error) { if (error) {
return ( return (
<div className="py-20"> <div className="py-20 bg-background">
<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"> <SectionHeader title={title} subtitle={subtitle} />
{title} <p className="text-sm text-muted-foreground mb-6">{error}</p>
</h2> <Button onClick={refetch} variant="outline">
<div className="mx-auto max-w-sm bg-white border border-border rounded-3xl p-10">
<i className="ri-alert-line text-5xl text-rose-500 mb-4"></i>
<h3 className="text-xl font-semibold mb-2">
Unable to load collections
</h3>
<p className="text-muted-foreground mb-6">{error}</p>
<Button onClick={refetch} variant="outline" className="btn-modern">
Retry Retry
</Button> </Button>
</div> </div>
</div> </div>
</div>
); );
} }
if (collections.length === 0) { if (collections.length === 0) {
return ( return (
<div className="py-20 bg-zinc-50"> <div className="py-20 bg-background">
<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"> <SectionHeader title={title} subtitle={subtitle} />
{title} <p className="text-sm text-muted-foreground">
</h2>
<div className="mx-auto max-w-md p-12">
<i className="ri-folder-open-line text-6xl text-muted-foreground mb-6"></i>
<h3 className="text-2xl font-semibold mb-3">
No collections found
</h3>
<p className="text-muted-foreground">
Collections will appear here once added to your Shopify store. Collections will appear here once added to your Shopify store.
</p> </p>
</div> </div>
</div> </div>
</div>
); );
} }
return ( return (
<div className="py-20"> <div className="py-20 bg-background">
<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"> <SectionHeader title={title} subtitle={subtitle} />
<div className="inline px-6 py-2 bg-zinc-100 rounded-3xl text-xs font-mono tracking-widest text-muted-foreground">
THEMES &amp; STORIES
</div>
</div>
<h2 className="text-center text-6xl font-bold tracking-tighter font-heading mb-6 text-foreground">
{title}
</h2>
<p className="text-center text-muted-foreground max-w-md mx-auto mb-16 text-lg">
Discover our carefully crafted worlds
</p>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8"> <div className={GRID_CLASSES}>
{collections.map((collection) => ( {collections.map((collection) => (
<CollectionCard key={collection.id} collection={collection} /> <CollectionCard key={collection.id} collection={collection} />
))} ))}
+13 -8
View File
@@ -5,6 +5,7 @@ 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 { RiShoppingBagLine, RiCloseLine, RiMenu3Line } from '@remixicon/react'; import { RiShoppingBagLine, RiCloseLine, RiMenu3Line } from '@remixicon/react';
import { Button } from '@/components/ui/button';
const CartIcon: React.FC = () => { const CartIcon: React.FC = () => {
const toggleCart = useCartStore((s) => s.toggleCart); const toggleCart = useCartStore((s) => s.toggleCart);
@@ -13,10 +14,12 @@ const CartIcon: React.FC = () => {
cart?.lines?.edges?.reduce((sum, { node }) => sum + node.quantity, 0) ?? 0; cart?.lines?.edges?.reduce((sum, { node }) => sum + node.quantity, 0) ?? 0;
return ( return (
<button <Button
onClick={toggleCart} onClick={toggleCart}
variant="ghost"
size="icon"
aria-label={`Open bag (${itemCount})`} aria-label={`Open bag (${itemCount})`}
className="relative p-2 text-foreground hover:text-muted-foreground transition-colors" className="relative"
> >
<RiShoppingBagLine size={20} /> <RiShoppingBagLine size={20} />
{itemCount > 0 && ( {itemCount > 0 && (
@@ -24,7 +27,7 @@ const CartIcon: React.FC = () => {
{itemCount > 99 ? '99+' : itemCount} {itemCount > 99 ? '99+' : itemCount}
</span> </span>
)} )}
</button> </Button>
); );
}; };
@@ -40,7 +43,7 @@ interface HeaderProps {
} }
const Header: React.FC<HeaderProps> = ({ const Header: React.FC<HeaderProps> = ({
storeName = 'STRIDE', storeName = 'Shop',
logoUrl, logoUrl,
links = [ links = [
{ label: 'Shop', url: '/' }, { label: 'Shop', url: '/' },
@@ -62,7 +65,7 @@ const Header: React.FC<HeaderProps> = ({
className="h-6 w-auto object-contain" className="h-6 w-auto object-contain"
/> />
) : ( ) : (
<span className="text-base font-medium tracking-tight text-foreground"> <span className="text-xl font-medium tracking-tight text-foreground">
{storeName} {storeName}
</span> </span>
)} )}
@@ -86,13 +89,15 @@ const Header: React.FC<HeaderProps> = ({
<CartIcon /> <CartIcon />
{/* Mobile hamburger */} {/* Mobile hamburger */}
<button <Button
onClick={() => setMenuOpen(!menuOpen)} onClick={() => setMenuOpen(!menuOpen)}
className="md:hidden p-2 text-foreground hover:text-muted-foreground transition-colors" variant="ghost"
size="icon"
className="md:hidden"
aria-label="Toggle menu" aria-label="Toggle menu"
> >
{menuOpen ? <RiCloseLine size={22} /> : <RiMenu3Line size={22} />} {menuOpen ? <RiCloseLine size={22} /> : <RiMenu3Line size={22} />}
</button> </Button>
</div> </div>
</div> </div>
</div> </div>
+2 -2
View File
@@ -80,13 +80,13 @@ const ProductCard: React.FC<ProductCardProps> = ({ product }) => {
)} )}
{hasDiscount && compareAtPrice && ( {hasDiscount && compareAtPrice && (
<span className="absolute top-0 left-0 text-[11px] font-mono tracking-widest text-rose-600"> <span className="absolute top-3 left-3 text-[11px] font-mono tracking-widest text-rose-600">
SALE SALE
</span> </span>
)} )}
{!isAvailable && ( {!isAvailable && (
<span className="absolute top-0 right-0 text-[11px] font-mono tracking-widest text-muted-foreground"> <span className="absolute top-3 right-3 text-[11px] font-mono tracking-widest text-muted-foreground">
SOLD OUT SOLD OUT
</span> </span>
)} )}
@@ -2,6 +2,7 @@
import React, { useCallback, useEffect, useRef, useState } from 'react'; import React, { useCallback, useEffect, useRef, useState } from 'react';
import { RiCloseLine } from '@remixicon/react'; import { RiCloseLine } from '@remixicon/react';
import { Button } from '@/components/ui/button';
interface ProductImage { interface ProductImage {
url: string; url: string;
@@ -95,7 +96,7 @@ const ProductDetailGallery: React.FC<ProductDetailGalleryProps> = ({
key={index} key={index}
onClick={() => setZoomedIndex(index)} onClick={() => setZoomedIndex(index)}
aria-label={`Zoom ${image.altText || 'product image'}`} 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 ${ className={`relative aspect-square w-full shrink-0 snap-center overflow-hidden bg-white pointer-events-none sm:pointer-events-auto sm:shrink sm:cursor-zoom-in ${
isSingle ? 'sm:col-span-2' : '' isSingle ? 'sm:col-span-2' : ''
}`} }`}
> >
@@ -142,13 +143,15 @@ const ProductDetailGallery: React.FC<ProductDetailGalleryProps> = ({
className="max-h-full max-w-full object-contain bg-white" className="max-h-full max-w-full object-contain bg-white"
/> />
<button <Button
onClick={close} onClick={close}
variant="ghost"
size="icon-lg"
aria-label="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" className="absolute top-4 right-4 rounded-full bg-background shadow-sm hover:bg-secondary"
> >
<RiCloseLine size={20} /> <RiCloseLine size={20} />
</button> </Button>
</div> </div>
)} )}
</> </>
@@ -1,7 +1,8 @@
import React from 'react'; import React from 'react';
import { Button } from '@/components/ui/button';
import { Loader } from '@/app/components/ui/loader'; import { Loader } from '@/app/components/ui/loader';
import { RiSubtractLine, RiAddLine } from '@remixicon/react'; import { RiSubtractLine, RiAddLine } from '@remixicon/react';
import ShopPayLogo from '@/components/shopify/shop-pay-logo'; import ShopPayButton from '@/components/shopify/shop-pay-button';
import type { ProductOption, ProductOptionValue } from '@/hooks/use-shopify-products'; import type { ProductOption, ProductOptionValue } from '@/hooks/use-shopify-products';
import { isSwatchOptionName, swatchColorForName } from '@/config/swatches'; import { isSwatchOptionName, swatchColorForName } from '@/config/swatches';
@@ -147,13 +148,15 @@ const ProductDetailInfo: React.FC<ProductDetailInfoProps> = ({
const { background, image } = swatchStyle(value); const { background, image } = swatchStyle(value);
return ( return (
<button <Button
key={value.id} key={value.id}
onClick={() => onOptionChange(option.name, value.name)} onClick={() => onOptionChange(option.name, value.name)}
variant="ghost"
size="icon-sm"
title={value.name} title={value.name}
aria-label={value.name} aria-label={value.name}
aria-pressed={isSelected} aria-pressed={isSelected}
className={`h-8 w-8 rounded-full bg-cover bg-center transition-shadow ${ className={`rounded-full bg-cover bg-center hover:bg-transparent ${
isSelected isSelected
? 'ring-2 ring-foreground ring-offset-2' ? 'ring-2 ring-foreground ring-offset-2'
: 'ring-1 ring-border hover:ring-foreground/40' : 'ring-1 ring-border hover:ring-foreground/40'
@@ -166,23 +169,24 @@ const ProductDetailInfo: React.FC<ProductDetailInfoProps> = ({
{!background && !image && ( {!background && !image && (
<span className="text-[10px]">{value.name.at(0)}</span> <span className="text-[10px]">{value.name.at(0)}</span>
)} )}
</button> </Button>
); );
} }
return ( return (
<button <Button
key={value.id} key={value.id}
onClick={() => onOptionChange(option.name, value.name)} onClick={() => onOptionChange(option.name, value.name)}
variant="outline"
aria-pressed={isSelected} aria-pressed={isSelected}
className={`min-w-14 px-5 py-2 rounded-md border text-sm text-center transition-colors ${ className={`min-w-14 px-5 font-normal shadow-none ${
isSelected isSelected
? 'border-foreground text-foreground' ? 'border-foreground text-foreground'
: 'border-border text-muted-foreground hover:border-foreground hover:text-foreground' : 'text-muted-foreground hover:border-foreground hover:text-foreground'
}`} }`}
> >
{value.name} {value.name}
</button> </Button>
); );
})} })}
</div> </div>
@@ -193,48 +197,50 @@ const ProductDetailInfo: React.FC<ProductDetailInfoProps> = ({
{/* Quantity + Add to Cart */} {/* Quantity + Add to Cart */}
<div className="mt-8 flex items-stretch gap-3"> <div className="mt-8 flex items-stretch gap-3">
<div className="flex items-center rounded-md border border-border h-11"> <div className="flex items-center rounded-md border border-border h-11">
<button <Button
onClick={() => setQuantity(Math.max(1, quantity - 1))} onClick={() => setQuantity(Math.max(1, quantity - 1))}
disabled={quantity <= 1} disabled={quantity <= 1}
variant="ghost"
size="icon"
aria-label="Decrease quantity" aria-label="Decrease quantity"
className="h-full px-3 text-foreground disabled:text-muted-foreground/50 transition-colors" className="h-full rounded-none rounded-l-md"
> >
<RiSubtractLine size={16} /> <RiSubtractLine size={16} />
</button> </Button>
<span className="w-8 text-center text-sm tabular-nums"> <span className="w-8 text-center text-sm tabular-nums">
{quantity} {quantity}
</span> </span>
<button <Button
onClick={() => setQuantity(quantity + 1)} onClick={() => setQuantity(quantity + 1)}
variant="ghost"
size="icon"
aria-label="Increase quantity" aria-label="Increase quantity"
className="h-full px-3 text-foreground transition-colors" className="h-full rounded-none rounded-r-md"
> >
<RiAddLine size={16} /> <RiAddLine size={16} />
</button> </Button>
</div> </div>
<button <Button
onClick={handleAddToCart} onClick={handleAddToCart}
disabled={!isAvailable || loading} 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" className="flex-1 h-11"
> >
{loading && <Loader size={16} />} {loading && <Loader size={16} />}
{isAvailable ? addToCartLabel : 'Out of Stock'} {isAvailable ? addToCartLabel : 'Out of Stock'}
</button> </Button>
</div> </div>
{/* Sends the shopper to the Shopify checkout, where Shop Pay is offered. */} {/* Cart permalink into Shop Pay; falls back to the cart checkout URL. */}
{handleBuyNow && ( <ShopPayButton
<button className="mt-3"
type="button" variants={
onClick={handleBuyNow} selectedVariant ? [{ id: selectedVariant.id, quantity }] : []
}
disabled={!isAvailable || buyingNow} 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" loading={buyingNow}
> onFallbackClick={handleBuyNow}
<span className="sr-only">Buy with</span> />
{buyingNow ? <Loader size={16} /> : <ShopPayLogo />}
</button>
)}
{/* Description */} {/* Description */}
{(product.descriptionHtml || product.description) && ( {(product.descriptionHtml || product.description) && (
+32 -40
View File
@@ -2,7 +2,7 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import ProductCard from './product-card'; import ProductCard from './product-card';
import { getProducts } from '@/hooks/use-shopify-products'; import { getProductsPage } from '@/hooks/use-shopify-products';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Loader } from '@/app/components/ui/loader'; import { Loader } from '@/app/components/ui/loader';
@@ -64,11 +64,11 @@ const Products: React.FC<ProductsProps> = ({
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [loadingMore, setLoadingMore] = useState(false); const [loadingMore, setLoadingMore] = useState(false);
const [hasMoreProducts, setHasMoreProducts] = useState(true); const [hasMoreProducts, setHasMoreProducts] = useState(true);
const [cursor, setCursor] = useState<string | null>(null);
const fetchProducts = async ( // Paging is cursor-based: without `after`, Shopify returns the same first
currentProducts: Product[] = [], // page every time and "load more" appends nothing.
loadMore = false const fetchProducts = async (loadMore = false) => {
) => {
try { try {
if (loadMore) { if (loadMore) {
setLoadingMore(true); setLoadingMore(true);
@@ -77,27 +77,25 @@ const Products: React.FC<ProductsProps> = ({
setError(null); setError(null);
} }
const newProducts = await getProducts({ const page = await getProductsPage({
first: limit, first: limit,
after: loadMore ? cursor : null,
sortKey: 'CREATED_AT', sortKey: 'CREATED_AT',
reverse: true, reverse: true,
}); });
if (loadMore) { setProducts((prev) => {
const existingIds = new Set(currentProducts.map((p) => p.id)); if (!loadMore) return page.products;
const uniqueNewProducts = newProducts.filter(
(p) => !existingIds.has(p.id)
);
if (uniqueNewProducts.length === 0) { const existingIds = new Set(prev.map((p) => p.id));
setHasMoreProducts(false); return [
} else { ...prev,
setProducts((prev) => [...prev, ...uniqueNewProducts]); ...page.products.filter((p) => !existingIds.has(p.id)),
} ];
} else { });
setProducts(newProducts);
setHasMoreProducts(newProducts.length === limit); setCursor(page.endCursor);
} setHasMoreProducts(page.hasNextPage);
} catch (err) { } catch (err) {
console.error('Error fetching products:', err); console.error('Error fetching products:', err);
setError(err instanceof Error ? err.message : 'Failed to load products'); setError(err instanceof Error ? err.message : 'Failed to load products');
@@ -113,7 +111,7 @@ const Products: React.FC<ProductsProps> = ({
const handleLoadMore = () => { const handleLoadMore = () => {
if (!loadingMore && hasMoreProducts) { if (!loadingMore && hasMoreProducts) {
fetchProducts(products, true); fetchProducts(true);
} }
}; };
@@ -146,7 +144,7 @@ const Products: React.FC<ProductsProps> = ({
if (error || products.length === 0) { if (error || products.length === 0) {
return ( return (
<div className="py-20 bg-zinc-50"> <div className="py-20 bg-background">
<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-2xl md:text-3xl font-normal max-w-3xl mx-auto text-foreground"> <h2 className="text-2xl md:text-3xl font-normal max-w-3xl mx-auto text-foreground">
{title} {title}
@@ -154,23 +152,22 @@ const Products: React.FC<ProductsProps> = ({
<p className="text-sm font-normal max-w-xl mx-auto text-muted-foreground mt-2.5 mb-12"> <p className="text-sm font-normal max-w-xl mx-auto text-muted-foreground mt-2.5 mb-12">
{subtitle} {subtitle}
</p> </p>
<div className="mx-auto max-w-sm bg-white border border-border rounded-3xl p-12"> <p className="text-sm text-muted-foreground mb-6">
<i className="ri-inbox-line text-6xl text-muted-foreground mb-6 block"></i>
<h3 className="font-semibold text-2xl mb-3 tracking-tight">
{error ? 'Connection Error' : 'Coming Soon'}
</h3>
<p className="text-muted-foreground mb-8 text-base">
{error || {error ||
'Our curated collection is being prepared. Please check back shortly.'} 'Our curated collection is being prepared. Please check back shortly.'}
</p> </p>
{error && ( {error && (
<Button onClick={() => fetchProducts()} className="btn-modern"> <Button
Try Again onClick={() => fetchProducts()}
variant="outline"
size="lg"
className="font-normal text-muted-foreground hover:text-foreground"
>
Try again
</Button> </Button>
)} )}
</div> </div>
</div> </div>
</div>
); );
} }
@@ -195,17 +192,12 @@ const Products: React.FC<ProductsProps> = ({
<Button <Button
onClick={handleLoadMore} onClick={handleLoadMore}
disabled={loadingMore} disabled={loadingMore}
variant="outline"
size="lg" size="lg"
className="btn-modern rounded-2xl px-14 py-7 text-sm tracking-widest font-medium border border-border" className="font-normal text-muted-foreground hover:text-foreground"
> >
{loadingMore ? ( {loadingMore && <Loader size={16} />}
<> {loadingMore ? 'Loading' : 'Load more'}
<Loader size={18} className="mr-3" />
LOADING MORE
</>
) : (
'LOAD MORE PRODUCTS'
)}
</Button> </Button>
</div> </div>
)} )}
+54 -104
View File
@@ -1,14 +1,10 @@
'use client'; 'use client';
import React, { useEffect, useRef, useState } from 'react'; import React from 'react';
import { SHOPIFY_STORE_DOMAIN } from '@/services/shopify/client'; import { SHOPIFY_STORE_DOMAIN } from '@/services/shopify/client';
import ShopPayLogo from '@/components/shopify/shop-pay-logo';
const SHOP_JS_URL = import { Loader } from '@/app/components/ui/loader';
'https://cdn.shopify.com/shopifycloud/shop-js/modules/v2/loader.pay-button.esm.js'; import { Button } from '@/components/ui/button';
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 { export interface ShopPayVariant {
id: string; id: string;
@@ -18,43 +14,13 @@ export interface ShopPayVariant {
interface ShopPayButtonProps { interface ShopPayButtonProps {
variants: ShopPayVariant[]; variants: ShopPayVariant[];
disabled?: boolean; disabled?: boolean;
loading?: boolean;
className?: string; className?: string;
width?: string; /** Used when no permalink can be built (missing domain or unusable IDs). */
borderRadius?: string; onFallbackClick?: () => void;
fallback?: React.ReactNode;
} }
let shopJsPromise: Promise<void> | null = null; // Cart permalinks need the bare numeric ID; the Storefront API returns GIDs.
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 { function toNumericVariantId(id: string): string | null {
const trimmed = id.trim(); const trimmed = id.trim();
const gid = trimmed.match(/^gid:\/\/shopify\/ProductVariant\/(\d+)/); const gid = trimmed.match(/^gid:\/\/shopify\/ProductVariant\/(\d+)/);
@@ -62,18 +28,6 @@ function toNumericVariantId(id: string): string | null {
return /^\d+$/.test(trimmed) ? trimmed : null; 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 { function toStoreUrl(domain?: string): string | null {
if (!domain) return null; if (!domain) return null;
try { try {
@@ -84,66 +38,62 @@ function toStoreUrl(domain?: string): string | null {
} }
} }
// https://{shop}/cart/{variantId}:{qty},{variantId}:{qty}?payment=shop_pay
// Loads the cart and drops the buyer straight into the Shop Pay checkout.
export function buildShopPayUrl(variants: ShopPayVariant[]): string | null {
const storeUrl = toStoreUrl(SHOPIFY_STORE_DOMAIN);
if (!storeUrl || variants.length === 0) return null;
const lines: string[] = [];
for (const { id, quantity = 1 } of variants) {
const numericId = toNumericVariantId(id);
if (!numericId || !Number.isInteger(quantity) || quantity < 1) return null;
lines.push(`${numericId}:${quantity}`);
}
return `${storeUrl}/cart/${lines.join(',')}?payment=shop_pay`;
}
const BUTTON_CLASSES =
'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';
const ShopPayButton: React.FC<ShopPayButtonProps> = ({ const ShopPayButton: React.FC<ShopPayButtonProps> = ({
variants, variants,
disabled = false, disabled = false,
className, loading = false,
width = '100%', className = '',
borderRadius = '8px', onFallbackClick,
fallback = null,
}) => { }) => {
const containerRef = useRef<HTMLDivElement>(null); const shopPayUrl = buildShopPayUrl(variants);
const [status, setStatus] = useState<'loading' | 'ready' | 'error'>( const contents = (
'loading' <>
<span className="sr-only">Buy with</span>
{loading ? <Loader size={16} /> : <ShopPayLogo />}
</>
); );
const storeUrl = toStoreUrl(SHOPIFY_STORE_DOMAIN); // An anchor keeps the checkout URL visible, openable in a new tab, and
const variantsAttribute = toVariantsAttribute(variants); // navigable without JS; the button only stands in when there's no URL.
if (shopPayUrl && !disabled) {
useEffect(() => { return (
if (!storeUrl || !variantsAttribute) return; <a
href={shopPayUrl}
let cancelled = false; className={`${BUTTON_CLASSES} ${className}`.trim()}
loadShopJs().then( >
() => !cancelled && setStatus('ready'), {contents}
() => !cancelled && setStatus('error') </a>
); );
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 ( return (
<div <Button
ref={containerRef} type="button"
className={className} onClick={onFallbackClick}
style={{ minHeight: MIN_HEIGHT }} disabled={disabled || (!shopPayUrl && !onFallbackClick)}
/> className={`${BUTTON_CLASSES} ${className}`.trim()}
>
{contents}
</Button>
); );
}; };
+20
View File
@@ -4,6 +4,10 @@ const CartFragment = `
id id
checkoutUrl checkoutUrl
totalQuantity totalQuantity
discountCodes {
code
applicable
}
cost { cost {
subtotalAmount { subtotalAmount {
amount amount
@@ -126,6 +130,22 @@ export const REMOVE_CART_LINES_MUTATION = `
} }
`; `;
// Apply (or clear) discount codes on the cart
export const UPDATE_CART_DISCOUNT_CODES_MUTATION = `
${CartFragment}
mutation UpdateCartDiscountCodes($cartId: ID!, $discountCodes: [String!]) {
cartDiscountCodesUpdate(cartId: $cartId, discountCodes: $discountCodes) {
cart {
...CartFragment
}
userErrors {
field
message
}
}
}
`;
// Get cart by ID // Get cart by ID
export const GET_CART_QUERY = ` export const GET_CART_QUERY = `
${CartFragment} ${CartFragment}
+2 -2
View File
@@ -103,8 +103,8 @@ export const ProductFragment = `
// Get multiple products // Get multiple products
export const GET_PRODUCTS_QUERY = ` export const GET_PRODUCTS_QUERY = `
${ProductFragment} ${ProductFragment}
query GetProducts($first: Int!, $query: String, $sortKey: ProductSortKeys, $reverse: Boolean) { query GetProducts($first: Int!, $after: String, $query: String, $sortKey: ProductSortKeys, $reverse: Boolean) {
products(first: $first, query: $query, sortKey: $sortKey, reverse: $reverse) { products(first: $first, after: $after, query: $query, sortKey: $sortKey, reverse: $reverse) {
edges { edges {
node { node {
...ProductFragment ...ProductFragment
+50
View File
@@ -7,6 +7,7 @@ import {
ADD_CART_LINES_MUTATION, ADD_CART_LINES_MUTATION,
UPDATE_CART_LINES_MUTATION, UPDATE_CART_LINES_MUTATION,
REMOVE_CART_LINES_MUTATION, REMOVE_CART_LINES_MUTATION,
UPDATE_CART_DISCOUNT_CODES_MUTATION,
GET_CART_QUERY, GET_CART_QUERY,
} from '@/graphql/cart'; } from '@/graphql/cart';
import { useEffect } from 'react'; import { useEffect } from 'react';
@@ -59,10 +60,16 @@ interface CartLine {
}; };
} }
export interface CartDiscountCode {
code: string;
applicable: boolean;
}
export interface Cart { export interface Cart {
id: string; id: string;
checkoutUrl: string; checkoutUrl: string;
totalQuantity: number; totalQuantity: number;
discountCodes?: CartDiscountCode[];
cost: { cost: {
subtotalAmount: { subtotalAmount: {
amount: string; amount: string;
@@ -147,6 +154,24 @@ async function removeCartLinesApi(
return response.data.cartLinesRemove.cart; return response.data.cartLinesRemove.cart;
} }
async function updateCartDiscountCodesApi(
cartId: string,
discountCodes: string[]
): Promise<Cart> {
const response = await shopifyFetch({
query: UPDATE_CART_DISCOUNT_CODES_MUTATION,
variables: { cartId, discountCodes },
});
if (response.data.cartDiscountCodesUpdate.userErrors.length > 0) {
throw new Error(
response.data.cartDiscountCodesUpdate.userErrors[0].message
);
}
return response.data.cartDiscountCodesUpdate.cart;
}
async function getCartApi(cartId: string): Promise<Cart | null> { async function getCartApi(cartId: string): Promise<Cart | null> {
const response = await shopifyFetch({ const response = await shopifyFetch({
query: GET_CART_QUERY, query: GET_CART_QUERY,
@@ -186,6 +211,7 @@ interface CartState {
addItem: (variantId: string, quantity?: number) => Promise<Cart>; addItem: (variantId: string, quantity?: number) => Promise<Cart>;
removeItem: (lineId: string) => Promise<Cart>; removeItem: (lineId: string) => Promise<Cart>;
updateItemQuantity: (lineId: string, quantity: number) => Promise<Cart>; updateItemQuantity: (lineId: string, quantity: number) => Promise<Cart>;
applyDiscountCode: (code: string) => Promise<Cart>;
refreshCart: () => Promise<void>; refreshCart: () => Promise<void>;
} }
@@ -312,6 +338,29 @@ export const useCartStore = create<CartState>((set, get) => ({
} }
}, },
// Apply a discount code. Shopify accepts unknown codes and reports them back
// as `applicable: false`, so callers should check the returned cart.
applyDiscountCode: async (code: string) => {
const { cartId } = get();
if (!cartId) throw new Error('No cart exists');
try {
set({ loading: true, error: null });
const trimmed = code.trim();
const updatedCart = await updateCartDiscountCodesApi(
cartId,
trimmed ? [trimmed] : []
);
set({ cart: updatedCart, loading: false });
return updatedCart;
} catch (err) {
const errorMessage =
err instanceof Error ? err.message : 'Failed to apply discount code';
set({ error: errorMessage, loading: false });
throw err;
}
},
// Refresh cart from Shopify // Refresh cart from Shopify
refreshCart: async () => { refreshCart: async () => {
const storedCartId = localStorage.getItem(CART_ID_KEY); const storedCartId = localStorage.getItem(CART_ID_KEY);
@@ -374,6 +423,7 @@ export function useShopifyCart() {
addItem: store.addItem, addItem: store.addItem,
removeItem: store.removeItem, removeItem: store.removeItem,
updateItemQuantity: store.updateItemQuantity, updateItemQuantity: store.updateItemQuantity,
applyDiscountCode: store.applyDiscountCode,
refreshCart: store.refreshCart, refreshCart: store.refreshCart,
}; };
} }
+27 -4
View File
@@ -81,11 +81,19 @@ export interface Product {
interface UseProductsOptions { interface UseProductsOptions {
first?: number; first?: number;
/** Cursor from a previous page's `endCursor`; omit for the first page. */
after?: string | null;
query?: string; query?: string;
sortKey?: 'BEST_SELLING' | 'CREATED_AT' | 'PRICE' | 'TITLE'; sortKey?: 'BEST_SELLING' | 'CREATED_AT' | 'PRICE' | 'TITLE';
reverse?: boolean; reverse?: boolean;
} }
export interface ProductsPage {
products: Product[];
hasNextPage: boolean;
endCursor: string | null;
}
interface UseProductsReturn { interface UseProductsReturn {
products: Product[]; products: Product[];
loading: boolean; loading: boolean;
@@ -94,18 +102,33 @@ interface UseProductsReturn {
} }
// Fetch multiple products // Fetch multiple products
export async function getProducts({ export async function getProducts(
options: UseProductsOptions = {}
): Promise<Product[]> {
const { products } = await getProductsPage(options);
return products;
}
// Same fetch, but keeps the cursor so callers can page through the catalogue.
export async function getProductsPage({
first = 20, first = 20,
after = null,
query = '', query = '',
sortKey = 'BEST_SELLING', sortKey = 'BEST_SELLING',
reverse = false, reverse = false,
}: UseProductsOptions = {}): Promise<Product[]> { }: UseProductsOptions = {}): Promise<ProductsPage> {
const response = await shopifyFetch({ const response = await shopifyFetch({
query: GET_PRODUCTS_QUERY, query: GET_PRODUCTS_QUERY,
variables: { first, query, sortKey, reverse }, variables: { first, after, query, sortKey, reverse },
}); });
return response.data.products.edges.map((edge: { node: Product }) => edge.node); const { edges, pageInfo } = response.data.products;
return {
products: edges.map((edge: { node: Product }) => edge.node),
hasNextPage: Boolean(pageInfo?.hasNextPage),
endCursor: pageInfo?.endCursor ?? null,
};
} }
// Fetch a single product by handle // Fetch a single product by handle