Fazekit

Code

"use client";
import * as React from "react";
import { AnimatePresence, LayoutGroup, motion, useReducedMotion } from "motion/react";
import { Check, ChevronDown, CircleDot, Download, MapPin, Package, PackageCheck, RotateCcw, Search, ShoppingCart, Truck, X, CreditCard, Home, Undo2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { ProductArt, type ArtKind } from "./product-art";

export type OrderStatus = "processing" | "shipped" | "out-for-delivery" | "delivered" | "cancelled" | "return-requested";
export type OrderLine = { id: string; name: string; variant?: string; price: number; qty: number; kind: ArtKind; hex: string; accent?: string; label?: string };
export type TrackingEvent = { label: string; detail?: string; time: string; done: boolean };
export type Order = {
  id: string;
  number: string;
  date: string;
  status: OrderStatus;
  lines: OrderLine[];
  shipping: number;
  address: string;
  payment: string;
  carrier?: string;
  trackingNo?: string;
  eta?: string;
  events: TrackingEvent[];
  returnCode?: string;
};
export type ReturnRequest = { orderId: string; lineIds: string[]; reason: string; resolution: "refund" | "exchange" | "credit" };

export interface OrderHistoryProps {
  title?: string;
  orders?: Order[];
  currency?: string;
  locale?: string;
  storeName?: string;
  onReorder?: (order: Order) => void;
  onReturnRequest?: (req: ReturnRequest) => string | void;
  onTrack?: (order: Order) => void;
  className?: string;
}

const L = (id: string, name: string, variant: string, price: number, qty: number, kind: ArtKind, hex: string, accent?: string, label?: string): OrderLine => ({ id, name, variant, price, qty, kind, hex, accent, label });

const ORDERS: Order[] = [
  {
    id: "o1",
    number: "LC-204981",
    date: "22 Sep 2026",
    status: "out-for-delivery",
    lines: [L("a", "Highland Single Malt 12", "Sherry cask · 70 cl", 39.9, 1, "bottle", "#b45309", "#f5efe0", "Lumen"), L("b", "Crystal Rocks Tumbler", "Set of 2", 12, 2, "tumbler", "#d97706")],
    shipping: 0,
    address: "12 Harbour Street, D02 X285 Dublin",
    payment: "Card ending 4242",
    carrier: "Northwind Express",
    trackingNo: "NW 8841 2290 17",
    eta: "Today, 14:00–16:00",
    events: [
      { label: "Order placed", time: "22 Sep, 09:12", done: true },
      { label: "Packed at Lumen warehouse", detail: "Dublin fulfilment centre", time: "22 Sep, 15:40", done: true },
      { label: "Shipped", detail: "Handed to Northwind Express", time: "23 Sep, 07:05", done: true },
      { label: "Out for delivery", detail: "Driver is 6 stops away", time: "24 Sep, 08:31", done: true },
      { label: "Delivered", time: "Expected today", done: false },
    ],
  },
  {
    id: "o2",
    number: "LC-203377",
    date: "9 Sep 2026",
    status: "delivered",
    lines: [L("c", "Reserva Tinto 2019", "75 cl", 18.9, 3, "wine", "#7f1d1d", "#d4af37", "Orbit"), L("d", "Hazy IPA 4-pack", "4 × 440 ml", 11.9, 1, "can", "#ca8a04", "#1e293b", "Brewlab"), L("e", "Soy Wax Candle", "Oak & amber", 16, 1, "candle", "#e7d5c0", "#1c1917", "Lumen")],
    shipping: 4.99,
    address: "12 Harbour Street, D02 X285 Dublin",
    payment: "Lumen Wallet",
    carrier: "Northwind Express",
    trackingNo: "NW 8812 0021 44",
    events: [
      { label: "Order placed", time: "9 Sep, 18:20", done: true },
      { label: "Packed", time: "10 Sep, 10:02", done: true },
      { label: "Shipped", time: "10 Sep, 16:45", done: true },
      { label: "Out for delivery", time: "11 Sep, 08:10", done: true },
      { label: "Delivered", detail: "Left with neighbour at no. 14", time: "11 Sep, 12:47", done: true },
    ],
  },
  {
    id: "o3",
    number: "LC-201560",
    date: "28 Aug 2026",
    status: "processing",
    lines: [L("f", "Tasting Gift Set", "4 × 5 cl", 59, 1, "box", "#6d28d9", "#fbbf24", "Orbit")],
    shipping: 0,
    address: "7 Linden Allee, 10117 Berlin",
    payment: "Card ending 1881",
    eta: "Ships in 1–2 days",
    events: [
      { label: "Order placed", time: "28 Aug, 21:03", done: true },
      { label: "Packing", detail: "Engraving your gift box", time: "In progress", done: false },
      { label: "Shipped", time: "—", done: false },
      { label: "Delivered", time: "—", done: false },
    ],
  },
  {
    id: "o4",
    number: "LC-198004",
    date: "2 Aug 2026",
    status: "delivered",
    lines: [L("g", "Coastal Dry Gin", "70 cl", 29.5, 2, "bottle", "#0ea5e9", "#f0f9ff", "Northwind"), L("h", "Morning Ceramic Mug", "Tangerine", 24, 1, "mug", "#f97316", "#fff7ed", "Lumen")],
    shipping: 4.99,
    address: "12 Harbour Street, D02 X285 Dublin",
    payment: "Card ending 4242",
    carrier: "Northwind Express",
    trackingNo: "NW 8790 5512 03",
    events: [
      { label: "Order placed", time: "2 Aug, 11:30", done: true },
      { label: "Shipped", time: "3 Aug, 09:14", done: true },
      { label: "Delivered", time: "4 Aug, 13:02", done: true },
    ],
  },
  {
    id: "o5",
    number: "LC-195212",
    date: "14 Jul 2026",
    status: "cancelled",
    lines: [L("i", "Añejo Tequila", "70 cl", 52, 1, "bottle", "#ca8a04", "#1c1917", "Northwind")],
    shipping: 0,
    address: "12 Harbour Street, D02 X285 Dublin",
    payment: "Refunded to card ending 4242",
    events: [
      { label: "Order placed", time: "14 Jul, 20:44", done: true },
      { label: "Cancelled", detail: "Cancelled by you · refund issued", time: "14 Jul, 21:02", done: true },
    ],
  },
];

const STATUS: Record<OrderStatus, { label: string; cls: string; dot: string }> = {
  processing: { label: "Processing", cls: "bg-amber-500/12 text-amber-700 dark:text-amber-300", dot: "bg-amber-500" },
  shipped: { label: "Shipped", cls: "bg-sky-500/12 text-sky-700 dark:text-sky-300", dot: "bg-sky-500" },
  "out-for-delivery": { label: "Out for delivery", cls: "bg-indigo-500/12 text-indigo-700 dark:text-indigo-300", dot: "bg-indigo-500" },
  delivered: { label: "Delivered", cls: "bg-emerald-500/12 text-emerald-700 dark:text-emerald-300", dot: "bg-emerald-500" },
  cancelled: { label: "Cancelled", cls: "bg-muted text-muted-foreground", dot: "bg-muted-foreground" },
  "return-requested": { label: "Return requested", cls: "bg-rose-500/12 text-rose-700 dark:text-rose-300", dot: "bg-rose-500" },
};
const TABS: { id: string; label: string; match: (s: OrderStatus) => boolean }[] = [
  { id: "all", label: "All orders", match: () => true },
  { id: "open", label: "In progress", match: (s) => s === "processing" || s === "shipped" || s === "out-for-delivery" },
  { id: "done", label: "Delivered", match: (s) => s === "delivered" },
  { id: "returns", label: "Returns & cancelled", match: (s) => s === "return-requested" || s === "cancelled" },
];
const REASONS = ["Changed my mind", "Arrived damaged", "Wrong item sent", "Not as described", "Better price elsewhere"];

const ring = "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background";

export function OrderHistory({
  title = "Your orders",
  orders: initialOrders = ORDERS,
  currency = "EUR",
  locale = "en-IE",
  storeName = "Lumen Cellars",
  onReorder,
  onReturnRequest,
  onTrack,
  className,
}: OrderHistoryProps) {
  const reduce = useReducedMotion();
  const fmt = React.useMemo(() => new Intl.NumberFormat(locale, { style: "currency", currency }), [locale, currency]);
  const [orders, setOrders] = React.useState(initialOrders);
  const [tab, setTab] = React.useState("all");
  const [q, setQ] = React.useState("");
  const [open, setOpen] = React.useState<string | null>(initialOrders[0]?.id ?? null);
  const [toast, setToast] = React.useState<string | null>(null);
  const [returning, setReturning] = React.useState<Order | null>(null);

  React.useEffect(() => {
    if (!toast) return;
    const t = window.setTimeout(() => setToast(null), 2800);
    return () => window.clearTimeout(t);
  }, [toast]);

  const tabDef = TABS.find((t) => t.id === tab) ?? TABS[0];
  const list = orders.filter((o) => tabDef.match(o.status) && (!q.trim() || `${o.number} ${o.lines.map((l) => l.name).join(" ")}`.toLowerCase().includes(q.trim().toLowerCase())));
  const totalOf = (o: Order) => o.lines.reduce((s, l) => s + l.price * l.qty, 0) + o.shipping;

  const invoice = (o: Order) => {
    const pad = (s: string, n: number) => (s.length > n ? s.slice(0, n - 1) + "…" : s.padEnd(n));
    const rows = o.lines.map((l) => `${pad(`${l.name} (${l.variant ?? ""})`, 44)} ${String(l.qty).padStart(3)}  ${fmt.format(l.price * l.qty).padStart(12)}`);
    const txt = [
      `${storeName.toUpperCase()} — INVOICE`,
      "=".repeat(64),
      `Order:    ${o.number}`,
      `Date:     ${o.date}`,
      `Ship to:  ${o.address}`,
      `Payment:  ${o.payment}`,
      "",
      `${pad("Item", 44)} Qty  ${"Amount".padStart(12)}`,
      "-".repeat(64),
      ...rows,
      "-".repeat(64),
      `${pad("Shipping", 49)}${fmt.format(o.shipping).padStart(12)}`,
      `${pad("TOTAL (incl. VAT)", 49)}${fmt.format(totalOf(o)).padStart(12)}`,
      "",
      "Thank you for shopping with us.",
    ].join("\n");
    const url = URL.createObjectURL(new Blob([txt], { type: "text/plain" }));
    const a = document.createElement("a");
    a.href = url;
    a.download = `invoice-${o.number}.txt`;
    document.body.appendChild(a);
    a.click();
    a.remove();
    window.setTimeout(() => URL.revokeObjectURL(url), 1000);
    setToast(`Invoice ${o.number} downloaded`);
  };

  return (
    <section className={cn("relative w-full bg-background py-8 text-foreground sm:py-12", className)}>
      <div className="mx-auto max-w-5xl px-4 sm:px-6">
        <div className="flex flex-wrap items-end justify-between gap-4">
          <div>
            <h2 className="text-2xl font-semibold tracking-tight sm:text-3xl">{title}</h2>
            <p className="mt-1 text-sm text-muted-foreground">Track, return or buy things again.</p>
          </div>
          <label className="relative w-full sm:w-72">
            <span className="sr-only">Search orders</span>
            <Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" aria-hidden />
            <input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search by order # or product" className={cn("h-10 w-full rounded-full border bg-background pl-9 pr-4 text-sm", ring)} />
          </label>
        </div>

        <div role="tablist" aria-label="Order filters" className="-mx-4 mt-6 flex gap-1 overflow-x-auto border-b px-4 [scrollbar-width:none] sm:mx-0 sm:px-0">
          {TABS.map((t) => {
            const n = orders.filter((o) => t.match(o.status)).length;
            const on = tab === t.id;
            return (
              <button key={t.id} role="tab" aria-selected={on} onClick={() => setTab(t.id)} className={cn("relative flex shrink-0 items-center gap-2 whitespace-nowrap px-3 pb-3 pt-1 text-sm font-medium transition", on ? "text-foreground" : "text-muted-foreground hover:text-foreground", ring)}>
                {t.label}
                <span className="rounded-full bg-muted px-1.5 text-[11px] tabular-nums">{n}</span>
                {on && <motion.span layoutId="oh-tab" className="absolute inset-x-0 -bottom-px h-0.5 rounded-full bg-foreground" />}
              </button>
            );
          })}
        </div>

        <LayoutGroup>
          <ul className="mt-6 space-y-4">
            <AnimatePresence initial={false} mode="popLayout">
              {list.map((o) => {
                const isOpen = open === o.id;
                const total = totalOf(o);
                return (
                  <motion.li key={o.id} layout={!reduce} initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, scale: 0.98 }} className="overflow-hidden rounded-2xl border bg-card">
                    <button type="button" aria-expanded={isOpen} aria-controls={`oh-${o.id}`} onClick={() => setOpen(isOpen ? null : o.id)} className={cn("flex w-full items-center gap-3 p-4 text-left sm:gap-6 sm:p-5", ring, "focus-visible:ring-offset-0 focus-visible:ring-inset")}>
                      <div className="flex shrink-0 -space-x-3">
                        {o.lines.slice(0, 3).map((l) => (
                          <span key={l.id} className="size-10 rounded-xl border-2 border-card bg-muted p-0.5 sm:size-12">
                            <ProductArt kind={l.kind} color={l.hex} accent={l.accent} />
                          </span>
                        ))}
                      </div>
                      <div className="min-w-0 flex-1">
                        <p className="text-sm font-semibold">{o.number}</p>
                        <p className="text-xs text-muted-foreground">
                          {o.date} · {o.lines.reduce((s, l) => s + l.qty, 0)} {o.lines.reduce((s, l) => s + l.qty, 0) === 1 ? "item" : "items"}
                        </p>
                        <StatusBadge status={o.status} className="mt-1.5 sm:hidden" />
                      </div>
                      <StatusBadge status={o.status} className="hidden sm:inline-flex" />
                      <span className="shrink-0 text-right text-sm font-semibold tabular-nums sm:w-20">{fmt.format(total)}</span>
                      <motion.span animate={{ rotate: isOpen ? 180 : 0 }} className="shrink-0">
                        <ChevronDown className="size-4 text-muted-foreground" aria-hidden />
                      </motion.span>
                    </button>

                    <AnimatePresence initial={false}>
                      {isOpen && (
                        <motion.div id={`oh-${o.id}`} initial={{ height: 0, opacity: 0 }} animate={{ height: "auto", opacity: 1 }} exit={{ height: 0, opacity: 0 }} transition={{ duration: reduce ? 0 : 0.3, ease: [0.4, 0, 0.2, 1] }} className="overflow-hidden">
                          <div className="grid gap-6 border-t p-4 sm:p-5 md:grid-cols-[1fr_280px]">
                            <div className="min-w-0">
                              {o.eta && o.status !== "delivered" && o.status !== "cancelled" && (
                                <div className="mb-5 flex items-center gap-3 rounded-xl bg-primary/8 p-3 text-sm">
                                  <span className="grid size-9 place-items-center rounded-lg bg-primary text-primary-foreground">
                                    <Truck className="size-4" aria-hidden />
                                  </span>
                                  <span>
                                    <span className="block text-xs text-muted-foreground">{o.status === "processing" ? "Estimated dispatch" : "Arriving"}</span>
                                    <span className="font-semibold">{o.eta}</span>
                                  </span>
                                </div>
                              )}
                              <ul className="divide-y">
                                {o.lines.map((l) => (
                                  <li key={l.id} className="flex items-center gap-3 py-3 first:pt-0">
                                    <span className="size-14 shrink-0 rounded-lg bg-muted p-1">
                                      <ProductArt kind={l.kind} color={l.hex} accent={l.accent} label={l.label} />
                                    </span>
                                    <span className="min-w-0 flex-1">
                                      <span className="block truncate text-sm font-medium">{l.name}</span>
                                      <span className="block text-xs text-muted-foreground">
                                        {l.variant} · Qty {l.qty}
                                      </span>
                                    </span>
                                    <span className="text-sm tabular-nums">{fmt.format(l.price * l.qty)}</span>
                                  </li>
                                ))}
                              </ul>
                              <dl className="mt-3 grid grid-cols-[auto_1fr] gap-x-3 gap-y-2 border-t pt-4 text-xs">
                                <dt className="flex items-center gap-1.5 text-muted-foreground">
                                  <Home className="size-3.5" aria-hidden /> Ship to
                                </dt>
                                <dd>{o.address}</dd>
                                <dt className="flex items-center gap-1.5 text-muted-foreground">
                                  <CreditCard className="size-3.5" aria-hidden /> Payment
                                </dt>
                                <dd>{o.payment}</dd>
                                {o.trackingNo && (
                                  <>
                                    <dt className="flex items-center gap-1.5 text-muted-foreground">
                                      <Package className="size-3.5" aria-hidden /> Tracking
                                    </dt>
                                    <dd className="font-mono">
                                      {o.carrier} · {o.trackingNo}
                                    </dd>
                                  </>
                                )}
                                {o.returnCode && (
                                  <>
                                    <dt className="flex items-center gap-1.5 text-muted-foreground">
                                      <Undo2 className="size-3.5" aria-hidden /> Return
                                    </dt>
                                    <dd className="font-mono font-semibold">{o.returnCode}</dd>
                                  </>
                                )}
                              </dl>
                              <div className="mt-5 flex flex-wrap gap-2">
                                {o.status !== "cancelled" && (
                                  <ActionButton
                                    primary
                                    onClick={() => {
                                      onReorder?.(o);
                                      setToast(`${o.lines.reduce((s, l) => s + l.qty, 0)} items from ${o.number} added to your cart`);
                                    }}
                                  >
                                    <ShoppingCart className="size-4" aria-hidden /> Buy again
                                  </ActionButton>
                                )}
                                <ActionButton onClick={() => invoice(o)}>
                                  <Download className="size-4" aria-hidden /> Invoice
                                </ActionButton>
                                {o.trackingNo && o.status !== "delivered" && (
                                  <ActionButton onClick={() => onTrack?.(o)}>
                                    <MapPin className="size-4" aria-hidden /> Live tracking
                                  </ActionButton>
                                )}
                                {o.status === "delivered" && (
                                  <ActionButton onClick={() => setReturning(o)}>
                                    <RotateCcw className="size-4" aria-hidden /> Return items
                                  </ActionButton>
                                )}
                              </div>
                            </div>
                            <Timeline events={o.events} cancelled={o.status === "cancelled"} />
                          </div>
                        </motion.div>
                      )}
                    </AnimatePresence>
                  </motion.li>
                );
              })}
            </AnimatePresence>
          </ul>
        </LayoutGroup>
        {list.length === 0 && (
          <div className="mt-6 flex flex-col items-center rounded-2xl border border-dashed py-14 text-center">
            <PackageCheck className="size-8 text-muted-foreground" aria-hidden />
            <p className="mt-3 font-medium">No orders here</p>
            <p className="text-sm text-muted-foreground">Try a different tab or search.</p>
          </div>
        )}
      </div>

      <AnimatePresence>
        {returning && (
          <ReturnDialog
            order={returning}
            fmt={fmt}
            onClose={() => setReturning(null)}
            onSubmit={(req) => {
              const code = onReturnRequest?.(req) || `RMA-${returning.number.slice(-4)}${req.lineIds.length}`;
              setOrders((os) =>
                os.map((o) =>
                  o.id === req.orderId
                    ? {
                        ...o,
                        status: "return-requested",
                        returnCode: code,
                        events: [...o.events, { label: "Return requested", detail: `${req.reason} · ${req.resolution}`, time: "Just now", done: true }],
                      }
                    : o,
                ),
              );
              return code;
            }}
          />
        )}
      </AnimatePresence>

      <div className="pointer-events-none fixed inset-x-0 bottom-4 z-[60] flex justify-center px-4" aria-live="polite">
        <AnimatePresence>
          {toast && (
            <motion.div initial={{ y: 30, opacity: 0 }} animate={{ y: 0, opacity: 1 }} exit={{ y: 20, opacity: 0 }} className="flex items-center gap-2.5 rounded-2xl bg-foreground px-4 py-3 text-sm text-background shadow-2xl">
              <span className="grid size-5 place-items-center rounded-full bg-emerald-500 text-white">
                <Check className="size-3" aria-hidden />
              </span>
              {toast}
            </motion.div>
          )}
        </AnimatePresence>
      </div>
    </section>
  );
}

function StatusBadge({ status, className }: { status: OrderStatus; className?: string }) {
  const reduce = useReducedMotion();
  const st = STATUS[status];
  return (
    <span className={cn("inline-flex w-fit shrink-0 items-center gap-1.5 whitespace-nowrap rounded-full px-2.5 py-1 text-xs font-semibold", st.cls, className)}>
      <span className="relative flex size-1.5">
        {(status === "out-for-delivery" || status === "processing") && !reduce && <span className={cn("absolute inset-0 animate-ping rounded-full opacity-70", st.dot)} />}
        <span className={cn("relative size-1.5 rounded-full", st.dot)} />
      </span>
      {st.label}
    </span>
  );
}

function ActionButton({ primary, onClick, children }: { primary?: boolean; onClick: () => void; children: React.ReactNode }) {
  return (
    <button
      type="button"
      onClick={onClick}
      className={cn("flex h-9 items-center gap-2 rounded-lg px-3.5 text-sm font-medium transition", primary ? "bg-foreground text-background hover:bg-foreground/85" : "border hover:bg-muted", ring)}
    >
      {children}
    </button>
  );
}

function Timeline({ events, cancelled }: { events: TrackingEvent[]; cancelled: boolean }) {
  const reduce = useReducedMotion();
  const last = events.map((e) => e.done).lastIndexOf(true);
  return (
    <div>
      <p className="mb-4 text-xs font-semibold uppercase tracking-[0.14em] text-muted-foreground">Tracking</p>
      <ol className="relative">
        {events.map((e, i) => {
          const current = i === last && i < events.length - 1 && !cancelled;
          return (
            <motion.li
              key={`${e.label}-${i}`}
              initial={reduce ? false : { opacity: 0, x: -6 }}
              animate={{ opacity: 1, x: 0 }}
              transition={{ delay: 0.1 + i * 0.08 }}
              className="relative flex gap-3 pb-5 last:pb-0"
            >
              {i < events.length - 1 && (
                <span className="absolute bottom-0 left-[11px] top-6 w-0.5 bg-muted" aria-hidden>
                  {events[i + 1].done && (
                    <motion.span
                      className={cn("absolute inset-0 origin-top", cancelled ? "bg-muted-foreground" : "bg-emerald-500")}
                      initial={{ scaleY: reduce ? 1 : 0 }}
                      animate={{ scaleY: 1 }}
                      transition={{ duration: 0.3, delay: 0.2 + i * 0.12 }}
                    />
                  )}
                </span>
              )}
              <span className={cn("relative z-10 grid size-6 shrink-0 place-items-center rounded-full border-2 bg-card", e.done ? (cancelled && i === events.length - 1 ? "border-muted-foreground bg-muted-foreground text-card" : "border-emerald-500 bg-emerald-500 text-white") : "border-muted")}>
                {e.done ? cancelled && i === events.length - 1 ? <X className="size-3" strokeWidth={3} aria-hidden /> : <Check className="size-3" strokeWidth={3} aria-hidden /> : <CircleDot className="size-3 text-muted-foreground/50" aria-hidden />}
                {current && !reduce && <span className="absolute -inset-1 animate-ping rounded-full border-2 border-emerald-500/60" aria-hidden />}
              </span>
              <span className="min-w-0 pt-0.5">
                <span className={cn("block text-sm font-medium", !e.done && "text-muted-foreground")}>{e.label}</span>
                {e.detail && <span className="block text-xs text-muted-foreground">{e.detail}</span>}
                <span className="block text-[11px] text-muted-foreground/80">{e.time}</span>
              </span>
            </motion.li>
          );
        })}
      </ol>
    </div>
  );
}

function ReturnDialog({ order, fmt, onClose, onSubmit }: { order: Order; fmt: Intl.NumberFormat; onClose: () => void; onSubmit: (r: ReturnRequest) => string }) {
  const ref = React.useRef<HTMLDivElement>(null);
  const [step, setStep] = React.useState<1 | 2 | 3>(1);
  const [sel, setSel] = React.useState<string[]>([]);
  const [reason, setReason] = React.useState("");
  const [resolution, setResolution] = React.useState<ReturnRequest["resolution"]>("refund");
  const [code, setCode] = React.useState("");
  const [err, setErr] = React.useState("");
  const refund = order.lines.filter((l) => sel.includes(l.id)).reduce((s, l) => s + l.price * l.qty, 0);

  React.useEffect(() => {
    const prev = document.activeElement as HTMLElement | null;
    ref.current?.querySelector<HTMLElement>("input, button")?.focus();
    const onKey = (e: KeyboardEvent) => {
      if (e.key === "Escape") onClose();
      if (e.key === "Tab" && ref.current) {
        const f = [...ref.current.querySelectorAll<HTMLElement>("button:not([disabled]), input, select")];
        if (e.shiftKey && document.activeElement === f[0]) {
          e.preventDefault();
          f[f.length - 1]?.focus();
        } else if (!e.shiftKey && document.activeElement === f[f.length - 1]) {
          e.preventDefault();
          f[0]?.focus();
        }
      }
    };
    document.addEventListener("keydown", onKey);
    return () => {
      document.removeEventListener("keydown", onKey);
      prev?.focus();
    };
  }, [onClose]);

  const next = () => {
    if (step === 1) {
      if (!sel.length) return setErr("Select at least one item to return.");
      setErr("");
      setStep(2);
    } else if (step === 2) {
      if (!reason) return setErr("Choose a reason.");
      setErr("");
      setCode(onSubmit({ orderId: order.id, lineIds: sel, reason, resolution }));
      setStep(3);
    }
  };

  return (
    <div className="fixed inset-0 z-50 flex items-end justify-center sm:items-center sm:p-6">
      <motion.div className="absolute inset-0 bg-black/55 backdrop-blur-sm" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} onClick={onClose} aria-hidden />
      <motion.div
        ref={ref}
        role="dialog"
        aria-modal="true"
        aria-labelledby="oh-return-title"
        initial={{ y: 40, opacity: 0 }}
        animate={{ y: 0, opacity: 1 }}
        exit={{ y: 40, opacity: 0 }}
        transition={{ type: "spring", stiffness: 360, damping: 34 }}
        className="relative w-full max-w-lg rounded-t-3xl border bg-background p-5 shadow-2xl sm:rounded-3xl sm:p-6"
      >
        <div className="flex items-start justify-between gap-4">
          <div>
            <h3 id="oh-return-title" className="text-lg font-semibold">
              {step === 3 ? "Return requested" : `Return items · ${order.number}`}
            </h3>
            {step < 3 && <p className="text-xs text-muted-foreground">Step {step} of 2 · Free returns within 30 days</p>}
          </div>
          <button type="button" onClick={onClose} aria-label="Close" className={cn("grid size-8 place-items-center rounded-full hover:bg-muted", ring)}>
            <X className="size-4" />
          </button>
        </div>
        {step < 3 && (
          <div className="mt-3 h-1 overflow-hidden rounded-full bg-muted">
            <motion.div className="h-full rounded-full bg-primary" animate={{ width: step === 1 ? "50%" : "100%" }} />
          </div>
        )}

        <AnimatePresence mode="wait" initial={false}>
          <motion.div key={step} initial={{ opacity: 0, x: 20 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: -20 }} transition={{ duration: 0.2 }} className="mt-5">
            {step === 1 && (
              <fieldset>
                <legend className="text-sm font-medium">Which items?</legend>
                <ul className="mt-3 space-y-2">
                  {order.lines.map((l) => {
                    const on = sel.includes(l.id);
                    return (
                      <li key={l.id}>
                        <label className={cn("flex cursor-pointer items-center gap-3 rounded-xl border p-3 transition", on ? "border-primary bg-primary/5" : "hover:bg-muted/50")}>
                          <input type="checkbox" checked={on} onChange={() => setSel((s) => (on ? s.filter((x) => x !== l.id) : [...s, l.id]))} className="size-4 accent-[var(--primary)]" />
                          <span className="size-11 shrink-0 rounded-lg bg-muted p-0.5">
                            <ProductArt kind={l.kind} color={l.hex} accent={l.accent} />
                          </span>
                          <span className="min-w-0 flex-1">
                            <span className="block truncate text-sm font-medium">{l.name}</span>
                            <span className="text-xs text-muted-foreground">Qty {l.qty}</span>
                          </span>
                          <span className="text-sm tabular-nums">{fmt.format(l.price * l.qty)}</span>
                        </label>
                      </li>
                    );
                  })}
                </ul>
              </fieldset>
            )}
            {step === 2 && (
              <div className="space-y-5">
                <div>
                  <label htmlFor="oh-reason" className="text-sm font-medium">
                    Reason
                  </label>
                  <div className="relative mt-1.5">
                    <select id="oh-reason" value={reason} onChange={(e) => setReason(e.target.value)} className={cn("h-11 w-full appearance-none rounded-lg border bg-background px-3 pr-9 text-sm", ring)}>
                      <option value="">Select a reason…</option>
                      {REASONS.map((r) => (
                        <option key={r}>{r}</option>
                      ))}
                    </select>
                    <ChevronDown className="pointer-events-none absolute right-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" aria-hidden />
                  </div>
                </div>
                <fieldset>
                  <legend className="text-sm font-medium">What would you like?</legend>
                  <div className="mt-2 grid grid-cols-3 gap-2">
                    {(["refund", "exchange", "credit"] as const).map((r) => (
                      <label key={r} className="cursor-pointer">
                        <input type="radio" name="oh-res" className="peer sr-only" checked={resolution === r} onChange={() => setResolution(r)} />
                        <span className="flex h-11 items-center justify-center rounded-xl border text-sm font-medium capitalize transition peer-checked:border-foreground peer-checked:bg-foreground peer-checked:text-background peer-focus-visible:ring-2 peer-focus-visible:ring-ring peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-background">
                          {r === "credit" ? "Store credit" : r}
                        </span>
                      </label>
                    ))}
                  </div>
                </fieldset>
                <p className="rounded-xl bg-muted/60 p-3 text-sm">
                  Estimated {resolution === "credit" ? "credit (+10% bonus)" : resolution}: <span className="font-semibold">{fmt.format(resolution === "credit" ? refund * 1.1 : refund)}</span>
                </p>
              </div>
            )}
            {step === 3 && (
              <div className="flex flex-col items-center py-4 text-center">
                <motion.span initial={{ scale: 0, rotate: -45 }} animate={{ scale: 1, rotate: 0 }} transition={{ type: "spring", stiffness: 400, damping: 15 }} className="grid size-14 place-items-center rounded-full bg-emerald-500 text-white">
                  <Check className="size-7" strokeWidth={3} aria-hidden />
                </motion.span>
                <p className="mt-4 text-sm text-muted-foreground">Your return code</p>
                <p className="mt-1 rounded-lg border border-dashed px-4 py-2 font-mono text-lg font-semibold tracking-wider">{code}</p>
                <p className="mt-3 max-w-xs text-sm text-muted-foreground">Drop the parcel at any Northwind point within 14 days. We&apos;ve emailed a prepaid label.</p>
              </div>
            )}
          </motion.div>
        </AnimatePresence>

        {err && (
          <p role="alert" className="mt-3 text-xs text-destructive">
            {err}
          </p>
        )}
        <div className="mt-6 flex justify-end gap-2">
          {step === 2 && (
            <button type="button" onClick={() => setStep(1)} className={cn("h-10 rounded-lg px-4 text-sm font-medium hover:bg-muted", ring)}>
              Back
            </button>
          )}
          {step < 3 ? (
            <button type="button" onClick={next} className={cn("h-10 rounded-lg bg-primary px-5 text-sm font-semibold text-primary-foreground hover:bg-primary/90", ring)}>
              {step === 1 ? `Continue${sel.length ? ` (${sel.length})` : ""}` : "Submit return"}
            </button>
          ) : (
            <button type="button" onClick={onClose} className={cn("h-10 rounded-lg bg-foreground px-5 text-sm font-semibold text-background", ring)}>
              Done
            </button>
          )}
        </div>
      </motion.div>
    </div>
  );
}

More in E-commerce

View all →