feat: sase.tr v2 full application implementation
Complete rewrite of sase.tr VIN lookup platform with modern stack: Backend (NestJS 10 + Drizzle ORM + PostgreSQL + Redis + BullMQ): - 34 DB models (core + PL24 + EMEX schemas) - Auth via Better Auth (email/password + social) - Brands, Plans, Subscriptions, Payments (iyzico + EFT) - VIN decode orchestration (Corgi + PL24 + EMEX + NHTSA) - Interactive schema viewer backend (MinIO storage) - EMEX scraping integration (Puppeteer + BullMQ workers) - Translation module (EN→TR automotive dictionary) - Admin dashboard API (stats, user mgmt, payment approval) - Rate limiting, Helmet security, file upload validation Frontend (Next.js 15 + Tailwind v4 + shadcn/ui + TanStack Query + Zustand): - 20 routes: auth, dashboard, VIN search, schema viewer, admin - Interactive schema viewer with zoom/pan/hotspot highlighting - Subscription management with brand selector - Payment flow (iyzico 3D Secure + EFT with receipt upload) - i18n support (TR/EN) - Error boundaries, loading skeletons, 404 page Infrastructure: - 85 tests (52 backend + 33 frontend, Vitest) - CI/CD (GitHub Actions: lint, typecheck, test, build, deploy) - Zero-downtime deploy script (PM2) - Env validation script Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
39
packages/shared/src/constants/error-codes.ts
Normal file
39
packages/shared/src/constants/error-codes.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
export const ERROR_CODES = {
|
||||
// Auth
|
||||
INVALID_CREDENTIALS: "AUTH_001",
|
||||
EMAIL_ALREADY_EXISTS: "AUTH_002",
|
||||
SESSION_EXPIRED: "AUTH_003",
|
||||
UNAUTHORIZED: "AUTH_004",
|
||||
FORBIDDEN: "AUTH_005",
|
||||
|
||||
// VIN
|
||||
INVALID_VIN: "VIN_001",
|
||||
VIN_DECODE_FAILED: "VIN_002",
|
||||
BRAND_NOT_SUPPORTED: "VIN_003",
|
||||
|
||||
// Subscription
|
||||
NO_ACTIVE_SUBSCRIPTION: "SUB_001",
|
||||
BRAND_ACCESS_DENIED: "SUB_002",
|
||||
INVALID_BRAND_COUNT: "SUB_003",
|
||||
SUBSCRIPTION_ALREADY_ACTIVE: "SUB_004",
|
||||
|
||||
// Payment
|
||||
PAYMENT_FAILED: "PAY_001",
|
||||
IYZICO_ERROR: "PAY_002",
|
||||
EFT_RECEIPT_REQUIRED: "PAY_003",
|
||||
PAYMENT_ALREADY_PROCESSED: "PAY_004",
|
||||
|
||||
// General
|
||||
NOT_FOUND: "GEN_001",
|
||||
VALIDATION_ERROR: "GEN_002",
|
||||
INTERNAL_ERROR: "GEN_003",
|
||||
RATE_LIMITED: "GEN_004",
|
||||
CONFLICT: "GEN_005",
|
||||
|
||||
// Integration
|
||||
PL24_ERROR: "INT_001",
|
||||
EMEX_ERROR: "INT_002",
|
||||
CORGI_ERROR: "INT_003",
|
||||
} as const;
|
||||
|
||||
export type ErrorCode = (typeof ERROR_CODES)[keyof typeof ERROR_CODES];
|
||||
33
packages/shared/src/constants/plans.ts
Normal file
33
packages/shared/src/constants/plans.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
export const PLANS = {
|
||||
SINGLE: {
|
||||
name: "1 Marka",
|
||||
brandCount: 1,
|
||||
priceMonthly: 200_00,
|
||||
priceYearly: 2000_00,
|
||||
},
|
||||
DOUBLE: {
|
||||
name: "2 Marka",
|
||||
brandCount: 2,
|
||||
priceMonthly: 350_00,
|
||||
priceYearly: 3500_00,
|
||||
},
|
||||
TRIPLE: {
|
||||
name: "3 Marka",
|
||||
brandCount: 3,
|
||||
priceMonthly: 500_00,
|
||||
priceYearly: 5000_00,
|
||||
},
|
||||
FULL: {
|
||||
name: "Full Paket",
|
||||
brandCount: 0,
|
||||
priceMonthly: 999_00,
|
||||
priceYearly: 9990_00,
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const REFERRAL_REWARDS = {
|
||||
TIER_1: { count: 3, extensionDays: 7 },
|
||||
TIER_2: { count: 5, extensionDays: 30 },
|
||||
} as const;
|
||||
|
||||
export const CURRENCY = "TRY" as const;
|
||||
5
packages/shared/src/constants/regex.ts
Normal file
5
packages/shared/src/constants/regex.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export const VIN_REGEX = /^[A-HJ-NPR-Z0-9]{17}$/;
|
||||
|
||||
export const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
export const OEM_CODE_REGEX = /^[A-Z0-9\-.\s]{3,30}$/i;
|
||||
43
packages/shared/src/index.ts
Normal file
43
packages/shared/src/index.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
// Types
|
||||
export type { User, UserProfile, UserSubscriptionSummary } from "./types/user.js";
|
||||
export type { Vehicle, VinDecodeResult, CategoryNode, VehicleSource } from "./types/vehicle.js";
|
||||
export type { Brand } from "./types/brand.js";
|
||||
export type { Part, PartSearchResult, PartSource } from "./types/part.js";
|
||||
export type { Category, CategoryWithSchema, SchemaPic, Hotspot } from "./types/category.js";
|
||||
export type {
|
||||
Plan,
|
||||
Subscription,
|
||||
UserBrand,
|
||||
CreateSubscriptionInput,
|
||||
SubscriptionStatus,
|
||||
} from "./types/subscription.js";
|
||||
export type {
|
||||
Payment,
|
||||
PaymentMethod,
|
||||
PaymentStatus,
|
||||
IyzicoInitializeInput,
|
||||
EftPaymentInput,
|
||||
} from "./types/payment.js";
|
||||
export type { ApiResponse, ApiError, PaginationMeta } from "./types/api-response.js";
|
||||
export type { PaginationInput, PaginatedResult } from "./types/pagination.js";
|
||||
|
||||
// Schemas
|
||||
export { vinSchema } from "./schemas/vin.js";
|
||||
export { loginSchema, registerSchema, forgotPasswordSchema, resetPasswordSchema } from "./schemas/auth.js";
|
||||
export { paginationSchema } from "./schemas/pagination.js";
|
||||
|
||||
// Constants
|
||||
export { VIN_REGEX, EMAIL_REGEX, OEM_CODE_REGEX } from "./constants/regex.js";
|
||||
export { PLANS, REFERRAL_REWARDS, CURRENCY } from "./constants/plans.js";
|
||||
export { ERROR_CODES } from "./constants/error-codes.js";
|
||||
export type { ErrorCode } from "./constants/error-codes.js";
|
||||
|
||||
// Utils
|
||||
export {
|
||||
isValidVin,
|
||||
validateVinCheckDigit,
|
||||
extractWmi,
|
||||
extractModelYear,
|
||||
} from "./utils/vin-validator.js";
|
||||
export { formatTRY, kurusToLira, liraToKurus } from "./utils/currency.js";
|
||||
export { formatVin, formatDate, formatDateTime, slugify, generateReferralCode } from "./utils/formatters.js";
|
||||
38
packages/shared/src/schemas/auth.ts
Normal file
38
packages/shared/src/schemas/auth.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const loginSchema = z.object({
|
||||
email: z.string().email("Invalid email address"),
|
||||
password: z.string().min(8, "Password must be at least 8 characters"),
|
||||
});
|
||||
|
||||
export const registerSchema = z.object({
|
||||
name: z.string().min(2, "Name must be at least 2 characters").max(100),
|
||||
email: z.string().email("Invalid email address"),
|
||||
password: z
|
||||
.string()
|
||||
.min(8, "Password must be at least 8 characters")
|
||||
.max(128)
|
||||
.regex(/[A-Z]/, "Password must contain at least one uppercase letter")
|
||||
.regex(/[a-z]/, "Password must contain at least one lowercase letter")
|
||||
.regex(/[0-9]/, "Password must contain at least one number"),
|
||||
});
|
||||
|
||||
export const forgotPasswordSchema = z.object({
|
||||
email: z.string().email("Invalid email address"),
|
||||
});
|
||||
|
||||
export const resetPasswordSchema = z.object({
|
||||
token: z.string().min(1),
|
||||
password: z
|
||||
.string()
|
||||
.min(8, "Password must be at least 8 characters")
|
||||
.max(128)
|
||||
.regex(/[A-Z]/, "Password must contain at least one uppercase letter")
|
||||
.regex(/[a-z]/, "Password must contain at least one lowercase letter")
|
||||
.regex(/[0-9]/, "Password must contain at least one number"),
|
||||
});
|
||||
|
||||
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>;
|
||||
8
packages/shared/src/schemas/pagination.ts
Normal file
8
packages/shared/src/schemas/pagination.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const paginationSchema = z.object({
|
||||
page: z.coerce.number().int().min(1).default(1),
|
||||
limit: z.coerce.number().int().min(1).max(100).default(20),
|
||||
});
|
||||
|
||||
export type PaginationQuery = z.infer<typeof paginationSchema>;
|
||||
10
packages/shared/src/schemas/vin.ts
Normal file
10
packages/shared/src/schemas/vin.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { z } from "zod";
|
||||
import { VIN_REGEX } from "../constants/regex.js";
|
||||
|
||||
export const vinSchema = z
|
||||
.string()
|
||||
.length(17, "VIN must be exactly 17 characters")
|
||||
.regex(VIN_REGEX, "VIN contains invalid characters (I, O, Q are not allowed)")
|
||||
.transform((v) => v.toUpperCase());
|
||||
|
||||
export type VinInput = z.input<typeof vinSchema>;
|
||||
21
packages/shared/src/types/api-response.ts
Normal file
21
packages/shared/src/types/api-response.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
export interface ApiResponse<T = unknown> {
|
||||
success: boolean;
|
||||
data: T;
|
||||
meta?: PaginationMeta;
|
||||
}
|
||||
|
||||
export interface ApiError {
|
||||
success: false;
|
||||
error: {
|
||||
code: string;
|
||||
message: string;
|
||||
details?: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
export interface PaginationMeta {
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
}
|
||||
9
packages/shared/src/types/brand.ts
Normal file
9
packages/shared/src/types/brand.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export interface Brand {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
logoUrl: string | null;
|
||||
isActive: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
34
packages/shared/src/types/category.ts
Normal file
34
packages/shared/src/types/category.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
export interface Category {
|
||||
id: string;
|
||||
vehicleId: string;
|
||||
name: string;
|
||||
nameOriginal: string | null;
|
||||
parentId: string | null;
|
||||
externalId: string | null;
|
||||
source: "pl24" | "emex";
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface CategoryWithSchema extends Category {
|
||||
schemaPics: SchemaPic[];
|
||||
}
|
||||
|
||||
export interface SchemaPic {
|
||||
id: string;
|
||||
categoryId: string;
|
||||
imageUrl: string;
|
||||
hotspots: Hotspot[];
|
||||
source: "pl24" | "emex";
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface Hotspot {
|
||||
index: number;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
partId: string | null;
|
||||
shape: "rect" | "circle" | "polygon";
|
||||
points?: { x: number; y: number }[];
|
||||
}
|
||||
14
packages/shared/src/types/pagination.ts
Normal file
14
packages/shared/src/types/pagination.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export interface PaginationInput {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface PaginatedResult<T> {
|
||||
items: T[];
|
||||
meta: {
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
};
|
||||
}
|
||||
26
packages/shared/src/types/part.ts
Normal file
26
packages/shared/src/types/part.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
export interface Part {
|
||||
id: string;
|
||||
vehicleId: string;
|
||||
categoryId: string;
|
||||
oemCode: string;
|
||||
name: string;
|
||||
nameOriginal: string | null;
|
||||
description: string | null;
|
||||
quantity: number | null;
|
||||
position: string | null;
|
||||
hotspotIndex: number | null;
|
||||
source: PartSource;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export type PartSource = "pl24" | "emex";
|
||||
|
||||
export interface PartSearchResult {
|
||||
part: Part;
|
||||
vehicleInfo: {
|
||||
vin: string;
|
||||
brandName: string;
|
||||
model: string | null;
|
||||
year: number | null;
|
||||
};
|
||||
}
|
||||
30
packages/shared/src/types/payment.ts
Normal file
30
packages/shared/src/types/payment.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
export interface Payment {
|
||||
id: string;
|
||||
userId: string;
|
||||
subscriptionId: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
method: PaymentMethod;
|
||||
status: PaymentStatus;
|
||||
iyzicoPaymentId: string | null;
|
||||
eftReceiptUrl: string | null;
|
||||
adminNote: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export type PaymentMethod = "iyzico" | "eft";
|
||||
export type PaymentStatus = "pending" | "completed" | "failed" | "refunded";
|
||||
|
||||
export interface IyzicoInitializeInput {
|
||||
subscriptionId: string;
|
||||
cardHolderName: string;
|
||||
cardNumber: string;
|
||||
expireMonth: string;
|
||||
expireYear: string;
|
||||
cvc: string;
|
||||
}
|
||||
|
||||
export interface EftPaymentInput {
|
||||
subscriptionId: string;
|
||||
}
|
||||
38
packages/shared/src/types/subscription.ts
Normal file
38
packages/shared/src/types/subscription.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
export interface Plan {
|
||||
id: string;
|
||||
name: string;
|
||||
brandCount: number;
|
||||
priceMonthly: number;
|
||||
priceYearly: number;
|
||||
isActive: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface Subscription {
|
||||
id: string;
|
||||
userId: string;
|
||||
planId: string;
|
||||
status: SubscriptionStatus;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
cancelledAt: Date | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export type SubscriptionStatus = "pending" | "active" | "cancelled" | "expired";
|
||||
|
||||
export interface UserBrand {
|
||||
id: string;
|
||||
userId: string;
|
||||
subscriptionId: string;
|
||||
brandId: string;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface CreateSubscriptionInput {
|
||||
planId: string;
|
||||
brandIds: string[];
|
||||
billingPeriod: "monthly" | "yearly";
|
||||
}
|
||||
31
packages/shared/src/types/user.ts
Normal file
31
packages/shared/src/types/user.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
export interface User {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
emailVerified: boolean;
|
||||
image: string | null;
|
||||
role: "user" | "admin";
|
||||
referralCode: string | null;
|
||||
referredBy: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface UserProfile {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
image: string | null;
|
||||
role: "user" | "admin";
|
||||
referralCode: string | null;
|
||||
subscription: UserSubscriptionSummary | null;
|
||||
}
|
||||
|
||||
export interface UserSubscriptionSummary {
|
||||
planName: string;
|
||||
status: SubscriptionStatus;
|
||||
brands: string[];
|
||||
expiresAt: Date;
|
||||
}
|
||||
|
||||
export type SubscriptionStatus = "pending" | "active" | "cancelled" | "expired";
|
||||
33
packages/shared/src/types/vehicle.ts
Normal file
33
packages/shared/src/types/vehicle.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
export interface Vehicle {
|
||||
id: string;
|
||||
userId: string;
|
||||
vin: string;
|
||||
brandId: string;
|
||||
brandName: string;
|
||||
model: string | null;
|
||||
year: number | null;
|
||||
engine: string | null;
|
||||
transmission: string | null;
|
||||
bodyType: string | null;
|
||||
market: string | null;
|
||||
rawData: Record<string, unknown> | null;
|
||||
source: VehicleSource;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export type VehicleSource = "pl24" | "emex" | "corgi" | "vin-api";
|
||||
|
||||
export interface VinDecodeResult {
|
||||
vehicle: Vehicle;
|
||||
categories: CategoryNode[];
|
||||
}
|
||||
|
||||
export interface CategoryNode {
|
||||
id: string;
|
||||
name: string;
|
||||
nameOriginal: string | null;
|
||||
parentId: string | null;
|
||||
children: CategoryNode[];
|
||||
hasSchema: boolean;
|
||||
}
|
||||
18
packages/shared/src/utils/currency.ts
Normal file
18
packages/shared/src/utils/currency.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
const TRY_FORMATTER = new Intl.NumberFormat("tr-TR", {
|
||||
style: "currency",
|
||||
currency: "TRY",
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
|
||||
export function formatTRY(amountInKurus: number): string {
|
||||
return TRY_FORMATTER.format(amountInKurus / 100);
|
||||
}
|
||||
|
||||
export function kurusToLira(kurus: number): number {
|
||||
return kurus / 100;
|
||||
}
|
||||
|
||||
export function liraToKurus(lira: number): number {
|
||||
return Math.round(lira * 100);
|
||||
}
|
||||
45
packages/shared/src/utils/formatters.ts
Normal file
45
packages/shared/src/utils/formatters.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
export function formatVin(vin: string): string {
|
||||
return vin.toUpperCase().replace(/[^A-HJ-NPR-Z0-9]/g, "");
|
||||
}
|
||||
|
||||
export function formatDate(date: Date | string): string {
|
||||
const d = typeof date === "string" ? new Date(date) : date;
|
||||
return d.toLocaleDateString("tr-TR", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
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 slugify(text: string): string {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/[çÇ]/g, "c")
|
||||
.replace(/[ğĞ]/g, "g")
|
||||
.replace(/[ıİ]/g, "i")
|
||||
.replace(/[öÖ]/g, "o")
|
||||
.replace(/[şŞ]/g, "s")
|
||||
.replace(/[üÜ]/g, "u")
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-|-$/g, "");
|
||||
}
|
||||
|
||||
export function generateReferralCode(): string {
|
||||
const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||
let code = "";
|
||||
for (let i = 0; i < 8; i++) {
|
||||
code += chars[Math.floor(Math.random() * chars.length)];
|
||||
}
|
||||
return code;
|
||||
}
|
||||
48
packages/shared/src/utils/vin-validator.ts
Normal file
48
packages/shared/src/utils/vin-validator.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { VIN_REGEX } from "../constants/regex.js";
|
||||
|
||||
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 POSITION_WEIGHTS = [8, 7, 6, 5, 4, 3, 2, 10, 0, 9, 8, 7, 6, 5, 4, 3, 2];
|
||||
|
||||
export function isValidVin(vin: string): boolean {
|
||||
if (!vin || vin.length !== 17) return false;
|
||||
const upper = vin.toUpperCase();
|
||||
return VIN_REGEX.test(upper);
|
||||
}
|
||||
|
||||
export function validateVinCheckDigit(vin: string): boolean {
|
||||
const upper = vin.toUpperCase();
|
||||
if (!isValidVin(upper)) return false;
|
||||
|
||||
let sum = 0;
|
||||
for (let i = 0; i < 17; i++) {
|
||||
const char = upper[i];
|
||||
const value = /\d/.test(char) ? Number.parseInt(char, 10) : TRANSLITERATION[char];
|
||||
if (value === undefined) return false;
|
||||
sum += value * POSITION_WEIGHTS[i];
|
||||
}
|
||||
|
||||
const remainder = sum % 11;
|
||||
const checkChar = remainder === 10 ? "X" : String(remainder);
|
||||
return upper[8] === checkChar;
|
||||
}
|
||||
|
||||
export function extractWmi(vin: string): string {
|
||||
return vin.toUpperCase().slice(0, 3);
|
||||
}
|
||||
|
||||
export function extractModelYear(vin: string): number | null {
|
||||
const yearChar = vin.toUpperCase()[9];
|
||||
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, S: 2025,
|
||||
T: 2026, V: 2027, W: 2028, X: 2029, Y: 2030,
|
||||
"1": 2001, "2": 2002, "3": 2003, "4": 2004, "5": 2005,
|
||||
"6": 2006, "7": 2007, "8": 2008, "9": 2009,
|
||||
};
|
||||
return yearMap[yearChar] ?? null;
|
||||
}
|
||||
Reference in New Issue
Block a user