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:
@@ -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>
|
||||
|
||||
@@ -466,5 +466,8 @@
|
||||
"tr": "Türkçe",
|
||||
"en": "English",
|
||||
"switchLanguage": "Switch Language"
|
||||
},
|
||||
"categories": {
|
||||
"root": "Root"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -466,5 +466,8 @@
|
||||
"tr": "Türkçe",
|
||||
"en": "English",
|
||||
"switchLanguage": "Dil Değiştir"
|
||||
},
|
||||
"categories": {
|
||||
"root": "Kök"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user