Files
meezi/web/dashboard/src/components/qr/qr-guest-menu.tsx
T

724 lines
23 KiB
TypeScript
Raw Normal View History

"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useLocale, useTranslations } from "next-intl";
import { menuItemMatchesSearch } from "@/lib/menu-display";
import { QR_ALL_CATEGORY_ID } from "@/lib/qr-menu-constants";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { MenuItemLabels } from "@/components/menu/menu-item-labels";
import { formatCurrency } from "@/lib/format";
import { resolveMediaUrl } from "@/lib/api/client";
import { ApiClientError } from "@/lib/api/client";
import {
callWaiter,
fetchBranchPublicMenu,
fetchPublicSecurityConfig,
placeBranchGuestOrder,
resolveQrCode,
type PublicSecurityConfig,
type QrCartLine,
type QrPublicMenuItem,
type QrResolve,
} from "@/lib/api/qr-public";
import {
buildQrThemeCssVars,
normalizeCafeTheme,
normalizeMenuTexture,
qrMenuTextureShellProps,
resolveQrGuestColors,
type CafeTheme,
} from "@/lib/cafe-theme";
import { QrFloatingCartBar, QrGuestMenuBody } from "@/components/qr/qr-guest-menu-body";
import { QrMenu3dSheet } from "@/components/qr/qr-menu-3d-sheet";
import { QrTurnstile } from "@/components/qr/qr-turnstile";
import { QrOrderTrack } from "@/components/qr/qr-order-track";
import {
loadGuestOrders,
ordersForTable,
saveGuestOrder,
type GuestOrderRef,
} from "@/lib/guest-order-storage";
import { cn } from "@/lib/utils";
type Screen = "loading" | "error" | "menu" | "cart" | "success" | "track" | "orders";
type QrGuestMenuProps = {
code: string;
};
export function QrGuestMenu({ code }: QrGuestMenuProps) {
const t = useTranslations("qrMenu");
const locale = useLocale();
const [screen, setScreen] = useState<Screen>("loading");
const [error, setError] = useState<string>("");
const [branch, setBranch] = useState<QrResolve | null>(null);
const [categories, setCategories] = useState<
Awaited<ReturnType<typeof fetchBranchPublicMenu>>["categories"]
>([]);
const [activeCategory, setActiveCategory] = useState("");
const [cart, setCart] = useState<QrCartLine[]>([]);
const [guestName, setGuestName] = useState("");
const [guestPhone, setGuestPhone] = useState("");
const [orderNumber, setOrderNumber] = useState("");
const [activeTrack, setActiveTrack] = useState<{ orderId: string; token: string } | null>(null);
const [tableOrders, setTableOrders] = useState<GuestOrderRef[]>([]);
const [submitting, setSubmitting] = useState(false);
const [menuTheme, setMenuTheme] = useState<CafeTheme | null>(null);
const [searchQuery, setSearchQuery] = useState("");
const [view3dItem, setView3dItem] = useState<QrPublicMenuItem | null>(null);
const [security, setSecurity] = useState<PublicSecurityConfig | null>(null);
const [captchaToken, setCaptchaToken] = useState<string | null>(null);
const [callWaiterState, setCallWaiterState] = useState<"idle" | "sending" | "sent" | "cooldown">("idle");
const themeColors = useMemo(
() => resolveQrGuestColors(menuTheme, branch?.primaryColor),
[menuTheme, branch?.primaryColor]
);
const primary = themeColors.primary;
const menuStyle = menuTheme?.menuStyle ?? "cards";
useEffect(() => {
let cancelled = false;
fetchPublicSecurityConfig()
.then((cfg) => {
if (!cancelled) setSecurity(cfg);
})
.catch(() => {
/* optional — orders still work when captcha is off */
});
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
if (!code) return;
let cancelled = false;
(async () => {
try {
const resolved = await resolveQrCode(code);
if (cancelled) return;
if (resolved.isCleaning) {
setError(t("tableCleaning"));
setScreen("error");
return;
}
setBranch(resolved);
const menu = await fetchBranchPublicMenu(resolved.cafeId, resolved.branchId);
if (cancelled) return;
const cats = menu.categories ?? [];
setCategories(cats);
setMenuTheme(normalizeCafeTheme(menu.theme ?? undefined));
setActiveCategory(QR_ALL_CATEGORY_ID);
if (cats.length === 0) {
setError(t("emptyMenu"));
setScreen("error");
return;
}
setScreen("menu");
setError("");
setTableOrders(ordersForTable(loadGuestOrders(), resolved.cafeId, resolved.tableId));
} catch (err) {
if (cancelled) return;
const message =
err instanceof ApiClientError
? err.code === "NOT_FOUND"
? t("tableNotFound")
: `${t("loadError")} (${err.message})`
: t("loadError");
setError(message);
setScreen("error");
}
})();
return () => {
cancelled = true;
};
}, [code, t]);
const totalItems = cart.reduce((s, c) => s + c.qty, 0);
const totalPrice = cart.reduce(
(s, c) => s + effectiveLinePrice(c.item) * c.qty,
0
);
const allItems = useMemo(
() => categories.flatMap((c) => c.items ?? []),
[categories]
);
const searchTrimmed = searchQuery.trim();
const isSearching = searchTrimmed.length > 0;
const showAllGrouped =
!isSearching && activeCategory === QR_ALL_CATEGORY_ID;
const activeItems = useMemo(() => {
const pool = isSearching
? allItems
: activeCategory === QR_ALL_CATEGORY_ID
? allItems
: categories.find((c) => c.id === activeCategory)?.items ?? [];
if (!isSearching) return pool;
return pool.filter((item) => menuItemMatchesSearch(item, searchTrimmed, locale));
}, [allItems, categories, activeCategory, isSearching, searchTrimmed, locale]);
const categoryNameById = useMemo(() => {
const map = new Map<string, string>();
for (const c of categories) map.set(c.id, c.name);
return map;
}, [categories]);
const addToCart = useCallback((item: QrPublicMenuItem) => {
setCart((prev) => {
const idx = prev.findIndex((c) => c.item.id === item.id);
if (idx >= 0) {
const next = [...prev];
next[idx] = { ...next[idx]!, qty: next[idx]!.qty + 1 };
return next;
}
return [...prev, { item, qty: 1 }];
});
}, []);
const removeFromCart = useCallback((itemId: string) => {
setCart((prev) => {
const idx = prev.findIndex((c) => c.item.id === itemId);
if (idx < 0) return prev;
const next = [...prev];
if (next[idx]!.qty > 1) {
next[idx] = { ...next[idx]!, qty: next[idx]!.qty - 1 };
return next;
}
next.splice(idx, 1);
return next;
});
}, []);
const refreshTableOrders = useCallback(() => {
if (!branch) return;
setTableOrders(
ordersForTable(loadGuestOrders(), branch.cafeId, branch.tableId)
);
}, [branch]);
useEffect(() => {
if (screen === "orders") refreshTableOrders();
}, [screen, refreshTableOrders]);
const handleCallWaiter = useCallback(async () => {
if (!branch || callWaiterState !== "idle") return;
setCallWaiterState("sending");
try {
await callWaiter(branch.cafeId, branch.tableId);
setCallWaiterState("sent");
setTimeout(() => setCallWaiterState("cooldown"), 2500);
setTimeout(() => setCallWaiterState("idle"), 62_000);
} catch (err) {
const code = err instanceof ApiClientError ? err.code : null;
setCallWaiterState(code === "RATE_LIMITED" ? "cooldown" : "idle");
if (code !== "RATE_LIMITED") setTimeout(() => setCallWaiterState("idle"), 3000);
}
}, [branch, callWaiterState]);
const captchaRequired =
!!security?.captchaRequired && !!security.turnstileSiteKey;
const submitOrder = async () => {
if (!branch || cart.length === 0) return;
if (captchaRequired && !captchaToken) {
setError(t("captchaRequired"));
return;
}
setSubmitting(true);
setError("");
try {
const result = await placeBranchGuestOrder(branch.cafeId, branch.branchId, {
tableId: branch.tableId,
guestName: guestName.trim() || null,
guestPhone: guestPhone.trim() || null,
captchaToken: captchaToken ?? undefined,
items: cart.map((c) => ({
menuItemId: c.item.id,
quantity: c.qty,
notes: c.note ?? null,
})),
});
setOrderNumber(result.orderNumber);
const orderRef: GuestOrderRef = {
orderId: result.orderId,
trackingToken: result.trackingToken,
orderNumber: result.orderNumber,
createdAt: new Date().toISOString(),
cafeId: branch.cafeId,
branchId: branch.branchId,
tableId: branch.tableId,
};
const saved = saveGuestOrder(orderRef);
setCart([]);
setCaptchaToken(null);
if (saved) {
refreshTableOrders();
} else {
setTableOrders((prev) => {
const filtered = prev.filter((o) => o.orderId !== orderRef.orderId);
return [orderRef, ...filtered];
});
}
setActiveTrack({ orderId: result.orderId, token: result.trackingToken });
setScreen("track");
} catch (err) {
if (err instanceof ApiClientError) {
if (err.code === "RATE_LIMITED") setError(t("rateLimited"));
else if (err.code?.startsWith("CAPTCHA")) setError(t("captchaRequired"));
else if (err.code === "CAFE_SUSPENDED") setError(t("cafeUnavailable"));
else setError(err.message || t("orderError"));
} else {
setError(t("orderError"));
}
setScreen("cart");
} finally {
setSubmitting(false);
}
};
if (screen === "loading") {
return (
<div
className="flex min-h-svh flex-col items-center justify-center gap-3 p-6"
data-qr-guest-menu
style={buildQrThemeCssVars(themeColors)}
>
<div
className="size-10 animate-spin rounded-full border-[3px] border-t-transparent"
style={{ borderColor: primary, borderTopColor: "transparent" }}
/>
<p className="text-sm qr-muted">{t("loading")}</p>
</div>
);
}
if (screen === "error") {
return (
<main
className="flex min-h-svh flex-col items-center justify-center p-6 text-center"
data-qr-guest-menu
style={buildQrThemeCssVars(themeColors)}
>
<p className="text-4xl">😕</p>
<p className="mt-4 font-medium qr-text">{error}</p>
<p className="mt-2 text-sm qr-muted">{t("scanAgain")}</p>
</main>
);
}
if (screen === "track" && activeTrack) {
return (
<main
className="mx-auto min-h-svh max-w-md"
dir="rtl"
data-qr-guest-menu
style={buildQrThemeCssVars(themeColors)}
>
<QrOrderTrack
orderId={activeTrack.orderId}
trackingToken={activeTrack.token}
primary={primary}
onBack={() => setScreen("menu")}
/>
<QrBottomNav
screen={screen}
primary={primary}
onMenu={() => setScreen("menu")}
onOrders={() => {
refreshTableOrders();
setScreen("orders");
}}
callWaiterState={callWaiterState}
onCallWaiter={() => void handleCallWaiter()}
/>
</main>
);
}
if (screen === "orders" && branch) {
return (
<main
className="mx-auto flex min-h-svh max-w-md flex-col"
dir="rtl"
data-qr-guest-menu
style={buildQrThemeCssVars(themeColors)}
>
<div className="flex-1 overflow-auto p-4">
<h2 className="mb-3 text-lg font-semibold qr-text">{t("myOrders")}</h2>
{tableOrders.length === 0 ? (
<p className="text-sm qr-muted">{t("noOrders")}</p>
) : (
<div className="space-y-2">
{tableOrders.map((o) => (
<button
key={o.orderId}
type="button"
className="w-full rounded-xl border qr-border qr-surface p-4 text-start transition"
style={{ borderColor: `color-mix(in srgb, ${primary} 35%, transparent)` }}
onClick={() => {
setActiveTrack({ orderId: o.orderId, token: o.trackingToken });
setScreen("track");
}}
>
<p className="font-medium qr-text">{o.orderNumber}</p>
<p className="text-xs qr-muted">
{new Date(o.createdAt).toLocaleString("fa-IR")}
</p>
</button>
))}
</div>
)}
</div>
<QrBottomNav
screen={screen}
primary={primary}
onMenu={() => setScreen("menu")}
onOrders={() => setScreen("orders")}
callWaiterState={callWaiterState}
onCallWaiter={() => void handleCallWaiter()}
/>
</main>
);
}
if (screen === "cart") {
return (
<div
className="mx-auto min-h-svh max-w-md p-4"
dir="rtl"
data-qr-guest-menu
style={buildQrThemeCssVars(themeColors)}
>
<header className="mb-4 flex items-center gap-2">
<Button variant="ghost" size="sm" onClick={() => setScreen("menu")}>
</Button>
<h2 className="text-lg font-semibold qr-text">{t("cartTitle")}</h2>
</header>
<div className="rounded-xl border qr-border qr-surface">
{cart.map((c) => (
<div
key={c.item.id}
className="flex items-center justify-between gap-3 border-b px-3 py-3 last:border-0"
>
<div className="min-w-0 flex-1">
<MenuItemLabels item={c.item} lines={1} primaryClassName="text-sm" />
<p className="text-sm font-medium" style={{ color: primary }}>
{formatCurrency(effectiveLinePrice(c.item), "fa-IR")}
</p>
</div>
<div className="flex items-center gap-2">
<QtyButton
label=""
onClick={() => removeFromCart(c.item.id)}
variant="outline"
color={primary}
/>
<span className="min-w-6 text-center font-semibold">{c.qty}</span>
<QtyButton
label="+"
onClick={() => addToCart(c.item)}
variant="filled"
color={primary}
/>
</div>
</div>
))}
</div>
<div className="mt-4 space-y-2">
<Input
value={guestName}
onChange={(e) => setGuestName(e.target.value)}
placeholder={t("guestName")}
className="text-end"
/>
<Input
value={guestPhone}
onChange={(e) => setGuestPhone(e.target.value)}
placeholder={t("guestPhone")}
inputMode="tel"
className="text-end"
/>
</div>
{captchaRequired && security?.turnstileSiteKey ? (
<div className="mt-4">
<QrTurnstile
siteKey={security.turnstileSiteKey}
onToken={(token) => {
setCaptchaToken(token);
if (error === t("captchaRequired")) setError("");
}}
onExpire={() => setCaptchaToken(null)}
/>
</div>
) : null}
{error ? (
<p className="mt-3 text-sm text-destructive">{error}</p>
) : null}
<div className="mt-4 rounded-xl border qr-border qr-surface p-4">
<div className="mb-3 flex justify-between font-semibold">
<span>{t("subtotal")}</span>
<span style={{ color: primary }}>
{formatCurrency(totalPrice, "fa-IR")}
</span>
</div>
<Button
className="w-full"
disabled={submitting}
style={{ backgroundColor: primary }}
onClick={() => void submitOrder()}
>
{submitting ? t("loading") : t("placeOrder")}
</Button>
</div>
</div>
);
}
const menuTexture = normalizeMenuTexture(menuTheme?.menuTexture);
const textureShell = qrMenuTextureShellProps(menuTexture, themeColors.background);
return (
<div
className="mx-auto flex min-h-svh max-w-md flex-col"
dir="rtl"
data-qr-guest-menu
data-qr-texture={textureShell["data-qr-texture"]}
style={{
...textureShell.style,
...buildQrThemeCssVars(themeColors),
}}
>
<header
className="border-b qr-border px-4 py-5 text-center qr-surface"
>
{branch?.logoUrl ? (
<img
src={resolveMediaUrl(branch.logoUrl)}
alt={branch.cafeName}
className="mx-auto mb-2 size-14 rounded-full object-cover"
/>
) : null}
<h1 className="text-lg font-bold qr-text">{branch?.cafeName}</h1>
<p className="text-sm qr-muted">{branch?.branchName}</p>
<p className="mt-1 text-xs qr-muted">
{branch?.welcomeText} {t("tableLabel")} {branch?.tableNumber}
</p>
</header>
<div
className={cn(
"min-h-0 flex-1 overflow-auto",
totalItems > 0 ? "pb-[8.5rem]" : "pb-20"
)}
>
<QrGuestMenuBody
showCartBar={false}
menuStyle={menuStyle}
colors={themeColors}
categories={categories}
activeCategory={activeCategory}
onCategoryChange={setActiveCategory}
activeItems={activeItems}
showAllGrouped={showAllGrouped}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
isSearching={isSearching}
categoryNameById={categoryNameById}
cart={cart}
onAdd={addToCart}
onRemove={removeFromCart}
onView3d={setView3dItem}
totalItems={totalItems}
totalPrice={totalPrice}
onOpenCart={() => setScreen("cart")}
labels={{
emptyCategory: isSearching ? t("searchNoResults") : t("emptyCategory"),
addToCart: t("addToCart"),
checkout: t("placeOrder"),
searchPlaceholder: t("searchPlaceholder"),
allCategories: t("allCategories"),
clearSearch: t("clearSearch"),
view3d: t("view3d"),
}}
/>
</div>
{totalItems > 0 ? (
<div className="pointer-events-none fixed inset-x-0 bottom-[3.25rem] z-40 mx-auto max-w-md px-3 pb-1">
<div
className="pointer-events-auto rounded-2xl p-1 shadow-lg backdrop-blur-sm qr-surface"
style={{ backgroundColor: `color-mix(in srgb, ${themeColors.surface} 95%, transparent)` }}
>
<QrFloatingCartBar
totalItems={totalItems}
totalPrice={totalPrice}
colors={themeColors}
onOpenCart={() => setScreen("cart")}
labels={{
emptyCategory: "",
addToCart: t("addToCart"),
checkout: t("placeOrder"),
searchPlaceholder: "",
allCategories: "",
clearSearch: "",
view3d: "",
}}
/>
</div>
</div>
) : null}
{view3dItem ? (
<QrMenu3dSheet
item={view3dItem}
primary={primary}
onClose={() => setView3dItem(null)}
onAdd={() => addToCart(view3dItem)}
addLabel={t("addToCart")}
/>
) : null}
<QrBottomNav
screen={screen}
primary={primary}
onMenu={() => setScreen("menu")}
onOrders={() => {
refreshTableOrders();
setScreen("orders");
}}
callWaiterState={callWaiterState}
onCallWaiter={() => void handleCallWaiter()}
/>
</div>
);
}
function QrBottomNav({
screen,
primary,
onMenu,
onOrders,
callWaiterState,
onCallWaiter,
}: {
screen: Screen;
primary: string;
onMenu: () => void;
onOrders: () => void;
callWaiterState: "idle" | "sending" | "sent" | "cooldown";
onCallWaiter: () => void;
}) {
const t = useTranslations("qrMenu");
const callLabel =
callWaiterState === "sending"
? "..."
: callWaiterState === "sent"
? t("callWaiterSent")
: callWaiterState === "cooldown"
? t("callWaiterCooldown")
: t("callWaiter");
return (
<nav className="fixed inset-x-0 bottom-0 z-30 mx-auto flex max-w-md items-stretch border-t qr-border qr-surface">
<button
type="button"
className={cn(
"flex-1 py-3 text-sm font-medium",
screen === "menu" || screen === "cart" ? "qr-text" : "qr-muted"
)}
style={screen === "menu" || screen === "cart" ? { color: primary } : undefined}
onClick={onMenu}
>
{t("tabMenu")}
</button>
{/* Call waiter — centre prominent button */}
<div className="flex items-center justify-center px-2 py-1.5">
<button
type="button"
onClick={onCallWaiter}
disabled={callWaiterState !== "idle"}
className={cn(
"flex items-center gap-1.5 rounded-full px-4 py-2 text-xs font-semibold transition-all duration-200 shadow-md active:scale-95",
callWaiterState === "sent"
? "bg-emerald-500 text-white"
: callWaiterState === "cooldown"
? "bg-gray-200 text-gray-400 cursor-not-allowed"
: callWaiterState === "sending"
? "opacity-70 cursor-wait text-white"
: "text-white"
)}
style={
callWaiterState === "idle" || callWaiterState === "sending"
? { backgroundColor: primary }
: undefined
}
>
<span
className={cn(
"inline-block transition-transform",
callWaiterState === "sent" && "animate-bounce"
)}
>
🔔
</span>
<span className="max-w-[7rem] truncate">{callLabel}</span>
</button>
</div>
<button
type="button"
className={cn(
"flex-1 py-3 text-sm font-medium",
screen === "orders" || screen === "track" ? "qr-text" : "qr-muted"
)}
style={screen === "orders" || screen === "track" ? { color: primary } : undefined}
onClick={onOrders}
>
{t("tabOrders")}
</button>
</nav>
);
}
function effectiveLinePrice(item: QrPublicMenuItem): number {
const discount = item.discountPercent > 0 ? item.discountPercent : 0;
return Math.round(item.price * (1 - discount / 100));
}
function QtyButton({
label,
onClick,
variant,
color,
}: {
label: string;
onClick: () => void;
variant: "outline" | "filled";
color: string;
}) {
return (
<button
type="button"
onClick={onClick}
className={`flex size-8 items-center justify-center rounded-full text-lg leading-none ${
variant === "filled" ? "text-white" : ""
}`}
style={
variant === "filled"
? { backgroundColor: color }
: { border: `1.5px solid ${color}`, color }
}
>
{label}
</button>
);
}