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
+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>