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",
"helmet": "^8.1.0",
"ioredis": "^5.4.0",
"openai": "^6.37.0",
"postgres": "^3.4.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.0",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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 {
raw: string;
exp: number;
host: string;
apiKey: string;
apiPath: string;
ip: string;
hash: string;
raw: string; // x-api-key value, e.g. "TWS-016EA7BE-..."
exp: number; // unix epoch seconds (capturedAt + TTL_FALLBACK)
apiPath: string; // x-api-path (upstream PCAT API base URL)
guiVersion: string; // x-gui-version (e.g. "3")
userId: string; // x-user-id (per-session UUID minted by widget)
origin: string; // partner-site origin
referer: string; // partner-site referer
}
export interface JwtSlot {
@@ -17,7 +22,12 @@ export interface JwtSlot {
}
export interface PcatSession {
authorization: string;
apiKey: string; // x-api-key (TWS- token)
apiPath: string;
guiVersion: string;
userId: string;
origin: string;
referer: string;
proxyUrl: string | null;
proxyConfig: { server: string; username: string; password: string } | null;
_slot: JwtSlot;

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

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

25
pnpm-lock.yaml generated
View File

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