feat(dashboard): Next.js 16 merchant panel with offline POS and PWA
Complete merchant dashboard upgrade:
Next.js 16 compatibility:
- Fix params/searchParams typed as Promise<{}> throughout App Router
- Replace middleware.ts with proxy.ts (Next.js 16 convention)
- Remove unused @ts-expect-error directives caught by stricter TS
- Cast dynamic next-intl t() keys to fix TranslateArgs type errors
Offline POS:
- IndexedDB queue (meezi_pos_offline) for orders created while offline
- Zustand sync store tracking queueCount, isSyncing, isOnline
- useOfflineSync hook: auto-syncs on reconnect/visibility-change
- SyncStatusIndicator chip in topbar (amber=offline, blue=syncing)
- submitOrderToApi falls back to local order on network failure
- Local orders skip payment flow; sync on reconnect
PWA (installable):
- @ducanh2912/next-pwa with Workbox runtime caching rules
- Web App Manifest (manifest.ts) — RTL/Farsi, theme #0F6E56
- PWA icons: 192px, 512px, maskable 512px
- next.config.ts replaces next.config.mjs
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,723 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user