feat(catalog): full-catalog search on vehicle page (leaf categories + OEM parts)
The vehicle page search previously only filtered category names at the currently rendered level. Add a server-side cross-tree search over what's already drilled into the DB. New GET /categories/search/:vehicleId?q= returns two sections: - categories: name-matched leaves UNION the leaf categories that contain a matching part (with hit count). "fren balatası" matches no leaf by name — the pads are parts under leaves like "Disk freni" — so the union surfaces the right leaves. - parts: parts matching every token on name, or the raw query on oem_code, with OEM + leaf + breadcrumb. Pure DB read (no upstream drill); a treeIncomplete hint is returned when the vehicle's tree looks barely drilled. Frontend adds a debounced search box on the vehicle page that hides the normal browse while active. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
246
apps/web/src/components/categories/catalog-search.tsx
Normal file
246
apps/web/src/components/categories/catalog-search.tsx
Normal file
@@ -0,0 +1,246 @@
|
||||
import { api } from "@/lib/api-client";
|
||||
import { getCategoryIcon } from "@/lib/category-icons";
|
||||
import { Badge, Input } from "@sase/ui";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { ChevronRight, Info, Loader2, Package, Search, X } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
interface Crumb {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface CategoryHit {
|
||||
id: string;
|
||||
name: string;
|
||||
source?: string;
|
||||
unavailable?: boolean;
|
||||
isLeaf: boolean;
|
||||
matchingPartCount: number;
|
||||
breadcrumb: Crumb[];
|
||||
}
|
||||
|
||||
interface PartHit {
|
||||
oemCode: string;
|
||||
name: string;
|
||||
categoryId: string;
|
||||
categoryName: string;
|
||||
unavailable?: boolean;
|
||||
breadcrumb: Crumb[];
|
||||
}
|
||||
|
||||
interface SearchResponse {
|
||||
query: string;
|
||||
categories: CategoryHit[];
|
||||
parts: PartHit[];
|
||||
treeIncomplete: boolean;
|
||||
}
|
||||
|
||||
/** Strip PL24 raw escapes (\r\n, literal \-) and collapse whitespace — part and
|
||||
* category names often arrive as multi-line illustration captions. */
|
||||
function clean(s: string): string {
|
||||
return (s || "").replace(/\r?\n/g, " ").replace(/\\/g, "").replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function useDebounced<T>(value: T, ms: number): T {
|
||||
const [debounced, setDebounced] = useState(value);
|
||||
useEffect(() => {
|
||||
const id = setTimeout(() => setDebounced(value), ms);
|
||||
return () => clearTimeout(id);
|
||||
}, [value, ms]);
|
||||
return debounced;
|
||||
}
|
||||
|
||||
function Breadcrumb({ trail, tail }: { trail: Crumb[]; tail?: string }) {
|
||||
const parts = [...trail.map((c) => clean(c.name)), ...(tail ? [clean(tail)] : [])];
|
||||
if (parts.length === 0) return null;
|
||||
return <span className="block truncate text-xs text-muted-foreground">{parts.join(" › ")}</span>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cross-tree catalog search for one vehicle. Queries the server over everything
|
||||
* already drilled into the DB and renders two sections: matching leaf categories
|
||||
* and matching parts (OEM). Calls `onActiveChange` so the parent can hide the
|
||||
* normal category browse while a search is active.
|
||||
*/
|
||||
export function CatalogSearch({
|
||||
vehicleId,
|
||||
onActiveChange,
|
||||
}: {
|
||||
vehicleId: string;
|
||||
onActiveChange?: (active: boolean) => void;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const [input, setInput] = useState("");
|
||||
const debounced = useDebounced(input.trim(), 300);
|
||||
const active = debounced.length >= 2;
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: setter from parent is stable
|
||||
useEffect(() => {
|
||||
onActiveChange?.(active);
|
||||
}, [active]);
|
||||
|
||||
const { data, isLoading, isFetching } = useQuery({
|
||||
queryKey: ["catalog-search", vehicleId, debounced],
|
||||
queryFn: () =>
|
||||
api.get<SearchResponse>(`/categories/search/${vehicleId}?q=${encodeURIComponent(debounced)}`),
|
||||
enabled: active,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const goCategory = (categoryId: string) =>
|
||||
navigate({
|
||||
to: "/dashboard/vehicles/$id/categories/$categoryId",
|
||||
params: { id: vehicleId, categoryId },
|
||||
});
|
||||
|
||||
const categories = data?.categories ?? [];
|
||||
const parts = data?.parts ?? [];
|
||||
const nothing = active && !isLoading && categories.length === 0 && parts.length === 0;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="relative mb-4">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
type="search"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder="Tüm katalogda ara — ör. fren balatası veya OEM kodu"
|
||||
aria-label="Tüm katalogda ara"
|
||||
className="pl-9 pr-9"
|
||||
/>
|
||||
{active && isFetching ? (
|
||||
<Loader2 className="absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 animate-spin text-muted-foreground" />
|
||||
) : input ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setInput("")}
|
||||
aria-label="Aramayı temizle"
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{active && (
|
||||
<div className="space-y-6">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center gap-2 py-8 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" /> Aranıyor…
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{data?.treeIncomplete && (
|
||||
<div className="flex items-start gap-2 rounded-lg border border-amber-500/30 bg-amber-500/5 p-3 text-xs text-muted-foreground">
|
||||
<Info className="mt-0.5 h-4 w-4 flex-shrink-0 text-amber-500" />
|
||||
<span>
|
||||
Bu aracın kataloğu henüz tam indirilmemiş. Arama yalnızca şu ana kadar açılmış
|
||||
kategorileri kapsar — kategorileri gezdikçe kapsam genişler.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{nothing ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||
"{debounced}" ile eşleşen kategori veya parça bulunamadı.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
{/* Section 1 — leaf categories */}
|
||||
<section>
|
||||
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Kategoriler {categories.length > 0 && `(${categories.length})`}
|
||||
</h3>
|
||||
{categories.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Eşleşen kategori yok.</p>
|
||||
) : (
|
||||
<ul className="space-y-1.5">
|
||||
{categories.map((c) => {
|
||||
const Icon = getCategoryIcon(c.name);
|
||||
return (
|
||||
<li key={c.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => goCategory(c.id)}
|
||||
disabled={c.unavailable}
|
||||
className={`group flex w-full items-center gap-3 rounded-lg border border-border p-3 text-left transition-colors hover:border-foreground/20 hover:bg-muted/40 ${c.unavailable ? "opacity-40" : ""}`}
|
||||
>
|
||||
<Icon className="h-5 w-5 flex-shrink-0 text-primary" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="truncate text-sm font-medium">
|
||||
{clean(c.name)}
|
||||
</span>
|
||||
{c.matchingPartCount > 0 && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="flex-shrink-0 text-[11px]"
|
||||
>
|
||||
{c.matchingPartCount} parça
|
||||
</Badge>
|
||||
)}
|
||||
</span>
|
||||
<Breadcrumb trail={c.breadcrumb} />
|
||||
</span>
|
||||
<ChevronRight className="h-4 w-4 flex-shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5" />
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Section 2 — parts (OEM) */}
|
||||
<section>
|
||||
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Parçalar (OEM) {parts.length > 0 && `(${parts.length})`}
|
||||
</h3>
|
||||
{parts.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Eşleşen parça yok. (Yalnızca açılmış kategorilerin parçaları aranır.)
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-1.5">
|
||||
{parts.map((p, i) => (
|
||||
<li key={`${p.oemCode}-${p.categoryId}-${i}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => goCategory(p.categoryId)}
|
||||
disabled={p.unavailable}
|
||||
className={`group flex w-full items-center gap-3 rounded-lg border border-border p-3 text-left transition-colors hover:border-foreground/20 hover:bg-muted/40 ${p.unavailable ? "opacity-40" : ""}`}
|
||||
>
|
||||
<Package className="h-5 w-5 flex-shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex items-center gap-2">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="flex-shrink-0 font-mono text-[11px]"
|
||||
>
|
||||
{p.oemCode}
|
||||
</Badge>
|
||||
<span className="truncate text-sm font-medium">
|
||||
{clean(p.name)}
|
||||
</span>
|
||||
</span>
|
||||
<Breadcrumb trail={p.breadcrumb} tail={p.categoryName} />
|
||||
</span>
|
||||
<ChevronRight className="h-4 w-4 flex-shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { CatalogSearch } from "@/components/categories/catalog-search";
|
||||
import { CategoryBreadcrumb } from "@/components/categories/category-breadcrumb";
|
||||
import { CategoryColumns } from "@/components/categories/category-columns";
|
||||
import { CategoryGrid } from "@/components/categories/category-grid";
|
||||
@@ -34,6 +35,9 @@ function VehicleDetailPage() {
|
||||
const [viewMode, setViewMode] = useState<"grid" | "tree" | "columns">(
|
||||
() => getUserSettings().categoryViewMode ?? "grid",
|
||||
);
|
||||
// When a cross-tree catalog search is active, the normal browse (and its view
|
||||
// toggle) are hidden in favour of the search results.
|
||||
const [searchActive, setSearchActive] = useState(false);
|
||||
|
||||
const changeViewMode = (mode: "grid" | "tree" | "columns") => {
|
||||
setViewMode(mode);
|
||||
@@ -215,24 +219,32 @@ function VehicleDetailPage() {
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-base">Yedek Parça Kategorileri</CardTitle>
|
||||
<CategoryViewToggle value={viewMode} onChange={changeViewMode} />
|
||||
{!searchActive && <CategoryViewToggle value={viewMode} onChange={changeViewMode} />}
|
||||
</CardHeader>
|
||||
<CardContent
|
||||
className={viewMode === "columns" ? "p-0 overflow-hidden rounded-b-lg" : undefined}
|
||||
className={
|
||||
viewMode === "columns" && !searchActive ? "p-0 overflow-hidden rounded-b-lg" : undefined
|
||||
}
|
||||
>
|
||||
{categoriesLoading ? (
|
||||
<div className="space-y-2">
|
||||
{KEYS_8.map((__k) => (
|
||||
<Skeleton key={__k} className="h-8 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : viewMode === "grid" ? (
|
||||
<CategoryGrid categories={categoryTree || []} vehicleId={id} />
|
||||
) : viewMode === "tree" ? (
|
||||
<CategoryTree categories={categoryTree || []} vehicleId={id} />
|
||||
) : (
|
||||
<CategoryColumns categories={categoryTree || []} vehicleId={id} />
|
||||
)}
|
||||
{/* Search box keeps its own padding when the columns view zeroes out
|
||||
CardContent padding. */}
|
||||
<div className={viewMode === "columns" && !searchActive ? "p-6 pb-0" : undefined}>
|
||||
<CatalogSearch vehicleId={id} onActiveChange={setSearchActive} />
|
||||
</div>
|
||||
{!searchActive &&
|
||||
(categoriesLoading ? (
|
||||
<div className="space-y-2">
|
||||
{KEYS_8.map((__k) => (
|
||||
<Skeleton key={__k} className="h-8 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : viewMode === "grid" ? (
|
||||
<CategoryGrid categories={categoryTree || []} vehicleId={id} />
|
||||
) : viewMode === "tree" ? (
|
||||
<CategoryTree categories={categoryTree || []} vehicleId={id} />
|
||||
) : (
|
||||
<CategoryColumns categories={categoryTree || []} vehicleId={id} />
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user