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

@@ -182,6 +182,8 @@ describe("CategoriesService", () => {
c.limit = vi.fn().mockReturnValue([category]);
return c;
}),
// getAncestors runs a raw recursive CTE via db.execute.
execute: vi.fn().mockResolvedValue([{ id: "root", name: "Kök", depth: 1 }]),
};
const { service } = createService(db);
@@ -196,6 +198,33 @@ describe("CategoriesService", () => {
expect(partsCatalogsService.fetchParts).not.toHaveBeenCalled();
expect(result.parts).toEqual([]);
expect(result.children).toEqual(discovered);
// The wrapper attaches the server-resolved ancestor trail (id+name only).
expect(result.ancestors).toEqual([{ id: "root", name: "Kök" }]);
});
});
describe("getAncestors", () => {
it("maps recursive-CTE rows to {id,name}, dropping the depth column", async () => {
const rows = [
{ id: "root", name: "Motor", depth: 2 },
{ id: "mid", name: "Silindir kapağı", depth: 1 },
];
const db = { execute: vi.fn().mockResolvedValue(rows) };
const { service } = createService(db);
const result = await service.getAncestors("leaf");
expect(result).toEqual([
{ id: "root", name: "Motor" },
{ id: "mid", name: "Silindir kapağı" },
]);
expect(db.execute).toHaveBeenCalledTimes(1);
});
it("returns an empty trail for a root category", async () => {
const db = { execute: vi.fn().mockResolvedValue([]) };
const { service } = createService(db);
expect(await service.getAncestors("root")).toEqual([]);
});
});

View File

@@ -593,7 +593,37 @@ export class CategoriesService {
* If the category is a parent (has children), returns children instead.
* If it's a leaf with a BOM linkPath, fetches parts from PL24 on-demand.
*/
/**
* Walk the parent_id chain to the root, returning the ancestor trail in
* root→parent order (the node itself excluded). Single recursive query;
* parent_id is indexed and the tree is shallow. This is the authoritative
* source for breadcrumbs — independent of any client-side tree cache, so it
* stays correct on refresh, deep links, and lazily-fetched deep nodes.
*/
async getAncestors(categoryId: string): Promise<Array<{ id: string; name: string }>> {
const rows = await this.db.execute<{ id: string; name: string; depth: number }>(sql`
WITH RECURSIVE ancestry AS (
SELECT id, name, parent_id, 0 AS depth
FROM categories WHERE id = ${categoryId}
UNION ALL
SELECT c.id, c.name, c.parent_id, a.depth + 1
FROM categories c
JOIN ancestry a ON c.id = a.parent_id
)
SELECT id, name, depth FROM ancestry WHERE depth > 0 ORDER BY depth DESC
`);
return rows.map((r) => ({ id: r.id, name: r.name }));
}
async getCategoryWithParts(categoryId: string) {
const result = await this.getCategoryWithPartsInner(categoryId);
// Attach the full ancestor trail so the client can render a complete,
// reliable breadcrumb regardless of what's in its tree cache.
const ancestors = await this.getAncestors(categoryId);
return { ...result, ancestors };
}
private async getCategoryWithPartsInner(categoryId: string) {
const [category] = await this.db
.select()
.from(categories)

View File

@@ -0,0 +1,71 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
// Render TanStack Link as a plain anchor; `to` becomes the href so we can assert
// the route target. `params` is stripped to avoid invalid-DOM-attr noise.
vi.mock("@tanstack/react-router", () => ({
Link: ({ children, to, params: _params, ...props }: any) => (
<a href={to} {...props}>
{children}
</a>
),
}));
import { CategoryBreadcrumb } from "../category-breadcrumb";
describe("CategoryBreadcrumb", () => {
it("renders the full trail: Arama → vehicle → ancestors → current", () => {
render(
<CategoryBreadcrumb
vehicleId="v1"
vehicleLabel="BMW 320i"
ancestors={[
{ id: "a1", name: "Motor" },
{ id: "a2", name: "Silindir kapağı" },
]}
currentName="Conta"
/>,
);
expect(screen.getByText("Arama")).toBeInTheDocument();
expect(screen.getByText("BMW 320i")).toBeInTheDocument();
expect(screen.getByText("Motor")).toBeInTheDocument();
expect(screen.getByText("Silindir kapağı")).toBeInTheDocument();
const current = screen.getByText("Conta");
expect(current).toHaveAttribute("aria-current", "page");
expect(current.closest("a")).toBeNull(); // current is plain text, not a link
});
it("makes the vehicle the terminal crumb on the vehicle page (no category context)", () => {
render(<CategoryBreadcrumb vehicleId="v1" vehicleLabel="BMW 320i" />);
expect(screen.getByText("Arama")).toBeInTheDocument();
const vehicle = screen.getByText("BMW 320i");
expect(vehicle).toHaveAttribute("aria-current", "page");
expect(vehicle.closest("a")).toBeNull();
expect(screen.queryByText("Motor")).not.toBeInTheDocument();
});
it("links Arama, the vehicle, and ancestors to their routes", () => {
render(
<CategoryBreadcrumb
vehicleId="v1"
vehicleLabel="BMW 320i"
ancestors={[{ id: "a1", name: "Motor" }]}
currentName="Conta"
/>,
);
expect(screen.getByText("Arama").closest("a")).toHaveAttribute("href", "/dashboard/search");
// With category context the vehicle is a link, not the current crumb.
expect(screen.getByText("BMW 320i").closest("a")).toHaveAttribute(
"href",
"/dashboard/vehicles/$id",
);
expect(screen.getByText("Motor").closest("a")).toHaveAttribute(
"href",
"/dashboard/vehicles/$id/categories/$categoryId",
);
});
});

View File

@@ -0,0 +1,88 @@
import { Link } from "@tanstack/react-router";
import { ChevronRight } from "lucide-react";
import { Fragment } from "react";
interface CategoryBreadcrumbProps {
vehicleId: string;
/** Display label for the vehicle root crumb (e.g. "BMW 320i"). */
vehicleLabel: string;
/** Ancestor trail in root→parent order (current node excluded). */
ancestors?: Array<{ id: string; name: string }>;
/**
* Name of the current category. When omitted, the vehicle itself is the
* terminal crumb — used on the vehicle detail page.
*/
currentName?: string | null;
}
/**
* The single breadcrumb for the vehicle drill-down flow. The trail is driven by
* the server-provided `ancestors`, so it stays complete and correct regardless
* of the client tree cache (refresh, deep link, lazily-fetched deep nodes).
*
* Chain: Arama → [Vehicle] → …ancestors → current
*/
export function CategoryBreadcrumb({
vehicleId,
vehicleLabel,
ancestors = [],
currentName,
}: CategoryBreadcrumbProps) {
// The vehicle is the terminal crumb only when we're on the vehicle page
// itself (no category context at all).
const vehicleIsCurrent = !currentName && ancestors.length === 0;
return (
<nav
aria-label="Breadcrumb"
className="flex flex-wrap items-center gap-x-1 gap-y-1 text-xs text-muted-foreground"
>
<Link
to="/dashboard/search"
data-faro-user-action-name="breadcrumb-search"
className="rounded px-1 transition-colors hover:bg-accent hover:text-foreground"
>
Arama
</Link>
<ChevronRight aria-hidden className="h-3 w-3 shrink-0" />
{vehicleIsCurrent ? (
<span aria-current="page" className="max-w-[220px] truncate px-1 text-foreground">
{vehicleLabel}
</span>
) : (
<Link
to="/dashboard/vehicles/$id"
params={{ id: vehicleId }}
data-faro-user-action-name="breadcrumb-vehicle"
className="max-w-[220px] truncate rounded px-1 transition-colors hover:bg-accent hover:text-foreground"
>
{vehicleLabel}
</Link>
)}
{ancestors.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: vehicleId, 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>
))}
{currentName && (
<>
<ChevronRight aria-hidden className="h-3 w-3 shrink-0" />
<span aria-current="page" className="max-w-[220px] truncate px-1 text-foreground">
{currentName}
</span>
</>
)}
</nav>
);
}

View File

@@ -5,7 +5,7 @@ import { Card, CardContent, Input } from "@sase/ui";
import { useQueryClient } from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router";
import { ChevronRight, Loader2, Search, X } from "lucide-react";
import { Fragment, useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
interface Category {
id: string;
@@ -18,12 +18,6 @@ interface Category {
source?: string;
}
interface StackEntry {
id: string | null;
name: string;
categories: Category[];
}
interface CategoryGridProps {
categories: Category[];
vehicleId: string;
@@ -45,49 +39,33 @@ export function CategoryGrid({
const queryClient = useQueryClient();
const navigate = useNavigate();
const [stack, setStack] = useState<StackEntry[]>([
{ id: parentId ?? null, name: t("categories.root"), categories },
]);
const [loadingId, setLoadingId] = useState<string | null>(null);
const [prefetchingIds, setPrefetchingIds] = useState<Set<string>>(new Set());
const [imageOverrides, setImageOverrides] = useState<Map<string, string>>(new Map());
const prefetchedRef = useRef<Set<string>>(new Set());
// Reset stack when input categories prop changes (parent navigation).
// `t` is intentionally excluded: useTranslation returns a fresh function each
// render, so including it would re-fire the effect on every render and reset
// the stack immediately after handleSelect pushes a child level — making
// parent-category clicks appear to do nothing.
// biome-ignore lint/correctness/useExhaustiveDependencies: t identity is unstable; key is static
useEffect(() => {
setStack([{ id: parentId ?? null, name: t("categories.root"), categories }]);
}, [categories, parentId]);
const current = stack[stack.length - 1];
const currentCategories = current.categories;
// Filter the current drill level by name — deep levels (e.g. Elektrik has 62
// Filter the current 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
// Reset the filter whenever the rendered level changes — route navigation
// swaps the `categories` prop, so that is the level identity now.
// biome-ignore lint/correctness/useExhaustiveDependencies: re-run on level change is intentional
useEffect(() => {
setFilter("");
}, [current]);
}, [categories]);
const trimmedFilter = filter.trim().toLocaleLowerCase("tr");
const visibleCategories = trimmedFilter
? currentCategories.filter((c) => c.name.toLocaleLowerCase("tr").includes(trimmedFilter))
: currentCategories;
const showFilter = currentCategories.length > 8;
? categories.filter((c) => c.name.toLocaleLowerCase("tr").includes(trimmedFilter))
: categories;
const showFilter = categories.length > 8;
// Prefetch schema images for leaf categories in batches of 2
useEffect(() => {
prefetchedRef.current.clear();
setImageOverrides(new Map());
const leafsWithoutImage = currentCategories.filter(
const leafsWithoutImage = categories.filter(
(c) =>
c.children !== undefined &&
c.children.length === 0 &&
@@ -100,7 +78,7 @@ export function CategoryGrid({
return;
}
const firstParentId = currentCategories[0]?.parentId;
const firstParentId = categories[0]?.parentId;
let didCancel = false;
const BATCH_SIZE = 2;
@@ -137,53 +115,38 @@ export function CategoryGrid({
return () => {
didCancel = true;
};
}, [currentCategories, vehicleId]);
}, [categories, vehicleId]);
const navigateToLeaf = useCallback(
(categoryId: string) => {
// Every drill — parent or leaf — is a route navigation, so each level has its
// own URL and history entry: browser-back goes up exactly one level, and deep
// levels are shareable/refreshable. For known parents we seed the destination
// query cache so children render instantly while the full payload (parts,
// ancestors) refetches in the background.
const navigateToCategory = useCallback(
(category: Category) => {
if (!catalogMode && category.children && category.children.length > 0) {
queryClient.setQueryData(["category-parts", vehicleId, category.id], {
id: category.id,
name: category.name,
description: null,
parentId: category.parentId ?? parentId ?? null,
parts: [],
schemaPics: [],
hotspots: [],
children: category.children,
});
}
navigate({
to: catalogMode
? "/dashboard/catalog/$brandName/$modelId/categories/$categoryId"
: "/dashboard/vehicles/$id/categories/$categoryId",
params: catalogMode
? { brandName: brandName ?? vehicleId, modelId: vehicleId, categoryId }
: { id: vehicleId, categoryId },
? { brandName: brandName ?? vehicleId, modelId: vehicleId, categoryId: category.id }
: { id: vehicleId, categoryId: category.id },
search: (catalogMode && variantSearch ? variantSearch : undefined) as any,
});
},
[navigate, catalogMode, brandName, vehicleId, variantSearch],
);
const handleSelect = useCallback(
async (category: Category) => {
const known = category.children;
if (known && known.length > 0) {
setStack((s) => [...s, { id: category.id, name: category.name, categories: known }]);
return;
}
if (category.children !== undefined && category.children.length === 0) {
navigateToLeaf(category.id);
return;
}
setLoadingId(category.id);
try {
const data = await queryClient.fetchQuery({
queryKey: ["category-children", category.id],
queryFn: () => api.get<Category[]>(`/categories/${category.id}/children`),
staleTime: 5 * 60 * 1000,
});
const children = data ?? [];
if (children.length === 0) {
navigateToLeaf(category.id);
return;
}
setStack((s) => [...s, { id: category.id, name: category.name, categories: children }]);
} finally {
setLoadingId(null);
}
},
[queryClient, navigateToLeaf],
[navigate, queryClient, catalogMode, brandName, vehicleId, parentId, variantSearch],
);
if (!categories || categories.length === 0) {
@@ -194,23 +157,6 @@ export function CategoryGrid({
return (
<div>
{stack.length > 1 && (
<nav className="flex items-center gap-1 text-sm text-muted-foreground mb-3">
{stack.map((s, i) => (
<Fragment key={s.id ?? "root"}>
{i > 0 && <ChevronRight className="h-3.5 w-3.5" />}
<button
type="button"
onClick={() => setStack((prev) => prev.slice(0, i + 1))}
className="hover:text-foreground hover:underline truncate max-w-[200px] disabled:hover:no-underline"
disabled={i === stack.length - 1}
>
{s.name}
</button>
</Fragment>
))}
</nav>
)}
{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" />
@@ -244,14 +190,13 @@ export function CategoryGrid({
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}
onClick={() => navigateToCategory(category)}
disabled={category.unavailable}
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
@@ -260,7 +205,7 @@ export function CategoryGrid({
Icon={Icon}
schemaImageUrl={schemaImageUrl}
isLeaf={isLeaf}
isLoading={prefetchingIds.has(category.id) || isSelectLoading}
isLoading={prefetchingIds.has(category.id)}
/>
</button>
);

View File

@@ -54,6 +54,9 @@ export interface CategorySchema {
parts: Part[];
schemaPics: SchemaPic[];
hotspots: Hotspot[];
/** Ancestor trail in root→parent order (current node excluded). Authoritative
* breadcrumb source from the server — reliable on refresh/deep-link. */
ancestors?: Array<{ id: string; name: string }>;
/** True when a source (e.g. PL24) fetch failed and the empty lists are a
* load error rather than a genuinely empty category — UI should offer retry. */
loadError?: boolean;

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" />