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",
|
||||
"helmet": "^8.1.0",
|
||||
"ioredis": "^5.4.0",
|
||||
"openai": "^6.37.0",
|
||||
"postgres": "^3.4.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.0",
|
||||
|
||||
@@ -60,15 +60,25 @@ export class CatalogController {
|
||||
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")
|
||||
getCategoryTree(
|
||||
@Param("id") id: string,
|
||||
@Query("body") body: string | undefined,
|
||||
@Query("engine") engine: string | undefined,
|
||||
@Query("gearbox") gearbox: string | undefined,
|
||||
@Query("mgp") mainGroupsPath: string | undefined,
|
||||
@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")
|
||||
|
||||
@@ -240,6 +240,30 @@ export class CatalogService {
|
||||
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).
|
||||
*/
|
||||
@@ -309,11 +333,14 @@ export class CatalogService {
|
||||
body = "_all_",
|
||||
engine = "_all_",
|
||||
gearbox = "_all_",
|
||||
mainGroupsPath?: string,
|
||||
) {
|
||||
const hasVariant = body !== "_all_" || engine !== "_all_" || gearbox !== "_all_";
|
||||
const cacheKey = hasVariant
|
||||
? `cat:catalog:tree:${catalogVehicleId}:b=${body}:e=${engine}:g=${gearbox}`
|
||||
: `cat:catalog:tree:${catalogVehicleId}`;
|
||||
const cacheKey = mainGroupsPath
|
||||
? `cat:catalog:tree:${catalogVehicleId}:mgp=${Buffer.from(mainGroupsPath).toString("base64").slice(0, 40)}`
|
||||
: hasVariant
|
||||
? `cat:catalog:tree:${catalogVehicleId}:b=${body}:e=${engine}:g=${gearbox}`
|
||||
: `cat:catalog:tree:${catalogVehicleId}`;
|
||||
const cached = await this.redis.getJson<any[]>(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
@@ -620,7 +647,10 @@ export class CatalogService {
|
||||
.from(categories)
|
||||
.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 {
|
||||
let pl24Categories;
|
||||
|
||||
@@ -637,7 +667,7 @@ export class CatalogService {
|
||||
} else {
|
||||
pl24Categories = await this.pl24Service.fetchMainGroups(
|
||||
vehicle.serviceName,
|
||||
vehicle.catalogPath,
|
||||
effectiveCatalogPath,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,9 +4,10 @@ import { CategoriesService } from "./categories.service";
|
||||
import { PL24Module } from "../integrations/pl24/pl24.module";
|
||||
import { EmexModule } from "../integrations/emex/emex.module";
|
||||
import { PartsCatalogsModule } from "../integrations/parts-catalogs/parts-catalogs.module";
|
||||
import { TranslationsModule } from "../translations/translations.module";
|
||||
|
||||
@Module({
|
||||
imports: [PL24Module, EmexModule, PartsCatalogsModule],
|
||||
imports: [PL24Module, EmexModule, PartsCatalogsModule, TranslationsModule],
|
||||
controllers: [CategoriesController],
|
||||
providers: [CategoriesService],
|
||||
exports: [CategoriesService],
|
||||
|
||||
@@ -25,8 +25,25 @@ function createService(db: any) {
|
||||
const pl24FordLegacyService = {
|
||||
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);
|
||||
return { service, db, redis, pl24Service };
|
||||
const translationsService = {
|
||||
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 */
|
||||
|
||||
@@ -9,6 +9,7 @@ import { EmexService } from "../integrations/emex/emex.service";
|
||||
import { PartsCatalogsService } from "../integrations/parts-catalogs/parts-catalogs.service";
|
||||
import type { PcatGroup } from "../integrations/parts-catalogs/parts-catalogs.types";
|
||||
import { StorageService } from "../storage/storage.service";
|
||||
import { TranslationsService } from "../translations/translations.service";
|
||||
|
||||
@Injectable()
|
||||
export class CategoriesService {
|
||||
@@ -22,6 +23,7 @@ export class CategoriesService {
|
||||
private partsCatalogsService: PartsCatalogsService,
|
||||
private storage: StorageService,
|
||||
private pl24FordLegacyService: PL24FordLegacyService,
|
||||
private translationsService: TranslationsService,
|
||||
) {}
|
||||
|
||||
async getCategoryTree(vehicleId: string) {
|
||||
@@ -176,10 +178,13 @@ export class CategoriesService {
|
||||
);
|
||||
|
||||
if (groups.length > 0) {
|
||||
const trMap = await this.translationsService.translateMany(
|
||||
groups.map((g) => g.name).filter(Boolean),
|
||||
);
|
||||
const insertData = groups.map((g) => ({
|
||||
vehicleId,
|
||||
catalogVehicleId: null as string | null,
|
||||
name: g.name,
|
||||
name: trMap.get(g.name) ?? g.name,
|
||||
nameOriginal: g.name,
|
||||
parentId: null as string | null,
|
||||
externalId: g.id,
|
||||
@@ -221,6 +226,20 @@ export class CategoriesService {
|
||||
// Recursive tree insertion from QuickGroups.aspx
|
||||
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 (
|
||||
nodes: Array<{ name: string; gid: string | null; url: string | null; children?: any[] }>,
|
||||
parentId: string | null,
|
||||
@@ -236,7 +255,7 @@ export class CategoriesService {
|
||||
.values({
|
||||
vehicleId,
|
||||
catalogVehicleId: null as string | null,
|
||||
name: node.name,
|
||||
name: trMap.get(node.name) ?? node.name,
|
||||
nameOriginal: node.name,
|
||||
parentId,
|
||||
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 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 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;
|
||||
seenNames.add(name);
|
||||
return true;
|
||||
@@ -274,7 +299,7 @@ export class CategoriesService {
|
||||
const insertData = uniqueCategories.map((c) => ({
|
||||
vehicleId,
|
||||
catalogVehicleId: null as string | null,
|
||||
name: c.nameTr || c.nameEn,
|
||||
name: trMap.get(c.nameEn) ?? c.nameEn,
|
||||
nameOriginal: c.nameEn,
|
||||
parentId: null as string | null,
|
||||
externalId: c.code,
|
||||
@@ -380,10 +405,13 @@ export class CategoriesService {
|
||||
return [];
|
||||
}
|
||||
|
||||
const trMap = await this.translationsService.translateMany(
|
||||
realSubGroups.map((g) => g.name).filter(Boolean),
|
||||
);
|
||||
const insertData = realSubGroups.map((g) => ({
|
||||
vehicleId: category.vehicleId,
|
||||
catalogVehicleId: category.catalogVehicleId,
|
||||
name: g.name,
|
||||
name: trMap.get(g.name) ?? g.name,
|
||||
nameOriginal: g.name,
|
||||
parentId: categoryId,
|
||||
externalId: g.id,
|
||||
@@ -588,31 +616,40 @@ export class CategoriesService {
|
||||
if (partsResult) {
|
||||
// Flatten part groups into parts
|
||||
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 p of pg.parts) {
|
||||
if (!p.number) continue;
|
||||
const posNum = p.positionNumber || pg.positionNumber || null;
|
||||
allParts.push({
|
||||
vehicleId: vehicle.id,
|
||||
categoryId,
|
||||
oemCode: p.number,
|
||||
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,
|
||||
rawParts.push({
|
||||
name: p.name || "",
|
||||
number: p.number,
|
||||
notice: p.notice || null,
|
||||
positionNumber: p.positionNumber || pg.positionNumber || null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
dbParts = await this.db.insert(parts).values(allParts).returning();
|
||||
this.logger.log(`Stored ${dbParts.length} PartsCatalogs parts for category ${categoryId}`);
|
||||
@@ -702,18 +739,27 @@ export class CategoriesService {
|
||||
}
|
||||
|
||||
if (needParts && emexResult.parts.length > 0) {
|
||||
const insertData = emexResult.parts.map((p) => ({
|
||||
vehicleId: vehicle.id,
|
||||
categoryId,
|
||||
oemCode: p.oemCode || "N/A",
|
||||
name: p.nameEn || "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,
|
||||
}));
|
||||
// Bulk translate part names (cache + DB + dictionary fallback).
|
||||
// After bootstrap, ~95%+ should be Redis hits.
|
||||
const trMap = await this.translationsService.translateMany(
|
||||
emexResult.parts.map((p) => p.nameEn || "").filter(Boolean),
|
||||
);
|
||||
|
||||
const insertData = emexResult.parts.map((p) => {
|
||||
const original = p.nameEn || "";
|
||||
return {
|
||||
vehicleId: vehicle.id,
|
||||
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();
|
||||
this.logger.log(`Stored ${dbParts.length} EMEX parts for category ${categoryId}`);
|
||||
|
||||
@@ -1,24 +1,25 @@
|
||||
/**
|
||||
* EMEX Response Mapper
|
||||
*
|
||||
* Transforms raw EmexVinScraper responses into standardized DecodedVehicle format.
|
||||
* Includes Turkish translation support for common automotive terms.
|
||||
* Transforms raw EmexVinScraper responses into the standardized DecodedVehicle
|
||||
* 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 {
|
||||
EmexScraperResponse,
|
||||
EmexCategory,
|
||||
EmexCategoryTreeNode,
|
||||
DecodedVehicle,
|
||||
DecodedCategory,
|
||||
CATALOG_MAP,
|
||||
} from './emex.types';
|
||||
|
||||
// ==================== TURKISH TRANSLATIONS ====================
|
||||
// ==================== VEHICLE ATTRIBUTE TRANSLATIONS ====================
|
||||
|
||||
/**
|
||||
* Turkish translations for common automotive terms
|
||||
*/
|
||||
const TR_TRANSLATIONS = {
|
||||
// Body types
|
||||
bodyTypes: {
|
||||
@@ -39,7 +40,6 @@ const TR_TRANSLATIONS = {
|
||||
roadster: 'Roadster',
|
||||
} as Record<string, string>,
|
||||
|
||||
// Engine types
|
||||
engineTypes: {
|
||||
gasoline: 'Benzin',
|
||||
petrol: 'Benzin',
|
||||
@@ -54,7 +54,6 @@ const TR_TRANSLATIONS = {
|
||||
hydrogen: 'Hidrojen',
|
||||
} as Record<string, string>,
|
||||
|
||||
// Transmission types
|
||||
transmissions: {
|
||||
automatic: 'Otomatik',
|
||||
manual: 'Manuel',
|
||||
@@ -69,7 +68,6 @@ const TR_TRANSLATIONS = {
|
||||
mt: 'Manuel',
|
||||
} as Record<string, string>,
|
||||
|
||||
// Drive types
|
||||
driveTypes: {
|
||||
fwd: 'Ondan Cekis',
|
||||
rwd: 'Arkadan Itis',
|
||||
@@ -83,211 +81,10 @@ const TR_TRANSLATIONS = {
|
||||
xdrive: 'xDrive (Dort Ceker)',
|
||||
'4matic': '4MATIC (Dort Ceker)',
|
||||
} 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 ====================
|
||||
|
||||
/**
|
||||
* Translates a term to Turkish if available
|
||||
*/
|
||||
function translateToTurkish(
|
||||
term: string | null | undefined,
|
||||
dictionary: Record<string, string>,
|
||||
@@ -297,52 +94,24 @@ function translateToTurkish(
|
||||
return dictionary[normalized] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Translates body type to Turkish
|
||||
*/
|
||||
export function translateBodyType(bodyType: string | null): string | null {
|
||||
return translateToTurkish(bodyType, TR_TRANSLATIONS.bodyTypes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Translates engine type to Turkish
|
||||
*/
|
||||
export function translateEngineType(engineType: string | null): string | null {
|
||||
return translateToTurkish(engineType, TR_TRANSLATIONS.engineTypes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Translates transmission type to Turkish
|
||||
*/
|
||||
export function translateTransmission(
|
||||
transmission: string | null,
|
||||
): string | null {
|
||||
return translateToTurkish(transmission, TR_TRANSLATIONS.transmissions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Translates drive type to Turkish
|
||||
*/
|
||||
export function translateDriveType(driveType: string | null): string | null {
|
||||
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 ====================
|
||||
|
||||
/**
|
||||
@@ -444,8 +213,11 @@ function buildRawResponse(
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps EMEX categories to standardized DecodedCategory format
|
||||
* NOTE: Parts are NOT included here - they will be fetched on-demand when user clicks a category
|
||||
* Maps EMEX categories to standardized DecodedCategory format.
|
||||
* 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(
|
||||
categories?: EmexCategory[],
|
||||
@@ -458,7 +230,7 @@ function mapCategories(
|
||||
return {
|
||||
code: cat.gid || `CAT_${index}`,
|
||||
nameEn: cat.name,
|
||||
nameTr: translateCategoryName(cat.name),
|
||||
nameTr: undefined,
|
||||
description: null,
|
||||
iconName: deriveIconName(cat.name),
|
||||
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
|
||||
* the Authorization header from requests to parts-catalogs.com.
|
||||
* JWT is IP-bound (~10 min TTL), so the same proxy port must be used for both
|
||||
* browser capture and subsequent API calls.
|
||||
* Tokens are captured by navigating to partner sites that embed the v3 widget.
|
||||
* The widget calls /v3/api/proxy/* with `x-api-key: TWS-{UUID}` and four other
|
||||
* X-* headers (api-path, gui-version, user-id, origin, referer); we intercept
|
||||
* 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:
|
||||
* 09:00-19:00 Istanbul → proactive: maintain >= 1 slot, auto-refresh before expiry
|
||||
* 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 {
|
||||
@@ -24,6 +23,7 @@ import { ConfigService } from "@nestjs/config";
|
||||
import type { Browser, BrowserContext } from "playwright";
|
||||
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 CAPTURE_POLL_INTERVAL = 500; // ms
|
||||
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 MAX_POOL_SIZE = 5;
|
||||
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.
|
||||
* Widget loads JS → calls /api/start → then calls /v1/catalogs/ with JWT.
|
||||
* Sites embedding the parts-catalogs.com v3 widget.
|
||||
* 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.
|
||||
*/
|
||||
const JWT_SITES = [
|
||||
@@ -50,7 +50,6 @@ const JWT_SITES = [
|
||||
"https://www.autodo.kz/#/catalogs",
|
||||
"https://avtoman124.ru/goodvin#/catalogs",
|
||||
"https://flynestauto.com/auto-parts-oem-catalog",
|
||||
"http://en.demo.tradesoft.hk.com/cats/#/catalogs",
|
||||
];
|
||||
|
||||
// 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_MAX = 10999;
|
||||
const DI_DEFAULT_USER = "1726bbe361918676d44e";
|
||||
const DI_DEFAULT_PASS = "f11c7b6128cc86c6";
|
||||
const DI_DEFAULT_PASS = "78ebc3d881de6ec0";
|
||||
|
||||
/** Simple counting semaphore (same pattern as EmexBrowserService) */
|
||||
class Semaphore {
|
||||
@@ -234,7 +233,12 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
|
||||
}
|
||||
: null;
|
||||
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,
|
||||
proxyConfig,
|
||||
_slot: slot,
|
||||
@@ -513,22 +517,28 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
|
||||
context = await this.browser!.newContext(contextOptions);
|
||||
const page = await context.newPage();
|
||||
|
||||
// Intercept requests to parts-catalogs.com
|
||||
let capturedJwt: string | null = null;
|
||||
// Intercept the v3 widget call to /v3/api/proxy/* — needs the full
|
||||
// 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) => {
|
||||
if (capturedJwt) return;
|
||||
if (capturedToken) return;
|
||||
const url = request.url();
|
||||
if (
|
||||
url.includes("parts-catalogs.com") ||
|
||||
url.includes("api.parts-catalogs.com")
|
||||
) {
|
||||
const auth = request.headers()["authorization"];
|
||||
if (auth) {
|
||||
capturedJwt = auth;
|
||||
this.logger.debug("JWT intercepted from request");
|
||||
}
|
||||
}
|
||||
if (!/\/v3\/api\/proxy\//i.test(url)) return;
|
||||
const h = request.headers();
|
||||
const apiKey = h["x-api-key"];
|
||||
if (!apiKey || !apiKey.startsWith("TWS-")) return;
|
||||
capturedToken = {
|
||||
raw: apiKey,
|
||||
exp: Math.floor(Date.now() / 1000) + TOKEN_TTL,
|
||||
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
|
||||
@@ -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++) {
|
||||
if (capturedJwt) break;
|
||||
if (capturedToken) break;
|
||||
await new Promise((r) => setTimeout(r, CAPTURE_POLL_INTERVAL));
|
||||
}
|
||||
|
||||
const elapsed = Date.now() - startTime;
|
||||
|
||||
if (capturedJwt) {
|
||||
const token = this.parseJwt(capturedJwt);
|
||||
if (capturedToken) {
|
||||
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;
|
||||
} catch (err) {
|
||||
this.logger.warn(`JWT capture error: ${(err as Error).message}`);
|
||||
this.logger.warn(`Token capture error: ${(err as Error).message}`);
|
||||
return null;
|
||||
} finally {
|
||||
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 ───────────────────────────────────
|
||||
|
||||
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
|
||||
* to ensure the JWT's IP-bound constraint is satisfied.
|
||||
* Calls the v3 widget proxy (gui.parts-catalogs.com/v3/api/proxy/*) with the
|
||||
* 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";
|
||||
@@ -16,7 +17,7 @@ import type {
|
||||
PcatSession,
|
||||
} 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;
|
||||
|
||||
@Injectable()
|
||||
@@ -192,10 +193,15 @@ export class PartsCatalogsService {
|
||||
const fetchOptions: RequestInit & { dispatcher?: any } = {
|
||||
method: "GET",
|
||||
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",
|
||||
"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),
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
raw: string;
|
||||
exp: number;
|
||||
host: string;
|
||||
apiKey: string;
|
||||
apiPath: string;
|
||||
ip: string;
|
||||
hash: string;
|
||||
raw: string; // x-api-key value, e.g. "TWS-016EA7BE-..."
|
||||
exp: number; // unix epoch seconds (capturedAt + TTL_FALLBACK)
|
||||
apiPath: string; // x-api-path (upstream PCAT API base URL)
|
||||
guiVersion: string; // x-gui-version (e.g. "3")
|
||||
userId: string; // x-user-id (per-session UUID minted by widget)
|
||||
origin: string; // partner-site origin
|
||||
referer: string; // partner-site referer
|
||||
}
|
||||
|
||||
export interface JwtSlot {
|
||||
@@ -17,7 +22,12 @@ export interface JwtSlot {
|
||||
}
|
||||
|
||||
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;
|
||||
proxyConfig: { server: string; username: string; password: string } | null;
|
||||
_slot: JwtSlot;
|
||||
|
||||
@@ -571,6 +571,73 @@ export class PL24Service {
|
||||
/**
|
||||
* 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(
|
||||
serviceName: string,
|
||||
mainGroupsPath: string,
|
||||
|
||||
@@ -284,7 +284,7 @@ export class SubscriptionsService {
|
||||
|
||||
const now = new Date();
|
||||
const endDate = new Date(now);
|
||||
endDate.setDate(endDate.getDate() + 7);
|
||||
endDate.setDate(endDate.getDate() + 30);
|
||||
|
||||
// Create trial subscription
|
||||
const [subscription] = await this.db
|
||||
|
||||
@@ -118,10 +118,10 @@ describe("TranslationsService", () => {
|
||||
it("should return original text when no dictionary match found", async () => {
|
||||
const result = await service.translate(
|
||||
"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.isAutoTranslated).toBe(false);
|
||||
});
|
||||
|
||||
@@ -1,105 +1,247 @@
|
||||
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 { emexCategoryTranslations } from "../database/schema/core";
|
||||
import { RedisService } from "../redis/redis.service";
|
||||
|
||||
/** 30 days in seconds */
|
||||
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:";
|
||||
|
||||
/** 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> = {
|
||||
"engine": "Motor",
|
||||
"brake": "Fren",
|
||||
"steering": "Direksiyon",
|
||||
"suspension": "Süspansiyon",
|
||||
"exhaust": "Egzoz",
|
||||
"transmission": "Şanzıman",
|
||||
"radiator": "Radyatör",
|
||||
"battery": "Akü",
|
||||
"filter": "Filtre",
|
||||
"clutch": "Debriyaj",
|
||||
"shock absorber": "Amortisör",
|
||||
"alternator": "Alternatör",
|
||||
"starter": "Marş Motoru",
|
||||
// Engine + powertrain
|
||||
engine: "Motor",
|
||||
motor: "Motor",
|
||||
piston: "Piston",
|
||||
cylinder: "Silindir",
|
||||
crankshaft: "Krank Mili",
|
||||
camshaft: "Eksantrik Mili",
|
||||
valve: "Supap",
|
||||
turbocharger: "Turbo",
|
||||
turbo: "Turbo",
|
||||
intercooler: "Intercooler",
|
||||
manifold: "Manifold",
|
||||
"intake manifold": "Emme Manifoldu",
|
||||
"exhaust manifold": "Egzoz Manifoldu",
|
||||
flywheel: "Volan",
|
||||
"spark plug": "Buji",
|
||||
"fuel pump": "Yakıt Pompası",
|
||||
"water pump": "Su Pompası",
|
||||
"oil pump": "Yağ Pompası",
|
||||
"timing belt": "Triger Kayışı",
|
||||
"fan belt": "Vantilatör Kayışı",
|
||||
"gasket": "Conta",
|
||||
"piston": "Piston",
|
||||
"cylinder": "Silindir",
|
||||
"crankshaft": "Krank Mili",
|
||||
"camshaft": "Eksantrik Mili",
|
||||
"valve": "Supap",
|
||||
"turbocharger": "Turbo",
|
||||
"intercooler": "Intercooler",
|
||||
"catalytic converter": "Katalitik Konvertör",
|
||||
"muffler": "Susturucu",
|
||||
"bumper": "Tampon",
|
||||
"fender": "Çamurluk",
|
||||
"hood": "Kaput",
|
||||
"trunk": "Bagaj",
|
||||
"windshield": "Ön Cam",
|
||||
"mirror": "Ayna",
|
||||
"headlight": "Far",
|
||||
"tail light": "Stop Lambası",
|
||||
"wiper": "Silecek",
|
||||
"door": "Kapı",
|
||||
"wheel": "Jant",
|
||||
"tire": "Lastik",
|
||||
"axle": "Aks",
|
||||
"bearing": "Rulman",
|
||||
"caliper": "Kaliper",
|
||||
"ignition coil": "Ateşleme Bobini",
|
||||
injector: "Enjektör",
|
||||
"fuel injector": "Yakıt Enjektörü",
|
||||
distributor: "Distribütör",
|
||||
"voltage regulator": "Voltaj Regülatörü",
|
||||
"throttle body": "Gaz Kelebeği",
|
||||
|
||||
// Transmission
|
||||
transmission: "Şanzıman",
|
||||
gearbox: "Vites Kutusu",
|
||||
clutch: "Debriyaj",
|
||||
"clutch kit": "Debriyaj Seti",
|
||||
"gear lever": "Vites Kolu",
|
||||
differential: "Diferansiyel",
|
||||
"drive shaft": "Şaft",
|
||||
driveshaft: "Şaft",
|
||||
"axle shaft": "Aks Mili",
|
||||
axle: "Aks",
|
||||
"cv joint": "Aks Kafası",
|
||||
"cv boot": "Aks Körüğü",
|
||||
|
||||
// Brake
|
||||
brake: "Fren",
|
||||
brakes: "Fren Sistemi",
|
||||
"brake pad": "Fren Balatası",
|
||||
"brake disc": "Fren Diski",
|
||||
"air filter": "Hava Filtresi",
|
||||
"oil filter": "Yağ Filtresi",
|
||||
"fuel filter": "Yakıt Filtresi",
|
||||
"cabin filter": "Polen Filtresi",
|
||||
"thermostat": "Termostat",
|
||||
"sensor": "Sensör",
|
||||
"relay": "Röle",
|
||||
"fuse": "Sigorta",
|
||||
"compressor": "Kompresör",
|
||||
"condenser": "Kondenser",
|
||||
"evaporator": "Evaporatör",
|
||||
"hose": "Hortum",
|
||||
"belt": "Kayış",
|
||||
"spring": "Yay",
|
||||
"strut": "Amortisör Bacağı",
|
||||
"brake rotor": "Fren Diski",
|
||||
"brake hose": "Fren Hortumu",
|
||||
"brake line": "Fren Borusu",
|
||||
"brake fluid": "Fren Hidroliği",
|
||||
caliper: "Fren Kaliperi",
|
||||
"master cylinder": "Ana Merkez",
|
||||
"slave cylinder": "Yardımcı Merkez",
|
||||
handbrake: "El Freni",
|
||||
|
||||
// Suspension + steering
|
||||
suspension: "Süspansiyon",
|
||||
steering: "Direksiyon",
|
||||
"steering wheel": "Direksiyon Simidi",
|
||||
"steering rack": "Kremayer",
|
||||
"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",
|
||||
"tie rod": "Rot Başı",
|
||||
"ball joint": "Rotil",
|
||||
"cv joint": "Aks Kafası",
|
||||
"drive shaft": "Şaft",
|
||||
"differential": "Diferansiyel",
|
||||
"gearbox": "Vites Kutusu",
|
||||
"flywheel": "Volan",
|
||||
"injector": "Enjektör",
|
||||
"throttle body": "Gaz Kelebeği",
|
||||
"manifold": "Manifold",
|
||||
|
||||
// Wheels + tires
|
||||
wheel: "Tekerlek",
|
||||
wheels: "Jantlar",
|
||||
"wheel hub": "Poyra",
|
||||
hub: "Poyra",
|
||||
"wheel bearing": "Tekerlek Rulmanı",
|
||||
"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ü",
|
||||
"abs sensor": "ABS Sensörü",
|
||||
"ignition coil": "Ateşleme Bobini",
|
||||
"distributor": "Distribütör",
|
||||
"voltage regulator": "Voltaj Regülatörü",
|
||||
"window regulator": "Cam Krikosu",
|
||||
"crankshaft sensor": "Krank Sensörü",
|
||||
"camshaft sensor": "Eksantrik Sensörü",
|
||||
"coolant sensor": "Su Isısı Sensörü",
|
||||
"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",
|
||||
"seat": "Koltuk",
|
||||
"dashboard": "Gösterge Paneli",
|
||||
"steering wheel": "Direksiyon Simidi",
|
||||
"gear lever": "Vites Kolu",
|
||||
"handbrake": "El Freni",
|
||||
"pedal": "Pedal",
|
||||
"radiator hose": "Radyatör Hortumu",
|
||||
"coolant": "Antifriz",
|
||||
"brake fluid": "Fren Hidroliği",
|
||||
"engine oil": "Motor Yağı",
|
||||
"power steering": "Hidrolik Direksiyon",
|
||||
"door hinge": "Kapı Menteşesi",
|
||||
window: "Cam",
|
||||
windshield: "Ön Cam",
|
||||
windscreen: "Ön Cam",
|
||||
"window regulator": "Cam Krikosu",
|
||||
"window motor": "Cam Motoru",
|
||||
mirror: "Ayna",
|
||||
"side mirror": "Yan Ayna",
|
||||
"rear view mirror": "İç Ayna",
|
||||
|
||||
// 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 {
|
||||
@@ -149,32 +291,9 @@ export class TranslationsService {
|
||||
return result;
|
||||
}
|
||||
|
||||
// 3. Try dictionary-based translation
|
||||
const dictTranslation = this.translateWithDictionary(sourceText);
|
||||
if (dictTranslation !== null) {
|
||||
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
|
||||
// 3. No DB hit — return original. Dictionary fallback removed: its
|
||||
// word-by-word replacement produces half-translated strings and
|
||||
// would poison the DB. Bootstrap script handles new terms via LLM.
|
||||
const result: TranslationResult = {
|
||||
key,
|
||||
sourceText,
|
||||
@@ -182,21 +301,122 @@ export class TranslationsService {
|
||||
source: "none",
|
||||
isAutoTranslated: false,
|
||||
};
|
||||
// Cache "miss" with shorter TTL (1 day) so it gets re-checked sooner
|
||||
await this.redis.setJson(cacheKey, result, 24 * 60 * 60);
|
||||
await this.redis.setJson(cacheKey, result, CACHE_MISS_TTL);
|
||||
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(
|
||||
items: { key: string; sourceText: string }[],
|
||||
): Promise<TranslationResult[]> {
|
||||
const results = await Promise.all(
|
||||
items.map((item) => this.translate(item.key, item.sourceText)),
|
||||
);
|
||||
return results;
|
||||
if (!items.length) return [];
|
||||
const trMap = await this.translateMany(items.map((i) => i.sourceText));
|
||||
return items.map((i) => {
|
||||
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();
|
||||
|
||||
// Invalidate cache
|
||||
const cacheKey = `${CACHE_PREFIX}${key}`;
|
||||
await this.redis.del(cacheKey);
|
||||
// Invalidate cache (key + sourceText so both lookup paths see the change)
|
||||
await this.redis.del(`${CACHE_PREFIX}${key}`);
|
||||
if (key !== sourceText) {
|
||||
await this.redis.del(`${CACHE_PREFIX}${sourceText}`);
|
||||
}
|
||||
|
||||
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.gstatic.com" crossorigin />
|
||||
<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"
|
||||
/>
|
||||
</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 *));
|
||||
|
||||
@theme {
|
||||
--color-background: #ffffff;
|
||||
--color-foreground: #0a0a0a;
|
||||
--color-muted: #f5f5f5;
|
||||
--color-muted-foreground: #737373;
|
||||
--color-border: #e5e5e5;
|
||||
--color-input: #e5e5e5;
|
||||
--color-ring: #0a0a0a;
|
||||
--color-primary: #0a0a0a;
|
||||
--color-primary-foreground: #fafafa;
|
||||
--color-secondary: #f5f5f5;
|
||||
--color-secondary-foreground: #171717;
|
||||
--color-accent: #f5f5f5;
|
||||
--color-accent-foreground: #171717;
|
||||
--color-destructive: #ef4444;
|
||||
--color-destructive-foreground: #fafafa;
|
||||
--color-surface: #f5f5f5;
|
||||
--color-surface-foreground: #171717;
|
||||
--color-surface-alt: #eaeaea;
|
||||
--color-card: #ffffff;
|
||||
--color-card-foreground: #0a0a0a;
|
||||
--color-popover: #ffffff;
|
||||
--color-popover-foreground: #0a0a0a;
|
||||
/* Light theme — warm-tinted neutrals (not pure white/black) */
|
||||
--color-background: oklch(99.2% 0.003 80);
|
||||
--color-foreground: oklch(15% 0.01 250);
|
||||
--color-muted: oklch(96.5% 0.004 80);
|
||||
--color-muted-foreground: oklch(50% 0.012 250);
|
||||
--color-border: oklch(91% 0.005 250);
|
||||
--color-input: oklch(91% 0.005 250);
|
||||
--color-ring: oklch(15% 0.01 250);
|
||||
--color-primary: oklch(15% 0.01 250);
|
||||
--color-primary-foreground: oklch(99.2% 0.003 80);
|
||||
--color-secondary: oklch(96.5% 0.004 80);
|
||||
--color-secondary-foreground: oklch(20% 0.01 250);
|
||||
--color-accent: oklch(96.5% 0.004 80);
|
||||
--color-accent-foreground: oklch(20% 0.01 250);
|
||||
--color-destructive: oklch(58% 0.18 28);
|
||||
--color-destructive-foreground: oklch(99.2% 0.003 80);
|
||||
--color-surface: oklch(96.5% 0.004 80);
|
||||
--color-surface-foreground: oklch(20% 0.01 250);
|
||||
--color-surface-alt: oklch(94% 0.005 80);
|
||||
--color-card: oklch(99.2% 0.003 80);
|
||||
--color-card-foreground: oklch(15% 0.01 250);
|
||||
--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-md: 0.375rem;
|
||||
--radius-lg: 0.5rem;
|
||||
--radius-xl: 0.75rem;
|
||||
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
|
||||
--font-display: "Space Grotesk", ui-sans-serif, system-ui, sans-serif;
|
||||
--radius-md: 0.5rem;
|
||||
--radius-lg: 0.75rem;
|
||||
--radius-xl: 1rem;
|
||||
--radius-2xl: 1.25rem;
|
||||
|
||||
--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 {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
body {
|
||||
@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:hover,
|
||||
input:-webkit-autofill:focus,
|
||||
@@ -62,26 +106,33 @@
|
||||
0% { transform: translateX(0); }
|
||||
100% { transform: translateX(-50%); }
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
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-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; }
|
||||
.scrollbar-none::-webkit-scrollbar { display: 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 */
|
||||
: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-warning: oklch(0.58 0.2 70);
|
||||
--sileo-state-info: oklch(0.50 0.2 237);
|
||||
@@ -89,32 +140,43 @@
|
||||
}
|
||||
|
||||
[data-sileo-description] {
|
||||
color: #333;
|
||||
color: oklch(25% 0.01 250);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--color-background: #0a0a0a;
|
||||
--color-foreground: #fafafa;
|
||||
--color-muted: #262626;
|
||||
--color-muted-foreground: #a3a3a3;
|
||||
--color-border: #262626;
|
||||
--color-input: #262626;
|
||||
--color-ring: #d4d4d4;
|
||||
--color-primary: #fafafa;
|
||||
--color-primary-foreground: #171717;
|
||||
--color-secondary: #262626;
|
||||
--color-secondary-foreground: #fafafa;
|
||||
--color-accent: #262626;
|
||||
--color-accent-foreground: #fafafa;
|
||||
--color-destructive: #dc2626;
|
||||
--color-destructive-foreground: #fafafa;
|
||||
--color-surface: #1a1a1a;
|
||||
--color-surface-foreground: #fafafa;
|
||||
--color-surface-alt: #0f0f0f;
|
||||
--color-card: #0a0a0a;
|
||||
--color-card-foreground: #fafafa;
|
||||
--color-popover: #0a0a0a;
|
||||
--color-popover-foreground: #fafafa;
|
||||
/* Dark theme — off-black with cool tint, never pure black */
|
||||
--color-background: oklch(13% 0.008 250);
|
||||
--color-foreground: oklch(97% 0.004 80);
|
||||
--color-muted: oklch(20% 0.008 250);
|
||||
--color-muted-foreground: oklch(65% 0.012 250);
|
||||
--color-border: oklch(22% 0.008 250);
|
||||
--color-input: oklch(22% 0.008 250);
|
||||
--color-ring: oklch(80% 0.005 250);
|
||||
--color-primary: oklch(97% 0.004 80);
|
||||
--color-primary-foreground: oklch(15% 0.01 250);
|
||||
--color-secondary: oklch(20% 0.008 250);
|
||||
--color-secondary-foreground: oklch(97% 0.004 80);
|
||||
--color-accent: oklch(20% 0.008 250);
|
||||
--color-accent-foreground: oklch(97% 0.004 80);
|
||||
--color-destructive: oklch(54% 0.20 28);
|
||||
--color-destructive-foreground: oklch(97% 0.004 80);
|
||||
--color-surface: oklch(17% 0.008 250);
|
||||
--color-surface-foreground: oklch(97% 0.004 80);
|
||||
--color-surface-alt: oklch(15% 0.008 250);
|
||||
--color-card: oklch(13% 0.008 250);
|
||||
--color-card-foreground: oklch(97% 0.004 80);
|
||||
--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-state-loading: oklch(0.7 0 0);
|
||||
@@ -122,13 +184,13 @@
|
||||
|
||||
.dark [data-sileo-pill],
|
||||
.dark [data-sileo-body] {
|
||||
fill: #1c1c1e !important;
|
||||
fill: oklch(17% 0.008 250) !important;
|
||||
}
|
||||
|
||||
.dark [data-sileo-description] {
|
||||
color: #d4d4d4;
|
||||
color: oklch(85% 0.005 80);
|
||||
}
|
||||
|
||||
.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;
|
||||
load().then((ph) => {
|
||||
ph.init(key, {
|
||||
api_host: "https://eu.i.posthog.com",
|
||||
api_host: "https://t.sase.tr",
|
||||
defaults: "2026-01-30",
|
||||
person_profiles: "identified_only",
|
||||
capture_pageview: false,
|
||||
capture_pageleave: false,
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
const STORAGE_KEY = "userSettings";
|
||||
|
||||
interface UserSettings {
|
||||
categoryViewMode?: "grid" | "tree";
|
||||
categoryViewMode?: "grid" | "tree" | "columns";
|
||||
modelViewMode?: "grid" | "tree" | "columns";
|
||||
brandViewMode?: "grid" | "tree" | "columns";
|
||||
sidebarCollapsed?: boolean;
|
||||
theme?: "light" | "dark" | "system";
|
||||
}
|
||||
|
||||
@@ -164,10 +164,10 @@
|
||||
"expired": "Expired"
|
||||
},
|
||||
"popular": "Popular",
|
||||
"trialTitle": "7-Day Full Package Trial",
|
||||
"trialDescription": "Free access to all brands for 7 days. No credit card required.",
|
||||
"trialTitle": "30-Day Full Package Trial",
|
||||
"trialDescription": "Free access to all brands for 30 days. No credit card required.",
|
||||
"startTrial": "Start Free Trial",
|
||||
"trialStarted": "Your 7-day Full Package trial has started!",
|
||||
"trialStarted": "Your 30-day Full Package trial has started!",
|
||||
"onboarding": {
|
||||
"provisioning": "Setting up your free trial",
|
||||
"step1": "Verifying account",
|
||||
@@ -175,7 +175,7 @@
|
||||
"step3": "Activating Full Package",
|
||||
"step4": "Completed!",
|
||||
"completed": "You can test all catalogs without limits!",
|
||||
"trialDuration": "7-Day Trial",
|
||||
"trialDuration": "30-Day Trial",
|
||||
"startSearching": "Start Searching",
|
||||
"error": "An error occurred while starting your trial.",
|
||||
"retry": "Try Again"
|
||||
|
||||
@@ -164,10 +164,10 @@
|
||||
"expired": "Süresi Doldu"
|
||||
},
|
||||
"popular": "Popüler",
|
||||
"trialTitle": "7 Gün Full Paket Denemesi",
|
||||
"trialDescription": "Tüm markalara 7 gün boyunca ücretsiz erişim. Kredi kartı gerekmez.",
|
||||
"trialTitle": "30 Gün Full Paket Denemesi",
|
||||
"trialDescription": "Tüm markalara 30 gün boyunca ücretsiz erişim. Kredi kartı gerekmez.",
|
||||
"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": {
|
||||
"provisioning": "Ücretsiz kullanım hakkınız tanımlanıyor",
|
||||
"step1": "Hesap doğrulanıyor",
|
||||
@@ -175,7 +175,7 @@
|
||||
"step3": "Full Paket aktif ediliyor",
|
||||
"step4": "Tamamlandı!",
|
||||
"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",
|
||||
"error": "Deneme başlatılırken bir hata oluştu.",
|
||||
"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 type { QueryClient } from "@tanstack/react-query";
|
||||
import { useEffect } from "react";
|
||||
import { getUserSettings } from "@/lib/user-settings";
|
||||
import { capturePageView, identifyUser, resetUser } from "@/lib/posthog";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { Button } from "@sase/ui";
|
||||
import { ArrowLeft, Home, Search } from "lucide-react";
|
||||
|
||||
interface RouterContext {
|
||||
queryClient: QueryClient;
|
||||
@@ -12,8 +14,71 @@ interface RouterContext {
|
||||
|
||||
export const Route = createRootRouteWithContext<RouterContext>()({
|
||||
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") {
|
||||
const isDark =
|
||||
theme === "dark" ||
|
||||
@@ -59,6 +124,9 @@ function RootComponent() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<a href="#main-content" className="skip-link">
|
||||
İçeriğe atla
|
||||
</a>
|
||||
<Outlet />
|
||||
<Toaster position="top-center" />
|
||||
</>
|
||||
|
||||
@@ -9,7 +9,7 @@ function AuthLayout() {
|
||||
return (
|
||||
<div className="flex min-h-screen">
|
||||
{/* 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="w-full max-w-md">
|
||||
<Outlet />
|
||||
@@ -23,17 +23,38 @@ function AuthLayout() {
|
||||
Sase.tr
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Right Panel — Promo (always dark, 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="flex flex-1 flex-col justify-center space-y-8">
|
||||
{/* Right Panel — Promo (always dark via .dark scope, hidden on mobile) */}
|
||||
<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">
|
||||
{/* 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 */}
|
||||
<div className="space-y-3">
|
||||
<h2 className="text-3xl font-bold tracking-tight">
|
||||
Doğru Parçayı İlk Seferde Bulun
|
||||
<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">
|
||||
<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>
|
||||
<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
|
||||
kodları. Şase numarasını girin, doğru parçayı saniyeler içinde
|
||||
bulun.
|
||||
@@ -42,11 +63,11 @@ function AuthLayout() {
|
||||
|
||||
{/* Stats */}
|
||||
<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" />
|
||||
1.2sn Sorgu
|
||||
</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
|
||||
className="size-3"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -62,36 +83,36 @@ function AuthLayout() {
|
||||
</svg>
|
||||
27 Marka
|
||||
</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" />
|
||||
243K+ OEM Parça
|
||||
<span className="tabular">243K+ OEM Parça</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" />
|
||||
%99.9 Uptime
|
||||
<span className="tabular">%99.9 Uptime</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 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 ·
|
||||
Hyundai · Ford · Opel · Skoda
|
||||
</p>
|
||||
|
||||
{/* Testimonial */}
|
||||
<div className="rounded-xl border border-white/10 bg-white/5 p-6">
|
||||
<p className="text-sm leading-relaxed text-neutral-300">
|
||||
<div className="rounded-2xl border border-border bg-surface/40 p-6 backdrop-blur-sm">
|
||||
<p className="text-sm leading-relaxed text-foreground/85">
|
||||
“Sase.tr'ye geçtiğimizden beri yanlış parça
|
||||
siparişlerimiz neredeyse sıfıra indi. Aylık 40 saatin üzerinde
|
||||
zaman tasarrufu sağlıyoruz.”
|
||||
</p>
|
||||
<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
|
||||
</div>
|
||||
<div>
|
||||
<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
|
||||
</p>
|
||||
</div>
|
||||
@@ -100,9 +121,9 @@ function AuthLayout() {
|
||||
</div>
|
||||
|
||||
{/* Bottom trial badge */}
|
||||
<div className="flex items-center gap-2 pt-6 text-sm text-neutral-400">
|
||||
<ShieldCheck className="size-4" />
|
||||
7 gün Full Paket ücretsiz deneyin — kredi kartı gerekmez
|
||||
<div className="relative flex items-center gap-2 pt-6 text-sm text-muted-foreground">
|
||||
<ShieldCheck className="size-4 text-brand" />
|
||||
30 gün Full Paket ücretsiz deneyin — kredi kartı gerekmez
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -67,9 +67,9 @@ function RegisterPage() {
|
||||
</p>
|
||||
|
||||
{/* 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">
|
||||
<ShieldCheck className="size-4 shrink-0" />
|
||||
7 gün Full Paket ücretsiz deneyin — kredi kartı gerekmez
|
||||
<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 text-brand" />
|
||||
30 gün Full Paket ücretsiz deneyin — kredi kartı gerekmez
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -326,7 +326,7 @@ const POSTS: Record<string, BlogPost> = {
|
||||
<h2 className="text-xl font-semibold text-foreground">Sonuç</h2>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -103,12 +103,17 @@ function NavLink({
|
||||
<Link
|
||||
to={to}
|
||||
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" }}
|
||||
activeOptions={exact ? { exact: true } : undefined}
|
||||
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>}
|
||||
</Link>
|
||||
);
|
||||
@@ -376,7 +381,7 @@ function DashboardLayout() {
|
||||
</header>
|
||||
|
||||
{/* 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 />
|
||||
</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 { api } from "@/lib/api-client";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import { Skeleton } from "@sase/ui";
|
||||
import { Library, Lock } from "lucide-react";
|
||||
import { Skeleton, cn } from "@sase/ui";
|
||||
import { Button } from "@sase/ui";
|
||||
import { ChevronRight, Columns2, LayoutGrid, Library, List, Lock } from "lucide-react";
|
||||
import { CarBrandLogo } from "@/components/ui/car-brand-logo";
|
||||
import { getUserSettings, setUserSetting } from "@/lib/user-settings";
|
||||
|
||||
export const Route = createFileRoute("/dashboard/catalog/")({
|
||||
component: CatalogBrandsPage,
|
||||
@@ -21,6 +24,15 @@ interface CatalogBrand {
|
||||
function CatalogBrandsPage() {
|
||||
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({
|
||||
queryKey: ["catalog-brands"],
|
||||
queryFn: () => api.get<CatalogBrand[]>("/catalog/brands"),
|
||||
@@ -28,9 +40,41 @@ function CatalogBrandsPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{t("catalog.title")}</h1>
|
||||
<p className="text-sm text-muted-foreground">{t("catalog.brands")}</p>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<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>
|
||||
|
||||
{isLoading ? (
|
||||
@@ -44,36 +88,59 @@ function CatalogBrandsPage() {
|
||||
<Library className="mb-4 size-12 text-muted-foreground/40" />
|
||||
<p className="text-muted-foreground">{t("catalog.noBrands")}</p>
|
||||
</div>
|
||||
) : (
|
||||
) : viewMode === "grid" ? (
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
|
||||
{brands.map((brand) => (
|
||||
<BrandCard key={brand.brandName} brand={brand} />
|
||||
))}
|
||||
</div>
|
||||
) : viewMode === "tree" ? (
|
||||
<BrandListTree brands={brands} />
|
||||
) : (
|
||||
<BrandListColumns brands={brands} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Grid card (existing) ── */
|
||||
|
||||
function BrandCard({ brand }: { brand: CatalogBrand }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!brand.hasAccess) {
|
||||
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">
|
||||
<CarBrandLogo brandName={brand.brandName} logoUrl={brand.logoUrl} size={40} />
|
||||
<div className="absolute -right-1 -bottom-1 flex size-4 items-center justify-center rounded-full bg-muted-foreground/60">
|
||||
<CarBrandLogo
|
||||
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" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm font-semibold text-foreground">{brand.brandName}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{t("catalog.locked")}</p>
|
||||
<p className="relative text-sm font-semibold text-foreground/70">{brand.brandName}</p>
|
||||
<p className="relative mt-1 text-[11px] uppercase tracking-wider text-muted-foreground/70">
|
||||
{t("catalog.locked")}
|
||||
</p>
|
||||
<Link
|
||||
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")}
|
||||
<ChevronRight className="size-3" />
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
@@ -84,10 +151,125 @@ function BrandCard({ brand }: { brand: CatalogBrand }) {
|
||||
to="/dashboard/catalog/$brandName"
|
||||
params={{ brandName: encodeURIComponent(brand.brandName) }}
|
||||
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>
|
||||
</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 { useQuery } from "@tanstack/react-query";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import { Button, Skeleton } from "@sase/ui";
|
||||
import { ArrowLeft, BookOpen, Car, ChevronRight, Loader2 } from "lucide-react";
|
||||
import { Button, Skeleton, cn } from "@sase/ui";
|
||||
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/")({
|
||||
validateSearch: (search: Record<string, unknown>) => ({
|
||||
@@ -27,6 +31,7 @@ interface CatalogVehicle {
|
||||
bodyType: string | null;
|
||||
transmission: string | null;
|
||||
architecture: string | null;
|
||||
catalogPath: string | null;
|
||||
}
|
||||
|
||||
function CatalogModelsPage() {
|
||||
@@ -37,6 +42,15 @@ function CatalogModelsPage() {
|
||||
|
||||
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
|
||||
const { data: catalogs, isLoading: catalogsLoading } = useQuery({
|
||||
queryKey: ["catalog-catalogs", decodedBrandName],
|
||||
@@ -139,10 +153,46 @@ function CatalogModelsPage() {
|
||||
<p className="text-muted-foreground">{t("catalog.noModels")}</p>
|
||||
</div>
|
||||
) : (
|
||||
<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 className="space-y-3">
|
||||
{/* View toggle */}
|
||||
<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>
|
||||
@@ -196,7 +246,7 @@ function ModelCard({ model, brandName }: { model: CatalogVehicle; brandName: str
|
||||
<Link
|
||||
to="/dashboard/catalog/$brandName/$modelId"
|
||||
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"
|
||||
>
|
||||
<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 { useQuery } from "@tanstack/react-query";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import { Button, Skeleton } from "@sase/ui";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { Button, Skeleton, cn } from "@sase/ui";
|
||||
import { ArrowLeft, Columns2, LayoutGrid, List } from "lucide-react";
|
||||
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(() =>
|
||||
import("@/components/schema/schema-viewer").then((mod) => ({
|
||||
@@ -36,6 +39,7 @@ export const Route = createFileRoute(
|
||||
body: typeof search.body === "string" ? search.body : undefined,
|
||||
engine: typeof search.engine === "string" ? search.engine : undefined,
|
||||
gearbox: typeof search.gearbox === "string" ? search.gearbox : undefined,
|
||||
mgp: typeof search.mgp === "string" ? search.mgp : undefined,
|
||||
}),
|
||||
component: CatalogCategoryPage,
|
||||
});
|
||||
@@ -59,7 +63,17 @@ function CatalogCategoryPage() {
|
||||
const engine = search.engine;
|
||||
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({
|
||||
queryKey: ["catalog-category", modelId, categoryId, body, engine, gearbox],
|
||||
@@ -77,13 +91,13 @@ function CatalogCategoryPage() {
|
||||
navigate({
|
||||
to: "/dashboard/catalog/$brandName/$modelId/categories/$categoryId",
|
||||
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 {
|
||||
navigate({
|
||||
to: "/dashboard/catalog/$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 (
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="icon" onClick={handleBack} title={t("common.back")}>
|
||||
<ArrowLeft className="size-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<Link to="/dashboard/catalog" className="hover:underline">
|
||||
{t("catalog.title")}
|
||||
</Link>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="icon" onClick={handleBack} title={t("common.back")}>
|
||||
<ArrowLeft className="size-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<Link to="/dashboard/catalog" className="hover:underline">
|
||||
{t("catalog.title")}
|
||||
</Link>
|
||||
</div>
|
||||
<h1 className="text-xl font-bold">{data?.name || t("catalog.categories")}</h1>
|
||||
</div>
|
||||
<h1 className="text-xl font-bold">{data?.name || t("catalog.categories")}</h1>
|
||||
</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>
|
||||
|
||||
{/* Content */}
|
||||
{hasChildren ? (
|
||||
<CategoryGrid
|
||||
categories={data.children}
|
||||
vehicleId={modelId}
|
||||
catalogMode
|
||||
brandName={brandName}
|
||||
parentId={categoryId}
|
||||
variantSearch={variantSearch}
|
||||
/>
|
||||
viewMode === "grid" ? (
|
||||
<CategoryGrid
|
||||
categories={data.children}
|
||||
vehicleId={modelId}
|
||||
catalogMode
|
||||
brandName={brandName}
|
||||
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 />}>
|
||||
<SchemaViewer
|
||||
|
||||
@@ -4,11 +4,13 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
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 { CategoryTree } from "@/components/categories/category-tree";
|
||||
import { CategoryColumns } from "@/components/categories/category-columns";
|
||||
import { PsaVariantSelector } from "@/components/catalog/psa-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";
|
||||
|
||||
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,
|
||||
engine: typeof search.engine === "string" ? search.engine : undefined,
|
||||
gearbox: typeof search.gearbox === "string" ? search.gearbox : undefined,
|
||||
mgp: typeof search.mgp === "string" ? search.mgp : undefined,
|
||||
}),
|
||||
component: CatalogVehiclePage,
|
||||
});
|
||||
@@ -38,15 +41,16 @@ function CatalogVehiclePage() {
|
||||
const body = search.body;
|
||||
const engine = search.engine;
|
||||
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",
|
||||
);
|
||||
|
||||
const decodedBrandName = decodeURIComponent(brandName);
|
||||
|
||||
const changeViewMode = (mode: "grid" | "tree") => {
|
||||
const changeViewMode = (mode: "grid" | "tree" | "columns") => {
|
||||
setViewMode(mode);
|
||||
setUserSetting("categoryViewMode", mode);
|
||||
};
|
||||
@@ -62,19 +66,39 @@ function CatalogVehiclePage() {
|
||||
"LEGACY_FORD",
|
||||
"LEGACY_VOLVO",
|
||||
].includes(vehicle?.architecture);
|
||||
const isP5WithRestrictions =
|
||||
vehicle?.architecture === "P5_MODERN" &&
|
||||
!!vehicle?.catalogPath &&
|
||||
!vehicle.catalogPath.includes("/mainGroup");
|
||||
const showPsaVariantSelector = isPsa && !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({
|
||||
queryKey: ["catalog-category-tree", modelId, body, engine, gearbox],
|
||||
queryFn: () =>
|
||||
api.get<any[]>(`/catalog/vehicles/${modelId}/categories${buildVariantQuery(body, engine, gearbox)}`),
|
||||
queryKey: ["catalog-category-tree", modelId, body, engine, gearbox, mgp],
|
||||
queryFn: () => {
|
||||
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,
|
||||
});
|
||||
|
||||
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 norm = (v: string) => (v && v !== "_all_" && v !== "_nor_" ? v : undefined);
|
||||
navigate({
|
||||
@@ -87,6 +111,7 @@ function CatalogVehiclePage() {
|
||||
body: norm(selectedBody) ?? (selectedBody === "_nor_" ? "_nor_" : undefined),
|
||||
engine: norm(selectedEngine),
|
||||
gearbox: norm(selectedGearbox),
|
||||
mgp: undefined,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -103,21 +128,22 @@ function CatalogVehiclePage() {
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl space-y-6">
|
||||
{/* Header / Breadcrumb */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() =>
|
||||
navigate({
|
||||
to: "/dashboard/catalog/$brandName",
|
||||
params: { brandName },
|
||||
search: { catalog: undefined },
|
||||
})
|
||||
}
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() =>
|
||||
navigate({
|
||||
to: "/dashboard/catalog/$brandName",
|
||||
params: { brandName },
|
||||
search: { catalog: undefined },
|
||||
})
|
||||
}
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<Link to="/dashboard/catalog" className="hover:underline">
|
||||
{t("catalog.title")}
|
||||
@@ -153,6 +179,34 @@ function CatalogVehiclePage() {
|
||||
{vehicle?.model}
|
||||
{vehicle?.year && <span className="ml-2 text-base font-normal text-muted-foreground">({vehicle.year})</span>}
|
||||
</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>
|
||||
|
||||
@@ -198,28 +252,14 @@ function CatalogVehiclePage() {
|
||||
<PsaVariantSelector vehicleId={modelId} onSelect={handleVariantSelect} />
|
||||
) : showFordVariantSelector ? (
|
||||
<FordVariantSelector vehicleId={modelId} onSelect={handleVariantSelect} />
|
||||
) : showP5RestrictionSelector ? (
|
||||
<P5RestrictionSelector vehicleId={modelId} onComplete={handleP5RestrictionComplete} />
|
||||
) : (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardHeader>
|
||||
<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>
|
||||
<CardContent>
|
||||
<CardContent className={viewMode === "columns" ? "p-0 overflow-hidden rounded-b-lg" : undefined}>
|
||||
{categoriesLoading ? (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
@@ -234,7 +274,7 @@ function CatalogVehiclePage() {
|
||||
brandName={brandName}
|
||||
variantSearch={variantSearch}
|
||||
/>
|
||||
) : (
|
||||
) : viewMode === "tree" ? (
|
||||
<CategoryTree
|
||||
categories={categoryTree || []}
|
||||
vehicleId={modelId}
|
||||
@@ -242,6 +282,14 @@ function CatalogVehiclePage() {
|
||||
brandName={brandName}
|
||||
variantSearch={variantSearch}
|
||||
/>
|
||||
) : (
|
||||
<CategoryColumns
|
||||
categories={categoryTree || []}
|
||||
vehicleId={modelId}
|
||||
catalogMode
|
||||
brandName={brandName}
|
||||
variantSearch={variantSearch}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -309,7 +309,7 @@ function DashboardHome() {
|
||||
<h3 className="text-lg font-bold">
|
||||
{subscription.plan?.name ?? "Aktif Plan"}
|
||||
</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"}
|
||||
</Badge>
|
||||
</div>
|
||||
@@ -371,7 +371,7 @@ function DashboardHome() {
|
||||
key={f}
|
||||
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}
|
||||
</span>
|
||||
))}
|
||||
|
||||
@@ -304,7 +304,7 @@ function SearchPage() {
|
||||
<div
|
||||
key={i}
|
||||
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 && (
|
||||
<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 size-10 shrink-0 items-center justify-center rounded-xl bg-emerald-500/10">
|
||||
<Car className="size-5 text-emerald-500" />
|
||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-brand/10">
|
||||
<Car className="size-5 text-brand" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<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">
|
||||
<Badge
|
||||
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ı
|
||||
</Badge>
|
||||
|
||||
@@ -296,12 +296,12 @@ function SubscriptionPage() {
|
||||
if (onboardingPhase === "provisioning") {
|
||||
return (
|
||||
<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">
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-emerald-500/5 to-teal-500/5" />
|
||||
<Card className="relative w-full overflow-hidden border-brand/25 bg-brand/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">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="h-6 w-6 animate-pulse text-emerald-600 dark:text-emerald-400" />
|
||||
<h2 className="text-xl font-bold text-emerald-900 dark:text-emerald-100">
|
||||
<Sparkles className="h-6 w-6 animate-pulse text-brand" />
|
||||
<h2 className="text-xl font-bold text-foreground">
|
||||
{t("subscription.onboarding.provisioning")}
|
||||
</h2>
|
||||
</div>
|
||||
@@ -309,7 +309,7 @@ function SubscriptionPage() {
|
||||
<Suspense
|
||||
fallback={
|
||||
<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>
|
||||
}
|
||||
>
|
||||
@@ -326,7 +326,7 @@ function SubscriptionPage() {
|
||||
|
||||
{/* If animation finished but mutation still pending */}
|
||||
{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" />
|
||||
{t("subscription.onboarding.step4")}...
|
||||
</div>
|
||||
@@ -357,20 +357,20 @@ function SubscriptionPage() {
|
||||
const freshSub = subData?.subscription;
|
||||
return (
|
||||
<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">
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-emerald-500/5 to-teal-500/5" />
|
||||
<Card className="relative w-full overflow-hidden border-brand/25 bg-brand/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">
|
||||
<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")}
|
||||
</h2>
|
||||
|
||||
{/* 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">
|
||||
<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 className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">{t("subscription.billingPeriod")}</span>
|
||||
@@ -387,8 +387,8 @@ function SubscriptionPage() {
|
||||
<Separator />
|
||||
<ul className="space-y-2 text-sm">
|
||||
{["allBrands", "vinSearch", "partsCatalog", "schemaViewer"].map((f) => (
|
||||
<li key={f} className="flex items-center gap-2 text-emerald-800 dark:text-emerald-200">
|
||||
<Check className="h-4 w-4 text-emerald-600 dark:text-emerald-400" />
|
||||
<li key={f} className="flex items-center gap-2 text-foreground/85">
|
||||
<Check className="h-4 w-4 text-brand" />
|
||||
{t(`subscription.features.${f}`)}
|
||||
</li>
|
||||
))}
|
||||
@@ -397,7 +397,7 @@ function SubscriptionPage() {
|
||||
|
||||
<Button
|
||||
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" })}
|
||||
>
|
||||
{t("subscription.onboarding.startSearching")}
|
||||
@@ -471,7 +471,7 @@ function SubscriptionPage() {
|
||||
</div>
|
||||
|
||||
{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" />
|
||||
{(() => {
|
||||
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 */}
|
||||
{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">
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-emerald-500/5 to-teal-500/5" />
|
||||
<Card className="relative overflow-hidden border-brand/25 bg-brand/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">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="h-5 w-5 text-emerald-600 dark:text-emerald-400" />
|
||||
<CardTitle className="text-emerald-900 dark:text-emerald-100">
|
||||
<Sparkles className="h-5 w-5 text-brand" />
|
||||
<CardTitle className="text-foreground">
|
||||
{t("subscription.trialTitle")}
|
||||
</CardTitle>
|
||||
</div>
|
||||
<CardDescription className="text-emerald-700/80 dark:text-emerald-300/80">
|
||||
<CardDescription className="text-muted-foreground">
|
||||
{t("subscription.trialDescription")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="relative space-y-4">
|
||||
<ul className="space-y-2 text-sm">
|
||||
{["allBrands", "vinSearch", "partsCatalog", "schemaViewer"].map((f) => (
|
||||
<li key={f} className="flex items-center gap-2 text-emerald-800 dark:text-emerald-200">
|
||||
<Check className="h-4 w-4 text-emerald-600 dark:text-emerald-400" />
|
||||
<li key={f} className="flex items-center gap-2 text-foreground/85">
|
||||
<Check className="h-4 w-4 text-brand" />
|
||||
{t(`subscription.features.${f}`)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Button
|
||||
className="bg-emerald-600 hover:bg-emerald-700 text-white"
|
||||
className="bg-brand text-brand-foreground hover:bg-brand/90"
|
||||
onClick={() => {
|
||||
startAction("trial-start");
|
||||
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 { useCategoryParts } from "@/hooks/use-parts";
|
||||
import { CategoryGrid } from "@/components/categories/category-grid";
|
||||
import { Button } from "@sase/ui";
|
||||
import { Skeleton } from "@sase/ui";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { CategoryTree } from "@/components/categories/category-tree";
|
||||
import { CategoryColumns } from "@/components/categories/category-columns";
|
||||
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(() =>
|
||||
import("@/components/schema/schema-viewer").then((mod) => ({
|
||||
@@ -49,6 +51,15 @@ function VehicleCategoryPage() {
|
||||
|
||||
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 = () => {
|
||||
if (data?.parentId) {
|
||||
navigate({
|
||||
@@ -66,25 +77,55 @@ function VehicleCategoryPage() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleBack}
|
||||
title="Geri don"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">
|
||||
{data?.name || "Kategori Detayi"}
|
||||
</h1>
|
||||
{data?.description && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{data.description}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleBack}
|
||||
title="Geri don"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">
|
||||
{data?.name || "Kategori Detayi"}
|
||||
</h1>
|
||||
{data?.description && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{data.description}
|
||||
</p>
|
||||
)}
|
||||
</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>
|
||||
|
||||
{/* Error state */}
|
||||
@@ -99,12 +140,24 @@ function VehicleCategoryPage() {
|
||||
<CategoryGridFallback />
|
||||
)}
|
||||
|
||||
{/* Parent category — show children grid */}
|
||||
{/* Parent category — show children */}
|
||||
{hasChildren && (
|
||||
<CategoryGrid
|
||||
categories={data.children!}
|
||||
vehicleId={id}
|
||||
/>
|
||||
viewMode === "grid" ? (
|
||||
<CategoryGrid
|
||||
categories={data.children!}
|
||||
vehicleId={id}
|
||||
/>
|
||||
) : viewMode === "tree" ? (
|
||||
<CategoryTree
|
||||
categories={data.children!}
|
||||
vehicleId={id}
|
||||
/>
|
||||
) : (
|
||||
<CategoryColumns
|
||||
categories={data.children!}
|
||||
vehicleId={id}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Leaf category — show schema viewer */}
|
||||
|
||||
@@ -4,9 +4,10 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { CategoryTree } from "@/components/categories/category-tree";
|
||||
import { CategoryGrid } from "@/components/categories/category-grid";
|
||||
import { CategoryColumns } from "@/components/categories/category-columns";
|
||||
import { Card, CardContent, CardHeader, CardTitle, cn } 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 { getUserSettings, setUserSetting } from "@/lib/user-settings";
|
||||
import { CarBrandLogo } from "@/components/ui/car-brand-logo";
|
||||
@@ -17,11 +18,11 @@ export const Route = createFileRoute("/dashboard/vehicles_/$id/")({
|
||||
|
||||
function VehicleDetailPage() {
|
||||
const { id } = Route.useParams();
|
||||
const [viewMode, setViewMode] = useState<"grid" | "tree">(
|
||||
const [viewMode, setViewMode] = useState<"grid" | "tree" | "columns">(
|
||||
() => getUserSettings().categoryViewMode ?? "grid",
|
||||
);
|
||||
|
||||
const changeViewMode = (mode: "grid" | "tree") => {
|
||||
const changeViewMode = (mode: "grid" | "tree" | "columns") => {
|
||||
setViewMode(mode);
|
||||
setUserSetting("categoryViewMode", mode);
|
||||
};
|
||||
@@ -97,6 +98,7 @@ function VehicleDetailPage() {
|
||||
? "bg-accent"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
title="Izgara"
|
||||
>
|
||||
<LayoutGrid className="h-4 w-4" />
|
||||
</button>
|
||||
@@ -109,12 +111,26 @@ function VehicleDetailPage() {
|
||||
? "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(
|
||||
"p-1.5 rounded",
|
||||
viewMode === "columns"
|
||||
? "bg-accent"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
title="Sutun"
|
||||
>
|
||||
<Columns2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className={viewMode === "columns" ? "p-0 overflow-hidden rounded-b-lg" : undefined}>
|
||||
{categoriesLoading ? (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
@@ -126,11 +142,16 @@ function VehicleDetailPage() {
|
||||
categories={categoryTree || []}
|
||||
vehicleId={id}
|
||||
/>
|
||||
) : (
|
||||
) : viewMode === "tree" ? (
|
||||
<CategoryTree
|
||||
categories={categoryTree || []}
|
||||
vehicleId={id}
|
||||
/>
|
||||
) : (
|
||||
<CategoryColumns
|
||||
categories={categoryTree || []}
|
||||
vehicleId={id}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -124,7 +124,8 @@ function DemoPage() {
|
||||
>
|
||||
{isDark ? <Sun className="size-4" /> : <Moon className="size-4" />}
|
||||
</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
|
||||
</span>
|
||||
<Link to="/register">
|
||||
@@ -137,7 +138,7 @@ function DemoPage() {
|
||||
</div>
|
||||
</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 */}
|
||||
<div className="mb-8 flex items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||
<button
|
||||
@@ -193,7 +194,7 @@ function DemoPage() {
|
||||
<div
|
||||
key={i}
|
||||
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 && (
|
||||
<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">
|
||||
<Car className="size-6 text-emerald-500" />
|
||||
<Car className="size-6 text-brand" />
|
||||
<div>
|
||||
<p className="font-semibold text-foreground">
|
||||
{vinPreview.make} {vinPreview.model}
|
||||
@@ -221,7 +222,8 @@ function DemoPage() {
|
||||
</div>
|
||||
<Button
|
||||
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
|
||||
<ArrowRight className="ml-2 size-4" />
|
||||
@@ -332,7 +334,7 @@ function DemoPage() {
|
||||
<div
|
||||
key={i}
|
||||
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 : ""}
|
||||
@@ -364,7 +366,7 @@ function DemoPage() {
|
||||
<p className="mt-0.5 text-sm text-muted-foreground">{part.name}</p>
|
||||
</div>
|
||||
{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
|
||||
</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">
|
||||
<h3 className="font-semibold">Tüm parçaları ve şemaları görün</h3>
|
||||
<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>
|
||||
<Link to="/register">
|
||||
<Button className="mt-4 rounded-full bg-foreground text-background hover:bg-foreground/90">
|
||||
|
||||
@@ -607,7 +607,7 @@ function HomePage() {
|
||||
</Link>
|
||||
<Link to="/register">
|
||||
<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>
|
||||
</Link>
|
||||
</>
|
||||
@@ -677,7 +677,7 @@ function HomePage() {
|
||||
</Link>
|
||||
<Link to="/register" onClick={() => setMobileMenuOpen(false)}>
|
||||
<Button className="w-full rounded-full bg-foreground text-background">
|
||||
7 Gün Ücretsiz Deneyin
|
||||
30 Gün Ücretsiz Deneyin
|
||||
</Button>
|
||||
</Link>
|
||||
</>
|
||||
@@ -687,6 +687,7 @@ function HomePage() {
|
||||
)}
|
||||
</header>
|
||||
|
||||
<main id="main-content">
|
||||
{/* ─── 2. HERO SECTION ──────────────────────────────────────────── */}
|
||||
<section className="relative overflow-hidden px-4 pb-16 pt-16 sm:px-6 sm:pt-24 lg:pt-28">
|
||||
{/* Decorative glow */}
|
||||
@@ -697,7 +698,10 @@ function HomePage() {
|
||||
<div className="relative mx-auto max-w-4xl text-center">
|
||||
{/* 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">
|
||||
<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
|
||||
</div>
|
||||
|
||||
@@ -764,7 +768,7 @@ function HomePage() {
|
||||
<div
|
||||
key={i}
|
||||
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 && (
|
||||
<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">
|
||||
<Car className="size-5 text-emerald-500" />
|
||||
<Car className="size-5 text-brand" />
|
||||
<span className="font-semibold text-foreground">
|
||||
{vinPreview.make} {vinPreview.model}
|
||||
</span>
|
||||
@@ -801,7 +805,8 @@ function HomePage() {
|
||||
size="sm"
|
||||
onClick={handleVinSearch}
|
||||
disabled={decodeLoading}
|
||||
className="rounded-full bg-emerald-500 text-white hover:bg-emerald-600"
|
||||
variant="brand"
|
||||
className="rounded-full"
|
||||
>
|
||||
{decodeLoading ? (
|
||||
<>
|
||||
@@ -921,7 +926,7 @@ function HomePage() {
|
||||
key={b}
|
||||
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}
|
||||
</li>
|
||||
))}
|
||||
@@ -993,7 +998,7 @@ function HomePage() {
|
||||
<td className="px-6 py-4 text-center text-sm text-muted-foreground">
|
||||
{row.manual}
|
||||
</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}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -1015,9 +1020,9 @@ function HomePage() {
|
||||
<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>
|
||||
</div>
|
||||
<div className="rounded-lg bg-emerald-500/10 p-2.5 text-center">
|
||||
<p className="text-[10px] font-medium uppercase tracking-wider text-emerald-500/70">Sase.tr</p>
|
||||
<p className="mt-1 text-sm font-medium text-emerald-500">{row.sase}</p>
|
||||
<div className="rounded-lg bg-brand/10 p-2.5 text-center">
|
||||
<p className="text-[10px] font-medium uppercase tracking-wider text-brand/70">Sase.tr</p>
|
||||
<p className="mt-1 text-sm font-medium text-brand">{row.sase}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1043,17 +1048,17 @@ function HomePage() {
|
||||
return (
|
||||
<div
|
||||
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">
|
||||
<Icon className="size-5 text-muted-foreground" />
|
||||
<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" />
|
||||
</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">
|
||||
{item.description}
|
||||
</p>
|
||||
{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}
|
||||
</p>
|
||||
)}
|
||||
@@ -1075,17 +1080,17 @@ function HomePage() {
|
||||
</div>
|
||||
|
||||
<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;
|
||||
return (
|
||||
<div
|
||||
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">
|
||||
<Icon className="size-5 text-muted-foreground" />
|
||||
<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" />
|
||||
</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">
|
||||
{uc.description}
|
||||
</p>
|
||||
@@ -1120,29 +1125,47 @@ function HomePage() {
|
||||
</section>
|
||||
|
||||
{/* ─── 8. RAKAMLARLA SASE.TR ────────────────────────────────────── */}
|
||||
<section className="bg-[#09090b] px-4 py-20 text-white sm:px-6">
|
||||
<div className="mx-auto max-w-7xl">
|
||||
<div className="text-center">
|
||||
<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">
|
||||
<section className="relative overflow-hidden bg-foreground px-4 py-24 text-background sm:px-6">
|
||||
{/* Ambient grid pattern — breaks digital flatness */}
|
||||
<div className="pointer-events-none absolute inset-0 opacity-[0.04]">
|
||||
<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
|
||||
</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">
|
||||
Rakamlarla Sase.tr
|
||||
<h2 className="mt-5 font-[family-name:var(--font-display)] text-4xl font-bold tracking-tight sm:text-5xl lg:text-6xl">
|
||||
Rakamlarla<br />
|
||||
<span className="text-background/50">Sase.tr</span>
|
||||
</h2>
|
||||
</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) => (
|
||||
<div
|
||||
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}
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-neutral-400">
|
||||
<p className="mt-3 text-sm text-background/60">
|
||||
{stat.label}
|
||||
</p>
|
||||
<div className="absolute bottom-0 left-0 h-px w-0 bg-brand transition-all duration-500 group-hover:w-full" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -1244,7 +1267,7 @@ function HomePage() {
|
||||
key={b}
|
||||
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}
|
||||
</li>
|
||||
))}
|
||||
@@ -1273,7 +1296,7 @@ function HomePage() {
|
||||
key={stat.label}
|
||||
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}
|
||||
</p>
|
||||
<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">
|
||||
{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">
|
||||
{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>
|
||||
<p className="mt-4 text-sm leading-relaxed text-muted-foreground">
|
||||
@@ -1322,104 +1352,159 @@ function HomePage() {
|
||||
</section>
|
||||
|
||||
{/* ─── 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="text-center">
|
||||
<div className="max-w-2xl">
|
||||
<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">
|
||||
Günde 7 TL'den Başlayan Fiyatlar
|
||||
<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
|
||||
<br />
|
||||
<span className="text-muted-foreground">başlayan fiyatlar.</span>
|
||||
</h2>
|
||||
<p className="mx-auto mt-4 max-w-xl text-muted-foreground">
|
||||
Tek bir yanlış parça iadesinin maliyetinden daha az.
|
||||
<p className="mt-5 max-w-xl text-base text-muted-foreground">
|
||||
Tek bir yanlış parça iadesinin maliyetinden daha az. Tüm planlarda 30 gün ücretsiz deneme var, kart bilgisi gerekmez.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Horizontally scrollable on mobile, grid on desktop */}
|
||||
<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">
|
||||
{/* Tier tiles — horizontal row, compact, breaks 4-tower symmetry */}
|
||||
<div className="mt-14 grid grid-cols-1 gap-4 sm:grid-cols-3 sm:gap-5">
|
||||
{[
|
||||
{
|
||||
name: "1 Marka",
|
||||
description: "Tek marka için yedek parça erişimi",
|
||||
description: "Tek marka için erişim",
|
||||
price: "200",
|
||||
yearly: "2.000",
|
||||
features: ["1 marka seçimi", "Sınırsız VIN arama", "Parça kataloğu", "Şema görüntüleyici"],
|
||||
},
|
||||
{
|
||||
name: "2 Marka",
|
||||
description: "İki farklı marka için erişim",
|
||||
description: "İki farklı marka",
|
||||
price: "350",
|
||||
yearly: "3.500",
|
||||
popular: true,
|
||||
features: ["2 marka seçimi", "Sınırsız VIN arama", "Parça kataloğu", "Şema görüntüleyici", "Öncelikli destek"],
|
||||
features: ["2 marka seçimi", "Sınırsız VIN arama", "Parça kataloğu", "Öncelikli destek"],
|
||||
},
|
||||
{
|
||||
name: "3 Marka",
|
||||
description: "Üç marka için kapsamlı erişim",
|
||||
description: "Üç marka kapsamlı",
|
||||
price: "500",
|
||||
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"],
|
||||
},
|
||||
{
|
||||
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) => (
|
||||
].map((plan, idx) => (
|
||||
<div
|
||||
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="mb-3 inline-flex self-start rounded-full bg-foreground px-3 py-1 text-xs font-medium text-background">
|
||||
En Popüler
|
||||
</div>
|
||||
)}
|
||||
<h3 className="font-[family-name:var(--font-display)] text-xl font-bold">
|
||||
{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
|
||||
<div className="flex items-baseline justify-between">
|
||||
<h3 className="font-[family-name:var(--font-display)] text-lg font-semibold">
|
||||
{plan.name}
|
||||
</h3>
|
||||
<span className="text-xs text-muted-foreground tabular">
|
||||
/ay
|
||||
</span>
|
||||
<span className="text-muted-foreground">/ay</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
veya {plan.yearly} TL/yıl
|
||||
<p className="mt-1 text-sm text-muted-foreground">{plan.description}</p>
|
||||
<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>
|
||||
<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) => (
|
||||
<li
|
||||
key={f}
|
||||
className="flex items-center gap-2 text-muted-foreground"
|
||||
>
|
||||
<CheckCircle2 className="size-4 shrink-0 text-emerald-500" />
|
||||
{f}
|
||||
<li key={f} className="flex items-start gap-2 text-muted-foreground">
|
||||
<CheckCircle2 className="mt-0.5 size-4 shrink-0 text-foreground/40" />
|
||||
<span>{f}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Link to="/register" className="mt-6">
|
||||
<Button
|
||||
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"}`}
|
||||
variant={plan.popular ? "default" : "outline"}
|
||||
>
|
||||
7 Gün Ücretsiz Deneyin
|
||||
<Link to="/register" className="mt-7">
|
||||
<Button variant="outline" className="w-full rounded-full">
|
||||
30 gün ücretsiz dene
|
||||
</Button>
|
||||
</Link>
|
||||
</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
|
||||
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" />
|
||||
</Link>
|
||||
</div>
|
||||
@@ -1499,6 +1584,7 @@ function HomePage() {
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
{/* ─── 13. FOOTER ───────────────────────────────────────────────── */}
|
||||
<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.
|
||||
</p>
|
||||
<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
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -63,7 +63,7 @@ function PricingPage() {
|
||||
usePageMeta({
|
||||
title: "Fiyatlandırma — Sase.tr | Şase Sorgulama Planları",
|
||||
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",
|
||||
});
|
||||
|
||||
@@ -85,7 +85,7 @@ function PricingPage() {
|
||||
</div>
|
||||
</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">
|
||||
<h1 className="text-4xl font-bold">Fiyatlandırma</h1>
|
||||
<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)
|
||||
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_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-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_ssl_server_name on;
|
||||
|
||||
@@ -55,6 +55,9 @@ export const envSchema = z.object({
|
||||
.transform((v) => v === "true")
|
||||
.default("false"),
|
||||
|
||||
// OpenRouter (used by scripts/emex-translate-bootstrap.ts → DeepSeek V3)
|
||||
OPENROUTER_API_KEY: z.string().optional(),
|
||||
|
||||
// Postal Email
|
||||
POSTAL_API_URL: z.string().url().optional(),
|
||||
POSTAL_API_KEY: z.string().optional(),
|
||||
|
||||
@@ -4,21 +4,27 @@ import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "./utils";
|
||||
|
||||
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: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||
destructive: "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
||||
outline: "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
|
||||
secondary: "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
||||
default:
|
||||
"bg-primary text-primary-foreground shadow-sm hover:bg-primary/92 hover:-translate-y-0.5 hover:shadow-md active:translate-y-0",
|
||||
brand:
|
||||
"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",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2",
|
||||
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",
|
||||
},
|
||||
},
|
||||
|
||||
25
pnpm-lock.yaml
generated
25
pnpm-lock.yaml
generated
@@ -98,7 +98,7 @@ importers:
|
||||
version: link:../../packages/shared
|
||||
better-auth:
|
||||
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:
|
||||
specifier: ^5.30.0
|
||||
version: 5.68.0
|
||||
@@ -117,6 +117,9 @@ importers:
|
||||
ioredis:
|
||||
specifier: ^5.4.0
|
||||
version: 5.9.2
|
||||
openai:
|
||||
specifier: ^6.37.0
|
||||
version: 6.37.0(zod@3.25.76)
|
||||
postgres:
|
||||
specifier: ^3.4.0
|
||||
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)
|
||||
better-auth:
|
||||
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:
|
||||
specifier: ^1.9.4
|
||||
version: 1.9.4
|
||||
@@ -4631,6 +4634,18 @@ packages:
|
||||
resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==}
|
||||
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:
|
||||
resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -8862,7 +8877,7 @@ snapshots:
|
||||
|
||||
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:
|
||||
'@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:
|
||||
mimic-fn: 2.1.0
|
||||
|
||||
openai@6.37.0(zod@3.25.76):
|
||||
optionalDependencies:
|
||||
zod: 3.25.76
|
||||
|
||||
ora@5.4.1:
|
||||
dependencies:
|
||||
bl: 4.1.0
|
||||
|
||||
Reference in New Issue
Block a user