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>
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>
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>
getMyPayments returned the raw payment row, exposing internal fields
(adminNote, iyzicoPaymentId, bankAccountId, session/intent ids) to the
end user. Replace with an explicit projection that returns only what the
billing UI needs, joins planName from the subscription's plan (was always
"-"), and surfaces Stripe receipt availability as a hasStripeReceipt
boolean instead of the raw payment intent id. Frontend reads the boolean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Subscription status is "cancelled" (and "pending") in the API; the i18n
keys/variant map used "canceled", so the badge showed the raw
billing.subStatus.cancelled key. Fix the spelling and add pending.
- Only show "View receipt" for Stripe payments that actually have a
stripePaymentIntentId. Seeded/legacy completed payments have none, so
the button used to appear and then toast "no receipt available".
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add GET /payments/:id/receipt — resolves the Stripe-hosted receipt URL
from the payment intent's latest charge (ownership-scoped; returns EFT
receipt directly when present, null otherwise). Wire a "View receipt"
action on completed Stripe rows that fetches the URL on demand and opens
it, with a toast when none is available.
Closes the last billing-audit item (#8).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Make the payments loading state mirror the table card (header + rows)
instead of generic bars, and paginate the list client-side (10 at a time
with a "Show more" button) so long payment histories don't render all at
once.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace the div-grid "table" with a real <table> (thead/th scope=col/
tbody/td) inside an overflow-x-auto wrapper, so screen readers announce
each cell under its column and every amount/status is labelled (drops the
hidden-header mobile hack). Convert the filter button rows into compact
segmented controls (fieldset/legend) and only show filters once payments
exist.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Billing was payment history only. Add a summary card at the top showing
the current plan, status badge, and next renewal / trial-end / end date
(from /subscriptions/me), with a "Manage subscription" link — or a
"Choose a plan" CTA when there's no active subscription.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Show "no payments match this filter" with a clear-filters button when
filters hide everything (was the misleading "no payments yet"). Follow
the active locale for the payment date instead of hard-coding tr-TR, and
use tabular figures for date and amount columns.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The receipt link read payment.receiptUrl, but the API returns the column
as eftReceiptUrl — so the download button never rendered (dead feature).
Use eftReceiptUrl. Also add the missing "EFT" option to the method
filter; EFT is a live method with rows and receipts but couldn't be
filtered.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Product changelog isn't an account setting. Give it a dedicated
/dashboard/changelog route and a "What's New" sidebar link (Support
section), and drop the changelog tab from settings. The header page
title now resolves support-section routes too.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New "Preferences" settings tab giving the theme a real home in settings
(light/dark/system selector wired to userSettings + applyTheme) alongside
the language switcher, instead of theme living only in the header toggle.
Theme group uses a fieldset/legend for accessible grouping.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add an optional `tab` search param (validated against the known tab set)
to /dashboard/settings and drive the Tabs component from it, so each tab
is shareable/bookmarkable (e.g. /dashboard/settings?tab=referral). Tab
clicks update the URL with replace to avoid history spam; existing links
need no search param since `tab` is optional.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Tailwind Preflight img'lere height:auto uyguladığından statik-SVG logolar
en-boy oranına göre ölçekleniyordu (Opel ~17px küçük, Renault ~29px taşma).
VinBrandIcon artık sabit 28px kare + object-contain ile hem img hem svg
logolarını aynı boyutta gösteriyor; büyüteç optik denge için biraz küçük.
Playwright ile landing VIN textbox üzerinde doğrulandı.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- WMI_BRAND_MAP + getBrandFromWmi packages/shared'e taşındı (tek kaynak);
corgi.service artık buradan import ediyor (davranış aynı, testler geçiyor)
- VinBrandIcon: VIN'in WMI'ı bilinen markaya denk gelince büyüteç yerine
marka logosu pop animasyonuyla görünür (landing + search VIN textbox)
- WMI haritasına prod DB'de decode edilmiş VIN'lerden 23 eksik WMI eklendi
(Türkiye fabrikaları NM4/NMT/NLA/NLH/NMB dahil; tüm DB WMI'ları artık tanınıyor)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The vehicle category flow had two competing navigation models: the grid
drilled in place (no URL change, own breadcrumb) while the category route
had a separate breadcrumb reconstructed from the cached tree plus a back
button with different semantics. The route breadcrumb collapsed on
refresh/deep-link and for lazily-fetched deep nodes, and browser-back from
an in-grid drill ejected the user out of the whole flow.
Make the URL + a server-provided ancestor trail the single source of truth:
- API: getCategoryWithParts now returns `ancestors[]` (root→parent) via a
recursive CTE, so breadcrumbs are complete regardless of client cache.
- New shared <CategoryBreadcrumb> (Arama → vehicle → ancestors → current)
used by both the vehicle and category pages.
- Category page back button derives the parent from `ancestors` (no more
cache-path race); cache reconstruction (findCategoryPath) removed.
- CategoryGrid drills via route navigation per level (each level a URL +
history entry), seeding the query cache for instant render. Browser-back
now goes up one level and deep levels are shareable/refreshable.
- Tree & Columns intentionally keep their in-place paradigm.
- a11y: aria-labels on back buttons and the breadcrumb nav.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extracted CredibilityStrip out of the subscription route module into
@/components/credibility-strip so the public pricing page can reuse it
without pulling in the heavy dashboard/Stripe/Remotion bundle. Subscription
now imports it (dropping the now-unused Boxes/Hash/Layers/Zap icons); pricing
renders it below the plan cards to justify price with real product facts
(50+ marka, 1M+ parça, OEM, saniyede sonuç, KVKK·SSL). Test import updated.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per request: shift the "Popüler" emphasis from the monthly-anchored 2 Marka
to Full Paket, default the billing toggle to yearly, and put a "+2 ay hediye"
gift badge on the yearly tab (replacing the abstract -17%). Removed the
now-redundant "2 ay bedava" note under the toggle since the badge carries
that message.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Run biome --write formatting on the subscription page + new tests, and
swap the trial-progress banner's role="status" for aria-live="polite"
(keeps the polite live-region announcement without tripping
lint/a11y/useSemanticElements).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Trial users previously got no reminder of remaining days until the urgent
(<=7d) amber banner appeared, leaving most of the 30-day window with no
conversion nudge. Added a calm, brand-toned TrialProgressBanner shown when
more than 7 days remain: days left, a progress bar (elapsed share of the
30-day trial), and a "Planını seç" CTA that scrolls to checkout and fires
trial_progress_cta_clicked. The urgent banner still owns the final 7 days.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The page had no credibility signal at the point of conversion. Added a
CredibilityStrip under the header (shown only to converting users:
no-sub / trial / expired / pending) surfacing real, verifiable product
facts — 50+ marka, 1M+ parça, OEM + alternatif kodlar, saniyede sonuç,
KVKK uyumlu · SSL. Deliberately no fabricated customer counts or
testimonials.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The brand-count plan model leaves users unsure whether they need 1/2/3
brands. Added a PlanHelperHint above the grid ("Hangi planı seçmeliyim? …
4+ marka için Full Paket marka başına daha avantajlı"). Also added an
outcome-focused hook line to the trial card ("Şase numarasını gir, doğru
parçayı saniyede bul …") above its feature list, selling the result rather
than only listing features.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three CRO additions at the point of decision:
- Outcome-focused page subtitle ("Doğru parçayı saniyede bul …") replacing
the purely procedural copy.
- Per-day price anchor on every plan card (≈ ₺X/gün), reframing the cost as
a small daily number. Computed from the displayed monthly/yearly price.
- "2 ay bedava" note under the billing toggle when yearly is selected,
making the otherwise-abstract -17% concrete (yearly = 10× monthly).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
At the payment step the user previously saw only the pay button with no
recap of what they were buying — a trust + friction gap right before
checkout. Added an OrderSummary block (plan, selected brands, billing
period, total) above the Stripe button, wired through the already-defined
but unused orderSummary* i18n keys. Brand names resolve from the cached
/brands query; the full plan shows "Tümü seçildi".
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The comparison table's column headers were empty <th/> cells (aria-label
only), so sighted users could not tell which column belonged to which plan.
Headers now show plan name + monthly price + the Popüler badge. Added a
"Marka sayısı" row exposing the brand limit (1/2/3/Tümü) — the attribute
that actually differentiates the brand1/brand2/brand3 tiers, which the
matrix previously omitted entirely.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reward engine:
- Recurring milestones (every 3 → +7d, every 5 → +14d) instead of one-time
tiers capped at 5; idempotent + transactional grants serialised per
referrer so concurrent qualifications can't double-count.
- Rewards now gated on the referred user's email verification
(afterEmailVerification hook); already-verified referees (OAuth) qualify
at apply time.
- Reward days banked as users.referral_credit_days when the referrer has no
live subscription, consumed on next trial start / activation (no more
silently lost rewards).
- Accurate cumulative rewardDays in stats; getMyReferrals returns referee
name/masked email/status.
Hardening / cleanup:
- onConflictDoNothing makes apply idempotent (no unhandled unique violation).
- Anti-fraud: normalizeEmail blocks self-referral via gmail dot/+tag aliases.
- Collision-safe referral code generation at signup.
- Single apply path (welcome onboarding modal); removed duplicate calls in
register + subscription pages. Input validation on the apply code.
Email verification UX:
- Verification link now lands on a dedicated /email-verified confirmation
page instead of the deep-linked VIN/search page.
Schema: referrals.status + qualified_at, users.referral_credit_days (0009).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
When Şase Çöz is clicked and the decode (reused pre-decode or fresh) is still in
flight, the search form is replaced by a vehicle-page-shaped skeleton with a
sweeping shimmer, so the wait reads as "the page is loading" rather than an
unresponsive button. On success we navigate; candidates/errors fall back to the
form. When the pre-decode already resolved, the skeleton is near-instant.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The 17-char speculative pre-decode fired a POST /vehicles/decode and discarded
the result, then the button click fired a second identical decode — two
requests per VIN. Keep the pre-decode promise and have runDecode reuse it on the
first attempt (awaiting it if still in flight), so the click navigates off the
already-running decode. Retries and the sub-350ms "clicked before warm" case
fall back to a fresh decode; a failed warm retries once.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When getCategoryWithParts reports loadError (the PL24 catalog fetch failed
rather than the category being genuinely empty), the leaf view now renders a
"Katalog şu an yüklenemedi — tekrar dene" alert with a retry button instead of
the misleading "Bu kategori için parça bulunamadı" empty state. Genuine empty
leaves are unaffected.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Catalog sources double up tokens in model names — "Laguna (Laguna)",
"Golf 1,6 GOLF", "Golf TDI Variant GOLF". Add a shared cleanModelName helper
that drops a parenthetical repeating surrounding text and exact case-insensitive
duplicate tokens (order preserved, conservative on ambiguous cases). Applied to
the vehicle page h2, the collapsed summary, and the category breadcrumb. Unit
tested.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The collapsed summary's "Motor kodu" read only PL24's rawData.engineCode, so it
was blank for emex/pcat/vin-api. Resolve it per source: PL24 engineCode, emex
parsedOptions.engine_type, vin-api engineModel, or the pcat "engine" parameter.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extend the source dispatcher to the remaining decode sources. emex surfaces its
parsedOptions codes (engine_type/gearbox_type/vehicle_type → Motor tipi /
Şanzıman tipi / Araç tipi), led by any readable DB columns (Vites, Kasa).
vin-api surfaces its flat fields (bodyClass, engineModel, transmissionStyle,
plantCountry → Kasa / Motor / Vites / Üretim ülkesi). Both replace the bare
3-field DB fallback these vehicles showed before.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Vehicle info only ever read PL24's vinfoBasic segment, so emex/pcat/vin-api
vehicles fell back to 3-4 sparse DB columns. Introduce a getDisplayAttributes
dispatcher keyed on the decode source. First source added: parts-catalogs,
which ships a structured pcatCar.parameters list (Year, Region, Steering,
Transmission type/code, Engine, Type, Configuration…) — sorted by sortOrder
with English names mapped to Turkish. A shared header-dedup drops the
brand/model/year rows that already live in the h2 and the collapsed summary.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The PL24 VIN decode already ships a readable description for every PR code in
the prNr segment (187 here for the VW Golf) — surface it instead of guessing a
dictionary. The expanded accordion now shows a "Donanım kodları" section: each
row pairs the code as a mono badge with its decoded Turkish description (paint
and color entries included), in a scrollable two-column list. Raw \r\n and \-
escapes are cleaned.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Collapse the Araç Bilgileri card by default. The always-visible header now
shows an at-a-glance summary (Model, Model yılı, Motor kodu); expanding reveals
the full attribute grid, which also gains the decoded drive type (Çekiş /
"Önden çekiş") from rawData. The loading skeleton now matches the collapsed
header so there's no layout jump when data lands.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The vehicle detail page had no error handling: a failed /vehicles/:id (404 or
network) rendered a broken header (" ()") with no retry, while only the
categories sub-page had a proper error block. Now a failed load shows a
not-found vs. generic-error alert with retry (mirroring the category page), and
404s skip retries so the state appears immediately. The back button now uses
the router history and falls back to /dashboard/search on deep links instead of
window.history.back() leaving the site.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Araç Bilgileri card showed only raw PL24 VIN-decoded codes (Satış tipi
1J10E4, Donanım 82, Motor kodu BCB…) and fully suppressed the friendlier DB
fields. Now it leads with the readable engine description + body type, then the
decoded codes; drops the Model/Model-yılı rows that duplicate the h2 header;
maps the known mojibake labels to clean Turkish; drops the uppercase transform
(which corrupted the Turkish dotted/dotless i); and lays out 2 cols on mobile
with long values spanning the full row.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The moment the VIN reaches 17 valid chars, warm the full decode in the background
(350ms debounce, deduped per VIN, fire-and-forget) so the "Şase Çöz" click hits
the backend lock / 24h positive cache and returns near-instantly. If the warm is
still in flight when the dealer clicks, the button's decode lock-waits on it and
navigates the moment it lands.
No analytics/state/navigation from the pre-decode — vin_decoded and the
success/candidates/error events still fire only from the button path, so the CRO
funnel stays = real intent. Raw fetch + AbortController (api.post takes no signal),
mirroring the existing 17-char preview effect.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
checkout_started only fired for the "full" plan (plus a mostly-unreachable
sticky-CTA branch), so non-full-plan users reached payment without it ever
firing — PostHog showed fewer checkout_started (1 person) than the downstream
payment_initiated (3), which is impossible in a clean funnel.
Move it to a step-driven effect that fires once when the payment step is
reached, via any path (plan card, sticky CTA, or a prefilled pending plan),
re-arming if the user returns to plan selection. Fixes the Monetization and
Ödeme funnels at the checkout step and keeps plan -> checkout meaningful
(brand-selection drop-off now shows up instead of a trivial 100% step).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New signups were redirected to /dashboard/subscription (the paywall):
91% of them hit it and 78% bounced without ever searching a VIN (PostHog,
30d). Acquisition is strong (homepage -> register) but activation was the
cliff — only 13% of new users ever reached the search page.
- register: redirect signups to /dashboard/search?welcome=1 (value-first),
carrying the landing-page VIN + referral code across.
- search: read welcome/vin/ref params; prefill a carried VIN and auto-decode
it once the activation modal is dismissed (no re-typing); strip params after.
- WelcomeOnboardingModal: the old full-page provisioning view is now a modal
over the search page. It applies the referral (covers the Google OAuth
signup path), starts the trial, plays the Remotion onboarding motion, then
steps aside so the user lands on their vehicle's parts.
- trial_started now fires for every eligible new user (was a rarely-clicked
button) — this populates the Onboarding funnel, which previously read 0.
- tests: add useSearch to the router stub; refresh the stale retry-CTA tests
to the shipped submit-doubles-as-retry behavior (retry CTA dropped in a33deef).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Araç ve katalog model detay sayfalarındaki max-w-4xl cap'i kaldırıldı;
kategori grid'i geniş monitörde xl:4 / 2xl:5 sütuna çıkıyor. Geniş
ekranlarda yanlardaki boşluk dolduruluyor ve kategori isimleri artık
kısaltılmadan sığıyor.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Only the search route was emitting data-faro-user-action-name attributes, so
the vehicle detail and category detail pages were dark in our Faro user-action
funnels even though they sit on the critical post-decode path.
Added attributes for:
- Vehicle page back button (`vehicle-back`)
- Category page back button (`category-back`)
- Category error retry button (`category-retry`)
- Breadcrumb root vehicle link (`breadcrumb-vehicle`)
- Breadcrumb intermediate category links (`breadcrumb-category`)
- View toggle buttons (`category-view-grid|tree|columns`) — emitted from the
shared component so any future consumer inherits the tagging.
No behavioral change; pure observability.
Phase 8/8 of the UX audit follow-up.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- VehicleAttributes wrapped in <dl> (was emitting <dt>/<dd> with no list
parent). Both the rich vinfoBasic branch and the DB-fallback branch now
share one layout instead of diverging into span-based and dt/dd-based
trees — easier to style and screen-reader friendly.
- New explicit empty state ("Bu araç için ayrıntı bilgisi bulunamadı") when
both branches resolve to nothing. Previously rendered an empty grid.
- Loading skeleton now mirrors the actual page structure (header chip + logo
+ 2-line title, info card with 6 attribute slots, categories card with 8
rows). The previous three plain rectangles caused a layout jump on resolve.
- Attribute rows use small-caps muted labels and tabular-nums values for
scannable data (engine codes, mileages, years).
Phase 7/8 of the UX audit follow-up.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Category page: the previous error UI was a single flat red block with no
recovery path — users had to refresh the browser. Now it uses the standard
alert pattern (heading + detail + action), surfaces the actual error message
when one is available, and offers a Tekrar dene button wired to refetch().
Search page: removed the dedicated "Tekrar Dene" button inside the error
banner. The main Şase Çöz submit button sits immediately above and remains
enabled after an error — having two near-identical CTAs stacked on top of
each other was just noise. The error banner is now informational only.
Also drops the now-unused handleRetry function and RotateCcw import in the
search route.
Phase 6/8 of the UX audit follow-up.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two unrelated polish items in the VIN search page bundled into one commit
because they touch the same file.
1. Hero card removed. The entire form lived inside a generic
`rounded-2xl border bg-background p-6 sm:p-8` container on a page that had
no other content competing for attention — the card added no hierarchy,
only a frame. The header is also no longer centered: icon sits to the left
of a left-aligned h2 + subtitle, breaking the AI-default centered hero.
2. I/O/Q auto-correction toast is now debounced. Each keystroke that produced
a correction fired its own toast, so a user holding the I key or pasting
"IIO" stacked three toasts on top of each other. Corrections now collect
in a ref-backed Set and a single consolidated toast fires 400ms after the
last edit, with cleanup on unmount.
Phase 5/8 of the UX audit follow-up.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Both the vehicle detail page and the category detail page shipped their own
copy of a three-button view-mode toggle (Grid/Tree/Columns). They diverged
subtly and shared the same accessibility gaps: 28px tap targets, only `title`
attributes for screen readers, no `aria-pressed`, no focus ring.
- New `CategoryViewToggle` (apps/web/src/components/categories/) renders a
proper `role="group"` segmented control with `aria-pressed`, `aria-label`
per option, 36px tap targets, and a focus-visible ring.
- Active state is conveyed by an elevated background pill rather than just a
hover-grey, so the selected mode is legible without color contrast guessing.
- Both pages collapse to a single line: `<CategoryViewToggle … />`.
Phase 4/8 of the UX audit follow-up.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Addresses long-term fix from insight cmpifhxfs000lvze9cy6ne0tx: users in
deep category trees had no orientation cue beyond a single back button.
- Breadcrumb derived from the cached /categories/tree/<vehicleId> response.
When the cache is warm (user came from the vehicle page), the full ancestry
renders; otherwise it falls back gracefully to "Vehicle → Current".
- Vehicle label fetched via the same query key already used by the vehicle
page, so the request is deduped.
- Back button now prefers the breadcrumb parent (resolved synchronously from
cache) over `data.parentId` (which is async). Clicking back before the
category payload loads no longer dumps the user to the vehicle root.
- H1 placeholder during load swapped from a stale "Kategori Detayı" string
to an inline pulse — prevents the title from briefly displaying wrong text
before the real name resolves. Uses a span-based pulse (Skeleton is a div
and would be invalid HTML inside an h1).
Phase 3/8 of the UX audit follow-up.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>