@easypayment/medusa-payment-paypal 0.9.21 → 0.9.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -36,18 +36,18 @@ declare global {
36
36
 
37
37
 
38
38
  const SERVICE_URL = "/admin/paypal/onboarding-link"
39
- // We open the mini-browser popup ourselves, synchronously inside the click
40
- // gesture (see openOnboardingPopup) the only reliable way to get a popup
41
- // rather than a blocked window or a new tab. The window name matches the
42
- // anchor's `target` and PayPal's conventional "PPFrame": partner.js opens the
43
- // flow with `window.open(url, target, ...)`, so when it runs right after us it
44
- // *reuses this already-open window* (a named-window navigation needs no user
45
- // activation) instead of trying — and failing, asynchronously — to spawn its
46
- // own. That lets partner.js still wire its completion bridge without ever
47
- // opening a second/late window.
39
+ // PayPal's conventional mini-browser window name; also used as the anchor's
40
+ // `target` so partner.js opens the flow in a popup.
48
41
  const POPUP_NAME = "PPFrame"
42
+
43
+ // The onboarding link is cached in the browser for 6 hours. This is the heart of
44
+ // the integration: partner.js can only wire the mini-browser + completion
45
+ // callback if the anchor already carries a valid onboarding href when partner.js
46
+ // initializes. By caching the link (and reloading once to populate the cache on
47
+ // a cold start) the link is always present on page load, so partner.js can take
48
+ // over the click and run its flow exactly as documented.
49
49
  const CACHE_PREFIX = "pp_onboard_cache"
50
- const CACHE_EXPIRY = 10 * 60 * 1000
50
+ const CACHE_EXPIRY = 6 * 60 * 60 * 1000 // 6 hours
51
51
 
52
52
  const ONBOARDING_COMPLETE_ENDPOINT = "/admin/paypal/onboard-complete"
53
53
  const STATUS_ENDPOINT = "/admin/paypal/status"
@@ -57,9 +57,9 @@ const ENVIRONMENT_ENDPOINT = "/admin/paypal/environment"
57
57
 
58
58
  type Env = "sandbox" | "live"
59
59
 
60
- // Onboarding URLs are environment-specific and short-lived, so the cache must be
61
- // keyed per environment — a single shared key let a stale sandbox URL leak into
62
- // the live flow (and vice-versa) after a reload.
60
+ // Onboarding URLs are environment-specific, so the cache is keyed per
61
+ // environment — a single shared key let a stale sandbox URL leak into the live
62
+ // flow (and vice-versa).
63
63
  const cacheKeyFor = (env: Env) => `${CACHE_PREFIX}_${env}`
64
64
 
65
65
  function readCachedUrl(env: Env): string | null {
@@ -81,11 +81,14 @@ function readCachedUrl(env: Env): string | null {
81
81
  return null
82
82
  }
83
83
 
84
- function writeCachedUrl(env: Env, url: string) {
84
+ // Returns true only if the value was actually persisted (storage enabled), which
85
+ // lets the caller decide whether reloading to pick up the cache is safe.
86
+ function writeCachedUrl(env: Env, url: string): boolean {
85
87
  try {
86
88
  localStorage.setItem(cacheKeyFor(env), JSON.stringify({ url, ts: Date.now() }))
89
+ return readCachedUrl(env) === url
87
90
  } catch {
88
- /* ignore quota / disabled storage */
91
+ return false
89
92
  }
90
93
  }
91
94
 
@@ -102,84 +105,22 @@ function clearCachedUrl(env?: Env) {
102
105
  }
103
106
  }
104
107
 
105
- // PayPal delivers the seamless-onboarding result by posting a message to
106
- // window.opener. We validate the sender is actually a PayPal origin before
107
- // trusting authCode/sharedId.
108
- function isPayPalOrigin(origin: string): boolean {
109
- try {
110
- const host = new URL(origin).hostname.toLowerCase()
111
- return (
112
- host === "www.paypal.com" ||
113
- host === "www.sandbox.paypal.com" ||
114
- host.endsWith(".paypal.com") ||
115
- host.endsWith(".paypalobjects.com")
116
- )
117
- } catch {
118
- return false
119
- }
120
- }
121
-
122
- // The completion message shape is not strongly documented, so extract
123
- // authCode/sharedId defensively from objects, JSON strings, or query strings.
124
- // We only ever act when BOTH values are present, which keeps the many unrelated
125
- // intermediate postMessages PayPal emits from triggering a false completion.
126
- function extractAuth(data: unknown): { authCode?: string; sharedId?: string } {
127
- if (!data) return {}
128
-
129
- if (typeof data === "object") {
130
- const obj = data as Record<string, any>
131
- const authCode = obj.authCode ?? obj.auth_code ?? obj.authcode
132
- const sharedId = obj.sharedId ?? obj.shared_id ?? obj.sharedid
133
- if (authCode && sharedId) {
134
- return { authCode: String(authCode), sharedId: String(sharedId) }
135
- }
136
- for (const key of ["data", "payload", "message", "detail", "body"]) {
137
- if (obj[key] && typeof obj[key] === "object") {
138
- const inner = extractAuth(obj[key])
139
- if (inner.authCode && inner.sharedId) return inner
140
- }
141
- }
142
- return {}
143
- }
144
-
145
- if (typeof data === "string") {
146
- const s = data.trim()
147
- if (!s) return {}
148
- if (s.startsWith("{")) {
149
- try {
150
- return extractAuth(JSON.parse(s))
151
- } catch {
152
- /* not JSON */
153
- }
154
- }
155
- if (/auth_?code/i.test(s) && /shared_?id/i.test(s)) {
156
- try {
157
- const sp = new URLSearchParams(s.replace(/^[?#]/, ""))
158
- const authCode = sp.get("authCode") || sp.get("auth_code") || undefined
159
- const sharedId = sp.get("sharedId") || sp.get("shared_id") || undefined
160
- if (authCode && sharedId) return { authCode, sharedId }
161
- } catch {
162
- /* not a query string */
163
- }
164
- }
165
- }
166
-
167
- return {}
168
- }
169
-
170
108
 
171
109
  export default function PayPalConnectionPage() {
172
110
  const [env, setEnv] = useState<Env>("live")
173
- // We must not generate/fetch an onboarding link until we know which
174
- // environment the server is actually on, otherwise the first fetch races the
175
- // GET /environment response and can target the wrong environment.
111
+ // We must know the server's environment before generating/looking up a link so
112
+ // the per-environment cache and the generated link always match.
176
113
  const [envReady, setEnvReady] = useState(false)
177
114
 
178
115
  const [connState, setConnState] = useState<
179
116
  "loading" | "ready" | "connected" | "error"
180
117
  >("loading")
118
+ // True once partner.js has loaded and bound its click interceptor to the
119
+ // button. The button stays in the DOM but is not clickable until then, so a
120
+ // click can never fall through to the native anchor (which would open a tab
121
+ // without partner.js's onboarding flow / completion callback).
122
+ const [partnerReady, setPartnerReady] = useState(false)
181
123
  const [error, setError] = useState<string | null>(null)
182
- const [popupBlocked, setPopupBlocked] = useState(false)
183
124
  const [finalUrl, setFinalUrl] = useState<string>("")
184
125
  const [showManual, setShowManual] = useState(false)
185
126
  const [clientId, setClientId] = useState("")
@@ -193,26 +134,15 @@ export default function PayPalConnectionPage() {
193
134
  const [onboardingInProgress, setOnboardingInProgress] = useState(false)
194
135
 
195
136
  const initLoaderRef = useRef<HTMLDivElement>(null)
196
- const paypalButtonRef = useRef<HTMLAnchorElement | null>(null)
197
137
  const errorLogRef = useRef<HTMLDivElement>(null)
198
138
  const runIdRef = useRef(0)
199
139
  const currentRunId = useRef(0)
200
140
 
201
- // Live mirrors of state so the document-level capture click listener (set up
202
- // once) and the stable completion callback always see current values.
203
- const finalUrlRef = useRef(finalUrl)
204
- finalUrlRef.current = finalUrl
205
- const connStateRef = useRef(connState)
206
- connStateRef.current = connState
207
- const inProgressRef = useRef(onboardingInProgress)
208
- inProgressRef.current = onboardingInProgress
209
141
  const envRef = useRef<Env>(env)
210
142
  envRef.current = env
211
143
 
212
- const popupRef = useRef<Window | null>(null)
213
- const popupPollRef = useRef<ReturnType<typeof setInterval> | null>(null)
214
- // Guards against the two redundant completion paths (our own message listener
215
- // and partner.js's onboardingCallback bridge) both POSTing the same result.
144
+ // Guards against partner.js's bridge invoking the completion callback more
145
+ // than once.
216
146
  const completedRef = useRef(false)
217
147
 
218
148
  const ppBtnMeasureRef = useRef<HTMLAnchorElement | null>(null)
@@ -227,196 +157,63 @@ export default function PayPalConnectionPage() {
227
157
  setError(msg)
228
158
  }, [])
229
159
 
230
- const stopPopupPoll = useCallback(() => {
231
- if (popupPollRef.current) {
232
- clearInterval(popupPollRef.current)
233
- popupPollRef.current = null
234
- }
235
- }, [])
236
-
237
- // Open the PayPal mini-browser ourselves, synchronously, inside the click
238
- // gesture. This is the crux of the fix: browsers only render window.open() as
239
- // a real popup (instead of a tab — or blocking it) when it runs synchronously
240
- // in a user gesture. Because finalUrl is already in state at click time, we
241
- // never need an await before opening, and we never depend on winning a race
242
- // with partner.js's asynchronous click binding.
243
- const openOnboardingPopup = useCallback(
244
- (url: string): Window | null => {
245
- const w = 450
246
- const h = 600
247
- const baseLeft =
248
- typeof window.screenX === "number" ? window.screenX : (window as any).screenLeft || 0
249
- const baseTop =
250
- typeof window.screenY === "number" ? window.screenY : (window as any).screenTop || 0
251
- const outerW =
252
- window.outerWidth || document.documentElement.clientWidth || 1024
253
- const outerH =
254
- window.outerHeight || document.documentElement.clientHeight || 768
255
- const left = Math.round(baseLeft + Math.max(0, (outerW - w) / 2))
256
- const top = Math.round(baseTop + Math.max(0, (outerH - h) / 2))
257
- const features = `popup=yes,width=${w},height=${h},left=${left},top=${top},scrollbars=yes,resizable=yes`
258
-
259
- let popup: Window | null = null
260
- try {
261
- popup = window.open(url, POPUP_NAME, features)
262
- } catch {
263
- popup = null
264
- }
265
-
266
- if (popup) {
267
- popupRef.current = popup
268
- try {
269
- popup.focus()
270
- } catch {
271
- /* ignore */
160
+ // Generate a fresh onboarding link, cache it for 6 hours, then reload the page
161
+ // so that on the next load the cached link is already present for partner.js.
162
+ // If browser storage is unavailable we fall back to using the link in-place
163
+ // (no reload) so we can never get stuck in a reload loop.
164
+ const generateLinkAndReload = useCallback(
165
+ async (targetEnv: Env, runId: number) => {
166
+ if (initLoaderRef.current) {
167
+ const loaderText = initLoaderRef.current.querySelector("#loader-text")
168
+ if (loaderText) {
169
+ loaderText.textContent = "Generating onboarding session..."
272
170
  }
273
- stopPopupPoll()
274
- popupPollRef.current = setInterval(() => {
275
- if (!popupRef.current || popupRef.current.closed) {
276
- stopPopupPoll()
277
- }
278
- }, 1000)
279
171
  }
280
172
 
281
- return popup
282
- },
283
- [stopPopupPoll]
284
- )
285
-
286
- // Exchange the seller authCode/sharedId for credentials. Reached from BOTH
287
- // PayPal's partner.js bridge (window.onboardingCallback) and our own postMessage
288
- // listener; completedRef makes it idempotent so we only POST once.
289
- const completeOnboarding = useCallback(
290
- async (authCode: string, sharedId: string) => {
291
- if (!authCode || !sharedId) return
292
- if (completedRef.current) return
293
- completedRef.current = true
294
-
295
- try {
296
- window.onbeforeunload = null
297
- } catch {
298
- /* ignore */
299
- }
300
-
301
- const activeEnv: Env = envRef.current === "sandbox" ? "sandbox" : "live"
302
-
303
- setOnboardingInProgress(true)
304
- setConnState("loading")
305
- setError(null)
306
- setPopupBlocked(false)
307
-
308
173
  try {
309
- const res = await fetch(ONBOARDING_COMPLETE_ENDPOINT, {
174
+ const res = await fetch(SERVICE_URL, {
310
175
  method: "POST",
311
176
  headers: { "content-type": "application/json" },
312
177
  credentials: "include",
313
- body: JSON.stringify({ authCode, sharedId, env: activeEnv }),
178
+ body: JSON.stringify({ products: ["PPCP"], environment: targetEnv }),
314
179
  })
315
180
 
316
- if (!res.ok) {
317
- const txt = await res.text().catch(() => "")
318
- throw new Error(txt || `Onboarding exchange failed (${res.status})`)
319
- }
181
+ if (!res.ok) throw new Error(`Service returned ${res.status}`)
320
182
 
321
- // Close the popup we opened, plus any partner.js-managed flow.
322
- try {
323
- popupRef.current?.close()
324
- } catch {
325
- /* ignore */
326
- }
327
- stopPopupPoll()
328
- try {
329
- const close1 = window.PAYPAL?.apps?.Signup?.MiniBrowser?.closeFlow
330
- if (typeof close1 === "function") close1()
331
- } catch {
332
- /* ignore */
333
- }
334
- try {
335
- const close2 =
336
- window.PAYPAL?.apps?.Signup?.miniBrowser &&
337
- (window.PAYPAL.apps.Signup.miniBrowser as any).closeFlow
338
- if (typeof close2 === "function") close2()
339
- } catch {
340
- /* ignore */
341
- }
183
+ const data = await res.json()
184
+ if (runId !== currentRunId.current) return
342
185
 
343
- clearCachedUrl(activeEnv)
344
-
345
- try {
346
- const statusRes = await fetch(
347
- `${STATUS_ENDPOINT}?environment=${activeEnv}`,
348
- { method: "GET", credentials: "include" }
349
- )
350
- const refreshedStatus = await statusRes.json().catch(() => ({}))
351
- setStatusInfo(refreshedStatus || null)
352
- } catch {
353
- /* ignore — still consider it connected below */
186
+ const href = data?.onboarding_url
187
+ if (!href) {
188
+ showError("Onboarding URL not returned.")
189
+ return
354
190
  }
355
191
 
356
- setConnState("connected")
357
- setShowManual(false)
358
- } catch (e: any) {
359
- // Allow a retry after a failed exchange.
360
- completedRef.current = false
361
- console.error(e)
362
- setConnState("error")
363
- setError(e?.message || "Exchange failed while saving credentials.")
364
- } finally {
365
- setOnboardingInProgress(false)
366
- }
367
- },
368
- [stopPopupPoll]
369
- )
370
-
371
- const fetchFreshLink = useCallback(
372
- (runId: number, targetEnv: Env) => {
373
- if (initLoaderRef.current) {
374
- const loaderText = initLoaderRef.current.querySelector("#loader-text")
375
- if (loaderText)
376
- loaderText.textContent = "Generating onboarding session..."
377
- }
378
-
379
- fetch(SERVICE_URL, {
380
- method: "POST",
381
- headers: { "content-type": "application/json" },
382
- credentials: "include",
383
- body: JSON.stringify({
384
- products: ["PPCP"],
385
- // Generate the link for the explicitly selected environment so it can
386
- // never depend on a racing POST /environment having landed yet.
387
- environment: targetEnv,
388
- }),
389
- })
390
- .then((r) => {
391
- if (!r.ok) throw new Error(`Service returned ${r.status}`)
392
- return r.json()
393
- })
394
- .then((data) => {
395
- if (runId !== currentRunId.current) return
192
+ const url =
193
+ href + (href.includes("?") ? "&" : "?") + "displayMode=minibrowser"
396
194
 
397
- const href = data?.onboarding_url
398
- if (!href) {
399
- showError("Onboarding URL not returned.")
400
- return
401
- }
195
+ const persisted = writeCachedUrl(targetEnv, url)
402
196
 
403
- const url =
404
- href + (href.includes("?") ? "&" : "?") + "displayMode=minibrowser"
405
-
406
- writeCachedUrl(targetEnv, url)
197
+ if (persisted) {
198
+ // The cache survived; reload so the link is present at first render and
199
+ // partner.js initializes against it. The post-reload load finds the
200
+ // cache (no regeneration), so this can't loop.
201
+ window.location.reload()
202
+ return
203
+ }
407
204
 
408
- setFinalUrl(url)
409
- setConnState("ready")
410
- })
411
- .catch(() => {
412
- if (runId !== currentRunId.current) return
413
- showError("Unable to connect to service.")
414
- })
205
+ // Storage disabled — use the link directly without reloading.
206
+ setFinalUrl(url)
207
+ setConnState("ready")
208
+ } catch {
209
+ if (runId !== currentRunId.current) return
210
+ showError("Unable to connect to service.")
211
+ }
415
212
  },
416
213
  [showError]
417
214
  )
418
215
 
419
- // Resolve the server's current environment before doing anything else.
216
+ // Resolve the server environment first.
420
217
  useEffect(() => {
421
218
  let alive = true
422
219
  fetch(ENVIRONMENT_ENDPOINT, { method: "GET", credentials: "include" })
@@ -434,14 +231,73 @@ export default function PayPalConnectionPage() {
434
231
  }
435
232
  }, [])
436
233
 
437
- // partner.js is loaded ONLY for its postMessage->callback bridge
438
- // (window.onboardingCallback), as a redundant path next to our own message
439
- // listener below. It is NO LONGER responsible for opening the window we do
440
- // that synchronously in the click handler. We still init it so its message
441
- // bridge registers inside this React SPA (its own DOMContentLoaded hook fired
442
- // long before this route mounted). If partner.js also intercepts the click,
443
- // the shared POPUP_NAME means it re-targets our single popup rather than
444
- // opening a second window.
234
+ // Page-load flow:
235
+ // 1. If already connected, show the connected state.
236
+ // 2. Else if a valid cached link exists, use it (partner.js will bind it).
237
+ // 3. Else generate a link, cache it for 6h, and reload so the cached link is
238
+ // present for partner.js on the next load.
239
+ useEffect(() => {
240
+ if (!envReady) return
241
+
242
+ currentRunId.current = ++runIdRef.current
243
+ const runId = currentRunId.current
244
+
245
+ let cancelled = false
246
+ completedRef.current = false
247
+
248
+ const run = async () => {
249
+ setConnState("loading")
250
+ setError(null)
251
+ setFinalUrl("")
252
+
253
+ try {
254
+ const r = await fetch(`${STATUS_ENDPOINT}?environment=${env}`, {
255
+ method: "GET",
256
+ credentials: "include",
257
+ })
258
+ const st = await r.json().catch(() => ({}))
259
+
260
+ if (cancelled || runId !== currentRunId.current) return
261
+
262
+ setStatusInfo(st)
263
+
264
+ const isConnected =
265
+ st?.status === "connected" && st?.seller_client_id_present === true
266
+
267
+ if (isConnected) {
268
+ setConnState("connected")
269
+ setShowManual(false)
270
+ return
271
+ }
272
+ } catch (e) {
273
+ console.error(e)
274
+ }
275
+
276
+ if (cancelled || runId !== currentRunId.current) return
277
+
278
+ const cached = readCachedUrl(env)
279
+ if (cached) {
280
+ setFinalUrl(cached)
281
+ setConnState("ready")
282
+ return
283
+ }
284
+
285
+ await generateLinkAndReload(env, runId)
286
+ }
287
+
288
+ run()
289
+
290
+ return () => {
291
+ cancelled = true
292
+ }
293
+ }, [env, envReady, generateLinkAndReload])
294
+
295
+ // Load partner.js once the anchor exists with its real (cached) href, then run
296
+ // PayPal's initializer. In a React SPA partner.js's own DOMContentLoaded hook
297
+ // already fired before this route mounted, so we must call init()/render()
298
+ // ourselves for it to discover the button and take over the click into the
299
+ // mini-browser — opening the popup, and wiring its postMessage bridge that
300
+ // invokes window.onboardingCallback and closes the popup on completion.
445
301
  useEffect(() => {
446
302
  if (connState !== "ready" || !finalUrl) return
447
303
 
@@ -449,6 +305,19 @@ export default function PayPalConnectionPage() {
449
305
  let cancelled = false
450
306
  let pollTimer: ReturnType<typeof setTimeout> | undefined
451
307
 
308
+ // The button is gated on partnerReady; reset it for this (re)load.
309
+ setPartnerReady(false)
310
+
311
+ // Safety net: if partner.js can't load (e.g. blocked), enable the button
312
+ // anyway after a few seconds rather than leaving it permanently disabled.
313
+ const readyFallback = setTimeout(() => {
314
+ if (!cancelled) setPartnerReady(true)
315
+ }, 6000)
316
+
317
+ const markReady = () => {
318
+ if (!cancelled) setPartnerReady(true)
319
+ }
320
+
452
321
  const initPartner = (attempt = 0) => {
453
322
  if (cancelled) return
454
323
 
@@ -462,13 +331,11 @@ export default function PayPalConnectionPage() {
462
331
 
463
332
  if (target?.init) {
464
333
  try {
465
- // Wires partner.js's click interceptor + its postMessage->callback
466
- // bridge. It will adopt the window we pre-open (same name) rather than
467
- // open its own.
468
334
  target.init()
469
335
  } catch (e) {
470
336
  console.error("[paypal] partner.js init failed:", e)
471
337
  }
338
+ markReady()
472
339
  return
473
340
  }
474
341
 
@@ -478,9 +345,12 @@ export default function PayPalConnectionPage() {
478
345
  } catch (e) {
479
346
  console.error("[paypal] partner.js render failed:", e)
480
347
  }
348
+ markReady()
481
349
  return
482
350
  }
483
351
 
352
+ // The namespace may not be populated the instant onload fires; retry
353
+ // briefly before giving up.
484
354
  if (attempt < 40) {
485
355
  pollTimer = setTimeout(() => initPartner(attempt + 1), 50)
486
356
  return
@@ -489,6 +359,7 @@ export default function PayPalConnectionPage() {
489
359
  try {
490
360
  document.dispatchEvent(new Event("DOMContentLoaded"))
491
361
  } catch {}
362
+ markReady()
492
363
  }
493
364
 
494
365
  // Remove any stale copy + namespace so the fresh load re-registers against
@@ -508,10 +379,12 @@ export default function PayPalConnectionPage() {
508
379
  ppScript.src = scriptUrl
509
380
  ppScript.async = true
510
381
  ppScript.onload = () => initPartner()
382
+ ppScript.onerror = () => markReady()
511
383
  document.body.appendChild(ppScript)
512
384
 
513
385
  return () => {
514
386
  cancelled = true
387
+ clearTimeout(readyFallback)
515
388
  if (pollTimer) {
516
389
  clearTimeout(pollTimer)
517
390
  }
@@ -521,81 +394,73 @@ export default function PayPalConnectionPage() {
521
394
  }
522
395
  }, [connState, finalUrl, env])
523
396
 
524
- // Primary completion path: receive PayPal's seamless-onboarding postMessage
525
- // ourselves. Independent of partner.js, so it works even when we opened the
526
- // popup directly.
527
- useEffect(() => {
528
- const onMessage = (ev: MessageEvent) => {
529
- if (!isPayPalOrigin(ev.origin)) return
530
- const { authCode, sharedId } = extractAuth(ev.data)
531
- if (authCode && sharedId) {
532
- completeOnboarding(authCode, sharedId)
533
- }
534
- }
535
- window.addEventListener("message", onMessage)
536
- return () => window.removeEventListener("message", onMessage)
537
- }, [completeOnboarding])
538
-
539
- // Status check + link generation. Gated on envReady so we always use the
540
- // correct environment. runId guards against a stale response from a previous
541
- // environment overwriting the current one.
542
- useEffect(() => {
543
- if (!envReady) return
544
-
545
- currentRunId.current = ++runIdRef.current
546
- const runId = currentRunId.current
547
-
548
- let cancelled = false
549
- completedRef.current = false
397
+ // partner.js calls this (by the name in data-paypal-onboard-complete) when the
398
+ // seller finishes onboarding, passing the authCode + sharedId to exchange for
399
+ // seller credentials.
400
+ const completeOnboarding = useCallback(async (authCode: string, sharedId: string) => {
401
+ if (!authCode || !sharedId) return
402
+ if (completedRef.current) return
403
+ completedRef.current = true
550
404
 
551
- const run = async () => {
552
- setConnState("loading")
553
- setError(null)
554
- setPopupBlocked(false)
555
- setFinalUrl("")
556
-
557
- try {
558
- const r = await fetch(`${STATUS_ENDPOINT}?environment=${env}`, {
559
- method: "GET",
560
- credentials: "include",
561
- })
562
- const st = await r.json().catch(() => ({}))
405
+ try {
406
+ window.onbeforeunload = null
407
+ } catch {}
563
408
 
564
- if (cancelled || runId !== currentRunId.current) return
409
+ const activeEnv: Env = envRef.current === "sandbox" ? "sandbox" : "live"
565
410
 
566
- setStatusInfo(st)
411
+ setOnboardingInProgress(true)
412
+ setConnState("loading")
413
+ setError(null)
567
414
 
568
- const isConnected =
569
- st?.status === "connected" && st?.seller_client_id_present === true
415
+ try {
416
+ const res = await fetch(ONBOARDING_COMPLETE_ENDPOINT, {
417
+ method: "POST",
418
+ headers: { "content-type": "application/json" },
419
+ credentials: "include",
420
+ body: JSON.stringify({ authCode, sharedId, env: activeEnv }),
421
+ })
570
422
 
571
- if (isConnected) {
572
- setConnState("connected")
573
- setShowManual(false)
574
- return
575
- }
576
- } catch (e) {
577
- console.error(e)
423
+ if (!res.ok) {
424
+ const txt = await res.text().catch(() => "")
425
+ throw new Error(txt || `Onboarding exchange failed (${res.status})`)
578
426
  }
579
427
 
580
- if (cancelled || runId !== currentRunId.current) return
428
+ // Close the partner.js mini-browser.
429
+ try {
430
+ const close1 = window.PAYPAL?.apps?.Signup?.MiniBrowser?.closeFlow
431
+ if (typeof close1 === "function") close1()
432
+ } catch {}
433
+ try {
434
+ const close2 =
435
+ window.PAYPAL?.apps?.Signup?.miniBrowser &&
436
+ (window.PAYPAL.apps.Signup.miniBrowser as any).closeFlow
437
+ if (typeof close2 === "function") close2()
438
+ } catch {}
581
439
 
582
- const cached = readCachedUrl(env)
583
- if (cached) {
584
- setFinalUrl(cached)
585
- setConnState("ready")
586
- } else {
587
- fetchFreshLink(runId, env)
588
- }
589
- }
440
+ // The link has been consumed; drop it so a future flow regenerates one.
441
+ clearCachedUrl(activeEnv)
590
442
 
591
- run()
443
+ try {
444
+ const statusRes = await fetch(`${STATUS_ENDPOINT}?environment=${activeEnv}`, {
445
+ method: "GET",
446
+ credentials: "include",
447
+ })
448
+ const refreshedStatus = await statusRes.json().catch(() => ({}))
449
+ setStatusInfo(refreshedStatus || null)
450
+ } catch {}
592
451
 
593
- return () => {
594
- cancelled = true
452
+ setConnState("connected")
453
+ setShowManual(false)
454
+ } catch (e: any) {
455
+ completedRef.current = false
456
+ console.error(e)
457
+ setConnState("error")
458
+ setError(e?.message || "Exchange failed while saving credentials.")
459
+ } finally {
460
+ setOnboardingInProgress(false)
595
461
  }
596
- }, [env, envReady, fetchFreshLink])
462
+ }, [])
597
463
 
598
- // Bridge partner.js's callback into our single idempotent completion handler.
599
464
  useLayoutEffect(() => {
600
465
  window.onboardingCallback = (authCode: string, sharedId: string) => {
601
466
  completeOnboarding(authCode, sharedId)
@@ -605,13 +470,6 @@ export default function PayPalConnectionPage() {
605
470
  }
606
471
  }, [completeOnboarding])
607
472
 
608
- // Clean up the popup poll on unmount.
609
- useEffect(() => {
610
- return () => {
611
- stopPopupPoll()
612
- }
613
- }, [stopPopupPoll])
614
-
615
473
  useLayoutEffect(() => {
616
474
  const el = ppBtnMeasureRef.current
617
475
  if (!el) return
@@ -637,57 +495,6 @@ export default function PayPalConnectionPage() {
637
495
  }
638
496
  }, [connState, env, finalUrl])
639
497
 
640
- // Capture-phase click handler — the single, reliable opener.
641
- //
642
- // It runs on `document` in the CAPTURE phase, i.e. BEFORE the event reaches
643
- // the anchor where partner.js binds its own (target-phase) handler. That
644
- // ordering is the whole point:
645
- // 1. We synchronously open the mini-browser popup here, inside the user
646
- // gesture, so the browser always renders a real popup (never a blocked
647
- // window, never a new tab).
648
- // 2. We do NOT stop propagation, so partner.js's handler still runs a beat
649
- // later. Since it opens with the same window name (POPUP_NAME), it adopts
650
- // the window we just opened instead of asynchronously spawning its own
651
- // (which the browser was blocking — "multiple popups ... lack of user
652
- // activation") — and it still wires its postMessage->onboardingCallback
653
- // bridge that auto-closes the popup and saves credentials.
654
- // 3. We always preventDefault so the anchor's href can never open a tab.
655
- //
656
- // Completion is handled by partner.js's bridge AND by our own postMessage
657
- // listener (see below), funnelling through one idempotent completeOnboarding.
658
- useEffect(() => {
659
- const onCaptureClick = (e: MouseEvent) => {
660
- const btn = paypalButtonRef.current
661
- if (!btn) return
662
- const target = e.target as Node | null
663
- if (!target || !btn.contains(target)) return
664
-
665
- e.preventDefault()
666
-
667
- if (
668
- connStateRef.current !== "ready" ||
669
- !finalUrlRef.current ||
670
- inProgressRef.current
671
- ) {
672
- return
673
- }
674
-
675
- completedRef.current = false
676
- const popup = openOnboardingPopup(finalUrlRef.current)
677
- setPopupBlocked(!popup)
678
- }
679
-
680
- document.addEventListener("click", onCaptureClick, true)
681
- return () => document.removeEventListener("click", onCaptureClick, true)
682
- }, [openOnboardingPopup])
683
-
684
- // Defensive only: the capture-phase listener above already opened the popup
685
- // and prevented the default. If for some reason it didn't run, make sure the
686
- // native target still can't open a tab.
687
- const handleConnectClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
688
- e.preventDefault()
689
- }
690
-
691
498
  const handleSaveManual = async () => {
692
499
  if (!canSaveManual || onboardingInProgress) return
693
500
  setOnboardingInProgress(true)
@@ -756,11 +563,12 @@ export default function PayPalConnectionPage() {
756
563
  }
757
564
 
758
565
  clearCachedUrl(env)
759
-
760
566
  completedRef.current = false
567
+
568
+ // Generate a fresh link for the now-disconnected environment and reload so
569
+ // the onboarding button is ready again.
761
570
  currentRunId.current = ++runIdRef.current
762
- const runId = currentRunId.current
763
- fetchFreshLink(runId, env)
571
+ await generateLinkAndReload(env, currentRunId.current)
764
572
  } catch (e: any) {
765
573
  console.error(e)
766
574
  setConnState("error")
@@ -772,18 +580,14 @@ export default function PayPalConnectionPage() {
772
580
 
773
581
  const handleEnvChange = async (e: React.ChangeEvent<HTMLSelectElement>) => {
774
582
  const next = e.target.value as Env
583
+ if (next === env || onboardingInProgress) return
775
584
 
776
585
  completedRef.current = false
777
- setPopupBlocked(false)
778
- // Drop both cached URLs so neither environment can serve a stale link.
779
586
  clearCachedUrl()
587
+ setConnState("loading")
780
588
 
781
- // Update local state immediately (drives the status/link refetch with the
782
- // explicit environment) and persist the choice. Because the link/status
783
- // fetches are environment-explicit, correctness no longer depends on this
784
- // POST winning a race.
785
- setEnv(next)
786
-
589
+ // Persist the environment BEFORE switching local state so the post-reload
590
+ // GET /environment returns the same value the cache was keyed under.
787
591
  try {
788
592
  await fetch(ENVIRONMENT_ENDPOINT, {
789
593
  method: "POST",
@@ -792,6 +596,8 @@ export default function PayPalConnectionPage() {
792
596
  body: JSON.stringify({ environment: next }),
793
597
  })
794
598
  } catch {}
599
+
600
+ setEnv(next)
795
601
  }
796
602
 
797
603
  return (
@@ -870,9 +676,12 @@ export default function PayPalConnectionPage() {
870
676
  </div>
871
677
 
872
678
  <div className={`${connState === "ready" ? "block" : "hidden"}`}>
679
+ {/* Standard PayPal partner onboarding button. partner.js binds
680
+ this anchor (it carries the cached onboarding href) and
681
+ takes over the click into the mini-browser; on completion
682
+ it calls window.onboardingCallback. */}
873
683
  <a
874
684
  ref={(node) => {
875
- paypalButtonRef.current = node
876
685
  ppBtnMeasureRef.current = node
877
686
  }}
878
687
  id="paypal-button"
@@ -880,24 +689,26 @@ export default function PayPalConnectionPage() {
880
689
  data-paypal-button="true"
881
690
  href={finalUrl || "#"}
882
691
  data-paypal-onboard-complete="onboardingCallback"
883
- onClick={handleConnectClick}
692
+ aria-disabled={onboardingInProgress || !partnerReady}
884
693
  className="transition-fg relative inline-flex w-fit items-center justify-center overflow-hidden rounded-md outline-none no-underline shadow-buttons-neutral text-ui-fg-base bg-ui-button-neutral after:transition-fg after:absolute after:inset-0 after:content-[''] after:button-neutral-gradient hover:bg-ui-button-neutral-hover hover:after:button-neutral-hover-gradient active:bg-ui-button-neutral-pressed active:after:button-neutral-pressed-gradient focus-visible:shadow-buttons-neutral-focus disabled:bg-ui-bg-disabled disabled:border-ui-border-base disabled:text-ui-fg-disabled disabled:shadow-buttons-neutral disabled:after:hidden txt-compact-small-plus px-3 py-1.5"
885
694
  style={{
886
- cursor: onboardingInProgress ? "not-allowed" : "pointer",
887
- opacity: onboardingInProgress ? 0.6 : 1,
888
- pointerEvents: onboardingInProgress ? "none" : "auto",
695
+ cursor:
696
+ onboardingInProgress || !partnerReady
697
+ ? "not-allowed"
698
+ : "pointer",
699
+ opacity: onboardingInProgress || !partnerReady ? 0.6 : 1,
700
+ // Keep the anchor mounted (partner.js must find it to bind)
701
+ // but un-clickable until partner.js has taken over the
702
+ // click — so it can never open a tab via the native href.
703
+ pointerEvents:
704
+ onboardingInProgress || !partnerReady ? "none" : "auto",
889
705
  }}
890
706
  >
891
- Connect to PayPal
707
+ {partnerReady || onboardingInProgress
708
+ ? "Connect to PayPal"
709
+ : "Preparing PayPal…"}
892
710
  </a>
893
711
 
894
- {popupBlocked && (
895
- <div className="mt-3 text-left text-xs bg-amber-50 text-amber-700 p-3 border border-amber-200 rounded">
896
- Your browser blocked the PayPal popup. Please allow popups
897
- for this site, then click “Connect to PayPal” again.
898
- </div>
899
- )}
900
-
901
712
  <div
902
713
  className="mt-2"
903
714
  style={{