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
@@ -82,6 +82,11 @@ import {
|
||||
} from "@/components/ui/table";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { BulkOfferEditor } from "@/features/catalog/components/bulk-offer-editor";
|
||||
import {
|
||||
discoverOffers,
|
||||
OFFER_FILTERS,
|
||||
type OfferFilter,
|
||||
} from "@/features/catalog/domain/offer-discovery";
|
||||
import { useServerAction } from "@/hooks/use-server-action";
|
||||
import { getSuggestions, recordMove } from "@/lib/move-suggestions";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -146,6 +151,7 @@ function SortableRow({
|
||||
}
|
||||
|
||||
interface CatalogItemsTableProps {
|
||||
initialSearch?: string;
|
||||
onDirtyChange?: (dirty: boolean) => void;
|
||||
onRefresh?: () => void;
|
||||
items: CatalogItemData[];
|
||||
@@ -158,6 +164,7 @@ interface CatalogItemsTableProps {
|
||||
}
|
||||
|
||||
export function CatalogItemsTable({
|
||||
initialSearch = "",
|
||||
onDirtyChange,
|
||||
onRefresh,
|
||||
items,
|
||||
@@ -170,6 +177,7 @@ export function CatalogItemsTable({
|
||||
}: CatalogItemsTableProps) {
|
||||
const router = useRouter();
|
||||
const bulk = useTranslations("pages.admin.catalog.bulk");
|
||||
const discovery = useTranslations("pages.admin.catalog.discovery");
|
||||
const [bulkRefreshFrom, setBulkRefreshFrom] = useState<
|
||||
CatalogItemData[] | null
|
||||
>(null);
|
||||
@@ -186,20 +194,14 @@ export function CatalogItemsTable({
|
||||
const [viewMode, setViewMode] = useState<"table" | "grid">("table");
|
||||
|
||||
// ── Search / Filter ─────────────────────────────────────────────
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
const filteredItems = useMemo(() => {
|
||||
if (!searchQuery.trim()) return items;
|
||||
const q = searchQuery.toLowerCase();
|
||||
return items.filter(
|
||||
(item) =>
|
||||
item.catalogName.toLowerCase().includes(q) ||
|
||||
item.baseItemName.toLowerCase().includes(q) ||
|
||||
item.baseName.toLowerCase().includes(q) ||
|
||||
item.itemIds.includes(q) ||
|
||||
String(item.id).includes(q),
|
||||
);
|
||||
}, [items, searchQuery]);
|
||||
const [searchQuery, setSearchQuery] = useState(initialSearch);
|
||||
const [offerFilter, setOfferFilter] = useState<OfferFilter>("all");
|
||||
const { items: filteredItems, counts: filterCounts } = useMemo(
|
||||
() => discoverOffers(items, searchQuery, offerFilter),
|
||||
[items, searchQuery, offerFilter],
|
||||
);
|
||||
const hasDiscoveryFilter =
|
||||
Boolean(searchQuery.trim()) || offerFilter !== "all";
|
||||
|
||||
// ── Move items dialog ───────────────────────────────────────────
|
||||
const [moveOpen, setMoveOpen] = useState(false);
|
||||
@@ -242,13 +244,36 @@ export function CatalogItemsTable({
|
||||
});
|
||||
}, []);
|
||||
|
||||
const allVisibleSelected =
|
||||
filteredItems.length > 0 &&
|
||||
filteredItems.every((item) => selected.has(item.id));
|
||||
const someVisibleSelected = filteredItems.some((item) =>
|
||||
selected.has(item.id),
|
||||
);
|
||||
const toggleAll = useCallback(() => {
|
||||
setSelected((prev) =>
|
||||
prev.size === filteredItems.length
|
||||
? new Set()
|
||||
: new Set(filteredItems.map((i) => i.id)),
|
||||
);
|
||||
setSelected((previous) => {
|
||||
const next = new Set(previous);
|
||||
const allSelected = filteredItems.every((item) => previous.has(item.id));
|
||||
for (const item of filteredItems) {
|
||||
if (allSelected) next.delete(item.id);
|
||||
else next.add(item.id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [filteredItems]);
|
||||
function updateSearch(query: string) {
|
||||
setSearchQuery(query);
|
||||
setSelected(new Set());
|
||||
}
|
||||
function updateOfferFilter(value: string) {
|
||||
setOfferFilter(value as OfferFilter);
|
||||
setSelected(new Set());
|
||||
}
|
||||
function resetDiscovery() {
|
||||
setSearchQuery("");
|
||||
setOfferFilter("all");
|
||||
setSelected(new Set());
|
||||
}
|
||||
|
||||
// ── Inline edits ─────────────────────────────────────────────────
|
||||
const [inlineEdits, setInlineEdits] = useState<Record<number, InlineEdit>>(
|
||||
@@ -947,15 +972,16 @@ export function CatalogItemsTable({
|
||||
<div className="min-w-0">
|
||||
<CardTitle>
|
||||
Items ({items.length})
|
||||
{searchQuery && filteredItems.length !== items.length && (
|
||||
<span className="text-muted-foreground font-normal text-sm ml-2">
|
||||
({filteredItems.length} shown)
|
||||
</span>
|
||||
)}
|
||||
{hasDiscoveryFilter &&
|
||||
filteredItems.length !== items.length && (
|
||||
<span className="text-muted-foreground font-normal text-sm ml-2">
|
||||
{discovery("shown", { count: filteredItems.length })}
|
||||
</span>
|
||||
)}
|
||||
</CardTitle>
|
||||
{selected.size > 0 && (
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{selected.size} selected
|
||||
{discovery("selected", { count: selected.size })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -1149,14 +1175,51 @@ export function CatalogItemsTable({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search bar */}
|
||||
{/* Offer discovery */}
|
||||
{items.length > 0 && (
|
||||
<SearchInput
|
||||
placeholder="Filter by name, classname, item ID..."
|
||||
value={searchQuery}
|
||||
onChange={setSearchQuery}
|
||||
onClear={() => setSearchQuery("")}
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<div className="min-w-0 flex-1">
|
||||
<SearchInput
|
||||
placeholder={discovery("searchPlaceholder")}
|
||||
value={searchQuery}
|
||||
onChange={updateSearch}
|
||||
onClear={() => updateSearch("")}
|
||||
/>
|
||||
</div>
|
||||
<Select value={offerFilter} onValueChange={updateOfferFilter}>
|
||||
<SelectTrigger
|
||||
aria-label={discovery("filterLabel")}
|
||||
className="w-full sm:w-52"
|
||||
>
|
||||
<SelectValue>
|
||||
{discovery(`filters.${offerFilter}`)} (
|
||||
{filterCounts[offerFilter]})
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{OFFER_FILTERS.map((filter) => (
|
||||
<SelectItem key={filter} value={filter}>
|
||||
{discovery(`filters.${filter}`)} ({filterCounts[filter]}
|
||||
)
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{hasDiscoveryFilter && (
|
||||
<Button variant="ghost" size="sm" onClick={resetDiscovery}>
|
||||
{discovery("reset")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{discovery("results", {
|
||||
count: filteredItems.length,
|
||||
total: items.length,
|
||||
})}
|
||||
{canEdit && ` ${discovery("selectionHint")}`}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -1178,7 +1241,7 @@ export function CatalogItemsTable({
|
||||
</div>
|
||||
) : filteredItems.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-8">
|
||||
No items match "{searchQuery}".
|
||||
{discovery("noResults")}
|
||||
</p>
|
||||
) : viewMode === "grid" ? (
|
||||
<ItemsShopPreview
|
||||
@@ -1204,12 +1267,12 @@ export function CatalogItemsTable({
|
||||
{canEdit && (
|
||||
<TableHead className="w-[40px] px-2">
|
||||
<Checkbox
|
||||
checked={
|
||||
filteredItems.length > 0 &&
|
||||
selected.size === filteredItems.length
|
||||
checked={allVisibleSelected}
|
||||
indeterminate={
|
||||
someVisibleSelected && !allVisibleSelected
|
||||
}
|
||||
onCheckedChange={toggleAll}
|
||||
aria-label="Select all"
|
||||
aria-label={discovery("selectVisible")}
|
||||
/>
|
||||
</TableHead>
|
||||
)}
|
||||
@@ -1237,7 +1300,7 @@ export function CatalogItemsTable({
|
||||
const modified = isItemModified(item);
|
||||
const isSaving = savingItemId === item.id;
|
||||
const furniInfo = getFurniInfo(item);
|
||||
const sortableDisabled = !canEdit || !!searchQuery;
|
||||
const sortableDisabled = !canEdit || hasDiscoveryFilter;
|
||||
return (
|
||||
<SortableRow
|
||||
key={item.id}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -19,9 +19,13 @@ import { BulkOfferEditor } from "./bulk-offer-editor";
|
||||
export function CatalogSearch({
|
||||
catalogType = "normal",
|
||||
canEdit = false,
|
||||
onNavigate,
|
||||
autoFocus = false,
|
||||
}: {
|
||||
catalogType?: SearchCatalogKind;
|
||||
canEdit?: boolean;
|
||||
onNavigate?: (result: CatalogSearchResult) => void | Promise<void>;
|
||||
autoFocus?: boolean;
|
||||
}) {
|
||||
const t = useTranslations("pages.admin.catalog.workspace");
|
||||
const [query, setQuery] = useState("");
|
||||
@@ -30,6 +34,10 @@ export function CatalogSearch({
|
||||
"idle",
|
||||
);
|
||||
const requests = useLatestRequest();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
useEffect(() => {
|
||||
if (autoFocus) inputRef.current?.focus();
|
||||
}, [autoFocus]);
|
||||
const bulk = useTranslations("pages.admin.catalog.bulk");
|
||||
const router = useRouter();
|
||||
const [selected, setSelected] = useState<Set<number>>(new Set());
|
||||
@@ -135,6 +143,7 @@ export function CatalogSearch({
|
||||
className="h-4 w-4 shrink-0 text-muted-foreground"
|
||||
/>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
aria-label={t("globalSearch")}
|
||||
placeholder={t("searchPlaceholder")}
|
||||
value={query}
|
||||
@@ -230,13 +239,25 @@ export function CatalogSearch({
|
||||
)}
|
||||
<Link
|
||||
href={result.href}
|
||||
onClick={(event) => {
|
||||
if (
|
||||
onNavigate &&
|
||||
!event.ctrlKey &&
|
||||
!event.metaKey &&
|
||||
!event.shiftKey &&
|
||||
!event.altKey
|
||||
) {
|
||||
event.preventDefault();
|
||||
void onNavigate(result);
|
||||
}
|
||||
}}
|
||||
className="block min-w-0 flex-1 rounded px-2 py-2 hover:bg-[var(--admin-canvas)] focus-visible:outline-2"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t(result.kind)}
|
||||
</span>
|
||||
<span className="font-medium break-words">
|
||||
<span className="font-medium break-words text-[var(--admin-text)]">
|
||||
{result.caption}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { discoverOffers } from "./offer-discovery";
|
||||
|
||||
const offers = [
|
||||
{
|
||||
id: 7,
|
||||
catalogName: "Oak chair",
|
||||
baseItemName: "chair_oak",
|
||||
baseName: "Wooden chair",
|
||||
itemIds: "10",
|
||||
costCredits: 0,
|
||||
costPoints: 0,
|
||||
limitedStack: 0,
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
catalogName: "Chair pair",
|
||||
baseItemName: "",
|
||||
baseName: "",
|
||||
itemIds: "10;11",
|
||||
costCredits: 0,
|
||||
costPoints: 5,
|
||||
limitedStack: 0,
|
||||
},
|
||||
{
|
||||
id: 9,
|
||||
catalogName: "Limited table",
|
||||
baseItemName: "table_oak",
|
||||
baseName: "Wooden table",
|
||||
itemIds: "12",
|
||||
costCredits: 15,
|
||||
costPoints: 0,
|
||||
limitedStack: 100,
|
||||
},
|
||||
{
|
||||
id: 10,
|
||||
catalogName: "Welcome bundle",
|
||||
baseItemName: "",
|
||||
baseName: "",
|
||||
itemIds: " 10 ; 12 ",
|
||||
costCredits: 0,
|
||||
costPoints: 0,
|
||||
limitedStack: 0,
|
||||
},
|
||||
];
|
||||
|
||||
describe("offer discovery", () => {
|
||||
it("matches #offer IDs exactly without matching longer IDs or furniture references", () => {
|
||||
const rows = [offers[0], { ...offers[1], id: 17, itemIds: "7" }];
|
||||
expect(
|
||||
discoverOffers(rows, " #7 ", "all").items.map((item) => item.id),
|
||||
).toEqual([7]);
|
||||
});
|
||||
it("requires both prices to be zero for free offers and includes points-only paid offers", () => {
|
||||
expect(
|
||||
discoverOffers(offers, "", "free").items.map((item) => item.id),
|
||||
).toEqual([7, 10]);
|
||||
expect(
|
||||
discoverOffers(offers, "", "paid").items.map((item) => item.id),
|
||||
).toEqual([8, 9]);
|
||||
});
|
||||
it("recognizes semicolon bundles without requiring a single base item", () => {
|
||||
expect(
|
||||
discoverOffers(offers, "", "bundles").items.map((item) => item.id),
|
||||
).toEqual([8, 10]);
|
||||
expect(
|
||||
discoverOffers([{ ...offers[0], itemIds: "10; ;" }], "", "bundles").items,
|
||||
).toEqual([]);
|
||||
});
|
||||
it("identifies limited editions by stock even without sold quantities", () => {
|
||||
expect(
|
||||
discoverOffers(offers, "", "limited").items.map((item) => item.id),
|
||||
).toEqual([9]);
|
||||
});
|
||||
it("combines trimmed case-insensitive text with a facet while counting other available facets", () => {
|
||||
const result = discoverOffers(offers, " CHAIR ", "paid");
|
||||
expect(result.items.map((item) => item.id)).toEqual([8]);
|
||||
expect(result.counts).toEqual({
|
||||
all: 2,
|
||||
free: 1,
|
||||
paid: 1,
|
||||
bundles: 1,
|
||||
limited: 0,
|
||||
});
|
||||
});
|
||||
it("searches public names, classnames, offer IDs and furniture references", () => {
|
||||
expect(
|
||||
discoverOffers(offers, "Wooden", "all").items.map((item) => item.id),
|
||||
).toEqual([7, 9]);
|
||||
expect(
|
||||
discoverOffers(offers, "table_oak", "all").items.map((item) => item.id),
|
||||
).toEqual([9]);
|
||||
expect(
|
||||
discoverOffers(offers, "7", "all").items.map((item) => item.id),
|
||||
).toEqual([7]);
|
||||
expect(
|
||||
discoverOffers(offers, "11", "all").items.map((item) => item.id),
|
||||
).toEqual([8]);
|
||||
});
|
||||
it("keeps source ordering and returns empty counts when the query has no matches", () => {
|
||||
const copy = structuredClone(offers);
|
||||
expect(discoverOffers(offers, " ", "all").items).toEqual(offers);
|
||||
expect(discoverOffers(offers, "absent", "all")).toEqual({
|
||||
items: [],
|
||||
counts: { all: 0, free: 0, paid: 0, bundles: 0, limited: 0 },
|
||||
});
|
||||
expect(offers).toEqual(copy);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { CatalogItemData } from "@/app/admin/catalog/[id]/catalog-items-table/types";
|
||||
|
||||
export const OFFER_FILTERS = [
|
||||
"all",
|
||||
"free",
|
||||
"paid",
|
||||
"bundles",
|
||||
"limited",
|
||||
] as const;
|
||||
export type OfferFilter = (typeof OFFER_FILTERS)[number];
|
||||
type DiscoverableOffer = Pick<
|
||||
CatalogItemData,
|
||||
| "id"
|
||||
| "catalogName"
|
||||
| "baseItemName"
|
||||
| "baseName"
|
||||
| "itemIds"
|
||||
| "costCredits"
|
||||
| "costPoints"
|
||||
| "limitedStack"
|
||||
>;
|
||||
|
||||
function matchesFilter(item: DiscoverableOffer, filter: OfferFilter) {
|
||||
switch (filter) {
|
||||
case "all":
|
||||
return true;
|
||||
case "free":
|
||||
return item.costCredits === 0 && item.costPoints === 0;
|
||||
case "paid":
|
||||
return item.costCredits > 0 || item.costPoints > 0;
|
||||
case "bundles":
|
||||
return (
|
||||
item.itemIds.split(";").filter((part) => part.trim().length > 0)
|
||||
.length > 1
|
||||
);
|
||||
case "limited":
|
||||
return item.limitedStack > 0;
|
||||
}
|
||||
}
|
||||
|
||||
/** Facet counts apply to the text search so changing a facet never hides its available matches. */
|
||||
export function discoverOffers<T extends DiscoverableOffer>(
|
||||
items: readonly T[],
|
||||
query: string,
|
||||
filter: OfferFilter,
|
||||
) {
|
||||
const normalized = query.trim().toLowerCase();
|
||||
const exactId = /^#\d+$/.test(normalized) ? normalized.slice(1) : null;
|
||||
const matches =
|
||||
exactId !== null
|
||||
? items.filter((item) => String(item.id) === exactId)
|
||||
: normalized
|
||||
? items.filter((item) =>
|
||||
[
|
||||
item.catalogName,
|
||||
item.baseItemName,
|
||||
item.baseName,
|
||||
item.itemIds,
|
||||
String(item.id),
|
||||
].some((value) => value.toLowerCase().includes(normalized)),
|
||||
)
|
||||
: [...items];
|
||||
const counts = {
|
||||
all: matches.length,
|
||||
free: 0,
|
||||
paid: 0,
|
||||
bundles: 0,
|
||||
limited: 0,
|
||||
};
|
||||
for (const item of matches)
|
||||
for (const facet of OFFER_FILTERS) {
|
||||
if (facet !== "all" && matchesFilter(item, facet)) counts[facet] += 1;
|
||||
}
|
||||
return {
|
||||
items: matches.filter((item) => matchesFilter(item, filter)),
|
||||
counts,
|
||||
};
|
||||
}
|
||||
+22
-1
@@ -1966,7 +1966,10 @@
|
||||
"suggested": "Suggested",
|
||||
"all": "All categories",
|
||||
"draftBlocked": "Save or discard category edits before duplicating."
|
||||
}
|
||||
},
|
||||
"unsaved": "Unsaved changes",
|
||||
"fullEditor": "Full editor",
|
||||
"saveHint": "Category changes are not saved. Use Save or Ctrl+S."
|
||||
},
|
||||
"bulk": {
|
||||
"previewCount": "{count} of {total} offers will change.",
|
||||
@@ -2024,6 +2027,24 @@
|
||||
"back": "Edit changes",
|
||||
"selectionLimit": "Select at most 500 offers for one operation.",
|
||||
"refreshing": "Refreshing updated offers…"
|
||||
},
|
||||
"discovery": {
|
||||
"searchPlaceholder": "Name, classname, furniture ID or #offer ID…",
|
||||
"filterLabel": "Filter offers",
|
||||
"filters": {
|
||||
"all": "All offers",
|
||||
"free": "Free",
|
||||
"paid": "Paid",
|
||||
"bundles": "Bundles",
|
||||
"limited": "Limited editions"
|
||||
},
|
||||
"shown": "({count} shown)",
|
||||
"selected": "{count} selected",
|
||||
"reset": "Reset filters",
|
||||
"results": "Showing {count} of {total} offers.",
|
||||
"selectionHint": "Changing the search or filter clears the selection.",
|
||||
"noResults": "No offers match the current search and filter.",
|
||||
"selectVisible": "Select all visible offers"
|
||||
}
|
||||
},
|
||||
"shop": {
|
||||
|
||||
+22
-1
@@ -1885,7 +1885,10 @@
|
||||
"suggested": "Suggerite",
|
||||
"all": "Tutte le categorie",
|
||||
"draftBlocked": "Salva o annulla le modifiche alla categoria prima di duplicarla."
|
||||
}
|
||||
},
|
||||
"unsaved": "Modifiche non salvate",
|
||||
"fullEditor": "Editor completo",
|
||||
"saveHint": "Le modifiche alla categoria non sono salvate. Usa Salva o Ctrl+S."
|
||||
},
|
||||
"bulk": {
|
||||
"previewCount": "Verranno modificate {count} offerte su {total}.",
|
||||
@@ -1943,6 +1946,24 @@
|
||||
"back": "Rivedi le modifiche",
|
||||
"selectionLimit": "Seleziona al massimo 500 offerte per operazione.",
|
||||
"refreshing": "Aggiornamento delle offerte modificate…"
|
||||
},
|
||||
"discovery": {
|
||||
"searchPlaceholder": "Nome, classname, ID furni o #ID offerta…",
|
||||
"filterLabel": "Filtra offerte",
|
||||
"filters": {
|
||||
"all": "Tutte le offerte",
|
||||
"free": "Gratuite",
|
||||
"paid": "A pagamento",
|
||||
"bundles": "Pacchetti",
|
||||
"limited": "Edizioni limitate"
|
||||
},
|
||||
"shown": "({count} visibili)",
|
||||
"selected": "{count} selezionate",
|
||||
"reset": "Reimposta filtri",
|
||||
"results": "{count} offerte visibili su {total}.",
|
||||
"selectionHint": "Cambiare ricerca o filtro azzera la selezione.",
|
||||
"noResults": "Nessuna offerta corrisponde alla ricerca e al filtro attuali.",
|
||||
"selectVisible": "Seleziona tutte le offerte visibili"
|
||||
}
|
||||
},
|
||||
"shop": {
|
||||
|
||||
+22
-1
@@ -1715,7 +1715,10 @@
|
||||
"suggested": "Voorgesteld",
|
||||
"all": "Alle categorieën",
|
||||
"draftBlocked": "Sla categoriewijzigingen op of annuleer ze voordat je dupliceert."
|
||||
}
|
||||
},
|
||||
"unsaved": "Niet-opgeslagen wijzigingen",
|
||||
"fullEditor": "Volledige editor",
|
||||
"saveHint": "Categoriewijzigingen zijn niet opgeslagen. Gebruik Opslaan of Ctrl+S."
|
||||
},
|
||||
"bulk": {
|
||||
"previewCount": "{count} van {total} aanbiedingen worden gewijzigd.",
|
||||
@@ -1773,6 +1776,24 @@
|
||||
"back": "Wijzigingen aanpassen",
|
||||
"selectionLimit": "Selecteer maximaal 500 aanbiedingen per bewerking.",
|
||||
"refreshing": "Bijgewerkte aanbiedingen vernieuwen…"
|
||||
},
|
||||
"discovery": {
|
||||
"searchPlaceholder": "Naam, classname, meubel-ID of #aanbiedings-ID…",
|
||||
"filterLabel": "Aanbiedingen filteren",
|
||||
"filters": {
|
||||
"all": "Alle aanbiedingen",
|
||||
"free": "Gratis",
|
||||
"paid": "Betaald",
|
||||
"bundles": "Bundels",
|
||||
"limited": "Gelimiteerde edities"
|
||||
},
|
||||
"shown": "({count} zichtbaar)",
|
||||
"selected": "{count} geselecteerd",
|
||||
"reset": "Filters wissen",
|
||||
"results": "{count} van {total} aanbiedingen zichtbaar.",
|
||||
"selectionHint": "De selectie wordt gewist wanneer je de zoekopdracht of het filter wijzigt.",
|
||||
"noResults": "Geen aanbiedingen passen bij de huidige zoekopdracht en het filter.",
|
||||
"selectVisible": "Alle zichtbare aanbiedingen selecteren"
|
||||
}
|
||||
},
|
||||
"shop": {
|
||||
|
||||
Reference in new issue
Block a user