mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
* fix(settings): stop paying users hitting 409 on stale Upgrade CTA
UnifiedSettings.renderUpgradeSection branched on `isEntitled()`, which
returns false while the Convex entitlement snapshot is still null during
a cold WebSocket load. The modal's `onEntitlementChange` listener only
re-rendered the `api-keys` tab, so the stale "Upgrade to Pro" button in
the Settings tab was never replaced once the snapshot arrived.
Paying users who opened settings in that window saw "Upgrade to Pro",
clicked it, hit `/api/create-checkout`, got 409 `duplicate_subscription`,
and cascaded into the billing-portal fallback path (which itself can
dead-end on a generic Dodo login). Same class of bug as the 2026-04-17/18
panel-overlay duplicate-subscription incident called out in
panel-gating.ts:20-22 -- different surface, same race.
Three-part fix in src/components/UnifiedSettings.ts:
- renderUpgradeSection() returns a hidden wrapper for non-Dodo premium
(API key / tester key / Clerk pro role) so those users don't get
stuck on the loading placeholder indefinitely.
- A signed-in user whose Convex entitlement snapshot is still null gets
a neutral loading placeholder, not "Upgrade to Pro". An early click
can no longer submit before Convex hydrates.
- open()'s onEntitlementChange handler now swaps the .upgrade-pro-section
element in place when the snapshot arrives. Click handlers are
delegated at overlay level, so replacing the node needs no rebind.
Observed signal behind this fix:
- WORLDMONITOR-NY (2026-04-23): Checkout error: duplicate_subscription
- WORLDMONITOR-NZ (2026-04-23): getCustomerPortalUrl Server Error, same
session, 4s later, triggered by the duplicate-subscription dialog.
* fix(settings): hide loading placeholder to avoid empty bordered card
The base .upgrade-pro-section CSS (main.css:22833) applies margin, padding,
border, and surface background. Without 'hidden', the empty loading
placeholder paints a visibly blank bordered box during the Convex
cold-load window — swapping one bad state (stale 'Upgrade to Pro') for
another (confusing empty card).
Adding 'hidden' lets the browser's default [hidden] { display: none }
suppress the card entirely. Element stays queryable for the replaceWith
swap in open(), so the onEntitlementChange listener still finds it.
* fix(settings): bounded readiness window + click-time isEntitled guard
Addresses P1 review on PR #3349: the initial fix treated
getEntitlementState() === null as "still loading", but null is ALSO a
terminal state when Convex is disabled (no VITE_CONVEX_URL), auth times
out at waitForConvexAuth (10s), or initEntitlementSubscription throws
(entitlements.ts:41,47,58,78). In those cases a signed-in free user
would have seen a permanently empty placeholder instead of the Upgrade
to Pro CTA — a real regression on the main conversion surface.
Changes in src/components/UnifiedSettings.ts:
- Add `entitlementReady` class flag + `entitlementReadyTimer`. The flag
flips true on first snapshot OR after a 12s fallback timer kicks in.
12s > the 10s waitForConvexAuth timeout so healthy-but-slow paths land
on the real state before the fallback fires.
- Seed `entitlementReady = getEntitlementState() !== null` BEFORE the
first render() so the initial paint branches on the current snapshot,
not the stale value from a prior open/close cycle.
- renderUpgradeSection() now gates the loading placeholder on
`!this.entitlementReady` so the signed-in-free branch eventually
renders the Upgrade CTA even when Convex never hydrates.
- handleUpgradeClick() defensively re-checks isEntitled() at click time:
if the snapshot arrives AFTER the 12s timer but BEFORE the user's
click, route to the billing portal instead of triggering
/api/create-checkout against an active subscription (which would 409
and re-enter the exact duplicate_subscription → getCustomerPortalUrl
cascade this PR is trying to eliminate).
- Extract replaceUpgradeSection() helper so both the listener and the
fallback timer share the same in-place swap path.
- close() clears the timer.
* fix(settings): clear entitlementReadyTimer in destroy()
Mirror the close() cleanup. Without this, if destroy() is called during
the 12s fallback window the timer fires after teardown and invokes
replaceUpgradeSection() against a detached overlay. The early-return
inside replaceUpgradeSection (querySelector returns null) makes the
callback a no-op, but the stray async callback + DOM reference stay
alive until fire — tidy them up at destroy time.