262 lines
11 KiB
TypeScript
262 lines
11 KiB
TypeScript
/**
|
|
* @component CartDrawer
|
|
* @purpose Slide-over cart preview with quantity controls, subtotal, and checkout CTA.
|
|
*/
|
|
'use client';
|
|
|
|
import { useEffect } from 'react';
|
|
import Link from 'next/link';
|
|
import { X, Trash2, Plus, Minus, ShoppingBag, ArrowRight, Truck } from 'lucide-react';
|
|
import { useCartStore } from '@/store/cartStore';
|
|
import { formatCurrency, getImageUrl } from '@/lib/utils';
|
|
import { requireLoginForCheckout } from '@/lib/requireLoginForCheckout';
|
|
|
|
function resolveItemImage(item: {
|
|
product: { images?: { image_url?: string }[]; name?: string };
|
|
selectedVariant: { images?: { image_url?: string }[] };
|
|
}): string {
|
|
const fromVariant = item.selectedVariant?.images?.[0]?.image_url;
|
|
const fromProduct = item.product?.images?.[0]?.image_url;
|
|
return getImageUrl(fromVariant || fromProduct || '');
|
|
}
|
|
|
|
export function CartDrawer() {
|
|
const isOpen = useCartStore((state) => state.cartDrawerOpen);
|
|
const setIsOpen = useCartStore((state) => state.setCartDrawerOpen);
|
|
const cart = useCartStore((state) => state.cart);
|
|
const removeFromCart = useCartStore((state) => state.removeFromCart);
|
|
const updateQuantity = useCartStore((state) => state.updateQuantity);
|
|
const getCartTotal = useCartStore((state) => state.getCartTotal);
|
|
|
|
useEffect(() => {
|
|
if (!isOpen) return;
|
|
const prev = document.body.style.overflow;
|
|
document.body.style.overflow = 'hidden';
|
|
const onKey = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape') setIsOpen(false);
|
|
};
|
|
document.addEventListener('keydown', onKey);
|
|
return () => {
|
|
document.body.style.overflow = prev;
|
|
document.removeEventListener('keydown', onKey);
|
|
};
|
|
}, [isOpen, setIsOpen]);
|
|
|
|
if (!isOpen) return null;
|
|
|
|
const total = getCartTotal();
|
|
const itemCount = cart.reduce((sum, i) => sum + (i.quantity || 1), 0);
|
|
const freeShippingThreshold = 999;
|
|
const progressPercent = Math.min(100, (total / freeShippingThreshold) * 100);
|
|
const amountNeeded = Math.max(0, freeShippingThreshold - total);
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-[200]" role="dialog" aria-modal="true" aria-label="Shopping cart">
|
|
{/* Backdrop */}
|
|
<button
|
|
type="button"
|
|
aria-label="Close cart"
|
|
onClick={() => setIsOpen(false)}
|
|
className="absolute inset-0 bg-black/55 border-0 cursor-pointer"
|
|
/>
|
|
|
|
{/* Panel */}
|
|
<div className="absolute inset-y-0 right-0 w-full max-w-[400px] bg-white shadow-2xl flex flex-col">
|
|
{/* Header */}
|
|
<div className="shrink-0 px-4 py-3.5 border-b border-gray-200 flex items-center justify-between bg-white">
|
|
<div className="flex items-center gap-2">
|
|
<ShoppingBag className="w-5 h-5 text-primary" />
|
|
<h2 className="text-[15px] font-bold text-[#1a1a1a]">
|
|
Shopping Cart ({itemCount})
|
|
</h2>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => setIsOpen(false)}
|
|
className="p-1.5 rounded-full text-gray-500 hover:bg-gray-100 hover:text-gray-800 transition-colors cursor-pointer"
|
|
aria-label="Close"
|
|
>
|
|
<X className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Free shipping bar */}
|
|
{cart.length > 0 && (
|
|
<div className="shrink-0 px-4 py-3 bg-[#f0f7ff] border-b border-[#d6e8fb]">
|
|
<div className="flex items-start gap-2 text-[12px] font-semibold text-[#1a1a1a] mb-2">
|
|
<Truck className="w-4 h-4 text-primary shrink-0 mt-0.5" />
|
|
{amountNeeded > 0 ? (
|
|
<span>
|
|
Add{' '}
|
|
<strong className="text-primary">{formatCurrency(amountNeeded)}</strong>{' '}
|
|
more for FREE Shipping!
|
|
</span>
|
|
) : (
|
|
<span className="text-emerald-600">
|
|
You unlocked FREE Delivery!
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="w-full h-1.5 bg-white rounded-full overflow-hidden">
|
|
<div
|
|
className="h-full bg-primary rounded-full transition-all duration-300"
|
|
style={{ width: `${progressPercent}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Scrollable items */}
|
|
<div className="flex-1 min-h-0 overflow-y-auto">
|
|
{cart.length === 0 ? (
|
|
<div className="h-full flex flex-col items-center justify-center text-center px-6 py-16">
|
|
<ShoppingBag className="w-14 h-14 text-gray-200 mb-3 stroke-[1.25]" />
|
|
<p className="text-sm font-bold text-[#1a1a1a]">Your cart is empty</p>
|
|
<p className="text-xs text-gray-400 mt-1.5 max-w-[240px]">
|
|
Add products from the shop to see them here.
|
|
</p>
|
|
<button
|
|
type="button"
|
|
onClick={() => setIsOpen(false)}
|
|
className="mt-5 bg-primary text-white text-xs font-bold px-5 py-2.5 rounded-md cursor-pointer hover:brightness-95"
|
|
>
|
|
Continue Shopping
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<ul className="divide-y divide-gray-100">
|
|
{cart.map((item) => {
|
|
const imageUrl = resolveItemImage(item);
|
|
const lineTotal =
|
|
Number(item.selectedVariant.price || 0) * (item.quantity || 1);
|
|
const maxStock =
|
|
typeof item.selectedVariant.available_stock === 'number'
|
|
? item.selectedVariant.available_stock
|
|
: undefined;
|
|
|
|
return (
|
|
<li key={item.id} className="p-4 flex gap-3">
|
|
{/* Thumbnail */}
|
|
<Link
|
|
href={`/products/${item.product.slug || item.product.product_id}`}
|
|
onClick={() => setIsOpen(false)}
|
|
className="relative w-[72px] h-[72px] rounded-md border border-gray-200 overflow-hidden bg-gray-50 shrink-0 flex items-center justify-center"
|
|
>
|
|
{imageUrl ? (
|
|
<img
|
|
src={imageUrl}
|
|
alt={item.product.name}
|
|
className="max-w-full max-h-full object-contain p-1"
|
|
/>
|
|
) : (
|
|
<ShoppingBag className="w-6 h-6 text-gray-300" />
|
|
)}
|
|
</Link>
|
|
|
|
{/* Details */}
|
|
<div className="flex-1 min-w-0 flex flex-col">
|
|
<Link
|
|
href={`/products/${item.product.slug || item.product.product_id}`}
|
|
onClick={() => setIsOpen(false)}
|
|
className="text-[13px] font-semibold text-[#1a1a1a] leading-snug line-clamp-2 hover:text-primary transition-colors"
|
|
>
|
|
{item.product.name}
|
|
</Link>
|
|
|
|
{item.selectedVariant.sku && (
|
|
<span className="text-[10px] text-gray-400 mt-0.5 truncate">
|
|
SKU: {item.selectedVariant.sku}
|
|
</span>
|
|
)}
|
|
|
|
<div className="mt-1.5 flex items-baseline gap-2">
|
|
<span className="text-[14px] font-bold text-[#e11d48]">
|
|
{formatCurrency(lineTotal)}
|
|
</span>
|
|
{(item.quantity || 1) > 1 && (
|
|
<span className="text-[11px] text-gray-400">
|
|
{formatCurrency(Number(item.selectedVariant.price || 0))} each
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
<div className="mt-auto pt-2.5 flex items-center justify-between gap-2">
|
|
<div className="inline-flex items-center border border-gray-200 rounded-md overflow-hidden bg-white">
|
|
<button
|
|
type="button"
|
|
onClick={() => updateQuantity(item.id, item.quantity - 1)}
|
|
className="w-8 h-8 flex items-center justify-center text-gray-600 hover:bg-gray-50 cursor-pointer"
|
|
aria-label="Decrease quantity"
|
|
>
|
|
<Minus className="w-3.5 h-3.5" />
|
|
</button>
|
|
<span className="w-9 text-center text-[13px] font-bold text-[#1a1a1a] border-x border-gray-200 py-1.5">
|
|
{item.quantity}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
onClick={() => updateQuantity(item.id, item.quantity + 1)}
|
|
disabled={maxStock != null && item.quantity >= maxStock}
|
|
className="w-8 h-8 flex items-center justify-center text-gray-600 hover:bg-gray-50 cursor-pointer disabled:opacity-30"
|
|
aria-label="Increase quantity"
|
|
>
|
|
<Plus className="w-3.5 h-3.5" />
|
|
</button>
|
|
</div>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={() => removeFromCart(item.id)}
|
|
className="w-8 h-8 flex items-center justify-center rounded-md text-gray-400 hover:text-red-600 hover:bg-red-50 transition-colors cursor-pointer"
|
|
aria-label="Remove item"
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
{cart.length > 0 && (
|
|
<div className="shrink-0 p-4 border-t border-gray-200 bg-white space-y-3 shadow-[0_-4px_12px_rgba(0,0,0,0.04)]">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-sm font-semibold text-[#1a1a1a]">Subtotal:</span>
|
|
<span className="text-base font-bold text-[#e11d48]">
|
|
{formatCurrency(total)}
|
|
</span>
|
|
</div>
|
|
<p className="text-[11px] text-gray-400 text-center">
|
|
Shipping & taxes calculated at checkout
|
|
</p>
|
|
<div className="grid grid-cols-2 gap-2.5">
|
|
<Link
|
|
href="/cart"
|
|
onClick={() => setIsOpen(false)}
|
|
className="bg-gray-100 hover:bg-gray-200 text-primary text-[13px] font-bold py-3 rounded-md text-center transition-colors"
|
|
>
|
|
View Cart
|
|
</Link>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
if (!requireLoginForCheckout('/checkout')) return;
|
|
setIsOpen(false);
|
|
window.location.assign('/checkout');
|
|
}}
|
|
className="bg-[#e11d48] hover:bg-[#c9183d] text-white text-[13px] font-bold py-3 rounded-md text-center transition-colors flex items-center justify-center gap-1.5 cursor-pointer"
|
|
>
|
|
<span>Checkout</span>
|
|
<ArrowRight className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|