feat(FN-373): grid view drill-down for parent categories (+1 more)

Commits merged:
- chore(FN-373): verify grid drill-down lint + typecheck clean
- fix(FN-373): grid view drill-down for parent categories

Files changed:
.../src/components/categories/category-grid.tsx    | 175 ++++++++++++++++-----
 apps/web/src/messages/en.json                      |   3 +
 apps/web/src/messages/tr.json                      |   3 +
 3 files changed, 138 insertions(+), 43 deletions(-)

Fusion-Task-Id: FN-373
This commit is contained in:
Fusion
2026-05-14 18:56:01 +00:00
parent 0ce68a7b99
commit 6c28688283
3 changed files with 137 additions and 42 deletions

View File

@@ -1,9 +1,11 @@
import { api } from "@/lib/api-client";
import { getCategoryIcon } from "@/lib/category-icons";
import { useTranslation } from "@/lib/i18n";
import { Card, CardContent } from "@sase/ui";
import { Link } from "@tanstack/react-router";
import { ChevronRight } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router";
import { ChevronRight, Loader2 } from "lucide-react";
import { Fragment, useCallback, useEffect, useRef, useState } from "react";
interface Category {
id: string;
@@ -16,6 +18,12 @@ interface Category {
source?: string;
}
interface StackEntry {
id: string | null;
name: string;
categories: Category[];
}
interface CategoryGridProps {
categories: Category[];
vehicleId: string;
@@ -33,16 +41,32 @@ export function CategoryGrid({
parentId,
variantSearch,
}: CategoryGridProps) {
const { t } = useTranslation();
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)
useEffect(() => {
setStack([{ id: parentId ?? null, name: t("categories.root"), categories }]);
}, [categories, parentId, t]);
const current = stack[stack.length - 1];
const currentCategories = current.categories;
// Prefetch schema images for leaf categories in batches of 2
useEffect(() => {
prefetchedRef.current.clear();
setImageOverrides(new Map());
const leafsWithoutImage = categories.filter(
const leafsWithoutImage = currentCategories.filter(
(c) =>
c.children !== undefined &&
c.children.length === 0 &&
@@ -55,7 +79,7 @@ export function CategoryGrid({
return;
}
const parentId = categories[0]?.parentId;
const firstParentId = currentCategories[0]?.parentId;
let didCancel = false;
const BATCH_SIZE = 2;
@@ -71,9 +95,9 @@ export function CategoryGrid({
for (const c of batch) prefetchedRef.current.add(c.id);
if (!didCancel && parentId) {
if (!didCancel && firstParentId) {
try {
const refreshed = await api.get<Category[]>(`/categories/${parentId}/children`);
const refreshed = await api.get<Category[]>(`/categories/${firstParentId}/children`);
if (!didCancel && refreshed?.length) {
setImageOverrides((prev) => {
const next = new Map(prev);
@@ -92,46 +116,107 @@ export function CategoryGrid({
return () => {
didCancel = true;
};
}, [categories, vehicleId]);
}, [currentCategories, vehicleId]);
const navigateToLeaf = useCallback(
(categoryId: string) => {
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 },
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],
);
if (!categories || categories.length === 0) {
return <p className="py-4 text-center text-sm text-muted-foreground">Kategori bulunamadi.</p>;
return (
<p className="py-4 text-center text-sm text-muted-foreground">{t("catalog.noCategories")}</p>
);
}
return (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{categories.map((category) => {
const Icon = getCategoryIcon(category.name);
const isLeaf = category.children !== undefined && category.children.length === 0;
const schemaImageUrl = imageOverrides.get(category.id) || category.schemaImageUrl;
<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>
)}
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{currentCategories.map((category) => {
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 (
<Link
key={category.id}
to={
catalogMode
? "/dashboard/catalog/$brandName/$modelId/categories/$categoryId"
: "/dashboard/vehicles/$id/categories/$categoryId"
}
params={
catalogMode
? { brandName: brandName ?? vehicleId, modelId: vehicleId, categoryId: category.id }
: { id: vehicleId, categoryId: category.id }
}
search={catalogMode && variantSearch ? variantSearch : undefined}
className={category.unavailable ? "opacity-40" : undefined}
>
<CategoryCard
name={category.name}
partCount={category.partCount}
Icon={Icon}
schemaImageUrl={schemaImageUrl}
isLeaf={isLeaf}
isLoading={prefetchingIds.has(category.id)}
/>
</Link>
);
})}
return (
<button
key={category.id}
type="button"
onClick={() => handleSelect(category)}
disabled={category.unavailable || isSelectLoading}
className={`w-full text-left ${category.unavailable ? "opacity-40" : ""}`}
>
<CategoryCard
name={category.name}
partCount={category.partCount}
Icon={Icon}
schemaImageUrl={schemaImageUrl}
isLeaf={isLeaf}
isLoading={prefetchingIds.has(category.id) || isSelectLoading}
/>
</button>
);
})}
</div>
</div>
);
}
@@ -198,7 +283,11 @@ function CategoryCard({
<p className="text-sm text-muted-foreground">{partCount} parça</p>
)}
</div>
<ChevronRight className="h-5 w-5 text-muted-foreground group-hover:text-primary group-hover:translate-x-1 transition-all flex-shrink-0" />
{isLoading ? (
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground flex-shrink-0" />
) : (
<ChevronRight className="h-5 w-5 text-muted-foreground group-hover:text-primary group-hover:translate-x-1 transition-all flex-shrink-0" />
)}
</div>
</CardContent>
</Card>

View File

@@ -466,5 +466,8 @@
"tr": "Türkçe",
"en": "English",
"switchLanguage": "Switch Language"
},
"categories": {
"root": "Root"
}
}

View File

@@ -466,5 +466,8 @@
"tr": "Türkçe",
"en": "English",
"switchLanguage": "Dil Değiştir"
},
"categories": {
"root": "Kök"
}
}