dev #46

Merged
root merged 13 commits from dev into main 2026-05-25 04:49:05 +03:00
8 changed files with 582 additions and 111 deletions

View File

@@ -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,
};
}

View File

@@ -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 && (

View File

@@ -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) {

View 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("");
});
});

View 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(" ");
}

View File

@@ -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">
&copy; {new Date().getFullYear()} Sase.tr | Gizlilik Politikası, Kullanım Koşulları
&copy; {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>

View File

@@ -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>
);
}

View File

@@ -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>
);
}