feat(catalog): improve visual manager search and offer discovery
This commit is contained in:
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}
|
||||
|
||||
Reference in new issue
Block a user