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";
|
import { translateItemsSchema } from "@/lib/validators/catalog";
|
||||||
|
|
||||||
/** Raw INSERT — catalog_items.id has no AUTO_INCREMENT on real Habbo DBs; page_id is often VARCHAR. */
|
/** 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;
|
pageId: number;
|
||||||
itemIds: string;
|
itemIds: string;
|
||||||
catalogName: string;
|
catalogName: string;
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
|
import { inArray, sql } from "drizzle-orm";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { insertCatalogItemRow } from "@/actions/catalog-items";
|
||||||
import { catalogFailure } from "@/features/catalog/server/errors";
|
import { catalogFailure } from "@/features/catalog/server/errors";
|
||||||
import {
|
import {
|
||||||
createPageCommand,
|
createPageCommand,
|
||||||
@@ -11,6 +13,7 @@ import {
|
|||||||
} from "@/features/catalog/server/page-commands";
|
} from "@/features/catalog/server/page-commands";
|
||||||
import { sendCatalogUpdate } from "@/features/catalog/server/sync-status";
|
import { sendCatalogUpdate } from "@/features/catalog/server/sync-status";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
|
import { db, ItemsBase } from "@/lib/db";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import type { ActionResult } from "@/lib/safe-action-shared";
|
import type { ActionResult } from "@/lib/safe-action-shared";
|
||||||
import { withCatalogExport } from "@/lib/services/catalog-git-queue";
|
import { withCatalogExport } from "@/lib/services/catalog-git-queue";
|
||||||
@@ -208,3 +211,139 @@ export async function reorderCatalogPages(input: {
|
|||||||
return { ok: true as const, data: {} };
|
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,
|
publicName: ItemsBase.publicName,
|
||||||
type: ItemsBase.type,
|
type: ItemsBase.type,
|
||||||
spriteId: ItemsBase.spriteId,
|
spriteId: ItemsBase.spriteId,
|
||||||
|
interactionType: ItemsBase.interactionType,
|
||||||
})
|
})
|
||||||
.from(ItemsBase)
|
.from(ItemsBase)
|
||||||
.where(or(...conditions))
|
.where(or(...conditions))
|
||||||
@@ -56,6 +57,7 @@ export const GET = withAdmin(
|
|||||||
publicName: r.publicName ?? "",
|
publicName: r.publicName ?? "",
|
||||||
type: r.type ?? "s",
|
type: r.type ?? "s",
|
||||||
spriteId: r.spriteId ?? 0,
|
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";
|
"use client";
|
||||||
|
|
||||||
import { LayoutGrid, Search, X } from "lucide-react";
|
import { LayoutGrid, Search, Wand2, X } from "lucide-react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
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 { CatalogSearch } from "@/features/catalog/components/catalog-search";
|
||||||
import type { CatalogSearchResult } from "@/features/catalog/domain/search";
|
import type { CatalogSearchResult } from "@/features/catalog/domain/search";
|
||||||
import type { RootTab } from "@/types/catalog";
|
import type { RootTab } from "@/types/catalog";
|
||||||
|
import { AutoCategoryDialog } from "./auto-category-dialog";
|
||||||
import { BreadcrumbBar } from "./breadcrumb-bar";
|
import { BreadcrumbBar } from "./breadcrumb-bar";
|
||||||
import { InlineEditor, type PageUpdateData } from "./inline-editor";
|
import { InlineEditor, type PageUpdateData } from "./inline-editor";
|
||||||
import { RootTabs } from "./root-tabs";
|
import { RootTabs } from "./root-tabs";
|
||||||
@@ -165,6 +166,7 @@ function CatalogManagerInner({
|
|||||||
const rootRequests = useLatestRequest();
|
const rootRequests = useLatestRequest();
|
||||||
const searchNavigation = useLatestRequest();
|
const searchNavigation = useLatestRequest();
|
||||||
const [searchOpen, setSearchOpen] = useState(false);
|
const [searchOpen, setSearchOpen] = useState(false);
|
||||||
|
const [autoOpen, setAutoOpen] = useState(false);
|
||||||
const [searchTarget, setSearchTarget] = useState<{
|
const [searchTarget, setSearchTarget] = useState<{
|
||||||
offerId: number | null;
|
offerId: number | null;
|
||||||
key: number;
|
key: number;
|
||||||
@@ -399,6 +401,23 @@ function CatalogManagerInner({
|
|||||||
{heading}
|
{heading}
|
||||||
</h2>
|
</h2>
|
||||||
<div className="flex items-center gap-1">
|
<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
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -437,6 +456,16 @@ function CatalogManagerInner({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<AutoCategoryDialog
|
||||||
|
catalogType={catalogType}
|
||||||
|
open={autoOpen}
|
||||||
|
onOpenChange={setAutoOpen}
|
||||||
|
onCreated={(pageId) => {
|
||||||
|
void loadRootPages();
|
||||||
|
handleSelectPage(pageId);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<BreadcrumbBar onSelectPage={handleSelectPage} />
|
<BreadcrumbBar onSelectPage={handleSelectPage} />
|
||||||
|
|
||||||
<RootTabs
|
<RootTabs
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
ExternalLink,
|
ExternalLink,
|
||||||
Languages,
|
Languages,
|
||||||
Loader2,
|
Loader2,
|
||||||
|
MonitorPlay,
|
||||||
Package,
|
Package,
|
||||||
Save,
|
Save,
|
||||||
Settings,
|
Settings,
|
||||||
@@ -24,8 +25,9 @@ import { CatalogTranslateTab } from "@/app/admin/catalog/[id]/catalog-translate-
|
|||||||
import { BulkImportItems } from "@/components/admin/catalog/bulk-import-items";
|
import { BulkImportItems } from "@/components/admin/catalog/bulk-import-items";
|
||||||
import { CatalogImagePicker } from "@/components/admin/catalog/catalog-image-picker";
|
import { CatalogImagePicker } from "@/components/admin/catalog/catalog-image-picker";
|
||||||
import { IconPicker } from "@/components/admin/catalog/icon-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 { 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 { CatalogIcon, LAYOUT_COLORS } from "@/components/admin/catalog-tree";
|
||||||
import Link from "@/components/link";
|
import Link from "@/components/link";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
@@ -144,7 +146,7 @@ function InlineEditorSession({
|
|||||||
const [activeTab, setActiveTab] = useState(
|
const [activeTab, setActiveTab] = useState(
|
||||||
initialOfferId ? "items" : "settings",
|
initialOfferId ? "items" : "settings",
|
||||||
);
|
);
|
||||||
const [showPreview, setShowPreview] = useState(false);
|
const [showPreview, setShowPreview] = useState(catalogType === "normal");
|
||||||
|
|
||||||
// Items/translate data (lazy loaded)
|
// Items/translate data (lazy loaded)
|
||||||
const [itemsData, setItemsData] = useState<ItemsData | null>(null);
|
const [itemsData, setItemsData] = useState<ItemsData | null>(null);
|
||||||
@@ -266,11 +268,13 @@ function InlineEditorSession({
|
|||||||
}
|
}
|
||||||
}, [pageId, loadPage, initialOfferId]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (catalogType === "bc") return;
|
if (catalogType === "bc") return;
|
||||||
if (
|
if (
|
||||||
(activeTab === "items" || activeTab === "translate") &&
|
(activeTab === "items" || activeTab === "translate" || showPreview) &&
|
||||||
!itemsLoaded &&
|
!itemsLoaded &&
|
||||||
!itemsLoading &&
|
!itemsLoading &&
|
||||||
pageId &&
|
pageId &&
|
||||||
@@ -285,6 +289,7 @@ function InlineEditorSession({
|
|||||||
pageId,
|
pageId,
|
||||||
loadItemsData,
|
loadItemsData,
|
||||||
catalogType,
|
catalogType,
|
||||||
|
showPreview,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
function update(key: string, value: string | number | null) {
|
function update(key: string, value: string | number | null) {
|
||||||
@@ -404,16 +409,33 @@ function InlineEditorSession({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Link href={pageHref} target="_blank">
|
<div className="flex items-center gap-1">
|
||||||
<Button
|
{catalogType === "normal" && (
|
||||||
variant="ghost"
|
<Button
|
||||||
size="sm"
|
variant="ghost"
|
||||||
className="h-7 text-xs gap-1 text-[var(--admin-text)]"
|
size="sm"
|
||||||
>
|
className={cn(
|
||||||
<ExternalLink className="h-3 w-3" />
|
"h-7 gap-1 text-xs",
|
||||||
{t("fullEditor")}
|
showPreview &&
|
||||||
</Button>
|
"border border-[var(--admin-info-border)] bg-[var(--admin-info-subtle)] text-[var(--admin-info)]",
|
||||||
</Link>
|
)}
|
||||||
|
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>
|
</div>
|
||||||
|
|
||||||
{/* Dirty banner */}
|
{/* Dirty banner */}
|
||||||
@@ -438,394 +460,404 @@ function InlineEditorSession({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Tabs */}
|
{/* Editor + live preview */}
|
||||||
<Tabs
|
<div className="flex min-h-0 flex-1">
|
||||||
value={activeTab}
|
<Tabs
|
||||||
onValueChange={(tab) => {
|
value={activeTab}
|
||||||
if (tab === activeTab) return;
|
onValueChange={(tab) => {
|
||||||
if (offersDirty && !window.confirm(tActions("leaveUnsaved"))) return;
|
if (tab === activeTab) return;
|
||||||
setOffersDirty(false);
|
if (offersDirty && !window.confirm(tActions("leaveUnsaved")))
|
||||||
setActiveTab(tab);
|
return;
|
||||||
}}
|
setOffersDirty(false);
|
||||||
className="flex-1 flex flex-col min-h-0"
|
setActiveTab(tab);
|
||||||
>
|
}}
|
||||||
<TabsList className="mx-4 mt-2 shrink-0">
|
className="flex-1 flex flex-col min-h-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"
|
|
||||||
>
|
>
|
||||||
<div className="space-y-4">
|
<TabsList className="mx-4 mt-2 shrink-0">
|
||||||
{/* Caption + Layout */}
|
<TabsTrigger value="settings" className="gap-1.5 text-xs">
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
<Settings className="h-3.5 w-3.5" />
|
||||||
<div className="space-y-1.5">
|
Settings
|
||||||
<Label className="text-xs">Caption</Label>
|
{isDirty && (
|
||||||
<Input
|
<span className="ml-0.5 h-1.5 w-1.5 rounded-full bg-[var(--admin-warning)]" />
|
||||||
value={String(form.caption || "")}
|
)}
|
||||||
onChange={(e) => update("caption", e.target.value)}
|
</TabsTrigger>
|
||||||
className="h-8 text-sm"
|
<TabsTrigger value="items" className="gap-1.5 text-xs">
|
||||||
/>
|
<Package className="h-3.5 w-3.5" />
|
||||||
</div>
|
Items
|
||||||
<div className="space-y-1.5">
|
{itemsData && (
|
||||||
<Label className="text-xs">Layout</Label>
|
<Badge
|
||||||
<Select
|
variant="secondary"
|
||||||
value={String(form.pageLayout || "")}
|
className="h-4 px-1 text-[9px] ml-0.5"
|
||||||
onValueChange={(v) => update("pageLayout", v)}
|
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-8 text-sm">
|
{itemsData.items.length}
|
||||||
<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
|
|
||||||
</Badge>
|
</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 && (
|
{/* ── Settings tab ──────────────────────────────── */}
|
||||||
<div className="space-y-3 pl-2 border-l-2 border-border/40">
|
<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">
|
<div className="space-y-1.5">
|
||||||
<Label className="text-xs">Text 1</Label>
|
<Label className="text-xs">Caption</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
|
<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) =>
|
onChange={(e) =>
|
||||||
update("pageSpecial", e.target.value || null)
|
update("orderNum", parseInt(e.target.value, 10) || 0)
|
||||||
}
|
}
|
||||||
className="h-8 text-sm"
|
className="h-8 text-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{catalogType === "normal" && (
|
||||||
)}
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">Min Rank</Label>
|
||||||
{/* Save button */}
|
<Input
|
||||||
<div className="pt-2">
|
type="number"
|
||||||
<Button
|
value={form.minRank as number}
|
||||||
onClick={handleSave}
|
onChange={(e) =>
|
||||||
disabled={saving || !isDirty || !canEdit}
|
update("minRank", parseInt(e.target.value, 10) || 1)
|
||||||
className="w-full"
|
}
|
||||||
>
|
className="h-8 text-sm"
|
||||||
{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}
|
|
||||||
/>
|
/>
|
||||||
<BulkImportItems
|
</div>
|
||||||
pageId={page.id}
|
|
||||||
onImported={reloadItems}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
<Button
|
<div className="space-y-1.5">
|
||||||
variant="outline"
|
<Label className="text-xs">Icon</Label>
|
||||||
size="sm"
|
<IconPicker
|
||||||
onClick={() => setShowPreview(!showPreview)}
|
value={(form.iconImage as number) ?? 0}
|
||||||
className="ml-auto"
|
onChange={(v) => update("iconImage", v)}
|
||||||
>
|
/>
|
||||||
{showPreview ? "Hide Preview" : "Shop Preview"}
|
</div>
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Shop preview (togglable) */}
|
{/* Toggles */}
|
||||||
{showPreview && (
|
<div className={styles.options}>
|
||||||
<ItemsShopPreview
|
<ToggleRow
|
||||||
items={itemsData.items}
|
id="ie-enabled"
|
||||||
furniRevisionMap={itemsData.furniRevisionMap}
|
disabled={!canEdit || saving}
|
||||||
layout={page.pageLayout}
|
label={t("access.enabled")}
|
||||||
canEdit={canEdit}
|
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 */}
|
{/* Save button */}
|
||||||
<CatalogItemsTable
|
<div className="pt-2">
|
||||||
initialSearch={
|
<Button
|
||||||
initialOfferId ? `#${initialOfferId}` : undefined
|
onClick={handleSave}
|
||||||
}
|
disabled={saving || !isDirty || !canEdit}
|
||||||
onDirtyChange={setOffersDirty}
|
className="w-full"
|
||||||
onRefresh={reloadItems}
|
>
|
||||||
items={itemsData.items}
|
{saving ? (
|
||||||
pageId={page.id}
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
pageLayout={page.pageLayout}
|
) : (
|
||||||
canEdit={canEdit}
|
<Save className="mr-2 h-4 w-4" />
|
||||||
interactionTypes={itemsData.interactionTypes}
|
)}
|
||||||
furniRevisionMap={itemsData.furniRevisionMap}
|
{tNavigation("save")}
|
||||||
allPages={itemsData.allPages}
|
</Button>
|
||||||
/>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
</TabsContent>
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
{/* ── Translate tab ─────────────────────────────── */}
|
{/* ── Items tab ─────────────────────────────────── */}
|
||||||
<TabsContent
|
<TabsContent
|
||||||
value="translate"
|
value="items"
|
||||||
className="flex-1 overflow-y-auto px-4 pb-4 mt-2"
|
className="flex-1 overflow-y-auto px-4 pb-4 mt-2"
|
||||||
>
|
>
|
||||||
{catalogType === "bc" ? (
|
{catalogType === "bc" ? (
|
||||||
<div className="rounded-md border border-dashed px-4 py-8 text-center text-sm text-muted-foreground">
|
<div className="rounded-md border border-dashed px-4 py-8 text-center text-sm text-muted-foreground space-y-3">
|
||||||
Translate tools are available for the shop catalog only.
|
<p>
|
||||||
</div>
|
BC items are managed on the full Builder Club page editor.
|
||||||
) : itemsError && !itemsData ? (
|
</p>
|
||||||
<div className="flex items-center justify-center py-12">
|
<Link href={pageHref} target="_blank">
|
||||||
<Button variant="outline" onClick={reloadItems}>
|
<Button variant="outline" size="sm" className="gap-1">
|
||||||
{t("retryItems")}
|
<ExternalLink className="h-3 w-3" />
|
||||||
</Button>
|
Open BC page editor
|
||||||
</div>
|
</Button>
|
||||||
) : itemsLoading && !itemsData ? (
|
</Link>
|
||||||
<div className="flex items-center justify-center py-12">
|
</div>
|
||||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
) : itemsError && !itemsData ? (
|
||||||
</div>
|
<div className="flex items-center justify-center py-12">
|
||||||
) : itemsData ? (
|
<Button variant="outline" onClick={reloadItems}>
|
||||||
<CatalogTranslateTab
|
{t("retryItems")}
|
||||||
baseItems={itemsData.baseItems}
|
</Button>
|
||||||
catalogNameMap={itemsData.catalogNameMap}
|
</div>
|
||||||
canEdit={canEdit}
|
) : itemsLoading && !itemsData ? (
|
||||||
furniDataIdList={itemsData.furniDataIdList}
|
<div className="flex items-center justify-center py-12">
|
||||||
furniDescriptionMap={itemsData.furniDescriptionMap}
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
furniRevisionMap={itemsData.furniRevisionMap}
|
</div>
|
||||||
gamedataHotel={itemsData.gamedataHotel}
|
) : itemsData ? (
|
||||||
/>
|
<div className="space-y-3">
|
||||||
) : null}
|
{/* Toolbar */}
|
||||||
</TabsContent>
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
</Tabs>
|
{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>
|
</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";
|
"use client";
|
||||||
|
|
||||||
import { Crown, Image as ImageIcon, Package, Sparkles } from "lucide-react";
|
import { Package } from "lucide-react";
|
||||||
import {
|
import type { CatalogItemData } from "@/app/admin/catalog/[id]/catalog-items-table/types";
|
||||||
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 { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
getShopGridClass,
|
||||||
|
getShopTileSize,
|
||||||
|
parseFirstItemId,
|
||||||
|
ShopTile,
|
||||||
|
} from "./shop-tile";
|
||||||
|
|
||||||
interface ItemsShopPreviewProps {
|
interface ItemsShopPreviewProps {
|
||||||
items: CatalogItemData[];
|
items: CatalogItemData[];
|
||||||
@@ -39,8 +36,8 @@ export function ItemsShopPreview({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const gridClass = getGridClass(layout);
|
const gridClass = getShopGridClass(layout);
|
||||||
const tileSize = getTileSize(layout);
|
const tileSize = getShopTileSize(layout);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border bg-gradient-to-b from-[var(--admin-surface)] to-[var(--admin-canvas)] p-4">
|
<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) => {
|
{items.map((item) => {
|
||||||
const firstId = parseFirstItemId(item.itemIds);
|
const firstId = parseFirstItemId(item.itemIds);
|
||||||
const furni = firstId != null ? furniRevisionMap[firstId] : undefined;
|
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 (
|
return (
|
||||||
<button
|
<ShopTile
|
||||||
key={item.id}
|
key={item.id}
|
||||||
type="button"
|
item={item}
|
||||||
onClick={() => onItemClick?.(item)}
|
furni={furni}
|
||||||
disabled={!canEdit && !onItemClick}
|
iconHeight={tileSize}
|
||||||
className={cn(
|
canEdit={canEdit}
|
||||||
"group relative flex flex-col overflow-hidden rounded-md border bg-card shadow-sm transition-all",
|
onClick={onItemClick}
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</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.",
|
"clubHint": "Restricts access to users with an HC subscription.",
|
||||||
"yes": "Yes",
|
"yes": "Yes",
|
||||||
"no": "No"
|
"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": {
|
"bulk": {
|
||||||
|
|||||||
@@ -1925,6 +1925,47 @@
|
|||||||
"clubHint": "Beperkt toegang tot gebruikers met een HC-abonnement.",
|
"clubHint": "Beperkt toegang tot gebruikers met een HC-abonnement.",
|
||||||
"yes": "Ja",
|
"yes": "Ja",
|
||||||
"no": "Nee"
|
"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": {
|
"bulk": {
|
||||||
|
|||||||
Reference in new issue
Block a user