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
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user