Files
meezi/web/dashboard/src/components/menu/menu-admin-screen.tsx
T
soroush.asadi 024a455ab3 fix: menu item/category create, demo banner reach, token refresh, blog publish
Dashboard & API bug fixes for owner-reported breakage:

- MenuController validators (PosValidators): NameEn was required but the
  dashboard sends null when blank, so every manual menu-item create failed
  and category create failed 100% (the form never sends nameEn). Now optional.
- DemoDataBanner: only showed when a cafe was exactly empty, so
  showcase-seeded cafes (2-3 cats / 3-5 items) could never trigger the
  one-click seed. Widened gate to sparse menus (<5 cats && <10 items) and
  added a clear "nothing to add" message when already populated.
- client.ts: added one-time JWT refresh-and-retry on 401 (shared in-flight
  promise) before bouncing to /login. Expired access tokens silently broke
  ticket list, add-table, and other reads.
- Surface API errors as toasts on menu + table mutations (were swallowed
  silently, so failures looked like "nothing happens").
- Admin blog editor: saving an edit dropped IsPublished (defaulted false,
  silently unpublishing the post on every save); now persisted with a
  toggle. Also hoisted the inner Field component to module scope - it was
  remounting every input on each keystroke and dropping focus.
- Admin integrations: replaced raw radio gateway selector with a styled
  RadioDot matching the iOS toggles.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 18:25:34 +03:30

961 lines
37 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useMemo, useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useLocale, useTranslations } from "next-intl";
import { useIsRtl } from "@/lib/use-is-rtl";
import { Box, Pencil, Plus, Search, Video, X } from "lucide-react";
import { DemoDataBanner } from "@/components/demo/demo-data-banner";
import { Menu3dUpload } from "@/components/media/menu-3d-upload";
import { MenuAi3dGenerate } from "@/components/media/menu-ai-3d-generate";
import { CategoryVisual } from "@/components/menu/category-visual";
import { CategoryMediaFields } from "@/components/menu/category-media-fields";
import type { CategoryIconSelection } from "@/components/menu/category-preset-picker";
import { DEFAULT_CATEGORY_ICON_STYLE } from "@/lib/category-icon-presets";
import { ApiClientError, apiGet, apiPatch, apiPost } from "@/lib/api/client";
import { notify } from "@/lib/notify";
import { useAuthStore } from "@/lib/stores/auth.store";
import { useBranchStore } from "@/lib/stores/branch.store";
import { formatCurrency, formatNumber } from "@/lib/format";
import { PageHeader } from "@/components/layout/page-header";
import { MediaPairUpload } from "@/components/media/media-pair-upload";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { LabeledField } from "@/components/ui/labeled-field";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
import { MenuItemLabels } from "@/components/menu/menu-item-labels";
import { MenuItemMedia } from "@/components/menu/menu-item-media";
import { buildCategoryNameMap, inferMenuItemKind } from "@/lib/menu-item-image";
import { menuItemMatchesSearch } from "@/lib/menu-display";
import { BranchMenuOverrides } from "@/components/menu/branch-menu-overrides";
// ─── Types ────────────────────────────────────────────────────────────────────
interface MenuCategory {
id: string;
name: string;
nameEn?: string;
nameAr?: string;
sortOrder: number;
discountPercent: number;
icon?: string;
iconPresetId?: string;
iconStyle?: string;
imageUrl?: string;
isActive: boolean;
}
interface MenuItem {
id: string;
categoryId: string;
name: string;
nameEn?: string;
nameAr?: string;
price: number;
discountPercent: number;
imageUrl?: string;
videoUrl?: string;
model3dUrl?: string;
isAvailable: boolean;
}
interface ItemForm {
categoryId: string;
name: string;
nameEn: string;
price: string;
discount: string;
imageUrl: string;
videoUrl: string;
model3dUrl: string;
}
interface CatForm {
name: string;
icon: string;
iconPreset: CategoryIconSelection;
imageUrl: string;
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
function discountedPrice(price: number, percent: number) {
if (percent <= 0) return price;
return Math.round(price * (1 - percent / 100));
}
function mediaField(url: string) {
return url.trim() === "" ? "" : url;
}
const defaultItemForm: ItemForm = {
categoryId: "",
name: "",
nameEn: "",
price: "",
discount: "0",
imageUrl: "",
videoUrl: "",
model3dUrl: "",
};
const defaultCatForm: CatForm = {
name: "",
icon: "",
iconPreset: { iconPresetId: null, iconStyle: DEFAULT_CATEGORY_ICON_STYLE },
imageUrl: "",
};
// ─── Toggle Switch ────────────────────────────────────────────────────────────
function ToggleSwitch({
checked,
onChange,
disabled,
label,
}: {
checked: boolean;
onChange: (v: boolean) => void;
disabled?: boolean;
label?: string;
}) {
return (
<button
role="switch"
aria-checked={checked}
aria-label={label}
type="button"
onClick={() => !disabled && onChange(!checked)}
className={cn(
"relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors duration-200",
checked ? "bg-[#0F6E56]" : "bg-slate-300 dark:bg-slate-600",
disabled && "cursor-not-allowed opacity-50"
)}
>
<span
className={cn(
"pointer-events-none block h-4 w-4 rounded-full bg-white shadow-md transition-transform duration-200",
checked ? "translate-x-4" : "translate-x-0"
)}
/>
</button>
);
}
// ─── Modal wrapper ────────────────────────────────────────────────────────────
function Modal({
open,
onClose,
title,
children,
maxWidth = "max-w-lg",
}: {
open: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
maxWidth?: string;
}) {
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/50 p-4 pt-12">
<div
className={cn(
"w-full rounded-2xl border border-border bg-background shadow-2xl",
maxWidth
)}
>
<div className="flex items-center justify-between border-b border-border px-5 py-4">
<h2 className="text-base font-semibold">{title}</h2>
<Button type="button" variant="ghost" size="icon" onClick={onClose}>
<X className="size-4" />
</Button>
</div>
<div className="max-h-[80vh] overflow-y-auto p-5">{children}</div>
</div>
</div>
);
}
// ─── Main Component ───────────────────────────────────────────────────────────
export function MenuAdminScreen() {
const t = useTranslations("menuAdmin");
const tCommon = useTranslations("common");
const tNotify = useTranslations("notify");
const showError = (err: unknown) =>
notify.error(
err instanceof ApiClientError ? err.message : tNotify("errorGeneric")
);
const isRtl = useIsRtl();
const locale = useLocale();
const numberLocale = locale === "en" ? "en-US" : "fa-IR";
const cafeId = useAuthStore((s) => s.user?.cafeId);
const branchId = useBranchStore((s) => s.branchId);
const queryClient = useQueryClient();
// ── UI state ───────────────────────────────────────────────────────────────
const [activeTab, setActiveTab] = useState<"catalog" | "branch">("catalog");
const [selectedCategoryId, setSelectedCategoryId] = useState<string | "all">("all");
const [itemSearch, setItemSearch] = useState("");
// Item modal
const [itemModalOpen, setItemModalOpen] = useState(false);
const [editingItem, setEditingItem] = useState<MenuItem | null>(null);
const [itemForm, setItemForm] = useState<ItemForm>(defaultItemForm);
// Category modal
const [catModalOpen, setCatModalOpen] = useState(false);
const [editingCategory, setEditingCategory] = useState<MenuCategory | null>(null);
const [catForm, setCatForm] = useState<CatForm>(defaultCatForm);
// ── Data queries ───────────────────────────────────────────────────────────
const { data: categories = [] } = useQuery({
queryKey: ["menu-categories", cafeId],
queryFn: () => apiGet<MenuCategory[]>(`/api/cafes/${cafeId}/menu/categories`),
enabled: !!cafeId,
});
const { data: items = [], isLoading } = useQuery({
queryKey: ["menu-items-all", cafeId],
queryFn: () => apiGet<MenuItem[]>(`/api/cafes/${cafeId}/menu/items`),
enabled: !!cafeId,
});
const categoryNameById = useMemo(
() => buildCategoryNameMap(categories),
[categories]
);
// ── Derived data ───────────────────────────────────────────────────────────
const itemCountByCategory = useMemo(() => {
const counts: Record<string, number> = {};
for (const item of items) {
counts[item.categoryId] = (counts[item.categoryId] ?? 0) + 1;
}
return counts;
}, [items]);
const filteredItems = useMemo(() => {
let result = items;
if (selectedCategoryId !== "all") {
result = result.filter((i) => i.categoryId === selectedCategoryId);
}
const q = itemSearch.trim();
if (q) {
result = result.filter((i) => menuItemMatchesSearch(i, q, locale));
}
return result;
}, [items, selectedCategoryId, itemSearch, locale]);
// ── Mutations ──────────────────────────────────────────────────────────────
const invalidateMenu = () => {
queryClient.invalidateQueries({ queryKey: ["menu-items-all", cafeId] });
queryClient.invalidateQueries({ queryKey: ["menu-categories", cafeId] });
queryClient.invalidateQueries({ queryKey: ["menu-items", cafeId] });
};
const addItemMutation = useMutation({
mutationFn: () =>
apiPost(`/api/cafes/${cafeId}/menu/items`, {
categoryId: itemForm.categoryId,
name: itemForm.name,
nameEn: itemForm.nameEn.trim() || null,
price: parseFloat(itemForm.price),
discountPercent: parseFloat(itemForm.discount) || 0,
imageUrl: itemForm.imageUrl || null,
videoUrl: itemForm.videoUrl || null,
model3dUrl: itemForm.model3dUrl || null,
}),
onSuccess: () => {
setItemModalOpen(false);
invalidateMenu();
},
onError: showError,
});
const updateItemMutation = useMutation({
mutationFn: (id: string) =>
apiPatch(`/api/cafes/${cafeId}/menu/items/${id}`, {
name: itemForm.name,
nameEn: itemForm.nameEn.trim() || null,
price: parseFloat(itemForm.price),
discountPercent: parseFloat(itemForm.discount) || 0,
imageUrl: mediaField(itemForm.imageUrl),
videoUrl: mediaField(itemForm.videoUrl),
model3dUrl: mediaField(itemForm.model3dUrl),
}),
onSuccess: () => {
setItemModalOpen(false);
invalidateMenu();
},
onError: showError,
});
const toggleItemMutation = useMutation({
mutationFn: ({ id, isAvailable }: { id: string; isAvailable: boolean }) =>
apiPatch(`/api/cafes/${cafeId}/menu/items/${id}/availability`, { isAvailable }),
onSuccess: invalidateMenu,
onError: showError,
});
const addCategoryMutation = useMutation({
mutationFn: () =>
apiPost(`/api/cafes/${cafeId}/menu/categories`, {
name: catForm.name,
sortOrder: categories.length + 1,
discountPercent: 0,
icon: catForm.icon.trim() || null,
iconPresetId: catForm.iconPreset.iconPresetId,
iconStyle: catForm.iconPreset.iconPresetId ? catForm.iconPreset.iconStyle : null,
imageUrl: catForm.imageUrl.trim() || null,
}),
onSuccess: () => {
setCatModalOpen(false);
invalidateMenu();
},
onError: showError,
});
const updateCategoryMutation = useMutation({
mutationFn: (id: string) =>
apiPatch(`/api/cafes/${cafeId}/menu/categories/${id}`, {
name: catForm.name,
icon: mediaField(catForm.icon),
iconPresetId: catForm.iconPreset.iconPresetId ?? "",
iconStyle: catForm.iconPreset.iconPresetId ? catForm.iconPreset.iconStyle : "",
imageUrl: mediaField(catForm.imageUrl),
}),
onSuccess: () => {
setCatModalOpen(false);
invalidateMenu();
},
onError: showError,
});
// ── Modal openers ──────────────────────────────────────────────────────────
const openAddItem = () => {
setEditingItem(null);
setItemForm({
...defaultItemForm,
categoryId:
selectedCategoryId !== "all" ? selectedCategoryId : (categories[0]?.id ?? ""),
});
setItemModalOpen(true);
};
const openEditItem = (item: MenuItem) => {
setEditingItem(item);
setItemForm({
categoryId: item.categoryId,
name: item.name,
nameEn: item.nameEn ?? "",
price: String(item.price),
discount: String(item.discountPercent),
imageUrl: item.imageUrl ?? "",
videoUrl: item.videoUrl ?? "",
model3dUrl: item.model3dUrl ?? "",
});
setItemModalOpen(true);
};
const openAddCategory = () => {
setEditingCategory(null);
setCatForm(defaultCatForm);
setCatModalOpen(true);
};
const openEditCategory = (cat: MenuCategory) => {
setEditingCategory(cat);
setCatForm({
name: cat.name,
icon: cat.icon ?? "",
iconPreset: {
iconPresetId: cat.iconPresetId ?? null,
iconStyle:
(cat.iconStyle as CategoryIconSelection["iconStyle"]) ??
DEFAULT_CATEGORY_ICON_STYLE,
},
imageUrl: cat.imageUrl ?? "",
});
setCatModalOpen(true);
};
// ── Form submit handlers ───────────────────────────────────────────────────
const handleItemSave = () => {
if (editingItem) {
updateItemMutation.mutate(editingItem.id);
} else {
addItemMutation.mutate();
}
};
const handleCategorySave = () => {
if (editingCategory) {
updateCategoryMutation.mutate(editingCategory.id);
} else {
addCategoryMutation.mutate();
}
};
const itemMutationBusy =
addItemMutation.isPending || updateItemMutation.isPending;
const catMutationBusy =
addCategoryMutation.isPending || updateCategoryMutation.isPending;
const itemFormValid =
itemForm.name.trim() &&
itemForm.categoryId &&
itemForm.price &&
!isNaN(parseFloat(itemForm.price));
if (!cafeId) return null;
// ── Tab bar ────────────────────────────────────────────────────────────────
return (
<div className="space-y-4" dir={isRtl ? "rtl" : "ltr"}>
<PageHeader title={t("title")} subtitle={t("subtitle")} />
{/* Tab switcher */}
<div className="flex gap-1 rounded-xl border border-border bg-muted/40 p-1 w-fit">
<button
type="button"
onClick={() => setActiveTab("catalog")}
className={cn(
"rounded-lg px-4 py-1.5 text-sm font-medium transition-colors cursor-pointer",
activeTab === "catalog"
? "bg-background shadow-sm text-foreground"
: "text-muted-foreground hover:text-foreground"
)}
>
{t("tabCatalog")}
</button>
<button
type="button"
onClick={() => setActiveTab("branch")}
className={cn(
"rounded-lg px-4 py-1.5 text-sm font-medium transition-colors cursor-pointer",
activeTab === "branch"
? "bg-background shadow-sm text-foreground"
: "text-muted-foreground hover:text-foreground"
)}
>
{t("tabBranch")}
</button>
</div>
{/* Branch tab */}
{activeTab === "branch" ? (
branchId ? (
<BranchMenuOverrides
cafeId={cafeId!}
branchId={branchId}
numberLocale={numberLocale}
/>
) : (
<p className="rounded-xl border border-border bg-muted/40 p-6 text-center text-sm text-muted-foreground">
{t("selectBranchForOverrides")}
</p>
)
) : (
/* ── Catalog tab ─────────────────────────────────────────────────── */
<div className="flex min-h-0 flex-col gap-4">
{categories.length < 5 && items.length < 10 && (
<DemoDataBanner
invalidateKeys={[
["menu-categories", cafeId],
["menu-items-all", cafeId],
["menu-items", cafeId],
["tables-board", cafeId],
["inventory", cafeId],
]}
/>
)}
<div className="flex min-h-0 gap-4">
{/* ── Category Sidebar (desktop) ─────────────────────────────── */}
<aside className="hidden w-52 shrink-0 lg:block">
<div className="sticky top-4 flex flex-col gap-1 rounded-xl border border-border bg-card p-2 shadow-sm">
{/* "All items" entry */}
<button
type="button"
onClick={() => setSelectedCategoryId("all")}
className={cn(
"flex w-full cursor-pointer items-center gap-2.5 rounded-lg px-3 py-2.5 text-start text-sm transition-colors",
selectedCategoryId === "all"
? "bg-primary/10 font-semibold text-primary"
: "text-foreground hover:bg-accent"
)}
>
<span className="size-5 flex items-center justify-center text-base"></span>
<span className="min-w-0 flex-1 truncate">{t("allItems")}</span>
<span className="shrink-0 rounded-full bg-border/80 px-1.5 py-0.5 text-[10px] text-muted-foreground">
{items.length}
</span>
</button>
<div className="my-1 h-px bg-border/60" />
{/* Category entries */}
{categories.map((cat) => (
<div key={cat.id} className="group relative flex items-center">
<button
type="button"
onClick={() => setSelectedCategoryId(cat.id)}
className={cn(
"flex w-full cursor-pointer items-center gap-2.5 rounded-lg px-3 py-2.5 text-start text-sm transition-colors pe-8",
selectedCategoryId === cat.id
? "bg-primary/10 font-semibold text-primary"
: "text-foreground hover:bg-accent"
)}
>
<CategoryVisual
icon={cat.icon}
iconPresetId={cat.iconPresetId}
iconStyle={cat.iconStyle}
imageUrl={cat.imageUrl}
size="xs"
/>
<span className="min-w-0 flex-1 truncate">{cat.name}</span>
<span className="shrink-0 rounded-full bg-border/80 px-1.5 py-0.5 text-[10px] text-muted-foreground">
{itemCountByCategory[cat.id] ?? 0}
</span>
</button>
{/* Edit category button */}
<button
type="button"
aria-label={t("editCategory")}
onClick={() => openEditCategory(cat)}
className="absolute end-1 top-1/2 -translate-y-1/2 flex size-6 cursor-pointer items-center justify-center rounded-md text-muted-foreground opacity-0 transition-opacity hover:bg-accent hover:text-foreground group-hover:opacity-100"
>
<Pencil className="size-3" />
</button>
</div>
))}
{/* Add category button */}
<div className="mt-1 border-t border-border/60 pt-1">
<button
type="button"
onClick={openAddCategory}
className="flex w-full cursor-pointer items-center gap-2 rounded-lg px-3 py-2 text-sm text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
<Plus className="size-4 shrink-0" />
{t("addCategory")}
</button>
</div>
</div>
</aside>
{/* ── Items panel ────────────────────────────────────────────── */}
<div className="min-w-0 flex-1">
{/* Mobile category tabs */}
<div className="mb-3 flex gap-1.5 overflow-x-auto pb-1 lg:hidden [scrollbar-width:none]">
<button
type="button"
onClick={() => setSelectedCategoryId("all")}
className={cn(
"shrink-0 rounded-full border px-3 py-1 text-xs font-medium transition-colors cursor-pointer",
selectedCategoryId === "all"
? "border-primary bg-primary/10 text-primary"
: "border-border bg-card text-muted-foreground hover:text-foreground"
)}
>
{t("allItems")} ({items.length})
</button>
{categories.map((cat) => (
<button
key={cat.id}
type="button"
onClick={() => setSelectedCategoryId(cat.id)}
className={cn(
"shrink-0 flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs font-medium transition-colors cursor-pointer",
selectedCategoryId === cat.id
? "border-primary bg-primary/10 text-primary"
: "border-border bg-card text-muted-foreground hover:text-foreground"
)}
>
<CategoryVisual
icon={cat.icon}
iconPresetId={cat.iconPresetId}
iconStyle={cat.iconStyle}
imageUrl={cat.imageUrl}
size="xs"
/>
{cat.name}
<span className="text-[10px] opacity-60">
{itemCountByCategory[cat.id] ?? 0}
</span>
</button>
))}
<button
type="button"
onClick={openAddCategory}
className="shrink-0 flex items-center gap-1 rounded-full border border-dashed border-border px-3 py-1 text-xs text-muted-foreground transition-colors hover:text-foreground cursor-pointer"
>
<Plus className="size-3" />
{t("addCategory")}
</button>
</div>
{/* Search + Add bar */}
<div className="mb-4 flex items-center gap-2">
<div className="relative flex-1">
<Search className="pointer-events-none absolute start-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" aria-hidden />
<Input
type="search"
value={itemSearch}
onChange={(e) => setItemSearch(e.target.value)}
placeholder={t("searchItemsPlaceholder")}
className="h-9 ps-9 pe-9"
/>
{itemSearch ? (
<Button
type="button"
variant="ghost"
size="icon"
className="absolute end-1 top-1/2 size-7 -translate-y-1/2"
onClick={() => setItemSearch("")}
>
<X className="size-4" />
</Button>
) : null}
</div>
<Button
onClick={openAddItem}
className="shrink-0 bg-[#0F6E56] hover:bg-[#0c5a46]"
disabled={categories.length === 0}
>
<Plus className="me-1.5 size-4" />
{t("newItem")}
</Button>
</div>
{/* Items grid */}
{isLoading ? (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{Array.from({ length: 8 }).map((_, i) => (
<Skeleton key={i} className="h-52 rounded-xl" />
))}
</div>
) : filteredItems.length === 0 ? (
/* Empty state */
<div className="flex flex-col items-center gap-3 rounded-2xl border border-dashed border-border py-16 text-center">
<div className="text-4xl">🍽</div>
<p className="text-sm font-medium text-muted-foreground">
{itemSearch ? t("noItemsMatchSearch") : t("noItemsInCategory")}
</p>
{!itemSearch ? (
<Button
variant="outline"
size="sm"
onClick={openAddItem}
disabled={categories.length === 0}
>
<Plus className="me-1.5 size-4" />
{t("addItem")}
</Button>
) : null}
</div>
) : (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{filteredItems.map((item) => {
const kind = inferMenuItemKind(
item.categoryId,
categoryNameById.get(item.categoryId)
);
const hasDiscount = item.discountPercent > 0;
const salePrice = discountedPrice(item.price, item.discountPercent);
return (
<div
key={item.id}
className={cn(
"group relative flex flex-col overflow-hidden rounded-xl border border-border bg-card shadow-sm transition-all hover:border-primary/40 hover:shadow-md",
!item.isAvailable && "opacity-70"
)}
>
{/* Image area */}
<div className="relative aspect-[4/3] overflow-hidden bg-muted/50">
<MenuItemMedia
imageUrl={item.imageUrl}
kind={kind}
size="md"
className="absolute inset-0"
/>
{/* Hover overlay — edit button */}
<div className="absolute inset-0 flex items-center justify-center bg-black/0 transition-all group-hover:bg-black/25">
<Button
size="sm"
className="translate-y-2 opacity-0 shadow-lg transition-all group-hover:translate-y-0 group-hover:opacity-100"
onClick={() => openEditItem(item)}
>
<Pencil className="me-1.5 size-3.5" />
{t("editItem")}
</Button>
</div>
{/* Discount badge */}
{hasDiscount ? (
<span className="absolute start-2 top-2 z-10 rounded-full border border-amber-200 bg-amber-50 px-2 py-0.5 text-[10px] font-semibold text-[#BA7517]">
{formatNumber(item.discountPercent, numberLocale)}% {t("discountBadge")}
</span>
) : null}
{/* Media badges */}
<div className="absolute bottom-1.5 start-1.5 flex gap-1">
{item.videoUrl ? (
<span className="flex items-center gap-0.5 rounded-md bg-black/60 px-1.5 py-0.5 text-[10px] text-white">
<Video className="size-2.5" />
</span>
) : null}
{item.model3dUrl ? (
<span className="flex items-center gap-0.5 rounded-md bg-[#0F6E56]/90 px-1.5 py-0.5 text-[10px] text-white">
<Box className="size-2.5" />
3D
</span>
) : null}
</div>
{/* Out of stock overlay */}
{!item.isAvailable ? (
<div className="absolute inset-0 flex items-center justify-center bg-background/50 backdrop-blur-[1px]">
<span className="rounded-full bg-slate-800/80 px-3 py-1 text-xs font-medium text-white">
{t("outOfStock")}
</span>
</div>
) : null}
</div>
{/* Body */}
<div className="flex items-start gap-2 p-3">
<div className="min-w-0 flex-1">
<MenuItemLabels
item={item}
lines={1}
primaryClassName="text-sm font-medium"
secondaryClassName="text-[10px]"
/>
<div className="mt-1 flex items-baseline gap-1.5">
{hasDiscount ? (
<>
<span className="text-[11px] text-muted-foreground line-through">
{formatCurrency(item.price, numberLocale)}
</span>
<span className="text-sm font-semibold text-[#0F6E56]">
{formatCurrency(salePrice, numberLocale)}
</span>
</>
) : (
<span className="text-sm font-semibold text-[#0F6E56]">
{formatCurrency(item.price, numberLocale)}
</span>
)}
</div>
</div>
{/* Availability toggle */}
<ToggleSwitch
checked={item.isAvailable}
onChange={(v) =>
toggleItemMutation.mutate({ id: item.id, isAvailable: v })
}
disabled={toggleItemMutation.isPending}
label={
item.isAvailable ? t("available") : t("unavailable")
}
/>
</div>
</div>
);
})}
</div>
)}
</div>
</div>
</div>
)}
{/* ── Item Add / Edit Modal ─────────────────────────────────────────── */}
<Modal
open={itemModalOpen}
onClose={() => setItemModalOpen(false)}
title={editingItem ? t("editItem") : t("newItem")}
maxWidth="max-w-lg"
>
<div className="space-y-4">
{/* Category selector */}
<LabeledField label={t("category")} htmlFor="modal-item-category">
<select
id="modal-item-category"
className="w-full rounded-lg border border-input bg-background px-3 py-2 text-sm"
value={itemForm.categoryId}
onChange={(e) =>
setItemForm((f) => ({ ...f, categoryId: e.target.value }))
}
>
<option value=""></option>
{categories.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</LabeledField>
<div className="grid gap-3 sm:grid-cols-2">
<LabeledField label={t("name")} htmlFor="modal-item-name">
<Input
id="modal-item-name"
value={itemForm.name}
onChange={(e) => setItemForm((f) => ({ ...f, name: e.target.value }))}
autoFocus
/>
</LabeledField>
<LabeledField label={t("nameEnOptional")} htmlFor="modal-item-name-en">
<Input
id="modal-item-name-en"
value={itemForm.nameEn}
onChange={(e) =>
setItemForm((f) => ({ ...f, nameEn: e.target.value }))
}
dir="ltr"
className="text-start"
placeholder="e.g. Espresso"
/>
</LabeledField>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<LabeledField label={t("price")} htmlFor="modal-item-price">
<Input
id="modal-item-price"
value={itemForm.price}
onChange={(e) => setItemForm((f) => ({ ...f, price: e.target.value }))}
inputMode="numeric"
dir="ltr"
className="text-end"
/>
</LabeledField>
<LabeledField label={t("discountPercent")} htmlFor="modal-item-discount">
<Input
id="modal-item-discount"
value={itemForm.discount}
onChange={(e) =>
setItemForm((f) => ({ ...f, discount: e.target.value }))
}
inputMode="numeric"
dir="ltr"
className="text-end"
/>
</LabeledField>
</div>
{/* Media */}
<LabeledField label={t("media")}>
<MediaPairUpload
cafeId={cafeId!}
kind="menu"
imageUrl={itemForm.imageUrl}
videoUrl={itemForm.videoUrl}
onImageChange={(url) =>
setItemForm((f) => ({ ...f, imageUrl: url ?? "" }))
}
onVideoChange={(url) =>
setItemForm((f) => ({ ...f, videoUrl: url ?? "" }))
}
/>
</LabeledField>
{/* 3D model */}
<LabeledField label={t("model3d")}>
<Menu3dUpload
cafeId={cafeId!}
model3dUrl={itemForm.model3dUrl || null}
onChange={(url) =>
setItemForm((f) => ({ ...f, model3dUrl: url ?? "" }))
}
/>
{editingItem ? (
<MenuAi3dGenerate
cafeId={cafeId!}
itemId={editingItem.id}
imageUrl={itemForm.imageUrl || editingItem.imageUrl}
onGenerated={(url) =>
setItemForm((f) => ({ ...f, model3dUrl: url }))
}
/>
) : null}
</LabeledField>
{/* Actions */}
<div className="flex justify-end gap-2 border-t border-border pt-4">
<Button
variant="ghost"
onClick={() => setItemModalOpen(false)}
>
{tCommon("cancel")}
</Button>
<Button
className="bg-[#0F6E56] hover:bg-[#0c5a46]"
disabled={!itemFormValid || itemMutationBusy}
onClick={handleItemSave}
>
{itemMutationBusy ? t("saving") : tCommon("save")}
</Button>
</div>
</div>
</Modal>
{/* ── Category Add / Edit Modal ─────────────────────────────────────── */}
<Modal
open={catModalOpen}
onClose={() => setCatModalOpen(false)}
title={editingCategory ? t("editCategoryTitle") : t("newCategory")}
maxWidth="max-w-md"
>
<div className="space-y-4">
<LabeledField label={t("name")} htmlFor="modal-cat-name">
<Input
id="modal-cat-name"
value={catForm.name}
onChange={(e) => setCatForm((f) => ({ ...f, name: e.target.value }))}
autoFocus
/>
</LabeledField>
<CategoryMediaFields
cafeId={cafeId!}
icon={catForm.icon}
iconPresetId={catForm.iconPreset.iconPresetId}
iconStyle={catForm.iconPreset.iconStyle}
imageUrl={catForm.imageUrl}
onIconChange={(icon) => setCatForm((f) => ({ ...f, icon }))}
onPresetChange={(iconPreset) => setCatForm((f) => ({ ...f, iconPreset }))}
onImageChange={(url) =>
setCatForm((f) => ({ ...f, imageUrl: url ?? "" }))
}
/>
<div className="flex justify-end gap-2 border-t border-border pt-4">
<Button variant="ghost" onClick={() => setCatModalOpen(false)}>
{tCommon("cancel")}
</Button>
<Button
className="bg-[#0F6E56] hover:bg-[#0c5a46]"
disabled={!catForm.name.trim() || catMutationBusy}
onClick={handleCategorySave}
>
{catMutationBusy ? t("saving") : tCommon("save")}
</Button>
</div>
</div>
</Modal>
</div>
);
}