Merge dev into main: catalog-issue report + pcat skip/transient timings
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Resolves the recurring promote-by-cherry-pick divergence (decode/pcat/emex live on both branches with different SHAs — all auto-merged). Net-new to main: - fix(decode)fd78957: mark pcat skip/transient in timings (stop false catalog-gap reads) - feat(catalog)c972a17: one-click report on empty/error catalog states - compose: P_DB_ENABLED/P_DB_URL env (inert until set in Coolify) Conflict: docker-compose.coolify.yml — both sides added env at the same spot; took dev's superset (main's Directus/Blog vars + dev's P_DB vars). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> # Conflicts: # docker-compose.coolify.yml
This commit is contained in:
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;
|
||||
@@ -92,6 +92,13 @@
|
||||
"when": 1780581755559,
|
||||
"tag": "0012_lifecycle_email_sent",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 13,
|
||||
"version": "7",
|
||||
"when": 1780600000000,
|
||||
"tag": "0013_fix_brand_casing",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import { DemoModule } from "./demo/demo.module";
|
||||
import { EmailModule } from "./email/email.module";
|
||||
import { HealthController } from "./health.controller";
|
||||
import { EmexModule } from "./integrations/emex/emex.module";
|
||||
import { PModule } from "./integrations/p/p.module";
|
||||
import { InternalAdminModule } from "./internal-admin/internal-admin.module";
|
||||
import { JobsModule } from "./jobs/jobs.module";
|
||||
import { MetaCapiModule } from "./meta-capi/meta-capi.module";
|
||||
@@ -86,6 +87,7 @@ import { VehiclesModule } from "./vehicles/vehicles.module";
|
||||
CategoriesModule,
|
||||
DemoModule,
|
||||
PartsModule,
|
||||
PModule,
|
||||
JobsModule,
|
||||
EmexModule,
|
||||
TranslationsModule,
|
||||
|
||||
@@ -88,6 +88,14 @@ export default () => ({
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean),
|
||||
},
|
||||
p: {
|
||||
// Read-only lookup against the imported P snapshot (db `td`). When
|
||||
// enabled + url set, the OEM detail page resolves a part's OEM code to
|
||||
// P aftermarket equivalents + OE cross-references. Disabled → endpoint
|
||||
// returns { matched: false } and the UI shows an empty state.
|
||||
enabled: process.env.P_DB_ENABLED === "true",
|
||||
url: process.env.P_DB_URL,
|
||||
},
|
||||
otel: {
|
||||
enabled: process.env.OTEL_ENABLED === "true",
|
||||
endpoint: process.env.OTEL_EXPORTER_OTLP_ENDPOINT,
|
||||
|
||||
@@ -184,7 +184,9 @@ export const userBrands = pgTable(
|
||||
],
|
||||
);
|
||||
|
||||
// ─── Bank Accounts (EFT/Havale destination accounts, one active at a time) ────
|
||||
// ─── Bank Accounts ───────────────────────────────────
|
||||
// @deprecated EFT/Havale was retired (Stripe is the sole payment method). Table
|
||||
// kept only to preserve historical FK integrity; no rows are created anymore.
|
||||
export const bankAccounts = pgTable(
|
||||
"bank_accounts",
|
||||
{
|
||||
@@ -225,7 +227,9 @@ export const payments = pgTable(
|
||||
iyzicoPaymentId: text("iyzico_payment_id"),
|
||||
stripeSessionId: text("stripe_session_id"),
|
||||
stripePaymentIntentId: text("stripe_payment_intent_id"),
|
||||
/** @deprecated EFT/Havale retired (Stripe-only). Kept for historical data. */
|
||||
bankAccountId: uuid("bank_account_id").references(() => bankAccounts.id),
|
||||
/** @deprecated EFT/Havale retired (Stripe-only). Kept for historical data. */
|
||||
eftReceiptUrl: text("eft_receipt_url"),
|
||||
adminNote: text("admin_note"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
|
||||
@@ -348,6 +348,7 @@ export const CATALOG_MAP: Record<string, CatalogEntry> = {
|
||||
// Opel
|
||||
W0L: { code: "GM_OP201809", brand: "Opel" },
|
||||
W0V: { code: "GM_OP201809", brand: "Opel" }, // newer Stellantis-era WMI
|
||||
VXK: { code: "GM_OP201809", brand: "Opel" }, // PSA/Stellantis-platform Opel (Corsa F, Mokka B)
|
||||
// Chevrolet
|
||||
KL1: { code: "GM_C201809", brand: "Chevrolet" },
|
||||
// SsangYong
|
||||
|
||||
256
apps/api/src/integrations/p/p-source-db.service.ts
Normal file
256
apps/api/src/integrations/p/p-source-db.service.ts
Normal file
@@ -0,0 +1,256 @@
|
||||
import { Injectable, Logger, type OnModuleDestroy, type OnModuleInit } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import postgres, { type Sql } from "postgres";
|
||||
import type { PArticle, PCompatible, POeNumber, POemResult } from "./p.types";
|
||||
|
||||
/**
|
||||
* Read-only lookup against the imported P snapshot (db `td` — a selective
|
||||
* copy of articles + OE numbers + aftermarket compatibility + images/eans; the
|
||||
* 29 GB vehicle-fitment table is intentionally excluded). Given an OEM code from
|
||||
* the sase catalog, returns the P articles that carry it as an OE number,
|
||||
* with their aftermarket equivalents and OE cross-references.
|
||||
*
|
||||
* Matching is normalisation-based, not exact: P stores OE codes with
|
||||
* spaces/dashes (`1J0 973 702`) while the catalog gives `1J0973702`, so both
|
||||
* sides are reduced to `[A-Z0-9]` uppercase before comparison (a precomputed
|
||||
* `code_norm` column, indexed, holds the P side). Exact matching recovers
|
||||
* almost nothing — verified ~1/10 vs normalised ~5/10 on real catalog codes.
|
||||
*
|
||||
* Never throws: disabled feature, too-short code, connection blip or no match
|
||||
* all collapse to `matched: false` so the UI has a single empty-state path.
|
||||
*/
|
||||
@Injectable()
|
||||
export class PSourceDbService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(PSourceDbService.name);
|
||||
private sql: Sql | null = null;
|
||||
private enabled = false;
|
||||
|
||||
// Short normalised codes (e.g. "NA" from "N/A", single digits) collide across
|
||||
// unrelated parts — refuse to match below this length. Real OE numbers are 5+.
|
||||
private static readonly MIN_NORM_LEN = 5;
|
||||
private static readonly MAX_ARTICLES = 60;
|
||||
private static readonly MAX_AGG = 300;
|
||||
|
||||
constructor(private readonly config: ConfigService) {}
|
||||
|
||||
onModuleInit() {
|
||||
const enabled = this.config.get<boolean>("p.enabled");
|
||||
const url = this.config.get<string>("p.url");
|
||||
if (!enabled || !url) {
|
||||
this.logger.log(`[p] disabled (enabled=${enabled}, urlSet=${Boolean(url)})`);
|
||||
return;
|
||||
}
|
||||
this.sql = postgres(url, {
|
||||
max: 5,
|
||||
idle_timeout: 30,
|
||||
connect_timeout: 10,
|
||||
prepare: false,
|
||||
});
|
||||
this.enabled = true;
|
||||
this.logger.log("[p] connected, OEM cross-reference lookup enabled");
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
if (this.sql) {
|
||||
await this.sql.end({ timeout: 5 });
|
||||
this.sql = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** `1J0 973 702` / `1j0-973-702` → `1J0973702`. Used for both the input code
|
||||
* and JS-side dedupe; the P side is matched against the stored
|
||||
* `code_norm` (built with the identical rule at import time). */
|
||||
private static norm(code: string): string {
|
||||
return code.toUpperCase().replace(/[^A-Z0-9]/g, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch membership test for the schema page: given the OEM codes in a parts
|
||||
* list, return the subset that has at least one OE cross-reference in the
|
||||
* snapshot. The panel links only these (and renders the rest as plain text),
|
||||
* so a click never lands on an empty "no equivalents" detail page. One indexed
|
||||
* query (`code_norm` btree). Fail-open to [] (→ no links) when disabled / on
|
||||
* error, echoing back the caller's original spelling for the matched codes.
|
||||
*/
|
||||
async matchedCodes(rawCodes: string[]): Promise<string[]> {
|
||||
if (!this.enabled || !this.sql || !rawCodes?.length) return [];
|
||||
const rawByNorm = new Map<string, string>();
|
||||
for (const raw of rawCodes) {
|
||||
const n = PSourceDbService.norm(raw ?? "");
|
||||
if (n.length >= PSourceDbService.MIN_NORM_LEN && !rawByNorm.has(n)) rawByNorm.set(n, raw);
|
||||
}
|
||||
if (rawByNorm.size === 0) return [];
|
||||
try {
|
||||
const norms = [...rawByNorm.keys()];
|
||||
const rows = await this.sql<Array<{ code_norm: string }>>`
|
||||
SELECT DISTINCT code_norm FROM article_oe_numbers
|
||||
WHERE code_norm IN ${this.sql(norms)}
|
||||
`;
|
||||
return rows
|
||||
.map((r) => rawByNorm.get(r.code_norm))
|
||||
.filter((c): c is string => c !== undefined);
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`[p] matchedCodes failed (${rawByNorm.size} codes): ${(err as Error).message}`,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async lookupByOem(rawCode: string): Promise<POemResult | null> {
|
||||
const query = (rawCode ?? "").trim();
|
||||
const queryNorm = PSourceDbService.norm(query);
|
||||
const miss: POemResult = {
|
||||
query,
|
||||
queryNorm,
|
||||
matched: false,
|
||||
articles: [],
|
||||
aftermarketParts: [],
|
||||
oeCrossReferences: [],
|
||||
truncated: false,
|
||||
};
|
||||
|
||||
if (!this.enabled || !this.sql) return miss;
|
||||
if (queryNorm.length < PSourceDbService.MIN_NORM_LEN) return miss;
|
||||
|
||||
try {
|
||||
const rows = await this.sql<
|
||||
Array<{
|
||||
id: string;
|
||||
brand: string;
|
||||
article_number: string;
|
||||
name: string | null;
|
||||
spare_info: string | null;
|
||||
oe_numbers: POeNumber[];
|
||||
compatible: PCompatible[];
|
||||
images: Array<{ url: string; thumb: string | null }>;
|
||||
}>
|
||||
>`
|
||||
WITH hit AS (
|
||||
SELECT DISTINCT article_id
|
||||
FROM article_oe_numbers
|
||||
WHERE code_norm = ${queryNorm}
|
||||
LIMIT ${PSourceDbService.MAX_ARTICLES}
|
||||
)
|
||||
SELECT
|
||||
a.id::text AS id,
|
||||
b.name AS brand,
|
||||
a.article_number AS article_number,
|
||||
a.name AS name,
|
||||
a.spare_info AS spare_info,
|
||||
COALESCE((
|
||||
SELECT json_agg(json_build_object('brand', o.brand, 'code', o.code))
|
||||
FROM (
|
||||
SELECT DISTINCT brand, code FROM article_oe_numbers
|
||||
WHERE article_id = a.id ORDER BY brand LIMIT 200
|
||||
) o
|
||||
), '[]') AS oe_numbers,
|
||||
COALESCE((
|
||||
SELECT json_agg(json_build_object('brand', c.compatible_brand, 'article', c.compatible_article))
|
||||
FROM (
|
||||
SELECT DISTINCT compatible_brand, compatible_article FROM article_compatibility
|
||||
WHERE article_id = a.id ORDER BY compatible_brand LIMIT 200
|
||||
) c
|
||||
), '[]') AS compatible,
|
||||
COALESCE((
|
||||
SELECT json_agg(json_build_object('url', i.image_url, 'thumb', i.thumb_url) ORDER BY i.sort_order)
|
||||
FROM (
|
||||
-- Only publicly-resolvable URLs. The current snapshot stores
|
||||
-- scrape-local '/_debug/...' paths (404 off-host) — filtering them
|
||||
-- here keeps the API contract honest so the UI shows no broken
|
||||
-- thumbnails; real CDN URLs surface automatically once present.
|
||||
SELECT image_url, thumb_url, sort_order FROM article_images
|
||||
WHERE article_id = a.id AND image_url LIKE 'http%'
|
||||
ORDER BY sort_order LIMIT 8
|
||||
) i
|
||||
), '[]') AS images
|
||||
FROM hit
|
||||
JOIN articles a ON a.id = hit.article_id
|
||||
JOIN article_brands b ON b.id = a.brand_id
|
||||
ORDER BY b.name, a.article_number
|
||||
`;
|
||||
|
||||
if (rows.length === 0) return miss;
|
||||
|
||||
let truncated = rows.length >= PSourceDbService.MAX_ARTICLES;
|
||||
|
||||
const articles: PArticle[] = rows.map((r) => {
|
||||
if (r.oe_numbers.length >= 200 || r.compatible.length >= 200) truncated = true;
|
||||
return {
|
||||
id: r.id,
|
||||
brand: r.brand,
|
||||
articleNumber: r.article_number,
|
||||
name: r.name,
|
||||
spareInfo: r.spare_info,
|
||||
images: r.images,
|
||||
oeNumbers: r.oe_numbers,
|
||||
compatible: r.compatible,
|
||||
};
|
||||
});
|
||||
|
||||
// ── Aggregate: buyable aftermarket part numbers ──────────────────────
|
||||
// The matched articles are themselves aftermarket parts; their
|
||||
// compatibility rows add equivalent numbers from other supplier brands.
|
||||
const afterSeen = new Set<string>();
|
||||
const aftermarketParts: POemResult["aftermarketParts"] = [];
|
||||
const pushAfter = (brand: string, articleNumber: string, thumb: string | null) => {
|
||||
const key = `${brand.toUpperCase().trim()}␟${PSourceDbService.norm(articleNumber)}`;
|
||||
if (afterSeen.has(key) || !articleNumber.trim()) return;
|
||||
afterSeen.add(key);
|
||||
if (aftermarketParts.length < PSourceDbService.MAX_AGG) {
|
||||
aftermarketParts.push({ brand, articleNumber, thumb });
|
||||
} else {
|
||||
truncated = true;
|
||||
}
|
||||
};
|
||||
for (const a of articles) {
|
||||
pushAfter(a.brand, a.articleNumber, a.images[0]?.thumb ?? a.images[0]?.url ?? null);
|
||||
}
|
||||
for (const a of articles) {
|
||||
for (const c of a.compatible) pushAfter(c.brand, c.article, null);
|
||||
}
|
||||
|
||||
// ── Aggregate: OE cross-references (same part, other makes) ───────────
|
||||
// Exclude restatements of the queried code itself (same normalised code).
|
||||
const oeSeen = new Set<string>();
|
||||
const oeCrossReferences: POeNumber[] = [];
|
||||
for (const a of articles) {
|
||||
for (const oe of a.oeNumbers) {
|
||||
const codeNorm = PSourceDbService.norm(oe.code);
|
||||
if (codeNorm === queryNorm) continue;
|
||||
const key = `${oe.brand.toUpperCase().trim()}␟${codeNorm}`;
|
||||
if (oeSeen.has(key)) continue;
|
||||
oeSeen.add(key);
|
||||
if (oeCrossReferences.length < PSourceDbService.MAX_AGG) {
|
||||
oeCrossReferences.push(oe);
|
||||
} else {
|
||||
truncated = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
query,
|
||||
queryNorm,
|
||||
matched: true,
|
||||
// Ship lean articles — the per-article oeNumbers/compatible lists were
|
||||
// only needed above to build the aggregates; sending them too bloated
|
||||
// heavy responses ~30x (370 KB → ~12 KB for a 60-article code).
|
||||
articles: articles.map((a) => ({
|
||||
id: a.id,
|
||||
brand: a.brand,
|
||||
articleNumber: a.articleNumber,
|
||||
name: a.name,
|
||||
spareInfo: a.spareInfo,
|
||||
images: a.images,
|
||||
})),
|
||||
aftermarketParts,
|
||||
oeCrossReferences,
|
||||
truncated,
|
||||
};
|
||||
} catch (err) {
|
||||
this.logger.warn(`[p] lookup failed (oem=${query}): ${(err as Error).message}`);
|
||||
return miss;
|
||||
}
|
||||
}
|
||||
}
|
||||
28
apps/api/src/integrations/p/p.controller.ts
Normal file
28
apps/api/src/integrations/p/p.controller.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Body, Controller, Get, Post, Query } from "@nestjs/common";
|
||||
import { PSourceDbService } from "./p-source-db.service";
|
||||
|
||||
@Controller("p")
|
||||
export class PController {
|
||||
constructor(private readonly p: PSourceDbService) {}
|
||||
|
||||
/**
|
||||
* Resolve an OEM code from the catalog to its P equivalents.
|
||||
* `GET /p/oem?code=1J0973702` → { matched, articles, aftermarketParts,
|
||||
* oeCrossReferences }. Always 200 with `matched: false` on any miss.
|
||||
*/
|
||||
@Get("oem")
|
||||
async oem(@Query("code") code: string) {
|
||||
return this.p.lookupByOem(code ?? "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch: which of these OEM codes have a P cross-reference. The schema page
|
||||
* sends the codes it's about to render and links only the returned ones, so
|
||||
* unmatched codes stay plain text and never open an empty detail page.
|
||||
* `POST /p/matched { codes: [...] }` → { matched: [...] }.
|
||||
*/
|
||||
@Post("matched")
|
||||
async matched(@Body("codes") codes: string[]) {
|
||||
return { matched: await this.p.matchedCodes(Array.isArray(codes) ? codes : []) };
|
||||
}
|
||||
}
|
||||
15
apps/api/src/integrations/p/p.module.ts
Normal file
15
apps/api/src/integrations/p/p.module.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { PSourceDbService } from "./p-source-db.service";
|
||||
import { PController } from "./p.controller";
|
||||
|
||||
/**
|
||||
* OEM cross-reference lookup against the imported P snapshot (db `td`).
|
||||
* Raw read-only queries — intentionally no Drizzle schema modelling, mirroring
|
||||
* CatalogSourceDbModule. Self-disables when P_DB_* env is unset.
|
||||
*/
|
||||
@Module({
|
||||
controllers: [PController],
|
||||
providers: [PSourceDbService],
|
||||
exports: [PSourceDbService],
|
||||
})
|
||||
export class PModule {}
|
||||
57
apps/api/src/integrations/p/p.types.ts
Normal file
57
apps/api/src/integrations/p/p.types.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
/** An OE (original-equipment) number cross-reference: the same physical part as
|
||||
* catalogued by a vehicle manufacturer (e.g. VAG `1J0 973 702`). */
|
||||
export interface POeNumber {
|
||||
brand: string;
|
||||
code: string;
|
||||
}
|
||||
|
||||
/** An aftermarket equivalent: a buyable part number from a supplier brand
|
||||
* (e.g. FEBI BILSTEIN `171903`). */
|
||||
export interface PCompatible {
|
||||
brand: string;
|
||||
article: string;
|
||||
}
|
||||
|
||||
export interface PImage {
|
||||
url: string;
|
||||
thumb: string | null;
|
||||
}
|
||||
|
||||
/** One P article whose OE number list contains the queried OEM code. Internal
|
||||
* shape — `oeNumbers`/`compatible` feed the aggregates and are NOT shipped to
|
||||
* the client (they're large and unused there; see PArticleSummary). */
|
||||
export interface PArticle {
|
||||
id: string;
|
||||
brand: string;
|
||||
articleNumber: string;
|
||||
name: string | null;
|
||||
spareInfo: string | null;
|
||||
images: PImage[];
|
||||
oeNumbers: POeNumber[];
|
||||
compatible: PCompatible[];
|
||||
}
|
||||
|
||||
/** What the OEM detail page actually renders per matched article — without the
|
||||
* heavy per-article OE/compat lists (those are deduped into the top-level
|
||||
* aggregates). Trimming them cut a 60-article payload from ~370 KB to ~12 KB. */
|
||||
export type PArticleSummary = Omit<PArticle, "oeNumbers" | "compatible">;
|
||||
|
||||
/** Response of the OEM detail lookup. `matched: false` covers every miss —
|
||||
* feature disabled, code too short, or no P article carries that OE
|
||||
* number — so the UI has a single empty-state path. */
|
||||
export interface POemResult {
|
||||
query: string;
|
||||
queryNorm: string;
|
||||
matched: boolean;
|
||||
/** Distinct articles whose OE list contains the queried code (lean — per-article
|
||||
* OE/compat lists are aggregated below, not duplicated here). */
|
||||
articles: PArticleSummary[];
|
||||
/** Deduped buyable aftermarket part numbers across all matched articles
|
||||
* (the matched articles themselves + their compatibility entries). */
|
||||
aftermarketParts: Array<{ brand: string; articleNumber: string; thumb: string | null }>;
|
||||
/** Deduped OE cross-references across all matched articles, excluding the
|
||||
* queried code itself — i.e. the same part's numbers under other makes. */
|
||||
oeCrossReferences: POeNumber[];
|
||||
/** True when any per-article list or the article set hit its cap. */
|
||||
truncated: boolean;
|
||||
}
|
||||
@@ -565,6 +565,7 @@ export const PL24_WMI_SERVICE_MAP: Record<string, string> = {
|
||||
// Opel / Vauxhall
|
||||
W0L: "opel_parts", // Opel AG (Germany)
|
||||
W0V: "opel_parts", // Opel (newer Stellantis-era WMI)
|
||||
VXK: "opel_parts", // PSA/Stellantis-platform Opel (Corsa F, Mokka B — France/Spain plants)
|
||||
|
||||
// Citroën (PSA)
|
||||
VF7: "citroen_parts", // Citroën SA (France)
|
||||
|
||||
@@ -15,6 +15,13 @@ export class PartsController {
|
||||
return this.partsService.searchByOem(oem);
|
||||
}
|
||||
|
||||
// Reverse catalog for the OEM detail page: which of the user's vehicles use
|
||||
// this OEM code. Declared before ":id" so the literal path wins the match.
|
||||
@Get("oem-vehicles")
|
||||
async vehiclesByOem(@Query("code") code: string) {
|
||||
return this.partsService.vehiclesByOem(code ?? "");
|
||||
}
|
||||
|
||||
@Get(":id")
|
||||
async getById(@Param("id") id: string) {
|
||||
return this.partsService.getById(id);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Inject, Injectable, Logger, NotFoundException } from "@nestjs/common";
|
||||
import { eq, like } from "drizzle-orm";
|
||||
import { eq, like, sql } from "drizzle-orm";
|
||||
import { DATABASE, type Database } from "../database/database.provider";
|
||||
import { categories, parts, schemaPics, vehicles } from "../database/schema/core";
|
||||
import { PL24Service } from "../integrations/pl24/pl24.service";
|
||||
@@ -142,4 +142,32 @@ export class PartsService {
|
||||
if (!part) throw new NotFoundException("Parça bulunamadı");
|
||||
return part;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse catalog: the user's own decoded vehicles whose parts list contains
|
||||
* this exact OEM code. Powers the "bu kod kataloğunuzda şu araçlarda var"
|
||||
* section of the OEM detail page — pure sase data, no TecDoc/vehicle-structure
|
||||
* dependency. Exact match on the indexed `oem_code` (the code came from a real
|
||||
* part row, so the spelling matches). One representative `categoryId` per
|
||||
* vehicle lets the UI deep-link straight to a schema page showing the part.
|
||||
*/
|
||||
async vehiclesByOem(oemCode: string) {
|
||||
const code = (oemCode ?? "").trim();
|
||||
if (!code) return [];
|
||||
return this.db
|
||||
.select({
|
||||
vehicleId: vehicles.id,
|
||||
brandName: vehicles.brandName,
|
||||
model: vehicles.model,
|
||||
year: vehicles.year,
|
||||
categoryId: sql<string>`(array_agg(${parts.categoryId} ORDER BY ${parts.categoryId}))[1]`,
|
||||
occurrences: sql<number>`count(*)::int`,
|
||||
})
|
||||
.from(parts)
|
||||
.innerJoin(vehicles, eq(parts.vehicleId, vehicles.id))
|
||||
.where(eq(parts.oemCode, code))
|
||||
.groupBy(vehicles.id, vehicles.brandName, vehicles.model, vehicles.year)
|
||||
.orderBy(vehicles.brandName, vehicles.model)
|
||||
.limit(50);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,9 +16,9 @@ export class PaymentsService {
|
||||
/**
|
||||
* Payment history for the billing page. Returns a curated projection — never
|
||||
* the raw row — so internal columns (adminNote, iyzicoPaymentId,
|
||||
* bankAccountId, session/intent ids) are not leaked to the client. planName
|
||||
* is joined from the subscription's plan; Stripe receipt availability is
|
||||
* surfaced as a boolean rather than exposing the payment intent id.
|
||||
* session/intent ids) are not leaked to the client. planName is joined from
|
||||
* the subscription's plan; Stripe receipt availability is surfaced as a
|
||||
* boolean rather than exposing the payment intent id.
|
||||
*/
|
||||
async getMyPayments(userId: string) {
|
||||
const rows = await this.db
|
||||
@@ -29,7 +29,6 @@ export class PaymentsService {
|
||||
status: payments.status,
|
||||
createdAt: payments.createdAt,
|
||||
planName: plans.name,
|
||||
eftReceiptUrl: payments.eftReceiptUrl,
|
||||
stripePaymentIntentId: payments.stripePaymentIntentId,
|
||||
})
|
||||
.from(payments)
|
||||
@@ -45,16 +44,15 @@ export class PaymentsService {
|
||||
status: row.status,
|
||||
createdAt: row.createdAt,
|
||||
planName: row.planName ?? null,
|
||||
eftReceiptUrl: row.eftReceiptUrl ?? null,
|
||||
hasStripeReceipt:
|
||||
row.method === "stripe" && row.status === "completed" && !!row.stripePaymentIntentId,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a receipt URL for one of the current user's payments.
|
||||
* EFT receipts are stored locally; Stripe receipts are fetched live from the
|
||||
* payment intent's latest charge. Returns { url: null } when none exists.
|
||||
* Resolve a receipt URL for one of the current user's payments. Stripe is the
|
||||
* sole payment method; receipts are fetched live from the payment intent's
|
||||
* latest charge. Returns { url: null } when none exists.
|
||||
*/
|
||||
async getReceiptUrl(userId: string, paymentId: string): Promise<{ url: string | null }> {
|
||||
const [payment] = await this.db
|
||||
@@ -67,10 +65,6 @@ export class PaymentsService {
|
||||
throw new NotFoundException("Payment not found");
|
||||
}
|
||||
|
||||
if (payment.eftReceiptUrl) {
|
||||
return { url: payment.eftReceiptUrl };
|
||||
}
|
||||
|
||||
if (payment.method === "stripe" && payment.stripePaymentIntentId) {
|
||||
return { url: await this.stripeService.getReceiptUrl(payment.stripePaymentIntentId) };
|
||||
}
|
||||
|
||||
@@ -143,6 +143,10 @@ export class StripeService {
|
||||
|
||||
const session = await this.stripe.checkout.sessions.create({
|
||||
mode: "payment",
|
||||
// Render Stripe's hosted page in Turkish. The audience is Turkish B2B; a
|
||||
// foreign-language checkout is a known abandonment driver (~60% of sessions
|
||||
// reached the page but never started a payment intent).
|
||||
locale: "tr",
|
||||
payment_method_types: ["card"],
|
||||
customer_email: userEmail,
|
||||
line_items: [
|
||||
@@ -257,6 +261,14 @@ export class StripeService {
|
||||
const paymentIntentId =
|
||||
typeof session.payment_intent === "string" ? session.payment_intent : null;
|
||||
|
||||
// Activate FIRST, then mark the payment completed. If activation throws, the
|
||||
// payment stays 'pending', so a Stripe webhook retry re-runs this handler
|
||||
// cleanly instead of hitting the completed-guard above and skipping
|
||||
// activation forever — which would leave a paid-but-never-activated
|
||||
// subscription. activateSubscription is idempotent, so a retry after a
|
||||
// successful activate but failed mark-completed won't double-count revenue.
|
||||
const activated = await this.subscriptionsService.activateSubscription(payment.subscriptionId);
|
||||
|
||||
await this.db
|
||||
.update(payments)
|
||||
.set({
|
||||
@@ -266,8 +278,6 @@ export class StripeService {
|
||||
})
|
||||
.where(eq(payments.id, paymentId));
|
||||
|
||||
const activated = await this.subscriptionsService.activateSubscription(payment.subscriptionId);
|
||||
|
||||
this.posthog.captureForUser(payment.userId, "payment_success", {
|
||||
method: "stripe",
|
||||
payment_id: paymentId,
|
||||
|
||||
@@ -288,4 +288,43 @@ describe("SubscriptionsService", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("activateSubscription", () => {
|
||||
it("is idempotent: skips re-activation when already active (no revenue double-count)", async () => {
|
||||
// A Stripe webhook retry must not re-fire revenue events, re-consume
|
||||
// referral credit, or re-insert brands on an already-active subscription.
|
||||
const activeSub = {
|
||||
id: "sub-1",
|
||||
userId: "user-1",
|
||||
planId: "plan-1",
|
||||
status: "active",
|
||||
billingPeriod: "monthly",
|
||||
endDate: new Date(),
|
||||
};
|
||||
const db = {
|
||||
select: vi.fn().mockReturnValue({
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
limit: vi.fn().mockReturnValue([activeSub]),
|
||||
}),
|
||||
update: vi.fn(),
|
||||
insert: vi.fn(),
|
||||
};
|
||||
const posthog = {
|
||||
captureForUser: vi.fn(),
|
||||
capture: vi.fn(),
|
||||
flush: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const metaCapi = { sendPurchase: vi.fn().mockResolvedValue(undefined) };
|
||||
const service = new SubscriptionsService(db as any, posthog as any, metaCapi as any);
|
||||
|
||||
const result = await service.activateSubscription("sub-1");
|
||||
|
||||
expect(result).toBe(activeSub);
|
||||
expect(db.update).not.toHaveBeenCalled();
|
||||
expect(db.insert).not.toHaveBeenCalled();
|
||||
expect(posthog.captureForUser).not.toHaveBeenCalled();
|
||||
expect(metaCapi.sendPurchase).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
ConflictException,
|
||||
Inject,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { and, desc, eq, inArray, or } from "drizzle-orm";
|
||||
@@ -20,6 +21,8 @@ import { PostHogService } from "../posthog/posthog.service";
|
||||
|
||||
@Injectable()
|
||||
export class SubscriptionsService {
|
||||
private readonly logger = new Logger(SubscriptionsService.name);
|
||||
|
||||
constructor(
|
||||
@Inject(DATABASE) private db: Database,
|
||||
private posthog: PostHogService,
|
||||
@@ -123,6 +126,17 @@ export class SubscriptionsService {
|
||||
|
||||
if (!sub) throw new NotFoundException("Abonelik bulunamadı");
|
||||
|
||||
// Idempotency guard. A Stripe webhook retry (or any double-delivery) must
|
||||
// not re-activate an already-active subscription: that would reset the
|
||||
// period from now, re-consume referral credit, double-insert Full-plan
|
||||
// brands, and double-count revenue (PostHog subscription_activated + Meta
|
||||
// CAPI Purchase). Legitimate first activations always run on a 'pending'
|
||||
// sub; admin manual activation pre-rejects 'active' before reaching here.
|
||||
if (sub.status === "active") {
|
||||
this.logger.warn(`activateSubscription: ${subscriptionId} already active — skipping`);
|
||||
return sub;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const endDate = new Date(now);
|
||||
if (sub.billingPeriod === "yearly") {
|
||||
|
||||
@@ -232,16 +232,22 @@ export class VehiclesService {
|
||||
|
||||
// 3. Brand access check
|
||||
let brandId: string | null = null;
|
||||
const brandName = resolved.brandName;
|
||||
// Match the brands table case-insensitively and adopt the CANONICAL name:
|
||||
// decode sources emit the brand with inconsistent casing ("FORD" vs "Ford"),
|
||||
// and a case-sensitive lookup left brand_id NULL + stored the raw uppercase
|
||||
// string, splitting one brand across casing variants. See migration
|
||||
// 0013_fix_brand_casing for the backfill of pre-existing rows.
|
||||
let brandName = resolved.brandName;
|
||||
if (brandName) {
|
||||
const [brand] = await this.db
|
||||
.select()
|
||||
.from(brands)
|
||||
.where(eq(brands.name, brandName))
|
||||
.where(sql`lower(${brands.name}) = lower(${brandName})`)
|
||||
.limit(1);
|
||||
|
||||
if (brand) {
|
||||
brandId = brand.id;
|
||||
brandName = brand.name;
|
||||
await this.checkBrandAccess(userId, brandId);
|
||||
}
|
||||
}
|
||||
@@ -773,8 +779,21 @@ export class VehiclesService {
|
||||
// Propagate pcat's transient signal to the shared outcome (neg-cache gate).
|
||||
if (pcatOutcome.transient && outcome) outcome.transient = true;
|
||||
pcatResolved = true;
|
||||
if (ctx && ctx.timings.pcat === undefined) {
|
||||
ctx.timings.pcat = Date.now() - pcatStart;
|
||||
if (ctx) {
|
||||
if (ctx.timings.pcat === undefined) {
|
||||
ctx.timings.pcat = Date.now() - pcatStart;
|
||||
}
|
||||
// pcat never definitively evaluated this VIN. Without these markers a
|
||||
// "No catalog — identified as X" produced while pcat was skipped/flaky
|
||||
// is indistinguishable from a true coverage gap, and the panel's
|
||||
// catalog-gap detector raises false brand-gap insights (seen
|
||||
// 2026-06-11: Audi flagged from cold-pool 1-2ms skips).
|
||||
if (pcatOutcome.coldSkip) {
|
||||
ctx.timings.pcat_skipped = 1;
|
||||
} else if (pcatOutcome.transient && !pcatCircuitOpen) {
|
||||
// Circuit-open skips already carry pcat_circuit_open.
|
||||
ctx.timings.pcat_transient = 1;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -16,7 +16,8 @@ describe("vinResolveCacheKeys (Q4)", () => {
|
||||
it("keeps the in-flight lock key version-independent (cross-deploy dedupe)", () => {
|
||||
const { lockKey } = vinResolveCacheKeys(VIN);
|
||||
expect(lockKey).toBe(`vin:lock:${VIN}`);
|
||||
expect(lockKey).not.toContain(DECODE_CHAIN_VERSION);
|
||||
// The VIN itself may contain the version digit — check the segment, not the char.
|
||||
expect(lockKey).not.toContain(`:${DECODE_CHAIN_VERSION}:`);
|
||||
});
|
||||
|
||||
it("bumping the version changes the namespace (old entries become unreachable)", () => {
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
* Both the decode path (VehiclesService) and the admin cache-buster
|
||||
* (InternalVehiclesService) MUST build keys through here so they stay in sync.
|
||||
*/
|
||||
export const DECODE_CHAIN_VERSION = "2";
|
||||
export const DECODE_CHAIN_VERSION = "3";
|
||||
|
||||
export function vinResolveCacheKeys(vin: string): {
|
||||
cacheKey: string;
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ReportCatalogIssueButton } from "@/components/catalog/report-catalog-issue";
|
||||
import type { Part } from "@/hooks/use-parts";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { capture } from "@/lib/posthog";
|
||||
@@ -36,6 +37,34 @@ export function PartsPanel({
|
||||
const viewedKeyRef = useRef<string | null>(null);
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||
const [resolvingCode, setResolvingCode] = useState<string | null>(null);
|
||||
// OEM codes that resolve to a non-empty cross-reference page. Only these are
|
||||
// rendered as links — unmatched codes stay plain text so a click never lands
|
||||
// on an empty "no equivalents" page. One batch lookup per parts list.
|
||||
const [matchedOemCodes, setMatchedOemCodes] = useState<Set<string>>(new Set());
|
||||
|
||||
const oemCodes = useMemo(
|
||||
() => [...new Set(parts.map((p) => p.oemCode).filter((c) => c && c !== "N/A"))],
|
||||
[parts],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (oemCodes.length === 0) {
|
||||
setMatchedOemCodes(new Set());
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
api
|
||||
.post<{ matched: string[] }>("/p/matched", { codes: oemCodes })
|
||||
.then((res) => {
|
||||
if (!cancelled) setMatchedOemCodes(new Set(res?.matched ?? []));
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setMatchedOemCodes(new Set());
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [oemCodes]);
|
||||
|
||||
// PL24 "bk. tablo:NNN-NNN" cross-reference jump. Resolved at load → open the
|
||||
// target illustration directly. Unresolved (target branch not seeded yet) →
|
||||
@@ -204,23 +233,30 @@ export function PartsPanel({
|
||||
{parts.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center gap-4 py-12 text-center text-sm text-muted-foreground">
|
||||
<p>Bu kategori için parça bulunamadı.</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
// Demand/abandonment signal from an empty "0 parça" panel — lets
|
||||
// us prioritise which catalogs to backfill, with readable context.
|
||||
capture("empty_catalog_cta_clicked", {
|
||||
vehicle_id: vehicleId,
|
||||
category_id: categoryId,
|
||||
vehicle_label: vehicleLabel,
|
||||
category_name: categoryName,
|
||||
});
|
||||
window.history.back();
|
||||
}}
|
||||
>
|
||||
Geri dön
|
||||
</Button>
|
||||
<div className="flex flex-wrap items-center justify-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
// Demand/abandonment signal from an empty "0 parça" panel — lets
|
||||
// us prioritise which catalogs to backfill, with readable context.
|
||||
capture("empty_catalog_cta_clicked", {
|
||||
vehicle_id: vehicleId,
|
||||
category_id: categoryId,
|
||||
vehicle_label: vehicleLabel,
|
||||
category_name: categoryName,
|
||||
});
|
||||
window.history.back();
|
||||
}}
|
||||
>
|
||||
Geri dön
|
||||
</Button>
|
||||
{/* User-driven report: a parts shop knows if this category SHOULD
|
||||
have parts. One click → structured Sentry feedback + replay. */}
|
||||
<ReportCatalogIssueButton
|
||||
ctx={{ vehicleId, categoryId, vehicleLabel, categoryName }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
@@ -344,7 +380,7 @@ export function PartsPanel({
|
||||
</td>
|
||||
<td className="px-3 py-2 font-mono text-xs">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{part.oemCode && (
|
||||
{part.oemCode && part.oemCode !== "N/A" && (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex shrink-0 items-center justify-center rounded p-0.5 text-muted-foreground hover:text-foreground"
|
||||
@@ -357,7 +393,39 @@ export function PartsPanel({
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{part.oemCode}
|
||||
{part.oemCode &&
|
||||
part.oemCode !== "N/A" &&
|
||||
matchedOemCodes.has(part.oemCode) ? (
|
||||
// Link ONLY codes with a real cross-reference. Plain
|
||||
// left-click → in-app client navigation (no full SPA
|
||||
// reload — the new-tab boot was the slow part). Real href
|
||||
// kept so ctrl/cmd/middle-click still opens a new tab.
|
||||
// Unmatched codes fall through to plain text.
|
||||
<a
|
||||
href={`/dashboard/oem/${encodeURIComponent(part.oemCode)}`}
|
||||
title="Uyumlu parça kodlarını gör"
|
||||
className="underline decoration-dotted underline-offset-2 transition-colors hover:text-foreground hover:decoration-solid"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
capture("oem_detail_opened", {
|
||||
oem_code: part.oemCode,
|
||||
part_id: part.id,
|
||||
vehicle_id: vehicleId,
|
||||
category_id: categoryId,
|
||||
});
|
||||
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
navigate({
|
||||
to: "/dashboard/oem/$code",
|
||||
params: { code: part.oemCode },
|
||||
});
|
||||
}}
|
||||
>
|
||||
{part.oemCode}
|
||||
</a>
|
||||
) : (
|
||||
part.oemCode
|
||||
)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center">{part.quantity}</td>
|
||||
|
||||
284
apps/web/src/components/survey-popover.tsx
Normal file
284
apps/web/src/components/survey-popover.tsx
Normal file
@@ -0,0 +1,284 @@
|
||||
import {
|
||||
type SurveyResponses,
|
||||
closeActiveSurvey,
|
||||
dismissActiveSurvey,
|
||||
submitActiveSurvey,
|
||||
subscribeActiveSurvey,
|
||||
} from "@/lib/surveys";
|
||||
import { Button } from "@sase/ui";
|
||||
import { X } from "lucide-react";
|
||||
import type { Survey } from "posthog-js";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
// Self-rendered PostHog survey popover (API-mode surveys). Question copy comes
|
||||
// from the survey definition in PostHog; this component only knows how to draw
|
||||
// the three question shapes we use (single choice, open text, rating/NPS).
|
||||
|
||||
const OTHER_CHOICE = "__other__";
|
||||
|
||||
// Rating scales render as one equal-width row. Static map so Tailwind's JIT
|
||||
// keeps these classes — a template-literal `grid-cols-${n}` would get purged.
|
||||
// (NPS = 0–10 → 11 buttons; CSAT/CES → 3/5/7.)
|
||||
const RATING_COLS: Record<number, string> = {
|
||||
3: "grid-cols-3",
|
||||
5: "grid-cols-5",
|
||||
7: "grid-cols-7",
|
||||
11: "grid-cols-11",
|
||||
};
|
||||
|
||||
export function SurveyPopover() {
|
||||
const [survey, setSurvey] = useState<Survey | null>(null);
|
||||
useEffect(() => subscribeActiveSurvey(setSurvey), []);
|
||||
if (!survey) return null;
|
||||
return <SurveyCard key={`${survey.id}-${survey.current_iteration ?? 0}`} survey={survey} />;
|
||||
}
|
||||
|
||||
function SurveyCard({ survey }: { survey: Survey }) {
|
||||
const [step, setStep] = useState(0);
|
||||
const [answers, setAnswers] = useState<SurveyResponses>({});
|
||||
const [choice, setChoice] = useState<string | null>(null);
|
||||
const [otherText, setOtherText] = useState("");
|
||||
const [openText, setOpenText] = useState("");
|
||||
const [rating, setRating] = useState<number | null>(null);
|
||||
const [done, setDone] = useState(false);
|
||||
const [shown, setShown] = useState(false);
|
||||
const advanceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const dismissRef = useRef<() => void>(() => {});
|
||||
|
||||
const questions = survey.questions;
|
||||
const q = questions[step];
|
||||
|
||||
useEffect(() => {
|
||||
if (!done) return;
|
||||
const t = setTimeout(() => closeActiveSurvey(), 6000);
|
||||
return () => clearTimeout(t);
|
||||
}, [done]);
|
||||
|
||||
// Cancel a pending rating auto-advance if the card unmounts mid-debounce.
|
||||
useEffect(() => () => clearTimeout(advanceTimer.current ?? undefined), []);
|
||||
|
||||
// Entrance: mount hidden, flip on the next frame so the CSS transition runs
|
||||
// (slide-up + fade). prefers-reduced-motion users skip straight to the end
|
||||
// state via the motion-reduce: utilities on the card.
|
||||
useEffect(() => {
|
||||
const id = requestAnimationFrame(() => setShown(true));
|
||||
return () => cancelAnimationFrame(id);
|
||||
}, []);
|
||||
|
||||
// Esc closes the popover (counts as a dismissal). The listener is attached
|
||||
// once; dismissRef keeps it pointed at the latest closure so it captures the
|
||||
// current answers/step rather than a stale first-render one.
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") dismissRef.current();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, []);
|
||||
|
||||
const qid = q?.id ?? `q${step}`;
|
||||
const choices = q && "choices" in q && Array.isArray(q.choices) ? q.choices : [];
|
||||
const hasOpenChoice = Boolean(q && "hasOpenChoice" in q && q.hasOpenChoice);
|
||||
const scale = q && "scale" in q && typeof q.scale === "number" ? q.scale : 5;
|
||||
const lowerLabel = q && "lowerBoundLabel" in q ? (q.lowerBoundLabel ?? "") : "";
|
||||
const upperLabel = q && "upperBoundLabel" in q ? (q.upperBoundLabel ?? "") : "";
|
||||
const optional = Boolean(q && "optional" in q && q.optional);
|
||||
|
||||
function currentValue(): string | number | null {
|
||||
if (!q) return null;
|
||||
if (q.type === "single_choice") {
|
||||
if (choice === OTHER_CHOICE) return otherText.trim() || null;
|
||||
return choice;
|
||||
}
|
||||
if (q.type === "rating") return rating;
|
||||
if (q.type === "open") return openText.trim() || null;
|
||||
return null;
|
||||
}
|
||||
|
||||
const canContinue = optional || currentValue() !== null;
|
||||
|
||||
function collect(): SurveyResponses {
|
||||
const value = currentValue();
|
||||
return value === null && !(qid in answers)
|
||||
? { ...answers, [qid]: null }
|
||||
: { ...answers, [qid]: value };
|
||||
}
|
||||
|
||||
function proceed(next: SurveyResponses) {
|
||||
setAnswers(next);
|
||||
if (step + 1 < questions.length) {
|
||||
setStep(step + 1);
|
||||
setChoice(null);
|
||||
setOtherText("");
|
||||
setOpenText("");
|
||||
setRating(null);
|
||||
return;
|
||||
}
|
||||
submitActiveSurvey(next);
|
||||
setDone(true);
|
||||
}
|
||||
|
||||
function advance() {
|
||||
proceed(collect());
|
||||
}
|
||||
|
||||
// Rating questions auto-advance — tapping a score IS the answer, so there's no
|
||||
// separate submit button. A short debounce lets the user change their pick
|
||||
// (and re-tapping clears the prior timer) before the card moves on.
|
||||
function selectRating(v: number) {
|
||||
setRating(v);
|
||||
clearTimeout(advanceTimer.current ?? undefined);
|
||||
advanceTimer.current = setTimeout(() => proceed({ ...answers, [qid]: v }), 450);
|
||||
}
|
||||
|
||||
function dismiss() {
|
||||
if (done) {
|
||||
closeActiveSurvey();
|
||||
return;
|
||||
}
|
||||
dismissActiveSurvey(collect());
|
||||
}
|
||||
dismissRef.current = dismiss;
|
||||
|
||||
const buttonLabel =
|
||||
(q && "buttonText" in q && q.buttonText) || (step + 1 < questions.length ? "Devam" : "Gönder");
|
||||
const ratingValues = Array.from({ length: scale === 10 ? 11 : scale }, (_, i) =>
|
||||
scale === 10 ? i : i + 1,
|
||||
);
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-label={survey.name}
|
||||
className={`fixed bottom-28 right-4 z-[70] w-[min(22rem,calc(100vw-2rem))] rounded-xl border border-border bg-card p-5 text-card-foreground shadow-2xl transition-all duration-300 ease-out motion-reduce:transition-none sm:bottom-24 ${
|
||||
shown ? "translate-y-0 opacity-100" : "translate-y-3 opacity-0"
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Kapat"
|
||||
onClick={dismiss}
|
||||
className="absolute right-3 top-3 rounded-md p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
|
||||
{done ? (
|
||||
<div>
|
||||
<p className="pr-6 text-sm font-semibold">
|
||||
{survey.appearance?.thankYouMessageHeader || "Teşekkürler!"}
|
||||
</p>
|
||||
{survey.appearance?.thankYouMessageDescription ? (
|
||||
<p className="mt-1.5 text-sm text-muted-foreground">
|
||||
{survey.appearance.thankYouMessageDescription}
|
||||
</p>
|
||||
) : null}
|
||||
<Button variant="outline" className="mt-4 w-full" onClick={closeActiveSurvey}>
|
||||
Kapat
|
||||
</Button>
|
||||
</div>
|
||||
) : q ? (
|
||||
<div>
|
||||
{questions.length > 1 ? (
|
||||
<p className="mb-2 text-xs font-medium tabular-nums text-muted-foreground">
|
||||
{step + 1} / {questions.length}
|
||||
</p>
|
||||
) : null}
|
||||
<p className="pr-6 text-sm font-medium leading-snug">{q.question}</p>
|
||||
|
||||
{q.type === "single_choice" ? (
|
||||
<div className="mt-3 flex flex-col gap-1.5">
|
||||
{choices.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
onClick={() => setChoice(c)}
|
||||
className={`rounded-lg border px-3 py-2 text-left text-sm transition-colors ${
|
||||
choice === c
|
||||
? "border-brand bg-brand/10 text-foreground"
|
||||
: "border-border text-muted-foreground hover:border-foreground/30 hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{c}
|
||||
</button>
|
||||
))}
|
||||
{hasOpenChoice ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setChoice(OTHER_CHOICE)}
|
||||
className={`rounded-lg border px-3 py-2 text-left text-sm transition-colors ${
|
||||
choice === OTHER_CHOICE
|
||||
? "border-brand bg-brand/10 text-foreground"
|
||||
: "border-border text-muted-foreground hover:border-foreground/30 hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
Diğer
|
||||
</button>
|
||||
{choice === OTHER_CHOICE ? (
|
||||
<textarea
|
||||
rows={2}
|
||||
value={otherText}
|
||||
onChange={(e) => setOtherText(e.target.value)}
|
||||
placeholder="Yazmaya başlayın..."
|
||||
// biome-ignore lint/a11y/noAutofocus: revealed by an explicit user action
|
||||
autoFocus
|
||||
className="w-full resize-none rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none focus:border-foreground/40"
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{q.type === "rating" ? (
|
||||
<div className="mt-3">
|
||||
<div className={`grid gap-1 ${RATING_COLS[ratingValues.length] ?? "grid-cols-5"}`}>
|
||||
{ratingValues.map((v) => (
|
||||
<button
|
||||
key={v}
|
||||
type="button"
|
||||
onClick={() => selectRating(v)}
|
||||
className={`flex h-9 items-center justify-center rounded-md border text-xs transition-colors ${
|
||||
rating === v
|
||||
? "border-brand bg-brand text-brand-foreground"
|
||||
: "border-border text-muted-foreground hover:border-foreground/30 hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{v}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{lowerLabel || upperLabel ? (
|
||||
<div className="mt-1.5 flex justify-between text-xs text-muted-foreground">
|
||||
<span>{lowerLabel}</span>
|
||||
<span>{upperLabel}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{q.type === "open" ? (
|
||||
<textarea
|
||||
rows={3}
|
||||
value={openText}
|
||||
onChange={(e) => setOpenText(e.target.value)}
|
||||
placeholder="Yazmaya başlayın..."
|
||||
className="mt-3 w-full resize-none rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none focus:border-foreground/40"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{q.type !== "rating" ? (
|
||||
<Button
|
||||
variant="brand"
|
||||
className="mt-4 w-full"
|
||||
disabled={!canContinue}
|
||||
onClick={advance}
|
||||
>
|
||||
{buttonLabel}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
147
apps/web/src/components/trial-value-upsell.tsx
Normal file
147
apps/web/src/components/trial-value-upsell.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
import { api } from "@/lib/api-client";
|
||||
import { useSession } from "@/lib/auth-client";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import { capture } from "@/lib/posthog";
|
||||
import { Button } from "@sase/ui";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { Sparkles, X } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
interface Subscription {
|
||||
status: string;
|
||||
plan?: { name: string; key: string };
|
||||
billingPeriod: string;
|
||||
endDate?: string;
|
||||
}
|
||||
|
||||
// Early, value-framed conversion nudge that fills the dead zone the urgency
|
||||
// banner leaves: the 30-day trial only shows the (red) urgency banner in its
|
||||
// final week, so for ~3 weeks there is no in-app push at all — and ~98% of
|
||||
// trials never reach checkout. This banner instead fires on PROVEN VALUE (the
|
||||
// user has decoded >= VALUE_THRESHOLD customer vehicles) and only outside the
|
||||
// urgency window, so the two never overlap and the tone stays positive.
|
||||
const VALUE_THRESHOLD = 3;
|
||||
// Urgency banner owns days <= 7; this one owns days > 7 (no overlap).
|
||||
const URGENCY_WINDOW_DAYS = 7;
|
||||
|
||||
function dismissStorageKey(userId: string | null | undefined, endDate: string): string {
|
||||
return `trialValueUpsellDismissed-${userId ?? "anon"}-${endDate}`;
|
||||
}
|
||||
|
||||
export function TrialValueUpsell() {
|
||||
const { t } = useTranslation();
|
||||
const { data: session } = useSession();
|
||||
const userId = session?.user?.id ?? null;
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const viewedRef = useRef(false);
|
||||
|
||||
const { data: subData, isLoading: subLoading } = useQuery({
|
||||
queryKey: ["subscription", "me"],
|
||||
queryFn: () =>
|
||||
api.get<{ subscription: Subscription | null; eligibleForTrial: boolean }>(
|
||||
"/subscriptions/me",
|
||||
),
|
||||
});
|
||||
|
||||
const subscription = subData?.subscription;
|
||||
const endDate = subscription?.endDate;
|
||||
const days =
|
||||
subscription?.status === "trial" && endDate
|
||||
? Math.max(0, Math.ceil((new Date(endDate).getTime() - Date.now()) / (1000 * 60 * 60 * 24)))
|
||||
: null;
|
||||
// Only outside the urgency window, so the urgency banner stays the sole voice
|
||||
// in the final week.
|
||||
const inWindow = !subLoading && days !== null && days > URGENCY_WINDOW_DAYS;
|
||||
|
||||
// Server-truth value signal: how many customer vehicles this trial user has
|
||||
// decoded. Requesting exactly VALUE_THRESHOLD rows is enough to answer
|
||||
// ">= threshold" without a COUNT endpoint. Gated so non-eligible users never
|
||||
// pay for the request.
|
||||
const { data: history } = useQuery({
|
||||
queryKey: ["vehicles", "history", "value-probe", VALUE_THRESHOLD],
|
||||
queryFn: () => api.get<unknown[]>(`/vehicles/history?limit=${VALUE_THRESHOLD}`),
|
||||
enabled: inWindow,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
const hasProvenValue = Array.isArray(history) && history.length >= VALUE_THRESHOLD;
|
||||
|
||||
const dismissKey = endDate ? dismissStorageKey(userId, endDate) : null;
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mounted || !dismissKey) return;
|
||||
if (localStorage.getItem(dismissKey) === "true") setDismissed(true);
|
||||
}, [mounted, dismissKey]);
|
||||
|
||||
const visible = inWindow && hasProvenValue && !dismissed;
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible || viewedRef.current || days === null) return;
|
||||
viewedRef.current = true;
|
||||
capture("trial_value_upsell_viewed", {
|
||||
days_remaining: days,
|
||||
plan_name: subscription?.plan?.name,
|
||||
billing_period: subscription?.billingPeriod,
|
||||
});
|
||||
}, [visible, days, subscription?.plan?.name, subscription?.billingPeriod]);
|
||||
|
||||
const handleCTAClick = useCallback(() => {
|
||||
capture("trial_value_upsell_cta_clicked", {
|
||||
days_remaining: days,
|
||||
plan_name: subscription?.plan?.name,
|
||||
billing_period: subscription?.billingPeriod,
|
||||
});
|
||||
}, [days, subscription?.plan?.name, subscription?.billingPeriod]);
|
||||
|
||||
if (!visible || !dismissKey) return null;
|
||||
|
||||
const handleDismiss = () => {
|
||||
capture("trial_value_upsell_dismissed", {
|
||||
days_remaining: days,
|
||||
plan_name: subscription?.plan?.name,
|
||||
billing_period: subscription?.billingPeriod,
|
||||
});
|
||||
localStorage.setItem(dismissKey, "true");
|
||||
setDismissed(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-label={t("subscription.valueUpsell.title")}
|
||||
className="flex items-start gap-3 border-b border-brand/20 bg-brand/5 px-4 py-3 sm:items-center sm:px-6"
|
||||
>
|
||||
<Sparkles className="mt-0.5 size-5 shrink-0 text-brand sm:mt-0" />
|
||||
|
||||
<div className="flex flex-1 flex-col gap-1 sm:flex-row sm:items-center sm:gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-foreground">
|
||||
{t("subscription.valueUpsell.title")}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("subscription.valueUpsell.description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Link to="/dashboard/subscription" className="shrink-0" onClick={handleCTAClick}>
|
||||
<Button size="sm" className="bg-brand text-white hover:bg-brand/90">
|
||||
{t("subscription.valueUpsell.cta")}
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDismiss}
|
||||
className="flex size-8 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-brand/10 hover:text-foreground"
|
||||
aria-label={t("common.close")}
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
73
apps/web/src/lib/__tests__/surveys.test.ts
Normal file
73
apps/web/src/lib/__tests__/surveys.test.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import type { Survey } from "posthog-js";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildDismissedProperties,
|
||||
buildSentProperties,
|
||||
seenSurveyStorageKey,
|
||||
surveyInteractionKey,
|
||||
} from "../surveys";
|
||||
|
||||
// The capture payloads must match what posthog-js's own widget sends — the
|
||||
// Surveys results UI, the internal targeting flags ($survey_dismissed/<id>)
|
||||
// and cross-device dedup all key off these exact shapes.
|
||||
|
||||
function mkSurvey(overrides: Record<string, unknown> = {}): Survey {
|
||||
return {
|
||||
id: "sv1",
|
||||
name: "Test Survey",
|
||||
type: "api",
|
||||
questions: [
|
||||
{ id: "q1", type: "single_choice", question: "Neden?", choices: ["A", "B"] },
|
||||
{ id: "q2", type: "open", question: "Detay?", optional: true },
|
||||
],
|
||||
current_iteration: null,
|
||||
current_iteration_start_date: null,
|
||||
...overrides,
|
||||
} as unknown as Survey;
|
||||
}
|
||||
|
||||
describe("surveyInteractionKey", () => {
|
||||
it("builds the plain person-property key for one-off surveys", () => {
|
||||
expect(surveyInteractionKey(mkSurvey(), "dismissed")).toBe("$survey_dismissed/sv1");
|
||||
});
|
||||
|
||||
it("appends the iteration for recurring surveys (matches the internal targeting flag)", () => {
|
||||
const s = mkSurvey({ current_iteration: 1 });
|
||||
expect(surveyInteractionKey(s, "responded")).toBe("$survey_responded/sv1/1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("seenSurveyStorageKey", () => {
|
||||
it("matches posthog-js seenSurvey_ localStorage convention", () => {
|
||||
expect(seenSurveyStorageKey(mkSurvey())).toBe("seenSurvey_sv1");
|
||||
expect(seenSurveyStorageKey(mkSurvey({ current_iteration: 2 }))).toBe("seenSurvey_sv1_2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildSentProperties", () => {
|
||||
it("keys responses by question id and marks the responded person property", () => {
|
||||
const props = buildSentProperties(mkSurvey(), { q1: "A", q2: null }, "sub-1");
|
||||
expect(props.$survey_id).toBe("sv1");
|
||||
expect(props.$survey_response_q1).toBe("A");
|
||||
expect(props.$survey_response_q2).toBeNull();
|
||||
expect(props.$survey_completed).toBe(true);
|
||||
expect(props.$survey_submission_id).toBe("sub-1");
|
||||
expect(props.$set).toEqual({ "$survey_responded/sv1": true });
|
||||
expect(props.$survey_questions).toEqual([
|
||||
{ id: "q1", question: "Neden?", response: "A" },
|
||||
{ id: "q2", question: "Detay?", response: null },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildDismissedProperties", () => {
|
||||
it("flags partial completion only when something was answered", () => {
|
||||
const empty = buildDismissedProperties(mkSurvey(), { q1: null });
|
||||
expect(empty.$survey_partially_completed).toBe(false);
|
||||
expect(empty.$set).toEqual({ "$survey_dismissed/sv1": true });
|
||||
|
||||
const partial = buildDismissedProperties(mkSurvey(), { q1: "B", q2: null });
|
||||
expect(partial.$survey_partially_completed).toBe(true);
|
||||
expect(partial.$survey_response_q1).toBe("B");
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,8 @@
|
||||
// PostHog — lazily loaded so it doesn't land in the initial bundle.
|
||||
// All exported functions are fire-and-forget; analytics loss on failure is acceptable.
|
||||
|
||||
import { resetSurveySession, scheduleSurveyCheck, surveysReceiverReady } from "./surveys";
|
||||
|
||||
type PostHog = import("posthog-js").PostHog;
|
||||
|
||||
let _ph: PostHog | null = null;
|
||||
@@ -39,11 +41,19 @@ export function initPostHog(): void {
|
||||
// PII leak.
|
||||
autocapture: false,
|
||||
enable_heatmaps: true,
|
||||
// Staging only: don't drop events from automation UAs (Playwright e2e of
|
||||
// surveys/flags runs against dev.sase.tr). Prod keeps the bot filter.
|
||||
opt_out_useragent_filter: window.location.hostname !== "sase.tr",
|
||||
session_recording: {
|
||||
maskAllInputs: false,
|
||||
maskInputOptions: { password: true },
|
||||
},
|
||||
});
|
||||
// dev.sase.tr now ships with the key too (surveys must be testable on dev
|
||||
// first) — stamp every event so staging traffic is filterable in analyses.
|
||||
ph.register({
|
||||
deploy_env: window.location.hostname === "sase.tr" ? "production" : "staging",
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -60,14 +70,22 @@ export function identifyUser(user: {
|
||||
role: user.role,
|
||||
});
|
||||
});
|
||||
scheduleSurveyCheck();
|
||||
}
|
||||
|
||||
export function resetUser(): void {
|
||||
load().then((ph) => ph.reset());
|
||||
resetSurveySession();
|
||||
}
|
||||
|
||||
export function capture(event: string, properties?: Record<string, unknown>): void {
|
||||
load().then((ph) => ph.capture(event, properties));
|
||||
// Hold captures until the surveys extension's event receiver is hooked (≤3s)
|
||||
// so event-triggered surveys never miss their trigger, then re-check
|
||||
// eligibility once the event has actually gone through.
|
||||
Promise.all([load(), surveysReceiverReady()]).then(([ph]) => {
|
||||
ph.capture(event, properties);
|
||||
scheduleSurveyCheck();
|
||||
});
|
||||
}
|
||||
|
||||
export function capturePageView(path: string): void {
|
||||
@@ -79,10 +97,12 @@ export function capturePageView(path: string): void {
|
||||
load().then((ph) =>
|
||||
ph.capture("$pageview", { $current_url: window.location.href, $pathname: path }),
|
||||
);
|
||||
scheduleSurveyCheck();
|
||||
}
|
||||
|
||||
export function setPeopleProperties(properties: Record<string, unknown>): void {
|
||||
load().then((ph) => ph.people?.set(properties));
|
||||
scheduleSurveyCheck();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -10,9 +10,20 @@
|
||||
import type { init as SentryInit } from "@sentry/react";
|
||||
|
||||
let initialized = false;
|
||||
// Set once init succeeds so the catalog-degradation helpers below can use the
|
||||
// already-loaded SDK without re-awaiting the dynamic import on every call.
|
||||
let sentryApi: typeof import("@sentry/react") | null = null;
|
||||
// Cache the init promise so concurrent callers (e.g. a degradation reporter that
|
||||
// fires on first paint, racing the main.tsx call) await the SAME init instead of
|
||||
// double-initialising the SDK.
|
||||
let initPromise: Promise<void> | null = null;
|
||||
|
||||
export async function initSentry() {
|
||||
if (initialized) return;
|
||||
export function initSentry(): Promise<void> {
|
||||
if (!initPromise) initPromise = doInitSentry();
|
||||
return initPromise;
|
||||
}
|
||||
|
||||
async function doInitSentry(): Promise<void> {
|
||||
const dsn = import.meta.env.VITE_SENTRY_DSN;
|
||||
if (!dsn) {
|
||||
if (import.meta.env.DEV) {
|
||||
@@ -21,20 +32,18 @@ export async function initSentry() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Both dev.sase.tr (staging) and sase.tr (prod) ship a production Vite build
|
||||
// (MODE === "production"), so MODE can't separate them. Derive from the host at
|
||||
// runtime instead, so dev errors stay filterable from prod in Sentry. An
|
||||
// explicit VITE_SENTRY_ENVIRONMENT still wins.
|
||||
// Hostname is authoritative for the two real deployments — sase.tr (prod) and
|
||||
// dev.sase.tr (staging) — which both ship a production Vite build baking the
|
||||
// SAME VITE_SENTRY_ENVIRONMENT (compose default "production"), so that var can't
|
||||
// tell them apart. Use it only as a fallback for local/preview hosts.
|
||||
const host = typeof window !== "undefined" ? window.location.hostname : "";
|
||||
const environment =
|
||||
(import.meta.env.VITE_SENTRY_ENVIRONMENT as string | undefined) ??
|
||||
(host === "sase.tr" || host === "www.sase.tr"
|
||||
host === "sase.tr" || host === "www.sase.tr"
|
||||
? "production"
|
||||
: host.endsWith("dev.sase.tr")
|
||||
? "staging"
|
||||
: import.meta.env.DEV
|
||||
? "development"
|
||||
: "staging");
|
||||
: (import.meta.env.VITE_SENTRY_ENVIRONMENT as string | undefined) ||
|
||||
(import.meta.env.DEV ? "development" : "staging");
|
||||
const release = import.meta.env.VITE_SENTRY_RELEASE;
|
||||
|
||||
try {
|
||||
@@ -46,6 +55,9 @@ export async function initSentry() {
|
||||
integrations: [
|
||||
Sentry.browserTracingIntegration(),
|
||||
Sentry.replayIntegration({ maskAllText: true, blockAllMedia: true }),
|
||||
// Contextual in-app feedback (no global floating button — opened from the
|
||||
// empty/error catalog states via openCatalogFeedback).
|
||||
Sentry.feedbackIntegration({ autoInject: false, showBranding: false }),
|
||||
],
|
||||
// Performance: 10% trace sample rate (low volume site, can raise later).
|
||||
tracesSampleRate: 0.1,
|
||||
@@ -75,8 +87,99 @@ export async function initSentry() {
|
||||
denyUrls: [/iabjs:\/\//],
|
||||
});
|
||||
initialized = true;
|
||||
sentryApi = Sentry;
|
||||
console.log("[sentry] browser SDK initialized");
|
||||
} catch (err) {
|
||||
console.warn("[sentry] init failed:", (err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Catalog UX-degradation reporting (browser) ───
|
||||
// Two things only the browser can add on top of the backend reporter
|
||||
// (apps/api/src/common/catalog-degradation.ts): a Session Replay of the user
|
||||
// hitting the dead-end, and a one-click structured feedback path. Browser events
|
||||
// are fingerprinted with source "browser" so they form their own "what users
|
||||
// actually saw" issues (carrying replays), distinct from the server-side
|
||||
// detections — same kinds, complementary views.
|
||||
|
||||
export type CatalogIssueKind = "empty-tree" | "empty-parts" | "drill-load-error";
|
||||
|
||||
export interface CatalogIssueContext {
|
||||
vehicleId?: string;
|
||||
vehicleLabel?: string;
|
||||
categoryId?: string;
|
||||
categoryName?: string;
|
||||
source?: string;
|
||||
vin?: string;
|
||||
}
|
||||
|
||||
// Per-session dedup: re-renders or browsing many empty categories shouldn't spam
|
||||
// the issue stream, and one replay upload already covers the whole journey.
|
||||
const reportedThisSession = new Set<string>();
|
||||
let replayFlushedThisSession = false;
|
||||
|
||||
const brandOf = (label?: string) => (label?.trim().split(/\s+/)[0] || "unknown").toLowerCase();
|
||||
|
||||
/**
|
||||
* Report the catalog failure the user is currently looking at and flush the
|
||||
* Session Replay so it's watchable in Sentry. Best-effort; never throws.
|
||||
*/
|
||||
export async function reportCatalogDegradation(
|
||||
kind: CatalogIssueKind,
|
||||
ctx: CatalogIssueContext,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const dedupKey = `${kind}:${ctx.categoryId ?? ctx.vehicleId ?? ""}`;
|
||||
if (reportedThisSession.has(dedupKey)) return;
|
||||
reportedThisSession.add(dedupKey);
|
||||
// The empty/error state can render before the SDK's dynamic import resolves —
|
||||
// wait for init so the failure is never silently dropped.
|
||||
await initSentry();
|
||||
const Sentry = sentryApi;
|
||||
if (!Sentry) return;
|
||||
|
||||
const brand = brandOf(ctx.vehicleLabel);
|
||||
Sentry.captureMessage(`catalog degraded: ${kind} (browser/${brand})`, {
|
||||
level: "warning",
|
||||
tags: { catalog_degradation: kind, catalog_source: ctx.source ?? "unknown" },
|
||||
fingerprint: ["catalog-degradation", "browser", kind, brand],
|
||||
extra: { ...ctx },
|
||||
});
|
||||
|
||||
if (!replayFlushedThisSession) {
|
||||
replayFlushedThisSession = true;
|
||||
await Sentry.getReplay()?.flush();
|
||||
}
|
||||
} catch {
|
||||
// telemetry must never break the UI
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the Sentry feedback dialog pre-tagged with the vehicle/category the user
|
||||
* is reporting from, so "bu araç çalışmadı" becomes a structured report with the
|
||||
* session replay attached — instead of an email.
|
||||
*/
|
||||
export async function openCatalogFeedback(ctx: CatalogIssueContext): Promise<void> {
|
||||
try {
|
||||
await initSentry();
|
||||
const Sentry = sentryApi;
|
||||
if (!Sentry) return;
|
||||
Sentry.setTag("catalog_source", ctx.source ?? "unknown");
|
||||
Sentry.setContext("catalog_issue", { ...ctx });
|
||||
const feedback = Sentry.getFeedback();
|
||||
if (!feedback) return;
|
||||
const form = await feedback.createForm({
|
||||
formTitle: "Bu araç / parça çalışmadı mı?",
|
||||
messagePlaceholder:
|
||||
"Hangi araç ve parça eksik veya yanlış? (şase ve kategori bilgisi otomatik eklenir)",
|
||||
submitButtonLabel: "Gönder",
|
||||
cancelButtonLabel: "Vazgeç",
|
||||
// The session replay + the catalog_issue context above ride along with this.
|
||||
});
|
||||
form.appendToDom();
|
||||
form.open();
|
||||
} catch {
|
||||
// feedback is best-effort
|
||||
}
|
||||
}
|
||||
|
||||
229
apps/web/src/lib/surveys.ts
Normal file
229
apps/web/src/lib/surveys.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
// PostHog surveys in API mode — we render the popover ourselves (zero PostHog
|
||||
// branding, no PostHog survey DOM), while targeting and analytics stay in
|
||||
// PostHog: eligibility (event triggers, URL/device rules, wait periods and the
|
||||
// person-property targeting flags, e.g. subscription_status=active) is
|
||||
// evaluated by posthog-js inside getActiveMatchingSurveys, and we capture the
|
||||
// same "survey shown/dismissed/sent" events with the exact property shapes the
|
||||
// official widget uses, so per-distinct_id dedup and the Surveys results UI
|
||||
// keep working unchanged.
|
||||
|
||||
import type { PostHog, Survey } from "posthog-js";
|
||||
|
||||
const ENABLED = Boolean(import.meta.env.VITE_POSTHOG_KEY);
|
||||
|
||||
function loadPh(): Promise<PostHog> {
|
||||
return import("posthog-js").then((m) => m.default);
|
||||
}
|
||||
|
||||
/** Answers keyed by question id; null = question left unanswered. */
|
||||
export type SurveyResponses = Record<string, string | number | null>;
|
||||
|
||||
// ── capture payloads (must mirror posthog-js dist/surveys.js) ────────────────
|
||||
|
||||
/** Person-property key the survey's internal targeting flag filters on —
|
||||
* `$survey_dismissed/<id>` plus `/<iteration>` for recurring surveys. */
|
||||
export function surveyInteractionKey(survey: Survey, kind: "dismissed" | "responded"): string {
|
||||
const base = `$survey_${kind}/${survey.id}`;
|
||||
return survey.current_iteration && survey.current_iteration > 0
|
||||
? `${base}/${survey.current_iteration}`
|
||||
: base;
|
||||
}
|
||||
|
||||
/** localStorage key posthog-js checks for client-side "already seen" gating. */
|
||||
export function seenSurveyStorageKey(survey: Survey): string {
|
||||
const base = `seenSurvey_${survey.id}`;
|
||||
return survey.current_iteration && survey.current_iteration > 0
|
||||
? `${base}_${survey.current_iteration}`
|
||||
: base;
|
||||
}
|
||||
|
||||
export function surveyResponseKey(questionId: string): string {
|
||||
return `$survey_response_${questionId}`;
|
||||
}
|
||||
|
||||
function baseProperties(survey: Survey): Record<string, unknown> {
|
||||
return {
|
||||
$survey_name: survey.name,
|
||||
$survey_id: survey.id,
|
||||
$survey_iteration: survey.current_iteration,
|
||||
$survey_iteration_start_date: survey.current_iteration_start_date,
|
||||
};
|
||||
}
|
||||
|
||||
function responseProperties(survey: Survey, responses: SurveyResponses): Record<string, unknown> {
|
||||
const props: Record<string, unknown> = {};
|
||||
for (const q of survey.questions) {
|
||||
if (!q.id) continue;
|
||||
props[surveyResponseKey(q.id)] = responses[q.id] ?? null;
|
||||
}
|
||||
return props;
|
||||
}
|
||||
|
||||
export function buildDismissedProperties(
|
||||
survey: Survey,
|
||||
responses: SurveyResponses,
|
||||
): Record<string, unknown> {
|
||||
const hasAnyResponse = Object.values(responses).some((v) => v !== null && v !== "");
|
||||
return {
|
||||
...baseProperties(survey),
|
||||
...responseProperties(survey, responses),
|
||||
$survey_partially_completed: hasAnyResponse,
|
||||
$set: { [surveyInteractionKey(survey, "dismissed")]: true },
|
||||
};
|
||||
}
|
||||
|
||||
export function buildSentProperties(
|
||||
survey: Survey,
|
||||
responses: SurveyResponses,
|
||||
submissionId: string,
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
...baseProperties(survey),
|
||||
...responseProperties(survey, responses),
|
||||
$survey_questions: survey.questions.map((q) => ({
|
||||
id: q.id,
|
||||
question: q.question,
|
||||
response: q.id ? (responses[q.id] ?? null) : null,
|
||||
})),
|
||||
$survey_submission_id: submissionId,
|
||||
$survey_completed: true,
|
||||
$set: { [surveyInteractionKey(survey, "responded")]: true },
|
||||
};
|
||||
}
|
||||
|
||||
// ── display manager ──────────────────────────────────────────────────────────
|
||||
|
||||
let _active: Survey | null = null;
|
||||
let _shownThisSession = new Set<string>();
|
||||
const _listeners = new Set<(survey: Survey | null) => void>();
|
||||
let _checkTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let _showTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let _wired = false;
|
||||
|
||||
function notify(): void {
|
||||
for (const cb of _listeners) cb(_active);
|
||||
}
|
||||
|
||||
function markSeen(survey: Survey): void {
|
||||
try {
|
||||
localStorage.setItem(seenSurveyStorageKey(survey), "true");
|
||||
} catch {
|
||||
// localStorage unavailable — the server-side person-property dedup still applies
|
||||
}
|
||||
}
|
||||
|
||||
function wireOnce(ph: PostHog): void {
|
||||
if (_wired) return;
|
||||
_wired = true;
|
||||
// Re-check when the lazily loaded surveys module becomes ready and whenever
|
||||
// feature flags re-evaluate (identify, property changes) — both change eligibility.
|
||||
ph.surveys?.onSurveysLoaded?.(() => scheduleSurveyCheck());
|
||||
ph.onFeatureFlags(() => scheduleSurveyCheck());
|
||||
}
|
||||
|
||||
function check(ph: PostHog): void {
|
||||
wireOnce(ph);
|
||||
if (_active || _showTimer) return;
|
||||
ph.getActiveMatchingSurveys((surveys) => {
|
||||
if (_active || _showTimer) return;
|
||||
const candidate = surveys.find((s) => s.type === "api" && !_shownThisSession.has(s.id));
|
||||
if (!candidate) return;
|
||||
const delaySeconds = candidate.appearance?.surveyPopupDelaySeconds ?? 0;
|
||||
_showTimer = setTimeout(() => {
|
||||
_showTimer = null;
|
||||
show(ph, candidate);
|
||||
}, delaySeconds * 1000);
|
||||
});
|
||||
}
|
||||
|
||||
function show(ph: PostHog, survey: Survey): void {
|
||||
if (_active) return;
|
||||
_shownThisSession.add(survey.id);
|
||||
_active = survey;
|
||||
ph.capture("survey shown", baseProperties(survey));
|
||||
try {
|
||||
localStorage.setItem("lastSeenSurveyDate", new Date().toISOString());
|
||||
} catch {
|
||||
// best effort — only affects cross-survey wait-period spacing
|
||||
}
|
||||
notify();
|
||||
}
|
||||
|
||||
let _readyPromise: Promise<void> | null = null;
|
||||
|
||||
/** Resolves once posthog-js's surveys extension has installed its capture hook
|
||||
* (or after a 3s safety timeout). Events captured before that hook exists are
|
||||
* invisible to event-triggered surveys — the trial survey's
|
||||
* trial_urgency_banner_viewed fires ~1s into the page load and was racing the
|
||||
* extension's lazy <script>, so posthog.ts awaits this before every capture. */
|
||||
export function surveysReceiverReady(): Promise<void> {
|
||||
if (!ENABLED || typeof window === "undefined") return Promise.resolve();
|
||||
if (_readyPromise) return _readyPromise;
|
||||
_readyPromise = new Promise((resolve) => {
|
||||
setTimeout(resolve, 3000);
|
||||
loadPh()
|
||||
.then((ph) => ph.surveys?.onSurveysLoaded?.(() => resolve()))
|
||||
.catch(() => resolve());
|
||||
});
|
||||
return _readyPromise;
|
||||
}
|
||||
|
||||
/** Debounced eligibility check — called after captures, route changes and identify. */
|
||||
export function scheduleSurveyCheck(): void {
|
||||
if (!ENABLED || typeof window === "undefined") return;
|
||||
if (_checkTimer) clearTimeout(_checkTimer);
|
||||
_checkTimer = setTimeout(() => {
|
||||
_checkTimer = null;
|
||||
loadPh()
|
||||
.then((ph) => check(ph))
|
||||
.catch(() => {});
|
||||
}, 700);
|
||||
}
|
||||
|
||||
export function subscribeActiveSurvey(cb: (survey: Survey | null) => void): () => void {
|
||||
_listeners.add(cb);
|
||||
cb(_active);
|
||||
return () => {
|
||||
_listeners.delete(cb);
|
||||
};
|
||||
}
|
||||
|
||||
export function dismissActiveSurvey(responses: SurveyResponses): void {
|
||||
const survey = _active;
|
||||
if (!survey) return;
|
||||
_active = null;
|
||||
notify();
|
||||
markSeen(survey);
|
||||
loadPh()
|
||||
.then((ph) => ph.capture("survey dismissed", buildDismissedProperties(survey, responses)))
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
/** Capture the responses; the popover stays mounted to show its thank-you
|
||||
* state and calls closeActiveSurvey() when it's done. */
|
||||
export function submitActiveSurvey(responses: SurveyResponses): void {
|
||||
const survey = _active;
|
||||
if (!survey) return;
|
||||
markSeen(survey);
|
||||
const submissionId = crypto.randomUUID();
|
||||
loadPh()
|
||||
.then((ph) => ph.capture("survey sent", buildSentProperties(survey, responses, submissionId)))
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
export function closeActiveSurvey(): void {
|
||||
if (!_active) return;
|
||||
_active = null;
|
||||
notify();
|
||||
}
|
||||
|
||||
/** Logout — drop in-memory survey state; localStorage/person-property dedup stays. */
|
||||
export function resetSurveySession(): void {
|
||||
if (_showTimer) {
|
||||
clearTimeout(_showTimer);
|
||||
_showTimer = null;
|
||||
}
|
||||
_active = null;
|
||||
_shownThisSession = new Set();
|
||||
notify();
|
||||
}
|
||||
@@ -185,7 +185,7 @@
|
||||
"tabPl24": "Pl24",
|
||||
"tabPcat": "Pcat",
|
||||
"tabEmex": "Emex",
|
||||
"tabTecdoc": "Tecdoc",
|
||||
"tabTecdoc": "P",
|
||||
"comingSoon": "Coming Soon",
|
||||
"categories": "Categories",
|
||||
"noCategories": "No categories found",
|
||||
@@ -413,6 +413,11 @@
|
||||
"cta": "Upgrade Plan",
|
||||
"progressLabel": "{percent}% of trial used"
|
||||
},
|
||||
"valueUpsell": {
|
||||
"title": "Ready to subscribe?",
|
||||
"description": "You're actively using the platform. Subscribe to keep querying customer vehicles and accessing OEM parts without interruption.",
|
||||
"cta": "Subscribe"
|
||||
},
|
||||
"trialProgress": {
|
||||
"title": "{days} days left in your Full Package trial",
|
||||
"description": "Pick a plan now to keep your access without interruption.",
|
||||
@@ -489,8 +494,7 @@
|
||||
"refunded": "Refunded"
|
||||
},
|
||||
"methodLabels": {
|
||||
"stripe": "Credit Card",
|
||||
"eft": "EFT/Wire"
|
||||
"stripe": "Credit Card"
|
||||
},
|
||||
"summary": {
|
||||
"title": "Current Plan",
|
||||
@@ -663,7 +667,7 @@
|
||||
"s2t": "See the parts",
|
||||
"s2d": "OEM codes, diagrams and compatible parts appear in seconds.",
|
||||
"s3t": "Pick the right part",
|
||||
"s3d": "Compare prices and find the right part in one go — no wrong orders."
|
||||
"s3d": "Copy the right OEM code and order the correct part from your supplier in one go — no wrong orders."
|
||||
},
|
||||
"faq": {
|
||||
"title": "Frequently asked questions",
|
||||
@@ -738,6 +742,8 @@
|
||||
"noCategoriesTitle": "No categories for this vehicle",
|
||||
"noCategoriesHint": "The vehicle was decoded but the catalog may not be ready yet. Reach out to support to expedite it.",
|
||||
"drillHint": "Parts live inside the subcategories — open a category to drill down to its part lists.",
|
||||
"reportIssue": "Not working? Report it",
|
||||
"reportIssueDone": "Reported — thanks",
|
||||
"noAttrs": "No detailed info available for this vehicle.",
|
||||
"labelModel": "Model",
|
||||
"labelYear": "Model year",
|
||||
@@ -798,7 +804,7 @@
|
||||
"subtitleA": "Enter the customer vehicle's VIN and find the correct OEM part the first time with cross-catalog verification. No returns, no losses — trusted by 500+ businesses. Try it free.",
|
||||
"lossPillB": "Start looking up VINs free — no card required",
|
||||
"titleB": "Find the Customer's Correct Part in Seconds",
|
||||
"subtitleB": "Enter the 17-digit VIN and reach the right OEM codes, part diagrams and prices in seconds. 27 brands, unlimited lookups. Start free — no credit card.",
|
||||
"subtitleB": "Enter the 17-digit VIN and reach the right OEM codes, part diagrams in seconds. 27 brands, unlimited lookups. Start free — no credit card.",
|
||||
"vinPlaceholder": "Example: WVWZZZ1JZ3W597935",
|
||||
"searchCta": "Search",
|
||||
"decoding": "Decoding...",
|
||||
@@ -868,7 +874,7 @@
|
||||
"description": "Cross-references multiple catalogs so OEM codes are always current and correct — no more wrong parts, no more returns.",
|
||||
"bullet1": "Multi-catalog cross-referencing",
|
||||
"bullet2": "Always-current OEM codes",
|
||||
"bullet3": "Price comparison",
|
||||
"bullet3": "Matched via exploded diagrams",
|
||||
"mockupTitle": "OEM Catalog — Parts List",
|
||||
"part1": "AC Compressor",
|
||||
"part2": "Heater Blower Motor",
|
||||
@@ -897,9 +903,9 @@
|
||||
"sase": "Multi-catalog cross-query"
|
||||
},
|
||||
"3": {
|
||||
"label": "Price comparison",
|
||||
"manual": "Impossible",
|
||||
"sase": "On a single screen"
|
||||
"label": "Interactive part diagrams",
|
||||
"manual": "Paper catalog",
|
||||
"sase": "Clickable diagrams"
|
||||
},
|
||||
"4": {
|
||||
"label": "Monthly time saved",
|
||||
@@ -1160,7 +1166,7 @@
|
||||
"items": {
|
||||
"0": {
|
||||
"q": "Which brands do you support?",
|
||||
"a": "We currently support 27 automotive brands and the list keeps growing. Enter your VIN in the search box to see right away whether your vehicle is covered."
|
||||
"a": "We currently support 27 automotive brands and the list keeps growing. Enter a VIN in the search box to see right away whether a vehicle is covered."
|
||||
},
|
||||
"1": {
|
||||
"q": "Is my data safe?",
|
||||
|
||||
@@ -185,7 +185,7 @@
|
||||
"tabPl24": "Pl24",
|
||||
"tabPcat": "Pcat",
|
||||
"tabEmex": "Emex",
|
||||
"tabTecdoc": "Tecdoc",
|
||||
"tabTecdoc": "P",
|
||||
"comingSoon": "Yakında",
|
||||
"categories": "Kategoriler",
|
||||
"noCategories": "Kategori bulunamadı",
|
||||
@@ -324,7 +324,7 @@
|
||||
"brandSearchPlaceholder": "Marka ara — ör. Volkswagen",
|
||||
"brandSearchNoMatch": "\"{query}\" ile eşleşen marka yok.",
|
||||
"features": {
|
||||
"vinSearch": "Sınırsız VIN arama",
|
||||
"vinSearch": "Sınırsız şase arama",
|
||||
"partsCatalog": "Parça kataloğu",
|
||||
"schemaViewer": "Şema görüntüleyici",
|
||||
"prioritySupport": "Öncelikli destek",
|
||||
@@ -413,6 +413,11 @@
|
||||
"cta": "Plana Yükselt",
|
||||
"progressLabel": "Denemenin %{percent}'i kullanıldı"
|
||||
},
|
||||
"valueUpsell": {
|
||||
"title": "Aboneliğe geçmeye hazır mısın?",
|
||||
"description": "Platformu aktif kullanıyorsun. Müşteri araçlarını sınırsız sorgulamaya ve OEM parça erişimine kesintisiz devam etmek için aboneliğe geç.",
|
||||
"cta": "Aboneliğe geç"
|
||||
},
|
||||
"trialProgress": {
|
||||
"title": "Full Paket denemende {days} gün kaldı",
|
||||
"description": "Kesintisiz devam etmek için planını şimdi seç.",
|
||||
@@ -489,8 +494,7 @@
|
||||
"refunded": "İade"
|
||||
},
|
||||
"methodLabels": {
|
||||
"stripe": "Kredi Kartı",
|
||||
"eft": "EFT/Havale"
|
||||
"stripe": "Kredi Kartı"
|
||||
},
|
||||
"summary": {
|
||||
"title": "Mevcut Plan",
|
||||
@@ -659,11 +663,11 @@
|
||||
"how": {
|
||||
"title": "Nasıl çalışır",
|
||||
"s1t": "Şase numarasını gir",
|
||||
"s1d": "17 haneli VIN/şase numarasını yapıştır.",
|
||||
"s1d": "17 haneli şase numarasını yapıştır.",
|
||||
"s2t": "Parçaları gör",
|
||||
"s2d": "Saniyeler içinde OEM kodları, şemalar ve uyumlu parçalar listelensin.",
|
||||
"s3t": "Doğru parçayı seç",
|
||||
"s3d": "Fiyatları karşılaştır, doğru parçayı tek seferde bul — yanlış sipariş yok."
|
||||
"s3d": "Doğru OEM kodunu kopyala, tedarikçine tek seferde doğru parçayı sipariş et — yanlış sipariş yok."
|
||||
},
|
||||
"faq": {
|
||||
"title": "Sık sorulan sorular",
|
||||
@@ -738,6 +742,8 @@
|
||||
"noCategoriesTitle": "Bu araç için kategori bulunamadı",
|
||||
"noCategoriesHint": "Araç decode edildi ama parça kataloğu henüz hazırlanmamış olabilir. Destek ekibimize bildirirseniz katalog hızlandırılır.",
|
||||
"drillHint": "Parçalar alt kategorilerin içinde yer alır. Bir kategoriye girerek ilerleyin — her grup, içindeki parça listesine kadar açılır.",
|
||||
"reportIssue": "Çalışmadı mı? Bildir",
|
||||
"reportIssueDone": "Bildirildi — teşekkürler",
|
||||
"noAttrs": "Bu araç için ayrıntı bilgisi bulunamadı.",
|
||||
"labelModel": "Model",
|
||||
"labelYear": "Model yılı",
|
||||
@@ -770,7 +776,7 @@
|
||||
"meta": {
|
||||
"title": "Şase Numarası Sorgulama & OEM Parça Kataloğu | Sase.tr",
|
||||
"description": "Araç şase numarasını (VIN) girin, saniyeler içinde OEM parça kodlarına ulaşın. 27 marka, 1M+ parça. Yanlış sipariş yapmadan doğru parçayı bulun.",
|
||||
"schemaOrgDescription": "Türkiye'nin VIN/şase numarası sorgulama ve OEM yedek parça kataloğu platformu.",
|
||||
"schemaOrgDescription": "Türkiye'nin şase numarası sorgulama ve OEM yedek parça kataloğu platformu.",
|
||||
"schemaSoftwareDescription": "Araç şase numarası ile OEM yedek parça sorgulama platformu."
|
||||
},
|
||||
"nav": {
|
||||
@@ -798,7 +804,7 @@
|
||||
"subtitleA": "Müşteri aracının şasesini girin; çapraz katalog doğrulamasıyla doğru OEM parçasını ilk seferde bulun. İade yok, kayıp yok — 500+ işletme güveniyor. Ücretsiz deneyin.",
|
||||
"lossPillB": "Şase sorgulamaya ücretsiz başlayın — kart gerekmez",
|
||||
"titleB": "Müşteri Aracının Doğru Parçasını Saniyede Bulun",
|
||||
"subtitleB": "17 haneli şaseyi girin; doğru OEM kodlarına, parça şemalarına ve fiyatlara saniyeler içinde ulaşın. 27 marka, sınırsız sorgu. Ücretsiz başlayın, kredi kartı istemiyoruz.",
|
||||
"subtitleB": "17 haneli şaseyi girin; doğru OEM kodlarına, parça şemalarına saniyeler içinde ulaşın. 27 marka, sınırsız sorgu. Ücretsiz başlayın, kredi kartı istemiyoruz.",
|
||||
"vinPlaceholder": "Örnek: WVWZZZ1JZ3W597935",
|
||||
"searchCta": "Ara",
|
||||
"decoding": "Çözülüyor...",
|
||||
@@ -868,7 +874,7 @@
|
||||
"description": "Birden fazla katalogda çapraz sorgulama yaparak her zaman en güncel ve doğru OEM kodlarını sunar — yanlış parça, iade derdi tarih olur.",
|
||||
"bullet1": "Çoklu katalog çapraz sorgulama",
|
||||
"bullet2": "Her zaman güncel OEM kodları",
|
||||
"bullet3": "Fiyat karşılaştırma",
|
||||
"bullet3": "Patlamış parça şemasıyla eşleştirme",
|
||||
"mockupTitle": "OEM Katalog — Parça Listesi",
|
||||
"part1": "Klima Kompresörü",
|
||||
"part2": "Kalorifer Motoru",
|
||||
@@ -897,9 +903,9 @@
|
||||
"sase": "Çoklu katalog çapraz sorgu"
|
||||
},
|
||||
"3": {
|
||||
"label": "Fiyat karşılaştırma",
|
||||
"manual": "İmkansız",
|
||||
"sase": "Tek ekranda"
|
||||
"label": "İnteraktif parça şeması",
|
||||
"manual": "Kağıt katalog",
|
||||
"sase": "Tıklanabilir diyagram"
|
||||
},
|
||||
"4": {
|
||||
"label": "Aylık zaman tasarrufu",
|
||||
@@ -1047,14 +1053,14 @@
|
||||
"ecommerce": {
|
||||
"badge": "E-Ticaret Entegrasyonu",
|
||||
"title": "Kendi Sitenize Şase Çözme Gücü Katın",
|
||||
"subtitle": "Müşterileriniz yanlış parça sipariş edip iade mi açıyor? Sase.tr entegrasyonu ile VIN bazlı filtreleme ekleyin — iadeler düşsün, dönüşüm artsın.",
|
||||
"subtitle": "Müşterileriniz yanlış parça sipariş edip iade mi açıyor? Sase.tr entegrasyonu ile şase bazlı filtreleme ekleyin — iadeler düşsün, dönüşüm artsın.",
|
||||
"frameTitle": "otoyedekparca.co — Sase.tr Entegrasyonu",
|
||||
"loading": "Yükleniyor…",
|
||||
"easyBadge": "Kolay Entegrasyon",
|
||||
"cardTitle": "Birkaç Satır Kod, Büyük Fark",
|
||||
"cardBody": "E-ticaret sitenize Sase.tr'nin VIN çözme motorunu entegre edin. Müşterileriniz şase numarasını girsin — sadece araçlarına uygun parçalar listelensin.",
|
||||
"cardBody": "E-ticaret sitenize Sase.tr'nin şase çözme motorunu entegre edin. Müşterileriniz şase numarasını girsin — sadece araçlarına uygun parçalar listelensin.",
|
||||
"bullet1": "API & Widget — Birkaç satır kodla sitenize entegre edin",
|
||||
"bullet2": "VIN Bazlı Filtreleme — Sadece uyumlu parçalar görünsün",
|
||||
"bullet2": "Şase Bazlı Filtreleme — Sadece uyumlu parçalar görünsün",
|
||||
"bullet3": "İade Oranını Düşürün — Doğru parça, ilk seferde",
|
||||
"bullet4": "White-Label — Widget sitenizin tasarımına uyum sağlar",
|
||||
"cta": "İletişime Geçin",
|
||||
@@ -1090,7 +1096,7 @@
|
||||
"2": {
|
||||
"name": "Özge D.",
|
||||
"role": "E-Ticaret Yöneticisi",
|
||||
"text": "Online mağazamıza VIN entegrasyonu ekledik. Müşteriler şase numarasını giriyor, sadece uyumlu parçalar listeleniyor. İade oranımız %42 düştü."
|
||||
"text": "Online mağazamıza şase entegrasyonu ekledik. Müşteriler şase numarasını giriyor, sadece uyumlu parçalar listeleniyor. İade oranımız %42 düştü."
|
||||
},
|
||||
"3": {
|
||||
"name": "Burak T.",
|
||||
@@ -1113,7 +1119,7 @@
|
||||
"name": "1 Marka",
|
||||
"description": "Tek marka için erişim",
|
||||
"feature1": "1 marka seçimi",
|
||||
"feature2": "Sınırsız VIN arama",
|
||||
"feature2": "Sınırsız şase arama",
|
||||
"feature3": "Parça kataloğu",
|
||||
"feature4": "Şema görüntüleyici"
|
||||
},
|
||||
@@ -1121,7 +1127,7 @@
|
||||
"name": "2 Marka",
|
||||
"description": "İki farklı marka",
|
||||
"feature1": "2 marka seçimi",
|
||||
"feature2": "Sınırsız VIN arama",
|
||||
"feature2": "Sınırsız şase arama",
|
||||
"feature3": "Parça kataloğu",
|
||||
"feature4": "Öncelikli destek"
|
||||
},
|
||||
@@ -1129,7 +1135,7 @@
|
||||
"name": "3 Marka",
|
||||
"description": "Üç marka kapsamlı",
|
||||
"feature1": "3 marka seçimi",
|
||||
"feature2": "Sınırsız VIN arama",
|
||||
"feature2": "Sınırsız şase arama",
|
||||
"feature3": "Parça kataloğu",
|
||||
"feature4": "Öncelikli destek"
|
||||
}
|
||||
@@ -1144,7 +1150,7 @@
|
||||
"ctaFineprint": "Kart bilgisi gerekmez. İstediğin zaman iptal et.",
|
||||
"included": "Pakete dahil",
|
||||
"feature1": "Tüm 27 markaya erişim",
|
||||
"feature2": "Sınırsız VIN arama",
|
||||
"feature2": "Sınırsız şase arama",
|
||||
"feature3": "OEM parça kataloğu",
|
||||
"feature4": "İnteraktif şema görüntüleyici",
|
||||
"feature5": "Geçmiş sorgular & favoriler",
|
||||
@@ -1160,7 +1166,7 @@
|
||||
"items": {
|
||||
"0": {
|
||||
"q": "Hangi markaları destekliyorsunuz?",
|
||||
"a": "Şu an 27 otomobil markası destekleniyor ve liste sürekli genişliyor. Şase numaranızı arama kutusuna girerek aracınızın desteklenip desteklenmediğini hemen görebilirsiniz."
|
||||
"a": "Şu an 27 otomobil markası destekleniyor ve liste sürekli genişliyor. Şase numarasını arama kutusuna girerek bir aracın desteklenip desteklenmediğini hemen görebilirsiniz."
|
||||
},
|
||||
"1": {
|
||||
"q": "Verilerim güvende mi?",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,5 @@
|
||||
import { InAppEscape } from "@/components/in-app-escape";
|
||||
import { SurveyPopover } from "@/components/survey-popover";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { resetChatwootUser, setChatwootUser } from "@/lib/chatwoot";
|
||||
@@ -156,6 +157,7 @@ function RootComponent() {
|
||||
</a>
|
||||
<InAppEscape />
|
||||
<Outlet />
|
||||
<SurveyPopover />
|
||||
<Toaster position="top-center" />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { usePageMeta } from "@/hooks/use-page-meta";
|
||||
import { SiteHeader } from "@/components/site-header";
|
||||
import { usePageMeta } from "@/hooks/use-page-meta";
|
||||
import { Button } from "@sase/ui";
|
||||
import { Link, createFileRoute } from "@tanstack/react-router";
|
||||
|
||||
@@ -11,7 +11,7 @@ function AboutPage() {
|
||||
usePageMeta({
|
||||
title: "Hakkımızda — Sase.tr | Türkiye'nin Şase Sorgulama Platformu",
|
||||
description:
|
||||
"Sase.tr, Türkiye yedek parça sektörüne yönelik VIN/şase numarası sorgulama ve OEM parça kataloğu platformudur.",
|
||||
"Sase.tr, Türkiye yedek parça sektörüne yönelik şase numarası sorgulama ve OEM parça kataloğu platformudur.",
|
||||
canonical: "https://sase.tr/about",
|
||||
});
|
||||
|
||||
@@ -25,21 +25,21 @@ function AboutPage() {
|
||||
<div className="mt-8 space-y-6 text-muted-foreground leading-relaxed">
|
||||
<p>
|
||||
Sase.tr, Türkiye'nin yedek parça sektörüne yönelik geliştirilen dijital bir platformdur.
|
||||
Şase numarası (VIN) ile araç tanımlama, orijinal parça kataloğuna erişim ve interaktif
|
||||
şema görüntüleme hizmetlerini tek bir çatı altında sunar.
|
||||
Şase numarası ile araç tanımlama, orijinal parça kataloğuna erişim ve interaktif şema
|
||||
görüntüleme hizmetlerini tek bir çatı altında sunar.
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold text-foreground">Misyonumuz</h2>
|
||||
<p>
|
||||
Yedek parça arama sürecini hızlandırmak, doğru parçaya ilk seferde ulaşmayı sağlamak ve
|
||||
sektördeki bilgi asimetrisini ortadan kaldırmak. Oto yedek parçacılar, servisler ve
|
||||
bireysel kullanıcılar için güvenilir bir referans noktası olmayı hedefliyoruz.
|
||||
sektördeki bilgi asimetrisini ortadan kaldırmak. Oto yedek parçacılar ve servisler için
|
||||
güvenilir bir referans noktası olmayı hedefliyoruz.
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold text-foreground">Ne Yapıyoruz?</h2>
|
||||
<ul className="list-disc space-y-2 pl-6">
|
||||
<li>
|
||||
<strong>Şase Çözme:</strong> VIN numarası ile aracın marka, model, yıl, motor tipi ve
|
||||
<strong>Şase Çözme:</strong> Şase numarası ile aracın marka, model, yıl, motor tipi ve
|
||||
donanım bilgilerine anında erişim.
|
||||
</li>
|
||||
<li>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { LanguageSwitcher } from "@/components/language-switcher";
|
||||
import { SiteFooter } from "@/components/site-footer";
|
||||
import { TrialUrgencyBanner } from "@/components/trial-urgency-banner";
|
||||
import { TrialValueUpsell } from "@/components/trial-value-upsell";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
@@ -509,6 +510,7 @@ function DashboardLayout() {
|
||||
{/* Page Content */}
|
||||
<main id="main-content" className="flex-1 overflow-auto bg-muted/30">
|
||||
<TrialUrgencyBanner />
|
||||
<TrialValueUpsell />
|
||||
<div className="p-4 sm:p-6">
|
||||
<Outlet />
|
||||
</div>
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Button } from "@sase/ui";
|
||||
import { Skeleton } from "@sase/ui";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link, createFileRoute } from "@tanstack/react-router";
|
||||
import { ArrowRight, Download, Filter, HelpCircle, Receipt, ReceiptText } from "lucide-react";
|
||||
import { ArrowRight, Filter, HelpCircle, Receipt, ReceiptText } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
export const Route = createFileRoute("/dashboard/billing")({
|
||||
@@ -26,10 +26,11 @@ interface Subscription {
|
||||
interface Payment {
|
||||
id: string;
|
||||
amount: number;
|
||||
method: "stripe" | "eft";
|
||||
// Stripe is the sole method; kept as string so retired historical values
|
||||
// (iyzico/eft) still render in the history table.
|
||||
method: string;
|
||||
status: "completed" | "pending" | "failed" | "refunded";
|
||||
planName?: string | null;
|
||||
eftReceiptUrl?: string | null;
|
||||
hasStripeReceipt?: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
@@ -213,7 +214,7 @@ function BillingPage() {
|
||||
onChange={setMethodFilter}
|
||||
options={[
|
||||
{ value: "all", label: t("common.all") },
|
||||
...["stripe", "eft"].map((m) => ({
|
||||
...["stripe"].map((m) => ({
|
||||
value: m,
|
||||
label: t(`billing.methodLabels.${m}`),
|
||||
})),
|
||||
@@ -309,7 +310,9 @@ function BillingPage() {
|
||||
</td>
|
||||
<td data-label={t("billing.method")} className={`${TD} sm:text-center`}>
|
||||
<Badge variant={payment.method === "stripe" ? "secondary" : "outline"}>
|
||||
{t(`billing.methodLabels.${payment.method}`)}
|
||||
{payment.method === "stripe"
|
||||
? t("billing.methodLabels.stripe")
|
||||
: payment.method.toUpperCase()}
|
||||
</Badge>
|
||||
</td>
|
||||
<td data-label={t("billing.status")} className={`${TD} sm:text-center`}>
|
||||
@@ -329,19 +332,7 @@ function BillingPage() {
|
||||
</span>
|
||||
</td>
|
||||
<td className="block pt-3 text-right sm:table-cell sm:py-4 sm:pt-4">
|
||||
{payment.eftReceiptUrl ? (
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<a
|
||||
href={payment.eftReceiptUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
download
|
||||
>
|
||||
<Download className="mr-1 h-3 w-3" />
|
||||
{t("billing.downloadReceipt")}
|
||||
</a>
|
||||
</Button>
|
||||
) : payment.hasStripeReceipt ? (
|
||||
{payment.hasStripeReceipt ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
|
||||
373
apps/web/src/routes/dashboard/oem.$code.tsx
Normal file
373
apps/web/src/routes/dashboard/oem.$code.tsx
Normal file
@@ -0,0 +1,373 @@
|
||||
import { api } from "@/lib/api-client";
|
||||
import { capture } from "@/lib/posthog";
|
||||
import { Badge, Button, Input, Skeleton } from "@sase/ui";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link, createFileRoute } from "@tanstack/react-router";
|
||||
import {
|
||||
AlertCircle,
|
||||
ArrowLeft,
|
||||
Check,
|
||||
ChevronRight,
|
||||
Copy,
|
||||
ImageOff,
|
||||
PackageSearch,
|
||||
Search,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
// ─── Types (mirror apps/api POemResult) ────────────────────────────────
|
||||
|
||||
interface OeNumber {
|
||||
brand: string;
|
||||
code: string;
|
||||
}
|
||||
interface PArticle {
|
||||
id: string;
|
||||
brand: string;
|
||||
articleNumber: string;
|
||||
name: string | null;
|
||||
spareInfo: string | null;
|
||||
images: Array<{ url: string; thumb: string | null }>;
|
||||
}
|
||||
interface POemResult {
|
||||
query: string;
|
||||
queryNorm: string;
|
||||
matched: boolean;
|
||||
articles: PArticle[];
|
||||
aftermarketParts: Array<{ brand: string; articleNumber: string; thumb: string | null }>;
|
||||
oeCrossReferences: OeNumber[];
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
/** A decoded vehicle in the user's own catalog whose parts include this OEM
|
||||
* code (reverse catalog — sase data, independent of the P snapshot). */
|
||||
interface CatalogVehicle {
|
||||
vehicleId: string;
|
||||
brandName: string | null;
|
||||
model: string | null;
|
||||
year: number | null;
|
||||
categoryId: string;
|
||||
occurrences: number;
|
||||
}
|
||||
|
||||
/** Normalise a code for search: strip non-alphanumerics + uppercase, so a query
|
||||
* like "1j0 973" matches a stored "1J0 973 702". */
|
||||
const normCode = (s: string) => s.toUpperCase().replace(/[^A-Z0-9]/g, "");
|
||||
|
||||
// ─── Reusable copy-to-clipboard chip ────────────────────────────────────────
|
||||
|
||||
function CopyCode({ code, className }: { code: string; className?: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const timer = useRef<number | null>(null);
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (timer.current) window.clearTimeout(timer.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(code);
|
||||
setCopied(true);
|
||||
if (timer.current) window.clearTimeout(timer.current);
|
||||
timer.current = window.setTimeout(() => setCopied(false), 1500);
|
||||
capture("oem_xref_code_copied", { code });
|
||||
}}
|
||||
title="Kopyala"
|
||||
className={`group inline-flex items-center gap-1.5 font-mono text-xs ${className ?? ""}`}
|
||||
>
|
||||
<span>{code}</span>
|
||||
{copied ? (
|
||||
<Check className="size-3 text-green-500" />
|
||||
) : (
|
||||
<Copy className="size-3 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Image with graceful fallback ───────────────────────────────────────────
|
||||
|
||||
function PartThumb({ src, alt }: { src: string | null; alt: string }) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
if (!src || failed) {
|
||||
return (
|
||||
<div className="flex size-16 shrink-0 items-center justify-center rounded-lg bg-muted">
|
||||
<ImageOff className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
loading="lazy"
|
||||
onError={() => setFailed(true)}
|
||||
className="size-16 shrink-0 rounded-lg border border-border bg-white object-contain"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Route ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export const Route = createFileRoute("/dashboard/oem/$code")({
|
||||
component: OemDetailPage,
|
||||
});
|
||||
|
||||
function OemDetailPage() {
|
||||
const { code } = Route.useParams();
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ["p-oem", code],
|
||||
queryFn: () => api.get<POemResult>(`/p/oem?code=${encodeURIComponent(code)}`),
|
||||
});
|
||||
|
||||
// Reverse catalog: the user's own vehicles that use this OEM code (sase data).
|
||||
const { data: catalogVehicles } = useQuery({
|
||||
queryKey: ["oem-vehicles", code],
|
||||
queryFn: () =>
|
||||
api.get<CatalogVehicle[]>(`/parts/oem-vehicles?code=${encodeURIComponent(code)}`),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
capture("oem_detail_viewed", {
|
||||
oem_code: code,
|
||||
matched: data.matched,
|
||||
article_count: data.articles.length,
|
||||
aftermarket_count: data.aftermarketParts.length,
|
||||
oe_xref_count: data.oeCrossReferences.length,
|
||||
});
|
||||
}
|
||||
}, [data, code]);
|
||||
|
||||
// ─── Brand/code filter ──────────────────────────────────────────────────
|
||||
const [q, setQ] = useState("");
|
||||
const ql = q.trim().toLowerCase();
|
||||
const qn = normCode(q.trim());
|
||||
const hit = useCallback(
|
||||
(brand: string, codeVal: string) =>
|
||||
!ql || brand.toLowerCase().includes(ql) || (qn.length > 0 && normCode(codeVal).includes(qn)),
|
||||
[ql, qn],
|
||||
);
|
||||
|
||||
const filteredArticles = useMemo(
|
||||
() => (data?.matched ? data.articles.filter((a) => hit(a.brand, a.articleNumber)) : []),
|
||||
[data, hit],
|
||||
);
|
||||
const filteredAftermarket = useMemo(
|
||||
() => (data?.matched ? data.aftermarketParts.filter((p) => hit(p.brand, p.articleNumber)) : []),
|
||||
[data, hit],
|
||||
);
|
||||
const filteredOe = useMemo(
|
||||
() => (data?.matched ? data.oeCrossReferences.filter((oe) => hit(oe.brand, oe.code)) : []),
|
||||
[data, hit],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl space-y-6">
|
||||
{/* ─── Header ─────────────────────────────────────────────────────── */}
|
||||
<header className="flex items-start gap-4">
|
||||
<Button asChild variant="ghost" size="icon" className="mt-0.5 shrink-0">
|
||||
<Link to="/dashboard/search" aria-label="Geri">
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
</Button>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
OEM kodu
|
||||
</p>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-3">
|
||||
<h1 className="font-mono text-2xl font-bold tracking-tight break-all">{code}</h1>
|
||||
<CopyCode
|
||||
code={code}
|
||||
className="rounded-md border border-border px-2 py-1 hover:bg-accent"
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Uyumlu parça kodları ve muadil numaralar
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* ─── Loading ────────────────────────────────────────────────────── */}
|
||||
{isLoading && (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-24 w-full rounded-xl" />
|
||||
<Skeleton className="h-40 w-full rounded-xl" />
|
||||
<Skeleton className="h-28 w-full rounded-xl" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ─── Error ──────────────────────────────────────────────────────── */}
|
||||
{error && (
|
||||
<div className="flex items-start gap-3 rounded-xl border border-destructive/40 bg-destructive/10 p-4">
|
||||
<AlertCircle className="mt-0.5 size-5 shrink-0 text-destructive" />
|
||||
<p className="text-sm text-destructive">
|
||||
Uyumlu parça bilgisi yüklenemedi. Lütfen tekrar deneyin.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ─── Empty state (no P match) ──────────────────────────────── */}
|
||||
{data && !data.matched && (
|
||||
<div className="flex flex-col items-center gap-3 rounded-2xl border border-border bg-background px-6 py-12 text-center">
|
||||
<div className="flex size-12 items-center justify-center rounded-xl bg-muted">
|
||||
<PackageSearch className="size-6 text-muted-foreground" />
|
||||
</div>
|
||||
<p className="font-medium">Bu OEM kodu için uyumlu parça bulunamadı</p>
|
||||
<p className="max-w-md text-sm text-muted-foreground">
|
||||
Bağlantı parçaları, klipsler ve bazı orijinal kodların muadili henüz kataloğumuzda
|
||||
olmayabilir. Katalog büyüdükçe eşleşme oranı artar.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ─── Results ────────────────────────────────────────────────────── */}
|
||||
{data?.matched && (
|
||||
<div className="space-y-6">
|
||||
{/* Summary */}
|
||||
<div className="flex flex-wrap gap-2 text-sm">
|
||||
<Badge variant="secondary">{data.articles.length} eşleşen parça</Badge>
|
||||
<Badge variant="secondary">{data.aftermarketParts.length} yan sanayi numarası</Badge>
|
||||
<Badge variant="secondary">{data.oeCrossReferences.length} muadil OE kodu</Badge>
|
||||
{data.truncated && (
|
||||
<Badge variant="outline" className="text-muted-foreground">
|
||||
liste kısaltıldı
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filter by brand or code (matches across all sections) */}
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
placeholder="Marka veya kod ara…"
|
||||
className="pl-9 pr-9"
|
||||
/>
|
||||
{q && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setQ("")}
|
||||
aria-label="Aramayı temizle"
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 rounded p-0.5 text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Matched P articles (highest-confidence: carry this OEM directly) */}
|
||||
{filteredArticles.length > 0 && (
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-sm font-semibold">Bu OEM'i taşıyan parçalar</h2>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{filteredArticles.map((a) => (
|
||||
<div
|
||||
key={a.id}
|
||||
className="flex gap-3 rounded-xl border border-border bg-background p-3"
|
||||
>
|
||||
{a.images[0] && (
|
||||
<PartThumb src={a.images[0].thumb ?? a.images[0].url} alt={a.brand} />
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-semibold">{a.brand}</p>
|
||||
<CopyCode code={a.articleNumber} className="mt-0.5" />
|
||||
{a.name && a.name !== a.articleNumber && (
|
||||
<p className="mt-0.5 truncate text-xs text-muted-foreground">{a.name}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Aftermarket equivalents (buyable substitutes across supplier brands) */}
|
||||
{filteredAftermarket.length > 0 && (
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-sm font-semibold">Yan sanayi muadilleri</h2>
|
||||
<div className="overflow-hidden rounded-xl border border-border">
|
||||
<table className="w-full text-sm">
|
||||
<tbody className="divide-y divide-border">
|
||||
{filteredAftermarket.map((p) => (
|
||||
<tr key={`${p.brand}-${p.articleNumber}`} className="hover:bg-accent/50">
|
||||
<td className="px-4 py-2 font-medium">{p.brand}</td>
|
||||
<td className="px-4 py-2 text-right">
|
||||
<CopyCode code={p.articleNumber} className="hover:text-foreground" />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* OE cross-references (same physical part, other vehicle makes) */}
|
||||
{filteredOe.length > 0 && (
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-sm font-semibold">Muadil orijinal (OE) kodları</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{filteredOe.map((oe) => (
|
||||
<span
|
||||
key={`${oe.brand}-${oe.code}`}
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-border bg-background px-2.5 py-1.5"
|
||||
>
|
||||
<span className="text-xs font-medium text-muted-foreground">{oe.brand}</span>
|
||||
<CopyCode code={oe.code} className="hover:text-foreground" />
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Nothing matches the active filter */}
|
||||
{ql &&
|
||||
filteredArticles.length === 0 &&
|
||||
filteredAftermarket.length === 0 &&
|
||||
filteredOe.length === 0 && (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||
"{q}" ile eşleşen marka veya kod yok.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ─── Reverse catalog: your vehicles that use this code ───────────── */}
|
||||
{catalogVehicles && catalogVehicles.length > 0 && (
|
||||
<section className="space-y-3">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold">Bu kod kataloğunuzda</h2>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
Bu OEM kodunun geçtiği araçlarınız
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{catalogVehicles.map((v) => (
|
||||
<Link
|
||||
key={v.vehicleId}
|
||||
to="/dashboard/vehicles/$id/categories/$categoryId"
|
||||
params={{ id: v.vehicleId, categoryId: v.categoryId }}
|
||||
className="flex items-center justify-between gap-2 rounded-xl border border-border bg-background p-3 transition-colors hover:bg-accent"
|
||||
>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-sm font-medium">
|
||||
{[v.brandName, v.model].filter(Boolean).join(" ") || "Araç"}
|
||||
</span>
|
||||
{v.year && <span className="text-xs text-muted-foreground">{v.year}</span>}
|
||||
</span>
|
||||
<ChevronRight className="size-4 shrink-0 text-muted-foreground" />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,7 @@
|
||||
import {
|
||||
ReportCatalogIssueButton,
|
||||
useReportCatalogDegradation,
|
||||
} from "@/components/catalog/report-catalog-issue";
|
||||
import { CategoryBreadcrumb } from "@/components/categories/category-breadcrumb";
|
||||
import { CategoryColumns } from "@/components/categories/category-columns";
|
||||
import { CategoryGrid } from "@/components/categories/category-grid";
|
||||
@@ -61,7 +65,9 @@ function VehicleCategoryPage() {
|
||||
const { data: vehicle } = useQuery({
|
||||
queryKey: ["vehicle", id],
|
||||
queryFn: () =>
|
||||
api.get<{ brandName?: string; model?: string; year?: number }>(`/vehicles/${id}`),
|
||||
api.get<{ brandName?: string; model?: string; year?: number; source?: string; vin?: string }>(
|
||||
`/vehicles/${id}`,
|
||||
),
|
||||
enabled: !!id,
|
||||
});
|
||||
|
||||
@@ -97,6 +103,17 @@ function VehicleCategoryPage() {
|
||||
? `${vehicle.brandName}${cleanModel ? ` ${cleanModel}` : ""}`
|
||||
: t("vehicle.title");
|
||||
|
||||
// A loadError = the drill/parts fetch failed and the user sees a "couldn't load"
|
||||
// panel instead of parts — report it (with a session replay) so it surfaces.
|
||||
useReportCatalogDegradation("drill-load-error", !!data?.loadError, {
|
||||
vehicleId: id,
|
||||
categoryId,
|
||||
categoryName: data?.name,
|
||||
vehicleLabel,
|
||||
source: vehicle?.source,
|
||||
vin: vehicle?.vin,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<CategoryBreadcrumb
|
||||
@@ -188,16 +205,28 @@ function VehicleCategoryPage() {
|
||||
<p className="font-medium text-destructive">{t("vehicle.catalogUnavailableTitle")}</p>
|
||||
<p className="mt-1 text-muted-foreground">{t("vehicle.catalogUnavailableHint")}</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => refetch()}
|
||||
disabled={isFetching}
|
||||
data-faro-user-action-name="category-loaderror-retry"
|
||||
>
|
||||
{isFetching ? t("common.loading") : t("vehicle.retry")}
|
||||
</Button>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => refetch()}
|
||||
disabled={isFetching}
|
||||
data-faro-user-action-name="category-loaderror-retry"
|
||||
>
|
||||
{isFetching ? t("common.loading") : t("vehicle.retry")}
|
||||
</Button>
|
||||
<ReportCatalogIssueButton
|
||||
ctx={{
|
||||
vehicleId: id,
|
||||
categoryId,
|
||||
categoryName: data?.name,
|
||||
vehicleLabel,
|
||||
source: vehicle?.source,
|
||||
vin: vehicle?.vin,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Suspense fallback={<SchemaViewerFallback />}>
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import {
|
||||
ReportCatalogIssueButton,
|
||||
useReportCatalogDegradation,
|
||||
} from "@/components/catalog/report-catalog-issue";
|
||||
import { CatalogSearch } from "@/components/categories/catalog-search";
|
||||
import { CategoryBreadcrumb } from "@/components/categories/category-breadcrumb";
|
||||
import { CategoryColumns } from "@/components/categories/category-columns";
|
||||
@@ -105,6 +109,16 @@ function VehicleDetailPage() {
|
||||
? `${vehicle.brandName}${cleanModelName(vehicle.model) ? ` ${cleanModelName(vehicle.model)}` : ""}`
|
||||
: t("vehicle.title");
|
||||
|
||||
// An empty category tree on a decoded vehicle is a silent failure ("model var,
|
||||
// kategori yok") — report it to Sentry with a session replay so it surfaces.
|
||||
const treeEmpty = !categoriesLoading && Array.isArray(categoryTree) && categoryTree.length === 0;
|
||||
useReportCatalogDegradation("empty-tree", treeEmpty, {
|
||||
vehicleId: id,
|
||||
vehicleLabel,
|
||||
source: vehicle?.source,
|
||||
vin: vehicle?.vin,
|
||||
});
|
||||
|
||||
// Surface the viewed vehicle to the support chat widget (VIN/brand/model)
|
||||
// so agents have the car context for part-compatibility questions.
|
||||
useEffect(() => {
|
||||
@@ -344,6 +358,16 @@ function VehicleDetailPage() {
|
||||
<p className="mx-auto mt-1 max-w-md text-xs text-muted-foreground">
|
||||
{t("vehicle.noCategoriesHint")}
|
||||
</p>
|
||||
<div className="mt-3 flex justify-center">
|
||||
<ReportCatalogIssueButton
|
||||
ctx={{
|
||||
vehicleId: id,
|
||||
vehicleLabel,
|
||||
source: vehicle?.source,
|
||||
vin: vehicle?.vin,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : viewMode === "grid" ? (
|
||||
<CategoryGrid categories={categoryTree} vehicleId={id} hideFilter />
|
||||
|
||||
@@ -100,6 +100,10 @@ services:
|
||||
# memory: catalog-wide bridge had 7-114x noise; only add a catalog once
|
||||
# its per-vehicle bridge is wired & OEM-verified.
|
||||
- EMEX_SOURCE_DB_ALLOWED_CATALOGS=${EMEX_SOURCE_DB_ALLOWED_CATALOGS:-}
|
||||
# P snapshot lookup (db `td`) backing the OEM detail page. Off until
|
||||
# both vars are set in Coolify; see sase-prod-db-api-access memory.
|
||||
- P_DB_ENABLED=${P_DB_ENABLED:-false}
|
||||
- P_DB_URL=${P_DB_URL:-}
|
||||
# Central Directus CMS (Süper Panel project on Coolify). Prod and staging
|
||||
# point at the SAME instance so blog content is shared across envs.
|
||||
# Internal docker-network URL — both stacks join the `coolify` network.
|
||||
|
||||
@@ -19,7 +19,6 @@ export const ERROR_CODES = {
|
||||
|
||||
// Payment
|
||||
PAYMENT_FAILED: "PAY_001",
|
||||
EFT_RECEIPT_REQUIRED: "PAY_003",
|
||||
PAYMENT_ALREADY_PROCESSED: "PAY_004",
|
||||
|
||||
// General
|
||||
|
||||
@@ -896,7 +896,6 @@ describe("ERROR_CODES", () => {
|
||||
|
||||
it("has payment error codes", () => {
|
||||
expect(ERROR_CODES.PAYMENT_FAILED).toBe("PAY_001");
|
||||
expect(ERROR_CODES.EFT_RECEIPT_REQUIRED).toBe("PAY_003");
|
||||
expect(ERROR_CODES.PAYMENT_ALREADY_PROCESSED).toBe("PAY_004");
|
||||
});
|
||||
|
||||
@@ -927,6 +926,7 @@ describe("getBrandFromWmi — RCA Q5 additions", () => {
|
||||
["LSV", "Volkswagen"],
|
||||
["LVV", "Chery"],
|
||||
["ZCF", "Iveco"],
|
||||
["VXK", "Opel"], // PSA/Stellantis-platform Opel (Corsa F 2023 customer case, 2026-06-10)
|
||||
];
|
||||
it.each(ADDED)("maps %s -> %s", (wmi, brand) => {
|
||||
expect(getBrandFromWmi(wmi)).toBe(brand);
|
||||
|
||||
@@ -15,7 +15,6 @@ export type {
|
||||
Payment,
|
||||
PaymentMethod,
|
||||
PaymentStatus,
|
||||
EftPaymentInput,
|
||||
} from "./types/payment.js";
|
||||
export type { ApiResponse, ApiError, PaginationMeta } from "./types/api-response.js";
|
||||
export type { PaginationInput, PaginatedResult } from "./types/pagination.js";
|
||||
|
||||
@@ -6,15 +6,12 @@ export interface Payment {
|
||||
currency: string;
|
||||
method: PaymentMethod;
|
||||
status: PaymentStatus;
|
||||
eftReceiptUrl: string | null;
|
||||
adminNote: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export type PaymentMethod = "stripe" | "eft";
|
||||
// Stripe is the sole payment method. "iyzico"/"eft" are retired methods kept
|
||||
// only as historical values in the DB `method` column — never offered to users.
|
||||
export type PaymentMethod = "stripe";
|
||||
export type PaymentStatus = "pending" | "completed" | "failed" | "refunded";
|
||||
|
||||
export interface EftPaymentInput {
|
||||
subscriptionId: string;
|
||||
}
|
||||
|
||||
@@ -118,6 +118,7 @@ export const WMI_BRAND_MAP: Record<string, string> = {
|
||||
// Opel
|
||||
W0L: "Opel",
|
||||
W0V: "Opel", // newer Stellantis-era WMI
|
||||
VXK: "Opel", // PSA/Stellantis-platform Opel (Corsa F, Mokka B — France/Spain plants)
|
||||
// Skoda
|
||||
TMB: "Skoda",
|
||||
// Seat
|
||||
|
||||
66
pnpm-lock.yaml
generated
66
pnpm-lock.yaml
generated
@@ -225,7 +225,7 @@ importers:
|
||||
version: 1.159.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
better-auth:
|
||||
specifier: ^1.2.0
|
||||
version: 1.4.18(drizzle-kit@0.31.9)(drizzle-orm@0.41.0(@opentelemetry/api@1.9.1)(@types/pg@8.15.6)(gel@2.2.0)(kysely@0.28.11)(mysql2@3.22.4(@types/node@22.19.11))(postgres@3.4.8))(mysql2@3.22.4(@types/node@22.19.11))(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.11)(jiti@2.6.1)(jsdom@28.0.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
|
||||
version: 1.4.18(drizzle-kit@0.31.9)(drizzle-orm@0.41.0(@opentelemetry/api@1.9.0)(@types/pg@8.15.6)(gel@2.2.0)(kysely@0.28.11)(mysql2@3.22.4(@types/node@22.19.11))(postgres@3.4.8))(mysql2@3.22.4(@types/node@22.19.11))(next@16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.11)(jiti@2.6.1)(jsdom@28.0.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
|
||||
canvas-confetti:
|
||||
specifier: ^1.9.4
|
||||
version: 1.9.4
|
||||
@@ -10176,30 +10176,7 @@ snapshots:
|
||||
drizzle-kit: 0.31.9
|
||||
drizzle-orm: 0.41.0(@opentelemetry/api@1.9.0)(@types/pg@8.15.6)(gel@2.2.0)(kysely@0.28.11)(mysql2@3.22.4(@types/node@22.19.11))(postgres@3.4.8)
|
||||
mysql2: 3.22.4(@types/node@22.19.11)
|
||||
next: 16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
vitest: 3.2.4(@types/debug@4.1.13)(@types/node@22.19.11)(jiti@2.6.1)(jsdom@28.0.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
|
||||
|
||||
better-auth@1.4.18(drizzle-kit@0.31.9)(drizzle-orm@0.41.0(@opentelemetry/api@1.9.1)(@types/pg@8.15.6)(gel@2.2.0)(kysely@0.28.11)(mysql2@3.22.4(@types/node@22.19.11))(postgres@3.4.8))(mysql2@3.22.4(@types/node@22.19.11))(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.11)(jiti@2.6.1)(jsdom@28.0.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)):
|
||||
dependencies:
|
||||
'@better-auth/core': 1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@3.25.76))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0)
|
||||
'@better-auth/telemetry': 1.4.18(@better-auth/core@1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@3.25.76))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0))
|
||||
'@better-auth/utils': 0.3.0
|
||||
'@better-fetch/fetch': 1.1.21
|
||||
'@noble/ciphers': 2.1.1
|
||||
'@noble/hashes': 2.0.1
|
||||
better-call: 1.1.8(zod@4.3.6)
|
||||
defu: 6.1.4
|
||||
jose: 6.1.3
|
||||
kysely: 0.28.11
|
||||
nanostores: 1.1.0
|
||||
zod: 4.3.6
|
||||
optionalDependencies:
|
||||
drizzle-kit: 0.31.9
|
||||
drizzle-orm: 0.41.0(@opentelemetry/api@1.9.1)(@types/pg@8.15.6)(gel@2.2.0)(kysely@0.28.11)(mysql2@3.22.4(@types/node@22.19.11))(postgres@3.4.8)
|
||||
mysql2: 3.22.4(@types/node@22.19.11)
|
||||
next: 16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
next: 16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
vitest: 3.2.4(@types/debug@4.1.13)(@types/node@22.19.11)(jiti@2.6.1)(jsdom@28.0.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
|
||||
@@ -10632,16 +10609,6 @@ snapshots:
|
||||
mysql2: 3.22.4(@types/node@22.19.11)
|
||||
postgres: 3.4.8
|
||||
|
||||
drizzle-orm@0.41.0(@opentelemetry/api@1.9.1)(@types/pg@8.15.6)(gel@2.2.0)(kysely@0.28.11)(mysql2@3.22.4(@types/node@22.19.11))(postgres@3.4.8):
|
||||
optionalDependencies:
|
||||
'@opentelemetry/api': 1.9.1
|
||||
'@types/pg': 8.15.6
|
||||
gel: 2.2.0
|
||||
kysely: 0.28.11
|
||||
mysql2: 3.22.4(@types/node@22.19.11)
|
||||
postgres: 3.4.8
|
||||
optional: true
|
||||
|
||||
dunder-proto@1.0.1:
|
||||
dependencies:
|
||||
call-bind-apply-helpers: 1.0.2
|
||||
@@ -11893,34 +11860,7 @@ snapshots:
|
||||
|
||||
neo-async@2.6.2: {}
|
||||
|
||||
next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
|
||||
dependencies:
|
||||
'@next/env': 16.1.6
|
||||
'@swc/helpers': 0.5.15
|
||||
baseline-browser-mapping: 2.9.19
|
||||
caniuse-lite: 1.0.30001769
|
||||
postcss: 8.4.31
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
styled-jsx: 5.1.6(@babel/core@7.29.0)(react@19.2.4)
|
||||
optionalDependencies:
|
||||
'@next/swc-darwin-arm64': 16.1.6
|
||||
'@next/swc-darwin-x64': 16.1.6
|
||||
'@next/swc-linux-arm64-gnu': 16.1.6
|
||||
'@next/swc-linux-arm64-musl': 16.1.6
|
||||
'@next/swc-linux-x64-gnu': 16.1.6
|
||||
'@next/swc-linux-x64-musl': 16.1.6
|
||||
'@next/swc-win32-arm64-msvc': 16.1.6
|
||||
'@next/swc-win32-x64-msvc': 16.1.6
|
||||
'@opentelemetry/api': 1.9.1
|
||||
'@playwright/test': 1.58.2
|
||||
sharp: 0.34.5
|
||||
transitivePeerDependencies:
|
||||
- '@babel/core'
|
||||
- babel-plugin-macros
|
||||
optional: true
|
||||
|
||||
next@16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
|
||||
next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
|
||||
dependencies:
|
||||
'@next/env': 16.1.6
|
||||
'@swc/helpers': 0.5.15
|
||||
|
||||
Reference in New Issue
Block a user