@easypayment/medusa-payment-paypal 0.9.25 → 0.9.26

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.
@@ -13,11 +13,16 @@ export const config = defineRouteConfig({
13
13
  label: "PayPal Connection",
14
14
  })
15
15
 
16
-
17
- const PARTNER_JS_URLS = {
18
- sandbox: "https://www.sandbox.paypal.com/webapps/merchantboarding/js/lib/lightbox/partner.js",
19
- live: "https://www.paypal.com/webapps/merchantboarding/js/lib/lightbox/partner.js",
20
- } as const
16
+ // Let partner.js own the entire onboarding flow: it opens PayPal's mini-browser
17
+ // itself (synchronously inside the click, so no new tab / no popup block), it
18
+ // receives PayPal's completion postMessage, and it invokes the callback named in
19
+ // `data-paypal-onboard-complete` with (authCode, sharedId). This is PayPal's
20
+ // documented integration and matches the official @easypayment WooCommerce
21
+ // plugin. Opening the popup ourselves (a previous attempt at fixing the "opens a
22
+ // new tab" issue) stopped partner.js from receiving completion, which left the
23
+ // popup stranded on PayPal's "isuDone" page and never saved credentials.
24
+ const PARTNER_JS_URL =
25
+ "https://www.paypal.com/webapps/merchantboarding/js/lib/lightbox/partner.js"
21
26
 
22
27
  declare global {
23
28
  interface Window {
@@ -26,7 +31,6 @@ declare global {
26
31
  Signup?: {
27
32
  miniBrowser?: { init?: () => void; closeFlow?: () => void }
28
33
  MiniBrowser?: { init?: () => void; closeFlow?: () => void }
29
- render?: () => void
30
34
  }
31
35
  }
32
36
  }
@@ -34,13 +38,7 @@ declare global {
34
38
  }
35
39
  }
36
40
 
37
-
38
41
  const SERVICE_URL = "/admin/paypal/onboarding-link"
39
- // PayPal's conventional mini-browser window name (also the anchor target).
40
- const POPUP_NAME = "PPFrame"
41
-
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.
44
42
  const CACHE_PREFIX = "pp_onboard_cache"
45
43
  const CACHE_EXPIRY = 6 * 60 * 60 * 1000 // 6 hours
46
44
 
@@ -73,12 +71,11 @@ function readCachedUrl(env: Env): string | null {
73
71
  return null
74
72
  }
75
73
 
76
- function writeCachedUrl(env: Env, url: string): boolean {
74
+ function writeCachedUrl(env: Env, url: string) {
77
75
  try {
78
76
  localStorage.setItem(cacheKeyFor(env), JSON.stringify({ url, ts: Date.now() }))
79
- return readCachedUrl(env) === url
80
77
  } catch {
81
- return false
78
+ /* ignore */
82
79
  }
83
80
  }
84
81
 
@@ -95,73 +92,6 @@ function clearCachedUrl(env?: Env) {
95
92
  }
96
93
  }
97
94
 
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
-
164
-
165
95
  export default function PayPalConnectionPage() {
166
96
  const [env, setEnv] = useState<Env>("live")
167
97
  const [envReady, setEnvReady] = useState(false)
@@ -170,7 +100,6 @@ export default function PayPalConnectionPage() {
170
100
  "loading" | "ready" | "connected" | "error"
171
101
  >("loading")
172
102
  const [error, setError] = useState<string | null>(null)
173
- const [popupBlocked, setPopupBlocked] = useState(false)
174
103
  const [finalUrl, setFinalUrl] = useState<string>("")
175
104
  const [showManual, setShowManual] = useState(false)
176
105
  const [clientId, setClientId] = useState("")
@@ -188,19 +117,15 @@ export default function PayPalConnectionPage() {
188
117
  const errorLogRef = useRef<HTMLDivElement>(null)
189
118
  const runIdRef = useRef(0)
190
119
  const currentRunId = useRef(0)
120
+ const completedRef = useRef(false)
191
121
 
122
+ // Long-lived listeners/timers read the current env through a ref so they never
123
+ // capture a stale value from the render that registered them.
192
124
  const envRef = useRef<Env>(env)
193
125
  envRef.current = env
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)
203
- const completedRef = useRef(false)
126
+
127
+ const pollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
128
+ const pollAttemptsRef = useRef(0)
204
129
 
205
130
  const ppBtnMeasureRef = useRef<HTMLAnchorElement | null>(null)
206
131
  const [ppBtnWidth, setPpBtnWidth] = useState<number | null>(null)
@@ -214,57 +139,89 @@ export default function PayPalConnectionPage() {
214
139
  setError(msg)
215
140
  }, [])
216
141
 
217
- const stopPoll = useCallback(() => {
218
- if (pollRef.current) {
219
- clearInterval(pollRef.current)
220
- pollRef.current = null
221
- }
142
+ // Close PayPal's mini-browser via partner.js's API (works under both the
143
+ // `miniBrowser` and `MiniBrowser` casings PayPal has shipped over time).
144
+ const closeMiniBrowser = useCallback(() => {
145
+ try {
146
+ const close1 = window.PAYPAL?.apps?.Signup?.MiniBrowser?.closeFlow
147
+ if (typeof close1 === "function") close1()
148
+ } catch {}
149
+ try {
150
+ const close2 = window.PAYPAL?.apps?.Signup?.miniBrowser?.closeFlow
151
+ if (typeof close2 === "function") close2()
152
+ } catch {}
222
153
  }, [])
223
154
 
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
155
+ const stopStatusPolling = useCallback(() => {
156
+ if (pollTimerRef.current) {
157
+ clearTimeout(pollTimerRef.current)
158
+ pollTimerRef.current = null
246
159
  }
247
- if (popup) {
248
- popupRef.current = popup
160
+ pollAttemptsRef.current = 0
161
+ }, [])
162
+
163
+ // Fetch status for an env and, if the seller credentials are present, flip the
164
+ // UI to "connected" and close the PayPal popup. Returns true once connected.
165
+ const refreshStatusAndMaybeConnect = useCallback(
166
+ async (activeEnv: Env): Promise<boolean> => {
249
167
  try {
250
- popup.focus()
168
+ const res = await fetch(`${STATUS_ENDPOINT}?environment=${activeEnv}`, {
169
+ method: "GET",
170
+ credentials: "include",
171
+ })
172
+ const st = await res.json().catch(() => ({}))
173
+ const connected =
174
+ st?.status === "connected" && st?.seller_client_id_present === true
175
+ if (connected) {
176
+ completedRef.current = true
177
+ setStatusInfo(st)
178
+ setConnState("connected")
179
+ setShowManual(false)
180
+ setOnboardingInProgress(false)
181
+ clearCachedUrl(activeEnv)
182
+ closeMiniBrowser()
183
+ stopStatusPolling()
184
+ }
185
+ return connected
251
186
  } catch {
252
- /* ignore */
187
+ return false
188
+ }
189
+ },
190
+ [closeMiniBrowser, stopStatusPolling]
191
+ )
192
+
193
+ // While a connect attempt is in flight, the seller may complete onboarding via
194
+ // a path that never reaches the opener (e.g. partner.js fails to fire its
195
+ // callback, or the popup's completion message is blocked). The return_url
196
+ // bridge still saves credentials server-side, so poll status as the reliable
197
+ // fallback: the moment credentials land, this flips to "connected" and closes
198
+ // the stranded popup.
199
+ const startStatusPolling = useCallback(() => {
200
+ stopStatusPolling()
201
+ const MAX_ATTEMPTS = 100 // ~5 min at 3s intervals
202
+ const tick = async () => {
203
+ pollAttemptsRef.current += 1
204
+ const connected = await refreshStatusAndMaybeConnect(envRef.current)
205
+ if (connected || completedRef.current) return
206
+ if (pollAttemptsRef.current >= MAX_ATTEMPTS) {
207
+ stopStatusPolling()
208
+ return
253
209
  }
210
+ pollTimerRef.current = setTimeout(tick, 3000)
254
211
  }
255
- return popup
256
- }, [])
212
+ pollTimerRef.current = setTimeout(tick, 3000)
213
+ }, [refreshStatusAndMaybeConnect, stopStatusPolling])
257
214
 
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.
215
+ // Shared completion: exchange authCode/sharedId for seller credentials, then
216
+ // close the popup and refresh status. Invoked by partner.js's onboardingCallback
217
+ // and by the return_url bridge's postMessage. Guarded + server-idempotent so
218
+ // redundant invocations are harmless.
261
219
  const completeOnboarding = useCallback(
262
220
  async (authCode: string, sharedId: string) => {
263
221
  if (!authCode || !sharedId) return
264
222
  if (completedRef.current) return
265
223
  completedRef.current = true
266
224
 
267
- stopPoll()
268
225
  try {
269
226
  window.onbeforeunload = null
270
227
  } catch {}
@@ -274,7 +231,6 @@ export default function PayPalConnectionPage() {
274
231
  setOnboardingInProgress(true)
275
232
  setConnState("loading")
276
233
  setError(null)
277
- setPopupBlocked(false)
278
234
 
279
235
  try {
280
236
  const res = await fetch(ONBOARDING_COMPLETE_ENDPOINT, {
@@ -289,82 +245,114 @@ export default function PayPalConnectionPage() {
289
245
  throw new Error(txt || `Onboarding exchange failed (${res.status})`)
290
246
  }
291
247
 
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
-
248
+ closeMiniBrowser()
306
249
  clearCachedUrl(activeEnv)
307
250
 
308
251
  try {
309
- const statusRes = await fetch(`${STATUS_ENDPOINT}?environment=${activeEnv}`, {
310
- method: "GET",
311
- credentials: "include",
312
- })
252
+ const statusRes = await fetch(
253
+ `${STATUS_ENDPOINT}?environment=${activeEnv}`,
254
+ { method: "GET", credentials: "include" }
255
+ )
313
256
  const refreshedStatus = await statusRes.json().catch(() => ({}))
314
257
  setStatusInfo(refreshedStatus || null)
315
258
  } catch {}
316
259
 
317
260
  setConnState("connected")
318
261
  setShowManual(false)
262
+ stopStatusPolling()
319
263
  } catch (e: any) {
320
264
  completedRef.current = false
321
265
  console.error(e)
266
+ // The bridge may have already saved credentials server-side; if so the
267
+ // poll will surface "connected". Don't strand the user on an error.
322
268
  setConnState("error")
323
269
  setError(e?.message || "Exchange failed while saving credentials.")
324
270
  } finally {
325
271
  setOnboardingInProgress(false)
326
272
  }
327
273
  },
328
- [stopPoll]
274
+ [closeMiniBrowser, stopStatusPolling]
329
275
  )
330
276
 
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
- }
277
+ // Bind partner.js to the connect button once it has a real onboarding URL.
278
+ const initPartner = useCallback(() => {
279
+ const signup = window.PAYPAL?.apps?.Signup
280
+ const init =
281
+ (signup?.miniBrowser && signup.miniBrowser.init) ||
282
+ (signup?.MiniBrowser && signup.MiniBrowser.init)
283
+ if (typeof init === "function") {
343
284
  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)
285
+ init()
286
+ } catch (e) {
287
+ console.error("[paypal] partner.js init failed:", e)
288
+ }
289
+ }
290
+ }, [])
291
+
292
+ // Load partner.js once for the lifetime of the page.
293
+ useEffect(() => {
294
+ const existingScript = document.getElementById("paypal-partner-js")
295
+ if (existingScript) return
296
+
297
+ const preloadHref = PARTNER_JS_URL
298
+ let preloadLink: HTMLLinkElement | null = null
299
+ if (!document.head.querySelector(`link[rel="preload"][href="${preloadHref}"]`)) {
300
+ preloadLink = document.createElement("link")
301
+ preloadLink.rel = "preload"
302
+ preloadLink.href = preloadHref
303
+ preloadLink.as = "script"
304
+ document.head.appendChild(preloadLink)
305
+ }
306
+
307
+ const ppScript = document.createElement("script")
308
+ ppScript.id = "paypal-partner-js"
309
+ ppScript.src = preloadHref
310
+ ppScript.async = true
311
+ document.head.appendChild(ppScript)
312
+
313
+ return () => {
314
+ if (preloadLink?.parentNode) preloadLink.parentNode.removeChild(preloadLink)
315
+ if (ppScript.parentNode) ppScript.parentNode.removeChild(ppScript)
316
+ }
317
+ }, [])
318
+
319
+ // Put the (cached or freshly generated) onboarding URL on the button, then wait
320
+ // for partner.js to be present before showing the button + initializing it. The
321
+ // button is only revealed once partner.js is ready, which is what guarantees
322
+ // the first click opens the mini-browser popup (never a new tab).
323
+ const activatePayPal = useCallback(
324
+ (url: string, runId: number) => {
325
+ if (paypalButtonRef.current) {
326
+ paypalButtonRef.current.href = url
327
+ }
328
+ setFinalUrl(url)
329
+
330
+ let attempts = 0
331
+ const MAX_ATTEMPTS = 200 // ~10s
332
+
333
+ const tryInit = () => {
334
+ if (runId !== currentRunId.current) return
335
+ if (window.PAYPAL?.apps?.Signup) {
336
+ initPartner()
337
+ setConnState("ready")
338
+ return
360
339
  }
361
- } catch {
362
- /* keep polling */
340
+ attempts++
341
+ if (attempts >= MAX_ATTEMPTS) {
342
+ showError(
343
+ "PayPal partner script failed to load. Please refresh and try again."
344
+ )
345
+ return
346
+ }
347
+ setTimeout(tryInit, 50)
363
348
  }
364
- }, 2000)
365
- }, [stopPoll])
366
349
 
367
- const generateLinkAndReload = useCallback(
350
+ tryInit()
351
+ },
352
+ [initPartner, showError]
353
+ )
354
+
355
+ const fetchFreshLink = useCallback(
368
356
  async (targetEnv: Env, runId: number) => {
369
357
  if (initLoaderRef.current) {
370
358
  const loaderText = initLoaderRef.current.querySelector("#loader-text")
@@ -393,20 +381,14 @@ export default function PayPalConnectionPage() {
393
381
  const url =
394
382
  href + (href.includes("?") ? "&" : "?") + "displayMode=minibrowser"
395
383
 
396
- const persisted = writeCachedUrl(targetEnv, url)
397
- if (persisted) {
398
- window.location.reload()
399
- return
400
- }
401
-
402
- setFinalUrl(url)
403
- setConnState("ready")
384
+ writeCachedUrl(targetEnv, url)
385
+ activatePayPal(url, runId)
404
386
  } catch {
405
387
  if (runId !== currentRunId.current) return
406
388
  showError("Unable to connect to service.")
407
389
  }
408
390
  },
409
- [showError]
391
+ [activatePayPal, showError]
410
392
  )
411
393
 
412
394
  // Resolve the server environment first.
@@ -427,7 +409,7 @@ export default function PayPalConnectionPage() {
427
409
  }
428
410
  }, [])
429
411
 
430
- // Page-load flow: connected? -> cached link? -> else generate + cache + reload.
412
+ // Page-load flow: connected? -> cached link? -> else generate a fresh link.
431
413
  useEffect(() => {
432
414
  if (!envReady) return
433
415
 
@@ -440,7 +422,6 @@ export default function PayPalConnectionPage() {
440
422
  const run = async () => {
441
423
  setConnState("loading")
442
424
  setError(null)
443
- setPopupBlocked(false)
444
425
  setFinalUrl("")
445
426
 
446
427
  try {
@@ -470,12 +451,11 @@ export default function PayPalConnectionPage() {
470
451
 
471
452
  const cached = readCachedUrl(env)
472
453
  if (cached) {
473
- setFinalUrl(cached)
474
- setConnState("ready")
454
+ activatePayPal(cached, runId)
475
455
  return
476
456
  }
477
457
 
478
- await generateLinkAndReload(env, runId)
458
+ await fetchFreshLink(env, runId)
479
459
  }
480
460
 
481
461
  run()
@@ -483,198 +463,51 @@ export default function PayPalConnectionPage() {
483
463
  return () => {
484
464
  cancelled = true
485
465
  }
486
- }, [env, envReady, generateLinkAndReload])
487
-
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.
491
- useEffect(() => {
492
- if (connState !== "ready" || !finalUrl) return
493
-
494
- const scriptUrl = PARTNER_JS_URLS[env]
495
- let cancelled = false
496
- let pollTimer: ReturnType<typeof setTimeout> | undefined
497
-
498
- const initPartner = (attempt = 0) => {
499
- if (cancelled) return
500
-
501
- const signup = window.PAYPAL?.apps?.Signup
502
- const target =
503
- signup?.miniBrowser && typeof signup.miniBrowser.init === "function"
504
- ? signup.miniBrowser
505
- : signup?.MiniBrowser && typeof signup.MiniBrowser.init === "function"
506
- ? signup.MiniBrowser
507
- : null
508
-
509
- if (target?.init) {
510
- try {
511
- target.init()
512
- } catch (e) {
513
- console.error("[paypal] partner.js init failed:", e)
514
- }
515
- return
516
- }
517
- if (typeof signup?.render === "function") {
518
- try {
519
- signup.render()
520
- } catch (e) {
521
- console.error("[paypal] partner.js render failed:", e)
522
- }
523
- return
524
- }
525
- if (attempt < 40) {
526
- pollTimer = setTimeout(() => initPartner(attempt + 1), 50)
527
- return
528
- }
529
- try {
530
- document.dispatchEvent(new Event("DOMContentLoaded"))
531
- } catch {}
532
- }
533
-
534
- const existingScript = document.getElementById("paypal-partner-js")
535
- if (existingScript) {
536
- existingScript.parentNode?.removeChild(existingScript)
537
- }
538
- if (window.PAYPAL?.apps?.Signup) {
539
- try {
540
- delete (window.PAYPAL.apps as any).Signup
541
- } catch {}
542
- }
543
-
544
- const ppScript = document.createElement("script")
545
- ppScript.id = "paypal-partner-js"
546
- ppScript.src = scriptUrl
547
- ppScript.async = true
548
- ppScript.onload = () => initPartner()
549
- document.body.appendChild(ppScript)
550
-
551
- return () => {
552
- cancelled = true
553
- if (pollTimer) clearTimeout(pollTimer)
554
- if (ppScript.parentNode) ppScript.parentNode.removeChild(ppScript)
555
- }
556
- }, [connState, finalUrl, env])
557
-
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 = ""
564
- try {
565
- serialized =
566
- typeof ev.data === "string" ? ev.data : JSON.stringify(ev.data)
567
- } catch {
568
- serialized = String(ev.data)
569
- }
466
+ }, [env, envReady, fetchFreshLink, activatePayPal])
570
467
 
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
- }
584
-
585
- // TERTIARY completion path: our own return_url bridge page (served by
586
- // GET /store/paypal/onboard-return) posts this from inside the popup when
587
- // PayPal redirects there at the end of onboarding. It runs on our backend
588
- // origin (not paypal.com), so it is handled before the PayPal-origin gate
589
- // below. It guarantees the popup is closed and the status is re-checked even
590
- // if every PayPal postMessage was missed.
591
- const d = ev.data as any
592
- if (d && typeof d === "object" && d.source === "paypal-onboarding-return") {
593
- try {
594
- popupRef.current?.close()
595
- } catch {}
596
- const fromReturn = extractAuth(d.params)
597
- if (fromReturn.authCode && fromReturn.sharedId) {
598
- completeOnboarding(fromReturn.authCode, fromReturn.sharedId)
599
- } else if (!completedRef.current) {
600
- // No authCode/sharedId on the return URL (the usual case): credentials
601
- // are exchanged via the postMessage/onboardingCallback path. Poll the
602
- // server so the UI flips to "connected" as soon as that lands.
603
- startStatusPoll()
604
- }
605
- return
606
- }
607
-
608
- if (!isPayPalOrigin(ev.origin)) return
609
- const { authCode, sharedId } = extractAuth(ev.data)
610
- if (authCode && sharedId) {
611
- completeOnboarding(authCode, sharedId)
612
- }
613
- }
614
- window.addEventListener("message", onMessage)
615
- return () => window.removeEventListener("message", onMessage)
616
- }, [completeOnboarding, startStatusPoll])
617
-
618
- // SECONDARY completion path: partner.js's bridge calls this by name.
468
+ // partner.js invokes this by name (data-paypal-onboard-complete) once PayPal
469
+ // returns the seller's authCode + sharedId.
619
470
  useLayoutEffect(() => {
620
471
  window.onboardingCallback = (authCode: string, sharedId: string) => {
621
- completeOnboarding(authCode, sharedId)
472
+ void completeOnboarding(authCode, sharedId)
622
473
  }
474
+
623
475
  return () => {
624
476
  window.onboardingCallback = undefined
625
477
  }
626
478
  }, [completeOnboarding])
627
479
 
628
- // Capture-phase opener: runs before partner.js and opens the popup
629
- // synchronously inside the user gesture so the mini-browser reliably opens as a
630
- // popup (never a blocked window or a new tab). We open it under PayPal's
631
- // conventional window name (POPUP_NAME / "PPFrame"); because partner.js also
632
- // targets that same name, letting its click handler run next makes it *adopt*
633
- // this very window instead of opening a second one. That adoption is what lets
634
- // partner.js receive PayPal's completion postMessage, invoke onboardingCallback
635
- // and auto-close the popup.
636
- //
637
- // IMPORTANT: we only preventDefault() (to stop the native anchor from opening a
638
- // new tab) and deliberately DO NOT stopImmediatePropagation(): stopping it was
639
- // what previously prevented partner.js from tracking the window, which broke
640
- // onboarding completion (the popup never closed and credentials were never
641
- // exchanged).
480
+ // The return_url bridge (/store/paypal/onboard-return), which runs inside the
481
+ // popup after PayPal redirects to it, posts its params back here. The bridge has
482
+ // already exchanged credentials server-side, so we just refresh status to flip
483
+ // to "connected" and close the popup. If for some reason credentials aren't yet
484
+ // saved but PayPal forwarded an authCode/sharedId, complete it from here too.
642
485
  useEffect(() => {
643
- const onCaptureClick = (e: MouseEvent) => {
644
- const btn = paypalButtonRef.current
645
- if (!btn) return
646
- const target = e.target as Node | null
647
- if (!target || !btn.contains(target)) return
648
-
649
- // Stop the native anchor navigation only. Let the event keep propagating so
650
- // partner.js's own click handler still runs and adopts our popup window.
651
- e.preventDefault()
652
-
653
- if (
654
- connStateRef.current !== "ready" ||
655
- !finalUrlRef.current ||
656
- inProgressRef.current
657
- ) {
658
- return
659
- }
660
-
661
- completedRef.current = false
662
- const popup = openOnboardingPopup(finalUrlRef.current)
663
- if (!popup) {
664
- setPopupBlocked(true)
665
- return
666
- }
667
- setPopupBlocked(false)
668
- startStatusPoll()
486
+ const onMessage = (event: MessageEvent) => {
487
+ const data = event?.data as
488
+ | { source?: string; params?: Record<string, string> }
489
+ | undefined
490
+ if (!data || data.source !== "paypal-onboarding-return") return
491
+
492
+ const params = data.params || {}
493
+ const authCode = params.authCode || params.auth_code
494
+ const sharedId = params.sharedId || params.shared_id
495
+ const activeEnv = envRef.current
496
+
497
+ void (async () => {
498
+ const connected = await refreshStatusAndMaybeConnect(activeEnv)
499
+ if (!connected && authCode && sharedId) {
500
+ await completeOnboarding(authCode, sharedId)
501
+ }
502
+ })()
669
503
  }
670
504
 
671
- document.addEventListener("click", onCaptureClick, true)
672
- return () => document.removeEventListener("click", onCaptureClick, true)
673
- }, [openOnboardingPopup, startStatusPoll])
505
+ window.addEventListener("message", onMessage)
506
+ return () => window.removeEventListener("message", onMessage)
507
+ }, [completeOnboarding, refreshStatusAndMaybeConnect])
674
508
 
675
- useEffect(() => {
676
- return () => stopPoll()
677
- }, [stopPoll])
509
+ // Always stop polling when the component unmounts.
510
+ useEffect(() => stopStatusPolling, [stopStatusPolling])
678
511
 
679
512
  useLayoutEffect(() => {
680
513
  const el = ppBtnMeasureRef.current
@@ -701,10 +534,17 @@ export default function PayPalConnectionPage() {
701
534
  }
702
535
  }, [connState, env, finalUrl])
703
536
 
704
- // Defensive: the capture listener handles the click; this just guarantees the
705
- // native href can never navigate.
537
+ // Only block the click while we are not ready; when ready, let the native click
538
+ // through so partner.js can open the mini-browser. Start polling status so we
539
+ // detect completion (and close the popup) even if no completion message reaches
540
+ // this page.
706
541
  const handleConnectClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
707
- e.preventDefault()
542
+ if (connState !== "ready" || !finalUrl || onboardingInProgress) {
543
+ e.preventDefault()
544
+ return
545
+ }
546
+ completedRef.current = false
547
+ startStatusPolling()
708
548
  }
709
549
 
710
550
  const handleSaveManual = async () => {
@@ -754,6 +594,7 @@ export default function PayPalConnectionPage() {
754
594
  if (onboardingInProgress) return
755
595
  if (!window.confirm("Disconnect PayPal for this environment?")) return
756
596
 
597
+ stopStatusPolling()
757
598
  setOnboardingInProgress(true)
758
599
  setConnState("loading")
759
600
  setError(null)
@@ -778,7 +619,7 @@ export default function PayPalConnectionPage() {
778
619
  completedRef.current = false
779
620
 
780
621
  currentRunId.current = ++runIdRef.current
781
- await generateLinkAndReload(env, currentRunId.current)
622
+ await fetchFreshLink(env, currentRunId.current)
782
623
  } catch (e: any) {
783
624
  console.error(e)
784
625
  setConnState("error")
@@ -792,9 +633,9 @@ export default function PayPalConnectionPage() {
792
633
  const next = e.target.value as Env
793
634
  if (next === env || onboardingInProgress) return
794
635
 
636
+ stopStatusPolling()
795
637
  completedRef.current = false
796
638
  clearCachedUrl()
797
- setPopupBlocked(false)
798
639
  setConnState("loading")
799
640
 
800
641
  try {
@@ -844,6 +685,14 @@ export default function PayPalConnectionPage() {
844
685
  <div>
845
686
  <div className="text-sm text-green-600 bg-green-50 p-3 rounded border border-green-200">
846
687
  ✅ Successfully connected to PayPal!
688
+ <a
689
+ data-paypal-button="true"
690
+ data-paypal-onboard-complete="onboardingCallback"
691
+ href="#"
692
+ style={{ display: "none" }}
693
+ >
694
+ PayPal
695
+ </a>
847
696
  </div>
848
697
  <div className="mt-3 rounded-md border border-ui-border-base bg-ui-bg-subtle p-3 text-xs text-ui-fg-subtle">
849
698
  <div className="font-medium text-ui-fg-base">
@@ -891,7 +740,6 @@ export default function PayPalConnectionPage() {
891
740
  ppBtnMeasureRef.current = node
892
741
  }}
893
742
  id="paypal-button"
894
- target={POPUP_NAME}
895
743
  data-paypal-button="true"
896
744
  href={finalUrl || "#"}
897
745
  data-paypal-onboard-complete="onboardingCallback"
@@ -906,13 +754,6 @@ export default function PayPalConnectionPage() {
906
754
  Connect to PayPal
907
755
  </a>
908
756
 
909
- {popupBlocked && (
910
- <div className="mt-3 text-left text-xs bg-amber-50 text-amber-700 p-3 border border-amber-200 rounded">
911
- Your browser blocked the PayPal popup. Please allow popups
912
- for this site, then click “Connect to PayPal” again.
913
- </div>
914
- )}
915
-
916
757
  <div
917
758
  className="mt-2"
918
759
  style={{