@easypayment/medusa-payment-paypal 0.9.18 → 0.9.20
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.
- package/.medusa/server/src/admin/index.js +270 -114
- package/.medusa/server/src/admin/index.mjs +270 -114
- package/.medusa/server/src/admin/routes/settings/paypal/connection/page.d.ts.map +1 -1
- package/.medusa/server/src/admin/routes/settings/paypal/connection/page.js +384 -126
- package/.medusa/server/src/admin/routes/settings/paypal/connection/page.js.map +1 -1
- package/.medusa/server/src/api/admin/paypal/onboarding-link/route.d.ts.map +1 -1
- package/.medusa/server/src/api/admin/paypal/onboarding-link/route.js +4 -0
- package/.medusa/server/src/api/admin/paypal/onboarding-link/route.js.map +1 -1
- package/.medusa/server/src/modules/paypal/service.d.ts +1 -0
- package/.medusa/server/src/modules/paypal/service.d.ts.map +1 -1
- package/.medusa/server/src/modules/paypal/service.js +5 -1
- package/.medusa/server/src/modules/paypal/service.js.map +1 -1
- package/package.json +1 -1
- package/src/admin/routes/settings/paypal/connection/page.tsx +420 -131
- package/src/api/admin/paypal/onboarding-link/route.ts +6 -0
- package/src/modules/paypal/service.ts +7 -2
|
@@ -36,47 +36,145 @@ declare global {
|
|
|
36
36
|
|
|
37
37
|
|
|
38
38
|
const SERVICE_URL = "/admin/paypal/onboarding-link"
|
|
39
|
-
|
|
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"
|
|
40
45
|
const CACHE_EXPIRY = 10 * 60 * 1000
|
|
41
46
|
|
|
42
47
|
const ONBOARDING_COMPLETE_ENDPOINT = "/admin/paypal/onboard-complete"
|
|
43
48
|
const STATUS_ENDPOINT = "/admin/paypal/status"
|
|
44
49
|
const SAVE_CREDENTIALS_ENDPOINT = "/admin/paypal/save-credentials"
|
|
45
50
|
const DISCONNECT_ENDPOINT = "/admin/paypal/disconnect"
|
|
51
|
+
const ENVIRONMENT_ENDPOINT = "/admin/paypal/environment"
|
|
46
52
|
|
|
47
|
-
|
|
48
|
-
|
|
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) {
|
|
49
80
|
try {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
|
55
135
|
}
|
|
56
136
|
}
|
|
57
|
-
|
|
58
|
-
console.error("Cache read error:", e)
|
|
137
|
+
return {}
|
|
59
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 {}
|
|
60
163
|
}
|
|
61
164
|
|
|
62
165
|
|
|
63
166
|
export default function PayPalConnectionPage() {
|
|
64
|
-
const [env, setEnv] = useState<
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
.then((d) => {
|
|
70
|
-
const v = d?.environment === "sandbox" ? "sandbox" : "live"
|
|
71
|
-
setEnv(v)
|
|
72
|
-
})
|
|
73
|
-
.catch(() => {})
|
|
74
|
-
}, [])
|
|
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)
|
|
75
172
|
|
|
76
173
|
const [connState, setConnState] = useState<
|
|
77
174
|
"loading" | "ready" | "connected" | "error"
|
|
78
175
|
>("loading")
|
|
79
176
|
const [error, setError] = useState<string | null>(null)
|
|
177
|
+
const [popupBlocked, setPopupBlocked] = useState(false)
|
|
80
178
|
const [finalUrl, setFinalUrl] = useState<string>("")
|
|
81
179
|
const [showManual, setShowManual] = useState(false)
|
|
82
180
|
const [clientId, setClientId] = useState("")
|
|
@@ -95,6 +193,29 @@ export default function PayPalConnectionPage() {
|
|
|
95
193
|
const runIdRef = useRef(0)
|
|
96
194
|
const currentRunId = useRef(0)
|
|
97
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
|
+
// 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
|
+
|
|
98
219
|
const ppBtnMeasureRef = useRef<HTMLAnchorElement | null>(null)
|
|
99
220
|
const [ppBtnWidth, setPpBtnWidth] = useState<number | null>(null)
|
|
100
221
|
|
|
@@ -107,8 +228,149 @@ export default function PayPalConnectionPage() {
|
|
|
107
228
|
setError(msg)
|
|
108
229
|
}, [])
|
|
109
230
|
|
|
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 */
|
|
273
|
+
}
|
|
274
|
+
stopPopupPoll()
|
|
275
|
+
popupPollRef.current = setInterval(() => {
|
|
276
|
+
if (!popupRef.current || popupRef.current.closed) {
|
|
277
|
+
stopPopupPoll()
|
|
278
|
+
}
|
|
279
|
+
}, 1000)
|
|
280
|
+
}
|
|
281
|
+
|
|
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
|
+
try {
|
|
310
|
+
const res = await fetch(ONBOARDING_COMPLETE_ENDPOINT, {
|
|
311
|
+
method: "POST",
|
|
312
|
+
headers: { "content-type": "application/json" },
|
|
313
|
+
credentials: "include",
|
|
314
|
+
body: JSON.stringify({ authCode, sharedId, env: activeEnv }),
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
if (!res.ok) {
|
|
318
|
+
const txt = await res.text().catch(() => "")
|
|
319
|
+
throw new Error(txt || `Onboarding exchange failed (${res.status})`)
|
|
320
|
+
}
|
|
321
|
+
|
|
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)
|
|
345
|
+
|
|
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 */
|
|
355
|
+
}
|
|
356
|
+
|
|
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
|
+
|
|
110
372
|
const fetchFreshLink = useCallback(
|
|
111
|
-
(runId: number) => {
|
|
373
|
+
(runId: number, targetEnv: Env) => {
|
|
112
374
|
if (initLoaderRef.current) {
|
|
113
375
|
const loaderText = initLoaderRef.current.querySelector("#loader-text")
|
|
114
376
|
if (loaderText)
|
|
@@ -121,6 +383,9 @@ export default function PayPalConnectionPage() {
|
|
|
121
383
|
credentials: "include",
|
|
122
384
|
body: JSON.stringify({
|
|
123
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,
|
|
124
389
|
}),
|
|
125
390
|
})
|
|
126
391
|
.then((r) => {
|
|
@@ -139,10 +404,7 @@ export default function PayPalConnectionPage() {
|
|
|
139
404
|
const url =
|
|
140
405
|
href + (href.includes("?") ? "&" : "?") + "displayMode=minibrowser"
|
|
141
406
|
|
|
142
|
-
|
|
143
|
-
CACHE_KEY,
|
|
144
|
-
JSON.stringify({ url, ts: Date.now() })
|
|
145
|
-
)
|
|
407
|
+
writeCachedUrl(targetEnv, url)
|
|
146
408
|
|
|
147
409
|
setFinalUrl(url)
|
|
148
410
|
setConnState("ready")
|
|
@@ -152,9 +414,35 @@ export default function PayPalConnectionPage() {
|
|
|
152
414
|
showError("Unable to connect to service.")
|
|
153
415
|
})
|
|
154
416
|
},
|
|
155
|
-
[
|
|
417
|
+
[showError]
|
|
156
418
|
)
|
|
157
419
|
|
|
420
|
+
// Resolve the server's current environment before doing anything else.
|
|
421
|
+
useEffect(() => {
|
|
422
|
+
let alive = true
|
|
423
|
+
fetch(ENVIRONMENT_ENDPOINT, { method: "GET", credentials: "include" })
|
|
424
|
+
.then((r) => r.json())
|
|
425
|
+
.then((d) => {
|
|
426
|
+
if (!alive) return
|
|
427
|
+
setEnv(d?.environment === "sandbox" ? "sandbox" : "live")
|
|
428
|
+
})
|
|
429
|
+
.catch(() => {})
|
|
430
|
+
.finally(() => {
|
|
431
|
+
if (alive) setEnvReady(true)
|
|
432
|
+
})
|
|
433
|
+
return () => {
|
|
434
|
+
alive = false
|
|
435
|
+
}
|
|
436
|
+
}, [])
|
|
437
|
+
|
|
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.
|
|
158
446
|
useEffect(() => {
|
|
159
447
|
if (connState !== "ready" || !finalUrl) return
|
|
160
448
|
|
|
@@ -162,15 +450,6 @@ export default function PayPalConnectionPage() {
|
|
|
162
450
|
let cancelled = false
|
|
163
451
|
let pollTimer: ReturnType<typeof setTimeout> | undefined
|
|
164
452
|
|
|
165
|
-
// PayPal's partner.js wires the mini-browser handler to its own
|
|
166
|
-
// DOMContentLoaded listener. Inside the Medusa admin (a React SPA) that
|
|
167
|
-
// event fired long before this route mounted, so partner.js never scans the
|
|
168
|
-
// button and the click falls through to the plain target="_blank" anchor —
|
|
169
|
-
// which opens PayPal onboarding in a NEW TAB instead of the popup.
|
|
170
|
-
//
|
|
171
|
-
// To fix this we load partner.js only after the anchor exists with its real
|
|
172
|
-
// href, then explicitly run PayPal's initializer so it discovers the button
|
|
173
|
-
// and intercepts the click into the mini-browser popup.
|
|
174
453
|
const initPartner = (attempt = 0) => {
|
|
175
454
|
if (cancelled) return
|
|
176
455
|
|
|
@@ -185,6 +464,10 @@ export default function PayPalConnectionPage() {
|
|
|
185
464
|
if (target?.init) {
|
|
186
465
|
try {
|
|
187
466
|
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
|
|
188
471
|
} catch (e) {
|
|
189
472
|
console.error("[paypal] partner.js init failed:", e)
|
|
190
473
|
}
|
|
@@ -194,29 +477,29 @@ export default function PayPalConnectionPage() {
|
|
|
194
477
|
if (typeof signup?.render === "function") {
|
|
195
478
|
try {
|
|
196
479
|
signup.render()
|
|
480
|
+
partnerBoundRef.current = true
|
|
197
481
|
} catch (e) {
|
|
198
482
|
console.error("[paypal] partner.js render failed:", e)
|
|
199
483
|
}
|
|
200
484
|
return
|
|
201
485
|
}
|
|
202
486
|
|
|
203
|
-
// The namespace may not be populated the instant onload fires; retry
|
|
204
|
-
// briefly before giving up.
|
|
205
487
|
if (attempt < 40) {
|
|
206
488
|
pollTimer = setTimeout(() => initPartner(attempt + 1), 50)
|
|
207
489
|
return
|
|
208
490
|
}
|
|
209
491
|
|
|
210
|
-
// Last resort: partner.js exposed no initializer we recognise. Re-fire
|
|
211
|
-
// DOMContentLoaded so any listener it registered runs against the
|
|
212
|
-
// now-present button.
|
|
213
492
|
try {
|
|
214
493
|
document.dispatchEvent(new Event("DOMContentLoaded"))
|
|
215
494
|
} catch {}
|
|
216
495
|
}
|
|
217
496
|
|
|
218
|
-
//
|
|
219
|
-
//
|
|
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
|
+
// Remove any stale copy + namespace so the fresh load re-registers against
|
|
502
|
+
// the current environment (sandbox vs live partner.js differ).
|
|
220
503
|
const existingScript = document.getElementById("paypal-partner-js")
|
|
221
504
|
if (existingScript) {
|
|
222
505
|
existingScript.parentNode?.removeChild(existingScript)
|
|
@@ -245,15 +528,37 @@ export default function PayPalConnectionPage() {
|
|
|
245
528
|
}
|
|
246
529
|
}, [connState, finalUrl, env])
|
|
247
530
|
|
|
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.
|
|
248
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
|
+
|
|
249
552
|
currentRunId.current = ++runIdRef.current
|
|
250
553
|
const runId = currentRunId.current
|
|
251
554
|
|
|
252
555
|
let cancelled = false
|
|
556
|
+
completedRef.current = false
|
|
253
557
|
|
|
254
558
|
const run = async () => {
|
|
255
559
|
setConnState("loading")
|
|
256
560
|
setError(null)
|
|
561
|
+
setPopupBlocked(false)
|
|
257
562
|
setFinalUrl("")
|
|
258
563
|
|
|
259
564
|
try {
|
|
@@ -279,11 +584,14 @@ export default function PayPalConnectionPage() {
|
|
|
279
584
|
console.error(e)
|
|
280
585
|
}
|
|
281
586
|
|
|
282
|
-
if (
|
|
283
|
-
|
|
587
|
+
if (cancelled || runId !== currentRunId.current) return
|
|
588
|
+
|
|
589
|
+
const cached = readCachedUrl(env)
|
|
590
|
+
if (cached) {
|
|
591
|
+
setFinalUrl(cached)
|
|
284
592
|
setConnState("ready")
|
|
285
593
|
} else {
|
|
286
|
-
fetchFreshLink(runId)
|
|
594
|
+
fetchFreshLink(runId, env)
|
|
287
595
|
}
|
|
288
596
|
}
|
|
289
597
|
|
|
@@ -291,80 +599,25 @@ export default function PayPalConnectionPage() {
|
|
|
291
599
|
|
|
292
600
|
return () => {
|
|
293
601
|
cancelled = true
|
|
294
|
-
currentRunId.current = 0
|
|
295
602
|
}
|
|
296
|
-
}, [env, fetchFreshLink])
|
|
603
|
+
}, [env, envReady, fetchFreshLink])
|
|
297
604
|
|
|
605
|
+
// Bridge partner.js's callback into our single idempotent completion handler.
|
|
298
606
|
useLayoutEffect(() => {
|
|
299
|
-
window.onboardingCallback =
|
|
300
|
-
|
|
301
|
-
window.onbeforeunload = null
|
|
302
|
-
} catch {}
|
|
303
|
-
|
|
304
|
-
setOnboardingInProgress(true)
|
|
305
|
-
setConnState("loading")
|
|
306
|
-
setError(null)
|
|
307
|
-
|
|
308
|
-
const payload = {
|
|
309
|
-
authCode,
|
|
310
|
-
sharedId,
|
|
311
|
-
env: env === "sandbox" ? "sandbox" : "live",
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
try {
|
|
315
|
-
const res = await fetch(ONBOARDING_COMPLETE_ENDPOINT, {
|
|
316
|
-
method: "POST",
|
|
317
|
-
headers: { "content-type": "application/json" },
|
|
318
|
-
credentials: "include",
|
|
319
|
-
body: JSON.stringify(payload),
|
|
320
|
-
})
|
|
321
|
-
|
|
322
|
-
if (!res.ok) {
|
|
323
|
-
const txt = await res.text().catch(() => "")
|
|
324
|
-
throw new Error(txt || `Onboarding exchange failed (${res.status})`)
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
try {
|
|
328
|
-
const close1 = window.PAYPAL?.apps?.Signup?.MiniBrowser?.closeFlow
|
|
329
|
-
if (typeof close1 === "function") close1()
|
|
330
|
-
} catch {}
|
|
331
|
-
try {
|
|
332
|
-
const close2 =
|
|
333
|
-
window.PAYPAL?.apps?.Signup?.miniBrowser &&
|
|
334
|
-
(window.PAYPAL.apps.Signup.miniBrowser as any).closeFlow
|
|
335
|
-
if (typeof close2 === "function") close2()
|
|
336
|
-
} catch {}
|
|
337
|
-
|
|
338
|
-
try {
|
|
339
|
-
localStorage.removeItem(CACHE_KEY)
|
|
340
|
-
} catch {}
|
|
341
|
-
|
|
342
|
-
try {
|
|
343
|
-
const statusRes = await fetch(`${STATUS_ENDPOINT}?environment=${env}`, {
|
|
344
|
-
method: "GET",
|
|
345
|
-
credentials: "include",
|
|
346
|
-
})
|
|
347
|
-
const refreshedStatus = await statusRes.json().catch(() => ({}))
|
|
348
|
-
setStatusInfo(refreshedStatus || null)
|
|
349
|
-
setConnState("connected")
|
|
350
|
-
setShowManual(false)
|
|
351
|
-
} catch {
|
|
352
|
-
setConnState("connected")
|
|
353
|
-
setShowManual(false)
|
|
354
|
-
}
|
|
355
|
-
setOnboardingInProgress(false)
|
|
356
|
-
} catch (e: any) {
|
|
357
|
-
console.error(e)
|
|
358
|
-
setConnState("error")
|
|
359
|
-
setError(e?.message || "Exchange failed while saving credentials.")
|
|
360
|
-
setOnboardingInProgress(false)
|
|
361
|
-
}
|
|
607
|
+
window.onboardingCallback = (authCode: string, sharedId: string) => {
|
|
608
|
+
completeOnboarding(authCode, sharedId)
|
|
362
609
|
}
|
|
363
|
-
|
|
364
610
|
return () => {
|
|
365
611
|
window.onboardingCallback = undefined
|
|
366
612
|
}
|
|
367
|
-
}, [
|
|
613
|
+
}, [completeOnboarding])
|
|
614
|
+
|
|
615
|
+
// Clean up the popup poll on unmount.
|
|
616
|
+
useEffect(() => {
|
|
617
|
+
return () => {
|
|
618
|
+
stopPopupPoll()
|
|
619
|
+
}
|
|
620
|
+
}, [stopPopupPoll])
|
|
368
621
|
|
|
369
622
|
useLayoutEffect(() => {
|
|
370
623
|
const el = ppBtnMeasureRef.current
|
|
@@ -391,10 +644,38 @@ export default function PayPalConnectionPage() {
|
|
|
391
644
|
}
|
|
392
645
|
}, [connState, env, finalUrl])
|
|
393
646
|
|
|
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.
|
|
394
660
|
const handleConnectClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
|
|
395
|
-
|
|
396
|
-
|
|
661
|
+
e.preventDefault()
|
|
662
|
+
|
|
663
|
+
if (
|
|
664
|
+
connStateRef.current !== "ready" ||
|
|
665
|
+
!finalUrlRef.current ||
|
|
666
|
+
inProgressRef.current
|
|
667
|
+
) {
|
|
668
|
+
return
|
|
397
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)
|
|
398
679
|
}
|
|
399
680
|
|
|
400
681
|
const handleSaveManual = async () => {
|
|
@@ -430,9 +711,7 @@ export default function PayPalConnectionPage() {
|
|
|
430
711
|
setStatusInfo(refreshedStatus || null)
|
|
431
712
|
setShowManual(false)
|
|
432
713
|
|
|
433
|
-
|
|
434
|
-
localStorage.removeItem(CACHE_KEY)
|
|
435
|
-
} catch {}
|
|
714
|
+
clearCachedUrl(env)
|
|
436
715
|
} catch (e: any) {
|
|
437
716
|
console.error(e)
|
|
438
717
|
setConnState("error")
|
|
@@ -466,14 +745,12 @@ export default function PayPalConnectionPage() {
|
|
|
466
745
|
throw new Error(t || `Disconnect failed (${res.status})`)
|
|
467
746
|
}
|
|
468
747
|
|
|
469
|
-
|
|
470
|
-
localStorage.removeItem(CACHE_KEY)
|
|
471
|
-
|
|
472
|
-
} catch {}
|
|
748
|
+
clearCachedUrl(env)
|
|
473
749
|
|
|
750
|
+
completedRef.current = false
|
|
474
751
|
currentRunId.current = ++runIdRef.current
|
|
475
752
|
const runId = currentRunId.current
|
|
476
|
-
fetchFreshLink(runId)
|
|
753
|
+
fetchFreshLink(runId, env)
|
|
477
754
|
} catch (e: any) {
|
|
478
755
|
console.error(e)
|
|
479
756
|
setConnState("error")
|
|
@@ -484,22 +761,27 @@ export default function PayPalConnectionPage() {
|
|
|
484
761
|
}
|
|
485
762
|
|
|
486
763
|
const handleEnvChange = async (e: React.ChangeEvent<HTMLSelectElement>) => {
|
|
487
|
-
const next = e.target.value as
|
|
764
|
+
const next = e.target.value as Env
|
|
765
|
+
|
|
766
|
+
completedRef.current = false
|
|
767
|
+
setPopupBlocked(false)
|
|
768
|
+
// Drop both cached URLs so neither environment can serve a stale link.
|
|
769
|
+
clearCachedUrl()
|
|
770
|
+
|
|
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.
|
|
488
775
|
setEnv(next)
|
|
489
|
-
cachedUrl = null
|
|
490
776
|
|
|
491
777
|
try {
|
|
492
|
-
await fetch(
|
|
778
|
+
await fetch(ENVIRONMENT_ENDPOINT, {
|
|
493
779
|
method: "POST",
|
|
494
780
|
headers: { "content-type": "application/json" },
|
|
495
781
|
credentials: "include",
|
|
496
782
|
body: JSON.stringify({ environment: next }),
|
|
497
783
|
})
|
|
498
784
|
} catch {}
|
|
499
|
-
|
|
500
|
-
try {
|
|
501
|
-
localStorage.removeItem(CACHE_KEY)
|
|
502
|
-
} catch {}
|
|
503
785
|
}
|
|
504
786
|
|
|
505
787
|
return (
|
|
@@ -584,7 +866,7 @@ export default function PayPalConnectionPage() {
|
|
|
584
866
|
ppBtnMeasureRef.current = node
|
|
585
867
|
}}
|
|
586
868
|
id="paypal-button"
|
|
587
|
-
target=
|
|
869
|
+
target={POPUP_NAME}
|
|
588
870
|
data-paypal-button="true"
|
|
589
871
|
href={finalUrl || "#"}
|
|
590
872
|
data-paypal-onboard-complete="onboardingCallback"
|
|
@@ -599,6 +881,13 @@ export default function PayPalConnectionPage() {
|
|
|
599
881
|
Connect to PayPal
|
|
600
882
|
</a>
|
|
601
883
|
|
|
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
|
+
|
|
602
891
|
<div
|
|
603
892
|
className="mt-2"
|
|
604
893
|
style={{
|