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:
soroush.asadi
2026-05-27 21:34:12 +03:30
parent ef15fd6247
commit 131ecdbbe6
208 changed files with 37123 additions and 0 deletions
@@ -0,0 +1,880 @@
"use client";
import { Search, ShoppingBag, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { QR_ALL_CATEGORY_ID } from "@/lib/qr-menu-constants";
import { MenuItemLabels } from "@/components/menu/menu-item-labels";
import { CategoryVisual } from "@/components/menu/category-visual";
import { formatCurrency } from "@/lib/format";
import { resolveMediaUrl } from "@/lib/api/client";
import { cn } from "@/lib/utils";
import type { QrCartLine, QrPublicMenuCategory, QrPublicMenuItem } from "@/lib/api/qr-public";
import type { CafeThemePalette } from "@/lib/cafe-theme";
import { hasMenu3dView } from "@/lib/menu-3d";
import { Box } from "lucide-react";
export type QrMenuBodyProps = {
menuStyle: string;
colors: CafeThemePalette;
categories: QrPublicMenuCategory[];
activeCategory: string;
onCategoryChange: (id: string) => void;
activeItems: QrPublicMenuItem[];
showAllGrouped?: boolean;
searchQuery: string;
onSearchChange: (value: string) => void;
isSearching?: boolean;
categoryNameById?: Map<string, string>;
cart: QrCartLine[];
onAdd: (item: QrPublicMenuItem) => void;
onRemove: (itemId: string) => void;
onView3d?: (item: QrPublicMenuItem) => void;
totalItems: number;
totalPrice: number;
onOpenCart: () => void;
labels: {
emptyCategory: string;
addToCart: string;
checkout: string;
searchPlaceholder: string;
allCategories: string;
clearSearch: string;
view3d: string;
};
};
export function QrGuestMenuBody({
showCartBar = true,
...props
}: QrMenuBodyProps & { showCartBar?: boolean }) {
const { colors } = props;
const primary = colors.primary;
const surface = colors.surface;
const style = props.menuStyle || "cards";
const listProps = {
...props,
primary,
surface,
colors,
showCategoryLabel: props.isSearching ?? false,
categoryNameById: props.categoryNameById,
};
return (
<div className="min-h-full">
<MenuSearchBar {...props} primary={primary} surface={surface} />
<CategoryTabs {...props} primary={primary} surface={surface} colors={colors} />
{props.showAllGrouped ? (
<GroupedAllSections {...listProps} />
) : style === "grid" ? (
<GridItems {...listProps} />
) : style === "list" ? (
<ListItems {...listProps} compact={false} />
) : style === "compact" ? (
<ListItems {...listProps} compact />
) : style === "magazine" ? (
<MagazineItems {...listProps} />
) : style === "classic" ? (
<ClassicLayout {...props} surface={surface} />
) : (
<CardItems {...listProps} />
)}
{showCartBar ? <CartBar {...props} floating={false} /> : null}
</div>
);
}
function MenuSearchBar({
searchQuery,
onSearchChange,
primary,
surface,
labels,
}: Pick<QrMenuBodyProps, "searchQuery" | "onSearchChange" | "labels"> & {
surface: string;
primary: string;
}) {
return (
<div
className="sticky top-0 z-20 px-3 pb-2 pt-2.5"
style={{ backgroundColor: surface }}
>
<div className="relative">
<Search
className="pointer-events-none absolute top-1/2 size-4 -translate-y-1/2 qr-icon start-3"
aria-hidden
/>
<Input
type="search"
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
placeholder={labels.searchPlaceholder}
className="h-10 rounded-xl qr-border qr-surface ps-9 pe-9 text-sm qr-text"
style={{ borderColor: `${primary}33` }}
/>
{searchQuery ? (
<button
type="button"
className="absolute top-1/2 flex size-8 -translate-y-1/2 items-center justify-center rounded-full qr-muted qr-fill-muted end-1"
onClick={() => onSearchChange("")}
aria-label={labels.clearSearch}
>
<X className="size-4 qr-icon" />
</button>
) : null}
</div>
</div>
);
}
function CategoryTabs({
categories,
activeCategory,
onCategoryChange,
primary,
surface,
colors,
labels,
}: Pick<QrMenuBodyProps, "categories" | "activeCategory" | "onCategoryChange" | "labels" | "colors"> & {
surface: string;
primary: string;
}) {
return (
<div
className="sticky top-[3.25rem] z-10 flex gap-2 overflow-x-auto border-b qr-border px-3 py-2.5 shadow-sm"
style={{ backgroundColor: surface }}
>
<button
type="button"
onClick={() => onCategoryChange(QR_ALL_CATEGORY_ID)}
className={cn(
"flex shrink-0 items-center gap-1.5 rounded-full border px-3 py-1.5 text-xs font-medium transition active:scale-[0.98]",
activeCategory === QR_ALL_CATEGORY_ID ? "text-white" : "qr-border qr-text"
)}
style={
activeCategory === QR_ALL_CATEGORY_ID
? { backgroundColor: primary, borderColor: primary }
: { backgroundColor: "transparent", color: colors.text }
}
>
{labels.allCategories}
</button>
{categories.map((cat) => (
<button
key={cat.id}
type="button"
onClick={() => onCategoryChange(cat.id)}
className={cn(
"flex shrink-0 items-center gap-1.5 rounded-full border px-3 py-1.5 text-xs font-medium transition active:scale-[0.98]",
activeCategory === cat.id ? "text-white" : "qr-border qr-text"
)}
style={
activeCategory === cat.id
? { backgroundColor: primary, borderColor: primary }
: { backgroundColor: "transparent", color: colors.text }
}
>
<CategoryVisual
icon={cat.icon}
iconPresetId={cat.iconPresetId}
iconStyle={cat.iconStyle}
imageUrl={cat.imageUrl}
size="xs"
brandColors={colors}
/>
{cat.name}
</button>
))}
</div>
);
}
type ItemListExtras = {
surface: string;
primary: string;
colors: CafeThemePalette;
showCategoryLabel?: boolean;
categoryNameById?: Map<string, string>;
};
function GroupedAllSections(
props: Pick<
QrMenuBodyProps,
"categories" | "cart" | "onAdd" | "onRemove" | "onView3d" | "labels"
> &
ItemListExtras
) {
const { categories, labels, surface, primary, colors, onView3d } = props;
const hasAny = categories.some((c) => (c.items?.length ?? 0) > 0);
if (!hasAny) return <EmptyCategory text={labels.emptyCategory} />;
return (
<div className="space-y-4 p-3 pb-4">
{categories.map((cat) => {
const items = cat.items ?? [];
if (items.length === 0) return null;
return (
<section key={cat.id}>
<p className="mb-2 px-1 text-[11px] font-medium uppercase tracking-[0.06em] qr-muted">
{cat.name}
</p>
<div className="space-y-2">
{items.map((item) => (
<ItemRowCard
key={item.id}
item={item}
cart={props.cart}
primary={primary}
surface={surface}
colors={colors}
onAdd={props.onAdd}
onRemove={props.onRemove}
onView3d={props.onView3d}
addLabel={labels.addToCart}
view3dLabel={labels.view3d}
/>
))}
</div>
</section>
);
})}
</div>
);
}
function CardItems({
activeItems,
cart,
onAdd,
onRemove,
onView3d,
primary,
labels,
surface,
colors,
showCategoryLabel,
categoryNameById,
}: Pick<
QrMenuBodyProps,
"activeItems" | "cart" | "onAdd" | "onRemove" | "onView3d" | "labels"
> &
ItemListExtras) {
return (
<div className="space-y-2 p-3 pb-4">
{activeItems.length === 0 ? (
<EmptyCategory text={labels.emptyCategory} />
) : (
activeItems.map((item) => (
<ItemRowCard
key={item.id}
item={item}
cart={cart}
primary={primary}
surface={surface}
colors={colors}
onAdd={onAdd}
onRemove={onRemove}
onView3d={onView3d}
addLabel={labels.addToCart}
view3dLabel={labels.view3d}
categoryLabel={
showCategoryLabel
? categoryNameById?.get(item.categoryId)
: undefined
}
/>
))
)}
</div>
);
}
function GridItems({
activeItems,
cart,
onAdd,
onRemove,
onView3d,
primary,
labels,
surface,
colors,
showCategoryLabel,
categoryNameById,
}: Pick<
QrMenuBodyProps,
"activeItems" | "cart" | "onAdd" | "onRemove" | "onView3d" | "labels"
> &
ItemListExtras) {
if (activeItems.length === 0) return <EmptyCategory text={labels.emptyCategory} />;
return (
<div className="grid grid-cols-2 gap-2.5 p-3 pb-4">
{activeItems.map((item) => (
<GridCard
key={item.id}
item={item}
cart={cart}
primary={primary}
surface={surface}
colors={colors}
onAdd={onAdd}
onRemove={onRemove}
onView3d={onView3d}
addLabel={labels.addToCart}
view3dLabel={labels.view3d}
categoryLabel={
showCategoryLabel ? categoryNameById?.get(item.categoryId) : undefined
}
/>
))}
</div>
);
}
function ListItems({
activeItems,
cart,
onAdd,
onRemove,
onView3d,
primary,
labels,
surface,
colors,
compact,
showCategoryLabel,
categoryNameById,
}: Pick<
QrMenuBodyProps,
"activeItems" | "cart" | "onAdd" | "onRemove" | "onView3d" | "labels"
> &
ItemListExtras & { compact: boolean }) {
return (
<div className="space-y-2 p-3 pb-4">
{activeItems.length === 0 ? (
<EmptyCategory text={labels.emptyCategory} />
) : (
activeItems.map((item) => (
<div
key={item.id}
className={cn(
"flex items-center gap-2 rounded-lg border border-border/60 px-2 shadow-sm",
compact ? "py-1.5" : "py-2.5"
)}
style={{ backgroundColor: surface }}
>
<div className="relative shrink-0">
{resolveMediaUrl(item.imageUrl) ? (
<img
src={resolveMediaUrl(item.imageUrl)}
alt=""
className={cn(
"rounded-md object-cover",
compact ? "size-10" : "size-12"
)}
/>
) : (
<div
className={cn("rounded-md qr-fill-muted", compact ? "size-10" : "size-12")}
/>
)}
{hasMenu3dView(item) && onView3d ? (
<View3dChip
label={labels.view3d}
onClick={() => onView3d(item)}
className="absolute -bottom-1 end-0 scale-90"
/>
) : null}
</div>
<div className="min-w-0 flex-1">
{showCategoryLabel && categoryNameById?.get(item.categoryId) ? (
<p className="mb-0.5 text-[10px] qr-muted">
{categoryNameById.get(item.categoryId)}
</p>
) : null}
<MenuItemLabels item={item} lines={1} primaryClassName="text-sm font-medium" />
<p className="text-xs font-semibold" style={{ color: primary }}>
{formatCurrency(effectivePrice(item), "fa-IR")}
</p>
</div>
<QtyControls
item={item}
cart={cart}
primary={primary}
onAdd={onAdd}
onRemove={onRemove}
addLabel={labels.addToCart}
small
/>
</div>
))
)}
</div>
);
}
function MagazineItems({
activeItems,
cart,
onAdd,
onRemove,
onView3d,
primary,
labels,
surface,
colors,
showCategoryLabel,
categoryNameById,
}: Pick<
QrMenuBodyProps,
"activeItems" | "cart" | "onAdd" | "onRemove" | "onView3d" | "labels"
> &
ItemListExtras) {
return (
<div className="space-y-3 p-3 pb-4">
{activeItems.length === 0 ? (
<EmptyCategory text={labels.emptyCategory} />
) : (
activeItems.map((item) => {
const img = resolveMediaUrl(item.imageUrl);
return (
<article
key={item.id}
className="overflow-hidden rounded-xl border border-border/80 shadow-sm"
style={{ backgroundColor: surface }}
>
<div className="relative">
{img ? (
<img src={img} alt="" className="aspect-[16/9] w-full object-cover" />
) : (
<div className="aspect-[16/9] w-full qr-fill-muted" />
)}
{hasMenu3dView(item) && onView3d ? (
<View3dChip
label={labels.view3d}
onClick={() => onView3d(item)}
className="absolute bottom-3 start-3"
/>
) : null}
</div>
<div className="p-3">
{showCategoryLabel && categoryNameById?.get(item.categoryId) ? (
<p className="mb-1 text-[10px] qr-muted">
{categoryNameById.get(item.categoryId)}
</p>
) : null}
<MenuItemLabels item={item} lines={2} primaryClassName="text-base font-semibold" />
<div className="mt-2 flex items-center justify-between">
<span className="font-bold" style={{ color: primary }}>
{formatCurrency(effectivePrice(item), "fa-IR")}
</span>
<QtyControls
item={item}
cart={cart}
primary={primary}
onAdd={onAdd}
onRemove={onRemove}
addLabel={labels.addToCart}
/>
</div>
</div>
</article>
);
})
)}
</div>
);
}
function ClassicLayout({
categories,
activeCategory,
onCategoryChange,
activeItems,
showAllGrouped,
cart,
onAdd,
onRemove,
onView3d,
colors,
labels,
surface,
}: QrMenuBodyProps & { surface: string }) {
const primary = colors.primary;
return (
<div className="flex min-h-[50vh]">
<aside
className="w-[4.5rem] shrink-0 space-y-2 border-e border-border/60 py-3"
style={{ backgroundColor: surface }}
>
<button
type="button"
onClick={() => onCategoryChange(QR_ALL_CATEGORY_ID)}
className={cn(
"mx-auto flex w-14 flex-col items-center gap-1 rounded-lg py-2 text-[10px] font-medium transition",
activeCategory === QR_ALL_CATEGORY_ID ? "text-white" : "qr-text"
)}
style={
activeCategory === QR_ALL_CATEGORY_ID
? { backgroundColor: primary }
: { color: colors.text }
}
>
<span className="text-base"></span>
<span className="line-clamp-2 text-center leading-tight">{labels.allCategories}</span>
</button>
{categories.map((cat) => (
<button
key={cat.id}
type="button"
onClick={() => onCategoryChange(cat.id)}
className={cn(
"mx-auto flex w-14 flex-col items-center gap-1 rounded-lg py-2 text-[10px] font-medium transition",
activeCategory === cat.id ? "text-white" : "qr-text"
)}
style={
activeCategory === cat.id
? { backgroundColor: primary }
: { color: colors.text }
}
>
<CategoryVisual
icon={cat.icon}
iconPresetId={cat.iconPresetId}
iconStyle={cat.iconStyle}
imageUrl={cat.imageUrl}
size="sm"
brandColors={colors}
/>
<span className="line-clamp-2 text-center leading-tight">{cat.name}</span>
</button>
))}
</aside>
<div className="min-w-0 flex-1">
{showAllGrouped ? (
<GroupedAllSections
categories={categories}
cart={cart}
onAdd={onAdd}
onRemove={onRemove}
onView3d={onView3d}
primary={primary}
labels={labels}
surface={surface}
colors={colors}
/>
) : (
<CardItems
activeItems={activeItems}
cart={cart}
onAdd={onAdd}
onRemove={onRemove}
onView3d={onView3d}
primary={primary}
labels={labels}
surface={surface}
colors={colors}
/>
)}
</div>
</div>
);
}
function ItemRowCard({
item,
cart,
primary,
surface,
colors,
onAdd,
onRemove,
onView3d,
addLabel,
view3dLabel,
categoryLabel,
}: {
item: QrPublicMenuItem;
cart: QrCartLine[];
primary: string;
surface: string;
colors: CafeThemePalette;
onAdd: (item: QrPublicMenuItem) => void;
onRemove: (itemId: string) => void;
onView3d?: (item: QrPublicMenuItem) => void;
addLabel: string;
view3dLabel: string;
categoryLabel?: string;
}) {
const img = resolveMediaUrl(item.imageUrl);
return (
<div
className="flex gap-3 rounded-xl border border-border/70 p-3 shadow-sm"
style={{ backgroundColor: surface }}
>
<div className="relative shrink-0">
{img ? (
<img src={img} alt="" className="size-[4.5rem] rounded-lg object-cover" />
) : (
<div className="size-[4.5rem] rounded-lg qr-fill-muted" />
)}
{hasMenu3dView(item) && onView3d ? (
<View3dChip
label={view3dLabel}
onClick={() => onView3d(item)}
className="absolute bottom-1 end-1"
/>
) : null}
</div>
<div className="min-w-0 flex-1">
{categoryLabel ? (
<p className="mb-0.5 text-[10px] font-medium qr-muted">{categoryLabel}</p>
) : null}
<div className="qr-text">
<MenuItemLabels item={item} lines={2} primaryClassName="text-sm font-semibold" />
</div>
{item.description ? (
<p className="mt-0.5 line-clamp-2 text-[11px] qr-muted">
{item.description}
</p>
) : null}
<div className="mt-2 flex items-center justify-between gap-2">
<span className="text-sm font-semibold" style={{ color: primary }}>
{formatCurrency(effectivePrice(item), "fa-IR")}
</span>
<QtyControls
item={item}
cart={cart}
primary={primary}
onAdd={onAdd}
onRemove={onRemove}
addLabel={addLabel}
/>
</div>
</div>
</div>
);
}
function GridCard({
item,
cart,
primary,
surface,
colors,
onAdd,
onRemove,
onView3d,
addLabel,
view3dLabel,
categoryLabel,
}: {
item: QrPublicMenuItem;
cart: QrCartLine[];
primary: string;
surface: string;
colors: CafeThemePalette;
onAdd: (item: QrPublicMenuItem) => void;
onRemove: (itemId: string) => void;
onView3d?: (item: QrPublicMenuItem) => void;
addLabel: string;
view3dLabel: string;
categoryLabel?: string;
}) {
const img = resolveMediaUrl(item.imageUrl);
return (
<article
className="flex flex-col overflow-hidden rounded-xl border border-border/80 shadow-sm"
style={{ backgroundColor: surface }}
>
<div className="relative">
{img ? (
<img src={img} alt="" className="aspect-square w-full object-cover" />
) : (
<div className="aspect-square w-full qr-fill-muted" />
)}
{hasMenu3dView(item) && onView3d ? (
<View3dChip label={view3dLabel} onClick={() => onView3d(item)} className="absolute bottom-2 start-2" />
) : null}
</div>
<div className="flex flex-1 flex-col p-2">
{categoryLabel ? (
<p className="mb-0.5 text-[10px] qr-muted">{categoryLabel}</p>
) : null}
<div className="qr-text">
<MenuItemLabels item={item} lines={2} primaryClassName="text-xs font-semibold" />
</div>
<p className="mt-1 text-xs font-bold" style={{ color: primary }}>
{formatCurrency(effectivePrice(item), "fa-IR")}
</p>
<div className="mt-auto pt-2">
<QtyControls
item={item}
cart={cart}
primary={primary}
onAdd={onAdd}
onRemove={onRemove}
addLabel={addLabel}
small
/>
</div>
</div>
</article>
);
}
function QtyControls({
item,
cart,
primary,
onAdd,
onRemove,
addLabel,
small,
}: {
item: QrPublicMenuItem;
cart: QrCartLine[];
primary: string;
onAdd: (item: QrPublicMenuItem) => void;
onRemove: (itemId: string) => void;
addLabel: string;
small?: boolean;
}) {
const inCart = cart.find((c) => c.item.id === item.id);
const size = small ? "size-7 text-base" : "size-8 text-lg";
if (inCart) {
return (
<div className="flex items-center gap-1.5">
<QtyBtn label="" className={size} variant="outline" color={primary} onClick={() => onRemove(item.id)} />
<span className="min-w-5 text-center text-sm font-bold">{inCart.qty}</span>
<QtyBtn label="+" className={size} variant="filled" color={primary} onClick={() => onAdd(item)} />
</div>
);
}
return (
<Button
size="sm"
className="h-8 rounded-full px-3 text-xs"
style={{ backgroundColor: primary }}
onClick={() => onAdd(item)}
>
{addLabel}
</Button>
);
}
function QtyBtn({
label,
className,
variant,
color,
onClick,
}: {
label: string;
className: string;
variant: "outline" | "filled";
color: string;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
"flex items-center justify-center rounded-full leading-none",
className,
variant === "filled" ? "text-white" : ""
)}
style={
variant === "filled"
? { backgroundColor: color }
: { border: `1.5px solid ${color}`, color }
}
>
{label}
</button>
);
}
function View3dChip({
label,
onClick,
className,
}: {
label: string;
onClick: () => void;
className?: string;
}) {
return (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onClick();
}}
className={cn(
"flex items-center gap-1 rounded-md bg-black/70 px-2 py-1 text-[10px] font-medium text-white shadow-sm backdrop-blur-sm transition active:scale-[0.98]",
className
)}
>
<Box className="h-3 w-3 shrink-0" aria-hidden />
{label}
</button>
);
}
export function QrFloatingCartBar(
props: Pick<QrMenuBodyProps, "totalItems" | "totalPrice" | "colors" | "onOpenCart" | "labels">
) {
return <CartBar {...props} floating />;
}
function CartBar({
totalItems,
totalPrice,
colors,
onOpenCart,
labels,
floating = true,
}: Pick<QrMenuBodyProps, "totalItems" | "totalPrice" | "colors" | "onOpenCart" | "labels"> & {
floating?: boolean;
}) {
const primary = colors.primary;
if (totalItems <= 0) return null;
return (
<div
className={cn(
floating
? "shadow-lg"
: "sticky bottom-0 z-20 border-t border-border/60 qr-surface/95 backdrop-blur"
)}
>
<Button
className="flex h-12 w-full items-center justify-between gap-3 rounded-2xl px-4 shadow-md"
style={{ backgroundColor: primary }}
onClick={onOpenCart}
>
<span className="flex size-7 items-center justify-center rounded-full bg-white/25 text-sm font-bold">
{totalItems.toLocaleString("fa-IR")}
</span>
<span className="flex items-center gap-2 font-semibold">
<ShoppingBag className="size-4 shrink-0 text-white" aria-hidden />
{labels.checkout}
</span>
<span className="text-sm font-bold">{formatCurrency(totalPrice, "fa-IR")}</span>
</Button>
</div>
);
}
function EmptyCategory({ text }: { text: string }) {
return <p className="p-8 text-center text-sm qr-muted">{text}</p>;
}
function effectivePrice(item: QrPublicMenuItem): number {
const discount = item.discountPercent > 0 ? item.discountPercent : 0;
return Math.round(item.price * (1 - discount / 100));
}
@@ -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>
);
}
@@ -0,0 +1,73 @@
"use client";
import { X } from "lucide-react";
import { useTranslations } from "next-intl";
import { MenuItemModelViewer } from "@/components/menu/menu-item-model-viewer";
import { MenuItemLabels } from "@/components/menu/menu-item-labels";
import { formatCurrency } from "@/lib/format";
import type { QrPublicMenuItem } from "@/lib/api/qr-public";
import { Button } from "@/components/ui/button";
type QrMenu3dSheetProps = {
item: QrPublicMenuItem;
primary: string;
onClose: () => void;
onAdd: () => void;
addLabel: string;
};
export function QrMenu3dSheet({ item, primary, onClose, onAdd, addLabel }: QrMenu3dSheetProps) {
const t = useTranslations("qrMenu");
if (!item.model3dUrl) return null;
return (
<div
className="fixed inset-0 z-50 flex flex-col bg-black/50 backdrop-blur-sm"
role="dialog"
aria-modal="true"
aria-label={t("view3d")}
>
<div className="mx-auto mt-auto flex w-full max-w-md flex-col rounded-t-2xl qr-surface shadow-2xl">
<div className="flex items-center justify-between border-b px-4 py-3">
<div className="min-w-0 flex-1">
<MenuItemLabels item={item} lines={1} primaryClassName="text-base font-semibold" />
<p className="text-sm font-medium" style={{ color: primary }}>
{formatCurrency(effectiveItemPrice(item), "fa-IR")}
</p>
</div>
<Button type="button" size="icon" variant="ghost" onClick={onClose} aria-label={t("close3d")}>
<X className="h-5 w-5" />
</Button>
</div>
<p className="px-4 pt-2 text-center text-xs qr-muted">{t("view3dHint")}</p>
<div className="min-h-[50vh] w-full px-2 pb-2">
<MenuItemModelViewer
modelUrl={item.model3dUrl}
posterUrl={item.imageUrl}
alt={item.name}
className="rounded-xl"
/>
</div>
<div className="border-t p-4">
<Button
type="button"
className="w-full text-white"
style={{ backgroundColor: primary }}
onClick={() => {
onAdd();
onClose();
}}
>
{addLabel}
</Button>
</div>
</div>
</div>
);
}
function effectiveItemPrice(item: QrPublicMenuItem): number {
const discount = item.discountPercent > 0 ? item.discountPercent : 0;
return Math.round(item.price * (1 - discount / 100));
}
@@ -0,0 +1,141 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { useTranslations } from "next-intl";
import * as signalR from "@microsoft/signalr";
import { Check } from "lucide-react";
import { fetchOrderTrack, type QrOrderTrack } from "@/lib/api/qr-public";
import { cn } from "@/lib/utils";
import { formatCurrency } from "@/lib/format";
import { Button } from "@/components/ui/button";
type QrOrderTrackProps = {
orderId: string;
trackingToken: string;
primary: string;
onBack?: () => void;
};
export function QrOrderTrack({ orderId, trackingToken, primary, onBack }: QrOrderTrackProps) {
const t = useTranslations("qrMenu.tracking");
const [track, setTrack] = useState<QrOrderTrack | null>(null);
const [error, setError] = useState(false);
const load = useCallback(async () => {
try {
const data = await fetchOrderTrack(orderId, trackingToken);
setTrack(data);
setError(false);
} catch {
setError(true);
}
}, [orderId, trackingToken]);
useEffect(() => {
void load();
const id = setInterval(() => void load(), 8000);
return () => clearInterval(id);
}, [load]);
useEffect(() => {
const baseUrl = process.env.NEXT_PUBLIC_API_URL ?? "https://localhost:7208";
const connection = new signalR.HubConnectionBuilder()
.withUrl(`${baseUrl}/hubs/guest-order`)
.withAutomaticReconnect()
.build();
connection
.start()
.then(() => connection.invoke("JoinOrder", orderId, trackingToken))
.catch(() => undefined);
connection.on("OrderTrackUpdated", (payload: QrOrderTrack) => {
setTrack(payload);
});
return () => {
void connection.stop();
};
}, [orderId, trackingToken]);
if (error) {
return (
<p className="p-6 text-center text-sm qr-muted">{t("loadError")}</p>
);
}
if (!track) {
return (
<div className="flex justify-center p-8">
<div
className="size-8 animate-spin rounded-full border-2 border-t-transparent"
style={{ borderColor: primary, borderTopColor: "transparent" }}
/>
</div>
);
}
const statusKey = track.statusLabelKey;
return (
<div className="space-y-4 p-4">
{onBack ? (
<Button variant="ghost" size="sm" onClick={onBack}>
{t("back")}
</Button>
) : null}
<div className="rounded-xl border qr-border qr-surface p-4 text-center">
<p className="text-[11px] uppercase tracking-[0.06em] qr-muted">
{t("orderNumber")}
</p>
<p className="text-lg font-bold qr-text">{track.orderNumber}</p>
<p className="mt-2 text-sm font-medium" style={{ color: primary }}>
{t(`status.${statusKey}`)}
</p>
<p className="mt-1 text-xs qr-muted">
{formatCurrency(track.total, "fa-IR")}
{track.tableNumber ? ` · ${t("table")} ${track.tableNumber}` : ""}
</p>
</div>
<ol className="space-y-0 rounded-xl border qr-border qr-surface p-4">
{track.steps.map((step) => (
<li key={step.key} className="flex gap-3 pb-4 last:pb-0">
<div
className={cn(
"flex size-8 shrink-0 items-center justify-center rounded-full border-2",
step.isComplete ? "border-transparent text-white" : "qr-border qr-fill-muted"
)}
style={step.isComplete ? { backgroundColor: primary } : undefined}
>
{step.isComplete ? <Check className="size-4" /> : null}
</div>
<div className="min-w-0 pt-1">
<p
className={cn(
"text-sm font-medium",
step.isCurrent && "qr-text",
!step.isCurrent && !step.isComplete && "qr-muted"
)}
>
{t(`steps.${step.labelKey}`)}
</p>
{step.isCurrent ? (
<p className="text-xs qr-muted">{t("currentStep")}</p>
) : null}
</div>
</li>
))}
</ol>
{statusKey === "ready" ? (
<p
className="rounded-lg px-3 py-2 text-center text-sm font-medium"
style={{ backgroundColor: `color-mix(in srgb, ${primary} 12%, #fff)`, color: primary }}
>
{t("readyHint")}
</p>
) : null}
</div>
);
}
@@ -0,0 +1,94 @@
"use client";
import { useEffect, useRef } from "react";
type TurnstileApi = {
render: (
container: HTMLElement,
options: {
sitekey: string;
callback: (token: string) => void;
"expired-callback"?: () => void;
"error-callback"?: () => void;
theme?: "light" | "dark" | "auto";
}
) => string;
remove: (widgetId: string) => void;
};
declare global {
interface Window {
turnstile?: TurnstileApi;
}
}
const SCRIPT_ID = "cf-turnstile-script";
const SCRIPT_SRC = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";
type QrTurnstileProps = {
siteKey: string;
onToken: (token: string) => void;
onExpire?: () => void;
};
export function QrTurnstile({ siteKey, onToken, onExpire }: QrTurnstileProps) {
const containerRef = useRef<HTMLDivElement>(null);
const widgetIdRef = useRef<string | null>(null);
useEffect(() => {
const container = containerRef.current;
if (!container || !siteKey) return;
let cancelled = false;
const renderWidget = () => {
if (cancelled || !containerRef.current || !window.turnstile) return;
if (widgetIdRef.current) {
window.turnstile.remove(widgetIdRef.current);
widgetIdRef.current = null;
}
widgetIdRef.current = window.turnstile.render(containerRef.current, {
sitekey: siteKey,
theme: "auto",
callback: (token) => onToken(token),
"expired-callback": () => onExpire?.(),
"error-callback": () => onExpire?.(),
});
};
const ensureScript = () => {
if (window.turnstile) {
renderWidget();
return;
}
const existing = document.getElementById(SCRIPT_ID) as HTMLScriptElement | null;
if (existing) {
existing.addEventListener("load", renderWidget);
return () => existing.removeEventListener("load", renderWidget);
}
const script = document.createElement("script");
script.id = SCRIPT_ID;
script.src = SCRIPT_SRC;
script.async = true;
script.defer = true;
script.onload = renderWidget;
document.head.appendChild(script);
return () => {
script.onload = null;
};
};
const cleanupScript = ensureScript();
return () => {
cancelled = true;
cleanupScript?.();
if (widgetIdRef.current && window.turnstile) {
window.turnstile.remove(widgetIdRef.current);
widgetIdRef.current = null;
}
};
}, [siteKey, onToken, onExpire]);
return <div ref={containerRef} className="flex justify-center py-2" />;
}