dev #46
@@ -670,7 +670,29 @@ export class CategoriesService {
|
||||
// to the parts path rather than returning a misleading empty leaf.
|
||||
}
|
||||
|
||||
// PL24 main/sub-group node (its link_wid marks a groups table) with no
|
||||
// children yet — drill it. If PL24 serves subgroups, return them as a
|
||||
// parent. If the drill comes back empty, the catalog snapshot is broken
|
||||
// server-side: surface that as a load error instead of a misleading
|
||||
// "no parts" leaf (these group nodes are never legitimately empty).
|
||||
if (category.source === "pl24" && category.linkWid?.includes("Group") && category.vehicleId) {
|
||||
const groupChildren = await this.getChildren(categoryId);
|
||||
const base = {
|
||||
id: category.id,
|
||||
name: category.name,
|
||||
description: category.nameOriginal || null,
|
||||
parentId: category.parentId || null,
|
||||
parts: [],
|
||||
schemaPics: [],
|
||||
hotspots: [],
|
||||
};
|
||||
return groupChildren.length > 0
|
||||
? { ...base, children: groupChildren }
|
||||
: { ...base, loadError: true };
|
||||
}
|
||||
|
||||
// Leaf category — get or fetch parts
|
||||
let loadError = false;
|
||||
let discoveredChildren: any[] = [];
|
||||
let dbParts = await this.db.select().from(parts).where(eq(parts.categoryId, categoryId));
|
||||
|
||||
@@ -1090,6 +1112,10 @@ export class CategoriesService {
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// PL24 fetch failed (e.g. broken catalog snapshot → HTTP 500).
|
||||
// Signal a load error so the UI can offer a retry instead of
|
||||
// rendering a misleading empty "no parts" leaf.
|
||||
loadError = true;
|
||||
this.logger.error(
|
||||
`Failed to fetch parts for category ${categoryId}: ${(err as Error).message}`,
|
||||
);
|
||||
@@ -1172,6 +1198,9 @@ export class CategoriesService {
|
||||
parts: dbParts,
|
||||
schemaPics: mappedPics,
|
||||
hotspots: mappedHotspots,
|
||||
// Only meaningful when the lists are empty: true means a source fetch
|
||||
// failed (retryable), as opposed to a genuinely empty leaf.
|
||||
loadError: loadError && dbParts.length === 0 && mappedPics.length === 0,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { api } from "@/lib/api-client";
|
||||
import { getCategoryIcon } from "@/lib/category-icons";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import { Card, CardContent } from "@sase/ui";
|
||||
import { Card, CardContent, Input } from "@sase/ui";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { ChevronRight, Loader2 } from "lucide-react";
|
||||
import { ChevronRight, Loader2, Search, X } from "lucide-react";
|
||||
import { Fragment, useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
interface Category {
|
||||
@@ -66,6 +66,22 @@ export function CategoryGrid({
|
||||
const current = stack[stack.length - 1];
|
||||
const currentCategories = current.categories;
|
||||
|
||||
// Filter the current drill 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
|
||||
useEffect(() => {
|
||||
setFilter("");
|
||||
}, [current]);
|
||||
|
||||
const trimmedFilter = filter.trim().toLocaleLowerCase("tr");
|
||||
const visibleCategories = trimmedFilter
|
||||
? currentCategories.filter((c) => c.name.toLocaleLowerCase("tr").includes(trimmedFilter))
|
||||
: currentCategories;
|
||||
const showFilter = currentCategories.length > 8;
|
||||
|
||||
// Prefetch schema images for leaf categories in batches of 2
|
||||
useEffect(() => {
|
||||
prefetchedRef.current.clear();
|
||||
@@ -195,33 +211,62 @@ export function CategoryGrid({
|
||||
))}
|
||||
</nav>
|
||||
)}
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5">
|
||||
{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 (
|
||||
{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" />
|
||||
<Input
|
||||
type="search"
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
placeholder="Kategori ara…"
|
||||
aria-label="Kategori ara"
|
||||
className="pl-8 pr-8"
|
||||
/>
|
||||
{filter && (
|
||||
<button
|
||||
key={category.id}
|
||||
type="button"
|
||||
onClick={() => handleSelect(category)}
|
||||
disabled={category.unavailable || isSelectLoading}
|
||||
className={`w-full text-left ${category.unavailable ? "opacity-40" : ""}`}
|
||||
onClick={() => setFilter("")}
|
||||
aria-label="Aramayı temizle"
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<CategoryCard
|
||||
name={category.name}
|
||||
partCount={category.partCount}
|
||||
Icon={Icon}
|
||||
schemaImageUrl={schemaImageUrl}
|
||||
isLeaf={isLeaf}
|
||||
isLoading={prefetchingIds.has(category.id) || isSelectLoading}
|
||||
/>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{visibleCategories.length === 0 ? (
|
||||
<p className="py-4 text-center text-sm text-muted-foreground">
|
||||
"{filter}" ile eşleşen kategori yok.
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5">
|
||||
{visibleCategories.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 (
|
||||
<button
|
||||
key={category.id}
|
||||
type="button"
|
||||
onClick={() => handleSelect(category)}
|
||||
disabled={category.unavailable || isSelectLoading}
|
||||
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
|
||||
name={category.name}
|
||||
partCount={category.partCount}
|
||||
Icon={Icon}
|
||||
schemaImageUrl={schemaImageUrl}
|
||||
isLeaf={isLeaf}
|
||||
isLoading={prefetchingIds.has(category.id) || isSelectLoading}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -281,7 +326,7 @@ function CategoryCard({
|
||||
<Icon className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold truncate group-hover:text-primary transition-colors">
|
||||
<h3 className="font-semibold line-clamp-2 group-hover:text-primary transition-colors">
|
||||
{name}
|
||||
</h3>
|
||||
{partCount != null && partCount > 0 && (
|
||||
|
||||
@@ -54,6 +54,9 @@ export interface CategorySchema {
|
||||
parts: Part[];
|
||||
schemaPics: SchemaPic[];
|
||||
hotspots: Hotspot[];
|
||||
/** 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;
|
||||
}
|
||||
|
||||
export function useCategoryParts(vehicleId: string, categoryId: string) {
|
||||
|
||||
24
apps/web/src/lib/__tests__/vehicle.test.ts
Normal file
24
apps/web/src/lib/__tests__/vehicle.test.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { cleanModelName } from "../vehicle";
|
||||
|
||||
describe("cleanModelName", () => {
|
||||
it("drops a parenthetical that repeats the name", () => {
|
||||
expect(cleanModelName("Laguna (Laguna)")).toBe("Laguna");
|
||||
});
|
||||
|
||||
it("drops a trailing duplicate token regardless of case", () => {
|
||||
expect(cleanModelName("Golf 1,6 GOLF")).toBe("Golf 1,6");
|
||||
expect(cleanModelName("Golf TDI Variant GOLF")).toBe("Golf TDI Variant");
|
||||
});
|
||||
|
||||
it("keeps non-redundant parentheticals and tokens", () => {
|
||||
expect(cleanModelName("Laguna (Laguna II)")).toBe("Laguna (Laguna II)");
|
||||
expect(cleanModelName("Astra Sports Tourer")).toBe("Astra Sports Tourer");
|
||||
});
|
||||
|
||||
it("handles empty / nullish input", () => {
|
||||
expect(cleanModelName("")).toBe("");
|
||||
expect(cleanModelName(null)).toBe("");
|
||||
expect(cleanModelName(undefined)).toBe("");
|
||||
});
|
||||
});
|
||||
31
apps/web/src/lib/vehicle.ts
Normal file
31
apps/web/src/lib/vehicle.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Collapse repeated tokens that catalog sources leave in model names, e.g.
|
||||
* "Laguna (Laguna)" → "Laguna", "Golf 1,6 GOLF" → "Golf 1,6". Conservative:
|
||||
* only drops a parenthetical that repeats surrounding text, and exact
|
||||
* (case-insensitive) duplicate whitespace tokens — order is preserved.
|
||||
*/
|
||||
export function cleanModelName(model?: string | null): string {
|
||||
if (!model) return "";
|
||||
let s = model.trim();
|
||||
|
||||
// 1) Drop a parenthetical group whose content already appears outside it.
|
||||
s = s
|
||||
.replace(/\(([^)]*)\)/g, (full, inner: string) => {
|
||||
const innerKey = inner.trim().toLocaleLowerCase("tr");
|
||||
const outside = s.replace(full, " ").toLocaleLowerCase("tr");
|
||||
return innerKey && outside.includes(innerKey) ? " " : full;
|
||||
})
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
|
||||
// 2) Drop exact duplicate tokens (case-insensitive), keeping the first.
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const token of s.split(" ")) {
|
||||
const key = token.toLocaleLowerCase("tr");
|
||||
if (key && seen.has(key)) continue;
|
||||
if (key) seen.add(key);
|
||||
out.push(token);
|
||||
}
|
||||
return out.join(" ");
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import { Link, Outlet, createFileRoute, useNavigate } from "@tanstack/react-rout
|
||||
import {
|
||||
ArrowRight,
|
||||
BarChart3,
|
||||
Bell,
|
||||
BookOpen,
|
||||
Copy,
|
||||
CreditCard,
|
||||
@@ -163,7 +162,7 @@ function DashboardLayout() {
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex min-h-screen">
|
||||
<div className="flex min-h-[100dvh]">
|
||||
<div className="hidden w-64 border-r border-border bg-background p-4 lg:block">
|
||||
<Skeleton className="mb-8 h-8 w-32" />
|
||||
{KEYS_5.map((__k) => (
|
||||
@@ -261,7 +260,7 @@ function DashboardLayout() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen bg-background">
|
||||
<div className="flex min-h-[100dvh] bg-background">
|
||||
{/* ─── Desktop Sidebar ──────────────────────────────────────────── */}
|
||||
<aside
|
||||
className={`hidden flex-shrink-0 border-r border-border transition-[width] duration-200 lg:flex lg:flex-col ${collapsed ? "w-[68px]" : "w-64"}`}
|
||||
@@ -332,14 +331,9 @@ function DashboardLayout() {
|
||||
<Menu className="size-5" />
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="hidden size-10 items-center justify-center rounded-full bg-muted text-sm font-semibold sm:flex">
|
||||
{initials}
|
||||
</div>
|
||||
<div className="hidden sm:block">
|
||||
<p className="text-sm font-semibold">{user.name}</p>
|
||||
<p className="text-xs text-muted-foreground">Sase.tr'ye hoş geldiniz 👋</p>
|
||||
</div>
|
||||
<div className="hidden sm:block">
|
||||
<p className="text-sm font-semibold">{user.name}</p>
|
||||
<p className="text-xs text-muted-foreground">Sase.tr'ye hoş geldiniz</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -363,12 +357,6 @@ function DashboardLayout() {
|
||||
>
|
||||
{isDark ? <Sun className="size-4" /> : <Moon className="size-4" />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-9 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<Bell className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSignOut}
|
||||
@@ -390,7 +378,14 @@ function DashboardLayout() {
|
||||
{/* Footer */}
|
||||
<div className="border-t border-border px-6 py-3">
|
||||
<p className="text-center text-xs text-muted-foreground/60">
|
||||
© {new Date().getFullYear()} Sase.tr | Gizlilik Politikası, Kullanım Koşulları
|
||||
© {new Date().getFullYear()} Sase.tr ·{" "}
|
||||
<Link to="/privacy" className="transition-colors hover:text-foreground">
|
||||
Gizlilik Politikası
|
||||
</Link>{" "}
|
||||
·{" "}
|
||||
<Link to="/terms" className="transition-colors hover:text-foreground">
|
||||
Kullanım Koşulları
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { CategoryViewToggle } from "@/components/categories/category-view-toggle
|
||||
import { useCategoryParts } from "@/hooks/use-parts";
|
||||
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";
|
||||
@@ -78,7 +79,8 @@ function VehicleCategoryPage() {
|
||||
// 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}`),
|
||||
queryFn: () =>
|
||||
api.get<{ brandName?: string; model?: string; year?: number }>(`/vehicles/${id}`),
|
||||
enabled: !!id,
|
||||
});
|
||||
|
||||
@@ -122,8 +124,9 @@ function VehicleCategoryPage() {
|
||||
navigate({ to: "/dashboard/vehicles/$id", params: { id } });
|
||||
};
|
||||
|
||||
const cleanModel = cleanModelName(vehicle?.model);
|
||||
const vehicleLabel = vehicle?.brandName
|
||||
? `${vehicle.brandName}${vehicle.model ? ` ${vehicle.model}` : ""}`
|
||||
? `${vehicle.brandName}${cleanModel ? ` ${cleanModel}` : ""}`
|
||||
: "Araç";
|
||||
|
||||
// Breadcrumb segments excluding the current page (last item rendered as text below)
|
||||
@@ -181,13 +184,12 @@ function VehicleCategoryPage() {
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">
|
||||
{data?.name ?? (
|
||||
isLoading ? (
|
||||
{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>
|
||||
@@ -206,9 +208,7 @@ function VehicleCategoryPage() {
|
||||
<div>
|
||||
<p className="font-medium text-destructive">Kategori yüklenemedi</p>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
{error instanceof Error
|
||||
? error.message
|
||||
: "Veriler yüklenirken bir hata oluştu."}
|
||||
{error instanceof Error ? error.message : "Veriler yüklenirken bir hata oluştu."}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
@@ -238,19 +238,44 @@ function VehicleCategoryPage() {
|
||||
<CategoryColumns categories={data.children} vehicleId={id} />
|
||||
))}
|
||||
|
||||
{/* Leaf category — show schema viewer */}
|
||||
{data && !hasChildren && (
|
||||
<Suspense fallback={<SchemaViewerFallback />}>
|
||||
<SchemaViewer
|
||||
schemaPic={data.schemaPics?.[0] ?? null}
|
||||
hotspots={data.hotspots ?? []}
|
||||
parts={data.parts ?? []}
|
||||
isLoading={isLoading}
|
||||
vehicleId={id}
|
||||
categoryId={categoryId}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
{/* Leaf category — schema viewer, or a retry prompt when the source
|
||||
catalog fetch failed (vs a genuinely empty category). */}
|
||||
{data &&
|
||||
!hasChildren &&
|
||||
(data.loadError ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="flex flex-col items-start gap-3 rounded-lg border border-destructive/40 bg-destructive/5 p-5 text-sm"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-destructive">Katalog şu an yüklenemedi</p>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
Bu kategori tedarikçi katalogundan alınamadı. Lütfen birazdan tekrar deneyin.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => refetch()}
|
||||
disabled={isFetching}
|
||||
data-faro-user-action-name="category-loaderror-retry"
|
||||
>
|
||||
{isFetching ? "Yükleniyor…" : "Tekrar dene"}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Suspense fallback={<SchemaViewerFallback />}>
|
||||
<SchemaViewer
|
||||
schemaPic={data.schemaPics?.[0] ?? null}
|
||||
hotspots={data.hotspots ?? []}
|
||||
parts={data.parts ?? []}
|
||||
isLoading={isLoading}
|
||||
vehicleId={id}
|
||||
categoryId={categoryId}
|
||||
/>
|
||||
</Suspense>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,17 +3,27 @@ import { CategoryGrid } from "@/components/categories/category-grid";
|
||||
import { CategoryTree } from "@/components/categories/category-tree";
|
||||
import { CategoryViewToggle } from "@/components/categories/category-view-toggle";
|
||||
import { CarBrandLogo } from "@/components/ui/car-brand-logo";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { ApiError, api } from "@/lib/api-client";
|
||||
import { getUserSettings, setUserSetting } from "@/lib/user-settings";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@sase/ui";
|
||||
import { cleanModelName } from "@/lib/vehicle";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@sase/ui";
|
||||
import { Skeleton } from "@sase/ui";
|
||||
import { Button } from "@sase/ui";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { createFileRoute, useCanGoBack, useNavigate, useRouter } from "@tanstack/react-router";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { KEYS_6, KEYS_8 } from "@/lib/keys";
|
||||
import { KEYS_8 } from "@/lib/keys";
|
||||
export const Route = createFileRoute("/dashboard/vehicles_/$id/")({
|
||||
component: VehicleDetailPage,
|
||||
});
|
||||
@@ -29,10 +39,31 @@ function VehicleDetailPage() {
|
||||
setUserSetting("categoryViewMode", mode);
|
||||
};
|
||||
|
||||
const { data: vehicle, isLoading: vehicleLoading } = useQuery({
|
||||
const router = useRouter();
|
||||
const canGoBack = useCanGoBack();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Go back within the app; if the page was opened from a deep link (no app
|
||||
// history), fall back to search instead of leaving the site.
|
||||
const handleBack = () => {
|
||||
if (canGoBack) router.history.back();
|
||||
else navigate({ to: "/dashboard/search" });
|
||||
};
|
||||
|
||||
const {
|
||||
data: vehicle,
|
||||
isLoading: vehicleLoading,
|
||||
isError: vehicleError,
|
||||
error: vehicleErrorObj,
|
||||
refetch: refetchVehicle,
|
||||
isFetching: vehicleFetching,
|
||||
} = useQuery({
|
||||
queryKey: ["vehicle", id],
|
||||
queryFn: () => api.get<any>(`/vehicles/${id}`),
|
||||
enabled: !!id,
|
||||
// A missing vehicle (404) is final — don't burn retries before showing the
|
||||
// not-found state. Transient errors still retry.
|
||||
retry: (count, err) => !(err instanceof ApiError && err.status === 404) && count < 2,
|
||||
});
|
||||
|
||||
const { data: categoryTree, isLoading: categoriesLoading } = useQuery({
|
||||
@@ -53,17 +84,10 @@ function VehicleDetailPage() {
|
||||
<Skeleton className="h-4 w-40" />
|
||||
</div>
|
||||
</div>
|
||||
{/* Vehicle info card skeleton: 4-cell attribute grid */}
|
||||
<div className="rounded-lg border border-border p-6">
|
||||
<Skeleton className="mb-4 h-5 w-32" />
|
||||
<div className="grid grid-cols-2 gap-x-6 gap-y-4 sm:grid-cols-3">
|
||||
{KEYS_6.map((__k) => (
|
||||
<div key={__k} className="flex flex-col gap-1.5">
|
||||
<Skeleton className="h-3 w-20" />
|
||||
<Skeleton className="h-4 w-28" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* Vehicle info card skeleton: collapsed accordion header */}
|
||||
<div className="rounded-xl border border-border px-6 py-4">
|
||||
<Skeleton className="mb-2 h-5 w-32" />
|
||||
<Skeleton className="h-3 w-64" />
|
||||
</div>
|
||||
{/* Categories skeleton: 8 rows */}
|
||||
<div className="rounded-lg border border-border p-6">
|
||||
@@ -78,6 +102,60 @@ function VehicleDetailPage() {
|
||||
);
|
||||
}
|
||||
|
||||
if (vehicleError) {
|
||||
const is404 = vehicleErrorObj instanceof ApiError && vehicleErrorObj.status === 404;
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleBack}
|
||||
title="Geri dön"
|
||||
data-faro-user-action-name="vehicle-back"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<h2 className="text-xl font-bold">{is404 ? "Araç bulunamadı" : "Araç yüklenemedi"}</h2>
|
||||
</div>
|
||||
<div
|
||||
role="alert"
|
||||
className="flex flex-col items-start gap-3 rounded-lg border border-destructive/40 bg-destructive/5 p-5 text-sm"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-destructive">
|
||||
{is404 ? "Bu araç kaydı bulunamadı" : "Araç bilgileri yüklenemedi"}
|
||||
</p>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
{is404
|
||||
? "Bağlantı geçersiz olabilir ya da kayıt kaldırılmış olabilir."
|
||||
: vehicleErrorObj instanceof Error
|
||||
? vehicleErrorObj.message
|
||||
: "Veriler yüklenirken bir hata oluştu."}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{!is404 && (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => refetchVehicle()}
|
||||
disabled={vehicleFetching}
|
||||
data-faro-user-action-name="vehicle-retry"
|
||||
>
|
||||
{vehicleFetching ? "Yükleniyor…" : "Tekrar dene"}
|
||||
</Button>
|
||||
)}
|
||||
<Button type="button" size="sm" variant="ghost" onClick={handleBack}>
|
||||
Geri dön
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
@@ -85,7 +163,7 @@ function VehicleDetailPage() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => window.history.back()}
|
||||
onClick={handleBack}
|
||||
title="Geri dön"
|
||||
data-faro-user-action-name="vehicle-back"
|
||||
>
|
||||
@@ -97,21 +175,31 @@ function VehicleDetailPage() {
|
||||
)}
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold">
|
||||
{vehicle?.brandName} {vehicle?.model} {vehicle?.year && `(${vehicle.year})`}
|
||||
{vehicle?.brandName} {cleanModelName(vehicle?.model)}{" "}
|
||||
{vehicle?.year && `(${vehicle.year})`}
|
||||
</h2>
|
||||
<p className="font-mono text-sm text-muted-foreground">{vehicle?.vin}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Vehicle Info */}
|
||||
{/* Vehicle Info — collapsed by default; the trigger shows an at-a-glance
|
||||
summary, expanding reveals the full attribute + equipment detail. */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Araç Bilgileri</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<VehicleAttributes vehicle={vehicle} />
|
||||
</CardContent>
|
||||
<Accordion type="single" collapsible>
|
||||
<AccordionItem value="info" className="border-b-0">
|
||||
<AccordionTrigger className="px-6 hover:no-underline">
|
||||
<div className="flex flex-1 flex-col items-start gap-1.5 pr-3 text-left">
|
||||
<span className="text-base font-semibold">Araç Bilgileri</span>
|
||||
<VehicleSummary vehicle={vehicle} />
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-6 pb-6">
|
||||
<VehicleAttributes vehicle={vehicle} />
|
||||
<VehicleEquipment vehicle={vehicle} />
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</Card>
|
||||
|
||||
{/* Categories */}
|
||||
@@ -142,7 +230,20 @@ function VehicleDetailPage() {
|
||||
);
|
||||
}
|
||||
|
||||
/** Extract vinfoBasic records from rawData, filtering out VIN. */
|
||||
/** Known PL24 raw labels (missing Turkish chars) → clean Turkish. Unknown
|
||||
* labels pass through unchanged. */
|
||||
const LABEL_OVERRIDES: Record<string, string> = {
|
||||
"Satis tipi": "Satış tipi",
|
||||
Donanim: "Donanım",
|
||||
"Aks tahrigi tanimi": "Aks tahriki tanımı",
|
||||
"Dis rengi / Boya numarasi": "Dış renk / boya kodu",
|
||||
"Model yili": "Model yılı",
|
||||
};
|
||||
|
||||
/** Labels already shown in the page header — dropped to avoid duplication. */
|
||||
const HEADER_LABELS = new Set(["model", "model yili", "model yılı"]);
|
||||
|
||||
/** Extract vinfoBasic records from rawData, dropping VIN and header dupes. */
|
||||
function getVehicleAttributes(vehicle: any): Array<{ label: string; value: string }> {
|
||||
const records = vehicle?.rawData?.raw?.segments?.vinfoBasic?.records;
|
||||
if (!Array.isArray(records)) return [];
|
||||
@@ -156,41 +257,259 @@ function getVehicleAttributes(vehicle: any): Array<{ label: string; value: strin
|
||||
}))
|
||||
.filter(
|
||||
(attr: { label: string; value: string }) =>
|
||||
attr.label && attr.value && attr.value.toLowerCase() !== vinLower,
|
||||
);
|
||||
attr.label &&
|
||||
attr.value &&
|
||||
attr.value.toLowerCase() !== vinLower &&
|
||||
!HEADER_LABELS.has(attr.label.toLowerCase()),
|
||||
)
|
||||
.map((attr: { label: string; value: string }) => ({
|
||||
label: LABEL_OVERRIDES[attr.label] ?? attr.label,
|
||||
value: attr.value,
|
||||
}));
|
||||
}
|
||||
|
||||
function VehicleAttributes({ vehicle }: { vehicle: any }) {
|
||||
const richAttrs = getVehicleAttributes(vehicle);
|
||||
const fallbackAttrs = [
|
||||
type Attr = { label: string; value: string };
|
||||
|
||||
const isAttr = (a: { label: string; value: unknown }): a is Attr =>
|
||||
a.value != null && String(a.value).trim() !== "";
|
||||
|
||||
/** Labels already conveyed by the page header (brand/model/year) or the
|
||||
* collapsed summary — dropped from the detail grid to avoid repetition. */
|
||||
const HEADER_DUPE = new Set([
|
||||
"marka",
|
||||
"model",
|
||||
"yıl",
|
||||
"yil",
|
||||
"year",
|
||||
"model yılı",
|
||||
"model yili",
|
||||
"car name",
|
||||
]);
|
||||
|
||||
const dropHeaderDupes = (attrs: Attr[]): Attr[] =>
|
||||
attrs.filter((a) => !HEADER_DUPE.has(a.label.toLowerCase()));
|
||||
|
||||
/** parts-catalogs (pcat) ships a structured parameter list with English names. */
|
||||
const PCAT_LABELS: Record<string, string> = {
|
||||
Year: "Yıl",
|
||||
Region: "Bölge",
|
||||
Steering: "Direksiyon",
|
||||
"Transmission type": "Şanzıman tipi",
|
||||
"Transmission code": "Şanzıman kodu",
|
||||
Engine: "Motor kodu",
|
||||
"Engine index": "Motor indeksi",
|
||||
Type: "Tip",
|
||||
Modification: "Tip",
|
||||
Configuration: "Donanım",
|
||||
"Car name": "Model",
|
||||
};
|
||||
|
||||
function fromPcat(vehicle: any): Attr[] {
|
||||
const params = vehicle?.rawData?.pcatCar?.parameters;
|
||||
if (!Array.isArray(params)) return [];
|
||||
return [...params]
|
||||
.sort((a: any, b: any) => (a?.sortOrder ?? 999) - (b?.sortOrder ?? 999))
|
||||
.map((p: any) => ({
|
||||
label: PCAT_LABELS[p?.name] ?? String(p?.name ?? "").trim(),
|
||||
value: String(p?.value ?? "").trim(),
|
||||
}))
|
||||
.filter((a: Attr) => Boolean(a.label && a.value));
|
||||
}
|
||||
|
||||
/** emex parsedOptions — bare codes; map the snake_case keys to Turkish. */
|
||||
const EMEX_LABELS: Record<string, string> = {
|
||||
engine_type: "Motor tipi",
|
||||
gearbox_type: "Şanzıman tipi",
|
||||
vehicle_type: "Araç tipi",
|
||||
};
|
||||
|
||||
function fromEmex(vehicle: any): Attr[] {
|
||||
const opts = vehicle?.rawData?.parsedOptions;
|
||||
if (!opts || typeof opts !== "object") return [];
|
||||
return Object.entries(EMEX_LABELS)
|
||||
.map(([key, label]) => ({ label, value: String(opts[key] ?? "").trim() }))
|
||||
.filter((a) => Boolean(a.value));
|
||||
}
|
||||
|
||||
/** vin-api ships flat, human-readable fields. */
|
||||
const VINAPI_FIELDS: Array<[string, string]> = [
|
||||
["bodyClass", "Kasa"],
|
||||
["engineModel", "Motor"],
|
||||
["transmissionStyle", "Vites"],
|
||||
["plantCountry", "Üretim ülkesi"],
|
||||
];
|
||||
|
||||
function fromVinApi(vehicle: any): Attr[] {
|
||||
const rd = vehicle?.rawData;
|
||||
if (!rd) return [];
|
||||
return VINAPI_FIELDS.map(([key, label]) => ({
|
||||
label,
|
||||
value: String(rd[key] ?? "").trim(),
|
||||
})).filter((a) => Boolean(a.value));
|
||||
}
|
||||
|
||||
/** Plain DB columns — last resort when no source payload is recognised. */
|
||||
function getFallbackAttributes(vehicle: any): Attr[] {
|
||||
return [
|
||||
{ label: "Marka", value: vehicle?.brandName },
|
||||
{ label: "Model", value: vehicle?.model },
|
||||
{ label: "Yıl", value: vehicle?.year },
|
||||
{ label: "Motor", value: vehicle?.engine },
|
||||
{ label: "Vites", value: vehicle?.transmission },
|
||||
{ label: "Kasa", value: vehicle?.bodyType },
|
||||
].filter((a): a is { label: string; value: string } => Boolean(a.value));
|
||||
].filter(isAttr);
|
||||
}
|
||||
|
||||
// Prefer the rich VIN-decoded attributes when available; otherwise show DB
|
||||
// fallback fields. Single layout for both — keeps semantics consistent.
|
||||
const attrs = richAttrs.length > 0 ? richAttrs : fallbackAttrs;
|
||||
/** Resolve the attribute list for the vehicle's decode source. Each catalog
|
||||
* ships its detail in a different shape; surface the richest one available. */
|
||||
function getDisplayAttributes(vehicle: any): Attr[] {
|
||||
// PL24 — readable extras lead, then the decoded VIN codes.
|
||||
const vinfo = getVehicleAttributes(vehicle);
|
||||
if (vinfo.length > 0) {
|
||||
const extras = [
|
||||
{ label: "Motor", value: vehicle?.engine },
|
||||
{ label: "Çekiş", value: vehicle?.rawData?.driveType },
|
||||
{ label: "Kasa", value: vehicle?.bodyType },
|
||||
].filter(isAttr);
|
||||
return [...extras, ...vinfo];
|
||||
}
|
||||
|
||||
// parts-catalogs — structured parameter list.
|
||||
if (vehicle?.source === "parts-catalogs") {
|
||||
const a = dropHeaderDupes(fromPcat(vehicle));
|
||||
if (a.length > 0) return a;
|
||||
}
|
||||
|
||||
// emex — lead with any readable DB columns, then the option codes.
|
||||
if (vehicle?.source === "emex") {
|
||||
const dbExtras = [
|
||||
{ label: "Vites", value: vehicle?.transmission },
|
||||
{ label: "Kasa", value: vehicle?.bodyType },
|
||||
].filter(isAttr);
|
||||
const a = dropHeaderDupes([...dbExtras, ...fromEmex(vehicle)]);
|
||||
if (a.length > 0) return a;
|
||||
}
|
||||
|
||||
// vin-api — flat readable fields.
|
||||
if (vehicle?.source === "vin-api") {
|
||||
const a = dropHeaderDupes(fromVinApi(vehicle));
|
||||
if (a.length > 0) return a;
|
||||
}
|
||||
|
||||
return dropHeaderDupes(getFallbackAttributes(vehicle));
|
||||
}
|
||||
|
||||
function VehicleAttributes({ vehicle }: { vehicle: any }) {
|
||||
const attrs = getDisplayAttributes(vehicle);
|
||||
|
||||
if (attrs.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Bu araç için ayrıntı bilgisi bulunamadı.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">Bu araç için ayrıntı bilgisi bulunamadı.</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<dl className="grid grid-cols-1 gap-x-6 gap-y-3 text-sm sm:grid-cols-2 md:grid-cols-3">
|
||||
{attrs.map((attr) => (
|
||||
<div key={attr.label} className="flex flex-col">
|
||||
<dt className="text-xs uppercase tracking-wide text-muted-foreground">{attr.label}</dt>
|
||||
<dd className="font-medium tabular-nums">{attr.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
<dl className="grid grid-cols-2 gap-x-6 gap-y-3 text-sm md:grid-cols-3">
|
||||
{attrs.map((attr, i) => {
|
||||
// Long values (e.g. the engine description) get the full row width so
|
||||
// they stay readable instead of cramping a single grid cell.
|
||||
const wide = attr.value.length > 40;
|
||||
return (
|
||||
<div
|
||||
key={`${attr.label}-${i}`}
|
||||
className={`flex flex-col ${wide ? "col-span-2 md:col-span-3" : ""}`}
|
||||
>
|
||||
<dt className="text-xs text-muted-foreground">{attr.label}</dt>
|
||||
<dd className="font-medium tabular-nums">{attr.value}</dd>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
|
||||
/** The engine code lives in a different spot per decode source. */
|
||||
function getEngineCode(vehicle: any): string | undefined {
|
||||
const rd = vehicle?.rawData;
|
||||
if (rd?.engineCode) return rd.engineCode; // pl24
|
||||
if (rd?.parsedOptions?.engine_type) return rd.parsedOptions.engine_type; // emex
|
||||
if (rd?.engineModel) return rd.engineModel; // vin-api
|
||||
// pcat — the "engine" parameter.
|
||||
const param = rd?.pcatCar?.parameters?.find(
|
||||
(p: any) => p?.key === "engine" || p?.name === "Engine",
|
||||
);
|
||||
return param?.value ? String(param.value) : undefined;
|
||||
}
|
||||
|
||||
/** Compact at-a-glance fields shown on the collapsed Araç Bilgileri header. */
|
||||
function VehicleSummary({ vehicle }: { vehicle: any }) {
|
||||
const fields = [
|
||||
{ label: "Model", value: cleanModelName(vehicle?.model) },
|
||||
{ label: "Model yılı", value: vehicle?.year },
|
||||
{ label: "Motor kodu", value: getEngineCode(vehicle) },
|
||||
].filter((f) => f.value != null && f.value !== "");
|
||||
|
||||
if (fields.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs font-normal">
|
||||
{fields.map((f) => (
|
||||
<span key={f.label} className="text-muted-foreground">
|
||||
{f.label}: <span className="font-medium tabular-nums text-foreground">{f.value}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Strip PL24 raw escapes — \r\n line breaks and literal \- backslashes — and
|
||||
* collapse whitespace. */
|
||||
function cleanText(s: string): string {
|
||||
return (s || "").replace(/\r?\n/g, " ").replace(/\\/g, "").replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
interface Equipment {
|
||||
code: string;
|
||||
group: string;
|
||||
desc: string;
|
||||
}
|
||||
|
||||
/** Decode the prNr (PR-code / equipment) segment — PL24 already ships a
|
||||
* readable description per code, so this is the equipment "dictionary". */
|
||||
function getEquipment(vehicle: any): Equipment[] {
|
||||
const records = vehicle?.rawData?.raw?.segments?.prNr?.records;
|
||||
if (!Array.isArray(records)) return [];
|
||||
return records
|
||||
.map((r: any) => ({
|
||||
code: (r.values?.col2 || "").trim(),
|
||||
group: cleanText(r.values?.col1 || ""),
|
||||
desc: cleanText(r.values?.col3 || ""),
|
||||
}))
|
||||
.filter((e: Equipment) => e.code || e.desc);
|
||||
}
|
||||
|
||||
/** Full decoded equipment / PR-code list, shown inside the expanded card. */
|
||||
function VehicleEquipment({ vehicle }: { vehicle: any }) {
|
||||
const items = getEquipment(vehicle);
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-6 border-t border-border pt-4">
|
||||
<p className="mb-3 text-xs font-semibold text-muted-foreground">
|
||||
Donanım kodları ({items.length})
|
||||
</p>
|
||||
<ul className="grid max-h-80 grid-cols-1 gap-x-6 gap-y-2 overflow-y-auto pr-1 sm:grid-cols-2">
|
||||
{items.map((it, i) => (
|
||||
<li key={`${it.code}-${i}`} className="flex items-baseline gap-2">
|
||||
{it.code && (
|
||||
<span className="shrink-0 rounded bg-muted px-1.5 py-0.5 font-mono text-[11px] leading-tight text-muted-foreground">
|
||||
{it.code}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-sm leading-snug">{it.desc || it.group}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user