Fazekit

Code

"use client";
import * as React from "react";
import { AnimatePresence, motion, MotionConfig } from "motion/react";
import { Bike, CheckCircle2, ChevronRight, MapPin, ShoppingBasket, Store, X } from "lucide-react";
import { cn } from "@/lib/utils";
import { BasketPanel, TipSelector, TotalsList } from "./basket-panel";
import { CheckoutView } from "./checkout-view";
import { MENU_CATEGORIES, MENU_ITEMS, PICKUP_SLOTS, RESTAURANT, TIP_PRESETS } from "./data";
import { dishBg, dishStyle, FoodArt } from "./food-art";
import { basketTotals, BrandMark, focusRing, lineKeyOf, optionSummary, Segmented, unitPrice } from "./food-ui";
import { ItemModal, type ModalState, useFocusTrap } from "./item-modal";
import { MenuView } from "./menu-view";
import { TrackerView } from "./tracker-view";
import type { BasketLine, DeliveryAddress, FulfilmentMode, MenuCategory, MenuItem, OrderStatus, PlacedOrder, Restaurant } from "./types";

export type { BasketLine, DeliveryAddress, FulfilmentMode, MenuCategory, MenuItem, OrderStatus, PlacedOrder, Restaurant };

export interface FoodOrderingAppProps {
  restaurant?: Restaurant;
  categories?: MenuCategory[];
  items?: MenuItem[];
  currency?: string;
  locale?: string;
  initialBasket?: BasketLine[];
  initialMode?: FulfilmentMode;
  tipPresets?: number[];
  pickupSlots?: string[];
  defaultAddress?: Partial<DeliveryAddress>;
  /** How long the simulated order takes from "received" to "delivered", in ms. */
  trackingDurationMs?: number;
  onAddToCart?: (line: BasketLine, item: MenuItem) => void;
  onBasketChange?: (lines: BasketLine[]) => void;
  onCheckout?: (lines: BasketLine[]) => void;
  onOrder?: (order: PlacedOrder) => void;
  onStatusChange?: (status: OrderStatus, order: PlacedOrder) => void;
  className?: string;
}

type View = "menu" | "checkout" | "tracking";
let seq = 0;

function subscribeLg(cb: () => void) {
  const mq = window.matchMedia("(min-width: 1024px)");
  mq.addEventListener("change", cb);
  return () => mq.removeEventListener("change", cb);
}
/** True on wide screens, where the basket is a persistent sidebar instead of a sheet. */
function useIsWide() {
  return React.useSyncExternalStore(
    subscribeLg,
    () => window.matchMedia("(min-width: 1024px)").matches,
    () => false,
  );
}

export function FoodOrderingApp({
  restaurant = RESTAURANT,
  categories = MENU_CATEGORIES,
  items = MENU_ITEMS,
  currency = "EUR",
  locale = "en-IE",
  initialBasket,
  initialMode = "delivery",
  tipPresets = TIP_PRESETS,
  pickupSlots = PICKUP_SLOTS,
  defaultAddress,
  trackingDurationMs = 60_000,
  onAddToCart,
  onBasketChange,
  onCheckout,
  onOrder,
  onStatusChange,
  className,
}: FoodOrderingAppProps) {
  const [view, setView] = React.useState<View>("menu");
  const [mode, setMode] = React.useState<FulfilmentMode>(initialMode);
  const [lines, setLines] = React.useState<BasketLine[]>(initialBasket ?? []);
  const [tipRate, setTipRate] = React.useState<number | null>(tipPresets[2] ?? 0.15);
  const [customTip, setCustomTip] = React.useState(0);
  const [modal, setModal] = React.useState<ModalState>(null);
  const [sheet, setSheet] = React.useState(false);
  const [order, setOrder] = React.useState<PlacedOrder | null>(null);
  const [toast, setToast] = React.useState<{ id: number; text: string } | null>(null);
  const [bump, setBump] = React.useState(0);
  const sheetRef = React.useRef<HTMLDivElement>(null);
  const wide = useIsWide();

  const money = React.useMemo(() => {
    const f = new Intl.NumberFormat(locale, { style: "currency", currency });
    return (n: number) => f.format(n);
  }, [locale, currency]);
  const byId = React.useMemo(() => Object.fromEntries(items.map((i) => [i.id, i])), [items]);
  const totals = basketTotals(lines, byId, restaurant, mode, tipRate, customTip);
  const qtyByItem = React.useMemo(() => {
    const m: Record<string, number> = {};
    for (const l of lines) m[l.itemId] = (m[l.itemId] ?? 0) + l.qty;
    return m;
  }, [lines]);

  const cbRef = React.useRef(onBasketChange);
  React.useLayoutEffect(() => {
    cbRef.current = onBasketChange;
  });
  const first = React.useRef(true);
  React.useEffect(() => {
    if (first.current) {
      first.current = false;
      return;
    }
    cbRef.current?.(lines);
  }, [lines]);

  const showToast = (text: string) => {
    const id = ++seq;
    setToast({ id, text });
    window.setTimeout(() => setToast((t) => (t?.id === id ? null : t)), 2600);
  };

  const closeModal = React.useCallback(() => setModal(null), []);
  const closeSheet = React.useCallback(() => setSheet(false), []);
  useFocusTrap(sheet, sheetRef, closeSheet);

  const submitItem = ({ item, selections, qty, notes, editKey }: { item: MenuItem; selections: Record<string, string[]>; qty: number; notes: string; editKey?: string }) => {
    const key = lineKeyOf(item.id, selections, notes);
    const line: BasketLine = { key, itemId: item.id, qty, selections, notes: notes.trim() || undefined };
    setLines((ls) => {
      const base = editKey ? ls.filter((l) => l.key !== editKey) : ls;
      const existing = base.find((l) => l.key === key);
      if (existing) return base.map((l) => (l.key === key ? { ...l, qty: editKey ? qty : l.qty + qty } : l));
      if (editKey) {
        const idx = ls.findIndex((l) => l.key === editKey);
        const next = [...base];
        next.splice(Math.max(0, idx), 0, line);
        return next;
      }
      return [...base, line];
    });
    if (!editKey) onAddToCart?.(line, item);
    setModal(null);
    setBump((b) => b + 1);
    showToast(editKey ? `Updated ${item.name}` : `Added ${qty > 1 ? `${qty}× ` : ""}${item.name}`);
  };

  const setQty = (key: string, qty: number) => setLines((ls) => (qty <= 0 ? ls.filter((l) => l.key !== key) : ls.map((l) => (l.key === key ? { ...l, qty } : l))));

  const goCheckout = () => {
    if (!lines.length || totals.belowMin) return;
    setSheet(false);
    onCheckout?.(lines);
    setView("checkout");
  };

  const place = ({ address, payment, pickupSlot }: { address: DeliveryAddress; payment: string; pickupSlot?: string }) => {
    const placedAt = Date.now();
    const o: PlacedOrder = {
      id: `LK${(placedAt % 100000).toString().padStart(5, "0")}`,
      placedAt,
      mode,
      lines: lines.flatMap((l) => {
        const it = byId[l.itemId];
        return it ? [{ name: it.name, qty: l.qty, options: optionSummary(it, l.selections), notes: l.notes, total: unitPrice(it, l.selections) * l.qty }] : [];
      }),
      subtotal: totals.subtotal,
      deliveryFee: totals.deliveryFee,
      serviceFee: totals.serviceFee,
      tip: totals.tip,
      total: totals.total,
      address,
      pickupSlot,
      payment,
      durationMs: trackingDurationMs,
    };
    setOrder(o);
    setLines([]);
    onOrder?.(o);
    setView("tracking");
  };

  const modeToggle = (id: string, className?: string) => (
    <Segmented
      id={id}
      label="Delivery or pickup"
      value={mode}
      onChange={setMode}
      className={className}
      options={[
        {
          id: "delivery",
          label: (
            <>
              <Bike className="size-4" /> Delivery
            </>
          ),
        },
        {
          id: "pickup",
          label: (
            <>
              <Store className="size-4" /> Pickup
            </>
          ),
        },
      ]}
    />
  );

  const tip = (
    <TipSelector
      presets={tipPresets}
      rate={tipRate}
      custom={customTip}
      subtotal={totals.subtotal}
      money={money}
      mode={mode}
      onChange={(r, c) => {
        setTipRate(r);
        setCustomTip(c);
      }}
    />
  );

  const basket = (
    <BasketPanel
      lines={lines}
      byId={byId}
      totals={totals}
      mode={mode}
      modeToggle={null}
      money={money}
      freeFrom={restaurant.freeDeliveryFrom}
      minOrder={restaurant.minOrder}
      tip={tip}
      onQty={setQty}
      onEdit={(l) => {
        const it = byId[l.itemId];
        if (it) setModal({ item: it, selections: l.selections, qty: l.qty, notes: l.notes, editKey: l.key });
      }}
      onCheckout={goCheckout}
    />
  );

  const summary = (
    <div className="space-y-4">
      <h2 className="text-base font-semibold">Your order</h2>
      <ul className="space-y-2.5">
        {lines.map((l) => {
          const it = byId[l.itemId];
          if (!it) return null;
          const opts = optionSummary(it, l.selections);
          return (
            <li key={l.key} className="flex items-center gap-3">
              <span className={cn("grid size-11 shrink-0 place-items-center rounded-lg", dishBg)} style={dishStyle(it.hue)}>
                <FoodArt kind={it.art} colors={it.colors} />
              </span>
              <span className="min-w-0 flex-1 text-sm">
                <span className="block truncate font-medium">
                  {l.qty}× {it.name}
                </span>
                {opts.length > 0 && <span className="block truncate text-xs text-muted-foreground">{opts.join(" · ")}</span>}
              </span>
              <span className="text-sm tabular-nums">{money(unitPrice(it, l.selections) * l.qty)}</span>
            </li>
          );
        })}
      </ul>
      <div className="border-t pt-4">{tip}</div>
      <div className="border-t pt-4">
        <TotalsList totals={totals} money={money} mode={mode} />
      </div>
    </div>
  );

  const overlay = Boolean(modal) || (sheet && !wide);

  return (
    <MotionConfig reducedMotion="user">
      <div className={cn("relative isolate flex h-[760px] w-full flex-col overflow-clip bg-background text-foreground antialiased", className)}>
        <div inert={overlay ? true : undefined} className="flex min-h-0 flex-1 flex-col">
          {/* top bar */}
          <header className="flex h-14 shrink-0 items-center gap-3 border-b px-4 sm:px-6">
            <button type="button" onClick={() => setView(order && view === "tracking" ? "tracking" : "menu")} className={cn("flex items-center gap-2 rounded-lg", focusRing)} aria-label={`${restaurant.name} menu`}>
              <BrandMark />
              <span className="hidden text-[15px] font-bold tracking-tight sm:inline">{restaurant.name}</span>
            </button>
            <span className="mx-1 hidden h-5 w-px bg-border sm:block" aria-hidden />
            <p className="flex min-w-0 items-center gap-1.5 text-[13px] text-muted-foreground">
              <MapPin className="size-4 shrink-0 text-orange-500" />
              <span className="truncate">{mode === "delivery" ? `Delivering to ${defaultAddress?.street ?? "42 Merrion Square"}` : `Pickup at ${restaurant.address}`}</span>
            </p>
            <div className="ml-auto flex items-center gap-2">
              {order && view !== "tracking" && (
                <button type="button" onClick={() => setView("tracking")} className={cn("hidden h-9 items-center gap-1.5 rounded-full bg-orange-500/10 px-3 text-xs font-semibold text-orange-700 sm:inline-flex dark:text-orange-300", focusRing)}>
                  <span className="size-1.5 animate-pulse rounded-full bg-orange-500" /> Track order <ChevronRight className="size-3.5" />
                </button>
              )}
              {view === "menu" && (
                <button
                  type="button"
                  onClick={() => setSheet(true)}
                  aria-label={`Open basket, ${totals.count} ${totals.count === 1 ? "item" : "items"}`}
                  className={cn("relative grid size-10 place-items-center rounded-full hover:bg-accent lg:hidden", focusRing)}
                >
                  <motion.span key={bump} animate={bump ? { scale: [1, 1.3, 1], rotate: [0, -10, 0] } : undefined} transition={{ duration: 0.45 }}>
                    <ShoppingBasket className="size-5" />
                  </motion.span>
                  {totals.count > 0 && (
                    <span className="absolute -right-0.5 -top-0.5 grid h-[18px] min-w-[18px] place-items-center rounded-full bg-gradient-to-r from-orange-500 to-rose-500 px-1 text-[10px] font-bold text-white ring-2 ring-background">
                      {totals.count}
                    </span>
                  )}
                </button>
              )}
            </div>
          </header>

          <div className="relative flex min-h-0 flex-1">
            <motion.main key={view} className="min-h-0 min-w-0 flex-1" initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ duration: 0.2 }}>
                {view === "menu" && (
                  <MenuView
                    restaurant={restaurant}
                    categories={categories}
                    items={items}
                    qtyByItem={qtyByItem}
                    money={money}
                    modeToggle={modeToggle("lumen-mode-menu", "w-full sm:w-auto lg:hidden")}
                    onOpenItem={(item) => setModal({ item })}
                    bottomPad={totals.count > 0}
                  />
                )}
                {view === "checkout" && (
                  <CheckoutView
                    mode={mode}
                    restaurant={restaurant}
                    slots={pickupSlots}
                    summary={summary}
                    total={money(totals.total)}
                    modeToggle={modeToggle("lumen-mode-checkout")}
                    defaultAddress={defaultAddress}
                    onBack={() => setView("menu")}
                    onPlace={place}
                  />
                )}
                {view === "tracking" && order && (
                  <TrackerView
                    key={order.id}
                    order={order}
                    restaurant={restaurant}
                    money={money}
                    onStatusChange={(s) => onStatusChange?.(s, order)}
                    onNewOrder={() => setView("menu")}
                  />
                )}
            </motion.main>

            {view === "menu" && wide && (
              <aside aria-labelledby="lumen-basket-title" className="hidden w-[360px] shrink-0 border-l bg-muted/20 lg:flex lg:flex-col">
                <div className="border-b px-5 pb-3 pt-4">{modeToggle("lumen-mode-side", "w-full")}</div>
                <div className="min-h-0 flex-1">{basket}</div>
              </aside>
            )}

            {/* mobile basket bar */}
            <AnimatePresence>
              {view === "menu" && totals.count > 0 && (
                <motion.div className="pointer-events-none absolute inset-x-0 bottom-0 z-20 p-4 lg:hidden" initial={{ y: 100 }} animate={{ y: 0 }} exit={{ y: 100 }} transition={{ type: "spring", stiffness: 380, damping: 34 }}>
                  <button
                    type="button"
                    onClick={() => setSheet(true)}
                    className={cn("pointer-events-auto flex h-14 w-full items-center gap-3 rounded-2xl bg-gradient-to-r from-orange-500 to-rose-500 px-4 text-white shadow-xl shadow-rose-500/30", focusRing)}
                  >
                    <motion.span key={bump} initial={{ scale: 0.6 }} animate={{ scale: 1 }} className="grid h-8 min-w-8 place-items-center rounded-lg bg-white/20 px-1.5 text-sm font-bold tabular-nums">
                      {totals.count}
                    </motion.span>
                    <span className="flex-1 text-left text-sm font-semibold">View basket</span>
                    <span className="text-sm font-bold tabular-nums">{money(totals.subtotal)}</span>
                  </button>
                </motion.div>
              )}
            </AnimatePresence>
          </div>
        </div>

        {/* mobile basket sheet */}
        <AnimatePresence>
          {sheet && !wide && (
            <>
              <motion.div aria-hidden className="absolute inset-0 z-40 bg-black/45" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} onClick={closeSheet} />
              <motion.div
                ref={sheetRef}
                role="dialog"
                aria-modal="true"
                aria-labelledby="lumen-basket-title"
                initial={{ y: "100%" }}
                animate={{ y: 0 }}
                exit={{ y: "100%" }}
                transition={{ type: "spring", stiffness: 380, damping: 40 }}
                className="absolute inset-x-0 bottom-0 z-50 flex h-[88%] flex-col overflow-hidden rounded-t-3xl border-t bg-background shadow-2xl sm:inset-x-auto sm:right-0 sm:top-0 sm:h-full sm:w-[400px] sm:rounded-none sm:border-l"
              >
                <button type="button" data-autofocus onClick={closeSheet} aria-label="Close basket" className={cn("absolute right-3 top-3 z-10 grid size-9 place-items-center rounded-full hover:bg-accent", focusRing)}>
                  <X className="size-5" />
                </button>
                <div className="border-b px-5 pb-3 pt-14">{modeToggle("lumen-mode-sheet", "w-full")}</div>
                <div className="min-h-0 flex-1">{basket}</div>
              </motion.div>
            </>
          )}
        </AnimatePresence>

        <ItemModal state={modal} money={money} onClose={closeModal} onSubmit={submitItem} />

        <div aria-live="polite" className="pointer-events-none absolute inset-x-0 top-16 z-[60] flex justify-center px-4">
          <AnimatePresence>
            {toast && (
              <motion.div
                key={toast.id}
                role="status"
                initial={{ opacity: 0, y: -16, scale: 0.9 }}
                animate={{ opacity: 1, y: 0, scale: 1 }}
                exit={{ opacity: 0, y: -10, scale: 0.95 }}
                transition={{ type: "spring", stiffness: 500, damping: 30 }}
                className="flex items-center gap-2 rounded-full bg-foreground px-4 py-2 text-[13px] font-medium text-background shadow-xl"
              >
                <CheckCircle2 className="size-4 text-emerald-400 dark:text-emerald-600" /> {toast.text}
              </motion.div>
            )}
          </AnimatePresence>
        </div>
      </div>
    </MotionConfig>
  );
}

export default FoodOrderingApp;

More in E-commerce

View all →