Commit Graph

353 Commits

Author SHA1 Message Date
02efa8c35d refactor(web): kill dashboard home, /dashboard now redirects to /search
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.
2026-06-01 00:02:05 +03:00
229666e333 i18n(web): full landing page TR↔EN — language switcher actually works now
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
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.
2026-05-31 23:50:52 +03:00
931c0cfd85 feat(web): pricing — by-the-numbers stat strip, verifiable facts only
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.
2026-05-31 23:18:14 +03:00
b8bbdbda67 polish(web): pricing zigzag timeline, FAQ JSON-LD, trust icon variety, feature matrix sticky thead
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
2026-05-31 23:12:21 +03:00
1ea53920b0 refactor(web): brand selector controlled state + search; trial banner urgency tiers + progress
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
2026-05-31 23:10:08 +03:00
35871403c4 feat(web): billing & Stripe — empty state CTA, failed-payment hint, inline retry, card-brand trust
- 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
2026-05-31 23:06:46 +03:00
611b41c793 feat(web): subscription — radiogroup plan picker, trial stuck guard, stale-brand cleanup, scoped LS
- 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
2026-05-31 23:04:20 +03:00
eb8d8c2dee feat(web): pricing — hero CTA, monthly/yearly toggle, numeric savings, popular elevation
- 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
2026-05-31 22:58:18 +03:00
984a02e9a3 fix(web): onboarding micro-polish — autofocus CTA, swap hard-coded red token
- 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>
2026-05-31 22:27:50 +03:00
229ada8adf feat(web): onboarding — skip affordance, stable layout, robust timing, dismissible close
- 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>
2026-05-31 22:26:45 +03:00
469a7af67e fix(web): onboarding — locale-aware end date, dynamic plan, completion event
- 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>
2026-05-31 22:25:10 +03:00
eb7b75fbe8 fix(web): onboarding modal — escape hatch + trial_failed analytics
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>
2026-05-31 22:24:20 +03:00
209e98cb41 fix(web): stop leaking /vehicles/undefined and the raw uuid parse error
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>
2026-05-31 22:04:21 +03:00
a4597544b4 feat(web): vehicle detail polish — analytics, chatwoot cleanup, skeleton, heading
- 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>
2026-05-31 21:45:04 +03:00
b9dd154bfd i18n(web): translate the vehicle catalog detail surface
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>
2026-05-31 21:42:48 +03:00
73fe3e33c1 refactor(web): type the vehicle detail page — drop the any-soup
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>
2026-05-31 21:35:37 +03:00
74f32b82e5 feat(web): vehicle detail — empty-categories state, equipment show-more, mobile header
- 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>
2026-05-31 21:29:49 +03:00
a6ce215c47 i18n(web): translate the search page
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>
2026-05-31 17:28:45 +03:00
29178c0eaa feat(web): post-signup goes to the catalog only for typed VINs
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>
2026-05-31 17:22:00 +03:00
81d1086fef a11y(web): label the search VIN input and announce history rows
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>
2026-05-31 17:19:30 +03:00
1fd21bd04b feat(web): search history opens the vehicle in one click
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>
2026-05-31 17:18:46 +03:00
6cff3dea24 refactor(web): type the search history list, fetch only what we render
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>
2026-05-31 17:18:15 +03:00
1d9f59ba86 fix(api): pause whole worker on cooldown, only per-job defer for off-hours
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
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>
2026-05-28 12:17:05 +03:00
f733186a04 fix(csp): allow destek.sase.tr for the Chatwoot widget
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
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>
2026-05-27 19:50:18 +03:00
1787607be5 feat: embed Chatwoot live-chat widget (destek.sase.tr)
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>
2026-05-27 19:28:50 +03:00
c7e59b90e0 fix(api): defer rate-limited prefetch jobs instead of failing them
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
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>
2026-05-27 18:02:36 +03:00
dae76085ef fix(api): translation job IDs must not contain ':'
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
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>
2026-05-27 16:49:46 +03:00
acd5691f6b fix(api): prefetch sub-job IDs must not contain ':'
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
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>
2026-05-27 15:18:04 +03:00
5f0bdb467d fix(api): gate catalog backfill on prod host, not NODE_ENV
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>
2026-05-27 14:29:07 +03:00
d0ee4df57f feat(api): hourly catalog backfill for decoded-but-unfetched vehicles
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
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>
2026-05-27 14:04:32 +03:00
f7d5f03572 feat(catalog): hide per-level category filter on vehicle page
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>
2026-05-27 13:35:50 +03:00
8718b08415 feat(catalog): full-catalog search on vehicle page (leaf categories + OEM parts)
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>
2026-05-27 13:30:21 +03:00
9753d00015 fix(web): link "Bize Ulaşın" custom-needs button to the contact page
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
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>
2026-05-27 12:49:16 +03:00
efd42ade4a fix(web): align remaining "50+ marka" claims to the real count (27)
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>
2026-05-27 11:59:46 +03:00
c76ab7a280 feat(web): plan preselection from landing pricing (CRO #7)
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>
2026-05-27 03:01:26 +03:00
0dc298631d fix(web): landing — align pricing tier cards for easy comparison
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>
2026-05-27 02:53:33 +03:00
e21683bce6 feat(web): landing — add FAQ section and trust signals near final CTA
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>
2026-05-27 02:52:51 +03:00
076c61d599 feat(web): landing — stronger final CTA, move referral after the close
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>
2026-05-27 02:51:11 +03:00
eaf6d47f05 fix(web): landing — consistent brand count (27) and wrong-part cost figure
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>
2026-05-27 02:50:12 +03:00
18576a2818 chore(web): contact — E-posta kartını kaldır, form placeholder + meta güncelle
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
- info@sase.tr E-posta bilgi kartı kaldırıldı (Destek + Adres kaldı)
- mesaj placeholder "Bize..." → "Size nasıl yardımcı olabiliriz?"
- meta description info@sase.tr referansından arındırıldı

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 02:37:21 +03:00
2cc2a917a5 fix(api): CSP'ye Cloudflare Turnstile'ı ekle (widget engelleniyordu)
helmet CSP script-src/connect-src/frame-src challenges.cloudflare.com'u
engellediğinden Turnstile widget'ı yüklenemiyordu → token üretilmiyor →
captcha enforce edilen login/register/contact "şifre hatalı" ile reddediyordu.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 02:21:15 +03:00
7eb2e85bbc feat(auth): "beni 30 gün hatırla" + login Turnstile koruması
- session.expiresIn = 30 gün; login'de rememberMe checkbox signIn.email'e bağlandı
  (işaretli=30 gün kalıcı çerez, değilse oturum çerezi)
- better-auth captcha plugin'i (cloudflare-turnstile, TURNSTILE_SECRET_KEY varsa)
  sign-in/sign-up'ı korur; login formuna Turnstile widget'ı + x-captcha-response

Not: auth.ts ve login.tsx her iki özelliği birlikte içerdiğinden tek commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 02:14:18 +03:00
a09182fb98 feat: Cloudflare Turnstile (register + contact) + captcha altyapısı
- Turnstile widget bileşeni (public site key gömülü, VITE_TURNSTILE_SITE_KEY ile override)
- register: signUp.email'e x-captcha-response header'ı
- contact: token body'de; ContactService Cloudflare siteverify ile doğrular
  (TURNSTILE_SECRET_KEY yoksa atlanır), contact.dto'ya turnstileToken
- @sase/config: TURNSTILE_SECRET_KEY env (opsiyonel)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 02:14:06 +03:00
1c79c18aff feat(api): wire Novu lifecycle email triggers
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
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>
2026-05-27 01:30:39 +03:00
d35a009653 i18n(web): relabel billing receipt action to "Makbuzunuz"
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 00:58:01 +03:00
cf1fee68a9 fix(web): responsive billing table + filters on mobile
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
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>
2026-05-27 00:36:03 +03:00
c633e846e2 fix(payments): curate /payments/me response — stop leaking internal columns, join plan name
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>
2026-05-27 00:31:33 +03:00
d77face831 fix(web): billing — correct cancelled status label and gate receipt button
- 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>
2026-05-27 00:28:39 +03:00
1b9492ba49 style(api): drop non-null assertion in refund amount
Replace `input.amount!` with `input.amount ?? Number(payment.amount)`,
which is behaviour-identical (undefined amount = full refund = full
amount) but satisfies lint/style/noNonNullAssertion.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 00:15:41 +03:00
7a8fe5e98e feat(payments): Stripe receipt link on billing rows
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>
2026-05-27 00:14:28 +03:00