2 Commits

Author SHA1 Message Date
Elie Habib
156cc4b86b refactor(checkout): rollout follow-up — P1 correctness + cleanup (#3276)
Squashed rebase onto current main (which now includes #3259, #3260,
#3261, #3270, #3273, #3274, #3265). PR-3276 was originally written
against PR-11's tip WITHOUT PR-14 (#3270) in its ancestry; now that
#3270 is merged, this rebase reconciles PR-3276's refactors with
the referral + nested-event-shape fixes that landed in main
independently.

Conflicts resolved (5 regions in src/services/checkout.ts):
  1. event shape read: kept main's nested event.data.message.status
     check + renamed _successFired → successFired (PR-3276's closure
     refactor)
  2. startCheckout session reset: applied PR-3276's _resetOverlaySession
     hook AND kept main's effectiveReferral / loadActiveReferral from
     #3270
  3. already-entitled banner branch: kept main's auto-dismiss fix
     (from #3261/#3265). PR-3276 was written without this fix; not
     regressing it. Used PR-3276's inlined isEntitled() check
     (computeInitialBannerState deletion per its P1 #251).
  4+5. banner timeout / active branches: kept main's stopEmailWatchers
     + currentState + currentMaskedEmail AND applied PR-3276's
     _currentBannerCleanup = null cleanup hook (per its P1 #254
     re-mount leak fix)

Also removed stale `origin: 'dashboard'` field from saveCheckoutAttempt
call (PR-3276 deleted that field from CheckoutAttempt interface per
its P1 #251 — dead write-only field).

Net refactor delivers all of PR-3276's intended fixes:
- #247 Module-scoped _successFired → per-session closure
- #249 #3163 hard-dep code markers
- #251 Delete over-engineered primitives (-65 LOC)
- #254 Banner re-mount listener leak cleanup

Tests pass (checkout-attempt-lifecycle + checkout-banner-initial-state).
Typecheck clean.
2026-04-22 16:08:43 +04:00
Elie Habib
c2c6ca355b feat(checkout): unify reload ownership + extended "still unlocking" banner (#3261)
* feat(checkout): unify reload ownership + extended "still unlocking" banner

Before: two independent reload sources competed after a successful
Dodo checkout:
  1. `setTimeout(reload, 3000)` inside the overlay `checkout.status`
      handler (checkout.ts).
  2. `window.location.reload()` inside the entitlement watcher on a
      free→pro transition (panel-layout.ts).

The 3s timer fired unconditionally, which meant the success banner
was guaranteed to be wiped at 3s regardless of webhook latency —
making a "still unlocking" UX impossible. If the webhook was slower
than 3s, the user saw locked panels for a beat before the watcher's
second reload eventually landed.

This PR makes the entitlement watcher the SINGLE reload source
(Primitive C) and extends the banner to stay mounted across the
reload via a three-state machine driven by entitlement events:

  pending  → "Payment received! Unlocking your premium features…"
  active   → "Premium activated — reloading…"  (watcher takes over)
  timeout  → 30s elapsed with no transition; swap to "Refresh if
              features haven't unlocked" + manual Refresh button +
              Sentry warning. Never silently disappears.

Hard-dep on #3163 (fix(pro): reliable post-payment activation) —
shipped 2026-04-18. That PR fixed the `skipInitialSnapshot` guard
that used to swallow the post-payment activation event; without it,
removing the 3s reload would leave some users stranded in pending.

Changes:
- `src/services/checkout.ts`:
  - Remove the 3s `setTimeout(reload)` from the overlay `succeeded`
    handler. Keep `markPostCheckout()` and `onSuccessCallback()` so
    the post-reload consume path still seeds the transition detector.
  - Rewrite `showCheckoutSuccess()` to accept `{ waitForEntitlement }`.
    Classic path (no option) keeps the 5s auto-dismiss. Extended path
    subscribes to `onEntitlementChange`, walks the three-state
    machine, and exposes `data-entitlement-state` for e2e selectors.
  - If already entitled at mount (e.g., post-reload with fast
    webhook), skip straight to "active" so the banner doesn't lie
    about a webhook still being in flight.
- `src/services/checkout-banner-state.ts` (new): pure helpers
  (`computeInitialBannerState`, `EXTENDED_UNLOCK_TIMEOUT_MS`,
  `CLASSIC_AUTO_DISMISS_MS`) extracted so they're testable without
  pulling in the Dodo SDK through checkout.ts (same pattern as
  PR-2's checkout-attempt.ts).
- `src/app/panel-layout.ts`: both success-banner call sites pass
  `{ waitForEntitlement: true }` — one on the checkout-return path
  and one as the `initCheckoutOverlay` onSuccess callback.

Tests: `tests/checkout-banner-initial-state.test.mts` — 5 cases
covering both initial-state branches + timing constant invariants
(30s > 5s ordering, exact values).

Acceptance criteria from PR-4 spec:
- [x] Grep `window.location.reload` in checkout.ts = 1 hit (the
      user-initiated manual Refresh button in the timeout state).
      The automatic reload has been removed.
- [x] Banner remains until entitlement transition OR 30s timeout.
- [x] Timeout branch shows retry CTA.
- [x] No double-reload (entitlement watcher is single source).
- [x] `markPostCheckout` flag still consumed post-reload; success
      banner still appears on next load.

PR-4 of the 14-PR rollout at
docs/plans/2026-04-21-002-feat-harden-auth-checkout-flow-ux-plan.md.

Stacked on PR-2 (`feat/checkout-attempt-lifecycle`). Uses the
discriminated-union return from checkout-return.ts.

Typecheck + scoped lint + boundaries clean. test:data 6078/6078
passing.

* fix(checkout): auto-dismiss already-entitled success banner

showCheckoutSuccess({waitForEntitlement:true}) branched into the
"active" fast-path when isEntitled() was already true at mount — but
that branch had no auto-dismiss and no timeout. The only exit was the
entitlement-watcher reload, which never fires when we're already in
steady pro state (watcher only triggers on transitions). Result: the
"Premium activated — reloading…" banner sat forever until the user
manually refreshed.

Treat the fast-path like a classic confirmation: show active copy and
dismiss on CLASSIC_AUTO_DISMISS_MS (5s) so the user gets closure instead
of a stuck banner.
2026-04-22 15:16:15 +04:00