ifixkart-storefront/app/cart/page.tsx

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