Files
shopify-template/components/shopify/cart-drawer.tsx
T
Rami BitarandClaude Opus 5 69f7435d6e Redesign storefront to match minimal reference design
Product cards, header, footer, PDP, and typography reworked toward a
leaner layout; adds shop policy pages backed by the Storefront API.

- Type: switch to Geist Sans/Mono, regular-weight headings
- Product cards: drop borders, rounded corners, and action buttons
- Header: shorter bar, no bottom border, center-out hover underline,
  bag icon replacing the cart icon (drawer wording updated to match)
- Footer: single line with policy links and social icons on bg-background
- Policies: /policies/[handle] renders shop.privacyPolicy,
  termsOfService, refundPolicy, shippingPolicy, subscriptionPolicy
  (SSG, hourly revalidation)
- PDP: image grid with mobile carousel + dots and click-to-zoom,
  sticky info column, colour swatches from option optionValues with a
  configurable name-to-colour fallback in config/swatches.ts,
  Shop-purple checkout button
- Recommendations: left-aligned heading on bg-background
- Ignore .env*.local and tsconfig.tsbuildinfo

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jWbNNJLksC1QG8z8845FX
2026-08-01 11:11:25 -04:00

253 lines
9.3 KiB
TypeScript

'use client';
import React from 'react';
import { useCartStore, redirectToCheckout } from '@/hooks/use-shopify-cart';
import { Button } from '@/components/ui/button';
import { Loader } from '@/app/components/ui/loader';
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetBody,
AnimatePresence,
} from '@/components/ui/sheet';
import {
RiCloseLine,
RiImageLine,
RiSubtractLine,
RiAddLine,
RiShoppingBagLine,
} from '@remixicon/react';
import {
Empty,
EmptyHeader,
EmptyTitle,
EmptyDescription,
EmptyContent,
} from '@/components/ui/empty';
const CartDrawer: React.FC = () => {
const isOpen = useCartStore((s) => s.isOpen);
const closeCart = useCartStore((s) => s.closeCart);
const loading = useCartStore((s) => s.loading);
const cart = useCartStore((s) => s.cart);
const removeItem = useCartStore((s) => s.removeItem);
const updateItemQuantity = useCartStore((s) => s.updateItemQuantity);
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 handleCheckout = () => {
if (checkoutUrl) {
redirectToCheckout(checkoutUrl);
}
};
const getItemImage = (item: (typeof items)[0]) => {
return item.merchandise.image?.url;
};
const getSelectedOptions = (item: (typeof items)[0]) => {
return item.merchandise.selectedOptions ?? [];
};
return (
<Sheet
open={isOpen}
onOpenChange={(open) => !open && closeCart()}
side="right"
>
<AnimatePresence>
{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">
<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>
<Button onClick={closeCart} variant="ghost" size="icon-sm">
<RiCloseLine size={20} />
</Button>
</div>
</SheetHeader>
{/* Cart Items */}
<SheetBody>
{loading && items.length === 0 ? (
<div className="flex items-center justify-center py-12">
<Loader size={32} />
</div>
) : items.length === 0 ? (
<Empty>
<EmptyHeader>
<EmptyTitle>Your bag is empty</EmptyTitle>
<EmptyDescription>
Add some products to get started!
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button onClick={closeCart} className="w-full">
Continue Shopping
</Button>
</EmptyContent>
</Empty>
) : (
<div className="space-y-6">
{items.map((item) => {
const image = getItemImage(item);
const selectedOptions = getSelectedOptions(item);
return (
<div
key={item.id}
className="flex items-start space-x-4 pb-6 border-b border-gray-200 last:border-b-0"
>
{/* Product Image */}
<div className="w-20 h-20 bg-gray-100 rounded-lg overflow-hidden flex-shrink-0">
{image ? (
<img
src={image}
alt={item.merchandise.product.title}
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>
)}
</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>
{/* 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>
)}
{/* Quantity Controls */}
<div className="flex items-center mt-3">
<div className="flex items-center border border-gray-300 rounded-lg">
<Button
onClick={() =>
updateItemQuantity(item.id, item.quantity - 1)
}
variant="ghost"
size="icon-sm"
disabled={item.quantity <= 1 || loading}
className="h-7 w-7"
>
<RiSubtractLine size={14} />
</Button>
<span className="px-2 py-1 font-semibold min-w-[30px] text-center text-sm">
{item.quantity}
</span>
<Button
onClick={() =>
updateItemQuantity(item.id, item.quantity + 1)
}
variant="ghost"
size="icon-sm"
disabled={loading}
className="h-7 w-7"
>
<RiAddLine size={14} />
</Button>
</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
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>
);
})}
</div>
)}
</SheetBody>
{/* Footer - Checkout Section */}
{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">
<Button
onClick={handleCheckout}
disabled={loading || !checkoutUrl}
className="w-full"
size="lg"
>
{loading ? (
<span className="flex items-center justify-center space-x-2">
<Loader size={16} />
<span>Processing...</span>
</span>
) : (
'Checkout'
)}
</Button>
<Button onClick={closeCart} variant="link" className="w-full">
Continue Shopping
</Button>
</div>
</div>
)}
</SheetContent>
)}
</AnimatePresence>
</Sheet>
);
};
export default CartDrawer;