Commit Graph

691 Commits

Author SHA1 Message Date
229ada8adf feat(web): onboarding — skip affordance, stable layout, robust timing, dismissible close
- Show a "Atla" link once the trial actually provisioned so impatient or
  returning users can jump straight to the confirmation card; captures
  onboarding_skipped.
- Reserve a min-height on the provisioning container so the dialog
  doesn't jerk when the loader/error blocks appear or disappear.
- Re-check animation-ended on visibilitychange — background tabs throttle
  setTimeout, which could leave a user stuck if they tabbed away during
  the 7.5s window.
- Show the default close X again when the modal is dismissible (completed
  or escape-hatch active) instead of hiding it for the whole lifecycle.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-31 22:26:45 +03:00
469a7af67e fix(web): onboarding — locale-aware end date, dynamic plan, completion event
- End date no longer hard-codes tr-TR; follows the active locale.
- The plan badge reads from subscription.plan.key (falls back to "full"),
  so a future change to what the trial provisions doesn't make the UI lie.
- The "Şase Aramaya Başla" CTA captures onboarding_completed, so the
  funnel can distinguish a user who actually clicked through vs one who
  dismissed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-31 22:25:10 +03:00
eb7b75fbe8 fix(web): onboarding modal — escape hatch + trial_failed analytics
When the trial mutation either fails repeatedly or just hangs past 30s,
new users were trapped staring at the spinner with no way out. Add:

- A `trial_failed` capture on every mutation error (Onboarding funnel can
  finally measure the activation failure rate).
- After 2 failures OR 30s in provisioning, surface a "still trying"
  panel with "Destek ile konuş" (opens Chatwoot) and "Yine de kapat"
  options, and let interact-outside / Esc dismiss the modal again.
- An openChatwoot() helper + typed `toggle` on the ChatwootApi so other
  components can open the support widget without poking the global.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-31 22:24:20 +03:00
209e98cb41 fix(web): stop leaking /vehicles/undefined and the raw uuid parse error
When the landing decodes a VIN for a logged-in visitor and the API
returns a multi-candidate response (no single id), the code was
navigating to /dashboard/vehicles/$id with id=undefined. The catalog
page then asked the API for /vehicles/undefined and the backend
"invalid input syntax for type uuid: 'undefined'" message landed in
front of the user.

Two-sided fix:
- On the landing, if the decode reply has no id, hand the VIN off to
  /dashboard/search?vin=… instead — that page already renders the
  candidate-picker and the unrecognised-VIN flow.
- On the vehicle detail page, gate both queries on a UUID check and
  render a friendly "Geçersiz araç bağlantısı / Aramaya git" panel for
  any malformed id. Also stop showing raw backend error messages in the
  generic error state — surface the localised hint instead.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-31 22:04:21 +03:00
a4597544b4 feat(web): vehicle detail polish — analytics, chatwoot cleanup, skeleton, heading
- Capture category_view_changed and catalog_search_opened so the funnel
  picks up category-view and cross-tree search activation (previously
  uninstrumented).
- Expose clearChatwootVehicle() and call it from the route's effect
  cleanup so a later support chat doesn't carry the previously-viewed
  car's VIN/brand.
- Match the loading skeleton shape to the active viewMode — render a
  card grid for grid mode (was always 8 row-bars).
- Switch the vehicle title to the display font (font-display, tracking-tight)
  for typography parity with the rest of the app.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-31 21:45:04 +03:00
b9dd154bfd i18n(web): translate the vehicle catalog detail surface
Move every hard-coded Turkish string on /dashboard/vehicles/:id and the
drill-down /categories/:categoryId page into a vehicle.* message block
(tr + en) — page header, error/404 panels with retry, "Araç Bilgileri"
accordion, "Yedek Parça Kategorileri" + the new empty state, equipment
title and show-more/less toggle, summary labels — plus the leaf-category
catalog-error panel and the drill-down's loading/retry copy.

Also translate the CatalogSearch component (placeholder, arias,
searching/treeIncomplete/noMatch hints, section headings, parts-count
badge) under a new catalogSearch.* block, since it lives inside the same
surface and the rest of the page already speaks two languages.

PCAT_LABELS / EMEX_LABELS / VINAPI_FIELDS / LABEL_OVERRIDES are
source-specific data tables, not UI chrome — left as-is.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-31 21:42:48 +03:00
73fe3e33c1 refactor(web): type the vehicle detail page — drop the any-soup
Replace `vehicle: any` in the route + every helper (getVehicleAttributes,
fromPcat, fromEmex, fromVinApi, getFallbackAttributes, getDisplayAttributes,
getEngineCode, getEquipment, VehicleSummary, VehicleAttributes,
VehicleEquipment) with the shared Vehicle type, and type the two queries
as Vehicle / CategoryNode[]. The opaque rawData blob now goes through a
small RawData interface that captures the four source-specific shapes
(pl24, parts-catalogs, emex, vin-api) instead of `any` index access, so
the per-source helpers stay readable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-31 21:35:37 +03:00
74f32b82e5 feat(web): vehicle detail — empty-categories state, equipment show-more, mobile header
- Render a composed "no categories found" panel when the category tree
  resolves to an empty array, so vehicles without a catalog stop showing
  a silent void under "Yedek Parça Kategorileri".
- Collapse the equipment list to the first 8 codes by default with a
  "Tümünü göster (N)" toggle instead of a sub-scrollarea — friendlier
  on mobile, no card-in-card scroll.
- Loosen the header flex layout: the title block gets `min-w-0 flex-1`
  with break-words and a truncated VIN line so long brand+model strings
  no longer crush the layout on narrow screens.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-31 21:29:49 +03:00
a6ce215c47 i18n(web): translate the search page
Move every hard-coded Turkish string on /dashboard/search into a
search.* message block (tr + en) and resolve them via t() — title,
subtitle, input label/placeholder, counter, example button, submit, the
error banner with the subscription nudge, the report-VIN flow + toasts,
preview loading + identified badge + hint, recent-searches header /
see-all / open hint / row aria-label, validation/generic/toast errors,
auto-correction toast, decoding skeleton sr-only text, and the candidate
selection failure toast.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-31 17:28:45 +03:00
29178c0eaa feat(web): post-signup goes to the catalog only for typed VINs
Until now any VIN carried from the landing — including the example VIN
filled by "Örnek aramayı deneyin" — auto-decoded after signup and jumped
straight to the vehicle catalog, skipping the search page entirely. That
is the right behaviour for a visitor who typed their own VIN, but the
example button is meant for exploration; users who clicked it should
land on the search page so they can look around.

Track an `isExampleVin` flag on the landing (set by fillExampleVin,
cleared on any keystroke), carry it through to /register and onward to
/dashboard/search as `example=1`, and skip the auto-decode there when
the flag is present (the VIN is still prefilled). User-typed VINs keep
the existing land-on-catalog behaviour.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-31 17:22:00 +03:00
81d1086fef a11y(web): label the search VIN input and announce history rows
Add a visually hidden Label for the VIN input (the previous version
relied on placeholder text only) and an aria-label on each history row
so screen readers announce the vehicle and VIN before activation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-31 17:19:30 +03:00
1fd21bd04b feat(web): search history opens the vehicle in one click
Clicking a recent search now navigates straight to the vehicle's catalog
page instead of just refilling the VIN input — the row already carries
the vehicle id, so the re-decode round trip is wasted. Hint copy updated
to match.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-31 17:18:46 +03:00
6cff3dea24 refactor(web): type the search history list, fetch only what we render
Replace the `any[]` history query with a small VehicleHistoryItem
interface covering the fields the row renders, and lower the API limit
from 20 to 6 since the UI slices to 6 anyway (saved bandwidth + correct
types on .map).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-31 17:18:15 +03:00
d0f62c2eda Merge pull request 'fix(api): pause whole worker on cooldown, only per-job defer for off-hours' (#65) from dev into main
Reviewed-on: #65
2026-05-31 13:16:42 +00:00
1d9f59ba86 fix(api): pause whole worker on cooldown, only per-job defer for off-hours
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Per-job moveToDelayed for cooldown livelocked the worker: the activity key is
refreshed by every user request, so 60s later the deferred job comes back, key
is still set, defers again. Last 3h on prod logged ~1800 deferrals against 12
real inits and one completion every ~6 min.

Tag RateLimitError with a `cause`. checkCooldown throws "cooldown"; the worker
now calls `this.worker.rateLimit(delayMs)` and throws Worker.RateLimitError() —
the whole queue waits once instead of cycling every job. checkTimeWindow throws
"time-window"; that branch keeps the existing job.moveToDelayed (per-job) so
EMEX (no scrape window) keeps flowing while PL24/pcat jobs sleep till 09:00.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 12:17:05 +03:00
90810b4bd0 Merge pull request 'dev' (#64) from dev into main
Reviewed-on: #64
2026-05-27 20:55:51 +00:00
f733186a04 fix(csp): allow destek.sase.tr for the Chatwoot widget
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
The live-chat SDK, widget iframe, websocket and avatars are served from
destek.sase.tr; the helmet CSP didn't whitelist it, so the browser
blocked sdk.js (script-src violation) and the widget never loaded.
Add destek.sase.tr to script-/img-/media-/connect-/frame-src (+wss for
ActionCable).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 19:50:18 +03:00
1787607be5 feat: embed Chatwoot live-chat widget (destek.sase.tr)
Site-wide live-chat widget served from the self-hosted Chatwoot at
destek.sase.tr, with verified user identity and vehicle context.

- apps/web: lib/chatwoot.ts loads the SDK lazily (mirrors the PostHog
  init pattern), init in main.tsx, identify logged-in users in __root
  via a server-computed HMAC, and attach the viewed vehicle (VIN/brand/
  model) as contact custom attributes on the vehicle detail page.
- apps/api: GET /api/chatwoot/identity (AuthGuard-protected) returns
  HMAC-SHA256(user.id) so the widget can use verified identity.
- env: VITE_CHATWOOT_BASE_URL + VITE_CHATWOOT_WEBSITE_TOKEN (build-time,
  wired through docker-compose.coolify.yml build args + Dockerfile ARG)
  and CHATWOOT_HMAC_TOKEN (api runtime). All optional — widget and
  endpoint no-op when unset.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 19:28:50 +03:00
6e440f55de Merge pull request 'fix(api): defer rate-limited prefetch jobs instead of failing them' (#63) from dev into main
Reviewed-on: #63
2026-05-27 15:05:59 +00:00
c7e59b90e0 fix(api): defer rate-limited prefetch jobs instead of failing them
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
The worker threw a custom RateLimitError for active-user cooldown and off-hours
windows, but BullMQ treated that as a normal failure: it retried with the
queue's exponential backoff (ignoring retryAfterMs) and dropped the job to
"failed" after attempts:3. So during busy hours sub-jobs were discarded en
masse and the requested defer time (e.g. "wait until 09:00") never applied.

Convert RateLimitError into job.moveToDelayed(now + retryAfterMs, token) +
DelayedError — BullMQ's contract for "not done, not failed, retry later". This
does not consume an attempt and honours the exact delay, so cooldown jobs wait
~60-120s and off-hours jobs wait until the window reopens, then resume. Genuine
errors still fail/retry normally.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 18:02:36 +03:00
4196638a8c Merge pull request 'fix(api): translation job IDs must not contain ':'' (#62) from dev into main
Reviewed-on: #62
2026-05-27 14:51:28 +00:00
dae76085ef fix(api): translation job IDs must not contain ':'
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Same BullMQ 5.68 restriction as the prefetch fix (acd5691): a custom job ID
containing ':' is rejected with "Custom Id cannot contain :". enqueueTranslation
built jobId `tr:<base64>`, so every enqueue threw — and both call sites
fire-and-forget with .catch(warn), so it failed silently: fresh terms were
NX-flagged as queued but never actually enqueued, leaving new EMEX/PCAT terms
untranslated (English). Use 'tr-' prefix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 16:49:46 +03:00
dc50d9673d Merge pull request 'dev' (#61) from dev into main
Reviewed-on: #61
2026-05-27 12:28:03 +00:00
acd5691f6b fix(api): prefetch sub-job IDs must not contain ':'
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
BullMQ 5.68 rejects custom job IDs containing ':' (its key separator) with
"Custom Id cannot contain :". prefetch-init's addJob built sub-job IDs as
prefetch:<vehicleId>:<categoryId>:<action>, so every attempt to queue a
children/parts job threw and the whole init failed. This was latent in the
reactive path (failures just logged) and surfaced once the hourly backfill
started driving inits at volume. Use '-' as the separator; the IDs only need
to be deterministic for dedup, not parseable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 15:18:04 +03:00
5f0bdb467d fix(api): gate catalog backfill on prod host, not NODE_ENV
dev.sase.tr (staging) and sase.tr (prod) BOTH run NODE_ENV=production with
SEPARATE databases, so the previous NODE_ENV check would have let the hourly
backfill sweep run against the dev DB too. Gate on the canonical prod host
instead (COOLIFY_FQDN / BETTER_AUTH_URL), with an explicit
CATALOG_BACKFILL_ENABLED override. Default off for any unknown host.

New isCatalogBackfillEnabled() helper used by both the cron registration and
processBackfillScan; dev redeploy now removes the stale scheduler.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 14:29:07 +03:00
cf52bada23 Merge pull request 'dev' (#60) from dev into main
Reviewed-on: #60
2026-05-27 11:08:33 +00:00
d0ee4df57f feat(api): hourly catalog backfill for decoded-but-unfetched vehicles
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
prefetch was reactive-only (on decode) and processInit read top-level
categories straight from DB, so a vehicle decoded but never viewed got
no catalog. Add a production-only sweep so no decoded vehicle is left
without catalog data.

- processInit self-seeds top categories via getCategoryTree when DB has
  none (fetches+inserts top groups from PL24/PSA/EMEX), closing the
  never-viewed gap for both reactive and backfill paths
- new backfill-scan job + hourly cron: phase 1 queues decoded vehicles
  with zero parts, phase 2 rolling createdAt-cursor rescan of all decoded
  vehicles (prefetch-init is idempotent → gap-fills partial ones)
- guardrails: skip wave if queue backlog > 1000, per-source cooldown,
  business-hours window (isWithinTimeWindow), batch <=20, in-flight guard
- PRODUCTION ONLY: gated on NODE_ENV both at cron registration and in
  processBackfillScan; dev has a separate DB and must not scrape

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 14:04:32 +03:00
f7d5f03572 feat(catalog): hide per-level category filter on vehicle page
The new cross-tree catalog search sits directly above the top-level grid on
the vehicle page, so the grid's own "Kategori ara…" filter was a redundant
second search box. Add a hideFilter prop to CategoryGrid and set it there;
catalog-browse and deep category levels (no global search) keep the filter.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 13:35:50 +03:00
8718b08415 feat(catalog): full-catalog search on vehicle page (leaf categories + OEM parts)
The vehicle page search previously only filtered category names at the
currently rendered level. Add a server-side cross-tree search over what's
already drilled into the DB.

New GET /categories/search/:vehicleId?q= returns two sections:
- categories: name-matched leaves UNION the leaf categories that contain a
  matching part (with hit count). "fren balatası" matches no leaf by name —
  the pads are parts under leaves like "Disk freni" — so the union surfaces
  the right leaves.
- parts: parts matching every token on name, or the raw query on oem_code,
  with OEM + leaf + breadcrumb.

Pure DB read (no upstream drill); a treeIncomplete hint is returned when the
vehicle's tree looks barely drilled. Frontend adds a debounced search box on
the vehicle page that hides the normal browse while active.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 13:30:21 +03:00
14fc693ed7 Merge pull request 'dev' (#59) from dev into main
Reviewed-on: #59
2026-05-27 09:50:17 +00:00
9753d00015 fix(web): link "Bize Ulaşın" custom-needs button to the contact page
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
The custom-integration / bulk-query CTA was a dead button. Wrap it in a
Link to /contact.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 12:49:16 +03:00
efd42ade4a fix(web): align remaining "50+ marka" claims to the real count (27)
Follow-up to the landing fix: the credibility strip (subscription/checkout),
meta description, auth-layout badge and blog copy still claimed "50+
marka" while the catalog supports 27. Align all to 27 for consistency.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 11:59:46 +03:00
c76ab7a280 feat(web): plan preselection from landing pricing (CRO #7)
Carry the plan a visitor picks on the landing pricing through to checkout:
- Pricing CTAs now pass ?plan=<key>; logged-in users go straight to
  /dashboard/subscription, others to /register.
- Subscription page accepts ?plan= (and a localStorage carrier that
  survives the value-first register→search onboarding detour) and
  preselects the plan — advancing to brand selection (or payment for
  Full) — unless the sub is active/pending or onboarding is in progress.
- Register validates ?plan and persists it on signup so the choice isn't
  lost through the trial onboarding.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 03:01:26 +03:00
0dc298631d fix(web): landing — align pricing tier cards for easy comparison
Drop the staggered sm:translate-y offsets on the three brand tiers so
their titles, prices and feature lists share a baseline, making the
plans scannable side by side instead of cascading down.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 02:53:33 +03:00
e21683bce6 feat(web): landing — add FAQ section and trust signals near final CTA
Add an objection-handling FAQ (brands, KVKK/security, no card, cancel
anytime, integration, no-VIN path) before the closing CTA, and a
KVKK/SSL trust line under the final CTA button.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 02:52:51 +03:00
076c61d599 feat(web): landing — stronger final CTA, move referral after the close
The closing CTA was an outline (ghost) button — the weakest style at the
most important conversion point. Make it a filled brand button, size lg,
with clearer copy ("30 Gün Ücretsiz Başla — Kart Gerekmez"). Move the
referral banner (a retention loop) to after the final CTA so it no longer
sits between the pricing and the primary close.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 02:51:11 +03:00
eaf6d47f05 fix(web): landing — consistent brand count (27) and wrong-part cost figure
The page claimed "50+ marka" in the hero, carousel, plan and meta while
the stats block and the actual brand carousel show 27 — a credibility
contradiction. Align every brand-count mention to 27. Also align the
wrong-part cost: hero said "450 TL", final CTA "200-500 TL" — use the
200-500 TL range in both.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 02:50:12 +03:00
5338ae9e80 Merge pull request 'dev' (#58) from dev into main
Reviewed-on: #58
2026-05-26 23:40:14 +00:00
18576a2818 chore(web): contact — E-posta kartını kaldır, form placeholder + meta güncelle
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
- info@sase.tr E-posta bilgi kartı kaldırıldı (Destek + Adres kaldı)
- mesaj placeholder "Bize..." → "Size nasıl yardımcı olabiliriz?"
- meta description info@sase.tr referansından arındırıldı

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 02:14:06 +03:00
e4b613f79c Merge pull request 'dev' (#57) from dev into main
Reviewed-on: #57
2026-05-26 22:38:22 +00:00
1c79c18aff feat(api): wire Novu lifecycle email triggers
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Route all lifecycle/transactional emails through Novu
(api.bildirim.semih.ai, delivered via Postal). A framework-agnostic
client is shared by the NestJS API and the standalone BullMQ worker.

- welcome + referral on signup (better-auth user.create.after)
- email-verification + password-reset (auth.ts; token links never
  track-wrapped so the one-time token survives)
- referral-qualified / referral-reward to the referrer on qualification
- payment-success / payment-failed in the Stripe webhook handlers
- trial-ending + win-back via a new daily lifecycle-email cron (worker),
  idempotent via a 1-day endDate window (no sent-flag column)
- signed track.sase.tr CTA links when MAILTRACK_SECRET is set
- NOVU_* / APP_PUBLIC_URL / MAILTRACK_SECRET env added to config,
  validation, .env.example and both compose service blocks

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 01:30:39 +03:00
d35a009653 i18n(web): relabel billing receipt action to "Makbuzunuz"
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 00:58:01 +03:00
ec0fa08e35 Merge pull request 'dev' (#56) from dev into main
Reviewed-on: #56
2026-05-26 21:49:30 +00:00
cf1fee68a9 fix(web): responsive billing table + filters on mobile
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
The payments table forced min-w-[640px] inside overflow-x-auto, so mobile
got an awkward horizontal scroll, and the 5-option status filter could
overflow the viewport. Make the table a responsive layout: on mobile each
payment renders as a labelled stacked card (column label via ::before
from data-label, header row hidden), and on sm+ it stays a normal table.
Let the segmented filters wrap instead of overflowing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 00:36:03 +03:00
c633e846e2 fix(payments): curate /payments/me response — stop leaking internal columns, join plan name
getMyPayments returned the raw payment row, exposing internal fields
(adminNote, iyzicoPaymentId, bankAccountId, session/intent ids) to the
end user. Replace with an explicit projection that returns only what the
billing UI needs, joins planName from the subscription's plan (was always
"-"), and surfaces Stripe receipt availability as a hasStripeReceipt
boolean instead of the raw payment intent id. Frontend reads the boolean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 00:31:33 +03:00
d77face831 fix(web): billing — correct cancelled status label and gate receipt button
- Subscription status is "cancelled" (and "pending") in the API; the i18n
  keys/variant map used "canceled", so the badge showed the raw
  billing.subStatus.cancelled key. Fix the spelling and add pending.
- Only show "View receipt" for Stripe payments that actually have a
  stripePaymentIntentId. Seeded/legacy completed payments have none, so
  the button used to appear and then toast "no receipt available".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 00:28:39 +03:00
1b9492ba49 style(api): drop non-null assertion in refund amount
Replace `input.amount!` with `input.amount ?? Number(payment.amount)`,
which is behaviour-identical (undefined amount = full refund = full
amount) but satisfies lint/style/noNonNullAssertion.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 00:15:41 +03:00