fix(pro-marketing): /pro reflects entitlement — swap upgrade CTAs to dashboard link for Pro users (#3301)

* fix(pro-marketing): /pro reflects entitlement — swap upgrade CTAs for dashboard link when user is already Pro

Reported: a Pro subscriber visiting /pro was pitched "UPGRADE TO PRO"
in the nav + "CHOOSE YOUR PLAN" in the hero, even though the dashboard
correctly recognized them as Pro. The avatar bubble was visible (per
PR-3250) but the upgrade CTAs were unconditional — none of the 14 prior
rollout PRs added an entitlement check to the /pro bundle.

Root cause: pro-test (/pro) is a separate React bundle with no Convex
client and no entitlement awareness. PR-3250 made the nav auth-aware
(signed-in vs anonymous) but not entitlement-aware (pro vs free). A
paying Dodo subscriber whose Clerk `publicMetadata.plan` isn't written
(our webhook pipeline doesn't set it — documented in panel-gating.ts)
still sees the upgrade pitch.

## Changes

### api/me/entitlement.ts (new)

Tiny edge endpoint returning `{ isPro: boolean }` via the existing
`isCallerPremium` helper, which does the canonical two-signal check
(Clerk pro role OR Convex Dodo entitlement tier >= 1). `Cache-Control:
private, no-store` — entitlement flips when Dodo webhooks fire, and
/pro reads it on every load.

### pro-test/src/App.tsx — useProEntitlement hook + conditional CTAs

- New `useProEntitlement(signedIn)` hook. When signed in, fetches
  `/api/me/entitlement` with the Clerk bearer token. Falls back to
  `isPro: false` on any error — /pro stays in upgrade-pitch mode
  rather than silently hiding the purchase path on a flaky network.
- Navbar: "UPGRADE TO PRO" → "GO TO DASHBOARD" (→ worldmonitor.app)
  when `isLoaded && user && isChecked && isPro`. Free/anonymous users
  see the original upgrade CTA unchanged.
- Hero: "CHOOSE YOUR PLAN" → "GO TO DASHBOARD" under the same condition.
  Also removes the #pricing anchor jump which is actively misleading
  for a paying customer.
- Deliberately delays the swap until the entitlement check resolves —
  a one-frame flash of "Upgrade" for a free signed-in user is better
  than a flash of "Go to Dashboard" for an unpaid visitor.

### Locale: en.json adds `nav.goToDashboard` + `hero.goToDashboard`

Other locales fall back to English via i18next's `fallbackLng` — no
translation files need updating for this change to work everywhere.

Bundle rebuilt on Node 22 to match CI.

## Post-Deploy Monitoring & Validation
- Test: sign in on /pro as an existing Pro user → nav shows
  "GO TO DASHBOARD", hero CTA shows "GO TO DASHBOARD".
- Test: sign in on /pro as a free user → original "UPGRADE TO PRO" /
  "CHOOSE YOUR PLAN" CTAs remain unchanged.
- Test: anonymous visitor → identical to pre-change behavior.
- Failure signal: any user report of "went to /pro as Pro user, still
  saw upgrade" within 48h → rollback trigger. Check Sentry for
  `surface: pro-marketing` + action `load-clerk-for-nav` or similar.

* fix(pro-marketing): address PR review — sebuf exception + Sentry on entitlement check

- api/api-route-exceptions.json: register /api/me/entitlement.ts as an
  internal-helper exception. It's a thin wrapper over the canonical
  isCallerPremium helper; the authoritative gates remain in panel-gating,
  isCallerPremium, and gateway.ts PREMIUM_RPC_PATHS. This endpoint exists
  only so the separate pro-test bundle (no Convex client) can ask the
  same question without reimplementing the two-signal check. Unblocks
  the sebuf API contract lint.
- Greptile P2: capture entitlement-check failures to Sentry to match
  the useClerkUser catch-block pattern. Tag surface=pro-marketing,
  action=check-entitlement.

* fix(pro-marketing): address PR review — retry-on-null-token + share entitlement state via context

Addresses reviewer P1 + P2 on PR #3301:

P1 — useProEntitlement treated a first null token as a final "not Pro"
result. Clerk can expose `user` before the session-token endpoint is
ready (same reason services/checkout.ts:getAuthToken retries once after
2s). Without retry, a real Pro user hitting /pro on a cold Clerk load
got a permanent isPro=false for the whole session, so the upgrade CTAs
stayed visible even after Clerk finished warming up. Fix: mirror the
checkout.ts retry pattern — try, sleep 2s, try again.

P2 — Navbar and Hero each called useProEntitlement(!!user), producing
two independent /api/me/entitlement fetches AND two independent state
machines that could disagree on transient failure (one 200, one 500 →
nav and hero showing different CTAs). Fix: hoist the effect into a
ProEntitlementProvider at the App root; Navbar and Hero now both read
from the same Context. One fetch per page load, one source of truth.

No behavior change for anonymous users or for successful Pro checks.

* fix(api/me/entitlement): distinguish auth failure from free-tier

Reviewer P2: returning 200 { isPro: false } for both "free user" and
"bearer missing/invalid" collapses the two states, making a /pro auth
regression read like normal free-tier traffic in edge logs / monitoring.

Fix: validate the bearer with validateBearerToken BEFORE delegating to
isCallerPremium. On missing/malformed/invalid bearer return 401
{ error: "unauthenticated" }; on valid bearer return 200 { isPro } as
before. /pro's client already treats any non-200 as isPro:false (safe
default), so no behavior change for callers — only observability
improves.

P1 (reviewer claim): PR-3298's wm_checkout=success bridge is not wired
end-to-end. NOT reproducible — src/services/checkout-return.ts lines
35-36, 52, and 100 already recognize the marker and return
{ kind: 'success' }, which src/app/panel-layout.ts:190 consumes via
`returnResult.kind === 'success'` to trigger showCheckoutSuccess. No
code change needed; the wiring landed in PR-3274 before PR-3298.
This commit is contained in:
Elie Habib
2026-04-22 23:39:32 +04:00
committed by GitHub
parent d8aee050cb
commit c489aa6dab
6 changed files with 278 additions and 95 deletions

View File

@@ -251,6 +251,13 @@
"owner": "@SebastienMelki",
"removal_issue": null
},
{
"path": "api/me/entitlement.ts",
"category": "internal-helper",
"reason": "Thin wrapper over the canonical server/_shared/premium-check.ts isCallerPremium helper — returns { isPro: boolean } for the /pro marketing bundle so it can swap upgrade CTAs for a dashboard link when the visitor is already a paying Pro user. Not a product data API: the authoritative gates live in panel-gating.ts (frontend), isCallerPremium (per-handler), and gateway.ts PREMIUM_RPC_PATHS (Bearer gate). This endpoint exists purely to let the separate pro-test bundle (no Convex client, no gateway client) ask the same question without reimplementing the two-signal check. Shape is unversioned by design — flip a boolean and ship.",
"owner": "@SebastienMelki",
"removal_issue": null
},
{
"path": "api/latest-brief.ts",

72
api/me/entitlement.ts Normal file
View File

@@ -0,0 +1,72 @@
/**
* GET /api/me/entitlement
*
* Returns { isPro: boolean } for the caller based on the same two-signal
* check used by every premium gate in the codebase (Clerk pro role OR
* Convex Dodo entitlement tier >= 1).
*
* Exists so the /pro marketing bundle (pro-test/) can swap its upgrade
* CTAs for "Go to dashboard" affordances without pulling in a full
* Convex client or reimplementing the two-signal check in a third place.
*
* Status code discipline:
* - 200 { isPro: true|false } — bearer validated; user is pro or free
* - 401 { error: "unauthenticated" } — no bearer, malformed bearer, or
* invalid/expired Clerk session. Distinguishing this from the free-
* tier case matters for observability: a `/pro` auth regression
* would otherwise be indistinguishable from normal free-tier traffic.
* The /pro client treats any non-200 as `isPro: false` (safe default).
*
* Cacheable per-request but NOT shared: Cache-Control private, no-store.
* A user's entitlement changes when Dodo webhooks fire, and /pro reads
* it on every page load — caching at the edge would serve stale state.
*/
export const config = { runtime: 'edge' };
// @ts-expect-error — JS module, no declaration file
import { getCorsHeaders } from '../_cors.js';
import { isCallerPremium } from '../../server/_shared/premium-check';
import { validateBearerToken } from '../../server/auth-session';
export default async function handler(req: Request): Promise<Response> {
const cors = getCorsHeaders(req);
const commonJsonHeaders = { ...cors, 'Content-Type': 'application/json', 'Cache-Control': 'private, no-store' };
if (req.method === 'OPTIONS') {
return new Response(null, { status: 204, headers: cors });
}
if (req.method !== 'GET') {
return new Response(JSON.stringify({ error: 'method_not_allowed' }), {
status: 405,
headers: { ...commonJsonHeaders, Allow: 'GET, OPTIONS' },
});
}
// Validate bearer BEFORE calling isCallerPremium so we can distinguish
// "no/invalid auth" from "auth ok but free tier." isCallerPremium
// itself fails closed (returns false for bad auth), which is safe but
// collapses both cases into identical 200 { isPro: false } responses —
// a /pro auth regression would read like normal free-tier traffic in
// the edge logs. Requiring a valid bearer here surfaces that regression
// as a 4xx surge instead.
const authHeader = req.headers.get('Authorization');
if (!authHeader?.startsWith('Bearer ')) {
return new Response(JSON.stringify({ error: 'unauthenticated' }), {
status: 401, headers: commonJsonHeaders,
});
}
const session = await validateBearerToken(authHeader.slice(7));
if (!session.valid) {
return new Response(JSON.stringify({ error: 'unauthenticated' }), {
status: 401, headers: commonJsonHeaders,
});
}
// Bearer is valid — delegate to the canonical two-signal check for the
// actual entitlement verdict (covers Clerk role AND Convex tier).
const isPro = await isCallerPremium(req);
return new Response(JSON.stringify({ isPro }), {
status: 200, headers: commonJsonHeaders,
});
}

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useRef, type ReactElement } from 'react';
import { useState, useEffect, useRef, createContext, useContext, type ReactElement, type ReactNode } from 'react';
import type { UserResource } from '@clerk/types';
import * as Sentry from '@sentry/react';
import { motion } from 'motion/react';
@@ -131,6 +131,77 @@ function useClerkUser(): { user: UserResource | null; isLoaded: boolean } {
return { user, isLoaded };
}
/**
* Entitlement state shared across /pro — `isPro: true` when the signed-in
* visitor has an active Pro entitlement, either via Clerk pro role OR a
* Convex Dodo subscription (tier >= 1). The provider below performs
* exactly one /api/me/entitlement fetch per page load and makes the
* result available via useProEntitlement(); Navbar and Hero (and any
* future caller) share a single source of truth, so the nav and hero
* can't disagree on transient failures.
*
* Defaults to `{ isPro: false, isChecked: false }` for consumers that
* render without a provider (e.g. tests) — matches the closed-by-default
* stance for unpaid visitors.
*/
type ProEntitlementState = { isPro: boolean; isChecked: boolean };
const ProEntitlementContext = createContext<ProEntitlementState>({ isPro: false, isChecked: false });
function ProEntitlementProvider({ children }: { children: ReactNode }): ReactElement {
const { user } = useClerkUser();
const signedIn = !!user;
const [state, setState] = useState<ProEntitlementState>({ isPro: false, isChecked: false });
useEffect(() => {
if (!signedIn) {
setState({ isPro: false, isChecked: true });
return;
}
let cancelled = false;
(async () => {
try {
const clerk = await ensureClerk();
// Clerk can expose `user` before its session-token endpoint is
// ready; a first null return is a known transient, not a final
// "no token." Retry once after a 2s gap — same pattern as
// services/checkout.ts:getAuthToken. Without the retry, a real
// Pro user hitting /pro on a cold Clerk load gets a permanent
// isPro=false for the whole session.
let token = await clerk.session?.getToken().catch(() => null);
if (!token) {
await new Promise((r) => setTimeout(r, 2000));
token = await clerk.session?.getToken().catch(() => null);
}
if (!token) {
if (!cancelled) setState({ isPro: false, isChecked: true });
return;
}
const resp = await fetch(`${API_BASE}/me/entitlement`, {
headers: { Authorization: `Bearer ${token}` },
signal: AbortSignal.timeout(8_000),
});
if (!resp.ok) {
if (!cancelled) setState({ isPro: false, isChecked: true });
return;
}
const data = await resp.json() as { isPro?: boolean };
if (!cancelled) setState({ isPro: data.isPro === true, isChecked: true });
} catch (err) {
console.error('[auth] Failed to check pro entitlement:', err);
Sentry.captureException(err, { tags: { surface: 'pro-marketing', action: 'check-entitlement' } });
if (!cancelled) setState({ isPro: false, isChecked: true });
}
})();
return () => { cancelled = true; };
}, [signedIn]);
return <ProEntitlementContext.Provider value={state}>{children}</ProEntitlementContext.Provider>;
}
function useProEntitlement(): ProEntitlementState {
return useContext(ProEntitlementContext);
}
/**
* Mounts Clerk's native UserButton (avatar + dropdown with profile + sign
* out) into a DOM node. Using Clerk's built-in widget avoids reimplementing
@@ -190,6 +261,14 @@ const Logo = () => (
/* ─── 0. Navbar ─── */
const Navbar = () => {
const { user, isLoaded } = useClerkUser();
const { isPro, isChecked } = useProEntitlement();
// Show "Go to Dashboard" instead of "Upgrade to Pro" once we confirm
// the visitor is already a paying customer. Until the entitlement
// check completes we keep the upgrade CTA in place — a signed-in
// free user would see a one-frame flash otherwise, which is less
// annoying than showing "Go to Dashboard" for half a second to a
// visitor who hasn't paid.
const showGoToDashboard = isLoaded && !!user && isChecked && isPro;
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">
@@ -214,9 +293,18 @@ const Navbar = () => {
{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>
{showGoToDashboard ? (
<a
href="https://worldmonitor.app"
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 inline-flex items-center gap-1.5"
>
{t('nav.goToDashboard')} <ArrowRight className="w-3 h-3" aria-hidden="true" />
</a>
) : (
<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>
@@ -286,10 +374,16 @@ const SignalBars = () => {
const Hero = () => {
const { user, isLoaded } = useClerkUser();
const { isPro, isChecked } = useProEntitlement();
// 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;
// Swap "Choose Plan" for "Go to Dashboard" once we confirm the visitor
// is already Pro — same reasoning as the nav swap, and also removes
// the #pricing anchor jump which is actively misleading for a paying
// customer.
const showGoToDashboard = isLoaded && !!user && isChecked && isPro;
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" />
@@ -316,9 +410,15 @@ const Hero = () => {
</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>
{showGoToDashboard ? (
<a href="https://worldmonitor.app" 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.goToDashboard')} <ArrowRight className="w-4 h-4" aria-hidden="true" />
</a>
) : (
<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')}
@@ -1306,26 +1406,28 @@ export default function App() {
if (page === 'enterprise') return <EnterprisePage />;
return (
<div className="min-h-screen selection:bg-wm-green/30 selection:text-wm-green">
<Navbar />
<main>
<Hero />
<SourceMarquee />
<Pillars />
<WhyUpgrade />
<TwoPathSplit />
<ProShowcase />
<DeliveryDesk />
<AudiencePersonas />
<SocialProof />
<LivePreview />
<PricingSection refCode={getRefCode()} />
<PricingTable />
<ApiSection />
<EnterpriseShowcase />
<FAQ />
</main>
<Footer />
</div>
<ProEntitlementProvider>
<div className="min-h-screen selection:bg-wm-green/30 selection:text-wm-green">
<Navbar />
<main>
<Hero />
<SourceMarquee />
<Pillars />
<WhyUpgrade />
<TwoPathSplit />
<ProShowcase />
<DeliveryDesk />
<AudiencePersonas />
<SocialProof />
<LivePreview />
<PricingSection refCode={getRefCode()} />
<PricingTable />
<ApiSection />
<EnterpriseShowcase />
<FAQ />
</main>
<Footer />
</div>
</ProEntitlementProvider>
);
}

View File

@@ -6,7 +6,8 @@
"enterprise": "Enterprise",
"reserveAccess": "Reserve Your Early Access",
"signIn": "Sign In",
"upgradeToPro": "Upgrade to Pro"
"upgradeToPro": "Upgrade to Pro",
"goToDashboard": "Go to Dashboard"
},
"hero": {
"noiseWord": "Noise",
@@ -18,6 +19,7 @@
"emailPlaceholder": "Enter your email",
"emailAriaLabel": "Email address for waitlist",
"choosePlan": "Choose Your Plan",
"goToDashboard": "Go to Dashboard",
"signIn": "Sign In"
},
"wired": {

File diff suppressed because one or more lines are too long

View File

@@ -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-CyhOBANM.js"></script>
<script type="module" crossorigin src="/pro/assets/index-B7bmlZIg.js"></script>
<link rel="stylesheet" crossorigin href="/pro/assets/index-xSEP0-ib.css">
</head>
<body>