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

File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -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">
File diff suppressed because it is too large. Load diff
@@ -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": {