feat(category-page): add breadcrumb trail + safer back navigation

Addresses long-term fix from insight cmpifhxfs000lvze9cy6ne0tx: users in
deep category trees had no orientation cue beyond a single back button.

- Breadcrumb derived from the cached /categories/tree/<vehicleId> response.
  When the cache is warm (user came from the vehicle page), the full ancestry
  renders; otherwise it falls back gracefully to "Vehicle → Current".
- Vehicle label fetched via the same query key already used by the vehicle
  page, so the request is deduped.
- Back button now prefers the breadcrumb parent (resolved synchronously from
  cache) over `data.parentId` (which is async). Clicking back before the
  category payload loads no longer dumps the user to the vehicle root.
- H1 placeholder during load swapped from a stale "Kategori Detayı" string
  to an inline pulse — prevents the title from briefly displaying wrong text
  before the real name resolves. Uses a span-based pulse (Skeleton is a div
  and would be invalid HTML inside an h1).

Phase 3/8 of the UX audit follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-23 20:48:15 +03:00
parent ae20ca9676
commit 34ad77b2a1

View File

@@ -2,11 +2,13 @@ import { CategoryColumns } from "@/components/categories/category-columns";
import { CategoryGrid } from "@/components/categories/category-grid";
import { CategoryTree } from "@/components/categories/category-tree";
import { useCategoryParts } from "@/hooks/use-parts";
import { api } from "@/lib/api-client";
import { getUserSettings, setUserSetting } from "@/lib/user-settings";
import { Button, Skeleton, cn } from "@sase/ui";
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { ArrowLeft, Columns2, LayoutGrid, List } from "lucide-react";
import { Suspense, lazy, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Link, createFileRoute, useNavigate } from "@tanstack/react-router";
import { ArrowLeft, ChevronRight, Columns2, LayoutGrid, List } from "lucide-react";
import { Fragment, Suspense, lazy, useMemo, useState } from "react";
import { KEYS_6, KEYS_8 } from "@/lib/keys";
const SchemaViewer = lazy(() =>
@@ -45,13 +47,48 @@ 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 } = useCategoryParts(id, categoryId);
// Vehicle for breadcrumb root — cached if user arrived from /dashboard/vehicles/$id
const { data: vehicle } = useQuery({
queryKey: ["vehicle", id],
queryFn: () => api.get<{ brandName?: string; model?: string; year?: number }>(`/vehicles/${id}`),
enabled: !!id,
});
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",
);
@@ -61,22 +98,72 @@ 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;
const handleBack = () => {
if (parentFromTrail) {
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 },
});
} else {
navigate({
to: "/dashboard/vehicles/$id",
params: { id },
});
return;
}
navigate({ to: "/dashboard/vehicles/$id", params: { id } });
};
const vehicleLabel = vehicle?.brandName
? `${vehicle.brandName}${vehicle.model ? ` ${vehicle.model}` : ""}`
: "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 }}
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 }}
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>
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
@@ -84,7 +171,15 @@ function VehicleCategoryPage() {
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="text-xl font-bold">{data?.name || "Kategori Detayı"}</h1>
<h1 className="text-xl font-bold">
{data?.name ?? (
isLoading ? (
<span className="inline-block h-6 w-48 animate-pulse rounded-md bg-primary/10 align-middle" />
) : (
"Kategori Detayı"
)
)}
</h1>
{data?.description && (
<p className="text-sm text-muted-foreground">{data.description}</p>
)}