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 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"; 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;
File diff suppressed because it is too large. Load diff
+2
View File
@@ -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",
})), })),
}); });
}, },
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -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
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+41
View File
@@ -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": {
+41
View File
@@ -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": {