Fazekit

Code

"use client";
import * as React from "react";
import { AnimatePresence, LayoutGroup, motion, useReducedMotion } from "motion/react";
import { Check, ChevronLeft, ChevronRight, Eye, Minus, Plus, ShoppingBag, Star, Truck, X } from "lucide-react";
import { cn } from "@/lib/utils";
import { ProductArt, type ArtKind } from "./product-art";

export type QvColor = { name: string; hex: string; accent?: string };
export type QvOption = { label: string; priceDelta?: number; soldOut?: boolean };
export type QvProduct = {
  id: string;
  name: string;
  brand: string;
  kind: ArtKind;
  price: number;
  compareAt?: number;
  rating: number;
  reviews: number;
  blurb: string;
  colors: QvColor[];
  optionLabel?: string;
  options?: QvOption[];
  tag?: string;
};
export type QvAddPayload = { product: QvProduct; color: QvColor; option?: QvOption; qty: number; total: number };

export interface QuickViewModalProps {
  title?: string;
  eyebrow?: string;
  products?: QvProduct[];
  currency?: string;
  locale?: string;
  onAddToCart?: (payload: QvAddPayload) => void;
  onViewDetails?: (p: QvProduct) => void;
  className?: string;
}

const DEFAULT_PRODUCTS: QvProduct[] = [
  {
    id: "h1",
    name: "Aura Studio Headphones",
    brand: "Orbit Audio",
    kind: "headphones",
    price: 249,
    compareAt: 299,
    rating: 4.8,
    reviews: 1240,
    blurb: "Adaptive noise cancelling, 40-hour battery and plush memory-foam cushions tuned for long listening sessions.",
    colors: [
      { name: "Graphite", hex: "#334155", accent: "#d6d3d1" },
      { name: "Sand", hex: "#a8a29e", accent: "#fafaf9" },
      { name: "Ocean", hex: "#1d4ed8", accent: "#bfdbfe" },
    ],
    optionLabel: "Edition",
    options: [{ label: "Standard" }, { label: "Pro · lossless", priceDelta: 50 }],
    tag: "Bestseller",
  },
  {
    id: "w1",
    name: "Pulse Field Watch",
    brand: "Northwind",
    kind: "watch",
    price: 189,
    rating: 4.6,
    reviews: 532,
    blurb: "Sapphire glass, 10 ATM water resistance and a week of battery. Tracks sleep, heart rate and 60+ sports.",
    colors: [
      { name: "Lime", hex: "#475569", accent: "#a3e635" },
      { name: "Coral", hex: "#1f2937", accent: "#fb7185" },
      { name: "Ivory", hex: "#78716c", accent: "#f5f5f4" },
    ],
    optionLabel: "Case",
    options: [{ label: "40 mm" }, { label: "44 mm", priceDelta: 20 }, { label: "48 mm", soldOut: true }],
    tag: "New",
  },
  {
    id: "s1",
    name: "Drift Portable Speaker",
    brand: "Lumen",
    kind: "speaker",
    price: 129,
    rating: 4.7,
    reviews: 890,
    blurb: "360° sound, IP67 dust and waterproof, and it floats. Pair two for stereo in one tap.",
    colors: [
      { name: "Lagoon", hex: "#0f766e", accent: "#fde68a" },
      { name: "Ember", hex: "#c2410c", accent: "#fef3c7" },
      { name: "Night", hex: "#1e293b", accent: "#94a3b8" },
    ],
  },
  {
    id: "k1",
    name: "Stride Runner 2",
    brand: "Acme",
    kind: "sneaker",
    price: 139,
    compareAt: 159,
    rating: 4.5,
    reviews: 2210,
    blurb: "Responsive foam midsole, breathable knit upper and a grippy recycled-rubber outsole.",
    colors: [
      { name: "Crimson", hex: "#e11d48", accent: "#f8fafc" },
      { name: "Volt", hex: "#65a30d", accent: "#1c1917" },
      { name: "Slate", hex: "#475569", accent: "#f1f5f9" },
    ],
    optionLabel: "Size (EU)",
    options: [{ label: "40" }, { label: "41" }, { label: "42", soldOut: true }, { label: "43" }, { label: "44" }],
  },
  {
    id: "b1",
    name: "Transit Daypack 22L",
    brand: "Orbit",
    kind: "backpack",
    price: 95,
    rating: 4.9,
    reviews: 318,
    blurb: "Weatherproof shell, padded 16″ laptop sleeve and a luggage pass-through for travel days.",
    colors: [
      { name: "Navy", hex: "#1e3a8a", accent: "#fb923c" },
      { name: "Olive", hex: "#4d7c0f", accent: "#fef08a" },
      { name: "Black", hex: "#27272a", accent: "#a1a1aa" },
    ],
    tag: "Eco",
  },
  {
    id: "m1",
    name: "Morning Ceramic Mug",
    brand: "Lumen Home",
    kind: "mug",
    price: 24,
    rating: 4.4,
    reviews: 146,
    blurb: "Hand-glazed stoneware, 350 ml. Dishwasher and microwave safe — and it keeps coffee warm longer.",
    colors: [
      { name: "Tangerine", hex: "#f97316", accent: "#fff7ed" },
      { name: "Sage", hex: "#65a30d", accent: "#f7fee7" },
      { name: "Plum", hex: "#7e22ce", accent: "#faf5ff" },
    ],
    optionLabel: "Pack",
    options: [{ label: "Single" }, { label: "Pair", priceDelta: 20 }],
  },
];

const ring = "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background";
const VIEWS = [
  { tilt: 0, zoom: 1 },
  { tilt: -12, zoom: 1.08 },
  { tilt: 8, zoom: 1.35 },
];

export function QuickViewModal({
  title = "New in tech & lifestyle",
  eyebrow = "Just landed",
  products = DEFAULT_PRODUCTS,
  currency = "EUR",
  locale = "en-IE",
  onAddToCart,
  onViewDetails,
  className,
}: QuickViewModalProps) {
  const fmt = React.useMemo(() => new Intl.NumberFormat(locale, { style: "currency", currency, maximumFractionDigits: 0 }), [locale, currency]);
  const [openId, setOpenId] = React.useState<string | null>(null);
  const [cardColor, setCardColor] = React.useState<Record<string, number>>({});
  const [toast, setToast] = React.useState<string | null>(null);
  const triggers = React.useRef<Record<string, HTMLButtonElement | null>>({});
  const active = products.find((p) => p.id === openId) ?? null;

  const close = React.useCallback(() => {
    const id = openId;
    setOpenId(null);
    if (id) window.setTimeout(() => triggers.current[id]?.focus(), 0);
  }, [openId]);

  const step = (dir: 1 | -1) => {
    if (!active) return;
    const i = products.findIndex((p) => p.id === active.id);
    setOpenId(products[(i + dir + products.length) % products.length].id);
  };

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

  return (
    <section className={cn("relative w-full bg-background py-10 text-foreground sm:py-14", className)}>
      <div className="mx-auto max-w-6xl px-4 sm:px-6">
        <div className="mb-8 flex items-end justify-between gap-4">
          <div>
            <p className="text-xs font-semibold uppercase tracking-[0.2em] text-primary">{eyebrow}</p>
            <h2 className="mt-2 text-2xl font-semibold tracking-tight sm:text-3xl">{title}</h2>
          </div>
          <a href="#" onClick={(e) => e.preventDefault()} className={cn("hidden rounded text-sm font-medium underline-offset-4 hover:underline sm:block", ring)}>
            Shop all
          </a>
        </div>

        <LayoutGroup>
          <ul className="grid grid-cols-2 gap-x-3 gap-y-8 sm:gap-x-5 lg:grid-cols-3">
            {products.map((p) => {
              const ci = cardColor[p.id] ?? 0;
              const c = p.colors[ci];
              const isOpen = openId === p.id;
              return (
                <li key={p.id} className="group">
                  <div className="relative aspect-square overflow-hidden rounded-3xl bg-muted/70 ring-1 ring-border/60">
                    {!isOpen ? (
                      <motion.div layoutId={`qv-art-${p.id}`} className="absolute inset-[12%] transition-transform duration-500 group-hover:scale-105" transition={{ type: "spring", stiffness: 300, damping: 32 }}>
                        <ProductArt kind={p.kind} color={c.hex} accent={c.accent} label={p.brand.split(" ")[0]} />
                      </motion.div>
                    ) : (
                      <div className="absolute inset-[12%]" />
                    )}
                    {p.tag && <span className="absolute left-3 top-3 rounded-full bg-background/90 px-2.5 py-1 text-[11px] font-semibold shadow-sm backdrop-blur">{p.tag}</span>}
                    <button
                      ref={(el) => {
                        triggers.current[p.id] = el;
                      }}
                      type="button"
                      onClick={() => setOpenId(p.id)}
                      aria-haspopup="dialog"
                      aria-label={`Quick view ${p.name}`}
                      className={cn(
                        "absolute bottom-3 left-1/2 flex h-10 -translate-x-1/2 items-center gap-2 whitespace-nowrap rounded-full bg-foreground px-4 text-sm font-medium text-background shadow-xl transition duration-300",
                        "sm:translate-y-4 sm:opacity-0 sm:group-hover:translate-y-0 sm:group-hover:opacity-100 sm:focus-visible:translate-y-0 sm:focus-visible:opacity-100",
                        ring,
                      )}
                    >
                      <Eye className="size-4" aria-hidden /> <span className="hidden sm:inline">Quick view</span>
                      <span className="sm:hidden">View</span>
                    </button>
                  </div>
                  <div className="mt-3 flex items-start justify-between gap-2 px-0.5">
                    <div className="min-w-0">
                      <p className="text-xs text-muted-foreground">{p.brand}</p>
                      <h3 className="truncate text-sm font-medium">{p.name}</h3>
                    </div>
                    <p className="shrink-0 text-right text-sm font-semibold tabular-nums">
                      {fmt.format(p.price)}
                      {p.compareAt && <s className="block text-xs font-normal text-muted-foreground">{fmt.format(p.compareAt)}</s>}
                    </p>
                  </div>
                  <div className="mt-2 flex gap-1.5 px-0.5" role="radiogroup" aria-label={`${p.name} colour`}>
                    {p.colors.map((col, i) => (
                      <button
                        key={col.name}
                        type="button"
                        role="radio"
                        aria-checked={ci === i}
                        aria-label={col.name}
                        onClick={() => setCardColor((s) => ({ ...s, [p.id]: i }))}
                        className={cn("size-4 rounded-full ring-offset-2 ring-offset-background transition", ci === i ? "ring-2 ring-foreground" : "ring-1 ring-border", ring)}
                        style={{ background: `linear-gradient(135deg, ${col.hex} 50%, ${col.accent ?? col.hex} 50%)` }}
                      />
                    ))}
                  </div>
                </li>
              );
            })}
          </ul>

          <AnimatePresence>
            {active && (
              <Dialog
                key="dialog"
                product={active}
                initialColor={cardColor[active.id] ?? 0}
                fmt={fmt}
                onClose={close}
                onStep={step}
                onViewDetails={onViewDetails}
                onAdd={(payload) => {
                  onAddToCart?.(payload);
                  setCardColor((s) => ({ ...s, [payload.product.id]: payload.product.colors.indexOf(payload.color) }));
                  setToast(`${payload.qty} × ${payload.product.name} added to cart`);
                }}
              />
            )}
          </AnimatePresence>
        </LayoutGroup>
      </div>

      <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: 40, opacity: 0, scale: 0.95 }}
              animate={{ y: 0, opacity: 1, scale: 1 }}
              exit={{ y: 20, opacity: 0 }}
              className="pointer-events-auto flex items-center gap-3 rounded-2xl bg-foreground px-4 py-3 text-sm text-background shadow-2xl"
            >
              <span className="grid size-6 place-items-center rounded-full bg-emerald-500 text-white">
                <Check className="size-3.5" aria-hidden />
              </span>
              {toast}
            </motion.div>
          )}
        </AnimatePresence>
      </div>
    </section>
  );
}

function Dialog({
  product: p,
  initialColor,
  fmt,
  onClose,
  onStep,
  onAdd,
  onViewDetails,
}: {
  product: QvProduct;
  initialColor: number;
  fmt: Intl.NumberFormat;
  onClose: () => void;
  onStep: (d: 1 | -1) => void;
  onAdd: (payload: QvAddPayload) => void;
  onViewDetails?: (p: QvProduct) => void;
}) {
  const reduce = useReducedMotion();
  const ref = React.useRef<HTMLDivElement>(null);
  const [color, setColor] = React.useState(initialColor);
  const [view, setView] = React.useState(0);
  const [opt, setOpt] = React.useState(() => Math.max(0, p.options?.findIndex((o) => !o.soldOut) ?? 0));
  const [qty, setQty] = React.useState(1);
  const [state, setState] = React.useState<"idle" | "done">("idle");
  const [prevId, setPrevId] = React.useState(p.id);
  if (prevId !== p.id) {
    setPrevId(p.id);
    setColor(initialColor);
    setView(0);
    setOpt(Math.max(0, p.options?.findIndex((o) => !o.soldOut) ?? 0));
    setQty(1);
    setState("idle");
  }

  const c = p.colors[color] ?? p.colors[0];
  const option = p.options?.[opt];
  const unit = p.price + (option?.priceDelta ?? 0);
  const titleId = `qv-title-${p.id}`;

  React.useEffect(() => {
    const el = ref.current;
    el?.querySelector<HTMLElement>("[data-autofocus]")?.focus();
    const prev = document.body.style.overflow;
    document.body.style.overflow = "hidden";
    return () => {
      document.body.style.overflow = prev;
    };
  }, []);

  React.useEffect(() => {
    const onKey = (e: KeyboardEvent) => {
      const el = ref.current;
      if (e.key === "Escape") {
        e.preventDefault();
        onClose();
      }
      if ((e.key === "ArrowRight" || e.key === "ArrowLeft") && e.altKey) {
        onStep(e.key === "ArrowRight" ? 1 : -1);
      }
      if (e.key === "Tab" && el) {
        const f = [...el.querySelectorAll<HTMLElement>("button:not([disabled]), input, a[href]")];
        if (!f.length) return;
        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);
  }, [onClose, onStep]);

  const add = () => {
    if (option?.soldOut) return;
    setState("done");
    onAdd({ product: p, color: c, option, qty, total: unit * qty });
    window.setTimeout(() => setState("idle"), 1600);
  };

  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={titleId}
        initial={reduce ? { opacity: 0 } : { opacity: 0, y: 40, scale: 0.97 }}
        animate={{ opacity: 1, y: 0, scale: 1 }}
        exit={reduce ? { opacity: 0 } : { opacity: 0, y: 30, scale: 0.97 }}
        transition={{ type: "spring", stiffness: 340, damping: 32 }}
        className="relative grid max-h-[92dvh] w-full max-w-4xl overflow-y-auto rounded-t-3xl border bg-background shadow-2xl sm:grid-cols-2 sm:rounded-3xl"
      >
        {/* Gallery */}
        <div className="relative bg-muted/60 p-4 sm:p-6">
          <div className="relative mx-auto aspect-square w-full max-w-[250px] sm:max-w-none">
            <div aria-hidden className="absolute inset-[18%] rounded-full opacity-40 blur-3xl transition-colors duration-500" style={{ background: c.hex }} />
            <motion.div layoutId={`qv-art-${p.id}`} className="absolute inset-[6%]" transition={{ type: "spring", stiffness: 300, damping: 32 }}>
              <AnimatePresence mode="popLayout" initial={false}>
                <motion.div
                  key={`${view}-${color}`}
                  className="absolute inset-0"
                  initial={{ opacity: 0, scale: 0.92 }}
                  animate={{ opacity: 1, scale: 1 }}
                  exit={{ opacity: 0, scale: 1.04 }}
                  transition={{ duration: 0.3 }}
                >
                  <ProductArt kind={p.kind} color={c.hex} accent={c.accent} label={p.brand.split(" ")[0]} tilt={VIEWS[view].tilt} zoom={VIEWS[view].zoom} />
                </motion.div>
              </AnimatePresence>
            </motion.div>
          </div>
          <div className="mt-3 flex justify-center gap-2" role="tablist" aria-label="Images">
            {VIEWS.map((v, i) => (
              <button
                key={i}
                type="button"
                role="tab"
                aria-selected={view === i}
                aria-label={`Image ${i + 1}`}
                onClick={() => setView(i)}
                className={cn("size-14 rounded-xl border bg-background/70 p-1 transition", view === i ? "border-foreground" : "border-transparent opacity-60 hover:opacity-100", ring)}
              >
                <ProductArt kind={p.kind} color={c.hex} accent={c.accent} tilt={v.tilt} zoom={v.zoom} />
              </button>
            ))}
          </div>
          <div className="absolute left-3 top-3 flex gap-1.5 sm:left-4 sm:top-4">
            <button type="button" onClick={() => onStep(-1)} aria-label="Previous product" className={cn("grid size-9 place-items-center rounded-full bg-background/80 shadow-sm backdrop-blur hover:bg-background", ring)}>
              <ChevronLeft className="size-4" />
            </button>
            <button type="button" onClick={() => onStep(1)} aria-label="Next product" className={cn("grid size-9 place-items-center rounded-full bg-background/80 shadow-sm backdrop-blur hover:bg-background", ring)}>
              <ChevronRight className="size-4" />
            </button>
          </div>
        </div>

        {/* Details */}
        <div className="flex flex-col p-5 sm:p-7">
          <button type="button" data-autofocus onClick={onClose} aria-label="Close quick view" className={cn("absolute right-3 top-3 grid size-9 place-items-center rounded-full bg-background/80 hover:bg-muted sm:right-4 sm:top-4", ring)}>
            <X className="size-5" />
          </button>
          <p className="text-sm text-muted-foreground">{p.brand}</p>
          <h2 id={titleId} className="mt-1 pr-8 text-xl font-semibold tracking-tight sm:text-2xl">
            {p.name}
          </h2>
          <div className="mt-2 flex items-center gap-2 text-sm">
            <span className="flex" aria-label={`${p.rating} out of 5 stars`}>
              {[0, 1, 2, 3, 4].map((i) => (
                <Star key={i} className={cn("size-4", i < Math.round(p.rating) ? "fill-amber-400 text-amber-400" : "text-muted-foreground/40")} aria-hidden />
              ))}
            </span>
            <span className="text-muted-foreground">
              {p.rating} · {p.reviews.toLocaleString()} reviews
            </span>
          </div>
          <div className="mt-4 flex items-baseline gap-2">
            <motion.span key={unit} initial={reduce ? false : { opacity: 0, y: 6 }} animate={{ opacity: 1, y: 0 }} className="text-2xl font-semibold tabular-nums">
              {fmt.format(unit)}
            </motion.span>
            {p.compareAt && <s className="text-sm text-muted-foreground">{fmt.format(p.compareAt + (option?.priceDelta ?? 0))}</s>}
          </div>
          <p className="mt-3 text-sm leading-relaxed text-muted-foreground">{p.blurb}</p>

          <fieldset className="mt-5">
            <legend className="text-sm">
              <span className="font-medium">Colour:</span> <span className="text-muted-foreground">{c.name}</span>
            </legend>
            <div className="mt-2 flex gap-2.5">
              {p.colors.map((col, i) => (
                <label key={col.name} className="relative cursor-pointer">
                  <input type="radio" name={`qv-color-${p.id}`} className="peer sr-only" checked={color === i} onChange={() => setColor(i)} aria-label={col.name} />
                  <span className="block size-8 rounded-full border border-black/10 peer-focus-visible:ring-2 peer-focus-visible:ring-ring peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-background dark:border-white/15" style={{ background: `linear-gradient(135deg, ${col.hex} 50%, ${col.accent ?? col.hex} 50%)` }} />
                  {color === i && <motion.span layoutId="qv-swatch-ring" className="pointer-events-none absolute -inset-1 rounded-full border-2 border-foreground" />}
                </label>
              ))}
            </div>
          </fieldset>

          {p.options && (
            <fieldset className="mt-5">
              <legend className="text-sm font-medium">{p.optionLabel ?? "Option"}</legend>
              <div className="mt-2 flex flex-wrap gap-2">
                {p.options.map((o, i) => (
                  <label key={o.label} className={cn("relative", o.soldOut ? "cursor-not-allowed" : "cursor-pointer")}>
                    <input type="radio" name={`qv-opt-${p.id}`} className="peer sr-only" checked={opt === i} disabled={o.soldOut} onChange={() => setOpt(i)} />
                    <span
                      className={cn(
                        "flex h-10 min-w-12 items-center justify-center rounded-xl border px-3 text-sm transition peer-focus-visible:ring-2 peer-focus-visible:ring-ring peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-background",
                        opt === i ? "border-foreground bg-foreground text-background" : "hover:border-foreground/40",
                        o.soldOut && "border-dashed text-muted-foreground line-through",
                      )}
                    >
                      {o.label}
                      {o.priceDelta ? <span className={cn("ml-1.5 text-xs", opt === i ? "text-background/70" : "text-muted-foreground")}>+{fmt.format(o.priceDelta)}</span> : null}
                    </span>
                  </label>
                ))}
              </div>
            </fieldset>
          )}

          <div className="mt-6 flex gap-3 sm:mt-auto sm:pt-6">
            <div className="flex h-12 items-center rounded-xl border" role="group" aria-label="Quantity">
              <button type="button" aria-label="Decrease quantity" disabled={qty <= 1} onClick={() => setQty((q) => q - 1)} className={cn("grid h-full w-10 place-items-center rounded-l-xl hover:bg-muted disabled:opacity-40", ring)}>
                <Minus className="size-4" />
              </button>
              <span className="w-7 text-center text-sm font-semibold tabular-nums">{qty}</span>
              <button type="button" aria-label="Increase quantity" disabled={qty >= 9} onClick={() => setQty((q) => q + 1)} className={cn("grid h-full w-10 place-items-center rounded-r-xl hover:bg-muted disabled:opacity-40", ring)}>
                <Plus className="size-4" />
              </button>
            </div>
            <button
              type="button"
              onClick={add}
              className={cn(
                "relative flex h-12 flex-1 items-center justify-center gap-2 overflow-hidden rounded-xl text-sm font-semibold transition-colors",
                state === "done" ? "bg-emerald-600 text-white" : "bg-primary text-primary-foreground hover:bg-primary/90",
                ring,
              )}
            >
              <AnimatePresence mode="wait" initial={false}>
                <motion.span key={state} initial={{ y: 14, opacity: 0 }} animate={{ y: 0, opacity: 1 }} exit={{ y: -14, opacity: 0 }} className="flex items-center gap-2">
                  {state === "done" ? <Check className="size-4" /> : <ShoppingBag className="size-4" />}
                  {state === "done" ? "Added" : `Add · ${fmt.format(unit * qty)}`}
                </motion.span>
              </AnimatePresence>
            </button>
          </div>
          <div className="mt-4 flex items-center justify-between gap-2 text-xs text-muted-foreground">
            <span className="flex items-center gap-1.5">
              <Truck className="size-3.5" aria-hidden /> Free delivery in 2–3 days
            </span>
            <button type="button" onClick={() => onViewDetails?.(p)} className={cn("rounded font-medium text-foreground underline underline-offset-4", ring)}>
              View full details
            </button>
          </div>
        </div>
      </motion.div>
    </div>
  );
}

More in E-commerce

View all →