mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
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
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useRef, type ReactElement } from 'react';
|
||||
import type { UserResource } from '@clerk/types';
|
||||
import * as Sentry from '@sentry/react';
|
||||
import { motion } from 'motion/react';
|
||||
import {
|
||||
@@ -62,6 +63,85 @@ function openSignIn(): void {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to Clerk's current user. Returns null while loading or signed out,
|
||||
* and the Clerk UserResource when signed in. Re-renders on any auth change
|
||||
* (sign-in, sign-out, user switch) via clerk.addListener.
|
||||
*
|
||||
* Used by the Navbar to swap the SIGN IN button for Clerk's UserButton avatar
|
||||
* once the visitor is authenticated, and by the Hero to hide its redundant
|
||||
* SIGN IN CTA. Single source of truth for "is the /pro visitor signed in".
|
||||
*/
|
||||
function useClerkUser(): { user: UserResource | null; isLoaded: boolean } {
|
||||
const [user, setUser] = useState<UserResource | null>(null);
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
let unsubscribe: (() => void) | undefined;
|
||||
|
||||
ensureClerk()
|
||||
.then((clerk) => {
|
||||
if (!mounted) return;
|
||||
setUser(clerk.user ?? null);
|
||||
setIsLoaded(true);
|
||||
unsubscribe = clerk.addListener(() => {
|
||||
if (!mounted) return;
|
||||
setUser(clerk.user ?? null);
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('[auth] Failed to load Clerk for nav auth state:', err);
|
||||
Sentry.captureException(err, { tags: { surface: 'pro-marketing', action: 'load-clerk-for-nav' } });
|
||||
if (mounted) setIsLoaded(true); // unblock UI; show signed-out state
|
||||
});
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
unsubscribe?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { user, isLoaded };
|
||||
}
|
||||
|
||||
/**
|
||||
* Mounts Clerk's native UserButton (avatar + dropdown with profile + sign
|
||||
* out) into a DOM node. Using Clerk's built-in widget avoids reimplementing
|
||||
* a signed-in UI from scratch and inherits theming from the existing
|
||||
* clerk.load() appearance options in services/checkout.ts.
|
||||
*/
|
||||
function ClerkUserButton(): ReactElement {
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current) return;
|
||||
const el = ref.current;
|
||||
let unmounted = false;
|
||||
|
||||
ensureClerk()
|
||||
.then((clerk) => {
|
||||
if (unmounted || !el) return;
|
||||
clerk.mountUserButton(el, {
|
||||
afterSignOutUrl: 'https://www.worldmonitor.app/pro',
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('[auth] Failed to mount user button:', err);
|
||||
Sentry.captureException(err, { tags: { surface: 'pro-marketing', action: 'mount-user-button' } });
|
||||
});
|
||||
|
||||
return () => {
|
||||
unmounted = true;
|
||||
ensureClerk().then((clerk) => {
|
||||
if (el) clerk.unmountUserButton(el);
|
||||
}).catch(() => { /* mount path already failed */ });
|
||||
};
|
||||
}, []);
|
||||
|
||||
return <div ref={ref} className="flex items-center" />;
|
||||
}
|
||||
|
||||
const SlackIcon = () => (
|
||||
<svg viewBox="0 0 24 24" className="w-5 h-5" fill="currentColor" aria-hidden="true">
|
||||
<path d="M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zM6.313 15.165a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313zM8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52H8.834zM8.834 6.313a2.528 2.528 0 0 1 2.521 2.521 2.528 2.528 0 0 1-2.521 2.521H2.522A2.528 2.528 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521h6.312zM18.956 8.834a2.528 2.528 0 0 1 2.522-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.522 2.521h-2.522V8.834zM17.688 8.834a2.528 2.528 0 0 1-2.523 2.521 2.527 2.527 0 0 1-2.52-2.521V2.522A2.527 2.527 0 0 1 15.165 0a2.528 2.528 0 0 1 2.523 2.522v6.312zM15.165 18.956a2.528 2.528 0 0 1 2.523 2.522A2.528 2.528 0 0 1 15.165 24a2.527 2.527 0 0 1-2.52-2.522v-2.522h2.52zM15.165 17.688a2.527 2.527 0 0 1-2.52-2.523 2.526 2.526 0 0 1 2.52-2.52h6.313A2.527 2.527 0 0 1 24 15.165a2.528 2.528 0 0 1-2.522 2.523h-6.313z"/>
|
||||
@@ -82,27 +162,40 @@ const Logo = () => (
|
||||
);
|
||||
|
||||
/* ─── 0. Navbar ─── */
|
||||
const Navbar = () => (
|
||||
<nav className="fixed top-0 left-0 right-0 z-50 glass-panel border-b-0 border-x-0 rounded-none" aria-label="Main navigation">
|
||||
<div className="max-w-7xl mx-auto px-6 h-16 flex items-center justify-between">
|
||||
<Logo />
|
||||
<div className="hidden md:flex items-center gap-8 text-sm font-mono text-wm-muted">
|
||||
<a href="#tiers" className="hover:text-wm-text transition-colors">{t('nav.free')}</a>
|
||||
<a href="#pro" className="hover:text-wm-green transition-colors">{t('nav.pro')}</a>
|
||||
<a href="#api" className="hover:text-wm-text transition-colors">{t('nav.api')}</a>
|
||||
<a href="#enterprise" className="hover:text-wm-text transition-colors">{t('nav.enterprise')}</a>
|
||||
const Navbar = () => {
|
||||
const { user, isLoaded } = useClerkUser();
|
||||
return (
|
||||
<nav className="fixed top-0 left-0 right-0 z-50 glass-panel border-b-0 border-x-0 rounded-none" aria-label="Main navigation">
|
||||
<div className="max-w-7xl mx-auto px-6 h-16 flex items-center justify-between">
|
||||
<Logo />
|
||||
<div className="hidden md:flex items-center gap-8 text-sm font-mono text-wm-muted">
|
||||
<a href="#tiers" className="hover:text-wm-text transition-colors">{t('nav.free')}</a>
|
||||
<a href="#pro" className="hover:text-wm-green transition-colors">{t('nav.pro')}</a>
|
||||
<a href="#api" className="hover:text-wm-text transition-colors">{t('nav.api')}</a>
|
||||
<a href="#enterprise" className="hover:text-wm-text transition-colors">{t('nav.enterprise')}</a>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* While Clerk is still loading, render nothing in the auth slot
|
||||
to avoid a SIGN IN → UserButton flicker for returning users. */}
|
||||
{isLoaded && (user
|
||||
? <ClerkUserButton />
|
||||
: (
|
||||
<button
|
||||
type="button"
|
||||
onClick={openSignIn}
|
||||
className="border border-wm-border text-wm-text px-4 py-2 rounded-sm font-mono text-xs uppercase tracking-wider font-bold hover:border-wm-text transition-colors"
|
||||
>
|
||||
{t('nav.signIn')}
|
||||
</button>
|
||||
))}
|
||||
<a href="#pricing" className="bg-wm-green text-wm-bg px-4 py-2 rounded-sm font-mono text-xs uppercase tracking-wider font-bold hover:bg-green-400 transition-colors">
|
||||
{t('nav.upgradeToPro')}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button type="button" onClick={openSignIn} className="border border-wm-border text-wm-text px-4 py-2 rounded-sm font-mono text-xs uppercase tracking-wider font-bold hover:border-wm-text transition-colors">
|
||||
{t('nav.signIn')}
|
||||
</button>
|
||||
<a href="#pricing" className="bg-wm-green text-wm-bg px-4 py-2 rounded-sm font-mono text-xs uppercase tracking-wider font-bold hover:bg-green-400 transition-colors">
|
||||
{t('nav.upgradeToPro')}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
/* ─── 1. Hero — Less noise, more signal ─── */
|
||||
const WiredBadge = () => (
|
||||
@@ -165,49 +258,58 @@ const SignalBars = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const Hero = () => (
|
||||
<section className="pt-28 pb-12 px-6 relative overflow-hidden">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_50%_20%,rgba(74,222,128,0.08)_0%,transparent_50%)] pointer-events-none" />
|
||||
<div className="max-w-4xl mx-auto text-center relative z-10">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6 }}
|
||||
>
|
||||
<div className="mb-4">
|
||||
<WiredBadge />
|
||||
</div>
|
||||
const Hero = () => {
|
||||
const { user, isLoaded } = useClerkUser();
|
||||
// Showing "Sign In" to an already-signed-in user wastes a CTA slot.
|
||||
// Hide it once auth state confirms; falls back to just the "Choose Plan"
|
||||
// CTA which is the relevant action for returning users anyway.
|
||||
const showSignIn = isLoaded && !user;
|
||||
return (
|
||||
<section className="pt-28 pb-12 px-6 relative overflow-hidden">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_50%_20%,rgba(74,222,128,0.08)_0%,transparent_50%)] pointer-events-none" />
|
||||
<div className="max-w-4xl mx-auto text-center relative z-10">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6 }}
|
||||
>
|
||||
<div className="mb-4">
|
||||
<WiredBadge />
|
||||
</div>
|
||||
|
||||
<h1 className="text-6xl md:text-8xl font-display font-bold tracking-tighter leading-[0.95]">
|
||||
<span className="text-wm-muted/40">{t('hero.noiseWord')}</span>
|
||||
<span className="mx-3 md:mx-5 text-wm-border/50">→</span>
|
||||
<span className="text-transparent bg-clip-text bg-gradient-to-r from-wm-green to-emerald-300 text-glow">{t('hero.signalWord')}</span>
|
||||
</h1>
|
||||
<h1 className="text-6xl md:text-8xl font-display font-bold tracking-tighter leading-[0.95]">
|
||||
<span className="text-wm-muted/40">{t('hero.noiseWord')}</span>
|
||||
<span className="mx-3 md:mx-5 text-wm-border/50">→</span>
|
||||
<span className="text-transparent bg-clip-text bg-gradient-to-r from-wm-green to-emerald-300 text-glow">{t('hero.signalWord')}</span>
|
||||
</h1>
|
||||
|
||||
<SignalBars />
|
||||
<SignalBars />
|
||||
|
||||
<p className="text-lg md:text-xl text-wm-muted max-w-xl mx-auto font-light leading-relaxed">
|
||||
{t('hero.valueProps')}
|
||||
</p>
|
||||
<p className="text-lg md:text-xl text-wm-muted max-w-xl mx-auto font-light leading-relaxed">
|
||||
{t('hero.valueProps')}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-3 justify-center mt-8">
|
||||
<a href="#pricing" className="bg-wm-green text-wm-bg px-6 py-3 rounded-sm font-mono text-sm uppercase tracking-wider font-bold hover:bg-green-400 transition-colors flex items-center justify-center gap-2">
|
||||
{t('hero.choosePlan')} <ArrowRight className="w-4 h-4" aria-hidden="true" />
|
||||
</a>
|
||||
<button type="button" onClick={openSignIn} className="border border-wm-border text-wm-text px-6 py-3 rounded-sm font-mono text-sm uppercase tracking-wider font-bold hover:border-wm-text transition-colors">
|
||||
{t('hero.signIn')}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row gap-3 justify-center mt-8">
|
||||
<a href="#pricing" className="bg-wm-green text-wm-bg px-6 py-3 rounded-sm font-mono text-sm uppercase tracking-wider font-bold hover:bg-green-400 transition-colors flex items-center justify-center gap-2">
|
||||
{t('hero.choosePlan')} <ArrowRight className="w-4 h-4" aria-hidden="true" />
|
||||
</a>
|
||||
{showSignIn && (
|
||||
<button type="button" onClick={openSignIn} className="border border-wm-border text-wm-text px-6 py-3 rounded-sm font-mono text-sm uppercase tracking-wider font-bold hover:border-wm-text transition-colors">
|
||||
{t('hero.signIn')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center mt-4">
|
||||
<a href="https://worldmonitor.app" className="text-xs text-wm-green font-mono hover:text-green-300 transition-colors flex items-center gap-1">
|
||||
{t('hero.tryFreeDashboard')} <ArrowRight className="w-3 h-3" aria-hidden="true" />
|
||||
</a>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
<div className="flex items-center justify-center mt-4">
|
||||
<a href="https://worldmonitor.app" className="text-xs text-wm-green font-mono hover:text-green-300 transition-colors flex items-center gap-1">
|
||||
{t('hero.tryFreeDashboard')} <ArrowRight className="w-3 h-3" aria-hidden="true" />
|
||||
</a>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
/* ─── 2. Social proof (current — WIRED badge already in hero) ─── */
|
||||
const SocialProof = () => (
|
||||
|
||||
248
public/pro/assets/index-BzWOWshY.js
Normal file
248
public/pro/assets/index-BzWOWshY.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -144,7 +144,7 @@
|
||||
}
|
||||
</script>
|
||||
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit" async defer></script>
|
||||
<script type="module" crossorigin src="/pro/assets/index-DxdCaItF.js"></script>
|
||||
<script type="module" crossorigin src="/pro/assets/index-BzWOWshY.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/pro/assets/index-InU6PrNf.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
import { trackGateHit } from '@/services/analytics';
|
||||
import { hasPremiumAccess } from '@/services/panel-gating';
|
||||
import { onEntitlementChange, getEntitlementState } from '@/services/entitlements';
|
||||
import { getCurrentClerkUser } from '@/services/clerk';
|
||||
|
||||
let bannerEl: HTMLElement | null = null;
|
||||
// Cached at first showProBanner() call (App.ts always calls it once at init,
|
||||
// regardless of premium state — the early-returns inside decide whether to
|
||||
// actually mount). Holding the container reference here lets the entitlement
|
||||
// listener re-mount the banner on a downgrade without needing App.ts to
|
||||
// re-call showProBanner. Guarded by the same dismiss / iframe / premium
|
||||
// checks that the original mount path uses, so the listener can never
|
||||
// surface a banner the user has already explicitly dismissed.
|
||||
let bannerContainer: HTMLElement | null = null;
|
||||
|
||||
// Versioned dismiss key. The banner copy changed from "Pro is coming / Reserve
|
||||
// your spot" to "Pro is launched / Upgrade to Pro"; a fresh key guarantees
|
||||
@@ -32,9 +43,35 @@ function dismiss(): void {
|
||||
}
|
||||
|
||||
export function showProBanner(container: HTMLElement): void {
|
||||
// Cache container even on early-return paths so the entitlement-change
|
||||
// listener can re-mount on a downgrade. App.ts calls this once at init
|
||||
// regardless of premium state, so caching here covers both "initially
|
||||
// free" and "initially premium then downgrade" trajectories.
|
||||
bannerContainer = container;
|
||||
|
||||
if (bannerEl) return;
|
||||
if (window.self !== window.top) return;
|
||||
if (isDismissed()) return;
|
||||
// Don't pitch Pro to users who already have it. hasPremiumAccess() is the
|
||||
// authoritative signal — unions API key, tester key, Clerk pro role, AND
|
||||
// Convex Dodo entitlement (panel-gating.ts:11-27). A paying user shouldn't
|
||||
// see "Upgrade to Pro" at the top of every dashboard refresh.
|
||||
if (hasPremiumAccess()) return;
|
||||
// Defer the initial mount when entitlement state hasn't loaded yet for a
|
||||
// signed-in user. App.ts:923 calls showProBanner() synchronously during
|
||||
// init Phase 1, but App.ts:868's `void initEntitlementSubscription()` is
|
||||
// non-awaited — the Convex snapshot can take up to ~10s on a cold start.
|
||||
// hasPremiumAccess() reads isEntitled() against currentState===null in
|
||||
// that window and returns false, which would mount an "Upgrade to Pro"
|
||||
// banner for a paying Convex-only user that the onEntitlementChange
|
||||
// listener then has to dismiss seconds later. The flash is jarring and
|
||||
// misleading; better to render nothing until we know the user's tier.
|
||||
//
|
||||
// The skip is gated on "signed in", because anonymous users will never
|
||||
// have a Convex entitlement and would otherwise wait forever. The
|
||||
// listener handles re-mounting once the first snapshot confirms the
|
||||
// user is actually free.
|
||||
if (getCurrentClerkUser() && getEntitlementState() === null) return;
|
||||
|
||||
trackGateHit('pro-banner');
|
||||
|
||||
@@ -77,3 +114,34 @@ export function hideProBanner(): void {
|
||||
export function isProBannerVisible(): boolean {
|
||||
return bannerEl !== null;
|
||||
}
|
||||
|
||||
// Reactive sync with entitlement state. App.ts calls showProBanner() ONCE at
|
||||
// init, so any later free↔pro flip (Dodo webhook lands mid-session, plan
|
||||
// cancelled, billing grace expires) needs an explicit re-render here —
|
||||
// otherwise the banner stays at whatever state the init call computed for
|
||||
// the rest of the SPA session.
|
||||
//
|
||||
// Both directions handled symmetrically:
|
||||
//
|
||||
// - Premium snapshot arrives + banner currently visible
|
||||
// → fade out. Dismiss timestamp intentionally NOT written, so a later
|
||||
// downgrade can re-show it.
|
||||
//
|
||||
// - Non-premium snapshot arrives + banner not visible + cached container +
|
||||
// not user-dismissed + not in iframe
|
||||
// → re-mount via showProBanner. Same gate set as the initial mount path,
|
||||
// so we can never surface a banner the user has already ✕'d this week.
|
||||
onEntitlementChange(() => {
|
||||
const premium = hasPremiumAccess();
|
||||
if (premium && bannerEl) {
|
||||
bannerEl.classList.add('pro-banner-out');
|
||||
setTimeout(() => {
|
||||
bannerEl?.remove();
|
||||
bannerEl = null;
|
||||
}, 300);
|
||||
return;
|
||||
}
|
||||
if (!premium && !bannerEl && bannerContainer) {
|
||||
showProBanner(bannerContainer);
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user