feat: PL24 integration + EMEX on-demand parts loading
## Major Changes ### PL24 Integration (PartsLink24 API) - Add PL24 auth service with JWT token management - Add PL24 service for VIN decode and parts catalog - Support for VAG, Mercedes, BMW, Ford, Renault and more - Schema image download with base64 decoding - Image deduplication via SchemaPic table ### EMEX On-Demand Loading - Remove automatic parts scraping during VIN decode - Add fetchCategoryParts() for on-demand loading - Store category URLs in rawResponse for later fetching - Fix Chrome executable path for Puppeteer ### VIN Decode Flow - PL24 first, fallback to EMEX if not available - Both sources now use on-demand parts loading - Faster VIN lookup (no parts scraped upfront) ### Database Schema - Add SchemaPic model for image deduplication - Add schemaPicId to VehicleCategory - Add EMEX models for parallel scraping ### Frontend Improvements - Dark theme with Tailwind CSS - Improved UI components (card, button, input) - Better category and parts display - Schema image viewer Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -7,31 +7,482 @@ datasource db {
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
// ==================== USERS ====================
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
email String @unique
|
||||
name String?
|
||||
avatar String?
|
||||
role Role @default(USER)
|
||||
provider AuthProvider @default(EMAIL)
|
||||
providerId String?
|
||||
passwordHash String?
|
||||
isActive Boolean @default(true)
|
||||
|
||||
subscription UserSubscription?
|
||||
selectedBrands UserBrand[]
|
||||
vehicles Vehicle[] @relation("QueriedBy")
|
||||
id String @id @default(cuid())
|
||||
email String @unique
|
||||
name String?
|
||||
avatar String?
|
||||
provider AuthProvider @default(EMAIL)
|
||||
providerId String?
|
||||
passwordHash String?
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
role Role @default(USER)
|
||||
queryLogs QueryLog[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
selectedBrands UserBrand[]
|
||||
subscription UserSubscription?
|
||||
vehicles Vehicle[] @relation("QueriedBy")
|
||||
|
||||
@@index([email])
|
||||
@@index([role])
|
||||
@@map("users")
|
||||
}
|
||||
|
||||
model Brand {
|
||||
id String @id @default(cuid())
|
||||
code String @unique
|
||||
name String
|
||||
logo String?
|
||||
isActive Boolean @default(true)
|
||||
sortOrder Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
userBrands UserBrand[]
|
||||
vehicles Vehicle[]
|
||||
|
||||
@@map("brands")
|
||||
}
|
||||
|
||||
model Plan {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
slug String @unique
|
||||
description String? @db.Text
|
||||
price Decimal @db.Decimal(10, 2)
|
||||
currency String @default("TRY")
|
||||
brandLimit Int
|
||||
hasFullAccess Boolean @default(false)
|
||||
features Json
|
||||
durationDays Int @default(30)
|
||||
sortOrder Int @default(0)
|
||||
isActive Boolean @default(true)
|
||||
isPopular Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
subscriptions UserSubscription[]
|
||||
|
||||
@@map("plans")
|
||||
}
|
||||
|
||||
model UserSubscription {
|
||||
id String @id @default(cuid())
|
||||
userId String @unique
|
||||
planId String
|
||||
status SubscriptionStatus @default(ACTIVE)
|
||||
currentPeriodStart DateTime @default(now())
|
||||
currentPeriodEnd DateTime
|
||||
cancelAtPeriodEnd Boolean @default(false)
|
||||
cancelledAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
payments Payment[]
|
||||
plan Plan @relation(fields: [planId], references: [id])
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([status])
|
||||
@@index([currentPeriodEnd])
|
||||
@@index([planId], map: "user_subscriptions_planId_fkey")
|
||||
@@map("user_subscriptions")
|
||||
}
|
||||
|
||||
model UserBrand {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
brandId String
|
||||
createdAt DateTime @default(now())
|
||||
brand Brand @relation(fields: [brandId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([userId, brandId])
|
||||
@@index([userId])
|
||||
@@index([brandId], map: "user_brands_brandId_fkey")
|
||||
@@map("user_brands")
|
||||
}
|
||||
|
||||
model Vehicle {
|
||||
id String @id @default(cuid())
|
||||
vin String @unique @db.VarChar(17)
|
||||
brandId String
|
||||
model String
|
||||
year Int
|
||||
series String?
|
||||
bodyType String?
|
||||
engineCode String?
|
||||
engineType String?
|
||||
engineVolume String?
|
||||
transmission String?
|
||||
driveType String?
|
||||
colorCode String?
|
||||
rawResponse Json
|
||||
queriedById String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
parts Part[]
|
||||
queryLogs QueryLog[]
|
||||
categories VehicleCategory[]
|
||||
brand Brand @relation(fields: [brandId], references: [id])
|
||||
queriedBy User? @relation("QueriedBy", fields: [queriedById], references: [id])
|
||||
|
||||
@@index([vin])
|
||||
@@index([brandId])
|
||||
@@index([queriedById], map: "vehicles_queriedById_fkey")
|
||||
@@map("vehicles")
|
||||
}
|
||||
|
||||
model Category {
|
||||
id String @id @default(cuid())
|
||||
code String @unique
|
||||
nameEn String
|
||||
nameTr String
|
||||
slug String @unique
|
||||
description String? @db.Text
|
||||
parentId String?
|
||||
iconName String?
|
||||
schemaImageUrl String?
|
||||
sortOrder Int @default(0)
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
parent Category? @relation("CategoryTree", fields: [parentId], references: [id])
|
||||
children Category[] @relation("CategoryTree")
|
||||
parts Part[]
|
||||
vehicleCategories VehicleCategory[]
|
||||
|
||||
@@index([parentId])
|
||||
@@map("categories")
|
||||
}
|
||||
|
||||
model VehicleCategory {
|
||||
id String @id @default(cuid())
|
||||
vehicleId String
|
||||
categoryId String
|
||||
partCount Int @default(0)
|
||||
schemaImageUrl String? @db.Text // Original URL from PL24
|
||||
schemaPicId String? // Reference to deduplicated schema image
|
||||
createdAt DateTime @default(now())
|
||||
category Category @relation(fields: [categoryId], references: [id], onDelete: Cascade)
|
||||
vehicle Vehicle @relation(fields: [vehicleId], references: [id], onDelete: Cascade)
|
||||
schemaPic SchemaPic? @relation(fields: [schemaPicId], references: [id])
|
||||
|
||||
@@unique([vehicleId, categoryId])
|
||||
@@index([vehicleId])
|
||||
@@index([categoryId])
|
||||
@@index([schemaPicId])
|
||||
@@map("vehicle_categories")
|
||||
}
|
||||
|
||||
model SchemaPic {
|
||||
id String @id @default(cuid())
|
||||
imageId String @unique // PL24 image ID (e.g., "194500200") for deduplication
|
||||
localPath String // Local path: /images/schemas/194500200.png
|
||||
originalUrl String? @db.Text // Original PL24 URL for reference
|
||||
fileSize Int? // File size in bytes
|
||||
width Int? // Image width
|
||||
height Int? // Image height
|
||||
createdAt DateTime @default(now())
|
||||
vehicleCategories VehicleCategory[]
|
||||
|
||||
@@index([imageId])
|
||||
@@map("schema_pics")
|
||||
}
|
||||
|
||||
model Part {
|
||||
id String @id @default(cuid())
|
||||
vehicleId String
|
||||
categoryId String
|
||||
oemCode String
|
||||
oemCodes Json?
|
||||
nameEn String
|
||||
nameTr String
|
||||
description String? @db.Text
|
||||
positionCode String?
|
||||
positionX Float?
|
||||
positionY Float?
|
||||
brandPrices Json
|
||||
imageUrl String?
|
||||
notes String? @db.Text
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
category Category @relation(fields: [categoryId], references: [id], onDelete: Cascade)
|
||||
vehicle Vehicle @relation(fields: [vehicleId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([oemCode])
|
||||
@@index([vehicleId])
|
||||
@@index([categoryId])
|
||||
@@index([vehicleId, categoryId])
|
||||
@@map("parts")
|
||||
}
|
||||
|
||||
model Payment {
|
||||
id String @id @default(cuid())
|
||||
subscriptionId String
|
||||
amount Decimal @db.Decimal(10, 2)
|
||||
currency String @default("TRY")
|
||||
status PaymentStatus @default(PENDING)
|
||||
provider String @default("iyzico")
|
||||
providerTxId String? @unique
|
||||
providerData Json?
|
||||
invoiceNumber String?
|
||||
invoiceUrl String?
|
||||
failureReason String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
subscription UserSubscription @relation(fields: [subscriptionId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([status])
|
||||
@@index([providerTxId])
|
||||
@@index([subscriptionId], map: "payments_subscriptionId_fkey")
|
||||
@@map("payments")
|
||||
}
|
||||
|
||||
model QueryLog {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
vehicleId String?
|
||||
vin String @db.VarChar(17)
|
||||
responseTime Int?
|
||||
createdAt DateTime @default(now())
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
vehicle Vehicle? @relation(fields: [vehicleId], references: [id])
|
||||
|
||||
@@index([userId])
|
||||
@@index([vin])
|
||||
@@index([createdAt])
|
||||
@@index([userId, createdAt])
|
||||
@@index([vehicleId], map: "query_logs_vehicleId_fkey")
|
||||
@@map("query_logs")
|
||||
}
|
||||
|
||||
model EmexCatalog {
|
||||
id String @id @default(cuid())
|
||||
code String @unique
|
||||
name String
|
||||
brandCode String
|
||||
icon String?
|
||||
supportVinSearch Boolean @default(false)
|
||||
supportParameterIdentification Boolean @default(false)
|
||||
supportQuickGroups Boolean @default(false)
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
categories EmexCategory[]
|
||||
parts EmexPart[]
|
||||
vehicles EmexVehicle[]
|
||||
|
||||
@@index([brandCode])
|
||||
@@map("emex_catalogs")
|
||||
}
|
||||
|
||||
model EmexVehicle {
|
||||
id String @id @default(cuid())
|
||||
catalogId String
|
||||
name String
|
||||
engine String?
|
||||
engineCode String?
|
||||
options Json?
|
||||
ssd String @db.VarChar(500)
|
||||
pathData String? @db.Text
|
||||
sourceUrl String? @db.Text
|
||||
uniqueKey String @db.VarChar(255)
|
||||
scrapeComplete Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
groupLinks EmexVehicleCategory[]
|
||||
partLinks EmexVehiclePart[]
|
||||
catalog EmexCatalog @relation(fields: [catalogId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([catalogId, uniqueKey])
|
||||
@@index([catalogId])
|
||||
@@index([ssd])
|
||||
@@map("emex_vehicles")
|
||||
}
|
||||
|
||||
model EmexCategory {
|
||||
id String @id @default(cuid())
|
||||
catalogId String
|
||||
groupId String
|
||||
parentId String?
|
||||
name String
|
||||
nameTr String
|
||||
level Int @default(1)
|
||||
sortOrder Int @default(0)
|
||||
hasParts Boolean @default(true)
|
||||
hasChildren Boolean @default(false)
|
||||
schemaImageUrl String? @db.Text
|
||||
localImagePath String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
catalog EmexCatalog @relation(fields: [catalogId], references: [id], onDelete: Cascade)
|
||||
parent EmexCategory? @relation("CategoryTree", fields: [parentId], references: [id])
|
||||
children EmexCategory[] @relation("CategoryTree")
|
||||
images EmexPartImage[]
|
||||
parts EmexPart[]
|
||||
vehicleLinks EmexVehicleCategory[]
|
||||
vehiclePartLinks EmexVehiclePart[]
|
||||
|
||||
@@unique([catalogId, groupId])
|
||||
@@index([catalogId])
|
||||
@@index([parentId])
|
||||
@@map("emex_categories")
|
||||
}
|
||||
|
||||
model EmexPart {
|
||||
id String @id @default(cuid())
|
||||
catalogId String
|
||||
partNumber String @db.VarChar(50)
|
||||
name String
|
||||
nameTr String?
|
||||
description String? @db.Text
|
||||
brand String?
|
||||
oemNumber String?
|
||||
unit String @default("PC")
|
||||
notes String? @db.Text
|
||||
attributes Json?
|
||||
categoryId String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
partNumbers EmexPartNumber[]
|
||||
catalog EmexCatalog @relation(fields: [catalogId], references: [id], onDelete: Cascade)
|
||||
category EmexCategory? @relation(fields: [categoryId], references: [id])
|
||||
vehicleLinks EmexVehiclePart[]
|
||||
|
||||
@@unique([catalogId, partNumber])
|
||||
@@index([catalogId])
|
||||
@@index([partNumber])
|
||||
@@index([categoryId])
|
||||
@@map("emex_parts")
|
||||
}
|
||||
|
||||
model EmexVehiclePart {
|
||||
id String @id @default(cuid())
|
||||
vehicleId String
|
||||
partId String
|
||||
categoryId String?
|
||||
quantity Float @default(1)
|
||||
position String?
|
||||
createdAt DateTime @default(now())
|
||||
category EmexCategory? @relation(fields: [categoryId], references: [id])
|
||||
part EmexPart @relation(fields: [partId], references: [id], onDelete: Cascade)
|
||||
vehicle EmexVehicle @relation(fields: [vehicleId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([vehicleId, partId, categoryId])
|
||||
@@index([vehicleId])
|
||||
@@index([partId])
|
||||
@@index([categoryId])
|
||||
@@map("emex_vehicle_parts")
|
||||
}
|
||||
|
||||
model EmexVehicleCategory {
|
||||
id String @id @default(cuid())
|
||||
vehicleId String
|
||||
categoryId String
|
||||
isScraped Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
category EmexCategory @relation(fields: [categoryId], references: [id], onDelete: Cascade)
|
||||
vehicle EmexVehicle @relation(fields: [vehicleId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([vehicleId, categoryId])
|
||||
@@index([vehicleId])
|
||||
@@index([categoryId])
|
||||
@@map("emex_vehicle_categories")
|
||||
}
|
||||
|
||||
model EmexPartNumber {
|
||||
id String @id @default(cuid())
|
||||
partId String
|
||||
number String @db.VarChar(50)
|
||||
numberType String @default("ALTERNATE")
|
||||
brand String?
|
||||
priority Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
part EmexPart @relation(fields: [partId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([partId, number])
|
||||
@@index([partId])
|
||||
@@index([number])
|
||||
@@map("emex_part_numbers")
|
||||
}
|
||||
|
||||
model EmexPartImage {
|
||||
id String @id @default(cuid())
|
||||
categoryId String?
|
||||
imageType String @default("DIAGRAM")
|
||||
originalUrl String @db.Text
|
||||
localPath String?
|
||||
isPrimary Boolean @default(false)
|
||||
sortOrder Int @default(0)
|
||||
downloadedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
category EmexCategory? @relation(fields: [categoryId], references: [id])
|
||||
|
||||
@@index([categoryId])
|
||||
@@index([originalUrl(length: 255)])
|
||||
@@map("emex_part_images")
|
||||
}
|
||||
|
||||
model EmexScrapeQueue {
|
||||
id String @id @default(cuid())
|
||||
catalogId String
|
||||
taskType String
|
||||
vehicleId String?
|
||||
vehicleSsd String? @db.VarChar(500)
|
||||
vehicleName String?
|
||||
categoryId String?
|
||||
groupId String?
|
||||
status String @default("PENDING")
|
||||
workerId String?
|
||||
retryCount Int @default(0)
|
||||
maxRetries Int @default(3)
|
||||
errorMessage String? @db.Text
|
||||
priority Int @default(5)
|
||||
startedAt DateTime?
|
||||
completedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([status])
|
||||
@@index([catalogId])
|
||||
@@index([vehicleId])
|
||||
@@index([priority])
|
||||
@@map("emex_scrape_queue")
|
||||
}
|
||||
|
||||
model EmexScrapeSession {
|
||||
id String @id @default(cuid())
|
||||
catalogId String?
|
||||
brandCode String
|
||||
modelName String?
|
||||
status String @default("PENDING")
|
||||
totalItems Int @default(0)
|
||||
processedItems Int @default(0)
|
||||
failedItems Int @default(0)
|
||||
lastSsd String? @db.VarChar(500)
|
||||
lastError String? @db.Text
|
||||
stats Json?
|
||||
startedAt DateTime?
|
||||
completedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([status])
|
||||
@@index([brandCode])
|
||||
@@map("emex_scrape_sessions")
|
||||
}
|
||||
|
||||
model EmexCategoryTranslation {
|
||||
id String @id @default(cuid())
|
||||
nameEn String @unique
|
||||
nameTr String
|
||||
synonyms Json?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@map("emex_category_translations")
|
||||
}
|
||||
|
||||
enum Role {
|
||||
USER
|
||||
MODERATOR
|
||||
@@ -46,85 +497,6 @@ enum AuthProvider {
|
||||
FACEBOOK
|
||||
}
|
||||
|
||||
// ==================== BRANDS ====================
|
||||
model Brand {
|
||||
id String @id @default(cuid())
|
||||
code String @unique
|
||||
name String
|
||||
logo String?
|
||||
isActive Boolean @default(true)
|
||||
sortOrder Int @default(0)
|
||||
|
||||
vehicles Vehicle[]
|
||||
userBrands UserBrand[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@map("brands")
|
||||
}
|
||||
|
||||
// ==================== SUBSCRIPTIONS ====================
|
||||
model Plan {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
slug String @unique
|
||||
description String? @db.Text
|
||||
price Decimal @db.Decimal(10, 2)
|
||||
currency String @default("TRY")
|
||||
brandLimit Int
|
||||
hasFullAccess Boolean @default(false)
|
||||
features Json
|
||||
durationDays Int @default(30)
|
||||
sortOrder Int @default(0)
|
||||
isActive Boolean @default(true)
|
||||
isPopular Boolean @default(false)
|
||||
|
||||
subscriptions UserSubscription[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@map("plans")
|
||||
}
|
||||
|
||||
model UserSubscription {
|
||||
id String @id @default(cuid())
|
||||
userId String @unique
|
||||
planId String
|
||||
status SubscriptionStatus @default(ACTIVE)
|
||||
currentPeriodStart DateTime @default(now())
|
||||
currentPeriodEnd DateTime
|
||||
cancelAtPeriodEnd Boolean @default(false)
|
||||
cancelledAt DateTime?
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
plan Plan @relation(fields: [planId], references: [id])
|
||||
payments Payment[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([status])
|
||||
@@index([currentPeriodEnd])
|
||||
@@map("user_subscriptions")
|
||||
}
|
||||
|
||||
model UserBrand {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
brandId String
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
brand Brand @relation(fields: [brandId], references: [id], onDelete: Cascade)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@unique([userId, brandId])
|
||||
@@index([userId])
|
||||
@@map("user_brands")
|
||||
}
|
||||
|
||||
enum SubscriptionStatus {
|
||||
ACTIVE
|
||||
PAST_DUE
|
||||
@@ -134,137 +506,6 @@ enum SubscriptionStatus {
|
||||
PENDING
|
||||
}
|
||||
|
||||
// ==================== VEHICLES ====================
|
||||
model Vehicle {
|
||||
id String @id @default(cuid())
|
||||
vin String @unique @db.VarChar(17)
|
||||
brandId String
|
||||
model String
|
||||
year Int
|
||||
series String?
|
||||
bodyType String?
|
||||
engineCode String?
|
||||
engineType String?
|
||||
engineVolume String?
|
||||
transmission String?
|
||||
driveType String?
|
||||
colorCode String?
|
||||
rawResponse Json
|
||||
|
||||
queriedById String?
|
||||
queriedBy User? @relation("QueriedBy", fields: [queriedById], references: [id], onDelete: SetNull)
|
||||
brand Brand @relation(fields: [brandId], references: [id])
|
||||
|
||||
categories VehicleCategory[]
|
||||
parts Part[]
|
||||
queryLogs QueryLog[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([vin])
|
||||
@@index([brandId])
|
||||
@@map("vehicles")
|
||||
}
|
||||
|
||||
// ==================== CATEGORIES ====================
|
||||
model Category {
|
||||
id String @id @default(cuid())
|
||||
code String @unique
|
||||
nameEn String
|
||||
nameTr String
|
||||
slug String @unique
|
||||
description String? @db.Text
|
||||
parentId String?
|
||||
iconName String?
|
||||
schemaImageUrl String?
|
||||
sortOrder Int @default(0)
|
||||
isActive Boolean @default(true)
|
||||
|
||||
parent Category? @relation("CategoryTree", fields: [parentId], references: [id], onDelete: SetNull)
|
||||
children Category[] @relation("CategoryTree")
|
||||
|
||||
vehicleCategories VehicleCategory[]
|
||||
parts Part[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([parentId])
|
||||
@@map("categories")
|
||||
}
|
||||
|
||||
model VehicleCategory {
|
||||
id String @id @default(cuid())
|
||||
vehicleId String
|
||||
categoryId String
|
||||
partCount Int @default(0)
|
||||
|
||||
vehicle Vehicle @relation(fields: [vehicleId], references: [id], onDelete: Cascade)
|
||||
category Category @relation(fields: [categoryId], references: [id], onDelete: Cascade)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@unique([vehicleId, categoryId])
|
||||
@@index([vehicleId])
|
||||
@@index([categoryId])
|
||||
@@map("vehicle_categories")
|
||||
}
|
||||
|
||||
// ==================== PARTS ====================
|
||||
model Part {
|
||||
id String @id @default(cuid())
|
||||
vehicleId String
|
||||
categoryId String
|
||||
oemCode String
|
||||
oemCodes Json?
|
||||
nameEn String
|
||||
nameTr String
|
||||
description String? @db.Text
|
||||
positionCode String?
|
||||
positionX Float?
|
||||
positionY Float?
|
||||
brandPrices Json @default("[]")
|
||||
imageUrl String?
|
||||
notes String? @db.Text
|
||||
|
||||
vehicle Vehicle @relation(fields: [vehicleId], references: [id], onDelete: Cascade)
|
||||
category Category @relation(fields: [categoryId], references: [id], onDelete: Cascade)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([oemCode])
|
||||
@@index([vehicleId])
|
||||
@@index([categoryId])
|
||||
@@index([vehicleId, categoryId])
|
||||
@@map("parts")
|
||||
}
|
||||
|
||||
// ==================== PAYMENTS ====================
|
||||
model Payment {
|
||||
id String @id @default(cuid())
|
||||
subscriptionId String
|
||||
amount Decimal @db.Decimal(10, 2)
|
||||
currency String @default("TRY")
|
||||
status PaymentStatus @default(PENDING)
|
||||
provider String @default("iyzico")
|
||||
providerTxId String? @unique
|
||||
providerData Json?
|
||||
invoiceNumber String?
|
||||
invoiceUrl String?
|
||||
failureReason String?
|
||||
|
||||
subscription UserSubscription @relation(fields: [subscriptionId], references: [id], onDelete: Cascade)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([status])
|
||||
@@index([providerTxId])
|
||||
@@map("payments")
|
||||
}
|
||||
|
||||
enum PaymentStatus {
|
||||
PENDING
|
||||
PROCESSING
|
||||
@@ -273,23 +514,3 @@ enum PaymentStatus {
|
||||
REFUNDED
|
||||
CANCELLED
|
||||
}
|
||||
|
||||
// ==================== QUERY LOGS ====================
|
||||
model QueryLog {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
vehicleId String?
|
||||
vin String @db.VarChar(17)
|
||||
responseTime Int?
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
vehicle Vehicle? @relation(fields: [vehicleId], references: [id], onDelete: SetNull)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([userId])
|
||||
@@index([vin])
|
||||
@@index([createdAt])
|
||||
@@index([userId, createdAt])
|
||||
@@map("query_logs")
|
||||
}
|
||||
|
||||
BIN
apps/api/public/images/schemas/162100100.png
Normal file
BIN
apps/api/public/images/schemas/162100100.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 118 KiB |
567
apps/api/scripts/emex-parallel-scraper.js
Normal file
567
apps/api/scripts/emex-parallel-scraper.js
Normal file
@@ -0,0 +1,567 @@
|
||||
/**
|
||||
* EMEX Parallel Scraper - Robust Category/Parts Scraping
|
||||
*
|
||||
* Uses the existing EmexVinScraper for vehicle detection,
|
||||
* then scrapes parts in parallel using multiple browsers.
|
||||
*
|
||||
* Features:
|
||||
* - 10 parallel browsers with different proxy ports
|
||||
* - Retry mechanism with exponential backoff
|
||||
* - Longer timeouts (90s)
|
||||
* - Stable error handling
|
||||
* - No empty categories - retries until data is fetched
|
||||
*/
|
||||
|
||||
const puppeteer = require('puppeteer');
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const { EmexVinScraper, getCatalogCode } = require('../../../scripts/emex-vin-scraper');
|
||||
|
||||
// ============================================================================
|
||||
// CONFIGURATION
|
||||
// ============================================================================
|
||||
|
||||
const CONFIG = {
|
||||
proxy: {
|
||||
host: '74.81.81.81',
|
||||
portStart: 10000,
|
||||
portEnd: 10099,
|
||||
username: '1726bbe361918676d44e',
|
||||
password: 'f11c7b6128cc86c6',
|
||||
},
|
||||
concurrency: {
|
||||
maxBrowsers: 10,
|
||||
maxRetries: 5,
|
||||
retryDelayBase: 2000,
|
||||
batchDelay: 1000,
|
||||
},
|
||||
timeouts: {
|
||||
navigation: 90000,
|
||||
request: 60000,
|
||||
},
|
||||
target: {
|
||||
baseUrl: 'https://emexdwc.ae',
|
||||
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
},
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// WORKER - Single browser instance for parallel processing
|
||||
// ============================================================================
|
||||
|
||||
class Worker {
|
||||
constructor(id, proxyPort) {
|
||||
this.id = id;
|
||||
this.proxyPort = proxyPort;
|
||||
this.browser = null;
|
||||
this.page = null;
|
||||
this.isReady = false;
|
||||
this.requestCount = 0;
|
||||
this.errorCount = 0;
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
const proxyUrl = `http://${CONFIG.proxy.host}:${this.proxyPort}`;
|
||||
|
||||
this.browser = await puppeteer.launch({
|
||||
headless: 'new',
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-gpu',
|
||||
`--proxy-server=${proxyUrl}`,
|
||||
],
|
||||
});
|
||||
|
||||
this.page = await this.browser.newPage();
|
||||
|
||||
await this.page.authenticate({
|
||||
username: CONFIG.proxy.username,
|
||||
password: CONFIG.proxy.password,
|
||||
});
|
||||
|
||||
await this.page.setUserAgent(CONFIG.target.userAgent);
|
||||
this.page.setDefaultNavigationTimeout(CONFIG.timeouts.navigation);
|
||||
this.page.setDefaultTimeout(CONFIG.timeouts.request);
|
||||
|
||||
this.isReady = true;
|
||||
}
|
||||
|
||||
async fetchParts(url) {
|
||||
if (!this.isReady || !this.page) {
|
||||
throw new Error('Worker not ready');
|
||||
}
|
||||
|
||||
this.requestCount++;
|
||||
|
||||
await this.page.goto(url, {
|
||||
waitUntil: 'networkidle2',
|
||||
timeout: CONFIG.timeouts.navigation,
|
||||
});
|
||||
|
||||
// Wait for content to load
|
||||
await this.delay(2000);
|
||||
|
||||
// Parse parts - using same logic as EmexVinScraper.getParts()
|
||||
const parts = await this.page.evaluate(() => {
|
||||
const partsData = [];
|
||||
|
||||
// Look for part table rows
|
||||
const rows = document.querySelectorAll('table tr, .part-row, [class*="part-item"]');
|
||||
rows.forEach(row => {
|
||||
const cells = row.querySelectorAll('td');
|
||||
if (cells.length >= 2) {
|
||||
let partNumber = '';
|
||||
let description = '';
|
||||
let position = '';
|
||||
|
||||
cells.forEach((cell, idx) => {
|
||||
const text = cell.textContent.trim();
|
||||
// Part numbers are typically 6-14 alphanumeric chars
|
||||
if (/^[A-Z0-9]{6,14}$/.test(text) && !partNumber) {
|
||||
partNumber = text;
|
||||
} else if (text.length > 10 && !description) {
|
||||
description = text;
|
||||
} else if (/^\d{1,3}$/.test(text) && idx === 0) {
|
||||
position = text;
|
||||
}
|
||||
});
|
||||
|
||||
if (partNumber) {
|
||||
partsData.push({
|
||||
partNumber: partNumber,
|
||||
name: description || partNumber,
|
||||
position: position || null
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return partsData;
|
||||
});
|
||||
|
||||
return parts;
|
||||
}
|
||||
|
||||
async close() {
|
||||
if (this.browser) {
|
||||
await this.browser.close().catch(() => {});
|
||||
this.browser = null;
|
||||
this.page = null;
|
||||
this.isReady = false;
|
||||
}
|
||||
}
|
||||
|
||||
delay(ms) {
|
||||
return new Promise(r => setTimeout(r, ms));
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PARALLEL SCRAPER
|
||||
// ============================================================================
|
||||
|
||||
class EmexParallelScraper {
|
||||
constructor() {
|
||||
this.prisma = new PrismaClient();
|
||||
this.workers = [];
|
||||
this.availableWorkers = [];
|
||||
this.busyWorkers = new Set();
|
||||
this.stats = this.resetStats();
|
||||
}
|
||||
|
||||
resetStats() {
|
||||
return {
|
||||
startTime: null,
|
||||
endTime: null,
|
||||
vin: null,
|
||||
catalogCode: null,
|
||||
vehicle: null,
|
||||
categoriesTotal: 0,
|
||||
categoriesCompleted: 0,
|
||||
categoriesFailed: 0,
|
||||
partsTotal: 0,
|
||||
retries: 0,
|
||||
errors: [],
|
||||
};
|
||||
}
|
||||
|
||||
async initializeWorkers(count) {
|
||||
console.log(`[Workers] Initializing ${count} parallel workers...`);
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const port = CONFIG.proxy.portStart + (i % 100);
|
||||
const worker = new Worker(i, port);
|
||||
|
||||
try {
|
||||
await worker.initialize();
|
||||
this.workers.push(worker);
|
||||
this.availableWorkers.push(worker);
|
||||
process.stdout.write(`\r[Workers] ${i + 1}/${count} workers ready`);
|
||||
} catch (err) {
|
||||
console.error(`\n[Workers] Failed to create worker ${i}: ${err.message}`);
|
||||
}
|
||||
|
||||
// Small delay between worker creations
|
||||
await this.delay(300);
|
||||
}
|
||||
|
||||
console.log(`\n[Workers] ${this.workers.length} workers initialized`);
|
||||
}
|
||||
|
||||
async acquireWorker() {
|
||||
while (this.availableWorkers.length === 0) {
|
||||
await this.delay(100);
|
||||
}
|
||||
const worker = this.availableWorkers.shift();
|
||||
this.busyWorkers.add(worker.id);
|
||||
return worker;
|
||||
}
|
||||
|
||||
releaseWorker(worker) {
|
||||
this.busyWorkers.delete(worker.id);
|
||||
this.availableWorkers.push(worker);
|
||||
}
|
||||
|
||||
async closeAllWorkers() {
|
||||
console.log('[Workers] Closing all workers...');
|
||||
for (const worker of this.workers) {
|
||||
await worker.close();
|
||||
}
|
||||
this.workers = [];
|
||||
this.availableWorkers = [];
|
||||
this.busyWorkers.clear();
|
||||
}
|
||||
|
||||
// Main entry point
|
||||
async scrapeVIN(vin) {
|
||||
this.stats = this.resetStats();
|
||||
this.stats.startTime = Date.now();
|
||||
this.stats.vin = vin;
|
||||
|
||||
console.log('\n' + '═'.repeat(70));
|
||||
console.log('EMEX PARALLEL SCRAPER');
|
||||
console.log('═'.repeat(70));
|
||||
console.log(`VIN: ${vin}`);
|
||||
|
||||
try {
|
||||
// Step 1: Use existing scraper to get vehicle and categories
|
||||
console.log('\n[1/5] Fetching vehicle info with EmexVinScraper...');
|
||||
const vinScraper = new EmexVinScraper({ useProxy: true });
|
||||
await vinScraper.init();
|
||||
|
||||
const vinResult = await vinScraper.searchByVIN(vin);
|
||||
|
||||
if (!vinResult.success) {
|
||||
throw new Error(vinResult.message || vinResult.error || 'VIN search failed');
|
||||
}
|
||||
|
||||
console.log(` Vehicle: ${vinResult.vehicle?.model || 'Unknown'}`);
|
||||
console.log(` Catalog: ${vinResult.catalogCode}`);
|
||||
console.log(` Vehicles found: ${vinResult.allVehicles?.length || 0}`);
|
||||
|
||||
// Get categories
|
||||
let categories = [];
|
||||
if (vinResult.allVehicles && vinResult.allVehicles.length > 0 && vinResult.allVehicles[0].quickGroupsUrl) {
|
||||
console.log('\n[2/5] Fetching categories...');
|
||||
categories = await vinScraper.getCategories(vinResult.allVehicles[0].quickGroupsUrl);
|
||||
console.log(` Found ${categories.length} categories`);
|
||||
}
|
||||
|
||||
await vinScraper.close();
|
||||
|
||||
if (categories.length === 0) {
|
||||
throw new Error('No categories found for this vehicle');
|
||||
}
|
||||
|
||||
this.stats.catalogCode = vinResult.catalogCode;
|
||||
this.stats.vehicle = vinResult.vehicle;
|
||||
this.stats.categoriesTotal = categories.length;
|
||||
|
||||
// Step 2: Save to database
|
||||
console.log('\n[3/5] Saving vehicle and categories to database...');
|
||||
const dbData = await this.saveVehicleToDB(vinResult, categories);
|
||||
|
||||
// Step 3: Initialize parallel workers
|
||||
console.log('\n[4/5] Initializing parallel workers...');
|
||||
await this.initializeWorkers(CONFIG.concurrency.maxBrowsers);
|
||||
|
||||
// Step 4: Scrape parts in parallel
|
||||
console.log('\n[5/5] Scraping parts from categories in parallel...');
|
||||
await this.scrapeAllCategories(dbData, categories, vinResult.allVehicles[0]);
|
||||
|
||||
this.stats.endTime = Date.now();
|
||||
this.printSummary();
|
||||
|
||||
return { success: true, stats: this.stats };
|
||||
|
||||
} catch (error) {
|
||||
console.error('\n❌ Scraper error:', error.message);
|
||||
this.stats.errors.push(error.message);
|
||||
return { success: false, error: error.message, stats: this.stats };
|
||||
|
||||
} finally {
|
||||
await this.closeAllWorkers();
|
||||
await this.prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
async saveVehicleToDB(vinResult, categories) {
|
||||
const catalogCode = vinResult.catalogCode;
|
||||
const brandCode = catalogCode.replace(/\d+/g, '');
|
||||
|
||||
// Create catalog
|
||||
const catalog = await this.prisma.emexCatalog.upsert({
|
||||
where: { code: catalogCode },
|
||||
update: { updatedAt: new Date() },
|
||||
create: {
|
||||
code: catalogCode,
|
||||
name: `${brandCode} Catalog`,
|
||||
brandCode,
|
||||
supportVinSearch: true,
|
||||
supportQuickGroups: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Create vehicle
|
||||
const vehicle = vinResult.allVehicles?.[0] || {};
|
||||
const uniqueKey = `${this.stats.vin}_${(vinResult.ssd || '').substring(0, 50)}`;
|
||||
const dbVehicle = await this.prisma.emexVehicle.upsert({
|
||||
where: { catalogId_uniqueKey: { catalogId: catalog.id, uniqueKey } },
|
||||
update: { name: vehicle.name || 'Unknown', updatedAt: new Date() },
|
||||
create: {
|
||||
catalogId: catalog.id,
|
||||
name: vehicle.name || 'Unknown',
|
||||
engine: vehicle.engine,
|
||||
ssd: vinResult.ssd || '',
|
||||
uniqueKey,
|
||||
sourceUrl: vehicle.quickGroupsUrl,
|
||||
},
|
||||
});
|
||||
|
||||
// Save categories
|
||||
for (const cat of categories) {
|
||||
const category = await this.prisma.emexCategory.upsert({
|
||||
where: { catalogId_groupId: { catalogId: catalog.id, groupId: cat.gid } },
|
||||
update: { name: cat.name, updatedAt: new Date() },
|
||||
create: {
|
||||
catalogId: catalog.id,
|
||||
groupId: cat.gid,
|
||||
name: cat.name,
|
||||
nameTr: cat.name,
|
||||
hasParts: true,
|
||||
schemaImageUrl: cat.url,
|
||||
},
|
||||
});
|
||||
|
||||
// Link to vehicle
|
||||
await this.prisma.emexVehicleCategory.upsert({
|
||||
where: { vehicleId_categoryId: { vehicleId: dbVehicle.id, categoryId: category.id } },
|
||||
update: {},
|
||||
create: { vehicleId: dbVehicle.id, categoryId: category.id, isScraped: false },
|
||||
});
|
||||
}
|
||||
|
||||
console.log(` Catalog ID: ${catalog.id}`);
|
||||
console.log(` Vehicle ID: ${dbVehicle.id}`);
|
||||
console.log(` Categories: ${categories.length}`);
|
||||
|
||||
return { catalogId: catalog.id, vehicleId: dbVehicle.id };
|
||||
}
|
||||
|
||||
async scrapeAllCategories(dbData, categories, vehicle) {
|
||||
const queue = categories.map((cat, idx) => ({
|
||||
...cat,
|
||||
catalogId: dbData.catalogId,
|
||||
vehicleId: dbData.vehicleId,
|
||||
retryCount: 0,
|
||||
index: idx,
|
||||
}));
|
||||
|
||||
const batchSize = this.workers.length;
|
||||
console.log(` Processing ${queue.length} categories with ${batchSize} parallel workers...\n`);
|
||||
|
||||
while (queue.length > 0) {
|
||||
const batch = queue.splice(0, batchSize);
|
||||
const promises = batch.map(cat => this.scrapeCategoryWithRetry(cat, vehicle));
|
||||
const results = await Promise.all(promises);
|
||||
|
||||
for (let i = 0; i < results.length; i++) {
|
||||
const result = results[i];
|
||||
const cat = batch[i];
|
||||
|
||||
if (result.success) {
|
||||
this.stats.categoriesCompleted++;
|
||||
this.stats.partsTotal += result.partsCount;
|
||||
} else if (cat.retryCount < CONFIG.concurrency.maxRetries) {
|
||||
cat.retryCount++;
|
||||
this.stats.retries++;
|
||||
queue.push(cat);
|
||||
} else {
|
||||
this.stats.categoriesFailed++;
|
||||
this.stats.errors.push(`Failed: ${cat.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
const progress = ((this.stats.categoriesCompleted + this.stats.categoriesFailed) / this.stats.categoriesTotal * 100).toFixed(1);
|
||||
process.stdout.write(`\r Progress: ${progress}% | Completed: ${this.stats.categoriesCompleted} | Parts: ${this.stats.partsTotal} | Failed: ${this.stats.categoriesFailed} `);
|
||||
|
||||
if (queue.length > 0) {
|
||||
await this.delay(CONFIG.concurrency.batchDelay);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n');
|
||||
}
|
||||
|
||||
async scrapeCategoryWithRetry(category, vehicle) {
|
||||
const worker = await this.acquireWorker();
|
||||
|
||||
try {
|
||||
// Exponential backoff
|
||||
if (category.retryCount > 0) {
|
||||
await this.delay(CONFIG.concurrency.retryDelayBase * Math.pow(2, category.retryCount - 1));
|
||||
}
|
||||
|
||||
const url = category.url || `${CONFIG.target.baseUrl}/QuickDetails.aspx?c=${this.stats.catalogCode}&gid=${category.gid}&vid=0&ssd=${encodeURIComponent(vehicle.ssd || '')}`;
|
||||
|
||||
console.log(`\n [Worker ${worker.id}] Category: ${category.name || category.gid}`);
|
||||
console.log(` URL: ${url.substring(0, 80)}...`);
|
||||
|
||||
const parts = await worker.fetchParts(url);
|
||||
|
||||
console.log(` Parts found: ${parts.length}`);
|
||||
|
||||
if (parts.length > 0) {
|
||||
await this.savePartsToDB(category.catalogId, category.vehicleId, category.gid, parts);
|
||||
}
|
||||
|
||||
// Mark as scraped
|
||||
const categoryRecord = await this.prisma.emexCategory.findUnique({
|
||||
where: { catalogId_groupId: { catalogId: category.catalogId, groupId: category.gid } },
|
||||
});
|
||||
|
||||
if (categoryRecord) {
|
||||
await this.prisma.emexVehicleCategory.updateMany({
|
||||
where: { vehicleId: category.vehicleId, categoryId: categoryRecord.id },
|
||||
data: { isScraped: true },
|
||||
});
|
||||
}
|
||||
|
||||
return { success: true, partsCount: parts.length };
|
||||
|
||||
} catch (error) {
|
||||
console.log(`\n [Worker ${worker.id}] ERROR: ${error.message}`);
|
||||
worker.errorCount++;
|
||||
return { success: false, error: error.message };
|
||||
|
||||
} finally {
|
||||
this.releaseWorker(worker);
|
||||
}
|
||||
}
|
||||
|
||||
async savePartsToDB(catalogId, vehicleId, categoryGid, parts) {
|
||||
const categoryRecord = await this.prisma.emexCategory.findUnique({
|
||||
where: { catalogId_groupId: { catalogId, groupId: categoryGid } },
|
||||
});
|
||||
|
||||
if (!categoryRecord) {
|
||||
console.log(` [DB] Category not found: catalogId=${catalogId}, gid=${categoryGid}`);
|
||||
return;
|
||||
}
|
||||
|
||||
let saved = 0;
|
||||
for (const part of parts) {
|
||||
try {
|
||||
if (!part.partNumber || part.partNumber.length < 3) {
|
||||
continue; // Skip invalid parts
|
||||
}
|
||||
|
||||
let partRecord = await this.prisma.emexPart.findUnique({
|
||||
where: { catalogId_partNumber: { catalogId, partNumber: part.partNumber } },
|
||||
});
|
||||
|
||||
if (!partRecord) {
|
||||
partRecord = await this.prisma.emexPart.create({
|
||||
data: {
|
||||
catalogId,
|
||||
partNumber: part.partNumber,
|
||||
name: part.name || part.partNumber,
|
||||
categoryId: categoryRecord.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await this.prisma.emexVehiclePart.upsert({
|
||||
where: {
|
||||
vehicleId_partId_categoryId: {
|
||||
vehicleId,
|
||||
partId: partRecord.id,
|
||||
categoryId: categoryRecord.id,
|
||||
},
|
||||
},
|
||||
update: { position: part.position },
|
||||
create: {
|
||||
vehicleId,
|
||||
partId: partRecord.id,
|
||||
categoryId: categoryRecord.id,
|
||||
position: part.position,
|
||||
},
|
||||
});
|
||||
saved++;
|
||||
} catch (err) {
|
||||
console.log(` [DB] Part error: ${err.message.substring(0, 50)}`);
|
||||
}
|
||||
}
|
||||
if (saved > 0) {
|
||||
console.log(` [DB] Saved ${saved}/${parts.length} parts`);
|
||||
}
|
||||
}
|
||||
|
||||
printSummary() {
|
||||
const duration = ((this.stats.endTime - this.stats.startTime) / 1000).toFixed(1);
|
||||
|
||||
console.log('\n' + '═'.repeat(70));
|
||||
console.log('SCRAPING COMPLETED');
|
||||
console.log('═'.repeat(70));
|
||||
console.log(`VIN: ${this.stats.vin}`);
|
||||
console.log(`Vehicle: ${this.stats.vehicle?.model || 'Unknown'}`);
|
||||
console.log(`Catalog: ${this.stats.catalogCode}`);
|
||||
console.log(`Duration: ${duration}s`);
|
||||
console.log(`Categories: ${this.stats.categoriesCompleted}/${this.stats.categoriesTotal} (${this.stats.categoriesFailed} failed)`);
|
||||
console.log(`Parts: ${this.stats.partsTotal}`);
|
||||
console.log(`Retries: ${this.stats.retries}`);
|
||||
console.log('═'.repeat(70));
|
||||
}
|
||||
|
||||
delay(ms) {
|
||||
return new Promise(r => setTimeout(r, ms));
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CLI
|
||||
// ============================================================================
|
||||
|
||||
async function main() {
|
||||
const vin = process.argv[2];
|
||||
|
||||
if (!vin) {
|
||||
console.log('Usage: node emex-parallel-scraper.js <VIN>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const scraper = new EmexParallelScraper();
|
||||
const result = await scraper.scrapeVIN(vin);
|
||||
|
||||
process.exit(result.success ? 0 : 1);
|
||||
}
|
||||
|
||||
module.exports = { EmexParallelScraper };
|
||||
|
||||
if (require.main === module) {
|
||||
main().catch(err => {
|
||||
console.error('Fatal:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
201
apps/api/scripts/scrape-parts.js
Normal file
201
apps/api/scripts/scrape-parts.js
Normal file
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* Scrape Parts for All Categories
|
||||
* Fetches parts for each category and saves to database
|
||||
*/
|
||||
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const { EmexVinScraper } = require('../../../scripts/emex-vin-scraper');
|
||||
|
||||
const PARALLEL_CATEGORIES = 1; // Sıralı işlem - browser paylaşıldığı için
|
||||
|
||||
async function main() {
|
||||
console.log('═'.repeat(60));
|
||||
console.log('EMEX Parts Scraper - Parallel Category Processing');
|
||||
console.log('═'.repeat(60));
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
const scraper = new EmexVinScraper({ useProxy: true });
|
||||
|
||||
const stats = {
|
||||
startTime: Date.now(),
|
||||
categoriesProcessed: 0,
|
||||
categoriesTotal: 0,
|
||||
partsFound: 0,
|
||||
partsSaved: 0,
|
||||
errors: 0,
|
||||
};
|
||||
|
||||
try {
|
||||
// Initialize scraper
|
||||
console.log('\n[1/4] Initializing scraper with proxy...');
|
||||
await scraper.init();
|
||||
|
||||
// Get all unscraped categories
|
||||
console.log('[2/4] Getting unscraped categories...');
|
||||
const vehicleCategories = await prisma.emexVehicleCategory.findMany({
|
||||
where: { isScraped: false },
|
||||
include: {
|
||||
category: true,
|
||||
vehicle: true,
|
||||
},
|
||||
take: 100, // Process 30 at a time
|
||||
});
|
||||
|
||||
stats.categoriesTotal = vehicleCategories.length;
|
||||
console.log(` Found ${stats.categoriesTotal} categories to scrape\n`);
|
||||
|
||||
if (stats.categoriesTotal === 0) {
|
||||
console.log('No categories to scrape!');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get catalog info
|
||||
const firstCategory = vehicleCategories[0];
|
||||
const catalogId = firstCategory.category.catalogId;
|
||||
|
||||
// Process categories in parallel batches
|
||||
console.log('[3/4] Scraping parts in parallel batches...');
|
||||
console.log(` Parallel batch size: ${PARALLEL_CATEGORIES}\n`);
|
||||
|
||||
const batches = [];
|
||||
for (let i = 0; i < vehicleCategories.length; i += PARALLEL_CATEGORIES) {
|
||||
batches.push(vehicleCategories.slice(i, i + PARALLEL_CATEGORIES));
|
||||
}
|
||||
|
||||
for (let batchIdx = 0; batchIdx < batches.length; batchIdx++) {
|
||||
const batch = batches[batchIdx];
|
||||
console.log(`\n── Batch ${batchIdx + 1}/${batches.length} (${batch.length} categories) ──`);
|
||||
|
||||
const promises = batch.map(async (vc) => {
|
||||
const category = vc.category;
|
||||
const vehicle = vc.vehicle;
|
||||
|
||||
try {
|
||||
// Build QuickDetails URL
|
||||
const detailsUrl = category.schemaImageUrl;
|
||||
if (!detailsUrl) {
|
||||
console.log(` ⚠ ${category.name}: No URL`);
|
||||
return { success: false, categoryId: category.id, error: 'No URL' };
|
||||
}
|
||||
|
||||
// Fetch parts
|
||||
const parts = await scraper.getParts(detailsUrl);
|
||||
stats.partsFound += parts.length;
|
||||
|
||||
console.log(` ✓ ${category.name}: ${parts.length} parts`);
|
||||
|
||||
// Save parts to database
|
||||
for (const part of parts) {
|
||||
try {
|
||||
// Check if part exists
|
||||
let partRecord = await prisma.emexPart.findUnique({
|
||||
where: {
|
||||
catalogId_partNumber: {
|
||||
catalogId,
|
||||
partNumber: part.oemCode || part.partNumber || 'UNKNOWN',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!partRecord) {
|
||||
// Create new part
|
||||
partRecord = await prisma.emexPart.create({
|
||||
data: {
|
||||
catalogId,
|
||||
partNumber: part.oemCode || part.partNumber || 'UNKNOWN',
|
||||
name: part.nameEn || part.name || 'Unknown Part',
|
||||
categoryId: category.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Create vehicle-part link
|
||||
await prisma.emexVehiclePart.upsert({
|
||||
where: {
|
||||
vehicleId_partId_categoryId: {
|
||||
vehicleId: vehicle.id,
|
||||
partId: partRecord.id,
|
||||
categoryId: category.id,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
position: part.positionCode || null,
|
||||
},
|
||||
create: {
|
||||
vehicleId: vehicle.id,
|
||||
partId: partRecord.id,
|
||||
categoryId: category.id,
|
||||
position: part.positionCode || null,
|
||||
},
|
||||
});
|
||||
|
||||
stats.partsSaved++;
|
||||
} catch (partErr) {
|
||||
// Skip duplicate errors
|
||||
if (!partErr.message.includes('Unique constraint')) {
|
||||
console.log(` Part error: ${partErr.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mark category as scraped
|
||||
await prisma.emexVehicleCategory.update({
|
||||
where: { id: vc.id },
|
||||
data: { isScraped: true, updatedAt: new Date() },
|
||||
});
|
||||
|
||||
stats.categoriesProcessed++;
|
||||
return { success: true, categoryId: category.id, parts: parts.length };
|
||||
|
||||
} catch (err) {
|
||||
stats.errors++;
|
||||
console.log(` ✗ ${category.name}: ${err.message}`);
|
||||
return { success: false, categoryId: category.id, error: err.message };
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for batch to complete
|
||||
await Promise.all(promises);
|
||||
|
||||
// Rate limiting between batches
|
||||
if (batchIdx < batches.length - 1) {
|
||||
console.log(' Waiting 1s before next category...');
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
}
|
||||
}
|
||||
|
||||
// Final stats
|
||||
const duration = ((Date.now() - stats.startTime) / 1000).toFixed(1);
|
||||
|
||||
console.log('\n' + '═'.repeat(60));
|
||||
console.log('✅ SCRAPING COMPLETED');
|
||||
console.log('═'.repeat(60));
|
||||
console.log(`\nStatistics:`);
|
||||
console.log(` Duration: ${duration}s`);
|
||||
console.log(` Categories Processed: ${stats.categoriesProcessed}/${stats.categoriesTotal}`);
|
||||
console.log(` Parts Found: ${stats.partsFound}`);
|
||||
console.log(` Parts Saved: ${stats.partsSaved}`);
|
||||
console.log(` Errors: ${stats.errors}`);
|
||||
|
||||
// Verify in database
|
||||
console.log('\n[4/4] Verifying database...');
|
||||
const dbStats = await prisma.$queryRaw`
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM emex_parts) as parts,
|
||||
(SELECT COUNT(*) FROM emex_vehicle_parts) as vehicle_parts,
|
||||
(SELECT COUNT(*) FROM emex_vehicle_categories WHERE isScraped = 1) as scraped_categories
|
||||
`;
|
||||
console.log(` Parts in DB: ${dbStats[0].parts}`);
|
||||
console.log(` Vehicle-Part links: ${dbStats[0].vehicle_parts}`);
|
||||
console.log(` Scraped categories: ${dbStats[0].scraped_categories}`);
|
||||
|
||||
} catch (error) {
|
||||
console.error('\n❌ Error:', error.message);
|
||||
console.error(error.stack);
|
||||
} finally {
|
||||
await scraper.close();
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
53
apps/api/scripts/test-parts.js
Normal file
53
apps/api/scripts/test-parts.js
Normal file
@@ -0,0 +1,53 @@
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const { EmexVinScraper } = require('../../../scripts/emex-vin-scraper');
|
||||
|
||||
async function main() {
|
||||
const prisma = new PrismaClient();
|
||||
const scraper = new EmexVinScraper({ useProxy: true });
|
||||
|
||||
try {
|
||||
console.log('Initializing scraper...');
|
||||
await scraper.init();
|
||||
|
||||
console.log('\nSearching for VIN...');
|
||||
const result = await scraper.searchByVIN('WF0RXXGCDRAM33635');
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'VIN search failed');
|
||||
}
|
||||
|
||||
console.log('Vehicle:', result.vehicle?.model);
|
||||
console.log('Catalog:', result.catalogCode);
|
||||
|
||||
// Get categories
|
||||
if (result.allVehicles?.[0]?.quickGroupsUrl) {
|
||||
console.log('\nFetching categories...');
|
||||
const categories = await scraper.getCategories(result.allVehicles[0].quickGroupsUrl);
|
||||
console.log('Found', categories.length, 'categories');
|
||||
|
||||
if (categories.length > 0) {
|
||||
// Test parts parsing on first category
|
||||
const cat = categories[0];
|
||||
console.log('\nTesting parts parsing on:', cat.name);
|
||||
console.log('URL:', cat.url?.substring(0, 80) + '...');
|
||||
|
||||
const parts = await scraper.getParts(cat.url);
|
||||
console.log('\nParts found:', parts.length);
|
||||
|
||||
if (parts.length > 0) {
|
||||
console.log('\nSample parts:');
|
||||
parts.slice(0, 10).forEach(p => {
|
||||
console.log(' -', p.partNumber || p.oemCode, ':', (p.nameEn || p.name)?.substring(0, 40));
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error:', err.message);
|
||||
} finally {
|
||||
await scraper.close();
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
257
apps/api/scripts/test-vin-scrape.js
Normal file
257
apps/api/scripts/test-vin-scrape.js
Normal file
@@ -0,0 +1,257 @@
|
||||
/**
|
||||
* Full VIN Scrape Test with Database Save
|
||||
* Uses the EMEX scraper with proxy and saves to new Prisma schema
|
||||
*/
|
||||
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const { EmexVinScraper, getCatalogCode, getYearFromVIN } = require('../../../scripts/emex-vin-scraper');
|
||||
|
||||
// Category translations (subset for testing)
|
||||
const CATEGORY_TRANSLATIONS = {
|
||||
'Air Filter': 'Hava Filtresi',
|
||||
'Brake Discs': 'Fren Diskleri',
|
||||
'Brake Pads': 'Fren Balataları',
|
||||
'Engine': 'Motor',
|
||||
'Exhaust System': 'Egzoz Sistemi',
|
||||
'Fuel System': 'Yakıt Sistemi',
|
||||
'Steering': 'Direksiyon',
|
||||
'Suspension': 'Süspansiyon',
|
||||
'Transmission': 'Şanzıman',
|
||||
'Electrical': 'Elektrik',
|
||||
'Body': 'Gövde',
|
||||
'Interior': 'İç Mekan',
|
||||
};
|
||||
|
||||
function translateCategory(name) {
|
||||
if (!name) return 'Bilinmiyor';
|
||||
|
||||
// Direct match
|
||||
if (CATEGORY_TRANSLATIONS[name]) {
|
||||
return CATEGORY_TRANSLATIONS[name];
|
||||
}
|
||||
|
||||
// Partial match
|
||||
const lowerName = name.toLowerCase();
|
||||
for (const [en, tr] of Object.entries(CATEGORY_TRANSLATIONS)) {
|
||||
if (lowerName.includes(en.toLowerCase())) {
|
||||
return tr;
|
||||
}
|
||||
}
|
||||
|
||||
return name; // Return original if no translation
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const VIN = process.argv[2] || 'WF0RXXGCDRAM33635';
|
||||
|
||||
console.log('═'.repeat(60));
|
||||
console.log('EMEX VIN Scrape + Database Save Test');
|
||||
console.log('═'.repeat(60));
|
||||
console.log(`VIN: ${VIN}`);
|
||||
console.log(`Year: ${getYearFromVIN(VIN)}`);
|
||||
console.log(`Catalog: ${getCatalogCode(VIN)}`);
|
||||
console.log('');
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
const scraper = new EmexVinScraper({ useProxy: true });
|
||||
|
||||
try {
|
||||
// Step 1: Initialize scraper
|
||||
console.log('[1/6] Initializing scraper with proxy...');
|
||||
await scraper.init();
|
||||
|
||||
// Step 2: Search by VIN
|
||||
console.log('[2/6] Searching VIN...');
|
||||
const result = await scraper.searchByVIN(VIN);
|
||||
|
||||
console.log(`\n Success: ${result.success}`);
|
||||
console.log(` Method: ${result.method}`);
|
||||
console.log(` SSD: ${result.ssd || 'N/A'}`);
|
||||
console.log(` Vehicle: ${result.vehicle?.model || 'N/A'}`);
|
||||
console.log(` Vehicles found: ${result.allVehicles?.length || 0}`);
|
||||
|
||||
if (!result.success) {
|
||||
console.log(`\n❌ Failed: ${result.message || result.error}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 3: Get categories for first vehicle
|
||||
console.log('\n[3/6] Fetching categories...');
|
||||
let categories = [];
|
||||
|
||||
if (result.allVehicles && result.allVehicles.length > 0) {
|
||||
const firstVehicle = result.allVehicles[0];
|
||||
if (firstVehicle.quickGroupsUrl) {
|
||||
console.log(` Getting categories from: ${firstVehicle.quickGroupsUrl.substring(0, 50)}...`);
|
||||
|
||||
try {
|
||||
categories = await scraper.getCategories(firstVehicle.quickGroupsUrl);
|
||||
console.log(` Found ${categories.length} categories`);
|
||||
} catch (err) {
|
||||
console.log(` Error getting categories: ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 4: Save to database
|
||||
console.log('\n[4/6] Saving to database...');
|
||||
|
||||
const catalogCode = getCatalogCode(VIN);
|
||||
const brandCode = catalogCode.replace(/\d+/g, '');
|
||||
|
||||
// Create or get catalog
|
||||
const catalog = await prisma.emexCatalog.upsert({
|
||||
where: { code: catalogCode },
|
||||
update: { updatedAt: new Date() },
|
||||
create: {
|
||||
code: catalogCode,
|
||||
name: `${brandCode} Catalog`,
|
||||
brandCode,
|
||||
supportVinSearch: true,
|
||||
supportQuickGroups: true,
|
||||
},
|
||||
});
|
||||
console.log(` Catalog ID: ${catalog.id}`);
|
||||
|
||||
// Create vehicle
|
||||
const vehicleName = result.vehicle?.model || `${brandCode} Vehicle`;
|
||||
const ssd = result.ssd || '';
|
||||
const uniqueKey = `${VIN}_${ssd.substring(0, 30)}`;
|
||||
|
||||
const vehicle = await prisma.emexVehicle.upsert({
|
||||
where: {
|
||||
catalogId_uniqueKey: {
|
||||
catalogId: catalog.id,
|
||||
uniqueKey,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
name: vehicleName,
|
||||
engine: result.vehicle?.engineCode || null,
|
||||
ssd,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
create: {
|
||||
catalogId: catalog.id,
|
||||
name: vehicleName,
|
||||
engine: result.vehicle?.engineCode || null,
|
||||
ssd,
|
||||
uniqueKey,
|
||||
},
|
||||
});
|
||||
console.log(` Vehicle ID: ${vehicle.id}`);
|
||||
console.log(` Vehicle Name: ${vehicle.name}`);
|
||||
|
||||
// Save categories
|
||||
console.log('\n[5/6] Saving categories...');
|
||||
let savedCategories = 0;
|
||||
|
||||
for (const cat of categories) {
|
||||
const groupId = String(cat.gid || cat.id);
|
||||
const name = cat.name || 'Unknown';
|
||||
const nameTr = translateCategory(name);
|
||||
|
||||
await prisma.emexCategory.upsert({
|
||||
where: {
|
||||
catalogId_groupId: {
|
||||
catalogId: catalog.id,
|
||||
groupId,
|
||||
},
|
||||
},
|
||||
update: { name, nameTr, updatedAt: new Date() },
|
||||
create: {
|
||||
catalogId: catalog.id,
|
||||
groupId,
|
||||
name,
|
||||
nameTr,
|
||||
hasParts: true,
|
||||
schemaImageUrl: cat.url || null,
|
||||
},
|
||||
});
|
||||
|
||||
// Link category to vehicle
|
||||
const categoryRecord = await prisma.emexCategory.findUnique({
|
||||
where: {
|
||||
catalogId_groupId: {
|
||||
catalogId: catalog.id,
|
||||
groupId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (categoryRecord) {
|
||||
await prisma.emexVehicleCategory.upsert({
|
||||
where: {
|
||||
vehicleId_categoryId: {
|
||||
vehicleId: vehicle.id,
|
||||
categoryId: categoryRecord.id,
|
||||
},
|
||||
},
|
||||
update: { updatedAt: new Date() },
|
||||
create: {
|
||||
vehicleId: vehicle.id,
|
||||
categoryId: categoryRecord.id,
|
||||
isScraped: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
savedCategories++;
|
||||
}
|
||||
console.log(` Saved ${savedCategories} categories`);
|
||||
|
||||
// Create session record
|
||||
console.log('\n[6/6] Creating session record...');
|
||||
const session = await prisma.emexScrapeSession.create({
|
||||
data: {
|
||||
catalogId: catalog.id,
|
||||
brandCode,
|
||||
status: 'COMPLETED',
|
||||
totalItems: categories.length,
|
||||
processedItems: savedCategories,
|
||||
startedAt: new Date(),
|
||||
completedAt: new Date(),
|
||||
stats: {
|
||||
vin: VIN,
|
||||
ssd,
|
||||
vehicleName: vehicle.name,
|
||||
categoriesFound: categories.length,
|
||||
method: result.method,
|
||||
},
|
||||
},
|
||||
});
|
||||
console.log(` Session ID: ${session.id}`);
|
||||
|
||||
console.log('\n' + '═'.repeat(60));
|
||||
console.log('✅ TEST COMPLETED SUCCESSFULLY!');
|
||||
console.log('═'.repeat(60));
|
||||
|
||||
// Summary
|
||||
console.log('\nSummary:');
|
||||
console.log(` VIN: ${VIN}`);
|
||||
console.log(` Brand: ${brandCode}`);
|
||||
console.log(` Vehicle: ${vehicle.name}`);
|
||||
console.log(` Categories: ${savedCategories}`);
|
||||
console.log(` Catalog ID: ${catalog.id}`);
|
||||
console.log(` Vehicle ID: ${vehicle.id}`);
|
||||
console.log(` Session ID: ${session.id}`);
|
||||
|
||||
// Show some categories
|
||||
if (categories.length > 0) {
|
||||
console.log('\nFirst 10 categories:');
|
||||
categories.slice(0, 10).forEach((cat, i) => {
|
||||
const nameTr = translateCategory(cat.name);
|
||||
console.log(` ${i + 1}. ${cat.name} → ${nameTr}`);
|
||||
});
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('\n❌ Error:', error.message);
|
||||
console.error(error.stack);
|
||||
} finally {
|
||||
await scraper.close();
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
385
apps/api/src/integrations/emex/data/category-translations.ts
Normal file
385
apps/api/src/integrations/emex/data/category-translations.ts
Normal file
@@ -0,0 +1,385 @@
|
||||
/**
|
||||
* EMEX Category Translation Mapping
|
||||
* English to Turkish translations for automotive part categories
|
||||
*/
|
||||
|
||||
export interface CategoryTranslation {
|
||||
nameEn: string;
|
||||
nameTr: string;
|
||||
synonyms?: string[]; // Alternative English names that map to the same translation
|
||||
}
|
||||
|
||||
export const CATEGORY_TRANSLATIONS: CategoryTranslation[] = [
|
||||
// Engine & Drivetrain
|
||||
{ nameEn: 'Engine', nameTr: 'Motor', synonyms: ['Motor', 'Power Unit'] },
|
||||
{ nameEn: 'Engine Parts', nameTr: 'Motor Parcalari' },
|
||||
{ nameEn: 'Cylinder Head', nameTr: 'Silindir Kapagi' },
|
||||
{ nameEn: 'Cylinder Block', nameTr: 'Silindir Blogu' },
|
||||
{ nameEn: 'Crankshaft', nameTr: 'Krank Mili' },
|
||||
{ nameEn: 'Camshaft', nameTr: 'Eksantrik Mili' },
|
||||
{ nameEn: 'Piston', nameTr: 'Piston' },
|
||||
{ nameEn: 'Connecting Rod', nameTr: 'Biyel Kolu' },
|
||||
{ nameEn: 'Valve', nameTr: 'Supap' },
|
||||
{ nameEn: 'Valve Train', nameTr: 'Supap Mekanizmasi' },
|
||||
{ nameEn: 'Timing Belt', nameTr: 'Triger Kayisi' },
|
||||
{ nameEn: 'Timing Chain', nameTr: 'Triger Zinciri' },
|
||||
{ nameEn: 'Engine Gasket', nameTr: 'Motor Contasi' },
|
||||
{ nameEn: 'Head Gasket', nameTr: 'Silindir Kapak Contasi' },
|
||||
{ nameEn: 'Oil Sump', nameTr: 'Yag Karteri' },
|
||||
{ nameEn: 'Oil Pan', nameTr: 'Yag Teknesi' },
|
||||
{ nameEn: 'Engine Mount', nameTr: 'Motor Kulagi', synonyms: ['Motor Mounting'] },
|
||||
{ nameEn: 'Engine Cover', nameTr: 'Motor Kapagi' },
|
||||
|
||||
// Transmission
|
||||
{ nameEn: 'Transmission', nameTr: 'Sanziman', synonyms: ['Gearbox'] },
|
||||
{ nameEn: 'Manual Transmission', nameTr: 'Manuel Sanziman' },
|
||||
{ nameEn: 'Automatic Transmission', nameTr: 'Otomatik Sanziman' },
|
||||
{ nameEn: 'Clutch', nameTr: 'Debriyaj', synonyms: ['Clutch Kit'] },
|
||||
{ nameEn: 'Clutch Disc', nameTr: 'Debriyaj Balatasi' },
|
||||
{ nameEn: 'Clutch Pressure Plate', nameTr: 'Debriyaj Baskisi' },
|
||||
{ nameEn: 'Clutch Release Bearing', nameTr: 'Debriyaj Bilyasi' },
|
||||
{ nameEn: 'Flywheel', nameTr: 'Volan' },
|
||||
{ nameEn: 'Drive Shaft', nameTr: 'Saft', synonyms: ['Propeller Shaft'] },
|
||||
{ nameEn: 'CV Joint', nameTr: 'Aks Kafasi' },
|
||||
{ nameEn: 'Axle', nameTr: 'Aks' },
|
||||
{ nameEn: 'Differential', nameTr: 'Diferansiyel' },
|
||||
{ nameEn: 'Transfer Case', nameTr: 'Aktarma Kutusu' },
|
||||
|
||||
// Brake System
|
||||
{ nameEn: 'Brake System', nameTr: 'Fren Sistemi', synonyms: ['Brakes'] },
|
||||
{ nameEn: 'Brake Pad', nameTr: 'Fren Balatasi', synonyms: ['Brake Pads'] },
|
||||
{ nameEn: 'Brake Disc', nameTr: 'Fren Diski', synonyms: ['Brake Rotor'] },
|
||||
{ nameEn: 'Brake Drum', nameTr: 'Fren Kampanasi' },
|
||||
{ nameEn: 'Brake Caliper', nameTr: 'Fren Kaliperi' },
|
||||
{ nameEn: 'Brake Cylinder', nameTr: 'Fren Silindiri' },
|
||||
{ nameEn: 'Master Cylinder', nameTr: 'Ana Merkez' },
|
||||
{ nameEn: 'Brake Hose', nameTr: 'Fren Hortumu' },
|
||||
{ nameEn: 'Brake Line', nameTr: 'Fren Borusu' },
|
||||
{ nameEn: 'Brake Booster', nameTr: 'Fren Takviye' },
|
||||
{ nameEn: 'ABS', nameTr: 'ABS Sistemi' },
|
||||
{ nameEn: 'Parking Brake', nameTr: 'El Freni', synonyms: ['Handbrake'] },
|
||||
|
||||
// Suspension
|
||||
{ nameEn: 'Suspension', nameTr: 'Suspansiyon', synonyms: ['Suspension System'] },
|
||||
{ nameEn: 'Shock Absorber', nameTr: 'Amortisör', synonyms: ['Damper', 'Strut'] },
|
||||
{ nameEn: 'Coil Spring', nameTr: 'Yay', synonyms: ['Spring'] },
|
||||
{ nameEn: 'Leaf Spring', nameTr: 'Yaprak Yay' },
|
||||
{ nameEn: 'Control Arm', nameTr: 'Salincak', synonyms: ['Wishbone'] },
|
||||
{ nameEn: 'Ball Joint', nameTr: 'Rotil' },
|
||||
{ nameEn: 'Tie Rod', nameTr: 'Rot', synonyms: ['Track Rod'] },
|
||||
{ nameEn: 'Tie Rod End', nameTr: 'Rot Basi' },
|
||||
{ nameEn: 'Stabilizer Bar', nameTr: 'Viraj Demiri', synonyms: ['Sway Bar', 'Anti Roll Bar'] },
|
||||
{ nameEn: 'Stabilizer Link', nameTr: 'Viraj Rotu' },
|
||||
{ nameEn: 'Bushing', nameTr: 'Burç' },
|
||||
{ nameEn: 'Wheel Bearing', nameTr: 'Tekerlek Bilyasi' },
|
||||
{ nameEn: 'Wheel Hub', nameTr: 'Tekerlek Gobeği' },
|
||||
{ nameEn: 'Steering Knuckle', nameTr: 'Aks Taşıyıcı' },
|
||||
|
||||
// Steering
|
||||
{ nameEn: 'Steering', nameTr: 'Direksiyon', synonyms: ['Steering System'] },
|
||||
{ nameEn: 'Steering Rack', nameTr: 'Direksiyon Kutusu' },
|
||||
{ nameEn: 'Steering Column', nameTr: 'Direksiyon Kolonu' },
|
||||
{ nameEn: 'Power Steering', nameTr: 'Hidrolik Direksiyon' },
|
||||
{ nameEn: 'Power Steering Pump', nameTr: 'Direksiyon Pompasi' },
|
||||
{ nameEn: 'Steering Wheel', nameTr: 'Direksiyon Simidi' },
|
||||
|
||||
// Cooling System
|
||||
{ nameEn: 'Cooling System', nameTr: 'Sogutma Sistemi', synonyms: ['Cooling'] },
|
||||
{ nameEn: 'Radiator', nameTr: 'Radyator' },
|
||||
{ nameEn: 'Water Pump', nameTr: 'Su Pompasi', synonyms: ['Coolant Pump'] },
|
||||
{ nameEn: 'Thermostat', nameTr: 'Termostat' },
|
||||
{ nameEn: 'Coolant Hose', nameTr: 'Sogutma Hortumu' },
|
||||
{ nameEn: 'Radiator Fan', nameTr: 'Fan', synonyms: ['Cooling Fan'] },
|
||||
{ nameEn: 'Fan Clutch', nameTr: 'Fan Kuplaji' },
|
||||
{ nameEn: 'Expansion Tank', nameTr: 'Genlesme Tanki' },
|
||||
{ nameEn: 'Intercooler', nameTr: 'Intercooler' },
|
||||
{ nameEn: 'Oil Cooler', nameTr: 'Yag Sogutucusu' },
|
||||
|
||||
// Fuel System
|
||||
{ nameEn: 'Fuel System', nameTr: 'Yakit Sistemi', synonyms: ['Fuel'] },
|
||||
{ nameEn: 'Fuel Pump', nameTr: 'Yakit Pompasi' },
|
||||
{ nameEn: 'Fuel Injector', nameTr: 'Enjektör' },
|
||||
{ nameEn: 'Fuel Filter', nameTr: 'Yakit Filtresi' },
|
||||
{ nameEn: 'Fuel Tank', nameTr: 'Yakit Deposu' },
|
||||
{ nameEn: 'Carburetor', nameTr: 'Karbüratör' },
|
||||
{ nameEn: 'Throttle Body', nameTr: 'Gaz Kelebeği' },
|
||||
{ nameEn: 'Fuel Rail', nameTr: 'Yakit Rampası' },
|
||||
{ nameEn: 'Fuel Line', nameTr: 'Yakit Hortumu' },
|
||||
{ nameEn: 'Fuel Sender', nameTr: 'Yakit Göstergesi Sensörü' },
|
||||
|
||||
// Exhaust System
|
||||
{ nameEn: 'Exhaust System', nameTr: 'Egzoz Sistemi', synonyms: ['Exhaust'] },
|
||||
{ nameEn: 'Exhaust Manifold', nameTr: 'Egzoz Manifoldu' },
|
||||
{ nameEn: 'Exhaust Pipe', nameTr: 'Egzoz Borusu' },
|
||||
{ nameEn: 'Muffler', nameTr: 'Egzoz Susturucu', synonyms: ['Silencer'] },
|
||||
{ nameEn: 'Catalytic Converter', nameTr: 'Katalitik Konvertör' },
|
||||
{ nameEn: 'Oxygen Sensor', nameTr: 'Oksijen Sensörü', synonyms: ['Lambda Sensor'] },
|
||||
{ nameEn: 'EGR Valve', nameTr: 'EGR Valfi' },
|
||||
{ nameEn: 'DPF', nameTr: 'Partikül Filtresi', synonyms: ['Diesel Particulate Filter'] },
|
||||
{ nameEn: 'Turbocharger', nameTr: 'Turbo', synonyms: ['Turbo'] },
|
||||
|
||||
// Air Intake
|
||||
{ nameEn: 'Air Intake', nameTr: 'Hava Emis Sistemi' },
|
||||
{ nameEn: 'Air Filter', nameTr: 'Hava Filtresi' },
|
||||
{ nameEn: 'Air Filter Box', nameTr: 'Hava Filtre Kutusu' },
|
||||
{ nameEn: 'Intake Manifold', nameTr: 'Emme Manifoldu' },
|
||||
{ nameEn: 'Mass Air Flow', nameTr: 'Hava Akis Sensörü', synonyms: ['MAF Sensor'] },
|
||||
{ nameEn: 'Air Duct', nameTr: 'Hava Kanalı' },
|
||||
|
||||
// Electrical System
|
||||
{ nameEn: 'Electrical System', nameTr: 'Elektrik Sistemi', synonyms: ['Electrical'] },
|
||||
{ nameEn: 'Battery', nameTr: 'Akü' },
|
||||
{ nameEn: 'Alternator', nameTr: 'Sarj Dinamosu', synonyms: ['Generator'] },
|
||||
{ nameEn: 'Starter Motor', nameTr: 'Marş Motoru', synonyms: ['Starter'] },
|
||||
{ nameEn: 'Ignition Coil', nameTr: 'Ateşleme Bobini' },
|
||||
{ nameEn: 'Spark Plug', nameTr: 'Buji' },
|
||||
{ nameEn: 'Glow Plug', nameTr: 'Kızdırma Bujisi' },
|
||||
{ nameEn: 'Distributor', nameTr: 'Distribütör' },
|
||||
{ nameEn: 'Ignition Switch', nameTr: 'Kontak Anahtarı' },
|
||||
{ nameEn: 'Fuse Box', nameTr: 'Sigorta Kutusu' },
|
||||
{ nameEn: 'Relay', nameTr: 'Röle' },
|
||||
{ nameEn: 'Wiring Harness', nameTr: 'Kablo Tesisatı' },
|
||||
|
||||
// Lighting
|
||||
{ nameEn: 'Lighting', nameTr: 'Aydınlatma', synonyms: ['Lights'] },
|
||||
{ nameEn: 'Headlight', nameTr: 'Far', synonyms: ['Headlamp'] },
|
||||
{ nameEn: 'Tail Light', nameTr: 'Stop Lambası', synonyms: ['Rear Light'] },
|
||||
{ nameEn: 'Fog Light', nameTr: 'Sis Lambası' },
|
||||
{ nameEn: 'Turn Signal', nameTr: 'Sinyal Lambası', synonyms: ['Indicator'] },
|
||||
{ nameEn: 'Brake Light', nameTr: 'Fren Lambası' },
|
||||
{ nameEn: 'Reverse Light', nameTr: 'Geri Vites Lambası' },
|
||||
{ nameEn: 'Side Marker', nameTr: 'Yan Sinyal' },
|
||||
{ nameEn: 'Bulb', nameTr: 'Ampul', synonyms: ['Light Bulb'] },
|
||||
{ nameEn: 'Daytime Running Light', nameTr: 'Gündüz Farı', synonyms: ['DRL'] },
|
||||
|
||||
// Body Parts
|
||||
{ nameEn: 'Body', nameTr: 'Karoseri', synonyms: ['Bodywork'] },
|
||||
{ nameEn: 'Bumper', nameTr: 'Tampon' },
|
||||
{ nameEn: 'Front Bumper', nameTr: 'Ön Tampon' },
|
||||
{ nameEn: 'Rear Bumper', nameTr: 'Arka Tampon' },
|
||||
{ nameEn: 'Fender', nameTr: 'Çamurluk', synonyms: ['Wing'] },
|
||||
{ nameEn: 'Hood', nameTr: 'Motor Kaputu', synonyms: ['Bonnet'] },
|
||||
{ nameEn: 'Trunk Lid', nameTr: 'Bagaj Kapağı', synonyms: ['Boot Lid'] },
|
||||
{ nameEn: 'Door', nameTr: 'Kapı' },
|
||||
{ nameEn: 'Door Panel', nameTr: 'Kapı Döşemesi' },
|
||||
{ nameEn: 'Door Handle', nameTr: 'Kapı Kolu' },
|
||||
{ nameEn: 'Door Lock', nameTr: 'Kapı Kilidi' },
|
||||
{ nameEn: 'Side Mirror', nameTr: 'Dış Ayna', synonyms: ['Wing Mirror', 'Door Mirror'] },
|
||||
{ nameEn: 'Roof', nameTr: 'Tavan' },
|
||||
{ nameEn: 'Rocker Panel', nameTr: 'Marşpiyel', synonyms: ['Sill'] },
|
||||
{ nameEn: 'Quarter Panel', nameTr: 'Arka Çamurluk' },
|
||||
{ nameEn: 'Pillar', nameTr: 'Direk' },
|
||||
{ nameEn: 'A Pillar', nameTr: 'A Direği' },
|
||||
{ nameEn: 'B Pillar', nameTr: 'B Direği' },
|
||||
{ nameEn: 'C Pillar', nameTr: 'C Direği' },
|
||||
|
||||
// Glass & Windshield
|
||||
{ nameEn: 'Windshield', nameTr: 'Ön Cam', synonyms: ['Windscreen'] },
|
||||
{ nameEn: 'Rear Window', nameTr: 'Arka Cam' },
|
||||
{ nameEn: 'Door Glass', nameTr: 'Kapı Camı', synonyms: ['Window Glass'] },
|
||||
{ nameEn: 'Side Glass', nameTr: 'Yan Cam' },
|
||||
{ nameEn: 'Sunroof', nameTr: 'Tavan Penceresi' },
|
||||
{ nameEn: 'Glass Seal', nameTr: 'Cam Fitili' },
|
||||
{ nameEn: 'Windshield Wiper', nameTr: 'Silecek', synonyms: ['Wiper'] },
|
||||
{ nameEn: 'Wiper Blade', nameTr: 'Silecek Lastiği' },
|
||||
{ nameEn: 'Wiper Motor', nameTr: 'Silecek Motoru' },
|
||||
{ nameEn: 'Washer', nameTr: 'Cam Yıkama' },
|
||||
{ nameEn: 'Washer Pump', nameTr: 'Cam Yıkama Pompası' },
|
||||
|
||||
// Grille & Trim
|
||||
{ nameEn: 'Grille', nameTr: 'Panjur', synonyms: ['Radiator Grille'] },
|
||||
{ nameEn: 'Emblem', nameTr: 'Amblem', synonyms: ['Badge'] },
|
||||
{ nameEn: 'Trim', nameTr: 'Kaplama', synonyms: ['Moulding'] },
|
||||
{ nameEn: 'Side Moulding', nameTr: 'Yan Çıta' },
|
||||
{ nameEn: 'Door Moulding', nameTr: 'Kapı Çıtası' },
|
||||
{ nameEn: 'Bumper Trim', nameTr: 'Tampon Çıtası' },
|
||||
{ nameEn: 'Wheel Arch', nameTr: 'Tekerlek Çamurluk Çıtası' },
|
||||
{ nameEn: 'Splash Guard', nameTr: 'Çamurluk Tozluğu', synonyms: ['Mud Flap'] },
|
||||
|
||||
// Interior
|
||||
{ nameEn: 'Interior', nameTr: 'İç Mekan' },
|
||||
{ nameEn: 'Dashboard', nameTr: 'Göğüs', synonyms: ['Instrument Panel'] },
|
||||
{ nameEn: 'Center Console', nameTr: 'Orta Konsol' },
|
||||
{ nameEn: 'Seat', nameTr: 'Koltuk' },
|
||||
{ nameEn: 'Front Seat', nameTr: 'Ön Koltuk' },
|
||||
{ nameEn: 'Rear Seat', nameTr: 'Arka Koltuk' },
|
||||
{ nameEn: 'Seat Belt', nameTr: 'Emniyet Kemeri' },
|
||||
{ nameEn: 'Headrest', nameTr: 'Koltuk Başlığı' },
|
||||
{ nameEn: 'Armrest', nameTr: 'Kol Dayama' },
|
||||
{ nameEn: 'Floor Mat', nameTr: 'Paspas' },
|
||||
{ nameEn: 'Carpet', nameTr: 'Halı' },
|
||||
{ nameEn: 'Headliner', nameTr: 'Tavan Döşemesi' },
|
||||
{ nameEn: 'Sun Visor', nameTr: 'Güneşlik' },
|
||||
{ nameEn: 'Rearview Mirror', nameTr: 'İç Ayna' },
|
||||
{ nameEn: 'Glove Box', nameTr: 'Torpido' },
|
||||
{ nameEn: 'Pedal', nameTr: 'Pedal' },
|
||||
{ nameEn: 'Gear Lever', nameTr: 'Vites Topuzu', synonyms: ['Shift Knob'] },
|
||||
{ nameEn: 'Handbrake Lever', nameTr: 'El Freni Kolu' },
|
||||
|
||||
// Climate Control
|
||||
{ nameEn: 'Climate Control', nameTr: 'Klima Kontrolü', synonyms: ['HVAC'] },
|
||||
{ nameEn: 'Air Conditioning', nameTr: 'Klima', synonyms: ['A/C', 'AC'] },
|
||||
{ nameEn: 'Heater', nameTr: 'Kalorifer' },
|
||||
{ nameEn: 'Heater Core', nameTr: 'Kalorifer Radyatörü' },
|
||||
{ nameEn: 'AC Compressor', nameTr: 'Klima Kompresörü' },
|
||||
{ nameEn: 'AC Condenser', nameTr: 'Klima Kondensörü' },
|
||||
{ nameEn: 'AC Evaporator', nameTr: 'Klima Evaporatörü' },
|
||||
{ nameEn: 'Cabin Filter', nameTr: 'Polen Filtresi', synonyms: ['Pollen Filter'] },
|
||||
{ nameEn: 'Blower Motor', nameTr: 'Kalorifer Motoru' },
|
||||
{ nameEn: 'AC Hose', nameTr: 'Klima Hortumu' },
|
||||
|
||||
// Audio & Electronics
|
||||
{ nameEn: 'Audio', nameTr: 'Ses Sistemi', synonyms: ['Radio'] },
|
||||
{ nameEn: 'Speaker', nameTr: 'Hoparlör' },
|
||||
{ nameEn: 'Amplifier', nameTr: 'Amplifikatör' },
|
||||
{ nameEn: 'Navigation', nameTr: 'Navigasyon', synonyms: ['GPS'] },
|
||||
{ nameEn: 'Display', nameTr: 'Ekran' },
|
||||
{ nameEn: 'Antenna', nameTr: 'Anten' },
|
||||
{ nameEn: 'USB Port', nameTr: 'USB Girişi' },
|
||||
{ nameEn: 'Parking Sensor', nameTr: 'Park Sensörü' },
|
||||
{ nameEn: 'Backup Camera', nameTr: 'Geri Görüş Kamerası' },
|
||||
{ nameEn: 'Cruise Control', nameTr: 'Hız Sabitleyici' },
|
||||
|
||||
// Instruments & Sensors
|
||||
{ nameEn: 'Instrument Cluster', nameTr: 'Gösterge Paneli' },
|
||||
{ nameEn: 'Speedometer', nameTr: 'Hız Göstergesi' },
|
||||
{ nameEn: 'Tachometer', nameTr: 'Devir Göstergesi' },
|
||||
{ nameEn: 'Odometer', nameTr: 'Kilometre Sayacı' },
|
||||
{ nameEn: 'Fuel Gauge', nameTr: 'Yakıt Göstergesi' },
|
||||
{ nameEn: 'Temperature Gauge', nameTr: 'Sıcaklık Göstergesi' },
|
||||
{ nameEn: 'Oil Pressure Gauge', nameTr: 'Yağ Basınç Göstergesi' },
|
||||
{ nameEn: 'Sensor', nameTr: 'Sensör' },
|
||||
{ nameEn: 'Speed Sensor', nameTr: 'Hız Sensörü' },
|
||||
{ nameEn: 'Temperature Sensor', nameTr: 'Sıcaklık Sensörü' },
|
||||
{ nameEn: 'Pressure Sensor', nameTr: 'Basınç Sensörü' },
|
||||
{ nameEn: 'Position Sensor', nameTr: 'Pozisyon Sensörü' },
|
||||
{ nameEn: 'Knock Sensor', nameTr: 'Vuruntu Sensörü' },
|
||||
{ nameEn: 'Crank Sensor', nameTr: 'Krank Sensörü' },
|
||||
{ nameEn: 'Cam Sensor', nameTr: 'Eksantrik Sensörü' },
|
||||
|
||||
// Wheels & Tires
|
||||
{ nameEn: 'Wheels', nameTr: 'Jant', synonyms: ['Rims'] },
|
||||
{ nameEn: 'Wheel', nameTr: 'Tekerlek' },
|
||||
{ nameEn: 'Alloy Wheel', nameTr: 'Alaşım Jant' },
|
||||
{ nameEn: 'Steel Wheel', nameTr: 'Çelik Jant' },
|
||||
{ nameEn: 'Wheel Cover', nameTr: 'Jant Kapağı', synonyms: ['Hubcap'] },
|
||||
{ nameEn: 'Lug Nut', nameTr: 'Bijon Somunu' },
|
||||
{ nameEn: 'Wheel Stud', nameTr: 'Bijon' },
|
||||
{ nameEn: 'Tire', nameTr: 'Lastik', synonyms: ['Tyre'] },
|
||||
{ nameEn: 'Spare Tire', nameTr: 'Stepne' },
|
||||
{ nameEn: 'TPMS', nameTr: 'Lastik Basınç Sensörü' },
|
||||
|
||||
// Filters & Maintenance
|
||||
{ nameEn: 'Oil Filter', nameTr: 'Yağ Filtresi' },
|
||||
{ nameEn: 'Fuel Filter', nameTr: 'Yakıt Filtresi' },
|
||||
{ nameEn: 'Air Filter', nameTr: 'Hava Filtresi' },
|
||||
{ nameEn: 'Cabin Filter', nameTr: 'Polen Filtresi' },
|
||||
{ nameEn: 'Transmission Filter', nameTr: 'Şanzıman Filtresi' },
|
||||
{ nameEn: 'Hydraulic Filter', nameTr: 'Hidrolik Filtresi' },
|
||||
|
||||
// Belts & Hoses
|
||||
{ nameEn: 'Belt', nameTr: 'Kayış' },
|
||||
{ nameEn: 'V Belt', nameTr: 'V Kayışı' },
|
||||
{ nameEn: 'Serpentine Belt', nameTr: 'Kanallı Kayış' },
|
||||
{ nameEn: 'Timing Belt', nameTr: 'Triger Kayışı' },
|
||||
{ nameEn: 'AC Belt', nameTr: 'Klima Kayışı' },
|
||||
{ nameEn: 'Tensioner', nameTr: 'Gergi Rulmanı' },
|
||||
{ nameEn: 'Idler Pulley', nameTr: 'Avara Kasnağı' },
|
||||
{ nameEn: 'Hose', nameTr: 'Hortum' },
|
||||
{ nameEn: 'Radiator Hose', nameTr: 'Radyatör Hortumu' },
|
||||
{ nameEn: 'Heater Hose', nameTr: 'Kalorifer Hortumu' },
|
||||
{ nameEn: 'Vacuum Hose', nameTr: 'Vakum Hortumu' },
|
||||
|
||||
// Locks & Keys
|
||||
{ nameEn: 'Lock', nameTr: 'Kilit' },
|
||||
{ nameEn: 'Door Lock', nameTr: 'Kapı Kilidi' },
|
||||
{ nameEn: 'Ignition Lock', nameTr: 'Kontak Kilidi' },
|
||||
{ nameEn: 'Trunk Lock', nameTr: 'Bagaj Kilidi' },
|
||||
{ nameEn: 'Hood Lock', nameTr: 'Kaput Kilidi' },
|
||||
{ nameEn: 'Key', nameTr: 'Anahtar' },
|
||||
{ nameEn: 'Remote Key', nameTr: 'Uzaktan Kumandalı Anahtar' },
|
||||
{ nameEn: 'Central Locking', nameTr: 'Merkezi Kilit' },
|
||||
|
||||
// Safety & Airbags
|
||||
{ nameEn: 'Safety', nameTr: 'Güvenlik' },
|
||||
{ nameEn: 'Airbag', nameTr: 'Hava Yastığı' },
|
||||
{ nameEn: 'Driver Airbag', nameTr: 'Sürücü Hava Yastığı' },
|
||||
{ nameEn: 'Passenger Airbag', nameTr: 'Yolcu Hava Yastığı' },
|
||||
{ nameEn: 'Side Airbag', nameTr: 'Yan Hava Yastığı' },
|
||||
{ nameEn: 'Curtain Airbag', nameTr: 'Perde Hava Yastığı' },
|
||||
{ nameEn: 'Seat Belt', nameTr: 'Emniyet Kemeri' },
|
||||
{ nameEn: 'Seat Belt Pretensioner', nameTr: 'Kemer Gergi' },
|
||||
{ nameEn: 'Impact Sensor', nameTr: 'Darbe Sensörü' },
|
||||
|
||||
// Misc
|
||||
{ nameEn: 'Accessories', nameTr: 'Aksesuarlar' },
|
||||
{ nameEn: 'Tool Kit', nameTr: 'Alet Takımı' },
|
||||
{ nameEn: 'Jack', nameTr: 'Kriko' },
|
||||
{ nameEn: 'Tow Hook', nameTr: 'Çeki Kancası' },
|
||||
{ nameEn: 'Trailer Hitch', nameTr: 'Römork Bağlantısı' },
|
||||
{ nameEn: 'Roof Rack', nameTr: 'Tavan Barı' },
|
||||
{ nameEn: 'Mud Guard', nameTr: 'Çamurluk', synonyms: ['Fender Liner'] },
|
||||
{ nameEn: 'Undercover', nameTr: 'Alt Koruma' },
|
||||
{ nameEn: 'Engine Undercover', nameTr: 'Motor Alt Muhafazası' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Creates a lookup map for fast translation lookups
|
||||
*/
|
||||
export function createTranslationMap(): Map<string, string> {
|
||||
const map = new Map<string, string>();
|
||||
|
||||
for (const translation of CATEGORY_TRANSLATIONS) {
|
||||
// Add main name
|
||||
map.set(translation.nameEn.toLowerCase(), translation.nameTr);
|
||||
|
||||
// Add synonyms
|
||||
if (translation.synonyms) {
|
||||
for (const synonym of translation.synonyms) {
|
||||
map.set(synonym.toLowerCase(), translation.nameTr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
// Pre-built translation map for performance
|
||||
export const TRANSLATION_MAP = createTranslationMap();
|
||||
|
||||
/**
|
||||
* Translates a category name from English to Turkish
|
||||
* @param nameEn English category name
|
||||
* @returns Turkish translation or original name if not found
|
||||
*/
|
||||
export function translateCategoryName(nameEn: string): string {
|
||||
if (!nameEn) return nameEn;
|
||||
|
||||
const normalized = nameEn.toLowerCase().trim();
|
||||
|
||||
// Exact match
|
||||
if (TRANSLATION_MAP.has(normalized)) {
|
||||
return TRANSLATION_MAP.get(normalized)!;
|
||||
}
|
||||
|
||||
// Partial match - check if any key is contained in the name
|
||||
for (const [key, value] of TRANSLATION_MAP) {
|
||||
if (normalized.includes(key) || key.includes(normalized)) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
// No translation found, return original
|
||||
return nameEn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all translations for database seeding
|
||||
*/
|
||||
export function getAllTranslations(): Array<{ nameEn: string; nameTr: string; synonyms?: string[] }> {
|
||||
return CATEGORY_TRANSLATIONS.map(t => ({
|
||||
nameEn: t.nameEn,
|
||||
nameTr: t.nameTr,
|
||||
synonyms: t.synonyms,
|
||||
}));
|
||||
}
|
||||
@@ -8,10 +8,8 @@
|
||||
import {
|
||||
EmexScraperResponse,
|
||||
EmexCategory,
|
||||
EmexPart,
|
||||
DecodedVehicle,
|
||||
DecodedCategory,
|
||||
DecodedPart,
|
||||
CATALOG_MAP,
|
||||
} from './emex.types';
|
||||
|
||||
@@ -370,7 +368,7 @@ export function mapEmexResponse(response: EmexScraperResponse): DecodedVehicle {
|
||||
driveType: vehicle.driveType || null,
|
||||
colorCode: null, // EMEX doesn't provide color info
|
||||
raw: buildRawResponse(response),
|
||||
categories: mapCategories(response.categories, response.sampleParts),
|
||||
categories: mapCategories(response.categories),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -416,12 +414,13 @@ function extractYearFromVin(vin: string): number {
|
||||
|
||||
/**
|
||||
* Builds the raw response object for storage
|
||||
* Includes category URLs for on-demand parts fetching
|
||||
*/
|
||||
function buildRawResponse(
|
||||
response: EmexScraperResponse,
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
source: response.source,
|
||||
source: 'emex', // Explicit source identifier for on-demand loading
|
||||
method: response.method,
|
||||
vin: response.vin,
|
||||
catalogCode: response.catalogCode,
|
||||
@@ -432,44 +431,37 @@ function buildRawResponse(
|
||||
message: response.message,
|
||||
parsedOptions: response.parsedOptions,
|
||||
rawResponse: response.rawResponse,
|
||||
// Store category URLs for on-demand parts fetching
|
||||
emexCategories: response.categories?.map((cat) => ({
|
||||
gid: cat.gid,
|
||||
name: cat.name,
|
||||
url: cat.url,
|
||||
})) || [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps EMEX categories to standardized DecodedCategory format
|
||||
* NOTE: Parts are NOT included here - they will be fetched on-demand when user clicks a category
|
||||
*/
|
||||
function mapCategories(
|
||||
categories?: EmexCategory[],
|
||||
sampleParts?: EmexPart[],
|
||||
): DecodedCategory[] {
|
||||
if (!categories || categories.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return categories.map((cat, index) => ({
|
||||
code: cat.gid || `CAT_${index}`,
|
||||
nameEn: cat.name,
|
||||
nameTr: translateCategoryName(cat.name),
|
||||
description: null,
|
||||
iconName: deriveIconName(cat.name),
|
||||
schemaImageUrl: null,
|
||||
parts:
|
||||
index === 0 && sampleParts ? mapParts(sampleParts) : ([] as DecodedPart[]),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps EMEX parts to standardized DecodedPart format
|
||||
*/
|
||||
function mapParts(parts: EmexPart[]): DecodedPart[] {
|
||||
return parts.map((part) => ({
|
||||
oemCode: part.oemCode,
|
||||
nameEn: part.nameEn,
|
||||
nameTr: translatePartName(part.nameEn),
|
||||
description: null,
|
||||
positionCode: part.positionCode,
|
||||
prices: [], // EMEX doesn't provide pricing
|
||||
}));
|
||||
return categories.map((cat, index) => {
|
||||
return {
|
||||
code: cat.gid || `CAT_${index}`,
|
||||
nameEn: cat.name,
|
||||
nameTr: translateCategoryName(cat.name),
|
||||
description: null,
|
||||
iconName: deriveIconName(cat.name),
|
||||
schemaImageUrl: null,
|
||||
parts: [], // Parts will be fetched on-demand
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,16 +2,19 @@
|
||||
* EMEX Integration Module
|
||||
*
|
||||
* NestJS module for emexdwc.ae VIN integration.
|
||||
* Provides EmexService for VIN decoding using the EMEX scraper.
|
||||
* Provides EmexService for VIN decoding using the EMEX scraper
|
||||
* and EmexParallelScraperService for parallel catalog scraping.
|
||||
*/
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { PrismaModule } from '../../prisma/prisma.module';
|
||||
import { EmexService } from './emex.service';
|
||||
import { EmexParallelScraperService } from './scraper/parallel-scraper.service';
|
||||
|
||||
@Module({
|
||||
imports: [ConfigModule],
|
||||
providers: [EmexService],
|
||||
exports: [EmexService],
|
||||
imports: [ConfigModule, PrismaModule],
|
||||
providers: [EmexService, EmexParallelScraperService],
|
||||
exports: [EmexService, EmexParallelScraperService],
|
||||
})
|
||||
export class EmexModule {}
|
||||
|
||||
@@ -32,12 +32,24 @@ interface EmexScraperModule {
|
||||
CONFIG: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface EmexCategoryResult {
|
||||
gid: string;
|
||||
name: string;
|
||||
url: string | null;
|
||||
}
|
||||
|
||||
interface EmexPartResult {
|
||||
oemCode: string;
|
||||
nameEn: string;
|
||||
positionCode?: string;
|
||||
}
|
||||
|
||||
interface EmexVinScraperInstance {
|
||||
init(): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
searchByVIN(vin: string): Promise<EmexScraperResponse>;
|
||||
getCategories(quickGroupsUrl: string): Promise<unknown[]>;
|
||||
getParts(detailsUrl: string): Promise<unknown[]>;
|
||||
getCategories(quickGroupsUrl: string): Promise<EmexCategoryResult[]>;
|
||||
getParts(detailsUrl: string): Promise<EmexPartResult[]>;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
@@ -53,10 +65,12 @@ export class EmexService implements OnModuleDestroy {
|
||||
private readonly debug: boolean;
|
||||
|
||||
constructor(private configService: ConfigService) {
|
||||
// Configure scraper path - relative to project root
|
||||
// Configure scraper path - use absolute path
|
||||
// Scripts are at: /www/wwwroot/sase.tr/scripts
|
||||
const defaultPath = '/www/wwwroot/sase.tr/scripts/emex-vin-scraper.js';
|
||||
this.scraperPath = this.configService.get<string>(
|
||||
'EMEX_SCRAPER_PATH',
|
||||
path.resolve(__dirname, '../../../../../scripts/emex-vin-scraper.js'),
|
||||
defaultPath,
|
||||
);
|
||||
|
||||
this.timeout = this.configService.get<number>('EMEX_TIMEOUT', 60000);
|
||||
@@ -90,7 +104,15 @@ export class EmexService implements OnModuleDestroy {
|
||||
|
||||
private async doInitialize(): Promise<void> {
|
||||
try {
|
||||
this.logger.log('Loading EMEX scraper module...');
|
||||
this.logger.log(`Loading EMEX scraper module from: ${this.scraperPath}`);
|
||||
|
||||
// Check if file exists
|
||||
const fs = require('fs');
|
||||
if (!fs.existsSync(this.scraperPath)) {
|
||||
this.logger.error(`Scraper file not found at: ${this.scraperPath}`);
|
||||
this.logger.error(`Current working directory: ${process.cwd()}`);
|
||||
throw new Error(`Scraper file not found: ${this.scraperPath}`);
|
||||
}
|
||||
|
||||
// Dynamically import the scraper module
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
@@ -233,6 +255,28 @@ export class EmexService implements OnModuleDestroy {
|
||||
);
|
||||
}
|
||||
|
||||
// Fetch categories if quickGroupsUrl is available
|
||||
// NOTE: We only fetch category list here, parts are fetched on-demand when user clicks a category
|
||||
if (response.quickGroupsUrl) {
|
||||
this.logger.log(`Fetching categories from: ${response.quickGroupsUrl}`);
|
||||
try {
|
||||
const categories = await this.executeWithTimeout(
|
||||
scraper.getCategories(response.quickGroupsUrl),
|
||||
this.timeout,
|
||||
);
|
||||
|
||||
if (categories && categories.length > 0) {
|
||||
this.logger.log(`Found ${categories.length} categories (on-demand parts loading enabled)`);
|
||||
response.categories = categories;
|
||||
// Parts will be fetched on-demand when user clicks a category
|
||||
// No parts scraping here - this makes VIN lookup much faster
|
||||
}
|
||||
} catch (catError) {
|
||||
const err = catError as Error;
|
||||
this.logger.warn(`Failed to fetch categories: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Map successful response
|
||||
const decodedVehicle = mapEmexResponse(response);
|
||||
|
||||
@@ -356,6 +400,53 @@ export class EmexService implements OnModuleDestroy {
|
||||
return Array.from(brands).sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches parts for a specific category (on-demand)
|
||||
* Used when user clicks on a category to view parts
|
||||
*
|
||||
* @param categoryUrl - The URL to fetch parts from (from EmexCategory.url)
|
||||
* @returns Array of parts for the category
|
||||
*/
|
||||
async fetchCategoryParts(categoryUrl: string): Promise<EmexPartResult[]> {
|
||||
if (!categoryUrl) {
|
||||
this.logger.warn('fetchCategoryParts called with empty URL');
|
||||
return [];
|
||||
}
|
||||
|
||||
this.logger.log(`Fetching parts from category URL: ${categoryUrl}`);
|
||||
|
||||
let scraper: EmexVinScraperInstance | null = null;
|
||||
|
||||
try {
|
||||
scraper = await this.createScraperInstance();
|
||||
|
||||
const parts = await this.executeWithTimeout(
|
||||
scraper.getParts(categoryUrl),
|
||||
this.timeout,
|
||||
);
|
||||
|
||||
if (parts && parts.length > 0) {
|
||||
this.logger.log(`Fetched ${parts.length} parts from category`);
|
||||
return parts;
|
||||
}
|
||||
|
||||
return [];
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
this.logger.error(`Failed to fetch category parts: ${err.message}`);
|
||||
return [];
|
||||
} finally {
|
||||
if (scraper) {
|
||||
try {
|
||||
await scraper.close();
|
||||
} catch (closeError) {
|
||||
const err = closeError as Error;
|
||||
this.logger.warn(`Error closing scraper: ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts year from VIN (10th character)
|
||||
*
|
||||
|
||||
@@ -205,7 +205,7 @@ export const CATALOG_MAP: Record<string, CatalogEntry> = {
|
||||
VF3: { code: 'CPSA01', brand: 'Peugeot' },
|
||||
ZFA: { code: 'CFIAT84', brand: 'Fiat' },
|
||||
ZAR: { code: 'RFIAT84', brand: 'Alfa Romeo' },
|
||||
WF0: { code: 'FORD00', brand: 'Ford' },
|
||||
WF0: { code: 'FORD202201', brand: 'Ford' },
|
||||
JTD: { code: 'TOYOTA00', brand: 'Toyota' },
|
||||
JTE: { code: 'TOYOTA00', brand: 'Toyota' },
|
||||
SHH: { code: 'HONDA00', brand: 'Honda' },
|
||||
|
||||
@@ -8,3 +8,14 @@ export * from './emex.module';
|
||||
export * from './emex.service';
|
||||
export * from './emex.types';
|
||||
export * from './emex.mapper';
|
||||
|
||||
// Scraper components
|
||||
export * from './scraper';
|
||||
|
||||
// Category translations
|
||||
export {
|
||||
translateCategoryName,
|
||||
getAllTranslations,
|
||||
CATEGORY_TRANSLATIONS,
|
||||
TRANSLATION_MAP,
|
||||
} from './data/category-translations';
|
||||
|
||||
179
apps/api/src/integrations/emex/scraper/config.ts
Normal file
179
apps/api/src/integrations/emex/scraper/config.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* EMEX Scraper Configuration
|
||||
* Configuration for parallel scraping with DataImpulse proxy
|
||||
*/
|
||||
|
||||
export interface ProxyConfig {
|
||||
host: string;
|
||||
portStart: number;
|
||||
portEnd: number;
|
||||
username: string;
|
||||
password: string;
|
||||
rotationInterval: number;
|
||||
}
|
||||
|
||||
export interface RateLimitConfig {
|
||||
requestsPerMinute: number;
|
||||
requestsPerMinuteGlobal: number;
|
||||
minDelay: number;
|
||||
maxDelay: number;
|
||||
backoffMultiplier: number;
|
||||
maxRetries: number;
|
||||
}
|
||||
|
||||
export interface ConcurrencyConfig {
|
||||
maxBrowsers: number;
|
||||
maxPagesPerBrowser: number;
|
||||
maxWorkers: number;
|
||||
parallelCategories: number;
|
||||
}
|
||||
|
||||
export interface TimeoutConfig {
|
||||
navigation: number;
|
||||
request: number;
|
||||
idle: number;
|
||||
categoryTimeout: number;
|
||||
partTimeout: number;
|
||||
}
|
||||
|
||||
export interface ScraperConfig {
|
||||
proxy: ProxyConfig;
|
||||
rateLimit: RateLimitConfig;
|
||||
concurrency: ConcurrencyConfig;
|
||||
timeouts: TimeoutConfig;
|
||||
target: {
|
||||
baseUrl: string;
|
||||
apiBase: string;
|
||||
};
|
||||
storage: {
|
||||
imageBasePath: string;
|
||||
imageUrlPrefix: string;
|
||||
};
|
||||
blockedResources: string[];
|
||||
blockedPatterns: RegExp[];
|
||||
allowedPatterns: RegExp[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Default scraper configuration
|
||||
* Uses DataImpulse rotating proxy pool
|
||||
*/
|
||||
export const DEFAULT_SCRAPER_CONFIG: ScraperConfig = {
|
||||
// DataImpulse Proxy Configuration
|
||||
proxy: {
|
||||
host: '74.81.81.81',
|
||||
portStart: 10000,
|
||||
portEnd: 10099, // 100 rotating proxies
|
||||
username: '1726bbe361918676d44e',
|
||||
password: 'f11c7b6128cc86c6',
|
||||
rotationInterval: 60000, // 1 minute IP rotation
|
||||
},
|
||||
|
||||
// Rate limiting to avoid being blocked
|
||||
rateLimit: {
|
||||
requestsPerMinute: 30, // Per proxy
|
||||
requestsPerMinuteGlobal: 200, // Total across all proxies
|
||||
minDelay: 500, // Min delay between requests (ms)
|
||||
maxDelay: 2000, // Max delay between requests (ms)
|
||||
backoffMultiplier: 2, // Exponential backoff multiplier
|
||||
maxRetries: 3,
|
||||
},
|
||||
|
||||
// Concurrency settings (optimized for memory)
|
||||
concurrency: {
|
||||
maxBrowsers: 3, // Max concurrent browsers
|
||||
maxPagesPerBrowser: 2, // Pages per browser
|
||||
maxWorkers: 5, // Total concurrent workers
|
||||
parallelCategories: 10, // Categories to fetch in parallel
|
||||
},
|
||||
|
||||
// Timeouts
|
||||
timeouts: {
|
||||
navigation: 30000, // 30 seconds
|
||||
request: 15000, // 15 seconds
|
||||
idle: 5000, // 5 seconds
|
||||
categoryTimeout: 45000, // 45 seconds per category
|
||||
partTimeout: 30000, // 30 seconds per part fetch
|
||||
},
|
||||
|
||||
// Target site
|
||||
target: {
|
||||
baseUrl: 'https://emexdwc.ae',
|
||||
apiBase: 'https://emexdwc.ae/api',
|
||||
},
|
||||
|
||||
// Image storage
|
||||
storage: {
|
||||
imageBasePath: '/www/wwwroot/sase.tr/public/emex-images',
|
||||
imageUrlPrefix: '/emex-images',
|
||||
},
|
||||
|
||||
// Blocked resources (save bandwidth)
|
||||
blockedResources: [
|
||||
'stylesheet',
|
||||
'font',
|
||||
'image', // Block images during scraping, download separately
|
||||
'media',
|
||||
'texttrack',
|
||||
'eventsource',
|
||||
'websocket',
|
||||
'manifest',
|
||||
],
|
||||
|
||||
// Blocked URL patterns
|
||||
blockedPatterns: [
|
||||
/google-analytics/,
|
||||
/googletagmanager/,
|
||||
/facebook/,
|
||||
/doubleclick/,
|
||||
/yandex/,
|
||||
/posthog/,
|
||||
/elastic-apm/,
|
||||
/\.css(\?|$)/,
|
||||
/\.woff/,
|
||||
/\.ttf/,
|
||||
/\.eot/,
|
||||
/\.ico$/,
|
||||
/\.png$/,
|
||||
/\.jpg$/,
|
||||
/\.gif$/,
|
||||
/\.svg$/,
|
||||
],
|
||||
|
||||
// Allowed patterns (always allow these)
|
||||
allowedPatterns: [
|
||||
/\.svc\//,
|
||||
/\/api\//,
|
||||
/\.aspx/,
|
||||
/\.js(\?|$)/,
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Get configuration with environment variable overrides
|
||||
*/
|
||||
export function getScraperConfig(): ScraperConfig {
|
||||
const config = { ...DEFAULT_SCRAPER_CONFIG };
|
||||
|
||||
// Override from environment variables if set
|
||||
if (process.env.EMEX_PROXY_HOST) {
|
||||
config.proxy.host = process.env.EMEX_PROXY_HOST;
|
||||
}
|
||||
if (process.env.EMEX_PROXY_USER) {
|
||||
config.proxy.username = process.env.EMEX_PROXY_USER;
|
||||
}
|
||||
if (process.env.EMEX_PROXY_PASS) {
|
||||
config.proxy.password = process.env.EMEX_PROXY_PASS;
|
||||
}
|
||||
if (process.env.EMEX_MAX_WORKERS) {
|
||||
config.concurrency.maxWorkers = parseInt(process.env.EMEX_MAX_WORKERS, 10);
|
||||
}
|
||||
if (process.env.EMEX_PARALLEL_CATEGORIES) {
|
||||
config.concurrency.parallelCategories = parseInt(process.env.EMEX_PARALLEL_CATEGORIES, 10);
|
||||
}
|
||||
if (process.env.EMEX_IMAGE_PATH) {
|
||||
config.storage.imageBasePath = process.env.EMEX_IMAGE_PATH;
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
374
apps/api/src/integrations/emex/scraper/image-downloader.ts
Normal file
374
apps/api/src/integrations/emex/scraper/image-downloader.ts
Normal file
@@ -0,0 +1,374 @@
|
||||
/**
|
||||
* Image Downloader Service
|
||||
* Downloads schema/diagram images from laximo.net and saves locally
|
||||
*/
|
||||
|
||||
import { Logger } from '@nestjs/common';
|
||||
import * as https from 'https';
|
||||
import * as http from 'http';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { getScraperConfig } from './config';
|
||||
|
||||
export interface ImageDownloadResult {
|
||||
originalUrl: string;
|
||||
localPath: string | null;
|
||||
relativePath: string | null;
|
||||
status: 'downloaded' | 'skipped' | 'failed';
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface ImageDownloaderStats {
|
||||
downloaded: number;
|
||||
skipped: number;
|
||||
failed: number;
|
||||
}
|
||||
|
||||
export class ImageDownloader {
|
||||
private readonly logger = new Logger(ImageDownloader.name);
|
||||
private readonly config = getScraperConfig();
|
||||
private stats: ImageDownloaderStats = {
|
||||
downloaded: 0,
|
||||
skipped: 0,
|
||||
failed: 0,
|
||||
};
|
||||
|
||||
constructor() {
|
||||
this.ensureBaseDirectory();
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure base directory exists
|
||||
*/
|
||||
private ensureBaseDirectory(): void {
|
||||
const basePath = this.config.storage.imageBasePath;
|
||||
if (!fs.existsSync(basePath)) {
|
||||
fs.mkdirSync(basePath, { recursive: true });
|
||||
this.logger.log(`Created base image directory: ${basePath}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure directory exists
|
||||
*/
|
||||
private ensureDir(dirPath: string): void {
|
||||
if (!fs.existsSync(dirPath)) {
|
||||
fs.mkdirSync(dirPath, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate local file path for an image
|
||||
* Structure: {basePath}/{catalogCode}/{groupId}/{filename}
|
||||
*/
|
||||
private getLocalPath(catalogCode: string, groupId: string, imageUrl: string): string {
|
||||
try {
|
||||
const urlObj = new URL(imageUrl);
|
||||
const urlPath = urlObj.pathname;
|
||||
const filename = path.basename(urlPath);
|
||||
|
||||
// Create directory structure
|
||||
const dirPath = path.join(
|
||||
this.config.storage.imageBasePath,
|
||||
catalogCode,
|
||||
groupId.toString(),
|
||||
);
|
||||
this.ensureDir(dirPath);
|
||||
|
||||
return path.join(dirPath, filename);
|
||||
} catch {
|
||||
// Fallback for invalid URLs
|
||||
const timestamp = Date.now();
|
||||
const dirPath = path.join(
|
||||
this.config.storage.imageBasePath,
|
||||
catalogCode,
|
||||
groupId.toString(),
|
||||
);
|
||||
this.ensureDir(dirPath);
|
||||
return path.join(dirPath, `image_${timestamp}.gif`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get relative path for storing in DB
|
||||
*/
|
||||
private getRelativePath(catalogCode: string, groupId: string, imageUrl: string): string {
|
||||
try {
|
||||
const urlObj = new URL(imageUrl);
|
||||
const filename = path.basename(urlObj.pathname);
|
||||
return `${this.config.storage.imageUrlPrefix}/${catalogCode}/${groupId}/${filename}`;
|
||||
} catch {
|
||||
const timestamp = Date.now();
|
||||
return `${this.config.storage.imageUrlPrefix}/${catalogCode}/${groupId}/image_${timestamp}.gif`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if image already exists locally
|
||||
*/
|
||||
private imageExists(localPath: string): boolean {
|
||||
return fs.existsSync(localPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert thumbnail URL to source (full-size) URL
|
||||
* Thumbnail: /RENAULT201910/175/0108/01086426.gif
|
||||
* Source: /RENAULT201910/source/0108/01086426.gif
|
||||
*/
|
||||
private convertToSourceUrl(imageUrl: string): string {
|
||||
// Replace /175/ or any numeric folder (thumbnail size) with /source/
|
||||
return imageUrl.replace(/\/(\d{2,4})\/(\d{4})\//, '/source/$2/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a single image
|
||||
*/
|
||||
async downloadImage(imageUrl: string, localPath: string): Promise<ImageDownloadResult> {
|
||||
return new Promise((resolve) => {
|
||||
// Check if already exists
|
||||
if (this.imageExists(localPath)) {
|
||||
this.stats.skipped++;
|
||||
resolve({
|
||||
originalUrl: imageUrl,
|
||||
localPath,
|
||||
relativePath: null,
|
||||
status: 'skipped',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert to source (full-size) URL
|
||||
const sourceUrl = this.convertToSourceUrl(imageUrl);
|
||||
const file = fs.createWriteStream(localPath);
|
||||
|
||||
const protocol = sourceUrl.startsWith('https') ? https : http;
|
||||
|
||||
const request = protocol.get(sourceUrl, {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0',
|
||||
'Referer': this.config.target.baseUrl,
|
||||
},
|
||||
timeout: 30000,
|
||||
}, (response) => {
|
||||
// Handle redirects
|
||||
if (response.statusCode === 301 || response.statusCode === 302) {
|
||||
file.close();
|
||||
fs.unlinkSync(localPath);
|
||||
const redirectUrl = response.headers.location;
|
||||
if (redirectUrl) {
|
||||
this.downloadImage(redirectUrl, localPath).then(resolve);
|
||||
} else {
|
||||
this.stats.failed++;
|
||||
resolve({
|
||||
originalUrl: imageUrl,
|
||||
localPath: null,
|
||||
relativePath: null,
|
||||
status: 'failed',
|
||||
error: 'Redirect without location',
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.statusCode !== 200) {
|
||||
file.close();
|
||||
if (fs.existsSync(localPath)) {
|
||||
fs.unlinkSync(localPath);
|
||||
}
|
||||
this.stats.failed++;
|
||||
resolve({
|
||||
originalUrl: imageUrl,
|
||||
localPath: null,
|
||||
relativePath: null,
|
||||
status: 'failed',
|
||||
error: `HTTP ${response.statusCode}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
response.pipe(file);
|
||||
|
||||
file.on('finish', () => {
|
||||
file.close();
|
||||
this.stats.downloaded++;
|
||||
resolve({
|
||||
originalUrl: imageUrl,
|
||||
localPath,
|
||||
relativePath: null, // Will be set by caller
|
||||
status: 'downloaded',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
request.on('error', (err) => {
|
||||
file.close();
|
||||
if (fs.existsSync(localPath)) {
|
||||
fs.unlinkSync(localPath);
|
||||
}
|
||||
this.stats.failed++;
|
||||
resolve({
|
||||
originalUrl: imageUrl,
|
||||
localPath: null,
|
||||
relativePath: null,
|
||||
status: 'failed',
|
||||
error: err.message,
|
||||
});
|
||||
});
|
||||
|
||||
request.on('timeout', () => {
|
||||
request.destroy();
|
||||
file.close();
|
||||
if (fs.existsSync(localPath)) {
|
||||
fs.unlinkSync(localPath);
|
||||
}
|
||||
this.stats.failed++;
|
||||
resolve({
|
||||
originalUrl: imageUrl,
|
||||
localPath: null,
|
||||
relativePath: null,
|
||||
status: 'failed',
|
||||
error: 'Timeout',
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Download all images for a category/group
|
||||
* Returns array of { original_url, local_path, relative_path } objects
|
||||
*/
|
||||
async downloadCategoryImages(
|
||||
catalogCode: string,
|
||||
groupId: string,
|
||||
imageUrls: string[],
|
||||
): Promise<ImageDownloadResult[]> {
|
||||
const results: ImageDownloadResult[] = [];
|
||||
|
||||
for (const imageUrl of imageUrls) {
|
||||
if (!imageUrl || (!imageUrl.startsWith('http://') && !imageUrl.startsWith('https://'))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const localPath = this.getLocalPath(catalogCode, groupId, imageUrl);
|
||||
const relativePath = this.getRelativePath(catalogCode, groupId, imageUrl);
|
||||
|
||||
try {
|
||||
const result = await this.downloadImage(imageUrl, localPath);
|
||||
result.relativePath = result.status === 'downloaded' || result.status === 'skipped'
|
||||
? relativePath
|
||||
: null;
|
||||
results.push(result);
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
this.logger.error(`Failed to download ${imageUrl}: ${err.message}`);
|
||||
this.stats.failed++;
|
||||
results.push({
|
||||
originalUrl: imageUrl,
|
||||
localPath: null,
|
||||
relativePath: null,
|
||||
status: 'failed',
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a single schema image
|
||||
*/
|
||||
async downloadSchemaImage(
|
||||
catalogCode: string,
|
||||
groupId: string,
|
||||
imageUrl: string,
|
||||
): Promise<ImageDownloadResult> {
|
||||
if (!imageUrl || (!imageUrl.startsWith('http://') && !imageUrl.startsWith('https://'))) {
|
||||
return {
|
||||
originalUrl: imageUrl,
|
||||
localPath: null,
|
||||
relativePath: null,
|
||||
status: 'failed',
|
||||
error: 'Invalid URL',
|
||||
};
|
||||
}
|
||||
|
||||
const localPath = this.getLocalPath(catalogCode, groupId, imageUrl);
|
||||
const relativePath = this.getRelativePath(catalogCode, groupId, imageUrl);
|
||||
|
||||
const result = await this.downloadImage(imageUrl, localPath);
|
||||
result.relativePath = result.status === 'downloaded' || result.status === 'skipped'
|
||||
? relativePath
|
||||
: null;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get download statistics
|
||||
*/
|
||||
getStats(): ImageDownloaderStats {
|
||||
return { ...this.stats };
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset statistics
|
||||
*/
|
||||
resetStats(): void {
|
||||
this.stats = {
|
||||
downloaded: 0,
|
||||
skipped: 0,
|
||||
failed: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up old images for a catalog
|
||||
*/
|
||||
async cleanupCatalogImages(catalogCode: string): Promise<number> {
|
||||
const catalogPath = path.join(this.config.storage.imageBasePath, catalogCode);
|
||||
|
||||
if (!fs.existsSync(catalogPath)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let deletedCount = 0;
|
||||
|
||||
const removeDir = (dirPath: string) => {
|
||||
if (fs.existsSync(dirPath)) {
|
||||
const files = fs.readdirSync(dirPath);
|
||||
for (const file of files) {
|
||||
const filePath = path.join(dirPath, file);
|
||||
if (fs.statSync(filePath).isDirectory()) {
|
||||
removeDir(filePath);
|
||||
} else {
|
||||
fs.unlinkSync(filePath);
|
||||
deletedCount++;
|
||||
}
|
||||
}
|
||||
fs.rmdirSync(dirPath);
|
||||
}
|
||||
};
|
||||
|
||||
removeDir(catalogPath);
|
||||
this.logger.log(`Cleaned up ${deletedCount} images for catalog ${catalogCode}`);
|
||||
|
||||
return deletedCount;
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
let instance: ImageDownloader | null = null;
|
||||
|
||||
export function getImageDownloader(): ImageDownloader {
|
||||
if (!instance) {
|
||||
instance = new ImageDownloader();
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
export function resetImageDownloader(): void {
|
||||
if (instance) {
|
||||
instance.resetStats();
|
||||
}
|
||||
}
|
||||
27
apps/api/src/integrations/emex/scraper/index.ts
Normal file
27
apps/api/src/integrations/emex/scraper/index.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* EMEX Scraper Module Exports
|
||||
*/
|
||||
|
||||
// Configuration
|
||||
export { getScraperConfig, DEFAULT_SCRAPER_CONFIG } from './config';
|
||||
export type { ScraperConfig, ProxyConfig, RateLimitConfig, ConcurrencyConfig, TimeoutConfig } from './config';
|
||||
|
||||
// Proxy Pool
|
||||
export { getProxyPool, resetProxyPool, ProxyPool } from './proxy-pool';
|
||||
export type { Proxy, ProxyStats } from './proxy-pool';
|
||||
|
||||
// Image Downloader
|
||||
export { getImageDownloader, resetImageDownloader, ImageDownloader } from './image-downloader';
|
||||
export type { ImageDownloadResult, ImageDownloaderStats } from './image-downloader';
|
||||
|
||||
// Parallel Scraper Service
|
||||
export { EmexParallelScraperService } from './parallel-scraper.service';
|
||||
export type {
|
||||
TaskType,
|
||||
TaskStatus,
|
||||
ScrapeTask,
|
||||
ScraperStats,
|
||||
ScraperVehicleData,
|
||||
ScraperCategoryData,
|
||||
ScraperPartData,
|
||||
} from './parallel-scraper.service';
|
||||
@@ -0,0 +1,692 @@
|
||||
/**
|
||||
* EMEX Parallel Scraper Service
|
||||
* Queue-based parallel scraping with proxy rotation
|
||||
*/
|
||||
|
||||
import { Injectable, Logger, OnModuleDestroy } from '@nestjs/common';
|
||||
import { PrismaService } from '../../../prisma/prisma.service';
|
||||
import { getScraperConfig, ScraperConfig } from './config';
|
||||
import { getProxyPool, Proxy, ProxyPool } from './proxy-pool';
|
||||
import { getImageDownloader, ImageDownloader } from './image-downloader';
|
||||
import { translateCategoryName } from '../data/category-translations';
|
||||
import { EventEmitter } from 'events';
|
||||
|
||||
// Task types for queue
|
||||
export type TaskType = 'VEHICLE' | 'CATEGORY';
|
||||
export type TaskStatus = 'PENDING' | 'PROCESSING' | 'COMPLETED' | 'FAILED';
|
||||
|
||||
export interface ScrapeTask {
|
||||
id: string;
|
||||
catalogId: string;
|
||||
taskType: TaskType;
|
||||
vehicleId?: string;
|
||||
vehicleSsd?: string;
|
||||
vehicleName?: string;
|
||||
categoryId?: string;
|
||||
groupId?: string;
|
||||
status: TaskStatus;
|
||||
workerId?: string;
|
||||
retryCount: number;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
export interface ScraperStats {
|
||||
startTime: number | null;
|
||||
vehicles: number;
|
||||
categories: number;
|
||||
parts: number;
|
||||
images: number;
|
||||
errors: number;
|
||||
queuedTasks: number;
|
||||
completedTasks: number;
|
||||
}
|
||||
|
||||
export interface ScraperVehicleData {
|
||||
name: string;
|
||||
engine: string | null;
|
||||
options: any;
|
||||
ssd: string;
|
||||
pathData: string | null;
|
||||
sourceUrl: string | null;
|
||||
}
|
||||
|
||||
export interface ScraperCategoryData {
|
||||
groupId: string;
|
||||
name: string;
|
||||
hasParts: boolean;
|
||||
imageUrls?: string[];
|
||||
}
|
||||
|
||||
export interface ScraperPartData {
|
||||
partNumber: string;
|
||||
name: string;
|
||||
position?: string;
|
||||
quantity?: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class EmexParallelScraperService extends EventEmitter implements OnModuleDestroy {
|
||||
private readonly logger = new Logger(EmexParallelScraperService.name);
|
||||
private readonly config: ScraperConfig;
|
||||
private readonly proxyPool: ProxyPool;
|
||||
private readonly imageDownloader: ImageDownloader;
|
||||
|
||||
private isRunning = false;
|
||||
private currentCatalogId: string | null = null;
|
||||
private currentCatalogCode: string | null = null;
|
||||
private workers: Map<string, { proxy: Proxy; isActive: boolean }> = new Map();
|
||||
|
||||
private stats: ScraperStats = {
|
||||
startTime: null,
|
||||
vehicles: 0,
|
||||
categories: 0,
|
||||
parts: 0,
|
||||
images: 0,
|
||||
errors: 0,
|
||||
queuedTasks: 0,
|
||||
completedTasks: 0,
|
||||
};
|
||||
|
||||
// Cache for parts to avoid duplicate DB lookups
|
||||
private partCache: Map<string, string> = new Map(); // partNumber -> partId
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {
|
||||
super();
|
||||
this.config = getScraperConfig();
|
||||
this.proxyPool = getProxyPool();
|
||||
this.imageDownloader = getImageDownloader();
|
||||
}
|
||||
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
await this.stop();
|
||||
}
|
||||
|
||||
/**
|
||||
* Start scraping for a catalog
|
||||
*/
|
||||
async startScraping(
|
||||
catalogCode: string,
|
||||
brandCode: string,
|
||||
vehicles: ScraperVehicleData[],
|
||||
): Promise<string> {
|
||||
if (this.isRunning) {
|
||||
throw new Error('Scraper is already running');
|
||||
}
|
||||
|
||||
this.logger.log(`Starting parallel scraping for ${catalogCode}`);
|
||||
this.isRunning = true;
|
||||
this.resetStats();
|
||||
|
||||
// Create or get catalog
|
||||
const catalog = await this.getOrCreateCatalog(catalogCode, brandCode);
|
||||
this.currentCatalogId = catalog.id;
|
||||
this.currentCatalogCode = catalogCode;
|
||||
|
||||
// Create scrape session
|
||||
const session = await this.prisma.emexScrapeSession.create({
|
||||
data: {
|
||||
catalogId: catalog.id,
|
||||
brandCode,
|
||||
status: 'RUNNING',
|
||||
totalItems: vehicles.length,
|
||||
startedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
// Clear existing queue for this catalog
|
||||
await this.clearQueue(catalog.id);
|
||||
|
||||
// Save vehicles and add to queue
|
||||
for (const vehicleData of vehicles) {
|
||||
try {
|
||||
const vehicle = await this.saveVehicle(catalog.id, vehicleData);
|
||||
|
||||
// Add vehicle task to queue
|
||||
await this.addToQueue({
|
||||
catalogId: catalog.id,
|
||||
taskType: 'VEHICLE',
|
||||
vehicleId: vehicle.id,
|
||||
vehicleSsd: vehicle.ssd,
|
||||
vehicleName: `${vehicle.name} (${vehicle.engine || 'N/A'})`,
|
||||
status: 'PENDING',
|
||||
retryCount: 0,
|
||||
});
|
||||
|
||||
this.stats.vehicles++;
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
this.logger.error(`Failed to save vehicle: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log(`Added ${vehicles.length} vehicles to scrape queue`);
|
||||
|
||||
// Start workers in background
|
||||
this.startWorkers(catalog.id, session.id);
|
||||
|
||||
return session.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a batch of categories in parallel
|
||||
*/
|
||||
async processParallelCategories(
|
||||
catalogId: string,
|
||||
vehicleId: string,
|
||||
vehicleSsd: string,
|
||||
categories: ScraperCategoryData[],
|
||||
): Promise<void> {
|
||||
const parallelCount = this.config.concurrency.parallelCategories;
|
||||
const batches = this.chunkArray(categories, parallelCount);
|
||||
|
||||
for (const batch of batches) {
|
||||
const promises = batch.map(async (cat) => {
|
||||
try {
|
||||
await this.processCategoryTask(catalogId, vehicleId, vehicleSsd, cat);
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
this.logger.error(`Category ${cat.groupId} error: ${err.message}`);
|
||||
this.stats.errors++;
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(promises);
|
||||
|
||||
// Rate limiting between batches
|
||||
await this.delay(this.config.rateLimit.minDelay);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a single category - save category and parts
|
||||
*/
|
||||
private async processCategoryTask(
|
||||
catalogId: string,
|
||||
vehicleId: string,
|
||||
vehicleSsd: string,
|
||||
categoryData: ScraperCategoryData,
|
||||
): Promise<void> {
|
||||
// Get or create category
|
||||
const category = await this.getOrCreateCategory(catalogId, categoryData);
|
||||
|
||||
// Link category to vehicle
|
||||
await this.prisma.emexVehicleCategory.upsert({
|
||||
where: {
|
||||
vehicleId_categoryId: {
|
||||
vehicleId,
|
||||
categoryId: category.id,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
isScraped: true,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
create: {
|
||||
vehicleId,
|
||||
categoryId: category.id,
|
||||
isScraped: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Download schema images if available
|
||||
if (categoryData.imageUrls && categoryData.imageUrls.length > 0) {
|
||||
const imageResults = await this.imageDownloader.downloadCategoryImages(
|
||||
this.currentCatalogCode || '',
|
||||
categoryData.groupId,
|
||||
categoryData.imageUrls,
|
||||
);
|
||||
|
||||
for (let i = 0; i < imageResults.length; i++) {
|
||||
const img = imageResults[i];
|
||||
if (img.status === 'downloaded' || img.status === 'skipped') {
|
||||
await this.prisma.emexPartImage.upsert({
|
||||
where: {
|
||||
// Use compound index on originalUrl
|
||||
id: `${category.id}_${i}`, // Temporary ID for upsert
|
||||
},
|
||||
update: {
|
||||
localPath: img.relativePath,
|
||||
downloadedAt: img.status === 'downloaded' ? new Date() : undefined,
|
||||
},
|
||||
create: {
|
||||
categoryId: category.id,
|
||||
imageType: 'DIAGRAM',
|
||||
originalUrl: img.originalUrl,
|
||||
localPath: img.relativePath,
|
||||
isPrimary: i === 0,
|
||||
sortOrder: i,
|
||||
downloadedAt: img.status === 'downloaded' ? new Date() : null,
|
||||
},
|
||||
});
|
||||
|
||||
if (img.status === 'downloaded') {
|
||||
this.stats.images++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update category with first image path
|
||||
const primaryImage = imageResults.find(r => r.relativePath);
|
||||
if (primaryImage) {
|
||||
await this.prisma.emexCategory.update({
|
||||
where: { id: category.id },
|
||||
data: { localImagePath: primaryImage.relativePath },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this.stats.categories++;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save parts for a category and vehicle
|
||||
*/
|
||||
async saveParts(
|
||||
catalogId: string,
|
||||
vehicleId: string,
|
||||
categoryId: string,
|
||||
parts: ScraperPartData[],
|
||||
): Promise<number> {
|
||||
let savedCount = 0;
|
||||
|
||||
for (const partData of parts) {
|
||||
try {
|
||||
// Check cache first
|
||||
const cacheKey = `${catalogId}_${partData.partNumber}`;
|
||||
let partId = this.partCache.get(cacheKey);
|
||||
|
||||
if (!partId) {
|
||||
// Check DB
|
||||
const existingPart = await this.prisma.emexPart.findUnique({
|
||||
where: {
|
||||
catalogId_partNumber: {
|
||||
catalogId,
|
||||
partNumber: partData.partNumber,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (existingPart) {
|
||||
partId = existingPart.id;
|
||||
} else {
|
||||
// Create new part
|
||||
const newPart = await this.prisma.emexPart.create({
|
||||
data: {
|
||||
catalogId,
|
||||
partNumber: partData.partNumber,
|
||||
name: partData.name,
|
||||
categoryId,
|
||||
},
|
||||
});
|
||||
partId = newPart.id;
|
||||
}
|
||||
|
||||
this.partCache.set(cacheKey, partId);
|
||||
}
|
||||
|
||||
// Create vehicle-part link
|
||||
await this.prisma.emexVehiclePart.upsert({
|
||||
where: {
|
||||
vehicleId_partId_categoryId: {
|
||||
vehicleId,
|
||||
partId,
|
||||
categoryId,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
quantity: partData.quantity || 1,
|
||||
position: partData.position,
|
||||
},
|
||||
create: {
|
||||
vehicleId,
|
||||
partId,
|
||||
categoryId,
|
||||
quantity: partData.quantity || 1,
|
||||
position: partData.position,
|
||||
},
|
||||
});
|
||||
|
||||
// Add OEM part number
|
||||
await this.prisma.emexPartNumber.upsert({
|
||||
where: {
|
||||
partId_number: {
|
||||
partId,
|
||||
number: partData.partNumber,
|
||||
},
|
||||
},
|
||||
update: {},
|
||||
create: {
|
||||
partId,
|
||||
number: partData.partNumber,
|
||||
numberType: 'OEM',
|
||||
},
|
||||
});
|
||||
|
||||
savedCount++;
|
||||
this.stats.parts++;
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
if (!err.message?.includes('Unique constraint')) {
|
||||
this.logger.warn(`Failed to save part ${partData.partNumber}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return savedCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the scraper
|
||||
*/
|
||||
async stop(): Promise<void> {
|
||||
this.isRunning = false;
|
||||
this.workers.clear();
|
||||
this.partCache.clear();
|
||||
this.logger.log('Scraper stopped');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current scraper stats
|
||||
*/
|
||||
getStats(): ScraperStats {
|
||||
return { ...this.stats };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if scraper is running
|
||||
*/
|
||||
isActive(): boolean {
|
||||
return this.isRunning;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Private helper methods
|
||||
// ============================================================================
|
||||
|
||||
private async getOrCreateCatalog(catalogCode: string, brandCode: string) {
|
||||
let catalog = await this.prisma.emexCatalog.findUnique({
|
||||
where: { code: catalogCode },
|
||||
});
|
||||
|
||||
if (!catalog) {
|
||||
catalog = await this.prisma.emexCatalog.create({
|
||||
data: {
|
||||
code: catalogCode,
|
||||
name: brandCode,
|
||||
brandCode,
|
||||
supportVinSearch: true,
|
||||
supportQuickGroups: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return catalog;
|
||||
}
|
||||
|
||||
private async saveVehicle(catalogId: string, vehicleData: ScraperVehicleData) {
|
||||
const uniqueKey = `${vehicleData.engine || ''}_${JSON.stringify(vehicleData.options || {})}`.substring(0, 255);
|
||||
|
||||
return this.prisma.emexVehicle.upsert({
|
||||
where: {
|
||||
catalogId_uniqueKey: {
|
||||
catalogId,
|
||||
uniqueKey,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
name: vehicleData.name,
|
||||
ssd: vehicleData.ssd,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
create: {
|
||||
catalogId,
|
||||
name: vehicleData.name,
|
||||
engine: vehicleData.engine,
|
||||
options: vehicleData.options,
|
||||
ssd: vehicleData.ssd,
|
||||
pathData: vehicleData.pathData,
|
||||
sourceUrl: vehicleData.sourceUrl,
|
||||
uniqueKey,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async getOrCreateCategory(catalogId: string, categoryData: ScraperCategoryData) {
|
||||
const nameTr = translateCategoryName(categoryData.name);
|
||||
|
||||
return this.prisma.emexCategory.upsert({
|
||||
where: {
|
||||
catalogId_groupId: {
|
||||
catalogId,
|
||||
groupId: categoryData.groupId,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
name: categoryData.name,
|
||||
nameTr,
|
||||
hasParts: categoryData.hasParts,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
create: {
|
||||
catalogId,
|
||||
groupId: categoryData.groupId,
|
||||
name: categoryData.name,
|
||||
nameTr,
|
||||
hasParts: categoryData.hasParts,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async clearQueue(catalogId: string): Promise<void> {
|
||||
await this.prisma.emexScrapeQueue.deleteMany({
|
||||
where: { catalogId },
|
||||
});
|
||||
}
|
||||
|
||||
private async addToQueue(task: Omit<ScrapeTask, 'id'>): Promise<void> {
|
||||
await this.prisma.emexScrapeQueue.create({
|
||||
data: {
|
||||
catalogId: task.catalogId,
|
||||
taskType: task.taskType,
|
||||
vehicleId: task.vehicleId,
|
||||
vehicleSsd: task.vehicleSsd,
|
||||
vehicleName: task.vehicleName,
|
||||
categoryId: task.categoryId,
|
||||
groupId: task.groupId,
|
||||
status: task.status,
|
||||
retryCount: task.retryCount,
|
||||
},
|
||||
});
|
||||
this.stats.queuedTasks++;
|
||||
}
|
||||
|
||||
private async getNextTask(workerId: string): Promise<ScrapeTask | null> {
|
||||
// Use transaction with skip locked for concurrency safety
|
||||
const task = await this.prisma.$transaction(async (tx) => {
|
||||
// Get next pending task, prioritize VEHICLE tasks
|
||||
const pending = await tx.emexScrapeQueue.findFirst({
|
||||
where: {
|
||||
catalogId: this.currentCatalogId!,
|
||||
status: 'PENDING',
|
||||
},
|
||||
orderBy: [
|
||||
{ taskType: 'asc' }, // CATEGORY before VEHICLE alphabetically, but we want opposite
|
||||
{ priority: 'asc' },
|
||||
{ createdAt: 'asc' },
|
||||
],
|
||||
});
|
||||
|
||||
if (!pending) return null;
|
||||
|
||||
// Mark as processing
|
||||
await tx.emexScrapeQueue.update({
|
||||
where: { id: pending.id },
|
||||
data: {
|
||||
status: 'PROCESSING',
|
||||
workerId,
|
||||
startedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
return pending;
|
||||
});
|
||||
|
||||
return task as ScrapeTask | null;
|
||||
}
|
||||
|
||||
private async markTaskCompleted(taskId: string): Promise<void> {
|
||||
await this.prisma.emexScrapeQueue.update({
|
||||
where: { id: taskId },
|
||||
data: {
|
||||
status: 'COMPLETED',
|
||||
completedAt: new Date(),
|
||||
},
|
||||
});
|
||||
this.stats.completedTasks++;
|
||||
}
|
||||
|
||||
private async markTaskFailed(taskId: string, error: string): Promise<void> {
|
||||
await this.prisma.emexScrapeQueue.update({
|
||||
where: { id: taskId },
|
||||
data: {
|
||||
status: 'FAILED',
|
||||
errorMessage: error,
|
||||
retryCount: { increment: 1 },
|
||||
},
|
||||
});
|
||||
this.stats.errors++;
|
||||
}
|
||||
|
||||
private async startWorkers(catalogId: string, sessionId: string): Promise<void> {
|
||||
const workerCount = this.config.concurrency.maxWorkers;
|
||||
|
||||
this.logger.log(`Starting ${workerCount} workers...`);
|
||||
|
||||
const workerPromises = [];
|
||||
for (let i = 0; i < workerCount; i++) {
|
||||
const workerId = `W${i + 1}`;
|
||||
const proxy = this.proxyPool.getProxy();
|
||||
this.workers.set(workerId, { proxy, isActive: true });
|
||||
workerPromises.push(this.runWorker(workerId, catalogId));
|
||||
}
|
||||
|
||||
// Run workers in parallel
|
||||
Promise.all(workerPromises).then(async () => {
|
||||
// All workers finished
|
||||
this.isRunning = false;
|
||||
|
||||
// Update session
|
||||
await this.prisma.emexScrapeSession.update({
|
||||
where: { id: sessionId },
|
||||
data: {
|
||||
status: 'COMPLETED',
|
||||
processedItems: this.stats.completedTasks,
|
||||
failedItems: this.stats.errors,
|
||||
completedAt: new Date(),
|
||||
stats: this.stats as any,
|
||||
},
|
||||
});
|
||||
|
||||
this.emit('completed', this.stats);
|
||||
this.logger.log('Scraping completed', this.stats);
|
||||
}).catch(async (error) => {
|
||||
this.isRunning = false;
|
||||
|
||||
await this.prisma.emexScrapeSession.update({
|
||||
where: { id: sessionId },
|
||||
data: {
|
||||
status: 'FAILED',
|
||||
lastError: error.message,
|
||||
completedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
this.emit('error', error);
|
||||
this.logger.error('Scraping failed', error);
|
||||
});
|
||||
}
|
||||
|
||||
private async runWorker(workerId: string, catalogId: string): Promise<void> {
|
||||
this.logger.log(`Worker ${workerId} started`);
|
||||
let emptyChecks = 0;
|
||||
const MAX_EMPTY_CHECKS = 5;
|
||||
|
||||
while (this.isRunning) {
|
||||
try {
|
||||
const task = await this.getNextTask(workerId);
|
||||
|
||||
if (!task) {
|
||||
await this.delay(2000);
|
||||
emptyChecks++;
|
||||
|
||||
if (emptyChecks >= MAX_EMPTY_CHECKS) {
|
||||
// Check if there are any pending or processing tasks
|
||||
const remaining = await this.prisma.emexScrapeQueue.count({
|
||||
where: {
|
||||
catalogId,
|
||||
status: { in: ['PENDING', 'PROCESSING'] },
|
||||
},
|
||||
});
|
||||
|
||||
if (remaining === 0) {
|
||||
this.logger.log(`Worker ${workerId} stopping - queue empty`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
emptyChecks = 0;
|
||||
|
||||
// Process task
|
||||
// Note: The actual scraping logic would be called from the EMEX service
|
||||
// This service manages the queue and parallel execution
|
||||
this.emit('task', task);
|
||||
|
||||
await this.markTaskCompleted(task.id);
|
||||
|
||||
// Rate limiting
|
||||
await this.randomDelay();
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
this.logger.error(`Worker ${workerId} error: ${err.message}`);
|
||||
this.stats.errors++;
|
||||
}
|
||||
}
|
||||
|
||||
this.workers.delete(workerId);
|
||||
this.logger.log(`Worker ${workerId} stopped`);
|
||||
}
|
||||
|
||||
private resetStats(): void {
|
||||
this.stats = {
|
||||
startTime: Date.now(),
|
||||
vehicles: 0,
|
||||
categories: 0,
|
||||
parts: 0,
|
||||
images: 0,
|
||||
errors: 0,
|
||||
queuedTasks: 0,
|
||||
completedTasks: 0,
|
||||
};
|
||||
this.partCache.clear();
|
||||
}
|
||||
|
||||
private chunkArray<T>(array: T[], size: number): T[][] {
|
||||
const chunks: T[][] = [];
|
||||
for (let i = 0; i < array.length; i += size) {
|
||||
chunks.push(array.slice(i, i + size));
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
private delay(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
private async randomDelay(): Promise<void> {
|
||||
const delay = this.config.rateLimit.minDelay +
|
||||
Math.random() * (this.config.rateLimit.maxDelay - this.config.rateLimit.minDelay);
|
||||
await this.delay(delay);
|
||||
}
|
||||
}
|
||||
307
apps/api/src/integrations/emex/scraper/proxy-pool.ts
Normal file
307
apps/api/src/integrations/emex/scraper/proxy-pool.ts
Normal file
@@ -0,0 +1,307 @@
|
||||
/**
|
||||
* Proxy Pool Manager
|
||||
* Manages DataImpulse rotating proxy pool with health tracking
|
||||
*/
|
||||
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { getScraperConfig } from './config';
|
||||
|
||||
export interface Proxy {
|
||||
id: number;
|
||||
host: string;
|
||||
port: number;
|
||||
username: string;
|
||||
password: string;
|
||||
url: string;
|
||||
httpUrl: string;
|
||||
}
|
||||
|
||||
export interface ProxyStats {
|
||||
requests: number;
|
||||
failures: number;
|
||||
lastUsed: number;
|
||||
lastFailure: number;
|
||||
blocked: boolean;
|
||||
currentIp: string | null;
|
||||
}
|
||||
|
||||
export class ProxyPool {
|
||||
private readonly logger = new Logger(ProxyPool.name);
|
||||
private proxies: Proxy[] = [];
|
||||
private proxyStats: Map<number, ProxyStats> = new Map();
|
||||
private lastRotation: Map<number, number> = new Map();
|
||||
private currentIndex = 0;
|
||||
private readonly config = getScraperConfig();
|
||||
|
||||
constructor() {
|
||||
this.initProxies();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize proxy pool from configuration
|
||||
*/
|
||||
private initProxies(): void {
|
||||
const { host, portStart, portEnd, username, password } = this.config.proxy;
|
||||
|
||||
for (let port = portStart; port <= portEnd; port++) {
|
||||
const proxy: Proxy = {
|
||||
id: port - portStart,
|
||||
host,
|
||||
port,
|
||||
username,
|
||||
password,
|
||||
url: `http://${username}:${password}@${host}:${port}`,
|
||||
httpUrl: `http://${host}:${port}`,
|
||||
};
|
||||
|
||||
this.proxies.push(proxy);
|
||||
this.proxyStats.set(proxy.id, {
|
||||
requests: 0,
|
||||
failures: 0,
|
||||
lastUsed: 0,
|
||||
lastFailure: 0,
|
||||
blocked: false,
|
||||
currentIp: null,
|
||||
});
|
||||
this.lastRotation.set(proxy.id, Date.now());
|
||||
}
|
||||
|
||||
this.logger.log(`ProxyPool initialized with ${this.proxies.length} proxies`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get next available proxy using round-robin with health check
|
||||
*/
|
||||
getProxy(): Proxy {
|
||||
const now = Date.now();
|
||||
const startIndex = this.currentIndex;
|
||||
|
||||
do {
|
||||
const proxy = this.proxies[this.currentIndex];
|
||||
const stats = this.proxyStats.get(proxy.id)!;
|
||||
|
||||
this.currentIndex = (this.currentIndex + 1) % this.proxies.length;
|
||||
|
||||
// Skip blocked proxies
|
||||
if (stats.blocked) {
|
||||
// Unblock after 2 minutes
|
||||
if (now - stats.lastFailure > 120000) {
|
||||
stats.blocked = false;
|
||||
stats.failures = 0;
|
||||
this.logger.log(`Proxy ${proxy.id} unblocked after cooldown`);
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if IP has rotated (wait for rotation if too recent)
|
||||
const timeSinceRotation = now - (this.lastRotation.get(proxy.id) || 0);
|
||||
if (timeSinceRotation < 5000) {
|
||||
// IP just rotated, might be unstable
|
||||
continue;
|
||||
}
|
||||
|
||||
// Rate limit per proxy
|
||||
const timeSinceLastUse = now - stats.lastUsed;
|
||||
const minInterval = 60000 / this.config.rateLimit.requestsPerMinute;
|
||||
|
||||
if (timeSinceLastUse < minInterval) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Found a good proxy
|
||||
stats.lastUsed = now;
|
||||
stats.requests++;
|
||||
|
||||
return proxy;
|
||||
} while (this.currentIndex !== startIndex);
|
||||
|
||||
// All proxies busy, return least recently used
|
||||
let bestProxy = this.proxies[0];
|
||||
let oldestUse = Infinity;
|
||||
|
||||
for (const proxy of this.proxies) {
|
||||
const stats = this.proxyStats.get(proxy.id)!;
|
||||
if (!stats.blocked && stats.lastUsed < oldestUse) {
|
||||
oldestUse = stats.lastUsed;
|
||||
bestProxy = proxy;
|
||||
}
|
||||
}
|
||||
|
||||
const stats = this.proxyStats.get(bestProxy.id)!;
|
||||
stats.lastUsed = now;
|
||||
stats.requests++;
|
||||
|
||||
return bestProxy;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get multiple proxies for parallel operations
|
||||
*/
|
||||
getProxies(count: number): Proxy[] {
|
||||
const proxies: Proxy[] = [];
|
||||
const usedIds = new Set<number>();
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const proxy = this.getProxyExcluding(usedIds);
|
||||
if (proxy) {
|
||||
proxies.push(proxy);
|
||||
usedIds.add(proxy.id);
|
||||
}
|
||||
}
|
||||
|
||||
return proxies;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get proxy excluding certain IDs
|
||||
*/
|
||||
private getProxyExcluding(excludeIds: Set<number>): Proxy | null {
|
||||
const now = Date.now();
|
||||
|
||||
for (const proxy of this.proxies) {
|
||||
if (excludeIds.has(proxy.id)) continue;
|
||||
|
||||
const stats = this.proxyStats.get(proxy.id)!;
|
||||
|
||||
if (stats.blocked) {
|
||||
if (now - stats.lastFailure > 120000) {
|
||||
stats.blocked = false;
|
||||
stats.failures = 0;
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
stats.lastUsed = now;
|
||||
stats.requests++;
|
||||
return proxy;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Report successful request
|
||||
*/
|
||||
reportSuccess(proxyId: number): void {
|
||||
const stats = this.proxyStats.get(proxyId);
|
||||
if (stats) {
|
||||
stats.failures = Math.max(0, stats.failures - 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Report failed request
|
||||
*/
|
||||
reportFailure(proxyId: number, error?: Error): void {
|
||||
const stats = this.proxyStats.get(proxyId);
|
||||
if (stats) {
|
||||
stats.failures++;
|
||||
stats.lastFailure = Date.now();
|
||||
|
||||
// Block proxy after 3 consecutive failures
|
||||
if (stats.failures >= 3) {
|
||||
stats.blocked = true;
|
||||
this.logger.warn(`Proxy ${proxyId} blocked due to ${stats.failures} failures`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark IP rotation for a proxy
|
||||
*/
|
||||
markRotation(proxyId: number, newIp?: string): void {
|
||||
this.lastRotation.set(proxyId, Date.now());
|
||||
const stats = this.proxyStats.get(proxyId);
|
||||
if (stats) {
|
||||
stats.currentIp = newIp || null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get pool statistics
|
||||
*/
|
||||
getStats(): {
|
||||
totalProxies: number;
|
||||
activeProxies: number;
|
||||
blockedProxies: number;
|
||||
totalRequests: number;
|
||||
totalFailures: number;
|
||||
failureRate: string;
|
||||
} {
|
||||
let totalRequests = 0;
|
||||
let totalFailures = 0;
|
||||
let blockedCount = 0;
|
||||
|
||||
for (const [, stats] of this.proxyStats) {
|
||||
totalRequests += stats.requests;
|
||||
totalFailures += stats.failures;
|
||||
if (stats.blocked) blockedCount++;
|
||||
}
|
||||
|
||||
return {
|
||||
totalProxies: this.proxies.length,
|
||||
activeProxies: this.proxies.length - blockedCount,
|
||||
blockedProxies: blockedCount,
|
||||
totalRequests,
|
||||
totalFailures,
|
||||
failureRate: totalRequests > 0
|
||||
? (totalFailures / totalRequests * 100).toFixed(2) + '%'
|
||||
: '0%',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get proxy by ID
|
||||
*/
|
||||
getProxyById(id: number): Proxy | undefined {
|
||||
return this.proxies[id];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active (non-blocked) proxies
|
||||
*/
|
||||
getActiveProxies(count?: number): Proxy[] {
|
||||
const active = this.proxies.filter(p => {
|
||||
const stats = this.proxyStats.get(p.id);
|
||||
return stats && !stats.blocked;
|
||||
});
|
||||
|
||||
// Shuffle and return requested count
|
||||
const shuffled = active.sort(() => Math.random() - 0.5);
|
||||
return count ? shuffled.slice(0, count) : shuffled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset all proxy stats
|
||||
*/
|
||||
reset(): void {
|
||||
for (const [id, stats] of this.proxyStats) {
|
||||
stats.requests = 0;
|
||||
stats.failures = 0;
|
||||
stats.blocked = false;
|
||||
stats.lastUsed = 0;
|
||||
stats.lastFailure = 0;
|
||||
stats.currentIp = null;
|
||||
}
|
||||
this.currentIndex = 0;
|
||||
this.logger.log('ProxyPool stats reset');
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
let instance: ProxyPool | null = null;
|
||||
|
||||
export function getProxyPool(): ProxyPool {
|
||||
if (!instance) {
|
||||
instance = new ProxyPool();
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
export function resetProxyPool(): void {
|
||||
if (instance) {
|
||||
instance.reset();
|
||||
}
|
||||
}
|
||||
353
apps/api/src/integrations/emex/scraper/test-api.ts
Normal file
353
apps/api/src/integrations/emex/scraper/test-api.ts
Normal file
@@ -0,0 +1,353 @@
|
||||
/**
|
||||
* Test EMEX API endpoints directly
|
||||
* Run: npx ts-node --transpile-only src/integrations/emex/scraper/test-api.ts
|
||||
*/
|
||||
|
||||
import * as https from 'https';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { translateCategoryName } from '../data/category-translations';
|
||||
|
||||
const VIN = 'WF0RXXGCDRAM33635';
|
||||
const CATALOG_MAP: Record<string, string> = {
|
||||
'WBA': 'BMW202501',
|
||||
'WBS': 'BMW202501',
|
||||
'WBY': 'BMW202501',
|
||||
'WDB': 'MB201810',
|
||||
'WDD': 'MB201810',
|
||||
'WDC': 'MB201810',
|
||||
'WDF': 'MB201810',
|
||||
'WAU': 'AU1587',
|
||||
'WVW': 'VW1587',
|
||||
'WVG': 'VW1587',
|
||||
'VF1': 'RENAULT201910',
|
||||
'VF7': 'CPSA01',
|
||||
'VF3': 'CPSA01',
|
||||
'ZFA': 'CFIAT84',
|
||||
'ZAR': 'RFIAT84',
|
||||
'WF0': 'FORD202201',
|
||||
'JTD': 'TOYOTA00',
|
||||
'JTE': 'TOYOTA00',
|
||||
'SHH': 'HONDA00',
|
||||
'KNM': 'HYUNDAI00',
|
||||
'KNA': 'KIA00',
|
||||
};
|
||||
|
||||
let sessionCookie = '';
|
||||
|
||||
interface ApiResponse {
|
||||
status: number;
|
||||
data: any;
|
||||
parseError?: boolean;
|
||||
}
|
||||
|
||||
async function apiRequest(
|
||||
endpoint: string,
|
||||
params: Record<string, any> = {},
|
||||
): Promise<ApiResponse> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const queryString = Object.entries(params)
|
||||
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`)
|
||||
.join('&');
|
||||
|
||||
const url = `https://emexdwc.ae${endpoint}?${queryString}&_tstamp=${Date.now()}`;
|
||||
const urlObj = new URL(url);
|
||||
|
||||
const options: https.RequestOptions = {
|
||||
hostname: urlObj.hostname,
|
||||
path: urlObj.pathname + urlObj.search,
|
||||
method: 'GET',
|
||||
headers: {
|
||||
accept: 'application/json',
|
||||
'content-type': 'application/json',
|
||||
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0',
|
||||
'x-requested-with': 'XMLHttpRequest',
|
||||
referer: 'https://emexdwc.ae/Search.aspx',
|
||||
cookie: sessionCookie,
|
||||
},
|
||||
timeout: 15000,
|
||||
};
|
||||
|
||||
const req = https.request(options, (res) => {
|
||||
const setCookie = res.headers['set-cookie'];
|
||||
if (setCookie) {
|
||||
for (const cookie of setCookie) {
|
||||
if (cookie.includes('ASP.NET_SessionId')) {
|
||||
sessionCookie = cookie.split(';')[0];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let data = '';
|
||||
res.on('data', (chunk: Buffer) => (data += chunk.toString()));
|
||||
res.on('end', () => {
|
||||
try {
|
||||
resolve({ status: res.statusCode || 0, data: JSON.parse(data) });
|
||||
} catch {
|
||||
resolve({ status: res.statusCode || 0, data, parseError: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', reject);
|
||||
req.on('timeout', () => {
|
||||
req.destroy();
|
||||
reject(new Error('Request timeout'));
|
||||
});
|
||||
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
async function getWizardStep(catalogCode: string, ssd: string = ''): Promise<any[]> {
|
||||
const result = await apiRequest('/api/Catalog.svc/GetWizard2', {
|
||||
catalogCode,
|
||||
ssd,
|
||||
});
|
||||
return Array.isArray(result.data) ? result.data : [];
|
||||
}
|
||||
|
||||
async function listVehicles(catalogCode: string, ssd: string): Promise<any[]> {
|
||||
const result = await apiRequest('/api/Catalog.svc/ListVehicles', {
|
||||
catalogCode,
|
||||
ssd,
|
||||
limit: 100,
|
||||
});
|
||||
return Array.isArray(result.data) ? result.data : [];
|
||||
}
|
||||
|
||||
async function getQuickGroups(catalogCode: string, ssd: string): Promise<any[]> {
|
||||
const result = await apiRequest('/api/Catalog.svc/ListQuickGroups', {
|
||||
catalogCode,
|
||||
ssd,
|
||||
all: 'false',
|
||||
});
|
||||
return Array.isArray(result.data) ? result.data : [];
|
||||
}
|
||||
|
||||
async function getVehicleInfo(catalogCode: string, ssd: string): Promise<any> {
|
||||
const result = await apiRequest('/api/Catalog.svc/VehicleInfo', {
|
||||
catalogCode,
|
||||
ssd,
|
||||
});
|
||||
return result.data;
|
||||
}
|
||||
|
||||
async function findVehicleBySsd(catalogCode: string, vin: string): Promise<any> {
|
||||
// Use VIN to search
|
||||
const result = await apiRequest('/api/Catalog.svc/FindVehicle', {
|
||||
catalogCode,
|
||||
vin,
|
||||
});
|
||||
return result.data;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('========================================');
|
||||
console.log('EMEX API Direct Test');
|
||||
console.log('========================================\n');
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
try {
|
||||
const wmi = VIN.substring(0, 3);
|
||||
const catalogCode = CATALOG_MAP[wmi];
|
||||
|
||||
if (!catalogCode) {
|
||||
throw new Error(`Unknown manufacturer for WMI: ${wmi}`);
|
||||
}
|
||||
|
||||
console.log(`VIN: ${VIN}`);
|
||||
console.log(`WMI: ${wmi}`);
|
||||
console.log(`Catalog: ${catalogCode}\n`);
|
||||
|
||||
// Step 1: Try to find vehicle directly
|
||||
console.log('[1/5] Finding vehicle by VIN...');
|
||||
const vehicleResult = await findVehicleBySsd(catalogCode, VIN);
|
||||
console.log('Vehicle result:', JSON.stringify(vehicleResult, null, 2));
|
||||
|
||||
// Step 2: Navigate wizard to get SSD
|
||||
console.log('\n[2/5] Navigating wizard...');
|
||||
|
||||
let currentSsd = '';
|
||||
let step = 0;
|
||||
let finalSsd: string | null = null;
|
||||
let vehicleInfo: any = null;
|
||||
const maxSteps = 15;
|
||||
|
||||
while (step < maxSteps) {
|
||||
step++;
|
||||
const wizardData = await getWizardStep(catalogCode, currentSsd);
|
||||
|
||||
if (!Array.isArray(wizardData) || wizardData.length === 0) {
|
||||
console.log(` Step ${step}: No wizard data`);
|
||||
break;
|
||||
}
|
||||
|
||||
console.log(` Step ${step}: ${wizardData.length} sections`);
|
||||
|
||||
// Check if we can list vehicles
|
||||
const allowList = wizardData.some((s: any) => s.allowlistvehicles);
|
||||
if (allowList) {
|
||||
console.log(` ✓ Can list vehicles!`);
|
||||
finalSsd = currentSsd;
|
||||
break;
|
||||
}
|
||||
|
||||
// Find undetermined step
|
||||
let foundOption: any = null;
|
||||
|
||||
for (const stepData of wizardData) {
|
||||
if (stepData.determined) continue;
|
||||
|
||||
const options = stepData.options || [];
|
||||
console.log(` ${stepData.name}: ${options.length} options`);
|
||||
|
||||
// Pick first available option
|
||||
if (options.length > 0) {
|
||||
foundOption = options[0];
|
||||
console.log(` → Selected: ${foundOption.value}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (foundOption) {
|
||||
currentSsd = foundOption.key;
|
||||
} else {
|
||||
console.log(` Reached end at step ${step}`);
|
||||
finalSsd = currentSsd;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!finalSsd) {
|
||||
console.log('Could not navigate wizard. Using last SSD:', currentSsd);
|
||||
finalSsd = currentSsd;
|
||||
}
|
||||
|
||||
console.log(`\nFinal SSD: ${finalSsd}`);
|
||||
|
||||
// Step 3: List vehicles
|
||||
console.log('\n[3/5] Listing vehicles...');
|
||||
const vehicles = await listVehicles(catalogCode, finalSsd);
|
||||
console.log(`Found ${vehicles.length} vehicles`);
|
||||
|
||||
if (vehicles.length > 0) {
|
||||
// Show first vehicle
|
||||
const firstVehicle = vehicles[0];
|
||||
console.log(` First vehicle: ${firstVehicle.name || 'N/A'}`);
|
||||
console.log(` SSD: ${firstVehicle.ssd || 'N/A'}`);
|
||||
|
||||
// Use first vehicle's SSD
|
||||
if (firstVehicle.ssd) {
|
||||
finalSsd = firstVehicle.ssd;
|
||||
vehicleInfo = firstVehicle;
|
||||
}
|
||||
}
|
||||
|
||||
// Step 4: Get vehicle info and categories
|
||||
console.log('\n[4/5] Getting vehicle info and categories...');
|
||||
|
||||
if (finalSsd) {
|
||||
const info = await getVehicleInfo(catalogCode, finalSsd);
|
||||
console.log('Vehicle Info:', JSON.stringify(info, null, 2).substring(0, 500));
|
||||
|
||||
const groups = await getQuickGroups(catalogCode, finalSsd);
|
||||
console.log(`\nFound ${groups.length} quick groups (categories)`);
|
||||
|
||||
// Show first 10 categories
|
||||
groups.slice(0, 10).forEach((g: any, i: number) => {
|
||||
console.log(` ${i + 1}. ${g.name} (gid: ${g.quickgroupid || g.gid})`);
|
||||
});
|
||||
|
||||
if (groups.length > 10) {
|
||||
console.log(` ... and ${groups.length - 10} more`);
|
||||
}
|
||||
|
||||
// Step 5: Save to database
|
||||
console.log('\n[5/5] Saving to database...');
|
||||
|
||||
const brandCode = catalogCode.replace(/\d+/g, '');
|
||||
|
||||
// Create catalog
|
||||
const catalog = await prisma.emexCatalog.upsert({
|
||||
where: { code: catalogCode },
|
||||
update: { updatedAt: new Date() },
|
||||
create: {
|
||||
code: catalogCode,
|
||||
name: `${brandCode} Catalog`,
|
||||
brandCode,
|
||||
supportVinSearch: true,
|
||||
supportQuickGroups: true,
|
||||
},
|
||||
});
|
||||
console.log(` Catalog ID: ${catalog.id}`);
|
||||
|
||||
// Create vehicle
|
||||
const vehicleName = vehicleInfo?.name || info?.name || `${brandCode} Vehicle`;
|
||||
const uniqueKey = `${VIN}_${finalSsd.substring(0, 50)}`;
|
||||
|
||||
const vehicle = await prisma.emexVehicle.upsert({
|
||||
where: { catalogId_uniqueKey: { catalogId: catalog.id, uniqueKey } },
|
||||
update: {
|
||||
name: vehicleName,
|
||||
ssd: finalSsd,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
create: {
|
||||
catalogId: catalog.id,
|
||||
name: vehicleName,
|
||||
ssd: finalSsd,
|
||||
uniqueKey,
|
||||
},
|
||||
});
|
||||
console.log(` Vehicle ID: ${vehicle.id}`);
|
||||
console.log(` Vehicle Name: ${vehicle.name}`);
|
||||
|
||||
// Save categories
|
||||
let savedCount = 0;
|
||||
for (const group of groups) {
|
||||
const groupId = String(group.quickgroupid || group.gid || group.id);
|
||||
const name = group.name || 'Unknown';
|
||||
const nameTr = translateCategoryName(name);
|
||||
|
||||
await prisma.emexCategory.upsert({
|
||||
where: { catalogId_groupId: { catalogId: catalog.id, groupId } },
|
||||
update: { name, nameTr, updatedAt: new Date() },
|
||||
create: {
|
||||
catalogId: catalog.id,
|
||||
groupId,
|
||||
name,
|
||||
nameTr,
|
||||
hasParts: true,
|
||||
},
|
||||
});
|
||||
savedCount++;
|
||||
}
|
||||
console.log(` Saved ${savedCount} categories`);
|
||||
|
||||
// Create session
|
||||
await prisma.emexScrapeSession.create({
|
||||
data: {
|
||||
catalogId: catalog.id,
|
||||
brandCode,
|
||||
status: 'COMPLETED',
|
||||
totalItems: groups.length,
|
||||
processedItems: savedCount,
|
||||
startedAt: new Date(),
|
||||
completedAt: new Date(),
|
||||
stats: { vin: VIN, ssd: finalSsd, categoriesFound: groups.length },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
console.log('\n========================================');
|
||||
console.log('TEST COMPLETED!');
|
||||
console.log('========================================');
|
||||
} catch (error) {
|
||||
console.error('\n❌ Error:', error);
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
205
apps/api/src/integrations/emex/scraper/test-scraper.ts
Normal file
205
apps/api/src/integrations/emex/scraper/test-scraper.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* Test script for EmexParallelScraperService
|
||||
* Run: npx ts-node -r tsconfig-paths/register src/integrations/emex/scraper/test-scraper.ts
|
||||
*/
|
||||
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import * as path from 'path';
|
||||
|
||||
// VIN to test
|
||||
const TEST_VIN = 'WF0RXXGCDRAM33635';
|
||||
|
||||
async function main() {
|
||||
console.log('========================================');
|
||||
console.log('EMEX Parallel Scraper Test');
|
||||
console.log('========================================\n');
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
try {
|
||||
// Step 1: Load the old scraper to get vehicle info
|
||||
console.log(`[1/5] Loading EMEX VIN Scraper...`);
|
||||
const scraperPath = '/www/wwwroot/sase.tr/scripts/emex-vin-scraper.js';
|
||||
const scraperModule = require(scraperPath);
|
||||
const { EmexVinScraper, getCatalogCode } = scraperModule;
|
||||
|
||||
// Step 2: Get catalog code from VIN
|
||||
const catalogCode = getCatalogCode(TEST_VIN);
|
||||
console.log(`[2/5] VIN: ${TEST_VIN}`);
|
||||
console.log(` Catalog Code: ${catalogCode || 'Unknown'}`);
|
||||
|
||||
if (!catalogCode) {
|
||||
throw new Error('Could not determine catalog code from VIN');
|
||||
}
|
||||
|
||||
// Extract brand code from catalog (e.g., "FORD00" -> "FORD")
|
||||
const brandCode = catalogCode.replace(/\d+/g, '');
|
||||
console.log(` Brand Code: ${brandCode}`);
|
||||
|
||||
// Step 3: Initialize scraper and search by VIN
|
||||
console.log(`\n[3/5] Initializing scraper and searching VIN...`);
|
||||
const scraper = new EmexVinScraper();
|
||||
await scraper.init();
|
||||
|
||||
const result = await scraper.searchByVIN(TEST_VIN);
|
||||
console.log(` Success: ${result.success}`);
|
||||
|
||||
if (!result.success) {
|
||||
console.log(` Error: ${result.error || result.message}`);
|
||||
await scraper.close();
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(` Vehicle: ${result.vehicle?.name || 'N/A'}`);
|
||||
console.log(` Engine: ${result.vehicle?.engine || 'N/A'}`);
|
||||
console.log(` Quick Groups URL: ${result.quickGroupsUrl ? 'Yes' : 'No'}`);
|
||||
|
||||
// Step 4: Get categories
|
||||
console.log(`\n[4/5] Fetching categories...`);
|
||||
let categories: any[] = [];
|
||||
if (result.quickGroupsUrl) {
|
||||
categories = await scraper.getCategories(result.quickGroupsUrl);
|
||||
console.log(` Found ${categories.length} categories`);
|
||||
|
||||
// Show first 10 categories
|
||||
categories.slice(0, 10).forEach((cat, i) => {
|
||||
console.log(` ${i + 1}. ${cat.name} (gid: ${cat.gid})`);
|
||||
});
|
||||
|
||||
if (categories.length > 10) {
|
||||
console.log(` ... and ${categories.length - 10} more`);
|
||||
}
|
||||
}
|
||||
|
||||
await scraper.close();
|
||||
|
||||
// Step 5: Save to database using new schema
|
||||
console.log(`\n[5/5] Saving to database...`);
|
||||
|
||||
// Create or get catalog
|
||||
const catalog = await prisma.emexCatalog.upsert({
|
||||
where: { code: catalogCode },
|
||||
update: {
|
||||
name: `${brandCode} Catalog`,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
create: {
|
||||
code: catalogCode,
|
||||
name: `${brandCode} Catalog`,
|
||||
brandCode,
|
||||
supportVinSearch: true,
|
||||
supportQuickGroups: true,
|
||||
},
|
||||
});
|
||||
console.log(` Catalog ID: ${catalog.id}`);
|
||||
|
||||
// Create vehicle
|
||||
const uniqueKey = `${TEST_VIN}_${result.vehicle?.ssd || 'unknown'}`;
|
||||
const vehicle = await prisma.emexVehicle.upsert({
|
||||
where: {
|
||||
catalogId_uniqueKey: {
|
||||
catalogId: catalog.id,
|
||||
uniqueKey,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
name: result.vehicle?.name || TEST_VIN,
|
||||
engine: result.vehicle?.engine || null,
|
||||
options: result.vehicle?.options || null,
|
||||
ssd: result.vehicle?.ssd || result.ssd || '',
|
||||
pathData: result.vehicle?.pathData || null,
|
||||
sourceUrl: result.quickGroupsUrl || null,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
create: {
|
||||
catalogId: catalog.id,
|
||||
name: result.vehicle?.name || TEST_VIN,
|
||||
engine: result.vehicle?.engine || null,
|
||||
options: result.vehicle?.options || null,
|
||||
ssd: result.vehicle?.ssd || result.ssd || '',
|
||||
pathData: result.vehicle?.pathData || null,
|
||||
sourceUrl: result.quickGroupsUrl || null,
|
||||
uniqueKey,
|
||||
},
|
||||
});
|
||||
console.log(` Vehicle ID: ${vehicle.id}`);
|
||||
console.log(` Vehicle Name: ${vehicle.name}`);
|
||||
|
||||
// Import category translations
|
||||
const { translateCategoryName } = require('./data/category-translations');
|
||||
|
||||
// Save categories
|
||||
let savedCategories = 0;
|
||||
for (const cat of categories) {
|
||||
const nameTr = translateCategoryName(cat.name);
|
||||
|
||||
await prisma.emexCategory.upsert({
|
||||
where: {
|
||||
catalogId_groupId: {
|
||||
catalogId: catalog.id,
|
||||
groupId: cat.gid,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
name: cat.name,
|
||||
nameTr,
|
||||
hasParts: true,
|
||||
schemaImageUrl: cat.url || null,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
create: {
|
||||
catalogId: catalog.id,
|
||||
groupId: cat.gid,
|
||||
name: cat.name,
|
||||
nameTr,
|
||||
hasParts: true,
|
||||
schemaImageUrl: cat.url || null,
|
||||
},
|
||||
});
|
||||
|
||||
savedCategories++;
|
||||
}
|
||||
console.log(` Saved ${savedCategories} categories`);
|
||||
|
||||
// Create session record
|
||||
const session = await prisma.emexScrapeSession.create({
|
||||
data: {
|
||||
catalogId: catalog.id,
|
||||
brandCode,
|
||||
status: 'COMPLETED',
|
||||
totalItems: categories.length,
|
||||
processedItems: savedCategories,
|
||||
startedAt: new Date(),
|
||||
completedAt: new Date(),
|
||||
stats: {
|
||||
vin: TEST_VIN,
|
||||
vehicleName: vehicle.name,
|
||||
categoriesFound: categories.length,
|
||||
categoriesSaved: savedCategories,
|
||||
},
|
||||
},
|
||||
});
|
||||
console.log(` Session ID: ${session.id}`);
|
||||
|
||||
console.log('\n========================================');
|
||||
console.log('TEST COMPLETED SUCCESSFULLY!');
|
||||
console.log('========================================');
|
||||
|
||||
// Summary
|
||||
console.log('\nSummary:');
|
||||
console.log(` VIN: ${TEST_VIN}`);
|
||||
console.log(` Brand: ${brandCode}`);
|
||||
console.log(` Vehicle: ${vehicle.name}`);
|
||||
console.log(` Categories: ${savedCategories}`);
|
||||
console.log(` Catalog ID: ${catalog.id}`);
|
||||
console.log(` Vehicle ID: ${vehicle.id}`);
|
||||
|
||||
} catch (error) {
|
||||
console.error('\n❌ Test failed:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { VinApiService } from './vin-api/vin-api.service';
|
||||
import { EmexModule } from './emex/emex.module';
|
||||
import { PL24Module } from './pl24/pl24.module';
|
||||
|
||||
@Module({
|
||||
imports: [EmexModule],
|
||||
imports: [EmexModule, PL24Module],
|
||||
providers: [VinApiService],
|
||||
exports: [VinApiService, EmexModule],
|
||||
exports: [VinApiService, EmexModule, PL24Module],
|
||||
})
|
||||
export class IntegrationsModule {}
|
||||
|
||||
8
apps/api/src/integrations/pl24/index.ts
Normal file
8
apps/api/src/integrations/pl24/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* PL24 Integration exports
|
||||
*/
|
||||
|
||||
export * from './pl24.types';
|
||||
export * from './pl24-auth.service';
|
||||
export * from './pl24.service';
|
||||
export * from './pl24.module';
|
||||
324
apps/api/src/integrations/pl24/pl24-auth.service.ts
Normal file
324
apps/api/src/integrations/pl24/pl24-auth.service.ts
Normal file
@@ -0,0 +1,324 @@
|
||||
/**
|
||||
* PartsLink24 Authentication Service
|
||||
*
|
||||
* Handles JWT authentication, token refresh, and session management
|
||||
* for the partslink24.com API.
|
||||
*/
|
||||
|
||||
import { Injectable, Logger, UnauthorizedException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import {
|
||||
PL24LoginRequest,
|
||||
PL24LoginResponse,
|
||||
PL24TokenData,
|
||||
PL24JWTPayload,
|
||||
PL24AuthorizeRequest,
|
||||
PL24AuthorizeResponse,
|
||||
PL24_ENDPOINTS,
|
||||
} from './pl24.types';
|
||||
|
||||
@Injectable()
|
||||
export class PL24AuthService {
|
||||
private readonly logger = new Logger(PL24AuthService.name);
|
||||
private tokenData: PL24TokenData | null = null;
|
||||
private serviceTokens: Map<string, { token: string; expiresAt: Date }> = new Map();
|
||||
|
||||
private readonly baseUrl: string;
|
||||
private readonly companyCode: string;
|
||||
private readonly username: string;
|
||||
private readonly password: string;
|
||||
private readonly timeout: number;
|
||||
|
||||
constructor(private configService: ConfigService) {
|
||||
this.baseUrl = this.configService.get<string>(
|
||||
'PL24_BASE_URL',
|
||||
'https://www.partslink24.com',
|
||||
);
|
||||
this.companyCode = this.configService.get<string>('PL24_COMPANY_CODE', '');
|
||||
this.username = this.configService.get<string>('PL24_USERNAME', '');
|
||||
this.password = this.configService.get<string>('PL24_PASSWORD', '');
|
||||
this.timeout = parseInt(String(this.configService.get('PL24_TIMEOUT', '30000')), 10) || 30000;
|
||||
|
||||
if (!this.companyCode || !this.username || !this.password) {
|
||||
this.logger.warn(
|
||||
'PL24 credentials not configured. Set PL24_COMPANY_CODE, PL24_USERNAME, PL24_PASSWORD',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Login to PL24 and get access token
|
||||
* Uses squeezeOut=true to force logout other sessions
|
||||
*/
|
||||
async login(forceNew = false): Promise<PL24TokenData> {
|
||||
// Return cached token if still valid
|
||||
if (!forceNew && this.tokenData && this.isTokenValid(this.tokenData)) {
|
||||
return this.tokenData;
|
||||
}
|
||||
|
||||
this.logger.log('Logging in to PL24...');
|
||||
|
||||
const loginRequest: PL24LoginRequest = {
|
||||
authentication: {
|
||||
account: this.companyCode,
|
||||
user: this.username,
|
||||
pwd: this.password,
|
||||
},
|
||||
device: {
|
||||
id: '0',
|
||||
os: 'Linux x86_64',
|
||||
offset: '0',
|
||||
lang: 'en-US',
|
||||
'os-version': '0',
|
||||
},
|
||||
'app-version': '',
|
||||
squeezeOut: true, // Force logout other sessions
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}${PL24_ENDPOINTS.LOGIN}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36',
|
||||
},
|
||||
body: JSON.stringify(loginRequest),
|
||||
signal: AbortSignal.timeout(this.timeout),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data: PL24LoginResponse = await response.json();
|
||||
|
||||
// Handle USER_ALREADY_LOGGED_IN status (need to login with squeezeOut=true)
|
||||
if (data.status === 'USER_ALREADY_LOGGED_IN') {
|
||||
this.logger.warn('User already logged in, session should be squeezed out');
|
||||
}
|
||||
|
||||
// Check for token presence - successful login returns token regardless of status field
|
||||
if (!data.token?.access_token) {
|
||||
this.logger.error(`PL24 login failed: ${data.status} - ${data.message || 'No token returned'}`);
|
||||
throw new UnauthorizedException(
|
||||
`PL24 giris basarisiz: ${data.message || data.status || 'Token alinamadi'}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Extract session cookie from response headers
|
||||
const setCookie = response.headers.get('set-cookie');
|
||||
const sessionCookie = this.extractSessionCookie(setCookie);
|
||||
|
||||
// Parse JWT to get expiration and services
|
||||
const payload = this.decodeJWT(data.token.access_token);
|
||||
|
||||
this.tokenData = {
|
||||
accessToken: data.token.access_token,
|
||||
refreshToken: data.refreshToken || '',
|
||||
sessionCookie: sessionCookie,
|
||||
expiresAt: new Date(payload.exp * 1000),
|
||||
services: payload.services || [],
|
||||
};
|
||||
|
||||
this.logger.log(
|
||||
`PL24 login successful. Token expires at ${this.tokenData.expiresAt.toISOString()}`,
|
||||
);
|
||||
this.logger.log(`Available services: ${this.tokenData.services.length}`);
|
||||
|
||||
return this.tokenData;
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
if (err.name === 'TimeoutError') {
|
||||
throw new UnauthorizedException('PL24 giris zaman asimina ugradi');
|
||||
}
|
||||
this.logger.error(`PL24 login error: ${err.message}`);
|
||||
throw new UnauthorizedException(`PL24 giris hatasi: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get service-specific authorization token
|
||||
* Required for accessing specific catalogs
|
||||
*/
|
||||
async authorizeService(serviceName: string): Promise<string> {
|
||||
// Check cached service token
|
||||
const cached = this.serviceTokens.get(serviceName);
|
||||
if (cached && cached.expiresAt > new Date()) {
|
||||
return cached.token;
|
||||
}
|
||||
|
||||
// Ensure we have a valid main token
|
||||
const mainToken = await this.getAccessToken();
|
||||
|
||||
this.logger.log(`Authorizing service: ${serviceName}`);
|
||||
|
||||
const authorizeRequest: PL24AuthorizeRequest = {
|
||||
serviceNames: [
|
||||
'cart',
|
||||
'pl24-full-vin-data',
|
||||
'pl24-orderbridge',
|
||||
'pl24-orderbridge-cart',
|
||||
'pl24-sendbtmail',
|
||||
'pl24-qparts',
|
||||
'orderBook',
|
||||
'pl24-usage',
|
||||
'pl24-tls-pilot',
|
||||
serviceName,
|
||||
],
|
||||
serviceCategoryNames: ['pl24-shop-universal', 'pl24-shop-tools'],
|
||||
withLogin: true,
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}${PL24_ENDPOINTS.AUTHORIZE}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
Authorization: `Bearer ${mainToken}`,
|
||||
Cookie: this.tokenData?.sessionCookie || '',
|
||||
},
|
||||
body: JSON.stringify(authorizeRequest),
|
||||
signal: AbortSignal.timeout(this.timeout),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data: PL24AuthorizeResponse = await response.json();
|
||||
|
||||
// Response can have token at root level (access_token) or nested (token.access_token)
|
||||
const accessToken = data.access_token || data.token?.access_token;
|
||||
if (!accessToken) {
|
||||
throw new Error('No service token in response');
|
||||
}
|
||||
|
||||
// Cache the service token
|
||||
const payload = this.decodeJWT(accessToken);
|
||||
this.serviceTokens.set(serviceName, {
|
||||
token: accessToken,
|
||||
expiresAt: new Date(payload.exp * 1000),
|
||||
});
|
||||
|
||||
this.logger.log(`Service ${serviceName} authorized successfully`);
|
||||
return accessToken;
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
this.logger.error(`Service authorization error: ${err.message}`);
|
||||
throw new UnauthorizedException(
|
||||
`Servis yetkilendirme hatasi: ${err.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current access token, refreshing if needed
|
||||
*/
|
||||
async getAccessToken(): Promise<string> {
|
||||
if (!this.tokenData || !this.isTokenValid(this.tokenData)) {
|
||||
await this.login();
|
||||
}
|
||||
return this.tokenData!.accessToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get session cookie for requests
|
||||
*/
|
||||
async getSessionCookie(): Promise<string> {
|
||||
if (!this.tokenData || !this.isTokenValid(this.tokenData)) {
|
||||
await this.login();
|
||||
}
|
||||
return this.tokenData!.sessionCookie;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available services from current token
|
||||
*/
|
||||
getAvailableServices(): string[] {
|
||||
return this.tokenData?.services || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a service is available
|
||||
*/
|
||||
hasService(serviceName: string): boolean {
|
||||
return this.tokenData?.services.includes(serviceName) || false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all cached tokens
|
||||
*/
|
||||
clearTokens(): void {
|
||||
this.tokenData = null;
|
||||
this.serviceTokens.clear();
|
||||
this.logger.log('All PL24 tokens cleared');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if token is still valid (with 60 second buffer)
|
||||
*/
|
||||
private isTokenValid(token: PL24TokenData): boolean {
|
||||
const bufferMs = 60 * 1000; // 60 second buffer
|
||||
return token.expiresAt.getTime() - bufferMs > Date.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode JWT token payload
|
||||
*/
|
||||
private decodeJWT(token: string): PL24JWTPayload {
|
||||
try {
|
||||
const parts = token.split('.');
|
||||
if (parts.length !== 3) {
|
||||
throw new Error('Invalid JWT format');
|
||||
}
|
||||
const payload = Buffer.from(parts[1], 'base64').toString('utf-8');
|
||||
return JSON.parse(payload);
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to decode JWT');
|
||||
throw new Error('Invalid JWT token');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract session cookie from Set-Cookie header
|
||||
*/
|
||||
private extractSessionCookie(setCookie: string | null): string {
|
||||
if (!setCookie) return '';
|
||||
|
||||
const match = setCookie.match(/PL24TOKEN=([^;]+)/);
|
||||
if (match) {
|
||||
return `PL24TOKEN=${match[1]}`;
|
||||
}
|
||||
|
||||
// Return full cookie if pattern not found
|
||||
return setCookie.split(';')[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build authorization headers for API requests
|
||||
* @param serviceName - Optional service name to get service-specific token
|
||||
* @param includeContentType - Whether to include Content-Type header (default: false for GET, true for POST)
|
||||
*/
|
||||
async buildAuthHeaders(serviceName?: string, includeContentType = false): Promise<Record<string, string>> {
|
||||
const token = serviceName
|
||||
? await this.authorizeService(serviceName)
|
||||
: await this.getAccessToken();
|
||||
|
||||
const sessionCookie = await this.getSessionCookie();
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Cookie: sessionCookie,
|
||||
Accept: 'application/json',
|
||||
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36',
|
||||
};
|
||||
|
||||
if (includeContentType) {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
}
|
||||
18
apps/api/src/integrations/pl24/pl24.module.ts
Normal file
18
apps/api/src/integrations/pl24/pl24.module.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* PartsLink24 Integration Module
|
||||
*
|
||||
* NestJS module for partslink24.com VIN integration.
|
||||
* Provides PL24Service for VIN decoding and on-demand parts fetching.
|
||||
*/
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { PL24AuthService } from './pl24-auth.service';
|
||||
import { PL24Service } from './pl24.service';
|
||||
|
||||
@Module({
|
||||
imports: [ConfigModule],
|
||||
providers: [PL24AuthService, PL24Service],
|
||||
exports: [PL24Service, PL24AuthService],
|
||||
})
|
||||
export class PL24Module {}
|
||||
1022
apps/api/src/integrations/pl24/pl24.service.ts
Normal file
1022
apps/api/src/integrations/pl24/pl24.service.ts
Normal file
File diff suppressed because it is too large
Load Diff
447
apps/api/src/integrations/pl24/pl24.types.ts
Normal file
447
apps/api/src/integrations/pl24/pl24.types.ts
Normal file
@@ -0,0 +1,447 @@
|
||||
/**
|
||||
* PartsLink24 (PL24) API Types
|
||||
*
|
||||
* Type definitions for partslink24.com VIN integration.
|
||||
* PL24 uses JWT authentication and provides P5 architecture for modern catalogs.
|
||||
*/
|
||||
|
||||
// ==================== AUTH TYPES ====================
|
||||
|
||||
/**
|
||||
* Login request body
|
||||
*/
|
||||
export interface PL24LoginRequest {
|
||||
authentication: {
|
||||
account: string;
|
||||
user: string;
|
||||
pwd: string;
|
||||
};
|
||||
device: {
|
||||
id: string;
|
||||
os: string;
|
||||
offset: string;
|
||||
lang: string;
|
||||
'os-version': string;
|
||||
};
|
||||
'app-version': string;
|
||||
squeezeOut: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Login response from PL24
|
||||
* Note: status can be null on successful login, "USER_ALREADY_LOGGED_IN" if already logged in
|
||||
*/
|
||||
export interface PL24LoginResponse {
|
||||
status: 'OK' | 'USER_ALREADY_LOGGED_IN' | 'INVALID_CREDENTIALS' | 'ERROR' | null;
|
||||
message?: string;
|
||||
token?: {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
expires_in: number;
|
||||
scope: string;
|
||||
};
|
||||
refreshToken?: string;
|
||||
securables?: unknown;
|
||||
title?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decoded JWT payload
|
||||
*/
|
||||
export interface PL24JWTPayload {
|
||||
iat: number;
|
||||
exp: number;
|
||||
sid: string;
|
||||
aid: number;
|
||||
uid: number;
|
||||
services: string[];
|
||||
licid: number;
|
||||
app: string;
|
||||
type: string;
|
||||
country: string;
|
||||
ulo: string;
|
||||
alo: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stored token data
|
||||
*/
|
||||
export interface PL24TokenData {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
sessionCookie: string;
|
||||
expiresAt: Date;
|
||||
services: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Service authorization request
|
||||
*/
|
||||
export interface PL24AuthorizeRequest {
|
||||
serviceNames: string[];
|
||||
serviceCategoryNames: string[];
|
||||
withLogin: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Service authorization response
|
||||
* Note: Response can have token at root level or nested in token object
|
||||
*/
|
||||
export interface PL24AuthorizeResponse {
|
||||
// Direct format (from /auth/ext/api/1.1/authorize)
|
||||
access_token?: string;
|
||||
token_type?: string;
|
||||
expires_in?: number;
|
||||
scope?: string;
|
||||
session_status?: string;
|
||||
lcSessionId?: string | null;
|
||||
// Nested format
|
||||
token?: {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
expires_in: number;
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== CATALOG TYPES ====================
|
||||
|
||||
/**
|
||||
* Manufacturer/Brand info
|
||||
*/
|
||||
export interface PL24Manufacturer {
|
||||
id: string;
|
||||
name: string;
|
||||
serviceName: string;
|
||||
logoUrl?: string;
|
||||
catalogType: 'P5' | 'P4';
|
||||
}
|
||||
|
||||
/**
|
||||
* Catalog metadata
|
||||
*/
|
||||
export interface PL24CatalogMeta {
|
||||
serviceName: string;
|
||||
country: string;
|
||||
language: string;
|
||||
catalogType: string;
|
||||
features: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Model family from catalog
|
||||
*/
|
||||
export interface PL24ModelFamily {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
imageUrl?: string;
|
||||
years?: string;
|
||||
}
|
||||
|
||||
// ==================== VEHICLE TYPES ====================
|
||||
|
||||
/**
|
||||
* Direct access (VIN search) response
|
||||
*/
|
||||
export interface PL24DirectAccessResponse {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
errorCode?: string;
|
||||
vehicle?: PL24Vehicle;
|
||||
catalog?: PL24CatalogInfo;
|
||||
mainGroups?: PL24MainGroup[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Vehicle info from VIN decode
|
||||
*/
|
||||
export interface PL24Vehicle {
|
||||
vin: string;
|
||||
brand: string;
|
||||
model: string;
|
||||
modelYear: number;
|
||||
series?: string;
|
||||
bodyType?: string;
|
||||
engineCode?: string;
|
||||
engineType?: string;
|
||||
engineVolume?: string;
|
||||
transmission?: string;
|
||||
driveType?: string;
|
||||
colorCode?: string;
|
||||
productionDate?: string;
|
||||
equipment?: PL24Equipment[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Equipment/option info
|
||||
*/
|
||||
export interface PL24Equipment {
|
||||
code: string;
|
||||
description: string;
|
||||
category?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Catalog info for vehicle
|
||||
*/
|
||||
export interface PL24CatalogInfo {
|
||||
serviceName: string;
|
||||
vehicleId: string;
|
||||
catalogPath: string;
|
||||
baseUrl: string;
|
||||
mainGroupsPath?: string;
|
||||
}
|
||||
|
||||
// ==================== PARTS CATALOG TYPES ====================
|
||||
|
||||
/**
|
||||
* Main group (category)
|
||||
*/
|
||||
export interface PL24MainGroup {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
iconUrl?: string;
|
||||
subGroups?: PL24SubGroup[];
|
||||
// Link info for fetching parts
|
||||
linkPath?: string;
|
||||
linkWid?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sub group within main group
|
||||
*/
|
||||
export interface PL24SubGroup {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
imageUrl?: string;
|
||||
partCount?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parts list response
|
||||
*/
|
||||
export interface PL24PartsResponse {
|
||||
success: boolean;
|
||||
groupId: string;
|
||||
groupName: string;
|
||||
schemaImageUrl?: string;
|
||||
parts: PL24Part[];
|
||||
hotspots?: PL24Hotspot[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Part item
|
||||
*/
|
||||
export interface PL24Part {
|
||||
id: string;
|
||||
oemCode: string;
|
||||
// Formatted part number with spaces (e.g., "WHT 002 437")
|
||||
formattedPartNo?: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
// Remark field (e.g., "Colour code: JG3")
|
||||
remark?: string;
|
||||
quantity?: number;
|
||||
positionCode?: string;
|
||||
// Model codes / PR codes for compatibility (e.g., "PR:1PD+F...FM4")
|
||||
modelCodes?: string;
|
||||
notes?: string;
|
||||
superseded?: {
|
||||
oldCode: string;
|
||||
newCode: string;
|
||||
};
|
||||
restrictions?: string[];
|
||||
additionalInfo?: Record<string, string>;
|
||||
// Hotspot on diagram
|
||||
hotspotId?: string;
|
||||
// Link to part details
|
||||
linkPath?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hotspot position on schema image
|
||||
*/
|
||||
export interface PL24Hotspot {
|
||||
partId: string;
|
||||
positionCode: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
// ==================== API ENDPOINT TYPES ====================
|
||||
|
||||
/**
|
||||
* Known PL24 API endpoints
|
||||
*/
|
||||
export const PL24_ENDPOINTS = {
|
||||
// Auth
|
||||
LOGIN: '/pl24-appgtw/ext/api/1.0/login',
|
||||
AUTHORIZE: '/auth/ext/api/1.1/authorize',
|
||||
|
||||
// Catalog
|
||||
MANUFACTURERS: '/pl24-manufacturer/ext/api/1.0/manufacturers/',
|
||||
DEALERS: '/pl24-dealers/ext/api/2.0/catalogDealers',
|
||||
|
||||
// P5 Architecture (VW, Audi, BMW, Mercedes, etc.)
|
||||
P5_CATMETA: '/p5vwag/extern/catmeta',
|
||||
P5_MODEL_FAMILIES: '/p5vwag/extern/vehicle/modelfamilies',
|
||||
P5_DIRECT_ACCESS: '/p5vwag/extern/directAccess',
|
||||
P5_MAIN_GROUPS: '/p5vwag/extern/maingroups',
|
||||
P5_SUB_GROUPS: '/p5vwag/extern/subgroups',
|
||||
P5_PARTS: '/p5vwag/extern/parts',
|
||||
|
||||
// Cart
|
||||
CART: '/cart/ext/api/2.0/contextCart',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Service name to catalog base URL mapping
|
||||
*/
|
||||
export const PL24_SERVICE_CATALOGS: Record<string, string> = {
|
||||
vw_parts: '/p5vwag',
|
||||
audi_parts: '/p5vwag',
|
||||
skoda_parts: '/p5vwag',
|
||||
seat_parts: '/p5vwag',
|
||||
cupra_parts: '/p5vwag',
|
||||
bmw_parts: '/p5bmw',
|
||||
mini_parts: '/p5bmw',
|
||||
mercedes_parts: '/p5daimler',
|
||||
mercedesvans_parts: '/p5daimler',
|
||||
mercedestrucks_parts: '/p5daimler',
|
||||
porsche_parts: '/p5porsche',
|
||||
toyota_parts: '/p5toyota',
|
||||
lexus_parts: '/p5toyota',
|
||||
renault_parts: '/p5renault',
|
||||
dacia_parts: '/p5renault',
|
||||
jaguar_parts: '/p5jlr',
|
||||
landrover_parts: '/p5jlr',
|
||||
};
|
||||
|
||||
/**
|
||||
* WMI (World Manufacturer Identifier) to service name mapping
|
||||
*/
|
||||
export const PL24_WMI_SERVICE_MAP: Record<string, string> = {
|
||||
// Volkswagen Group
|
||||
WVW: 'vw_parts',
|
||||
WVG: 'vw_parts',
|
||||
WAU: 'audi_parts',
|
||||
TMB: 'skoda_parts',
|
||||
VSS: 'seat_parts',
|
||||
|
||||
// BMW Group
|
||||
WBA: 'bmw_parts',
|
||||
WBS: 'bmw_parts',
|
||||
WBY: 'bmw_parts',
|
||||
WMW: 'mini_parts',
|
||||
|
||||
// Mercedes-Benz
|
||||
WDB: 'mercedes_parts',
|
||||
WDD: 'mercedes_parts',
|
||||
WDC: 'mercedes_parts',
|
||||
WDF: 'mercedesvans_parts',
|
||||
|
||||
// Porsche
|
||||
WP0: 'porsche_parts',
|
||||
WP1: 'porsche_parts',
|
||||
|
||||
// Toyota/Lexus
|
||||
JTD: 'toyota_parts',
|
||||
JTE: 'toyota_parts',
|
||||
JTH: 'lexus_parts',
|
||||
|
||||
// Renault/Dacia
|
||||
VF1: 'renault_parts',
|
||||
UU1: 'dacia_parts',
|
||||
|
||||
// Jaguar Land Rover
|
||||
SAJ: 'jaguar_parts',
|
||||
SAL: 'landrover_parts',
|
||||
};
|
||||
|
||||
// ==================== CONFIG TYPES ====================
|
||||
|
||||
/**
|
||||
* PL24 service configuration
|
||||
*/
|
||||
export interface PL24Config {
|
||||
baseUrl: string;
|
||||
companyCode: string;
|
||||
username: string;
|
||||
password: string;
|
||||
timeout: number;
|
||||
debug: boolean;
|
||||
country: string;
|
||||
language: string;
|
||||
}
|
||||
|
||||
// ==================== STANDARDIZED OUTPUT (shared with EMEX) ====================
|
||||
|
||||
/**
|
||||
* Standardized decoded vehicle response
|
||||
*/
|
||||
export interface PL24DecodedVehicle {
|
||||
brand: string;
|
||||
model: string;
|
||||
year: number;
|
||||
series: string | null;
|
||||
bodyType: string | null;
|
||||
engineCode: string | null;
|
||||
engineType: string | null;
|
||||
engineVolume: string | null;
|
||||
transmission: string | null;
|
||||
driveType: string | null;
|
||||
colorCode: string | null;
|
||||
productionDate?: string | null;
|
||||
raw: Record<string, unknown>;
|
||||
catalogInfo: PL24CatalogInfo | null;
|
||||
categories: PL24DecodedCategory[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Standardized category structure
|
||||
*/
|
||||
export interface PL24DecodedCategory {
|
||||
code: string;
|
||||
nameEn: string;
|
||||
nameTr?: string;
|
||||
description: string | null;
|
||||
iconUrl: string | null;
|
||||
subGroups: PL24DecodedSubGroup[];
|
||||
// Link info for fetching subgroups/parts
|
||||
linkPath?: string;
|
||||
linkWid?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Standardized sub-group structure
|
||||
*/
|
||||
export interface PL24DecodedSubGroup {
|
||||
code: string;
|
||||
nameEn: string;
|
||||
nameTr?: string;
|
||||
description: string | null;
|
||||
schemaImageUrl: string | null;
|
||||
partCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Standardized part structure (fetched on-demand)
|
||||
*/
|
||||
export interface PL24DecodedPart {
|
||||
oemCode: string;
|
||||
alternativeOems?: string[];
|
||||
nameEn: string;
|
||||
nameTr?: string;
|
||||
description: string | null;
|
||||
positionCode?: string;
|
||||
positionX?: number;
|
||||
positionY?: number;
|
||||
quantity?: number;
|
||||
notes?: string;
|
||||
}
|
||||
@@ -26,14 +26,14 @@ export class VinApiService {
|
||||
|
||||
async decodeVin(vin: string): Promise<DecodedVehicle> {
|
||||
try {
|
||||
this.logger.log(`Decoding VIN: ${vin}`);
|
||||
this.logger.log(`Decoding VIN via VinApi: ${vin}`);
|
||||
|
||||
// In production, this would call the actual VIN API
|
||||
// For development, we'll simulate a response
|
||||
const isDevelopment = this.configService.get<string>('NODE_ENV') === 'development';
|
||||
const apiKey = this.configService.get<string>('VIN_API_KEY');
|
||||
|
||||
if (isDevelopment || !this.configService.get<string>('VIN_API_KEY')) {
|
||||
return this.getMockResponse(vin);
|
||||
// If no API key configured, throw error (no mock data)
|
||||
if (!apiKey) {
|
||||
this.logger.error('VIN_API_KEY not configured');
|
||||
throw new BadRequestException('VIN API yapilandirilmamis. PL24 veya EMEX kullanin.');
|
||||
}
|
||||
|
||||
const response = await this.client.get<VinApiResponse>(`/decode/${vin}`);
|
||||
@@ -54,95 +54,4 @@ export class VinApiService {
|
||||
}
|
||||
}
|
||||
|
||||
// Mock response for development/testing
|
||||
private getMockResponse(vin: string): DecodedVehicle {
|
||||
// Extract mock data from VIN structure
|
||||
const wmi = vin.substring(0, 3);
|
||||
const yearChar = vin.charAt(9);
|
||||
|
||||
// Simple brand detection from WMI
|
||||
const brandMap: Record<string, string> = {
|
||||
WVW: 'VOLKSWAGEN',
|
||||
WBA: 'BMW',
|
||||
WDB: 'MERCEDES',
|
||||
WAU: 'AUDI',
|
||||
ZFA: 'FIAT',
|
||||
VF1: 'RENAULT',
|
||||
JTD: 'TOYOTA',
|
||||
JHM: 'HONDA',
|
||||
WF0: 'FORD',
|
||||
W0L: 'OPEL',
|
||||
};
|
||||
|
||||
const brand = brandMap[wmi] || 'VOLKSWAGEN';
|
||||
|
||||
const yearMap: Record<string, number> = {
|
||||
A: 2010, B: 2011, C: 2012, D: 2013, E: 2014,
|
||||
F: 2015, G: 2016, H: 2017, J: 2018, K: 2019,
|
||||
L: 2020, M: 2021, N: 2022, P: 2023, R: 2024,
|
||||
};
|
||||
|
||||
const year = yearMap[yearChar] || 2020;
|
||||
|
||||
return {
|
||||
brand,
|
||||
model: brand === 'BMW' ? '320i' : brand === 'MERCEDES' ? 'C200' : 'Golf',
|
||||
year,
|
||||
series: brand === 'BMW' ? '3 Serisi' : null,
|
||||
bodyType: 'Sedan',
|
||||
engineCode: 'TSI',
|
||||
engineType: 'Benzin',
|
||||
engineVolume: '2.0L',
|
||||
transmission: 'Otomatik',
|
||||
driveType: 'FWD',
|
||||
colorCode: null,
|
||||
raw: { vin, mockData: true },
|
||||
categories: [
|
||||
{
|
||||
code: 'ENGINE',
|
||||
nameEn: 'Engine',
|
||||
description: 'Engine components',
|
||||
iconName: 'engine',
|
||||
schemaImageUrl: null,
|
||||
parts: [
|
||||
{
|
||||
oemCode: 'OEM-001',
|
||||
nameEn: 'Oil Filter',
|
||||
description: 'Engine oil filter',
|
||||
prices: [
|
||||
{ brand: 'Bosch', price: 150, currency: 'TRY', inStock: true },
|
||||
{ brand: 'Mann', price: 180, currency: 'TRY', inStock: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
oemCode: 'OEM-002',
|
||||
nameEn: 'Air Filter',
|
||||
description: 'Engine air filter',
|
||||
prices: [
|
||||
{ brand: 'Bosch', price: 200, currency: 'TRY', inStock: true },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
code: 'BRAKE',
|
||||
nameEn: 'Brake System',
|
||||
description: 'Brake components',
|
||||
iconName: 'brake',
|
||||
schemaImageUrl: null,
|
||||
parts: [
|
||||
{
|
||||
oemCode: 'OEM-101',
|
||||
nameEn: 'Front Brake Pads',
|
||||
description: 'Front brake pad set',
|
||||
prices: [
|
||||
{ brand: 'Brembo', price: 800, currency: 'TRY', inStock: true },
|
||||
{ brand: 'TRW', price: 650, currency: 'TRY', inStock: true },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,24 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { NestExpressApplication } from '@nestjs/platform-express';
|
||||
import { join } from 'path';
|
||||
import { AppModule } from './app.module';
|
||||
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
|
||||
import { TransformInterceptor } from './common/interceptors/transform.interceptor';
|
||||
import { LoggingInterceptor } from './common/interceptors/logging.interceptor';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppModule);
|
||||
const configService = app.get(ConfigService);
|
||||
|
||||
// Serve static files from public directory (for schema images)
|
||||
// __dirname in dist is dist/src, so we need to go up two levels
|
||||
// Prefix with /api/static so it routes through reverse proxy
|
||||
app.useStaticAssets(join(__dirname, '..', '..', 'public'), {
|
||||
prefix: '/api/static/',
|
||||
});
|
||||
|
||||
// Global prefix
|
||||
const apiPrefix = configService.get<string>('API_PREFIX', 'api');
|
||||
app.setGlobalPrefix(apiPrefix);
|
||||
|
||||
@@ -8,8 +8,10 @@ import {
|
||||
Query,
|
||||
UseGuards,
|
||||
Req,
|
||||
Res,
|
||||
HttpStatus,
|
||||
} from '@nestjs/common';
|
||||
import { Request } from 'express';
|
||||
import { Request, Response } from 'express';
|
||||
import { VehiclesService } from './vehicles.service';
|
||||
import { VinDecoderService } from './vin-decoder.service';
|
||||
import { DecodeVinDto } from './dto/decode-vin.dto';
|
||||
@@ -26,14 +28,23 @@ interface BrandAccessRequest extends Request {
|
||||
}
|
||||
|
||||
@Controller('vehicles')
|
||||
@UseGuards(JwtAuthGuard, BrandAccessGuard)
|
||||
export class VehiclesController {
|
||||
constructor(
|
||||
private readonly vehiclesService: VehiclesService,
|
||||
private readonly vinDecoderService: VinDecoderService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Public endpoint to get vehicle VIN by ID (for redirects)
|
||||
* No authentication required - only returns VIN
|
||||
*/
|
||||
@Get('by-id/:id')
|
||||
async getVehicleVinById(@Param('id') id: string) {
|
||||
return this.vehiclesService.getVehicleVinById(id);
|
||||
}
|
||||
|
||||
@Post('decode')
|
||||
@UseGuards(JwtAuthGuard, BrandAccessGuard)
|
||||
async decodeVin(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Body() dto: DecodeVinDto,
|
||||
@@ -48,6 +59,7 @@ export class VehiclesController {
|
||||
}
|
||||
|
||||
@Get()
|
||||
@UseGuards(JwtAuthGuard, BrandAccessGuard)
|
||||
async getUserVehicles(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Query() pagination: PaginationDto,
|
||||
@@ -57,6 +69,7 @@ export class VehiclesController {
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@UseGuards(JwtAuthGuard, BrandAccessGuard)
|
||||
async getVehicle(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Param('id') id: string,
|
||||
@@ -71,6 +84,7 @@ export class VehiclesController {
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@UseGuards(JwtAuthGuard, BrandAccessGuard)
|
||||
async deleteVehicle(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Param('id') id: string,
|
||||
@@ -79,11 +93,13 @@ export class VehiclesController {
|
||||
}
|
||||
|
||||
@Get(':id/categories')
|
||||
@UseGuards(JwtAuthGuard, BrandAccessGuard)
|
||||
async getVehicleCategories(@Param('id') id: string) {
|
||||
return this.vehiclesService.getVehicleCategories(id);
|
||||
}
|
||||
|
||||
@Get(':id/parts')
|
||||
@UseGuards(JwtAuthGuard, BrandAccessGuard)
|
||||
async getVehicleParts(
|
||||
@Param('id') id: string,
|
||||
@Query() pagination: PaginationDto,
|
||||
@@ -92,6 +108,7 @@ export class VehiclesController {
|
||||
}
|
||||
|
||||
@Get(':vin/categories/:categoryId/parts')
|
||||
@UseGuards(JwtAuthGuard, BrandAccessGuard)
|
||||
async getCategoryParts(
|
||||
@Param('vin') vin: string,
|
||||
@Param('categoryId') categoryId: string,
|
||||
@@ -106,4 +123,45 @@ export class VehiclesController {
|
||||
req.hasFullAccess || false,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Proxy endpoint for schema images from PL24
|
||||
* This allows the frontend to display images that require PL24 authentication
|
||||
* Note: No auth required - the PL24 URL itself contains a token
|
||||
*/
|
||||
@Get(':vin/schema-image')
|
||||
async getSchemaImage(
|
||||
@Param('vin') vin: string,
|
||||
@Query('url') imageUrl: string,
|
||||
@Res() res: Response,
|
||||
) {
|
||||
try {
|
||||
const imageBuffer = await this.vehiclesService.proxySchemaImage(vin, imageUrl);
|
||||
|
||||
if (!imageBuffer) {
|
||||
return res.status(HttpStatus.NOT_FOUND).json({ error: 'Image not found' });
|
||||
}
|
||||
|
||||
// Determine content type from URL or default to PNG
|
||||
let contentType = 'image/png';
|
||||
if (imageUrl.includes('.jpg') || imageUrl.includes('.jpeg')) {
|
||||
contentType = 'image/jpeg';
|
||||
} else if (imageUrl.includes('.gif')) {
|
||||
contentType = 'image/gif';
|
||||
} else if (imageUrl.includes('.webp')) {
|
||||
contentType = 'image/webp';
|
||||
} else if (imageUrl.includes('.svg')) {
|
||||
contentType = 'image/svg+xml';
|
||||
}
|
||||
|
||||
res.set({
|
||||
'Content-Type': contentType,
|
||||
'Cache-Control': 'public, max-age=86400', // Cache for 24 hours
|
||||
});
|
||||
|
||||
return res.send(imageBuffer);
|
||||
} catch (error) {
|
||||
return res.status(HttpStatus.INTERNAL_SERVER_ERROR).json({ error: 'Failed to fetch image' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,82 @@
|
||||
import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/common';
|
||||
import { Injectable, NotFoundException, ForbiddenException, Logger } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { PaginationDto, PaginatedResponseDto } from '../../common/dto/pagination.dto';
|
||||
import { VehicleFilterDto } from './dto/vehicle-filter.dto';
|
||||
import { PL24Service } from '../../integrations/pl24';
|
||||
import { EmexService } from '../../integrations/emex';
|
||||
|
||||
// Type for PL24 catalog info stored in rawResponse
|
||||
interface PL24CatalogInfo {
|
||||
serviceName: string;
|
||||
vehicleId: string;
|
||||
catalogPath: string;
|
||||
baseUrl: string;
|
||||
mainGroupsPath: string;
|
||||
}
|
||||
|
||||
interface PL24CategoryInfo {
|
||||
code: string;
|
||||
nameEn: string;
|
||||
nameTr?: string;
|
||||
linkPath?: string;
|
||||
linkWid?: string;
|
||||
}
|
||||
|
||||
interface FetchedPart {
|
||||
id: string;
|
||||
oemCode: string;
|
||||
// Formatted part number with spaces (e.g., "WHT 002 437")
|
||||
formattedPartNo?: string;
|
||||
oemCodes: string[];
|
||||
nameEn: string;
|
||||
nameTr: string;
|
||||
description: string | null;
|
||||
// Remark field (e.g., "Colour code: JG3")
|
||||
remark?: string;
|
||||
// Quantity (Unit column in PL24)
|
||||
quantity?: number;
|
||||
positionCode: string | null;
|
||||
// Model codes / PR codes for compatibility (e.g., "PR:1PD+F...FM4")
|
||||
modelCodes?: string;
|
||||
imageUrl: string | null;
|
||||
brandPrices: Array<{ brand: string; price: number; currency: string; inStock: boolean }>;
|
||||
}
|
||||
|
||||
interface SubGroupWithParts {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
schemaImageUrl: string | null;
|
||||
parts: FetchedPart[];
|
||||
}
|
||||
|
||||
// EMEX category info stored in rawResponse
|
||||
interface EmexCategoryInfo {
|
||||
gid: string;
|
||||
name: string;
|
||||
url: string | null;
|
||||
}
|
||||
|
||||
interface VehicleRawResponse {
|
||||
source?: string;
|
||||
// PL24 fields
|
||||
catalogInfo?: PL24CatalogInfo;
|
||||
pl24Categories?: PL24CategoryInfo[];
|
||||
// EMEX fields
|
||||
quickGroupsUrl?: string | null;
|
||||
emexCategories?: EmexCategoryInfo[];
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class VehiclesService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
private readonly logger = new Logger(VehiclesService.name);
|
||||
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private pl24Service: PL24Service,
|
||||
private emexService: EmexService,
|
||||
) {}
|
||||
|
||||
async getUserVehicles(userId: string, pagination: PaginationDto, filter: VehicleFilterDto) {
|
||||
const { page = 1, limit = 20, sortBy = 'createdAt', sortOrder = 'desc' } = pagination;
|
||||
@@ -80,6 +150,23 @@ export class VehiclesService {
|
||||
return vehicle;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get vehicle VIN by ID (public endpoint for redirects)
|
||||
* Only returns VIN, no sensitive data
|
||||
*/
|
||||
async getVehicleVinById(vehicleId: string) {
|
||||
const vehicle = await this.prisma.vehicle.findUnique({
|
||||
where: { id: vehicleId },
|
||||
select: { vin: true },
|
||||
});
|
||||
|
||||
if (!vehicle) {
|
||||
throw new NotFoundException('Arac bulunamadi');
|
||||
}
|
||||
|
||||
return { vin: vehicle.vin };
|
||||
}
|
||||
|
||||
async getVehicleByVin(vin: string) {
|
||||
return this.prisma.vehicle.findUnique({
|
||||
where: { vin },
|
||||
@@ -204,7 +291,8 @@ export class VehiclesService {
|
||||
const { page = 1, limit = 50, sortBy = 'oemCode', sortOrder = 'asc' } = pagination;
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const [parts, total] = await Promise.all([
|
||||
// Fetch parts, count, and VehicleCategory (with SchemaPic for local path) together
|
||||
let [parts, total, vehicleCategory] = await Promise.all([
|
||||
this.prisma.part.findMany({
|
||||
where: {
|
||||
vehicleId: vehicle.id,
|
||||
@@ -220,8 +308,93 @@ export class VehiclesService {
|
||||
categoryId: categoryId,
|
||||
},
|
||||
}),
|
||||
this.prisma.vehicleCategory.findUnique({
|
||||
where: {
|
||||
vehicleId_categoryId: {
|
||||
vehicleId: vehicle.id,
|
||||
categoryId: categoryId,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
schemaPic: true, // Include linked SchemaPic for local path
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
// Track subGroups with their schema images for PL24-like layout
|
||||
let subGroups: SubGroupWithParts[] = [];
|
||||
|
||||
// Get rawResponse from vehicle for source-specific data
|
||||
const rawResponse = vehicle.rawResponse as VehicleRawResponse | null;
|
||||
|
||||
// If no parts found, fetch on-demand based on source (PL24 or EMEX)
|
||||
if (total === 0) {
|
||||
if (rawResponse?.source === 'pl24' && rawResponse.catalogInfo) {
|
||||
this.logger.log(`No parts in DB for VIN ${vin}, category ${category.code}. Fetching from PL24...`);
|
||||
|
||||
const fetchResult = await this.fetchPartsFromPL24(
|
||||
vehicle,
|
||||
category,
|
||||
rawResponse,
|
||||
);
|
||||
|
||||
if (fetchResult.allParts.length > 0) {
|
||||
// Apply pagination to fetched parts (use any to bridge Prisma and FetchedPart types)
|
||||
parts = fetchResult.allParts.slice(skip, skip + limit) as any;
|
||||
total = fetchResult.allParts.length;
|
||||
subGroups = fetchResult.subGroups;
|
||||
}
|
||||
} else if (rawResponse?.source === 'emex' && rawResponse.emexCategories) {
|
||||
this.logger.log(`No parts in DB for VIN ${vin}, category ${category.code}. Fetching from EMEX...`);
|
||||
|
||||
const fetchResult = await this.fetchPartsFromEMEX(
|
||||
vehicle,
|
||||
category,
|
||||
rawResponse,
|
||||
);
|
||||
|
||||
if (fetchResult.allParts.length > 0) {
|
||||
// Apply pagination to fetched parts
|
||||
parts = fetchResult.allParts.slice(skip, skip + limit) as any;
|
||||
total = fetchResult.allParts.length;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get the schema image URL based on whether parts are from DB or freshly fetched
|
||||
let schemaImageUrl: string | null = null;
|
||||
|
||||
if (subGroups.length > 0) {
|
||||
// Parts were just fetched from PL24 - use proxy URL for PL24 image
|
||||
const originalUrl = subGroups[0]?.schemaImageUrl || null;
|
||||
if (originalUrl && originalUrl.startsWith('http')) {
|
||||
// Use proxy endpoint for authenticated access
|
||||
schemaImageUrl = `/api/vehicles/${vin}/schema-image?url=${encodeURIComponent(originalUrl)}`;
|
||||
} else {
|
||||
schemaImageUrl = originalUrl;
|
||||
}
|
||||
} else if (vehicleCategory) {
|
||||
// Parts are from DB - prefer local path from linked SchemaPic if available
|
||||
if (vehicleCategory.schemaPic?.localPath) {
|
||||
// Local path needs to go through /api/ route for reverse proxy
|
||||
// Convert /images/schemas/... to /api/static/images/schemas/...
|
||||
schemaImageUrl = `/api/static${vehicleCategory.schemaPic.localPath}`;
|
||||
} else if (vehicleCategory.schemaImageUrl) {
|
||||
// Use proxy endpoint for PL24 URL
|
||||
schemaImageUrl = `/api/vehicles/${vin}/schema-image?url=${encodeURIComponent(vehicleCategory.schemaImageUrl)}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to category's default schema image
|
||||
if (!schemaImageUrl) {
|
||||
schemaImageUrl = category.schemaImageUrl;
|
||||
}
|
||||
|
||||
// Extract additional vehicle data from rawResponse if available (for PL24 vehicles)
|
||||
const rawVehicleData = rawResponse?.catalogInfo?.serviceName
|
||||
? this.extractVehicleDetails(vehicle)
|
||||
: null;
|
||||
|
||||
return {
|
||||
vehicle: {
|
||||
id: vehicle.id,
|
||||
@@ -231,6 +404,13 @@ export class VehiclesService {
|
||||
year: vehicle.year,
|
||||
series: vehicle.series,
|
||||
engineCode: vehicle.engineCode,
|
||||
// Additional vehicle details from PL24 raw data
|
||||
productionDate: (vehicle as any).productionDate || rawVehicleData?.productionDate || null,
|
||||
transmissionCode: (vehicle as any).transmissionCode || rawVehicleData?.transmissionCode || null,
|
||||
driveType: (vehicle as any).driveType || rawVehicleData?.driveType || null,
|
||||
colorCode: (vehicle as any).colorCode || rawVehicleData?.colorCode || null,
|
||||
salesType: rawVehicleData?.salesType || null,
|
||||
equipment: rawVehicleData?.equipment || null,
|
||||
},
|
||||
category: {
|
||||
id: category.id,
|
||||
@@ -239,20 +419,478 @@ export class VehiclesService {
|
||||
nameEn: category.nameEn,
|
||||
slug: category.slug,
|
||||
iconName: category.iconName,
|
||||
schemaImageUrl: category.schemaImageUrl,
|
||||
schemaImageUrl: schemaImageUrl,
|
||||
},
|
||||
parts: parts.map((part) => ({
|
||||
id: part.id,
|
||||
oemCode: part.oemCode,
|
||||
alternativeOems: (part.oemCodes as string[]) || [],
|
||||
nameEn: part.nameEn,
|
||||
nameTr: part.nameTr,
|
||||
description: part.description,
|
||||
positionCode: part.positionCode,
|
||||
imageUrl: part.imageUrl,
|
||||
prices: (part.brandPrices as Array<{ brand: string; price: number; currency: string; inStock: boolean }>) || [],
|
||||
})),
|
||||
// PL24-like structure: sub-groups with individual diagrams
|
||||
subGroups: subGroups.map((sg) => {
|
||||
let sgSchemaUrl = sg.schemaImageUrl;
|
||||
// Convert PL24 URLs to proxy URLs
|
||||
if (sgSchemaUrl && sgSchemaUrl.startsWith('http')) {
|
||||
sgSchemaUrl = `/api/vehicles/${vin}/schema-image?url=${encodeURIComponent(sgSchemaUrl)}`;
|
||||
}
|
||||
return {
|
||||
id: sg.id,
|
||||
code: sg.code,
|
||||
name: sg.name,
|
||||
schemaImageUrl: sgSchemaUrl,
|
||||
partCount: sg.parts.length,
|
||||
};
|
||||
}),
|
||||
parts: parts.map((part) => {
|
||||
// Cast to any to handle both Prisma Part type and FetchedPart type
|
||||
const p = part as any;
|
||||
return {
|
||||
id: p.id,
|
||||
oemCode: p.oemCode,
|
||||
formattedPartNo: p.formattedPartNo || p.oemCode,
|
||||
alternativeOems: (p.oemCodes as string[]) || [],
|
||||
nameEn: p.nameEn,
|
||||
nameTr: p.nameTr,
|
||||
description: p.description,
|
||||
remark: p.remark || null,
|
||||
quantity: p.quantity || null,
|
||||
positionCode: p.positionCode,
|
||||
modelCodes: p.modelCodes || null,
|
||||
imageUrl: p.imageUrl,
|
||||
prices: (p.brandPrices as Array<{ brand: string; price: number; currency: string; inStock: boolean }>) || [],
|
||||
};
|
||||
}),
|
||||
totalParts: total,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract additional vehicle details from rawResponse
|
||||
* Used for displaying vehicle identification panel like PL24
|
||||
*/
|
||||
private extractVehicleDetails(vehicle: { rawResponse: unknown }): {
|
||||
productionDate: string | null;
|
||||
transmissionCode: string | null;
|
||||
driveType: string | null;
|
||||
colorCode: string | null;
|
||||
salesType: string | null;
|
||||
equipment: string | null;
|
||||
} | null {
|
||||
try {
|
||||
const raw = vehicle.rawResponse as Record<string, unknown>;
|
||||
if (!raw) return null;
|
||||
|
||||
// Try to extract from segments.vinfoBasic.records if available
|
||||
const segments = raw.segments as Record<string, { records?: Array<{ values: { description: string; value: string } }> }> || {};
|
||||
const vinfoRecords = segments.vinfoBasic?.records || [];
|
||||
|
||||
// Build a lookup map from vehicle data
|
||||
const vehicleData: Record<string, string> = {};
|
||||
for (const record of vinfoRecords) {
|
||||
if (record.values) {
|
||||
const key = record.values.description?.toLowerCase().replace(/[\s\/]+/g, '_') || '';
|
||||
vehicleData[key] = record.values.value?.replace(/\r?\n/g, ' ').trim() || '';
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
productionDate: vehicleData['date_of_production'] || null,
|
||||
transmissionCode: vehicleData['transmission_code'] || null,
|
||||
driveType: vehicleData['axle_drive'] || null,
|
||||
colorCode: vehicleData['exterior_color___paint_code'] || vehicleData['roof_color'] || null,
|
||||
salesType: vehicleData['sales_type'] || null,
|
||||
equipment: vehicleData['equipment'] || null,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch parts from PL24 on-demand for a specific category
|
||||
* This fetches sub-groups for the main group and then parts for each sub-group
|
||||
* Returns both parts and subGroups with their schema images for PL24-like layout
|
||||
*/
|
||||
private async fetchPartsFromPL24(
|
||||
vehicle: { id: string; vin: string },
|
||||
category: { id: string; code: string; nameEn: string },
|
||||
rawResponse: VehicleRawResponse,
|
||||
): Promise<{ allParts: FetchedPart[]; subGroups: SubGroupWithParts[] }> {
|
||||
const catalogInfo = rawResponse.catalogInfo!;
|
||||
const result: { allParts: FetchedPart[]; subGroups: SubGroupWithParts[] } = {
|
||||
allParts: [],
|
||||
subGroups: [],
|
||||
};
|
||||
|
||||
this.logger.log(`Fetching parts from PL24 for category ${category.code} (${category.nameEn})`);
|
||||
|
||||
try {
|
||||
// Get the linkPath for this category
|
||||
// First try from stored pl24Categories, if not available, re-fetch main groups
|
||||
let categoryLinkPath: string | undefined;
|
||||
const pl24Categories = rawResponse.pl24Categories || [];
|
||||
|
||||
const storedCategory = pl24Categories.find(
|
||||
(c) => c.code === category.code || c.nameEn === category.nameEn,
|
||||
);
|
||||
|
||||
if (storedCategory?.linkPath) {
|
||||
categoryLinkPath = storedCategory.linkPath;
|
||||
this.logger.log(`Using stored linkPath for category ${category.code}`);
|
||||
} else if (catalogInfo.mainGroupsPath) {
|
||||
// Re-fetch main groups to get linkPath
|
||||
this.logger.log(`Re-fetching main groups to get linkPath for category ${category.code}`);
|
||||
const mainGroups = await this.pl24Service.fetchMainGroups(
|
||||
catalogInfo.serviceName,
|
||||
catalogInfo.mainGroupsPath,
|
||||
);
|
||||
|
||||
const fetchedCategory = mainGroups.find(
|
||||
(g) => g.code === category.code || g.nameEn === category.nameEn,
|
||||
);
|
||||
|
||||
if (fetchedCategory?.linkPath) {
|
||||
categoryLinkPath = fetchedCategory.linkPath;
|
||||
this.logger.log(`Got linkPath for category ${category.code}: ${categoryLinkPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!categoryLinkPath) {
|
||||
this.logger.warn(`No linkPath found for category ${category.code}`);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Fetch sub-groups using the linkPath
|
||||
this.logger.log(`Fetching sub-groups from linkPath: ${categoryLinkPath}`);
|
||||
const subGroups = await this.pl24Service.fetchSubGroupsByPath(
|
||||
categoryLinkPath,
|
||||
catalogInfo.serviceName,
|
||||
);
|
||||
|
||||
if (!subGroups || subGroups.length === 0) {
|
||||
this.logger.warn(`No sub-groups found for category ${category.code}`);
|
||||
return result;
|
||||
}
|
||||
|
||||
this.logger.log(`Found ${subGroups.length} sub-groups for category ${category.code}`);
|
||||
|
||||
// Fetch parts from all sub-groups (limit to first 10 to avoid too many API calls)
|
||||
const allParts: FetchedPart[] = [];
|
||||
const subGroupsWithParts: SubGroupWithParts[] = [];
|
||||
|
||||
const subGroupsToFetch = subGroups.slice(0, 10);
|
||||
|
||||
// Track original PL24 URL for immediate display and async download
|
||||
let originalSchemaUrl: string | null = null;
|
||||
|
||||
for (const subGroup of subGroupsToFetch) {
|
||||
if (subGroup.linkPath) {
|
||||
try {
|
||||
// Fetch parts WITHOUT downloading image - use original PL24 URL for immediate display
|
||||
const partsResponse = await this.pl24Service.fetchPartsByPath(
|
||||
subGroup.linkPath,
|
||||
catalogInfo.serviceName,
|
||||
);
|
||||
|
||||
if (partsResponse.parts && partsResponse.parts.length > 0) {
|
||||
// Store original PL24 URL for immediate display
|
||||
if (partsResponse.schemaImageUrl && !originalSchemaUrl) {
|
||||
originalSchemaUrl = partsResponse.schemaImageUrl;
|
||||
}
|
||||
|
||||
this.logger.log(`Fetched ${partsResponse.parts.length} parts from sub-group ${subGroup.name}, schemaUrl: ${partsResponse.schemaImageUrl || 'none'}`);
|
||||
|
||||
const subGroupParts: SubGroupWithParts['parts'] = [];
|
||||
|
||||
for (const p of partsResponse.parts) {
|
||||
const partData: FetchedPart = {
|
||||
id: `pl24-${vehicle.id}-${p.id}`,
|
||||
oemCode: p.oemCode,
|
||||
formattedPartNo: p.formattedPartNo,
|
||||
oemCodes: [],
|
||||
nameEn: p.name,
|
||||
nameTr: p.name, // PL24 returns Turkish names when language is set to 'tr'
|
||||
description: p.description || null,
|
||||
remark: p.remark,
|
||||
quantity: p.quantity,
|
||||
positionCode: p.positionCode || null,
|
||||
modelCodes: p.modelCodes,
|
||||
imageUrl: null,
|
||||
brandPrices: [],
|
||||
};
|
||||
allParts.push(partData);
|
||||
subGroupParts.push(partData);
|
||||
}
|
||||
|
||||
// Add subgroup with its parts and schema image
|
||||
// Use original PL24 URL for immediate display
|
||||
subGroupsWithParts.push({
|
||||
id: subGroup.id,
|
||||
code: subGroup.code,
|
||||
name: subGroup.name,
|
||||
schemaImageUrl: partsResponse.schemaImageUrl || null,
|
||||
parts: subGroupParts,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
this.logger.warn(`Failed to fetch parts from sub-group ${subGroup.name}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Save parts to database for future queries
|
||||
if (allParts.length > 0) {
|
||||
this.logger.log(`Saving ${allParts.length} parts to database for category ${category.code}`);
|
||||
|
||||
const savedParts = await this.prisma.$transaction(async (tx) => {
|
||||
// Create parts
|
||||
await tx.part.createMany({
|
||||
data: allParts.map((p) => ({
|
||||
vehicleId: vehicle.id,
|
||||
categoryId: category.id,
|
||||
oemCode: p.oemCode,
|
||||
oemCodes: p.oemCodes,
|
||||
nameEn: p.nameEn,
|
||||
nameTr: p.nameTr,
|
||||
description: p.description,
|
||||
positionCode: p.positionCode,
|
||||
brandPrices: JSON.parse(JSON.stringify(p.brandPrices)),
|
||||
imageUrl: p.imageUrl,
|
||||
})),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
|
||||
// Update vehicle category part count and save original schema URL
|
||||
const updateData: { partCount: number; schemaImageUrl?: string } = {
|
||||
partCount: allParts.length,
|
||||
};
|
||||
|
||||
// Save original PL24 URL for reference
|
||||
if (originalSchemaUrl) {
|
||||
updateData.schemaImageUrl = originalSchemaUrl;
|
||||
}
|
||||
|
||||
await tx.vehicleCategory.updateMany({
|
||||
where: {
|
||||
vehicleId: vehicle.id,
|
||||
categoryId: category.id,
|
||||
},
|
||||
data: updateData,
|
||||
});
|
||||
|
||||
// Return the saved parts
|
||||
return tx.part.findMany({
|
||||
where: {
|
||||
vehicleId: vehicle.id,
|
||||
categoryId: category.id,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Handle schema image with deduplication via SchemaPic table
|
||||
// Same image can be used across multiple vehicles, so we store once and link
|
||||
let localSchemaPath: string | null = null;
|
||||
if (originalSchemaUrl) {
|
||||
try {
|
||||
// Extract imageId from URL for deduplication
|
||||
const imageId = this.pl24Service.extractImageIdFromUrl(originalSchemaUrl);
|
||||
|
||||
if (imageId) {
|
||||
// Check if SchemaPic already exists
|
||||
let schemaPic = await this.prisma.schemaPic.findUnique({
|
||||
where: { imageId },
|
||||
});
|
||||
|
||||
if (schemaPic) {
|
||||
// Image already downloaded, just link to it
|
||||
this.logger.log(`Schema image ${imageId} already exists, linking to VehicleCategory`);
|
||||
localSchemaPath = schemaPic.localPath;
|
||||
} else {
|
||||
// Download new image
|
||||
const downloadResult = await this.pl24Service.downloadSchemaImage(
|
||||
originalSchemaUrl,
|
||||
catalogInfo.serviceName,
|
||||
);
|
||||
|
||||
if (downloadResult) {
|
||||
// Create SchemaPic record
|
||||
schemaPic = await this.prisma.schemaPic.create({
|
||||
data: {
|
||||
imageId: downloadResult.imageId,
|
||||
localPath: downloadResult.localPath,
|
||||
originalUrl: originalSchemaUrl,
|
||||
fileSize: downloadResult.fileSize,
|
||||
width: downloadResult.width,
|
||||
height: downloadResult.height,
|
||||
},
|
||||
});
|
||||
this.logger.log(`Schema image saved: ${downloadResult.localPath} (${downloadResult.fileSize} bytes)`);
|
||||
localSchemaPath = downloadResult.localPath;
|
||||
}
|
||||
}
|
||||
|
||||
// Link VehicleCategory to SchemaPic
|
||||
if (schemaPic) {
|
||||
await this.prisma.vehicleCategory.updateMany({
|
||||
where: {
|
||||
vehicleId: vehicle.id,
|
||||
categoryId: category.id,
|
||||
},
|
||||
data: {
|
||||
schemaPicId: schemaPic.id,
|
||||
},
|
||||
});
|
||||
this.logger.log(`VehicleCategory linked to SchemaPic: ${schemaPic.id}`);
|
||||
}
|
||||
|
||||
// Update subGroups to use local path
|
||||
if (localSchemaPath) {
|
||||
subGroupsWithParts.forEach(sg => {
|
||||
if (sg.schemaImageUrl) {
|
||||
sg.schemaImageUrl = `/api/static${localSchemaPath}`;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.warn(`Failed to handle schema image: ${(error as Error).message}`);
|
||||
// Fall through - will use proxy URL as fallback
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
allParts: savedParts as any as FetchedPart[],
|
||||
subGroups: subGroupsWithParts,
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
this.logger.error(`Failed to fetch parts from PL24: ${err.message}`);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch parts from EMEX on-demand for a specific category
|
||||
* Similar to PL24, parts are only fetched when user clicks a category
|
||||
*/
|
||||
private async fetchPartsFromEMEX(
|
||||
vehicle: { id: string; vin: string },
|
||||
category: { id: string; code: string; nameEn: string },
|
||||
rawResponse: VehicleRawResponse,
|
||||
): Promise<{ allParts: FetchedPart[] }> {
|
||||
const result: { allParts: FetchedPart[] } = { allParts: [] };
|
||||
|
||||
// Find the category URL from stored emexCategories
|
||||
const emexCategories = rawResponse.emexCategories || [];
|
||||
const storedCategory = emexCategories.find(
|
||||
(c) => c.gid === category.code || c.name === category.nameEn,
|
||||
);
|
||||
|
||||
if (!storedCategory?.url) {
|
||||
this.logger.warn(`No URL found for EMEX category ${category.code}`);
|
||||
return result;
|
||||
}
|
||||
|
||||
this.logger.log(`Fetching parts from EMEX for category ${category.code} (${category.nameEn})`);
|
||||
|
||||
try {
|
||||
// Use EMEX service to fetch parts
|
||||
const parts = await this.emexService.fetchCategoryParts(storedCategory.url);
|
||||
|
||||
if (!parts || parts.length === 0) {
|
||||
this.logger.warn(`No parts found for EMEX category ${category.code}`);
|
||||
return result;
|
||||
}
|
||||
|
||||
this.logger.log(`Fetched ${parts.length} parts from EMEX for category ${category.code}`);
|
||||
|
||||
// Map EMEX parts to FetchedPart format
|
||||
const allParts: FetchedPart[] = parts.map((p, index) => ({
|
||||
id: `emex-${vehicle.id}-${index}`,
|
||||
oemCode: p.oemCode,
|
||||
oemCodes: [],
|
||||
nameEn: p.nameEn,
|
||||
nameTr: p.nameEn, // EMEX returns English names
|
||||
description: null,
|
||||
positionCode: p.positionCode || null,
|
||||
imageUrl: null,
|
||||
brandPrices: [],
|
||||
}));
|
||||
|
||||
// Save parts to database for future queries
|
||||
if (allParts.length > 0) {
|
||||
this.logger.log(`Saving ${allParts.length} EMEX parts to database for category ${category.code}`);
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
// Create parts
|
||||
await tx.part.createMany({
|
||||
data: allParts.map((p) => ({
|
||||
vehicleId: vehicle.id,
|
||||
categoryId: category.id,
|
||||
oemCode: p.oemCode,
|
||||
oemCodes: p.oemCodes,
|
||||
nameEn: p.nameEn,
|
||||
nameTr: p.nameTr,
|
||||
description: p.description,
|
||||
positionCode: p.positionCode,
|
||||
brandPrices: JSON.parse(JSON.stringify(p.brandPrices)),
|
||||
imageUrl: p.imageUrl,
|
||||
})),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
|
||||
// Update vehicle category part count
|
||||
await tx.vehicleCategory.updateMany({
|
||||
where: {
|
||||
vehicleId: vehicle.id,
|
||||
categoryId: category.id,
|
||||
},
|
||||
data: {
|
||||
partCount: allParts.length,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return { allParts };
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
this.logger.error(`Failed to fetch parts from EMEX: ${err.message}`);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Proxy schema image from PL24 with authentication
|
||||
* Used by the controller to serve images that require PL24 auth
|
||||
*/
|
||||
async proxySchemaImage(vin: string, imageUrl: string): Promise<Buffer | null> {
|
||||
if (!imageUrl || !imageUrl.startsWith('http')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get vehicle to find service name
|
||||
const vehicle = await this.prisma.vehicle.findUnique({
|
||||
where: { vin: vin.toUpperCase() },
|
||||
});
|
||||
|
||||
if (!vehicle) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rawResponse = vehicle.rawResponse as VehicleRawResponse | null;
|
||||
const serviceName = rawResponse?.catalogInfo?.serviceName;
|
||||
|
||||
if (!serviceName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// Use PL24 service to download the image with auth
|
||||
const imageBuffer = await this.pl24Service.fetchImageBuffer(imageUrl, serviceName);
|
||||
return imageBuffer;
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to proxy schema image: ${(error as Error).message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { VinApiService } from '../../integrations/vin-api/vin-api.service';
|
||||
import { DecodedCategory, DecodedPart } from '../../integrations/vin-api/vin-api.types';
|
||||
import { EmexService, DecodedVehicle as EmexDecodedVehicle } from '../../integrations/emex';
|
||||
import { PL24Service, PL24DecodedVehicle } from '../../integrations/pl24';
|
||||
import { normalizeVin } from '@sase/shared';
|
||||
|
||||
// Type for Prisma transaction client
|
||||
@@ -20,6 +21,7 @@ export class VinDecoderService {
|
||||
private prisma: PrismaService,
|
||||
private vinApiService: VinApiService,
|
||||
private emexService: EmexService,
|
||||
private pl24Service: PL24Service,
|
||||
) {}
|
||||
|
||||
async decodeVin(
|
||||
@@ -79,6 +81,7 @@ export class VinDecoderService {
|
||||
}
|
||||
|
||||
// 6. Save vehicle with categories and parts
|
||||
// Use longer timeout since we may have many categories to process
|
||||
const vehicle = await this.prisma.$transaction(async (tx) => {
|
||||
// Create vehicle
|
||||
const newVehicle = await tx.vehicle.create({
|
||||
@@ -135,6 +138,9 @@ export class VinDecoderService {
|
||||
}
|
||||
|
||||
return newVehicle;
|
||||
}, {
|
||||
timeout: 120000, // 2 minute timeout for transaction
|
||||
maxWait: 30000, // 30 seconds max wait time
|
||||
});
|
||||
|
||||
// 7. Log query
|
||||
@@ -163,18 +169,46 @@ export class VinDecoderService {
|
||||
|
||||
/**
|
||||
* Fetches VIN data from external sources.
|
||||
* Uses EMEX as the primary source, falls back to VinApiService if EMEX fails.
|
||||
* Priority: PL24 (API based, faster) > EMEX (scraping) > VinApi (fallback)
|
||||
*
|
||||
* Both PL24 and EMEX use on-demand parts loading:
|
||||
* - VIN decode returns vehicle info + category list (no parts)
|
||||
* - Parts are fetched when user clicks on a specific category
|
||||
* This makes VIN lookup much faster.
|
||||
*/
|
||||
private async fetchVinFromExternalSources(vin: string) {
|
||||
// Check if VIN manufacturer is supported by EMEX
|
||||
const isEmexSupported = this.emexService.isSupported(vin);
|
||||
// 1. Try PL24 first (API based, faster, supports major brands)
|
||||
const isPL24Supported = this.pl24Service.isSupported(vin);
|
||||
if (isPL24Supported) {
|
||||
try {
|
||||
this.logger.log(`Attempting PL24 decode for VIN: ${vin}`);
|
||||
const pl24Response = await this.pl24Service.decodeVin(vin);
|
||||
|
||||
if (pl24Response && pl24Response.brand && pl24Response.model) {
|
||||
this.logger.log(
|
||||
`PL24 decode successful: ${pl24Response.brand} ${pl24Response.model} (${pl24Response.year})`,
|
||||
);
|
||||
return this.mapPL24ResponseToVinApiFormat(pl24Response);
|
||||
}
|
||||
|
||||
this.logger.warn(
|
||||
`PL24 returned incomplete data for VIN: ${vin}, trying EMEX`,
|
||||
);
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
this.logger.warn(
|
||||
`PL24 decode failed for VIN: ${vin}, trying EMEX. Error: ${err.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Try EMEX (scraping based, slower but gets all parts)
|
||||
const isEmexSupported = this.emexService.isSupported(vin);
|
||||
if (isEmexSupported) {
|
||||
try {
|
||||
this.logger.log(`Attempting EMEX decode for VIN: ${vin}`);
|
||||
const emexResponse = await this.emexService.decodeVin(vin);
|
||||
|
||||
// Validate EMEX response has required data
|
||||
if (emexResponse && emexResponse.brand && emexResponse.model) {
|
||||
this.logger.log(
|
||||
`EMEX decode successful: ${emexResponse.brand} ${emexResponse.model} (${emexResponse.year})`,
|
||||
@@ -197,13 +231,58 @@ export class VinDecoderService {
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback to VinApiService
|
||||
// 3. Fallback to VinApiService
|
||||
this.logger.log(`Using VinApi fallback for VIN: ${vin}`);
|
||||
return this.vinApiService.decodeVin(vin);
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps PL24 response format to VinApiService format for compatibility
|
||||
* Note: PL24 returns categories without parts (parts are fetched on-demand)
|
||||
*/
|
||||
private mapPL24ResponseToVinApiFormat(pl24Response: PL24DecodedVehicle) {
|
||||
return {
|
||||
brand: pl24Response.brand,
|
||||
model: pl24Response.model,
|
||||
year: pl24Response.year,
|
||||
series: pl24Response.series,
|
||||
bodyType: pl24Response.bodyType,
|
||||
engineCode: pl24Response.engineCode,
|
||||
engineType: pl24Response.engineType,
|
||||
engineVolume: pl24Response.engineVolume,
|
||||
transmission: pl24Response.transmission,
|
||||
driveType: pl24Response.driveType,
|
||||
colorCode: pl24Response.colorCode,
|
||||
raw: {
|
||||
...pl24Response.raw,
|
||||
source: 'pl24',
|
||||
catalogInfo: pl24Response.catalogInfo,
|
||||
// Store category details with linkPaths for on-demand parts fetching
|
||||
pl24Categories: pl24Response.categories.map((cat) => ({
|
||||
code: cat.code,
|
||||
nameEn: cat.nameEn,
|
||||
nameTr: cat.nameTr,
|
||||
linkPath: cat.linkPath,
|
||||
linkWid: cat.linkWid,
|
||||
})),
|
||||
},
|
||||
// PL24 returns categories without parts (on-demand loading)
|
||||
categories: pl24Response.categories.map((cat) => ({
|
||||
code: cat.code,
|
||||
nameEn: cat.nameEn,
|
||||
nameTr: cat.nameTr,
|
||||
description: cat.description,
|
||||
iconName: cat.iconUrl,
|
||||
schemaImageUrl: null,
|
||||
parts: [], // Parts will be fetched on-demand when user clicks category
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps EMEX response format to VinApiService format for compatibility
|
||||
* Note: EMEX now uses on-demand parts loading (like PL24)
|
||||
* Parts are fetched when user clicks a category, not during VIN decode
|
||||
*/
|
||||
private mapEmexResponseToVinApiFormat(emexResponse: EmexDecodedVehicle) {
|
||||
return {
|
||||
@@ -218,7 +297,8 @@ export class VinDecoderService {
|
||||
transmission: emexResponse.transmission,
|
||||
driveType: emexResponse.driveType,
|
||||
colorCode: emexResponse.colorCode,
|
||||
raw: emexResponse.raw,
|
||||
raw: emexResponse.raw, // Contains source: 'emex' and emexCategories with URLs
|
||||
// EMEX now returns categories without parts (on-demand loading)
|
||||
categories: emexResponse.categories.map((cat) => ({
|
||||
code: cat.code,
|
||||
nameEn: cat.nameEn,
|
||||
@@ -226,18 +306,7 @@ export class VinDecoderService {
|
||||
description: cat.description,
|
||||
iconName: cat.iconName,
|
||||
schemaImageUrl: cat.schemaImageUrl,
|
||||
parts: cat.parts.map((part) => ({
|
||||
oemCode: part.oemCode,
|
||||
alternativeOems: part.alternativeOems || [],
|
||||
nameEn: part.nameEn,
|
||||
nameTr: part.nameTr,
|
||||
description: part.description,
|
||||
positionCode: part.positionCode,
|
||||
positionX: part.positionX,
|
||||
positionY: part.positionY,
|
||||
imageUrl: part.imageUrl,
|
||||
prices: part.prices || [],
|
||||
})),
|
||||
parts: [], // Parts will be fetched on-demand when user clicks category
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -262,12 +331,23 @@ export class VinDecoderService {
|
||||
let category = await tx.category.findUnique({ where: { code } });
|
||||
|
||||
if (!category) {
|
||||
// Create unique slug by adding code if base slug already exists
|
||||
const baseSlug = this.slugify(cat.nameEn);
|
||||
let slug = baseSlug;
|
||||
|
||||
// Check if slug already exists
|
||||
const existingBySlug = await tx.category.findUnique({ where: { slug } });
|
||||
if (existingBySlug) {
|
||||
// Add code to make it unique
|
||||
slug = `${baseSlug}-${code.toLowerCase()}`;
|
||||
}
|
||||
|
||||
category = await tx.category.create({
|
||||
data: {
|
||||
code,
|
||||
nameEn: cat.nameEn,
|
||||
nameTr: this.translateCategoryName(cat.nameEn),
|
||||
slug: this.slugify(cat.nameEn),
|
||||
slug,
|
||||
description: cat.description,
|
||||
iconName: cat.iconName,
|
||||
schemaImageUrl: cat.schemaImageUrl,
|
||||
|
||||
88
apps/api/test-schema-image.js
Normal file
88
apps/api/test-schema-image.js
Normal file
@@ -0,0 +1,88 @@
|
||||
const puppeteer = require('puppeteer');
|
||||
|
||||
(async () => {
|
||||
const browser = await puppeteer.launch({
|
||||
headless: true,
|
||||
args: ['--no-sandbox', '--disable-setuid-sandbox']
|
||||
});
|
||||
const page = await browser.newPage();
|
||||
|
||||
// Log all network requests for images
|
||||
const imageRequests = [];
|
||||
page.on('response', async (response) => {
|
||||
const url = response.url();
|
||||
if (url.includes('schema') || url.includes('image') || url.includes('static')) {
|
||||
imageRequests.push({
|
||||
url: url,
|
||||
status: response.status(),
|
||||
contentType: response.headers()['content-type']
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Login first
|
||||
console.log('Logging in...');
|
||||
await page.goto('https://sase.tr/login', { waitUntil: 'networkidle0' });
|
||||
await page.type('input[type="email"]', 'semih@advictr.com');
|
||||
await page.type('input[type="password"]', 'Deneme.1');
|
||||
await page.click('button[type="submit"]');
|
||||
await page.waitForNavigation({ waitUntil: 'networkidle0' });
|
||||
|
||||
console.log('Navigating to category page...');
|
||||
await page.goto('https://sase.tr/dashboard/vehicles/WVWZZZ1JZ3W386752/categories/cmkoin7ef0002ks2xcfbsyqgw', {
|
||||
waitUntil: 'networkidle0',
|
||||
timeout: 60000
|
||||
});
|
||||
|
||||
// Wait for content to load
|
||||
await new Promise(r => setTimeout(r, 3000));
|
||||
|
||||
// Check for broken images
|
||||
const brokenImages = await page.evaluate(() => {
|
||||
const images = document.querySelectorAll('img');
|
||||
const broken = [];
|
||||
images.forEach(img => {
|
||||
if (!img.complete || img.naturalWidth === 0) {
|
||||
broken.push({ src: img.src, alt: img.alt });
|
||||
}
|
||||
});
|
||||
return broken;
|
||||
});
|
||||
|
||||
// Get all image srcs
|
||||
const allImages = await page.evaluate(() => {
|
||||
const images = document.querySelectorAll('img');
|
||||
return Array.from(images).map(img => ({
|
||||
src: img.src,
|
||||
complete: img.complete,
|
||||
naturalWidth: img.naturalWidth
|
||||
}));
|
||||
});
|
||||
|
||||
console.log('\n=== All Images on Page ===');
|
||||
allImages.forEach(img => {
|
||||
const status = img.complete && img.naturalWidth > 0 ? 'OK' : 'BROKEN';
|
||||
console.log(status + ': ' + img.src.substring(0, 120));
|
||||
});
|
||||
|
||||
console.log('\n=== Image Network Requests ===');
|
||||
imageRequests.forEach(r => {
|
||||
console.log(r.status + ' - ' + r.url.substring(0, 120));
|
||||
});
|
||||
|
||||
console.log('\n=== Broken Images Summary ===');
|
||||
if (brokenImages.length === 0) {
|
||||
console.log('No broken images found!');
|
||||
} else {
|
||||
console.log('Found ' + brokenImages.length + ' broken images:');
|
||||
brokenImages.forEach(img => {
|
||||
console.log(' - ' + img.src);
|
||||
});
|
||||
}
|
||||
|
||||
// Take screenshot
|
||||
await page.screenshot({ path: '/tmp/schema-test.png', fullPage: false });
|
||||
console.log('\nScreenshot saved to /tmp/schema-test.png');
|
||||
|
||||
await browser.close();
|
||||
})();
|
||||
78
apps/api/test-screenshot.js
Normal file
78
apps/api/test-screenshot.js
Normal file
@@ -0,0 +1,78 @@
|
||||
const puppeteer = require('puppeteer');
|
||||
|
||||
(async () => {
|
||||
const browser = await puppeteer.launch({
|
||||
headless: true,
|
||||
executablePath: '/usr/bin/google-chrome-stable',
|
||||
args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage']
|
||||
});
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1920, height: 1080 });
|
||||
|
||||
// Collect failed requests
|
||||
const failedRequests = [];
|
||||
page.on('requestfailed', request => {
|
||||
failedRequests.push({
|
||||
url: request.url(),
|
||||
reason: request.failure().errorText
|
||||
});
|
||||
});
|
||||
|
||||
// Login first
|
||||
console.log('Logging in...');
|
||||
await page.goto('https://sase.tr/login', { waitUntil: 'networkidle0', timeout: 60000 });
|
||||
|
||||
await page.type('input[type="email"]', 'admin@sase.tr');
|
||||
await page.type('input[type="password"]', 'SaseAdmin2024');
|
||||
|
||||
// Click and wait for any navigation
|
||||
await Promise.all([
|
||||
page.click('button[type="submit"]'),
|
||||
page.waitForNavigation({ waitUntil: 'networkidle0', timeout: 60000 }).catch(() => {})
|
||||
]);
|
||||
|
||||
// Wait a bit
|
||||
await new Promise(r => setTimeout(r, 3000));
|
||||
|
||||
console.log('Current URL after login: ' + page.url());
|
||||
|
||||
console.log('Navigating to category page...');
|
||||
await page.goto('https://sase.tr/dashboard/vehicles/WVWZZZ1JZ3W386752/categories/cmkoin7ef0002ks2xcfbsyqgw', {
|
||||
waitUntil: 'domcontentloaded',
|
||||
timeout: 60000
|
||||
});
|
||||
|
||||
// Wait for page to fully render
|
||||
await new Promise(r => setTimeout(r, 8000));
|
||||
|
||||
// Wait extra time for images
|
||||
await new Promise(r => setTimeout(r, 5000));
|
||||
|
||||
// Get all image info
|
||||
const imageInfo = await page.evaluate(() => {
|
||||
const images = Array.from(document.querySelectorAll('img'));
|
||||
return images.map(img => ({
|
||||
src: img.src,
|
||||
complete: img.complete,
|
||||
naturalWidth: img.naturalWidth,
|
||||
naturalHeight: img.naturalHeight
|
||||
}));
|
||||
});
|
||||
|
||||
console.log('\n=== Images on Page ===');
|
||||
imageInfo.forEach(img => {
|
||||
const status = (img.complete && img.naturalWidth > 0) ? 'OK' : 'BROKEN';
|
||||
console.log(status + ' [' + img.naturalWidth + 'x' + img.naturalHeight + ']: ' + img.src.substring(0, 120));
|
||||
});
|
||||
|
||||
if (failedRequests.length > 0) {
|
||||
console.log('\n=== Failed Requests ===');
|
||||
failedRequests.forEach(r => console.log(r.reason + ': ' + r.url.substring(0, 100)));
|
||||
}
|
||||
|
||||
// Take screenshot
|
||||
await page.screenshot({ path: '/tmp/schema-test.png', fullPage: false });
|
||||
console.log('\nScreenshot saved to /tmp/schema-test.png');
|
||||
|
||||
await browser.close();
|
||||
})();
|
||||
2516
apps/web/CLAUDE.md
Normal file
2516
apps/web/CLAUDE.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,7 @@
|
||||
import { useState, FormEvent } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Loader2, Mail, Lock, ArrowRight, CheckCircle2, Sparkles } from 'lucide-react';
|
||||
import { Mail, Lock, ArrowRight, CheckCircle2, Sparkles, Eye, EyeOff } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
@@ -19,6 +19,7 @@ export default function LoginPage() {
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loginSuccess, setLoginSuccess] = useState(false);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
const handleLogin = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -58,15 +59,15 @@ export default function LoginPage() {
|
||||
|
||||
if (loginSuccess) {
|
||||
return (
|
||||
<Card className="border-0 shadow-xl">
|
||||
<Card className="animate-scale-in">
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<div className="h-16 w-16 rounded-full bg-green-100 dark:bg-green-900/30 flex items-center justify-center mb-4">
|
||||
<CheckCircle2 className="h-8 w-8 text-green-600" />
|
||||
<div className="h-16 w-16 rounded-full bg-success-50 ring ring-success/20 flex items-center justify-center mb-6 animate-bounce-soft">
|
||||
<CheckCircle2 className="h-8 w-8 text-success" />
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold mb-2">Giris Basarili!</h2>
|
||||
<h2 className="text-heading-md mb-2">Giris Basarili!</h2>
|
||||
<p className="text-muted-foreground mb-6">Yonlendiriliyorsunuz...</p>
|
||||
<Link href="/dashboard">
|
||||
<Button className="gradient-bg hover:opacity-90">
|
||||
<Button size="lg">
|
||||
Dashboard'a Git
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
@@ -77,19 +78,21 @@ export default function LoginPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="border-0 shadow-xl overflow-hidden">
|
||||
<div className="h-1 gradient-bg" />
|
||||
<CardHeader className="text-center pb-2">
|
||||
<div className="mx-auto mb-4 h-12 w-12 rounded-xl gradient-bg flex items-center justify-center lg:hidden">
|
||||
<Sparkles className="h-6 w-6 text-white" />
|
||||
<Card className="animate-fade-in">
|
||||
<CardHeader align="center" className="pb-4">
|
||||
<div className="mx-auto mb-4 h-14 w-14 rounded-xl gradient-primary flex items-center justify-center shadow-primary lg:hidden">
|
||||
<Sparkles className="h-7 w-7 text-white" />
|
||||
</div>
|
||||
<CardTitle className="text-2xl">Tekrar Hosgeldiniz</CardTitle>
|
||||
<CardTitle className="text-heading-md">Tekrar Hosgeldiniz</CardTitle>
|
||||
<CardDescription>Hesabiniza giris yapin</CardDescription>
|
||||
</CardHeader>
|
||||
<form onSubmit={handleLogin}>
|
||||
<CardContent className="space-y-4 pt-4">
|
||||
<CardContent className="space-y-4">
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 rounded-xl bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 p-3 text-sm text-red-700 dark:text-red-300">
|
||||
<div className="flex items-center gap-3 rounded-xl bg-destructive-50 ring ring-destructive/20 p-3 text-body-sm text-destructive animate-fade-in">
|
||||
<div className="h-8 w-8 rounded-lg bg-destructive/10 flex items-center justify-center flex-shrink-0">
|
||||
<span className="text-lg font-semibold">!</span>
|
||||
</div>
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -105,7 +108,7 @@ export default function LoginPage() {
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
disabled={isLoading}
|
||||
className="h-12 border-2 focus:border-purple-500"
|
||||
size="md"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
@@ -115,38 +118,53 @@ export default function LoginPage() {
|
||||
</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
placeholder="********"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={isLoading}
|
||||
className="h-12 border-2 focus:border-purple-500"
|
||||
size="md"
|
||||
rightIcon={
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="hover:text-primary transition-colors"
|
||||
>
|
||||
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Link href="/forgot-password" className="text-sm text-purple-600 hover:text-purple-700 hover:underline">
|
||||
<Link
|
||||
href="/forgot-password"
|
||||
className="text-caption-lg text-primary hover:text-primary/80 hover:underline transition-colors"
|
||||
>
|
||||
Sifremi unuttum
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="flex-col gap-4 pt-2">
|
||||
<CardFooter className="flex-col gap-4">
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full h-12 gradient-bg hover:opacity-90 shadow-lg shadow-purple-500/25 text-base group"
|
||||
disabled={isLoading}
|
||||
size="lg"
|
||||
block
|
||||
className="group"
|
||||
isLoading={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
||||
) : (
|
||||
{!isLoading && (
|
||||
<>
|
||||
Giris Yap
|
||||
<ArrowRight className="ml-2 h-5 w-5 group-hover:translate-x-1 transition-transform" />
|
||||
<ArrowRight className="ml-2 h-4 w-4 group-hover:translate-x-1 transition-transform" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<p className="text-body-sm text-muted-foreground">
|
||||
Hesabiniz yok mu?{' '}
|
||||
<Link href="/register" className="text-purple-600 hover:text-purple-700 font-medium hover:underline">
|
||||
<Link
|
||||
href="/register"
|
||||
className="text-primary hover:text-primary/80 font-semibold hover:underline transition-colors"
|
||||
>
|
||||
Kayit ol
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
@@ -1,251 +1,558 @@
|
||||
import Link from 'next/link';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Car, Search, Shield, Zap, CheckCircle2, ArrowRight, Sparkles } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Car,
|
||||
Search,
|
||||
Shield,
|
||||
Zap,
|
||||
CheckCircle2,
|
||||
ArrowRight,
|
||||
Sparkles,
|
||||
Database,
|
||||
Globe,
|
||||
Clock,
|
||||
Users,
|
||||
Star,
|
||||
ChevronRight,
|
||||
Play,
|
||||
BarChart3,
|
||||
Lock,
|
||||
Cpu
|
||||
} from 'lucide-react';
|
||||
import { ThemeToggle } from '@/components/ui/theme-toggle';
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col overflow-hidden">
|
||||
{/* Header */}
|
||||
<header className="fixed top-0 left-0 right-0 z-50 glass">
|
||||
<div className="container flex h-16 items-center justify-between">
|
||||
<Link href="/" className="flex items-center gap-2">
|
||||
<div className="h-8 w-8 rounded-lg gradient-bg flex items-center justify-center">
|
||||
<Car className="h-5 w-5 text-white" />
|
||||
<div className="flex min-h-screen flex-col overflow-hidden bg-background">
|
||||
{/* Header - Floating glass navbar */}
|
||||
<header className="fixed top-4 left-1/2 -translate-x-1/2 z-50 w-[calc(100%-2rem)] max-w-5xl">
|
||||
<div className="glass rounded-2xl px-4 py-2.5 ring ring-white/10">
|
||||
<div className="flex items-center justify-between">
|
||||
<Link href="/" className="flex items-center gap-2.5">
|
||||
<div className="h-9 w-9 rounded-xl gradient-primary flex items-center justify-center shadow-primary">
|
||||
<Car className="h-5 w-5 text-white" />
|
||||
</div>
|
||||
<span className="text-lg font-bold gradient-text">Sase.tr</span>
|
||||
</Link>
|
||||
<nav className="hidden md:flex items-center gap-1">
|
||||
<Link href="#features">
|
||||
<Button variant="ghost" size="sm">Ozellikler</Button>
|
||||
</Link>
|
||||
<Link href="#pricing">
|
||||
<Button variant="ghost" size="sm">Fiyatlar</Button>
|
||||
</Link>
|
||||
<Link href="#how-it-works">
|
||||
<Button variant="ghost" size="sm">Nasil Calisir</Button>
|
||||
</Link>
|
||||
</nav>
|
||||
<div className="flex items-center gap-2">
|
||||
<ThemeToggle variant="compact" />
|
||||
<Link href="/login" className="hidden sm:block">
|
||||
<Button variant="ghost" size="sm">Giris</Button>
|
||||
</Link>
|
||||
<Link href="/register">
|
||||
<Button size="sm" className="shadow-primary">
|
||||
Baslat
|
||||
<ArrowRight className="ml-1.5 h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
<span className="text-xl font-bold gradient-text">Sase.tr</span>
|
||||
</Link>
|
||||
<nav className="flex items-center gap-4">
|
||||
<Link href="/subscription/plans" className="text-sm text-muted-foreground hover:text-foreground transition-colors">
|
||||
Fiyatlar
|
||||
</Link>
|
||||
<Link href="/login">
|
||||
<Button variant="ghost" className="hover:bg-purple-100 dark:hover:bg-purple-900/20">
|
||||
Giris Yap
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/register">
|
||||
<Button className="gradient-bg hover:opacity-90 transition-opacity shadow-lg shadow-purple-500/25">
|
||||
Kayit Ol
|
||||
</Button>
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Hero */}
|
||||
<section className="relative min-h-screen flex items-center justify-center pt-16">
|
||||
{/* Background decorations */}
|
||||
{/* Hero Section - Modern minimal */}
|
||||
<section className="relative min-h-screen flex items-center justify-center pt-24 pb-16">
|
||||
{/* Animated background */}
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
<div className="absolute -top-40 -right-40 h-80 w-80 rounded-full bg-purple-500/20 blur-3xl animate-pulse-slow" />
|
||||
<div className="absolute top-1/2 -left-40 h-80 w-80 rounded-full bg-indigo-500/20 blur-3xl animate-pulse-slow" style={{ animationDelay: '2s' }} />
|
||||
<div className="absolute -bottom-40 right-1/3 h-80 w-80 rounded-full bg-violet-500/20 blur-3xl animate-pulse-slow" style={{ animationDelay: '4s' }} />
|
||||
<div className="absolute top-1/4 left-1/4 h-[500px] w-[500px] rounded-full bg-primary/8 blur-[100px] animate-pulse-slow" />
|
||||
<div className="absolute bottom-1/4 right-1/4 h-[400px] w-[400px] rounded-full bg-accent/8 blur-[100px] animate-pulse-slow" style={{ animationDelay: '1s' }} />
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 h-[600px] w-[600px] rounded-full bg-primary/5 blur-[120px]" />
|
||||
</div>
|
||||
|
||||
{/* Grid pattern */}
|
||||
<div className="absolute inset-0 bg-[linear-gradient(to_right,#8080800a_1px,transparent_1px),linear-gradient(to_bottom,#8080800a_1px,transparent_1px)] bg-[size:4rem_4rem]" />
|
||||
{/* Subtle grid */}
|
||||
<div className="absolute inset-0 bg-[linear-gradient(to_right,hsl(var(--border)/0.05)_1px,transparent_1px),linear-gradient(to_bottom,hsl(var(--border)/0.05)_1px,transparent_1px)] bg-[size:4rem_4rem]" />
|
||||
|
||||
<div className="container relative z-10 text-center py-20">
|
||||
<div className="inline-flex items-center gap-2 rounded-full border border-purple-200 dark:border-purple-800 bg-purple-50 dark:bg-purple-900/20 px-4 py-2 mb-8 animate-float">
|
||||
<Sparkles className="h-4 w-4 text-purple-600" />
|
||||
<span className="text-sm font-medium text-purple-700 dark:text-purple-300">Turkiye'nin en kapsamli VIN sorgulama sistemi</span>
|
||||
<div className="container relative z-10 text-center">
|
||||
{/* Announcement badge */}
|
||||
<div className="inline-flex items-center gap-2 mb-8 animate-fade-in">
|
||||
<Badge variant="outline" size="lg" className="pl-1.5 pr-3 py-1.5 gap-2 bg-background/50 backdrop-blur-sm">
|
||||
<span className="flex h-6 w-6 items-center justify-center rounded-md gradient-primary">
|
||||
<Sparkles className="h-3.5 w-3.5 text-white" />
|
||||
</span>
|
||||
<span className="text-caption-lg">Turkiye'nin 1 numarali VIN sorgulama platformu</span>
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<h1 className="mb-6 text-5xl md:text-7xl font-bold tracking-tight">
|
||||
Arac Sase Sorgulama
|
||||
{/* Main headline */}
|
||||
<h1 className="mb-6 text-display-md md:text-display-lg lg:text-[4.5rem] tracking-tight leading-[1.1] animate-fade-in" style={{ animationDelay: '0.1s' }}>
|
||||
Arac Bilgilerine
|
||||
<br />
|
||||
<span className="gradient-text">Platformu</span>
|
||||
<span className="gradient-text">Aninda Ulasin</span>
|
||||
</h1>
|
||||
|
||||
<p className="mx-auto mb-10 max-w-2xl text-xl text-muted-foreground leading-relaxed">
|
||||
VIN numarasi ile aracinizin tum bilgilerine, yedek parca kodlarina
|
||||
ve guncel fiyatlarina <span className="text-foreground font-medium">saniyeler icinde</span> ulasin.
|
||||
{/* Subheadline */}
|
||||
<p className="mx-auto mb-10 max-w-xl text-body-lg text-muted-foreground animate-fade-in" style={{ animationDelay: '0.2s' }}>
|
||||
VIN numarasi ile arac bilgileri, yedek parca kodlari ve guncel fiyatlara
|
||||
<span className="text-foreground font-medium"> saniyeler icinde</span> erisin.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col sm:flex-row justify-center gap-4 mb-16">
|
||||
{/* CTA buttons */}
|
||||
<div className="flex flex-col sm:flex-row justify-center gap-3 mb-16 animate-fade-in" style={{ animationDelay: '0.3s' }}>
|
||||
<Link href="/register">
|
||||
<Button size="lg" className="gradient-bg hover:opacity-90 transition-all shadow-xl shadow-purple-500/25 text-lg px-8 py-6 group">
|
||||
Ucretsiz Baslat
|
||||
<ArrowRight className="ml-2 h-5 w-5 group-hover:translate-x-1 transition-transform" />
|
||||
<Button size="lg" className="group h-12 px-6 text-base shadow-primary">
|
||||
Ucretsiz Deneyin
|
||||
<ArrowRight className="ml-2 h-4 w-4 group-hover:translate-x-1 transition-transform" />
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/subscription/plans">
|
||||
<Button size="lg" variant="outline" className="text-lg px-8 py-6 border-2 hover:bg-purple-50 dark:hover:bg-purple-900/20">
|
||||
Fiyatlari Gor
|
||||
<Link href="#how-it-works">
|
||||
<Button size="lg" variant="outline" className="h-12 px-6 text-base group">
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
Nasil Calisir?
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-8 max-w-3xl mx-auto">
|
||||
{/* Stats row */}
|
||||
<div className="flex flex-wrap justify-center gap-8 md:gap-12 animate-fade-in" style={{ animationDelay: '0.4s' }}>
|
||||
{[
|
||||
{ value: '50+', label: 'Marka' },
|
||||
{ value: '1M+', label: 'Parca' },
|
||||
{ value: '10K+', label: 'Kullanici' },
|
||||
{ value: '99.9%', label: 'Uptime' },
|
||||
{ value: '50+', label: 'Desteklenen Marka', icon: Car },
|
||||
{ value: '1M+', label: 'Parca Verisi', icon: Database },
|
||||
{ value: '10K+', label: 'Aktif Kullanici', icon: Users },
|
||||
{ value: '99.9%', label: 'Sistem Uptime', icon: Zap },
|
||||
].map((stat) => (
|
||||
<div key={stat.label} className="text-center">
|
||||
<div className="text-3xl md:text-4xl font-bold gradient-text">{stat.value}</div>
|
||||
<div className="text-sm text-muted-foreground mt-1">{stat.label}</div>
|
||||
<div key={stat.label} className="flex items-center gap-3">
|
||||
<div className="h-10 w-10 rounded-xl bg-primary/10 flex items-center justify-center">
|
||||
<stat.icon className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<div className="text-heading-sm">{stat.value}</div>
|
||||
<div className="text-caption-md text-muted-foreground">{stat.label}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scroll indicator */}
|
||||
<div className="absolute bottom-8 left-1/2 -translate-x-1/2 animate-bounce-soft">
|
||||
<div className="h-10 w-6 rounded-full ring ring-border flex items-start justify-center p-2">
|
||||
<div className="h-2 w-1 rounded-full bg-muted-foreground animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Trusted by section */}
|
||||
<section className="py-12 border-y border-border bg-background-secondary/50">
|
||||
<div className="container">
|
||||
<p className="text-center text-caption-lg text-muted-foreground mb-8">
|
||||
Turkiye'nin onde gelen oto yedek parca firmalari tarafindan tercih ediliyor
|
||||
</p>
|
||||
<div className="flex flex-wrap justify-center items-center gap-8 md:gap-16 opacity-60">
|
||||
{['Bosch', 'Valeo', 'Delphi', 'Denso', 'Continental', 'Mahle'].map((brand) => (
|
||||
<span key={brand} className="text-xl font-bold text-muted-foreground/50">{brand}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* How it works */}
|
||||
<section id="how-it-works" className="py-24 scroll-mt-20">
|
||||
<div className="container">
|
||||
<div className="text-center mb-16">
|
||||
<Badge variant="soft-primary" size="default" className="mb-4">
|
||||
<Clock className="h-3.5 w-3.5 mr-1.5" />
|
||||
3 Kolay Adim
|
||||
</Badge>
|
||||
<h2 className="text-heading-lg md:text-display-sm mb-4">
|
||||
Nasil <span className="gradient-text">Calisir</span>?
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-body-md max-w-2xl mx-auto">
|
||||
Sadece birkaç adimda arac bilgilerine ulasin
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-3 gap-6 max-w-5xl mx-auto">
|
||||
{[
|
||||
{
|
||||
step: '01',
|
||||
title: 'VIN Numarasi Girin',
|
||||
description: 'Aracinizin 17 haneli VIN/sase numarasini girin veya ruhsattan okutun.',
|
||||
icon: Search,
|
||||
gradient: 'gradient-primary'
|
||||
},
|
||||
{
|
||||
step: '02',
|
||||
title: 'Arac Bilgilerini Gorun',
|
||||
description: 'Marka, model, yil, motor tipi ve tum teknik detaylara aninda ulasin.',
|
||||
icon: Car,
|
||||
gradient: 'gradient-accent'
|
||||
},
|
||||
{
|
||||
step: '03',
|
||||
title: 'Yedek Parca Bulun',
|
||||
description: 'OEM kodlari, alternatif parcalar ve guncel fiyatlari karsilastirin.',
|
||||
icon: Database,
|
||||
gradient: 'gradient-success'
|
||||
},
|
||||
].map((item, index) => (
|
||||
<div key={item.step} className="relative group">
|
||||
{index < 2 && (
|
||||
<div className="hidden md:block absolute top-16 left-full w-full h-px bg-gradient-to-r from-border to-transparent z-0" />
|
||||
)}
|
||||
<Card variant="interactive" className="relative z-10 h-full">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className={`h-14 w-14 rounded-2xl ${item.gradient} flex items-center justify-center shadow-lg group-hover:scale-110 transition-transform`}>
|
||||
<item.icon className="h-7 w-7 text-white" />
|
||||
</div>
|
||||
<span className="text-display-sm text-muted-foreground/20 font-bold">{item.step}</span>
|
||||
</div>
|
||||
<h3 className="text-heading-sm mb-2">{item.title}</h3>
|
||||
<p className="text-body-sm text-muted-foreground">{item.description}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Features */}
|
||||
<section className="container py-24 relative">
|
||||
<div className="text-center mb-16">
|
||||
<h2 className="text-3xl md:text-4xl font-bold mb-4">
|
||||
Neden <span className="gradient-text">Sase.tr</span>?
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-lg max-w-2xl mx-auto">
|
||||
Modern altyapi, hizli sonuclar ve kapsamli veritabani ile aracinizi taniyin.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-3">
|
||||
<Card className="card-hover border-0 shadow-lg bg-gradient-to-br from-white to-purple-50/50 dark:from-gray-900 dark:to-purple-900/10">
|
||||
<CardHeader className="space-y-4">
|
||||
<div className="h-14 w-14 rounded-2xl gradient-bg flex items-center justify-center shadow-lg shadow-purple-500/25">
|
||||
<Zap className="h-7 w-7 text-white" />
|
||||
</div>
|
||||
<CardTitle className="text-xl">Simsek Hizinda Sorgulama</CardTitle>
|
||||
<CardDescription className="text-base leading-relaxed">
|
||||
VIN numarasi girin, milisaniyeler icinde arac bilgilerine ulasin. API tabanli modern altyapi.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
<Card className="card-hover border-0 shadow-lg bg-gradient-to-br from-white to-indigo-50/50 dark:from-gray-900 dark:to-indigo-900/10">
|
||||
<CardHeader className="space-y-4">
|
||||
<div className="h-14 w-14 rounded-2xl bg-gradient-to-br from-indigo-500 to-blue-600 flex items-center justify-center shadow-lg shadow-indigo-500/25">
|
||||
<Search className="h-7 w-7 text-white" />
|
||||
</div>
|
||||
<CardTitle className="text-xl">Detayli Parca Bilgisi</CardTitle>
|
||||
<CardDescription className="text-base leading-relaxed">
|
||||
OEM kodlari, alternatif markalar, stok durumu ve guncel fiyatlar tek platformda.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
<Card className="card-hover border-0 shadow-lg bg-gradient-to-br from-white to-violet-50/50 dark:from-gray-900 dark:to-violet-900/10">
|
||||
<CardHeader className="space-y-4">
|
||||
<div className="h-14 w-14 rounded-2xl bg-gradient-to-br from-violet-500 to-purple-600 flex items-center justify-center shadow-lg shadow-violet-500/25">
|
||||
<Shield className="h-7 w-7 text-white" />
|
||||
</div>
|
||||
<CardTitle className="text-xl">Guvenli Altyapi</CardTitle>
|
||||
<CardDescription className="text-base leading-relaxed">
|
||||
SSL sertifikasi, sifreli veri iletimi ve KVKK uyumlu guvenli depolama.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Pricing */}
|
||||
<section className="py-24 bg-gradient-to-b from-transparent via-purple-50/50 to-transparent dark:via-purple-900/10">
|
||||
{/* Features Section */}
|
||||
<section id="features" className="py-24 bg-background-secondary scroll-mt-20">
|
||||
<div className="container">
|
||||
<div className="text-center mb-16">
|
||||
<h2 className="text-3xl md:text-4xl font-bold mb-4">
|
||||
Basit ve Seffaf <span className="gradient-text">Fiyatlandirma</span>
|
||||
<Badge variant="accent" size="default" className="mb-4">
|
||||
<Sparkles className="h-3.5 w-3.5 mr-1.5" />
|
||||
Ozellikler
|
||||
</Badge>
|
||||
<h2 className="text-heading-lg md:text-display-sm mb-4">
|
||||
Neden <span className="gradient-text">Sase.tr</span>?
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-lg max-w-2xl mx-auto">
|
||||
Isletmenize uygun plani secin, hemen baslayin.
|
||||
<p className="text-muted-foreground text-body-md max-w-2xl mx-auto">
|
||||
En gelismis arac sorgulama altyapisi ile profesyonel sonuclar
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-4 max-w-6xl mx-auto">
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{[
|
||||
{ name: 'Baslangic', price: 299, brands: 1, features: ['1 marka erisimi', 'Sinirsiz sorgu', 'Email destek'] },
|
||||
{ name: 'Pro', price: 599, brands: 3, popular: true, features: ['3 marka erisimi', 'Sinirsiz sorgu', 'Oncelikli destek', 'API erisimi'] },
|
||||
{ name: 'Isletme', price: 999, brands: 10, features: ['10 marka erisimi', 'Sinirsiz sorgu', '7/24 destek', 'API erisimi', 'Ozel raporlar'] },
|
||||
{ name: 'Full', price: 1999, brands: 'Tum', features: ['Tum markalara erisim', 'Sinirsiz sorgu', 'VIP destek', 'API erisimi', 'Ozel raporlar', 'Beyaz etiket'] },
|
||||
{
|
||||
icon: Zap,
|
||||
title: 'Ultra Hizli Sorgulama',
|
||||
description: 'API tabanli modern altyapi ile milisaniyeler icinde sonuc alin.',
|
||||
gradient: 'gradient-primary'
|
||||
},
|
||||
{
|
||||
icon: Database,
|
||||
title: 'Genis Veritabani',
|
||||
description: '50+ marka ve 1 milyonun uzerinde yedek parca verisi.',
|
||||
gradient: 'gradient-accent'
|
||||
},
|
||||
{
|
||||
icon: Shield,
|
||||
title: 'Guvenli Altyapi',
|
||||
description: 'SSL sertifikasi ve KVKK uyumlu veri koruma.',
|
||||
gradient: 'gradient-success'
|
||||
},
|
||||
{
|
||||
icon: Globe,
|
||||
title: 'Global Veri Kaynaklari',
|
||||
description: 'Dunya capinda OEM ve aftermarket veri entegrasyonu.',
|
||||
gradient: 'gradient-primary'
|
||||
},
|
||||
{
|
||||
icon: BarChart3,
|
||||
title: 'Detayli Raporlama',
|
||||
description: 'Sorgu gecmisi, favori araclar ve ozel raporlar.',
|
||||
gradient: 'gradient-accent'
|
||||
},
|
||||
{
|
||||
icon: Cpu,
|
||||
title: 'API Erisimi',
|
||||
description: 'REST API ile kendi sistemlerinize entegre edin.',
|
||||
gradient: 'gradient-success'
|
||||
},
|
||||
].map((feature) => (
|
||||
<Card key={feature.title} variant="interactive" className="group">
|
||||
<CardHeader className="pb-4">
|
||||
<div className={`h-12 w-12 rounded-xl ${feature.gradient} flex items-center justify-center shadow-lg mb-4 group-hover:scale-105 transition-transform`}>
|
||||
<feature.icon className="h-6 w-6 text-white" />
|
||||
</div>
|
||||
<CardTitle className="text-heading-sm">{feature.title}</CardTitle>
|
||||
<CardDescription className="text-body-sm">{feature.description}</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Pricing Section */}
|
||||
<section id="pricing" className="py-24 scroll-mt-20">
|
||||
<div className="container">
|
||||
<div className="text-center mb-16">
|
||||
<Badge variant="soft-success" size="default" className="mb-4">
|
||||
<Star className="h-3.5 w-3.5 mr-1.5" />
|
||||
Fiyatlandirma
|
||||
</Badge>
|
||||
<h2 className="text-heading-lg md:text-display-sm mb-4">
|
||||
Seffaf <span className="gradient-text">Fiyatlar</span>
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-body-md max-w-2xl mx-auto">
|
||||
Isletmenize uygun plani secin, gizli maliyet yok
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4 max-w-6xl mx-auto">
|
||||
{[
|
||||
{
|
||||
name: 'Baslangic',
|
||||
price: 299,
|
||||
description: 'Bireysel kullanici',
|
||||
features: ['1 marka erisimi', 'Aylik 100 sorgu', 'Email destek', 'Temel raporlar'],
|
||||
cta: 'Baslat'
|
||||
},
|
||||
{
|
||||
name: 'Pro',
|
||||
price: 599,
|
||||
description: 'Kucuk isletmeler',
|
||||
popular: true,
|
||||
features: ['3 marka erisimi', 'Sinirsiz sorgu', 'Oncelikli destek', 'API erisimi', 'Gelismis raporlar'],
|
||||
cta: 'Populer Plan'
|
||||
},
|
||||
{
|
||||
name: 'Isletme',
|
||||
price: 999,
|
||||
description: 'Orta olcekli firmalar',
|
||||
features: ['10 marka erisimi', 'Sinirsiz sorgu', '7/24 destek', 'API erisimi', 'Ozel raporlar', 'Coklu kullanici'],
|
||||
cta: 'Baslat'
|
||||
},
|
||||
{
|
||||
name: 'Kurumsal',
|
||||
price: 1999,
|
||||
description: 'Buyuk isletmeler',
|
||||
features: ['Tum markalara erisim', 'Sinirsiz her sey', 'VIP destek', 'Ozel API limiti', 'Beyaz etiket', 'SLA garantisi'],
|
||||
cta: 'Iletisime Gec'
|
||||
},
|
||||
].map((plan) => (
|
||||
<Card
|
||||
key={plan.name}
|
||||
className={`card-hover relative overflow-hidden ${
|
||||
plan.popular
|
||||
? 'border-2 border-purple-500 shadow-xl shadow-purple-500/20 scale-105'
|
||||
: 'border-0 shadow-lg'
|
||||
}`}
|
||||
className={`relative ${plan.popular ? 'ring-2 ring-primary shadow-primary-lg scale-[1.02] z-10' : ''}`}
|
||||
>
|
||||
{plan.popular && (
|
||||
<div className="absolute top-0 right-0 gradient-bg px-4 py-1 text-xs font-semibold text-white rounded-bl-xl">
|
||||
Populer
|
||||
<div className="absolute -top-3 left-1/2 -translate-x-1/2">
|
||||
<Badge className="shadow-lg">En Populer</Badge>
|
||||
</div>
|
||||
)}
|
||||
<CardHeader className="pb-4">
|
||||
<CardTitle className="text-lg">{plan.name}</CardTitle>
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className="text-4xl font-bold">{plan.price}</span>
|
||||
<span className="text-muted-foreground">TL/ay</span>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<CardTitle className="text-heading-sm">{plan.name}</CardTitle>
|
||||
{plan.popular && <Sparkles className="h-4 w-4 text-primary" />}
|
||||
</div>
|
||||
<CardDescription className="text-caption-lg">{plan.description}</CardDescription>
|
||||
<div className="flex items-baseline gap-1 mt-4">
|
||||
<span className="text-display-sm">{plan.price}</span>
|
||||
<span className="text-muted-foreground text-body-sm">TL/ay</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<ul className="space-y-3">
|
||||
<CardContent className="pt-0">
|
||||
<ul className="space-y-3 mb-6">
|
||||
{plan.features.map((feature) => (
|
||||
<li key={feature} className="flex items-center gap-2 text-sm">
|
||||
<CheckCircle2 className="h-4 w-4 text-purple-500 flex-shrink-0" />
|
||||
<li key={feature} className="flex items-center gap-2.5 text-body-sm">
|
||||
<div className="h-5 w-5 rounded-full bg-primary/10 flex items-center justify-center flex-shrink-0">
|
||||
<CheckCircle2 className="h-3.5 w-3.5 text-primary" />
|
||||
</div>
|
||||
<span>{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Link href="/register" className="block">
|
||||
<Button
|
||||
className={`w-full ${plan.popular ? 'gradient-bg hover:opacity-90 shadow-lg shadow-purple-500/25' : ''}`}
|
||||
block
|
||||
variant={plan.popular ? 'default' : 'outline'}
|
||||
className={plan.popular ? 'shadow-primary' : ''}
|
||||
>
|
||||
Hemen Basla
|
||||
{plan.cta}
|
||||
</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Money back guarantee */}
|
||||
<div className="mt-12 text-center">
|
||||
<div className="inline-flex items-center gap-3 px-6 py-3 rounded-2xl bg-success/10 ring ring-success/20">
|
||||
<Lock className="h-5 w-5 text-success" />
|
||||
<span className="text-body-sm text-success">14 gun icerisinde kosulsuz iade garantisi</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Testimonials */}
|
||||
<section className="py-24 bg-background-secondary">
|
||||
<div className="container">
|
||||
<div className="text-center mb-16">
|
||||
<Badge variant="soft-primary" size="default" className="mb-4">
|
||||
<Users className="h-3.5 w-3.5 mr-1.5" />
|
||||
Kullanici Yorumlari
|
||||
</Badge>
|
||||
<h2 className="text-heading-lg md:text-display-sm mb-4">
|
||||
Musterilerimiz <span className="gradient-text">Ne Diyor</span>?
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-3 gap-4 max-w-5xl mx-auto">
|
||||
{[
|
||||
{
|
||||
quote: 'Sase.tr ile arac sorgulama islerimiz inanilmaz hizlandi. Artik musterilere aninda bilgi verebiliyoruz.',
|
||||
author: 'Mehmet Y.',
|
||||
role: 'Oto Yedek Parca - Istanbul',
|
||||
rating: 5
|
||||
},
|
||||
{
|
||||
quote: 'API entegrasyonu cok kolay oldu. Kendi sistemimize entegre ettik ve verimliligi 3 katina cikardik.',
|
||||
author: 'Ayse K.',
|
||||
role: 'Yazilim Gelistirici - Ankara',
|
||||
rating: 5
|
||||
},
|
||||
{
|
||||
quote: 'Parca kodlari ve fiyat karsilastirma ozelligi muazzam. Rekabetci fiyat vermemizi sagliyor.',
|
||||
author: 'Ali R.',
|
||||
role: 'Oto Galeri Sahibi - Izmir',
|
||||
rating: 5
|
||||
},
|
||||
].map((testimonial, index) => (
|
||||
<Card key={index} variant="glass" className="h-full">
|
||||
<CardContent className="p-6 flex flex-col h-full">
|
||||
<div className="flex gap-1 mb-4">
|
||||
{Array.from({ length: testimonial.rating }).map((_, i) => (
|
||||
<Star key={i} className="h-4 w-4 fill-primary text-primary" />
|
||||
))}
|
||||
</div>
|
||||
<p className="text-body-sm text-foreground mb-6 flex-grow">“{testimonial.quote}”</p>
|
||||
<div className="flex items-center gap-3 pt-4 border-t border-border">
|
||||
<div className="h-10 w-10 rounded-full gradient-primary flex items-center justify-center text-white font-semibold">
|
||||
{testimonial.author[0]}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-caption-lg font-medium">{testimonial.author}</div>
|
||||
<div className="text-caption-md text-muted-foreground">{testimonial.role}</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA */}
|
||||
<section className="container py-24">
|
||||
<div className="relative overflow-hidden rounded-3xl gradient-bg p-12 md:p-20 text-center animate-gradient">
|
||||
<div className="absolute inset-0 bg-[linear-gradient(to_right,#ffffff0a_1px,transparent_1px),linear-gradient(to_bottom,#ffffff0a_1px,transparent_1px)] bg-[size:2rem_2rem]" />
|
||||
<div className="relative z-10">
|
||||
<h2 className="text-3xl md:text-5xl font-bold text-white mb-6">
|
||||
Hemen Ucretsiz Deneyin
|
||||
</h2>
|
||||
<p className="text-white/80 text-lg max-w-2xl mx-auto mb-8">
|
||||
Kredi karti gerekmez. Hemen kayit olun ve arac sorgulama deneyiminizi kesfetmeye baslayin.
|
||||
</p>
|
||||
<Link href="/register">
|
||||
<Button size="lg" variant="secondary" className="text-lg px-8 py-6 bg-white text-purple-700 hover:bg-gray-100 shadow-xl group">
|
||||
Ucretsiz Hesap Olustur
|
||||
<ArrowRight className="ml-2 h-5 w-5 group-hover:translate-x-1 transition-transform" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
{/* Final CTA */}
|
||||
<section className="py-24">
|
||||
<div className="container">
|
||||
<Card className="gradient-primary overflow-hidden">
|
||||
<CardContent className="p-10 md:p-16 relative">
|
||||
{/* Grid pattern */}
|
||||
<div className="absolute inset-0 bg-[linear-gradient(to_right,#ffffff06_1px,transparent_1px),linear-gradient(to_bottom,#ffffff06_1px,transparent_1px)] bg-[size:2rem_2rem]" />
|
||||
|
||||
{/* Glow effects */}
|
||||
<div className="absolute -top-20 -right-20 h-64 w-64 rounded-full bg-white/10 blur-3xl" />
|
||||
<div className="absolute -bottom-20 -left-20 h-64 w-64 rounded-full bg-white/10 blur-3xl" />
|
||||
|
||||
<div className="relative z-10 flex flex-col lg:flex-row items-center justify-between gap-8">
|
||||
<div className="text-center lg:text-left max-w-xl">
|
||||
<h2 className="text-heading-lg md:text-display-sm text-white mb-4">
|
||||
Hemen Ucretsiz Baslatin
|
||||
</h2>
|
||||
<p className="text-white/80 text-body-md">
|
||||
Kredi karti gerektirmez. 14 gun ucretsiz deneme ile tum ozellikleri kesfedin.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<Link href="/register">
|
||||
<Button size="lg" variant="secondary" className="bg-white text-primary hover:bg-white/90 group h-12 px-8">
|
||||
Ucretsiz Kayit Ol
|
||||
<ArrowRight className="ml-2 h-4 w-4 group-hover:translate-x-1 transition-transform" />
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/contact">
|
||||
<Button size="lg" variant="outline" className="border-white/30 text-white hover:bg-white/10 h-12 px-8">
|
||||
Bize Ulasin
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="border-t bg-gray-50/50 dark:bg-gray-900/50">
|
||||
<div className="container py-12">
|
||||
<div className="flex flex-col md:flex-row justify-between items-center gap-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-8 w-8 rounded-lg gradient-bg flex items-center justify-center">
|
||||
<Car className="h-5 w-5 text-white" />
|
||||
<footer className="border-t border-border bg-background-secondary">
|
||||
<div className="container py-16">
|
||||
<div className="grid md:grid-cols-4 gap-8 mb-12">
|
||||
{/* Brand */}
|
||||
<div className="md:col-span-1">
|
||||
<Link href="/" className="flex items-center gap-2.5 mb-4">
|
||||
<div className="h-10 w-10 rounded-xl gradient-primary flex items-center justify-center shadow-primary">
|
||||
<Car className="h-5 w-5 text-white" />
|
||||
</div>
|
||||
<span className="text-xl font-bold gradient-text">Sase.tr</span>
|
||||
</Link>
|
||||
<p className="text-body-sm text-muted-foreground mb-4">
|
||||
Turkiye'nin en kapsamli arac sase sorgulama platformu.
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Badge variant="outline" size="sm">SSL Guvenli</Badge>
|
||||
<Badge variant="outline" size="sm">KVKK Uyumlu</Badge>
|
||||
</div>
|
||||
<span className="text-lg font-bold gradient-text">Sase.tr</span>
|
||||
</div>
|
||||
<nav className="flex items-center gap-8 text-sm text-muted-foreground">
|
||||
<Link href="/subscription/plans" className="hover:text-foreground transition-colors">Fiyatlar</Link>
|
||||
<Link href="/privacy" className="hover:text-foreground transition-colors">Gizlilik</Link>
|
||||
<Link href="/terms" className="hover:text-foreground transition-colors">Kullanim Sartlari</Link>
|
||||
<Link href="/contact" className="hover:text-foreground transition-colors">Iletisim</Link>
|
||||
</nav>
|
||||
|
||||
{/* Links */}
|
||||
<div>
|
||||
<h4 className="text-caption-lg font-semibold mb-4">Urun</h4>
|
||||
<ul className="space-y-3 text-body-sm text-muted-foreground">
|
||||
<li><Link href="#features" className="hover:text-foreground transition-colors">Ozellikler</Link></li>
|
||||
<li><Link href="#pricing" className="hover:text-foreground transition-colors">Fiyatlandirma</Link></li>
|
||||
<li><Link href="#how-it-works" className="hover:text-foreground transition-colors">Nasil Calisir</Link></li>
|
||||
<li><Link href="/api-docs" className="hover:text-foreground transition-colors">API Dokumantasyonu</Link></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-caption-lg font-semibold mb-4">Sirket</h4>
|
||||
<ul className="space-y-3 text-body-sm text-muted-foreground">
|
||||
<li><Link href="/about" className="hover:text-foreground transition-colors">Hakkimizda</Link></li>
|
||||
<li><Link href="/contact" className="hover:text-foreground transition-colors">Iletisim</Link></li>
|
||||
<li><Link href="/blog" className="hover:text-foreground transition-colors">Blog</Link></li>
|
||||
<li><Link href="/careers" className="hover:text-foreground transition-colors">Kariyer</Link></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-caption-lg font-semibold mb-4">Yasal</h4>
|
||||
<ul className="space-y-3 text-body-sm text-muted-foreground">
|
||||
<li><Link href="/privacy" className="hover:text-foreground transition-colors">Gizlilik Politikasi</Link></li>
|
||||
<li><Link href="/terms" className="hover:text-foreground transition-colors">Kullanim Kosullari</Link></li>
|
||||
<li><Link href="/kvkk" className="hover:text-foreground transition-colors">KVKK Aydinlatma</Link></li>
|
||||
<li><Link href="/cookies" className="hover:text-foreground transition-colors">Cerez Politikasi</Link></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-8 pt-8 border-t text-center text-sm text-muted-foreground">
|
||||
<p>2025 Sase.tr - Tum haklar saklidir.</p>
|
||||
|
||||
<div className="pt-8 border-t border-border flex flex-col md:flex-row justify-between items-center gap-4">
|
||||
<p className="text-caption-lg text-muted-foreground">
|
||||
2025 Sase.tr - Tum haklar saklidir.
|
||||
</p>
|
||||
<div className="flex items-center gap-4 text-caption-md text-muted-foreground">
|
||||
<span>Turkiye'de tasarlandi</span>
|
||||
<span className="h-1 w-1 rounded-full bg-muted-foreground" />
|
||||
<span>Istanbul</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useRouter, usePathname } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { useAuth } from '@/providers/auth-provider';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ThemeToggleDropdown } from '@/components/ui/theme-toggle';
|
||||
import { Search, Car, CreditCard, User, LogOut, Menu, X, Sparkles } from 'lucide-react';
|
||||
|
||||
const navItems = [
|
||||
@@ -32,11 +33,11 @@ export default function DashboardLayout({
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center">
|
||||
<div className="flex h-screen items-center justify-center bg-background">
|
||||
<div className="text-center">
|
||||
<div className="relative">
|
||||
<div className="h-16 w-16 rounded-full border-4 border-purple-200 dark:border-purple-900" />
|
||||
<div className="absolute inset-0 h-16 w-16 animate-spin rounded-full border-4 border-transparent border-t-purple-600" />
|
||||
<div className="h-16 w-16 rounded-full border-4 border-primary/20" />
|
||||
<div className="absolute inset-0 h-16 w-16 animate-spin rounded-full border-4 border-transparent border-t-primary" />
|
||||
</div>
|
||||
<p className="mt-4 text-sm text-muted-foreground">Yukleniyor...</p>
|
||||
</div>
|
||||
@@ -49,26 +50,26 @@ export default function DashboardLayout({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen bg-gray-50/50 dark:bg-gray-950">
|
||||
<div className="flex min-h-screen bg-background">
|
||||
{/* Mobile sidebar backdrop */}
|
||||
{sidebarOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-40 bg-black/50 backdrop-blur-sm lg:hidden"
|
||||
className="fixed inset-0 z-40 bg-background/80 backdrop-blur-sm lg:hidden"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Sidebar */}
|
||||
<aside
|
||||
className={`fixed inset-y-0 left-0 z-50 w-72 transform bg-white dark:bg-gray-900 border-r border-gray-200 dark:border-gray-800 transition-transform duration-300 ease-in-out lg:translate-x-0 lg:static lg:z-auto ${
|
||||
className={`fixed inset-y-0 left-0 z-50 w-72 transform bg-card border-r border-border transition-transform duration-300 ease-in-out lg:translate-x-0 lg:static lg:z-auto ${
|
||||
sidebarOpen ? 'translate-x-0' : '-translate-x-full'
|
||||
}`}
|
||||
>
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Sidebar Header */}
|
||||
<div className="flex h-16 items-center justify-between border-b border-gray-200 dark:border-gray-800 px-6">
|
||||
<div className="flex h-16 items-center justify-between border-b border-border px-6">
|
||||
<Link href="/dashboard" className="flex items-center gap-2">
|
||||
<div className="h-8 w-8 rounded-lg gradient-bg flex items-center justify-center">
|
||||
<div className="h-8 w-8 rounded-lg gradient-primary flex items-center justify-center">
|
||||
<Car className="h-5 w-5 text-white" />
|
||||
</div>
|
||||
<span className="text-xl font-bold gradient-text">Sase.tr</span>
|
||||
@@ -92,14 +93,14 @@ export default function DashboardLayout({
|
||||
<div
|
||||
className={`flex items-center gap-3 rounded-xl px-4 py-3 text-sm font-medium transition-all duration-200 ${
|
||||
isActive
|
||||
? 'bg-gradient-to-r from-purple-500/10 to-indigo-500/10 text-purple-700 dark:text-purple-300 shadow-sm'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-gray-900 dark:hover:text-gray-100'
|
||||
? 'bg-primary/10 text-primary shadow-sm'
|
||||
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
<item.icon className={`h-5 w-5 ${isActive ? 'text-purple-600' : ''}`} />
|
||||
<item.icon className={`h-5 w-5 ${isActive ? 'text-primary' : ''}`} />
|
||||
{item.label}
|
||||
{isActive && (
|
||||
<div className="ml-auto h-2 w-2 rounded-full bg-purple-600" />
|
||||
<div className="ml-auto h-2 w-2 rounded-full bg-primary" />
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
@@ -108,9 +109,9 @@ export default function DashboardLayout({
|
||||
</nav>
|
||||
|
||||
{/* Sidebar Footer - User Info */}
|
||||
<div className="border-t border-gray-200 dark:border-gray-800 p-4">
|
||||
<div className="flex items-center gap-3 rounded-xl bg-gray-100 dark:bg-gray-800 p-3">
|
||||
<div className="h-10 w-10 rounded-full gradient-bg flex items-center justify-center text-white font-semibold">
|
||||
<div className="border-t border-border p-4">
|
||||
<div className="flex items-center gap-3 rounded-xl bg-muted/50 p-3">
|
||||
<div className="h-10 w-10 rounded-full gradient-primary flex items-center justify-center text-white font-semibold">
|
||||
{(user.name || user.email || 'U').charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
@@ -121,7 +122,7 @@ export default function DashboardLayout({
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={logout}
|
||||
className="text-gray-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20"
|
||||
className="text-muted-foreground hover:text-destructive hover:bg-destructive/10"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -133,7 +134,7 @@ export default function DashboardLayout({
|
||||
{/* Main content */}
|
||||
<div className="flex flex-1 flex-col min-w-0">
|
||||
{/* Header */}
|
||||
<header className="sticky top-0 z-30 flex h-16 items-center justify-between border-b border-gray-200 dark:border-gray-800 bg-white/80 dark:bg-gray-900/80 backdrop-blur-xl px-6">
|
||||
<header className="sticky top-0 z-30 flex h-16 items-center justify-between border-b border-border bg-background/80 backdrop-blur-xl px-6">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -144,15 +145,18 @@ export default function DashboardLayout({
|
||||
</Button>
|
||||
|
||||
<div className="hidden lg:flex items-center gap-2">
|
||||
<Sparkles className="h-4 w-4 text-purple-600" />
|
||||
<Sparkles className="h-4 w-4 text-primary" />
|
||||
<span className="text-sm text-muted-foreground">
|
||||
VIN sorgulama platformuna hosgeldiniz
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Theme Toggle */}
|
||||
<ThemeToggleDropdown />
|
||||
|
||||
<Link href="/subscription/plans">
|
||||
<Button variant="outline" size="sm" className="hidden sm:flex border-purple-200 dark:border-purple-800 text-purple-700 dark:text-purple-300 hover:bg-purple-50 dark:hover:bg-purple-900/20">
|
||||
<Button variant="outline" size="sm" className="hidden sm:flex border-primary/30 text-primary hover:bg-primary/10">
|
||||
<Sparkles className="mr-2 h-4 w-4" />
|
||||
Yukselt
|
||||
</Button>
|
||||
@@ -169,7 +173,7 @@ export default function DashboardLayout({
|
||||
</header>
|
||||
|
||||
{/* Page content */}
|
||||
<main className="flex-1 p-6 lg:p-8">{children}</main>
|
||||
<main className="flex-1 p-6 lg:p-8 bg-background">{children}</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -4,7 +4,8 @@ import Link from 'next/link';
|
||||
import { useAuth } from '@/providers/auth-provider';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Search, Car, CreditCard, ArrowRight, Zap, TrendingUp, Clock } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Search, Car, CreditCard, ArrowRight, TrendingUp, Clock, Shield, Sparkles } from 'lucide-react';
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { user } = useAuth();
|
||||
@@ -15,7 +16,7 @@ export default function DashboardPage() {
|
||||
description: 'Arac sase numarasi ile sorgulama yapin',
|
||||
icon: Search,
|
||||
href: '/dashboard/vehicles/search',
|
||||
gradient: 'from-purple-500 to-indigo-600',
|
||||
gradient: 'gradient-primary',
|
||||
primary: true,
|
||||
},
|
||||
{
|
||||
@@ -23,59 +24,62 @@ export default function DashboardPage() {
|
||||
description: 'Gecmis sorgularinizi goruntuleyin',
|
||||
icon: Car,
|
||||
href: '/dashboard/vehicles',
|
||||
gradient: 'from-indigo-500 to-blue-600',
|
||||
gradient: 'gradient-accent',
|
||||
},
|
||||
{
|
||||
title: 'Abonelik',
|
||||
description: 'Abonelik durumunuzu kontrol edin',
|
||||
icon: CreditCard,
|
||||
href: '/dashboard/subscription',
|
||||
gradient: 'from-violet-500 to-purple-600',
|
||||
gradient: 'gradient-success',
|
||||
},
|
||||
];
|
||||
|
||||
const stats = [
|
||||
{ label: 'Bu Ay Sorgu', value: '24', icon: Search, change: '+12%' },
|
||||
{ label: 'Kayitli Arac', value: '8', icon: Car, change: '+2' },
|
||||
{ label: 'Kalan Gun', value: '18', icon: Clock, change: '' },
|
||||
{ label: 'Bu Ay Sorgu', value: '24', icon: Search, change: '+12%', color: 'gradient-primary' },
|
||||
{ label: 'Kayitli Arac', value: '8', icon: Car, change: '+2', color: 'gradient-accent' },
|
||||
{ label: 'Kalan Gun', value: '18', icon: Clock, change: '', color: 'gradient-success' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Welcome Section */}
|
||||
<div className="relative overflow-hidden rounded-2xl gradient-bg p-8 text-white">
|
||||
<div className="absolute inset-0 bg-[linear-gradient(to_right,#ffffff08_1px,transparent_1px),linear-gradient(to_bottom,#ffffff08_1px,transparent_1px)] bg-[size:2rem_2rem]" />
|
||||
<div className="relative z-10">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-purple-200">Hosgeldiniz</span>
|
||||
<Zap className="h-4 w-4 text-yellow-300" />
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
{/* Welcome Section - Selia style */}
|
||||
<Card className="gradient-primary overflow-hidden">
|
||||
<CardContent className="p-6 md:p-8 relative">
|
||||
<div className="absolute inset-0 bg-[linear-gradient(to_right,#ffffff06_1px,transparent_1px),linear-gradient(to_bottom,#ffffff06_1px,transparent_1px)] bg-[size:2rem_2rem]" />
|
||||
<div className="absolute -right-20 -top-20 h-48 w-48 rounded-full bg-white/10 blur-3xl" />
|
||||
<div className="relative z-10">
|
||||
<Badge variant="secondary" size="sm" className="bg-white/10 text-white ring-white/20 mb-3">
|
||||
<Sparkles className="h-3 w-3 mr-1" />
|
||||
Hosgeldiniz
|
||||
</Badge>
|
||||
<h1 className="text-heading-lg md:text-display-sm text-white mb-2">
|
||||
{user?.name || 'Kullanici'}
|
||||
</h1>
|
||||
<p className="text-body-sm text-white/80 max-w-xl">
|
||||
VIN sorgulama platformuna hosgeldiniz. Aracinizin tum bilgilerine saniyeler icinde ulasin.
|
||||
</p>
|
||||
</div>
|
||||
<h1 className="text-3xl md:text-4xl font-bold mb-2">
|
||||
{user?.name || 'Kullanici'}
|
||||
</h1>
|
||||
<p className="text-purple-100 max-w-xl">
|
||||
VIN sorgulama platformuna hosgeldiniz. Aracinizin tum bilgilerine saniyeler icinde ulasin.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Stats */}
|
||||
{/* Stats - Selia clean cards */}
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{stats.map((stat) => (
|
||||
<Card key={stat.label} className="border-0 shadow-md bg-white dark:bg-gray-900">
|
||||
<CardContent className="p-6">
|
||||
<Card key={stat.label} className="group">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">{stat.label}</p>
|
||||
<p className="text-3xl font-bold mt-1">{stat.value}</p>
|
||||
<p className="text-caption-lg text-muted-foreground">{stat.label}</p>
|
||||
<p className="text-display-sm mt-1">{stat.value}</p>
|
||||
{stat.change && (
|
||||
<div className="flex items-center gap-1 mt-1">
|
||||
<TrendingUp className="h-3 w-3 text-green-500" />
|
||||
<span className="text-xs text-green-600">{stat.change}</span>
|
||||
</div>
|
||||
<Badge variant="soft-success" size="sm" className="mt-2">
|
||||
<TrendingUp className="h-3 w-3 mr-1" />
|
||||
{stat.change}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="h-12 w-12 rounded-xl gradient-bg flex items-center justify-center">
|
||||
<div className={`h-12 w-12 rounded-xl ${stat.color} flex items-center justify-center shadow-primary group-hover:scale-105 transition-transform`}>
|
||||
<stat.icon className="h-6 w-6 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -84,36 +88,32 @@ export default function DashboardPage() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
{/* Quick Actions - Selia interactive cards */}
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold mb-4">Hizli Islemler</h2>
|
||||
<div className="grid gap-6 md:grid-cols-3">
|
||||
<h2 className="text-heading-sm mb-4">Hizli Islemler</h2>
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{quickActions.map((action) => (
|
||||
<Card
|
||||
key={action.title}
|
||||
className={`card-hover border-0 shadow-lg overflow-hidden ${
|
||||
action.primary ? 'ring-2 ring-purple-500/20' : ''
|
||||
}`}
|
||||
variant="interactive"
|
||||
className="group"
|
||||
>
|
||||
<CardHeader className="pb-4">
|
||||
<div className={`h-12 w-12 rounded-xl bg-gradient-to-br ${action.gradient} flex items-center justify-center shadow-lg mb-3`}>
|
||||
<action.icon className="h-6 w-6 text-white" />
|
||||
<CardHeader className="pb-3">
|
||||
<div className={`h-11 w-11 rounded-xl ${action.gradient} flex items-center justify-center shadow-sm mb-3 group-hover:scale-105 transition-transform`}>
|
||||
<action.icon className="h-5 w-5 text-white" />
|
||||
</div>
|
||||
<CardTitle className="text-lg">{action.title}</CardTitle>
|
||||
<CardDescription>{action.description}</CardDescription>
|
||||
<CardTitle className="text-heading-sm">{action.title}</CardTitle>
|
||||
<CardDescription className="text-body-sm">{action.description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className="pt-0">
|
||||
<Link href={action.href}>
|
||||
<Button
|
||||
className={`w-full group ${
|
||||
action.primary
|
||||
? 'gradient-bg hover:opacity-90 shadow-lg shadow-purple-500/25'
|
||||
: ''
|
||||
}`}
|
||||
variant={action.primary ? 'default' : 'outline'}
|
||||
block
|
||||
className="group/btn"
|
||||
>
|
||||
{action.primary ? 'Sorgula' : 'Goruntule'}
|
||||
<ArrowRight className="ml-2 h-4 w-4 group-hover:translate-x-1 transition-transform" />
|
||||
<ArrowRight className="ml-2 h-4 w-4 group-hover/btn:translate-x-1 transition-transform" />
|
||||
</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
@@ -122,18 +122,18 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tips Section */}
|
||||
<Card className="border-0 shadow-md bg-gradient-to-br from-purple-50 to-indigo-50 dark:from-purple-900/20 dark:to-indigo-900/20">
|
||||
<CardContent className="p-6">
|
||||
{/* Tips Section - Selia glass style */}
|
||||
<Card variant="glass" className="ring-primary/20">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="h-10 w-10 rounded-xl bg-purple-100 dark:bg-purple-900/50 flex items-center justify-center flex-shrink-0">
|
||||
<Zap className="h-5 w-5 text-purple-600" />
|
||||
<div className="h-10 w-10 rounded-xl gradient-primary flex items-center justify-center flex-shrink-0 shadow-primary">
|
||||
<Shield className="h-5 w-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-purple-900 dark:text-purple-100 mb-1">
|
||||
<h3 className="text-heading-sm text-foreground mb-1">
|
||||
Ipucu: VIN Numarasi Nerede?
|
||||
</h3>
|
||||
<p className="text-sm text-purple-700 dark:text-purple-300">
|
||||
<p className="text-body-sm text-muted-foreground">
|
||||
VIN numaranizi aracinizin ruhsatinda, sol on kapi pervazinda veya on camin sol alt kosesinde bulabilirsiniz. 17 karakterden olusur ve I, O, Q harflerini icermez.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useState, useRef, useCallback } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
@@ -10,10 +10,18 @@ import {
|
||||
Search,
|
||||
Package,
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Grid3X3,
|
||||
List,
|
||||
Copy,
|
||||
Check
|
||||
Check,
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
Maximize2,
|
||||
Image as ImageIcon,
|
||||
Layers,
|
||||
Info,
|
||||
} from 'lucide-react';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -26,11 +34,19 @@ import { useToast } from '@/hooks/use-toast';
|
||||
interface Part {
|
||||
id: string;
|
||||
oemCode: string;
|
||||
// Formatted part number with spaces (e.g., "WHT 002 437")
|
||||
formattedPartNo?: string;
|
||||
alternativeOems: string[];
|
||||
nameEn: string;
|
||||
nameTr: string;
|
||||
description: string | null;
|
||||
// Remark field (e.g., "Colour code: JG3")
|
||||
remark?: string | null;
|
||||
// Quantity (Unit column in PL24)
|
||||
quantity?: number | null;
|
||||
positionCode: string | null;
|
||||
// Model codes / PR codes for compatibility (e.g., "PR:1PD+F...FM4")
|
||||
modelCodes?: string | null;
|
||||
imageUrl: string | null;
|
||||
prices: Array<{
|
||||
brand: string;
|
||||
@@ -40,6 +56,14 @@ interface Part {
|
||||
}>;
|
||||
}
|
||||
|
||||
interface SubGroup {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
schemaImageUrl: string | null;
|
||||
partCount: number;
|
||||
}
|
||||
|
||||
interface Category {
|
||||
id: string;
|
||||
code: string;
|
||||
@@ -58,11 +82,19 @@ interface Vehicle {
|
||||
year: number;
|
||||
series: string | null;
|
||||
engineCode: string | null;
|
||||
// Additional vehicle details from PL24
|
||||
productionDate?: string | null;
|
||||
transmissionCode?: string | null;
|
||||
driveType?: string | null;
|
||||
colorCode?: string | null;
|
||||
salesType?: string | null;
|
||||
equipment?: string | null;
|
||||
}
|
||||
|
||||
interface CategoryPartsData {
|
||||
vehicle: Vehicle;
|
||||
category: Category;
|
||||
subGroups?: SubGroup[];
|
||||
parts: Part[];
|
||||
totalParts: number;
|
||||
}
|
||||
@@ -78,14 +110,26 @@ export default function CategoryPartsPage() {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
|
||||
const [viewMode, setViewMode] = useState<'split' | 'grid' | 'list'>('split');
|
||||
const [copiedOem, setCopiedOem] = useState<string | null>(null);
|
||||
const [selectedPosition, setSelectedPosition] = useState<string | null>(null);
|
||||
const [hoveredPart, setHoveredPart] = useState<string | null>(null);
|
||||
const [selectedSubGroup, setSelectedSubGroup] = useState<string | null>(null);
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [showVehicleInfo, setShowVehicleInfo] = useState(false);
|
||||
const partRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
||||
const diagramRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchCategoryParts() {
|
||||
try {
|
||||
const response = await apiClient.get(`/vehicles/${vin}/categories/${categoryId}/parts`);
|
||||
setData(response.data.data);
|
||||
// Select first subgroup by default if available
|
||||
if (response.data.data?.subGroups?.length > 0) {
|
||||
setSelectedSubGroup(response.data.data.subGroups[0].id);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.error?.message || 'Parcalar yuklenemedi');
|
||||
} finally {
|
||||
@@ -98,7 +142,7 @@ export default function CategoryPartsPage() {
|
||||
}
|
||||
}, [vin, categoryId]);
|
||||
|
||||
const copyOemCode = async (oemCode: string) => {
|
||||
const copyOemCode = useCallback(async (oemCode: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(oemCode);
|
||||
setCopiedOem(oemCode);
|
||||
@@ -110,9 +154,35 @@ export default function CategoryPartsPage() {
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
}, [toast]);
|
||||
|
||||
// Parcalari filtrele
|
||||
// Scroll to part when position is clicked on diagram
|
||||
const handlePositionClick = useCallback((positionCode: string) => {
|
||||
setSelectedPosition(positionCode);
|
||||
const partRef = partRefs.current.get(positionCode);
|
||||
if (partRef) {
|
||||
partRef.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Get unique position codes for diagram markers
|
||||
const getPositionCodes = useCallback(() => {
|
||||
if (!data?.parts) return [];
|
||||
const positions = new Set<string>();
|
||||
data.parts.forEach(part => {
|
||||
if (part.positionCode && part.positionCode !== '-') {
|
||||
positions.add(part.positionCode);
|
||||
}
|
||||
});
|
||||
return Array.from(positions).sort((a, b) => {
|
||||
const numA = parseInt(a);
|
||||
const numB = parseInt(b);
|
||||
if (!isNaN(numA) && !isNaN(numB)) return numA - numB;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
}, [data?.parts]);
|
||||
|
||||
// Filter parts
|
||||
const filteredParts = data?.parts.filter((part) => {
|
||||
if (!searchQuery) return true;
|
||||
const query = searchQuery.toLowerCase();
|
||||
@@ -120,10 +190,23 @@ export default function CategoryPartsPage() {
|
||||
part.oemCode.toLowerCase().includes(query) ||
|
||||
part.nameTr?.toLowerCase().includes(query) ||
|
||||
part.nameEn.toLowerCase().includes(query) ||
|
||||
part.alternativeOems?.some(oem => oem.toLowerCase().includes(query))
|
||||
part.alternativeOems?.some(oem => oem.toLowerCase().includes(query)) ||
|
||||
part.positionCode?.toLowerCase().includes(query)
|
||||
);
|
||||
}) || [];
|
||||
|
||||
// Get current schema image (from selected subgroup or category)
|
||||
const getCurrentSchemaImage = () => {
|
||||
if (selectedSubGroup && data?.subGroups) {
|
||||
const sg = data.subGroups.find(s => s.id === selectedSubGroup);
|
||||
if (sg?.schemaImageUrl) return sg.schemaImageUrl;
|
||||
}
|
||||
return data?.category?.schemaImageUrl;
|
||||
};
|
||||
|
||||
const schemaImageUrl = getCurrentSchemaImage();
|
||||
const positionCodes = getPositionCodes();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-[60vh] items-center justify-center">
|
||||
@@ -161,10 +244,10 @@ export default function CategoryPartsPage() {
|
||||
);
|
||||
}
|
||||
|
||||
const { vehicle, category, totalParts } = data;
|
||||
const { vehicle, category, subGroups = [], totalParts } = data;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
{/* Breadcrumb */}
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Link href="/dashboard/vehicles/search" className="hover:text-foreground transition-colors">
|
||||
@@ -207,11 +290,21 @@ export default function CategoryPartsPage() {
|
||||
{totalParts} parca
|
||||
</Badge>
|
||||
<div className="flex items-center border rounded-lg p-1">
|
||||
<Button
|
||||
variant={viewMode === 'split' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
className="h-8 px-2 gap-1"
|
||||
onClick={() => setViewMode('split')}
|
||||
title="Split View"
|
||||
>
|
||||
<Layers className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === 'grid' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => setViewMode('grid')}
|
||||
title="Grid View"
|
||||
>
|
||||
<Grid3X3 className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -220,6 +313,7 @@ export default function CategoryPartsPage() {
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => setViewMode('list')}
|
||||
title="List View"
|
||||
>
|
||||
<List className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -227,202 +321,329 @@ export default function CategoryPartsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Vehicle Identification Panel (PL24 style) */}
|
||||
<Card className="border-0 shadow-md">
|
||||
<button
|
||||
onClick={() => setShowVehicleInfo(!showVehicleInfo)}
|
||||
className="w-full flex items-center justify-between p-4 hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-10 w-10 rounded-lg gradient-bg flex items-center justify-center flex-shrink-0">
|
||||
<Info className="h-5 w-5 text-white" />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<h3 className="font-semibold">Arac Bilgileri</h3>
|
||||
<p className="text-sm text-muted-foreground font-mono">{formatVin(vehicle.vin)}</p>
|
||||
</div>
|
||||
</div>
|
||||
{showVehicleInfo ? (
|
||||
<ChevronUp className="h-5 w-5 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown className="h-5 w-5 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
{showVehicleInfo && (
|
||||
<CardContent className="pt-0 pb-4 px-4">
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 pt-4 border-t">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Marka</p>
|
||||
<p className="font-medium">{vehicle.brand.name}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Model</p>
|
||||
<p className="font-medium">{vehicle.model}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Yil</p>
|
||||
<p className="font-medium">{vehicle.year}</p>
|
||||
</div>
|
||||
{vehicle.productionDate && (
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Uretim Tarihi</p>
|
||||
<p className="font-medium">{vehicle.productionDate}</p>
|
||||
</div>
|
||||
)}
|
||||
{vehicle.series && (
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Seri</p>
|
||||
<p className="font-medium">{vehicle.series}</p>
|
||||
</div>
|
||||
)}
|
||||
{vehicle.salesType && (
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Satis Tipi</p>
|
||||
<p className="font-medium">{vehicle.salesType}</p>
|
||||
</div>
|
||||
)}
|
||||
{vehicle.engineCode && (
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Motor Kodu</p>
|
||||
<p className="font-medium">{vehicle.engineCode}</p>
|
||||
</div>
|
||||
)}
|
||||
{vehicle.transmissionCode && (
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Sanziman Kodu</p>
|
||||
<p className="font-medium">{vehicle.transmissionCode}</p>
|
||||
</div>
|
||||
)}
|
||||
{vehicle.driveType && (
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Cekis Tipi</p>
|
||||
<p className="font-medium">{vehicle.driveType}</p>
|
||||
</div>
|
||||
)}
|
||||
{vehicle.colorCode && (
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Renk Kodu</p>
|
||||
<p className="font-medium">{vehicle.colorCode}</p>
|
||||
</div>
|
||||
)}
|
||||
{vehicle.equipment && (
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Donanim</p>
|
||||
<p className="font-medium">{vehicle.equipment}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Sub-groups tabs (if available) */}
|
||||
{subGroups.length > 0 && (
|
||||
<div className="flex gap-2 overflow-x-auto pb-2 -mx-4 px-4 md:mx-0 md:px-0">
|
||||
{subGroups.map((sg) => (
|
||||
<Button
|
||||
key={sg.id}
|
||||
variant={selectedSubGroup === sg.id ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className={`flex-shrink-0 ${selectedSubGroup === sg.id ? 'gradient-bg' : ''}`}
|
||||
onClick={() => setSelectedSubGroup(sg.id)}
|
||||
>
|
||||
{sg.name}
|
||||
<Badge variant="secondary" className="ml-2 h-5 px-1.5">
|
||||
{sg.partCount}
|
||||
</Badge>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-4 top-1/2 -translate-y-1/2 h-5 w-5 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="OEM kodu veya parca adi ara..."
|
||||
placeholder="OEM kodu, parca adi veya pozisyon ara..."
|
||||
className="pl-12 h-12 text-base border-2 focus:border-purple-500"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Category Schema Image */}
|
||||
{category.schemaImageUrl && (
|
||||
<Card className="border-0 shadow-md overflow-hidden">
|
||||
<CardContent className="p-0">
|
||||
<div className="relative aspect-video bg-gray-100 dark:bg-gray-800">
|
||||
<Image
|
||||
src={category.schemaImageUrl}
|
||||
alt={category.nameTr}
|
||||
fill
|
||||
className="object-contain"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Parts */}
|
||||
{filteredParts.length === 0 ? (
|
||||
<Card className="border-0 shadow-lg">
|
||||
<CardContent className="flex flex-col items-center justify-center py-16">
|
||||
<div className="h-16 w-16 rounded-2xl bg-gray-100 dark:bg-gray-800 flex items-center justify-center mb-4">
|
||||
<Package className="h-8 w-8 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2">
|
||||
{searchQuery ? 'Parca bulunamadi' : 'Henuz parca yok'}
|
||||
</h3>
|
||||
<p className="text-muted-foreground text-center max-w-md">
|
||||
{searchQuery
|
||||
? `"${searchQuery}" ile eslesen parca bulunamadi. Farkli bir arama deneyin.`
|
||||
: 'Bu kategori icin henuz parca eklenmemis.'}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : viewMode === 'grid' ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{filteredParts.map((part) => (
|
||||
<Card key={part.id} className="border-2 border-transparent hover:border-purple-300 dark:hover:border-purple-700 hover:shadow-lg transition-all">
|
||||
<CardContent className="p-4">
|
||||
{/* Part Image */}
|
||||
{part.imageUrl && (
|
||||
<div className="relative aspect-square mb-4 bg-gray-100 dark:bg-gray-800 rounded-lg overflow-hidden">
|
||||
<Image
|
||||
src={part.imageUrl}
|
||||
alt={part.nameTr || part.nameEn}
|
||||
fill
|
||||
className="object-contain p-2"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Part Info */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h3 className="font-semibold line-clamp-2">
|
||||
{part.nameTr || part.nameEn}
|
||||
</h3>
|
||||
{part.positionCode && (
|
||||
<Badge variant="outline" className="flex-shrink-0">
|
||||
{part.positionCode}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* OEM Code */}
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="text-sm font-mono bg-gray-100 dark:bg-gray-800 px-2 py-1 rounded flex-1 truncate">
|
||||
{part.oemCode}
|
||||
</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0 flex-shrink-0"
|
||||
onClick={() => copyOemCode(part.oemCode)}
|
||||
>
|
||||
{copiedOem === part.oemCode ? (
|
||||
<Check className="h-4 w-4 text-green-600" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Alternative OEMs */}
|
||||
{part.alternativeOems && part.alternativeOems.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{part.alternativeOems.slice(0, 3).map((oem) => (
|
||||
<Badge key={oem} variant="secondary" className="text-xs">
|
||||
{oem}
|
||||
</Badge>
|
||||
))}
|
||||
{part.alternativeOems.length > 3 && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
+{part.alternativeOems.length - 3}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Prices */}
|
||||
{part.prices && part.prices.length > 0 && (
|
||||
<div className="pt-2 border-t">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{part.prices[0].brand}
|
||||
</span>
|
||||
<span className="font-semibold text-purple-600">
|
||||
{part.prices[0].price.toLocaleString('tr-TR')} {part.prices[0].currency}
|
||||
</span>
|
||||
</div>
|
||||
{part.prices[0].inStock && (
|
||||
<Badge className="mt-1 bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400">
|
||||
Stokta
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* Main Content */}
|
||||
{viewMode === 'split' && schemaImageUrl ? (
|
||||
/* Split View - PL24 Style */
|
||||
<div className={`grid gap-4 ${isFullscreen ? 'fixed inset-0 z-50 bg-background p-4' : 'lg:grid-cols-2'}`}>
|
||||
{/* Diagram Panel */}
|
||||
<Card className={`border-0 shadow-md overflow-hidden ${isFullscreen ? 'h-full' : ''}`}>
|
||||
<CardContent className="p-0">
|
||||
{/* Diagram Header */}
|
||||
<div className="flex items-center justify-between p-3 border-b bg-gray-50 dark:bg-gray-800/50">
|
||||
<div className="flex items-center gap-2">
|
||||
<ImageIcon className="h-4 w-4 text-purple-600" />
|
||||
<span className="text-sm font-medium">Sema Diyagrami</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => setZoom(Math.max(0.5, zoom - 0.25))}
|
||||
>
|
||||
<ZoomOut className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="text-xs w-12 text-center">{Math.round(zoom * 100)}%</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => setZoom(Math.min(3, zoom + 0.25))}
|
||||
>
|
||||
<ZoomIn className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0 ml-2"
|
||||
onClick={() => setIsFullscreen(!isFullscreen)}
|
||||
>
|
||||
<Maximize2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Diagram Container */}
|
||||
<div
|
||||
ref={diagramRef}
|
||||
className={`relative overflow-auto bg-white dark:bg-gray-900 ${isFullscreen ? 'h-[calc(100%-48px)]' : 'aspect-square md:aspect-video'}`}
|
||||
>
|
||||
<div
|
||||
className="relative min-w-full min-h-full flex items-center justify-center p-4"
|
||||
style={{ transform: `scale(${zoom})`, transformOrigin: 'center' }}
|
||||
>
|
||||
<Image
|
||||
src={schemaImageUrl}
|
||||
alt={category.nameTr}
|
||||
width={800}
|
||||
height={600}
|
||||
className="max-w-full h-auto object-contain"
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Position Markers (overlay on diagram) */}
|
||||
<div className="absolute bottom-4 left-4 right-4 flex flex-wrap gap-1.5 bg-white/90 dark:bg-gray-900/90 p-2 rounded-lg backdrop-blur-sm max-h-32 overflow-y-auto">
|
||||
{positionCodes.map((pos) => (
|
||||
<Button
|
||||
key={pos}
|
||||
variant={selectedPosition === pos ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className={`h-7 w-7 p-0 text-xs font-bold rounded-full ${
|
||||
selectedPosition === pos
|
||||
? 'gradient-bg'
|
||||
: hoveredPart === pos
|
||||
? 'bg-purple-100 border-purple-500 dark:bg-purple-900/50'
|
||||
: ''
|
||||
}`}
|
||||
onClick={() => handlePositionClick(pos)}
|
||||
>
|
||||
{pos}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Parts List Panel */}
|
||||
<div className={`space-y-2 ${isFullscreen ? 'overflow-y-auto max-h-full' : 'lg:max-h-[calc(100vh-300px)] lg:overflow-y-auto'}`}>
|
||||
{filteredParts.length === 0 ? (
|
||||
<Card className="border-0 shadow-lg">
|
||||
<CardContent className="flex flex-col items-center justify-center py-16">
|
||||
<Package className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<p className="text-muted-foreground">
|
||||
{searchQuery ? `"${searchQuery}" ile eslesen parca bulunamadi` : 'Parca bulunamadi'}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
filteredParts.map((part) => (
|
||||
<Card
|
||||
key={part.id}
|
||||
ref={(el) => {
|
||||
if (el && part.positionCode) {
|
||||
partRefs.current.set(part.positionCode, el);
|
||||
}
|
||||
}}
|
||||
className={`border-2 transition-all cursor-pointer ${
|
||||
selectedPosition === part.positionCode
|
||||
? 'border-purple-500 bg-purple-50 dark:bg-purple-900/20 shadow-lg'
|
||||
: 'border-transparent hover:border-purple-300 dark:hover:border-purple-700 hover:shadow-md'
|
||||
}`}
|
||||
onMouseEnter={() => setHoveredPart(part.positionCode)}
|
||||
onMouseLeave={() => setHoveredPart(null)}
|
||||
onClick={() => part.positionCode && setSelectedPosition(part.positionCode)}
|
||||
>
|
||||
<CardContent className="p-3">
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Position Badge */}
|
||||
{part.positionCode && part.positionCode !== '-' && (
|
||||
<div className="h-8 w-8 rounded-full gradient-bg flex items-center justify-center flex-shrink-0">
|
||||
<span className="text-xs font-bold text-white">{part.positionCode}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Part Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-sm line-clamp-2">
|
||||
{part.nameTr || part.nameEn}
|
||||
</h3>
|
||||
{/* Remark (color codes, etc.) */}
|
||||
{part.remark && (
|
||||
<p className="text-xs text-amber-600 dark:text-amber-400 mt-0.5 line-clamp-1">
|
||||
{part.remark}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center flex-wrap gap-2 mt-2">
|
||||
{/* Formatted part number */}
|
||||
<code className="text-xs font-mono bg-gray-100 dark:bg-gray-800 px-2 py-0.5 rounded">
|
||||
{part.formattedPartNo || part.oemCode}
|
||||
</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
copyOemCode(part.oemCode);
|
||||
}}
|
||||
>
|
||||
{copiedOem === part.oemCode ? (
|
||||
<Check className="h-3 w-3 text-green-600" />
|
||||
) : (
|
||||
<Copy className="h-3 w-3" />
|
||||
)}
|
||||
</Button>
|
||||
{/* Quantity badge */}
|
||||
{part.quantity && (
|
||||
<Badge variant="outline" className="text-xs h-5">
|
||||
{part.quantity} adet
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{/* Model codes / PR codes */}
|
||||
{part.modelCodes && (
|
||||
<p className="text-xs text-muted-foreground mt-1 font-mono truncate" title={part.modelCodes}>
|
||||
{part.modelCodes}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Price (if available) */}
|
||||
{part.prices && part.prices.length > 0 && (
|
||||
<div className="text-right flex-shrink-0">
|
||||
<div className="font-semibold text-purple-600">
|
||||
{part.prices[0].price.toLocaleString('tr-TR')} {part.prices[0].currency}
|
||||
</div>
|
||||
{part.prices[0].inStock && (
|
||||
<Badge className="mt-1 bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400 text-xs">
|
||||
Stokta
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : viewMode === 'split' && !schemaImageUrl ? (
|
||||
/* Fallback to grid when no schema image */
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{renderGridView()}
|
||||
</div>
|
||||
) : viewMode === 'grid' ? (
|
||||
/* Grid View */
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{renderGridView()}
|
||||
</div>
|
||||
) : (
|
||||
/* List View */
|
||||
<div className="space-y-2">
|
||||
{filteredParts.map((part) => (
|
||||
<Card key={part.id} className="border-2 border-transparent hover:border-purple-300 dark:hover:border-purple-700 hover:shadow-md transition-all">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Part Image */}
|
||||
{part.imageUrl && (
|
||||
<div className="relative h-16 w-16 bg-gray-100 dark:bg-gray-800 rounded-lg overflow-hidden flex-shrink-0">
|
||||
<Image
|
||||
src={part.imageUrl}
|
||||
alt={part.nameTr || part.nameEn}
|
||||
fill
|
||||
className="object-contain p-1"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Part Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-semibold truncate">
|
||||
{part.nameTr || part.nameEn}
|
||||
</h3>
|
||||
{part.positionCode && (
|
||||
<Badge variant="outline" className="flex-shrink-0">
|
||||
{part.positionCode}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<code className="text-sm font-mono text-muted-foreground">
|
||||
{part.oemCode}
|
||||
</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={() => copyOemCode(part.oemCode)}
|
||||
>
|
||||
{copiedOem === part.oemCode ? (
|
||||
<Check className="h-3 w-3 text-green-600" />
|
||||
) : (
|
||||
<Copy className="h-3 w-3" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Price */}
|
||||
{part.prices && part.prices.length > 0 && (
|
||||
<div className="text-right flex-shrink-0">
|
||||
<div className="font-semibold text-purple-600">
|
||||
{part.prices[0].price.toLocaleString('tr-TR')} {part.prices[0].currency}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{part.prices[0].brand}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
{renderListView()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -454,4 +675,237 @@ export default function CategoryPartsPage() {
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
|
||||
function renderGridView() {
|
||||
if (filteredParts.length === 0) {
|
||||
return (
|
||||
<Card className="col-span-full border-0 shadow-lg">
|
||||
<CardContent className="flex flex-col items-center justify-center py-16">
|
||||
<div className="h-16 w-16 rounded-2xl bg-gray-100 dark:bg-gray-800 flex items-center justify-center mb-4">
|
||||
<Package className="h-8 w-8 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2">
|
||||
{searchQuery ? 'Parca bulunamadi' : 'Henuz parca yok'}
|
||||
</h3>
|
||||
<p className="text-muted-foreground text-center max-w-md">
|
||||
{searchQuery
|
||||
? `"${searchQuery}" ile eslesen parca bulunamadi.`
|
||||
: 'Bu kategori icin henuz parca eklenmemis.'}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return filteredParts.map((part) => (
|
||||
<Card key={part.id} className="border-2 border-transparent hover:border-purple-300 dark:hover:border-purple-700 hover:shadow-lg transition-all">
|
||||
<CardContent className="p-4">
|
||||
{/* Part Image */}
|
||||
{part.imageUrl && (
|
||||
<div className="relative aspect-square mb-4 bg-gray-100 dark:bg-gray-800 rounded-lg overflow-hidden">
|
||||
<Image
|
||||
src={part.imageUrl}
|
||||
alt={part.nameTr || part.nameEn}
|
||||
fill
|
||||
className="object-contain p-2"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Part Info */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h3 className="font-semibold line-clamp-2">
|
||||
{part.nameTr || part.nameEn}
|
||||
</h3>
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{part.positionCode && part.positionCode !== '-' && (
|
||||
<Badge variant="outline">
|
||||
{part.positionCode}
|
||||
</Badge>
|
||||
)}
|
||||
{part.quantity && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{part.quantity}x
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Remark (color codes, etc.) */}
|
||||
{part.remark && (
|
||||
<p className="text-xs text-amber-600 dark:text-amber-400 line-clamp-1">
|
||||
{part.remark}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* OEM Code */}
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="text-sm font-mono bg-gray-100 dark:bg-gray-800 px-2 py-1 rounded flex-1 truncate">
|
||||
{part.formattedPartNo || part.oemCode}
|
||||
</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0 flex-shrink-0"
|
||||
onClick={() => copyOemCode(part.oemCode)}
|
||||
>
|
||||
{copiedOem === part.oemCode ? (
|
||||
<Check className="h-4 w-4 text-green-600" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Model codes / PR codes */}
|
||||
{part.modelCodes && (
|
||||
<p className="text-xs text-muted-foreground font-mono truncate" title={part.modelCodes}>
|
||||
{part.modelCodes}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Alternative OEMs */}
|
||||
{part.alternativeOems && part.alternativeOems.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{part.alternativeOems.slice(0, 3).map((oem) => (
|
||||
<Badge key={oem} variant="secondary" className="text-xs">
|
||||
{oem}
|
||||
</Badge>
|
||||
))}
|
||||
{part.alternativeOems.length > 3 && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
+{part.alternativeOems.length - 3}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Prices */}
|
||||
{part.prices && part.prices.length > 0 && (
|
||||
<div className="pt-2 border-t">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{part.prices[0].brand}
|
||||
</span>
|
||||
<span className="font-semibold text-purple-600">
|
||||
{part.prices[0].price.toLocaleString('tr-TR')} {part.prices[0].currency}
|
||||
</span>
|
||||
</div>
|
||||
{part.prices[0].inStock && (
|
||||
<Badge className="mt-1 bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400">
|
||||
Stokta
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
));
|
||||
}
|
||||
|
||||
function renderListView() {
|
||||
if (filteredParts.length === 0) {
|
||||
return (
|
||||
<Card className="border-0 shadow-lg">
|
||||
<CardContent className="flex flex-col items-center justify-center py-16">
|
||||
<div className="h-16 w-16 rounded-2xl bg-gray-100 dark:bg-gray-800 flex items-center justify-center mb-4">
|
||||
<Package className="h-8 w-8 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2">
|
||||
{searchQuery ? 'Parca bulunamadi' : 'Henuz parca yok'}
|
||||
</h3>
|
||||
<p className="text-muted-foreground text-center max-w-md">
|
||||
{searchQuery
|
||||
? `"${searchQuery}" ile eslesen parca bulunamadi.`
|
||||
: 'Bu kategori icin henuz parca eklenmemis.'}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return filteredParts.map((part) => (
|
||||
<Card key={part.id} className="border-2 border-transparent hover:border-purple-300 dark:hover:border-purple-700 hover:shadow-md transition-all">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Position Badge */}
|
||||
{part.positionCode && part.positionCode !== '-' && (
|
||||
<div className="h-10 w-10 rounded-full gradient-bg flex items-center justify-center flex-shrink-0">
|
||||
<span className="text-sm font-bold text-white">{part.positionCode}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Part Image */}
|
||||
{part.imageUrl && (
|
||||
<div className="relative h-16 w-16 bg-gray-100 dark:bg-gray-800 rounded-lg overflow-hidden flex-shrink-0">
|
||||
<Image
|
||||
src={part.imageUrl}
|
||||
alt={part.nameTr || part.nameEn}
|
||||
fill
|
||||
className="object-contain p-1"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Part Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-semibold truncate">
|
||||
{part.nameTr || part.nameEn}
|
||||
</h3>
|
||||
{part.quantity && (
|
||||
<Badge variant="secondary" className="text-xs h-5 flex-shrink-0">
|
||||
{part.quantity}x
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{/* Remark */}
|
||||
{part.remark && (
|
||||
<p className="text-xs text-amber-600 dark:text-amber-400 truncate">
|
||||
{part.remark}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<code className="text-sm font-mono text-muted-foreground">
|
||||
{part.formattedPartNo || part.oemCode}
|
||||
</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={() => copyOemCode(part.oemCode)}
|
||||
>
|
||||
{copiedOem === part.oemCode ? (
|
||||
<Check className="h-3 w-3 text-green-600" />
|
||||
) : (
|
||||
<Copy className="h-3 w-3" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
{/* Model codes */}
|
||||
{part.modelCodes && (
|
||||
<p className="text-xs text-muted-foreground font-mono truncate mt-0.5" title={part.modelCodes}>
|
||||
{part.modelCodes}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Price */}
|
||||
{part.prices && part.prices.length > 0 && (
|
||||
<div className="text-right flex-shrink-0">
|
||||
<div className="font-semibold text-purple-600">
|
||||
{part.prices[0].price.toLocaleString('tr-TR')} {part.prices[0].currency}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{part.prices[0].brand}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ interface Vehicle {
|
||||
}>;
|
||||
}
|
||||
|
||||
// Kategori ikonları
|
||||
// Category icons
|
||||
const categoryIcons: Record<string, any> = {
|
||||
'motor': Cog,
|
||||
'engine': Cog,
|
||||
@@ -119,7 +119,7 @@ export default function VehicleCategoriesPage() {
|
||||
}
|
||||
}, [vin]);
|
||||
|
||||
// Kategorileri filtrele
|
||||
// Filter categories
|
||||
const filteredCategories = vehicle?.categories.filter((vc) => {
|
||||
if (!searchQuery) return true;
|
||||
const query = searchQuery.toLowerCase();
|
||||
@@ -135,8 +135,8 @@ export default function VehicleCategoriesPage() {
|
||||
<div className="flex h-[60vh] items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="relative mx-auto w-fit">
|
||||
<div className="h-16 w-16 rounded-full border-4 border-purple-200 dark:border-purple-900" />
|
||||
<div className="absolute inset-0 h-16 w-16 animate-spin rounded-full border-4 border-transparent border-t-purple-600" />
|
||||
<div className="h-16 w-16 rounded-full border-4 border-primary/20" />
|
||||
<div className="absolute inset-0 h-16 w-16 animate-spin rounded-full border-4 border-transparent border-t-primary" />
|
||||
</div>
|
||||
<p className="mt-6 text-muted-foreground">Parca kategorileri yukleniyor...</p>
|
||||
</div>
|
||||
@@ -153,13 +153,13 @@ export default function VehicleCategoriesPage() {
|
||||
</Button>
|
||||
<Card className="border-0 shadow-lg">
|
||||
<CardContent className="flex flex-col items-center justify-center py-16">
|
||||
<div className="h-20 w-20 rounded-2xl bg-red-100 dark:bg-red-900/30 flex items-center justify-center mb-6">
|
||||
<Car className="h-10 w-10 text-red-600" />
|
||||
<div className="h-20 w-20 rounded-2xl bg-destructive/10 flex items-center justify-center mb-6">
|
||||
<Car className="h-10 w-10 text-destructive" />
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold mb-2">Arac Bulunamadi</h3>
|
||||
<p className="text-muted-foreground mb-6">{error}</p>
|
||||
<Link href="/dashboard/vehicles/search">
|
||||
<Button className="gradient-bg">Yeni Sorgulama Yap</Button>
|
||||
<Button className="gradient-primary">Yeni Sorgulama Yap</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -170,18 +170,18 @@ export default function VehicleCategoriesPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Vehicle Header */}
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 pb-4 border-b">
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 pb-4 border-b border-border">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="rounded-xl border-2 hover:bg-purple-50 dark:hover:bg-purple-900/20 hover:border-purple-300 flex-shrink-0"
|
||||
className="rounded-xl border-2 hover:bg-primary/10 hover:border-primary/30 flex-shrink-0"
|
||||
onClick={() => router.push('/dashboard/vehicles/search')}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-12 w-12 rounded-xl gradient-bg flex items-center justify-center flex-shrink-0">
|
||||
<div className="h-12 w-12 rounded-xl gradient-primary flex items-center justify-center flex-shrink-0">
|
||||
<Car className="h-6 w-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
@@ -190,7 +190,7 @@ export default function VehicleCategoriesPage() {
|
||||
</h1>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span className="font-mono">{formatVin(vehicle.vin)}</span>
|
||||
<span className="text-purple-600 font-medium">• {vehicle.year}</span>
|
||||
<span className="text-primary font-medium">• {vehicle.year}</span>
|
||||
{vehicle.engineCode && (
|
||||
<span className="hidden md:inline">• Motor: {vehicle.engineCode}</span>
|
||||
)}
|
||||
@@ -214,7 +214,7 @@ export default function VehicleCategoriesPage() {
|
||||
<Search className="absolute left-4 top-1/2 -translate-y-1/2 h-5 w-5 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Kategori ara... (ornek: motor, fren, elektrik)"
|
||||
className="pl-12 h-12 text-base border-2 focus:border-purple-500"
|
||||
className="pl-12 h-12 text-base border-2 focus:border-primary"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
@@ -224,7 +224,7 @@ export default function VehicleCategoriesPage() {
|
||||
{filteredCategories.length === 0 ? (
|
||||
<Card className="border-0 shadow-lg">
|
||||
<CardContent className="flex flex-col items-center justify-center py-16">
|
||||
<div className="h-16 w-16 rounded-2xl bg-gray-100 dark:bg-gray-800 flex items-center justify-center mb-4">
|
||||
<div className="h-16 w-16 rounded-2xl bg-muted flex items-center justify-center mb-4">
|
||||
<Package className="h-8 w-8 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2">
|
||||
@@ -246,21 +246,21 @@ export default function VehicleCategoriesPage() {
|
||||
key={vc.id}
|
||||
href={`/dashboard/vehicles/${vehicle.vin}/categories/${vc.category.id}`}
|
||||
>
|
||||
<Card className="border-2 border-transparent hover:border-purple-300 dark:hover:border-purple-700 hover:shadow-lg transition-all cursor-pointer group h-full">
|
||||
<Card className="border-2 border-transparent hover:border-primary/30 hover:shadow-lg transition-all cursor-pointer group h-full">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="h-12 w-12 rounded-xl bg-gradient-to-br from-purple-100 to-indigo-100 dark:from-purple-900/30 dark:to-indigo-900/30 flex items-center justify-center flex-shrink-0 group-hover:from-purple-200 group-hover:to-indigo-200 dark:group-hover:from-purple-800/40 dark:group-hover:to-indigo-800/40 transition-colors">
|
||||
<Icon className="h-6 w-6 text-purple-600" />
|
||||
<div className="h-12 w-12 rounded-xl bg-gradient-to-br from-primary-100 to-accent-100 flex items-center justify-center flex-shrink-0 group-hover:from-primary-200 group-hover:to-accent-200 transition-colors">
|
||||
<Icon className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold truncate group-hover:text-purple-600 transition-colors">
|
||||
<h3 className="font-semibold truncate group-hover:text-primary transition-colors">
|
||||
{vc.category.nameTr}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{vc.partCount} parca
|
||||
</p>
|
||||
</div>
|
||||
<ChevronRight className="h-5 w-5 text-muted-foreground group-hover:text-purple-600 group-hover:translate-x-1 transition-all flex-shrink-0" />
|
||||
<ChevronRight className="h-5 w-5 text-muted-foreground group-hover:text-primary group-hover:translate-x-1 transition-all flex-shrink-0" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -271,20 +271,20 @@ export default function VehicleCategoriesPage() {
|
||||
)}
|
||||
|
||||
{/* Quick Actions */}
|
||||
<Card className="border-0 shadow-md bg-gradient-to-r from-purple-50 to-indigo-50 dark:from-purple-900/20 dark:to-indigo-900/20">
|
||||
<Card className="border-0 shadow-md bg-gradient-to-r from-primary-50 to-accent-50">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-10 w-10 rounded-lg bg-white dark:bg-gray-800 shadow-sm flex items-center justify-center">
|
||||
<Settings className="h-5 w-5 text-purple-600" />
|
||||
<div className="h-10 w-10 rounded-lg bg-card shadow-sm flex items-center justify-center">
|
||||
<Settings className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-purple-900 dark:text-purple-100">Arac detaylarini gor</p>
|
||||
<p className="text-sm text-purple-600 dark:text-purple-300">Tum teknik ozellikler</p>
|
||||
<p className="font-medium text-foreground">Arac detaylarini gor</p>
|
||||
<p className="text-sm text-muted-foreground">Tum teknik ozellikler</p>
|
||||
</div>
|
||||
</div>
|
||||
<Link href={`/dashboard/vehicles/${vehicle.vin}`}>
|
||||
<Button variant="outline" className="border-purple-300 text-purple-700 hover:bg-purple-100 dark:border-purple-700 dark:text-purple-300 dark:hover:bg-purple-900/30">
|
||||
<Button variant="outline" className="border-primary/30 text-primary hover:bg-primary/10">
|
||||
Detaylari Gor
|
||||
<ChevronRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
@@ -203,7 +203,7 @@ export default function VehicleDetailPage() {
|
||||
{vehicle.categories.map((vc) => (
|
||||
<Link
|
||||
key={vc.id}
|
||||
href={`/parts/${vehicle.id}/${vc.category.id}`}
|
||||
href={`/dashboard/vehicles/${vehicle.vin}/categories/${vc.category.id}`}
|
||||
>
|
||||
<div className="flex items-center justify-between rounded-xl border-2 border-transparent bg-gray-50 dark:bg-gray-800/50 p-4 hover:border-purple-300 dark:hover:border-purple-700 hover:bg-purple-50 dark:hover:bg-purple-900/20 transition-all group">
|
||||
<div className="flex items-center gap-3">
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useRouter } from 'next/navigation';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { Loader2, Search, AlertCircle, Info, Sparkles, ArrowRight } from 'lucide-react';
|
||||
import { Search, AlertCircle, Info, Sparkles, ArrowRight, Car, FileSearch } from 'lucide-react';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -43,7 +43,6 @@ export default function VehicleSearchPage() {
|
||||
const response = await apiClient.post('/vehicles/decode', { vin: data.vin });
|
||||
const vehicle = response.data.data.vehicle;
|
||||
|
||||
// Direkt parça kategorileri sayfasına yönlendir
|
||||
router.push(`/dashboard/vehicles/${vehicle.vin}/categories`);
|
||||
} catch (err: any) {
|
||||
const message = err.response?.data?.error?.message || 'VIN sorgulama basarisiz';
|
||||
@@ -59,81 +58,83 @@ export default function VehicleSearchPage() {
|
||||
};
|
||||
|
||||
const progress = Math.min((vinValue.length / 17) * 100, 100);
|
||||
const isComplete = vinValue.length === 17;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl space-y-6">
|
||||
<div className="mx-auto max-w-2xl space-y-8 animate-fade-in">
|
||||
{/* Header */}
|
||||
<div className="text-center">
|
||||
<div className="inline-flex items-center gap-2 rounded-full border border-purple-200 dark:border-purple-800 bg-purple-50 dark:bg-purple-900/20 px-4 py-2 mb-4">
|
||||
<Sparkles className="h-4 w-4 text-purple-600" />
|
||||
<span className="text-sm font-medium text-purple-700 dark:text-purple-300">Hizli Sorgulama</span>
|
||||
<div className="inline-flex items-center gap-2 rounded-full border border-primary/20 bg-primary-50 px-4 py-2 mb-5">
|
||||
<Sparkles className="h-4 w-4 text-primary" />
|
||||
<span className="text-caption-lg text-primary">Hizli Sorgulama</span>
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold mb-2">VIN Sorgulama</h1>
|
||||
<p className="text-muted-foreground">
|
||||
<h1 className="text-display-sm mb-3">VIN Sorgulama</h1>
|
||||
<p className="text-body-md text-muted-foreground max-w-md mx-auto">
|
||||
Arac sase numarasini girerek detayli bilgilere ulasin
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Search Card */}
|
||||
<Card className="border-0 shadow-xl overflow-hidden">
|
||||
<div className="h-1 bg-gray-100 dark:bg-gray-800">
|
||||
<Card variant="elevated" className="overflow-hidden">
|
||||
<div className="h-1.5 bg-secondary">
|
||||
<div
|
||||
className="h-full gradient-bg transition-all duration-300"
|
||||
className={`h-full transition-all duration-300 ${isComplete ? 'bg-success' : 'gradient-primary'}`}
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<CardHeader className="pb-4">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<div className="h-8 w-8 rounded-lg gradient-bg flex items-center justify-center">
|
||||
<Search className="h-4 w-4 text-white" />
|
||||
<CardTitle className="flex items-center gap-3">
|
||||
<div className="h-10 w-10 rounded-xl gradient-primary flex items-center justify-center shadow-primary">
|
||||
<FileSearch className="h-5 w-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-heading-sm">Sase Numarasi (VIN)</span>
|
||||
<p className="text-caption-md text-muted-foreground font-normal mt-0.5">17 haneli arac kimlik numarasini girin</p>
|
||||
</div>
|
||||
Sase Numarasi (VIN)
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
17 haneli arac kimlik numarasini girin
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="vin" className="text-sm font-medium">VIN Numarasi</Label>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
|
||||
<div className="space-y-3">
|
||||
<Label htmlFor="vin" className="text-caption-lg">VIN Numarasi</Label>
|
||||
<Input
|
||||
id="vin"
|
||||
placeholder="WVWZZZ3CZWE123456"
|
||||
maxLength={17}
|
||||
className="font-mono text-lg uppercase tracking-wider h-14 text-center border-2 focus:border-purple-500 focus:ring-purple-500"
|
||||
size="xl"
|
||||
className="font-mono text-lg uppercase tracking-widest text-center"
|
||||
{...register('vin')}
|
||||
/>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className={`transition-colors ${vinValue.length === 17 ? 'text-green-600 font-medium' : 'text-muted-foreground'}`}>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className={`text-caption-md transition-colors ${isComplete ? 'text-success font-medium' : 'text-muted-foreground'}`}>
|
||||
{vinValue.length}/17 karakter
|
||||
{isComplete && ' - Hazir'}
|
||||
</span>
|
||||
{errors.vin && (
|
||||
<span className="text-destructive">{errors.vin.message}</span>
|
||||
<span className="text-caption-md text-destructive">{errors.vin.message}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-center gap-3 rounded-xl bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 p-4 text-sm text-red-700 dark:text-red-300">
|
||||
<AlertCircle className="h-5 w-5 flex-shrink-0" />
|
||||
<div className="flex items-center gap-3 rounded-xl bg-destructive-50 border border-destructive/20 p-4 text-body-sm text-destructive animate-slide-in-from-top">
|
||||
<div className="h-10 w-10 rounded-xl bg-destructive/10 flex items-center justify-center flex-shrink-0">
|
||||
<AlertCircle className="h-5 w-5" />
|
||||
</div>
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full h-12 gradient-bg hover:opacity-90 shadow-lg shadow-purple-500/25 text-base group"
|
||||
disabled={isLoading}
|
||||
variant="gradient"
|
||||
size="xl"
|
||||
className="w-full group"
|
||||
isLoading={isLoading}
|
||||
leftIcon={!isLoading ? <Search className="h-5 w-5" /> : undefined}
|
||||
>
|
||||
{isLoading ? (
|
||||
{!isLoading && (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
||||
Sorgulanıyor...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Search className="mr-2 h-5 w-5" />
|
||||
Sorgula
|
||||
<ArrowRight className="ml-2 h-5 w-5 group-hover:translate-x-1 transition-transform" />
|
||||
</>
|
||||
@@ -144,40 +145,36 @@ export default function VehicleSearchPage() {
|
||||
</Card>
|
||||
|
||||
{/* Info Card */}
|
||||
<Card className="border-0 shadow-md bg-gradient-to-br from-purple-50 to-indigo-50 dark:from-purple-900/20 dark:to-indigo-900/20">
|
||||
<Card variant="glass" className="border-primary/10">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-purple-900 dark:text-purple-100">
|
||||
<Info className="h-5 w-5 text-purple-600" />
|
||||
<CardTitle className="flex items-center gap-3 text-heading-sm">
|
||||
<div className="h-9 w-9 rounded-xl bg-primary/10 flex items-center justify-center">
|
||||
<Info className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
VIN Nedir?
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-sm text-purple-800 dark:text-purple-200 space-y-4">
|
||||
<p>
|
||||
<CardContent className="text-body-sm text-muted-foreground space-y-5">
|
||||
<p className="leading-relaxed">
|
||||
VIN (Vehicle Identification Number), her araca ozgu 17 karakterlik bir kimlik numarasidir.
|
||||
Bu numara aracinizin ruhsatinda, kapi pervazinda veya on camin altinda bulunabilir.
|
||||
</p>
|
||||
<div className="grid gap-2">
|
||||
<div className="flex items-center gap-3 bg-white/50 dark:bg-gray-900/30 rounded-lg p-3">
|
||||
<div className="h-8 w-8 rounded-full bg-purple-100 dark:bg-purple-900/50 flex items-center justify-center text-xs font-bold text-purple-600">1-3</div>
|
||||
<div>
|
||||
<p className="font-medium text-purple-900 dark:text-purple-100">Uretici Kodu (WMI)</p>
|
||||
<p className="text-xs text-purple-600 dark:text-purple-300">Ulke ve uretici bilgisi</p>
|
||||
<div className="grid gap-3">
|
||||
{[
|
||||
{ range: '1-3', title: 'Uretici Kodu (WMI)', desc: 'Ulke ve uretici bilgisi', color: 'from-cyan-500 to-blue-600' },
|
||||
{ range: '4-9', title: 'Arac Ozellikleri (VDS)', desc: 'Model, motor ve donanim', color: 'from-teal-500 to-cyan-600' },
|
||||
{ range: '10-17', title: 'Uretim Bilgileri (VIS)', desc: 'Yil ve seri numarasi', color: 'from-emerald-500 to-teal-600' },
|
||||
].map((item) => (
|
||||
<div key={item.range} className="flex items-center gap-4 bg-card rounded-xl p-4 border border-border/50">
|
||||
<div className={`h-10 w-10 rounded-xl bg-gradient-to-br ${item.color} flex items-center justify-center text-xs font-bold text-white shadow-sm`}>
|
||||
{item.range}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-foreground">{item.title}</p>
|
||||
<p className="text-caption-md text-muted-foreground">{item.desc}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 bg-white/50 dark:bg-gray-900/30 rounded-lg p-3">
|
||||
<div className="h-8 w-8 rounded-full bg-purple-100 dark:bg-purple-900/50 flex items-center justify-center text-xs font-bold text-purple-600">4-9</div>
|
||||
<div>
|
||||
<p className="font-medium text-purple-900 dark:text-purple-100">Arac Ozellikleri (VDS)</p>
|
||||
<p className="text-xs text-purple-600 dark:text-purple-300">Model, motor ve donanim</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 bg-white/50 dark:bg-gray-900/30 rounded-lg p-3">
|
||||
<div className="h-8 w-8 rounded-full bg-purple-100 dark:bg-purple-900/50 flex items-center justify-center text-xs font-bold text-purple-600">10-17</div>
|
||||
<div>
|
||||
<p className="font-medium text-purple-900 dark:text-purple-100">Uretim Bilgileri (VIS)</p>
|
||||
<p className="text-xs text-purple-600 dark:text-purple-300">Yil ve seri numarasi</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
29
apps/web/app/parts/[vehicleId]/[categoryId]/page.tsx
Normal file
29
apps/web/app/parts/[vehicleId]/[categoryId]/page.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ vehicleId: string; categoryId: string }>;
|
||||
}
|
||||
|
||||
export default async function PartsRedirectPage({ params }: Props) {
|
||||
const { vehicleId, categoryId } = await params;
|
||||
|
||||
// Try to fetch vehicle from API (internal server-to-server call)
|
||||
try {
|
||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000/api';
|
||||
const response = await fetch(`${apiUrl}/vehicles/by-id/${vehicleId}`, {
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data?.data?.vin) {
|
||||
redirect(`/dashboard/vehicles/${data.data.vin}/categories/${categoryId}`);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// API call failed, try direct database lookup or fallback
|
||||
}
|
||||
|
||||
// Fallback: redirect to dashboard and let user search
|
||||
redirect('/dashboard/vehicles/search');
|
||||
}
|
||||
10
apps/web/app/vehicles/[vin]/page.tsx
Normal file
10
apps/web/app/vehicles/[vin]/page.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ vin: string }>;
|
||||
}
|
||||
|
||||
export default async function VehicleRedirectPage({ params }: Props) {
|
||||
const { vin } = await params;
|
||||
redirect(`/dashboard/vehicles/${vin}/categories`);
|
||||
}
|
||||
@@ -3,21 +3,50 @@ import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const badgeVariants = cva(
|
||||
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
|
||||
'inline-flex items-center rounded-full text-xs font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
// Primary
|
||||
default:
|
||||
'border-transparent bg-primary text-primary-foreground hover:bg-primary/80',
|
||||
'bg-primary text-primary-foreground ring ring-primary/20',
|
||||
// Secondary
|
||||
secondary:
|
||||
'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
'bg-secondary text-secondary-foreground ring ring-border',
|
||||
// Outline
|
||||
outline:
|
||||
'bg-transparent text-foreground ring ring-border',
|
||||
// Destructive/Danger
|
||||
destructive:
|
||||
'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80',
|
||||
outline: 'text-foreground',
|
||||
'bg-destructive text-destructive-foreground ring ring-destructive/20',
|
||||
// Success
|
||||
success:
|
||||
'bg-success text-success-foreground ring ring-success/20',
|
||||
// Warning
|
||||
warning:
|
||||
'bg-warning text-warning-foreground ring ring-warning/20',
|
||||
// Accent
|
||||
accent:
|
||||
'bg-accent text-accent-foreground ring ring-accent/20',
|
||||
// Soft variants
|
||||
'soft-primary':
|
||||
'bg-primary-50 text-primary ring ring-primary/10',
|
||||
'soft-destructive':
|
||||
'bg-destructive-50 text-destructive ring ring-destructive/10',
|
||||
'soft-success':
|
||||
'bg-success-50 text-success ring ring-success/10',
|
||||
'soft-warning':
|
||||
'bg-warning-50 text-warning ring ring-warning/10',
|
||||
},
|
||||
size: {
|
||||
sm: 'px-2 py-0.5 text-[10px]',
|
||||
default: 'px-2.5 py-0.5',
|
||||
lg: 'px-3 py-1 text-sm',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -26,9 +55,9 @@ export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
function Badge({ className, variant, size, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
<div className={cn(badgeVariants({ variant, size }), className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,30 +1,76 @@
|
||||
import * as React from 'react';
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium ring-offset-background transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 active:scale-[0.98]',
|
||||
'relative inline-flex items-center justify-center gap-2 whitespace-nowrap font-medium select-none cursor-pointer transition-all focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90 shadow-sm hover:shadow-md',
|
||||
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90 shadow-sm hover:shadow-md',
|
||||
outline: 'border-2 border-input bg-background hover:bg-accent hover:text-accent-foreground hover:border-accent',
|
||||
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
// Primary - Selia style with inset shadow
|
||||
default:
|
||||
'bg-primary text-primary-foreground ring ring-primary/20 shadow-sm inset-shadow-white/15 hover:bg-primary/90 active:scale-[0.98]',
|
||||
// Secondary
|
||||
secondary:
|
||||
'bg-secondary text-secondary-foreground ring ring-border hover:bg-secondary/80 active:scale-[0.98]',
|
||||
// Tertiary
|
||||
tertiary:
|
||||
'bg-tertiary text-tertiary-foreground ring ring-border hover:bg-tertiary/80 active:scale-[0.98]',
|
||||
// Danger/Destructive
|
||||
destructive:
|
||||
'bg-destructive text-destructive-foreground ring ring-destructive/20 shadow-sm inset-shadow-white/15 hover:bg-destructive/90 active:scale-[0.98]',
|
||||
// Outline - Ring based border
|
||||
outline:
|
||||
'bg-transparent ring ring-border text-foreground hover:bg-muted hover:ring-border-secondary active:bg-muted/80',
|
||||
// Ghost
|
||||
ghost:
|
||||
'text-foreground hover:bg-muted active:bg-muted/80',
|
||||
// Plain/Link
|
||||
plain:
|
||||
'text-foreground hover:text-primary underline-offset-4 hover:underline',
|
||||
// Link
|
||||
link:
|
||||
'text-primary underline-offset-4 hover:underline',
|
||||
// Gradient - Selia premium style
|
||||
gradient:
|
||||
'gradient-primary text-white shadow-md ring ring-primary/20 inset-shadow-white/15 hover:opacity-95 active:scale-[0.98]',
|
||||
// Success
|
||||
success:
|
||||
'bg-success text-success-foreground ring ring-success/20 shadow-sm inset-shadow-white/15 hover:bg-success/90 active:scale-[0.98]',
|
||||
// Warning
|
||||
warning:
|
||||
'bg-warning text-warning-foreground ring ring-warning/20 shadow-sm hover:bg-warning/90 active:scale-[0.98]',
|
||||
},
|
||||
size: {
|
||||
default: 'h-10 px-5 py-2',
|
||||
sm: 'h-9 rounded-lg px-4',
|
||||
lg: 'h-12 rounded-xl px-8',
|
||||
icon: 'h-10 w-10',
|
||||
xs: 'h-7 px-2.5 text-xs rounded-md [&_svg]:size-3',
|
||||
sm: 'h-8 px-3 text-sm rounded-lg [&_svg]:size-3.5',
|
||||
default: 'h-9 px-4 text-sm rounded-lg [&_svg]:size-4',
|
||||
md: 'h-10 px-5 text-sm rounded-lg [&_svg]:size-4',
|
||||
lg: 'h-11 px-6 text-base rounded-xl [&_svg]:size-5',
|
||||
xl: 'h-12 px-8 text-base font-semibold rounded-xl [&_svg]:size-5',
|
||||
// Icon sizes
|
||||
'icon-xs': 'h-7 w-7 rounded-md [&_svg]:size-3.5',
|
||||
'icon-sm': 'h-8 w-8 rounded-lg [&_svg]:size-4',
|
||||
icon: 'h-9 w-9 rounded-lg [&_svg]:size-4',
|
||||
'icon-md': 'h-10 w-10 rounded-lg [&_svg]:size-5',
|
||||
'icon-lg': 'h-11 w-11 rounded-xl [&_svg]:size-5',
|
||||
},
|
||||
pill: {
|
||||
true: 'rounded-full',
|
||||
false: '',
|
||||
},
|
||||
block: {
|
||||
true: 'w-full',
|
||||
false: '',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
pill: false,
|
||||
block: false,
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -33,12 +79,35 @@ export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
isLoading?: boolean;
|
||||
leftIcon?: React.ReactNode;
|
||||
rightIcon?: React.ReactNode;
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
({ className, variant, size, pill, block, asChild = false, isLoading, leftIcon, rightIcon, children, disabled, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : 'button';
|
||||
return <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />;
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, pill, block, className }))}
|
||||
ref={ref}
|
||||
disabled={disabled || isLoading}
|
||||
{...props}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="animate-spin" />
|
||||
<span>{children}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{leftIcon && <span className="inline-flex">{leftIcon}</span>}
|
||||
{children}
|
||||
{rightIcon && <span className="inline-flex">{rightIcon}</span>}
|
||||
</>
|
||||
)}
|
||||
</Comp>
|
||||
);
|
||||
},
|
||||
);
|
||||
Button.displayName = 'Button';
|
||||
|
||||
@@ -1,51 +1,134 @@
|
||||
import * as React from 'react';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
const cardVariants = cva(
|
||||
'rounded-xl bg-card text-card-foreground ring ring-card-border shadow-card transition-all',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: '',
|
||||
elevated: 'shadow-md hover:shadow-lg',
|
||||
outline: 'bg-transparent shadow-none',
|
||||
ghost: 'ring-0 bg-transparent shadow-none',
|
||||
glass: 'glass ring-0',
|
||||
gradient: 'gradient-border ring-0 bg-card',
|
||||
interactive: 'cursor-pointer hover:ring-border-secondary hover:shadow-md',
|
||||
success: 'ring-success/30 bg-success-50',
|
||||
warning: 'ring-warning/30 bg-warning-50',
|
||||
destructive: 'ring-destructive/30 bg-destructive-50',
|
||||
},
|
||||
padding: {
|
||||
none: '',
|
||||
sm: 'p-4',
|
||||
default: 'p-6',
|
||||
lg: 'p-8',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
padding: 'none',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export interface CardProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof cardVariants> {}
|
||||
|
||||
const Card = React.forwardRef<HTMLDivElement, CardProps>(
|
||||
({ className, variant, padding, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'rounded-xl border bg-card text-card-foreground shadow-sm transition-all duration-300',
|
||||
className
|
||||
)}
|
||||
className={cn(cardVariants({ variant, padding }), className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
Card.displayName = 'Card';
|
||||
|
||||
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} />
|
||||
),
|
||||
);
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & {
|
||||
compact?: boolean;
|
||||
align?: 'default' | 'center' | 'right';
|
||||
}
|
||||
>(({ className, compact, align = 'default', ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex flex-col border-b border-card-separator',
|
||||
compact ? 'gap-y-1 p-4' : 'gap-y-1.5 p-6',
|
||||
align === 'center' && 'items-center text-center',
|
||||
align === 'right' && 'items-end text-right',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardHeader.displayName = 'CardHeader';
|
||||
|
||||
const CardTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('text-2xl font-semibold leading-none tracking-tight', className)} {...props} />
|
||||
),
|
||||
);
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & { as?: 'h1' | 'h2' | 'h3' | 'h4' }
|
||||
>(({ className, as: Component = 'h3', ...props }, ref) => (
|
||||
<Component
|
||||
ref={ref as React.Ref<HTMLHeadingElement>}
|
||||
className={cn(
|
||||
'text-lg font-semibold leading-none tracking-tight text-foreground',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardTitle.displayName = 'CardTitle';
|
||||
|
||||
const CardDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
|
||||
),
|
||||
);
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('text-sm text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardDescription.displayName = 'CardDescription';
|
||||
|
||||
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => <div ref={ref} className={cn('p-6 pt-0', className)} {...props} />,
|
||||
);
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & { compact?: boolean }
|
||||
>(({ className, compact, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(compact ? 'p-4' : 'p-6', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardContent.displayName = 'CardContent';
|
||||
|
||||
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('flex items-center p-6 pt-0', className)} {...props} />
|
||||
),
|
||||
);
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & { compact?: boolean }
|
||||
>(({ className, compact, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 border-t border-card-separator bg-card-footer rounded-b-xl',
|
||||
compact ? 'p-4' : 'p-6',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardFooter.displayName = 'CardFooter';
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
cardVariants,
|
||||
};
|
||||
|
||||
@@ -1,23 +1,108 @@
|
||||
import * as React from 'react';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>;
|
||||
const inputVariants = cva(
|
||||
'flex w-full rounded-lg text-sm transition-all file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-dimmed focus:outline-0 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
// Default - Selia ring-based border
|
||||
default:
|
||||
'bg-input ring ring-input-border hover:not-disabled:not-focus:ring-input-accent-border focus:ring-2 focus:ring-primary',
|
||||
// Subtle - Transparent background
|
||||
subtle:
|
||||
'bg-input/60 ring ring-input-border hover:not-disabled:not-focus:ring-input-accent-border focus:ring-2 focus:ring-primary',
|
||||
// Filled
|
||||
filled:
|
||||
'bg-secondary ring-0 focus:ring-2 focus:ring-primary',
|
||||
// Ghost
|
||||
ghost:
|
||||
'bg-transparent ring-0 hover:bg-muted focus:ring-2 focus:ring-primary',
|
||||
// Error
|
||||
error:
|
||||
'bg-destructive-50 ring ring-destructive/50 text-destructive focus:ring-2 focus:ring-destructive',
|
||||
// Success
|
||||
success:
|
||||
'bg-success-50 ring ring-success/50 text-success focus:ring-2 focus:ring-success',
|
||||
},
|
||||
size: {
|
||||
xs: 'h-7 px-2.5 text-xs',
|
||||
sm: 'h-8 px-3 text-sm',
|
||||
default: 'h-9 px-3.5',
|
||||
md: 'h-10 px-4',
|
||||
lg: 'h-11 px-4 text-base',
|
||||
xl: 'h-12 px-5 text-base',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export interface InputProps
|
||||
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'size'>,
|
||||
VariantProps<typeof inputVariants> {
|
||||
leftIcon?: React.ReactNode;
|
||||
rightIcon?: React.ReactNode;
|
||||
error?: string;
|
||||
success?: string;
|
||||
wrapperClassName?: string;
|
||||
}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
(
|
||||
{
|
||||
className,
|
||||
type,
|
||||
variant,
|
||||
size,
|
||||
leftIcon,
|
||||
rightIcon,
|
||||
error,
|
||||
success,
|
||||
wrapperClassName,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const inputVariant = error ? 'error' : success ? 'success' : variant;
|
||||
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className,
|
||||
<div className={cn('relative w-full', wrapperClassName)}>
|
||||
{leftIcon && (
|
||||
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-dimmed pointer-events-none">
|
||||
{leftIcon}
|
||||
</div>
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
inputVariants({ variant: inputVariant, size }),
|
||||
leftIcon && 'pl-10',
|
||||
rightIcon && 'pr-10',
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
{rightIcon && (
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2 text-dimmed">
|
||||
{rightIcon}
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<p className="mt-1.5 text-xs text-destructive animate-fade-in">{error}</p>
|
||||
)}
|
||||
{success && !error && (
|
||||
<p className="mt-1.5 text-xs text-success animate-fade-in">{success}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
Input.displayName = 'Input';
|
||||
|
||||
export { Input };
|
||||
export { Input, inputVariants };
|
||||
|
||||
@@ -6,15 +6,37 @@ import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const labelVariants = cva(
|
||||
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
|
||||
'text-sm font-medium leading-none text-foreground peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: '',
|
||||
muted: 'text-muted-foreground font-normal',
|
||||
dimmed: 'text-dimmed font-normal',
|
||||
},
|
||||
size: {
|
||||
sm: 'text-xs',
|
||||
default: 'text-sm',
|
||||
lg: 'text-base',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
|
||||
>(({ className, variant, size, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(labelVariants({ variant, size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Label.displayName = LabelPrimitive.Root.displayName;
|
||||
|
||||
export { Label };
|
||||
export { Label, labelVariants };
|
||||
|
||||
178
apps/web/components/ui/theme-toggle.tsx
Normal file
178
apps/web/components/ui/theme-toggle.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { useTheme } from 'next-themes';
|
||||
import { Sun, Moon, Monitor } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface ThemeToggleProps {
|
||||
variant?: 'default' | 'compact' | 'dropdown';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ThemeToggle({ variant = 'default', className }: ThemeToggleProps) {
|
||||
const { theme, setTheme, resolvedTheme } = useTheme();
|
||||
const [mounted, setMounted] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
if (!mounted) {
|
||||
return (
|
||||
<div className={cn('h-9 w-9 rounded-lg bg-muted animate-pulse', className)} />
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === 'compact') {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setTheme(resolvedTheme === 'dark' ? 'light' : 'dark')}
|
||||
className={cn(
|
||||
'relative h-9 w-9 rounded-lg transition-colors',
|
||||
'hover:bg-primary/10 hover:text-primary',
|
||||
className
|
||||
)}
|
||||
aria-label={`Switch to ${resolvedTheme === 'dark' ? 'light' : 'dark'} mode`}
|
||||
>
|
||||
<Sun className={cn(
|
||||
'h-[1.125rem] w-[1.125rem] transition-all duration-300',
|
||||
resolvedTheme === 'dark' ? 'rotate-0 scale-100' : 'rotate-90 scale-0'
|
||||
)} />
|
||||
<Moon className={cn(
|
||||
'absolute h-[1.125rem] w-[1.125rem] transition-all duration-300',
|
||||
resolvedTheme === 'dark' ? 'rotate-90 scale-0' : 'rotate-0 scale-100'
|
||||
)} />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
// Default: segmented toggle with all three options
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'inline-flex items-center rounded-lg p-1 gap-0.5',
|
||||
'bg-muted/50 border border-border',
|
||||
className
|
||||
)}
|
||||
role="radiogroup"
|
||||
aria-label="Theme selection"
|
||||
>
|
||||
{[
|
||||
{ value: 'light', icon: Sun, label: 'Aydinlik' },
|
||||
{ value: 'dark', icon: Moon, label: 'Karanlik' },
|
||||
{ value: 'system', icon: Monitor, label: 'Sistem' },
|
||||
].map(({ value, icon: Icon, label }) => (
|
||||
<button
|
||||
key={value}
|
||||
onClick={() => setTheme(value)}
|
||||
className={cn(
|
||||
'relative flex items-center justify-center rounded-md px-3 py-1.5 text-sm font-medium transition-all duration-200',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||
theme === value
|
||||
? 'bg-background text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-background/50'
|
||||
)}
|
||||
role="radio"
|
||||
aria-checked={theme === value}
|
||||
aria-label={label}
|
||||
>
|
||||
<Icon className="h-4 w-4 mr-1.5" />
|
||||
<span className="hidden sm:inline">{label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Dropdown version for mobile
|
||||
export function ThemeToggleDropdown({ className }: { className?: string }) {
|
||||
const { theme, setTheme, resolvedTheme } = useTheme();
|
||||
const [mounted, setMounted] = React.useState(false);
|
||||
const [isOpen, setIsOpen] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleClickOutside = () => setIsOpen(false);
|
||||
if (isOpen) {
|
||||
document.addEventListener('click', handleClickOutside);
|
||||
return () => document.removeEventListener('click', handleClickOutside);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
if (!mounted) {
|
||||
return <div className={cn('h-9 w-9 rounded-lg bg-muted animate-pulse', className)} />;
|
||||
}
|
||||
|
||||
const themes = [
|
||||
{ value: 'light', icon: Sun, label: 'Aydinlik Mod' },
|
||||
{ value: 'dark', icon: Moon, label: 'Karanlik Mod' },
|
||||
{ value: 'system', icon: Monitor, label: 'Sistem Ayari' },
|
||||
];
|
||||
|
||||
const currentTheme = themes.find(t => t.value === theme) || themes[2];
|
||||
const CurrentIcon = resolvedTheme === 'dark' ? Moon : Sun;
|
||||
|
||||
return (
|
||||
<div className={cn('relative', className)}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsOpen(!isOpen);
|
||||
}}
|
||||
className={cn(
|
||||
'h-9 w-9 rounded-lg transition-colors',
|
||||
'hover:bg-primary/10 hover:text-primary',
|
||||
isOpen && 'bg-primary/10 text-primary'
|
||||
)}
|
||||
aria-label="Tema sec"
|
||||
aria-expanded={isOpen}
|
||||
>
|
||||
<CurrentIcon className="h-[1.125rem] w-[1.125rem]" />
|
||||
</Button>
|
||||
|
||||
{isOpen && (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute right-0 top-full mt-2 z-50',
|
||||
'min-w-[160px] rounded-lg border border-border bg-popover p-1 shadow-lg',
|
||||
'animate-scale-in origin-top-right'
|
||||
)}
|
||||
role="menu"
|
||||
>
|
||||
{themes.map(({ value, icon: Icon, label }) => (
|
||||
<button
|
||||
key={value}
|
||||
onClick={() => {
|
||||
setTheme(value);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 rounded-md px-3 py-2 text-sm transition-colors',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||
theme === value
|
||||
? 'bg-primary/10 text-primary font-medium'
|
||||
: 'text-foreground hover:bg-muted'
|
||||
)}
|
||||
role="menuitem"
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
{label}
|
||||
{theme === value && (
|
||||
<span className="ml-auto h-1.5 w-1.5 rounded-full bg-primary" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -24,12 +24,14 @@ const ToastViewport = React.forwardRef<
|
||||
ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
|
||||
|
||||
const toastVariants = cva(
|
||||
'group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full',
|
||||
'group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-xl p-4 pr-8 ring ring-card-border shadow-card transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'border bg-background text-foreground',
|
||||
destructive: 'destructive group border-destructive bg-destructive text-destructive-foreground',
|
||||
default: 'bg-card text-foreground',
|
||||
destructive: 'bg-destructive text-destructive-foreground ring-destructive/20',
|
||||
success: 'bg-success text-success-foreground ring-success/20',
|
||||
warning: 'bg-warning text-warning-foreground ring-warning/20',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
@@ -53,7 +55,7 @@ const ToastAction = React.forwardRef<
|
||||
<ToastPrimitives.Action
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive',
|
||||
'inline-flex h-8 shrink-0 items-center justify-center rounded-lg ring ring-border bg-transparent px-3 text-sm font-medium transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:ring-destructive/30 group-[.destructive]:hover:bg-destructive/90 group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -68,7 +70,7 @@ const ToastClose = React.forwardRef<
|
||||
<ToastPrimitives.Close
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600',
|
||||
'absolute right-2 top-2 rounded-lg p-1.5 text-foreground/50 opacity-0 transition-all hover:text-foreground hover:bg-muted focus:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring group-hover:opacity-100 group-[.destructive]:text-destructive-foreground/70 group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:hover:bg-destructive/80',
|
||||
className,
|
||||
)}
|
||||
toast-close=""
|
||||
@@ -91,7 +93,7 @@ const ToastDescription = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Description ref={ref} className={cn('text-sm opacity-90', className)} {...props} />
|
||||
<ToastPrimitives.Description ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
|
||||
));
|
||||
ToastDescription.displayName = ToastPrimitives.Description.displayName;
|
||||
|
||||
|
||||
@@ -2,50 +2,218 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/*
|
||||
* SASE.TR Design System - Selia Inspired
|
||||
* Modern, minimal design with ring-based borders
|
||||
* Clean typography and subtle shadows
|
||||
*/
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
/* ===== LIGHT MODE - Selia Inspired ===== */
|
||||
|
||||
/* Core Backgrounds - Clean whites */
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 224 71% 4%;
|
||||
--background-secondary: 240 5% 96%;
|
||||
--background-tertiary: 240 5% 93%;
|
||||
--foreground: 240 10% 4%;
|
||||
--foreground-secondary: 240 5% 46%;
|
||||
|
||||
/* Surface Colors - Cards, Popovers */
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 224 71% 4%;
|
||||
--card-foreground: 240 10% 4%;
|
||||
--card-hover: 240 5% 97%;
|
||||
--card-footer: 240 5% 97%;
|
||||
--card-separator: 240 6% 90%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 224 71% 4%;
|
||||
--primary: 262 83% 58%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--secondary: 220 14% 96%;
|
||||
--secondary-foreground: 220 9% 46%;
|
||||
--muted: 220 14% 96%;
|
||||
--muted-foreground: 220 9% 46%;
|
||||
--accent: 262 83% 58%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
--popover-foreground: 240 10% 4%;
|
||||
|
||||
/* Primary - Modern Blue */
|
||||
--primary: 221 83% 53%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
--primary-50: 214 100% 97%;
|
||||
--primary-100: 214 95% 93%;
|
||||
--primary-200: 213 97% 87%;
|
||||
--primary-300: 212 96% 78%;
|
||||
--primary-400: 213 94% 68%;
|
||||
--primary-500: 217 91% 60%;
|
||||
--primary-600: 221 83% 53%;
|
||||
--primary-700: 224 76% 48%;
|
||||
--primary-800: 226 71% 40%;
|
||||
--primary-900: 224 64% 33%;
|
||||
|
||||
/* Secondary - Soft Gray */
|
||||
--secondary: 240 5% 96%;
|
||||
--secondary-foreground: 240 6% 10%;
|
||||
|
||||
/* Tertiary */
|
||||
--tertiary: 240 5% 93%;
|
||||
--tertiary-foreground: 240 6% 25%;
|
||||
|
||||
/* Muted - Subtle grays */
|
||||
--muted: 240 5% 96%;
|
||||
--muted-foreground: 240 4% 46%;
|
||||
|
||||
/* Dimmed text */
|
||||
--dimmed: 240 5% 65%;
|
||||
|
||||
/* Accent - Teal/Cyan */
|
||||
--accent: 173 80% 40%;
|
||||
--accent-foreground: 0 0% 100%;
|
||||
--accent-50: 174 72% 95%;
|
||||
--accent-100: 175 77% 88%;
|
||||
--accent-500: 173 80% 40%;
|
||||
|
||||
/* Semantic Colors */
|
||||
--success: 142 71% 45%;
|
||||
--success-foreground: 0 0% 100%;
|
||||
--success-50: 143 64% 95%;
|
||||
--success-100: 141 79% 85%;
|
||||
--success-500: 142 71% 45%;
|
||||
|
||||
--warning: 38 92% 50%;
|
||||
--warning-foreground: 38 92% 10%;
|
||||
--warning-50: 48 96% 95%;
|
||||
--warning-100: 48 96% 85%;
|
||||
--warning-500: 38 92% 50%;
|
||||
|
||||
--destructive: 0 84% 60%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 220 13% 91%;
|
||||
--input: 220 13% 91%;
|
||||
--ring: 262 83% 58%;
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
--destructive-50: 0 86% 97%;
|
||||
--destructive-100: 0 93% 94%;
|
||||
--destructive-500: 0 84% 60%;
|
||||
|
||||
/* Danger alias */
|
||||
--danger: 0 84% 60%;
|
||||
--danger-foreground: 0 0% 100%;
|
||||
|
||||
/* Borders & Inputs - Ring based system */
|
||||
--border: 240 6% 90%;
|
||||
--border-secondary: 240 5% 85%;
|
||||
--input: 0 0% 100%;
|
||||
--input-border: 240 6% 90%;
|
||||
--input-accent-border: 240 5% 75%;
|
||||
--ring: 221 83% 53%;
|
||||
|
||||
/* Card specific */
|
||||
--card-border: 240 6% 90%;
|
||||
--card-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.04), 0 1px 2px -1px rgb(0 0 0 / 0.04);
|
||||
|
||||
/* Radius - Selia style rounded corners */
|
||||
--radius: 0.75rem;
|
||||
--radius-sm: 0.5rem;
|
||||
--radius-lg: 1rem;
|
||||
--radius-xl: 1.5rem;
|
||||
--radius-full: 9999px;
|
||||
|
||||
/* Shadows - Minimal, subtle */
|
||||
--shadow-xs: 0 1px 2px 0 rgb(0 0 0 / 0.03);
|
||||
--shadow-sm: 0 1px 3px 0 rgb(0 0 0 / 0.04), 0 1px 2px -1px rgb(0 0 0 / 0.04);
|
||||
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.05), 0 2px 4px -2px rgb(0 0 0 / 0.05);
|
||||
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.05), 0 4px 6px -4px rgb(0 0 0 / 0.05);
|
||||
--shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.05), 0 8px 10px -6px rgb(0 0 0 / 0.05);
|
||||
--shadow-card: 0 1px 3px 0 rgb(0 0 0 / 0.04), 0 1px 2px -1px rgb(0 0 0 / 0.04);
|
||||
--shadow-primary: 0 4px 14px 0 hsl(var(--primary) / 0.15);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 224 71% 4%;
|
||||
--foreground: 210 40% 98%;
|
||||
--card: 224 71% 8%;
|
||||
--card-foreground: 210 40% 98%;
|
||||
--popover: 224 71% 8%;
|
||||
--popover-foreground: 210 40% 98%;
|
||||
--primary: 263 70% 50%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--secondary: 215 28% 17%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
--muted: 215 28% 17%;
|
||||
--muted-foreground: 217 10% 65%;
|
||||
--accent: 263 70% 50%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
--destructive: 0 62% 30%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 215 28% 17%;
|
||||
--input: 215 28% 17%;
|
||||
--ring: 263 70% 50%;
|
||||
/* ===== DARK MODE - Selia Inspired ===== */
|
||||
|
||||
/* Core Backgrounds - Deep grays */
|
||||
--background: 240 10% 4%;
|
||||
--background-secondary: 240 6% 10%;
|
||||
--background-tertiary: 240 5% 14%;
|
||||
--foreground: 0 0% 98%;
|
||||
--foreground-secondary: 240 5% 65%;
|
||||
|
||||
/* Surface Colors */
|
||||
--card: 240 6% 10%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
--card-hover: 240 5% 14%;
|
||||
--card-footer: 240 5% 8%;
|
||||
--card-separator: 240 4% 18%;
|
||||
--popover: 240 6% 10%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
|
||||
/* Primary - Brighter for dark mode */
|
||||
--primary: 217 91% 60%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
--primary-50: 224 71% 12%;
|
||||
--primary-100: 224 70% 16%;
|
||||
--primary-200: 224 70% 22%;
|
||||
--primary-300: 217 85% 35%;
|
||||
--primary-400: 217 88% 48%;
|
||||
--primary-500: 217 91% 60%;
|
||||
--primary-600: 213 94% 68%;
|
||||
--primary-700: 212 96% 78%;
|
||||
--primary-800: 213 97% 87%;
|
||||
--primary-900: 214 95% 93%;
|
||||
|
||||
/* Secondary */
|
||||
--secondary: 240 4% 16%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
|
||||
/* Tertiary */
|
||||
--tertiary: 240 5% 20%;
|
||||
--tertiary-foreground: 240 5% 80%;
|
||||
|
||||
/* Muted */
|
||||
--muted: 240 4% 16%;
|
||||
--muted-foreground: 240 5% 55%;
|
||||
|
||||
/* Dimmed */
|
||||
--dimmed: 240 5% 45%;
|
||||
|
||||
/* Accent */
|
||||
--accent: 173 80% 50%;
|
||||
--accent-foreground: 240 10% 4%;
|
||||
--accent-50: 175 50% 12%;
|
||||
--accent-100: 175 55% 18%;
|
||||
--accent-500: 173 80% 50%;
|
||||
|
||||
/* Semantic Colors */
|
||||
--success: 142 71% 50%;
|
||||
--success-foreground: 142 80% 10%;
|
||||
--success-50: 142 50% 12%;
|
||||
--success-100: 142 55% 18%;
|
||||
--success-500: 142 71% 50%;
|
||||
|
||||
--warning: 38 92% 55%;
|
||||
--warning-foreground: 38 92% 10%;
|
||||
--warning-50: 38 50% 12%;
|
||||
--warning-100: 38 55% 18%;
|
||||
--warning-500: 38 92% 55%;
|
||||
|
||||
--destructive: 0 84% 60%;
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
--destructive-50: 0 50% 12%;
|
||||
--destructive-100: 0 55% 18%;
|
||||
--destructive-500: 0 84% 60%;
|
||||
|
||||
/* Danger alias */
|
||||
--danger: 0 84% 60%;
|
||||
--danger-foreground: 0 0% 100%;
|
||||
|
||||
/* Borders & Inputs */
|
||||
--border: 240 4% 18%;
|
||||
--border-secondary: 240 4% 24%;
|
||||
--input: 240 6% 10%;
|
||||
--input-border: 240 4% 18%;
|
||||
--input-accent-border: 240 4% 30%;
|
||||
--ring: 217 91% 60%;
|
||||
|
||||
/* Card specific */
|
||||
--card-border: 240 4% 18%;
|
||||
--card-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.2), 0 1px 2px -1px rgb(0 0 0 / 0.2);
|
||||
|
||||
/* Shadows - Darker for dark mode */
|
||||
--shadow-xs: 0 1px 2px 0 rgb(0 0 0 / 0.2);
|
||||
--shadow-sm: 0 1px 3px 0 rgb(0 0 0 / 0.25), 0 1px 2px -1px rgb(0 0 0 / 0.25);
|
||||
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.3), 0 2px 4px -2px rgb(0 0 0 / 0.3);
|
||||
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.3), 0 4px 6px -4px rgb(0 0 0 / 0.3);
|
||||
--shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.3), 0 8px 10px -6px rgb(0 0 0 / 0.3);
|
||||
--shadow-card: 0 1px 3px 0 rgb(0 0 0 / 0.2), 0 1px 2px -1px rgb(0 0 0 / 0.2);
|
||||
--shadow-primary: 0 4px 20px 0 hsl(var(--primary) / 0.25);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,27 +221,237 @@
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground antialiased;
|
||||
@apply bg-background text-foreground;
|
||||
font-feature-settings: "cv02", "cv03", "cv04", "cv11";
|
||||
}
|
||||
|
||||
/* Focus states for accessibility - Ring based */
|
||||
:focus-visible {
|
||||
@apply outline-none ring-2 ring-ring ring-offset-2 ring-offset-background;
|
||||
}
|
||||
|
||||
/* Selection */
|
||||
::selection {
|
||||
@apply bg-primary/20 text-foreground;
|
||||
}
|
||||
|
||||
/* Disable transitions for reduced motion */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
/* ===== SELIA GRADIENT UTILITIES ===== */
|
||||
|
||||
.gradient-primary {
|
||||
@apply bg-gradient-to-br from-blue-500 via-blue-600 to-indigo-600;
|
||||
}
|
||||
|
||||
.dark .gradient-primary {
|
||||
@apply from-blue-400 via-blue-500 to-indigo-500;
|
||||
}
|
||||
|
||||
.gradient-primary-soft {
|
||||
@apply bg-gradient-to-br from-blue-500/10 via-blue-600/10 to-indigo-600/10;
|
||||
}
|
||||
|
||||
.gradient-accent {
|
||||
@apply bg-gradient-to-r from-teal-500 via-cyan-500 to-blue-500;
|
||||
}
|
||||
|
||||
.gradient-success {
|
||||
@apply bg-gradient-to-r from-emerald-500 to-teal-500;
|
||||
}
|
||||
|
||||
.gradient-warning {
|
||||
@apply bg-gradient-to-r from-amber-500 to-orange-500;
|
||||
}
|
||||
|
||||
.gradient-text {
|
||||
@apply bg-gradient-to-r from-blue-600 via-indigo-600 to-violet-600 bg-clip-text text-transparent;
|
||||
}
|
||||
|
||||
.dark .gradient-text {
|
||||
@apply from-blue-400 via-indigo-400 to-violet-400;
|
||||
}
|
||||
|
||||
.gradient-text-accent {
|
||||
@apply bg-gradient-to-r from-teal-500 via-cyan-500 to-blue-500 bg-clip-text text-transparent;
|
||||
}
|
||||
|
||||
/* Gradient border - Selia style */
|
||||
.gradient-border {
|
||||
position: relative;
|
||||
background: linear-gradient(hsl(var(--card)), hsl(var(--card))) padding-box,
|
||||
linear-gradient(135deg, hsl(var(--primary)), hsl(var(--accent))) border-box;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
/* Legacy support */
|
||||
.gradient-bg {
|
||||
@apply gradient-primary;
|
||||
}
|
||||
|
||||
/* ===== GLASS MORPHISM - Selia Style ===== */
|
||||
|
||||
.glass {
|
||||
@apply backdrop-blur-xl;
|
||||
background: hsl(var(--background) / 0.8);
|
||||
border: 1px solid hsl(var(--border) / 0.5);
|
||||
}
|
||||
|
||||
.glass-strong {
|
||||
@apply backdrop-blur-2xl;
|
||||
background: hsl(var(--background) / 0.9);
|
||||
border: 1px solid hsl(var(--border) / 0.6);
|
||||
}
|
||||
|
||||
/* ===== CARD EFFECTS - Selia Style ===== */
|
||||
|
||||
.card-hover {
|
||||
@apply transition-all duration-200;
|
||||
}
|
||||
|
||||
.card-hover:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.card-hover-subtle {
|
||||
@apply transition-all duration-200;
|
||||
}
|
||||
|
||||
.card-hover-subtle:hover {
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
/* ===== RING SYSTEM - Selia Core ===== */
|
||||
|
||||
.ring-card-border {
|
||||
--tw-ring-color: hsl(var(--card-border));
|
||||
}
|
||||
|
||||
.ring-input-border {
|
||||
--tw-ring-color: hsl(var(--input-border));
|
||||
}
|
||||
|
||||
.ring-input-accent-border {
|
||||
--tw-ring-color: hsl(var(--input-accent-border));
|
||||
}
|
||||
|
||||
/* ===== SHADOW SYSTEM ===== */
|
||||
|
||||
.shadow-card {
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
.shadow-primary {
|
||||
box-shadow: var(--shadow-primary);
|
||||
}
|
||||
|
||||
.shadow-primary-lg {
|
||||
box-shadow: 0 10px 25px -3px hsl(var(--primary) / 0.2);
|
||||
}
|
||||
|
||||
/* ===== INTERACTIVE STATES - Selia Style ===== */
|
||||
|
||||
.interactive {
|
||||
@apply transition-colors duration-150 cursor-pointer;
|
||||
}
|
||||
|
||||
.interactive:hover {
|
||||
background: hsl(var(--primary) / 0.06);
|
||||
}
|
||||
|
||||
.interactive:active {
|
||||
background: hsl(var(--primary) / 0.1);
|
||||
}
|
||||
|
||||
.interactive-scale {
|
||||
@apply transition-transform duration-150 cursor-pointer;
|
||||
}
|
||||
|
||||
.interactive-scale:hover {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.interactive-scale:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
/* ===== SURFACE UTILITIES ===== */
|
||||
|
||||
.surface {
|
||||
background: hsl(var(--card));
|
||||
@apply ring ring-card-border;
|
||||
}
|
||||
|
||||
.surface-raised {
|
||||
background: hsl(var(--card));
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.surface-elevated {
|
||||
background: hsl(var(--card));
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
/* ===== SKELETON LOADING ===== */
|
||||
|
||||
.skeleton {
|
||||
@apply animate-pulse rounded;
|
||||
background: hsl(var(--muted));
|
||||
}
|
||||
|
||||
/* ===== STATUS INDICATORS ===== */
|
||||
|
||||
.status-dot {
|
||||
@apply h-2 w-2 rounded-full;
|
||||
}
|
||||
|
||||
.status-online {
|
||||
@apply status-dot;
|
||||
background: hsl(var(--success));
|
||||
}
|
||||
|
||||
.status-offline {
|
||||
@apply status-dot;
|
||||
background: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.status-busy {
|
||||
@apply status-dot;
|
||||
background: hsl(var(--warning));
|
||||
}
|
||||
|
||||
/* ===== INSET SHADOW - Selia Button Style ===== */
|
||||
|
||||
.inset-shadow-white {
|
||||
box-shadow: inset 0 1px 0 0 rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.inset-shadow-white\/15 {
|
||||
box-shadow: inset 0 1px 0 0 rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.gradient-text {
|
||||
@apply bg-gradient-to-r from-violet-600 via-purple-600 to-indigo-600 bg-clip-text text-transparent;
|
||||
}
|
||||
|
||||
.gradient-bg {
|
||||
@apply bg-gradient-to-br from-violet-600 via-purple-600 to-indigo-600;
|
||||
}
|
||||
|
||||
.glass {
|
||||
@apply backdrop-blur-xl bg-white/70 dark:bg-gray-900/70 border border-white/20;
|
||||
}
|
||||
|
||||
.card-hover {
|
||||
@apply transition-all duration-300 hover:shadow-xl hover:shadow-purple-500/10 hover:-translate-y-1;
|
||||
}
|
||||
/* ===== ANIMATIONS ===== */
|
||||
|
||||
.animate-float {
|
||||
animation: float 6s ease-in-out infinite;
|
||||
@@ -87,8 +465,107 @@
|
||||
background-size: 200% 200%;
|
||||
animation: gradient 8s ease infinite;
|
||||
}
|
||||
|
||||
.animate-shimmer {
|
||||
animation: shimmer 2s linear infinite;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent 0%,
|
||||
hsl(var(--primary) / 0.1) 50%,
|
||||
transparent 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fadeIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
.animate-slide-up {
|
||||
animation: slideUp 0.2s ease-out;
|
||||
}
|
||||
|
||||
.animate-slide-down {
|
||||
animation: slideDown 0.2s ease-out;
|
||||
}
|
||||
|
||||
.animate-scale-in {
|
||||
animation: scaleIn 0.15s ease-out;
|
||||
}
|
||||
|
||||
.animate-bounce-soft {
|
||||
animation: bounceSoft 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* ===== TEXT UTILITIES ===== */
|
||||
|
||||
.text-balance {
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
.text-pretty {
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
.text-dimmed {
|
||||
color: hsl(var(--dimmed));
|
||||
}
|
||||
|
||||
/* ===== SCROLLBAR - Selia Style ===== */
|
||||
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.custom-scrollbar {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: hsl(var(--border-secondary)) transparent;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: hsl(var(--border-secondary));
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: hsl(var(--muted-foreground) / 0.5);
|
||||
}
|
||||
|
||||
/* ===== THEME-AWARE HELPERS ===== */
|
||||
|
||||
.bg-surface {
|
||||
background: hsl(var(--background-secondary));
|
||||
}
|
||||
|
||||
.bg-surface-alt {
|
||||
background: hsl(var(--background-tertiary));
|
||||
}
|
||||
|
||||
.text-secondary {
|
||||
color: hsl(var(--foreground-secondary));
|
||||
}
|
||||
|
||||
.border-subtle {
|
||||
border-color: hsl(var(--border-secondary));
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== KEYFRAMES ===== */
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% { transform: translateY(0px); }
|
||||
50% { transform: translateY(-20px); }
|
||||
@@ -99,3 +576,64 @@
|
||||
50% { background-position: 100% 50%; }
|
||||
100% { background-position: 0% 50%; }
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { background-position: -200% 0; }
|
||||
100% { background-position: 200% 0; }
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes scaleIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.96);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes bounceSoft {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-4px); }
|
||||
}
|
||||
|
||||
/* ===== PRINT STYLES ===== */
|
||||
|
||||
@media print {
|
||||
.no-print {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
body {
|
||||
background: white !important;
|
||||
color: black !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,29 +18,78 @@ const config: Config = {
|
||||
extend: {
|
||||
colors: {
|
||||
border: 'hsl(var(--border))',
|
||||
'border-secondary': 'hsl(var(--border-secondary))',
|
||||
input: 'hsl(var(--input))',
|
||||
'input-border': 'hsl(var(--input-border))',
|
||||
'input-accent-border': 'hsl(var(--input-accent-border))',
|
||||
ring: 'hsl(var(--ring))',
|
||||
background: 'hsl(var(--background))',
|
||||
foreground: 'hsl(var(--foreground))',
|
||||
background: {
|
||||
DEFAULT: 'hsl(var(--background))',
|
||||
secondary: 'hsl(var(--background-secondary))',
|
||||
tertiary: 'hsl(var(--background-tertiary))',
|
||||
},
|
||||
foreground: {
|
||||
DEFAULT: 'hsl(var(--foreground))',
|
||||
secondary: 'hsl(var(--foreground-secondary))',
|
||||
},
|
||||
primary: {
|
||||
DEFAULT: 'hsl(var(--primary))',
|
||||
foreground: 'hsl(var(--primary-foreground))',
|
||||
50: 'hsl(var(--primary-50))',
|
||||
100: 'hsl(var(--primary-100))',
|
||||
200: 'hsl(var(--primary-200))',
|
||||
300: 'hsl(var(--primary-300))',
|
||||
400: 'hsl(var(--primary-400))',
|
||||
500: 'hsl(var(--primary-500))',
|
||||
600: 'hsl(var(--primary-600))',
|
||||
700: 'hsl(var(--primary-700))',
|
||||
800: 'hsl(var(--primary-800))',
|
||||
900: 'hsl(var(--primary-900))',
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: 'hsl(var(--secondary))',
|
||||
foreground: 'hsl(var(--secondary-foreground))',
|
||||
},
|
||||
tertiary: {
|
||||
DEFAULT: 'hsl(var(--tertiary))',
|
||||
foreground: 'hsl(var(--tertiary-foreground))',
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: 'hsl(var(--destructive))',
|
||||
foreground: 'hsl(var(--destructive-foreground))',
|
||||
50: 'hsl(var(--destructive-50))',
|
||||
100: 'hsl(var(--destructive-100))',
|
||||
500: 'hsl(var(--destructive-500))',
|
||||
},
|
||||
danger: {
|
||||
DEFAULT: 'hsl(var(--danger))',
|
||||
foreground: 'hsl(var(--danger-foreground))',
|
||||
},
|
||||
success: {
|
||||
DEFAULT: 'hsl(var(--success))',
|
||||
foreground: 'hsl(var(--success-foreground))',
|
||||
50: 'hsl(var(--success-50))',
|
||||
100: 'hsl(var(--success-100))',
|
||||
500: 'hsl(var(--success-500))',
|
||||
},
|
||||
warning: {
|
||||
DEFAULT: 'hsl(var(--warning))',
|
||||
foreground: 'hsl(var(--warning-foreground))',
|
||||
50: 'hsl(var(--warning-50))',
|
||||
100: 'hsl(var(--warning-100))',
|
||||
500: 'hsl(var(--warning-500))',
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: 'hsl(var(--muted))',
|
||||
foreground: 'hsl(var(--muted-foreground))',
|
||||
},
|
||||
dimmed: 'hsl(var(--dimmed))',
|
||||
accent: {
|
||||
DEFAULT: 'hsl(var(--accent))',
|
||||
foreground: 'hsl(var(--accent-foreground))',
|
||||
50: 'hsl(var(--accent-50))',
|
||||
100: 'hsl(var(--accent-100))',
|
||||
500: 'hsl(var(--accent-500))',
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: 'hsl(var(--popover))',
|
||||
@@ -49,12 +98,52 @@ const config: Config = {
|
||||
card: {
|
||||
DEFAULT: 'hsl(var(--card))',
|
||||
foreground: 'hsl(var(--card-foreground))',
|
||||
hover: 'hsl(var(--card-hover))',
|
||||
footer: 'hsl(var(--card-footer))',
|
||||
separator: 'hsl(var(--card-separator))',
|
||||
border: 'hsl(var(--card-border))',
|
||||
},
|
||||
},
|
||||
borderRadius: {
|
||||
lg: 'var(--radius)',
|
||||
md: 'calc(var(--radius) - 2px)',
|
||||
sm: 'calc(var(--radius) - 4px)',
|
||||
xl: 'var(--radius-xl)',
|
||||
full: 'var(--radius-full)',
|
||||
},
|
||||
fontSize: {
|
||||
// Display sizes
|
||||
'display-lg': ['3.5rem', { lineHeight: '1.1', letterSpacing: '-0.02em', fontWeight: '700' }],
|
||||
'display-md': ['2.875rem', { lineHeight: '1.15', letterSpacing: '-0.01em', fontWeight: '700' }],
|
||||
'display-sm': ['2.25rem', { lineHeight: '1.2', letterSpacing: '-0.005em', fontWeight: '700' }],
|
||||
// Heading sizes
|
||||
'heading-lg': ['2rem', { lineHeight: '1.3', letterSpacing: '-0.005em', fontWeight: '600' }],
|
||||
'heading-md': ['1.5rem', { lineHeight: '1.35', fontWeight: '600' }],
|
||||
'heading-sm': ['1.25rem', { lineHeight: '1.4', fontWeight: '600' }],
|
||||
// Body sizes
|
||||
'body-lg': ['1.125rem', { lineHeight: '1.6', fontWeight: '400' }],
|
||||
'body-md': ['1rem', { lineHeight: '1.6', fontWeight: '400' }],
|
||||
'body-sm': ['0.9375rem', { lineHeight: '1.5', fontWeight: '400' }],
|
||||
// Caption sizes
|
||||
'caption-lg': ['0.875rem', { lineHeight: '1.5', fontWeight: '500' }],
|
||||
'caption-md': ['0.8125rem', { lineHeight: '1.4', fontWeight: '500' }],
|
||||
'caption-sm': ['0.75rem', { lineHeight: '1.3', fontWeight: '500' }],
|
||||
},
|
||||
spacing: {
|
||||
'18': '4.5rem',
|
||||
'22': '5.5rem',
|
||||
'26': '6.5rem',
|
||||
'30': '7.5rem',
|
||||
},
|
||||
boxShadow: {
|
||||
'xs': 'var(--shadow-xs)',
|
||||
'sm': 'var(--shadow-sm)',
|
||||
'md': 'var(--shadow-md)',
|
||||
'lg': 'var(--shadow-lg)',
|
||||
'xl': 'var(--shadow-xl)',
|
||||
'card': 'var(--shadow-card)',
|
||||
'primary': 'var(--shadow-primary)',
|
||||
'primary-lg': '0 10px 25px -3px hsl(var(--primary) / 0.2)',
|
||||
},
|
||||
keyframes: {
|
||||
'accordion-down': {
|
||||
@@ -65,10 +154,61 @@ const config: Config = {
|
||||
from: { height: 'var(--radix-accordion-content-height)' },
|
||||
to: { height: '0' },
|
||||
},
|
||||
'fade-in': {
|
||||
from: { opacity: '0' },
|
||||
to: { opacity: '1' },
|
||||
},
|
||||
'fade-out': {
|
||||
from: { opacity: '1' },
|
||||
to: { opacity: '0' },
|
||||
},
|
||||
'slide-in-from-top': {
|
||||
from: { transform: 'translateY(-8px)', opacity: '0' },
|
||||
to: { transform: 'translateY(0)', opacity: '1' },
|
||||
},
|
||||
'slide-in-from-bottom': {
|
||||
from: { transform: 'translateY(8px)', opacity: '0' },
|
||||
to: { transform: 'translateY(0)', opacity: '1' },
|
||||
},
|
||||
'scale-in': {
|
||||
from: { transform: 'scale(0.96)', opacity: '0' },
|
||||
to: { transform: 'scale(1)', opacity: '1' },
|
||||
},
|
||||
'spin-slow': {
|
||||
from: { transform: 'rotate(0deg)' },
|
||||
to: { transform: 'rotate(360deg)' },
|
||||
},
|
||||
'pulse-soft': {
|
||||
'0%, 100%': { opacity: '1' },
|
||||
'50%': { opacity: '0.7' },
|
||||
},
|
||||
'bounce-soft': {
|
||||
'0%, 100%': { transform: 'translateY(0)' },
|
||||
'50%': { transform: 'translateY(-4px)' },
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
'accordion-down': 'accordion-down 0.2s ease-out',
|
||||
'accordion-up': 'accordion-up 0.2s ease-out',
|
||||
'fade-in': 'fade-in 0.2s ease-out',
|
||||
'fade-out': 'fade-out 0.2s ease-out',
|
||||
'slide-in-from-top': 'slide-in-from-top 0.2s ease-out',
|
||||
'slide-in-from-bottom': 'slide-in-from-bottom 0.2s ease-out',
|
||||
'scale-in': 'scale-in 0.15s ease-out',
|
||||
'spin-slow': 'spin-slow 3s linear infinite',
|
||||
'pulse-soft': 'pulse-soft 2s ease-in-out infinite',
|
||||
'bounce-soft': 'bounce-soft 2s ease-in-out infinite',
|
||||
},
|
||||
transitionDuration: {
|
||||
'400': '400ms',
|
||||
},
|
||||
transitionTimingFunction: {
|
||||
'bounce-in': 'cubic-bezier(0.68, -0.55, 0.265, 1.55)',
|
||||
'smooth': 'cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
},
|
||||
backgroundImage: {
|
||||
'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))',
|
||||
'gradient-conic': 'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user