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

More in E-commerce