024a455ab3
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>
961 lines
37 KiB
TypeScript
961 lines
37 KiB
TypeScript
"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>
|
||
);
|
||
}
|