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:
2026-05-25 22:17:58 +00:00
25 changed files with 841 additions and 323 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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" />

View File

@@ -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" />

View File

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

View File

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

View File

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