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:
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user