@easypayment/medusa-payment-paypal 0.9.18 → 0.9.20

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
55
135
  }
56
136
  }
57
- } catch (e) {
58
- console.error("Cache read error:", e)
137
+ return {}
59
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 */
158
+ }
159
+ }
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,29 @@ 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
+ // True once partner.js has initialized and bound its click interceptor to the
213
+ // button. When bound, partner.js opens the mini-browser AND wires its
214
+ // postMessage->onboardingCallback bridge (which auto-closes the popup and
215
+ // saves credentials), so we must NOT open a second window. Until it is bound
216
+ // (cold first click), we open the popup ourselves so the click still works.
217
+ const partnerBoundRef = useRef(false)
218
+
98
219
  const ppBtnMeasureRef = useRef<HTMLAnchorElement | null>(null)
99
220
  const [ppBtnWidth, setPpBtnWidth] = useState<number | null>(null)
100
221
 
@@ -107,8 +228,149 @@ export default function PayPalConnectionPage() {
107
228
  setError(msg)
108
229
  }, [])
109
230
 
231
+ const stopPopupPoll = useCallback(() => {
232
+ if (popupPollRef.current) {
233
+ clearInterval(popupPollRef.current)
234
+ popupPollRef.current = null
235
+ }
236
+ }, [])
237
+
238
+ // Open the PayPal mini-browser ourselves, synchronously, inside the click
239
+ // gesture. This is the crux of the fix: browsers only render window.open() as
240
+ // a real popup (instead of a tab — or blocking it) when it runs synchronously
241
+ // in a user gesture. Because finalUrl is already in state at click time, we
242
+ // never need an await before opening, and we never depend on winning a race
243
+ // with partner.js's asynchronous click binding.
244
+ const openOnboardingPopup = useCallback(
245
+ (url: string): Window | null => {
246
+ const w = 450
247
+ const h = 600
248
+ const baseLeft =
249
+ typeof window.screenX === "number" ? window.screenX : (window as any).screenLeft || 0
250
+ const baseTop =
251
+ typeof window.screenY === "number" ? window.screenY : (window as any).screenTop || 0
252
+ const outerW =
253
+ window.outerWidth || document.documentElement.clientWidth || 1024
254
+ const outerH =
255
+ window.outerHeight || document.documentElement.clientHeight || 768
256
+ const left = Math.round(baseLeft + Math.max(0, (outerW - w) / 2))
257
+ const top = Math.round(baseTop + Math.max(0, (outerH - h) / 2))
258
+ const features = `popup=yes,width=${w},height=${h},left=${left},top=${top},scrollbars=yes,resizable=yes`
259
+
260
+ let popup: Window | null = null
261
+ try {
262
+ popup = window.open(url, POPUP_NAME, features)
263
+ } catch {
264
+ popup = null
265
+ }
266
+
267
+ if (popup) {
268
+ popupRef.current = popup
269
+ try {
270
+ popup.focus()
271
+ } catch {
272
+ /* ignore */
273
+ }
274
+ stopPopupPoll()
275
+ popupPollRef.current = setInterval(() => {
276
+ if (!popupRef.current || popupRef.current.closed) {
277
+ stopPopupPoll()
278
+ }
279
+ }, 1000)
280
+ }
281
+
282
+ return popup
283
+ },
284
+ [stopPopupPoll]
285
+ )
286
+
287
+ // Exchange the seller authCode/sharedId for credentials. Reached from BOTH
288
+ // PayPal's partner.js bridge (window.onboardingCallback) and our own postMessage
289
+ // listener; completedRef makes it idempotent so we only POST once.
290
+ const completeOnboarding = useCallback(
291
+ async (authCode: string, sharedId: string) => {
292
+ if (!authCode || !sharedId) return
293
+ if (completedRef.current) return
294
+ completedRef.current = true
295
+
296
+ try {
297
+ window.onbeforeunload = null
298
+ } catch {
299
+ /* ignore */
300
+ }
301
+
302
+ const activeEnv: Env = envRef.current === "sandbox" ? "sandbox" : "live"
303
+
304
+ setOnboardingInProgress(true)
305
+ setConnState("loading")
306
+ setError(null)
307
+ setPopupBlocked(false)
308
+
309
+ try {
310
+ const res = await fetch(ONBOARDING_COMPLETE_ENDPOINT, {
311
+ method: "POST",
312
+ headers: { "content-type": "application/json" },
313
+ credentials: "include",
314
+ body: JSON.stringify({ authCode, sharedId, env: activeEnv }),
315
+ })
316
+
317
+ if (!res.ok) {
318
+ const txt = await res.text().catch(() => "")
319
+ throw new Error(txt || `Onboarding exchange failed (${res.status})`)
320
+ }
321
+
322
+ // Close the popup we opened, plus any partner.js-managed flow.
323
+ try {
324
+ popupRef.current?.close()
325
+ } catch {
326
+ /* ignore */
327
+ }
328
+ stopPopupPoll()
329
+ try {
330
+ const close1 = window.PAYPAL?.apps?.Signup?.MiniBrowser?.closeFlow
331
+ if (typeof close1 === "function") close1()
332
+ } catch {
333
+ /* ignore */
334
+ }
335
+ try {
336
+ const close2 =
337
+ window.PAYPAL?.apps?.Signup?.miniBrowser &&
338
+ (window.PAYPAL.apps.Signup.miniBrowser as any).closeFlow
339
+ if (typeof close2 === "function") close2()
340
+ } catch {
341
+ /* ignore */
342
+ }
343
+
344
+ clearCachedUrl(activeEnv)
345
+
346
+ try {
347
+ const statusRes = await fetch(
348
+ `${STATUS_ENDPOINT}?environment=${activeEnv}`,
349
+ { method: "GET", credentials: "include" }
350
+ )
351
+ const refreshedStatus = await statusRes.json().catch(() => ({}))
352
+ setStatusInfo(refreshedStatus || null)
353
+ } catch {
354
+ /* ignore — still consider it connected below */
355
+ }
356
+
357
+ setConnState("connected")
358
+ setShowManual(false)
359
+ } catch (e: any) {
360
+ // Allow a retry after a failed exchange.
361
+ completedRef.current = false
362
+ console.error(e)
363
+ setConnState("error")
364
+ setError(e?.message || "Exchange failed while saving credentials.")
365
+ } finally {
366
+ setOnboardingInProgress(false)
367
+ }
368
+ },
369
+ [stopPopupPoll]
370
+ )
371
+
110
372
  const fetchFreshLink = useCallback(
111
- (runId: number) => {
373
+ (runId: number, targetEnv: Env) => {
112
374
  if (initLoaderRef.current) {
113
375
  const loaderText = initLoaderRef.current.querySelector("#loader-text")
114
376
  if (loaderText)
@@ -121,6 +383,9 @@ export default function PayPalConnectionPage() {
121
383
  credentials: "include",
122
384
  body: JSON.stringify({
123
385
  products: ["PPCP"],
386
+ // Generate the link for the explicitly selected environment so it can
387
+ // never depend on a racing POST /environment having landed yet.
388
+ environment: targetEnv,
124
389
  }),
125
390
  })
126
391
  .then((r) => {
@@ -139,10 +404,7 @@ export default function PayPalConnectionPage() {
139
404
  const url =
140
405
  href + (href.includes("?") ? "&" : "?") + "displayMode=minibrowser"
141
406
 
142
- localStorage.setItem(
143
- CACHE_KEY,
144
- JSON.stringify({ url, ts: Date.now() })
145
- )
407
+ writeCachedUrl(targetEnv, url)
146
408
 
147
409
  setFinalUrl(url)
148
410
  setConnState("ready")
@@ -152,9 +414,35 @@ export default function PayPalConnectionPage() {
152
414
  showError("Unable to connect to service.")
153
415
  })
154
416
  },
155
- [env, showError]
417
+ [showError]
156
418
  )
157
419
 
420
+ // Resolve the server's current environment before doing anything else.
421
+ useEffect(() => {
422
+ let alive = true
423
+ fetch(ENVIRONMENT_ENDPOINT, { method: "GET", credentials: "include" })
424
+ .then((r) => r.json())
425
+ .then((d) => {
426
+ if (!alive) return
427
+ setEnv(d?.environment === "sandbox" ? "sandbox" : "live")
428
+ })
429
+ .catch(() => {})
430
+ .finally(() => {
431
+ if (alive) setEnvReady(true)
432
+ })
433
+ return () => {
434
+ alive = false
435
+ }
436
+ }, [])
437
+
438
+ // partner.js is loaded ONLY for its postMessage->callback bridge
439
+ // (window.onboardingCallback), as a redundant path next to our own message
440
+ // listener below. It is NO LONGER responsible for opening the window — we do
441
+ // that synchronously in the click handler. We still init it so its message
442
+ // bridge registers inside this React SPA (its own DOMContentLoaded hook fired
443
+ // long before this route mounted). If partner.js also intercepts the click,
444
+ // the shared POPUP_NAME means it re-targets our single popup rather than
445
+ // opening a second window.
158
446
  useEffect(() => {
159
447
  if (connState !== "ready" || !finalUrl) return
160
448
 
@@ -162,15 +450,6 @@ export default function PayPalConnectionPage() {
162
450
  let cancelled = false
163
451
  let pollTimer: ReturnType<typeof setTimeout> | undefined
164
452
 
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
453
  const initPartner = (attempt = 0) => {
175
454
  if (cancelled) return
176
455
 
@@ -185,6 +464,10 @@ export default function PayPalConnectionPage() {
185
464
  if (target?.init) {
186
465
  try {
187
466
  target.init()
467
+ // partner.js scans the DOM and binds the button synchronously inside
468
+ // init(), so from here a click is intercepted by partner.js (popup +
469
+ // callback bridge) and we should not also open our own window.
470
+ partnerBoundRef.current = true
188
471
  } catch (e) {
189
472
  console.error("[paypal] partner.js init failed:", e)
190
473
  }
@@ -194,29 +477,29 @@ export default function PayPalConnectionPage() {
194
477
  if (typeof signup?.render === "function") {
195
478
  try {
196
479
  signup.render()
480
+ partnerBoundRef.current = true
197
481
  } catch (e) {
198
482
  console.error("[paypal] partner.js render failed:", e)
199
483
  }
200
484
  return
201
485
  }
202
486
 
203
- // The namespace may not be populated the instant onload fires; retry
204
- // briefly before giving up.
205
487
  if (attempt < 40) {
206
488
  pollTimer = setTimeout(() => initPartner(attempt + 1), 50)
207
489
  return
208
490
  }
209
491
 
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
492
  try {
214
493
  document.dispatchEvent(new Event("DOMContentLoaded"))
215
494
  } catch {}
216
495
  }
217
496
 
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).
497
+ // This load hasn't bound the button yet; until init() runs, a click should
498
+ // be handled by us (cold path) rather than assumed to reach partner.js.
499
+ partnerBoundRef.current = false
500
+
501
+ // Remove any stale copy + namespace so the fresh load re-registers against
502
+ // the current environment (sandbox vs live partner.js differ).
220
503
  const existingScript = document.getElementById("paypal-partner-js")
221
504
  if (existingScript) {
222
505
  existingScript.parentNode?.removeChild(existingScript)
@@ -245,15 +528,37 @@ export default function PayPalConnectionPage() {
245
528
  }
246
529
  }, [connState, finalUrl, env])
247
530
 
531
+ // Primary completion path: receive PayPal's seamless-onboarding postMessage
532
+ // ourselves. Independent of partner.js, so it works even when we opened the
533
+ // popup directly.
248
534
  useEffect(() => {
535
+ const onMessage = (ev: MessageEvent) => {
536
+ if (!isPayPalOrigin(ev.origin)) return
537
+ const { authCode, sharedId } = extractAuth(ev.data)
538
+ if (authCode && sharedId) {
539
+ completeOnboarding(authCode, sharedId)
540
+ }
541
+ }
542
+ window.addEventListener("message", onMessage)
543
+ return () => window.removeEventListener("message", onMessage)
544
+ }, [completeOnboarding])
545
+
546
+ // Status check + link generation. Gated on envReady so we always use the
547
+ // correct environment. runId guards against a stale response from a previous
548
+ // environment overwriting the current one.
549
+ useEffect(() => {
550
+ if (!envReady) return
551
+
249
552
  currentRunId.current = ++runIdRef.current
250
553
  const runId = currentRunId.current
251
554
 
252
555
  let cancelled = false
556
+ completedRef.current = false
253
557
 
254
558
  const run = async () => {
255
559
  setConnState("loading")
256
560
  setError(null)
561
+ setPopupBlocked(false)
257
562
  setFinalUrl("")
258
563
 
259
564
  try {
@@ -279,11 +584,14 @@ export default function PayPalConnectionPage() {
279
584
  console.error(e)
280
585
  }
281
586
 
282
- if (cachedUrl) {
283
- setFinalUrl(cachedUrl)
587
+ if (cancelled || runId !== currentRunId.current) return
588
+
589
+ const cached = readCachedUrl(env)
590
+ if (cached) {
591
+ setFinalUrl(cached)
284
592
  setConnState("ready")
285
593
  } else {
286
- fetchFreshLink(runId)
594
+ fetchFreshLink(runId, env)
287
595
  }
288
596
  }
289
597
 
@@ -291,80 +599,25 @@ export default function PayPalConnectionPage() {
291
599
 
292
600
  return () => {
293
601
  cancelled = true
294
- currentRunId.current = 0
295
602
  }
296
- }, [env, fetchFreshLink])
603
+ }, [env, envReady, fetchFreshLink])
297
604
 
605
+ // Bridge partner.js's callback into our single idempotent completion handler.
298
606
  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
- }
326
-
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 {}
337
-
338
- try {
339
- localStorage.removeItem(CACHE_KEY)
340
- } catch {}
341
-
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
- }
607
+ window.onboardingCallback = (authCode: string, sharedId: string) => {
608
+ completeOnboarding(authCode, sharedId)
362
609
  }
363
-
364
610
  return () => {
365
611
  window.onboardingCallback = undefined
366
612
  }
367
- }, [env])
613
+ }, [completeOnboarding])
614
+
615
+ // Clean up the popup poll on unmount.
616
+ useEffect(() => {
617
+ return () => {
618
+ stopPopupPoll()
619
+ }
620
+ }, [stopPopupPoll])
368
621
 
369
622
  useLayoutEffect(() => {
370
623
  const el = ppBtnMeasureRef.current
@@ -391,10 +644,38 @@ export default function PayPalConnectionPage() {
391
644
  }
392
645
  }, [connState, env, finalUrl])
393
646
 
647
+ // Click handling for "Connect to PayPal".
648
+ //
649
+ // We always preventDefault so the native target can never open a new tab.
650
+ //
651
+ // - If partner.js has bound the button, its own click handler already ran (in
652
+ // the capture/target phase, before this React bubble handler) and opened the
653
+ // mini-browser AND wired its postMessage->onboardingCallback bridge — which
654
+ // auto-closes the popup and saves credentials. We must NOT open a second
655
+ // window, so we just return.
656
+ // - Otherwise (cold first click before partner.js finished initializing) we
657
+ // open the popup ourselves so the click still works. Completion is then
658
+ // handled by our own postMessage listener, and also by partner.js's bridge
659
+ // once it finishes initializing a moment later.
394
660
  const handleConnectClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
395
- if (connState !== "ready" || !finalUrl || onboardingInProgress) {
396
- e.preventDefault()
661
+ e.preventDefault()
662
+
663
+ if (
664
+ connStateRef.current !== "ready" ||
665
+ !finalUrlRef.current ||
666
+ inProgressRef.current
667
+ ) {
668
+ return
397
669
  }
670
+
671
+ if (partnerBoundRef.current) {
672
+ // partner.js owns this click (popup + auto-close + callback bridge).
673
+ return
674
+ }
675
+
676
+ completedRef.current = false
677
+ const popup = openOnboardingPopup(finalUrlRef.current)
678
+ setPopupBlocked(!popup)
398
679
  }
399
680
 
400
681
  const handleSaveManual = async () => {
@@ -430,9 +711,7 @@ export default function PayPalConnectionPage() {
430
711
  setStatusInfo(refreshedStatus || null)
431
712
  setShowManual(false)
432
713
 
433
- try {
434
- localStorage.removeItem(CACHE_KEY)
435
- } catch {}
714
+ clearCachedUrl(env)
436
715
  } catch (e: any) {
437
716
  console.error(e)
438
717
  setConnState("error")
@@ -466,14 +745,12 @@ export default function PayPalConnectionPage() {
466
745
  throw new Error(t || `Disconnect failed (${res.status})`)
467
746
  }
468
747
 
469
- try {
470
- localStorage.removeItem(CACHE_KEY)
471
-
472
- } catch {}
748
+ clearCachedUrl(env)
473
749
 
750
+ completedRef.current = false
474
751
  currentRunId.current = ++runIdRef.current
475
752
  const runId = currentRunId.current
476
- fetchFreshLink(runId)
753
+ fetchFreshLink(runId, env)
477
754
  } catch (e: any) {
478
755
  console.error(e)
479
756
  setConnState("error")
@@ -484,22 +761,27 @@ export default function PayPalConnectionPage() {
484
761
  }
485
762
 
486
763
  const handleEnvChange = async (e: React.ChangeEvent<HTMLSelectElement>) => {
487
- const next = e.target.value as "sandbox" | "live"
764
+ const next = e.target.value as Env
765
+
766
+ completedRef.current = false
767
+ setPopupBlocked(false)
768
+ // Drop both cached URLs so neither environment can serve a stale link.
769
+ clearCachedUrl()
770
+
771
+ // Update local state immediately (drives the status/link refetch with the
772
+ // explicit environment) and persist the choice. Because the link/status
773
+ // fetches are environment-explicit, correctness no longer depends on this
774
+ // POST winning a race.
488
775
  setEnv(next)
489
- cachedUrl = null
490
776
 
491
777
  try {
492
- await fetch("/admin/paypal/environment", {
778
+ await fetch(ENVIRONMENT_ENDPOINT, {
493
779
  method: "POST",
494
780
  headers: { "content-type": "application/json" },
495
781
  credentials: "include",
496
782
  body: JSON.stringify({ environment: next }),
497
783
  })
498
784
  } catch {}
499
-
500
- try {
501
- localStorage.removeItem(CACHE_KEY)
502
- } catch {}
503
785
  }
504
786
 
505
787
  return (
@@ -584,7 +866,7 @@ export default function PayPalConnectionPage() {
584
866
  ppBtnMeasureRef.current = node
585
867
  }}
586
868
  id="paypal-button"
587
- target="_blank"
869
+ target={POPUP_NAME}
588
870
  data-paypal-button="true"
589
871
  href={finalUrl || "#"}
590
872
  data-paypal-onboard-complete="onboardingCallback"
@@ -599,6 +881,13 @@ export default function PayPalConnectionPage() {
599
881
  Connect to PayPal
600
882
  </a>
601
883
 
884
+ {popupBlocked && (
885
+ <div className="mt-3 text-left text-xs bg-amber-50 text-amber-700 p-3 border border-amber-200 rounded">
886
+ Your browser blocked the PayPal popup. Please allow popups
887
+ for this site, then click “Connect to PayPal” again.
888
+ </div>
889
+ )}
890
+
602
891
  <div
603
892
  className="mt-2"
604
893
  style={{