Files
meezi/web/dashboard/src/components/menu/menu-admin-screen.tsx
T

961 lines
37 KiB
TypeScript
Raw Normal View History

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