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,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));
|
||||
}
|
||||
Reference in New Issue
Block a user