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:
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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() })
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
97
apps/web/src/lib/keys.ts
Normal 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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" ? (
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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" ? (
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user