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

{title}

{children}
); } // ─── 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("all"); const [itemSearch, setItemSearch] = useState(""); // Item modal const [itemModalOpen, setItemModalOpen] = useState(false); const [editingItem, setEditingItem] = useState(null); const [itemForm, setItemForm] = useState(defaultItemForm); // Category modal const [catModalOpen, setCatModalOpen] = useState(false); const [editingCategory, setEditingCategory] = useState(null); const [catForm, setCatForm] = useState(defaultCatForm); // ── Data queries ─────────────────────────────────────────────────────────── const { data: categories = [] } = useQuery({ queryKey: ["menu-categories", cafeId], queryFn: () => apiGet(`/api/cafes/${cafeId}/menu/categories`), enabled: !!cafeId, }); const { data: items = [], isLoading } = useQuery({ queryKey: ["menu-items-all", cafeId], queryFn: () => apiGet(`/api/cafes/${cafeId}/menu/items`), enabled: !!cafeId, }); const categoryNameById = useMemo( () => buildCategoryNameMap(categories), [categories] ); // ── Derived data ─────────────────────────────────────────────────────────── const itemCountByCategory = useMemo(() => { const counts: Record = {}; 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 (
{/* Tab switcher */}
{/* Branch tab */} {activeTab === "branch" ? ( branchId ? ( ) : (

{t("selectBranchForOverrides")}

) ) : ( /* ── Catalog tab ─────────────────────────────────────────────────── */
{categories.length < 5 && items.length < 10 && ( )}
{/* ── Category Sidebar (desktop) ─────────────────────────────── */} {/* ── Items panel ────────────────────────────────────────────── */}
{/* Mobile category tabs */}
{categories.map((cat) => ( ))}
{/* Search + Add bar */}
setItemSearch(e.target.value)} placeholder={t("searchItemsPlaceholder")} className="h-9 ps-9 pe-9" /> {itemSearch ? ( ) : null}
{/* Items grid */} {isLoading ? (
{Array.from({ length: 8 }).map((_, i) => ( ))}
) : filteredItems.length === 0 ? ( /* Empty state */
🍽️

{itemSearch ? t("noItemsMatchSearch") : t("noItemsInCategory")}

{!itemSearch ? ( ) : null}
) : (
{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 (
{/* Image area */}
{/* Hover overlay — edit button */}
{/* Discount badge */} {hasDiscount ? ( {formatNumber(item.discountPercent, numberLocale)}% {t("discountBadge")} ) : null} {/* Media badges */}
{item.videoUrl ? ( ) : null} {item.model3dUrl ? ( 3D ) : null}
{/* Out of stock overlay */} {!item.isAvailable ? (
{t("outOfStock")}
) : null}
{/* Body */}
{hasDiscount ? ( <> {formatCurrency(item.price, numberLocale)} {formatCurrency(salePrice, numberLocale)} ) : ( {formatCurrency(item.price, numberLocale)} )}
{/* Availability toggle */} toggleItemMutation.mutate({ id: item.id, isAvailable: v }) } disabled={toggleItemMutation.isPending} label={ item.isAvailable ? t("available") : t("unavailable") } />
); })}
)}
)} {/* ── Item Add / Edit Modal ─────────────────────────────────────────── */} setItemModalOpen(false)} title={editingItem ? t("editItem") : t("newItem")} maxWidth="max-w-lg" >
{/* Category selector */}
setItemForm((f) => ({ ...f, name: e.target.value }))} autoFocus /> setItemForm((f) => ({ ...f, nameEn: e.target.value })) } dir="ltr" className="text-start" placeholder="e.g. Espresso" />
setItemForm((f) => ({ ...f, price: e.target.value }))} inputMode="numeric" dir="ltr" className="text-end" /> setItemForm((f) => ({ ...f, discount: e.target.value })) } inputMode="numeric" dir="ltr" className="text-end" />
{/* Media */} setItemForm((f) => ({ ...f, imageUrl: url ?? "" })) } onVideoChange={(url) => setItemForm((f) => ({ ...f, videoUrl: url ?? "" })) } /> {/* 3D model */} setItemForm((f) => ({ ...f, model3dUrl: url ?? "" })) } /> {editingItem ? ( setItemForm((f) => ({ ...f, model3dUrl: url })) } /> ) : null} {/* Actions */}
{/* ── Category Add / Edit Modal ─────────────────────────────────────── */} setCatModalOpen(false)} title={editingCategory ? t("editCategoryTitle") : t("newCategory")} maxWidth="max-w-md" >
setCatForm((f) => ({ ...f, name: e.target.value }))} autoFocus /> setCatForm((f) => ({ ...f, icon }))} onPresetChange={(iconPreset) => setCatForm((f) => ({ ...f, iconPreset }))} onImageChange={(url) => setCatForm((f) => ({ ...f, imageUrl: url ?? "" })) } />
); }