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>
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).
PR #68 added `@sentry/react ^9.0.0` to apps/web/package.json without
updating pnpm-lock.yaml — the merge deploy (zwso4wc44skscw8k0ocw8wwo)
failed at `pnpm install --frozen-lockfile`:
ERR_PNPM_OUTDATED_LOCKFILE: specifiers in the lockfile don't match
specifiers in package.json:
* 1 dependencies were added: @sentry/react@^9.0.0
Regenerated with pnpm@10.29.3 (matches the Dockerfile activation).
Only +80 lines (the new sentry deps); no other importers touched.
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.
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>
Coolify env vars only reach docker-compose services that explicitly reference
them via ${VAR}. Adding CATALOG_SOURCE_DB_ENABLED + PCAT_SOURCE_DB_URL +
EMEX_SOURCE_DB_URL to both the api and worker `environment:` blocks so the
catalog-source-db lookup layer can pick them up. All three default empty/false
so this is a no-op until set in the Coolify env editor for an environment.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
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>
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").
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>
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.
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).
Migrates the brand index off its inline view toggle to the shared
ViewModeToggle/CatalogHeader primitives from 10A, then adds the toolbox the
audit called out:
- Brand search: client-side, Turkish-locale fold over brandName; visible
X-clear button; the no-match panel exposes the original query
- In-plan brands rendered above locked ones with two sub-section headers
("Planındaki markalar" / "Plan dışı markalar"); locked section gets the
"upgrade to access" microcopy
- Hide-locked toggle with aria-pressed (also persisted as a click event so
we can size the cohort that prefers a clean view)
- Searching collapses the section split into one ranked list — matches
the "show me what I typed" mental model
- Locked brand cards/rows are now real `<Link to="/dashboard/subscription">`
buttons so the upgrade target is one tap; firing
`catalog_locked_brand_upgrade_clicked` with brand_name + surface
- `hover:-translate-y-0.5` (jittery on grid scroll) → `hover:scale-[1.02]`
- ARIA tablist replaced by the role="group" + aria-pressed pattern from
the shared ViewModeToggle — keyboard story actually works now
- Drops manual `encodeURIComponent(brand.brandName)` in `params`; TanStack
Router already encodes — caught the audit's double-encoding risk
New PostHog events: catalog_brands_viewed, catalog_brand_search_used,
catalog_hide_locked_toggled, catalog_view_mode_changed,
catalog_brand_clicked, catalog_locked_brand_upgrade_clicked.
Three drift-prone catalog surfaces (main /catalog, /catalog/$brandName, and
the legacy CategoryViewToggle) each carried their own inline view-mode
toggle with subtly different aria labels, sizing, icon paths and even a
"Sutun" typo. New surfaces will inherit a single primitive.
- ViewModeToggle: grid/tree/columns radio-group with i18n labels driven by
`catalog.view.*` keys; fan-out by passing `groupLabelKey`.
- useViewMode(key): centralises read/write of `brandViewMode`/`modelViewMode`
in user-settings — page just owns its current value.
- CatalogHeader: title + subtitle + optional breadcrumb trail + back button +
trailing actions slot, so every catalog drill page renders the same shell.
i18n: adds catalog.subtitle, catalog.view.{groupLabel,grid,list,columns},
catalog.{brandSearch,modelSearch}{Placeholder,NoMatch}, catalog.{hideLocked,
showLocked,lockedSection,lockedSectionHint,inPlanSection,goToModels,
selectBrandHint,columnsBrowseHint,sortLabel}, catalog.sort.{newest,oldest,
alphabetical}.
These primitives stand alone in this commit — the main /catalog and
brand-detail pages migrate to them in 10B and 10C.
The old 66-line history page was a raw list with `any[]` typing, no search,
no sort, no delete, no dates, no i18n and no analytics — opening a vehicle
detail was the only action available.
Replaces it with a real management surface:
- Search-by-text (brand, model, VIN, year, engine) — client-side over the
paginated payload, Turkish-locale case-folding
- Sort dropdown (newest, oldest, brand A→Z, year DESC)
- Per-item Remove with a confirmation dialog; mutation does optimistic
setQueryData so the card disappears immediately, then invalidates the
search-page recent-history query
- Relative-time "decoded {when}" using Intl.RelativeTimeFormat with the active
locale; absolute timestamp lives in title/aria for power users
- Plan-lock badge: when the user's plan no longer covers a brand in their
history, that card surfaces a Lock chip linking to /subscription
- Brand logo, VIN with copy-to-clipboard, focus-visible rings, Link.preload
intent so opening a vehicle is instant
- Empty state (no history) and no-results state (filter wiped everything)
are now distinct, both with the right CTA
- PostHog events: history_viewed, history_search_used, history_sort_changed,
history_load_more, history_item_clicked, history_vin_copied,
history_item_deleted — full funnel coverage from open to action
- Full i18n via new `history.*` block in tr.json/en.json, plus the page now
fetches paginated (PAGE_SIZE × 8 first chunk, "show more" client-side)
The 460-line dashboard "home" was a low-value stat-and-profile screen:
new users saw mostly zeros, the subscription card duplicated info already on
/subscription and /billing, and the page sat between login and the product's
actual value (VIN decoding). Login → 1 click → search added no information.
Now /dashboard short-circuits via beforeLoad into /dashboard/search.
Trial urgency and subscription status are already surfaced by the dashboard
layout shell (TrialUrgencyBanner). The "Gösterge Paneli" sidebar entry is
removed since it now duplicates "Arama".
If we miss the at-a-glance overview later we'll bring it back as a real
command center (VIN input front-and-center, recent searches prominent, plan
mini-strip) — based on PostHog data, not on SaaS-template reflex.
Root cause: landing was almost entirely hard-coded Turkish (5 t() calls in
1889 lines), and initLocale() — which reads the persisted locale from
localStorage on boot — was never called from main.tsx, so a reload always
reverted to the TR default.
This commit fixes both.
apps/web/src/main.tsx
- Call initLocale() before first render so the persisted choice survives reload.
apps/web/src/routes/__root.tsx
- Translate the skip-to-content link via a new `a11y.skipToContent` key.
apps/web/src/routes/index.tsx
- Internationalize ~145 user-visible strings across hero, brand strip, features,
comparison table, bento grid, use cases, stats, dashboard showcase, ecommerce
block, testimonials, pricing tiers, FAQ, final CTA, referral banner and ARIA
labels. Module-scope data arrays moved inside the component so re-renders
pick up the active locale.
- Wrap the three Remotion players (Schema / Dashboard / Ecommerce) so they read
the current locale and pass translated labels into their inputProps.
Remotion demos (DashboardDemo, EcommerceDemo, SchemaDemo)
- Each now accepts a `labels` prop (DashboardDemoLabels / EcommerceDemoLabels /
partLabel). All hard-coded TR sidebar items, placeholders, category names,
product names, "Add to Cart" buttons, the cart-added toast, overlays and
stat labels are now driven by labels with a TR fallback (keeps Remotion
Studio preview working standalone).
messages/{tr,en}.json
- New blocks: `a11y`, `landing.*` (huge tree covering every section above),
`landing.dashboardDemo`, `landing.ecommerceDemo`.
Intentionally preserved as data, not translated: brand names (Volkswagen,
Mitsubishi …), VIN literals, OEM codes (1J0 820 803F …), numeric stat values
(1.2sn, 27, 1M+, %99.9 …), prices (200/350/500/999), and the testimonial
person name "Özge D." (proper noun).
Verified: typecheck clean, 17/17 test files / 57/57 tests pass, biome clean,
Playwright TR↔EN round-trip succeeds, only the testimonial name remains as
Turkish on the EN page.
Adds a four-stat band below the hero CTAs — 27 brand catalogs, 1M+ OEM/alt
parts, 30-day free trial, 7-day no-questions refund. Big tabular-nums numerals
with small labels; divides on sm+. Every number is product or policy fact we
can defend — no fabricated customer counts, no testimonial copy.
Side effect: FAQPage JSON-LD now injected into <head> via useEffect (was an
inline <script dangerouslySetInnerHTML>). Same SEO payload, less attack
surface, tagged with data-sase-faq so it's diff-friendly.
Pricing page
- "Nasıl çalışır" now renders as a vertical timeline with alternating sides on
md+ and a connecting rail — no more 3-equal-cards AI tell
- Hero trust strip swaps four identical Check icons for distinct ones
(CalendarClock / CreditCard / RotateCcw / Check) so each promise reads on
its own
- FAQ section emits FAQPage JSON-LD for rich SERP results (free SEO upside;
no UI cost)
Subscription page
- FeatureMatrix gets sticky thead + a right-fade gradient on narrow viewports
so users can tell more columns lie offscreen
BrandSelector
- Now fully controlled — drops the internal `selected` state that could drift
from the prop source of truth
- Full-plan auto-select effect compares against the live selection and only
fires when out of sync, removing the infinite-loop risk if parents passed an
unstable onSelectionChange (now also ref-stashed)
- Search input appears when there are more than 12 brands; Turkish-locale
case-folding so "Volkswagen"/"volkswagen" both match
- No-match state for empty search results
TrialUrgencyBanner
- Three urgency tiers (soft 7d, urgent ≤3d, critical ≤1d) — colors and
tighter copy escalate as the trial winds down
- Adds an elapsed-time progress bar so "3 days left" reads as 90% used at a
glance (number alone undersells urgency at the tail)
- LS dismissal key now userId-scoped (mirrors subscription page)
- Banner emits `tier` alongside days for funnel segmentation
- Billing empty state now has a ReceiptText avatar and (when no subscription)
a "choose plan" CTA — beats a bare "no payments yet" line
- Receipt buttons promoted from ghost to outline so users actually find them
- Failed payments show a help icon with a hint (card declined → bank / retry)
- Stripe button: Clock → Loader2 (Clock-as-spinner was awkward)
- Stripe init failure now also surfaces inline with a retry button — a toast
vanishing in 4s left users with no recovery affordance
- Adds an accepted-cards trust strip (VISA / Mastercard / Troy / 3D Secure)
below the pay button to ground the secure-checkout copy
- PlanGrid now wraps cards as a real radiogroup with role=radio + aria-checked +
focus-visible ring (was aria-pressed on plain buttons)
- Adds ProvisioningStuckHint: at 30s in trial provisioning we surface a
contact-support + retry escape so users aren't trapped on a spinner
- PaymentMethodSection strips stale brand IDs against the live /brands query
before Stripe checkout (would have 4xx'd silently)
- trial-banner dismissal LS key now scoped to userId + endDate (was endDate
only — two users on the same browser could leak state)
- Sticky CTA disabled state gains a hint title + aria-disabled + opacity
- Brand-selector Suspense gets an 8s slow-load fallback with reload affordance
- OnboardingCompletedView shows the actual subscription plan name (was hard-
coded "Full Paket")
- Picks up sase-pending-period stashed by the public pricing toggle
- Hero gets a primary "start trial" CTA + secondary "see plans" scroll-to anchor
- Adds monthly/yearly billing toggle that switches displayed amounts and surfaces
effective per-month yearly cost
- Yearly savings shown as both percent and TL/year (no more vague "save")
- Full Paket card visually elevated (ring + tinted bg + slight vertical offset on lg)
- Plan CTA carries plan=key into /register; chosen period stashed in localStorage
- New PostHog events: pricing_hero_cta_clicked, pricing_period_toggled,
pricing_plan_cta_clicked
- Auto-focus the "Şase Aramaya Başla" button when the completed card
mounts so keyboard users can Enter through without tabbing.
- Replace the hard-coded `text-red-600 dark:text-red-400` on the trial
error message with the `text-destructive` design token.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Show a "Atla" link once the trial actually provisioned so impatient or
returning users can jump straight to the confirmation card; captures
onboarding_skipped.
- Reserve a min-height on the provisioning container so the dialog
doesn't jerk when the loader/error blocks appear or disappear.
- Re-check animation-ended on visibilitychange — background tabs throttle
setTimeout, which could leave a user stuck if they tabbed away during
the 7.5s window.
- Show the default close X again when the modal is dismissible (completed
or escape-hatch active) instead of hiding it for the whole lifecycle.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- End date no longer hard-codes tr-TR; follows the active locale.
- The plan badge reads from subscription.plan.key (falls back to "full"),
so a future change to what the trial provisions doesn't make the UI lie.
- The "Şase Aramaya Başla" CTA captures onboarding_completed, so the
funnel can distinguish a user who actually clicked through vs one who
dismissed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When the trial mutation either fails repeatedly or just hangs past 30s,
new users were trapped staring at the spinner with no way out. Add:
- A `trial_failed` capture on every mutation error (Onboarding funnel can
finally measure the activation failure rate).
- After 2 failures OR 30s in provisioning, surface a "still trying"
panel with "Destek ile konuş" (opens Chatwoot) and "Yine de kapat"
options, and let interact-outside / Esc dismiss the modal again.
- An openChatwoot() helper + typed `toggle` on the ChatwootApi so other
components can open the support widget without poking the global.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When the landing decodes a VIN for a logged-in visitor and the API
returns a multi-candidate response (no single id), the code was
navigating to /dashboard/vehicles/$id with id=undefined. The catalog
page then asked the API for /vehicles/undefined and the backend
"invalid input syntax for type uuid: 'undefined'" message landed in
front of the user.
Two-sided fix:
- On the landing, if the decode reply has no id, hand the VIN off to
/dashboard/search?vin=… instead — that page already renders the
candidate-picker and the unrecognised-VIN flow.
- On the vehicle detail page, gate both queries on a UUID check and
render a friendly "Geçersiz araç bağlantısı / Aramaya git" panel for
any malformed id. Also stop showing raw backend error messages in the
generic error state — surface the localised hint instead.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Capture category_view_changed and catalog_search_opened so the funnel
picks up category-view and cross-tree search activation (previously
uninstrumented).
- Expose clearChatwootVehicle() and call it from the route's effect
cleanup so a later support chat doesn't carry the previously-viewed
car's VIN/brand.
- Match the loading skeleton shape to the active viewMode — render a
card grid for grid mode (was always 8 row-bars).
- Switch the vehicle title to the display font (font-display, tracking-tight)
for typography parity with the rest of the app.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Move every hard-coded Turkish string on /dashboard/vehicles/:id and the
drill-down /categories/:categoryId page into a vehicle.* message block
(tr + en) — page header, error/404 panels with retry, "Araç Bilgileri"
accordion, "Yedek Parça Kategorileri" + the new empty state, equipment
title and show-more/less toggle, summary labels — plus the leaf-category
catalog-error panel and the drill-down's loading/retry copy.
Also translate the CatalogSearch component (placeholder, arias,
searching/treeIncomplete/noMatch hints, section headings, parts-count
badge) under a new catalogSearch.* block, since it lives inside the same
surface and the rest of the page already speaks two languages.
PCAT_LABELS / EMEX_LABELS / VINAPI_FIELDS / LABEL_OVERRIDES are
source-specific data tables, not UI chrome — left as-is.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace `vehicle: any` in the route + every helper (getVehicleAttributes,
fromPcat, fromEmex, fromVinApi, getFallbackAttributes, getDisplayAttributes,
getEngineCode, getEquipment, VehicleSummary, VehicleAttributes,
VehicleEquipment) with the shared Vehicle type, and type the two queries
as Vehicle / CategoryNode[]. The opaque rawData blob now goes through a
small RawData interface that captures the four source-specific shapes
(pl24, parts-catalogs, emex, vin-api) instead of `any` index access, so
the per-source helpers stay readable.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Render a composed "no categories found" panel when the category tree
resolves to an empty array, so vehicles without a catalog stop showing
a silent void under "Yedek Parça Kategorileri".
- Collapse the equipment list to the first 8 codes by default with a
"Tümünü göster (N)" toggle instead of a sub-scrollarea — friendlier
on mobile, no card-in-card scroll.
- Loosen the header flex layout: the title block gets `min-w-0 flex-1`
with break-words and a truncated VIN line so long brand+model strings
no longer crush the layout on narrow screens.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Move every hard-coded Turkish string on /dashboard/search into a
search.* message block (tr + en) and resolve them via t() — title,
subtitle, input label/placeholder, counter, example button, submit, the
error banner with the subscription nudge, the report-VIN flow + toasts,
preview loading + identified badge + hint, recent-searches header /
see-all / open hint / row aria-label, validation/generic/toast errors,
auto-correction toast, decoding skeleton sr-only text, and the candidate
selection failure toast.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Until now any VIN carried from the landing — including the example VIN
filled by "Örnek aramayı deneyin" — auto-decoded after signup and jumped
straight to the vehicle catalog, skipping the search page entirely. That
is the right behaviour for a visitor who typed their own VIN, but the
example button is meant for exploration; users who clicked it should
land on the search page so they can look around.
Track an `isExampleVin` flag on the landing (set by fillExampleVin,
cleared on any keystroke), carry it through to /register and onward to
/dashboard/search as `example=1`, and skip the auto-decode there when
the flag is present (the VIN is still prefilled). User-typed VINs keep
the existing land-on-catalog behaviour.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add a visually hidden Label for the VIN input (the previous version
relied on placeholder text only) and an aria-label on each history row
so screen readers announce the vehicle and VIN before activation.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Clicking a recent search now navigates straight to the vehicle's catalog
page instead of just refilling the VIN input — the row already carries
the vehicle id, so the re-decode round trip is wasted. Hint copy updated
to match.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace the `any[]` history query with a small VehicleHistoryItem
interface covering the fields the row renders, and lower the API limit
from 20 to 6 since the UI slices to 6 anyway (saved bandwidth + correct
types on .map).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per-job moveToDelayed for cooldown livelocked the worker: the activity key is
refreshed by every user request, so 60s later the deferred job comes back, key
is still set, defers again. Last 3h on prod logged ~1800 deferrals against 12
real inits and one completion every ~6 min.
Tag RateLimitError with a `cause`. checkCooldown throws "cooldown"; the worker
now calls `this.worker.rateLimit(delayMs)` and throws Worker.RateLimitError() —
the whole queue waits once instead of cycling every job. checkTimeWindow throws
"time-window"; that branch keeps the existing job.moveToDelayed (per-job) so
EMEX (no scrape window) keeps flowing while PL24/pcat jobs sleep till 09:00.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The live-chat SDK, widget iframe, websocket and avatars are served from
destek.sase.tr; the helmet CSP didn't whitelist it, so the browser
blocked sdk.js (script-src violation) and the widget never loaded.
Add destek.sase.tr to script-/img-/media-/connect-/frame-src (+wss for
ActionCable).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Site-wide live-chat widget served from the self-hosted Chatwoot at
destek.sase.tr, with verified user identity and vehicle context.
- apps/web: lib/chatwoot.ts loads the SDK lazily (mirrors the PostHog
init pattern), init in main.tsx, identify logged-in users in __root
via a server-computed HMAC, and attach the viewed vehicle (VIN/brand/
model) as contact custom attributes on the vehicle detail page.
- apps/api: GET /api/chatwoot/identity (AuthGuard-protected) returns
HMAC-SHA256(user.id) so the widget can use verified identity.
- env: VITE_CHATWOOT_BASE_URL + VITE_CHATWOOT_WEBSITE_TOKEN (build-time,
wired through docker-compose.coolify.yml build args + Dockerfile ARG)
and CHATWOOT_HMAC_TOKEN (api runtime). All optional — widget and
endpoint no-op when unset.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The worker threw a custom RateLimitError for active-user cooldown and off-hours
windows, but BullMQ treated that as a normal failure: it retried with the
queue's exponential backoff (ignoring retryAfterMs) and dropped the job to
"failed" after attempts:3. So during busy hours sub-jobs were discarded en
masse and the requested defer time (e.g. "wait until 09:00") never applied.
Convert RateLimitError into job.moveToDelayed(now + retryAfterMs, token) +
DelayedError — BullMQ's contract for "not done, not failed, retry later". This
does not consume an attempt and honours the exact delay, so cooldown jobs wait
~60-120s and off-hours jobs wait until the window reopens, then resume. Genuine
errors still fail/retry normally.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>