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",
|
||||
|
||||
Reference in New Issue
Block a user