- 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>
The payments table forced min-w-[640px] inside overflow-x-auto, so mobile
got an awkward horizontal scroll, and the 5-option status filter could
overflow the viewport. Make the table a responsive layout: on mobile each
payment renders as a labelled stacked card (column label via ::before
from data-label, header row hidden), and on sm+ it stays a normal table.
Let the segmented filters wrap instead of overflowing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>