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 (
<>
<Header
storeName="Stride"
storeName="Shop"
logoUrl=""
links={[
{ label: 'Products', url: '/' },
@@ -15,8 +15,8 @@ export default function Page() {
/>
<CollectionDetail />
<Footer
storeName="Stride"
copyright="© 2026 Stride. All rights reserved."
storeName="Shop"
copyright="© 2026 Shop. All rights reserved."
/>
</>
);
+3 -3
View File
@@ -6,7 +6,7 @@ export default function Page() {
return (
<>
<Header
storeName="Stride"
storeName="Shop"
logoUrl=""
links={[
{ label: 'Products', url: '/' },
@@ -15,8 +15,8 @@ export default function Page() {
/>
<Collections title="Our Collections" />
<Footer
storeName="Stride"
copyright="© 2026 Stride. All rights reserved."
storeName="Shop"
copyright="© 2026 Shop. All rights reserved."
/>
</>
);
+3 -3
View File
@@ -6,7 +6,7 @@ export default function Page() {
return (
<>
<Header
storeName="Stride"
storeName="Shop"
logoUrl=""
links={[
{ label: 'Products', url: '/' },
@@ -18,8 +18,8 @@ export default function Page() {
subtitle="An agent-friendly Shopify storefront built with Next.js and Hydrogen."
/>
<Footer
storeName="Stride"
copyright="© 2026 Stride. All rights reserved."
storeName="Shop"
copyright="© 2026 Shop. All rights reserved."
/>
</>
);
+4 -4
View File
@@ -16,7 +16,7 @@ export async function generateMetadata({
const policy = await getShopPolicy(handle);
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 (
<>
<Header
storeName="Stride"
storeName="Shop"
logoUrl=""
links={[
{ label: 'Products', url: '/' },
@@ -57,8 +57,8 @@ export default async function PolicyPage({
</main>
<Footer
storeName="Stride"
copyright="© 2026 Stride. All rights reserved."
storeName="Shop"
copyright="© 2026 Shop. All rights reserved."
/>
</>
);
+3 -3
View File
@@ -7,7 +7,7 @@ export default function Page() {
return (
<>
<Header
storeName="Stride"
storeName="Shop"
logoUrl=""
links={[
{ label: 'Products', url: '/' },
@@ -17,8 +17,8 @@ export default function Page() {
<ProductDetail addToCartLabel="Add to Cart" />
<ProductRecommendations title="You May Also Like" />
<Footer
storeName="Stride"
copyright="© 2026 Stride. All rights reserved."
storeName="Shop"
copyright="© 2026 Shop. All rights reserved."
/>
</>
);
+3 -3
View File
@@ -6,7 +6,7 @@ export default function Page() {
return (
<>
<Header
storeName="Stride"
storeName="Shop"
logoUrl=""
links={[
{ label: 'Products', url: '/' },
@@ -15,8 +15,8 @@ export default function Page() {
/>
<Collections title="Our Collections" />
<Footer
storeName="Stride"
copyright="© 2026 Stride. All rights reserved."
storeName="Shop"
copyright="© 2026 Shop. All rights reserved."
/>
</>
);
+185 -94
View File
@@ -1,6 +1,6 @@
'use client';
import React from 'react';
import React, { useState } from 'react';
import { useCartStore, redirectToCheckout } from '@/hooks/use-shopify-cart';
import { Button } from '@/components/ui/button';
import { Loader } from '@/app/components/ui/loader';
@@ -17,7 +17,6 @@ import {
RiImageLine,
RiSubtractLine,
RiAddLine,
RiShoppingBagLine,
} from '@remixicon/react';
import {
Empty,
@@ -34,11 +33,34 @@ const CartDrawer: React.FC = () => {
const cart = useCartStore((s) => s.cart);
const removeItem = useCartStore((s) => s.removeItem);
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 itemCount = items.reduce((sum, item) => sum + item.quantity, 0);
const totalAmount = parseFloat(cart?.cost?.totalAmount?.amount ?? '0');
const checkoutUrl = cart?.checkoutUrl ?? null;
const appliedDiscounts =
cart?.discountCodes?.filter((discount) => discount.applicable) ?? [];
const handleCheckout = () => {
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]) => {
return item.merchandise.image?.url;
};
@@ -64,20 +115,27 @@ const CartDrawer: React.FC = () => {
{isOpen && (
<SheetContent className="w-full max-w-md" showCloseButton={false}>
{/* 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">
<SheetTitle className="text-base flex items-center gap-x-2">
<RiShoppingBagLine size={18} />
Bag ({itemCount})
<SheetTitle className="text-base font-medium flex items-center gap-x-2">
Cart
<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>
<Button onClick={closeCart} variant="ghost" size="icon-sm">
<Button
onClick={closeCart}
variant="ghost"
size="icon-sm"
aria-label="Close cart"
>
<RiCloseLine size={20} />
</Button>
</div>
</SheetHeader>
{/* Cart Items */}
<SheetBody>
<SheetBody className="px-5">
{loading && items.length === 0 ? (
<div className="flex items-center justify-center py-12">
<Loader size={32} />
@@ -85,7 +143,7 @@ const CartDrawer: React.FC = () => {
) : items.length === 0 ? (
<Empty>
<EmptyHeader>
<EmptyTitle>Your bag is empty</EmptyTitle>
<EmptyTitle>Your cart is empty</EmptyTitle>
<EmptyDescription>
Add some products to get started!
</EmptyDescription>
@@ -97,18 +155,16 @@ const CartDrawer: React.FC = () => {
</EmptyContent>
</Empty>
) : (
<div className="space-y-6">
<div className="space-y-5">
{items.map((item) => {
const image = getItemImage(item);
const selectedOptions = getSelectedOptions(item);
const isPending = pendingLineId === item.id;
return (
<div
key={item.id}
className="flex items-start space-x-4 pb-6 border-b border-gray-200 last:border-b-0"
>
<div key={item.id} className="flex items-start gap-x-3">
{/* 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 ? (
<img
src={image}
@@ -116,86 +172,93 @@ const CartDrawer: React.FC = () => {
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center text-gray-400">
<RiImageLine size={24} />
<div className="w-full h-full flex items-center justify-center text-zinc-400">
<RiImageLine size={20} />
</div>
)}
</div>
{/* Product Details */}
<div className="flex-1 min-w-0">
<h4 className="font-semibold text-gray-900 mb-1 line-clamp-2">
{item.merchandise.product.title}
</h4>
<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}
</h4>
<span className="shrink-0 font-mono tabular-nums tracking-tight text-sm text-foreground">
$
{parseFloat(
item.cost?.totalAmount?.amount ??
item.merchandise.price.amount
).toFixed(2)}
</span>
</div>
{/* Variant Info */}
{selectedOptions.length > 0 && (
<div className="text-sm text-gray-500 mb-2">
{selectedOptions.map((option, index) => (
<span key={option.name}>
{option.value}
{index < selectedOptions.length - 1
? ' / '
: ''}
</span>
))}
<div className="text-xs text-muted-foreground mt-0.5">
{selectedOptions
.map((option) => option.value)
.join(' / ')}
</div>
)}
{/* Quantity Controls */}
<div className="flex items-center mt-3">
<div className="flex items-center border border-gray-300 rounded-lg">
<div className="flex items-center gap-x-2 mt-2">
<div className="inline-flex items-center rounded-full bg-secondary">
<Button
onClick={() =>
updateItemQuantity(item.id, item.quantity - 1)
runLineAction(item.id, () =>
updateItemQuantity(
item.id,
item.quantity - 1
)
)
}
disabled={item.quantity <= 1 || isPending}
variant="ghost"
size="icon-sm"
disabled={item.quantity <= 1 || loading}
className="h-7 w-7"
aria-label="Decrease quantity"
className="size-7 rounded-full"
>
<RiSubtractLine size={14} />
</Button>
<span className="px-2 py-1 font-semibold min-w-[30px] text-center text-sm">
{item.quantity}
<span className="min-w-8 px-1 text-sm tabular-nums text-center">
{isPending ? (
<Loader size={12} className="mx-auto" />
) : (
item.quantity
)}
</span>
<Button
onClick={() =>
updateItemQuantity(item.id, item.quantity + 1)
runLineAction(item.id, () =>
updateItemQuantity(
item.id,
item.quantity + 1
)
)
}
disabled={isPending}
variant="ghost"
size="icon-sm"
disabled={loading}
className="h-7 w-7"
aria-label="Increase quantity"
className="size-7 rounded-full"
>
<RiAddLine size={14} />
</Button>
</div>
<Button
onClick={() =>
runLineAction(item.id, () => removeItem(item.id))
}
disabled={isPending}
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"
>
remove
</Button>
</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
onClick={() => removeItem(item.id)}
variant="ghost"
size="icon-sm"
disabled={loading}
className="text-gray-400 hover:text-red-500"
>
<RiCloseLine size={18} />
</Button>
</div>
</div>
);
})}
@@ -203,43 +266,71 @@ const CartDrawer: React.FC = () => {
)}
</SheetBody>
{/* Footer - Checkout Section */}
{/* Footer — discount, total, checkout */}
{items.length > 0 && (
<div className="border-t border-border p-6">
{/* Subtotal */}
<div className="flex items-center justify-between mb-4">
<span className="text-base font-semibold">Subtotal</span>
<span className="text-lg font-bold">
${totalAmount.toFixed(2)}
</span>
</div>
<div className="text-sm text-gray-500 mb-4">
Shipping and taxes calculated at checkout
</div>
{/* Action Buttons */}
<div className="space-y-3">
<div className="px-5 py-5 space-y-4">
{/* Discount Code */}
<form onSubmit={handleApplyDiscount} className="flex gap-x-2">
<input
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
onClick={handleCheckout}
disabled={loading || !checkoutUrl}
className="w-full"
size="lg"
type="submit"
disabled={!discountCode.trim() || applyingDiscount}
className="h-10 bg-muted-foreground px-5 hover:bg-foreground"
>
{loading ? (
<span className="flex items-center justify-center space-x-2">
<Loader size={16} />
<span>Processing...</span>
</span>
) : (
'Checkout'
)}
{applyingDiscount ? <Loader size={16} /> : 'Apply'}
</Button>
</form>
<Button onClick={closeCart} variant="link" className="w-full">
Continue Shopping
</Button>
{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)}
</span>
</div>
<p className="text-xs text-muted-foreground mt-1">
Taxes and shipping calculated at checkout.
</p>
</div>
<Button
onClick={handleCheckout}
disabled={!checkoutUrl || pendingLineId !== null}
className="h-12 w-full"
>
Go to Checkout
</Button>
<Button
onClick={closeCart}
variant="ghost"
className="w-full font-normal text-muted-foreground hover:text-foreground"
>
Continue Shopping
</Button>
</div>
)}
</SheetContent>
+19 -38
View File
@@ -1,6 +1,5 @@
import React from 'react';
import Link from 'next/link';
import { Card, CardContent } from '@/components/ui/card';
interface CollectionImage {
url: string;
@@ -25,44 +24,26 @@ const CollectionCard: React.FC<CollectionCardProps> = ({ collection }) => {
href={`/collections/${collection.handle}`}
className="group block h-full"
>
<div className="card-modern bg-card rounded-3xl overflow-hidden h-full flex flex-col shadow-sm">
{/* Collection Image */}
<div className="aspect-[16/10] relative overflow-hidden bg-zinc-100">
{collection.image ? (
<img
src={collection.image.url}
alt={collection.image.altText || collection.title}
className="w-full h-full object-cover transition-transform duration-700 group-hover:scale-110"
/>
) : (
<div className="absolute inset-0 flex flex-col items-center justify-center text-zinc-300">
<i className="ri-folder-line text-8xl mb-4"></i>
<span className="text-xs tracking-widest font-mono">
COLLECTION
</span>
</div>
)}
<div className="absolute inset-0 bg-gradient-to-b from-black/10 via-transparent to-black/30"></div>
</div>
{/* Collection Info */}
<div className="p-8 flex flex-col flex-1">
<h3 className="font-heading text-3xl font-semibold tracking-tighter text-foreground mb-4 group-hover:text-primary transition-colors">
{collection.title}
</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>
{/* Collection Image */}
<div className="relative aspect-square overflow-hidden bg-white">
{collection.image ? (
<img
src={collection.image.url}
alt={collection.image.altText || collection.title}
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-[1.04]"
/>
) : (
<div className="absolute inset-0 flex items-center justify-center text-zinc-300">
<i className="ri-folder-line text-8xl"></i>
</div>
</div>
)}
</div>
{/* Collection Info */}
<div className="flex flex-col flex-1 py-2.5">
<h3 className="text-sm font-medium text-foreground line-clamp-1">
{collection.title}
</h3>
</div>
</Link>
);
+33 -53
View File
@@ -4,11 +4,20 @@ import React from 'react';
import { useParams } from 'next/navigation';
import { useCollectionProducts } from '@/hooks/use-shopify-collections';
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 params = useParams();
const handle = params?.handle as string;
console.log('[CollectionDetail] params:', params, 'handle:', handle);
const { collection, loading, error, refetch } = useCollectionProducts(handle);
@@ -19,25 +28,18 @@ const CollectionDetail: React.FC = () => {
if (loading) {
return (
<div className="py-16">
<div className="container mx-auto px-4">
<h2 className="text-5xl font-bold text-center mb-16 text-gray-900 font-heading">
{formattedTitle}
</h2>
<div className="py-16 bg-background">
<div className="max-w-screen-2xl mx-auto px-8">
<CollectionTitle title={formattedTitle} />
{/* 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) => (
<div
key={index}
className="bg-white rounded-lg shadow-md overflow-hidden animate-pulse"
>
<div className="aspect-square bg-gray-200"></div>
<div className="p-6">
<div className="h-6 bg-gray-200 rounded mb-2"></div>
<div className="h-4 bg-gray-200 rounded mb-4"></div>
<div className="h-8 bg-gray-200 rounded mb-4"></div>
<div className="h-12 bg-gray-200 rounded"></div>
<div 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>
))}
@@ -49,25 +51,13 @@ const CollectionDetail: React.FC = () => {
if (error) {
return (
<div className="py-16">
<div className="container mx-auto px-4 text-center">
<h2 className="text-5xl font-bold mb-8 font-heading">
{formattedTitle}
</h2>
<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
</button>
</div>
<div className="py-16 bg-background">
<div className="max-w-screen-2xl mx-auto px-8 text-center">
<CollectionTitle title={formattedTitle} />
<p className="text-sm text-muted-foreground mb-6">{error}</p>
<Button onClick={() => refetch()} variant="outline">
Try Again
</Button>
</div>
</div>
);
@@ -77,26 +67,16 @@ const CollectionDetail: React.FC = () => {
const title = collection?.title || formattedTitle;
return (
<div className="py-16">
<div className="container mx-auto px-4">
<h2 className="text-5xl font-bold text-center mb-16 text-gray-900 font-heading">
{title}
</h2>
<div className="py-16 bg-background">
<div className="max-w-screen-2xl mx-auto px-8">
<CollectionTitle title={title} />
{products.length === 0 ? (
<div className="text-center py-12">
<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.
</p>
</div>
</div>
<p className="text-center text-sm text-muted-foreground">
This collection doesn&apos;t have any products yet.
</p>
) : (
<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) => (
<ProductCard key={product.id} product={product} />
))}
+42 -63
View File
@@ -7,40 +7,45 @@ import { Button } from '@/components/ui/button';
interface CollectionsProps {
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> = ({
title = 'Our Collections',
subtitle = 'Discover our carefully crafted worlds',
}) => {
const { collections, loading, error, refetch } = useCollections(12);
if (loading) {
return (
<div className="py-20">
<div className="py-20 bg-background">
<div className="max-w-screen-2xl mx-auto px-8">
<div className="flex justify-center mb-6">
<div className="inline px-6 py-2 bg-zinc-100 rounded-3xl text-xs font-mono tracking-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>
<SectionHeader title={title} subtitle={subtitle} />
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{Array.from({ length: 6 }).map((_, index) => (
<div
key={index}
className="bg-white rounded-3xl overflow-hidden animate-pulse border border-border h-[520px]"
>
<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 className={GRID_CLASSES}>
{Array.from({ length: 8 }).map((_, index) => (
<div key={index} className="animate-pulse">
<div className="aspect-square bg-zinc-100"></div>
<div className="pt-4">
<div className="h-4 bg-zinc-200 w-3/5"></div>
</div>
</div>
))}
@@ -52,21 +57,13 @@ const Collections: React.FC<CollectionsProps> = ({
if (error) {
return (
<div className="py-20">
<div className="py-20 bg-background">
<div className="max-w-screen-2xl mx-auto px-8 text-center">
<h2 className="text-6xl font-bold tracking-tighter font-heading mb-8">
{title}
</h2>
<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
</Button>
</div>
<SectionHeader title={title} subtitle={subtitle} />
<p className="text-sm text-muted-foreground mb-6">{error}</p>
<Button onClick={refetch} variant="outline">
Retry
</Button>
</div>
</div>
);
@@ -74,41 +71,23 @@ const Collections: React.FC<CollectionsProps> = ({
if (collections.length === 0) {
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">
<h2 className="text-6xl font-bold tracking-tighter font-heading mb-8">
{title}
</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.
</p>
</div>
<SectionHeader title={title} subtitle={subtitle} />
<p className="text-sm text-muted-foreground">
Collections will appear here once added to your Shopify store.
</p>
</div>
</div>
);
}
return (
<div className="py-20">
<div className="py-20 bg-background">
<div className="max-w-screen-2xl mx-auto px-8">
<div className="flex justify-center mb-6">
<div className="inline px-6 py-2 bg-zinc-100 rounded-3xl text-xs font-mono tracking-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>
<SectionHeader title={title} subtitle={subtitle} />
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
<div className={GRID_CLASSES}>
{collections.map((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 CartDrawer from '@/components/shopify/cart-drawer';
import { RiShoppingBagLine, RiCloseLine, RiMenu3Line } from '@remixicon/react';
import { Button } from '@/components/ui/button';
const CartIcon: React.FC = () => {
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;
return (
<button
<Button
onClick={toggleCart}
variant="ghost"
size="icon"
aria-label={`Open bag (${itemCount})`}
className="relative p-2 text-foreground hover:text-muted-foreground transition-colors"
className="relative"
>
<RiShoppingBagLine size={20} />
{itemCount > 0 && (
@@ -24,7 +27,7 @@ const CartIcon: React.FC = () => {
{itemCount > 99 ? '99+' : itemCount}
</span>
)}
</button>
</Button>
);
};
@@ -40,7 +43,7 @@ interface HeaderProps {
}
const Header: React.FC<HeaderProps> = ({
storeName = 'STRIDE',
storeName = 'Shop',
logoUrl,
links = [
{ label: 'Shop', url: '/' },
@@ -62,7 +65,7 @@ const Header: React.FC<HeaderProps> = ({
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}
</span>
)}
@@ -86,13 +89,15 @@ const Header: React.FC<HeaderProps> = ({
<CartIcon />
{/* Mobile hamburger */}
<button
<Button
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"
>
{menuOpen ? <RiCloseLine size={22} /> : <RiMenu3Line size={22} />}
</button>
</Button>
</div>
</div>
</div>
+2 -2
View File
@@ -80,13 +80,13 @@ const ProductCard: React.FC<ProductCardProps> = ({ product }) => {
)}
{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
</span>
)}
{!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
</span>
)}
@@ -2,6 +2,7 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { RiCloseLine } from '@remixicon/react';
import { Button } from '@/components/ui/button';
interface ProductImage {
url: string;
@@ -95,7 +96,7 @@ const ProductDetailGallery: React.FC<ProductDetailGalleryProps> = ({
key={index}
onClick={() => setZoomedIndex(index)}
aria-label={`Zoom ${image.altText || 'product image'}`}
className={`relative aspect-square w-full shrink-0 snap-center overflow-hidden bg-white cursor-zoom-in sm:shrink ${
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' : ''
}`}
>
@@ -142,13 +143,15 @@ const ProductDetailGallery: React.FC<ProductDetailGalleryProps> = ({
className="max-h-full max-w-full object-contain bg-white"
/>
<button
<Button
onClick={close}
variant="ghost"
size="icon-lg"
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} />
</button>
</Button>
</div>
)}
</>
@@ -1,7 +1,8 @@
import React from 'react';
import { Button } from '@/components/ui/button';
import { Loader } from '@/app/components/ui/loader';
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 { isSwatchOptionName, swatchColorForName } from '@/config/swatches';
@@ -147,13 +148,15 @@ const ProductDetailInfo: React.FC<ProductDetailInfoProps> = ({
const { background, image } = swatchStyle(value);
return (
<button
<Button
key={value.id}
onClick={() => onOptionChange(option.name, value.name)}
variant="ghost"
size="icon-sm"
title={value.name}
aria-label={value.name}
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
? 'ring-2 ring-foreground ring-offset-2'
: 'ring-1 ring-border hover:ring-foreground/40'
@@ -166,23 +169,24 @@ const ProductDetailInfo: React.FC<ProductDetailInfoProps> = ({
{!background && !image && (
<span className="text-[10px]">{value.name.at(0)}</span>
)}
</button>
</Button>
);
}
return (
<button
<Button
key={value.id}
onClick={() => onOptionChange(option.name, value.name)}
variant="outline"
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
? '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}
</button>
</Button>
);
})}
</div>
@@ -193,48 +197,50 @@ const ProductDetailInfo: React.FC<ProductDetailInfoProps> = ({
{/* 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
<Button
onClick={() => setQuantity(Math.max(1, quantity - 1))}
disabled={quantity <= 1}
variant="ghost"
size="icon"
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} />
</button>
</Button>
<span className="w-8 text-center text-sm tabular-nums">
{quantity}
</span>
<button
<Button
onClick={() => setQuantity(quantity + 1)}
variant="ghost"
size="icon"
aria-label="Increase quantity"
className="h-full px-3 text-foreground transition-colors"
className="h-full rounded-none rounded-r-md"
>
<RiAddLine size={16} />
</button>
</Button>
</div>
<button
<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"
className="flex-1 h-11"
>
{loading && <Loader size={16} />}
{isAvailable ? addToCartLabel : 'Out of Stock'}
</button>
</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>
)}
{/* Cart permalink into Shop Pay; falls back to the cart checkout URL. */}
<ShopPayButton
className="mt-3"
variants={
selectedVariant ? [{ id: selectedVariant.id, quantity }] : []
}
disabled={!isAvailable || buyingNow}
loading={buyingNow}
onFallbackClick={handleBuyNow}
/>
{/* Description */}
{(product.descriptionHtml || product.description) && (
+38 -46
View File
@@ -2,7 +2,7 @@
import React, { useState, useEffect } from 'react';
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 { Loader } from '@/app/components/ui/loader';
@@ -64,11 +64,11 @@ const Products: React.FC<ProductsProps> = ({
const [error, setError] = useState<string | null>(null);
const [loadingMore, setLoadingMore] = useState(false);
const [hasMoreProducts, setHasMoreProducts] = useState(true);
const [cursor, setCursor] = useState<string | null>(null);
const fetchProducts = async (
currentProducts: Product[] = [],
loadMore = false
) => {
// Paging is cursor-based: without `after`, Shopify returns the same first
// page every time and "load more" appends nothing.
const fetchProducts = async (loadMore = false) => {
try {
if (loadMore) {
setLoadingMore(true);
@@ -77,27 +77,25 @@ const Products: React.FC<ProductsProps> = ({
setError(null);
}
const newProducts = await getProducts({
const page = await getProductsPage({
first: limit,
after: loadMore ? cursor : null,
sortKey: 'CREATED_AT',
reverse: true,
});
if (loadMore) {
const existingIds = new Set(currentProducts.map((p) => p.id));
const uniqueNewProducts = newProducts.filter(
(p) => !existingIds.has(p.id)
);
setProducts((prev) => {
if (!loadMore) return page.products;
if (uniqueNewProducts.length === 0) {
setHasMoreProducts(false);
} else {
setProducts((prev) => [...prev, ...uniqueNewProducts]);
}
} else {
setProducts(newProducts);
setHasMoreProducts(newProducts.length === limit);
}
const existingIds = new Set(prev.map((p) => p.id));
return [
...prev,
...page.products.filter((p) => !existingIds.has(p.id)),
];
});
setCursor(page.endCursor);
setHasMoreProducts(page.hasNextPage);
} catch (err) {
console.error('Error fetching products:', err);
setError(err instanceof Error ? err.message : 'Failed to load products');
@@ -113,7 +111,7 @@ const Products: React.FC<ProductsProps> = ({
const handleLoadMore = () => {
if (!loadingMore && hasMoreProducts) {
fetchProducts(products, true);
fetchProducts(true);
}
};
@@ -146,7 +144,7 @@ const Products: React.FC<ProductsProps> = ({
if (error || products.length === 0) {
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">
<h2 className="text-2xl md:text-3xl font-normal max-w-3xl mx-auto text-foreground">
{title}
@@ -154,21 +152,20 @@ const Products: React.FC<ProductsProps> = ({
<p className="text-sm font-normal max-w-xl mx-auto text-muted-foreground mt-2.5 mb-12">
{subtitle}
</p>
<div className="mx-auto max-w-sm bg-white border border-border rounded-3xl p-12">
<i className="ri-inbox-line text-6xl text-muted-foreground mb-6 block"></i>
<h3 className="font-semibold text-2xl mb-3 tracking-tight">
{error ? 'Connection Error' : 'Coming Soon'}
</h3>
<p className="text-muted-foreground mb-8 text-base">
{error ||
'Our curated collection is being prepared. Please check back shortly.'}
</p>
{error && (
<Button onClick={() => fetchProducts()} className="btn-modern">
Try Again
</Button>
)}
</div>
<p className="text-sm text-muted-foreground mb-6">
{error ||
'Our curated collection is being prepared. Please check back shortly.'}
</p>
{error && (
<Button
onClick={() => fetchProducts()}
variant="outline"
size="lg"
className="font-normal text-muted-foreground hover:text-foreground"
>
Try again
</Button>
)}
</div>
</div>
);
@@ -195,17 +192,12 @@ const Products: React.FC<ProductsProps> = ({
<Button
onClick={handleLoadMore}
disabled={loadingMore}
variant="outline"
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 ? (
<>
<Loader size={18} className="mr-3" />
LOADING MORE
</>
) : (
'LOAD MORE PRODUCTS'
)}
{loadingMore && <Loader size={16} />}
{loadingMore ? 'Loading' : 'Load more'}
</Button>
</div>
)}
+54 -104
View File
@@ -1,14 +1,10 @@
'use client';
import React, { useEffect, useRef, useState } from 'react';
import React 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;
import ShopPayLogo from '@/components/shopify/shop-pay-logo';
import { Loader } from '@/app/components/ui/loader';
import { Button } from '@/components/ui/button';
export interface ShopPayVariant {
id: string;
@@ -18,43 +14,13 @@ export interface ShopPayVariant {
interface ShopPayButtonProps {
variants: ShopPayVariant[];
disabled?: boolean;
loading?: boolean;
className?: string;
width?: string;
borderRadius?: string;
fallback?: React.ReactNode;
/** Used when no permalink can be built (missing domain or unusable IDs). */
onFallbackClick?: () => void;
}
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.
// Cart permalinks need the bare numeric ID; the Storefront API returns GIDs.
function toNumericVariantId(id: string): string | null {
const trimmed = id.trim();
const gid = trimmed.match(/^gid:\/\/shopify\/ProductVariant\/(\d+)/);
@@ -62,18 +28,6 @@ function toNumericVariantId(id: string): string | 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 {
if (!domain) return null;
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> = ({
variants,
disabled = false,
className,
width = '100%',
borderRadius = '8px',
fallback = null,
loading = false,
className = '',
onFallbackClick,
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const [status, setStatus] = useState<'loading' | 'ready' | 'error'>(
'loading'
const shopPayUrl = buildShopPayUrl(variants);
const contents = (
<>
<span className="sr-only">Buy with</span>
{loading ? <Loader size={16} /> : <ShopPayLogo />}
</>
);
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')
// An anchor keeps the checkout URL visible, openable in a new tab, and
// navigable without JS; the button only stands in when there's no URL.
if (shopPayUrl && !disabled) {
return (
<a
href={shopPayUrl}
className={`${BUTTON_CLASSES} ${className}`.trim()}
>
{contents}
</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 (
<div
ref={containerRef}
className={className}
style={{ minHeight: MIN_HEIGHT }}
/>
<Button
type="button"
onClick={onFallbackClick}
disabled={disabled || (!shopPayUrl && !onFallbackClick)}
className={`${BUTTON_CLASSES} ${className}`.trim()}
>
{contents}
</Button>
);
};
+20
View File
@@ -4,6 +4,10 @@ const CartFragment = `
id
checkoutUrl
totalQuantity
discountCodes {
code
applicable
}
cost {
subtotalAmount {
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
export const GET_CART_QUERY = `
${CartFragment}
+2 -2
View File
@@ -103,8 +103,8 @@ export const ProductFragment = `
// Get multiple products
export const GET_PRODUCTS_QUERY = `
${ProductFragment}
query GetProducts($first: Int!, $query: String, $sortKey: ProductSortKeys, $reverse: Boolean) {
products(first: $first, query: $query, sortKey: $sortKey, reverse: $reverse) {
query GetProducts($first: Int!, $after: String, $query: String, $sortKey: ProductSortKeys, $reverse: Boolean) {
products(first: $first, after: $after, query: $query, sortKey: $sortKey, reverse: $reverse) {
edges {
node {
...ProductFragment
+50
View File
@@ -7,6 +7,7 @@ import {
ADD_CART_LINES_MUTATION,
UPDATE_CART_LINES_MUTATION,
REMOVE_CART_LINES_MUTATION,
UPDATE_CART_DISCOUNT_CODES_MUTATION,
GET_CART_QUERY,
} from '@/graphql/cart';
import { useEffect } from 'react';
@@ -59,10 +60,16 @@ interface CartLine {
};
}
export interface CartDiscountCode {
code: string;
applicable: boolean;
}
export interface Cart {
id: string;
checkoutUrl: string;
totalQuantity: number;
discountCodes?: CartDiscountCode[];
cost: {
subtotalAmount: {
amount: string;
@@ -147,6 +154,24 @@ async function removeCartLinesApi(
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> {
const response = await shopifyFetch({
query: GET_CART_QUERY,
@@ -186,6 +211,7 @@ interface CartState {
addItem: (variantId: string, quantity?: number) => Promise<Cart>;
removeItem: (lineId: string) => Promise<Cart>;
updateItemQuantity: (lineId: string, quantity: number) => Promise<Cart>;
applyDiscountCode: (code: string) => Promise<Cart>;
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
refreshCart: async () => {
const storedCartId = localStorage.getItem(CART_ID_KEY);
@@ -374,6 +423,7 @@ export function useShopifyCart() {
addItem: store.addItem,
removeItem: store.removeItem,
updateItemQuantity: store.updateItemQuantity,
applyDiscountCode: store.applyDiscountCode,
refreshCart: store.refreshCart,
};
}
+27 -4
View File
@@ -81,11 +81,19 @@ export interface Product {
interface UseProductsOptions {
first?: number;
/** Cursor from a previous page's `endCursor`; omit for the first page. */
after?: string | null;
query?: string;
sortKey?: 'BEST_SELLING' | 'CREATED_AT' | 'PRICE' | 'TITLE';
reverse?: boolean;
}
export interface ProductsPage {
products: Product[];
hasNextPage: boolean;
endCursor: string | null;
}
interface UseProductsReturn {
products: Product[];
loading: boolean;
@@ -94,18 +102,33 @@ interface UseProductsReturn {
}
// 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,
after = null,
query = '',
sortKey = 'BEST_SELLING',
reverse = false,
}: UseProductsOptions = {}): Promise<Product[]> {
}: UseProductsOptions = {}): Promise<ProductsPage> {
const response = await shopifyFetch({
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