Initial commit: Sase.tr VIN Sorgulama Platformu

Features:
- Next.js 16.1.3 frontend with Turbopack
- NestJS API with Prisma ORM
- EMEX VIN scraper integration
- Turkish translations for automotive parts
- JWT authentication with refresh tokens
- PM2 production deployment

Tech Stack:
- Frontend: Next.js 16.1, React 19, TailwindCSS
- Backend: NestJS, Prisma, MySQL
- Scraping: Puppeteer

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
root
2026-01-17 03:04:30 +01:00
commit b9532a61ee
195 changed files with 31777 additions and 0 deletions

View File

@@ -0,0 +1,41 @@
{
"name": "@sase/shared",
"version": "1.0.0",
"private": true,
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"./types": {
"types": "./dist/types/index.d.ts",
"default": "./dist/types/index.js"
},
"./constants": {
"types": "./dist/constants/index.d.ts",
"default": "./dist/constants/index.js"
},
"./utils": {
"types": "./dist/utils/index.d.ts",
"default": "./dist/utils/index.js"
},
"./schemas": {
"types": "./dist/schemas/index.d.ts",
"default": "./dist/schemas/index.js"
}
},
"scripts": {
"build": "tsc",
"lint": "eslint src/",
"clean": "rm -rf dist"
},
"dependencies": {
"zod": "^3.24.1"
},
"devDependencies": {
"@types/node": "^22.10.0",
"typescript": "^5.7.2"
}
}

View File

@@ -0,0 +1,76 @@
export const ERROR_CODES = {
// Auth errors
AUTH_INVALID_CREDENTIALS: 'AUTH_INVALID_CREDENTIALS',
AUTH_EMAIL_EXISTS: 'AUTH_EMAIL_EXISTS',
AUTH_USER_NOT_FOUND: 'AUTH_USER_NOT_FOUND',
AUTH_TOKEN_EXPIRED: 'AUTH_TOKEN_EXPIRED',
AUTH_TOKEN_INVALID: 'AUTH_TOKEN_INVALID',
AUTH_UNAUTHORIZED: 'AUTH_UNAUTHORIZED',
// Subscription errors
SUBSCRIPTION_NOT_FOUND: 'SUBSCRIPTION_NOT_FOUND',
SUBSCRIPTION_EXPIRED: 'SUBSCRIPTION_EXPIRED',
SUBSCRIPTION_INACTIVE: 'SUBSCRIPTION_INACTIVE',
SUBSCRIPTION_BRAND_LIMIT_EXCEEDED: 'SUBSCRIPTION_BRAND_LIMIT_EXCEEDED',
// Brand errors
BRAND_ACCESS_DENIED: 'BRAND_ACCESS_DENIED',
BRAND_NOT_FOUND: 'BRAND_NOT_FOUND',
BRAND_SELECTION_REQUIRED: 'BRAND_SELECTION_REQUIRED',
// Vehicle errors
VEHICLE_NOT_FOUND: 'VEHICLE_NOT_FOUND',
VIN_INVALID: 'VIN_INVALID',
VIN_DECODE_FAILED: 'VIN_DECODE_FAILED',
// Part errors
PART_NOT_FOUND: 'PART_NOT_FOUND',
CATEGORY_NOT_FOUND: 'CATEGORY_NOT_FOUND',
// Payment errors
PAYMENT_FAILED: 'PAYMENT_FAILED',
PAYMENT_CANCELLED: 'PAYMENT_CANCELLED',
PAYMENT_INVALID_CARD: 'PAYMENT_INVALID_CARD',
// General errors
VALIDATION_ERROR: 'VALIDATION_ERROR',
NOT_FOUND: 'NOT_FOUND',
INTERNAL_ERROR: 'INTERNAL_ERROR',
RATE_LIMIT_EXCEEDED: 'RATE_LIMIT_EXCEEDED',
} as const;
export type ErrorCode = (typeof ERROR_CODES)[keyof typeof ERROR_CODES];
export const ERROR_MESSAGES: Record<ErrorCode, string> = {
[ERROR_CODES.AUTH_INVALID_CREDENTIALS]: 'Email veya sifre hatali',
[ERROR_CODES.AUTH_EMAIL_EXISTS]: 'Bu email adresi zaten kayitli',
[ERROR_CODES.AUTH_USER_NOT_FOUND]: 'Kullanici bulunamadi',
[ERROR_CODES.AUTH_TOKEN_EXPIRED]: 'Oturum suresi dolmus',
[ERROR_CODES.AUTH_TOKEN_INVALID]: 'Gecersiz oturum',
[ERROR_CODES.AUTH_UNAUTHORIZED]: 'Bu islemi yapmaya yetkiniz yok',
[ERROR_CODES.SUBSCRIPTION_NOT_FOUND]: 'Abonelik bulunamadi',
[ERROR_CODES.SUBSCRIPTION_EXPIRED]: 'Abonelik suresi dolmus',
[ERROR_CODES.SUBSCRIPTION_INACTIVE]: 'Aktif aboneliginiz bulunmuyor',
[ERROR_CODES.SUBSCRIPTION_BRAND_LIMIT_EXCEEDED]: 'Marka limiti asildi',
[ERROR_CODES.BRAND_ACCESS_DENIED]: 'Bu markaya erisiniz bulunmuyor',
[ERROR_CODES.BRAND_NOT_FOUND]: 'Marka bulunamadi',
[ERROR_CODES.BRAND_SELECTION_REQUIRED]: 'Lutfen marka seciniz',
[ERROR_CODES.VEHICLE_NOT_FOUND]: 'Arac bulunamadi',
[ERROR_CODES.VIN_INVALID]: 'Gecersiz VIN numarasi',
[ERROR_CODES.VIN_DECODE_FAILED]: 'VIN cozumlenemedi',
[ERROR_CODES.PART_NOT_FOUND]: 'Parca bulunamadi',
[ERROR_CODES.CATEGORY_NOT_FOUND]: 'Kategori bulunamadi',
[ERROR_CODES.PAYMENT_FAILED]: 'Odeme basarisiz',
[ERROR_CODES.PAYMENT_CANCELLED]: 'Odeme iptal edildi',
[ERROR_CODES.PAYMENT_INVALID_CARD]: 'Gecersiz kart bilgileri',
[ERROR_CODES.VALIDATION_ERROR]: 'Dogrulama hatasi',
[ERROR_CODES.NOT_FOUND]: 'Kayit bulunamadi',
[ERROR_CODES.INTERNAL_ERROR]: 'Sunucu hatasi',
[ERROR_CODES.RATE_LIMIT_EXCEEDED]: 'Cok fazla istek gonderdiniz',
};

View File

@@ -0,0 +1,51 @@
export * from './plans';
export * from './error-codes';
export * from './regex';
// API Configuration
export const API_VERSION = 'v1';
export const DEFAULT_PAGE_SIZE = 20;
export const MAX_PAGE_SIZE = 100;
// VIN Configuration
export const VIN_LENGTH = 17;
// JWT Configuration
export const JWT_ACCESS_TOKEN_EXPIRY = '15m';
export const JWT_REFRESH_TOKEN_EXPIRY = '7d';
// Rate Limiting
export const RATE_LIMIT_TTL = 60; // seconds
export const RATE_LIMIT_MAX = 100; // requests per TTL
// Cache TTL (seconds)
export const CACHE_TTL = {
BRANDS: 3600, // 1 hour
PLANS: 3600, // 1 hour
CATEGORIES: 3600, // 1 hour
USER_SESSION: 900, // 15 minutes
} as const;
// Supported brands (seed data)
export const SUPPORTED_BRANDS = [
{ code: 'FIAT', name: 'Fiat' },
{ code: 'RENAULT', name: 'Renault' },
{ code: 'VOLKSWAGEN', name: 'Volkswagen' },
{ code: 'BMW', name: 'BMW' },
{ code: 'MERCEDES', name: 'Mercedes-Benz' },
{ code: 'AUDI', name: 'Audi' },
{ code: 'TOYOTA', name: 'Toyota' },
{ code: 'HONDA', name: 'Honda' },
{ code: 'FORD', name: 'Ford' },
{ code: 'OPEL', name: 'Opel' },
{ code: 'HYUNDAI', name: 'Hyundai' },
{ code: 'KIA', name: 'Kia' },
{ code: 'PEUGEOT', name: 'Peugeot' },
{ code: 'CITROEN', name: 'Citroen' },
{ code: 'SKODA', name: 'Skoda' },
{ code: 'SEAT', name: 'SEAT' },
{ code: 'NISSAN', name: 'Nissan' },
{ code: 'MAZDA', name: 'Mazda' },
{ code: 'VOLVO', name: 'Volvo' },
{ code: 'DACIA', name: 'Dacia' },
] as const;

View File

@@ -0,0 +1,43 @@
export const PLAN_SLUGS = {
STARTER: 'starter',
PRO: 'pro',
BUSINESS: 'business',
FULL: 'full',
} as const;
export type PlanSlug = (typeof PLAN_SLUGS)[keyof typeof PLAN_SLUGS];
export const PLAN_CONFIGS = {
[PLAN_SLUGS.STARTER]: {
name: 'Baslangic',
price: 299,
brandLimit: 1,
hasFullAccess: false,
features: ['1 marka erisimi', 'Sinirsiz sorgu', 'Email destek'],
},
[PLAN_SLUGS.PRO]: {
name: 'Pro',
price: 599,
brandLimit: 3,
hasFullAccess: false,
features: ['3 marka erisimi', 'Sinirsiz sorgu', 'Oncelikli destek'],
isPopular: true,
},
[PLAN_SLUGS.BUSINESS]: {
name: 'Isletme',
price: 999,
brandLimit: 10,
hasFullAccess: false,
features: ['10 marka erisimi', 'Sinirsiz sorgu', 'Telefon destek', 'API erisimi'],
},
[PLAN_SLUGS.FULL]: {
name: 'Full',
price: 1999,
brandLimit: 0,
hasFullAccess: true,
features: ['Tum markalar', 'Sinirsiz sorgu', '7/24 destek', 'API erisimi', 'Ozel entegrasyon'],
},
} as const;
export const DEFAULT_PLAN_DURATION_DAYS = 30;
export const DEFAULT_CURRENCY = 'TRY';

View File

@@ -0,0 +1,20 @@
// VIN: 17 characters, excluding I, O, Q
export const VIN_REGEX = /^[A-HJ-NPR-Z0-9]{17}$/i;
// Email validation
export const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
// Turkish phone number
export const PHONE_REGEX = /^(\+90|0)?[0-9]{10}$/;
// Turkish identity number (TC Kimlik No)
export const TC_KIMLIK_REGEX = /^[1-9][0-9]{10}$/;
// OEM Part code (alphanumeric, hyphens allowed)
export const OEM_CODE_REGEX = /^[A-Z0-9][A-Z0-9\-]{2,30}$/i;
// Slug format
export const SLUG_REGEX = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
// Password: min 8 chars, at least 1 letter and 1 number
export const PASSWORD_REGEX = /^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d@$!%*#?&]{8,}$/;

View File

@@ -0,0 +1,11 @@
// Types
export * from './types';
// Constants
export * from './constants';
// Utils
export * from './utils';
// Schemas
export * from './schemas';

View File

@@ -0,0 +1,30 @@
export * from './vin.schema';
export * from './user.schema';
export * from './subscription.schema';
export * from './payment.schema';
import { z } from 'zod';
// Pagination schema
export const paginationSchema = z.object({
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().positive().max(100).default(20),
sortBy: z.string().optional(),
sortOrder: z.enum(['asc', 'desc']).default('desc'),
});
export type PaginationInput = z.infer<typeof paginationSchema>;
// ID param schema
export const idParamSchema = z.object({
id: z.string().cuid('Gecersiz ID'),
});
export type IdParamInput = z.infer<typeof idParamSchema>;
// Search schema
export const searchSchema = z.object({
q: z.string().min(1).optional(),
});
export type SearchInput = z.infer<typeof searchSchema>;

View File

@@ -0,0 +1,41 @@
import { z } from 'zod';
import { TC_KIMLIK_REGEX, PHONE_REGEX } from '../constants';
export const cardSchema = z.object({
cardHolderName: z.string().min(3, 'Kart sahibi adi gerekli'),
cardNumber: z
.string()
.regex(/^\d{16}$/, 'Kart numarasi 16 haneli olmalidir')
.transform((val) => val.replace(/\s/g, '')),
expireMonth: z.string().regex(/^(0[1-9]|1[0-2])$/, 'Gecersiz ay'),
expireYear: z.string().regex(/^\d{2}$/, 'Gecersiz yil'),
cvc: z.string().regex(/^\d{3,4}$/, 'Gecersiz CVC'),
});
export const buyerSchema = z.object({
name: z.string().min(2, 'Ad gerekli'),
surname: z.string().min(2, 'Soyad gerekli'),
phone: z.string().regex(PHONE_REGEX, 'Gecersiz telefon numarasi'),
identityNumber: z.string().regex(TC_KIMLIK_REGEX, 'Gecersiz TC Kimlik numarasi'),
email: z.string().email('Gecersiz email'),
address: z.string().min(10, 'Adres en az 10 karakter olmalidir'),
city: z.string().min(2, 'Sehir gerekli'),
country: z.string().default('Turkey'),
});
export const initializePaymentSchema = z.object({
planId: z.string().cuid('Gecersiz plan'),
brandIds: z.array(z.string().cuid()).min(1, 'En az bir marka secmelisiniz'),
card: cardSchema,
buyer: buyerSchema,
});
export const paymentCallbackSchema = z.object({
token: z.string().min(1),
conversationId: z.string().min(1),
});
export type CardInput = z.infer<typeof cardSchema>;
export type BuyerInput = z.infer<typeof buyerSchema>;
export type InitializePaymentInput = z.infer<typeof initializePaymentSchema>;
export type PaymentCallbackInput = z.infer<typeof paymentCallbackSchema>;

View File

@@ -0,0 +1,19 @@
import { z } from 'zod';
export const selectBrandsSchema = z.object({
brandIds: z.array(z.string().cuid()).min(1, 'En az bir marka secmelisiniz'),
});
export const createSubscriptionSchema = z.object({
planId: z.string().cuid('Gecersiz plan'),
brandIds: z.array(z.string().cuid()).min(1, 'En az bir marka secmelisiniz'),
});
export const updateSubscriptionSchema = z.object({
planId: z.string().cuid('Gecersiz plan').optional(),
brandIds: z.array(z.string().cuid()).optional(),
});
export type SelectBrandsInput = z.infer<typeof selectBrandsSchema>;
export type CreateSubscriptionInput = z.infer<typeof createSubscriptionSchema>;
export type UpdateSubscriptionInput = z.infer<typeof updateSubscriptionSchema>;

View File

@@ -0,0 +1,40 @@
import { z } from 'zod';
import { EMAIL_REGEX, PASSWORD_REGEX } from '../constants';
export const emailSchema = z.string().regex(EMAIL_REGEX, 'Gecersiz email adresi');
export const passwordSchema = z
.string()
.min(8, 'Sifre en az 8 karakter olmalidir')
.regex(PASSWORD_REGEX, 'Sifre en az bir harf ve bir rakam icermelidir');
export const loginSchema = z.object({
email: emailSchema,
password: z.string().min(1, 'Sifre gerekli'),
});
export const registerSchema = z.object({
email: emailSchema,
password: passwordSchema,
name: z.string().min(2, 'Ad en az 2 karakter olmalidir').optional(),
});
export const forgotPasswordSchema = z.object({
email: emailSchema,
});
export const resetPasswordSchema = z.object({
token: z.string().min(1, 'Token gerekli'),
password: passwordSchema,
});
export const updateProfileSchema = z.object({
name: z.string().min(2).max(100).optional(),
avatar: z.string().url().optional(),
});
export type LoginInput = z.infer<typeof loginSchema>;
export type RegisterInput = z.infer<typeof registerSchema>;
export type ForgotPasswordInput = z.infer<typeof forgotPasswordSchema>;
export type ResetPasswordInput = z.infer<typeof resetPasswordSchema>;
export type UpdateProfileInput = z.infer<typeof updateProfileSchema>;

View File

@@ -0,0 +1,14 @@
import { z } from 'zod';
import { VIN_REGEX } from '../constants';
export const vinSchema = z
.string()
.length(17, 'VIN 17 karakter olmalidir')
.regex(VIN_REGEX, 'Gecersiz VIN formati (I, O, Q kullanilamaz)')
.transform((val) => val.toUpperCase());
export const decodeVinSchema = z.object({
vin: vinSchema,
});
export type DecodeVinInput = z.infer<typeof decodeVinSchema>;

View File

@@ -0,0 +1,26 @@
export interface Brand {
id: string;
code: string;
name: string;
logo: string | null;
isActive: boolean;
sortOrder: number;
createdAt: Date;
updatedAt: Date;
}
export interface BrandListResponse {
items: Brand[];
total: number;
}
export interface SelectBrandsRequest {
brandIds: string[];
}
export interface UserBrandResponse {
id: string;
brandId: string;
brand: Brand;
createdAt: Date;
}

View File

@@ -0,0 +1,51 @@
export interface Category {
id: string;
code: string;
nameEn: string;
nameTr: string;
slug: string;
description: string | null;
parentId: string | null;
iconName: string | null;
schemaImageUrl: string | null;
sortOrder: number;
isActive: boolean;
createdAt: Date;
updatedAt: Date;
}
export interface CategoryWithChildren extends Category {
children: CategoryWithChildren[];
}
export interface CategoryTreeResponse {
items: CategoryWithChildren[];
}
export interface CategoryWithParts extends Category {
parts: Part[];
}
export interface Part {
id: string;
vehicleId: string;
categoryId: string;
oemCode: string;
oemCodes: string[] | null;
nameEn: string;
nameTr: string;
description: string | null;
positionCode: string | null;
positionX: number | null;
positionY: number | null;
brandPrices: BrandPrice[];
imageUrl: string | null;
notes: string | null;
}
export interface BrandPrice {
brand: string;
price: number;
currency: string;
inStock: boolean;
}

View File

@@ -0,0 +1,70 @@
// Brand types (source of truth)
export * from './brand.types';
// Subscription types (source of truth for Plan, SubscriptionStatus)
export * from './subscription.types';
// Category types (source of truth for Category, Part, BrandPrice)
export * from './category.types';
// User types (re-exports Brand, Plan, SubscriptionStatus for convenience)
export {
AuthProvider,
User,
UserWithSubscription,
UserSubscription,
UserBrand,
LoginRequest,
RegisterRequest,
AuthResponse,
JwtPayload,
} from './user.types';
// Vehicle types (re-exports Category, Part, BrandPrice for convenience)
export {
Vehicle,
VehicleWithDetails,
VehicleCategory,
DecodeVinRequest,
DecodeVinResponse,
VehicleListResponse,
} from './vehicle.types';
// Payment types
export * from './payment.types';
// Part types
export * from './part.types';
// API Response wrapper
export interface ApiResponse<T> {
success: boolean;
data: T;
message?: string;
timestamp: string;
}
export interface ApiErrorResponse {
success: false;
error: {
code: string;
message: string;
details?: Record<string, string[]>;
};
timestamp: string;
}
export interface PaginationQuery {
page?: number;
limit?: number;
sortBy?: string;
sortOrder?: 'asc' | 'desc';
}
export interface PaginatedResponse<T> {
items: T[];
total: number;
page: number;
limit: number;
totalPages: number;
}

View File

@@ -0,0 +1,31 @@
import { Part } from './category.types';
export interface PartListResponse {
items: Part[];
total: number;
page: number;
limit: number;
totalPages: number;
}
export interface PartSearchRequest {
oem?: string;
brand?: string;
vehicleId?: string;
categoryId?: string;
}
export interface PartDetailResponse extends Part {
vehicle: {
id: string;
vin: string;
brand: string;
model: string;
year: number;
};
category: {
id: string;
nameTr: string;
slug: string;
};
}

View File

@@ -0,0 +1,66 @@
export enum PaymentStatus {
PENDING = 'PENDING',
PROCESSING = 'PROCESSING',
COMPLETED = 'COMPLETED',
FAILED = 'FAILED',
REFUNDED = 'REFUNDED',
CANCELLED = 'CANCELLED',
}
export interface Payment {
id: string;
subscriptionId: string;
amount: number;
currency: string;
status: PaymentStatus;
provider: string;
providerTxId: string | null;
providerData: Record<string, unknown> | null;
invoiceNumber: string | null;
invoiceUrl: string | null;
failureReason: string | null;
createdAt: Date;
updatedAt: Date;
}
export interface InitializePaymentRequest {
planId: string;
brandIds: string[];
card: {
cardHolderName: string;
cardNumber: string;
expireMonth: string;
expireYear: string;
cvc: string;
};
buyer: {
name: string;
surname: string;
phone: string;
identityNumber: string;
email: string;
address: string;
city: string;
country: string;
};
}
export interface InitializePaymentResponse {
status: string;
htmlContent?: string;
paymentId?: string;
conversationId?: string;
errorMessage?: string;
}
export interface PaymentCallbackRequest {
token: string;
conversationId: string;
}
export interface PaymentHistoryResponse {
items: Payment[];
total: number;
page: number;
limit: number;
}

View File

@@ -0,0 +1,51 @@
export interface Plan {
id: string;
name: string;
slug: string;
description: string | null;
price: number;
currency: string;
brandLimit: number;
hasFullAccess: boolean;
features: string[];
durationDays: number;
sortOrder: number;
isActive: boolean;
isPopular: boolean;
}
export interface PlanListResponse {
items: Plan[];
}
export enum SubscriptionStatus {
ACTIVE = 'ACTIVE',
PAST_DUE = 'PAST_DUE',
CANCELLED = 'CANCELLED',
EXPIRED = 'EXPIRED',
SUSPENDED = 'SUSPENDED',
}
export interface Subscription {
id: string;
userId: string;
planId: string;
status: SubscriptionStatus;
currentPeriodStart: Date;
currentPeriodEnd: Date;
cancelAtPeriodEnd: boolean;
cancelledAt: Date | null;
plan: Plan;
createdAt: Date;
updatedAt: Date;
}
export interface CreateSubscriptionRequest {
planId: string;
brandIds: string[];
}
export interface SubscriptionResponse {
subscription: Subscription;
selectedBrands: { id: string; code: string; name: string }[];
}

View File

@@ -0,0 +1,72 @@
import { Brand } from './brand.types';
import { Plan, SubscriptionStatus } from './subscription.types';
export { Brand, Plan, SubscriptionStatus };
export enum AuthProvider {
EMAIL = 'EMAIL',
GOOGLE = 'GOOGLE',
APPLE = 'APPLE',
FACEBOOK = 'FACEBOOK',
}
export interface User {
id: string;
email: string;
name: string | null;
avatar: string | null;
provider: AuthProvider;
providerId: string | null;
isActive: boolean;
createdAt: Date;
updatedAt: Date;
}
export interface UserWithSubscription extends User {
subscription: UserSubscription | null;
selectedBrands: UserBrand[];
}
export interface UserSubscription {
id: string;
userId: string;
planId: string;
status: SubscriptionStatus;
currentPeriodStart: Date;
currentPeriodEnd: Date;
cancelAtPeriodEnd: boolean;
cancelledAt: Date | null;
plan: Plan;
}
export interface UserBrand {
id: string;
userId: string;
brandId: string;
brand: Brand;
createdAt: Date;
}
export interface LoginRequest {
email: string;
password: string;
}
export interface RegisterRequest {
email: string;
password: string;
name?: string;
}
export interface AuthResponse {
user: User;
accessToken: string;
refreshToken: string;
}
export interface JwtPayload {
sub: string;
email: string;
iat?: number;
exp?: number;
}

View File

@@ -0,0 +1,56 @@
import { Brand } from './brand.types';
import { Category, Part, BrandPrice } from './category.types';
export { Category, Part, BrandPrice };
export interface Vehicle {
id: string;
vin: string;
brandId: string;
brand: Brand;
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;
rawResponse: Record<string, unknown>;
queriedById: string | null;
createdAt: Date;
updatedAt: Date;
}
export interface VehicleWithDetails extends Vehicle {
categories: VehicleCategory[];
parts: Part[];
}
export interface VehicleCategory {
id: string;
vehicleId: string;
categoryId: string;
partCount: number;
category: Category;
createdAt: Date;
}
export interface DecodeVinRequest {
vin: string;
}
export interface DecodeVinResponse {
vehicle: VehicleWithDetails;
fromCache: boolean;
}
export interface VehicleListResponse {
items: Vehicle[];
total: number;
page: number;
limit: number;
totalPages: number;
}

View File

@@ -0,0 +1,50 @@
export const CURRENCIES = {
TRY: { symbol: '₺', name: 'Turk Lirasi', locale: 'tr-TR' },
USD: { symbol: '$', name: 'ABD Dolari', locale: 'en-US' },
EUR: { symbol: '€', name: 'Euro', locale: 'de-DE' },
} as const;
export type CurrencyCode = keyof typeof CURRENCIES;
export function getCurrencySymbol(code: CurrencyCode): string {
return CURRENCIES[code]?.symbol || code;
}
export function formatPrice(amount: number, currency: CurrencyCode = 'TRY'): string {
const config = CURRENCIES[currency];
return new Intl.NumberFormat(config?.locale || 'tr-TR', {
style: 'currency',
currency,
minimumFractionDigits: 0,
maximumFractionDigits: 2,
}).format(amount);
}
export function parsePriceString(priceString: string): number {
// Remove currency symbols and whitespace
const cleaned = priceString.replace(/[^\d.,]/g, '');
// Handle Turkish format (1.234,56) vs US format (1,234.56)
if (cleaned.includes(',') && cleaned.includes('.')) {
// Turkish format: dots for thousands, comma for decimal
if (cleaned.lastIndexOf(',') > cleaned.lastIndexOf('.')) {
return parseFloat(cleaned.replace(/\./g, '').replace(',', '.'));
}
// US format
return parseFloat(cleaned.replace(/,/g, ''));
}
// Only comma: could be Turkish decimal or US thousands
if (cleaned.includes(',')) {
const parts = cleaned.split(',');
if (parts[parts.length - 1].length === 2) {
// Likely decimal
return parseFloat(cleaned.replace(',', '.'));
}
// Likely thousands separator
return parseFloat(cleaned.replace(/,/g, ''));
}
return parseFloat(cleaned);
}

View File

@@ -0,0 +1,78 @@
import { DEFAULT_CURRENCY } from '../constants';
export function formatCurrency(amount: number, currency: string = DEFAULT_CURRENCY): string {
const formatter = new Intl.NumberFormat('tr-TR', {
style: 'currency',
currency,
minimumFractionDigits: 0,
maximumFractionDigits: 2,
});
return formatter.format(amount);
}
export function formatDate(date: Date | string, options?: Intl.DateTimeFormatOptions): string {
const d = typeof date === 'string' ? new Date(date) : date;
return d.toLocaleDateString('tr-TR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
...options,
});
}
export function formatDateTime(date: Date | string): string {
const d = typeof date === 'string' ? new Date(date) : date;
return d.toLocaleString('tr-TR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
export function formatVin(vin: string): string {
// Format: XXX XXXXXX XXXXXXXX (WMI VDS VIS)
const normalized = vin.toUpperCase().replace(/[^A-HJ-NPR-Z0-9]/gi, '');
if (normalized.length !== 17) return vin;
return `${normalized.slice(0, 3)} ${normalized.slice(3, 9)} ${normalized.slice(9)}`;
}
export function slugify(text: string): string {
return text
.toString()
.toLowerCase()
.trim()
.replace(/\s+/g, '-')
.replace(/[^\w\-]+/g, '')
.replace(/\-\-+/g, '-')
.replace(/^-+/, '')
.replace(/-+$/, '');
}
export function truncate(text: string, length: number, suffix: string = '...'): string {
if (text.length <= length) return text;
return text.slice(0, length - suffix.length) + suffix;
}
export function capitalizeFirst(text: string): string {
return text.charAt(0).toUpperCase() + text.slice(1).toLowerCase();
}
export function formatPhoneNumber(phone: string): string {
const cleaned = phone.replace(/\D/g, '');
if (cleaned.length === 10) {
return `(${cleaned.slice(0, 3)}) ${cleaned.slice(3, 6)} ${cleaned.slice(6, 8)} ${cleaned.slice(8)}`;
}
if (cleaned.length === 11 && cleaned.startsWith('0')) {
return `(${cleaned.slice(1, 4)}) ${cleaned.slice(4, 7)} ${cleaned.slice(7, 9)} ${cleaned.slice(9)}`;
}
return phone;
}

View File

@@ -0,0 +1,60 @@
export * from './vin-validator';
export * from './formatters';
export * from './currency';
// General utilities
export function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export function isEmptyObject(obj: object): boolean {
return Object.keys(obj).length === 0;
}
export function omit<T extends object, K extends keyof T>(obj: T, keys: K[]): Omit<T, K> {
const result = { ...obj };
keys.forEach((key) => delete result[key]);
return result;
}
export function pick<T extends object, K extends keyof T>(obj: T, keys: K[]): Pick<T, K> {
const result = {} as Pick<T, K>;
keys.forEach((key) => {
if (key in obj) {
result[key] = obj[key];
}
});
return result;
}
export function groupBy<T>(array: T[], key: keyof T): Record<string, T[]> {
return array.reduce(
(result, item) => {
const groupKey = String(item[key]);
if (!result[groupKey]) {
result[groupKey] = [];
}
result[groupKey].push(item);
return result;
},
{} as Record<string, T[]>,
);
}
export function uniqueBy<T>(array: T[], key: keyof T): T[] {
const seen = new Set<unknown>();
return array.filter((item) => {
const value = item[key];
if (seen.has(value)) {
return false;
}
seen.add(value);
return true;
});
}
export function generateId(prefix: string = ''): string {
const timestamp = Date.now().toString(36);
const random = Math.random().toString(36).substring(2, 9);
return prefix ? `${prefix}_${timestamp}${random}` : `${timestamp}${random}`;
}

View File

@@ -0,0 +1,175 @@
import { VIN_REGEX, VIN_LENGTH } from '../constants';
const TRANSLITERATION: Record<string, number> = {
A: 1,
B: 2,
C: 3,
D: 4,
E: 5,
F: 6,
G: 7,
H: 8,
J: 1,
K: 2,
L: 3,
M: 4,
N: 5,
P: 7,
R: 9,
S: 2,
T: 3,
U: 4,
V: 5,
W: 6,
X: 7,
Y: 8,
Z: 9,
};
const WEIGHTS = [8, 7, 6, 5, 4, 3, 2, 10, 0, 9, 8, 7, 6, 5, 4, 3, 2];
export function validateVin(vin: string): { valid: boolean; error?: string } {
if (!vin) {
return { valid: false, error: 'VIN numarasi gerekli' };
}
const normalizedVin = vin.toUpperCase().trim();
if (normalizedVin.length !== VIN_LENGTH) {
return { valid: false, error: `VIN ${VIN_LENGTH} karakter olmalidir` };
}
if (!VIN_REGEX.test(normalizedVin)) {
return { valid: false, error: 'VIN gecersiz karakterler iceriyor (I, O, Q kullanilamaz)' };
}
// Check digit validation (position 9)
const checkDigit = calculateCheckDigit(normalizedVin);
const actualCheckDigit = normalizedVin[8];
if (checkDigit !== actualCheckDigit) {
// Some manufacturers don't follow check digit standard
// So we just warn but don't fail
console.warn(`VIN check digit mismatch: expected ${checkDigit}, got ${actualCheckDigit}`);
}
return { valid: true };
}
function calculateCheckDigit(vin: string): string {
let sum = 0;
for (let i = 0; i < 17; i++) {
const char = vin[i];
let value: number;
if (/[0-9]/.test(char)) {
value = parseInt(char, 10);
} else {
value = TRANSLITERATION[char] || 0;
}
sum += value * WEIGHTS[i];
}
const remainder = sum % 11;
return remainder === 10 ? 'X' : remainder.toString();
}
export function normalizeVin(vin: string): string {
return vin.toUpperCase().trim().replace(/[^A-HJ-NPR-Z0-9]/gi, '');
}
export function getVinInfo(vin: string): {
wmi: string;
vds: string;
vis: string;
region: string;
year: number | null;
} {
const normalizedVin = normalizeVin(vin);
return {
wmi: normalizedVin.substring(0, 3), // World Manufacturer Identifier
vds: normalizedVin.substring(3, 9), // Vehicle Descriptor Section
vis: normalizedVin.substring(9, 17), // Vehicle Identifier Section
region: getRegion(normalizedVin[0]),
year: getModelYear(normalizedVin[9]),
};
}
function getRegion(char: string): string {
const regions: Record<string, string> = {
A: 'Africa',
B: 'Africa',
C: 'Africa',
D: 'Africa',
E: 'Africa',
F: 'Africa',
G: 'Africa',
H: 'Africa',
J: 'Asia',
K: 'Asia',
L: 'Asia',
M: 'Asia',
N: 'Asia',
P: 'Asia',
R: 'Asia',
S: 'Europe',
T: 'Europe',
U: 'Europe',
V: 'Europe',
W: 'Europe',
X: 'Europe',
Y: 'Europe',
Z: 'Europe',
'1': 'North America',
'2': 'North America',
'3': 'North America',
'4': 'North America',
'5': 'North America',
'6': 'Oceania',
'7': 'Oceania',
'8': 'South America',
'9': 'South America',
};
return regions[char] || 'Unknown';
}
function getModelYear(char: string): number | null {
const years: 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,
S: 2025,
T: 2026,
V: 2027,
W: 2028,
X: 2029,
Y: 2030,
'1': 2031,
'2': 2032,
'3': 2033,
'4': 2034,
'5': 2035,
'6': 2036,
'7': 2037,
'8': 2038,
'9': 2039,
};
return years[char] || null;
}

View File

@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["ES2020"],
"module": "commonjs",
"moduleResolution": "node",
"outDir": "./dist",
"rootDir": "./src",
"declaration": true,
"declarationMap": true,
"strict": true,
"strictNullChecks": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

File diff suppressed because one or more lines are too long