feat(catalog): improve visual manager search and offer discovery

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

@@ -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 &quot;{searchQuery}&quot;.
{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
View File
@@ -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
View File
@@ -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
View File
@@ -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": {