feat(catalog): improve visual manager search and offer discovery
CI / check (push) Successful in 1m14s
CI / deploy (push) Successful in 1m29s

This commit is contained in:
Simo committed 2026-09-06 20:58:53 +02:00
1 parent 08321df2aa
commit 93a7e9a7c2
9 files changed
+505 -72

No files matched your search

@@ -1,8 +1,8 @@
"use client";
import { LayoutGrid, X } from "lucide-react";
import { LayoutGrid, Search, X } from "lucide-react";
import { useTranslations } from "next-intl";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { toast } from "sonner";
import { createCatalogPage } from "@/actions/catalog";
@@ -12,6 +12,8 @@ import { TooltipProvider } from "@/components/ui/tooltip";
import type { CatalogSelection } from "@/features/catalog/client/catalog-selection-url";
import { useCatalogNavigation } from "@/features/catalog/client/use-catalog-navigation";
import { useLatestRequest } from "@/features/catalog/client/use-latest-request";
import { CatalogSearch } from "@/features/catalog/components/catalog-search";
import type { CatalogSearchResult } from "@/features/catalog/domain/search";
import type { RootTab } from "@/types/catalog";
import { BreadcrumbBar } from "./breadcrumb-bar";
import { InlineEditor, type PageUpdateData } from "./inline-editor";
@@ -161,12 +163,20 @@ function CatalogManagerInner({
const { state, dispatch, catalogType, canEdit } = useCatalogManager();
const catQs = catalogTreeQs(catalogType);
const rootRequests = useLatestRequest();
const searchNavigation = useLatestRequest();
const [searchOpen, setSearchOpen] = useState(false);
const [searchTarget, setSearchTarget] = useState<{
offerId: number | null;
key: number;
}>({ offerId: null, key: 0 });
const tActions = useTranslations("pages.admin.actions");
const t = useTranslations("pages.admin.catalog.workspace");
const mayLeave = useCallback(
() => !state.editorDirty || window.confirm(tActions("leaveUnsaved")),
[state.editorDirty, tActions],
);
const mayLeaveRef = useRef(mayLeave);
mayLeaveRef.current = mayLeave;
const restoreSelection = useCallback(
(selection: CatalogSelection) => {
dispatch({ type: "RESTORE_SELECTION", ...selection });
@@ -196,6 +206,11 @@ function CatalogManagerInner({
if (target.closest?.("[role=listbox], [role=menu], [role=combobox]"))
return;
event.preventDefault();
if (searchOpen) {
searchNavigation.cancel();
setSearchOpen(false);
return;
}
close();
};
const onBeforeUnload = (event: BeforeUnloadEvent) => {
@@ -210,7 +225,7 @@ function CatalogManagerInner({
window.removeEventListener("keydown", onKey);
window.removeEventListener("beforeunload", onBeforeUnload);
};
}, [close, state.editorDirty, embedded]);
}, [close, state.editorDirty, embedded, searchOpen, searchNavigation]);
const loadRootPages = useCallback(async () => {
const request = rootRequests.start();
@@ -253,10 +268,12 @@ function CatalogManagerInner({
}, [open, dispatch]);
const handleTabClick = (id: number) => {
searchNavigation.cancel();
if (id === state.activeTabId) return;
if (!mayLeave()) {
return;
}
setSearchTarget((previous) => ({ ...previous, offerId: null }));
saveSelectionUrl({ rootId: id, pageId: null });
dispatch({ type: "SET_ACTIVE_TAB", id });
};
@@ -285,17 +302,21 @@ function CatalogManagerInner({
};
const handleSelectPage = (id: number) => {
searchNavigation.cancel();
if (id === state.selectedPageId) return;
if (state.editorDirty && !confirm("You have unsaved changes. Discard?")) {
if (!mayLeave()) {
return;
}
setSearchTarget((previous) => ({ ...previous, offerId: null }));
saveSelectionUrl({ rootId: state.activeTabId, pageId: id > 0 ? id : null });
dispatch({ type: "SELECT_PAGE", id: id > 0 ? id : null });
};
const handleEditTab = (id: number) => {
searchNavigation.cancel();
if (id === state.selectedPageId && id === state.activeTabId) return;
if (!mayLeave()) return;
setSearchTarget((previous) => ({ ...previous, offerId: null }));
saveSelectionUrl({ rootId: id, pageId: id });
if (state.activeTabId !== id) dispatch({ type: "SET_ACTIVE_TAB", id });
dispatch({ type: "SELECT_PAGE", id });
@@ -316,6 +337,49 @@ function CatalogManagerInner({
}
};
async function openSearchResult(result: CatalogSearchResult) {
const request = searchNavigation.start();
try {
const response = await fetch(
`/api/admin/catalog/tree?ancestors=${result.pageId}${catQs}`,
{ signal: request.signal },
);
if (!response.ok) throw Error("Navigation failed");
const data = await response.json();
if (!request.isCurrent() || !mayLeaveRef.current()) return;
const rootId = Number(data.ancestors?.[0]?.id ?? result.pageId);
dispatch({ type: "RESTORE_SELECTION", rootId, pageId: result.pageId });
saveSelectionUrl({ rootId, pageId: result.pageId });
setSearchTarget((previous) => ({
offerId: result.kind === "offer" ? result.id : null,
key: previous.key + 1,
}));
setSearchOpen(false);
} catch {
if (request.isCurrent()) toast.error(t("searchFailed"));
}
}
useEffect(() => {
const onKey = (event: KeyboardEvent) => {
if (
!(event.ctrlKey || event.metaKey) ||
!event.shiftKey ||
event.key.toLowerCase() !== "f"
)
return;
const dialog = (event.target as HTMLElement).closest?.('[role="dialog"]');
if (
dialog &&
dialog.getAttribute("aria-labelledby") !==
"visual-catalog-manager-title"
)
return;
event.preventDefault();
setSearchOpen(true);
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, []);
const heading =
catalogType === "bc"
? "BC Visual Catalog Manager"
@@ -334,19 +398,44 @@ function CatalogManagerInner({
<LayoutGrid className="h-4 w-4" />
{heading}
</h2>
{!embedded && (
<div className="flex items-center gap-1">
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={close}
aria-label="Close"
variant="outline"
size="sm"
aria-label={t("globalSearch")}
aria-expanded={searchOpen}
onClick={() => {
searchNavigation.cancel();
setSearchOpen((value) => !value);
}}
>
<X className="h-4 w-4" />
<Search className="size-4" />
<span className="hidden sm:inline">{t("globalSearch")}</span>
<kbd className="hidden lg:inline text-xs">Ctrl Shift F</kbd>
</Button>
)}
{!embedded && (
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={close}
aria-label="Close"
>
<X className="h-4 w-4" />
</Button>
)}
</div>
</div>
{searchOpen && (
<div className="shrink-0 max-h-[45vh] overflow-y-auto border-b p-2">
<CatalogSearch
catalogType={catalogType}
onNavigate={openSearchResult}
autoFocus
/>
</div>
)}
<BreadcrumbBar onSelectPage={handleSelectPage} />
@@ -387,6 +476,8 @@ function CatalogManagerInner({
{t("backToCategories")}
</Button>
<InlineEditor
key={`${state.selectedPageId}:${searchTarget.key}`}
initialOfferId={searchTarget.offerId}
pageId={state.selectedPageId}
onSaved={handleEditorSaved}
/>
@@ -102,6 +102,7 @@ export interface PageUpdateData {
}
interface InlineEditorProps {
initialOfferId?: number | null;
pageId: number | null;
onSaved: (updated: PageUpdateData) => void;
}
@@ -115,7 +116,11 @@ export function InlineEditor(props: InlineEditorProps) {
);
}
function InlineEditorSession({ pageId, onSaved }: InlineEditorProps) {
function InlineEditorSession({
pageId,
onSaved,
initialOfferId,
}: InlineEditorProps) {
const pageRequests = useLatestRequest();
const itemRequests = useLatestRequest();
const saveRequests = useLatestRequest();
@@ -135,7 +140,9 @@ function InlineEditorSession({ pageId, onSaved }: InlineEditorProps) {
const [form, setForm] = useState<FormState>({});
const [original, setOriginal] = useState<FormState>({});
const [showTexts, setShowTexts] = useState(false);
const [activeTab, setActiveTab] = useState("settings");
const [activeTab, setActiveTab] = useState(
initialOfferId ? "items" : "settings",
);
const [showPreview, setShowPreview] = useState(false);
// Items/translate data (lazy loaded)
@@ -247,7 +254,7 @@ function InlineEditorSession({ pageId, onSaved }: InlineEditorProps) {
loadPage(pageId);
setItemsData(null);
setItemsLoaded(false);
setActiveTab("settings");
setActiveTab(initialOfferId ? "items" : "settings");
setShowPreview(false);
} else {
setPage(null);
@@ -256,7 +263,7 @@ function InlineEditorSession({ pageId, onSaved }: InlineEditorProps) {
setItemsData(null);
setItemsLoaded(false);
}
}, [pageId, loadPage]);
}, [pageId, loadPage, initialOfferId]);
// Lazy-load items (shop catalog only — BC items live on the detail page)
useEffect(() => {
@@ -360,14 +367,14 @@ function InlineEditorSession({ pageId, onSaved }: InlineEditorProps) {
return (
<div className="flex-1 flex flex-col min-h-0">
{/* Header */}
<div className="flex items-center justify-between px-4 py-2 border-b shrink-0">
<div className="flex flex-wrap items-center justify-between gap-2 px-4 py-2 border-b shrink-0">
<div className="flex items-center gap-2">
<CatalogIcon
iconImage={(form.iconImage as number) ?? page.iconImage}
size={24}
/>
<div>
<div className="flex items-center gap-2">
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-sm font-semibold">
{(form.caption as string) || page.caption}
</h3>
@@ -379,13 +386,13 @@ function InlineEditorSession({ pageId, onSaved }: InlineEditorProps) {
>
{form.pageLayout as string}
</Badge>
{isDirty && (
{(isDirty || offersDirty) && (
<Badge
variant="outline"
className="h-4 px-1.5 text-[9px] text-[var(--admin-warning)] border-[var(--admin-warning-border)] bg-[var(--admin-warning-subtle)]"
>
<AlertCircle className="h-2.5 w-2.5 mr-0.5" />
unsaved
{t("unsaved")}
</Badge>
)}
</div>
@@ -397,9 +404,13 @@ function InlineEditorSession({ pageId, onSaved }: InlineEditorProps) {
</div>
</div>
<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 text-[var(--admin-text)]"
>
<ExternalLink className="h-3 w-3" />
Full editor
{t("fullEditor")}
</Button>
</Link>
</div>
@@ -408,13 +419,7 @@ function InlineEditorSession({ pageId, onSaved }: InlineEditorProps) {
{isDirty && (
<div className="flex items-center gap-2 px-4 py-1.5 bg-[var(--admin-warning-subtle)] border-b border-[var(--admin-warning-border)] text-xs text-[var(--admin-warning)] shrink-0">
<AlertCircle className="h-3.5 w-3.5 shrink-0" />
<span className="flex-1">
Unsaved changes — press{" "}
<kbd className="rounded border bg-background px-1 font-mono text-[10px]">
Ctrl+S
</kbd>{" "}
to save
</span>
<span className="flex-1">{t("saveHint")}</span>
<Button
size="sm"
variant="outline"
@@ -768,6 +773,9 @@ function InlineEditorSession({ pageId, onSaved }: InlineEditorProps) {
{/* Items table */}
<CatalogItemsTable
initialSearch={
initialOfferId ? `#${initialOfferId}` : undefined
}
onDirtyChange={setOffersDirty}
onRefresh={reloadItems}
items={itemsData.items}