Commit Graph

636 Commits

Author SHA1 Message Date
6177f080b2 Merge pull request 'dev' (#77) from dev into main
Reviewed-on: #77
2026-06-01 21:38:13 +00:00
5e9d9050b9 fix(pl24): stop .action shadowing PSA illustration/parts dispatch
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
isP4LegacyPath matched any ".action" path, shadowing the PSA
json-illustrations.action / image-board.action dispatch in
fetchSubGroupsByPath and fetchPartsByPath. Every PSA (Citroen/Peugeot/DS)
drill below main-group level fell through to the Ford/Fiat legacy parser,
which cannot parse PSA JSON, so it returned empty. PSA vehicles decoded
since the catalog module landed (72c0de6) showed categories but 0 parts
(41/43 affected). Exclude /psa/ from isP4LegacyPath so these paths reach
fetchPsaIllustrations / fetchPsaParts. Heals existing vehicles on demand;
no re-decode needed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 00:28:41 +03:00
10327b514c style: biome format — single-line p tags in demo footer CTAs 2026-06-02 00:27:19 +03:00
a43c3492c5 fix(demo): B2B copy + mobile-friendly footer CTA layout
Founder caught two issues on the live /demo page:

1) Copy was framed as B2C ("Kendi aracınız için sınırsız erişim") but
   sase.tr's audience is parts shops / garages / e-commerce sellers — they
   decode 5-30 VINs/day for their customers, not for their own car. Rewrote
   banner + both footer CTAs to frame conversion around "sınırsız şase
   sorgulamak için ücretsiz hesap aç" — that's the actual unit they pay for.

2) Footer "Hesap Aç" button sat awkwardly on mobile: arbitrary-width inline
   element flex-stacked under the headline text, overlapping the Chatwoot
   widget in the bottom-right. Made the button `w-full sm:w-auto` so it
   reads as a clear primary action on mobile, and added `mb-20 sm:mb-0` to
   the card to clear the Chatwoot widget on small screens.
2026-06-02 00:27:01 +03:00
1608da9319 feat(landing): hero Ara → /demo for empty input or example VIN match
Real visitors were repeatedly clicking the hero "Ara" button while only the
placeholder VIN (WVWZZZ1JZ3W597935) was showing — they mistook the example
for typed text. The click hit handleVinSearch's `if (!trimmed) return`
early-out and did nothing, leaving them stuck.

Now: empty input OR a value that exactly matches the example VIN routes to
/demo (the pre-warmed VW Golf 2003 catalog) instead of /register. The button
label also updates to "Örnek aracı dene" in that state so the outcome is
predictable before the click. Any other typed VIN still goes through the
existing /register?vin= flow unchanged.

PostHog: emits hero_ara_demo_redirect with {had_value, was_example_match}
so we can split the funnel by what triggered the demo entry.
2026-06-02 00:17:40 +03:00
078076b619 feat(demo): public /demo namespace serving pre-warmed VW Golf 2003 catalog
Replaces the old marketing "guided tour" /demo with a real, fully-functional
catalog browsing experience for the pre-warmed example vehicle. No auth
required, no upstream calls — entirely served from prod DB.

Backend (apps/api/src/demo):
* New @Public() controller exposing five endpoints under /api/demo:
  - GET /vehicle                    → demo vehicle metadata
  - GET /categories/tree            → top-level category tree
  - GET /categories/search?q=       → cross-tree search
  - GET /categories/:id             → getCategoryWithParts (parts+schema+hotspots)
  - GET /categories/:id/children    → drill children
* DemoService validates every category id against DEMO_VEHICLE_ID before any
  downstream service call — the public surface can't be used to read an
  arbitrary vehicle's catalog (1-row SELECT, NotFound on miss or wrong owner).
* Vehicle id is env-driven (DEMO_VEHICLE_ID, defaults to the pre-warmed
  WVWZZZ1JZ3W597935 — VW Golf 2003 with 277 cats / 9841 parts / 178 schemas
  fully drilled in prod).
* Wires CategoriesModule (already exports CategoriesService) — zero new
  business logic, just a thin public façade.

Frontend (apps/web):
* /demo (replaces old marketing page): vehicle header + top categories grid
  reading /api/demo/* + sticky DemoBanner with sign-up CTA.
* /demo/categories/$categoryId: drill page rendering either a children grid
  (parent) or the existing SchemaViewer + parts panel (leaf) — same shape
  the dashboard uses, so hotspot overlay, breadcrumb trail, retry on
  upstream loadError all just work.
* DemoBanner: sticky top, "Örnek araç: {label} — Kayıt Ol" CTA. The
  "Yeni VIN sorgula" explicit paywall trigger lands in a follow-up task.
* PostHog events: demo_loaded (source query-param-aware),
  demo_category_clicked, demo_category_detail_viewed, demo_to_register_click
  (banner / footer / category_footer placements).
* usePageMeta gains an opt-in `noindex` flag — demo sets it to noindex,follow
  for the first 4-6 weeks per spec; cleaned up on unmount so SPA navigation
  doesn't carry it to the next route.
2026-06-02 00:07:29 +03:00
ca0c18924d Merge pull request 'fix(pcat-auth): two-armed post-goto poll — fail-fast on dead sites' (#75) from dev into main
Reviewed-on: #75
2026-06-01 19:18:06 +00:00
286307155e fix(pcat-auth): two-armed post-goto poll — fail-fast on dead sites
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
The post-goto token poll ran a blind 20s wait regardless of whether
page.goto succeeded or threw. On a healthy goto the widget API call
fires within ~1-2s; on a failed goto the request either already went
through (rare) or never will (common). The 20s cap was the dominant
cost on failed-site attempts — verified tonight as a 28s "No token
after ..." log on auto-komplekt after page.goto ERR_TIMED_OUT.

* CAPTURE_POLL_AFTER_OK   = 10 (5s)  — token usually arrives in <2s
* CAPTURE_POLL_AFTER_FAIL = 4  (2s)  — brief grace then bail

Per-attempt worst case on a dead site: 10s goto + 2s grace = 12s
(was 10s + 20s = 30s). On a healthy site, well-known capture times
(3-5s) stay comfortably inside the 5s post-goto cap.
2026-06-01 22:17:41 +03:00
ec5cfae4e6 Merge pull request 'dev' (#74) from dev into main
Reviewed-on: #74
2026-06-01 19:10:03 +00:00
dcf7e068c9 feat(pcat-auth): cache JWT slot in Redis (PL24-style cross-restart hydration)
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
A captured slot now lives in Redis under `pcat:jwt:slot` for the JWT's
remaining lifetime (minus a 60s safety buffer). On module init we try
Redis before launching Playwright — if a fresh slot is there we adopt it
and schedule its refresh, skipping the ~5s capture entirely. After every
successful capture+validation we publish to Redis so the next restart (or
any sibling pod) can inherit. invalidateSession deletes the Redis copy
because a 401/403 means the cached IP-binding is dead.

Token is still IP-bound to its proxyPort. If a hydrating container reads
the slot but the proxy has rotated away from the captured IP, the next
upstream call 401s and the existing invalidateSession fallback re-captures
locally — so worst case = today's cold-capture behavior, never worse.

Note: dev and prod use separate Redis instances. This patch reaches PL24
parity (same-env redeploy hydration); a true dev↔prod shared cache would
need either an external Redis or an internal-token bridge.
2026-06-01 22:08:45 +03:00
30be00465b fix(pcat-auth): cap concurrent captures at JWT_SITES.length, not 3
Concurrent captures are bound by how many distinct partner sites we can
drive in parallel — each capture needs its own site so they don't collide
on the synchronous siteIndex round-robin. Ports are effectively unlimited
(10000-10999) and Playwright contexts are isolated, so the previous
arbitrary cap of 3 left headroom on the table when the pool is cold and
N>3 user clicks race in at once.

Tying the cap to JWT_SITES.length also means future site additions or
removals auto-adjust the ceiling.
2026-06-01 19:58:35 +03:00
9627e58cdd fix(pcat-auth): allow 3 concurrent captures + validate slots upstream
Two follow-ups to the warm-pool patch:

* Semaphore 1 → 3. Playwright contexts are isolated, the captureToPool
  site/port allocation is synchronous (no race), and concurrent user
  clicks that all miss the pool no longer serialize behind a single
  ~5s capture. Peak memory grows from one context to three; each is
  short-lived.

* Capture-time validation. After Playwright extracts the JWT, do one
  cheap upstream call (/car/info with the public demo VIN) through the
  same proxy port before pushing the slot to the pool. DataImpulse
  occasionally rotates to IPs the partner widget can load but the
  upstream API can't reach, or that get instantly 401/403'd; those
  ports used to spend 30s timing out on the first real user click.
  Failures rotate to the next site within the existing 4-retry budget.

Adds ~1s to each successful capture; saves up to 30s per dead slot.
2026-06-01 19:50:19 +03:00
17293ae218 Merge pull request 'dev' (#72) from dev into main
Reviewed-on: #72
2026-06-01 16:33:50 +00:00
a4644463e4 fix(pcat-auth): keep JWT pool warm 24/7 + tighten capture timeouts
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
The pool was gated to 09:00-19:00 Istanbul so off-hours the slot list was
empty and every first user click paid a 30-120s cold-start tax (Playwright
nav + retries across 4 sites). On the dashboard category page this looked
like an infinite skeleton loader. Pool refresh is cheap (~160 captures/day
per slot) — keep it warm always.

* isBusinessHours() now returns true; scheduleBusinessHours() is a no-op
  stub so the onModuleInit call site and businessHoursTimer field stay
  valid. All 8 existing gates (initial capture, replacement, refresh
  scheduling, refresh skip, scaling) become unconditional.
* getIstanbulTime() dropped — last consumer gone.
* PAGE_TIMEOUT 30s → 10s. Healthy partner sites load in <5s through the
  DataImpulse proxy; the longer wait only stretched dead-port retries.
* Drop e-acca.com from JWT_SITES — the current DataImpulse rotating proxy
  (74.81.81.81:10000-10999) cannot reach it; page.goto always blocked
  until PAGE_TIMEOUT instead of failing fast like the other sites.

Worst-case capture wall-clock: ~120s (4 × 30s) → ~40s (4 × 10s).
First off-hours request, with warm pool: ~120s+ → instant.
2026-06-01 19:26:44 +03:00
d6602a3e51 fix(config): preprocess empty env strings to undefined for .url() fields
PCAT_SOURCE_DB_URL and EMEX_SOURCE_DB_URL are declared as
`z.string().url().optional()` (and `.optional()` resp). docker-compose's
`${VAR:-}` substitution ALWAYS sets the env var, even to "", so when the
Coolify env is unset the container receives PCAT_SOURCE_DB_URL="".
zod's `.optional()` only accepts undefined, so `.url()` then rejects ""
and the api crashes on boot with "Invalid url".

This is exactly what took prod down on commit 939e4dc — the dev→main
merge brought in the catalog-source env schema without the empty-string
preprocess. Hotfixed by setting the env to a dummy URL via Coolify DB;
this patch makes the schema resilient permanently so future env edits
that clear the value won't recrash boot.

Apply a preprocess that maps empty/whitespace strings to undefined
before the URL check fires. Mirror the same treatment on EMEX so it can
also be unset without surprises.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 19:05:34 +03:00
4a5d200e42 Merge pull request 'dev' (#71) from dev into main
Reviewed-on: #71
2026-06-01 15:52:42 +00:00
3613caa072 fix(catalog-source): gate emex parts behind allowlist (safety)
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
The catalog-wide bridge in EmexSourceDbService.fetchCategoryParts was
measured against vehicle_parts on 2026-06-01 and found to return 7-114x
more parts than belong to the requesting vehicle, with 49-98 wrong OEM
codes per 100 served. That directly violates the project rule that the
user must never see a wrong OEM.

Per-catalog noiseRatio sample (catalog-wide / per-vehicle):
  RENAULT201910 51x | FFIAT84 45x | VOLVO201410 24x | MB201810 14x
  AU1587 8x | BMW202501 70x (+ gid namespace mismatch ETK vs numeric)
  GM_C201809 114x | MINI202501 12x | LRE201412 7x | MAZDA2020 54x
  GM_OP201809 dump has only 1 wildcard vehicle (unique_key="_") so the
  single Crossland X "owns" all 47k Opel parts — same firehose served
  to any Opel sub-model in sase prod.

All alternative bridges were proven dead:
  SSD eşleştirme        - session-bound, 0/91 sase SSDs match dump
  scrape_queue_v2.vehicle_ssd - same session SSD format
  api_cache replay      - table empty (0 rows)
  wizard_parameters     - table empty (0 rows)
  VIN direct            - no VIN column in dump
The only viable per-vehicle bridge is vehicles.unique_key reconstruction
from raw_data.parsedOptions, but sase currently stores the required 4
wizard fields on just 5/103 emex vehicles (all Renault). That work is
follow-up; this patch only stops the bleeding.

Change:
- Add EMEX_SOURCE_DB_ALLOWED_CATALOGS env (comma-separated, default "")
- EmexSourceDbService.fetchCategoryParts returns null unless catalogCode
  is in the allowlist. Empty allowlist = service is effectively off for
  parts, full fallthrough to live emex.
- Connection pool stays alive so the follow-up per-vehicle bridge /
  schema-only path can use it without flipping env.
- Boot logs warn loudly when connected with an empty allowlist.

Prod was never affected — CATALOG_SOURCE_DB_ENABLED was unset there. This
fixes dev branch behaviour (default-on since commit 3a3a7d3) and keeps
prod safe by default once main is promoted.

Files:
- packages/config/src/index.ts        env schema + audit notes
- apps/api/src/config/configuration.ts parse allowlist into string[]
- apps/api/src/integrations/catalog-source-db/emex-source-db.service.ts
  allowlist field, init logging, fetchCategoryParts gate, class doc
- docker-compose.coolify.yml          env injection for api + worker

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 18:39:59 +03:00
c01987da7d Merge pull request 'fix(observability): whitelist Sentry ingest in CSP connect-src' (#70) from fix/sentry-csp-connect-src into dev 2026-06-01 15:28:48 +00:00
Süper Panel
778931c880 fix(observability): whitelist Sentry ingest in CSP connect-src
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
The browser Sentry SDK initialised fine (DSN reached the bundle,
__SENTRY__ carrier registered) but envelope POSTs were silently
blocked by the existing CSP — `connect-src` didn't list any Sentry
host. Playwright verification on dev.sase.tr confirmed zero requests
to *.sentry.io even after a deliberate uncaught error.

Adds https://*.ingest.de.sentry.io (otolog org lives in the EU/de
region; this matches both the python and sase-web project DSNs).
2026-06-01 18:28:47 +03:00
ed3a777d87 fix(observability): regenerate pnpm-lock.yaml for @sentry/react (#69) 2026-06-01 15:21:56 +00:00
Süper Panel
c6d65e0a89 fix(observability): regenerate pnpm-lock.yaml for @sentry/react
PR #68 added `@sentry/react ^9.0.0` to apps/web/package.json without
updating pnpm-lock.yaml — the merge deploy (zwso4wc44skscw8k0ocw8wwo)
failed at `pnpm install --frozen-lockfile`:

  ERR_PNPM_OUTDATED_LOCKFILE: specifiers in the lockfile don't match
  specifiers in package.json:
  * 1 dependencies were added: @sentry/react@^9.0.0

Regenerated with pnpm@10.29.3 (matches the Dockerfile activation).
Only +80 lines (the new sentry deps); no other importers touched.
2026-06-01 18:20:42 +03:00
f4e74908b9 Merge pull request 'feat(observability): browser Sentry SDK (gated on VITE_SENTRY_DSN)' (#68) from feat/sentry-browser into dev
Reviewed-on: #68
2026-06-01 15:15:12 +00:00
Süper Panel
9f3eb0ff86 feat(observability): browser Sentry SDK (gated on VITE_SENTRY_DSN)
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Sase frontend has Faro (RUM) and PostHog (product analytics) but no browser
error monitoring. A recent panel session investigation (panel insight
cmpv9q3ms004dfcphluw0z8bh — silent rage-clicks after parts_panel_viewed
with no API call) couldn't be confirmed or refuted from JS error data
because there was no JS error channel at all. This adds one.

- @sentry/react ^9 (dynamic import in lib/sentry.ts so the bundle only
  pays the SDK cost when DSN is configured)
- Init is gated on VITE_SENTRY_DSN — unset = no-op, no behaviour change
- Replay only fires on error (sessionSampleRate 0, onErrorSampleRate 1)
- KVKK: sendDefaultPii false, maskAllText + blockAllMedia on replay
- Builds emit hidden source maps so Sentry can de-minify traces while
  end users don't fetch the maps in the browser
- docker-compose.coolify.yml threads VITE_SENTRY_* through as build args
  (VITE_* must be build-time; runtime env never reaches a Vite bundle)

Wiring on the Coolify side is a separate manual step — set
VITE_SENTRY_DSN on the prod (ro48g…) and/or dev (jwgwkg…) app and
redeploy. Backend Sentry (NestJS) is unchanged.

Refs: Süper Panel docs/ARCHITECTURE.md, panel sentry-archive job.
2026-06-01 18:13:13 +03:00
3a3a7d3ebf chore(catalog-source): split per-source kill switches; default pcat off
Verified 2026-06-01 against dev's 103 unique pcat carIds: the current pcat
dump's deep-scrape (7.978 cars with real parts data via schema_parts or
part_groups+part_group_items) targets a US/JDM-market subset — Toyota 2112,
Nissan 1508, Audi 1311, Chevy 1050, Hyundai 745. **None** of sase's TR-market
vehicles intersect that rich subset:

  - 18/103 sase carIds are in dump.cars at all (registry only)
  - 0/103 yield parts via Bridge A (schema_images → schema_parts)
  - 0/103 yield parts via Bridge B (part_groups → part_group_items)

Even the cars that match by exact carId (Fiat Doblo 368 schemas, Renault
Megane, Bravo 456 schemas) have only diagram metadata — no parts annotation.
The dump scraper finished tier-1 (catalog/model/car listing) and tier-2
(schema diagrams) for these, but stopped before tier-3 (parts annotation).

Under the strict "always correct OEM" constraint there is no safe pcat lookup
today. Disable it. The container stays up for future use cases (OEM cross-
reference search, alt-part matching) and so we can flip the env back without
a code change if a richer dump arrives.

EMEX stays on (its catalog-allowlist is the next step).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 17:50:07 +03:00
506f11d636 chore(deploy): expose CATALOG_SOURCE_DB_* env to api+worker via compose
Coolify env vars only reach docker-compose services that explicitly reference
them via ${VAR}. Adding CATALOG_SOURCE_DB_ENABLED + PCAT_SOURCE_DB_URL +
EMEX_SOURCE_DB_URL to both the api and worker `environment:` blocks so the
catalog-source-db lookup layer can pick them up. All three default empty/false
so this is a no-op until set in the Coolify env editor for an environment.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 15:27:32 +03:00
94117dd75d chore(api): bump source-db hit logs to log level for dev verification
Easier to see [source-db hit pcat/emex] lines while dev verifies coverage.
Can be downgraded back to debug once we've measured prod hit rates.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 15:07:47 +03:00
41acff2ab2 fix(api): emex source-db lookup uses catalogCode+gid, not SSD
Initial design routed emex lookups through vehicles.rawData.ssd → dump vehicles
→ vehicle_parts. Smoke test against prod ssd values: 0 / 10 matched. EMEX
regenerates the SSD on every decode session, so sase's stored SSD never
matches the SSD the dump scraper recorded for the same physical vehicle.

Pivot to a catalog-wide bridge that actually works:
  catalogs.code    ↔ vehicles.rawData.catalogCode  (e.g. "RENAULT201910")
  part_groups.group_id ↔ categories.externalId    (e.g. "11754")
  → parts via vehicle_parts.group_id (dump's parts.group_id is 100% NULL)

Verified coverage on prod's 8287 unique (catalogCode, gid) pairs: 25/26
catalog codes resolve, 7178 pairs hit a part_group (87%), 5919 of those
return actual parts via vehicle_parts (~71% net). Tradeoff: returns all
parts in the (catalog, group) across every variant in the catalog, so the
result is slightly noisier than the live per-vehicle scrape. Acceptable —
parts overlap heavily and the upstream-call savings outweigh the noise.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 15:06:48 +03:00
939e4dcdab Merge pull request 'dev' (#67) from dev into main
Reviewed-on: #67
2026-05-31 21:46:34 +00:00
e5ed6b9f36 feat(web): /catalog — recently-used brands strip
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Adds a horizontal scrollable "Son ziyaret ettiklerin" strip above the main
brand grid. Renders only when the user has actually opened at least one
brand detail page before — silent in cold-start state.

Why localStorage, not a backend endpoint
- This is a behavioural shortcut, not authoritative state. Adding a
  user_recent_brands table for data we don't have yet is premature.
- Keying by `sase-recent-brands-${userId}` mirrors the trial-banner
  scoping pattern; a second user on the same browser doesn't inherit the
  first user's list.
- localStorage failures (private mode, quota) silently degrade — the
  strip just stays hidden, never throws.

How a brand gets added
- Tracked at the destination (`/catalog/:brandName` visit), not on the
  link click. A click that never resolves into a real visit (auth gate,
  slow nav cancel) shouldn't be a "recently used" signal.
- 12 entries stored, 8 surfaced. Headroom for future ranking (e.g.
  weight by frequency × recency) without re-recording history.

Plan-lock awareness
- The strip cross-references the `/catalog/brands` access map, so a
  brand the user opened while on Full and then lost on a downgrade
  shows the same lock chip + amber upgrade route used by the main grid.
- New PostHog event: `catalog_recent_brand_clicked`. Locked recent
  chips reuse `catalog_locked_brand_upgrade_clicked` with
  `surface: "recents"` for funnel distinction.

i18n: `catalog.recentSection` (TR: "Son ziyaret ettiklerin" / EN:
"Recently visited").
2026-06-01 00:36:50 +03:00
4724a71113 feat(api): source-DB lookup-first for pcat/emex catalog fetches
Adds an optional local-dump lookup layer in front of the live PartsCatalogs and
EMEX scrapes. When enabled, getCategoryWithPartsInner queries a Postgres
(pcat) or MariaDB (emex) dump for the requested schema/group's parts and
hotspots; on miss it falls through to the existing upstream call unchanged.
Hits avoid the live API, its cooldown, and its rate-limits — direct DB latency.

- New CatalogSourceDbModule with PcatSourceDbService + EmexSourceDbService
  (raw SQL, no Drizzle schema modeling — dump shapes are frozen snapshots).
- pcat lookup keys on schema_images.schema_ext_id (the dump's column that
  matches sase's pcat groupId; observed ~7% hit rate on prod's 6596 unique
  groupIds, of which ~10% have schema_parts → ~3-5% net parts coverage).
  Joins schema_parts → parts directly; the dump's part_groups+part_group_items
  linkage covers 0 of our hits, so we skip that path entirely.
- emex lookup uses (catalog_id, ssd) → vehicles.id then (vehicle_id, group_id)
  → vehicle_parts → parts + part_images. The ssd is already persisted into
  vehicles.rawData.ssd by the existing emex.mapper, no extra capture needed.

Gated behind CATALOG_SOURCE_DB_ENABLED + PCAT_SOURCE_DB_URL / EMEX_SOURCE_DB_URL.
All three default unset, so this commit is a no-op until prod env is configured.
Adds mysql2 dep for the MariaDB client.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 00:35:45 +03:00
d9b14af96d feat(web): catalog sub-route normalize — CatalogHeader + PostHog on pcat & emex landings
Brings the secondary catalog entry points onto the shared header so
breadcrumbs and back behaviour stop drifting from /catalog/:brandName.

pcat/$catalogId
- Switches the inline "ArrowLeft + h1" combo to CatalogHeader with a
  Katalog / {CATALOG_ID} breadcrumb
- Replaces `Array.from({ length: 8 })` + numeric index keys with
  KEYS_10 (stable, no React-key warning risk)
- `{model.carsCount} araç` was hard-coded TR — now reuses
  `catalog.modelCount2` so EN-locale users see "N model(s)"
- Empty-state img placeholder swapped from arbitrary emerald to
  `bg-primary/10 text-primary` (token-driven, theme-correct)
- preload="intent" on model links

emex/$catalogCode
- CatalogHeader with breadcrumb; reset button moves into the actions slot
- Adds `catalog_emex_wizard_opened` view event for funnel parity with
  pl24/pcat — emex wizard is the highest-cognitive-load surface and we
  had zero data on its open-vs-abandon rate

Future passes will translate the remaining hard-coded TR phrases inside
the wizard body and the matched-vehicles list — kept out of this commit
to avoid sprawl.
2026-06-01 00:28:12 +03:00
141e90d176 feat(web): /catalog/:brandName — model search/sort, multi-catalog icons, primitives, PostHog
Migrates the brand-detail page to the shared CatalogHeader (with real
breadcrumb) + ViewModeToggle from 10A. Adds the toolbox the audit called
out and quietly fixes the "Sutun" typo by retiring the inline buttons.

Model browsing
- Search across {model, engine, year, bodyType} with Turkish-locale fold
- Sort dropdown: year DESC (default), year ASC, model A→Z — replaces the
  silent API-defined order
- No-match state with the original query exposed
- ModelCard attributes now Badge-pilled (year secondary, others outline)
  instead of flat space-separated text — "1998 1.6 FSI Hatchback Manuel"
  was a wall of words
- preload="intent" on each model link so the categories drill page is
  warmed up on hover

Multi-catalog selector
- iconForService(): Pl24 → Layers, Pcat → Database, Emex → Zap, Tecdoc
  → Wrench, fallback BookOpen. Four identical BookOpen tiles read as one
  catalog before.
- Drops the raw `serviceName` muted-text exposure under each tile

Breadcrumb / header
- "Katalog / Volkswagen / Pl24" now renders through CatalogHeader; the
  inline `<div className="text-xs">` tree got retired
- Back button stays handle multi-catalog vs single-source distinction

Loading
- Removed the doubled-up `Loader2 "Modeller yükleniyor…"` line; skeleton
  alone is enough

PostHog: catalog_models_viewed, catalog_model_search_used,
catalog_model_sort_changed, catalog_subcatalog_selected,
catalog_model_clicked, catalog_view_mode_changed (surface=models).
2026-06-01 00:26:23 +03:00
91d636527e feat(web): /catalog main page — search, in-plan-first, hide-locked, PostHog, primitives
Migrates the brand index off its inline view toggle to the shared
ViewModeToggle/CatalogHeader primitives from 10A, then adds the toolbox the
audit called out:

- Brand search: client-side, Turkish-locale fold over brandName; visible
  X-clear button; the no-match panel exposes the original query
- In-plan brands rendered above locked ones with two sub-section headers
  ("Planındaki markalar" / "Plan dışı markalar"); locked section gets the
  "upgrade to access" microcopy
- Hide-locked toggle with aria-pressed (also persisted as a click event so
  we can size the cohort that prefers a clean view)
- Searching collapses the section split into one ranked list — matches
  the "show me what I typed" mental model
- Locked brand cards/rows are now real `<Link to="/dashboard/subscription">`
  buttons so the upgrade target is one tap; firing
  `catalog_locked_brand_upgrade_clicked` with brand_name + surface
- `hover:-translate-y-0.5` (jittery on grid scroll) → `hover:scale-[1.02]`
- ARIA tablist replaced by the role="group" + aria-pressed pattern from
  the shared ViewModeToggle — keyboard story actually works now
- Drops manual `encodeURIComponent(brand.brandName)` in `params`; TanStack
  Router already encodes — caught the audit's double-encoding risk

New PostHog events: catalog_brands_viewed, catalog_brand_search_used,
catalog_hide_locked_toggled, catalog_view_mode_changed,
catalog_brand_clicked, catalog_locked_brand_upgrade_clicked.
2026-06-01 00:24:45 +03:00
05de73dd41 feat(web): catalog shared primitives — ViewModeToggle, CatalogHeader, useViewMode
Three drift-prone catalog surfaces (main /catalog, /catalog/$brandName, and
the legacy CategoryViewToggle) each carried their own inline view-mode
toggle with subtly different aria labels, sizing, icon paths and even a
"Sutun" typo. New surfaces will inherit a single primitive.

- ViewModeToggle: grid/tree/columns radio-group with i18n labels driven by
  `catalog.view.*` keys; fan-out by passing `groupLabelKey`.
- useViewMode(key): centralises read/write of `brandViewMode`/`modelViewMode`
  in user-settings — page just owns its current value.
- CatalogHeader: title + subtitle + optional breadcrumb trail + back button +
  trailing actions slot, so every catalog drill page renders the same shell.

i18n: adds catalog.subtitle, catalog.view.{groupLabel,grid,list,columns},
catalog.{brandSearch,modelSearch}{Placeholder,NoMatch}, catalog.{hideLocked,
showLocked,lockedSection,lockedSectionHint,inPlanSection,goToModels,
selectBrandHint,columnsBrowseHint,sortLabel}, catalog.sort.{newest,oldest,
alphabetical}.

These primitives stand alone in this commit — the main /catalog and
brand-detail pages migrate to them in 10B and 10C.
2026-06-01 00:23:05 +03:00
adbf23945e feat(web): history production refactor — search, sort, delete, plan-lock, i18n, analytics
The old 66-line history page was a raw list with `any[]` typing, no search,
no sort, no delete, no dates, no i18n and no analytics — opening a vehicle
detail was the only action available.

Replaces it with a real management surface:
- Search-by-text (brand, model, VIN, year, engine) — client-side over the
  paginated payload, Turkish-locale case-folding
- Sort dropdown (newest, oldest, brand A→Z, year DESC)
- Per-item Remove with a confirmation dialog; mutation does optimistic
  setQueryData so the card disappears immediately, then invalidates the
  search-page recent-history query
- Relative-time "decoded {when}" using Intl.RelativeTimeFormat with the active
  locale; absolute timestamp lives in title/aria for power users
- Plan-lock badge: when the user's plan no longer covers a brand in their
  history, that card surfaces a Lock chip linking to /subscription
- Brand logo, VIN with copy-to-clipboard, focus-visible rings, Link.preload
  intent so opening a vehicle is instant
- Empty state (no history) and no-results state (filter wiped everything)
  are now distinct, both with the right CTA
- PostHog events: history_viewed, history_search_used, history_sort_changed,
  history_load_more, history_item_clicked, history_vin_copied,
  history_item_deleted — full funnel coverage from open to action
- Full i18n via new `history.*` block in tr.json/en.json, plus the page now
  fetches paginated (PAGE_SIZE × 8 first chunk, "show more" client-side)
2026-06-01 00:10:50 +03:00
02efa8c35d refactor(web): kill dashboard home, /dashboard now redirects to /search
The 460-line dashboard "home" was a low-value stat-and-profile screen:
new users saw mostly zeros, the subscription card duplicated info already on
/subscription and /billing, and the page sat between login and the product's
actual value (VIN decoding). Login → 1 click → search added no information.

Now /dashboard short-circuits via beforeLoad into /dashboard/search.
Trial urgency and subscription status are already surfaced by the dashboard
layout shell (TrialUrgencyBanner). The "Gösterge Paneli" sidebar entry is
removed since it now duplicates "Arama".

If we miss the at-a-glance overview later we'll bring it back as a real
command center (VIN input front-and-center, recent searches prominent, plan
mini-strip) — based on PostHog data, not on SaaS-template reflex.
2026-06-01 00:02:05 +03:00
c02bfbfdd5 Merge pull request 'dev' (#66) from dev into main
Reviewed-on: #66
2026-05-31 20:56:44 +00:00
229666e333 i18n(web): full landing page TR↔EN — language switcher actually works now
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Root cause: landing was almost entirely hard-coded Turkish (5 t() calls in
1889 lines), and initLocale() — which reads the persisted locale from
localStorage on boot — was never called from main.tsx, so a reload always
reverted to the TR default.

This commit fixes both.

apps/web/src/main.tsx
- Call initLocale() before first render so the persisted choice survives reload.

apps/web/src/routes/__root.tsx
- Translate the skip-to-content link via a new `a11y.skipToContent` key.

apps/web/src/routes/index.tsx
- Internationalize ~145 user-visible strings across hero, brand strip, features,
  comparison table, bento grid, use cases, stats, dashboard showcase, ecommerce
  block, testimonials, pricing tiers, FAQ, final CTA, referral banner and ARIA
  labels. Module-scope data arrays moved inside the component so re-renders
  pick up the active locale.
- Wrap the three Remotion players (Schema / Dashboard / Ecommerce) so they read
  the current locale and pass translated labels into their inputProps.

Remotion demos (DashboardDemo, EcommerceDemo, SchemaDemo)
- Each now accepts a `labels` prop (DashboardDemoLabels / EcommerceDemoLabels /
  partLabel). All hard-coded TR sidebar items, placeholders, category names,
  product names, "Add to Cart" buttons, the cart-added toast, overlays and
  stat labels are now driven by labels with a TR fallback (keeps Remotion
  Studio preview working standalone).

messages/{tr,en}.json
- New blocks: `a11y`, `landing.*` (huge tree covering every section above),
  `landing.dashboardDemo`, `landing.ecommerceDemo`.

Intentionally preserved as data, not translated: brand names (Volkswagen,
Mitsubishi …), VIN literals, OEM codes (1J0 820 803F …), numeric stat values
(1.2sn, 27, 1M+, %99.9 …), prices (200/350/500/999), and the testimonial
person name "Özge D." (proper noun).

Verified: typecheck clean, 17/17 test files / 57/57 tests pass, biome clean,
Playwright TR↔EN round-trip succeeds, only the testimonial name remains as
Turkish on the EN page.
2026-05-31 23:50:52 +03:00
931c0cfd85 feat(web): pricing — by-the-numbers stat strip, verifiable facts only
Adds a four-stat band below the hero CTAs — 27 brand catalogs, 1M+ OEM/alt
parts, 30-day free trial, 7-day no-questions refund. Big tabular-nums numerals
with small labels; divides on sm+. Every number is product or policy fact we
can defend — no fabricated customer counts, no testimonial copy.

Side effect: FAQPage JSON-LD now injected into <head> via useEffect (was an
inline <script dangerouslySetInnerHTML>). Same SEO payload, less attack
surface, tagged with data-sase-faq so it's diff-friendly.
2026-05-31 23:18:14 +03:00
b8bbdbda67 polish(web): pricing zigzag timeline, FAQ JSON-LD, trust icon variety, feature matrix sticky thead
Pricing page
- "Nasıl çalışır" now renders as a vertical timeline with alternating sides on
  md+ and a connecting rail — no more 3-equal-cards AI tell
- Hero trust strip swaps four identical Check icons for distinct ones
  (CalendarClock / CreditCard / RotateCcw / Check) so each promise reads on
  its own
- FAQ section emits FAQPage JSON-LD for rich SERP results (free SEO upside;
  no UI cost)

Subscription page
- FeatureMatrix gets sticky thead + a right-fade gradient on narrow viewports
  so users can tell more columns lie offscreen
2026-05-31 23:12:21 +03:00
1ea53920b0 refactor(web): brand selector controlled state + search; trial banner urgency tiers + progress
BrandSelector
- Now fully controlled — drops the internal `selected` state that could drift
  from the prop source of truth
- Full-plan auto-select effect compares against the live selection and only
  fires when out of sync, removing the infinite-loop risk if parents passed an
  unstable onSelectionChange (now also ref-stashed)
- Search input appears when there are more than 12 brands; Turkish-locale
  case-folding so "Volkswagen"/"volkswagen" both match
- No-match state for empty search results

TrialUrgencyBanner
- Three urgency tiers (soft 7d, urgent ≤3d, critical ≤1d) — colors and
  tighter copy escalate as the trial winds down
- Adds an elapsed-time progress bar so "3 days left" reads as 90% used at a
  glance (number alone undersells urgency at the tail)
- LS dismissal key now userId-scoped (mirrors subscription page)
- Banner emits `tier` alongside days for funnel segmentation
2026-05-31 23:10:08 +03:00
35871403c4 feat(web): billing & Stripe — empty state CTA, failed-payment hint, inline retry, card-brand trust
- Billing empty state now has a ReceiptText avatar and (when no subscription)
  a "choose plan" CTA — beats a bare "no payments yet" line
- Receipt buttons promoted from ghost to outline so users actually find them
- Failed payments show a help icon with a hint (card declined → bank / retry)
- Stripe button: Clock → Loader2 (Clock-as-spinner was awkward)
- Stripe init failure now also surfaces inline with a retry button — a toast
  vanishing in 4s left users with no recovery affordance
- Adds an accepted-cards trust strip (VISA / Mastercard / Troy / 3D Secure)
  below the pay button to ground the secure-checkout copy
2026-05-31 23:06:46 +03:00
611b41c793 feat(web): subscription — radiogroup plan picker, trial stuck guard, stale-brand cleanup, scoped LS
- PlanGrid now wraps cards as a real radiogroup with role=radio + aria-checked +
  focus-visible ring (was aria-pressed on plain buttons)
- Adds ProvisioningStuckHint: at 30s in trial provisioning we surface a
  contact-support + retry escape so users aren't trapped on a spinner
- PaymentMethodSection strips stale brand IDs against the live /brands query
  before Stripe checkout (would have 4xx'd silently)
- trial-banner dismissal LS key now scoped to userId + endDate (was endDate
  only — two users on the same browser could leak state)
- Sticky CTA disabled state gains a hint title + aria-disabled + opacity
- Brand-selector Suspense gets an 8s slow-load fallback with reload affordance
- OnboardingCompletedView shows the actual subscription plan name (was hard-
  coded "Full Paket")
- Picks up sase-pending-period stashed by the public pricing toggle
2026-05-31 23:04:20 +03:00
eb8d8c2dee feat(web): pricing — hero CTA, monthly/yearly toggle, numeric savings, popular elevation
- Hero gets a primary "start trial" CTA + secondary "see plans" scroll-to anchor
- Adds monthly/yearly billing toggle that switches displayed amounts and surfaces
  effective per-month yearly cost
- Yearly savings shown as both percent and TL/year (no more vague "save")
- Full Paket card visually elevated (ring + tinted bg + slight vertical offset on lg)
- Plan CTA carries plan=key into /register; chosen period stashed in localStorage
- New PostHog events: pricing_hero_cta_clicked, pricing_period_toggled,
  pricing_plan_cta_clicked
2026-05-31 22:58:18 +03:00
984a02e9a3 fix(web): onboarding micro-polish — autofocus CTA, swap hard-coded red token
- Auto-focus the "Şase Aramaya Başla" button when the completed card
  mounts so keyboard users can Enter through without tabbing.
- Replace the hard-coded `text-red-600 dark:text-red-400` on the trial
  error message with the `text-destructive` design token.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-31 22:27:50 +03:00
229ada8adf feat(web): onboarding — skip affordance, stable layout, robust timing, dismissible close
- Show a "Atla" link once the trial actually provisioned so impatient or
  returning users can jump straight to the confirmation card; captures
  onboarding_skipped.
- Reserve a min-height on the provisioning container so the dialog
  doesn't jerk when the loader/error blocks appear or disappear.
- Re-check animation-ended on visibilitychange — background tabs throttle
  setTimeout, which could leave a user stuck if they tabbed away during
  the 7.5s window.
- Show the default close X again when the modal is dismissible (completed
  or escape-hatch active) instead of hiding it for the whole lifecycle.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-31 22:26:45 +03:00
469a7af67e fix(web): onboarding — locale-aware end date, dynamic plan, completion event
- End date no longer hard-codes tr-TR; follows the active locale.
- The plan badge reads from subscription.plan.key (falls back to "full"),
  so a future change to what the trial provisions doesn't make the UI lie.
- The "Şase Aramaya Başla" CTA captures onboarding_completed, so the
  funnel can distinguish a user who actually clicked through vs one who
  dismissed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-31 22:25:10 +03:00
eb7b75fbe8 fix(web): onboarding modal — escape hatch + trial_failed analytics
When the trial mutation either fails repeatedly or just hangs past 30s,
new users were trapped staring at the spinner with no way out. Add:

- A `trial_failed` capture on every mutation error (Onboarding funnel can
  finally measure the activation failure rate).
- After 2 failures OR 30s in provisioning, surface a "still trying"
  panel with "Destek ile konuş" (opens Chatwoot) and "Yine de kapat"
  options, and let interact-outside / Esc dismiss the modal again.
- An openChatwoot() helper + typed `toggle` on the ChatwootApi so other
  components can open the support widget without poking the global.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-31 22:24:20 +03:00
209e98cb41 fix(web): stop leaking /vehicles/undefined and the raw uuid parse error
When the landing decodes a VIN for a logged-in visitor and the API
returns a multi-candidate response (no single id), the code was
navigating to /dashboard/vehicles/$id with id=undefined. The catalog
page then asked the API for /vehicles/undefined and the backend
"invalid input syntax for type uuid: 'undefined'" message landed in
front of the user.

Two-sided fix:
- On the landing, if the decode reply has no id, hand the VIN off to
  /dashboard/search?vin=… instead — that page already renders the
  candidate-picker and the unrecognised-VIN flow.
- On the vehicle detail page, gate both queries on a UUID check and
  render a friendly "Geçersiz araç bağlantısı / Aramaya git" panel for
  any malformed id. Also stop showing raw backend error messages in the
  generic error state — surface the localised hint instead.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-31 22:04:21 +03:00
a4597544b4 feat(web): vehicle detail polish — analytics, chatwoot cleanup, skeleton, heading
- Capture category_view_changed and catalog_search_opened so the funnel
  picks up category-view and cross-tree search activation (previously
  uninstrumented).
- Expose clearChatwootVehicle() and call it from the route's effect
  cleanup so a later support chat doesn't carry the previously-viewed
  car's VIN/brand.
- Match the loading skeleton shape to the active viewMode — render a
  card grid for grid mode (was always 8 row-bars).
- Switch the vehicle title to the display font (font-display, tracking-tight)
  for typography parity with the rest of the app.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-31 21:45:04 +03:00