feat(catalog): add auto-category wizard with live preview and smart suggestions
- Add AutoCategoryDialog: pick furni (or empty page), suggest caption/icon/layout, live preview - Add createAutoCategory server action (page + offers in one export) with CatalogKind - Extend furni search API with interactionType - Share ShopTile and refactor inline-editor/items-shop-preview to use it - Add suggestion heuristics (suggestCategoryName/dIcon/layout) with tests - Add autoCategory translations (en/nl)
This commit is contained in:
1 parent
baeb54aeb5
commit
f832479e52
14 files changed
+2577
-567
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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 } };
|
||||
});
|
||||
}
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in new issue
Block a user