@easypayment/medusa-payment-paypal 0.9.20 → 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,13 +36,18 @@ declare global {
36
36
 
37
37
 
38
38
  const SERVICE_URL = "/admin/paypal/onboarding-link"
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"
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.
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.
44
49
  const CACHE_PREFIX = "pp_onboard_cache"
45
- const CACHE_EXPIRY = 10 * 60 * 1000
50
+ const CACHE_EXPIRY = 6 * 60 * 60 * 1000 // 6 hours
46
51
 
47
52
  const ONBOARDING_COMPLETE_ENDPOINT = "/admin/paypal/onboard-complete"
48
53
  const STATUS_ENDPOINT = "/admin/paypal/status"
@@ -52,9 +57,9 @@ const ENVIRONMENT_ENDPOINT = "/admin/paypal/environment"
52
57
 
53
58
  type Env = "sandbox" | "live"
54
59
 
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.
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).
58
63
  const cacheKeyFor = (env: Env) => `${CACHE_PREFIX}_${env}`
59
64
 
60
65
  function readCachedUrl(env: Env): string | null {
@@ -76,11 +81,14 @@ function readCachedUrl(env: Env): string | null {
76
81
  return null
77
82
  }
78
83
 
79
- 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 {
80
87
  try {
81
88
  localStorage.setItem(cacheKeyFor(env), JSON.stringify({ url, ts: Date.now() }))
89
+ return readCachedUrl(env) === url
82
90
  } catch {
83
- /* ignore quota / disabled storage */
91
+ return false
84
92
  }
85
93
  }
86
94
 
@@ -97,84 +105,22 @@ function clearCachedUrl(env?: Env) {
97
105
  }
98
106
  }
99
107
 
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 */
158
- }
159
- }
160
- }
161
-
162
- return {}
163
- }
164
-
165
108
 
166
109
  export default function PayPalConnectionPage() {
167
110
  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.
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.
171
113
  const [envReady, setEnvReady] = useState(false)
172
114
 
173
115
  const [connState, setConnState] = useState<
174
116
  "loading" | "ready" | "connected" | "error"
175
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)
176
123
  const [error, setError] = useState<string | null>(null)
177
- const [popupBlocked, setPopupBlocked] = useState(false)
178
124
  const [finalUrl, setFinalUrl] = useState<string>("")
179
125
  const [showManual, setShowManual] = useState(false)
180
126
  const [clientId, setClientId] = useState("")
@@ -188,33 +134,16 @@ export default function PayPalConnectionPage() {
188
134
  const [onboardingInProgress, setOnboardingInProgress] = useState(false)
189
135
 
190
136
  const initLoaderRef = useRef<HTMLDivElement>(null)
191
- const paypalButtonRef = useRef<HTMLAnchorElement | null>(null)
192
137
  const errorLogRef = useRef<HTMLDivElement>(null)
193
138
  const runIdRef = useRef(0)
194
139
  const currentRunId = useRef(0)
195
140
 
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
141
  const envRef = useRef<Env>(env)
205
142
  envRef.current = env
206
143
 
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.
144
+ // Guards against partner.js's bridge invoking the completion callback more
145
+ // than once.
211
146
  const completedRef = useRef(false)
212
- // True once partner.js has initialized and bound its click interceptor to the
213
- // button. When bound, partner.js opens the mini-browser AND wires its
214
- // postMessage->onboardingCallback bridge (which auto-closes the popup and
215
- // saves credentials), so we must NOT open a second window. Until it is bound
216
- // (cold first click), we open the popup ourselves so the click still works.
217
- const partnerBoundRef = useRef(false)
218
147
 
219
148
  const ppBtnMeasureRef = useRef<HTMLAnchorElement | null>(null)
220
149
  const [ppBtnWidth, setPpBtnWidth] = useState<number | null>(null)
@@ -228,196 +157,63 @@ export default function PayPalConnectionPage() {
228
157
  setError(msg)
229
158
  }, [])
230
159
 
231
- const stopPopupPoll = useCallback(() => {
232
- if (popupPollRef.current) {
233
- clearInterval(popupPollRef.current)
234
- popupPollRef.current = null
235
- }
236
- }, [])
237
-
238
- // Open the PayPal mini-browser ourselves, synchronously, inside the click
239
- // gesture. This is the crux of the fix: browsers only render window.open() as
240
- // a real popup (instead of a tab — or blocking it) when it runs synchronously
241
- // in a user gesture. Because finalUrl is already in state at click time, we
242
- // never need an await before opening, and we never depend on winning a race
243
- // with partner.js's asynchronous click binding.
244
- const openOnboardingPopup = useCallback(
245
- (url: string): Window | null => {
246
- const w = 450
247
- const h = 600
248
- const baseLeft =
249
- typeof window.screenX === "number" ? window.screenX : (window as any).screenLeft || 0
250
- const baseTop =
251
- typeof window.screenY === "number" ? window.screenY : (window as any).screenTop || 0
252
- const outerW =
253
- window.outerWidth || document.documentElement.clientWidth || 1024
254
- const outerH =
255
- window.outerHeight || document.documentElement.clientHeight || 768
256
- const left = Math.round(baseLeft + Math.max(0, (outerW - w) / 2))
257
- const top = Math.round(baseTop + Math.max(0, (outerH - h) / 2))
258
- const features = `popup=yes,width=${w},height=${h},left=${left},top=${top},scrollbars=yes,resizable=yes`
259
-
260
- let popup: Window | null = null
261
- try {
262
- popup = window.open(url, POPUP_NAME, features)
263
- } catch {
264
- popup = null
265
- }
266
-
267
- if (popup) {
268
- popupRef.current = popup
269
- try {
270
- popup.focus()
271
- } catch {
272
- /* 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..."
273
170
  }
274
- stopPopupPoll()
275
- popupPollRef.current = setInterval(() => {
276
- if (!popupRef.current || popupRef.current.closed) {
277
- stopPopupPoll()
278
- }
279
- }, 1000)
280
171
  }
281
172
 
282
- return popup
283
- },
284
- [stopPopupPoll]
285
- )
286
-
287
- // Exchange the seller authCode/sharedId for credentials. Reached from BOTH
288
- // PayPal's partner.js bridge (window.onboardingCallback) and our own postMessage
289
- // listener; completedRef makes it idempotent so we only POST once.
290
- const completeOnboarding = useCallback(
291
- async (authCode: string, sharedId: string) => {
292
- if (!authCode || !sharedId) return
293
- if (completedRef.current) return
294
- completedRef.current = true
295
-
296
- try {
297
- window.onbeforeunload = null
298
- } catch {
299
- /* ignore */
300
- }
301
-
302
- const activeEnv: Env = envRef.current === "sandbox" ? "sandbox" : "live"
303
-
304
- setOnboardingInProgress(true)
305
- setConnState("loading")
306
- setError(null)
307
- setPopupBlocked(false)
308
-
309
173
  try {
310
- const res = await fetch(ONBOARDING_COMPLETE_ENDPOINT, {
174
+ const res = await fetch(SERVICE_URL, {
311
175
  method: "POST",
312
176
  headers: { "content-type": "application/json" },
313
177
  credentials: "include",
314
- body: JSON.stringify({ authCode, sharedId, env: activeEnv }),
178
+ body: JSON.stringify({ products: ["PPCP"], environment: targetEnv }),
315
179
  })
316
180
 
317
- if (!res.ok) {
318
- const txt = await res.text().catch(() => "")
319
- throw new Error(txt || `Onboarding exchange failed (${res.status})`)
320
- }
181
+ if (!res.ok) throw new Error(`Service returned ${res.status}`)
321
182
 
322
- // Close the popup we opened, plus any partner.js-managed flow.
323
- try {
324
- popupRef.current?.close()
325
- } catch {
326
- /* ignore */
327
- }
328
- stopPopupPoll()
329
- try {
330
- const close1 = window.PAYPAL?.apps?.Signup?.MiniBrowser?.closeFlow
331
- if (typeof close1 === "function") close1()
332
- } catch {
333
- /* ignore */
334
- }
335
- try {
336
- const close2 =
337
- window.PAYPAL?.apps?.Signup?.miniBrowser &&
338
- (window.PAYPAL.apps.Signup.miniBrowser as any).closeFlow
339
- if (typeof close2 === "function") close2()
340
- } catch {
341
- /* ignore */
342
- }
343
-
344
- clearCachedUrl(activeEnv)
183
+ const data = await res.json()
184
+ if (runId !== currentRunId.current) return
345
185
 
346
- try {
347
- const statusRes = await fetch(
348
- `${STATUS_ENDPOINT}?environment=${activeEnv}`,
349
- { method: "GET", credentials: "include" }
350
- )
351
- const refreshedStatus = await statusRes.json().catch(() => ({}))
352
- setStatusInfo(refreshedStatus || null)
353
- } catch {
354
- /* ignore — still consider it connected below */
186
+ const href = data?.onboarding_url
187
+ if (!href) {
188
+ showError("Onboarding URL not returned.")
189
+ return
355
190
  }
356
191
 
357
- setConnState("connected")
358
- setShowManual(false)
359
- } catch (e: any) {
360
- // Allow a retry after a failed exchange.
361
- completedRef.current = false
362
- console.error(e)
363
- setConnState("error")
364
- setError(e?.message || "Exchange failed while saving credentials.")
365
- } finally {
366
- setOnboardingInProgress(false)
367
- }
368
- },
369
- [stopPopupPoll]
370
- )
371
-
372
- const fetchFreshLink = useCallback(
373
- (runId: number, targetEnv: Env) => {
374
- if (initLoaderRef.current) {
375
- const loaderText = initLoaderRef.current.querySelector("#loader-text")
376
- if (loaderText)
377
- loaderText.textContent = "Generating onboarding session..."
378
- }
379
-
380
- fetch(SERVICE_URL, {
381
- method: "POST",
382
- headers: { "content-type": "application/json" },
383
- credentials: "include",
384
- body: JSON.stringify({
385
- products: ["PPCP"],
386
- // Generate the link for the explicitly selected environment so it can
387
- // never depend on a racing POST /environment having landed yet.
388
- environment: targetEnv,
389
- }),
390
- })
391
- .then((r) => {
392
- if (!r.ok) throw new Error(`Service returned ${r.status}`)
393
- return r.json()
394
- })
395
- .then((data) => {
396
- if (runId !== currentRunId.current) return
397
-
398
- const href = data?.onboarding_url
399
- if (!href) {
400
- showError("Onboarding URL not returned.")
401
- return
402
- }
192
+ const url =
193
+ href + (href.includes("?") ? "&" : "?") + "displayMode=minibrowser"
403
194
 
404
- const url =
405
- href + (href.includes("?") ? "&" : "?") + "displayMode=minibrowser"
195
+ const persisted = writeCachedUrl(targetEnv, url)
406
196
 
407
- 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
+ }
408
204
 
409
- setFinalUrl(url)
410
- setConnState("ready")
411
- })
412
- .catch(() => {
413
- if (runId !== currentRunId.current) return
414
- showError("Unable to connect to service.")
415
- })
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
+ }
416
212
  },
417
213
  [showError]
418
214
  )
419
215
 
420
- // Resolve the server's current environment before doing anything else.
216
+ // Resolve the server environment first.
421
217
  useEffect(() => {
422
218
  let alive = true
423
219
  fetch(ENVIRONMENT_ENDPOINT, { method: "GET", credentials: "include" })
@@ -435,14 +231,73 @@ export default function PayPalConnectionPage() {
435
231
  }
436
232
  }, [])
437
233
 
438
- // partner.js is loaded ONLY for its postMessage->callback bridge
439
- // (window.onboardingCallback), as a redundant path next to our own message
440
- // listener below. It is NO LONGER responsible for opening the window we do
441
- // that synchronously in the click handler. We still init it so its message
442
- // bridge registers inside this React SPA (its own DOMContentLoaded hook fired
443
- // long before this route mounted). If partner.js also intercepts the click,
444
- // the shared POPUP_NAME means it re-targets our single popup rather than
445
- // 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.
446
301
  useEffect(() => {
447
302
  if (connState !== "ready" || !finalUrl) return
448
303
 
@@ -450,6 +305,19 @@ export default function PayPalConnectionPage() {
450
305
  let cancelled = false
451
306
  let pollTimer: ReturnType<typeof setTimeout> | undefined
452
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
+
453
321
  const initPartner = (attempt = 0) => {
454
322
  if (cancelled) return
455
323
 
@@ -464,26 +332,25 @@ export default function PayPalConnectionPage() {
464
332
  if (target?.init) {
465
333
  try {
466
334
  target.init()
467
- // partner.js scans the DOM and binds the button synchronously inside
468
- // init(), so from here a click is intercepted by partner.js (popup +
469
- // callback bridge) and we should not also open our own window.
470
- partnerBoundRef.current = true
471
335
  } catch (e) {
472
336
  console.error("[paypal] partner.js init failed:", e)
473
337
  }
338
+ markReady()
474
339
  return
475
340
  }
476
341
 
477
342
  if (typeof signup?.render === "function") {
478
343
  try {
479
344
  signup.render()
480
- partnerBoundRef.current = true
481
345
  } catch (e) {
482
346
  console.error("[paypal] partner.js render failed:", e)
483
347
  }
348
+ markReady()
484
349
  return
485
350
  }
486
351
 
352
+ // The namespace may not be populated the instant onload fires; retry
353
+ // briefly before giving up.
487
354
  if (attempt < 40) {
488
355
  pollTimer = setTimeout(() => initPartner(attempt + 1), 50)
489
356
  return
@@ -492,12 +359,9 @@ export default function PayPalConnectionPage() {
492
359
  try {
493
360
  document.dispatchEvent(new Event("DOMContentLoaded"))
494
361
  } catch {}
362
+ markReady()
495
363
  }
496
364
 
497
- // This load hasn't bound the button yet; until init() runs, a click should
498
- // be handled by us (cold path) rather than assumed to reach partner.js.
499
- partnerBoundRef.current = false
500
-
501
365
  // Remove any stale copy + namespace so the fresh load re-registers against
502
366
  // the current environment (sandbox vs live partner.js differ).
503
367
  const existingScript = document.getElementById("paypal-partner-js")
@@ -515,10 +379,12 @@ export default function PayPalConnectionPage() {
515
379
  ppScript.src = scriptUrl
516
380
  ppScript.async = true
517
381
  ppScript.onload = () => initPartner()
382
+ ppScript.onerror = () => markReady()
518
383
  document.body.appendChild(ppScript)
519
384
 
520
385
  return () => {
521
386
  cancelled = true
387
+ clearTimeout(readyFallback)
522
388
  if (pollTimer) {
523
389
  clearTimeout(pollTimer)
524
390
  }
@@ -528,81 +394,73 @@ export default function PayPalConnectionPage() {
528
394
  }
529
395
  }, [connState, finalUrl, env])
530
396
 
531
- // Primary completion path: receive PayPal's seamless-onboarding postMessage
532
- // ourselves. Independent of partner.js, so it works even when we opened the
533
- // popup directly.
534
- useEffect(() => {
535
- const onMessage = (ev: MessageEvent) => {
536
- if (!isPayPalOrigin(ev.origin)) return
537
- const { authCode, sharedId } = extractAuth(ev.data)
538
- if (authCode && sharedId) {
539
- completeOnboarding(authCode, sharedId)
540
- }
541
- }
542
- window.addEventListener("message", onMessage)
543
- return () => window.removeEventListener("message", onMessage)
544
- }, [completeOnboarding])
545
-
546
- // Status check + link generation. Gated on envReady so we always use the
547
- // correct environment. runId guards against a stale response from a previous
548
- // environment overwriting the current one.
549
- useEffect(() => {
550
- if (!envReady) return
551
-
552
- currentRunId.current = ++runIdRef.current
553
- const runId = currentRunId.current
554
-
555
- let cancelled = false
556
- 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
557
404
 
558
- const run = async () => {
559
- setConnState("loading")
560
- setError(null)
561
- setPopupBlocked(false)
562
- setFinalUrl("")
563
-
564
- try {
565
- const r = await fetch(`${STATUS_ENDPOINT}?environment=${env}`, {
566
- method: "GET",
567
- credentials: "include",
568
- })
569
- const st = await r.json().catch(() => ({}))
405
+ try {
406
+ window.onbeforeunload = null
407
+ } catch {}
570
408
 
571
- if (cancelled || runId !== currentRunId.current) return
409
+ const activeEnv: Env = envRef.current === "sandbox" ? "sandbox" : "live"
572
410
 
573
- setStatusInfo(st)
411
+ setOnboardingInProgress(true)
412
+ setConnState("loading")
413
+ setError(null)
574
414
 
575
- const isConnected =
576
- 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
+ })
577
422
 
578
- if (isConnected) {
579
- setConnState("connected")
580
- setShowManual(false)
581
- return
582
- }
583
- } catch (e) {
584
- console.error(e)
423
+ if (!res.ok) {
424
+ const txt = await res.text().catch(() => "")
425
+ throw new Error(txt || `Onboarding exchange failed (${res.status})`)
585
426
  }
586
427
 
587
- 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 {}
588
439
 
589
- const cached = readCachedUrl(env)
590
- if (cached) {
591
- setFinalUrl(cached)
592
- setConnState("ready")
593
- } else {
594
- fetchFreshLink(runId, env)
595
- }
596
- }
440
+ // The link has been consumed; drop it so a future flow regenerates one.
441
+ clearCachedUrl(activeEnv)
597
442
 
598
- 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 {}
599
451
 
600
- return () => {
601
- 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)
602
461
  }
603
- }, [env, envReady, fetchFreshLink])
462
+ }, [])
604
463
 
605
- // Bridge partner.js's callback into our single idempotent completion handler.
606
464
  useLayoutEffect(() => {
607
465
  window.onboardingCallback = (authCode: string, sharedId: string) => {
608
466
  completeOnboarding(authCode, sharedId)
@@ -612,13 +470,6 @@ export default function PayPalConnectionPage() {
612
470
  }
613
471
  }, [completeOnboarding])
614
472
 
615
- // Clean up the popup poll on unmount.
616
- useEffect(() => {
617
- return () => {
618
- stopPopupPoll()
619
- }
620
- }, [stopPopupPoll])
621
-
622
473
  useLayoutEffect(() => {
623
474
  const el = ppBtnMeasureRef.current
624
475
  if (!el) return
@@ -644,40 +495,6 @@ export default function PayPalConnectionPage() {
644
495
  }
645
496
  }, [connState, env, finalUrl])
646
497
 
647
- // Click handling for "Connect to PayPal".
648
- //
649
- // We always preventDefault so the native target can never open a new tab.
650
- //
651
- // - If partner.js has bound the button, its own click handler already ran (in
652
- // the capture/target phase, before this React bubble handler) and opened the
653
- // mini-browser AND wired its postMessage->onboardingCallback bridge — which
654
- // auto-closes the popup and saves credentials. We must NOT open a second
655
- // window, so we just return.
656
- // - Otherwise (cold first click before partner.js finished initializing) we
657
- // open the popup ourselves so the click still works. Completion is then
658
- // handled by our own postMessage listener, and also by partner.js's bridge
659
- // once it finishes initializing a moment later.
660
- const handleConnectClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
661
- e.preventDefault()
662
-
663
- if (
664
- connStateRef.current !== "ready" ||
665
- !finalUrlRef.current ||
666
- inProgressRef.current
667
- ) {
668
- return
669
- }
670
-
671
- if (partnerBoundRef.current) {
672
- // partner.js owns this click (popup + auto-close + callback bridge).
673
- return
674
- }
675
-
676
- completedRef.current = false
677
- const popup = openOnboardingPopup(finalUrlRef.current)
678
- setPopupBlocked(!popup)
679
- }
680
-
681
498
  const handleSaveManual = async () => {
682
499
  if (!canSaveManual || onboardingInProgress) return
683
500
  setOnboardingInProgress(true)
@@ -746,11 +563,12 @@ export default function PayPalConnectionPage() {
746
563
  }
747
564
 
748
565
  clearCachedUrl(env)
749
-
750
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.
751
570
  currentRunId.current = ++runIdRef.current
752
- const runId = currentRunId.current
753
- fetchFreshLink(runId, env)
571
+ await generateLinkAndReload(env, currentRunId.current)
754
572
  } catch (e: any) {
755
573
  console.error(e)
756
574
  setConnState("error")
@@ -762,18 +580,14 @@ export default function PayPalConnectionPage() {
762
580
 
763
581
  const handleEnvChange = async (e: React.ChangeEvent<HTMLSelectElement>) => {
764
582
  const next = e.target.value as Env
583
+ if (next === env || onboardingInProgress) return
765
584
 
766
585
  completedRef.current = false
767
- setPopupBlocked(false)
768
- // Drop both cached URLs so neither environment can serve a stale link.
769
586
  clearCachedUrl()
587
+ setConnState("loading")
770
588
 
771
- // Update local state immediately (drives the status/link refetch with the
772
- // explicit environment) and persist the choice. Because the link/status
773
- // fetches are environment-explicit, correctness no longer depends on this
774
- // POST winning a race.
775
- setEnv(next)
776
-
589
+ // Persist the environment BEFORE switching local state so the post-reload
590
+ // GET /environment returns the same value the cache was keyed under.
777
591
  try {
778
592
  await fetch(ENVIRONMENT_ENDPOINT, {
779
593
  method: "POST",
@@ -782,6 +596,8 @@ export default function PayPalConnectionPage() {
782
596
  body: JSON.stringify({ environment: next }),
783
597
  })
784
598
  } catch {}
599
+
600
+ setEnv(next)
785
601
  }
786
602
 
787
603
  return (
@@ -860,9 +676,12 @@ export default function PayPalConnectionPage() {
860
676
  </div>
861
677
 
862
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. */}
863
683
  <a
864
684
  ref={(node) => {
865
- paypalButtonRef.current = node
866
685
  ppBtnMeasureRef.current = node
867
686
  }}
868
687
  id="paypal-button"
@@ -870,24 +689,26 @@ export default function PayPalConnectionPage() {
870
689
  data-paypal-button="true"
871
690
  href={finalUrl || "#"}
872
691
  data-paypal-onboard-complete="onboardingCallback"
873
- onClick={handleConnectClick}
692
+ aria-disabled={onboardingInProgress || !partnerReady}
874
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"
875
694
  style={{
876
- cursor: onboardingInProgress ? "not-allowed" : "pointer",
877
- opacity: onboardingInProgress ? 0.6 : 1,
878
- 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",
879
705
  }}
880
706
  >
881
- Connect to PayPal
707
+ {partnerReady || onboardingInProgress
708
+ ? "Connect to PayPal"
709
+ : "Preparing PayPal…"}
882
710
  </a>
883
711
 
884
- {popupBlocked && (
885
- <div className="mt-3 text-left text-xs bg-amber-50 text-amber-700 p-3 border border-amber-200 rounded">
886
- Your browser blocked the PayPal popup. Please allow popups
887
- for this site, then click “Connect to PayPal” again.
888
- </div>
889
- )}
890
-
891
712
  <div
892
713
  className="mt-2"
893
714
  style={{