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

@@ -15,6 +15,7 @@ import Link from "next/link";
import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { updateCatalogPage } from "@/actions/catalog";
import { updateBcPage } from "@/actions/catalog-bc";
import type { CatalogItemData } 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";
@@ -45,7 +46,7 @@ import {
} from "@/lib/catalog-layouts";
import { cn } from "@/lib/utils";
import { ImagePreview } from "./image-preview";
import { useCatalogManager } from "./use-catalog-store";
import { catalogTreeQs, useCatalogManager } from "./use-catalog-store";
interface PageDetail {
id: number;
@@ -104,7 +105,12 @@ interface InlineEditorProps {
type FormState = Record<string, string | number | null>;
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 [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
@@ -133,48 +139,53 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
function onKeyDown(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key === "s") {
e.preventDefault();
if (isDirty && !saving && page) handleSave();
if (canEdit && isDirty && !saving && page) handleSave();
}
}
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}); // eslint-disable-line react-hooks/exhaustive-deps
const loadPage = useCallback(async (id: number) => {
setLoading(true);
try {
const res = await fetch(`/api/admin/catalog/tree?pageId=${id}`);
if (!res.ok) throw new Error();
const data = await res.json();
const p = data.page as PageDetail;
setPage(p);
const formData: FormState = {
caption: p.caption,
pageLayout: p.pageLayout,
enabled: String(p.enabled),
visible: String(p.visible),
minRank: p.minRank,
clubOnly: String(p.clubOnly),
orderNum: p.orderNum,
iconImage: p.iconImage,
iconColor: p.iconColor,
pageHeadline: p.pageHeadline || "",
pageTeaser: p.pageTeaser || "",
pageSpecial: p.pageSpecial || "",
pageText1: p.pageText1 || "",
pageText2: p.pageText2 || "",
pageTextDetails: p.pageTextDetails || "",
pageTextTeaser: p.pageTextTeaser || "",
};
setForm(formData);
setOriginal(formData);
} catch {
toast.error("Failed to load page");
setPage(null);
} finally {
setLoading(false);
}
}, []);
const loadPage = useCallback(
async (id: number) => {
setLoading(true);
try {
const res = await fetch(
`/api/admin/catalog/tree?pageId=${id}${catQs}`,
);
if (!res.ok) throw new Error();
const data = await res.json();
const p = data.page as PageDetail;
setPage(p);
const formData: FormState = {
caption: p.caption,
pageLayout: p.pageLayout,
enabled: String(p.enabled),
visible: String(p.visible),
minRank: p.minRank ?? 0,
clubOnly: String(p.clubOnly ?? "0"),
orderNum: p.orderNum,
iconImage: p.iconImage,
iconColor: p.iconColor,
pageHeadline: p.pageHeadline || "",
pageTeaser: p.pageTeaser || "",
pageSpecial: p.pageSpecial || "",
pageText1: p.pageText1 || "",
pageText2: p.pageText2 || "",
pageTextDetails: p.pageTextDetails || "",
pageTextTeaser: p.pageTextTeaser || "",
};
setForm(formData);
setOriginal(formData);
} catch {
toast.error("Failed to load page");
setPage(null);
} finally {
setLoading(false);
}
},
[catQs],
);
const loadItemsData = useCallback(async (id: number) => {
setItemsLoading(true);
@@ -211,8 +222,9 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
}
}, [pageId, loadPage]);
// Lazy-load items
// Lazy-load items (shop catalog only — BC items live on the detail page)
useEffect(() => {
if (catalogType === "bc") return;
if (
(activeTab === "items" || activeTab === "translate") &&
!itemsLoaded &&
@@ -222,17 +234,28 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
) {
loadItemsData(pageId);
}
}, [activeTab, itemsLoaded, itemsLoading, pageId, loadItemsData]);
}, [
activeTab,
itemsLoaded,
itemsLoading,
pageId,
loadItemsData,
catalogType,
]);
function update(key: string, value: string | number | null) {
if (!canEdit) return;
setForm((prev) => ({ ...prev, [key]: value }));
}
async function handleSave() {
if (!page) return;
if (!page || !canEdit) return;
setSaving(true);
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");
toast.success("Page updated & catalog synced");
setOriginal({ ...form });
@@ -323,7 +346,7 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
</p>
</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">
<ExternalLink className="h-3 w-3" />
Full editor
@@ -347,7 +370,7 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
variant="outline"
className="h-6 text-[11px] px-2"
onClick={handleSave}
disabled={saving}
disabled={saving || !canEdit}
>
{saving ? (
<Loader2 className="mr-1 h-3 w-3 animate-spin" />
@@ -609,7 +632,7 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
<div className="pt-2">
<Button
onClick={handleSave}
disabled={saving || !isDirty}
disabled={saving || !isDirty || !canEdit}
className="w-full"
>
{saving ? (
@@ -628,7 +651,19 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
value="items"
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">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
@@ -636,11 +671,18 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
<div className="space-y-3">
{/* Toolbar */}
<div className="flex items-center gap-2 flex-wrap">
<QuickAddFurni
pageId={page.id}
furniRevisionMap={itemsData.furniRevisionMap}
/>
<BulkImportItems pageId={page.id} onImported={reloadItems} />
{canEdit && (
<>
<QuickAddFurni
pageId={page.id}
furniRevisionMap={itemsData.furniRevisionMap}
/>
<BulkImportItems
pageId={page.id}
onImported={reloadItems}
/>
</>
)}
<Button
variant="outline"
size="sm"
@@ -657,7 +699,7 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
items={itemsData.items}
furniRevisionMap={itemsData.furniRevisionMap}
layout={page.pageLayout}
canEdit={true}
canEdit={canEdit}
/>
)}
@@ -666,7 +708,7 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
items={itemsData.items}
pageId={page.id}
pageLayout={page.pageLayout}
canEdit={true}
canEdit={canEdit}
interactionTypes={itemsData.interactionTypes}
furniRevisionMap={itemsData.furniRevisionMap}
allPages={itemsData.allPages}
@@ -680,7 +722,11 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
value="translate"
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">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
@@ -688,7 +734,7 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
<CatalogTranslateTab
baseItems={itemsData.baseItems}
catalogNameMap={itemsData.catalogNameMap}
canEdit={true}
canEdit={canEdit}
furniDataIdList={itemsData.furniDataIdList}
furniDescriptionMap={itemsData.furniDescriptionMap}
furniRevisionMap={itemsData.furniRevisionMap}