"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("loading"); const [error, setError] = useState(""); const [branch, setBranch] = useState(null); const [categories, setCategories] = useState< Awaited>["categories"] >([]); const [activeCategory, setActiveCategory] = useState(""); const [cart, setCart] = useState([]); 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([]); const [submitting, setSubmitting] = useState(false); const [menuTheme, setMenuTheme] = useState(null); const [showWatermark, setShowWatermark] = useState(false); const [searchQuery, setSearchQuery] = useState(""); const [view3dItem, setView3dItem] = useState(null); const [security, setSecurity] = useState(null); const [captchaToken, setCaptchaToken] = useState(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)); setShowWatermark(menu.showWatermark ?? false); 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(); 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 (

{t("loading")}

); } if (screen === "error") { return (

😕

{error}

{t("scanAgain")}

); } if (screen === "track" && activeTrack) { return (
setScreen("menu")} /> setScreen("menu")} onOrders={() => { refreshTableOrders(); setScreen("orders"); }} callWaiterState={callWaiterState} onCallWaiter={() => void handleCallWaiter()} />
); } if (screen === "orders" && branch) { return (

{t("myOrders")}

{tableOrders.length === 0 ? (

{t("noOrders")}

) : (
{tableOrders.map((o) => ( ))}
)}
setScreen("menu")} onOrders={() => setScreen("orders")} callWaiterState={callWaiterState} onCallWaiter={() => void handleCallWaiter()} />
); } if (screen === "cart") { return (

{t("cartTitle")}

{cart.map((c) => (

{formatCurrency(effectiveLinePrice(c.item), "fa-IR")}

removeFromCart(c.item.id)} variant="outline" color={primary} /> {c.qty} addToCart(c.item)} variant="filled" color={primary} />
setCart((prev) => prev.map((l) => l.item.id === c.item.id ? { ...l, note: e.target.value } : l ) ) } placeholder={t("itemNote")} className="w-full rounded-md border qr-border bg-transparent px-2 py-1.5 text-xs placeholder:opacity-60 focus:outline-none" />
))}
setGuestName(e.target.value)} placeholder={t("guestName")} className="text-end" /> setGuestPhone(e.target.value)} placeholder={t("guestPhone")} inputMode="tel" className="text-end" />
{captchaRequired && security?.turnstileSiteKey ? (
{ setCaptchaToken(token); if (error === t("captchaRequired")) setError(""); }} onExpire={() => setCaptchaToken(null)} />
) : null} {error ? (

{error}

) : null}
{t("subtotal")} {formatCurrency(totalPrice, "fa-IR")}
); } const menuTexture = normalizeMenuTexture(menuTheme?.menuTexture); const textureShell = qrMenuTextureShellProps(menuTexture, themeColors.background); return (
{branch?.logoUrl ? ( {branch.cafeName} ) : null}

{branch?.cafeName}

{branch?.branchName}

{branch?.welcomeText} — {t("tableLabel")} {branch?.tableNumber}

0 ? "pb-[8.5rem]" : "pb-20" )} > 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"), }} /> {showWatermark ? ( ساخته‌شده با میزی ) : null}
{totalItems > 0 ? (
setScreen("cart")} labels={{ emptyCategory: "", addToCart: t("addToCart"), checkout: t("placeOrder"), searchPlaceholder: "", allCategories: "", clearSearch: "", view3d: "", }} />
) : null} {view3dItem ? ( setView3dItem(null)} onAdd={() => addToCart(view3dItem)} addLabel={t("addToCart")} /> ) : null} setScreen("menu")} onOrders={() => { refreshTableOrders(); setScreen("orders"); }} callWaiterState={callWaiterState} onCallWaiter={() => void handleCallWaiter()} />
); } 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 ( ); } 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 ( ); }