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

@@ -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,
};
}