feat: catalog browser polish + design system refresh + translation pipeline
- catalog: P5 restriction selector flow (mainGroupsPath), grid/tree/columns view modes for brands and models with persisted user settings - translations: bulk translateMany() path with 1d cache-miss TTL, expanded automotive dictionary; categories.service now drives EN→TR via TranslationsService instead of mapper-side strings - pcat: migrate auth from v1 JWT to v3 widget tokens (TWS- api-key + supporting X-* headers, IP-bound via DataImpulse proxy) - pl24: new fetchP5Restrictions() for restriction-level navigation - subscriptions: trial extended 7 → 30 days - design: oklch color tokens, brand semantic color, Geist + Instrument Serif fonts, tinted shadows, button "brand" variant with hover-lift, accessible focus rings, skip link, 404 NotFound page, auth layout polish - nginx: dynamic resolver for Faro upstream - config: OPENROUTER_API_KEY env (used by emex translate bootstrap script) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -52,6 +52,7 @@
|
|||||||
"drizzle-orm": "^0.41.0",
|
"drizzle-orm": "^0.41.0",
|
||||||
"helmet": "^8.1.0",
|
"helmet": "^8.1.0",
|
||||||
"ioredis": "^5.4.0",
|
"ioredis": "^5.4.0",
|
||||||
|
"openai": "^6.37.0",
|
||||||
"postgres": "^3.4.0",
|
"postgres": "^3.4.0",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.0",
|
"rxjs": "^7.8.0",
|
||||||
|
|||||||
@@ -60,15 +60,25 @@ export class CatalogController {
|
|||||||
return this.catalogService.getPsaGearboxes(id, body, engine, user.id);
|
return this.catalogService.getPsaGearboxes(id, body, engine, user.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get("vehicles/:id/p5-restrictions")
|
||||||
|
getP5Restrictions(
|
||||||
|
@Param("id") id: string,
|
||||||
|
@Query("path") path: string | undefined,
|
||||||
|
@CurrentUser() user: { id: string },
|
||||||
|
) {
|
||||||
|
return this.catalogService.getP5Restrictions(id, user.id, path);
|
||||||
|
}
|
||||||
|
|
||||||
@Get("vehicles/:id/categories")
|
@Get("vehicles/:id/categories")
|
||||||
getCategoryTree(
|
getCategoryTree(
|
||||||
@Param("id") id: string,
|
@Param("id") id: string,
|
||||||
@Query("body") body: string | undefined,
|
@Query("body") body: string | undefined,
|
||||||
@Query("engine") engine: string | undefined,
|
@Query("engine") engine: string | undefined,
|
||||||
@Query("gearbox") gearbox: string | undefined,
|
@Query("gearbox") gearbox: string | undefined,
|
||||||
|
@Query("mgp") mainGroupsPath: string | undefined,
|
||||||
@CurrentUser() user: { id: string },
|
@CurrentUser() user: { id: string },
|
||||||
) {
|
) {
|
||||||
return this.catalogService.getCategoryTree(id, user.id, body, engine, gearbox);
|
return this.catalogService.getCategoryTree(id, user.id, body, engine, gearbox, mainGroupsPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get("vehicles/:id/categories/:categoryId")
|
@Get("vehicles/:id/categories/:categoryId")
|
||||||
|
|||||||
@@ -240,6 +240,30 @@ export class CatalogService {
|
|||||||
return vehicle;
|
return vehicle;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get P5 Modern restriction options for a catalog vehicle.
|
||||||
|
* Fetches the first restriction level (from vehicle.catalogPath) or a subsequent level (from `nextPath`).
|
||||||
|
*/
|
||||||
|
async getP5Restrictions(
|
||||||
|
catalogVehicleId: string,
|
||||||
|
userId: string,
|
||||||
|
nextPath?: string,
|
||||||
|
): Promise<{ options: Array<{ code: string; name: string; path: string }>; isFinal: boolean }> {
|
||||||
|
const [vehicle] = await this.db
|
||||||
|
.select()
|
||||||
|
.from(catalogVehicles)
|
||||||
|
.where(eq(catalogVehicles.id, catalogVehicleId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!vehicle) throw new NotFoundException("Katalog aracı bulunamadı");
|
||||||
|
if (vehicle.brandId) await this.checkBrandAccess(userId, vehicle.brandId);
|
||||||
|
|
||||||
|
const pathToFetch = nextPath || vehicle.catalogPath;
|
||||||
|
if (!pathToFetch) return { options: [], isFinal: false };
|
||||||
|
|
||||||
|
return this.pl24Service.fetchP5Restrictions(vehicle.serviceName, pathToFetch);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get available body types for a PSA catalog vehicle (for variant selector UI).
|
* Get available body types for a PSA catalog vehicle (for variant selector UI).
|
||||||
*/
|
*/
|
||||||
@@ -309,11 +333,14 @@ export class CatalogService {
|
|||||||
body = "_all_",
|
body = "_all_",
|
||||||
engine = "_all_",
|
engine = "_all_",
|
||||||
gearbox = "_all_",
|
gearbox = "_all_",
|
||||||
|
mainGroupsPath?: string,
|
||||||
) {
|
) {
|
||||||
const hasVariant = body !== "_all_" || engine !== "_all_" || gearbox !== "_all_";
|
const hasVariant = body !== "_all_" || engine !== "_all_" || gearbox !== "_all_";
|
||||||
const cacheKey = hasVariant
|
const cacheKey = mainGroupsPath
|
||||||
? `cat:catalog:tree:${catalogVehicleId}:b=${body}:e=${engine}:g=${gearbox}`
|
? `cat:catalog:tree:${catalogVehicleId}:mgp=${Buffer.from(mainGroupsPath).toString("base64").slice(0, 40)}`
|
||||||
: `cat:catalog:tree:${catalogVehicleId}`;
|
: hasVariant
|
||||||
|
? `cat:catalog:tree:${catalogVehicleId}:b=${body}:e=${engine}:g=${gearbox}`
|
||||||
|
: `cat:catalog:tree:${catalogVehicleId}`;
|
||||||
const cached = await this.redis.getJson<any[]>(cacheKey);
|
const cached = await this.redis.getJson<any[]>(cacheKey);
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
|
|
||||||
@@ -620,7 +647,10 @@ export class CatalogService {
|
|||||||
.from(categories)
|
.from(categories)
|
||||||
.where(eq(categories.catalogVehicleId, catalogVehicleId));
|
.where(eq(categories.catalogVehicleId, catalogVehicleId));
|
||||||
|
|
||||||
if (dbCategories.length === 0 && vehicle.catalogPath) {
|
// When mainGroupsPath is provided (P5 restriction flow), use it directly
|
||||||
|
const effectiveCatalogPath = mainGroupsPath || vehicle.catalogPath;
|
||||||
|
|
||||||
|
if (dbCategories.length === 0 && effectiveCatalogPath) {
|
||||||
try {
|
try {
|
||||||
let pl24Categories;
|
let pl24Categories;
|
||||||
|
|
||||||
@@ -637,7 +667,7 @@ export class CatalogService {
|
|||||||
} else {
|
} else {
|
||||||
pl24Categories = await this.pl24Service.fetchMainGroups(
|
pl24Categories = await this.pl24Service.fetchMainGroups(
|
||||||
vehicle.serviceName,
|
vehicle.serviceName,
|
||||||
vehicle.catalogPath,
|
effectiveCatalogPath,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,9 +4,10 @@ import { CategoriesService } from "./categories.service";
|
|||||||
import { PL24Module } from "../integrations/pl24/pl24.module";
|
import { PL24Module } from "../integrations/pl24/pl24.module";
|
||||||
import { EmexModule } from "../integrations/emex/emex.module";
|
import { EmexModule } from "../integrations/emex/emex.module";
|
||||||
import { PartsCatalogsModule } from "../integrations/parts-catalogs/parts-catalogs.module";
|
import { PartsCatalogsModule } from "../integrations/parts-catalogs/parts-catalogs.module";
|
||||||
|
import { TranslationsModule } from "../translations/translations.module";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PL24Module, EmexModule, PartsCatalogsModule],
|
imports: [PL24Module, EmexModule, PartsCatalogsModule, TranslationsModule],
|
||||||
controllers: [CategoriesController],
|
controllers: [CategoriesController],
|
||||||
providers: [CategoriesService],
|
providers: [CategoriesService],
|
||||||
exports: [CategoriesService],
|
exports: [CategoriesService],
|
||||||
|
|||||||
@@ -25,8 +25,25 @@ function createService(db: any) {
|
|||||||
const pl24FordLegacyService = {
|
const pl24FordLegacyService = {
|
||||||
fetchCategoriesForPsaVin: vi.fn().mockResolvedValue([]),
|
fetchCategoriesForPsaVin: vi.fn().mockResolvedValue([]),
|
||||||
};
|
};
|
||||||
const service = new CategoriesService(db as any, redis as any, pl24Service as any, emexService as any, partsCatalogsService as any, storage as any, pl24FordLegacyService as any);
|
const translationsService = {
|
||||||
return { service, db, redis, pl24Service };
|
translate: vi.fn().mockImplementation((_key: string, sourceText: string) =>
|
||||||
|
Promise.resolve({ translatedText: sourceText, source: "none", isAutoTranslated: false }),
|
||||||
|
),
|
||||||
|
translateMany: vi.fn().mockImplementation((texts: string[]) =>
|
||||||
|
Promise.resolve(new Map<string, string>(texts.map((t) => [t, t]))),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
const service = new CategoriesService(
|
||||||
|
db as any,
|
||||||
|
redis as any,
|
||||||
|
pl24Service as any,
|
||||||
|
emexService as any,
|
||||||
|
partsCatalogsService as any,
|
||||||
|
storage as any,
|
||||||
|
pl24FordLegacyService as any,
|
||||||
|
translationsService as any,
|
||||||
|
);
|
||||||
|
return { service, db, redis, pl24Service, translationsService };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Chainable mock where limit is terminal */
|
/** Chainable mock where limit is terminal */
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { EmexService } from "../integrations/emex/emex.service";
|
|||||||
import { PartsCatalogsService } from "../integrations/parts-catalogs/parts-catalogs.service";
|
import { PartsCatalogsService } from "../integrations/parts-catalogs/parts-catalogs.service";
|
||||||
import type { PcatGroup } from "../integrations/parts-catalogs/parts-catalogs.types";
|
import type { PcatGroup } from "../integrations/parts-catalogs/parts-catalogs.types";
|
||||||
import { StorageService } from "../storage/storage.service";
|
import { StorageService } from "../storage/storage.service";
|
||||||
|
import { TranslationsService } from "../translations/translations.service";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CategoriesService {
|
export class CategoriesService {
|
||||||
@@ -22,6 +23,7 @@ export class CategoriesService {
|
|||||||
private partsCatalogsService: PartsCatalogsService,
|
private partsCatalogsService: PartsCatalogsService,
|
||||||
private storage: StorageService,
|
private storage: StorageService,
|
||||||
private pl24FordLegacyService: PL24FordLegacyService,
|
private pl24FordLegacyService: PL24FordLegacyService,
|
||||||
|
private translationsService: TranslationsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async getCategoryTree(vehicleId: string) {
|
async getCategoryTree(vehicleId: string) {
|
||||||
@@ -176,10 +178,13 @@ export class CategoriesService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (groups.length > 0) {
|
if (groups.length > 0) {
|
||||||
|
const trMap = await this.translationsService.translateMany(
|
||||||
|
groups.map((g) => g.name).filter(Boolean),
|
||||||
|
);
|
||||||
const insertData = groups.map((g) => ({
|
const insertData = groups.map((g) => ({
|
||||||
vehicleId,
|
vehicleId,
|
||||||
catalogVehicleId: null as string | null,
|
catalogVehicleId: null as string | null,
|
||||||
name: g.name,
|
name: trMap.get(g.name) ?? g.name,
|
||||||
nameOriginal: g.name,
|
nameOriginal: g.name,
|
||||||
parentId: null as string | null,
|
parentId: null as string | null,
|
||||||
externalId: g.id,
|
externalId: g.id,
|
||||||
@@ -221,6 +226,20 @@ export class CategoriesService {
|
|||||||
// Recursive tree insertion from QuickGroups.aspx
|
// Recursive tree insertion from QuickGroups.aspx
|
||||||
this.logger.log(`Inserting ${tree.length} EMEX top-level category groups recursively`);
|
this.logger.log(`Inserting ${tree.length} EMEX top-level category groups recursively`);
|
||||||
|
|
||||||
|
// 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[] }>,
|
||||||
|
) => {
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (node.name) uniqueNames.add(node.name);
|
||||||
|
if (node.children?.length) collect(node.children);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
collect(tree);
|
||||||
|
const trMap = await this.translationsService.translateMany([...uniqueNames]);
|
||||||
|
|
||||||
const insertNodes = async (
|
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,
|
parentId: string | null,
|
||||||
@@ -236,7 +255,7 @@ export class CategoriesService {
|
|||||||
.values({
|
.values({
|
||||||
vehicleId,
|
vehicleId,
|
||||||
catalogVehicleId: null as string | null,
|
catalogVehicleId: null as string | null,
|
||||||
name: node.name,
|
name: trMap.get(node.name) ?? node.name,
|
||||||
nameOriginal: node.name,
|
nameOriginal: node.name,
|
||||||
parentId,
|
parentId,
|
||||||
externalId: node.gid || null,
|
externalId: node.gid || null,
|
||||||
@@ -263,9 +282,15 @@ export class CategoriesService {
|
|||||||
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]));
|
const urlMap = new Map(emexCats.map((c) => [c.gid, c.url]));
|
||||||
|
|
||||||
|
// Bulk translate English names before inserting; mapper no longer
|
||||||
|
// populates nameTr — TranslationsService is the single source.
|
||||||
|
const trMap = await this.translationsService.translateMany(
|
||||||
|
emexResult.categories.map((c) => c.nameEn).filter(Boolean),
|
||||||
|
);
|
||||||
|
|
||||||
const seenNames = new Set<string>();
|
const seenNames = new Set<string>();
|
||||||
const uniqueCategories = emexResult.categories.filter((c) => {
|
const uniqueCategories = emexResult.categories.filter((c) => {
|
||||||
const name = c.nameTr || c.nameEn;
|
const name = trMap.get(c.nameEn) ?? c.nameEn;
|
||||||
if (seenNames.has(name)) return false;
|
if (seenNames.has(name)) return false;
|
||||||
seenNames.add(name);
|
seenNames.add(name);
|
||||||
return true;
|
return true;
|
||||||
@@ -274,7 +299,7 @@ export class CategoriesService {
|
|||||||
const insertData = uniqueCategories.map((c) => ({
|
const insertData = uniqueCategories.map((c) => ({
|
||||||
vehicleId,
|
vehicleId,
|
||||||
catalogVehicleId: null as string | null,
|
catalogVehicleId: null as string | null,
|
||||||
name: c.nameTr || c.nameEn,
|
name: trMap.get(c.nameEn) ?? c.nameEn,
|
||||||
nameOriginal: c.nameEn,
|
nameOriginal: c.nameEn,
|
||||||
parentId: null as string | null,
|
parentId: null as string | null,
|
||||||
externalId: c.code,
|
externalId: c.code,
|
||||||
@@ -380,10 +405,13 @@ export class CategoriesService {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const trMap = await this.translationsService.translateMany(
|
||||||
|
realSubGroups.map((g) => g.name).filter(Boolean),
|
||||||
|
);
|
||||||
const insertData = realSubGroups.map((g) => ({
|
const insertData = realSubGroups.map((g) => ({
|
||||||
vehicleId: category.vehicleId,
|
vehicleId: category.vehicleId,
|
||||||
catalogVehicleId: category.catalogVehicleId,
|
catalogVehicleId: category.catalogVehicleId,
|
||||||
name: g.name,
|
name: trMap.get(g.name) ?? g.name,
|
||||||
nameOriginal: g.name,
|
nameOriginal: g.name,
|
||||||
parentId: categoryId,
|
parentId: categoryId,
|
||||||
externalId: g.id,
|
externalId: g.id,
|
||||||
@@ -588,31 +616,40 @@ export class CategoriesService {
|
|||||||
if (partsResult) {
|
if (partsResult) {
|
||||||
// Flatten part groups into parts
|
// Flatten part groups into parts
|
||||||
if (needParts) {
|
if (needParts) {
|
||||||
const allParts: Array<typeof parts.$inferInsert> = [];
|
const rawParts: Array<{ name: string; number: string; notice: string | null; positionNumber: string | null }> = [];
|
||||||
|
|
||||||
for (const pg of partsResult.partGroups) {
|
for (const pg of partsResult.partGroups) {
|
||||||
for (const p of pg.parts) {
|
for (const p of pg.parts) {
|
||||||
if (!p.number) continue;
|
if (!p.number) continue;
|
||||||
const posNum = p.positionNumber || pg.positionNumber || null;
|
rawParts.push({
|
||||||
allParts.push({
|
name: p.name || "",
|
||||||
vehicleId: vehicle.id,
|
number: p.number,
|
||||||
categoryId,
|
notice: p.notice || null,
|
||||||
oemCode: p.number,
|
positionNumber: p.positionNumber || pg.positionNumber || null,
|
||||||
name: p.name || "Unknown",
|
|
||||||
nameOriginal: p.name || null,
|
|
||||||
description: p.notice || null,
|
|
||||||
quantity: null,
|
|
||||||
position: posNum,
|
|
||||||
hotspotIndex: posNum ? parseInt(posNum, 10) || null : null,
|
|
||||||
unavailable: false,
|
|
||||||
remark: null as string | null,
|
|
||||||
modelCodes: null as string | null,
|
|
||||||
presel: false,
|
|
||||||
source: "parts-catalogs" as const,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const trMap = await this.translationsService.translateMany(
|
||||||
|
rawParts.map((p) => p.name).filter(Boolean),
|
||||||
|
);
|
||||||
|
|
||||||
|
const allParts: Array<typeof parts.$inferInsert> = rawParts.map((p) => ({
|
||||||
|
vehicleId: vehicle.id,
|
||||||
|
categoryId,
|
||||||
|
oemCode: p.number,
|
||||||
|
name: trMap.get(p.name) ?? p.name ?? "Unknown",
|
||||||
|
nameOriginal: p.name || null,
|
||||||
|
description: p.notice,
|
||||||
|
quantity: null,
|
||||||
|
position: p.positionNumber,
|
||||||
|
hotspotIndex: p.positionNumber ? parseInt(p.positionNumber, 10) || null : null,
|
||||||
|
unavailable: false,
|
||||||
|
remark: null as string | null,
|
||||||
|
modelCodes: null as string | null,
|
||||||
|
presel: false,
|
||||||
|
source: "parts-catalogs" as const,
|
||||||
|
}));
|
||||||
|
|
||||||
if (allParts.length > 0) {
|
if (allParts.length > 0) {
|
||||||
dbParts = await this.db.insert(parts).values(allParts).returning();
|
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}`);
|
||||||
@@ -702,18 +739,27 @@ export class CategoriesService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (needParts && emexResult.parts.length > 0) {
|
if (needParts && emexResult.parts.length > 0) {
|
||||||
const insertData = emexResult.parts.map((p) => ({
|
// Bulk translate part names (cache + DB + dictionary fallback).
|
||||||
vehicleId: vehicle.id,
|
// After bootstrap, ~95%+ should be Redis hits.
|
||||||
categoryId,
|
const trMap = await this.translationsService.translateMany(
|
||||||
oemCode: p.oemCode || "N/A",
|
emexResult.parts.map((p) => p.nameEn || "").filter(Boolean),
|
||||||
name: p.nameEn || "Unknown",
|
);
|
||||||
nameOriginal: p.nameEn || null,
|
|
||||||
description: null as string | null,
|
const insertData = emexResult.parts.map((p) => {
|
||||||
quantity: null as number | null,
|
const original = p.nameEn || "";
|
||||||
position: p.positionCode || null,
|
return {
|
||||||
hotspotIndex: p.positionCode ? (posCodeToIndex.get(p.positionCode) ?? null) : null,
|
vehicleId: vehicle.id,
|
||||||
source: "emex" as const,
|
categoryId,
|
||||||
}));
|
oemCode: p.oemCode || "N/A",
|
||||||
|
name: trMap.get(original) ?? original ?? "Unknown",
|
||||||
|
nameOriginal: p.nameEn || null,
|
||||||
|
description: null as string | null,
|
||||||
|
quantity: null as number | null,
|
||||||
|
position: p.positionCode || null,
|
||||||
|
hotspotIndex: p.positionCode ? (posCodeToIndex.get(p.positionCode) ?? null) : null,
|
||||||
|
source: "emex" as const,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
dbParts = await this.db.insert(parts).values(insertData).returning();
|
dbParts = await this.db.insert(parts).values(insertData).returning();
|
||||||
this.logger.log(`Stored ${dbParts.length} EMEX parts for category ${categoryId}`);
|
this.logger.log(`Stored ${dbParts.length} EMEX parts for category ${categoryId}`);
|
||||||
|
|||||||
@@ -1,24 +1,25 @@
|
|||||||
/**
|
/**
|
||||||
* EMEX Response Mapper
|
* EMEX Response Mapper
|
||||||
*
|
*
|
||||||
* Transforms raw EmexVinScraper responses into standardized DecodedVehicle format.
|
* Transforms raw EmexVinScraper responses into the standardized DecodedVehicle
|
||||||
* Includes Turkish translation support for common automotive terms.
|
* format. Vehicle attribute translations (body type, engine type, transmission,
|
||||||
|
* drive type) live here. Category and part name translations now live in
|
||||||
|
* TranslationsService (apps/api/src/translations/translations.service.ts) —
|
||||||
|
* mapCategories returns nameTr=null and the insertion path
|
||||||
|
* (categories.service.ts) calls translationsService.translateMany() before
|
||||||
|
* persisting.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import {
|
||||||
EmexScraperResponse,
|
EmexScraperResponse,
|
||||||
EmexCategory,
|
EmexCategory,
|
||||||
EmexCategoryTreeNode,
|
|
||||||
DecodedVehicle,
|
DecodedVehicle,
|
||||||
DecodedCategory,
|
DecodedCategory,
|
||||||
CATALOG_MAP,
|
CATALOG_MAP,
|
||||||
} from './emex.types';
|
} from './emex.types';
|
||||||
|
|
||||||
// ==================== TURKISH TRANSLATIONS ====================
|
// ==================== VEHICLE ATTRIBUTE TRANSLATIONS ====================
|
||||||
|
|
||||||
/**
|
|
||||||
* Turkish translations for common automotive terms
|
|
||||||
*/
|
|
||||||
const TR_TRANSLATIONS = {
|
const TR_TRANSLATIONS = {
|
||||||
// Body types
|
// Body types
|
||||||
bodyTypes: {
|
bodyTypes: {
|
||||||
@@ -39,7 +40,6 @@ const TR_TRANSLATIONS = {
|
|||||||
roadster: 'Roadster',
|
roadster: 'Roadster',
|
||||||
} as Record<string, string>,
|
} as Record<string, string>,
|
||||||
|
|
||||||
// Engine types
|
|
||||||
engineTypes: {
|
engineTypes: {
|
||||||
gasoline: 'Benzin',
|
gasoline: 'Benzin',
|
||||||
petrol: 'Benzin',
|
petrol: 'Benzin',
|
||||||
@@ -54,7 +54,6 @@ const TR_TRANSLATIONS = {
|
|||||||
hydrogen: 'Hidrojen',
|
hydrogen: 'Hidrojen',
|
||||||
} as Record<string, string>,
|
} as Record<string, string>,
|
||||||
|
|
||||||
// Transmission types
|
|
||||||
transmissions: {
|
transmissions: {
|
||||||
automatic: 'Otomatik',
|
automatic: 'Otomatik',
|
||||||
manual: 'Manuel',
|
manual: 'Manuel',
|
||||||
@@ -69,7 +68,6 @@ const TR_TRANSLATIONS = {
|
|||||||
mt: 'Manuel',
|
mt: 'Manuel',
|
||||||
} as Record<string, string>,
|
} as Record<string, string>,
|
||||||
|
|
||||||
// Drive types
|
|
||||||
driveTypes: {
|
driveTypes: {
|
||||||
fwd: 'Ondan Cekis',
|
fwd: 'Ondan Cekis',
|
||||||
rwd: 'Arkadan Itis',
|
rwd: 'Arkadan Itis',
|
||||||
@@ -83,211 +81,10 @@ const TR_TRANSLATIONS = {
|
|||||||
xdrive: 'xDrive (Dort Ceker)',
|
xdrive: 'xDrive (Dort Ceker)',
|
||||||
'4matic': '4MATIC (Dort Ceker)',
|
'4matic': '4MATIC (Dort Ceker)',
|
||||||
} as Record<string, string>,
|
} as Record<string, string>,
|
||||||
|
|
||||||
// Common part categories
|
|
||||||
categories: {
|
|
||||||
engine: 'Motor',
|
|
||||||
brake: 'Fren Sistemi',
|
|
||||||
brakes: 'Fren Sistemi',
|
|
||||||
suspension: 'Suspansiyon',
|
|
||||||
steering: 'Direksiyon',
|
|
||||||
transmission: 'Sanziman',
|
|
||||||
exhaust: 'Egzoz Sistemi',
|
|
||||||
cooling: 'Sogutma Sistemi',
|
|
||||||
electrical: 'Elektrik Sistemi',
|
|
||||||
interior: 'Ic Aksam',
|
|
||||||
exterior: 'Dis Aksam',
|
|
||||||
body: 'Kaporta',
|
|
||||||
lighting: 'Aydinlatma',
|
|
||||||
lights: 'Aydinlatma',
|
|
||||||
wheels: 'Jantlar',
|
|
||||||
tires: 'Lastikler',
|
|
||||||
fuel: 'Yakit Sistemi',
|
|
||||||
'fuel system': 'Yakit Sistemi',
|
|
||||||
air: 'Hava Sistemi',
|
|
||||||
'air conditioning': 'Klima',
|
|
||||||
climate: 'Klima',
|
|
||||||
filters: 'Filtreler',
|
|
||||||
oil: 'Yag',
|
|
||||||
battery: 'Akku',
|
|
||||||
alternator: 'Alternator',
|
|
||||||
starter: 'Mars Motoru',
|
|
||||||
clutch: 'Debriyaj',
|
|
||||||
gearbox: 'Vites Kutusu',
|
|
||||||
axle: 'Aks',
|
|
||||||
differential: 'Diferansiyel',
|
|
||||||
driveshaft: 'Saft',
|
|
||||||
'cv joint': 'Aks Kafasi',
|
|
||||||
'tie rod': 'Rot Kolu',
|
|
||||||
'ball joint': 'Rotil',
|
|
||||||
'control arm': 'Salincak',
|
|
||||||
shock: 'Amortisor',
|
|
||||||
'shock absorber': 'Amortisor',
|
|
||||||
spring: 'Yay',
|
|
||||||
strut: 'Makfersan',
|
|
||||||
'brake pad': 'Fren Balatasi',
|
|
||||||
'brake disc': 'Fren Diski',
|
|
||||||
'brake rotor': 'Fren Diski',
|
|
||||||
caliper: 'Fren Kaliperi',
|
|
||||||
'master cylinder': 'Ana Merkez',
|
|
||||||
'wheel bearing': 'Bilyali Rulman',
|
|
||||||
hub: 'Porya',
|
|
||||||
mirror: 'Ayna',
|
|
||||||
bumper: 'Tampon',
|
|
||||||
fender: 'Camurluk',
|
|
||||||
hood: 'Kaput',
|
|
||||||
bonnet: 'Kaput',
|
|
||||||
trunk: 'Bagaj',
|
|
||||||
boot: 'Bagaj',
|
|
||||||
door: 'Kapi',
|
|
||||||
window: 'Cam',
|
|
||||||
windshield: 'On Cam',
|
|
||||||
windscreen: 'On Cam',
|
|
||||||
wiper: 'Silecek',
|
|
||||||
headlight: 'Far',
|
|
||||||
taillight: 'Stop Lambasi',
|
|
||||||
'turn signal': 'Sinyal Lambasi',
|
|
||||||
indicator: 'Sinyal Lambasi',
|
|
||||||
seat: 'Koltuk',
|
|
||||||
dashboard: 'Gosterge Paneli',
|
|
||||||
'steering wheel': 'Direksiyon Simidi',
|
|
||||||
pedal: 'Pedal',
|
|
||||||
carpet: 'Hali',
|
|
||||||
mat: 'Paspas',
|
|
||||||
'water pump': 'Su Pompasi',
|
|
||||||
thermostat: 'Termostat',
|
|
||||||
radiator: 'Radyator',
|
|
||||||
fan: 'Fan',
|
|
||||||
hose: 'Hortum',
|
|
||||||
belt: 'Kayis',
|
|
||||||
'timing belt': 'Eksantrik Kayisi',
|
|
||||||
'timing chain': 'Eksantrik Zinciri',
|
|
||||||
'serpentine belt': 'V Kayis',
|
|
||||||
gasket: 'Conta',
|
|
||||||
seal: 'Simir',
|
|
||||||
'o-ring': 'O-Ring',
|
|
||||||
sensor: 'Sensor',
|
|
||||||
oxygen: 'Oksijen Sensoru',
|
|
||||||
abs: 'ABS Sensoru',
|
|
||||||
airbag: 'Hava Yastigi',
|
|
||||||
horn: 'Korna',
|
|
||||||
relay: 'Role',
|
|
||||||
fuse: 'Sigorta',
|
|
||||||
switch: 'Dugme',
|
|
||||||
motor: 'Motor',
|
|
||||||
pump: 'Pompa',
|
|
||||||
compressor: 'Kompresor',
|
|
||||||
condenser: 'Kondenser',
|
|
||||||
evaporator: 'Evaporator',
|
|
||||||
heater: 'Isitici',
|
|
||||||
blower: 'Ufleyici',
|
|
||||||
'spark plug': 'Buji',
|
|
||||||
'ignition coil': 'Atesleme Bobini',
|
|
||||||
injector: 'Enjktor',
|
|
||||||
'fuel pump': 'Yakit Pompasi',
|
|
||||||
'fuel filter': 'Yakit Filtresi',
|
|
||||||
'air filter': 'Hava Filtresi',
|
|
||||||
'oil filter': 'Yag Filtresi',
|
|
||||||
'cabin filter': 'Polen Filtresi',
|
|
||||||
'pollen filter': 'Polen Filtresi',
|
|
||||||
} as Record<string, string>,
|
|
||||||
|
|
||||||
// Common part names
|
|
||||||
parts: {
|
|
||||||
'oil filter': 'Yag Filtresi',
|
|
||||||
'air filter': 'Hava Filtresi',
|
|
||||||
'fuel filter': 'Yakit Filtresi',
|
|
||||||
'cabin filter': 'Polen Filtresi',
|
|
||||||
'spark plug': 'Buji',
|
|
||||||
'brake pad': 'Fren Balatasi',
|
|
||||||
'brake disc': 'Fren Diski',
|
|
||||||
'brake rotor': 'Fren Diski',
|
|
||||||
'timing belt': 'Eksantrik Kayisi',
|
|
||||||
'water pump': 'Su Pompasi',
|
|
||||||
thermostat: 'Termostat',
|
|
||||||
alternator: 'Alternator',
|
|
||||||
starter: 'Mars Motoru',
|
|
||||||
battery: 'Akku',
|
|
||||||
radiator: 'Radyator',
|
|
||||||
'shock absorber': 'Amortisor',
|
|
||||||
'control arm': 'Salincak',
|
|
||||||
'tie rod': 'Rot Kolu',
|
|
||||||
'ball joint': 'Rotil',
|
|
||||||
'cv joint': 'Aks Kafasi',
|
|
||||||
clutch: 'Debriyaj',
|
|
||||||
'clutch kit': 'Debriyaj Seti',
|
|
||||||
flywheel: 'Volan',
|
|
||||||
'wheel bearing': 'Bilyali Rulman',
|
|
||||||
'hub bearing': 'Porya Rulmani',
|
|
||||||
caliper: 'Fren Kaliperi',
|
|
||||||
'master cylinder': 'Ana Merkez',
|
|
||||||
'slave cylinder': 'Yardimci Merkez',
|
|
||||||
'brake hose': 'Fren Hortumu',
|
|
||||||
'brake line': 'Fren Borusu',
|
|
||||||
'abs sensor': 'ABS Sensoru',
|
|
||||||
'oxygen sensor': 'Oksijen Sensoru',
|
|
||||||
'crankshaft sensor': 'Krank Sensoru',
|
|
||||||
'camshaft sensor': 'Eksantrik Sensoru',
|
|
||||||
'coolant sensor': 'Su Isisi Sensoru',
|
|
||||||
'oil pressure sensor': 'Yag Basinci Sensoru',
|
|
||||||
'ignition coil': 'Atesleme Bobini',
|
|
||||||
injector: 'Enjktor',
|
|
||||||
'fuel pump': 'Yakit Pompasi',
|
|
||||||
'fuel injector': 'Yakit Enjektoru',
|
|
||||||
gasket: 'Conta',
|
|
||||||
'head gasket': 'Silindir Kapagi Contasi',
|
|
||||||
'valve cover gasket': 'Kulbtor Kapagi Contasi',
|
|
||||||
'oil pan gasket': 'Karter Contasi',
|
|
||||||
'intake manifold gasket': 'Emme Manifoldu Contasi',
|
|
||||||
'exhaust manifold gasket': 'Egzoz Manifoldu Contasi',
|
|
||||||
'serpentine belt': 'V Kayis',
|
|
||||||
'drive belt': 'Tahrik Kayisi',
|
|
||||||
tensioner: 'Gerdirici',
|
|
||||||
'belt tensioner': 'Kayis Gerdirici',
|
|
||||||
idler: 'Avare',
|
|
||||||
'idler pulley': 'Avare Kasnak',
|
|
||||||
pulley: 'Kasnak',
|
|
||||||
'crankshaft pulley': 'Krank Kasnagi',
|
|
||||||
'power steering pump': 'Hidrolik Direksiyon Pompasi',
|
|
||||||
'power steering hose': 'Hidrolik Direksiyon Hortumu',
|
|
||||||
'steering rack': 'Kremayer',
|
|
||||||
'steering gear': 'Direksiyon Kutusu',
|
|
||||||
'cv boot': 'Aks Korfezi',
|
|
||||||
'drive shaft': 'Saft',
|
|
||||||
'axle shaft': 'Aks Mili',
|
|
||||||
'wheel hub': 'Porya',
|
|
||||||
'wheel stud': 'Bijon',
|
|
||||||
'lug nut': 'Bijon Somunu',
|
|
||||||
headlight: 'Far',
|
|
||||||
'headlight bulb': 'Far Ampulu',
|
|
||||||
taillight: 'Stop Lambasi',
|
|
||||||
'turn signal': 'Sinyal Lambasi',
|
|
||||||
'fog light': 'Sis Lambasi',
|
|
||||||
mirror: 'Ayna',
|
|
||||||
'side mirror': 'Yan Ayna',
|
|
||||||
'rear view mirror': 'Ic Ayna',
|
|
||||||
wiper: 'Silecek',
|
|
||||||
'wiper blade': 'Silecek Lastigi',
|
|
||||||
'wiper motor': 'Silecek Motoru',
|
|
||||||
'window regulator': 'Cam Mekanizmasi',
|
|
||||||
'window motor': 'Cam Motoru',
|
|
||||||
'door handle': 'Kapi Kolu',
|
|
||||||
'door lock': 'Kapi Kilidi',
|
|
||||||
'door hinge': 'Kapi Mentesesi',
|
|
||||||
bumper: 'Tampon',
|
|
||||||
grille: 'Izgara',
|
|
||||||
hood: 'Kaput',
|
|
||||||
fender: 'Camurluk',
|
|
||||||
'splash guard': 'Paclama',
|
|
||||||
mudguard: 'Camurluk',
|
|
||||||
} as Record<string, string>,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// ==================== TRANSLATION HELPERS ====================
|
// ==================== TRANSLATION HELPERS ====================
|
||||||
|
|
||||||
/**
|
|
||||||
* Translates a term to Turkish if available
|
|
||||||
*/
|
|
||||||
function translateToTurkish(
|
function translateToTurkish(
|
||||||
term: string | null | undefined,
|
term: string | null | undefined,
|
||||||
dictionary: Record<string, string>,
|
dictionary: Record<string, string>,
|
||||||
@@ -297,52 +94,24 @@ function translateToTurkish(
|
|||||||
return dictionary[normalized] || null;
|
return dictionary[normalized] || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Translates body type to Turkish
|
|
||||||
*/
|
|
||||||
export function translateBodyType(bodyType: string | null): string | null {
|
export function translateBodyType(bodyType: string | null): string | null {
|
||||||
return translateToTurkish(bodyType, TR_TRANSLATIONS.bodyTypes);
|
return translateToTurkish(bodyType, TR_TRANSLATIONS.bodyTypes);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Translates engine type to Turkish
|
|
||||||
*/
|
|
||||||
export function translateEngineType(engineType: string | null): string | null {
|
export function translateEngineType(engineType: string | null): string | null {
|
||||||
return translateToTurkish(engineType, TR_TRANSLATIONS.engineTypes);
|
return translateToTurkish(engineType, TR_TRANSLATIONS.engineTypes);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Translates transmission type to Turkish
|
|
||||||
*/
|
|
||||||
export function translateTransmission(
|
export function translateTransmission(
|
||||||
transmission: string | null,
|
transmission: string | null,
|
||||||
): string | null {
|
): string | null {
|
||||||
return translateToTurkish(transmission, TR_TRANSLATIONS.transmissions);
|
return translateToTurkish(transmission, TR_TRANSLATIONS.transmissions);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Translates drive type to Turkish
|
|
||||||
*/
|
|
||||||
export function translateDriveType(driveType: string | null): string | null {
|
export function translateDriveType(driveType: string | null): string | null {
|
||||||
return translateToTurkish(driveType, TR_TRANSLATIONS.driveTypes);
|
return translateToTurkish(driveType, TR_TRANSLATIONS.driveTypes);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Translates category name to Turkish
|
|
||||||
*/
|
|
||||||
export function translateCategoryName(name: string): string {
|
|
||||||
const normalized = name.toLowerCase().trim();
|
|
||||||
return TR_TRANSLATIONS.categories[normalized] || name;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Translates part name to Turkish
|
|
||||||
*/
|
|
||||||
export function translatePartName(name: string): string {
|
|
||||||
const normalized = name.toLowerCase().trim();
|
|
||||||
return TR_TRANSLATIONS.parts[normalized] || name;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== MAPPER FUNCTIONS ====================
|
// ==================== MAPPER FUNCTIONS ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -444,8 +213,11 @@ function buildRawResponse(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Maps EMEX categories to standardized DecodedCategory format
|
* Maps EMEX categories to standardized DecodedCategory format.
|
||||||
* NOTE: Parts are NOT included here - they will be fetched on-demand when user clicks a category
|
* nameTr is left null on purpose — the consumer (categories.service.ts)
|
||||||
|
* runs nameEn through TranslationsService.translateMany() before insert.
|
||||||
|
* Parts are NOT included here; they are fetched on-demand when the user
|
||||||
|
* clicks a category.
|
||||||
*/
|
*/
|
||||||
function mapCategories(
|
function mapCategories(
|
||||||
categories?: EmexCategory[],
|
categories?: EmexCategory[],
|
||||||
@@ -458,7 +230,7 @@ function mapCategories(
|
|||||||
return {
|
return {
|
||||||
code: cat.gid || `CAT_${index}`,
|
code: cat.gid || `CAT_${index}`,
|
||||||
nameEn: cat.name,
|
nameEn: cat.name,
|
||||||
nameTr: translateCategoryName(cat.name),
|
nameTr: undefined,
|
||||||
description: null,
|
description: null,
|
||||||
iconName: deriveIconName(cat.name),
|
iconName: deriveIconName(cat.name),
|
||||||
schemaImageUrl: null,
|
schemaImageUrl: null,
|
||||||
|
|||||||
@@ -1,17 +1,16 @@
|
|||||||
/**
|
/**
|
||||||
* Parts-Catalogs Auth Service — JWT warm pool via Playwright + DataImpulse proxy
|
* Parts-Catalogs Auth Service — v3 token warm pool via Playwright + DataImpulse proxy
|
||||||
*
|
*
|
||||||
* JWT is captured by navigating to partner sites and intercepting
|
* Tokens are captured by navigating to partner sites that embed the v3 widget.
|
||||||
* the Authorization header from requests to parts-catalogs.com.
|
* The widget calls /v3/api/proxy/* with `x-api-key: TWS-{UUID}` and four other
|
||||||
* JWT is IP-bound (~10 min TTL), so the same proxy port must be used for both
|
* X-* headers (api-path, gui-version, user-id, origin, referer); we intercept
|
||||||
* browser capture and subsequent API calls.
|
* all of them so backend requests can replay the exact header set.
|
||||||
|
*
|
||||||
|
* Tokens are IP-bound — same proxy port must be reused for the API calls.
|
||||||
*
|
*
|
||||||
* Warm pool behavior:
|
* Warm pool behavior:
|
||||||
* 09:00-19:00 Istanbul → proactive: maintain >= 1 slot, auto-refresh before expiry
|
* 09:00-19:00 Istanbul → proactive: maintain >= 1 slot, auto-refresh before expiry
|
||||||
* 19:00-09:00 → on-demand only: capture only when needed
|
* 19:00-09:00 → on-demand only: capture only when needed
|
||||||
*
|
|
||||||
* Each slot manages its own refresh timer (no polling loop).
|
|
||||||
* Dynamic scaling: 1 JWT per 6 req/min, capped at 5 slots.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -24,6 +23,7 @@ import { ConfigService } from "@nestjs/config";
|
|||||||
import type { Browser, BrowserContext } from "playwright";
|
import type { Browser, BrowserContext } from "playwright";
|
||||||
import type { PcatJwtToken, JwtSlot, PcatSession } from "./parts-catalogs.types";
|
import type { PcatJwtToken, JwtSlot, PcatSession } from "./parts-catalogs.types";
|
||||||
|
|
||||||
|
const TOKEN_TTL = 600; // seconds — TWS- has no built-in expiry, refresh aggressively
|
||||||
const REFRESH_BUFFER = 90; // Refresh 90s before expiry
|
const REFRESH_BUFFER = 90; // Refresh 90s before expiry
|
||||||
const CAPTURE_POLL_INTERVAL = 500; // ms
|
const CAPTURE_POLL_INTERVAL = 500; // ms
|
||||||
const CAPTURE_POLL_MAX = 40; // 40 × 500ms = 20s max wait
|
const CAPTURE_POLL_MAX = 40; // 40 × 500ms = 20s max wait
|
||||||
@@ -32,11 +32,11 @@ const CONTEXT_CLOSE_TIMEOUT = 5_000;
|
|||||||
const SITE_COOLDOWN = 10 * 60 * 1000; // 10 min per site
|
const SITE_COOLDOWN = 10 * 60 * 1000; // 10 min per site
|
||||||
const MAX_POOL_SIZE = 5;
|
const MAX_POOL_SIZE = 5;
|
||||||
const RPM_WINDOW = 60_000; // 1-minute rolling window
|
const RPM_WINDOW = 60_000; // 1-minute rolling window
|
||||||
const RPM_PER_SLOT = 6; // 1 JWT per 6 req/min
|
const RPM_PER_SLOT = 6; // 1 token per 6 req/min
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sites that embed the parts-catalogs.com widget.
|
* Sites embedding the parts-catalogs.com v3 widget.
|
||||||
* Widget loads JS → calls /api/start → then calls /v1/catalogs/ with JWT.
|
* Widget loads JS → calls /v3/api/proxy/* with x-api-key + supporting X-* headers.
|
||||||
* Each site uses a different proxy port (IP) to avoid rate limiting.
|
* Each site uses a different proxy port (IP) to avoid rate limiting.
|
||||||
*/
|
*/
|
||||||
const JWT_SITES = [
|
const JWT_SITES = [
|
||||||
@@ -50,7 +50,6 @@ const JWT_SITES = [
|
|||||||
"https://www.autodo.kz/#/catalogs",
|
"https://www.autodo.kz/#/catalogs",
|
||||||
"https://avtoman124.ru/goodvin#/catalogs",
|
"https://avtoman124.ru/goodvin#/catalogs",
|
||||||
"https://flynestauto.com/auto-parts-oem-catalog",
|
"https://flynestauto.com/auto-parts-oem-catalog",
|
||||||
"http://en.demo.tradesoft.hk.com/cats/#/catalogs",
|
|
||||||
];
|
];
|
||||||
|
|
||||||
// DataImpulse proxy defaults (port-based IP rotation)
|
// DataImpulse proxy defaults (port-based IP rotation)
|
||||||
@@ -58,7 +57,7 @@ const DI_HOST = "gw.dataimpulse.com";
|
|||||||
const DI_PORT_MIN = 10000;
|
const DI_PORT_MIN = 10000;
|
||||||
const DI_PORT_MAX = 10999;
|
const DI_PORT_MAX = 10999;
|
||||||
const DI_DEFAULT_USER = "1726bbe361918676d44e";
|
const DI_DEFAULT_USER = "1726bbe361918676d44e";
|
||||||
const DI_DEFAULT_PASS = "f11c7b6128cc86c6";
|
const DI_DEFAULT_PASS = "78ebc3d881de6ec0";
|
||||||
|
|
||||||
/** Simple counting semaphore (same pattern as EmexBrowserService) */
|
/** Simple counting semaphore (same pattern as EmexBrowserService) */
|
||||||
class Semaphore {
|
class Semaphore {
|
||||||
@@ -234,7 +233,12 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
|
|||||||
}
|
}
|
||||||
: null;
|
: null;
|
||||||
return {
|
return {
|
||||||
authorization: slot.jwt.raw,
|
apiKey: slot.jwt.raw,
|
||||||
|
apiPath: slot.jwt.apiPath,
|
||||||
|
guiVersion: slot.jwt.guiVersion,
|
||||||
|
userId: slot.jwt.userId,
|
||||||
|
origin: slot.jwt.origin,
|
||||||
|
referer: slot.jwt.referer,
|
||||||
proxyUrl,
|
proxyUrl,
|
||||||
proxyConfig,
|
proxyConfig,
|
||||||
_slot: slot,
|
_slot: slot,
|
||||||
@@ -513,22 +517,28 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
|
|||||||
context = await this.browser!.newContext(contextOptions);
|
context = await this.browser!.newContext(contextOptions);
|
||||||
const page = await context.newPage();
|
const page = await context.newPage();
|
||||||
|
|
||||||
// Intercept requests to parts-catalogs.com
|
// Intercept the v3 widget call to /v3/api/proxy/* — needs the full
|
||||||
let capturedJwt: string | null = null;
|
// header set (x-api-key + x-api-path + x-gui-version + x-user-id +
|
||||||
|
// origin + referer) to replay against gui.parts-catalogs.com.
|
||||||
|
let capturedToken: PcatJwtToken | null = null;
|
||||||
|
|
||||||
page.on("request", (request) => {
|
page.on("request", (request) => {
|
||||||
if (capturedJwt) return;
|
if (capturedToken) return;
|
||||||
const url = request.url();
|
const url = request.url();
|
||||||
if (
|
if (!/\/v3\/api\/proxy\//i.test(url)) return;
|
||||||
url.includes("parts-catalogs.com") ||
|
const h = request.headers();
|
||||||
url.includes("api.parts-catalogs.com")
|
const apiKey = h["x-api-key"];
|
||||||
) {
|
if (!apiKey || !apiKey.startsWith("TWS-")) return;
|
||||||
const auth = request.headers()["authorization"];
|
capturedToken = {
|
||||||
if (auth) {
|
raw: apiKey,
|
||||||
capturedJwt = auth;
|
exp: Math.floor(Date.now() / 1000) + TOKEN_TTL,
|
||||||
this.logger.debug("JWT intercepted from request");
|
apiPath: h["x-api-path"] || "https://api.parts-catalogs.com/v1",
|
||||||
}
|
guiVersion: h["x-gui-version"] || "3",
|
||||||
}
|
userId: h["x-user-id"] || "",
|
||||||
|
origin: h["origin"] || "",
|
||||||
|
referer: h["referer"] || "",
|
||||||
|
};
|
||||||
|
this.logger.debug(`Token intercepted (key=${apiKey.slice(0, 16)}...)`);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Block heavy resources to save proxy bandwidth
|
// Block heavy resources to save proxy bandwidth
|
||||||
@@ -569,26 +579,25 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Poll for JWT
|
// Poll for token
|
||||||
for (let i = 0; i < CAPTURE_POLL_MAX; i++) {
|
for (let i = 0; i < CAPTURE_POLL_MAX; i++) {
|
||||||
if (capturedJwt) break;
|
if (capturedToken) break;
|
||||||
await new Promise((r) => setTimeout(r, CAPTURE_POLL_INTERVAL));
|
await new Promise((r) => setTimeout(r, CAPTURE_POLL_INTERVAL));
|
||||||
}
|
}
|
||||||
|
|
||||||
const elapsed = Date.now() - startTime;
|
const elapsed = Date.now() - startTime;
|
||||||
|
|
||||||
if (capturedJwt) {
|
if (capturedToken) {
|
||||||
const token = this.parseJwt(capturedJwt);
|
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`JWT captured in ${elapsed}ms from ${new URL(siteUrl).hostname}`,
|
`Token captured in ${elapsed}ms from ${new URL(siteUrl).hostname}`,
|
||||||
);
|
);
|
||||||
return token;
|
return capturedToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.debug(`No JWT after ${elapsed}ms from ${siteUrl}`);
|
this.logger.debug(`No token after ${elapsed}ms from ${siteUrl}`);
|
||||||
return null;
|
return null;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.warn(`JWT capture error: ${(err as Error).message}`);
|
this.logger.warn(`Token capture error: ${(err as Error).message}`);
|
||||||
return null;
|
return null;
|
||||||
} finally {
|
} finally {
|
||||||
if (context) {
|
if (context) {
|
||||||
@@ -604,34 +613,6 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private parseJwt(rawToken: string): PcatJwtToken {
|
|
||||||
const parts = rawToken.split(".");
|
|
||||||
if (parts.length !== 3) {
|
|
||||||
throw new Error("Invalid JWT format");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Decode payload with proper base64url padding
|
|
||||||
let payloadB64 = parts[1];
|
|
||||||
const padding = 4 - (payloadB64.length % 4);
|
|
||||||
if (padding !== 4) {
|
|
||||||
payloadB64 += "=".repeat(padding);
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = JSON.parse(
|
|
||||||
Buffer.from(payloadB64, "base64url").toString("utf-8"),
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
raw: rawToken,
|
|
||||||
exp: payload.exp || 0,
|
|
||||||
host: payload.host || "",
|
|
||||||
apiKey: payload.apiKey || "",
|
|
||||||
apiPath: payload.apiPath || "",
|
|
||||||
ip: payload.ip || "",
|
|
||||||
hash: payload.h || "",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Browser lifecycle ───────────────────────────────────
|
// ─── Browser lifecycle ───────────────────────────────────
|
||||||
|
|
||||||
private async launchBrowser(): Promise<void> {
|
private async launchBrowser(): Promise<void> {
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* Parts-Catalogs API Service — HTTP client for parts-catalogs.com
|
* Parts-Catalogs API Service — HTTP client for parts-catalogs.com v3
|
||||||
*
|
*
|
||||||
* All requests go through the same DataImpulse proxy as the JWT capture
|
* Calls the v3 widget proxy (gui.parts-catalogs.com/v3/api/proxy/*) with the
|
||||||
* to ensure the JWT's IP-bound constraint is satisfied.
|
* captured TWS- token + supporting X-* headers. Requests must go through the
|
||||||
|
* same DataImpulse proxy port that captured the token (IP-bound).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Injectable, Logger } from "@nestjs/common";
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
@@ -16,7 +17,7 @@ import type {
|
|||||||
PcatSession,
|
PcatSession,
|
||||||
} from "./parts-catalogs.types";
|
} from "./parts-catalogs.types";
|
||||||
|
|
||||||
const API_BASE = "https://api.parts-catalogs.com/v1";
|
const API_BASE = "https://gui.parts-catalogs.com/v3/api/proxy";
|
||||||
const REQUEST_TIMEOUT = 30_000;
|
const REQUEST_TIMEOUT = 30_000;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -192,10 +193,15 @@ export class PartsCatalogsService {
|
|||||||
const fetchOptions: RequestInit & { dispatcher?: any } = {
|
const fetchOptions: RequestInit & { dispatcher?: any } = {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: session.authorization,
|
"x-api-key": session.apiKey,
|
||||||
|
"x-api-path": session.apiPath,
|
||||||
|
"x-gui-version": session.guiVersion,
|
||||||
|
"x-user-id": session.userId,
|
||||||
|
origin: session.origin,
|
||||||
|
referer: session.referer,
|
||||||
Accept: "application/json",
|
Accept: "application/json",
|
||||||
"User-Agent":
|
"User-Agent":
|
||||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36",
|
||||||
},
|
},
|
||||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT),
|
signal: AbortSignal.timeout(REQUEST_TIMEOUT),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
|
/**
|
||||||
|
* Captured from a parts-catalogs.com v3 widget request.
|
||||||
|
* Token + the supporting X-* headers the widget sends with every API call.
|
||||||
|
* IP-bound (must be reused with the same proxy port that captured it).
|
||||||
|
*/
|
||||||
export interface PcatJwtToken {
|
export interface PcatJwtToken {
|
||||||
raw: string;
|
raw: string; // x-api-key value, e.g. "TWS-016EA7BE-..."
|
||||||
exp: number;
|
exp: number; // unix epoch seconds (capturedAt + TTL_FALLBACK)
|
||||||
host: string;
|
apiPath: string; // x-api-path (upstream PCAT API base URL)
|
||||||
apiKey: string;
|
guiVersion: string; // x-gui-version (e.g. "3")
|
||||||
apiPath: string;
|
userId: string; // x-user-id (per-session UUID minted by widget)
|
||||||
ip: string;
|
origin: string; // partner-site origin
|
||||||
hash: string;
|
referer: string; // partner-site referer
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface JwtSlot {
|
export interface JwtSlot {
|
||||||
@@ -17,7 +22,12 @@ export interface JwtSlot {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface PcatSession {
|
export interface PcatSession {
|
||||||
authorization: string;
|
apiKey: string; // x-api-key (TWS- token)
|
||||||
|
apiPath: string;
|
||||||
|
guiVersion: string;
|
||||||
|
userId: string;
|
||||||
|
origin: string;
|
||||||
|
referer: string;
|
||||||
proxyUrl: string | null;
|
proxyUrl: string | null;
|
||||||
proxyConfig: { server: string; username: string; password: string } | null;
|
proxyConfig: { server: string; username: string; password: string } | null;
|
||||||
_slot: JwtSlot;
|
_slot: JwtSlot;
|
||||||
|
|||||||
@@ -571,6 +571,73 @@ export class PL24Service {
|
|||||||
/**
|
/**
|
||||||
* Re-fetch main groups using a stored mainGroupsPath.
|
* Re-fetch main groups using a stored mainGroupsPath.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Fetch P5 restriction options from a given path (restrictions1/2/3 endpoint).
|
||||||
|
* Returns an array of selectable options, each with a path to the next level.
|
||||||
|
*/
|
||||||
|
async fetchP5Restrictions(
|
||||||
|
serviceName: string,
|
||||||
|
restrictionPath: string,
|
||||||
|
): Promise<{
|
||||||
|
options: Array<{ code: string; name: string; path: string }>;
|
||||||
|
isFinal: boolean;
|
||||||
|
}> {
|
||||||
|
await this.touchActivity();
|
||||||
|
try {
|
||||||
|
await this.authService.authorizeService(serviceName);
|
||||||
|
const headers = await this.authService.buildAuthHeaders(serviceName);
|
||||||
|
const localizedPath = restrictionPath.replace(/lang=\w+/, `lang=${this.language}`);
|
||||||
|
const url = `${this.baseUrl}${localizedPath}`;
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: "GET",
|
||||||
|
headers,
|
||||||
|
signal: AbortSignal.timeout(this.timeout),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
this.logger.warn(`P5 restrictions fetch failed: HTTP ${response.status}`);
|
||||||
|
return { options: [], isFinal: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as Record<string, any>;
|
||||||
|
let records: any[] = [];
|
||||||
|
if (Array.isArray(data?.data?.records)) {
|
||||||
|
records = data.data.records;
|
||||||
|
} else if (Array.isArray(data)) {
|
||||||
|
records = data;
|
||||||
|
}
|
||||||
|
|
||||||
|
const options = records
|
||||||
|
.filter((r) => r.id || r.values?.caption)
|
||||||
|
.map((r) => ({
|
||||||
|
code: String(r.id ?? ""),
|
||||||
|
name: String(r.values?.caption ?? r.id ?? ""),
|
||||||
|
path: String(r.link?.path ?? ""),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Detect if we've reached the mainGroups / parts level:
|
||||||
|
// - The fetched URL itself contains "/mainGroup" (we ARE at the mainGroups endpoint)
|
||||||
|
// - OR the records' link.wid indicates parts navigation (subGroup, partsList, etc.)
|
||||||
|
const fetchedMainGroups = restrictionPath.includes("/mainGroup");
|
||||||
|
const firstWid = String(records[0]?.link?.wid ?? "");
|
||||||
|
const partsWids = ["subGroupTable", "subGroupNodeTable", "partsListTable", "mainGroupNodeTable"];
|
||||||
|
const widsIndicateParts = partsWids.some((w) => firstWid.includes(w) || firstWid.includes("Group"));
|
||||||
|
|
||||||
|
const isFinal = fetchedMainGroups || widsIndicateParts;
|
||||||
|
if (isFinal) {
|
||||||
|
this.logger.log(
|
||||||
|
`P5 restrictions: reached final level (path=${restrictionPath.substring(0, 80)}, wid=${firstWid})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { options, isFinal };
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn(`fetchP5Restrictions failed: ${(error as Error).message}`);
|
||||||
|
return { options: [], isFinal: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fetchMainGroups(
|
async fetchMainGroups(
|
||||||
serviceName: string,
|
serviceName: string,
|
||||||
mainGroupsPath: string,
|
mainGroupsPath: string,
|
||||||
|
|||||||
@@ -284,7 +284,7 @@ export class SubscriptionsService {
|
|||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const endDate = new Date(now);
|
const endDate = new Date(now);
|
||||||
endDate.setDate(endDate.getDate() + 7);
|
endDate.setDate(endDate.getDate() + 30);
|
||||||
|
|
||||||
// Create trial subscription
|
// Create trial subscription
|
||||||
const [subscription] = await this.db
|
const [subscription] = await this.db
|
||||||
|
|||||||
@@ -118,10 +118,10 @@ describe("TranslationsService", () => {
|
|||||||
it("should return original text when no dictionary match found", async () => {
|
it("should return original text when no dictionary match found", async () => {
|
||||||
const result = await service.translate(
|
const result = await service.translate(
|
||||||
"cat:unknown-part",
|
"cat:unknown-part",
|
||||||
"xylophone bracket",
|
"xylophone harpsichord",
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(result.translatedText).toBe("xylophone bracket");
|
expect(result.translatedText).toBe("xylophone harpsichord");
|
||||||
expect(result.source).toBe("none");
|
expect(result.source).toBe("none");
|
||||||
expect(result.isAutoTranslated).toBe(false);
|
expect(result.isAutoTranslated).toBe(false);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,105 +1,247 @@
|
|||||||
import { Inject, Injectable, Logger } from "@nestjs/common";
|
import { Inject, Injectable, Logger } from "@nestjs/common";
|
||||||
import { eq, ilike, or } from "drizzle-orm";
|
import { eq, ilike, inArray, or } from "drizzle-orm";
|
||||||
import { DATABASE, Database } from "../database/database.provider";
|
import { DATABASE, Database } from "../database/database.provider";
|
||||||
import { emexCategoryTranslations } from "../database/schema/core";
|
import { emexCategoryTranslations } from "../database/schema/core";
|
||||||
import { RedisService } from "../redis/redis.service";
|
import { RedisService } from "../redis/redis.service";
|
||||||
|
|
||||||
/** 30 days in seconds */
|
/** 30 days in seconds */
|
||||||
const CACHE_TTL = 30 * 24 * 60 * 60;
|
const CACHE_TTL = 30 * 24 * 60 * 60;
|
||||||
|
/** 1 day TTL for cache misses (allow re-check sooner) */
|
||||||
|
const CACHE_MISS_TTL = 24 * 60 * 60;
|
||||||
const CACHE_PREFIX = "tr:";
|
const CACHE_PREFIX = "tr:";
|
||||||
|
|
||||||
/** Common EN → TR automotive dictionary */
|
/**
|
||||||
|
* EN → TR automotive dictionary. Used as the last fallback before returning
|
||||||
|
* the original text. Keys are lowercased; values use Turkish diacritics.
|
||||||
|
*
|
||||||
|
* Covers the common terms most likely to appear in EMEX category/part names.
|
||||||
|
* Anything unmapped here is sent through the LLM bootstrap script
|
||||||
|
* (scripts/emex-translate-bootstrap.ts) and persisted in
|
||||||
|
* `emex_category_translations`.
|
||||||
|
*/
|
||||||
const AUTOMOTIVE_DICTIONARY: Record<string, string> = {
|
const AUTOMOTIVE_DICTIONARY: Record<string, string> = {
|
||||||
"engine": "Motor",
|
// Engine + powertrain
|
||||||
"brake": "Fren",
|
engine: "Motor",
|
||||||
"steering": "Direksiyon",
|
motor: "Motor",
|
||||||
"suspension": "Süspansiyon",
|
piston: "Piston",
|
||||||
"exhaust": "Egzoz",
|
cylinder: "Silindir",
|
||||||
"transmission": "Şanzıman",
|
crankshaft: "Krank Mili",
|
||||||
"radiator": "Radyatör",
|
camshaft: "Eksantrik Mili",
|
||||||
"battery": "Akü",
|
valve: "Supap",
|
||||||
"filter": "Filtre",
|
turbocharger: "Turbo",
|
||||||
"clutch": "Debriyaj",
|
turbo: "Turbo",
|
||||||
"shock absorber": "Amortisör",
|
intercooler: "Intercooler",
|
||||||
"alternator": "Alternatör",
|
manifold: "Manifold",
|
||||||
"starter": "Marş Motoru",
|
"intake manifold": "Emme Manifoldu",
|
||||||
|
"exhaust manifold": "Egzoz Manifoldu",
|
||||||
|
flywheel: "Volan",
|
||||||
"spark plug": "Buji",
|
"spark plug": "Buji",
|
||||||
"fuel pump": "Yakıt Pompası",
|
"ignition coil": "Ateşleme Bobini",
|
||||||
"water pump": "Su Pompası",
|
injector: "Enjektör",
|
||||||
"oil pump": "Yağ Pompası",
|
"fuel injector": "Yakıt Enjektörü",
|
||||||
"timing belt": "Triger Kayışı",
|
distributor: "Distribütör",
|
||||||
"fan belt": "Vantilatör Kayışı",
|
"voltage regulator": "Voltaj Regülatörü",
|
||||||
"gasket": "Conta",
|
"throttle body": "Gaz Kelebeği",
|
||||||
"piston": "Piston",
|
|
||||||
"cylinder": "Silindir",
|
// Transmission
|
||||||
"crankshaft": "Krank Mili",
|
transmission: "Şanzıman",
|
||||||
"camshaft": "Eksantrik Mili",
|
gearbox: "Vites Kutusu",
|
||||||
"valve": "Supap",
|
clutch: "Debriyaj",
|
||||||
"turbocharger": "Turbo",
|
"clutch kit": "Debriyaj Seti",
|
||||||
"intercooler": "Intercooler",
|
"gear lever": "Vites Kolu",
|
||||||
"catalytic converter": "Katalitik Konvertör",
|
differential: "Diferansiyel",
|
||||||
"muffler": "Susturucu",
|
"drive shaft": "Şaft",
|
||||||
"bumper": "Tampon",
|
driveshaft: "Şaft",
|
||||||
"fender": "Çamurluk",
|
"axle shaft": "Aks Mili",
|
||||||
"hood": "Kaput",
|
axle: "Aks",
|
||||||
"trunk": "Bagaj",
|
"cv joint": "Aks Kafası",
|
||||||
"windshield": "Ön Cam",
|
"cv boot": "Aks Körüğü",
|
||||||
"mirror": "Ayna",
|
|
||||||
"headlight": "Far",
|
// Brake
|
||||||
"tail light": "Stop Lambası",
|
brake: "Fren",
|
||||||
"wiper": "Silecek",
|
brakes: "Fren Sistemi",
|
||||||
"door": "Kapı",
|
|
||||||
"wheel": "Jant",
|
|
||||||
"tire": "Lastik",
|
|
||||||
"axle": "Aks",
|
|
||||||
"bearing": "Rulman",
|
|
||||||
"caliper": "Kaliper",
|
|
||||||
"brake pad": "Fren Balatası",
|
"brake pad": "Fren Balatası",
|
||||||
"brake disc": "Fren Diski",
|
"brake disc": "Fren Diski",
|
||||||
"air filter": "Hava Filtresi",
|
"brake rotor": "Fren Diski",
|
||||||
"oil filter": "Yağ Filtresi",
|
"brake hose": "Fren Hortumu",
|
||||||
"fuel filter": "Yakıt Filtresi",
|
"brake line": "Fren Borusu",
|
||||||
"cabin filter": "Polen Filtresi",
|
"brake fluid": "Fren Hidroliği",
|
||||||
"thermostat": "Termostat",
|
caliper: "Fren Kaliperi",
|
||||||
"sensor": "Sensör",
|
"master cylinder": "Ana Merkez",
|
||||||
"relay": "Röle",
|
"slave cylinder": "Yardımcı Merkez",
|
||||||
"fuse": "Sigorta",
|
handbrake: "El Freni",
|
||||||
"compressor": "Kompresör",
|
|
||||||
"condenser": "Kondenser",
|
// Suspension + steering
|
||||||
"evaporator": "Evaporatör",
|
suspension: "Süspansiyon",
|
||||||
"hose": "Hortum",
|
steering: "Direksiyon",
|
||||||
"belt": "Kayış",
|
"steering wheel": "Direksiyon Simidi",
|
||||||
"spring": "Yay",
|
"steering rack": "Kremayer",
|
||||||
"strut": "Amortisör Bacağı",
|
"steering gear": "Direksiyon Kutusu",
|
||||||
|
"power steering": "Hidrolik Direksiyon",
|
||||||
|
"power steering pump": "Hidrolik Direksiyon Pompası",
|
||||||
|
"power steering hose": "Hidrolik Direksiyon Hortumu",
|
||||||
|
"shock absorber": "Amortisör",
|
||||||
|
shock: "Amortisör",
|
||||||
|
strut: "Makferson",
|
||||||
|
spring: "Yay",
|
||||||
"control arm": "Salıncak",
|
"control arm": "Salıncak",
|
||||||
"tie rod": "Rot Başı",
|
"tie rod": "Rot Başı",
|
||||||
"ball joint": "Rotil",
|
"ball joint": "Rotil",
|
||||||
"cv joint": "Aks Kafası",
|
|
||||||
"drive shaft": "Şaft",
|
// Wheels + tires
|
||||||
"differential": "Diferansiyel",
|
wheel: "Tekerlek",
|
||||||
"gearbox": "Vites Kutusu",
|
wheels: "Jantlar",
|
||||||
"flywheel": "Volan",
|
"wheel hub": "Poyra",
|
||||||
"injector": "Enjektör",
|
hub: "Poyra",
|
||||||
"throttle body": "Gaz Kelebeği",
|
"wheel bearing": "Tekerlek Rulmanı",
|
||||||
"manifold": "Manifold",
|
"hub bearing": "Poyra Rulmanı",
|
||||||
|
bearing: "Rulman",
|
||||||
|
"wheel stud": "Bijon",
|
||||||
|
"lug nut": "Bijon Somunu",
|
||||||
|
tire: "Lastik",
|
||||||
|
tires: "Lastikler",
|
||||||
|
|
||||||
|
// Cooling + fuel + air
|
||||||
|
cooling: "Soğutma Sistemi",
|
||||||
|
radiator: "Radyatör",
|
||||||
|
"radiator hose": "Radyatör Hortumu",
|
||||||
|
thermostat: "Termostat",
|
||||||
|
"water pump": "Su Pompası",
|
||||||
|
"fan belt": "Vantilatör Kayışı",
|
||||||
|
fan: "Fan",
|
||||||
|
coolant: "Antifriz",
|
||||||
|
fuel: "Yakıt",
|
||||||
|
"fuel system": "Yakıt Sistemi",
|
||||||
|
"fuel pump": "Yakıt Pompası",
|
||||||
|
"fuel filter": "Yakıt Filtresi",
|
||||||
|
"fuel tank": "Yakıt Deposu",
|
||||||
|
air: "Hava",
|
||||||
|
"air filter": "Hava Filtresi",
|
||||||
|
"cabin filter": "Polen Filtresi",
|
||||||
|
"pollen filter": "Polen Filtresi",
|
||||||
|
"oil filter": "Yağ Filtresi",
|
||||||
|
"oil pump": "Yağ Pompası",
|
||||||
|
"oil pan": "Karter",
|
||||||
|
"engine oil": "Motor Yağı",
|
||||||
|
oil: "Yağ",
|
||||||
|
filter: "Filtre",
|
||||||
|
filters: "Filtreler",
|
||||||
|
|
||||||
|
// Exhaust
|
||||||
|
exhaust: "Egzoz Sistemi",
|
||||||
|
"catalytic converter": "Katalitik Konvertör",
|
||||||
|
muffler: "Susturucu",
|
||||||
|
|
||||||
|
// Electrical
|
||||||
|
electrical: "Elektrik Sistemi",
|
||||||
|
battery: "Akü",
|
||||||
|
alternator: "Alternatör",
|
||||||
|
starter: "Marş Motoru",
|
||||||
|
sensor: "Sensör",
|
||||||
"oxygen sensor": "Oksijen Sensörü",
|
"oxygen sensor": "Oksijen Sensörü",
|
||||||
"abs sensor": "ABS Sensörü",
|
"abs sensor": "ABS Sensörü",
|
||||||
"ignition coil": "Ateşleme Bobini",
|
"crankshaft sensor": "Krank Sensörü",
|
||||||
"distributor": "Distribütör",
|
"camshaft sensor": "Eksantrik Sensörü",
|
||||||
"voltage regulator": "Voltaj Regülatörü",
|
"coolant sensor": "Su Isısı Sensörü",
|
||||||
"window regulator": "Cam Krikosu",
|
"oil pressure sensor": "Yağ Basıncı Sensörü",
|
||||||
|
relay: "Röle",
|
||||||
|
fuse: "Sigorta",
|
||||||
|
switch: "Anahtar",
|
||||||
|
horn: "Korna",
|
||||||
|
airbag: "Hava Yastığı",
|
||||||
|
|
||||||
|
// HVAC
|
||||||
|
"air conditioning": "Klima",
|
||||||
|
climate: "Klima",
|
||||||
|
compressor: "Kompresör",
|
||||||
|
condenser: "Kondenser",
|
||||||
|
evaporator: "Evaporatör",
|
||||||
|
heater: "Isıtıcı",
|
||||||
|
blower: "Üfleyici",
|
||||||
|
|
||||||
|
// Belts, seals, gaskets
|
||||||
|
belt: "Kayış",
|
||||||
|
"timing belt": "Triger Kayışı",
|
||||||
|
"timing chain": "Triger Zinciri",
|
||||||
|
"serpentine belt": "V Kayışı",
|
||||||
|
"drive belt": "Tahrik Kayışı",
|
||||||
|
tensioner: "Gerdirici",
|
||||||
|
"belt tensioner": "Kayış Gerdirici",
|
||||||
|
idler: "Avare",
|
||||||
|
"idler pulley": "Avare Kasnak",
|
||||||
|
pulley: "Kasnak",
|
||||||
|
"crankshaft pulley": "Krank Kasnağı",
|
||||||
|
hose: "Hortum",
|
||||||
|
gasket: "Conta",
|
||||||
|
"head gasket": "Silindir Kapağı Contası",
|
||||||
|
"valve cover gasket": "Külbütör Kapağı Contası",
|
||||||
|
"oil pan gasket": "Karter Contası",
|
||||||
|
"intake manifold gasket": "Emme Manifoldu Contası",
|
||||||
|
"exhaust manifold gasket": "Egzoz Manifoldu Contası",
|
||||||
|
seal: "Keçe",
|
||||||
|
"o-ring": "O-Ring",
|
||||||
|
|
||||||
|
// Body + exterior
|
||||||
|
body: "Kaporta",
|
||||||
|
bumper: "Tampon",
|
||||||
|
"front bumper": "Ön Tampon",
|
||||||
|
"rear bumper": "Arka Tampon",
|
||||||
|
fender: "Çamurluk",
|
||||||
|
mudguard: "Çamurluk",
|
||||||
|
"splash guard": "Paçalık",
|
||||||
|
hood: "Kaput",
|
||||||
|
bonnet: "Kaput",
|
||||||
|
trunk: "Bagaj",
|
||||||
|
boot: "Bagaj",
|
||||||
|
grille: "Izgara",
|
||||||
|
exterior: "Dış Aksam",
|
||||||
|
|
||||||
|
// Doors + windows + mirrors
|
||||||
|
door: "Kapı",
|
||||||
|
"door handle": "Kapı Kolu",
|
||||||
"door lock": "Kapı Kilidi",
|
"door lock": "Kapı Kilidi",
|
||||||
"seat": "Koltuk",
|
"door hinge": "Kapı Menteşesi",
|
||||||
"dashboard": "Gösterge Paneli",
|
window: "Cam",
|
||||||
"steering wheel": "Direksiyon Simidi",
|
windshield: "Ön Cam",
|
||||||
"gear lever": "Vites Kolu",
|
windscreen: "Ön Cam",
|
||||||
"handbrake": "El Freni",
|
"window regulator": "Cam Krikosu",
|
||||||
"pedal": "Pedal",
|
"window motor": "Cam Motoru",
|
||||||
"radiator hose": "Radyatör Hortumu",
|
mirror: "Ayna",
|
||||||
"coolant": "Antifriz",
|
"side mirror": "Yan Ayna",
|
||||||
"brake fluid": "Fren Hidroliği",
|
"rear view mirror": "İç Ayna",
|
||||||
"engine oil": "Motor Yağı",
|
|
||||||
"power steering": "Hidrolik Direksiyon",
|
// Wipers + lights
|
||||||
|
wiper: "Silecek",
|
||||||
|
"wiper blade": "Silecek Lastiği",
|
||||||
|
"wiper motor": "Silecek Motoru",
|
||||||
|
lighting: "Aydınlatma",
|
||||||
|
lights: "Aydınlatma",
|
||||||
|
headlight: "Far",
|
||||||
|
"headlight bulb": "Far Ampulü",
|
||||||
|
"tail light": "Stop Lambası",
|
||||||
|
taillight: "Stop Lambası",
|
||||||
|
"fog light": "Sis Lambası",
|
||||||
|
"turn signal": "Sinyal Lambası",
|
||||||
|
indicator: "Sinyal Lambası",
|
||||||
|
|
||||||
|
// Interior
|
||||||
|
interior: "İç Aksam",
|
||||||
|
seat: "Koltuk",
|
||||||
|
dashboard: "Gösterge Paneli",
|
||||||
|
pedal: "Pedal",
|
||||||
|
carpet: "Halı",
|
||||||
|
mat: "Paspas",
|
||||||
|
|
||||||
|
// Misc / fasteners
|
||||||
|
pump: "Pompa",
|
||||||
|
screw: "Vida",
|
||||||
|
bolt: "Cıvata",
|
||||||
|
nut: "Somun",
|
||||||
|
washer: "Pul",
|
||||||
|
clip: "Klips",
|
||||||
|
bracket: "Braket",
|
||||||
|
cover: "Kapak",
|
||||||
|
plug: "Tıpa",
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface TranslationResult {
|
export interface TranslationResult {
|
||||||
@@ -149,32 +291,9 @@ export class TranslationsService {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Try dictionary-based translation
|
// 3. No DB hit — return original. Dictionary fallback removed: its
|
||||||
const dictTranslation = this.translateWithDictionary(sourceText);
|
// word-by-word replacement produces half-translated strings and
|
||||||
if (dictTranslation !== null) {
|
// would poison the DB. Bootstrap script handles new terms via LLM.
|
||||||
const result: TranslationResult = {
|
|
||||||
key,
|
|
||||||
sourceText,
|
|
||||||
translatedText: dictTranslation,
|
|
||||||
source: "dictionary",
|
|
||||||
isAutoTranslated: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Persist to DB for future lookups
|
|
||||||
await this.db
|
|
||||||
.insert(emexCategoryTranslations)
|
|
||||||
.values({
|
|
||||||
originalName: sourceText,
|
|
||||||
translatedName: dictTranslation,
|
|
||||||
isManual: false,
|
|
||||||
})
|
|
||||||
.onConflictDoNothing();
|
|
||||||
|
|
||||||
await this.redis.setJson(cacheKey, result, CACHE_TTL);
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. No translation found — return original with flag
|
|
||||||
const result: TranslationResult = {
|
const result: TranslationResult = {
|
||||||
key,
|
key,
|
||||||
sourceText,
|
sourceText,
|
||||||
@@ -182,21 +301,122 @@ export class TranslationsService {
|
|||||||
source: "none",
|
source: "none",
|
||||||
isAutoTranslated: false,
|
isAutoTranslated: false,
|
||||||
};
|
};
|
||||||
// Cache "miss" with shorter TTL (1 day) so it gets re-checked sooner
|
await this.redis.setJson(cacheKey, result, CACHE_MISS_TTL);
|
||||||
await this.redis.setJson(cacheKey, result, 24 * 60 * 60);
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Batch translate multiple items
|
* Bulk translate many strings in one shot — used by EMEX category/part
|
||||||
|
* insertion paths (categories.service.ts) where a single fetch can yield
|
||||||
|
* dozens to hundreds of names.
|
||||||
|
*
|
||||||
|
* Pipeline: Redis MGET → DB IN(...) → dictionary fallback → bulk INSERT
|
||||||
|
* → Redis pipeline SET. Returns Map<originalText, translatedText>; entries
|
||||||
|
* for which no translation is available map to the original text.
|
||||||
|
*/
|
||||||
|
async translateMany(sourceTexts: string[]): Promise<Map<string, string>> {
|
||||||
|
const result = new Map<string, string>();
|
||||||
|
if (!sourceTexts.length) return result;
|
||||||
|
|
||||||
|
// Dedupe and drop empties — caller often has duplicates across rows
|
||||||
|
const unique = [...new Set(sourceTexts.filter((s) => s && s.trim().length > 0))];
|
||||||
|
if (!unique.length) return result;
|
||||||
|
|
||||||
|
// Phase 1: Redis MGET
|
||||||
|
const redis = this.redis.getClient();
|
||||||
|
const cacheKeys = unique.map((s) => `${CACHE_PREFIX}${s}`);
|
||||||
|
const cachedRaw = await redis.mget(...cacheKeys);
|
||||||
|
|
||||||
|
const missing: string[] = [];
|
||||||
|
cachedRaw.forEach((raw, idx) => {
|
||||||
|
const text = unique[idx];
|
||||||
|
if (raw) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as TranslationResult;
|
||||||
|
result.set(text, parsed.translatedText);
|
||||||
|
} catch {
|
||||||
|
missing.push(text);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
missing.push(text);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!missing.length) return result;
|
||||||
|
|
||||||
|
// Phase 2: DB IN(...) for cache misses
|
||||||
|
const dbRows = await this.db
|
||||||
|
.select({
|
||||||
|
originalName: emexCategoryTranslations.originalName,
|
||||||
|
translatedName: emexCategoryTranslations.translatedName,
|
||||||
|
isManual: emexCategoryTranslations.isManual,
|
||||||
|
})
|
||||||
|
.from(emexCategoryTranslations)
|
||||||
|
.where(inArray(emexCategoryTranslations.originalName, missing));
|
||||||
|
|
||||||
|
const dbMap = new Map<string, { translated: string; isManual: boolean }>();
|
||||||
|
for (const row of dbRows) {
|
||||||
|
dbMap.set(row.originalName, { translated: row.translatedName, isManual: row.isManual });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 3: Build result map + populate Redis pipeline.
|
||||||
|
// We deliberately do NOT use translateWithDictionary's word-by-word
|
||||||
|
// replacement here — it produces half-translated strings (e.g.
|
||||||
|
// "Body frame" → "Kaporta frame") and would persist them to the DB,
|
||||||
|
// poisoning future lookups. Anything missing falls through to a
|
||||||
|
// short-TTL cache miss; the next emex-translate-bootstrap.ts run
|
||||||
|
// picks it up and writes the proper LLM translation.
|
||||||
|
const pipeline = redis.pipeline();
|
||||||
|
|
||||||
|
for (const text of missing) {
|
||||||
|
const dbHit = dbMap.get(text);
|
||||||
|
if (dbHit) {
|
||||||
|
result.set(text, dbHit.translated);
|
||||||
|
const payload: TranslationResult = {
|
||||||
|
key: text,
|
||||||
|
sourceText: text,
|
||||||
|
translatedText: dbHit.translated,
|
||||||
|
source: "db",
|
||||||
|
isAutoTranslated: !dbHit.isManual,
|
||||||
|
};
|
||||||
|
pipeline.set(`${CACHE_PREFIX}${text}`, JSON.stringify(payload), "EX", CACHE_TTL);
|
||||||
|
} else {
|
||||||
|
result.set(text, text);
|
||||||
|
const payload: TranslationResult = {
|
||||||
|
key: text,
|
||||||
|
sourceText: text,
|
||||||
|
translatedText: text,
|
||||||
|
source: "none",
|
||||||
|
isAutoTranslated: false,
|
||||||
|
};
|
||||||
|
pipeline.set(`${CACHE_PREFIX}${text}`, JSON.stringify(payload), "EX", CACHE_MISS_TTL);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await pipeline.exec();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Batch translate — kept for backwards compatibility with the controller's
|
||||||
|
* /translations/batch endpoint. Internally uses translateMany.
|
||||||
*/
|
*/
|
||||||
async translateBatch(
|
async translateBatch(
|
||||||
items: { key: string; sourceText: string }[],
|
items: { key: string; sourceText: string }[],
|
||||||
): Promise<TranslationResult[]> {
|
): Promise<TranslationResult[]> {
|
||||||
const results = await Promise.all(
|
if (!items.length) return [];
|
||||||
items.map((item) => this.translate(item.key, item.sourceText)),
|
const trMap = await this.translateMany(items.map((i) => i.sourceText));
|
||||||
);
|
return items.map((i) => {
|
||||||
return results;
|
const translated = trMap.get(i.sourceText) ?? i.sourceText;
|
||||||
|
const same = translated === i.sourceText;
|
||||||
|
return {
|
||||||
|
key: i.key,
|
||||||
|
sourceText: i.sourceText,
|
||||||
|
translatedText: translated,
|
||||||
|
source: same ? "none" : "db",
|
||||||
|
isAutoTranslated: !same,
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -225,9 +445,11 @@ export class TranslationsService {
|
|||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
// Invalidate cache
|
// Invalidate cache (key + sourceText so both lookup paths see the change)
|
||||||
const cacheKey = `${CACHE_PREFIX}${key}`;
|
await this.redis.del(`${CACHE_PREFIX}${key}`);
|
||||||
await this.redis.del(cacheKey);
|
if (key !== sourceText) {
|
||||||
|
await this.redis.del(`${CACHE_PREFIX}${sourceText}`);
|
||||||
|
}
|
||||||
|
|
||||||
this.logger.log(`Translation set: "${sourceText}" → "${translatedText}"`);
|
this.logger.log(`Translation set: "${sourceText}" → "${translatedText}"`);
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
<link
|
<link
|
||||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Space+Grotesk:wght@500;600;700&display=swap"
|
href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700;800&family=Geist+Mono:wght@400;500;600&family=Instrument+Serif:ital@0;1&display=swap"
|
||||||
rel="stylesheet"
|
rel="stylesheet"
|
||||||
/>
|
/>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
368
apps/web/src/components/catalog/model-list-columns.tsx
Normal file
368
apps/web/src/components/catalog/model-list-columns.tsx
Normal file
@@ -0,0 +1,368 @@
|
|||||||
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
|
import { Link, useNavigate } from "@tanstack/react-router";
|
||||||
|
import { ArrowLeft, Car, ChevronRight, Loader2 } from "lucide-react";
|
||||||
|
import { cn } from "@sase/ui";
|
||||||
|
import { api } from "@/lib/api-client";
|
||||||
|
import { getCategoryIcon } from "@/lib/category-icons";
|
||||||
|
|
||||||
|
/* ── Types ── */
|
||||||
|
|
||||||
|
interface CatalogVehicle {
|
||||||
|
id: string;
|
||||||
|
brandName: string;
|
||||||
|
model: string;
|
||||||
|
year: string | null;
|
||||||
|
engine: string | null;
|
||||||
|
bodyType: string | null;
|
||||||
|
transmission: string | null;
|
||||||
|
architecture: string | null;
|
||||||
|
catalogPath: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ColumnItem =
|
||||||
|
| { kind: "model"; id: string; label: string; sublabel?: string; vehicle: CatalogVehicle }
|
||||||
|
| { kind: "restriction"; code: string; name: string; path: string; isFinal: boolean }
|
||||||
|
| { kind: "category"; id: string; name: string; isLeaf: boolean; children?: any[] };
|
||||||
|
|
||||||
|
interface Column {
|
||||||
|
type: "models" | "restrictions" | "categories";
|
||||||
|
items: ColumnItem[];
|
||||||
|
selectedId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Component ── */
|
||||||
|
|
||||||
|
export function ModelListColumns({
|
||||||
|
models,
|
||||||
|
brandName,
|
||||||
|
}: {
|
||||||
|
models: CatalogVehicle[];
|
||||||
|
brandName: string;
|
||||||
|
}) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [loadingCol, setLoadingCol] = useState<number | null>(null);
|
||||||
|
|
||||||
|
// Track selected vehicle for category navigation and back button
|
||||||
|
const selectedVehicleRef = useRef<string | null>(null);
|
||||||
|
const selectedModelLabelRef = useRef<string | null>(null);
|
||||||
|
const [inCategoryMode, setInCategoryMode] = useState(false);
|
||||||
|
|
||||||
|
const modelItems: ColumnItem[] = models.map((m) => ({
|
||||||
|
kind: "model" as const,
|
||||||
|
id: m.id,
|
||||||
|
label: m.model,
|
||||||
|
sublabel: [m.year, m.engine].filter(Boolean).join(" · ") || undefined,
|
||||||
|
vehicle: m,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const [columns, setColumns] = useState<Column[]>([
|
||||||
|
{ type: "models", items: modelItems },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Reset when models change
|
||||||
|
useEffect(() => {
|
||||||
|
setColumns([{ type: "models", items: modelItems }]);
|
||||||
|
selectedVehicleRef.current = null;
|
||||||
|
selectedModelLabelRef.current = null;
|
||||||
|
setInCategoryMode(false);
|
||||||
|
}, [models]);
|
||||||
|
|
||||||
|
const handleBackToModels = useCallback(() => {
|
||||||
|
setColumns([{ type: "models", items: modelItems }]);
|
||||||
|
setInCategoryMode(false);
|
||||||
|
}, [modelItems]);
|
||||||
|
|
||||||
|
// Auto-scroll right when new column added
|
||||||
|
useEffect(() => {
|
||||||
|
if (scrollRef.current) {
|
||||||
|
scrollRef.current.scrollLeft = scrollRef.current.scrollWidth;
|
||||||
|
}
|
||||||
|
}, [columns.length]);
|
||||||
|
|
||||||
|
const truncateColumns = useCallback((fromIndex: number) => {
|
||||||
|
setColumns((prev) => prev.slice(0, fromIndex + 1));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setSelectedInColumn = useCallback((colIdx: number, itemId: string) => {
|
||||||
|
setColumns((prev) =>
|
||||||
|
prev.map((col, i) => (i === colIdx ? { ...col, selectedId: itemId } : col)),
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/* ── Handlers ── */
|
||||||
|
|
||||||
|
const handleModelSelect = useCallback(
|
||||||
|
async (item: ColumnItem & { kind: "model" }, colIdx: number) => {
|
||||||
|
const vehicle = item.vehicle;
|
||||||
|
truncateColumns(colIdx);
|
||||||
|
setSelectedInColumn(colIdx, item.id);
|
||||||
|
selectedVehicleRef.current = vehicle.id;
|
||||||
|
selectedModelLabelRef.current = item.label;
|
||||||
|
|
||||||
|
const arch = vehicle.architecture;
|
||||||
|
const isLegacyVariant = ["LEGACY_PSA", "LEGACY_FORD", "LEGACY_VOLVO"].includes(arch ?? "");
|
||||||
|
|
||||||
|
if (isLegacyVariant) {
|
||||||
|
// Navigate away — legacy brands need full-page variant selectors
|
||||||
|
navigate({
|
||||||
|
to: "/dashboard/catalog/$brandName/$modelId",
|
||||||
|
params: { brandName, modelId: vehicle.id },
|
||||||
|
search: { body: undefined, engine: undefined, gearbox: undefined, mgp: undefined },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const needsP5Restrictions =
|
||||||
|
arch === "P5_MODERN" &&
|
||||||
|
!!vehicle.catalogPath &&
|
||||||
|
!vehicle.catalogPath.includes("/mainGroup");
|
||||||
|
|
||||||
|
setLoadingCol(colIdx);
|
||||||
|
try {
|
||||||
|
if (needsP5Restrictions) {
|
||||||
|
// Fetch first restriction level
|
||||||
|
const data = await api.get<{ options: any[]; isFinal: boolean }>(
|
||||||
|
`/catalog/vehicles/${vehicle.id}/p5-restrictions`,
|
||||||
|
);
|
||||||
|
const items: ColumnItem[] = (data.options ?? []).map((o: any) => ({
|
||||||
|
kind: "restriction" as const,
|
||||||
|
code: o.code,
|
||||||
|
name: o.name,
|
||||||
|
path: o.path,
|
||||||
|
isFinal: data.isFinal,
|
||||||
|
}));
|
||||||
|
setColumns((prev) => [
|
||||||
|
...prev.slice(0, colIdx + 1),
|
||||||
|
{ type: "restrictions", items },
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
// Fetch categories directly
|
||||||
|
await fetchCategories(vehicle.id, colIdx, undefined);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// On error, do nothing
|
||||||
|
} finally {
|
||||||
|
setLoadingCol(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[brandName, navigate, truncateColumns, setSelectedInColumn],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleRestrictionSelect = useCallback(
|
||||||
|
async (item: ColumnItem & { kind: "restriction" }, colIdx: number) => {
|
||||||
|
truncateColumns(colIdx);
|
||||||
|
setSelectedInColumn(colIdx, item.code);
|
||||||
|
|
||||||
|
const vehicleId = selectedVehicleRef.current;
|
||||||
|
if (!vehicleId) return;
|
||||||
|
|
||||||
|
setLoadingCol(colIdx);
|
||||||
|
try {
|
||||||
|
if (item.isFinal) {
|
||||||
|
// Restrictions complete — fetch categories with mgp
|
||||||
|
await fetchCategories(vehicleId, colIdx, item.path);
|
||||||
|
} else {
|
||||||
|
// More restriction levels
|
||||||
|
const data = await api.get<{ options: any[]; isFinal: boolean }>(
|
||||||
|
`/catalog/vehicles/${vehicleId}/p5-restrictions?path=${encodeURIComponent(item.path)}`,
|
||||||
|
);
|
||||||
|
const items: ColumnItem[] = (data.options ?? []).map((o: any) => ({
|
||||||
|
kind: "restriction" as const,
|
||||||
|
code: o.code,
|
||||||
|
name: o.name,
|
||||||
|
path: o.path,
|
||||||
|
isFinal: data.isFinal,
|
||||||
|
}));
|
||||||
|
setColumns((prev) => [
|
||||||
|
...prev.slice(0, colIdx + 1),
|
||||||
|
{ type: "restrictions", items },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// On error, do nothing
|
||||||
|
} finally {
|
||||||
|
setLoadingCol(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[truncateColumns, setSelectedInColumn],
|
||||||
|
);
|
||||||
|
|
||||||
|
const fetchCategories = useCallback(
|
||||||
|
async (vehicleId: string, _afterColIdx: number, mgp: string | undefined) => {
|
||||||
|
const mgpParam = mgp ? `?mgp=${encodeURIComponent(mgp)}` : "";
|
||||||
|
const categories = await api.get<any[]>(
|
||||||
|
`/catalog/vehicles/${vehicleId}/categories${mgpParam}`,
|
||||||
|
);
|
||||||
|
const items: ColumnItem[] = (categories ?? []).map((c: any) => ({
|
||||||
|
kind: "category" as const,
|
||||||
|
id: c.id,
|
||||||
|
name: c.name,
|
||||||
|
isLeaf: c.children !== undefined && c.children.length === 0,
|
||||||
|
children: c.children,
|
||||||
|
}));
|
||||||
|
// Reset columns — categories start fresh from leftmost column
|
||||||
|
setColumns([{ type: "categories", items }]);
|
||||||
|
setInCategoryMode(true);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCategorySelect = useCallback(
|
||||||
|
async (item: ColumnItem & { kind: "category" }, colIdx: number) => {
|
||||||
|
truncateColumns(colIdx);
|
||||||
|
setSelectedInColumn(colIdx, item.id);
|
||||||
|
|
||||||
|
const vehicleId = selectedVehicleRef.current;
|
||||||
|
if (!vehicleId) return;
|
||||||
|
|
||||||
|
if (item.isLeaf) {
|
||||||
|
// Navigate to schema page
|
||||||
|
navigate({
|
||||||
|
to: "/dashboard/catalog/$brandName/$modelId/categories/$categoryId",
|
||||||
|
params: { brandName, modelId: vehicleId, categoryId: item.id },
|
||||||
|
search: { body: undefined, engine: undefined, gearbox: undefined, mgp: undefined },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If children already known from initial data
|
||||||
|
if (item.children && item.children.length > 0) {
|
||||||
|
const items: ColumnItem[] = item.children.map((c: any) => ({
|
||||||
|
kind: "category" as const,
|
||||||
|
id: c.id,
|
||||||
|
name: c.name,
|
||||||
|
isLeaf: c.children !== undefined && c.children.length === 0,
|
||||||
|
children: c.children,
|
||||||
|
}));
|
||||||
|
setColumns((prev) => [
|
||||||
|
...prev.slice(0, colIdx + 1),
|
||||||
|
{ type: "categories", items },
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lazy fetch children
|
||||||
|
setLoadingCol(colIdx);
|
||||||
|
try {
|
||||||
|
const children = await api.get<any[]>(`/categories/${item.id}/children`);
|
||||||
|
if (!children || children.length === 0) {
|
||||||
|
// Actually a leaf — navigate
|
||||||
|
navigate({
|
||||||
|
to: "/dashboard/catalog/$brandName/$modelId/categories/$categoryId",
|
||||||
|
params: { brandName, modelId: vehicleId, categoryId: item.id },
|
||||||
|
search: { body: undefined, engine: undefined, gearbox: undefined, mgp: undefined },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const items: ColumnItem[] = children.map((c: any) => ({
|
||||||
|
kind: "category" as const,
|
||||||
|
id: c.id,
|
||||||
|
name: c.name,
|
||||||
|
isLeaf: c.children !== undefined && c.children.length === 0,
|
||||||
|
children: c.children,
|
||||||
|
}));
|
||||||
|
setColumns((prev) => [
|
||||||
|
...prev.slice(0, colIdx + 1),
|
||||||
|
{ type: "categories", items },
|
||||||
|
]);
|
||||||
|
} catch {
|
||||||
|
// On error, do nothing
|
||||||
|
} finally {
|
||||||
|
setLoadingCol(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[brandName, navigate, truncateColumns, setSelectedInColumn],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleItemClick = useCallback(
|
||||||
|
(item: ColumnItem, colIdx: number) => {
|
||||||
|
if (item.kind === "model") handleModelSelect(item, colIdx);
|
||||||
|
else if (item.kind === "restriction") handleRestrictionSelect(item, colIdx);
|
||||||
|
else if (item.kind === "category") handleCategorySelect(item, colIdx);
|
||||||
|
},
|
||||||
|
[handleModelSelect, handleRestrictionSelect, handleCategorySelect],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{inCategoryMode && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleBackToModels}
|
||||||
|
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="size-3.5" />
|
||||||
|
<span>{selectedModelLabelRef.current ?? "Modellere Dön"}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
ref={scrollRef}
|
||||||
|
className="flex border rounded-lg overflow-x-auto"
|
||||||
|
style={{ minHeight: 320 }}
|
||||||
|
>
|
||||||
|
{columns.map((col, colIdx) => (
|
||||||
|
<div
|
||||||
|
key={colIdx}
|
||||||
|
className={cn(
|
||||||
|
"w-[220px] shrink-0 overflow-y-auto",
|
||||||
|
colIdx < columns.length - 1 && "border-r",
|
||||||
|
)}
|
||||||
|
style={{ maxHeight: 480 }}
|
||||||
|
>
|
||||||
|
{col.items.length === 0 ? (
|
||||||
|
<div className="flex h-full items-center justify-center p-4 text-xs text-muted-foreground">
|
||||||
|
Sonuç yok
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
col.items.map((item) => {
|
||||||
|
const itemId = item.kind === "restriction" ? item.code : item.id;
|
||||||
|
const isSelected = col.selectedId === itemId;
|
||||||
|
const isLoading = loadingCol === colIdx && isSelected;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={itemId}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleItemClick(item, colIdx)}
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center gap-2 px-3 py-2 text-left text-sm transition-colors",
|
||||||
|
"hover:bg-accent hover:text-accent-foreground",
|
||||||
|
isSelected && "bg-accent text-accent-foreground font-medium",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ItemIcon item={item} />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="truncate">
|
||||||
|
{item.kind === "model" ? item.label : item.name}
|
||||||
|
</p>
|
||||||
|
{item.kind === "model" && item.sublabel && (
|
||||||
|
<p className="text-xs text-muted-foreground truncate">{item.sublabel}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{isLoading ? (
|
||||||
|
<Loader2 className="size-3.5 shrink-0 animate-spin" />
|
||||||
|
) : item.kind === "category" && item.isLeaf ? null : (
|
||||||
|
<ChevronRight className="size-3.5 shrink-0 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ItemIcon({ item }: { item: ColumnItem }) {
|
||||||
|
if (item.kind === "model") {
|
||||||
|
return <Car className="size-4 shrink-0 text-muted-foreground" />;
|
||||||
|
}
|
||||||
|
if (item.kind === "category") {
|
||||||
|
const Icon = getCategoryIcon(item.name);
|
||||||
|
return <Icon className="size-4 shrink-0 text-muted-foreground" />;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
46
apps/web/src/components/catalog/model-list-tree.tsx
Normal file
46
apps/web/src/components/catalog/model-list-tree.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { Link } from "@tanstack/react-router";
|
||||||
|
import { Car, ChevronRight } from "lucide-react";
|
||||||
|
|
||||||
|
interface CatalogVehicle {
|
||||||
|
id: string;
|
||||||
|
brandName: string;
|
||||||
|
model: string;
|
||||||
|
year: string | null;
|
||||||
|
engine: string | null;
|
||||||
|
bodyType: string | null;
|
||||||
|
transmission: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ModelListTree({
|
||||||
|
models,
|
||||||
|
brandName,
|
||||||
|
}: {
|
||||||
|
models: CatalogVehicle[];
|
||||||
|
brandName: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="divide-y rounded-lg border">
|
||||||
|
{models.map((model) => (
|
||||||
|
<Link
|
||||||
|
key={model.id}
|
||||||
|
to="/dashboard/catalog/$brandName/$modelId"
|
||||||
|
params={{ brandName, modelId: model.id }}
|
||||||
|
search={{ body: undefined, engine: undefined, gearbox: undefined, mgp: undefined }}
|
||||||
|
className="flex items-center gap-3 px-4 py-3 transition-colors hover:bg-accent"
|
||||||
|
>
|
||||||
|
<Car className="size-4 shrink-0 text-muted-foreground" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-medium truncate">{model.model}</p>
|
||||||
|
<div className="flex flex-wrap gap-x-2 gap-y-0 text-xs text-muted-foreground">
|
||||||
|
{model.year && <span>{model.year}</span>}
|
||||||
|
{model.engine && <span>{model.engine}</span>}
|
||||||
|
{model.bodyType && <span>{model.bodyType}</span>}
|
||||||
|
{model.transmission && <span>{model.transmission}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ChevronRight className="size-4 shrink-0 text-muted-foreground" />
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
115
apps/web/src/components/catalog/p5-restriction-selector.tsx
Normal file
115
apps/web/src/components/catalog/p5-restriction-selector.tsx
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { api } from "@/lib/api-client";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@sase/ui";
|
||||||
|
import { ArrowLeft, Loader2 } from "lucide-react";
|
||||||
|
|
||||||
|
interface RestrictionOption {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface P5RestrictionsResponse {
|
||||||
|
options: RestrictionOption[];
|
||||||
|
isFinal: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface P5RestrictionSelectorProps {
|
||||||
|
vehicleId: string;
|
||||||
|
onComplete: (mainGroupsPath: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function P5RestrictionSelector({ vehicleId, onComplete }: P5RestrictionSelectorProps) {
|
||||||
|
const [steps, setSteps] = useState<
|
||||||
|
Array<{ label: string; selectedCode: string; selectedPath: string }>
|
||||||
|
>([]);
|
||||||
|
const [currentPath, setCurrentPath] = useState<string | undefined>(undefined);
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery<P5RestrictionsResponse>({
|
||||||
|
queryKey: ["p5-restrictions", vehicleId, currentPath ?? "initial"],
|
||||||
|
queryFn: () => {
|
||||||
|
const pathParam = currentPath
|
||||||
|
? `?path=${encodeURIComponent(currentPath)}`
|
||||||
|
: "";
|
||||||
|
return api.get<P5RestrictionsResponse>(
|
||||||
|
`/catalog/vehicles/${vehicleId}/p5-restrictions${pathParam}`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
enabled: !!vehicleId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const options = data?.options ?? [];
|
||||||
|
|
||||||
|
const handleSelect = (option: RestrictionOption) => {
|
||||||
|
if (data?.isFinal) {
|
||||||
|
// This is the last selection step — option.path leads to mainGroups
|
||||||
|
onComplete(option.path);
|
||||||
|
} else {
|
||||||
|
// More levels needed — advance to next restriction
|
||||||
|
setSteps((prev) => [
|
||||||
|
...prev,
|
||||||
|
{ label: option.name, selectedCode: option.code, selectedPath: option.path },
|
||||||
|
]);
|
||||||
|
setCurrentPath(option.path);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBack = () => {
|
||||||
|
if (steps.length === 0) return;
|
||||||
|
const newSteps = steps.slice(0, -1);
|
||||||
|
setSteps(newSteps);
|
||||||
|
setCurrentPath(newSteps.length > 0 ? newSteps[newSteps.length - 1].selectedPath : undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Araç Konfigürasyonu</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{steps.length > 0 && (
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleBack}
|
||||||
|
className="flex items-center gap-1 text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="size-3" />
|
||||||
|
Geri
|
||||||
|
</button>
|
||||||
|
<span className="text-muted-foreground">|</span>
|
||||||
|
{steps.map((step, i) => (
|
||||||
|
<span key={step.selectedCode} className="text-muted-foreground">
|
||||||
|
{i > 0 && " / "}
|
||||||
|
<span className="font-medium text-foreground">{step.label}</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
Seçenekler yükleniyor...
|
||||||
|
</div>
|
||||||
|
) : options.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">Seçenek bulunamadı</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{options.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option.code}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleSelect(option)}
|
||||||
|
className="rounded-md border border-border bg-background px-3 py-1.5 text-sm transition-colors hover:bg-accent hover:border-accent-foreground/20"
|
||||||
|
>
|
||||||
|
{option.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
259
apps/web/src/components/categories/category-columns.tsx
Normal file
259
apps/web/src/components/categories/category-columns.tsx
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { ChevronRight, Loader2 } from "lucide-react";
|
||||||
|
import { cn } from "@sase/ui";
|
||||||
|
import { api } from "@/lib/api-client";
|
||||||
|
import { getCategoryIcon } from "@/lib/category-icons";
|
||||||
|
|
||||||
|
interface Category {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
children?: Category[];
|
||||||
|
partCount?: number;
|
||||||
|
schemaImageUrl?: string | null;
|
||||||
|
parentId?: string | null;
|
||||||
|
unavailable?: boolean;
|
||||||
|
source?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CategoryColumnsProps {
|
||||||
|
categories: Category[];
|
||||||
|
vehicleId: string;
|
||||||
|
catalogMode?: boolean;
|
||||||
|
brandName?: string;
|
||||||
|
variantSearch?: { body?: string; engine?: string; gearbox?: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CategoryColumns({
|
||||||
|
categories,
|
||||||
|
vehicleId,
|
||||||
|
catalogMode,
|
||||||
|
brandName,
|
||||||
|
variantSearch,
|
||||||
|
}: CategoryColumnsProps) {
|
||||||
|
// columns[0] = root categories, columns[1] = children of selected[0], etc.
|
||||||
|
const [columns, setColumns] = useState<Category[][]>([categories]);
|
||||||
|
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||||
|
const [loadingId, setLoadingId] = useState<string | null>(null);
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
// Reset when root categories change
|
||||||
|
useEffect(() => {
|
||||||
|
setColumns([categories]);
|
||||||
|
setSelectedIds([]);
|
||||||
|
}, [categories]);
|
||||||
|
|
||||||
|
// Auto-scroll right when new column added
|
||||||
|
useEffect(() => {
|
||||||
|
if (scrollRef.current) {
|
||||||
|
scrollRef.current.scrollLeft = scrollRef.current.scrollWidth;
|
||||||
|
}
|
||||||
|
}, [columns.length]);
|
||||||
|
|
||||||
|
const handleSelect = useCallback(
|
||||||
|
async (category: Category, columnIndex: number) => {
|
||||||
|
// Update selected path up to this column, clear deeper selections
|
||||||
|
setSelectedIds((prev) => {
|
||||||
|
const next = prev.slice(0, columnIndex);
|
||||||
|
next[columnIndex] = category.id;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
// If already have children in initial data, use them
|
||||||
|
const knownChildren = category.children;
|
||||||
|
if (knownChildren && knownChildren.length > 0) {
|
||||||
|
setColumns((prev) => [...prev.slice(0, columnIndex + 1), knownChildren]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lazy fetch
|
||||||
|
setLoadingId(category.id);
|
||||||
|
try {
|
||||||
|
const data = await queryClient.fetchQuery({
|
||||||
|
queryKey: ["category-children", category.id],
|
||||||
|
queryFn: () => api.get<Category[]>(`/categories/${category.id}/children`),
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
});
|
||||||
|
const children = data || [];
|
||||||
|
if (children.length === 0) {
|
||||||
|
// True leaf — navigate to schema page
|
||||||
|
navigate({
|
||||||
|
to: catalogMode
|
||||||
|
? "/dashboard/catalog/$brandName/$modelId/categories/$categoryId"
|
||||||
|
: "/dashboard/vehicles/$id/categories/$categoryId",
|
||||||
|
params: catalogMode
|
||||||
|
? { brandName: brandName ?? vehicleId, modelId: vehicleId, categoryId: category.id }
|
||||||
|
: { id: vehicleId, categoryId: category.id },
|
||||||
|
search: (catalogMode && variantSearch ? variantSearch : undefined) as any,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setColumns((prev) => [...prev.slice(0, columnIndex + 1), children]);
|
||||||
|
} catch {
|
||||||
|
setColumns((prev) => [...prev.slice(0, columnIndex + 1), []]);
|
||||||
|
} finally {
|
||||||
|
setLoadingId(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[queryClient, navigate, catalogMode, brandName, vehicleId, variantSearch],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!categories || categories.length === 0) {
|
||||||
|
return (
|
||||||
|
<p className="py-4 text-center text-sm text-muted-foreground">Kategori bulunamadi.</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={scrollRef}
|
||||||
|
className="flex overflow-x-auto border rounded-md"
|
||||||
|
style={{ minHeight: 320 }}
|
||||||
|
>
|
||||||
|
{columns.map((col, colIdx) => (
|
||||||
|
<ColumnPanel
|
||||||
|
key={colIdx}
|
||||||
|
categories={col}
|
||||||
|
selectedId={selectedIds[colIdx]}
|
||||||
|
columnIndex={colIdx}
|
||||||
|
loadingId={loadingId}
|
||||||
|
vehicleId={vehicleId}
|
||||||
|
onSelect={handleSelect}
|
||||||
|
isLast={colIdx === columns.length - 1}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ColumnPanel({
|
||||||
|
categories,
|
||||||
|
selectedId,
|
||||||
|
columnIndex,
|
||||||
|
loadingId,
|
||||||
|
vehicleId,
|
||||||
|
onSelect,
|
||||||
|
isLast,
|
||||||
|
}: {
|
||||||
|
categories: Category[];
|
||||||
|
selectedId?: string;
|
||||||
|
columnIndex: number;
|
||||||
|
loadingId: string | null;
|
||||||
|
vehicleId: string;
|
||||||
|
onSelect: (cat: Category, colIdx: number) => void;
|
||||||
|
isLast: boolean;
|
||||||
|
}) {
|
||||||
|
const [imageOverrides, setImageOverrides] = useState<Map<string, string>>(new Map());
|
||||||
|
const prefetchedRef = useRef<Set<string>>(new Set());
|
||||||
|
|
||||||
|
// Prefetch schema images for leaf categories in this column
|
||||||
|
useEffect(() => {
|
||||||
|
prefetchedRef.current.clear();
|
||||||
|
setImageOverrides(new Map());
|
||||||
|
|
||||||
|
const leafs = categories.filter(
|
||||||
|
(c) =>
|
||||||
|
c.children !== undefined &&
|
||||||
|
c.children.length === 0 &&
|
||||||
|
!c.schemaImageUrl &&
|
||||||
|
c.source !== "parts-catalogs",
|
||||||
|
);
|
||||||
|
if (leafs.length === 0) return;
|
||||||
|
|
||||||
|
const parentId = categories[0]?.parentId;
|
||||||
|
let cancelled = false;
|
||||||
|
const BATCH_SIZE = 2;
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
for (let i = 0; i < leafs.length; i += BATCH_SIZE) {
|
||||||
|
if (cancelled) break;
|
||||||
|
const batch = leafs.slice(i, i + BATCH_SIZE);
|
||||||
|
await Promise.allSettled(
|
||||||
|
batch.map((c) => api.get(`/vehicles/${vehicleId}/categories/${c.id}`)),
|
||||||
|
);
|
||||||
|
for (const c of batch) prefetchedRef.current.add(c.id);
|
||||||
|
if (!cancelled && parentId) {
|
||||||
|
try {
|
||||||
|
const refreshed = await api.get<Category[]>(`/categories/${parentId}/children`);
|
||||||
|
if (!cancelled && refreshed?.length) {
|
||||||
|
setImageOverrides((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
for (const r of refreshed) {
|
||||||
|
if (r.schemaImageUrl) next.set(r.id, r.schemaImageUrl);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [categories, vehicleId]);
|
||||||
|
|
||||||
|
if (categories.length === 0) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"w-[220px] shrink-0 flex items-center justify-center text-xs text-muted-foreground",
|
||||||
|
!isLast && "border-r",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Sonuç yok
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"w-[220px] shrink-0 overflow-y-auto",
|
||||||
|
!isLast && "border-r",
|
||||||
|
)}
|
||||||
|
style={{ maxHeight: 420 }}
|
||||||
|
>
|
||||||
|
{categories.map((category) => {
|
||||||
|
const isSelected = selectedId === category.id;
|
||||||
|
const isLoading = loadingId === category.id;
|
||||||
|
const Icon = getCategoryIcon(category.name);
|
||||||
|
const schemaImageUrl = imageOverrides.get(category.id) || category.schemaImageUrl;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={category.id}
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center gap-2 px-3 py-2 text-sm text-left transition-colors",
|
||||||
|
"hover:bg-accent hover:text-accent-foreground",
|
||||||
|
isSelected && "bg-accent text-accent-foreground font-medium",
|
||||||
|
category.unavailable && "opacity-40",
|
||||||
|
)}
|
||||||
|
onClick={() => onSelect(category, columnIndex)}
|
||||||
|
>
|
||||||
|
{schemaImageUrl ? (
|
||||||
|
<img
|
||||||
|
src={schemaImageUrl}
|
||||||
|
alt={category.name}
|
||||||
|
className="h-6 w-6 shrink-0 object-contain"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Icon className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
<span className="flex-1 truncate">{category.name}</span>
|
||||||
|
{isLoading ? (
|
||||||
|
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,44 +4,88 @@
|
|||||||
@variant dark (&:where(.dark, .dark *));
|
@variant dark (&:where(.dark, .dark *));
|
||||||
|
|
||||||
@theme {
|
@theme {
|
||||||
--color-background: #ffffff;
|
/* Light theme — warm-tinted neutrals (not pure white/black) */
|
||||||
--color-foreground: #0a0a0a;
|
--color-background: oklch(99.2% 0.003 80);
|
||||||
--color-muted: #f5f5f5;
|
--color-foreground: oklch(15% 0.01 250);
|
||||||
--color-muted-foreground: #737373;
|
--color-muted: oklch(96.5% 0.004 80);
|
||||||
--color-border: #e5e5e5;
|
--color-muted-foreground: oklch(50% 0.012 250);
|
||||||
--color-input: #e5e5e5;
|
--color-border: oklch(91% 0.005 250);
|
||||||
--color-ring: #0a0a0a;
|
--color-input: oklch(91% 0.005 250);
|
||||||
--color-primary: #0a0a0a;
|
--color-ring: oklch(15% 0.01 250);
|
||||||
--color-primary-foreground: #fafafa;
|
--color-primary: oklch(15% 0.01 250);
|
||||||
--color-secondary: #f5f5f5;
|
--color-primary-foreground: oklch(99.2% 0.003 80);
|
||||||
--color-secondary-foreground: #171717;
|
--color-secondary: oklch(96.5% 0.004 80);
|
||||||
--color-accent: #f5f5f5;
|
--color-secondary-foreground: oklch(20% 0.01 250);
|
||||||
--color-accent-foreground: #171717;
|
--color-accent: oklch(96.5% 0.004 80);
|
||||||
--color-destructive: #ef4444;
|
--color-accent-foreground: oklch(20% 0.01 250);
|
||||||
--color-destructive-foreground: #fafafa;
|
--color-destructive: oklch(58% 0.18 28);
|
||||||
--color-surface: #f5f5f5;
|
--color-destructive-foreground: oklch(99.2% 0.003 80);
|
||||||
--color-surface-foreground: #171717;
|
--color-surface: oklch(96.5% 0.004 80);
|
||||||
--color-surface-alt: #eaeaea;
|
--color-surface-foreground: oklch(20% 0.01 250);
|
||||||
--color-card: #ffffff;
|
--color-surface-alt: oklch(94% 0.005 80);
|
||||||
--color-card-foreground: #0a0a0a;
|
--color-card: oklch(99.2% 0.003 80);
|
||||||
--color-popover: #ffffff;
|
--color-card-foreground: oklch(15% 0.01 250);
|
||||||
--color-popover-foreground: #0a0a0a;
|
--color-popover: oklch(99.2% 0.003 80);
|
||||||
|
--color-popover-foreground: oklch(15% 0.01 250);
|
||||||
|
|
||||||
|
/* Brand accent — single semantic token; replaces ad-hoc emerald usage */
|
||||||
|
--color-brand: oklch(56% 0.13 158);
|
||||||
|
--color-brand-foreground: oklch(99.2% 0.003 80);
|
||||||
|
--color-brand-muted: oklch(94% 0.04 158);
|
||||||
|
--color-brand-soft: oklch(56% 0.13 158 / 0.12);
|
||||||
|
|
||||||
|
/* Tinted shadows — carry the cool-neutral hue rather than pure black */
|
||||||
|
--shadow-sm: 0 1px 2px oklch(15% 0.01 250 / 0.06);
|
||||||
|
--shadow-md: 0 4px 14px oklch(15% 0.01 250 / 0.08);
|
||||||
|
--shadow-lg: 0 16px 40px oklch(15% 0.01 250 / 0.12);
|
||||||
|
--shadow-brand: 0 8px 28px oklch(56% 0.13 158 / 0.18);
|
||||||
|
|
||||||
--radius-sm: 0.25rem;
|
--radius-sm: 0.25rem;
|
||||||
--radius-md: 0.375rem;
|
--radius-md: 0.5rem;
|
||||||
--radius-lg: 0.5rem;
|
--radius-lg: 0.75rem;
|
||||||
--radius-xl: 0.75rem;
|
--radius-xl: 1rem;
|
||||||
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
|
--radius-2xl: 1.25rem;
|
||||||
--font-display: "Space Grotesk", ui-sans-serif, system-ui, sans-serif;
|
|
||||||
|
--font-sans: "Geist", ui-sans-serif, system-ui, sans-serif;
|
||||||
|
--font-mono: "Geist Mono", ui-monospace, "SF Mono", monospace;
|
||||||
|
--font-display: "Geist", ui-sans-serif, system-ui, sans-serif;
|
||||||
|
--font-serif: "Instrument Serif", ui-serif, Georgia, serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
* {
|
* {
|
||||||
@apply border-border;
|
@apply border-border;
|
||||||
}
|
}
|
||||||
|
html {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground antialiased font-sans;
|
@apply bg-background text-foreground antialiased font-sans;
|
||||||
|
font-feature-settings: "cv11", "ss01", "ss03";
|
||||||
}
|
}
|
||||||
/* Fix autofill contrast in dark mode — Chrome/Safari force a light bg on autofilled inputs */
|
/* Headlines: tighter tracking, balanced wraps (no orphans) */
|
||||||
|
h1, h2, h3 {
|
||||||
|
text-wrap: balance;
|
||||||
|
letter-spacing: -0.025em;
|
||||||
|
}
|
||||||
|
h4, h5, p {
|
||||||
|
text-wrap: pretty;
|
||||||
|
}
|
||||||
|
/* Numbers in data contexts use tabular alignment */
|
||||||
|
.tabular,
|
||||||
|
[data-tabular],
|
||||||
|
input[type="number"],
|
||||||
|
.font-mono {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
/* Skip-to-content link for keyboard users */
|
||||||
|
.skip-link {
|
||||||
|
@apply sr-only;
|
||||||
|
}
|
||||||
|
.skip-link:focus {
|
||||||
|
@apply not-sr-only fixed left-4 top-4 z-50 rounded-md bg-foreground px-4 py-2 text-background shadow-lg;
|
||||||
|
}
|
||||||
|
/* Fix autofill contrast — Chrome/Safari force a light bg on autofilled inputs */
|
||||||
input:-webkit-autofill,
|
input:-webkit-autofill,
|
||||||
input:-webkit-autofill:hover,
|
input:-webkit-autofill:hover,
|
||||||
input:-webkit-autofill:focus,
|
input:-webkit-autofill:focus,
|
||||||
@@ -62,26 +106,33 @@
|
|||||||
0% { transform: translateX(0); }
|
0% { transform: translateX(0); }
|
||||||
100% { transform: translateX(-50%); }
|
100% { transform: translateX(-50%); }
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes float {
|
@keyframes float {
|
||||||
0%, 100% { transform: translateY(0); }
|
0%, 100% { transform: translateY(0); }
|
||||||
50% { transform: translateY(-8px); }
|
50% { transform: translateY(-8px); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes fade-in-up {
|
||||||
|
from { opacity: 0; transform: translateY(12px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fade-in {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
.animate-scroll-left { animation: scroll-left 30s linear infinite; }
|
.animate-scroll-left { animation: scroll-left 30s linear infinite; }
|
||||||
.animate-float { animation: float 3s ease-in-out infinite; }
|
.animate-float { animation: float 3s ease-in-out infinite; }
|
||||||
|
.animate-fade-in-up { animation: fade-in-up 0.5s cubic-bezier(0.16, 1, 0.3, 1); }
|
||||||
|
.animate-fade-in { animation: fade-in 0.4s ease-out; }
|
||||||
.carousel-track:hover .animate-scroll-left { animation-play-state: paused; }
|
.carousel-track:hover .animate-scroll-left { animation-play-state: paused; }
|
||||||
.scrollbar-none::-webkit-scrollbar { display: none; }
|
.scrollbar-none::-webkit-scrollbar { display: none; }
|
||||||
.scrollbar-none { -ms-overflow-style: none; scrollbar-width: none; }
|
.scrollbar-none { -ms-overflow-style: none; scrollbar-width: none; }
|
||||||
|
|
||||||
@keyframes fade-in-up {
|
|
||||||
from { opacity: 0; transform: translateY(10px); }
|
|
||||||
to { opacity: 1; transform: translateY(0); }
|
|
||||||
}
|
|
||||||
.animate-fade-in-up { animation: fade-in-up 0.3s ease-out; }
|
|
||||||
|
|
||||||
/* Sileo toast: deeper state colors for light mode */
|
/* Sileo toast: deeper state colors for light mode */
|
||||||
:root {
|
:root {
|
||||||
--sileo-state-success: oklch(0.52 0.24 142);
|
--sileo-state-success: oklch(0.56 0.13 158);
|
||||||
--sileo-state-error: oklch(0.48 0.26 25);
|
--sileo-state-error: oklch(0.48 0.26 25);
|
||||||
--sileo-state-warning: oklch(0.58 0.2 70);
|
--sileo-state-warning: oklch(0.58 0.2 70);
|
||||||
--sileo-state-info: oklch(0.50 0.2 237);
|
--sileo-state-info: oklch(0.50 0.2 237);
|
||||||
@@ -89,32 +140,43 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
[data-sileo-description] {
|
[data-sileo-description] {
|
||||||
color: #333;
|
color: oklch(25% 0.01 250);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
--color-background: #0a0a0a;
|
/* Dark theme — off-black with cool tint, never pure black */
|
||||||
--color-foreground: #fafafa;
|
--color-background: oklch(13% 0.008 250);
|
||||||
--color-muted: #262626;
|
--color-foreground: oklch(97% 0.004 80);
|
||||||
--color-muted-foreground: #a3a3a3;
|
--color-muted: oklch(20% 0.008 250);
|
||||||
--color-border: #262626;
|
--color-muted-foreground: oklch(65% 0.012 250);
|
||||||
--color-input: #262626;
|
--color-border: oklch(22% 0.008 250);
|
||||||
--color-ring: #d4d4d4;
|
--color-input: oklch(22% 0.008 250);
|
||||||
--color-primary: #fafafa;
|
--color-ring: oklch(80% 0.005 250);
|
||||||
--color-primary-foreground: #171717;
|
--color-primary: oklch(97% 0.004 80);
|
||||||
--color-secondary: #262626;
|
--color-primary-foreground: oklch(15% 0.01 250);
|
||||||
--color-secondary-foreground: #fafafa;
|
--color-secondary: oklch(20% 0.008 250);
|
||||||
--color-accent: #262626;
|
--color-secondary-foreground: oklch(97% 0.004 80);
|
||||||
--color-accent-foreground: #fafafa;
|
--color-accent: oklch(20% 0.008 250);
|
||||||
--color-destructive: #dc2626;
|
--color-accent-foreground: oklch(97% 0.004 80);
|
||||||
--color-destructive-foreground: #fafafa;
|
--color-destructive: oklch(54% 0.20 28);
|
||||||
--color-surface: #1a1a1a;
|
--color-destructive-foreground: oklch(97% 0.004 80);
|
||||||
--color-surface-foreground: #fafafa;
|
--color-surface: oklch(17% 0.008 250);
|
||||||
--color-surface-alt: #0f0f0f;
|
--color-surface-foreground: oklch(97% 0.004 80);
|
||||||
--color-card: #0a0a0a;
|
--color-surface-alt: oklch(15% 0.008 250);
|
||||||
--color-card-foreground: #fafafa;
|
--color-card: oklch(13% 0.008 250);
|
||||||
--color-popover: #0a0a0a;
|
--color-card-foreground: oklch(97% 0.004 80);
|
||||||
--color-popover-foreground: #fafafa;
|
--color-popover: oklch(13% 0.008 250);
|
||||||
|
--color-popover-foreground: oklch(97% 0.004 80);
|
||||||
|
|
||||||
|
--color-brand: oklch(68% 0.16 158);
|
||||||
|
--color-brand-foreground: oklch(13% 0.008 250);
|
||||||
|
--color-brand-muted: oklch(28% 0.06 158);
|
||||||
|
--color-brand-soft: oklch(68% 0.16 158 / 0.16);
|
||||||
|
|
||||||
|
--shadow-sm: 0 1px 2px oklch(0% 0 0 / 0.4);
|
||||||
|
--shadow-md: 0 4px 14px oklch(0% 0 0 / 0.5);
|
||||||
|
--shadow-lg: 0 16px 40px oklch(0% 0 0 / 0.6);
|
||||||
|
--shadow-brand: 0 8px 32px oklch(68% 0.16 158 / 0.22);
|
||||||
|
|
||||||
/* Sileo toast: dark pill/body, light text, subtler shadow */
|
/* Sileo toast: dark pill/body, light text, subtler shadow */
|
||||||
--sileo-state-loading: oklch(0.7 0 0);
|
--sileo-state-loading: oklch(0.7 0 0);
|
||||||
@@ -122,13 +184,13 @@
|
|||||||
|
|
||||||
.dark [data-sileo-pill],
|
.dark [data-sileo-pill],
|
||||||
.dark [data-sileo-body] {
|
.dark [data-sileo-body] {
|
||||||
fill: #1c1c1e !important;
|
fill: oklch(17% 0.008 250) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark [data-sileo-description] {
|
.dark [data-sileo-description] {
|
||||||
color: #d4d4d4;
|
color: oklch(85% 0.005 80);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark [data-sileo-toast] {
|
.dark [data-sileo-toast] {
|
||||||
filter: drop-shadow(0 0 12px rgba(0, 0, 0, 0.4));
|
filter: drop-shadow(0 0 12px oklch(0% 0 0 / 0.4));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,8 @@ export function initPostHog(): void {
|
|||||||
_initialized = true;
|
_initialized = true;
|
||||||
load().then((ph) => {
|
load().then((ph) => {
|
||||||
ph.init(key, {
|
ph.init(key, {
|
||||||
api_host: "https://eu.i.posthog.com",
|
api_host: "https://t.sase.tr",
|
||||||
|
defaults: "2026-01-30",
|
||||||
person_profiles: "identified_only",
|
person_profiles: "identified_only",
|
||||||
capture_pageview: false,
|
capture_pageview: false,
|
||||||
capture_pageleave: false,
|
capture_pageleave: false,
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
const STORAGE_KEY = "userSettings";
|
const STORAGE_KEY = "userSettings";
|
||||||
|
|
||||||
interface UserSettings {
|
interface UserSettings {
|
||||||
categoryViewMode?: "grid" | "tree";
|
categoryViewMode?: "grid" | "tree" | "columns";
|
||||||
|
modelViewMode?: "grid" | "tree" | "columns";
|
||||||
|
brandViewMode?: "grid" | "tree" | "columns";
|
||||||
sidebarCollapsed?: boolean;
|
sidebarCollapsed?: boolean;
|
||||||
theme?: "light" | "dark" | "system";
|
theme?: "light" | "dark" | "system";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -164,10 +164,10 @@
|
|||||||
"expired": "Expired"
|
"expired": "Expired"
|
||||||
},
|
},
|
||||||
"popular": "Popular",
|
"popular": "Popular",
|
||||||
"trialTitle": "7-Day Full Package Trial",
|
"trialTitle": "30-Day Full Package Trial",
|
||||||
"trialDescription": "Free access to all brands for 7 days. No credit card required.",
|
"trialDescription": "Free access to all brands for 30 days. No credit card required.",
|
||||||
"startTrial": "Start Free Trial",
|
"startTrial": "Start Free Trial",
|
||||||
"trialStarted": "Your 7-day Full Package trial has started!",
|
"trialStarted": "Your 30-day Full Package trial has started!",
|
||||||
"onboarding": {
|
"onboarding": {
|
||||||
"provisioning": "Setting up your free trial",
|
"provisioning": "Setting up your free trial",
|
||||||
"step1": "Verifying account",
|
"step1": "Verifying account",
|
||||||
@@ -175,7 +175,7 @@
|
|||||||
"step3": "Activating Full Package",
|
"step3": "Activating Full Package",
|
||||||
"step4": "Completed!",
|
"step4": "Completed!",
|
||||||
"completed": "You can test all catalogs without limits!",
|
"completed": "You can test all catalogs without limits!",
|
||||||
"trialDuration": "7-Day Trial",
|
"trialDuration": "30-Day Trial",
|
||||||
"startSearching": "Start Searching",
|
"startSearching": "Start Searching",
|
||||||
"error": "An error occurred while starting your trial.",
|
"error": "An error occurred while starting your trial.",
|
||||||
"retry": "Try Again"
|
"retry": "Try Again"
|
||||||
|
|||||||
@@ -164,10 +164,10 @@
|
|||||||
"expired": "Süresi Doldu"
|
"expired": "Süresi Doldu"
|
||||||
},
|
},
|
||||||
"popular": "Popüler",
|
"popular": "Popüler",
|
||||||
"trialTitle": "7 Gün Full Paket Denemesi",
|
"trialTitle": "30 Gün Full Paket Denemesi",
|
||||||
"trialDescription": "Tüm markalara 7 gün boyunca ücretsiz erişim. Kredi kartı gerekmez.",
|
"trialDescription": "Tüm markalara 30 gün boyunca ücretsiz erişim. Kredi kartı gerekmez.",
|
||||||
"startTrial": "Ücretsiz Denemeyi Başlat",
|
"startTrial": "Ücretsiz Denemeyi Başlat",
|
||||||
"trialStarted": "7 günlük Full Paket denemeniz başlatıldı!",
|
"trialStarted": "30 günlük Full Paket denemeniz başlatıldı!",
|
||||||
"onboarding": {
|
"onboarding": {
|
||||||
"provisioning": "Ücretsiz kullanım hakkınız tanımlanıyor",
|
"provisioning": "Ücretsiz kullanım hakkınız tanımlanıyor",
|
||||||
"step1": "Hesap doğrulanıyor",
|
"step1": "Hesap doğrulanıyor",
|
||||||
@@ -175,7 +175,7 @@
|
|||||||
"step3": "Full Paket aktif ediliyor",
|
"step3": "Full Paket aktif ediliyor",
|
||||||
"step4": "Tamamlandı!",
|
"step4": "Tamamlandı!",
|
||||||
"completed": "Tüm katalogları sınırsız test edebilirsiniz!",
|
"completed": "Tüm katalogları sınırsız test edebilirsiniz!",
|
||||||
"trialDuration": "7 Gün Deneme",
|
"trialDuration": "30 Gün Deneme",
|
||||||
"startSearching": "Şase Aramaya Başla",
|
"startSearching": "Şase Aramaya Başla",
|
||||||
"error": "Deneme başlatılırken bir hata oluştu.",
|
"error": "Deneme başlatılırken bir hata oluştu.",
|
||||||
"retry": "Tekrar Dene"
|
"retry": "Tekrar Dene"
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { createRootRouteWithContext, Outlet, useLocation } from "@tanstack/react-router";
|
import { createRootRouteWithContext, Link, Outlet, useLocation } from "@tanstack/react-router";
|
||||||
import { Toaster } from "@/lib/toast";
|
import { Toaster } from "@/lib/toast";
|
||||||
import type { QueryClient } from "@tanstack/react-query";
|
import type { QueryClient } from "@tanstack/react-query";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { getUserSettings } from "@/lib/user-settings";
|
import { getUserSettings } from "@/lib/user-settings";
|
||||||
import { capturePageView, identifyUser, resetUser } from "@/lib/posthog";
|
import { capturePageView, identifyUser, resetUser } from "@/lib/posthog";
|
||||||
import { useAuth } from "@/hooks/use-auth";
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
|
import { Button } from "@sase/ui";
|
||||||
|
import { ArrowLeft, Home, Search } from "lucide-react";
|
||||||
|
|
||||||
interface RouterContext {
|
interface RouterContext {
|
||||||
queryClient: QueryClient;
|
queryClient: QueryClient;
|
||||||
@@ -12,8 +14,71 @@ interface RouterContext {
|
|||||||
|
|
||||||
export const Route = createRootRouteWithContext<RouterContext>()({
|
export const Route = createRootRouteWithContext<RouterContext>()({
|
||||||
component: RootComponent,
|
component: RootComponent,
|
||||||
|
notFoundComponent: NotFoundComponent,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function NotFoundComponent() {
|
||||||
|
return (
|
||||||
|
<main className="relative flex min-h-screen flex-col items-center justify-center overflow-hidden px-6 py-12">
|
||||||
|
{/* Ambient brand glow */}
|
||||||
|
<div className="pointer-events-none absolute -left-32 top-1/4 h-[500px] w-[500px] rounded-full bg-brand/8 blur-[140px]" />
|
||||||
|
<div className="pointer-events-none absolute -right-32 bottom-1/4 h-[400px] w-[400px] rounded-full bg-brand/5 blur-[120px]" />
|
||||||
|
|
||||||
|
<div className="relative max-w-xl text-center">
|
||||||
|
<p className="font-mono text-sm font-medium uppercase tracking-[0.2em] text-muted-foreground">
|
||||||
|
404 — sayfa bulunamadı
|
||||||
|
</p>
|
||||||
|
<h1 className="mt-6 font-[family-name:var(--font-display)] text-6xl font-bold tracking-tight sm:text-7xl">
|
||||||
|
Yanlış parça,
|
||||||
|
<br />
|
||||||
|
<span className="text-muted-foreground">yanlış adres.</span>
|
||||||
|
</h1>
|
||||||
|
<p className="mx-auto mt-6 max-w-md text-base text-muted-foreground">
|
||||||
|
Aradığın sayfa silinmiş ya da hiç olmamış olabilir. Aşağıdan ana sayfaya
|
||||||
|
dönebilir veya doğrudan şase aramaya gidebilirsin.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-10 flex flex-col items-center justify-center gap-3 sm:flex-row">
|
||||||
|
<Link to="/">
|
||||||
|
<Button variant="outline" className="rounded-full">
|
||||||
|
<ArrowLeft className="size-4" />
|
||||||
|
Ana sayfaya dön
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<Link to="/dashboard/search">
|
||||||
|
<Button variant="brand" className="rounded-full">
|
||||||
|
<Search className="size-4" />
|
||||||
|
Şase aramaya git
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-12 flex flex-wrap items-center justify-center gap-x-6 gap-y-2 text-sm text-muted-foreground">
|
||||||
|
<Link
|
||||||
|
to="/"
|
||||||
|
className="inline-flex items-center gap-1.5 transition-colors hover:text-foreground"
|
||||||
|
>
|
||||||
|
<Home className="size-3.5" />
|
||||||
|
Anasayfa
|
||||||
|
</Link>
|
||||||
|
<span className="size-1 rounded-full bg-border" aria-hidden="true" />
|
||||||
|
<Link to="/pricing" className="transition-colors hover:text-foreground">
|
||||||
|
Fiyatlandırma
|
||||||
|
</Link>
|
||||||
|
<span className="size-1 rounded-full bg-border" aria-hidden="true" />
|
||||||
|
<Link to="/demo" className="transition-colors hover:text-foreground">
|
||||||
|
Demo
|
||||||
|
</Link>
|
||||||
|
<span className="size-1 rounded-full bg-border" aria-hidden="true" />
|
||||||
|
<Link to="/contact" className="transition-colors hover:text-foreground">
|
||||||
|
İletişim
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function applyTheme(theme: "light" | "dark" | "system") {
|
function applyTheme(theme: "light" | "dark" | "system") {
|
||||||
const isDark =
|
const isDark =
|
||||||
theme === "dark" ||
|
theme === "dark" ||
|
||||||
@@ -59,6 +124,9 @@ function RootComponent() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<a href="#main-content" className="skip-link">
|
||||||
|
İçeriğe atla
|
||||||
|
</a>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
<Toaster position="top-center" />
|
<Toaster position="top-center" />
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ function AuthLayout() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen">
|
<div className="flex min-h-screen">
|
||||||
{/* Left Panel — Form */}
|
{/* Left Panel — Form */}
|
||||||
<div className="flex w-full flex-col justify-between px-6 py-8 lg:w-1/2">
|
<main id="main-content" className="flex w-full flex-col justify-between px-6 py-8 lg:w-1/2">
|
||||||
<div className="flex flex-1 items-center justify-center">
|
<div className="flex flex-1 items-center justify-center">
|
||||||
<div className="w-full max-w-md">
|
<div className="w-full max-w-md">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
@@ -23,17 +23,38 @@ function AuthLayout() {
|
|||||||
Sase.tr
|
Sase.tr
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</main>
|
||||||
|
|
||||||
{/* Right Panel — Promo (always dark, hidden on mobile) */}
|
{/* Right Panel — Promo (always dark via .dark scope, hidden on mobile) */}
|
||||||
<div className="hidden border-l border-white/5 bg-[#09090b] text-white lg:flex lg:w-1/2 lg:flex-col lg:justify-between lg:px-12 lg:py-12">
|
<div className="dark relative hidden overflow-hidden border-l border-border bg-background text-foreground lg:flex lg:w-1/2 lg:flex-col lg:justify-between lg:px-12 lg:py-12">
|
||||||
<div className="flex flex-1 flex-col justify-center space-y-8">
|
{/* Brand glow ambient */}
|
||||||
|
<div className="pointer-events-none absolute -left-32 top-0 h-[500px] w-[500px] rounded-full bg-brand/10 blur-[140px]" />
|
||||||
|
<div className="pointer-events-none absolute -right-24 bottom-0 h-[400px] w-[400px] rounded-full bg-brand/8 blur-[120px]" />
|
||||||
|
{/* Subtle grid */}
|
||||||
|
<div className="pointer-events-none absolute inset-0 opacity-[0.04]">
|
||||||
|
<svg width="100%" height="100%" aria-hidden="true">
|
||||||
|
<defs>
|
||||||
|
<pattern id="auth-grid" width="48" height="48" patternUnits="userSpaceOnUse">
|
||||||
|
<path d="M 48 0 L 0 0 0 48" fill="none" stroke="currentColor" strokeWidth="1" />
|
||||||
|
</pattern>
|
||||||
|
</defs>
|
||||||
|
<rect width="100%" height="100%" fill="url(#auth-grid)" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative flex flex-1 flex-col justify-center space-y-8">
|
||||||
{/* Heading */}
|
{/* Heading */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<h2 className="text-3xl font-bold tracking-tight">
|
<span className="inline-flex items-center gap-2 rounded-full border border-border bg-surface/60 px-3 py-1 text-xs font-medium text-muted-foreground backdrop-blur-sm">
|
||||||
Doğru Parçayı İlk Seferde Bulun
|
<span className="size-1.5 rounded-full bg-brand" />
|
||||||
|
Sase.tr
|
||||||
|
</span>
|
||||||
|
<h2 className="font-[family-name:var(--font-display)] text-4xl font-bold tracking-tight">
|
||||||
|
Doğru parçayı
|
||||||
|
<br />
|
||||||
|
<span className="text-foreground/60">ilk seferde bulun.</span>
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-base leading-relaxed text-neutral-400">
|
<p className="text-base leading-relaxed text-muted-foreground">
|
||||||
Birden fazla katalogda çapraz sorgulama ile her zaman en güncel OEM
|
Birden fazla katalogda çapraz sorgulama ile her zaman en güncel OEM
|
||||||
kodları. Şase numarasını girin, doğru parçayı saniyeler içinde
|
kodları. Şase numarasını girin, doğru parçayı saniyeler içinde
|
||||||
bulun.
|
bulun.
|
||||||
@@ -42,11 +63,11 @@ function AuthLayout() {
|
|||||||
|
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<span className="inline-flex items-center gap-1.5 rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-xs text-neutral-300">
|
<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">
|
||||||
<Zap className="size-3" />
|
<Zap className="size-3" />
|
||||||
1.2sn Sorgu
|
1.2sn Sorgu
|
||||||
</span>
|
</span>
|
||||||
<span className="inline-flex items-center gap-1.5 rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-xs text-neutral-300">
|
<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
|
<svg
|
||||||
className="size-3"
|
className="size-3"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
@@ -62,36 +83,36 @@ function AuthLayout() {
|
|||||||
</svg>
|
</svg>
|
||||||
27 Marka
|
27 Marka
|
||||||
</span>
|
</span>
|
||||||
<span className="inline-flex items-center gap-1.5 rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-xs text-neutral-300">
|
<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">
|
||||||
<Database className="size-3" />
|
<Database className="size-3" />
|
||||||
243K+ OEM Parça
|
<span className="tabular">243K+ OEM Parça</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="inline-flex items-center gap-1.5 rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-xs text-neutral-300">
|
<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">
|
||||||
<ShieldCheck className="size-3" />
|
<ShieldCheck className="size-3" />
|
||||||
%99.9 Uptime
|
<span className="tabular">%99.9 Uptime</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Brand row */}
|
{/* Brand row */}
|
||||||
<p className="text-sm text-neutral-500">
|
<p className="text-sm text-muted-foreground">
|
||||||
BMW · Mercedes-Benz · Audi · VW · Fiat · Renault · Toyota · Honda ·
|
BMW · Mercedes-Benz · Audi · VW · Fiat · Renault · Toyota · Honda ·
|
||||||
Hyundai · Ford · Opel · Skoda
|
Hyundai · Ford · Opel · Skoda
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Testimonial */}
|
{/* Testimonial */}
|
||||||
<div className="rounded-xl border border-white/10 bg-white/5 p-6">
|
<div className="rounded-2xl border border-border bg-surface/40 p-6 backdrop-blur-sm">
|
||||||
<p className="text-sm leading-relaxed text-neutral-300">
|
<p className="text-sm leading-relaxed text-foreground/85">
|
||||||
“Sase.tr'ye geçtiğimizden beri yanlış parça
|
“Sase.tr'ye geçtiğimizden beri yanlış parça
|
||||||
siparişlerimiz neredeyse sıfıra indi. Aylık 40 saatin üzerinde
|
siparişlerimiz neredeyse sıfıra indi. Aylık 40 saatin üzerinde
|
||||||
zaman tasarrufu sağlıyoruz.”
|
zaman tasarrufu sağlıyoruz.”
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-4 flex items-center gap-3">
|
<div className="mt-4 flex items-center gap-3">
|
||||||
<div className="flex size-10 items-center justify-center rounded-full bg-white/10 text-sm font-medium">
|
<div className="flex size-10 items-center justify-center rounded-full bg-muted text-sm font-medium">
|
||||||
MK
|
MK
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium">Mehmet K.</p>
|
<p className="text-sm font-medium">Mehmet K.</p>
|
||||||
<p className="text-xs text-neutral-400">
|
<p className="text-xs text-muted-foreground">
|
||||||
Yedek Parça İşletme Sahibi
|
Yedek Parça İşletme Sahibi
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -100,9 +121,9 @@ function AuthLayout() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bottom trial badge */}
|
{/* Bottom trial badge */}
|
||||||
<div className="flex items-center gap-2 pt-6 text-sm text-neutral-400">
|
<div className="relative flex items-center gap-2 pt-6 text-sm text-muted-foreground">
|
||||||
<ShieldCheck className="size-4" />
|
<ShieldCheck className="size-4 text-brand" />
|
||||||
7 gün Full Paket ücretsiz deneyin — kredi kartı gerekmez
|
30 gün Full Paket ücretsiz deneyin — kredi kartı gerekmez
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -67,9 +67,9 @@ function RegisterPage() {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Trial messaging */}
|
{/* Trial messaging */}
|
||||||
<div className="mt-3 flex items-center gap-2 rounded-lg bg-emerald-500/10 px-3 py-2 text-sm text-emerald-600 dark:text-emerald-400">
|
<div className="mt-3 flex items-center gap-2 rounded-lg border border-brand/20 bg-brand/10 px-3 py-2 text-sm text-foreground">
|
||||||
<ShieldCheck className="size-4 shrink-0" />
|
<ShieldCheck className="size-4 shrink-0 text-brand" />
|
||||||
7 gün Full Paket ücretsiz deneyin — kredi kartı gerekmez
|
30 gün Full Paket ücretsiz deneyin — kredi kartı gerekmez
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -326,7 +326,7 @@ const POSTS: Record<string, BlogPost> = {
|
|||||||
<h2 className="text-xl font-semibold text-foreground">Sonuç</h2>
|
<h2 className="text-xl font-semibold text-foreground">Sonuç</h2>
|
||||||
<p>
|
<p>
|
||||||
Sase.tr ile tek bir yanlış parça iadesinden tasarruf ettiğiniz para, aylık abonelik
|
Sase.tr ile tek bir yanlış parça iadesinden tasarruf ettiğiniz para, aylık abonelik
|
||||||
ücretini karşılar. 7 günlük ücretsiz deneme süresiyle platformu bugün deneyin —
|
ücretini karşılar. 30 günlük ücretsiz deneme süresiyle platformu bugün deneyin —
|
||||||
kredi kartı gerektirmez.
|
kredi kartı gerektirmez.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -103,12 +103,17 @@ function NavLink({
|
|||||||
<Link
|
<Link
|
||||||
to={to}
|
to={to}
|
||||||
title={collapsed ? label : undefined}
|
title={collapsed ? label : undefined}
|
||||||
className={`flex items-center rounded-lg text-sm font-medium transition-colors hover:bg-accent [&.active]:bg-accent [&.active]:text-accent-foreground ${collapsed ? "justify-center p-2.5" : "gap-3 px-3 py-2"}`}
|
className={`group relative flex items-center rounded-lg text-sm font-medium text-muted-foreground transition-all duration-200 hover:bg-accent hover:text-foreground [&.active]:bg-accent [&.active]:text-foreground [&.active]:font-semibold ${collapsed ? "justify-center p-2.5" : "gap-3 px-3 py-2"}`}
|
||||||
activeProps={{ className: "active" }}
|
activeProps={{ className: "active" }}
|
||||||
activeOptions={exact ? { exact: true } : undefined}
|
activeOptions={exact ? { exact: true } : undefined}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
>
|
>
|
||||||
<Icon className="size-4 shrink-0" />
|
{/* Left accent bar — only visible when active */}
|
||||||
|
<span
|
||||||
|
className={`absolute left-0 top-1/2 h-5 w-0.5 -translate-y-1/2 rounded-r-full bg-brand opacity-0 transition-opacity duration-200 group-[.active]:opacity-100 ${collapsed ? "hidden" : ""}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<Icon className="size-4 shrink-0 text-muted-foreground transition-colors duration-200 group-hover:text-foreground group-[.active]:text-brand" />
|
||||||
{!collapsed && <span>{label}</span>}
|
{!collapsed && <span>{label}</span>}
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
@@ -376,7 +381,7 @@ function DashboardLayout() {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Page Content */}
|
{/* Page Content */}
|
||||||
<main className="flex-1 overflow-auto bg-muted/30 p-4 sm:p-6">
|
<main id="main-content" className="flex-1 overflow-auto bg-muted/30 p-4 sm:p-6">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
import { useState } from "react";
|
||||||
|
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { api } from "@/lib/api-client";
|
import { api } from "@/lib/api-client";
|
||||||
import { useTranslation } from "@/lib/i18n";
|
import { useTranslation } from "@/lib/i18n";
|
||||||
import { Skeleton } from "@sase/ui";
|
import { Skeleton, cn } from "@sase/ui";
|
||||||
import { Library, Lock } from "lucide-react";
|
import { Button } from "@sase/ui";
|
||||||
|
import { ChevronRight, Columns2, LayoutGrid, Library, List, Lock } from "lucide-react";
|
||||||
import { CarBrandLogo } from "@/components/ui/car-brand-logo";
|
import { CarBrandLogo } from "@/components/ui/car-brand-logo";
|
||||||
|
import { getUserSettings, setUserSetting } from "@/lib/user-settings";
|
||||||
|
|
||||||
export const Route = createFileRoute("/dashboard/catalog/")({
|
export const Route = createFileRoute("/dashboard/catalog/")({
|
||||||
component: CatalogBrandsPage,
|
component: CatalogBrandsPage,
|
||||||
@@ -21,6 +24,15 @@ interface CatalogBrand {
|
|||||||
function CatalogBrandsPage() {
|
function CatalogBrandsPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const [viewMode, setViewMode] = useState<"grid" | "tree" | "columns">(
|
||||||
|
() => getUserSettings().brandViewMode ?? "grid",
|
||||||
|
);
|
||||||
|
|
||||||
|
const changeViewMode = (mode: "grid" | "tree" | "columns") => {
|
||||||
|
setViewMode(mode);
|
||||||
|
setUserSetting("brandViewMode", mode);
|
||||||
|
};
|
||||||
|
|
||||||
const { data: brands, isLoading } = useQuery({
|
const { data: brands, isLoading } = useQuery({
|
||||||
queryKey: ["catalog-brands"],
|
queryKey: ["catalog-brands"],
|
||||||
queryFn: () => api.get<CatalogBrand[]>("/catalog/brands"),
|
queryFn: () => api.get<CatalogBrand[]>("/catalog/brands"),
|
||||||
@@ -28,9 +40,41 @@ function CatalogBrandsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-2xl font-bold">{t("catalog.title")}</h1>
|
<div>
|
||||||
<p className="text-sm text-muted-foreground">{t("catalog.brands")}</p>
|
<h1 className="text-2xl font-bold">{t("catalog.title")}</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">{t("catalog.brands")}</p>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
role="tablist"
|
||||||
|
aria-label="Görünüm modu"
|
||||||
|
className="inline-flex items-center gap-0.5 rounded-lg border border-border bg-muted/40 p-0.5"
|
||||||
|
>
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
{ mode: "grid" as const, Icon: LayoutGrid, label: "Izgara" },
|
||||||
|
{ mode: "tree" as const, Icon: List, label: "Liste" },
|
||||||
|
{ mode: "columns" as const, Icon: Columns2, label: "Sütun" },
|
||||||
|
]
|
||||||
|
).map(({ mode, Icon, label }) => (
|
||||||
|
<button
|
||||||
|
key={mode}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={viewMode === mode}
|
||||||
|
onClick={() => changeViewMode(mode)}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex size-7 items-center justify-center rounded-md transition-all duration-200",
|
||||||
|
viewMode === mode
|
||||||
|
? "bg-background text-foreground shadow-sm"
|
||||||
|
: "text-muted-foreground hover:text-foreground",
|
||||||
|
)}
|
||||||
|
title={label}
|
||||||
|
>
|
||||||
|
<Icon className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
@@ -44,36 +88,59 @@ function CatalogBrandsPage() {
|
|||||||
<Library className="mb-4 size-12 text-muted-foreground/40" />
|
<Library className="mb-4 size-12 text-muted-foreground/40" />
|
||||||
<p className="text-muted-foreground">{t("catalog.noBrands")}</p>
|
<p className="text-muted-foreground">{t("catalog.noBrands")}</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : viewMode === "grid" ? (
|
||||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
|
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
|
||||||
{brands.map((brand) => (
|
{brands.map((brand) => (
|
||||||
<BrandCard key={brand.brandName} brand={brand} />
|
<BrandCard key={brand.brandName} brand={brand} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
) : viewMode === "tree" ? (
|
||||||
|
<BrandListTree brands={brands} />
|
||||||
|
) : (
|
||||||
|
<BrandListColumns brands={brands} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Grid card (existing) ── */
|
||||||
|
|
||||||
function BrandCard({ brand }: { brand: CatalogBrand }) {
|
function BrandCard({ brand }: { brand: CatalogBrand }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
if (!brand.hasAccess) {
|
if (!brand.hasAccess) {
|
||||||
return (
|
return (
|
||||||
<div className="relative flex flex-col items-center justify-center rounded-xl border border-border/50 bg-muted/30 p-4 text-center opacity-60 select-none">
|
<div className="group relative flex flex-col items-center justify-center overflow-hidden rounded-xl border border-border/50 bg-muted/30 p-4 text-center select-none">
|
||||||
|
{/* Diagonal stripe overlay for locked feel */}
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute inset-0 opacity-[0.06]"
|
||||||
|
style={{
|
||||||
|
backgroundImage:
|
||||||
|
"repeating-linear-gradient(45deg, currentColor 0 1px, transparent 1px 8px)",
|
||||||
|
}}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
<div className="relative mb-2">
|
<div className="relative mb-2">
|
||||||
<CarBrandLogo brandName={brand.brandName} logoUrl={brand.logoUrl} size={40} />
|
<CarBrandLogo
|
||||||
<div className="absolute -right-1 -bottom-1 flex size-4 items-center justify-center rounded-full bg-muted-foreground/60">
|
brandName={brand.brandName}
|
||||||
|
logoUrl={brand.logoUrl}
|
||||||
|
size={40}
|
||||||
|
className="grayscale opacity-70"
|
||||||
|
/>
|
||||||
|
<div className="absolute -right-1 -bottom-1 flex size-4 items-center justify-center rounded-full bg-foreground">
|
||||||
<Lock className="size-2.5 text-background" />
|
<Lock className="size-2.5 text-background" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm font-semibold text-foreground">{brand.brandName}</p>
|
<p className="relative text-sm font-semibold text-foreground/70">{brand.brandName}</p>
|
||||||
<p className="mt-1 text-xs text-muted-foreground">{t("catalog.locked")}</p>
|
<p className="relative mt-1 text-[11px] uppercase tracking-wider text-muted-foreground/70">
|
||||||
|
{t("catalog.locked")}
|
||||||
|
</p>
|
||||||
<Link
|
<Link
|
||||||
to="/dashboard/subscription"
|
to="/dashboard/subscription"
|
||||||
className="mt-2 text-xs font-medium text-primary hover:underline"
|
className="relative mt-2 inline-flex items-center gap-1 rounded-full text-xs font-medium text-brand transition-colors hover:text-brand/80"
|
||||||
>
|
>
|
||||||
{t("catalog.upgradeCta")}
|
{t("catalog.upgradeCta")}
|
||||||
|
<ChevronRight className="size-3" />
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -84,10 +151,125 @@ function BrandCard({ brand }: { brand: CatalogBrand }) {
|
|||||||
to="/dashboard/catalog/$brandName"
|
to="/dashboard/catalog/$brandName"
|
||||||
params={{ brandName: encodeURIComponent(brand.brandName) }}
|
params={{ brandName: encodeURIComponent(brand.brandName) }}
|
||||||
search={{ catalog: undefined }}
|
search={{ catalog: undefined }}
|
||||||
className="flex flex-col items-center justify-center rounded-xl border border-border bg-card p-4 text-center transition-colors hover:bg-accent hover:border-accent-foreground/20"
|
className="group flex flex-col items-center justify-center rounded-xl border border-border bg-card p-4 text-center transition-all duration-200 hover:-translate-y-0.5 hover:border-foreground/20 hover:shadow-[var(--shadow-md)]"
|
||||||
>
|
>
|
||||||
<CarBrandLogo brandName={brand.brandName} logoUrl={brand.logoUrl} size={40} className="mb-2" />
|
<CarBrandLogo
|
||||||
|
brandName={brand.brandName}
|
||||||
|
logoUrl={brand.logoUrl}
|
||||||
|
size={40}
|
||||||
|
className="mb-2 transition-transform duration-200 group-hover:scale-105"
|
||||||
|
/>
|
||||||
<p className="text-sm font-semibold">{brand.brandName}</p>
|
<p className="text-sm font-semibold">{brand.brandName}</p>
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Tree (flat list) ── */
|
||||||
|
|
||||||
|
function BrandListTree({ brands }: { brands: CatalogBrand[] }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="divide-y rounded-lg border">
|
||||||
|
{brands.map((brand) => {
|
||||||
|
if (!brand.hasAccess) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={brand.brandName}
|
||||||
|
className="flex items-center gap-3 px-4 py-3 opacity-50"
|
||||||
|
>
|
||||||
|
<CarBrandLogo brandName={brand.brandName} logoUrl={brand.logoUrl} size={24} />
|
||||||
|
<span className="flex-1 truncate text-sm font-medium">{brand.brandName}</span>
|
||||||
|
<Lock className="size-3.5 shrink-0 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={brand.brandName}
|
||||||
|
to="/dashboard/catalog/$brandName"
|
||||||
|
params={{ brandName: encodeURIComponent(brand.brandName) }}
|
||||||
|
search={{ catalog: undefined }}
|
||||||
|
className="flex items-center gap-3 px-4 py-3 transition-colors hover:bg-accent"
|
||||||
|
>
|
||||||
|
<CarBrandLogo brandName={brand.brandName} logoUrl={brand.logoUrl} size={24} />
|
||||||
|
<span className="flex-1 truncate text-sm font-medium">{brand.brandName}</span>
|
||||||
|
<ChevronRight className="size-4 shrink-0 text-muted-foreground" />
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Columns (left: brand list, right: detail + CTA) ── */
|
||||||
|
|
||||||
|
function BrandListColumns({ brands }: { brands: CatalogBrand[] }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [selectedName, setSelectedName] = useState<string | null>(null);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const selected = brands.find((b) => b.brandName === selectedName) ?? null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex border rounded-lg overflow-hidden" style={{ minHeight: 320 }}>
|
||||||
|
{/* Left panel */}
|
||||||
|
<div className="w-[240px] shrink-0 border-r overflow-y-auto" style={{ maxHeight: 480 }}>
|
||||||
|
{brands.map((brand) => (
|
||||||
|
<button
|
||||||
|
key={brand.brandName}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectedName(brand.brandName)}
|
||||||
|
disabled={!brand.hasAccess}
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center gap-2 px-3 py-2.5 text-left text-sm transition-colors",
|
||||||
|
brand.hasAccess ? "hover:bg-accent" : "opacity-50 cursor-not-allowed",
|
||||||
|
selectedName === brand.brandName && "bg-accent font-medium",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<CarBrandLogo brandName={brand.brandName} logoUrl={brand.logoUrl} size={20} />
|
||||||
|
<span className="flex-1 truncate">{brand.brandName}</span>
|
||||||
|
{brand.hasAccess ? (
|
||||||
|
<ChevronRight className="size-3.5 shrink-0 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<Lock className="size-3 shrink-0 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right panel */}
|
||||||
|
<div className="flex-1 flex flex-col items-center justify-center p-6 text-center">
|
||||||
|
{selected ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<CarBrandLogo brandName={selected.brandName} logoUrl={selected.logoUrl} size={56} />
|
||||||
|
<p className="text-lg font-semibold">{selected.brandName}</p>
|
||||||
|
{selected.hasAccess ? (
|
||||||
|
<Button
|
||||||
|
onClick={() =>
|
||||||
|
navigate({
|
||||||
|
to: "/dashboard/catalog/$brandName",
|
||||||
|
params: { brandName: encodeURIComponent(selected.brandName) },
|
||||||
|
search: { catalog: undefined },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Modellere Git
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm text-muted-foreground">{t("catalog.locked")}</p>
|
||||||
|
<Button variant="outline" asChild>
|
||||||
|
<Link to="/dashboard/subscription">{t("catalog.upgradeCta")}</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">Soldan bir marka seçin</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
|
import { useState } from "react";
|
||||||
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
|
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { api } from "@/lib/api-client";
|
import { api } from "@/lib/api-client";
|
||||||
import { useTranslation } from "@/lib/i18n";
|
import { useTranslation } from "@/lib/i18n";
|
||||||
import { Button, Skeleton } from "@sase/ui";
|
import { Button, Skeleton, cn } from "@sase/ui";
|
||||||
import { ArrowLeft, BookOpen, Car, ChevronRight, Loader2 } from "lucide-react";
|
import { ArrowLeft, BookOpen, Car, ChevronRight, Columns2, LayoutGrid, List, Loader2 } from "lucide-react";
|
||||||
|
import { ModelListTree } from "@/components/catalog/model-list-tree";
|
||||||
|
import { ModelListColumns } from "@/components/catalog/model-list-columns";
|
||||||
|
import { getUserSettings, setUserSetting } from "@/lib/user-settings";
|
||||||
|
|
||||||
export const Route = createFileRoute("/dashboard/catalog_/$brandName/")({
|
export const Route = createFileRoute("/dashboard/catalog_/$brandName/")({
|
||||||
validateSearch: (search: Record<string, unknown>) => ({
|
validateSearch: (search: Record<string, unknown>) => ({
|
||||||
@@ -27,6 +31,7 @@ interface CatalogVehicle {
|
|||||||
bodyType: string | null;
|
bodyType: string | null;
|
||||||
transmission: string | null;
|
transmission: string | null;
|
||||||
architecture: string | null;
|
architecture: string | null;
|
||||||
|
catalogPath: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function CatalogModelsPage() {
|
function CatalogModelsPage() {
|
||||||
@@ -37,6 +42,15 @@ function CatalogModelsPage() {
|
|||||||
|
|
||||||
const decodedBrandName = decodeURIComponent(brandName);
|
const decodedBrandName = decodeURIComponent(brandName);
|
||||||
|
|
||||||
|
const [viewMode, setViewMode] = useState<"grid" | "tree" | "columns">(
|
||||||
|
() => getUserSettings().modelViewMode ?? "grid",
|
||||||
|
);
|
||||||
|
|
||||||
|
const changeViewMode = (mode: "grid" | "tree" | "columns") => {
|
||||||
|
setViewMode(mode);
|
||||||
|
setUserSetting("modelViewMode", mode);
|
||||||
|
};
|
||||||
|
|
||||||
// Always fetch catalogs to know whether this brand has multiple sub-catalogs
|
// Always fetch catalogs to know whether this brand has multiple sub-catalogs
|
||||||
const { data: catalogs, isLoading: catalogsLoading } = useQuery({
|
const { data: catalogs, isLoading: catalogsLoading } = useQuery({
|
||||||
queryKey: ["catalog-catalogs", decodedBrandName],
|
queryKey: ["catalog-catalogs", decodedBrandName],
|
||||||
@@ -139,10 +153,46 @@ function CatalogModelsPage() {
|
|||||||
<p className="text-muted-foreground">{t("catalog.noModels")}</p>
|
<p className="text-muted-foreground">{t("catalog.noModels")}</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="space-y-3">
|
||||||
{models.map((model) => (
|
{/* View toggle */}
|
||||||
<ModelCard key={model.id} model={model} brandName={brandName} />
|
<div className="flex items-center justify-end gap-1">
|
||||||
))}
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => changeViewMode("grid")}
|
||||||
|
className={cn("rounded p-1.5", viewMode === "grid" ? "bg-accent" : "text-muted-foreground hover:text-foreground")}
|
||||||
|
title="Izgara"
|
||||||
|
>
|
||||||
|
<LayoutGrid className="size-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => changeViewMode("tree")}
|
||||||
|
className={cn("rounded p-1.5", viewMode === "tree" ? "bg-accent" : "text-muted-foreground hover:text-foreground")}
|
||||||
|
title="Liste"
|
||||||
|
>
|
||||||
|
<List className="size-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => changeViewMode("columns")}
|
||||||
|
className={cn("rounded p-1.5", viewMode === "columns" ? "bg-accent" : "text-muted-foreground hover:text-foreground")}
|
||||||
|
title="Sutun"
|
||||||
|
>
|
||||||
|
<Columns2 className="size-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{viewMode === "grid" ? (
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{models.map((model) => (
|
||||||
|
<ModelCard key={model.id} model={model} brandName={brandName} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : viewMode === "tree" ? (
|
||||||
|
<ModelListTree models={models} brandName={brandName} />
|
||||||
|
) : (
|
||||||
|
<ModelListColumns models={models} brandName={brandName} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -196,7 +246,7 @@ function ModelCard({ model, brandName }: { model: CatalogVehicle; brandName: str
|
|||||||
<Link
|
<Link
|
||||||
to="/dashboard/catalog/$brandName/$modelId"
|
to="/dashboard/catalog/$brandName/$modelId"
|
||||||
params={{ brandName, modelId: model.id }}
|
params={{ brandName, modelId: model.id }}
|
||||||
search={{ body: undefined, engine: undefined, gearbox: undefined }}
|
search={{ body: undefined, engine: undefined, gearbox: undefined, mgp: undefined }}
|
||||||
className="flex flex-col rounded-lg border border-border bg-card p-4 transition-colors hover:bg-accent hover:border-accent-foreground/20"
|
className="flex flex-col rounded-lg border border-border bg-card p-4 transition-colors hover:bg-accent hover:border-accent-foreground/20"
|
||||||
>
|
>
|
||||||
<p className="font-semibold">{model.model}</p>
|
<p className="font-semibold">{model.model}</p>
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import { lazy, Suspense } from "react";
|
import { lazy, Suspense, useState } from "react";
|
||||||
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
|
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { api } from "@/lib/api-client";
|
import { api } from "@/lib/api-client";
|
||||||
import { useTranslation } from "@/lib/i18n";
|
import { useTranslation } from "@/lib/i18n";
|
||||||
import { Button, Skeleton } from "@sase/ui";
|
import { Button, Skeleton, cn } from "@sase/ui";
|
||||||
import { ArrowLeft } from "lucide-react";
|
import { ArrowLeft, Columns2, LayoutGrid, List } from "lucide-react";
|
||||||
import { CategoryGrid } from "@/components/categories/category-grid";
|
import { CategoryGrid } from "@/components/categories/category-grid";
|
||||||
|
import { CategoryTree } from "@/components/categories/category-tree";
|
||||||
|
import { CategoryColumns } from "@/components/categories/category-columns";
|
||||||
|
import { getUserSettings, setUserSetting } from "@/lib/user-settings";
|
||||||
|
|
||||||
const SchemaViewer = lazy(() =>
|
const SchemaViewer = lazy(() =>
|
||||||
import("@/components/schema/schema-viewer").then((mod) => ({
|
import("@/components/schema/schema-viewer").then((mod) => ({
|
||||||
@@ -36,6 +39,7 @@ export const Route = createFileRoute(
|
|||||||
body: typeof search.body === "string" ? search.body : undefined,
|
body: typeof search.body === "string" ? search.body : undefined,
|
||||||
engine: typeof search.engine === "string" ? search.engine : undefined,
|
engine: typeof search.engine === "string" ? search.engine : undefined,
|
||||||
gearbox: typeof search.gearbox === "string" ? search.gearbox : undefined,
|
gearbox: typeof search.gearbox === "string" ? search.gearbox : undefined,
|
||||||
|
mgp: typeof search.mgp === "string" ? search.mgp : undefined,
|
||||||
}),
|
}),
|
||||||
component: CatalogCategoryPage,
|
component: CatalogCategoryPage,
|
||||||
});
|
});
|
||||||
@@ -59,7 +63,17 @@ function CatalogCategoryPage() {
|
|||||||
const engine = search.engine;
|
const engine = search.engine;
|
||||||
const gearbox = search.gearbox;
|
const gearbox = search.gearbox;
|
||||||
|
|
||||||
const variantSearch = body || engine || gearbox ? { body, engine, gearbox } : undefined;
|
const mgp = search.mgp;
|
||||||
|
const variantSearch = body || engine || gearbox || mgp ? { body, engine, gearbox, mgp } : undefined;
|
||||||
|
|
||||||
|
const [viewMode, setViewMode] = useState<"grid" | "tree" | "columns">(
|
||||||
|
() => getUserSettings().categoryViewMode ?? "grid",
|
||||||
|
);
|
||||||
|
|
||||||
|
const changeViewMode = (mode: "grid" | "tree" | "columns") => {
|
||||||
|
setViewMode(mode);
|
||||||
|
setUserSetting("categoryViewMode", mode);
|
||||||
|
};
|
||||||
|
|
||||||
const { data, isLoading, error } = useQuery({
|
const { data, isLoading, error } = useQuery({
|
||||||
queryKey: ["catalog-category", modelId, categoryId, body, engine, gearbox],
|
queryKey: ["catalog-category", modelId, categoryId, body, engine, gearbox],
|
||||||
@@ -77,13 +91,13 @@ function CatalogCategoryPage() {
|
|||||||
navigate({
|
navigate({
|
||||||
to: "/dashboard/catalog/$brandName/$modelId/categories/$categoryId",
|
to: "/dashboard/catalog/$brandName/$modelId/categories/$categoryId",
|
||||||
params: { brandName, modelId, categoryId: data.parentId },
|
params: { brandName, modelId, categoryId: data.parentId },
|
||||||
search: variantSearch ?? { body: undefined, engine: undefined, gearbox: undefined },
|
search: variantSearch ?? { body: undefined, engine: undefined, gearbox: undefined, mgp: undefined },
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
navigate({
|
navigate({
|
||||||
to: "/dashboard/catalog/$brandName/$modelId",
|
to: "/dashboard/catalog/$brandName/$modelId",
|
||||||
params: { brandName, modelId },
|
params: { brandName, modelId },
|
||||||
search: variantSearch ?? { body: undefined, engine: undefined, gearbox: undefined },
|
search: variantSearch ?? { body: undefined, engine: undefined, gearbox: undefined, mgp: undefined },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -111,30 +125,78 @@ function CatalogCategoryPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center justify-between">
|
||||||
<Button variant="ghost" size="icon" onClick={handleBack} title={t("common.back")}>
|
<div className="flex items-center gap-3">
|
||||||
<ArrowLeft className="size-4" />
|
<Button variant="ghost" size="icon" onClick={handleBack} title={t("common.back")}>
|
||||||
</Button>
|
<ArrowLeft className="size-4" />
|
||||||
<div>
|
</Button>
|
||||||
<div className="text-xs text-muted-foreground">
|
<div>
|
||||||
<Link to="/dashboard/catalog" className="hover:underline">
|
<div className="text-xs text-muted-foreground">
|
||||||
{t("catalog.title")}
|
<Link to="/dashboard/catalog" className="hover:underline">
|
||||||
</Link>
|
{t("catalog.title")}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-xl font-bold">{data?.name || t("catalog.categories")}</h1>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-xl font-bold">{data?.name || t("catalog.categories")}</h1>
|
|
||||||
</div>
|
</div>
|
||||||
|
{hasChildren && (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => changeViewMode("grid")}
|
||||||
|
className={cn("rounded p-1.5", viewMode === "grid" ? "bg-accent" : "text-muted-foreground hover:text-foreground")}
|
||||||
|
title="Izgara"
|
||||||
|
>
|
||||||
|
<LayoutGrid className="size-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => changeViewMode("tree")}
|
||||||
|
className={cn("rounded p-1.5", viewMode === "tree" ? "bg-accent" : "text-muted-foreground hover:text-foreground")}
|
||||||
|
title="Agac"
|
||||||
|
>
|
||||||
|
<List className="size-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => changeViewMode("columns")}
|
||||||
|
className={cn("rounded p-1.5", viewMode === "columns" ? "bg-accent" : "text-muted-foreground hover:text-foreground")}
|
||||||
|
title="Sutun"
|
||||||
|
>
|
||||||
|
<Columns2 className="size-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
{hasChildren ? (
|
{hasChildren ? (
|
||||||
<CategoryGrid
|
viewMode === "grid" ? (
|
||||||
categories={data.children}
|
<CategoryGrid
|
||||||
vehicleId={modelId}
|
categories={data.children}
|
||||||
catalogMode
|
vehicleId={modelId}
|
||||||
brandName={brandName}
|
catalogMode
|
||||||
parentId={categoryId}
|
brandName={brandName}
|
||||||
variantSearch={variantSearch}
|
parentId={categoryId}
|
||||||
/>
|
variantSearch={variantSearch}
|
||||||
|
/>
|
||||||
|
) : viewMode === "tree" ? (
|
||||||
|
<CategoryTree
|
||||||
|
categories={data.children}
|
||||||
|
vehicleId={modelId}
|
||||||
|
catalogMode
|
||||||
|
brandName={brandName}
|
||||||
|
variantSearch={variantSearch}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<CategoryColumns
|
||||||
|
categories={data.children}
|
||||||
|
vehicleId={modelId}
|
||||||
|
catalogMode
|
||||||
|
brandName={brandName}
|
||||||
|
variantSearch={variantSearch}
|
||||||
|
/>
|
||||||
|
)
|
||||||
) : (
|
) : (
|
||||||
<Suspense fallback={<SchemaViewerFallback />}>
|
<Suspense fallback={<SchemaViewerFallback />}>
|
||||||
<SchemaViewer
|
<SchemaViewer
|
||||||
|
|||||||
@@ -4,11 +4,13 @@ import { useQuery } from "@tanstack/react-query";
|
|||||||
import { api } from "@/lib/api-client";
|
import { api } from "@/lib/api-client";
|
||||||
import { useTranslation } from "@/lib/i18n";
|
import { useTranslation } from "@/lib/i18n";
|
||||||
import { Button, Skeleton, Card, CardContent, CardHeader, CardTitle } from "@sase/ui";
|
import { Button, Skeleton, Card, CardContent, CardHeader, CardTitle } from "@sase/ui";
|
||||||
import { ArrowLeft, LayoutGrid, List } from "lucide-react";
|
import { ArrowLeft, Columns2, LayoutGrid, List } from "lucide-react";
|
||||||
import { CategoryGrid } from "@/components/categories/category-grid";
|
import { CategoryGrid } from "@/components/categories/category-grid";
|
||||||
import { CategoryTree } from "@/components/categories/category-tree";
|
import { CategoryTree } from "@/components/categories/category-tree";
|
||||||
|
import { CategoryColumns } from "@/components/categories/category-columns";
|
||||||
import { PsaVariantSelector } from "@/components/catalog/psa-variant-selector";
|
import { PsaVariantSelector } from "@/components/catalog/psa-variant-selector";
|
||||||
import { FordVariantSelector } from "@/components/catalog/ford-variant-selector";
|
import { FordVariantSelector } from "@/components/catalog/ford-variant-selector";
|
||||||
|
import { P5RestrictionSelector } from "@/components/catalog/p5-restriction-selector";
|
||||||
import { getUserSettings, setUserSetting } from "@/lib/user-settings";
|
import { getUserSettings, setUserSetting } from "@/lib/user-settings";
|
||||||
|
|
||||||
export const Route = createFileRoute("/dashboard/catalog_/$brandName_/$modelId/")({
|
export const Route = createFileRoute("/dashboard/catalog_/$brandName_/$modelId/")({
|
||||||
@@ -16,6 +18,7 @@ export const Route = createFileRoute("/dashboard/catalog_/$brandName_/$modelId/"
|
|||||||
body: typeof search.body === "string" ? search.body : undefined,
|
body: typeof search.body === "string" ? search.body : undefined,
|
||||||
engine: typeof search.engine === "string" ? search.engine : undefined,
|
engine: typeof search.engine === "string" ? search.engine : undefined,
|
||||||
gearbox: typeof search.gearbox === "string" ? search.gearbox : undefined,
|
gearbox: typeof search.gearbox === "string" ? search.gearbox : undefined,
|
||||||
|
mgp: typeof search.mgp === "string" ? search.mgp : undefined,
|
||||||
}),
|
}),
|
||||||
component: CatalogVehiclePage,
|
component: CatalogVehiclePage,
|
||||||
});
|
});
|
||||||
@@ -38,15 +41,16 @@ function CatalogVehiclePage() {
|
|||||||
const body = search.body;
|
const body = search.body;
|
||||||
const engine = search.engine;
|
const engine = search.engine;
|
||||||
const gearbox = search.gearbox;
|
const gearbox = search.gearbox;
|
||||||
const hasVariant = !!(body || engine || gearbox);
|
const mgp = search.mgp;
|
||||||
|
const hasVariant = !!(body || engine || gearbox || mgp);
|
||||||
|
|
||||||
const [viewMode, setViewMode] = useState<"grid" | "tree">(
|
const [viewMode, setViewMode] = useState<"grid" | "tree" | "columns">(
|
||||||
() => getUserSettings().categoryViewMode ?? "grid",
|
() => getUserSettings().categoryViewMode ?? "grid",
|
||||||
);
|
);
|
||||||
|
|
||||||
const decodedBrandName = decodeURIComponent(brandName);
|
const decodedBrandName = decodeURIComponent(brandName);
|
||||||
|
|
||||||
const changeViewMode = (mode: "grid" | "tree") => {
|
const changeViewMode = (mode: "grid" | "tree" | "columns") => {
|
||||||
setViewMode(mode);
|
setViewMode(mode);
|
||||||
setUserSetting("categoryViewMode", mode);
|
setUserSetting("categoryViewMode", mode);
|
||||||
};
|
};
|
||||||
@@ -62,19 +66,39 @@ function CatalogVehiclePage() {
|
|||||||
"LEGACY_FORD",
|
"LEGACY_FORD",
|
||||||
"LEGACY_VOLVO",
|
"LEGACY_VOLVO",
|
||||||
].includes(vehicle?.architecture);
|
].includes(vehicle?.architecture);
|
||||||
|
const isP5WithRestrictions =
|
||||||
|
vehicle?.architecture === "P5_MODERN" &&
|
||||||
|
!!vehicle?.catalogPath &&
|
||||||
|
!vehicle.catalogPath.includes("/mainGroup");
|
||||||
const showPsaVariantSelector = isPsa && !hasVariant;
|
const showPsaVariantSelector = isPsa && !hasVariant;
|
||||||
const showFordVariantSelector = isP4Legacy && !hasVariant;
|
const showFordVariantSelector = isP4Legacy && !hasVariant;
|
||||||
const showVariantSelector = showPsaVariantSelector || showFordVariantSelector;
|
const showP5RestrictionSelector = isP5WithRestrictions && !hasVariant;
|
||||||
|
const showVariantSelector = showPsaVariantSelector || showFordVariantSelector || showP5RestrictionSelector;
|
||||||
|
|
||||||
const variantSearch = hasVariant ? { body, engine, gearbox } : undefined;
|
const variantSearch = hasVariant ? { body, engine, gearbox, mgp } : undefined;
|
||||||
|
|
||||||
const { data: categoryTree, isLoading: categoriesLoading } = useQuery({
|
const { data: categoryTree, isLoading: categoriesLoading } = useQuery({
|
||||||
queryKey: ["catalog-category-tree", modelId, body, engine, gearbox],
|
queryKey: ["catalog-category-tree", modelId, body, engine, gearbox, mgp],
|
||||||
queryFn: () =>
|
queryFn: () => {
|
||||||
api.get<any[]>(`/catalog/vehicles/${modelId}/categories${buildVariantQuery(body, engine, gearbox)}`),
|
const params = new URLSearchParams();
|
||||||
|
if (body) params.set("body", body);
|
||||||
|
if (engine) params.set("engine", engine);
|
||||||
|
if (gearbox) params.set("gearbox", gearbox);
|
||||||
|
if (mgp) params.set("mgp", mgp);
|
||||||
|
const qs = params.toString();
|
||||||
|
return api.get<any[]>(`/catalog/vehicles/${modelId}/categories${qs ? `?${qs}` : ""}`);
|
||||||
|
},
|
||||||
enabled: !!modelId && !vehicleLoading && !showVariantSelector,
|
enabled: !!modelId && !vehicleLoading && !showVariantSelector,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const handleP5RestrictionComplete = (mainGroupsPath: string) => {
|
||||||
|
navigate({
|
||||||
|
to: "/dashboard/catalog/$brandName/$modelId",
|
||||||
|
params: { brandName, modelId },
|
||||||
|
search: { mgp: mainGroupsPath, body: undefined, engine: undefined, gearbox: undefined },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const handleVariantSelect = (selectedBody: string, selectedEngine: string, selectedGearbox: string) => {
|
const handleVariantSelect = (selectedBody: string, selectedEngine: string, selectedGearbox: string) => {
|
||||||
const norm = (v: string) => (v && v !== "_all_" && v !== "_nor_" ? v : undefined);
|
const norm = (v: string) => (v && v !== "_all_" && v !== "_nor_" ? v : undefined);
|
||||||
navigate({
|
navigate({
|
||||||
@@ -87,6 +111,7 @@ function CatalogVehiclePage() {
|
|||||||
body: norm(selectedBody) ?? (selectedBody === "_nor_" ? "_nor_" : undefined),
|
body: norm(selectedBody) ?? (selectedBody === "_nor_" ? "_nor_" : undefined),
|
||||||
engine: norm(selectedEngine),
|
engine: norm(selectedEngine),
|
||||||
gearbox: norm(selectedGearbox),
|
gearbox: norm(selectedGearbox),
|
||||||
|
mgp: undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -103,21 +128,22 @@ function CatalogVehiclePage() {
|
|||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-4xl space-y-6">
|
<div className="mx-auto max-w-4xl space-y-6">
|
||||||
{/* Header / Breadcrumb */}
|
{/* Header / Breadcrumb */}
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center justify-between">
|
||||||
<Button
|
<div className="flex items-center gap-3">
|
||||||
variant="ghost"
|
<Button
|
||||||
size="icon"
|
variant="ghost"
|
||||||
onClick={() =>
|
size="icon"
|
||||||
navigate({
|
onClick={() =>
|
||||||
to: "/dashboard/catalog/$brandName",
|
navigate({
|
||||||
params: { brandName },
|
to: "/dashboard/catalog/$brandName",
|
||||||
search: { catalog: undefined },
|
params: { brandName },
|
||||||
})
|
search: { catalog: undefined },
|
||||||
}
|
})
|
||||||
>
|
}
|
||||||
<ArrowLeft className="size-4" />
|
>
|
||||||
</Button>
|
<ArrowLeft className="size-4" />
|
||||||
<div>
|
</Button>
|
||||||
|
<div>
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="text-xs text-muted-foreground">
|
||||||
<Link to="/dashboard/catalog" className="hover:underline">
|
<Link to="/dashboard/catalog" className="hover:underline">
|
||||||
{t("catalog.title")}
|
{t("catalog.title")}
|
||||||
@@ -153,6 +179,34 @@ function CatalogVehiclePage() {
|
|||||||
{vehicle?.model}
|
{vehicle?.model}
|
||||||
{vehicle?.year && <span className="ml-2 text-base font-normal text-muted-foreground">({vehicle.year})</span>}
|
{vehicle?.year && <span className="ml-2 text-base font-normal text-muted-foreground">({vehicle.year})</span>}
|
||||||
</h1>
|
</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* View toggle — always visible */}
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => changeViewMode("grid")}
|
||||||
|
className={`rounded p-1.5 ${viewMode === "grid" ? "bg-accent" : "text-muted-foreground hover:text-foreground"}`}
|
||||||
|
title="Izgara"
|
||||||
|
>
|
||||||
|
<LayoutGrid className="size-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => changeViewMode("tree")}
|
||||||
|
className={`rounded p-1.5 ${viewMode === "tree" ? "bg-accent" : "text-muted-foreground hover:text-foreground"}`}
|
||||||
|
title="Agac"
|
||||||
|
>
|
||||||
|
<List className="size-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => changeViewMode("columns")}
|
||||||
|
className={`rounded p-1.5 ${viewMode === "columns" ? "bg-accent" : "text-muted-foreground hover:text-foreground"}`}
|
||||||
|
title="Sutun"
|
||||||
|
>
|
||||||
|
<Columns2 className="size-4" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -198,28 +252,14 @@ function CatalogVehiclePage() {
|
|||||||
<PsaVariantSelector vehicleId={modelId} onSelect={handleVariantSelect} />
|
<PsaVariantSelector vehicleId={modelId} onSelect={handleVariantSelect} />
|
||||||
) : showFordVariantSelector ? (
|
) : showFordVariantSelector ? (
|
||||||
<FordVariantSelector vehicleId={modelId} onSelect={handleVariantSelect} />
|
<FordVariantSelector vehicleId={modelId} onSelect={handleVariantSelect} />
|
||||||
|
) : showP5RestrictionSelector ? (
|
||||||
|
<P5RestrictionSelector vehicleId={modelId} onComplete={handleP5RestrictionComplete} />
|
||||||
) : (
|
) : (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between">
|
<CardHeader>
|
||||||
<CardTitle className="text-base">{t("catalog.categories")}</CardTitle>
|
<CardTitle className="text-base">{t("catalog.categories")}</CardTitle>
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => changeViewMode("grid")}
|
|
||||||
className={`rounded p-1.5 ${viewMode === "grid" ? "bg-accent" : "text-muted-foreground hover:text-foreground"}`}
|
|
||||||
>
|
|
||||||
<LayoutGrid className="size-4" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => changeViewMode("tree")}
|
|
||||||
className={`rounded p-1.5 ${viewMode === "tree" ? "bg-accent" : "text-muted-foreground hover:text-foreground"}`}
|
|
||||||
>
|
|
||||||
<List className="size-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className={viewMode === "columns" ? "p-0 overflow-hidden rounded-b-lg" : undefined}>
|
||||||
{categoriesLoading ? (
|
{categoriesLoading ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{Array.from({ length: 8 }).map((_, i) => (
|
{Array.from({ length: 8 }).map((_, i) => (
|
||||||
@@ -234,7 +274,7 @@ function CatalogVehiclePage() {
|
|||||||
brandName={brandName}
|
brandName={brandName}
|
||||||
variantSearch={variantSearch}
|
variantSearch={variantSearch}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : viewMode === "tree" ? (
|
||||||
<CategoryTree
|
<CategoryTree
|
||||||
categories={categoryTree || []}
|
categories={categoryTree || []}
|
||||||
vehicleId={modelId}
|
vehicleId={modelId}
|
||||||
@@ -242,6 +282,14 @@ function CatalogVehiclePage() {
|
|||||||
brandName={brandName}
|
brandName={brandName}
|
||||||
variantSearch={variantSearch}
|
variantSearch={variantSearch}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<CategoryColumns
|
||||||
|
categories={categoryTree || []}
|
||||||
|
vehicleId={modelId}
|
||||||
|
catalogMode
|
||||||
|
brandName={brandName}
|
||||||
|
variantSearch={variantSearch}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -309,7 +309,7 @@ function DashboardHome() {
|
|||||||
<h3 className="text-lg font-bold">
|
<h3 className="text-lg font-bold">
|
||||||
{subscription.plan?.name ?? "Aktif Plan"}
|
{subscription.plan?.name ?? "Aktif Plan"}
|
||||||
</h3>
|
</h3>
|
||||||
<Badge variant="default" className="bg-emerald-600 text-xs">
|
<Badge variant="default" className="bg-brand text-xs text-brand-foreground hover:bg-brand/90">
|
||||||
{subscription.status === "trial" ? "Deneme" : "Aktif"}
|
{subscription.status === "trial" ? "Deneme" : "Aktif"}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
@@ -371,7 +371,7 @@ function DashboardHome() {
|
|||||||
key={f}
|
key={f}
|
||||||
className="flex items-center gap-1.5 text-sm text-muted-foreground"
|
className="flex items-center gap-1.5 text-sm text-muted-foreground"
|
||||||
>
|
>
|
||||||
<CheckCircle2 className="size-3.5 text-emerald-500" />
|
<CheckCircle2 className="size-3.5 text-brand" />
|
||||||
{f}
|
{f}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -304,7 +304,7 @@ function SearchPage() {
|
|||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
className={`h-1.5 flex-1 rounded-full transition-colors duration-200 ${
|
className={`h-1.5 flex-1 rounded-full transition-colors duration-200 ${
|
||||||
i < vin.length ? "bg-emerald-500" : "bg-muted"
|
i < vin.length ? "bg-brand" : "bg-muted"
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -398,10 +398,10 @@ function SearchPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{preview && !previewLoading && (
|
{preview && !previewLoading && (
|
||||||
<div className="rounded-2xl border border-emerald-500/30 bg-background p-5 sm:p-6">
|
<div className="rounded-2xl border border-brand/30 bg-background p-5 sm:p-6">
|
||||||
<div className="flex items-start gap-4">
|
<div className="flex items-start gap-4">
|
||||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-emerald-500/10">
|
<div className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-brand/10">
|
||||||
<Car className="size-5 text-emerald-500" />
|
<Car className="size-5 text-brand" />
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="font-[family-name:var(--font-display)] text-lg font-bold">
|
<p className="font-[family-name:var(--font-display)] text-lg font-bold">
|
||||||
@@ -414,7 +414,7 @@ function SearchPage() {
|
|||||||
<div className="mt-3 flex flex-wrap gap-2">
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
<Badge
|
<Badge
|
||||||
variant="default"
|
variant="default"
|
||||||
className="bg-emerald-600 text-xs text-white"
|
className="bg-brand text-xs text-brand-foreground hover:bg-brand/90"
|
||||||
>
|
>
|
||||||
Araç tanımlandı
|
Araç tanımlandı
|
||||||
</Badge>
|
</Badge>
|
||||||
|
|||||||
@@ -296,12 +296,12 @@ function SubscriptionPage() {
|
|||||||
if (onboardingPhase === "provisioning") {
|
if (onboardingPhase === "provisioning") {
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto flex max-w-2xl flex-col items-center justify-center px-4 py-12">
|
<div className="mx-auto flex max-w-2xl flex-col items-center justify-center px-4 py-12">
|
||||||
<Card className="relative w-full overflow-hidden border-emerald-500/30 bg-gradient-to-br from-emerald-50/50 to-teal-50/50 dark:from-emerald-950/20 dark:to-teal-950/20">
|
<Card className="relative w-full overflow-hidden border-brand/25 bg-brand/5">
|
||||||
<div className="absolute inset-0 bg-gradient-to-r from-emerald-500/5 to-teal-500/5" />
|
<div className="pointer-events-none absolute -right-24 top-0 h-[300px] w-[300px] rounded-full bg-brand/15 blur-[100px]" />
|
||||||
<CardContent className="relative flex flex-col items-center gap-6 py-10">
|
<CardContent className="relative flex flex-col items-center gap-6 py-10">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Sparkles className="h-6 w-6 animate-pulse text-emerald-600 dark:text-emerald-400" />
|
<Sparkles className="h-6 w-6 animate-pulse text-brand" />
|
||||||
<h2 className="text-xl font-bold text-emerald-900 dark:text-emerald-100">
|
<h2 className="text-xl font-bold text-foreground">
|
||||||
{t("subscription.onboarding.provisioning")}
|
{t("subscription.onboarding.provisioning")}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
@@ -309,7 +309,7 @@ function SubscriptionPage() {
|
|||||||
<Suspense
|
<Suspense
|
||||||
fallback={
|
fallback={
|
||||||
<div className="flex h-[200px] w-full items-center justify-center">
|
<div className="flex h-[200px] w-full items-center justify-center">
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-emerald-600" />
|
<Loader2 className="h-8 w-8 animate-spin text-brand" />
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -326,7 +326,7 @@ function SubscriptionPage() {
|
|||||||
|
|
||||||
{/* If animation finished but mutation still pending */}
|
{/* If animation finished but mutation still pending */}
|
||||||
{animationEnded && trialMutation.isPending && (
|
{animationEnded && trialMutation.isPending && (
|
||||||
<div className="flex items-center gap-2 text-sm text-emerald-700 dark:text-emerald-300">
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
{t("subscription.onboarding.step4")}...
|
{t("subscription.onboarding.step4")}...
|
||||||
</div>
|
</div>
|
||||||
@@ -357,20 +357,20 @@ function SubscriptionPage() {
|
|||||||
const freshSub = subData?.subscription;
|
const freshSub = subData?.subscription;
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto flex max-w-2xl flex-col items-center justify-center px-4 py-12">
|
<div className="mx-auto flex max-w-2xl flex-col items-center justify-center px-4 py-12">
|
||||||
<Card className="relative w-full overflow-hidden border-emerald-500/30 bg-gradient-to-br from-emerald-50/50 to-teal-50/50 dark:from-emerald-950/20 dark:to-teal-950/20">
|
<Card className="relative w-full overflow-hidden border-brand/25 bg-brand/5">
|
||||||
<div className="absolute inset-0 bg-gradient-to-r from-emerald-500/5 to-teal-500/5" />
|
<div className="pointer-events-none absolute -right-24 top-0 h-[300px] w-[300px] rounded-full bg-brand/15 blur-[100px]" />
|
||||||
<CardContent className="relative flex flex-col items-center gap-6 py-10">
|
<CardContent className="relative flex flex-col items-center gap-6 py-10">
|
||||||
<CheckCircle2 className="h-16 w-16 text-emerald-500" />
|
<CheckCircle2 className="h-16 w-16 text-brand" />
|
||||||
|
|
||||||
<h2 className="text-center text-2xl font-bold text-emerald-900 dark:text-emerald-100">
|
<h2 className="text-center text-2xl font-bold text-foreground">
|
||||||
{t("subscription.onboarding.completed")}
|
{t("subscription.onboarding.completed")}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
{/* Subscription info box */}
|
{/* Subscription info box */}
|
||||||
<div className="w-full max-w-md space-y-4 rounded-xl border border-emerald-200 bg-white/60 p-5 dark:border-emerald-800 dark:bg-white/5">
|
<div className="w-full max-w-md space-y-4 rounded-xl border border-brand/20 bg-background/60 p-5 backdrop-blur-sm dark:bg-background/30">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-sm text-muted-foreground">{t("subscription.currentPlan")}</span>
|
<span className="text-sm text-muted-foreground">{t("subscription.currentPlan")}</span>
|
||||||
<Badge className="bg-emerald-600 text-white">Full Paket</Badge>
|
<Badge className="bg-brand text-brand-foreground">Full Paket</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-sm text-muted-foreground">{t("subscription.billingPeriod")}</span>
|
<span className="text-sm text-muted-foreground">{t("subscription.billingPeriod")}</span>
|
||||||
@@ -387,8 +387,8 @@ function SubscriptionPage() {
|
|||||||
<Separator />
|
<Separator />
|
||||||
<ul className="space-y-2 text-sm">
|
<ul className="space-y-2 text-sm">
|
||||||
{["allBrands", "vinSearch", "partsCatalog", "schemaViewer"].map((f) => (
|
{["allBrands", "vinSearch", "partsCatalog", "schemaViewer"].map((f) => (
|
||||||
<li key={f} className="flex items-center gap-2 text-emerald-800 dark:text-emerald-200">
|
<li key={f} className="flex items-center gap-2 text-foreground/85">
|
||||||
<Check className="h-4 w-4 text-emerald-600 dark:text-emerald-400" />
|
<Check className="h-4 w-4 text-brand" />
|
||||||
{t(`subscription.features.${f}`)}
|
{t(`subscription.features.${f}`)}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
@@ -397,7 +397,7 @@ function SubscriptionPage() {
|
|||||||
|
|
||||||
<Button
|
<Button
|
||||||
size="lg"
|
size="lg"
|
||||||
className="bg-emerald-600 hover:bg-emerald-700 text-white"
|
className="bg-brand text-brand-foreground hover:bg-brand/90"
|
||||||
onClick={() => navigate({ to: "/dashboard/search" })}
|
onClick={() => navigate({ to: "/dashboard/search" })}
|
||||||
>
|
>
|
||||||
{t("subscription.onboarding.startSearching")}
|
{t("subscription.onboarding.startSearching")}
|
||||||
@@ -471,7 +471,7 @@ function SubscriptionPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{subscription.status === "trial" && subscription.endDate && (
|
{subscription.status === "trial" && subscription.endDate && (
|
||||||
<div className="flex items-center gap-2 rounded-lg bg-emerald-500/10 px-3 py-2 text-sm text-emerald-600 dark:text-emerald-400">
|
<div className="flex items-center gap-2 rounded-lg bg-brand/10 px-3 py-2 text-sm text-brand">
|
||||||
<Sparkles className="h-4 w-4" />
|
<Sparkles className="h-4 w-4" />
|
||||||
{(() => {
|
{(() => {
|
||||||
const days = Math.max(0, Math.ceil((new Date(subscription.endDate).getTime() - Date.now()) / (1000 * 60 * 60 * 24)));
|
const days = Math.max(0, Math.ceil((new Date(subscription.endDate).getTime() - Date.now()) / (1000 * 60 * 60 * 24)));
|
||||||
@@ -528,30 +528,30 @@ function SubscriptionPage() {
|
|||||||
|
|
||||||
{/* Trial CTA Card */}
|
{/* Trial CTA Card */}
|
||||||
{eligibleForTrial && (!subscription || subscription.status === "expired" || subscription.status === "trial") && (
|
{eligibleForTrial && (!subscription || subscription.status === "expired" || subscription.status === "trial") && (
|
||||||
<Card className="relative overflow-hidden border-emerald-500/30 bg-gradient-to-br from-emerald-50/50 to-teal-50/50 dark:from-emerald-950/20 dark:to-teal-950/20">
|
<Card className="relative overflow-hidden border-brand/25 bg-brand/5">
|
||||||
<div className="absolute inset-0 bg-gradient-to-r from-emerald-500/5 to-teal-500/5" />
|
<div className="pointer-events-none absolute -right-24 top-0 h-[300px] w-[300px] rounded-full bg-brand/15 blur-[100px]" />
|
||||||
<CardHeader className="relative">
|
<CardHeader className="relative">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Sparkles className="h-5 w-5 text-emerald-600 dark:text-emerald-400" />
|
<Sparkles className="h-5 w-5 text-brand" />
|
||||||
<CardTitle className="text-emerald-900 dark:text-emerald-100">
|
<CardTitle className="text-foreground">
|
||||||
{t("subscription.trialTitle")}
|
{t("subscription.trialTitle")}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</div>
|
</div>
|
||||||
<CardDescription className="text-emerald-700/80 dark:text-emerald-300/80">
|
<CardDescription className="text-muted-foreground">
|
||||||
{t("subscription.trialDescription")}
|
{t("subscription.trialDescription")}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="relative space-y-4">
|
<CardContent className="relative space-y-4">
|
||||||
<ul className="space-y-2 text-sm">
|
<ul className="space-y-2 text-sm">
|
||||||
{["allBrands", "vinSearch", "partsCatalog", "schemaViewer"].map((f) => (
|
{["allBrands", "vinSearch", "partsCatalog", "schemaViewer"].map((f) => (
|
||||||
<li key={f} className="flex items-center gap-2 text-emerald-800 dark:text-emerald-200">
|
<li key={f} className="flex items-center gap-2 text-foreground/85">
|
||||||
<Check className="h-4 w-4 text-emerald-600 dark:text-emerald-400" />
|
<Check className="h-4 w-4 text-brand" />
|
||||||
{t(`subscription.features.${f}`)}
|
{t(`subscription.features.${f}`)}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
<Button
|
<Button
|
||||||
className="bg-emerald-600 hover:bg-emerald-700 text-white"
|
className="bg-brand text-brand-foreground hover:bg-brand/90"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
startAction("trial-start");
|
startAction("trial-start");
|
||||||
capture("trial_started");
|
capture("trial_started");
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { lazy, Suspense } from "react";
|
import { lazy, Suspense, useState } from "react";
|
||||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||||
import { useCategoryParts } from "@/hooks/use-parts";
|
import { useCategoryParts } from "@/hooks/use-parts";
|
||||||
import { CategoryGrid } from "@/components/categories/category-grid";
|
import { CategoryGrid } from "@/components/categories/category-grid";
|
||||||
import { Button } from "@sase/ui";
|
import { CategoryTree } from "@/components/categories/category-tree";
|
||||||
import { Skeleton } from "@sase/ui";
|
import { CategoryColumns } from "@/components/categories/category-columns";
|
||||||
import { ArrowLeft } from "lucide-react";
|
import { Button, Skeleton, cn } from "@sase/ui";
|
||||||
|
import { ArrowLeft, Columns2, LayoutGrid, List } from "lucide-react";
|
||||||
|
import { getUserSettings, setUserSetting } from "@/lib/user-settings";
|
||||||
|
|
||||||
const SchemaViewer = lazy(() =>
|
const SchemaViewer = lazy(() =>
|
||||||
import("@/components/schema/schema-viewer").then((mod) => ({
|
import("@/components/schema/schema-viewer").then((mod) => ({
|
||||||
@@ -49,6 +51,15 @@ function VehicleCategoryPage() {
|
|||||||
|
|
||||||
const hasChildren = data?.children && data.children.length > 0;
|
const hasChildren = data?.children && data.children.length > 0;
|
||||||
|
|
||||||
|
const [viewMode, setViewMode] = useState<"grid" | "tree" | "columns">(
|
||||||
|
() => getUserSettings().categoryViewMode ?? "grid",
|
||||||
|
);
|
||||||
|
|
||||||
|
const changeViewMode = (mode: "grid" | "tree" | "columns") => {
|
||||||
|
setViewMode(mode);
|
||||||
|
setUserSetting("categoryViewMode", mode);
|
||||||
|
};
|
||||||
|
|
||||||
const handleBack = () => {
|
const handleBack = () => {
|
||||||
if (data?.parentId) {
|
if (data?.parentId) {
|
||||||
navigate({
|
navigate({
|
||||||
@@ -66,25 +77,55 @@ function VehicleCategoryPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center justify-between">
|
||||||
<Button
|
<div className="flex items-center gap-3">
|
||||||
variant="ghost"
|
<Button
|
||||||
size="icon"
|
variant="ghost"
|
||||||
onClick={handleBack}
|
size="icon"
|
||||||
title="Geri don"
|
onClick={handleBack}
|
||||||
>
|
title="Geri don"
|
||||||
<ArrowLeft className="h-4 w-4" />
|
>
|
||||||
</Button>
|
<ArrowLeft className="h-4 w-4" />
|
||||||
<div>
|
</Button>
|
||||||
<h1 className="text-xl font-bold">
|
<div>
|
||||||
{data?.name || "Kategori Detayi"}
|
<h1 className="text-xl font-bold">
|
||||||
</h1>
|
{data?.name || "Kategori Detayi"}
|
||||||
{data?.description && (
|
</h1>
|
||||||
<p className="text-sm text-muted-foreground">
|
{data?.description && (
|
||||||
{data.description}
|
<p className="text-sm text-muted-foreground">
|
||||||
</p>
|
{data.description}
|
||||||
)}
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{hasChildren && (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => changeViewMode("grid")}
|
||||||
|
className={cn("rounded p-1.5", viewMode === "grid" ? "bg-accent" : "text-muted-foreground hover:text-foreground")}
|
||||||
|
title="Izgara"
|
||||||
|
>
|
||||||
|
<LayoutGrid className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => changeViewMode("tree")}
|
||||||
|
className={cn("rounded p-1.5", viewMode === "tree" ? "bg-accent" : "text-muted-foreground hover:text-foreground")}
|
||||||
|
title="Agac"
|
||||||
|
>
|
||||||
|
<List className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => changeViewMode("columns")}
|
||||||
|
className={cn("rounded p-1.5", viewMode === "columns" ? "bg-accent" : "text-muted-foreground hover:text-foreground")}
|
||||||
|
title="Sutun"
|
||||||
|
>
|
||||||
|
<Columns2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Error state */}
|
{/* Error state */}
|
||||||
@@ -99,12 +140,24 @@ function VehicleCategoryPage() {
|
|||||||
<CategoryGridFallback />
|
<CategoryGridFallback />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Parent category — show children grid */}
|
{/* Parent category — show children */}
|
||||||
{hasChildren && (
|
{hasChildren && (
|
||||||
<CategoryGrid
|
viewMode === "grid" ? (
|
||||||
categories={data.children!}
|
<CategoryGrid
|
||||||
vehicleId={id}
|
categories={data.children!}
|
||||||
/>
|
vehicleId={id}
|
||||||
|
/>
|
||||||
|
) : viewMode === "tree" ? (
|
||||||
|
<CategoryTree
|
||||||
|
categories={data.children!}
|
||||||
|
vehicleId={id}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<CategoryColumns
|
||||||
|
categories={data.children!}
|
||||||
|
vehicleId={id}
|
||||||
|
/>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Leaf category — show schema viewer */}
|
{/* Leaf category — show schema viewer */}
|
||||||
|
|||||||
@@ -4,9 +4,10 @@ import { useQuery } from "@tanstack/react-query";
|
|||||||
import { api } from "@/lib/api-client";
|
import { api } from "@/lib/api-client";
|
||||||
import { CategoryTree } from "@/components/categories/category-tree";
|
import { CategoryTree } from "@/components/categories/category-tree";
|
||||||
import { CategoryGrid } from "@/components/categories/category-grid";
|
import { CategoryGrid } from "@/components/categories/category-grid";
|
||||||
|
import { CategoryColumns } from "@/components/categories/category-columns";
|
||||||
import { Card, CardContent, CardHeader, CardTitle, cn } from "@sase/ui";
|
import { Card, CardContent, CardHeader, CardTitle, cn } from "@sase/ui";
|
||||||
import { Skeleton } from "@sase/ui";
|
import { Skeleton } from "@sase/ui";
|
||||||
import { ArrowLeft, LayoutGrid, List } from "lucide-react";
|
import { ArrowLeft, Columns2, LayoutGrid, List } from "lucide-react";
|
||||||
import { Button } from "@sase/ui";
|
import { Button } from "@sase/ui";
|
||||||
import { getUserSettings, setUserSetting } from "@/lib/user-settings";
|
import { getUserSettings, setUserSetting } from "@/lib/user-settings";
|
||||||
import { CarBrandLogo } from "@/components/ui/car-brand-logo";
|
import { CarBrandLogo } from "@/components/ui/car-brand-logo";
|
||||||
@@ -17,11 +18,11 @@ export const Route = createFileRoute("/dashboard/vehicles_/$id/")({
|
|||||||
|
|
||||||
function VehicleDetailPage() {
|
function VehicleDetailPage() {
|
||||||
const { id } = Route.useParams();
|
const { id } = Route.useParams();
|
||||||
const [viewMode, setViewMode] = useState<"grid" | "tree">(
|
const [viewMode, setViewMode] = useState<"grid" | "tree" | "columns">(
|
||||||
() => getUserSettings().categoryViewMode ?? "grid",
|
() => getUserSettings().categoryViewMode ?? "grid",
|
||||||
);
|
);
|
||||||
|
|
||||||
const changeViewMode = (mode: "grid" | "tree") => {
|
const changeViewMode = (mode: "grid" | "tree" | "columns") => {
|
||||||
setViewMode(mode);
|
setViewMode(mode);
|
||||||
setUserSetting("categoryViewMode", mode);
|
setUserSetting("categoryViewMode", mode);
|
||||||
};
|
};
|
||||||
@@ -97,6 +98,7 @@ function VehicleDetailPage() {
|
|||||||
? "bg-accent"
|
? "bg-accent"
|
||||||
: "text-muted-foreground hover:text-foreground",
|
: "text-muted-foreground hover:text-foreground",
|
||||||
)}
|
)}
|
||||||
|
title="Izgara"
|
||||||
>
|
>
|
||||||
<LayoutGrid className="h-4 w-4" />
|
<LayoutGrid className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -109,12 +111,26 @@ function VehicleDetailPage() {
|
|||||||
? "bg-accent"
|
? "bg-accent"
|
||||||
: "text-muted-foreground hover:text-foreground",
|
: "text-muted-foreground hover:text-foreground",
|
||||||
)}
|
)}
|
||||||
|
title="Agac"
|
||||||
>
|
>
|
||||||
<List className="h-4 w-4" />
|
<List className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => changeViewMode("columns")}
|
||||||
|
className={cn(
|
||||||
|
"p-1.5 rounded",
|
||||||
|
viewMode === "columns"
|
||||||
|
? "bg-accent"
|
||||||
|
: "text-muted-foreground hover:text-foreground",
|
||||||
|
)}
|
||||||
|
title="Sutun"
|
||||||
|
>
|
||||||
|
<Columns2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className={viewMode === "columns" ? "p-0 overflow-hidden rounded-b-lg" : undefined}>
|
||||||
{categoriesLoading ? (
|
{categoriesLoading ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{Array.from({ length: 8 }).map((_, i) => (
|
{Array.from({ length: 8 }).map((_, i) => (
|
||||||
@@ -126,11 +142,16 @@ function VehicleDetailPage() {
|
|||||||
categories={categoryTree || []}
|
categories={categoryTree || []}
|
||||||
vehicleId={id}
|
vehicleId={id}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : viewMode === "tree" ? (
|
||||||
<CategoryTree
|
<CategoryTree
|
||||||
categories={categoryTree || []}
|
categories={categoryTree || []}
|
||||||
vehicleId={id}
|
vehicleId={id}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<CategoryColumns
|
||||||
|
categories={categoryTree || []}
|
||||||
|
vehicleId={id}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -124,7 +124,8 @@ function DemoPage() {
|
|||||||
>
|
>
|
||||||
{isDark ? <Sun className="size-4" /> : <Moon className="size-4" />}
|
{isDark ? <Sun className="size-4" /> : <Moon className="size-4" />}
|
||||||
</button>
|
</button>
|
||||||
<span className="rounded-full bg-amber-500/10 px-3 py-1 text-xs font-medium text-amber-600">
|
<span className="inline-flex items-center gap-1.5 rounded-full border border-border bg-muted px-3 py-1 text-xs font-medium text-muted-foreground">
|
||||||
|
<span className="size-1.5 rounded-full bg-brand" />
|
||||||
Demo
|
Demo
|
||||||
</span>
|
</span>
|
||||||
<Link to="/register">
|
<Link to="/register">
|
||||||
@@ -137,7 +138,7 @@ function DemoPage() {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className="mx-auto max-w-5xl px-4 py-12 sm:px-6">
|
<main id="main-content" className="mx-auto max-w-5xl px-4 py-12 sm:px-6">
|
||||||
{/* Step indicator */}
|
{/* Step indicator */}
|
||||||
<div className="mb-8 flex items-center justify-center gap-2 text-sm text-muted-foreground">
|
<div className="mb-8 flex items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||||
<button
|
<button
|
||||||
@@ -193,7 +194,7 @@ function DemoPage() {
|
|||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
className={`h-1 flex-1 rounded-full transition-colors duration-200 ${
|
className={`h-1 flex-1 rounded-full transition-colors duration-200 ${
|
||||||
i < vin.length ? "bg-emerald-500" : "bg-border"
|
i < vin.length ? "bg-brand" : "bg-border"
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -207,9 +208,9 @@ function DemoPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{vinPreview && !vinLoading && (
|
{vinPreview && !vinLoading && (
|
||||||
<div className="animate-fade-in-up rounded-2xl border border-emerald-500/30 bg-surface p-6">
|
<div className="animate-fade-in-up rounded-2xl border border-brand/30 bg-surface p-6">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Car className="size-6 text-emerald-500" />
|
<Car className="size-6 text-brand" />
|
||||||
<div>
|
<div>
|
||||||
<p className="font-semibold text-foreground">
|
<p className="font-semibold text-foreground">
|
||||||
{vinPreview.make} {vinPreview.model}
|
{vinPreview.make} {vinPreview.model}
|
||||||
@@ -221,7 +222,8 @@ function DemoPage() {
|
|||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => setStep("categories")}
|
onClick={() => setStep("categories")}
|
||||||
className="mt-4 w-full rounded-full bg-emerald-600 text-white hover:bg-emerald-700"
|
variant="brand"
|
||||||
|
className="mt-4 w-full rounded-full"
|
||||||
>
|
>
|
||||||
Parça Kataloğuna Devam Et
|
Parça Kataloğuna Devam Et
|
||||||
<ArrowRight className="ml-2 size-4" />
|
<ArrowRight className="ml-2 size-4" />
|
||||||
@@ -332,7 +334,7 @@ function DemoPage() {
|
|||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
className={`flex items-center justify-center rounded-lg border border-border text-xs text-muted-foreground ${
|
className={`flex items-center justify-center rounded-lg border border-border text-xs text-muted-foreground ${
|
||||||
[2, 5, 9, 13].includes(i) ? "border-emerald-500/50 bg-emerald-500/10 text-emerald-500" : "bg-muted/50"
|
[2, 5, 9, 13].includes(i) ? "border-brand/50 bg-brand/10 text-brand" : "bg-muted/50"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{[2, 5, 9, 13].includes(i) ? EXAMPLE_SCHEMA_PARTS[[2, 5, 9, 13].indexOf(i)]?.position : ""}
|
{[2, 5, 9, 13].includes(i) ? EXAMPLE_SCHEMA_PARTS[[2, 5, 9, 13].indexOf(i)]?.position : ""}
|
||||||
@@ -364,7 +366,7 @@ function DemoPage() {
|
|||||||
<p className="mt-0.5 text-sm text-muted-foreground">{part.name}</p>
|
<p className="mt-0.5 text-sm text-muted-foreground">{part.name}</p>
|
||||||
</div>
|
</div>
|
||||||
{idx < 2 ? (
|
{idx < 2 ? (
|
||||||
<span className="rounded-full bg-emerald-500/10 px-2 py-0.5 text-xs text-emerald-500">
|
<span className="rounded-full bg-brand/10 px-2 py-0.5 text-xs text-brand">
|
||||||
Görünür
|
Görünür
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
@@ -380,7 +382,7 @@ function DemoPage() {
|
|||||||
<div className="mt-6 rounded-2xl border-2 border-dashed border-border bg-surface p-6 text-center">
|
<div className="mt-6 rounded-2xl border-2 border-dashed border-border bg-surface p-6 text-center">
|
||||||
<h3 className="font-semibold">Tüm parçaları ve şemaları görün</h3>
|
<h3 className="font-semibold">Tüm parçaları ve şemaları görün</h3>
|
||||||
<p className="mt-2 text-sm text-muted-foreground">
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
7 gün ücretsiz deneyin — kredi kartı gerekmez
|
30 gün ücretsiz deneyin — kredi kartı gerekmez
|
||||||
</p>
|
</p>
|
||||||
<Link to="/register">
|
<Link to="/register">
|
||||||
<Button className="mt-4 rounded-full bg-foreground text-background hover:bg-foreground/90">
|
<Button className="mt-4 rounded-full bg-foreground text-background hover:bg-foreground/90">
|
||||||
|
|||||||
@@ -607,7 +607,7 @@ function HomePage() {
|
|||||||
</Link>
|
</Link>
|
||||||
<Link to="/register">
|
<Link to="/register">
|
||||||
<Button data-faro-user-action-name="hero-register" className="rounded-full bg-foreground text-background hover:bg-foreground/90">
|
<Button data-faro-user-action-name="hero-register" className="rounded-full bg-foreground text-background hover:bg-foreground/90">
|
||||||
7 Gün Ücretsiz Deneyin
|
30 Gün Ücretsiz Deneyin
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</>
|
</>
|
||||||
@@ -677,7 +677,7 @@ function HomePage() {
|
|||||||
</Link>
|
</Link>
|
||||||
<Link to="/register" onClick={() => setMobileMenuOpen(false)}>
|
<Link to="/register" onClick={() => setMobileMenuOpen(false)}>
|
||||||
<Button className="w-full rounded-full bg-foreground text-background">
|
<Button className="w-full rounded-full bg-foreground text-background">
|
||||||
7 Gün Ücretsiz Deneyin
|
30 Gün Ücretsiz Deneyin
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</>
|
</>
|
||||||
@@ -687,6 +687,7 @@ function HomePage() {
|
|||||||
)}
|
)}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<main id="main-content">
|
||||||
{/* ─── 2. HERO SECTION ──────────────────────────────────────────── */}
|
{/* ─── 2. HERO SECTION ──────────────────────────────────────────── */}
|
||||||
<section className="relative overflow-hidden px-4 pb-16 pt-16 sm:px-6 sm:pt-24 lg:pt-28">
|
<section className="relative overflow-hidden px-4 pb-16 pt-16 sm:px-6 sm:pt-24 lg:pt-28">
|
||||||
{/* Decorative glow */}
|
{/* Decorative glow */}
|
||||||
@@ -697,7 +698,10 @@ function HomePage() {
|
|||||||
<div className="relative mx-auto max-w-4xl text-center">
|
<div className="relative mx-auto max-w-4xl text-center">
|
||||||
{/* Pill badge — loss framing */}
|
{/* Pill badge — loss framing */}
|
||||||
<div className="mb-4 inline-flex items-center gap-2 rounded-full border border-border bg-muted px-4 py-1.5 text-xs text-muted-foreground">
|
<div className="mb-4 inline-flex items-center gap-2 rounded-full border border-border bg-muted px-4 py-1.5 text-xs text-muted-foreground">
|
||||||
<span className="size-2 rounded-full bg-amber-500" />
|
<span className="relative flex size-2">
|
||||||
|
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-destructive/60 opacity-75" />
|
||||||
|
<span className="relative inline-flex size-2 rounded-full bg-destructive" />
|
||||||
|
</span>
|
||||||
Her yanlış parça siparişi ortalama 450 TL'ye mal olur
|
Her yanlış parça siparişi ortalama 450 TL'ye mal olur
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -764,7 +768,7 @@ function HomePage() {
|
|||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
className={`h-1 flex-1 rounded-full transition-colors duration-200 ${
|
className={`h-1 flex-1 rounded-full transition-colors duration-200 ${
|
||||||
i < vin.length ? "bg-emerald-500" : "bg-border"
|
i < vin.length ? "bg-brand" : "bg-border"
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -781,9 +785,9 @@ function HomePage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{vinPreview && !vinLoading && (
|
{vinPreview && !vinLoading && (
|
||||||
<div className="animate-fade-in-up mx-auto mt-4 max-w-xl rounded-2xl border border-emerald-500/30 bg-surface p-5 text-left">
|
<div className="animate-fade-in-up mx-auto mt-4 max-w-xl rounded-2xl border border-brand/30 bg-surface p-5 text-left">
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<Car className="size-5 text-emerald-500" />
|
<Car className="size-5 text-brand" />
|
||||||
<span className="font-semibold text-foreground">
|
<span className="font-semibold text-foreground">
|
||||||
{vinPreview.make} {vinPreview.model}
|
{vinPreview.make} {vinPreview.model}
|
||||||
</span>
|
</span>
|
||||||
@@ -801,7 +805,8 @@ function HomePage() {
|
|||||||
size="sm"
|
size="sm"
|
||||||
onClick={handleVinSearch}
|
onClick={handleVinSearch}
|
||||||
disabled={decodeLoading}
|
disabled={decodeLoading}
|
||||||
className="rounded-full bg-emerald-500 text-white hover:bg-emerald-600"
|
variant="brand"
|
||||||
|
className="rounded-full"
|
||||||
>
|
>
|
||||||
{decodeLoading ? (
|
{decodeLoading ? (
|
||||||
<>
|
<>
|
||||||
@@ -921,7 +926,7 @@ function HomePage() {
|
|||||||
key={b}
|
key={b}
|
||||||
className="flex items-center gap-2 text-sm text-muted-foreground"
|
className="flex items-center gap-2 text-sm text-muted-foreground"
|
||||||
>
|
>
|
||||||
<CheckCircle2 className="size-4 shrink-0 text-emerald-500" />
|
<CheckCircle2 className="size-4 shrink-0 text-brand" />
|
||||||
{b}
|
{b}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
@@ -993,7 +998,7 @@ function HomePage() {
|
|||||||
<td className="px-6 py-4 text-center text-sm text-muted-foreground">
|
<td className="px-6 py-4 text-center text-sm text-muted-foreground">
|
||||||
{row.manual}
|
{row.manual}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 text-center text-sm font-medium text-emerald-500">
|
<td className="px-6 py-4 text-center text-sm font-medium text-brand">
|
||||||
{row.sase}
|
{row.sase}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -1015,9 +1020,9 @@ function HomePage() {
|
|||||||
<p className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/70">Manuel</p>
|
<p className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/70">Manuel</p>
|
||||||
<p className="mt-1 text-sm text-muted-foreground">{row.manual}</p>
|
<p className="mt-1 text-sm text-muted-foreground">{row.manual}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-lg bg-emerald-500/10 p-2.5 text-center">
|
<div className="rounded-lg bg-brand/10 p-2.5 text-center">
|
||||||
<p className="text-[10px] font-medium uppercase tracking-wider text-emerald-500/70">Sase.tr</p>
|
<p className="text-[10px] font-medium uppercase tracking-wider text-brand/70">Sase.tr</p>
|
||||||
<p className="mt-1 text-sm font-medium text-emerald-500">{row.sase}</p>
|
<p className="mt-1 text-sm font-medium text-brand">{row.sase}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1043,17 +1048,17 @@ function HomePage() {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={item.title}
|
key={item.title}
|
||||||
className={`rounded-2xl border border-border bg-surface p-6 sm:p-8 ${wide ? "md:col-span-2" : "md:col-span-1"}`}
|
className={`group relative overflow-hidden rounded-2xl border border-border bg-surface p-6 transition-all duration-300 hover:-translate-y-1 hover:border-foreground/20 hover:shadow-[var(--shadow-md)] sm:p-8 ${wide ? "md:col-span-2" : "md:col-span-1"}`}
|
||||||
>
|
>
|
||||||
<div className="mb-4 inline-flex rounded-lg bg-muted p-2.5">
|
<div className="mb-4 inline-flex rounded-lg bg-muted p-2.5 text-muted-foreground transition-colors duration-300 group-hover:bg-foreground group-hover:text-background">
|
||||||
<Icon className="size-5 text-muted-foreground" />
|
<Icon className="size-5" />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-lg font-semibold">{item.title}</h3>
|
<h3 className="text-lg font-semibold tracking-tight">{item.title}</h3>
|
||||||
<p className="mt-2 text-sm text-muted-foreground">
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
{item.description}
|
{item.description}
|
||||||
</p>
|
</p>
|
||||||
{item.stat && (
|
{item.stat && (
|
||||||
<p className="mt-4 font-[family-name:var(--font-display)] text-4xl font-bold">
|
<p className="mt-4 font-[family-name:var(--font-display)] text-4xl font-bold tabular tracking-tight">
|
||||||
{item.stat}
|
{item.stat}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -1075,17 +1080,17 @@ function HomePage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-12 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="mt-12 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{USE_CASES.map((uc) => {
|
{USE_CASES.map((uc, idx) => {
|
||||||
const Icon = uc.icon;
|
const Icon = uc.icon;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={uc.title}
|
key={uc.title}
|
||||||
className="rounded-2xl border border-border bg-surface p-6"
|
className={`group relative rounded-2xl border border-border bg-surface p-6 transition-all duration-300 hover:-translate-y-1 hover:border-foreground/20 hover:shadow-[var(--shadow-md)] ${idx % 2 === 1 ? "lg:translate-y-6" : ""}`}
|
||||||
>
|
>
|
||||||
<div className="mb-4 inline-flex rounded-lg bg-muted p-2.5">
|
<div className="mb-4 inline-flex rounded-lg bg-muted p-2.5 text-muted-foreground transition-colors duration-300 group-hover:bg-foreground group-hover:text-background">
|
||||||
<Icon className="size-5 text-muted-foreground" />
|
<Icon className="size-5" />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-lg font-semibold">{uc.title}</h3>
|
<h3 className="text-lg font-semibold tracking-tight">{uc.title}</h3>
|
||||||
<p className="mt-2 text-sm text-muted-foreground">
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
{uc.description}
|
{uc.description}
|
||||||
</p>
|
</p>
|
||||||
@@ -1120,29 +1125,47 @@ function HomePage() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* ─── 8. RAKAMLARLA SASE.TR ────────────────────────────────────── */}
|
{/* ─── 8. RAKAMLARLA SASE.TR ────────────────────────────────────── */}
|
||||||
<section className="bg-[#09090b] px-4 py-20 text-white sm:px-6">
|
<section className="relative overflow-hidden bg-foreground px-4 py-24 text-background sm:px-6">
|
||||||
<div className="mx-auto max-w-7xl">
|
{/* Ambient grid pattern — breaks digital flatness */}
|
||||||
<div className="text-center">
|
<div className="pointer-events-none absolute inset-0 opacity-[0.04]">
|
||||||
<span className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-4 py-1.5 text-xs font-medium text-neutral-400">
|
<svg width="100%" height="100%" aria-hidden="true">
|
||||||
|
<defs>
|
||||||
|
<pattern id="stats-grid" width="48" height="48" patternUnits="userSpaceOnUse">
|
||||||
|
<path d="M 48 0 L 0 0 0 48" fill="none" stroke="currentColor" strokeWidth="1" />
|
||||||
|
</pattern>
|
||||||
|
</defs>
|
||||||
|
<rect width="100%" height="100%" fill="url(#stats-grid)" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
{/* Brand accent glow */}
|
||||||
|
<div className="pointer-events-none absolute -left-32 top-1/2 h-[400px] w-[400px] -translate-y-1/2 rounded-full bg-brand/20 blur-[120px]" />
|
||||||
|
<div className="pointer-events-none absolute -right-32 top-0 h-[300px] w-[300px] rounded-full bg-brand/10 blur-[100px]" />
|
||||||
|
|
||||||
|
<div className="relative mx-auto max-w-7xl">
|
||||||
|
<div className="max-w-2xl">
|
||||||
|
<span className="inline-flex items-center gap-2 rounded-full border border-background/15 bg-background/5 px-4 py-1.5 text-xs font-medium text-background/70 backdrop-blur-sm">
|
||||||
|
<span className="size-1.5 rounded-full bg-brand" />
|
||||||
Platform
|
Platform
|
||||||
</span>
|
</span>
|
||||||
<h2 className="mt-4 font-[family-name:var(--font-display)] text-3xl font-bold tracking-tight text-white sm:text-4xl lg:text-5xl">
|
<h2 className="mt-5 font-[family-name:var(--font-display)] text-4xl font-bold tracking-tight sm:text-5xl lg:text-6xl">
|
||||||
Rakamlarla Sase.tr
|
Rakamlarla<br />
|
||||||
|
<span className="text-background/50">Sase.tr</span>
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-12 grid grid-cols-2 gap-4 sm:gap-6 lg:grid-cols-4">
|
<div className="mt-14 grid grid-cols-2 gap-px overflow-hidden rounded-2xl border border-background/10 bg-background/10 lg:grid-cols-4">
|
||||||
{STATS.map((stat) => (
|
{STATS.map((stat) => (
|
||||||
<div
|
<div
|
||||||
key={stat.label}
|
key={stat.label}
|
||||||
className="rounded-2xl border border-white/10 bg-white/5 p-6 text-center sm:p-8"
|
className="group relative bg-foreground p-7 transition-colors duration-300 hover:bg-background/[0.04] sm:p-9"
|
||||||
>
|
>
|
||||||
<p className="font-[family-name:var(--font-display)] text-4xl font-bold text-white sm:text-5xl">
|
<p className="font-[family-name:var(--font-display)] text-5xl font-bold tracking-tight tabular sm:text-6xl">
|
||||||
{stat.value}
|
{stat.value}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-2 text-sm text-neutral-400">
|
<p className="mt-3 text-sm text-background/60">
|
||||||
{stat.label}
|
{stat.label}
|
||||||
</p>
|
</p>
|
||||||
|
<div className="absolute bottom-0 left-0 h-px w-0 bg-brand transition-all duration-500 group-hover:w-full" />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -1244,7 +1267,7 @@ function HomePage() {
|
|||||||
key={b}
|
key={b}
|
||||||
className="flex items-center gap-2 text-sm text-muted-foreground"
|
className="flex items-center gap-2 text-sm text-muted-foreground"
|
||||||
>
|
>
|
||||||
<CheckCircle2 className="size-4 shrink-0 text-emerald-500" />
|
<CheckCircle2 className="size-4 shrink-0 text-brand" />
|
||||||
{b}
|
{b}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
@@ -1273,7 +1296,7 @@ function HomePage() {
|
|||||||
key={stat.label}
|
key={stat.label}
|
||||||
className="rounded-2xl border border-border bg-surface p-6 sm:p-8 text-center"
|
className="rounded-2xl border border-border bg-surface p-6 sm:p-8 text-center"
|
||||||
>
|
>
|
||||||
<p className="font-[family-name:var(--font-display)] text-3xl font-bold text-emerald-500 sm:text-4xl">
|
<p className="font-[family-name:var(--font-display)] text-3xl font-bold text-brand sm:text-4xl">
|
||||||
{stat.value}
|
{stat.value}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-2 text-sm text-muted-foreground">
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
@@ -1297,10 +1320,17 @@ function HomePage() {
|
|||||||
|
|
||||||
<div className="mt-12 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
<div className="mt-12 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
{TESTIMONIALS.map((t) => (
|
{TESTIMONIALS.map((t) => (
|
||||||
<div key={t.name} className="rounded-2xl border border-border bg-surface p-6">
|
<div key={t.name} className="group rounded-2xl border border-border bg-surface p-6 transition-all duration-300 hover:-translate-y-1 hover:border-foreground/20 hover:shadow-[var(--shadow-md)]">
|
||||||
<div className="flex gap-0.5">
|
<div className="flex gap-0.5">
|
||||||
{Array.from({ length: t.rating }).map((_, i) => (
|
{Array.from({ length: t.rating }).map((_, i) => (
|
||||||
<span key={i} className="text-amber-400">⭐</span>
|
<svg
|
||||||
|
key={i}
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
className="size-3.5 fill-foreground/85"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M9.05.927c.3-.921 1.603-.921 1.902 0l1.794 5.522a1 1 0 00.95.69h5.806c.969 0 1.371 1.24.588 1.81l-4.696 3.412a1 1 0 00-.363 1.118l1.793 5.522c.3.921-.755 1.688-1.539 1.118l-4.695-3.413a1 1 0 00-1.176 0l-4.695 3.413c-.784.57-1.838-.197-1.539-1.118l1.793-5.522a1 1 0 00-.363-1.118L2.31 8.95c-.783-.57-.38-1.81.588-1.81h5.807a1 1 0 00.95-.69L9.05.927z" />
|
||||||
|
</svg>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-4 text-sm leading-relaxed text-muted-foreground">
|
<p className="mt-4 text-sm leading-relaxed text-muted-foreground">
|
||||||
@@ -1322,104 +1352,159 @@ function HomePage() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* ─── 10. PRICING ─────────────────────────────────────────────── */}
|
{/* ─── 10. PRICING ─────────────────────────────────────────────── */}
|
||||||
<section className="px-4 py-20 sm:px-6">
|
<section className="px-4 py-24 sm:px-6">
|
||||||
<div className="mx-auto max-w-6xl">
|
<div className="mx-auto max-w-6xl">
|
||||||
<div className="text-center">
|
<div className="max-w-2xl">
|
||||||
<SectionBadge>Fiyatlandırma</SectionBadge>
|
<SectionBadge>Fiyatlandırma</SectionBadge>
|
||||||
<h2 className="mt-4 font-[family-name:var(--font-display)] text-3xl font-bold tracking-tight sm:text-4xl lg:text-5xl">
|
<h2 className="mt-4 font-[family-name:var(--font-display)] text-4xl font-bold tracking-tight sm:text-5xl lg:text-6xl">
|
||||||
Günde 7 TL'den Başlayan Fiyatlar
|
Günde 7 TL'den
|
||||||
|
<br />
|
||||||
|
<span className="text-muted-foreground">başlayan fiyatlar.</span>
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mx-auto mt-4 max-w-xl text-muted-foreground">
|
<p className="mt-5 max-w-xl text-base text-muted-foreground">
|
||||||
Tek bir yanlış parça iadesinin maliyetinden daha az.
|
Tek bir yanlış parça iadesinin maliyetinden daha az. Tüm planlarda 30 gün ücretsiz deneme var, kart bilgisi gerekmez.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Horizontally scrollable on mobile, grid on desktop */}
|
{/* Tier tiles — horizontal row, compact, breaks 4-tower symmetry */}
|
||||||
<div className="mt-12 flex gap-4 overflow-x-auto pb-4 sm:grid sm:grid-cols-2 sm:overflow-visible sm:pb-0 lg:grid-cols-4">
|
<div className="mt-14 grid grid-cols-1 gap-4 sm:grid-cols-3 sm:gap-5">
|
||||||
{[
|
{[
|
||||||
{
|
{
|
||||||
name: "1 Marka",
|
name: "1 Marka",
|
||||||
description: "Tek marka için yedek parça erişimi",
|
description: "Tek marka için erişim",
|
||||||
price: "200",
|
price: "200",
|
||||||
yearly: "2.000",
|
yearly: "2.000",
|
||||||
features: ["1 marka seçimi", "Sınırsız VIN arama", "Parça kataloğu", "Şema görüntüleyici"],
|
features: ["1 marka seçimi", "Sınırsız VIN arama", "Parça kataloğu", "Şema görüntüleyici"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "2 Marka",
|
name: "2 Marka",
|
||||||
description: "İki farklı marka için erişim",
|
description: "İki farklı marka",
|
||||||
price: "350",
|
price: "350",
|
||||||
yearly: "3.500",
|
yearly: "3.500",
|
||||||
popular: true,
|
features: ["2 marka seçimi", "Sınırsız VIN arama", "Parça kataloğu", "Öncelikli destek"],
|
||||||
features: ["2 marka seçimi", "Sınırsız VIN arama", "Parça kataloğu", "Şema görüntüleyici", "Öncelikli destek"],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "3 Marka",
|
name: "3 Marka",
|
||||||
description: "Üç marka için kapsamlı erişim",
|
description: "Üç marka kapsamlı",
|
||||||
price: "500",
|
price: "500",
|
||||||
yearly: "5.000",
|
yearly: "5.000",
|
||||||
features: ["3 marka seçimi", "Sınırsız VIN arama", "Parça kataloğu", "Şema görüntüleyici", "Öncelikli destek"],
|
features: ["3 marka seçimi", "Sınırsız VIN arama", "Parça kataloğu", "Öncelikli destek"],
|
||||||
},
|
},
|
||||||
{
|
].map((plan, idx) => (
|
||||||
name: "Full Paket",
|
|
||||||
description: "Tüm markalara sınırsız erişim",
|
|
||||||
price: "999",
|
|
||||||
yearly: "9.990",
|
|
||||||
features: ["Tüm markalar", "Sınırsız VIN arama", "Parça kataloğu", "Şema görüntüleyici", "Öncelikli destek", "OEM parça arama"],
|
|
||||||
},
|
|
||||||
].map((plan) => (
|
|
||||||
<div
|
<div
|
||||||
key={plan.name}
|
key={plan.name}
|
||||||
className={`flex min-w-[260px] flex-1 flex-col rounded-2xl border bg-surface p-6 sm:p-8 ${plan.popular ? "border-2 border-foreground/20" : "border-border"}`}
|
className={`group relative flex flex-col rounded-2xl border border-border bg-surface p-7 transition-all duration-300 hover:-translate-y-1 hover:border-foreground/20 hover:shadow-[var(--shadow-md)] ${idx === 1 ? "sm:translate-y-3" : ""} ${idx === 2 ? "sm:translate-y-6" : ""}`}
|
||||||
>
|
>
|
||||||
{plan.popular && (
|
<div className="flex items-baseline justify-between">
|
||||||
<div className="mb-3 inline-flex self-start rounded-full bg-foreground px-3 py-1 text-xs font-medium text-background">
|
<h3 className="font-[family-name:var(--font-display)] text-lg font-semibold">
|
||||||
En Popüler
|
{plan.name}
|
||||||
</div>
|
</h3>
|
||||||
)}
|
<span className="text-xs text-muted-foreground tabular">
|
||||||
<h3 className="font-[family-name:var(--font-display)] text-xl font-bold">
|
/ay
|
||||||
{plan.name}
|
|
||||||
</h3>
|
|
||||||
<p className="mt-1 text-sm text-muted-foreground">
|
|
||||||
{plan.description}
|
|
||||||
</p>
|
|
||||||
<div className="mt-4">
|
|
||||||
<span className="font-[family-name:var(--font-display)] text-4xl font-bold">
|
|
||||||
{plan.price} TL
|
|
||||||
</span>
|
</span>
|
||||||
<span className="text-muted-foreground">/ay</span>
|
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
<p className="mt-1 text-sm text-muted-foreground">{plan.description}</p>
|
||||||
veya {plan.yearly} TL/yıl
|
<div className="mt-5 flex items-baseline gap-1">
|
||||||
|
<span className="font-[family-name:var(--font-display)] text-4xl font-bold tabular tracking-tight">
|
||||||
|
{plan.price}
|
||||||
|
</span>
|
||||||
|
<span className="text-base font-medium text-muted-foreground">TL</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground tabular">
|
||||||
|
yıllık {plan.yearly} TL
|
||||||
</p>
|
</p>
|
||||||
<ul className="mt-6 flex-1 space-y-2 text-sm">
|
<ul className="mt-6 flex-1 space-y-2.5 text-sm">
|
||||||
{plan.features.map((f) => (
|
{plan.features.map((f) => (
|
||||||
<li
|
<li key={f} className="flex items-start gap-2 text-muted-foreground">
|
||||||
key={f}
|
<CheckCircle2 className="mt-0.5 size-4 shrink-0 text-foreground/40" />
|
||||||
className="flex items-center gap-2 text-muted-foreground"
|
<span>{f}</span>
|
||||||
>
|
|
||||||
<CheckCircle2 className="size-4 shrink-0 text-emerald-500" />
|
|
||||||
{f}
|
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
<Link to="/register" className="mt-6">
|
<Link to="/register" className="mt-7">
|
||||||
<Button
|
<Button variant="outline" className="w-full rounded-full">
|
||||||
className={`w-full rounded-full ${plan.popular ? "bg-foreground text-background hover:bg-foreground/90" : "border-border text-muted-foreground hover:bg-muted hover:text-foreground"}`}
|
30 gün ücretsiz dene
|
||||||
variant={plan.popular ? "default" : "outline"}
|
|
||||||
>
|
|
||||||
7 Gün Ücretsiz Deneyin
|
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-8 text-center">
|
{/* Hero plan — Full Paket, asymmetric horizontal card with brand emphasis */}
|
||||||
|
<div className="mt-8 sm:mt-10">
|
||||||
|
<div className="relative overflow-hidden rounded-2xl border border-foreground/10 bg-foreground text-background shadow-[var(--shadow-lg)]">
|
||||||
|
{/* Brand accent glow */}
|
||||||
|
<div className="pointer-events-none absolute -right-32 top-1/2 h-[400px] w-[400px] -translate-y-1/2 rounded-full bg-brand/30 blur-[120px]" />
|
||||||
|
<div className="pointer-events-none absolute -left-24 -top-24 h-[300px] w-[300px] rounded-full bg-brand/15 blur-[100px]" />
|
||||||
|
|
||||||
|
<div className="relative grid gap-8 p-8 sm:p-10 lg:grid-cols-[1.1fr_1fr] lg:gap-12 lg:p-14">
|
||||||
|
{/* Left: pitch + price */}
|
||||||
|
<div>
|
||||||
|
<div className="inline-flex items-center gap-2 rounded-full border border-background/15 bg-background/5 px-3 py-1 text-xs font-medium text-background/80 backdrop-blur-sm">
|
||||||
|
<span className="size-1.5 rounded-full bg-brand" />
|
||||||
|
En çok tercih edilen
|
||||||
|
</div>
|
||||||
|
<h3 className="mt-5 font-[family-name:var(--font-display)] text-3xl font-bold tracking-tight sm:text-4xl">
|
||||||
|
Full Paket
|
||||||
|
</h3>
|
||||||
|
<p className="mt-2 max-w-md text-background/70">
|
||||||
|
Tüm markalara sınırsız erişim. Tek bir aboneliğe sığdırdık.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-7 flex items-baseline gap-2">
|
||||||
|
<span className="font-[family-name:var(--font-display)] text-6xl font-bold tabular tracking-tight">
|
||||||
|
999
|
||||||
|
</span>
|
||||||
|
<span className="text-lg font-medium text-background/60">TL/ay</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-sm text-background/50 tabular">
|
||||||
|
yıllık 9.990 TL — ayda 832 TL'ye denk gelir
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Link to="/register" className="mt-8 inline-block">
|
||||||
|
<Button variant="brand" size="lg" className="rounded-full">
|
||||||
|
30 gün ücretsiz dene
|
||||||
|
<ArrowRight className="ml-2 size-4" />
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<p className="mt-3 text-xs text-background/50">
|
||||||
|
Kart bilgisi gerekmez. İstediğin zaman iptal et.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right: feature list, two columns */}
|
||||||
|
<div className="border-t border-background/10 pt-8 lg:border-l lg:border-t-0 lg:pl-12 lg:pt-0">
|
||||||
|
<p className="text-xs font-medium uppercase tracking-wider text-background/50">
|
||||||
|
Pakete dahil
|
||||||
|
</p>
|
||||||
|
<ul className="mt-5 grid grid-cols-1 gap-x-6 gap-y-3 sm:grid-cols-2">
|
||||||
|
{[
|
||||||
|
"Tüm 27+ markaya erişim",
|
||||||
|
"Sınırsız VIN arama",
|
||||||
|
"OEM parça kataloğu",
|
||||||
|
"İnteraktif şema görüntüleyici",
|
||||||
|
"Geçmiş sorgular & favoriler",
|
||||||
|
"Öncelikli e-posta desteği",
|
||||||
|
"API erişimi (talep üzerine)",
|
||||||
|
"Toplu sorgu özelliği",
|
||||||
|
].map((f) => (
|
||||||
|
<li key={f} className="flex items-start gap-2.5 text-sm text-background/85">
|
||||||
|
<CheckCircle2 className="mt-0.5 size-4 shrink-0 text-brand" />
|
||||||
|
<span>{f}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-10 flex items-center justify-center">
|
||||||
<Link
|
<Link
|
||||||
to="/pricing"
|
to="/pricing"
|
||||||
className="inline-flex items-center gap-1 text-sm text-muted-foreground transition hover:text-foreground"
|
className="inline-flex items-center gap-1.5 rounded-full border border-border px-4 py-2 text-sm text-muted-foreground transition-all duration-200 hover:-translate-y-0.5 hover:border-foreground/30 hover:text-foreground hover:shadow-sm"
|
||||||
>
|
>
|
||||||
Detaylı karşılaştırma
|
Detaylı karşılaştırmayı gör
|
||||||
<ArrowRight className="size-3.5" />
|
<ArrowRight className="size-3.5" />
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
@@ -1499,6 +1584,7 @@ function HomePage() {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
{/* ─── 13. FOOTER ───────────────────────────────────────────────── */}
|
{/* ─── 13. FOOTER ───────────────────────────────────────────────── */}
|
||||||
<footer className="bg-background px-4 sm:px-6">
|
<footer className="bg-background px-4 sm:px-6">
|
||||||
@@ -1595,7 +1681,7 @@ function HomePage() {
|
|||||||
© {new Date().getFullYear()} Sase.tr. Tüm hakları saklıdır.
|
© {new Date().getFullYear()} Sase.tr. Tüm hakları saklıdır.
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground/70">
|
<div className="flex items-center gap-2 text-sm text-muted-foreground/70">
|
||||||
<span className="size-2 rounded-full bg-emerald-500" />
|
<span className="size-2 rounded-full bg-brand" />
|
||||||
Tüm servisler aktif
|
Tüm servisler aktif
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ function PricingPage() {
|
|||||||
usePageMeta({
|
usePageMeta({
|
||||||
title: "Fiyatlandırma — Sase.tr | Şase Sorgulama Planları",
|
title: "Fiyatlandırma — Sase.tr | Şase Sorgulama Planları",
|
||||||
description:
|
description:
|
||||||
"200 TL/ay'dan başlayan şase numarası ve OEM parça sorgulama planları. 7 gün ücretsiz deneyin.",
|
"200 TL/ay'dan başlayan şase numarası ve OEM parça sorgulama planları. 30 gün ücretsiz deneyin.",
|
||||||
canonical: "https://sase.tr/pricing",
|
canonical: "https://sase.tr/pricing",
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -85,7 +85,7 @@ function PricingPage() {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className="container mx-auto px-4 py-24">
|
<main id="main-content" className="container mx-auto px-4 py-24">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h1 className="text-4xl font-bold">Fiyatlandırma</h1>
|
<h1 className="text-4xl font-bold">Fiyatlandırma</h1>
|
||||||
<p className="mt-4 text-lg text-muted-foreground">
|
<p className="mt-4 text-lg text-muted-foreground">
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -30,9 +30,11 @@ server {
|
|||||||
|
|
||||||
# Faro telemetry proxy → Grafana Cloud (avoids CORS)
|
# Faro telemetry proxy → Grafana Cloud (avoids CORS)
|
||||||
location /collect/ {
|
location /collect/ {
|
||||||
proxy_pass https://faro-collector-prod-eu-west-2.grafana.net/collect/;
|
resolver 1.1.1.1 8.8.8.8 valid=300s ipv6=off;
|
||||||
|
set $faro_upstream "faro-collector-prod-eu-west-2.grafana.net";
|
||||||
|
proxy_pass https://$faro_upstream/collect/;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host faro-collector-prod-eu-west-2.grafana.net;
|
proxy_set_header Host $faro_upstream;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_ssl_server_name on;
|
proxy_ssl_server_name on;
|
||||||
|
|||||||
@@ -55,6 +55,9 @@ export const envSchema = z.object({
|
|||||||
.transform((v) => v === "true")
|
.transform((v) => v === "true")
|
||||||
.default("false"),
|
.default("false"),
|
||||||
|
|
||||||
|
// OpenRouter (used by scripts/emex-translate-bootstrap.ts → DeepSeek V3)
|
||||||
|
OPENROUTER_API_KEY: z.string().optional(),
|
||||||
|
|
||||||
// Postal Email
|
// Postal Email
|
||||||
POSTAL_API_URL: z.string().url().optional(),
|
POSTAL_API_URL: z.string().url().optional(),
|
||||||
POSTAL_API_KEY: z.string().optional(),
|
POSTAL_API_KEY: z.string().optional(),
|
||||||
|
|||||||
@@ -4,21 +4,27 @@ import { cva, type VariantProps } from "class-variance-authority";
|
|||||||
import { cn } from "./utils";
|
import { cn } from "./utils";
|
||||||
|
|
||||||
const buttonVariants = cva(
|
const buttonVariants = cva(
|
||||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-[transform,background-color,border-color,box-shadow,color] duration-200 ease-out will-change-transform focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 active:scale-[0.97] [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: "bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
default:
|
||||||
destructive: "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
"bg-primary text-primary-foreground shadow-sm hover:bg-primary/92 hover:-translate-y-0.5 hover:shadow-md active:translate-y-0",
|
||||||
outline: "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
|
brand:
|
||||||
secondary: "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
"bg-brand text-brand-foreground shadow-sm hover:bg-brand/92 hover:-translate-y-0.5 hover:shadow-[var(--shadow-brand)] active:translate-y-0",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/92 hover:-translate-y-0.5 hover:shadow-md active:translate-y-0",
|
||||||
|
outline:
|
||||||
|
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground hover:border-foreground/30 hover:-translate-y-0.5 hover:shadow-md active:translate-y-0",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80 hover:-translate-y-0.5 active:translate-y-0",
|
||||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||||
link: "text-primary underline-offset-4 hover:underline",
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
default: "h-9 px-4 py-2",
|
default: "h-9 px-4 py-2",
|
||||||
sm: "h-8 rounded-md px-3 text-xs",
|
sm: "h-8 rounded-md px-3 text-xs",
|
||||||
lg: "h-10 rounded-md px-8",
|
lg: "h-11 rounded-md px-8",
|
||||||
icon: "h-9 w-9",
|
icon: "h-9 w-9",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
25
pnpm-lock.yaml
generated
25
pnpm-lock.yaml
generated
@@ -98,7 +98,7 @@ importers:
|
|||||||
version: link:../../packages/shared
|
version: link:../../packages/shared
|
||||||
better-auth:
|
better-auth:
|
||||||
specifier: ^1.2.0
|
specifier: ^1.2.0
|
||||||
version: 1.4.18(drizzle-kit@0.31.9)(drizzle-orm@0.41.0(@opentelemetry/api@1.9.0)(gel@2.2.0)(kysely@0.28.11)(postgres@3.4.8))(next@16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@3.2.4(@types/node@22.19.11)(jiti@2.6.1)(jsdom@28.0.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
|
version: 1.4.18(drizzle-kit@0.31.9)(drizzle-orm@0.41.0(@opentelemetry/api@1.9.0)(gel@2.2.0)(kysely@0.28.11)(postgres@3.4.8))(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@3.2.4(@types/node@22.19.11)(jiti@2.6.1)(jsdom@28.0.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
|
||||||
bullmq:
|
bullmq:
|
||||||
specifier: ^5.30.0
|
specifier: ^5.30.0
|
||||||
version: 5.68.0
|
version: 5.68.0
|
||||||
@@ -117,6 +117,9 @@ importers:
|
|||||||
ioredis:
|
ioredis:
|
||||||
specifier: ^5.4.0
|
specifier: ^5.4.0
|
||||||
version: 5.9.2
|
version: 5.9.2
|
||||||
|
openai:
|
||||||
|
specifier: ^6.37.0
|
||||||
|
version: 6.37.0(zod@3.25.76)
|
||||||
postgres:
|
postgres:
|
||||||
specifier: ^3.4.0
|
specifier: ^3.4.0
|
||||||
version: 3.4.8
|
version: 3.4.8
|
||||||
@@ -198,7 +201,7 @@ importers:
|
|||||||
version: 1.159.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
version: 1.159.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
better-auth:
|
better-auth:
|
||||||
specifier: ^1.2.0
|
specifier: ^1.2.0
|
||||||
version: 1.4.18(drizzle-kit@0.31.9)(drizzle-orm@0.41.0(@opentelemetry/api@1.9.0)(gel@2.2.0)(kysely@0.28.11)(postgres@3.4.8))(next@16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@3.2.4(@types/node@22.19.11)(jiti@2.6.1)(jsdom@28.0.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
|
version: 1.4.18(drizzle-kit@0.31.9)(drizzle-orm@0.41.0(@opentelemetry/api@1.9.0)(gel@2.2.0)(kysely@0.28.11)(postgres@3.4.8))(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@3.2.4(@types/node@22.19.11)(jiti@2.6.1)(jsdom@28.0.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
|
||||||
canvas-confetti:
|
canvas-confetti:
|
||||||
specifier: ^1.9.4
|
specifier: ^1.9.4
|
||||||
version: 1.9.4
|
version: 1.9.4
|
||||||
@@ -4631,6 +4634,18 @@ packages:
|
|||||||
resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==}
|
resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
|
openai@6.37.0:
|
||||||
|
resolution: {integrity: sha512-0H5dEGFmmLv6KSd0W1w2nyL8WsLkX6yoLeQpU+dZAOuGcany5qkYQMmj35ZrKgb6yiyYqpUzFOpR8mZQkgqeEQ==}
|
||||||
|
hasBin: true
|
||||||
|
peerDependencies:
|
||||||
|
ws: ^8.18.0
|
||||||
|
zod: ^3.25 || ^4.0
|
||||||
|
peerDependenciesMeta:
|
||||||
|
ws:
|
||||||
|
optional: true
|
||||||
|
zod:
|
||||||
|
optional: true
|
||||||
|
|
||||||
ora@5.4.1:
|
ora@5.4.1:
|
||||||
resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==}
|
resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -8862,7 +8877,7 @@ snapshots:
|
|||||||
|
|
||||||
baseline-browser-mapping@2.9.19: {}
|
baseline-browser-mapping@2.9.19: {}
|
||||||
|
|
||||||
better-auth@1.4.18(drizzle-kit@0.31.9)(drizzle-orm@0.41.0(@opentelemetry/api@1.9.0)(gel@2.2.0)(kysely@0.28.11)(postgres@3.4.8))(next@16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@3.2.4(@types/node@22.19.11)(jiti@2.6.1)(jsdom@28.0.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)):
|
better-auth@1.4.18(drizzle-kit@0.31.9)(drizzle-orm@0.41.0(@opentelemetry/api@1.9.0)(gel@2.2.0)(kysely@0.28.11)(postgres@3.4.8))(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@3.2.4(@types/node@22.19.11)(jiti@2.6.1)(jsdom@28.0.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@better-auth/core': 1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@3.25.76))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0)
|
'@better-auth/core': 1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@3.25.76))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0)
|
||||||
'@better-auth/telemetry': 1.4.18(@better-auth/core@1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@3.25.76))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0))
|
'@better-auth/telemetry': 1.4.18(@better-auth/core@1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@3.25.76))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0))
|
||||||
@@ -10161,6 +10176,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
mimic-fn: 2.1.0
|
mimic-fn: 2.1.0
|
||||||
|
|
||||||
|
openai@6.37.0(zod@3.25.76):
|
||||||
|
optionalDependencies:
|
||||||
|
zod: 3.25.76
|
||||||
|
|
||||||
ora@5.4.1:
|
ora@5.4.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
bl: 4.1.0
|
bl: 4.1.0
|
||||||
|
|||||||
Reference in New Issue
Block a user