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

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:
2026-06-11 16:25:57 +03:00
47 changed files with 2891 additions and 760 deletions

127
PROJECT_INDEX.json Normal file
View 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 (00000013) + 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
View 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`

View 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;

View File

@@ -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
}
]
}

View File

@@ -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,

View File

@@ -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,

View File

@@ -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(),

View File

@@ -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

View 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;
}
}
}

View 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 : []) };
}
}

View 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 {}

View 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;
}

View File

@@ -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)

View File

@@ -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);

View File

@@ -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);
}
}

View File

@@ -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) };
}

View File

@@ -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,

View File

@@ -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();
});
});
});

View File

@@ -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") {

View File

@@ -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;
}
}
});

View File

@@ -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)", () => {

View File

@@ -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;

View 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>
);
}

View File

@@ -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>

View 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 = 010 → 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>
);
}

View 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>
);
}

View 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");
});
});

View File

@@ -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();
}
/**

View File

@@ -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
View 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();
}

View File

@@ -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?",

View File

@@ -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

View File

@@ -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" />
</>
);

View File

@@ -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>

View File

@@ -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>

View File

@@ -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"

View 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>
);
}

View File

@@ -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 />}>

View File

@@ -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 />

View File

@@ -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.

View File

@@ -19,7 +19,6 @@ export const ERROR_CODES = {
// Payment
PAYMENT_FAILED: "PAY_001",
EFT_RECEIPT_REQUIRED: "PAY_003",
PAYMENT_ALREADY_PROCESSED: "PAY_004",
// General

View File

@@ -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);

View File

@@ -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";

View File

@@ -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;
}

View File

@@ -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
View File

@@ -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