feat(catalog): Visual Manager for Builder Club with honest canEdit

Mount BC Visual Manager, thread catalogType through tree/editor actions, and stop hardcoding canEdit=true in items/translate tabs.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
SimoandCursor committed 2026-07-18 21:03:07 +02:00
1 parent 8ccade73cc
commit 7d50b72ade
8 files changed
+457 -151

No files matched your search

+110 -7
View File
@@ -169,19 +169,24 @@ export async function toggleBcPage({
export async function createBcPage(input: { export async function createBcPage(input: {
caption: string; caption: string;
parentId: number; parentId: number;
pageLayout: string; pageLayout?: string;
iconImage?: number;
iconColor?: number;
enabled?: "0" | "1";
visible?: "0" | "1";
orderNum?: number;
}) { }) {
const staff = await requirePermission(PERMS.CATALOG_EDIT); const staff = await requirePermission(PERMS.CATALOG_EDIT);
const created = await prisma.catalogPagesBc.create({ const created = await prisma.catalogPagesBc.create({
data: { data: {
caption: input.caption, caption: input.caption,
parentId: input.parentId, parentId: input.parentId,
pageLayout: input.pageLayout, pageLayout: input.pageLayout ?? "default_3x3",
iconColor: 0, iconColor: input.iconColor ?? 0,
iconImage: 0, iconImage: input.iconImage ?? 0,
orderNum: 0, orderNum: input.orderNum ?? 0,
visible: "1", visible: input.visible ?? "1",
enabled: "1", enabled: input.enabled ?? "1",
pageHeadline: "", pageHeadline: "",
pageTeaser: "", pageTeaser: "",
}, },
@@ -194,6 +199,104 @@ export async function createBcPage(input: {
targetType: "catalog_page_bc", targetType: "catalog_page_bc",
targetId: created.id, targetId: created.id,
}); });
revalidatePath("/admin/catalog");
revalidatePath("/admin/catalog/builder-club"); revalidatePath("/admin/catalog/builder-club");
return { ok: true as const, data: { id: created.id } }; return { ok: true as const, data: { id: created.id } };
} }
async function moveBcPage(pageId: number, newParentId: number): Promise<void> {
if (newParentId > 0) {
let currentId = newParentId;
for (let i = 0; i < 50; i++) {
if (currentId === pageId) {
throw new Error("Cannot move page: would create a circular hierarchy");
}
const parent = await prisma.catalogPagesBc.findUnique({
where: { id: currentId },
select: { parentId: true },
});
if (!parent || parent.parentId <= 0) break;
currentId = parent.parentId;
}
}
await prisma.catalogPagesBc.update({
where: { id: pageId },
data: { parentId: newParentId },
});
}
export async function reorderBcTreePage(input: {
pageId: number;
newParentId?: number;
newOrderNum: number;
}) {
await requirePermission(PERMS.CATALOG_EDIT);
if (input.newParentId !== undefined) {
try {
await moveBcPage(input.pageId, input.newParentId);
} catch (err) {
return {
ok: false as const,
error: err instanceof Error ? err.message : "Invalid move",
};
}
}
await prisma.catalogPagesBc.update({
where: { id: input.pageId },
data: { orderNum: input.newOrderNum },
});
await rcon.updateCatalog();
revalidatePath("/admin/catalog");
revalidatePath("/admin/catalog/builder-club");
return { ok: true as const, data: {} };
}
export async function deleteBcTreePage(input: {
pageId: number;
mode: "reparent" | "cascade";
}) {
await requirePermission(PERMS.CATALOG_EDIT);
const page = await prisma.catalogPagesBc.findUnique({
where: { id: input.pageId },
select: { parentId: true },
});
if (!page) return { ok: false as const, error: "Page not found" };
if (input.mode === "reparent") {
await prisma.$transaction([
prisma.catalogPagesBc.updateMany({
where: { parentId: input.pageId },
data: { parentId: page.parentId },
}),
prisma.catalogItemsBc.deleteMany({ where: { pageId: input.pageId } }),
prisma.catalogPagesBc.delete({ where: { id: input.pageId } }),
]);
} else {
const toDelete: number[] = [input.pageId];
const queue: number[] = [input.pageId];
while (queue.length > 0) {
const children = await prisma.catalogPagesBc.findMany({
where: { parentId: { in: queue } },
select: { id: true },
});
queue.length = 0;
for (const child of children) {
toDelete.push(child.id);
queue.push(child.id);
}
}
await prisma.$transaction([
prisma.catalogItemsBc.deleteMany({
where: { pageId: { in: toDelete } },
}),
prisma.catalogPagesBc.deleteMany({
where: { id: { in: toDelete } },
}),
]);
}
await rcon.updateCatalog();
revalidatePath("/admin/catalog");
revalidatePath("/admin/catalog/builder-club");
return { ok: true as const, data: {} };
}
+15
View File
@@ -208,6 +208,19 @@ export default async function CatalogPagesPage({
</p> </p>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<CatalogViewToggle currentView={viewMode} catalogType="bc" /> <CatalogViewToggle currentView={viewMode} catalogType="bc" />
{canEdit && (
<CatalogManagerDialog
catalogType="bc"
canEdit={canEdit}
initialRoots={bcRootPages.map((p) => ({
id: p.id,
caption: p.caption,
iconImage: p.iconImage,
enabled: p.enabled,
orderNum: 0,
}))}
/>
)}
{canEdit && <CatalogActions catalogType="bc" />} {canEdit && <CatalogActions catalogType="bc" />}
</div> </div>
</div> </div>
@@ -383,6 +396,8 @@ export default async function CatalogPagesPage({
<CatalogViewToggle currentView={viewMode} catalogType="normal" /> <CatalogViewToggle currentView={viewMode} catalogType="normal" />
{canEdit && ( {canEdit && (
<CatalogManagerDialog <CatalogManagerDialog
catalogType="normal"
canEdit={canEdit}
initialRoots={rootPages.map((p) => ({ initialRoots={rootPages.map((p) => ({
id: p.id, id: p.id,
caption: p.caption, caption: p.caption,
@@ -5,8 +5,9 @@ import { Fragment, useEffect } from "react";
import { useCatalogManager } from "./use-catalog-store"; import { useCatalogManager } from "./use-catalog-store";
export function BreadcrumbBar() { export function BreadcrumbBar() {
const { state, dispatch } = useCatalogManager(); const { state, dispatch, catalogType } = useCatalogManager();
const { selectedPageId, ancestors } = state; const { selectedPageId, ancestors } = state;
const catQs = catalogType === "bc" ? "&catalog=bc" : "";
useEffect(() => { useEffect(() => {
if (!selectedPageId || selectedPageId <= 0) { if (!selectedPageId || selectedPageId <= 0) {
@@ -14,7 +15,7 @@ export function BreadcrumbBar() {
return; return;
} }
const ac = new AbortController(); const ac = new AbortController();
fetch(`/api/admin/catalog/tree?ancestors=${selectedPageId}`, { fetch(`/api/admin/catalog/tree?ancestors=${selectedPageId}${catQs}`, {
signal: ac.signal, signal: ac.signal,
}) })
.then((r) => r.json()) .then((r) => r.json())
@@ -25,7 +26,7 @@ export function BreadcrumbBar() {
}) })
.catch(() => {}); .catch(() => {});
return () => ac.abort(); return () => ac.abort();
}, [selectedPageId, dispatch]); }, [selectedPageId, dispatch, catQs]);
if (!selectedPageId || ancestors.length === 0) return null; if (!selectedPageId || ancestors.length === 0) return null;
@@ -5,6 +5,7 @@ import { useCallback, useEffect, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { toast } from "sonner"; import { toast } from "sonner";
import { createCatalogPage } from "@/actions/catalog"; import { createCatalogPage } from "@/actions/catalog";
import { createBcPage } from "@/actions/catalog-bc";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { TooltipProvider } from "@/components/ui/tooltip"; import { TooltipProvider } from "@/components/ui/tooltip";
import type { RootTab } from "@/types/catalog"; import type { RootTab } from "@/types/catalog";
@@ -13,7 +14,12 @@ import { InlineEditor, type PageUpdateData } from "./inline-editor";
import { RootTabs } from "./root-tabs"; import { RootTabs } from "./root-tabs";
import { SortableTree } from "./sortable-tree"; import { SortableTree } from "./sortable-tree";
import { StatsBar } from "./stats-bar"; import { StatsBar } from "./stats-bar";
import { CatalogManagerProvider, useCatalogManager } from "./use-catalog-store"; import {
CatalogManagerProvider,
type CatalogManagerMode,
catalogTreeQs,
useCatalogManager,
} from "./use-catalog-store";
/** /**
* Visual Catalog Manager — matches habbo-next UX (full-viewport editor). * Visual Catalog Manager — matches habbo-next UX (full-viewport editor).
@@ -21,8 +27,12 @@ import { CatalogManagerProvider, useCatalogManager } from "./use-catalog-store";
*/ */
export function CatalogManagerDialog({ export function CatalogManagerDialog({
initialRoots = [], initialRoots = [],
catalogType = "normal",
canEdit = false,
}: { }: {
initialRoots?: RootTab[]; initialRoots?: RootTab[];
catalogType?: CatalogManagerMode;
canEdit?: boolean;
}) { }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
@@ -46,6 +56,9 @@ export function CatalogManagerDialog({
}; };
}, [open]); }, [open]);
const title =
catalogType === "bc" ? "BC Visual Manager" : "Visual Manager";
return ( return (
<> <>
<Button <Button
@@ -55,7 +68,7 @@ export function CatalogManagerDialog({
onClick={() => setOpen(true)} onClick={() => setOpen(true)}
> >
<LayoutGrid className="h-4 w-4" /> <LayoutGrid className="h-4 w-4" />
Visual Manager {title}
</Button> </Button>
{mounted && open {mounted && open
? createPortal( ? createPortal(
@@ -68,7 +81,10 @@ export function CatalogManagerDialog({
className="fixed top-[2.5vh] left-[1vw] z-[201] flex h-[95vh] max-h-[95vh] w-[98vw] max-w-[98vw] flex-col overflow-hidden rounded-lg border border-[var(--admin-border)] bg-[var(--admin-canvas)] text-[var(--admin-text)] shadow-2xl" className="fixed top-[2.5vh] left-[1vw] z-[201] flex h-[95vh] max-h-[95vh] w-[98vw] max-w-[98vw] flex-col overflow-hidden rounded-lg border border-[var(--admin-border)] bg-[var(--admin-canvas)] text-[var(--admin-text)] shadow-2xl"
style={{ backgroundColor: "var(--admin-canvas)" }} style={{ backgroundColor: "var(--admin-canvas)" }}
> >
<CatalogManagerProvider> <CatalogManagerProvider
catalogType={catalogType}
canEdit={canEdit}
>
<CatalogManagerInner <CatalogManagerInner
open={open} open={open}
onOpenChange={setOpen} onOpenChange={setOpen}
@@ -93,12 +109,15 @@ function CatalogManagerInner({
onOpenChange: (v: boolean) => void; onOpenChange: (v: boolean) => void;
initialRoots: RootTab[]; initialRoots: RootTab[];
}) { }) {
const { state, dispatch } = useCatalogManager(); const { state, dispatch, catalogType, canEdit } = useCatalogManager();
const catQs = catalogTreeQs(catalogType);
const loadRootPages = useCallback(async () => { const loadRootPages = useCallback(async () => {
dispatch({ type: "SET_LOADING_ROOT", loading: true }); dispatch({ type: "SET_LOADING_ROOT", loading: true });
try { try {
const res = await fetch("/api/admin/catalog/tree?parentId=-1"); const res = await fetch(
`/api/admin/catalog/tree?parentId=-1${catQs}`,
);
const data = await res.json().catch(() => ({})); const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "Failed to load roots"); if (!res.ok) throw new Error(data.error || "Failed to load roots");
const pages: RootTab[] = (data.pages || []).map( const pages: RootTab[] = (data.pages || []).map(
@@ -122,7 +141,7 @@ function CatalogManagerInner({
toast.error("Failed to load catalog root pages"); toast.error("Failed to load catalog root pages");
dispatch({ type: "SET_LOADING_ROOT", loading: false }); dispatch({ type: "SET_LOADING_ROOT", loading: false });
} }
}, [dispatch, state.activeTabId]); }, [dispatch, state.activeTabId, catQs]);
// Seed from SSR immediately, then refresh from API. // Seed from SSR immediately, then refresh from API.
useEffect(() => { useEffect(() => {
@@ -157,13 +176,20 @@ function CatalogManagerInner({
}; };
const handleCreateRoot = async () => { const handleCreateRoot = async () => {
if (!canEdit) return;
const caption = prompt("Root page name:"); const caption = prompt("Root page name:");
if (!caption?.trim()) return; if (!caption?.trim()) return;
try { try {
const result = await createCatalogPage({ const result =
parentId: -1, catalogType === "bc"
caption: caption.trim(), ? await createBcPage({
}); parentId: -1,
caption: caption.trim(),
})
: await createCatalogPage({
parentId: -1,
caption: caption.trim(),
});
if (!result.ok) throw new Error(result.error); if (!result.ok) throw new Error(result.error);
await loadRootPages(); await loadRootPages();
toast.success("Root page created"); toast.success("Root page created");
@@ -211,6 +237,11 @@ function CatalogManagerInner({
} }
}; };
const heading =
catalogType === "bc"
? "BC Visual Catalog Manager"
: "Visual Catalog Manager";
return ( return (
<TooltipProvider delayDuration={300}> <TooltipProvider delayDuration={300}>
<div <div
@@ -222,7 +253,7 @@ function CatalogManagerInner({
className="flex items-center gap-2 text-base font-semibold leading-none" className="flex items-center gap-2 text-base font-semibold leading-none"
> >
<LayoutGrid className="h-4 w-4" /> <LayoutGrid className="h-4 w-4" />
Visual Catalog Manager {heading}
</h2> </h2>
<Button <Button
type="button" type="button"
@@ -15,6 +15,7 @@ import Link from "next/link";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { updateCatalogPage } from "@/actions/catalog"; import { updateCatalogPage } from "@/actions/catalog";
import { updateBcPage } from "@/actions/catalog-bc";
import type { CatalogItemData } from "@/app/admin/catalog/[id]/catalog-items-table"; import type { CatalogItemData } from "@/app/admin/catalog/[id]/catalog-items-table";
import { CatalogItemsTable } from "@/app/admin/catalog/[id]/catalog-items-table"; import { CatalogItemsTable } from "@/app/admin/catalog/[id]/catalog-items-table";
import { CatalogTranslateTab } from "@/app/admin/catalog/[id]/catalog-translate-tab"; import { CatalogTranslateTab } from "@/app/admin/catalog/[id]/catalog-translate-tab";
@@ -45,7 +46,7 @@ import {
} from "@/lib/catalog-layouts"; } from "@/lib/catalog-layouts";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { ImagePreview } from "./image-preview"; import { ImagePreview } from "./image-preview";
import { useCatalogManager } from "./use-catalog-store"; import { catalogTreeQs, useCatalogManager } from "./use-catalog-store";
interface PageDetail { interface PageDetail {
id: number; id: number;
@@ -104,7 +105,12 @@ interface InlineEditorProps {
type FormState = Record<string, string | number | null>; type FormState = Record<string, string | number | null>;
export function InlineEditor({ pageId, onSaved }: InlineEditorProps) { export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
const { dispatch } = useCatalogManager(); const { dispatch, catalogType, canEdit } = useCatalogManager();
const catQs = catalogTreeQs(catalogType);
const pageHref =
catalogType === "bc"
? `/admin/catalog/builder-club/${pageId}`
: `/admin/catalog/${pageId}`;
const [page, setPage] = useState<PageDetail | null>(null); const [page, setPage] = useState<PageDetail | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@@ -133,48 +139,53 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
function onKeyDown(e: KeyboardEvent) { function onKeyDown(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key === "s") { if ((e.metaKey || e.ctrlKey) && e.key === "s") {
e.preventDefault(); e.preventDefault();
if (isDirty && !saving && page) handleSave(); if (canEdit && isDirty && !saving && page) handleSave();
} }
} }
document.addEventListener("keydown", onKeyDown); document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown); return () => document.removeEventListener("keydown", onKeyDown);
}); // eslint-disable-line react-hooks/exhaustive-deps }); // eslint-disable-line react-hooks/exhaustive-deps
const loadPage = useCallback(async (id: number) => { const loadPage = useCallback(
setLoading(true); async (id: number) => {
try { setLoading(true);
const res = await fetch(`/api/admin/catalog/tree?pageId=${id}`); try {
if (!res.ok) throw new Error(); const res = await fetch(
const data = await res.json(); `/api/admin/catalog/tree?pageId=${id}${catQs}`,
const p = data.page as PageDetail; );
setPage(p); if (!res.ok) throw new Error();
const formData: FormState = { const data = await res.json();
caption: p.caption, const p = data.page as PageDetail;
pageLayout: p.pageLayout, setPage(p);
enabled: String(p.enabled), const formData: FormState = {
visible: String(p.visible), caption: p.caption,
minRank: p.minRank, pageLayout: p.pageLayout,
clubOnly: String(p.clubOnly), enabled: String(p.enabled),
orderNum: p.orderNum, visible: String(p.visible),
iconImage: p.iconImage, minRank: p.minRank ?? 0,
iconColor: p.iconColor, clubOnly: String(p.clubOnly ?? "0"),
pageHeadline: p.pageHeadline || "", orderNum: p.orderNum,
pageTeaser: p.pageTeaser || "", iconImage: p.iconImage,
pageSpecial: p.pageSpecial || "", iconColor: p.iconColor,
pageText1: p.pageText1 || "", pageHeadline: p.pageHeadline || "",
pageText2: p.pageText2 || "", pageTeaser: p.pageTeaser || "",
pageTextDetails: p.pageTextDetails || "", pageSpecial: p.pageSpecial || "",
pageTextTeaser: p.pageTextTeaser || "", pageText1: p.pageText1 || "",
}; pageText2: p.pageText2 || "",
setForm(formData); pageTextDetails: p.pageTextDetails || "",
setOriginal(formData); pageTextTeaser: p.pageTextTeaser || "",
} catch { };
toast.error("Failed to load page"); setForm(formData);
setPage(null); setOriginal(formData);
} finally { } catch {
setLoading(false); toast.error("Failed to load page");
} setPage(null);
}, []); } finally {
setLoading(false);
}
},
[catQs],
);
const loadItemsData = useCallback(async (id: number) => { const loadItemsData = useCallback(async (id: number) => {
setItemsLoading(true); setItemsLoading(true);
@@ -211,8 +222,9 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
} }
}, [pageId, loadPage]); }, [pageId, loadPage]);
// Lazy-load items // Lazy-load items (shop catalog only — BC items live on the detail page)
useEffect(() => { useEffect(() => {
if (catalogType === "bc") return;
if ( if (
(activeTab === "items" || activeTab === "translate") && (activeTab === "items" || activeTab === "translate") &&
!itemsLoaded && !itemsLoaded &&
@@ -222,17 +234,28 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
) { ) {
loadItemsData(pageId); loadItemsData(pageId);
} }
}, [activeTab, itemsLoaded, itemsLoading, pageId, loadItemsData]); }, [
activeTab,
itemsLoaded,
itemsLoading,
pageId,
loadItemsData,
catalogType,
]);
function update(key: string, value: string | number | null) { function update(key: string, value: string | number | null) {
if (!canEdit) return;
setForm((prev) => ({ ...prev, [key]: value })); setForm((prev) => ({ ...prev, [key]: value }));
} }
async function handleSave() { async function handleSave() {
if (!page) return; if (!page || !canEdit) return;
setSaving(true); setSaving(true);
try { try {
const result = await updateCatalogPage({ id: page.id, ...form }); const result =
catalogType === "bc"
? await updateBcPage({ id: page.id, ...form })
: await updateCatalogPage({ id: page.id, ...form });
if (!result.ok) throw new Error(result.error || "Failed to save"); if (!result.ok) throw new Error(result.error || "Failed to save");
toast.success("Page updated & catalog synced"); toast.success("Page updated & catalog synced");
setOriginal({ ...form }); setOriginal({ ...form });
@@ -323,7 +346,7 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
</p> </p>
</div> </div>
</div> </div>
<Link href={`/admin/catalog/${page.id}`} target="_blank"> <Link href={pageHref} target="_blank">
<Button variant="ghost" size="sm" className="h-7 text-xs gap-1"> <Button variant="ghost" size="sm" className="h-7 text-xs gap-1">
<ExternalLink className="h-3 w-3" /> <ExternalLink className="h-3 w-3" />
Full editor Full editor
@@ -347,7 +370,7 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
variant="outline" variant="outline"
className="h-6 text-[11px] px-2" className="h-6 text-[11px] px-2"
onClick={handleSave} onClick={handleSave}
disabled={saving} disabled={saving || !canEdit}
> >
{saving ? ( {saving ? (
<Loader2 className="mr-1 h-3 w-3 animate-spin" /> <Loader2 className="mr-1 h-3 w-3 animate-spin" />
@@ -609,7 +632,7 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
<div className="pt-2"> <div className="pt-2">
<Button <Button
onClick={handleSave} onClick={handleSave}
disabled={saving || !isDirty} disabled={saving || !isDirty || !canEdit}
className="w-full" className="w-full"
> >
{saving ? ( {saving ? (
@@ -628,7 +651,19 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
value="items" value="items"
className="flex-1 overflow-y-auto px-4 pb-4 mt-2" className="flex-1 overflow-y-auto px-4 pb-4 mt-2"
> >
{itemsLoading ? ( {catalogType === "bc" ? (
<div className="rounded-md border border-dashed px-4 py-8 text-center text-sm text-muted-foreground space-y-3">
<p>
BC items are managed on the full Builder Club page editor.
</p>
<Link href={pageHref} target="_blank">
<Button variant="outline" size="sm" className="gap-1">
<ExternalLink className="h-3 w-3" />
Open BC page editor
</Button>
</Link>
</div>
) : itemsLoading ? (
<div className="flex items-center justify-center py-12"> <div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /> <Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div> </div>
@@ -636,11 +671,18 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
<div className="space-y-3"> <div className="space-y-3">
{/* Toolbar */} {/* Toolbar */}
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<QuickAddFurni {canEdit && (
pageId={page.id} <>
furniRevisionMap={itemsData.furniRevisionMap} <QuickAddFurni
/> pageId={page.id}
<BulkImportItems pageId={page.id} onImported={reloadItems} /> furniRevisionMap={itemsData.furniRevisionMap}
/>
<BulkImportItems
pageId={page.id}
onImported={reloadItems}
/>
</>
)}
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
@@ -657,7 +699,7 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
items={itemsData.items} items={itemsData.items}
furniRevisionMap={itemsData.furniRevisionMap} furniRevisionMap={itemsData.furniRevisionMap}
layout={page.pageLayout} layout={page.pageLayout}
canEdit={true} canEdit={canEdit}
/> />
)} )}
@@ -666,7 +708,7 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
items={itemsData.items} items={itemsData.items}
pageId={page.id} pageId={page.id}
pageLayout={page.pageLayout} pageLayout={page.pageLayout}
canEdit={true} canEdit={canEdit}
interactionTypes={itemsData.interactionTypes} interactionTypes={itemsData.interactionTypes}
furniRevisionMap={itemsData.furniRevisionMap} furniRevisionMap={itemsData.furniRevisionMap}
allPages={itemsData.allPages} allPages={itemsData.allPages}
@@ -680,7 +722,11 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
value="translate" value="translate"
className="flex-1 overflow-y-auto px-4 pb-4 mt-2" className="flex-1 overflow-y-auto px-4 pb-4 mt-2"
> >
{itemsLoading ? ( {catalogType === "bc" ? (
<div className="rounded-md border border-dashed px-4 py-8 text-center text-sm text-muted-foreground">
Translate tools are available for the shop catalog only.
</div>
) : itemsLoading ? (
<div className="flex items-center justify-center py-12"> <div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /> <Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div> </div>
@@ -688,7 +734,7 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
<CatalogTranslateTab <CatalogTranslateTab
baseItems={itemsData.baseItems} baseItems={itemsData.baseItems}
catalogNameMap={itemsData.catalogNameMap} catalogNameMap={itemsData.catalogNameMap}
canEdit={true} canEdit={canEdit}
furniDataIdList={itemsData.furniDataIdList} furniDataIdList={itemsData.furniDataIdList}
furniDescriptionMap={itemsData.furniDescriptionMap} furniDescriptionMap={itemsData.furniDescriptionMap}
furniRevisionMap={itemsData.furniRevisionMap} furniRevisionMap={itemsData.furniRevisionMap}
@@ -25,6 +25,11 @@ import {
reorderTreePage, reorderTreePage,
toggleCatalogPage, toggleCatalogPage,
} from "@/actions/catalog"; } from "@/actions/catalog";
import {
deleteBcTreePage,
reorderBcTreePage,
toggleBcPage,
} from "@/actions/catalog-bc";
import { useConfirmDialog } from "@/components/admin/confirm-dialog"; import { useConfirmDialog } from "@/components/admin/confirm-dialog";
import { CatalogIcon } from "@/components/admin/catalog-tree"; import { CatalogIcon } from "@/components/admin/catalog-tree";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -36,6 +41,7 @@ import {
ContextMenuTrigger, ContextMenuTrigger,
} from "@/components/ui/context-menu"; } from "@/components/ui/context-menu";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useCatalogManager } from "./use-catalog-store";
export type { RootTab } from "@/types/catalog"; export type { RootTab } from "@/types/catalog";
@@ -58,6 +64,7 @@ export function RootTabs({
onCreateRoot, onCreateRoot,
loading, loading,
}: RootTabsProps) { }: RootTabsProps) {
const { catalogType, canEdit } = useCatalogManager();
const { confirm, dialog: confirmDialog } = useConfirmDialog(); const { confirm, dialog: confirmDialog } = useConfirmDialog();
const [activeDragId, setActiveDragId] = useState<number | null>(null); const [activeDragId, setActiveDragId] = useState<number | null>(null);
const sensors = useSensors( const sensors = useSensors(
@@ -78,13 +85,16 @@ export function RootTabs({
const newIndex = tabs.findIndex((t) => t.id === over.id); const newIndex = tabs.findIndex((t) => t.id === over.id);
if (oldIndex === -1 || newIndex === -1) return; if (oldIndex === -1 || newIndex === -1) return;
if (!canEdit) return;
const newOrder = arrayMove(tabs, oldIndex, newIndex); const newOrder = arrayMove(tabs, oldIndex, newIndex);
// Batch reorder // Batch reorder
try { try {
const reorder =
catalogType === "bc" ? reorderBcTreePage : reorderTreePage;
const results = await Promise.allSettled( const results = await Promise.allSettled(
newOrder.map((tab, idx) => newOrder.map((tab, idx) =>
reorderTreePage({ pageId: tab.id, newOrderNum: idx + 1 }), reorder({ pageId: tab.id, newOrderNum: idx + 1 }),
), ),
); );
const failed = results.filter((r) => r.status === "rejected").length; const failed = results.filter((r) => r.status === "rejected").length;
@@ -94,12 +104,17 @@ export function RootTabs({
toast.error("Failed to reorder tabs"); toast.error("Failed to reorder tabs");
} }
}, },
[tabs], [tabs, canEdit, catalogType],
); );
const handleToggle = async (tab: RootTab) => { const handleToggle = async (tab: RootTab) => {
if (!canEdit) return;
try { try {
await toggleCatalogPage({ id: tab.id, action: "toggleEnabled" }); if (catalogType === "bc") {
await toggleBcPage({ id: tab.id, field: "enabled" });
} else {
await toggleCatalogPage({ id: tab.id, action: "toggleEnabled" });
}
toast.success( toast.success(
`${tab.caption} ${tab.enabled === "1" ? "disabled" : "enabled"}`, `${tab.caption} ${tab.enabled === "1" ? "disabled" : "enabled"}`,
); );
@@ -109,6 +124,7 @@ export function RootTabs({
}; };
const handleDelete = async (tab: RootTab) => { const handleDelete = async (tab: RootTab) => {
if (!canEdit) return;
const ok = await confirm({ const ok = await confirm({
title: "Delete root page", title: "Delete root page",
description: `Delete root page "${tab.caption}"? Children will be reparented.`, description: `Delete root page "${tab.caption}"? Children will be reparented.`,
@@ -116,7 +132,11 @@ export function RootTabs({
}); });
if (!ok) return; if (!ok) return;
try { try {
await deleteTreePage({ pageId: tab.id, mode: "reparent" }); if (catalogType === "bc") {
await deleteBcTreePage({ pageId: tab.id, mode: "reparent" });
} else {
await deleteTreePage({ pageId: tab.id, mode: "reparent" });
}
toast.success(`"${tab.caption}" deleted`); toast.success(`"${tab.caption}" deleted`);
} catch { } catch {
toast.error("Failed to delete"); toast.error("Failed to delete");
@@ -168,16 +188,18 @@ export function RootTabs({
)} )}
</DragOverlay> </DragOverlay>
</DndContext> </DndContext>
<Button {canEdit && (
type="button" <Button
variant="ghost" type="button"
size="icon" variant="ghost"
className="h-7 w-7 shrink-0 ml-1" size="icon"
title="New root page" className="h-7 w-7 shrink-0 ml-1"
onClick={onCreateRoot} title="New root page"
> onClick={onCreateRoot}
<Plus className="h-3.5 w-3.5" /> >
</Button> <Plus className="h-3.5 w-3.5" />
</Button>
)}
</div> </div>
); );
} }
@@ -40,6 +40,13 @@ import {
toggleCatalogPage, toggleCatalogPage,
updateCatalogPage, updateCatalogPage,
} from "@/actions/catalog"; } from "@/actions/catalog";
import {
createBcPage,
deleteBcTreePage,
reorderBcTreePage,
toggleBcPage,
updateBcPage,
} from "@/actions/catalog-bc";
import { useConfirmDialog } from "@/components/admin/confirm-dialog"; import { useConfirmDialog } from "@/components/admin/confirm-dialog";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -56,7 +63,7 @@ import {
TreeItemContent, TreeItemContent,
type TreeItemProps, type TreeItemProps,
} from "./tree-item"; } from "./tree-item";
import { useCatalogManager } from "./use-catalog-store"; import { catalogTreeQs, useCatalogManager } from "./use-catalog-store";
/* ─── Types ──────────────────────────────────────────────── */ /* ─── Types ──────────────────────────────────────────────── */
@@ -83,7 +90,10 @@ export function SortableTree({
onTreeMutation, onTreeMutation,
nodeUpdate, nodeUpdate,
}: SortableTreeProps) { }: SortableTreeProps) {
const { state, dispatch } = useCatalogManager(); const { state, dispatch, catalogType, canEdit } = useCatalogManager();
const catQs = catalogTreeQs(catalogType);
const pageBase =
catalogType === "bc" ? "/admin/catalog/builder-club" : "/admin/catalog";
const { confirm, dialog: confirmDialog } = useConfirmDialog(); const { confirm, dialog: confirmDialog } = useConfirmDialog();
const { const {
nodes, nodes,
@@ -137,13 +147,13 @@ export function SortableTree({
const loadChildren = useCallback( const loadChildren = useCallback(
async (parentId: number): Promise<TreeNodeData[]> => { async (parentId: number): Promise<TreeNodeData[]> => {
const res = await fetch( const res = await fetch(
`/api/admin/catalog/tree?parentId=${parentId}`, `/api/admin/catalog/tree?parentId=${parentId}${catQs}`,
); );
const data = await res.json().catch(() => ({})); const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "Failed to load pages"); if (!res.ok) throw new Error(data.error || "Failed to load pages");
return normalizePages(data.pages || []); return normalizePages(data.pages || []);
}, },
[normalizePages], [normalizePages, catQs],
); );
const loadTabTree = useCallback(async () => { const loadTabTree = useCallback(async () => {
@@ -188,7 +198,7 @@ export function SortableTree({
setSearching(true); setSearching(true);
try { try {
const res = await fetch( const res = await fetch(
`/api/admin/catalog/tree?search=${encodeURIComponent(searchQuery.trim())}`, `/api/admin/catalog/tree?search=${encodeURIComponent(searchQuery.trim())}${catQs}`,
); );
if (res.ok) { if (res.ok) {
const data = await res.json(); const data = await res.json();
@@ -201,7 +211,7 @@ export function SortableTree({
return () => { return () => {
if (searchTimeout.current) clearTimeout(searchTimeout.current); if (searchTimeout.current) clearTimeout(searchTimeout.current);
}; };
}, [searchQuery]); }, [searchQuery, catQs]);
// Apply external node updates // Apply external node updates
useEffect(() => { useEffect(() => {
@@ -435,10 +445,13 @@ export function SortableTree({
children: newOrder.map((id) => nodes.get(id)!).filter(Boolean), children: newOrder.map((id) => nodes.get(id)!).filter(Boolean),
}); });
if (!canEdit) return;
const reorder =
catalogType === "bc" ? reorderBcTreePage : reorderTreePage;
try { try {
const results = await Promise.allSettled( const results = await Promise.allSettled(
newOrder.map((id, idx) => newOrder.map((id, idx) =>
reorderTreePage({ pageId: id, newOrderNum: idx + 1 }), reorder({ pageId: id, newOrderNum: idx + 1 }),
), ),
); );
const failed = results.filter((r) => r.status === "rejected").length; const failed = results.filter((r) => r.status === "rejected").length;
@@ -450,11 +463,14 @@ export function SortableTree({
loadFullTree(); loadFullTree();
} }
} else { } else {
if (!canEdit) return;
const reorder =
catalogType === "bc" ? reorderBcTreePage : reorderTreePage;
const newParentId = overNode.parentId; const newParentId = overNode.parentId;
const siblings = childrenMap.get(newParentId) || []; const siblings = childrenMap.get(newParentId) || [];
const overIndex = siblings.indexOf(over.id as number); const overIndex = siblings.indexOf(over.id as number);
try { try {
const result = await reorderTreePage({ const result = await reorder({
pageId: active.id as number, pageId: active.id as number,
newParentId, newParentId,
newOrderNum: overIndex >= 0 ? overIndex + 1 : siblings.length + 1, newOrderNum: overIndex >= 0 ? overIndex + 1 : siblings.length + 1,
@@ -471,18 +487,25 @@ export function SortableTree({
// ── Actions ───────────────────────────────────────────────── // ── Actions ─────────────────────────────────────────────────
const handleAddSubpage = (parentId: number) => { const handleAddSubpage = (parentId: number) => {
if (!canEdit) return;
setSubpageParentId(parentId); setSubpageParentId(parentId);
setSubpageName(""); setSubpageName("");
}; };
const handleCreateSubpage = async () => { const handleCreateSubpage = async () => {
if (subpageParentId === null || !subpageName.trim()) return; if (!canEdit || subpageParentId === null || !subpageName.trim()) return;
setCreatingSubpage(true); setCreatingSubpage(true);
try { try {
const result = await createCatalogPage({ const result =
parentId: subpageParentId, catalogType === "bc"
caption: subpageName.trim(), ? await createBcPage({
}); parentId: subpageParentId,
caption: subpageName.trim(),
})
: await createCatalogPage({
parentId: subpageParentId,
caption: subpageName.trim(),
});
if (!result.ok) throw new Error(result.error); if (!result.ok) throw new Error(result.error);
dispatch({ type: "SET_EXPANDED", id: subpageParentId, expanded: true }); dispatch({ type: "SET_EXPANDED", id: subpageParentId, expanded: true });
await loadFullTree(); await loadFullTree();
@@ -500,9 +523,13 @@ export function SortableTree({
}; };
const handleToggleEnabled = async (id: number, currentEnabled: string) => { const handleToggleEnabled = async (id: number, currentEnabled: string) => {
if (!canEdit) return;
const newEnabled = currentEnabled === "1" ? "0" : "1"; const newEnabled = currentEnabled === "1" ? "0" : "1";
try { try {
const result = await toggleCatalogPage({ id, action: "toggleEnabled" }); const result =
catalogType === "bc"
? await toggleBcPage({ id, field: "enabled" })
: await toggleCatalogPage({ id, action: "toggleEnabled" });
if (!result.ok) throw new Error(); if (!result.ok) throw new Error();
dispatch({ type: "UPDATE_NODE", update: { id, enabled: newEnabled } }); dispatch({ type: "UPDATE_NODE", update: { id, enabled: newEnabled } });
onTreeMutation(); onTreeMutation();
@@ -512,9 +539,13 @@ export function SortableTree({
}; };
const handleToggleVisible = async (id: number, currentVisible: string) => { const handleToggleVisible = async (id: number, currentVisible: string) => {
if (!canEdit) return;
const newVisible = currentVisible === "1" ? "0" : "1"; const newVisible = currentVisible === "1" ? "0" : "1";
try { try {
const result = await toggleCatalogPage({ id, action: "toggleVisible" }); const result =
catalogType === "bc"
? await toggleBcPage({ id, field: "visible" })
: await toggleCatalogPage({ id, action: "toggleVisible" });
if (!result.ok) throw new Error(); if (!result.ok) throw new Error();
dispatch({ type: "UPDATE_NODE", update: { id, visible: newVisible } }); dispatch({ type: "UPDATE_NODE", update: { id, visible: newVisible } });
onTreeMutation(); onTreeMutation();
@@ -524,6 +555,7 @@ export function SortableTree({
}; };
const handleDelete = async (id: number) => { const handleDelete = async (id: number) => {
if (!canEdit) return;
const node = nodes.get(id); const node = nodes.get(id);
const ok = await confirm({ const ok = await confirm({
title: "Delete page", title: "Delete page",
@@ -532,7 +564,10 @@ export function SortableTree({
}); });
if (!ok) return; if (!ok) return;
try { try {
const result = await deleteTreePage({ pageId: id, mode: "reparent" }); const result =
catalogType === "bc"
? await deleteBcTreePage({ pageId: id, mode: "reparent" })
: await deleteTreePage({ pageId: id, mode: "reparent" });
if (!result.ok) throw new Error(); if (!result.ok) throw new Error();
dispatch({ type: "REMOVE_NODE", id }); dispatch({ type: "REMOVE_NODE", id });
if (selectedPageId === id) onSelectPage(-1); if (selectedPageId === id) onSelectPage(-1);
@@ -544,32 +579,59 @@ export function SortableTree({
}; };
const handleDuplicate = async (id: number) => { const handleDuplicate = async (id: number) => {
if (!canEdit) return;
try { try {
const res = await fetch(`/api/admin/catalog/tree?pageId=${id}`); const res = await fetch(
`/api/admin/catalog/tree?pageId=${id}${catQs}`,
);
if (!res.ok) throw new Error(); if (!res.ok) throw new Error();
const { page } = await res.json(); const { page } = await res.json();
const result = await createCatalogPage({ const result =
parentId: page.parentId ?? -1, catalogType === "bc"
caption: `${page.caption} (copy)`, ? await createBcPage({
pageLayout: page.pageLayout ?? "default_3x3", parentId: page.parentId ?? -1,
iconImage: page.iconImage ?? 0, caption: `${page.caption} (copy)`,
iconColor: page.iconColor ?? 0, pageLayout: page.pageLayout ?? "default_3x3",
enabled: "0", iconImage: page.iconImage ?? 0,
visible: page.visible ?? "1", iconColor: page.iconColor ?? 0,
minRank: page.minRank ?? 1, enabled: "0",
orderNum: (page.orderNum ?? 0) + 1, visible: page.visible ?? "1",
}); orderNum: (page.orderNum ?? 0) + 1,
})
: await createCatalogPage({
parentId: page.parentId ?? -1,
caption: `${page.caption} (copy)`,
pageLayout: page.pageLayout ?? "default_3x3",
iconImage: page.iconImage ?? 0,
iconColor: page.iconColor ?? 0,
enabled: "0",
visible: page.visible ?? "1",
minRank: page.minRank ?? 1,
orderNum: (page.orderNum ?? 0) + 1,
});
if (!result.ok) throw new Error(result.error); if (!result.ok) throw new Error(result.error);
const newId = (result.data as Record<string, unknown>).id as number; const newId = (result.data as Record<string, unknown>).id as number;
await updateCatalogPage({ if (catalogType === "bc") {
id: newId, await updateBcPage({
pageHeadline: page.pageHeadline ?? "", id: newId,
pageTeaser: page.pageTeaser ?? "", pageHeadline: page.pageHeadline ?? "",
pageText1: page.pageText1, pageTeaser: page.pageTeaser ?? "",
pageText2: page.pageText2, pageText1: page.pageText1,
pageTextDetails: page.pageTextDetails, pageText2: page.pageText2,
pageTextTeaser: page.pageTextTeaser, pageTextDetails: page.pageTextDetails,
}); pageTextTeaser: page.pageTextTeaser,
});
} else {
await updateCatalogPage({
id: newId,
pageHeadline: page.pageHeadline ?? "",
pageTeaser: page.pageTeaser ?? "",
pageText1: page.pageText1,
pageText2: page.pageText2,
pageTextDetails: page.pageTextDetails,
pageTextTeaser: page.pageTextTeaser,
});
}
await loadFullTree(); await loadFullTree();
toast.success(`Duplicated "${page.caption}" (items not copied)`); toast.success(`Duplicated "${page.caption}" (items not copied)`);
onSelectPage(newId); onSelectPage(newId);
@@ -580,7 +642,7 @@ export function SortableTree({
}; };
const handleOpenFull = (id: number) => const handleOpenFull = (id: number) =>
window.open(`/admin/catalog/${id}`, "_blank"); window.open(`${pageBase}/${id}`, "_blank");
// ── Expand/Collapse all ───────────────────────────────────── // ── Expand/Collapse all ─────────────────────────────────────
const handleExpandAll = useCallback(() => { const handleExpandAll = useCallback(() => {
@@ -610,13 +672,16 @@ export function SortableTree({
// ── Bulk actions ──────────────────────────────────────────── // ── Bulk actions ────────────────────────────────────────────
const handleBulkEnable = async (enable: boolean) => { const handleBulkEnable = async (enable: boolean) => {
if (!canEdit) return;
const ids = [...multiSelectedIds]; const ids = [...multiSelectedIds];
const results = await Promise.allSettled( const results = await Promise.allSettled(
ids.map((id) => { ids.map((id) => {
const node = nodes.get(id); const node = nodes.get(id);
if (!node || (node.enabled === "1") === enable) if (!node || (node.enabled === "1") === enable)
return Promise.resolve(); return Promise.resolve();
return toggleCatalogPage({ id, action: "toggleEnabled" }); return catalogType === "bc"
? toggleBcPage({ id, field: "enabled" })
: toggleCatalogPage({ id, action: "toggleEnabled" });
}), }),
); );
for (const id of ids) for (const id of ids)
@@ -633,6 +698,7 @@ export function SortableTree({
}; };
const handleBulkDelete = async () => { const handleBulkDelete = async () => {
if (!canEdit) return;
const ids = [...multiSelectedIds]; const ids = [...multiSelectedIds];
const ok = await confirm({ const ok = await confirm({
title: "Delete pages", title: "Delete pages",
@@ -640,8 +706,9 @@ export function SortableTree({
confirmLabel: "Delete", confirmLabel: "Delete",
}); });
if (!ok) return; if (!ok) return;
const del = catalogType === "bc" ? deleteBcTreePage : deleteTreePage;
await Promise.allSettled( await Promise.allSettled(
ids.map((id) => deleteTreePage({ pageId: id, mode: "reparent" })), ids.map((id) => del({ pageId: id, mode: "reparent" })),
); );
dispatch({ type: "CLEAR_MULTI_SELECT" }); dispatch({ type: "CLEAR_MULTI_SELECT" });
await loadFullTree(); await loadFullTree();
@@ -768,25 +835,27 @@ export function SortableTree({
</button> </button>
) : null} ) : null}
</div> </div>
<Tooltip> {canEdit && (
<TooltipTrigger asChild> <Tooltip>
<Button <TooltipTrigger asChild>
variant={sortMode ? "default" : "outline"} <Button
size="icon" variant={sortMode ? "default" : "outline"}
className="h-7 w-7 shrink-0" size="icon"
onClick={() => dispatch({ type: "TOGGLE_SORT_MODE" })} className="h-7 w-7 shrink-0"
> onClick={() => dispatch({ type: "TOGGLE_SORT_MODE" })}
{sortMode ? ( >
<GripVertical className="h-3.5 w-3.5" /> {sortMode ? (
) : ( <GripVertical className="h-3.5 w-3.5" />
<MousePointer className="h-3.5 w-3.5" /> ) : (
)} <MousePointer className="h-3.5 w-3.5" />
</Button> )}
</TooltipTrigger> </Button>
<TooltipContent> </TooltipTrigger>
<p>{sortMode ? "Sort mode" : "Edit mode"}</p> <TooltipContent>
</TooltipContent> <p>{sortMode ? "Sort mode" : "Edit mode"}</p>
</Tooltip> </TooltipContent>
</Tooltip>
)}
</div> </div>
{/* Filters + expand/collapse */} {/* Filters + expand/collapse */}
@@ -314,19 +314,33 @@ function reducer(
// ── Context ─────────────────────────────────────────────────── // ── Context ───────────────────────────────────────────────────
export type CatalogManagerMode = "normal" | "bc";
interface CatalogManagerContextValue { interface CatalogManagerContextValue {
state: CatalogManagerState; state: CatalogManagerState;
dispatch: Dispatch<CatalogAction>; dispatch: Dispatch<CatalogAction>;
catalogType: CatalogManagerMode;
canEdit: boolean;
} }
const CatalogManagerContext = createContext<CatalogManagerContextValue | null>( const CatalogManagerContext = createContext<CatalogManagerContextValue | null>(
null, null,
); );
export function CatalogManagerProvider({ children }: { children: ReactNode }) { export function CatalogManagerProvider({
children,
catalogType = "normal",
canEdit = false,
}: {
children: ReactNode;
catalogType?: CatalogManagerMode;
canEdit?: boolean;
}) {
const [state, dispatch] = useReducer(reducer, initialState); const [state, dispatch] = useReducer(reducer, initialState);
return ( return (
<CatalogManagerContext.Provider value={{ state, dispatch }}> <CatalogManagerContext.Provider
value={{ state, dispatch, catalogType, canEdit }}
>
{children} {children}
</CatalogManagerContext.Provider> </CatalogManagerContext.Provider>
); );
@@ -340,3 +354,8 @@ export function useCatalogManager() {
); );
return ctx; return ctx;
} }
/** Append to tree API query strings when in Builder Club mode. */
export function catalogTreeQs(catalogType: CatalogManagerMode): string {
return catalogType === "bc" ? "&catalog=bc" : "";
}