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

@@ -0,0 +1,229 @@
import { describe, expect, it } from "vitest";
import type { CatalogItemData } from "@/app/admin/catalog/[id]/catalog-items-table/types";
import {
buildSortOrders,
type SortStrategy,
type SugFurni,
sortCatalogItems,
suggestCatalogIcon,
suggestCatalogLayout,
suggestCatalogLayoutFromBaseItems,
suggestCategoryName,
} from "./organize";
function offer(overrides: Partial<CatalogItemData> = {}): CatalogItemData {
return {
id: 1,
catalogName: "",
itemIds: "1",
costCredits: 0,
costPoints: 0,
pointsType: 0,
amount: 1,
limitedSells: 0,
limitedStack: 0,
orderNumber: 0,
offerId: -1,
songId: 0,
haveOffer: "1",
clubOnly: "0",
extradata: "",
baseName: "",
baseItemName: "",
spriteId: 0,
baseItem: null,
...overrides,
};
}
const sample = [
offer({ id: 3, catalogName: "Zebra", costCredits: 10, limitedStack: 0 }),
offer({ id: 1, catalogName: "Apple", costCredits: 5, limitedStack: 0 }),
offer({ id: 2, catalogName: "Mango", costCredits: 5, limitedStack: 50 }),
];
describe("sortCatalogItems", () => {
it("sorts by name ascending", () => {
const sorted = sortCatalogItems(sample, "name-asc");
expect(sorted.map((i) => i.id)).toEqual([1, 2, 3]);
});
it("sorts by name descending", () => {
const sorted = sortCatalogItems(sample, "name-desc");
expect(sorted.map((i) => i.id)).toEqual([3, 2, 1]);
});
it("sorts by price then id", () => {
const sorted = sortCatalogItems(sample, "price-asc");
expect(sorted.map((i) => i.id)).toEqual([1, 2, 3]);
});
it("sorts expensive first", () => {
const sorted = sortCatalogItems(sample, "price-desc");
expect(sorted.map((i) => i.id)).toEqual([3, 1, 2]);
});
it("keeps limited editions first", () => {
const sorted = sortCatalogItems(sample, "limited-first");
expect(sorted.map((i) => i.id)).toEqual([2, 1, 3]);
});
it("keeps offers first", () => {
const input = [
offer({ id: 1, catalogName: "a", haveOffer: "0" }),
offer({ id: 2, catalogName: "b", haveOffer: "1" }),
];
const sorted = sortCatalogItems(input, "offer-first");
expect(sorted.map((i) => i.id)).toEqual([2, 1]);
});
it("sorts amount descending", () => {
const input = [
offer({ id: 1, catalogName: "a", amount: 1 }),
offer({ id: 2, catalogName: "b", amount: 10 }),
];
const sorted = sortCatalogItems(input, "amount-desc");
expect(sorted.map((i) => i.id)).toEqual([2, 1]);
});
it("is stable for unknown strategies", () => {
const sorted = sortCatalogItems(sample, "name-asc" as SortStrategy);
const re = sortCatalogItems([...sorted].reverse(), "name-asc");
expect(re.map((i) => i.id)).toEqual([1, 2, 3]);
});
});
describe("buildSortOrders", () => {
it("assigns sequential order numbers", () => {
const orders = buildSortOrders([sample[2], sample[0]]);
expect(orders).toEqual([
{ id: 2, orderNumber: 0 },
{ id: 3, orderNumber: 1 },
]);
});
});
describe("suggestCatalogLayout", () => {
it("returns null for empty lists", () => {
expect(suggestCatalogLayout([])).toBeNull();
});
it("suggests soundmachine when songs present", () => {
const input = [
offer({ songId: 55, baseItem: { interactionType: "default" } as never }),
];
expect(suggestCatalogLayout(input)?.layout).toBe("soundmachine");
});
it("suggests pets when base item type is 'p'", () => {
const input = [offer({ baseItem: { type: "p" } as never })];
expect(suggestCatalogLayout(input)?.layout).toBe("pets");
});
it("suggests trophies for trophy interactions", () => {
const input = [offer({ baseItem: { interactionType: "trophy" } as never })];
expect(suggestCatalogLayout(input)?.layout).toBe("trophies");
});
it("suggests guilds for guild interactions", () => {
const input = [offer({ baseItem: { interactionType: "guild" } as never })];
expect(suggestCatalogLayout(input)?.layout).toBe("guilds");
});
it("returns null when no strong signal", () => {
const input = [
offer({ baseItem: { interactionType: "default" } as never }),
];
expect(suggestCatalogLayout(input)).toBeNull();
});
});
describe("suggestCatalogLayoutFromBaseItems", () => {
it("returns null for empty lists", () => {
expect(suggestCatalogLayoutFromBaseItems([])).toBeNull();
});
it("suggests soundmachine from interaction type", () => {
expect(
suggestCatalogLayoutFromBaseItems([{ interactionType: "soundmachine" }])
?.layout,
).toBe("soundmachine");
});
it("suggests pets from base type", () => {
expect(
suggestCatalogLayoutFromBaseItems([
{ type: "p", interactionType: "default" },
])?.layout,
).toBe("pets");
});
it("suggests trophies for trophy interactions", () => {
expect(
suggestCatalogLayoutFromBaseItems([{ interactionType: "trophy" }])
?.layout,
).toBe("trophies");
});
it("returns null when no strong signal", () => {
expect(
suggestCatalogLayoutFromBaseItems([{ interactionType: "default" }]),
).toBeNull();
});
});
function fum(itemName?: string, interactionType?: string): SugFurni {
return { itemName, interactionType };
}
describe("suggestCategoryName", () => {
it("returns null for empty lists", () => {
expect(suggestCategoryName([])).toBeNull();
});
it("derives a pluralized label from the dominant classname token", () => {
const input = [fum("chair_wooden"), fum("chair_oak"), fum("chair_polyfon")];
expect(suggestCategoryName(input)).toBe("Chairs");
});
it("keeps singular-only tokens singular", () => {
expect(suggestCategoryName([fum("wallpaper_leaf")])).toBe("Wallpaper");
expect(suggestCategoryName([fum("floor_plain")])).toBe("Floor");
});
it("prefers interaction labels over classname tokens", () => {
const input = [
fum("trophy_cup", "trophy"),
fum("trophy_crown", "trophy"),
fum("chair_oak", "default"),
];
expect(suggestCategoryName(input)).toBe("Trophies");
});
it("uses the last-resort mapping for known interactions", () => {
expect(
suggestCategoryName([fum("soundmachine_flat", "soundmachine")]),
).toBe("Sounds");
});
it("returns null when nothing can be derived", () => {
expect(suggestCategoryName([fum("")])).toBeNull();
});
});
describe("suggestCatalogIcon", () => {
it("returns 0 for unknown categories", () => {
expect(suggestCatalogIcon([fum("")])).toBe(0);
});
it("maps detected categories to a catalog icon id", () => {
expect(suggestCatalogIcon([fum("chair_oak")])).toBeGreaterThan(0);
expect(suggestCatalogIcon([fum("trophy_cup", "trophy")])).toBe(7);
});
it("is deterministic", () => {
const a = suggestCatalogIcon([fum("chair_oak"), fum("chair_oak")]);
const b = suggestCatalogIcon([fum("chair_oak"), fum("chair_oak")]);
expect(a).toBe(b);
});
});
+330
View File
@@ -0,0 +1,330 @@
import type { CatalogItemData } from "@/app/admin/catalog/[id]/catalog-items-table/types";
import type { CatalogLayout } from "@/lib/catalog-layouts";
export type SortStrategy =
| "name-asc"
| "name-desc"
| "price-asc"
| "price-desc"
| "limited-first"
| "offer-first"
| "amount-desc";
export const SORT_STRATEGIES: Array<{ id: SortStrategy; label: string }> = [
{ id: "name-asc", label: "Name A → Z" },
{ id: "name-desc", label: "Name Z → A" },
{ id: "price-asc", label: "Cheapest first" },
{ id: "price-desc", label: "Most expensive first" },
{ id: "limited-first", label: "Limited editions first" },
{ id: "offer-first", label: "Offers first" },
{ id: "amount-desc", label: "Largest stack first" },
];
function offerPrice(item: CatalogItemData): number {
return item.costCredits + item.costPoints;
}
function offerName(item: CatalogItemData): string {
return (item.catalogName || item.baseName || `#${item.id}`).toLowerCase();
}
function compareBook(a: string, b: string): number {
return a === b ? 0 : a < b ? -1 : 1;
}
/**
* Deterministically order a page's offers for a chosen strategy.
* Unstable collations are resolved by id so the result is reproducible.
*/
export function sortCatalogItems(
items: CatalogItemData[],
strategy: SortStrategy,
): CatalogItemData[] {
const sorted = [...items].sort((a, b) => {
switch (strategy) {
case "name-asc":
return compareBook(offerName(a), offerName(b)) || a.id - b.id;
case "name-desc":
return compareBook(offerName(b), offerName(a)) || a.id - b.id;
case "price-asc":
return offerPrice(a) - offerPrice(b) || a.id - b.id;
case "price-desc":
return offerPrice(b) - offerPrice(a) || a.id - b.id;
case "limited-first": {
const altd = a.limitedStack > 0 ? 0 : 1;
const bltd = b.limitedStack > 0 ? 0 : 1;
return altd - bltd || b.limitedStack - a.limitedStack || a.id - b.id;
}
case "offer-first": {
const aoff = a.haveOffer === "1" ? 0 : 1;
const boff = b.haveOffer === "1" ? 0 : 1;
return aoff - boff || a.id - b.id;
}
case "amount-desc":
return b.amount - a.amount || a.id - b.id;
default:
return a.id - b.id;
}
});
return sorted;
}
/** Emit `order_number` assignments (sequential from 0) for a new order. */
export function buildSortOrders(items: CatalogItemData[]): Array<{
id: number;
orderNumber: number;
}> {
return items.map((item, index) => ({
id: item.id,
orderNumber: index,
}));
}
export interface LayoutSuggestion {
layout: CatalogLayout;
reason: string;
}
/** Strong, deterministic signals only — never suggest when unsure. */
export function suggestCatalogLayout(
items: CatalogItemData[],
): LayoutSuggestion | null {
if (items.length === 0) return null;
const interactions = new Set(
items.map((i) => i.baseItem?.interactionType ?? "").filter(Boolean),
);
const types = new Set(items.map((i) => i.baseItem?.type ?? ""));
const anySong = items.some((i) => i.songId > 0);
return layoutFromSignals({ interactions, types, anySong });
}
function layoutFromSignals(params: {
interactions: Set<string>;
types: Set<string>;
anySong: boolean;
}): LayoutSuggestion | null {
const { interactions, types, anySong } = params;
if (anySong || interactions.has("soundmachine"))
return {
layout: "soundmachine",
reason: "contains songs / soundmachine items",
};
if (types.has("p")) return { layout: "pets", reason: "contains pets" };
if (interactions.has("pet"))
return { layout: "pets2", reason: "contains pet wear/pets" };
if (interactions.has("trophy"))
return { layout: "trophies", reason: "contains trophies" };
if (interactions.has("guild"))
return { layout: "guilds", reason: "contains guild items" };
return null;
}
/** Layout suggestion using base-item fields directly (for the auto-category wizard). */
export function suggestCatalogLayoutFromBaseItems(
items: Array<{ type?: string; interactionType?: string }>,
): LayoutSuggestion | null {
if (items.length === 0) return null;
const interactions = new Set(
items.map((i) => i.interactionType ?? "").filter(Boolean),
);
const types = new Set(items.map((i) => i.type ?? ""));
return layoutFromSignals({ interactions, types, anySong: false });
}
// ── Auto-category suggestion helpers ──────────────────────────
export interface SugFurni {
itemName?: string | null;
publicName?: string | null;
type?: string | null;
interactionType?: string | null;
}
const THEME_PREFIXES = new Set([
"a",
"habbo",
"hween",
"boo",
"xmas",
"easter",
"summer",
"santorini",
"marquee",
"val",
"bf",
"ny",
"lonely",
"hotel",
"expo",
"tiki",
"jungle",
"dino",
"factory",
"champions",
"f1",
"bubble",
"ice",
"beach",
"country",
"modern",
"elegant",
"gothic",
"fanti",
"rock",
"disco",
"spa",
"rainy",
"skel",
"vampire",
"ninja",
"pirate",
"royal",
"space",
"wild",
"woody",
"zen",
]);
const INTERACTION_LABELS: Record<string, string> = {
soundmachine: "Sounds",
jukebox: "Sounds",
trophy: "Trophies",
pet: "Pets",
guild: "Guilds",
dimmer: "Lighting",
bed: "Beds",
chair: "Chairs",
table: "Tables",
roller: "Rollers",
vending: "Vending",
poster: "Posters",
wallpaper: "Wallpaper",
floor: "Floor",
teleport: "Teleports",
dice: "Dice",
sticky: "Stickies",
football: "Football",
mannequin: "Clothing",
clothing: "Clothing",
magicbox: "Magic Items",
freeze: "Freeze",
furni: "Furni",
default: "",
};
const KEEP_SINGULAR = new Set([
"wallpaper",
"floor",
"poster",
"rug",
"sauna",
"teleport",
"clothing",
"pool",
"garden",
"park",
"city",
"hotel",
"surface",
"fx",
"clothing",
]);
const THEME_SUFFIXES = new Set([
"hween",
"xmas",
"easter",
"summer",
"val",
"bf",
"ny",
]);
function classToken(item: SugFurni): string | null {
const raw = (item.itemName || "").split("/")[0]?.trim() || "";
if (!raw) return null;
const first = raw.split("_")[0]?.toLowerCase();
if (!first || THEME_PREFIXES.has(first) || THEME_SUFFIXES.has(first))
return null;
return first;
}
function pluralizeLabel(token: string): string {
const lower = token.toLowerCase();
const cap = lower.charAt(0).toUpperCase() + lower.slice(1);
if (KEEP_SINGULAR.has(lower)) return cap;
if (/(?:s|ss|sh|ch|x|z)$/.test(lower) && lower.length > 3) return cap;
return `${cap}s`;
}
/**
* Best-effort English category label derived from a batch of base items.
* Never blocks — returns null when it can't determine anything useful.
*/
export function suggestCategoryName(furni: SugFurni[]): string | null {
if (furni.length === 0) return null;
const labelCounts = new Map<string, number>();
for (const f of furni) {
const inter = f.interactionType?.toLowerCase();
let label: string | undefined;
if (inter && inter !== "default") {
label = INTERACTION_LABELS[inter];
}
if (!label) {
const token = classToken(f);
if (token) label = pluralizeLabel(token);
}
if (label) labelCounts.set(label, (labelCounts.get(label) ?? 0) + 1);
}
const sorted = [...labelCounts.entries()].sort(
(a, b) => b[1] - a[1] || a[0].localeCompare(b[0]),
);
return sorted[0]?.[0] ?? null;
}
/**
* Curated mapping from category labels to catalog icon IDs.
* All IDs are known to exist in the standard gamedata icon set (1–326).
*/
const CATEGORY_ICON_MAP: Record<string, number> = {
Sounds: 6,
Pets: 8,
Trophies: 7,
Guilds: 3,
Wallpaper: 4,
Floor: 5,
Lighting: 9,
Posters: 2,
Chairs: 10,
Beds: 11,
Tables: 12,
Rollers: 13,
Vending: 14,
Teleports: 15,
Dice: 16,
Stickies: 17,
Football: 18,
Clothing: 19,
"Magic Items": 20,
Freeze: 21,
Furni: 22,
};
/**
* Suggest a catalog icon ID based on detected category.
* Returns 0 when it can't determine a sensible icon.
*/
export function suggestCatalogIcon(furni: SugFurni[]): number {
const name = suggestCategoryName(furni);
if (!name) return 0;
return CATEGORY_ICON_MAP[name] ?? 1;
}