fix(checkout): implement checkout.redirect_requested — the Dodo handler we were missing (#3346)

* fix(checkout): implement checkout.redirect_requested — the Dodo handler we were missing (revert #3298)

Buyers got stuck on /pro after successful Dodo payment because NEITHER
pro-test nor dashboard checkout services handled `checkout.redirect_requested`
— the event Dodo's SDK fires under `manualRedirect: true` carrying the
URL the MERCHANT must navigate to. We were only listening for
`checkout.status`, so navigation never happened for Safari users (saw
the orphaned about:blank tab).

PR #3298 chased the wrong theory (flipped /pro to `manualRedirect: false`,
hoping the SDK would auto-navigate). Dodo docs explicitly say that mode
disables BOTH `checkout.status` AND `checkout.redirect_requested`
("only when manualRedirect is enabled"), and the SDK's internal redirect
is where Safari breaks.

Fix:
- Revert /pro to `manualRedirect: true`
- Add `checkout.redirect_requested` handler in BOTH /pro and dashboard:
  `window.location.href = event.data.message.redirect_to`
- Align `status` read to docs-documented `event.data.message.status` only
  (drop legacy top-level `.status` guess)
- `checkout.link_expired` logged to Sentry (follow-up if volume warrants UX)
- Rebuilt public/pro/ bundle on Node 22 (new hash: index-CiMZEtgt.js)

Docs: https://docs.dodopayments.com/developer-resources/overlay-checkout

## Test plan

- [ ] Vercel preview: complete Dodo test-mode checkout on /pro with 4242
      card. Verify console shows `[checkout] dodo event checkout.status
      {status: "succeeded"}` followed by `checkout.redirect_requested`,
      and the tab navigates to worldmonitor.app/?wm_checkout=success&...
      WITHOUT an about:blank second tab.
- [ ] Vercel preview: same flow with a 3DS-required test card.
- [ ] Vercel preview: dashboard in-app upgrade click → overlay → success
      → same-origin navigation lands on worldmonitor.app with Dodo's
      appended ?payment_id=...&status=succeeded&...
- [ ] Post-deploy: Sentry breadcrumbs show full event sequence on every
      success; no new "stuck after paying" user reports in 24h.

## Rollback

Single `git revert` + bundle rebuild. Fallback state is PR #3298's
broken-for-Safari `manualRedirect: false`.

* chore(ci): retrigger Vercel preview build — initial push skipped via ignore-step
This commit is contained in:
Elie Habib
2026-04-23 20:15:46 +04:00
committed by GitHub
parent 53c50f4ba9
commit 64edfffdfc
4 changed files with 82 additions and 49 deletions

View File

@@ -136,20 +136,41 @@ export function initOverlay(onSuccess?: () => void): void {
// payment_id) depending on event type, and anything logged here
// lands in Sentry breadcrumbs via the console integration.
const data = event.data as Record<string, unknown> | undefined;
const status = data?.status
?? (data?.message as Record<string, unknown> | undefined)?.status;
const msg = data?.message as Record<string, unknown> | undefined;
const status = msg?.status as string | undefined;
console.info('[checkout] dodo event', event.event_type,
status !== undefined ? { status } : undefined);
// Dodo's documented `manualRedirect: true` flow emits TWO events
// on terminal success: `checkout.status` for UI updates, and
// `checkout.redirect_requested` carrying the URL WE must navigate
// to. The SDK explicitly hands navigation to the merchant in this
// mode — ignoring `checkout.redirect_requested` is what stranded
// users after paying (docs: overlay-checkout.mdx, inline-checkout.mdx).
//
// Status shape is ONLY `event.data.message.status` per docs — the
// legacy top-level `event.data.status` read was a guess against
// an older SDK version and most likely never matched.
if (event.event_type === 'checkout.status' && status === 'succeeded') {
// Best-effort: with `manualRedirect: false` the SDK performs
// `window.location.href = redirect_to` on a sibling
// `checkout.redirect` event, and that navigation can race
// with this callback. Callers should treat any side effects
// here as a bonus, not a guarantee. The authoritative success
// path is the `?wm_checkout=success` bridge on
// worldmonitor.app that the SDK's redirect lands on.
onSuccess?.();
}
if (event.event_type === 'checkout.redirect_requested') {
const redirectTo = msg?.redirect_to as string | undefined;
// Dodo builds redirect_to from the return_url we sent, appending
// payment_id/subscription_id/status/license_key/email per
// changelog v1.84.0. Our return_url carries `?wm_checkout=success`
// so the dashboard bridge (src/services/checkout-return.ts) fires
// regardless of Dodo's appended params.
window.location.href = redirectTo || 'https://worldmonitor.app/?wm_checkout=success';
}
if (event.event_type === 'checkout.link_expired') {
// Not user-blocking — log-only for now; follow-up if Sentry
// shows volume.
Sentry.captureMessage('Dodo checkout link expired', {
level: 'info',
tags: { surface: 'pro-marketing', code: 'link_expired' },
});
}
},
});
}).catch((err) => {
@@ -320,15 +341,16 @@ async function doCheckout(
DodoPayments.Checkout.open({
checkoutUrl: result.checkout_url,
options: {
// manualRedirect: false — Dodo performs the parent-window
// redirect on success, landing at the `returnUrl` we sent to
// /api/create-checkout (which carries ?wm_checkout=success).
// Relying on the SDK's own redirect avoids a class of bugs
// where `checkout.status=succeeded` never reaches our onEvent
// (iframe internally navigates to wallet-return, postMessage
// gets lost) and the user is stuck on /pro#pricing with the
// overlay open.
manualRedirect: false,
// manualRedirect: true — Dodo emits `checkout.redirect_requested`
// with the final redirect URL and the MERCHANT performs the
// navigation. Reverting PR #3298's `false`: that mode disables
// both `checkout.status` and `checkout.redirect_requested` events
// (docs: "only when manualRedirect is enabled") and depends on
// the SDK's internal redirect, which fails for Safari users
// (stuck on a spinner with an orphaned about:blank tab). The
// correct flow per docs is manualRedirect:true + a
// checkout.redirect_requested handler — see onEvent above.
manualRedirect: true,
themeConfig: {
dark: {
bgPrimary: '#0d0d0d',

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

View File

@@ -164,14 +164,12 @@ export function initCheckoutOverlay(onSuccess?: () => void): void {
onEvent: (event: CheckoutEvent) => {
switch (event.event_type) {
case 'checkout.status': {
// Dodo SDK has emitted `event.data.status` in some versions and
// `event.data.message.status` in others (the /pro build reads both
// already; main app was only reading the first, so successes went
// unnoticed whenever the SDK used the nested shape). Read both.
// Docs-documented shape is ONLY `event.data.message.status` —
// the prior top-level `event.data.status` read was a guess
// against an older SDK version and most likely never matched.
// (overlay-checkout.mdx / inline-checkout.mdx, SDK >= 0.109.2).
const rawData = event.data as Record<string, unknown> | undefined;
const status = typeof rawData?.status === 'string'
? rawData.status
: (rawData?.message as Record<string, unknown> | undefined)?.status;
const status = (rawData?.message as Record<string, unknown> | undefined)?.status;
if (status === 'succeeded') {
successFired = true;
onSuccessCallback?.();
@@ -215,6 +213,19 @@ export function initCheckoutOverlay(onSuccess?: () => void): void {
clearPendingCheckoutIntent();
}
break;
case 'checkout.redirect_requested': {
// With `manualRedirect: true` (below), Dodo's SDK hands the
// final navigation to the merchant via this event. Dodo's own
// redirect path (manualRedirect:false) has been observed to
// fail on Safari with an orphaned about:blank tab; we follow
// the docs-prescribed handler instead.
// (overlay-checkout.mdx: "Redirect the customer manually".)
const redirectTo = (event.data?.message as Record<string, unknown> | undefined)?.redirect_to as string | undefined;
if (redirectTo) {
window.location.href = redirectTo;
}
break;
}
case 'checkout.error':
console.error('[checkout] Overlay error:', event.data?.message);
Sentry.captureMessage(`Dodo checkout overlay error: ${event.data?.message || 'unknown'}`, { level: 'error', tags: { component: 'dodo-checkout' } });