From f832479e52decb339322e54955e1a0af98e878df Mon Sep 17 00:00:00 2001 From: openhands Date: Fri, 11 Sep 2026 18:47:11 +0200 Subject: [PATCH] feat(catalog): add auto-category wizard with live preview and smart suggestions - Add AutoCategoryDialog: pick furni (or empty page), suggest caption/icon/layout, live preview - Add createAutoCategory server action (page + offers in one export) with CatalogKind - Extend furni search API with interactionType - Share ShopTile and refactor inline-editor/items-shop-preview to use it - Add suggestion heuristics (suggestCategoryName/dIcon/layout) with tests - Add autoCategory translations (en/nl) --- src/actions/catalog-items.ts | 2 +- src/actions/catalog.ts | 139 +++ src/app/api/admin/furni/search/route.ts | 2 + .../catalog-manager/auto-category-dialog.tsx | 727 ++++++++++++++++ .../admin/catalog-manager/auto-organize.tsx | 122 +++ .../catalog-manager-dialog.tsx | 31 +- .../admin/catalog-manager/inline-editor.tsx | 794 +++++++++--------- .../catalog-manager/live-catalog-preview.tsx | 280 ++++++ .../admin/catalog/items-shop-preview.tsx | 201 +---- src/components/admin/catalog/shop-tile.tsx | 205 +++++ src/features/catalog/domain/organize.test.ts | 229 +++++ src/features/catalog/domain/organize.ts | 330 ++++++++ src/messages/en.json | 41 + src/messages/nl.json | 41 + 14 files changed, 2577 insertions(+), 567 deletions(-) create mode 100644 src/components/admin/catalog-manager/auto-category-dialog.tsx create mode 100644 src/components/admin/catalog-manager/auto-organize.tsx create mode 100644 src/components/admin/catalog-manager/live-catalog-preview.tsx create mode 100644 src/components/admin/catalog/shop-tile.tsx create mode 100644 src/features/catalog/domain/organize.test.ts create mode 100644 src/features/catalog/domain/organize.ts diff --git a/src/actions/catalog-items.ts b/src/actions/catalog-items.ts index 27d73892..138ee254 100644 --- a/src/actions/catalog-items.ts +++ b/src/actions/catalog-items.ts @@ -20,7 +20,7 @@ import { logStaffActivity } from "@/lib/services/staff-activity"; import { translateItemsSchema } from "@/lib/validators/catalog"; /** Raw INSERT — catalog_items.id has no AUTO_INCREMENT on real Habbo DBs; page_id is often VARCHAR. */ -async function insertCatalogItemRow(data: { +export async function insertCatalogItemRow(data: { pageId: number; itemIds: string; catalogName: string; diff --git a/src/actions/catalog.ts b/src/actions/catalog.ts index ed67ce5e..cbce816b 100644 --- a/src/actions/catalog.ts +++ b/src/actions/catalog.ts @@ -1,6 +1,8 @@ "use server"; +import { inArray, sql } from "drizzle-orm"; import { revalidatePath } from "next/cache"; +import { insertCatalogItemRow } from "@/actions/catalog-items"; import { catalogFailure } from "@/features/catalog/server/errors"; import { createPageCommand, @@ -11,6 +13,7 @@ import { } from "@/features/catalog/server/page-commands"; import { sendCatalogUpdate } from "@/features/catalog/server/sync-status"; import { requirePermission } from "@/lib/admin/guard"; +import { db, ItemsBase } from "@/lib/db"; import { PERMS } from "@/lib/permissions"; import type { ActionResult } from "@/lib/safe-action-shared"; import { withCatalogExport } from "@/lib/services/catalog-git-queue"; @@ -208,3 +211,139 @@ export async function reorderCatalogPages(input: { return { ok: true as const, data: {} }; }); } + +export type CatalogKind = "normal" | "bc"; + +export async function createAutoCategory(input: { + kind: CatalogKind; + parentId: number; + caption: string; + pageLayout?: string; + iconImage?: number; + enabled?: "0" | "1"; + visible?: "0" | "1"; + minRank?: number; + offerRows?: Array<{ + baseId: number; + credits?: number; + points?: number; + pointsType?: number; + amount?: number; + }>; +}): Promise> { + const staff = await requirePermission(PERMS.CATALOG_EDIT); + return await withCatalogExport(async () => { + const createResult = + input.kind === "bc" + ? await createPageCommand("bc", { + caption: input.caption, + parentId: input.parentId, + pageLayout: input.pageLayout ?? "default_3x3", + captionSave: input.caption.slice(0, 25), + iconImage: input.iconImage ?? 0, + iconColor: 0, + minRank: input.minRank ?? 1, + orderNum: 0, + visible: input.visible ?? "1", + enabled: input.enabled ?? "1", + clubOnly: "0", + vipOnly: "0", + pageHeadline: "", + pageTeaser: "", + includes: "", + }) + : await createPageCommand("normal", { + caption: input.caption, + parentId: input.parentId, + pageLayout: input.pageLayout ?? "default_3x3", + captionSave: input.caption.slice(0, 25), + iconImage: input.iconImage ?? 0, + iconColor: 0, + minRank: input.minRank ?? 1, + orderNum: 0, + visible: input.visible ?? "1", + enabled: input.enabled ?? "1", + clubOnly: "0", + vipOnly: "0", + pageHeadline: "", + pageTeaser: "", + includes: "", + }); + + const pageId = Number(createResult); + + // Create the new page's offers in the same export run (single snapshot). + if (input.kind === "normal" && (input.offerRows?.length ?? 0) > 0) { + if ((input.offerRows?.length ?? 0) > 500) { + return { ok: false as const, error: "Max 500 items per auto category" }; + } + + const rows = input.offerRows ?? []; + const baseIds = [...new Set(rows.map((r) => r.baseId))]; + const bases = await db + .select({ + id: ItemsBase.id, + publicName: ItemsBase.publicName, + itemName: ItemsBase.itemName, + }) + .from(ItemsBase) + .where(inArray(ItemsBase.id, baseIds)); + const baseMap = new Map(bases.map((b) => [b.id, b])); + + const [maxOrder] = (await db.execute(sql` + SELECT COALESCE(MAX(order_number), 0) AS maxOrder FROM catalog_items WHERE page_id = ${String(pageId)} + `)) as unknown as [{ maxOrder: number }, unknown]; + let nextOrder = Number(maxOrder?.maxOrder ?? 0) + 1; + + let created = 0; + for (const row of rows) { + const base = baseMap.get(row.baseId); + if (!base) continue; + try { + await insertCatalogItemRow({ + pageId, + itemIds: String(row.baseId), + catalogName: base.publicName || base.itemName || String(row.baseId), + costCredits: row.credits ?? 0, + costPoints: row.points ?? 0, + pointsType: row.pointsType ?? 0, + amount: row.amount ?? 1, + limitedSells: 0, + limitedStack: 0, + orderNumber: nextOrder++, + offerId: -1, + songId: 0, + haveOffer: "1", + clubOnly: "0", + extradata: "", + }); + created++; + } catch (error) { + console.error( + `[createAutoCategory] failed to insert item #${row.baseId}:`, + error, + ); + } + } + + await logStaffActivity({ + staffId: staff.id, + action: "catalog_items_bulk_create", + description: `Auto-created ${created} catalog item(s) on page #${pageId}`, + targetType: "catalog_page", + targetId: pageId, + }); + } + + await sendCatalogUpdate(); + await logStaffActivity({ + staffId: staff.id, + action: "catalog_page_create", + description: `Created catalog page #${pageId} "${input.caption}" (${input.kind})`, + targetType: "catalog_page", + targetId: pageId, + }); + revalidatePath("/admin/catalog"); + return { ok: true as const, data: { pageId } }; + }); +} diff --git a/src/app/api/admin/furni/search/route.ts b/src/app/api/admin/furni/search/route.ts index 91aa449c..cb51c920 100644 --- a/src/app/api/admin/furni/search/route.ts +++ b/src/app/api/admin/furni/search/route.ts @@ -43,6 +43,7 @@ export const GET = withAdmin( publicName: ItemsBase.publicName, type: ItemsBase.type, spriteId: ItemsBase.spriteId, + interactionType: ItemsBase.interactionType, }) .from(ItemsBase) .where(or(...conditions)) @@ -56,6 +57,7 @@ export const GET = withAdmin( publicName: r.publicName ?? "", type: r.type ?? "s", spriteId: r.spriteId ?? 0, + interactionType: r.interactionType ?? "default", })), }); }, diff --git a/src/components/admin/catalog-manager/auto-category-dialog.tsx b/src/components/admin/catalog-manager/auto-category-dialog.tsx new file mode 100644 index 00000000..8346048e --- /dev/null +++ b/src/components/admin/catalog-manager/auto-category-dialog.tsx @@ -0,0 +1,727 @@ +"use client"; + +import { Folder, Home, Loader2, Plus, Search, Wand2, X } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { toast } from "sonner"; +import { type CatalogKind, createAutoCategory } from "@/actions/catalog"; +import type { CatalogItemData } from "@/app/admin/catalog/[id]/catalog-items-table/types"; +import { IconPicker } from "@/components/admin/catalog/icon-picker"; +import { useCatalogManager } from "@/components/admin/catalog-manager/use-catalog-store"; +import { CatalogIcon, LAYOUT_COLORS } from "@/components/admin/catalog-tree"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + suggestCatalogIcon, + suggestCatalogLayoutFromBaseItems, + suggestCategoryName, +} from "@/features/catalog/domain/organize"; +import { CATALOG_LAYOUTS, type CatalogLayout } from "@/lib/catalog-layouts"; +import { getLocalIconUrl } from "@/lib/furni/classname"; +import { cn } from "@/lib/utils"; +import { LiveCatalogPreview } from "./live-catalog-preview"; + +interface FurniResult { + id: number; + itemName: string; + publicName: string; + type: string; + spriteId: number; + interactionType: string; +} + +interface AutoCategoryDialogProps { + catalogType: CatalogKind; + open: boolean; + onOpenChange: (open: boolean) => void; + onCreated: (pageId: number) => void; +} + +export function AutoCategoryDialog({ + catalogType, + open, + onOpenChange, + onCreated, +}: AutoCategoryDialogProps) { + const { state } = useCatalogManager(); + const t = useTranslations("pages.admin.catalog.workspace.autoCategory"); + const { rootPages, selectedPageId } = state; + + const [mode, setMode] = useState<"empty" | "furni">("furni"); + + const [query, setQuery] = useState(""); + const [debounced, setDebounced] = useState(""); + const [results, setResults] = useState([]); + const [searchLoading, setSearchLoading] = useState(false); + const [selected, setSelected] = useState([]); + const inputRef = useRef(null); + + const [parentId, setParentId] = useState(-1); + const [caption, setCaption] = useState(""); + const [iconImage, setIconImage] = useState(0); + const [layout, setLayout] = useState("default_3x3"); + const [enabled, setEnabled] = useState("1"); + const [visible, setVisible] = useState("1"); + const [isCreating, setIsCreating] = useState(false); + + const [captionTouched, setCaptionTouched] = useState(false); + const [iconTouched, setIconTouched] = useState(false); + const [layoutTouched, setLayoutTouched] = useState(false); + + const [freeMode, setFreeMode] = useState(true); + const [credits, setCredits] = useState(0); + const [points, setPoints] = useState(0); + const [pointsType, setPointsType] = useState(0); + + // Default the parent to the currently selected page when the wizard opens. + useEffect(() => { + if (!open) return; + setParentId(selectedPageId ?? -1); + }, [open, selectedPageId]); + + const parentOptions = useMemo(() => { + const options: Array<{ id: number; caption: string }> = [ + { id: -1, caption: "Root" }, + ]; + const selectedNode = + selectedPageId != null ? state.nodes.get(selectedPageId) : undefined; + if (selectedNode) + options.push({ id: selectedNode.id, caption: selectedNode.caption }); + for (const p of rootPages) { + if (!options.some((o) => o.id === p.id)) { + options.push({ id: p.id, caption: p.caption }); + } + } + return options; + }, [rootPages, selectedPageId, state.nodes]); + + useEffect(() => { + const t = setTimeout(() => setDebounced(query.trim()), 300); + return () => clearTimeout(t); + }, [query]); + + useEffect(() => { + if (!open) return; + const t = setTimeout(() => inputRef.current?.focus(), 50); + return () => clearTimeout(t); + }, [open]); + + useEffect(() => { + if (!open || mode !== "furni" || debounced.length < 2) { + setResults([]); + return; + } + setSearchLoading(true); + const ac = new AbortController(); + fetch( + `/api/admin/furni/search?q=${encodeURIComponent(debounced)}&limit=40`, + { + signal: ac.signal, + }, + ) + .then((r) => r.json()) + .then((data) => setResults(data.results ?? [])) + .catch((error) => + console.error("[AutoCategory] furni search failed:", error), + ) + .finally(() => setSearchLoading(false)); + return () => ac.abort(); + }, [debounced, mode, open]); + + const sug = useMemo(() => { + if (mode !== "furni" || selected.length === 0) return null; + return { + name: suggestCategoryName(selected), + layout: suggestCatalogLayoutFromBaseItems( + selected.map((f) => ({ + type: f.type, + interactionType: f.interactionType, + })), + ), + icon: suggestCatalogIcon(selected), + }; + }, [selected, mode]); + + useEffect(() => { + if (!sug) return; + if (!captionTouched && sug.name) setCaption(sug.name); + if (!layoutTouched && sug.layout) setLayout(sug.layout.layout); + if (!iconTouched && sug.icon > 0) setIconImage(sug.icon); + }, [sug, captionTouched, layoutTouched, iconTouched]); + + const previewItems = useMemo(() => { + if (mode !== "furni" || selected.length === 0) return []; + return selected.map((f, idx) => ({ + id: -1 - idx, + catalogName: f.publicName || f.itemName, + itemIds: String(f.id), + costCredits: freeMode ? 0 : credits, + costPoints: freeMode ? 0 : points, + pointsType: freeMode ? 0 : pointsType, + amount: 1, + limitedSells: 0, + limitedStack: 0, + orderNumber: idx, + offerId: -1, + songId: 0, + haveOffer: "1", + clubOnly: "0", + extradata: "", + baseName: f.publicName || f.itemName, + baseItemName: f.itemName, + spriteId: f.spriteId, + baseItem: { + id: f.id, + spriteId: f.spriteId, + publicName: f.publicName || f.itemName, + itemName: f.itemName, + type: f.type, + width: 1, + length: 1, + stackHeight: 0, + allowStack: 1, + allowSit: 0, + allowLay: 0, + allowWalk: 0, + allowGift: 1, + allowTrade: 1, + allowRecycle: 0, + allowMarketplaceSell: 0, + allowInventoryStack: 1, + interactionType: f.interactionType, + interactionModesCount: 1, + vendingIds: "0", + customparams: "", + effectIdMale: 0, + effectIdFemale: 0, + clothingOnWalk: "", + }, + })); + }, [selected, mode, freeMode, credits, points, pointsType]); + + const toggleFurni = useCallback((f: FurniResult) => { + setSelected((prev) => { + if (prev.some((x) => x.id === f.id)) { + return prev.filter((x) => x.id !== f.id); + } + return [...prev, f]; + }); + }, []); + + const handleCreate = async () => { + if (isCreating) return; + if (!caption.trim()) { + toast.error("Name is required"); + return; + } + if (mode === "furni" && selected.length === 0) { + toast.error("Select at least one furni item"); + return; + } + setIsCreating(true); + try { + const offerRows = + mode === "furni" + ? selected.map((f) => ({ + baseId: f.id, + credits: freeMode ? 0 : credits, + points: freeMode ? 0 : points, + pointsType: freeMode ? 0 : pointsType, + })) + : []; + const result = await createAutoCategory({ + kind: catalogType, + parentId, + caption: caption.trim(), + pageLayout: layout, + iconImage: iconImage, + enabled: enabled as "0" | "1", + visible: visible as "0" | "1", + offerRows, + }); + if (!result.ok) throw new Error(result.error ?? "Failed"); + const pageId = result.data?.pageId; + if (pageId == null) throw new Error("No page id returned"); + toast.success( + `Created "${caption.trim()}" with ${offerRows.length} items`, + ); + onCreated(pageId); + resetState(); + onOpenChange(false); + } catch (error) { + console.error("[AutoCategory] create failed:", error); + toast.error("Failed to create category"); + } finally { + setIsCreating(false); + } + }; + + const resetState = () => { + setMode("furni"); + setQuery(""); + setDebounced(""); + setSelected([]); + setCaption(""); + setIconImage(0); + setLayout("default_3x3"); + setEnabled("1"); + setVisible("1"); + setCaptionTouched(false); + setIconTouched(false); + setLayoutTouched(false); + setFreeMode(true); + setCredits(0); + setPoints(0); + }; + + return ( + + + + + + {t("title")} + {selected.length > 0 && ( + + {t("itemsBadge", { count: selected.length })} + + )} + + + +
+ {/* Left: form */} +
+ {/* Mode */} +
+ + +
+ + {/* Furni search + picker */} + {mode === "furni" && ( +
+
+ + setQuery(e.target.value)} + className="h-8 pl-9 text-xs" + /> + {searchLoading && ( + + )} +
+ + {debounced.length >= 2 && ( +
+ {results.length === 0 && !searchLoading ? ( +

+ {t("noResults")} +

+ ) : ( + results.map((r) => { + const isSelected = selected.some((x) => x.id === r.id); + return ( + + ); + }) + )} +
+ )} + + {selected.length > 0 && ( +
+ {selected.map((f) => ( + + ))} +
+ )} +
+ )} + + {/* Price */} + {mode === "furni" && ( +
+
+ {t("price")} + + +
+ {!freeMode && ( +
+ + + + {t("pointsType")} + + +
+ )} +
+ )} + + {/* Parent + settings */} +
+
+ {t("parent")} + +
+ +
+ {t("caption")} +
+ { + setCaption(e.target.value); + setCaptionTouched(true); + }} + placeholder={t("captionPlaceholder")} + /> + {sug?.name && caption !== sug.name && ( + + )} +
+
+ +
+ {t("icon")} +
+ { + setIconImage(id); + setIconTouched(true); + }} + > + + + {sug && sug.icon > 0 && iconImage !== sug.icon && ( + + )} +
+
+ +
+ {t("layout")} +
+ {CATALOG_LAYOUTS.map((l) => { + const color = + LAYOUT_COLORS[l] || "bg-muted text-muted-foreground"; + return ( + + ); + })} +
+ {sug?.layout && layout !== sug.layout.layout && ( + + )} +
+ +
+ + +
+
+
+ + {/* Right: live preview */} +
+ setLayout(l as CatalogLayout)} + /> +
+
+ +
+ + +
+
+
+ ); +} + +function FurniThumb({ + item, + size = 24, +}: { + item: { itemName: string }; + size?: number; +}) { + const primary = getLocalIconUrl(item.itemName); + return ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {item.itemName} { + const img = e.target as HTMLImageElement; + img.style.opacity = "0"; + }} + loading="lazy" + /> +
+ ); +} diff --git a/src/components/admin/catalog-manager/auto-organize.tsx b/src/components/admin/catalog-manager/auto-organize.tsx new file mode 100644 index 00000000..b3e3a119 --- /dev/null +++ b/src/components/admin/catalog-manager/auto-organize.tsx @@ -0,0 +1,122 @@ +"use client"; + +import { ArrowDownUp, Loader2, Wand2 } from "lucide-react"; +import { useState } from "react"; +import { toast } from "sonner"; +import { reorderCatalogItems } from "@/actions/catalog-items"; +import type { CatalogItemData } from "@/app/admin/catalog/[id]/catalog-items-table/types"; +import { Button } from "@/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + buildSortOrders, + SORT_STRATEGIES, + type SortStrategy, + sortCatalogItems, + suggestCatalogLayout, +} from "@/features/catalog/domain/organize"; + +interface AutoOrganizeProps { + items: CatalogItemData[]; + currentLayout: string; + canEdit?: boolean; + onSorted: () => void; + onApplyLayout: (layout: string) => void; +} + +/** + * One-click organizational helpers: pick a smart sort order and apply it, + * or apply the suggested layout for this page's offers. + */ +export function AutoOrganize({ + items, + currentLayout, + canEdit, + onSorted, + onApplyLayout, +}: AutoOrganizeProps) { + const [strategy, setStrategy] = useState("name-asc"); + const [sorting, setSorting] = useState(false); + + const suggestion = items.length > 0 ? suggestCatalogLayout(items) : null; + const showSuggestion = + suggestion !== null && suggestion.layout !== currentLayout; + + const disabled = !canEdit || items.length === 0; + + async function handleSmartSort() { + if (!canEdit || items.length === 0 || sorting) return; + setSorting(true); + try { + const ordered = sortCatalogItems(items, strategy); + const orders = buildSortOrders(ordered); + const result = await reorderCatalogItems({ orders }); + if (!result.ok) throw new Error("Failed to sort"); + toast.success( + `Sorted ${items.length} offers (${SORT_STRATEGIES.find((s) => s.id === strategy)?.label})`, + ); + onSorted(); + } catch { + toast.error("Failed to sort offers"); + } finally { + setSorting(false); + } + } + + return ( +
+ + + Auto + + + + {showSuggestion && suggestion && canEdit && ( + + )} +
+ ); +} diff --git a/src/components/admin/catalog-manager/catalog-manager-dialog.tsx b/src/components/admin/catalog-manager/catalog-manager-dialog.tsx index c65601e6..5b2d3e7f 100644 --- a/src/components/admin/catalog-manager/catalog-manager-dialog.tsx +++ b/src/components/admin/catalog-manager/catalog-manager-dialog.tsx @@ -1,6 +1,6 @@ "use client"; -import { LayoutGrid, Search, X } from "lucide-react"; +import { LayoutGrid, Search, Wand2, X } from "lucide-react"; import { useTranslations } from "next-intl"; import { useCallback, useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; @@ -15,6 +15,7 @@ import { useLatestRequest } from "@/features/catalog/client/use-latest-request"; import { CatalogSearch } from "@/features/catalog/components/catalog-search"; import type { CatalogSearchResult } from "@/features/catalog/domain/search"; import type { RootTab } from "@/types/catalog"; +import { AutoCategoryDialog } from "./auto-category-dialog"; import { BreadcrumbBar } from "./breadcrumb-bar"; import { InlineEditor, type PageUpdateData } from "./inline-editor"; import { RootTabs } from "./root-tabs"; @@ -165,6 +166,7 @@ function CatalogManagerInner({ const rootRequests = useLatestRequest(); const searchNavigation = useLatestRequest(); const [searchOpen, setSearchOpen] = useState(false); + const [autoOpen, setAutoOpen] = useState(false); const [searchTarget, setSearchTarget] = useState<{ offerId: number | null; key: number; @@ -399,6 +401,23 @@ function CatalogManagerInner({ {heading}
+ {canEdit && catalogType === "normal" && ( + + )}
- - - +
+ {catalogType === "normal" && ( + + )} + + + +
{/* Dirty banner */} @@ -438,394 +460,404 @@ function InlineEditorSession({ )} - {/* Tabs */} - { - if (tab === activeTab) return; - if (offersDirty && !window.confirm(tActions("leaveUnsaved"))) return; - setOffersDirty(false); - setActiveTab(tab); - }} - className="flex-1 flex flex-col min-h-0" - > - - - - Settings - {isDirty && ( - - )} - - - - Items - {itemsData && ( - - {itemsData.items.length} - - )} - - - - Translate - {itemsData && - (() => { - const furniSet = new Set(itemsData.furniDataIdList); - const missing = itemsData.baseItems.filter( - (b) => !furniSet.has(b.id), - ).length; - return missing > 0 ? ( - - {missing} - - ) : null; - })()} - - - - {/* ── Settings tab ──────────────────────────────── */} - + { + if (tab === activeTab) return; + if (offersDirty && !window.confirm(tActions("leaveUnsaved"))) + return; + setOffersDirty(false); + setActiveTab(tab); + }} + className="flex-1 flex flex-col min-h-0" > -
- {/* Caption + Layout */} -
-
- - update("caption", e.target.value)} - className="h-8 text-sm" - /> -
-
- - -

- {LAYOUT_DESCRIPTIONS[ - String(form.pageLayout) as CatalogLayout - ] ?? "Custom layout"} -

-
-
- - {/* Order + Rank + Icon */} -
-
- - - update("orderNum", parseInt(e.target.value, 10) || 0) - } - className="h-8 text-sm" - /> -
- {catalogType === "normal" && ( -
- - - update("minRank", parseInt(e.target.value, 10) || 1) - } - className="h-8 text-sm" - /> -
- )} -
- - update("iconImage", v)} - /> -
-
- - {/* Toggles */} -
- update("enabled", v ? "1" : "0")} - /> - update("visible", v ? "1" : "0")} - /> - {catalogType === "normal" && ( - update("clubOnly", v ? "1" : "0")} - /> - )} -
- - {/* Headline image picker */} -
- - update("pageHeadline", v)} - type="header" - label="Headline image" - /> - -
- - {/* Teaser image picker */} -
- - update("pageTeaser", v)} - type="teaser" - label="Teaser image" - /> - -
- - {/* Collapsible text fields */} - + + + + Translate + {itemsData && + (() => { + const furniSet = new Set(itemsData.furniDataIdList); + const missing = itemsData.baseItems.filter( + (b) => !furniSet.has(b.id), + ).length; + return missing > 0 ? ( + + {missing} + + ) : null; + })()} + + - {showTexts && ( -
+ {/* ── Settings tab ──────────────────────────────── */} + +
+ {/* Caption + Layout */} +
- -