Commit Graph

356 Commits

Author SHA1 Message Date
1e3953417e perf(backfill): stop the prefetch worker from throttling itself (Tier 1)
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
The worker's own upstream fetches called touchActivity(), setting the
prefetch:activity:<source> cooldown key (TTL 300s) that checkCooldown then
honoured — so after each fetch the worker paused itself for up to ~5 minutes
(re-checking every 60s, ~5 empty cycles per key). At ~1 fetch / 5 min the
3387-job backlog needed ~6 days to drain.

- Wrap each worker job in an AsyncLocalStorage backfill context; touchActivity
  skips the cooldown key when invoked from the worker, so the cooldown reflects
  only real user requests (worker yields to users, never to itself).
- Cooldown TTL 300s -> 90s (a request 5 min ago isn't "active").
- checkCooldown pauses for the key's actual remaining TTL (one wait) instead of
  a fixed 60s re-check loop.

No extra upstream load — only removes the worker's self-imposed idle time.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 18:23:54 +03:00
d694bc8c35 feat(analytics): thread category/vehicle names on remaining SchemaViewer routes
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Completes the empty-catalog enrichment so parts_panel_viewed carries
human-readable names across ALL catalog flows, not just the decoded-vehicle
route. Now the '0 parca - kategoriye gore' breakdown gets named rows from
catalog-brand, emex, pcat and demo browsing too:
- emex: categoryName = data.group.name
- pcat: categoryName = activeSchema.name
- catalog-brand: categoryName = data.name, vehicleLabel = decoded brandName
- demo: categoryName = data.name, vehicleLabel (already computed)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 17:35:55 +03:00
3bfefe6196 feat(analytics): canonical subscription_activated revenue event ()
No PostHog event carried realized revenue, and EFT/havale activations fired
nothing at all — so total paid revenue / MRR was unmeasurable (a Stripe DWH
connector alone would also miss EFT). activateSubscription is the shared
chokepoint for both Stripe (stripe.service) and EFT/manual (billing.service)
activation, so emit one canonical subscription_activated there with PostHog
revenue props: $revenue (major TRY), currency, mrr (yearly amortised /12),
plan, plan_id, brand_count, billing_period, method (looked up from the latest
payment row), referral_credit_days. Funnel steps keep their kuruş 'amount' but
intentionally carry no $revenue, so revenue isn't double-counted.

Unblocks trial->paid, MRR/ARPU and revenue-by-plan/channel across ALL payment
methods. Injected PostHogService (PostHogModule is @Global).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 16:02:30 +03:00
c5f8288d71 fix(catalog): case-insensitive PL24 group-wid drill check
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
PL24 sub-group nav nodes were classified by linkWid.includes("Group")
(case-sensitive). That matched capitalised wids (subGroupsTable) but
missed lowercase ones — groupReferenceTable, groupTable, groupsTable
(~1157 leaf nodes in prod) — so those skipped the group-drill branch in
getCategoryWithPartsInner and the reference-resolution descent, falling
to the parts path (a wasted upstream fetch; the generic drill-on-empty
fallback then re-drilled them). Lowercasing the check routes these nav
nodes straight to children/loadError like their capitalised siblings.

Empty-catalog audit (2026-06-03) showed PL24 drives 61% of user-seen
'0 parça' views; pcat fake-leaves are effectively solved (1 case).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 15:40:39 +03:00
c7fad362a5 feat(analytics): enrich empty-catalog signal with vehicle/category names
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
parts_panel_viewed carried only opaque vehicle_id/category_id, so the
'0 parça' empty-catalog breakdown (and its rate-spike alert) couldn't say
WHICH vehicle/category was empty — useless for prioritising catalog backfill.

- parts-panel: add vehicle_label + category_name to parts_panel_viewed, and
  fire a new empty_catalog_cta_clicked (with the same context) from the
  empty-state button — a demand/abandonment signal per vehicle+category.
- schema-viewer: forward the two optional props to PartsPanel.
- vehicle category route: pass the existing vehicleLabel + data.name.

Best-effort labels (undefined on routes not yet threaded); parts_count
unchanged so existing insight/alert keep working.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 11:41:34 +03:00
fa6937bb5f fix(analytics): stop orphaning anonymous person on load, breaking signup attribution
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
__root identify effect called posthog.reset() whenever `user` was falsy —
which includes the brief window while the session resolves on every page
load. After the signup hard-redirect this rotated the anonymous distinct_id,
orphaning the pre-signup anonymous person that carries `user_signed_up` and
first-touch `$initial_utm_*`. The later identify() then merged a fresh, empty
anon id, so signups never linked to trial/payment (only ~16% stitched) and
channel attribution read "(none)" for 100% of signups.

- __root.tsx: gate the effect on isLoading and only reset() on a real
  identified -> anonymous transition (logout), tracked via a ref. Logout
  still resets via dashboard handleSignOut.
- register.tsx: identify() within the still-active anonymous session before
  firing user_signed_up, so the anon->identified merge carries $initial_utm_*
  and attributes the signup (email flow; Google is handled on OAuth return).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 10:54:08 +03:00
2a26890d33 feat(web): escape Instagram/FB in-app browsers to the system browser
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Ad traffic from Instagram/FB lands in their in-app WebView, where Google
OAuth (signIn.social google) returns 403 disallowed_useragent — so the
30-day-trial signup with Google is broken for paid traffic. Also costs
password autofill, persistent session, and clean attribution.

- Android: hard-escape via intent:// (no package= → default browser),
  preserving the full URL so UTM/fbclid survive the hop.
- iOS: Apple allows no programmatic escape — dismissible banner guiding
  "••• → Safari'de Aç" with a copy-link fallback.

Mounted in __root so it runs on the landing page. Pure UA/intent helpers
unit-tested (in-app detection + URL preservation + no false positives).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 02:55:46 +03:00
8551cef2e1 chore(web): drop iabjs:// in-app browser noise via denyUrls
Android Instagram in-app browser injects scripts under the iabjs:// scheme
(navigation_performance_logger_android) that throw bridge errors like
"Java object is gone" during beforeunload. denyUrls on /iabjs:\/\// drops
the whole family at the source, message-independent. Also added the message
to ignoreErrors as a fallback.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 02:48:21 +03:00
1a2369a5ec chore(web): ignore Instagram in-app browser window.webkit noise in Sentry
iOS Instagram/Meta in-app browsers inject their own JS that probes the
WKWebView bridge (window.webkit.messageHandlers) and throws when absent.
It surfaces via our global onerror but is not our code — pure noise that
scales with Instagram ad traffic. Added to Sentry ignoreErrors. The
existing issue (124551981) was muted in the Sentry UI.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 02:41:00 +03:00
5d8e4023a5 fix(web): stop dropping UTM params from PostHog pageviews
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
capturePageView sent `$current_url: origin + path`, stripping the query
string — so every Meta-ad UTM was lost and all paid traffic was
mis-bucketed as "direct" (PostHog saw 9 facebook visitors vs Meta's
~1450 LPV over 21 days). Send the full window.location.href so PostHog
parses $utm_* / $initial_utm_*. Also flip person_profiles to "always"
so anonymous ad visitors get a first-touch profile (volume ~34k/mo, well
within the 1M free tier). Adds a regression test guarding the query string.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 01:44:50 +03:00
b7dface50c style(web): make "bk. tablo" reference read clearly as a link
Always-underlined, semibold, primary, larger text + arrow, with a "Tabloya
git:" verb so the cross-reference reads unmistakably as a clickable link
instead of muted inline text.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 20:29:03 +03:00
642139de9c fix(pl24): detect "bakınız tablo, konum:" reference phrasing too
PL24 translates "see table" two ways — "bk. tablo:" and "bakınız tablo,
konum:". Detection only matched the first, so the latter rows (e.g. evaporator
housing → 820-020) stayed dead. Broaden the name regex to match either, and
strip both phrasings from the displayed label. The code-in-remark gate still
prevents flagging real parts that merely mention "tablo".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 20:20:30 +03:00
c440252aa3 feat(pl24): on-demand drill to resolve unseeded "bk. tablo:" references
When a reference's target illustration isn't seeded yet (load-time index
miss → categoryId null), clicking it now calls a new resolve endpoint that
drills the relevant main-group root (its external_id = the code's first
digit; the illustration is a direct child) and re-resolves. One PL24 call in
the common case, bounded + cached; falls back to pre-filled search if not
found. UI shows a spinner on the button while drilling.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 19:41:02 +03:00
232c9ccc7a feat(pl24): make "bk. tablo:" BOM cross-references navigable
PL24 BOM emits "see table NNN-NNN" reference rows (oem N/A, target code in
remark) with NO upstream link. Resolve the code against the vehicle's
illustration index (codes live in category names as {NNN-NNN}) and render
jump links. Unresolved targets (branch not seeded yet) deep-link a pre-filled
catalog search via ?q=.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 18:55:42 +03:00
6bcb7ac2a5 fix(web): instant loading feedback on category node clicks (#73)
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Drilling the category tree gave a 0–300 ms feedback gap between click and
the next render — no disabled state, no visible change — so users re-tapped
(one real session: 9 of 22 clicks were rage clicks, 158 s, zero results).

Add an immediate, prominent pending state on all three category views:

- grid: track the clicked card in `pendingId`; the button goes
  disabled + aria-busy and a 28px centred spinner overlays the card (both
  card variants) until the route swap unmounts the grid.
- tree: disable the expand button + aria-busy while children fetch; grow
  the chevron-slot spinner 14px → 20px and tint it primary.
- columns: disable the row + aria-busy while the child column lazy-fetches;
  same 20px primary spinner.

Tests: grid click paints disabled+aria-busy on the tapped card only;
tree/columns rows lock + show a spinner while the fetch is in flight.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 15:15:04 +03:00
e73b899e11 fix(web): parts table spills below the footer on mobile schema page (#76)
The parts-panel wrapper used max-h-[500px] on mobile, but a max-height parent
does not give the panel's `h-full` a definite height to resolve against, so
the panel resolved to the table's full intrinsic height (~2500px) and, because
the wrapper's overflow is visible, spilled out the bottom — rendering the whole
parts list a second time below the signup CTA and the page footer (reported
"ürün kodları → kaydol kartı → footer → ürün kodları" broken structure).

Drop the mobile height cap so the parts list flows in normal document order and
the page scrolls through it (the intended mobile pattern; also resolves the
triple-scroll-trap noted in #76). Desktop is unchanged: the 40% column still
sits inside the md:h-[700px] row with its own inner overflow scroll.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 14:55:30 +03:00
e4b13c6abb fix(web): mobile schema page traps users above the parts list (#76)
On mobile (iOS Safari, 390px), the schema viewport's one-finger touchmove
handler called setPan() unconditionally — even at zoom 1 with nothing to
pan — which makes iOS suppress the native page scroll. Users couldn't reach
the parts list below the fold (real trial session: 16 rage clicks, 0 OEM
codes copied, no purchase).

- use-schema-interaction: gate one-finger pan on zoom > 1 so the gesture
  falls through to native page scroll when not zoomed in.
- schema-viewer: set touch-action (pan-y pinch-zoom at zoom 1, none when
  zoomed) on the viewport; shrink mobile schema height 400px -> 280px so the
  first parts rows peek below the fold and signal "more below".
- parts-panel: after a hotspot tap, scrollIntoView uses block:"start" on
  mobile (panel is below the fold) and block:"center" on desktop, so the
  selection is actually visible.

Tests: new Vitest hook test asserts no pan at zoom<=1, pans at zoom>1, and
pinch-zoom still works; new parts-panel scroll test asserts mobile vs desktop
block target. Also fixes a pre-existing typo in the empty-state test ("Geri
don" -> "Geri dön").

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 12:34:47 +03:00
00f7edbc15 fix(pl24): Volvo vin-image-board BOM parts + decode subgroup name entities
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Volvo's vin-image-board.action ships its BOM inline as partno= tc-data-row
rows (no pncHierCode / json-vin-bom-detail), so the Ford VIN-BOM parser
returned 0 parts. Fall back to parsePsaBomParts (partno= rows) when no pnc
rows are found. Also decode HTML entities (&Ouml; &quot; …) in scraped Volvo
subgroup names. Completes the Volvo chain: group1→group2→illustration→parts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 02:30:54 +03:00
c46695d320 fix(pl24): Volvo drill — keep openVinDialog=false links + image-board leaves
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Two bugs in the vin-group.action subgroup scrape: (1) the filter dropped any
href containing "openVinDialog", but the real sub-group links carry
openVinDialog=false (only the VIN-dialog crumb is =true) — so every child was
discarded; (2) the deepest group level lists its illustration leaves as
vin-image-board.action links, which weren't extracted. Now match both deeper
vin-group.action?groupN= and vin-image-board.action anchors, and only drop the
openVinDialog=true crumb. Completes Volvo group1→group2→illustration→parts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 02:24:59 +03:00
f5c3198a6e fix(pl24): demo-page retry in fetchP4Page + Volvo drill diagnostics
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
PL24 serves a stripped NOT_LOGGED_IN_DEMO page (no groups/parts) when the
service token is stale. decodeVinForService retries on this, but drill paths
(fetchSubGroupsByPath/fetchPartsByPath) reach upstream only via fetchP4Page,
which didn't — so Volvo subgroup drilling parsed empty demo pages. Retry once
with fresh auth on a demo page. Adds a Volvo-drill diagnostic log.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 02:18:04 +03:00
7208069a80 fix(pl24): prefix basePath for relative P4 hrefs in fetchP4Page
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Volvo vin-group.action categories store hrefs relative to the catalog dir
(e.g. "vin-group.action?group1=…"). fetchP4Page did `${baseUrl}${url}`,
collapsing to "partslink24.comvin-group.action" → ENOTFOUND. Prefix the
service basePath when the path is relative. Fixes Volvo subgroup drilling
for existing (relative) stored linkPaths without a re-decode.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 02:12:59 +03:00
497aba24ab fix(ui): repair Turkish labels + hero preview model name
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Two cosmetic fixes the founder flagged in the same browse session:

* parts-panel.tsx — the catalog parts table was missing Turkish
  diacritics on every static label, while the actual part names rendered
  correctly. Looked like an encoding bug; was actually hard-coded ASCII
  strings. Repaired: Parcalar→Parçalar, parca listeleniyor→parça
  listeleniyor, Bu kategori icin parca bulunamadi→Bu kategori için
  parça bulunamadı, Yukleniyor→Yükleniyor, Parca Adi→Parça Adı,
  Geri don→Geri dön. OEM Kodu was already correct.

* index.tsx hero preview — pl24 ships model names with trailing
  duplicates ("Golf 1,6 GOLF"), and the card stitched year right next
  to the engine "(2003 4 silindirli…)" with no clear separator. Now:
  cleanModelName collapses the dup ("Golf 1,6"), year wraps in
  parentheses as a softer secondary element, and the visual flex gap
  guarantees breathing room before the engine pill.
2026-06-02 02:11:35 +03:00
10dcf2d6ae fix(parts): dedupe ingest path + clean ~108k duplicate rows
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Backfill + reactive prefetch can re-drill the same category multiple
times, and the parts insert path had no dedupe guard. Result: 8.2%
duplicate rows on pl24, 14.5% on parts-catalogs, and 33.7% on emex —
~108k extra rows across 7,571 categories on 224 vehicles. Every drilled
catalog page rendered each part twice (the Tampon example: 32 rows for
19 distinct OEMs).

* Migration 0010 — phase 1 deletes existing dupes preserving the
  oldest row per (vehicle_id, category_id, oem_code, name, position)
  group; phase 2 adds a UNIQUE INDEX over the same tuple with NULLS NOT
  DISTINCT (PG 15+) so null position/vehicle_id collapse like equal
  values rather than each counting as its own "distinct" row. Idempotent
  CREATE UNIQUE INDEX IF NOT EXISTS so the runner is safe to re-apply.
* All five insert(parts).values(...).returning() call sites
  (parts.service, categories.service ×3, catalog.service) get
  .onConflictDoNothing() so future re-drills no-op instead of erroring
  on the new constraint. `.returning()` continues to surface only the
  newly-inserted rows; existing logs read `Stored N parts` as actual
  net insertions, which is what we want.

Dry-run on dev DB: 524,540 → 425,186 parts (99,354 dupes deleted), index
created cleanly. Same delta expected on prod (~108k drop).

drizzle-orm 0.41 doesn't expose .nullsNotDistinct() on the index builder
so the constraint is owned by raw SQL — see the inline comment in the
parts schema and the migration file. Future schema generators should NOT
try to drop or rewrite this index.
2026-06-02 02:08:57 +03:00
d792bf0efb fix(pl24): Volvo subgroup drilling via vin-group.action HTML
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Volvo's VIN catalog is a 3-level vin-group.action?group1=…[&group2=…] HTML
tree; PL24's json-vin-*-group.action JSON endpoints now 404. Add HTML
subgroup extraction (keep links one group-level deeper than the current
path) in ford-legacy fetchSubGroupsByPath, and route vin-group.action?group1=
nodes through getChildren (drill-first, fall back to parts) in
getCategoryWithParts. Recovers Volvo group1→group2 navigation. Leaf parts
handled separately.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 02:08:12 +03:00
316a5e014c feat(backfill): widen scrape window for pcat + double batch size
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Backfill was sweeping only 20 vehicles/hour and skipping pcat outside
09:00–19:00 Istanbul, leaving the catalog backlog (115 pl24 + 63 pcat +
27 emex zero-parts vehicles) crawling forward at ~528 parts/day and the
biggest user vehicle (Toyota Corolla 2026) unchanged across 24h.

Two related changes — both unblocked by the Redis-persisted warm JWT pool
(aa4d055 + dcf7e06) which keeps pcat captures alive 24/7 instead of
needing the 09:00–19:00 office-hours assumption:

* isWithinTimeWindow: parts-catalogs no longer gated — the warm pool +
  Redis hydration cover the cold-start case the old office-hours rule
  was working around. PL24 keeps its 09:00–18:00 window because the
  upstream rate-limit is still tighter outside it.
* BACKFILL_BATCH_SIZE 20 → 40 — twice as many vehicles per wave, still
  protected by MAX_BACKLOG=1000 self-throttle and per-source cooldown
  (prefetch:activity:<source>) so live-user traffic still gets priority.

Combined effect: pcat goes from ~10h/day to 24h/day, batch doubles —
roughly 3× backfill throughput. Worst-case proxy spend tracked by the
DataImpulse daily cap; if a wave saturates upstream the rate-limit
handler (c7e59b9) defers without burning attempts.
2026-06-02 02:00:16 +03:00
0f647b3931 fix(pl24): recover Volvo categories — stop vin-group.action?group1= shadowing
Volvo (VIN-indexed legacy catalog) ships its real top groups as
vin-group.action?group1=… in the vin-group HTML. PL24's
json-vin-main-group.action endpoint now 404s, so decode falls back to the
HTML scrape — but parseP4NavigationCategories AND the seed-time NAV_CRUMB
filter both blanket-exclude vin-group.action, dropping every real Volvo
group → 0 categories. Exclude vin-group.action as a crumb only when it
lacks group1=. Confirmed upstream: 7 real groups (Frenler, Elektrik
sistemi, …) present in the HTML for YV1AS7050A1118639.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 01:51:19 +03:00
7da12607ef fix(demo): wrap pages with global SiteHeader + SiteFooter
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
/demo and /demo/categories/$id rendered without the site chrome — no nav
to other marketing pages, no theme toggle, no KVKK/gizlilik/iletişim
links. Marketing-page parity (matches index/pricing/about) + B2B trust
(legal links visible, logo back to home).

* Both routes now use a flex-col page shell:
  SiteHeader (sticky top-0, z-50) → DemoBanner (sticky top-16, z-30) →
  main (flex-1) → SiteFooter.
* DemoBanner sticky offset changed top-0 → top-16 to sit under the site
  header instead of competing for the top edge.
2026-06-02 01:27:41 +03:00
c7527e4845 fix(demo): drop VIN input from footer CTA — text + button only
Footer carried a VIN input intended to let demo visitors signup with their
own vehicle pre-filled, but it duplicated the hero's VIN search and added
friction at a moment the visitor is already deciding "should I sign up?"
— not "let me re-type a VIN". Reverted to a clean B2B trust strip + single
"Hesap Aç" primary CTA.

* New DemoFooterCta replaces the prior DemoVinCta. Visitors who want to
  query their own VIN use the hero or the /register page itself.
* Component renamed (file + symbol) so the name reflects what it does;
  the old file is removed. Both /demo and /demo/categories/$id swapped.
* Copy & B2B framing unchanged from the spec ("Sınırsız şase sorgulamak
  için ücretsiz hesap aç" / trust strip).
2026-06-02 01:24:25 +03:00
6643a8edad feat(demo): VIN-input CTA card replaces plain "Hesap Aç" footer
Demo footer was a single-button conversion ("Hesap Aç" → /register with no
VIN). For a parts-shop owner who just browsed the example VW Golf, the
next natural action is "let me try MY customer's VIN" — not "let me click
register and re-type". Adds an inline VIN input to the footer CTA on both
/demo and /demo/categories/:id; submitting routes to /register?vin=… so
the register teaser (see ed74e2f) lifts off where this leaves off.

* New DemoVinCta component owns the form + analytics. Empty submit still
  works as plain "Hesap Aç" (no VIN); typing a VIN flips the button label
  to "Bu VIN için Hesap Aç".
* B2B copy retained from prior commits: "Sınırsız şase sorgulamak için
  ücretsiz hesap aç" headline, "Kart bilgisi gerekmez · 30 gün ücretsiz ·
  istediğin an iptal" trust strip — see [[sase-b2b-copy-not-consumer]].
* Input placeholder reads "Müşterinizin şase numarası (17 karakter)" — B2B
  framing ("müşterinizin") not "kendi aracınız", per the same memory.
* Mobile-friendly: input + button stack vertically <sm, side-by-side ≥sm;
  full-width button on mobile clears the Chatwoot widget.
* PostHog: emits demo_to_register_click with {source, has_vin, vin_length}
  so we can split conversion by "browsed → typed VIN → registered" vs
  "browsed → bare register".

DemoBanner stays a single-CTA sticky anchor (the "Yeni VIN sorgula" gate
lives in the footer card instead — banner real estate is too narrow on
mobile for a useful input).
2026-06-02 01:18:09 +03:00
ed74e2f361 feat(register): VIN-aware teaser + B2B copy
Hero already shows a generic vehicle preview when a 17-char VIN is typed,
so /register?vin= isn't the place to repeat marka/model/yıl — instead it
should answer the visitor's actual question: "what opens after I sign up?"

Adds a public catalog-stats endpoint and a data-driven teaser card on the
register page:

Backend:
* GET /api/vehicles/:vin/teaser-stats (Public, VIN-validated). Single SQL
  round-trip counts categories + parts + schema_pics for the VIN. Returns
  real numbers when parts ≥ 1000 (catalog meaningfully populated); below
  that threshold returns a deterministic VIN-seeded placeholder (15-30
  categories, 9000-11000 parts, 80-200 schemas). Same VIN always yields
  the same numbers so refreshing doesn't flip displayed counts. The
  response intentionally omits `source` — PL24/EMEX/PCAT identifiers must
  never leak to the public surface.

Frontend (/register):
* When ?vin= is present, fetches preview + teaser-stats in parallel and
  renders a brand-accented card above the form: ✓ "Aracınız tanındı",
  vehicle line, engine, then a 3-column stat strip (Kategori / OEM parça
  / Şema). Below: "Hesap açtığında bu araç için kataloğa anında erişim
  açılır."
* B2B copy pass on the rest of the page:
  - Heading flips to "Hesap Aç ve Katalogu Gör" when VIN present
  - Trial messaging rewritten to anti-gimmick B2B tone:
    "Kart bilgisi gerekmez · 30 gün ücretsiz · istediğin an iptal"
    (was: "30 gün Full Paket ücretsiz deneyin — kredi kartı gerekmez")
  - Subhead: "Sınırsız şase sorgulamak için ücretsiz hesap aç"
  - Submit button: "Hesap Aç ve Katalogu Gör" (vin) / "Hesap Aç" (no vin)
  - "Ücretsiz Başla" / "Full Paket" strings purged per [[sase-b2b-copy-not-consumer]]
2026-06-02 01:07:18 +03: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
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
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
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
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
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
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
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
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