feat(catalog): add auto-category wizard with live preview and smart suggestions
CI / check (push) Successful in 3m1s
CI / deploy (push) Successful in 2m7s
CI / publish-container (push) Successful in 45s

- 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)
This commit is contained in:
openhands committed 2026-09-11 18:47:31 +02:00
1 parent baeb54aeb5
commit f832479e52
14 files changed
+2577 -567

No files matched your search

@@ -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<FurniResult[]>([]);
const [searchLoading, setSearchLoading] = useState(false);
const [selected, setSelected] = useState<FurniResult[]>([]);
const inputRef = useRef<HTMLInputElement>(null);
const [parentId, setParentId] = useState<number>(-1);
const [caption, setCaption] = useState("");
const [iconImage, setIconImage] = useState(0);
const [layout, setLayout] = useState<CatalogLayout>("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<CatalogItemData[]>(() => {
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="flex h-[90vh] max-h-[90vh] w-[95vw] max-w-[95vw] flex-col overflow-hidden p-0">
<DialogHeader className="flex-none border-b px-4 py-3">
<DialogTitle className="flex items-center gap-2 text-sm font-semibold">
<Wand2 className="h-4 w-4" />
{t("title")}
{selected.length > 0 && (
<Badge variant="secondary" className="text-[10px]">
{t("itemsBadge", { count: selected.length })}
</Badge>
)}
</DialogTitle>
</DialogHeader>
<div className="flex min-h-0 flex-1 overflow-hidden">
{/* Left: form */}
<div className="flex min-w-0 flex-1 flex-col gap-4 overflow-y-auto p-4">
{/* Mode */}
<div className="flex gap-1">
<Button
type="button"
variant={mode === "furni" ? "default" : "outline"}
size="sm"
className="h-7 text-xs"
onClick={() => setMode("furni")}
>
{t("fromFurni")}
</Button>
<Button
type="button"
variant={mode === "empty" ? "default" : "outline"}
size="sm"
className="h-7 text-xs"
onClick={() => {
setMode("empty");
setSelected([]);
}}
>
{t("emptyPage")}
</Button>
</div>
{/* Furni search + picker */}
{mode === "furni" && (
<div className="space-y-3 rounded-md border bg-[var(--admin-surface)] p-3">
<div className="relative">
<Search className="absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
ref={inputRef}
placeholder={t("searchPlaceholder")}
value={query}
onChange={(e) => setQuery(e.target.value)}
className="h-8 pl-9 text-xs"
/>
{searchLoading && (
<Loader2 className="absolute right-2.5 top-1/2 h-4 w-4 -translate-y-1/2 animate-spin text-muted-foreground" />
)}
</div>
{debounced.length >= 2 && (
<div className="max-h-[140px] divide-y overflow-y-auto rounded border">
{results.length === 0 && !searchLoading ? (
<p className="py-4 text-center text-xs text-muted-foreground">
{t("noResults")}
</p>
) : (
results.map((r) => {
const isSelected = selected.some((x) => x.id === r.id);
return (
<button
key={r.id}
type="button"
onClick={() => toggleFurni(r)}
className={cn(
"flex w-full items-center gap-2 px-2 py-1.5 text-left hover:bg-accent",
isSelected && "bg-accent",
)}
>
<FurniThumb item={r} />
<div className="min-w-0 flex-1">
<p className="truncate text-xs font-medium">
{r.publicName || (
<span className="italic text-muted-foreground">
{r.itemName}
</span>
)}
</p>
<p className="truncate font-mono text-[10px] text-muted-foreground">
#{r.id} · {r.type} · {r.interactionType}
</p>
</div>
{isSelected && (
<Badge
variant="secondary"
className="h-4 text-[9px]"
>
{t("added")}
</Badge>
)}
</button>
);
})
)}
</div>
)}
{selected.length > 0 && (
<div className="flex max-h-[80px] flex-wrap gap-1 overflow-y-auto">
{selected.map((f) => (
<button
key={f.id}
type="button"
onClick={() => toggleFurni(f)}
className="group flex items-center gap-1 rounded bg-[var(--admin-accent-subtle)] px-1.5 py-0.5 text-[10px]"
>
<FurniThumb item={f} size={14} />
{f.publicName || f.itemName}
<X className="h-2.5 w-2.5 text-muted-foreground group-hover:text-destructive" />
</button>
))}
</div>
)}
</div>
)}
{/* Price */}
{mode === "furni" && (
<div className="space-y-2 rounded-md border bg-[var(--admin-surface)] p-3">
<div className="flex items-center gap-2 text-xs font-medium text-[var(--admin-text-muted)]">
{t("price")}
<Button
type="button"
variant={freeMode ? "default" : "outline"}
size="sm"
className="h-6 px-2 text-[10px]"
onClick={() => setFreeMode(true)}
>
{t("free")}
</Button>
<Button
type="button"
variant={!freeMode ? "default" : "outline"}
size="sm"
className="h-6 px-2 text-[10px]"
onClick={() => setFreeMode(false)}
>
{t("setPrice")}
</Button>
</div>
{!freeMode && (
<div className="flex gap-2 text-xs">
<label className="flex items-center gap-1 text-[10px] text-muted-foreground">
{t("credits")}
<input
type="number"
className="w-16 rounded border bg-[var(--admin-canvas)] px-1.5 py-1 text-xs"
value={credits}
onChange={(e) => setCredits(Number(e.target.value))}
min={0}
/>
</label>
<label className="flex items-center gap-1 text-[10px] text-muted-foreground">
{t("points")}
<input
type="number"
className="w-16 rounded border bg-[var(--admin-canvas)] px-1.5 py-1 text-xs"
value={points}
onChange={(e) => setPoints(Number(e.target.value))}
min={0}
/>
</label>
<span className="flex items-center gap-1 text-[10px] text-muted-foreground">
{t("pointsType")}
<Select
value={String(pointsType)}
onValueChange={(v) => setPointsType(Number(v))}
>
<SelectTrigger className="h-7 w-24 text-[10px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="0" className="text-xs">
Duckets
</SelectItem>
<SelectItem value="5" className="text-xs">
Diamonds
</SelectItem>
</SelectContent>
</Select>
</span>
</div>
)}
</div>
)}
{/* Parent + settings */}
<div className="space-y-3 rounded-md border bg-[var(--admin-surface)] p-3">
<div className="flex flex-col gap-1 text-xs font-medium text-[var(--admin-text-muted)]">
{t("parent")}
<Select
value={String(parentId)}
onValueChange={(v) => setParentId(Number(v))}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{parentOptions.map((p) => (
<SelectItem
key={p.id}
value={String(p.id)}
className="text-xs"
>
<span className="flex items-center gap-1.5">
{p.id === -1 ? (
<Home className="h-3 w-3 shrink-0" />
) : (
<Folder className="h-3 w-3 shrink-0" />
)}
{p.id === -1 ? t("root") : p.caption}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1 text-xs font-medium text-[var(--admin-text-muted)]">
{t("caption")}
<div className="flex gap-1">
<Input
className="h-8 text-xs"
value={caption}
onChange={(e) => {
setCaption(e.target.value);
setCaptionTouched(true);
}}
placeholder={t("captionPlaceholder")}
/>
{sug?.name && caption !== sug.name && (
<Button
type="button"
variant="outline"
size="sm"
className="h-8 shrink-0 px-2 text-[10px]"
onClick={() => {
setCaption(sug.name ?? "");
setCaptionTouched(false);
}}
>
{sug.name}
</Button>
)}
</div>
</div>
<div className="flex flex-col gap-1 text-xs font-medium text-[var(--admin-text-muted)]">
{t("icon")}
<div className="flex items-center gap-1.5">
<IconPicker
value={iconImage}
onChange={(id) => {
setIconImage(id);
setIconTouched(true);
}}
>
<Button
type="button"
variant="outline"
size="sm"
className="h-8 gap-1.5 px-2"
>
<CatalogIcon iconImage={iconImage} size={18} />
<span className="font-mono text-[10px] text-muted-foreground">
{iconImage > 0 ? `#${iconImage}` : t("pickIcon")}
</span>
</Button>
</IconPicker>
{sug && sug.icon > 0 && iconImage !== sug.icon && (
<Button
type="button"
variant="outline"
size="sm"
className="h-8 px-2 text-[10px]"
onClick={() => {
setIconImage(sug.icon);
setIconTouched(false);
}}
>
<Wand2 className="mr-0.5 h-2.5 w-2.5" />
{sug.icon}
</Button>
)}
</div>
</div>
<div className="flex flex-col gap-1 text-xs font-medium text-[var(--admin-text-muted)]">
{t("layout")}
<div className="flex flex-wrap gap-1">
{CATALOG_LAYOUTS.map((l) => {
const color =
LAYOUT_COLORS[l] || "bg-muted text-muted-foreground";
return (
<button
key={l}
type="button"
onClick={() => {
setLayout(l);
setLayoutTouched(true);
}}
className={cn(
"rounded border px-1.5 py-0.5 font-mono text-[10px] transition-colors",
layout === l
? cn(color, "border-current ring-1 ring-current/20")
: "border-[var(--admin-border)] bg-[var(--admin-canvas)] text-[var(--admin-text-muted)] hover:border-current",
)}
>
{l.replace("default_3x3", "3x3")}
</button>
);
})}
</div>
{sug?.layout && layout !== sug.layout.layout && (
<Button
type="button"
variant="ghost"
size="sm"
className="h-6 w-fit border border-[var(--admin-warning-border)] bg-[var(--admin-warning-subtle)] px-2 text-[10px] text-[var(--admin-warning)]"
onClick={() => {
setLayout(sug.layout ? sug.layout.layout : layout);
setLayoutTouched(false);
}}
>
<Wand2 className="mr-0.5 h-2.5 w-2.5" />
{t("suggestedLayout", { layout: sug.layout.layout })}
</Button>
)}
</div>
<div className="flex gap-2">
<label className="flex items-center gap-1.5 text-[10px] text-muted-foreground">
<input
type="checkbox"
checked={enabled === "1"}
onChange={(e) => setEnabled(e.target.checked ? "1" : "0")}
/>
{t("enabled")}
</label>
<label className="flex items-center gap-1.5 text-[10px] text-muted-foreground">
<input
type="checkbox"
checked={visible === "1"}
onChange={(e) => setVisible(e.target.checked ? "1" : "0")}
/>
{t("visible")}
</label>
</div>
</div>
</div>
{/* Right: live preview */}
<div className="flex w-[340px] shrink-0 flex-col overflow-y-auto border-l">
<LiveCatalogPreview
form={{
caption,
pageLayout: layout,
iconImage,
enabled,
visible,
}}
items={previewItems}
loadingItems={false}
onApplyLayout={(l) => setLayout(l as CatalogLayout)}
/>
</div>
</div>
<div className="flex flex-none justify-end gap-2 border-t px-4 py-2.5">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => onOpenChange(false)}
>
{t("cancel")}
</Button>
<Button
type="button"
size="sm"
className="gap-1"
disabled={isCreating || !caption.trim()}
onClick={() => void handleCreate()}
>
{isCreating ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<Plus className="h-3 w-3" />
)}
{selected.length > 0
? t("createWithItems", { count: selected.length })
: t("create")}
</Button>
</div>
</DialogContent>
</Dialog>
);
}
function FurniThumb({
item,
size = 24,
}: {
item: { itemName: string };
size?: number;
}) {
const primary = getLocalIconUrl(item.itemName);
return (
<div
className="flex items-center justify-center rounded bg-muted/50"
style={{ width: size, height: size }}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={primary}
alt={item.itemName}
className="max-h-full max-w-full object-contain"
style={{ imageRendering: "pixelated" }}
onError={(e) => {
const img = e.target as HTMLImageElement;
img.style.opacity = "0";
}}
loading="lazy"
/>
</div>
);
}
@@ -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<SortStrategy>("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 (
<div className="flex flex-wrap items-center gap-1.5 rounded-md border bg-[var(--admin-surface)] px-2 py-1.5">
<span className="inline-flex items-center gap-1 text-[11px] font-medium text-[var(--admin-text-muted)]">
<Wand2 className="h-3 w-3" />
Auto
</span>
<Select
value={strategy}
onValueChange={(v) => setStrategy(v as SortStrategy)}
disabled={disabled}
>
<SelectTrigger className="h-7 w-36 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{SORT_STRATEGIES.map((s) => (
<SelectItem key={s.id} value={s.id} className="text-xs">
{s.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
type="button"
variant="outline"
size="sm"
className="h-7 gap-1 text-xs"
disabled={disabled || sorting}
onClick={() => void handleSmartSort()}
>
{sorting ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<ArrowDownUp className="h-3 w-3" />
)}
Sort
</Button>
{showSuggestion && suggestion && canEdit && (
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 gap-1 border border-[var(--admin-warning-border)] bg-[var(--admin-warning-subtle)] text-[11px] text-[var(--admin-warning)] hover:bg-[var(--admin-warning-subtle)]"
onClick={() => onApplyLayout(suggestion.layout)}
>
<Wand2 className="h-3 w-3" />
Use {suggestion.layout} layout
</Button>
)}
</div>
);
}
@@ -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}
</h2>
<div className="flex items-center gap-1">
{canEdit && catalogType === "normal" && (
<Button
variant="outline"
size="sm"
aria-label={t("autoCategory.title")}
title={t("autoCategory.title")}
onClick={() => {
if (!mayLeave()) return;
setAutoOpen(true);
}}
>
<Wand2 className="size-4" />
<span className="hidden sm:inline">
{t("autoCategory.title")}
</span>
</Button>
)}
<Button
variant="outline"
size="sm"
@@ -437,6 +456,16 @@ function CatalogManagerInner({
</div>
)}
<AutoCategoryDialog
catalogType={catalogType}
open={autoOpen}
onOpenChange={setAutoOpen}
onCreated={(pageId) => {
void loadRootPages();
handleSelectPage(pageId);
}}
/>
<BreadcrumbBar onSelectPage={handleSelectPage} />
<RootTabs
@@ -7,6 +7,7 @@ import {
ExternalLink,
Languages,
Loader2,
MonitorPlay,
Package,
Save,
Settings,
@@ -24,8 +25,9 @@ import { CatalogTranslateTab } from "@/app/admin/catalog/[id]/catalog-translate-
import { BulkImportItems } from "@/components/admin/catalog/bulk-import-items";
import { CatalogImagePicker } from "@/components/admin/catalog/catalog-image-picker";
import { IconPicker } from "@/components/admin/catalog/icon-picker";
import { ItemsShopPreview } from "@/components/admin/catalog/items-shop-preview";
import { QuickAddFurni } from "@/components/admin/catalog/quick-add-furni";
import { AutoOrganize } from "@/components/admin/catalog-manager/auto-organize";
import { LiveCatalogPreview } from "@/components/admin/catalog-manager/live-catalog-preview";
import { CatalogIcon, LAYOUT_COLORS } from "@/components/admin/catalog-tree";
import Link from "@/components/link";
import { Badge } from "@/components/ui/badge";
@@ -144,7 +146,7 @@ function InlineEditorSession({
const [activeTab, setActiveTab] = useState(
initialOfferId ? "items" : "settings",
);
const [showPreview, setShowPreview] = useState(false);
const [showPreview, setShowPreview] = useState(catalogType === "normal");
// Items/translate data (lazy loaded)
const [itemsData, setItemsData] = useState<ItemsData | null>(null);
@@ -266,11 +268,13 @@ function InlineEditorSession({
}
}, [pageId, loadPage, initialOfferId]);
// Lazy-load items (shop catalog only — BC items live on the detail page)
// Lazy-load items (shop catalog only — BC items live on the detail page).
// Also eager-loads while the live preview panel is open so the preview
// always reflects the page's offers.
useEffect(() => {
if (catalogType === "bc") return;
if (
(activeTab === "items" || activeTab === "translate") &&
(activeTab === "items" || activeTab === "translate" || showPreview) &&
!itemsLoaded &&
!itemsLoading &&
pageId &&
@@ -285,6 +289,7 @@ function InlineEditorSession({
pageId,
loadItemsData,
catalogType,
showPreview,
]);
function update(key: string, value: string | number | null) {
@@ -404,16 +409,33 @@ function InlineEditorSession({
</p>
</div>
</div>
<Link href={pageHref} target="_blank">
<Button
variant="ghost"
size="sm"
className="h-7 text-xs gap-1 text-[var(--admin-text)]"
>
<ExternalLink className="h-3 w-3" />
{t("fullEditor")}
</Button>
</Link>
<div className="flex items-center gap-1">
{catalogType === "normal" && (
<Button
variant="ghost"
size="sm"
className={cn(
"h-7 gap-1 text-xs",
showPreview &&
"border border-[var(--admin-info-border)] bg-[var(--admin-info-subtle)] text-[var(--admin-info)]",
)}
onClick={() => setShowPreview((v) => !v)}
>
<MonitorPlay className="h-3 w-3" />
{t("preview.title")}
</Button>
)}
<Link href={pageHref} target="_blank">
<Button
variant="ghost"
size="sm"
className="h-7 text-xs gap-1 text-[var(--admin-text)]"
>
<ExternalLink className="h-3 w-3" />
{t("fullEditor")}
</Button>
</Link>
</div>
</div>
{/* Dirty banner */}
@@ -438,394 +460,404 @@ function InlineEditorSession({
</div>
)}
{/* Tabs */}
<Tabs
value={activeTab}
onValueChange={(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"
>
<TabsList className="mx-4 mt-2 shrink-0">
<TabsTrigger value="settings" className="gap-1.5 text-xs">
<Settings className="h-3.5 w-3.5" />
Settings
{isDirty && (
<span className="ml-0.5 h-1.5 w-1.5 rounded-full bg-[var(--admin-warning)]" />
)}
</TabsTrigger>
<TabsTrigger value="items" className="gap-1.5 text-xs">
<Package className="h-3.5 w-3.5" />
Items
{itemsData && (
<Badge variant="secondary" className="h-4 px-1 text-[9px] ml-0.5">
{itemsData.items.length}
</Badge>
)}
</TabsTrigger>
<TabsTrigger value="translate" className="gap-1.5 text-xs">
<Languages className="h-3.5 w-3.5" />
Translate
{itemsData &&
(() => {
const furniSet = new Set(itemsData.furniDataIdList);
const missing = itemsData.baseItems.filter(
(b) => !furniSet.has(b.id),
).length;
return missing > 0 ? (
<span className="ml-0.5 rounded-full bg-[var(--admin-warning)] text-[var(--admin-warning-foreground)] text-[9px] px-1 py-0.5 leading-none font-medium">
{missing}
</span>
) : null;
})()}
</TabsTrigger>
</TabsList>
{/* ── Settings tab ──────────────────────────────── */}
<TabsContent
value="settings"
className="flex-1 overflow-y-auto px-4 pb-4 mt-2"
{/* Editor + live preview */}
<div className="flex min-h-0 flex-1">
<Tabs
value={activeTab}
onValueChange={(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"
>
<div className="space-y-4">
{/* Caption + Layout */}
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1.5">
<Label className="text-xs">Caption</Label>
<Input
value={String(form.caption || "")}
onChange={(e) => update("caption", e.target.value)}
className="h-8 text-sm"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Layout</Label>
<Select
value={String(form.pageLayout || "")}
onValueChange={(v) => update("pageLayout", v)}
<TabsList className="mx-4 mt-2 shrink-0">
<TabsTrigger value="settings" className="gap-1.5 text-xs">
<Settings className="h-3.5 w-3.5" />
Settings
{isDirty && (
<span className="ml-0.5 h-1.5 w-1.5 rounded-full bg-[var(--admin-warning)]" />
)}
</TabsTrigger>
<TabsTrigger value="items" className="gap-1.5 text-xs">
<Package className="h-3.5 w-3.5" />
Items
{itemsData && (
<Badge
variant="secondary"
className="h-4 px-1 text-[9px] ml-0.5"
>
<SelectTrigger className="h-8 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{CATALOG_LAYOUTS.map((l) => {
const color =
LAYOUT_COLORS[l] || "bg-muted text-muted-foreground";
return (
<SelectItem key={l} value={l}>
<span className="flex items-center gap-2">
<span
className={cn(
"inline-block h-2 w-2 rounded-full",
color,
)}
/>
{l}
</span>
</SelectItem>
);
})}
</SelectContent>
</Select>
<p className="text-[10px] text-muted-foreground">
{LAYOUT_DESCRIPTIONS[
String(form.pageLayout) as CatalogLayout
] ?? "Custom layout"}
</p>
</div>
</div>
{/* Order + Rank + Icon */}
<div className="grid gap-3 sm:grid-cols-3">
<div className="space-y-1.5">
<Label className="text-xs">Order</Label>
<Input
type="number"
value={form.orderNum as number}
onChange={(e) =>
update("orderNum", parseInt(e.target.value, 10) || 0)
}
className="h-8 text-sm"
/>
</div>
{catalogType === "normal" && (
<div className="space-y-1.5">
<Label className="text-xs">Min Rank</Label>
<Input
type="number"
value={form.minRank as number}
onChange={(e) =>
update("minRank", parseInt(e.target.value, 10) || 1)
}
className="h-8 text-sm"
/>
</div>
)}
<div className="space-y-1.5">
<Label className="text-xs">Icon</Label>
<IconPicker
value={(form.iconImage as number) ?? 0}
onChange={(v) => update("iconImage", v)}
/>
</div>
</div>
{/* Toggles */}
<div className={styles.options}>
<ToggleRow
id="ie-enabled"
disabled={!canEdit || saving}
label={t("access.enabled")}
description={t("access.enabledHint")}
checked={form.enabled === "1"}
onChange={(v) => update("enabled", v ? "1" : "0")}
/>
<ToggleRow
id="ie-visible"
disabled={!canEdit || saving}
label={t("access.visible")}
description={t("access.visibleHint")}
checked={form.visible === "1"}
onChange={(v) => update("visible", v ? "1" : "0")}
/>
{catalogType === "normal" && (
<ToggleRow
id="ie-club"
disabled={!canEdit || saving}
label={t("access.club")}
description={t("access.clubHint")}
checked={form.clubOnly === "1"}
onChange={(v) => update("clubOnly", v ? "1" : "0")}
/>
)}
</div>
{/* Headline image picker */}
<div className="space-y-1.5">
<Label className="text-xs">Headline</Label>
<CatalogImagePicker
value={String(form.pageHeadline || "")}
onChange={(v) => update("pageHeadline", v)}
type="header"
label="Headline image"
/>
<ImagePreview
value={String(form.pageHeadline || "")}
height={50}
/>
</div>
{/* Teaser image picker */}
<div className="space-y-1.5">
<Label className="text-xs">Teaser</Label>
<CatalogImagePicker
value={String(form.pageTeaser || "")}
onChange={(v) => update("pageTeaser", v)}
type="teaser"
label="Teaser image"
/>
<ImagePreview value={String(form.pageTeaser || "")} height={50} />
</div>
{/* Collapsible text fields */}
<button
type="button"
onClick={() => setShowTexts(!showTexts)}
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
{showTexts ? (
<ChevronDown className="h-3.5 w-3.5" />
) : (
<ChevronRight className="h-3.5 w-3.5" />
)}
Text fields
{(form.pageText1 || form.pageText2 || form.pageTextDetails) && (
<Badge variant="secondary" className="h-4 px-1 text-[9px]">
has content
{itemsData.items.length}
</Badge>
)}
</button>
</TabsTrigger>
<TabsTrigger value="translate" className="gap-1.5 text-xs">
<Languages className="h-3.5 w-3.5" />
Translate
{itemsData &&
(() => {
const furniSet = new Set(itemsData.furniDataIdList);
const missing = itemsData.baseItems.filter(
(b) => !furniSet.has(b.id),
).length;
return missing > 0 ? (
<span className="ml-0.5 rounded-full bg-[var(--admin-warning)] text-[var(--admin-warning-foreground)] text-[9px] px-1 py-0.5 leading-none font-medium">
{missing}
</span>
) : null;
})()}
</TabsTrigger>
</TabsList>
{showTexts && (
<div className="space-y-3 pl-2 border-l-2 border-border/40">
{/* ── Settings tab ──────────────────────────────── */}
<TabsContent
value="settings"
className="flex-1 overflow-y-auto px-4 pb-4 mt-2"
>
<div className="space-y-4">
{/* Caption + Layout */}
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1.5">
<Label className="text-xs">Text 1</Label>
<Textarea
rows={2}
value={String(form.pageText1 || "")}
onChange={(e) =>
update("pageText1", e.target.value || null)
}
className="text-sm"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Text 2</Label>
<Textarea
rows={2}
value={String(form.pageText2 || "")}
onChange={(e) =>
update("pageText2", e.target.value || null)
}
className="text-sm"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Text Details</Label>
<Textarea
rows={2}
value={String(form.pageTextDetails || "")}
onChange={(e) =>
update("pageTextDetails", e.target.value || null)
}
className="text-sm"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Special</Label>
<Label className="text-xs">Caption</Label>
<Input
value={String(form.pageSpecial || "")}
value={String(form.caption || "")}
onChange={(e) => update("caption", e.target.value)}
className="h-8 text-sm"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Layout</Label>
<Select
value={String(form.pageLayout || "")}
onValueChange={(v) => update("pageLayout", v)}
>
<SelectTrigger className="h-8 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{CATALOG_LAYOUTS.map((l) => {
const color =
LAYOUT_COLORS[l] || "bg-muted text-muted-foreground";
return (
<SelectItem key={l} value={l}>
<span className="flex items-center gap-2">
<span
className={cn(
"inline-block h-2 w-2 rounded-full",
color,
)}
/>
{l}
</span>
</SelectItem>
);
})}
</SelectContent>
</Select>
<p className="text-[10px] text-muted-foreground">
{LAYOUT_DESCRIPTIONS[
String(form.pageLayout) as CatalogLayout
] ?? "Custom layout"}
</p>
</div>
</div>
{/* Order + Rank + Icon */}
<div className="grid gap-3 sm:grid-cols-3">
<div className="space-y-1.5">
<Label className="text-xs">Order</Label>
<Input
type="number"
value={form.orderNum as number}
onChange={(e) =>
update("pageSpecial", e.target.value || null)
update("orderNum", parseInt(e.target.value, 10) || 0)
}
className="h-8 text-sm"
/>
</div>
</div>
)}
{/* Save button */}
<div className="pt-2">
<Button
onClick={handleSave}
disabled={saving || !isDirty || !canEdit}
className="w-full"
>
{saving ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
{tNavigation("save")}
</Button>
</div>
</div>
</TabsContent>
{/* ── Items tab ─────────────────────────────────── */}
<TabsContent
value="items"
className="flex-1 overflow-y-auto px-4 pb-4 mt-2"
>
{catalogType === "bc" ? (
<div className="rounded-md border border-dashed px-4 py-8 text-center text-sm text-muted-foreground space-y-3">
<p>BC items are managed on the full Builder Club page editor.</p>
<Link href={pageHref} target="_blank">
<Button variant="outline" size="sm" className="gap-1">
<ExternalLink className="h-3 w-3" />
Open BC page editor
</Button>
</Link>
</div>
) : itemsError && !itemsData ? (
<div className="flex items-center justify-center py-12">
<Button variant="outline" onClick={reloadItems}>
{t("retryItems")}
</Button>
</div>
) : itemsLoading && !itemsData ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : itemsData ? (
<div className="space-y-3">
{/* Toolbar */}
<div className="flex items-center gap-2 flex-wrap">
{canEdit && (
<>
<QuickAddFurni
pageId={page.id}
furniRevisionMap={itemsData.furniRevisionMap}
{catalogType === "normal" && (
<div className="space-y-1.5">
<Label className="text-xs">Min Rank</Label>
<Input
type="number"
value={form.minRank as number}
onChange={(e) =>
update("minRank", parseInt(e.target.value, 10) || 1)
}
className="h-8 text-sm"
/>
<BulkImportItems
pageId={page.id}
onImported={reloadItems}
/>
</>
</div>
)}
<Button
variant="outline"
size="sm"
onClick={() => setShowPreview(!showPreview)}
className="ml-auto"
>
{showPreview ? "Hide Preview" : "Shop Preview"}
</Button>
<div className="space-y-1.5">
<Label className="text-xs">Icon</Label>
<IconPicker
value={(form.iconImage as number) ?? 0}
onChange={(v) => update("iconImage", v)}
/>
</div>
</div>
{/* Shop preview (togglable) */}
{showPreview && (
<ItemsShopPreview
items={itemsData.items}
furniRevisionMap={itemsData.furniRevisionMap}
layout={page.pageLayout}
canEdit={canEdit}
{/* Toggles */}
<div className={styles.options}>
<ToggleRow
id="ie-enabled"
disabled={!canEdit || saving}
label={t("access.enabled")}
description={t("access.enabledHint")}
checked={form.enabled === "1"}
onChange={(v) => update("enabled", v ? "1" : "0")}
/>
<ToggleRow
id="ie-visible"
disabled={!canEdit || saving}
label={t("access.visible")}
description={t("access.visibleHint")}
checked={form.visible === "1"}
onChange={(v) => update("visible", v ? "1" : "0")}
/>
{catalogType === "normal" && (
<ToggleRow
id="ie-club"
disabled={!canEdit || saving}
label={t("access.club")}
description={t("access.clubHint")}
checked={form.clubOnly === "1"}
onChange={(v) => update("clubOnly", v ? "1" : "0")}
/>
)}
</div>
{/* Headline image picker */}
<div className="space-y-1.5">
<Label className="text-xs">Headline</Label>
<CatalogImagePicker
value={String(form.pageHeadline || "")}
onChange={(v) => update("pageHeadline", v)}
type="header"
label="Headline image"
/>
<ImagePreview
value={String(form.pageHeadline || "")}
height={50}
/>
</div>
{/* Teaser image picker */}
<div className="space-y-1.5">
<Label className="text-xs">Teaser</Label>
<CatalogImagePicker
value={String(form.pageTeaser || "")}
onChange={(v) => update("pageTeaser", v)}
type="teaser"
label="Teaser image"
/>
<ImagePreview
value={String(form.pageTeaser || "")}
height={50}
/>
</div>
{/* Collapsible text fields */}
<button
type="button"
onClick={() => setShowTexts(!showTexts)}
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
{showTexts ? (
<ChevronDown className="h-3.5 w-3.5" />
) : (
<ChevronRight className="h-3.5 w-3.5" />
)}
Text fields
{(form.pageText1 || form.pageText2 || form.pageTextDetails) && (
<Badge variant="secondary" className="h-4 px-1 text-[9px]">
has content
</Badge>
)}
</button>
{showTexts && (
<div className="space-y-3 pl-2 border-l-2 border-border/40">
<div className="space-y-1.5">
<Label className="text-xs">Text 1</Label>
<Textarea
rows={2}
value={String(form.pageText1 || "")}
onChange={(e) =>
update("pageText1", e.target.value || null)
}
className="text-sm"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Text 2</Label>
<Textarea
rows={2}
value={String(form.pageText2 || "")}
onChange={(e) =>
update("pageText2", e.target.value || null)
}
className="text-sm"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Text Details</Label>
<Textarea
rows={2}
value={String(form.pageTextDetails || "")}
onChange={(e) =>
update("pageTextDetails", e.target.value || null)
}
className="text-sm"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Special</Label>
<Input
value={String(form.pageSpecial || "")}
onChange={(e) =>
update("pageSpecial", e.target.value || null)
}
className="h-8 text-sm"
/>
</div>
</div>
)}
{/* Items table */}
<CatalogItemsTable
initialSearch={
initialOfferId ? `#${initialOfferId}` : undefined
}
onDirtyChange={setOffersDirty}
onRefresh={reloadItems}
items={itemsData.items}
pageId={page.id}
pageLayout={page.pageLayout}
canEdit={canEdit}
interactionTypes={itemsData.interactionTypes}
furniRevisionMap={itemsData.furniRevisionMap}
allPages={itemsData.allPages}
/>
{/* Save button */}
<div className="pt-2">
<Button
onClick={handleSave}
disabled={saving || !isDirty || !canEdit}
className="w-full"
>
{saving ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
{tNavigation("save")}
</Button>
</div>
</div>
) : null}
</TabsContent>
</TabsContent>
{/* ── Translate tab ─────────────────────────────── */}
<TabsContent
value="translate"
className="flex-1 overflow-y-auto px-4 pb-4 mt-2"
>
{catalogType === "bc" ? (
<div className="rounded-md border border-dashed px-4 py-8 text-center text-sm text-muted-foreground">
Translate tools are available for the shop catalog only.
</div>
) : itemsError && !itemsData ? (
<div className="flex items-center justify-center py-12">
<Button variant="outline" onClick={reloadItems}>
{t("retryItems")}
</Button>
</div>
) : itemsLoading && !itemsData ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : itemsData ? (
<CatalogTranslateTab
baseItems={itemsData.baseItems}
catalogNameMap={itemsData.catalogNameMap}
canEdit={canEdit}
furniDataIdList={itemsData.furniDataIdList}
furniDescriptionMap={itemsData.furniDescriptionMap}
furniRevisionMap={itemsData.furniRevisionMap}
gamedataHotel={itemsData.gamedataHotel}
/>
) : null}
</TabsContent>
</Tabs>
{/* ── Items tab ─────────────────────────────────── */}
<TabsContent
value="items"
className="flex-1 overflow-y-auto px-4 pb-4 mt-2"
>
{catalogType === "bc" ? (
<div className="rounded-md border border-dashed px-4 py-8 text-center text-sm text-muted-foreground space-y-3">
<p>
BC items are managed on the full Builder Club page editor.
</p>
<Link href={pageHref} target="_blank">
<Button variant="outline" size="sm" className="gap-1">
<ExternalLink className="h-3 w-3" />
Open BC page editor
</Button>
</Link>
</div>
) : itemsError && !itemsData ? (
<div className="flex items-center justify-center py-12">
<Button variant="outline" onClick={reloadItems}>
{t("retryItems")}
</Button>
</div>
) : itemsLoading && !itemsData ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : itemsData ? (
<div className="space-y-3">
{/* Toolbar */}
<div className="flex items-center gap-2 flex-wrap">
{canEdit && (
<>
<QuickAddFurni
pageId={page.id}
furniRevisionMap={itemsData.furniRevisionMap}
/>
<BulkImportItems
pageId={page.id}
onImported={reloadItems}
/>
</>
)}
<AutoOrganize
items={itemsData.items}
currentLayout={page.pageLayout}
canEdit={canEdit}
onSorted={reloadItems}
onApplyLayout={(layout) => update("pageLayout", layout)}
/>
</div>
{/* Items table */}
<CatalogItemsTable
initialSearch={
initialOfferId ? `#${initialOfferId}` : undefined
}
onDirtyChange={setOffersDirty}
onRefresh={reloadItems}
items={itemsData.items}
pageId={page.id}
pageLayout={page.pageLayout}
canEdit={canEdit}
interactionTypes={itemsData.interactionTypes}
furniRevisionMap={itemsData.furniRevisionMap}
allPages={itemsData.allPages}
/>
</div>
) : null}
</TabsContent>
{/* ── Translate tab ─────────────────────────────── */}
<TabsContent
value="translate"
className="flex-1 overflow-y-auto px-4 pb-4 mt-2"
>
{catalogType === "bc" ? (
<div className="rounded-md border border-dashed px-4 py-8 text-center text-sm text-muted-foreground">
Translate tools are available for the shop catalog only.
</div>
) : itemsError && !itemsData ? (
<div className="flex items-center justify-center py-12">
<Button variant="outline" onClick={reloadItems}>
{t("retryItems")}
</Button>
</div>
) : itemsLoading && !itemsData ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : itemsData ? (
<CatalogTranslateTab
baseItems={itemsData.baseItems}
catalogNameMap={itemsData.catalogNameMap}
canEdit={canEdit}
furniDataIdList={itemsData.furniDataIdList}
furniDescriptionMap={itemsData.furniDescriptionMap}
furniRevisionMap={itemsData.furniRevisionMap}
gamedataHotel={itemsData.gamedataHotel}
/>
) : null}
</TabsContent>
</Tabs>
{catalogType === "normal" && showPreview && (
<LiveCatalogPreview
form={form}
items={itemsData?.items ?? null}
furniRevisionMap={itemsData?.furniRevisionMap ?? {}}
loadingItems={itemsLoading}
onApplyLayout={(layout) => update("pageLayout", layout)}
/>
)}
</div>
</div>
);
}
@@ -0,0 +1,280 @@
"use client";
import {
AlertTriangle,
Eye,
EyeOff,
Loader2,
MonitorPlay,
PackageX,
Wand2,
} from "lucide-react";
import { useTranslations } from "next-intl";
import type { CatalogItemData } from "@/app/admin/catalog/[id]/catalog-items-table/types";
import {
getShopGridClass,
getShopTileSize,
parseFirstItemId,
ShopTile,
} from "@/components/admin/catalog/shop-tile";
import { ImagePreview } from "@/components/admin/catalog-manager/image-preview";
import { CatalogIcon, LAYOUT_COLORS } from "@/components/admin/catalog-tree";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { suggestCatalogLayout } from "@/features/catalog/domain/organize";
import { cn } from "@/lib/utils";
interface LivePageForm {
caption?: string | number | null;
pageLayout?: string | number | null;
pageHeadline?: string | number | null;
pageTeaser?: string | number | null;
pageText1?: string | number | null;
pageText2?: string | number | null;
pageTextDetails?: string | number | null;
pageSpecial?: string | number | null;
pageTextTeaser?: string | number | null;
iconImage?: string | number | null;
enabled?: string | number | null;
visible?: string | number | null;
}
interface LiveCatalogPreviewProps {
form: LivePageForm;
items?: CatalogItemData[] | null;
furniRevisionMap?: Record<number, { classname: string; revision: number }>;
loadingItems?: boolean;
className?: string;
onApplyLayout?: (layout: string) => void;
}
/**
* Always-live shop preview of a catalog page: reflects the current editor
* form state (title, layout, images, texts) plus the page's offers so the
* staff member sees exactly how the page is organized for players.
*/
export function LiveCatalogPreview({
form,
items = [],
furniRevisionMap = {},
loadingItems,
className,
onApplyLayout,
}: LiveCatalogPreviewProps) {
const t = useTranslations("pages.admin.catalog.workspace.preview");
const layout = String(form.pageLayout || "default_3x3");
const caption = String(form.caption || "");
const isEnabled = String(form.enabled ?? "1") === "1";
const isVisible = String(form.visible ?? "1") === "1";
const itemsList = items ?? [];
const suggestion =
itemsList.length > 0 ? suggestCatalogLayout(itemsList) : null;
const showSuggestion =
suggestion !== null && suggestion.layout !== layout && onApplyLayout;
const gridClass = cn(
"grid gap-2",
layout === "frontpage" ||
layout === "single_bundle" ||
layout === "room_bundle"
? "grid-cols-1"
: getShopGridClass(layout).includes("grid-cols-1")
? "grid-cols-1"
: "grid-cols-2",
);
const tileSize = Math.min(getShopTileSize(layout), 88);
return (
<aside
aria-label={t("label")}
className={cn(
"flex min-h-0 w-80 shrink-0 flex-col overflow-hidden border-l border-[var(--admin-border)] bg-[var(--admin-canvas)] text-[var(--admin-text)]",
className,
)}
>
{/* Panel header */}
<div className="flex shrink-0 items-center justify-between gap-2 border-b border-[var(--admin-border)] bg-[var(--admin-surface)] px-3 py-2">
<div className="flex items-center gap-1.5 text-xs font-semibold">
<MonitorPlay className="h-3.5 w-3.5" />
{t("title")}
</div>
<div className="flex items-center gap-1">
<PreviewStatusDot on={isEnabled}>
{t(isEnabled ? "on" : "off")}
</PreviewStatusDot>
<PreviewStatusDot on={isVisible}>
{isVisible ? (
<Eye className="h-2.5 w-2.5" />
) : (
<EyeOff className="h-2.5 w-2.5" />
)}
</PreviewStatusDot>
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-3">
{/* Auto-organize hints */}
{showSuggestion && suggestion && (
<div className="mb-3 flex items-start gap-2 rounded-md border border-[var(--admin-warning-border)] bg-[var(--admin-warning-subtle)] p-2 text-[11px] text-[var(--admin-warning)]">
<Wand2 className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<div className="min-w-0 flex-1">
<p className="font-medium">{t("suggestedLayout")}</p>
<p className="break-words text-[var(--admin-text-muted)]">
{t("suggestedLayoutReason", {
layout: suggestion.layout,
reason: suggestion.reason,
})}
</p>
<Button
type="button"
variant="outline"
size="sm"
className="mt-1.5 h-6 text-[11px] px-2"
onClick={() => onApplyLayout(suggestion.layout)}
>
{t("applyLayout")}
</Button>
</div>
</div>
)}
{!isEnabled && (
<div className="mb-3 flex items-center gap-2 rounded-md border border-[var(--admin-warning-border)] bg-[var(--admin-warning-subtle)] p-2 text-[11px] text-[var(--admin-warning)]">
<AlertTriangle className="h-3.5 w-3.5 shrink-0" />
{t("disabledHint")}
</div>
)}
{/* Game-catalog window */}
<div className="overflow-hidden rounded-lg border border-[var(--admin-border)] bg-[var(--admin-canvas)] shadow-sm">
{/* Window title bar */}
<div className="flex items-center gap-2 border-b border-[var(--admin-border)] bg-[var(--admin-surface)] px-2.5 py-1.5">
<CatalogIcon iconImage={Number(form.iconImage) || 0} size={16} />
<span className="min-w-0 flex-1 truncate text-xs font-semibold">
{caption || t("untitled")}
</span>
<Badge
className={cn(
"h-4 border-0 px-1 font-mono text-[8px]",
LAYOUT_COLORS[layout] || "bg-muted text-muted-foreground",
)}
>
{layout.replace("default_3x3", "3x3")}
</Badge>
</div>
{/* Main body */}
<div
className="p-2.5"
style={{
backgroundColor:
"var(--admin-surface-muted, var(--admin-canvas))",
}}
>
{/* Headline banner */}
{String(form.pageHeadline || "") && (
<div className="mb-2">
<ImagePreview
value={String(form.pageHeadline)}
height={56}
className="w-full rounded"
/>
</div>
)}
{/* Teaser row */}
{String(form.pageTeaser || "") && (
<div className="mb-2">
<ImagePreview
value={String(form.pageTeaser)}
height={48}
className="w-full rounded"
/>
</div>
)}
{/* Text blocks */}
{String(form.pageText1 || "") && (
<p className="mb-1 text-[11px] leading-snug break-words" key="p1">
{String(form.pageText1)}
</p>
)}
{String(form.pageText2 || "") && (
<p
className="mb-1 text-[11px] text-[var(--admin-text-muted)] leading-snug break-words"
key="p2"
>
{String(form.pageText2)}
</p>
)}
{String(form.pageTextDetails || "") && (
<p
className="mb-2 text-[10px] text-[var(--admin-text-muted)] break-words"
key="pd"
>
{String(form.pageTextDetails)}
</p>
)}
{/* Offers */}
{loadingItems && !items ? (
<div className="flex items-center justify-center py-10">
<Loader2 className="h-5 w-5 animate-spin text-[var(--admin-text-muted)]" />
</div>
) : itemsList.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-md border-2 border-dashed border-[var(--admin-border)] py-8 text-center">
<PackageX className="h-7 w-7 text-[var(--admin-text-muted)]/40" />
<p className="mt-1.5 text-[11px] text-[var(--admin-text-muted)]">
{t("noItems")}
</p>
</div>
) : (
<>
<div className={gridClass}>
{itemsList.map((item) => {
const firstId = parseFirstItemId(item.itemIds);
const furni =
firstId != null ? furniRevisionMap[firstId] : undefined;
return (
<ShopTile
key={item.id}
item={item}
furni={furni}
iconHeight={tileSize}
/>
);
})}
</div>
<p className="mt-2 text-center text-[10px] text-[var(--admin-text-muted)]">
{itemsList.length} {t("items")}
</p>
</>
)}
</div>
</div>
</div>
</aside>
);
}
function PreviewStatusDot({
on,
children,
}: {
on: boolean;
children: React.ReactNode;
}) {
return (
<span
className={cn(
"inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 text-[9px] font-medium leading-none",
on
? "bg-[var(--admin-success-subtle)] text-[var(--admin-success)]"
: "bg-[var(--admin-error-subtle)] text-[var(--admin-error)]",
)}
>
{children}
</span>
);
}
@@ -1,17 +1,14 @@
"use client";
import { Crown, Image as ImageIcon, Package, Sparkles } from "lucide-react";
import {
type CatalogItemData,
getPointsLabel,
} from "@/app/admin/catalog/[id]/catalog-items-table/types";
import {
CurrencyIcon,
currencyKindFromPointsType,
} from "@/components/shared/currency-icon";
import { Badge } from "@/components/ui/badge";
import { getHabboCdnIconUrl, getLocalIconUrl } from "@/lib/furni/classname";
import { Package } from "lucide-react";
import type { CatalogItemData } from "@/app/admin/catalog/[id]/catalog-items-table/types";
import { cn } from "@/lib/utils";
import {
getShopGridClass,
getShopTileSize,
parseFirstItemId,
ShopTile,
} from "./shop-tile";
interface ItemsShopPreviewProps {
items: CatalogItemData[];
@@ -39,8 +36,8 @@ export function ItemsShopPreview({
);
}
const gridClass = getGridClass(layout);
const tileSize = getTileSize(layout);
const gridClass = getShopGridClass(layout);
const tileSize = getShopTileSize(layout);
return (
<div className="rounded-lg border bg-gradient-to-b from-[var(--admin-surface)] to-[var(--admin-canvas)] p-4">
@@ -52,183 +49,19 @@ export function ItemsShopPreview({
{items.map((item) => {
const firstId = parseFirstItemId(item.itemIds);
const furni = firstId != null ? furniRevisionMap[firstId] : undefined;
const points = getPointsLabel(item.pointsType);
const isClub = item.clubOnly === "1";
const isOffer = item.haveOffer === "1" && item.offerId > 0;
return (
<button
<ShopTile
key={item.id}
type="button"
onClick={() => onItemClick?.(item)}
disabled={!canEdit && !onItemClick}
className={cn(
"group relative flex flex-col overflow-hidden rounded-md border bg-card shadow-sm transition-all",
canEdit &&
"hover:border-primary hover:shadow-md hover:scale-[1.02] cursor-pointer",
!canEdit && "cursor-default",
)}
title={item.catalogName || item.baseName}
>
{/* Tile icon */}
<div
className="relative flex items-center justify-center bg-gradient-to-br from-muted/30 to-muted/60"
style={{ height: tileSize }}
>
<ShopFurniIcon
classname={furni?.classname ?? ""}
revision={furni?.revision ?? 0}
size={Math.floor(tileSize * 0.7)}
/>
{/* Badges overlay */}
<div className="absolute right-1 top-1 flex flex-col gap-0.5">
{isClub && (
<Badge className="h-4 gap-0.5 border-0 bg-[var(--admin-warning-subtle)] px-1 text-[8px] text-[var(--admin-warning)]">
<Crown className="h-2.5 w-2.5" />
HC
</Badge>
)}
{isOffer && (
<Badge className="h-4 gap-0.5 border-0 bg-[var(--admin-error)] px-1 text-[8px] text-[var(--admin-error-foreground)]">
<Sparkles className="h-2.5 w-2.5" />
OFFER
</Badge>
)}
</div>
{/* Amount badge */}
{item.amount > 1 && (
<Badge className="absolute left-1 top-1 h-4 border-0 bg-[var(--admin-surface)] px-1 text-[9px] text-[var(--admin-text)]">
x{item.amount}
</Badge>
)}
{/* Limited */}
{item.limitedStack > 0 && (
<Badge className="absolute bottom-1 left-1 h-4 border-0 bg-[var(--admin-accent)] px-1 text-[8px] text-[var(--admin-accent-foreground)]">
LTD {item.limitedSells}/{item.limitedStack}
</Badge>
)}
</div>
{/* Name + price */}
<div className="border-t bg-background/95 px-2 py-1.5">
<p className="truncate text-xs font-medium leading-tight">
{item.catalogName || item.baseName || `#${item.id}`}
</p>
<div className="mt-0.5 flex items-center gap-1.5 text-[10px] font-mono">
{item.costCredits > 0 && (
<span className="inline-flex items-center gap-0.5 text-[var(--admin-warning)] dark:text-[var(--admin-warning)]">
<CurrencyIcon kind="credits" size={12} />
{item.costCredits}
</span>
)}
{item.costPoints > 0 && (
<span
className={`inline-flex items-center gap-0.5 ${points.color}`}
>
<CurrencyIcon
kind={currencyKindFromPointsType(item.pointsType)}
size={12}
/>
{item.costPoints}
</span>
)}
{item.costCredits === 0 && item.costPoints === 0 && (
<span className="text-muted-foreground">Free</span>
)}
</div>
</div>
</button>
item={item}
furni={furni}
iconHeight={tileSize}
canEdit={canEdit}
onClick={onItemClick}
/>
);
})}
</div>
</div>
);
}
// ── Helpers ─────────────────────────────────────────────────────
function parseFirstItemId(itemIds: string): number | null {
if (!itemIds) return null;
const first = itemIds.split(";")[0]?.trim();
const n = parseInt(first, 10);
return Number.isNaN(n) ? null : n;
}
function getGridClass(layout: string): string {
switch (layout) {
case "spaces_new":
return "grid-cols-2 sm:grid-cols-3 md:grid-cols-4";
case "frontpage":
return "grid-cols-1 sm:grid-cols-2 md:grid-cols-3";
case "single_bundle":
case "room_bundle":
return "grid-cols-1 sm:grid-cols-2";
case "pets":
case "pets2":
case "pets3":
return "grid-cols-2 sm:grid-cols-3 md:grid-cols-4";
default:
return "grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5";
}
}
function getTileSize(layout: string): number {
switch (layout) {
case "frontpage":
case "single_bundle":
case "room_bundle":
return 140;
case "spaces_new":
return 110;
default:
return 96;
}
}
// ── Shop tile furni icon (resizable) ────────────────────────────
function ShopFurniIcon({
classname,
revision,
size,
}: {
classname: string;
revision: number;
size: number;
}) {
if (!classname) {
return (
<ImageIcon
className="text-muted-foreground/40"
style={{ width: size * 0.5, height: size * 0.5 }}
/>
);
}
const primary = getLocalIconUrl(classname);
function handleError(e: React.SyntheticEvent<HTMLImageElement>) {
const img = e.target as HTMLImageElement;
if (!img.dataset.fallback && revision > 0) {
img.dataset.fallback = "cdn";
img.src = getHabboCdnIconUrl(classname, revision);
} else {
img.style.opacity = "0";
}
}
return (
// eslint-disable-next-line @next/next/no-img-element
<img
src={primary}
alt={classname}
className="object-contain"
style={{ imageRendering: "pixelated", maxWidth: size, maxHeight: size }}
onError={handleError}
loading="lazy"
/>
);
}
+205
View File
@@ -0,0 +1,205 @@
"use client";
import { Crown, Package, Sparkles } from "lucide-react";
import type { CatalogItemData } from "@/app/admin/catalog/[id]/catalog-items-table/types";
import { getPointsLabel } from "@/app/admin/catalog/[id]/catalog-items-table/types";
import {
CurrencyIcon,
currencyKindFromPointsType,
} from "@/components/shared/currency-icon";
import { Badge } from "@/components/ui/badge";
import { getHabboCdnIconUrl, getLocalIconUrl } from "@/lib/furni/classname";
import { cn } from "@/lib/utils";
/** Grid + tile sizing per page layout, shared by shop previews. */
export function getShopGridClass(layout: string): string {
switch (layout) {
case "spaces_new":
return "grid-cols-2 sm:grid-cols-3 md:grid-cols-4";
case "frontpage":
return "grid-cols-1 sm:grid-cols-2 md:grid-cols-3";
case "single_bundle":
case "room_bundle":
return "grid-cols-1 sm:grid-cols-2";
case "pets":
case "pets2":
case "pets3":
return "grid-cols-2 sm:grid-cols-3 md:grid-cols-4";
default:
return "grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5";
}
}
export function getShopTileSize(layout: string): number {
switch (layout) {
case "frontpage":
case "single_bundle":
case "room_bundle":
return 140;
case "spaces_new":
return 110;
default:
return 96;
}
}
export function parseFirstItemId(itemIds: string): number | null {
if (!itemIds) return null;
const first = itemIds.split(";")[0]?.trim();
const n = parseInt(first, 10);
return Number.isNaN(n) ? null : n;
}
interface ShopTileProps {
item: CatalogItemData;
furni?: { classname: string; revision: number } | null;
/** Pixel height of the icon area. Defaults to a small standard tile. */
iconHeight?: number;
canEdit?: boolean;
onClick?: (item: CatalogItemData) => void;
}
/** One purchasable offer tile exactly as it appears in the shop. */
export function ShopTile({
item,
furni,
iconHeight = 96,
canEdit,
onClick,
}: ShopTileProps) {
const points = getPointsLabel(item.pointsType);
const isClub = item.clubOnly === "1";
const isOffer = item.haveOffer === "1" && item.offerId > 0;
const isLimited = item.limitedStack > 0;
return (
<button
type="button"
onClick={() => onClick?.(item)}
disabled={!canEdit && !onClick}
className={cn(
"group relative flex flex-col overflow-hidden rounded-md border bg-card shadow-sm transition-all",
canEdit &&
"hover:border-primary hover:shadow-md hover:scale-[1.02] cursor-pointer",
!canEdit && "cursor-default",
)}
title={item.catalogName || item.baseName}
>
{/* Tile icon */}
<div
className="relative flex items-center justify-center bg-gradient-to-br from-muted/30 to-muted/60"
style={{ height: iconHeight }}
>
<ShopFurniIcon
classname={furni?.classname ?? ""}
revision={furni?.revision ?? 0}
size={Math.floor(iconHeight * 0.7)}
/>
{/* Badges overlay */}
<div className="absolute right-1 top-1 flex flex-col gap-0.5">
{isClub && (
<Badge className="h-4 gap-0.5 border-0 bg-[var(--admin-warning-subtle)] px-1 text-[8px] text-[var(--admin-warning)]">
<Crown className="h-2.5 w-2.5" />
HC
</Badge>
)}
{isOffer && (
<Badge className="h-4 gap-0.5 border-0 bg-[var(--admin-error)] px-1 text-[8px] text-[var(--admin-error-foreground)]">
<Sparkles className="h-2.5 w-2.5" />
OFFER
</Badge>
)}
</div>
{/* Amount badge */}
{item.amount > 1 && (
<Badge className="absolute left-1 top-1 h-4 border-0 bg-[var(--admin-surface)] px-1 text-[9px] text-[var(--admin-text)]">
x{item.amount}
</Badge>
)}
{/* Limited */}
{isLimited && (
<Badge className="absolute bottom-1 left-1 h-4 border-0 bg-[var(--admin-accent)] px-1 text-[8px] text-[var(--admin-accent-foreground)]">
LTD {item.limitedSells}/{item.limitedStack}
</Badge>
)}
</div>
{/* Name + price */}
<div className="border-t bg-background/95 px-2 py-1.5">
<p className="truncate text-xs font-medium leading-tight">
{item.catalogName || item.baseName || `#${item.id}`}
</p>
<div className="mt-0.5 flex items-center gap-1.5 text-[10px] font-mono">
{item.costCredits > 0 && (
<span className="inline-flex items-center gap-0.5 text-[var(--admin-warning)] dark:text-[var(--admin-warning)]">
<CurrencyIcon kind="credits" size={12} />
{item.costCredits}
</span>
)}
{item.costPoints > 0 && (
<span
className={`inline-flex items-center gap-0.5 ${points.color}`}
>
<CurrencyIcon
kind={currencyKindFromPointsType(item.pointsType)}
size={12}
/>
{item.costPoints}
</span>
)}
{item.costCredits === 0 && item.costPoints === 0 && (
<span className="text-muted-foreground">Free</span>
)}
</div>
</div>
</button>
);
}
// ── Shop tile furni icon (resizable) ────────────────────────────
export function ShopFurniIcon({
classname,
revision,
size,
}: {
classname: string;
revision: number;
size: number;
}) {
if (!classname) {
return (
<Package
className="text-muted-foreground/40"
style={{ width: size * 0.5, height: size * 0.5 }}
/>
);
}
const primary = getLocalIconUrl(classname);
function handleError(e: React.SyntheticEvent<HTMLImageElement>) {
const img = e.target as HTMLImageElement;
if (!img.dataset.fallback && revision > 0) {
img.dataset.fallback = "cdn";
img.src = getHabboCdnIconUrl(classname, revision);
} else {
img.style.opacity = "0";
}
}
return (
// eslint-disable-next-line @next/next/no-img-element
<img
src={primary}
alt={classname}
className="object-contain"
style={{ imageRendering: "pixelated", maxWidth: size, maxHeight: size }}
onError={handleError}
loading="lazy"
/>
);
}