1 Commits

Author SHA1 Message Date
Elie Habib
8172ea5660 feat(auth): expose Sign Up CTA + adjacent Settings button + signup analytics (#3258)
* feat(auth): expose Sign Up CTA + adjacent Settings button + signup analytics

The header auth widget had three latent gaps that this PR closes in one
cohesive change — all touch the same ~30 lines of widget code and the
same Clerk service module:

1. No explicit Sign Up entry point anywhere in the app. Clerk's sign-up
   modal was only reachable via the footer link inside the sign-in
   modal. Add `openSignUp()` to `clerk.ts` + `AuthLauncher` for symmetry
   with `openSignIn`, and render a secondary "Create account" CTA in
   the signed-out header.

2. `AuthHeaderWidget` constructor accepted `_onSignInClick` /
   `_onSettingsClick` callbacks but the underscore prefix was literal —
   the callbacks were never invoked. `event-handlers.ts:1121` passed
   `modal.open()` and `unifiedSettings?.open()` that went nowhere. The
   signed-in state now renders the Clerk UserButton + an adjacent
   Settings button (gear icon) wired to the previously-dead callback,
   so `UserButton → Settings → Manage Billing` is 2 clicks from the
   avatar area. Sign-in path also now honors `onSignInClick` when
   provided, falling back to direct `openSignIn()`.

3. `trackSignUp` was exported but never called. Wiring it to a button
   click would conflate "opened the modal" with "completed sign-up".
   Instead, detect the genuine null→non-null user-id transition inside
   `initAuthAnalytics` and gate on `user.createdAt` being within a 60s
   fresh-signup window. Factored the predicate into
   `isLikelyFreshSignup()` for unit testing.

Changes:
- `src/services/clerk.ts` — add `openSignUp()` + `getClerkUserCreatedAt()`.
- `src/components/AuthHeaderWidget.ts` — consume callbacks; render
  Create Account link (signed-out) and Settings button (signed-in).
- `src/components/AuthLauncher.ts` — add `.openSignUp()` method.
- `src/services/analytics.ts` — detect fresh signup inside auth-state
  listener; export `isLikelyFreshSignup` + `FRESH_SIGNUP_WINDOW_MS`.
- `src/locales/en.json` — new `auth.signIn`, `auth.createAccount`,
  `auth.settings` keys.
- `src/styles/main.css` — `.auth-signup-link` and `.auth-settings-btn`.
- `tests/signup-analytics-gate.test.mts` — 8 unit tests for the
  fresh-signup predicate including boundary, clock-skew, and sign-in
  vs sign-up discrimination.

PR-1 of the 14-PR rollout at
docs/plans/2026-04-21-002-feat-harden-auth-checkout-flow-ux-plan.md.
Pre-merge: verify sign-up is enabled in the Clerk dashboard (if not,
`openSignUp` is a no-op but does not crash).

Typecheck + lint clean. test:data 6036/6036 passing.

* fix(analytics): bound clock-skew tolerance + fix contradictory test

isLikelyFreshSignup previously accepted ANY future createdAt as fresh —
a 10-minute-future timestamp (buggy client clock, spoofed user input)
would fire trackSignUp('clerk'). Now tolerates up to 5s of forward skew
(real server/client drift) and rejects anything further out as malformed.

Test 'returns false when createdAt is in the future (clock skew guard)'
actually asserted true, making the name lie about the behaviour. Split
into two tests with accurate names: one for tiny accepted skew, one for
unrealistic rejected skew.

* fix(analytics): persist signup-tracked marker in sessionStorage

_lastAuth resets to null on every page load, so the null→userId transition
looks identical on the actual signup-completion reload AND on any tab
reload within the 60s createdAt freshness window. Without a durable
marker, trackSignUp fired on every reload until createdAt aged out —
inflating the signup metric.

Add sessionStorage-keyed per-user fire-once guard (`wm-signup-tracked:<uid>`).
Tab-scoped so a fresh session correctly re-counts, and keyed per user so
account switches within the same tab still register if the new user just
signed up. Fails open on storage-unavailable (private mode / quota).

* fix(analytics): promote signup-tracked marker to localStorage (cross-tab)

Reviewer caught a per-tab scope hole: sessionStorage is per-tab, so a
user who signs up in tab A and opens the app in tab B within the 60s
createdAt window would fire a second trackSignUp from B's fresh
sessionStorage. Promote to localStorage (shared across all tabs in the
same browser profile) — once any tab marks the user as tracked, no
other tab for the same user re-fires.

Clerk user ids are effectively unique forever, so no cleanup is needed
and the cross-tab scope is safe (no risk of id reuse).
2026-04-22 17:11:18 +04:00