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));
|
|||
|
|
}
|