mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
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:
@@ -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
72
api/me/entitlement.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user