Merge pull request 'dev' (#52) from dev into main
Reviewed-on: #52
This commit was merged in pull request #52.
This commit is contained in:
@@ -182,6 +182,8 @@ describe("CategoriesService", () => {
|
||||
c.limit = vi.fn().mockReturnValue([category]);
|
||||
return c;
|
||||
}),
|
||||
// getAncestors runs a raw recursive CTE via db.execute.
|
||||
execute: vi.fn().mockResolvedValue([{ id: "root", name: "Kök", depth: 1 }]),
|
||||
};
|
||||
|
||||
const { service } = createService(db);
|
||||
@@ -196,6 +198,33 @@ describe("CategoriesService", () => {
|
||||
expect(partsCatalogsService.fetchParts).not.toHaveBeenCalled();
|
||||
expect(result.parts).toEqual([]);
|
||||
expect(result.children).toEqual(discovered);
|
||||
// The wrapper attaches the server-resolved ancestor trail (id+name only).
|
||||
expect(result.ancestors).toEqual([{ id: "root", name: "Kök" }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAncestors", () => {
|
||||
it("maps recursive-CTE rows to {id,name}, dropping the depth column", async () => {
|
||||
const rows = [
|
||||
{ id: "root", name: "Motor", depth: 2 },
|
||||
{ id: "mid", name: "Silindir kapağı", depth: 1 },
|
||||
];
|
||||
const db = { execute: vi.fn().mockResolvedValue(rows) };
|
||||
const { service } = createService(db);
|
||||
|
||||
const result = await service.getAncestors("leaf");
|
||||
expect(result).toEqual([
|
||||
{ id: "root", name: "Motor" },
|
||||
{ id: "mid", name: "Silindir kapağı" },
|
||||
]);
|
||||
expect(db.execute).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("returns an empty trail for a root category", async () => {
|
||||
const db = { execute: vi.fn().mockResolvedValue([]) };
|
||||
const { service } = createService(db);
|
||||
|
||||
expect(await service.getAncestors("root")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -593,7 +593,37 @@ export class CategoriesService {
|
||||
* If the category is a parent (has children), returns children instead.
|
||||
* If it's a leaf with a BOM linkPath, fetches parts from PL24 on-demand.
|
||||
*/
|
||||
/**
|
||||
* Walk the parent_id chain to the root, returning the ancestor trail in
|
||||
* root→parent order (the node itself excluded). Single recursive query;
|
||||
* parent_id is indexed and the tree is shallow. This is the authoritative
|
||||
* source for breadcrumbs — independent of any client-side tree cache, so it
|
||||
* stays correct on refresh, deep links, and lazily-fetched deep nodes.
|
||||
*/
|
||||
async getAncestors(categoryId: string): Promise<Array<{ id: string; name: string }>> {
|
||||
const rows = await this.db.execute<{ id: string; name: string; depth: number }>(sql`
|
||||
WITH RECURSIVE ancestry AS (
|
||||
SELECT id, name, parent_id, 0 AS depth
|
||||
FROM categories WHERE id = ${categoryId}
|
||||
UNION ALL
|
||||
SELECT c.id, c.name, c.parent_id, a.depth + 1
|
||||
FROM categories c
|
||||
JOIN ancestry a ON c.id = a.parent_id
|
||||
)
|
||||
SELECT id, name, depth FROM ancestry WHERE depth > 0 ORDER BY depth DESC
|
||||
`);
|
||||
return rows.map((r) => ({ id: r.id, name: r.name }));
|
||||
}
|
||||
|
||||
async getCategoryWithParts(categoryId: string) {
|
||||
const result = await this.getCategoryWithPartsInner(categoryId);
|
||||
// Attach the full ancestor trail so the client can render a complete,
|
||||
// reliable breadcrumb regardless of what's in its tree cache.
|
||||
const ancestors = await this.getAncestors(categoryId);
|
||||
return { ...result, ancestors };
|
||||
}
|
||||
|
||||
private async getCategoryWithPartsInner(categoryId: string) {
|
||||
const [category] = await this.db
|
||||
.select()
|
||||
.from(categories)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { WMI_BRAND_MAP } from "@sase/shared";
|
||||
|
||||
interface CorgiDecodeResult {
|
||||
brandName: string;
|
||||
@@ -7,105 +8,6 @@ interface CorgiDecodeResult {
|
||||
isKnown: boolean;
|
||||
}
|
||||
|
||||
const WMI_DATABASE: Record<string, string> = {
|
||||
// BMW
|
||||
WBA: "BMW",
|
||||
WBS: "BMW",
|
||||
WBY: "BMW",
|
||||
"5UX": "BMW",
|
||||
// Mercedes-Benz
|
||||
WDB: "Mercedes-Benz",
|
||||
WDC: "Mercedes-Benz",
|
||||
WDD: "Mercedes-Benz",
|
||||
WDF: "Mercedes-Benz",
|
||||
// Audi
|
||||
WAU: "Audi",
|
||||
WUA: "Audi",
|
||||
// Volkswagen
|
||||
WVW: "Volkswagen",
|
||||
WVG: "Volkswagen",
|
||||
"3VW": "Volkswagen",
|
||||
WV1: "Volkswagen",
|
||||
WV2: "Volkswagen",
|
||||
WV3: "Volkswagen",
|
||||
// Toyota
|
||||
JTD: "Toyota",
|
||||
JTE: "Toyota",
|
||||
JTN: "Toyota",
|
||||
"2T1": "Toyota",
|
||||
"4T1": "Toyota",
|
||||
// Fiat
|
||||
ZFA: "Fiat",
|
||||
ZFC: "Fiat",
|
||||
// Renault
|
||||
VF1: "Renault",
|
||||
VF2: "Renault",
|
||||
// Peugeot
|
||||
VF3: "Peugeot",
|
||||
VR3: "Peugeot", // newer Stellantis-era WMI
|
||||
VR7: "Peugeot", // newer Stellantis-era WMI
|
||||
// Citroen
|
||||
VF7: "Citroen",
|
||||
VR1: "Citroen", // newer Citroën/DS WMI
|
||||
// Honda
|
||||
JHM: "Honda",
|
||||
SHH: "Honda",
|
||||
"1HG": "Honda",
|
||||
// Hyundai
|
||||
KMH: "Hyundai",
|
||||
"5NP": "Hyundai",
|
||||
// Kia
|
||||
KNA: "Kia",
|
||||
KND: "Kia",
|
||||
// Ford
|
||||
WF0: "Ford",
|
||||
NM0: "Ford",
|
||||
"1FA": "Ford",
|
||||
"3FA": "Ford",
|
||||
// Opel
|
||||
W0L: "Opel",
|
||||
W0V: "Opel", // newer Stellantis-era WMI
|
||||
// Skoda
|
||||
TMB: "Skoda",
|
||||
// Seat
|
||||
VSS: "Seat",
|
||||
// Volvo
|
||||
YV1: "Volvo",
|
||||
// Nissan
|
||||
JN1: "Nissan",
|
||||
"1N4": "Nissan",
|
||||
"3N1": "Nissan",
|
||||
// Mazda
|
||||
JMZ: "Mazda",
|
||||
JM1: "Mazda",
|
||||
JM3: "Mazda",
|
||||
// Porsche
|
||||
WP0: "Porsche",
|
||||
WP1: "Porsche",
|
||||
// Land Rover
|
||||
SAL: "Land Rover",
|
||||
// Jaguar
|
||||
SAJ: "Jaguar",
|
||||
// Mini
|
||||
WMW: "Mini",
|
||||
// Dacia
|
||||
UU1: "Dacia",
|
||||
// Subaru
|
||||
JF1: "Subaru",
|
||||
JF2: "Subaru",
|
||||
// Suzuki
|
||||
JS2: "Suzuki",
|
||||
JS3: "Suzuki",
|
||||
TSM: "Suzuki",
|
||||
MA3: "Suzuki",
|
||||
MBH: "Suzuki",
|
||||
// Mitsubishi
|
||||
JMB: "Mitsubishi",
|
||||
JMY: "Mitsubishi",
|
||||
MMB: "Mitsubishi",
|
||||
ML3: "Mitsubishi",
|
||||
};
|
||||
|
||||
const YEAR_MAP: Record<string, number> = {
|
||||
A: 2010,
|
||||
B: 2011,
|
||||
@@ -148,7 +50,7 @@ export class CorgiService {
|
||||
if (upper.length !== 17) return null;
|
||||
|
||||
const wmi = upper.substring(0, 3);
|
||||
const brandName = WMI_DATABASE[wmi];
|
||||
const brandName = WMI_BRAND_MAP[wmi];
|
||||
|
||||
if (!brandName) {
|
||||
this.logger.warn(`Unknown WMI: ${wmi}`);
|
||||
@@ -169,6 +71,6 @@ export class CorgiService {
|
||||
}
|
||||
|
||||
getBrandFromWmi(wmi: string): string | null {
|
||||
return WMI_DATABASE[wmi.toUpperCase()] ?? null;
|
||||
return WMI_BRAND_MAP[wmi.toUpperCase()] ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
// Render TanStack Link as a plain anchor; `to` becomes the href so we can assert
|
||||
// the route target. `params` is stripped to avoid invalid-DOM-attr noise.
|
||||
vi.mock("@tanstack/react-router", () => ({
|
||||
Link: ({ children, to, params: _params, ...props }: any) => (
|
||||
<a href={to} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}));
|
||||
|
||||
import { CategoryBreadcrumb } from "../category-breadcrumb";
|
||||
|
||||
describe("CategoryBreadcrumb", () => {
|
||||
it("renders the full trail: Arama → vehicle → ancestors → current", () => {
|
||||
render(
|
||||
<CategoryBreadcrumb
|
||||
vehicleId="v1"
|
||||
vehicleLabel="BMW 320i"
|
||||
ancestors={[
|
||||
{ id: "a1", name: "Motor" },
|
||||
{ id: "a2", name: "Silindir kapağı" },
|
||||
]}
|
||||
currentName="Conta"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Arama")).toBeInTheDocument();
|
||||
expect(screen.getByText("BMW 320i")).toBeInTheDocument();
|
||||
expect(screen.getByText("Motor")).toBeInTheDocument();
|
||||
expect(screen.getByText("Silindir kapağı")).toBeInTheDocument();
|
||||
|
||||
const current = screen.getByText("Conta");
|
||||
expect(current).toHaveAttribute("aria-current", "page");
|
||||
expect(current.closest("a")).toBeNull(); // current is plain text, not a link
|
||||
});
|
||||
|
||||
it("makes the vehicle the terminal crumb on the vehicle page (no category context)", () => {
|
||||
render(<CategoryBreadcrumb vehicleId="v1" vehicleLabel="BMW 320i" />);
|
||||
|
||||
expect(screen.getByText("Arama")).toBeInTheDocument();
|
||||
const vehicle = screen.getByText("BMW 320i");
|
||||
expect(vehicle).toHaveAttribute("aria-current", "page");
|
||||
expect(vehicle.closest("a")).toBeNull();
|
||||
expect(screen.queryByText("Motor")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("links Arama, the vehicle, and ancestors to their routes", () => {
|
||||
render(
|
||||
<CategoryBreadcrumb
|
||||
vehicleId="v1"
|
||||
vehicleLabel="BMW 320i"
|
||||
ancestors={[{ id: "a1", name: "Motor" }]}
|
||||
currentName="Conta"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Arama").closest("a")).toHaveAttribute("href", "/dashboard/search");
|
||||
// With category context the vehicle is a link, not the current crumb.
|
||||
expect(screen.getByText("BMW 320i").closest("a")).toHaveAttribute(
|
||||
"href",
|
||||
"/dashboard/vehicles/$id",
|
||||
);
|
||||
expect(screen.getByText("Motor").closest("a")).toHaveAttribute(
|
||||
"href",
|
||||
"/dashboard/vehicles/$id/categories/$categoryId",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const navigateMock = vi.fn();
|
||||
const setQueryDataMock = vi.fn();
|
||||
|
||||
vi.mock("@/lib/i18n", () => ({ useTranslation: () => ({ t: (k: string) => k }) }));
|
||||
vi.mock("@/lib/category-icons", () => ({ getCategoryIcon: () => () => null }));
|
||||
vi.mock("@/lib/api-client", () => ({ api: { get: vi.fn().mockResolvedValue([]) } }));
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQueryClient: () => ({ setQueryData: setQueryDataMock }),
|
||||
}));
|
||||
vi.mock("@tanstack/react-router", () => ({ useNavigate: () => navigateMock }));
|
||||
vi.mock("@sase/ui", () => ({
|
||||
Card: ({ children }: any) => <div>{children}</div>,
|
||||
CardContent: ({ children }: any) => <div>{children}</div>,
|
||||
Input: (props: any) => <input {...props} />,
|
||||
}));
|
||||
|
||||
import { CategoryGrid } from "../category-grid";
|
||||
|
||||
describe("CategoryGrid navigation", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("vehicles mode: clicking a parent routes to the category page and seeds the cache", () => {
|
||||
render(
|
||||
<CategoryGrid
|
||||
vehicleId="v1"
|
||||
categories={[
|
||||
{ id: "c1", name: "Motor", parentId: null, children: [{ id: "c2", name: "Silindir" }] },
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText("Motor"));
|
||||
|
||||
expect(navigateMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
to: "/dashboard/vehicles/$id/categories/$categoryId",
|
||||
params: { id: "v1", categoryId: "c1" },
|
||||
}),
|
||||
);
|
||||
// Known children are seeded so the destination renders instantly.
|
||||
expect(setQueryDataMock).toHaveBeenCalledWith(
|
||||
["category-parts", "v1", "c1"],
|
||||
expect.objectContaining({ id: "c1", children: [{ id: "c2", name: "Silindir" }] }),
|
||||
);
|
||||
});
|
||||
|
||||
it("catalog mode: parent click routes to the catalog page, preserving variant + mgp, without seeding", () => {
|
||||
render(
|
||||
<CategoryGrid
|
||||
vehicleId="m1"
|
||||
catalogMode
|
||||
brandName="bmw"
|
||||
categories={[{ id: "c1", name: "Motor", children: [{ id: "c2", name: "Silindir" }] }]}
|
||||
variantSearch={{ body: "sedan", engine: "n20", gearbox: "auto", mgp: "x" } as any}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText("Motor"));
|
||||
|
||||
expect(navigateMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
to: "/dashboard/catalog/$brandName/$modelId/categories/$categoryId",
|
||||
params: { brandName: "bmw", modelId: "m1", categoryId: "c1" },
|
||||
search: { body: "sedan", engine: "n20", gearbox: "auto", mgp: "x" },
|
||||
}),
|
||||
);
|
||||
// Catalog mode uses a different query key/route — no vehicles-mode seeding.
|
||||
expect(setQueryDataMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
88
apps/web/src/components/categories/category-breadcrumb.tsx
Normal file
88
apps/web/src/components/categories/category-breadcrumb.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { Fragment } from "react";
|
||||
|
||||
interface CategoryBreadcrumbProps {
|
||||
vehicleId: string;
|
||||
/** Display label for the vehicle root crumb (e.g. "BMW 320i"). */
|
||||
vehicleLabel: string;
|
||||
/** Ancestor trail in root→parent order (current node excluded). */
|
||||
ancestors?: Array<{ id: string; name: string }>;
|
||||
/**
|
||||
* Name of the current category. When omitted, the vehicle itself is the
|
||||
* terminal crumb — used on the vehicle detail page.
|
||||
*/
|
||||
currentName?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The single breadcrumb for the vehicle drill-down flow. The trail is driven by
|
||||
* the server-provided `ancestors`, so it stays complete and correct regardless
|
||||
* of the client tree cache (refresh, deep link, lazily-fetched deep nodes).
|
||||
*
|
||||
* Chain: Arama → [Vehicle] → …ancestors → current
|
||||
*/
|
||||
export function CategoryBreadcrumb({
|
||||
vehicleId,
|
||||
vehicleLabel,
|
||||
ancestors = [],
|
||||
currentName,
|
||||
}: CategoryBreadcrumbProps) {
|
||||
// The vehicle is the terminal crumb only when we're on the vehicle page
|
||||
// itself (no category context at all).
|
||||
const vehicleIsCurrent = !currentName && ancestors.length === 0;
|
||||
|
||||
return (
|
||||
<nav
|
||||
aria-label="Breadcrumb"
|
||||
className="flex flex-wrap items-center gap-x-1 gap-y-1 text-xs text-muted-foreground"
|
||||
>
|
||||
<Link
|
||||
to="/dashboard/search"
|
||||
data-faro-user-action-name="breadcrumb-search"
|
||||
className="rounded px-1 transition-colors hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
Arama
|
||||
</Link>
|
||||
|
||||
<ChevronRight aria-hidden className="h-3 w-3 shrink-0" />
|
||||
{vehicleIsCurrent ? (
|
||||
<span aria-current="page" className="max-w-[220px] truncate px-1 text-foreground">
|
||||
{vehicleLabel}
|
||||
</span>
|
||||
) : (
|
||||
<Link
|
||||
to="/dashboard/vehicles/$id"
|
||||
params={{ id: vehicleId }}
|
||||
data-faro-user-action-name="breadcrumb-vehicle"
|
||||
className="max-w-[220px] truncate rounded px-1 transition-colors hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
{vehicleLabel}
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{ancestors.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: vehicleId, categoryId: node.id }}
|
||||
data-faro-user-action-name="breadcrumb-category"
|
||||
className="max-w-[180px] truncate rounded px-1 transition-colors hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
{node.name}
|
||||
</Link>
|
||||
</Fragment>
|
||||
))}
|
||||
|
||||
{currentName && (
|
||||
<>
|
||||
<ChevronRight aria-hidden className="h-3 w-3 shrink-0" />
|
||||
<span aria-current="page" className="max-w-[220px] truncate px-1 text-foreground">
|
||||
{currentName}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { Card, CardContent, Input } from "@sase/ui";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { ChevronRight, Loader2, Search, X } from "lucide-react";
|
||||
import { Fragment, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
interface Category {
|
||||
id: string;
|
||||
@@ -18,12 +18,6 @@ interface Category {
|
||||
source?: string;
|
||||
}
|
||||
|
||||
interface StackEntry {
|
||||
id: string | null;
|
||||
name: string;
|
||||
categories: Category[];
|
||||
}
|
||||
|
||||
interface CategoryGridProps {
|
||||
categories: Category[];
|
||||
vehicleId: string;
|
||||
@@ -45,49 +39,33 @@ export function CategoryGrid({
|
||||
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).
|
||||
// `t` is intentionally excluded: useTranslation returns a fresh function each
|
||||
// render, so including it would re-fire the effect on every render and reset
|
||||
// the stack immediately after handleSelect pushes a child level — making
|
||||
// parent-category clicks appear to do nothing.
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: t identity is unstable; key is static
|
||||
useEffect(() => {
|
||||
setStack([{ id: parentId ?? null, name: t("categories.root"), categories }]);
|
||||
}, [categories, parentId]);
|
||||
|
||||
const current = stack[stack.length - 1];
|
||||
const currentCategories = current.categories;
|
||||
|
||||
// Filter the current drill level by name — deep levels (e.g. Elektrik has 62
|
||||
// Filter the current 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
|
||||
// Reset the filter whenever the rendered level changes — route navigation
|
||||
// swaps the `categories` prop, so that is the level identity now.
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: re-run on level change is intentional
|
||||
useEffect(() => {
|
||||
setFilter("");
|
||||
}, [current]);
|
||||
}, [categories]);
|
||||
|
||||
const trimmedFilter = filter.trim().toLocaleLowerCase("tr");
|
||||
const visibleCategories = trimmedFilter
|
||||
? currentCategories.filter((c) => c.name.toLocaleLowerCase("tr").includes(trimmedFilter))
|
||||
: currentCategories;
|
||||
const showFilter = currentCategories.length > 8;
|
||||
? categories.filter((c) => c.name.toLocaleLowerCase("tr").includes(trimmedFilter))
|
||||
: categories;
|
||||
const showFilter = categories.length > 8;
|
||||
|
||||
// Prefetch schema images for leaf categories in batches of 2
|
||||
useEffect(() => {
|
||||
prefetchedRef.current.clear();
|
||||
setImageOverrides(new Map());
|
||||
|
||||
const leafsWithoutImage = currentCategories.filter(
|
||||
const leafsWithoutImage = categories.filter(
|
||||
(c) =>
|
||||
c.children !== undefined &&
|
||||
c.children.length === 0 &&
|
||||
@@ -100,7 +78,7 @@ export function CategoryGrid({
|
||||
return;
|
||||
}
|
||||
|
||||
const firstParentId = currentCategories[0]?.parentId;
|
||||
const firstParentId = categories[0]?.parentId;
|
||||
let didCancel = false;
|
||||
|
||||
const BATCH_SIZE = 2;
|
||||
@@ -137,53 +115,38 @@ export function CategoryGrid({
|
||||
return () => {
|
||||
didCancel = true;
|
||||
};
|
||||
}, [currentCategories, vehicleId]);
|
||||
}, [categories, vehicleId]);
|
||||
|
||||
const navigateToLeaf = useCallback(
|
||||
(categoryId: string) => {
|
||||
// Every drill — parent or leaf — is a route navigation, so each level has its
|
||||
// own URL and history entry: browser-back goes up exactly one level, and deep
|
||||
// levels are shareable/refreshable. For known parents we seed the destination
|
||||
// query cache so children render instantly while the full payload (parts,
|
||||
// ancestors) refetches in the background.
|
||||
const navigateToCategory = useCallback(
|
||||
(category: Category) => {
|
||||
if (!catalogMode && category.children && category.children.length > 0) {
|
||||
queryClient.setQueryData(["category-parts", vehicleId, category.id], {
|
||||
id: category.id,
|
||||
name: category.name,
|
||||
description: null,
|
||||
parentId: category.parentId ?? parentId ?? null,
|
||||
parts: [],
|
||||
schemaPics: [],
|
||||
hotspots: [],
|
||||
children: category.children,
|
||||
});
|
||||
}
|
||||
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 },
|
||||
? { brandName: brandName ?? vehicleId, modelId: vehicleId, categoryId: category.id }
|
||||
: { id: vehicleId, categoryId: category.id },
|
||||
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],
|
||||
[navigate, queryClient, catalogMode, brandName, vehicleId, parentId, variantSearch],
|
||||
);
|
||||
|
||||
if (!categories || categories.length === 0) {
|
||||
@@ -194,23 +157,6 @@ export function CategoryGrid({
|
||||
|
||||
return (
|
||||
<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>
|
||||
)}
|
||||
{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" />
|
||||
@@ -244,14 +190,13 @@ export function CategoryGrid({
|
||||
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}
|
||||
onClick={() => navigateToCategory(category)}
|
||||
disabled={category.unavailable}
|
||||
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
|
||||
@@ -260,7 +205,7 @@ export function CategoryGrid({
|
||||
Icon={Icon}
|
||||
schemaImageUrl={schemaImageUrl}
|
||||
isLeaf={isLeaf}
|
||||
isLoading={prefetchingIds.has(category.id) || isSelectLoading}
|
||||
isLoading={prefetchingIds.has(category.id)}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -189,7 +189,7 @@ function CategoryNode({
|
||||
name={category.name}
|
||||
shimmer={isShimmering}
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate">{category.name}</span>
|
||||
<span className="min-w-0 flex-1 break-words leading-snug">{category.name}</span>
|
||||
{category.partCount != null && category.partCount > 0 && (
|
||||
<span className="shrink-0 tabular-nums text-xs text-muted-foreground">
|
||||
{category.partCount}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { cn } from "@sase/ui";
|
||||
import { Columns2, LayoutGrid, List } from "lucide-react";
|
||||
import { CardsViewIcon, ListViewIcon, TreeViewIcon, type ViewIconProps } from "./view-toggle-icons";
|
||||
|
||||
export type CategoryViewMode = "grid" | "tree" | "columns";
|
||||
|
||||
const OPTIONS: Array<{
|
||||
value: CategoryViewMode;
|
||||
label: string;
|
||||
Icon: React.ComponentType<{ className?: string }>;
|
||||
Icon: React.ComponentType<ViewIconProps>;
|
||||
}> = [
|
||||
{ value: "grid", label: "Izgara görünümü", Icon: LayoutGrid },
|
||||
{ value: "tree", label: "Ağaç görünümü", Icon: List },
|
||||
{ value: "columns", label: "Sütun görünümü", Icon: Columns2 },
|
||||
{ value: "grid", label: "Izgara görünümü", Icon: CardsViewIcon },
|
||||
{ value: "tree", label: "Ağaç görünümü", Icon: TreeViewIcon },
|
||||
{ value: "columns", label: "Sütun görünümü", Icon: ListViewIcon },
|
||||
];
|
||||
|
||||
export function CategoryViewToggle({
|
||||
@@ -50,7 +50,7 @@ export function CategoryViewToggle({
|
||||
: "text-muted-foreground hover:bg-background/60 hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
<Icon isActive={active} />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
112
apps/web/src/components/categories/view-toggle-icons.tsx
Normal file
112
apps/web/src/components/categories/view-toggle-icons.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import { cn } from "@sase/ui";
|
||||
|
||||
/**
|
||||
* Görünüm-toggle için animasyonlu ikonlar (saf CSS, motion bağımlılığı yok).
|
||||
* unlumen cards-view / list-view ikonlarından uyarlanmıştır; tree ikonu bespoke.
|
||||
* Her ikon `isActive` ile sürülür ve dinlenme (rest) görünümü her iki durumda aynıdır;
|
||||
* yalnızca geçiş/animasyon farklıdır.
|
||||
*/
|
||||
export interface ViewIconProps {
|
||||
isActive: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// ── Grid → 2×2 kareler, aktifken yer değiştirir (cards-view-icon) ──
|
||||
// 16px kutuda 4px kareler, 6.5px adımla; kareler komşu hücreye kayar (hepsi aynı görünür).
|
||||
const CARD_POS = [
|
||||
{ left: 2.75, top: 2.75 }, // 0 TL
|
||||
{ left: 9.25, top: 2.75 }, // 1 TR
|
||||
{ left: 2.75, top: 9.25 }, // 2 BL
|
||||
{ left: 9.25, top: 9.25 }, // 3 BR
|
||||
];
|
||||
const CARD_TRAVEL = [
|
||||
{ x: 0, y: 6.5 }, // 0 → BL
|
||||
{ x: -6.5, y: 0 }, // 1 → TL
|
||||
{ x: 6.5, y: 0 }, // 2 → BR
|
||||
{ x: 0, y: -6.5 }, // 3 → TR
|
||||
];
|
||||
|
||||
export function CardsViewIcon({ isActive, className }: ViewIconProps) {
|
||||
return (
|
||||
<span className={cn("relative inline-block h-4 w-4", className)} aria-hidden="true">
|
||||
{CARD_POS.map((p, i) => (
|
||||
<span
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: sabit uzunlukta statik ikon parçaları
|
||||
key={i}
|
||||
className="absolute size-[4px] rounded-[1px] bg-current transition-transform duration-[420ms] ease-out motion-reduce:transition-none"
|
||||
style={{
|
||||
left: p.left,
|
||||
top: p.top,
|
||||
transform: isActive
|
||||
? `translate(${CARD_TRAVEL[i].x}px, ${CARD_TRAVEL[i].y}px)`
|
||||
: "none",
|
||||
transitionDelay: `${i * 67}ms`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ── List (columns butonu) → 3 yatay çizgi, aktifken birer slot kayar (list-view-icon) ──
|
||||
const LINE_TRAVEL = [5, 5, -10];
|
||||
|
||||
export function ListViewIcon({ isActive, className }: ViewIconProps) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex h-4 w-4 flex-col items-center justify-center gap-[3px]",
|
||||
className,
|
||||
)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{LINE_TRAVEL.map((dy, i) => (
|
||||
<span
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: sabit uzunlukta statik ikon parçaları
|
||||
key={i}
|
||||
className="h-[2px] w-3 rounded-full bg-current transition-transform duration-[360ms] ease-out motion-reduce:transition-none"
|
||||
style={{
|
||||
transform: isActive ? `translateY(${dy}px)` : "none",
|
||||
transitionDelay: `${i * 20}ms`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Tree → kök + iki dal; aktifte dallar gövdeden anında "büyüyerek" çıkar ──
|
||||
// Gecikme yok, dallar her zaman görünür: tıklama anında belirgin tepki verir.
|
||||
export function TreeViewIcon({ isActive, className }: ViewIconProps) {
|
||||
return (
|
||||
<span className={cn("relative inline-block h-4 w-4", className)} aria-hidden="true">
|
||||
{/* kök düğüm */}
|
||||
<span
|
||||
className="absolute rounded-[1.5px] bg-current"
|
||||
style={{ left: 2, top: 2.5, width: 4, height: 2.5 }}
|
||||
/>
|
||||
{/* dikey bağlayıcı */}
|
||||
<span
|
||||
className="absolute rounded bg-current/50"
|
||||
style={{ left: 3, top: 5, width: 1.5, height: 7.5 }}
|
||||
/>
|
||||
{/* iki çocuk dal: dirsek + düğüm — origin-left ile gövdeden büyür */}
|
||||
{[0, 1].map((i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={cn(
|
||||
"absolute inline-flex origin-left items-center",
|
||||
isActive && "animate-tree-grow",
|
||||
)}
|
||||
style={{ left: 3, top: i === 0 ? 6.25 : 10.25 }}
|
||||
>
|
||||
<span className="rounded bg-current/50" style={{ width: 3.5, height: 1.5 }} />
|
||||
<span
|
||||
className="ml-[1.5px] rounded-[1.5px] bg-current"
|
||||
style={{ width: 4, height: 2.5 }}
|
||||
/>
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
BMWLogoDark,
|
||||
BentleyLogo,
|
||||
BentleyLogoDark,
|
||||
ChevroletLogo,
|
||||
ChevroletLogoDark,
|
||||
FiatLogo,
|
||||
FiatLogoDark,
|
||||
FordLogo,
|
||||
@@ -87,6 +89,7 @@ const BRAND_MAP: Record<string, BrandEntry> = {
|
||||
porsche: { light: PorscheLogo, dark: PorscheLogoDark },
|
||||
bentley: { light: BentleyLogo, dark: BentleyLogoDark },
|
||||
bmw: { light: BMWLogo, dark: BMWLogoDark },
|
||||
chevrolet: { light: ChevroletLogo, dark: ChevroletLogoDark },
|
||||
mini: { light: MiniLogo, dark: MiniLogoDark },
|
||||
mercedes: { light: MBLogo, dark: MBLogoDark },
|
||||
"mercedes-benz": { light: MBLogo, dark: MBLogoDark },
|
||||
|
||||
38
apps/web/src/components/ui/smart-animate-text.tsx
Normal file
38
apps/web/src/components/ui/smart-animate-text.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { cn } from "@sase/ui";
|
||||
|
||||
interface SmartAnimateTextProps {
|
||||
/** Metin — karakter karakter animasyonla görünür. */
|
||||
text: string;
|
||||
className?: string;
|
||||
/** Her karaktere uygulanan ek sınıf. */
|
||||
charClassName?: string;
|
||||
/** Karakterler arası gecikme (ms). */
|
||||
stagger?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Metni karakter karakter (blur + yukarı kayma + fade) animasyonla yazar.
|
||||
* Animasyonu yeniden tetiklemek için bileşene değişen bir `key` verin.
|
||||
*/
|
||||
export function SmartAnimateText({
|
||||
text,
|
||||
className,
|
||||
charClassName,
|
||||
stagger = 35,
|
||||
}: SmartAnimateTextProps) {
|
||||
return (
|
||||
<span className={cn("inline-flex", className)} aria-label={text}>
|
||||
{Array.from(text).map((char, i) => (
|
||||
<span
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: konum tabanlı animasyon sırası gerekli
|
||||
key={`${char}-${i}`}
|
||||
aria-hidden="true"
|
||||
className={cn("inline-block animate-char-in", charClassName)}
|
||||
style={{ animationDelay: `${i * stagger}ms`, animationFillMode: "backwards" }}
|
||||
>
|
||||
{char === " " ? " " : char}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
51
apps/web/src/components/ui/vin-brand-icon.tsx
Normal file
51
apps/web/src/components/ui/vin-brand-icon.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { CarBrandLogo } from "@/components/ui/car-brand-logo";
|
||||
import { getBrandFromWmi } from "@sase/shared";
|
||||
import { cn } from "@sase/ui";
|
||||
import { Search } from "lucide-react";
|
||||
|
||||
interface VinBrandIconProps {
|
||||
/** Mevcut VIN değeri — ilk 3 karakter (WMI) bilinen bir markaya denk gelirse logo gösterilir. */
|
||||
vin: string;
|
||||
/** İkon kutusunun kenar uzunluğu (px). Logolar bu kutu içinde object-contain ile ölçeklenir. */
|
||||
size?: number;
|
||||
/** Konumlandırma için sarmalayıcıya verilen sınıf (örn. absolute left-4 ...). */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* VIN textbox'ının solundaki ikon. WMI (ilk 3 karakter) bilinen bir markaya
|
||||
* denk gelince büyüteç yerine ilgili marka logosu pop animasyonuyla görünür;
|
||||
* WMI silinince tekrar büyüteç olur.
|
||||
*
|
||||
* Logolar sabit bir kareye `object-contain` ile yerleştirilir; böylece dikey
|
||||
* (Renault) veya yatay (Opel) viewBox'lı SVG'ler de tutarlı boyutta görünür
|
||||
* (Tailwind Preflight'ın img'lere uyguladığı height:auto sorununu da çözer).
|
||||
*/
|
||||
export function VinBrandIcon({ vin, size = 28, className }: VinBrandIconProps) {
|
||||
const wmi = vin.trim().toUpperCase().slice(0, 3);
|
||||
const brand = wmi.length === 3 ? getBrandFromWmi(wmi) : null;
|
||||
|
||||
return (
|
||||
<span
|
||||
style={{ width: size, height: size }}
|
||||
className={cn("pointer-events-none flex items-center justify-center", className)}
|
||||
>
|
||||
{brand ? (
|
||||
<CarBrandLogo
|
||||
// key: marka değiştiğinde yeniden mount edip pop animasyonunu tetikler
|
||||
key={`brand:${brand}`}
|
||||
brandName={brand}
|
||||
size={size}
|
||||
className="size-full animate-icon-pop object-contain"
|
||||
/>
|
||||
) : (
|
||||
// Büyüteç logolardan biraz küçük dursun (optik denge)
|
||||
<Search
|
||||
key="search"
|
||||
style={{ width: size * 0.8, height: size * 0.8 }}
|
||||
className="animate-icon-pop text-muted-foreground"
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -152,6 +152,51 @@
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes char-in {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(0.45em) scale(0.92);
|
||||
filter: blur(5px);
|
||||
}
|
||||
60% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
filter: blur(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes tree-grow {
|
||||
0% {
|
||||
transform: scaleX(0.35);
|
||||
}
|
||||
70% {
|
||||
transform: scaleX(1.05);
|
||||
}
|
||||
100% {
|
||||
transform: scaleX(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes icon-pop {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0.4) rotate(-12deg);
|
||||
filter: blur(2px);
|
||||
}
|
||||
60% {
|
||||
opacity: 1;
|
||||
transform: scale(1.12) rotate(0);
|
||||
filter: blur(0);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1) rotate(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes accordion-down {
|
||||
from {
|
||||
height: 0;
|
||||
@@ -182,6 +227,16 @@
|
||||
.animate-fade-in {
|
||||
animation: fade-in 0.4s ease-out;
|
||||
}
|
||||
.animate-char-in {
|
||||
animation: char-in 0.5s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
.animate-icon-pop {
|
||||
animation: icon-pop 0.32s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
.animate-tree-grow {
|
||||
animation: tree-grow 0.24s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
transform-origin: left center;
|
||||
}
|
||||
.carousel-track:hover .animate-scroll-left {
|
||||
animation-play-state: paused;
|
||||
}
|
||||
|
||||
@@ -54,6 +54,9 @@ export interface CategorySchema {
|
||||
parts: Part[];
|
||||
schemaPics: SchemaPic[];
|
||||
hotspots: Hotspot[];
|
||||
/** Ancestor trail in root→parent order (current node excluded). Authoritative
|
||||
* breadcrumb source from the server — reliable on refresh/deep-link. */
|
||||
ancestors?: Array<{ id: string; name: string }>;
|
||||
/** 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;
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import {
|
||||
CardsViewIcon,
|
||||
ListViewIcon,
|
||||
TreeViewIcon,
|
||||
} from "@/components/categories/view-toggle-icons";
|
||||
import { CarBrandLogo } from "@/components/ui/car-brand-logo";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
@@ -6,7 +11,7 @@ import { Skeleton, cn } from "@sase/ui";
|
||||
import { Button } from "@sase/ui";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link, createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { ChevronRight, Columns2, LayoutGrid, Library, List, Lock } from "lucide-react";
|
||||
import { ChevronRight, Library, Lock } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { KEYS_10 } from "@/lib/keys";
|
||||
@@ -52,9 +57,9 @@ function CatalogBrandsPage() {
|
||||
className="inline-flex items-center gap-0.5 rounded-lg border border-border bg-muted/40 p-0.5"
|
||||
>
|
||||
{[
|
||||
{ mode: "grid" as const, Icon: LayoutGrid, label: "Izgara" },
|
||||
{ mode: "tree" as const, Icon: List, label: "Liste" },
|
||||
{ mode: "columns" as const, Icon: Columns2, label: "Sütun" },
|
||||
{ mode: "grid" as const, Icon: CardsViewIcon, label: "Izgara" },
|
||||
{ mode: "tree" as const, Icon: TreeViewIcon, label: "Liste" },
|
||||
{ mode: "columns" as const, Icon: ListViewIcon, label: "Sütun" },
|
||||
].map(({ mode, Icon, label }) => (
|
||||
<button
|
||||
key={mode}
|
||||
@@ -70,7 +75,7 @@ function CatalogBrandsPage() {
|
||||
)}
|
||||
title={label}
|
||||
>
|
||||
<Icon className="size-3.5" />
|
||||
<Icon isActive={viewMode === mode} className="size-3.5" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { ModelListColumns } from "@/components/catalog/model-list-columns";
|
||||
import { ModelListTree } from "@/components/catalog/model-list-tree";
|
||||
import {
|
||||
CardsViewIcon,
|
||||
ListViewIcon,
|
||||
TreeViewIcon,
|
||||
} from "@/components/categories/view-toggle-icons";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import { KEYS_4, KEYS_9 } from "@/lib/keys";
|
||||
@@ -7,16 +12,7 @@ import { getUserSettings, setUserSetting } from "@/lib/user-settings";
|
||||
import { Button, Skeleton, cn } from "@sase/ui";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link, createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import {
|
||||
ArrowLeft,
|
||||
BookOpen,
|
||||
Car,
|
||||
ChevronRight,
|
||||
Columns2,
|
||||
LayoutGrid,
|
||||
List,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import { ArrowLeft, BookOpen, Car, ChevronRight, Loader2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
export const Route = createFileRoute("/dashboard/catalog_/$brandName/")({
|
||||
@@ -167,7 +163,7 @@ function CatalogModelsPage() {
|
||||
)}
|
||||
title="Izgara"
|
||||
>
|
||||
<LayoutGrid className="size-4" />
|
||||
<CardsViewIcon isActive={viewMode === "grid"} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -178,7 +174,7 @@ function CatalogModelsPage() {
|
||||
)}
|
||||
title="Liste"
|
||||
>
|
||||
<List className="size-4" />
|
||||
<TreeViewIcon isActive={viewMode === "tree"} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -191,7 +187,7 @@ function CatalogModelsPage() {
|
||||
)}
|
||||
title="Sutun"
|
||||
>
|
||||
<Columns2 className="size-4" />
|
||||
<ListViewIcon isActive={viewMode === "columns"} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import { CategoryColumns } from "@/components/categories/category-columns";
|
||||
import { CategoryGrid } from "@/components/categories/category-grid";
|
||||
import { CategoryTree } from "@/components/categories/category-tree";
|
||||
import {
|
||||
CardsViewIcon,
|
||||
ListViewIcon,
|
||||
TreeViewIcon,
|
||||
} from "@/components/categories/view-toggle-icons";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import { getUserSettings, setUserSetting } from "@/lib/user-settings";
|
||||
import { Button, Skeleton, cn } from "@sase/ui";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link, createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { ArrowLeft, Columns2, LayoutGrid, List } from "lucide-react";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { Suspense, lazy, useState } from "react";
|
||||
|
||||
import { KEYS_8 } from "@/lib/keys";
|
||||
@@ -162,7 +167,7 @@ function CatalogCategoryPage() {
|
||||
)}
|
||||
title="Izgara"
|
||||
>
|
||||
<LayoutGrid className="size-4" />
|
||||
<CardsViewIcon isActive={viewMode === "grid"} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -173,7 +178,7 @@ function CatalogCategoryPage() {
|
||||
)}
|
||||
title="Agac"
|
||||
>
|
||||
<List className="size-4" />
|
||||
<TreeViewIcon isActive={viewMode === "tree"} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -186,7 +191,7 @@ function CatalogCategoryPage() {
|
||||
)}
|
||||
title="Sutun"
|
||||
>
|
||||
<Columns2 className="size-4" />
|
||||
<ListViewIcon isActive={viewMode === "columns"} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -4,13 +4,18 @@ import { PsaVariantSelector } from "@/components/catalog/psa-variant-selector";
|
||||
import { CategoryColumns } from "@/components/categories/category-columns";
|
||||
import { CategoryGrid } from "@/components/categories/category-grid";
|
||||
import { CategoryTree } from "@/components/categories/category-tree";
|
||||
import {
|
||||
CardsViewIcon,
|
||||
ListViewIcon,
|
||||
TreeViewIcon,
|
||||
} from "@/components/categories/view-toggle-icons";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import { getUserSettings, setUserSetting } from "@/lib/user-settings";
|
||||
import { Button, Card, CardContent, CardHeader, CardTitle, Skeleton } from "@sase/ui";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link, createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { ArrowLeft, Columns2, LayoutGrid, List } from "lucide-react";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { KEYS_8 } from "@/lib/keys";
|
||||
@@ -203,7 +208,7 @@ function CatalogVehiclePage() {
|
||||
className={`rounded p-1.5 ${viewMode === "grid" ? "bg-accent" : "text-muted-foreground hover:text-foreground"}`}
|
||||
title="Izgara"
|
||||
>
|
||||
<LayoutGrid className="size-4" />
|
||||
<CardsViewIcon isActive={viewMode === "grid"} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -211,7 +216,7 @@ function CatalogVehiclePage() {
|
||||
className={`rounded p-1.5 ${viewMode === "tree" ? "bg-accent" : "text-muted-foreground hover:text-foreground"}`}
|
||||
title="Agac"
|
||||
>
|
||||
<List className="size-4" />
|
||||
<TreeViewIcon isActive={viewMode === "tree"} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -219,7 +224,7 @@ function CatalogVehiclePage() {
|
||||
className={`rounded p-1.5 ${viewMode === "columns" ? "bg-accent" : "text-muted-foreground hover:text-foreground"}`}
|
||||
title="Sutun"
|
||||
>
|
||||
<Columns2 className="size-4" />
|
||||
<ListViewIcon isActive={viewMode === "columns"} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { WelcomeOnboardingModal } from "@/components/onboarding/welcome-onboarding-modal";
|
||||
import { VinBrandIcon } from "@/components/ui/vin-brand-icon";
|
||||
import { VehicleSelectModal } from "@/components/vehicles/vehicle-select-modal";
|
||||
import { ApiError, api } from "@/lib/api-client";
|
||||
import { startAction } from "@/lib/faro";
|
||||
@@ -500,7 +501,7 @@ function SearchPage() {
|
||||
<form onSubmit={handleSearch} className="space-y-4">
|
||||
{/* Input */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-4 top-1/2 size-5 -translate-y-1/2 text-muted-foreground" />
|
||||
<VinBrandIcon vin={vin} className="absolute left-4 top-1/2 -translate-y-1/2" />
|
||||
<Input
|
||||
ref={inputRef}
|
||||
placeholder="Şase numarasını girin (17 karakter)"
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { CategoryBreadcrumb } from "@/components/categories/category-breadcrumb";
|
||||
import { CategoryColumns } from "@/components/categories/category-columns";
|
||||
import { CategoryGrid } from "@/components/categories/category-grid";
|
||||
import { CategoryTree } from "@/components/categories/category-tree";
|
||||
@@ -7,10 +8,10 @@ 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";
|
||||
import { ArrowLeft, ChevronRight } from "lucide-react";
|
||||
import { Fragment, Suspense, lazy, useMemo, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { Suspense, lazy, useState } from "react";
|
||||
|
||||
import { KEYS_6, KEYS_8 } from "@/lib/keys";
|
||||
const SchemaViewer = lazy(() =>
|
||||
@@ -49,31 +50,9 @@ 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, refetch, isFetching } = useCategoryParts(id, categoryId);
|
||||
|
||||
// Vehicle for breadcrumb root — cached if user arrived from /dashboard/vehicles/$id
|
||||
@@ -86,12 +65,6 @@ function VehicleCategoryPage() {
|
||||
|
||||
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",
|
||||
);
|
||||
@@ -101,23 +74,16 @@ 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;
|
||||
|
||||
// Back = one level up the hierarchy, derived from the server ancestor trail
|
||||
// (reliable on refresh/deep-link). The last ancestor is the direct parent;
|
||||
// when there is none we're at the top category, so return to the vehicle.
|
||||
const handleBack = () => {
|
||||
if (parentFromTrail) {
|
||||
const ancestors = data?.ancestors ?? [];
|
||||
const parentId = ancestors.at(-1)?.id ?? data?.parentId ?? null;
|
||||
if (parentId) {
|
||||
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 },
|
||||
params: { id, categoryId: parentId },
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -129,46 +95,14 @@ function VehicleCategoryPage() {
|
||||
? `${vehicle.brandName}${cleanModel ? ` ${cleanModel}` : ""}`
|
||||
: "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 }}
|
||||
data-faro-user-action-name="breadcrumb-vehicle"
|
||||
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 }}
|
||||
data-faro-user-action-name="breadcrumb-category"
|
||||
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>
|
||||
<CategoryBreadcrumb
|
||||
vehicleId={id}
|
||||
vehicleLabel={vehicleLabel}
|
||||
ancestors={data?.ancestors ?? []}
|
||||
currentName={data?.name ?? (isLoading ? "…" : undefined)}
|
||||
/>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -178,6 +112,7 @@ function VehicleCategoryPage() {
|
||||
size="icon"
|
||||
onClick={handleBack}
|
||||
title="Geri dön"
|
||||
aria-label="Geri dön"
|
||||
data-faro-user-action-name="category-back"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { CategoryBreadcrumb } from "@/components/categories/category-breadcrumb";
|
||||
import { CategoryColumns } from "@/components/categories/category-columns";
|
||||
import { CategoryGrid } from "@/components/categories/category-grid";
|
||||
import { CategoryTree } from "@/components/categories/category-tree";
|
||||
@@ -72,6 +73,10 @@ function VehicleDetailPage() {
|
||||
enabled: !!id,
|
||||
});
|
||||
|
||||
const vehicleLabel = vehicle?.brandName
|
||||
? `${vehicle.brandName}${cleanModelName(vehicle?.model) ? ` ${cleanModelName(vehicle.model)}` : ""}`
|
||||
: "Araç";
|
||||
|
||||
if (vehicleLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -112,6 +117,7 @@ function VehicleDetailPage() {
|
||||
size="icon"
|
||||
onClick={handleBack}
|
||||
title="Geri dön"
|
||||
aria-label="Geri dön"
|
||||
data-faro-user-action-name="vehicle-back"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
@@ -158,6 +164,8 @@ function VehicleDetailPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<CategoryBreadcrumb vehicleId={id} vehicleLabel={vehicleLabel} />
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
@@ -165,6 +173,7 @@ function VehicleDetailPage() {
|
||||
size="icon"
|
||||
onClick={handleBack}
|
||||
title="Geri dön"
|
||||
aria-label="Geri dön"
|
||||
data-faro-user-action-name="vehicle-back"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { CarBrandLogo } from "@/components/ui/car-brand-logo";
|
||||
import { SmartAnimateText } from "@/components/ui/smart-animate-text";
|
||||
import { VinBrandIcon } from "@/components/ui/vin-brand-icon";
|
||||
import { KEYS_17, dynamicKeys } from "@/lib/keys";
|
||||
import { Button, Input, Separator } from "@sase/ui";
|
||||
import { Link, createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
@@ -433,6 +436,8 @@ export function HomePage() {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const [vin, setVin] = useState("");
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
// Karusel: tıklanan marka adı (n = animasyonu her tıklamada yeniden tetiklemek için)
|
||||
const [selectedBrand, setSelectedBrand] = useState<{ name: string; n: number } | null>(null);
|
||||
const [isDark, setIsDark] = useState(() => {
|
||||
const theme = getUserSettings().theme ?? "dark";
|
||||
if (theme === "system") {
|
||||
@@ -836,7 +841,7 @@ export function HomePage() {
|
||||
{/* VIN Input */}
|
||||
<div className="mx-auto mt-8 flex max-w-xl flex-col gap-3 sm:flex-row">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-4 top-1/2 size-5 -translate-y-1/2 text-muted-foreground" />
|
||||
<VinBrandIcon vin={vin} className="absolute left-4 top-1/2 -translate-y-1/2" />
|
||||
<Input
|
||||
value={vin}
|
||||
onChange={(e) => setVin(e.target.value.toUpperCase())}
|
||||
@@ -965,8 +970,18 @@ export function HomePage() {
|
||||
{/* ─── 3. MARKA CAROUSEL ────────────────────────────────────────── */}
|
||||
<section className="border-y border-border py-12">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6">
|
||||
<p className="mb-8 text-center text-sm text-muted-foreground">
|
||||
Desteklenen 50+ Otomobil Markası
|
||||
<p className="mb-8 flex h-8 items-center justify-center text-center">
|
||||
{selectedBrand ? (
|
||||
<SmartAnimateText
|
||||
key={selectedBrand.n}
|
||||
text={selectedBrand.name}
|
||||
className="font-[family-name:var(--font-display)] text-2xl font-bold text-brand"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Desteklenen 50+ Otomobil Markası
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="carousel-track relative overflow-hidden">
|
||||
@@ -974,15 +989,24 @@ export function HomePage() {
|
||||
<div className="pointer-events-none absolute left-0 top-0 z-10 h-full w-24 bg-gradient-to-r from-background to-transparent" />
|
||||
<div className="pointer-events-none absolute right-0 top-0 z-10 h-full w-24 bg-gradient-to-l from-background to-transparent" />
|
||||
|
||||
<div className="animate-scroll-left flex w-max gap-8 whitespace-nowrap">
|
||||
{[...BRANDS.map((b) => `${b}::a`), ...BRANDS.map((b) => `${b}::b`)].map((id) => (
|
||||
<span
|
||||
key={id}
|
||||
className="inline-block text-sm font-medium text-muted-foreground transition hover:text-foreground"
|
||||
>
|
||||
{id.split("::")[0]}
|
||||
</span>
|
||||
))}
|
||||
<div className="animate-scroll-left flex w-max items-center gap-12 whitespace-nowrap">
|
||||
{[...BRANDS.map((b) => `${b}::a`), ...BRANDS.map((b) => `${b}::b`)].map((id) => {
|
||||
const brand = id.split("::")[0];
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={id}
|
||||
onClick={() =>
|
||||
setSelectedBrand((prev) => ({ name: brand, n: (prev?.n ?? 0) + 1 }))
|
||||
}
|
||||
title={brand}
|
||||
aria-label={`${brand} markasını göster`}
|
||||
className="inline-flex shrink-0 cursor-pointer items-center opacity-60 grayscale transition hover:opacity-100 hover:grayscale-0 focus-visible:opacity-100 focus-visible:grayscale-0 focus-visible:outline-none"
|
||||
>
|
||||
<CarBrandLogo brandName={brand} size={44} />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -61,6 +61,8 @@ export {
|
||||
validateVinCheckDigit,
|
||||
extractWmi,
|
||||
extractModelYear,
|
||||
getBrandFromWmi,
|
||||
WMI_BRAND_MAP,
|
||||
} from "./utils/vin-validator.js";
|
||||
export { formatTRY, kurusToLira, liraToKurus } from "./utils/currency.js";
|
||||
export {
|
||||
|
||||
@@ -55,6 +55,142 @@ export function extractWmi(vin: string): string {
|
||||
return vin.toUpperCase().slice(0, 3);
|
||||
}
|
||||
|
||||
/**
|
||||
* World Manufacturer Identifier (VIN'in ilk 3 karakteri) → marka adı.
|
||||
* Marka adlarının çoğu @sase/ui CarBrandLogo eşlemesiyle uyumludur
|
||||
* (Chevrolet/Lancia gibi birkaçında logo yok, harf fallback'i gösterilir).
|
||||
*/
|
||||
export const WMI_BRAND_MAP: Record<string, string> = {
|
||||
// BMW
|
||||
WBA: "BMW",
|
||||
WBS: "BMW",
|
||||
WBY: "BMW",
|
||||
"5UX": "BMW",
|
||||
// Mercedes-Benz
|
||||
WDB: "Mercedes-Benz",
|
||||
WDC: "Mercedes-Benz",
|
||||
WDD: "Mercedes-Benz",
|
||||
WDF: "Mercedes-Benz",
|
||||
// Audi
|
||||
WAU: "Audi",
|
||||
WUA: "Audi",
|
||||
// Volkswagen
|
||||
WVW: "Volkswagen",
|
||||
WVG: "Volkswagen",
|
||||
"3VW": "Volkswagen",
|
||||
WV1: "Volkswagen",
|
||||
WV2: "Volkswagen",
|
||||
WV3: "Volkswagen",
|
||||
// Toyota
|
||||
JTD: "Toyota",
|
||||
JTE: "Toyota",
|
||||
JTN: "Toyota",
|
||||
"2T1": "Toyota",
|
||||
"4T1": "Toyota",
|
||||
// Fiat
|
||||
ZFA: "Fiat",
|
||||
ZFC: "Fiat",
|
||||
// Renault
|
||||
VF1: "Renault",
|
||||
VF2: "Renault",
|
||||
// Peugeot
|
||||
VF3: "Peugeot",
|
||||
VR3: "Peugeot", // newer Stellantis-era WMI
|
||||
VR7: "Peugeot", // newer Stellantis-era WMI
|
||||
// Citroen
|
||||
VF7: "Citroen",
|
||||
VR1: "Citroen", // newer Citroën/DS WMI
|
||||
// Honda
|
||||
JHM: "Honda",
|
||||
SHH: "Honda",
|
||||
"1HG": "Honda",
|
||||
// Hyundai
|
||||
KMH: "Hyundai",
|
||||
"5NP": "Hyundai",
|
||||
// Kia
|
||||
KNA: "Kia",
|
||||
KND: "Kia",
|
||||
// Ford
|
||||
WF0: "Ford",
|
||||
NM0: "Ford",
|
||||
"1FA": "Ford",
|
||||
"3FA": "Ford",
|
||||
// Opel
|
||||
W0L: "Opel",
|
||||
W0V: "Opel", // newer Stellantis-era WMI
|
||||
// Skoda
|
||||
TMB: "Skoda",
|
||||
// Seat
|
||||
VSS: "Seat",
|
||||
// Volvo
|
||||
YV1: "Volvo",
|
||||
// Nissan
|
||||
JN1: "Nissan",
|
||||
"1N4": "Nissan",
|
||||
"3N1": "Nissan",
|
||||
// Mazda
|
||||
JMZ: "Mazda",
|
||||
JM1: "Mazda",
|
||||
JM3: "Mazda",
|
||||
// Porsche
|
||||
WP0: "Porsche",
|
||||
WP1: "Porsche",
|
||||
// Land Rover
|
||||
SAL: "Land Rover",
|
||||
// Jaguar
|
||||
SAJ: "Jaguar",
|
||||
// Mini
|
||||
WMW: "Mini",
|
||||
// Dacia
|
||||
UU1: "Dacia",
|
||||
// Subaru
|
||||
JF1: "Subaru",
|
||||
JF2: "Subaru",
|
||||
// Suzuki
|
||||
JS2: "Suzuki",
|
||||
JS3: "Suzuki",
|
||||
TSM: "Suzuki",
|
||||
MA3: "Suzuki",
|
||||
MBH: "Suzuki",
|
||||
// Mitsubishi
|
||||
JMB: "Mitsubishi",
|
||||
JMY: "Mitsubishi",
|
||||
MMB: "Mitsubishi",
|
||||
ML3: "Mitsubishi",
|
||||
|
||||
// ── Prod DB'de decode edilmiş VIN'lerden eklenen WMI'lar (2026-05-25) ──
|
||||
// Türkiye / ek pazar fabrikaları dahil. Marka = katalog decode sonucu.
|
||||
NM4: "Fiat", // Tofaş — Türkiye (Egea/Doblo/Fiorino)
|
||||
NMT: "Toyota", // Toyota — Sakarya, Türkiye (Corolla/C-HR/Yaris)
|
||||
NLA: "Honda", // Honda — Gebze, Türkiye (Civic)
|
||||
NLH: "Hyundai", // Hyundai Assan — İzmit, Türkiye (i10/i20)
|
||||
NLJ: "Hyundai", // Hyundai — H-1 / Grand Starex
|
||||
NMB: "Mercedes-Benz", // Mercedes-Benz — Türkiye
|
||||
TMA: "Hyundai", // Hyundai — Çek Cumhuriyeti (Nošovice)
|
||||
XW8: "Skoda", // VW Group — Skoda
|
||||
U5Y: "Kia", // Kia — Slovakya (Žilina)
|
||||
KNE: "Kia", // Kia (Spectra/Sephia/Shuma/Mentor)
|
||||
KL1: "Chevrolet", // GM Korea — Chevrolet/Daewoo
|
||||
SB1: "Toyota", // Toyota — Avrupa (Avensis/Auris)
|
||||
SJN: "Nissan", // Nissan — İngiltere (Sunderland)
|
||||
MDH: "Nissan", // Nissan — Hindistan (Micra K13)
|
||||
MR0: "Toyota", // Toyota — Tayland (Hilux)
|
||||
MAK: "Honda", // Honda — Hindistan (City)
|
||||
XMC: "Mitsubishi", // Mitsubishi — Tayland
|
||||
JSA: "Suzuki", // Suzuki (Swift)
|
||||
"2T2": "Lexus", // Toyota/Lexus — Kanada (RX)
|
||||
ZAR: "Alfa Romeo", // Alfa Romeo (145/146)
|
||||
ZLA: "Lancia", // Lancia (Kappa) — CarBrandLogo'da logo yok
|
||||
WMA: "MAN", // MAN — ticari araç/kamyon
|
||||
};
|
||||
|
||||
/**
|
||||
* WMI'dan marka adını döndürür. Bilinmeyen WMI için null.
|
||||
*/
|
||||
export function getBrandFromWmi(wmi: string): string | null {
|
||||
return WMI_BRAND_MAP[wmi.toUpperCase()] ?? null;
|
||||
}
|
||||
|
||||
export function extractModelYear(vin: string): number | null {
|
||||
const yearChar = vin.toUpperCase()[9];
|
||||
const yearMap: Record<string, number> = {
|
||||
|
||||
Reference in New Issue
Block a user