ifixkart-storefront/components/store/CartDrawer.tsx

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>
);
}