@easypayment/medusa-payment-paypal 0.9.18 → 0.9.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -36,47 +36,145 @@ declare global {
36
36
 
37
37
 
38
38
  const SERVICE_URL = "/admin/paypal/onboarding-link"
39
- const CACHE_KEY = "pp_onboard_cache"
39
+ // The mini-browser popup is opened by us (see openOnboardingPopup). We give it a
40
+ // stable window name so that, even if PayPal's partner.js *also* intercepts the
41
+ // click, window.open() with the same name re-targets the one window instead of
42
+ // spawning a second one.
43
+ const POPUP_NAME = "PPMiniBrowser"
44
+ const CACHE_PREFIX = "pp_onboard_cache"
40
45
  const CACHE_EXPIRY = 10 * 60 * 1000
41
46
 
42
47
  const ONBOARDING_COMPLETE_ENDPOINT = "/admin/paypal/onboard-complete"
43
48
  const STATUS_ENDPOINT = "/admin/paypal/status"
44
49
  const SAVE_CREDENTIALS_ENDPOINT = "/admin/paypal/save-credentials"
45
50
  const DISCONNECT_ENDPOINT = "/admin/paypal/disconnect"
51
+ const ENVIRONMENT_ENDPOINT = "/admin/paypal/environment"
46
52
 
47
- let cachedUrl: string | null = null
48
- if (typeof window !== "undefined") {
53
+ type Env = "sandbox" | "live"
54
+
55
+ // Onboarding URLs are environment-specific and short-lived, so the cache must be
56
+ // keyed per environment — a single shared key let a stale sandbox URL leak into
57
+ // the live flow (and vice-versa) after a reload.
58
+ const cacheKeyFor = (env: Env) => `${CACHE_PREFIX}_${env}`
59
+
60
+ function readCachedUrl(env: Env): string | null {
61
+ if (typeof window === "undefined") return null
62
+ try {
63
+ const raw = localStorage.getItem(cacheKeyFor(env))
64
+ if (!raw) return null
65
+ const data = JSON.parse(raw)
66
+ if (
67
+ data &&
68
+ typeof data.url === "string" &&
69
+ Date.now() - (Number(data.ts) || 0) < CACHE_EXPIRY
70
+ ) {
71
+ return data.url
72
+ }
73
+ } catch {
74
+ /* ignore malformed cache */
75
+ }
76
+ return null
77
+ }
78
+
79
+ function writeCachedUrl(env: Env, url: string) {
49
80
  try {
50
- const cached = localStorage.getItem(CACHE_KEY)
51
- if (cached) {
52
- const data = JSON.parse(cached)
53
- if (new Date().getTime() - data.ts < CACHE_EXPIRY) {
54
- cachedUrl = data.url
81
+ localStorage.setItem(cacheKeyFor(env), JSON.stringify({ url, ts: Date.now() }))
82
+ } catch {
83
+ /* ignore quota / disabled storage */
84
+ }
85
+ }
86
+
87
+ function clearCachedUrl(env?: Env) {
88
+ try {
89
+ if (env) {
90
+ localStorage.removeItem(cacheKeyFor(env))
91
+ } else {
92
+ localStorage.removeItem(cacheKeyFor("sandbox"))
93
+ localStorage.removeItem(cacheKeyFor("live"))
94
+ }
95
+ } catch {
96
+ /* ignore */
97
+ }
98
+ }
99
+
100
+ // PayPal delivers the seamless-onboarding result by posting a message to
101
+ // window.opener. We validate the sender is actually a PayPal origin before
102
+ // trusting authCode/sharedId.
103
+ function isPayPalOrigin(origin: string): boolean {
104
+ try {
105
+ const host = new URL(origin).hostname.toLowerCase()
106
+ return (
107
+ host === "www.paypal.com" ||
108
+ host === "www.sandbox.paypal.com" ||
109
+ host.endsWith(".paypal.com") ||
110
+ host.endsWith(".paypalobjects.com")
111
+ )
112
+ } catch {
113
+ return false
114
+ }
115
+ }
116
+
117
+ // The completion message shape is not strongly documented, so extract
118
+ // authCode/sharedId defensively from objects, JSON strings, or query strings.
119
+ // We only ever act when BOTH values are present, which keeps the many unrelated
120
+ // intermediate postMessages PayPal emits from triggering a false completion.
121
+ function extractAuth(data: unknown): { authCode?: string; sharedId?: string } {
122
+ if (!data) return {}
123
+
124
+ if (typeof data === "object") {
125
+ const obj = data as Record<string, any>
126
+ const authCode = obj.authCode ?? obj.auth_code ?? obj.authcode
127
+ const sharedId = obj.sharedId ?? obj.shared_id ?? obj.sharedid
128
+ if (authCode && sharedId) {
129
+ return { authCode: String(authCode), sharedId: String(sharedId) }
130
+ }
131
+ for (const key of ["data", "payload", "message", "detail", "body"]) {
132
+ if (obj[key] && typeof obj[key] === "object") {
133
+ const inner = extractAuth(obj[key])
134
+ if (inner.authCode && inner.sharedId) return inner
135
+ }
136
+ }
137
+ return {}
138
+ }
139
+
140
+ if (typeof data === "string") {
141
+ const s = data.trim()
142
+ if (!s) return {}
143
+ if (s.startsWith("{")) {
144
+ try {
145
+ return extractAuth(JSON.parse(s))
146
+ } catch {
147
+ /* not JSON */
148
+ }
149
+ }
150
+ if (/auth_?code/i.test(s) && /shared_?id/i.test(s)) {
151
+ try {
152
+ const sp = new URLSearchParams(s.replace(/^[?#]/, ""))
153
+ const authCode = sp.get("authCode") || sp.get("auth_code") || undefined
154
+ const sharedId = sp.get("sharedId") || sp.get("shared_id") || undefined
155
+ if (authCode && sharedId) return { authCode, sharedId }
156
+ } catch {
157
+ /* not a query string */
55
158
  }
56
159
  }
57
- } catch (e) {
58
- console.error("Cache read error:", e)
59
160
  }
161
+
162
+ return {}
60
163
  }
61
164
 
62
165
 
63
166
  export default function PayPalConnectionPage() {
64
- const [env, setEnv] = useState<"sandbox" | "live">("live")
65
-
66
- useEffect(() => {
67
- fetch("/admin/paypal/environment", { method: "GET", credentials: "include" })
68
- .then((r) => r.json())
69
- .then((d) => {
70
- const v = d?.environment === "sandbox" ? "sandbox" : "live"
71
- setEnv(v)
72
- })
73
- .catch(() => {})
74
- }, [])
167
+ const [env, setEnv] = useState<Env>("live")
168
+ // We must not generate/fetch an onboarding link until we know which
169
+ // environment the server is actually on, otherwise the first fetch races the
170
+ // GET /environment response and can target the wrong environment.
171
+ const [envReady, setEnvReady] = useState(false)
75
172
 
76
173
  const [connState, setConnState] = useState<
77
174
  "loading" | "ready" | "connected" | "error"
78
175
  >("loading")
79
176
  const [error, setError] = useState<string | null>(null)
177
+ const [popupBlocked, setPopupBlocked] = useState(false)
80
178
  const [finalUrl, setFinalUrl] = useState<string>("")
81
179
  const [showManual, setShowManual] = useState(false)
82
180
  const [clientId, setClientId] = useState("")
@@ -95,6 +193,23 @@ export default function PayPalConnectionPage() {
95
193
  const runIdRef = useRef(0)
96
194
  const currentRunId = useRef(0)
97
195
 
196
+ // Live mirrors of state so the document-level capture click listener (set up
197
+ // once) and the stable completion callback always see current values.
198
+ const finalUrlRef = useRef(finalUrl)
199
+ finalUrlRef.current = finalUrl
200
+ const connStateRef = useRef(connState)
201
+ connStateRef.current = connState
202
+ const inProgressRef = useRef(onboardingInProgress)
203
+ inProgressRef.current = onboardingInProgress
204
+ const envRef = useRef<Env>(env)
205
+ envRef.current = env
206
+
207
+ const popupRef = useRef<Window | null>(null)
208
+ const popupPollRef = useRef<ReturnType<typeof setInterval> | null>(null)
209
+ // Guards against the two redundant completion paths (our own message listener
210
+ // and partner.js's onboardingCallback bridge) both POSTing the same result.
211
+ const completedRef = useRef(false)
212
+
98
213
  const ppBtnMeasureRef = useRef<HTMLAnchorElement | null>(null)
99
214
  const [ppBtnWidth, setPpBtnWidth] = useState<number | null>(null)
100
215
 
@@ -107,8 +222,168 @@ export default function PayPalConnectionPage() {
107
222
  setError(msg)
108
223
  }, [])
109
224
 
225
+ const stopPopupPoll = useCallback(() => {
226
+ if (popupPollRef.current) {
227
+ clearInterval(popupPollRef.current)
228
+ popupPollRef.current = null
229
+ }
230
+ }, [])
231
+
232
+ // Open the PayPal mini-browser ourselves, synchronously, inside the click
233
+ // gesture. This is the crux of the fix: browsers only render window.open() as
234
+ // a real popup (instead of a tab — or blocking it) when it runs synchronously
235
+ // in a user gesture. Because finalUrl is already in state at click time, we
236
+ // never need an await before opening, and we never depend on winning a race
237
+ // with partner.js's asynchronous click binding.
238
+ const openOnboardingPopup = useCallback(
239
+ (url: string): Window | null => {
240
+ const w = 450
241
+ const h = 600
242
+ const baseLeft =
243
+ typeof window.screenX === "number" ? window.screenX : (window as any).screenLeft || 0
244
+ const baseTop =
245
+ typeof window.screenY === "number" ? window.screenY : (window as any).screenTop || 0
246
+ const outerW =
247
+ window.outerWidth || document.documentElement.clientWidth || 1024
248
+ const outerH =
249
+ window.outerHeight || document.documentElement.clientHeight || 768
250
+ const left = Math.round(baseLeft + Math.max(0, (outerW - w) / 2))
251
+ const top = Math.round(baseTop + Math.max(0, (outerH - h) / 2))
252
+ const features = `popup=yes,width=${w},height=${h},left=${left},top=${top},scrollbars=yes,resizable=yes`
253
+
254
+ let popup: Window | null = null
255
+ try {
256
+ popup = window.open(url, POPUP_NAME, features)
257
+ } catch {
258
+ popup = null
259
+ }
260
+
261
+ if (popup) {
262
+ popupRef.current = popup
263
+ try {
264
+ popup.focus()
265
+ } catch {
266
+ /* ignore */
267
+ }
268
+ stopPopupPoll()
269
+ popupPollRef.current = setInterval(() => {
270
+ if (!popupRef.current || popupRef.current.closed) {
271
+ stopPopupPoll()
272
+ }
273
+ }, 1000)
274
+ }
275
+
276
+ return popup
277
+ },
278
+ [stopPopupPoll]
279
+ )
280
+
281
+ // Single entry point for "Connect to PayPal". Hard-guards on a real, ready
282
+ // URL so a half-loaded button can never open anything.
283
+ const openConnect = useCallback(() => {
284
+ if (
285
+ connStateRef.current !== "ready" ||
286
+ !finalUrlRef.current ||
287
+ inProgressRef.current
288
+ ) {
289
+ return
290
+ }
291
+ completedRef.current = false
292
+ const popup = openOnboardingPopup(finalUrlRef.current)
293
+ if (!popup) {
294
+ setPopupBlocked(true)
295
+ return
296
+ }
297
+ setPopupBlocked(false)
298
+ }, [openOnboardingPopup])
299
+
300
+ // Exchange the seller authCode/sharedId for credentials. Reached from BOTH
301
+ // PayPal's partner.js bridge (window.onboardingCallback) and our own postMessage
302
+ // listener; completedRef makes it idempotent so we only POST once.
303
+ const completeOnboarding = useCallback(
304
+ async (authCode: string, sharedId: string) => {
305
+ if (!authCode || !sharedId) return
306
+ if (completedRef.current) return
307
+ completedRef.current = true
308
+
309
+ try {
310
+ window.onbeforeunload = null
311
+ } catch {
312
+ /* ignore */
313
+ }
314
+
315
+ const activeEnv: Env = envRef.current === "sandbox" ? "sandbox" : "live"
316
+
317
+ setOnboardingInProgress(true)
318
+ setConnState("loading")
319
+ setError(null)
320
+ setPopupBlocked(false)
321
+
322
+ try {
323
+ const res = await fetch(ONBOARDING_COMPLETE_ENDPOINT, {
324
+ method: "POST",
325
+ headers: { "content-type": "application/json" },
326
+ credentials: "include",
327
+ body: JSON.stringify({ authCode, sharedId, env: activeEnv }),
328
+ })
329
+
330
+ if (!res.ok) {
331
+ const txt = await res.text().catch(() => "")
332
+ throw new Error(txt || `Onboarding exchange failed (${res.status})`)
333
+ }
334
+
335
+ // Close the popup we opened, plus any partner.js-managed flow.
336
+ try {
337
+ popupRef.current?.close()
338
+ } catch {
339
+ /* ignore */
340
+ }
341
+ stopPopupPoll()
342
+ try {
343
+ const close1 = window.PAYPAL?.apps?.Signup?.MiniBrowser?.closeFlow
344
+ if (typeof close1 === "function") close1()
345
+ } catch {
346
+ /* ignore */
347
+ }
348
+ try {
349
+ const close2 =
350
+ window.PAYPAL?.apps?.Signup?.miniBrowser &&
351
+ (window.PAYPAL.apps.Signup.miniBrowser as any).closeFlow
352
+ if (typeof close2 === "function") close2()
353
+ } catch {
354
+ /* ignore */
355
+ }
356
+
357
+ clearCachedUrl(activeEnv)
358
+
359
+ try {
360
+ const statusRes = await fetch(
361
+ `${STATUS_ENDPOINT}?environment=${activeEnv}`,
362
+ { method: "GET", credentials: "include" }
363
+ )
364
+ const refreshedStatus = await statusRes.json().catch(() => ({}))
365
+ setStatusInfo(refreshedStatus || null)
366
+ } catch {
367
+ /* ignore — still consider it connected below */
368
+ }
369
+
370
+ setConnState("connected")
371
+ setShowManual(false)
372
+ } catch (e: any) {
373
+ // Allow a retry after a failed exchange.
374
+ completedRef.current = false
375
+ console.error(e)
376
+ setConnState("error")
377
+ setError(e?.message || "Exchange failed while saving credentials.")
378
+ } finally {
379
+ setOnboardingInProgress(false)
380
+ }
381
+ },
382
+ [stopPopupPoll]
383
+ )
384
+
110
385
  const fetchFreshLink = useCallback(
111
- (runId: number) => {
386
+ (runId: number, targetEnv: Env) => {
112
387
  if (initLoaderRef.current) {
113
388
  const loaderText = initLoaderRef.current.querySelector("#loader-text")
114
389
  if (loaderText)
@@ -121,6 +396,9 @@ export default function PayPalConnectionPage() {
121
396
  credentials: "include",
122
397
  body: JSON.stringify({
123
398
  products: ["PPCP"],
399
+ // Generate the link for the explicitly selected environment so it can
400
+ // never depend on a racing POST /environment having landed yet.
401
+ environment: targetEnv,
124
402
  }),
125
403
  })
126
404
  .then((r) => {
@@ -139,10 +417,7 @@ export default function PayPalConnectionPage() {
139
417
  const url =
140
418
  href + (href.includes("?") ? "&" : "?") + "displayMode=minibrowser"
141
419
 
142
- localStorage.setItem(
143
- CACHE_KEY,
144
- JSON.stringify({ url, ts: Date.now() })
145
- )
420
+ writeCachedUrl(targetEnv, url)
146
421
 
147
422
  setFinalUrl(url)
148
423
  setConnState("ready")
@@ -152,9 +427,35 @@ export default function PayPalConnectionPage() {
152
427
  showError("Unable to connect to service.")
153
428
  })
154
429
  },
155
- [env, showError]
430
+ [showError]
156
431
  )
157
432
 
433
+ // Resolve the server's current environment before doing anything else.
434
+ useEffect(() => {
435
+ let alive = true
436
+ fetch(ENVIRONMENT_ENDPOINT, { method: "GET", credentials: "include" })
437
+ .then((r) => r.json())
438
+ .then((d) => {
439
+ if (!alive) return
440
+ setEnv(d?.environment === "sandbox" ? "sandbox" : "live")
441
+ })
442
+ .catch(() => {})
443
+ .finally(() => {
444
+ if (alive) setEnvReady(true)
445
+ })
446
+ return () => {
447
+ alive = false
448
+ }
449
+ }, [])
450
+
451
+ // partner.js is loaded ONLY for its postMessage->callback bridge
452
+ // (window.onboardingCallback), as a redundant path next to our own message
453
+ // listener below. It is NO LONGER responsible for opening the window — we do
454
+ // that synchronously in the click handler. We still init it so its message
455
+ // bridge registers inside this React SPA (its own DOMContentLoaded hook fired
456
+ // long before this route mounted). If partner.js also intercepts the click,
457
+ // the shared POPUP_NAME means it re-targets our single popup rather than
458
+ // opening a second window.
158
459
  useEffect(() => {
159
460
  if (connState !== "ready" || !finalUrl) return
160
461
 
@@ -162,15 +463,6 @@ export default function PayPalConnectionPage() {
162
463
  let cancelled = false
163
464
  let pollTimer: ReturnType<typeof setTimeout> | undefined
164
465
 
165
- // PayPal's partner.js wires the mini-browser handler to its own
166
- // DOMContentLoaded listener. Inside the Medusa admin (a React SPA) that
167
- // event fired long before this route mounted, so partner.js never scans the
168
- // button and the click falls through to the plain target="_blank" anchor —
169
- // which opens PayPal onboarding in a NEW TAB instead of the popup.
170
- //
171
- // To fix this we load partner.js only after the anchor exists with its real
172
- // href, then explicitly run PayPal's initializer so it discovers the button
173
- // and intercepts the click into the mini-browser popup.
174
466
  const initPartner = (attempt = 0) => {
175
467
  if (cancelled) return
176
468
 
@@ -200,23 +492,18 @@ export default function PayPalConnectionPage() {
200
492
  return
201
493
  }
202
494
 
203
- // The namespace may not be populated the instant onload fires; retry
204
- // briefly before giving up.
205
495
  if (attempt < 40) {
206
496
  pollTimer = setTimeout(() => initPartner(attempt + 1), 50)
207
497
  return
208
498
  }
209
499
 
210
- // Last resort: partner.js exposed no initializer we recognise. Re-fire
211
- // DOMContentLoaded so any listener it registered runs against the
212
- // now-present button.
213
500
  try {
214
501
  document.dispatchEvent(new Event("DOMContentLoaded"))
215
502
  } catch {}
216
503
  }
217
504
 
218
- // Remove any stale copy + namespace so the fresh load re-scans the current
219
- // button (href/environment may have changed since the last render).
505
+ // Remove any stale copy + namespace so the fresh load re-registers against
506
+ // the current environment (sandbox vs live partner.js differ).
220
507
  const existingScript = document.getElementById("paypal-partner-js")
221
508
  if (existingScript) {
222
509
  existingScript.parentNode?.removeChild(existingScript)
@@ -245,15 +532,37 @@ export default function PayPalConnectionPage() {
245
532
  }
246
533
  }, [connState, finalUrl, env])
247
534
 
535
+ // Primary completion path: receive PayPal's seamless-onboarding postMessage
536
+ // ourselves. Independent of partner.js, so it works even when we opened the
537
+ // popup directly.
538
+ useEffect(() => {
539
+ const onMessage = (ev: MessageEvent) => {
540
+ if (!isPayPalOrigin(ev.origin)) return
541
+ const { authCode, sharedId } = extractAuth(ev.data)
542
+ if (authCode && sharedId) {
543
+ completeOnboarding(authCode, sharedId)
544
+ }
545
+ }
546
+ window.addEventListener("message", onMessage)
547
+ return () => window.removeEventListener("message", onMessage)
548
+ }, [completeOnboarding])
549
+
550
+ // Status check + link generation. Gated on envReady so we always use the
551
+ // correct environment. runId guards against a stale response from a previous
552
+ // environment overwriting the current one.
248
553
  useEffect(() => {
554
+ if (!envReady) return
555
+
249
556
  currentRunId.current = ++runIdRef.current
250
557
  const runId = currentRunId.current
251
558
 
252
559
  let cancelled = false
560
+ completedRef.current = false
253
561
 
254
562
  const run = async () => {
255
563
  setConnState("loading")
256
564
  setError(null)
565
+ setPopupBlocked(false)
257
566
  setFinalUrl("")
258
567
 
259
568
  try {
@@ -279,11 +588,14 @@ export default function PayPalConnectionPage() {
279
588
  console.error(e)
280
589
  }
281
590
 
282
- if (cachedUrl) {
283
- setFinalUrl(cachedUrl)
591
+ if (cancelled || runId !== currentRunId.current) return
592
+
593
+ const cached = readCachedUrl(env)
594
+ if (cached) {
595
+ setFinalUrl(cached)
284
596
  setConnState("ready")
285
597
  } else {
286
- fetchFreshLink(runId)
598
+ fetchFreshLink(runId, env)
287
599
  }
288
600
  }
289
601
 
@@ -291,80 +603,46 @@ export default function PayPalConnectionPage() {
291
603
 
292
604
  return () => {
293
605
  cancelled = true
294
- currentRunId.current = 0
295
606
  }
296
- }, [env, fetchFreshLink])
607
+ }, [env, envReady, fetchFreshLink])
297
608
 
609
+ // Bridge partner.js's callback into our single idempotent completion handler.
298
610
  useLayoutEffect(() => {
299
- window.onboardingCallback = async function (authCode: string, sharedId: string) {
300
- try {
301
- window.onbeforeunload = null
302
- } catch {}
303
-
304
- setOnboardingInProgress(true)
305
- setConnState("loading")
306
- setError(null)
307
-
308
- const payload = {
309
- authCode,
310
- sharedId,
311
- env: env === "sandbox" ? "sandbox" : "live",
312
- }
313
-
314
- try {
315
- const res = await fetch(ONBOARDING_COMPLETE_ENDPOINT, {
316
- method: "POST",
317
- headers: { "content-type": "application/json" },
318
- credentials: "include",
319
- body: JSON.stringify(payload),
320
- })
321
-
322
- if (!res.ok) {
323
- const txt = await res.text().catch(() => "")
324
- throw new Error(txt || `Onboarding exchange failed (${res.status})`)
325
- }
611
+ window.onboardingCallback = (authCode: string, sharedId: string) => {
612
+ completeOnboarding(authCode, sharedId)
613
+ }
614
+ return () => {
615
+ window.onboardingCallback = undefined
616
+ }
617
+ }, [completeOnboarding])
326
618
 
327
- try {
328
- const close1 = window.PAYPAL?.apps?.Signup?.MiniBrowser?.closeFlow
329
- if (typeof close1 === "function") close1()
330
- } catch {}
331
- try {
332
- const close2 =
333
- window.PAYPAL?.apps?.Signup?.miniBrowser &&
334
- (window.PAYPAL.apps.Signup.miniBrowser as any).closeFlow
335
- if (typeof close2 === "function") close2()
336
- } catch {}
619
+ // Clean up the popup poll on unmount.
620
+ useEffect(() => {
621
+ return () => {
622
+ stopPopupPoll()
623
+ }
624
+ }, [stopPopupPoll])
337
625
 
338
- try {
339
- localStorage.removeItem(CACHE_KEY)
340
- } catch {}
626
+ // Capture-phase click interceptor: guarantees WE own the popup. Running in the
627
+ // capture phase on the document means we execute before partner.js's own click
628
+ // handler, so we can suppress it (no duplicate window) and open the popup
629
+ // ourselves within the same user gesture. This is what makes the first click
630
+ // after a cold load reliably open a popup instead of a new tab.
631
+ useEffect(() => {
632
+ const onCaptureClick = (e: MouseEvent) => {
633
+ const btn = paypalButtonRef.current
634
+ if (!btn) return
635
+ const target = e.target as Node | null
636
+ if (!target || !btn.contains(target)) return
341
637
 
342
- try {
343
- const statusRes = await fetch(`${STATUS_ENDPOINT}?environment=${env}`, {
344
- method: "GET",
345
- credentials: "include",
346
- })
347
- const refreshedStatus = await statusRes.json().catch(() => ({}))
348
- setStatusInfo(refreshedStatus || null)
349
- setConnState("connected")
350
- setShowManual(false)
351
- } catch {
352
- setConnState("connected")
353
- setShowManual(false)
354
- }
355
- setOnboardingInProgress(false)
356
- } catch (e: any) {
357
- console.error(e)
358
- setConnState("error")
359
- setError(e?.message || "Exchange failed while saving credentials.")
360
- setOnboardingInProgress(false)
361
- }
638
+ e.preventDefault()
639
+ e.stopImmediatePropagation()
640
+ openConnect()
362
641
  }
363
642
 
364
- return () => {
365
- window.onboardingCallback = undefined
366
- }
367
- }, [env])
643
+ document.addEventListener("click", onCaptureClick, true)
644
+ return () => document.removeEventListener("click", onCaptureClick, true)
645
+ }, [openConnect])
368
646
 
369
647
  useLayoutEffect(() => {
370
648
  const el = ppBtnMeasureRef.current
@@ -391,10 +669,12 @@ export default function PayPalConnectionPage() {
391
669
  }
392
670
  }, [connState, env, finalUrl])
393
671
 
672
+ // Defensive fallback: the capture listener above normally handles the click
673
+ // and stops propagation before React sees it. If for any reason it didn't, we
674
+ // still prevent the native target from opening a tab and open the popup here.
394
675
  const handleConnectClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
395
- if (connState !== "ready" || !finalUrl || onboardingInProgress) {
396
- e.preventDefault()
397
- }
676
+ e.preventDefault()
677
+ openConnect()
398
678
  }
399
679
 
400
680
  const handleSaveManual = async () => {
@@ -430,9 +710,7 @@ export default function PayPalConnectionPage() {
430
710
  setStatusInfo(refreshedStatus || null)
431
711
  setShowManual(false)
432
712
 
433
- try {
434
- localStorage.removeItem(CACHE_KEY)
435
- } catch {}
713
+ clearCachedUrl(env)
436
714
  } catch (e: any) {
437
715
  console.error(e)
438
716
  setConnState("error")
@@ -466,14 +744,12 @@ export default function PayPalConnectionPage() {
466
744
  throw new Error(t || `Disconnect failed (${res.status})`)
467
745
  }
468
746
 
469
- try {
470
- localStorage.removeItem(CACHE_KEY)
471
-
472
- } catch {}
747
+ clearCachedUrl(env)
473
748
 
749
+ completedRef.current = false
474
750
  currentRunId.current = ++runIdRef.current
475
751
  const runId = currentRunId.current
476
- fetchFreshLink(runId)
752
+ fetchFreshLink(runId, env)
477
753
  } catch (e: any) {
478
754
  console.error(e)
479
755
  setConnState("error")
@@ -484,22 +760,27 @@ export default function PayPalConnectionPage() {
484
760
  }
485
761
 
486
762
  const handleEnvChange = async (e: React.ChangeEvent<HTMLSelectElement>) => {
487
- const next = e.target.value as "sandbox" | "live"
763
+ const next = e.target.value as Env
764
+
765
+ completedRef.current = false
766
+ setPopupBlocked(false)
767
+ // Drop both cached URLs so neither environment can serve a stale link.
768
+ clearCachedUrl()
769
+
770
+ // Update local state immediately (drives the status/link refetch with the
771
+ // explicit environment) and persist the choice. Because the link/status
772
+ // fetches are environment-explicit, correctness no longer depends on this
773
+ // POST winning a race.
488
774
  setEnv(next)
489
- cachedUrl = null
490
775
 
491
776
  try {
492
- await fetch("/admin/paypal/environment", {
777
+ await fetch(ENVIRONMENT_ENDPOINT, {
493
778
  method: "POST",
494
779
  headers: { "content-type": "application/json" },
495
780
  credentials: "include",
496
781
  body: JSON.stringify({ environment: next }),
497
782
  })
498
783
  } catch {}
499
-
500
- try {
501
- localStorage.removeItem(CACHE_KEY)
502
- } catch {}
503
784
  }
504
785
 
505
786
  return (
@@ -584,7 +865,7 @@ export default function PayPalConnectionPage() {
584
865
  ppBtnMeasureRef.current = node
585
866
  }}
586
867
  id="paypal-button"
587
- target="_blank"
868
+ target={POPUP_NAME}
588
869
  data-paypal-button="true"
589
870
  href={finalUrl || "#"}
590
871
  data-paypal-onboard-complete="onboardingCallback"
@@ -599,6 +880,13 @@ export default function PayPalConnectionPage() {
599
880
  Connect to PayPal
600
881
  </a>
601
882
 
883
+ {popupBlocked && (
884
+ <div className="mt-3 text-left text-xs bg-amber-50 text-amber-700 p-3 border border-amber-200 rounded">
885
+ Your browser blocked the PayPal popup. Please allow popups
886
+ for this site, then click “Connect to PayPal” again.
887
+ </div>
888
+ )}
889
+
602
890
  <div
603
891
  className="mt-2"
604
892
  style={{