300 lines
14 KiB
TypeScript
300 lines
14 KiB
TypeScript
/**
|
|
* @page Full Shopping Cart (`app/cart/page.tsx`)
|
|
* @purpose Customer full cart page with quantity modification, line totals,
|
|
* free-shipping progress, promo input, and checkout CTA.
|
|
*/
|
|
'use client';
|
|
|
|
import Link from 'next/link';
|
|
import { useRouter } from 'next/navigation';
|
|
import { useCartStore } from '@/store/cartStore';
|
|
import { formatCurrency, getImageUrl } from '@/lib/utils';
|
|
import { requireLoginForCheckout } from '@/lib/requireLoginForCheckout';
|
|
import { Header } from '@/layout/Header';
|
|
import { Footer } from '@/layout/Footer';
|
|
import { StickyHeaderSpacer } from '@/components/StickyHeaderSpacer';
|
|
import BlurHashImage from '@/components/ui/BlurHashImage';
|
|
import {
|
|
Trash2,
|
|
Plus,
|
|
Minus,
|
|
ArrowRight,
|
|
ShoppingBag,
|
|
Truck,
|
|
Tag,
|
|
} from 'lucide-react';
|
|
|
|
const FREE_SHIPPING_THRESHOLD = 999;
|
|
|
|
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 default function CartPage() {
|
|
const router = useRouter();
|
|
const cart = useCartStore((state) => state.cart);
|
|
const removeFromCart = useCartStore((state) => state.removeFromCart);
|
|
const updateQuantity = useCartStore((state) => state.updateQuantity);
|
|
const getCartTotal = useCartStore((state) => state.getCartTotal);
|
|
|
|
const subtotal = getCartTotal();
|
|
const shipping = subtotal > 0 && subtotal < FREE_SHIPPING_THRESHOLD ? 15.0 : 0;
|
|
const total = subtotal + shipping;
|
|
const progressPercent = Math.min(100, (subtotal / FREE_SHIPPING_THRESHOLD) * 100);
|
|
const amountNeeded = Math.max(0, FREE_SHIPPING_THRESHOLD - subtotal);
|
|
|
|
return (
|
|
<div className="w-full min-h-screen bg-[#F8F9FB] flex flex-col">
|
|
<Header />
|
|
<StickyHeaderSpacer />
|
|
|
|
<main className="flex-grow py-8 md:py-10">
|
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
|
{cart.length > 0 && (
|
|
<div className="flex justify-end mb-6">
|
|
<Link
|
|
href="/shop"
|
|
className="text-[13px] font-semibold text-primary hover:underline"
|
|
>
|
|
Continue shopping
|
|
</Link>
|
|
</div>
|
|
)}
|
|
|
|
{cart.length === 0 ? (
|
|
<div className="bg-white border border-gray-200 rounded-xl p-10 md:p-14 text-center max-w-lg mx-auto shadow-sm">
|
|
<div className="w-16 h-16 rounded-full bg-primary-light text-primary flex items-center justify-center mx-auto mb-4">
|
|
<ShoppingBag className="w-7 h-7" />
|
|
</div>
|
|
<h2 className="text-lg font-bold text-[#1a1a1a]">
|
|
Your cart is empty
|
|
</h2>
|
|
<p className="text-[13px] text-gray-500 mt-1.5 mb-6">
|
|
Looks like you haven't added any products yet.
|
|
</p>
|
|
<Link
|
|
href="/shop"
|
|
className="inline-flex items-center gap-1.5 bg-primary hover:brightness-95 text-white text-[13px] font-bold px-6 py-3 rounded-lg transition"
|
|
>
|
|
Explore products <ArrowRight className="w-4 h-4" />
|
|
</Link>
|
|
</div>
|
|
) : (
|
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 lg:gap-8 items-start">
|
|
{/* Items */}
|
|
<div className="lg:col-span-2 bg-white border border-gray-200 rounded-xl overflow-hidden shadow-sm">
|
|
<div className="px-4 md:px-5 py-3.5 border-b border-gray-100 bg-gray-50/80 flex items-center justify-between text-[12px] font-bold text-gray-500 uppercase tracking-wide">
|
|
<span>Product</span>
|
|
<span className="hidden sm:inline">Subtotal</span>
|
|
</div>
|
|
|
|
<div className="divide-y divide-gray-100">
|
|
{cart.map((item) => {
|
|
const img = resolveItemImage(item);
|
|
const priceNum = typeof item.selectedVariant.price === 'number'
|
|
? item.selectedVariant.price
|
|
: parseFloat(String(item.selectedVariant.price || 0));
|
|
const lineTotal = priceNum * item.quantity;
|
|
const atMax =
|
|
typeof item.selectedVariant.available_stock ===
|
|
'number' &&
|
|
item.quantity >= item.selectedVariant.available_stock;
|
|
|
|
return (
|
|
<div
|
|
key={item.id}
|
|
className="p-4 md:p-5 flex items-start sm:items-center gap-3 md:gap-4"
|
|
>
|
|
<Link
|
|
href={`/products/${item.product.slug}`}
|
|
className="relative w-20 h-20 rounded-lg border border-gray-100 overflow-hidden bg-gray-50 shrink-0"
|
|
>
|
|
{img ? (
|
|
<BlurHashImage
|
|
src={img}
|
|
blurHash={(item.product as any).blur_hash || null}
|
|
alt={item.product.name}
|
|
fill
|
|
sizes="80px"
|
|
className="object-contain p-1"
|
|
/>
|
|
) : (
|
|
<div className="w-full h-full flex items-center justify-center">
|
|
<ShoppingBag className="w-5 h-5 text-gray-300" />
|
|
</div>
|
|
)}
|
|
</Link>
|
|
|
|
<div className="flex-1 min-w-0">
|
|
<Link
|
|
href={`/products/${item.product.slug}`}
|
|
className="text-[13px] font-semibold text-[#1a1a1a] hover:text-primary line-clamp-2 transition-colors"
|
|
>
|
|
{item.product.name}
|
|
</Link>
|
|
<span className="text-[11px] text-gray-400 block mt-0.5">
|
|
SKU: {item.selectedVariant.sku}
|
|
</span>
|
|
<span className="text-[13px] font-bold text-primary mt-1 block">
|
|
{formatCurrency(item.selectedVariant.price)}
|
|
</span>
|
|
|
|
<div className="flex items-center gap-3 mt-2.5">
|
|
<div className="inline-flex items-center border border-gray-200 rounded-lg overflow-hidden bg-white">
|
|
<button
|
|
type="button"
|
|
onClick={() =>
|
|
updateQuantity(item.id, item.quantity - 1)
|
|
}
|
|
className="p-2 hover:bg-gray-50 text-gray-600 cursor-pointer"
|
|
aria-label="Decrease quantity"
|
|
>
|
|
<Minus className="w-3.5 h-3.5" />
|
|
</button>
|
|
<span className="px-3 text-[13px] font-bold text-[#1a1a1a] min-w-[2rem] text-center">
|
|
{item.quantity}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
onClick={() =>
|
|
updateQuantity(item.id, item.quantity + 1)
|
|
}
|
|
disabled={atMax}
|
|
className="p-2 hover:bg-gray-50 text-gray-600 disabled:opacity-30 cursor-pointer"
|
|
aria-label="Increase quantity"
|
|
>
|
|
<Plus className="w-3.5 h-3.5" />
|
|
</button>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => removeFromCart(item.id)}
|
|
className="text-gray-400 hover:text-red-500 p-1.5 rounded-md hover:bg-red-50 transition cursor-pointer"
|
|
aria-label="Remove item"
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="text-[13px] font-bold text-[#1a1a1a] shrink-0 pt-1 sm:pt-0">
|
|
{formatCurrency(lineTotal)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Summary */}
|
|
<div className="lg:sticky lg:top-28 space-y-4">
|
|
<div className="bg-white border border-gray-200 rounded-xl p-5 md:p-6 shadow-sm space-y-4 text-[13px]">
|
|
<h3 className="text-[14px] font-bold text-[#1a1a1a] border-b border-gray-100 pb-3">
|
|
Order summary
|
|
</h3>
|
|
|
|
{/* Free shipping progress */}
|
|
<div className="rounded-lg bg-primary/5 border border-primary/10 p-3 space-y-2">
|
|
<div className="flex items-start gap-2">
|
|
<Truck className="w-4 h-4 text-primary shrink-0 mt-0.5" />
|
|
<p className="text-[12px] text-gray-600 leading-snug">
|
|
{shipping === 0 ? (
|
|
<span className="font-semibold text-emerald-600">
|
|
You've unlocked free shipping!
|
|
</span>
|
|
) : (
|
|
<>
|
|
Add{' '}
|
|
<span className="font-bold text-primary">
|
|
{formatCurrency(amountNeeded)}
|
|
</span>{' '}
|
|
more for free shipping
|
|
</>
|
|
)}
|
|
</p>
|
|
</div>
|
|
<div className="h-1.5 bg-white rounded-full overflow-hidden border border-primary/10">
|
|
<div
|
|
className="h-full bg-primary rounded-full transition-all duration-300"
|
|
style={{ width: `${progressPercent}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-2.5 text-gray-500">
|
|
<div className="flex justify-between">
|
|
<span>Subtotal</span>
|
|
<span className="font-semibold text-[#1a1a1a]">
|
|
{formatCurrency(subtotal)}
|
|
</span>
|
|
</div>
|
|
<div className="flex justify-between items-center">
|
|
<span>Shipping</span>
|
|
<span>
|
|
{shipping === 0 ? (
|
|
<strong className="text-emerald-600 text-[11px] font-bold uppercase">
|
|
Free
|
|
</strong>
|
|
) : (
|
|
<span className="font-semibold text-[#1a1a1a]">
|
|
{formatCurrency(shipping)}
|
|
</span>
|
|
)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex gap-2 pt-1 border-t border-gray-100">
|
|
<div className="relative flex-1">
|
|
<Tag className="w-3.5 h-3.5 text-gray-400 absolute left-3 top-1/2 -translate-y-1/2" />
|
|
<input
|
|
type="text"
|
|
placeholder="Promo code"
|
|
className="w-full bg-gray-50 border border-gray-200 rounded-lg pl-9 pr-3 py-2.5 outline-none text-[13px] focus:border-primary focus:ring-2 focus:ring-primary/15"
|
|
/>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
className="bg-primary hover:brightness-95 text-white font-bold px-4 py-2.5 rounded-lg text-[13px] transition cursor-pointer"
|
|
>
|
|
Apply
|
|
</button>
|
|
</div>
|
|
|
|
<div className="border-t border-gray-200 pt-3 flex justify-between items-center">
|
|
<span className="font-bold text-[#1a1a1a]">Total</span>
|
|
<span className="text-lg font-bold text-primary">
|
|
{formatCurrency(total)}
|
|
</span>
|
|
</div>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
if (!requireLoginForCheckout('/checkout')) return;
|
|
router.push('/checkout');
|
|
}}
|
|
className="w-full bg-primary hover:brightness-95 text-white font-bold py-3.5 rounded-lg text-center transition flex items-center justify-center gap-1.5 text-[14px] cursor-pointer"
|
|
>
|
|
Proceed to checkout <ArrowRight className="w-4 h-4" />
|
|
</button>
|
|
|
|
<p className="text-[11px] text-gray-400 text-center">
|
|
Taxes calculated at checkout
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</main>
|
|
|
|
<Footer />
|
|
</div>
|
|
);
|
|
}
|