Files
meezi/web/dashboard/src/components/qr/qr-guest-menu-body.tsx
T

881 lines
24 KiB
TypeScript
Raw Normal View History

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