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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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="{"imageViewerParamsUrl":"/ford/fordp_parts/json-image-ticket.action?x=1&y=2"}"></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="{"commonTxt":{"ok":"TAMAM"}}"></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="{"commonTxt":{}}"></div>',
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(/"/g, '"').replace(/&/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(/"/g, '"').replace(/&/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(/"/g, '"')
|
||||
.replace(/&/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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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:-}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user