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:
openhands
2026-09-11 18:47:31 +02:00
parent baeb54aeb5
commit f832479e52
14 changed files with 2577 additions and 567 deletions
@@ -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);
});
});