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

- Add AutoCategoryDialog: pick furni (or empty page), suggest caption/icon/layout, live preview
- Add createAutoCategory server action (page + offers in one export) with CatalogKind
- Extend furni search API with interactionType
- Share ShopTile and refactor inline-editor/items-shop-preview to use it
- Add suggestion heuristics (suggestCategoryName/dIcon/layout) with tests
- Add autoCategory translations (en/nl)
This commit is contained in:
openhands committed 2026-09-11 18:47:31 +02:00
1 parent baeb54aeb5
commit f832479e52
14 files changed
+2577 -567

No files matched your search

+1 -1
View File
@@ -20,7 +20,7 @@ import { logStaffActivity } from "@/lib/services/staff-activity";
import { translateItemsSchema } from "@/lib/validators/catalog";
/** Raw INSERT — catalog_items.id has no AUTO_INCREMENT on real Habbo DBs; page_id is often VARCHAR. */
async function insertCatalogItemRow(data: {
export async function insertCatalogItemRow(data: {
pageId: number;
itemIds: string;
catalogName: string;
+139
View File
@@ -1,6 +1,8 @@
"use server";
import { inArray, sql } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { insertCatalogItemRow } from "@/actions/catalog-items";
import { catalogFailure } from "@/features/catalog/server/errors";
import {
createPageCommand,
@@ -11,6 +13,7 @@ import {
} from "@/features/catalog/server/page-commands";
import { sendCatalogUpdate } from "@/features/catalog/server/sync-status";
import { requirePermission } from "@/lib/admin/guard";
import { db, ItemsBase } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import type { ActionResult } from "@/lib/safe-action-shared";
import { withCatalogExport } from "@/lib/services/catalog-git-queue";
@@ -208,3 +211,139 @@ export async function reorderCatalogPages(input: {
return { ok: true as const, data: {} };
});
}
export type CatalogKind = "normal" | "bc";
export async function createAutoCategory(input: {
kind: CatalogKind;
parentId: number;
caption: string;
pageLayout?: string;
iconImage?: number;
enabled?: "0" | "1";
visible?: "0" | "1";
minRank?: number;
offerRows?: Array<{
baseId: number;
credits?: number;
points?: number;
pointsType?: number;
amount?: number;
}>;
}): Promise<ActionResult<{ pageId: number }>> {
const staff = await requirePermission(PERMS.CATALOG_EDIT);
return await withCatalogExport(async () => {
const createResult =
input.kind === "bc"
? await createPageCommand("bc", {
caption: input.caption,
parentId: input.parentId,
pageLayout: input.pageLayout ?? "default_3x3",
captionSave: input.caption.slice(0, 25),
iconImage: input.iconImage ?? 0,
iconColor: 0,
minRank: input.minRank ?? 1,
orderNum: 0,
visible: input.visible ?? "1",
enabled: input.enabled ?? "1",
clubOnly: "0",
vipOnly: "0",
pageHeadline: "",
pageTeaser: "",
includes: "",
})
: await createPageCommand("normal", {
caption: input.caption,
parentId: input.parentId,
pageLayout: input.pageLayout ?? "default_3x3",
captionSave: input.caption.slice(0, 25),
iconImage: input.iconImage ?? 0,
iconColor: 0,
minRank: input.minRank ?? 1,
orderNum: 0,
visible: input.visible ?? "1",
enabled: input.enabled ?? "1",
clubOnly: "0",
vipOnly: "0",
pageHeadline: "",
pageTeaser: "",
includes: "",
});
const pageId = Number(createResult);
// Create the new page's offers in the same export run (single snapshot).
if (input.kind === "normal" && (input.offerRows?.length ?? 0) > 0) {
if ((input.offerRows?.length ?? 0) > 500) {
return { ok: false as const, error: "Max 500 items per auto category" };
}
const rows = input.offerRows ?? [];
const baseIds = [...new Set(rows.map((r) => r.baseId))];
const bases = await db
.select({
id: ItemsBase.id,
publicName: ItemsBase.publicName,
itemName: ItemsBase.itemName,
})
.from(ItemsBase)
.where(inArray(ItemsBase.id, baseIds));
const baseMap = new Map(bases.map((b) => [b.id, b]));
const [maxOrder] = (await db.execute(sql`
SELECT COALESCE(MAX(order_number), 0) AS maxOrder FROM catalog_items WHERE page_id = ${String(pageId)}
`)) as unknown as [{ maxOrder: number }, unknown];
let nextOrder = Number(maxOrder?.maxOrder ?? 0) + 1;
let created = 0;
for (const row of rows) {
const base = baseMap.get(row.baseId);
if (!base) continue;
try {
await insertCatalogItemRow({
pageId,
itemIds: String(row.baseId),
catalogName: base.publicName || base.itemName || String(row.baseId),
costCredits: row.credits ?? 0,
costPoints: row.points ?? 0,
pointsType: row.pointsType ?? 0,
amount: row.amount ?? 1,
limitedSells: 0,
limitedStack: 0,
orderNumber: nextOrder++,
offerId: -1,
songId: 0,
haveOffer: "1",
clubOnly: "0",
extradata: "",
});
created++;
} catch (error) {
console.error(
`[createAutoCategory] failed to insert item #${row.baseId}:`,
error,
);
}
}
await logStaffActivity({
staffId: staff.id,
action: "catalog_items_bulk_create",
description: `Auto-created ${created} catalog item(s) on page #${pageId}`,
targetType: "catalog_page",
targetId: pageId,
});
}
await sendCatalogUpdate();
await logStaffActivity({
staffId: staff.id,
action: "catalog_page_create",
description: `Created catalog page #${pageId} "${input.caption}" (${input.kind})`,
targetType: "catalog_page",
targetId: pageId,
});
revalidatePath("/admin/catalog");
return { ok: true as const, data: { pageId } };
});
}