@easypayment/medusa-payment-paypal 0.9.24 → 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.
Files changed (22) hide show
  1. package/.medusa/server/src/admin/index.js +187 -287
  2. package/.medusa/server/src/admin/index.mjs +187 -287
  3. package/.medusa/server/src/admin/routes/settings/paypal/connection/page.d.ts +0 -1
  4. package/.medusa/server/src/admin/routes/settings/paypal/connection/page.d.ts.map +1 -1
  5. package/.medusa/server/src/admin/routes/settings/paypal/connection/page.js +204 -332
  6. package/.medusa/server/src/admin/routes/settings/paypal/connection/page.js.map +1 -1
  7. package/.medusa/server/src/api/admin/paypal/onboard-complete/route.d.ts +7 -0
  8. package/.medusa/server/src/api/admin/paypal/onboard-complete/route.d.ts.map +1 -1
  9. package/.medusa/server/src/api/admin/paypal/onboard-complete/route.js +7 -0
  10. package/.medusa/server/src/api/admin/paypal/onboard-complete/route.js.map +1 -1
  11. package/.medusa/server/src/api/store/paypal/onboard-return/route.d.ts +25 -0
  12. package/.medusa/server/src/api/store/paypal/onboard-return/route.d.ts.map +1 -0
  13. package/.medusa/server/src/api/store/paypal/onboard-return/route.js +82 -0
  14. package/.medusa/server/src/api/store/paypal/onboard-return/route.js.map +1 -0
  15. package/.medusa/server/src/modules/paypal/service.d.ts.map +1 -1
  16. package/.medusa/server/src/modules/paypal/service.js +32 -2
  17. package/.medusa/server/src/modules/paypal/service.js.map +1 -1
  18. package/package.json +1 -1
  19. package/src/admin/routes/settings/paypal/connection/page.tsx +230 -354
  20. package/src/api/admin/paypal/onboard-complete/route.ts +7 -0
  21. package/src/api/store/paypal/onboard-return/route.ts +88 -0
  22. package/src/modules/paypal/service.ts +34 -3
@@ -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
253
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
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,163 +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
- }
570
-
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
- if (!isPayPalOrigin(ev.origin)) return
586
- const { authCode, sharedId } = extractAuth(ev.data)
587
- if (authCode && sharedId) {
588
- completeOnboarding(authCode, sharedId)
589
- }
590
- }
591
- window.addEventListener("message", onMessage)
592
- return () => window.removeEventListener("message", onMessage)
593
- }, [completeOnboarding])
466
+ }, [env, envReady, fetchFreshLink, activatePayPal])
594
467
 
595
- // 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.
596
470
  useLayoutEffect(() => {
597
471
  window.onboardingCallback = (authCode: string, sharedId: string) => {
598
- completeOnboarding(authCode, sharedId)
472
+ void completeOnboarding(authCode, sharedId)
599
473
  }
474
+
600
475
  return () => {
601
476
  window.onboardingCallback = undefined
602
477
  }
603
478
  }, [completeOnboarding])
604
479
 
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.
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.
608
485
  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()
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
+ })()
634
503
  }
635
504
 
636
- document.addEventListener("click", onCaptureClick, true)
637
- return () => document.removeEventListener("click", onCaptureClick, true)
638
- }, [openOnboardingPopup, startStatusPoll])
505
+ window.addEventListener("message", onMessage)
506
+ return () => window.removeEventListener("message", onMessage)
507
+ }, [completeOnboarding, refreshStatusAndMaybeConnect])
639
508
 
640
- useEffect(() => {
641
- return () => stopPoll()
642
- }, [stopPoll])
509
+ // Always stop polling when the component unmounts.
510
+ useEffect(() => stopStatusPolling, [stopStatusPolling])
643
511
 
644
512
  useLayoutEffect(() => {
645
513
  const el = ppBtnMeasureRef.current
@@ -666,10 +534,17 @@ export default function PayPalConnectionPage() {
666
534
  }
667
535
  }, [connState, env, finalUrl])
668
536
 
669
- // Defensive: the capture listener handles the click; this just guarantees the
670
- // 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.
671
541
  const handleConnectClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
672
- e.preventDefault()
542
+ if (connState !== "ready" || !finalUrl || onboardingInProgress) {
543
+ e.preventDefault()
544
+ return
545
+ }
546
+ completedRef.current = false
547
+ startStatusPolling()
673
548
  }
674
549
 
675
550
  const handleSaveManual = async () => {
@@ -719,6 +594,7 @@ export default function PayPalConnectionPage() {
719
594
  if (onboardingInProgress) return
720
595
  if (!window.confirm("Disconnect PayPal for this environment?")) return
721
596
 
597
+ stopStatusPolling()
722
598
  setOnboardingInProgress(true)
723
599
  setConnState("loading")
724
600
  setError(null)
@@ -743,7 +619,7 @@ export default function PayPalConnectionPage() {
743
619
  completedRef.current = false
744
620
 
745
621
  currentRunId.current = ++runIdRef.current
746
- await generateLinkAndReload(env, currentRunId.current)
622
+ await fetchFreshLink(env, currentRunId.current)
747
623
  } catch (e: any) {
748
624
  console.error(e)
749
625
  setConnState("error")
@@ -757,9 +633,9 @@ export default function PayPalConnectionPage() {
757
633
  const next = e.target.value as Env
758
634
  if (next === env || onboardingInProgress) return
759
635
 
636
+ stopStatusPolling()
760
637
  completedRef.current = false
761
638
  clearCachedUrl()
762
- setPopupBlocked(false)
763
639
  setConnState("loading")
764
640
 
765
641
  try {
@@ -809,6 +685,14 @@ export default function PayPalConnectionPage() {
809
685
  <div>
810
686
  <div className="text-sm text-green-600 bg-green-50 p-3 rounded border border-green-200">
811
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>
812
696
  </div>
813
697
  <div className="mt-3 rounded-md border border-ui-border-base bg-ui-bg-subtle p-3 text-xs text-ui-fg-subtle">
814
698
  <div className="font-medium text-ui-fg-base">
@@ -856,7 +740,6 @@ export default function PayPalConnectionPage() {
856
740
  ppBtnMeasureRef.current = node
857
741
  }}
858
742
  id="paypal-button"
859
- target={POPUP_NAME}
860
743
  data-paypal-button="true"
861
744
  href={finalUrl || "#"}
862
745
  data-paypal-onboard-complete="onboardingCallback"
@@ -871,13 +754,6 @@ export default function PayPalConnectionPage() {
871
754
  Connect to PayPal
872
755
  </a>
873
756
 
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
-
881
757
  <div
882
758
  className="mt-2"
883
759
  style={{