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>
Same BullMQ 5.68 restriction as the prefetch fix (acd5691): a custom job ID
containing ':' is rejected with "Custom Id cannot contain :". enqueueTranslation
built jobId `tr:<base64>`, so every enqueue threw — and both call sites
fire-and-forget with .catch(warn), so it failed silently: fresh terms were
NX-flagged as queued but never actually enqueued, leaving new EMEX/PCAT terms
untranslated (English). Use 'tr-' prefix.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
BullMQ 5.68 rejects custom job IDs containing ':' (its key separator) with
"Custom Id cannot contain :". prefetch-init's addJob built sub-job IDs as
prefetch:<vehicleId>:<categoryId>:<action>, so every attempt to queue a
children/parts job threw and the whole init failed. This was latent in the
reactive path (failures just logged) and surfaced once the hourly backfill
started driving inits at volume. Use '-' as the separator; the IDs only need
to be deterministic for dedup, not parseable.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
dev.sase.tr (staging) and sase.tr (prod) BOTH run NODE_ENV=production with
SEPARATE databases, so the previous NODE_ENV check would have let the hourly
backfill sweep run against the dev DB too. Gate on the canonical prod host
instead (COOLIFY_FQDN / BETTER_AUTH_URL), with an explicit
CATALOG_BACKFILL_ENABLED override. Default off for any unknown host.
New isCatalogBackfillEnabled() helper used by both the cron registration and
processBackfillScan; dev redeploy now removes the stale scheduler.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
prefetch was reactive-only (on decode) and processInit read top-level
categories straight from DB, so a vehicle decoded but never viewed got
no catalog. Add a production-only sweep so no decoded vehicle is left
without catalog data.
- processInit self-seeds top categories via getCategoryTree when DB has
none (fetches+inserts top groups from PL24/PSA/EMEX), closing the
never-viewed gap for both reactive and backfill paths
- new backfill-scan job + hourly cron: phase 1 queues decoded vehicles
with zero parts, phase 2 rolling createdAt-cursor rescan of all decoded
vehicles (prefetch-init is idempotent → gap-fills partial ones)
- guardrails: skip wave if queue backlog > 1000, per-source cooldown,
business-hours window (isWithinTimeWindow), batch <=20, in-flight guard
- PRODUCTION ONLY: gated on NODE_ENV both at cron registration and in
processBackfillScan; dev has a separate DB and must not scrape
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The new cross-tree catalog search sits directly above the top-level grid on
the vehicle page, so the grid's own "Kategori ara…" filter was a redundant
second search box. Add a hideFilter prop to CategoryGrid and set it there;
catalog-browse and deep category levels (no global search) keep the filter.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The vehicle page search previously only filtered category names at the
currently rendered level. Add a server-side cross-tree search over what's
already drilled into the DB.
New GET /categories/search/:vehicleId?q= returns two sections:
- categories: name-matched leaves UNION the leaf categories that contain a
matching part (with hit count). "fren balatası" matches no leaf by name —
the pads are parts under leaves like "Disk freni" — so the union surfaces
the right leaves.
- parts: parts matching every token on name, or the raw query on oem_code,
with OEM + leaf + breadcrumb.
Pure DB read (no upstream drill); a treeIncomplete hint is returned when the
vehicle's tree looks barely drilled. Frontend adds a debounced search box on
the vehicle page that hides the normal browse while active.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The custom-integration / bulk-query CTA was a dead button. Wrap it in a
Link to /contact.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Follow-up to the landing fix: the credibility strip (subscription/checkout),
meta description, auth-layout badge and blog copy still claimed "50+
marka" while the catalog supports 27. Align all to 27 for consistency.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Carry the plan a visitor picks on the landing pricing through to checkout:
- Pricing CTAs now pass ?plan=<key>; logged-in users go straight to
/dashboard/subscription, others to /register.
- Subscription page accepts ?plan= (and a localStorage carrier that
survives the value-first register→search onboarding detour) and
preselects the plan — advancing to brand selection (or payment for
Full) — unless the sub is active/pending or onboarding is in progress.
- Register validates ?plan and persists it on signup so the choice isn't
lost through the trial onboarding.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Drop the staggered sm:translate-y offsets on the three brand tiers so
their titles, prices and feature lists share a baseline, making the
plans scannable side by side instead of cascading down.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add an objection-handling FAQ (brands, KVKK/security, no card, cancel
anytime, integration, no-VIN path) before the closing CTA, and a
KVKK/SSL trust line under the final CTA button.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The closing CTA was an outline (ghost) button — the weakest style at the
most important conversion point. Make it a filled brand button, size lg,
with clearer copy ("30 Gün Ücretsiz Başla — Kart Gerekmez"). Move the
referral banner (a retention loop) to after the final CTA so it no longer
sits between the pricing and the primary close.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The page claimed "50+ marka" in the hero, carousel, plan and meta while
the stats block and the actual brand carousel show 27 — a credibility
contradiction. Align every brand-count mention to 27. Also align the
wrong-part cost: hero said "450 TL", final CTA "200-500 TL" — use the
200-500 TL range in both.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Route all lifecycle/transactional emails through Novu
(api.bildirim.semih.ai, delivered via Postal). A framework-agnostic
client is shared by the NestJS API and the standalone BullMQ worker.
- welcome + referral on signup (better-auth user.create.after)
- email-verification + password-reset (auth.ts; token links never
track-wrapped so the one-time token survives)
- referral-qualified / referral-reward to the referrer on qualification
- payment-success / payment-failed in the Stripe webhook handlers
- trial-ending + win-back via a new daily lifecycle-email cron (worker),
idempotent via a 1-day endDate window (no sent-flag column)
- signed track.sase.tr CTA links when MAILTRACK_SECRET is set
- NOVU_* / APP_PUBLIC_URL / MAILTRACK_SECRET env added to config,
validation, .env.example and both compose service blocks
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>