feat(web): add per-level search filter to category grid

Deep category levels are hard to scan — Elektrik has 62 children, Kaporta 58.
Add a name filter input that appears when a level has more than 8 items and
filters the current drill level live (Turkish-aware lowercasing for İ/ı), with a
clear button and a "no match" message. Resets automatically when navigating
between levels. Benefits the vehicle, catalog, and category sub-page flows since
they all share CategoryGrid.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-25 03:12:27 +03:00
parent 136c947c31
commit 301e7e50a8

View File

@@ -1,10 +1,10 @@
import { api } from "@/lib/api-client";
import { getCategoryIcon } from "@/lib/category-icons";
import { useTranslation } from "@/lib/i18n";
import { Card, CardContent } from "@sase/ui";
import { Card, CardContent, Input } from "@sase/ui";
import { useQueryClient } from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router";
import { ChevronRight, Loader2 } from "lucide-react";
import { ChevronRight, Loader2, Search, X } from "lucide-react";
import { Fragment, useCallback, useEffect, useRef, useState } from "react";
interface Category {
@@ -66,6 +66,22 @@ export function CategoryGrid({
const current = stack[stack.length - 1];
const currentCategories = current.categories;
// Filter the current drill level by name — deep levels (e.g. Elektrik has 62
// children) are hard to scan otherwise. Turkish-aware lowercasing for İ/ı.
const [filter, setFilter] = useState("");
// Reset the filter whenever the visible level changes (drill in/out).
// biome-ignore lint/correctness/useExhaustiveDependencies: re-run on level change is intentional — current is the level identity
useEffect(() => {
setFilter("");
}, [current]);
const trimmedFilter = filter.trim().toLocaleLowerCase("tr");
const visibleCategories = trimmedFilter
? currentCategories.filter((c) => c.name.toLocaleLowerCase("tr").includes(trimmedFilter))
: currentCategories;
const showFilter = currentCategories.length > 8;
// Prefetch schema images for leaf categories in batches of 2
useEffect(() => {
prefetchedRef.current.clear();
@@ -195,33 +211,62 @@ export function CategoryGrid({
))}
</nav>
)}
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5">
{currentCategories.map((category) => {
const Icon = getCategoryIcon(category.name);
const isLeaf = category.children !== undefined && category.children.length === 0;
const schemaImageUrl = imageOverrides.get(category.id) || category.schemaImageUrl;
const isSelectLoading = loadingId === category.id;
return (
{showFilter && (
<div className="relative mb-3 max-w-xs">
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
type="search"
value={filter}
onChange={(e) => setFilter(e.target.value)}
placeholder="Kategori ara…"
aria-label="Kategori ara"
className="pl-8 pr-8"
/>
{filter && (
<button
key={category.id}
type="button"
onClick={() => handleSelect(category)}
disabled={category.unavailable || isSelectLoading}
className={`w-full rounded-xl text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background ${category.unavailable ? "opacity-40" : ""}`}
onClick={() => setFilter("")}
aria-label="Aramayı temizle"
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
<CategoryCard
name={category.name}
partCount={category.partCount}
Icon={Icon}
schemaImageUrl={schemaImageUrl}
isLeaf={isLeaf}
isLoading={prefetchingIds.has(category.id) || isSelectLoading}
/>
<X className="h-4 w-4" />
</button>
);
})}
</div>
)}
</div>
)}
{visibleCategories.length === 0 ? (
<p className="py-4 text-center text-sm text-muted-foreground">
"{filter}" ile eşleşen kategori yok.
</p>
) : (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5">
{visibleCategories.map((category) => {
const Icon = getCategoryIcon(category.name);
const isLeaf = category.children !== undefined && category.children.length === 0;
const schemaImageUrl = imageOverrides.get(category.id) || category.schemaImageUrl;
const isSelectLoading = loadingId === category.id;
return (
<button
key={category.id}
type="button"
onClick={() => handleSelect(category)}
disabled={category.unavailable || isSelectLoading}
className={`w-full rounded-xl text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background ${category.unavailable ? "opacity-40" : ""}`}
>
<CategoryCard
name={category.name}
partCount={category.partCount}
Icon={Icon}
schemaImageUrl={schemaImageUrl}
isLeaf={isLeaf}
isLoading={prefetchingIds.has(category.id) || isSelectLoading}
/>
</button>
);
})}
</div>
)}
</div>
);
}