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:
Semih
2026-01-22 11:15:06 +01:00
parent b9532a61ee
commit 2358b38b4d
155 changed files with 31575 additions and 1109 deletions

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View 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';

View File

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

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

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

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

View File

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

View 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';

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

View 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 {}

File diff suppressed because it is too large Load Diff

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View 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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -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&apos;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&apos;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&apos;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">&ldquo;{testimonial.quote}&rdquo;</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&apos;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&apos;de tasarlandi</span>
<span className="h-1 w-1 rounded-full bg-muted-foreground" />
<span>Istanbul</span>
</div>
</div>
</div>
</footer>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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