Catalog
Code
"use client";
import * as React from "react";
import { Minus, Plus, ShoppingBag, Tag, Trash2, Truck, X } from "lucide-react";
import { cn } from "@/lib/utils";
export type CartLine = { id: string; name: string; variant?: string; price: number; qty: number; color?: string };
export interface CartPanelProps {
initialItems: CartLine[];
freeShippingAt?: number;
shipping?: number;
currency?: string;
onCheckout?: (items: CartLine[]) => void;
onClose?: () => void;
className?: string;
}
export function CartPanel({ initialItems, freeShippingAt = 100, shipping = 4.99, currency = "EUR", onCheckout, onClose, className }: CartPanelProps) {
const [items, setItems] = React.useState(initialItems);
const [removed, setRemoved] = React.useState<{ line: CartLine; index: number } | null>(null);
const [code, setCode] = React.useState("");
const [discount, setDiscount] = React.useState(0);
const [codeMsg, setCodeMsg] = React.useState("");
const fmt = new Intl.NumberFormat("en-IE", { style: "currency", currency });
const subtotal = items.reduce((s, i) => s + i.price * i.qty, 0);
const ship = subtotal === 0 || subtotal >= freeShippingAt ? 0 : shipping;
const total = Math.max(0, subtotal - discount + ship);
const pct = Math.min(100, (subtotal / freeShippingAt) * 100);
const setQty = (id: string, qty: number) => setItems((l) => l.map((i) => (i.id === id ? { ...i, qty: Math.max(1, Math.min(99, qty)) } : i)));
const remove = (id: string) => {
const index = items.findIndex((i) => i.id === id);
setRemoved({ line: items[index], index });
setItems((l) => l.filter((i) => i.id !== id));
};
const undo = () => {
if (!removed) return;
setItems((l) => [...l.slice(0, removed.index), removed.line, ...l.slice(removed.index)]);
setRemoved(null);
};
const apply = (e: React.FormEvent) => {
e.preventDefault();
// Demo rule — replace with a server call.
if (code.trim().toUpperCase() === "SAVE10") {
setDiscount(Math.round(subtotal * 10) / 100);
setCodeMsg("10% discount applied");
} else {
setDiscount(0);
setCodeMsg("Code not recognised");
}
};
return (
<div className={cn("flex h-[600px] w-full max-w-md flex-col overflow-hidden rounded-2xl border bg-card text-card-foreground shadow-2xl", className)}>
<div className="flex items-center gap-2 border-b px-5 py-4">
<ShoppingBag className="size-5" />
<h2 className="font-semibold">Your cart</h2>
<span className="rounded-full bg-muted px-2 text-xs font-medium tabular-nums">{items.reduce((s, i) => s + i.qty, 0)}</span>
<button onClick={onClose} aria-label="Close cart" className="ml-auto rounded-full p-1.5 transition hover:bg-accent">
<X className="size-4" />
</button>
</div>
<div className="border-b px-5 py-3">
<p className="flex items-center gap-2 text-xs">
<Truck className="size-3.5 text-primary" />
{ship === 0 && subtotal > 0 ? "Free shipping unlocked" : `${fmt.format(Math.max(0, freeShippingAt - subtotal))} away from free shipping`}
</p>
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-muted">
<div className="h-full rounded-full bg-primary transition-[width] duration-500" style={{ width: `${pct}%` }} />
</div>
</div>
<ul className="flex-1 divide-y overflow-y-auto px-5">
{items.map((i) => (
<li key={i.id} className="flex gap-3 py-4 animate-in fade-in">
<div className="size-16 shrink-0 rounded-lg" style={{ background: i.color ?? "var(--muted)" }} />
<div className="min-w-0 flex-1">
<div className="flex items-start gap-2">
<div className="min-w-0">
<p className="truncate text-sm font-medium">{i.name}</p>
{i.variant && <p className="text-xs text-muted-foreground">{i.variant}</p>}
</div>
<button onClick={() => remove(i.id)} aria-label={`Remove ${i.name}`} className="ml-auto rounded p-1 text-muted-foreground transition hover:text-destructive">
<Trash2 className="size-4" />
</button>
</div>
<div className="mt-2 flex items-center justify-between">
<div className="inline-flex items-center rounded-full border">
<button className="grid size-7 place-items-center disabled:opacity-40" onClick={() => setQty(i.id, i.qty - 1)} disabled={i.qty <= 1} aria-label="Decrease">
<Minus className="size-3.5" />
</button>
<span className="w-6 text-center text-sm tabular-nums">{i.qty}</span>
<button className="grid size-7 place-items-center" onClick={() => setQty(i.id, i.qty + 1)} aria-label="Increase">
<Plus className="size-3.5" />
</button>
</div>
<span className="text-sm font-medium tabular-nums">{fmt.format(i.price * i.qty)}</span>
</div>
</div>
</li>
))}
{!items.length && <li className="py-16 text-center text-sm text-muted-foreground">Your cart is empty.</li>}
</ul>
{removed && (
<div className="mx-5 mb-2 flex items-center justify-between rounded-lg bg-muted px-3 py-2 text-xs">
<span className="truncate">Removed {removed.line.name}</span>
<button onClick={undo} className="font-medium text-primary hover:underline">Undo</button>
</div>
)}
<div className="space-y-3 border-t bg-muted/30 px-5 py-4">
<form onSubmit={apply} className="flex gap-2">
<div className="relative flex-1">
<Tag className="absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
<input value={code} onChange={(e) => setCode(e.target.value)} placeholder="Promo code (try SAVE10)" aria-label="Promo code" className="h-9 w-full rounded-lg border bg-background pl-8 pr-3 text-sm outline-none focus:ring-2 focus:ring-ring/30" />
</div>
<button className="h-9 rounded-lg border bg-background px-3 text-sm font-medium transition hover:bg-accent">Apply</button>
</form>
{codeMsg && <p className={cn("text-xs", discount ? "text-emerald-600" : "text-destructive")}>{codeMsg}</p>}
<dl className="space-y-1 text-sm">
<div className="flex justify-between"><dt className="text-muted-foreground">Subtotal</dt><dd className="tabular-nums">{fmt.format(subtotal)}</dd></div>
{discount > 0 && <div className="flex justify-between text-emerald-600"><dt>Discount</dt><dd className="tabular-nums">−{fmt.format(discount)}</dd></div>}
<div className="flex justify-between"><dt className="text-muted-foreground">Shipping</dt><dd className="tabular-nums">{ship ? fmt.format(ship) : "Free"}</dd></div>
<div className="flex justify-between pt-1 text-base font-semibold"><dt>Total</dt><dd className="tabular-nums">{fmt.format(total)}</dd></div>
</dl>
<button
disabled={!items.length}
onClick={() => onCheckout?.(items)}
className="h-11 w-full rounded-xl bg-foreground font-medium text-background transition hover:opacity-90 disabled:opacity-40"
>
Checkout
</button>
</div>
</div>
);
}