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

@@ -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}"`);