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

+1 -1
View File
@@ -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;
+139
View File
@@ -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<ActionResult<{ pageId: number }>> {
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 } };
});
}
+2
View File
@@ -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",
})),
});
},
@@ -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"
/>
);
}
@@ -0,0 +1,229 @@
import { describe, expect, it } from "vitest";
import type { CatalogItemData } from "@/app/admin/catalog/[id]/catalog-items-table/types";
import {
buildSortOrders,
type SortStrategy,
type SugFurni,
sortCatalogItems,
suggestCatalogIcon,
suggestCatalogLayout,
suggestCatalogLayoutFromBaseItems,
suggestCategoryName,
} from "./organize";
function offer(overrides: Partial<CatalogItemData> = {}): CatalogItemData {
return {
id: 1,
catalogName: "",
itemIds: "1",
costCredits: 0,
costPoints: 0,
pointsType: 0,
amount: 1,
limitedSells: 0,
limitedStack: 0,
orderNumber: 0,
offerId: -1,
songId: 0,
haveOffer: "1",
clubOnly: "0",
extradata: "",
baseName: "",
baseItemName: "",
spriteId: 0,
baseItem: null,
...overrides,
};
}
const sample = [
offer({ id: 3, catalogName: "Zebra", costCredits: 10, limitedStack: 0 }),
offer({ id: 1, catalogName: "Apple", costCredits: 5, limitedStack: 0 }),
offer({ id: 2, catalogName: "Mango", costCredits: 5, limitedStack: 50 }),
];
describe("sortCatalogItems", () => {
it("sorts by name ascending", () => {
const sorted = sortCatalogItems(sample, "name-asc");
expect(sorted.map((i) => i.id)).toEqual([1, 2, 3]);
});
it("sorts by name descending", () => {
const sorted = sortCatalogItems(sample, "name-desc");
expect(sorted.map((i) => i.id)).toEqual([3, 2, 1]);
});
it("sorts by price then id", () => {
const sorted = sortCatalogItems(sample, "price-asc");
expect(sorted.map((i) => i.id)).toEqual([1, 2, 3]);
});
it("sorts expensive first", () => {
const sorted = sortCatalogItems(sample, "price-desc");
expect(sorted.map((i) => i.id)).toEqual([3, 1, 2]);
});
it("keeps limited editions first", () => {
const sorted = sortCatalogItems(sample, "limited-first");
expect(sorted.map((i) => i.id)).toEqual([2, 1, 3]);
});
it("keeps offers first", () => {
const input = [
offer({ id: 1, catalogName: "a", haveOffer: "0" }),
offer({ id: 2, catalogName: "b", haveOffer: "1" }),
];
const sorted = sortCatalogItems(input, "offer-first");
expect(sorted.map((i) => i.id)).toEqual([2, 1]);
});
it("sorts amount descending", () => {
const input = [
offer({ id: 1, catalogName: "a", amount: 1 }),
offer({ id: 2, catalogName: "b", amount: 10 }),
];
const sorted = sortCatalogItems(input, "amount-desc");
expect(sorted.map((i) => i.id)).toEqual([2, 1]);
});
it("is stable for unknown strategies", () => {
const sorted = sortCatalogItems(sample, "name-asc" as SortStrategy);
const re = sortCatalogItems([...sorted].reverse(), "name-asc");
expect(re.map((i) => i.id)).toEqual([1, 2, 3]);
});
});
describe("buildSortOrders", () => {
it("assigns sequential order numbers", () => {
const orders = buildSortOrders([sample[2], sample[0]]);
expect(orders).toEqual([
{ id: 2, orderNumber: 0 },
{ id: 3, orderNumber: 1 },
]);
});
});
describe("suggestCatalogLayout", () => {
it("returns null for empty lists", () => {
expect(suggestCatalogLayout([])).toBeNull();
});
it("suggests soundmachine when songs present", () => {
const input = [
offer({ songId: 55, baseItem: { interactionType: "default" } as never }),
];
expect(suggestCatalogLayout(input)?.layout).toBe("soundmachine");
});
it("suggests pets when base item type is 'p'", () => {
const input = [offer({ baseItem: { type: "p" } as never })];
expect(suggestCatalogLayout(input)?.layout).toBe("pets");
});
it("suggests trophies for trophy interactions", () => {
const input = [offer({ baseItem: { interactionType: "trophy" } as never })];
expect(suggestCatalogLayout(input)?.layout).toBe("trophies");
});
it("suggests guilds for guild interactions", () => {
const input = [offer({ baseItem: { interactionType: "guild" } as never })];
expect(suggestCatalogLayout(input)?.layout).toBe("guilds");
});
it("returns null when no strong signal", () => {
const input = [
offer({ baseItem: { interactionType: "default" } as never }),
];
expect(suggestCatalogLayout(input)).toBeNull();
});
});
describe("suggestCatalogLayoutFromBaseItems", () => {
it("returns null for empty lists", () => {
expect(suggestCatalogLayoutFromBaseItems([])).toBeNull();
});
it("suggests soundmachine from interaction type", () => {
expect(
suggestCatalogLayoutFromBaseItems([{ interactionType: "soundmachine" }])
?.layout,
).toBe("soundmachine");
});
it("suggests pets from base type", () => {
expect(
suggestCatalogLayoutFromBaseItems([
{ type: "p", interactionType: "default" },
])?.layout,
).toBe("pets");
});
it("suggests trophies for trophy interactions", () => {
expect(
suggestCatalogLayoutFromBaseItems([{ interactionType: "trophy" }])
?.layout,
).toBe("trophies");
});
it("returns null when no strong signal", () => {
expect(
suggestCatalogLayoutFromBaseItems([{ interactionType: "default" }]),
).toBeNull();
});
});
function fum(itemName?: string, interactionType?: string): SugFurni {
return { itemName, interactionType };
}
describe("suggestCategoryName", () => {
it("returns null for empty lists", () => {
expect(suggestCategoryName([])).toBeNull();
});
it("derives a pluralized label from the dominant classname token", () => {
const input = [fum("chair_wooden"), fum("chair_oak"), fum("chair_polyfon")];
expect(suggestCategoryName(input)).toBe("Chairs");
});
it("keeps singular-only tokens singular", () => {
expect(suggestCategoryName([fum("wallpaper_leaf")])).toBe("Wallpaper");
expect(suggestCategoryName([fum("floor_plain")])).toBe("Floor");
});
it("prefers interaction labels over classname tokens", () => {
const input = [
fum("trophy_cup", "trophy"),
fum("trophy_crown", "trophy"),
fum("chair_oak", "default"),
];
expect(suggestCategoryName(input)).toBe("Trophies");
});
it("uses the last-resort mapping for known interactions", () => {
expect(
suggestCategoryName([fum("soundmachine_flat", "soundmachine")]),
).toBe("Sounds");
});
it("returns null when nothing can be derived", () => {
expect(suggestCategoryName([fum("")])).toBeNull();
});
});
describe("suggestCatalogIcon", () => {
it("returns 0 for unknown categories", () => {
expect(suggestCatalogIcon([fum("")])).toBe(0);
});
it("maps detected categories to a catalog icon id", () => {
expect(suggestCatalogIcon([fum("chair_oak")])).toBeGreaterThan(0);
expect(suggestCatalogIcon([fum("trophy_cup", "trophy")])).toBe(7);
});
it("is deterministic", () => {
const a = suggestCatalogIcon([fum("chair_oak"), fum("chair_oak")]);
const b = suggestCatalogIcon([fum("chair_oak"), fum("chair_oak")]);
expect(a).toBe(b);
});
});
+330
View File
@@ -0,0 +1,330 @@
import type { CatalogItemData } from "@/app/admin/catalog/[id]/catalog-items-table/types";
import type { CatalogLayout } from "@/lib/catalog-layouts";
export type SortStrategy =
| "name-asc"
| "name-desc"
| "price-asc"
| "price-desc"
| "limited-first"
| "offer-first"
| "amount-desc";
export const SORT_STRATEGIES: Array<{ id: SortStrategy; label: string }> = [
{ id: "name-asc", label: "Name A → Z" },
{ id: "name-desc", label: "Name Z → A" },
{ id: "price-asc", label: "Cheapest first" },
{ id: "price-desc", label: "Most expensive first" },
{ id: "limited-first", label: "Limited editions first" },
{ id: "offer-first", label: "Offers first" },
{ id: "amount-desc", label: "Largest stack first" },
];
function offerPrice(item: CatalogItemData): number {
return item.costCredits + item.costPoints;
}
function offerName(item: CatalogItemData): string {
return (item.catalogName || item.baseName || `#${item.id}`).toLowerCase();
}
function compareBook(a: string, b: string): number {
return a === b ? 0 : a < b ? -1 : 1;
}
/**
* Deterministically order a page's offers for a chosen strategy.
* Unstable collations are resolved by id so the result is reproducible.
*/
export function sortCatalogItems(
items: CatalogItemData[],
strategy: SortStrategy,
): CatalogItemData[] {
const sorted = [...items].sort((a, b) => {
switch (strategy) {
case "name-asc":
return compareBook(offerName(a), offerName(b)) || a.id - b.id;
case "name-desc":
return compareBook(offerName(b), offerName(a)) || a.id - b.id;
case "price-asc":
return offerPrice(a) - offerPrice(b) || a.id - b.id;
case "price-desc":
return offerPrice(b) - offerPrice(a) || a.id - b.id;
case "limited-first": {
const altd = a.limitedStack > 0 ? 0 : 1;
const bltd = b.limitedStack > 0 ? 0 : 1;
return altd - bltd || b.limitedStack - a.limitedStack || a.id - b.id;
}
case "offer-first": {
const aoff = a.haveOffer === "1" ? 0 : 1;
const boff = b.haveOffer === "1" ? 0 : 1;
return aoff - boff || a.id - b.id;
}
case "amount-desc":
return b.amount - a.amount || a.id - b.id;
default:
return a.id - b.id;
}
});
return sorted;
}
/** Emit `order_number` assignments (sequential from 0) for a new order. */
export function buildSortOrders(items: CatalogItemData[]): Array<{
id: number;
orderNumber: number;
}> {
return items.map((item, index) => ({
id: item.id,
orderNumber: index,
}));
}
export interface LayoutSuggestion {
layout: CatalogLayout;
reason: string;
}
/** Strong, deterministic signals only — never suggest when unsure. */
export function suggestCatalogLayout(
items: CatalogItemData[],
): LayoutSuggestion | null {
if (items.length === 0) return null;
const interactions = new Set(
items.map((i) => i.baseItem?.interactionType ?? "").filter(Boolean),
);
const types = new Set(items.map((i) => i.baseItem?.type ?? ""));
const anySong = items.some((i) => i.songId > 0);
return layoutFromSignals({ interactions, types, anySong });
}
function layoutFromSignals(params: {
interactions: Set<string>;
types: Set<string>;
anySong: boolean;
}): LayoutSuggestion | null {
const { interactions, types, anySong } = params;
if (anySong || interactions.has("soundmachine"))
return {
layout: "soundmachine",
reason: "contains songs / soundmachine items",
};
if (types.has("p")) return { layout: "pets", reason: "contains pets" };
if (interactions.has("pet"))
return { layout: "pets2", reason: "contains pet wear/pets" };
if (interactions.has("trophy"))
return { layout: "trophies", reason: "contains trophies" };
if (interactions.has("guild"))
return { layout: "guilds", reason: "contains guild items" };
return null;
}
/** Layout suggestion using base-item fields directly (for the auto-category wizard). */
export function suggestCatalogLayoutFromBaseItems(
items: Array<{ type?: string; interactionType?: string }>,
): LayoutSuggestion | null {
if (items.length === 0) return null;
const interactions = new Set(
items.map((i) => i.interactionType ?? "").filter(Boolean),
);
const types = new Set(items.map((i) => i.type ?? ""));
return layoutFromSignals({ interactions, types, anySong: false });
}
// ── Auto-category suggestion helpers ──────────────────────────
export interface SugFurni {
itemName?: string | null;
publicName?: string | null;
type?: string | null;
interactionType?: string | null;
}
const THEME_PREFIXES = new Set([
"a",
"habbo",
"hween",
"boo",
"xmas",
"easter",
"summer",
"santorini",
"marquee",
"val",
"bf",
"ny",
"lonely",
"hotel",
"expo",
"tiki",
"jungle",
"dino",
"factory",
"champions",
"f1",
"bubble",
"ice",
"beach",
"country",
"modern",
"elegant",
"gothic",
"fanti",
"rock",
"disco",
"spa",
"rainy",
"skel",
"vampire",
"ninja",
"pirate",
"royal",
"space",
"wild",
"woody",
"zen",
]);
const INTERACTION_LABELS: Record<string, string> = {
soundmachine: "Sounds",
jukebox: "Sounds",
trophy: "Trophies",
pet: "Pets",
guild: "Guilds",
dimmer: "Lighting",
bed: "Beds",
chair: "Chairs",
table: "Tables",
roller: "Rollers",
vending: "Vending",
poster: "Posters",
wallpaper: "Wallpaper",
floor: "Floor",
teleport: "Teleports",
dice: "Dice",
sticky: "Stickies",
football: "Football",
mannequin: "Clothing",
clothing: "Clothing",
magicbox: "Magic Items",
freeze: "Freeze",
furni: "Furni",
default: "",
};
const KEEP_SINGULAR = new Set([
"wallpaper",
"floor",
"poster",
"rug",
"sauna",
"teleport",
"clothing",
"pool",
"garden",
"park",
"city",
"hotel",
"surface",
"fx",
"clothing",
]);
const THEME_SUFFIXES = new Set([
"hween",
"xmas",
"easter",
"summer",
"val",
"bf",
"ny",
]);
function classToken(item: SugFurni): string | null {
const raw = (item.itemName || "").split("/")[0]?.trim() || "";
if (!raw) return null;
const first = raw.split("_")[0]?.toLowerCase();
if (!first || THEME_PREFIXES.has(first) || THEME_SUFFIXES.has(first))
return null;
return first;
}
function pluralizeLabel(token: string): string {
const lower = token.toLowerCase();
const cap = lower.charAt(0).toUpperCase() + lower.slice(1);
if (KEEP_SINGULAR.has(lower)) return cap;
if (/(?:s|ss|sh|ch|x|z)$/.test(lower) && lower.length > 3) return cap;
return `${cap}s`;
}
/**
* Best-effort English category label derived from a batch of base items.
* Never blocks — returns null when it can't determine anything useful.
*/
export function suggestCategoryName(furni: SugFurni[]): string | null {
if (furni.length === 0) return null;
const labelCounts = new Map<string, number>();
for (const f of furni) {
const inter = f.interactionType?.toLowerCase();
let label: string | undefined;
if (inter && inter !== "default") {
label = INTERACTION_LABELS[inter];
}
if (!label) {
const token = classToken(f);
if (token) label = pluralizeLabel(token);
}
if (label) labelCounts.set(label, (labelCounts.get(label) ?? 0) + 1);
}
const sorted = [...labelCounts.entries()].sort(
(a, b) => b[1] - a[1] || a[0].localeCompare(b[0]),
);
return sorted[0]?.[0] ?? null;
}
/**
* Curated mapping from category labels to catalog icon IDs.
* All IDs are known to exist in the standard gamedata icon set (1–326).
*/
const CATEGORY_ICON_MAP: Record<string, number> = {
Sounds: 6,
Pets: 8,
Trophies: 7,
Guilds: 3,
Wallpaper: 4,
Floor: 5,
Lighting: 9,
Posters: 2,
Chairs: 10,
Beds: 11,
Tables: 12,
Rollers: 13,
Vending: 14,
Teleports: 15,
Dice: 16,
Stickies: 17,
Football: 18,
Clothing: 19,
"Magic Items": 20,
Freeze: 21,
Furni: 22,
};
/**
* Suggest a catalog icon ID based on detected category.
* Returns 0 when it can't determine a sensible icon.
*/
export function suggestCatalogIcon(furni: SugFurni[]): number {
const name = suggestCategoryName(furni);
if (!name) return 0;
return CATEGORY_ICON_MAP[name] ?? 1;
}
+41
View File
@@ -2157,6 +2157,47 @@
"clubHint": "Restricts access to users with an HC subscription.",
"yes": "Yes",
"no": "No"
},
"preview": {
"label": "Live catalog preview",
"title": "Live preview",
"on": "On",
"off": "Off",
"untitled": "Untitled page",
"noItems": "No offers on this page yet",
"items": "offer(s)",
"disabledHint": "This page is disabled — players cannot see it.",
"suggestedLayout": "Better layout suggested",
"suggestedLayoutReason": "This page {reason}. Switch to “{layout}” to match players’ expectations.",
"applyLayout": "Apply layout"
},
"autoCategory": {
"title": "Auto-Category",
"itemsBadge": "{count} items",
"fromFurni": "From furni",
"emptyPage": "Empty page",
"searchPlaceholder": "Search furni by name or classname…",
"noResults": "No results",
"added": "Added",
"price": "Price",
"free": "Free",
"setPrice": "Set a price",
"credits": "Credits",
"points": "Points",
"pointsType": "Type",
"parent": "Parent",
"root": "Catalog root",
"caption": "Caption",
"captionPlaceholder": "e.g. Chairs, Wallpapers…",
"icon": "Icon",
"pickIcon": "Pick icon",
"layout": "Layout",
"suggestedLayout": "Suggested: {layout}",
"enabled": "Enabled",
"visible": "Visible",
"cancel": "Cancel",
"create": "Create",
"createWithItems": "Create ({count} items)"
}
},
"bulk": {
+41
View File
@@ -1925,6 +1925,47 @@
"clubHint": "Beperkt toegang tot gebruikers met een HC-abonnement.",
"yes": "Ja",
"no": "Nee"
},
"preview": {
"label": "Live catalogusvoorbeeld",
"title": "Live voorbeeld",
"on": "Aan",
"off": "Uit",
"untitled": "Naamloze pagina",
"noItems": "Nog geen aanbiedingen op deze pagina",
"items": "aanbieding(en)",
"disabledHint": "Deze pagina is uitgeschakeld — spelers zien haar niet.",
"suggestedLayout": "Betere indeling voorgesteld",
"suggestedLayoutReason": "Deze pagina {reason}. Schakel over naar “{layout}” zodat deze overeenkomt met wat spelers verwachten.",
"applyLayout": "Indeling toepassen"
},
"autoCategory": {
"title": "Auto-Categorie",
"itemsBadge": "{count} items",
"fromFurni": "Vanuit furni",
"emptyPage": "Lege pagina",
"searchPlaceholder": "Zoek furni op naam of classname…",
"noResults": "Geen resultaten",
"added": "Toegevoegd",
"price": "Prijs",
"free": "Gratis",
"setPrice": "Stel een prijs in",
"credits": "Credits",
"points": "Punten",
"pointsType": "Type",
"parent": "Bovenliggende categorie",
"root": "Catalogus-hoofdmap",
"caption": "Naam",
"captionPlaceholder": "bijv. Stoelen, Behang…",
"icon": "Icoon",
"pickIcon": "Kies icoon",
"layout": "Indeling",
"suggestedLayout": "Voorgesteld: {layout}",
"enabled": "Ingeschakeld",
"visible": "Zichtbaar",
"cancel": "Annuleren",
"create": "Aanmaken",
"createWithItems": "Aanmaken ({count} items)"
}
},
"bulk": {