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>
Same shared-core + thin-brand pattern (P4BrandHooks). Both decoded with polluted models (Opel kept
the platform code "…: P10"; Hyundai kept the VIN breadcrumb), wrong VIN-char years, empty transmission.
Fix reads the vin-group info grid + title:
- Opel: model from <title> (segment after the platform code), year from Model yili, transmission from
Şanzıman kodu, engine from Motor tipi. Verified 6/6 prod (ASTRA-J/2014, CORSA-D/2011, INSIGNIA-A, AGILA-A…).
- Hyundai/Kia: model from <title>, build-year from Üretim tarihi, transmission/body from the
ENGLISH-labelled grid (plain lowercase — tr-locale would map I→ı and miss "TRANSMISSION").
Verified (GETZ 02/2004/5 SPEED MT, RIO / STONIC 17/2019…).
Nissan deferred: both prod Nissan VINs are unresolvable upstream (PL24 returns "Model seçimi", no
model) — nothing to parse. Engine/categories/drill unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ford decoded with a polluted model ("Ford Nutzfahrzeuge {VIN}: Transit Connect - TC7…", from the
page title), a wrong VIN-char year, and empty transmission. The clean data is in the vin-group info
grid (Araç Hattı=model line, Üretim tarihi=build date, Vites Kutusu=transmission, Motor Tipi=engine,
Gövde Tarzı=body). New thin PL24FordService supplies parseFordVinInfo via the shared P4 brand hook;
orchestrator routes LEGACY_FORD to it. Engine/categories/drill unchanged.
Verified live vs 9 prod Ford VINs: 7 now decode clean model + correct build-year + transmission
(Transit Connect/2006, Mondeo/1997 CD4E Otomatik, Focus/2000, …) — VIN-char years were off by up to a
decade and transmission was empty for all. 2 VINs are upstream gaps (no Araç Hattı → unchanged).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Volvo decoded as "Volvo {VIN}" with no real model: the generic Ford-shaped
parseP4VehicleResponse reads window.vehicles/<title>/<h1>, but Volvo ships model/year/type in a
<table id="vinInfoTable"> caption/value grid (Model="V60 Cross Country (19-)", Model yili=2021,
Türü="V60 CC II"). 9 vehicles affected (3 dev + 6 prod).
- Add a per-brand vehicle-info hook (P4BrandHooks.parseVehicleInfo) to the shared P4 engine
(PL24FordLegacyService); brand values win, generic fills gaps. Backward-compatible: no hook → identical.
- New thin PL24VolvoService supplies parseVolvoVinInfo (vinInfoTable parser); orchestrator routes
LEGACY_VOLVO decode to it. Categories/drill unchanged (shared engine).
Verified live vs 6 prod Volvo VINs: all decode real models (S80/S60/S40/V40/EX40·XC40/V60 CC) +
correct years; drill intact (motor → 7 subgroups). First step of the per-brand split
(shared core + thin brand services).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
dev.sase.tr was running the daily lifecycle-email cron (trial-ending,
win-back) against sase_dev, which is a seeded copy of the prod DB —
real customers were getting duplicate mails (one from prod, one from
dev). Mirror the isCatalogBackfillEnabled() gate so the lifecycle cron
only registers on the canonical prod host (COOLIFY_FQDN=sase.tr or
BETTER_AUTH_URL=https://sase.tr). LIFECYCLE_EMAIL_ENABLED env can force-
enable for staging testing. The else branch removes any stale scheduler
from Redis so a previously-registered cron stops firing.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The trial-ending / payment-success / payment-failed emails were pointing
to /dashboard/settings?tab=subscription which does not exist; the correct
path is /dashboard/subscription. Referral paths are unchanged.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
PSA (Peugeot/Citroën/DS) VIN decode was systemically broken: the catalog
vin-group page returns all families unfiltered, so decode fell back to the first
family/salesType (a manual base variant) — yielding "{Brand} {VIN}" model names,
empty transmission, wrong model year, and manual-only parts trees (automatic
gearbox parts missing). Reported for a 1999 Peugeot 106 automatic shown as a 2029
manual with no automatic parts.
- New self-contained PL24PsaService: consumes PL24's FI flow (vin.action →
hintstoken → FI page → json-vin-main-groups → json-vin-illustrations →
vin-image-board). Reads model/year/transmission from the FI identification
table; builds the VIN-indexed parts tree (correct per actual VIN). Does not
touch Ford/Volvo/Nissan/Opel/Hyundai-Kia/Fiat.
- Orchestrator + categories.service route PSA VIN decode/drill to the new service.
- Cycle-correct extractModelYear in @sase/shared (X→1999, not 2029): resolve the
30-yr VIN year code to the most-recent plausible year (≤ now+1); dedupe 6 copies.
Validated live against 13 already-decoded PSA VINs: 12/13 full trees with real
model/year/transmission; automatics correctly detected (106 BVA, 206 AL4, 3008 BVA8).
Co-Authored-By: Claude Opus 4.8 (1M context) <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>
Second batch from undecoded-vin-rca.md. Every entry verified (authoritative
WMI registry + prod sibling + VDS fingerprint + live PL24 probe) before adding
— a wrong brand label or a dead PL24 route both have real downside.
Q5 — WMI_BRAND_MAP (packages/shared, UX/telemetry only; does NOT change which
catalog decodes). Adds 9: W1V=Mercedes-Benz, VXF=Fiat, YAR=Toyota, NL1=Hyundai,
KPA=SsangYong, PL1=Proton, LSV=Volkswagen, LVV=Chery, ZCF=Iveco. Resolves the
RCA conflicts: VXF=Fiat (not Opel), YAR=Toyota ProAce (not Opel); NL1=Hyundai
(not TOGG — MY char predates TOGG). Deliberately NOT added: WF1/WAA/W0W (1-char
corruptions of WF0/WAU/WVW — mapping them would mislabel typo classes), VE1
(unresolved, deferred).
Q6 — PL24_WMI_SERVICE_MAP (real decode routing). Both target services are
proven live, so these only append WMIs to working routes (no dead-route →
isDecodeable()=true → 25s-budget/circuit-breaker hazard):
- SJN/VSK/MNT → nissan_parts (Sunderland/Spain/Thailand) — regression restore,
dropped in the 757905f refactor; nissan_parts proven (JN1 decodes in prod).
- W1V → mercedesvans_parts (Sprinter/Vito) — retires the RCA "never tried" flag:
mercedesvans_parts decodes WDF44770513186937 live today via the same P5 path.
Tests: shared getBrandFromWmi (adds + corruption-skips return null), pl24 route
map. Full api suite green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Second batch from undecoded-vin-rca.md. Every entry verified (authoritative
WMI registry + prod sibling + VDS fingerprint + live PL24 probe) before adding
— a wrong brand label or a dead PL24 route both have real downside.
Q5 — WMI_BRAND_MAP (packages/shared, UX/telemetry only; does NOT change which
catalog decodes). Adds 9: W1V=Mercedes-Benz, VXF=Fiat, YAR=Toyota, NL1=Hyundai,
KPA=SsangYong, PL1=Proton, LSV=Volkswagen, LVV=Chery, ZCF=Iveco. Resolves the
RCA conflicts: VXF=Fiat (not Opel), YAR=Toyota ProAce (not Opel); NL1=Hyundai
(not TOGG — MY char predates TOGG). Deliberately NOT added: WF1/WAA/W0W (1-char
corruptions of WF0/WAU/WVW — mapping them would mislabel typo classes), VE1
(unresolved, deferred).
Q6 — PL24_WMI_SERVICE_MAP (real decode routing). Both target services are
proven live, so these only append WMIs to working routes (no dead-route →
isDecodeable()=true → 25s-budget/circuit-breaker hazard):
- SJN/VSK/MNT → nissan_parts (Sunderland/Spain/Thailand) — regression restore,
dropped in the 757905f refactor; nissan_parts proven (JN1 decodes in prod).
- W1V → mercedesvans_parts (Sprinter/Vito) — retires the RCA "never tried" flag:
mercedesvans_parts decodes WDF44770513186937 live today via the same P5 path.
Tests: shared getBrandFromWmi (adds + corruption-skips return null), pl24 route
map. Full api suite green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ConfigService.get<number>("EMEX_PROXY_PORT_START") returns the raw env
STRING; the port-pick arithmetic then string-concatenated it
(45 + "10001" = "4510001"), producing an out-of-range port that made
undici's `new URL` throw "Invalid URL" at EmexService construction —
crashing the entire API on boot.
A single-port range (823) happened to concat to a still-parseable "0823",
which masked the bug for months. It surfaced the moment the prod
EMEX_PROXY_PORT range was widened (823 -> 10001-10099) to let Q1's
per-request port rotation work: prod crash-looped until the env was
reverted. Coerce to a validated integer port (1-65535) with default
fallback so a real range is safe.
Regression test: constructing EmexService with string port env over a
real range must not throw.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Root-cause analysis live re-decoded all 124 historically-undecoded prod
VINs; 38 already decode now. These 4 fixes target confirmed code bugs
that drop or mask real decodes (see undecoded-vin-rca.md):
Q2 — PL24 circuit breaker now only counts transient transport faults. A
definitive upstream negative (NotFound/BadRequest) no longer trips the
global 30s breaker that was starving PL24 for every subsequent VIN
(the sibling-VIN inconsistency in the report). Live-proven on VR7.
Q3 — previewVin / multi-candidate path no longer returns an empty
success: the pcat/emex candidate branches fill brandName (from catalogId
/ WMI), fixing the 6 "HTTP 200 with null brand+model" cases.
Q1 — EMEX fetch retries transient proxy failures with a FRESH ProxyAgent
per attempt (rotates the DataImpulse port; ~42% blip rate observed),
plus an opt-in direct fallback (EMEX_DIRECT_FALLBACK). HTTP answers are
never retried.
Q4 — VIN resolve cache keys namespaced by DECODE_CHAIN_VERSION and the
negative TTL drops 6h -> 30m, so a decode-chain fix self-heals stale
negatives on deploy instead of masking phantom-undecoded VINs for hours.
The admin cache-buster uses the same key builder.
Tests: 179 passed (+ new Q2/Q3/Q4 specs). typecheck + biome clean.
Deploy note: prod EMEX_PROXY_PORT_START/END are both 823 (single port);
widen to a real range (e.g. 10001-10099) in Coolify so Q1's port
rotation takes full effect.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase-1 of the backfill scan selects all zero-parts decoded vehicles every wave.
Genuine residue (VINs with no catalog data anywhere — model-indexed HKN,
EMEX-uncovered, etc.) stays zero-parts forever, so it filled the batch every
wave, re-attempting dead vehicles and starving the Phase-2 rolling rescan (its
cursor was stuck for a week).
Track a per-vehicle no-result counter (prefetch:noresult:<id>) incremented when a
prefetch attempt finishes with the vehicle still at zero parts (0 categories in
init, or 0 parts after the whole chain). tryPick skips vehicles past
PREFETCH_NORESULT_MAX (default 2) attempts; the counter has a TTL
(PREFETCH_NORESULT_TTL_DAYS, default 7) so a later catalog fix re-fills them.
Frees capacity for fillable vehicles and lets Phase-2 run.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
After Tier 1 removed the self-throttle, the 5 jobs/min limiter + concurrency 1
became the bottleneck. Make throughput env-tunable so prod can ramp while
watching the fail rate:
- concurrency 1 -> 3 (PREFETCH_CONCURRENCY): parallelises emex/pl24 so a slow
parts-catalogs job no longer head-of-line-blocks the queue.
- rate ceiling 5 -> 20 jobs/min (PREFETCH_RATE_MAX).
- parts-catalogs: drop the pathological cumulative index*20s enqueue delay (the
Nth leaf of a vehicle waited N*20s); keep one per-job pace (PCAT_PACE_MS,
default 15s, 0 to disable).
- PL24 09-18 scrape window now env-tunable (PREFETCH_PL24_START / _END).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The worker's own upstream fetches called touchActivity(), setting the
prefetch:activity:<source> cooldown key (TTL 300s) that checkCooldown then
honoured — so after each fetch the worker paused itself for up to ~5 minutes
(re-checking every 60s, ~5 empty cycles per key). At ~1 fetch / 5 min the
3387-job backlog needed ~6 days to drain.
- Wrap each worker job in an AsyncLocalStorage backfill context; touchActivity
skips the cooldown key when invoked from the worker, so the cooldown reflects
only real user requests (worker yields to users, never to itself).
- Cooldown TTL 300s -> 90s (a request 5 min ago isn't "active").
- checkCooldown pauses for the key's actual remaining TTL (one wait) instead of
a fixed 60s re-check loop.
No extra upstream load — only removes the worker's self-imposed idle time.
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>
Server-side PostHog was disabled in prod (POSTHOG_API_KEY never reached the
container — it wasn't referenced in the compose environment blocks, and per
prior Coolify experience env only injects when referenced as ${VAR}). So all
server-side events (payment_success/failed/refunded, subscription_activated)
silently no-op'd. Reference the vars in both api and worker environment blocks
so a Coolify-set value reaches the container. POSTHOG_HOST defaults to the EU
ingestion host; empty key keeps it disabled (e.g. on dev, to avoid polluting
the prod project).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
No PostHog event carried realized revenue, and EFT/havale activations fired
nothing at all — so total paid revenue / MRR was unmeasurable (a Stripe DWH
connector alone would also miss EFT). activateSubscription is the shared
chokepoint for both Stripe (stripe.service) and EFT/manual (billing.service)
activation, so emit one canonical subscription_activated there with PostHog
revenue props: $revenue (major TRY), currency, mrr (yearly amortised /12),
plan, plan_id, brand_count, billing_period, method (looked up from the latest
payment row), referral_credit_days. Funnel steps keep their kuruş 'amount' but
intentionally carry no $revenue, so revenue isn't double-counted.
Unblocks trial->paid, MRR/ARPU and revenue-by-plan/channel across ALL payment
methods. Injected PostHogService (PostHogModule is @Global).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
PL24 sub-group nav nodes were classified by linkWid.includes("Group")
(case-sensitive). That matched capitalised wids (subGroupsTable) but
missed lowercase ones — groupReferenceTable, groupTable, groupsTable
(~1157 leaf nodes in prod) — so those skipped the group-drill branch in
getCategoryWithPartsInner and the reference-resolution descent, falling
to the parts path (a wasted upstream fetch; the generic drill-on-empty
fallback then re-drilled them). Lowercasing the check routes these nav
nodes straight to children/loadError like their capitalised siblings.
Empty-catalog audit (2026-06-03) showed PL24 drives 61% of user-seen
'0 parça' views; pcat fake-leaves are effectively solved (1 case).
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>