feat: VIN textbox WMI marka ikonu + WMI haritasını shared'e taşı/genişlet

- WMI_BRAND_MAP + getBrandFromWmi packages/shared'e taşındı (tek kaynak);
  corgi.service artık buradan import ediyor (davranış aynı, testler geçiyor)
- VinBrandIcon: VIN'in WMI'ı bilinen markaya denk gelince büyüteç yerine
  marka logosu pop animasyonuyla görünür (landing + search VIN textbox)
- WMI haritasına prod DB'de decode edilmiş VIN'lerden 23 eksik WMI eklendi
  (Türkiye fabrikaları NM4/NMT/NLA/NLH/NMB dahil; tüm DB WMI'ları artık tanınıyor)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-25 23:51:40 +03:00
parent 96171c0726
commit 77de860211
7 changed files with 215 additions and 103 deletions

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,42 @@
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;
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.
*/
export function VinBrandIcon({ vin, size = 20, className }: VinBrandIconProps) {
const wmi = vin.trim().toUpperCase().slice(0, 3);
const brand = wmi.length === 3 ? getBrandFromWmi(wmi) : null;
return (
<span 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="animate-icon-pop"
/>
) : (
<Search
key="search"
style={{ width: size, height: size }}
className="animate-icon-pop text-muted-foreground"
/>
)}
</span>
);
}

View File

@@ -168,6 +168,23 @@
}
}
@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;
@@ -201,6 +218,9 @@
.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);
}
.carousel-track:hover .animate-scroll-left {
animation-play-state: paused;
}

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,11 @@ 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}
size={22}
className="absolute left-4 top-1/2 size-6 -translate-y-1/2"
/>
<Input
ref={inputRef}
placeholder="Şase numarasını girin (17 karakter)"

View File

@@ -1,5 +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";
@@ -840,7 +841,11 @@ 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}
size={22}
className="absolute left-4 top-1/2 size-6 -translate-y-1/2"
/>
<Input
value={vin}
onChange={(e) => setVin(e.target.value.toUpperCase())}

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