@easypayment/medusa-payment-paypal 0.9.22 → 0.9.24

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.
Files changed (40) hide show
  1. package/.medusa/server/src/admin/index.js +262 -92
  2. package/.medusa/server/src/admin/index.mjs +262 -92
  3. package/.medusa/server/src/admin/routes/settings/paypal/connection/page.d.ts.map +1 -1
  4. package/.medusa/server/src/admin/routes/settings/paypal/connection/page.js +304 -146
  5. package/.medusa/server/src/admin/routes/settings/paypal/connection/page.js.map +1 -1
  6. package/.medusa/server/src/api/admin/payment-collections/[id]/payment-sessions/route.d.ts.map +1 -1
  7. package/.medusa/server/src/api/admin/payment-collections/[id]/payment-sessions/route.js +6 -8
  8. package/.medusa/server/src/api/admin/payment-collections/[id]/payment-sessions/route.js.map +1 -1
  9. package/.medusa/server/src/api/admin/paypal/environment/route.d.ts.map +1 -1
  10. package/.medusa/server/src/api/admin/paypal/environment/route.js +11 -3
  11. package/.medusa/server/src/api/admin/paypal/environment/route.js.map +1 -1
  12. package/.medusa/server/src/api/admin/paypal/onboarding-status/route.d.ts.map +1 -1
  13. package/.medusa/server/src/api/admin/paypal/onboarding-status/route.js +4 -1
  14. package/.medusa/server/src/api/admin/paypal/onboarding-status/route.js.map +1 -1
  15. package/.medusa/server/src/api/store/payment-collections/[id]/payment-sessions/route.d.ts.map +1 -1
  16. package/.medusa/server/src/api/store/payment-collections/[id]/payment-sessions/route.js +6 -8
  17. package/.medusa/server/src/api/store/payment-collections/[id]/payment-sessions/route.js.map +1 -1
  18. package/.medusa/server/src/api/store/paypal/capture-order/route.d.ts.map +1 -1
  19. package/.medusa/server/src/api/store/paypal/capture-order/route.js +11 -0
  20. package/.medusa/server/src/api/store/paypal/capture-order/route.js.map +1 -1
  21. package/.medusa/server/src/api/store/paypal-complete/route.d.ts.map +1 -1
  22. package/.medusa/server/src/api/store/paypal-complete/route.js +2 -4
  23. package/.medusa/server/src/api/store/paypal-complete/route.js.map +1 -1
  24. package/.medusa/server/src/modules/paypal/payment-provider/card-service.d.ts.map +1 -1
  25. package/.medusa/server/src/modules/paypal/payment-provider/card-service.js +55 -34
  26. package/.medusa/server/src/modules/paypal/payment-provider/card-service.js.map +1 -1
  27. package/.medusa/server/src/modules/paypal/utils/core-workflow.d.ts +32 -0
  28. package/.medusa/server/src/modules/paypal/utils/core-workflow.d.ts.map +1 -0
  29. package/.medusa/server/src/modules/paypal/utils/core-workflow.js +40 -0
  30. package/.medusa/server/src/modules/paypal/utils/core-workflow.js.map +1 -0
  31. package/package.json +1 -1
  32. package/src/admin/routes/settings/paypal/connection/page.tsx +330 -161
  33. package/src/api/admin/payment-collections/[id]/payment-sessions/route.ts +6 -8
  34. package/src/api/admin/paypal/environment/route.ts +10 -3
  35. package/src/api/admin/paypal/onboarding-status/route.ts +4 -1
  36. package/src/api/store/payment-collections/[id]/payment-sessions/route.ts +6 -8
  37. package/src/api/store/paypal/capture-order/route.ts +15 -0
  38. package/src/api/store/paypal-complete/route.ts +6 -4
  39. package/src/modules/paypal/payment-provider/card-service.ts +72 -38
  40. package/src/modules/paypal/utils/core-workflow.ts +46 -0
@@ -36,16 +36,11 @@ declare global {
36
36
 
37
37
 
38
38
  const SERVICE_URL = "/admin/paypal/onboarding-link"
39
- // PayPal's conventional mini-browser window name; also used as the anchor's
40
- // `target` so partner.js opens the flow in a popup.
39
+ // PayPal's conventional mini-browser window name (also the anchor target).
41
40
  const POPUP_NAME = "PPFrame"
42
41
 
43
- // The onboarding link is cached in the browser for 6 hours. This is the heart of
44
- // the integration: partner.js can only wire the mini-browser + completion
45
- // callback if the anchor already carries a valid onboarding href when partner.js
46
- // initializes. By caching the link (and reloading once to populate the cache on
47
- // a cold start) the link is always present on page load, so partner.js can take
48
- // over the click and run its flow exactly as documented.
42
+ // The onboarding link is cached in the browser for 6 hours so it is always
43
+ // present for the mini-browser flow without regenerating on every load.
49
44
  const CACHE_PREFIX = "pp_onboard_cache"
50
45
  const CACHE_EXPIRY = 6 * 60 * 60 * 1000 // 6 hours
51
46
 
@@ -57,9 +52,6 @@ const ENVIRONMENT_ENDPOINT = "/admin/paypal/environment"
57
52
 
58
53
  type Env = "sandbox" | "live"
59
54
 
60
- // Onboarding URLs are environment-specific, so the cache is keyed per
61
- // environment — a single shared key let a stale sandbox URL leak into the live
62
- // flow (and vice-versa).
63
55
  const cacheKeyFor = (env: Env) => `${CACHE_PREFIX}_${env}`
64
56
 
65
57
  function readCachedUrl(env: Env): string | null {
@@ -81,8 +73,6 @@ function readCachedUrl(env: Env): string | null {
81
73
  return null
82
74
  }
83
75
 
84
- // Returns true only if the value was actually persisted (storage enabled), which
85
- // lets the caller decide whether reloading to pick up the cache is safe.
86
76
  function writeCachedUrl(env: Env, url: string): boolean {
87
77
  try {
88
78
  localStorage.setItem(cacheKeyFor(env), JSON.stringify({ url, ts: Date.now() }))
@@ -105,22 +95,82 @@ function clearCachedUrl(env?: Env) {
105
95
  }
106
96
  }
107
97
 
98
+ function isPayPalOrigin(origin: string): boolean {
99
+ try {
100
+ const host = new URL(origin).hostname.toLowerCase()
101
+ return (
102
+ host === "www.paypal.com" ||
103
+ host === "www.sandbox.paypal.com" ||
104
+ host.endsWith(".paypal.com") ||
105
+ host.endsWith(".paypalobjects.com")
106
+ )
107
+ } catch {
108
+ return false
109
+ }
110
+ }
111
+
112
+ // Defensively pull authCode/sharedId out of whatever shape PayPal posts back
113
+ // (object, JSON string, or query string; possibly nested). We only act when both
114
+ // are present, which ignores the many unrelated intermediate messages PayPal
115
+ // emits.
116
+ function extractAuth(data: unknown): { authCode?: string; sharedId?: string } {
117
+ if (!data) return {}
118
+
119
+ if (typeof data === "object") {
120
+ const obj = data as Record<string, any>
121
+ const authCode = obj.authCode ?? obj.auth_code ?? obj.authcode ?? obj.code
122
+ const sharedId = obj.sharedId ?? obj.shared_id ?? obj.sharedid
123
+ if (authCode && sharedId) {
124
+ return { authCode: String(authCode), sharedId: String(sharedId) }
125
+ }
126
+ for (const key of ["data", "payload", "message", "detail", "body", "params"]) {
127
+ if (obj[key] && typeof obj[key] === "object") {
128
+ const inner = extractAuth(obj[key])
129
+ if (inner.authCode && inner.sharedId) return inner
130
+ }
131
+ if (typeof obj[key] === "string") {
132
+ const inner = extractAuth(obj[key])
133
+ if (inner.authCode && inner.sharedId) return inner
134
+ }
135
+ }
136
+ return {}
137
+ }
138
+
139
+ if (typeof data === "string") {
140
+ const s = data.trim()
141
+ if (!s) return {}
142
+ if (s.startsWith("{") || s.startsWith("[")) {
143
+ try {
144
+ return extractAuth(JSON.parse(s))
145
+ } catch {
146
+ /* not JSON */
147
+ }
148
+ }
149
+ if (/auth_?code/i.test(s) && /shared_?id/i.test(s)) {
150
+ try {
151
+ const sp = new URLSearchParams(s.replace(/^[?#]/, ""))
152
+ const authCode = sp.get("authCode") || sp.get("auth_code") || undefined
153
+ const sharedId = sp.get("sharedId") || sp.get("shared_id") || undefined
154
+ if (authCode && sharedId) return { authCode, sharedId }
155
+ } catch {
156
+ /* not a query string */
157
+ }
158
+ }
159
+ }
160
+
161
+ return {}
162
+ }
163
+
108
164
 
109
165
  export default function PayPalConnectionPage() {
110
166
  const [env, setEnv] = useState<Env>("live")
111
- // We must know the server's environment before generating/looking up a link so
112
- // the per-environment cache and the generated link always match.
113
167
  const [envReady, setEnvReady] = useState(false)
114
168
 
115
169
  const [connState, setConnState] = useState<
116
170
  "loading" | "ready" | "connected" | "error"
117
171
  >("loading")
118
- // True once partner.js has loaded and bound its click interceptor to the
119
- // button. The button stays in the DOM but is not clickable until then, so a
120
- // click can never fall through to the native anchor (which would open a tab
121
- // without partner.js's onboarding flow / completion callback).
122
- const [partnerReady, setPartnerReady] = useState(false)
123
172
  const [error, setError] = useState<string | null>(null)
173
+ const [popupBlocked, setPopupBlocked] = useState(false)
124
174
  const [finalUrl, setFinalUrl] = useState<string>("")
125
175
  const [showManual, setShowManual] = useState(false)
126
176
  const [clientId, setClientId] = useState("")
@@ -134,15 +184,22 @@ export default function PayPalConnectionPage() {
134
184
  const [onboardingInProgress, setOnboardingInProgress] = useState(false)
135
185
 
136
186
  const initLoaderRef = useRef<HTMLDivElement>(null)
187
+ const paypalButtonRef = useRef<HTMLAnchorElement | null>(null)
137
188
  const errorLogRef = useRef<HTMLDivElement>(null)
138
189
  const runIdRef = useRef(0)
139
190
  const currentRunId = useRef(0)
140
191
 
141
192
  const envRef = useRef<Env>(env)
142
193
  envRef.current = env
143
-
144
- // Guards against partner.js's bridge invoking the completion callback more
145
- // than once.
194
+ const finalUrlRef = useRef(finalUrl)
195
+ finalUrlRef.current = finalUrl
196
+ const connStateRef = useRef(connState)
197
+ connStateRef.current = connState
198
+ const inProgressRef = useRef(onboardingInProgress)
199
+ inProgressRef.current = onboardingInProgress
200
+
201
+ const popupRef = useRef<Window | null>(null)
202
+ const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
146
203
  const completedRef = useRef(false)
147
204
 
148
205
  const ppBtnMeasureRef = useRef<HTMLAnchorElement | null>(null)
@@ -157,17 +214,161 @@ export default function PayPalConnectionPage() {
157
214
  setError(msg)
158
215
  }, [])
159
216
 
160
- // Generate a fresh onboarding link, cache it for 6 hours, then reload the page
161
- // so that on the next load the cached link is already present for partner.js.
162
- // If browser storage is unavailable we fall back to using the link in-place
163
- // (no reload) so we can never get stuck in a reload loop.
217
+ const stopPoll = useCallback(() => {
218
+ if (pollRef.current) {
219
+ clearInterval(pollRef.current)
220
+ pollRef.current = null
221
+ }
222
+ }, [])
223
+
224
+ // Open PayPal's mini-browser ourselves, synchronously inside the click gesture.
225
+ // This is the only reliable way to get a popup (rather than a new tab, which is
226
+ // what happens when partner.js's asynchronous open misses the user-activation
227
+ // window). Named POPUP_NAME and sized like PayPal's mini-browser.
228
+ const openOnboardingPopup = useCallback((url: string): Window | null => {
229
+ const w = 450
230
+ const h = 600
231
+ const baseLeft =
232
+ typeof window.screenX === "number" ? window.screenX : (window as any).screenLeft || 0
233
+ const baseTop =
234
+ typeof window.screenY === "number" ? window.screenY : (window as any).screenTop || 0
235
+ const outerW = window.outerWidth || document.documentElement.clientWidth || 1024
236
+ const outerH = window.outerHeight || document.documentElement.clientHeight || 768
237
+ const left = Math.round(baseLeft + Math.max(0, (outerW - w) / 2))
238
+ const top = Math.round(baseTop + Math.max(0, (outerH - h) / 2))
239
+ const features = `popup=yes,width=${w},height=${h},left=${left},top=${top},scrollbars=yes,resizable=yes`
240
+
241
+ let popup: Window | null = null
242
+ try {
243
+ popup = window.open(url, POPUP_NAME, features)
244
+ } catch {
245
+ popup = null
246
+ }
247
+ if (popup) {
248
+ popupRef.current = popup
249
+ try {
250
+ popup.focus()
251
+ } catch {
252
+ /* ignore */
253
+ }
254
+ }
255
+ return popup
256
+ }, [])
257
+
258
+ // Exchange authCode/sharedId for seller credentials. Idempotent so the multiple
259
+ // completion signals (our message listener, partner.js's bridge, the status
260
+ // poll) only run it once.
261
+ const completeOnboarding = useCallback(
262
+ async (authCode: string, sharedId: string) => {
263
+ if (!authCode || !sharedId) return
264
+ if (completedRef.current) return
265
+ completedRef.current = true
266
+
267
+ stopPoll()
268
+ try {
269
+ window.onbeforeunload = null
270
+ } catch {}
271
+
272
+ const activeEnv: Env = envRef.current === "sandbox" ? "sandbox" : "live"
273
+
274
+ setOnboardingInProgress(true)
275
+ setConnState("loading")
276
+ setError(null)
277
+ setPopupBlocked(false)
278
+
279
+ try {
280
+ const res = await fetch(ONBOARDING_COMPLETE_ENDPOINT, {
281
+ method: "POST",
282
+ headers: { "content-type": "application/json" },
283
+ credentials: "include",
284
+ body: JSON.stringify({ authCode, sharedId, env: activeEnv }),
285
+ })
286
+
287
+ if (!res.ok) {
288
+ const txt = await res.text().catch(() => "")
289
+ throw new Error(txt || `Onboarding exchange failed (${res.status})`)
290
+ }
291
+
292
+ try {
293
+ popupRef.current?.close()
294
+ } catch {}
295
+ try {
296
+ const close1 = window.PAYPAL?.apps?.Signup?.MiniBrowser?.closeFlow
297
+ if (typeof close1 === "function") close1()
298
+ } catch {}
299
+ try {
300
+ const close2 =
301
+ window.PAYPAL?.apps?.Signup?.miniBrowser &&
302
+ (window.PAYPAL.apps.Signup.miniBrowser as any).closeFlow
303
+ if (typeof close2 === "function") close2()
304
+ } catch {}
305
+
306
+ clearCachedUrl(activeEnv)
307
+
308
+ try {
309
+ const statusRes = await fetch(`${STATUS_ENDPOINT}?environment=${activeEnv}`, {
310
+ method: "GET",
311
+ credentials: "include",
312
+ })
313
+ const refreshedStatus = await statusRes.json().catch(() => ({}))
314
+ setStatusInfo(refreshedStatus || null)
315
+ } catch {}
316
+
317
+ setConnState("connected")
318
+ setShowManual(false)
319
+ } catch (e: any) {
320
+ completedRef.current = false
321
+ console.error(e)
322
+ setConnState("error")
323
+ setError(e?.message || "Exchange failed while saving credentials.")
324
+ } finally {
325
+ setOnboardingInProgress(false)
326
+ }
327
+ },
328
+ [stopPoll]
329
+ )
330
+
331
+ // After the popup is opened, poll the server. If the credentials get saved by
332
+ // any completion path, the status flips to "connected" and we sync the UI even
333
+ // if a message was missed.
334
+ const startStatusPoll = useCallback(() => {
335
+ stopPoll()
336
+ let ticks = 0
337
+ pollRef.current = setInterval(async () => {
338
+ ticks += 1
339
+ if (completedRef.current || ticks > 150 /* ~5 min */) {
340
+ stopPoll()
341
+ return
342
+ }
343
+ try {
344
+ const r = await fetch(`${STATUS_ENDPOINT}?environment=${envRef.current}`, {
345
+ method: "GET",
346
+ credentials: "include",
347
+ })
348
+ const st = await r.json().catch(() => ({}))
349
+ if (st?.status === "connected" && st?.seller_client_id_present === true) {
350
+ completedRef.current = true
351
+ stopPoll()
352
+ try {
353
+ popupRef.current?.close()
354
+ } catch {}
355
+ clearCachedUrl(envRef.current)
356
+ setStatusInfo(st)
357
+ setConnState("connected")
358
+ setShowManual(false)
359
+ setOnboardingInProgress(false)
360
+ }
361
+ } catch {
362
+ /* keep polling */
363
+ }
364
+ }, 2000)
365
+ }, [stopPoll])
366
+
164
367
  const generateLinkAndReload = useCallback(
165
368
  async (targetEnv: Env, runId: number) => {
166
369
  if (initLoaderRef.current) {
167
370
  const loaderText = initLoaderRef.current.querySelector("#loader-text")
168
- if (loaderText) {
169
- loaderText.textContent = "Generating onboarding session..."
170
- }
371
+ if (loaderText) loaderText.textContent = "Generating onboarding session..."
171
372
  }
172
373
 
173
374
  try {
@@ -193,16 +394,11 @@ export default function PayPalConnectionPage() {
193
394
  href + (href.includes("?") ? "&" : "?") + "displayMode=minibrowser"
194
395
 
195
396
  const persisted = writeCachedUrl(targetEnv, url)
196
-
197
397
  if (persisted) {
198
- // The cache survived; reload so the link is present at first render and
199
- // partner.js initializes against it. The post-reload load finds the
200
- // cache (no regeneration), so this can't loop.
201
398
  window.location.reload()
202
399
  return
203
400
  }
204
401
 
205
- // Storage disabled — use the link directly without reloading.
206
402
  setFinalUrl(url)
207
403
  setConnState("ready")
208
404
  } catch {
@@ -231,11 +427,7 @@ export default function PayPalConnectionPage() {
231
427
  }
232
428
  }, [])
233
429
 
234
- // Page-load flow:
235
- // 1. If already connected, show the connected state.
236
- // 2. Else if a valid cached link exists, use it (partner.js will bind it).
237
- // 3. Else generate a link, cache it for 6h, and reload so the cached link is
238
- // present for partner.js on the next load.
430
+ // Page-load flow: connected? -> cached link? -> else generate + cache + reload.
239
431
  useEffect(() => {
240
432
  if (!envReady) return
241
433
 
@@ -248,6 +440,7 @@ export default function PayPalConnectionPage() {
248
440
  const run = async () => {
249
441
  setConnState("loading")
250
442
  setError(null)
443
+ setPopupBlocked(false)
251
444
  setFinalUrl("")
252
445
 
253
446
  try {
@@ -292,12 +485,9 @@ export default function PayPalConnectionPage() {
292
485
  }
293
486
  }, [env, envReady, generateLinkAndReload])
294
487
 
295
- // Load partner.js once the anchor exists with its real (cached) href, then run
296
- // PayPal's initializer. In a React SPA partner.js's own DOMContentLoaded hook
297
- // already fired before this route mounted, so we must call init()/render()
298
- // ourselves for it to discover the button and take over the click into the
299
- // mini-browser — opening the popup, and wiring its postMessage bridge that
300
- // invokes window.onboardingCallback and closes the popup on completion.
488
+ // Load partner.js + init for its postMessage->onboardingCallback bridge (a
489
+ // secondary completion path next to our own message listener). We open the
490
+ // popup ourselves, so partner.js is NOT relied on to open the window.
301
491
  useEffect(() => {
302
492
  if (connState !== "ready" || !finalUrl) return
303
493
 
@@ -305,19 +495,6 @@ export default function PayPalConnectionPage() {
305
495
  let cancelled = false
306
496
  let pollTimer: ReturnType<typeof setTimeout> | undefined
307
497
 
308
- // The button is gated on partnerReady; reset it for this (re)load.
309
- setPartnerReady(false)
310
-
311
- // Safety net: if partner.js can't load (e.g. blocked), enable the button
312
- // anyway after a few seconds rather than leaving it permanently disabled.
313
- const readyFallback = setTimeout(() => {
314
- if (!cancelled) setPartnerReady(true)
315
- }, 6000)
316
-
317
- const markReady = () => {
318
- if (!cancelled) setPartnerReady(true)
319
- }
320
-
321
498
  const initPartner = (attempt = 0) => {
322
499
  if (cancelled) return
323
500
 
@@ -335,35 +512,25 @@ export default function PayPalConnectionPage() {
335
512
  } catch (e) {
336
513
  console.error("[paypal] partner.js init failed:", e)
337
514
  }
338
- markReady()
339
515
  return
340
516
  }
341
-
342
517
  if (typeof signup?.render === "function") {
343
518
  try {
344
519
  signup.render()
345
520
  } catch (e) {
346
521
  console.error("[paypal] partner.js render failed:", e)
347
522
  }
348
- markReady()
349
523
  return
350
524
  }
351
-
352
- // The namespace may not be populated the instant onload fires; retry
353
- // briefly before giving up.
354
525
  if (attempt < 40) {
355
526
  pollTimer = setTimeout(() => initPartner(attempt + 1), 50)
356
527
  return
357
528
  }
358
-
359
529
  try {
360
530
  document.dispatchEvent(new Event("DOMContentLoaded"))
361
531
  } catch {}
362
- markReady()
363
532
  }
364
533
 
365
- // Remove any stale copy + namespace so the fresh load re-registers against
366
- // the current environment (sandbox vs live partner.js differ).
367
534
  const existingScript = document.getElementById("paypal-partner-js")
368
535
  if (existingScript) {
369
536
  existingScript.parentNode?.removeChild(existingScript)
@@ -379,88 +546,53 @@ export default function PayPalConnectionPage() {
379
546
  ppScript.src = scriptUrl
380
547
  ppScript.async = true
381
548
  ppScript.onload = () => initPartner()
382
- ppScript.onerror = () => markReady()
383
549
  document.body.appendChild(ppScript)
384
550
 
385
551
  return () => {
386
552
  cancelled = true
387
- clearTimeout(readyFallback)
388
- if (pollTimer) {
389
- clearTimeout(pollTimer)
390
- }
391
- if (ppScript.parentNode) {
392
- ppScript.parentNode.removeChild(ppScript)
393
- }
553
+ if (pollTimer) clearTimeout(pollTimer)
554
+ if (ppScript.parentNode) ppScript.parentNode.removeChild(ppScript)
394
555
  }
395
556
  }, [connState, finalUrl, env])
396
557
 
397
- // partner.js calls this (by the name in data-paypal-onboard-complete) when the
398
- // seller finishes onboarding, passing the authCode + sharedId to exchange for
399
- // seller credentials.
400
- const completeOnboarding = useCallback(async (authCode: string, sharedId: string) => {
401
- if (!authCode || !sharedId) return
402
- if (completedRef.current) return
403
- completedRef.current = true
404
-
405
- try {
406
- window.onbeforeunload = null
407
- } catch {}
408
-
409
- const activeEnv: Env = envRef.current === "sandbox" ? "sandbox" : "live"
410
-
411
- setOnboardingInProgress(true)
412
- setConnState("loading")
413
- setError(null)
414
-
415
- try {
416
- const res = await fetch(ONBOARDING_COMPLETE_ENDPOINT, {
417
- method: "POST",
418
- headers: { "content-type": "application/json" },
419
- credentials: "include",
420
- body: JSON.stringify({ authCode, sharedId, env: activeEnv }),
421
- })
422
-
423
- if (!res.ok) {
424
- const txt = await res.text().catch(() => "")
425
- throw new Error(txt || `Onboarding exchange failed (${res.status})`)
426
- }
427
-
428
- // Close the partner.js mini-browser.
429
- try {
430
- const close1 = window.PAYPAL?.apps?.Signup?.MiniBrowser?.closeFlow
431
- if (typeof close1 === "function") close1()
432
- } catch {}
558
+ // PRIMARY completion path: receive PayPal's onboarding postMessage ourselves.
559
+ // The diagnostic log lets us confirm the exact origin/shape PayPal sends in a
560
+ // real run if a tweak to the parser is ever needed.
561
+ useEffect(() => {
562
+ const onMessage = (ev: MessageEvent) => {
563
+ let serialized = ""
433
564
  try {
434
- const close2 =
435
- window.PAYPAL?.apps?.Signup?.miniBrowser &&
436
- (window.PAYPAL.apps.Signup.miniBrowser as any).closeFlow
437
- if (typeof close2 === "function") close2()
438
- } catch {}
439
-
440
- // The link has been consumed; drop it so a future flow regenerates one.
441
- clearCachedUrl(activeEnv)
565
+ serialized =
566
+ typeof ev.data === "string" ? ev.data : JSON.stringify(ev.data)
567
+ } catch {
568
+ serialized = String(ev.data)
569
+ }
442
570
 
443
- try {
444
- const statusRes = await fetch(`${STATUS_ENDPOINT}?environment=${activeEnv}`, {
445
- method: "GET",
446
- credentials: "include",
447
- })
448
- const refreshedStatus = await statusRes.json().catch(() => ({}))
449
- setStatusInfo(refreshedStatus || null)
450
- } catch {}
571
+ const looksRelevant =
572
+ isPayPalOrigin(ev.origin) ||
573
+ /auth_?code|shared_?id|onboard|merchantId/i.test(serialized || "")
574
+
575
+ if (looksRelevant) {
576
+ // eslint-disable-next-line no-console
577
+ console.info(
578
+ "[PayPal onboarding] message from",
579
+ ev.origin,
580
+ "·",
581
+ (serialized || "").slice(0, 500)
582
+ )
583
+ }
451
584
 
452
- setConnState("connected")
453
- setShowManual(false)
454
- } catch (e: any) {
455
- completedRef.current = false
456
- console.error(e)
457
- setConnState("error")
458
- setError(e?.message || "Exchange failed while saving credentials.")
459
- } finally {
460
- setOnboardingInProgress(false)
585
+ if (!isPayPalOrigin(ev.origin)) return
586
+ const { authCode, sharedId } = extractAuth(ev.data)
587
+ if (authCode && sharedId) {
588
+ completeOnboarding(authCode, sharedId)
589
+ }
461
590
  }
462
- }, [])
591
+ window.addEventListener("message", onMessage)
592
+ return () => window.removeEventListener("message", onMessage)
593
+ }, [completeOnboarding])
463
594
 
595
+ // SECONDARY completion path: partner.js's bridge calls this by name.
464
596
  useLayoutEffect(() => {
465
597
  window.onboardingCallback = (authCode: string, sharedId: string) => {
466
598
  completeOnboarding(authCode, sharedId)
@@ -470,6 +602,45 @@ export default function PayPalConnectionPage() {
470
602
  }
471
603
  }, [completeOnboarding])
472
604
 
605
+ // Capture-phase opener: runs before partner.js, opens the popup synchronously,
606
+ // and stops partner.js's (unreliable, async) open + the native anchor so we get
607
+ // exactly one popup and never a new tab.
608
+ useEffect(() => {
609
+ const onCaptureClick = (e: MouseEvent) => {
610
+ const btn = paypalButtonRef.current
611
+ if (!btn) return
612
+ const target = e.target as Node | null
613
+ if (!target || !btn.contains(target)) return
614
+
615
+ e.preventDefault()
616
+ e.stopImmediatePropagation()
617
+
618
+ if (
619
+ connStateRef.current !== "ready" ||
620
+ !finalUrlRef.current ||
621
+ inProgressRef.current
622
+ ) {
623
+ return
624
+ }
625
+
626
+ completedRef.current = false
627
+ const popup = openOnboardingPopup(finalUrlRef.current)
628
+ if (!popup) {
629
+ setPopupBlocked(true)
630
+ return
631
+ }
632
+ setPopupBlocked(false)
633
+ startStatusPoll()
634
+ }
635
+
636
+ document.addEventListener("click", onCaptureClick, true)
637
+ return () => document.removeEventListener("click", onCaptureClick, true)
638
+ }, [openOnboardingPopup, startStatusPoll])
639
+
640
+ useEffect(() => {
641
+ return () => stopPoll()
642
+ }, [stopPoll])
643
+
473
644
  useLayoutEffect(() => {
474
645
  const el = ppBtnMeasureRef.current
475
646
  if (!el) return
@@ -495,6 +666,12 @@ export default function PayPalConnectionPage() {
495
666
  }
496
667
  }, [connState, env, finalUrl])
497
668
 
669
+ // Defensive: the capture listener handles the click; this just guarantees the
670
+ // native href can never navigate.
671
+ const handleConnectClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
672
+ e.preventDefault()
673
+ }
674
+
498
675
  const handleSaveManual = async () => {
499
676
  if (!canSaveManual || onboardingInProgress) return
500
677
  setOnboardingInProgress(true)
@@ -565,8 +742,6 @@ export default function PayPalConnectionPage() {
565
742
  clearCachedUrl(env)
566
743
  completedRef.current = false
567
744
 
568
- // Generate a fresh link for the now-disconnected environment and reload so
569
- // the onboarding button is ready again.
570
745
  currentRunId.current = ++runIdRef.current
571
746
  await generateLinkAndReload(env, currentRunId.current)
572
747
  } catch (e: any) {
@@ -584,10 +759,9 @@ export default function PayPalConnectionPage() {
584
759
 
585
760
  completedRef.current = false
586
761
  clearCachedUrl()
762
+ setPopupBlocked(false)
587
763
  setConnState("loading")
588
764
 
589
- // Persist the environment BEFORE switching local state so the post-reload
590
- // GET /environment returns the same value the cache was keyed under.
591
765
  try {
592
766
  await fetch(ENVIRONMENT_ENDPOINT, {
593
767
  method: "POST",
@@ -676,12 +850,9 @@ export default function PayPalConnectionPage() {
676
850
  </div>
677
851
 
678
852
  <div className={`${connState === "ready" ? "block" : "hidden"}`}>
679
- {/* Standard PayPal partner onboarding button. partner.js binds
680
- this anchor (it carries the cached onboarding href) and
681
- takes over the click into the mini-browser; on completion
682
- it calls window.onboardingCallback. */}
683
853
  <a
684
854
  ref={(node) => {
855
+ paypalButtonRef.current = node
685
856
  ppBtnMeasureRef.current = node
686
857
  }}
687
858
  id="paypal-button"
@@ -689,26 +860,24 @@ export default function PayPalConnectionPage() {
689
860
  data-paypal-button="true"
690
861
  href={finalUrl || "#"}
691
862
  data-paypal-onboard-complete="onboardingCallback"
692
- aria-disabled={onboardingInProgress || !partnerReady}
863
+ onClick={handleConnectClick}
693
864
  className="transition-fg relative inline-flex w-fit items-center justify-center overflow-hidden rounded-md outline-none no-underline shadow-buttons-neutral text-ui-fg-base bg-ui-button-neutral after:transition-fg after:absolute after:inset-0 after:content-[''] after:button-neutral-gradient hover:bg-ui-button-neutral-hover hover:after:button-neutral-hover-gradient active:bg-ui-button-neutral-pressed active:after:button-neutral-pressed-gradient focus-visible:shadow-buttons-neutral-focus disabled:bg-ui-bg-disabled disabled:border-ui-border-base disabled:text-ui-fg-disabled disabled:shadow-buttons-neutral disabled:after:hidden txt-compact-small-plus px-3 py-1.5"
694
865
  style={{
695
- cursor:
696
- onboardingInProgress || !partnerReady
697
- ? "not-allowed"
698
- : "pointer",
699
- opacity: onboardingInProgress || !partnerReady ? 0.6 : 1,
700
- // Keep the anchor mounted (partner.js must find it to bind)
701
- // but un-clickable until partner.js has taken over the
702
- // click — so it can never open a tab via the native href.
703
- pointerEvents:
704
- onboardingInProgress || !partnerReady ? "none" : "auto",
866
+ cursor: onboardingInProgress ? "not-allowed" : "pointer",
867
+ opacity: onboardingInProgress ? 0.6 : 1,
868
+ pointerEvents: onboardingInProgress ? "none" : "auto",
705
869
  }}
706
870
  >
707
- {partnerReady || onboardingInProgress
708
- ? "Connect to PayPal"
709
- : "Preparing PayPal…"}
871
+ Connect to PayPal
710
872
  </a>
711
873
 
874
+ {popupBlocked && (
875
+ <div className="mt-3 text-left text-xs bg-amber-50 text-amber-700 p-3 border border-amber-200 rounded">
876
+ Your browser blocked the PayPal popup. Please allow popups
877
+ for this site, then click “Connect to PayPal” again.
878
+ </div>
879
+ )}
880
+
712
881
  <div
713
882
  className="mt-2"
714
883
  style={{