Resolves the recurring promote-by-cherry-pick divergence (decode/pcat/emex live
on both branches with different SHAs — all auto-merged). Net-new to main:
- fix(decode) fd78957: mark pcat skip/transient in timings (stop false catalog-gap reads)
- feat(catalog) c972a17: one-click report on empty/error catalog states
- compose: P_DB_ENABLED/P_DB_URL env (inert until set in Coolify)
Conflict: docker-compose.coolify.yml — both sides added env at the same spot;
took dev's superset (main's Directus/Blog vars + dev's P_DB vars).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
# Conflicts:
# docker-compose.coolify.yml
'Çalışmadı mı? Bildir' opened the Sentry feedback dialog; most users
bail at a form. Now a single click captures a catalog_issue_reported
PostHog event with the vehicle/category context (vin, vehicle_label,
category_name, source, page) and flips to 'Bildirildi — teşekkürler'.
The identified person already carries the reporter's email; the Süper
Panel lists these under /analytics/reported-categories. The automatic
Sentry degradation report (with session replay) still fires via
useReportCatalogDegradation — unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Cold-pool fast-fail (and transport blips) make pcat return null without
ever evaluating the VIN; EMEX then identifies the car and the row lands
as 'No catalog — identified as X' (pcat sub-timing 1-2ms). Downstream
catalog-gap detection can't tell these from true coverage gaps and
flags covered brands (Audi, 2026-06-11, 7 retries by one user).
- timings.pcat_skipped=1 on cold-pool fast-fail
- timings.pcat_transient=1 on transport failure (circuit-open skips
already carry pcat_circuit_open)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
EMEX egress was DataImpulse-first: fetchEmexHtml tried 3 dead-prone DataImpulse
ports before falling to the Floxy fallback, and the browser scraper launched on a
random DataImpulse port. With ~50% of DataImpulse ports dead, this burned the 25s
decode budget before reaching Floxy (observed: a Ford VIN where pcat correctly
returned null fast, then EMEX's DataImpulse retries ate the budget).
ssd tokens are replayed from emex's own HTML and are NOT strictly IP-bound on the
.aspx endpoints (the prior random-port-per-call primary proved that), so a sticky
Floxy IP across the flow is strictly safer.
- EMEX_PROXY_PROVIDER (default "floxy" | "dataimpulse" | "none"). floxy →
fetchEmexHtml schedule = [floxy, floxy, dataimpulse] (Floxy sticky primary,
rolls IP on transport failure, single DataImpulse last-ditch). dataimpulse →
legacy [di,di,di,floxy,floxy]. Default HTTP agent (image-dims) follows provider.
- EmexBrowserService launches chromium on a sticky Floxy session (one residential
exit IP for the browser lifetime; fresh id per relaunch) under the same flag.
- Reuses the existing EMEX_FLOXY_* config (same Floxy account pcat now uses).
Rollback: EMEX_PROXY_PROVIDER=dataimpulse. typecheck+biome+11 emex tests green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
EMEX egress was DataImpulse-first: fetchEmexHtml tried 3 dead-prone DataImpulse
ports before falling to the Floxy fallback, and the browser scraper launched on a
random DataImpulse port. With ~50% of DataImpulse ports dead, this burned the 25s
decode budget before reaching Floxy (observed: a Ford VIN where pcat correctly
returned null fast, then EMEX's DataImpulse retries ate the budget).
ssd tokens are replayed from emex's own HTML and are NOT strictly IP-bound on the
.aspx endpoints (the prior random-port-per-call primary proved that), so a sticky
Floxy IP across the flow is strictly safer.
- EMEX_PROXY_PROVIDER (default "floxy" | "dataimpulse" | "none"). floxy →
fetchEmexHtml schedule = [floxy, floxy, dataimpulse] (Floxy sticky primary,
rolls IP on transport failure, single DataImpulse last-ditch). dataimpulse →
legacy [di,di,di,floxy,floxy]. Default HTTP agent (image-dims) follows provider.
- EmexBrowserService launches chromium on a sticky Floxy session (one residential
exit IP for the browser lifetime; fresh id per relaunch) under the same flag.
- Reuses the existing EMEX_FLOXY_* config (same Floxy account pcat now uses).
Rollback: EMEX_PROXY_PROVIDER=dataimpulse. typecheck+biome+11 emex tests green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Prod decode was dominated by the DataImpulse proxy: ~50% of ports dead at any
moment, 57% of captured tokens discarded by validation, the dead-port slot
never evicted (invalidateSession only fired on 401/403, never on transport
timeout), and a cold pool blocked the user inline on a multi-second capture.
pcat sub-timing was p50 8.3s / p90 56s, blowing the 25s decode budget.
Verified on prod: the TWS- token is NOT request-time IP-bound (0 auth 401/403
across thousands of calls; a token captured on one IP replays 200 through
arbitrary fresh residential IPs). So the proxy can rotate freely.
- Transport swap to Floxy residential (PCAT_PROXY_PROVIDER=floxy|dataimpulse|none).
Call/validate leg = a fresh exit IP per request (max IP diversity → min per-IP
ban); capture leg = a sticky session per Playwright attempt. DataImpulse +
direct kept as env fallbacks. Token decoupled from the slot (proxyPort removed).
- Prune 3 zero-yield JWT sites (knkauto/autodo.kz/flynestauto — 0 captures, ~80%
of attempts); add PCAT_JWT_SITES env override.
- Lower catalog-scrape timeout 30s→10s (env-tunable) so a slow IP aborts within
budget and the retry rotates to a fresh IP.
- Cold-pool fast-fail on the decode race: warm in background, let EMEX/PL24
answer instead of blocking inline; treated as a transient miss (no neg-cache).
- Evict a token after N consecutive transport failures on rotating IPs (the
dead-slot bug); reset on any 2xx.
- PCAT circuit breaker mirroring PL24 (transient-only; cold-skip neutral; skip
marks outcome.transient so a pcat-only VIN isn't negative-cached 30m).
Typecheck + Biome + unit tests (categories/vehicles) green. Default flips pcat
to Floxy on next deploy; instant rollback via PCAT_PROXY_PROVIDER.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Prod decode was dominated by the DataImpulse proxy: ~50% of ports dead at any
moment, 57% of captured tokens discarded by validation, the dead-port slot
never evicted (invalidateSession only fired on 401/403, never on transport
timeout), and a cold pool blocked the user inline on a multi-second capture.
pcat sub-timing was p50 8.3s / p90 56s, blowing the 25s decode budget.
Verified on prod: the TWS- token is NOT request-time IP-bound (0 auth 401/403
across thousands of calls; a token captured on one IP replays 200 through
arbitrary fresh residential IPs). So the proxy can rotate freely.
- Transport swap to Floxy residential (PCAT_PROXY_PROVIDER=floxy|dataimpulse|none).
Call/validate leg = a fresh exit IP per request (max IP diversity → min per-IP
ban); capture leg = a sticky session per Playwright attempt. DataImpulse +
direct kept as env fallbacks. Token decoupled from the slot (proxyPort removed).
- Prune 3 zero-yield JWT sites (knkauto/autodo.kz/flynestauto — 0 captures, ~80%
of attempts); add PCAT_JWT_SITES env override.
- Lower catalog-scrape timeout 30s→10s (env-tunable) so a slow IP aborts within
budget and the retry rotates to a fresh IP.
- Cold-pool fast-fail on the decode race: warm in background, let EMEX/PL24
answer instead of blocking inline; treated as a transient miss (no neg-cache).
- Evict a token after N consecutive transport failures on rotating IPs (the
dead-slot bug); reset on any 2xx.
- PCAT circuit breaker mirroring PL24 (transient-only; cold-skip neutral; skip
marks outcome.transient so a pcat-only VIN isn't negative-cached 30m).
Typecheck + Biome + unit tests (categories/vehicles) green. Default flips pcat
to Floxy on next deploy; instant rollback via PCAT_PROXY_PROVIDER.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
New "Bu kod kataloğunuzda" section lists the user's decoded vehicles
whose parts include the queried OEM code, each linking to the schema
page that shows it. Pure sase data (parts.oem_code → vehicles, indexed
exact match) — no TecDoc / vehicle-structure dependency.
GET /parts/oem-vehicles?code=.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Customer entered VXKUPHNKSP4032040 (2023 Corsa F) and got a bare
'Marka desteklenmiyor' — VXK was missing from all three WMI maps, so
identifyBasic could not even name the brand. PL24's opel_parts catalog
ends at CORSA-E (2015-2019, verified via the model-picker page), so the
catalog itself stays a gap, but the user now gets the honest
'Opel 2023 olarak tanındı, katalog henüz mevcut değil' message and the
miss is logged for the coverage backlog.
- WMI_BRAND_MAP + PL24_WMI_SERVICE_MAP + EMEX CATALOG_MAP: VXK -> Opel
- DECODE_CHAIN_VERSION 2 -> 3 (brand-mapping change; invalidates the
stale negative cache for the customer's VIN)
- vin-cache-keys.spec: lock-key assertion checked the bare version char,
which now appears inside the test VIN — assert the ✌️ segment instead
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
POST /vehicles/decode returned the full vehicles row while every consumer
(search, landing, service-test) only navigates by id and re-fetches via
GET /vehicles/:id. That shipped the raw upstream decode payload to the
client on every decode — rawData is 19KB avg / 127KB p95 / 207KB max in
prod — plus the provider name, for nothing.
The response is now { id, brandName, model, year } on both the DB-hit and
fresh-decode paths. Frontend drops the (now absent) source property from
the vin_decode_success event; decode source remains queryable server-side
via query_logs.source.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 02c8321023)
The multi-candidate decode response leaked provider internals (source name,
pcat car ids, EMEX _ssd/_vid/_quickGroupsUrl/catalogId) and made the client
carry them between requests: the frontend stored candidateSource and echoed
pcatCarId/emexCarIndex back on selection.
Now the candidate list returned to the client carries only display fields
(name, description, parameters) plus an opaque key, and the provider mapping
is stashed in Redis (vin:candidates:*, 30m TTL, resolve-cache fallback). The
pick request sends just { vin, candidate }. Legacy pcatCarId/emexCarIndex
body params still work for already-loaded bundles.
Also drops `source` from the public /vehicles/preview response — no consumer
used it, and provider names must never be public (same policy as
teaser-stats).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 2077a9724a)
POST /vehicles/decode returned the full vehicles row while every consumer
(search, landing, service-test) only navigates by id and re-fetches via
GET /vehicles/:id. That shipped the raw upstream decode payload to the
client on every decode — rawData is 19KB avg / 127KB p95 / 207KB max in
prod — plus the provider name, for nothing.
The response is now { id, brandName, model, year } on both the DB-hit and
fresh-decode paths. Frontend drops the (now absent) source property from
the vin_decode_success event; decode source remains queryable server-side
via query_logs.source.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The multi-candidate decode response leaked provider internals (source name,
pcat car ids, EMEX _ssd/_vid/_quickGroupsUrl/catalogId) and made the client
carry them between requests: the frontend stored candidateSource and echoed
pcatCarId/emexCarIndex back on selection.
Now the candidate list returned to the client carries only display fields
(name, description, parameters) plus an opaque key, and the provider mapping
is stashed in Redis (vin:candidates:*, 30m TTL, resolve-cache fallback). The
pick request sends just { vin, candidate }. Legacy pcatCarId/emexCarIndex
body params still work for already-loaded bundles.
Also drops `source` from the public /vehicles/preview response — no consumer
used it, and provider names must never be public (same policy as
teaser-stats).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Polish pass on the survey popover:
- Entrance: slide-up + fade on mount (300ms ease-out, respects
prefers-reduced-motion via motion-reduce:transition-none).
- Step progress: "1 / N" indicator on multi-question surveys so users know
how much is left.
- Esc closes the card (counts as a dismissal); listener attached once with a
dismissRef so it captures current answers, not a stale first-render closure.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Design review of the live survey popover surfaced three issues:
1. NPS 0–10 wrapped to two rows (flex-wrap + flex-1 made the 8/9/10 row
stretch absurdly wide) — now a single equal-width grid row (grid-cols-N
by scale; static map so Tailwind keeps the classes).
2. Single-question ratings forced a redundant "Devam" tap — tapping a score
now auto-advances (450ms debounce to allow changing the pick) and the
submit button is hidden on rating questions.
3. On mobile the card crowded the Chatwoot launcher — bumped to bottom-28
(sm:bottom-24 keeps desktop unchanged).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Remove the EAN line from the matched-article cards and stop fetching/
shipping eans in /p/oem (its only consumer was that line). Snapshot
still holds the data; the endpoint just no longer queries it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
OEM detail "slow load" was two things, not the query (DB 135ms / API
13-120ms):
1. The parts-panel link opened a NEW TAB → full SPA cold boot every
click. Switch to in-app client navigation on plain click (real href
kept, so ctrl/cmd/middle-click still opens a new tab).
2. The /p/oem response shipped each article's oeNumbers + compatible
lists (up to 200 each × 60 articles) that the UI never renders — 96%
of a 370 KB payload. Ship lean articles; aggregates already carry
the cross-refs. 60-article code: 370 KB → ~12 KB.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
trial_urgency_banner_viewed fires ~1s into the page load and raced the lazy
surveys extension — events captured before its hook exists can never activate
an event-triggered survey (affects real users, not just e2e). capture() now
awaits surveysReceiverReady() (onSurveysLoaded, 3s safety timeout) and runs the
eligibility check after the event has gone through.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Long cross-reference lists (some parts have 90+ aftermarket numbers,
160+ OE codes) were hard to scan. Add a filter input that matches
across all three sections (matched articles, aftermarket equivalents,
OE cross-refs) by brand (substring) or code (normalised, so "1j0 973"
finds "1J0 973 702"). Clear button + "no results" state.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
posthog-js silently drops every capture when userAgentData.brands contains
HeadlessChrome (UA string/webdriver masking doesn't help), which also starves
event-triggered surveys in Playwright runs against dev.sase.tr. Production
keeps the bot filter.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Formbricks CE turned out to gate person-based targeting (setUserId/attributes
→ 403 enterprise) — the exact flexibility surveys need. PostHog already holds
the person properties, so surveys move back there in API mode: posthog-js
evaluates eligibility (event triggers, targeting flags like
subscription_status=active, wait periods, per-distinct_id dedup) via
getActiveMatchingSurveys, and we render the popover ourselves — zero PostHog
branding, sase.tr dark-theme styling.
- lib/surveys.ts: display manager + capture payload builders that mirror
posthog-js's own "survey shown/dismissed/sent" shapes exactly
($survey_response_<qid>, $set $survey_dismissed/<id>[/iter], seenSurvey_*,
lastSeenSurveyDate) so the PostHog Surveys results UI works unchanged
- components/survey-popover.tsx: single_choice (+Diğer), open text, rating/NPS
- posthog.ts: capture/pageview/identify now schedule survey checks; register
deploy_env (dev.sase.tr ships the key now → staging traffic is filterable)
- remove @formbricks/js, its CSP entries and build args
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- cover_file uploads in Directus land in the public blog-assets MinIO bucket;
API resolves them to storage.sase.tr URLs (Directus itself is Tailscale-only)
- POST /blog/cache/purge (automation token) lets a Directus Flow drop the
30-min list cache the moment a post changes in the CMS
- blog list + detail pages now render purely from the API; the 4 hand-authored
posts were migrated to Directus earlier and the JSX copies are removed
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Every OEM code was a link → unmatched codes (the majority — fasteners,
clips, body parts TecDoc doesn't carry) opened an empty "no
equivalents" page. Now the schema page batch-checks its codes against
the snapshot (POST /p/matched, one indexed query) and links ONLY the
matched ones; unmatched codes render as plain text. Copy stays for all.
No more dead-end empty pages from the parts panel.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- cover_file uploads in Directus land in the public blog-assets MinIO bucket;
API resolves them to storage.sase.tr URLs (Directus itself is Tailscale-only)
- POST /blog/cache/purge (automation token) lets a Directus Flow drop the
30-min list cache the moment a post changes in the CMS
- blog list + detail pages now render purely from the API; the 4 hand-authored
posts were migrated to Directus earlier and the JSX copies are removed
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@formbricks/js is a loader shim that injects the real SDK as an external
script from anket.sase.tr — connect-src alone wasn't enough; the browser
blocked /js/formbricks.umd.cjs and no survey could render.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Decode persist looked brands up with a case-sensitive eq(), so an
uppercase decode string ("FORD") missed canonical "Ford" → brand_id
NULL + raw uppercase stored as brand_name, splitting one brand across
casing variants in analytics/catalog. Now matches brands
case-insensitively and stores the canonical name. Migration
0013_fix_brand_casing backfills existing rows (60 on prod, 1 on dev).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
"P karşılığı bulunamadı" / "P kapsamında" read as nonsense — "P" is
the internal name, not a user-facing word. Use plain Turkish: "Bu OEM
kodu için uyumlu parça bulunamadı" and describe the catalog gap
without a brand or single-letter label.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Finishes 3b14431 (which only captured the file renames): apply the
identifier/endpoint/env/UI changes so the code matches the new paths
— PModule/PController/PSourceDbService, @Controller("p"), /p/oem,
config key `p`, P_DB_ENABLED/P_DB_URL, "P kataloğundan…" copy.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Drop the third-party brand name from the product surface and code.
The OEM detail feature is now "P" (short for parts) everywhere:
endpoint /p/oem, PModule/PController/PSourceDbService, config key
`p`, env P_DB_ENABLED/P_DB_URL, UI copy ("P kataloğundan…"), and the
unused tab label. Physical snapshot DB stays `td` (server-only).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Blog posts now live in the shared Directus instance (Coolify / Süper Panel
project) instead of the per-env blog_posts table, so prod and staging serve
identical content. API response shape is unchanged; Redis list cache and the
n8n automation endpoint keep working as before.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Blog posts now live in the shared Directus instance (Coolify / Süper Panel
project) instead of the per-env blog_posts table, so prod and staging serve
identical content. API response shape is unchanged; Redis list cache and the
n8n automation endpoint keep working as before.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The snapshot stores scrape-local '/_debug/...' image paths that 404
off-host. Filter the OEM lookup to http(s) image URLs and render the
thumbnail only when one exists, so the detail page shows clean
code-focused cards instead of a grid of broken-image placeholders.
Real CDN URLs will surface automatically once present.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Full marketing-copy audit. Removes price-comparison claims (pricing
step, hero variant B, blog tip) that aren't built; normalises VIN→şase
across TR copy (plan features, e-commerce section, FAQ, blog, about) —
keeping VIN only in the educational "VIN nedir" post and the EN file;
de-consumerises framing (FAQ "aracınız", about "bireysel kullanıcılar").
Fabricated metrics/testimonials left untouched pending review.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Resolve a catalog OEM code to its TecDoc equivalents on a new
/dashboard/oem/$code page: the aftermarket parts that carry it
(brand + article number + image + EAN), buyable supplier
substitutes, and OE cross-references (same part under other makes).
- API: TecdocModule (read-only postgres-js client to the imported
`td` snapshot), GET /tecdoc/oem?code=. Normalisation-based match
(TecDoc stores `1J0 973 702`, catalog gives `1J0973702`); exact
match recovers ~1/10 vs normalised ~5/10 on real codes. Self-
disables without TECDOC_DB_* env → { matched: false }.
- Web: OEM code in the parts panel is now a link (new tab) to the
detail page; "N/A" stays plain text.
- Mirrors CatalogSourceDbModule (raw queries, no Drizzle modelling).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The landing OEM card, the manual-vs-Sase comparison table, and a blog
post advertised "price comparison" / "order history & part tracking" —
features that aren't built. Replaced with real capabilities
(exploded-diagram matching, interactive part diagrams, brand catalogs)
in both tr + en. Mirrors the same accuracy fix already shipped to the
Novu lifecycle emails.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Only ~1.6% of trials reach checkout: the 30-day full-access trial shows the
(urgency) banner solely in its final 7 days, leaving ~3 weeks with no in-app
push, and the value moment (first few queries) goes unconverted.
Add TrialValueUpsell — a calm, value-framed banner that fires on PROVEN VALUE
(user has decoded >= 3 customer vehicles, read server-truth via /vehicles/history)
and only OUTSIDE the urgency window (days > 7), so it fills the gap without ever
overlapping or competing with the urgency banner. B2B copy: frames around
"müşteri araçlarını sınırsız sorgulama", never personal-vehicle wording.
Tracks viewed/cta_clicked/dismissed in PostHog; dismissal persists per trial.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- initSentry() is now idempotent via a cached promise, and the degradation
reporter / feedback opener await it — so an empty/error state that renders
before the SDK's dynamic import resolves no longer drops the event (not missing
the failure is the whole point of the auto-capture).
- environment now resolves hostname-first (sase.tr → production, dev.sase.tr →
staging), so the compose-baked VITE_SENTRY_ENVIRONMENT default ("production")
no longer makes dev browser events indistinguishable from prod.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
handleCheckoutCompleted marked the payment 'completed' BEFORE activating the
subscription, but the idempotency guard keys off payment.status === 'completed'.
So if activateSubscription threw after the payment was marked completed, the
Stripe webhook retry would hit the guard, skip activation, and leave a
paid-but-never-activated subscription.
Fix: activate FIRST, then mark completed — a mid-activation failure leaves the
payment 'pending' so the retry re-runs cleanly. Guard activateSubscription with
an idempotency check (return early when already active) so a retry after a
successful activate but failed mark-completed cannot re-fire revenue events
(PostHog subscription_activated + Meta CAPI Purchase), re-consume referral
credit, or double-insert Full-plan brands. Admin manual-activate already
pre-rejects 'active', so legitimate first activations are unaffected.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
EFT/Havale was retired; Stripe is now the sole payment method. Remove the
EFT code surface (shared PaymentMethod "eft" + EftPaymentInput + eftReceiptUrl,
EFT_RECEIPT_REQUIRED error code, billing UI receipt/filter/label paths,
payments.service eft read paths). DB columns (eft_receipt_url, bank_account_id,
bank_accounts) are kept and marked @deprecated to preserve historical records
and avoid a destructive migration — same pattern as the retired iyzico column.
Faz 3 conversion lever: set locale "tr" on the Stripe Checkout session. The
audience is Turkish B2B and ~60% of sessions reached the foreign-language
hosted page but never started a payment intent (pure abandonment, not decline).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The two things only the browser can add on top of the backend catalog-degradation
reporting:
- Replay-on-failure: when the catalog UI renders empty-tree (decoded vehicle, no
categories) or a drill loadError, capture a browser Sentry warning and flush the
Session Replay → you can WATCH the user hit the dead-end (serkan's session,
reproducible). Per-session deduped (one replay/session covers the whole journey).
- In-app feedback: a "Çalışmadı mı? Bildir" button on the empty-parts, empty-tree
and loadError states opens the Sentry feedback dialog pre-tagged with the
vehicle/category (+ session replay) — turns a parts shop's complaint into a
structured, triageable report instead of an email.
Browser events are fingerprinted source="browser" so they form their own
"what users actually saw" issues (carrying replays) next to the server-side
detections. tsc + biome + web build clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Paid ads pull many end-users who trial but never pay (~1% trial→paid in the DB
funnel). Add a soft qualifier before the register form: the visitor picks their
segment (parts dealer / wholesaler / e-commerce / service-fleet / vehicle-owner).
Business segments proceed; "vehicle owner" gets a gentle B2B-only note with a
"continue anyway" escape hatch (soft, no hard gate — protects trial volume).
The choice is captured (signup_segment_selected event + b2b_segment person
property) so qualified-trial ratio is measurable and channel→segment→paid becomes
queryable; later it can seed a Meta audience / CAPI qualified signal. Copy follows
the B2B framing rule (no "kendi aracınız"; "sınırsız şase sorgulama"). Persisted in
localStorage so a reload / OAuth round-trip doesn't re-ask.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>