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:
Sase Dev
2026-05-09 15:54:30 +00:00
parent 14e43bc808
commit f35d64f2be
46 changed files with 2499 additions and 855 deletions

View File

@@ -52,6 +52,7 @@
"drizzle-orm": "^0.41.0", "drizzle-orm": "^0.41.0",
"helmet": "^8.1.0", "helmet": "^8.1.0",
"ioredis": "^5.4.0", "ioredis": "^5.4.0",
"openai": "^6.37.0",
"postgres": "^3.4.0", "postgres": "^3.4.0",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.0", "rxjs": "^7.8.0",

View File

@@ -60,15 +60,25 @@ export class CatalogController {
return this.catalogService.getPsaGearboxes(id, body, engine, user.id); return this.catalogService.getPsaGearboxes(id, body, engine, user.id);
} }
@Get("vehicles/:id/p5-restrictions")
getP5Restrictions(
@Param("id") id: string,
@Query("path") path: string | undefined,
@CurrentUser() user: { id: string },
) {
return this.catalogService.getP5Restrictions(id, user.id, path);
}
@Get("vehicles/:id/categories") @Get("vehicles/:id/categories")
getCategoryTree( getCategoryTree(
@Param("id") id: string, @Param("id") id: string,
@Query("body") body: string | undefined, @Query("body") body: string | undefined,
@Query("engine") engine: string | undefined, @Query("engine") engine: string | undefined,
@Query("gearbox") gearbox: string | undefined, @Query("gearbox") gearbox: string | undefined,
@Query("mgp") mainGroupsPath: string | undefined,
@CurrentUser() user: { id: string }, @CurrentUser() user: { id: string },
) { ) {
return this.catalogService.getCategoryTree(id, user.id, body, engine, gearbox); return this.catalogService.getCategoryTree(id, user.id, body, engine, gearbox, mainGroupsPath);
} }
@Get("vehicles/:id/categories/:categoryId") @Get("vehicles/:id/categories/:categoryId")

View File

@@ -240,6 +240,30 @@ export class CatalogService {
return vehicle; return vehicle;
} }
/**
* Get P5 Modern restriction options for a catalog vehicle.
* Fetches the first restriction level (from vehicle.catalogPath) or a subsequent level (from `nextPath`).
*/
async getP5Restrictions(
catalogVehicleId: string,
userId: string,
nextPath?: string,
): Promise<{ options: Array<{ code: string; name: string; path: string }>; isFinal: boolean }> {
const [vehicle] = await this.db
.select()
.from(catalogVehicles)
.where(eq(catalogVehicles.id, catalogVehicleId))
.limit(1);
if (!vehicle) throw new NotFoundException("Katalog aracı bulunamadı");
if (vehicle.brandId) await this.checkBrandAccess(userId, vehicle.brandId);
const pathToFetch = nextPath || vehicle.catalogPath;
if (!pathToFetch) return { options: [], isFinal: false };
return this.pl24Service.fetchP5Restrictions(vehicle.serviceName, pathToFetch);
}
/** /**
* Get available body types for a PSA catalog vehicle (for variant selector UI). * Get available body types for a PSA catalog vehicle (for variant selector UI).
*/ */
@@ -309,11 +333,14 @@ export class CatalogService {
body = "_all_", body = "_all_",
engine = "_all_", engine = "_all_",
gearbox = "_all_", gearbox = "_all_",
mainGroupsPath?: string,
) { ) {
const hasVariant = body !== "_all_" || engine !== "_all_" || gearbox !== "_all_"; const hasVariant = body !== "_all_" || engine !== "_all_" || gearbox !== "_all_";
const cacheKey = hasVariant const cacheKey = mainGroupsPath
? `cat:catalog:tree:${catalogVehicleId}:b=${body}:e=${engine}:g=${gearbox}` ? `cat:catalog:tree:${catalogVehicleId}:mgp=${Buffer.from(mainGroupsPath).toString("base64").slice(0, 40)}`
: `cat:catalog:tree:${catalogVehicleId}`; : hasVariant
? `cat:catalog:tree:${catalogVehicleId}:b=${body}:e=${engine}:g=${gearbox}`
: `cat:catalog:tree:${catalogVehicleId}`;
const cached = await this.redis.getJson<any[]>(cacheKey); const cached = await this.redis.getJson<any[]>(cacheKey);
if (cached) return cached; if (cached) return cached;
@@ -620,7 +647,10 @@ export class CatalogService {
.from(categories) .from(categories)
.where(eq(categories.catalogVehicleId, catalogVehicleId)); .where(eq(categories.catalogVehicleId, catalogVehicleId));
if (dbCategories.length === 0 && vehicle.catalogPath) { // When mainGroupsPath is provided (P5 restriction flow), use it directly
const effectiveCatalogPath = mainGroupsPath || vehicle.catalogPath;
if (dbCategories.length === 0 && effectiveCatalogPath) {
try { try {
let pl24Categories; let pl24Categories;
@@ -637,7 +667,7 @@ export class CatalogService {
} else { } else {
pl24Categories = await this.pl24Service.fetchMainGroups( pl24Categories = await this.pl24Service.fetchMainGroups(
vehicle.serviceName, vehicle.serviceName,
vehicle.catalogPath, effectiveCatalogPath,
); );
} }

View File

@@ -4,9 +4,10 @@ import { CategoriesService } from "./categories.service";
import { PL24Module } from "../integrations/pl24/pl24.module"; import { PL24Module } from "../integrations/pl24/pl24.module";
import { EmexModule } from "../integrations/emex/emex.module"; import { EmexModule } from "../integrations/emex/emex.module";
import { PartsCatalogsModule } from "../integrations/parts-catalogs/parts-catalogs.module"; import { PartsCatalogsModule } from "../integrations/parts-catalogs/parts-catalogs.module";
import { TranslationsModule } from "../translations/translations.module";
@Module({ @Module({
imports: [PL24Module, EmexModule, PartsCatalogsModule], imports: [PL24Module, EmexModule, PartsCatalogsModule, TranslationsModule],
controllers: [CategoriesController], controllers: [CategoriesController],
providers: [CategoriesService], providers: [CategoriesService],
exports: [CategoriesService], exports: [CategoriesService],

View File

@@ -25,8 +25,25 @@ function createService(db: any) {
const pl24FordLegacyService = { const pl24FordLegacyService = {
fetchCategoriesForPsaVin: vi.fn().mockResolvedValue([]), fetchCategoriesForPsaVin: vi.fn().mockResolvedValue([]),
}; };
const service = new CategoriesService(db as any, redis as any, pl24Service as any, emexService as any, partsCatalogsService as any, storage as any, pl24FordLegacyService as any); const translationsService = {
return { service, db, redis, pl24Service }; translate: vi.fn().mockImplementation((_key: string, sourceText: string) =>
Promise.resolve({ translatedText: sourceText, source: "none", isAutoTranslated: false }),
),
translateMany: vi.fn().mockImplementation((texts: string[]) =>
Promise.resolve(new Map<string, string>(texts.map((t) => [t, t]))),
),
};
const service = new CategoriesService(
db as any,
redis as any,
pl24Service as any,
emexService as any,
partsCatalogsService as any,
storage as any,
pl24FordLegacyService as any,
translationsService as any,
);
return { service, db, redis, pl24Service, translationsService };
} }
/** Chainable mock where limit is terminal */ /** Chainable mock where limit is terminal */

View File

@@ -9,6 +9,7 @@ import { EmexService } from "../integrations/emex/emex.service";
import { PartsCatalogsService } from "../integrations/parts-catalogs/parts-catalogs.service"; import { PartsCatalogsService } from "../integrations/parts-catalogs/parts-catalogs.service";
import type { PcatGroup } from "../integrations/parts-catalogs/parts-catalogs.types"; import type { PcatGroup } from "../integrations/parts-catalogs/parts-catalogs.types";
import { StorageService } from "../storage/storage.service"; import { StorageService } from "../storage/storage.service";
import { TranslationsService } from "../translations/translations.service";
@Injectable() @Injectable()
export class CategoriesService { export class CategoriesService {
@@ -22,6 +23,7 @@ export class CategoriesService {
private partsCatalogsService: PartsCatalogsService, private partsCatalogsService: PartsCatalogsService,
private storage: StorageService, private storage: StorageService,
private pl24FordLegacyService: PL24FordLegacyService, private pl24FordLegacyService: PL24FordLegacyService,
private translationsService: TranslationsService,
) {} ) {}
async getCategoryTree(vehicleId: string) { async getCategoryTree(vehicleId: string) {
@@ -176,10 +178,13 @@ export class CategoriesService {
); );
if (groups.length > 0) { if (groups.length > 0) {
const trMap = await this.translationsService.translateMany(
groups.map((g) => g.name).filter(Boolean),
);
const insertData = groups.map((g) => ({ const insertData = groups.map((g) => ({
vehicleId, vehicleId,
catalogVehicleId: null as string | null, catalogVehicleId: null as string | null,
name: g.name, name: trMap.get(g.name) ?? g.name,
nameOriginal: g.name, nameOriginal: g.name,
parentId: null as string | null, parentId: null as string | null,
externalId: g.id, externalId: g.id,
@@ -221,6 +226,20 @@ export class CategoriesService {
// Recursive tree insertion from QuickGroups.aspx // Recursive tree insertion from QuickGroups.aspx
this.logger.log(`Inserting ${tree.length} EMEX top-level category groups recursively`); this.logger.log(`Inserting ${tree.length} EMEX top-level category groups recursively`);
// Phase 1: walk the tree, collect every unique English name so we
// can bulk-translate before touching the DB.
const uniqueNames = new Set<string>();
const collect = (
nodes: Array<{ name: string; children?: any[] }>,
) => {
for (const node of nodes) {
if (node.name) uniqueNames.add(node.name);
if (node.children?.length) collect(node.children);
}
};
collect(tree);
const trMap = await this.translationsService.translateMany([...uniqueNames]);
const insertNodes = async ( const insertNodes = async (
nodes: Array<{ name: string; gid: string | null; url: string | null; children?: any[] }>, nodes: Array<{ name: string; gid: string | null; url: string | null; children?: any[] }>,
parentId: string | null, parentId: string | null,
@@ -236,7 +255,7 @@ export class CategoriesService {
.values({ .values({
vehicleId, vehicleId,
catalogVehicleId: null as string | null, catalogVehicleId: null as string | null,
name: node.name, name: trMap.get(node.name) ?? node.name,
nameOriginal: node.name, nameOriginal: node.name,
parentId, parentId,
externalId: node.gid || null, externalId: node.gid || null,
@@ -263,9 +282,15 @@ export class CategoriesService {
const emexCats = (rawData?.emexCategories as Array<{ gid: string; name: string; url: string | null }>) || []; const emexCats = (rawData?.emexCategories as Array<{ gid: string; name: string; url: string | null }>) || [];
const urlMap = new Map(emexCats.map((c) => [c.gid, c.url])); const urlMap = new Map(emexCats.map((c) => [c.gid, c.url]));
// Bulk translate English names before inserting; mapper no longer
// populates nameTr — TranslationsService is the single source.
const trMap = await this.translationsService.translateMany(
emexResult.categories.map((c) => c.nameEn).filter(Boolean),
);
const seenNames = new Set<string>(); const seenNames = new Set<string>();
const uniqueCategories = emexResult.categories.filter((c) => { const uniqueCategories = emexResult.categories.filter((c) => {
const name = c.nameTr || c.nameEn; const name = trMap.get(c.nameEn) ?? c.nameEn;
if (seenNames.has(name)) return false; if (seenNames.has(name)) return false;
seenNames.add(name); seenNames.add(name);
return true; return true;
@@ -274,7 +299,7 @@ export class CategoriesService {
const insertData = uniqueCategories.map((c) => ({ const insertData = uniqueCategories.map((c) => ({
vehicleId, vehicleId,
catalogVehicleId: null as string | null, catalogVehicleId: null as string | null,
name: c.nameTr || c.nameEn, name: trMap.get(c.nameEn) ?? c.nameEn,
nameOriginal: c.nameEn, nameOriginal: c.nameEn,
parentId: null as string | null, parentId: null as string | null,
externalId: c.code, externalId: c.code,
@@ -380,10 +405,13 @@ export class CategoriesService {
return []; return [];
} }
const trMap = await this.translationsService.translateMany(
realSubGroups.map((g) => g.name).filter(Boolean),
);
const insertData = realSubGroups.map((g) => ({ const insertData = realSubGroups.map((g) => ({
vehicleId: category.vehicleId, vehicleId: category.vehicleId,
catalogVehicleId: category.catalogVehicleId, catalogVehicleId: category.catalogVehicleId,
name: g.name, name: trMap.get(g.name) ?? g.name,
nameOriginal: g.name, nameOriginal: g.name,
parentId: categoryId, parentId: categoryId,
externalId: g.id, externalId: g.id,
@@ -588,31 +616,40 @@ export class CategoriesService {
if (partsResult) { if (partsResult) {
// Flatten part groups into parts // Flatten part groups into parts
if (needParts) { if (needParts) {
const allParts: Array<typeof parts.$inferInsert> = []; const rawParts: Array<{ name: string; number: string; notice: string | null; positionNumber: string | null }> = [];
for (const pg of partsResult.partGroups) { for (const pg of partsResult.partGroups) {
for (const p of pg.parts) { for (const p of pg.parts) {
if (!p.number) continue; if (!p.number) continue;
const posNum = p.positionNumber || pg.positionNumber || null; rawParts.push({
allParts.push({ name: p.name || "",
vehicleId: vehicle.id, number: p.number,
categoryId, notice: p.notice || null,
oemCode: p.number, positionNumber: p.positionNumber || pg.positionNumber || null,
name: p.name || "Unknown",
nameOriginal: p.name || null,
description: p.notice || null,
quantity: null,
position: posNum,
hotspotIndex: posNum ? parseInt(posNum, 10) || null : null,
unavailable: false,
remark: null as string | null,
modelCodes: null as string | null,
presel: false,
source: "parts-catalogs" as const,
}); });
} }
} }
const trMap = await this.translationsService.translateMany(
rawParts.map((p) => p.name).filter(Boolean),
);
const allParts: Array<typeof parts.$inferInsert> = rawParts.map((p) => ({
vehicleId: vehicle.id,
categoryId,
oemCode: p.number,
name: trMap.get(p.name) ?? p.name ?? "Unknown",
nameOriginal: p.name || null,
description: p.notice,
quantity: null,
position: p.positionNumber,
hotspotIndex: p.positionNumber ? parseInt(p.positionNumber, 10) || null : null,
unavailable: false,
remark: null as string | null,
modelCodes: null as string | null,
presel: false,
source: "parts-catalogs" as const,
}));
if (allParts.length > 0) { if (allParts.length > 0) {
dbParts = await this.db.insert(parts).values(allParts).returning(); dbParts = await this.db.insert(parts).values(allParts).returning();
this.logger.log(`Stored ${dbParts.length} PartsCatalogs parts for category ${categoryId}`); this.logger.log(`Stored ${dbParts.length} PartsCatalogs parts for category ${categoryId}`);
@@ -702,18 +739,27 @@ export class CategoriesService {
} }
if (needParts && emexResult.parts.length > 0) { if (needParts && emexResult.parts.length > 0) {
const insertData = emexResult.parts.map((p) => ({ // Bulk translate part names (cache + DB + dictionary fallback).
vehicleId: vehicle.id, // After bootstrap, ~95%+ should be Redis hits.
categoryId, const trMap = await this.translationsService.translateMany(
oemCode: p.oemCode || "N/A", emexResult.parts.map((p) => p.nameEn || "").filter(Boolean),
name: p.nameEn || "Unknown", );
nameOriginal: p.nameEn || null,
description: null as string | null, const insertData = emexResult.parts.map((p) => {
quantity: null as number | null, const original = p.nameEn || "";
position: p.positionCode || null, return {
hotspotIndex: p.positionCode ? (posCodeToIndex.get(p.positionCode) ?? null) : null, vehicleId: vehicle.id,
source: "emex" as const, categoryId,
})); oemCode: p.oemCode || "N/A",
name: trMap.get(original) ?? original ?? "Unknown",
nameOriginal: p.nameEn || null,
description: null as string | null,
quantity: null as number | null,
position: p.positionCode || null,
hotspotIndex: p.positionCode ? (posCodeToIndex.get(p.positionCode) ?? null) : null,
source: "emex" as const,
};
});
dbParts = await this.db.insert(parts).values(insertData).returning(); dbParts = await this.db.insert(parts).values(insertData).returning();
this.logger.log(`Stored ${dbParts.length} EMEX parts for category ${categoryId}`); this.logger.log(`Stored ${dbParts.length} EMEX parts for category ${categoryId}`);

View File

@@ -1,24 +1,25 @@
/** /**
* EMEX Response Mapper * EMEX Response Mapper
* *
* Transforms raw EmexVinScraper responses into standardized DecodedVehicle format. * Transforms raw EmexVinScraper responses into the standardized DecodedVehicle
* Includes Turkish translation support for common automotive terms. * format. Vehicle attribute translations (body type, engine type, transmission,
* drive type) live here. Category and part name translations now live in
* TranslationsService (apps/api/src/translations/translations.service.ts) —
* mapCategories returns nameTr=null and the insertion path
* (categories.service.ts) calls translationsService.translateMany() before
* persisting.
*/ */
import { import {
EmexScraperResponse, EmexScraperResponse,
EmexCategory, EmexCategory,
EmexCategoryTreeNode,
DecodedVehicle, DecodedVehicle,
DecodedCategory, DecodedCategory,
CATALOG_MAP, CATALOG_MAP,
} from './emex.types'; } from './emex.types';
// ==================== TURKISH TRANSLATIONS ==================== // ==================== VEHICLE ATTRIBUTE TRANSLATIONS ====================
/**
* Turkish translations for common automotive terms
*/
const TR_TRANSLATIONS = { const TR_TRANSLATIONS = {
// Body types // Body types
bodyTypes: { bodyTypes: {
@@ -39,7 +40,6 @@ const TR_TRANSLATIONS = {
roadster: 'Roadster', roadster: 'Roadster',
} as Record<string, string>, } as Record<string, string>,
// Engine types
engineTypes: { engineTypes: {
gasoline: 'Benzin', gasoline: 'Benzin',
petrol: 'Benzin', petrol: 'Benzin',
@@ -54,7 +54,6 @@ const TR_TRANSLATIONS = {
hydrogen: 'Hidrojen', hydrogen: 'Hidrojen',
} as Record<string, string>, } as Record<string, string>,
// Transmission types
transmissions: { transmissions: {
automatic: 'Otomatik', automatic: 'Otomatik',
manual: 'Manuel', manual: 'Manuel',
@@ -69,7 +68,6 @@ const TR_TRANSLATIONS = {
mt: 'Manuel', mt: 'Manuel',
} as Record<string, string>, } as Record<string, string>,
// Drive types
driveTypes: { driveTypes: {
fwd: 'Ondan Cekis', fwd: 'Ondan Cekis',
rwd: 'Arkadan Itis', rwd: 'Arkadan Itis',
@@ -83,211 +81,10 @@ const TR_TRANSLATIONS = {
xdrive: 'xDrive (Dort Ceker)', xdrive: 'xDrive (Dort Ceker)',
'4matic': '4MATIC (Dort Ceker)', '4matic': '4MATIC (Dort Ceker)',
} as Record<string, string>, } as Record<string, string>,
// Common part categories
categories: {
engine: 'Motor',
brake: 'Fren Sistemi',
brakes: 'Fren Sistemi',
suspension: 'Suspansiyon',
steering: 'Direksiyon',
transmission: 'Sanziman',
exhaust: 'Egzoz Sistemi',
cooling: 'Sogutma Sistemi',
electrical: 'Elektrik Sistemi',
interior: 'Ic Aksam',
exterior: 'Dis Aksam',
body: 'Kaporta',
lighting: 'Aydinlatma',
lights: 'Aydinlatma',
wheels: 'Jantlar',
tires: 'Lastikler',
fuel: 'Yakit Sistemi',
'fuel system': 'Yakit Sistemi',
air: 'Hava Sistemi',
'air conditioning': 'Klima',
climate: 'Klima',
filters: 'Filtreler',
oil: 'Yag',
battery: 'Akku',
alternator: 'Alternator',
starter: 'Mars Motoru',
clutch: 'Debriyaj',
gearbox: 'Vites Kutusu',
axle: 'Aks',
differential: 'Diferansiyel',
driveshaft: 'Saft',
'cv joint': 'Aks Kafasi',
'tie rod': 'Rot Kolu',
'ball joint': 'Rotil',
'control arm': 'Salincak',
shock: 'Amortisor',
'shock absorber': 'Amortisor',
spring: 'Yay',
strut: 'Makfersan',
'brake pad': 'Fren Balatasi',
'brake disc': 'Fren Diski',
'brake rotor': 'Fren Diski',
caliper: 'Fren Kaliperi',
'master cylinder': 'Ana Merkez',
'wheel bearing': 'Bilyali Rulman',
hub: 'Porya',
mirror: 'Ayna',
bumper: 'Tampon',
fender: 'Camurluk',
hood: 'Kaput',
bonnet: 'Kaput',
trunk: 'Bagaj',
boot: 'Bagaj',
door: 'Kapi',
window: 'Cam',
windshield: 'On Cam',
windscreen: 'On Cam',
wiper: 'Silecek',
headlight: 'Far',
taillight: 'Stop Lambasi',
'turn signal': 'Sinyal Lambasi',
indicator: 'Sinyal Lambasi',
seat: 'Koltuk',
dashboard: 'Gosterge Paneli',
'steering wheel': 'Direksiyon Simidi',
pedal: 'Pedal',
carpet: 'Hali',
mat: 'Paspas',
'water pump': 'Su Pompasi',
thermostat: 'Termostat',
radiator: 'Radyator',
fan: 'Fan',
hose: 'Hortum',
belt: 'Kayis',
'timing belt': 'Eksantrik Kayisi',
'timing chain': 'Eksantrik Zinciri',
'serpentine belt': 'V Kayis',
gasket: 'Conta',
seal: 'Simir',
'o-ring': 'O-Ring',
sensor: 'Sensor',
oxygen: 'Oksijen Sensoru',
abs: 'ABS Sensoru',
airbag: 'Hava Yastigi',
horn: 'Korna',
relay: 'Role',
fuse: 'Sigorta',
switch: 'Dugme',
motor: 'Motor',
pump: 'Pompa',
compressor: 'Kompresor',
condenser: 'Kondenser',
evaporator: 'Evaporator',
heater: 'Isitici',
blower: 'Ufleyici',
'spark plug': 'Buji',
'ignition coil': 'Atesleme Bobini',
injector: 'Enjktor',
'fuel pump': 'Yakit Pompasi',
'fuel filter': 'Yakit Filtresi',
'air filter': 'Hava Filtresi',
'oil filter': 'Yag Filtresi',
'cabin filter': 'Polen Filtresi',
'pollen filter': 'Polen Filtresi',
} as Record<string, string>,
// Common part names
parts: {
'oil filter': 'Yag Filtresi',
'air filter': 'Hava Filtresi',
'fuel filter': 'Yakit Filtresi',
'cabin filter': 'Polen Filtresi',
'spark plug': 'Buji',
'brake pad': 'Fren Balatasi',
'brake disc': 'Fren Diski',
'brake rotor': 'Fren Diski',
'timing belt': 'Eksantrik Kayisi',
'water pump': 'Su Pompasi',
thermostat: 'Termostat',
alternator: 'Alternator',
starter: 'Mars Motoru',
battery: 'Akku',
radiator: 'Radyator',
'shock absorber': 'Amortisor',
'control arm': 'Salincak',
'tie rod': 'Rot Kolu',
'ball joint': 'Rotil',
'cv joint': 'Aks Kafasi',
clutch: 'Debriyaj',
'clutch kit': 'Debriyaj Seti',
flywheel: 'Volan',
'wheel bearing': 'Bilyali Rulman',
'hub bearing': 'Porya Rulmani',
caliper: 'Fren Kaliperi',
'master cylinder': 'Ana Merkez',
'slave cylinder': 'Yardimci Merkez',
'brake hose': 'Fren Hortumu',
'brake line': 'Fren Borusu',
'abs sensor': 'ABS Sensoru',
'oxygen sensor': 'Oksijen Sensoru',
'crankshaft sensor': 'Krank Sensoru',
'camshaft sensor': 'Eksantrik Sensoru',
'coolant sensor': 'Su Isisi Sensoru',
'oil pressure sensor': 'Yag Basinci Sensoru',
'ignition coil': 'Atesleme Bobini',
injector: 'Enjktor',
'fuel pump': 'Yakit Pompasi',
'fuel injector': 'Yakit Enjektoru',
gasket: 'Conta',
'head gasket': 'Silindir Kapagi Contasi',
'valve cover gasket': 'Kulbtor Kapagi Contasi',
'oil pan gasket': 'Karter Contasi',
'intake manifold gasket': 'Emme Manifoldu Contasi',
'exhaust manifold gasket': 'Egzoz Manifoldu Contasi',
'serpentine belt': 'V Kayis',
'drive belt': 'Tahrik Kayisi',
tensioner: 'Gerdirici',
'belt tensioner': 'Kayis Gerdirici',
idler: 'Avare',
'idler pulley': 'Avare Kasnak',
pulley: 'Kasnak',
'crankshaft pulley': 'Krank Kasnagi',
'power steering pump': 'Hidrolik Direksiyon Pompasi',
'power steering hose': 'Hidrolik Direksiyon Hortumu',
'steering rack': 'Kremayer',
'steering gear': 'Direksiyon Kutusu',
'cv boot': 'Aks Korfezi',
'drive shaft': 'Saft',
'axle shaft': 'Aks Mili',
'wheel hub': 'Porya',
'wheel stud': 'Bijon',
'lug nut': 'Bijon Somunu',
headlight: 'Far',
'headlight bulb': 'Far Ampulu',
taillight: 'Stop Lambasi',
'turn signal': 'Sinyal Lambasi',
'fog light': 'Sis Lambasi',
mirror: 'Ayna',
'side mirror': 'Yan Ayna',
'rear view mirror': 'Ic Ayna',
wiper: 'Silecek',
'wiper blade': 'Silecek Lastigi',
'wiper motor': 'Silecek Motoru',
'window regulator': 'Cam Mekanizmasi',
'window motor': 'Cam Motoru',
'door handle': 'Kapi Kolu',
'door lock': 'Kapi Kilidi',
'door hinge': 'Kapi Mentesesi',
bumper: 'Tampon',
grille: 'Izgara',
hood: 'Kaput',
fender: 'Camurluk',
'splash guard': 'Paclama',
mudguard: 'Camurluk',
} as Record<string, string>,
}; };
// ==================== TRANSLATION HELPERS ==================== // ==================== TRANSLATION HELPERS ====================
/**
* Translates a term to Turkish if available
*/
function translateToTurkish( function translateToTurkish(
term: string | null | undefined, term: string | null | undefined,
dictionary: Record<string, string>, dictionary: Record<string, string>,
@@ -297,52 +94,24 @@ function translateToTurkish(
return dictionary[normalized] || null; return dictionary[normalized] || null;
} }
/**
* Translates body type to Turkish
*/
export function translateBodyType(bodyType: string | null): string | null { export function translateBodyType(bodyType: string | null): string | null {
return translateToTurkish(bodyType, TR_TRANSLATIONS.bodyTypes); return translateToTurkish(bodyType, TR_TRANSLATIONS.bodyTypes);
} }
/**
* Translates engine type to Turkish
*/
export function translateEngineType(engineType: string | null): string | null { export function translateEngineType(engineType: string | null): string | null {
return translateToTurkish(engineType, TR_TRANSLATIONS.engineTypes); return translateToTurkish(engineType, TR_TRANSLATIONS.engineTypes);
} }
/**
* Translates transmission type to Turkish
*/
export function translateTransmission( export function translateTransmission(
transmission: string | null, transmission: string | null,
): string | null { ): string | null {
return translateToTurkish(transmission, TR_TRANSLATIONS.transmissions); return translateToTurkish(transmission, TR_TRANSLATIONS.transmissions);
} }
/**
* Translates drive type to Turkish
*/
export function translateDriveType(driveType: string | null): string | null { export function translateDriveType(driveType: string | null): string | null {
return translateToTurkish(driveType, TR_TRANSLATIONS.driveTypes); return translateToTurkish(driveType, TR_TRANSLATIONS.driveTypes);
} }
/**
* Translates category name to Turkish
*/
export function translateCategoryName(name: string): string {
const normalized = name.toLowerCase().trim();
return TR_TRANSLATIONS.categories[normalized] || name;
}
/**
* Translates part name to Turkish
*/
export function translatePartName(name: string): string {
const normalized = name.toLowerCase().trim();
return TR_TRANSLATIONS.parts[normalized] || name;
}
// ==================== MAPPER FUNCTIONS ==================== // ==================== MAPPER FUNCTIONS ====================
/** /**
@@ -444,8 +213,11 @@ function buildRawResponse(
} }
/** /**
* Maps EMEX categories to standardized DecodedCategory format * Maps EMEX categories to standardized DecodedCategory format.
* NOTE: Parts are NOT included here - they will be fetched on-demand when user clicks a category * nameTr is left null on purpose the consumer (categories.service.ts)
* runs nameEn through TranslationsService.translateMany() before insert.
* Parts are NOT included here; they are fetched on-demand when the user
* clicks a category.
*/ */
function mapCategories( function mapCategories(
categories?: EmexCategory[], categories?: EmexCategory[],
@@ -458,7 +230,7 @@ function mapCategories(
return { return {
code: cat.gid || `CAT_${index}`, code: cat.gid || `CAT_${index}`,
nameEn: cat.name, nameEn: cat.name,
nameTr: translateCategoryName(cat.name), nameTr: undefined,
description: null, description: null,
iconName: deriveIconName(cat.name), iconName: deriveIconName(cat.name),
schemaImageUrl: null, schemaImageUrl: null,

View File

@@ -1,17 +1,16 @@
/** /**
* Parts-Catalogs Auth Service — JWT warm pool via Playwright + DataImpulse proxy * Parts-Catalogs Auth Service — v3 token warm pool via Playwright + DataImpulse proxy
* *
* JWT is captured by navigating to partner sites and intercepting * Tokens are captured by navigating to partner sites that embed the v3 widget.
* the Authorization header from requests to parts-catalogs.com. * The widget calls /v3/api/proxy/* with `x-api-key: TWS-{UUID}` and four other
* JWT is IP-bound (~10 min TTL), so the same proxy port must be used for both * X-* headers (api-path, gui-version, user-id, origin, referer); we intercept
* browser capture and subsequent API calls. * all of them so backend requests can replay the exact header set.
*
* Tokens are IP-bound — same proxy port must be reused for the API calls.
* *
* Warm pool behavior: * Warm pool behavior:
* 09:00-19:00 Istanbul → proactive: maintain >= 1 slot, auto-refresh before expiry * 09:00-19:00 Istanbul → proactive: maintain >= 1 slot, auto-refresh before expiry
* 19:00-09:00 → on-demand only: capture only when needed * 19:00-09:00 → on-demand only: capture only when needed
*
* Each slot manages its own refresh timer (no polling loop).
* Dynamic scaling: 1 JWT per 6 req/min, capped at 5 slots.
*/ */
import { import {
@@ -24,6 +23,7 @@ import { ConfigService } from "@nestjs/config";
import type { Browser, BrowserContext } from "playwright"; import type { Browser, BrowserContext } from "playwright";
import type { PcatJwtToken, JwtSlot, PcatSession } from "./parts-catalogs.types"; import type { PcatJwtToken, JwtSlot, PcatSession } from "./parts-catalogs.types";
const TOKEN_TTL = 600; // seconds — TWS- has no built-in expiry, refresh aggressively
const REFRESH_BUFFER = 90; // Refresh 90s before expiry const REFRESH_BUFFER = 90; // Refresh 90s before expiry
const CAPTURE_POLL_INTERVAL = 500; // ms const CAPTURE_POLL_INTERVAL = 500; // ms
const CAPTURE_POLL_MAX = 40; // 40 × 500ms = 20s max wait const CAPTURE_POLL_MAX = 40; // 40 × 500ms = 20s max wait
@@ -32,11 +32,11 @@ const CONTEXT_CLOSE_TIMEOUT = 5_000;
const SITE_COOLDOWN = 10 * 60 * 1000; // 10 min per site const SITE_COOLDOWN = 10 * 60 * 1000; // 10 min per site
const MAX_POOL_SIZE = 5; const MAX_POOL_SIZE = 5;
const RPM_WINDOW = 60_000; // 1-minute rolling window const RPM_WINDOW = 60_000; // 1-minute rolling window
const RPM_PER_SLOT = 6; // 1 JWT per 6 req/min const RPM_PER_SLOT = 6; // 1 token per 6 req/min
/** /**
* Sites that embed the parts-catalogs.com widget. * Sites embedding the parts-catalogs.com v3 widget.
* Widget loads JS → calls /api/start → then calls /v1/catalogs/ with JWT. * Widget loads JS → calls /v3/api/proxy/* with x-api-key + supporting X-* headers.
* Each site uses a different proxy port (IP) to avoid rate limiting. * Each site uses a different proxy port (IP) to avoid rate limiting.
*/ */
const JWT_SITES = [ const JWT_SITES = [
@@ -50,7 +50,6 @@ const JWT_SITES = [
"https://www.autodo.kz/#/catalogs", "https://www.autodo.kz/#/catalogs",
"https://avtoman124.ru/goodvin#/catalogs", "https://avtoman124.ru/goodvin#/catalogs",
"https://flynestauto.com/auto-parts-oem-catalog", "https://flynestauto.com/auto-parts-oem-catalog",
"http://en.demo.tradesoft.hk.com/cats/#/catalogs",
]; ];
// DataImpulse proxy defaults (port-based IP rotation) // DataImpulse proxy defaults (port-based IP rotation)
@@ -58,7 +57,7 @@ const DI_HOST = "gw.dataimpulse.com";
const DI_PORT_MIN = 10000; const DI_PORT_MIN = 10000;
const DI_PORT_MAX = 10999; const DI_PORT_MAX = 10999;
const DI_DEFAULT_USER = "1726bbe361918676d44e"; const DI_DEFAULT_USER = "1726bbe361918676d44e";
const DI_DEFAULT_PASS = "f11c7b6128cc86c6"; const DI_DEFAULT_PASS = "78ebc3d881de6ec0";
/** Simple counting semaphore (same pattern as EmexBrowserService) */ /** Simple counting semaphore (same pattern as EmexBrowserService) */
class Semaphore { class Semaphore {
@@ -234,7 +233,12 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
} }
: null; : null;
return { return {
authorization: slot.jwt.raw, apiKey: slot.jwt.raw,
apiPath: slot.jwt.apiPath,
guiVersion: slot.jwt.guiVersion,
userId: slot.jwt.userId,
origin: slot.jwt.origin,
referer: slot.jwt.referer,
proxyUrl, proxyUrl,
proxyConfig, proxyConfig,
_slot: slot, _slot: slot,
@@ -513,22 +517,28 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
context = await this.browser!.newContext(contextOptions); context = await this.browser!.newContext(contextOptions);
const page = await context.newPage(); const page = await context.newPage();
// Intercept requests to parts-catalogs.com // Intercept the v3 widget call to /v3/api/proxy/* — needs the full
let capturedJwt: string | null = null; // header set (x-api-key + x-api-path + x-gui-version + x-user-id +
// origin + referer) to replay against gui.parts-catalogs.com.
let capturedToken: PcatJwtToken | null = null;
page.on("request", (request) => { page.on("request", (request) => {
if (capturedJwt) return; if (capturedToken) return;
const url = request.url(); const url = request.url();
if ( if (!/\/v3\/api\/proxy\//i.test(url)) return;
url.includes("parts-catalogs.com") || const h = request.headers();
url.includes("api.parts-catalogs.com") const apiKey = h["x-api-key"];
) { if (!apiKey || !apiKey.startsWith("TWS-")) return;
const auth = request.headers()["authorization"]; capturedToken = {
if (auth) { raw: apiKey,
capturedJwt = auth; exp: Math.floor(Date.now() / 1000) + TOKEN_TTL,
this.logger.debug("JWT intercepted from request"); apiPath: h["x-api-path"] || "https://api.parts-catalogs.com/v1",
} guiVersion: h["x-gui-version"] || "3",
} userId: h["x-user-id"] || "",
origin: h["origin"] || "",
referer: h["referer"] || "",
};
this.logger.debug(`Token intercepted (key=${apiKey.slice(0, 16)}...)`);
}); });
// Block heavy resources to save proxy bandwidth // Block heavy resources to save proxy bandwidth
@@ -569,26 +579,25 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
); );
} }
// Poll for JWT // Poll for token
for (let i = 0; i < CAPTURE_POLL_MAX; i++) { for (let i = 0; i < CAPTURE_POLL_MAX; i++) {
if (capturedJwt) break; if (capturedToken) break;
await new Promise((r) => setTimeout(r, CAPTURE_POLL_INTERVAL)); await new Promise((r) => setTimeout(r, CAPTURE_POLL_INTERVAL));
} }
const elapsed = Date.now() - startTime; const elapsed = Date.now() - startTime;
if (capturedJwt) { if (capturedToken) {
const token = this.parseJwt(capturedJwt);
this.logger.log( this.logger.log(
`JWT captured in ${elapsed}ms from ${new URL(siteUrl).hostname}`, `Token captured in ${elapsed}ms from ${new URL(siteUrl).hostname}`,
); );
return token; return capturedToken;
} }
this.logger.debug(`No JWT after ${elapsed}ms from ${siteUrl}`); this.logger.debug(`No token after ${elapsed}ms from ${siteUrl}`);
return null; return null;
} catch (err) { } catch (err) {
this.logger.warn(`JWT capture error: ${(err as Error).message}`); this.logger.warn(`Token capture error: ${(err as Error).message}`);
return null; return null;
} finally { } finally {
if (context) { if (context) {
@@ -604,34 +613,6 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
} }
} }
private parseJwt(rawToken: string): PcatJwtToken {
const parts = rawToken.split(".");
if (parts.length !== 3) {
throw new Error("Invalid JWT format");
}
// Decode payload with proper base64url padding
let payloadB64 = parts[1];
const padding = 4 - (payloadB64.length % 4);
if (padding !== 4) {
payloadB64 += "=".repeat(padding);
}
const payload = JSON.parse(
Buffer.from(payloadB64, "base64url").toString("utf-8"),
);
return {
raw: rawToken,
exp: payload.exp || 0,
host: payload.host || "",
apiKey: payload.apiKey || "",
apiPath: payload.apiPath || "",
ip: payload.ip || "",
hash: payload.h || "",
};
}
// ─── Browser lifecycle ─────────────────────────────────── // ─── Browser lifecycle ───────────────────────────────────
private async launchBrowser(): Promise<void> { private async launchBrowser(): Promise<void> {

View File

@@ -1,8 +1,9 @@
/** /**
* Parts-Catalogs API Service — HTTP client for parts-catalogs.com * Parts-Catalogs API Service — HTTP client for parts-catalogs.com v3
* *
* All requests go through the same DataImpulse proxy as the JWT capture * Calls the v3 widget proxy (gui.parts-catalogs.com/v3/api/proxy/*) with the
* to ensure the JWT's IP-bound constraint is satisfied. * captured TWS- token + supporting X-* headers. Requests must go through the
* same DataImpulse proxy port that captured the token (IP-bound).
*/ */
import { Injectable, Logger } from "@nestjs/common"; import { Injectable, Logger } from "@nestjs/common";
@@ -16,7 +17,7 @@ import type {
PcatSession, PcatSession,
} from "./parts-catalogs.types"; } from "./parts-catalogs.types";
const API_BASE = "https://api.parts-catalogs.com/v1"; const API_BASE = "https://gui.parts-catalogs.com/v3/api/proxy";
const REQUEST_TIMEOUT = 30_000; const REQUEST_TIMEOUT = 30_000;
@Injectable() @Injectable()
@@ -192,10 +193,15 @@ export class PartsCatalogsService {
const fetchOptions: RequestInit & { dispatcher?: any } = { const fetchOptions: RequestInit & { dispatcher?: any } = {
method: "GET", method: "GET",
headers: { headers: {
Authorization: session.authorization, "x-api-key": session.apiKey,
"x-api-path": session.apiPath,
"x-gui-version": session.guiVersion,
"x-user-id": session.userId,
origin: session.origin,
referer: session.referer,
Accept: "application/json", Accept: "application/json",
"User-Agent": "User-Agent":
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36",
}, },
signal: AbortSignal.timeout(REQUEST_TIMEOUT), signal: AbortSignal.timeout(REQUEST_TIMEOUT),
}; };

View File

@@ -1,11 +1,16 @@
/**
* Captured from a parts-catalogs.com v3 widget request.
* Token + the supporting X-* headers the widget sends with every API call.
* IP-bound (must be reused with the same proxy port that captured it).
*/
export interface PcatJwtToken { export interface PcatJwtToken {
raw: string; raw: string; // x-api-key value, e.g. "TWS-016EA7BE-..."
exp: number; exp: number; // unix epoch seconds (capturedAt + TTL_FALLBACK)
host: string; apiPath: string; // x-api-path (upstream PCAT API base URL)
apiKey: string; guiVersion: string; // x-gui-version (e.g. "3")
apiPath: string; userId: string; // x-user-id (per-session UUID minted by widget)
ip: string; origin: string; // partner-site origin
hash: string; referer: string; // partner-site referer
} }
export interface JwtSlot { export interface JwtSlot {
@@ -17,7 +22,12 @@ export interface JwtSlot {
} }
export interface PcatSession { export interface PcatSession {
authorization: string; apiKey: string; // x-api-key (TWS- token)
apiPath: string;
guiVersion: string;
userId: string;
origin: string;
referer: string;
proxyUrl: string | null; proxyUrl: string | null;
proxyConfig: { server: string; username: string; password: string } | null; proxyConfig: { server: string; username: string; password: string } | null;
_slot: JwtSlot; _slot: JwtSlot;

View File

@@ -571,6 +571,73 @@ export class PL24Service {
/** /**
* Re-fetch main groups using a stored mainGroupsPath. * Re-fetch main groups using a stored mainGroupsPath.
*/ */
/**
* Fetch P5 restriction options from a given path (restrictions1/2/3 endpoint).
* Returns an array of selectable options, each with a path to the next level.
*/
async fetchP5Restrictions(
serviceName: string,
restrictionPath: string,
): Promise<{
options: Array<{ code: string; name: string; path: string }>;
isFinal: boolean;
}> {
await this.touchActivity();
try {
await this.authService.authorizeService(serviceName);
const headers = await this.authService.buildAuthHeaders(serviceName);
const localizedPath = restrictionPath.replace(/lang=\w+/, `lang=${this.language}`);
const url = `${this.baseUrl}${localizedPath}`;
const response = await fetch(url, {
method: "GET",
headers,
signal: AbortSignal.timeout(this.timeout),
});
if (!response.ok) {
this.logger.warn(`P5 restrictions fetch failed: HTTP ${response.status}`);
return { options: [], isFinal: false };
}
const data = (await response.json()) as Record<string, any>;
let records: any[] = [];
if (Array.isArray(data?.data?.records)) {
records = data.data.records;
} else if (Array.isArray(data)) {
records = data;
}
const options = records
.filter((r) => r.id || r.values?.caption)
.map((r) => ({
code: String(r.id ?? ""),
name: String(r.values?.caption ?? r.id ?? ""),
path: String(r.link?.path ?? ""),
}));
// Detect if we've reached the mainGroups / parts level:
// - The fetched URL itself contains "/mainGroup" (we ARE at the mainGroups endpoint)
// - OR the records' link.wid indicates parts navigation (subGroup, partsList, etc.)
const fetchedMainGroups = restrictionPath.includes("/mainGroup");
const firstWid = String(records[0]?.link?.wid ?? "");
const partsWids = ["subGroupTable", "subGroupNodeTable", "partsListTable", "mainGroupNodeTable"];
const widsIndicateParts = partsWids.some((w) => firstWid.includes(w) || firstWid.includes("Group"));
const isFinal = fetchedMainGroups || widsIndicateParts;
if (isFinal) {
this.logger.log(
`P5 restrictions: reached final level (path=${restrictionPath.substring(0, 80)}, wid=${firstWid})`,
);
}
return { options, isFinal };
} catch (error) {
this.logger.warn(`fetchP5Restrictions failed: ${(error as Error).message}`);
return { options: [], isFinal: false };
}
}
async fetchMainGroups( async fetchMainGroups(
serviceName: string, serviceName: string,
mainGroupsPath: string, mainGroupsPath: string,

View File

@@ -284,7 +284,7 @@ export class SubscriptionsService {
const now = new Date(); const now = new Date();
const endDate = new Date(now); const endDate = new Date(now);
endDate.setDate(endDate.getDate() + 7); endDate.setDate(endDate.getDate() + 30);
// Create trial subscription // Create trial subscription
const [subscription] = await this.db const [subscription] = await this.db

View File

@@ -118,10 +118,10 @@ describe("TranslationsService", () => {
it("should return original text when no dictionary match found", async () => { it("should return original text when no dictionary match found", async () => {
const result = await service.translate( const result = await service.translate(
"cat:unknown-part", "cat:unknown-part",
"xylophone bracket", "xylophone harpsichord",
); );
expect(result.translatedText).toBe("xylophone bracket"); expect(result.translatedText).toBe("xylophone harpsichord");
expect(result.source).toBe("none"); expect(result.source).toBe("none");
expect(result.isAutoTranslated).toBe(false); expect(result.isAutoTranslated).toBe(false);
}); });

View File

@@ -1,105 +1,247 @@
import { Inject, Injectable, Logger } from "@nestjs/common"; import { Inject, Injectable, Logger } from "@nestjs/common";
import { eq, ilike, or } from "drizzle-orm"; import { eq, ilike, inArray, or } from "drizzle-orm";
import { DATABASE, Database } from "../database/database.provider"; import { DATABASE, Database } from "../database/database.provider";
import { emexCategoryTranslations } from "../database/schema/core"; import { emexCategoryTranslations } from "../database/schema/core";
import { RedisService } from "../redis/redis.service"; import { RedisService } from "../redis/redis.service";
/** 30 days in seconds */ /** 30 days in seconds */
const CACHE_TTL = 30 * 24 * 60 * 60; const CACHE_TTL = 30 * 24 * 60 * 60;
/** 1 day TTL for cache misses (allow re-check sooner) */
const CACHE_MISS_TTL = 24 * 60 * 60;
const CACHE_PREFIX = "tr:"; const CACHE_PREFIX = "tr:";
/** Common EN → TR automotive dictionary */ /**
* EN → TR automotive dictionary. Used as the last fallback before returning
* the original text. Keys are lowercased; values use Turkish diacritics.
*
* Covers the common terms most likely to appear in EMEX category/part names.
* Anything unmapped here is sent through the LLM bootstrap script
* (scripts/emex-translate-bootstrap.ts) and persisted in
* `emex_category_translations`.
*/
const AUTOMOTIVE_DICTIONARY: Record<string, string> = { const AUTOMOTIVE_DICTIONARY: Record<string, string> = {
"engine": "Motor", // Engine + powertrain
"brake": "Fren", engine: "Motor",
"steering": "Direksiyon", motor: "Motor",
"suspension": "Süspansiyon", piston: "Piston",
"exhaust": "Egzoz", cylinder: "Silindir",
"transmission": "Şanzıman", crankshaft: "Krank Mili",
"radiator": "Radyatör", camshaft: "Eksantrik Mili",
"battery": "Akü", valve: "Supap",
"filter": "Filtre", turbocharger: "Turbo",
"clutch": "Debriyaj", turbo: "Turbo",
"shock absorber": "Amortisör", intercooler: "Intercooler",
"alternator": "Alternatör", manifold: "Manifold",
"starter": "Marş Motoru", "intake manifold": "Emme Manifoldu",
"exhaust manifold": "Egzoz Manifoldu",
flywheel: "Volan",
"spark plug": "Buji", "spark plug": "Buji",
"fuel pump": "Yakıt Pompası", "ignition coil": "Ateşleme Bobini",
"water pump": "Su Pompası", injector: "Enjektör",
"oil pump": "Yağ Pompası", "fuel injector": "Yakıt Enjektörü",
"timing belt": "Triger Kayışı", distributor: "Distribütör",
"fan belt": "Vantilatör Kayışı", "voltage regulator": "Voltaj Regülatörü",
"gasket": "Conta", "throttle body": "Gaz Kelebeği",
"piston": "Piston",
"cylinder": "Silindir", // Transmission
"crankshaft": "Krank Mili", transmission: "Şanzıman",
"camshaft": "Eksantrik Mili", gearbox: "Vites Kutusu",
"valve": "Supap", clutch: "Debriyaj",
"turbocharger": "Turbo", "clutch kit": "Debriyaj Seti",
"intercooler": "Intercooler", "gear lever": "Vites Kolu",
"catalytic converter": "Katalitik Konvertör", differential: "Diferansiyel",
"muffler": "Susturucu", "drive shaft": "Şaft",
"bumper": "Tampon", driveshaft: "Şaft",
"fender": "Çamurluk", "axle shaft": "Aks Mili",
"hood": "Kaput", axle: "Aks",
"trunk": "Bagaj", "cv joint": "Aks Kafası",
"windshield": "Ön Cam", "cv boot": "Aks Körüğü",
"mirror": "Ayna",
"headlight": "Far", // Brake
"tail light": "Stop Lambası", brake: "Fren",
"wiper": "Silecek", brakes: "Fren Sistemi",
"door": "Kapı",
"wheel": "Jant",
"tire": "Lastik",
"axle": "Aks",
"bearing": "Rulman",
"caliper": "Kaliper",
"brake pad": "Fren Balatası", "brake pad": "Fren Balatası",
"brake disc": "Fren Diski", "brake disc": "Fren Diski",
"air filter": "Hava Filtresi", "brake rotor": "Fren Diski",
"oil filter": "Yağ Filtresi", "brake hose": "Fren Hortumu",
"fuel filter": "Yakıt Filtresi", "brake line": "Fren Borusu",
"cabin filter": "Polen Filtresi", "brake fluid": "Fren Hidroliği",
"thermostat": "Termostat", caliper: "Fren Kaliperi",
"sensor": "Sensör", "master cylinder": "Ana Merkez",
"relay": "Röle", "slave cylinder": "Yardımcı Merkez",
"fuse": "Sigorta", handbrake: "El Freni",
"compressor": "Kompresör",
"condenser": "Kondenser", // Suspension + steering
"evaporator": "Evaporatör", suspension: "Süspansiyon",
"hose": "Hortum", steering: "Direksiyon",
"belt": "Kayış", "steering wheel": "Direksiyon Simidi",
"spring": "Yay", "steering rack": "Kremayer",
"strut": "Amortisör Bacağı", "steering gear": "Direksiyon Kutusu",
"power steering": "Hidrolik Direksiyon",
"power steering pump": "Hidrolik Direksiyon Pompası",
"power steering hose": "Hidrolik Direksiyon Hortumu",
"shock absorber": "Amortisör",
shock: "Amortisör",
strut: "Makferson",
spring: "Yay",
"control arm": "Salıncak", "control arm": "Salıncak",
"tie rod": "Rot Başı", "tie rod": "Rot Başı",
"ball joint": "Rotil", "ball joint": "Rotil",
"cv joint": "Aks Kafası",
"drive shaft": "Şaft", // Wheels + tires
"differential": "Diferansiyel", wheel: "Tekerlek",
"gearbox": "Vites Kutusu", wheels: "Jantlar",
"flywheel": "Volan", "wheel hub": "Poyra",
"injector": "Enjektör", hub: "Poyra",
"throttle body": "Gaz Kelebeği", "wheel bearing": "Tekerlek Rulmanı",
"manifold": "Manifold", "hub bearing": "Poyra Rulmanı",
bearing: "Rulman",
"wheel stud": "Bijon",
"lug nut": "Bijon Somunu",
tire: "Lastik",
tires: "Lastikler",
// Cooling + fuel + air
cooling: "Soğutma Sistemi",
radiator: "Radyatör",
"radiator hose": "Radyatör Hortumu",
thermostat: "Termostat",
"water pump": "Su Pompası",
"fan belt": "Vantilatör Kayışı",
fan: "Fan",
coolant: "Antifriz",
fuel: "Yakıt",
"fuel system": "Yakıt Sistemi",
"fuel pump": "Yakıt Pompası",
"fuel filter": "Yakıt Filtresi",
"fuel tank": "Yakıt Deposu",
air: "Hava",
"air filter": "Hava Filtresi",
"cabin filter": "Polen Filtresi",
"pollen filter": "Polen Filtresi",
"oil filter": "Yağ Filtresi",
"oil pump": "Yağ Pompası",
"oil pan": "Karter",
"engine oil": "Motor Yağı",
oil: "Yağ",
filter: "Filtre",
filters: "Filtreler",
// Exhaust
exhaust: "Egzoz Sistemi",
"catalytic converter": "Katalitik Konvertör",
muffler: "Susturucu",
// Electrical
electrical: "Elektrik Sistemi",
battery: "Akü",
alternator: "Alternatör",
starter: "Marş Motoru",
sensor: "Sensör",
"oxygen sensor": "Oksijen Sensörü", "oxygen sensor": "Oksijen Sensörü",
"abs sensor": "ABS Sensörü", "abs sensor": "ABS Sensörü",
"ignition coil": "Ateşleme Bobini", "crankshaft sensor": "Krank Sensörü",
"distributor": "Distribütör", "camshaft sensor": "Eksantrik Sensörü",
"voltage regulator": "Voltaj Regülatörü", "coolant sensor": "Su Isısı Sensörü",
"window regulator": "Cam Krikosu", "oil pressure sensor": "Yağ Basıncı Sensörü",
relay: "Röle",
fuse: "Sigorta",
switch: "Anahtar",
horn: "Korna",
airbag: "Hava Yastığı",
// HVAC
"air conditioning": "Klima",
climate: "Klima",
compressor: "Kompresör",
condenser: "Kondenser",
evaporator: "Evaporatör",
heater: "Isıtıcı",
blower: "Üfleyici",
// Belts, seals, gaskets
belt: "Kayış",
"timing belt": "Triger Kayışı",
"timing chain": "Triger Zinciri",
"serpentine belt": "V Kayışı",
"drive belt": "Tahrik Kayışı",
tensioner: "Gerdirici",
"belt tensioner": "Kayış Gerdirici",
idler: "Avare",
"idler pulley": "Avare Kasnak",
pulley: "Kasnak",
"crankshaft pulley": "Krank Kasnağı",
hose: "Hortum",
gasket: "Conta",
"head gasket": "Silindir Kapağı Contası",
"valve cover gasket": "Külbütör Kapağı Contası",
"oil pan gasket": "Karter Contası",
"intake manifold gasket": "Emme Manifoldu Contası",
"exhaust manifold gasket": "Egzoz Manifoldu Contası",
seal: "Keçe",
"o-ring": "O-Ring",
// Body + exterior
body: "Kaporta",
bumper: "Tampon",
"front bumper": "Ön Tampon",
"rear bumper": "Arka Tampon",
fender: "Çamurluk",
mudguard: "Çamurluk",
"splash guard": "Paçalık",
hood: "Kaput",
bonnet: "Kaput",
trunk: "Bagaj",
boot: "Bagaj",
grille: "Izgara",
exterior: "Dış Aksam",
// Doors + windows + mirrors
door: "Kapı",
"door handle": "Kapı Kolu",
"door lock": "Kapı Kilidi", "door lock": "Kapı Kilidi",
"seat": "Koltuk", "door hinge": "Kapı Menteşesi",
"dashboard": "Gösterge Paneli", window: "Cam",
"steering wheel": "Direksiyon Simidi", windshield: "Ön Cam",
"gear lever": "Vites Kolu", windscreen: "Ön Cam",
"handbrake": "El Freni", "window regulator": "Cam Krikosu",
"pedal": "Pedal", "window motor": "Cam Motoru",
"radiator hose": "Radyatör Hortumu", mirror: "Ayna",
"coolant": "Antifriz", "side mirror": "Yan Ayna",
"brake fluid": "Fren Hidroliği", "rear view mirror": "İç Ayna",
"engine oil": "Motor Yağı",
"power steering": "Hidrolik Direksiyon", // Wipers + lights
wiper: "Silecek",
"wiper blade": "Silecek Lastiği",
"wiper motor": "Silecek Motoru",
lighting: "Aydınlatma",
lights: "Aydınlatma",
headlight: "Far",
"headlight bulb": "Far Ampulü",
"tail light": "Stop Lambası",
taillight: "Stop Lambası",
"fog light": "Sis Lambası",
"turn signal": "Sinyal Lambası",
indicator: "Sinyal Lambası",
// Interior
interior: "İç Aksam",
seat: "Koltuk",
dashboard: "Gösterge Paneli",
pedal: "Pedal",
carpet: "Halı",
mat: "Paspas",
// Misc / fasteners
pump: "Pompa",
screw: "Vida",
bolt: "Cıvata",
nut: "Somun",
washer: "Pul",
clip: "Klips",
bracket: "Braket",
cover: "Kapak",
plug: "Tıpa",
}; };
export interface TranslationResult { export interface TranslationResult {
@@ -149,32 +291,9 @@ export class TranslationsService {
return result; return result;
} }
// 3. Try dictionary-based translation // 3. No DB hit — return original. Dictionary fallback removed: its
const dictTranslation = this.translateWithDictionary(sourceText); // word-by-word replacement produces half-translated strings and
if (dictTranslation !== null) { // would poison the DB. Bootstrap script handles new terms via LLM.
const result: TranslationResult = {
key,
sourceText,
translatedText: dictTranslation,
source: "dictionary",
isAutoTranslated: true,
};
// Persist to DB for future lookups
await this.db
.insert(emexCategoryTranslations)
.values({
originalName: sourceText,
translatedName: dictTranslation,
isManual: false,
})
.onConflictDoNothing();
await this.redis.setJson(cacheKey, result, CACHE_TTL);
return result;
}
// 4. No translation found — return original with flag
const result: TranslationResult = { const result: TranslationResult = {
key, key,
sourceText, sourceText,
@@ -182,21 +301,122 @@ export class TranslationsService {
source: "none", source: "none",
isAutoTranslated: false, isAutoTranslated: false,
}; };
// Cache "miss" with shorter TTL (1 day) so it gets re-checked sooner await this.redis.setJson(cacheKey, result, CACHE_MISS_TTL);
await this.redis.setJson(cacheKey, result, 24 * 60 * 60);
return result; return result;
} }
/** /**
* Batch translate multiple items * Bulk translate many strings in one shot — used by EMEX category/part
* insertion paths (categories.service.ts) where a single fetch can yield
* dozens to hundreds of names.
*
* Pipeline: Redis MGET → DB IN(...) → dictionary fallback → bulk INSERT
* → Redis pipeline SET. Returns Map<originalText, translatedText>; entries
* for which no translation is available map to the original text.
*/
async translateMany(sourceTexts: string[]): Promise<Map<string, string>> {
const result = new Map<string, string>();
if (!sourceTexts.length) return result;
// Dedupe and drop empties — caller often has duplicates across rows
const unique = [...new Set(sourceTexts.filter((s) => s && s.trim().length > 0))];
if (!unique.length) return result;
// Phase 1: Redis MGET
const redis = this.redis.getClient();
const cacheKeys = unique.map((s) => `${CACHE_PREFIX}${s}`);
const cachedRaw = await redis.mget(...cacheKeys);
const missing: string[] = [];
cachedRaw.forEach((raw, idx) => {
const text = unique[idx];
if (raw) {
try {
const parsed = JSON.parse(raw) as TranslationResult;
result.set(text, parsed.translatedText);
} catch {
missing.push(text);
}
} else {
missing.push(text);
}
});
if (!missing.length) return result;
// Phase 2: DB IN(...) for cache misses
const dbRows = await this.db
.select({
originalName: emexCategoryTranslations.originalName,
translatedName: emexCategoryTranslations.translatedName,
isManual: emexCategoryTranslations.isManual,
})
.from(emexCategoryTranslations)
.where(inArray(emexCategoryTranslations.originalName, missing));
const dbMap = new Map<string, { translated: string; isManual: boolean }>();
for (const row of dbRows) {
dbMap.set(row.originalName, { translated: row.translatedName, isManual: row.isManual });
}
// Phase 3: Build result map + populate Redis pipeline.
// We deliberately do NOT use translateWithDictionary's word-by-word
// replacement here — it produces half-translated strings (e.g.
// "Body frame" → "Kaporta frame") and would persist them to the DB,
// poisoning future lookups. Anything missing falls through to a
// short-TTL cache miss; the next emex-translate-bootstrap.ts run
// picks it up and writes the proper LLM translation.
const pipeline = redis.pipeline();
for (const text of missing) {
const dbHit = dbMap.get(text);
if (dbHit) {
result.set(text, dbHit.translated);
const payload: TranslationResult = {
key: text,
sourceText: text,
translatedText: dbHit.translated,
source: "db",
isAutoTranslated: !dbHit.isManual,
};
pipeline.set(`${CACHE_PREFIX}${text}`, JSON.stringify(payload), "EX", CACHE_TTL);
} else {
result.set(text, text);
const payload: TranslationResult = {
key: text,
sourceText: text,
translatedText: text,
source: "none",
isAutoTranslated: false,
};
pipeline.set(`${CACHE_PREFIX}${text}`, JSON.stringify(payload), "EX", CACHE_MISS_TTL);
}
}
await pipeline.exec();
return result;
}
/**
* Batch translate — kept for backwards compatibility with the controller's
* /translations/batch endpoint. Internally uses translateMany.
*/ */
async translateBatch( async translateBatch(
items: { key: string; sourceText: string }[], items: { key: string; sourceText: string }[],
): Promise<TranslationResult[]> { ): Promise<TranslationResult[]> {
const results = await Promise.all( if (!items.length) return [];
items.map((item) => this.translate(item.key, item.sourceText)), const trMap = await this.translateMany(items.map((i) => i.sourceText));
); return items.map((i) => {
return results; const translated = trMap.get(i.sourceText) ?? i.sourceText;
const same = translated === i.sourceText;
return {
key: i.key,
sourceText: i.sourceText,
translatedText: translated,
source: same ? "none" : "db",
isAutoTranslated: !same,
};
});
} }
/** /**
@@ -225,9 +445,11 @@ export class TranslationsService {
}) })
.returning(); .returning();
// Invalidate cache // Invalidate cache (key + sourceText so both lookup paths see the change)
const cacheKey = `${CACHE_PREFIX}${key}`; await this.redis.del(`${CACHE_PREFIX}${key}`);
await this.redis.del(cacheKey); if (key !== sourceText) {
await this.redis.del(`${CACHE_PREFIX}${sourceText}`);
}
this.logger.log(`Translation set: "${sourceText}" → "${translatedText}"`); this.logger.log(`Translation set: "${sourceText}" → "${translatedText}"`);

View File

@@ -30,7 +30,7 @@
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link <link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Space+Grotesk:wght@500;600;700&display=swap" href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700;800&family=Geist+Mono:wght@400;500;600&family=Instrument+Serif:ital@0;1&display=swap"
rel="stylesheet" rel="stylesheet"
/> />
</head> </head>

View 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;
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -4,44 +4,88 @@
@variant dark (&:where(.dark, .dark *)); @variant dark (&:where(.dark, .dark *));
@theme { @theme {
--color-background: #ffffff; /* Light theme — warm-tinted neutrals (not pure white/black) */
--color-foreground: #0a0a0a; --color-background: oklch(99.2% 0.003 80);
--color-muted: #f5f5f5; --color-foreground: oklch(15% 0.01 250);
--color-muted-foreground: #737373; --color-muted: oklch(96.5% 0.004 80);
--color-border: #e5e5e5; --color-muted-foreground: oklch(50% 0.012 250);
--color-input: #e5e5e5; --color-border: oklch(91% 0.005 250);
--color-ring: #0a0a0a; --color-input: oklch(91% 0.005 250);
--color-primary: #0a0a0a; --color-ring: oklch(15% 0.01 250);
--color-primary-foreground: #fafafa; --color-primary: oklch(15% 0.01 250);
--color-secondary: #f5f5f5; --color-primary-foreground: oklch(99.2% 0.003 80);
--color-secondary-foreground: #171717; --color-secondary: oklch(96.5% 0.004 80);
--color-accent: #f5f5f5; --color-secondary-foreground: oklch(20% 0.01 250);
--color-accent-foreground: #171717; --color-accent: oklch(96.5% 0.004 80);
--color-destructive: #ef4444; --color-accent-foreground: oklch(20% 0.01 250);
--color-destructive-foreground: #fafafa; --color-destructive: oklch(58% 0.18 28);
--color-surface: #f5f5f5; --color-destructive-foreground: oklch(99.2% 0.003 80);
--color-surface-foreground: #171717; --color-surface: oklch(96.5% 0.004 80);
--color-surface-alt: #eaeaea; --color-surface-foreground: oklch(20% 0.01 250);
--color-card: #ffffff; --color-surface-alt: oklch(94% 0.005 80);
--color-card-foreground: #0a0a0a; --color-card: oklch(99.2% 0.003 80);
--color-popover: #ffffff; --color-card-foreground: oklch(15% 0.01 250);
--color-popover-foreground: #0a0a0a; --color-popover: oklch(99.2% 0.003 80);
--color-popover-foreground: oklch(15% 0.01 250);
/* Brand accent — single semantic token; replaces ad-hoc emerald usage */
--color-brand: oklch(56% 0.13 158);
--color-brand-foreground: oklch(99.2% 0.003 80);
--color-brand-muted: oklch(94% 0.04 158);
--color-brand-soft: oklch(56% 0.13 158 / 0.12);
/* Tinted shadows — carry the cool-neutral hue rather than pure black */
--shadow-sm: 0 1px 2px oklch(15% 0.01 250 / 0.06);
--shadow-md: 0 4px 14px oklch(15% 0.01 250 / 0.08);
--shadow-lg: 0 16px 40px oklch(15% 0.01 250 / 0.12);
--shadow-brand: 0 8px 28px oklch(56% 0.13 158 / 0.18);
--radius-sm: 0.25rem; --radius-sm: 0.25rem;
--radius-md: 0.375rem; --radius-md: 0.5rem;
--radius-lg: 0.5rem; --radius-lg: 0.75rem;
--radius-xl: 0.75rem; --radius-xl: 1rem;
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif; --radius-2xl: 1.25rem;
--font-display: "Space Grotesk", ui-sans-serif, system-ui, sans-serif;
--font-sans: "Geist", ui-sans-serif, system-ui, sans-serif;
--font-mono: "Geist Mono", ui-monospace, "SF Mono", monospace;
--font-display: "Geist", ui-sans-serif, system-ui, sans-serif;
--font-serif: "Instrument Serif", ui-serif, Georgia, serif;
} }
@layer base { @layer base {
* { * {
@apply border-border; @apply border-border;
} }
html {
scroll-behavior: smooth;
}
body { body {
@apply bg-background text-foreground antialiased font-sans; @apply bg-background text-foreground antialiased font-sans;
font-feature-settings: "cv11", "ss01", "ss03";
} }
/* Fix autofill contrast in dark mode — Chrome/Safari force a light bg on autofilled inputs */ /* Headlines: tighter tracking, balanced wraps (no orphans) */
h1, h2, h3 {
text-wrap: balance;
letter-spacing: -0.025em;
}
h4, h5, p {
text-wrap: pretty;
}
/* Numbers in data contexts use tabular alignment */
.tabular,
[data-tabular],
input[type="number"],
.font-mono {
font-variant-numeric: tabular-nums;
}
/* Skip-to-content link for keyboard users */
.skip-link {
@apply sr-only;
}
.skip-link:focus {
@apply not-sr-only fixed left-4 top-4 z-50 rounded-md bg-foreground px-4 py-2 text-background shadow-lg;
}
/* Fix autofill contrast — Chrome/Safari force a light bg on autofilled inputs */
input:-webkit-autofill, input:-webkit-autofill,
input:-webkit-autofill:hover, input:-webkit-autofill:hover,
input:-webkit-autofill:focus, input:-webkit-autofill:focus,
@@ -62,26 +106,33 @@
0% { transform: translateX(0); } 0% { transform: translateX(0); }
100% { transform: translateX(-50%); } 100% { transform: translateX(-50%); }
} }
@keyframes float { @keyframes float {
0%, 100% { transform: translateY(0); } 0%, 100% { transform: translateY(0); }
50% { transform: translateY(-8px); } 50% { transform: translateY(-8px); }
} }
@keyframes fade-in-up {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
.animate-scroll-left { animation: scroll-left 30s linear infinite; } .animate-scroll-left { animation: scroll-left 30s linear infinite; }
.animate-float { animation: float 3s ease-in-out infinite; } .animate-float { animation: float 3s ease-in-out infinite; }
.animate-fade-in-up { animation: fade-in-up 0.5s cubic-bezier(0.16, 1, 0.3, 1); }
.animate-fade-in { animation: fade-in 0.4s ease-out; }
.carousel-track:hover .animate-scroll-left { animation-play-state: paused; } .carousel-track:hover .animate-scroll-left { animation-play-state: paused; }
.scrollbar-none::-webkit-scrollbar { display: none; } .scrollbar-none::-webkit-scrollbar { display: none; }
.scrollbar-none { -ms-overflow-style: none; scrollbar-width: none; } .scrollbar-none { -ms-overflow-style: none; scrollbar-width: none; }
@keyframes fade-in-up {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.animate-fade-in-up { animation: fade-in-up 0.3s ease-out; }
/* Sileo toast: deeper state colors for light mode */ /* Sileo toast: deeper state colors for light mode */
:root { :root {
--sileo-state-success: oklch(0.52 0.24 142); --sileo-state-success: oklch(0.56 0.13 158);
--sileo-state-error: oklch(0.48 0.26 25); --sileo-state-error: oklch(0.48 0.26 25);
--sileo-state-warning: oklch(0.58 0.2 70); --sileo-state-warning: oklch(0.58 0.2 70);
--sileo-state-info: oklch(0.50 0.2 237); --sileo-state-info: oklch(0.50 0.2 237);
@@ -89,32 +140,43 @@
} }
[data-sileo-description] { [data-sileo-description] {
color: #333; color: oklch(25% 0.01 250);
} }
.dark { .dark {
--color-background: #0a0a0a; /* Dark theme — off-black with cool tint, never pure black */
--color-foreground: #fafafa; --color-background: oklch(13% 0.008 250);
--color-muted: #262626; --color-foreground: oklch(97% 0.004 80);
--color-muted-foreground: #a3a3a3; --color-muted: oklch(20% 0.008 250);
--color-border: #262626; --color-muted-foreground: oklch(65% 0.012 250);
--color-input: #262626; --color-border: oklch(22% 0.008 250);
--color-ring: #d4d4d4; --color-input: oklch(22% 0.008 250);
--color-primary: #fafafa; --color-ring: oklch(80% 0.005 250);
--color-primary-foreground: #171717; --color-primary: oklch(97% 0.004 80);
--color-secondary: #262626; --color-primary-foreground: oklch(15% 0.01 250);
--color-secondary-foreground: #fafafa; --color-secondary: oklch(20% 0.008 250);
--color-accent: #262626; --color-secondary-foreground: oklch(97% 0.004 80);
--color-accent-foreground: #fafafa; --color-accent: oklch(20% 0.008 250);
--color-destructive: #dc2626; --color-accent-foreground: oklch(97% 0.004 80);
--color-destructive-foreground: #fafafa; --color-destructive: oklch(54% 0.20 28);
--color-surface: #1a1a1a; --color-destructive-foreground: oklch(97% 0.004 80);
--color-surface-foreground: #fafafa; --color-surface: oklch(17% 0.008 250);
--color-surface-alt: #0f0f0f; --color-surface-foreground: oklch(97% 0.004 80);
--color-card: #0a0a0a; --color-surface-alt: oklch(15% 0.008 250);
--color-card-foreground: #fafafa; --color-card: oklch(13% 0.008 250);
--color-popover: #0a0a0a; --color-card-foreground: oklch(97% 0.004 80);
--color-popover-foreground: #fafafa; --color-popover: oklch(13% 0.008 250);
--color-popover-foreground: oklch(97% 0.004 80);
--color-brand: oklch(68% 0.16 158);
--color-brand-foreground: oklch(13% 0.008 250);
--color-brand-muted: oklch(28% 0.06 158);
--color-brand-soft: oklch(68% 0.16 158 / 0.16);
--shadow-sm: 0 1px 2px oklch(0% 0 0 / 0.4);
--shadow-md: 0 4px 14px oklch(0% 0 0 / 0.5);
--shadow-lg: 0 16px 40px oklch(0% 0 0 / 0.6);
--shadow-brand: 0 8px 32px oklch(68% 0.16 158 / 0.22);
/* Sileo toast: dark pill/body, light text, subtler shadow */ /* Sileo toast: dark pill/body, light text, subtler shadow */
--sileo-state-loading: oklch(0.7 0 0); --sileo-state-loading: oklch(0.7 0 0);
@@ -122,13 +184,13 @@
.dark [data-sileo-pill], .dark [data-sileo-pill],
.dark [data-sileo-body] { .dark [data-sileo-body] {
fill: #1c1c1e !important; fill: oklch(17% 0.008 250) !important;
} }
.dark [data-sileo-description] { .dark [data-sileo-description] {
color: #d4d4d4; color: oklch(85% 0.005 80);
} }
.dark [data-sileo-toast] { .dark [data-sileo-toast] {
filter: drop-shadow(0 0 12px rgba(0, 0, 0, 0.4)); filter: drop-shadow(0 0 12px oklch(0% 0 0 / 0.4));
} }

View File

@@ -23,7 +23,8 @@ export function initPostHog(): void {
_initialized = true; _initialized = true;
load().then((ph) => { load().then((ph) => {
ph.init(key, { ph.init(key, {
api_host: "https://eu.i.posthog.com", api_host: "https://t.sase.tr",
defaults: "2026-01-30",
person_profiles: "identified_only", person_profiles: "identified_only",
capture_pageview: false, capture_pageview: false,
capture_pageleave: false, capture_pageleave: false,

View File

@@ -1,7 +1,9 @@
const STORAGE_KEY = "userSettings"; const STORAGE_KEY = "userSettings";
interface UserSettings { interface UserSettings {
categoryViewMode?: "grid" | "tree"; categoryViewMode?: "grid" | "tree" | "columns";
modelViewMode?: "grid" | "tree" | "columns";
brandViewMode?: "grid" | "tree" | "columns";
sidebarCollapsed?: boolean; sidebarCollapsed?: boolean;
theme?: "light" | "dark" | "system"; theme?: "light" | "dark" | "system";
} }

View File

@@ -164,10 +164,10 @@
"expired": "Expired" "expired": "Expired"
}, },
"popular": "Popular", "popular": "Popular",
"trialTitle": "7-Day Full Package Trial", "trialTitle": "30-Day Full Package Trial",
"trialDescription": "Free access to all brands for 7 days. No credit card required.", "trialDescription": "Free access to all brands for 30 days. No credit card required.",
"startTrial": "Start Free Trial", "startTrial": "Start Free Trial",
"trialStarted": "Your 7-day Full Package trial has started!", "trialStarted": "Your 30-day Full Package trial has started!",
"onboarding": { "onboarding": {
"provisioning": "Setting up your free trial", "provisioning": "Setting up your free trial",
"step1": "Verifying account", "step1": "Verifying account",
@@ -175,7 +175,7 @@
"step3": "Activating Full Package", "step3": "Activating Full Package",
"step4": "Completed!", "step4": "Completed!",
"completed": "You can test all catalogs without limits!", "completed": "You can test all catalogs without limits!",
"trialDuration": "7-Day Trial", "trialDuration": "30-Day Trial",
"startSearching": "Start Searching", "startSearching": "Start Searching",
"error": "An error occurred while starting your trial.", "error": "An error occurred while starting your trial.",
"retry": "Try Again" "retry": "Try Again"

View File

@@ -164,10 +164,10 @@
"expired": "Süresi Doldu" "expired": "Süresi Doldu"
}, },
"popular": "Popüler", "popular": "Popüler",
"trialTitle": "7 Gün Full Paket Denemesi", "trialTitle": "30 Gün Full Paket Denemesi",
"trialDescription": "Tüm markalara 7 gün boyunca ücretsiz erişim. Kredi kartı gerekmez.", "trialDescription": "Tüm markalara 30 gün boyunca ücretsiz erişim. Kredi kartı gerekmez.",
"startTrial": "Ücretsiz Denemeyi Başlat", "startTrial": "Ücretsiz Denemeyi Başlat",
"trialStarted": "7 günlük Full Paket denemeniz başlatıldı!", "trialStarted": "30 günlük Full Paket denemeniz başlatıldı!",
"onboarding": { "onboarding": {
"provisioning": "Ücretsiz kullanım hakkınız tanımlanıyor", "provisioning": "Ücretsiz kullanım hakkınız tanımlanıyor",
"step1": "Hesap doğrulanıyor", "step1": "Hesap doğrulanıyor",
@@ -175,7 +175,7 @@
"step3": "Full Paket aktif ediliyor", "step3": "Full Paket aktif ediliyor",
"step4": "Tamamlandı!", "step4": "Tamamlandı!",
"completed": "Tüm katalogları sınırsız test edebilirsiniz!", "completed": "Tüm katalogları sınırsız test edebilirsiniz!",
"trialDuration": "7 Gün Deneme", "trialDuration": "30 Gün Deneme",
"startSearching": "Şase Aramaya Başla", "startSearching": "Şase Aramaya Başla",
"error": "Deneme başlatılırken bir hata oluştu.", "error": "Deneme başlatılırken bir hata oluştu.",
"retry": "Tekrar Dene" "retry": "Tekrar Dene"

View File

@@ -1,10 +1,12 @@
import { createRootRouteWithContext, Outlet, useLocation } from "@tanstack/react-router"; import { createRootRouteWithContext, Link, Outlet, useLocation } from "@tanstack/react-router";
import { Toaster } from "@/lib/toast"; import { Toaster } from "@/lib/toast";
import type { QueryClient } from "@tanstack/react-query"; import type { QueryClient } from "@tanstack/react-query";
import { useEffect } from "react"; import { useEffect } from "react";
import { getUserSettings } from "@/lib/user-settings"; import { getUserSettings } from "@/lib/user-settings";
import { capturePageView, identifyUser, resetUser } from "@/lib/posthog"; import { capturePageView, identifyUser, resetUser } from "@/lib/posthog";
import { useAuth } from "@/hooks/use-auth"; import { useAuth } from "@/hooks/use-auth";
import { Button } from "@sase/ui";
import { ArrowLeft, Home, Search } from "lucide-react";
interface RouterContext { interface RouterContext {
queryClient: QueryClient; queryClient: QueryClient;
@@ -12,8 +14,71 @@ interface RouterContext {
export const Route = createRootRouteWithContext<RouterContext>()({ export const Route = createRootRouteWithContext<RouterContext>()({
component: RootComponent, component: RootComponent,
notFoundComponent: NotFoundComponent,
}); });
function NotFoundComponent() {
return (
<main className="relative flex min-h-screen flex-col items-center justify-center overflow-hidden px-6 py-12">
{/* Ambient brand glow */}
<div className="pointer-events-none absolute -left-32 top-1/4 h-[500px] w-[500px] rounded-full bg-brand/8 blur-[140px]" />
<div className="pointer-events-none absolute -right-32 bottom-1/4 h-[400px] w-[400px] rounded-full bg-brand/5 blur-[120px]" />
<div className="relative max-w-xl text-center">
<p className="font-mono text-sm font-medium uppercase tracking-[0.2em] text-muted-foreground">
404 sayfa bulunamadı
</p>
<h1 className="mt-6 font-[family-name:var(--font-display)] text-6xl font-bold tracking-tight sm:text-7xl">
Yanlış parça,
<br />
<span className="text-muted-foreground">yanlış adres.</span>
</h1>
<p className="mx-auto mt-6 max-w-md text-base text-muted-foreground">
Aradığın sayfa silinmiş ya da hiç olmamış olabilir. Aşağıdan ana sayfaya
dönebilir veya doğrudan şase aramaya gidebilirsin.
</p>
<div className="mt-10 flex flex-col items-center justify-center gap-3 sm:flex-row">
<Link to="/">
<Button variant="outline" className="rounded-full">
<ArrowLeft className="size-4" />
Ana sayfaya dön
</Button>
</Link>
<Link to="/dashboard/search">
<Button variant="brand" className="rounded-full">
<Search className="size-4" />
Şase aramaya git
</Button>
</Link>
</div>
<div className="mt-12 flex flex-wrap items-center justify-center gap-x-6 gap-y-2 text-sm text-muted-foreground">
<Link
to="/"
className="inline-flex items-center gap-1.5 transition-colors hover:text-foreground"
>
<Home className="size-3.5" />
Anasayfa
</Link>
<span className="size-1 rounded-full bg-border" aria-hidden="true" />
<Link to="/pricing" className="transition-colors hover:text-foreground">
Fiyatlandırma
</Link>
<span className="size-1 rounded-full bg-border" aria-hidden="true" />
<Link to="/demo" className="transition-colors hover:text-foreground">
Demo
</Link>
<span className="size-1 rounded-full bg-border" aria-hidden="true" />
<Link to="/contact" className="transition-colors hover:text-foreground">
İletişim
</Link>
</div>
</div>
</main>
);
}
function applyTheme(theme: "light" | "dark" | "system") { function applyTheme(theme: "light" | "dark" | "system") {
const isDark = const isDark =
theme === "dark" || theme === "dark" ||
@@ -59,6 +124,9 @@ function RootComponent() {
return ( return (
<> <>
<a href="#main-content" className="skip-link">
İçeriğe atla
</a>
<Outlet /> <Outlet />
<Toaster position="top-center" /> <Toaster position="top-center" />
</> </>

View File

@@ -9,7 +9,7 @@ function AuthLayout() {
return ( return (
<div className="flex min-h-screen"> <div className="flex min-h-screen">
{/* Left Panel — Form */} {/* Left Panel — Form */}
<div className="flex w-full flex-col justify-between px-6 py-8 lg:w-1/2"> <main id="main-content" className="flex w-full flex-col justify-between px-6 py-8 lg:w-1/2">
<div className="flex flex-1 items-center justify-center"> <div className="flex flex-1 items-center justify-center">
<div className="w-full max-w-md"> <div className="w-full max-w-md">
<Outlet /> <Outlet />
@@ -23,17 +23,38 @@ function AuthLayout() {
Sase.tr Sase.tr
</Link> </Link>
</div> </div>
</div> </main>
{/* Right Panel — Promo (always dark, hidden on mobile) */} {/* Right Panel — Promo (always dark via .dark scope, hidden on mobile) */}
<div className="hidden border-l border-white/5 bg-[#09090b] text-white lg:flex lg:w-1/2 lg:flex-col lg:justify-between lg:px-12 lg:py-12"> <div className="dark relative hidden overflow-hidden border-l border-border bg-background text-foreground lg:flex lg:w-1/2 lg:flex-col lg:justify-between lg:px-12 lg:py-12">
<div className="flex flex-1 flex-col justify-center space-y-8"> {/* Brand glow ambient */}
<div className="pointer-events-none absolute -left-32 top-0 h-[500px] w-[500px] rounded-full bg-brand/10 blur-[140px]" />
<div className="pointer-events-none absolute -right-24 bottom-0 h-[400px] w-[400px] rounded-full bg-brand/8 blur-[120px]" />
{/* Subtle grid */}
<div className="pointer-events-none absolute inset-0 opacity-[0.04]">
<svg width="100%" height="100%" aria-hidden="true">
<defs>
<pattern id="auth-grid" width="48" height="48" patternUnits="userSpaceOnUse">
<path d="M 48 0 L 0 0 0 48" fill="none" stroke="currentColor" strokeWidth="1" />
</pattern>
</defs>
<rect width="100%" height="100%" fill="url(#auth-grid)" />
</svg>
</div>
<div className="relative flex flex-1 flex-col justify-center space-y-8">
{/* Heading */} {/* Heading */}
<div className="space-y-3"> <div className="space-y-3">
<h2 className="text-3xl font-bold tracking-tight"> <span className="inline-flex items-center gap-2 rounded-full border border-border bg-surface/60 px-3 py-1 text-xs font-medium text-muted-foreground backdrop-blur-sm">
Doğru Parçayı İlk Seferde Bulun <span className="size-1.5 rounded-full bg-brand" />
Sase.tr
</span>
<h2 className="font-[family-name:var(--font-display)] text-4xl font-bold tracking-tight">
Doğru parçayı
<br />
<span className="text-foreground/60">ilk seferde bulun.</span>
</h2> </h2>
<p className="text-base leading-relaxed text-neutral-400"> <p className="text-base leading-relaxed text-muted-foreground">
Birden fazla katalogda çapraz sorgulama ile her zaman en güncel OEM Birden fazla katalogda çapraz sorgulama ile her zaman en güncel OEM
kodları. Şase numarasını girin, doğru parçayı saniyeler içinde kodları. Şase numarasını girin, doğru parçayı saniyeler içinde
bulun. bulun.
@@ -42,11 +63,11 @@ function AuthLayout() {
{/* Stats */} {/* Stats */}
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<span className="inline-flex items-center gap-1.5 rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-xs text-neutral-300"> <span className="inline-flex items-center gap-1.5 rounded-full border border-border bg-surface/60 px-3 py-1.5 text-xs text-foreground/80 backdrop-blur-sm">
<Zap className="size-3" /> <Zap className="size-3" />
1.2sn Sorgu 1.2sn Sorgu
</span> </span>
<span className="inline-flex items-center gap-1.5 rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-xs text-neutral-300"> <span className="inline-flex items-center gap-1.5 rounded-full border border-border bg-surface/60 px-3 py-1.5 text-xs text-foreground/80 backdrop-blur-sm">
<svg <svg
className="size-3" className="size-3"
viewBox="0 0 24 24" viewBox="0 0 24 24"
@@ -62,36 +83,36 @@ function AuthLayout() {
</svg> </svg>
27 Marka 27 Marka
</span> </span>
<span className="inline-flex items-center gap-1.5 rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-xs text-neutral-300"> <span className="inline-flex items-center gap-1.5 rounded-full border border-border bg-surface/60 px-3 py-1.5 text-xs text-foreground/80 backdrop-blur-sm">
<Database className="size-3" /> <Database className="size-3" />
243K+ OEM Parça <span className="tabular">243K+ OEM Parça</span>
</span> </span>
<span className="inline-flex items-center gap-1.5 rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-xs text-neutral-300"> <span className="inline-flex items-center gap-1.5 rounded-full border border-border bg-surface/60 px-3 py-1.5 text-xs text-foreground/80 backdrop-blur-sm">
<ShieldCheck className="size-3" /> <ShieldCheck className="size-3" />
%99.9 Uptime <span className="tabular">%99.9 Uptime</span>
</span> </span>
</div> </div>
{/* Brand row */} {/* Brand row */}
<p className="text-sm text-neutral-500"> <p className="text-sm text-muted-foreground">
BMW · Mercedes-Benz · Audi · VW · Fiat · Renault · Toyota · Honda · BMW · Mercedes-Benz · Audi · VW · Fiat · Renault · Toyota · Honda ·
Hyundai · Ford · Opel · Skoda Hyundai · Ford · Opel · Skoda
</p> </p>
{/* Testimonial */} {/* Testimonial */}
<div className="rounded-xl border border-white/10 bg-white/5 p-6"> <div className="rounded-2xl border border-border bg-surface/40 p-6 backdrop-blur-sm">
<p className="text-sm leading-relaxed text-neutral-300"> <p className="text-sm leading-relaxed text-foreground/85">
&ldquo;Sase.tr&apos;ye geçtiğimizden beri yanlış parça &ldquo;Sase.tr&apos;ye geçtiğimizden beri yanlış parça
siparişlerimiz neredeyse sıfıra indi. Aylık 40 saatin üzerinde siparişlerimiz neredeyse sıfıra indi. Aylık 40 saatin üzerinde
zaman tasarrufu sağlıyoruz.&rdquo; zaman tasarrufu sağlıyoruz.&rdquo;
</p> </p>
<div className="mt-4 flex items-center gap-3"> <div className="mt-4 flex items-center gap-3">
<div className="flex size-10 items-center justify-center rounded-full bg-white/10 text-sm font-medium"> <div className="flex size-10 items-center justify-center rounded-full bg-muted text-sm font-medium">
MK MK
</div> </div>
<div> <div>
<p className="text-sm font-medium">Mehmet K.</p> <p className="text-sm font-medium">Mehmet K.</p>
<p className="text-xs text-neutral-400"> <p className="text-xs text-muted-foreground">
Yedek Parça İşletme Sahibi Yedek Parça İşletme Sahibi
</p> </p>
</div> </div>
@@ -100,9 +121,9 @@ function AuthLayout() {
</div> </div>
{/* Bottom trial badge */} {/* Bottom trial badge */}
<div className="flex items-center gap-2 pt-6 text-sm text-neutral-400"> <div className="relative flex items-center gap-2 pt-6 text-sm text-muted-foreground">
<ShieldCheck className="size-4" /> <ShieldCheck className="size-4 text-brand" />
7 gün Full Paket ücretsiz deneyin kredi kartı gerekmez 30 gün Full Paket ücretsiz deneyin kredi kartı gerekmez
</div> </div>
</div> </div>
</div> </div>

View File

@@ -67,9 +67,9 @@ function RegisterPage() {
</p> </p>
{/* Trial messaging */} {/* Trial messaging */}
<div className="mt-3 flex items-center gap-2 rounded-lg bg-emerald-500/10 px-3 py-2 text-sm text-emerald-600 dark:text-emerald-400"> <div className="mt-3 flex items-center gap-2 rounded-lg border border-brand/20 bg-brand/10 px-3 py-2 text-sm text-foreground">
<ShieldCheck className="size-4 shrink-0" /> <ShieldCheck className="size-4 shrink-0 text-brand" />
7 gün Full Paket ücretsiz deneyin kredi kartı gerekmez 30 gün Full Paket ücretsiz deneyin kredi kartı gerekmez
</div> </div>
</div> </div>

View File

@@ -326,7 +326,7 @@ const POSTS: Record<string, BlogPost> = {
<h2 className="text-xl font-semibold text-foreground">Sonuç</h2> <h2 className="text-xl font-semibold text-foreground">Sonuç</h2>
<p> <p>
Sase.tr ile tek bir yanlış parça iadesinden tasarruf ettiğiniz para, aylık abonelik Sase.tr ile tek bir yanlış parça iadesinden tasarruf ettiğiniz para, aylık abonelik
ücretini karşılar. 7 günlük ücretsiz deneme süresiyle platformu bugün deneyin ücretini karşılar. 30 günlük ücretsiz deneme süresiyle platformu bugün deneyin
kredi kartı gerektirmez. kredi kartı gerektirmez.
</p> </p>
</div> </div>

View File

@@ -103,12 +103,17 @@ function NavLink({
<Link <Link
to={to} to={to}
title={collapsed ? label : undefined} title={collapsed ? label : undefined}
className={`flex items-center rounded-lg text-sm font-medium transition-colors hover:bg-accent [&.active]:bg-accent [&.active]:text-accent-foreground ${collapsed ? "justify-center p-2.5" : "gap-3 px-3 py-2"}`} className={`group relative flex items-center rounded-lg text-sm font-medium text-muted-foreground transition-all duration-200 hover:bg-accent hover:text-foreground [&.active]:bg-accent [&.active]:text-foreground [&.active]:font-semibold ${collapsed ? "justify-center p-2.5" : "gap-3 px-3 py-2"}`}
activeProps={{ className: "active" }} activeProps={{ className: "active" }}
activeOptions={exact ? { exact: true } : undefined} activeOptions={exact ? { exact: true } : undefined}
onClick={onClick} onClick={onClick}
> >
<Icon className="size-4 shrink-0" /> {/* Left accent bar — only visible when active */}
<span
className={`absolute left-0 top-1/2 h-5 w-0.5 -translate-y-1/2 rounded-r-full bg-brand opacity-0 transition-opacity duration-200 group-[.active]:opacity-100 ${collapsed ? "hidden" : ""}`}
aria-hidden="true"
/>
<Icon className="size-4 shrink-0 text-muted-foreground transition-colors duration-200 group-hover:text-foreground group-[.active]:text-brand" />
{!collapsed && <span>{label}</span>} {!collapsed && <span>{label}</span>}
</Link> </Link>
); );
@@ -376,7 +381,7 @@ function DashboardLayout() {
</header> </header>
{/* Page Content */} {/* Page Content */}
<main className="flex-1 overflow-auto bg-muted/30 p-4 sm:p-6"> <main id="main-content" className="flex-1 overflow-auto bg-muted/30 p-4 sm:p-6">
<Outlet /> <Outlet />
</main> </main>

View File

@@ -1,10 +1,13 @@
import { createFileRoute, Link } from "@tanstack/react-router"; import { useState } from "react";
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { api } from "@/lib/api-client"; import { api } from "@/lib/api-client";
import { useTranslation } from "@/lib/i18n"; import { useTranslation } from "@/lib/i18n";
import { Skeleton } from "@sase/ui"; import { Skeleton, cn } from "@sase/ui";
import { Library, Lock } from "lucide-react"; import { Button } from "@sase/ui";
import { ChevronRight, Columns2, LayoutGrid, Library, List, Lock } from "lucide-react";
import { CarBrandLogo } from "@/components/ui/car-brand-logo"; import { CarBrandLogo } from "@/components/ui/car-brand-logo";
import { getUserSettings, setUserSetting } from "@/lib/user-settings";
export const Route = createFileRoute("/dashboard/catalog/")({ export const Route = createFileRoute("/dashboard/catalog/")({
component: CatalogBrandsPage, component: CatalogBrandsPage,
@@ -21,6 +24,15 @@ interface CatalogBrand {
function CatalogBrandsPage() { function CatalogBrandsPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const [viewMode, setViewMode] = useState<"grid" | "tree" | "columns">(
() => getUserSettings().brandViewMode ?? "grid",
);
const changeViewMode = (mode: "grid" | "tree" | "columns") => {
setViewMode(mode);
setUserSetting("brandViewMode", mode);
};
const { data: brands, isLoading } = useQuery({ const { data: brands, isLoading } = useQuery({
queryKey: ["catalog-brands"], queryKey: ["catalog-brands"],
queryFn: () => api.get<CatalogBrand[]>("/catalog/brands"), queryFn: () => api.get<CatalogBrand[]>("/catalog/brands"),
@@ -28,9 +40,41 @@ function CatalogBrandsPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div> <div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">{t("catalog.title")}</h1> <div>
<p className="text-sm text-muted-foreground">{t("catalog.brands")}</p> <h1 className="text-2xl font-bold">{t("catalog.title")}</h1>
<p className="text-sm text-muted-foreground">{t("catalog.brands")}</p>
</div>
<div
role="tablist"
aria-label="Görünüm modu"
className="inline-flex items-center gap-0.5 rounded-lg border border-border bg-muted/40 p-0.5"
>
{(
[
{ mode: "grid" as const, Icon: LayoutGrid, label: "Izgara" },
{ mode: "tree" as const, Icon: List, label: "Liste" },
{ mode: "columns" as const, Icon: Columns2, label: "Sütun" },
]
).map(({ mode, Icon, label }) => (
<button
key={mode}
type="button"
role="tab"
aria-selected={viewMode === mode}
onClick={() => changeViewMode(mode)}
className={cn(
"inline-flex size-7 items-center justify-center rounded-md transition-all duration-200",
viewMode === mode
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
title={label}
>
<Icon className="size-3.5" />
</button>
))}
</div>
</div> </div>
{isLoading ? ( {isLoading ? (
@@ -44,36 +88,59 @@ function CatalogBrandsPage() {
<Library className="mb-4 size-12 text-muted-foreground/40" /> <Library className="mb-4 size-12 text-muted-foreground/40" />
<p className="text-muted-foreground">{t("catalog.noBrands")}</p> <p className="text-muted-foreground">{t("catalog.noBrands")}</p>
</div> </div>
) : ( ) : viewMode === "grid" ? (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5"> <div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
{brands.map((brand) => ( {brands.map((brand) => (
<BrandCard key={brand.brandName} brand={brand} /> <BrandCard key={brand.brandName} brand={brand} />
))} ))}
</div> </div>
) : viewMode === "tree" ? (
<BrandListTree brands={brands} />
) : (
<BrandListColumns brands={brands} />
)} )}
</div> </div>
); );
} }
/* ── Grid card (existing) ── */
function BrandCard({ brand }: { brand: CatalogBrand }) { function BrandCard({ brand }: { brand: CatalogBrand }) {
const { t } = useTranslation(); const { t } = useTranslation();
if (!brand.hasAccess) { if (!brand.hasAccess) {
return ( return (
<div className="relative flex flex-col items-center justify-center rounded-xl border border-border/50 bg-muted/30 p-4 text-center opacity-60 select-none"> <div className="group relative flex flex-col items-center justify-center overflow-hidden rounded-xl border border-border/50 bg-muted/30 p-4 text-center select-none">
{/* Diagonal stripe overlay for locked feel */}
<div
className="pointer-events-none absolute inset-0 opacity-[0.06]"
style={{
backgroundImage:
"repeating-linear-gradient(45deg, currentColor 0 1px, transparent 1px 8px)",
}}
aria-hidden="true"
/>
<div className="relative mb-2"> <div className="relative mb-2">
<CarBrandLogo brandName={brand.brandName} logoUrl={brand.logoUrl} size={40} /> <CarBrandLogo
<div className="absolute -right-1 -bottom-1 flex size-4 items-center justify-center rounded-full bg-muted-foreground/60"> brandName={brand.brandName}
logoUrl={brand.logoUrl}
size={40}
className="grayscale opacity-70"
/>
<div className="absolute -right-1 -bottom-1 flex size-4 items-center justify-center rounded-full bg-foreground">
<Lock className="size-2.5 text-background" /> <Lock className="size-2.5 text-background" />
</div> </div>
</div> </div>
<p className="text-sm font-semibold text-foreground">{brand.brandName}</p> <p className="relative text-sm font-semibold text-foreground/70">{brand.brandName}</p>
<p className="mt-1 text-xs text-muted-foreground">{t("catalog.locked")}</p> <p className="relative mt-1 text-[11px] uppercase tracking-wider text-muted-foreground/70">
{t("catalog.locked")}
</p>
<Link <Link
to="/dashboard/subscription" to="/dashboard/subscription"
className="mt-2 text-xs font-medium text-primary hover:underline" className="relative mt-2 inline-flex items-center gap-1 rounded-full text-xs font-medium text-brand transition-colors hover:text-brand/80"
> >
{t("catalog.upgradeCta")} {t("catalog.upgradeCta")}
<ChevronRight className="size-3" />
</Link> </Link>
</div> </div>
); );
@@ -84,10 +151,125 @@ function BrandCard({ brand }: { brand: CatalogBrand }) {
to="/dashboard/catalog/$brandName" to="/dashboard/catalog/$brandName"
params={{ brandName: encodeURIComponent(brand.brandName) }} params={{ brandName: encodeURIComponent(brand.brandName) }}
search={{ catalog: undefined }} search={{ catalog: undefined }}
className="flex flex-col items-center justify-center rounded-xl border border-border bg-card p-4 text-center transition-colors hover:bg-accent hover:border-accent-foreground/20" className="group flex flex-col items-center justify-center rounded-xl border border-border bg-card p-4 text-center transition-all duration-200 hover:-translate-y-0.5 hover:border-foreground/20 hover:shadow-[var(--shadow-md)]"
> >
<CarBrandLogo brandName={brand.brandName} logoUrl={brand.logoUrl} size={40} className="mb-2" /> <CarBrandLogo
brandName={brand.brandName}
logoUrl={brand.logoUrl}
size={40}
className="mb-2 transition-transform duration-200 group-hover:scale-105"
/>
<p className="text-sm font-semibold">{brand.brandName}</p> <p className="text-sm font-semibold">{brand.brandName}</p>
</Link> </Link>
); );
} }
/* ── Tree (flat list) ── */
function BrandListTree({ brands }: { brands: CatalogBrand[] }) {
const { t } = useTranslation();
return (
<div className="divide-y rounded-lg border">
{brands.map((brand) => {
if (!brand.hasAccess) {
return (
<div
key={brand.brandName}
className="flex items-center gap-3 px-4 py-3 opacity-50"
>
<CarBrandLogo brandName={brand.brandName} logoUrl={brand.logoUrl} size={24} />
<span className="flex-1 truncate text-sm font-medium">{brand.brandName}</span>
<Lock className="size-3.5 shrink-0 text-muted-foreground" />
</div>
);
}
return (
<Link
key={brand.brandName}
to="/dashboard/catalog/$brandName"
params={{ brandName: encodeURIComponent(brand.brandName) }}
search={{ catalog: undefined }}
className="flex items-center gap-3 px-4 py-3 transition-colors hover:bg-accent"
>
<CarBrandLogo brandName={brand.brandName} logoUrl={brand.logoUrl} size={24} />
<span className="flex-1 truncate text-sm font-medium">{brand.brandName}</span>
<ChevronRight className="size-4 shrink-0 text-muted-foreground" />
</Link>
);
})}
</div>
);
}
/* ── Columns (left: brand list, right: detail + CTA) ── */
function BrandListColumns({ brands }: { brands: CatalogBrand[] }) {
const { t } = useTranslation();
const [selectedName, setSelectedName] = useState<string | null>(null);
const navigate = useNavigate();
const selected = brands.find((b) => b.brandName === selectedName) ?? null;
return (
<div className="flex border rounded-lg overflow-hidden" style={{ minHeight: 320 }}>
{/* Left panel */}
<div className="w-[240px] shrink-0 border-r overflow-y-auto" style={{ maxHeight: 480 }}>
{brands.map((brand) => (
<button
key={brand.brandName}
type="button"
onClick={() => setSelectedName(brand.brandName)}
disabled={!brand.hasAccess}
className={cn(
"flex w-full items-center gap-2 px-3 py-2.5 text-left text-sm transition-colors",
brand.hasAccess ? "hover:bg-accent" : "opacity-50 cursor-not-allowed",
selectedName === brand.brandName && "bg-accent font-medium",
)}
>
<CarBrandLogo brandName={brand.brandName} logoUrl={brand.logoUrl} size={20} />
<span className="flex-1 truncate">{brand.brandName}</span>
{brand.hasAccess ? (
<ChevronRight className="size-3.5 shrink-0 text-muted-foreground" />
) : (
<Lock className="size-3 shrink-0 text-muted-foreground" />
)}
</button>
))}
</div>
{/* Right panel */}
<div className="flex-1 flex flex-col items-center justify-center p-6 text-center">
{selected ? (
<div className="space-y-4">
<CarBrandLogo brandName={selected.brandName} logoUrl={selected.logoUrl} size={56} />
<p className="text-lg font-semibold">{selected.brandName}</p>
{selected.hasAccess ? (
<Button
onClick={() =>
navigate({
to: "/dashboard/catalog/$brandName",
params: { brandName: encodeURIComponent(selected.brandName) },
search: { catalog: undefined },
})
}
>
Modellere Git
</Button>
) : (
<div className="space-y-2">
<p className="text-sm text-muted-foreground">{t("catalog.locked")}</p>
<Button variant="outline" asChild>
<Link to="/dashboard/subscription">{t("catalog.upgradeCta")}</Link>
</Button>
</div>
)}
</div>
) : (
<p className="text-sm text-muted-foreground">Soldan bir marka seçin</p>
)}
</div>
</div>
);
}

View File

@@ -1,9 +1,13 @@
import { useState } from "react";
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { api } from "@/lib/api-client"; import { api } from "@/lib/api-client";
import { useTranslation } from "@/lib/i18n"; import { useTranslation } from "@/lib/i18n";
import { Button, Skeleton } from "@sase/ui"; import { Button, Skeleton, cn } from "@sase/ui";
import { ArrowLeft, BookOpen, Car, ChevronRight, Loader2 } from "lucide-react"; import { ArrowLeft, BookOpen, Car, ChevronRight, Columns2, LayoutGrid, List, Loader2 } from "lucide-react";
import { ModelListTree } from "@/components/catalog/model-list-tree";
import { ModelListColumns } from "@/components/catalog/model-list-columns";
import { getUserSettings, setUserSetting } from "@/lib/user-settings";
export const Route = createFileRoute("/dashboard/catalog_/$brandName/")({ export const Route = createFileRoute("/dashboard/catalog_/$brandName/")({
validateSearch: (search: Record<string, unknown>) => ({ validateSearch: (search: Record<string, unknown>) => ({
@@ -27,6 +31,7 @@ interface CatalogVehicle {
bodyType: string | null; bodyType: string | null;
transmission: string | null; transmission: string | null;
architecture: string | null; architecture: string | null;
catalogPath: string | null;
} }
function CatalogModelsPage() { function CatalogModelsPage() {
@@ -37,6 +42,15 @@ function CatalogModelsPage() {
const decodedBrandName = decodeURIComponent(brandName); const decodedBrandName = decodeURIComponent(brandName);
const [viewMode, setViewMode] = useState<"grid" | "tree" | "columns">(
() => getUserSettings().modelViewMode ?? "grid",
);
const changeViewMode = (mode: "grid" | "tree" | "columns") => {
setViewMode(mode);
setUserSetting("modelViewMode", mode);
};
// Always fetch catalogs to know whether this brand has multiple sub-catalogs // Always fetch catalogs to know whether this brand has multiple sub-catalogs
const { data: catalogs, isLoading: catalogsLoading } = useQuery({ const { data: catalogs, isLoading: catalogsLoading } = useQuery({
queryKey: ["catalog-catalogs", decodedBrandName], queryKey: ["catalog-catalogs", decodedBrandName],
@@ -139,10 +153,46 @@ function CatalogModelsPage() {
<p className="text-muted-foreground">{t("catalog.noModels")}</p> <p className="text-muted-foreground">{t("catalog.noModels")}</p>
</div> </div>
) : ( ) : (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3"> <div className="space-y-3">
{models.map((model) => ( {/* View toggle */}
<ModelCard key={model.id} model={model} brandName={brandName} /> <div className="flex items-center justify-end gap-1">
))} <button
type="button"
onClick={() => changeViewMode("grid")}
className={cn("rounded p-1.5", viewMode === "grid" ? "bg-accent" : "text-muted-foreground hover:text-foreground")}
title="Izgara"
>
<LayoutGrid className="size-4" />
</button>
<button
type="button"
onClick={() => changeViewMode("tree")}
className={cn("rounded p-1.5", viewMode === "tree" ? "bg-accent" : "text-muted-foreground hover:text-foreground")}
title="Liste"
>
<List className="size-4" />
</button>
<button
type="button"
onClick={() => changeViewMode("columns")}
className={cn("rounded p-1.5", viewMode === "columns" ? "bg-accent" : "text-muted-foreground hover:text-foreground")}
title="Sutun"
>
<Columns2 className="size-4" />
</button>
</div>
{viewMode === "grid" ? (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{models.map((model) => (
<ModelCard key={model.id} model={model} brandName={brandName} />
))}
</div>
) : viewMode === "tree" ? (
<ModelListTree models={models} brandName={brandName} />
) : (
<ModelListColumns models={models} brandName={brandName} />
)}
</div> </div>
)} )}
</div> </div>
@@ -196,7 +246,7 @@ function ModelCard({ model, brandName }: { model: CatalogVehicle; brandName: str
<Link <Link
to="/dashboard/catalog/$brandName/$modelId" to="/dashboard/catalog/$brandName/$modelId"
params={{ brandName, modelId: model.id }} params={{ brandName, modelId: model.id }}
search={{ body: undefined, engine: undefined, gearbox: undefined }} search={{ body: undefined, engine: undefined, gearbox: undefined, mgp: undefined }}
className="flex flex-col rounded-lg border border-border bg-card p-4 transition-colors hover:bg-accent hover:border-accent-foreground/20" className="flex flex-col rounded-lg border border-border bg-card p-4 transition-colors hover:bg-accent hover:border-accent-foreground/20"
> >
<p className="font-semibold">{model.model}</p> <p className="font-semibold">{model.model}</p>

View File

@@ -1,11 +1,14 @@
import { lazy, Suspense } from "react"; import { lazy, Suspense, useState } from "react";
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { api } from "@/lib/api-client"; import { api } from "@/lib/api-client";
import { useTranslation } from "@/lib/i18n"; import { useTranslation } from "@/lib/i18n";
import { Button, Skeleton } from "@sase/ui"; import { Button, Skeleton, cn } from "@sase/ui";
import { ArrowLeft } from "lucide-react"; import { ArrowLeft, Columns2, LayoutGrid, List } from "lucide-react";
import { CategoryGrid } from "@/components/categories/category-grid"; import { CategoryGrid } from "@/components/categories/category-grid";
import { CategoryTree } from "@/components/categories/category-tree";
import { CategoryColumns } from "@/components/categories/category-columns";
import { getUserSettings, setUserSetting } from "@/lib/user-settings";
const SchemaViewer = lazy(() => const SchemaViewer = lazy(() =>
import("@/components/schema/schema-viewer").then((mod) => ({ import("@/components/schema/schema-viewer").then((mod) => ({
@@ -36,6 +39,7 @@ export const Route = createFileRoute(
body: typeof search.body === "string" ? search.body : undefined, body: typeof search.body === "string" ? search.body : undefined,
engine: typeof search.engine === "string" ? search.engine : undefined, engine: typeof search.engine === "string" ? search.engine : undefined,
gearbox: typeof search.gearbox === "string" ? search.gearbox : undefined, gearbox: typeof search.gearbox === "string" ? search.gearbox : undefined,
mgp: typeof search.mgp === "string" ? search.mgp : undefined,
}), }),
component: CatalogCategoryPage, component: CatalogCategoryPage,
}); });
@@ -59,7 +63,17 @@ function CatalogCategoryPage() {
const engine = search.engine; const engine = search.engine;
const gearbox = search.gearbox; const gearbox = search.gearbox;
const variantSearch = body || engine || gearbox ? { body, engine, gearbox } : undefined; const mgp = search.mgp;
const variantSearch = body || engine || gearbox || mgp ? { body, engine, gearbox, mgp } : undefined;
const [viewMode, setViewMode] = useState<"grid" | "tree" | "columns">(
() => getUserSettings().categoryViewMode ?? "grid",
);
const changeViewMode = (mode: "grid" | "tree" | "columns") => {
setViewMode(mode);
setUserSetting("categoryViewMode", mode);
};
const { data, isLoading, error } = useQuery({ const { data, isLoading, error } = useQuery({
queryKey: ["catalog-category", modelId, categoryId, body, engine, gearbox], queryKey: ["catalog-category", modelId, categoryId, body, engine, gearbox],
@@ -77,13 +91,13 @@ function CatalogCategoryPage() {
navigate({ navigate({
to: "/dashboard/catalog/$brandName/$modelId/categories/$categoryId", to: "/dashboard/catalog/$brandName/$modelId/categories/$categoryId",
params: { brandName, modelId, categoryId: data.parentId }, params: { brandName, modelId, categoryId: data.parentId },
search: variantSearch ?? { body: undefined, engine: undefined, gearbox: undefined }, search: variantSearch ?? { body: undefined, engine: undefined, gearbox: undefined, mgp: undefined },
}); });
} else { } else {
navigate({ navigate({
to: "/dashboard/catalog/$brandName/$modelId", to: "/dashboard/catalog/$brandName/$modelId",
params: { brandName, modelId }, params: { brandName, modelId },
search: variantSearch ?? { body: undefined, engine: undefined, gearbox: undefined }, search: variantSearch ?? { body: undefined, engine: undefined, gearbox: undefined, mgp: undefined },
}); });
} }
}; };
@@ -111,30 +125,78 @@ function CatalogCategoryPage() {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{/* Header */} {/* Header */}
<div className="flex items-center gap-3"> <div className="flex items-center justify-between">
<Button variant="ghost" size="icon" onClick={handleBack} title={t("common.back")}> <div className="flex items-center gap-3">
<ArrowLeft className="size-4" /> <Button variant="ghost" size="icon" onClick={handleBack} title={t("common.back")}>
</Button> <ArrowLeft className="size-4" />
<div> </Button>
<div className="text-xs text-muted-foreground"> <div>
<Link to="/dashboard/catalog" className="hover:underline"> <div className="text-xs text-muted-foreground">
{t("catalog.title")} <Link to="/dashboard/catalog" className="hover:underline">
</Link> {t("catalog.title")}
</Link>
</div>
<h1 className="text-xl font-bold">{data?.name || t("catalog.categories")}</h1>
</div> </div>
<h1 className="text-xl font-bold">{data?.name || t("catalog.categories")}</h1>
</div> </div>
{hasChildren && (
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => changeViewMode("grid")}
className={cn("rounded p-1.5", viewMode === "grid" ? "bg-accent" : "text-muted-foreground hover:text-foreground")}
title="Izgara"
>
<LayoutGrid className="size-4" />
</button>
<button
type="button"
onClick={() => changeViewMode("tree")}
className={cn("rounded p-1.5", viewMode === "tree" ? "bg-accent" : "text-muted-foreground hover:text-foreground")}
title="Agac"
>
<List className="size-4" />
</button>
<button
type="button"
onClick={() => changeViewMode("columns")}
className={cn("rounded p-1.5", viewMode === "columns" ? "bg-accent" : "text-muted-foreground hover:text-foreground")}
title="Sutun"
>
<Columns2 className="size-4" />
</button>
</div>
)}
</div> </div>
{/* Content */} {/* Content */}
{hasChildren ? ( {hasChildren ? (
<CategoryGrid viewMode === "grid" ? (
categories={data.children} <CategoryGrid
vehicleId={modelId} categories={data.children}
catalogMode vehicleId={modelId}
brandName={brandName} catalogMode
parentId={categoryId} brandName={brandName}
variantSearch={variantSearch} parentId={categoryId}
/> variantSearch={variantSearch}
/>
) : viewMode === "tree" ? (
<CategoryTree
categories={data.children}
vehicleId={modelId}
catalogMode
brandName={brandName}
variantSearch={variantSearch}
/>
) : (
<CategoryColumns
categories={data.children}
vehicleId={modelId}
catalogMode
brandName={brandName}
variantSearch={variantSearch}
/>
)
) : ( ) : (
<Suspense fallback={<SchemaViewerFallback />}> <Suspense fallback={<SchemaViewerFallback />}>
<SchemaViewer <SchemaViewer

View File

@@ -4,11 +4,13 @@ import { useQuery } from "@tanstack/react-query";
import { api } from "@/lib/api-client"; import { api } from "@/lib/api-client";
import { useTranslation } from "@/lib/i18n"; import { useTranslation } from "@/lib/i18n";
import { Button, Skeleton, Card, CardContent, CardHeader, CardTitle } from "@sase/ui"; import { Button, Skeleton, Card, CardContent, CardHeader, CardTitle } from "@sase/ui";
import { ArrowLeft, LayoutGrid, List } from "lucide-react"; import { ArrowLeft, Columns2, LayoutGrid, List } from "lucide-react";
import { CategoryGrid } from "@/components/categories/category-grid"; import { CategoryGrid } from "@/components/categories/category-grid";
import { CategoryTree } from "@/components/categories/category-tree"; import { CategoryTree } from "@/components/categories/category-tree";
import { CategoryColumns } from "@/components/categories/category-columns";
import { PsaVariantSelector } from "@/components/catalog/psa-variant-selector"; import { PsaVariantSelector } from "@/components/catalog/psa-variant-selector";
import { FordVariantSelector } from "@/components/catalog/ford-variant-selector"; import { FordVariantSelector } from "@/components/catalog/ford-variant-selector";
import { P5RestrictionSelector } from "@/components/catalog/p5-restriction-selector";
import { getUserSettings, setUserSetting } from "@/lib/user-settings"; import { getUserSettings, setUserSetting } from "@/lib/user-settings";
export const Route = createFileRoute("/dashboard/catalog_/$brandName_/$modelId/")({ export const Route = createFileRoute("/dashboard/catalog_/$brandName_/$modelId/")({
@@ -16,6 +18,7 @@ export const Route = createFileRoute("/dashboard/catalog_/$brandName_/$modelId/"
body: typeof search.body === "string" ? search.body : undefined, body: typeof search.body === "string" ? search.body : undefined,
engine: typeof search.engine === "string" ? search.engine : undefined, engine: typeof search.engine === "string" ? search.engine : undefined,
gearbox: typeof search.gearbox === "string" ? search.gearbox : undefined, gearbox: typeof search.gearbox === "string" ? search.gearbox : undefined,
mgp: typeof search.mgp === "string" ? search.mgp : undefined,
}), }),
component: CatalogVehiclePage, component: CatalogVehiclePage,
}); });
@@ -38,15 +41,16 @@ function CatalogVehiclePage() {
const body = search.body; const body = search.body;
const engine = search.engine; const engine = search.engine;
const gearbox = search.gearbox; const gearbox = search.gearbox;
const hasVariant = !!(body || engine || gearbox); const mgp = search.mgp;
const hasVariant = !!(body || engine || gearbox || mgp);
const [viewMode, setViewMode] = useState<"grid" | "tree">( const [viewMode, setViewMode] = useState<"grid" | "tree" | "columns">(
() => getUserSettings().categoryViewMode ?? "grid", () => getUserSettings().categoryViewMode ?? "grid",
); );
const decodedBrandName = decodeURIComponent(brandName); const decodedBrandName = decodeURIComponent(brandName);
const changeViewMode = (mode: "grid" | "tree") => { const changeViewMode = (mode: "grid" | "tree" | "columns") => {
setViewMode(mode); setViewMode(mode);
setUserSetting("categoryViewMode", mode); setUserSetting("categoryViewMode", mode);
}; };
@@ -62,19 +66,39 @@ function CatalogVehiclePage() {
"LEGACY_FORD", "LEGACY_FORD",
"LEGACY_VOLVO", "LEGACY_VOLVO",
].includes(vehicle?.architecture); ].includes(vehicle?.architecture);
const isP5WithRestrictions =
vehicle?.architecture === "P5_MODERN" &&
!!vehicle?.catalogPath &&
!vehicle.catalogPath.includes("/mainGroup");
const showPsaVariantSelector = isPsa && !hasVariant; const showPsaVariantSelector = isPsa && !hasVariant;
const showFordVariantSelector = isP4Legacy && !hasVariant; const showFordVariantSelector = isP4Legacy && !hasVariant;
const showVariantSelector = showPsaVariantSelector || showFordVariantSelector; const showP5RestrictionSelector = isP5WithRestrictions && !hasVariant;
const showVariantSelector = showPsaVariantSelector || showFordVariantSelector || showP5RestrictionSelector;
const variantSearch = hasVariant ? { body, engine, gearbox } : undefined; const variantSearch = hasVariant ? { body, engine, gearbox, mgp } : undefined;
const { data: categoryTree, isLoading: categoriesLoading } = useQuery({ const { data: categoryTree, isLoading: categoriesLoading } = useQuery({
queryKey: ["catalog-category-tree", modelId, body, engine, gearbox], queryKey: ["catalog-category-tree", modelId, body, engine, gearbox, mgp],
queryFn: () => queryFn: () => {
api.get<any[]>(`/catalog/vehicles/${modelId}/categories${buildVariantQuery(body, engine, gearbox)}`), const params = new URLSearchParams();
if (body) params.set("body", body);
if (engine) params.set("engine", engine);
if (gearbox) params.set("gearbox", gearbox);
if (mgp) params.set("mgp", mgp);
const qs = params.toString();
return api.get<any[]>(`/catalog/vehicles/${modelId}/categories${qs ? `?${qs}` : ""}`);
},
enabled: !!modelId && !vehicleLoading && !showVariantSelector, enabled: !!modelId && !vehicleLoading && !showVariantSelector,
}); });
const handleP5RestrictionComplete = (mainGroupsPath: string) => {
navigate({
to: "/dashboard/catalog/$brandName/$modelId",
params: { brandName, modelId },
search: { mgp: mainGroupsPath, body: undefined, engine: undefined, gearbox: undefined },
});
};
const handleVariantSelect = (selectedBody: string, selectedEngine: string, selectedGearbox: string) => { const handleVariantSelect = (selectedBody: string, selectedEngine: string, selectedGearbox: string) => {
const norm = (v: string) => (v && v !== "_all_" && v !== "_nor_" ? v : undefined); const norm = (v: string) => (v && v !== "_all_" && v !== "_nor_" ? v : undefined);
navigate({ navigate({
@@ -87,6 +111,7 @@ function CatalogVehiclePage() {
body: norm(selectedBody) ?? (selectedBody === "_nor_" ? "_nor_" : undefined), body: norm(selectedBody) ?? (selectedBody === "_nor_" ? "_nor_" : undefined),
engine: norm(selectedEngine), engine: norm(selectedEngine),
gearbox: norm(selectedGearbox), gearbox: norm(selectedGearbox),
mgp: undefined,
}, },
}); });
}; };
@@ -103,21 +128,22 @@ function CatalogVehiclePage() {
return ( return (
<div className="mx-auto max-w-4xl space-y-6"> <div className="mx-auto max-w-4xl space-y-6">
{/* Header / Breadcrumb */} {/* Header / Breadcrumb */}
<div className="flex items-center gap-3"> <div className="flex items-center justify-between">
<Button <div className="flex items-center gap-3">
variant="ghost" <Button
size="icon" variant="ghost"
onClick={() => size="icon"
navigate({ onClick={() =>
to: "/dashboard/catalog/$brandName", navigate({
params: { brandName }, to: "/dashboard/catalog/$brandName",
search: { catalog: undefined }, params: { brandName },
}) search: { catalog: undefined },
} })
> }
<ArrowLeft className="size-4" /> >
</Button> <ArrowLeft className="size-4" />
<div> </Button>
<div>
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
<Link to="/dashboard/catalog" className="hover:underline"> <Link to="/dashboard/catalog" className="hover:underline">
{t("catalog.title")} {t("catalog.title")}
@@ -153,6 +179,34 @@ function CatalogVehiclePage() {
{vehicle?.model} {vehicle?.model}
{vehicle?.year && <span className="ml-2 text-base font-normal text-muted-foreground">({vehicle.year})</span>} {vehicle?.year && <span className="ml-2 text-base font-normal text-muted-foreground">({vehicle.year})</span>}
</h1> </h1>
</div>
</div>
{/* View toggle — always visible */}
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => changeViewMode("grid")}
className={`rounded p-1.5 ${viewMode === "grid" ? "bg-accent" : "text-muted-foreground hover:text-foreground"}`}
title="Izgara"
>
<LayoutGrid className="size-4" />
</button>
<button
type="button"
onClick={() => changeViewMode("tree")}
className={`rounded p-1.5 ${viewMode === "tree" ? "bg-accent" : "text-muted-foreground hover:text-foreground"}`}
title="Agac"
>
<List className="size-4" />
</button>
<button
type="button"
onClick={() => changeViewMode("columns")}
className={`rounded p-1.5 ${viewMode === "columns" ? "bg-accent" : "text-muted-foreground hover:text-foreground"}`}
title="Sutun"
>
<Columns2 className="size-4" />
</button>
</div> </div>
</div> </div>
@@ -198,28 +252,14 @@ function CatalogVehiclePage() {
<PsaVariantSelector vehicleId={modelId} onSelect={handleVariantSelect} /> <PsaVariantSelector vehicleId={modelId} onSelect={handleVariantSelect} />
) : showFordVariantSelector ? ( ) : showFordVariantSelector ? (
<FordVariantSelector vehicleId={modelId} onSelect={handleVariantSelect} /> <FordVariantSelector vehicleId={modelId} onSelect={handleVariantSelect} />
) : showP5RestrictionSelector ? (
<P5RestrictionSelector vehicleId={modelId} onComplete={handleP5RestrictionComplete} />
) : ( ) : (
<Card> <Card>
<CardHeader className="flex flex-row items-center justify-between"> <CardHeader>
<CardTitle className="text-base">{t("catalog.categories")}</CardTitle> <CardTitle className="text-base">{t("catalog.categories")}</CardTitle>
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => changeViewMode("grid")}
className={`rounded p-1.5 ${viewMode === "grid" ? "bg-accent" : "text-muted-foreground hover:text-foreground"}`}
>
<LayoutGrid className="size-4" />
</button>
<button
type="button"
onClick={() => changeViewMode("tree")}
className={`rounded p-1.5 ${viewMode === "tree" ? "bg-accent" : "text-muted-foreground hover:text-foreground"}`}
>
<List className="size-4" />
</button>
</div>
</CardHeader> </CardHeader>
<CardContent> <CardContent className={viewMode === "columns" ? "p-0 overflow-hidden rounded-b-lg" : undefined}>
{categoriesLoading ? ( {categoriesLoading ? (
<div className="space-y-2"> <div className="space-y-2">
{Array.from({ length: 8 }).map((_, i) => ( {Array.from({ length: 8 }).map((_, i) => (
@@ -234,7 +274,7 @@ function CatalogVehiclePage() {
brandName={brandName} brandName={brandName}
variantSearch={variantSearch} variantSearch={variantSearch}
/> />
) : ( ) : viewMode === "tree" ? (
<CategoryTree <CategoryTree
categories={categoryTree || []} categories={categoryTree || []}
vehicleId={modelId} vehicleId={modelId}
@@ -242,6 +282,14 @@ function CatalogVehiclePage() {
brandName={brandName} brandName={brandName}
variantSearch={variantSearch} variantSearch={variantSearch}
/> />
) : (
<CategoryColumns
categories={categoryTree || []}
vehicleId={modelId}
catalogMode
brandName={brandName}
variantSearch={variantSearch}
/>
)} )}
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -309,7 +309,7 @@ function DashboardHome() {
<h3 className="text-lg font-bold"> <h3 className="text-lg font-bold">
{subscription.plan?.name ?? "Aktif Plan"} {subscription.plan?.name ?? "Aktif Plan"}
</h3> </h3>
<Badge variant="default" className="bg-emerald-600 text-xs"> <Badge variant="default" className="bg-brand text-xs text-brand-foreground hover:bg-brand/90">
{subscription.status === "trial" ? "Deneme" : "Aktif"} {subscription.status === "trial" ? "Deneme" : "Aktif"}
</Badge> </Badge>
</div> </div>
@@ -371,7 +371,7 @@ function DashboardHome() {
key={f} key={f}
className="flex items-center gap-1.5 text-sm text-muted-foreground" className="flex items-center gap-1.5 text-sm text-muted-foreground"
> >
<CheckCircle2 className="size-3.5 text-emerald-500" /> <CheckCircle2 className="size-3.5 text-brand" />
{f} {f}
</span> </span>
))} ))}

View File

@@ -304,7 +304,7 @@ function SearchPage() {
<div <div
key={i} key={i}
className={`h-1.5 flex-1 rounded-full transition-colors duration-200 ${ className={`h-1.5 flex-1 rounded-full transition-colors duration-200 ${
i < vin.length ? "bg-emerald-500" : "bg-muted" i < vin.length ? "bg-brand" : "bg-muted"
}`} }`}
/> />
))} ))}
@@ -398,10 +398,10 @@ function SearchPage() {
)} )}
{preview && !previewLoading && ( {preview && !previewLoading && (
<div className="rounded-2xl border border-emerald-500/30 bg-background p-5 sm:p-6"> <div className="rounded-2xl border border-brand/30 bg-background p-5 sm:p-6">
<div className="flex items-start gap-4"> <div className="flex items-start gap-4">
<div className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-emerald-500/10"> <div className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-brand/10">
<Car className="size-5 text-emerald-500" /> <Car className="size-5 text-brand" />
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="font-[family-name:var(--font-display)] text-lg font-bold"> <p className="font-[family-name:var(--font-display)] text-lg font-bold">
@@ -414,7 +414,7 @@ function SearchPage() {
<div className="mt-3 flex flex-wrap gap-2"> <div className="mt-3 flex flex-wrap gap-2">
<Badge <Badge
variant="default" variant="default"
className="bg-emerald-600 text-xs text-white" className="bg-brand text-xs text-brand-foreground hover:bg-brand/90"
> >
Araç tanımlandı Araç tanımlandı
</Badge> </Badge>

View File

@@ -296,12 +296,12 @@ function SubscriptionPage() {
if (onboardingPhase === "provisioning") { if (onboardingPhase === "provisioning") {
return ( return (
<div className="mx-auto flex max-w-2xl flex-col items-center justify-center px-4 py-12"> <div className="mx-auto flex max-w-2xl flex-col items-center justify-center px-4 py-12">
<Card className="relative w-full overflow-hidden border-emerald-500/30 bg-gradient-to-br from-emerald-50/50 to-teal-50/50 dark:from-emerald-950/20 dark:to-teal-950/20"> <Card className="relative w-full overflow-hidden border-brand/25 bg-brand/5">
<div className="absolute inset-0 bg-gradient-to-r from-emerald-500/5 to-teal-500/5" /> <div className="pointer-events-none absolute -right-24 top-0 h-[300px] w-[300px] rounded-full bg-brand/15 blur-[100px]" />
<CardContent className="relative flex flex-col items-center gap-6 py-10"> <CardContent className="relative flex flex-col items-center gap-6 py-10">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Sparkles className="h-6 w-6 animate-pulse text-emerald-600 dark:text-emerald-400" /> <Sparkles className="h-6 w-6 animate-pulse text-brand" />
<h2 className="text-xl font-bold text-emerald-900 dark:text-emerald-100"> <h2 className="text-xl font-bold text-foreground">
{t("subscription.onboarding.provisioning")} {t("subscription.onboarding.provisioning")}
</h2> </h2>
</div> </div>
@@ -309,7 +309,7 @@ function SubscriptionPage() {
<Suspense <Suspense
fallback={ fallback={
<div className="flex h-[200px] w-full items-center justify-center"> <div className="flex h-[200px] w-full items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-emerald-600" /> <Loader2 className="h-8 w-8 animate-spin text-brand" />
</div> </div>
} }
> >
@@ -326,7 +326,7 @@ function SubscriptionPage() {
{/* If animation finished but mutation still pending */} {/* If animation finished but mutation still pending */}
{animationEnded && trialMutation.isPending && ( {animationEnded && trialMutation.isPending && (
<div className="flex items-center gap-2 text-sm text-emerald-700 dark:text-emerald-300"> <div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className="h-4 w-4 animate-spin" />
{t("subscription.onboarding.step4")}... {t("subscription.onboarding.step4")}...
</div> </div>
@@ -357,20 +357,20 @@ function SubscriptionPage() {
const freshSub = subData?.subscription; const freshSub = subData?.subscription;
return ( return (
<div className="mx-auto flex max-w-2xl flex-col items-center justify-center px-4 py-12"> <div className="mx-auto flex max-w-2xl flex-col items-center justify-center px-4 py-12">
<Card className="relative w-full overflow-hidden border-emerald-500/30 bg-gradient-to-br from-emerald-50/50 to-teal-50/50 dark:from-emerald-950/20 dark:to-teal-950/20"> <Card className="relative w-full overflow-hidden border-brand/25 bg-brand/5">
<div className="absolute inset-0 bg-gradient-to-r from-emerald-500/5 to-teal-500/5" /> <div className="pointer-events-none absolute -right-24 top-0 h-[300px] w-[300px] rounded-full bg-brand/15 blur-[100px]" />
<CardContent className="relative flex flex-col items-center gap-6 py-10"> <CardContent className="relative flex flex-col items-center gap-6 py-10">
<CheckCircle2 className="h-16 w-16 text-emerald-500" /> <CheckCircle2 className="h-16 w-16 text-brand" />
<h2 className="text-center text-2xl font-bold text-emerald-900 dark:text-emerald-100"> <h2 className="text-center text-2xl font-bold text-foreground">
{t("subscription.onboarding.completed")} {t("subscription.onboarding.completed")}
</h2> </h2>
{/* Subscription info box */} {/* Subscription info box */}
<div className="w-full max-w-md space-y-4 rounded-xl border border-emerald-200 bg-white/60 p-5 dark:border-emerald-800 dark:bg-white/5"> <div className="w-full max-w-md space-y-4 rounded-xl border border-brand/20 bg-background/60 p-5 backdrop-blur-sm dark:bg-background/30">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">{t("subscription.currentPlan")}</span> <span className="text-sm text-muted-foreground">{t("subscription.currentPlan")}</span>
<Badge className="bg-emerald-600 text-white">Full Paket</Badge> <Badge className="bg-brand text-brand-foreground">Full Paket</Badge>
</div> </div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">{t("subscription.billingPeriod")}</span> <span className="text-sm text-muted-foreground">{t("subscription.billingPeriod")}</span>
@@ -387,8 +387,8 @@ function SubscriptionPage() {
<Separator /> <Separator />
<ul className="space-y-2 text-sm"> <ul className="space-y-2 text-sm">
{["allBrands", "vinSearch", "partsCatalog", "schemaViewer"].map((f) => ( {["allBrands", "vinSearch", "partsCatalog", "schemaViewer"].map((f) => (
<li key={f} className="flex items-center gap-2 text-emerald-800 dark:text-emerald-200"> <li key={f} className="flex items-center gap-2 text-foreground/85">
<Check className="h-4 w-4 text-emerald-600 dark:text-emerald-400" /> <Check className="h-4 w-4 text-brand" />
{t(`subscription.features.${f}`)} {t(`subscription.features.${f}`)}
</li> </li>
))} ))}
@@ -397,7 +397,7 @@ function SubscriptionPage() {
<Button <Button
size="lg" size="lg"
className="bg-emerald-600 hover:bg-emerald-700 text-white" className="bg-brand text-brand-foreground hover:bg-brand/90"
onClick={() => navigate({ to: "/dashboard/search" })} onClick={() => navigate({ to: "/dashboard/search" })}
> >
{t("subscription.onboarding.startSearching")} {t("subscription.onboarding.startSearching")}
@@ -471,7 +471,7 @@ function SubscriptionPage() {
</div> </div>
{subscription.status === "trial" && subscription.endDate && ( {subscription.status === "trial" && subscription.endDate && (
<div className="flex items-center gap-2 rounded-lg bg-emerald-500/10 px-3 py-2 text-sm text-emerald-600 dark:text-emerald-400"> <div className="flex items-center gap-2 rounded-lg bg-brand/10 px-3 py-2 text-sm text-brand">
<Sparkles className="h-4 w-4" /> <Sparkles className="h-4 w-4" />
{(() => { {(() => {
const days = Math.max(0, Math.ceil((new Date(subscription.endDate).getTime() - Date.now()) / (1000 * 60 * 60 * 24))); const days = Math.max(0, Math.ceil((new Date(subscription.endDate).getTime() - Date.now()) / (1000 * 60 * 60 * 24)));
@@ -528,30 +528,30 @@ function SubscriptionPage() {
{/* Trial CTA Card */} {/* Trial CTA Card */}
{eligibleForTrial && (!subscription || subscription.status === "expired" || subscription.status === "trial") && ( {eligibleForTrial && (!subscription || subscription.status === "expired" || subscription.status === "trial") && (
<Card className="relative overflow-hidden border-emerald-500/30 bg-gradient-to-br from-emerald-50/50 to-teal-50/50 dark:from-emerald-950/20 dark:to-teal-950/20"> <Card className="relative overflow-hidden border-brand/25 bg-brand/5">
<div className="absolute inset-0 bg-gradient-to-r from-emerald-500/5 to-teal-500/5" /> <div className="pointer-events-none absolute -right-24 top-0 h-[300px] w-[300px] rounded-full bg-brand/15 blur-[100px]" />
<CardHeader className="relative"> <CardHeader className="relative">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Sparkles className="h-5 w-5 text-emerald-600 dark:text-emerald-400" /> <Sparkles className="h-5 w-5 text-brand" />
<CardTitle className="text-emerald-900 dark:text-emerald-100"> <CardTitle className="text-foreground">
{t("subscription.trialTitle")} {t("subscription.trialTitle")}
</CardTitle> </CardTitle>
</div> </div>
<CardDescription className="text-emerald-700/80 dark:text-emerald-300/80"> <CardDescription className="text-muted-foreground">
{t("subscription.trialDescription")} {t("subscription.trialDescription")}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="relative space-y-4"> <CardContent className="relative space-y-4">
<ul className="space-y-2 text-sm"> <ul className="space-y-2 text-sm">
{["allBrands", "vinSearch", "partsCatalog", "schemaViewer"].map((f) => ( {["allBrands", "vinSearch", "partsCatalog", "schemaViewer"].map((f) => (
<li key={f} className="flex items-center gap-2 text-emerald-800 dark:text-emerald-200"> <li key={f} className="flex items-center gap-2 text-foreground/85">
<Check className="h-4 w-4 text-emerald-600 dark:text-emerald-400" /> <Check className="h-4 w-4 text-brand" />
{t(`subscription.features.${f}`)} {t(`subscription.features.${f}`)}
</li> </li>
))} ))}
</ul> </ul>
<Button <Button
className="bg-emerald-600 hover:bg-emerald-700 text-white" className="bg-brand text-brand-foreground hover:bg-brand/90"
onClick={() => { onClick={() => {
startAction("trial-start"); startAction("trial-start");
capture("trial_started"); capture("trial_started");

View File

@@ -1,10 +1,12 @@
import { lazy, Suspense } from "react"; import { lazy, Suspense, useState } from "react";
import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { useCategoryParts } from "@/hooks/use-parts"; import { useCategoryParts } from "@/hooks/use-parts";
import { CategoryGrid } from "@/components/categories/category-grid"; import { CategoryGrid } from "@/components/categories/category-grid";
import { Button } from "@sase/ui"; import { CategoryTree } from "@/components/categories/category-tree";
import { Skeleton } from "@sase/ui"; import { CategoryColumns } from "@/components/categories/category-columns";
import { ArrowLeft } from "lucide-react"; import { Button, Skeleton, cn } from "@sase/ui";
import { ArrowLeft, Columns2, LayoutGrid, List } from "lucide-react";
import { getUserSettings, setUserSetting } from "@/lib/user-settings";
const SchemaViewer = lazy(() => const SchemaViewer = lazy(() =>
import("@/components/schema/schema-viewer").then((mod) => ({ import("@/components/schema/schema-viewer").then((mod) => ({
@@ -49,6 +51,15 @@ function VehicleCategoryPage() {
const hasChildren = data?.children && data.children.length > 0; const hasChildren = data?.children && data.children.length > 0;
const [viewMode, setViewMode] = useState<"grid" | "tree" | "columns">(
() => getUserSettings().categoryViewMode ?? "grid",
);
const changeViewMode = (mode: "grid" | "tree" | "columns") => {
setViewMode(mode);
setUserSetting("categoryViewMode", mode);
};
const handleBack = () => { const handleBack = () => {
if (data?.parentId) { if (data?.parentId) {
navigate({ navigate({
@@ -66,25 +77,55 @@ function VehicleCategoryPage() {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{/* Header */} {/* Header */}
<div className="flex items-center gap-3"> <div className="flex items-center justify-between">
<Button <div className="flex items-center gap-3">
variant="ghost" <Button
size="icon" variant="ghost"
onClick={handleBack} size="icon"
title="Geri don" onClick={handleBack}
> title="Geri don"
<ArrowLeft className="h-4 w-4" /> >
</Button> <ArrowLeft className="h-4 w-4" />
<div> </Button>
<h1 className="text-xl font-bold"> <div>
{data?.name || "Kategori Detayi"} <h1 className="text-xl font-bold">
</h1> {data?.name || "Kategori Detayi"}
{data?.description && ( </h1>
<p className="text-sm text-muted-foreground"> {data?.description && (
{data.description} <p className="text-sm text-muted-foreground">
</p> {data.description}
)} </p>
)}
</div>
</div> </div>
{hasChildren && (
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => changeViewMode("grid")}
className={cn("rounded p-1.5", viewMode === "grid" ? "bg-accent" : "text-muted-foreground hover:text-foreground")}
title="Izgara"
>
<LayoutGrid className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => changeViewMode("tree")}
className={cn("rounded p-1.5", viewMode === "tree" ? "bg-accent" : "text-muted-foreground hover:text-foreground")}
title="Agac"
>
<List className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => changeViewMode("columns")}
className={cn("rounded p-1.5", viewMode === "columns" ? "bg-accent" : "text-muted-foreground hover:text-foreground")}
title="Sutun"
>
<Columns2 className="h-4 w-4" />
</button>
</div>
)}
</div> </div>
{/* Error state */} {/* Error state */}
@@ -99,12 +140,24 @@ function VehicleCategoryPage() {
<CategoryGridFallback /> <CategoryGridFallback />
)} )}
{/* Parent category — show children grid */} {/* Parent category — show children */}
{hasChildren && ( {hasChildren && (
<CategoryGrid viewMode === "grid" ? (
categories={data.children!} <CategoryGrid
vehicleId={id} categories={data.children!}
/> vehicleId={id}
/>
) : viewMode === "tree" ? (
<CategoryTree
categories={data.children!}
vehicleId={id}
/>
) : (
<CategoryColumns
categories={data.children!}
vehicleId={id}
/>
)
)} )}
{/* Leaf category — show schema viewer */} {/* Leaf category — show schema viewer */}

View File

@@ -4,9 +4,10 @@ import { useQuery } from "@tanstack/react-query";
import { api } from "@/lib/api-client"; import { api } from "@/lib/api-client";
import { CategoryTree } from "@/components/categories/category-tree"; import { CategoryTree } from "@/components/categories/category-tree";
import { CategoryGrid } from "@/components/categories/category-grid"; import { CategoryGrid } from "@/components/categories/category-grid";
import { CategoryColumns } from "@/components/categories/category-columns";
import { Card, CardContent, CardHeader, CardTitle, cn } from "@sase/ui"; import { Card, CardContent, CardHeader, CardTitle, cn } from "@sase/ui";
import { Skeleton } from "@sase/ui"; import { Skeleton } from "@sase/ui";
import { ArrowLeft, LayoutGrid, List } from "lucide-react"; import { ArrowLeft, Columns2, LayoutGrid, List } from "lucide-react";
import { Button } from "@sase/ui"; import { Button } from "@sase/ui";
import { getUserSettings, setUserSetting } from "@/lib/user-settings"; import { getUserSettings, setUserSetting } from "@/lib/user-settings";
import { CarBrandLogo } from "@/components/ui/car-brand-logo"; import { CarBrandLogo } from "@/components/ui/car-brand-logo";
@@ -17,11 +18,11 @@ export const Route = createFileRoute("/dashboard/vehicles_/$id/")({
function VehicleDetailPage() { function VehicleDetailPage() {
const { id } = Route.useParams(); const { id } = Route.useParams();
const [viewMode, setViewMode] = useState<"grid" | "tree">( const [viewMode, setViewMode] = useState<"grid" | "tree" | "columns">(
() => getUserSettings().categoryViewMode ?? "grid", () => getUserSettings().categoryViewMode ?? "grid",
); );
const changeViewMode = (mode: "grid" | "tree") => { const changeViewMode = (mode: "grid" | "tree" | "columns") => {
setViewMode(mode); setViewMode(mode);
setUserSetting("categoryViewMode", mode); setUserSetting("categoryViewMode", mode);
}; };
@@ -97,6 +98,7 @@ function VehicleDetailPage() {
? "bg-accent" ? "bg-accent"
: "text-muted-foreground hover:text-foreground", : "text-muted-foreground hover:text-foreground",
)} )}
title="Izgara"
> >
<LayoutGrid className="h-4 w-4" /> <LayoutGrid className="h-4 w-4" />
</button> </button>
@@ -109,12 +111,26 @@ function VehicleDetailPage() {
? "bg-accent" ? "bg-accent"
: "text-muted-foreground hover:text-foreground", : "text-muted-foreground hover:text-foreground",
)} )}
title="Agac"
> >
<List className="h-4 w-4" /> <List className="h-4 w-4" />
</button> </button>
<button
type="button"
onClick={() => changeViewMode("columns")}
className={cn(
"p-1.5 rounded",
viewMode === "columns"
? "bg-accent"
: "text-muted-foreground hover:text-foreground",
)}
title="Sutun"
>
<Columns2 className="h-4 w-4" />
</button>
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent className={viewMode === "columns" ? "p-0 overflow-hidden rounded-b-lg" : undefined}>
{categoriesLoading ? ( {categoriesLoading ? (
<div className="space-y-2"> <div className="space-y-2">
{Array.from({ length: 8 }).map((_, i) => ( {Array.from({ length: 8 }).map((_, i) => (
@@ -126,11 +142,16 @@ function VehicleDetailPage() {
categories={categoryTree || []} categories={categoryTree || []}
vehicleId={id} vehicleId={id}
/> />
) : ( ) : viewMode === "tree" ? (
<CategoryTree <CategoryTree
categories={categoryTree || []} categories={categoryTree || []}
vehicleId={id} vehicleId={id}
/> />
) : (
<CategoryColumns
categories={categoryTree || []}
vehicleId={id}
/>
)} )}
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -124,7 +124,8 @@ function DemoPage() {
> >
{isDark ? <Sun className="size-4" /> : <Moon className="size-4" />} {isDark ? <Sun className="size-4" /> : <Moon className="size-4" />}
</button> </button>
<span className="rounded-full bg-amber-500/10 px-3 py-1 text-xs font-medium text-amber-600"> <span className="inline-flex items-center gap-1.5 rounded-full border border-border bg-muted px-3 py-1 text-xs font-medium text-muted-foreground">
<span className="size-1.5 rounded-full bg-brand" />
Demo Demo
</span> </span>
<Link to="/register"> <Link to="/register">
@@ -137,7 +138,7 @@ function DemoPage() {
</div> </div>
</header> </header>
<main className="mx-auto max-w-5xl px-4 py-12 sm:px-6"> <main id="main-content" className="mx-auto max-w-5xl px-4 py-12 sm:px-6">
{/* Step indicator */} {/* Step indicator */}
<div className="mb-8 flex items-center justify-center gap-2 text-sm text-muted-foreground"> <div className="mb-8 flex items-center justify-center gap-2 text-sm text-muted-foreground">
<button <button
@@ -193,7 +194,7 @@ function DemoPage() {
<div <div
key={i} key={i}
className={`h-1 flex-1 rounded-full transition-colors duration-200 ${ className={`h-1 flex-1 rounded-full transition-colors duration-200 ${
i < vin.length ? "bg-emerald-500" : "bg-border" i < vin.length ? "bg-brand" : "bg-border"
}`} }`}
/> />
))} ))}
@@ -207,9 +208,9 @@ function DemoPage() {
)} )}
{vinPreview && !vinLoading && ( {vinPreview && !vinLoading && (
<div className="animate-fade-in-up rounded-2xl border border-emerald-500/30 bg-surface p-6"> <div className="animate-fade-in-up rounded-2xl border border-brand/30 bg-surface p-6">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Car className="size-6 text-emerald-500" /> <Car className="size-6 text-brand" />
<div> <div>
<p className="font-semibold text-foreground"> <p className="font-semibold text-foreground">
{vinPreview.make} {vinPreview.model} {vinPreview.make} {vinPreview.model}
@@ -221,7 +222,8 @@ function DemoPage() {
</div> </div>
<Button <Button
onClick={() => setStep("categories")} onClick={() => setStep("categories")}
className="mt-4 w-full rounded-full bg-emerald-600 text-white hover:bg-emerald-700" variant="brand"
className="mt-4 w-full rounded-full"
> >
Parça Kataloğuna Devam Et Parça Kataloğuna Devam Et
<ArrowRight className="ml-2 size-4" /> <ArrowRight className="ml-2 size-4" />
@@ -332,7 +334,7 @@ function DemoPage() {
<div <div
key={i} key={i}
className={`flex items-center justify-center rounded-lg border border-border text-xs text-muted-foreground ${ className={`flex items-center justify-center rounded-lg border border-border text-xs text-muted-foreground ${
[2, 5, 9, 13].includes(i) ? "border-emerald-500/50 bg-emerald-500/10 text-emerald-500" : "bg-muted/50" [2, 5, 9, 13].includes(i) ? "border-brand/50 bg-brand/10 text-brand" : "bg-muted/50"
}`} }`}
> >
{[2, 5, 9, 13].includes(i) ? EXAMPLE_SCHEMA_PARTS[[2, 5, 9, 13].indexOf(i)]?.position : ""} {[2, 5, 9, 13].includes(i) ? EXAMPLE_SCHEMA_PARTS[[2, 5, 9, 13].indexOf(i)]?.position : ""}
@@ -364,7 +366,7 @@ function DemoPage() {
<p className="mt-0.5 text-sm text-muted-foreground">{part.name}</p> <p className="mt-0.5 text-sm text-muted-foreground">{part.name}</p>
</div> </div>
{idx < 2 ? ( {idx < 2 ? (
<span className="rounded-full bg-emerald-500/10 px-2 py-0.5 text-xs text-emerald-500"> <span className="rounded-full bg-brand/10 px-2 py-0.5 text-xs text-brand">
Görünür Görünür
</span> </span>
) : ( ) : (
@@ -380,7 +382,7 @@ function DemoPage() {
<div className="mt-6 rounded-2xl border-2 border-dashed border-border bg-surface p-6 text-center"> <div className="mt-6 rounded-2xl border-2 border-dashed border-border bg-surface p-6 text-center">
<h3 className="font-semibold">Tüm parçaları ve şemaları görün</h3> <h3 className="font-semibold">Tüm parçaları ve şemaları görün</h3>
<p className="mt-2 text-sm text-muted-foreground"> <p className="mt-2 text-sm text-muted-foreground">
7 gün ücretsiz deneyin kredi kartı gerekmez 30 gün ücretsiz deneyin kredi kartı gerekmez
</p> </p>
<Link to="/register"> <Link to="/register">
<Button className="mt-4 rounded-full bg-foreground text-background hover:bg-foreground/90"> <Button className="mt-4 rounded-full bg-foreground text-background hover:bg-foreground/90">

View File

@@ -607,7 +607,7 @@ function HomePage() {
</Link> </Link>
<Link to="/register"> <Link to="/register">
<Button data-faro-user-action-name="hero-register" className="rounded-full bg-foreground text-background hover:bg-foreground/90"> <Button data-faro-user-action-name="hero-register" className="rounded-full bg-foreground text-background hover:bg-foreground/90">
7 Gün Ücretsiz Deneyin 30 Gün Ücretsiz Deneyin
</Button> </Button>
</Link> </Link>
</> </>
@@ -677,7 +677,7 @@ function HomePage() {
</Link> </Link>
<Link to="/register" onClick={() => setMobileMenuOpen(false)}> <Link to="/register" onClick={() => setMobileMenuOpen(false)}>
<Button className="w-full rounded-full bg-foreground text-background"> <Button className="w-full rounded-full bg-foreground text-background">
7 Gün Ücretsiz Deneyin 30 Gün Ücretsiz Deneyin
</Button> </Button>
</Link> </Link>
</> </>
@@ -687,6 +687,7 @@ function HomePage() {
)} )}
</header> </header>
<main id="main-content">
{/* ─── 2. HERO SECTION ──────────────────────────────────────────── */} {/* ─── 2. HERO SECTION ──────────────────────────────────────────── */}
<section className="relative overflow-hidden px-4 pb-16 pt-16 sm:px-6 sm:pt-24 lg:pt-28"> <section className="relative overflow-hidden px-4 pb-16 pt-16 sm:px-6 sm:pt-24 lg:pt-28">
{/* Decorative glow */} {/* Decorative glow */}
@@ -697,7 +698,10 @@ function HomePage() {
<div className="relative mx-auto max-w-4xl text-center"> <div className="relative mx-auto max-w-4xl text-center">
{/* Pill badge — loss framing */} {/* Pill badge — loss framing */}
<div className="mb-4 inline-flex items-center gap-2 rounded-full border border-border bg-muted px-4 py-1.5 text-xs text-muted-foreground"> <div className="mb-4 inline-flex items-center gap-2 rounded-full border border-border bg-muted px-4 py-1.5 text-xs text-muted-foreground">
<span className="size-2 rounded-full bg-amber-500" /> <span className="relative flex size-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-destructive/60 opacity-75" />
<span className="relative inline-flex size-2 rounded-full bg-destructive" />
</span>
Her yanlış parça siparişi ortalama 450 TL'ye mal olur Her yanlış parça siparişi ortalama 450 TL'ye mal olur
</div> </div>
@@ -764,7 +768,7 @@ function HomePage() {
<div <div
key={i} key={i}
className={`h-1 flex-1 rounded-full transition-colors duration-200 ${ className={`h-1 flex-1 rounded-full transition-colors duration-200 ${
i < vin.length ? "bg-emerald-500" : "bg-border" i < vin.length ? "bg-brand" : "bg-border"
}`} }`}
/> />
))} ))}
@@ -781,9 +785,9 @@ function HomePage() {
)} )}
{vinPreview && !vinLoading && ( {vinPreview && !vinLoading && (
<div className="animate-fade-in-up mx-auto mt-4 max-w-xl rounded-2xl border border-emerald-500/30 bg-surface p-5 text-left"> <div className="animate-fade-in-up mx-auto mt-4 max-w-xl rounded-2xl border border-brand/30 bg-surface p-5 text-left">
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
<Car className="size-5 text-emerald-500" /> <Car className="size-5 text-brand" />
<span className="font-semibold text-foreground"> <span className="font-semibold text-foreground">
{vinPreview.make} {vinPreview.model} {vinPreview.make} {vinPreview.model}
</span> </span>
@@ -801,7 +805,8 @@ function HomePage() {
size="sm" size="sm"
onClick={handleVinSearch} onClick={handleVinSearch}
disabled={decodeLoading} disabled={decodeLoading}
className="rounded-full bg-emerald-500 text-white hover:bg-emerald-600" variant="brand"
className="rounded-full"
> >
{decodeLoading ? ( {decodeLoading ? (
<> <>
@@ -921,7 +926,7 @@ function HomePage() {
key={b} key={b}
className="flex items-center gap-2 text-sm text-muted-foreground" className="flex items-center gap-2 text-sm text-muted-foreground"
> >
<CheckCircle2 className="size-4 shrink-0 text-emerald-500" /> <CheckCircle2 className="size-4 shrink-0 text-brand" />
{b} {b}
</li> </li>
))} ))}
@@ -993,7 +998,7 @@ function HomePage() {
<td className="px-6 py-4 text-center text-sm text-muted-foreground"> <td className="px-6 py-4 text-center text-sm text-muted-foreground">
{row.manual} {row.manual}
</td> </td>
<td className="px-6 py-4 text-center text-sm font-medium text-emerald-500"> <td className="px-6 py-4 text-center text-sm font-medium text-brand">
{row.sase} {row.sase}
</td> </td>
</tr> </tr>
@@ -1015,9 +1020,9 @@ function HomePage() {
<p className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/70">Manuel</p> <p className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/70">Manuel</p>
<p className="mt-1 text-sm text-muted-foreground">{row.manual}</p> <p className="mt-1 text-sm text-muted-foreground">{row.manual}</p>
</div> </div>
<div className="rounded-lg bg-emerald-500/10 p-2.5 text-center"> <div className="rounded-lg bg-brand/10 p-2.5 text-center">
<p className="text-[10px] font-medium uppercase tracking-wider text-emerald-500/70">Sase.tr</p> <p className="text-[10px] font-medium uppercase tracking-wider text-brand/70">Sase.tr</p>
<p className="mt-1 text-sm font-medium text-emerald-500">{row.sase}</p> <p className="mt-1 text-sm font-medium text-brand">{row.sase}</p>
</div> </div>
</div> </div>
</div> </div>
@@ -1043,17 +1048,17 @@ function HomePage() {
return ( return (
<div <div
key={item.title} key={item.title}
className={`rounded-2xl border border-border bg-surface p-6 sm:p-8 ${wide ? "md:col-span-2" : "md:col-span-1"}`} className={`group relative overflow-hidden rounded-2xl border border-border bg-surface p-6 transition-all duration-300 hover:-translate-y-1 hover:border-foreground/20 hover:shadow-[var(--shadow-md)] sm:p-8 ${wide ? "md:col-span-2" : "md:col-span-1"}`}
> >
<div className="mb-4 inline-flex rounded-lg bg-muted p-2.5"> <div className="mb-4 inline-flex rounded-lg bg-muted p-2.5 text-muted-foreground transition-colors duration-300 group-hover:bg-foreground group-hover:text-background">
<Icon className="size-5 text-muted-foreground" /> <Icon className="size-5" />
</div> </div>
<h3 className="text-lg font-semibold">{item.title}</h3> <h3 className="text-lg font-semibold tracking-tight">{item.title}</h3>
<p className="mt-2 text-sm text-muted-foreground"> <p className="mt-2 text-sm text-muted-foreground">
{item.description} {item.description}
</p> </p>
{item.stat && ( {item.stat && (
<p className="mt-4 font-[family-name:var(--font-display)] text-4xl font-bold"> <p className="mt-4 font-[family-name:var(--font-display)] text-4xl font-bold tabular tracking-tight">
{item.stat} {item.stat}
</p> </p>
)} )}
@@ -1075,17 +1080,17 @@ function HomePage() {
</div> </div>
<div className="mt-12 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3"> <div className="mt-12 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{USE_CASES.map((uc) => { {USE_CASES.map((uc, idx) => {
const Icon = uc.icon; const Icon = uc.icon;
return ( return (
<div <div
key={uc.title} key={uc.title}
className="rounded-2xl border border-border bg-surface p-6" className={`group relative rounded-2xl border border-border bg-surface p-6 transition-all duration-300 hover:-translate-y-1 hover:border-foreground/20 hover:shadow-[var(--shadow-md)] ${idx % 2 === 1 ? "lg:translate-y-6" : ""}`}
> >
<div className="mb-4 inline-flex rounded-lg bg-muted p-2.5"> <div className="mb-4 inline-flex rounded-lg bg-muted p-2.5 text-muted-foreground transition-colors duration-300 group-hover:bg-foreground group-hover:text-background">
<Icon className="size-5 text-muted-foreground" /> <Icon className="size-5" />
</div> </div>
<h3 className="text-lg font-semibold">{uc.title}</h3> <h3 className="text-lg font-semibold tracking-tight">{uc.title}</h3>
<p className="mt-2 text-sm text-muted-foreground"> <p className="mt-2 text-sm text-muted-foreground">
{uc.description} {uc.description}
</p> </p>
@@ -1120,29 +1125,47 @@ function HomePage() {
</section> </section>
{/* ─── 8. RAKAMLARLA SASE.TR ────────────────────────────────────── */} {/* ─── 8. RAKAMLARLA SASE.TR ────────────────────────────────────── */}
<section className="bg-[#09090b] px-4 py-20 text-white sm:px-6"> <section className="relative overflow-hidden bg-foreground px-4 py-24 text-background sm:px-6">
<div className="mx-auto max-w-7xl"> {/* Ambient grid pattern — breaks digital flatness */}
<div className="text-center"> <div className="pointer-events-none absolute inset-0 opacity-[0.04]">
<span className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-4 py-1.5 text-xs font-medium text-neutral-400"> <svg width="100%" height="100%" aria-hidden="true">
<defs>
<pattern id="stats-grid" width="48" height="48" patternUnits="userSpaceOnUse">
<path d="M 48 0 L 0 0 0 48" fill="none" stroke="currentColor" strokeWidth="1" />
</pattern>
</defs>
<rect width="100%" height="100%" fill="url(#stats-grid)" />
</svg>
</div>
{/* Brand accent glow */}
<div className="pointer-events-none absolute -left-32 top-1/2 h-[400px] w-[400px] -translate-y-1/2 rounded-full bg-brand/20 blur-[120px]" />
<div className="pointer-events-none absolute -right-32 top-0 h-[300px] w-[300px] rounded-full bg-brand/10 blur-[100px]" />
<div className="relative mx-auto max-w-7xl">
<div className="max-w-2xl">
<span className="inline-flex items-center gap-2 rounded-full border border-background/15 bg-background/5 px-4 py-1.5 text-xs font-medium text-background/70 backdrop-blur-sm">
<span className="size-1.5 rounded-full bg-brand" />
Platform Platform
</span> </span>
<h2 className="mt-4 font-[family-name:var(--font-display)] text-3xl font-bold tracking-tight text-white sm:text-4xl lg:text-5xl"> <h2 className="mt-5 font-[family-name:var(--font-display)] text-4xl font-bold tracking-tight sm:text-5xl lg:text-6xl">
Rakamlarla Sase.tr Rakamlarla<br />
<span className="text-background/50">Sase.tr</span>
</h2> </h2>
</div> </div>
<div className="mt-12 grid grid-cols-2 gap-4 sm:gap-6 lg:grid-cols-4"> <div className="mt-14 grid grid-cols-2 gap-px overflow-hidden rounded-2xl border border-background/10 bg-background/10 lg:grid-cols-4">
{STATS.map((stat) => ( {STATS.map((stat) => (
<div <div
key={stat.label} key={stat.label}
className="rounded-2xl border border-white/10 bg-white/5 p-6 text-center sm:p-8" className="group relative bg-foreground p-7 transition-colors duration-300 hover:bg-background/[0.04] sm:p-9"
> >
<p className="font-[family-name:var(--font-display)] text-4xl font-bold text-white sm:text-5xl"> <p className="font-[family-name:var(--font-display)] text-5xl font-bold tracking-tight tabular sm:text-6xl">
{stat.value} {stat.value}
</p> </p>
<p className="mt-2 text-sm text-neutral-400"> <p className="mt-3 text-sm text-background/60">
{stat.label} {stat.label}
</p> </p>
<div className="absolute bottom-0 left-0 h-px w-0 bg-brand transition-all duration-500 group-hover:w-full" />
</div> </div>
))} ))}
</div> </div>
@@ -1244,7 +1267,7 @@ function HomePage() {
key={b} key={b}
className="flex items-center gap-2 text-sm text-muted-foreground" className="flex items-center gap-2 text-sm text-muted-foreground"
> >
<CheckCircle2 className="size-4 shrink-0 text-emerald-500" /> <CheckCircle2 className="size-4 shrink-0 text-brand" />
{b} {b}
</li> </li>
))} ))}
@@ -1273,7 +1296,7 @@ function HomePage() {
key={stat.label} key={stat.label}
className="rounded-2xl border border-border bg-surface p-6 sm:p-8 text-center" className="rounded-2xl border border-border bg-surface p-6 sm:p-8 text-center"
> >
<p className="font-[family-name:var(--font-display)] text-3xl font-bold text-emerald-500 sm:text-4xl"> <p className="font-[family-name:var(--font-display)] text-3xl font-bold text-brand sm:text-4xl">
{stat.value} {stat.value}
</p> </p>
<p className="mt-2 text-sm text-muted-foreground"> <p className="mt-2 text-sm text-muted-foreground">
@@ -1297,10 +1320,17 @@ function HomePage() {
<div className="mt-12 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4"> <div className="mt-12 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
{TESTIMONIALS.map((t) => ( {TESTIMONIALS.map((t) => (
<div key={t.name} className="rounded-2xl border border-border bg-surface p-6"> <div key={t.name} className="group rounded-2xl border border-border bg-surface p-6 transition-all duration-300 hover:-translate-y-1 hover:border-foreground/20 hover:shadow-[var(--shadow-md)]">
<div className="flex gap-0.5"> <div className="flex gap-0.5">
{Array.from({ length: t.rating }).map((_, i) => ( {Array.from({ length: t.rating }).map((_, i) => (
<span key={i} className="text-amber-400"></span> <svg
key={i}
viewBox="0 0 20 20"
className="size-3.5 fill-foreground/85"
aria-hidden="true"
>
<path d="M9.05.927c.3-.921 1.603-.921 1.902 0l1.794 5.522a1 1 0 00.95.69h5.806c.969 0 1.371 1.24.588 1.81l-4.696 3.412a1 1 0 00-.363 1.118l1.793 5.522c.3.921-.755 1.688-1.539 1.118l-4.695-3.413a1 1 0 00-1.176 0l-4.695 3.413c-.784.57-1.838-.197-1.539-1.118l1.793-5.522a1 1 0 00-.363-1.118L2.31 8.95c-.783-.57-.38-1.81.588-1.81h5.807a1 1 0 00.95-.69L9.05.927z" />
</svg>
))} ))}
</div> </div>
<p className="mt-4 text-sm leading-relaxed text-muted-foreground"> <p className="mt-4 text-sm leading-relaxed text-muted-foreground">
@@ -1322,104 +1352,159 @@ function HomePage() {
</section> </section>
{/* ─── 10. PRICING ─────────────────────────────────────────────── */} {/* ─── 10. PRICING ─────────────────────────────────────────────── */}
<section className="px-4 py-20 sm:px-6"> <section className="px-4 py-24 sm:px-6">
<div className="mx-auto max-w-6xl"> <div className="mx-auto max-w-6xl">
<div className="text-center"> <div className="max-w-2xl">
<SectionBadge>Fiyatlandırma</SectionBadge> <SectionBadge>Fiyatlandırma</SectionBadge>
<h2 className="mt-4 font-[family-name:var(--font-display)] text-3xl font-bold tracking-tight sm:text-4xl lg:text-5xl"> <h2 className="mt-4 font-[family-name:var(--font-display)] text-4xl font-bold tracking-tight sm:text-5xl lg:text-6xl">
Günde 7 TL'den Başlayan Fiyatlar Günde 7 TL'den
<br />
<span className="text-muted-foreground">başlayan fiyatlar.</span>
</h2> </h2>
<p className="mx-auto mt-4 max-w-xl text-muted-foreground"> <p className="mt-5 max-w-xl text-base text-muted-foreground">
Tek bir yanlış parça iadesinin maliyetinden daha az. Tek bir yanlış parça iadesinin maliyetinden daha az. Tüm planlarda 30 gün ücretsiz deneme var, kart bilgisi gerekmez.
</p> </p>
</div> </div>
{/* Horizontally scrollable on mobile, grid on desktop */} {/* Tier tiles — horizontal row, compact, breaks 4-tower symmetry */}
<div className="mt-12 flex gap-4 overflow-x-auto pb-4 sm:grid sm:grid-cols-2 sm:overflow-visible sm:pb-0 lg:grid-cols-4"> <div className="mt-14 grid grid-cols-1 gap-4 sm:grid-cols-3 sm:gap-5">
{[ {[
{ {
name: "1 Marka", name: "1 Marka",
description: "Tek marka için yedek parça erişimi", description: "Tek marka için erişim",
price: "200", price: "200",
yearly: "2.000", yearly: "2.000",
features: ["1 marka seçimi", "Sınırsız VIN arama", "Parça kataloğu", "Şema görüntüleyici"], features: ["1 marka seçimi", "Sınırsız VIN arama", "Parça kataloğu", "Şema görüntüleyici"],
}, },
{ {
name: "2 Marka", name: "2 Marka",
description: "İki farklı marka için erişim", description: "İki farklı marka",
price: "350", price: "350",
yearly: "3.500", yearly: "3.500",
popular: true, features: ["2 marka seçimi", "Sınırsız VIN arama", "Parça kataloğu", "Öncelikli destek"],
features: ["2 marka seçimi", "Sınırsız VIN arama", "Parça kataloğu", "Şema görüntüleyici", "Öncelikli destek"],
}, },
{ {
name: "3 Marka", name: "3 Marka",
description: "Üç marka için kapsamlı erişim", description: "Üç marka kapsamlı",
price: "500", price: "500",
yearly: "5.000", yearly: "5.000",
features: ["3 marka seçimi", "Sınırsız VIN arama", "Parça kataloğu", "Şema görüntüleyici", "Öncelikli destek"], features: ["3 marka seçimi", "Sınırsız VIN arama", "Parça kataloğu", "Öncelikli destek"],
}, },
{ ].map((plan, idx) => (
name: "Full Paket",
description: "Tüm markalara sınırsız erişim",
price: "999",
yearly: "9.990",
features: ["Tüm markalar", "Sınırsız VIN arama", "Parça kataloğu", "Şema görüntüleyici", "Öncelikli destek", "OEM parça arama"],
},
].map((plan) => (
<div <div
key={plan.name} key={plan.name}
className={`flex min-w-[260px] flex-1 flex-col rounded-2xl border bg-surface p-6 sm:p-8 ${plan.popular ? "border-2 border-foreground/20" : "border-border"}`} className={`group relative flex flex-col rounded-2xl border border-border bg-surface p-7 transition-all duration-300 hover:-translate-y-1 hover:border-foreground/20 hover:shadow-[var(--shadow-md)] ${idx === 1 ? "sm:translate-y-3" : ""} ${idx === 2 ? "sm:translate-y-6" : ""}`}
> >
{plan.popular && ( <div className="flex items-baseline justify-between">
<div className="mb-3 inline-flex self-start rounded-full bg-foreground px-3 py-1 text-xs font-medium text-background"> <h3 className="font-[family-name:var(--font-display)] text-lg font-semibold">
En Popüler {plan.name}
</div> </h3>
)} <span className="text-xs text-muted-foreground tabular">
<h3 className="font-[family-name:var(--font-display)] text-xl font-bold"> /ay
{plan.name}
</h3>
<p className="mt-1 text-sm text-muted-foreground">
{plan.description}
</p>
<div className="mt-4">
<span className="font-[family-name:var(--font-display)] text-4xl font-bold">
{plan.price} TL
</span> </span>
<span className="text-muted-foreground">/ay</span>
</div> </div>
<p className="mt-1 text-xs text-muted-foreground"> <p className="mt-1 text-sm text-muted-foreground">{plan.description}</p>
veya {plan.yearly} TL/yıl <div className="mt-5 flex items-baseline gap-1">
<span className="font-[family-name:var(--font-display)] text-4xl font-bold tabular tracking-tight">
{plan.price}
</span>
<span className="text-base font-medium text-muted-foreground">TL</span>
</div>
<p className="mt-1 text-xs text-muted-foreground tabular">
yıllık {plan.yearly} TL
</p> </p>
<ul className="mt-6 flex-1 space-y-2 text-sm"> <ul className="mt-6 flex-1 space-y-2.5 text-sm">
{plan.features.map((f) => ( {plan.features.map((f) => (
<li <li key={f} className="flex items-start gap-2 text-muted-foreground">
key={f} <CheckCircle2 className="mt-0.5 size-4 shrink-0 text-foreground/40" />
className="flex items-center gap-2 text-muted-foreground" <span>{f}</span>
>
<CheckCircle2 className="size-4 shrink-0 text-emerald-500" />
{f}
</li> </li>
))} ))}
</ul> </ul>
<Link to="/register" className="mt-6"> <Link to="/register" className="mt-7">
<Button <Button variant="outline" className="w-full rounded-full">
className={`w-full rounded-full ${plan.popular ? "bg-foreground text-background hover:bg-foreground/90" : "border-border text-muted-foreground hover:bg-muted hover:text-foreground"}`} 30 gün ücretsiz dene
variant={plan.popular ? "default" : "outline"}
>
7 Gün Ücretsiz Deneyin
</Button> </Button>
</Link> </Link>
</div> </div>
))} ))}
</div> </div>
<div className="mt-8 text-center"> {/* Hero plan — Full Paket, asymmetric horizontal card with brand emphasis */}
<div className="mt-8 sm:mt-10">
<div className="relative overflow-hidden rounded-2xl border border-foreground/10 bg-foreground text-background shadow-[var(--shadow-lg)]">
{/* Brand accent glow */}
<div className="pointer-events-none absolute -right-32 top-1/2 h-[400px] w-[400px] -translate-y-1/2 rounded-full bg-brand/30 blur-[120px]" />
<div className="pointer-events-none absolute -left-24 -top-24 h-[300px] w-[300px] rounded-full bg-brand/15 blur-[100px]" />
<div className="relative grid gap-8 p-8 sm:p-10 lg:grid-cols-[1.1fr_1fr] lg:gap-12 lg:p-14">
{/* Left: pitch + price */}
<div>
<div className="inline-flex items-center gap-2 rounded-full border border-background/15 bg-background/5 px-3 py-1 text-xs font-medium text-background/80 backdrop-blur-sm">
<span className="size-1.5 rounded-full bg-brand" />
En çok tercih edilen
</div>
<h3 className="mt-5 font-[family-name:var(--font-display)] text-3xl font-bold tracking-tight sm:text-4xl">
Full Paket
</h3>
<p className="mt-2 max-w-md text-background/70">
Tüm markalara sınırsız erişim. Tek bir aboneliğe sığdırdık.
</p>
<div className="mt-7 flex items-baseline gap-2">
<span className="font-[family-name:var(--font-display)] text-6xl font-bold tabular tracking-tight">
999
</span>
<span className="text-lg font-medium text-background/60">TL/ay</span>
</div>
<p className="mt-1 text-sm text-background/50 tabular">
yıllık 9.990 TL — ayda 832 TL'ye denk gelir
</p>
<Link to="/register" className="mt-8 inline-block">
<Button variant="brand" size="lg" className="rounded-full">
30 gün ücretsiz dene
<ArrowRight className="ml-2 size-4" />
</Button>
</Link>
<p className="mt-3 text-xs text-background/50">
Kart bilgisi gerekmez. İstediğin zaman iptal et.
</p>
</div>
{/* Right: feature list, two columns */}
<div className="border-t border-background/10 pt-8 lg:border-l lg:border-t-0 lg:pl-12 lg:pt-0">
<p className="text-xs font-medium uppercase tracking-wider text-background/50">
Pakete dahil
</p>
<ul className="mt-5 grid grid-cols-1 gap-x-6 gap-y-3 sm:grid-cols-2">
{[
"Tüm 27+ markaya erişim",
"Sınırsız VIN arama",
"OEM parça kataloğu",
"İnteraktif şema görüntüleyici",
"Geçmiş sorgular & favoriler",
"Öncelikli e-posta desteği",
"API erişimi (talep üzerine)",
"Toplu sorgu özelliği",
].map((f) => (
<li key={f} className="flex items-start gap-2.5 text-sm text-background/85">
<CheckCircle2 className="mt-0.5 size-4 shrink-0 text-brand" />
<span>{f}</span>
</li>
))}
</ul>
</div>
</div>
</div>
</div>
<div className="mt-10 flex items-center justify-center">
<Link <Link
to="/pricing" to="/pricing"
className="inline-flex items-center gap-1 text-sm text-muted-foreground transition hover:text-foreground" className="inline-flex items-center gap-1.5 rounded-full border border-border px-4 py-2 text-sm text-muted-foreground transition-all duration-200 hover:-translate-y-0.5 hover:border-foreground/30 hover:text-foreground hover:shadow-sm"
> >
Detaylı karşılaştırma Detaylı karşılaştırmayı gör
<ArrowRight className="size-3.5" /> <ArrowRight className="size-3.5" />
</Link> </Link>
</div> </div>
@@ -1499,6 +1584,7 @@ function HomePage() {
</Link> </Link>
</div> </div>
</section> </section>
</main>
{/* ─── 13. FOOTER ───────────────────────────────────────────────── */} {/* ─── 13. FOOTER ───────────────────────────────────────────────── */}
<footer className="bg-background px-4 sm:px-6"> <footer className="bg-background px-4 sm:px-6">
@@ -1595,7 +1681,7 @@ function HomePage() {
&copy; {new Date().getFullYear()} Sase.tr. Tüm hakları saklıdır. &copy; {new Date().getFullYear()} Sase.tr. Tüm hakları saklıdır.
</p> </p>
<div className="flex items-center gap-2 text-sm text-muted-foreground/70"> <div className="flex items-center gap-2 text-sm text-muted-foreground/70">
<span className="size-2 rounded-full bg-emerald-500" /> <span className="size-2 rounded-full bg-brand" />
Tüm servisler aktif Tüm servisler aktif
</div> </div>
</div> </div>

View File

@@ -63,7 +63,7 @@ function PricingPage() {
usePageMeta({ usePageMeta({
title: "Fiyatlandırma — Sase.tr | Şase Sorgulama Planları", title: "Fiyatlandırma — Sase.tr | Şase Sorgulama Planları",
description: description:
"200 TL/ay'dan başlayan şase numarası ve OEM parça sorgulama planları. 7 gün ücretsiz deneyin.", "200 TL/ay'dan başlayan şase numarası ve OEM parça sorgulama planları. 30 gün ücretsiz deneyin.",
canonical: "https://sase.tr/pricing", canonical: "https://sase.tr/pricing",
}); });
@@ -85,7 +85,7 @@ function PricingPage() {
</div> </div>
</header> </header>
<main className="container mx-auto px-4 py-24"> <main id="main-content" className="container mx-auto px-4 py-24">
<div className="text-center"> <div className="text-center">
<h1 className="text-4xl font-bold">Fiyatlandırma</h1> <h1 className="text-4xl font-bold">Fiyatlandırma</h1>
<p className="mt-4 text-lg text-muted-foreground"> <p className="mt-4 text-lg text-muted-foreground">

File diff suppressed because one or more lines are too long

View File

@@ -30,9 +30,11 @@ server {
# Faro telemetry proxy → Grafana Cloud (avoids CORS) # Faro telemetry proxy → Grafana Cloud (avoids CORS)
location /collect/ { location /collect/ {
proxy_pass https://faro-collector-prod-eu-west-2.grafana.net/collect/; resolver 1.1.1.1 8.8.8.8 valid=300s ipv6=off;
set $faro_upstream "faro-collector-prod-eu-west-2.grafana.net";
proxy_pass https://$faro_upstream/collect/;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Host faro-collector-prod-eu-west-2.grafana.net; proxy_set_header Host $faro_upstream;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_ssl_server_name on; proxy_ssl_server_name on;

View File

@@ -55,6 +55,9 @@ export const envSchema = z.object({
.transform((v) => v === "true") .transform((v) => v === "true")
.default("false"), .default("false"),
// OpenRouter (used by scripts/emex-translate-bootstrap.ts → DeepSeek V3)
OPENROUTER_API_KEY: z.string().optional(),
// Postal Email // Postal Email
POSTAL_API_URL: z.string().url().optional(), POSTAL_API_URL: z.string().url().optional(),
POSTAL_API_KEY: z.string().optional(), POSTAL_API_KEY: z.string().optional(),

View File

@@ -4,21 +4,27 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "./utils"; import { cn } from "./utils";
const buttonVariants = cva( const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-[transform,background-color,border-color,box-shadow,color] duration-200 ease-out will-change-transform focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 active:scale-[0.97] [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{ {
variants: { variants: {
variant: { variant: {
default: "bg-primary text-primary-foreground shadow hover:bg-primary/90", default:
destructive: "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", "bg-primary text-primary-foreground shadow-sm hover:bg-primary/92 hover:-translate-y-0.5 hover:shadow-md active:translate-y-0",
outline: "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground", brand:
secondary: "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", "bg-brand text-brand-foreground shadow-sm hover:bg-brand/92 hover:-translate-y-0.5 hover:shadow-[var(--shadow-brand)] active:translate-y-0",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/92 hover:-translate-y-0.5 hover:shadow-md active:translate-y-0",
outline:
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground hover:border-foreground/30 hover:-translate-y-0.5 hover:shadow-md active:translate-y-0",
secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80 hover:-translate-y-0.5 active:translate-y-0",
ghost: "hover:bg-accent hover:text-accent-foreground", ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline", link: "text-primary underline-offset-4 hover:underline",
}, },
size: { size: {
default: "h-9 px-4 py-2", default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs", sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8", lg: "h-11 rounded-md px-8",
icon: "h-9 w-9", icon: "h-9 w-9",
}, },
}, },

25
pnpm-lock.yaml generated
View File

@@ -98,7 +98,7 @@ importers:
version: link:../../packages/shared version: link:../../packages/shared
better-auth: better-auth:
specifier: ^1.2.0 specifier: ^1.2.0
version: 1.4.18(drizzle-kit@0.31.9)(drizzle-orm@0.41.0(@opentelemetry/api@1.9.0)(gel@2.2.0)(kysely@0.28.11)(postgres@3.4.8))(next@16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@3.2.4(@types/node@22.19.11)(jiti@2.6.1)(jsdom@28.0.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) version: 1.4.18(drizzle-kit@0.31.9)(drizzle-orm@0.41.0(@opentelemetry/api@1.9.0)(gel@2.2.0)(kysely@0.28.11)(postgres@3.4.8))(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@3.2.4(@types/node@22.19.11)(jiti@2.6.1)(jsdom@28.0.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
bullmq: bullmq:
specifier: ^5.30.0 specifier: ^5.30.0
version: 5.68.0 version: 5.68.0
@@ -117,6 +117,9 @@ importers:
ioredis: ioredis:
specifier: ^5.4.0 specifier: ^5.4.0
version: 5.9.2 version: 5.9.2
openai:
specifier: ^6.37.0
version: 6.37.0(zod@3.25.76)
postgres: postgres:
specifier: ^3.4.0 specifier: ^3.4.0
version: 3.4.8 version: 3.4.8
@@ -198,7 +201,7 @@ importers:
version: 1.159.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4) version: 1.159.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
better-auth: better-auth:
specifier: ^1.2.0 specifier: ^1.2.0
version: 1.4.18(drizzle-kit@0.31.9)(drizzle-orm@0.41.0(@opentelemetry/api@1.9.0)(gel@2.2.0)(kysely@0.28.11)(postgres@3.4.8))(next@16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@3.2.4(@types/node@22.19.11)(jiti@2.6.1)(jsdom@28.0.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) version: 1.4.18(drizzle-kit@0.31.9)(drizzle-orm@0.41.0(@opentelemetry/api@1.9.0)(gel@2.2.0)(kysely@0.28.11)(postgres@3.4.8))(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@3.2.4(@types/node@22.19.11)(jiti@2.6.1)(jsdom@28.0.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
canvas-confetti: canvas-confetti:
specifier: ^1.9.4 specifier: ^1.9.4
version: 1.9.4 version: 1.9.4
@@ -4631,6 +4634,18 @@ packages:
resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==}
engines: {node: '>=6'} engines: {node: '>=6'}
openai@6.37.0:
resolution: {integrity: sha512-0H5dEGFmmLv6KSd0W1w2nyL8WsLkX6yoLeQpU+dZAOuGcany5qkYQMmj35ZrKgb6yiyYqpUzFOpR8mZQkgqeEQ==}
hasBin: true
peerDependencies:
ws: ^8.18.0
zod: ^3.25 || ^4.0
peerDependenciesMeta:
ws:
optional: true
zod:
optional: true
ora@5.4.1: ora@5.4.1:
resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==} resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -8862,7 +8877,7 @@ snapshots:
baseline-browser-mapping@2.9.19: {} baseline-browser-mapping@2.9.19: {}
better-auth@1.4.18(drizzle-kit@0.31.9)(drizzle-orm@0.41.0(@opentelemetry/api@1.9.0)(gel@2.2.0)(kysely@0.28.11)(postgres@3.4.8))(next@16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@3.2.4(@types/node@22.19.11)(jiti@2.6.1)(jsdom@28.0.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)): better-auth@1.4.18(drizzle-kit@0.31.9)(drizzle-orm@0.41.0(@opentelemetry/api@1.9.0)(gel@2.2.0)(kysely@0.28.11)(postgres@3.4.8))(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@3.2.4(@types/node@22.19.11)(jiti@2.6.1)(jsdom@28.0.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)):
dependencies: dependencies:
'@better-auth/core': 1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@3.25.76))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0) '@better-auth/core': 1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@3.25.76))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0)
'@better-auth/telemetry': 1.4.18(@better-auth/core@1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@3.25.76))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0)) '@better-auth/telemetry': 1.4.18(@better-auth/core@1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@3.25.76))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0))
@@ -10161,6 +10176,10 @@ snapshots:
dependencies: dependencies:
mimic-fn: 2.1.0 mimic-fn: 2.1.0
openai@6.37.0(zod@3.25.76):
optionalDependencies:
zod: 3.25.76
ora@5.4.1: ora@5.4.1:
dependencies: dependencies:
bl: 4.1.0 bl: 4.1.0