@easypayment/medusa-payment-paypal 0.9.18 → 0.9.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.medusa/server/src/admin/index.js +287 -118
- package/.medusa/server/src/admin/index.mjs +287 -118
- 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 +386 -127
- 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 +419 -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
|
|
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 */
|
|
55
158
|
}
|
|
56
159
|
}
|
|
57
|
-
} catch (e) {
|
|
58
|
-
console.error("Cache read error:", e)
|
|
59
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,23 @@ 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
|
+
|
|
98
213
|
const ppBtnMeasureRef = useRef<HTMLAnchorElement | null>(null)
|
|
99
214
|
const [ppBtnWidth, setPpBtnWidth] = useState<number | null>(null)
|
|
100
215
|
|
|
@@ -107,8 +222,168 @@ export default function PayPalConnectionPage() {
|
|
|
107
222
|
setError(msg)
|
|
108
223
|
}, [])
|
|
109
224
|
|
|
225
|
+
const stopPopupPoll = useCallback(() => {
|
|
226
|
+
if (popupPollRef.current) {
|
|
227
|
+
clearInterval(popupPollRef.current)
|
|
228
|
+
popupPollRef.current = null
|
|
229
|
+
}
|
|
230
|
+
}, [])
|
|
231
|
+
|
|
232
|
+
// Open the PayPal mini-browser ourselves, synchronously, inside the click
|
|
233
|
+
// gesture. This is the crux of the fix: browsers only render window.open() as
|
|
234
|
+
// a real popup (instead of a tab — or blocking it) when it runs synchronously
|
|
235
|
+
// in a user gesture. Because finalUrl is already in state at click time, we
|
|
236
|
+
// never need an await before opening, and we never depend on winning a race
|
|
237
|
+
// with partner.js's asynchronous click binding.
|
|
238
|
+
const openOnboardingPopup = useCallback(
|
|
239
|
+
(url: string): Window | null => {
|
|
240
|
+
const w = 450
|
|
241
|
+
const h = 600
|
|
242
|
+
const baseLeft =
|
|
243
|
+
typeof window.screenX === "number" ? window.screenX : (window as any).screenLeft || 0
|
|
244
|
+
const baseTop =
|
|
245
|
+
typeof window.screenY === "number" ? window.screenY : (window as any).screenTop || 0
|
|
246
|
+
const outerW =
|
|
247
|
+
window.outerWidth || document.documentElement.clientWidth || 1024
|
|
248
|
+
const outerH =
|
|
249
|
+
window.outerHeight || document.documentElement.clientHeight || 768
|
|
250
|
+
const left = Math.round(baseLeft + Math.max(0, (outerW - w) / 2))
|
|
251
|
+
const top = Math.round(baseTop + Math.max(0, (outerH - h) / 2))
|
|
252
|
+
const features = `popup=yes,width=${w},height=${h},left=${left},top=${top},scrollbars=yes,resizable=yes`
|
|
253
|
+
|
|
254
|
+
let popup: Window | null = null
|
|
255
|
+
try {
|
|
256
|
+
popup = window.open(url, POPUP_NAME, features)
|
|
257
|
+
} catch {
|
|
258
|
+
popup = null
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (popup) {
|
|
262
|
+
popupRef.current = popup
|
|
263
|
+
try {
|
|
264
|
+
popup.focus()
|
|
265
|
+
} catch {
|
|
266
|
+
/* ignore */
|
|
267
|
+
}
|
|
268
|
+
stopPopupPoll()
|
|
269
|
+
popupPollRef.current = setInterval(() => {
|
|
270
|
+
if (!popupRef.current || popupRef.current.closed) {
|
|
271
|
+
stopPopupPoll()
|
|
272
|
+
}
|
|
273
|
+
}, 1000)
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return popup
|
|
277
|
+
},
|
|
278
|
+
[stopPopupPoll]
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
// Single entry point for "Connect to PayPal". Hard-guards on a real, ready
|
|
282
|
+
// URL so a half-loaded button can never open anything.
|
|
283
|
+
const openConnect = useCallback(() => {
|
|
284
|
+
if (
|
|
285
|
+
connStateRef.current !== "ready" ||
|
|
286
|
+
!finalUrlRef.current ||
|
|
287
|
+
inProgressRef.current
|
|
288
|
+
) {
|
|
289
|
+
return
|
|
290
|
+
}
|
|
291
|
+
completedRef.current = false
|
|
292
|
+
const popup = openOnboardingPopup(finalUrlRef.current)
|
|
293
|
+
if (!popup) {
|
|
294
|
+
setPopupBlocked(true)
|
|
295
|
+
return
|
|
296
|
+
}
|
|
297
|
+
setPopupBlocked(false)
|
|
298
|
+
}, [openOnboardingPopup])
|
|
299
|
+
|
|
300
|
+
// Exchange the seller authCode/sharedId for credentials. Reached from BOTH
|
|
301
|
+
// PayPal's partner.js bridge (window.onboardingCallback) and our own postMessage
|
|
302
|
+
// listener; completedRef makes it idempotent so we only POST once.
|
|
303
|
+
const completeOnboarding = useCallback(
|
|
304
|
+
async (authCode: string, sharedId: string) => {
|
|
305
|
+
if (!authCode || !sharedId) return
|
|
306
|
+
if (completedRef.current) return
|
|
307
|
+
completedRef.current = true
|
|
308
|
+
|
|
309
|
+
try {
|
|
310
|
+
window.onbeforeunload = null
|
|
311
|
+
} catch {
|
|
312
|
+
/* ignore */
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const activeEnv: Env = envRef.current === "sandbox" ? "sandbox" : "live"
|
|
316
|
+
|
|
317
|
+
setOnboardingInProgress(true)
|
|
318
|
+
setConnState("loading")
|
|
319
|
+
setError(null)
|
|
320
|
+
setPopupBlocked(false)
|
|
321
|
+
|
|
322
|
+
try {
|
|
323
|
+
const res = await fetch(ONBOARDING_COMPLETE_ENDPOINT, {
|
|
324
|
+
method: "POST",
|
|
325
|
+
headers: { "content-type": "application/json" },
|
|
326
|
+
credentials: "include",
|
|
327
|
+
body: JSON.stringify({ authCode, sharedId, env: activeEnv }),
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
if (!res.ok) {
|
|
331
|
+
const txt = await res.text().catch(() => "")
|
|
332
|
+
throw new Error(txt || `Onboarding exchange failed (${res.status})`)
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Close the popup we opened, plus any partner.js-managed flow.
|
|
336
|
+
try {
|
|
337
|
+
popupRef.current?.close()
|
|
338
|
+
} catch {
|
|
339
|
+
/* ignore */
|
|
340
|
+
}
|
|
341
|
+
stopPopupPoll()
|
|
342
|
+
try {
|
|
343
|
+
const close1 = window.PAYPAL?.apps?.Signup?.MiniBrowser?.closeFlow
|
|
344
|
+
if (typeof close1 === "function") close1()
|
|
345
|
+
} catch {
|
|
346
|
+
/* ignore */
|
|
347
|
+
}
|
|
348
|
+
try {
|
|
349
|
+
const close2 =
|
|
350
|
+
window.PAYPAL?.apps?.Signup?.miniBrowser &&
|
|
351
|
+
(window.PAYPAL.apps.Signup.miniBrowser as any).closeFlow
|
|
352
|
+
if (typeof close2 === "function") close2()
|
|
353
|
+
} catch {
|
|
354
|
+
/* ignore */
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
clearCachedUrl(activeEnv)
|
|
358
|
+
|
|
359
|
+
try {
|
|
360
|
+
const statusRes = await fetch(
|
|
361
|
+
`${STATUS_ENDPOINT}?environment=${activeEnv}`,
|
|
362
|
+
{ method: "GET", credentials: "include" }
|
|
363
|
+
)
|
|
364
|
+
const refreshedStatus = await statusRes.json().catch(() => ({}))
|
|
365
|
+
setStatusInfo(refreshedStatus || null)
|
|
366
|
+
} catch {
|
|
367
|
+
/* ignore — still consider it connected below */
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
setConnState("connected")
|
|
371
|
+
setShowManual(false)
|
|
372
|
+
} catch (e: any) {
|
|
373
|
+
// Allow a retry after a failed exchange.
|
|
374
|
+
completedRef.current = false
|
|
375
|
+
console.error(e)
|
|
376
|
+
setConnState("error")
|
|
377
|
+
setError(e?.message || "Exchange failed while saving credentials.")
|
|
378
|
+
} finally {
|
|
379
|
+
setOnboardingInProgress(false)
|
|
380
|
+
}
|
|
381
|
+
},
|
|
382
|
+
[stopPopupPoll]
|
|
383
|
+
)
|
|
384
|
+
|
|
110
385
|
const fetchFreshLink = useCallback(
|
|
111
|
-
(runId: number) => {
|
|
386
|
+
(runId: number, targetEnv: Env) => {
|
|
112
387
|
if (initLoaderRef.current) {
|
|
113
388
|
const loaderText = initLoaderRef.current.querySelector("#loader-text")
|
|
114
389
|
if (loaderText)
|
|
@@ -121,6 +396,9 @@ export default function PayPalConnectionPage() {
|
|
|
121
396
|
credentials: "include",
|
|
122
397
|
body: JSON.stringify({
|
|
123
398
|
products: ["PPCP"],
|
|
399
|
+
// Generate the link for the explicitly selected environment so it can
|
|
400
|
+
// never depend on a racing POST /environment having landed yet.
|
|
401
|
+
environment: targetEnv,
|
|
124
402
|
}),
|
|
125
403
|
})
|
|
126
404
|
.then((r) => {
|
|
@@ -139,10 +417,7 @@ export default function PayPalConnectionPage() {
|
|
|
139
417
|
const url =
|
|
140
418
|
href + (href.includes("?") ? "&" : "?") + "displayMode=minibrowser"
|
|
141
419
|
|
|
142
|
-
|
|
143
|
-
CACHE_KEY,
|
|
144
|
-
JSON.stringify({ url, ts: Date.now() })
|
|
145
|
-
)
|
|
420
|
+
writeCachedUrl(targetEnv, url)
|
|
146
421
|
|
|
147
422
|
setFinalUrl(url)
|
|
148
423
|
setConnState("ready")
|
|
@@ -152,9 +427,35 @@ export default function PayPalConnectionPage() {
|
|
|
152
427
|
showError("Unable to connect to service.")
|
|
153
428
|
})
|
|
154
429
|
},
|
|
155
|
-
[
|
|
430
|
+
[showError]
|
|
156
431
|
)
|
|
157
432
|
|
|
433
|
+
// Resolve the server's current environment before doing anything else.
|
|
434
|
+
useEffect(() => {
|
|
435
|
+
let alive = true
|
|
436
|
+
fetch(ENVIRONMENT_ENDPOINT, { method: "GET", credentials: "include" })
|
|
437
|
+
.then((r) => r.json())
|
|
438
|
+
.then((d) => {
|
|
439
|
+
if (!alive) return
|
|
440
|
+
setEnv(d?.environment === "sandbox" ? "sandbox" : "live")
|
|
441
|
+
})
|
|
442
|
+
.catch(() => {})
|
|
443
|
+
.finally(() => {
|
|
444
|
+
if (alive) setEnvReady(true)
|
|
445
|
+
})
|
|
446
|
+
return () => {
|
|
447
|
+
alive = false
|
|
448
|
+
}
|
|
449
|
+
}, [])
|
|
450
|
+
|
|
451
|
+
// partner.js is loaded ONLY for its postMessage->callback bridge
|
|
452
|
+
// (window.onboardingCallback), as a redundant path next to our own message
|
|
453
|
+
// listener below. It is NO LONGER responsible for opening the window — we do
|
|
454
|
+
// that synchronously in the click handler. We still init it so its message
|
|
455
|
+
// bridge registers inside this React SPA (its own DOMContentLoaded hook fired
|
|
456
|
+
// long before this route mounted). If partner.js also intercepts the click,
|
|
457
|
+
// the shared POPUP_NAME means it re-targets our single popup rather than
|
|
458
|
+
// opening a second window.
|
|
158
459
|
useEffect(() => {
|
|
159
460
|
if (connState !== "ready" || !finalUrl) return
|
|
160
461
|
|
|
@@ -162,15 +463,6 @@ export default function PayPalConnectionPage() {
|
|
|
162
463
|
let cancelled = false
|
|
163
464
|
let pollTimer: ReturnType<typeof setTimeout> | undefined
|
|
164
465
|
|
|
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
466
|
const initPartner = (attempt = 0) => {
|
|
175
467
|
if (cancelled) return
|
|
176
468
|
|
|
@@ -200,23 +492,18 @@ export default function PayPalConnectionPage() {
|
|
|
200
492
|
return
|
|
201
493
|
}
|
|
202
494
|
|
|
203
|
-
// The namespace may not be populated the instant onload fires; retry
|
|
204
|
-
// briefly before giving up.
|
|
205
495
|
if (attempt < 40) {
|
|
206
496
|
pollTimer = setTimeout(() => initPartner(attempt + 1), 50)
|
|
207
497
|
return
|
|
208
498
|
}
|
|
209
499
|
|
|
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
500
|
try {
|
|
214
501
|
document.dispatchEvent(new Event("DOMContentLoaded"))
|
|
215
502
|
} catch {}
|
|
216
503
|
}
|
|
217
504
|
|
|
218
|
-
// Remove any stale copy + namespace so the fresh load re-
|
|
219
|
-
//
|
|
505
|
+
// Remove any stale copy + namespace so the fresh load re-registers against
|
|
506
|
+
// the current environment (sandbox vs live partner.js differ).
|
|
220
507
|
const existingScript = document.getElementById("paypal-partner-js")
|
|
221
508
|
if (existingScript) {
|
|
222
509
|
existingScript.parentNode?.removeChild(existingScript)
|
|
@@ -245,15 +532,37 @@ export default function PayPalConnectionPage() {
|
|
|
245
532
|
}
|
|
246
533
|
}, [connState, finalUrl, env])
|
|
247
534
|
|
|
535
|
+
// Primary completion path: receive PayPal's seamless-onboarding postMessage
|
|
536
|
+
// ourselves. Independent of partner.js, so it works even when we opened the
|
|
537
|
+
// popup directly.
|
|
538
|
+
useEffect(() => {
|
|
539
|
+
const onMessage = (ev: MessageEvent) => {
|
|
540
|
+
if (!isPayPalOrigin(ev.origin)) return
|
|
541
|
+
const { authCode, sharedId } = extractAuth(ev.data)
|
|
542
|
+
if (authCode && sharedId) {
|
|
543
|
+
completeOnboarding(authCode, sharedId)
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
window.addEventListener("message", onMessage)
|
|
547
|
+
return () => window.removeEventListener("message", onMessage)
|
|
548
|
+
}, [completeOnboarding])
|
|
549
|
+
|
|
550
|
+
// Status check + link generation. Gated on envReady so we always use the
|
|
551
|
+
// correct environment. runId guards against a stale response from a previous
|
|
552
|
+
// environment overwriting the current one.
|
|
248
553
|
useEffect(() => {
|
|
554
|
+
if (!envReady) return
|
|
555
|
+
|
|
249
556
|
currentRunId.current = ++runIdRef.current
|
|
250
557
|
const runId = currentRunId.current
|
|
251
558
|
|
|
252
559
|
let cancelled = false
|
|
560
|
+
completedRef.current = false
|
|
253
561
|
|
|
254
562
|
const run = async () => {
|
|
255
563
|
setConnState("loading")
|
|
256
564
|
setError(null)
|
|
565
|
+
setPopupBlocked(false)
|
|
257
566
|
setFinalUrl("")
|
|
258
567
|
|
|
259
568
|
try {
|
|
@@ -279,11 +588,14 @@ export default function PayPalConnectionPage() {
|
|
|
279
588
|
console.error(e)
|
|
280
589
|
}
|
|
281
590
|
|
|
282
|
-
if (
|
|
283
|
-
|
|
591
|
+
if (cancelled || runId !== currentRunId.current) return
|
|
592
|
+
|
|
593
|
+
const cached = readCachedUrl(env)
|
|
594
|
+
if (cached) {
|
|
595
|
+
setFinalUrl(cached)
|
|
284
596
|
setConnState("ready")
|
|
285
597
|
} else {
|
|
286
|
-
fetchFreshLink(runId)
|
|
598
|
+
fetchFreshLink(runId, env)
|
|
287
599
|
}
|
|
288
600
|
}
|
|
289
601
|
|
|
@@ -291,80 +603,46 @@ export default function PayPalConnectionPage() {
|
|
|
291
603
|
|
|
292
604
|
return () => {
|
|
293
605
|
cancelled = true
|
|
294
|
-
currentRunId.current = 0
|
|
295
606
|
}
|
|
296
|
-
}, [env, fetchFreshLink])
|
|
607
|
+
}, [env, envReady, fetchFreshLink])
|
|
297
608
|
|
|
609
|
+
// Bridge partner.js's callback into our single idempotent completion handler.
|
|
298
610
|
useLayoutEffect(() => {
|
|
299
|
-
window.onboardingCallback =
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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
|
-
}
|
|
611
|
+
window.onboardingCallback = (authCode: string, sharedId: string) => {
|
|
612
|
+
completeOnboarding(authCode, sharedId)
|
|
613
|
+
}
|
|
614
|
+
return () => {
|
|
615
|
+
window.onboardingCallback = undefined
|
|
616
|
+
}
|
|
617
|
+
}, [completeOnboarding])
|
|
326
618
|
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
window.PAYPAL?.apps?.Signup?.miniBrowser &&
|
|
334
|
-
(window.PAYPAL.apps.Signup.miniBrowser as any).closeFlow
|
|
335
|
-
if (typeof close2 === "function") close2()
|
|
336
|
-
} catch {}
|
|
619
|
+
// Clean up the popup poll on unmount.
|
|
620
|
+
useEffect(() => {
|
|
621
|
+
return () => {
|
|
622
|
+
stopPopupPoll()
|
|
623
|
+
}
|
|
624
|
+
}, [stopPopupPoll])
|
|
337
625
|
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
626
|
+
// Capture-phase click interceptor: guarantees WE own the popup. Running in the
|
|
627
|
+
// capture phase on the document means we execute before partner.js's own click
|
|
628
|
+
// handler, so we can suppress it (no duplicate window) and open the popup
|
|
629
|
+
// ourselves within the same user gesture. This is what makes the first click
|
|
630
|
+
// after a cold load reliably open a popup instead of a new tab.
|
|
631
|
+
useEffect(() => {
|
|
632
|
+
const onCaptureClick = (e: MouseEvent) => {
|
|
633
|
+
const btn = paypalButtonRef.current
|
|
634
|
+
if (!btn) return
|
|
635
|
+
const target = e.target as Node | null
|
|
636
|
+
if (!target || !btn.contains(target)) return
|
|
341
637
|
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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
|
-
}
|
|
638
|
+
e.preventDefault()
|
|
639
|
+
e.stopImmediatePropagation()
|
|
640
|
+
openConnect()
|
|
362
641
|
}
|
|
363
642
|
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
}, [env])
|
|
643
|
+
document.addEventListener("click", onCaptureClick, true)
|
|
644
|
+
return () => document.removeEventListener("click", onCaptureClick, true)
|
|
645
|
+
}, [openConnect])
|
|
368
646
|
|
|
369
647
|
useLayoutEffect(() => {
|
|
370
648
|
const el = ppBtnMeasureRef.current
|
|
@@ -391,10 +669,12 @@ export default function PayPalConnectionPage() {
|
|
|
391
669
|
}
|
|
392
670
|
}, [connState, env, finalUrl])
|
|
393
671
|
|
|
672
|
+
// Defensive fallback: the capture listener above normally handles the click
|
|
673
|
+
// and stops propagation before React sees it. If for any reason it didn't, we
|
|
674
|
+
// still prevent the native target from opening a tab and open the popup here.
|
|
394
675
|
const handleConnectClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
}
|
|
676
|
+
e.preventDefault()
|
|
677
|
+
openConnect()
|
|
398
678
|
}
|
|
399
679
|
|
|
400
680
|
const handleSaveManual = async () => {
|
|
@@ -430,9 +710,7 @@ export default function PayPalConnectionPage() {
|
|
|
430
710
|
setStatusInfo(refreshedStatus || null)
|
|
431
711
|
setShowManual(false)
|
|
432
712
|
|
|
433
|
-
|
|
434
|
-
localStorage.removeItem(CACHE_KEY)
|
|
435
|
-
} catch {}
|
|
713
|
+
clearCachedUrl(env)
|
|
436
714
|
} catch (e: any) {
|
|
437
715
|
console.error(e)
|
|
438
716
|
setConnState("error")
|
|
@@ -466,14 +744,12 @@ export default function PayPalConnectionPage() {
|
|
|
466
744
|
throw new Error(t || `Disconnect failed (${res.status})`)
|
|
467
745
|
}
|
|
468
746
|
|
|
469
|
-
|
|
470
|
-
localStorage.removeItem(CACHE_KEY)
|
|
471
|
-
|
|
472
|
-
} catch {}
|
|
747
|
+
clearCachedUrl(env)
|
|
473
748
|
|
|
749
|
+
completedRef.current = false
|
|
474
750
|
currentRunId.current = ++runIdRef.current
|
|
475
751
|
const runId = currentRunId.current
|
|
476
|
-
fetchFreshLink(runId)
|
|
752
|
+
fetchFreshLink(runId, env)
|
|
477
753
|
} catch (e: any) {
|
|
478
754
|
console.error(e)
|
|
479
755
|
setConnState("error")
|
|
@@ -484,22 +760,27 @@ export default function PayPalConnectionPage() {
|
|
|
484
760
|
}
|
|
485
761
|
|
|
486
762
|
const handleEnvChange = async (e: React.ChangeEvent<HTMLSelectElement>) => {
|
|
487
|
-
const next = e.target.value as
|
|
763
|
+
const next = e.target.value as Env
|
|
764
|
+
|
|
765
|
+
completedRef.current = false
|
|
766
|
+
setPopupBlocked(false)
|
|
767
|
+
// Drop both cached URLs so neither environment can serve a stale link.
|
|
768
|
+
clearCachedUrl()
|
|
769
|
+
|
|
770
|
+
// Update local state immediately (drives the status/link refetch with the
|
|
771
|
+
// explicit environment) and persist the choice. Because the link/status
|
|
772
|
+
// fetches are environment-explicit, correctness no longer depends on this
|
|
773
|
+
// POST winning a race.
|
|
488
774
|
setEnv(next)
|
|
489
|
-
cachedUrl = null
|
|
490
775
|
|
|
491
776
|
try {
|
|
492
|
-
await fetch(
|
|
777
|
+
await fetch(ENVIRONMENT_ENDPOINT, {
|
|
493
778
|
method: "POST",
|
|
494
779
|
headers: { "content-type": "application/json" },
|
|
495
780
|
credentials: "include",
|
|
496
781
|
body: JSON.stringify({ environment: next }),
|
|
497
782
|
})
|
|
498
783
|
} catch {}
|
|
499
|
-
|
|
500
|
-
try {
|
|
501
|
-
localStorage.removeItem(CACHE_KEY)
|
|
502
|
-
} catch {}
|
|
503
784
|
}
|
|
504
785
|
|
|
505
786
|
return (
|
|
@@ -584,7 +865,7 @@ export default function PayPalConnectionPage() {
|
|
|
584
865
|
ppBtnMeasureRef.current = node
|
|
585
866
|
}}
|
|
586
867
|
id="paypal-button"
|
|
587
|
-
target=
|
|
868
|
+
target={POPUP_NAME}
|
|
588
869
|
data-paypal-button="true"
|
|
589
870
|
href={finalUrl || "#"}
|
|
590
871
|
data-paypal-onboard-complete="onboardingCallback"
|
|
@@ -599,6 +880,13 @@ export default function PayPalConnectionPage() {
|
|
|
599
880
|
Connect to PayPal
|
|
600
881
|
</a>
|
|
601
882
|
|
|
883
|
+
{popupBlocked && (
|
|
884
|
+
<div className="mt-3 text-left text-xs bg-amber-50 text-amber-700 p-3 border border-amber-200 rounded">
|
|
885
|
+
Your browser blocked the PayPal popup. Please allow popups
|
|
886
|
+
for this site, then click “Connect to PayPal” again.
|
|
887
|
+
</div>
|
|
888
|
+
)}
|
|
889
|
+
|
|
602
890
|
<div
|
|
603
891
|
className="mt-2"
|
|
604
892
|
style={{
|