pcat/emex vehicles decode into a tree whose top level is ~100% folders with
zero parts (6746/9458 root nodes are parents, 0 direct leaves across 477
vehicles). Users — serkan filazi among them — saw an opaque wall of identical
folder cards, assumed the parts feature was missing, and bounced without ever
drilling (his session: 3 view-mode toggles, 0 category clicks; his data was
2-3 clicks deep and fully reachable).
Two changes, no upstream/proxy cost:
- getCategoryTree now rolls up a subtree partCount per node (one grouped parts
read + JS post-order sum). The grid already renders "N parça", so folders
now read "Fren sistemi · 142 parça" and steer drilling toward the branches
that actually contain parts; empty branches stay quiet. Counts reflect
drilled data and grow as branches are visited.
- A one-line drill hint on a "cold" tree (nothing drilled yet → no counts) so
first-visit users know parts live inside the subcategories instead of reading
the folder wall as a broken/empty catalog. Drops away once any branch is hit.
Verified: subtree roll-up unit test + existing suite (10/10), tsc + biome clean
across api/web/shared.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Meta Ads traffic converts to signup well below expectation. Same design, three
hero-copy variants (lossPill + title + subtitle) behind the exp-landing-copy
multivariate flag, measuring user_signed_up (experiment 83160):
- control: current "find the right part instantly" (feature / accuracy)
- variant_a: "end wrong-part returns" (ROI / business-outcome + risk reversal)
- variant_b: "find it in seconds, free, no card" (risk reversal / signup friction)
useFeatureFlag on the index hero, undefined -> control. B2B-safe copy
("customer vehicle", not "your car"). Stays draft until prod, then launch.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
New users land on an empty search box with only a tiny fill-only "try example"
link; activation (first vin_decode_success) sits at ~32%. This adds a prominent
one-click "decode a sample vehicle" card that autoDecodes the sample VIN ->
straight to the vehicle page (the aha-moment).
Gated behind the exp-activation-guided-decode multivariate flag (control/guided),
read ONLY for unactivated users (empty history) so power-user decodes don't
dilute the metric; undefined -> control (status quo). PostHog experiment 83153
measures vin_decode_success. B2B-safe copy (sample vehicle, not "your car").
The experiment stays in draft until this ships to prod (web flags need the prod
VITE_POSTHOG_KEY), then launch.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two visible defects when a user picks a Ford model (variant selector step):
1. **N identical buttons.** Ford's `modelFamilyToModelList` gives every sub-catCode
the same caption — just the family name. parseFordCatCodesForFamily returned
`[{code:CBV,name:Kuga},{code:CBS,name:Kuga},{code:CTD,name:Kuga}]` for Kuga
2012-2020. The UI rendered three indistinguishable "Kuga" pills with no way
for the user to tell them apart. Same on Galaxy ("Galaxy"/"Galaxy"), every
multi-catCode family.
Fix: detect duplicate baseNames per family and graft a disambiguator —
year first (friendliest), catCode as a last resort. Result:
"Kuga (2013-2016)" / "Kuga (2016-2020)" / "Kuga (CTD)" — or, when year is
present even for unique entries, always include it for a uniform look.
2. **Section title was lying.** UI label was `catalog.fordVariant.modelYear`
= "Model Yılı" ("Model Year") but for Ford LEGACY these are catCodes
(generation identifiers), not years. Rename the key value to "Varyant" /
"Variant" — semantically correct for both Ford catCodes and Volvo years
(Volvo flows through the same selector and its codes ARE years; "Varyant"
covers both).
Cleared the affected Redis cache (`pl24:ford:config:*`) on dev so the next
hit fetches fresh disambiguated data; prod cache will roll over on its own
TTL after promote.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The PL24 vehicle page rendered a Card titled `t("catalog.models")` ("Modeller")
whose content was the *selected vehicle's* specs (motor/kasa/vites/pazar). When
all four spec fields were null — common for sparse decodes — the user saw an
empty card with a bogus "Modeller" heading just below the model name.
Replace the misleading card with a flat spec-chip row (icon + label : value),
hidden entirely when no specs exist. Use the shared CatalogHeader so brand →
vehicle has a proper breadcrumb back to "/catalog/$brandName" and uses the same
back-button affordance as the rest of the catalog surface. Use the shared
ViewModeToggle so view-mode buttons stop having hardcoded TR title attrs
("Izgara", "Agac", "Sutun") and inherit the i18n + a11y from the component.
Same cleanup for pcat/$catalogId_/$modelId and emex/$catalogCode_/$vehicleId:
both rolled their own header — both now use CatalogHeader with full crumbs and
i18n'd search/count/empty-state strings (catalog.pcat.*, catalog.emex.*).
Why now: catalog flow audit caught the empty-card bug ("model seçilince
yukarıda boş bir alan kalıyor"), plus drift between drill levels (some pages
used CatalogHeader, vehicle/pcat/emex did not). One unified pattern across
brands → models → vehicle → categories.
i18n: +catalog.categoryCount, +catalog.vehicleSpecs.{engine,body,transmission,
market}, +catalog.pcat.{searchPlaceholder,vehicleCount,schemaCount},
+catalog.emex.{searchPlaceholder,optionCount,variantCount,noResults,
noPartsTitle,noPartsHint,loadError}.
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>
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.
The old 66-line history page was a raw list with `any[]` typing, no search,
no sort, no delete, no dates, no i18n and no analytics — opening a vehicle
detail was the only action available.
Replaces it with a real management surface:
- Search-by-text (brand, model, VIN, year, engine) — client-side over the
paginated payload, Turkish-locale case-folding
- Sort dropdown (newest, oldest, brand A→Z, year DESC)
- Per-item Remove with a confirmation dialog; mutation does optimistic
setQueryData so the card disappears immediately, then invalidates the
search-page recent-history query
- Relative-time "decoded {when}" using Intl.RelativeTimeFormat with the active
locale; absolute timestamp lives in title/aria for power users
- Plan-lock badge: when the user's plan no longer covers a brand in their
history, that card surfaces a Lock chip linking to /subscription
- Brand logo, VIN with copy-to-clipboard, focus-visible rings, Link.preload
intent so opening a vehicle is instant
- Empty state (no history) and no-results state (filter wiped everything)
are now distinct, both with the right CTA
- PostHog events: history_viewed, history_search_used, history_sort_changed,
history_load_more, history_item_clicked, history_vin_copied,
history_item_deleted — full funnel coverage from open to action
- Full i18n via new `history.*` block in tr.json/en.json, plus the page now
fetches paginated (PAGE_SIZE × 8 first chunk, "show more" client-side)
Root cause: landing was almost entirely hard-coded Turkish (5 t() calls in
1889 lines), and initLocale() — which reads the persisted locale from
localStorage on boot — was never called from main.tsx, so a reload always
reverted to the TR default.
This commit fixes both.
apps/web/src/main.tsx
- Call initLocale() before first render so the persisted choice survives reload.
apps/web/src/routes/__root.tsx
- Translate the skip-to-content link via a new `a11y.skipToContent` key.
apps/web/src/routes/index.tsx
- Internationalize ~145 user-visible strings across hero, brand strip, features,
comparison table, bento grid, use cases, stats, dashboard showcase, ecommerce
block, testimonials, pricing tiers, FAQ, final CTA, referral banner and ARIA
labels. Module-scope data arrays moved inside the component so re-renders
pick up the active locale.
- Wrap the three Remotion players (Schema / Dashboard / Ecommerce) so they read
the current locale and pass translated labels into their inputProps.
Remotion demos (DashboardDemo, EcommerceDemo, SchemaDemo)
- Each now accepts a `labels` prop (DashboardDemoLabels / EcommerceDemoLabels /
partLabel). All hard-coded TR sidebar items, placeholders, category names,
product names, "Add to Cart" buttons, the cart-added toast, overlays and
stat labels are now driven by labels with a TR fallback (keeps Remotion
Studio preview working standalone).
messages/{tr,en}.json
- New blocks: `a11y`, `landing.*` (huge tree covering every section above),
`landing.dashboardDemo`, `landing.ecommerceDemo`.
Intentionally preserved as data, not translated: brand names (Volkswagen,
Mitsubishi …), VIN literals, OEM codes (1J0 820 803F …), numeric stat values
(1.2sn, 27, 1M+, %99.9 …), prices (200/350/500/999), and the testimonial
person name "Özge D." (proper noun).
Verified: typecheck clean, 17/17 test files / 57/57 tests pass, biome clean,
Playwright TR↔EN round-trip succeeds, only the testimonial name remains as
Turkish on the EN page.
Adds a four-stat band below the hero CTAs — 27 brand catalogs, 1M+ OEM/alt
parts, 30-day free trial, 7-day no-questions refund. Big tabular-nums numerals
with small labels; divides on sm+. Every number is product or policy fact we
can defend — no fabricated customer counts, no testimonial copy.
Side effect: FAQPage JSON-LD now injected into <head> via useEffect (was an
inline <script dangerouslySetInnerHTML>). Same SEO payload, less attack
surface, tagged with data-sase-faq so it's diff-friendly.
BrandSelector
- Now fully controlled — drops the internal `selected` state that could drift
from the prop source of truth
- Full-plan auto-select effect compares against the live selection and only
fires when out of sync, removing the infinite-loop risk if parents passed an
unstable onSelectionChange (now also ref-stashed)
- Search input appears when there are more than 12 brands; Turkish-locale
case-folding so "Volkswagen"/"volkswagen" both match
- No-match state for empty search results
TrialUrgencyBanner
- Three urgency tiers (soft 7d, urgent ≤3d, critical ≤1d) — colors and
tighter copy escalate as the trial winds down
- Adds an elapsed-time progress bar so "3 days left" reads as 90% used at a
glance (number alone undersells urgency at the tail)
- LS dismissal key now userId-scoped (mirrors subscription page)
- Banner emits `tier` alongside days for funnel segmentation
- Billing empty state now has a ReceiptText avatar and (when no subscription)
a "choose plan" CTA — beats a bare "no payments yet" line
- Receipt buttons promoted from ghost to outline so users actually find them
- Failed payments show a help icon with a hint (card declined → bank / retry)
- Stripe button: Clock → Loader2 (Clock-as-spinner was awkward)
- Stripe init failure now also surfaces inline with a retry button — a toast
vanishing in 4s left users with no recovery affordance
- Adds an accepted-cards trust strip (VISA / Mastercard / Troy / 3D Secure)
below the pay button to ground the secure-checkout copy
- PlanGrid now wraps cards as a real radiogroup with role=radio + aria-checked +
focus-visible ring (was aria-pressed on plain buttons)
- Adds ProvisioningStuckHint: at 30s in trial provisioning we surface a
contact-support + retry escape so users aren't trapped on a spinner
- PaymentMethodSection strips stale brand IDs against the live /brands query
before Stripe checkout (would have 4xx'd silently)
- trial-banner dismissal LS key now scoped to userId + endDate (was endDate
only — two users on the same browser could leak state)
- Sticky CTA disabled state gains a hint title + aria-disabled + opacity
- Brand-selector Suspense gets an 8s slow-load fallback with reload affordance
- OnboardingCompletedView shows the actual subscription plan name (was hard-
coded "Full Paket")
- Picks up sase-pending-period stashed by the public pricing toggle
- Hero gets a primary "start trial" CTA + secondary "see plans" scroll-to anchor
- Adds monthly/yearly billing toggle that switches displayed amounts and surfaces
effective per-month yearly cost
- Yearly savings shown as both percent and TL/year (no more vague "save")
- Full Paket card visually elevated (ring + tinted bg + slight vertical offset on lg)
- Plan CTA carries plan=key into /register; chosen period stashed in localStorage
- New PostHog events: pricing_hero_cta_clicked, pricing_period_toggled,
pricing_plan_cta_clicked
When the trial mutation either fails repeatedly or just hangs past 30s,
new users were trapped staring at the spinner with no way out. Add:
- A `trial_failed` capture on every mutation error (Onboarding funnel can
finally measure the activation failure rate).
- After 2 failures OR 30s in provisioning, surface a "still trying"
panel with "Destek ile konuş" (opens Chatwoot) and "Yine de kapat"
options, and let interact-outside / Esc dismiss the modal again.
- An openChatwoot() helper + typed `toggle` on the ChatwootApi so other
components can open the support widget without poking the global.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When the landing decodes a VIN for a logged-in visitor and the API
returns a multi-candidate response (no single id), the code was
navigating to /dashboard/vehicles/$id with id=undefined. The catalog
page then asked the API for /vehicles/undefined and the backend
"invalid input syntax for type uuid: 'undefined'" message landed in
front of the user.
Two-sided fix:
- On the landing, if the decode reply has no id, hand the VIN off to
/dashboard/search?vin=… instead — that page already renders the
candidate-picker and the unrecognised-VIN flow.
- On the vehicle detail page, gate both queries on a UUID check and
render a friendly "Geçersiz araç bağlantısı / Aramaya git" panel for
any malformed id. Also stop showing raw backend error messages in the
generic error state — surface the localised hint instead.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
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>
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>
- 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>
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>
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 a PasswordField with show/hide toggle, hint text and inline error;
the new-password hint and live "passwords do not match" message now
render under the fields (submit disabled on mismatch) instead of only
firing a toast on submit.
- Show skeletons while the connections and referral-stats queries load,
instead of flashing "Not linked"/0.
- De-nest the referral stat cards (cards-inside-cards) into plain muted
panels and use tabular-nums for the figures.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
TR/EN toggle in the footer bottom bar, wired to the i18n store's
setLocale (persists to localStorage, updates <html lang>). Buttons use
aria-pressed within a labelled group; active locale is highlighted.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Point the three product links to distinct, real feature anchors
(#feature-vin / #feature-oem / #feature-schema); add matching ids and
scroll-mt to the feature cards in index.tsx (were all -> #features).
- De-duplicate the email: brand-column address is now the single
mailto link; removed the redundant bottom-bar copy.
- Remove the static "all services operational" indicator that never
reflected real status (misleading trust signal).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Move all hard-coded Turkish footer copy (column titles, links, tagline,
copyright, status) into a footer.* message block in tr/en and resolve
them via t(), so the footer follows the selected locale.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Shows a brand-tinted "Hesabını Yükselt" badge above the username in the
sidebar footer, linking to the subscription page. Hidden for users with
an active paid plan. Renders in desktop (expanded + collapsed icon
variant), and the mobile drawer.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace the click-to-logout profile trap with a proper DropdownMenu
(settings / subscription / logout), swap the hand-rolled mobile overlay
for a Radix-Dialog Sheet (focus-trap, Esc, scroll-lock), and fix
accessibility gaps: aria-labels on icon buttons, aria-current + labelled
nav landmarks, and effect-based auth redirect instead of navigating
during render.
Also: reorder nav (Main → Account → Support → Admin), full i18n of
labels/sections, conditional "upgrade plan" button, header page title,
collapsed-mode active indicator + tooltips, contrast/touch-target
tweaks, and a typed NavItem to drop the `as any` exact cast.
Adds DropdownMenu, Tooltip and Sheet primitives to @sase/ui.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Cold visitors landing straight on /pricing had no product context. Added a
compact 3-step band between the credibility strip and FAQ: enter the chassis
number -> see the parts (OEM codes, diagrams, compatible parts) -> pick the
right part by comparing prices. Grounded in the actual product, no ordering
claims.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The pricing page had no objection handling and a half-empty page below the
cards. Added a PricingFaq disclosure section (native <details>) covering the
six recurring questions: how the free trial works, whether a card is needed,
cancellation, refunds, changing plan/brands, and payment security — all
grounded in actual product behaviour (30-day Full trial, no card, period-end
access on cancel, downgrade, Stripe PCI-DSS / KVKK).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bring the public pricing cards in line with the in-app subscription page and
strengthen the CTA:
- Move the "Popüler" badge from 2 Marka to Full Paket (one consistent story
across both surfaces).
- CTA "Başla" -> "Ücretsiz Başla" (leans on the free trial the flow grants).
- Yearly line now uses a consistent ₺ format ("veya ₺2.000,00/yıl") plus a
"· 2 ay bedava" savings cue instead of the bare, differently-formatted
"veya 2.000,00 TL/yıl".
- Plan-decision helper above the grid ("Hangi planı seçmeliyim? …").
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The pricing hero was purely procedural ("Fiyatlandırma" / "İhtiyacınıza
uygun planı seçin") with no value prop and no mention of the free trial —
the strongest conversion lever, present in the meta description and header
CTA but absent from the page body. Reworked into an eyebrow + outcome
headline ("Doğru parçayı saniyede bul") + supporting line, plus a trust
row: 30 gün ücretsiz · kredi kartı gerekmez · istediğin zaman iptal ·
7 gün iade garantisi (reusing existing subscription trust keys).
Also sorted pricing.tsx imports to satisfy biome.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The checkout button read "{amount} ile öde"; per request it now reads
"Kredi Kartı ile Öde" (EN: "Pay with Credit Card"), clarifying the payment
method. The amount stays visible in the order summary directly above the
button, so no pricing context is lost.
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>
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>
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>
Removes the EFT (havale) payment surface across API, web, i18n, and docs.
Card payment via Stripe is now the only checkout path.
API (apps/api/src):
- Delete payments/bank-accounts/ module (controller + service + module)
- payments.service: drop EFT methods (createEftPayment, uploadEftReceipt,
approveEft, rejectEft, getActiveBankInfo, getPendingEftPayments) and the
BankAccountsService dependency
- payments.controller: drop /payments/eft, /payments/eft/:id/{receipt,approve,reject},
/payments/bank-info, /payments/pending; keep /payments/me
- payments.module: drop BankAccountsModule import
- admin.service: drop pending EFT counter from getDashboardStats; drop
getPendingPayments (admin EFT approval list)
- admin.controller: drop /admin/payments/pending endpoint
- admin.service.spec: drop the getPendingPayments + pendingPayments assertions
Web (apps/web/src):
- Delete components/payment/bank-transfer-card.tsx
- Delete routes/dashboard/admin/payments.tsx (EFT approval page); regenerate
routeTree.gen.ts
- subscription/index.tsx: drop EFT tab from PaymentMethodSection, drop the
paymentMethod state + handleEftCompleted, simplify ConfirmationCard to the
Stripe-only path, drop unused imports (Tabs, BankTransferCard, Building2)
- billing.tsx: drop "eft" from method filter chips; keep methodLabels.eft and
the legacy receipt download so historical EFT records still display
- dashboard.tsx + admin/index.tsx: drop /dashboard/admin/payments from the
sidebar + admin quick-link grid; trim unused icon imports; KEYS_6 → KEYS_5
for the now-5-card admin stats skeleton
- messages/{tr,en}.json: strip every EFT-only key under payment.* (eftTransfer,
eftConfirmationDescription, eftPaid, eftStatus, bank.*, uploadReceipt* etc.)
Schema (apps/api/src/database/schema):
- bankAccounts table + payments.bankAccountId column are kept as-is: legacy
EFT payments remain visible on the billing page and the schema preserves
historical records.
Docs:
- README.md: "Stripe (kart) + EFT" → "Stripe (kart)"
- CLAUDE.md: stack table + PaymentsModule row + payments table note + route
list updated; admin EFT approval route removed
Verification:
- pnpm typecheck: green (api + web + shared + config + ui)
- pnpm lint: green
- pnpm test: 20 web tests pass, 173 api tests pass
A user who picked a plan + brands, clicked the checkout button to
create the pending subscription, then closed the tab without paying
came back to a frozen "Mevcut Plan · Bekliyor" card with no payment
button and no way to edit. Status was "pending", which my conditional
treated as an active subscription and routed away from the stepper.
Frontend
- hasActiveSub narrowed to active|cancelled only. Pending users now
see the stepper again.
- New effect pre-fills selectedPlanKey, selectedBrandIds,
billingPeriod from the pending row on first render and jumps
straight to the payment step.
- New PendingPaymentBanner at the top of the stepper with
"Odemeye devam et" (scrolls + sets step=payment) and "Vazgec"
(calls cancel-pending then resets selection).
Backend
- subscriptions.service.create() auto-expires existing pending
rows for the user before inserting a new one — abandoned checkouts
no longer accumulate and a fresh attempt with a different plan
doesn't 409.
- New PATCH /subscriptions/cancel-pending + service method for
the banner's explicit cancel action.
i18n: subscription.pendingBanner.* (tr/en).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Subscription/checkout flow rewritten end-to-end. The plan-card "Devam Et"
button silently wiped brand selection on re-click and the actual proceed
button lived offscreen — fixed by collapsing plan/brands/payment/confirm
into a single vertical stepper with one sticky CTA.
Backend
- Stripe Hosted Checkout (`/payments/stripe/checkout`) + webhook
(`/payments/stripe/webhook`, raw body) replacing the stubbed Iyzico
module. Webhook activates subscription on `checkout.session.completed`,
expires the pending subscription on cancel/expire so users can retry.
- New `bank_accounts` table — multiple rows, single `is_active` enforced
by a partial unique index. Admin CRUD under `/admin/bank-accounts`
with multipart QR upload to MinIO; atomic `/activate` swap in a
transaction; `GET /payments/bank-info` returns the active row.
- `payments` gains `stripe_session_id`, `stripe_payment_intent_id`,
`bank_account_id`. EFT flow now reads the active bank account at
payment time and stores the FK for reconciliation.
- Env: `IYZICO_*` removed, `STRIPE_*` added (validated by zod schema).
- `main.ts` `rawBody: true` for Stripe signature verification.
- Drizzle 0003 snapshot id collision fixed (VIEW-only migration shared
prevId with 0002, blocking new generates).
Frontend
- `/dashboard/subscription` rewritten as a 4-step vertical stepper with
step-aware sticky bottom CTA; plan re-selection is idempotent and
preserves brand state. `/dashboard/subscription/pay` deleted; Stripe
returns to the same page via `?stripe=success|cancelled` and the UI
polls `/subscriptions/me` until the webhook activates the row.
- New components: `bank-transfer-card.tsx` (DB-driven IBAN + Kolay Adres
+ uploaded QR image + receipt upload) and `stripe-checkout-button.tsx`.
- Active subscription view, trial onboarding/urgency banner, downgrade
and cancel dialogs preserved.
- TR/EN i18n: new `subscription.steps.*`, `subscription.stickyCta.*`,
`payment.stripe.*`, `payment.bank.*`; provider label updated.
PostHog: `method: "iyzico"` → `"stripe"`; new events `iban_copied`,
`kolay_adres_copied`, `qr_viewed`, `eft_initiated`,
`stripe_redirect_returned`.
Deploy runs `db:migrate` which applies 0004_hot_quicksilver
(additive: new table + nullable columns; safe to apply on prod).
Operator must add `STRIPE_SECRET_KEY`, `STRIPE_PUBLISHABLE_KEY`,
`STRIPE_WEBHOOK_SECRET` to env and create the first
`bank_accounts` row via the admin endpoint before the bank tab works.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>