The snapshot stores scrape-local '/_debug/...' image paths that 404
off-host. Filter the OEM lookup to http(s) image URLs and render the
thumbnail only when one exists, so the detail page shows clean
code-focused cards instead of a grid of broken-image placeholders.
Real CDN URLs will surface automatically once present.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Full marketing-copy audit. Removes price-comparison claims (pricing
step, hero variant B, blog tip) that aren't built; normalises VIN→şase
across TR copy (plan features, e-commerce section, FAQ, blog, about) —
keeping VIN only in the educational "VIN nedir" post and the EN file;
de-consumerises framing (FAQ "aracınız", about "bireysel kullanıcılar").
Fabricated metrics/testimonials left untouched pending review.
Co-Authored-By: Claude Opus 4.8 <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>
The landing OEM card, the manual-vs-Sase comparison table, and a blog
post advertised "price comparison" / "order history & part tracking" —
features that aren't built. Replaced with real capabilities
(exploded-diagram matching, interactive part diagrams, brand catalogs)
in both tr + en. Mirrors the same accuracy fix already shipped to the
Novu lifecycle emails.
Co-Authored-By: Claude Opus 4.8 <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>
- initSentry() is now idempotent via a cached promise, and the degradation
reporter / feedback opener await it — so an empty/error state that renders
before the SDK's dynamic import resolves no longer drops the event (not missing
the failure is the whole point of the auto-capture).
- environment now resolves hostname-first (sase.tr → production, dev.sase.tr →
staging), so the compose-baked VITE_SENTRY_ENVIRONMENT default ("production")
no longer makes dev browser events indistinguishable from prod.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
EFT/Havale was retired; Stripe is now the sole payment method. Remove the
EFT code surface (shared PaymentMethod "eft" + EftPaymentInput + eftReceiptUrl,
EFT_RECEIPT_REQUIRED error code, billing UI receipt/filter/label paths,
payments.service eft read paths). DB columns (eft_receipt_url, bank_account_id,
bank_accounts) are kept and marked @deprecated to preserve historical records
and avoid a destructive migration — same pattern as the retired iyzico column.
Faz 3 conversion lever: set locale "tr" on the Stripe Checkout session. The
audience is Turkish B2B and ~60% of sessions reached the foreign-language
hosted page but never started a payment intent (pure abandonment, not decline).
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>
dev.sase.tr and sase.tr both run NODE_ENV=production (and ship the same
production Vite build), so every Sentry event — api, worker, and browser — was
tagged environment=production, making dev errors indistinguishable from prod.
Resolve the environment from the canonical prod host instead: COOLIFY_FQDN /
BETTER_AUTH_URL on the server (the same signal isCatalogBackfillEnabled uses),
window.location.hostname on the web. So dev now tags as "staging" and stays
filterable. Explicit SENTRY_ENVIRONMENT / VITE_SENTRY_ENVIRONMENT still win.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
The browser pixel under-counts signups badly: ~96% of paid traffic is mobile
in-app browsers where iOS ITP / ad-blockers drop client events, and the OAuth
path never fired it reliably. Meta recorded ~0 registrations for a 7.5K-spend
campaign while PostHog saw 98 facebook signups — so Meta could neither optimize
toward nor attribute signups, which is the main driver of the low signup rate.
This adds a server-side CAPI CompleteRegistration:
- MetaCapiService + @Global module. Fail-open: no-ops unless META_CAPI_PIXEL_ID
+ META_CAPI_ACCESS_TOKEN are set; never throws (signup must not break).
SHA-256 hashed email + fbp/fbc/IP/UA.
- Fired from the better-auth user.create.after hook for ALL signups (reliable,
covers Google OAuth which the browser pixel missed entirely).
- A session-gated POST /analytics/meta/complete-registration endpoint adds
fbp/fbc/IP/UA (ad-click attribution) for the email path.
- The browser pixel now passes a shared event_id (signup_<userId>); the
premature Google client-pixel fire (fired on click, before completion) is
removed.
- All sources dedupe via event_id=signup_<userId>.
Activate by setting META_CAPI_PIXEL_ID + META_CAPI_ACCESS_TOKEN (Events Manager)
in the api env; META_CAPI_TEST_EVENT_CODE routes to Test Events for verification.
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>
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>
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>
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>
Lands the §9.3 "compliance + brand" tier of mailAudit.md as one PR. Six
changes share enough surface (notifications, shared utils, infrastructure)
that splitting them would require multiple stacked PRs.
#9 — Turkish-locale title-case for names at signup
• New `normalizeName()` in @sase/shared, locale-aware (İ/ı pairs handled
via toLocaleLowerCase('tr-TR') + matching toLocaleUpperCase). Hyphen-
aware, collapses whitespace, idempotent.
• Wired into better-auth's `user.create.before` hook so every new signup
gets canonicalised before the row lands in Postgres.
• 28 unit tests in packages/shared/src/index.spec.ts.
• Backfill script at `scripts/backfill-user-names.ts` (already run
against prod + dev — 210/402 prod users and 72/153 dev users
canonicalised, plus 71 Novu subscribers).
#10 — Email typo correction at signup
• New `suggestEmailFix()` in @sase/shared: exact-match typo dictionary
for the addresses we've actually suppressed (icould.com, gmial.com,
xn--gmail-bgd.com, …) plus Levenshtein ≤ 2 fallback against popular
providers.
• Inline UI hint on the register form — "Bunu mu demek istedin? <link>"
that swaps the email on click; PostHog event tracks acceptance.
#11 — Strip EN branches (decision: TR-only)
• 0/205 prod subscribers have locale='en' and there's no marketing in
English — the {{#equals subscriber.locale "en"}}…{{else}}…{{/equals}}
framework was dead code in all 10 templates.
• Templates updated in-place (avg ~30 % smaller). Renamed
`novu-welcome-tr.html` → `novu-welcome.html` for consistency with the
other 9 files.
• Novu workflow definitions in both Dev + Prod envs updated via Mongo:
subjects collapsed to TR-only, content replaced with new HTML
(mongodump/restore-safe).
• App code: `NovuRecipient.locale` and `NovuUser.locale` removed; the
`...(user.locale === "en" ? { locale: "en" } : {})` spread in NovuService
is gone.
#12 — DKIM rotated to 2048-bit RSA
• Postal default was 1024-bit (selector `postal-YeIm3w`). Generated new
2048-bit key, added DNS TXT `postal-2k260604._domainkey.sase.tr`,
atomically swapped `domains.dkim_identifier_string` +
`dkim_private_key` in Postal MariaDB, restarted Postal SMTP.
• Verified: outgoing welcome mail now signs with `s=postal-2k260604`
and a 256-byte signature body (vs the previous 128-byte 1024-bit
signature). Pubkey on DNS matches the private key.
• OLD TXT record (`postal-YeIm3w._domainkey`) stays in DNS for ~7 days
as a grace window for in-flight mail.
#13 — MTA-STS + TLS-RPT
• Extended the existing mailtrack Cloudflare Worker to also serve
`mta-sts.sase.tr/.well-known/mta-sts.txt` (`mode: enforce, mx:
mx.postal.sase.tr, max_age: 604800`). Workers Domain bound to the
mailtrack service via Cloudflare API.
• DNS:
`_mta-sts.sase.tr` TXT "v=STSv1; id=20260604111347"
`_smtp._tls.sase.tr` TXT "v=TLSRPTv1; rua=mailto:dmarc@sase.tr"
• Verified policy fetch returns 200 with the expected body; cert valid
(sase.tr SAN issued by GTS).
#14 — Unsubscribe preferences + RFC 8058 one-click endpoint
• New `email_preferences` table (migration 0011) keyed
(user_id, workflow), captures source for audit
(one_click / manual_link / settings_page).
• New `UnsubscribeController` at `/api/email/unsubscribe`:
- POST: Gmail/Yahoo one-click bot path (200 fast)
- GET: human-visit, renders a Turkish confirmation page
Both validate an HMAC-SHA256(`userId|workflow`) token under
`UNSUBSCRIBE_SECRET` — stateless, no DB lookup to validate, secret
rotation invalidates all outstanding tokens.
• `triggerNovu()` now mints the per-call `overrides.email.headers`:
`List-Unsubscribe: <https://…?u=&w=&t=>, <mailto:unsubscribe@…>`
`List-Unsubscribe-Post: List-Unsubscribe=One-Click`
Auth + payment workflows opt out via NO_UNSUBSCRIBE_WORKFLOWS so the
unsubscribe URL never appears on transactional mail.
• `NovuService.trigger()` pre-flight-checks `isOptedOut()` and skips the
trigger entirely if the user opted out. Fail-open on DB error so a
transient blip can't swallow auth mail.
• `lifecycle-email.processor.ts` (standalone BullMQ worker — no NestJS
DI) does the same check inline via a LEFT JOIN on
`email_preferences WHERE opted_out IS NULL`.
• Coolify env wired in both Prod and Dev apps:
`UNSUBSCRIBE_SECRET` (32-byte hex, distinct per env)
`UNSUBSCRIBE_URL_BASE` = `https://(dev.)sase.tr/api/email/unsubscribe`
## Companion sibling changes (already applied, NOT in this PR)
- Cloudflare worker `mailtrack` redeployed with mta-sts.sase.tr custom domain.
- Postal MariaDB `domains.dkim_identifier_string` + `dkim_private_key`
updated to the new 2k260604 selector (live since 2026-06-04 11:18).
- `postal-2k260604._domainkey.sase.tr` TXT record live at Cloudflare.
- `_mta-sts.sase.tr` + `_smtp._tls.sase.tr` TXT records live at Cloudflare.
- Novu Mongo notification + message templates updated to TR-only.
- 282 user names canonicalised across prod + dev + Novu subscribers.
## Verification snapshot
- Postal raw_headers (ID 157, post-rotation): `s=postal-2k260604` + 256-byte b=
- `dig +short TXT _mta-sts.sase.tr @1.1.1.1` ⇒ live id=20260604111347
- `curl https://mta-sts.sase.tr/.well-known/mta-sts.txt` ⇒ 200 with policy
- 28 unit tests (normalizeName + suggestEmailFix) all green via Node sanity.
## Deploy notes
- Re-run `pnpm db:generate` to regenerate the drizzle snapshot for 0011
(added the journal entry manually because no drizzle-kit on this box).
- Run `pnpm tsx scripts/backfill-user-names.ts --apply` against any DB not
yet canonicalised (already done for prod + dev today).
- The host-side Novu nodemailer-headers patch at
`postal/novu-patches/apply-headers-patch.sh` must be re-run after every
Novu container redeploy or the List-Unsubscribe header is silently dropped
before reaching Postal (see audit §9.1 #3 for the upstream cause).
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>
Completes the empty-catalog enrichment so parts_panel_viewed carries
human-readable names across ALL catalog flows, not just the decoded-vehicle
route. Now the '0 parca - kategoriye gore' breakdown gets named rows from
catalog-brand, emex, pcat and demo browsing too:
- emex: categoryName = data.group.name
- pcat: categoryName = activeSchema.name
- catalog-brand: categoryName = data.name, vehicleLabel = decoded brandName
- demo: categoryName = data.name, vehicleLabel (already computed)
Co-Authored-By: Claude Opus 4.8 <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>
__root identify effect called posthog.reset() whenever `user` was falsy —
which includes the brief window while the session resolves on every page
load. After the signup hard-redirect this rotated the anonymous distinct_id,
orphaning the pre-signup anonymous person that carries `user_signed_up` and
first-touch `$initial_utm_*`. The later identify() then merged a fresh, empty
anon id, so signups never linked to trial/payment (only ~16% stitched) and
channel attribution read "(none)" for 100% of signups.
- __root.tsx: gate the effect on isLoading and only reset() on a real
identified -> anonymous transition (logout), tracked via a ref. Logout
still resets via dashboard handleSignOut.
- register.tsx: identify() within the still-active anonymous session before
firing user_signed_up, so the anon->identified merge carries $initial_utm_*
and attributes the signup (email flow; Google is handled on OAuth return).
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>
Android Instagram in-app browser injects scripts under the iabjs:// scheme
(navigation_performance_logger_android) that throw bridge errors like
"Java object is gone" during beforeunload. denyUrls on /iabjs:\/\// drops
the whole family at the source, message-independent. Also added the message
to ignoreErrors as a fallback.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
iOS Instagram/Meta in-app browsers inject their own JS that probes the
WKWebView bridge (window.webkit.messageHandlers) and throws when absent.
It surfaces via our global onerror but is not our code — pure noise that
scales with Instagram ad traffic. Added to Sentry ignoreErrors. The
existing issue (124551981) was muted in the Sentry UI.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
capturePageView sent `$current_url: origin + path`, stripping the query
string — so every Meta-ad UTM was lost and all paid traffic was
mis-bucketed as "direct" (PostHog saw 9 facebook visitors vs Meta's
~1450 LPV over 21 days). Send the full window.location.href so PostHog
parses $utm_* / $initial_utm_*. Also flip person_profiles to "always"
so anonymous ad visitors get a first-touch profile (volume ~34k/mo, well
within the 1M free tier). Adds a regression test guarding the query string.
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).
Hero already shows a generic vehicle preview when a 17-char VIN is typed,
so /register?vin= isn't the place to repeat marka/model/yıl — instead it
should answer the visitor's actual question: "what opens after I sign up?"
Adds a public catalog-stats endpoint and a data-driven teaser card on the
register page:
Backend:
* GET /api/vehicles/:vin/teaser-stats (Public, VIN-validated). Single SQL
round-trip counts categories + parts + schema_pics for the VIN. Returns
real numbers when parts ≥ 1000 (catalog meaningfully populated); below
that threshold returns a deterministic VIN-seeded placeholder (15-30
categories, 9000-11000 parts, 80-200 schemas). Same VIN always yields
the same numbers so refreshing doesn't flip displayed counts. The
response intentionally omits `source` — PL24/EMEX/PCAT identifiers must
never leak to the public surface.
Frontend (/register):
* When ?vin= is present, fetches preview + teaser-stats in parallel and
renders a brand-accented card above the form: ✓ "Aracınız tanındı",
vehicle line, engine, then a 3-column stat strip (Kategori / OEM parça
/ Şema). Below: "Hesap açtığında bu araç için kataloğa anında erişim
açılır."
* B2B copy pass on the rest of the page:
- Heading flips to "Hesap Aç ve Katalogu Gör" when VIN present
- Trial messaging rewritten to anti-gimmick B2B tone:
"Kart bilgisi gerekmez · 30 gün ücretsiz · istediğin an iptal"
(was: "30 gün Full Paket ücretsiz deneyin — kredi kartı gerekmez")
- Subhead: "Sınırsız şase sorgulamak için ücretsiz hesap aç"
- Submit button: "Hesap Aç ve Katalogu Gör" (vin) / "Hesap Aç" (no vin)
- "Ücretsiz Başla" / "Full Paket" strings purged per [[sase-b2b-copy-not-consumer]]
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.
Real visitors were repeatedly clicking the hero "Ara" button while only the
placeholder VIN (WVWZZZ1JZ3W597935) was showing — they mistook the example
for typed text. The click hit handleVinSearch's `if (!trimmed) return`
early-out and did nothing, leaving them stuck.
Now: empty input OR a value that exactly matches the example VIN routes to
/demo (the pre-warmed VW Golf 2003 catalog) instead of /register. The button
label also updates to "Örnek aracı dene" in that state so the outcome is
predictable before the click. Any other typed VIN still goes through the
existing /register?vin= flow unchanged.
PostHog: emits hero_ara_demo_redirect with {had_value, was_example_match}
so we can split the funnel by what triggered the demo entry.
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.
Sase frontend has Faro (RUM) and PostHog (product analytics) but no browser
error monitoring. A recent panel session investigation (panel insight
cmpv9q3ms004dfcphluw0z8bh — silent rage-clicks after parts_panel_viewed
with no API call) couldn't be confirmed or refuted from JS error data
because there was no JS error channel at all. This adds one.
- @sentry/react ^9 (dynamic import in lib/sentry.ts so the bundle only
pays the SDK cost when DSN is configured)
- Init is gated on VITE_SENTRY_DSN — unset = no-op, no behaviour change
- Replay only fires on error (sessionSampleRate 0, onErrorSampleRate 1)
- KVKK: sendDefaultPii false, maskAllText + blockAllMedia on replay
- Builds emit hidden source maps so Sentry can de-minify traces while
end users don't fetch the maps in the browser
- docker-compose.coolify.yml threads VITE_SENTRY_* through as build args
(VITE_* must be build-time; runtime env never reaches a Vite bundle)
Wiring on the Coolify side is a separate manual step — set
VITE_SENTRY_DSN on the prod (ro48g…) and/or dev (jwgwkg…) app and
redeploy. Backend Sentry (NestJS) is unchanged.
Refs: Süper Panel docs/ARCHITECTURE.md, panel sentry-archive job.
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").
Brings the secondary catalog entry points onto the shared header so
breadcrumbs and back behaviour stop drifting from /catalog/:brandName.
pcat/$catalogId
- Switches the inline "ArrowLeft + h1" combo to CatalogHeader with a
Katalog / {CATALOG_ID} breadcrumb
- Replaces `Array.from({ length: 8 })` + numeric index keys with
KEYS_10 (stable, no React-key warning risk)
- `{model.carsCount} araç` was hard-coded TR — now reuses
`catalog.modelCount2` so EN-locale users see "N model(s)"
- Empty-state img placeholder swapped from arbitrary emerald to
`bg-primary/10 text-primary` (token-driven, theme-correct)
- preload="intent" on model links
emex/$catalogCode
- CatalogHeader with breadcrumb; reset button moves into the actions slot
- Adds `catalog_emex_wizard_opened` view event for funnel parity with
pl24/pcat — emex wizard is the highest-cognitive-load surface and we
had zero data on its open-vs-abandon rate
Future passes will translate the remaining hard-coded TR phrases inside
the wizard body and the matched-vehicles list — kept out of this commit
to avoid sprawl.