Commit Graph

626 Commits

Author SHA1 Message Date
68433bc5f1 Merge pull request 'feat(analytics): thread category/vehicle names on remaining SchemaViewer routes' (#87) from dev into main
Reviewed-on: #87
2026-06-03 14:42:55 +00: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
c5cc757166 Merge pull request 'dev' (#86) from dev into main
Reviewed-on: #86
2026-06-03 14:20:40 +00:00
b56ea7c0fb chore(infra): wire POSTHOG_API_KEY/POSTHOG_HOST into api+worker compose env
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Server-side PostHog was disabled in prod (POSTHOG_API_KEY never reached the
container — it wasn't referenced in the compose environment blocks, and per
prior Coolify experience env only injects when referenced as ${VAR}). So all
server-side events (payment_success/failed/refunded, subscription_activated)
silently no-op'd. Reference the vars in both api and worker environment blocks
so a Coolify-set value reaches the container. POSTHOG_HOST defaults to the EU
ingestion host; empty key keeps it disabled (e.g. on dev, to avoid polluting
the prod project).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 16:10:21 +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
5d40626a55 Merge pull request 'fix(catalog): case-insensitive PL24 group-wid drill check' (#85) from dev into main
Reviewed-on: #85
2026-06-03 12:49:21 +00: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
0decda8845 Merge pull request 'feat(analytics): enrich empty-catalog signal with vehicle/category names' (#84) from dev into main
Reviewed-on: #84
2026-06-03 11:19:26 +00: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
f233080623 Merge pull request 'fix(analytics): stop orphaning anonymous person on load, breaking signup attribution' (#83) from dev into main
Reviewed-on: #83
2026-06-03 08:05:00 +00: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
ca50a3e176 Merge pull request 'dev' (#82) from dev into main
Reviewed-on: #82
2026-06-03 00:03:13 +00: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
88beae4930 Merge pull request 'dev' (#81) from dev into main
Reviewed-on: #81
2026-06-02 22:49:49 +00: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
83033a160d Merge pull request 'dev' (#80) from dev into main
Reviewed-on: #80
2026-06-02 12:32:58 +00: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
ed01ee44d2 Merge pull request 'dev' (#79) from dev into main
Reviewed-on: #79
2026-06-01 23:37:52 +00: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
dad2060bec Merge pull request 'dev' (#78) from dev into main
Reviewed-on: #78
2026-06-01 22:41:51 +00: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
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