2026-05-27 21:34:12 +03:30
|
|
|
|
"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);
|
2026-06-03 02:10:24 +03:30
|
|
|
|
const [showWatermark, setShowWatermark] = useState(false);
|
2026-05-27 21:34:12 +03:30
|
|
|
|
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));
|
2026-06-03 02:10:24 +03:30
|
|
|
|
setShowWatermark(menu.showWatermark ?? false);
|
2026-05-27 21:34:12 +03:30
|
|
|
|
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}
|
2026-06-02 09:37:59 +03:30
|
|
|
|
className="flex flex-col gap-2 border-b px-3 py-3 last:border-0"
|
2026-05-27 21:34:12 +03:30
|
|
|
|
>
|
2026-06-02 09:37:59 +03:30
|
|
|
|
<div className="flex items-center justify-between gap-3">
|
|
|
|
|
|
<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>
|
2026-05-27 21:34:12 +03:30
|
|
|
|
</div>
|
2026-06-02 09:37:59 +03:30
|
|
|
|
<input
|
|
|
|
|
|
type="text"
|
|
|
|
|
|
value={c.note ?? ""}
|
|
|
|
|
|
onChange={(e) =>
|
|
|
|
|
|
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"
|
|
|
|
|
|
/>
|
2026-05-27 21:34:12 +03:30
|
|
|
|
</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"),
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
2026-06-03 02:10:24 +03:30
|
|
|
|
{showWatermark ? (
|
|
|
|
|
|
<a
|
|
|
|
|
|
href="https://meezi.ir"
|
|
|
|
|
|
target="_blank"
|
|
|
|
|
|
rel="noopener noreferrer"
|
|
|
|
|
|
className="flex items-center justify-center gap-1 py-5 text-xs qr-muted opacity-70"
|
|
|
|
|
|
>
|
|
|
|
|
|
ساختهشده با <span className="font-bold">میزی</span>
|
|
|
|
|
|
</a>
|
|
|
|
|
|
) : null}
|
2026-05-27 21:34:12 +03:30
|
|
|
|
</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>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|