chore(lint): manual cleanup batch 2/3 (group 6 partial + format sweep)

- new helper: apps/web/src/lib/keys.ts — stable string-key arrays for fixed-length skeleton/decorative lists, plus dynamicKeys() for runtime-sized lists
- group 6 (noArrayIndexKey): convert ~25 of 32 Array.from skeleton patterns to KEYS_N.map across 21 files (admin pages, catalog, dashboard, schema viewer, remotion demos)
- DashboardDemo HOTSPOTS now carries explicit ids; OnboardingProgress uses step.label as key
- biome --write format pass (24 files)

Lint count: 218 → 164 (groups 6/7 still in progress: 9 noArrayIndexKey + 121 noExplicitAny + 42 noNonNullAssertion + a few small remaining).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sase Dev
2026-05-09 16:48:58 +00:00
parent 0af3fbf725
commit bc8e3a3212
46 changed files with 748 additions and 444 deletions

View File

@@ -8,9 +8,7 @@ export class AdminController {
constructor(private adminService: AdminService) {}
@Post("users")
async createUser(
@Body() body: { name: string; email: string; password: string; role?: string },
) {
async createUser(@Body() body: { name: string; email: string; password: string; role?: string }) {
return this.adminService.createUser(body);
}
@@ -87,10 +85,7 @@ export class AdminController {
}
@Get("copy-logs/top")
async getTopCopiedCodes(
@Query("days") days?: string,
@Query("limit") limit?: string,
) {
async getTopCopiedCodes(@Query("days") days?: string, @Query("limit") limit?: string) {
return this.adminService.getTopCopiedCodes(
days ? Number.parseInt(days, 10) : 30,
limit ? Number.parseInt(limit, 10) : 20,

View File

@@ -1,10 +1,4 @@
import {
ConflictException,
Inject,
Injectable,
Logger,
NotFoundException,
} from "@nestjs/common";
import { ConflictException, Inject, Injectable, Logger, NotFoundException } from "@nestjs/common";
import { generateReferralCode } from "@sase/shared";
import { hashPassword } from "better-auth/crypto";
import { and, count, desc, eq, gte, ilike, inArray, or, sql } from "drizzle-orm";
@@ -92,7 +86,9 @@ export class AdminService {
pendingPaymentsResult,
] = await Promise.all([
// Total users
this.db.select({ count: count() }).from(users),
this.db
.select({ count: count() })
.from(users),
// Active subscriptions
this.db
.select({ count: count() })
@@ -119,9 +115,7 @@ export class AdminService {
this.db
.select({ count: count() })
.from(payments)
.where(
and(eq(payments.method, "eft"), eq(payments.status, "pending")),
),
.where(and(eq(payments.method, "eft"), eq(payments.status, "pending"))),
]);
return {
@@ -138,10 +132,7 @@ export class AdminService {
const offset = (page - 1) * limit;
const conditions = search
? or(
ilike(users.email, `%${search}%`),
ilike(users.name, `%${search}%`),
)
? or(ilike(users.email, `%${search}%`), ilike(users.name, `%${search}%`))
: undefined;
const [items, totalResult] = await Promise.all([
@@ -197,11 +188,7 @@ export class AdminService {
}
async getUserDetail(userId: string) {
const [user] = await this.db
.select()
.from(users)
.where(eq(users.id, userId))
.limit(1);
const [user] = await this.db.select().from(users).where(eq(users.id, userId)).limit(1);
if (!user) {
throw new NotFoundException("Kullanıcı bulunamadı");
@@ -253,9 +240,7 @@ export class AdminService {
async getQueryLogs(page = 1, limit = 50, userId?: string) {
const offset = (page - 1) * limit;
const conditions = userId
? eq(queryLogs.userId, userId)
: undefined;
const conditions = userId ? eq(queryLogs.userId, userId) : undefined;
const [items, totalResult] = await Promise.all([
this.db
@@ -300,10 +285,7 @@ export class AdminService {
const referrerAlias = users;
const conditions = search
? or(
ilike(users.name, `%${search}%`),
ilike(users.email, `%${search}%`),
)
? or(ilike(users.name, `%${search}%`), ilike(users.email, `%${search}%`))
: undefined;
// Get all referrals with referrer and referred user info
@@ -324,17 +306,18 @@ export class AdminService {
const allUserIds = [...new Set([...referrerIds, ...referredIds])];
// Get all relevant users
const allUsers = allUserIds.length > 0
? await this.db
.select({
id: users.id,
name: users.name,
email: users.email,
referralCode: users.referralCode,
})
.from(users)
.where(inArray(users.id, allUserIds))
: [];
const allUsers =
allUserIds.length > 0
? await this.db
.select({
id: users.id,
name: users.name,
email: users.email,
referralCode: users.referralCode,
})
.from(users)
.where(inArray(users.id, allUserIds))
: [];
const userMap = new Map(allUsers.map((u) => [u.id, u]));
@@ -388,9 +371,7 @@ export class AdminService {
}
async getDailyStats() {
const thirtyDaysAgo = new Date(
Date.now() - 30 * 24 * 60 * 60 * 1000,
);
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
const result = await this.db
.select({

View File

@@ -30,7 +30,10 @@ export class BrandsService {
return brand;
}
async update(id: string, data: { name?: string; slug?: string; logoUrl?: string; isActive?: boolean }) {
async update(
id: string,
data: { name?: string; slug?: string; logoUrl?: string; isActive?: boolean },
) {
const [brand] = await this.db
.update(brands)
.set({ ...data, updatedAt: new Date() })

View File

@@ -1,10 +1,4 @@
import {
ForbiddenException,
Inject,
Injectable,
Logger,
NotFoundException,
} from "@nestjs/common";
import { ForbiddenException, Inject, Injectable, Logger, NotFoundException } from "@nestjs/common";
import { and, eq, inArray, or, sql } from "drizzle-orm";
import { DATABASE, type Database } from "../database/database.provider";
import {
@@ -267,7 +261,10 @@ export class CatalogService {
/**
* Get available body types for a PSA catalog vehicle (for variant selector UI).
*/
async getPsaBodies(catalogVehicleId: string, userId: string): Promise<{ code: string; name: string }[]> {
async getPsaBodies(
catalogVehicleId: string,
userId: string,
): Promise<{ code: string; name: string }[]> {
const [vehicle] = await this.db
.select()
.from(catalogVehicles)
@@ -287,7 +284,11 @@ export class CatalogService {
/**
* Get available engines for a PSA catalog vehicle given a selected body code.
*/
async getPsaEngines(catalogVehicleId: string, body: string, userId: string): Promise<{ code: string; name: string }[]> {
async getPsaEngines(
catalogVehicleId: string,
body: string,
userId: string,
): Promise<{ code: string; name: string }[]> {
const [vehicle] = await this.db
.select()
.from(catalogVehicles)
@@ -301,13 +302,25 @@ export class CatalogService {
const { familyId, salesTypeId, mode, upds } = this.extractPsaMeta(vehicle);
if (!familyId || !salesTypeId) return [];
return this.pl24Service.fetchPsaEnginesForBody(vehicle.serviceName, familyId, salesTypeId, body, mode, upds);
return this.pl24Service.fetchPsaEnginesForBody(
vehicle.serviceName,
familyId,
salesTypeId,
body,
mode,
upds,
);
}
/**
* Get available gearboxes for a PSA catalog vehicle given selected body + engine codes.
*/
async getPsaGearboxes(catalogVehicleId: string, body: string, engine: string, userId: string): Promise<{ code: string; name: string }[]> {
async getPsaGearboxes(
catalogVehicleId: string,
body: string,
engine: string,
userId: string,
): Promise<{ code: string; name: string }[]> {
const [vehicle] = await this.db
.select()
.from(catalogVehicles)
@@ -321,7 +334,15 @@ export class CatalogService {
const { familyId, salesTypeId, mode, upds } = this.extractPsaMeta(vehicle);
if (!familyId || !salesTypeId) return [];
return this.pl24Service.fetchPsaGearboxes(vehicle.serviceName, familyId, salesTypeId, body, engine, mode, upds);
return this.pl24Service.fetchPsaGearboxes(
vehicle.serviceName,
familyId,
salesTypeId,
body,
engine,
mode,
upds,
);
}
/**
@@ -448,7 +469,9 @@ export class CatalogService {
await this.redis.setJson(cacheKey, tree, 7200);
return tree;
} catch (err) {
this.logger.warn(`PSA variant category fetch failed for ${catalogVehicleId}: ${(err as Error).message}`);
this.logger.warn(
`PSA variant category fetch failed for ${catalogVehicleId}: ${(err as Error).message}`,
);
return [];
}
}
@@ -542,7 +565,7 @@ export class CatalogService {
// For LEGACY_FORD: body param = catCode (e.g. "CB7" for C-MAX Grand C-MAX).
// For LEGACY_VOLVO: body param = model year (e.g. "1638").
if (this.isP4FordLikeArch(vehicle.architecture)) {
if (!hasVariant) return []; // no variant selected yet → let frontend show variant selector
if (!hasVariant) return []; // no variant selected yet → let frontend show variant selector
try {
const { familyId, mode, upds } = this.extractFordMeta(vehicle);
if (familyId) {
@@ -712,7 +735,9 @@ export class CatalogService {
.where(eq(catalogVehicles.id, catalogVehicleId));
}
} catch (err) {
this.logger.warn(`PL24 category fetch failed for catalog vehicle ${catalogVehicleId}: ${(err as Error).message}`);
this.logger.warn(
`PL24 category fetch failed for catalog vehicle ${catalogVehicleId}: ${(err as Error).message}`,
);
}
}
@@ -729,7 +754,9 @@ export class CatalogService {
const dbCategories = await this.db
.select()
.from(categories)
.where(and(eq(categories.catalogVehicleId, catalogVehicleId), sql`${categories.parentId} IS NULL`));
.where(
and(eq(categories.catalogVehicleId, catalogVehicleId), sql`${categories.parentId} IS NULL`),
);
if (dbCategories.length === 0) return [];
const tree = this.buildTree(dbCategories);
await this.redis.setJson(cacheKey, tree, 3600); // 1h cache for DB fallback
@@ -762,8 +789,9 @@ export class CatalogService {
// PSA variant trees return PL24 codes (e.g. "_FCT0100") as IDs instead of UUIDs.
// Detect and resolve to DB UUID via externalId lookup.
const isUuid =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(categoryIdInput);
const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
categoryIdInput,
);
let categoryRow: typeof categories.$inferSelect | undefined;
if (isUuid) {
@@ -803,7 +831,9 @@ export class CatalogService {
// Self-healing: if the linkPath is a leaf path but DB has children, those are
// stale records created by the previous case-insensitive bug. Delete and re-fetch.
if (children.length > 0 && linkPath && this.isLeafPath(linkPath)) {
this.logger.warn(`Stale children detected on leaf category ${categoryId} (linkPath: ${linkPath}), cleaning up`);
this.logger.warn(
`Stale children detected on leaf category ${categoryId} (linkPath: ${linkPath}), cleaning up`,
);
await this.db.delete(categories).where(eq(categories.parentId, categoryId));
children = [];
}
@@ -883,10 +913,7 @@ export class CatalogService {
}
// Leaf category — get or fetch parts
let dbParts = await this.db
.select()
.from(parts)
.where(eq(parts.categoryId, categoryId));
let dbParts = await this.db.select().from(parts).where(eq(parts.categoryId, categoryId));
const pics = await this.db
.select()
@@ -915,7 +942,7 @@ export class CatalogService {
name: p.name,
nameOriginal: p.name,
description: p.description || null,
quantity: p.quantity ? (Number.parseInt(String(p.quantity), 10) || null) : null,
quantity: p.quantity ? Number.parseInt(String(p.quantity), 10) || null : null,
position: p.positionCode || null,
hotspotIndex: p.hotspotId
? (() => {
@@ -939,7 +966,9 @@ export class CatalogService {
// PSA provides a pre-downloaded buffer (ticket URLs expire immediately)
if (pl24Result.schemaImageBuffer) {
try {
const ext = (pl24Result.schemaImageContentType || "image/png").includes("jpeg") ? "jpg" : "png";
const ext = (pl24Result.schemaImageContentType || "image/png").includes("jpeg")
? "jpg"
: "png";
const fileName = `catalog/${catalogVehicleId}/${categoryId}.${ext}`;
const uploadedUrl = await this.storage.upload(
fileName,
@@ -962,7 +991,9 @@ export class CatalogService {
.returning();
pics.push(inserted);
} catch (imgErr) {
this.logger.warn(`PSA image upload failed for ${categoryId}: ${(imgErr as Error).message}`);
this.logger.warn(
`PSA image upload failed for ${categoryId}: ${(imgErr as Error).message}`,
);
}
} else if (pl24Result.schemaImageUrl) {
const imageResult = await this.pl24Service.getSchemaImage(
@@ -995,7 +1026,9 @@ export class CatalogService {
}
}
} catch (err) {
this.logger.error(`Failed to fetch parts for category ${categoryId}: ${(err as Error).message}`);
this.logger.error(
`Failed to fetch parts for category ${categoryId}: ${(err as Error).message}`,
);
}
}
@@ -1139,7 +1172,8 @@ export class CatalogService {
if (!vehicle) throw new NotFoundException("Katalog aracı bulunamadı");
if (vehicle.brandId) await this.checkBrandAccess(userId, vehicle.brandId);
if (!this.isP4FordLikeArch(vehicle.architecture)) return { modelYears: [], engines: [], gearboxes: [] };
if (!this.isP4FordLikeArch(vehicle.architecture))
return { modelYears: [], engines: [], gearboxes: [] };
const { familyId, mode, upds } = this.extractFordMeta(vehicle);
if (!familyId) return { modelYears: [], engines: [], gearboxes: [] };
@@ -1198,9 +1232,7 @@ export class CatalogService {
const subBrands = await this.db
.select({ brandId: userBrands.brandId })
.from(userBrands)
.where(
and(eq(userBrands.userId, userId), eq(userBrands.subscriptionId, sub.id)),
);
.where(and(eq(userBrands.userId, userId), eq(userBrands.subscriptionId, sub.id)));
return new Set(subBrands.map((b) => b.brandId));
}
@@ -1219,7 +1251,9 @@ export class CatalogService {
.limit(1);
if (!sub) {
throw new ForbiddenException("Aktif aboneliğiniz yok. Katalog verilerine erişmek için abone olun.");
throw new ForbiddenException(
"Aktif aboneliğiniz yok. Katalog verilerine erişmek için abone olun.",
);
}
if (sub.brandCount === 0) return;

View File

@@ -83,7 +83,9 @@ export class CategoriesService {
dbCategories = await this.db.insert(categories).values(insertData).returning();
}
} catch (err) {
this.logger.warn(`PL24 category fetch failed for ${vehicleId}: ${(err as Error).message}`);
this.logger.warn(
`PL24 category fetch failed for ${vehicleId}: ${(err as Error).message}`,
);
}
}
}
@@ -97,7 +99,10 @@ export class CategoriesService {
const svcName: string = rawData?.catalogInfo?.serviceName ?? "";
if (catPath.startsWith("/psa/") && svcName && vehicle.vin) {
try {
const scopes = await this.pl24FordLegacyService.fetchCategoriesForPsaVin(svcName, vehicle.vin);
const scopes = await this.pl24FordLegacyService.fetchCategoriesForPsaVin(
svcName,
vehicle.vin,
);
if (scopes.length > 0) {
const insertData = scopes.map((s) => ({
vehicleId,
@@ -115,7 +120,9 @@ export class CategoriesService {
.values(insertData)
.onConflictDoNothing()
.returning();
this.logger.log(`Stored ${dbCategories.length} PSA scope categories for ${vehicle.vin}`);
this.logger.log(
`Stored ${dbCategories.length} PSA scope categories for ${vehicle.vin}`,
);
}
} catch (err) {
this.logger.warn(`PSA scope fetch failed for ${vehicleId}: ${(err as Error).message}`);
@@ -129,39 +136,53 @@ export class CategoriesService {
const rawData = vehicle.rawData as any;
const catPath: string = rawData?.catalogInfo?.catalogPath ?? "";
if (!catPath.startsWith("/psa/")) {
const decodedCats = Array.isArray(rawData?.categories)
? (rawData.categories as Array<{ code: string; nameEn: string; nameTr?: string; linkPath?: string; linkWid?: string }>)
: null;
const decodedCats = Array.isArray(rawData?.categories)
? (rawData.categories as Array<{
code: string;
nameEn: string;
nameTr?: string;
linkPath?: string;
linkWid?: string;
}>)
: null;
if (decodedCats && decodedCats.length > 0) {
try {
const seenNames = new Set<string>();
const uniqueCats = decodedCats.filter((c) => {
const name = c.nameTr || c.nameEn;
if (seenNames.has(name)) return false;
seenNames.add(name);
return true;
});
if (decodedCats && decodedCats.length > 0) {
try {
const seenNames = new Set<string>();
const uniqueCats = decodedCats.filter((c) => {
const name = c.nameTr || c.nameEn;
if (seenNames.has(name)) return false;
seenNames.add(name);
return true;
});
const insertData = uniqueCats.map((c) => ({
vehicleId,
catalogVehicleId: null as string | null,
name: c.nameTr || c.nameEn,
nameOriginal: c.nameEn,
parentId: null as string | null,
externalId: c.code,
linkPath: c.linkPath || null,
linkWid: c.linkWid || null,
source: "pl24" as const,
}));
const insertData = uniqueCats.map((c) => ({
vehicleId,
catalogVehicleId: null as string | null,
name: c.nameTr || c.nameEn,
nameOriginal: c.nameEn,
parentId: null as string | null,
externalId: c.code,
linkPath: c.linkPath || null,
linkWid: c.linkWid || null,
source: "pl24" as const,
}));
dbCategories = await this.db.insert(categories).values(insertData).onConflictDoNothing().returning();
this.logger.log(`Stored ${dbCategories.length} P4 legacy categories for ${vehicle.vin}`);
} catch (err) {
this.logger.warn(`P4 legacy category insert failed for ${vehicleId}: ${(err as Error).message}`);
dbCategories = await this.db
.insert(categories)
.values(insertData)
.onConflictDoNothing()
.returning();
this.logger.log(
`Stored ${dbCategories.length} P4 legacy categories for ${vehicle.vin}`,
);
} catch (err) {
this.logger.warn(
`P4 legacy category insert failed for ${vehicleId}: ${(err as Error).message}`,
);
}
}
}
}
}
// If still no categories, try PartsCatalogs
@@ -193,17 +214,25 @@ export class CategoriesService {
source: "parts-catalogs" as const,
}));
dbCategories = await this.db.insert(categories).values(insertData).onConflictDoNothing().returning();
dbCategories = await this.db
.insert(categories)
.values(insertData)
.onConflictDoNothing()
.returning();
if (dbCategories.length < insertData.length) {
dbCategories = await this.db
.select()
.from(categories)
.where(eq(categories.vehicleId, vehicleId));
}
this.logger.log(`Stored ${dbCategories.length} PartsCatalogs categories for ${vehicle.vin}`);
this.logger.log(
`Stored ${dbCategories.length} PartsCatalogs categories for ${vehicle.vin}`,
);
}
} catch (err) {
this.logger.warn(`PartsCatalogs category fetch failed for ${vehicleId}: ${(err as Error).message}`);
this.logger.warn(
`PartsCatalogs category fetch failed for ${vehicleId}: ${(err as Error).message}`,
);
}
}
}
@@ -215,12 +244,14 @@ export class CategoriesService {
const emexResult = await this.emexService.decodeVin(vehicle.vin);
if (emexResult) {
const rawData = emexResult.raw as Record<string, unknown>;
const tree = rawData?.emexCategoryTree as Array<{
name: string;
gid: string | null;
url: string | null;
children: any[];
}> | undefined;
const tree = rawData?.emexCategoryTree as
| Array<{
name: string;
gid: string | null;
url: string | null;
children: any[];
}>
| undefined;
if (tree && tree.length > 0) {
// Recursive tree insertion from QuickGroups.aspx
@@ -229,9 +260,7 @@ export class CategoriesService {
// Phase 1: walk the tree, collect every unique English name so we
// can bulk-translate before touching the DB.
const uniqueNames = new Set<string>();
const collect = (
nodes: Array<{ name: string; children?: any[] }>,
) => {
const collect = (nodes: Array<{ name: string; children?: any[] }>) => {
for (const node of nodes) {
if (node.name) uniqueNames.add(node.name);
if (node.children?.length) collect(node.children);
@@ -241,7 +270,12 @@ export class CategoriesService {
const trMap = await this.translationsService.translateMany([...uniqueNames]);
const insertNodes = async (
nodes: Array<{ name: string; gid: string | null; url: string | null; children?: any[] }>,
nodes: Array<{
name: string;
gid: string | null;
url: string | null;
children?: any[];
}>,
parentId: string | null,
) => {
for (const node of nodes) {
@@ -259,7 +293,7 @@ export class CategoriesService {
nameOriginal: node.name,
parentId,
externalId: node.gid || null,
linkPath: isLeaf ? (node.url || null) : null,
linkPath: isLeaf ? node.url || null : null,
linkWid: null as string | null,
source: "emex" as const,
})
@@ -276,10 +310,17 @@ export class CategoriesService {
};
await insertNodes(tree, null);
this.logger.log(`Stored ${dbCategories.length} EMEX categories (tree) for ${vehicle.vin}`);
this.logger.log(
`Stored ${dbCategories.length} EMEX categories (tree) for ${vehicle.vin}`,
);
} else if (emexResult.categories.length > 0) {
// Flat fallback: insert all categories without hierarchy
const emexCats = (rawData?.emexCategories as Array<{ gid: string; name: string; url: string | null }>) || [];
const emexCats =
(rawData?.emexCategories as Array<{
gid: string;
name: string;
url: string | null;
}>) || [];
const urlMap = new Map(emexCats.map((c) => [c.gid, c.url]));
// Bulk translate English names before inserting; mapper no longer
@@ -309,7 +350,9 @@ export class CategoriesService {
}));
dbCategories = await this.db.insert(categories).values(insertData).returning();
this.logger.log(`Stored ${dbCategories.length} EMEX categories (flat) for ${vehicle.vin}`);
this.logger.log(
`Stored ${dbCategories.length} EMEX categories (flat) for ${vehicle.vin}`,
);
}
// Update vehicle source/rawData if it was vin-api
@@ -325,7 +368,9 @@ export class CategoriesService {
}
}
} catch (emexErr) {
this.logger.warn(`EMEX category fallback failed for ${vehicle.vin}: ${(emexErr as Error).message}`);
this.logger.warn(
`EMEX category fallback failed for ${vehicle.vin}: ${(emexErr as Error).message}`,
);
}
}
@@ -372,7 +417,11 @@ export class CategoriesService {
const linkPath = category.linkPath;
// PartsCatalogs subgroups (on-demand drill-down)
if (category.source === "parts-catalogs" && rawData?.source === "parts-catalogs" && category.externalId) {
if (
category.source === "parts-catalogs" &&
rawData?.source === "parts-catalogs" &&
category.externalId
) {
let subGroups: PcatGroup[] = [];
try {
const carParams = this.buildPcatCarParams(rawData.parameters);
@@ -436,7 +485,9 @@ export class CategoriesService {
await this.redis.del(`cat:tree:${category.vehicleId}`);
}
} catch (err) {
this.logger.error(`PartsCatalogs subgroup fetch failed for ${categoryId} (externalId=${category.externalId}): ${(err as Error).message}`);
this.logger.error(
`PartsCatalogs subgroup fetch failed for ${categoryId} (externalId=${category.externalId}): ${(err as Error).message}`,
);
}
if (children.length > 0) {
@@ -459,7 +510,12 @@ export class CategoriesService {
// BOM / servicepart item links are leaf categories — they return parts, not subgroups
const lp = linkPath.toLowerCase();
if (lp.includes("/bom/") || lp.includes("/bomdetails") || lp.includes("/partinfo/") || lp.includes("/servicepart/vin_items")) {
if (
lp.includes("/bom/") ||
lp.includes("/bomdetails") ||
lp.includes("/partinfo/") ||
lp.includes("/servicepart/vin_items")
) {
return [];
}
@@ -549,7 +605,8 @@ export class CategoriesService {
// • json-illustrations.action paths (mid-level main groups → illustration lists)
const isPsaParent =
category.linkPath?.startsWith("psa::") ||
(category.linkPath?.includes("/psa/") && category.linkPath?.includes("json-illustrations.action"));
(category.linkPath?.includes("/psa/") &&
category.linkPath?.includes("json-illustrations.action"));
if (isPsaParent && category.vehicleId) {
const psaChildren = await this.getChildren(categoryId);
return {
@@ -566,10 +623,7 @@ export class CategoriesService {
// Leaf category — get or fetch parts
let discoveredChildren: any[] = [];
let dbParts = await this.db
.select()
.from(parts)
.where(eq(parts.categoryId, categoryId));
let dbParts = await this.db.select().from(parts).where(eq(parts.categoryId, categoryId));
const pics = await this.db
.select()
@@ -586,14 +640,14 @@ export class CategoriesService {
if ((needParts || needImage) && category.linkPath) {
const [vehicle] = category.vehicleId
? await this.db
.select()
.from(vehicles)
.where(eq(vehicles.id, category.vehicleId))
.limit(1)
? await this.db.select().from(vehicles).where(eq(vehicles.id, category.vehicleId)).limit(1)
: [];
if (vehicle && category.source === "parts-catalogs" && category.linkPath?.startsWith("pcat:")) {
if (
vehicle &&
category.source === "parts-catalogs" &&
category.linkPath?.startsWith("pcat:")
) {
// PartsCatalogs: fetch parts + schema image via API
try {
// Format: pcat:{catalogId}:{carId}:{groupId} — split only on first 3 colons
@@ -616,7 +670,12 @@ export class CategoriesService {
if (partsResult) {
// Flatten part groups into parts
if (needParts) {
const rawParts: Array<{ name: string; number: string; notice: string | null; positionNumber: string | null }> = [];
const rawParts: Array<{
name: string;
number: string;
notice: string | null;
positionNumber: string | null;
}> = [];
for (const pg of partsResult.partGroups) {
for (const p of pg.parts) {
if (!p.number) continue;
@@ -642,7 +701,9 @@ export class CategoriesService {
description: p.notice,
quantity: null,
position: p.positionNumber,
hotspotIndex: p.positionNumber ? Number.parseInt(p.positionNumber, 10) || null : null,
hotspotIndex: p.positionNumber
? Number.parseInt(p.positionNumber, 10) || null
: null,
unavailable: false,
remark: null as string | null,
modelCodes: null as string | null,
@@ -652,14 +713,18 @@ export class CategoriesService {
if (allParts.length > 0) {
dbParts = await this.db.insert(parts).values(allParts).returning();
this.logger.log(`Stored ${dbParts.length} PartsCatalogs parts for category ${categoryId}`);
this.logger.log(
`Stored ${dbParts.length} PartsCatalogs parts for category ${categoryId}`,
);
}
}
// Download schema image and store hotspots
if (needImage && partsResult.img) {
try {
const imgUrl = partsResult.img.startsWith("//") ? `https:${partsResult.img}` : partsResult.img;
const imgUrl = partsResult.img.startsWith("//")
? `https:${partsResult.img}`
: partsResult.img;
const imgResp = await fetch(imgUrl, {
signal: AbortSignal.timeout(15000),
});
@@ -705,13 +770,17 @@ export class CategoriesService {
);
}
} catch (imgErr) {
this.logger.warn(`Failed to download PC schema image: ${(imgErr as Error).message}`);
this.logger.warn(
`Failed to download PC schema image: ${(imgErr as Error).message}`,
);
}
}
}
} catch (err) {
const msg = (err as Error).message;
this.logger.error(`Failed to fetch PartsCatalogs parts for category ${categoryId}: ${msg}`);
this.logger.error(
`Failed to fetch PartsCatalogs parts for category ${categoryId}: ${msg}`,
);
// HTTP 400 = upstream API has no direct parts for this group — it may be a parent group
if (msg.includes("HTTP 400")) {
discoveredChildren = await this.getChildren(categoryId);
@@ -720,7 +789,9 @@ export class CategoriesService {
.update(categories)
.set({ unavailable: true })
.where(eq(categories.id, categoryId));
this.logger.warn(`Marked category ${categoryId} as unavailable (empty group from PartsCatalogs)`);
this.logger.warn(
`Marked category ${categoryId} as unavailable (empty group from PartsCatalogs)`,
);
}
}
}
@@ -773,14 +844,15 @@ export class CategoriesService {
});
if (imgResp.ok) {
const buf = Buffer.from(await imgResp.arrayBuffer());
const ext = emexResult.schemaImageUrl.includes('.gif') ? 'gif' : 'png';
const ext = emexResult.schemaImageUrl.includes(".gif") ? "gif" : "png";
const key = `schemas/emex-${categoryId}.${ext}`;
const minioUrl = await this.storage.upload(key, buf, `image/${ext}`);
// Use scraper dimensions (from naturalWidth/Height), fallback to buffer parsing
const dims = (emexResult.schemaWidth && emexResult.schemaHeight)
? { width: emexResult.schemaWidth, height: emexResult.schemaHeight }
: this.getImageDimensions(buf, ext);
const dims =
emexResult.schemaWidth && emexResult.schemaHeight
? { width: emexResult.schemaWidth, height: emexResult.schemaHeight }
: this.getImageDimensions(buf, ext);
// Convert EMEX hotspots to storage format with sequential integer keys
const hotspotItems = emexResult.hotspots.map((hs) => ({
@@ -794,20 +866,30 @@ export class CategoriesService {
.values({
categoryId,
imageUrl: minioUrl,
hotspots: JSON.stringify({ width: dims.width, height: dims.height, items: hotspotItems }),
hotspots: JSON.stringify({
width: dims.width,
height: dims.height,
items: hotspotItems,
}),
source: "emex",
})
.returning();
pics.push(inserted);
this.logger.log(`Stored EMEX schema image in MinIO for category ${categoryId}: ${minioUrl} (${dims.width}x${dims.height})`);
this.logger.log(
`Stored EMEX schema image in MinIO for category ${categoryId}: ${minioUrl} (${dims.width}x${dims.height})`,
);
}
} catch (imgErr) {
this.logger.warn(`Failed to download EMEX schema image: ${(imgErr as Error).message}`);
this.logger.warn(
`Failed to download EMEX schema image: ${(imgErr as Error).message}`,
);
}
}
} catch (err) {
this.logger.error(`Failed to fetch EMEX parts for category ${categoryId}: ${(err as Error).message}`);
this.logger.error(
`Failed to fetch EMEX parts for category ${categoryId}: ${(err as Error).message}`,
);
}
} else if (vehicle) {
// PL24: fetch parts + schema image via PL24 API
@@ -830,12 +912,14 @@ export class CategoriesService {
name: p.name,
nameOriginal: p.name,
description: p.description || null,
quantity: p.quantity ? (Number.parseInt(String(p.quantity), 10) || null) : null,
quantity: p.quantity ? Number.parseInt(String(p.quantity), 10) || null : null,
position: p.positionCode || null,
hotspotIndex: p.hotspotId ? (() => {
const val = Number.parseInt(p.hotspotId!, 10);
return (val > 0 && val <= 2147483647) ? val : null;
})() : null,
hotspotIndex: p.hotspotId
? (() => {
const val = Number.parseInt(p.hotspotId!, 10);
return val > 0 && val <= 2147483647 ? val : null;
})()
: null,
unavailable: p.unavailable || false,
remark: p.remark || null,
modelCodes: p.modelCodes || null,
@@ -850,11 +934,18 @@ export class CategoriesService {
// Store schema image if available
// PSA: if cache hit returned no buffer, re-fetch fresh to get the image
const isPsaBoard = category.linkPath.includes("/psa/") && category.linkPath.includes("image-board.action");
const isPsaBoard =
category.linkPath.includes("/psa/") &&
category.linkPath.includes("image-board.action");
if (needImage && isPsaBoard && !pl24Result.schemaImageBuffer) {
try {
const freshResult = await this.pl24FordLegacyService.fetchPsaParts(
category.linkPath, catalogInfo.serviceName, "_all_", "_all_", "_all_", true,
category.linkPath,
catalogInfo.serviceName,
"_all_",
"_all_",
"_all_",
true,
);
if (freshResult.schemaImageBuffer) {
(pl24Result as any).schemaImageBuffer = freshResult.schemaImageBuffer;
@@ -864,10 +955,15 @@ export class CategoriesService {
(pl24Result as any).hotspots = freshResult.hotspots;
}
} catch (refetchErr) {
this.logger.warn(`PSA image re-fetch failed for ${categoryId}: ${(refetchErr as Error).message}`);
this.logger.warn(
`PSA image re-fetch failed for ${categoryId}: ${(refetchErr as Error).message}`,
);
}
}
if (needImage && (pl24Result.schemaImageBuffer || (!isPsaBoard && pl24Result.schemaImageUrl))) {
if (
needImage &&
(pl24Result.schemaImageBuffer || (!isPsaBoard && pl24Result.schemaImageUrl))
) {
// PSA: image already downloaded as buffer — upload directly to MinIO
if (pl24Result.schemaImageBuffer) {
try {
@@ -890,9 +986,13 @@ export class CategoriesService {
})
.returning();
pics.push(inserted);
this.logger.log(`Stored PSA schema image for category ${categoryId}: ${minioUrl}`);
this.logger.log(
`Stored PSA schema image for category ${categoryId}: ${minioUrl}`,
);
} catch (imgErr) {
this.logger.warn(`Failed to upload PSA schema image: ${(imgErr as Error).message}`);
this.logger.warn(
`Failed to upload PSA schema image: ${(imgErr as Error).message}`,
);
}
} else {
// P5 REST: download via getSchemaImage
@@ -905,9 +1005,10 @@ export class CategoriesService {
const hotspotsData = {
width: imageResult.width || pl24Result.schemaWidth || null,
height: imageResult.height || pl24Result.schemaHeight || null,
items: imageResult.hotspots.length > 0
? imageResult.hotspots
: pl24Result.hotspots || [],
items:
imageResult.hotspots.length > 0
? imageResult.hotspots
: pl24Result.hotspots || [],
};
const [inserted] = await this.db
@@ -925,7 +1026,9 @@ export class CategoriesService {
}
}
} catch (err) {
this.logger.error(`Failed to fetch parts for category ${categoryId}: ${(err as Error).message}`);
this.logger.error(
`Failed to fetch parts for category ${categoryId}: ${(err as Error).message}`,
);
}
}
}
@@ -973,7 +1076,11 @@ export class CategoriesService {
// Transform raw hotspots {key, label?, areas} to frontend format
const mappedHotspots = hotspots.flatMap(
(hs: { key: string; label?: string; areas?: Array<{ left: number; top: number; width: number; height: number }> }) =>
(hs: {
key: string;
label?: string;
areas?: Array<{ left: number; top: number; width: number; height: number }>;
}) =>
(hs.areas || []).map((area, areaIdx) => ({
id: `hs-${hs.key}-${areaIdx}`,
key: hs.key,
@@ -1050,11 +1157,16 @@ export class CategoriesService {
// EMEX: leaf only if linkPath exists and no DB children
// PL24: leaf if BOM/servicepart-items linkPath, or no linkPath and no DB children
// PartsCatalogs: leaf if linkPath starts with "pcat:" and no DB children
const isLeaf = c.source === "emex"
? (!!c.linkPath && dbChildCount === 0)
: c.source === "parts-catalogs"
? (!!c.linkPath?.startsWith("pcat:") && dbChildCount === 0)
: (c.linkPath?.toLowerCase()?.includes("/bom/") || c.linkPath?.toLowerCase()?.includes("/bomdetails") || c.linkPath?.toLowerCase()?.includes("/partinfo/") || c.linkPath?.toLowerCase()?.includes("/servicepart/vin_items") || (!c.linkPath && dbChildCount === 0));
const isLeaf =
c.source === "emex"
? !!c.linkPath && dbChildCount === 0
: c.source === "parts-catalogs"
? !!c.linkPath?.startsWith("pcat:") && dbChildCount === 0
: c.linkPath?.toLowerCase()?.includes("/bom/") ||
c.linkPath?.toLowerCase()?.includes("/bomdetails") ||
c.linkPath?.toLowerCase()?.includes("/partinfo/") ||
c.linkPath?.toLowerCase()?.includes("/servicepart/vin_items") ||
(!c.linkPath && dbChildCount === 0);
return {
...c,
schemaImageUrl: picMap.get(c.id) || null,
@@ -1067,7 +1179,9 @@ export class CategoriesService {
* Build query params from PartsCatalogs car parameters.
* Parameters are [{key, idx, value}] — API expects {key: idx} as query params.
*/
private buildPcatCarParams(parameters?: Array<{ key: string; idx: string; value: string }>): Record<string, string> {
private buildPcatCarParams(
parameters?: Array<{ key: string; idx: string; value: string }>,
): Record<string, string> {
if (!parameters || !Array.isArray(parameters)) return {};
const params: Record<string, string> = {};
for (const p of parameters) {
@@ -1080,11 +1194,11 @@ export class CategoriesService {
private getImageDimensions(buf: Buffer, ext: string): { width: number; height: number } {
try {
if (ext === 'gif' && buf.length >= 10) {
if (ext === "gif" && buf.length >= 10) {
// GIF: width at bytes 6-7, height at bytes 8-9 (little-endian)
return { width: buf.readUInt16LE(6), height: buf.readUInt16LE(8) };
}
if (ext === 'png' && buf.length >= 24) {
if (ext === "png" && buf.length >= 24) {
// PNG: width at bytes 16-19, height at bytes 20-23 (big-endian)
return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
}

View File

@@ -1,4 +1,10 @@
import { type CanActivate, type ExecutionContext, ForbiddenException, Inject, Injectable } from "@nestjs/common";
import {
type CanActivate,
type ExecutionContext,
ForbiddenException,
Inject,
Injectable,
} from "@nestjs/common";
import { and, eq, or } from "drizzle-orm";
import { DATABASE, type Database } from "../../database/database.provider";
import { plans, userBrands, userSubscriptions } from "../../database/schema/core";
@@ -28,7 +34,12 @@ export class BrandAccessGuard implements CanActivate {
})
.from(userSubscriptions)
.innerJoin(plans, eq(userSubscriptions.planId, plans.id))
.where(and(eq(userSubscriptions.userId, user.id), or(eq(userSubscriptions.status, "active"), eq(userSubscriptions.status, "trial"))))
.where(
and(
eq(userSubscriptions.userId, user.id),
or(eq(userSubscriptions.status, "active"), eq(userSubscriptions.status, "trial")),
),
)
.limit(1);
if (activeSub.length === 0) {

View File

@@ -22,7 +22,12 @@ import {
CatalogPrefetchQueueProvider,
PrefetchWorkerService,
],
exports: [EMEX_SCRAPE_QUEUE, SUBSCRIPTION_EXPIRY_QUEUE, QUERY_CLEANUP_QUEUE, CATALOG_PREFETCH_QUEUE],
exports: [
EMEX_SCRAPE_QUEUE,
SUBSCRIPTION_EXPIRY_QUEUE,
QUERY_CLEANUP_QUEUE,
CATALOG_PREFETCH_QUEUE,
],
})
export class JobsModule implements OnModuleInit, OnModuleDestroy {
constructor(

View File

@@ -19,10 +19,7 @@ import {
initProgress,
updateProgress,
} from "./prefetch-utils";
import type {
PrefetchCategoryJobData,
PrefetchInitJobData,
} from "./prefetch.types";
import type { PrefetchCategoryJobData, PrefetchInitJobData } from "./prefetch.types";
import { CATALOG_PREFETCH_QUEUE } from "./queues/catalog-prefetch.queue";
const MAX_DEPTH = 5;
@@ -41,15 +38,11 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
) {}
onModuleInit() {
this.worker = new Worker(
QUEUE_NAMES.CATALOG_PREFETCH,
(job) => this.process(job),
{
connection: getBullConnection(),
concurrency: 1,
limiter: { max: 5, duration: 60_000 },
},
);
this.worker = new Worker(QUEUE_NAMES.CATALOG_PREFETCH, (job) => this.process(job), {
connection: getBullConnection(),
concurrency: 1,
limiter: { max: 5, duration: 60_000 },
});
this.worker.on("failed", (job, err) => {
if (err instanceof RateLimitError) {
@@ -57,9 +50,7 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
`[prefetch] Job ${job?.name} rate-limited, will retry in ${err.retryAfterMs}ms`,
);
} else {
this.logger.warn(
`[prefetch] Job ${job?.name} failed: ${err.message}`,
);
this.logger.warn(`[prefetch] Job ${job?.name} failed: ${err.message}`);
}
});
@@ -125,12 +116,7 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
const topCategories = await this.db
.select()
.from(categories)
.where(
and(
eq(categories.vehicleId, vehicleId),
isNull(categories.parentId),
),
);
.where(and(eq(categories.vehicleId, vehicleId), isNull(categories.parentId)));
if (topCategories.length === 0) {
this.logger.log(`[prefetch] No categories for vehicle=${vehicleId}`);
@@ -203,21 +189,15 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
/**
* Fetch children (sub-categories) for a category.
*/
private async processChildren(
job: Job<PrefetchCategoryJobData>,
): Promise<void> {
private async processChildren(job: Job<PrefetchCategoryJobData>): Promise<void> {
const { vehicleId, categoryId, source, depth } = job.data;
this.logger.log(
`[prefetch] Children for category=${categoryId}, depth=${depth}`,
);
this.logger.log(`[prefetch] Children for category=${categoryId}, depth=${depth}`);
await checkCooldown(this.redis, source);
checkTimeWindow(source);
if (depth >= MAX_DEPTH) {
this.logger.warn(
`[prefetch] Max depth reached for category=${categoryId}`,
);
this.logger.warn(`[prefetch] Max depth reached for category=${categoryId}`);
return;
}
@@ -234,9 +214,8 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
if (queued > 0) {
await updateProgress(this.redis, vehicleId, {
total:
((await this.redis.getJson<{ total: number }>(
`prefetch:progress:${vehicleId}`,
))?.total || 0) + queued,
((await this.redis.getJson<{ total: number }>(`prefetch:progress:${vehicleId}`))
?.total || 0) + queued,
});
}
@@ -253,9 +232,7 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
/**
* Fetch parts + schema for a leaf category.
*/
private async processParts(
job: Job<PrefetchCategoryJobData>,
): Promise<void> {
private async processParts(job: Job<PrefetchCategoryJobData>): Promise<void> {
const { vehicleId, categoryId, source } = job.data;
this.logger.log(`[prefetch] Parts for category=${categoryId}`);
@@ -345,10 +322,7 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
);
}
private async addJob(
name: string,
data: PrefetchCategoryJobData,
): Promise<void> {
private async addJob(name: string, data: PrefetchCategoryJobData): Promise<void> {
const opts: Record<string, unknown> = {
jobId: `prefetch:${data.vehicleId}:${data.categoryId}:${data.action}`,
};
@@ -384,9 +358,7 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
}
private async incrementErrors(vehicleId: string): Promise<void> {
const progress = await this.redis.getJson<{ errors: number }>(
`prefetch:progress:${vehicleId}`,
);
const progress = await this.redis.getJson<{ errors: number }>(`prefetch:progress:${vehicleId}`);
if (!progress) return;
await updateProgress(this.redis, vehicleId, {
errors: (progress.errors || 0) + 1,

View File

@@ -15,10 +15,7 @@ export class PartsService {
async getByCategory(categoryId: string) {
// Check DB first
let dbParts = await this.db
.select()
.from(parts)
.where(eq(parts.categoryId, categoryId));
let dbParts = await this.db.select().from(parts).where(eq(parts.categoryId, categoryId));
if (dbParts.length > 0) return dbParts;
@@ -52,10 +49,7 @@ export class PartsService {
}
// Use fetchPartsByPath which handles the actual PL24 BOM endpoint
const pl24Result = await this.pl24Service.fetchPartsByPath(
linkPath,
catalogInfo.serviceName,
);
const pl24Result = await this.pl24Service.fetchPartsByPath(linkPath, catalogInfo.serviceName);
if (pl24Result.parts.length > 0) {
const insertData = pl24Result.parts.map((p) => ({
@@ -65,12 +59,14 @@ export class PartsService {
name: p.name,
nameOriginal: p.name,
description: p.description || null,
quantity: p.quantity ? (Number.parseInt(String(p.quantity), 10) || null) : null,
quantity: p.quantity ? Number.parseInt(String(p.quantity), 10) || null : null,
position: p.positionCode || null,
hotspotIndex: p.hotspotId ? (() => {
const val = Number.parseInt(p.hotspotId!, 10);
return (val > 0 && val <= 2147483647) ? val : null;
})() : null,
hotspotIndex: p.hotspotId
? (() => {
const val = Number.parseInt(p.hotspotId!, 10);
return val > 0 && val <= 2147483647 ? val : null;
})()
: null,
unavailable: p.unavailable || false,
remark: p.remark || null,
modelCodes: p.modelCodes || null,
@@ -102,9 +98,8 @@ export class PartsService {
const hotspotsData = {
width: imageResult.width || pl24Result.schemaWidth || null,
height: imageResult.height || pl24Result.schemaHeight || null,
items: imageResult.hotspots.length > 0
? imageResult.hotspots
: pl24Result.hotspots || [],
items:
imageResult.hotspots.length > 0 ? imageResult.hotspots : pl24Result.hotspots || [],
};
await this.db.insert(schemaPics).values({
@@ -115,7 +110,9 @@ export class PartsService {
});
}
} catch (err) {
this.logger.error(`Failed to store schema image for category ${categoryId}: ${(err as Error).message}`);
this.logger.error(
`Failed to store schema image for category ${categoryId}: ${(err as Error).message}`,
);
}
}
}

View File

@@ -25,7 +25,12 @@ export class PaymentsController {
@CurrentUser("id") userId: string,
@Body() body: { planKey: string; billingPeriod: "monthly" | "yearly"; brandIds: string[] },
) {
return this.paymentsService.initializeIyzico(userId, body.planKey, body.billingPeriod, body.brandIds);
return this.paymentsService.initializeIyzico(
userId,
body.planKey,
body.billingPeriod,
body.brandIds,
);
}
@Post("iyzico/callback")
@@ -44,7 +49,12 @@ export class PaymentsController {
@CurrentUser("id") userId: string,
@Body() body: { planKey: string; billingPeriod: "monthly" | "yearly"; brandIds: string[] },
) {
return this.paymentsService.createEftPayment(userId, body.planKey, body.billingPeriod, body.brandIds);
return this.paymentsService.createEftPayment(
userId,
body.planKey,
body.billingPeriod,
body.brandIds,
);
}
@Post("eft/:id/receipt")

View File

@@ -1,10 +1,4 @@
import {
BadRequestException,
Inject,
Injectable,
Logger,
NotFoundException,
} from "@nestjs/common";
import { BadRequestException, Inject, Injectable, Logger, NotFoundException } from "@nestjs/common";
import type { ConfigService } from "@nestjs/config";
import { and, desc, eq } from "drizzle-orm";
import { DATABASE, type Database } from "../database/database.provider";
@@ -32,7 +26,8 @@ export class PaymentsService {
private async resolvePlanId(planKey: string): Promise<string> {
const brandCount = PLAN_KEY_TO_BRAND_COUNT[planKey];
if (brandCount === undefined) throw new BadRequestException(`Geçersiz plan anahtarı: ${planKey}`);
if (brandCount === undefined)
throw new BadRequestException(`Geçersiz plan anahtarı: ${planKey}`);
const [plan] = await this.db
.select()
@@ -51,7 +46,11 @@ export class PaymentsService {
brandIds: string[],
) {
const planId = await this.resolvePlanId(planKey);
const subscription = await this.subscriptionsService.create(userId, { planId, brandIds, billingPeriod });
const subscription = await this.subscriptionsService.create(userId, {
planId,
brandIds,
billingPeriod,
});
if (brandIds.length > 0 && planKey !== "full") {
await this.subscriptionsService.addBrandsToSubscription(subscription.id, userId, brandIds);
@@ -60,12 +59,20 @@ export class PaymentsService {
return subscription;
}
async initializeIyzico(userId: string, planKey: string, billingPeriod: "monthly" | "yearly", brandIds: string[]) {
async initializeIyzico(
userId: string,
planKey: string,
billingPeriod: "monthly" | "yearly",
brandIds: string[],
) {
const sub = await this.createSubscriptionForPayment(userId, planKey, billingPeriod, brandIds);
const amount = billingPeriod === "yearly"
? (await this.db.select().from(plans).where(eq(plans.id, sub.planId)).limit(1))[0].priceYearly
: (await this.db.select().from(plans).where(eq(plans.id, sub.planId)).limit(1))[0].priceMonthly;
const amount =
billingPeriod === "yearly"
? (await this.db.select().from(plans).where(eq(plans.id, sub.planId)).limit(1))[0]
.priceYearly
: (await this.db.select().from(plans).where(eq(plans.id, sub.planId)).limit(1))[0]
.priceMonthly;
const [payment] = await this.db
.insert(payments)
@@ -115,7 +122,12 @@ export class PaymentsService {
return { status: newStatus };
}
async createEftPayment(userId: string, planKey: string, billingPeriod: "monthly" | "yearly", brandIds: string[]) {
async createEftPayment(
userId: string,
planKey: string,
billingPeriod: "monthly" | "yearly",
brandIds: string[],
) {
const sub = await this.createSubscriptionForPayment(userId, planKey, billingPeriod, brandIds);
const [plan] = await this.db.select().from(plans).where(eq(plans.id, sub.planId)).limit(1);

View File

@@ -28,7 +28,13 @@ export class PlansController {
@Roles("admin")
async update(
@Param("id") id: string,
@Body() body: { name?: string; brandCount?: number; priceMonthly?: number; priceYearly?: number; isActive?: boolean },
@Body() body: {
name?: string;
brandCount?: number;
priceMonthly?: number;
priceYearly?: number;
isActive?: boolean;
},
) {
return this.plansService.update(id, body);
}

View File

@@ -9,7 +9,11 @@ export class PlansService {
async findAll(activeOnly = true) {
if (activeOnly) {
return this.db.select().from(plans).where(eq(plans.isActive, true)).orderBy(plans.priceMonthly);
return this.db
.select()
.from(plans)
.where(eq(plans.isActive, true))
.orderBy(plans.priceMonthly);
}
return this.db.select().from(plans).orderBy(plans.priceMonthly);
}
@@ -20,14 +24,25 @@ export class PlansService {
return result[0];
}
async create(data: { name: string; brandCount: number; priceMonthly: number; priceYearly: number }) {
async create(data: {
name: string;
brandCount: number;
priceMonthly: number;
priceYearly: number;
}) {
const [plan] = await this.db.insert(plans).values(data).returning();
return plan;
}
async update(
id: string,
data: { name?: string; brandCount?: number; priceMonthly?: number; priceYearly?: number; isActive?: boolean },
data: {
name?: string;
brandCount?: number;
priceMonthly?: number;
priceYearly?: number;
isActive?: boolean;
},
) {
const [plan] = await this.db
.update(plans)

View File

@@ -17,10 +17,7 @@ export class ReferralsController {
}
@Post("apply")
async applyCode(
@CurrentUser("id") userId: string,
@Body() body: { code: string },
) {
async applyCode(@CurrentUser("id") userId: string, @Body() body: { code: string }) {
return this.referralsService.applyReferralCode(userId, body.code);
}
}

View File

@@ -58,7 +58,8 @@ export class ReferralsService {
.limit(1);
if (!referrer) throw new NotFoundException("Geçersiz referans kodu");
if (referrer.id === userId) throw new BadRequestException("Kendi referans kodunuzu kullanamazsınız");
if (referrer.id === userId)
throw new BadRequestException("Kendi referans kodunuzu kullanamazsınız");
// Check if already referred
const existing = await this.db

View File

@@ -13,7 +13,10 @@ import { brands, plans, userBrands, userSubscriptions } from "../database/schema
export class SubscriptionsService {
constructor(@Inject(DATABASE) private db: Database) {}
async create(userId: string, data: { planId: string; brandIds: string[]; billingPeriod: "monthly" | "yearly" }) {
async create(
userId: string,
data: { planId: string; brandIds: string[]; billingPeriod: "monthly" | "yearly" },
) {
// Check for existing active subscription
const existing = await this.db
.select()
@@ -48,10 +51,7 @@ export class SubscriptionsService {
// Validate brands exist
if (data.brandIds.length > 0) {
const existingBrands = await this.db
.select()
.from(brands)
.where(eq(brands.isActive, true));
const existingBrands = await this.db.select().from(brands).where(eq(brands.isActive, true));
const validBrandIds = new Set(existingBrands.map((b) => b.id));
for (const brandId of data.brandIds) {
if (!validBrandIds.has(brandId)) {
@@ -104,18 +104,11 @@ export class SubscriptionsService {
.returning();
// Get the plan to determine brands
const plan = await this.db
.select()
.from(plans)
.where(eq(plans.id, sub.planId))
.limit(1);
const plan = await this.db.select().from(plans).where(eq(plans.id, sub.planId)).limit(1);
// If Full plan (brandCount=0), add all active brands
if (plan[0]?.brandCount === 0) {
const allBrands = await this.db
.select()
.from(brands)
.where(eq(brands.isActive, true));
const allBrands = await this.db.select().from(brands).where(eq(brands.isActive, true));
if (allBrands.length > 0) {
await this.db.insert(userBrands).values(
@@ -300,10 +293,7 @@ export class SubscriptionsService {
.returning();
// Add all active brands to userBrands
const allBrands = await this.db
.select()
.from(brands)
.where(eq(brands.isActive, true));
const allBrands = await this.db.select().from(brands).where(eq(brands.isActive, true));
if (allBrands.length > 0) {
await this.db.insert(userBrands).values(

View File

@@ -1,13 +1,4 @@
import {
Body,
Controller,
Get,
Param,
Post,
Put,
Query,
UseGuards,
} from "@nestjs/common";
import { Body, Controller, Get, Param, Post, Put, Query, UseGuards } from "@nestjs/common";
import { Public } from "../common/decorators/public.decorator";
import { Roles } from "../common/decorators/roles.decorator";
import { RolesGuard } from "../common/guards/roles.guard";
@@ -31,9 +22,7 @@ export class TranslationsController {
}
@Post("batch")
async translateBatch(
@Body() body: { items: { key: string; sourceText: string }[] },
) {
async translateBatch(@Body() body: { items: { key: string; sourceText: string }[] }) {
return this.translationsService.translateBatch(body.items);
}
@@ -44,10 +33,6 @@ export class TranslationsController {
@Param("key") key: string,
@Body() body: { sourceText: string; translatedText: string },
) {
return this.translationsService.setTranslation(
key,
body.sourceText,
body.translatedText,
);
return this.translationsService.setTranslation(key, body.sourceText, body.translatedText);
}
}

View File

@@ -401,9 +401,7 @@ export class TranslationsService {
* Batch translate — kept for backwards compatibility with the controller's
* /translations/batch endpoint. Internally uses translateMany.
*/
async translateBatch(
items: { key: string; sourceText: string }[],
): Promise<TranslationResult[]> {
async translateBatch(items: { key: string; sourceText: string }[]): Promise<TranslationResult[]> {
if (!items.length) return [];
const trMap = await this.translateMany(items.map((i) => i.sourceText));
return items.map((i) => {

View File

@@ -1,4 +1,14 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, UseGuards } from "@nestjs/common";
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Query,
UseGuards,
} from "@nestjs/common";
import { CurrentUser } from "../common/decorators/current-user.decorator";
import { Public } from "../common/decorators/public.decorator";
import { Roles } from "../common/decorators/roles.decorator";
@@ -35,10 +45,7 @@ export class UsersController {
}
@Delete("me/connections/:provider")
async unlinkConnection(
@CurrentUser("id") userId: string,
@Param("provider") provider: string,
) {
async unlinkConnection(@CurrentUser("id") userId: string, @Param("provider") provider: string) {
return this.usersService.unlinkConnection(userId, provider);
}

View File

@@ -49,12 +49,7 @@ export class UsersService {
async unlinkConnection(userId: string, provider: string) {
const result = await this.db
.delete(accounts)
.where(
and(
eq(accounts.userId, userId),
eq(accounts.providerId, provider),
),
)
.where(and(eq(accounts.userId, userId), eq(accounts.providerId, provider)))
.returning();
if (result.length === 0) {
@@ -67,12 +62,7 @@ export class UsersService {
const [account] = await this.db
.select()
.from(accounts)
.where(
and(
eq(accounts.userId, userId),
eq(accounts.providerId, "credential"),
),
)
.where(and(eq(accounts.userId, userId), eq(accounts.providerId, "credential")))
.limit(1);
if (!account?.password) {
@@ -98,10 +88,7 @@ export class UsersService {
}
async deleteAccount(userId: string) {
const result = await this.db
.delete(users)
.where(eq(users.id, userId))
.returning();
const result = await this.db.delete(users).where(eq(users.id, userId)).returning();
if (result.length === 0) {
throw new NotFoundException("Kullanıcı bulunamadı");
@@ -117,5 +104,4 @@ export class UsersService {
]);
return { items, total: countResult.length };
}
}

View File

@@ -66,10 +66,7 @@ export class VehiclesController {
}
@Get(":vehicleId/prefetch-status")
async prefetchStatus(
@Param("vehicleId") vehicleId: string,
@CurrentUser("id") userId: string,
) {
async prefetchStatus(@Param("vehicleId") vehicleId: string, @CurrentUser("id") userId: string) {
await this.vehiclesService.getById(vehicleId, userId);
return this.vehiclesService.getPrefetchStatus(vehicleId);
}

View File

@@ -71,11 +71,7 @@ export class VehiclesService {
}
// 1. Check for shared vehicle config by VIN (no userId filter)
const [existing] = await this.db
.select()
.from(vehicles)
.where(eq(vehicles.vin, vin))
.limit(1);
const [existing] = await this.db.select().from(vehicles).where(eq(vehicles.vin, vin)).limit(1);
if (existing && !pcatCarId) {
// DB'de kayıtlı araç varsa doğrudan dön — yaş kontrolü yok
@@ -91,7 +87,15 @@ export class VehiclesService {
const resolved = await this.resolveVin(vin, pcatCarId, emexCarIndex, userId);
if (!resolved) {
await this.logQuery(userId, vin, null, "corgi", false, Date.now() - startTime, "Unknown VIN/brand");
await this.logQuery(
userId,
vin,
null,
"corgi",
false,
Date.now() - startTime,
"Unknown VIN/brand",
);
throw new BadRequestException("Şase numarası tanınamadı. Marka desteklenmiyor.");
}
@@ -214,7 +218,12 @@ export class VehiclesService {
* @param pcatCarId If provided, skip resolve chain and use this specific PC car
* @param emexCarIndex If provided, skip resolve chain and use this EMEX candidate index
*/
private async resolveVin(vin: string, pcatCarId?: string, emexCarIndex?: number, userId?: string): Promise<VinResolveResult | null> {
private async resolveVin(
vin: string,
pcatCarId?: string,
emexCarIndex?: number,
userId?: string,
): Promise<VinResolveResult | null> {
// If user selected a specific PC car from candidates, resolve directly
if (pcatCarId) {
return this.resolvePcatCarById(vin, pcatCarId);
@@ -237,19 +246,15 @@ export class VehiclesService {
// ── Parallel: PartsCatalogs + EMEX (EMEX capped at 3s) ──────────────
const EMEX_RACE_MS = 3000;
const pcatPromise = this.partsCatalogsService
.decodeVin(vin)
.catch((err: Error) => {
this.logger.warn(`PartsCatalogs decode failed for ${vin}: ${err.message}`);
return null;
});
const pcatPromise = this.partsCatalogsService.decodeVin(vin).catch((err: Error) => {
this.logger.warn(`PartsCatalogs decode failed for ${vin}: ${err.message}`);
return null;
});
const emexBasePromise = this.emexService
.decodeVinOrCandidates(vin)
.catch((err: Error) => {
this.logger.warn(`EMEX decode failed for ${vin}: ${err.message}`);
return null;
});
const emexBasePromise = this.emexService.decodeVinOrCandidates(vin).catch((err: Error) => {
this.logger.warn(`EMEX decode failed for ${vin}: ${err.message}`);
return null;
});
// Emex result capped at 3s — if it doesn't arrive in time, falls back to PL24
const emexTimedPromise = Promise.race([
@@ -382,7 +387,10 @@ export class VehiclesService {
/**
* Resolve a specific PartsCatalogs car by ID (after user selects from candidates).
*/
private async resolvePcatCarById(vin: string, pcatCarId: string): Promise<VinResolveResult | null> {
private async resolvePcatCarById(
vin: string,
pcatCarId: string,
): Promise<VinResolveResult | null> {
const corgiResult = null;
const corgiKnown = false;
let brandName: string | null = null;
@@ -422,7 +430,10 @@ export class VehiclesService {
* Resolve a specific EMEX vehicle by its candidate index
* (after user selects from the multi-candidate modal).
*/
private async resolveEmexCarByIndex(vin: string, index: number): Promise<VinResolveResult | null> {
private async resolveEmexCarByIndex(
vin: string,
index: number,
): Promise<VinResolveResult | null> {
const corgiResult = null;
const corgiKnown = false;
@@ -452,17 +463,37 @@ export class VehiclesService {
/** Map common parts-catalogs catalog IDs to brand display names */
private static readonly PCAT_CATALOG_BRAND_MAP: Record<string, string> = {
vw: "Volkswagen", volkswagen: "Volkswagen",
bmw: "BMW", mercedes: "Mercedes-Benz", "mercedes-benz": "Mercedes-Benz",
audi: "Audi", porsche: "Porsche", skoda: "Skoda",
seat: "Seat", ford: "Ford", opel: "Opel",
renault: "Renault", peugeot: "Peugeot", citroen: "Citroen",
fiat: "Fiat", toyota: "Toyota", honda: "Honda",
hyundai: "Hyundai", kia: "Kia", nissan: "Nissan",
mazda: "Mazda", subaru: "Subaru", volvo: "Volvo",
jaguar: "Jaguar", "land-rover": "Land Rover", landrover: "Land Rover",
mini: "Mini", dacia: "Dacia", suzuki: "Suzuki",
mitsubishi: "Mitsubishi", chevrolet: "Chevrolet",
vw: "Volkswagen",
volkswagen: "Volkswagen",
bmw: "BMW",
mercedes: "Mercedes-Benz",
"mercedes-benz": "Mercedes-Benz",
audi: "Audi",
porsche: "Porsche",
skoda: "Skoda",
seat: "Seat",
ford: "Ford",
opel: "Opel",
renault: "Renault",
peugeot: "Peugeot",
citroen: "Citroen",
fiat: "Fiat",
toyota: "Toyota",
honda: "Honda",
hyundai: "Hyundai",
kia: "Kia",
nissan: "Nissan",
mazda: "Mazda",
subaru: "Subaru",
volvo: "Volvo",
jaguar: "Jaguar",
"land-rover": "Land Rover",
landrover: "Land Rover",
mini: "Mini",
dacia: "Dacia",
suzuki: "Suzuki",
mitsubishi: "Mitsubishi",
chevrolet: "Chevrolet",
};
private extractBrandFromPcatCar(car: PcatCar): string | null {
@@ -587,11 +618,18 @@ export class VehiclesService {
})
.from(userSubscriptions)
.innerJoin(plans, eq(userSubscriptions.planId, plans.id))
.where(and(eq(userSubscriptions.userId, userId), or(eq(userSubscriptions.status, "active"), eq(userSubscriptions.status, "trial"))))
.where(
and(
eq(userSubscriptions.userId, userId),
or(eq(userSubscriptions.status, "active"), eq(userSubscriptions.status, "trial")),
),
)
.limit(1);
if (!sub) {
throw new ForbiddenException("Aktif aboneliğiniz yok. Araç verilerine erişmek için abone olun.");
throw new ForbiddenException(
"Aktif aboneliğiniz yok. Araç verilerine erişmek için abone olun.",
);
}
// brandCount === 0 means unlimited (Full Paket) — skip per-brand check

View File

@@ -95,7 +95,9 @@ export function HotspotOverlay({ hotspots, imageWidth, imageHeight }: HotspotOve
const isDark = useIsDark();
return (
<svg role="img" aria-label="icon"
<svg
role="img"
aria-label="icon"
className="absolute inset-0 h-full w-full"
viewBox={`0 0 ${imageWidth} ${imageHeight}`}
preserveAspectRatio="xMidYMid meet"

View File

@@ -8,6 +8,7 @@ import { HotspotOverlay } from "./hotspot-overlay";
import { PartsPanel } from "./parts-panel";
import { SchemaToolbar } from "./schema-toolbar";
import { KEYS_8 } from "@/lib/keys";
interface SchemaViewerProps {
schemaPic: SchemaPic | null;
hotspots: Hotspot[];
@@ -76,8 +77,8 @@ export function SchemaViewer({
</div>
<div className="w-full space-y-3 p-4 md:w-[40%]">
<Skeleton className="h-6 w-1/2" />
{Array.from({ length: 8 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
{KEYS_8.map((__k) => (
<Skeleton key={__k} className="h-10 w-full" />
))}
</div>
</div>

97
apps/web/src/lib/keys.ts Normal file
View File

@@ -0,0 +1,97 @@
/**
* Stable React `key` arrays for fixed-length skeleton/decorative lists.
*
* Lets us replace `Array.from({ length: N }).map((_, i) => <… key={i} />)`
* (which Biome flags via lint/suspicious/noArrayIndexKey) with
* `KEYS_N.map((k) => <… key={k} />)` — the keys are module-level constants
* so they don't change identity across renders.
*
* If you need a length not listed here, add a new export — they cost nothing.
*/
export const KEYS_4 = ["k0", "k1", "k2", "k3"];
export const KEYS_5 = ["k0", "k1", "k2", "k3", "k4"];
export const KEYS_6 = ["k0", "k1", "k2", "k3", "k4", "k5"];
export const KEYS_8 = ["k0", "k1", "k2", "k3", "k4", "k5", "k6", "k7"];
export const KEYS_9 = ["k0", "k1", "k2", "k3", "k4", "k5", "k6", "k7", "k8"];
export const KEYS_10 = ["k0", "k1", "k2", "k3", "k4", "k5", "k6", "k7", "k8", "k9"];
export const KEYS_16 = [
"k0",
"k1",
"k2",
"k3",
"k4",
"k5",
"k6",
"k7",
"k8",
"k9",
"k10",
"k11",
"k12",
"k13",
"k14",
"k15",
];
export const KEYS_17 = [
"k0",
"k1",
"k2",
"k3",
"k4",
"k5",
"k6",
"k7",
"k8",
"k9",
"k10",
"k11",
"k12",
"k13",
"k14",
"k15",
"k16",
];
export const KEYS_25 = [
"k0",
"k1",
"k2",
"k3",
"k4",
"k5",
"k6",
"k7",
"k8",
"k9",
"k10",
"k11",
"k12",
"k13",
"k14",
"k15",
"k16",
"k17",
"k18",
"k19",
"k20",
"k21",
"k22",
"k23",
"k24",
];
/**
* Get a stable key list of arbitrary length. Use this when the count is
* dynamic (e.g. derived from props/state). The result is memoized per length.
*/
const dynamicCache = new Map<number, string[]>();
export function dynamicKeys(length: number): string[] {
let cached = dynamicCache.get(length);
if (!cached) {
cached = Array.from({ length }, (_, i) => `k${i}`);
dynamicCache.set(length, cached);
}
return cached;
}
/** Backwards-compat alias used by a few earlier call sites. */
export const BRAND_SKELETON_KEYS = KEYS_8;

View File

@@ -92,7 +92,9 @@ const VinInputScene: React.FC<{ isDark: boolean }> = ({ isDark }) => {
border: `1px solid ${c.border}`,
}}
>
<svg role="img" aria-label="icon"
<svg
role="img"
aria-label="icon"
width="14"
height="14"
viewBox="0 0 24 24"
@@ -190,7 +192,9 @@ const VehicleInfoScene: React.FC<{ isDark: boolean }> = ({ isDark }) => {
marginBottom: 16,
}}
>
<svg role="img" aria-label="icon"
<svg
role="img"
aria-label="icon"
width="14"
height="14"
viewBox="0 0 24 24"
@@ -366,10 +370,10 @@ const SchemaViewScene: React.FC<{ isDark: boolean }> = ({ isDark }) => {
const c = getColors(isDark);
const HOTSPOTS = [
{ x: "30%", y: "35%" },
{ x: "60%", y: "25%" },
{ x: "45%", y: "60%" },
{ x: "70%", y: "55%" },
{ id: "hs-1", x: "30%", y: "35%" },
{ id: "hs-2", x: "60%", y: "25%" },
{ id: "hs-3", x: "45%", y: "60%" },
{ id: "hs-4", x: "70%", y: "55%" },
];
return (
@@ -468,7 +472,7 @@ const SchemaViewScene: React.FC<{ isDark: boolean }> = ({ isDark }) => {
return (
<div
key={i}
key={spot.id}
style={{
position: "absolute",
left: spot.x,

View File

@@ -84,7 +84,9 @@ const StorefrontScene: React.FC<{ isDark: boolean }> = ({ isDark }) => {
border: `1px solid ${c.border}`,
}}
>
<svg role="img" aria-label="icon"
<svg
role="img"
aria-label="icon"
width="12"
height="12"
viewBox="0 0 24 24"
@@ -109,7 +111,9 @@ const StorefrontScene: React.FC<{ isDark: boolean }> = ({ isDark }) => {
</div>
{/* Cart icon */}
<div style={{ position: "relative" }}>
<svg role="img" aria-label="icon"
<svg
role="img"
aria-label="icon"
width="16"
height="16"
viewBox="0 0 24 24"
@@ -328,7 +332,9 @@ const WidgetIntegrationScene: React.FC<{ isDark: boolean }> = ({ isDark }) => {
minWidth: 0,
}}
>
<svg role="img" aria-label="icon"
<svg
role="img"
aria-label="icon"
width="12"
height="12"
viewBox="0 0 24 24"
@@ -390,7 +396,9 @@ const WidgetIntegrationScene: React.FC<{ isDark: boolean }> = ({ isDark }) => {
{/* Cart icon */}
<div style={{ position: "relative", flexShrink: 0 }}>
<svg role="img" aria-label="icon"
<svg
role="img"
aria-label="icon"
width="16"
height="16"
viewBox="0 0 24 24"
@@ -572,7 +580,9 @@ const VinFilterScene: React.FC<{ isDark: boolean }> = ({ isDark }) => {
minWidth: 0,
}}
>
<svg role="img" aria-label="icon"
<svg
role="img"
aria-label="icon"
width="12"
height="12"
viewBox="0 0 24 24"
@@ -643,7 +653,9 @@ const VinFilterScene: React.FC<{ isDark: boolean }> = ({ isDark }) => {
{/* Cart */}
<div style={{ position: "relative", flexShrink: 0 }}>
<svg role="img" aria-label="icon"
<svg
role="img"
aria-label="icon"
width="16"
height="16"
viewBox="0 0 24 24"
@@ -906,7 +918,9 @@ const AddToCartScene: React.FC<{ isDark: boolean }> = ({ isDark }) => {
{/* Cart icon with animated badge */}
<div style={{ position: "relative", flexShrink: 0 }}>
<svg role="img" aria-label="icon"
<svg
role="img"
aria-label="icon"
width="16"
height="16"
viewBox="0 0 24 24"

View File

@@ -81,7 +81,7 @@ export const OnboardingProgress: React.FC<{
return (
<div
key={i}
key={label}
style={{
position: "absolute",
opacity: labelOpacity,
@@ -152,7 +152,7 @@ export const OnboardingProgress: React.FC<{
const circleSize = 28;
return (
<div key={i}>
<div key={step.label}>
{/* Outer circle */}
<div
style={{
@@ -181,7 +181,9 @@ export const OnboardingProgress: React.FC<{
}}
/>
{/* Checkmark */}
<svg role="img" aria-label="icon"
<svg
role="img"
aria-label="icon"
viewBox="0 0 24 24"
width={14}
height={14}

View File

@@ -67,7 +67,9 @@ function AuthLayout() {
1.2sn Sorgu
</span>
<span className="inline-flex items-center gap-1.5 rounded-full border border-border bg-surface/60 px-3 py-1.5 text-xs text-foreground/80 backdrop-blur-sm">
<svg role="img" aria-label="icon"
<svg
role="img"
aria-label="icon"
className="size-3"
viewBox="0 0 24 24"
fill="none"

View File

@@ -1,5 +1,6 @@
import { useAuth } from "@/hooks/use-auth";
import { useTranslation } from "@/lib/i18n";
import { KEYS_5 } from "@/lib/keys";
import { capture, resetUser } from "@/lib/posthog";
import { getUserSettings, setUserSetting } from "@/lib/user-settings";
import { Button, Separator, Skeleton } from "@sase/ui";
@@ -164,8 +165,8 @@ function DashboardLayout() {
<div className="flex min-h-screen">
<div className="hidden w-64 border-r border-border bg-background p-4 lg:block">
<Skeleton className="mb-8 h-8 w-32" />
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={`nav-skel-${i}`} className="mb-3 h-10 w-full" />
{KEYS_5.map((__k) => (
<Skeleton key={__k} className="mb-3 h-10 w-full" />
))}
</div>
<div className="flex-1 p-6">

View File

@@ -11,6 +11,7 @@ import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { Activity, CheckCircle, ChevronLeft, ChevronRight, Search, X, XCircle } from "lucide-react";
import { useEffect, useState } from "react";
import { KEYS_10 } from "@/lib/keys";
export const Route = createFileRoute("/dashboard/admin/analytics")({
component: AdminAnalyticsPage,
});
@@ -126,8 +127,8 @@ function AdminAnalyticsPage() {
{/* Table */}
{isLoading ? (
<div className="space-y-3">
{Array.from({ length: 10 }).map((_, i) => (
<Skeleton key={`log-skeleton-${i}`} className="h-12 w-full" />
{KEYS_10.map((__k) => (
<Skeleton key={__k} className="h-12 w-full" />
))}
</div>
) : !data || data.items.length === 0 ? (

View File

@@ -11,6 +11,7 @@ import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { ChevronLeft, ChevronRight, Copy, Search, TrendingUp, X } from "lucide-react";
import { useEffect, useState } from "react";
import { KEYS_10 } from "@/lib/keys";
export const Route = createFileRoute("/dashboard/admin/copy-logs")({
component: AdminCopyLogsPage,
});
@@ -152,8 +153,8 @@ function AdminCopyLogsPage() {
{/* Table */}
{isLoading ? (
<div className="space-y-3">
{Array.from({ length: 10 }).map((_, i) => (
<Skeleton key={`copy-skel-${i}`} className="h-12 w-full" />
{KEYS_10.map((__k) => (
<Skeleton key={__k} className="h-12 w-full" />
))}
</div>
) : !data || data.items.length === 0 ? (
@@ -250,8 +251,8 @@ function AdminCopyLogsPage() {
{tab === "top" &&
(topLoading ? (
<div className="space-y-3">
{Array.from({ length: 10 }).map((_, i) => (
<Skeleton key={`top-skel-${i}`} className="h-12 w-full" />
{KEYS_10.map((__k) => (
<Skeleton key={__k} className="h-12 w-full" />
))}
</div>
) : !topCodes || topCodes.length === 0 ? (

View File

@@ -1,6 +1,7 @@
import { useAuth } from "@/hooks/use-auth";
import { api } from "@/lib/api-client";
import { useTranslation } from "@/lib/i18n";
import { KEYS_6 } from "@/lib/keys";
import { Card, CardContent } from "@sase/ui";
import { Skeleton } from "@sase/ui";
import { Badge } from "@sase/ui";
@@ -75,8 +76,8 @@ function AdminDashboardPage() {
<div className="space-y-6">
<Skeleton className="h-8 w-48" />
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={`skeleton-${i}`} className="h-32" />
{KEYS_6.map((__k) => (
<Skeleton key={__k} className="h-32" />
))}
</div>
</div>
@@ -188,8 +189,8 @@ function AdminDashboardPage() {
{/* Stat Cards */}
{statsLoading ? (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={`stat-skeleton-${i}`} className="h-32" />
{KEYS_6.map((__k) => (
<Skeleton key={__k} className="h-32" />
))}
</div>
) : (

View File

@@ -10,6 +10,7 @@ import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { AlertTriangle, CheckCircle, ExternalLink, Receipt, XCircle } from "lucide-react";
import { useEffect, useState } from "react";
import { KEYS_5 } from "@/lib/keys";
export const Route = createFileRoute("/dashboard/admin/payments")({
component: AdminPaymentsPage,
});
@@ -106,8 +107,8 @@ function AdminPaymentsPage() {
{isLoading ? (
<div className="space-y-4">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={`payment-skeleton-${i}`} className="h-32 w-full" />
{KEYS_5.map((__k) => (
<Skeleton key={__k} className="h-32 w-full" />
))}
</div>
) : !payments || payments.length === 0 ? (

View File

@@ -1,5 +1,6 @@
import { useAuth } from "@/hooks/use-auth";
import { api } from "@/lib/api-client";
import { KEYS_6 } from "@/lib/keys";
import { Card, CardContent } from "@sase/ui";
import { Button } from "@sase/ui";
import { Input } from "@sase/ui";
@@ -180,8 +181,8 @@ function AdminReferralsPage() {
{/* Referrers List */}
{isLoading ? (
<div className="space-y-3">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={`ref-skeleton-${i}`} className="h-20 w-full" />
{KEYS_6.map((__k) => (
<Skeleton key={__k} className="h-20 w-full" />
))}
</div>
) : !data || data.items.length === 0 ? (

View File

@@ -1,6 +1,7 @@
import { useAuth } from "@/hooks/use-auth";
import { api } from "@/lib/api-client";
import { useTranslation } from "@/lib/i18n";
import { KEYS_8 } from "@/lib/keys";
import { toast } from "@/lib/toast";
import { Card, CardContent, CardHeader, CardTitle } from "@sase/ui";
import { Button } from "@sase/ui";
@@ -431,8 +432,8 @@ function AdminUsersPage() {
{/* Table */}
{isLoading ? (
<div className="space-y-3">
{Array.from({ length: 8 }).map((_, i) => (
<Skeleton key={`row-skeleton-${i}`} className="h-14 w-full" />
{KEYS_8.map((__k) => (
<Skeleton key={__k} className="h-14 w-full" />
))}
</div>
) : !data || data.items.length === 0 ? (

View File

@@ -9,6 +9,7 @@ import { Link, createFileRoute, useNavigate } from "@tanstack/react-router";
import { ChevronRight, Columns2, LayoutGrid, Library, List, Lock } from "lucide-react";
import { useState } from "react";
import { KEYS_10 } from "@/lib/keys";
export const Route = createFileRoute("/dashboard/catalog/")({
component: CatalogBrandsPage,
});
@@ -77,8 +78,8 @@ function CatalogBrandsPage() {
{isLoading ? (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
{Array.from({ length: 10 }).map((_, i) => (
<Skeleton key={`brand-skel-${i}`} className="h-28 w-full rounded-xl" />
{KEYS_10.map((__k) => (
<Skeleton key={__k} className="h-28 w-full rounded-xl" />
))}
</div>
) : !brands || brands.length === 0 ? (

View File

@@ -2,6 +2,7 @@ import { ModelListColumns } from "@/components/catalog/model-list-columns";
import { ModelListTree } from "@/components/catalog/model-list-tree";
import { api } from "@/lib/api-client";
import { useTranslation } from "@/lib/i18n";
import { KEYS_4, KEYS_9 } from "@/lib/keys";
import { getUserSettings, setUserSetting } from "@/lib/user-settings";
import { Button, Skeleton, cn } from "@sase/ui";
import { useQuery } from "@tanstack/react-query";
@@ -129,8 +130,8 @@ function CatalogModelsPage() {
{catalogsLoading ? (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={`cat-skel-${i}`} className="h-24 w-full rounded-xl" />
{KEYS_4.map((__k) => (
<Skeleton key={__k} className="h-24 w-full rounded-xl" />
))}
</div>
) : isMultiCatalog && !activeCatalog ? (
@@ -143,8 +144,8 @@ function CatalogModelsPage() {
{t("catalog.loadingModels")}
</div>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 9 }).map((_, i) => (
<Skeleton key={`model-skel-${i}`} className="h-24 w-full rounded-lg" />
{KEYS_9.map((__k) => (
<Skeleton key={__k} className="h-24 w-full rounded-lg" />
))}
</div>
</div>

View File

@@ -10,6 +10,7 @@ import { Link, createFileRoute, useNavigate } from "@tanstack/react-router";
import { ArrowLeft, Columns2, LayoutGrid, List } from "lucide-react";
import { Suspense, lazy, useState } from "react";
import { KEYS_8 } from "@/lib/keys";
const SchemaViewer = lazy(() =>
import("@/components/schema/schema-viewer").then((mod) => ({
default: mod.SchemaViewer,
@@ -24,8 +25,8 @@ function SchemaViewerFallback() {
</div>
<div className="w-full space-y-3 p-4 md:w-[40%]">
<Skeleton className="h-6 w-1/2" />
{Array.from({ length: 8 }).map((_, i) => (
<Skeleton key={`schema-skel-${i}`} className="h-10 w-full" />
{KEYS_8.map((__k) => (
<Skeleton key={__k} className="h-10 w-full" />
))}
</div>
</div>

View File

@@ -13,6 +13,7 @@ import { Link, createFileRoute, useNavigate } from "@tanstack/react-router";
import { ArrowLeft, Columns2, LayoutGrid, List } from "lucide-react";
import { useState } from "react";
import { KEYS_8 } from "@/lib/keys";
export const Route = createFileRoute("/dashboard/catalog_/$brandName_/$modelId/")({
validateSearch: (search) => ({
body: typeof search.body === "string" ? search.body : undefined,
@@ -277,8 +278,8 @@ function CatalogVehiclePage() {
>
{categoriesLoading ? (
<div className="space-y-2">
{Array.from({ length: 8 }).map((_, i) => (
<Skeleton key={`cat-skel-${i}`} className="h-8 w-full" />
{KEYS_8.map((__k) => (
<Skeleton key={__k} className="h-8 w-full" />
))}
</div>
) : viewMode === "grid" ? (

View File

@@ -1,6 +1,7 @@
import { CarBrandLogo } from "@/components/ui/car-brand-logo";
import { useAuth } from "@/hooks/use-auth";
import { api } from "@/lib/api-client";
import { KEYS_4 } from "@/lib/keys";
import { Badge, Button, Separator, Skeleton } from "@sase/ui";
import { useQuery } from "@tanstack/react-query";
import { Link, createFileRoute } from "@tanstack/react-router";
@@ -231,8 +232,8 @@ function DashboardHome() {
{/* ─── STAT CARDS ─────────────────────────────────────────────── */}
{isLoadingCards ? (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={`card-skel-${i}`} className="h-64 w-full rounded-2xl" />
{KEYS_4.map((__k) => (
<Skeleton key={__k} className="h-64 w-full rounded-2xl" />
))}
</div>
) : (

View File

@@ -2,6 +2,7 @@ import { CarBrandLogo } from "@/components/ui/car-brand-logo";
import { api } from "@/lib/api-client";
import { startAction } from "@/lib/faro";
import { useTranslation } from "@/lib/i18n";
import { BRAND_SKELETON_KEYS } from "@/lib/keys";
import { capture, setPeopleProperties } from "@/lib/posthog";
import { toast } from "@/lib/toast";
import { getUserSettings } from "@/lib/user-settings";
@@ -50,8 +51,8 @@ const LazyOnboardingProgress = lazy(() =>
function BrandSelectorFallback() {
return (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4">
{Array.from({ length: 8 }).map((_, i) => (
<Skeleton key={`brand-skel-${i}`} className="h-24 w-full rounded-lg" />
{BRAND_SKELETON_KEYS.map((k) => (
<Skeleton key={k} className="h-24 w-full rounded-lg" />
))}
</div>
);

View File

@@ -8,6 +8,7 @@ import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { ArrowLeft, Columns2, LayoutGrid, List } from "lucide-react";
import { Suspense, lazy, useState } from "react";
import { KEYS_6, KEYS_8 } from "@/lib/keys";
const SchemaViewer = lazy(() =>
import("@/components/schema/schema-viewer").then((mod) => ({
default: mod.SchemaViewer,
@@ -22,8 +23,8 @@ function SchemaViewerFallback() {
</div>
<div className="w-full space-y-3 p-4 md:w-[40%]">
<Skeleton className="h-6 w-1/2" />
{Array.from({ length: 8 }).map((_, i) => (
<Skeleton key={`schema-skel-${i}`} className="h-10 w-full" />
{KEYS_8.map((__k) => (
<Skeleton key={__k} className="h-10 w-full" />
))}
</div>
</div>
@@ -33,8 +34,8 @@ function SchemaViewerFallback() {
function CategoryGridFallback() {
return (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={`cat-grid-skel-${i}`} className="h-24 w-full rounded-lg" />
{KEYS_6.map((__k) => (
<Skeleton key={__k} className="h-24 w-full rounded-lg" />
))}
</div>
);

View File

@@ -12,6 +12,7 @@ import { createFileRoute } from "@tanstack/react-router";
import { ArrowLeft, Columns2, LayoutGrid, List } from "lucide-react";
import { useState } from "react";
import { KEYS_8 } from "@/lib/keys";
export const Route = createFileRoute("/dashboard/vehicles_/$id/")({
component: VehicleDetailPage,
});
@@ -126,8 +127,8 @@ function VehicleDetailPage() {
>
{categoriesLoading ? (
<div className="space-y-2">
{Array.from({ length: 8 }).map((_, i) => (
<Skeleton key={`cat-skel-${i}`} className="h-8 w-full" />
{KEYS_8.map((__k) => (
<Skeleton key={__k} className="h-8 w-full" />
))}
</div>
) : viewMode === "grid" ? (

View File

@@ -117,7 +117,8 @@ function DemoPage() {
Sase.tr
</Link>
<div className="flex items-center gap-3">
<button type="button"
<button
type="button"
onClick={toggleTheme}
className="inline-flex size-9 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
aria-label="Tema değiştir"
@@ -144,7 +145,8 @@ function DemoPage() {
<main id="main-content" className="mx-auto max-w-5xl px-4 py-12 sm:px-6">
{/* Step indicator */}
<div className="mb-8 flex items-center justify-center gap-2 text-sm text-muted-foreground">
<button type="button"
<button
type="button"
onClick={() => {
setStep("vin");
setSelectedCategory(null);
@@ -154,7 +156,8 @@ function DemoPage() {
1. VIN Girin
</button>
<div className="h-px w-6 bg-border" />
<button type="button"
<button
type="button"
onClick={() => vinPreview && setStep("categories")}
className={`rounded-full px-3 py-1 transition ${step === "categories" ? "bg-foreground text-background" : "bg-muted"} ${!vinPreview ? "opacity-50 cursor-not-allowed" : ""}`}
disabled={!vinPreview}
@@ -162,7 +165,8 @@ function DemoPage() {
2. Kategori Seçin
</button>
<div className="h-px w-6 bg-border" />
<button type="button"
<button
type="button"
onClick={() => selectedCategory && setStep("schema")}
className={`rounded-full px-3 py-1 transition ${step === "schema" ? "bg-foreground text-background" : "bg-muted"} ${!selectedCategory ? "opacity-50 cursor-not-allowed" : ""}`}
disabled={!selectedCategory}
@@ -244,7 +248,8 @@ function DemoPage() {
)}
{!vin && (
<button type="button"
<button
type="button"
onClick={() => setVin("WVWZZZ1JZ3W597935")}
className="mx-auto block text-sm text-muted-foreground underline underline-offset-4 transition hover:text-foreground"
>
@@ -268,7 +273,8 @@ function DemoPage() {
</p>
)}
</div>
<button type="button"
<button
type="button"
onClick={() => {
setStep("vin");
setSelectedCategory(null);
@@ -281,7 +287,8 @@ function DemoPage() {
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{EXAMPLE_CATEGORIES.map((cat) => (
<button type="button"
<button
type="button"
key={cat.name}
onClick={() => {
setSelectedCategory(cat.name);
@@ -319,7 +326,8 @@ function DemoPage() {
</p>
)}
</div>
<button type="button"
<button
type="button"
onClick={() => setStep("categories")}
className="text-sm text-muted-foreground underline underline-offset-4 hover:text-foreground"
>

View File

@@ -572,7 +572,8 @@ function HomePage() {
</nav>
<div className="hidden items-center gap-3 md:flex">
<button type="button"
<button
type="button"
onClick={toggleTheme}
className="inline-flex size-9 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
aria-label="Tema değiştir"
@@ -610,7 +611,8 @@ function HomePage() {
{/* Mobile toggle */}
<div className="flex items-center gap-2 md:hidden">
<button type="button"
<button
type="button"
onClick={toggleTheme}
className="inline-flex size-9 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
aria-label="Tema değiştir"
@@ -627,7 +629,7 @@ function HomePage() {
{mobileMenuOpen && (
<div className="border-t border-border px-4 py-4 md:hidden">
<nav className="flex flex-col gap-3 text-sm text-muted-foreground">
<button type="button"
<button
type="button"
className="text-left transition hover:text-foreground"
onClick={() => {
@@ -637,7 +639,7 @@ function HomePage() {
>
Özellikler
</button>
<button type="button"
<button
type="button"
className="text-left transition hover:text-foreground"
onClick={() => {
@@ -828,7 +830,8 @@ function HomePage() {
{/* Example VIN invite */}
<p className="mt-4 text-sm text-muted-foreground">
Şase numaranız yok mu?{" "}
<button type="button"
<button
type="button"
onClick={fillExampleVin}
data-faro-user-action-name="hero-free-trial"
className="text-foreground underline underline-offset-4 transition hover:text-foreground/80"
@@ -1313,7 +1316,9 @@ function HomePage() {
>
<div className="flex gap-0.5">
{Array.from({ length: t.rating }).map((_, i) => (
<svg role="img" aria-label="icon"
<svg
role="img"
aria-label="icon"
key={i}
viewBox="0 0 20 20"
className="size-3.5 fill-foreground/85"