feat(web): unify vehicle drill-down breadcrumb & back navigation

The vehicle category flow had two competing navigation models: the grid
drilled in place (no URL change, own breadcrumb) while the category route
had a separate breadcrumb reconstructed from the cached tree plus a back
button with different semantics. The route breadcrumb collapsed on
refresh/deep-link and for lazily-fetched deep nodes, and browser-back from
an in-grid drill ejected the user out of the whole flow.

Make the URL + a server-provided ancestor trail the single source of truth:

- API: getCategoryWithParts now returns `ancestors[]` (root→parent) via a
  recursive CTE, so breadcrumbs are complete regardless of client cache.
- New shared <CategoryBreadcrumb> (Arama → vehicle → ancestors → current)
  used by both the vehicle and category pages.
- Category page back button derives the parent from `ancestors` (no more
  cache-path race); cache reconstruction (findCategoryPath) removed.
- CategoryGrid drills via route navigation per level (each level a URL +
  history entry), seeding the query cache for instant render. Browser-back
  now goes up one level and deep levels are shareable/refreshable.
- Tree & Columns intentionally keep their in-place paradigm.
- a11y: aria-labels on back buttons and the breadcrumb nav.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-25 22:55:01 +03:00
parent 9fc222d525
commit 001ad1986c
8 changed files with 286 additions and 176 deletions

View File

@@ -1,3 +1,4 @@
import { CategoryBreadcrumb } from "@/components/categories/category-breadcrumb";
import { CategoryColumns } from "@/components/categories/category-columns";
import { CategoryGrid } from "@/components/categories/category-grid";
import { CategoryTree } from "@/components/categories/category-tree";
@@ -7,10 +8,10 @@ import { api } from "@/lib/api-client";
import { getUserSettings, setUserSetting } from "@/lib/user-settings";
import { cleanModelName } from "@/lib/vehicle";
import { Button, Skeleton } from "@sase/ui";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Link, createFileRoute, useNavigate } from "@tanstack/react-router";
import { ArrowLeft, ChevronRight } from "lucide-react";
import { Fragment, Suspense, lazy, useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { ArrowLeft } from "lucide-react";
import { Suspense, lazy, useState } from "react";
import { KEYS_6, KEYS_8 } from "@/lib/keys";
const SchemaViewer = lazy(() =>
@@ -49,31 +50,9 @@ export const Route = createFileRoute("/dashboard/vehicles_/$id/categories_/$cate
component: VehicleCategoryPage,
});
interface CategoryTreeNode {
id: string;
name: string;
children?: CategoryTreeNode[];
}
function findCategoryPath(
nodes: CategoryTreeNode[] | undefined,
targetId: string,
trail: CategoryTreeNode[] = [],
): CategoryTreeNode[] | null {
if (!nodes) return null;
for (const node of nodes) {
const next = [...trail, node];
if (node.id === targetId) return next;
const found = findCategoryPath(node.children, targetId, next);
if (found) return found;
}
return null;
}
function VehicleCategoryPage() {
const { id, categoryId } = Route.useParams();
const navigate = useNavigate();
const queryClient = useQueryClient();
const { data, isLoading, error, refetch, isFetching } = useCategoryParts(id, categoryId);
// Vehicle for breadcrumb root — cached if user arrived from /dashboard/vehicles/$id
@@ -86,12 +65,6 @@ function VehicleCategoryPage() {
const hasChildren = data?.children && data.children.length > 0;
// Resolve breadcrumb trail from the cached category tree (if available)
const breadcrumbTrail = useMemo(() => {
const tree = queryClient.getQueryData<CategoryTreeNode[]>(["category-tree", id]);
return findCategoryPath(tree, categoryId) ?? [];
}, [queryClient, id, categoryId]);
const [viewMode, setViewMode] = useState<"grid" | "tree" | "columns">(
() => getUserSettings().categoryViewMode ?? "grid",
);
@@ -101,23 +74,16 @@ function VehicleCategoryPage() {
setUserSetting("categoryViewMode", mode);
};
// Prefer the breadcrumb parent (works even before `data` loads). Fall back to
// data.parentId, then to the vehicle page.
const parentFromTrail =
breadcrumbTrail.length >= 2 ? breadcrumbTrail[breadcrumbTrail.length - 2] : null;
// Back = one level up the hierarchy, derived from the server ancestor trail
// (reliable on refresh/deep-link). The last ancestor is the direct parent;
// when there is none we're at the top category, so return to the vehicle.
const handleBack = () => {
if (parentFromTrail) {
const ancestors = data?.ancestors ?? [];
const parentId = ancestors.at(-1)?.id ?? data?.parentId ?? null;
if (parentId) {
navigate({
to: "/dashboard/vehicles/$id/categories/$categoryId",
params: { id, categoryId: parentFromTrail.id },
});
return;
}
if (data?.parentId) {
navigate({
to: "/dashboard/vehicles/$id/categories/$categoryId",
params: { id, categoryId: data.parentId },
params: { id, categoryId: parentId },
});
return;
}
@@ -129,46 +95,14 @@ function VehicleCategoryPage() {
? `${vehicle.brandName}${cleanModel ? ` ${cleanModel}` : ""}`
: "Araç";
// Breadcrumb segments excluding the current page (last item rendered as text below)
const trailWithoutCurrent = breadcrumbTrail.slice(0, -1);
return (
<div className="space-y-4">
{/* Breadcrumb */}
<nav
aria-label="Breadcrumb"
className="flex flex-wrap items-center gap-x-1 gap-y-1 text-xs text-muted-foreground"
>
<Link
to="/dashboard/vehicles/$id"
params={{ id }}
data-faro-user-action-name="breadcrumb-vehicle"
className="truncate rounded px-1 transition-colors hover:bg-accent hover:text-foreground"
>
{vehicleLabel}
</Link>
{trailWithoutCurrent.map((node) => (
<Fragment key={node.id}>
<ChevronRight aria-hidden className="h-3 w-3 shrink-0" />
<Link
to="/dashboard/vehicles/$id/categories/$categoryId"
params={{ id, categoryId: node.id }}
data-faro-user-action-name="breadcrumb-category"
className="max-w-[180px] truncate rounded px-1 transition-colors hover:bg-accent hover:text-foreground"
>
{node.name}
</Link>
</Fragment>
))}
{(breadcrumbTrail.length > 0 || data?.name) && (
<>
<ChevronRight aria-hidden className="h-3 w-3 shrink-0" />
<span aria-current="page" className="truncate px-1 text-foreground">
{data?.name ?? breadcrumbTrail[breadcrumbTrail.length - 1]?.name ?? "…"}
</span>
</>
)}
</nav>
<CategoryBreadcrumb
vehicleId={id}
vehicleLabel={vehicleLabel}
ancestors={data?.ancestors ?? []}
currentName={data?.name ?? (isLoading ? "…" : undefined)}
/>
{/* Header */}
<div className="flex items-center justify-between">
@@ -178,6 +112,7 @@ function VehicleCategoryPage() {
size="icon"
onClick={handleBack}
title="Geri dön"
aria-label="Geri dön"
data-faro-user-action-name="category-back"
>
<ArrowLeft className="h-4 w-4" />

View File

@@ -1,3 +1,4 @@
import { CategoryBreadcrumb } from "@/components/categories/category-breadcrumb";
import { CategoryColumns } from "@/components/categories/category-columns";
import { CategoryGrid } from "@/components/categories/category-grid";
import { CategoryTree } from "@/components/categories/category-tree";
@@ -72,6 +73,10 @@ function VehicleDetailPage() {
enabled: !!id,
});
const vehicleLabel = vehicle?.brandName
? `${vehicle.brandName}${cleanModelName(vehicle?.model) ? ` ${cleanModelName(vehicle.model)}` : ""}`
: "Araç";
if (vehicleLoading) {
return (
<div className="space-y-6">
@@ -112,6 +117,7 @@ function VehicleDetailPage() {
size="icon"
onClick={handleBack}
title="Geri dön"
aria-label="Geri dön"
data-faro-user-action-name="vehicle-back"
>
<ArrowLeft className="h-4 w-4" />
@@ -158,6 +164,8 @@ function VehicleDetailPage() {
return (
<div className="space-y-6">
<CategoryBreadcrumb vehicleId={id} vehicleLabel={vehicleLabel} />
{/* Header */}
<div className="flex items-center gap-3">
<Button
@@ -165,6 +173,7 @@ function VehicleDetailPage() {
size="icon"
onClick={handleBack}
title="Geri dön"
aria-label="Geri dön"
data-faro-user-action-name="vehicle-back"
>
<ArrowLeft className="h-4 w-4" />