@easypayment/medusa-payment-paypal 0.9.17 → 0.9.19

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