Uyumlu/Uyumsuz butonlarında oy sayıları artık görünmüyor (çoğunluğu ele
vermek oyu yönlendiriyordu); özet yine çekilir — kullanıcının kendi oyu
butonda işaretli kalır.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Topluluk oyu kartının koyu dolgusu kalktı (sayfa arkaplanıyla aynı, yalnız
çerçeve); desktop'ta başlıkta OEM kodunun sağına yerleşti, mobilde kodun
altına iner. Katalogdan gelişte araç etiketi + parça adı search-param'la
taşınır (v/p/vid) ve kartta butonların üstünde "araç · parça" satırı olarak
gösterilir — neyle neyin uyumlu olduğu tek alanda. vid oy kaydına analitik
bağlam olarak geri eklendi; doğrudan ziyarette genel soru metnine düşülür.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Üstteki üç-kartlı kural bloğu ve oy kartındaki kural cümlesi kaldırıldı;
kurallar tek satır küçük puntoyla sayfanın en altında ("Oy ver +1 ·
çoğunluğu tuttur +2 bonus · kodu ilk değerlendiren 3 puanı kapar").
Başlık ve boş-durum metinleri de oyunlaştırıldı; oy kartında yalnız
"Her oy puan kazandırır · Parça Uzmanları sıralaması" linki kaldı.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Parça tablosu sadeleşti: Uyum ve Adet kolonları kalktı, liste açılışındaki
toplu istekler (/oem-votes/lookup ve /p/matched) tamamen kaldırıldı. Her OEM
kodu artık koşulsuz /dashboard/oem/$code'a linklenir — P eşleşmesi olmayan
kodda da sayfa dolu: topluluk oyu kartı, muadil önerileri ve ters katalog.
OEM detay sayfası: OemVoteCard (uyumlu/uyumsuz, sayaçlar, puan toast'ı,
puanlama özeti + Parça Uzmanları linki) ve OemSuggestionsSection — eşleşme
bulunamayan kodlar için kullanıcıdan marka + parça kodu önerisi toplar.
Öneriler oem_suggestions tablosunda (kullanıcı+kod+normalize öneri başına
tek satır, ON CONFLICT yutulur), markaya+normalize koda göre gruplanıp
"× N kullanıcı" rozetiyle listelenir; önerilen kod kendi detayına linklenir.
Şimdilik öneri puan kazandırmaz; status kolonu moderasyon kancası.
API: oem-suggestions modülü (POST 10/dk throttle, GET ?code=), migration
0016_oem_suggestions. Eski tablo-içi OemVoteButtons bileşeni silindi.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Parça satırlarına topluluk oyu eklendi: her OEM kodu için uyumlu/uyumsuz
ikilisi (👍/👎, sayaçlı). Oylar oem_votes'a (kullanıcı+kod başına tek oy,
fikir değişikliği günceller, puan üretmez), ödüller oem_vote_points
ledger'ına yazılır: oy +1, kesin çoğunlukla aynı yönde +2 (kodu ilk
oylayan her zaman 3 alır); ödüller oy anında kesinleşir, çoğunluk sonra
dönse de geri alınmaz. Aynı koda eşzamanlı oylar advisory lock ile
sıralanır.
/dashboard/uzmanlar: Trophy Gamification UI Kit'ten (ui.trophy.so, MIT)
uyarlanan kürsü + sıralama + puan rozetiyle "Parça Uzmanları" liderlik
sayfası; adlar KVKK-maskeli (S*** Y***), cevap kullanıcı id sızdırmaz.
Not: sidebar nav linki, tr/en i18n anahtarları ve routeTree 09a9487'de
gitmişti; bu commit eksik kalan rota/bileşen/API dosyalarını tamamlayarak
dev build'ini düzeltir. Migration: 0015_oem_votes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Drop /dashboard/changelog route, ChangelogTab component, useChangelog
hook, the sidebar nav item, and the related tr/en translation keys.
API-side changelog schemas are untouched.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sidebar linked /blog and /contact pointed at public marketing pages, so
clicking them dropped the user out of the dashboard shell. Extract page
content into shared components (blog-content, contact-content) and add
dashboard-wrapped routes /dashboard/blog, /dashboard/blog/$slug and
/dashboard/contact; sidebar now links to those. Public SEO pages stay
unchanged and remain the canonical URLs.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
'Çalışmadı mı? Bildir' opened the Sentry feedback dialog; most users
bail at a form. Now a single click captures a catalog_issue_reported
PostHog event with the vehicle/category context (vin, vehicle_label,
category_name, source, page) and flips to 'Bildirildi — teşekkürler'.
The identified person already carries the reporter's email; the Süper
Panel lists these under /analytics/reported-categories. The automatic
Sentry degradation report (with session replay) still fires via
useReportCatalogDegradation — unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Polish pass on the survey popover:
- Entrance: slide-up + fade on mount (300ms ease-out, respects
prefers-reduced-motion via motion-reduce:transition-none).
- Step progress: "1 / N" indicator on multi-question surveys so users know
how much is left.
- Esc closes the card (counts as a dismissal); listener attached once with a
dismissRef so it captures current answers, not a stale first-render closure.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Design review of the live survey popover surfaced three issues:
1. NPS 0–10 wrapped to two rows (flex-wrap + flex-1 made the 8/9/10 row
stretch absurdly wide) — now a single equal-width grid row (grid-cols-N
by scale; static map so Tailwind keeps the classes).
2. Single-question ratings forced a redundant "Devam" tap — tapping a score
now auto-advances (450ms debounce to allow changing the pick) and the
submit button is hidden on rating questions.
3. On mobile the card crowded the Chatwoot launcher — bumped to bottom-28
(sm:bottom-24 keeps desktop unchanged).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
OEM detail "slow load" was two things, not the query (DB 135ms / API
13-120ms):
1. The parts-panel link opened a NEW TAB → full SPA cold boot every
click. Switch to in-app client navigation on plain click (real href
kept, so ctrl/cmd/middle-click still opens a new tab).
2. The /p/oem response shipped each article's oeNumbers + compatible
lists (up to 200 each × 60 articles) that the UI never renders — 96%
of a 370 KB payload. Ship lean articles; aggregates already carry
the cross-refs. 60-article code: 370 KB → ~12 KB.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Formbricks CE turned out to gate person-based targeting (setUserId/attributes
→ 403 enterprise) — the exact flexibility surveys need. PostHog already holds
the person properties, so surveys move back there in API mode: posthog-js
evaluates eligibility (event triggers, targeting flags like
subscription_status=active, wait periods, per-distinct_id dedup) via
getActiveMatchingSurveys, and we render the popover ourselves — zero PostHog
branding, sase.tr dark-theme styling.
- lib/surveys.ts: display manager + capture payload builders that mirror
posthog-js's own "survey shown/dismissed/sent" shapes exactly
($survey_response_<qid>, $set $survey_dismissed/<id>[/iter], seenSurvey_*,
lastSeenSurveyDate) so the PostHog Surveys results UI works unchanged
- components/survey-popover.tsx: single_choice (+Diğer), open text, rating/NPS
- posthog.ts: capture/pageview/identify now schedule survey checks; register
deploy_env (dev.sase.tr ships the key now → staging traffic is filterable)
- remove @formbricks/js, its CSP entries and build args
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Every OEM code was a link → unmatched codes (the majority — fasteners,
clips, body parts TecDoc doesn't carry) opened an empty "no
equivalents" page. Now the schema page batch-checks its codes against
the snapshot (POST /p/matched, one indexed query) and links ONLY the
matched ones; unmatched codes render as plain text. Copy stays for all.
No more dead-end empty pages from the parts panel.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Finishes 3b14431 (which only captured the file renames): apply the
identifier/endpoint/env/UI changes so the code matches the new paths
— PModule/PController/PSourceDbService, @Controller("p"), /p/oem,
config key `p`, P_DB_ENABLED/P_DB_URL, "P kataloğundan…" copy.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Resolve a catalog OEM code to its TecDoc equivalents on a new
/dashboard/oem/$code page: the aftermarket parts that carry it
(brand + article number + image + EAN), buyable supplier
substitutes, and OE cross-references (same part under other makes).
- API: TecdocModule (read-only postgres-js client to the imported
`td` snapshot), GET /tecdoc/oem?code=. Normalisation-based match
(TecDoc stores `1J0 973 702`, catalog gives `1J0973702`); exact
match recovers ~1/10 vs normalised ~5/10 on real codes. Self-
disables without TECDOC_DB_* env → { matched: false }.
- Web: OEM code in the parts panel is now a link (new tab) to the
detail page; "N/A" stays plain text.
- Mirrors CatalogSourceDbModule (raw queries, no Drizzle modelling).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Only ~1.6% of trials reach checkout: the 30-day full-access trial shows the
(urgency) banner solely in its final 7 days, leaving ~3 weeks with no in-app
push, and the value moment (first few queries) goes unconverted.
Add TrialValueUpsell — a calm, value-framed banner that fires on PROVEN VALUE
(user has decoded >= 3 customer vehicles, read server-truth via /vehicles/history)
and only OUTSIDE the urgency window (days > 7), so it fills the gap without ever
overlapping or competing with the urgency banner. B2B copy: frames around
"müşteri araçlarını sınırsız sorgulama", never personal-vehicle wording.
Tracks viewed/cta_clicked/dismissed in PostHog; dismissal persists per trial.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The two things only the browser can add on top of the backend catalog-degradation
reporting:
- Replay-on-failure: when the catalog UI renders empty-tree (decoded vehicle, no
categories) or a drill loadError, capture a browser Sentry warning and flush the
Session Replay → you can WATCH the user hit the dead-end (serkan's session,
reproducible). Per-session deduped (one replay/session covers the whole journey).
- In-app feedback: a "Çalışmadı mı? Bildir" button on the empty-parts, empty-tree
and loadError states opens the Sentry feedback dialog pre-tagged with the
vehicle/category (+ session replay) — turns a parts shop's complaint into a
structured, triageable report instead of an email.
Browser events are fingerprinted source="browser" so they form their own
"what users actually saw" issues (carrying replays) next to the server-side
detections. tsc + biome + web build clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Paid ads pull many end-users who trial but never pay (~1% trial→paid in the DB
funnel). Add a soft qualifier before the register form: the visitor picks their
segment (parts dealer / wholesaler / e-commerce / service-fleet / vehicle-owner).
Business segments proceed; "vehicle owner" gets a gentle B2B-only note with a
"continue anyway" escape hatch (soft, no hard gate — protects trial volume).
The choice is captured (signup_segment_selected event + b2b_segment person
property) so qualified-trial ratio is measurable and channel→segment→paid becomes
queryable; later it can seed a Meta audience / CAPI qualified signal. Copy follows
the B2B framing rule (no "kendi aracınız"; "sınırsız şase sorgulama"). Persisted in
localStorage so a reload / OAuth round-trip doesn't re-ask.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Both the catalog flow ($brandName/$modelId) and the VIN-decode flow
(vehicles/$id) render the same CategoryColumns. It was stuck inside a fixed
visual window: container min-height 320px, each column max-height 420px. With
20+ root categories the user saw an internal scrollbar inside an otherwise
short rectangle even though there was plenty of page room.
Make it flex naturally:
- Outer container: min-h-[24rem] keeps a solid floor for empty/cold state,
max-h-[calc(100dvh-220px)] keeps a 200-leaf catalog from running off the
bottom of the page. Within that band, height tracks the tallest column's
natural content (flex stretch — siblings share the height).
- Inner column panels: drop the 420px max-height. Each column flexes to the
shared height; overflow-y-auto only kicks in when the outer viewport cap
compresses the row.
Net result: small catalogs render a short, tight columns view; large
catalogs grow to fill the available height up to the viewport cap, then
the columns scroll individually. Same component fixes /catalog/.../$modelId,
/vehicles/$id, and the nested /categories/$categoryId drill.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
After the user picks the last meaningful dimension, the next step is determined
— there is no decision left to make. Yet the Ford and PSA selectors stopped at
that point and waited for a "Kataloga Git" / "Proceed" button click. For Ford
specifically this was the worst case: LEGACY_FORD's upstream returns no engines
or gearboxes, so picking a year/catCode IS the whole flow — the user had to
click twice for one decision.
Auto-fire onSelect when the last available dimension is filled in:
- **Ford**: when year picked and (!hasEngines && !hasGearboxes); when engine
picked and !hasGearboxes; when gearbox picked. Also: when config returns
empty (no variants at all), fire onSelect("_nor_", "_nor_", "_nor_") via a
one-shot useEffect so the selector skips itself rather than dead-ending on
a "no config" notice with no actionable button.
- **PSA**: when gearbox is picked. Body→engine→gearbox is always 3 steps,
picking gearbox is unambiguous intent. "Show All" buttons already auto-
advanced — this just removes the asymmetry on the explicit-pick path.
Drop the now-dead Proceed button + handleProceed handler in both. P5
restriction selector already auto-advances on `isFinal` and stays untouched.
i18n keys catalog.fordVariant.proceed / catalog.psaVariant.proceed are kept
in case we ever re-introduce a confirmation button.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Fiat catalog browse returned 0 models because the generic single-endpoint P5
model-list flow can't express Fiat's two-level hierarchy and the drill defaulted
to the tr account (Fiat is licensed only on de-708171).
- fetchVehicleList: dispatch fiatp_parts/fiatt_parts to new fetchFiatVehicleList,
which expands modelOverview (34 families) → models?modelFamily=N (model codes +
year ranges) into flat catalog vehicles whose catalogPath is the maingroups
endpoint. Verified live: 34 families → 123 models, end-to-end drill to parts+image.
- fetchMainGroups / fetchP5Restrictions: account-aware (resolveAccount → de + DE
proxy for Fiat; tr unchanged for every other P5 brand) so browse maingroups no
longer hit the tr demo/empty page.
- web: case-insensitive "/maingroup" gate so Fiat (lowercase /mdl/maingroups,
already a maingroups endpoint) skips the empty restriction selector and loads
categories directly.
- formatFiatYear: "(2016,2020)" → "2016-2020"; +unit tests.
Subgroups/parts/images already resolve account→de for Fiat (unchanged).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The two rows under Settings → Bildirimler were carrying a sentence each
that read like AI-generated filler ("Mobil uygulama push bildirimleri.
Mobil uygulama yayınlandığında bu tercih kullanılır.") — the row title is
enough; the auth/payment caveat lives in the static footer below.
• Drop `description` from NOTIFICATION_CATEGORIES + the GET response shape.
• Drop the `<p className="text-muted-foreground">{description}</p>`
line from NotificationsCard so each row is now a single bold title
line + the toggle button.
• Tighten the row to `items-center` since the second-line description
is gone and `items-start` looked off with only one line of content.
• Rename `FALLBACK_CATEGORY_COPY` → `FALLBACK_CATEGORY_LABEL` to match
the slimmer shape.
• Capitalise the Mobile row to "Mobil Bildirim" (was "Mobil bildirim").
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Settings → Bildirimler had grown a 6-row list (welcome / trial-ending /
referral / referral-qualified / referral-reward / win-back) that read like
an internal cron schedule rather than a user choice. Users care about
e-mail vs mobile, not which Novu trigger fires the day-3 nudge.
Replaces the per-workflow UI with two switches:
• E-posta bildirimleri — bundles all six marketing/lifecycle workflows
above, off = mute all
• Mobil bildirim — placeholder for the not-yet-shipped mobile
app push channel; the preference is stored
so it Just Works when push ships
Auth + payment mail remain unaffected — the server-side OPTIONAL_WORKFLOWS
filter is still the canonical opt-out gate.
API
---
Same path (`/api/email/preferences`), category-shaped payload:
GET → `[{category, label, description, optedOut}, …]` (two rows)
POST → body `{category, optedOut}` (toggles every
workflow in
the bundle)
UnsubscribeController is untouched — one-click List-Unsubscribe URLs in
mail still address a single workflow (we don't want clicking the welcome-
mail unsub link to also kill the trial-ending nudge a week later).
Service
-------
New `NOTIFICATION_CATEGORIES` const + `getCategoryState()` /
`setCategoryState()` on EmailPreferencesService. `mobile_push` added to
OPTIONAL_WORKFLOWS so the same row-presence guard works for it.
UI
--
NotificationsCard renders two rows (or two skeletons) — keys are stable
so the skeletons match the final layout. Category copy comes from the
API; static FALLBACK_CATEGORY_COPY avoids a flash of untitled rows
before GET resolves.
PostHog events renamed from `email_workflow_opted_in/out` to
`notifications_category_opted_in/out` since the per-workflow event was
never going to be useful.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Lands the user-facing half of the unsubscribe preferences work. The
one-click endpoint already shipped in this PR's main commit; this adds
the proactive self-service path at /dashboard/settings?tab=notifications
so users don't have to wait for a mail to land before tuning their
preferences.
Backend
-------
New EmailPreferencesController at /api/email/preferences:
GET → returns one row per OPTIONAL_WORKFLOWS entry, each with current
optedOut boolean (false when no DB row exists).
POST → body {workflow, optedOut} flips the row; source='settings_page'
captured for the audit trail.
Auth+payment workflows are deliberately not exposed — the server's
OPTIONAL_WORKFLOWS set stays the single source of truth.
Frontend
--------
Adds a 'notifications' tab to /dashboard/settings (between 'preferences'
and 'security'). One toggle row per optional workflow with TR copy that
explains what each mail is for. Optimistic update — switch flips
instantly and reverts on failure; PostHog event captures accept/reject.
Static footer note clarifies that auth + payment mail keeps coming
regardless of the switches above (so users don't think they've
unsubscribed from password-reset).
i18n
----
Added settings.tabs.notifications + settings.notifications.{title,
description} to both tr.json and en.json. Body copy is hard-coded TR
(matches audit §9.3 #11 TR-only decision).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- Enable PostHog heatmaps (coordinate-only; autocapture stays off so the
PII-heavy logged-in dashboard never ships VIN/OEM text to PostHog).
- Add reusable feature-flag plumbing: subscribeFeatureFlag in lib/posthog
and a useFeatureFlag React hook (works for boolean flags + experiments).
- Wire experiment `demo-cta-copy` into DemoFooterCta: control = current
"sınırsız sorgulama" framing, benefit = B2B OEM value hook. Variant is
tagged on demo_to_register_click; exposure tracked via $feature_flag_called.
PostHog experiment 82775 created in draft (primary metric: demo→user_signed_up,
test accounts excluded). Launches after this reaches prod.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
parts_panel_viewed carried only opaque vehicle_id/category_id, so the
'0 parça' empty-catalog breakdown (and its rate-spike alert) couldn't say
WHICH vehicle/category was empty — useless for prioritising catalog backfill.
- parts-panel: add vehicle_label + category_name to parts_panel_viewed, and
fire a new empty_catalog_cta_clicked (with the same context) from the
empty-state button — a demand/abandonment signal per vehicle+category.
- schema-viewer: forward the two optional props to PartsPanel.
- vehicle category route: pass the existing vehicleLabel + data.name.
Best-effort labels (undefined on routes not yet threaded); parts_count
unchanged so existing insight/alert keep working.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Ad traffic from Instagram/FB lands in their in-app WebView, where Google
OAuth (signIn.social google) returns 403 disallowed_useragent — so the
30-day-trial signup with Google is broken for paid traffic. Also costs
password autofill, persistent session, and clean attribution.
- Android: hard-escape via intent:// (no package= → default browser),
preserving the full URL so UTM/fbclid survive the hop.
- iOS: Apple allows no programmatic escape — dismissible banner guiding
"••• → Safari'de Aç" with a copy-link fallback.
Mounted in __root so it runs on the landing page. Pure UA/intent helpers
unit-tested (in-app detection + URL preservation + no false positives).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Always-underlined, semibold, primary, larger text + arrow, with a "Tabloya
git:" verb so the cross-reference reads unmistakably as a clickable link
instead of muted inline text.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
PL24 translates "see table" two ways — "bk. tablo:" and "bakınız tablo,
konum:". Detection only matched the first, so the latter rows (e.g. evaporator
housing → 820-020) stayed dead. Broaden the name regex to match either, and
strip both phrasings from the displayed label. The code-in-remark gate still
prevents flagging real parts that merely mention "tablo".
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
When a reference's target illustration isn't seeded yet (load-time index
miss → categoryId null), clicking it now calls a new resolve endpoint that
drills the relevant main-group root (its external_id = the code's first
digit; the illustration is a direct child) and re-resolves. One PL24 call in
the common case, bounded + cached; falls back to pre-filled search if not
found. UI shows a spinner on the button while drilling.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
PL24 BOM emits "see table NNN-NNN" reference rows (oem N/A, target code in
remark) with NO upstream link. Resolve the code against the vehicle's
illustration index (codes live in category names as {NNN-NNN}) and render
jump links. Unresolved targets (branch not seeded yet) deep-link a pre-filled
catalog search via ?q=.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Drilling the category tree gave a 0–300 ms feedback gap between click and
the next render — no disabled state, no visible change — so users re-tapped
(one real session: 9 of 22 clicks were rage clicks, 158 s, zero results).
Add an immediate, prominent pending state on all three category views:
- grid: track the clicked card in `pendingId`; the button goes
disabled + aria-busy and a 28px centred spinner overlays the card (both
card variants) until the route swap unmounts the grid.
- tree: disable the expand button + aria-busy while children fetch; grow
the chevron-slot spinner 14px → 20px and tint it primary.
- columns: disable the row + aria-busy while the child column lazy-fetches;
same 20px primary spinner.
Tests: grid click paints disabled+aria-busy on the tapped card only;
tree/columns rows lock + show a spinner while the fetch is in flight.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The parts-panel wrapper used max-h-[500px] on mobile, but a max-height parent
does not give the panel's `h-full` a definite height to resolve against, so
the panel resolved to the table's full intrinsic height (~2500px) and, because
the wrapper's overflow is visible, spilled out the bottom — rendering the whole
parts list a second time below the signup CTA and the page footer (reported
"ürün kodları → kaydol kartı → footer → ürün kodları" broken structure).
Drop the mobile height cap so the parts list flows in normal document order and
the page scrolls through it (the intended mobile pattern; also resolves the
triple-scroll-trap noted in #76). Desktop is unchanged: the 40% column still
sits inside the md:h-[700px] row with its own inner overflow scroll.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
On mobile (iOS Safari, 390px), the schema viewport's one-finger touchmove
handler called setPan() unconditionally — even at zoom 1 with nothing to
pan — which makes iOS suppress the native page scroll. Users couldn't reach
the parts list below the fold (real trial session: 16 rage clicks, 0 OEM
codes copied, no purchase).
- use-schema-interaction: gate one-finger pan on zoom > 1 so the gesture
falls through to native page scroll when not zoomed in.
- schema-viewer: set touch-action (pan-y pinch-zoom at zoom 1, none when
zoomed) on the viewport; shrink mobile schema height 400px -> 280px so the
first parts rows peek below the fold and signal "more below".
- parts-panel: after a hotspot tap, scrollIntoView uses block:"start" on
mobile (panel is below the fold) and block:"center" on desktop, so the
selection is actually visible.
Tests: new Vitest hook test asserts no pan at zoom<=1, pans at zoom>1, and
pinch-zoom still works; new parts-panel scroll test asserts mobile vs desktop
block target. Also fixes a pre-existing typo in the empty-state test ("Geri
don" -> "Geri dön").
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two cosmetic fixes the founder flagged in the same browse session:
* parts-panel.tsx — the catalog parts table was missing Turkish
diacritics on every static label, while the actual part names rendered
correctly. Looked like an encoding bug; was actually hard-coded ASCII
strings. Repaired: Parcalar→Parçalar, parca listeleniyor→parça
listeleniyor, Bu kategori icin parca bulunamadi→Bu kategori için
parça bulunamadı, Yukleniyor→Yükleniyor, Parca Adi→Parça Adı,
Geri don→Geri dön. OEM Kodu was already correct.
* index.tsx hero preview — pl24 ships model names with trailing
duplicates ("Golf 1,6 GOLF"), and the card stitched year right next
to the engine "(2003 4 silindirli…)" with no clear separator. Now:
cleanModelName collapses the dup ("Golf 1,6"), year wraps in
parentheses as a softer secondary element, and the visual flex gap
guarantees breathing room before the engine pill.
/demo and /demo/categories/$id rendered without the site chrome — no nav
to other marketing pages, no theme toggle, no KVKK/gizlilik/iletişim
links. Marketing-page parity (matches index/pricing/about) + B2B trust
(legal links visible, logo back to home).
* Both routes now use a flex-col page shell:
SiteHeader (sticky top-0, z-50) → DemoBanner (sticky top-16, z-30) →
main (flex-1) → SiteFooter.
* DemoBanner sticky offset changed top-0 → top-16 to sit under the site
header instead of competing for the top edge.
Footer carried a VIN input intended to let demo visitors signup with their
own vehicle pre-filled, but it duplicated the hero's VIN search and added
friction at a moment the visitor is already deciding "should I sign up?"
— not "let me re-type a VIN". Reverted to a clean B2B trust strip + single
"Hesap Aç" primary CTA.
* New DemoFooterCta replaces the prior DemoVinCta. Visitors who want to
query their own VIN use the hero or the /register page itself.
* Component renamed (file + symbol) so the name reflects what it does;
the old file is removed. Both /demo and /demo/categories/$id swapped.
* Copy & B2B framing unchanged from the spec ("Sınırsız şase sorgulamak
için ücretsiz hesap aç" / trust strip).
Demo footer was a single-button conversion ("Hesap Aç" → /register with no
VIN). For a parts-shop owner who just browsed the example VW Golf, the
next natural action is "let me try MY customer's VIN" — not "let me click
register and re-type". Adds an inline VIN input to the footer CTA on both
/demo and /demo/categories/:id; submitting routes to /register?vin=… so
the register teaser (see ed74e2f) lifts off where this leaves off.
* New DemoVinCta component owns the form + analytics. Empty submit still
works as plain "Hesap Aç" (no VIN); typing a VIN flips the button label
to "Bu VIN için Hesap Aç".
* B2B copy retained from prior commits: "Sınırsız şase sorgulamak için
ücretsiz hesap aç" headline, "Kart bilgisi gerekmez · 30 gün ücretsiz ·
istediğin an iptal" trust strip — see [[sase-b2b-copy-not-consumer]].
* Input placeholder reads "Müşterinizin şase numarası (17 karakter)" — B2B
framing ("müşterinizin") not "kendi aracınız", per the same memory.
* Mobile-friendly: input + button stack vertically <sm, side-by-side ≥sm;
full-width button on mobile clears the Chatwoot widget.
* PostHog: emits demo_to_register_click with {source, has_vin, vin_length}
so we can split conversion by "browsed → typed VIN → registered" vs
"browsed → bare register".
DemoBanner stays a single-CTA sticky anchor (the "Yeni VIN sorgula" gate
lives in the footer card instead — banner real estate is too narrow on
mobile for a useful input).
Founder caught two issues on the live /demo page:
1) Copy was framed as B2C ("Kendi aracınız için sınırsız erişim") but
sase.tr's audience is parts shops / garages / e-commerce sellers — they
decode 5-30 VINs/day for their customers, not for their own car. Rewrote
banner + both footer CTAs to frame conversion around "sınırsız şase
sorgulamak için ücretsiz hesap aç" — that's the actual unit they pay for.
2) Footer "Hesap Aç" button sat awkwardly on mobile: arbitrary-width inline
element flex-stacked under the headline text, overlapping the Chatwoot
widget in the bottom-right. Made the button `w-full sm:w-auto` so it
reads as a clear primary action on mobile, and added `mb-20 sm:mb-0` to
the card to clear the Chatwoot widget on small screens.
Replaces the old marketing "guided tour" /demo with a real, fully-functional
catalog browsing experience for the pre-warmed example vehicle. No auth
required, no upstream calls — entirely served from prod DB.
Backend (apps/api/src/demo):
* New @Public() controller exposing five endpoints under /api/demo:
- GET /vehicle → demo vehicle metadata
- GET /categories/tree → top-level category tree
- GET /categories/search?q= → cross-tree search
- GET /categories/:id → getCategoryWithParts (parts+schema+hotspots)
- GET /categories/:id/children → drill children
* DemoService validates every category id against DEMO_VEHICLE_ID before any
downstream service call — the public surface can't be used to read an
arbitrary vehicle's catalog (1-row SELECT, NotFound on miss or wrong owner).
* Vehicle id is env-driven (DEMO_VEHICLE_ID, defaults to the pre-warmed
WVWZZZ1JZ3W597935 — VW Golf 2003 with 277 cats / 9841 parts / 178 schemas
fully drilled in prod).
* Wires CategoriesModule (already exports CategoriesService) — zero new
business logic, just a thin public façade.
Frontend (apps/web):
* /demo (replaces old marketing page): vehicle header + top categories grid
reading /api/demo/* + sticky DemoBanner with sign-up CTA.
* /demo/categories/$categoryId: drill page rendering either a children grid
(parent) or the existing SchemaViewer + parts panel (leaf) — same shape
the dashboard uses, so hotspot overlay, breadcrumb trail, retry on
upstream loadError all just work.
* DemoBanner: sticky top, "Örnek araç: {label} — Kayıt Ol" CTA. The
"Yeni VIN sorgula" explicit paywall trigger lands in a follow-up task.
* PostHog events: demo_loaded (source query-param-aware),
demo_category_clicked, demo_category_detail_viewed, demo_to_register_click
(banner / footer / category_footer placements).
* usePageMeta gains an opt-in `noindex` flag — demo sets it to noindex,follow
for the first 4-6 weeks per spec; cleaned up on unmount so SPA navigation
doesn't carry it to the next route.
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").
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.
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
- 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>
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>
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>