131ecdbbe6
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>
881 lines
24 KiB
TypeScript
881 lines
24 KiB
TypeScript
"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));
|
||
}
|