Files
meezi/web/dashboard/src/components/qr/qr-guest-menu.tsx
T
soroush.asadi 7d06f149d3 feat(plans): menu watermark on Free (removed by paid feature)
Guest QR menu shows a "ساخته‌شده با میزی" watermark under the menu unless the café's
plan has the `watermark_removed` feature (Starter+).

- PublicMenuDto gains ShowWatermark; PublicService computes it from
  IsFeatureEnabledForCafeAsync("watermark_removed") for both slug and branch menus.
- Guest menu renders the watermark footer when showWatermark.
- NoOpPlatformCatalogService test double (all features on) for the PublicService
  ctor; QrMenuTests updated.

86 tests pass; dashboard tsc clean.
2026-06-03 02:10:24 +03:30

751 lines
24 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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 [showWatermark, setShowWatermark] = useState(false);
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));
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<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 flex-col gap-2 border-b px-3 py-3 last:border-0"
>
<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>
</div>
<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"
/>
</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"),
}}
/>
{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}
</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>
);
}