Files
worldmonitor/pro-test
Elie Habib c279f6f426 fix(pro-marketing): nav reflects auth state, hide pro banner for pro users (#3250)
* fix(pro-marketing): reflect auth state in nav, hide pro banner for pro users

Two related signed-in-experience bugs caught by the user during the
post-purchase flow:

1. /pro Navbar's SIGN IN button never reacted to auth state. The
   component was a static const Navbar = () => <nav>...</nav>; with
   no Clerk subscription, so signing in left the SIGN IN button in
   place even though the user was authenticated.

2. The "Pro is launched — Upgrade to Pro" announcement banner on the
   main app showed for ALL visitors including paying Pro subscribers.
   Pitching upgrade to a customer who already paid is a small but
   real annoyance, and it stays sticky for 7 days via the localStorage
   dismiss key — so a returning paying user dismisses it once and
   then never sees the (genuinely useful) banner again if they later
   downgrade.

## Changes

### pro-test/src/App.tsx — useClerkUser hook + ClerkUserButton

- New useClerkUser() hook subscribes to Clerk via clerk.addListener
  and returns { user, isLoaded } so any component can react to auth
  changes (sign-in, sign-out, account switch).
- New ClerkUserButton component mounts Clerk's native UserButton
  widget (avatar + dropdown with profile/sign-out) into a div via
  clerk.mountUserButton — inherits the existing dark-theme appearance
  options from services/checkout.ts::ensureClerk.
- Navbar swaps SIGN IN button for ClerkUserButton when user is
  signed in. Slot is intentionally empty during isLoaded=false to
  avoid a SIGN IN → avatar flicker for returning users.
- Hero hides its redundant SIGN IN CTA when signed in; collapses to
  just "Choose Plan" which is the relevant action for returning users.
- Public/pro/ rebuilt to ship the change (per PR #3229's bundle-
  freshness rule).

### src/components/ProBanner.ts — premium-aware show + reactive auto-hide

- showProBanner returns early if hasPremiumAccess() — same authoritative
  signal used by the frontend's panel-gating layer (unions API key,
  tester key, Clerk pro role, AND Convex Dodo entitlement).
- onEntitlementChange listener auto-dismisses the banner if a Convex
  snapshot arrives mid-session that flips the user to premium (e.g.
  Dodo webhook lands while they're sitting on the dashboard). Does NOT
  write the dismiss timestamp, so the banner reappears correctly if
  they later downgrade.

## Test plan

### pro-test (sign-in UI)

- [ ] Anonymous user loads /pro → SIGN IN button visible in nav.
- [ ] Click SIGN IN, complete Clerk modal → button replaced with
      Clerk's UserButton avatar dropdown.
- [ ] Open dropdown, click Sign Out → reverts to SIGN IN button.
- [ ] Hard reload as signed-in user → SIGN IN button never flashes;
      avatar appears once Clerk loads.

### main app (banner gating)

- [ ] Anonymous user loads / → "Pro is launched" banner shows.
- [ ] Click ✕ to dismiss → banner stays dismissed for 7 days
      (existing behavior preserved).
- [ ] Pro user (active Convex entitlement) loads / → banner does
      NOT appear, regardless of dismiss state.
- [ ] Free user opens /, then completes checkout in another tab and
      Convex publishes the entitlement snapshot → banner auto-hides
      in the dashboard tab without reload.
- [ ] Pro user whose subscription lapses (validUntil < now) → banner
      reappears on next page load, since dismiss timestamp wasn't
      written by the entitlement-change auto-hide.

* fix(pro-banner): symmetric show/hide on entitlement change

Reviewer caught that the previous iteration only handled the upgrade
direction (premium snapshot → hide banner) but never re-showed the
banner on a downgrade. App.ts calls showProBanner() once at init, so
without a symmetric show path, a session that started premium and
then lost entitlement (cancellation, billing grace expiry, plan
downgrade for the same user) would stay banner-less for the rest of
the SPA session — until a full reload re-ran App.ts init.

Net effect of the bug: the comment claiming "the banner reappears
correctly if they later downgrade or the entitlement lapses" was
false in practice for any in-tab transition.

Two changes:

  1. Cache the container on every showProBanner() call, including
     the early-return paths. App.ts always calls showProBanner()
     once at init regardless of premium state, so this guarantees
     the listener has the container reference even when the initial
     mount was skipped (premium user, dismissed, in iframe).

  2. Make onEntitlementChange handler symmetric:
       - premium snapshot + visible → hide (existing behavior)
       - non-premium snapshot + not visible + cached container +
         not dismissed + not in iframe → re-mount via showProBanner

The non-premium re-mount goes through showProBanner() so it gets the
same gate checks as the initial path (isDismissed, iframe, premium).
We can never surface a banner the user has already explicitly ✕'d
this week.

Edge cases handled:
  - User starts premium, no banner shown, downgrades mid-session
    → listener fires, premium false, no bannerEl, container cached,
       not dismissed → showProBanner mounts banner ✓
  - User starts free, sees banner, upgrades mid-session
    → listener fires, premium true, bannerEl present → fade out ✓
  - User starts free, dismisses banner, upgrades, downgrades
    → listener fires on downgrade, premium false, no bannerEl,
       container cached, isDismissed=true → showProBanner returns early ✓
  - User starts free, banner showing, multiple entitlement snapshots
    arrive without state change → premium=false && bannerEl present,
    neither branch fires, idempotent no-op ✓

* fix(pro-banner): defer initial mount while entitlement is loading

Greptile P1 round-2: hasPremiumAccess() at line 48 reads isEntitled()
synchronously, but the Convex entitlement subscription is fired
non-awaited at App.ts:868 (`void initEntitlementSubscription()`).
showProBanner() runs at App.ts:923 during init Phase 1, before the
first Convex snapshot arrives.

So a Convex-only paying user (Clerk role 'free' + Dodo entitlement
tier=1) sees this sequence:

  t=0    init runs → hasPremiumAccess() === false (isEntitled() reads
         currentState===null) → "Upgrade to Pro" banner mounts
  t=~1s  Convex snapshot arrives → onEntitlementChange fires → my
         listener detects premium=true && bannerEl !== null → fade out

That's a 1+ second flash of "you should upgrade!" content for someone
who has already paid. Worst case is closer to ~10s on a cold-start
Convex client, which is much worse — looks like the upgrade pitch is
the actual UI.

Defer the initial mount when (1) the user is signed in (so they
plausibly have a Convex entitlement) AND (2) the entitlement state
hasn't loaded yet (currentState === null). The existing
onEntitlementChange listener will mount it later if the first
snapshot confirms the user is actually free.

Two reasons this is gated on "signed in":
  - Anonymous users will never have a Convex entitlement, so
    deferring would mean the banner NEVER mounts for them. Bad
    regression: anon visitors are the highest-value audience for
    the upgrade pitch.
  - For signed-in users, the worst case if no entitlement EVER
    arrives is the banner stays absent — which is identical to a
    paying user's correct state, so it fails-closed safely.

Edge case behavior:
  - Anonymous user: no Clerk session → first condition false →
    banner mounts immediately ✓
  - Signed-in free user with first snapshot pre-loaded somehow:
    second condition false → banner mounts immediately ✓
  - Signed-in user, snapshot pending: deferred → listener mounts
    on first snapshot if user turns out free ✓
  - Signed-in user, snapshot pending, user turns out premium: never
    mounted ✓ (the desired path)
  - Signed-in user, snapshot pending, never arrives (Convex outage):
    banner never shows → see above, this fails-closed safely
2026-04-21 11:01:57 +04:00
..

GHBanner

Run and deploy your AI Studio app

This contains everything you need to run your app locally.

View your app in AI Studio: https://ai.studio/apps/ef577c64-7776-42d3-bb38-3f0a627564c3

Run Locally

Prerequisites: Node.js

  1. Install dependencies: npm install
  2. Set the GEMINI_API_KEY in .env.local to your Gemini API key
  3. Run the app: npm run dev