Merge pull request 'dev' (#112) from dev into main

Reviewed-on: #112
This commit was merged in pull request #112.
This commit is contained in:
2026-06-09 08:03:27 +00:00
20 changed files with 698 additions and 372 deletions

View File

@@ -17,6 +17,7 @@ function makeConfig(overrides: Record<string, string>): ConfigService {
const browser = {} as never;
const redis = { set: vi.fn() } as never;
const posthog = {} as never;
describe("EmexService proxy-port coercion (regression)", () => {
it("does not throw 'Invalid URL' when ports arrive as strings over a real range", () => {
@@ -27,7 +28,7 @@ describe("EmexService proxy-port coercion (regression)", () => {
EMEX_PROXY_PORT_END: "10099",
});
// Pre-fix: threw "Invalid URL" here (port "4510001" > 65535).
expect(() => new EmexService(config, browser, redis)).not.toThrow();
expect(() => new EmexService(config, browser, redis, posthog)).not.toThrow();
});
it("constructs with a single-port range (string env)", () => {
@@ -36,7 +37,7 @@ describe("EmexService proxy-port coercion (regression)", () => {
EMEX_PROXY_PORT_START: "823",
EMEX_PROXY_PORT_END: "823",
});
expect(() => new EmexService(config, browser, redis)).not.toThrow();
expect(() => new EmexService(config, browser, redis, posthog)).not.toThrow();
});
it("falls back to a valid default when the port env is garbage", () => {
@@ -45,11 +46,11 @@ describe("EmexService proxy-port coercion (regression)", () => {
EMEX_PROXY_PORT_START: "not-a-number",
EMEX_PROXY_PORT_END: "999999",
});
expect(() => new EmexService(config, browser, redis)).not.toThrow();
expect(() => new EmexService(config, browser, redis, posthog)).not.toThrow();
});
it("constructs cleanly with the proxy disabled", () => {
const config = makeConfig({ EMEX_USE_PROXY: "false" });
expect(() => new EmexService(config, browser, redis)).not.toThrow();
expect(() => new EmexService(config, browser, redis, posthog)).not.toThrow();
});
});

View File

@@ -21,6 +21,7 @@ import { ConfigService } from "@nestjs/config";
import { ProxyAgent } from "undici";
import { isBackfillContext } from "../../jobs/prefetch-context";
import { PostHogService } from "../../posthog/posthog.service";
import { RedisService } from "../../redis/redis.service";
import { parseUnitLeaves, parseVehicleTree } from "./emex-tree.parser";
import { EmexBrowserService } from "./emex.browser";
@@ -136,6 +137,7 @@ export class EmexService {
private configService: ConfigService,
private browserService: EmexBrowserService,
private redis: RedisService,
private posthog: PostHogService,
) {
// __dirname is apps/api/src/integrations/emex/ or dist/integrations/emex/
// Scraper lives at <monorepo-root>/scripts/emex-vin-scraper.js
@@ -618,6 +620,10 @@ export class EmexService {
| { type: "notFound" }
| { type: "error" }
> {
if (!(await this.posthog.isSourceLive("emex"))) {
this.logger.warn("EMEX disabled by kill switch (kill-source-emex)");
return { type: "notFound" };
}
try {
const vinUrl = `${EMEX_BASE_URL}/Vehicles.aspx?ft=findByVIN&c=&ssd=&vin=${vin}`;
const html = await this.fetchEmexHtml(vinUrl);
@@ -708,6 +714,10 @@ export class EmexService {
* Used after the user selects a vehicle from the multi-candidate modal.
*/
async decodeVinByIndex(vin: string, index: number): Promise<DecodedVehicle | null> {
if (!(await this.posthog.isSourceLive("emex"))) {
this.logger.warn("EMEX disabled by kill switch (kill-source-emex)");
return null;
}
try {
const vinUrl = `${EMEX_BASE_URL}/Vehicles.aspx?ft=findByVIN&c=&ssd=&vin=${vin}`;
const vinHtml = await this.fetchEmexHtml(vinUrl);

View File

@@ -8,6 +8,7 @@
import { Injectable, Logger } from "@nestjs/common";
import { isBackfillContext } from "../../jobs/prefetch-context";
import { PostHogService } from "../../posthog/posthog.service";
import { RedisService } from "../../redis/redis.service";
import { PartsCatalogsAuthService } from "./parts-catalogs-auth.service";
import {
@@ -40,6 +41,7 @@ export class PartsCatalogsService {
constructor(
private authService: PartsCatalogsAuthService,
private redis: RedisService,
private posthog: PostHogService,
) {}
/** Mark parts-catalogs as actively used (5min TTL) to defer prefetch worker */
@@ -62,6 +64,10 @@ export class PartsCatalogsService {
signal?: AbortSignal,
outcome?: { transient: boolean },
): Promise<PcatVinResult | null> {
if (!(await this.posthog.isSourceLive("parts-catalogs"))) {
this.logger.warn("parts-catalogs disabled by kill switch (kill-source-parts-catalogs)");
return null;
}
try {
const data = await this.fetchWithAuth("/car/info", { q: vin }, signal, {
timeoutMs: DECODE_REQUEST_TIMEOUT,

View File

@@ -102,3 +102,34 @@ describe("PL24FordLegacyService.parseFordGroupsFromHtml — nav-crumb junk filte
expect(names).not.toContain("Geri");
});
});
describe("PL24FordLegacyService.extractPsaImageTicketUrl — image-board ticket URL", () => {
const ex = svc as unknown as { extractPsaImageTicketUrl(html: string): string | null };
it("reads imageViewerParamsUrl from the id=jsinitparams attribute (Ford/PSA/Opel/Volvo)", () => {
const html =
'<div id="jsinitparams" data-params="{&quot;imageViewerParamsUrl&quot;:&quot;/ford/fordp_parts/json-image-ticket.action?x=1&amp;y=2&quot;}"></div>';
expect(ex.extractPsaImageTicketUrl(html)).toBe(
"/ford/fordp_parts/json-image-ticket.action?x=1&y=2",
);
});
it("falls back to the <script> JSON blob when jsinitparams only has localization (Hyundai/Kia/Nissan)", () => {
// Real shape: jsinitparams carries commonTxt only; imageViewerParamsUrl lives in
// a separate script blob with raw quotes and &-escaped ampersands.
const html =
'<div id="jsinitparams" data-params="{&quot;commonTxt&quot;:{&quot;ok&quot;:&quot;TAMAM&quot;}}"></div>' +
'<script>var p = {"qty":"Miktar","imageViewerParamsUrl":"/hyundai-kia-automotive-group/hyundai_parts/json-image-ticket.action?catalog=HMT1B0PA00\\u0026illustration=1\\u0026signature=abc"};</script>';
expect(ex.extractPsaImageTicketUrl(html)).toBe(
"/hyundai-kia-automotive-group/hyundai_parts/json-image-ticket.action?catalog=HMT1B0PA00&illustration=1&signature=abc",
);
});
it("returns null when no image ticket url is present", () => {
expect(
ex.extractPsaImageTicketUrl(
'<div id="jsinitparams" data-params="{&quot;commonTxt&quot;:{}}"></div>',
),
).toBeNull();
});
});

View File

@@ -1712,25 +1712,39 @@ export class PL24FordLegacyService {
const models = familyKey ? familyMap[familyKey] : undefined;
if (!Array.isArray(models) || models.length === 0) return [];
const results: { code: string; name: string }[] = [];
// First pass: collect (catCode, baseCaption, year). Ford's modelFamilyToModelList
// typically gives every sub-catCode the SAME caption (just the family name like
// "Kuga" / "Galaxy"). Without disambiguation the UI shows N identical buttons.
const raw: Array<{ code: string; baseName: string; year: string }> = [];
for (const m2 of models) {
if (m2.gray === true) continue; // Skip unavailable sub-models
// catCode may be in identifier OR embedded in the URL
const catCode =
m2.identifier?.trim() || "" || m2.url?.match(/[?&]catCode=([^&"]+)/)?.[1] || "";
const catCode = m2.identifier?.trim() || m2.url?.match(/[?&]catCode=([^&"]+)/)?.[1] || "";
if (!catCode) continue;
// Build display name: caption if available, else family+year range
const caption = m2.caption?.trim();
const yearStr = m2.year?.replace(/[()]/g, "").trim();
const yearDisplay = yearStr ? ` (${yearStr.replace(",", "-")})` : "";
const name = caption || `${familyId}${yearDisplay}`;
results.push({ code: catCode, name });
const caption = m2.caption?.trim() || "";
const yearStr = m2.year?.replace(/[()]/g, "").trim() || "";
raw.push({ code: catCode, baseName: caption || familyId, year: yearStr });
}
return results;
// Count how many entries share each baseName — if the upstream uses the same
// caption for multiple sub-codes, we have to graft a disambiguator on.
const baseCounts = new Map<string, number>();
for (const r of raw) baseCounts.set(r.baseName, (baseCounts.get(r.baseName) ?? 0) + 1);
return raw.map(({ code, baseName, year }) => {
const isDup = (baseCounts.get(baseName) ?? 0) > 1;
const yearDisplay = year ? year.replace(",", "-") : "";
// Year is the friendliest disambiguator. catCode is the last-resort fallback
// since it's an internal identifier ("CBV") — better than two identical buttons.
if (isDup) {
return {
code,
name: yearDisplay ? `${baseName} (${yearDisplay})` : `${baseName} (${code})`,
};
}
return { code, name: yearDisplay ? `${baseName} (${yearDisplay})` : baseName };
});
}
/**
@@ -2833,23 +2847,38 @@ export class PL24FordLegacyService {
}
/**
* Extract the json-image-ticket.action URL from image-board.action HTML jsinitparams.
* Extract the json-image-ticket.action URL from image-board.action HTML.
*
* PSA / Ford / Opel / Volvo embed it in the `id="jsinitparams"` data-params
* attribute (HTML-encoded JSON). Hyundai / Kia / Nissan instead carry only a
* localization dict in jsinitparams and put `imageViewerParamsUrl` in a separate
* <script> JSON blob (raw quotes, &-escaped &) — so when the attribute parse
* yields nothing we scan the whole document as a brand-agnostic fallback.
* Without this fallback those three brands silently render parts with no schema
* image (and no hotspots).
*/
private extractPsaImageTicketUrl(html: string): string | null {
const m = html.match(/id="jsinitparams"[^>]+data-params="([^"]+)"/);
if (!m) return null;
try {
const dataParams = JSON.parse(m[1].replace(/&quot;/g, '"').replace(/&amp;/g, "&"));
// PSA: top-level imageViewerParamsUrl
// Ford/Hyundai/Opel/Volvo: nested under jsIlluData[0].imageViewerParamsUrl
return (
(dataParams.imageViewerParamsUrl as string) ||
(dataParams.jsIlluData?.[0]?.imageViewerParamsUrl as string) ||
null
);
} catch {
return null;
if (m) {
try {
const dataParams = JSON.parse(m[1].replace(/&quot;/g, '"').replace(/&amp;/g, "&"));
const url =
(dataParams.imageViewerParamsUrl as string) ||
(dataParams.jsIlluData?.[0]?.imageViewerParamsUrl as string);
if (url) return url;
} catch {
// fall through to the script-blob scan below
}
}
// Hyundai/Kia/Nissan: imageViewerParamsUrl lives in a <script> JSON blob.
const blob = html.match(/"imageViewerParamsUrl"\s*:\s*"([^"]+)"/);
if (blob) {
return blob[1]
.replace(/\\u0026/g, "&")
.replace(/&quot;/g, '"')
.replace(/&amp;/g, "&");
}
return null;
}
/**
@@ -3284,7 +3313,7 @@ export class PL24FordLegacyService {
url: string,
serviceName: string,
isFullUrl = false,
account: "tr" | "de" = "tr",
accountParam: "tr" | "de" = "tr",
retried = false,
): Promise<string | null> {
// Hyundai/Kia/Nissan parts are licensed ONLY on the de account — the tr
@@ -3292,8 +3321,9 @@ export class PL24FordLegacyService {
// fetch for these brands to de (which carries the Korean/Japanese license),
// and ensure the de service token is authorized. Verified on dev: de yields
// 266 Hyundai / 169 Kia models, non-demo, with real drill content.
if (LEGACY_DE_SERVICES.has(serviceName)) {
account = "de";
const isDeOnly = LEGACY_DE_SERVICES.has(serviceName);
const account = isDeOnly ? "de" : accountParam;
if (isDeOnly) {
await this.authService.authorizeServiceForAccount(serviceName, "de");
}
// Some catalogs (Volvo vin-group.action) store hrefs relative to the catalog

View File

@@ -14,6 +14,7 @@ const svc = new PL24Service(
configStub, // configService
{} as never, // redis
{} as never, // storage
{} as never, // posthog
);
const p = svc as unknown as {
parseVehicleResponse(

View File

@@ -16,6 +16,7 @@ import {
import { ConfigService } from "@nestjs/config";
import { extractModelYear } from "@sase/shared";
import { isBackfillContext } from "../../jobs/prefetch-context";
import { PostHogService } from "../../posthog/posthog.service";
import { RedisService } from "../../redis/redis.service";
import { StorageService } from "../../storage/storage.service";
import { PL24AuthService } from "./pl24-auth.service";
@@ -41,6 +42,19 @@ import {
isP5Modern,
} from "./pl24.types";
/**
* Legacy (P4) architecture → kill-switch source tag. Lets one fragile brand be
* disabled (`kill-source-psa`, `kill-source-volvo`, …) without taking all of PL24
* down. Unmapped legacy brands fall back to the shared `pl24-legacy` switch.
*/
const LEGACY_ARCH_SOURCE_TAG: Record<string, string> = {
LEGACY_PSA: "psa",
LEGACY_VOLVO: "volvo",
LEGACY_FORD: "ford",
LEGACY_OPEL: "opel",
LEGACY_HYUNDAI_KIA: "hyundai-kia",
};
@Injectable()
export class PL24Service {
private readonly logger = new Logger(PL24Service.name);
@@ -59,6 +73,7 @@ export class PL24Service {
private configService: ConfigService,
private redis: RedisService,
private storage: StorageService,
private posthog: PostHogService,
) {
this.baseUrl = this.configService.get<string>("pl24.baseUrl", "https://www.partslink24.com");
this.timeout = 30000;
@@ -70,6 +85,10 @@ export class PL24Service {
* Only P5 Modern architecture is supported here; legacy is dispatched to fordLegacyService.
*/
async decodeVin(vin: string, userId?: string): Promise<PL24DecodedVehicle | null> {
if (!(await this.posthog.isSourceLive("pl24"))) {
this.logger.warn("PL24 disabled by kill switch (kill-source-pl24)");
return null;
}
const cleanVin = vin.toUpperCase().replace(/[^A-HJ-NPR-Z0-9]/g, "");
this.validateVin(cleanVin);
@@ -88,6 +107,15 @@ export class PL24Service {
// Dispatch P4 legacy architectures to the generic legacy service
if (!isP5Modern(serviceName)) {
// Per-brand kill switch: disable one fragile legacy brand without taking all
// of PL24 down. PSA/Volvo/Ford/etc. break independently upstream (cf. the
// PSA illustration-dispatch outage that broke 41/43 vehicles for months).
const legacyArch = getServiceConfig(serviceName)?.architecture ?? "";
const brandTag = LEGACY_ARCH_SOURCE_TAG[legacyArch] ?? "pl24-legacy";
if (!(await this.posthog.isSourceLive(brandTag))) {
this.logger.warn(`PL24 ${brandTag} disabled by kill switch (kill-source-${brandTag})`);
return null;
}
// PSA (Peugeot/Citroën/DS) uses a dedicated FI/VIN-indexed decode service.
if (getServiceConfig(serviceName)?.architecture === "LEGACY_PSA") {
return this.psaService.decodeVinForService(cleanVin, serviceName, userId);

View File

@@ -10,6 +10,7 @@ import { and, asc, eq, gt, inArray, isNull, notExists, sql } from "drizzle-orm";
import { CategoriesService } from "../categories/categories.service";
import { DATABASE, type Database } from "../database/database.provider";
import { categories, parts, vehicles } from "../database/schema/core";
import { PostHogService } from "../posthog/posthog.service";
import { RedisService } from "../redis/redis.service";
import { QUEUE_NAMES, getBullConnection } from "./bull.config";
import { backfillContext } from "./prefetch-context";
@@ -79,6 +80,7 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
@Inject(CATALOG_PREFETCH_QUEUE) private queue: Queue,
private categoriesService: CategoriesService,
private redis: RedisService,
private posthog: PostHogService,
@Inject(DATABASE) private db: Database,
) {}
@@ -361,11 +363,26 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
return;
}
// Live-tunable knobs via the `cfg-backfill-tuning` remote-config flag — change
// batch size / backlog ceiling / business-hours gating from the PostHog UI
// without a redeploy. Falls back to the compiled-in defaults on any miss.
const cfg = await this.posthog.payload<{
batchSize?: number;
maxBacklog?: number;
businessHoursOnly?: boolean;
}>("cfg-backfill-tuning", {});
const batchSize =
typeof cfg.batchSize === "number" && cfg.batchSize > 0 ? cfg.batchSize : BACKFILL_BATCH_SIZE;
const maxBacklog =
typeof cfg.maxBacklog === "number" && cfg.maxBacklog > 0
? cfg.maxBacklog
: BACKFILL_MAX_BACKLOG;
// Self-throttle: don't pile on if the queue is already deep — let it drain.
const counts = await this.queue.getJobCounts("waiting", "delayed", "active");
const backlog = (counts.waiting ?? 0) + (counts.delayed ?? 0) + (counts.active ?? 0);
if (backlog > BACKFILL_MAX_BACKLOG) {
this.logger.log(`[backfill] Skip — queue backlog ${backlog} > ${BACKFILL_MAX_BACKLOG}`);
if (backlog > maxBacklog) {
this.logger.log(`[backfill] Skip — queue backlog ${backlog} > ${maxBacklog}`);
return;
}
@@ -374,7 +391,7 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
const eligible: string[] = [];
for (const s of BACKFILL_SOURCES) {
if (await this.redis.exists(`prefetch:activity:${s}`)) continue;
if (!isWithinTimeWindow(s)) continue;
if (cfg.businessHoursOnly !== false && !isWithinTimeWindow(s)) continue;
eligible.push(s);
}
if (eligible.length === 0) {
@@ -384,10 +401,10 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
const picked: Array<{ id: string; source: string }> = [];
const seen = new Set<string>();
const overfetch = BACKFILL_BATCH_SIZE * 4; // headroom for in-flight skips
const overfetch = batchSize * 4; // headroom for in-flight skips
const tryPick = async (v: { id: string; source: string | null }): Promise<void> => {
if (picked.length >= BACKFILL_BATCH_SIZE || seen.has(v.id) || !v.source) return;
if (picked.length >= batchSize || seen.has(v.id) || !v.source) return;
if (await this.redis.exists(`prefetch:scheduled:${v.id}`)) return; // already in flight
// Skip exhausted residue: vehicles whose prefetch keeps finishing with zero
// parts (no catalog data). They'd otherwise be re-picked every wave forever.
@@ -416,7 +433,7 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
// Phase 2 — rolling rescan of ALL decoded vehicles to gap-fill partially-fetched
// ones. A createdAt cursor walks forward and wraps around at the end.
if (picked.length < BACKFILL_BATCH_SIZE) {
if (picked.length < batchSize) {
const cursorObj = await this.redis.getJson<{ ts: string }>(BACKFILL_CURSOR_KEY);
const cursor = cursorObj?.ts ? new Date(cursorObj.ts) : new Date(0);

View File

@@ -6,16 +6,26 @@ import type { PostHog as PostHogClient } from "posthog-node";
export class PostHogService implements OnModuleDestroy {
private readonly logger = new Logger(PostHogService.name);
private client: PostHogClient | null = null;
private enabled = false;
/** Analytics-capture gate — independent of flag eval so a non-prod env can
* evaluate flags without emitting events to the shared prod project. */
private captureEnabled = false;
/** True once a personal API key is configured → local flag evaluation works. */
private flagsEnabled = false;
constructor(private configService: ConfigService) {
const apiKey = this.configService.get<string>("POSTHOG_API_KEY");
// Capture is gated SEPARATELY from flag evaluation: a non-prod env can set the
// project key to evaluate flags (kill switches, rollout) while keeping capture
// off, so it never ships analytics to the shared prod project. Default on.
this.captureEnabled = this.configService.get<string>("POSTHOG_CAPTURE_ENABLED") !== "false";
if (apiKey) {
this.enabled = true;
// Lazily import posthog-node to avoid requiring it when not configured
// The client powers BOTH capture and local feature-flag evaluation.
// Lazily import posthog-node to avoid requiring it when not configured.
this.initClient(apiKey);
} else {
this.logger.warn("PostHog API key not configured — server-side analytics disabled");
this.logger.warn(
"PostHog project key not configured — server-side analytics & flags disabled",
);
}
}
@@ -23,11 +33,32 @@ export class PostHogService implements OnModuleDestroy {
try {
const { PostHog } = await import("posthog-node");
const host = this.configService.get<string>("POSTHOG_HOST") ?? "https://t.sase.tr";
this.client = new PostHog(apiKey, { host, flushAt: 1, flushInterval: 1000 });
this.logger.log("PostHog server-side client initialized");
// A personal API key (or feature-flags secure key) unlocks LOCAL flag
// evaluation: the SDK polls + caches flag definitions, so isEnabled() and
// payload() resolve with zero network I/O. That matters because the source
// kill switches sit on the VIN-decode hot path — a per-call network hop
// would be unacceptable. Without the key, server-side flags fail open (see
// isEnabled) and add no latency.
// Empty string (an unset compose `${VAR:-}`) must not be passed as a key, or
// the SDK polls flag definitions with a bogus credential — coalesce to undefined.
const personalApiKey =
this.configService.get<string>("POSTHOG_PERSONAL_API_KEY") || undefined;
this.client = new PostHog(apiKey, {
host,
personalApiKey,
featureFlagsPollingInterval: 30_000,
flushAt: 1,
flushInterval: 1000,
});
this.flagsEnabled = Boolean(personalApiKey);
this.logger.log(
`PostHog server-side client initialized (local flag evaluation: ${
this.flagsEnabled ? "enabled" : "disabled — set POSTHOG_PERSONAL_API_KEY"
})`,
);
} catch (err) {
this.logger.error("Failed to initialize PostHog client", (err as Error).stack);
this.enabled = false;
this.client = null;
}
}
@@ -46,7 +77,7 @@ export class PostHogService implements OnModuleDestroy {
* Uses distinctId from frontend identity when provided; otherwise uses "server".
*/
capture(event: string, properties?: Record<string, unknown>, distinctId = "server"): void {
if (!this.enabled || !this.client) return;
if (!this.captureEnabled || !this.client) return;
try {
this.client.capture({ distinctId, event, properties });
@@ -61,4 +92,82 @@ export class PostHogService implements OnModuleDestroy {
captureForUser(userId: string, event: string, properties?: Record<string, unknown>): void {
this.capture(event, { ...properties, $user_id: userId }, userId);
}
// ──────────────────────────────────────────────────────────────────────────
// Feature flags — server-side, local evaluation
// ──────────────────────────────────────────────────────────────────────────
/**
* Evaluate a boolean feature flag server-side, LOCALLY.
*
* `onlyEvaluateLocally` guarantees zero network I/O (no latency on the request
* being guarded) and that `distinctId` — which may be a VIN — is NEVER sent to
* PostHog. Returns `fallback` whenever the flag can't be resolved locally (client
* not ready, no personal API key, PostHog unreachable, or flag undefined), so
* every caller MUST pass a fail-safe default.
*/
async isEnabled(key: string, distinctId = "server", fallback = false): Promise<boolean> {
if (!this.client) return fallback;
try {
const value = await this.client.isFeatureEnabled(key, distinctId, {
onlyEvaluateLocally: true,
sendFeatureFlagEvents: false,
});
return value ?? fallback;
} catch (err) {
this.logger.warn(`Feature flag "${key}" evaluation failed: ${(err as Error).message}`);
return fallback;
}
}
/**
* Resolve a multivariant flag to its variant key (e.g. "control" / "test") for
* guarded / percentage rollout of new code paths. Pass the VIN as `distinctId`
* for stable per-VIN bucketing. Local-only; never leaks the id. Returns
* `fallback` when unresolved.
*/
async variant(
key: string,
distinctId = "server",
fallback?: string,
): Promise<string | undefined> {
if (!this.client) return fallback;
try {
const value = await this.client.getFeatureFlag(key, distinctId, {
onlyEvaluateLocally: true,
sendFeatureFlagEvents: false,
});
return typeof value === "string" ? value : fallback;
} catch {
return fallback;
}
}
/**
* Read a remote-config / flag payload (JSON). Used OFF the hot path (e.g. the
* hourly backfill cron), so remote evaluation is acceptable and the personal
* key is not required. Returns `fallback` on any failure.
*/
async payload<T>(key: string, fallback: T, distinctId = "server"): Promise<T> {
if (!this.client) return fallback;
try {
const value = await this.client.getFeatureFlagPayload(key, distinctId);
return (value as T | undefined) ?? fallback;
} catch (err) {
this.logger.warn(`Feature flag payload "${key}" failed: ${(err as Error).message}`);
return fallback;
}
}
/**
* Is an upstream source allowed to run? Returns false ONLY when its kill switch
* `kill-source-<source>` is explicitly enabled in PostHog. Fail-OPEN: any
* inability to evaluate (no key, flag absent, PostHog down) leaves the source
* live, so an analytics outage can never black out VIN decoding.
*
* @example if (!(await this.posthog.isSourceLive("emex"))) return { type: "notFound" };
*/
async isSourceLive(source: string, distinctId = "server"): Promise<boolean> {
return !(await this.isEnabled(`kill-source-${source}`, distinctId, false));
}
}

View File

@@ -1,9 +1,9 @@
import { api } from "@/lib/api-client";
import { useTranslation } from "@/lib/i18n";
import { Button, Card, CardContent, CardHeader, CardTitle } from "@sase/ui";
import { Card, CardContent, CardHeader, CardTitle } from "@sase/ui";
import { useQuery } from "@tanstack/react-query";
import { Loader2 } from "lucide-react";
import { useState } from "react";
import { useEffect, useRef, useState } from "react";
interface VariantItem {
code: string;
@@ -42,33 +42,42 @@ export function FordVariantSelector({ vehicleId, onSelect }: FordVariantSelector
const hasEngines = engines.length > 0;
const hasGearboxes = gearboxes.length > 0;
// Auto-advance: if a selection makes all required dimensions filled in, fire
// onSelect immediately instead of asking the user to click an extra button.
// For LEGACY_FORD this is almost always after the very first pick because the
// upstream returns no engines/gearboxes — one click should go to categories.
const handleYearSelect = (code: string) => {
setSelectedYear(code);
setSelectedEngine(null);
setSelectedGearbox(null);
if (!hasEngines && !hasGearboxes) {
onSelect(code, "_nor_", "_nor_");
}
};
const handleEngineSelect = (code: string) => {
setSelectedEngine(code);
setSelectedGearbox(null);
if (!hasGearboxes) {
onSelect(selectedYear ?? "_nor_", code, "_nor_");
}
};
const handleGearboxSelect = (code: string) => {
setSelectedGearbox(code);
onSelect(selectedYear ?? "_nor_", selectedEngine ?? "_nor_", code);
};
// No variants at all (config came back empty) — skip the selector outright
// instead of forcing the user to click a button that does nothing meaningful.
const hasAny = hasYears || hasEngines || hasGearboxes;
const canProceed =
!hasAny || // No variants available — can always proceed
((!hasYears || !!selectedYear) &&
(!hasEngines || !!selectedEngine) &&
(!hasGearboxes || !!selectedGearbox));
const handleProceed = () => {
if (!canProceed) return;
// When no variants, use "_nor_" so hasVariant=true and variant selector is skipped
onSelect(selectedYear ?? "_nor_", selectedEngine ?? "_nor_", selectedGearbox ?? "_nor_");
};
const autoSkipFired = useRef(false);
useEffect(() => {
if (!isLoading && config && !hasAny && !autoSkipFired.current) {
autoSkipFired.current = true;
onSelect("_nor_", "_nor_", "_nor_");
}
}, [isLoading, config, hasAny, onSelect]);
return (
<Card>
@@ -152,17 +161,11 @@ export function FordVariantSelector({ vehicleId, onSelect }: FordVariantSelector
</div>
)}
{/* No config available — allow skipping */}
{!hasYears && !hasEngines && !hasGearboxes && (
{/* No config available — autoSkip useEffect above will fire
onSelect("_nor_",…) shortly; show a brief notice meanwhile. */}
{!hasAny && (
<p className="text-sm text-muted-foreground">{t("catalog.fordVariant.noConfig")}</p>
)}
{/* Proceed button — only enabled when all required dimensions are selected */}
<div className="flex gap-2">
<Button onClick={handleProceed} disabled={!canProceed} className="w-full sm:w-auto">
{t("catalog.fordVariant.proceed")}
</Button>
</div>
</>
)}
</CardContent>

View File

@@ -1,6 +1,6 @@
import { api } from "@/lib/api-client";
import { useTranslation } from "@/lib/i18n";
import { Button, Card, CardContent, CardHeader, CardTitle } from "@sase/ui";
import { Card, CardContent, CardHeader, CardTitle } from "@sase/ui";
import { useQuery } from "@tanstack/react-query";
import { Loader2 } from "lucide-react";
import { useState } from "react";
@@ -66,6 +66,9 @@ export function PsaVariantSelector({ vehicleId, onSelect }: PsaVariantSelectorPr
setSelectedGearbox(null);
};
// Auto-advance on the final dimension — gearbox is always the last step in
// PSA's body→engine→gearbox flow, so picking one is unambiguous intent to
// proceed. No reason to make the user click an extra button.
const handleGearboxSelect = (code: string | "_all_") => {
if (code === "_all_") {
if (!selectedBody || !selectedEngine) return;
@@ -73,16 +76,11 @@ export function PsaVariantSelector({ vehicleId, onSelect }: PsaVariantSelectorPr
return;
}
setSelectedGearbox(code);
};
const handleProceed = () => {
if (selectedBody && selectedEngine && selectedGearbox) {
onSelect(selectedBody, selectedEngine, selectedGearbox);
if (selectedBody && selectedEngine) {
onSelect(selectedBody, selectedEngine, code);
}
};
const canProceed = selectedBody && selectedEngine && selectedGearbox;
return (
<Card>
<CardHeader>
@@ -198,13 +196,6 @@ export function PsaVariantSelector({ vehicleId, onSelect }: PsaVariantSelectorPr
)}
</div>
)}
{/* Proceed button */}
{canProceed && (
<Button onClick={handleProceed} className="w-full sm:w-auto">
{t("catalog.psaVariant.proceed")}
</Button>
)}
</CardContent>
</Card>
);

View File

@@ -109,8 +109,11 @@ export function CategoryColumns({
return (
<div
ref={scrollRef}
className="flex overflow-x-auto border rounded-md"
style={{ minHeight: 320 }}
// Dynamic height — the outer container stretches to the tallest column's
// natural content (flex children share height), capped at viewport - chrome
// so a 200-leaf catalog can't overflow the page. min-h keeps an empty/cold
// state from collapsing to nothing.
className="flex overflow-x-auto rounded-md border min-h-[24rem] max-h-[calc(100dvh-220px)]"
>
{columns.map((col, colIdx) => (
<ColumnPanel
@@ -212,8 +215,10 @@ function ColumnPanel({
return (
<div
// No fixed maxHeight — each column flexes to the tallest column's height
// (default flex stretch), and overflow-y-auto only kicks in once the outer
// viewport-based cap on the parent compresses the row.
className={cn("w-[220px] shrink-0 overflow-y-auto", !isLast && "border-r")}
style={{ maxHeight: 420 }}
>
{categories.map((category) => {
const isSelected = selectedId === category.id;

View File

@@ -160,6 +160,27 @@
"oldest": "Year (oldest first)",
"alphabetical": "Model (A→Z)"
},
"categoryCount": "{count} categories",
"vehicleSpecs": {
"engine": "Engine",
"body": "Body",
"transmission": "Transmission",
"market": "Market"
},
"pcat": {
"searchPlaceholder": "Search vehicle",
"vehicleCount": "{count} vehicles",
"schemaCount": "{count} schemas"
},
"emex": {
"searchPlaceholder": "Search",
"optionCount": "{count} options",
"variantCount": "{count} variants",
"noResults": "No results found",
"noPartsTitle": "No parts data yet for this configuration",
"noPartsHint": "Try a different model or variant.",
"loadError": "Could not load catalog data. Please try again."
},
"tabSasetr": "SASE",
"tabPl24": "Pl24",
"tabPcat": "Pcat",
@@ -192,7 +213,7 @@
"fordVariant": {
"title": "Select Model",
"subtitle": "Optional — use Show All to browse all variants",
"modelYear": "Model Year",
"modelYear": "Variant",
"engine": "Engine",
"gearbox": "Gearbox",
"showAll": "Show All",
@@ -1168,4 +1189,4 @@
"decodeGeneric": "Something went wrong. Please try again."
}
}
}
}

View File

@@ -160,6 +160,27 @@
"oldest": "Yıla göre (eskiden yeniye)",
"alphabetical": "Modele göre (A→Z)"
},
"categoryCount": "{count} kategori",
"vehicleSpecs": {
"engine": "Motor",
"body": "Kasa",
"transmission": "Vites",
"market": "Pazar"
},
"pcat": {
"searchPlaceholder": "Araç ara",
"vehicleCount": "{count} araç",
"schemaCount": "{count} şema"
},
"emex": {
"searchPlaceholder": "Ara",
"optionCount": "{count} seçenek",
"variantCount": "{count} varyant",
"noResults": "Sonuç bulunamadı",
"noPartsTitle": "Bu araç konfigürasyonu için parça verisi henüz yok",
"noPartsHint": "Farklı bir model veya varyant seçmeyi deneyin.",
"loadError": "Katalog verileri yüklenemedi. Lütfen tekrar deneyin."
},
"tabSasetr": "SASE",
"tabPl24": "Pl24",
"tabPcat": "Pcat",
@@ -192,7 +213,7 @@
"fordVariant": {
"title": "Model Seçin",
"subtitle": "İsteğe bağlı — tüm varyantlar için Tümü seçeneğini kullanın",
"modelYear": "Model Yılı",
"modelYear": "Varyant",
"engine": "Motor",
"gearbox": "Şanzıman",
"showAll": "Tümü",
@@ -1168,4 +1189,4 @@
"decodeGeneric": "Bir hata oluştu. Lütfen tekrar deneyin."
}
}
}
}

View File

@@ -1,24 +1,21 @@
import { CatalogHeader } from "@/components/catalog/catalog-header";
import { FordVariantSelector } from "@/components/catalog/ford-variant-selector";
import { P5RestrictionSelector } from "@/components/catalog/p5-restriction-selector";
import { PsaVariantSelector } from "@/components/catalog/psa-variant-selector";
import { ViewModeToggle } from "@/components/catalog/view-mode-toggle";
import { CategoryColumns } from "@/components/categories/category-columns";
import { CategoryGrid } from "@/components/categories/category-grid";
import { CategoryTree } from "@/components/categories/category-tree";
import {
CardsViewIcon,
ListViewIcon,
TreeViewIcon,
} from "@/components/categories/view-toggle-icons";
import { api } from "@/lib/api-client";
import { useTranslation } from "@/lib/i18n";
import { KEYS_8 } from "@/lib/keys";
import { getUserSettings, setUserSetting } from "@/lib/user-settings";
import { Button, Card, CardContent, CardHeader, CardTitle, Skeleton } from "@sase/ui";
import { Skeleton } from "@sase/ui";
import { useQuery } from "@tanstack/react-query";
import { Link, createFileRoute, useNavigate } from "@tanstack/react-router";
import { ArrowLeft } from "lucide-react";
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { Cog, Gauge, MapPin, Settings2 } from "lucide-react";
import { useState } from "react";
import { KEYS_8 } from "@/lib/keys";
export const Route = createFileRoute("/dashboard/catalog_/$brandName_/$modelId/")({
validateSearch: (search) => ({
body: typeof search.body === "string" ? search.body : undefined,
@@ -29,14 +26,7 @@ export const Route = createFileRoute("/dashboard/catalog_/$brandName_/$modelId/"
component: CatalogVehiclePage,
});
function buildVariantQuery(body?: string, engine?: string, gearbox?: string): string {
const params = new URLSearchParams();
if (body) params.set("body", body);
if (engine) params.set("engine", engine);
if (gearbox) params.set("gearbox", gearbox);
const qs = params.toString();
return qs ? `?${qs}` : "";
}
type ViewMode = "grid" | "tree" | "columns";
function CatalogVehiclePage() {
const { brandName, modelId } = Route.useParams();
@@ -50,13 +40,13 @@ function CatalogVehiclePage() {
const mgp = search.mgp;
const hasVariant = !!(body || engine || gearbox || mgp);
const [viewMode, setViewMode] = useState<"grid" | "tree" | "columns">(
() => getUserSettings().categoryViewMode ?? "grid",
const [viewMode, setViewMode] = useState<ViewMode>(
() => (getUserSettings().categoryViewMode as ViewMode | undefined) ?? "grid",
);
const decodedBrandName = decodeURIComponent(brandName);
const changeViewMode = (mode: "grid" | "tree" | "columns") => {
const changeViewMode = (mode: ViewMode) => {
setViewMode(mode);
setUserSetting("categoryViewMode", mode);
};
@@ -72,8 +62,6 @@ function CatalogVehiclePage() {
const isP5WithRestrictions =
vehicle?.architecture === "P5_MODERN" &&
!!vehicle?.catalogPath &&
// Case-insensitive: Fiat's catalogPath is already a maingroups endpoint
// (/extern/groups/mdl/maingroups) → no restriction step, categories load directly.
!/\/maingroup/i.test(vehicle.catalogPath);
const showPsaVariantSelector = isPsa && !hasVariant;
const showFordVariantSelector = isP4Legacy && !hasVariant;
@@ -115,9 +103,6 @@ function CatalogVehiclePage() {
to: "/dashboard/catalog/$brandName/$modelId",
params: { brandName, modelId },
search: {
// For Ford (catCode) / Volvo (year) variants: keep the meaningful selection,
// strip _all_ / _nor_ (no-restriction) values to keep URL clean.
// Special case: if ALL are _nor_, pass body="_nor_" so hasVariant=true skips re-showing selector.
body: norm(selectedBody) ?? (selectedBody === "_nor_" ? "_nor_" : undefined),
engine: norm(selectedEngine),
gearbox: norm(selectedGearbox),
@@ -129,144 +114,54 @@ function CatalogVehiclePage() {
if (vehicleLoading) {
return (
<div className="space-y-6">
<Skeleton className="h-8 w-64" />
<Skeleton className="h-9 w-72" />
<Skeleton className="h-6 w-96" />
<Skeleton className="h-64 w-full" />
</div>
);
}
const titleYear = vehicle?.year ? ` (${vehicle.year})` : "";
const headerTitle = `${vehicle?.model ?? decodedBrandName}${titleYear}`;
const categoryCount = categoryTree?.length ?? 0;
const crumbs = [
{ label: t("catalog.title"), to: "/dashboard/catalog" },
{
label: decodedBrandName,
to: "/dashboard/catalog/$brandName",
search: { catalog: undefined },
},
{ label: vehicle?.model ?? "" },
];
return (
<div className="space-y-6">
{/* Header / Breadcrumb */}
<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")}
</Link>
{" / "}
<Link
to="/dashboard/catalog/$brandName"
params={{ brandName }}
search={{ catalog: undefined }}
className="hover:underline"
>
{decodedBrandName}
</Link>
{" / "}
<span className="font-medium text-foreground">{vehicle?.model}</span>
{hasVariant && (
<>
{body && body !== "_all_" && (
<>
<span className="mx-1">/</span>
<span className="font-medium text-foreground">{body}</span>
</>
)}
{engine && engine !== "_all_" && (
<>
<span className="mx-1">/</span>
<span className="font-medium text-foreground">{engine}</span>
</>
)}
{gearbox && gearbox !== "_all_" && (
<>
<span className="mx-1">/</span>
<span className="font-medium text-foreground">{gearbox}</span>
</>
)}
</>
)}
</div>
<h1 className="text-xl font-bold">
{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"
>
<CardsViewIcon isActive={viewMode === "grid"} />
</button>
<button
type="button"
onClick={() => changeViewMode("tree")}
className={`rounded p-1.5 ${viewMode === "tree" ? "bg-accent" : "text-muted-foreground hover:text-foreground"}`}
title="Agac"
>
<TreeViewIcon isActive={viewMode === "tree"} />
</button>
<button
type="button"
onClick={() => changeViewMode("columns")}
className={`rounded p-1.5 ${viewMode === "columns" ? "bg-accent" : "text-muted-foreground hover:text-foreground"}`}
title="Sutun"
>
<ListViewIcon isActive={viewMode === "columns"} />
</button>
</div>
</div>
<CatalogHeader
crumbs={crumbs}
title={headerTitle}
onBack={() =>
navigate({
to: "/dashboard/catalog/$brandName",
params: { brandName },
search: { catalog: undefined },
})
}
actions={
!showVariantSelector ? (
<ViewModeToggle
value={viewMode}
onChange={changeViewMode}
groupLabelKey="catalog.view.groupLabel"
/>
) : undefined
}
/>
{/* Vehicle Info */}
{vehicle && (
<Card>
<CardHeader>
<CardTitle className="text-base">{t("catalog.models")}</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-3 text-sm sm:grid-cols-3">
{vehicle.engine && (
<div>
<span className="text-muted-foreground">Motor:</span>{" "}
<span className="font-medium">{vehicle.engine}</span>
</div>
)}
{vehicle.bodyType && (
<div>
<span className="text-muted-foreground">Kasa:</span>{" "}
<span className="font-medium">{vehicle.bodyType}</span>
</div>
)}
{vehicle.transmission && (
<div>
<span className="text-muted-foreground">Vites:</span>{" "}
<span className="font-medium">{vehicle.transmission}</span>
</div>
)}
{vehicle.market && (
<div>
<span className="text-muted-foreground">Pazar:</span>{" "}
<span className="font-medium">{vehicle.market}</span>
</div>
)}
</div>
</CardContent>
</Card>
)}
{/* Spec chips — flat row instead of a card. Only renders if at least one
spec is populated. Each chip carries an icon so the row reads as
metadata, not a free-floating label list. */}
{vehicle && <VehicleSpecRow vehicle={vehicle} variantSearch={variantSearch} />}
{/* Variant Selector OR Categories */}
{showPsaVariantSelector ? (
@@ -276,36 +171,41 @@ function CatalogVehiclePage() {
) : showP5RestrictionSelector ? (
<P5RestrictionSelector vehicleId={modelId} onComplete={handleP5RestrictionComplete} />
) : (
<Card>
<CardHeader>
<CardTitle className="text-base">{t("catalog.categories")}</CardTitle>
</CardHeader>
<CardContent
className={viewMode === "columns" ? "p-0 overflow-hidden rounded-b-lg" : undefined}
>
{categoriesLoading ? (
<div className="space-y-2">
{KEYS_8.map((__k) => (
<Skeleton key={__k} className="h-8 w-full" />
))}
</div>
) : viewMode === "grid" ? (
<CategoryGrid
categories={categoryTree || []}
vehicleId={modelId}
catalogMode
brandName={brandName}
variantSearch={variantSearch}
/>
) : viewMode === "tree" ? (
<CategoryTree
categories={categoryTree || []}
vehicleId={modelId}
catalogMode
brandName={brandName}
variantSearch={variantSearch}
/>
) : (
<section className="space-y-3">
<div className="flex items-baseline justify-between">
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
{t("catalog.categories")}
</h2>
{categoryTree && categoryTree.length > 0 && (
<span className="text-xs tabular-nums text-muted-foreground">
{t("catalog.categoryCount", { count: categoryCount })}
</span>
)}
</div>
{categoriesLoading ? (
<div className="space-y-2">
{KEYS_8.map((__k) => (
<Skeleton key={__k} className="h-10 w-full rounded-md" />
))}
</div>
) : viewMode === "grid" ? (
<CategoryGrid
categories={categoryTree || []}
vehicleId={modelId}
catalogMode
brandName={brandName}
variantSearch={variantSearch}
/>
) : viewMode === "tree" ? (
<CategoryTree
categories={categoryTree || []}
vehicleId={modelId}
catalogMode
brandName={brandName}
variantSearch={variantSearch}
/>
) : (
<div className="overflow-hidden rounded-lg border border-border">
<CategoryColumns
categories={categoryTree || []}
vehicleId={modelId}
@@ -313,10 +213,82 @@ function CatalogVehiclePage() {
brandName={brandName}
variantSearch={variantSearch}
/>
)}
</CardContent>
</Card>
</div>
)}
</section>
)}
</div>
);
}
function VehicleSpecRow({
vehicle,
variantSearch,
}: {
vehicle: {
engine?: string | null;
bodyType?: string | null;
transmission?: string | null;
market?: string | null;
};
variantSearch?: { body?: string; engine?: string; gearbox?: string; mgp?: string };
}) {
const { t } = useTranslation();
// Show the catalog-vehicle's intrinsic specs first; overlay the actively
// selected variant (PSA/Ford restrictions) when the user has drilled
// further. Skip non-meaningful sentinels.
const items: Array<{ key: string; label: string; value: string; Icon: typeof Gauge }> = [];
const skip = (v?: string | null) => !v || v === "_all_" || v === "_nor_";
const engineValue = !skip(variantSearch?.engine) ? variantSearch?.engine : vehicle.engine;
const bodyValue = !skip(variantSearch?.body) ? variantSearch?.body : vehicle.bodyType;
const gearboxValue = !skip(variantSearch?.gearbox)
? variantSearch?.gearbox
: vehicle.transmission;
if (engineValue)
items.push({
key: "engine",
label: t("catalog.vehicleSpecs.engine"),
value: engineValue,
Icon: Gauge,
});
if (bodyValue)
items.push({
key: "body",
label: t("catalog.vehicleSpecs.body"),
value: bodyValue,
Icon: Cog,
});
if (gearboxValue)
items.push({
key: "transmission",
label: t("catalog.vehicleSpecs.transmission"),
value: gearboxValue,
Icon: Settings2,
});
if (vehicle.market)
items.push({
key: "market",
label: t("catalog.vehicleSpecs.market"),
value: vehicle.market,
Icon: MapPin,
});
if (items.length === 0) return null;
return (
<div className="flex flex-wrap items-center gap-2">
{items.map(({ key, label, value, Icon }) => (
<span
key={key}
className="inline-flex items-center gap-2 rounded-full border border-border bg-muted/40 px-3 py-1 text-xs"
>
<Icon className="size-3.5 text-muted-foreground" aria-hidden="true" />
<span className="text-muted-foreground">{label}:</span>
<span className="font-medium text-foreground">{value}</span>
</span>
))}
</div>
);
}

View File

@@ -163,7 +163,7 @@ function EmexVehicleListPage() {
{wizardError && (
<div className="flex items-center gap-2 rounded-lg border border-destructive/50 bg-destructive/10 p-4 text-sm text-destructive">
<AlertCircle className="size-4 shrink-0" />
<p>Katalog verileri yüklenemedi. Lütfen tekrar deneyin.</p>
<p>{t("catalog.emex.loadError")}</p>
</div>
)}
@@ -192,7 +192,9 @@ function EmexVehicleListPage() {
</div>
) : matchedVehicles && matchedVehicles.length > 0 ? (
<div className="space-y-1">
<p className="text-xs text-muted-foreground">{matchedVehicles.length} varyant</p>
<p className="text-xs tabular-nums text-muted-foreground">
{t("catalog.emex.variantCount", { count: matchedVehicles.length })}
</p>
{matchedVehicles.map((v) => (
<VehicleRow key={v.id} vehicle={v} catalogCode={catalogCode} />
))}
@@ -200,11 +202,9 @@ function EmexVehicleListPage() {
) : (
<div className="rounded-lg border border-dashed border-border py-8 text-center">
<Car className="mx-auto mb-2 size-8 text-muted-foreground/50" />
<p className="text-sm text-muted-foreground">
Bu araç konfigürasyonu için parça verisi henüz mevcut değil.
</p>
<p className="text-sm text-muted-foreground">{t("catalog.emex.noPartsTitle")}</p>
<p className="mt-1 text-xs text-muted-foreground/70">
Farklı bir model veya varyant seçmeyi deneyin.
{t("catalog.emex.noPartsHint")}
</p>
</div>
)}
@@ -228,27 +228,31 @@ function WizardStep({
row: WizardRow;
onSelect: (rowName: string, option: WizardOption) => void;
}) {
const { t } = useTranslation();
const [search, setSearch] = useState("");
const filtered = useMemo(() => {
if (!search) return row.options;
const q = search.toLowerCase();
return row.options.filter((o) => o.value.toLowerCase().includes(q));
const q = search.toLocaleLowerCase("tr");
return row.options.filter((o) => o.value.toLocaleLowerCase("tr").includes(q));
}, [row.options, search]);
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<h2 className="text-sm font-medium">{row.name}</h2>
<span className="text-xs text-muted-foreground">{row.options.length} seçenek</span>
<span className="text-xs tabular-nums text-muted-foreground">
{t("catalog.emex.optionCount", { count: row.options.length })}
</span>
</div>
{row.options.length > 10 && (
<input
type="text"
type="search"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Ara..."
placeholder={t("catalog.emex.searchPlaceholder")}
aria-label={t("catalog.emex.searchPlaceholder")}
className="h-8 w-full rounded-md border border-input bg-background px-3 text-sm shadow-sm focus:outline-none focus:ring-1 focus:ring-ring"
/>
)}
@@ -266,7 +270,9 @@ function WizardStep({
</button>
))}
{filtered.length === 0 && (
<p className="py-4 text-center text-sm text-muted-foreground">Sonuç bulunamadı</p>
<p className="py-4 text-center text-sm text-muted-foreground">
{t("catalog.emex.noResults")}
</p>
)}
</div>
</div>

View File

@@ -1,10 +1,12 @@
import { CatalogHeader } from "@/components/catalog/catalog-header";
import { api } from "@/lib/api-client";
import { EMEX_GROUP_HIERARCHY } from "@/lib/emex-group-hierarchy";
import { useTranslation } from "@/lib/i18n";
import { Button, Skeleton } from "@sase/ui";
import { KEYS_8 } from "@/lib/keys";
import { Skeleton } from "@sase/ui";
import { useQuery } from "@tanstack/react-query";
import { Link, createFileRoute } from "@tanstack/react-router";
import { ArrowLeft, ChevronDown, ChevronRight, FolderOpen } from "lucide-react";
import { Link, createFileRoute, useNavigate } from "@tanstack/react-router";
import { ChevronDown, ChevronRight, FolderOpen } from "lucide-react";
import { useState } from "react";
export const Route = createFileRoute("/dashboard/catalog_/emex/$catalogCode_/$vehicleId/")({
@@ -60,6 +62,7 @@ function buildTree(groups: EmexGroup[]): TreeNode[] {
function EmexGroupListPage() {
const { t } = useTranslation();
const { catalogCode, vehicleId } = Route.useParams();
const navigate = useNavigate();
const { data: groups, isLoading } = useQuery({
queryKey: ["emex-groups", vehicleId],
@@ -69,23 +72,37 @@ function EmexGroupListPage() {
const tree = groups ? buildTree(groups) : [];
const isFlat = tree.length === 0 && groups && groups.length > 0;
const crumbs = [
{ label: t("catalog.title"), to: "/dashboard/catalog" },
{
label: decodeURIComponent(catalogCode),
to: "/dashboard/catalog/emex/$catalogCode",
search: {},
},
{ label: t("catalog.categories") },
];
return (
<div className="space-y-4">
<div className="flex items-center gap-3">
<Link to="/dashboard/catalog/emex/$catalogCode" params={{ catalogCode }}>
<Button variant="ghost" size="sm">
<ArrowLeft className="mr-1 size-4" />
{t("catalog.backToModels")}
</Button>
</Link>
<h1 className="text-xl font-bold">{t("catalog.categories")}</h1>
{groups && <span className="text-sm text-muted-foreground">({groups.length})</span>}
</div>
<div className="space-y-6">
<CatalogHeader
crumbs={crumbs}
title={t("catalog.categories")}
onBack={() =>
navigate({ to: "/dashboard/catalog/emex/$catalogCode", params: { catalogCode } })
}
actions={
groups && groups.length > 0 ? (
<span className="text-xs tabular-nums text-muted-foreground">
{t("catalog.categoryCount", { count: groups.length })}
</span>
) : undefined
}
/>
{isLoading ? (
<div className="space-y-2">
{Array.from({ length: 8 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full rounded-lg" />
{KEYS_8.map((__k) => (
<Skeleton key={__k} className="h-10 w-full rounded-lg" />
))}
</div>
) : !groups || groups.length === 0 ? (

View File

@@ -1,9 +1,11 @@
import { CatalogHeader } from "@/components/catalog/catalog-header";
import { api } from "@/lib/api-client";
import { useTranslation } from "@/lib/i18n";
import { Button, Skeleton } from "@sase/ui";
import { KEYS_8 } from "@/lib/keys";
import { Input, Skeleton } from "@sase/ui";
import { useQuery } from "@tanstack/react-query";
import { Link, createFileRoute } from "@tanstack/react-router";
import { ArrowLeft, Car, ChevronRight } from "lucide-react";
import { Link, createFileRoute, useNavigate } from "@tanstack/react-router";
import { Car, ChevronRight, Search, X } from "lucide-react";
import { useMemo, useState } from "react";
export const Route = createFileRoute("/dashboard/catalog_/pcat/$catalogId_/$modelId/")({
@@ -29,6 +31,7 @@ interface PcatCar {
function PcatCarsPage() {
const { t } = useTranslation();
const { catalogId, modelId } = Route.useParams();
const navigate = useNavigate();
const [search, setSearch] = useState("");
const { data: cars, isLoading } = useQuery({
@@ -39,84 +42,118 @@ function PcatCarsPage() {
const filtered = useMemo(() => {
if (!cars) return [];
if (!search) return cars;
const q = search.toLowerCase();
const q = search.toLocaleLowerCase("tr");
return cars.filter(
(c) =>
c.name.toLowerCase().includes(q) ||
c.engine?.toLowerCase().includes(q) ||
c.bodyType?.toLowerCase().includes(q),
c.name.toLocaleLowerCase("tr").includes(q) ||
c.engine?.toLocaleLowerCase("tr").includes(q) ||
c.bodyType?.toLocaleLowerCase("tr").includes(q),
);
}, [cars, search]);
const crumbs = [
{ label: t("catalog.title"), to: "/dashboard/catalog" },
{
label: catalogId.toUpperCase(),
to: "/dashboard/catalog/pcat/$catalogId",
search: {},
},
{ label: t("catalog.models") },
];
return (
<div className="space-y-4">
<div className="flex items-center gap-3">
<Link to="/dashboard/catalog/pcat/$catalogId" params={{ catalogId }}>
<Button variant="ghost" size="sm">
<ArrowLeft className="mr-1 size-4" />
{t("catalog.backToModels")}
</Button>
</Link>
<h1 className="text-xl font-bold">{catalogId.toUpperCase()}</h1>
</div>
<div className="space-y-6">
<CatalogHeader
crumbs={crumbs}
title={catalogId.toUpperCase()}
onBack={() => navigate({ to: "/dashboard/catalog/pcat/$catalogId", params: { catalogId } })}
/>
{isLoading ? (
<div className="space-y-2">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full rounded-lg" />
{KEYS_8.map((__k) => (
<Skeleton key={__k} className="h-14 w-full rounded-lg" />
))}
</div>
) : !cars || cars.length === 0 ? (
<p className="py-8 text-center text-muted-foreground">{t("catalog.noModels")}</p>
<div className="flex flex-col items-center justify-center py-16 text-center">
<Car className="mb-4 size-12 text-muted-foreground/40" />
<p className="text-muted-foreground">{t("catalog.noModels")}</p>
</div>
) : (
<>
<div className="space-y-3">
{cars.length > 10 && (
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Ara..."
className="h-8 w-full rounded-md border border-input bg-background px-3 text-sm shadow-sm focus:outline-none focus:ring-1 focus:ring-ring"
/>
)}
<div className="space-y-1">
<p className="text-xs text-muted-foreground">{filtered.length} araç</p>
{filtered.map((car) => (
<Link
key={car.id}
to="/dashboard/catalog/pcat/$catalogId/$modelId/$carId"
params={{ catalogId, modelId, carId: car.id }}
className="flex items-center gap-3 rounded-lg border border-border bg-card px-3 py-2.5 transition-colors hover:bg-accent"
>
<Car className="size-4 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{car.name}</p>
<p className="truncate text-xs text-muted-foreground">
{[
car.engine,
car.bodyType,
car.transmission,
car.fuelType,
car.yearFrom && car.yearTo
? `${car.yearFrom}-${car.yearTo}`
: car.yearFrom
? `${car.yearFrom}+`
: null,
]
.filter(Boolean)
.join(" · ")}
</p>
</div>
{car.schemasCount > 0 && (
<span className="shrink-0 text-xs text-muted-foreground">
{car.schemasCount} şema
</span>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="relative flex-1 sm:max-w-md">
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
type="search"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t("catalog.pcat.searchPlaceholder")}
aria-label={t("catalog.pcat.searchPlaceholder")}
className="pl-9"
/>
{search && (
<button
type="button"
onClick={() => setSearch("")}
aria-label={t("common.cancel")}
className="absolute right-2 top-1/2 -translate-y-1/2 rounded p-1 text-muted-foreground hover:text-foreground"
>
<X className="size-3.5" />
</button>
)}
<ChevronRight className="size-4 shrink-0 text-muted-foreground" />
</Link>
))}
</div>
</>
</div>
<span className="text-xs tabular-nums text-muted-foreground">
{t("catalog.pcat.vehicleCount", { count: filtered.length })}
</span>
</div>
)}
{filtered.length === 0 ? (
<div className="rounded-2xl border border-border bg-muted/10 p-8 text-center">
<p className="text-sm font-medium">{t("catalog.modelSearchNoMatch")}</p>
</div>
) : (
<div className="space-y-1.5">
{filtered.map((car) => (
<Link
key={car.id}
to="/dashboard/catalog/pcat/$catalogId/$modelId/$carId"
params={{ catalogId, modelId, carId: car.id }}
preload="intent"
className="flex items-center gap-3 rounded-lg border border-border bg-card px-3 py-3 transition-colors hover:border-primary/30 hover:bg-accent/40"
>
<Car className="size-4 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{car.name}</p>
<p className="truncate text-xs text-muted-foreground">
{[
car.engine,
car.bodyType,
car.transmission,
car.fuelType,
car.yearFrom && car.yearTo
? `${car.yearFrom}-${car.yearTo}`
: car.yearFrom
? `${car.yearFrom}+`
: null,
]
.filter(Boolean)
.join(" · ")}
</p>
</div>
{car.schemasCount > 0 && (
<span className="shrink-0 text-xs tabular-nums text-muted-foreground">
{t("catalog.pcat.schemaCount", { count: car.schemasCount })}
</span>
)}
<ChevronRight className="size-4 shrink-0 text-muted-foreground" />
</Link>
))}
</div>
)}
</div>
)}
</div>
);

View File

@@ -68,6 +68,11 @@ services:
# PostHog server-side analytics (payments, subscription lifecycle, $revenue).
# Empty key → PostHogService no-ops (server-side analytics disabled).
- POSTHOG_API_KEY=${POSTHOG_API_KEY:-}
# Personal/secure key → LOCAL feature-flag evaluation (source kill switches,
# guarded decode rollout, remote config). Empty → flags fail open (inert).
- POSTHOG_PERSONAL_API_KEY=${POSTHOG_PERSONAL_API_KEY:-}
# "false" on non-prod → evaluate flags but DON'T emit analytics to prod project.
- POSTHOG_CAPTURE_ENABLED=${POSTHOG_CAPTURE_ENABLED:-}
- POSTHOG_HOST=${POSTHOG_HOST:-https://eu.i.posthog.com}
- OTEL_ENABLED=${OTEL_ENABLED:-false}
- OTEL_EXPORTER_OTLP_ENDPOINT=${OTEL_EXPORTER_OTLP_ENDPOINT:-}
@@ -151,6 +156,11 @@ services:
# PostHog server-side analytics (payments, subscription lifecycle, $revenue).
# Empty key → PostHogService no-ops (server-side analytics disabled).
- POSTHOG_API_KEY=${POSTHOG_API_KEY:-}
# Personal/secure key → LOCAL feature-flag evaluation (source kill switches,
# guarded decode rollout, remote config). Empty → flags fail open (inert).
- POSTHOG_PERSONAL_API_KEY=${POSTHOG_PERSONAL_API_KEY:-}
# "false" on non-prod → evaluate flags but DON'T emit analytics to prod project.
- POSTHOG_CAPTURE_ENABLED=${POSTHOG_CAPTURE_ENABLED:-}
- POSTHOG_HOST=${POSTHOG_HOST:-https://eu.i.posthog.com}
- OTEL_ENABLED=${OTEL_ENABLED:-false}
- OTEL_EXPORTER_OTLP_ENDPOINT=${OTEL_EXPORTER_OTLP_ENDPOINT:-}

View File

@@ -98,6 +98,16 @@ export const envSchema = z.object({
// PostHog — product analytics (server-side)
POSTHOG_API_KEY: z.string().optional(),
POSTHOG_HOST: z.string().url().default("https://t.sase.tr"),
// Personal API key (or feature-flags secure key) → enables LOCAL server-side
// feature-flag evaluation: source kill switches, guarded decode rollout, and
// remote-config ops tuning. Without it those flags fail open (no effect, no
// added latency). Keep secret — never expose to the browser.
POSTHOG_PERSONAL_API_KEY: z.string().optional(),
// Gate server-side analytics CAPTURE independently of flag evaluation, so a
// non-prod env can evaluate flags (kill switches, rollout) while the project
// key is set WITHOUT shipping events to the shared prod project. Default on;
// set "false" on dev. (Plain string — read as `!== "false"`.)
POSTHOG_CAPTURE_ENABLED: z.string().optional(),
// Sentry — error tracking
SENTRY_DSN: z.string().url().optional(),