Compare commits
73 Commits
blog-cms-a
...
promote-ex
| Author | SHA1 | Date | |
|---|---|---|---|
| 8c1d247c45 | |||
| 579ee8d275 | |||
| 4df06ab3a1 | |||
| 58a0faeb99 | |||
| 6c414f9a59 | |||
| fe35bbd826 | |||
| 55894bec0e | |||
| 4068091a21 | |||
| 5c7803e12a | |||
| 24e668ea01 | |||
| eccecf73e6 | |||
| 6575c611e8 | |||
| 4072c6e736 | |||
| d922fb7a02 | |||
| 8fddd09087 | |||
| 09a9487564 | |||
| e6c8c9c32e | |||
| 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");
|
||||||
37
apps/api/drizzle/0015_oem_votes.sql
Normal file
37
apps/api/drizzle/0015_oem_votes.sql
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
-- OEM community voting + gamification ledger.
|
||||||
|
-- oem_votes: one row per (user, oem_code) uyumlu/uyumsuz verdict cast from the
|
||||||
|
-- catalog part rows. Votes pool globally per code; part/vehicle/category are
|
||||||
|
-- analytics breadcrumbs only (catalog routes pass catalog_vehicles ids → no FK,
|
||||||
|
-- mirrors oem_code_copies). Re-votes update the row in place.
|
||||||
|
-- oem_vote_points: append-only, exactly one row per vote (vote_id unique),
|
||||||
|
-- written in the same transaction. points = 1 (vote) + 2 (agreed with strict
|
||||||
|
-- majority at cast time; first voter always 3). Never retro-adjusted.
|
||||||
|
CREATE TABLE "oem_votes" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"user_id" uuid NOT NULL,
|
||||||
|
"oem_code" varchar(100) NOT NULL,
|
||||||
|
"vote" varchar(12) NOT NULL,
|
||||||
|
"part_id" uuid,
|
||||||
|
"vehicle_id" uuid,
|
||||||
|
"category_id" uuid,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "oem_vote_points" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"user_id" uuid NOT NULL,
|
||||||
|
"vote_id" uuid NOT NULL,
|
||||||
|
"oem_code" varchar(100) NOT NULL,
|
||||||
|
"points" integer NOT NULL,
|
||||||
|
"correct" boolean DEFAULT false NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "oem_votes" ADD CONSTRAINT "oem_votes_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "oem_vote_points" ADD CONSTRAINT "oem_vote_points_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "oem_vote_points" ADD CONSTRAINT "oem_vote_points_vote_id_oem_votes_id_fk" FOREIGN KEY ("vote_id") REFERENCES "public"."oem_votes"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "oem_votes_user_oem_idx" ON "oem_votes" USING btree ("user_id","oem_code");--> statement-breakpoint
|
||||||
|
CREATE INDEX "oem_votes_oem_code_idx" ON "oem_votes" USING btree ("oem_code");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "oem_vote_points_vote_id_idx" ON "oem_vote_points" USING btree ("vote_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "oem_vote_points_user_id_idx" ON "oem_vote_points" USING btree ("user_id");
|
||||||
18
apps/api/drizzle/0016_oem_suggestions.sql
Normal file
18
apps/api/drizzle/0016_oem_suggestions.sql
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
-- Community OEM cross-reference suggestions, collected on the OEM detail page
|
||||||
|
-- ("muadil parça öner": brand + code). Primarily for codes the P snapshot has
|
||||||
|
-- no match for. suggested_code_norm (upper alphanumerics) powers grouping and
|
||||||
|
-- the per-user dedup; status is a moderation hook (active|hidden).
|
||||||
|
CREATE TABLE "oem_suggestions" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"user_id" uuid NOT NULL,
|
||||||
|
"oem_code" varchar(100) NOT NULL,
|
||||||
|
"brand" varchar(80) NOT NULL,
|
||||||
|
"suggested_code" varchar(100) NOT NULL,
|
||||||
|
"suggested_code_norm" varchar(100) NOT NULL,
|
||||||
|
"status" varchar(16) DEFAULT 'active' NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "oem_suggestions" ADD CONSTRAINT "oem_suggestions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "oem_suggestions_user_oem_code_idx" ON "oem_suggestions" USING btree ("user_id","oem_code","suggested_code_norm");--> statement-breakpoint
|
||||||
|
CREATE INDEX "oem_suggestions_oem_code_idx" ON "oem_suggestions" USING btree ("oem_code");
|
||||||
18
apps/api/drizzle/0017_oem_expert_rewards.sql
Normal file
18
apps/api/drizzle/0017_oem_expert_rewards.sql
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
-- Monthly Parça Uzmanları prizes: one row per (TR-month, rank). Written by the
|
||||||
|
-- expert-rewards cron (1st of month 00:00 Europe/Istanbul) when it closes the
|
||||||
|
-- finished season and grants the top 3 voters a subscription extension
|
||||||
|
-- (30/15/7 days). The unique index is the run-once guard — a re-run for the
|
||||||
|
-- same period inserts nothing, so days are never granted twice.
|
||||||
|
CREATE TABLE "oem_expert_rewards" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"period_start" timestamp with time zone NOT NULL,
|
||||||
|
"user_id" uuid NOT NULL,
|
||||||
|
"rank" integer NOT NULL,
|
||||||
|
"points" integer NOT NULL,
|
||||||
|
"reward_days" integer NOT NULL,
|
||||||
|
"granted_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "oem_expert_rewards" ADD CONSTRAINT "oem_expert_rewards_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "oem_expert_rewards_period_rank_idx" ON "oem_expert_rewards" USING btree ("period_start","rank");--> statement-breakpoint
|
||||||
|
CREATE INDEX "oem_expert_rewards_user_id_idx" ON "oem_expert_rewards" USING btree ("user_id");
|
||||||
@@ -92,6 +92,41 @@
|
|||||||
"when": 1780581755559,
|
"when": 1780581755559,
|
||||||
"tag": "0012_lifecycle_email_sent",
|
"tag": "0012_lifecycle_email_sent",
|
||||||
"breakpoints": true
|
"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
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 15,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1781222400000,
|
||||||
|
"tag": "0015_oem_votes",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 16,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1781308800000,
|
||||||
|
"tag": "0016_oem_suggestions",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 17,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1781395200000,
|
||||||
|
"tag": "0017_oem_expert_rewards",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -29,10 +29,13 @@ import { DemoModule } from "./demo/demo.module";
|
|||||||
import { EmailModule } from "./email/email.module";
|
import { EmailModule } from "./email/email.module";
|
||||||
import { HealthController } from "./health.controller";
|
import { HealthController } from "./health.controller";
|
||||||
import { EmexModule } from "./integrations/emex/emex.module";
|
import { EmexModule } from "./integrations/emex/emex.module";
|
||||||
|
import { PModule } from "./integrations/p/p.module";
|
||||||
import { InternalAdminModule } from "./internal-admin/internal-admin.module";
|
import { InternalAdminModule } from "./internal-admin/internal-admin.module";
|
||||||
import { JobsModule } from "./jobs/jobs.module";
|
import { JobsModule } from "./jobs/jobs.module";
|
||||||
import { MetaCapiModule } from "./meta-capi/meta-capi.module";
|
import { MetaCapiModule } from "./meta-capi/meta-capi.module";
|
||||||
import { NotificationsModule } from "./notifications/notifications.module";
|
import { NotificationsModule } from "./notifications/notifications.module";
|
||||||
|
import { OemSuggestionsModule } from "./oem-suggestions/oem-suggestions.module";
|
||||||
|
import { OemVotesModule } from "./oem-votes/oem-votes.module";
|
||||||
import { PartsModule } from "./parts/parts.module";
|
import { PartsModule } from "./parts/parts.module";
|
||||||
import { PaymentsModule } from "./payments/payments.module";
|
import { PaymentsModule } from "./payments/payments.module";
|
||||||
import { PlansModule } from "./plans/plans.module";
|
import { PlansModule } from "./plans/plans.module";
|
||||||
@@ -86,11 +89,14 @@ import { VehiclesModule } from "./vehicles/vehicles.module";
|
|||||||
CategoriesModule,
|
CategoriesModule,
|
||||||
DemoModule,
|
DemoModule,
|
||||||
PartsModule,
|
PartsModule,
|
||||||
|
PModule,
|
||||||
JobsModule,
|
JobsModule,
|
||||||
EmexModule,
|
EmexModule,
|
||||||
TranslationsModule,
|
TranslationsModule,
|
||||||
AdminModule,
|
AdminModule,
|
||||||
AnalyticsModule,
|
AnalyticsModule,
|
||||||
|
OemVotesModule,
|
||||||
|
OemSuggestionsModule,
|
||||||
CatalogModule,
|
CatalogModule,
|
||||||
ChangelogModule,
|
ChangelogModule,
|
||||||
ChatwootModule,
|
ChatwootModule,
|
||||||
|
|||||||
@@ -320,6 +320,84 @@ describe("CategoriesService", () => {
|
|||||||
"drill-load-error",
|
"drill-load-error",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("routes a P4 Ford VIN node (json-vin-sub-group.action, positional link_wid) to children, not an empty leaf", async () => {
|
||||||
|
// link_wid "1" is a positional code (not a *Group* table) so the link_wid
|
||||||
|
// group-drill branch misses it; the path is …group.action (not /psa/, not
|
||||||
|
// vin-group.action) so the PSA / Volvo branches miss it too. Before the
|
||||||
|
// dedicated gate these fell through to the leaf parts path → silent empty
|
||||||
|
// panel (Ford/Nissan/Opel analog of the PSA-parent-gate gap).
|
||||||
|
const category = {
|
||||||
|
id: "ford1",
|
||||||
|
name: "elektrik sistemi",
|
||||||
|
nameOriginal: "elektrik sistemi",
|
||||||
|
parentId: null,
|
||||||
|
vehicleId: "v1",
|
||||||
|
source: "pl24",
|
||||||
|
linkPath: "/ford/fordp_parts/json-vin-sub-group.action?mainGroupId=CAP1&vin=WF0FXX",
|
||||||
|
linkWid: "1",
|
||||||
|
hasSubgroups: null,
|
||||||
|
hasParts: null,
|
||||||
|
};
|
||||||
|
let selectCall = 0;
|
||||||
|
const db = {
|
||||||
|
select: vi.fn().mockImplementation(() => {
|
||||||
|
selectCall++;
|
||||||
|
const captured = selectCall;
|
||||||
|
const c: Record<string, any> = {};
|
||||||
|
c.from = vi.fn().mockReturnValue(c);
|
||||||
|
c.where = vi.fn().mockImplementation(() => (captured === 2 ? [] : c));
|
||||||
|
c.limit = vi.fn().mockReturnValue([category]);
|
||||||
|
return c;
|
||||||
|
}),
|
||||||
|
execute: vi.fn().mockResolvedValue([]),
|
||||||
|
};
|
||||||
|
const { service } = createService(db);
|
||||||
|
const discovered = [{ id: "sg1", name: "kablolar", children: undefined }];
|
||||||
|
vi.spyOn(service, "getChildren").mockResolvedValue(discovered as any);
|
||||||
|
|
||||||
|
const result = await service.getCategoryWithParts("ford1");
|
||||||
|
|
||||||
|
expect(service.getChildren).toHaveBeenCalledWith("ford1");
|
||||||
|
expect(result.parts).toEqual([]);
|
||||||
|
expect((result as { children?: unknown }).children).toEqual(discovered);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("surfaces loadError when a P4 group.action drill comes back empty (not a silent leaf)", async () => {
|
||||||
|
const category = {
|
||||||
|
id: "ford2",
|
||||||
|
name: "kaporta",
|
||||||
|
nameOriginal: "kaporta",
|
||||||
|
parentId: null,
|
||||||
|
vehicleId: "v1",
|
||||||
|
source: "pl24",
|
||||||
|
linkPath: "/ford/fordp_parts/json-vin-sub-group.action?mainGroupId=GDB&vin=WF0FXX",
|
||||||
|
linkWid: "2",
|
||||||
|
hasSubgroups: null,
|
||||||
|
hasParts: null,
|
||||||
|
};
|
||||||
|
let selectCall = 0;
|
||||||
|
const db = {
|
||||||
|
select: vi.fn().mockImplementation(() => {
|
||||||
|
selectCall++;
|
||||||
|
const captured = selectCall;
|
||||||
|
const c: Record<string, any> = {};
|
||||||
|
c.from = vi.fn().mockReturnValue(c);
|
||||||
|
c.where = vi.fn().mockImplementation(() => (captured === 2 ? [] : c));
|
||||||
|
c.limit = vi.fn().mockReturnValue([category]);
|
||||||
|
return c;
|
||||||
|
}),
|
||||||
|
execute: vi.fn().mockResolvedValue([]),
|
||||||
|
};
|
||||||
|
const { service } = createService(db);
|
||||||
|
vi.spyOn(service, "getChildren").mockResolvedValue([] as any);
|
||||||
|
|
||||||
|
const result = await service.getCategoryWithParts("ford2");
|
||||||
|
|
||||||
|
expect(service.getChildren).toHaveBeenCalledWith("ford2");
|
||||||
|
expect((result as { loadError?: boolean }).loadError).toBe(true);
|
||||||
|
expect((result as { children?: unknown }).children).toBeUndefined();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("getAncestors", () => {
|
describe("getAncestors", () => {
|
||||||
|
|||||||
@@ -120,6 +120,10 @@ export class CategoriesService {
|
|||||||
externalId: s.code,
|
externalId: s.code,
|
||||||
linkPath: s.linkPath || null,
|
linkPath: s.linkPath || null,
|
||||||
linkWid: null as string | null,
|
linkWid: null as string | null,
|
||||||
|
// PSA scopes are always parents (they hold main-groups, never direct
|
||||||
|
// parts) — flag them so the tree UI signposts them as folders and the
|
||||||
|
// prefetch worker pre-drills them instead of treating them as leaves.
|
||||||
|
hasSubgroups: true,
|
||||||
source: "pl24" as const,
|
source: "pl24" as const,
|
||||||
}));
|
}));
|
||||||
dbCategories = await this.db
|
dbCategories = await this.db
|
||||||
@@ -181,6 +185,10 @@ export class CategoriesService {
|
|||||||
externalId: c.code,
|
externalId: c.code,
|
||||||
linkPath: c.linkPath || null,
|
linkPath: c.linkPath || null,
|
||||||
linkWid: c.linkWid || null,
|
linkWid: c.linkWid || null,
|
||||||
|
// Mark P4 VIN/legacy group-drill nodes (…group.action) as parents so
|
||||||
|
// the tree UI shows them as folders and prefetch pre-drills them. Leaf
|
||||||
|
// pages (image-board.action/bom) stay null (unknown→leaf downstream).
|
||||||
|
hasSubgroups: c.linkPath?.includes("group.action") ? true : null,
|
||||||
source: "pl24" as const,
|
source: "pl24" as const,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -1123,6 +1131,39 @@ export class CategoriesService {
|
|||||||
// No subgroups → leaf; fall through to the parts path below.
|
// No subgroups → leaf; fall through to the parts path below.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PL24 P4 VIN / legacy group node whose link_wid is a positional code
|
||||||
|
// ("1","2","CAP1"…) rather than a *Group* table, so the link_wid branch above
|
||||||
|
// misses it, and which is neither PSA- nor Volvo-special. Its linkPath is a
|
||||||
|
// sub-group / main-group drill action (…group.action — never a parts leaf like
|
||||||
|
// image-board.action). This is the Ford / Nissan / Opel / Hyundai-Kia analog of
|
||||||
|
// the PSA-parent-gate gap: without it these VIN scopes ("mekanik", "kaporta",
|
||||||
|
// "Elektrikli aksam"…) fell through every parent branch to the leaf parts path,
|
||||||
|
// fetched no parts and rendered a silent empty panel — confirmed the dominant
|
||||||
|
// current "0 parça" cluster. Volvo's vin-group.action keeps its own branch above
|
||||||
|
// (it may legitimately bottom out in parts), so exclude it here. Drill on demand:
|
||||||
|
// subgroups → parent; an empty drill is a transient upstream failure → retryable
|
||||||
|
// load error (a group node is never legitimately a parts leaf).
|
||||||
|
if (
|
||||||
|
category.source === "pl24" &&
|
||||||
|
category.linkPath?.includes("group.action") &&
|
||||||
|
!category.linkPath.includes("vin-group.action") &&
|
||||||
|
category.vehicleId
|
||||||
|
) {
|
||||||
|
const p4Children = await this.getChildren(categoryId);
|
||||||
|
const base = {
|
||||||
|
id: category.id,
|
||||||
|
name: category.name,
|
||||||
|
description: category.nameOriginal || null,
|
||||||
|
parentId: category.parentId || null,
|
||||||
|
parts: [],
|
||||||
|
schemaPics: [],
|
||||||
|
hotspots: [],
|
||||||
|
};
|
||||||
|
return p4Children.length > 0
|
||||||
|
? { ...base, children: p4Children }
|
||||||
|
: { ...base, loadError: true };
|
||||||
|
}
|
||||||
|
|
||||||
// EMEX Vehicle.aspx group node (linkWid="emex-group") — a parent, never a
|
// EMEX Vehicle.aspx group node (linkWid="emex-group") — a parent, never a
|
||||||
// parts leaf. Return its children (seeded sub-groups, or Unit leaves drilled
|
// parts leaf. Return its children (seeded sub-groups, or Unit leaves drilled
|
||||||
// on demand by getChildren). Mirrors the pl24 group-node branch above; an
|
// on demand by getChildren). Mirrors the pl24 group-node branch above; an
|
||||||
|
|||||||
@@ -88,6 +88,14 @@ export default () => ({
|
|||||||
.map((s) => s.trim())
|
.map((s) => s.trim())
|
||||||
.filter(Boolean),
|
.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: {
|
otel: {
|
||||||
enabled: process.env.OTEL_ENABLED === "true",
|
enabled: process.env.OTEL_ENABLED === "true",
|
||||||
endpoint: process.env.OTEL_EXPORTER_OTLP_ENDPOINT,
|
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(
|
export const bankAccounts = pgTable(
|
||||||
"bank_accounts",
|
"bank_accounts",
|
||||||
{
|
{
|
||||||
@@ -225,7 +227,9 @@ export const payments = pgTable(
|
|||||||
iyzicoPaymentId: text("iyzico_payment_id"),
|
iyzicoPaymentId: text("iyzico_payment_id"),
|
||||||
stripeSessionId: text("stripe_session_id"),
|
stripeSessionId: text("stripe_session_id"),
|
||||||
stripePaymentIntentId: text("stripe_payment_intent_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),
|
bankAccountId: uuid("bank_account_id").references(() => bankAccounts.id),
|
||||||
|
/** @deprecated EFT/Havale retired (Stripe-only). Kept for historical data. */
|
||||||
eftReceiptUrl: text("eft_receipt_url"),
|
eftReceiptUrl: text("eft_receipt_url"),
|
||||||
adminNote: text("admin_note"),
|
adminNote: text("admin_note"),
|
||||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
@@ -613,3 +617,156 @@ export const emexCategoryTranslations = pgTable(
|
|||||||
},
|
},
|
||||||
(table) => [uniqueIndex("emex_translations_original_name_idx").on(table.originalName)],
|
(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),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── OEM Votes (community compatibility verdicts) ─────
|
||||||
|
// One row per (user, oemCode): a parts seller's uyumlu/uyumsuz verdict on an
|
||||||
|
// OEM code, cast from the catalog part rows. Votes pool globally per code —
|
||||||
|
// the part/vehicle/category columns are analytics breadcrumbs only (mirrors
|
||||||
|
// oem_code_copies: catalog routes pass catalog_vehicles ids here, so no FK).
|
||||||
|
// Re-voting updates `vote` in place; points are only ever granted on the
|
||||||
|
// first insert (see oem_vote_points).
|
||||||
|
export const oemVotes = pgTable(
|
||||||
|
"oem_votes",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
userId: uuid("user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
oemCode: varchar("oem_code", { length: 100 }).notNull(),
|
||||||
|
// compatible | incompatible
|
||||||
|
vote: varchar("vote", { length: 12 }).notNull(),
|
||||||
|
partId: uuid("part_id"),
|
||||||
|
vehicleId: uuid("vehicle_id"),
|
||||||
|
categoryId: uuid("category_id"),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
uniqueIndex("oem_votes_user_oem_idx").on(table.userId, table.oemCode),
|
||||||
|
index("oem_votes_oem_code_idx").on(table.oemCode),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── OEM Vote Points (gamification ledger) ────────────
|
||||||
|
// Append-only: exactly one row per oem_votes row (vote_id unique), written in
|
||||||
|
// the same transaction as the vote insert. `points` = 1 for voting +2 when
|
||||||
|
// the vote agreed with the strict majority at cast time (first voter on a
|
||||||
|
// code always agrees with themselves → 3). Awards are never retro-adjusted
|
||||||
|
// when the majority later flips. Leaderboard = SUM(points) per user.
|
||||||
|
export const oemVotePoints = pgTable(
|
||||||
|
"oem_vote_points",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
userId: uuid("user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
voteId: uuid("vote_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => oemVotes.id, { onDelete: "cascade" }),
|
||||||
|
oemCode: varchar("oem_code", { length: 100 }).notNull(),
|
||||||
|
points: integer("points").notNull(),
|
||||||
|
correct: boolean("correct").default(false).notNull(),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
uniqueIndex("oem_vote_points_vote_id_idx").on(table.voteId),
|
||||||
|
index("oem_vote_points_user_id_idx").on(table.userId),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── OEM Suggestions (community cross-references) ─────
|
||||||
|
// Crowd-sourced equivalents for an OEM code: "this code's compatible part is
|
||||||
|
// <brand> <suggestedCode>", collected on the OEM detail page — primarily for
|
||||||
|
// codes the P snapshot can't match. suggestedCodeNorm (upper, alphanumerics
|
||||||
|
// only) powers grouping and the per-user dedup constraint; display keeps the
|
||||||
|
// submitted casing. `status` is a moderation hook (active|hidden) — no rows
|
||||||
|
// are hidden automatically today.
|
||||||
|
export const oemSuggestions = pgTable(
|
||||||
|
"oem_suggestions",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
userId: uuid("user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
oemCode: varchar("oem_code", { length: 100 }).notNull(),
|
||||||
|
brand: varchar("brand", { length: 80 }).notNull(),
|
||||||
|
suggestedCode: varchar("suggested_code", { length: 100 }).notNull(),
|
||||||
|
suggestedCodeNorm: varchar("suggested_code_norm", { length: 100 }).notNull(),
|
||||||
|
status: varchar("status", { length: 16 }).default("active").notNull(),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
uniqueIndex("oem_suggestions_user_oem_code_idx").on(
|
||||||
|
table.userId,
|
||||||
|
table.oemCode,
|
||||||
|
table.suggestedCodeNorm,
|
||||||
|
),
|
||||||
|
index("oem_suggestions_oem_code_idx").on(table.oemCode),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── OEM Expert Rewards (monthly leaderboard prizes) ──
|
||||||
|
// One row per (TR-month, rank): the expert-rewards cron (1st of month 00:00
|
||||||
|
// Europe/Istanbul) closes the finished season and grants the top 3 voters a
|
||||||
|
// subscription extension (30/15/7 days — EXPERT_REWARD_LADDER). The unique
|
||||||
|
// index doubles as the run-once guard: a second run for the same period
|
||||||
|
// inserts nothing, so days are never granted twice. Granting mirrors the
|
||||||
|
// referral mechanic: extend a live active/trial sub, else bank the days in
|
||||||
|
// users.referral_credit_days (consumed at next trial/activation).
|
||||||
|
export const oemExpertRewards = pgTable(
|
||||||
|
"oem_expert_rewards",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
// TR-ayının başlangıcı (UTC timestamptz) — ödüllendirilen sezon.
|
||||||
|
periodStart: timestamp("period_start", { withTimezone: true }).notNull(),
|
||||||
|
userId: uuid("user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
rank: integer("rank").notNull(),
|
||||||
|
points: integer("points").notNull(),
|
||||||
|
rewardDays: integer("reward_days").notNull(),
|
||||||
|
grantedAt: timestamp("granted_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
uniqueIndex("oem_expert_rewards_period_rank_idx").on(table.periodStart, table.rank),
|
||||||
|
index("oem_expert_rewards_user_id_idx").on(table.userId),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|||||||
@@ -61,22 +61,44 @@ export class EmexBrowserService implements OnModuleInit, OnModuleDestroy {
|
|||||||
private launching: Promise<void> | null = null;
|
private launching: Promise<void> | null = null;
|
||||||
|
|
||||||
private readonly useProxy: boolean;
|
private readonly useProxy: boolean;
|
||||||
|
// "floxy" residential (default) | "dataimpulse" legacy | "none".
|
||||||
|
private readonly proxyProvider: "floxy" | "dataimpulse" | "none";
|
||||||
private readonly proxyHost: string;
|
private readonly proxyHost: string;
|
||||||
private readonly proxyPortStart: number;
|
private readonly proxyPortStart: number;
|
||||||
private readonly proxyPortEnd: number;
|
private readonly proxyPortEnd: number;
|
||||||
private readonly proxyUsername: string;
|
private readonly proxyUsername: string;
|
||||||
private readonly proxyPassword: 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;
|
private startedAt = 0;
|
||||||
|
|
||||||
constructor(private configService: ConfigService) {
|
constructor(private configService: ConfigService) {
|
||||||
this.semaphore = new Semaphore(MAX_CONCURRENT_PAGES);
|
this.semaphore = new Semaphore(MAX_CONCURRENT_PAGES);
|
||||||
|
|
||||||
this.useProxy = this.configService.get<string>("EMEX_USE_PROXY", "true") === "true";
|
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.proxyHost = this.configService.get<string>("EMEX_PROXY_HOST", "74.81.81.81");
|
||||||
this.proxyPortStart = this.configService.get<number>("EMEX_PROXY_PORT_START", 10001);
|
this.proxyPortStart = this.configService.get<number>("EMEX_PROXY_PORT_START", 10001);
|
||||||
this.proxyPortEnd = this.configService.get<number>("EMEX_PROXY_PORT_END", 10099);
|
this.proxyPortEnd = this.configService.get<number>("EMEX_PROXY_PORT_END", 10099);
|
||||||
this.proxyUsername = this.configService.get<string>("EMEX_PROXY_USER", "1726bbe361918676d44e");
|
this.proxyUsername = this.configService.get<string>("EMEX_PROXY_USER", "1726bbe361918676d44e");
|
||||||
this.proxyPassword = this.configService.get<string>("EMEX_PROXY_PASS", "f11c7b6128cc86c6");
|
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> {
|
async onModuleInit(): Promise<void> {
|
||||||
@@ -184,14 +206,30 @@ export class EmexBrowserService implements OnModuleInit, OnModuleDestroy {
|
|||||||
const executablePath = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH;
|
const executablePath = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH;
|
||||||
if (executablePath) launchOptions.executablePath = executablePath;
|
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();
|
const port = this.randomProxyPort();
|
||||||
launchOptions.proxy = {
|
launchOptions.proxy = {
|
||||||
server: `http://${this.proxyHost}:${port}`,
|
server: `http://${this.proxyHost}:${port}`,
|
||||||
username: this.proxyUsername,
|
username: this.proxyUsername,
|
||||||
password: this.proxyPassword,
|
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);
|
this.browser = await chromium.launch(launchOptions);
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
|
import { ProxyTelemetryModule } from "../proxy-telemetry/proxy-telemetry.module";
|
||||||
import { EmexBrowserService } from "./emex.browser";
|
import { EmexBrowserService } from "./emex.browser";
|
||||||
import { EmexService } from "./emex.service";
|
import { EmexService } from "./emex.service";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [ProxyTelemetryModule],
|
||||||
providers: [EmexBrowserService, EmexService],
|
providers: [EmexBrowserService, EmexService],
|
||||||
exports: [EmexService],
|
exports: [EmexService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ function makeConfig(overrides: Record<string, string>): ConfigService {
|
|||||||
const browser = {} as never;
|
const browser = {} as never;
|
||||||
const redis = { set: vi.fn() } as never;
|
const redis = { set: vi.fn() } as never;
|
||||||
const posthog = {} as never;
|
const posthog = {} as never;
|
||||||
|
const proxyTelemetry = { record: vi.fn() } as never;
|
||||||
|
|
||||||
describe("EmexService proxy-port coercion (regression)", () => {
|
describe("EmexService proxy-port coercion (regression)", () => {
|
||||||
it("does not throw 'Invalid URL' when ports arrive as strings over a real range", () => {
|
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",
|
EMEX_PROXY_PORT_END: "10099",
|
||||||
});
|
});
|
||||||
// Pre-fix: threw "Invalid URL" here (port "4510001" > 65535).
|
// 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)", () => {
|
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_START: "823",
|
||||||
EMEX_PROXY_PORT_END: "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", () => {
|
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_START: "not-a-number",
|
||||||
EMEX_PROXY_PORT_END: "999999",
|
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", () => {
|
it("constructs cleanly with the proxy disabled", () => {
|
||||||
const config = makeConfig({ EMEX_USE_PROXY: "false" });
|
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 { isBackfillContext } from "../../jobs/prefetch-context";
|
||||||
import { PostHogService } from "../../posthog/posthog.service";
|
import { PostHogService } from "../../posthog/posthog.service";
|
||||||
import { RedisService } from "../../redis/redis.service";
|
import { RedisService } from "../../redis/redis.service";
|
||||||
|
import {
|
||||||
|
ProxyTelemetryService,
|
||||||
|
classifyTransportError,
|
||||||
|
} from "../proxy-telemetry/proxy-telemetry.service";
|
||||||
import { parseUnitLeaves, parseVehicleTree } from "./emex-tree.parser";
|
import { parseUnitLeaves, parseVehicleTree } from "./emex-tree.parser";
|
||||||
import { EmexBrowserService } from "./emex.browser";
|
import { EmexBrowserService } from "./emex.browser";
|
||||||
import { createEmptyDecodedVehicle, mapEmexResponse } from "./emex.mapper";
|
import { createEmptyDecodedVehicle, mapEmexResponse } from "./emex.mapper";
|
||||||
@@ -132,12 +136,17 @@ export class EmexService {
|
|||||||
private floxySessionId = Math.random().toString(36).slice(2, 10);
|
private floxySessionId = Math.random().toString(36).slice(2, 10);
|
||||||
private floxySessionBornMs = Date.now();
|
private floxySessionBornMs = Date.now();
|
||||||
private readonly emexDirectFallback: boolean;
|
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(
|
constructor(
|
||||||
private configService: ConfigService,
|
private configService: ConfigService,
|
||||||
private browserService: EmexBrowserService,
|
private browserService: EmexBrowserService,
|
||||||
private redis: RedisService,
|
private redis: RedisService,
|
||||||
private posthog: PostHogService,
|
private posthog: PostHogService,
|
||||||
|
private proxyTelemetry: ProxyTelemetryService,
|
||||||
) {
|
) {
|
||||||
// __dirname is apps/api/src/integrations/emex/ or dist/integrations/emex/
|
// __dirname is apps/api/src/integrations/emex/ or dist/integrations/emex/
|
||||||
// Scraper lives at <monorepo-root>/scripts/emex-vin-scraper.js
|
// Scraper lives at <monorepo-root>/scripts/emex-vin-scraper.js
|
||||||
@@ -169,15 +178,11 @@ export class EmexService {
|
|||||||
portStart: toPort("EMEX_PROXY_PORT_START", 10001),
|
portStart: toPort("EMEX_PROXY_PORT_START", 10001),
|
||||||
portEnd: toPort("EMEX_PROXY_PORT_END", 10099),
|
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(
|
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 {
|
} else {
|
||||||
this.emexProxy = null;
|
this.emexProxy = null;
|
||||||
this.proxyAgent = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Floxy residential fallback for when the DataImpulse pool flakes (connect
|
// Floxy residential fallback for when the DataImpulse pool flakes (connect
|
||||||
@@ -189,8 +194,8 @@ export class EmexService {
|
|||||||
this.emexFloxy = {
|
this.emexFloxy = {
|
||||||
host: this.configService.get<string>("EMEX_FLOXY_HOST", "residential.floxy.io"),
|
host: this.configService.get<string>("EMEX_FLOXY_HOST", "residential.floxy.io"),
|
||||||
port: Number.isInteger(fport) && fport >= 1 && fport <= 65535 ? fport : 12321,
|
port: Number.isInteger(fport) && fport >= 1 && fport <= 65535 ? fport : 12321,
|
||||||
user: this.configService.get<string>("EMEX_FLOXY_USER", "d739255e819b"),
|
user: this.configService.get<string>("EMEX_FLOXY_USER", "cac4b0d96a80"),
|
||||||
pass: this.configService.get<string>("EMEX_FLOXY_PASS", "9092873ba4e0"),
|
pass: this.configService.get<string>("EMEX_FLOXY_PASS", "dfc38fe467a3"),
|
||||||
lifetime: Number.isInteger(flife) && flife >= 0 ? flife : 300,
|
lifetime: Number.isInteger(flife) && flife >= 0 ? flife : 300,
|
||||||
};
|
};
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
@@ -200,6 +205,28 @@ export class EmexService {
|
|||||||
this.emexFloxy = null;
|
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}`);
|
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> {
|
private async fetchEmexHtml(url: string): Promise<string> {
|
||||||
// Attempt schedule: DataImpulse (rotating port, fresh agent each try to dodge
|
// Attempt schedule by provider. floxy (default): Floxy residential sticky
|
||||||
// a flaky sticky port — ~42% blip rate, undecoded-vin-rca.md EMEX #1) first,
|
// primary (one exit IP across the flow; rolls to a fresh IP on transport
|
||||||
// then the Floxy residential fallback when the DataImpulse pool throws
|
// failure), then a single DataImpulse last-ditch try — this avoids DataImpulse's
|
||||||
// transport errors. A definitive HTTP answer (e.g. 404) stops the schedule —
|
// ~50% dead ports that used to burn the decode budget before reaching Floxy.
|
||||||
// it's a real result, and a different proxy IP must not "retry" it away.
|
// 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"> = [];
|
const schedule: Array<"dataimpulse" | "floxy"> = [];
|
||||||
if (this.emexProxy) schedule.push("dataimpulse", "dataimpulse", "dataimpulse");
|
if (this.emexProxyProvider === "floxy") {
|
||||||
if (this.emexFloxy) schedule.push("floxy", "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
|
const maxAttempts = schedule.length || 1; // 0 → single proxy-less attempt
|
||||||
let lastErr: unknown;
|
let lastErr: unknown;
|
||||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||||
const provider = schedule[attempt - 1]; // undefined when no proxy → direct
|
const provider = schedule[attempt - 1]; // undefined when no proxy → direct
|
||||||
const agent = provider ? this.newProxyAgent(provider) : null;
|
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 {
|
try {
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
headers: { "User-Agent": EMEX_UA, Accept: "text/html,application/xhtml+xml" },
|
headers: { "User-Agent": EMEX_UA, Accept: "text/html,application/xhtml+xml" },
|
||||||
signal: AbortSignal.timeout(this.timeout),
|
signal: AbortSignal.timeout(this.timeout),
|
||||||
...(agent ? { dispatcher: agent } : {}),
|
...(agent ? { dispatcher: agent } : {}),
|
||||||
} as RequestInit);
|
} 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) {
|
if (!res.ok) {
|
||||||
throw new Error(`EMEX HTTP ${res.status} for ${url}`);
|
throw new Error(`EMEX HTTP ${res.status} for ${url}`);
|
||||||
}
|
}
|
||||||
@@ -378,6 +425,19 @@ export class EmexService {
|
|||||||
const e = err as Error & { cause?: unknown };
|
const e = err as Error & { cause?: unknown };
|
||||||
// A real HTTP response ("EMEX HTTP 404") is a definitive answer — never retry.
|
// A real HTTP response ("EMEX HTTP 404") is a definitive answer — never retry.
|
||||||
const httpAnswer = /^EMEX HTTP \d/.test(e.message);
|
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 =
|
const transient =
|
||||||
!httpAnswer &&
|
!httpAnswer &&
|
||||||
(e.name === "TimeoutError" ||
|
(e.name === "TimeoutError" ||
|
||||||
|
|||||||
@@ -348,6 +348,7 @@ export const CATALOG_MAP: Record<string, CatalogEntry> = {
|
|||||||
// Opel
|
// Opel
|
||||||
W0L: { code: "GM_OP201809", brand: "Opel" },
|
W0L: { code: "GM_OP201809", brand: "Opel" },
|
||||||
W0V: { code: "GM_OP201809", brand: "Opel" }, // newer Stellantis-era WMI
|
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
|
// Chevrolet
|
||||||
KL1: { code: "GM_C201809", brand: "Chevrolet" },
|
KL1: { code: "GM_C201809", brand: "Chevrolet" },
|
||||||
// SsangYong
|
// 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.
|
* 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
|
* 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
|
* X-* headers (api-path, gui-version, user-id, origin, referer); we intercept
|
||||||
* all of them so backend requests can replay the exact header set.
|
* 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:
|
* Warm pool behavior:
|
||||||
* 24/7 → proactive: maintain >= 1 slot, auto-refresh ~9.5min before expiry.
|
* 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 { ConfigService } from "@nestjs/config";
|
||||||
import { Browser, BrowserContext } from "playwright";
|
import { Browser, BrowserContext } from "playwright";
|
||||||
import { RedisService } from "../../redis/redis.service";
|
import { RedisService } from "../../redis/redis.service";
|
||||||
|
import {
|
||||||
|
ProxyTelemetryService,
|
||||||
|
classifyTransportError,
|
||||||
|
resolveExitIp,
|
||||||
|
} from "../proxy-telemetry/proxy-telemetry.service";
|
||||||
import { JwtSlot, PcatJwtToken, PcatSession } from "./parts-catalogs.types";
|
import { JwtSlot, PcatJwtToken, PcatSession } from "./parts-catalogs.types";
|
||||||
|
|
||||||
// Shared single-slot cache so dev + prod (and any restarted container) can
|
// Shared single-slot cache so dev + prod (and any restarted container) can
|
||||||
// inherit a freshly-captured warm slot instead of paying the Playwright
|
// inherit a freshly-captured warm token instead of paying the Playwright
|
||||||
// capture cost from cold. Token is still IP-bound to its proxyPort; if a
|
// capture cost from cold. The token is portable (not IP-bound), so any
|
||||||
// reader on a different IP gets 401/403, invalidateSession's existing
|
// container can serve it through its own rotating call-leg IPs; if it's dead
|
||||||
// fallback re-captures locally. So worst case = today's behavior.
|
// at the source, invalidateSession drops it and the next read re-captures.
|
||||||
const REDIS_SLOT_KEY = "pcat:jwt:slot";
|
const REDIS_SLOT_KEY = "pcat:jwt:slot";
|
||||||
const REDIS_SAFETY_BUFFER_S = 60; // don't serve from Redis if <60s of life left
|
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 TOKEN_TTL = 600; // seconds — TWS- has no built-in expiry, refresh aggressively
|
||||||
const REFRESH_BUFFER = 90; // Refresh 90s before expiry
|
const REFRESH_BUFFER = 90; // Refresh 90s before expiry
|
||||||
const CAPTURE_POLL_INTERVAL = 500; // ms
|
const CAPTURE_POLL_INTERVAL = 500; // ms
|
||||||
// Two-armed post-goto poll: a healthy goto means the widget JS already loaded
|
// Post-goto poll. We now navigate with `domcontentloaded` (reliable, doesn't
|
||||||
// and the API call typically fires within ~1-2s — short cap is enough. A
|
// depend on networkidle settling on JS-heavy catalog sites), then poll for the
|
||||||
// failed goto means we lost the page, but the widget request may still have
|
// widget's token call. Measured through Floxy residential 2026-06-11: DOM ready
|
||||||
// gone through before the navigation timeout — give a brief grace window
|
// ~7s, the /v3/api/proxy token call fires ~11.8s (≈5s after DOM). The poll must
|
||||||
// and bail. Old single-armed 20s cap was the dominant cost on failed-site
|
// comfortably cover that gap, so 12s — too short and every capture misses the
|
||||||
// attempts (e.g. 10s page timeout + 20s blind poll = 30s wasted).
|
// token (seen at the old 5s cap: "No token after 12s", whole pool starved).
|
||||||
const CAPTURE_POLL_AFTER_OK = 10; // 10 × 500ms = 5s after a healthy goto
|
const CAPTURE_POLL_AFTER_OK = Number(process.env.PCAT_CAPTURE_POLL_OK) || 24; // 24 × 500ms = 12s
|
||||||
const CAPTURE_POLL_AFTER_FAIL = 4; // 4 × 500ms = 2s grace after goto failure
|
const CAPTURE_POLL_AFTER_FAIL = 8; // 8 × 500ms = 4s grace after goto failure
|
||||||
const PAGE_TIMEOUT = 10_000; // 10s — healthy partner sites load in <5s through
|
// 25s — residential exit IPs are slow; the token call fired at ~11.8s in the
|
||||||
// the proxy; a longer wait only prolongs dead-port connects in capture retries.
|
// 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 CONTEXT_CLOSE_TIMEOUT = 5_000;
|
||||||
const SITE_COOLDOWN = 10 * 60 * 1000; // 10 min per site
|
const SITE_COOLDOWN = 10 * 60 * 1000; // 10 min per site
|
||||||
const MAX_POOL_SIZE = 5;
|
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.
|
* 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
|
// 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
|
// a cold pool waits on. Sites that consistently load fast stay at the top so
|
||||||
// stay at the top so initial JWT capture finishes in seconds.
|
// initial JWT capture finishes in seconds.
|
||||||
// (e-acca.com was previously included last but removed — the current DataImpulse
|
// Pruned 2026-06-10 to the 6 sites that actually yielded captures on prod —
|
||||||
// rotating proxy 74.81.81.81:10000-10999 cannot reach it; page.goto always
|
// knkauto.ru / autodo.kz / flynestauto.com produced 0 captures yet consumed
|
||||||
// blocked until PAGE_TIMEOUT instead of failing fast like the other sites.)
|
// ~80% of capture attempts (the per-site cooldown kept forcing the retry loop
|
||||||
const JWT_SITES = [
|
// 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://www.alkatalog.com/cats/#/catalogs",
|
||||||
"https://auto-komplekt.ru/goodvin#/catalogs",
|
"https://auto-komplekt.ru/goodvin#/catalogs",
|
||||||
"https://www.autotrade.md/cats/#/catalogs",
|
"https://www.autotrade.md/cats/#/catalogs",
|
||||||
"https://www.e-trak.ru/cats/#/catalogs",
|
"https://www.e-trak.ru/cats/#/catalogs",
|
||||||
"https://www.autopolyus.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://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_HOST = "gw.dataimpulse.com";
|
||||||
const DI_PORT_MIN = 10000;
|
const DI_PORT_MIN = 10000;
|
||||||
const DI_PORT_MAX = 10999;
|
const DI_PORT_MAX = 10999;
|
||||||
const DI_DEFAULT_USER = "1726bbe361918676d44e";
|
const DI_DEFAULT_USER = "1726bbe361918676d44e";
|
||||||
const DI_DEFAULT_PASS = "78ebc3d881de6ec0";
|
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) */
|
/** Simple counting semaphore (same pattern as EmexBrowserService) */
|
||||||
class Semaphore {
|
class Semaphore {
|
||||||
private current = 0;
|
private current = 0;
|
||||||
@@ -119,18 +149,20 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
|
|||||||
|
|
||||||
private browser: Browser | null = null;
|
private browser: Browser | null = null;
|
||||||
private launching: Promise<void> | null = null;
|
private launching: Promise<void> | null = null;
|
||||||
// Concurrent captures capped at JWT_SITES.length — the real bottleneck is
|
// Active JWT capture sites (PCAT_JWT_SITES override or DEFAULT_JWT_SITES),
|
||||||
// how many distinct partner sites we can drive in parallel (one capture per
|
// resolved in the constructor.
|
||||||
// site, since each site = a different IP / cookie origin). Ports are
|
private readonly jwtSites: string[];
|
||||||
// ~unlimited (10000-10999) and Playwright contexts are isolated; the
|
// Concurrent captures capped at jwtSites.length — the real bottleneck is how
|
||||||
// synchronous siteIndex round-robin hands each concurrent capture a
|
// many distinct partner sites we can drive in parallel (one capture per site,
|
||||||
// different site so they don't collide.
|
// since each site = a different cookie origin). Playwright contexts are
|
||||||
private readonly semaphore = new Semaphore(JWT_SITES.length);
|
// 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
|
// Pool state
|
||||||
private pool: JwtSlot[] = [];
|
private pool: JwtSlot[] = [];
|
||||||
private siteLastUsedAt = new Map<string, number>();
|
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
|
private requestRoundRobin = 0; // round-robin across pool slots
|
||||||
|
|
||||||
// Business hours scheduling
|
// Business hours scheduling
|
||||||
@@ -139,20 +171,117 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
|
|||||||
// RPM tracking
|
// RPM tracking
|
||||||
private requestTimestamps: number[] = [];
|
private requestTimestamps: number[] = [];
|
||||||
|
|
||||||
// Config
|
// Transport config — see ProxyProvider. The TWS- token is NOT request-time
|
||||||
private readonly useProxy: boolean;
|
// IP-bound (parts-catalogs.types.ts), so the call leg rotates exit IPs per
|
||||||
private readonly proxyHost: string;
|
// request and only the capture leg holds a sticky IP per attempt.
|
||||||
private readonly proxyUser: string;
|
private readonly proxyProvider: ProxyProvider;
|
||||||
private readonly proxyPass: string;
|
// 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(
|
constructor(
|
||||||
private configService: ConfigService,
|
private configService: ConfigService,
|
||||||
private readonly redis: RedisService,
|
private readonly redis: RedisService,
|
||||||
|
private readonly proxyTelemetry: ProxyTelemetryService,
|
||||||
) {
|
) {
|
||||||
this.useProxy = this.configService.get<string>("PCAT_USE_PROXY", "true") === "true";
|
const cfg = this.configService;
|
||||||
this.proxyHost = this.configService.get<string>("PCAT_PROXY_HOST", DI_HOST);
|
|
||||||
this.proxyUser = this.configService.get<string>("PCAT_PROXY_USER", DI_DEFAULT_USER);
|
// Active capture sites: PCAT_JWT_SITES override (comma-separated) or the
|
||||||
this.proxyPass = this.configService.get<string>("PCAT_PROXY_PASS", DI_DEFAULT_PASS);
|
// 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> {
|
async onModuleInit(): Promise<void> {
|
||||||
@@ -193,10 +322,14 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
|
|||||||
// ─── Public API ───────────────────────────────────────────
|
// ─── Public API ───────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Acquire a matched JWT + proxy session from the pool.
|
* Acquire a JWT session from the pool.
|
||||||
* Returns a valid slot (round-robin) or captures on-demand if pool is empty.
|
* 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();
|
this.trackRequest();
|
||||||
|
|
||||||
// Try to find a valid slot in the pool
|
// Try to find a valid slot in the pool
|
||||||
@@ -207,14 +340,26 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
|
|||||||
return this.slotToSession(slot);
|
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...");
|
this.logger.log("JWT pool empty — capturing on-demand...");
|
||||||
const newSlot = await this.captureToPool();
|
const newSlot = await this.captureToPool();
|
||||||
return this.slotToSession(newSlot);
|
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> {
|
async invalidateSession(session: PcatSession): Promise<void> {
|
||||||
const idx = this.pool.indexOf(session._slot);
|
const idx = this.pool.indexOf(session._slot);
|
||||||
@@ -222,17 +367,18 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
|
|||||||
this.clearSlotTimer(this.pool[idx]);
|
this.clearSlotTimer(this.pool[idx]);
|
||||||
this.pool.splice(idx, 1);
|
this.pool.splice(idx, 1);
|
||||||
this.logger.log(
|
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
|
// The Redis-shared slot may point at this same now-dead token. Drop it so
|
||||||
// away on the proxy). Drop it so other containers re-capture instead of
|
// a cold-booting container re-captures instead of hydrating the dead token.
|
||||||
// inheriting the same dead slot.
|
|
||||||
this.deleteFromRedis().catch(() => {});
|
this.deleteFromRedis().catch(() => {});
|
||||||
|
|
||||||
// Capture replacement in background (don't block the caller's retry)
|
// Only force an immediate replacement when the pool emptied; otherwise let
|
||||||
if (this.isBusinessHours() || this.pool.length === 0) {
|
// 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.captureToPool().catch((err) => {
|
||||||
this.logger.error(`Replacement capture failed: ${(err as Error).message}`);
|
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 {
|
private slotToSession(slot: JwtSlot): PcatSession {
|
||||||
const proxyUrl = this.useProxy
|
// Fresh rotating call-leg proxy per request — and per retry, since
|
||||||
? `http://${this.proxyUser}:${this.proxyPass}@${this.proxyHost}:${slot.proxyPort}`
|
// fetchWithAuth re-acquires the session each attempt → a new exit IP each
|
||||||
: null;
|
// time, so a slow/blocked residential IP is shed on the next try.
|
||||||
const proxyConfig = this.useProxy
|
const { proxyUrl, proxyConfig } = this.buildProxy("call");
|
||||||
? {
|
|
||||||
server: `http://${this.proxyHost}:${slot.proxyPort}`,
|
|
||||||
username: this.proxyUser,
|
|
||||||
password: this.proxyPass,
|
|
||||||
}
|
|
||||||
: null;
|
|
||||||
return {
|
return {
|
||||||
apiKey: slot.jwt.raw,
|
apiKey: slot.jwt.raw,
|
||||||
apiPath: slot.jwt.apiPath,
|
apiPath: slot.jwt.apiPath,
|
||||||
@@ -294,17 +434,16 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
|
|||||||
|
|
||||||
const maxRetries = 4;
|
const maxRetries = 4;
|
||||||
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||||
const port = this.allocatePort();
|
|
||||||
const siteUrl = this.getAvailableSite();
|
const siteUrl = this.getAvailableSite();
|
||||||
|
|
||||||
const jwt = await this.attemptCapture(siteUrl, port);
|
const jwt = await this.attemptCapture(siteUrl);
|
||||||
if (jwt) {
|
if (jwt) {
|
||||||
// Validate the JWT+proxy combo against the real upstream before
|
// Validate the token against the real upstream (on a fresh rotating
|
||||||
// publishing the slot — see VALIDATION_VIN comment above.
|
// call-leg IP) before publishing the slot — see VALIDATION_VIN.
|
||||||
const valid = await this.validateSlot(jwt, port);
|
const valid = await this.validateSlot(jwt);
|
||||||
if (!valid) {
|
if (!valid) {
|
||||||
this.logger.warn(
|
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.
|
// Mark site as used anyway so we rotate away rather than retry it.
|
||||||
this.siteLastUsedAt.set(siteUrl, Date.now());
|
this.siteLastUsedAt.set(siteUrl, Date.now());
|
||||||
@@ -313,10 +452,10 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
|
|||||||
|
|
||||||
const slot: JwtSlot = {
|
const slot: JwtSlot = {
|
||||||
jwt,
|
jwt,
|
||||||
proxyPort: port,
|
|
||||||
siteUsed: siteUrl,
|
siteUsed: siteUrl,
|
||||||
capturedAt: Date.now(),
|
capturedAt: Date.now(),
|
||||||
refreshTimer: null,
|
refreshTimer: null,
|
||||||
|
failCount: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Mark site as used
|
// 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
|
* Probe the just-captured token with a single cheap upstream call (/car/info
|
||||||
* call (/car/info with a public demo VIN). Returns true iff the upstream
|
* with a public demo VIN) on a fresh rotating call-leg IP — the SAME transport
|
||||||
* accepts the combo with 2xx within VALIDATION_TIMEOUT_MS. A failure here
|
* a real request will use. Returns true iff upstream accepts it with 2xx within
|
||||||
* (proxy dead, IP blocked, token 401/403) is far cheaper to absorb at
|
* VALIDATION_TIMEOUT_MS. Validating on the call-leg transport (not the capture
|
||||||
* capture time than to inherit when a real user clicks a category.
|
* 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 url = `${PCAT_API_BASE}/car/info?q=${VALIDATION_VIN}`;
|
||||||
const fetchOptions: RequestInit & { dispatcher?: any } = {
|
const fetchOptions: RequestInit & { dispatcher?: any } = {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
@@ -375,17 +515,35 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
|
|||||||
},
|
},
|
||||||
signal: AbortSignal.timeout(VALIDATION_TIMEOUT_MS),
|
signal: AbortSignal.timeout(VALIDATION_TIMEOUT_MS),
|
||||||
};
|
};
|
||||||
if (this.useProxy) {
|
const { proxyUrl } = this.buildProxy("call");
|
||||||
|
if (proxyUrl) {
|
||||||
const { ProxyAgent } = await import("undici");
|
const { ProxyAgent } = await import("undici");
|
||||||
fetchOptions.dispatcher = new ProxyAgent({
|
fetchOptions.dispatcher = new ProxyAgent({
|
||||||
uri: `http://${this.proxyUser}:${this.proxyPass}@${this.proxyHost}:${port}`,
|
uri: proxyUrl,
|
||||||
connect: { timeout: 6_000 },
|
connect: { timeout: 6_000 },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
const start = Date.now();
|
||||||
try {
|
try {
|
||||||
const r = await fetch(url, fetchOptions);
|
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;
|
return r.ok;
|
||||||
} catch (err) {
|
} 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}`);
|
this.logger.debug(`Slot validation threw: ${(err as Error).message}`);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -539,15 +697,16 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
|
|||||||
|
|
||||||
private getAvailableSite(): string {
|
private getAvailableSite(): string {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
const sites = this.jwtSites;
|
||||||
|
|
||||||
// Try round-robin, preferring sites not on cooldown
|
// Try round-robin, preferring sites not on cooldown
|
||||||
for (let i = 0; i < JWT_SITES.length; i++) {
|
for (let i = 0; i < sites.length; i++) {
|
||||||
const idx = (this.siteIndex + i) % JWT_SITES.length;
|
const idx = (this.siteIndex + i) % sites.length;
|
||||||
const site = JWT_SITES[idx];
|
const site = sites[idx];
|
||||||
const lastUsed = this.siteLastUsedAt.get(site) || 0;
|
const lastUsed = this.siteLastUsedAt.get(site) || 0;
|
||||||
|
|
||||||
if (now - lastUsed >= SITE_COOLDOWN) {
|
if (now - lastUsed >= SITE_COOLDOWN) {
|
||||||
this.siteIndex = (idx + 1) % JWT_SITES.length;
|
this.siteIndex = (idx + 1) % sites.length;
|
||||||
return site;
|
return site;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -555,15 +714,15 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
|
|||||||
// All on cooldown — pick the one with oldest usage
|
// All on cooldown — pick the one with oldest usage
|
||||||
let oldestIdx = 0;
|
let oldestIdx = 0;
|
||||||
let oldestTime = Number.POSITIVE_INFINITY;
|
let oldestTime = Number.POSITIVE_INFINITY;
|
||||||
for (let i = 0; i < JWT_SITES.length; i++) {
|
for (let i = 0; i < sites.length; i++) {
|
||||||
const lastUsed = this.siteLastUsedAt.get(JWT_SITES[i]) || 0;
|
const lastUsed = this.siteLastUsedAt.get(sites[i]) || 0;
|
||||||
if (lastUsed < oldestTime) {
|
if (lastUsed < oldestTime) {
|
||||||
oldestTime = lastUsed;
|
oldestTime = lastUsed;
|
||||||
oldestIdx = i;
|
oldestIdx = i;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.siteIndex = (oldestIdx + 1) % JWT_SITES.length;
|
this.siteIndex = (oldestIdx + 1) % sites.length;
|
||||||
return JWT_SITES[oldestIdx];
|
return sites[oldestIdx];
|
||||||
}
|
}
|
||||||
|
|
||||||
private allocatePort(): number {
|
private allocatePort(): number {
|
||||||
@@ -572,19 +731,37 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
|
|||||||
|
|
||||||
// ─── JWT capture via Playwright ───────────────────────────
|
// ─── 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;
|
let context: BrowserContext | null = null;
|
||||||
const startTime = Date.now();
|
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 {
|
try {
|
||||||
// Build context options with proxy
|
|
||||||
const contextOptions: Record<string, unknown> = {};
|
const contextOptions: Record<string, unknown> = {};
|
||||||
if (this.useProxy) {
|
if (proxyConfig) {
|
||||||
contextOptions.proxy = {
|
contextOptions.proxy = proxyConfig;
|
||||||
server: `http://${this.proxyHost}:${port}`,
|
|
||||||
username: this.proxyUser,
|
|
||||||
password: this.proxyPass,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!this.browser) throw new Error("PCAT browser not initialized");
|
if (!this.browser) throw new Error("PCAT browser not initialized");
|
||||||
@@ -640,12 +817,16 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
|
|||||||
return route.continue();
|
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;
|
let gotoOk = true;
|
||||||
try {
|
try {
|
||||||
await page.goto(siteUrl, {
|
await page.goto(siteUrl, {
|
||||||
timeout: PAGE_TIMEOUT,
|
timeout: PAGE_TIMEOUT,
|
||||||
waitUntil: "networkidle",
|
waitUntil: "domcontentloaded",
|
||||||
});
|
});
|
||||||
} catch (navErr) {
|
} catch (navErr) {
|
||||||
// Navigation may timeout but JWT could still be captured if the
|
// 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;
|
const elapsed = Date.now() - startTime;
|
||||||
|
|
||||||
if (capturedToken) {
|
if (capturedToken) {
|
||||||
|
logCapture(true);
|
||||||
this.logger.log(`Token captured in ${elapsed}ms from ${new URL(siteUrl).hostname}`);
|
this.logger.log(`Token captured in ${elapsed}ms from ${new URL(siteUrl).hostname}`);
|
||||||
return capturedToken;
|
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}`);
|
this.logger.debug(`No token after ${elapsed}ms from ${siteUrl}`);
|
||||||
return null;
|
return null;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
logCapture(false, classifyTransportError(err));
|
||||||
this.logger.warn(`Token capture error: ${(err as Error).message}`);
|
this.logger.warn(`Token capture error: ${(err as Error).message}`);
|
||||||
return null;
|
return null;
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
|
import { ProxyTelemetryModule } from "../proxy-telemetry/proxy-telemetry.module";
|
||||||
import { PartsCatalogsAuthService } from "./parts-catalogs-auth.service";
|
import { PartsCatalogsAuthService } from "./parts-catalogs-auth.service";
|
||||||
import { PartsCatalogsService } from "./parts-catalogs.service";
|
import { PartsCatalogsService } from "./parts-catalogs.service";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [ProxyTelemetryModule],
|
||||||
providers: [PartsCatalogsAuthService, PartsCatalogsService],
|
providers: [PartsCatalogsAuthService, PartsCatalogsService],
|
||||||
exports: [PartsCatalogsService],
|
exports: [PartsCatalogsService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,14 +2,20 @@
|
|||||||
* Parts-Catalogs API Service — HTTP client for parts-catalogs.com v3
|
* 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
|
* 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
|
* captured TWS- token + supporting X-* headers. The token is portable (NOT
|
||||||
* same DataImpulse proxy port that captured the token (IP-bound).
|
* 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 { Injectable, Logger } from "@nestjs/common";
|
||||||
import { isBackfillContext } from "../../jobs/prefetch-context";
|
import { isBackfillContext } from "../../jobs/prefetch-context";
|
||||||
import { PostHogService } from "../../posthog/posthog.service";
|
import { PostHogService } from "../../posthog/posthog.service";
|
||||||
import { RedisService } from "../../redis/redis.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 { PartsCatalogsAuthService } from "./parts-catalogs-auth.service";
|
||||||
import {
|
import {
|
||||||
PcatCar,
|
PcatCar,
|
||||||
@@ -20,19 +26,38 @@ import {
|
|||||||
} from "./parts-catalogs.types";
|
} from "./parts-catalogs.types";
|
||||||
|
|
||||||
const API_BASE = "https://gui.parts-catalogs.com/v3/api/proxy";
|
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;
|
const MAX_RETRIES = Number(process.env.PCAT_MAX_RETRIES) || 2;
|
||||||
// A dead DataImpulse proxy port otherwise stalls for undici's 10s default connect
|
// undici's ProxyAgent connect.timeout bounds the connect to the proxy entry.
|
||||||
// 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.
|
|
||||||
const PROXY_CONNECT_TIMEOUT = Number(process.env.PCAT_PROXY_CONNECT_TIMEOUT_MS) || 6_000;
|
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
|
// VIN decode (/car/info) answers in 1-4s through the rotating residential proxy.
|
||||||
// prolongs dead-port connects. undici's ProxyAgent connect.timeout does NOT bound the
|
// A short per-call timeout + retries means a slow exit IP aborts fast and the
|
||||||
// connection to the proxy itself (it stays at undici's 10s default), so we bound it at
|
// next attempt re-acquires the session → a fresh IP, all within the 25s budget.
|
||||||
// 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.
|
|
||||||
const DECODE_REQUEST_TIMEOUT = Number(process.env.PCAT_DECODE_TIMEOUT_MS) || 6_000;
|
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;
|
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()
|
@Injectable()
|
||||||
export class PartsCatalogsService {
|
export class PartsCatalogsService {
|
||||||
@@ -42,6 +67,7 @@ export class PartsCatalogsService {
|
|||||||
private authService: PartsCatalogsAuthService,
|
private authService: PartsCatalogsAuthService,
|
||||||
private redis: RedisService,
|
private redis: RedisService,
|
||||||
private posthog: PostHogService,
|
private posthog: PostHogService,
|
||||||
|
private proxyTelemetry: ProxyTelemetryService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** Mark parts-catalogs as actively used (5min TTL) to defer prefetch worker */
|
/** Mark parts-catalogs as actively used (5min TTL) to defer prefetch worker */
|
||||||
@@ -62,7 +88,8 @@ export class PartsCatalogsService {
|
|||||||
async decodeVin(
|
async decodeVin(
|
||||||
vin: string,
|
vin: string,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
outcome?: { transient: boolean },
|
outcome?: { transient: boolean; coldSkip?: boolean },
|
||||||
|
opts?: { blockOnEmpty?: boolean },
|
||||||
): Promise<PcatVinResult | null> {
|
): Promise<PcatVinResult | null> {
|
||||||
if (!(await this.posthog.isSourceLive("parts-catalogs"))) {
|
if (!(await this.posthog.isSourceLive("parts-catalogs"))) {
|
||||||
this.logger.warn("parts-catalogs disabled by kill switch (kill-source-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, {
|
const data = await this.fetchWithAuth("/car/info", { q: vin }, signal, {
|
||||||
timeoutMs: DECODE_REQUEST_TIMEOUT,
|
timeoutMs: DECODE_REQUEST_TIMEOUT,
|
||||||
maxRetries: DECODE_MAX_RETRIES,
|
maxRetries: DECODE_MAX_RETRIES,
|
||||||
|
blockOnEmpty: opts?.blockOnEmpty,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!data || typeof data !== "object") {
|
if (!data || typeof data !== "object") {
|
||||||
@@ -105,9 +133,23 @@ export class PartsCatalogsService {
|
|||||||
return { cars };
|
return { cars };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const e = err as Error & { cause?: unknown };
|
const e = err as Error & { cause?: unknown };
|
||||||
// Distinguish a transient transport blip (proxy timeout / dropped DataImpulse
|
// Cold-pool fast-fail: pcat was skipped (pool warming), NOT a definitive
|
||||||
// connection) from a genuine "not found". The caller uses `outcome.transient`
|
// miss. Mark transient so the orchestrator doesn't negative-cache a VIN
|
||||||
// to decide whether to poison the negative cache — a blip must not.
|
// 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 (
|
if (
|
||||||
outcome &&
|
outcome &&
|
||||||
(e.name === "TimeoutError" ||
|
(e.name === "TimeoutError" ||
|
||||||
@@ -212,7 +254,7 @@ export class PartsCatalogsService {
|
|||||||
endpoint: string,
|
endpoint: string,
|
||||||
params?: Record<string, string>,
|
params?: Record<string, string>,
|
||||||
externalSignal?: AbortSignal,
|
externalSignal?: AbortSignal,
|
||||||
opts?: { timeoutMs?: number; maxRetries?: number },
|
opts?: { timeoutMs?: number; maxRetries?: number; blockOnEmpty?: boolean },
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
const maxRetries = opts?.maxRetries ?? MAX_RETRIES;
|
const maxRetries = opts?.maxRetries ?? MAX_RETRIES;
|
||||||
const timeoutMs = opts?.timeoutMs ?? REQUEST_TIMEOUT;
|
const timeoutMs = opts?.timeoutMs ?? REQUEST_TIMEOUT;
|
||||||
@@ -223,7 +265,12 @@ export class PartsCatalogsService {
|
|||||||
if (externalSignal?.aborted) {
|
if (externalSignal?.aborted) {
|
||||||
throw new Error("Request aborted by caller");
|
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}`);
|
const url = new URL(`${API_BASE}${endpoint}`);
|
||||||
if (params) {
|
if (params) {
|
||||||
@@ -232,6 +279,7 @@ export class PartsCatalogsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const attemptStart = Date.now();
|
||||||
try {
|
try {
|
||||||
const signals = [AbortSignal.timeout(timeoutMs)];
|
const signals = [AbortSignal.timeout(timeoutMs)];
|
||||||
if (externalSignal) signals.push(externalSignal);
|
if (externalSignal) signals.push(externalSignal);
|
||||||
@@ -262,7 +310,20 @@ export class PartsCatalogsService {
|
|||||||
|
|
||||||
const response = await fetch(url.toString(), fetchOptions);
|
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) {
|
if (response.ok) {
|
||||||
|
// Token proven good on this IP — clear any accrued failure streak.
|
||||||
|
session._slot.failCount = 0;
|
||||||
return await response.json();
|
return await response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -280,6 +341,18 @@ export class PartsCatalogsService {
|
|||||||
throw new Error(`HTTP ${response.status} from ${endpoint}: ${text.slice(0, 200)}`);
|
throw new Error(`HTTP ${response.status} from ${endpoint}: ${text.slice(0, 200)}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const e = err as Error & { cause?: unknown };
|
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
|
// Retry transient TRANSPORT failures only: request timeouts and undici
|
||||||
// network errors ("TypeError: fetch failed" — a dropped/reset DataImpulse
|
// network errors ("TypeError: fetch failed" — a dropped/reset DataImpulse
|
||||||
// proxy connection). A definitive HTTP response (e.g. 400 "list of parts
|
// 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(
|
/fetch failed|ECONNRESET|ECONNREFUSED|EAI_AGAIN|socket hang up|other side closed|terminated|UND_ERR/i.test(
|
||||||
`${e.message} ${String(e.cause ?? "")}`,
|
`${e.message} ${String(e.cause ?? "")}`,
|
||||||
);
|
);
|
||||||
if (transient && attempt < maxRetries) {
|
if (transient) {
|
||||||
this.logger.warn(
|
// A token that keeps failing transport across rotating IPs is dead at
|
||||||
`Transient transport error on ${endpoint} (attempt ${attempt + 1}/${maxRetries + 1}): ${e.message}${
|
// the source — evict it so it stops being round-robin-served (the old
|
||||||
e.cause ? ` [cause: ${String(e.cause)}]` : ""
|
// 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) {
|
||||||
// Brief backoff so a momentarily-flaky proxy port can recover; the next
|
session._slot.failCount = (session._slot.failCount ?? 0) + 1;
|
||||||
// loop iteration re-acquires a session (round-robin across the pool).
|
if (session._slot.failCount >= TRANSPORT_FAIL_EVICT_THRESHOLD) {
|
||||||
await new Promise((r) => setTimeout(r, 400 * (attempt + 1)));
|
await this.authService.invalidateSession(session);
|
||||||
continue;
|
}
|
||||||
|
}
|
||||||
|
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;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
/**
|
/**
|
||||||
* Captured from a parts-catalogs.com v3 widget request.
|
* Captured from a parts-catalogs.com v3 widget request.
|
||||||
* Token + the supporting X-* headers the widget sends with every API call.
|
* 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 {
|
export interface PcatJwtToken {
|
||||||
raw: string; // x-api-key value, e.g. "TWS-016EA7BE-..."
|
raw: string; // x-api-key value, e.g. "TWS-016EA7BE-..."
|
||||||
@@ -15,10 +20,14 @@ export interface PcatJwtToken {
|
|||||||
|
|
||||||
export interface JwtSlot {
|
export interface JwtSlot {
|
||||||
jwt: PcatJwtToken;
|
jwt: PcatJwtToken;
|
||||||
proxyPort: number;
|
|
||||||
siteUsed: string;
|
siteUsed: string;
|
||||||
capturedAt: number;
|
capturedAt: number;
|
||||||
refreshTimer: ReturnType<typeof setTimeout> | null;
|
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 {
|
export interface PcatSession {
|
||||||
|
|||||||
@@ -565,6 +565,7 @@ export const PL24_WMI_SERVICE_MAP: Record<string, string> = {
|
|||||||
// Opel / Vauxhall
|
// Opel / Vauxhall
|
||||||
W0L: "opel_parts", // Opel AG (Germany)
|
W0L: "opel_parts", // Opel AG (Germany)
|
||||||
W0V: "opel_parts", // Opel (newer Stellantis-era WMI)
|
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)
|
// Citroën (PSA)
|
||||||
VF7: "citroen_parts", // Citroën SA (France)
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,4 +27,5 @@ export const QUEUE_NAMES = {
|
|||||||
CATALOG_PREFETCH: "catalog-prefetch",
|
CATALOG_PREFETCH: "catalog-prefetch",
|
||||||
TRANSLATION: "translation",
|
TRANSLATION: "translation",
|
||||||
LIFECYCLE_EMAIL: "lifecycle-email",
|
LIFECYCLE_EMAIL: "lifecycle-email",
|
||||||
|
EXPERT_REWARDS: "expert-rewards",
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
CatalogPrefetchQueueProvider,
|
CatalogPrefetchQueueProvider,
|
||||||
} from "./queues/catalog-prefetch.queue";
|
} from "./queues/catalog-prefetch.queue";
|
||||||
import { EMEX_SCRAPE_QUEUE, EmexScrapeQueueProvider } from "./queues/emex-scrape.queue";
|
import { EMEX_SCRAPE_QUEUE, EmexScrapeQueueProvider } from "./queues/emex-scrape.queue";
|
||||||
|
import { EXPERT_REWARDS_QUEUE, ExpertRewardsQueueProvider } from "./queues/expert-rewards.queue";
|
||||||
import { LIFECYCLE_EMAIL_QUEUE, LifecycleEmailQueueProvider } from "./queues/lifecycle-email.queue";
|
import { LIFECYCLE_EMAIL_QUEUE, LifecycleEmailQueueProvider } from "./queues/lifecycle-email.queue";
|
||||||
import { QUERY_CLEANUP_QUEUE, QueryCleanupQueueProvider } from "./queues/query-cleanup.queue";
|
import { QUERY_CLEANUP_QUEUE, QueryCleanupQueueProvider } from "./queues/query-cleanup.queue";
|
||||||
import {
|
import {
|
||||||
@@ -23,6 +24,7 @@ import {
|
|||||||
QueryCleanupQueueProvider,
|
QueryCleanupQueueProvider,
|
||||||
CatalogPrefetchQueueProvider,
|
CatalogPrefetchQueueProvider,
|
||||||
LifecycleEmailQueueProvider,
|
LifecycleEmailQueueProvider,
|
||||||
|
ExpertRewardsQueueProvider,
|
||||||
PrefetchWorkerService,
|
PrefetchWorkerService,
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
@@ -31,6 +33,7 @@ import {
|
|||||||
QUERY_CLEANUP_QUEUE,
|
QUERY_CLEANUP_QUEUE,
|
||||||
CATALOG_PREFETCH_QUEUE,
|
CATALOG_PREFETCH_QUEUE,
|
||||||
LIFECYCLE_EMAIL_QUEUE,
|
LIFECYCLE_EMAIL_QUEUE,
|
||||||
|
EXPERT_REWARDS_QUEUE,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class JobsModule implements OnModuleInit, OnModuleDestroy {
|
export class JobsModule implements OnModuleInit, OnModuleDestroy {
|
||||||
@@ -39,6 +42,7 @@ export class JobsModule implements OnModuleInit, OnModuleDestroy {
|
|||||||
@Inject(QUERY_CLEANUP_QUEUE) private queryCleanupQueue: Queue,
|
@Inject(QUERY_CLEANUP_QUEUE) private queryCleanupQueue: Queue,
|
||||||
@Inject(CATALOG_PREFETCH_QUEUE) private catalogPrefetchQueue: Queue,
|
@Inject(CATALOG_PREFETCH_QUEUE) private catalogPrefetchQueue: Queue,
|
||||||
@Inject(LIFECYCLE_EMAIL_QUEUE) private lifecycleEmailQueue: Queue,
|
@Inject(LIFECYCLE_EMAIL_QUEUE) private lifecycleEmailQueue: Queue,
|
||||||
|
@Inject(EXPERT_REWARDS_QUEUE) private expertRewardsQueue: Queue,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async onModuleInit() {
|
async onModuleInit() {
|
||||||
@@ -124,6 +128,24 @@ export class JobsModule implements OnModuleInit, OnModuleDestroy {
|
|||||||
await this.catalogPrefetchQueue.removeJobScheduler("catalog-backfill-hourly").catch(() => {});
|
await this.catalogPrefetchQueue.removeJobScheduler("catalog-backfill-hourly").catch(() => {});
|
||||||
console.log("[jobs] Skipped catalog-backfill cron (not prod host)");
|
console.log("[jobs] Skipped catalog-backfill cron (not prod host)");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Parça Uzmanları sezon kapanışı: her ayın 1'i 00:00 Türkiye saati —
|
||||||
|
// biten ayın ilk 3 oylayıcısına üyelik uzatması (30/15/7 gün). Ödül
|
||||||
|
// yalnız DB'ye yazar (dış yan etki yok), o yüzden dev'de de çalışır;
|
||||||
|
// (period, rank) unique index'i çift vermeyi zaten engeller.
|
||||||
|
await this.expertRewardsQueue.upsertJobScheduler(
|
||||||
|
"expert-rewards-monthly",
|
||||||
|
{ pattern: "0 0 1 * *", tz: "Europe/Istanbul" },
|
||||||
|
{
|
||||||
|
name: "expert-rewards-grant",
|
||||||
|
data: {},
|
||||||
|
opts: {
|
||||||
|
removeOnComplete: { count: 24 },
|
||||||
|
removeOnFail: { count: 50 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
console.log("[jobs] Registered expert-rewards cron: 0 0 1 * * (Europe/Istanbul)");
|
||||||
}
|
}
|
||||||
|
|
||||||
async onModuleDestroy() {
|
async onModuleDestroy() {
|
||||||
@@ -132,6 +154,7 @@ export class JobsModule implements OnModuleInit, OnModuleDestroy {
|
|||||||
this.queryCleanupQueue.close(),
|
this.queryCleanupQueue.close(),
|
||||||
this.catalogPrefetchQueue.close(),
|
this.catalogPrefetchQueue.close(),
|
||||||
this.lifecycleEmailQueue.close(),
|
this.lifecycleEmailQueue.close(),
|
||||||
|
this.expertRewardsQueue.close(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
114
apps/api/src/jobs/processors/expert-rewards.processor.ts
Normal file
114
apps/api/src/jobs/processors/expert-rewards.processor.ts
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import { Job } from "bullmq";
|
||||||
|
import { and, asc, desc, eq, gte, lt, or, sql } from "drizzle-orm";
|
||||||
|
import { PostgresJsDatabase } from "drizzle-orm/postgres-js";
|
||||||
|
import {
|
||||||
|
oemExpertRewards,
|
||||||
|
oemVotePoints,
|
||||||
|
userSubscriptions,
|
||||||
|
users,
|
||||||
|
} from "../../database/schema/core";
|
||||||
|
import { EXPERT_REWARD_LADDER, previousTrMonthWindow } from "../../oem-votes/expert-period";
|
||||||
|
|
||||||
|
type Database = PostgresJsDatabase<Record<string, unknown>>;
|
||||||
|
|
||||||
|
// Parça Uzmanları sezon kapanışı: her ayın 1'i 00:00 TR'de biten ayın ilk 3
|
||||||
|
// oylayıcısına üyelik uzatması (30/15/7 gün). oem_expert_rewards'taki
|
||||||
|
// (period, rank) unique index run-once garantisidir — yeniden çalıştırma
|
||||||
|
// (retry, elle tetik) hiçbir şeyi ikinci kez vermez.
|
||||||
|
export async function processExpertRewards(
|
||||||
|
job: Job,
|
||||||
|
db: Database,
|
||||||
|
): Promise<{ period: string; granted: number; skipped: boolean }> {
|
||||||
|
const { start, end } = previousTrMonthWindow(new Date());
|
||||||
|
const periodLabel = start.toISOString();
|
||||||
|
console.log(`[expert-rewards] Processing job ${job.id} for period ${periodLabel}`);
|
||||||
|
|
||||||
|
const [alreadyGranted] = await db
|
||||||
|
.select({ id: oemExpertRewards.id })
|
||||||
|
.from(oemExpertRewards)
|
||||||
|
.where(eq(oemExpertRewards.periodStart, start))
|
||||||
|
.limit(1);
|
||||||
|
if (alreadyGranted) {
|
||||||
|
console.log(`[expert-rewards] Period ${periodLabel} already granted — skipping`);
|
||||||
|
return { period: periodLabel, granted: 0, skipped: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Biten sezonun ilk 3'ü — liderlik tablosuyla aynı sıralama: puan desc,
|
||||||
|
// eşitlikte puana daha erken ulaşan önde.
|
||||||
|
const totalPoints = sql<number>`sum(${oemVotePoints.points})::int`;
|
||||||
|
const top = await db
|
||||||
|
.select({ userId: oemVotePoints.userId, points: totalPoints })
|
||||||
|
.from(oemVotePoints)
|
||||||
|
.where(and(gte(oemVotePoints.createdAt, start), lt(oemVotePoints.createdAt, end)))
|
||||||
|
.groupBy(oemVotePoints.userId)
|
||||||
|
.orderBy(desc(totalPoints), asc(sql`min(${oemVotePoints.createdAt})`))
|
||||||
|
.limit(EXPERT_REWARD_LADDER.length);
|
||||||
|
|
||||||
|
if (top.length === 0) {
|
||||||
|
console.log(`[expert-rewards] No votes in period ${periodLabel} — nothing to grant`);
|
||||||
|
return { period: periodLabel, granted: 0, skipped: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
let granted = 0;
|
||||||
|
for (let i = 0; i < top.length; i++) {
|
||||||
|
const winner = top[i];
|
||||||
|
const { rank, days } = EXPERT_REWARD_LADDER[i];
|
||||||
|
|
||||||
|
await db.transaction(async (tx) => {
|
||||||
|
const inserted = await tx
|
||||||
|
.insert(oemExpertRewards)
|
||||||
|
.values({
|
||||||
|
periodStart: start,
|
||||||
|
userId: winner.userId,
|
||||||
|
rank,
|
||||||
|
points: winner.points,
|
||||||
|
rewardDays: days,
|
||||||
|
})
|
||||||
|
.onConflictDoNothing()
|
||||||
|
.returning({ id: oemExpertRewards.id });
|
||||||
|
// Yarış/yeniden-deneme: kayıt zaten varsa gün de verilmiş demektir.
|
||||||
|
if (inserted.length === 0) return;
|
||||||
|
|
||||||
|
// Referral ödül mekaniğinin birebir kopyası (worker Nest DI'sız çalıştığı
|
||||||
|
// için ReferralsService.grantRewardDays buradan çağrılamıyor): canlı
|
||||||
|
// active/trial aboneliği uzat, yoksa günleri krediye banka et — kredi bir
|
||||||
|
// sonraki trial/aktivasyonda otomatik tüketilir.
|
||||||
|
const [sub] = await tx
|
||||||
|
.select({ id: userSubscriptions.id, endDate: userSubscriptions.endDate })
|
||||||
|
.from(userSubscriptions)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(userSubscriptions.userId, winner.userId),
|
||||||
|
or(eq(userSubscriptions.status, "active"), eq(userSubscriptions.status, "trial")),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.orderBy(desc(userSubscriptions.endDate))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (sub?.endDate) {
|
||||||
|
const newEnd = new Date(sub.endDate);
|
||||||
|
newEnd.setDate(newEnd.getDate() + days);
|
||||||
|
await tx
|
||||||
|
.update(userSubscriptions)
|
||||||
|
.set({ endDate: newEnd, updatedAt: new Date() })
|
||||||
|
.where(eq(userSubscriptions.id, sub.id));
|
||||||
|
} else {
|
||||||
|
await tx
|
||||||
|
.update(users)
|
||||||
|
.set({
|
||||||
|
referralCreditDays: sql`${users.referralCreditDays} + ${days}`,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(users.id, winner.userId));
|
||||||
|
}
|
||||||
|
|
||||||
|
granted++;
|
||||||
|
console.log(
|
||||||
|
`[expert-rewards] rank=${rank} user=${winner.userId} points=${winner.points} +${days}d`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[expert-rewards] Period ${periodLabel}: granted ${granted} reward(s)`);
|
||||||
|
return { period: periodLabel, granted, skipped: false };
|
||||||
|
}
|
||||||
@@ -67,17 +67,11 @@ async function sendTrialEnding(db: Database, now: Date): Promise<number> {
|
|||||||
.innerJoin(users, eq(userSubscriptions.userId, users.id))
|
.innerJoin(users, eq(userSubscriptions.userId, users.id))
|
||||||
.leftJoin(
|
.leftJoin(
|
||||||
emailPreferences,
|
emailPreferences,
|
||||||
and(
|
and(eq(emailPreferences.userId, users.id), eq(emailPreferences.workflow, "trial-ending")),
|
||||||
eq(emailPreferences.userId, users.id),
|
|
||||||
eq(emailPreferences.workflow, "trial-ending"),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
.leftJoin(
|
.leftJoin(
|
||||||
lifecycleEmailSent,
|
lifecycleEmailSent,
|
||||||
and(
|
and(eq(lifecycleEmailSent.userId, users.id), eq(lifecycleEmailSent.workflow, "trial-ending")),
|
||||||
eq(lifecycleEmailSent.userId, users.id),
|
|
||||||
eq(lifecycleEmailSent.workflow, "trial-ending"),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
@@ -96,11 +90,7 @@ async function sendTrialEnding(db: Database, now: Date): Promise<number> {
|
|||||||
{ subscriberId: r.userId, email: r.email, firstName: firstNameOf(r.name) },
|
{ subscriberId: r.userId, email: r.email, firstName: firstNameOf(r.name) },
|
||||||
{
|
{
|
||||||
daysLeft: 3,
|
daysLeft: 3,
|
||||||
ctaUrl: buildTrackedUrl(
|
ctaUrl: buildTrackedUrl("trial-ending", r.email, webUrl("/dashboard/subscription")),
|
||||||
"trial-ending",
|
|
||||||
r.email,
|
|
||||||
webUrl("/dashboard/subscription"),
|
|
||||||
),
|
|
||||||
...(trackPixel ? { trackPixel } : {}),
|
...(trackPixel ? { trackPixel } : {}),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -134,17 +124,11 @@ async function sendWinBack(db: Database, now: Date): Promise<number> {
|
|||||||
.innerJoin(users, eq(userSubscriptions.userId, users.id))
|
.innerJoin(users, eq(userSubscriptions.userId, users.id))
|
||||||
.leftJoin(
|
.leftJoin(
|
||||||
emailPreferences,
|
emailPreferences,
|
||||||
and(
|
and(eq(emailPreferences.userId, users.id), eq(emailPreferences.workflow, "win-back")),
|
||||||
eq(emailPreferences.userId, users.id),
|
|
||||||
eq(emailPreferences.workflow, "win-back"),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
.leftJoin(
|
.leftJoin(
|
||||||
lifecycleEmailSent,
|
lifecycleEmailSent,
|
||||||
and(
|
and(eq(lifecycleEmailSent.userId, users.id), eq(lifecycleEmailSent.workflow, "win-back")),
|
||||||
eq(lifecycleEmailSent.userId, users.id),
|
|
||||||
eq(lifecycleEmailSent.workflow, "win-back"),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import { Job } from "bullmq";
|
import { Job } from "bullmq";
|
||||||
import { lt } from "drizzle-orm";
|
import { lt } from "drizzle-orm";
|
||||||
import { PostgresJsDatabase } from "drizzle-orm/postgres-js";
|
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>>;
|
type Database = PostgresJsDatabase<Record<string, unknown>>;
|
||||||
|
|
||||||
const RETENTION_DAYS = 90;
|
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(
|
export async function processQueryCleanup(
|
||||||
job: Job,
|
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()})`,
|
`[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 };
|
return { deletedCount };
|
||||||
}
|
}
|
||||||
|
|||||||
25
apps/api/src/jobs/queues/expert-rewards.queue.ts
Normal file
25
apps/api/src/jobs/queues/expert-rewards.queue.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { Provider } from "@nestjs/common";
|
||||||
|
import { Queue } from "bullmq";
|
||||||
|
import { QUEUE_NAMES, getBullConnection, getBullTelemetry } from "../bull.config";
|
||||||
|
|
||||||
|
export const EXPERT_REWARDS_QUEUE = "EXPERT_REWARDS_QUEUE";
|
||||||
|
|
||||||
|
export const ExpertRewardsQueueProvider: Provider = {
|
||||||
|
provide: EXPERT_REWARDS_QUEUE,
|
||||||
|
useFactory: () => {
|
||||||
|
const telemetry = getBullTelemetry();
|
||||||
|
return new Queue(QUEUE_NAMES.EXPERT_REWARDS, {
|
||||||
|
connection: getBullConnection(),
|
||||||
|
...(telemetry ? { telemetry } : {}),
|
||||||
|
defaultJobOptions: {
|
||||||
|
attempts: 3,
|
||||||
|
backoff: {
|
||||||
|
type: "exponential",
|
||||||
|
delay: 10000,
|
||||||
|
},
|
||||||
|
removeOnComplete: { count: 100 },
|
||||||
|
removeOnFail: { count: 200 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
35
apps/api/src/oem-suggestions/oem-suggestions.controller.ts
Normal file
35
apps/api/src/oem-suggestions/oem-suggestions.controller.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { BadRequestException, Body, Controller, Get, Post, Query } from "@nestjs/common";
|
||||||
|
import { Throttle } from "@nestjs/throttler";
|
||||||
|
import { CurrentUser } from "../common/decorators/current-user.decorator";
|
||||||
|
import { ExpertAccessService } from "../oem-votes/expert-access.service";
|
||||||
|
import { createOemSuggestionSchema } from "./oem-suggestions.dto";
|
||||||
|
import { OemSuggestionsService } from "./oem-suggestions.service";
|
||||||
|
|
||||||
|
@Controller("oem-suggestions")
|
||||||
|
export class OemSuggestionsController {
|
||||||
|
constructor(
|
||||||
|
private readonly oemSuggestionsService: OemSuggestionsService,
|
||||||
|
private readonly expertAccess: ExpertAccessService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// Spam koruması: dakikada en fazla 10 öneri.
|
||||||
|
@Post()
|
||||||
|
@Throttle({ default: { limit: 10, ttl: 60_000 } })
|
||||||
|
async create(@CurrentUser("id") userId: string, @Body() body: unknown) {
|
||||||
|
await this.expertAccess.assert(userId);
|
||||||
|
const parsed = createOemSuggestionSchema.safeParse(body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
throw new BadRequestException(parsed.error.issues[0]?.message ?? "Geçersiz öneri");
|
||||||
|
}
|
||||||
|
return this.oemSuggestionsService.create(userId, parsed.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
async list(@CurrentUser("id") userId: string, @Query("code") code: string) {
|
||||||
|
const oemCode = (code ?? "").trim();
|
||||||
|
if (oemCode.length < 2 || oemCode.length > 100) {
|
||||||
|
throw new BadRequestException("OEM kodu geçersiz");
|
||||||
|
}
|
||||||
|
return { suggestions: await this.oemSuggestionsService.list(userId, oemCode) };
|
||||||
|
}
|
||||||
|
}
|
||||||
13
apps/api/src/oem-suggestions/oem-suggestions.dto.ts
Normal file
13
apps/api/src/oem-suggestions/oem-suggestions.dto.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const createOemSuggestionSchema = z.object({
|
||||||
|
oemCode: z.string().trim().min(2, "OEM kodu geçersiz").max(100, "OEM kodu çok uzun"),
|
||||||
|
brand: z.string().trim().min(2, "Marka en az 2 karakter olmalı").max(80, "Marka çok uzun"),
|
||||||
|
suggestedCode: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(2, "Parça kodu en az 2 karakter olmalı")
|
||||||
|
.max(100, "Parça kodu çok uzun")
|
||||||
|
.refine((value) => /[a-zA-Z0-9]{2,}/.test(value), "Parça kodu geçersiz"),
|
||||||
|
});
|
||||||
|
export type CreateOemSuggestionInput = z.infer<typeof createOemSuggestionSchema>;
|
||||||
45
apps/api/src/oem-suggestions/oem-suggestions.logic.spec.ts
Normal file
45
apps/api/src/oem-suggestions/oem-suggestions.logic.spec.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { aggregateSuggestions, normalizeSuggestedCode } from "./oem-suggestions.logic";
|
||||||
|
|
||||||
|
describe("normalizeSuggestedCode", () => {
|
||||||
|
it("boşluk/tire söker, büyütür", () => {
|
||||||
|
expect(normalizeSuggestedCode("1j0 973-702")).toBe("1J0973702");
|
||||||
|
expect(normalizeSuggestedCode("8V0 615 423 E")).toBe("8V0615423E");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("aggregateSuggestions", () => {
|
||||||
|
const row = (userId: string, brand: string, code: string) => ({
|
||||||
|
userId,
|
||||||
|
brand,
|
||||||
|
suggestedCode: code,
|
||||||
|
suggestedCodeNorm: normalizeSuggestedCode(code),
|
||||||
|
});
|
||||||
|
|
||||||
|
it("aynı marka+normalize kodu tek satırda toplar, çok önerilen üste çıkar", () => {
|
||||||
|
const rows = [
|
||||||
|
row("u1", "Bosch", "0 986 478 123"),
|
||||||
|
row("u2", "bosch", "0986478123"),
|
||||||
|
row("u3", "TRW", "DF4823"),
|
||||||
|
];
|
||||||
|
const out = aggregateSuggestions(rows, "u3");
|
||||||
|
expect(out).toHaveLength(2);
|
||||||
|
expect(out[0]).toMatchObject({ brand: "Bosch", code: "0 986 478 123", count: 2, mine: false });
|
||||||
|
expect(out[1]).toMatchObject({ brand: "TRW", count: 1, mine: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("görünen yazım ilk gönderenin yazımıdır", () => {
|
||||||
|
const out = aggregateSuggestions(
|
||||||
|
[row("u1", "VALEO", "PHC123"), row("u2", "Valeo", "phc-123")],
|
||||||
|
"x",
|
||||||
|
);
|
||||||
|
expect(out[0].brand).toBe("VALEO");
|
||||||
|
expect(out[0].code).toBe("PHC123");
|
||||||
|
expect(out[0].count).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("farklı marka aynı kod ayrı satırdır", () => {
|
||||||
|
const out = aggregateSuggestions([row("u1", "Bosch", "X1"), row("u2", "TRW", "X1")], "u1");
|
||||||
|
expect(out).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
45
apps/api/src/oem-suggestions/oem-suggestions.logic.ts
Normal file
45
apps/api/src/oem-suggestions/oem-suggestions.logic.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
// Kod normalizasyonu: gruplama ve kullanıcı-başına dedup anahtarı. Görselde
|
||||||
|
// kullanıcının yazdığı hâl korunur ("1J0 973 702" ↔ "1j0973702" aynı öneri).
|
||||||
|
export function normalizeSuggestedCode(code: string): string {
|
||||||
|
return code.toUpperCase().replace(/[^A-Z0-9]/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SuggestionRow {
|
||||||
|
userId: string;
|
||||||
|
brand: string;
|
||||||
|
suggestedCode: string;
|
||||||
|
suggestedCodeNorm: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AggregatedSuggestion {
|
||||||
|
brand: string;
|
||||||
|
code: string;
|
||||||
|
count: number;
|
||||||
|
mine: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aynı (marka, normalize kod) önerisini tek satırda toplar: en çok önerilen
|
||||||
|
// üstte, eşitlikte ilk gönderilen önde (rows createdAt sıralı gelir). Görünen
|
||||||
|
// marka/kod ilk gönderenin yazımıdır.
|
||||||
|
export function aggregateSuggestions(
|
||||||
|
rows: SuggestionRow[],
|
||||||
|
userId: string,
|
||||||
|
): AggregatedSuggestion[] {
|
||||||
|
const groups = new Map<string, AggregatedSuggestion>();
|
||||||
|
for (const row of rows) {
|
||||||
|
const key = `${row.brand.trim().toLocaleLowerCase("tr-TR")}|${row.suggestedCodeNorm}`;
|
||||||
|
const existing = groups.get(key);
|
||||||
|
if (existing) {
|
||||||
|
existing.count += 1;
|
||||||
|
existing.mine ||= row.userId === userId;
|
||||||
|
} else {
|
||||||
|
groups.set(key, {
|
||||||
|
brand: row.brand.trim(),
|
||||||
|
code: row.suggestedCode.trim(),
|
||||||
|
count: 1,
|
||||||
|
mine: row.userId === userId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...groups.values()].sort((a, b) => b.count - a.count);
|
||||||
|
}
|
||||||
12
apps/api/src/oem-suggestions/oem-suggestions.module.ts
Normal file
12
apps/api/src/oem-suggestions/oem-suggestions.module.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { OemVotesModule } from "../oem-votes/oem-votes.module";
|
||||||
|
import { OemSuggestionsController } from "./oem-suggestions.controller";
|
||||||
|
import { OemSuggestionsService } from "./oem-suggestions.service";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [OemVotesModule],
|
||||||
|
controllers: [OemSuggestionsController],
|
||||||
|
providers: [OemSuggestionsService],
|
||||||
|
exports: [OemSuggestionsService],
|
||||||
|
})
|
||||||
|
export class OemSuggestionsModule {}
|
||||||
60
apps/api/src/oem-suggestions/oem-suggestions.service.ts
Normal file
60
apps/api/src/oem-suggestions/oem-suggestions.service.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { Inject, Injectable } from "@nestjs/common";
|
||||||
|
import { and, asc, eq } from "drizzle-orm";
|
||||||
|
import { DATABASE, type Database } from "../database/database.provider";
|
||||||
|
import { oemSuggestions } from "../database/schema/core";
|
||||||
|
import {
|
||||||
|
type AggregatedSuggestion,
|
||||||
|
aggregateSuggestions,
|
||||||
|
normalizeSuggestedCode,
|
||||||
|
} from "./oem-suggestions.logic";
|
||||||
|
|
||||||
|
export interface CreateSuggestionResult {
|
||||||
|
created: boolean; // false = bu kullanıcı aynı öneriyi zaten göndermiş
|
||||||
|
suggestions: AggregatedSuggestion[];
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class OemSuggestionsService {
|
||||||
|
constructor(@Inject(DATABASE) private db: Database) {}
|
||||||
|
|
||||||
|
async create(
|
||||||
|
userId: string,
|
||||||
|
input: { oemCode: string; brand: string; suggestedCode: string },
|
||||||
|
): Promise<CreateSuggestionResult> {
|
||||||
|
const oemCode = input.oemCode.trim();
|
||||||
|
// Aynı kullanıcının aynı (kod, normalize öneri) tekrarı sessizce yutulur —
|
||||||
|
// sayaç şişirme yok; farklı kullanıcılar aynı öneriyi destekleyerek büyütür.
|
||||||
|
const inserted = await this.db
|
||||||
|
.insert(oemSuggestions)
|
||||||
|
.values({
|
||||||
|
userId,
|
||||||
|
oemCode,
|
||||||
|
brand: input.brand.trim(),
|
||||||
|
suggestedCode: input.suggestedCode.trim(),
|
||||||
|
suggestedCodeNorm: normalizeSuggestedCode(input.suggestedCode),
|
||||||
|
})
|
||||||
|
.onConflictDoNothing()
|
||||||
|
.returning({ id: oemSuggestions.id });
|
||||||
|
|
||||||
|
return {
|
||||||
|
created: inserted.length > 0,
|
||||||
|
suggestions: await this.list(userId, oemCode),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async list(userId: string, oemCode: string): Promise<AggregatedSuggestion[]> {
|
||||||
|
const rows = await this.db
|
||||||
|
.select({
|
||||||
|
userId: oemSuggestions.userId,
|
||||||
|
brand: oemSuggestions.brand,
|
||||||
|
suggestedCode: oemSuggestions.suggestedCode,
|
||||||
|
suggestedCodeNorm: oemSuggestions.suggestedCodeNorm,
|
||||||
|
})
|
||||||
|
.from(oemSuggestions)
|
||||||
|
.where(and(eq(oemSuggestions.oemCode, oemCode.trim()), eq(oemSuggestions.status, "active")))
|
||||||
|
.orderBy(asc(oemSuggestions.createdAt))
|
||||||
|
.limit(500);
|
||||||
|
|
||||||
|
return aggregateSuggestions(rows, userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
49
apps/api/src/oem-votes/expert-access.service.ts
Normal file
49
apps/api/src/oem-votes/expert-access.service.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { ForbiddenException, Inject, Injectable } from "@nestjs/common";
|
||||||
|
import { and, countDistinct, eq } from "drizzle-orm";
|
||||||
|
import { DATABASE, type Database } from "../database/database.provider";
|
||||||
|
import { oemCodeCopies, queryLogs } from "../database/schema/core";
|
||||||
|
import { PostHogService } from "../posthog/posthog.service";
|
||||||
|
import { meetsExpertCriteria } from "./oem-votes.logic";
|
||||||
|
|
||||||
|
// Parça Uzmanları programının ana şalteri (PostHog, local-eval). Kapatınca
|
||||||
|
// program herkesten gizlenir; yüzdeli rollout gerekirse oradan daraltılır.
|
||||||
|
export const EXPERT_PROGRAM_FLAG = "oem-expert-program";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ExpertAccessService {
|
||||||
|
constructor(
|
||||||
|
@Inject(DATABASE) private db: Database,
|
||||||
|
private readonly posthog: PostHogService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// Erişim = flag AÇIK VE kullanım kriteri: en az 5 FARKLI başarılı VIN
|
||||||
|
// decode + en az 5 FARKLI OEM kodu kopyası (kayıtlı, kataloğu gerçekten
|
||||||
|
// kullanan üyeler — eşikler oem-votes.logic.ts'te). Flag local-eval
|
||||||
|
// edilemezse fail-open — kitleyi kriter zaten daraltır, PostHog kesintisi
|
||||||
|
// programı söndürmesin.
|
||||||
|
async check(userId: string): Promise<{ enabled: boolean }> {
|
||||||
|
const flagOn = await this.posthog.isEnabled(EXPERT_PROGRAM_FLAG, userId, true);
|
||||||
|
if (!flagOn) return { enabled: false };
|
||||||
|
|
||||||
|
const [vinRows, copyRows] = await Promise.all([
|
||||||
|
this.db
|
||||||
|
.select({ n: countDistinct(queryLogs.vin) })
|
||||||
|
.from(queryLogs)
|
||||||
|
.where(and(eq(queryLogs.userId, userId), eq(queryLogs.success, true))),
|
||||||
|
this.db
|
||||||
|
.select({ n: countDistinct(oemCodeCopies.oemCode) })
|
||||||
|
.from(oemCodeCopies)
|
||||||
|
.where(eq(oemCodeCopies.userId, userId)),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { enabled: meetsExpertCriteria(vinRows[0]?.n ?? 0, copyRows[0]?.n ?? 0) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Yazma uçları (oy, öneri) UI gizlense bile doğrudan istekle delinemesin.
|
||||||
|
async assert(userId: string): Promise<void> {
|
||||||
|
const { enabled } = await this.check(userId);
|
||||||
|
if (!enabled) {
|
||||||
|
throw new ForbiddenException("Parça Uzmanları programı hesabınızda henüz aktif değil");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
37
apps/api/src/oem-votes/expert-period.spec.ts
Normal file
37
apps/api/src/oem-votes/expert-period.spec.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { EXPERT_REWARD_LADDER, previousTrMonthWindow, trMonthStart } from "./expert-period";
|
||||||
|
|
||||||
|
describe("trMonthStart", () => {
|
||||||
|
it("ay ortasında TR-ayının başını UTC olarak verir (TR 00:00 = UTC 21:00 önceki gün)", () => {
|
||||||
|
const now = new Date("2026-06-15T10:00:00Z");
|
||||||
|
expect(trMonthStart(now).toISOString()).toBe("2026-05-31T21:00:00.000Z");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("UTC gece TR'de yeni aya geçmişse TR gününü baz alır", () => {
|
||||||
|
// UTC 30 Haziran 22:30 = TR 1 Temmuz 01:30 → içinde bulunulan TR-ayı Temmuz
|
||||||
|
const now = new Date("2026-06-30T22:30:00Z");
|
||||||
|
expect(trMonthStart(now).toISOString()).toBe("2026-06-30T21:00:00.000Z");
|
||||||
|
expect(trMonthStart(now, 1).toISOString()).toBe("2026-05-31T21:00:00.000Z");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("yıl devrini doğru çözer (TR ocak → geriye aralık)", () => {
|
||||||
|
const now = new Date("2026-01-10T12:00:00Z");
|
||||||
|
expect(trMonthStart(now, 1).toISOString()).toBe("2025-11-30T21:00:00.000Z");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("previousTrMonthWindow", () => {
|
||||||
|
it("cron anında (TR 1'i 00:00) biten ayı kapsar", () => {
|
||||||
|
// TR 1 Temmuz 00:00 = UTC 30 Haziran 21:00
|
||||||
|
const cronMoment = new Date("2026-06-30T21:00:00Z");
|
||||||
|
const { start, end } = previousTrMonthWindow(cronMoment);
|
||||||
|
expect(start.toISOString()).toBe("2026-05-31T21:00:00.000Z"); // TR 1 Haziran 00:00
|
||||||
|
expect(end.toISOString()).toBe("2026-06-30T21:00:00.000Z"); // TR 1 Temmuz 00:00
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("EXPERT_REWARD_LADDER", () => {
|
||||||
|
it("1. → 30 gün, 2. → 15 gün, 3. → 7 gün", () => {
|
||||||
|
expect(EXPERT_REWARD_LADDER.map((r) => r.days)).toEqual([30, 15, 7]);
|
||||||
|
});
|
||||||
|
});
|
||||||
23
apps/api/src/oem-votes/expert-period.ts
Normal file
23
apps/api/src/oem-votes/expert-period.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
// Parça Uzmanları aylık sezon takvimi. Sınırlar Türkiye saatine göredir
|
||||||
|
// (Europe/Istanbul, 2016'dan beri sabit UTC+3 — DST yok); sıralama her ayın
|
||||||
|
// 1'i 00:00 TR'de sıfırlanır, biten ayın ilk 3'üne üyelik uzatması verilir.
|
||||||
|
const TR_OFFSET_MS = 3 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
// Ödül merdiveni: 1. → 1 ay (30 gün), 2. → 15 gün, 3. → 7 gün üyelik.
|
||||||
|
export const EXPERT_REWARD_LADDER = [
|
||||||
|
{ rank: 1, days: 30 },
|
||||||
|
{ rank: 2, days: 15 },
|
||||||
|
{ rank: 3, days: 7 },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
// `monthsBack` ay geriden TR-ayının başlangıcını UTC Date olarak döndürür
|
||||||
|
// (0 = içinde bulunulan ay). Date.UTC ay taşmalarını (ocak-1 → aralık) çözer.
|
||||||
|
export function trMonthStart(now: Date, monthsBack = 0): Date {
|
||||||
|
const tr = new Date(now.getTime() + TR_OFFSET_MS);
|
||||||
|
return new Date(Date.UTC(tr.getUTCFullYear(), tr.getUTCMonth() - monthsBack, 1) - TR_OFFSET_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Biten sezonun penceresi: [önceki TR-ay başı, bu TR-ay başı).
|
||||||
|
export function previousTrMonthWindow(now: Date): { start: Date; end: Date } {
|
||||||
|
return { start: trMonthStart(now, 1), end: trMonthStart(now, 0) };
|
||||||
|
}
|
||||||
47
apps/api/src/oem-votes/oem-votes.controller.ts
Normal file
47
apps/api/src/oem-votes/oem-votes.controller.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { BadRequestException, Body, Controller, Get, Post } from "@nestjs/common";
|
||||||
|
import { Throttle } from "@nestjs/throttler";
|
||||||
|
import { CurrentUser } from "../common/decorators/current-user.decorator";
|
||||||
|
import { ExpertAccessService } from "./expert-access.service";
|
||||||
|
import { castOemVoteSchema, lookupOemVotesSchema } from "./oem-votes.dto";
|
||||||
|
import { OemVotesService } from "./oem-votes.service";
|
||||||
|
|
||||||
|
@Controller("oem-votes")
|
||||||
|
export class OemVotesController {
|
||||||
|
constructor(
|
||||||
|
private readonly oemVotesService: OemVotesService,
|
||||||
|
private readonly expertAccess: ExpertAccessService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// Program kapısı: flag + kullanım kriteri (web açılışta bir kez sorar).
|
||||||
|
@Get("access")
|
||||||
|
async access(@CurrentUser("id") userId: string) {
|
||||||
|
return this.expertAccess.check(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Puan çiftliğine karşı insan-hızı sınırı: dakikada en fazla 30 oy.
|
||||||
|
@Post()
|
||||||
|
@Throttle({ default: { limit: 30, ttl: 60_000 } })
|
||||||
|
async cast(@CurrentUser("id") userId: string, @Body() body: unknown) {
|
||||||
|
await this.expertAccess.assert(userId);
|
||||||
|
const parsed = castOemVoteSchema.safeParse(body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
throw new BadRequestException(parsed.error.issues[0]?.message ?? "Geçersiz oy verisi");
|
||||||
|
}
|
||||||
|
return this.oemVotesService.castVote(userId, parsed.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kod listesi URL sınırına sığmayacak kadar uzayabildiği için POST (bkz. /p/matched).
|
||||||
|
@Post("lookup")
|
||||||
|
async lookup(@CurrentUser("id") userId: string, @Body() body: unknown) {
|
||||||
|
const parsed = lookupOemVotesSchema.safeParse(body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
throw new BadRequestException(parsed.error.issues[0]?.message ?? "Geçersiz kod listesi");
|
||||||
|
}
|
||||||
|
return { votes: await this.oemVotesService.lookup(userId, parsed.data.codes) };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("leaderboard")
|
||||||
|
async leaderboard(@CurrentUser("id") userId: string) {
|
||||||
|
return this.oemVotesService.leaderboard(userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
17
apps/api/src/oem-votes/oem-votes.dto.ts
Normal file
17
apps/api/src/oem-votes/oem-votes.dto.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const castOemVoteSchema = z.object({
|
||||||
|
oemCode: z.string().trim().min(2, "OEM kodu geçersiz").max(100, "OEM kodu çok uzun"),
|
||||||
|
vote: z.enum(["compatible", "incompatible"]),
|
||||||
|
// Analitik bağlamı — katalog rotaları catalog_vehicles id'si yollayabilir,
|
||||||
|
// UUID olmayan değerler serviste sessizce düşürülür (oy yine kaydedilir).
|
||||||
|
partId: z.string().max(100).optional(),
|
||||||
|
vehicleId: z.string().max(100).optional(),
|
||||||
|
categoryId: z.string().max(100).optional(),
|
||||||
|
});
|
||||||
|
export type CastOemVoteInput = z.infer<typeof castOemVoteSchema>;
|
||||||
|
|
||||||
|
export const lookupOemVotesSchema = z.object({
|
||||||
|
codes: z.array(z.string().trim().min(1).max(100)).min(1).max(400),
|
||||||
|
});
|
||||||
|
export type LookupOemVotesInput = z.infer<typeof lookupOemVotesSchema>;
|
||||||
79
apps/api/src/oem-votes/oem-votes.logic.spec.ts
Normal file
79
apps/api/src/oem-votes/oem-votes.logic.spec.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { computeVoteAward, maskExpertName, meetsExpertCriteria } from "./oem-votes.logic";
|
||||||
|
|
||||||
|
describe("computeVoteAward", () => {
|
||||||
|
it("ilk oy: kimse oylamamışsa 3 puan (1 oy + 2 doğru)", () => {
|
||||||
|
expect(computeVoteAward({ compatible: 0, incompatible: 0 }, "compatible")).toEqual({
|
||||||
|
points: 3,
|
||||||
|
correct: true,
|
||||||
|
});
|
||||||
|
expect(computeVoteAward({ compatible: 0, incompatible: 0 }, "incompatible")).toEqual({
|
||||||
|
points: 3,
|
||||||
|
correct: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("1 kişi uyumlu demişken uyumsuz diyen 1 puan, uyumlu diyen 3 puan alır", () => {
|
||||||
|
const prior = { compatible: 1, incompatible: 0 };
|
||||||
|
expect(computeVoteAward(prior, "incompatible")).toEqual({ points: 1, correct: false });
|
||||||
|
expect(computeVoteAward(prior, "compatible")).toEqual({ points: 3, correct: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("beraberliği bozan oy çoğunluğu kendi tarafına çevirdiği için 3 puan alır", () => {
|
||||||
|
expect(computeVoteAward({ compatible: 1, incompatible: 1 }, "compatible")).toEqual({
|
||||||
|
points: 3,
|
||||||
|
correct: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("açık çoğunluğa karşı oy yalnız taban puanı alır", () => {
|
||||||
|
expect(computeVoteAward({ compatible: 5, incompatible: 1 }, "incompatible")).toEqual({
|
||||||
|
points: 1,
|
||||||
|
correct: false,
|
||||||
|
});
|
||||||
|
expect(computeVoteAward({ compatible: 5, incompatible: 1 }, "compatible")).toEqual({
|
||||||
|
points: 3,
|
||||||
|
correct: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("oyumla beraberlik oluşuyorsa çoğunluk sağlanmaz, 1 puan", () => {
|
||||||
|
// 2-1 iken uyumsuz oyu → 2-2: kesin çoğunluk yok.
|
||||||
|
expect(computeVoteAward({ compatible: 2, incompatible: 1 }, "incompatible")).toEqual({
|
||||||
|
points: 1,
|
||||||
|
correct: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("maskExpertName", () => {
|
||||||
|
it("ad ve soyadın yalnız ilk harfini açık bırakır", () => {
|
||||||
|
expect(maskExpertName("Semih Yılmaz")).toBe("S*** Y***");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ara adları düşürür, soyad olarak son kelimeyi alır", () => {
|
||||||
|
expect(maskExpertName("Ali Rıza Demir")).toBe("A*** D***");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tek kelimelik adı maskeler", () => {
|
||||||
|
expect(maskExpertName("Semih")).toBe("S***");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Türkçe karakterleri doğru büyütür", () => {
|
||||||
|
expect(maskExpertName("ismail çelik")).toBe("İ*** Ç***");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("boş ada güvenli düşer", () => {
|
||||||
|
expect(maskExpertName(" ")).toBe("Üye");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("meetsExpertCriteria", () => {
|
||||||
|
it("en az 5 farklı VIN VE en az 5 farklı OEM kopyası ister", () => {
|
||||||
|
expect(meetsExpertCriteria(5, 5)).toBe(true);
|
||||||
|
expect(meetsExpertCriteria(12, 7)).toBe(true);
|
||||||
|
expect(meetsExpertCriteria(4, 5)).toBe(false);
|
||||||
|
expect(meetsExpertCriteria(5, 4)).toBe(false);
|
||||||
|
expect(meetsExpertCriteria(0, 0)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
46
apps/api/src/oem-votes/oem-votes.logic.ts
Normal file
46
apps/api/src/oem-votes/oem-votes.logic.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
export type OemVoteChoice = "compatible" | "incompatible";
|
||||||
|
|
||||||
|
export interface VoteCounts {
|
||||||
|
compatible: number;
|
||||||
|
incompatible: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VoteAward {
|
||||||
|
points: number;
|
||||||
|
correct: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const VOTE_BASE_POINTS = 1;
|
||||||
|
export const VOTE_MAJORITY_BONUS = 2;
|
||||||
|
|
||||||
|
// "Doğru oy" = kendi oyu da sayıldığında kesin çoğunlukla aynı tarafta olmak.
|
||||||
|
// İlk oy (0-0 → 1-0) ve beraberliği bozan oy çoğunluğu kendi tarafına
|
||||||
|
// çevirdiği için doğrudur (3 puan); mevcut çoğunluğa karşı oy yalnız taban
|
||||||
|
// puanı alır (1). Ödül oy anında kesinleşir, çoğunluk sonradan dönse bile
|
||||||
|
// geriye dönük düzeltilmez.
|
||||||
|
export function computeVoteAward(prior: VoteCounts, vote: OemVoteChoice): VoteAward {
|
||||||
|
const mine = (vote === "compatible" ? prior.compatible : prior.incompatible) + 1;
|
||||||
|
const other = vote === "compatible" ? prior.incompatible : prior.compatible;
|
||||||
|
const correct = mine > other;
|
||||||
|
return { points: VOTE_BASE_POINTS + (correct ? VOTE_MAJORITY_BONUS : 0), correct };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Program kitle kriteri: kataloğu gerçekten kullanan kayıtlı üyeler —
|
||||||
|
// en az 5 FARKLI başarılı VIN decode VE en az 5 FARKLI OEM kodu kopyası.
|
||||||
|
export const MIN_DISTINCT_VINS = 5;
|
||||||
|
export const MIN_DISTINCT_OEM_COPIES = 5;
|
||||||
|
|
||||||
|
export function meetsExpertCriteria(distinctVins: number, distinctOemCopies: number): boolean {
|
||||||
|
return distinctVins >= MIN_DISTINCT_VINS && distinctOemCopies >= MIN_DISTINCT_OEM_COPIES;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Liderlik listesi adları KVKK-dostu: yalnız ad ve soyadın ilk harfi açık
|
||||||
|
// ("Semih Yılmaz" → "S*** Y***"). Ara adlar tamamen düşer; tek kelimelik
|
||||||
|
// adlarda o kelimenin ilk harfi kalır.
|
||||||
|
export function maskExpertName(fullName: string): string {
|
||||||
|
const words = fullName.trim().split(/\s+/).filter(Boolean);
|
||||||
|
if (words.length === 0) return "Üye";
|
||||||
|
const maskWord = (w: string) => `${w.charAt(0).toLocaleUpperCase("tr-TR")}***`;
|
||||||
|
if (words.length === 1) return maskWord(words[0]);
|
||||||
|
return `${maskWord(words[0])} ${maskWord(words[words.length - 1])}`;
|
||||||
|
}
|
||||||
11
apps/api/src/oem-votes/oem-votes.module.ts
Normal file
11
apps/api/src/oem-votes/oem-votes.module.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { ExpertAccessService } from "./expert-access.service";
|
||||||
|
import { OemVotesController } from "./oem-votes.controller";
|
||||||
|
import { OemVotesService } from "./oem-votes.service";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [OemVotesController],
|
||||||
|
providers: [OemVotesService, ExpertAccessService],
|
||||||
|
exports: [OemVotesService, ExpertAccessService],
|
||||||
|
})
|
||||||
|
export class OemVotesModule {}
|
||||||
209
apps/api/src/oem-votes/oem-votes.service.ts
Normal file
209
apps/api/src/oem-votes/oem-votes.service.ts
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
import { Inject, Injectable } from "@nestjs/common";
|
||||||
|
import { and, count, desc, eq, gte, inArray, sql } from "drizzle-orm";
|
||||||
|
import { DATABASE, type Database } from "../database/database.provider";
|
||||||
|
import { oemVotePoints, oemVotes, users } from "../database/schema/core";
|
||||||
|
import { trMonthStart } from "./expert-period";
|
||||||
|
import {
|
||||||
|
type OemVoteChoice,
|
||||||
|
type VoteCounts,
|
||||||
|
computeVoteAward,
|
||||||
|
maskExpertName,
|
||||||
|
} from "./oem-votes.logic";
|
||||||
|
|
||||||
|
type Tx = Parameters<Parameters<Database["transaction"]>[0]>[0];
|
||||||
|
|
||||||
|
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||||
|
const uuidOrNull = (value?: string) => (value && UUID_RE.test(value) ? value : null);
|
||||||
|
|
||||||
|
export interface CastVoteResult {
|
||||||
|
oemCode: string;
|
||||||
|
myVote: OemVoteChoice;
|
||||||
|
counts: VoteCounts;
|
||||||
|
pointsAwarded: number;
|
||||||
|
correct: boolean | null;
|
||||||
|
isNew: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OemVoteSummary extends VoteCounts {
|
||||||
|
myVote: OemVoteChoice | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LeaderboardEntry {
|
||||||
|
rank: number;
|
||||||
|
name: string;
|
||||||
|
points: number;
|
||||||
|
votes: number;
|
||||||
|
isMe: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class OemVotesService {
|
||||||
|
constructor(@Inject(DATABASE) private db: Database) {}
|
||||||
|
|
||||||
|
async castVote(
|
||||||
|
userId: string,
|
||||||
|
input: {
|
||||||
|
oemCode: string;
|
||||||
|
vote: OemVoteChoice;
|
||||||
|
partId?: string;
|
||||||
|
vehicleId?: string;
|
||||||
|
categoryId?: string;
|
||||||
|
},
|
||||||
|
): Promise<CastVoteResult> {
|
||||||
|
const oemCode = input.oemCode.trim();
|
||||||
|
const context = {
|
||||||
|
partId: uuidOrNull(input.partId),
|
||||||
|
vehicleId: uuidOrNull(input.vehicleId),
|
||||||
|
categoryId: uuidOrNull(input.categoryId),
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.db.transaction(async (tx) => {
|
||||||
|
// Aynı kod üzerindeki eşzamanlı oyları sıraya sok: ilk-oy bonusu ve
|
||||||
|
// çoğunluk hesabı yarışsız, deterministik kalır. Kod bazlı kilit —
|
||||||
|
// farklı kodlar birbirini bekletmez, tx sonunda otomatik bırakılır.
|
||||||
|
await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtext(${`oem-vote:${oemCode}`}))`);
|
||||||
|
|
||||||
|
const [existing] = await tx
|
||||||
|
.select({ id: oemVotes.id, vote: oemVotes.vote })
|
||||||
|
.from(oemVotes)
|
||||||
|
.where(and(eq(oemVotes.userId, userId), eq(oemVotes.oemCode, oemCode)))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
// Fikir değişikliği oyu günceller ama puan üretmez (puan çiftliği yok).
|
||||||
|
if (existing.vote !== input.vote) {
|
||||||
|
await tx
|
||||||
|
.update(oemVotes)
|
||||||
|
.set({ vote: input.vote, ...context, updatedAt: new Date() })
|
||||||
|
.where(eq(oemVotes.id, existing.id));
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
oemCode,
|
||||||
|
myVote: input.vote,
|
||||||
|
counts: await this.countVotes(tx, oemCode),
|
||||||
|
pointsAwarded: 0,
|
||||||
|
correct: null,
|
||||||
|
isNew: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const prior = await this.countVotes(tx, oemCode);
|
||||||
|
const award = computeVoteAward(prior, input.vote);
|
||||||
|
|
||||||
|
const [inserted] = await tx
|
||||||
|
.insert(oemVotes)
|
||||||
|
.values({ userId, oemCode, vote: input.vote, ...context })
|
||||||
|
.returning({ id: oemVotes.id });
|
||||||
|
|
||||||
|
await tx.insert(oemVotePoints).values({
|
||||||
|
userId,
|
||||||
|
voteId: inserted.id,
|
||||||
|
oemCode,
|
||||||
|
points: award.points,
|
||||||
|
correct: award.correct,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
oemCode,
|
||||||
|
myVote: input.vote,
|
||||||
|
counts: {
|
||||||
|
compatible: prior.compatible + (input.vote === "compatible" ? 1 : 0),
|
||||||
|
incompatible: prior.incompatible + (input.vote === "incompatible" ? 1 : 0),
|
||||||
|
},
|
||||||
|
pointsAwarded: award.points,
|
||||||
|
correct: award.correct,
|
||||||
|
isNew: true,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parça listesi açılırken görünen kodların oy özetleri — tek toplu sorgu.
|
||||||
|
async lookup(userId: string, codes: string[]): Promise<Record<string, OemVoteSummary>> {
|
||||||
|
const unique = [...new Set(codes.map((c) => c.trim()).filter(Boolean))];
|
||||||
|
if (unique.length === 0) return {};
|
||||||
|
|
||||||
|
const [tallies, mine] = await Promise.all([
|
||||||
|
this.db
|
||||||
|
.select({ oemCode: oemVotes.oemCode, vote: oemVotes.vote, total: count() })
|
||||||
|
.from(oemVotes)
|
||||||
|
.where(inArray(oemVotes.oemCode, unique))
|
||||||
|
.groupBy(oemVotes.oemCode, oemVotes.vote),
|
||||||
|
this.db
|
||||||
|
.select({ oemCode: oemVotes.oemCode, vote: oemVotes.vote })
|
||||||
|
.from(oemVotes)
|
||||||
|
.where(and(eq(oemVotes.userId, userId), inArray(oemVotes.oemCode, unique))),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result: Record<string, OemVoteSummary> = {};
|
||||||
|
const entry = (code: string) => {
|
||||||
|
result[code] ??= { compatible: 0, incompatible: 0, myVote: null };
|
||||||
|
return result[code];
|
||||||
|
};
|
||||||
|
for (const row of tallies) {
|
||||||
|
const summary = entry(row.oemCode);
|
||||||
|
if (row.vote === "compatible") summary.compatible = row.total;
|
||||||
|
else summary.incompatible = row.total;
|
||||||
|
}
|
||||||
|
for (const row of mine) {
|
||||||
|
entry(row.oemCode).myVote = row.vote as OemVoteChoice;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parça Uzmanları: içinde bulunulan TR-ayının (sezonun) sıralaması — her
|
||||||
|
// ayın 1'i 00:00 TR'de sıfırlanır, biten ayın ilk 3'üne expert-rewards
|
||||||
|
// cron'u üyelik uzatması verir. Puana göre herkes, adlar maskeli
|
||||||
|
// ("S*** Y***"); yanıt userId sızdırmaz, istek sahibi kendi satırını isMe
|
||||||
|
// ile bulur. Sıralama ödül job'ıyla birebir aynı: puan desc, eşitlikte
|
||||||
|
// puana daha erken ulaşan önde.
|
||||||
|
async leaderboard(userId: string): Promise<{
|
||||||
|
entries: LeaderboardEntry[];
|
||||||
|
me: { rank: number | null; points: number; votes: number };
|
||||||
|
periodStart: string;
|
||||||
|
}> {
|
||||||
|
const periodStart = trMonthStart(new Date());
|
||||||
|
const totalPoints = sql<number>`sum(${oemVotePoints.points})::int`;
|
||||||
|
const rows = await this.db
|
||||||
|
.select({
|
||||||
|
userId: oemVotePoints.userId,
|
||||||
|
name: users.name,
|
||||||
|
points: totalPoints,
|
||||||
|
votes: count(),
|
||||||
|
})
|
||||||
|
.from(oemVotePoints)
|
||||||
|
.innerJoin(users, eq(users.id, oemVotePoints.userId))
|
||||||
|
.where(gte(oemVotePoints.createdAt, periodStart))
|
||||||
|
.groupBy(oemVotePoints.userId, users.name)
|
||||||
|
.orderBy(desc(totalPoints), sql`min(${oemVotePoints.createdAt}) asc`)
|
||||||
|
.limit(500);
|
||||||
|
|
||||||
|
const entries = rows.map((row, i) => ({
|
||||||
|
rank: i + 1,
|
||||||
|
name: maskExpertName(row.name),
|
||||||
|
points: row.points,
|
||||||
|
votes: row.votes,
|
||||||
|
isMe: row.userId === userId,
|
||||||
|
}));
|
||||||
|
const my = entries.find((e) => e.isMe);
|
||||||
|
return {
|
||||||
|
entries,
|
||||||
|
me: { rank: my?.rank ?? null, points: my?.points ?? 0, votes: my?.votes ?? 0 },
|
||||||
|
periodStart: periodStart.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async countVotes(tx: Tx, oemCode: string): Promise<VoteCounts> {
|
||||||
|
const rows = await tx
|
||||||
|
.select({ vote: oemVotes.vote, total: count() })
|
||||||
|
.from(oemVotes)
|
||||||
|
.where(eq(oemVotes.oemCode, oemCode))
|
||||||
|
.groupBy(oemVotes.vote);
|
||||||
|
|
||||||
|
const counts: VoteCounts = { compatible: 0, incompatible: 0 };
|
||||||
|
for (const row of rows) {
|
||||||
|
if (row.vote === "compatible") counts.compatible = row.total;
|
||||||
|
else counts.incompatible = row.total;
|
||||||
|
}
|
||||||
|
return counts;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,13 @@ export class PartsController {
|
|||||||
return this.partsService.searchByOem(oem);
|
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")
|
@Get(":id")
|
||||||
async getById(@Param("id") id: string) {
|
async getById(@Param("id") id: string) {
|
||||||
return this.partsService.getById(id);
|
return this.partsService.getById(id);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Inject, Injectable, Logger, NotFoundException } from "@nestjs/common";
|
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 { DATABASE, type Database } from "../database/database.provider";
|
||||||
import { categories, parts, schemaPics, vehicles } from "../database/schema/core";
|
import { categories, parts, schemaPics, vehicles } from "../database/schema/core";
|
||||||
import { PL24Service } from "../integrations/pl24/pl24.service";
|
import { PL24Service } from "../integrations/pl24/pl24.service";
|
||||||
@@ -142,4 +142,32 @@ export class PartsService {
|
|||||||
if (!part) throw new NotFoundException("Parça bulunamadı");
|
if (!part) throw new NotFoundException("Parça bulunamadı");
|
||||||
return part;
|
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
|
* Payment history for the billing page. Returns a curated projection — never
|
||||||
* the raw row — so internal columns (adminNote, iyzicoPaymentId,
|
* the raw row — so internal columns (adminNote, iyzicoPaymentId,
|
||||||
* bankAccountId, session/intent ids) are not leaked to the client. planName
|
* session/intent ids) are not leaked to the client. planName is joined from
|
||||||
* is joined from the subscription's plan; Stripe receipt availability is
|
* the subscription's plan; Stripe receipt availability is surfaced as a
|
||||||
* surfaced as a boolean rather than exposing the payment intent id.
|
* boolean rather than exposing the payment intent id.
|
||||||
*/
|
*/
|
||||||
async getMyPayments(userId: string) {
|
async getMyPayments(userId: string) {
|
||||||
const rows = await this.db
|
const rows = await this.db
|
||||||
@@ -29,7 +29,6 @@ export class PaymentsService {
|
|||||||
status: payments.status,
|
status: payments.status,
|
||||||
createdAt: payments.createdAt,
|
createdAt: payments.createdAt,
|
||||||
planName: plans.name,
|
planName: plans.name,
|
||||||
eftReceiptUrl: payments.eftReceiptUrl,
|
|
||||||
stripePaymentIntentId: payments.stripePaymentIntentId,
|
stripePaymentIntentId: payments.stripePaymentIntentId,
|
||||||
})
|
})
|
||||||
.from(payments)
|
.from(payments)
|
||||||
@@ -45,16 +44,15 @@ export class PaymentsService {
|
|||||||
status: row.status,
|
status: row.status,
|
||||||
createdAt: row.createdAt,
|
createdAt: row.createdAt,
|
||||||
planName: row.planName ?? null,
|
planName: row.planName ?? null,
|
||||||
eftReceiptUrl: row.eftReceiptUrl ?? null,
|
|
||||||
hasStripeReceipt:
|
hasStripeReceipt:
|
||||||
row.method === "stripe" && row.status === "completed" && !!row.stripePaymentIntentId,
|
row.method === "stripe" && row.status === "completed" && !!row.stripePaymentIntentId,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve a receipt URL for one of the current user's payments.
|
* Resolve a receipt URL for one of the current user's payments. Stripe is the
|
||||||
* EFT receipts are stored locally; Stripe receipts are fetched live from the
|
* sole payment method; receipts are fetched live from the payment intent's
|
||||||
* payment intent's latest charge. Returns { url: null } when none exists.
|
* latest charge. Returns { url: null } when none exists.
|
||||||
*/
|
*/
|
||||||
async getReceiptUrl(userId: string, paymentId: string): Promise<{ url: string | null }> {
|
async getReceiptUrl(userId: string, paymentId: string): Promise<{ url: string | null }> {
|
||||||
const [payment] = await this.db
|
const [payment] = await this.db
|
||||||
@@ -67,10 +65,6 @@ export class PaymentsService {
|
|||||||
throw new NotFoundException("Payment not found");
|
throw new NotFoundException("Payment not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (payment.eftReceiptUrl) {
|
|
||||||
return { url: payment.eftReceiptUrl };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (payment.method === "stripe" && payment.stripePaymentIntentId) {
|
if (payment.method === "stripe" && payment.stripePaymentIntentId) {
|
||||||
return { url: await this.stripeService.getReceiptUrl(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({
|
const session = await this.stripe.checkout.sessions.create({
|
||||||
mode: "payment",
|
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"],
|
payment_method_types: ["card"],
|
||||||
customer_email: userEmail,
|
customer_email: userEmail,
|
||||||
line_items: [
|
line_items: [
|
||||||
@@ -257,6 +261,14 @@ export class StripeService {
|
|||||||
const paymentIntentId =
|
const paymentIntentId =
|
||||||
typeof session.payment_intent === "string" ? session.payment_intent : null;
|
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
|
await this.db
|
||||||
.update(payments)
|
.update(payments)
|
||||||
.set({
|
.set({
|
||||||
@@ -266,8 +278,6 @@ export class StripeService {
|
|||||||
})
|
})
|
||||||
.where(eq(payments.id, paymentId));
|
.where(eq(payments.id, paymentId));
|
||||||
|
|
||||||
const activated = await this.subscriptionsService.activateSubscription(payment.subscriptionId);
|
|
||||||
|
|
||||||
this.posthog.captureForUser(payment.userId, "payment_success", {
|
this.posthog.captureForUser(payment.userId, "payment_success", {
|
||||||
method: "stripe",
|
method: "stripe",
|
||||||
payment_id: paymentId,
|
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,
|
ConflictException,
|
||||||
Inject,
|
Inject,
|
||||||
Injectable,
|
Injectable,
|
||||||
|
Logger,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { and, desc, eq, inArray, or } from "drizzle-orm";
|
import { and, desc, eq, inArray, or } from "drizzle-orm";
|
||||||
@@ -20,6 +21,8 @@ import { PostHogService } from "../posthog/posthog.service";
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SubscriptionsService {
|
export class SubscriptionsService {
|
||||||
|
private readonly logger = new Logger(SubscriptionsService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(DATABASE) private db: Database,
|
@Inject(DATABASE) private db: Database,
|
||||||
private posthog: PostHogService,
|
private posthog: PostHogService,
|
||||||
@@ -123,6 +126,17 @@ export class SubscriptionsService {
|
|||||||
|
|
||||||
if (!sub) throw new NotFoundException("Abonelik bulunamadı");
|
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 now = new Date();
|
||||||
const endDate = new Date(now);
|
const endDate = new Date(now);
|
||||||
if (sub.billingPeriod === "yearly") {
|
if (sub.billingPeriod === "yearly") {
|
||||||
|
|||||||
@@ -37,10 +37,14 @@ export class VehiclesController {
|
|||||||
async decode(
|
async decode(
|
||||||
@CurrentUser("id") userId: string,
|
@CurrentUser("id") userId: string,
|
||||||
@Body("vin", VinValidationPipe) vin: 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("pcatCarId") pcatCarId?: string,
|
||||||
@Body("emexCarIndex") emexCarIndex?: number,
|
@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")
|
@Get("history")
|
||||||
|
|||||||
@@ -166,7 +166,11 @@ describe("VehiclesService", () => {
|
|||||||
|
|
||||||
const { service } = createService(db);
|
const { service } = createService(db);
|
||||||
const result = await service.decodeVin("WBAPH5C55BA123456", "u1");
|
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 () => {
|
it("should throw BadRequestException when corgi doesn't recognize VIN", async () => {
|
||||||
@@ -435,7 +439,8 @@ describe("VehiclesService", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const result = await service.decodeVin("WBAPH5C55BA123456", "u1");
|
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(partsCatalogsService.decodeVin).toHaveBeenCalled();
|
||||||
expect(emexService.decodeVinOrCandidates).toHaveBeenCalledWith("WBAPH5C55BA123456");
|
expect(emexService.decodeVinOrCandidates).toHaveBeenCalledWith("WBAPH5C55BA123456");
|
||||||
});
|
});
|
||||||
@@ -473,7 +478,7 @@ describe("VehiclesService", () => {
|
|||||||
emexService.decodeVinOrCandidates.mockResolvedValue({ type: "notFound" });
|
emexService.decodeVinOrCandidates.mockResolvedValue({ type: "notFound" });
|
||||||
|
|
||||||
const result = await service.decodeVin("WVWZZZ1JZ3W597935", "u1");
|
const result = await service.decodeVin("WVWZZZ1JZ3W597935", "u1");
|
||||||
expect(result).toEqual(savedVehicle);
|
expect(result).toMatchObject({ id: "v-pcat" });
|
||||||
expect(pl24Service.decodeVin).not.toHaveBeenCalled();
|
expect(pl24Service.decodeVin).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -488,7 +493,7 @@ describe("VehiclesService", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const result = await service.decodeVin("WBAPH5C55BA123456", "u1");
|
const result = await service.decodeVin("WBAPH5C55BA123456", "u1");
|
||||||
expect(result).toEqual(savedVehicle);
|
expect(result).toMatchObject({ id: "v-emex" });
|
||||||
expect(emexService.decodeVinOrCandidates).toHaveBeenCalled();
|
expect(emexService.decodeVinOrCandidates).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -511,7 +516,7 @@ describe("VehiclesService", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const result = await service.decodeVin("VR3EFYHZ3PJ674071", "u1");
|
const result = await service.decodeVin("VR3EFYHZ3PJ674071", "u1");
|
||||||
expect(result).toEqual(savedVehicle);
|
expect(result).toMatchObject({ id: "v-pl24" });
|
||||||
expect(pl24Service.decodeVin).toHaveBeenCalledWith("VR3EFYHZ3PJ674071", "u1");
|
expect(pl24Service.decodeVin).toHaveBeenCalledWith("VR3EFYHZ3PJ674071", "u1");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -620,8 +625,9 @@ describe("VehiclesService", () => {
|
|||||||
|
|
||||||
const res = await service.previewVin("NM435600006H43436");
|
const res = await service.previewVin("NM435600006H43436");
|
||||||
|
|
||||||
expect(res.source).toBe("parts-catalogs");
|
|
||||||
expect(res.brandName).toBe("Fiat"); // was null before the fix
|
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 { EmexService } from "../integrations/emex/emex.service";
|
||||||
import { EmexCandidate } from "../integrations/emex/emex.service";
|
import { EmexCandidate } from "../integrations/emex/emex.service";
|
||||||
import { PartsCatalogsService } from "../integrations/parts-catalogs/parts-catalogs.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 { PL24Service } from "../integrations/pl24/pl24.service";
|
||||||
import { VinApiService } from "../integrations/vin-api/vin-api.service";
|
import { VinApiService } from "../integrations/vin-api/vin-api.service";
|
||||||
import { PrefetchSource } from "../jobs/prefetch.types";
|
import { PrefetchSource } from "../jobs/prefetch.types";
|
||||||
import { CATALOG_PREFETCH_QUEUE } from "../jobs/queues/catalog-prefetch.queue";
|
import { CATALOG_PREFETCH_QUEUE } from "../jobs/queues/catalog-prefetch.queue";
|
||||||
import { RedisService } from "../redis/redis.service";
|
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
|
* Per-decode metadata, persisted to `query_logs.timings` jsonb. Despite the
|
||||||
@@ -66,6 +66,26 @@ interface VinResolveResult {
|
|||||||
emexCandidates?: EmexCandidate[];
|
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()
|
@Injectable()
|
||||||
export class VehiclesService {
|
export class VehiclesService {
|
||||||
private readonly logger = new Logger(VehiclesService.name);
|
private readonly logger = new Logger(VehiclesService.name);
|
||||||
@@ -81,8 +101,31 @@ export class VehiclesService {
|
|||||||
private redis: RedisService,
|
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();
|
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 = {
|
const ctx: ResolveContext = {
|
||||||
timings: {
|
timings: {
|
||||||
wmi: vin.length >= 3 ? vin.substring(0, 3).toUpperCase() : "",
|
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)
|
// 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);
|
const [existing] = await this.db.select().from(vehicles).where(eq(vehicles.vin, vin)).limit(1);
|
||||||
|
|
||||||
@@ -115,7 +154,7 @@ export class VehiclesService {
|
|||||||
undefined,
|
undefined,
|
||||||
ctx.timings,
|
ctx.timings,
|
||||||
);
|
);
|
||||||
return existing;
|
return this.toDecodeResponse(existing);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Resolve VIN via cached decode chain (Corgi → PartsCatalogs → PL24 → EMEX)
|
// 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.");
|
throw new BadRequestException("Şase numarası tanınamadı. Marka desteklenmiyor.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2b. If resolveVin returned multiple candidates, return them for frontend selection
|
// 2b. If resolveVin returned multiple candidates, return them for frontend
|
||||||
if (resolved.pcatCandidates && resolved.pcatCandidates.length > 1) {
|
// 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(
|
await this.logQuery(
|
||||||
userId,
|
userId,
|
||||||
vin,
|
vin,
|
||||||
null,
|
null,
|
||||||
"parts-catalogs",
|
stash.source,
|
||||||
true,
|
true,
|
||||||
Date.now() - startTime,
|
Date.now() - startTime,
|
||||||
undefined,
|
undefined,
|
||||||
ctx.timings,
|
ctx.timings,
|
||||||
);
|
);
|
||||||
return { candidates: resolved.pcatCandidates, vin, source: "parts-catalogs" };
|
const raw = hasPcatCandidates
|
||||||
}
|
? (resolved.pcatCandidates as PcatCar[])
|
||||||
if (resolved.emexCandidates && resolved.emexCandidates.length > 1) {
|
: (resolved.emexCandidates as EmexCandidate[]);
|
||||||
await this.logQuery(
|
return { candidates: this.toPublicCandidates(raw), vin };
|
||||||
userId,
|
|
||||||
vin,
|
|
||||||
null,
|
|
||||||
"emex",
|
|
||||||
true,
|
|
||||||
Date.now() - startTime,
|
|
||||||
undefined,
|
|
||||||
ctx.timings,
|
|
||||||
);
|
|
||||||
return { candidates: resolved.emexCandidates, vin, source: "emex" };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Brand access check
|
// 3. Brand access check
|
||||||
let brandId: string | null = null;
|
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) {
|
if (brandName) {
|
||||||
const [brand] = await this.db
|
const [brand] = await this.db
|
||||||
.select()
|
.select()
|
||||||
.from(brands)
|
.from(brands)
|
||||||
.where(eq(brands.name, brandName))
|
.where(sql`lower(${brands.name}) = lower(${brandName})`)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (brand) {
|
if (brand) {
|
||||||
brandId = brand.id;
|
brandId = brand.id;
|
||||||
|
brandName = brand.name;
|
||||||
await this.checkBrandAccess(userId, brandId);
|
await this.checkBrandAccess(userId, brandId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -263,7 +308,22 @@ export class VehiclesService {
|
|||||||
ctx.timings,
|
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ı");
|
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
|
const [existing] = await this.db
|
||||||
.select({
|
.select({
|
||||||
brandName: vehicles.brandName,
|
brandName: vehicles.brandName,
|
||||||
model: vehicles.model,
|
model: vehicles.model,
|
||||||
year: vehicles.year,
|
year: vehicles.year,
|
||||||
engine: vehicles.engine,
|
engine: vehicles.engine,
|
||||||
source: vehicles.source,
|
|
||||||
})
|
})
|
||||||
.from(vehicles)
|
.from(vehicles)
|
||||||
.where(eq(vehicles.vin, vin))
|
.where(eq(vehicles.vin, vin))
|
||||||
@@ -372,7 +432,6 @@ export class VehiclesService {
|
|||||||
model: resolved.model,
|
model: resolved.model,
|
||||||
year: resolved.year,
|
year: resolved.year,
|
||||||
engine: resolved.engine,
|
engine: resolved.engine,
|
||||||
source: resolved.source,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -392,6 +451,10 @@ export class VehiclesService {
|
|||||||
// PL24 decode (responseTimeMs=1006123) that ran long after the request was
|
// PL24 decode (responseTimeMs=1006123) that ran long after the request was
|
||||||
// already aborted by TimeoutInterceptor. Anything past this is wasted work.
|
// already aborted by TimeoutInterceptor. Anything past this is wasted work.
|
||||||
private static readonly RESOLVE_BUDGET_MS = 25_000;
|
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 circuit breaker ──────────────────────────────
|
||||||
// PL24 had p95=12min in production. When it goes bad it stays bad — every
|
// 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);
|
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.
|
* Shared VIN decode chain — Corgi → PartsCatalogs → PL24 → EMEX.
|
||||||
*
|
*
|
||||||
@@ -637,16 +741,59 @@ export class VehiclesService {
|
|||||||
|
|
||||||
const pcatStart = Date.now();
|
const pcatStart = Date.now();
|
||||||
let pcatResolved = false;
|
let pcatResolved = false;
|
||||||
const pcatPromise = this.partsCatalogsService
|
// Circuit breaker: when pcat is in a bad transport window, skip it so the
|
||||||
.decodeVin(vin, signal, outcome)
|
// 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) => {
|
.catch((err: Error) => {
|
||||||
this.logger.warn(`PartsCatalogs decode failed for ${vin}: ${err.message}`);
|
this.logger.warn(`PartsCatalogs decode failed for ${vin}: ${err.message}`);
|
||||||
return null;
|
return null;
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
|
// Propagate pcat's transient signal to the shared outcome (neg-cache gate).
|
||||||
|
if (pcatOutcome.transient && outcome) outcome.transient = true;
|
||||||
pcatResolved = true;
|
pcatResolved = true;
|
||||||
if (ctx && ctx.timings.pcat === undefined) {
|
if (ctx) {
|
||||||
ctx.timings.pcat = Date.now() - pcatStart;
|
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;
|
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).
|
* 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)", () => {
|
it("keeps the in-flight lock key version-independent (cross-deploy dedupe)", () => {
|
||||||
const { lockKey } = vinResolveCacheKeys(VIN);
|
const { lockKey } = vinResolveCacheKeys(VIN);
|
||||||
expect(lockKey).toBe(`vin:lock:${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)", () => {
|
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
|
* Both the decode path (VehiclesService) and the admin cache-buster
|
||||||
* (InternalVehiclesService) MUST build keys through here so they stay in sync.
|
* (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): {
|
export function vinResolveCacheKeys(vin: string): {
|
||||||
cacheKey: string;
|
cacheKey: string;
|
||||||
@@ -29,3 +29,14 @@ export function vinResolveCacheKeys(vin: string): {
|
|||||||
lockKey: `vin:lock:${vin}`,
|
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}`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import OpenAI from "openai";
|
|||||||
import postgres from "postgres";
|
import postgres from "postgres";
|
||||||
import { QUEUE_NAMES, getBullConnection, getBullTelemetry } from "./jobs/bull.config";
|
import { QUEUE_NAMES, getBullConnection, getBullTelemetry } from "./jobs/bull.config";
|
||||||
import { processEmexScrape } from "./jobs/processors/emex-scrape.processor";
|
import { processEmexScrape } from "./jobs/processors/emex-scrape.processor";
|
||||||
|
import { processExpertRewards } from "./jobs/processors/expert-rewards.processor";
|
||||||
import { processLifecycleEmails } from "./jobs/processors/lifecycle-email.processor";
|
import { processLifecycleEmails } from "./jobs/processors/lifecycle-email.processor";
|
||||||
import { processQueryCleanup } from "./jobs/processors/query-cleanup.processor";
|
import { processQueryCleanup } from "./jobs/processors/query-cleanup.processor";
|
||||||
import { processSubscriptionExpiry } from "./jobs/processors/subscription-expiry.processor";
|
import { processSubscriptionExpiry } from "./jobs/processors/subscription-expiry.processor";
|
||||||
@@ -159,6 +160,32 @@ lifecycleEmailWorker.on("failed", (job, err) => {
|
|||||||
|
|
||||||
workers.push(lifecycleEmailWorker);
|
workers.push(lifecycleEmailWorker);
|
||||||
|
|
||||||
|
// Expert Rewards Worker (monthly Parça Uzmanları top-3 subscription prizes)
|
||||||
|
const expertRewardsWorker = new Worker(
|
||||||
|
QUEUE_NAMES.EXPERT_REWARDS,
|
||||||
|
async (job) => {
|
||||||
|
return processExpertRewards(job, db);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
connection,
|
||||||
|
concurrency: 1,
|
||||||
|
...(telemetry ? { telemetry } : {}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expertRewardsWorker.on("completed", (job) => {
|
||||||
|
console.log(`[worker] expert-rewards job ${job.id} completed`);
|
||||||
|
});
|
||||||
|
|
||||||
|
expertRewardsWorker.on("failed", (job, err) => {
|
||||||
|
console.error(`[worker] expert-rewards job ${job?.id} failed: ${err.message}`);
|
||||||
|
Sentry.captureException(err, {
|
||||||
|
tags: { queue: QUEUE_NAMES.EXPERT_REWARDS, jobId: job?.id },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
workers.push(expertRewardsWorker);
|
||||||
|
|
||||||
// Translation Worker (async LLM translation for new EMEX/PCAT terms)
|
// Translation Worker (async LLM translation for new EMEX/PCAT terms)
|
||||||
const openrouterApiKey = process.env.OPENROUTER_API_KEY;
|
const openrouterApiKey = process.env.OPENROUTER_API_KEY;
|
||||||
if (openrouterApiKey) {
|
if (openrouterApiKey) {
|
||||||
|
|||||||
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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const captureMock = vi.fn();
|
||||||
|
const toastSuccessMock = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("@/lib/posthog", () => ({
|
||||||
|
capture: (...args: unknown[]) => captureMock(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/api-client", () => ({
|
||||||
|
api: {
|
||||||
|
post: vi.fn().mockResolvedValue({}),
|
||||||
|
get: vi.fn().mockResolvedValue({}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/toast", () => ({
|
||||||
|
toast: {
|
||||||
|
success: (...args: unknown[]) => toastSuccessMock(...args),
|
||||||
|
error: vi.fn(),
|
||||||
|
info: vi.fn(),
|
||||||
|
warning: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@tanstack/react-router", () => ({
|
||||||
|
Link: ({
|
||||||
|
children,
|
||||||
|
to,
|
||||||
|
params,
|
||||||
|
}: { children: React.ReactNode; to: string; params?: { code: string } }) => (
|
||||||
|
<a href={params ? to.replace("$code", params.code) : to}>{children}</a>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { api } from "@/lib/api-client";
|
||||||
|
import { OemSuggestionsSection } from "../oem-suggestions-section";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
captureMock.mockClear();
|
||||||
|
toastSuccessMock.mockClear();
|
||||||
|
vi.mocked(api.get).mockReset().mockResolvedValue({ suggestions: [] });
|
||||||
|
vi.mocked(api.post).mockReset().mockResolvedValue({});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("OemSuggestionsSection", () => {
|
||||||
|
it("lists aggregated suggestions with supporter counts", async () => {
|
||||||
|
vi.mocked(api.get).mockResolvedValue({
|
||||||
|
suggestions: [
|
||||||
|
{ brand: "Bosch", code: "0 986 478 123", count: 3, mine: true },
|
||||||
|
{ brand: "TRW", code: "DF4823", count: 1, mine: false },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<OemSuggestionsSection oemCode="8V0615423E" />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(api.get).toHaveBeenCalledWith("/oem-suggestions?code=8V0615423E");
|
||||||
|
expect(screen.getByText("Bosch")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(screen.getByRole("link", { name: "0 986 478 123" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("sizin öneriniz")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("submits a brand + code suggestion and refreshes the list", async () => {
|
||||||
|
vi.mocked(api.post).mockResolvedValue({
|
||||||
|
created: true,
|
||||||
|
suggestions: [{ brand: "Valeo", code: "PHC123", count: 1, mine: true }],
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<OemSuggestionsSection oemCode="X1" />);
|
||||||
|
await waitFor(() => expect(api.get).toHaveBeenCalled());
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /Muadil öner/ }));
|
||||||
|
fireEvent.change(screen.getByPlaceholderText("Marka (ör. Bosch)"), {
|
||||||
|
target: { value: "Valeo" },
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByPlaceholderText("Parça kodu"), {
|
||||||
|
target: { value: "PHC123" },
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Gönder" }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(api.post).toHaveBeenCalledWith("/oem-suggestions", {
|
||||||
|
oemCode: "X1",
|
||||||
|
brand: "Valeo",
|
||||||
|
suggestedCode: "PHC123",
|
||||||
|
});
|
||||||
|
expect(toastSuccessMock).toHaveBeenCalled();
|
||||||
|
expect(captureMock).toHaveBeenCalledWith(
|
||||||
|
"oem_suggestion_submitted",
|
||||||
|
expect.objectContaining({ oem_code: "X1", brand: "Valeo", created: true }),
|
||||||
|
);
|
||||||
|
expect(screen.getByText("Valeo")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
105
apps/web/src/components/catalog/__tests__/oem-vote-card.test.tsx
Normal file
105
apps/web/src/components/catalog/__tests__/oem-vote-card.test.tsx
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const captureMock = vi.fn();
|
||||||
|
const toastSuccessMock = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("@/lib/posthog", () => ({
|
||||||
|
capture: (...args: unknown[]) => captureMock(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/api-client", () => ({
|
||||||
|
api: {
|
||||||
|
post: vi.fn().mockResolvedValue({}),
|
||||||
|
get: vi.fn().mockResolvedValue({}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/toast", () => ({
|
||||||
|
toast: {
|
||||||
|
success: (...args: unknown[]) => toastSuccessMock(...args),
|
||||||
|
error: vi.fn(),
|
||||||
|
info: vi.fn(),
|
||||||
|
warning: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@tanstack/react-router", () => ({
|
||||||
|
Link: ({ children, to }: { children: React.ReactNode; to: string }) => (
|
||||||
|
<a href={to}>{children}</a>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { api } from "@/lib/api-client";
|
||||||
|
import { OemVoteCard } from "../oem-vote-card";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
captureMock.mockClear();
|
||||||
|
toastSuccessMock.mockClear();
|
||||||
|
vi.mocked(api.post).mockReset().mockResolvedValue({});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("OemVoteCard", () => {
|
||||||
|
it("loads the summary, marks my vote and hides the tallies", async () => {
|
||||||
|
vi.mocked(api.post).mockResolvedValue({
|
||||||
|
votes: { "8V0615423E": { compatible: 4, incompatible: 1, myVote: "compatible" } },
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<OemVoteCard oemCode="8V0615423E" vehicleLabel="Audi A4 (2018)" partName="Fren balatası" />,
|
||||||
|
);
|
||||||
|
|
||||||
|
// bağlam satırı: neyle neyin uyumlu olduğu tek alanda
|
||||||
|
expect(screen.getByText("Audi A4 (2018) · Fren balatası")).toBeInTheDocument();
|
||||||
|
|
||||||
|
const upButton = screen.getByRole("button", { name: /Uyumlu/ });
|
||||||
|
const downButton = screen.getByRole("button", { name: /Uyumsuz/ });
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(api.post).toHaveBeenCalledWith("/oem-votes/lookup", { codes: ["8V0615423E"] });
|
||||||
|
expect(upButton).toHaveAttribute("aria-pressed", "true");
|
||||||
|
});
|
||||||
|
// sayaçlar butonlarda görünmez (çoğunluk oyu yönlendirmesin)
|
||||||
|
expect(upButton.textContent).not.toContain("4");
|
||||||
|
expect(downButton.textContent).not.toContain("1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("casts a vote, updates the counts and reports the points", async () => {
|
||||||
|
const postMock = vi.mocked(api.post);
|
||||||
|
postMock.mockImplementation((path: string) => {
|
||||||
|
if (path === "/oem-votes") {
|
||||||
|
return Promise.resolve({
|
||||||
|
oemCode: "X1",
|
||||||
|
myVote: "incompatible",
|
||||||
|
counts: { compatible: 0, incompatible: 1 },
|
||||||
|
pointsAwarded: 3,
|
||||||
|
correct: true,
|
||||||
|
isNew: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Promise.resolve({ votes: {} });
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<OemVoteCard oemCode="X1" />);
|
||||||
|
|
||||||
|
const downButton = screen.getByRole("button", { name: /Uyumsuz/ });
|
||||||
|
await waitFor(() => expect(downButton).toBeEnabled());
|
||||||
|
fireEvent.click(downButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(postMock).toHaveBeenCalledWith(
|
||||||
|
"/oem-votes",
|
||||||
|
expect.objectContaining({ oemCode: "X1", vote: "incompatible" }),
|
||||||
|
);
|
||||||
|
expect(toastSuccessMock).toHaveBeenCalledWith("+3 puan kazandınız!", expect.anything());
|
||||||
|
expect(captureMock).toHaveBeenCalledWith(
|
||||||
|
"oem_vote_cast",
|
||||||
|
expect.objectContaining({ oem_code: "X1", vote: "incompatible", surface: "oem_detail" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
expect(downButton).toHaveAttribute("aria-pressed", "true");
|
||||||
|
});
|
||||||
|
});
|
||||||
158
apps/web/src/components/catalog/oem-suggestions-section.tsx
Normal file
158
apps/web/src/components/catalog/oem-suggestions-section.tsx
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
import { api } from "@/lib/api-client";
|
||||||
|
import { capture } from "@/lib/posthog";
|
||||||
|
import { toast } from "@/lib/toast";
|
||||||
|
import { Badge, Button, Input, Skeleton } from "@sase/ui";
|
||||||
|
import { Link } from "@tanstack/react-router";
|
||||||
|
import { Plus, Users } from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
export interface AggregatedSuggestion {
|
||||||
|
brand: string;
|
||||||
|
code: string;
|
||||||
|
count: number;
|
||||||
|
mine: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Topluluk muadil önerileri: P kataloğunda eşleşmesi olmayan (ya da eksik
|
||||||
|
// kalan) OEM kodları için parça satıcılarından marka + kod toplar. Aynı
|
||||||
|
// öneriyi veren kullanıcı sayısı rozetle gösterilir; önerilen kod kendi OEM
|
||||||
|
// detay sayfasına linklenir.
|
||||||
|
export function OemSuggestionsSection({ oemCode }: { oemCode: string }) {
|
||||||
|
const [suggestions, setSuggestions] = useState<AggregatedSuggestion[] | null>(null);
|
||||||
|
const [formOpen, setFormOpen] = useState(false);
|
||||||
|
const [brand, setBrand] = useState("");
|
||||||
|
const [code, setCode] = useState("");
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setSuggestions(null);
|
||||||
|
api
|
||||||
|
.get<{ suggestions: AggregatedSuggestion[] }>(
|
||||||
|
`/oem-suggestions?code=${encodeURIComponent(oemCode)}`,
|
||||||
|
)
|
||||||
|
.then((res) => {
|
||||||
|
if (!cancelled) setSuggestions(res?.suggestions ?? []);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setSuggestions([]);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [oemCode]);
|
||||||
|
|
||||||
|
const submit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
const res = await api.post<{ created: boolean; suggestions: AggregatedSuggestion[] }>(
|
||||||
|
"/oem-suggestions",
|
||||||
|
{ oemCode, brand, suggestedCode: code },
|
||||||
|
);
|
||||||
|
setSuggestions(res.suggestions);
|
||||||
|
if (res.created) {
|
||||||
|
toast.success("Öneriniz eklendi, teşekkürler!", {
|
||||||
|
description: "Aynı öneriyi veren satıcı arttıkça öneri güçlenir.",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast.info("Bu öneriyi zaten eklemişsiniz.");
|
||||||
|
}
|
||||||
|
capture("oem_suggestion_submitted", {
|
||||||
|
oem_code: oemCode,
|
||||||
|
brand,
|
||||||
|
created: res.created,
|
||||||
|
});
|
||||||
|
setBrand("");
|
||||||
|
setCode("");
|
||||||
|
setFormOpen(false);
|
||||||
|
} catch {
|
||||||
|
toast.error("Öneri kaydedilemedi", { description: "Lütfen tekrar deneyin." });
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="space-y-3">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-semibold">Topluluk muadil önerileri</h2>
|
||||||
|
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||||
|
Bu kodun muadilini biliyorsanız marka + parça kodu olarak ekleyin.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={() => setFormOpen((o) => !o)}>
|
||||||
|
<Plus className="mr-1.5 size-4" />
|
||||||
|
Muadil öner
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{formOpen && (
|
||||||
|
<form
|
||||||
|
onSubmit={submit}
|
||||||
|
className="flex flex-col gap-2 rounded-xl border border-border bg-background p-3 sm:flex-row"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
value={brand}
|
||||||
|
onChange={(e) => setBrand(e.target.value)}
|
||||||
|
placeholder="Marka (ör. Bosch)"
|
||||||
|
required
|
||||||
|
minLength={2}
|
||||||
|
maxLength={80}
|
||||||
|
className="sm:max-w-48"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
value={code}
|
||||||
|
onChange={(e) => setCode(e.target.value)}
|
||||||
|
placeholder="Parça kodu"
|
||||||
|
required
|
||||||
|
minLength={2}
|
||||||
|
maxLength={100}
|
||||||
|
className="font-mono"
|
||||||
|
/>
|
||||||
|
<Button type="submit" disabled={submitting}>
|
||||||
|
{submitting ? "Gönderiliyor…" : "Gönder"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{suggestions === null ? (
|
||||||
|
<Skeleton className="h-14 w-full rounded-xl" />
|
||||||
|
) : suggestions.length === 0 ? (
|
||||||
|
<p className="rounded-xl border border-dashed border-border px-4 py-6 text-center text-sm text-muted-foreground">
|
||||||
|
Henüz öneri yok — muadilini biliyorsanız ilk öneriyi siz ekleyin.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y divide-border overflow-hidden rounded-xl border border-border">
|
||||||
|
{suggestions.map((s) => (
|
||||||
|
<li
|
||||||
|
key={`${s.brand.toLocaleLowerCase("tr-TR")}|${s.code}`}
|
||||||
|
className="flex items-center justify-between gap-3 bg-background px-4 py-2.5"
|
||||||
|
>
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="text-sm font-medium">{s.brand}</span>{" "}
|
||||||
|
<Link
|
||||||
|
to="/dashboard/oem/$code"
|
||||||
|
params={{ code: s.code }}
|
||||||
|
className="font-mono text-xs underline decoration-dotted underline-offset-2 hover:decoration-solid"
|
||||||
|
>
|
||||||
|
{s.code}
|
||||||
|
</Link>
|
||||||
|
{s.mine && (
|
||||||
|
<span className="ml-2 text-[10px] font-semibold uppercase tracking-wide text-primary">
|
||||||
|
sizin öneriniz
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<Badge variant="secondary" title="Bu muadili öneren kullanıcı sayısı">
|
||||||
|
<Users className="mr-1 size-3" />
|
||||||
|
{s.count}
|
||||||
|
</Badge>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
170
apps/web/src/components/catalog/oem-vote-card.tsx
Normal file
170
apps/web/src/components/catalog/oem-vote-card.tsx
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
import { api } from "@/lib/api-client";
|
||||||
|
import { capture } from "@/lib/posthog";
|
||||||
|
import { toast } from "@/lib/toast";
|
||||||
|
import { cn } from "@sase/ui";
|
||||||
|
import { Link } from "@tanstack/react-router";
|
||||||
|
import { Car, ThumbsDown, ThumbsUp } from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
export type OemVoteChoice = "compatible" | "incompatible";
|
||||||
|
|
||||||
|
export interface OemVoteSummary {
|
||||||
|
compatible: number;
|
||||||
|
incompatible: number;
|
||||||
|
myVote: OemVoteChoice | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CastOemVoteResult {
|
||||||
|
oemCode: string;
|
||||||
|
myVote: OemVoteChoice;
|
||||||
|
counts: { compatible: number; incompatible: number };
|
||||||
|
pointsAwarded: number;
|
||||||
|
correct: boolean | null;
|
||||||
|
isNew: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OemVoteCardProps {
|
||||||
|
oemCode: string;
|
||||||
|
// Katalogdan gelişte taşınan bağlam: "neyle neyin uyumlu olduğu" tek alanda
|
||||||
|
// görünsün diye butonların üstünde araç + parça adı gösterilir. Doğrudan
|
||||||
|
// ziyarette (bağlam yok) genel soru metnine düşülür.
|
||||||
|
vehicleLabel?: string;
|
||||||
|
partName?: string;
|
||||||
|
vehicleId?: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_SUMMARY: OemVoteSummary = { compatible: 0, incompatible: 0, myVote: null };
|
||||||
|
|
||||||
|
// OEM detay başlığındaki topluluk oyu kartı: uyumlu/uyumsuz + sayaçlar.
|
||||||
|
// Arkaplanı sayfayla aynı (dolgu yok, yalnız çerçeve). Puan kuralı yalnız
|
||||||
|
// Parça Uzmanları sayfasında yazılıdır; burada toast geri bildirimi yeter.
|
||||||
|
export function OemVoteCard({
|
||||||
|
oemCode,
|
||||||
|
vehicleLabel,
|
||||||
|
partName,
|
||||||
|
vehicleId,
|
||||||
|
className,
|
||||||
|
}: OemVoteCardProps) {
|
||||||
|
const [summary, setSummary] = useState<OemVoteSummary | null>(null);
|
||||||
|
const [pending, setPending] = useState(false);
|
||||||
|
|
||||||
|
const context = [vehicleLabel, partName].filter(Boolean).join(" · ");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setSummary(null);
|
||||||
|
api
|
||||||
|
.post<{ votes: Record<string, OemVoteSummary> }>("/oem-votes/lookup", { codes: [oemCode] })
|
||||||
|
.then((res) => {
|
||||||
|
if (!cancelled) setSummary(res?.votes?.[oemCode] ?? EMPTY_SUMMARY);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setSummary(EMPTY_SUMMARY);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [oemCode]);
|
||||||
|
|
||||||
|
const cast = async (vote: OemVoteChoice) => {
|
||||||
|
setPending(true);
|
||||||
|
try {
|
||||||
|
const result = await api.post<CastOemVoteResult>("/oem-votes", {
|
||||||
|
oemCode,
|
||||||
|
vote,
|
||||||
|
vehicleId,
|
||||||
|
});
|
||||||
|
setSummary({ ...result.counts, myVote: result.myVote });
|
||||||
|
if (result.isNew) {
|
||||||
|
const isFirstVote = result.counts.compatible + result.counts.incompatible === 1;
|
||||||
|
toast.success(`+${result.pointsAwarded} puan kazandınız!`, {
|
||||||
|
description: isFirstVote
|
||||||
|
? "Bu OEM kodunu ilk değerlendiren sizsiniz."
|
||||||
|
: result.correct
|
||||||
|
? "Çoğunluk görüşüyle aynı yöndesiniz."
|
||||||
|
: "Oyunuz kaydedildi — çoğunluk şimdilik farklı görüşte.",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast.info("Oyunuz güncellendi.");
|
||||||
|
}
|
||||||
|
capture("oem_vote_cast", {
|
||||||
|
oem_code: oemCode,
|
||||||
|
vote,
|
||||||
|
points_awarded: result.pointsAwarded,
|
||||||
|
correct: result.correct,
|
||||||
|
is_new: result.isNew,
|
||||||
|
vehicle_id: vehicleId,
|
||||||
|
surface: "oem_detail",
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
toast.error("Oy kaydedilemedi", { description: "Lütfen tekrar deneyin." });
|
||||||
|
} finally {
|
||||||
|
setPending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Sayaçlar bilinçli olarak gösterilmiyor (çoğunluğu ele vermek oyu yönlendirir);
|
||||||
|
// özet yine de çekilir — kullanıcının kendi oyu butonda işaretli kalır.
|
||||||
|
const options = [
|
||||||
|
{
|
||||||
|
vote: "compatible" as const,
|
||||||
|
icon: ThumbsUp,
|
||||||
|
label: "Uyumlu",
|
||||||
|
activeClass: "border-green-500/50 bg-green-500/10 text-green-500",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
vote: "incompatible" as const,
|
||||||
|
icon: ThumbsDown,
|
||||||
|
label: "Uyumsuz",
|
||||||
|
activeClass: "border-red-500/50 bg-red-500/10 text-red-500",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className={cn("rounded-xl border border-border p-4", className)}>
|
||||||
|
<h2 className="text-sm font-semibold">Topluluk uyumluluk oyu</h2>
|
||||||
|
{context ? (
|
||||||
|
<p className="mt-1 flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
|
<Car className="size-3.5 shrink-0" />
|
||||||
|
<span className="truncate" title={context}>
|
||||||
|
{context}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
Bu OEM kodu sizce doğru mu? Oyunuz diğer parça satıcılarına yol gösterir.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="mt-3 flex gap-2">
|
||||||
|
{options.map(({ vote, icon: Icon, label, activeClass }) => {
|
||||||
|
const isActive = summary?.myVote === vote;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={vote}
|
||||||
|
type="button"
|
||||||
|
disabled={pending || summary === null}
|
||||||
|
aria-pressed={isActive}
|
||||||
|
onClick={() => cast(vote)}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex flex-1 items-center justify-center gap-2 rounded-lg border px-3 py-2 text-sm font-medium transition-colors disabled:opacity-50",
|
||||||
|
isActive
|
||||||
|
? activeClass
|
||||||
|
: "border-border text-muted-foreground hover:bg-accent hover:text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="size-4 shrink-0" fill={isActive ? "currentColor" : "none"} />
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<p className="mt-3 text-xs text-muted-foreground">
|
||||||
|
Her oy puan kazandırır ·{" "}
|
||||||
|
<Link to="/dashboard/uzmanlar" className="font-medium text-primary hover:underline">
|
||||||
|
Parça Uzmanları sıralaması
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
96
apps/web/src/components/gamification/leaderboard-podium.tsx
Normal file
96
apps/web/src/components/gamification/leaderboard-podium.tsx
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
// Trophy Gamification UI Kit'in leaderboard-podium bileşeni (MIT, ui.trophy.so)
|
||||||
|
// sase'ye uyarlandı: shadcn yerine @sase/ui, cva bağımlılığı söküldü, rank
|
||||||
|
// renk token'ları somut Tailwind renklerine bağlandı, avatar servisi yerine
|
||||||
|
// maskeli adın ilk harfi gösteriliyor (liderlik listesi KVKK-maskeli).
|
||||||
|
import { cn } from "@sase/ui";
|
||||||
|
import { Crown } from "lucide-react";
|
||||||
|
|
||||||
|
export interface PodiumRanking {
|
||||||
|
userId: string;
|
||||||
|
userName: string | null;
|
||||||
|
rank: number;
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PODIUM_CONFIG = {
|
||||||
|
1: { color: "text-amber-400", bg: "bg-amber-400/50", height: "h-32" },
|
||||||
|
2: { color: "text-zinc-400", bg: "bg-zinc-400/30", height: "h-24" },
|
||||||
|
3: { color: "text-orange-700", bg: "bg-orange-700/40", height: "h-20" },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
interface LeaderboardPodiumProps {
|
||||||
|
/** İlk 3 sıra (rank 1-3 beklenir) */
|
||||||
|
rankings: PodiumRanking[];
|
||||||
|
showValue?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LeaderboardPodium({
|
||||||
|
rankings,
|
||||||
|
showValue = true,
|
||||||
|
className,
|
||||||
|
}: LeaderboardPodiumProps) {
|
||||||
|
// Kürsü dizilimi: 2. — 1. — 3.
|
||||||
|
const top3 = rankings.slice(0, 3);
|
||||||
|
const podiumOrder = [
|
||||||
|
top3.find((r) => r.rank === 2),
|
||||||
|
top3.find((r) => r.rank === 1),
|
||||||
|
top3.find((r) => r.rank === 3),
|
||||||
|
].filter((r): r is PodiumRanking => Boolean(r));
|
||||||
|
|
||||||
|
if (podiumOrder.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul
|
||||||
|
className={cn("flex items-end justify-center gap-4", className)}
|
||||||
|
aria-label="İlk 3 sıralama"
|
||||||
|
>
|
||||||
|
{podiumOrder.map((ranking) => {
|
||||||
|
const config = PODIUM_CONFIG[ranking.rank as 1 | 2 | 3];
|
||||||
|
if (!config) return null;
|
||||||
|
const displayName = ranking.userName || "Üye";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={ranking.userId}
|
||||||
|
aria-label={`Sıra ${ranking.rank}: ${displayName}${showValue ? `, ${ranking.value.toLocaleString("tr-TR")} puan` : ""}`}
|
||||||
|
className="flex flex-col items-center"
|
||||||
|
>
|
||||||
|
<div className="relative mb-2" aria-hidden="true">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex h-14 w-14 items-center justify-center rounded-full text-lg font-semibold",
|
||||||
|
config.bg,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{displayName.charAt(0)}
|
||||||
|
</div>
|
||||||
|
<div className="absolute -bottom-1 -right-1 flex h-6 w-6 items-center justify-center rounded-full bg-background shadow-sm">
|
||||||
|
<Crown className={cn("h-4 w-4", config.color)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className="max-w-20 truncate text-center text-sm font-medium" title={displayName}>
|
||||||
|
{displayName}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{showValue && (
|
||||||
|
<span className="text-sm tabular-nums text-muted-foreground">
|
||||||
|
{ranking.value.toLocaleString("tr-TR")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={cn("mt-2 w-22 rounded-t-lg", config.height, config.bg)}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<div className={cn("flex h-8 items-center justify-center font-bold", config.color)}>
|
||||||
|
{ranking.rank}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
166
apps/web/src/components/gamification/leaderboard-rankings.tsx
Normal file
166
apps/web/src/components/gamification/leaderboard-rankings.tsx
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
// Trophy Gamification UI Kit'in leaderboard-rankings bileşeni (MIT, ui.trophy.so)
|
||||||
|
// sase'ye uyarlandı: @sase/ui importları, Türkçe metinler, avatar yerine
|
||||||
|
// maskeli adın ilk harfi, currentUserId karşılaştırması yerine isCurrentUser
|
||||||
|
// bayrağı (liderlik cevabı kullanıcı id'si sızdırmaz).
|
||||||
|
import { Button, cn } from "@sase/ui";
|
||||||
|
import { ChevronLeft, ChevronRight, Crown } from "lucide-react";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
|
export interface LeaderboardRankingItem {
|
||||||
|
userId: string;
|
||||||
|
userName: string | null;
|
||||||
|
rank: number;
|
||||||
|
value: number;
|
||||||
|
byline?: string | null;
|
||||||
|
isCurrentUser?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LeaderboardRankingsProps {
|
||||||
|
rankings: LeaderboardRankingItem[];
|
||||||
|
showPagination?: boolean;
|
||||||
|
defaultPageSize?: 10 | 25 | 50 | 100;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const crownColorMap = {
|
||||||
|
1: "text-amber-400",
|
||||||
|
2: "text-zinc-400",
|
||||||
|
3: "text-orange-700",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const pageSizeOptions = [10, 25, 50, 100] as const;
|
||||||
|
|
||||||
|
function formatLeaderboardValue(value: number) {
|
||||||
|
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}m`;
|
||||||
|
if (value >= 1_000) return `${(value / 1_000).toFixed(1)}k`;
|
||||||
|
return value.toLocaleString("tr-TR");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LeaderboardRankings({
|
||||||
|
rankings,
|
||||||
|
showPagination = false,
|
||||||
|
defaultPageSize = 25,
|
||||||
|
className,
|
||||||
|
}: LeaderboardRankingsProps) {
|
||||||
|
const [pageSize, setPageSize] = useState<10 | 25 | 50 | 100>(defaultPageSize);
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil(rankings.length / pageSize));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCurrentPage(1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentPage > totalPages) setCurrentPage(totalPages);
|
||||||
|
}, [currentPage, totalPages]);
|
||||||
|
|
||||||
|
const pagedRankings = useMemo(
|
||||||
|
() =>
|
||||||
|
showPagination
|
||||||
|
? rankings.slice((currentPage - 1) * pageSize, currentPage * pageSize)
|
||||||
|
: rankings,
|
||||||
|
[rankings, showPagination, currentPage, pageSize],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("w-full rounded-xl border bg-card", className)}>
|
||||||
|
<ul aria-label="Parça uzmanları sıralaması" className="divide-y divide-border">
|
||||||
|
{pagedRankings.map((ranking) => {
|
||||||
|
const displayName = ranking.userName || "Üye";
|
||||||
|
const showCrown = ranking.rank <= 3;
|
||||||
|
const crownColor = crownColorMap[ranking.rank as 1 | 2 | 3];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={ranking.userId}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 px-4 py-2",
|
||||||
|
ranking.isCurrentUser && "rounded-md border-2 border-primary bg-muted",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex w-12 items-center gap-1">
|
||||||
|
<span className="w-4 text-sm font-semibold tabular-nums">{ranking.rank}</span>
|
||||||
|
{showCrown ? (
|
||||||
|
<Crown className={cn("h-5 w-5", crownColor)} aria-hidden="true" />
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-muted text-sm font-medium text-muted-foreground">
|
||||||
|
{displayName.charAt(0).toLocaleUpperCase("tr-TR")}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate font-medium text-foreground">
|
||||||
|
{displayName}
|
||||||
|
{ranking.isCurrentUser && (
|
||||||
|
<span className="ml-2 text-xs font-semibold text-primary">(Siz)</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
{ranking.byline ? (
|
||||||
|
<p className="truncate text-sm text-muted-foreground">{ranking.byline}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="font-semibold leading-none tabular-nums">
|
||||||
|
{formatLeaderboardValue(ranking.value)}
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{showPagination ? (
|
||||||
|
<div className="flex items-center justify-between gap-3 border-t px-4 py-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<label htmlFor="leaderboard-page-size" className="text-sm text-muted-foreground">
|
||||||
|
Göster
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="leaderboard-page-size"
|
||||||
|
value={pageSize}
|
||||||
|
onChange={(e) => {
|
||||||
|
setPageSize(Number(e.target.value) as 10 | 25 | 50 | 100);
|
||||||
|
setCurrentPage(1);
|
||||||
|
}}
|
||||||
|
className="rounded-md border bg-background px-2 py-1 text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
|
{pageSizeOptions.map((option) => (
|
||||||
|
<option key={option} value={option}>
|
||||||
|
{option}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
aria-label="Önceki sayfa"
|
||||||
|
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
className="rounded-md border p-1.5 transition-colors hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
Sayfa {currentPage} / {totalPages}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
aria-label="Sonraki sayfa"
|
||||||
|
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
className="rounded-md border p-1.5 transition-colors hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
44
apps/web/src/components/gamification/points-badge.tsx
Normal file
44
apps/web/src/components/gamification/points-badge.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
// Trophy Gamification UI Kit'in points-badge bileşeni (MIT, ui.trophy.so)
|
||||||
|
// sase'ye uyarlandı: @sase/ui importları, cva bağımlılığı söküldü.
|
||||||
|
import { cn } from "@sase/ui";
|
||||||
|
import { Sparkle } from "lucide-react";
|
||||||
|
|
||||||
|
interface PointsBadgeProps {
|
||||||
|
name: string;
|
||||||
|
total: number;
|
||||||
|
icon?: React.ComponentType<{ className?: string }>;
|
||||||
|
formatValue?: (value: number) => string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PointsBadge({
|
||||||
|
name,
|
||||||
|
total,
|
||||||
|
icon: CustomIcon,
|
||||||
|
formatValue,
|
||||||
|
className,
|
||||||
|
}: PointsBadgeProps) {
|
||||||
|
const Icon = CustomIcon ?? Sparkle;
|
||||||
|
const displayValue = formatValue ? formatValue(total) : total.toLocaleString("tr-TR");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<output
|
||||||
|
aria-label={`${displayValue} ${name}`}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-3 rounded-lg border bg-card p-4 transition-colors",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary/10"
|
||||||
|
>
|
||||||
|
<Icon className="h-5 w-5 text-primary" />
|
||||||
|
</div>
|
||||||
|
<span className="text-xl font-bold tabular-nums">{displayValue}</span>
|
||||||
|
</div>
|
||||||
|
<span className="truncate text-muted-foreground">{name}</span>
|
||||||
|
</output>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@ vi.mock("@/stores/schema.store", () => ({
|
|||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
import { api } from "@/lib/api-client";
|
||||||
import { PartsPanel } from "../parts-panel";
|
import { PartsPanel } from "../parts-panel";
|
||||||
|
|
||||||
const buildPart = (overrides: Partial<Part> = {}): Part => ({
|
const buildPart = (overrides: Partial<Part> = {}): Part => ({
|
||||||
@@ -49,6 +50,7 @@ beforeEach(() => {
|
|||||||
captureMock.mockClear();
|
captureMock.mockClear();
|
||||||
setSelectedGroupMock.mockClear();
|
setSelectedGroupMock.mockClear();
|
||||||
setHighlightedGroupMock.mockClear();
|
setHighlightedGroupMock.mockClear();
|
||||||
|
vi.mocked(api.post).mockReset().mockResolvedValue({});
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -148,4 +150,27 @@ describe("PartsPanel", () => {
|
|||||||
parts_count: parts.length,
|
parts_count: parts.length,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("links every OEM code to the detail page without a /p/matched gate", () => {
|
||||||
|
const postMock = vi.mocked(api.post);
|
||||||
|
|
||||||
|
render(<PartsPanel parts={[buildPart()]} vehicleId="v1" categoryId="c1" />);
|
||||||
|
|
||||||
|
const link = screen.getByRole("link", { name: "OEM-1" });
|
||||||
|
// href bağlam taşır: parça adı + vehicleId (oy kartındaki "araç · parça" satırı)
|
||||||
|
expect(link.getAttribute("href")).toContain("/dashboard/oem/OEM-1");
|
||||||
|
expect(link.getAttribute("href")).toContain("vid=v1");
|
||||||
|
// Liste açılışı artık toplu istek atmaz (/p/matched ve oy lookup'ı kalktı);
|
||||||
|
// tek istisna kopyalama anındaki fire-and-forget analytics POST'udur.
|
||||||
|
expect(postMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not render Uyum or Adet columns", () => {
|
||||||
|
render(<PartsPanel parts={[buildPart({ quantity: 4 })]} />);
|
||||||
|
|
||||||
|
expect(screen.queryByText("Adet")).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("Uyum")).not.toBeInTheDocument();
|
||||||
|
const headers = screen.getAllByRole("columnheader").map((th) => th.textContent);
|
||||||
|
expect(headers).toEqual(["#", "Parça Adı", "OEM Kodu", "Pozisyon"]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { ReportCatalogIssueButton } from "@/components/catalog/report-catalog-issue";
|
||||||
import type { Part } from "@/hooks/use-parts";
|
import type { Part } from "@/hooks/use-parts";
|
||||||
import { api } from "@/lib/api-client";
|
import { api } from "@/lib/api-client";
|
||||||
import { capture } from "@/lib/posthog";
|
import { capture } from "@/lib/posthog";
|
||||||
@@ -21,6 +22,17 @@ interface PartsPanelProps {
|
|||||||
|
|
||||||
const SKELETON_ROW_KEYS = ["s0", "s1", "s2", "s3", "s4", "s5", "s6", "s7"] as const;
|
const SKELETON_ROW_KEYS = ["s0", "s1", "s2", "s3", "s4", "s5", "s6", "s7"] as const;
|
||||||
|
|
||||||
|
// OEM detay linki, oy kartının "araç · parça" bağlam satırı için görüntü
|
||||||
|
// bağlamını search-param olarak taşır (v/p/vid; oem.$code validateSearch).
|
||||||
|
function oemDetailHref(code: string, search: { v?: string; p?: string; vid?: string }) {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if (search.v) qs.set("v", search.v);
|
||||||
|
if (search.p) qs.set("p", search.p);
|
||||||
|
if (search.vid) qs.set("vid", search.vid);
|
||||||
|
const tail = qs.toString();
|
||||||
|
return `/dashboard/oem/${encodeURIComponent(code)}${tail ? `?${tail}` : ""}`;
|
||||||
|
}
|
||||||
|
|
||||||
export function PartsPanel({
|
export function PartsPanel({
|
||||||
parts,
|
parts,
|
||||||
vehicleId,
|
vehicleId,
|
||||||
@@ -204,23 +216,30 @@ export function PartsPanel({
|
|||||||
{parts.length === 0 ? (
|
{parts.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center gap-4 py-12 text-center text-sm text-muted-foreground">
|
<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>
|
<p>Bu kategori için parça bulunamadı.</p>
|
||||||
<Button
|
<div className="flex flex-wrap items-center justify-center gap-2">
|
||||||
type="button"
|
<Button
|
||||||
variant="outline"
|
type="button"
|
||||||
onClick={() => {
|
variant="outline"
|
||||||
// Demand/abandonment signal from an empty "0 parça" panel — lets
|
onClick={() => {
|
||||||
// us prioritise which catalogs to backfill, with readable context.
|
// Demand/abandonment signal from an empty "0 parça" panel — lets
|
||||||
capture("empty_catalog_cta_clicked", {
|
// us prioritise which catalogs to backfill, with readable context.
|
||||||
vehicle_id: vehicleId,
|
capture("empty_catalog_cta_clicked", {
|
||||||
category_id: categoryId,
|
vehicle_id: vehicleId,
|
||||||
vehicle_label: vehicleLabel,
|
category_id: categoryId,
|
||||||
category_name: categoryName,
|
vehicle_label: vehicleLabel,
|
||||||
});
|
category_name: categoryName,
|
||||||
window.history.back();
|
});
|
||||||
}}
|
window.history.back();
|
||||||
>
|
}}
|
||||||
Geri dön
|
>
|
||||||
</Button>
|
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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
@@ -229,7 +248,6 @@ export function PartsPanel({
|
|||||||
<th className="px-3 py-2 w-10">#</th>
|
<th className="px-3 py-2 w-10">#</th>
|
||||||
<th className="px-3 py-2">Parça Adı</th>
|
<th className="px-3 py-2">Parça Adı</th>
|
||||||
<th className="px-3 py-2">OEM Kodu</th>
|
<th className="px-3 py-2">OEM Kodu</th>
|
||||||
<th className="px-3 py-2 w-14 text-center">Adet</th>
|
|
||||||
<th className="px-3 py-2">Pozisyon</th>
|
<th className="px-3 py-2">Pozisyon</th>
|
||||||
{hasPrices && <th className="px-3 py-2 text-right">Fiyat</th>}
|
{hasPrices && <th className="px-3 py-2 text-right">Fiyat</th>}
|
||||||
</tr>
|
</tr>
|
||||||
@@ -254,7 +272,7 @@ export function PartsPanel({
|
|||||||
return (
|
return (
|
||||||
<tr key={part.id} className="border-b border-border/50 bg-muted/20">
|
<tr key={part.id} className="border-b border-border/50 bg-muted/20">
|
||||||
<td className="px-3 py-2 text-muted-foreground">{part.hotspotIndex}</td>
|
<td className="px-3 py-2 text-muted-foreground">{part.hotspotIndex}</td>
|
||||||
<td className="px-3 py-2" colSpan={hasPrices ? 5 : 4}>
|
<td className="px-3 py-2" colSpan={hasPrices ? 4 : 3}>
|
||||||
{label && <span className="font-medium">{label}</span>}
|
{label && <span className="font-medium">{label}</span>}
|
||||||
<span className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1">
|
<span className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||||
{refs.map((ref) => {
|
{refs.map((ref) => {
|
||||||
@@ -344,7 +362,7 @@ export function PartsPanel({
|
|||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 font-mono text-xs">
|
<td className="px-3 py-2 font-mono text-xs">
|
||||||
<span className="inline-flex items-center gap-1">
|
<span className="inline-flex items-center gap-1">
|
||||||
{part.oemCode && (
|
{part.oemCode && part.oemCode !== "N/A" && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="inline-flex shrink-0 items-center justify-center rounded p-0.5 text-muted-foreground hover:text-foreground"
|
className="inline-flex shrink-0 items-center justify-center rounded p-0.5 text-muted-foreground hover:text-foreground"
|
||||||
@@ -357,10 +375,45 @@ export function PartsPanel({
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{part.oemCode}
|
{part.oemCode && part.oemCode !== "N/A" ? (
|
||||||
|
// Every code links to the OEM detail page — without a P
|
||||||
|
// cross-reference the page still carries the community
|
||||||
|
// vote, equivalent suggestions and the reverse catalog.
|
||||||
|
// Plain left-click → in-app client navigation (no full
|
||||||
|
// SPA reload); real href kept so ctrl/cmd/middle-click
|
||||||
|
// still opens a new tab.
|
||||||
|
<a
|
||||||
|
href={oemDetailHref(part.oemCode, {
|
||||||
|
v: vehicleLabel,
|
||||||
|
p: part.name,
|
||||||
|
vid: vehicleId,
|
||||||
|
})}
|
||||||
|
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 },
|
||||||
|
search: { v: vehicleLabel, p: part.name, vid: vehicleId },
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{part.oemCode}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
part.oemCode
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 text-center">{part.quantity}</td>
|
|
||||||
<td className="px-3 py-2 text-muted-foreground">{part.position}</td>
|
<td className="px-3 py-2 text-muted-foreground">{part.position}</td>
|
||||||
{hasPrices && (
|
{hasPrices && (
|
||||||
<td className="px-3 py-2 text-right text-xs">
|
<td className="px-3 py-2 text-right text-xs">
|
||||||
|
|||||||
@@ -1,118 +0,0 @@
|
|||||||
import { useChangelog } from "@/hooks/use-changelog";
|
|
||||||
import { useTranslation } from "@/lib/i18n";
|
|
||||||
import type { ChangelogEntry } from "@sase/shared";
|
|
||||||
import {
|
|
||||||
Accordion,
|
|
||||||
AccordionContent,
|
|
||||||
AccordionItem,
|
|
||||||
AccordionTrigger,
|
|
||||||
Badge,
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
Skeleton,
|
|
||||||
} from "@sase/ui";
|
|
||||||
import { CalendarDays } from "lucide-react";
|
|
||||||
|
|
||||||
function formatDate(iso: string, locale: "tr" | "en"): string {
|
|
||||||
const date = new Date(iso);
|
|
||||||
const options: Intl.DateTimeFormatOptions = {
|
|
||||||
day: "numeric",
|
|
||||||
month: "long",
|
|
||||||
year: "numeric",
|
|
||||||
};
|
|
||||||
return date.toLocaleDateString(locale === "tr" ? "tr-TR" : "en-US", options);
|
|
||||||
}
|
|
||||||
|
|
||||||
function changeTypeLabel(
|
|
||||||
changeType: ChangelogEntry["changeType"],
|
|
||||||
t: (key: string) => string,
|
|
||||||
): string {
|
|
||||||
return t(`settings.changelog.changeType.${changeType}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ChangelogSkeleton() {
|
|
||||||
return (
|
|
||||||
<div className="space-y-0">
|
|
||||||
{[1, 2, 3].map((i) => (
|
|
||||||
<div key={i} className="relative pb-6 pl-8">
|
|
||||||
<div className="absolute left-0 top-1.5 h-2.5 w-2.5 rounded-full bg-muted-foreground/20" />
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Skeleton className="h-5 w-16 rounded-md" />
|
|
||||||
<Skeleton className="h-5 w-48" />
|
|
||||||
</div>
|
|
||||||
<Skeleton className="h-4 w-28" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ChangelogEmpty({ t }: { t: (key: string) => string }) {
|
|
||||||
return (
|
|
||||||
<Card className="border-dashed">
|
|
||||||
<CardHeader className="text-center">
|
|
||||||
<CalendarDays className="mx-auto h-10 w-10 text-muted-foreground" />
|
|
||||||
<CardTitle>{t("settings.changelog.emptyTitle")}</CardTitle>
|
|
||||||
<CardDescription>{t("settings.changelog.emptyDescription")}</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ChangelogTab() {
|
|
||||||
const { data: entries, isLoading } = useChangelog();
|
|
||||||
const { t, locale } = useTranslation();
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return <ChangelogSkeleton />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!entries || entries.length === 0) {
|
|
||||||
return <ChangelogEmpty t={t} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>{t("settings.changelog.title")}</CardTitle>
|
|
||||||
<CardDescription>{t("settings.changelog.description")}</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<Accordion
|
|
||||||
type="single"
|
|
||||||
collapsible
|
|
||||||
className="relative border-l-2 border-muted-foreground/20"
|
|
||||||
>
|
|
||||||
{entries.map((entry) => (
|
|
||||||
<AccordionItem key={entry.id} value={entry.id} className="border-b-0 pl-6">
|
|
||||||
<div className="absolute left-[-5px] mt-6 h-2.5 w-2.5 rounded-full border-2 border-background bg-foreground dark:bg-primary" />
|
|
||||||
<AccordionTrigger className="py-3 hover:no-underline">
|
|
||||||
<div className="flex flex-col items-start gap-1.5 text-left">
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<Badge changeType={entry.changeType}>
|
|
||||||
{changeTypeLabel(entry.changeType, t)}
|
|
||||||
</Badge>
|
|
||||||
<span className="font-medium">{entry.title}</span>
|
|
||||||
</div>
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{formatDate(entry.publishedAt, locale)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</AccordionTrigger>
|
|
||||||
<AccordionContent>
|
|
||||||
<div className="text-sm text-muted-foreground leading-relaxed whitespace-pre-wrap">
|
|
||||||
{entry.description}
|
|
||||||
</div>
|
|
||||||
</AccordionContent>
|
|
||||||
</AccordionItem>
|
|
||||||
))}
|
|
||||||
</Accordion>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import { api } from "@/lib/api-client";
|
|
||||||
import type { ChangelogEntry } from "@sase/shared";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
|
|
||||||
export function useChangelog() {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["changelog"],
|
|
||||||
queryFn: () => api.get<ChangelogEntry[]>("/changelog"),
|
|
||||||
staleTime: 30 * 60 * 1000, // 30 min — matches Redis cache TTL
|
|
||||||
});
|
|
||||||
}
|
|
||||||
15
apps/web/src/hooks/use-expert-access.ts
Normal file
15
apps/web/src/hooks/use-expert-access.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { api } from "@/lib/api-client";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
// Parça Uzmanları programı kapısı: PostHog ana şalteri + kullanım kriteri
|
||||||
|
// sunucuda birlikte değerlendirilir; web yalnız sonucu sorar. Kapalıysa
|
||||||
|
// program yüzeyleri (nav linki, oy kartı, öneri bölümü, uzmanlar sayfası)
|
||||||
|
// hiç render edilmez — kriterler istemciye sızdırılmaz.
|
||||||
|
export function useExpertAccess(enabled = true) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["expert-access"],
|
||||||
|
queryFn: () => api.get<{ enabled: boolean }>("/oem-votes/access"),
|
||||||
|
staleTime: 5 * 60_000,
|
||||||
|
enabled,
|
||||||
|
});
|
||||||
|
}
|
||||||
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 = [];
|
_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");
|
const script = document.createElement("script");
|
||||||
script.id = "chatwoot-sdk";
|
script.id = "chatwoot-sdk";
|
||||||
script.src = `${BASE_URL}/packs/js/sdk.js`;
|
script.src = `${BASE_URL}/packs/js/sdk.js`;
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
// PostHog — lazily loaded so it doesn't land in the initial bundle.
|
// 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.
|
// 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;
|
type PostHog = import("posthog-js").PostHog;
|
||||||
|
|
||||||
let _ph: PostHog | null = null;
|
let _ph: PostHog | null = null;
|
||||||
@@ -39,11 +41,19 @@ export function initPostHog(): void {
|
|||||||
// PII leak.
|
// PII leak.
|
||||||
autocapture: false,
|
autocapture: false,
|
||||||
enable_heatmaps: true,
|
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: {
|
session_recording: {
|
||||||
maskAllInputs: false,
|
maskAllInputs: false,
|
||||||
maskInputOptions: { password: true },
|
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,
|
role: user.role,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
scheduleSurveyCheck();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resetUser(): void {
|
export function resetUser(): void {
|
||||||
load().then((ph) => ph.reset());
|
load().then((ph) => ph.reset());
|
||||||
|
resetSurveySession();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function capture(event: string, properties?: Record<string, unknown>): void {
|
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 {
|
export function capturePageView(path: string): void {
|
||||||
@@ -79,10 +97,12 @@ export function capturePageView(path: string): void {
|
|||||||
load().then((ph) =>
|
load().then((ph) =>
|
||||||
ph.capture("$pageview", { $current_url: window.location.href, $pathname: path }),
|
ph.capture("$pageview", { $current_url: window.location.href, $pathname: path }),
|
||||||
);
|
);
|
||||||
|
scheduleSurveyCheck();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setPeopleProperties(properties: Record<string, unknown>): void {
|
export function setPeopleProperties(properties: Record<string, unknown>): void {
|
||||||
load().then((ph) => ph.people?.set(properties));
|
load().then((ph) => ph.people?.set(properties));
|
||||||
|
scheduleSurveyCheck();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -10,9 +10,20 @@
|
|||||||
import type { init as SentryInit } from "@sentry/react";
|
import type { init as SentryInit } from "@sentry/react";
|
||||||
|
|
||||||
let initialized = false;
|
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() {
|
export function initSentry(): Promise<void> {
|
||||||
if (initialized) return;
|
if (!initPromise) initPromise = doInitSentry();
|
||||||
|
return initPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doInitSentry(): Promise<void> {
|
||||||
const dsn = import.meta.env.VITE_SENTRY_DSN;
|
const dsn = import.meta.env.VITE_SENTRY_DSN;
|
||||||
if (!dsn) {
|
if (!dsn) {
|
||||||
if (import.meta.env.DEV) {
|
if (import.meta.env.DEV) {
|
||||||
@@ -21,20 +32,18 @@ export async function initSentry() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Both dev.sase.tr (staging) and sase.tr (prod) ship a production Vite build
|
// Hostname is authoritative for the two real deployments — sase.tr (prod) and
|
||||||
// (MODE === "production"), so MODE can't separate them. Derive from the host at
|
// dev.sase.tr (staging) — which both ship a production Vite build baking the
|
||||||
// runtime instead, so dev errors stay filterable from prod in Sentry. An
|
// SAME VITE_SENTRY_ENVIRONMENT (compose default "production"), so that var can't
|
||||||
// explicit VITE_SENTRY_ENVIRONMENT still wins.
|
// tell them apart. Use it only as a fallback for local/preview hosts.
|
||||||
const host = typeof window !== "undefined" ? window.location.hostname : "";
|
const host = typeof window !== "undefined" ? window.location.hostname : "";
|
||||||
const environment =
|
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"
|
? "production"
|
||||||
: host.endsWith("dev.sase.tr")
|
: host.endsWith("dev.sase.tr")
|
||||||
? "staging"
|
? "staging"
|
||||||
: import.meta.env.DEV
|
: (import.meta.env.VITE_SENTRY_ENVIRONMENT as string | undefined) ||
|
||||||
? "development"
|
(import.meta.env.DEV ? "development" : "staging");
|
||||||
: "staging");
|
|
||||||
const release = import.meta.env.VITE_SENTRY_RELEASE;
|
const release = import.meta.env.VITE_SENTRY_RELEASE;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -46,6 +55,9 @@ export async function initSentry() {
|
|||||||
integrations: [
|
integrations: [
|
||||||
Sentry.browserTracingIntegration(),
|
Sentry.browserTracingIntegration(),
|
||||||
Sentry.replayIntegration({ maskAllText: true, blockAllMedia: true }),
|
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).
|
// Performance: 10% trace sample rate (low volume site, can raise later).
|
||||||
tracesSampleRate: 0.1,
|
tracesSampleRate: 0.1,
|
||||||
@@ -75,8 +87,99 @@ export async function initSentry() {
|
|||||||
denyUrls: [/iabjs:\/\//],
|
denyUrls: [/iabjs:\/\//],
|
||||||
});
|
});
|
||||||
initialized = true;
|
initialized = true;
|
||||||
|
sentryApi = Sentry;
|
||||||
console.log("[sentry] browser SDK initialized");
|
console.log("[sentry] browser SDK initialized");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn("[sentry] init failed:", (err as Error).message);
|
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();
|
||||||
|
}
|
||||||
@@ -74,6 +74,7 @@
|
|||||||
"dashboard": "Dashboard",
|
"dashboard": "Dashboard",
|
||||||
"search": "Search",
|
"search": "Search",
|
||||||
"history": "History",
|
"history": "History",
|
||||||
|
"experts": "Parts Experts",
|
||||||
"catalog": "Catalog",
|
"catalog": "Catalog",
|
||||||
"subscription": "Subscription",
|
"subscription": "Subscription",
|
||||||
"billing": "Payment",
|
"billing": "Payment",
|
||||||
@@ -82,7 +83,6 @@
|
|||||||
"logout": "Log Out",
|
"logout": "Log Out",
|
||||||
"contact": "Contact",
|
"contact": "Contact",
|
||||||
"blog": "Blog",
|
"blog": "Blog",
|
||||||
"changelog": "What's New",
|
|
||||||
"account": "Account",
|
"account": "Account",
|
||||||
"sectionMain": "Main Menu",
|
"sectionMain": "Main Menu",
|
||||||
"sectionAccount": "Account",
|
"sectionAccount": "Account",
|
||||||
@@ -185,7 +185,7 @@
|
|||||||
"tabPl24": "Pl24",
|
"tabPl24": "Pl24",
|
||||||
"tabPcat": "Pcat",
|
"tabPcat": "Pcat",
|
||||||
"tabEmex": "Emex",
|
"tabEmex": "Emex",
|
||||||
"tabTecdoc": "Tecdoc",
|
"tabTecdoc": "P",
|
||||||
"comingSoon": "Coming Soon",
|
"comingSoon": "Coming Soon",
|
||||||
"categories": "Categories",
|
"categories": "Categories",
|
||||||
"noCategories": "No categories found",
|
"noCategories": "No categories found",
|
||||||
@@ -413,6 +413,11 @@
|
|||||||
"cta": "Upgrade Plan",
|
"cta": "Upgrade Plan",
|
||||||
"progressLabel": "{percent}% of trial used"
|
"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": {
|
"trialProgress": {
|
||||||
"title": "{days} days left in your Full Package trial",
|
"title": "{days} days left in your Full Package trial",
|
||||||
"description": "Pick a plan now to keep your access without interruption.",
|
"description": "Pick a plan now to keep your access without interruption.",
|
||||||
@@ -489,8 +494,7 @@
|
|||||||
"refunded": "Refunded"
|
"refunded": "Refunded"
|
||||||
},
|
},
|
||||||
"methodLabels": {
|
"methodLabels": {
|
||||||
"stripe": "Credit Card",
|
"stripe": "Credit Card"
|
||||||
"eft": "EFT/Wire"
|
|
||||||
},
|
},
|
||||||
"summary": {
|
"summary": {
|
||||||
"title": "Current Plan",
|
"title": "Current Plan",
|
||||||
@@ -518,7 +522,6 @@
|
|||||||
"connections": "Connections",
|
"connections": "Connections",
|
||||||
"referral": "Referral",
|
"referral": "Referral",
|
||||||
"account": "Account",
|
"account": "Account",
|
||||||
"changelog": "Changelog",
|
|
||||||
"notifications": "Notifications"
|
"notifications": "Notifications"
|
||||||
},
|
},
|
||||||
"preferences": {
|
"preferences": {
|
||||||
@@ -589,17 +592,6 @@
|
|||||||
"typeConfirm": "Type 'DELETE' to confirm",
|
"typeConfirm": "Type 'DELETE' to confirm",
|
||||||
"confirmWord": "DELETE"
|
"confirmWord": "DELETE"
|
||||||
},
|
},
|
||||||
"changelog": {
|
|
||||||
"title": "Changelog",
|
|
||||||
"description": "Latest platform updates, new features, and bug fixes.",
|
|
||||||
"emptyTitle": "No updates yet",
|
|
||||||
"emptyDescription": "Platform updates will appear here soon.",
|
|
||||||
"changeType": {
|
|
||||||
"fix": "Fix",
|
|
||||||
"feature": "New Feature",
|
|
||||||
"improvement": "Improvement"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"title": "Notification preferences",
|
"title": "Notification preferences",
|
||||||
"description": "Choose which lifecycle e-mails you want to receive. Account security and payment notifications keep coming."
|
"description": "Choose which lifecycle e-mails you want to receive. Account security and payment notifications keep coming."
|
||||||
@@ -663,7 +655,7 @@
|
|||||||
"s2t": "See the parts",
|
"s2t": "See the parts",
|
||||||
"s2d": "OEM codes, diagrams and compatible parts appear in seconds.",
|
"s2d": "OEM codes, diagrams and compatible parts appear in seconds.",
|
||||||
"s3t": "Pick the right part",
|
"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": {
|
"faq": {
|
||||||
"title": "Frequently asked questions",
|
"title": "Frequently asked questions",
|
||||||
@@ -713,7 +705,6 @@
|
|||||||
"previewHint": "Click Decode VIN to access the full parts catalog.",
|
"previewHint": "Click Decode VIN to access the full parts catalog.",
|
||||||
"recent": "Recent searches",
|
"recent": "Recent searches",
|
||||||
"seeAll": "See all →",
|
"seeAll": "See all →",
|
||||||
"openHint": "Click to reopen the vehicle with its parts catalog",
|
|
||||||
"historyAria": "{brand} {model} — VIN {vin}, open from history",
|
"historyAria": "{brand} {model} — VIN {vin}, open from history",
|
||||||
"errorInvalidVin": "Invalid VIN. Must be 17 characters; I, O, Q are not allowed.",
|
"errorInvalidVin": "Invalid VIN. Must be 17 characters; I, O, Q are not allowed.",
|
||||||
"errorGeneric": "An error occurred. Please try again.",
|
"errorGeneric": "An error occurred. Please try again.",
|
||||||
@@ -738,6 +729,8 @@
|
|||||||
"noCategoriesTitle": "No categories for this vehicle",
|
"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.",
|
"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.",
|
"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.",
|
"noAttrs": "No detailed info available for this vehicle.",
|
||||||
"labelModel": "Model",
|
"labelModel": "Model",
|
||||||
"labelYear": "Model year",
|
"labelYear": "Model year",
|
||||||
@@ -798,7 +791,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.",
|
"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",
|
"lossPillB": "Start looking up VINs free — no card required",
|
||||||
"titleB": "Find the Customer's Correct Part in Seconds",
|
"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",
|
"vinPlaceholder": "Example: WVWZZZ1JZ3W597935",
|
||||||
"searchCta": "Search",
|
"searchCta": "Search",
|
||||||
"decoding": "Decoding...",
|
"decoding": "Decoding...",
|
||||||
@@ -868,7 +861,7 @@
|
|||||||
"description": "Cross-references multiple catalogs so OEM codes are always current and correct — no more wrong parts, no more returns.",
|
"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",
|
"bullet1": "Multi-catalog cross-referencing",
|
||||||
"bullet2": "Always-current OEM codes",
|
"bullet2": "Always-current OEM codes",
|
||||||
"bullet3": "Price comparison",
|
"bullet3": "Matched via exploded diagrams",
|
||||||
"mockupTitle": "OEM Catalog — Parts List",
|
"mockupTitle": "OEM Catalog — Parts List",
|
||||||
"part1": "AC Compressor",
|
"part1": "AC Compressor",
|
||||||
"part2": "Heater Blower Motor",
|
"part2": "Heater Blower Motor",
|
||||||
@@ -897,9 +890,9 @@
|
|||||||
"sase": "Multi-catalog cross-query"
|
"sase": "Multi-catalog cross-query"
|
||||||
},
|
},
|
||||||
"3": {
|
"3": {
|
||||||
"label": "Price comparison",
|
"label": "Interactive part diagrams",
|
||||||
"manual": "Impossible",
|
"manual": "Paper catalog",
|
||||||
"sase": "On a single screen"
|
"sase": "Clickable diagrams"
|
||||||
},
|
},
|
||||||
"4": {
|
"4": {
|
||||||
"label": "Monthly time saved",
|
"label": "Monthly time saved",
|
||||||
@@ -1160,7 +1153,7 @@
|
|||||||
"items": {
|
"items": {
|
||||||
"0": {
|
"0": {
|
||||||
"q": "Which brands do you support?",
|
"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": {
|
"1": {
|
||||||
"q": "Is my data safe?",
|
"q": "Is my data safe?",
|
||||||
|
|||||||
@@ -74,6 +74,7 @@
|
|||||||
"dashboard": "Gösterge Paneli",
|
"dashboard": "Gösterge Paneli",
|
||||||
"search": "Arama",
|
"search": "Arama",
|
||||||
"history": "Geçmiş",
|
"history": "Geçmiş",
|
||||||
|
"experts": "Parça Uzmanları",
|
||||||
"catalog": "Katalog",
|
"catalog": "Katalog",
|
||||||
"subscription": "Abonelik",
|
"subscription": "Abonelik",
|
||||||
"billing": "Ödeme",
|
"billing": "Ödeme",
|
||||||
@@ -82,7 +83,6 @@
|
|||||||
"logout": "Çıkış Yap",
|
"logout": "Çıkış Yap",
|
||||||
"contact": "İletişim",
|
"contact": "İletişim",
|
||||||
"blog": "Blog",
|
"blog": "Blog",
|
||||||
"changelog": "Yenilikler",
|
|
||||||
"account": "Hesap",
|
"account": "Hesap",
|
||||||
"sectionMain": "Ana Menü",
|
"sectionMain": "Ana Menü",
|
||||||
"sectionAccount": "Hesap",
|
"sectionAccount": "Hesap",
|
||||||
@@ -185,7 +185,7 @@
|
|||||||
"tabPl24": "Pl24",
|
"tabPl24": "Pl24",
|
||||||
"tabPcat": "Pcat",
|
"tabPcat": "Pcat",
|
||||||
"tabEmex": "Emex",
|
"tabEmex": "Emex",
|
||||||
"tabTecdoc": "Tecdoc",
|
"tabTecdoc": "P",
|
||||||
"comingSoon": "Yakında",
|
"comingSoon": "Yakında",
|
||||||
"categories": "Kategoriler",
|
"categories": "Kategoriler",
|
||||||
"noCategories": "Kategori bulunamadı",
|
"noCategories": "Kategori bulunamadı",
|
||||||
@@ -324,7 +324,7 @@
|
|||||||
"brandSearchPlaceholder": "Marka ara — ör. Volkswagen",
|
"brandSearchPlaceholder": "Marka ara — ör. Volkswagen",
|
||||||
"brandSearchNoMatch": "\"{query}\" ile eşleşen marka yok.",
|
"brandSearchNoMatch": "\"{query}\" ile eşleşen marka yok.",
|
||||||
"features": {
|
"features": {
|
||||||
"vinSearch": "Sınırsız VIN arama",
|
"vinSearch": "Sınırsız şase arama",
|
||||||
"partsCatalog": "Parça kataloğu",
|
"partsCatalog": "Parça kataloğu",
|
||||||
"schemaViewer": "Şema görüntüleyici",
|
"schemaViewer": "Şema görüntüleyici",
|
||||||
"prioritySupport": "Öncelikli destek",
|
"prioritySupport": "Öncelikli destek",
|
||||||
@@ -413,6 +413,11 @@
|
|||||||
"cta": "Plana Yükselt",
|
"cta": "Plana Yükselt",
|
||||||
"progressLabel": "Denemenin %{percent}'i kullanıldı"
|
"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": {
|
"trialProgress": {
|
||||||
"title": "Full Paket denemende {days} gün kaldı",
|
"title": "Full Paket denemende {days} gün kaldı",
|
||||||
"description": "Kesintisiz devam etmek için planını şimdi seç.",
|
"description": "Kesintisiz devam etmek için planını şimdi seç.",
|
||||||
@@ -489,8 +494,7 @@
|
|||||||
"refunded": "İade"
|
"refunded": "İade"
|
||||||
},
|
},
|
||||||
"methodLabels": {
|
"methodLabels": {
|
||||||
"stripe": "Kredi Kartı",
|
"stripe": "Kredi Kartı"
|
||||||
"eft": "EFT/Havale"
|
|
||||||
},
|
},
|
||||||
"summary": {
|
"summary": {
|
||||||
"title": "Mevcut Plan",
|
"title": "Mevcut Plan",
|
||||||
@@ -518,7 +522,6 @@
|
|||||||
"connections": "Bağlantılar",
|
"connections": "Bağlantılar",
|
||||||
"referral": "Referans",
|
"referral": "Referans",
|
||||||
"account": "Hesap",
|
"account": "Hesap",
|
||||||
"changelog": "Değişiklik Günlüğü",
|
|
||||||
"notifications": "Bildirimler"
|
"notifications": "Bildirimler"
|
||||||
},
|
},
|
||||||
"preferences": {
|
"preferences": {
|
||||||
@@ -589,17 +592,6 @@
|
|||||||
"typeConfirm": "Onaylamak için 'SİL' yazın",
|
"typeConfirm": "Onaylamak için 'SİL' yazın",
|
||||||
"confirmWord": "SİL"
|
"confirmWord": "SİL"
|
||||||
},
|
},
|
||||||
"changelog": {
|
|
||||||
"title": "Değişiklik Günlüğü",
|
|
||||||
"description": "Platformdaki son güncellemeler, yeni özellikler ve hata düzeltmeleri.",
|
|
||||||
"emptyTitle": "Henüz güncelleme yok",
|
|
||||||
"emptyDescription": "Yakında platform güncellemeleri burada görünecek.",
|
|
||||||
"changeType": {
|
|
||||||
"fix": "Düzeltme",
|
|
||||||
"feature": "Yeni Özellik",
|
|
||||||
"improvement": "Geliştirme"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"title": "Bildirim tercihleri",
|
"title": "Bildirim tercihleri",
|
||||||
"description": "Hangi lifecycle maillerini almak istediğini seç. Hesap güvenliği ve ödeme bildirimleri her zaman gelmeye devam eder."
|
"description": "Hangi lifecycle maillerini almak istediğini seç. Hesap güvenliği ve ödeme bildirimleri her zaman gelmeye devam eder."
|
||||||
@@ -659,11 +651,11 @@
|
|||||||
"how": {
|
"how": {
|
||||||
"title": "Nasıl çalışır",
|
"title": "Nasıl çalışır",
|
||||||
"s1t": "Şase numarasını gir",
|
"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",
|
"s2t": "Parçaları gör",
|
||||||
"s2d": "Saniyeler içinde OEM kodları, şemalar ve uyumlu parçalar listelensin.",
|
"s2d": "Saniyeler içinde OEM kodları, şemalar ve uyumlu parçalar listelensin.",
|
||||||
"s3t": "Doğru parçayı seç",
|
"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": {
|
"faq": {
|
||||||
"title": "Sık sorulan sorular",
|
"title": "Sık sorulan sorular",
|
||||||
@@ -713,7 +705,6 @@
|
|||||||
"previewHint": "Şase Çöz butonuna tıklayarak tam parça kataloğuna erişin.",
|
"previewHint": "Şase Çöz butonuna tıklayarak tam parça kataloğuna erişin.",
|
||||||
"recent": "Son Aramalar",
|
"recent": "Son Aramalar",
|
||||||
"seeAll": "Tümünü Gör →",
|
"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ç",
|
"historyAria": "{brand} {model} — şase {vin}, geçmişten aç",
|
||||||
"errorInvalidVin": "Geçersiz şase. 17 karakter olmalı, I, O, Q harfleri kullanılamaz.",
|
"errorInvalidVin": "Geçersiz şase. 17 karakter olmalı, I, O, Q harfleri kullanılamaz.",
|
||||||
"errorGeneric": "Bir hata oluştu. Lütfen tekrar deneyin.",
|
"errorGeneric": "Bir hata oluştu. Lütfen tekrar deneyin.",
|
||||||
@@ -738,6 +729,8 @@
|
|||||||
"noCategoriesTitle": "Bu araç için kategori bulunamadı",
|
"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.",
|
"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.",
|
"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ı.",
|
"noAttrs": "Bu araç için ayrıntı bilgisi bulunamadı.",
|
||||||
"labelModel": "Model",
|
"labelModel": "Model",
|
||||||
"labelYear": "Model yılı",
|
"labelYear": "Model yılı",
|
||||||
@@ -770,7 +763,7 @@
|
|||||||
"meta": {
|
"meta": {
|
||||||
"title": "Şase Numarası Sorgulama & OEM Parça Kataloğu | Sase.tr",
|
"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.",
|
"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."
|
"schemaSoftwareDescription": "Araç şase numarası ile OEM yedek parça sorgulama platformu."
|
||||||
},
|
},
|
||||||
"nav": {
|
"nav": {
|
||||||
@@ -798,7 +791,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.",
|
"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",
|
"lossPillB": "Şase sorgulamaya ücretsiz başlayın — kart gerekmez",
|
||||||
"titleB": "Müşteri Aracının Doğru Parçasını Saniyede Bulun",
|
"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",
|
"vinPlaceholder": "Örnek: WVWZZZ1JZ3W597935",
|
||||||
"searchCta": "Ara",
|
"searchCta": "Ara",
|
||||||
"decoding": "Çözülüyor...",
|
"decoding": "Çözülüyor...",
|
||||||
@@ -868,7 +861,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.",
|
"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",
|
"bullet1": "Çoklu katalog çapraz sorgulama",
|
||||||
"bullet2": "Her zaman güncel OEM kodları",
|
"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",
|
"mockupTitle": "OEM Katalog — Parça Listesi",
|
||||||
"part1": "Klima Kompresörü",
|
"part1": "Klima Kompresörü",
|
||||||
"part2": "Kalorifer Motoru",
|
"part2": "Kalorifer Motoru",
|
||||||
@@ -897,9 +890,9 @@
|
|||||||
"sase": "Çoklu katalog çapraz sorgu"
|
"sase": "Çoklu katalog çapraz sorgu"
|
||||||
},
|
},
|
||||||
"3": {
|
"3": {
|
||||||
"label": "Fiyat karşılaştırma",
|
"label": "İnteraktif parça şeması",
|
||||||
"manual": "İmkansız",
|
"manual": "Kağıt katalog",
|
||||||
"sase": "Tek ekranda"
|
"sase": "Tıklanabilir diyagram"
|
||||||
},
|
},
|
||||||
"4": {
|
"4": {
|
||||||
"label": "Aylık zaman tasarrufu",
|
"label": "Aylık zaman tasarrufu",
|
||||||
@@ -1047,14 +1040,14 @@
|
|||||||
"ecommerce": {
|
"ecommerce": {
|
||||||
"badge": "E-Ticaret Entegrasyonu",
|
"badge": "E-Ticaret Entegrasyonu",
|
||||||
"title": "Kendi Sitenize Şase Çözme Gücü Katın",
|
"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",
|
"frameTitle": "otoyedekparca.co — Sase.tr Entegrasyonu",
|
||||||
"loading": "Yükleniyor…",
|
"loading": "Yükleniyor…",
|
||||||
"easyBadge": "Kolay Entegrasyon",
|
"easyBadge": "Kolay Entegrasyon",
|
||||||
"cardTitle": "Birkaç Satır Kod, Büyük Fark",
|
"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",
|
"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",
|
"bullet3": "İade Oranını Düşürün — Doğru parça, ilk seferde",
|
||||||
"bullet4": "White-Label — Widget sitenizin tasarımına uyum sağlar",
|
"bullet4": "White-Label — Widget sitenizin tasarımına uyum sağlar",
|
||||||
"cta": "İletişime Geçin",
|
"cta": "İletişime Geçin",
|
||||||
@@ -1090,7 +1083,7 @@
|
|||||||
"2": {
|
"2": {
|
||||||
"name": "Özge D.",
|
"name": "Özge D.",
|
||||||
"role": "E-Ticaret Yöneticisi",
|
"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": {
|
"3": {
|
||||||
"name": "Burak T.",
|
"name": "Burak T.",
|
||||||
@@ -1113,7 +1106,7 @@
|
|||||||
"name": "1 Marka",
|
"name": "1 Marka",
|
||||||
"description": "Tek marka için erişim",
|
"description": "Tek marka için erişim",
|
||||||
"feature1": "1 marka seçimi",
|
"feature1": "1 marka seçimi",
|
||||||
"feature2": "Sınırsız VIN arama",
|
"feature2": "Sınırsız şase arama",
|
||||||
"feature3": "Parça kataloğu",
|
"feature3": "Parça kataloğu",
|
||||||
"feature4": "Şema görüntüleyici"
|
"feature4": "Şema görüntüleyici"
|
||||||
},
|
},
|
||||||
@@ -1121,7 +1114,7 @@
|
|||||||
"name": "2 Marka",
|
"name": "2 Marka",
|
||||||
"description": "İki farklı marka",
|
"description": "İki farklı marka",
|
||||||
"feature1": "2 marka seçimi",
|
"feature1": "2 marka seçimi",
|
||||||
"feature2": "Sınırsız VIN arama",
|
"feature2": "Sınırsız şase arama",
|
||||||
"feature3": "Parça kataloğu",
|
"feature3": "Parça kataloğu",
|
||||||
"feature4": "Öncelikli destek"
|
"feature4": "Öncelikli destek"
|
||||||
},
|
},
|
||||||
@@ -1129,7 +1122,7 @@
|
|||||||
"name": "3 Marka",
|
"name": "3 Marka",
|
||||||
"description": "Üç marka kapsamlı",
|
"description": "Üç marka kapsamlı",
|
||||||
"feature1": "3 marka seçimi",
|
"feature1": "3 marka seçimi",
|
||||||
"feature2": "Sınırsız VIN arama",
|
"feature2": "Sınırsız şase arama",
|
||||||
"feature3": "Parça kataloğu",
|
"feature3": "Parça kataloğu",
|
||||||
"feature4": "Öncelikli destek"
|
"feature4": "Öncelikli destek"
|
||||||
}
|
}
|
||||||
@@ -1144,7 +1137,7 @@
|
|||||||
"ctaFineprint": "Kart bilgisi gerekmez. İstediğin zaman iptal et.",
|
"ctaFineprint": "Kart bilgisi gerekmez. İstediğin zaman iptal et.",
|
||||||
"included": "Pakete dahil",
|
"included": "Pakete dahil",
|
||||||
"feature1": "Tüm 27 markaya erişim",
|
"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",
|
"feature3": "OEM parça kataloğu",
|
||||||
"feature4": "İnteraktif şema görüntüleyici",
|
"feature4": "İnteraktif şema görüntüleyici",
|
||||||
"feature5": "Geçmiş sorgular & favoriler",
|
"feature5": "Geçmiş sorgular & favoriler",
|
||||||
@@ -1160,7 +1153,7 @@
|
|||||||
"items": {
|
"items": {
|
||||||
"0": {
|
"0": {
|
||||||
"q": "Hangi markaları destekliyorsunuz?",
|
"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": {
|
"1": {
|
||||||
"q": "Verilerim güvende mi?",
|
"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 { InAppEscape } from "@/components/in-app-escape";
|
||||||
|
import { SurveyPopover } from "@/components/survey-popover";
|
||||||
import { useAuth } from "@/hooks/use-auth";
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
import { api } from "@/lib/api-client";
|
import { api } from "@/lib/api-client";
|
||||||
import { resetChatwootUser, setChatwootUser } from "@/lib/chatwoot";
|
import { resetChatwootUser, setChatwootUser } from "@/lib/chatwoot";
|
||||||
@@ -156,6 +157,7 @@ function RootComponent() {
|
|||||||
</a>
|
</a>
|
||||||
<InAppEscape />
|
<InAppEscape />
|
||||||
<Outlet />
|
<Outlet />
|
||||||
|
<SurveyPopover />
|
||||||
<Toaster position="top-center" />
|
<Toaster position="top-center" />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { usePageMeta } from "@/hooks/use-page-meta";
|
|
||||||
import { SiteHeader } from "@/components/site-header";
|
import { SiteHeader } from "@/components/site-header";
|
||||||
|
import { usePageMeta } from "@/hooks/use-page-meta";
|
||||||
import { Button } from "@sase/ui";
|
import { Button } from "@sase/ui";
|
||||||
import { Link, createFileRoute } from "@tanstack/react-router";
|
import { Link, createFileRoute } from "@tanstack/react-router";
|
||||||
|
|
||||||
@@ -11,7 +11,7 @@ function AboutPage() {
|
|||||||
usePageMeta({
|
usePageMeta({
|
||||||
title: "Hakkımızda — Sase.tr | Türkiye'nin Şase Sorgulama Platformu",
|
title: "Hakkımızda — Sase.tr | Türkiye'nin Şase Sorgulama Platformu",
|
||||||
description:
|
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",
|
canonical: "https://sase.tr/about",
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -25,21 +25,21 @@ function AboutPage() {
|
|||||||
<div className="mt-8 space-y-6 text-muted-foreground leading-relaxed">
|
<div className="mt-8 space-y-6 text-muted-foreground leading-relaxed">
|
||||||
<p>
|
<p>
|
||||||
Sase.tr, Türkiye'nin yedek parça sektörüne yönelik geliştirilen dijital bir platformdur.
|
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
|
Şase numarası ile araç tanımlama, orijinal parça kataloğuna erişim ve interaktif şema
|
||||||
şema görüntüleme hizmetlerini tek bir çatı altında sunar.
|
görüntüleme hizmetlerini tek bir çatı altında sunar.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h2 className="text-2xl font-semibold text-foreground">Misyonumuz</h2>
|
<h2 className="text-2xl font-semibold text-foreground">Misyonumuz</h2>
|
||||||
<p>
|
<p>
|
||||||
Yedek parça arama sürecini hızlandırmak, doğru parçaya ilk seferde ulaşmayı sağlamak ve
|
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
|
sektördeki bilgi asimetrisini ortadan kaldırmak. Oto yedek parçacılar ve servisler için
|
||||||
bireysel kullanıcılar için güvenilir bir referans noktası olmayı hedefliyoruz.
|
güvenilir bir referans noktası olmayı hedefliyoruz.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h2 className="text-2xl font-semibold text-foreground">Ne Yapıyoruz?</h2>
|
<h2 className="text-2xl font-semibold text-foreground">Ne Yapıyoruz?</h2>
|
||||||
<ul className="list-disc space-y-2 pl-6">
|
<ul className="list-disc space-y-2 pl-6">
|
||||||
<li>
|
<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.
|
donanım bilgilerine anında erişim.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
|
|||||||
@@ -1,61 +1,18 @@
|
|||||||
|
import { BlogListContent } from "@/components/blog-content";
|
||||||
import { SiteHeader } from "@/components/site-header";
|
import { SiteHeader } from "@/components/site-header";
|
||||||
import { useBlogPosts } from "@/hooks/use-blog";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { usePageMeta } from "@/hooks/use-page-meta";
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@sase/ui";
|
|
||||||
import { Link, createFileRoute } from "@tanstack/react-router";
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/blog")({
|
export const Route = createFileRoute("/blog")({
|
||||||
component: BlogPage,
|
component: BlogPage,
|
||||||
});
|
});
|
||||||
|
|
||||||
function 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 (
|
return (
|
||||||
<div className="min-h-screen">
|
<div className="min-h-screen">
|
||||||
<SiteHeader />
|
<SiteHeader />
|
||||||
|
|
||||||
<main className="container mx-auto max-w-4xl px-4 py-16">
|
<main className="container mx-auto max-w-4xl px-4 py-16">
|
||||||
<h1 className="text-4xl font-bold">Blog</h1>
|
<BlogListContent postLinkTo="/blog/$slug" />
|
||||||
<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>
|
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,42 +1,6 @@
|
|||||||
|
import { BlogPostContent } from "@/components/blog-content";
|
||||||
import { SiteHeader } from "@/components/site-header";
|
import { SiteHeader } from "@/components/site-header";
|
||||||
import { useBlogPost } from "@/hooks/use-blog";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
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 ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/blog_/$slug")({
|
export const Route = createFileRoute("/blog_/$slug")({
|
||||||
component: BlogPostPage,
|
component: BlogPostPage,
|
||||||
@@ -44,65 +8,13 @@ export const Route = createFileRoute("/blog_/$slug")({
|
|||||||
|
|
||||||
function BlogPostPage() {
|
function BlogPostPage() {
|
||||||
const { slug } = Route.useParams();
|
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 (
|
return (
|
||||||
<div className="min-h-screen">
|
<div className="min-h-screen">
|
||||||
<SiteHeader />
|
<SiteHeader />
|
||||||
|
|
||||||
<main className="container mx-auto max-w-3xl px-4 py-16">
|
<main className="container mx-auto max-w-3xl px-4 py-16">
|
||||||
{/* Breadcrumb */}
|
<BlogPostContent slug={slug} listLinkTo="/blog" />
|
||||||
<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>
|
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,230 +1,18 @@
|
|||||||
|
import { ContactContent } from "@/components/contact-content";
|
||||||
import { SiteHeader } from "@/components/site-header";
|
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 { 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")({
|
export const Route = createFileRoute("/contact")({
|
||||||
component: ContactPage,
|
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() {
|
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 (
|
return (
|
||||||
<div className="min-h-screen">
|
<div className="min-h-screen">
|
||||||
<SiteHeader />
|
<SiteHeader />
|
||||||
|
|
||||||
<main className="container mx-auto max-w-5xl px-4 py-16">
|
<main className="container mx-auto max-w-5xl px-4 py-16">
|
||||||
<h1 className="text-4xl font-bold">İletişim</h1>
|
<ContactContent />
|
||||||
<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>
|
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { LanguageSwitcher } from "@/components/language-switcher";
|
import { LanguageSwitcher } from "@/components/language-switcher";
|
||||||
import { SiteFooter } from "@/components/site-footer";
|
import { SiteFooter } from "@/components/site-footer";
|
||||||
import { TrialUrgencyBanner } from "@/components/trial-urgency-banner";
|
import { TrialUrgencyBanner } from "@/components/trial-urgency-banner";
|
||||||
|
import { TrialValueUpsell } from "@/components/trial-value-upsell";
|
||||||
import { useAuth } from "@/hooks/use-auth";
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
|
import { useExpertAccess } from "@/hooks/use-expert-access";
|
||||||
import { api } from "@/lib/api-client";
|
import { api } from "@/lib/api-client";
|
||||||
import { useTranslation } from "@/lib/i18n";
|
import { useTranslation } from "@/lib/i18n";
|
||||||
import { KEYS_5 } from "@/lib/keys";
|
import { KEYS_5 } from "@/lib/keys";
|
||||||
@@ -30,7 +32,6 @@ import { Link, Outlet, createFileRoute, useNavigate, useRouterState } from "@tan
|
|||||||
import {
|
import {
|
||||||
BarChart3,
|
BarChart3,
|
||||||
BookOpen,
|
BookOpen,
|
||||||
CalendarDays,
|
|
||||||
ChevronsUpDown,
|
ChevronsUpDown,
|
||||||
Copy,
|
Copy,
|
||||||
CreditCard,
|
CreditCard,
|
||||||
@@ -50,6 +51,7 @@ import {
|
|||||||
Shield,
|
Shield,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Sun,
|
Sun,
|
||||||
|
Trophy,
|
||||||
Users,
|
Users,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
@@ -71,6 +73,7 @@ const mainMenuItems: readonly NavItem[] = [
|
|||||||
{ to: "/dashboard/search", labelKey: "nav.search", icon: Search },
|
{ to: "/dashboard/search", labelKey: "nav.search", icon: Search },
|
||||||
{ to: "/dashboard/catalog", labelKey: "nav.catalog", icon: Library },
|
{ to: "/dashboard/catalog", labelKey: "nav.catalog", icon: Library },
|
||||||
{ to: "/dashboard/history", labelKey: "nav.history", icon: History },
|
{ to: "/dashboard/history", labelKey: "nav.history", icon: History },
|
||||||
|
{ to: "/dashboard/uzmanlar", labelKey: "nav.experts", icon: Trophy },
|
||||||
];
|
];
|
||||||
|
|
||||||
const accountItems: readonly NavItem[] = [
|
const accountItems: readonly NavItem[] = [
|
||||||
@@ -80,9 +83,8 @@ const accountItems: readonly NavItem[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const supportItems: readonly NavItem[] = [
|
const supportItems: readonly NavItem[] = [
|
||||||
{ to: "/dashboard/changelog", labelKey: "nav.changelog", icon: CalendarDays },
|
{ to: "/dashboard/contact", labelKey: "nav.contact", icon: Mail },
|
||||||
{ to: "/contact", labelKey: "nav.contact", icon: Mail },
|
{ to: "/dashboard/blog", labelKey: "nav.blog", icon: BookOpen },
|
||||||
{ to: "/blog", labelKey: "nav.blog", icon: BookOpen },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const adminItems: readonly NavItem[] = [
|
const adminItems: readonly NavItem[] = [
|
||||||
@@ -179,6 +181,13 @@ function DashboardLayout() {
|
|||||||
});
|
});
|
||||||
const hasActivePlan = subData?.subscription?.status === "active";
|
const hasActivePlan = subData?.subscription?.status === "active";
|
||||||
|
|
||||||
|
// Parça Uzmanları programı flag+kriter kapısı: kapalıyken nav linki hiç
|
||||||
|
// görünmez (program yüzeyleri sunucu tarafında da kapalıdır).
|
||||||
|
const { data: expertAccess } = useExpertAccess(!!user);
|
||||||
|
const visibleMainMenuItems = mainMenuItems.filter(
|
||||||
|
(item) => item.to !== "/dashboard/uzmanlar" || expertAccess?.enabled === true,
|
||||||
|
);
|
||||||
|
|
||||||
// Redirect unauthenticated users without mutating router state during render.
|
// Redirect unauthenticated users without mutating router state during render.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isLoading && !user) {
|
if (!isLoading && !user) {
|
||||||
@@ -354,7 +363,7 @@ function DashboardLayout() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<NavSection title={t("nav.sectionMain")} collapsed={isCollapsed} />
|
<NavSection title={t("nav.sectionMain")} collapsed={isCollapsed} />
|
||||||
{mainMenuItems.map((item) => (
|
{visibleMainMenuItems.map((item) => (
|
||||||
<NavLink
|
<NavLink
|
||||||
key={item.to}
|
key={item.to}
|
||||||
to={item.to}
|
to={item.to}
|
||||||
@@ -509,6 +518,7 @@ function DashboardLayout() {
|
|||||||
{/* Page Content */}
|
{/* Page Content */}
|
||||||
<main id="main-content" className="flex-1 overflow-auto bg-muted/30">
|
<main id="main-content" className="flex-1 overflow-auto bg-muted/30">
|
||||||
<TrialUrgencyBanner />
|
<TrialUrgencyBanner />
|
||||||
|
<TrialValueUpsell />
|
||||||
<div className="p-4 sm:p-6">
|
<div className="p-4 sm:p-6">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { Button } from "@sase/ui";
|
|||||||
import { Skeleton } from "@sase/ui";
|
import { Skeleton } from "@sase/ui";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Link, createFileRoute } from "@tanstack/react-router";
|
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";
|
import { useState } from "react";
|
||||||
|
|
||||||
export const Route = createFileRoute("/dashboard/billing")({
|
export const Route = createFileRoute("/dashboard/billing")({
|
||||||
@@ -26,10 +26,11 @@ interface Subscription {
|
|||||||
interface Payment {
|
interface Payment {
|
||||||
id: string;
|
id: string;
|
||||||
amount: number;
|
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";
|
status: "completed" | "pending" | "failed" | "refunded";
|
||||||
planName?: string | null;
|
planName?: string | null;
|
||||||
eftReceiptUrl?: string | null;
|
|
||||||
hasStripeReceipt?: boolean;
|
hasStripeReceipt?: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
@@ -213,7 +214,7 @@ function BillingPage() {
|
|||||||
onChange={setMethodFilter}
|
onChange={setMethodFilter}
|
||||||
options={[
|
options={[
|
||||||
{ value: "all", label: t("common.all") },
|
{ value: "all", label: t("common.all") },
|
||||||
...["stripe", "eft"].map((m) => ({
|
...["stripe"].map((m) => ({
|
||||||
value: m,
|
value: m,
|
||||||
label: t(`billing.methodLabels.${m}`),
|
label: t(`billing.methodLabels.${m}`),
|
||||||
})),
|
})),
|
||||||
@@ -309,7 +310,9 @@ function BillingPage() {
|
|||||||
</td>
|
</td>
|
||||||
<td data-label={t("billing.method")} className={`${TD} sm:text-center`}>
|
<td data-label={t("billing.method")} className={`${TD} sm:text-center`}>
|
||||||
<Badge variant={payment.method === "stripe" ? "secondary" : "outline"}>
|
<Badge variant={payment.method === "stripe" ? "secondary" : "outline"}>
|
||||||
{t(`billing.methodLabels.${payment.method}`)}
|
{payment.method === "stripe"
|
||||||
|
? t("billing.methodLabels.stripe")
|
||||||
|
: payment.method.toUpperCase()}
|
||||||
</Badge>
|
</Badge>
|
||||||
</td>
|
</td>
|
||||||
<td data-label={t("billing.status")} className={`${TD} sm:text-center`}>
|
<td data-label={t("billing.status")} className={`${TD} sm:text-center`}>
|
||||||
@@ -329,19 +332,7 @@ function BillingPage() {
|
|||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="block pt-3 text-right sm:table-cell sm:py-4 sm:pt-4">
|
<td className="block pt-3 text-right sm:table-cell sm:py-4 sm:pt-4">
|
||||||
{payment.eftReceiptUrl ? (
|
{payment.hasStripeReceipt ? (
|
||||||
<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 ? (
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
import { ChangelogTab } from "@/components/settings/changelog-tab";
|
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/dashboard/changelog")({
|
|
||||||
component: ChangelogPage,
|
|
||||||
});
|
|
||||||
|
|
||||||
function ChangelogPage() {
|
|
||||||
return (
|
|
||||||
<div className="mx-auto max-w-3xl">
|
|
||||||
<ChangelogTab />
|
|
||||||
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
405
apps/web/src/routes/dashboard/oem.$code.tsx
Normal file
405
apps/web/src/routes/dashboard/oem.$code.tsx
Normal file
@@ -0,0 +1,405 @@
|
|||||||
|
import { OemSuggestionsSection } from "@/components/catalog/oem-suggestions-section";
|
||||||
|
import { OemVoteCard } from "@/components/catalog/oem-vote-card";
|
||||||
|
import { useExpertAccess } from "@/hooks/use-expert-access";
|
||||||
|
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")({
|
||||||
|
// Katalogdan gelişte taşınan görüntü bağlamı: v = araç etiketi, p = parça
|
||||||
|
// adı (oy kartında "neyle neyin uyumlu olduğu" satırı), vid = vehicleId
|
||||||
|
// (oy kaydına analitik bağlam). Doğrudan ziyarette üçü de boş.
|
||||||
|
validateSearch: (search: Record<string, unknown>): { v?: string; p?: string; vid?: string } => ({
|
||||||
|
v: typeof search.v === "string" && search.v ? search.v : undefined,
|
||||||
|
p: typeof search.p === "string" && search.p ? search.p : undefined,
|
||||||
|
vid: typeof search.vid === "string" && search.vid ? search.vid : undefined,
|
||||||
|
}),
|
||||||
|
component: OemDetailPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
function OemDetailPage() {
|
||||||
|
const { code } = Route.useParams();
|
||||||
|
const { v: vehicleLabel, p: partName, vid: vehicleId } = Route.useSearch();
|
||||||
|
// Parça Uzmanları programı kapısı — kapalıyken oy kartı ve öneri bölümü
|
||||||
|
// hiç render edilmez (cross-ref içeriği herkese açık kalır).
|
||||||
|
const { data: expertAccess } = useExpertAccess();
|
||||||
|
const expertEnabled = expertAccess?.enabled === true;
|
||||||
|
|
||||||
|
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: solda OEM kodu, desktop'ta sağ simetriğinde topluluk
|
||||||
|
oyu kartı; mobilde kart kodun altına iner ─────────────────────── */}
|
||||||
|
<header className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||||
|
<div className="flex min-w-0 flex-1 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>
|
||||||
|
</div>
|
||||||
|
{expertEnabled && (
|
||||||
|
<OemVoteCard
|
||||||
|
oemCode={code}
|
||||||
|
vehicleLabel={vehicleLabel}
|
||||||
|
partName={partName}
|
||||||
|
vehicleId={vehicleId}
|
||||||
|
className="w-full lg:w-96 lg:shrink-0"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</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. Muadilini biliyorsanız aşağıdan
|
||||||
|
önerin — diğer parça satıcılarına yol gösterir.
|
||||||
|
</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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ─── Topluluk muadil önerileri (eşleşme olmasa da toplanır) ──────── */}
|
||||||
|
{!isLoading && expertEnabled && <OemSuggestionsSection oemCode={code} />}
|
||||||
|
|
||||||
|
{/* ─── 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 pendingVinRef = useRef<string | null>(null);
|
||||||
const didInitFromUrlRef = useRef(false);
|
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 [candidates, setCandidates] = useState<any[] | null>(null);
|
||||||
const [candidateVin, setCandidateVin] = useState("");
|
const [candidateVin, setCandidateVin] = useState("");
|
||||||
const [candidateSource, setCandidateSource] = useState<"parts-catalogs" | "emex" | null>(null);
|
|
||||||
const [selectLoading, setSelectLoading] = useState(false);
|
const [selectLoading, setSelectLoading] = useState(false);
|
||||||
|
|
||||||
// Live preview state
|
// Live preview state
|
||||||
@@ -285,12 +285,10 @@ function SearchPage() {
|
|||||||
if (data.candidates && Array.isArray(data.candidates)) {
|
if (data.candidates && Array.isArray(data.candidates)) {
|
||||||
setCandidates(data.candidates);
|
setCandidates(data.candidates);
|
||||||
setCandidateVin(cleanVin);
|
setCandidateVin(cleanVin);
|
||||||
setCandidateSource(data.source ?? "parts-catalogs");
|
|
||||||
candidatesShownAtRef.current = performance.now();
|
candidatesShownAtRef.current = performance.now();
|
||||||
capture("vin_decode_candidates", {
|
capture("vin_decode_candidates", {
|
||||||
vin: cleanVin,
|
vin: cleanVin,
|
||||||
count: data.candidates.length,
|
count: data.candidates.length,
|
||||||
source: data.source,
|
|
||||||
response_time_ms: responseTimeMs,
|
response_time_ms: responseTimeMs,
|
||||||
query_source: querySourceRef.current,
|
query_source: querySourceRef.current,
|
||||||
});
|
});
|
||||||
@@ -301,7 +299,6 @@ function SearchPage() {
|
|||||||
vin: cleanVin,
|
vin: cleanVin,
|
||||||
vehicle_id: data.id,
|
vehicle_id: data.id,
|
||||||
response_time_ms: responseTimeMs,
|
response_time_ms: responseTimeMs,
|
||||||
source: data.source ?? null,
|
|
||||||
query_source: querySourceRef.current,
|
query_source: querySourceRef.current,
|
||||||
});
|
});
|
||||||
navigate({ to: "/dashboard/vehicles/$id", params: { id: data.id } });
|
navigate({ to: "/dashboard/vehicles/$id", params: { id: data.id } });
|
||||||
@@ -461,16 +458,14 @@ function SearchPage() {
|
|||||||
? Math.round(performance.now() - candidatesShownAtRef.current)
|
? Math.round(performance.now() - candidatesShownAtRef.current)
|
||||||
: null;
|
: null;
|
||||||
try {
|
try {
|
||||||
const payload: Record<string, unknown> = { vin: candidateVin };
|
// The carId is the opaque candidate key from the decode response — the
|
||||||
if (candidateSource === "emex") {
|
// server maps it back to the provider-specific selection.
|
||||||
payload.emexCarIndex = Number.parseInt(carId, 10);
|
const data = await api.post<any>("/vehicles/decode", {
|
||||||
} else {
|
vin: candidateVin,
|
||||||
payload.pcatCarId = carId;
|
candidate: carId,
|
||||||
}
|
});
|
||||||
const data = await api.post<any>("/vehicles/decode", payload);
|
|
||||||
capture("vin_decode_candidate_selected", {
|
capture("vin_decode_candidate_selected", {
|
||||||
vin: candidateVin,
|
vin: candidateVin,
|
||||||
source: candidateSource,
|
|
||||||
carId,
|
carId,
|
||||||
vehicle_id: data.id,
|
vehicle_id: data.id,
|
||||||
selected_index: selectedIndex,
|
selected_index: selectedIndex,
|
||||||
@@ -479,7 +474,6 @@ function SearchPage() {
|
|||||||
});
|
});
|
||||||
candidatesShownAtRef.current = null;
|
candidatesShownAtRef.current = null;
|
||||||
setCandidates(null);
|
setCandidates(null);
|
||||||
setCandidateSource(null);
|
|
||||||
navigate({
|
navigate({
|
||||||
to: "/dashboard/vehicles/$id",
|
to: "/dashboard/vehicles/$id",
|
||||||
params: { id: data.id },
|
params: { id: data.id },
|
||||||
@@ -488,7 +482,6 @@ function SearchPage() {
|
|||||||
const message = err instanceof ApiError ? err.message : t("search.errorGeneric");
|
const message = err instanceof ApiError ? err.message : t("search.errorGeneric");
|
||||||
setError(message);
|
setError(message);
|
||||||
setCandidates(null);
|
setCandidates(null);
|
||||||
setCandidateSource(null);
|
|
||||||
toast.error(t("search.candidateSelectFailed"));
|
toast.error(t("search.candidateSelectFailed"));
|
||||||
} finally {
|
} finally {
|
||||||
setSelectLoading(false);
|
setSelectLoading(false);
|
||||||
@@ -798,8 +791,6 @@ function SearchPage() {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-center text-xs text-muted-foreground">{t("search.openHint")}</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user