Files
meezi/web/dashboard/src/components/qr/qr-guest-menu-body.tsx
T
soroush.asadi 131ecdbbe6 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>
2026-05-27 21:34:12 +03:30

881 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
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 { 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));
}