Compare commits
58 Commits
blog-cms-a
...
fix/backfi
| Author | SHA1 | Date | |
|---|---|---|---|
| a7678acbe1 | |||
| 0de6bd3faa | |||
| 4bcac424e0 | |||
| e91159003e | |||
| 45f7e3f5a4 | |||
| 9f47d15312 | |||
| 3c9245bd8b | |||
| ffcd417939 | |||
| 75d5b11475 | |||
| 4a6e673af1 | |||
| d82bcb560b | |||
| 46e4b7cfcb | |||
| 2e6c8ac751 | |||
| 2f6a0e3158 | |||
| b6e4deb7bd | |||
| c972a17670 | |||
| fd789574fd | |||
| 4b5f3bb41e | |||
| 63908b1fca | |||
| ef4ab9bcd5 | |||
| 1392a642a9 | |||
| b886a05b5b | |||
| 25ba37103d | |||
| a237e55874 | |||
| 8b7dc83bd2 | |||
| c2dfa5cd2e | |||
| 0652fb3a18 | |||
| 7aafdc92b6 | |||
| 6154656c3e | |||
| 02c8321023 | |||
| 2077a9724a | |||
| 43dd4d38ac | |||
| 34cb361b67 | |||
| c18c513139 | |||
| 5a13f13e8e | |||
| f29730fae0 | |||
| c1ae18e5f1 | |||
| 7f5db3234b | |||
| 72c3fe0dc2 | |||
| 7ce8e34d81 | |||
| 4ffc3577a5 | |||
| 572337ba62 | |||
| 07cf710bc3 | |||
| f955bb7ef8 | |||
| 597391bbdf | |||
| 4716232398 | |||
| e91af4b953 | |||
| 3b144313b3 | |||
| aeb438e442 | |||
| 24e44a49f8 | |||
| eae373c553 | |||
| fb2c28faf1 | |||
| 5e1675f95a | |||
| d3ae278c75 | |||
| cd54cf9443 | |||
| f02f97f69f | |||
| 11975d8b5a | |||
| d6c88ede73 |
127
PROJECT_INDEX.json
Normal file
127
PROJECT_INDEX.json
Normal file
@@ -0,0 +1,127 @@
|
||||
{
|
||||
"project": "sase-v2 (sase.tr)",
|
||||
"description": "Turkish B2B SaaS: VIN decode + OEM parts-catalog platform for parts dealers & repair shops",
|
||||
"generated": "2026-06-10",
|
||||
"generator": "/sc:index-repo",
|
||||
"humanReadable": "PROJECT_INDEX.md",
|
||||
"stack": {
|
||||
"packageManager": "pnpm@10.29.3",
|
||||
"node": "22 (alpine in Docker)",
|
||||
"monorepo": "pnpm workspaces + turbo",
|
||||
"language": "TypeScript",
|
||||
"lint": "biome"
|
||||
},
|
||||
"workspaces": {
|
||||
"apps/api": { "name": "api", "framework": "NestJS ^10.4", "role": "REST API :4000 + BullMQ worker (worker.ts)" },
|
||||
"apps/web": { "name": "web", "framework": "React ^19 + TanStack Router ^1.120 + Vite", "role": "SPA dashboard + marketing pages" },
|
||||
"packages/shared": { "name": "@sase/shared", "role": "zod schemas, types, constants (plans, error-codes, regex), utils (vin-validator, formatters, currency)" },
|
||||
"packages/config": { "name": "@sase/config", "role": "shared config" },
|
||||
"packages/ui": { "name": "@sase/ui", "role": "shadcn-style component library" }
|
||||
},
|
||||
"entryPoints": {
|
||||
"api": "apps/api/src/main.ts",
|
||||
"worker": "apps/api/src/worker.ts",
|
||||
"web": "apps/web/src/main.tsx",
|
||||
"migrations": "apps/api/src/database/migrate.ts"
|
||||
},
|
||||
"apiModules": {
|
||||
"vehicles": "VIN decode orchestration; fallback chain Corgi→PartsCatalogs→PL24→EMEX→NHTSA; slim response + Redis candidate stash (vin-cache-keys.ts)",
|
||||
"integrations/pl24": "PartsLink24 — shared P4 core + brand services: pl24.service, pl24-psa, pl24-volvo, pl24-ford, pl24-hyundai-kia, pl24-opel, pl24-ford-legacy, pl24-auth, parsers/",
|
||||
"integrations/emex": "emexdwc.ae scrape — HTTP (undici ProxyAgent) primary, Playwright fallback (emex.browser.ts), emex-tree.parser",
|
||||
"integrations/parts-catalogs": "PartsCatalogs API (pcat)",
|
||||
"integrations/p": "OEM cross-reference 'P' — TecDoc-derived MySQL (P_DB_* env): p.controller, p-source-db.service",
|
||||
"integrations/corgi": "offline WMI lookup (instant)",
|
||||
"integrations/vin-api": "NHTSA fallback decoder",
|
||||
"integrations/catalog-source-db": "upstream source-DB bridge",
|
||||
"catalog": "catalog browse — catalog/pcat-catalog/emex-catalog controllers+services",
|
||||
"categories": "category tree: partCount rollup, lazy drill/heal, PSA parent gates",
|
||||
"parts": "part listings",
|
||||
"brands": "brand canonicalization (case-insensitive lookup)",
|
||||
"auth": "better-auth cookie sessions",
|
||||
"users": "user management",
|
||||
"referrals": "referral program",
|
||||
"payments": "Stripe",
|
||||
"subscriptions": "subscription lifecycle",
|
||||
"plans": "plan definitions",
|
||||
"jobs": "BullMQ — queues: catalog-prefetch, emex-scrape, translation, lifecycle-email, subscription-expiry, query-cleanup; processors mirror queues; prefetch-worker.service",
|
||||
"posthog": "server-side feature-flag eval + kill-source-* switches",
|
||||
"meta-capi": "Meta Conversions API (event_id dedup, fail-open)",
|
||||
"notifications": "Novu lifecycle triggers",
|
||||
"email": "Postal HTTP API ops mail",
|
||||
"chatwoot": "support-desk integration",
|
||||
"analytics": "product analytics endpoints",
|
||||
"telemetry": "OpenTelemetry → OTLP exporters",
|
||||
"common": "shared infra incl. catalog-degradation → Sentry (fingerprinted, Redis-deduped)",
|
||||
"redis": "ioredis provider",
|
||||
"storage": "S3/MinIO",
|
||||
"translations": "OpenAI-backed translation",
|
||||
"admin": "admin endpoints",
|
||||
"internal-admin": "internal admin",
|
||||
"blog": "blog content",
|
||||
"changelog": "changelog content",
|
||||
"demo": "public demo flow",
|
||||
"contact": "contact form",
|
||||
"config": "configuration.ts + env.validation.ts (zod; empty-string trap)",
|
||||
"database": "Drizzle provider; schema split: core, pl24, emex, parts-catalogs, relations"
|
||||
},
|
||||
"webRoutes": {
|
||||
"public": ["index", "pricing", "blog", "blog_/$slug", "demo", "demo_/categories_/$categoryId", "about", "contact", "kvkk", "privacy", "terms"],
|
||||
"auth": ["login", "register", "forgot-password", "reset-password", "email-verified"],
|
||||
"dashboard": [
|
||||
"index", "search", "history", "settings", "billing", "changelog",
|
||||
"vehicles_/$id", "vehicles_/$id/categories_/$categoryId",
|
||||
"catalog/index", "catalog_/$brandName", "catalog_/$brandName_/$modelId", "catalog_/$brandName_/$modelId/categories_/$categoryId",
|
||||
"catalog_/pcat/$catalogId[/_$modelId/_$carId[/groups/$groupId]]",
|
||||
"catalog_/emex/$catalogCode[/_$vehicleId[/groups/$groupId]]",
|
||||
"oem.$code", "subscription", "service-test"
|
||||
],
|
||||
"admin": ["admin/index", "admin/users", "admin/analytics", "admin/referrals", "admin/copy-logs"]
|
||||
},
|
||||
"webLib": ["api-client", "auth-client", "posthog", "surveys", "meta-pixel", "faro", "sentry", "chatwoot", "i18n", "vehicle", "toast", "user-settings", "keys", "category-icons", "emex-group-hierarchy", "recently-used-brands"],
|
||||
"configFiles": {
|
||||
"turbo.json": "task pipeline",
|
||||
"biome.json": "lint/format",
|
||||
"docker-compose.coolify.yml": "prod/staging: api + worker + sase-redis; env vars must be referenced as ${VAR} per service block",
|
||||
"Dockerfile": "multi-stage node:22-alpine + Chromium; runtime imports must be in dependencies",
|
||||
"ecosystem.config.js": "PM2",
|
||||
"apps/api/src/config/env.validation.ts": "zod env gate"
|
||||
},
|
||||
"database": {
|
||||
"primary": "PostgreSQL via drizzle-orm ^0.41",
|
||||
"migrations": "apps/api/drizzle/ — 14 SQL files (0000–0013) + meta/_journal.json; custom SHA256-tracked runner (src/database/migrate.ts)",
|
||||
"schemas": ["core", "pl24", "emex", "parts-catalogs", "relations"],
|
||||
"secondary": "MySQL (mysql2) — TecDoc-derived 'P' OEM cross-ref db",
|
||||
"cache": "Redis (sase-redis container) — BullMQ + VIN candidate stash + dedup"
|
||||
},
|
||||
"tests": {
|
||||
"api": { "framework": "vitest", "count": 30, "pattern": "*.spec.ts", "run": "pnpm --filter api test" },
|
||||
"web": { "framework": "vitest (no Playwright e2e)", "count": 24, "pattern": "*.test.ts(x)", "run": "pnpm --filter web test" },
|
||||
"qa": "qa/post-deploy smoke checks"
|
||||
},
|
||||
"keyDependencies": {
|
||||
"api": ["@nestjs/* ^10.4", "drizzle-orm + postgres", "better-auth", "bullmq + ioredis", "stripe", "posthog-node", "openai", "undici", "@sentry/nestjs", "@opentelemetry/*", "mysql2", "zod"],
|
||||
"web": ["react ^19", "@tanstack/react-router + react-query", "zustand", "posthog-js", "@grafana/faro-web-sdk", "@sentry/react", "remotion", "better-auth", "zod"]
|
||||
},
|
||||
"commands": {
|
||||
"install": "pnpm install",
|
||||
"dev": "pnpm dev",
|
||||
"build": "pnpm build",
|
||||
"lint": "pnpm lint",
|
||||
"typecheck": "pnpm typecheck",
|
||||
"test": "pnpm test",
|
||||
"dbMigrate": "pnpm --filter api db:migrate",
|
||||
"dbStudio": "pnpm --filter api db:studio"
|
||||
},
|
||||
"deployment": {
|
||||
"platform": "Coolify (cool.semih.ai)",
|
||||
"staging": { "branch": "dev", "fqdn": "dev.sase.tr", "rule": "verify every change here first" },
|
||||
"production": { "branch": "main", "fqdn": "sase.tr", "rule": "promote only after dev verified" },
|
||||
"policy": "never edit on server/container; push-to-deploy via Gitea webhooks"
|
||||
},
|
||||
"docs": {
|
||||
"assistant": ["CLAUDE.md", "AGENTS.md"],
|
||||
"internal": "docs/ (INDEX.md, 00-testing.md, design-specs/, pl24-catalog/, product/, clarification/)",
|
||||
"rootReports": ["upstream-prob.md", "undecoded-vin-rca.md", "posthog-max-leverage.md", "posthog-feature-roadmap.md", "katalogayapi.md", "knowledge.md", "ralpRapor.md", "emex-hierarchy-plan.md", "MANIFEST.md", "MANIFEST-FN-403.md", "RECOVERY-FN-412.md", "SCOPING-FN-411.md", "SASE_PRD_Hesap_Paylasimi_Onleme_v1.1.md"],
|
||||
"bizWorkspace": "/home/s/ss/INDEX.md"
|
||||
}
|
||||
}
|
||||
88
PROJECT_INDEX.md
Normal file
88
PROJECT_INDEX.md
Normal file
@@ -0,0 +1,88 @@
|
||||
# Project Index: sase.tr (sase-v2)
|
||||
|
||||
Generated: 2026-06-10 · regenerate via `/sc:index-repo` · machine-readable twin: `PROJECT_INDEX.json`
|
||||
|
||||
Turkish B2B SaaS: VIN decode + OEM parts-catalog platform for parts dealers & repair shops.
|
||||
pnpm 10.29.3 workspaces + Turbo · Node 22 · TypeScript · Biome.
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
apps/api NestJS 10 backend — REST API (:4000) + separate BullMQ worker
|
||||
apps/web React 19 + TanStack Router/Query + Vite SPA (Remotion video, i18n)
|
||||
packages/shared @sase/shared — zod schemas, types, constants (plans, error-codes), utils (vin-validator, formatters)
|
||||
packages/config @sase/config — shared config
|
||||
packages/ui @sase/ui — shadcn-style components
|
||||
scripts/ one-off ops/debug (emex scrapers, backfills, deploy.sh, pl24-catalog-explorer)
|
||||
qa/post-deploy post-deploy smoke checks
|
||||
docs/ internal docs — see docs/INDEX.md (testing, design-specs, pl24-catalog, product)
|
||||
docker/ local dev compose (minio, nginx)
|
||||
```
|
||||
|
||||
## Entry Points
|
||||
|
||||
- `apps/api/src/main.ts` — API bootstrap (also serves built web via ServeStatic; no nginx in prod)
|
||||
- `apps/api/src/worker.ts` — BullMQ worker process (separate container)
|
||||
- `apps/api/src/database/migrate.ts` — custom SHA256-tracked migration runner over `apps/api/drizzle/` (14 SQL migrations + `meta/_journal.json`)
|
||||
- `apps/web/src/main.tsx` — SPA bootstrap (`routeTree.gen.ts` generated by `tsr generate`)
|
||||
|
||||
## API Modules (`apps/api/src/`)
|
||||
|
||||
- **vehicles/** — VIN decode orchestration; fallback chain Corgi → PartsCatalogs → PL24 → EMEX → NHTSA; slim decode response + Redis candidate stash (`vin-cache-keys.ts`)
|
||||
- **integrations/pl24/** — PartsLink24: shared P4 core + per-brand services (`psa`, `volvo`, `ford`, `hyundai-kia`, `opel`, `ford-legacy`) + `parsers/`
|
||||
- **integrations/emex/** — emexdwc.ae scrape: HTTP via undici ProxyAgent, Playwright fallback (`emex.browser.ts`), tree parser
|
||||
- **integrations/parts-catalogs/** — PartsCatalogs API ("pcat")
|
||||
- **integrations/p/** — OEM cross-reference ("P", TecDoc-derived MySQL db; `P_DB_*` env)
|
||||
- **integrations/corgi/**, **vin-api/** — offline WMI lookup; NHTSA fallback
|
||||
- **integrations/catalog-source-db/** — upstream source-DB bridge
|
||||
- **catalog/** — catalog browse endpoints (`catalog`, `pcat-catalog`, `emex-catalog` controllers/services)
|
||||
- **categories/**, **parts/**, **brands/** — category tree (partCount rollup, lazy drill/heal), parts, brand canonicalization
|
||||
- **auth/** (better-auth cookie sessions), **users/**, **referrals/**
|
||||
- **payments/** (Stripe), **subscriptions/**, **plans/**
|
||||
- **jobs/** — BullMQ queues + processors: `catalog-prefetch`, `emex-scrape`, `translation`, `lifecycle-email`, `subscription-expiry`, `query-cleanup`
|
||||
- **posthog/** — server-side flag eval + `kill-source-*` switches · **meta-capi/** — Meta Conversions API
|
||||
- **notifications/** (Novu), **email/** (Postal), **chatwoot/**
|
||||
- **analytics/**, **telemetry/** (OTel → OTLP), **common/** (incl. catalog-degradation → Sentry), **redis/**, **storage/** (S3/MinIO), **translations/** (OpenAI), **admin/**, **internal-admin/**, **blog/**, **changelog/**, **demo/**, **contact/**
|
||||
- **config/** — `env.validation.ts` zod gate (beware `""` vs undefined), `configuration.ts`
|
||||
- **database/** — Drizzle provider + `schema/` split: `core`, `pl24`, `emex`, `parts-catalogs`, `relations`
|
||||
|
||||
## Web (`apps/web/src/`)
|
||||
|
||||
- **routes/** — public: `index`, `pricing`, `blog`, `demo`, `about/contact/kvkk/privacy/terms` · `_auth/`: login, register, forgot/reset password, email-verified · `dashboard/`: `search` (main VIN flow), `vehicles_/$id[/categories_/$categoryId]`, catalog trees (`catalog_/$brandName/...`, `catalog_/pcat/...`, `catalog_/emex/...`), `oem.$code` (cross-ref), `subscription`, `billing`, `history`, `settings`, `service-test` (admin), `admin/*` (users, analytics, referrals, copy-logs)
|
||||
- **lib/** — `api-client`, `auth-client`, `posthog`, `surveys` (API-mode survey renderer), `meta-pixel`, `faro`, `sentry`, `chatwoot`, `i18n`, `vehicle`
|
||||
- **stores/** (zustand), **components/**, **remotion/**, **messages/** (i18n), **hooks/**
|
||||
|
||||
## Configuration
|
||||
|
||||
- `turbo.json`, `biome.json`, `tsconfig.json` — build/lint/typecheck pipeline
|
||||
- `docker-compose.coolify.yml` — prod/staging: `api` + `worker` + `sase-redis` (Coolify env reaches a service ONLY if its `environment:` block references `${VAR}`)
|
||||
- `Dockerfile` — multi-stage node:22-alpine + Chromium (runtime imports MUST be in `dependencies`)
|
||||
- `ecosystem.config.js` — PM2 config
|
||||
|
||||
## Tests
|
||||
|
||||
- API: 30 `*.spec.ts` (Vitest) → `pnpm --filter api test`
|
||||
- Web: 24 `*.test.ts(x)` (Vitest only; **no Playwright e2e**) → `pnpm --filter web test`; plus per-package `index.spec.ts`
|
||||
- `qa/post-deploy/` smoke checks
|
||||
|
||||
## Key Dependencies
|
||||
|
||||
NestJS 10 · drizzle-orm 0.41 + postgres · better-auth · bullmq + ioredis · stripe · posthog-node · openai (translations) · undici (proxied fetch — global fetch lacks `dispatcher`) · Sentry + OpenTelemetry · mysql2 (P/TecDoc) ·· React 19 · TanStack Router/Query · zustand · posthog-js · @grafana/faro · remotion · zod throughout.
|
||||
|
||||
## Quick Start
|
||||
|
||||
1. `pnpm install`
|
||||
2. `pnpm dev` (turbo: api :4000 + web :3000)
|
||||
3. `pnpm lint` / `pnpm typecheck` / `pnpm test`
|
||||
4. DB: `pnpm --filter api db:migrate` (custom runner) · `db:studio` · `db:seed`
|
||||
|
||||
## Deployment (Coolify — NEVER edit on server)
|
||||
|
||||
- Push `dev` → auto-deploys staging **dev.sase.tr** — verify every change here first
|
||||
- Merge to `main` → auto-deploys prod **sase.tr** (only after dev verified)
|
||||
|
||||
## Docs Map
|
||||
|
||||
- `CLAUDE.md` / `AGENTS.md` — assistant instructions · `docs/INDEX.md` — internal docs index
|
||||
- Root reports: `upstream-prob.md`, `undecoded-vin-rca.md`, `posthog-max-leverage.md`, `posthog-feature-roadmap.md`, `katalogayapi.md`, `knowledge.md`, `ralpRapor.md`, `emex-hierarchy-plan.md`, `MANIFEST*.md`, `RECOVERY-FN-412.md`, `SCOPING-FN-411.md`, `SASE_PRD_Hesap_Paylasimi_Onleme_v1.1.md`
|
||||
- Business/ops workspace knowledge base: `/home/s/ss/INDEX.md`
|
||||
28
apps/api/drizzle/0013_fix_brand_casing.sql
Normal file
28
apps/api/drizzle/0013_fix_brand_casing.sql
Normal file
@@ -0,0 +1,28 @@
|
||||
-- Canonicalise vehicle brand casing + backfill missing brand_id.
|
||||
--
|
||||
-- Root cause: the decode persist (vehicles.service.ts) looked brands up with a
|
||||
-- case-SENSITIVE `eq(brands.name, brandName)`. A decode source that yields an
|
||||
-- uppercase brand string (e.g. "FORD", "MERCEDES-BENZ") missed the canonical
|
||||
-- "Ford" / "Mercedes-Benz" row → brand_id stayed NULL and the raw uppercase
|
||||
-- string was stored as brand_name. That split the same brand across casing
|
||||
-- variants in analytics + the catalog ("Ford" vs "FORD"). The companion code
|
||||
-- fix makes the lookup case-insensitive and stores the canonical name; this
|
||||
-- migration repairs the rows already written.
|
||||
--
|
||||
-- Backfill every vehicle whose brand_name matches a canonical brand
|
||||
-- case-insensitively: set the FK and the canonical-cased name. (catalog_vehicles
|
||||
-- is intentionally untouched — its brand_name namespace does not match the
|
||||
-- brands table, so 0 rows would qualify.)
|
||||
UPDATE "vehicles" v
|
||||
SET brand_id = b.id, brand_name = b.name
|
||||
FROM "brands" b
|
||||
WHERE v.brand_id IS NULL AND lower(v.brand_name) = lower(b.name);
|
||||
--> statement-breakpoint
|
||||
|
||||
-- Defensive: realign any vehicle whose brand_id is set but whose denormalised
|
||||
-- brand_name has drifted from the canonical brands.name (none today, but keeps
|
||||
-- the FK and the denormalised name consistent going forward).
|
||||
UPDATE "vehicles" v
|
||||
SET brand_name = b.name
|
||||
FROM "brands" b
|
||||
WHERE v.brand_id = b.id AND v.brand_name <> b.name;
|
||||
23
apps/api/drizzle/0014_proxy_logs.sql
Normal file
23
apps/api/drizzle/0014_proxy_logs.sql
Normal file
@@ -0,0 +1,23 @@
|
||||
-- Proxy telemetry: one row per proxied upstream attempt (pcat call/capture/
|
||||
-- validate, emex http). Lets the Süper Panel grade proxy providers per service
|
||||
-- (success rate, ban rate, latency) and track banned residential exit IPs /
|
||||
-- sticky sessions. Written fire-and-forget by ProxyTelemetryService; 30-day
|
||||
-- retention enforced by the query-cleanup job.
|
||||
CREATE TABLE "proxy_logs" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"service" varchar(30) NOT NULL,
|
||||
"provider" varchar(20) NOT NULL,
|
||||
"session_key" varchar(60),
|
||||
"exit_ip" varchar(45),
|
||||
"target_host" varchar(120),
|
||||
"status_code" integer,
|
||||
"error_kind" varchar(30),
|
||||
"success" boolean DEFAULT false NOT NULL,
|
||||
"banned" boolean DEFAULT false NOT NULL,
|
||||
"duration_ms" integer,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX "proxy_logs_created_at_idx" ON "proxy_logs" ("created_at");
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX "proxy_logs_banned_created_at_idx" ON "proxy_logs" ("banned","created_at");
|
||||
@@ -92,6 +92,20 @@
|
||||
"when": 1780581755559,
|
||||
"tag": "0012_lifecycle_email_sent",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 13,
|
||||
"version": "7",
|
||||
"when": 1780600000000,
|
||||
"tag": "0013_fix_brand_casing",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 14,
|
||||
"version": "7",
|
||||
"when": 1781136000000,
|
||||
"tag": "0014_proxy_logs",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import { DemoModule } from "./demo/demo.module";
|
||||
import { EmailModule } from "./email/email.module";
|
||||
import { HealthController } from "./health.controller";
|
||||
import { EmexModule } from "./integrations/emex/emex.module";
|
||||
import { PModule } from "./integrations/p/p.module";
|
||||
import { InternalAdminModule } from "./internal-admin/internal-admin.module";
|
||||
import { JobsModule } from "./jobs/jobs.module";
|
||||
import { MetaCapiModule } from "./meta-capi/meta-capi.module";
|
||||
@@ -86,6 +87,7 @@ import { VehiclesModule } from "./vehicles/vehicles.module";
|
||||
CategoriesModule,
|
||||
DemoModule,
|
||||
PartsModule,
|
||||
PModule,
|
||||
JobsModule,
|
||||
EmexModule,
|
||||
TranslationsModule,
|
||||
|
||||
@@ -88,6 +88,14 @@ export default () => ({
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean),
|
||||
},
|
||||
p: {
|
||||
// Read-only lookup against the imported P snapshot (db `td`). When
|
||||
// enabled + url set, the OEM detail page resolves a part's OEM code to
|
||||
// P aftermarket equivalents + OE cross-references. Disabled → endpoint
|
||||
// returns { matched: false } and the UI shows an empty state.
|
||||
enabled: process.env.P_DB_ENABLED === "true",
|
||||
url: process.env.P_DB_URL,
|
||||
},
|
||||
otel: {
|
||||
enabled: process.env.OTEL_ENABLED === "true",
|
||||
endpoint: process.env.OTEL_EXPORTER_OTLP_ENDPOINT,
|
||||
|
||||
@@ -184,7 +184,9 @@ export const userBrands = pgTable(
|
||||
],
|
||||
);
|
||||
|
||||
// ─── Bank Accounts (EFT/Havale destination accounts, one active at a time) ────
|
||||
// ─── Bank Accounts ───────────────────────────────────
|
||||
// @deprecated EFT/Havale was retired (Stripe is the sole payment method). Table
|
||||
// kept only to preserve historical FK integrity; no rows are created anymore.
|
||||
export const bankAccounts = pgTable(
|
||||
"bank_accounts",
|
||||
{
|
||||
@@ -225,7 +227,9 @@ export const payments = pgTable(
|
||||
iyzicoPaymentId: text("iyzico_payment_id"),
|
||||
stripeSessionId: text("stripe_session_id"),
|
||||
stripePaymentIntentId: text("stripe_payment_intent_id"),
|
||||
/** @deprecated EFT/Havale retired (Stripe-only). Kept for historical data. */
|
||||
bankAccountId: uuid("bank_account_id").references(() => bankAccounts.id),
|
||||
/** @deprecated EFT/Havale retired (Stripe-only). Kept for historical data. */
|
||||
eftReceiptUrl: text("eft_receipt_url"),
|
||||
adminNote: text("admin_note"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
@@ -613,3 +617,41 @@ export const emexCategoryTranslations = pgTable(
|
||||
},
|
||||
(table) => [uniqueIndex("emex_translations_original_name_idx").on(table.originalName)],
|
||||
);
|
||||
|
||||
// ─── Proxy Logs ──────────────────────────────────────
|
||||
// One row per proxied upstream attempt (pcat call/capture/validate, emex http).
|
||||
// Written fire-and-forget by ProxyTelemetryService so the Süper Panel can grade
|
||||
// proxy providers (success/ban/latency per service) and track banned exit IPs.
|
||||
// `banned` = HTTP 403/429 from the upstream (IP-level block signal); `session_key`
|
||||
// identifies a sticky Floxy session or a DataImpulse port so bans are attributable
|
||||
// even when the exact exit IP is unknown (rotating call legs).
|
||||
export const proxyLogs = pgTable(
|
||||
"proxy_logs",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
// Which integration leg made the request: pcat_call | pcat_capture |
|
||||
// pcat_validate | emex_http
|
||||
service: varchar("service", { length: 30 }).notNull(),
|
||||
// floxy | dataimpulse | none (direct)
|
||||
provider: varchar("provider", { length: 20 }).notNull(),
|
||||
// Sticky Floxy session id ("s-ab12cd34") or DataImpulse port ("di:10042");
|
||||
// null on per-request rotating legs (exit IP changes every call).
|
||||
sessionKey: varchar("session_key", { length: 60 }),
|
||||
// Resolved residential exit IP (sticky sessions only — one cheap ipify probe
|
||||
// per session). Null when rotation makes the IP unknowable.
|
||||
exitIp: varchar("exit_ip", { length: 45 }),
|
||||
targetHost: varchar("target_host", { length: 120 }),
|
||||
statusCode: integer("status_code"),
|
||||
// Transport failure class when no HTTP answer arrived:
|
||||
// timeout | connect | reset | dns | aborted | transport
|
||||
errorKind: varchar("error_kind", { length: 30 }),
|
||||
success: boolean("success").default(false).notNull(),
|
||||
banned: boolean("banned").default(false).notNull(),
|
||||
durationMs: integer("duration_ms"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(table) => [
|
||||
index("proxy_logs_created_at_idx").on(table.createdAt),
|
||||
index("proxy_logs_banned_created_at_idx").on(table.banned, table.createdAt),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -61,22 +61,44 @@ export class EmexBrowserService implements OnModuleInit, OnModuleDestroy {
|
||||
private launching: Promise<void> | null = null;
|
||||
|
||||
private readonly useProxy: boolean;
|
||||
// "floxy" residential (default) | "dataimpulse" legacy | "none".
|
||||
private readonly proxyProvider: "floxy" | "dataimpulse" | "none";
|
||||
private readonly proxyHost: string;
|
||||
private readonly proxyPortStart: number;
|
||||
private readonly proxyPortEnd: number;
|
||||
private readonly proxyUsername: string;
|
||||
private readonly proxyPassword: string;
|
||||
// Floxy residential — a sticky session pins ONE exit IP for the browser's whole
|
||||
// lifetime (relaunch mints a fresh session id), which suits the chained scrape.
|
||||
private readonly floxyHost: string;
|
||||
private readonly floxyPort: number;
|
||||
private readonly floxyUser: string;
|
||||
private readonly floxyPass: string;
|
||||
private readonly floxyLifetime: number;
|
||||
private startedAt = 0;
|
||||
|
||||
constructor(private configService: ConfigService) {
|
||||
this.semaphore = new Semaphore(MAX_CONCURRENT_PAGES);
|
||||
|
||||
this.useProxy = this.configService.get<string>("EMEX_USE_PROXY", "true") === "true";
|
||||
const rawProvider = this.configService
|
||||
.get<string>("EMEX_PROXY_PROVIDER", "floxy")
|
||||
.toLowerCase();
|
||||
this.proxyProvider = !this.useProxy
|
||||
? "none"
|
||||
: rawProvider === "dataimpulse" || rawProvider === "none"
|
||||
? (rawProvider as "dataimpulse" | "none")
|
||||
: "floxy";
|
||||
this.proxyHost = this.configService.get<string>("EMEX_PROXY_HOST", "74.81.81.81");
|
||||
this.proxyPortStart = this.configService.get<number>("EMEX_PROXY_PORT_START", 10001);
|
||||
this.proxyPortEnd = this.configService.get<number>("EMEX_PROXY_PORT_END", 10099);
|
||||
this.proxyUsername = this.configService.get<string>("EMEX_PROXY_USER", "1726bbe361918676d44e");
|
||||
this.proxyPassword = this.configService.get<string>("EMEX_PROXY_PASS", "f11c7b6128cc86c6");
|
||||
this.floxyHost = this.configService.get<string>("EMEX_FLOXY_HOST", "residential.floxy.io");
|
||||
this.floxyPort = Number(this.configService.get("EMEX_FLOXY_PORT", 12321)) || 12321;
|
||||
this.floxyUser = this.configService.get<string>("EMEX_FLOXY_USER", "cac4b0d96a80");
|
||||
this.floxyPass = this.configService.get<string>("EMEX_FLOXY_PASS", "dfc38fe467a3");
|
||||
this.floxyLifetime = Number(this.configService.get("EMEX_FLOXY_LIFETIME", 300)) || 300;
|
||||
}
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
@@ -184,14 +206,30 @@ export class EmexBrowserService implements OnModuleInit, OnModuleDestroy {
|
||||
const executablePath = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH;
|
||||
if (executablePath) launchOptions.executablePath = executablePath;
|
||||
|
||||
if (this.useProxy) {
|
||||
if (this.proxyProvider === "floxy") {
|
||||
// Sticky Floxy session for the browser's lifetime → one residential exit IP
|
||||
// for the whole chained scrape; a fresh id is minted on each (re)launch.
|
||||
const sid = Math.random().toString(36).slice(2, 10);
|
||||
const pass =
|
||||
this.floxyLifetime > 0
|
||||
? `${this.floxyPass}_session-${sid}_lifetime-${this.floxyLifetime}`
|
||||
: this.floxyPass;
|
||||
launchOptions.proxy = {
|
||||
server: `http://${this.floxyHost}:${this.floxyPort}`,
|
||||
username: this.floxyUser,
|
||||
password: pass,
|
||||
};
|
||||
this.logger.log(
|
||||
`Using Floxy residential proxy: ${this.floxyHost}:${this.floxyPort} (sticky ${this.floxyLifetime}s)`,
|
||||
);
|
||||
} else if (this.proxyProvider === "dataimpulse") {
|
||||
const port = this.randomProxyPort();
|
||||
launchOptions.proxy = {
|
||||
server: `http://${this.proxyHost}:${port}`,
|
||||
username: this.proxyUsername,
|
||||
password: this.proxyPassword,
|
||||
};
|
||||
this.logger.log(`Using proxy: ${this.proxyHost}:${port}`);
|
||||
this.logger.log(`Using DataImpulse proxy: ${this.proxyHost}:${port}`);
|
||||
}
|
||||
|
||||
this.browser = await chromium.launch(launchOptions);
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { ProxyTelemetryModule } from "../proxy-telemetry/proxy-telemetry.module";
|
||||
import { EmexBrowserService } from "./emex.browser";
|
||||
import { EmexService } from "./emex.service";
|
||||
|
||||
@Module({
|
||||
imports: [ProxyTelemetryModule],
|
||||
providers: [EmexBrowserService, EmexService],
|
||||
exports: [EmexService],
|
||||
})
|
||||
|
||||
@@ -18,6 +18,7 @@ function makeConfig(overrides: Record<string, string>): ConfigService {
|
||||
const browser = {} as never;
|
||||
const redis = { set: vi.fn() } as never;
|
||||
const posthog = {} as never;
|
||||
const proxyTelemetry = { record: vi.fn() } as never;
|
||||
|
||||
describe("EmexService proxy-port coercion (regression)", () => {
|
||||
it("does not throw 'Invalid URL' when ports arrive as strings over a real range", () => {
|
||||
@@ -28,7 +29,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, posthog)).not.toThrow();
|
||||
expect(() => new EmexService(config, browser, redis, posthog, proxyTelemetry)).not.toThrow();
|
||||
});
|
||||
|
||||
it("constructs with a single-port range (string env)", () => {
|
||||
@@ -37,7 +38,7 @@ describe("EmexService proxy-port coercion (regression)", () => {
|
||||
EMEX_PROXY_PORT_START: "823",
|
||||
EMEX_PROXY_PORT_END: "823",
|
||||
});
|
||||
expect(() => new EmexService(config, browser, redis, posthog)).not.toThrow();
|
||||
expect(() => new EmexService(config, browser, redis, posthog, proxyTelemetry)).not.toThrow();
|
||||
});
|
||||
|
||||
it("falls back to a valid default when the port env is garbage", () => {
|
||||
@@ -46,11 +47,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, posthog)).not.toThrow();
|
||||
expect(() => new EmexService(config, browser, redis, posthog, proxyTelemetry)).not.toThrow();
|
||||
});
|
||||
|
||||
it("constructs cleanly with the proxy disabled", () => {
|
||||
const config = makeConfig({ EMEX_USE_PROXY: "false" });
|
||||
expect(() => new EmexService(config, browser, redis, posthog)).not.toThrow();
|
||||
expect(() => new EmexService(config, browser, redis, posthog, proxyTelemetry)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,6 +23,10 @@ import { ProxyAgent } from "undici";
|
||||
import { isBackfillContext } from "../../jobs/prefetch-context";
|
||||
import { PostHogService } from "../../posthog/posthog.service";
|
||||
import { RedisService } from "../../redis/redis.service";
|
||||
import {
|
||||
ProxyTelemetryService,
|
||||
classifyTransportError,
|
||||
} from "../proxy-telemetry/proxy-telemetry.service";
|
||||
import { parseUnitLeaves, parseVehicleTree } from "./emex-tree.parser";
|
||||
import { EmexBrowserService } from "./emex.browser";
|
||||
import { createEmptyDecodedVehicle, mapEmexResponse } from "./emex.mapper";
|
||||
@@ -132,12 +136,17 @@ export class EmexService {
|
||||
private floxySessionId = Math.random().toString(36).slice(2, 10);
|
||||
private floxySessionBornMs = Date.now();
|
||||
private readonly emexDirectFallback: boolean;
|
||||
// Transport provider for EMEX egress (HTTP + image-dims). "floxy" residential
|
||||
// is primary by default (sticky session pins one exit IP across a chained flow),
|
||||
// DataImpulse is the last-ditch fallback. See constructor.
|
||||
private readonly emexProxyProvider: "floxy" | "dataimpulse" | "none";
|
||||
|
||||
constructor(
|
||||
private configService: ConfigService,
|
||||
private browserService: EmexBrowserService,
|
||||
private redis: RedisService,
|
||||
private posthog: PostHogService,
|
||||
private proxyTelemetry: ProxyTelemetryService,
|
||||
) {
|
||||
// __dirname is apps/api/src/integrations/emex/ or dist/integrations/emex/
|
||||
// Scraper lives at <monorepo-root>/scripts/emex-vin-scraper.js
|
||||
@@ -169,15 +178,11 @@ export class EmexService {
|
||||
portStart: toPort("EMEX_PROXY_PORT_START", 10001),
|
||||
portEnd: toPort("EMEX_PROXY_PORT_END", 10099),
|
||||
};
|
||||
// Default agent for the low-stakes image-dims path; fetchEmexHtml builds a
|
||||
// fresh agent per request so a flaky port can't pin every call.
|
||||
this.proxyAgent = this.newProxyAgent();
|
||||
this.logger.log(
|
||||
`EMEX HTTP proxy enabled: ${this.emexProxy.host}:${this.emexProxy.portStart}-${this.emexProxy.portEnd}`,
|
||||
`EMEX DataImpulse fallback enabled: ${this.emexProxy.host}:${this.emexProxy.portStart}-${this.emexProxy.portEnd}`,
|
||||
);
|
||||
} else {
|
||||
this.emexProxy = null;
|
||||
this.proxyAgent = null;
|
||||
}
|
||||
|
||||
// Floxy residential fallback for when the DataImpulse pool flakes (connect
|
||||
@@ -189,8 +194,8 @@ export class EmexService {
|
||||
this.emexFloxy = {
|
||||
host: this.configService.get<string>("EMEX_FLOXY_HOST", "residential.floxy.io"),
|
||||
port: Number.isInteger(fport) && fport >= 1 && fport <= 65535 ? fport : 12321,
|
||||
user: this.configService.get<string>("EMEX_FLOXY_USER", "d739255e819b"),
|
||||
pass: this.configService.get<string>("EMEX_FLOXY_PASS", "9092873ba4e0"),
|
||||
user: this.configService.get<string>("EMEX_FLOXY_USER", "cac4b0d96a80"),
|
||||
pass: this.configService.get<string>("EMEX_FLOXY_PASS", "dfc38fe467a3"),
|
||||
lifetime: Number.isInteger(flife) && flife >= 0 ? flife : 300,
|
||||
};
|
||||
this.logger.log(
|
||||
@@ -200,6 +205,28 @@ export class EmexService {
|
||||
this.emexFloxy = null;
|
||||
}
|
||||
|
||||
// Transport provider. EMEX_PROXY_PROVIDER: "floxy" (default) → Floxy residential
|
||||
// sticky primary + a single DataImpulse last-ditch fallback; "dataimpulse" →
|
||||
// legacy DataImpulse-first order; "none" → direct. ssd tokens are replayed from
|
||||
// emex's own HTML and aren't strictly IP-bound on the .aspx endpoints (the old
|
||||
// random-port-per-call primary proved that), but a sticky Floxy IP across the
|
||||
// chained flow is still the safest choice — and avoids DataImpulse's ~50% dead
|
||||
// ports that were eating the decode budget before reaching the Floxy fallback.
|
||||
this.emexProxyProvider = !useProxy
|
||||
? "none"
|
||||
: (() => {
|
||||
const p = this.configService.get<string>("EMEX_PROXY_PROVIDER", "floxy").toLowerCase();
|
||||
return p === "dataimpulse" || p === "none" ? p : "floxy";
|
||||
})();
|
||||
// Default agent for the low-stakes image-dims path; fetchEmexHtml builds a
|
||||
// fresh agent per request so a flaky exit can't pin every call.
|
||||
this.proxyAgent =
|
||||
this.emexProxyProvider === "none"
|
||||
? null
|
||||
: (this.newProxyAgent(this.emexProxyProvider === "dataimpulse" ? "dataimpulse" : "floxy") ??
|
||||
this.newProxyAgent("dataimpulse"));
|
||||
this.logger.log(`EMEX transport provider: ${this.emexProxyProvider}`);
|
||||
|
||||
this.logger.log(`EMEX Service initialized with scraper path: ${this.scraperPath}`);
|
||||
}
|
||||
|
||||
@@ -347,25 +374,45 @@ export class EmexService {
|
||||
}
|
||||
|
||||
private async fetchEmexHtml(url: string): Promise<string> {
|
||||
// Attempt schedule: DataImpulse (rotating port, fresh agent each try to dodge
|
||||
// a flaky sticky port — ~42% blip rate, undecoded-vin-rca.md EMEX #1) first,
|
||||
// then the Floxy residential fallback when the DataImpulse pool throws
|
||||
// transport errors. A definitive HTTP answer (e.g. 404) stops the schedule —
|
||||
// it's a real result, and a different proxy IP must not "retry" it away.
|
||||
// Attempt schedule by provider. floxy (default): Floxy residential sticky
|
||||
// primary (one exit IP across the flow; rolls to a fresh IP on transport
|
||||
// failure), then a single DataImpulse last-ditch try — this avoids DataImpulse's
|
||||
// ~50% dead ports that used to burn the decode budget before reaching Floxy.
|
||||
// dataimpulse (legacy): DataImpulse-first (rotating port each try), Floxy after.
|
||||
// A definitive HTTP answer (e.g. 404) stops the schedule — it's a real result,
|
||||
// and a different proxy IP must not "retry" it away.
|
||||
const schedule: Array<"dataimpulse" | "floxy"> = [];
|
||||
if (this.emexProxy) schedule.push("dataimpulse", "dataimpulse", "dataimpulse");
|
||||
if (this.emexFloxy) schedule.push("floxy", "floxy");
|
||||
if (this.emexProxyProvider === "floxy") {
|
||||
if (this.emexFloxy) schedule.push("floxy", "floxy");
|
||||
if (this.emexProxy) schedule.push("dataimpulse");
|
||||
} else if (this.emexProxyProvider === "dataimpulse") {
|
||||
if (this.emexProxy) schedule.push("dataimpulse", "dataimpulse", "dataimpulse");
|
||||
if (this.emexFloxy) schedule.push("floxy", "floxy");
|
||||
}
|
||||
const maxAttempts = schedule.length || 1; // 0 → single proxy-less attempt
|
||||
let lastErr: unknown;
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
const provider = schedule[attempt - 1]; // undefined when no proxy → direct
|
||||
const agent = provider ? this.newProxyAgent(provider) : null;
|
||||
const attemptStart = Date.now();
|
||||
// Sticky Floxy sessions are the attributable identity here; DataImpulse
|
||||
// rotates a random port per agent, so there is nothing stable to pin.
|
||||
const sessionKey = provider === "floxy" ? `s-${this.floxySessionId}` : null;
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
headers: { "User-Agent": EMEX_UA, Accept: "text/html,application/xhtml+xml" },
|
||||
signal: AbortSignal.timeout(this.timeout),
|
||||
...(agent ? { dispatcher: agent } : {}),
|
||||
} as RequestInit);
|
||||
this.proxyTelemetry.record({
|
||||
service: "emex_http",
|
||||
provider: provider ?? "none",
|
||||
sessionKey,
|
||||
targetHost: new URL(url).hostname,
|
||||
statusCode: res.status,
|
||||
success: res.ok,
|
||||
durationMs: Date.now() - attemptStart,
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`EMEX HTTP ${res.status} for ${url}`);
|
||||
}
|
||||
@@ -378,6 +425,19 @@ export class EmexService {
|
||||
const e = err as Error & { cause?: unknown };
|
||||
// A real HTTP response ("EMEX HTTP 404") is a definitive answer — never retry.
|
||||
const httpAnswer = /^EMEX HTTP \d/.test(e.message);
|
||||
// HTTP answers were already recorded right after fetch; only transport
|
||||
// failures (no answer at all) still need a row.
|
||||
if (!httpAnswer) {
|
||||
this.proxyTelemetry.record({
|
||||
service: "emex_http",
|
||||
provider: provider ?? "none",
|
||||
sessionKey,
|
||||
targetHost: new URL(url).hostname,
|
||||
errorKind: classifyTransportError(e),
|
||||
success: false,
|
||||
durationMs: Date.now() - attemptStart,
|
||||
});
|
||||
}
|
||||
const transient =
|
||||
!httpAnswer &&
|
||||
(e.name === "TimeoutError" ||
|
||||
|
||||
@@ -348,6 +348,7 @@ export const CATALOG_MAP: Record<string, CatalogEntry> = {
|
||||
// Opel
|
||||
W0L: { code: "GM_OP201809", brand: "Opel" },
|
||||
W0V: { code: "GM_OP201809", brand: "Opel" }, // newer Stellantis-era WMI
|
||||
VXK: { code: "GM_OP201809", brand: "Opel" }, // PSA/Stellantis-platform Opel (Corsa F, Mokka B)
|
||||
// Chevrolet
|
||||
KL1: { code: "GM_C201809", brand: "Chevrolet" },
|
||||
// SsangYong
|
||||
|
||||
256
apps/api/src/integrations/p/p-source-db.service.ts
Normal file
256
apps/api/src/integrations/p/p-source-db.service.ts
Normal file
@@ -0,0 +1,256 @@
|
||||
import { Injectable, Logger, type OnModuleDestroy, type OnModuleInit } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import postgres, { type Sql } from "postgres";
|
||||
import type { PArticle, PCompatible, POeNumber, POemResult } from "./p.types";
|
||||
|
||||
/**
|
||||
* Read-only lookup against the imported P snapshot (db `td` — a selective
|
||||
* copy of articles + OE numbers + aftermarket compatibility + images/eans; the
|
||||
* 29 GB vehicle-fitment table is intentionally excluded). Given an OEM code from
|
||||
* the sase catalog, returns the P articles that carry it as an OE number,
|
||||
* with their aftermarket equivalents and OE cross-references.
|
||||
*
|
||||
* Matching is normalisation-based, not exact: P stores OE codes with
|
||||
* spaces/dashes (`1J0 973 702`) while the catalog gives `1J0973702`, so both
|
||||
* sides are reduced to `[A-Z0-9]` uppercase before comparison (a precomputed
|
||||
* `code_norm` column, indexed, holds the P side). Exact matching recovers
|
||||
* almost nothing — verified ~1/10 vs normalised ~5/10 on real catalog codes.
|
||||
*
|
||||
* Never throws: disabled feature, too-short code, connection blip or no match
|
||||
* all collapse to `matched: false` so the UI has a single empty-state path.
|
||||
*/
|
||||
@Injectable()
|
||||
export class PSourceDbService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(PSourceDbService.name);
|
||||
private sql: Sql | null = null;
|
||||
private enabled = false;
|
||||
|
||||
// Short normalised codes (e.g. "NA" from "N/A", single digits) collide across
|
||||
// unrelated parts — refuse to match below this length. Real OE numbers are 5+.
|
||||
private static readonly MIN_NORM_LEN = 5;
|
||||
private static readonly MAX_ARTICLES = 60;
|
||||
private static readonly MAX_AGG = 300;
|
||||
|
||||
constructor(private readonly config: ConfigService) {}
|
||||
|
||||
onModuleInit() {
|
||||
const enabled = this.config.get<boolean>("p.enabled");
|
||||
const url = this.config.get<string>("p.url");
|
||||
if (!enabled || !url) {
|
||||
this.logger.log(`[p] disabled (enabled=${enabled}, urlSet=${Boolean(url)})`);
|
||||
return;
|
||||
}
|
||||
this.sql = postgres(url, {
|
||||
max: 5,
|
||||
idle_timeout: 30,
|
||||
connect_timeout: 10,
|
||||
prepare: false,
|
||||
});
|
||||
this.enabled = true;
|
||||
this.logger.log("[p] connected, OEM cross-reference lookup enabled");
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
if (this.sql) {
|
||||
await this.sql.end({ timeout: 5 });
|
||||
this.sql = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** `1J0 973 702` / `1j0-973-702` → `1J0973702`. Used for both the input code
|
||||
* and JS-side dedupe; the P side is matched against the stored
|
||||
* `code_norm` (built with the identical rule at import time). */
|
||||
private static norm(code: string): string {
|
||||
return code.toUpperCase().replace(/[^A-Z0-9]/g, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch membership test for the schema page: given the OEM codes in a parts
|
||||
* list, return the subset that has at least one OE cross-reference in the
|
||||
* snapshot. The panel links only these (and renders the rest as plain text),
|
||||
* so a click never lands on an empty "no equivalents" detail page. One indexed
|
||||
* query (`code_norm` btree). Fail-open to [] (→ no links) when disabled / on
|
||||
* error, echoing back the caller's original spelling for the matched codes.
|
||||
*/
|
||||
async matchedCodes(rawCodes: string[]): Promise<string[]> {
|
||||
if (!this.enabled || !this.sql || !rawCodes?.length) return [];
|
||||
const rawByNorm = new Map<string, string>();
|
||||
for (const raw of rawCodes) {
|
||||
const n = PSourceDbService.norm(raw ?? "");
|
||||
if (n.length >= PSourceDbService.MIN_NORM_LEN && !rawByNorm.has(n)) rawByNorm.set(n, raw);
|
||||
}
|
||||
if (rawByNorm.size === 0) return [];
|
||||
try {
|
||||
const norms = [...rawByNorm.keys()];
|
||||
const rows = await this.sql<Array<{ code_norm: string }>>`
|
||||
SELECT DISTINCT code_norm FROM article_oe_numbers
|
||||
WHERE code_norm IN ${this.sql(norms)}
|
||||
`;
|
||||
return rows
|
||||
.map((r) => rawByNorm.get(r.code_norm))
|
||||
.filter((c): c is string => c !== undefined);
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`[p] matchedCodes failed (${rawByNorm.size} codes): ${(err as Error).message}`,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async lookupByOem(rawCode: string): Promise<POemResult | null> {
|
||||
const query = (rawCode ?? "").trim();
|
||||
const queryNorm = PSourceDbService.norm(query);
|
||||
const miss: POemResult = {
|
||||
query,
|
||||
queryNorm,
|
||||
matched: false,
|
||||
articles: [],
|
||||
aftermarketParts: [],
|
||||
oeCrossReferences: [],
|
||||
truncated: false,
|
||||
};
|
||||
|
||||
if (!this.enabled || !this.sql) return miss;
|
||||
if (queryNorm.length < PSourceDbService.MIN_NORM_LEN) return miss;
|
||||
|
||||
try {
|
||||
const rows = await this.sql<
|
||||
Array<{
|
||||
id: string;
|
||||
brand: string;
|
||||
article_number: string;
|
||||
name: string | null;
|
||||
spare_info: string | null;
|
||||
oe_numbers: POeNumber[];
|
||||
compatible: PCompatible[];
|
||||
images: Array<{ url: string; thumb: string | null }>;
|
||||
}>
|
||||
>`
|
||||
WITH hit AS (
|
||||
SELECT DISTINCT article_id
|
||||
FROM article_oe_numbers
|
||||
WHERE code_norm = ${queryNorm}
|
||||
LIMIT ${PSourceDbService.MAX_ARTICLES}
|
||||
)
|
||||
SELECT
|
||||
a.id::text AS id,
|
||||
b.name AS brand,
|
||||
a.article_number AS article_number,
|
||||
a.name AS name,
|
||||
a.spare_info AS spare_info,
|
||||
COALESCE((
|
||||
SELECT json_agg(json_build_object('brand', o.brand, 'code', o.code))
|
||||
FROM (
|
||||
SELECT DISTINCT brand, code FROM article_oe_numbers
|
||||
WHERE article_id = a.id ORDER BY brand LIMIT 200
|
||||
) o
|
||||
), '[]') AS oe_numbers,
|
||||
COALESCE((
|
||||
SELECT json_agg(json_build_object('brand', c.compatible_brand, 'article', c.compatible_article))
|
||||
FROM (
|
||||
SELECT DISTINCT compatible_brand, compatible_article FROM article_compatibility
|
||||
WHERE article_id = a.id ORDER BY compatible_brand LIMIT 200
|
||||
) c
|
||||
), '[]') AS compatible,
|
||||
COALESCE((
|
||||
SELECT json_agg(json_build_object('url', i.image_url, 'thumb', i.thumb_url) ORDER BY i.sort_order)
|
||||
FROM (
|
||||
-- Only publicly-resolvable URLs. The current snapshot stores
|
||||
-- scrape-local '/_debug/...' paths (404 off-host) — filtering them
|
||||
-- here keeps the API contract honest so the UI shows no broken
|
||||
-- thumbnails; real CDN URLs surface automatically once present.
|
||||
SELECT image_url, thumb_url, sort_order FROM article_images
|
||||
WHERE article_id = a.id AND image_url LIKE 'http%'
|
||||
ORDER BY sort_order LIMIT 8
|
||||
) i
|
||||
), '[]') AS images
|
||||
FROM hit
|
||||
JOIN articles a ON a.id = hit.article_id
|
||||
JOIN article_brands b ON b.id = a.brand_id
|
||||
ORDER BY b.name, a.article_number
|
||||
`;
|
||||
|
||||
if (rows.length === 0) return miss;
|
||||
|
||||
let truncated = rows.length >= PSourceDbService.MAX_ARTICLES;
|
||||
|
||||
const articles: PArticle[] = rows.map((r) => {
|
||||
if (r.oe_numbers.length >= 200 || r.compatible.length >= 200) truncated = true;
|
||||
return {
|
||||
id: r.id,
|
||||
brand: r.brand,
|
||||
articleNumber: r.article_number,
|
||||
name: r.name,
|
||||
spareInfo: r.spare_info,
|
||||
images: r.images,
|
||||
oeNumbers: r.oe_numbers,
|
||||
compatible: r.compatible,
|
||||
};
|
||||
});
|
||||
|
||||
// ── Aggregate: buyable aftermarket part numbers ──────────────────────
|
||||
// The matched articles are themselves aftermarket parts; their
|
||||
// compatibility rows add equivalent numbers from other supplier brands.
|
||||
const afterSeen = new Set<string>();
|
||||
const aftermarketParts: POemResult["aftermarketParts"] = [];
|
||||
const pushAfter = (brand: string, articleNumber: string, thumb: string | null) => {
|
||||
const key = `${brand.toUpperCase().trim()}␟${PSourceDbService.norm(articleNumber)}`;
|
||||
if (afterSeen.has(key) || !articleNumber.trim()) return;
|
||||
afterSeen.add(key);
|
||||
if (aftermarketParts.length < PSourceDbService.MAX_AGG) {
|
||||
aftermarketParts.push({ brand, articleNumber, thumb });
|
||||
} else {
|
||||
truncated = true;
|
||||
}
|
||||
};
|
||||
for (const a of articles) {
|
||||
pushAfter(a.brand, a.articleNumber, a.images[0]?.thumb ?? a.images[0]?.url ?? null);
|
||||
}
|
||||
for (const a of articles) {
|
||||
for (const c of a.compatible) pushAfter(c.brand, c.article, null);
|
||||
}
|
||||
|
||||
// ── Aggregate: OE cross-references (same part, other makes) ───────────
|
||||
// Exclude restatements of the queried code itself (same normalised code).
|
||||
const oeSeen = new Set<string>();
|
||||
const oeCrossReferences: POeNumber[] = [];
|
||||
for (const a of articles) {
|
||||
for (const oe of a.oeNumbers) {
|
||||
const codeNorm = PSourceDbService.norm(oe.code);
|
||||
if (codeNorm === queryNorm) continue;
|
||||
const key = `${oe.brand.toUpperCase().trim()}␟${codeNorm}`;
|
||||
if (oeSeen.has(key)) continue;
|
||||
oeSeen.add(key);
|
||||
if (oeCrossReferences.length < PSourceDbService.MAX_AGG) {
|
||||
oeCrossReferences.push(oe);
|
||||
} else {
|
||||
truncated = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
query,
|
||||
queryNorm,
|
||||
matched: true,
|
||||
// Ship lean articles — the per-article oeNumbers/compatible lists were
|
||||
// only needed above to build the aggregates; sending them too bloated
|
||||
// heavy responses ~30x (370 KB → ~12 KB for a 60-article code).
|
||||
articles: articles.map((a) => ({
|
||||
id: a.id,
|
||||
brand: a.brand,
|
||||
articleNumber: a.articleNumber,
|
||||
name: a.name,
|
||||
spareInfo: a.spareInfo,
|
||||
images: a.images,
|
||||
})),
|
||||
aftermarketParts,
|
||||
oeCrossReferences,
|
||||
truncated,
|
||||
};
|
||||
} catch (err) {
|
||||
this.logger.warn(`[p] lookup failed (oem=${query}): ${(err as Error).message}`);
|
||||
return miss;
|
||||
}
|
||||
}
|
||||
}
|
||||
28
apps/api/src/integrations/p/p.controller.ts
Normal file
28
apps/api/src/integrations/p/p.controller.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Body, Controller, Get, Post, Query } from "@nestjs/common";
|
||||
import { PSourceDbService } from "./p-source-db.service";
|
||||
|
||||
@Controller("p")
|
||||
export class PController {
|
||||
constructor(private readonly p: PSourceDbService) {}
|
||||
|
||||
/**
|
||||
* Resolve an OEM code from the catalog to its P equivalents.
|
||||
* `GET /p/oem?code=1J0973702` → { matched, articles, aftermarketParts,
|
||||
* oeCrossReferences }. Always 200 with `matched: false` on any miss.
|
||||
*/
|
||||
@Get("oem")
|
||||
async oem(@Query("code") code: string) {
|
||||
return this.p.lookupByOem(code ?? "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch: which of these OEM codes have a P cross-reference. The schema page
|
||||
* sends the codes it's about to render and links only the returned ones, so
|
||||
* unmatched codes stay plain text and never open an empty detail page.
|
||||
* `POST /p/matched { codes: [...] }` → { matched: [...] }.
|
||||
*/
|
||||
@Post("matched")
|
||||
async matched(@Body("codes") codes: string[]) {
|
||||
return { matched: await this.p.matchedCodes(Array.isArray(codes) ? codes : []) };
|
||||
}
|
||||
}
|
||||
15
apps/api/src/integrations/p/p.module.ts
Normal file
15
apps/api/src/integrations/p/p.module.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { PSourceDbService } from "./p-source-db.service";
|
||||
import { PController } from "./p.controller";
|
||||
|
||||
/**
|
||||
* OEM cross-reference lookup against the imported P snapshot (db `td`).
|
||||
* Raw read-only queries — intentionally no Drizzle schema modelling, mirroring
|
||||
* CatalogSourceDbModule. Self-disables when P_DB_* env is unset.
|
||||
*/
|
||||
@Module({
|
||||
controllers: [PController],
|
||||
providers: [PSourceDbService],
|
||||
exports: [PSourceDbService],
|
||||
})
|
||||
export class PModule {}
|
||||
57
apps/api/src/integrations/p/p.types.ts
Normal file
57
apps/api/src/integrations/p/p.types.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
/** An OE (original-equipment) number cross-reference: the same physical part as
|
||||
* catalogued by a vehicle manufacturer (e.g. VAG `1J0 973 702`). */
|
||||
export interface POeNumber {
|
||||
brand: string;
|
||||
code: string;
|
||||
}
|
||||
|
||||
/** An aftermarket equivalent: a buyable part number from a supplier brand
|
||||
* (e.g. FEBI BILSTEIN `171903`). */
|
||||
export interface PCompatible {
|
||||
brand: string;
|
||||
article: string;
|
||||
}
|
||||
|
||||
export interface PImage {
|
||||
url: string;
|
||||
thumb: string | null;
|
||||
}
|
||||
|
||||
/** One P article whose OE number list contains the queried OEM code. Internal
|
||||
* shape — `oeNumbers`/`compatible` feed the aggregates and are NOT shipped to
|
||||
* the client (they're large and unused there; see PArticleSummary). */
|
||||
export interface PArticle {
|
||||
id: string;
|
||||
brand: string;
|
||||
articleNumber: string;
|
||||
name: string | null;
|
||||
spareInfo: string | null;
|
||||
images: PImage[];
|
||||
oeNumbers: POeNumber[];
|
||||
compatible: PCompatible[];
|
||||
}
|
||||
|
||||
/** What the OEM detail page actually renders per matched article — without the
|
||||
* heavy per-article OE/compat lists (those are deduped into the top-level
|
||||
* aggregates). Trimming them cut a 60-article payload from ~370 KB to ~12 KB. */
|
||||
export type PArticleSummary = Omit<PArticle, "oeNumbers" | "compatible">;
|
||||
|
||||
/** Response of the OEM detail lookup. `matched: false` covers every miss —
|
||||
* feature disabled, code too short, or no P article carries that OE
|
||||
* number — so the UI has a single empty-state path. */
|
||||
export interface POemResult {
|
||||
query: string;
|
||||
queryNorm: string;
|
||||
matched: boolean;
|
||||
/** Distinct articles whose OE list contains the queried code (lean — per-article
|
||||
* OE/compat lists are aggregated below, not duplicated here). */
|
||||
articles: PArticleSummary[];
|
||||
/** Deduped buyable aftermarket part numbers across all matched articles
|
||||
* (the matched articles themselves + their compatibility entries). */
|
||||
aftermarketParts: Array<{ brand: string; articleNumber: string; thumb: string | null }>;
|
||||
/** Deduped OE cross-references across all matched articles, excluding the
|
||||
* queried code itself — i.e. the same part's numbers under other makes. */
|
||||
oeCrossReferences: POeNumber[];
|
||||
/** True when any per-article list or the article set hit its cap. */
|
||||
truncated: boolean;
|
||||
}
|
||||
@@ -1,12 +1,16 @@
|
||||
/**
|
||||
* Parts-Catalogs Auth Service — v3 token warm pool via Playwright + DataImpulse proxy
|
||||
* Parts-Catalogs Auth Service — v3 token warm pool via Playwright + rotating proxy
|
||||
*
|
||||
* Tokens are captured by navigating to partner sites that embed the v3 widget.
|
||||
* The widget calls /v3/api/proxy/* with `x-api-key: TWS-{UUID}` and four other
|
||||
* X-* headers (api-path, gui-version, user-id, origin, referer); we intercept
|
||||
* all of them so backend requests can replay the exact header set.
|
||||
*
|
||||
* Tokens are IP-bound — same proxy port must be reused for the API calls.
|
||||
* Transport (PCAT_PROXY_PROVIDER): "floxy" residential (default) rotates a fresh
|
||||
* exit IP per call (the token is NOT IP-bound — see parts-catalogs.types.ts — so
|
||||
* max IP diversity = min per-IP ban), with a sticky IP only per Playwright
|
||||
* capture; "dataimpulse" is the legacy port-bound datacenter proxy; "none" calls
|
||||
* direct from the server IP.
|
||||
*
|
||||
* Warm pool behavior:
|
||||
* 24/7 → proactive: maintain >= 1 slot, auto-refresh ~9.5min before expiry.
|
||||
@@ -19,29 +23,37 @@ import { Injectable, Logger, type OnModuleDestroy, type OnModuleInit } from "@ne
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { Browser, BrowserContext } from "playwright";
|
||||
import { RedisService } from "../../redis/redis.service";
|
||||
import {
|
||||
ProxyTelemetryService,
|
||||
classifyTransportError,
|
||||
resolveExitIp,
|
||||
} from "../proxy-telemetry/proxy-telemetry.service";
|
||||
import { JwtSlot, PcatJwtToken, PcatSession } from "./parts-catalogs.types";
|
||||
|
||||
// Shared single-slot cache so dev + prod (and any restarted container) can
|
||||
// inherit a freshly-captured warm slot instead of paying the Playwright
|
||||
// capture cost from cold. Token is still IP-bound to its proxyPort; if a
|
||||
// reader on a different IP gets 401/403, invalidateSession's existing
|
||||
// fallback re-captures locally. So worst case = today's behavior.
|
||||
// inherit a freshly-captured warm token instead of paying the Playwright
|
||||
// capture cost from cold. The token is portable (not IP-bound), so any
|
||||
// container can serve it through its own rotating call-leg IPs; if it's dead
|
||||
// at the source, invalidateSession drops it and the next read re-captures.
|
||||
const REDIS_SLOT_KEY = "pcat:jwt:slot";
|
||||
const REDIS_SAFETY_BUFFER_S = 60; // don't serve from Redis if <60s of life left
|
||||
|
||||
const TOKEN_TTL = 600; // seconds — TWS- has no built-in expiry, refresh aggressively
|
||||
const REFRESH_BUFFER = 90; // Refresh 90s before expiry
|
||||
const CAPTURE_POLL_INTERVAL = 500; // ms
|
||||
// Two-armed post-goto poll: a healthy goto means the widget JS already loaded
|
||||
// and the API call typically fires within ~1-2s — short cap is enough. A
|
||||
// failed goto means we lost the page, but the widget request may still have
|
||||
// gone through before the navigation timeout — give a brief grace window
|
||||
// and bail. Old single-armed 20s cap was the dominant cost on failed-site
|
||||
// attempts (e.g. 10s page timeout + 20s blind poll = 30s wasted).
|
||||
const CAPTURE_POLL_AFTER_OK = 10; // 10 × 500ms = 5s after a healthy goto
|
||||
const CAPTURE_POLL_AFTER_FAIL = 4; // 4 × 500ms = 2s grace after goto failure
|
||||
const PAGE_TIMEOUT = 10_000; // 10s — healthy partner sites load in <5s through
|
||||
// the proxy; a longer wait only prolongs dead-port connects in capture retries.
|
||||
// Post-goto poll. We now navigate with `domcontentloaded` (reliable, doesn't
|
||||
// depend on networkidle settling on JS-heavy catalog sites), then poll for the
|
||||
// widget's token call. Measured through Floxy residential 2026-06-11: DOM ready
|
||||
// ~7s, the /v3/api/proxy token call fires ~11.8s (≈5s after DOM). The poll must
|
||||
// comfortably cover that gap, so 12s — too short and every capture misses the
|
||||
// token (seen at the old 5s cap: "No token after 12s", whole pool starved).
|
||||
const CAPTURE_POLL_AFTER_OK = Number(process.env.PCAT_CAPTURE_POLL_OK) || 24; // 24 × 500ms = 12s
|
||||
const CAPTURE_POLL_AFTER_FAIL = 8; // 8 × 500ms = 4s grace after goto failure
|
||||
// 25s — residential exit IPs are slow; the token call fired at ~11.8s in the
|
||||
// 2026-06-11 measurement, so 10s timed out before it. Capture is background
|
||||
// (warm pool, never blocks a user with cold-pool fast-fail), so a longer cap is
|
||||
// cheap insurance against residential variance. Env-tunable for future rotation.
|
||||
const PAGE_TIMEOUT = Number(process.env.PCAT_PAGE_TIMEOUT_MS) || 25_000;
|
||||
const CONTEXT_CLOSE_TIMEOUT = 5_000;
|
||||
const SITE_COOLDOWN = 10 * 60 * 1000; // 10 min per site
|
||||
const MAX_POOL_SIZE = 5;
|
||||
@@ -62,30 +74,48 @@ const RPM_PER_SLOT = 6; // 1 token per 6 req/min
|
||||
* Each site uses a different proxy port (IP) to avoid rate limiting.
|
||||
*/
|
||||
// Order matters: round-robin starts from index 0, so the first site is the one
|
||||
// a cold pool waits on. Sites that consistently load in <10s through the proxy
|
||||
// stay at the top so initial JWT capture finishes in seconds.
|
||||
// (e-acca.com was previously included last but removed — the current DataImpulse
|
||||
// rotating proxy 74.81.81.81:10000-10999 cannot reach it; page.goto always
|
||||
// blocked until PAGE_TIMEOUT instead of failing fast like the other sites.)
|
||||
const JWT_SITES = [
|
||||
// a cold pool waits on. Sites that consistently load fast stay at the top so
|
||||
// initial JWT capture finishes in seconds.
|
||||
// Pruned 2026-06-10 to the 6 sites that actually yielded captures on prod —
|
||||
// knkauto.ru / autodo.kz / flynestauto.com produced 0 captures yet consumed
|
||||
// ~80% of capture attempts (the per-site cooldown kept forcing the retry loop
|
||||
// onto them). Override the list at runtime with PCAT_JWT_SITES (comma-separated)
|
||||
// without a deploy, e.g. to give dev a disjoint subset.
|
||||
const DEFAULT_JWT_SITES = [
|
||||
"https://www.alkatalog.com/cats/#/catalogs",
|
||||
"https://auto-komplekt.ru/goodvin#/catalogs",
|
||||
"https://www.autotrade.md/cats/#/catalogs",
|
||||
"https://www.e-trak.ru/cats/#/catalogs",
|
||||
"https://www.autopolyus.ru/cats/#/catalogs",
|
||||
"https://knkauto.ru/goodvin#/catalogs",
|
||||
"https://www.autodo.kz/#/catalogs",
|
||||
"https://avtoman124.ru/goodvin#/catalogs",
|
||||
"https://flynestauto.com/auto-parts-oem-catalog",
|
||||
];
|
||||
|
||||
// DataImpulse proxy defaults (port-based IP rotation)
|
||||
// DataImpulse proxy defaults (port-based IP rotation) — legacy. Half its ports
|
||||
// were dead at any moment (measured 50% connect-timeout), which drove the bulk of
|
||||
// pcat decode latency + failures. Kept selectable via PCAT_PROXY_PROVIDER for
|
||||
// instant rollback, but Floxy is the default.
|
||||
const DI_HOST = "gw.dataimpulse.com";
|
||||
const DI_PORT_MIN = 10000;
|
||||
const DI_PORT_MAX = 10999;
|
||||
const DI_DEFAULT_USER = "1726bbe361918676d44e";
|
||||
const DI_DEFAULT_PASS = "78ebc3d881de6ec0";
|
||||
|
||||
// Floxy residential defaults (session-based IP rotation; same account EMEX uses).
|
||||
// Call leg → plain creds = a fresh residential exit IP per request (max IP
|
||||
// diversity → minimal per-IP ban exposure, since each IP sees ~1 request).
|
||||
// Capture leg → a sticky session id pins one IP for the duration of a single
|
||||
// Playwright capture so the partner site sees a coherent session.
|
||||
const FLOXY_HOST = "residential.floxy.io";
|
||||
const FLOXY_PORT = 12321;
|
||||
// Floxy account creds (overridable via PCAT_FLOXY_USER/PASS). Rotated 2026-06-11
|
||||
// after the prior account (d739255e819b) hit HTTP 402 (balance exhausted).
|
||||
const FLOXY_DEFAULT_USER = "cac4b0d96a80";
|
||||
const FLOXY_DEFAULT_PASS = "dfc38fe467a3";
|
||||
const FLOXY_CAPTURE_LIFETIME_S = 60; // sticky window for one capture attempt
|
||||
|
||||
type ProxyProvider = "floxy" | "dataimpulse" | "none";
|
||||
type ProxyLeg = "call" | "capture";
|
||||
|
||||
/** Simple counting semaphore (same pattern as EmexBrowserService) */
|
||||
class Semaphore {
|
||||
private current = 0;
|
||||
@@ -119,18 +149,20 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
|
||||
|
||||
private browser: Browser | null = null;
|
||||
private launching: Promise<void> | null = null;
|
||||
// Concurrent captures capped at JWT_SITES.length — the real bottleneck is
|
||||
// how many distinct partner sites we can drive in parallel (one capture per
|
||||
// site, since each site = a different IP / cookie origin). Ports are
|
||||
// ~unlimited (10000-10999) and Playwright contexts are isolated; the
|
||||
// synchronous siteIndex round-robin hands each concurrent capture a
|
||||
// different site so they don't collide.
|
||||
private readonly semaphore = new Semaphore(JWT_SITES.length);
|
||||
// Active JWT capture sites (PCAT_JWT_SITES override or DEFAULT_JWT_SITES),
|
||||
// resolved in the constructor.
|
||||
private readonly jwtSites: string[];
|
||||
// Concurrent captures capped at jwtSites.length — the real bottleneck is how
|
||||
// many distinct partner sites we can drive in parallel (one capture per site,
|
||||
// since each site = a different cookie origin). Playwright contexts are
|
||||
// isolated; the synchronous siteIndex round-robin hands each concurrent
|
||||
// capture a different site so they don't collide. Initialized in constructor.
|
||||
private readonly semaphore: Semaphore;
|
||||
|
||||
// Pool state
|
||||
private pool: JwtSlot[] = [];
|
||||
private siteLastUsedAt = new Map<string, number>();
|
||||
private siteIndex = 0; // round-robin across JWT_SITES
|
||||
private siteIndex = 0; // round-robin across jwtSites
|
||||
private requestRoundRobin = 0; // round-robin across pool slots
|
||||
|
||||
// Business hours scheduling
|
||||
@@ -139,20 +171,117 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
|
||||
// RPM tracking
|
||||
private requestTimestamps: number[] = [];
|
||||
|
||||
// Config
|
||||
private readonly useProxy: boolean;
|
||||
private readonly proxyHost: string;
|
||||
private readonly proxyUser: string;
|
||||
private readonly proxyPass: string;
|
||||
// Transport config — see ProxyProvider. The TWS- token is NOT request-time
|
||||
// IP-bound (parts-catalogs.types.ts), so the call leg rotates exit IPs per
|
||||
// request and only the capture leg holds a sticky IP per attempt.
|
||||
private readonly proxyProvider: ProxyProvider;
|
||||
// Floxy residential
|
||||
private readonly floxyHost: string;
|
||||
private readonly floxyPort: number;
|
||||
private readonly floxyUser: string;
|
||||
private readonly floxyPass: string;
|
||||
private readonly floxyCaptureLifetime: number;
|
||||
// DataImpulse legacy (port-rotated)
|
||||
private readonly diHost: string;
|
||||
private readonly diUser: string;
|
||||
private readonly diPass: string;
|
||||
|
||||
constructor(
|
||||
private configService: ConfigService,
|
||||
private readonly redis: RedisService,
|
||||
private readonly proxyTelemetry: ProxyTelemetryService,
|
||||
) {
|
||||
this.useProxy = this.configService.get<string>("PCAT_USE_PROXY", "true") === "true";
|
||||
this.proxyHost = this.configService.get<string>("PCAT_PROXY_HOST", DI_HOST);
|
||||
this.proxyUser = this.configService.get<string>("PCAT_PROXY_USER", DI_DEFAULT_USER);
|
||||
this.proxyPass = this.configService.get<string>("PCAT_PROXY_PASS", DI_DEFAULT_PASS);
|
||||
const cfg = this.configService;
|
||||
|
||||
// Active capture sites: PCAT_JWT_SITES override (comma-separated) or the
|
||||
// pruned default. Empty/blank entries are dropped; falls back to default if
|
||||
// the override resolves to nothing.
|
||||
const sitesRaw = cfg.get<string>("PCAT_JWT_SITES", "");
|
||||
const parsedSites = sitesRaw
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 0);
|
||||
this.jwtSites = parsedSites.length > 0 ? parsedSites : DEFAULT_JWT_SITES;
|
||||
this.semaphore = new Semaphore(this.jwtSites.length);
|
||||
|
||||
// Provider selection. Back-compat: PCAT_USE_PROXY=false still forces direct.
|
||||
const rawProvider = cfg.get<string>("PCAT_PROXY_PROVIDER", "floxy").toLowerCase();
|
||||
const useProxy = cfg.get<string>("PCAT_USE_PROXY", "true") === "true";
|
||||
this.proxyProvider = !useProxy
|
||||
? "none"
|
||||
: rawProvider === "dataimpulse" || rawProvider === "none"
|
||||
? (rawProvider as ProxyProvider)
|
||||
: "floxy";
|
||||
|
||||
const toInt = (key: string, def: number): number => {
|
||||
const n = Number(cfg.get(key, def));
|
||||
return Number.isInteger(n) && n >= 0 ? n : def;
|
||||
};
|
||||
const toPort = (key: string, def: number): number => {
|
||||
const n = Number(cfg.get(key, def));
|
||||
return Number.isInteger(n) && n >= 1 && n <= 65535 ? n : def;
|
||||
};
|
||||
|
||||
this.floxyHost = cfg.get<string>("PCAT_FLOXY_HOST", FLOXY_HOST);
|
||||
this.floxyPort = toPort("PCAT_FLOXY_PORT", FLOXY_PORT);
|
||||
this.floxyUser = cfg.get<string>("PCAT_FLOXY_USER", FLOXY_DEFAULT_USER);
|
||||
this.floxyPass = cfg.get<string>("PCAT_FLOXY_PASS", FLOXY_DEFAULT_PASS);
|
||||
this.floxyCaptureLifetime = toInt("PCAT_FLOXY_CAPTURE_LIFETIME", FLOXY_CAPTURE_LIFETIME_S);
|
||||
|
||||
this.diHost = cfg.get<string>("PCAT_PROXY_HOST", DI_HOST);
|
||||
this.diUser = cfg.get<string>("PCAT_PROXY_USER", DI_DEFAULT_USER);
|
||||
this.diPass = cfg.get<string>("PCAT_PROXY_PASS", DI_DEFAULT_PASS);
|
||||
|
||||
this.logger.log(
|
||||
this.proxyProvider === "floxy"
|
||||
? `PCAT transport: floxy residential ${this.floxyHost}:${this.floxyPort} (call=rotating, capture=sticky ${this.floxyCaptureLifetime}s)`
|
||||
: this.proxyProvider === "dataimpulse"
|
||||
? `PCAT transport: dataimpulse ${this.diHost}:${DI_PORT_MIN}-${DI_PORT_MAX}`
|
||||
: "PCAT transport: direct (no proxy)",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a proxy for one leg. "call" rotates the exit IP per invocation (the
|
||||
* token is not IP-bound, so each decode/scrape/validate request gets a fresh
|
||||
* residential IP → max IP diversity, min ban). "capture" pins a sticky IP for
|
||||
* the lifetime of a single Playwright capture so the partner site sees a
|
||||
* coherent session. Returns nulls when provider is "none" (direct).
|
||||
*/
|
||||
private buildProxy(leg: ProxyLeg): {
|
||||
proxyUrl: string | null;
|
||||
proxyConfig: { server: string; username: string; password: string } | null;
|
||||
// Telemetry identity: sticky Floxy session id or DataImpulse port. Null on
|
||||
// rotating legs (exit IP changes per request — nothing stable to attribute).
|
||||
sessionKey: string | null;
|
||||
} {
|
||||
if (this.proxyProvider === "none")
|
||||
return { proxyUrl: null, proxyConfig: null, sessionKey: null };
|
||||
|
||||
if (this.proxyProvider === "dataimpulse") {
|
||||
const port = this.allocatePort();
|
||||
const server = `http://${this.diHost}:${port}`;
|
||||
return {
|
||||
proxyUrl: `http://${this.diUser}:${this.diPass}@${this.diHost}:${port}`,
|
||||
proxyConfig: { server, username: this.diUser, password: this.diPass },
|
||||
sessionKey: `di:${port}`,
|
||||
};
|
||||
}
|
||||
|
||||
// floxy
|
||||
let password = this.floxyPass;
|
||||
let sessionKey: string | null = null;
|
||||
if (leg === "capture" && this.floxyCaptureLifetime > 0) {
|
||||
const sid = Math.random().toString(36).slice(2, 10);
|
||||
password = `${this.floxyPass}_session-${sid}_lifetime-${this.floxyCaptureLifetime}`;
|
||||
sessionKey = `s-${sid}`;
|
||||
}
|
||||
const server = `http://${this.floxyHost}:${this.floxyPort}`;
|
||||
return {
|
||||
proxyUrl: `http://${this.floxyUser}:${password}@${this.floxyHost}:${this.floxyPort}`,
|
||||
proxyConfig: { server, username: this.floxyUser, password },
|
||||
sessionKey,
|
||||
};
|
||||
}
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
@@ -193,10 +322,14 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
|
||||
// ─── Public API ───────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Acquire a matched JWT + proxy session from the pool.
|
||||
* Returns a valid slot (round-robin) or captures on-demand if pool is empty.
|
||||
* Acquire a JWT session from the pool.
|
||||
* Returns a valid slot (round-robin) or, if the pool is empty, captures
|
||||
* on-demand. With `blockOnEmpty: false` it does NOT block on a cold pool —
|
||||
* it kicks off a background warm-up and returns null so a latency-sensitive
|
||||
* caller (the decode race) can let a faster source answer instead of eating
|
||||
* the multi-second capture cost inline.
|
||||
*/
|
||||
async acquireSession(): Promise<PcatSession> {
|
||||
async acquireSession(opts?: { blockOnEmpty?: boolean }): Promise<PcatSession | null> {
|
||||
this.trackRequest();
|
||||
|
||||
// Try to find a valid slot in the pool
|
||||
@@ -207,14 +340,26 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
|
||||
return this.slotToSession(slot);
|
||||
}
|
||||
|
||||
// No valid slot — capture on-demand
|
||||
// No valid slot. Latency-sensitive callers fast-fail and warm in background.
|
||||
if (opts?.blockOnEmpty === false) {
|
||||
this.logger.log("JWT pool empty — fast-fail (warming in background)");
|
||||
this.captureToPool().catch((err) => {
|
||||
this.logger.warn(`Background warm capture failed: ${(err as Error).message}`);
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
// Capture on-demand (blocks the caller).
|
||||
this.logger.log("JWT pool empty — capturing on-demand...");
|
||||
const newSlot = await this.captureToPool();
|
||||
return this.slotToSession(newSlot);
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate a session after 401/403, remove the slot, and capture a replacement.
|
||||
* Invalidate a session (401/403, or N consecutive transport failures on
|
||||
* rotating IPs = a token that's dead at the source), remove the slot, and
|
||||
* capture a replacement only if the pool is now empty — a surviving sibling
|
||||
* plus the scheduled refresh/scale-up covers the rest without a capture storm.
|
||||
*/
|
||||
async invalidateSession(session: PcatSession): Promise<void> {
|
||||
const idx = this.pool.indexOf(session._slot);
|
||||
@@ -222,17 +367,18 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
|
||||
this.clearSlotTimer(this.pool[idx]);
|
||||
this.pool.splice(idx, 1);
|
||||
this.logger.log(
|
||||
`JWT pool: slot invalidated (port ${session._slot.proxyPort}), ${this.pool.length} remaining`,
|
||||
`JWT pool: slot invalidated (${new URL(session._slot.siteUsed).hostname}), ${this.pool.length} remaining`,
|
||||
);
|
||||
}
|
||||
|
||||
// The Redis-shared slot may point at a now-401'd JWT (e.g. IP rotated
|
||||
// away on the proxy). Drop it so other containers re-capture instead of
|
||||
// inheriting the same dead slot.
|
||||
// The Redis-shared slot may point at this same now-dead token. Drop it so
|
||||
// a cold-booting container re-captures instead of hydrating the dead token.
|
||||
this.deleteFromRedis().catch(() => {});
|
||||
|
||||
// Capture replacement in background (don't block the caller's retry)
|
||||
if (this.isBusinessHours() || this.pool.length === 0) {
|
||||
// Only force an immediate replacement when the pool emptied; otherwise let
|
||||
// the surviving slot serve and the refresh/scale-up timers backfill, so a
|
||||
// burst of evictions can't stampede the capture pipeline.
|
||||
if (this.pool.length === 0) {
|
||||
this.captureToPool().catch((err) => {
|
||||
this.logger.error(`Replacement capture failed: ${(err as Error).message}`);
|
||||
});
|
||||
@@ -262,16 +408,10 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
|
||||
}
|
||||
|
||||
private slotToSession(slot: JwtSlot): PcatSession {
|
||||
const proxyUrl = this.useProxy
|
||||
? `http://${this.proxyUser}:${this.proxyPass}@${this.proxyHost}:${slot.proxyPort}`
|
||||
: null;
|
||||
const proxyConfig = this.useProxy
|
||||
? {
|
||||
server: `http://${this.proxyHost}:${slot.proxyPort}`,
|
||||
username: this.proxyUser,
|
||||
password: this.proxyPass,
|
||||
}
|
||||
: null;
|
||||
// Fresh rotating call-leg proxy per request — and per retry, since
|
||||
// fetchWithAuth re-acquires the session each attempt → a new exit IP each
|
||||
// time, so a slow/blocked residential IP is shed on the next try.
|
||||
const { proxyUrl, proxyConfig } = this.buildProxy("call");
|
||||
return {
|
||||
apiKey: slot.jwt.raw,
|
||||
apiPath: slot.jwt.apiPath,
|
||||
@@ -294,17 +434,16 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
|
||||
|
||||
const maxRetries = 4;
|
||||
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||
const port = this.allocatePort();
|
||||
const siteUrl = this.getAvailableSite();
|
||||
|
||||
const jwt = await this.attemptCapture(siteUrl, port);
|
||||
const jwt = await this.attemptCapture(siteUrl);
|
||||
if (jwt) {
|
||||
// Validate the JWT+proxy combo against the real upstream before
|
||||
// publishing the slot — see VALIDATION_VIN comment above.
|
||||
const valid = await this.validateSlot(jwt, port);
|
||||
// Validate the token against the real upstream (on a fresh rotating
|
||||
// call-leg IP) before publishing the slot — see VALIDATION_VIN.
|
||||
const valid = await this.validateSlot(jwt);
|
||||
if (!valid) {
|
||||
this.logger.warn(
|
||||
`JWT capture attempt ${attempt + 1}/${maxRetries} validation failed (port ${port}, ${new URL(siteUrl).hostname}), trying next site...`,
|
||||
`JWT capture attempt ${attempt + 1}/${maxRetries} validation failed (${new URL(siteUrl).hostname}), trying next site...`,
|
||||
);
|
||||
// Mark site as used anyway so we rotate away rather than retry it.
|
||||
this.siteLastUsedAt.set(siteUrl, Date.now());
|
||||
@@ -313,10 +452,10 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
|
||||
|
||||
const slot: JwtSlot = {
|
||||
jwt,
|
||||
proxyPort: port,
|
||||
siteUsed: siteUrl,
|
||||
capturedAt: Date.now(),
|
||||
refreshTimer: null,
|
||||
failCount: 0,
|
||||
};
|
||||
|
||||
// Mark site as used
|
||||
@@ -352,13 +491,14 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe the just-captured token + proxy port with a single cheap upstream
|
||||
* call (/car/info with a public demo VIN). Returns true iff the upstream
|
||||
* accepts the combo with 2xx within VALIDATION_TIMEOUT_MS. A failure here
|
||||
* (proxy dead, IP blocked, token 401/403) is far cheaper to absorb at
|
||||
* capture time than to inherit when a real user clicks a category.
|
||||
* Probe the just-captured token with a single cheap upstream call (/car/info
|
||||
* with a public demo VIN) on a fresh rotating call-leg IP — the SAME transport
|
||||
* a real request will use. Returns true iff upstream accepts it with 2xx within
|
||||
* VALIDATION_TIMEOUT_MS. Validating on the call-leg transport (not the capture
|
||||
* IP) is what makes the check meaningful: it rejects a token only if it can't
|
||||
* be served the way it will actually be served.
|
||||
*/
|
||||
private async validateSlot(jwt: PcatJwtToken, port: number): Promise<boolean> {
|
||||
private async validateSlot(jwt: PcatJwtToken): Promise<boolean> {
|
||||
const url = `${PCAT_API_BASE}/car/info?q=${VALIDATION_VIN}`;
|
||||
const fetchOptions: RequestInit & { dispatcher?: any } = {
|
||||
method: "GET",
|
||||
@@ -375,17 +515,35 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
|
||||
},
|
||||
signal: AbortSignal.timeout(VALIDATION_TIMEOUT_MS),
|
||||
};
|
||||
if (this.useProxy) {
|
||||
const { proxyUrl } = this.buildProxy("call");
|
||||
if (proxyUrl) {
|
||||
const { ProxyAgent } = await import("undici");
|
||||
fetchOptions.dispatcher = new ProxyAgent({
|
||||
uri: `http://${this.proxyUser}:${this.proxyPass}@${this.proxyHost}:${port}`,
|
||||
uri: proxyUrl,
|
||||
connect: { timeout: 6_000 },
|
||||
});
|
||||
}
|
||||
const start = Date.now();
|
||||
try {
|
||||
const r = await fetch(url, fetchOptions);
|
||||
this.proxyTelemetry.record({
|
||||
service: "pcat_validate",
|
||||
provider: this.proxyProvider,
|
||||
targetHost: "gui.parts-catalogs.com",
|
||||
statusCode: r.status,
|
||||
success: r.ok,
|
||||
durationMs: Date.now() - start,
|
||||
});
|
||||
return r.ok;
|
||||
} catch (err) {
|
||||
this.proxyTelemetry.record({
|
||||
service: "pcat_validate",
|
||||
provider: this.proxyProvider,
|
||||
targetHost: "gui.parts-catalogs.com",
|
||||
errorKind: classifyTransportError(err),
|
||||
success: false,
|
||||
durationMs: Date.now() - start,
|
||||
});
|
||||
this.logger.debug(`Slot validation threw: ${(err as Error).message}`);
|
||||
return false;
|
||||
}
|
||||
@@ -539,15 +697,16 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
|
||||
|
||||
private getAvailableSite(): string {
|
||||
const now = Date.now();
|
||||
const sites = this.jwtSites;
|
||||
|
||||
// Try round-robin, preferring sites not on cooldown
|
||||
for (let i = 0; i < JWT_SITES.length; i++) {
|
||||
const idx = (this.siteIndex + i) % JWT_SITES.length;
|
||||
const site = JWT_SITES[idx];
|
||||
for (let i = 0; i < sites.length; i++) {
|
||||
const idx = (this.siteIndex + i) % sites.length;
|
||||
const site = sites[idx];
|
||||
const lastUsed = this.siteLastUsedAt.get(site) || 0;
|
||||
|
||||
if (now - lastUsed >= SITE_COOLDOWN) {
|
||||
this.siteIndex = (idx + 1) % JWT_SITES.length;
|
||||
this.siteIndex = (idx + 1) % sites.length;
|
||||
return site;
|
||||
}
|
||||
}
|
||||
@@ -555,15 +714,15 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
|
||||
// All on cooldown — pick the one with oldest usage
|
||||
let oldestIdx = 0;
|
||||
let oldestTime = Number.POSITIVE_INFINITY;
|
||||
for (let i = 0; i < JWT_SITES.length; i++) {
|
||||
const lastUsed = this.siteLastUsedAt.get(JWT_SITES[i]) || 0;
|
||||
for (let i = 0; i < sites.length; i++) {
|
||||
const lastUsed = this.siteLastUsedAt.get(sites[i]) || 0;
|
||||
if (lastUsed < oldestTime) {
|
||||
oldestTime = lastUsed;
|
||||
oldestIdx = i;
|
||||
}
|
||||
}
|
||||
this.siteIndex = (oldestIdx + 1) % JWT_SITES.length;
|
||||
return JWT_SITES[oldestIdx];
|
||||
this.siteIndex = (oldestIdx + 1) % sites.length;
|
||||
return sites[oldestIdx];
|
||||
}
|
||||
|
||||
private allocatePort(): number {
|
||||
@@ -572,19 +731,37 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
|
||||
|
||||
// ─── JWT capture via Playwright ───────────────────────────
|
||||
|
||||
private async attemptCapture(siteUrl: string, port: number): Promise<PcatJwtToken | null> {
|
||||
private async attemptCapture(siteUrl: string): Promise<PcatJwtToken | null> {
|
||||
let context: BrowserContext | null = null;
|
||||
const startTime = Date.now();
|
||||
|
||||
// Sticky capture proxy: one residential exit IP pinned for the lifetime of
|
||||
// this capture so the partner site + widget see a coherent session.
|
||||
const { proxyConfig, proxyUrl, sessionKey } = this.buildProxy("capture");
|
||||
|
||||
// The session is sticky, so a parallel ipify probe exits from the SAME IP
|
||||
// the capture will use — that's what makes banned-IP tracking concrete.
|
||||
// Resolves in ~1-2s while the capture itself takes 7s+; never throws.
|
||||
const exitIpPromise = proxyUrl ? resolveExitIp(proxyUrl) : Promise.resolve(null);
|
||||
const logCapture = (ok: boolean, errorKind?: string): void => {
|
||||
void exitIpPromise.then((exitIp) =>
|
||||
this.proxyTelemetry.record({
|
||||
service: "pcat_capture",
|
||||
provider: this.proxyProvider,
|
||||
sessionKey,
|
||||
exitIp,
|
||||
targetHost: new URL(siteUrl).hostname,
|
||||
errorKind: errorKind ?? null,
|
||||
success: ok,
|
||||
durationMs: Date.now() - startTime,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
try {
|
||||
// Build context options with proxy
|
||||
const contextOptions: Record<string, unknown> = {};
|
||||
if (this.useProxy) {
|
||||
contextOptions.proxy = {
|
||||
server: `http://${this.proxyHost}:${port}`,
|
||||
username: this.proxyUser,
|
||||
password: this.proxyPass,
|
||||
};
|
||||
if (proxyConfig) {
|
||||
contextOptions.proxy = proxyConfig;
|
||||
}
|
||||
|
||||
if (!this.browser) throw new Error("PCAT browser not initialized");
|
||||
@@ -640,12 +817,16 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
|
||||
return route.continue();
|
||||
});
|
||||
|
||||
// Navigate — networkidle waits for widget JS to load + make API calls
|
||||
// Navigate with domcontentloaded (not networkidle): on JS-heavy catalog
|
||||
// sites through a slow residential proxy networkidle often never settles
|
||||
// and the goto times out before the widget fires its token call. DOM-ready
|
||||
// is reliable (~7s measured); the request listener + post-goto poll then
|
||||
// catch the /v3/api/proxy token call (~11.8s measured).
|
||||
let gotoOk = true;
|
||||
try {
|
||||
await page.goto(siteUrl, {
|
||||
timeout: PAGE_TIMEOUT,
|
||||
waitUntil: "networkidle",
|
||||
waitUntil: "domcontentloaded",
|
||||
});
|
||||
} catch (navErr) {
|
||||
// Navigation may timeout but JWT could still be captured if the
|
||||
@@ -665,13 +846,18 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
|
||||
const elapsed = Date.now() - startTime;
|
||||
|
||||
if (capturedToken) {
|
||||
logCapture(true);
|
||||
this.logger.log(`Token captured in ${elapsed}ms from ${new URL(siteUrl).hostname}`);
|
||||
return capturedToken;
|
||||
}
|
||||
|
||||
// Widget never fired its token call — on a sticky residential IP this is
|
||||
// the classic "partner site blocked/ratelimited this exit IP" signature.
|
||||
logCapture(false, "capture_timeout");
|
||||
this.logger.debug(`No token after ${elapsed}ms from ${siteUrl}`);
|
||||
return null;
|
||||
} catch (err) {
|
||||
logCapture(false, classifyTransportError(err));
|
||||
this.logger.warn(`Token capture error: ${(err as Error).message}`);
|
||||
return null;
|
||||
} finally {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { ProxyTelemetryModule } from "../proxy-telemetry/proxy-telemetry.module";
|
||||
import { PartsCatalogsAuthService } from "./parts-catalogs-auth.service";
|
||||
import { PartsCatalogsService } from "./parts-catalogs.service";
|
||||
|
||||
@Module({
|
||||
imports: [ProxyTelemetryModule],
|
||||
providers: [PartsCatalogsAuthService, PartsCatalogsService],
|
||||
exports: [PartsCatalogsService],
|
||||
})
|
||||
|
||||
@@ -2,14 +2,20 @@
|
||||
* Parts-Catalogs API Service — HTTP client for parts-catalogs.com v3
|
||||
*
|
||||
* Calls the v3 widget proxy (gui.parts-catalogs.com/v3/api/proxy/*) with the
|
||||
* captured TWS- token + supporting X-* headers. Requests must go through the
|
||||
* same DataImpulse proxy port that captured the token (IP-bound).
|
||||
* captured TWS- token + supporting X-* headers. The token is portable (NOT
|
||||
* IP-bound), so each request rotates a fresh exit IP via the auth service's
|
||||
* call-leg proxy (PartsCatalogsAuthService.buildProxy).
|
||||
*/
|
||||
|
||||
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 {
|
||||
ProxyTelemetryService,
|
||||
classifyTransportError,
|
||||
describeProxyUrl,
|
||||
} from "../proxy-telemetry/proxy-telemetry.service";
|
||||
import { PartsCatalogsAuthService } from "./parts-catalogs-auth.service";
|
||||
import {
|
||||
PcatCar,
|
||||
@@ -20,19 +26,38 @@ import {
|
||||
} from "./parts-catalogs.types";
|
||||
|
||||
const API_BASE = "https://gui.parts-catalogs.com/v3/api/proxy";
|
||||
const REQUEST_TIMEOUT = Number(process.env.PCAT_REQUEST_TIMEOUT_MS) || 30_000;
|
||||
// Catalog scrape (groups2/parts2). Lowered 30s→10s (2026-06-10): the old 30s
|
||||
// default could blow the caller's 25s decode budget on a single slow attempt,
|
||||
// and through a rotating residential proxy a healthy call answers in 1-4s. A
|
||||
// slow/blocked exit IP now aborts at 10s and the retry re-acquires a fresh IP.
|
||||
const REQUEST_TIMEOUT = Number(process.env.PCAT_REQUEST_TIMEOUT_MS) || 10_000;
|
||||
const MAX_RETRIES = Number(process.env.PCAT_MAX_RETRIES) || 2;
|
||||
// A dead DataImpulse proxy port otherwise stalls for undici's 10s default connect
|
||||
// timeout before the retry rotates to a fresh port — three of those blow the
|
||||
// caller's 25s decode budget. Fail fast so retries reach a live port in time.
|
||||
// undici's ProxyAgent connect.timeout bounds the connect to the proxy entry.
|
||||
const PROXY_CONNECT_TIMEOUT = Number(process.env.PCAT_PROXY_CONNECT_TIMEOUT_MS) || 6_000;
|
||||
// VIN decode (/car/info) answers in <1s on a healthy proxy, so a long timeout only
|
||||
// prolongs dead-port connects. undici's ProxyAgent connect.timeout does NOT bound the
|
||||
// connection to the proxy itself (it stays at undici's 10s default), so we bound it at
|
||||
// the fetch level: a short per-call timeout + extra retries makes a stuck DataImpulse
|
||||
// port abort fast and rotate to a live one within the caller's 25s decode budget.
|
||||
// VIN decode (/car/info) answers in 1-4s through the rotating residential proxy.
|
||||
// A short per-call timeout + retries means a slow exit IP aborts fast and the
|
||||
// next attempt re-acquires the session → a fresh IP, all within the 25s budget.
|
||||
const DECODE_REQUEST_TIMEOUT = Number(process.env.PCAT_DECODE_TIMEOUT_MS) || 6_000;
|
||||
const DECODE_MAX_RETRIES = Number(process.env.PCAT_DECODE_MAX_RETRIES) || 4;
|
||||
// Evict a pooled token after this many consecutive transport failures (reset on
|
||||
// any 2xx). Through the rotating residential proxy each attempt hits a different
|
||||
// exit IP, so N failures in a row on N different IPs ⇒ the token is dead at the
|
||||
// source, not a flaky IP. Pre-rotation this was the "dead slot never evicted"
|
||||
// bug: invalidateSession only fired on 401/403, so a bad token was served until TTL.
|
||||
const TRANSPORT_FAIL_EVICT_THRESHOLD = Number(process.env.PCAT_TRANSPORT_FAIL_EVICT) || 2;
|
||||
|
||||
/**
|
||||
* Thrown when a fast-fail (blockOnEmpty:false) caller hits a cold pool. NOT a
|
||||
* "VIN not found" — pcat was skipped while the pool warmed, so the caller must
|
||||
* treat it as a transient miss (don't poison the negative cache) and let
|
||||
* another source answer.
|
||||
*/
|
||||
class PcatColdPoolError extends Error {
|
||||
constructor() {
|
||||
super("pcat cold pool — skipped to avoid inline capture");
|
||||
this.name = "PcatColdPoolError";
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PartsCatalogsService {
|
||||
@@ -42,6 +67,7 @@ export class PartsCatalogsService {
|
||||
private authService: PartsCatalogsAuthService,
|
||||
private redis: RedisService,
|
||||
private posthog: PostHogService,
|
||||
private proxyTelemetry: ProxyTelemetryService,
|
||||
) {}
|
||||
|
||||
/** Mark parts-catalogs as actively used (5min TTL) to defer prefetch worker */
|
||||
@@ -62,7 +88,8 @@ export class PartsCatalogsService {
|
||||
async decodeVin(
|
||||
vin: string,
|
||||
signal?: AbortSignal,
|
||||
outcome?: { transient: boolean },
|
||||
outcome?: { transient: boolean; coldSkip?: boolean },
|
||||
opts?: { blockOnEmpty?: boolean },
|
||||
): Promise<PcatVinResult | null> {
|
||||
if (!(await this.posthog.isSourceLive("parts-catalogs"))) {
|
||||
this.logger.warn("parts-catalogs disabled by kill switch (kill-source-parts-catalogs)");
|
||||
@@ -72,6 +99,7 @@ export class PartsCatalogsService {
|
||||
const data = await this.fetchWithAuth("/car/info", { q: vin }, signal, {
|
||||
timeoutMs: DECODE_REQUEST_TIMEOUT,
|
||||
maxRetries: DECODE_MAX_RETRIES,
|
||||
blockOnEmpty: opts?.blockOnEmpty,
|
||||
});
|
||||
|
||||
if (!data || typeof data !== "object") {
|
||||
@@ -105,9 +133,23 @@ export class PartsCatalogsService {
|
||||
return { cars };
|
||||
} catch (err) {
|
||||
const e = err as Error & { cause?: unknown };
|
||||
// Distinguish a transient transport blip (proxy timeout / dropped DataImpulse
|
||||
// connection) from a genuine "not found". The caller uses `outcome.transient`
|
||||
// to decide whether to poison the negative cache — a blip must not.
|
||||
// Cold-pool fast-fail: pcat was skipped (pool warming), NOT a definitive
|
||||
// miss. Mark transient so the orchestrator doesn't negative-cache a VIN
|
||||
// pcat hasn't actually evaluated, and stay quiet (this is expected).
|
||||
if (e instanceof PcatColdPoolError) {
|
||||
if (outcome) {
|
||||
outcome.transient = true;
|
||||
// A deliberate cold-pool skip is not a transport fault — flag it so the
|
||||
// circuit breaker doesn't count it (the pool is already warming).
|
||||
outcome.coldSkip = true;
|
||||
}
|
||||
this.logger.debug(`pcat decode skipped for ${vin}: cold pool (warming)`);
|
||||
return null;
|
||||
}
|
||||
// Distinguish a transient transport blip (proxy timeout / dropped
|
||||
// connection) from a genuine "not found". The caller uses
|
||||
// `outcome.transient` to decide whether to poison the negative cache — a
|
||||
// blip must not.
|
||||
if (
|
||||
outcome &&
|
||||
(e.name === "TimeoutError" ||
|
||||
@@ -212,7 +254,7 @@ export class PartsCatalogsService {
|
||||
endpoint: string,
|
||||
params?: Record<string, string>,
|
||||
externalSignal?: AbortSignal,
|
||||
opts?: { timeoutMs?: number; maxRetries?: number },
|
||||
opts?: { timeoutMs?: number; maxRetries?: number; blockOnEmpty?: boolean },
|
||||
): Promise<any> {
|
||||
const maxRetries = opts?.maxRetries ?? MAX_RETRIES;
|
||||
const timeoutMs = opts?.timeoutMs ?? REQUEST_TIMEOUT;
|
||||
@@ -223,7 +265,12 @@ export class PartsCatalogsService {
|
||||
if (externalSignal?.aborted) {
|
||||
throw new Error("Request aborted by caller");
|
||||
}
|
||||
session = await this.authService.acquireSession();
|
||||
session = await this.authService.acquireSession({ blockOnEmpty: opts?.blockOnEmpty });
|
||||
if (!session) {
|
||||
// Cold pool + fast-fail: warming in the background; don't retry, let the
|
||||
// caller fall through to another source.
|
||||
throw new PcatColdPoolError();
|
||||
}
|
||||
|
||||
const url = new URL(`${API_BASE}${endpoint}`);
|
||||
if (params) {
|
||||
@@ -232,6 +279,7 @@ export class PartsCatalogsService {
|
||||
}
|
||||
}
|
||||
|
||||
const attemptStart = Date.now();
|
||||
try {
|
||||
const signals = [AbortSignal.timeout(timeoutMs)];
|
||||
if (externalSignal) signals.push(externalSignal);
|
||||
@@ -262,7 +310,20 @@ export class PartsCatalogsService {
|
||||
|
||||
const response = await fetch(url.toString(), fetchOptions);
|
||||
|
||||
// An HTTP answer arrived — the proxy leg worked at transport level;
|
||||
// log the upstream's verdict (403/429 are the IP-ban signals).
|
||||
this.proxyTelemetry.record({
|
||||
service: "pcat_call",
|
||||
...describeProxyUrl(session.proxyUrl),
|
||||
targetHost: url.hostname,
|
||||
statusCode: response.status,
|
||||
success: response.ok,
|
||||
durationMs: Date.now() - attemptStart,
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
// Token proven good on this IP — clear any accrued failure streak.
|
||||
session._slot.failCount = 0;
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
@@ -280,6 +341,18 @@ export class PartsCatalogsService {
|
||||
throw new Error(`HTTP ${response.status} from ${endpoint}: ${text.slice(0, 200)}`);
|
||||
} catch (err) {
|
||||
const e = err as Error & { cause?: unknown };
|
||||
// Transport failure (no HTTP answer) — the try block already logged any
|
||||
// real HTTP response before rethrowing it as `Error: HTTP <code> …`.
|
||||
if (!/^HTTP \d/.test(e.message ?? "")) {
|
||||
this.proxyTelemetry.record({
|
||||
service: "pcat_call",
|
||||
...describeProxyUrl(session.proxyUrl),
|
||||
targetHost: url.hostname,
|
||||
errorKind: classifyTransportError(e),
|
||||
success: false,
|
||||
durationMs: Date.now() - attemptStart,
|
||||
});
|
||||
}
|
||||
// Retry transient TRANSPORT failures only: request timeouts and undici
|
||||
// network errors ("TypeError: fetch failed" — a dropped/reset DataImpulse
|
||||
// proxy connection). A definitive HTTP response (e.g. 400 "list of parts
|
||||
@@ -291,16 +364,28 @@ export class PartsCatalogsService {
|
||||
/fetch failed|ECONNRESET|ECONNREFUSED|EAI_AGAIN|socket hang up|other side closed|terminated|UND_ERR/i.test(
|
||||
`${e.message} ${String(e.cause ?? "")}`,
|
||||
);
|
||||
if (transient && attempt < maxRetries) {
|
||||
this.logger.warn(
|
||||
`Transient transport error on ${endpoint} (attempt ${attempt + 1}/${maxRetries + 1}): ${e.message}${
|
||||
e.cause ? ` [cause: ${String(e.cause)}]` : ""
|
||||
}`,
|
||||
);
|
||||
// Brief backoff so a momentarily-flaky proxy port can recover; the next
|
||||
// loop iteration re-acquires a session (round-robin across the pool).
|
||||
await new Promise((r) => setTimeout(r, 400 * (attempt + 1)));
|
||||
continue;
|
||||
if (transient) {
|
||||
// A token that keeps failing transport across rotating IPs is dead at
|
||||
// the source — evict it so it stops being round-robin-served (the old
|
||||
// 401/403-only eviction never caught this). Only when a proxy is in use;
|
||||
// direct-mode transport errors are upstream, not token/IP problems.
|
||||
if (session.proxyUrl) {
|
||||
session._slot.failCount = (session._slot.failCount ?? 0) + 1;
|
||||
if (session._slot.failCount >= TRANSPORT_FAIL_EVICT_THRESHOLD) {
|
||||
await this.authService.invalidateSession(session);
|
||||
}
|
||||
}
|
||||
if (attempt < maxRetries) {
|
||||
this.logger.warn(
|
||||
`Transient transport error on ${endpoint} (attempt ${attempt + 1}/${maxRetries + 1}): ${e.message}${
|
||||
e.cause ? ` [cause: ${String(e.cause)}]` : ""
|
||||
}`,
|
||||
);
|
||||
// Brief backoff so a momentarily-flaky exit IP can recover; the next
|
||||
// loop iteration re-acquires a session (a fresh rotating IP).
|
||||
await new Promise((r) => setTimeout(r, 400 * (attempt + 1)));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
/**
|
||||
* Captured from a parts-catalogs.com v3 widget request.
|
||||
* Token + the supporting X-* headers the widget sends with every API call.
|
||||
* IP-bound (must be reused with the same proxy port that captured it).
|
||||
*
|
||||
* NOT request-time IP-bound. Verified on prod 2026-06-10: 0 auth 401/403 across
|
||||
* thousands of proxied calls, and a token captured on one exit IP replays with a
|
||||
* 200 through arbitrary other IPs (incl. fresh residential ones). Upstream issues
|
||||
* a stable shared token per origin. So the call leg rotates exit IPs freely; only
|
||||
* the capture (Playwright) leg holds a sticky IP for the duration of one capture.
|
||||
*/
|
||||
export interface PcatJwtToken {
|
||||
raw: string; // x-api-key value, e.g. "TWS-016EA7BE-..."
|
||||
@@ -15,10 +20,14 @@ export interface PcatJwtToken {
|
||||
|
||||
export interface JwtSlot {
|
||||
jwt: PcatJwtToken;
|
||||
proxyPort: number;
|
||||
siteUsed: string;
|
||||
capturedAt: number;
|
||||
refreshTimer: ReturnType<typeof setTimeout> | null;
|
||||
// Consecutive transport failures across requests (reset on any 2xx). The slot
|
||||
// is evicted once this crosses a threshold — a token that keeps failing on
|
||||
// fresh rotating IPs is dead at the source, not a flaky exit IP. Optional so
|
||||
// slots persisted to Redis by older builds still hydrate.
|
||||
failCount?: number;
|
||||
}
|
||||
|
||||
export interface PcatSession {
|
||||
|
||||
@@ -565,6 +565,7 @@ export const PL24_WMI_SERVICE_MAP: Record<string, string> = {
|
||||
// Opel / Vauxhall
|
||||
W0L: "opel_parts", // Opel AG (Germany)
|
||||
W0V: "opel_parts", // Opel (newer Stellantis-era WMI)
|
||||
VXK: "opel_parts", // PSA/Stellantis-platform Opel (Corsa F, Mokka B — France/Spain plants)
|
||||
|
||||
// Citroën (PSA)
|
||||
VF7: "citroen_parts", // Citroën SA (France)
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { ProxyTelemetryService } from "./proxy-telemetry.service";
|
||||
|
||||
// Imported by PartsCatalogsModule and EmexModule; Nest module caching makes the
|
||||
// service a singleton (one shared flush buffer) across both.
|
||||
@Module({
|
||||
providers: [ProxyTelemetryService],
|
||||
exports: [ProxyTelemetryService],
|
||||
})
|
||||
export class ProxyTelemetryModule {}
|
||||
@@ -0,0 +1,151 @@
|
||||
import { Inject, Injectable, Logger, OnModuleDestroy } from "@nestjs/common";
|
||||
import { DATABASE, type Database } from "../../database/database.provider";
|
||||
import { proxyLogs } from "../../database/schema/core";
|
||||
|
||||
/**
|
||||
* Proxy telemetry — fire-and-forget logging of every proxied upstream attempt
|
||||
* (pcat call/capture/validate, emex http) into `proxy_logs`, so the Süper Panel
|
||||
* can grade proxy providers per service and track banned exit IPs / sessions.
|
||||
*
|
||||
* Design constraints:
|
||||
* - MUST never affect the request path: record() only pushes to an in-memory
|
||||
* buffer; a timer flushes batches, and every DB error is swallowed.
|
||||
* - Bounded memory: the buffer is capped; when full, oldest rows are dropped.
|
||||
*/
|
||||
|
||||
export type ProxyServiceLeg = "pcat_call" | "pcat_capture" | "pcat_validate" | "emex_http";
|
||||
export type ProxyProviderName = "floxy" | "dataimpulse" | "none";
|
||||
|
||||
export interface ProxyLogEvent {
|
||||
service: ProxyServiceLeg;
|
||||
provider: ProxyProviderName;
|
||||
sessionKey?: string | null;
|
||||
exitIp?: string | null;
|
||||
targetHost?: string | null;
|
||||
statusCode?: number | null;
|
||||
errorKind?: string | null;
|
||||
success: boolean;
|
||||
durationMs?: number | null;
|
||||
}
|
||||
|
||||
const FLUSH_INTERVAL_MS = 5_000;
|
||||
const FLUSH_BATCH_SIZE = 50;
|
||||
const BUFFER_CAP = 500;
|
||||
|
||||
// Upstream statuses that signal an IP-level block (not an auth/data problem):
|
||||
// 403 = forbidden (WAF/geo/IP ban), 429 = rate limited. 402 is the proxy
|
||||
// provider itself rejecting (balance exhausted — Floxy did this 2026-06-10);
|
||||
// counted as banned=false but worth surfacing via status_code.
|
||||
export function isBanStatus(status: number | null | undefined): boolean {
|
||||
return status === 403 || status === 429;
|
||||
}
|
||||
|
||||
/** Classify a transport error (no HTTP answer) into a coarse error_kind. */
|
||||
export function classifyTransportError(err: unknown): string {
|
||||
const e = err as Error & { cause?: unknown };
|
||||
const text = `${e?.name ?? ""} ${e?.message ?? ""} ${String(e?.cause ?? "")}`;
|
||||
if (/TimeoutError|UND_ERR_CONNECT_TIMEOUT|UND_ERR_HEADERS_TIMEOUT/i.test(text)) return "timeout";
|
||||
if (/AbortError|aborted/i.test(text)) return "aborted";
|
||||
if (/ECONNREFUSED|UND_ERR_SOCKET|connect/i.test(text)) return "connect";
|
||||
if (/ECONNRESET|socket hang up|other side closed|terminated/i.test(text)) return "reset";
|
||||
if (/EAI_AGAIN|ENOTFOUND/i.test(text)) return "dns";
|
||||
return "transport";
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive provider + session key from a proxy URL the way the integrations
|
||||
* build them: Floxy passwords may embed `_session-<id>_lifetime-<n>` (sticky);
|
||||
* DataImpulse rotates by port, so the port IS the session identity.
|
||||
*/
|
||||
export function describeProxyUrl(proxyUrl: string | null | undefined): {
|
||||
provider: ProxyProviderName;
|
||||
sessionKey: string | null;
|
||||
} {
|
||||
if (!proxyUrl) return { provider: "none", sessionKey: null };
|
||||
try {
|
||||
const u = new URL(proxyUrl);
|
||||
if (/floxy/i.test(u.hostname)) {
|
||||
const m = decodeURIComponent(u.password).match(/_session-([A-Za-z0-9]+)/);
|
||||
return { provider: "floxy", sessionKey: m ? `s-${m[1]}` : null };
|
||||
}
|
||||
return { provider: "dataimpulse", sessionKey: `di:${u.port}` };
|
||||
} catch {
|
||||
return { provider: "none", sessionKey: null };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the residential exit IP of a (sticky) proxy session with one cheap
|
||||
* probe through the same proxy. Only meaningful for sticky sessions — on a
|
||||
* rotating leg the probe and the real request exit from different IPs.
|
||||
*/
|
||||
export async function resolveExitIp(
|
||||
proxyUrl: string,
|
||||
timeoutMs = 5_000,
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const { ProxyAgent } = await import("undici");
|
||||
const res = await fetch("https://api.ipify.org?format=text", {
|
||||
dispatcher: new ProxyAgent({ uri: proxyUrl, connect: { timeout: timeoutMs } }),
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
} as unknown as RequestInit);
|
||||
if (!res.ok) return null;
|
||||
const ip = (await res.text()).trim();
|
||||
return /^[0-9a-fA-F.:]{3,45}$/.test(ip) ? ip : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ProxyTelemetryService implements OnModuleDestroy {
|
||||
private readonly logger = new Logger(ProxyTelemetryService.name);
|
||||
private buffer: ProxyLogEvent[] = [];
|
||||
private flushTimer: NodeJS.Timeout | null = null;
|
||||
private flushing = false;
|
||||
|
||||
constructor(@Inject(DATABASE) private readonly db: Database) {
|
||||
this.flushTimer = setInterval(() => void this.flush(), FLUSH_INTERVAL_MS);
|
||||
// Never keep the process alive just to flush telemetry.
|
||||
this.flushTimer.unref?.();
|
||||
}
|
||||
|
||||
/** Queue one proxied-attempt record. Never throws, never blocks. */
|
||||
record(event: ProxyLogEvent): void {
|
||||
if (this.buffer.length >= BUFFER_CAP) this.buffer.shift();
|
||||
this.buffer.push(event);
|
||||
if (this.buffer.length >= FLUSH_BATCH_SIZE) void this.flush();
|
||||
}
|
||||
|
||||
private async flush(): Promise<void> {
|
||||
if (this.flushing || this.buffer.length === 0) return;
|
||||
this.flushing = true;
|
||||
const batch = this.buffer.splice(0, FLUSH_BATCH_SIZE * 2);
|
||||
try {
|
||||
await this.db.insert(proxyLogs).values(
|
||||
batch.map((e) => ({
|
||||
service: e.service,
|
||||
provider: e.provider,
|
||||
sessionKey: e.sessionKey ?? null,
|
||||
exitIp: e.exitIp ?? null,
|
||||
targetHost: e.targetHost?.slice(0, 120) ?? null,
|
||||
statusCode: e.statusCode ?? null,
|
||||
errorKind: e.errorKind ?? null,
|
||||
success: e.success,
|
||||
banned: isBanStatus(e.statusCode),
|
||||
durationMs: e.durationMs != null ? Math.round(e.durationMs) : null,
|
||||
})),
|
||||
);
|
||||
} catch (err) {
|
||||
// Telemetry must never matter more than the product. Drop the batch.
|
||||
this.logger.debug(`proxy_logs flush failed (${batch.length} rows): ${(err as Error).message}`);
|
||||
} finally {
|
||||
this.flushing = false;
|
||||
}
|
||||
}
|
||||
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
if (this.flushTimer) clearInterval(this.flushTimer);
|
||||
await this.flush();
|
||||
}
|
||||
}
|
||||
@@ -115,6 +115,12 @@ export class JobsModule implements OnModuleInit, OnModuleDestroy {
|
||||
opts: {
|
||||
removeOnComplete: { count: 48 },
|
||||
removeOnFail: { count: 100 },
|
||||
// lifo so the scan job lands at the tail of the wait list and runs on
|
||||
// the next tick instead of queueing behind the deep-drill backlog —
|
||||
// otherwise the hourly scan is buried for days and never fires. BullMQ
|
||||
// drains wait (RPOPLPUSH from the tail) before the prioritized ZSET, so
|
||||
// lifo — not `priority` — is what jumps an already-deep wait queue.
|
||||
lifo: true,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
143
apps/api/src/jobs/prefetch-worker.service.spec.ts
Normal file
143
apps/api/src/jobs/prefetch-worker.service.spec.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { PrefetchWorkerService } from "./prefetch-worker.service";
|
||||
|
||||
/**
|
||||
* Chainable drizzle mock: select/from/where/orderBy return the chain; the
|
||||
* terminal limit() yields the next queued result array. Mirrors the trick the
|
||||
* other api specs use (await on a plain array resolves to the array itself).
|
||||
*/
|
||||
function makeDb(limitResults: unknown[][]) {
|
||||
const queued = [...limitResults];
|
||||
const chain: Record<string, unknown> = {};
|
||||
for (const m of ["select", "from", "where", "orderBy"]) {
|
||||
chain[m] = vi.fn(() => chain);
|
||||
}
|
||||
chain.limit = vi.fn(() => queued.shift() ?? []);
|
||||
return chain;
|
||||
}
|
||||
|
||||
function makeDeps(opts: { waiting: number; limitResults: unknown[][] }) {
|
||||
const queue = {
|
||||
// typed args so `.mock.calls[i]` is `unknown[]` (not a 0-length tuple) — the
|
||||
// production `nest build` compiles spec files and rejects tuple-index access.
|
||||
add: vi.fn((..._args: unknown[]) => Promise.resolve(undefined)),
|
||||
getJobCounts: vi.fn(async () => ({ waiting: opts.waiting, delayed: 0, active: 0 })),
|
||||
};
|
||||
const redis = {
|
||||
exists: vi.fn(async () => false), // no source cooldown, no in-flight guard
|
||||
get: vi.fn(async () => null), // no no-result residue
|
||||
set: vi.fn(async () => undefined),
|
||||
getJson: vi.fn(async () => null), // Phase-2 cursor empty
|
||||
setJson: vi.fn(async () => undefined),
|
||||
};
|
||||
const posthog = { payload: vi.fn(async () => ({})) }; // compiled-in defaults
|
||||
const db = makeDb(opts.limitResults);
|
||||
const service = new PrefetchWorkerService(
|
||||
queue as never,
|
||||
{} as never, // categoriesService — unused by the scan
|
||||
redis as never,
|
||||
posthog as never,
|
||||
db as never,
|
||||
);
|
||||
return { service, queue, redis, posthog, db };
|
||||
}
|
||||
|
||||
describe("PrefetchWorkerService — fast lane (lifo) + backlog gating", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
process.env.CATALOG_BACKFILL_ENABLED = "true"; // force the prod-host gate on
|
||||
});
|
||||
afterEach(() => {
|
||||
process.env.CATALOG_BACKFILL_ENABLED = undefined;
|
||||
});
|
||||
|
||||
describe("enqueueInit", () => {
|
||||
it("sets lifo + data.fast when fast", async () => {
|
||||
const { service, queue } = makeDeps({ waiting: 0, limitResults: [] });
|
||||
await (service as never as { enqueueInit: (a: string, b: string, c: boolean) => Promise<void> })
|
||||
.enqueueInit("v1", "emex", true);
|
||||
const [name, data, jobOpts] = queue.add.mock.calls[0];
|
||||
expect(name).toBe("prefetch-init");
|
||||
expect(data).toMatchObject({ vehicleId: "v1", source: "emex", fast: true });
|
||||
expect(jobOpts).toMatchObject({ lifo: true });
|
||||
});
|
||||
|
||||
it("omits lifo when not fast", async () => {
|
||||
const { service, queue } = makeDeps({ waiting: 0, limitResults: [] });
|
||||
await (service as never as { enqueueInit: (a: string, b: string, c: boolean) => Promise<void> })
|
||||
.enqueueInit("v1", "emex", false);
|
||||
const [, data, jobOpts] = queue.add.mock.calls[0];
|
||||
expect(data).toMatchObject({ fast: false });
|
||||
expect((jobOpts as { lifo?: boolean }).lifo).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("addJob (child jobs)", () => {
|
||||
it("sets lifo when data.fast is true", async () => {
|
||||
const { service, queue } = makeDeps({ waiting: 0, limitResults: [] });
|
||||
await (service as never as { addJob: (n: string, d: unknown) => Promise<void> }).addJob(
|
||||
"prefetch-children",
|
||||
{ vehicleId: "v1", categoryId: "c1", source: "emex", action: "children", depth: 1, fast: true },
|
||||
);
|
||||
const [, , jobOpts] = queue.add.mock.calls[0];
|
||||
expect(jobOpts).toMatchObject({ lifo: true });
|
||||
});
|
||||
|
||||
it("omits lifo when data.fast is falsy", async () => {
|
||||
const { service, queue } = makeDeps({ waiting: 0, limitResults: [] });
|
||||
await (service as never as { addJob: (n: string, d: unknown) => Promise<void> }).addJob(
|
||||
"prefetch-children",
|
||||
{ vehicleId: "v1", categoryId: "c1", source: "emex", action: "children", depth: 1 },
|
||||
);
|
||||
const [, , jobOpts] = queue.add.mock.calls[0];
|
||||
expect((jobOpts as { lifo?: boolean }).lifo).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("processBackfillScan", () => {
|
||||
it("runs Phase-1 in the fast lane even when the backlog is far over the ceiling", async () => {
|
||||
// waiting 50k ≫ default maxBacklog 1000 → Phase-2 must be suspended, but a
|
||||
// zero-parts vehicle must still be onboarded with lifo.
|
||||
const { service, queue, redis } = makeDeps({
|
||||
waiting: 50_000,
|
||||
limitResults: [[{ id: "empty1", source: "emex" }]], // Phase-1 query result
|
||||
});
|
||||
await (service as never as { processBackfillScan: () => Promise<void> }).processBackfillScan();
|
||||
|
||||
expect(queue.add).toHaveBeenCalledTimes(1);
|
||||
const [name, data, jobOpts] = queue.add.mock.calls[0];
|
||||
expect(name).toBe("prefetch-init");
|
||||
expect(data).toMatchObject({ vehicleId: "empty1", fast: true });
|
||||
expect(jobOpts).toMatchObject({ lifo: true });
|
||||
// Phase-2 suspended → the rolling cursor is never read or advanced.
|
||||
expect(redis.getJson).not.toHaveBeenCalled();
|
||||
expect(redis.setJson).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("runs Phase-2 (normal lane, no lifo) when the backlog is under the ceiling", async () => {
|
||||
// waiting 10 < 1000 → Phase-1 empty, Phase-2 picks a vehicle with fast=false.
|
||||
const { service, queue, redis } = makeDeps({
|
||||
waiting: 10,
|
||||
limitResults: [
|
||||
[], // Phase-1: no zero-parts vehicles
|
||||
[{ id: "stale1", source: "emex", createdAt: new Date("2026-01-01T00:00:00Z") }], // Phase-2
|
||||
],
|
||||
});
|
||||
await (service as never as { processBackfillScan: () => Promise<void> }).processBackfillScan();
|
||||
|
||||
expect(queue.add).toHaveBeenCalledTimes(1);
|
||||
const [, data, jobOpts] = queue.add.mock.calls[0];
|
||||
expect(data).toMatchObject({ vehicleId: "stale1", fast: false });
|
||||
expect((jobOpts as { lifo?: boolean }).lifo).toBeUndefined();
|
||||
// Phase-2 ran → cursor advanced.
|
||||
expect(redis.setJson).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips entirely when the backfill gate is off (not prod host)", async () => {
|
||||
process.env.CATALOG_BACKFILL_ENABLED = "false";
|
||||
const { service, queue } = makeDeps({ waiting: 0, limitResults: [] });
|
||||
await (service as never as { processBackfillScan: () => Promise<void> }).processBackfillScan();
|
||||
expect(queue.add).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -167,7 +167,7 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
|
||||
* Init job: walk the category tree for a vehicle and queue sub-jobs.
|
||||
*/
|
||||
private async processInit(job: Job<PrefetchInitJobData>): Promise<void> {
|
||||
const { vehicleId, source } = job.data;
|
||||
const { vehicleId, source, fast = false } = job.data;
|
||||
this.logger.log(`[prefetch] Init for vehicle=${vehicleId}, source=${source}`);
|
||||
|
||||
await checkCooldown(this.redis, source);
|
||||
@@ -244,7 +244,7 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
|
||||
|
||||
for (const child of children) {
|
||||
if (child.unavailable) continue;
|
||||
await this.queueCategoryJob(child, vehicleId, source, 1);
|
||||
await this.queueCategoryJob(child, vehicleId, source, 1, fast);
|
||||
queued++;
|
||||
}
|
||||
} else if (this.isLeafLinkPath(cat.linkPath, cat.source, cat.hasSubgroups)) {
|
||||
@@ -262,6 +262,7 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
|
||||
source,
|
||||
action: "parts" as const,
|
||||
depth: 0,
|
||||
fast,
|
||||
});
|
||||
queued++;
|
||||
}
|
||||
@@ -273,6 +274,7 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
|
||||
source,
|
||||
action: "children" as const,
|
||||
depth: 0,
|
||||
fast,
|
||||
});
|
||||
queued++;
|
||||
}
|
||||
@@ -286,7 +288,7 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
|
||||
* Fetch children (sub-categories) for a category.
|
||||
*/
|
||||
private async processChildren(job: Job<PrefetchCategoryJobData>): Promise<void> {
|
||||
const { vehicleId, categoryId, source, depth } = job.data;
|
||||
const { vehicleId, categoryId, source, depth, fast = false } = job.data;
|
||||
this.logger.log(`[prefetch] Children for category=${categoryId}, depth=${depth}`);
|
||||
|
||||
await checkCooldown(this.redis, source);
|
||||
@@ -303,7 +305,7 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
|
||||
let queued = 0;
|
||||
for (const child of children) {
|
||||
if (child.unavailable) continue;
|
||||
await this.queueCategoryJob(child, vehicleId, source, depth + 1);
|
||||
await this.queueCategoryJob(child, vehicleId, source, depth + 1, fast);
|
||||
queued++;
|
||||
}
|
||||
|
||||
@@ -378,12 +380,16 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
|
||||
? cfg.maxBacklog
|
||||
: BACKFILL_MAX_BACKLOG;
|
||||
|
||||
// Self-throttle: don't pile on if the queue is already deep — let it drain.
|
||||
// Self-throttle: when the queue is already deep, suspend only the Phase-2
|
||||
// rolling rescan (the part that piles on). Phase-1 still runs every wave so
|
||||
// genuinely-empty vehicles keep getting onboarded through the fast lane even
|
||||
// while a large deep-drill backlog is still draining — otherwise a single
|
||||
// backlog spike freezes new-vehicle coverage until the whole queue clears.
|
||||
const counts = await this.queue.getJobCounts("waiting", "delayed", "active");
|
||||
const backlog = (counts.waiting ?? 0) + (counts.delayed ?? 0) + (counts.active ?? 0);
|
||||
if (backlog > maxBacklog) {
|
||||
this.logger.log(`[backfill] Skip — queue backlog ${backlog} > ${maxBacklog}`);
|
||||
return;
|
||||
const phase2Allowed = backlog <= maxBacklog;
|
||||
if (!phase2Allowed) {
|
||||
this.logger.log(`[backfill] Backlog ${backlog} > ${maxBacklog} — Phase-1 (fast lane) only`);
|
||||
}
|
||||
|
||||
// Only target sources eligible right now: not in cooldown (user active) and
|
||||
@@ -399,11 +405,14 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
|
||||
return;
|
||||
}
|
||||
|
||||
const picked: Array<{ id: string; source: string }> = [];
|
||||
const picked: Array<{ id: string; source: string; fast: boolean }> = [];
|
||||
const seen = new Set<string>();
|
||||
const overfetch = batchSize * 4; // headroom for in-flight skips
|
||||
|
||||
const tryPick = async (v: { id: string; source: string | null }): Promise<void> => {
|
||||
const tryPick = async (
|
||||
v: { id: string; source: string | null },
|
||||
fast: boolean,
|
||||
): Promise<void> => {
|
||||
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
|
||||
@@ -411,7 +420,7 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
|
||||
const noResult = await this.redis.get(this.noResultKey(v.id));
|
||||
if (noResult && Number(noResult) >= NORESULT_MAX_ATTEMPTS) return;
|
||||
seen.add(v.id);
|
||||
picked.push({ id: v.id, source: v.source });
|
||||
picked.push({ id: v.id, source: v.source, fast });
|
||||
};
|
||||
|
||||
// Phase 1 — clear the obvious backlog first: decoded vehicles with zero parts.
|
||||
@@ -429,11 +438,12 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
|
||||
.orderBy(asc(vehicles.createdAt))
|
||||
.limit(overfetch);
|
||||
|
||||
for (const v of noParts) await tryPick(v);
|
||||
for (const v of noParts) await tryPick(v, true);
|
||||
|
||||
// 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 < batchSize) {
|
||||
// ones. A createdAt cursor walks forward and wraps around at the end. Gated by
|
||||
// the backlog ceiling (above) so it doesn't pile on while the queue is deep.
|
||||
if (phase2Allowed && 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);
|
||||
|
||||
@@ -456,7 +466,7 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
|
||||
let lastTs: Date | null = null;
|
||||
for (const v of rolling) {
|
||||
lastTs = v.createdAt;
|
||||
await tryPick(v);
|
||||
await tryPick(v, false);
|
||||
}
|
||||
if (lastTs) {
|
||||
await this.redis.setJson(BACKFILL_CURSOR_KEY, { ts: lastTs.toISOString() }, 30 * 86400);
|
||||
@@ -468,18 +478,26 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const v of picked) await this.enqueueInit(v.id, v.source);
|
||||
for (const v of picked) await this.enqueueInit(v.id, v.source, v.fast);
|
||||
const fastCount = picked.filter((v) => v.fast).length;
|
||||
this.logger.log(
|
||||
`[backfill] Queued ${picked.length} vehicle(s) (sources=${eligible.join(",")}, backlog=${backlog})`,
|
||||
`[backfill] Queued ${picked.length} vehicle(s) (${fastCount} fast-lane, ` +
|
||||
`sources=${eligible.join(",")}, backlog=${backlog})`,
|
||||
);
|
||||
}
|
||||
|
||||
/** Queue a prefetch-init for a vehicle and set the in-flight guard. */
|
||||
private async enqueueInit(vehicleId: string, source: string): Promise<void> {
|
||||
private async enqueueInit(vehicleId: string, source: string, fast = false): Promise<void> {
|
||||
await this.queue.add(
|
||||
"prefetch-init",
|
||||
{ vehicleId, source: source as PrefetchInitJobData["source"] },
|
||||
{ removeOnComplete: { count: 1000 }, removeOnFail: { count: 5000 } },
|
||||
{ vehicleId, source: source as PrefetchInitJobData["source"], fast },
|
||||
{
|
||||
removeOnComplete: { count: 1000 },
|
||||
removeOnFail: { count: 5000 },
|
||||
// Fast lane (Phase-1): lifo so a zero-parts vehicle's whole chain jumps
|
||||
// the deep-drill backlog instead of queueing behind it (see addJob).
|
||||
...(fast ? { lifo: true } : {}),
|
||||
},
|
||||
);
|
||||
// Guard cleared on completion (incrementCompleted) or by TTL if the run dies.
|
||||
await this.redis.set(`prefetch:scheduled:${vehicleId}`, "1", BACKFILL_SCHEDULED_TTL);
|
||||
@@ -498,6 +516,7 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
|
||||
vehicleId: string,
|
||||
source: string,
|
||||
depth: number,
|
||||
fast = false,
|
||||
): Promise<void> {
|
||||
if (cat.unavailable) return;
|
||||
|
||||
@@ -516,6 +535,7 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
|
||||
source: source as "pl24" | "emex",
|
||||
action: "parts" as const,
|
||||
depth,
|
||||
fast,
|
||||
});
|
||||
}
|
||||
} else if (cat.linkPath) {
|
||||
@@ -535,7 +555,7 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
|
||||
|
||||
for (const child of children) {
|
||||
if (child.unavailable) continue;
|
||||
await this.queueCategoryJob(child, vehicleId, source, depth + 1);
|
||||
await this.queueCategoryJob(child, vehicleId, source, depth + 1, fast);
|
||||
}
|
||||
} else {
|
||||
await this.addJob("prefetch-children", {
|
||||
@@ -544,6 +564,7 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
|
||||
source: source as "pl24" | "emex",
|
||||
action: "children" as const,
|
||||
depth,
|
||||
fast,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -583,6 +604,12 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
|
||||
// "-" instead. The values are UUIDs — the ID only needs to be deterministic
|
||||
// (for dedup), not parseable.
|
||||
jobId: `prefetch-${data.vehicleId}-${data.categoryId}-${data.action}`,
|
||||
// Fast lane (Phase-1 / reactive): add with `lifo` so the job RPUSHes to the
|
||||
// TAIL of the wait list, where BullMQ's RPOPLPUSH picks it next — i.e. ahead
|
||||
// of the deep deep-drill backlog already sitting in wait. (BullMQ 5 drains
|
||||
// the wait list before the prioritized ZSET, so `priority` would do the
|
||||
// OPPOSITE here and starve the job behind the backlog; lifo is correct.)
|
||||
...(data.fast ? { lifo: true } : {}),
|
||||
};
|
||||
|
||||
// parts-catalogs pacing is handled per-job in process() (PCAT_PACE_MS) + the
|
||||
|
||||
@@ -4,6 +4,13 @@ export type PrefetchSource = "pl24" | "emex" | "parts-catalogs";
|
||||
export interface PrefetchInitJobData {
|
||||
vehicleId: string;
|
||||
source: PrefetchSource;
|
||||
/**
|
||||
* Fast lane: enqueue this job (and its whole sub-job chain) with BullMQ `lifo`
|
||||
* so it lands at the tail of the wait list and is picked before the deep
|
||||
* deep-drill backlog. Set for Phase-1 (zero-parts) backfill so newly-decoded
|
||||
* vehicles aren't starved behind the rolling rescan. See PrefetchWorkerService.
|
||||
*/
|
||||
fast?: boolean;
|
||||
}
|
||||
|
||||
/** Per-category job: fetches children OR parts */
|
||||
@@ -13,4 +20,6 @@ export interface PrefetchCategoryJobData {
|
||||
source: PrefetchSource;
|
||||
action: "children" | "parts";
|
||||
depth: number;
|
||||
/** Inherited from the init job — keeps the whole chain in the fast lane. */
|
||||
fast?: boolean;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { Job } from "bullmq";
|
||||
import { lt } from "drizzle-orm";
|
||||
import { PostgresJsDatabase } from "drizzle-orm/postgres-js";
|
||||
import { queryLogs } from "../../database/schema/core";
|
||||
import { proxyLogs, queryLogs } from "../../database/schema/core";
|
||||
|
||||
type Database = PostgresJsDatabase<Record<string, unknown>>;
|
||||
|
||||
const RETENTION_DAYS = 90;
|
||||
// proxy_logs is high-volume per-attempt telemetry; 30 days is plenty for
|
||||
// grading providers and spotting ban waves.
|
||||
const PROXY_LOG_RETENTION_DAYS = 30;
|
||||
|
||||
export async function processQueryCleanup(
|
||||
job: Job,
|
||||
@@ -27,5 +30,15 @@ export async function processQueryCleanup(
|
||||
`[query-cleanup] Deleted ${deletedCount} query log(s) older than ${RETENTION_DAYS} days (before ${cutoffDate.toISOString()})`,
|
||||
);
|
||||
|
||||
const proxyCutoff = new Date();
|
||||
proxyCutoff.setDate(proxyCutoff.getDate() - PROXY_LOG_RETENTION_DAYS);
|
||||
const proxyDeleted = await db
|
||||
.delete(proxyLogs)
|
||||
.where(lt(proxyLogs.createdAt, proxyCutoff))
|
||||
.returning({ id: proxyLogs.id });
|
||||
console.log(
|
||||
`[query-cleanup] Deleted ${proxyDeleted.length} proxy log(s) older than ${PROXY_LOG_RETENTION_DAYS} days`,
|
||||
);
|
||||
|
||||
return { deletedCount };
|
||||
}
|
||||
|
||||
@@ -15,6 +15,13 @@ export class PartsController {
|
||||
return this.partsService.searchByOem(oem);
|
||||
}
|
||||
|
||||
// Reverse catalog for the OEM detail page: which of the user's vehicles use
|
||||
// this OEM code. Declared before ":id" so the literal path wins the match.
|
||||
@Get("oem-vehicles")
|
||||
async vehiclesByOem(@Query("code") code: string) {
|
||||
return this.partsService.vehiclesByOem(code ?? "");
|
||||
}
|
||||
|
||||
@Get(":id")
|
||||
async getById(@Param("id") id: string) {
|
||||
return this.partsService.getById(id);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Inject, Injectable, Logger, NotFoundException } from "@nestjs/common";
|
||||
import { eq, like } from "drizzle-orm";
|
||||
import { eq, like, sql } from "drizzle-orm";
|
||||
import { DATABASE, type Database } from "../database/database.provider";
|
||||
import { categories, parts, schemaPics, vehicles } from "../database/schema/core";
|
||||
import { PL24Service } from "../integrations/pl24/pl24.service";
|
||||
@@ -142,4 +142,32 @@ export class PartsService {
|
||||
if (!part) throw new NotFoundException("Parça bulunamadı");
|
||||
return part;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse catalog: the user's own decoded vehicles whose parts list contains
|
||||
* this exact OEM code. Powers the "bu kod kataloğunuzda şu araçlarda var"
|
||||
* section of the OEM detail page — pure sase data, no TecDoc/vehicle-structure
|
||||
* dependency. Exact match on the indexed `oem_code` (the code came from a real
|
||||
* part row, so the spelling matches). One representative `categoryId` per
|
||||
* vehicle lets the UI deep-link straight to a schema page showing the part.
|
||||
*/
|
||||
async vehiclesByOem(oemCode: string) {
|
||||
const code = (oemCode ?? "").trim();
|
||||
if (!code) return [];
|
||||
return this.db
|
||||
.select({
|
||||
vehicleId: vehicles.id,
|
||||
brandName: vehicles.brandName,
|
||||
model: vehicles.model,
|
||||
year: vehicles.year,
|
||||
categoryId: sql<string>`(array_agg(${parts.categoryId} ORDER BY ${parts.categoryId}))[1]`,
|
||||
occurrences: sql<number>`count(*)::int`,
|
||||
})
|
||||
.from(parts)
|
||||
.innerJoin(vehicles, eq(parts.vehicleId, vehicles.id))
|
||||
.where(eq(parts.oemCode, code))
|
||||
.groupBy(vehicles.id, vehicles.brandName, vehicles.model, vehicles.year)
|
||||
.orderBy(vehicles.brandName, vehicles.model)
|
||||
.limit(50);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,9 +16,9 @@ export class PaymentsService {
|
||||
/**
|
||||
* Payment history for the billing page. Returns a curated projection — never
|
||||
* the raw row — so internal columns (adminNote, iyzicoPaymentId,
|
||||
* bankAccountId, session/intent ids) are not leaked to the client. planName
|
||||
* is joined from the subscription's plan; Stripe receipt availability is
|
||||
* surfaced as a boolean rather than exposing the payment intent id.
|
||||
* session/intent ids) are not leaked to the client. planName is joined from
|
||||
* the subscription's plan; Stripe receipt availability is surfaced as a
|
||||
* boolean rather than exposing the payment intent id.
|
||||
*/
|
||||
async getMyPayments(userId: string) {
|
||||
const rows = await this.db
|
||||
@@ -29,7 +29,6 @@ export class PaymentsService {
|
||||
status: payments.status,
|
||||
createdAt: payments.createdAt,
|
||||
planName: plans.name,
|
||||
eftReceiptUrl: payments.eftReceiptUrl,
|
||||
stripePaymentIntentId: payments.stripePaymentIntentId,
|
||||
})
|
||||
.from(payments)
|
||||
@@ -45,16 +44,15 @@ export class PaymentsService {
|
||||
status: row.status,
|
||||
createdAt: row.createdAt,
|
||||
planName: row.planName ?? null,
|
||||
eftReceiptUrl: row.eftReceiptUrl ?? null,
|
||||
hasStripeReceipt:
|
||||
row.method === "stripe" && row.status === "completed" && !!row.stripePaymentIntentId,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a receipt URL for one of the current user's payments.
|
||||
* EFT receipts are stored locally; Stripe receipts are fetched live from the
|
||||
* payment intent's latest charge. Returns { url: null } when none exists.
|
||||
* Resolve a receipt URL for one of the current user's payments. Stripe is the
|
||||
* sole payment method; receipts are fetched live from the payment intent's
|
||||
* latest charge. Returns { url: null } when none exists.
|
||||
*/
|
||||
async getReceiptUrl(userId: string, paymentId: string): Promise<{ url: string | null }> {
|
||||
const [payment] = await this.db
|
||||
@@ -67,10 +65,6 @@ export class PaymentsService {
|
||||
throw new NotFoundException("Payment not found");
|
||||
}
|
||||
|
||||
if (payment.eftReceiptUrl) {
|
||||
return { url: payment.eftReceiptUrl };
|
||||
}
|
||||
|
||||
if (payment.method === "stripe" && payment.stripePaymentIntentId) {
|
||||
return { url: await this.stripeService.getReceiptUrl(payment.stripePaymentIntentId) };
|
||||
}
|
||||
|
||||
@@ -143,6 +143,10 @@ export class StripeService {
|
||||
|
||||
const session = await this.stripe.checkout.sessions.create({
|
||||
mode: "payment",
|
||||
// Render Stripe's hosted page in Turkish. The audience is Turkish B2B; a
|
||||
// foreign-language checkout is a known abandonment driver (~60% of sessions
|
||||
// reached the page but never started a payment intent).
|
||||
locale: "tr",
|
||||
payment_method_types: ["card"],
|
||||
customer_email: userEmail,
|
||||
line_items: [
|
||||
@@ -257,6 +261,14 @@ export class StripeService {
|
||||
const paymentIntentId =
|
||||
typeof session.payment_intent === "string" ? session.payment_intent : null;
|
||||
|
||||
// Activate FIRST, then mark the payment completed. If activation throws, the
|
||||
// payment stays 'pending', so a Stripe webhook retry re-runs this handler
|
||||
// cleanly instead of hitting the completed-guard above and skipping
|
||||
// activation forever — which would leave a paid-but-never-activated
|
||||
// subscription. activateSubscription is idempotent, so a retry after a
|
||||
// successful activate but failed mark-completed won't double-count revenue.
|
||||
const activated = await this.subscriptionsService.activateSubscription(payment.subscriptionId);
|
||||
|
||||
await this.db
|
||||
.update(payments)
|
||||
.set({
|
||||
@@ -266,8 +278,6 @@ export class StripeService {
|
||||
})
|
||||
.where(eq(payments.id, paymentId));
|
||||
|
||||
const activated = await this.subscriptionsService.activateSubscription(payment.subscriptionId);
|
||||
|
||||
this.posthog.captureForUser(payment.userId, "payment_success", {
|
||||
method: "stripe",
|
||||
payment_id: paymentId,
|
||||
|
||||
@@ -288,4 +288,43 @@ describe("SubscriptionsService", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("activateSubscription", () => {
|
||||
it("is idempotent: skips re-activation when already active (no revenue double-count)", async () => {
|
||||
// A Stripe webhook retry must not re-fire revenue events, re-consume
|
||||
// referral credit, or re-insert brands on an already-active subscription.
|
||||
const activeSub = {
|
||||
id: "sub-1",
|
||||
userId: "user-1",
|
||||
planId: "plan-1",
|
||||
status: "active",
|
||||
billingPeriod: "monthly",
|
||||
endDate: new Date(),
|
||||
};
|
||||
const db = {
|
||||
select: vi.fn().mockReturnValue({
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
limit: vi.fn().mockReturnValue([activeSub]),
|
||||
}),
|
||||
update: vi.fn(),
|
||||
insert: vi.fn(),
|
||||
};
|
||||
const posthog = {
|
||||
captureForUser: vi.fn(),
|
||||
capture: vi.fn(),
|
||||
flush: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const metaCapi = { sendPurchase: vi.fn().mockResolvedValue(undefined) };
|
||||
const service = new SubscriptionsService(db as any, posthog as any, metaCapi as any);
|
||||
|
||||
const result = await service.activateSubscription("sub-1");
|
||||
|
||||
expect(result).toBe(activeSub);
|
||||
expect(db.update).not.toHaveBeenCalled();
|
||||
expect(db.insert).not.toHaveBeenCalled();
|
||||
expect(posthog.captureForUser).not.toHaveBeenCalled();
|
||||
expect(metaCapi.sendPurchase).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
ConflictException,
|
||||
Inject,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { and, desc, eq, inArray, or } from "drizzle-orm";
|
||||
@@ -20,6 +21,8 @@ import { PostHogService } from "../posthog/posthog.service";
|
||||
|
||||
@Injectable()
|
||||
export class SubscriptionsService {
|
||||
private readonly logger = new Logger(SubscriptionsService.name);
|
||||
|
||||
constructor(
|
||||
@Inject(DATABASE) private db: Database,
|
||||
private posthog: PostHogService,
|
||||
@@ -123,6 +126,17 @@ export class SubscriptionsService {
|
||||
|
||||
if (!sub) throw new NotFoundException("Abonelik bulunamadı");
|
||||
|
||||
// Idempotency guard. A Stripe webhook retry (or any double-delivery) must
|
||||
// not re-activate an already-active subscription: that would reset the
|
||||
// period from now, re-consume referral credit, double-insert Full-plan
|
||||
// brands, and double-count revenue (PostHog subscription_activated + Meta
|
||||
// CAPI Purchase). Legitimate first activations always run on a 'pending'
|
||||
// sub; admin manual activation pre-rejects 'active' before reaching here.
|
||||
if (sub.status === "active") {
|
||||
this.logger.warn(`activateSubscription: ${subscriptionId} already active — skipping`);
|
||||
return sub;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const endDate = new Date(now);
|
||||
if (sub.billingPeriod === "yearly") {
|
||||
|
||||
@@ -37,10 +37,14 @@ export class VehiclesController {
|
||||
async decode(
|
||||
@CurrentUser("id") userId: string,
|
||||
@Body("vin", VinValidationPipe) vin: string,
|
||||
// Deprecated: provider-specific picks from pre-stash frontend bundles.
|
||||
// New clients send only the opaque `candidate` key; the provider mapping
|
||||
// lives in the server-side stash.
|
||||
@Body("pcatCarId") pcatCarId?: string,
|
||||
@Body("emexCarIndex") emexCarIndex?: number,
|
||||
@Body("candidate") candidate?: string,
|
||||
) {
|
||||
return this.vehiclesService.decodeVin(vin, userId, pcatCarId, emexCarIndex);
|
||||
return this.vehiclesService.decodeVin(vin, userId, pcatCarId, emexCarIndex, candidate);
|
||||
}
|
||||
|
||||
@Get("history")
|
||||
|
||||
@@ -166,7 +166,11 @@ describe("VehiclesService", () => {
|
||||
|
||||
const { service } = createService(db);
|
||||
const result = await service.decodeVin("WBAPH5C55BA123456", "u1");
|
||||
expect(result).toEqual(cached);
|
||||
// Decode response is intentionally slim — id + display summary only;
|
||||
// rawData/source never leave the API on this endpoint.
|
||||
expect(result).toMatchObject({ id: "v1" });
|
||||
expect(result).not.toHaveProperty("rawData");
|
||||
expect(result).not.toHaveProperty("source");
|
||||
});
|
||||
|
||||
it("should throw BadRequestException when corgi doesn't recognize VIN", async () => {
|
||||
@@ -435,7 +439,8 @@ describe("VehiclesService", () => {
|
||||
});
|
||||
|
||||
const result = await service.decodeVin("WBAPH5C55BA123456", "u1");
|
||||
expect(result).toEqual(savedVehicle);
|
||||
expect(result).toMatchObject({ id: "v-emex" });
|
||||
expect(result).not.toHaveProperty("source");
|
||||
expect(partsCatalogsService.decodeVin).toHaveBeenCalled();
|
||||
expect(emexService.decodeVinOrCandidates).toHaveBeenCalledWith("WBAPH5C55BA123456");
|
||||
});
|
||||
@@ -473,7 +478,7 @@ describe("VehiclesService", () => {
|
||||
emexService.decodeVinOrCandidates.mockResolvedValue({ type: "notFound" });
|
||||
|
||||
const result = await service.decodeVin("WVWZZZ1JZ3W597935", "u1");
|
||||
expect(result).toEqual(savedVehicle);
|
||||
expect(result).toMatchObject({ id: "v-pcat" });
|
||||
expect(pl24Service.decodeVin).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -488,7 +493,7 @@ describe("VehiclesService", () => {
|
||||
});
|
||||
|
||||
const result = await service.decodeVin("WBAPH5C55BA123456", "u1");
|
||||
expect(result).toEqual(savedVehicle);
|
||||
expect(result).toMatchObject({ id: "v-emex" });
|
||||
expect(emexService.decodeVinOrCandidates).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -511,7 +516,7 @@ describe("VehiclesService", () => {
|
||||
});
|
||||
|
||||
const result = await service.decodeVin("VR3EFYHZ3PJ674071", "u1");
|
||||
expect(result).toEqual(savedVehicle);
|
||||
expect(result).toMatchObject({ id: "v-pl24" });
|
||||
expect(pl24Service.decodeVin).toHaveBeenCalledWith("VR3EFYHZ3PJ674071", "u1");
|
||||
});
|
||||
|
||||
@@ -620,8 +625,9 @@ describe("VehiclesService", () => {
|
||||
|
||||
const res = await service.previewVin("NM435600006H43436");
|
||||
|
||||
expect(res.source).toBe("parts-catalogs");
|
||||
expect(res.brandName).toBe("Fiat"); // was null before the fix
|
||||
// Provider/source adı public preview cevabına asla sızmamalı.
|
||||
expect("source" in res).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -29,13 +29,13 @@ import {
|
||||
import { EmexService } from "../integrations/emex/emex.service";
|
||||
import { EmexCandidate } from "../integrations/emex/emex.service";
|
||||
import { PartsCatalogsService } from "../integrations/parts-catalogs/parts-catalogs.service";
|
||||
import { PcatCar } from "../integrations/parts-catalogs/parts-catalogs.types";
|
||||
import { PcatCar, PcatVinResult } from "../integrations/parts-catalogs/parts-catalogs.types";
|
||||
import { PL24Service } from "../integrations/pl24/pl24.service";
|
||||
import { VinApiService } from "../integrations/vin-api/vin-api.service";
|
||||
import { PrefetchSource } from "../jobs/prefetch.types";
|
||||
import { CATALOG_PREFETCH_QUEUE } from "../jobs/queues/catalog-prefetch.queue";
|
||||
import { RedisService } from "../redis/redis.service";
|
||||
import { vinResolveCacheKeys } from "./vin-cache-keys";
|
||||
import { vinCandidateStashKey, vinResolveCacheKeys } from "./vin-cache-keys";
|
||||
|
||||
/**
|
||||
* Per-decode metadata, persisted to `query_logs.timings` jsonb. Despite the
|
||||
@@ -66,6 +66,26 @@ interface VinResolveResult {
|
||||
emexCandidates?: EmexCandidate[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-side record of a pending multi-candidate selection. The client only
|
||||
* ever sees the opaque per-candidate key (the index, as a string); which decode
|
||||
* source produced the list and how to re-address the chosen car never leave
|
||||
* the API.
|
||||
*/
|
||||
interface CandidateStash {
|
||||
source: "parts-catalogs" | "emex";
|
||||
/** Indexed by candidate key — provider-specific selector for each entry. */
|
||||
picks: Array<{ pcatCarId?: string; emexCarIndex?: number }>;
|
||||
}
|
||||
|
||||
/** Display-only candidate shape returned to the client. */
|
||||
export interface PublicCandidate {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
parameters?: PcatCar["parameters"];
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class VehiclesService {
|
||||
private readonly logger = new Logger(VehiclesService.name);
|
||||
@@ -81,8 +101,31 @@ export class VehiclesService {
|
||||
private redis: RedisService,
|
||||
) {}
|
||||
|
||||
async decodeVin(vin: string, userId: string, pcatCarId?: string, emexCarIndex?: number) {
|
||||
async decodeVin(
|
||||
vin: string,
|
||||
userId: string,
|
||||
legacyPcatCarId?: string,
|
||||
legacyEmexCarIndex?: number,
|
||||
candidateKey?: string,
|
||||
) {
|
||||
const startTime = Date.now();
|
||||
|
||||
if (!isValidVin(vin)) {
|
||||
throw new BadRequestException("Geçersiz şase numarası");
|
||||
}
|
||||
|
||||
// Opaque candidate pick → provider-specific selection, restored from the
|
||||
// server-side stash so the client never round-trips decode-source details
|
||||
// (source name, pcat car ids, EMEX indexes) between requests. The legacy
|
||||
// pcatCarId/emexCarIndex body params still work for old bundles.
|
||||
let pcatCarId = legacyPcatCarId;
|
||||
let emexCarIndex = legacyEmexCarIndex;
|
||||
if (candidateKey !== undefined && pcatCarId === undefined && emexCarIndex === undefined) {
|
||||
const pick = await this.resolveCandidateKey(vin, candidateKey);
|
||||
pcatCarId = pick.pcatCarId;
|
||||
emexCarIndex = pick.emexCarIndex;
|
||||
}
|
||||
|
||||
const ctx: ResolveContext = {
|
||||
timings: {
|
||||
wmi: vin.length >= 3 ? vin.substring(0, 3).toUpperCase() : "",
|
||||
@@ -90,10 +133,6 @@ export class VehiclesService {
|
||||
},
|
||||
};
|
||||
|
||||
if (!isValidVin(vin)) {
|
||||
throw new BadRequestException("Geçersiz şase numarası");
|
||||
}
|
||||
|
||||
// 1. Check for shared vehicle config by VIN (no userId filter)
|
||||
const [existing] = await this.db.select().from(vehicles).where(eq(vehicles.vin, vin)).limit(1);
|
||||
|
||||
@@ -115,7 +154,7 @@ export class VehiclesService {
|
||||
undefined,
|
||||
ctx.timings,
|
||||
);
|
||||
return existing;
|
||||
return this.toDecodeResponse(existing);
|
||||
}
|
||||
|
||||
// 2. Resolve VIN via cached decode chain (Corgi → PartsCatalogs → PL24 → EMEX)
|
||||
@@ -163,46 +202,52 @@ export class VehiclesService {
|
||||
throw new BadRequestException("Şase numarası tanınamadı. Marka desteklenmiyor.");
|
||||
}
|
||||
|
||||
// 2b. If resolveVin returned multiple candidates, return them for frontend selection
|
||||
if (resolved.pcatCandidates && resolved.pcatCandidates.length > 1) {
|
||||
// 2b. If resolveVin returned multiple candidates, return them for frontend
|
||||
// selection. Provider details (source name, car ids, EMEX internals) are
|
||||
// stashed server-side; the client only echoes back the opaque candidate key.
|
||||
const hasPcatCandidates = !!resolved.pcatCandidates && resolved.pcatCandidates.length > 1;
|
||||
const hasEmexCandidates = !!resolved.emexCandidates && resolved.emexCandidates.length > 1;
|
||||
if (hasPcatCandidates || hasEmexCandidates) {
|
||||
const stash = this.buildCandidateStash(resolved);
|
||||
await this.redis.setJson(
|
||||
vinCandidateStashKey(vin),
|
||||
stash,
|
||||
VehiclesService.CANDIDATE_STASH_TTL_S,
|
||||
);
|
||||
await this.logQuery(
|
||||
userId,
|
||||
vin,
|
||||
null,
|
||||
"parts-catalogs",
|
||||
stash.source,
|
||||
true,
|
||||
Date.now() - startTime,
|
||||
undefined,
|
||||
ctx.timings,
|
||||
);
|
||||
return { candidates: resolved.pcatCandidates, vin, source: "parts-catalogs" };
|
||||
}
|
||||
if (resolved.emexCandidates && resolved.emexCandidates.length > 1) {
|
||||
await this.logQuery(
|
||||
userId,
|
||||
vin,
|
||||
null,
|
||||
"emex",
|
||||
true,
|
||||
Date.now() - startTime,
|
||||
undefined,
|
||||
ctx.timings,
|
||||
);
|
||||
return { candidates: resolved.emexCandidates, vin, source: "emex" };
|
||||
const raw = hasPcatCandidates
|
||||
? (resolved.pcatCandidates as PcatCar[])
|
||||
: (resolved.emexCandidates as EmexCandidate[]);
|
||||
return { candidates: this.toPublicCandidates(raw), vin };
|
||||
}
|
||||
|
||||
// 3. Brand access check
|
||||
let brandId: string | null = null;
|
||||
const brandName = resolved.brandName;
|
||||
// Match the brands table case-insensitively and adopt the CANONICAL name:
|
||||
// decode sources emit the brand with inconsistent casing ("FORD" vs "Ford"),
|
||||
// and a case-sensitive lookup left brand_id NULL + stored the raw uppercase
|
||||
// string, splitting one brand across casing variants. See migration
|
||||
// 0013_fix_brand_casing for the backfill of pre-existing rows.
|
||||
let brandName = resolved.brandName;
|
||||
if (brandName) {
|
||||
const [brand] = await this.db
|
||||
.select()
|
||||
.from(brands)
|
||||
.where(eq(brands.name, brandName))
|
||||
.where(sql`lower(${brands.name}) = lower(${brandName})`)
|
||||
.limit(1);
|
||||
|
||||
if (brand) {
|
||||
brandId = brand.id;
|
||||
brandName = brand.name;
|
||||
await this.checkBrandAccess(userId, brandId);
|
||||
}
|
||||
}
|
||||
@@ -263,7 +308,22 @@ export class VehiclesService {
|
||||
ctx.timings,
|
||||
);
|
||||
|
||||
return savedVehicle;
|
||||
return this.toDecodeResponse(savedVehicle);
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal decode response. Every consumer navigates by id and re-fetches the
|
||||
* vehicle via GET /vehicles/:id; returning the full row shipped the raw
|
||||
* upstream decode payload (rawData, p95 ~127KB) and the provider name to the
|
||||
* client on every decode for nothing.
|
||||
*/
|
||||
private toDecodeResponse(v: {
|
||||
id: string;
|
||||
brandName: string | null;
|
||||
model: string | null;
|
||||
year: number | null;
|
||||
}) {
|
||||
return { id: v.id, brandName: v.brandName, model: v.model, year: v.year };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -340,14 +400,14 @@ export class VehiclesService {
|
||||
throw new BadRequestException("Geçersiz şase numarası");
|
||||
}
|
||||
|
||||
// DB'de varsa direkt dön — dış API çağrısına gerek yok
|
||||
// DB'de varsa direkt dön — dış API çağrısına gerek yok.
|
||||
// Source/provider adı public preview'a asla dahil edilmez.
|
||||
const [existing] = await this.db
|
||||
.select({
|
||||
brandName: vehicles.brandName,
|
||||
model: vehicles.model,
|
||||
year: vehicles.year,
|
||||
engine: vehicles.engine,
|
||||
source: vehicles.source,
|
||||
})
|
||||
.from(vehicles)
|
||||
.where(eq(vehicles.vin, vin))
|
||||
@@ -372,7 +432,6 @@ export class VehiclesService {
|
||||
model: resolved.model,
|
||||
year: resolved.year,
|
||||
engine: resolved.engine,
|
||||
source: resolved.source,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -392,6 +451,10 @@ export class VehiclesService {
|
||||
// PL24 decode (responseTimeMs=1006123) that ran long after the request was
|
||||
// already aborted by TimeoutInterceptor. Anything past this is wasted work.
|
||||
private static readonly RESOLVE_BUDGET_MS = 25_000;
|
||||
// How long a multi-candidate selection stays valid. Picks normally happen
|
||||
// within seconds; if the stash expires we fall back to the resolve cache
|
||||
// (24h TTL) before giving up.
|
||||
private static readonly CANDIDATE_STASH_TTL_S = 1_800; // 30m
|
||||
|
||||
// ─── PL24 circuit breaker ──────────────────────────────
|
||||
// PL24 had p95=12min in production. When it goes bad it stays bad — every
|
||||
@@ -431,6 +494,47 @@ export class VehiclesService {
|
||||
await this.redis.del(VehiclesService.PL24_CB_FAILURE_KEY);
|
||||
}
|
||||
|
||||
// ─── PCAT circuit breaker ──────────────────────────────
|
||||
// Mirrors the PL24 breaker. parts-catalogs is the #1 winning decode source, so
|
||||
// when its transport degrades (proxy/upstream bad window) it drags the whole
|
||||
// race. After N consecutive TRANSIENT pcat failures we open the circuit briefly
|
||||
// and skip pcat so EMEX/PL24 answer fast. CRITICAL: only transient faults count
|
||||
// — a definitive "no cars" miss must NOT trip it (that's a real, fast answer),
|
||||
// and a skip must mark outcome.transient so a pcat-only VIN isn't negative-cached
|
||||
// for 30m while the circuit self-heals in 30s.
|
||||
private static readonly PCAT_CB_FAILURE_THRESHOLD = 3;
|
||||
private static readonly PCAT_CB_COOLDOWN_MS = 30_000;
|
||||
private static readonly PCAT_CB_COUNTER_TTL_S = 300;
|
||||
private static readonly PCAT_CB_FAILURE_KEY = "pcat:cb:consec_failures";
|
||||
private static readonly PCAT_CB_COOLDOWN_KEY = "pcat:cb:cooldown_until";
|
||||
|
||||
private async isPcatCircuitOpen(): Promise<boolean> {
|
||||
const until = await this.redis.get(VehiclesService.PCAT_CB_COOLDOWN_KEY);
|
||||
return until !== null && Number(until) > Date.now();
|
||||
}
|
||||
|
||||
private async recordPcatFailure(): Promise<void> {
|
||||
const c = await this.redis.incr(VehiclesService.PCAT_CB_FAILURE_KEY);
|
||||
await this.redis.expire(
|
||||
VehiclesService.PCAT_CB_FAILURE_KEY,
|
||||
VehiclesService.PCAT_CB_COUNTER_TTL_S,
|
||||
);
|
||||
if (c >= VehiclesService.PCAT_CB_FAILURE_THRESHOLD) {
|
||||
await this.redis.set(
|
||||
VehiclesService.PCAT_CB_COOLDOWN_KEY,
|
||||
String(Date.now() + VehiclesService.PCAT_CB_COOLDOWN_MS),
|
||||
Math.ceil(VehiclesService.PCAT_CB_COOLDOWN_MS / 1000) + 5,
|
||||
);
|
||||
this.logger.warn(
|
||||
`PCAT circuit opened for ${VehiclesService.PCAT_CB_COOLDOWN_MS}ms (${c} consecutive failures)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async recordPcatSuccess(): Promise<void> {
|
||||
await this.redis.del(VehiclesService.PCAT_CB_FAILURE_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared VIN decode chain — Corgi → PartsCatalogs → PL24 → EMEX.
|
||||
*
|
||||
@@ -637,16 +741,59 @@ export class VehiclesService {
|
||||
|
||||
const pcatStart = Date.now();
|
||||
let pcatResolved = false;
|
||||
const pcatPromise = this.partsCatalogsService
|
||||
.decodeVin(vin, signal, outcome)
|
||||
// Circuit breaker: when pcat is in a bad transport window, skip it so the
|
||||
// race isn't dragged; EMEX/PL24 answer instead. A pcat-scoped outcome lets
|
||||
// the breaker count ONLY pcat's transient faults (not another source's).
|
||||
const pcatCircuitOpen = await this.isPcatCircuitOpen();
|
||||
if (ctx && pcatCircuitOpen) ctx.timings.pcat_circuit_open = true;
|
||||
const pcatOutcome: { transient: boolean; coldSkip?: boolean } = { transient: false };
|
||||
// Fast-fail on a cold JWT pool: EMEX (already racing) or PL24 can answer
|
||||
// while the pool warms in the background, instead of the user eating the
|
||||
// multi-second inline capture. The pcat result self-heals on the next decode.
|
||||
const basePcat: Promise<PcatVinResult | null> = pcatCircuitOpen
|
||||
? (() => {
|
||||
// Skipped (open) — NOT a definitive miss. Mark transient so a
|
||||
// pcat-only VIN isn't negative-cached while the circuit self-heals.
|
||||
this.logger.warn(`PCAT skipped for ${vin}: circuit breaker is open`);
|
||||
pcatOutcome.transient = true;
|
||||
return Promise.resolve(null);
|
||||
})()
|
||||
: this.partsCatalogsService.decodeVin(vin, signal, pcatOutcome, { blockOnEmpty: false });
|
||||
const pcatPromise = basePcat
|
||||
.then(async (r) => {
|
||||
// Breaker bookkeeping (skip when already open — nothing was attempted):
|
||||
// a transient TRANSPORT fault counts toward opening; any definitive
|
||||
// answer (cars OR a clean "no cars" miss) clears the streak. A deliberate
|
||||
// cold-pool skip is neutral — it isn't a fault and the pool is warming.
|
||||
if (!pcatCircuitOpen && !pcatOutcome.coldSkip) {
|
||||
if (pcatOutcome.transient) await this.recordPcatFailure();
|
||||
else await this.recordPcatSuccess();
|
||||
}
|
||||
return r;
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
this.logger.warn(`PartsCatalogs decode failed for ${vin}: ${err.message}`);
|
||||
return null;
|
||||
})
|
||||
.finally(() => {
|
||||
// Propagate pcat's transient signal to the shared outcome (neg-cache gate).
|
||||
if (pcatOutcome.transient && outcome) outcome.transient = true;
|
||||
pcatResolved = true;
|
||||
if (ctx && ctx.timings.pcat === undefined) {
|
||||
ctx.timings.pcat = Date.now() - pcatStart;
|
||||
if (ctx) {
|
||||
if (ctx.timings.pcat === undefined) {
|
||||
ctx.timings.pcat = Date.now() - pcatStart;
|
||||
}
|
||||
// pcat never definitively evaluated this VIN. Without these markers a
|
||||
// "No catalog — identified as X" produced while pcat was skipped/flaky
|
||||
// is indistinguishable from a true coverage gap, and the panel's
|
||||
// catalog-gap detector raises false brand-gap insights (seen
|
||||
// 2026-06-11: Audi flagged from cold-pool 1-2ms skips).
|
||||
if (pcatOutcome.coldSkip) {
|
||||
ctx.timings.pcat_skipped = 1;
|
||||
} else if (pcatOutcome.transient && !pcatCircuitOpen) {
|
||||
// Circuit-open skips already carry pcat_circuit_open.
|
||||
ctx.timings.pcat_transient = 1;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -863,6 +1010,60 @@ export class VehiclesService {
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── Candidate stash (opaque pick keys) ────────────────
|
||||
|
||||
/** Build the server-side selector list for a multi-candidate resolve. */
|
||||
private buildCandidateStash(resolved: VinResolveResult): CandidateStash {
|
||||
if (resolved.pcatCandidates && resolved.pcatCandidates.length > 0) {
|
||||
return {
|
||||
source: "parts-catalogs",
|
||||
picks: resolved.pcatCandidates.map((c) => ({ pcatCarId: c.id })),
|
||||
};
|
||||
}
|
||||
return {
|
||||
source: "emex",
|
||||
picks: (resolved.emexCandidates ?? []).map((c) => ({
|
||||
emexCarIndex: Number.isInteger(c._index) ? c._index : Number.parseInt(c.id, 10),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/** Strip provider internals before the candidate list leaves the API. */
|
||||
private toPublicCandidates(candidates: Array<PcatCar | EmexCandidate>): PublicCandidate[] {
|
||||
return candidates.map((c, i) => ({
|
||||
id: String(i),
|
||||
name: c.name,
|
||||
description: "description" in c ? c.description : undefined,
|
||||
parameters: c.parameters,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Map an opaque candidate key back to the provider-specific selection.
|
||||
* Falls back to re-resolving the VIN (normally a Redis resolve-cache hit)
|
||||
* when the stash has expired.
|
||||
*/
|
||||
private async resolveCandidateKey(
|
||||
vin: string,
|
||||
candidateKey: string,
|
||||
): Promise<{ pcatCarId?: string; emexCarIndex?: number }> {
|
||||
let stash = await this.redis.getJson<CandidateStash>(vinCandidateStashKey(vin));
|
||||
if (!stash) {
|
||||
const resolved = await this.resolveVin(vin);
|
||||
if (resolved?.pcatCandidates?.length || resolved?.emexCandidates?.length) {
|
||||
stash = this.buildCandidateStash(resolved);
|
||||
}
|
||||
}
|
||||
const idx = Number(candidateKey);
|
||||
const pick = stash && Number.isInteger(idx) ? stash.picks[idx] : undefined;
|
||||
if (!pick) {
|
||||
throw new BadRequestException(
|
||||
"Araç seçimi zaman aşımına uğradı. Lütfen şase numarasını tekrar sorgulayın.",
|
||||
);
|
||||
}
|
||||
return pick;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a specific PartsCatalogs car by ID (after user selects from candidates).
|
||||
*/
|
||||
|
||||
@@ -16,7 +16,8 @@ describe("vinResolveCacheKeys (Q4)", () => {
|
||||
it("keeps the in-flight lock key version-independent (cross-deploy dedupe)", () => {
|
||||
const { lockKey } = vinResolveCacheKeys(VIN);
|
||||
expect(lockKey).toBe(`vin:lock:${VIN}`);
|
||||
expect(lockKey).not.toContain(DECODE_CHAIN_VERSION);
|
||||
// The VIN itself may contain the version digit — check the segment, not the char.
|
||||
expect(lockKey).not.toContain(`:${DECODE_CHAIN_VERSION}:`);
|
||||
});
|
||||
|
||||
it("bumping the version changes the namespace (old entries become unreachable)", () => {
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
* Both the decode path (VehiclesService) and the admin cache-buster
|
||||
* (InternalVehiclesService) MUST build keys through here so they stay in sync.
|
||||
*/
|
||||
export const DECODE_CHAIN_VERSION = "2";
|
||||
export const DECODE_CHAIN_VERSION = "3";
|
||||
|
||||
export function vinResolveCacheKeys(vin: string): {
|
||||
cacheKey: string;
|
||||
@@ -29,3 +29,14 @@ export function vinResolveCacheKeys(vin: string): {
|
||||
lockKey: `vin:lock:${vin}`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-side stash for a multi-candidate decode: maps the opaque candidate
|
||||
* keys returned to the client back to the provider-specific selection
|
||||
* (parts-catalogs car id / EMEX list index). Exists so the client never has to
|
||||
* carry decode-source details between the decode and the candidate-pick
|
||||
* requests.
|
||||
*/
|
||||
export function vinCandidateStashKey(vin: string): string {
|
||||
return `vin:candidates:${DECODE_CHAIN_VERSION}:${vin}`;
|
||||
}
|
||||
|
||||
157
apps/web/src/components/blog-content.tsx
Normal file
157
apps/web/src/components/blog-content.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import { useBlogPost, useBlogPosts } from "@/hooks/use-blog";
|
||||
import { usePageMeta } from "@/hooks/use-page-meta";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@sase/ui";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
|
||||
// Blog content is rendered in two shells: the public marketing pages (/blog)
|
||||
// and the dashboard-wrapped pages (/dashboard/blog) so logged-in users keep
|
||||
// the sidebar. Only the internal link targets differ; the canonical URL always
|
||||
// points at the public page.
|
||||
|
||||
type BlogListPath = "/blog" | "/dashboard/blog";
|
||||
type BlogPostPath = "/blog/$slug" | "/dashboard/blog/$slug";
|
||||
|
||||
// ─── LIST ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function BlogListContent({ postLinkTo }: { postLinkTo: BlogPostPath }) {
|
||||
usePageMeta({
|
||||
title: "Blog — Sase.tr | Şase & Yedek Parça Rehberi",
|
||||
description: "Şase numarası okuma, OEM vs muadil parça, dijital dönüşüm ve daha fazlası.",
|
||||
canonical: "https://sase.tr/blog",
|
||||
});
|
||||
|
||||
// All posts come from the central Directus CMS through the API.
|
||||
const { data: apiPosts, isLoading } = useBlogPosts();
|
||||
|
||||
const posts = (apiPosts ?? [])
|
||||
.map((p) => ({
|
||||
slug: p.slug,
|
||||
title: p.title,
|
||||
description: p.metaDescription ?? "",
|
||||
date: p.publishedAt.slice(0, 10),
|
||||
}))
|
||||
.sort((a, b) => b.date.localeCompare(a.date));
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="text-4xl font-bold">Blog</h1>
|
||||
<p className="mt-4 text-lg text-muted-foreground">
|
||||
Yedek parça sektörü, araç bakımı ve Sase.tr hakkında güncel yazılar.
|
||||
</p>
|
||||
|
||||
{isLoading && posts.length === 0 && (
|
||||
<p className="mt-12 text-muted-foreground">Yükleniyor…</p>
|
||||
)}
|
||||
|
||||
<div className="mt-12 grid gap-6 sm:grid-cols-2">
|
||||
{posts.map((post) => (
|
||||
<Link key={post.slug} to={postLinkTo} params={{ slug: post.slug }}>
|
||||
<Card className="h-full transition hover:border-foreground/20">
|
||||
<CardHeader>
|
||||
<CardDescription>{post.date}</CardDescription>
|
||||
<CardTitle className="text-lg">{post.title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">{post.description}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── POST ─────────────────────────────────────────────────────────────────────
|
||||
// Posts live in the central Directus CMS and arrive as markdown via the API.
|
||||
|
||||
const mdComponents = {
|
||||
h2: (props: React.ComponentProps<"h2">) => (
|
||||
<h2 className="text-xl font-semibold text-foreground" {...props} />
|
||||
),
|
||||
h3: (props: React.ComponentProps<"h3">) => (
|
||||
<h3 className="text-lg font-semibold text-foreground" {...props} />
|
||||
),
|
||||
ul: (props: React.ComponentProps<"ul">) => <ul className="list-disc space-y-2 pl-6" {...props} />,
|
||||
ol: (props: React.ComponentProps<"ol">) => (
|
||||
<ol className="list-decimal space-y-2 pl-6" {...props} />
|
||||
),
|
||||
strong: (props: React.ComponentProps<"strong">) => (
|
||||
<strong className="text-foreground" {...props} />
|
||||
),
|
||||
a: (props: React.ComponentProps<"a">) => <a className="text-foreground underline" {...props} />,
|
||||
};
|
||||
|
||||
function MarkdownBody({ markdown }: { markdown: string }) {
|
||||
return (
|
||||
<div className="space-y-6 leading-relaxed text-muted-foreground">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={mdComponents}>
|
||||
{markdown}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function BlogPostContent({ slug, listLinkTo }: { slug: string; listLinkTo: BlogListPath }) {
|
||||
const { data: post, isLoading, isError } = useBlogPost(slug);
|
||||
|
||||
const title = post?.title ?? "Blog";
|
||||
const description = post?.metaDescription ?? "";
|
||||
const date = post?.publishedAt?.slice(0, 10) ?? "";
|
||||
|
||||
usePageMeta({
|
||||
title: `${title} | Sase.tr Blog`,
|
||||
description,
|
||||
canonical: `https://sase.tr/blog/${slug}`,
|
||||
});
|
||||
|
||||
let body: React.ReactNode;
|
||||
if (post) body = <MarkdownBody markdown={post.bodyMarkdown} />;
|
||||
else if (isLoading) body = <p className="text-muted-foreground">Yükleniyor…</p>;
|
||||
else
|
||||
body = (
|
||||
<p className="text-muted-foreground">{isError ? "Yazı bulunamadı." : "Yazı yüklenemedi."}</p>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Breadcrumb */}
|
||||
<nav className="mb-8 flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
<Link to={listLinkTo} className="transition hover:text-foreground">
|
||||
Blog
|
||||
</Link>
|
||||
<ChevronRight className="size-3.5" />
|
||||
<span className="text-foreground">{title}</span>
|
||||
</nav>
|
||||
|
||||
<article>
|
||||
{date && <time className="text-sm text-muted-foreground">{date}</time>}
|
||||
<h1 className="mt-3 text-3xl font-bold leading-tight sm:text-4xl">{title}</h1>
|
||||
{description && <p className="mt-4 text-lg text-muted-foreground">{description}</p>}
|
||||
|
||||
{post?.coverImage && (
|
||||
<img
|
||||
src={post.coverImage}
|
||||
alt={title}
|
||||
className="mt-8 w-full rounded-lg border object-cover"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="mt-10">{body}</div>
|
||||
</article>
|
||||
|
||||
{/* Back link */}
|
||||
<div className="mt-16 border-t pt-8">
|
||||
<Link
|
||||
to={listLinkTo}
|
||||
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground transition hover:text-foreground"
|
||||
>
|
||||
← Tüm yazılar
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
79
apps/web/src/components/catalog/report-catalog-issue.tsx
Normal file
79
apps/web/src/components/catalog/report-catalog-issue.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import { capture } from "@/lib/posthog";
|
||||
import {
|
||||
type CatalogIssueContext,
|
||||
type CatalogIssueKind,
|
||||
reportCatalogDegradation,
|
||||
} from "@/lib/sentry";
|
||||
import { Button } from "@sase/ui";
|
||||
import { Check, Flag } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
/**
|
||||
* Fire-and-forget: when `active` becomes true, report the catalog degradation the
|
||||
* user is looking at to Sentry (with a flushed session replay so it's watchable).
|
||||
* Deduped per session in the lib, so re-renders are safe.
|
||||
*/
|
||||
export function useReportCatalogDegradation(
|
||||
kind: CatalogIssueKind,
|
||||
active: boolean,
|
||||
ctx: CatalogIssueContext,
|
||||
): void {
|
||||
const { vehicleId, categoryId, vehicleLabel, categoryName, source, vin } = ctx;
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
void reportCatalogDegradation(kind, {
|
||||
vehicleId,
|
||||
categoryId,
|
||||
vehicleLabel,
|
||||
categoryName,
|
||||
source,
|
||||
vin,
|
||||
});
|
||||
}, [active, kind, vehicleId, categoryId, vehicleLabel, categoryName, source, vin]);
|
||||
}
|
||||
|
||||
/**
|
||||
* "Çalışmadı mı? Bildir" — one-click report, no form. Captures a
|
||||
* `catalog_issue_reported` PostHog event carrying the vehicle/category context
|
||||
* (the identified person already carries the user's email), which the Süper
|
||||
* Panel surfaces under /analytics/reported-categories. The Sentry degradation
|
||||
* report with session replay still fires automatically via the hook above —
|
||||
* this button is the explicit "a human cared enough to press it" signal.
|
||||
*/
|
||||
export function ReportCatalogIssueButton({
|
||||
ctx,
|
||||
className,
|
||||
}: {
|
||||
ctx: CatalogIssueContext;
|
||||
className?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [reported, setReported] = useState(false);
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={className}
|
||||
disabled={reported}
|
||||
onClick={() => {
|
||||
if (reported) return;
|
||||
capture("catalog_issue_reported", {
|
||||
vehicle_id: ctx.vehicleId,
|
||||
vehicle_label: ctx.vehicleLabel,
|
||||
category_id: ctx.categoryId,
|
||||
category_name: ctx.categoryName,
|
||||
source: ctx.source,
|
||||
vin: ctx.vin,
|
||||
page: window.location.pathname + window.location.search,
|
||||
});
|
||||
setReported(true);
|
||||
}}
|
||||
data-faro-user-action-name="report-catalog-issue"
|
||||
>
|
||||
{reported ? <Check className="mr-1.5 h-4 w-4" /> : <Flag className="mr-1.5 h-4 w-4" />}
|
||||
{reported ? t("vehicle.reportIssueDone") : t("vehicle.reportIssue")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
225
apps/web/src/components/contact-content.tsx
Normal file
225
apps/web/src/components/contact-content.tsx
Normal file
@@ -0,0 +1,225 @@
|
||||
import { Turnstile, type TurnstileHandle } from "@/components/turnstile";
|
||||
import { usePageMeta } from "@/hooks/use-page-meta";
|
||||
import { ApiError, api } from "@/lib/api-client";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { Button, Card, CardContent, CardHeader, CardTitle, Input, Label } from "@sase/ui";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { type FormEvent, useRef, useState } from "react";
|
||||
import { z } from "zod";
|
||||
|
||||
// Contact content is rendered in two shells: the public marketing page
|
||||
// (/contact) and the dashboard-wrapped page (/dashboard/contact) so logged-in
|
||||
// users keep the sidebar.
|
||||
|
||||
// Backend (POST /contact) ile aynı kurallar
|
||||
const contactSchema = z.object({
|
||||
name: z.string().trim().min(2, "Ad en az 2 karakter olmalı").max(100, "Ad çok uzun"),
|
||||
email: z.string().trim().email("Geçerli bir e-posta adresi girin").max(200),
|
||||
subject: z.string().trim().max(200, "Konu çok uzun").optional(),
|
||||
message: z.string().trim().min(10, "Mesaj en az 10 karakter olmalı").max(5000, "Mesaj çok uzun"),
|
||||
});
|
||||
|
||||
type FieldErrors = Partial<Record<"name" | "email" | "subject" | "message", string>>;
|
||||
|
||||
const textareaClass =
|
||||
"flex min-h-[140px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50";
|
||||
|
||||
function ContactForm() {
|
||||
const [name, setName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [subject, setSubject] = useState("");
|
||||
const [message, setMessage] = useState("");
|
||||
const [errors, setErrors] = useState<FieldErrors>({});
|
||||
const [captchaToken, setCaptchaToken] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const turnstileRef = useRef<TurnstileHandle>(null);
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
const parsed = contactSchema.safeParse({ name, email, subject, message });
|
||||
if (!parsed.success) {
|
||||
const next: FieldErrors = {};
|
||||
for (const issue of parsed.error.issues) {
|
||||
const key = issue.path[0] as keyof FieldErrors;
|
||||
if (key && !next[key]) next[key] = issue.message;
|
||||
}
|
||||
setErrors(next);
|
||||
return;
|
||||
}
|
||||
setErrors({});
|
||||
setLoading(true);
|
||||
try {
|
||||
await api.post("/contact", { ...parsed.data, turnstileToken: captchaToken });
|
||||
toast.success("Mesajınız gönderildi", {
|
||||
description: "En kısa sürede size dönüş yapacağız.",
|
||||
});
|
||||
setName("");
|
||||
setEmail("");
|
||||
setSubject("");
|
||||
setMessage("");
|
||||
turnstileRef.current?.reset();
|
||||
setCaptchaToken("");
|
||||
} catch (err) {
|
||||
// Token tek kullanımlık — başarısız denemeden sonra widget'ı sıfırla
|
||||
turnstileRef.current?.reset();
|
||||
setCaptchaToken("");
|
||||
const msg =
|
||||
err instanceof ApiError ? err.message : "Mesaj gönderilemedi. Lütfen tekrar deneyin.";
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Bize yazın</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4" noValidate>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="contact-name">Ad Soyad</Label>
|
||||
<Input
|
||||
id="contact-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Adınız"
|
||||
aria-invalid={!!errors.name}
|
||||
disabled={loading}
|
||||
/>
|
||||
{errors.name && <p className="text-sm text-destructive">{errors.name}</p>}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="contact-email">E-posta</Label>
|
||||
<Input
|
||||
id="contact-email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="ornek@eposta.com"
|
||||
aria-invalid={!!errors.email}
|
||||
disabled={loading}
|
||||
/>
|
||||
{errors.email && <p className="text-sm text-destructive">{errors.email}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="contact-subject">Konu (opsiyonel)</Label>
|
||||
<Input
|
||||
id="contact-subject"
|
||||
value={subject}
|
||||
onChange={(e) => setSubject(e.target.value)}
|
||||
placeholder="Mesajınızın konusu"
|
||||
aria-invalid={!!errors.subject}
|
||||
disabled={loading}
|
||||
/>
|
||||
{errors.subject && <p className="text-sm text-destructive">{errors.subject}</p>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="contact-message">Mesaj</Label>
|
||||
<textarea
|
||||
id="contact-message"
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
placeholder="Size nasıl yardımcı olabiliriz?"
|
||||
aria-invalid={!!errors.message}
|
||||
disabled={loading}
|
||||
className={textareaClass}
|
||||
/>
|
||||
{errors.message && <p className="text-sm text-destructive">{errors.message}</p>}
|
||||
</div>
|
||||
|
||||
<Turnstile
|
||||
ref={turnstileRef}
|
||||
onVerify={setCaptchaToken}
|
||||
onExpire={() => setCaptchaToken("")}
|
||||
/>
|
||||
|
||||
<Button type="submit" disabled={loading} className="w-full sm:w-auto">
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||
Gönderiliyor…
|
||||
</>
|
||||
) : (
|
||||
"Gönder"
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function ContactContent() {
|
||||
usePageMeta({
|
||||
title: "İletişim — Sase.tr",
|
||||
description: "Sase.tr destek ve iletişim — sorularınız için bize ulaşın.",
|
||||
canonical: "https://sase.tr/contact",
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="text-4xl font-bold">İletişim</h1>
|
||||
<p className="mt-4 text-lg text-muted-foreground">
|
||||
Sorularınız, önerileriniz veya iş birliği talepleriniz için bize ulaşın.
|
||||
</p>
|
||||
|
||||
<div className="mt-12 grid gap-6 lg:grid-cols-5">
|
||||
{/* İletişim formu */}
|
||||
<div className="lg:col-span-3">
|
||||
<ContactForm />
|
||||
</div>
|
||||
|
||||
{/* İletişim bilgileri */}
|
||||
<div className="space-y-6 lg:col-span-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Destek</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<a href="mailto:destek@sase.tr" className="text-primary underline">
|
||||
destek@sase.tr
|
||||
</a>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Teknik sorunlar ve hesap işlemleri için.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Adres</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-1 text-sm text-muted-foreground">
|
||||
<p>
|
||||
<span className="text-foreground">Firma Adı:</span> THINXTRA LLC
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-foreground">Adres:</span> 1209 Mountain Road PL NE #11131
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-foreground">Şehir:</span> Albuquerque, NM 87110
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-foreground">Ülke:</span> United States
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-foreground">Vergi No:</span> 36-5177177
|
||||
</p>
|
||||
</div>
|
||||
<p className="mt-3 text-sm text-muted-foreground">
|
||||
Çalışma saatleri: Pazartesi – Cuma, 09:00 – 18:00
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ReportCatalogIssueButton } from "@/components/catalog/report-catalog-issue";
|
||||
import type { Part } from "@/hooks/use-parts";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { capture } from "@/lib/posthog";
|
||||
@@ -36,6 +37,34 @@ export function PartsPanel({
|
||||
const viewedKeyRef = useRef<string | null>(null);
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||
const [resolvingCode, setResolvingCode] = useState<string | null>(null);
|
||||
// OEM codes that resolve to a non-empty cross-reference page. Only these are
|
||||
// rendered as links — unmatched codes stay plain text so a click never lands
|
||||
// on an empty "no equivalents" page. One batch lookup per parts list.
|
||||
const [matchedOemCodes, setMatchedOemCodes] = useState<Set<string>>(new Set());
|
||||
|
||||
const oemCodes = useMemo(
|
||||
() => [...new Set(parts.map((p) => p.oemCode).filter((c) => c && c !== "N/A"))],
|
||||
[parts],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (oemCodes.length === 0) {
|
||||
setMatchedOemCodes(new Set());
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
api
|
||||
.post<{ matched: string[] }>("/p/matched", { codes: oemCodes })
|
||||
.then((res) => {
|
||||
if (!cancelled) setMatchedOemCodes(new Set(res?.matched ?? []));
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setMatchedOemCodes(new Set());
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [oemCodes]);
|
||||
|
||||
// PL24 "bk. tablo:NNN-NNN" cross-reference jump. Resolved at load → open the
|
||||
// target illustration directly. Unresolved (target branch not seeded yet) →
|
||||
@@ -204,23 +233,30 @@ export function PartsPanel({
|
||||
{parts.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center gap-4 py-12 text-center text-sm text-muted-foreground">
|
||||
<p>Bu kategori için parça bulunamadı.</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
// Demand/abandonment signal from an empty "0 parça" panel — lets
|
||||
// us prioritise which catalogs to backfill, with readable context.
|
||||
capture("empty_catalog_cta_clicked", {
|
||||
vehicle_id: vehicleId,
|
||||
category_id: categoryId,
|
||||
vehicle_label: vehicleLabel,
|
||||
category_name: categoryName,
|
||||
});
|
||||
window.history.back();
|
||||
}}
|
||||
>
|
||||
Geri dön
|
||||
</Button>
|
||||
<div className="flex flex-wrap items-center justify-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
// Demand/abandonment signal from an empty "0 parça" panel — lets
|
||||
// us prioritise which catalogs to backfill, with readable context.
|
||||
capture("empty_catalog_cta_clicked", {
|
||||
vehicle_id: vehicleId,
|
||||
category_id: categoryId,
|
||||
vehicle_label: vehicleLabel,
|
||||
category_name: categoryName,
|
||||
});
|
||||
window.history.back();
|
||||
}}
|
||||
>
|
||||
Geri dön
|
||||
</Button>
|
||||
{/* User-driven report: a parts shop knows if this category SHOULD
|
||||
have parts. One click → structured Sentry feedback + replay. */}
|
||||
<ReportCatalogIssueButton
|
||||
ctx={{ vehicleId, categoryId, vehicleLabel, categoryName }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
@@ -344,7 +380,7 @@ export function PartsPanel({
|
||||
</td>
|
||||
<td className="px-3 py-2 font-mono text-xs">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{part.oemCode && (
|
||||
{part.oemCode && part.oemCode !== "N/A" && (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex shrink-0 items-center justify-center rounded p-0.5 text-muted-foreground hover:text-foreground"
|
||||
@@ -357,7 +393,39 @@ export function PartsPanel({
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{part.oemCode}
|
||||
{part.oemCode &&
|
||||
part.oemCode !== "N/A" &&
|
||||
matchedOemCodes.has(part.oemCode) ? (
|
||||
// Link ONLY codes with a real cross-reference. Plain
|
||||
// left-click → in-app client navigation (no full SPA
|
||||
// reload — the new-tab boot was the slow part). Real href
|
||||
// kept so ctrl/cmd/middle-click still opens a new tab.
|
||||
// Unmatched codes fall through to plain text.
|
||||
<a
|
||||
href={`/dashboard/oem/${encodeURIComponent(part.oemCode)}`}
|
||||
title="Uyumlu parça kodlarını gör"
|
||||
className="underline decoration-dotted underline-offset-2 transition-colors hover:text-foreground hover:decoration-solid"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
capture("oem_detail_opened", {
|
||||
oem_code: part.oemCode,
|
||||
part_id: part.id,
|
||||
vehicle_id: vehicleId,
|
||||
category_id: categoryId,
|
||||
});
|
||||
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
navigate({
|
||||
to: "/dashboard/oem/$code",
|
||||
params: { code: part.oemCode },
|
||||
});
|
||||
}}
|
||||
>
|
||||
{part.oemCode}
|
||||
</a>
|
||||
) : (
|
||||
part.oemCode
|
||||
)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center">{part.quantity}</td>
|
||||
|
||||
284
apps/web/src/components/survey-popover.tsx
Normal file
284
apps/web/src/components/survey-popover.tsx
Normal file
@@ -0,0 +1,284 @@
|
||||
import {
|
||||
type SurveyResponses,
|
||||
closeActiveSurvey,
|
||||
dismissActiveSurvey,
|
||||
submitActiveSurvey,
|
||||
subscribeActiveSurvey,
|
||||
} from "@/lib/surveys";
|
||||
import { Button } from "@sase/ui";
|
||||
import { X } from "lucide-react";
|
||||
import type { Survey } from "posthog-js";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
// Self-rendered PostHog survey popover (API-mode surveys). Question copy comes
|
||||
// from the survey definition in PostHog; this component only knows how to draw
|
||||
// the three question shapes we use (single choice, open text, rating/NPS).
|
||||
|
||||
const OTHER_CHOICE = "__other__";
|
||||
|
||||
// Rating scales render as one equal-width row. Static map so Tailwind's JIT
|
||||
// keeps these classes — a template-literal `grid-cols-${n}` would get purged.
|
||||
// (NPS = 0–10 → 11 buttons; CSAT/CES → 3/5/7.)
|
||||
const RATING_COLS: Record<number, string> = {
|
||||
3: "grid-cols-3",
|
||||
5: "grid-cols-5",
|
||||
7: "grid-cols-7",
|
||||
11: "grid-cols-11",
|
||||
};
|
||||
|
||||
export function SurveyPopover() {
|
||||
const [survey, setSurvey] = useState<Survey | null>(null);
|
||||
useEffect(() => subscribeActiveSurvey(setSurvey), []);
|
||||
if (!survey) return null;
|
||||
return <SurveyCard key={`${survey.id}-${survey.current_iteration ?? 0}`} survey={survey} />;
|
||||
}
|
||||
|
||||
function SurveyCard({ survey }: { survey: Survey }) {
|
||||
const [step, setStep] = useState(0);
|
||||
const [answers, setAnswers] = useState<SurveyResponses>({});
|
||||
const [choice, setChoice] = useState<string | null>(null);
|
||||
const [otherText, setOtherText] = useState("");
|
||||
const [openText, setOpenText] = useState("");
|
||||
const [rating, setRating] = useState<number | null>(null);
|
||||
const [done, setDone] = useState(false);
|
||||
const [shown, setShown] = useState(false);
|
||||
const advanceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const dismissRef = useRef<() => void>(() => {});
|
||||
|
||||
const questions = survey.questions;
|
||||
const q = questions[step];
|
||||
|
||||
useEffect(() => {
|
||||
if (!done) return;
|
||||
const t = setTimeout(() => closeActiveSurvey(), 6000);
|
||||
return () => clearTimeout(t);
|
||||
}, [done]);
|
||||
|
||||
// Cancel a pending rating auto-advance if the card unmounts mid-debounce.
|
||||
useEffect(() => () => clearTimeout(advanceTimer.current ?? undefined), []);
|
||||
|
||||
// Entrance: mount hidden, flip on the next frame so the CSS transition runs
|
||||
// (slide-up + fade). prefers-reduced-motion users skip straight to the end
|
||||
// state via the motion-reduce: utilities on the card.
|
||||
useEffect(() => {
|
||||
const id = requestAnimationFrame(() => setShown(true));
|
||||
return () => cancelAnimationFrame(id);
|
||||
}, []);
|
||||
|
||||
// Esc closes the popover (counts as a dismissal). The listener is attached
|
||||
// once; dismissRef keeps it pointed at the latest closure so it captures the
|
||||
// current answers/step rather than a stale first-render one.
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") dismissRef.current();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, []);
|
||||
|
||||
const qid = q?.id ?? `q${step}`;
|
||||
const choices = q && "choices" in q && Array.isArray(q.choices) ? q.choices : [];
|
||||
const hasOpenChoice = Boolean(q && "hasOpenChoice" in q && q.hasOpenChoice);
|
||||
const scale = q && "scale" in q && typeof q.scale === "number" ? q.scale : 5;
|
||||
const lowerLabel = q && "lowerBoundLabel" in q ? (q.lowerBoundLabel ?? "") : "";
|
||||
const upperLabel = q && "upperBoundLabel" in q ? (q.upperBoundLabel ?? "") : "";
|
||||
const optional = Boolean(q && "optional" in q && q.optional);
|
||||
|
||||
function currentValue(): string | number | null {
|
||||
if (!q) return null;
|
||||
if (q.type === "single_choice") {
|
||||
if (choice === OTHER_CHOICE) return otherText.trim() || null;
|
||||
return choice;
|
||||
}
|
||||
if (q.type === "rating") return rating;
|
||||
if (q.type === "open") return openText.trim() || null;
|
||||
return null;
|
||||
}
|
||||
|
||||
const canContinue = optional || currentValue() !== null;
|
||||
|
||||
function collect(): SurveyResponses {
|
||||
const value = currentValue();
|
||||
return value === null && !(qid in answers)
|
||||
? { ...answers, [qid]: null }
|
||||
: { ...answers, [qid]: value };
|
||||
}
|
||||
|
||||
function proceed(next: SurveyResponses) {
|
||||
setAnswers(next);
|
||||
if (step + 1 < questions.length) {
|
||||
setStep(step + 1);
|
||||
setChoice(null);
|
||||
setOtherText("");
|
||||
setOpenText("");
|
||||
setRating(null);
|
||||
return;
|
||||
}
|
||||
submitActiveSurvey(next);
|
||||
setDone(true);
|
||||
}
|
||||
|
||||
function advance() {
|
||||
proceed(collect());
|
||||
}
|
||||
|
||||
// Rating questions auto-advance — tapping a score IS the answer, so there's no
|
||||
// separate submit button. A short debounce lets the user change their pick
|
||||
// (and re-tapping clears the prior timer) before the card moves on.
|
||||
function selectRating(v: number) {
|
||||
setRating(v);
|
||||
clearTimeout(advanceTimer.current ?? undefined);
|
||||
advanceTimer.current = setTimeout(() => proceed({ ...answers, [qid]: v }), 450);
|
||||
}
|
||||
|
||||
function dismiss() {
|
||||
if (done) {
|
||||
closeActiveSurvey();
|
||||
return;
|
||||
}
|
||||
dismissActiveSurvey(collect());
|
||||
}
|
||||
dismissRef.current = dismiss;
|
||||
|
||||
const buttonLabel =
|
||||
(q && "buttonText" in q && q.buttonText) || (step + 1 < questions.length ? "Devam" : "Gönder");
|
||||
const ratingValues = Array.from({ length: scale === 10 ? 11 : scale }, (_, i) =>
|
||||
scale === 10 ? i : i + 1,
|
||||
);
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-label={survey.name}
|
||||
className={`fixed bottom-28 right-4 z-[70] w-[min(22rem,calc(100vw-2rem))] rounded-xl border border-border bg-card p-5 text-card-foreground shadow-2xl transition-all duration-300 ease-out motion-reduce:transition-none sm:bottom-24 ${
|
||||
shown ? "translate-y-0 opacity-100" : "translate-y-3 opacity-0"
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Kapat"
|
||||
onClick={dismiss}
|
||||
className="absolute right-3 top-3 rounded-md p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
|
||||
{done ? (
|
||||
<div>
|
||||
<p className="pr-6 text-sm font-semibold">
|
||||
{survey.appearance?.thankYouMessageHeader || "Teşekkürler!"}
|
||||
</p>
|
||||
{survey.appearance?.thankYouMessageDescription ? (
|
||||
<p className="mt-1.5 text-sm text-muted-foreground">
|
||||
{survey.appearance.thankYouMessageDescription}
|
||||
</p>
|
||||
) : null}
|
||||
<Button variant="outline" className="mt-4 w-full" onClick={closeActiveSurvey}>
|
||||
Kapat
|
||||
</Button>
|
||||
</div>
|
||||
) : q ? (
|
||||
<div>
|
||||
{questions.length > 1 ? (
|
||||
<p className="mb-2 text-xs font-medium tabular-nums text-muted-foreground">
|
||||
{step + 1} / {questions.length}
|
||||
</p>
|
||||
) : null}
|
||||
<p className="pr-6 text-sm font-medium leading-snug">{q.question}</p>
|
||||
|
||||
{q.type === "single_choice" ? (
|
||||
<div className="mt-3 flex flex-col gap-1.5">
|
||||
{choices.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
onClick={() => setChoice(c)}
|
||||
className={`rounded-lg border px-3 py-2 text-left text-sm transition-colors ${
|
||||
choice === c
|
||||
? "border-brand bg-brand/10 text-foreground"
|
||||
: "border-border text-muted-foreground hover:border-foreground/30 hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{c}
|
||||
</button>
|
||||
))}
|
||||
{hasOpenChoice ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setChoice(OTHER_CHOICE)}
|
||||
className={`rounded-lg border px-3 py-2 text-left text-sm transition-colors ${
|
||||
choice === OTHER_CHOICE
|
||||
? "border-brand bg-brand/10 text-foreground"
|
||||
: "border-border text-muted-foreground hover:border-foreground/30 hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
Diğer
|
||||
</button>
|
||||
{choice === OTHER_CHOICE ? (
|
||||
<textarea
|
||||
rows={2}
|
||||
value={otherText}
|
||||
onChange={(e) => setOtherText(e.target.value)}
|
||||
placeholder="Yazmaya başlayın..."
|
||||
// biome-ignore lint/a11y/noAutofocus: revealed by an explicit user action
|
||||
autoFocus
|
||||
className="w-full resize-none rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none focus:border-foreground/40"
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{q.type === "rating" ? (
|
||||
<div className="mt-3">
|
||||
<div className={`grid gap-1 ${RATING_COLS[ratingValues.length] ?? "grid-cols-5"}`}>
|
||||
{ratingValues.map((v) => (
|
||||
<button
|
||||
key={v}
|
||||
type="button"
|
||||
onClick={() => selectRating(v)}
|
||||
className={`flex h-9 items-center justify-center rounded-md border text-xs transition-colors ${
|
||||
rating === v
|
||||
? "border-brand bg-brand text-brand-foreground"
|
||||
: "border-border text-muted-foreground hover:border-foreground/30 hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{v}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{lowerLabel || upperLabel ? (
|
||||
<div className="mt-1.5 flex justify-between text-xs text-muted-foreground">
|
||||
<span>{lowerLabel}</span>
|
||||
<span>{upperLabel}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{q.type === "open" ? (
|
||||
<textarea
|
||||
rows={3}
|
||||
value={openText}
|
||||
onChange={(e) => setOpenText(e.target.value)}
|
||||
placeholder="Yazmaya başlayın..."
|
||||
className="mt-3 w-full resize-none rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none focus:border-foreground/40"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{q.type !== "rating" ? (
|
||||
<Button
|
||||
variant="brand"
|
||||
className="mt-4 w-full"
|
||||
disabled={!canContinue}
|
||||
onClick={advance}
|
||||
>
|
||||
{buttonLabel}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
147
apps/web/src/components/trial-value-upsell.tsx
Normal file
147
apps/web/src/components/trial-value-upsell.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
import { api } from "@/lib/api-client";
|
||||
import { useSession } from "@/lib/auth-client";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import { capture } from "@/lib/posthog";
|
||||
import { Button } from "@sase/ui";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { Sparkles, X } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
interface Subscription {
|
||||
status: string;
|
||||
plan?: { name: string; key: string };
|
||||
billingPeriod: string;
|
||||
endDate?: string;
|
||||
}
|
||||
|
||||
// Early, value-framed conversion nudge that fills the dead zone the urgency
|
||||
// banner leaves: the 30-day trial only shows the (red) urgency banner in its
|
||||
// final week, so for ~3 weeks there is no in-app push at all — and ~98% of
|
||||
// trials never reach checkout. This banner instead fires on PROVEN VALUE (the
|
||||
// user has decoded >= VALUE_THRESHOLD customer vehicles) and only outside the
|
||||
// urgency window, so the two never overlap and the tone stays positive.
|
||||
const VALUE_THRESHOLD = 3;
|
||||
// Urgency banner owns days <= 7; this one owns days > 7 (no overlap).
|
||||
const URGENCY_WINDOW_DAYS = 7;
|
||||
|
||||
function dismissStorageKey(userId: string | null | undefined, endDate: string): string {
|
||||
return `trialValueUpsellDismissed-${userId ?? "anon"}-${endDate}`;
|
||||
}
|
||||
|
||||
export function TrialValueUpsell() {
|
||||
const { t } = useTranslation();
|
||||
const { data: session } = useSession();
|
||||
const userId = session?.user?.id ?? null;
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const viewedRef = useRef(false);
|
||||
|
||||
const { data: subData, isLoading: subLoading } = useQuery({
|
||||
queryKey: ["subscription", "me"],
|
||||
queryFn: () =>
|
||||
api.get<{ subscription: Subscription | null; eligibleForTrial: boolean }>(
|
||||
"/subscriptions/me",
|
||||
),
|
||||
});
|
||||
|
||||
const subscription = subData?.subscription;
|
||||
const endDate = subscription?.endDate;
|
||||
const days =
|
||||
subscription?.status === "trial" && endDate
|
||||
? Math.max(0, Math.ceil((new Date(endDate).getTime() - Date.now()) / (1000 * 60 * 60 * 24)))
|
||||
: null;
|
||||
// Only outside the urgency window, so the urgency banner stays the sole voice
|
||||
// in the final week.
|
||||
const inWindow = !subLoading && days !== null && days > URGENCY_WINDOW_DAYS;
|
||||
|
||||
// Server-truth value signal: how many customer vehicles this trial user has
|
||||
// decoded. Requesting exactly VALUE_THRESHOLD rows is enough to answer
|
||||
// ">= threshold" without a COUNT endpoint. Gated so non-eligible users never
|
||||
// pay for the request.
|
||||
const { data: history } = useQuery({
|
||||
queryKey: ["vehicles", "history", "value-probe", VALUE_THRESHOLD],
|
||||
queryFn: () => api.get<unknown[]>(`/vehicles/history?limit=${VALUE_THRESHOLD}`),
|
||||
enabled: inWindow,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
const hasProvenValue = Array.isArray(history) && history.length >= VALUE_THRESHOLD;
|
||||
|
||||
const dismissKey = endDate ? dismissStorageKey(userId, endDate) : null;
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mounted || !dismissKey) return;
|
||||
if (localStorage.getItem(dismissKey) === "true") setDismissed(true);
|
||||
}, [mounted, dismissKey]);
|
||||
|
||||
const visible = inWindow && hasProvenValue && !dismissed;
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible || viewedRef.current || days === null) return;
|
||||
viewedRef.current = true;
|
||||
capture("trial_value_upsell_viewed", {
|
||||
days_remaining: days,
|
||||
plan_name: subscription?.plan?.name,
|
||||
billing_period: subscription?.billingPeriod,
|
||||
});
|
||||
}, [visible, days, subscription?.plan?.name, subscription?.billingPeriod]);
|
||||
|
||||
const handleCTAClick = useCallback(() => {
|
||||
capture("trial_value_upsell_cta_clicked", {
|
||||
days_remaining: days,
|
||||
plan_name: subscription?.plan?.name,
|
||||
billing_period: subscription?.billingPeriod,
|
||||
});
|
||||
}, [days, subscription?.plan?.name, subscription?.billingPeriod]);
|
||||
|
||||
if (!visible || !dismissKey) return null;
|
||||
|
||||
const handleDismiss = () => {
|
||||
capture("trial_value_upsell_dismissed", {
|
||||
days_remaining: days,
|
||||
plan_name: subscription?.plan?.name,
|
||||
billing_period: subscription?.billingPeriod,
|
||||
});
|
||||
localStorage.setItem(dismissKey, "true");
|
||||
setDismissed(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-label={t("subscription.valueUpsell.title")}
|
||||
className="flex items-start gap-3 border-b border-brand/20 bg-brand/5 px-4 py-3 sm:items-center sm:px-6"
|
||||
>
|
||||
<Sparkles className="mt-0.5 size-5 shrink-0 text-brand sm:mt-0" />
|
||||
|
||||
<div className="flex flex-1 flex-col gap-1 sm:flex-row sm:items-center sm:gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-foreground">
|
||||
{t("subscription.valueUpsell.title")}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("subscription.valueUpsell.description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Link to="/dashboard/subscription" className="shrink-0" onClick={handleCTAClick}>
|
||||
<Button size="sm" className="bg-brand text-white hover:bg-brand/90">
|
||||
{t("subscription.valueUpsell.cta")}
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDismiss}
|
||||
className="flex size-8 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-brand/10 hover:text-foreground"
|
||||
aria-label={t("common.close")}
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
73
apps/web/src/lib/__tests__/surveys.test.ts
Normal file
73
apps/web/src/lib/__tests__/surveys.test.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import type { Survey } from "posthog-js";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildDismissedProperties,
|
||||
buildSentProperties,
|
||||
seenSurveyStorageKey,
|
||||
surveyInteractionKey,
|
||||
} from "../surveys";
|
||||
|
||||
// The capture payloads must match what posthog-js's own widget sends — the
|
||||
// Surveys results UI, the internal targeting flags ($survey_dismissed/<id>)
|
||||
// and cross-device dedup all key off these exact shapes.
|
||||
|
||||
function mkSurvey(overrides: Record<string, unknown> = {}): Survey {
|
||||
return {
|
||||
id: "sv1",
|
||||
name: "Test Survey",
|
||||
type: "api",
|
||||
questions: [
|
||||
{ id: "q1", type: "single_choice", question: "Neden?", choices: ["A", "B"] },
|
||||
{ id: "q2", type: "open", question: "Detay?", optional: true },
|
||||
],
|
||||
current_iteration: null,
|
||||
current_iteration_start_date: null,
|
||||
...overrides,
|
||||
} as unknown as Survey;
|
||||
}
|
||||
|
||||
describe("surveyInteractionKey", () => {
|
||||
it("builds the plain person-property key for one-off surveys", () => {
|
||||
expect(surveyInteractionKey(mkSurvey(), "dismissed")).toBe("$survey_dismissed/sv1");
|
||||
});
|
||||
|
||||
it("appends the iteration for recurring surveys (matches the internal targeting flag)", () => {
|
||||
const s = mkSurvey({ current_iteration: 1 });
|
||||
expect(surveyInteractionKey(s, "responded")).toBe("$survey_responded/sv1/1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("seenSurveyStorageKey", () => {
|
||||
it("matches posthog-js seenSurvey_ localStorage convention", () => {
|
||||
expect(seenSurveyStorageKey(mkSurvey())).toBe("seenSurvey_sv1");
|
||||
expect(seenSurveyStorageKey(mkSurvey({ current_iteration: 2 }))).toBe("seenSurvey_sv1_2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildSentProperties", () => {
|
||||
it("keys responses by question id and marks the responded person property", () => {
|
||||
const props = buildSentProperties(mkSurvey(), { q1: "A", q2: null }, "sub-1");
|
||||
expect(props.$survey_id).toBe("sv1");
|
||||
expect(props.$survey_response_q1).toBe("A");
|
||||
expect(props.$survey_response_q2).toBeNull();
|
||||
expect(props.$survey_completed).toBe(true);
|
||||
expect(props.$survey_submission_id).toBe("sub-1");
|
||||
expect(props.$set).toEqual({ "$survey_responded/sv1": true });
|
||||
expect(props.$survey_questions).toEqual([
|
||||
{ id: "q1", question: "Neden?", response: "A" },
|
||||
{ id: "q2", question: "Detay?", response: null },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildDismissedProperties", () => {
|
||||
it("flags partial completion only when something was answered", () => {
|
||||
const empty = buildDismissedProperties(mkSurvey(), { q1: null });
|
||||
expect(empty.$survey_partially_completed).toBe(false);
|
||||
expect(empty.$set).toEqual({ "$survey_dismissed/sv1": true });
|
||||
|
||||
const partial = buildDismissedProperties(mkSurvey(), { q1: "B", q2: null });
|
||||
expect(partial.$survey_partially_completed).toBe(true);
|
||||
expect(partial.$survey_response_q1).toBe("B");
|
||||
});
|
||||
});
|
||||
@@ -68,6 +68,15 @@ export function initChatwoot(): void {
|
||||
_pending = [];
|
||||
});
|
||||
|
||||
// Hide the floating launcher bubble on mobile — it covers content on small
|
||||
// screens. The widget itself stays functional: openChatwoot() (subscription
|
||||
// page, onboarding modal) still opens it fullscreen with its own close button.
|
||||
const style = document.createElement("style");
|
||||
style.id = "chatwoot-mobile-hide";
|
||||
style.textContent =
|
||||
"@media (max-width: 767px) { .woot--bubble-holder { display: none !important; } }";
|
||||
document.head.appendChild(style);
|
||||
|
||||
const script = document.createElement("script");
|
||||
script.id = "chatwoot-sdk";
|
||||
script.src = `${BASE_URL}/packs/js/sdk.js`;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// PostHog — lazily loaded so it doesn't land in the initial bundle.
|
||||
// All exported functions are fire-and-forget; analytics loss on failure is acceptable.
|
||||
|
||||
import { resetSurveySession, scheduleSurveyCheck, surveysReceiverReady } from "./surveys";
|
||||
|
||||
type PostHog = import("posthog-js").PostHog;
|
||||
|
||||
let _ph: PostHog | null = null;
|
||||
@@ -39,11 +41,19 @@ export function initPostHog(): void {
|
||||
// PII leak.
|
||||
autocapture: false,
|
||||
enable_heatmaps: true,
|
||||
// Staging only: don't drop events from automation UAs (Playwright e2e of
|
||||
// surveys/flags runs against dev.sase.tr). Prod keeps the bot filter.
|
||||
opt_out_useragent_filter: window.location.hostname !== "sase.tr",
|
||||
session_recording: {
|
||||
maskAllInputs: false,
|
||||
maskInputOptions: { password: true },
|
||||
},
|
||||
});
|
||||
// dev.sase.tr now ships with the key too (surveys must be testable on dev
|
||||
// first) — stamp every event so staging traffic is filterable in analyses.
|
||||
ph.register({
|
||||
deploy_env: window.location.hostname === "sase.tr" ? "production" : "staging",
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -60,14 +70,22 @@ export function identifyUser(user: {
|
||||
role: user.role,
|
||||
});
|
||||
});
|
||||
scheduleSurveyCheck();
|
||||
}
|
||||
|
||||
export function resetUser(): void {
|
||||
load().then((ph) => ph.reset());
|
||||
resetSurveySession();
|
||||
}
|
||||
|
||||
export function capture(event: string, properties?: Record<string, unknown>): void {
|
||||
load().then((ph) => ph.capture(event, properties));
|
||||
// Hold captures until the surveys extension's event receiver is hooked (≤3s)
|
||||
// so event-triggered surveys never miss their trigger, then re-check
|
||||
// eligibility once the event has actually gone through.
|
||||
Promise.all([load(), surveysReceiverReady()]).then(([ph]) => {
|
||||
ph.capture(event, properties);
|
||||
scheduleSurveyCheck();
|
||||
});
|
||||
}
|
||||
|
||||
export function capturePageView(path: string): void {
|
||||
@@ -79,10 +97,12 @@ export function capturePageView(path: string): void {
|
||||
load().then((ph) =>
|
||||
ph.capture("$pageview", { $current_url: window.location.href, $pathname: path }),
|
||||
);
|
||||
scheduleSurveyCheck();
|
||||
}
|
||||
|
||||
export function setPeopleProperties(properties: Record<string, unknown>): void {
|
||||
load().then((ph) => ph.people?.set(properties));
|
||||
scheduleSurveyCheck();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -10,9 +10,20 @@
|
||||
import type { init as SentryInit } from "@sentry/react";
|
||||
|
||||
let initialized = false;
|
||||
// Set once init succeeds so the catalog-degradation helpers below can use the
|
||||
// already-loaded SDK without re-awaiting the dynamic import on every call.
|
||||
let sentryApi: typeof import("@sentry/react") | null = null;
|
||||
// Cache the init promise so concurrent callers (e.g. a degradation reporter that
|
||||
// fires on first paint, racing the main.tsx call) await the SAME init instead of
|
||||
// double-initialising the SDK.
|
||||
let initPromise: Promise<void> | null = null;
|
||||
|
||||
export async function initSentry() {
|
||||
if (initialized) return;
|
||||
export function initSentry(): Promise<void> {
|
||||
if (!initPromise) initPromise = doInitSentry();
|
||||
return initPromise;
|
||||
}
|
||||
|
||||
async function doInitSentry(): Promise<void> {
|
||||
const dsn = import.meta.env.VITE_SENTRY_DSN;
|
||||
if (!dsn) {
|
||||
if (import.meta.env.DEV) {
|
||||
@@ -21,20 +32,18 @@ export async function initSentry() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Both dev.sase.tr (staging) and sase.tr (prod) ship a production Vite build
|
||||
// (MODE === "production"), so MODE can't separate them. Derive from the host at
|
||||
// runtime instead, so dev errors stay filterable from prod in Sentry. An
|
||||
// explicit VITE_SENTRY_ENVIRONMENT still wins.
|
||||
// Hostname is authoritative for the two real deployments — sase.tr (prod) and
|
||||
// dev.sase.tr (staging) — which both ship a production Vite build baking the
|
||||
// SAME VITE_SENTRY_ENVIRONMENT (compose default "production"), so that var can't
|
||||
// tell them apart. Use it only as a fallback for local/preview hosts.
|
||||
const host = typeof window !== "undefined" ? window.location.hostname : "";
|
||||
const environment =
|
||||
(import.meta.env.VITE_SENTRY_ENVIRONMENT as string | undefined) ??
|
||||
(host === "sase.tr" || host === "www.sase.tr"
|
||||
host === "sase.tr" || host === "www.sase.tr"
|
||||
? "production"
|
||||
: host.endsWith("dev.sase.tr")
|
||||
? "staging"
|
||||
: import.meta.env.DEV
|
||||
? "development"
|
||||
: "staging");
|
||||
: (import.meta.env.VITE_SENTRY_ENVIRONMENT as string | undefined) ||
|
||||
(import.meta.env.DEV ? "development" : "staging");
|
||||
const release = import.meta.env.VITE_SENTRY_RELEASE;
|
||||
|
||||
try {
|
||||
@@ -46,6 +55,9 @@ export async function initSentry() {
|
||||
integrations: [
|
||||
Sentry.browserTracingIntegration(),
|
||||
Sentry.replayIntegration({ maskAllText: true, blockAllMedia: true }),
|
||||
// Contextual in-app feedback (no global floating button — opened from the
|
||||
// empty/error catalog states via openCatalogFeedback).
|
||||
Sentry.feedbackIntegration({ autoInject: false, showBranding: false }),
|
||||
],
|
||||
// Performance: 10% trace sample rate (low volume site, can raise later).
|
||||
tracesSampleRate: 0.1,
|
||||
@@ -75,8 +87,99 @@ export async function initSentry() {
|
||||
denyUrls: [/iabjs:\/\//],
|
||||
});
|
||||
initialized = true;
|
||||
sentryApi = Sentry;
|
||||
console.log("[sentry] browser SDK initialized");
|
||||
} catch (err) {
|
||||
console.warn("[sentry] init failed:", (err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Catalog UX-degradation reporting (browser) ───
|
||||
// Two things only the browser can add on top of the backend reporter
|
||||
// (apps/api/src/common/catalog-degradation.ts): a Session Replay of the user
|
||||
// hitting the dead-end, and a one-click structured feedback path. Browser events
|
||||
// are fingerprinted with source "browser" so they form their own "what users
|
||||
// actually saw" issues (carrying replays), distinct from the server-side
|
||||
// detections — same kinds, complementary views.
|
||||
|
||||
export type CatalogIssueKind = "empty-tree" | "empty-parts" | "drill-load-error";
|
||||
|
||||
export interface CatalogIssueContext {
|
||||
vehicleId?: string;
|
||||
vehicleLabel?: string;
|
||||
categoryId?: string;
|
||||
categoryName?: string;
|
||||
source?: string;
|
||||
vin?: string;
|
||||
}
|
||||
|
||||
// Per-session dedup: re-renders or browsing many empty categories shouldn't spam
|
||||
// the issue stream, and one replay upload already covers the whole journey.
|
||||
const reportedThisSession = new Set<string>();
|
||||
let replayFlushedThisSession = false;
|
||||
|
||||
const brandOf = (label?: string) => (label?.trim().split(/\s+/)[0] || "unknown").toLowerCase();
|
||||
|
||||
/**
|
||||
* Report the catalog failure the user is currently looking at and flush the
|
||||
* Session Replay so it's watchable in Sentry. Best-effort; never throws.
|
||||
*/
|
||||
export async function reportCatalogDegradation(
|
||||
kind: CatalogIssueKind,
|
||||
ctx: CatalogIssueContext,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const dedupKey = `${kind}:${ctx.categoryId ?? ctx.vehicleId ?? ""}`;
|
||||
if (reportedThisSession.has(dedupKey)) return;
|
||||
reportedThisSession.add(dedupKey);
|
||||
// The empty/error state can render before the SDK's dynamic import resolves —
|
||||
// wait for init so the failure is never silently dropped.
|
||||
await initSentry();
|
||||
const Sentry = sentryApi;
|
||||
if (!Sentry) return;
|
||||
|
||||
const brand = brandOf(ctx.vehicleLabel);
|
||||
Sentry.captureMessage(`catalog degraded: ${kind} (browser/${brand})`, {
|
||||
level: "warning",
|
||||
tags: { catalog_degradation: kind, catalog_source: ctx.source ?? "unknown" },
|
||||
fingerprint: ["catalog-degradation", "browser", kind, brand],
|
||||
extra: { ...ctx },
|
||||
});
|
||||
|
||||
if (!replayFlushedThisSession) {
|
||||
replayFlushedThisSession = true;
|
||||
await Sentry.getReplay()?.flush();
|
||||
}
|
||||
} catch {
|
||||
// telemetry must never break the UI
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the Sentry feedback dialog pre-tagged with the vehicle/category the user
|
||||
* is reporting from, so "bu araç çalışmadı" becomes a structured report with the
|
||||
* session replay attached — instead of an email.
|
||||
*/
|
||||
export async function openCatalogFeedback(ctx: CatalogIssueContext): Promise<void> {
|
||||
try {
|
||||
await initSentry();
|
||||
const Sentry = sentryApi;
|
||||
if (!Sentry) return;
|
||||
Sentry.setTag("catalog_source", ctx.source ?? "unknown");
|
||||
Sentry.setContext("catalog_issue", { ...ctx });
|
||||
const feedback = Sentry.getFeedback();
|
||||
if (!feedback) return;
|
||||
const form = await feedback.createForm({
|
||||
formTitle: "Bu araç / parça çalışmadı mı?",
|
||||
messagePlaceholder:
|
||||
"Hangi araç ve parça eksik veya yanlış? (şase ve kategori bilgisi otomatik eklenir)",
|
||||
submitButtonLabel: "Gönder",
|
||||
cancelButtonLabel: "Vazgeç",
|
||||
// The session replay + the catalog_issue context above ride along with this.
|
||||
});
|
||||
form.appendToDom();
|
||||
form.open();
|
||||
} catch {
|
||||
// feedback is best-effort
|
||||
}
|
||||
}
|
||||
|
||||
229
apps/web/src/lib/surveys.ts
Normal file
229
apps/web/src/lib/surveys.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
// PostHog surveys in API mode — we render the popover ourselves (zero PostHog
|
||||
// branding, no PostHog survey DOM), while targeting and analytics stay in
|
||||
// PostHog: eligibility (event triggers, URL/device rules, wait periods and the
|
||||
// person-property targeting flags, e.g. subscription_status=active) is
|
||||
// evaluated by posthog-js inside getActiveMatchingSurveys, and we capture the
|
||||
// same "survey shown/dismissed/sent" events with the exact property shapes the
|
||||
// official widget uses, so per-distinct_id dedup and the Surveys results UI
|
||||
// keep working unchanged.
|
||||
|
||||
import type { PostHog, Survey } from "posthog-js";
|
||||
|
||||
const ENABLED = Boolean(import.meta.env.VITE_POSTHOG_KEY);
|
||||
|
||||
function loadPh(): Promise<PostHog> {
|
||||
return import("posthog-js").then((m) => m.default);
|
||||
}
|
||||
|
||||
/** Answers keyed by question id; null = question left unanswered. */
|
||||
export type SurveyResponses = Record<string, string | number | null>;
|
||||
|
||||
// ── capture payloads (must mirror posthog-js dist/surveys.js) ────────────────
|
||||
|
||||
/** Person-property key the survey's internal targeting flag filters on —
|
||||
* `$survey_dismissed/<id>` plus `/<iteration>` for recurring surveys. */
|
||||
export function surveyInteractionKey(survey: Survey, kind: "dismissed" | "responded"): string {
|
||||
const base = `$survey_${kind}/${survey.id}`;
|
||||
return survey.current_iteration && survey.current_iteration > 0
|
||||
? `${base}/${survey.current_iteration}`
|
||||
: base;
|
||||
}
|
||||
|
||||
/** localStorage key posthog-js checks for client-side "already seen" gating. */
|
||||
export function seenSurveyStorageKey(survey: Survey): string {
|
||||
const base = `seenSurvey_${survey.id}`;
|
||||
return survey.current_iteration && survey.current_iteration > 0
|
||||
? `${base}_${survey.current_iteration}`
|
||||
: base;
|
||||
}
|
||||
|
||||
export function surveyResponseKey(questionId: string): string {
|
||||
return `$survey_response_${questionId}`;
|
||||
}
|
||||
|
||||
function baseProperties(survey: Survey): Record<string, unknown> {
|
||||
return {
|
||||
$survey_name: survey.name,
|
||||
$survey_id: survey.id,
|
||||
$survey_iteration: survey.current_iteration,
|
||||
$survey_iteration_start_date: survey.current_iteration_start_date,
|
||||
};
|
||||
}
|
||||
|
||||
function responseProperties(survey: Survey, responses: SurveyResponses): Record<string, unknown> {
|
||||
const props: Record<string, unknown> = {};
|
||||
for (const q of survey.questions) {
|
||||
if (!q.id) continue;
|
||||
props[surveyResponseKey(q.id)] = responses[q.id] ?? null;
|
||||
}
|
||||
return props;
|
||||
}
|
||||
|
||||
export function buildDismissedProperties(
|
||||
survey: Survey,
|
||||
responses: SurveyResponses,
|
||||
): Record<string, unknown> {
|
||||
const hasAnyResponse = Object.values(responses).some((v) => v !== null && v !== "");
|
||||
return {
|
||||
...baseProperties(survey),
|
||||
...responseProperties(survey, responses),
|
||||
$survey_partially_completed: hasAnyResponse,
|
||||
$set: { [surveyInteractionKey(survey, "dismissed")]: true },
|
||||
};
|
||||
}
|
||||
|
||||
export function buildSentProperties(
|
||||
survey: Survey,
|
||||
responses: SurveyResponses,
|
||||
submissionId: string,
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
...baseProperties(survey),
|
||||
...responseProperties(survey, responses),
|
||||
$survey_questions: survey.questions.map((q) => ({
|
||||
id: q.id,
|
||||
question: q.question,
|
||||
response: q.id ? (responses[q.id] ?? null) : null,
|
||||
})),
|
||||
$survey_submission_id: submissionId,
|
||||
$survey_completed: true,
|
||||
$set: { [surveyInteractionKey(survey, "responded")]: true },
|
||||
};
|
||||
}
|
||||
|
||||
// ── display manager ──────────────────────────────────────────────────────────
|
||||
|
||||
let _active: Survey | null = null;
|
||||
let _shownThisSession = new Set<string>();
|
||||
const _listeners = new Set<(survey: Survey | null) => void>();
|
||||
let _checkTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let _showTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let _wired = false;
|
||||
|
||||
function notify(): void {
|
||||
for (const cb of _listeners) cb(_active);
|
||||
}
|
||||
|
||||
function markSeen(survey: Survey): void {
|
||||
try {
|
||||
localStorage.setItem(seenSurveyStorageKey(survey), "true");
|
||||
} catch {
|
||||
// localStorage unavailable — the server-side person-property dedup still applies
|
||||
}
|
||||
}
|
||||
|
||||
function wireOnce(ph: PostHog): void {
|
||||
if (_wired) return;
|
||||
_wired = true;
|
||||
// Re-check when the lazily loaded surveys module becomes ready and whenever
|
||||
// feature flags re-evaluate (identify, property changes) — both change eligibility.
|
||||
ph.surveys?.onSurveysLoaded?.(() => scheduleSurveyCheck());
|
||||
ph.onFeatureFlags(() => scheduleSurveyCheck());
|
||||
}
|
||||
|
||||
function check(ph: PostHog): void {
|
||||
wireOnce(ph);
|
||||
if (_active || _showTimer) return;
|
||||
ph.getActiveMatchingSurveys((surveys) => {
|
||||
if (_active || _showTimer) return;
|
||||
const candidate = surveys.find((s) => s.type === "api" && !_shownThisSession.has(s.id));
|
||||
if (!candidate) return;
|
||||
const delaySeconds = candidate.appearance?.surveyPopupDelaySeconds ?? 0;
|
||||
_showTimer = setTimeout(() => {
|
||||
_showTimer = null;
|
||||
show(ph, candidate);
|
||||
}, delaySeconds * 1000);
|
||||
});
|
||||
}
|
||||
|
||||
function show(ph: PostHog, survey: Survey): void {
|
||||
if (_active) return;
|
||||
_shownThisSession.add(survey.id);
|
||||
_active = survey;
|
||||
ph.capture("survey shown", baseProperties(survey));
|
||||
try {
|
||||
localStorage.setItem("lastSeenSurveyDate", new Date().toISOString());
|
||||
} catch {
|
||||
// best effort — only affects cross-survey wait-period spacing
|
||||
}
|
||||
notify();
|
||||
}
|
||||
|
||||
let _readyPromise: Promise<void> | null = null;
|
||||
|
||||
/** Resolves once posthog-js's surveys extension has installed its capture hook
|
||||
* (or after a 3s safety timeout). Events captured before that hook exists are
|
||||
* invisible to event-triggered surveys — the trial survey's
|
||||
* trial_urgency_banner_viewed fires ~1s into the page load and was racing the
|
||||
* extension's lazy <script>, so posthog.ts awaits this before every capture. */
|
||||
export function surveysReceiverReady(): Promise<void> {
|
||||
if (!ENABLED || typeof window === "undefined") return Promise.resolve();
|
||||
if (_readyPromise) return _readyPromise;
|
||||
_readyPromise = new Promise((resolve) => {
|
||||
setTimeout(resolve, 3000);
|
||||
loadPh()
|
||||
.then((ph) => ph.surveys?.onSurveysLoaded?.(() => resolve()))
|
||||
.catch(() => resolve());
|
||||
});
|
||||
return _readyPromise;
|
||||
}
|
||||
|
||||
/** Debounced eligibility check — called after captures, route changes and identify. */
|
||||
export function scheduleSurveyCheck(): void {
|
||||
if (!ENABLED || typeof window === "undefined") return;
|
||||
if (_checkTimer) clearTimeout(_checkTimer);
|
||||
_checkTimer = setTimeout(() => {
|
||||
_checkTimer = null;
|
||||
loadPh()
|
||||
.then((ph) => check(ph))
|
||||
.catch(() => {});
|
||||
}, 700);
|
||||
}
|
||||
|
||||
export function subscribeActiveSurvey(cb: (survey: Survey | null) => void): () => void {
|
||||
_listeners.add(cb);
|
||||
cb(_active);
|
||||
return () => {
|
||||
_listeners.delete(cb);
|
||||
};
|
||||
}
|
||||
|
||||
export function dismissActiveSurvey(responses: SurveyResponses): void {
|
||||
const survey = _active;
|
||||
if (!survey) return;
|
||||
_active = null;
|
||||
notify();
|
||||
markSeen(survey);
|
||||
loadPh()
|
||||
.then((ph) => ph.capture("survey dismissed", buildDismissedProperties(survey, responses)))
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
/** Capture the responses; the popover stays mounted to show its thank-you
|
||||
* state and calls closeActiveSurvey() when it's done. */
|
||||
export function submitActiveSurvey(responses: SurveyResponses): void {
|
||||
const survey = _active;
|
||||
if (!survey) return;
|
||||
markSeen(survey);
|
||||
const submissionId = crypto.randomUUID();
|
||||
loadPh()
|
||||
.then((ph) => ph.capture("survey sent", buildSentProperties(survey, responses, submissionId)))
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
export function closeActiveSurvey(): void {
|
||||
if (!_active) return;
|
||||
_active = null;
|
||||
notify();
|
||||
}
|
||||
|
||||
/** Logout — drop in-memory survey state; localStorage/person-property dedup stays. */
|
||||
export function resetSurveySession(): void {
|
||||
if (_showTimer) {
|
||||
clearTimeout(_showTimer);
|
||||
_showTimer = null;
|
||||
}
|
||||
_active = null;
|
||||
_shownThisSession = new Set();
|
||||
notify();
|
||||
}
|
||||
@@ -185,7 +185,7 @@
|
||||
"tabPl24": "Pl24",
|
||||
"tabPcat": "Pcat",
|
||||
"tabEmex": "Emex",
|
||||
"tabTecdoc": "Tecdoc",
|
||||
"tabTecdoc": "P",
|
||||
"comingSoon": "Coming Soon",
|
||||
"categories": "Categories",
|
||||
"noCategories": "No categories found",
|
||||
@@ -413,6 +413,11 @@
|
||||
"cta": "Upgrade Plan",
|
||||
"progressLabel": "{percent}% of trial used"
|
||||
},
|
||||
"valueUpsell": {
|
||||
"title": "Ready to subscribe?",
|
||||
"description": "You're actively using the platform. Subscribe to keep querying customer vehicles and accessing OEM parts without interruption.",
|
||||
"cta": "Subscribe"
|
||||
},
|
||||
"trialProgress": {
|
||||
"title": "{days} days left in your Full Package trial",
|
||||
"description": "Pick a plan now to keep your access without interruption.",
|
||||
@@ -489,8 +494,7 @@
|
||||
"refunded": "Refunded"
|
||||
},
|
||||
"methodLabels": {
|
||||
"stripe": "Credit Card",
|
||||
"eft": "EFT/Wire"
|
||||
"stripe": "Credit Card"
|
||||
},
|
||||
"summary": {
|
||||
"title": "Current Plan",
|
||||
@@ -663,7 +667,7 @@
|
||||
"s2t": "See the parts",
|
||||
"s2d": "OEM codes, diagrams and compatible parts appear in seconds.",
|
||||
"s3t": "Pick the right part",
|
||||
"s3d": "Compare prices and find the right part in one go — no wrong orders."
|
||||
"s3d": "Copy the right OEM code and order the correct part from your supplier in one go — no wrong orders."
|
||||
},
|
||||
"faq": {
|
||||
"title": "Frequently asked questions",
|
||||
@@ -713,7 +717,6 @@
|
||||
"previewHint": "Click Decode VIN to access the full parts catalog.",
|
||||
"recent": "Recent searches",
|
||||
"seeAll": "See all →",
|
||||
"openHint": "Click to reopen the vehicle with its parts catalog",
|
||||
"historyAria": "{brand} {model} — VIN {vin}, open from history",
|
||||
"errorInvalidVin": "Invalid VIN. Must be 17 characters; I, O, Q are not allowed.",
|
||||
"errorGeneric": "An error occurred. Please try again.",
|
||||
@@ -738,6 +741,8 @@
|
||||
"noCategoriesTitle": "No categories for this vehicle",
|
||||
"noCategoriesHint": "The vehicle was decoded but the catalog may not be ready yet. Reach out to support to expedite it.",
|
||||
"drillHint": "Parts live inside the subcategories — open a category to drill down to its part lists.",
|
||||
"reportIssue": "Not working? Report it",
|
||||
"reportIssueDone": "Reported — thanks",
|
||||
"noAttrs": "No detailed info available for this vehicle.",
|
||||
"labelModel": "Model",
|
||||
"labelYear": "Model year",
|
||||
@@ -798,7 +803,7 @@
|
||||
"subtitleA": "Enter the customer vehicle's VIN and find the correct OEM part the first time with cross-catalog verification. No returns, no losses — trusted by 500+ businesses. Try it free.",
|
||||
"lossPillB": "Start looking up VINs free — no card required",
|
||||
"titleB": "Find the Customer's Correct Part in Seconds",
|
||||
"subtitleB": "Enter the 17-digit VIN and reach the right OEM codes, part diagrams and prices in seconds. 27 brands, unlimited lookups. Start free — no credit card.",
|
||||
"subtitleB": "Enter the 17-digit VIN and reach the right OEM codes, part diagrams in seconds. 27 brands, unlimited lookups. Start free — no credit card.",
|
||||
"vinPlaceholder": "Example: WVWZZZ1JZ3W597935",
|
||||
"searchCta": "Search",
|
||||
"decoding": "Decoding...",
|
||||
@@ -868,7 +873,7 @@
|
||||
"description": "Cross-references multiple catalogs so OEM codes are always current and correct — no more wrong parts, no more returns.",
|
||||
"bullet1": "Multi-catalog cross-referencing",
|
||||
"bullet2": "Always-current OEM codes",
|
||||
"bullet3": "Price comparison",
|
||||
"bullet3": "Matched via exploded diagrams",
|
||||
"mockupTitle": "OEM Catalog — Parts List",
|
||||
"part1": "AC Compressor",
|
||||
"part2": "Heater Blower Motor",
|
||||
@@ -897,9 +902,9 @@
|
||||
"sase": "Multi-catalog cross-query"
|
||||
},
|
||||
"3": {
|
||||
"label": "Price comparison",
|
||||
"manual": "Impossible",
|
||||
"sase": "On a single screen"
|
||||
"label": "Interactive part diagrams",
|
||||
"manual": "Paper catalog",
|
||||
"sase": "Clickable diagrams"
|
||||
},
|
||||
"4": {
|
||||
"label": "Monthly time saved",
|
||||
@@ -1160,7 +1165,7 @@
|
||||
"items": {
|
||||
"0": {
|
||||
"q": "Which brands do you support?",
|
||||
"a": "We currently support 27 automotive brands and the list keeps growing. Enter your VIN in the search box to see right away whether your vehicle is covered."
|
||||
"a": "We currently support 27 automotive brands and the list keeps growing. Enter a VIN in the search box to see right away whether a vehicle is covered."
|
||||
},
|
||||
"1": {
|
||||
"q": "Is my data safe?",
|
||||
|
||||
@@ -185,7 +185,7 @@
|
||||
"tabPl24": "Pl24",
|
||||
"tabPcat": "Pcat",
|
||||
"tabEmex": "Emex",
|
||||
"tabTecdoc": "Tecdoc",
|
||||
"tabTecdoc": "P",
|
||||
"comingSoon": "Yakında",
|
||||
"categories": "Kategoriler",
|
||||
"noCategories": "Kategori bulunamadı",
|
||||
@@ -324,7 +324,7 @@
|
||||
"brandSearchPlaceholder": "Marka ara — ör. Volkswagen",
|
||||
"brandSearchNoMatch": "\"{query}\" ile eşleşen marka yok.",
|
||||
"features": {
|
||||
"vinSearch": "Sınırsız VIN arama",
|
||||
"vinSearch": "Sınırsız şase arama",
|
||||
"partsCatalog": "Parça kataloğu",
|
||||
"schemaViewer": "Şema görüntüleyici",
|
||||
"prioritySupport": "Öncelikli destek",
|
||||
@@ -413,6 +413,11 @@
|
||||
"cta": "Plana Yükselt",
|
||||
"progressLabel": "Denemenin %{percent}'i kullanıldı"
|
||||
},
|
||||
"valueUpsell": {
|
||||
"title": "Aboneliğe geçmeye hazır mısın?",
|
||||
"description": "Platformu aktif kullanıyorsun. Müşteri araçlarını sınırsız sorgulamaya ve OEM parça erişimine kesintisiz devam etmek için aboneliğe geç.",
|
||||
"cta": "Aboneliğe geç"
|
||||
},
|
||||
"trialProgress": {
|
||||
"title": "Full Paket denemende {days} gün kaldı",
|
||||
"description": "Kesintisiz devam etmek için planını şimdi seç.",
|
||||
@@ -489,8 +494,7 @@
|
||||
"refunded": "İade"
|
||||
},
|
||||
"methodLabels": {
|
||||
"stripe": "Kredi Kartı",
|
||||
"eft": "EFT/Havale"
|
||||
"stripe": "Kredi Kartı"
|
||||
},
|
||||
"summary": {
|
||||
"title": "Mevcut Plan",
|
||||
@@ -659,11 +663,11 @@
|
||||
"how": {
|
||||
"title": "Nasıl çalışır",
|
||||
"s1t": "Şase numarasını gir",
|
||||
"s1d": "17 haneli VIN/şase numarasını yapıştır.",
|
||||
"s1d": "17 haneli şase numarasını yapıştır.",
|
||||
"s2t": "Parçaları gör",
|
||||
"s2d": "Saniyeler içinde OEM kodları, şemalar ve uyumlu parçalar listelensin.",
|
||||
"s3t": "Doğru parçayı seç",
|
||||
"s3d": "Fiyatları karşılaştır, doğru parçayı tek seferde bul — yanlış sipariş yok."
|
||||
"s3d": "Doğru OEM kodunu kopyala, tedarikçine tek seferde doğru parçayı sipariş et — yanlış sipariş yok."
|
||||
},
|
||||
"faq": {
|
||||
"title": "Sık sorulan sorular",
|
||||
@@ -713,7 +717,6 @@
|
||||
"previewHint": "Şase Çöz butonuna tıklayarak tam parça kataloğuna erişin.",
|
||||
"recent": "Son Aramalar",
|
||||
"seeAll": "Tümünü Gör →",
|
||||
"openHint": "Aracı parça kataloğuyla birlikte yeniden açmak için tıklayın",
|
||||
"historyAria": "{brand} {model} — şase {vin}, geçmişten aç",
|
||||
"errorInvalidVin": "Geçersiz şase. 17 karakter olmalı, I, O, Q harfleri kullanılamaz.",
|
||||
"errorGeneric": "Bir hata oluştu. Lütfen tekrar deneyin.",
|
||||
@@ -738,6 +741,8 @@
|
||||
"noCategoriesTitle": "Bu araç için kategori bulunamadı",
|
||||
"noCategoriesHint": "Araç decode edildi ama parça kataloğu henüz hazırlanmamış olabilir. Destek ekibimize bildirirseniz katalog hızlandırılır.",
|
||||
"drillHint": "Parçalar alt kategorilerin içinde yer alır. Bir kategoriye girerek ilerleyin — her grup, içindeki parça listesine kadar açılır.",
|
||||
"reportIssue": "Çalışmadı mı? Bildir",
|
||||
"reportIssueDone": "Bildirildi — teşekkürler",
|
||||
"noAttrs": "Bu araç için ayrıntı bilgisi bulunamadı.",
|
||||
"labelModel": "Model",
|
||||
"labelYear": "Model yılı",
|
||||
@@ -770,7 +775,7 @@
|
||||
"meta": {
|
||||
"title": "Şase Numarası Sorgulama & OEM Parça Kataloğu | Sase.tr",
|
||||
"description": "Araç şase numarasını (VIN) girin, saniyeler içinde OEM parça kodlarına ulaşın. 27 marka, 1M+ parça. Yanlış sipariş yapmadan doğru parçayı bulun.",
|
||||
"schemaOrgDescription": "Türkiye'nin VIN/şase numarası sorgulama ve OEM yedek parça kataloğu platformu.",
|
||||
"schemaOrgDescription": "Türkiye'nin şase numarası sorgulama ve OEM yedek parça kataloğu platformu.",
|
||||
"schemaSoftwareDescription": "Araç şase numarası ile OEM yedek parça sorgulama platformu."
|
||||
},
|
||||
"nav": {
|
||||
@@ -798,7 +803,7 @@
|
||||
"subtitleA": "Müşteri aracının şasesini girin; çapraz katalog doğrulamasıyla doğru OEM parçasını ilk seferde bulun. İade yok, kayıp yok — 500+ işletme güveniyor. Ücretsiz deneyin.",
|
||||
"lossPillB": "Şase sorgulamaya ücretsiz başlayın — kart gerekmez",
|
||||
"titleB": "Müşteri Aracının Doğru Parçasını Saniyede Bulun",
|
||||
"subtitleB": "17 haneli şaseyi girin; doğru OEM kodlarına, parça şemalarına ve fiyatlara saniyeler içinde ulaşın. 27 marka, sınırsız sorgu. Ücretsiz başlayın, kredi kartı istemiyoruz.",
|
||||
"subtitleB": "17 haneli şaseyi girin; doğru OEM kodlarına, parça şemalarına saniyeler içinde ulaşın. 27 marka, sınırsız sorgu. Ücretsiz başlayın, kredi kartı istemiyoruz.",
|
||||
"vinPlaceholder": "Örnek: WVWZZZ1JZ3W597935",
|
||||
"searchCta": "Ara",
|
||||
"decoding": "Çözülüyor...",
|
||||
@@ -868,7 +873,7 @@
|
||||
"description": "Birden fazla katalogda çapraz sorgulama yaparak her zaman en güncel ve doğru OEM kodlarını sunar — yanlış parça, iade derdi tarih olur.",
|
||||
"bullet1": "Çoklu katalog çapraz sorgulama",
|
||||
"bullet2": "Her zaman güncel OEM kodları",
|
||||
"bullet3": "Fiyat karşılaştırma",
|
||||
"bullet3": "Patlamış parça şemasıyla eşleştirme",
|
||||
"mockupTitle": "OEM Katalog — Parça Listesi",
|
||||
"part1": "Klima Kompresörü",
|
||||
"part2": "Kalorifer Motoru",
|
||||
@@ -897,9 +902,9 @@
|
||||
"sase": "Çoklu katalog çapraz sorgu"
|
||||
},
|
||||
"3": {
|
||||
"label": "Fiyat karşılaştırma",
|
||||
"manual": "İmkansız",
|
||||
"sase": "Tek ekranda"
|
||||
"label": "İnteraktif parça şeması",
|
||||
"manual": "Kağıt katalog",
|
||||
"sase": "Tıklanabilir diyagram"
|
||||
},
|
||||
"4": {
|
||||
"label": "Aylık zaman tasarrufu",
|
||||
@@ -1047,14 +1052,14 @@
|
||||
"ecommerce": {
|
||||
"badge": "E-Ticaret Entegrasyonu",
|
||||
"title": "Kendi Sitenize Şase Çözme Gücü Katın",
|
||||
"subtitle": "Müşterileriniz yanlış parça sipariş edip iade mi açıyor? Sase.tr entegrasyonu ile VIN bazlı filtreleme ekleyin — iadeler düşsün, dönüşüm artsın.",
|
||||
"subtitle": "Müşterileriniz yanlış parça sipariş edip iade mi açıyor? Sase.tr entegrasyonu ile şase bazlı filtreleme ekleyin — iadeler düşsün, dönüşüm artsın.",
|
||||
"frameTitle": "otoyedekparca.co — Sase.tr Entegrasyonu",
|
||||
"loading": "Yükleniyor…",
|
||||
"easyBadge": "Kolay Entegrasyon",
|
||||
"cardTitle": "Birkaç Satır Kod, Büyük Fark",
|
||||
"cardBody": "E-ticaret sitenize Sase.tr'nin VIN çözme motorunu entegre edin. Müşterileriniz şase numarasını girsin — sadece araçlarına uygun parçalar listelensin.",
|
||||
"cardBody": "E-ticaret sitenize Sase.tr'nin şase çözme motorunu entegre edin. Müşterileriniz şase numarasını girsin — sadece araçlarına uygun parçalar listelensin.",
|
||||
"bullet1": "API & Widget — Birkaç satır kodla sitenize entegre edin",
|
||||
"bullet2": "VIN Bazlı Filtreleme — Sadece uyumlu parçalar görünsün",
|
||||
"bullet2": "Şase Bazlı Filtreleme — Sadece uyumlu parçalar görünsün",
|
||||
"bullet3": "İade Oranını Düşürün — Doğru parça, ilk seferde",
|
||||
"bullet4": "White-Label — Widget sitenizin tasarımına uyum sağlar",
|
||||
"cta": "İletişime Geçin",
|
||||
@@ -1090,7 +1095,7 @@
|
||||
"2": {
|
||||
"name": "Özge D.",
|
||||
"role": "E-Ticaret Yöneticisi",
|
||||
"text": "Online mağazamıza VIN entegrasyonu ekledik. Müşteriler şase numarasını giriyor, sadece uyumlu parçalar listeleniyor. İade oranımız %42 düştü."
|
||||
"text": "Online mağazamıza şase entegrasyonu ekledik. Müşteriler şase numarasını giriyor, sadece uyumlu parçalar listeleniyor. İade oranımız %42 düştü."
|
||||
},
|
||||
"3": {
|
||||
"name": "Burak T.",
|
||||
@@ -1113,7 +1118,7 @@
|
||||
"name": "1 Marka",
|
||||
"description": "Tek marka için erişim",
|
||||
"feature1": "1 marka seçimi",
|
||||
"feature2": "Sınırsız VIN arama",
|
||||
"feature2": "Sınırsız şase arama",
|
||||
"feature3": "Parça kataloğu",
|
||||
"feature4": "Şema görüntüleyici"
|
||||
},
|
||||
@@ -1121,7 +1126,7 @@
|
||||
"name": "2 Marka",
|
||||
"description": "İki farklı marka",
|
||||
"feature1": "2 marka seçimi",
|
||||
"feature2": "Sınırsız VIN arama",
|
||||
"feature2": "Sınırsız şase arama",
|
||||
"feature3": "Parça kataloğu",
|
||||
"feature4": "Öncelikli destek"
|
||||
},
|
||||
@@ -1129,7 +1134,7 @@
|
||||
"name": "3 Marka",
|
||||
"description": "Üç marka kapsamlı",
|
||||
"feature1": "3 marka seçimi",
|
||||
"feature2": "Sınırsız VIN arama",
|
||||
"feature2": "Sınırsız şase arama",
|
||||
"feature3": "Parça kataloğu",
|
||||
"feature4": "Öncelikli destek"
|
||||
}
|
||||
@@ -1144,7 +1149,7 @@
|
||||
"ctaFineprint": "Kart bilgisi gerekmez. İstediğin zaman iptal et.",
|
||||
"included": "Pakete dahil",
|
||||
"feature1": "Tüm 27 markaya erişim",
|
||||
"feature2": "Sınırsız VIN arama",
|
||||
"feature2": "Sınırsız şase arama",
|
||||
"feature3": "OEM parça kataloğu",
|
||||
"feature4": "İnteraktif şema görüntüleyici",
|
||||
"feature5": "Geçmiş sorgular & favoriler",
|
||||
@@ -1160,7 +1165,7 @@
|
||||
"items": {
|
||||
"0": {
|
||||
"q": "Hangi markaları destekliyorsunuz?",
|
||||
"a": "Şu an 27 otomobil markası destekleniyor ve liste sürekli genişliyor. Şase numaranızı arama kutusuna girerek aracınızın desteklenip desteklenmediğini hemen görebilirsiniz."
|
||||
"a": "Şu an 27 otomobil markası destekleniyor ve liste sürekli genişliyor. Şase numarasını arama kutusuna girerek bir aracın desteklenip desteklenmediğini hemen görebilirsiniz."
|
||||
},
|
||||
"1": {
|
||||
"q": "Verilerim güvende mi?",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,5 @@
|
||||
import { InAppEscape } from "@/components/in-app-escape";
|
||||
import { SurveyPopover } from "@/components/survey-popover";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { resetChatwootUser, setChatwootUser } from "@/lib/chatwoot";
|
||||
@@ -156,6 +157,7 @@ function RootComponent() {
|
||||
</a>
|
||||
<InAppEscape />
|
||||
<Outlet />
|
||||
<SurveyPopover />
|
||||
<Toaster position="top-center" />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { usePageMeta } from "@/hooks/use-page-meta";
|
||||
import { SiteHeader } from "@/components/site-header";
|
||||
import { usePageMeta } from "@/hooks/use-page-meta";
|
||||
import { Button } from "@sase/ui";
|
||||
import { Link, createFileRoute } from "@tanstack/react-router";
|
||||
|
||||
@@ -11,7 +11,7 @@ function AboutPage() {
|
||||
usePageMeta({
|
||||
title: "Hakkımızda — Sase.tr | Türkiye'nin Şase Sorgulama Platformu",
|
||||
description:
|
||||
"Sase.tr, Türkiye yedek parça sektörüne yönelik VIN/şase numarası sorgulama ve OEM parça kataloğu platformudur.",
|
||||
"Sase.tr, Türkiye yedek parça sektörüne yönelik şase numarası sorgulama ve OEM parça kataloğu platformudur.",
|
||||
canonical: "https://sase.tr/about",
|
||||
});
|
||||
|
||||
@@ -25,21 +25,21 @@ function AboutPage() {
|
||||
<div className="mt-8 space-y-6 text-muted-foreground leading-relaxed">
|
||||
<p>
|
||||
Sase.tr, Türkiye'nin yedek parça sektörüne yönelik geliştirilen dijital bir platformdur.
|
||||
Şase numarası (VIN) ile araç tanımlama, orijinal parça kataloğuna erişim ve interaktif
|
||||
şema görüntüleme hizmetlerini tek bir çatı altında sunar.
|
||||
Şase numarası ile araç tanımlama, orijinal parça kataloğuna erişim ve interaktif şema
|
||||
görüntüleme hizmetlerini tek bir çatı altında sunar.
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold text-foreground">Misyonumuz</h2>
|
||||
<p>
|
||||
Yedek parça arama sürecini hızlandırmak, doğru parçaya ilk seferde ulaşmayı sağlamak ve
|
||||
sektördeki bilgi asimetrisini ortadan kaldırmak. Oto yedek parçacılar, servisler ve
|
||||
bireysel kullanıcılar için güvenilir bir referans noktası olmayı hedefliyoruz.
|
||||
sektördeki bilgi asimetrisini ortadan kaldırmak. Oto yedek parçacılar ve servisler için
|
||||
güvenilir bir referans noktası olmayı hedefliyoruz.
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold text-foreground">Ne Yapıyoruz?</h2>
|
||||
<ul className="list-disc space-y-2 pl-6">
|
||||
<li>
|
||||
<strong>Şase Çözme:</strong> VIN numarası ile aracın marka, model, yıl, motor tipi ve
|
||||
<strong>Şase Çözme:</strong> Şase numarası ile aracın marka, model, yıl, motor tipi ve
|
||||
donanım bilgilerine anında erişim.
|
||||
</li>
|
||||
<li>
|
||||
|
||||
@@ -1,61 +1,18 @@
|
||||
import { BlogListContent } from "@/components/blog-content";
|
||||
import { SiteHeader } from "@/components/site-header";
|
||||
import { useBlogPosts } from "@/hooks/use-blog";
|
||||
import { usePageMeta } from "@/hooks/use-page-meta";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@sase/ui";
|
||||
import { Link, createFileRoute } from "@tanstack/react-router";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/blog")({
|
||||
component: BlogPage,
|
||||
});
|
||||
|
||||
function BlogPage() {
|
||||
usePageMeta({
|
||||
title: "Blog — Sase.tr | Şase & Yedek Parça Rehberi",
|
||||
description: "Şase numarası okuma, OEM vs muadil parça, dijital dönüşüm ve daha fazlası.",
|
||||
canonical: "https://sase.tr/blog",
|
||||
});
|
||||
|
||||
// All posts come from the central Directus CMS through the API.
|
||||
const { data: apiPosts, isLoading } = useBlogPosts();
|
||||
|
||||
const posts = (apiPosts ?? [])
|
||||
.map((p) => ({
|
||||
slug: p.slug,
|
||||
title: p.title,
|
||||
description: p.metaDescription ?? "",
|
||||
date: p.publishedAt.slice(0, 10),
|
||||
}))
|
||||
.sort((a, b) => b.date.localeCompare(a.date));
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<SiteHeader />
|
||||
|
||||
<main className="container mx-auto max-w-4xl px-4 py-16">
|
||||
<h1 className="text-4xl font-bold">Blog</h1>
|
||||
<p className="mt-4 text-lg text-muted-foreground">
|
||||
Yedek parça sektörü, araç bakımı ve Sase.tr hakkında güncel yazılar.
|
||||
</p>
|
||||
|
||||
{isLoading && posts.length === 0 && (
|
||||
<p className="mt-12 text-muted-foreground">Yükleniyor…</p>
|
||||
)}
|
||||
|
||||
<div className="mt-12 grid gap-6 sm:grid-cols-2">
|
||||
{posts.map((post) => (
|
||||
<Link key={post.slug} to="/blog/$slug" params={{ slug: post.slug }}>
|
||||
<Card className="h-full transition hover:border-foreground/20">
|
||||
<CardHeader>
|
||||
<CardDescription>{post.date}</CardDescription>
|
||||
<CardTitle className="text-lg">{post.title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">{post.description}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
<BlogListContent postLinkTo="/blog/$slug" />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,42 +1,6 @@
|
||||
import { BlogPostContent } from "@/components/blog-content";
|
||||
import { SiteHeader } from "@/components/site-header";
|
||||
import { useBlogPost } from "@/hooks/use-blog";
|
||||
import { usePageMeta } from "@/hooks/use-page-meta";
|
||||
import { Link, createFileRoute } from "@tanstack/react-router";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
|
||||
// ─── MARKDOWN RENDERING ───────────────────────────────────────────────────────
|
||||
// Posts live in the central Directus CMS and arrive as markdown via the API.
|
||||
|
||||
const mdComponents = {
|
||||
h2: (props: React.ComponentProps<"h2">) => (
|
||||
<h2 className="text-xl font-semibold text-foreground" {...props} />
|
||||
),
|
||||
h3: (props: React.ComponentProps<"h3">) => (
|
||||
<h3 className="text-lg font-semibold text-foreground" {...props} />
|
||||
),
|
||||
ul: (props: React.ComponentProps<"ul">) => <ul className="list-disc space-y-2 pl-6" {...props} />,
|
||||
ol: (props: React.ComponentProps<"ol">) => (
|
||||
<ol className="list-decimal space-y-2 pl-6" {...props} />
|
||||
),
|
||||
strong: (props: React.ComponentProps<"strong">) => (
|
||||
<strong className="text-foreground" {...props} />
|
||||
),
|
||||
a: (props: React.ComponentProps<"a">) => <a className="text-foreground underline" {...props} />,
|
||||
};
|
||||
|
||||
function MarkdownBody({ markdown }: { markdown: string }) {
|
||||
return (
|
||||
<div className="space-y-6 leading-relaxed text-muted-foreground">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={mdComponents}>
|
||||
{markdown}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── ROUTE ───────────────────────────────────────────────────────────────────
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/blog_/$slug")({
|
||||
component: BlogPostPage,
|
||||
@@ -44,65 +8,13 @@ export const Route = createFileRoute("/blog_/$slug")({
|
||||
|
||||
function BlogPostPage() {
|
||||
const { slug } = Route.useParams();
|
||||
const { data: post, isLoading, isError } = useBlogPost(slug);
|
||||
|
||||
const title = post?.title ?? "Blog";
|
||||
const description = post?.metaDescription ?? "";
|
||||
const date = post?.publishedAt?.slice(0, 10) ?? "";
|
||||
|
||||
usePageMeta({
|
||||
title: `${title} | Sase.tr Blog`,
|
||||
description,
|
||||
canonical: `https://sase.tr/blog/${slug}`,
|
||||
});
|
||||
|
||||
let body: React.ReactNode;
|
||||
if (post) body = <MarkdownBody markdown={post.bodyMarkdown} />;
|
||||
else if (isLoading) body = <p className="text-muted-foreground">Yükleniyor…</p>;
|
||||
else
|
||||
body = (
|
||||
<p className="text-muted-foreground">{isError ? "Yazı bulunamadı." : "Yazı yüklenemedi."}</p>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<SiteHeader />
|
||||
|
||||
<main className="container mx-auto max-w-3xl px-4 py-16">
|
||||
{/* Breadcrumb */}
|
||||
<nav className="mb-8 flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
<Link to="/blog" className="transition hover:text-foreground">
|
||||
Blog
|
||||
</Link>
|
||||
<ChevronRight className="size-3.5" />
|
||||
<span className="text-foreground">{title}</span>
|
||||
</nav>
|
||||
|
||||
<article>
|
||||
{date && <time className="text-sm text-muted-foreground">{date}</time>}
|
||||
<h1 className="mt-3 text-3xl font-bold leading-tight sm:text-4xl">{title}</h1>
|
||||
{description && <p className="mt-4 text-lg text-muted-foreground">{description}</p>}
|
||||
|
||||
{post?.coverImage && (
|
||||
<img
|
||||
src={post.coverImage}
|
||||
alt={title}
|
||||
className="mt-8 w-full rounded-lg border object-cover"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="mt-10">{body}</div>
|
||||
</article>
|
||||
|
||||
{/* Back link */}
|
||||
<div className="mt-16 border-t pt-8">
|
||||
<Link
|
||||
to="/blog"
|
||||
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground transition hover:text-foreground"
|
||||
>
|
||||
← Tüm yazılar
|
||||
</Link>
|
||||
</div>
|
||||
<BlogPostContent slug={slug} listLinkTo="/blog" />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,230 +1,18 @@
|
||||
import { ContactContent } from "@/components/contact-content";
|
||||
import { SiteHeader } from "@/components/site-header";
|
||||
import { Turnstile, type TurnstileHandle } from "@/components/turnstile";
|
||||
import { usePageMeta } from "@/hooks/use-page-meta";
|
||||
import { ApiError, api } from "@/lib/api-client";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { Button, Card, CardContent, CardHeader, CardTitle, Input, Label } from "@sase/ui";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { type FormEvent, useRef, useState } from "react";
|
||||
import { z } from "zod";
|
||||
|
||||
export const Route = createFileRoute("/contact")({
|
||||
component: ContactPage,
|
||||
});
|
||||
|
||||
// Backend (POST /contact) ile aynı kurallar
|
||||
const contactSchema = z.object({
|
||||
name: z.string().trim().min(2, "Ad en az 2 karakter olmalı").max(100, "Ad çok uzun"),
|
||||
email: z.string().trim().email("Geçerli bir e-posta adresi girin").max(200),
|
||||
subject: z.string().trim().max(200, "Konu çok uzun").optional(),
|
||||
message: z.string().trim().min(10, "Mesaj en az 10 karakter olmalı").max(5000, "Mesaj çok uzun"),
|
||||
});
|
||||
|
||||
type FieldErrors = Partial<Record<"name" | "email" | "subject" | "message", string>>;
|
||||
|
||||
const textareaClass =
|
||||
"flex min-h-[140px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50";
|
||||
|
||||
function ContactForm() {
|
||||
const [name, setName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [subject, setSubject] = useState("");
|
||||
const [message, setMessage] = useState("");
|
||||
const [errors, setErrors] = useState<FieldErrors>({});
|
||||
const [captchaToken, setCaptchaToken] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const turnstileRef = useRef<TurnstileHandle>(null);
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
const parsed = contactSchema.safeParse({ name, email, subject, message });
|
||||
if (!parsed.success) {
|
||||
const next: FieldErrors = {};
|
||||
for (const issue of parsed.error.issues) {
|
||||
const key = issue.path[0] as keyof FieldErrors;
|
||||
if (key && !next[key]) next[key] = issue.message;
|
||||
}
|
||||
setErrors(next);
|
||||
return;
|
||||
}
|
||||
setErrors({});
|
||||
setLoading(true);
|
||||
try {
|
||||
await api.post("/contact", { ...parsed.data, turnstileToken: captchaToken });
|
||||
toast.success("Mesajınız gönderildi", {
|
||||
description: "En kısa sürede size dönüş yapacağız.",
|
||||
});
|
||||
setName("");
|
||||
setEmail("");
|
||||
setSubject("");
|
||||
setMessage("");
|
||||
turnstileRef.current?.reset();
|
||||
setCaptchaToken("");
|
||||
} catch (err) {
|
||||
// Token tek kullanımlık — başarısız denemeden sonra widget'ı sıfırla
|
||||
turnstileRef.current?.reset();
|
||||
setCaptchaToken("");
|
||||
const msg =
|
||||
err instanceof ApiError ? err.message : "Mesaj gönderilemedi. Lütfen tekrar deneyin.";
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Bize yazın</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4" noValidate>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="contact-name">Ad Soyad</Label>
|
||||
<Input
|
||||
id="contact-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Adınız"
|
||||
aria-invalid={!!errors.name}
|
||||
disabled={loading}
|
||||
/>
|
||||
{errors.name && <p className="text-sm text-destructive">{errors.name}</p>}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="contact-email">E-posta</Label>
|
||||
<Input
|
||||
id="contact-email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="ornek@eposta.com"
|
||||
aria-invalid={!!errors.email}
|
||||
disabled={loading}
|
||||
/>
|
||||
{errors.email && <p className="text-sm text-destructive">{errors.email}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="contact-subject">Konu (opsiyonel)</Label>
|
||||
<Input
|
||||
id="contact-subject"
|
||||
value={subject}
|
||||
onChange={(e) => setSubject(e.target.value)}
|
||||
placeholder="Mesajınızın konusu"
|
||||
aria-invalid={!!errors.subject}
|
||||
disabled={loading}
|
||||
/>
|
||||
{errors.subject && <p className="text-sm text-destructive">{errors.subject}</p>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="contact-message">Mesaj</Label>
|
||||
<textarea
|
||||
id="contact-message"
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
placeholder="Size nasıl yardımcı olabiliriz?"
|
||||
aria-invalid={!!errors.message}
|
||||
disabled={loading}
|
||||
className={textareaClass}
|
||||
/>
|
||||
{errors.message && <p className="text-sm text-destructive">{errors.message}</p>}
|
||||
</div>
|
||||
|
||||
<Turnstile
|
||||
ref={turnstileRef}
|
||||
onVerify={setCaptchaToken}
|
||||
onExpire={() => setCaptchaToken("")}
|
||||
/>
|
||||
|
||||
<Button type="submit" disabled={loading} className="w-full sm:w-auto">
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||
Gönderiliyor…
|
||||
</>
|
||||
) : (
|
||||
"Gönder"
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function ContactPage() {
|
||||
usePageMeta({
|
||||
title: "İletişim — Sase.tr",
|
||||
description: "Sase.tr destek ve iletişim — sorularınız için bize ulaşın.",
|
||||
canonical: "https://sase.tr/contact",
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<SiteHeader />
|
||||
|
||||
<main className="container mx-auto max-w-5xl px-4 py-16">
|
||||
<h1 className="text-4xl font-bold">İletişim</h1>
|
||||
<p className="mt-4 text-lg text-muted-foreground">
|
||||
Sorularınız, önerileriniz veya iş birliği talepleriniz için bize ulaşın.
|
||||
</p>
|
||||
|
||||
<div className="mt-12 grid gap-6 lg:grid-cols-5">
|
||||
{/* İletişim formu */}
|
||||
<div className="lg:col-span-3">
|
||||
<ContactForm />
|
||||
</div>
|
||||
|
||||
{/* İletişim bilgileri */}
|
||||
<div className="space-y-6 lg:col-span-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Destek</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<a href="mailto:destek@sase.tr" className="text-primary underline">
|
||||
destek@sase.tr
|
||||
</a>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Teknik sorunlar ve hesap işlemleri için.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Adres</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-1 text-sm text-muted-foreground">
|
||||
<p>
|
||||
<span className="text-foreground">Firma Adı:</span> THINXTRA LLC
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-foreground">Adres:</span> 1209 Mountain Road PL NE #11131
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-foreground">Şehir:</span> Albuquerque, NM 87110
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-foreground">Ülke:</span> United States
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-foreground">Vergi No:</span> 36-5177177
|
||||
</p>
|
||||
</div>
|
||||
<p className="mt-3 text-sm text-muted-foreground">
|
||||
Çalışma saatleri: Pazartesi – Cuma, 09:00 – 18:00
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
<ContactContent />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { LanguageSwitcher } from "@/components/language-switcher";
|
||||
import { SiteFooter } from "@/components/site-footer";
|
||||
import { TrialUrgencyBanner } from "@/components/trial-urgency-banner";
|
||||
import { TrialValueUpsell } from "@/components/trial-value-upsell";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
@@ -81,8 +82,8 @@ const accountItems: readonly NavItem[] = [
|
||||
|
||||
const supportItems: readonly NavItem[] = [
|
||||
{ to: "/dashboard/changelog", labelKey: "nav.changelog", icon: CalendarDays },
|
||||
{ to: "/contact", labelKey: "nav.contact", icon: Mail },
|
||||
{ to: "/blog", labelKey: "nav.blog", icon: BookOpen },
|
||||
{ to: "/dashboard/contact", labelKey: "nav.contact", icon: Mail },
|
||||
{ to: "/dashboard/blog", labelKey: "nav.blog", icon: BookOpen },
|
||||
];
|
||||
|
||||
const adminItems: readonly NavItem[] = [
|
||||
@@ -509,6 +510,7 @@ function DashboardLayout() {
|
||||
{/* Page Content */}
|
||||
<main id="main-content" className="flex-1 overflow-auto bg-muted/30">
|
||||
<TrialUrgencyBanner />
|
||||
<TrialValueUpsell />
|
||||
<div className="p-4 sm:p-6">
|
||||
<Outlet />
|
||||
</div>
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Button } from "@sase/ui";
|
||||
import { Skeleton } from "@sase/ui";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link, createFileRoute } from "@tanstack/react-router";
|
||||
import { ArrowRight, Download, Filter, HelpCircle, Receipt, ReceiptText } from "lucide-react";
|
||||
import { ArrowRight, Filter, HelpCircle, Receipt, ReceiptText } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
export const Route = createFileRoute("/dashboard/billing")({
|
||||
@@ -26,10 +26,11 @@ interface Subscription {
|
||||
interface Payment {
|
||||
id: string;
|
||||
amount: number;
|
||||
method: "stripe" | "eft";
|
||||
// Stripe is the sole method; kept as string so retired historical values
|
||||
// (iyzico/eft) still render in the history table.
|
||||
method: string;
|
||||
status: "completed" | "pending" | "failed" | "refunded";
|
||||
planName?: string | null;
|
||||
eftReceiptUrl?: string | null;
|
||||
hasStripeReceipt?: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
@@ -213,7 +214,7 @@ function BillingPage() {
|
||||
onChange={setMethodFilter}
|
||||
options={[
|
||||
{ value: "all", label: t("common.all") },
|
||||
...["stripe", "eft"].map((m) => ({
|
||||
...["stripe"].map((m) => ({
|
||||
value: m,
|
||||
label: t(`billing.methodLabels.${m}`),
|
||||
})),
|
||||
@@ -309,7 +310,9 @@ function BillingPage() {
|
||||
</td>
|
||||
<td data-label={t("billing.method")} className={`${TD} sm:text-center`}>
|
||||
<Badge variant={payment.method === "stripe" ? "secondary" : "outline"}>
|
||||
{t(`billing.methodLabels.${payment.method}`)}
|
||||
{payment.method === "stripe"
|
||||
? t("billing.methodLabels.stripe")
|
||||
: payment.method.toUpperCase()}
|
||||
</Badge>
|
||||
</td>
|
||||
<td data-label={t("billing.status")} className={`${TD} sm:text-center`}>
|
||||
@@ -329,19 +332,7 @@ function BillingPage() {
|
||||
</span>
|
||||
</td>
|
||||
<td className="block pt-3 text-right sm:table-cell sm:py-4 sm:pt-4">
|
||||
{payment.eftReceiptUrl ? (
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<a
|
||||
href={payment.eftReceiptUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
download
|
||||
>
|
||||
<Download className="mr-1 h-3 w-3" />
|
||||
{t("billing.downloadReceipt")}
|
||||
</a>
|
||||
</Button>
|
||||
) : payment.hasStripeReceipt ? (
|
||||
{payment.hasStripeReceipt ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
|
||||
14
apps/web/src/routes/dashboard/blog.tsx
Normal file
14
apps/web/src/routes/dashboard/blog.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { BlogListContent } from "@/components/blog-content";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/dashboard/blog")({
|
||||
component: DashboardBlogPage,
|
||||
});
|
||||
|
||||
function DashboardBlogPage() {
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<BlogListContent postLinkTo="/dashboard/blog/$slug" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
16
apps/web/src/routes/dashboard/blog_/$slug.tsx
Normal file
16
apps/web/src/routes/dashboard/blog_/$slug.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { BlogPostContent } from "@/components/blog-content";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/dashboard/blog_/$slug")({
|
||||
component: DashboardBlogPostPage,
|
||||
});
|
||||
|
||||
function DashboardBlogPostPage() {
|
||||
const { slug } = Route.useParams();
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl">
|
||||
<BlogPostContent slug={slug} listLinkTo="/dashboard/blog" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
14
apps/web/src/routes/dashboard/contact.tsx
Normal file
14
apps/web/src/routes/dashboard/contact.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { ContactContent } from "@/components/contact-content";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/dashboard/contact")({
|
||||
component: DashboardContactPage,
|
||||
});
|
||||
|
||||
function DashboardContactPage() {
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl">
|
||||
<ContactContent />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
373
apps/web/src/routes/dashboard/oem.$code.tsx
Normal file
373
apps/web/src/routes/dashboard/oem.$code.tsx
Normal file
@@ -0,0 +1,373 @@
|
||||
import { api } from "@/lib/api-client";
|
||||
import { capture } from "@/lib/posthog";
|
||||
import { Badge, Button, Input, Skeleton } from "@sase/ui";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link, createFileRoute } from "@tanstack/react-router";
|
||||
import {
|
||||
AlertCircle,
|
||||
ArrowLeft,
|
||||
Check,
|
||||
ChevronRight,
|
||||
Copy,
|
||||
ImageOff,
|
||||
PackageSearch,
|
||||
Search,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
// ─── Types (mirror apps/api POemResult) ────────────────────────────────
|
||||
|
||||
interface OeNumber {
|
||||
brand: string;
|
||||
code: string;
|
||||
}
|
||||
interface PArticle {
|
||||
id: string;
|
||||
brand: string;
|
||||
articleNumber: string;
|
||||
name: string | null;
|
||||
spareInfo: string | null;
|
||||
images: Array<{ url: string; thumb: string | null }>;
|
||||
}
|
||||
interface POemResult {
|
||||
query: string;
|
||||
queryNorm: string;
|
||||
matched: boolean;
|
||||
articles: PArticle[];
|
||||
aftermarketParts: Array<{ brand: string; articleNumber: string; thumb: string | null }>;
|
||||
oeCrossReferences: OeNumber[];
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
/** A decoded vehicle in the user's own catalog whose parts include this OEM
|
||||
* code (reverse catalog — sase data, independent of the P snapshot). */
|
||||
interface CatalogVehicle {
|
||||
vehicleId: string;
|
||||
brandName: string | null;
|
||||
model: string | null;
|
||||
year: number | null;
|
||||
categoryId: string;
|
||||
occurrences: number;
|
||||
}
|
||||
|
||||
/** Normalise a code for search: strip non-alphanumerics + uppercase, so a query
|
||||
* like "1j0 973" matches a stored "1J0 973 702". */
|
||||
const normCode = (s: string) => s.toUpperCase().replace(/[^A-Z0-9]/g, "");
|
||||
|
||||
// ─── Reusable copy-to-clipboard chip ────────────────────────────────────────
|
||||
|
||||
function CopyCode({ code, className }: { code: string; className?: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const timer = useRef<number | null>(null);
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (timer.current) window.clearTimeout(timer.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(code);
|
||||
setCopied(true);
|
||||
if (timer.current) window.clearTimeout(timer.current);
|
||||
timer.current = window.setTimeout(() => setCopied(false), 1500);
|
||||
capture("oem_xref_code_copied", { code });
|
||||
}}
|
||||
title="Kopyala"
|
||||
className={`group inline-flex items-center gap-1.5 font-mono text-xs ${className ?? ""}`}
|
||||
>
|
||||
<span>{code}</span>
|
||||
{copied ? (
|
||||
<Check className="size-3 text-green-500" />
|
||||
) : (
|
||||
<Copy className="size-3 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Image with graceful fallback ───────────────────────────────────────────
|
||||
|
||||
function PartThumb({ src, alt }: { src: string | null; alt: string }) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
if (!src || failed) {
|
||||
return (
|
||||
<div className="flex size-16 shrink-0 items-center justify-center rounded-lg bg-muted">
|
||||
<ImageOff className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
loading="lazy"
|
||||
onError={() => setFailed(true)}
|
||||
className="size-16 shrink-0 rounded-lg border border-border bg-white object-contain"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Route ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export const Route = createFileRoute("/dashboard/oem/$code")({
|
||||
component: OemDetailPage,
|
||||
});
|
||||
|
||||
function OemDetailPage() {
|
||||
const { code } = Route.useParams();
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ["p-oem", code],
|
||||
queryFn: () => api.get<POemResult>(`/p/oem?code=${encodeURIComponent(code)}`),
|
||||
});
|
||||
|
||||
// Reverse catalog: the user's own vehicles that use this OEM code (sase data).
|
||||
const { data: catalogVehicles } = useQuery({
|
||||
queryKey: ["oem-vehicles", code],
|
||||
queryFn: () =>
|
||||
api.get<CatalogVehicle[]>(`/parts/oem-vehicles?code=${encodeURIComponent(code)}`),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
capture("oem_detail_viewed", {
|
||||
oem_code: code,
|
||||
matched: data.matched,
|
||||
article_count: data.articles.length,
|
||||
aftermarket_count: data.aftermarketParts.length,
|
||||
oe_xref_count: data.oeCrossReferences.length,
|
||||
});
|
||||
}
|
||||
}, [data, code]);
|
||||
|
||||
// ─── Brand/code filter ──────────────────────────────────────────────────
|
||||
const [q, setQ] = useState("");
|
||||
const ql = q.trim().toLowerCase();
|
||||
const qn = normCode(q.trim());
|
||||
const hit = useCallback(
|
||||
(brand: string, codeVal: string) =>
|
||||
!ql || brand.toLowerCase().includes(ql) || (qn.length > 0 && normCode(codeVal).includes(qn)),
|
||||
[ql, qn],
|
||||
);
|
||||
|
||||
const filteredArticles = useMemo(
|
||||
() => (data?.matched ? data.articles.filter((a) => hit(a.brand, a.articleNumber)) : []),
|
||||
[data, hit],
|
||||
);
|
||||
const filteredAftermarket = useMemo(
|
||||
() => (data?.matched ? data.aftermarketParts.filter((p) => hit(p.brand, p.articleNumber)) : []),
|
||||
[data, hit],
|
||||
);
|
||||
const filteredOe = useMemo(
|
||||
() => (data?.matched ? data.oeCrossReferences.filter((oe) => hit(oe.brand, oe.code)) : []),
|
||||
[data, hit],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl space-y-6">
|
||||
{/* ─── Header ─────────────────────────────────────────────────────── */}
|
||||
<header className="flex items-start gap-4">
|
||||
<Button asChild variant="ghost" size="icon" className="mt-0.5 shrink-0">
|
||||
<Link to="/dashboard/search" aria-label="Geri">
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
</Button>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
OEM kodu
|
||||
</p>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-3">
|
||||
<h1 className="font-mono text-2xl font-bold tracking-tight break-all">{code}</h1>
|
||||
<CopyCode
|
||||
code={code}
|
||||
className="rounded-md border border-border px-2 py-1 hover:bg-accent"
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Uyumlu parça kodları ve muadil numaralar
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* ─── Loading ────────────────────────────────────────────────────── */}
|
||||
{isLoading && (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-24 w-full rounded-xl" />
|
||||
<Skeleton className="h-40 w-full rounded-xl" />
|
||||
<Skeleton className="h-28 w-full rounded-xl" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ─── Error ──────────────────────────────────────────────────────── */}
|
||||
{error && (
|
||||
<div className="flex items-start gap-3 rounded-xl border border-destructive/40 bg-destructive/10 p-4">
|
||||
<AlertCircle className="mt-0.5 size-5 shrink-0 text-destructive" />
|
||||
<p className="text-sm text-destructive">
|
||||
Uyumlu parça bilgisi yüklenemedi. Lütfen tekrar deneyin.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ─── Empty state (no P match) ──────────────────────────────── */}
|
||||
{data && !data.matched && (
|
||||
<div className="flex flex-col items-center gap-3 rounded-2xl border border-border bg-background px-6 py-12 text-center">
|
||||
<div className="flex size-12 items-center justify-center rounded-xl bg-muted">
|
||||
<PackageSearch className="size-6 text-muted-foreground" />
|
||||
</div>
|
||||
<p className="font-medium">Bu OEM kodu için uyumlu parça bulunamadı</p>
|
||||
<p className="max-w-md text-sm text-muted-foreground">
|
||||
Bağlantı parçaları, klipsler ve bazı orijinal kodların muadili henüz kataloğumuzda
|
||||
olmayabilir. Katalog büyüdükçe eşleşme oranı artar.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ─── Results ────────────────────────────────────────────────────── */}
|
||||
{data?.matched && (
|
||||
<div className="space-y-6">
|
||||
{/* Summary */}
|
||||
<div className="flex flex-wrap gap-2 text-sm">
|
||||
<Badge variant="secondary">{data.articles.length} eşleşen parça</Badge>
|
||||
<Badge variant="secondary">{data.aftermarketParts.length} yan sanayi numarası</Badge>
|
||||
<Badge variant="secondary">{data.oeCrossReferences.length} muadil OE kodu</Badge>
|
||||
{data.truncated && (
|
||||
<Badge variant="outline" className="text-muted-foreground">
|
||||
liste kısaltıldı
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filter by brand or code (matches across all sections) */}
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
placeholder="Marka veya kod ara…"
|
||||
className="pl-9 pr-9"
|
||||
/>
|
||||
{q && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setQ("")}
|
||||
aria-label="Aramayı temizle"
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 rounded p-0.5 text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Matched P articles (highest-confidence: carry this OEM directly) */}
|
||||
{filteredArticles.length > 0 && (
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-sm font-semibold">Bu OEM'i taşıyan parçalar</h2>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{filteredArticles.map((a) => (
|
||||
<div
|
||||
key={a.id}
|
||||
className="flex gap-3 rounded-xl border border-border bg-background p-3"
|
||||
>
|
||||
{a.images[0] && (
|
||||
<PartThumb src={a.images[0].thumb ?? a.images[0].url} alt={a.brand} />
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-semibold">{a.brand}</p>
|
||||
<CopyCode code={a.articleNumber} className="mt-0.5" />
|
||||
{a.name && a.name !== a.articleNumber && (
|
||||
<p className="mt-0.5 truncate text-xs text-muted-foreground">{a.name}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Aftermarket equivalents (buyable substitutes across supplier brands) */}
|
||||
{filteredAftermarket.length > 0 && (
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-sm font-semibold">Yan sanayi muadilleri</h2>
|
||||
<div className="overflow-hidden rounded-xl border border-border">
|
||||
<table className="w-full text-sm">
|
||||
<tbody className="divide-y divide-border">
|
||||
{filteredAftermarket.map((p) => (
|
||||
<tr key={`${p.brand}-${p.articleNumber}`} className="hover:bg-accent/50">
|
||||
<td className="px-4 py-2 font-medium">{p.brand}</td>
|
||||
<td className="px-4 py-2 text-right">
|
||||
<CopyCode code={p.articleNumber} className="hover:text-foreground" />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* OE cross-references (same physical part, other vehicle makes) */}
|
||||
{filteredOe.length > 0 && (
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-sm font-semibold">Muadil orijinal (OE) kodları</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{filteredOe.map((oe) => (
|
||||
<span
|
||||
key={`${oe.brand}-${oe.code}`}
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-border bg-background px-2.5 py-1.5"
|
||||
>
|
||||
<span className="text-xs font-medium text-muted-foreground">{oe.brand}</span>
|
||||
<CopyCode code={oe.code} className="hover:text-foreground" />
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Nothing matches the active filter */}
|
||||
{ql &&
|
||||
filteredArticles.length === 0 &&
|
||||
filteredAftermarket.length === 0 &&
|
||||
filteredOe.length === 0 && (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||
"{q}" ile eşleşen marka veya kod yok.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ─── Reverse catalog: your vehicles that use this code ───────────── */}
|
||||
{catalogVehicles && catalogVehicles.length > 0 && (
|
||||
<section className="space-y-3">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold">Bu kod kataloğunuzda</h2>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
Bu OEM kodunun geçtiği araçlarınız
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{catalogVehicles.map((v) => (
|
||||
<Link
|
||||
key={v.vehicleId}
|
||||
to="/dashboard/vehicles/$id/categories/$categoryId"
|
||||
params={{ id: v.vehicleId, categoryId: v.categoryId }}
|
||||
className="flex items-center justify-between gap-2 rounded-xl border border-border bg-background p-3 transition-colors hover:bg-accent"
|
||||
>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-sm font-medium">
|
||||
{[v.brandName, v.model].filter(Boolean).join(" ") || "Araç"}
|
||||
</span>
|
||||
{v.year && <span className="text-xs text-muted-foreground">{v.year}</span>}
|
||||
</span>
|
||||
<ChevronRight className="size-4 shrink-0 text-muted-foreground" />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -142,10 +142,10 @@ function SearchPage() {
|
||||
const pendingVinRef = useRef<string | null>(null);
|
||||
const didInitFromUrlRef = useRef(false);
|
||||
|
||||
// Vehicle candidate selection (PartsCatalogs or EMEX multi-result)
|
||||
// Vehicle candidate selection (multi-result decode). Candidates carry only
|
||||
// display fields + an opaque key; the decode source stays server-side.
|
||||
const [candidates, setCandidates] = useState<any[] | null>(null);
|
||||
const [candidateVin, setCandidateVin] = useState("");
|
||||
const [candidateSource, setCandidateSource] = useState<"parts-catalogs" | "emex" | null>(null);
|
||||
const [selectLoading, setSelectLoading] = useState(false);
|
||||
|
||||
// Live preview state
|
||||
@@ -285,12 +285,10 @@ function SearchPage() {
|
||||
if (data.candidates && Array.isArray(data.candidates)) {
|
||||
setCandidates(data.candidates);
|
||||
setCandidateVin(cleanVin);
|
||||
setCandidateSource(data.source ?? "parts-catalogs");
|
||||
candidatesShownAtRef.current = performance.now();
|
||||
capture("vin_decode_candidates", {
|
||||
vin: cleanVin,
|
||||
count: data.candidates.length,
|
||||
source: data.source,
|
||||
response_time_ms: responseTimeMs,
|
||||
query_source: querySourceRef.current,
|
||||
});
|
||||
@@ -301,7 +299,6 @@ function SearchPage() {
|
||||
vin: cleanVin,
|
||||
vehicle_id: data.id,
|
||||
response_time_ms: responseTimeMs,
|
||||
source: data.source ?? null,
|
||||
query_source: querySourceRef.current,
|
||||
});
|
||||
navigate({ to: "/dashboard/vehicles/$id", params: { id: data.id } });
|
||||
@@ -461,16 +458,14 @@ function SearchPage() {
|
||||
? Math.round(performance.now() - candidatesShownAtRef.current)
|
||||
: null;
|
||||
try {
|
||||
const payload: Record<string, unknown> = { vin: candidateVin };
|
||||
if (candidateSource === "emex") {
|
||||
payload.emexCarIndex = Number.parseInt(carId, 10);
|
||||
} else {
|
||||
payload.pcatCarId = carId;
|
||||
}
|
||||
const data = await api.post<any>("/vehicles/decode", payload);
|
||||
// The carId is the opaque candidate key from the decode response — the
|
||||
// server maps it back to the provider-specific selection.
|
||||
const data = await api.post<any>("/vehicles/decode", {
|
||||
vin: candidateVin,
|
||||
candidate: carId,
|
||||
});
|
||||
capture("vin_decode_candidate_selected", {
|
||||
vin: candidateVin,
|
||||
source: candidateSource,
|
||||
carId,
|
||||
vehicle_id: data.id,
|
||||
selected_index: selectedIndex,
|
||||
@@ -479,7 +474,6 @@ function SearchPage() {
|
||||
});
|
||||
candidatesShownAtRef.current = null;
|
||||
setCandidates(null);
|
||||
setCandidateSource(null);
|
||||
navigate({
|
||||
to: "/dashboard/vehicles/$id",
|
||||
params: { id: data.id },
|
||||
@@ -488,7 +482,6 @@ function SearchPage() {
|
||||
const message = err instanceof ApiError ? err.message : t("search.errorGeneric");
|
||||
setError(message);
|
||||
setCandidates(null);
|
||||
setCandidateSource(null);
|
||||
toast.error(t("search.candidateSelectFailed"));
|
||||
} finally {
|
||||
setSelectLoading(false);
|
||||
@@ -798,8 +791,6 @@ function SearchPage() {
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="text-center text-xs text-muted-foreground">{t("search.openHint")}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -160,7 +160,7 @@ function ServiceTestPage() {
|
||||
try {
|
||||
const data = await api.post<any>("/vehicles/decode", {
|
||||
vin: candidateVin,
|
||||
emexCarIndex: Number.parseInt(carId, 10),
|
||||
candidate: carId,
|
||||
});
|
||||
setCandidates(null);
|
||||
if (data.id) {
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import {
|
||||
ReportCatalogIssueButton,
|
||||
useReportCatalogDegradation,
|
||||
} from "@/components/catalog/report-catalog-issue";
|
||||
import { CategoryBreadcrumb } from "@/components/categories/category-breadcrumb";
|
||||
import { CategoryColumns } from "@/components/categories/category-columns";
|
||||
import { CategoryGrid } from "@/components/categories/category-grid";
|
||||
@@ -61,7 +65,9 @@ function VehicleCategoryPage() {
|
||||
const { data: vehicle } = useQuery({
|
||||
queryKey: ["vehicle", id],
|
||||
queryFn: () =>
|
||||
api.get<{ brandName?: string; model?: string; year?: number }>(`/vehicles/${id}`),
|
||||
api.get<{ brandName?: string; model?: string; year?: number; source?: string; vin?: string }>(
|
||||
`/vehicles/${id}`,
|
||||
),
|
||||
enabled: !!id,
|
||||
});
|
||||
|
||||
@@ -97,6 +103,17 @@ function VehicleCategoryPage() {
|
||||
? `${vehicle.brandName}${cleanModel ? ` ${cleanModel}` : ""}`
|
||||
: t("vehicle.title");
|
||||
|
||||
// A loadError = the drill/parts fetch failed and the user sees a "couldn't load"
|
||||
// panel instead of parts — report it (with a session replay) so it surfaces.
|
||||
useReportCatalogDegradation("drill-load-error", !!data?.loadError, {
|
||||
vehicleId: id,
|
||||
categoryId,
|
||||
categoryName: data?.name,
|
||||
vehicleLabel,
|
||||
source: vehicle?.source,
|
||||
vin: vehicle?.vin,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<CategoryBreadcrumb
|
||||
@@ -188,16 +205,28 @@ function VehicleCategoryPage() {
|
||||
<p className="font-medium text-destructive">{t("vehicle.catalogUnavailableTitle")}</p>
|
||||
<p className="mt-1 text-muted-foreground">{t("vehicle.catalogUnavailableHint")}</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => refetch()}
|
||||
disabled={isFetching}
|
||||
data-faro-user-action-name="category-loaderror-retry"
|
||||
>
|
||||
{isFetching ? t("common.loading") : t("vehicle.retry")}
|
||||
</Button>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => refetch()}
|
||||
disabled={isFetching}
|
||||
data-faro-user-action-name="category-loaderror-retry"
|
||||
>
|
||||
{isFetching ? t("common.loading") : t("vehicle.retry")}
|
||||
</Button>
|
||||
<ReportCatalogIssueButton
|
||||
ctx={{
|
||||
vehicleId: id,
|
||||
categoryId,
|
||||
categoryName: data?.name,
|
||||
vehicleLabel,
|
||||
source: vehicle?.source,
|
||||
vin: vehicle?.vin,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Suspense fallback={<SchemaViewerFallback />}>
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import {
|
||||
ReportCatalogIssueButton,
|
||||
useReportCatalogDegradation,
|
||||
} from "@/components/catalog/report-catalog-issue";
|
||||
import { CatalogSearch } from "@/components/categories/catalog-search";
|
||||
import { CategoryBreadcrumb } from "@/components/categories/category-breadcrumb";
|
||||
import { CategoryColumns } from "@/components/categories/category-columns";
|
||||
@@ -105,6 +109,16 @@ function VehicleDetailPage() {
|
||||
? `${vehicle.brandName}${cleanModelName(vehicle.model) ? ` ${cleanModelName(vehicle.model)}` : ""}`
|
||||
: t("vehicle.title");
|
||||
|
||||
// An empty category tree on a decoded vehicle is a silent failure ("model var,
|
||||
// kategori yok") — report it to Sentry with a session replay so it surfaces.
|
||||
const treeEmpty = !categoriesLoading && Array.isArray(categoryTree) && categoryTree.length === 0;
|
||||
useReportCatalogDegradation("empty-tree", treeEmpty, {
|
||||
vehicleId: id,
|
||||
vehicleLabel,
|
||||
source: vehicle?.source,
|
||||
vin: vehicle?.vin,
|
||||
});
|
||||
|
||||
// Surface the viewed vehicle to the support chat widget (VIN/brand/model)
|
||||
// so agents have the car context for part-compatibility questions.
|
||||
useEffect(() => {
|
||||
@@ -344,6 +358,16 @@ function VehicleDetailPage() {
|
||||
<p className="mx-auto mt-1 max-w-md text-xs text-muted-foreground">
|
||||
{t("vehicle.noCategoriesHint")}
|
||||
</p>
|
||||
<div className="mt-3 flex justify-center">
|
||||
<ReportCatalogIssueButton
|
||||
ctx={{
|
||||
vehicleId: id,
|
||||
vehicleLabel,
|
||||
source: vehicle?.source,
|
||||
vin: vehicle?.vin,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : viewMode === "grid" ? (
|
||||
<CategoryGrid categories={categoryTree} vehicleId={id} hideFilter />
|
||||
|
||||
@@ -100,6 +100,10 @@ services:
|
||||
# memory: catalog-wide bridge had 7-114x noise; only add a catalog once
|
||||
# its per-vehicle bridge is wired & OEM-verified.
|
||||
- EMEX_SOURCE_DB_ALLOWED_CATALOGS=${EMEX_SOURCE_DB_ALLOWED_CATALOGS:-}
|
||||
# P snapshot lookup (db `td`) backing the OEM detail page. Off until
|
||||
# both vars are set in Coolify; see sase-prod-db-api-access memory.
|
||||
- P_DB_ENABLED=${P_DB_ENABLED:-false}
|
||||
- P_DB_URL=${P_DB_URL:-}
|
||||
# Central Directus CMS (Süper Panel project on Coolify). Prod and staging
|
||||
# point at the SAME instance so blog content is shared across envs.
|
||||
# Internal docker-network URL — both stacks join the `coolify` network.
|
||||
|
||||
@@ -19,7 +19,6 @@ export const ERROR_CODES = {
|
||||
|
||||
// Payment
|
||||
PAYMENT_FAILED: "PAY_001",
|
||||
EFT_RECEIPT_REQUIRED: "PAY_003",
|
||||
PAYMENT_ALREADY_PROCESSED: "PAY_004",
|
||||
|
||||
// General
|
||||
|
||||
@@ -896,7 +896,6 @@ describe("ERROR_CODES", () => {
|
||||
|
||||
it("has payment error codes", () => {
|
||||
expect(ERROR_CODES.PAYMENT_FAILED).toBe("PAY_001");
|
||||
expect(ERROR_CODES.EFT_RECEIPT_REQUIRED).toBe("PAY_003");
|
||||
expect(ERROR_CODES.PAYMENT_ALREADY_PROCESSED).toBe("PAY_004");
|
||||
});
|
||||
|
||||
@@ -927,6 +926,7 @@ describe("getBrandFromWmi — RCA Q5 additions", () => {
|
||||
["LSV", "Volkswagen"],
|
||||
["LVV", "Chery"],
|
||||
["ZCF", "Iveco"],
|
||||
["VXK", "Opel"], // PSA/Stellantis-platform Opel (Corsa F 2023 customer case, 2026-06-10)
|
||||
];
|
||||
it.each(ADDED)("maps %s -> %s", (wmi, brand) => {
|
||||
expect(getBrandFromWmi(wmi)).toBe(brand);
|
||||
|
||||
@@ -15,7 +15,6 @@ export type {
|
||||
Payment,
|
||||
PaymentMethod,
|
||||
PaymentStatus,
|
||||
EftPaymentInput,
|
||||
} from "./types/payment.js";
|
||||
export type { ApiResponse, ApiError, PaginationMeta } from "./types/api-response.js";
|
||||
export type { PaginationInput, PaginatedResult } from "./types/pagination.js";
|
||||
|
||||
@@ -6,15 +6,12 @@ export interface Payment {
|
||||
currency: string;
|
||||
method: PaymentMethod;
|
||||
status: PaymentStatus;
|
||||
eftReceiptUrl: string | null;
|
||||
adminNote: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export type PaymentMethod = "stripe" | "eft";
|
||||
// Stripe is the sole payment method. "iyzico"/"eft" are retired methods kept
|
||||
// only as historical values in the DB `method` column — never offered to users.
|
||||
export type PaymentMethod = "stripe";
|
||||
export type PaymentStatus = "pending" | "completed" | "failed" | "refunded";
|
||||
|
||||
export interface EftPaymentInput {
|
||||
subscriptionId: string;
|
||||
}
|
||||
|
||||
@@ -118,6 +118,7 @@ export const WMI_BRAND_MAP: Record<string, string> = {
|
||||
// Opel
|
||||
W0L: "Opel",
|
||||
W0V: "Opel", // newer Stellantis-era WMI
|
||||
VXK: "Opel", // PSA/Stellantis-platform Opel (Corsa F, Mokka B — France/Spain plants)
|
||||
// Skoda
|
||||
TMB: "Skoda",
|
||||
// Seat
|
||||
|
||||
66
pnpm-lock.yaml
generated
66
pnpm-lock.yaml
generated
@@ -225,7 +225,7 @@ importers:
|
||||
version: 1.159.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
better-auth:
|
||||
specifier: ^1.2.0
|
||||
version: 1.4.18(drizzle-kit@0.31.9)(drizzle-orm@0.41.0(@opentelemetry/api@1.9.1)(@types/pg@8.15.6)(gel@2.2.0)(kysely@0.28.11)(mysql2@3.22.4(@types/node@22.19.11))(postgres@3.4.8))(mysql2@3.22.4(@types/node@22.19.11))(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.11)(jiti@2.6.1)(jsdom@28.0.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
|
||||
version: 1.4.18(drizzle-kit@0.31.9)(drizzle-orm@0.41.0(@opentelemetry/api@1.9.0)(@types/pg@8.15.6)(gel@2.2.0)(kysely@0.28.11)(mysql2@3.22.4(@types/node@22.19.11))(postgres@3.4.8))(mysql2@3.22.4(@types/node@22.19.11))(next@16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.11)(jiti@2.6.1)(jsdom@28.0.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
|
||||
canvas-confetti:
|
||||
specifier: ^1.9.4
|
||||
version: 1.9.4
|
||||
@@ -10176,30 +10176,7 @@ snapshots:
|
||||
drizzle-kit: 0.31.9
|
||||
drizzle-orm: 0.41.0(@opentelemetry/api@1.9.0)(@types/pg@8.15.6)(gel@2.2.0)(kysely@0.28.11)(mysql2@3.22.4(@types/node@22.19.11))(postgres@3.4.8)
|
||||
mysql2: 3.22.4(@types/node@22.19.11)
|
||||
next: 16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
vitest: 3.2.4(@types/debug@4.1.13)(@types/node@22.19.11)(jiti@2.6.1)(jsdom@28.0.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
|
||||
|
||||
better-auth@1.4.18(drizzle-kit@0.31.9)(drizzle-orm@0.41.0(@opentelemetry/api@1.9.1)(@types/pg@8.15.6)(gel@2.2.0)(kysely@0.28.11)(mysql2@3.22.4(@types/node@22.19.11))(postgres@3.4.8))(mysql2@3.22.4(@types/node@22.19.11))(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.11)(jiti@2.6.1)(jsdom@28.0.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)):
|
||||
dependencies:
|
||||
'@better-auth/core': 1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@3.25.76))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0)
|
||||
'@better-auth/telemetry': 1.4.18(@better-auth/core@1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@3.25.76))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0))
|
||||
'@better-auth/utils': 0.3.0
|
||||
'@better-fetch/fetch': 1.1.21
|
||||
'@noble/ciphers': 2.1.1
|
||||
'@noble/hashes': 2.0.1
|
||||
better-call: 1.1.8(zod@4.3.6)
|
||||
defu: 6.1.4
|
||||
jose: 6.1.3
|
||||
kysely: 0.28.11
|
||||
nanostores: 1.1.0
|
||||
zod: 4.3.6
|
||||
optionalDependencies:
|
||||
drizzle-kit: 0.31.9
|
||||
drizzle-orm: 0.41.0(@opentelemetry/api@1.9.1)(@types/pg@8.15.6)(gel@2.2.0)(kysely@0.28.11)(mysql2@3.22.4(@types/node@22.19.11))(postgres@3.4.8)
|
||||
mysql2: 3.22.4(@types/node@22.19.11)
|
||||
next: 16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
next: 16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
vitest: 3.2.4(@types/debug@4.1.13)(@types/node@22.19.11)(jiti@2.6.1)(jsdom@28.0.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
|
||||
@@ -10632,16 +10609,6 @@ snapshots:
|
||||
mysql2: 3.22.4(@types/node@22.19.11)
|
||||
postgres: 3.4.8
|
||||
|
||||
drizzle-orm@0.41.0(@opentelemetry/api@1.9.1)(@types/pg@8.15.6)(gel@2.2.0)(kysely@0.28.11)(mysql2@3.22.4(@types/node@22.19.11))(postgres@3.4.8):
|
||||
optionalDependencies:
|
||||
'@opentelemetry/api': 1.9.1
|
||||
'@types/pg': 8.15.6
|
||||
gel: 2.2.0
|
||||
kysely: 0.28.11
|
||||
mysql2: 3.22.4(@types/node@22.19.11)
|
||||
postgres: 3.4.8
|
||||
optional: true
|
||||
|
||||
dunder-proto@1.0.1:
|
||||
dependencies:
|
||||
call-bind-apply-helpers: 1.0.2
|
||||
@@ -11893,34 +11860,7 @@ snapshots:
|
||||
|
||||
neo-async@2.6.2: {}
|
||||
|
||||
next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
|
||||
dependencies:
|
||||
'@next/env': 16.1.6
|
||||
'@swc/helpers': 0.5.15
|
||||
baseline-browser-mapping: 2.9.19
|
||||
caniuse-lite: 1.0.30001769
|
||||
postcss: 8.4.31
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
styled-jsx: 5.1.6(@babel/core@7.29.0)(react@19.2.4)
|
||||
optionalDependencies:
|
||||
'@next/swc-darwin-arm64': 16.1.6
|
||||
'@next/swc-darwin-x64': 16.1.6
|
||||
'@next/swc-linux-arm64-gnu': 16.1.6
|
||||
'@next/swc-linux-arm64-musl': 16.1.6
|
||||
'@next/swc-linux-x64-gnu': 16.1.6
|
||||
'@next/swc-linux-x64-musl': 16.1.6
|
||||
'@next/swc-win32-arm64-msvc': 16.1.6
|
||||
'@next/swc-win32-x64-msvc': 16.1.6
|
||||
'@opentelemetry/api': 1.9.1
|
||||
'@playwright/test': 1.58.2
|
||||
sharp: 0.34.5
|
||||
transitivePeerDependencies:
|
||||
- '@babel/core'
|
||||
- babel-plugin-macros
|
||||
optional: true
|
||||
|
||||
next@16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
|
||||
next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
|
||||
dependencies:
|
||||
'@next/env': 16.1.6
|
||||
'@swc/helpers': 0.5.15
|
||||
|
||||
Reference in New Issue
Block a user