@easypayment/medusa-payment-paypal 0.9.21 → 0.9.24
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 +258 -214
- package/.medusa/server/src/admin/index.mjs +258 -214
- 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 +210 -223
- package/.medusa/server/src/admin/routes/settings/paypal/connection/page.js.map +1 -1
- package/.medusa/server/src/api/admin/payment-collections/[id]/payment-sessions/route.d.ts.map +1 -1
- package/.medusa/server/src/api/admin/payment-collections/[id]/payment-sessions/route.js +6 -8
- package/.medusa/server/src/api/admin/payment-collections/[id]/payment-sessions/route.js.map +1 -1
- package/.medusa/server/src/api/admin/paypal/environment/route.d.ts.map +1 -1
- package/.medusa/server/src/api/admin/paypal/environment/route.js +11 -3
- package/.medusa/server/src/api/admin/paypal/environment/route.js.map +1 -1
- package/.medusa/server/src/api/admin/paypal/onboarding-status/route.d.ts.map +1 -1
- package/.medusa/server/src/api/admin/paypal/onboarding-status/route.js +4 -1
- package/.medusa/server/src/api/admin/paypal/onboarding-status/route.js.map +1 -1
- package/.medusa/server/src/api/store/payment-collections/[id]/payment-sessions/route.d.ts.map +1 -1
- package/.medusa/server/src/api/store/payment-collections/[id]/payment-sessions/route.js +6 -8
- package/.medusa/server/src/api/store/payment-collections/[id]/payment-sessions/route.js.map +1 -1
- package/.medusa/server/src/api/store/paypal/capture-order/route.d.ts.map +1 -1
- package/.medusa/server/src/api/store/paypal/capture-order/route.js +11 -0
- package/.medusa/server/src/api/store/paypal/capture-order/route.js.map +1 -1
- package/.medusa/server/src/api/store/paypal-complete/route.d.ts.map +1 -1
- package/.medusa/server/src/api/store/paypal-complete/route.js +2 -4
- package/.medusa/server/src/api/store/paypal-complete/route.js.map +1 -1
- package/.medusa/server/src/modules/paypal/payment-provider/card-service.d.ts.map +1 -1
- package/.medusa/server/src/modules/paypal/payment-provider/card-service.js +55 -34
- package/.medusa/server/src/modules/paypal/payment-provider/card-service.js.map +1 -1
- package/.medusa/server/src/modules/paypal/utils/core-workflow.d.ts +32 -0
- package/.medusa/server/src/modules/paypal/utils/core-workflow.d.ts.map +1 -0
- package/.medusa/server/src/modules/paypal/utils/core-workflow.js +40 -0
- package/.medusa/server/src/modules/paypal/utils/core-workflow.js.map +1 -0
- package/package.json +1 -1
- package/src/admin/routes/settings/paypal/connection/page.tsx +273 -293
- package/src/api/admin/payment-collections/[id]/payment-sessions/route.ts +6 -8
- package/src/api/admin/paypal/environment/route.ts +10 -3
- package/src/api/admin/paypal/onboarding-status/route.ts +4 -1
- package/src/api/store/payment-collections/[id]/payment-sessions/route.ts +6 -8
- package/src/api/store/paypal/capture-order/route.ts +15 -0
- package/src/api/store/paypal-complete/route.ts +6 -4
- package/src/modules/paypal/payment-provider/card-service.ts +72 -38
- package/src/modules/paypal/utils/core-workflow.ts +46 -0
|
@@ -36,18 +36,13 @@ declare global {
|
|
|
36
36
|
|
|
37
37
|
|
|
38
38
|
const SERVICE_URL = "/admin/paypal/onboarding-link"
|
|
39
|
-
//
|
|
40
|
-
// gesture (see openOnboardingPopup) — the only reliable way to get a popup
|
|
41
|
-
// rather than a blocked window or a new tab. The window name matches the
|
|
42
|
-
// anchor's `target` and PayPal's conventional "PPFrame": partner.js opens the
|
|
43
|
-
// flow with `window.open(url, target, ...)`, so when it runs right after us it
|
|
44
|
-
// *reuses this already-open window* (a named-window navigation needs no user
|
|
45
|
-
// activation) instead of trying — and failing, asynchronously — to spawn its
|
|
46
|
-
// own. That lets partner.js still wire its completion bridge without ever
|
|
47
|
-
// opening a second/late window.
|
|
39
|
+
// PayPal's conventional mini-browser window name (also the anchor target).
|
|
48
40
|
const POPUP_NAME = "PPFrame"
|
|
41
|
+
|
|
42
|
+
// The onboarding link is cached in the browser for 6 hours so it is always
|
|
43
|
+
// present for the mini-browser flow without regenerating on every load.
|
|
49
44
|
const CACHE_PREFIX = "pp_onboard_cache"
|
|
50
|
-
const CACHE_EXPIRY =
|
|
45
|
+
const CACHE_EXPIRY = 6 * 60 * 60 * 1000 // 6 hours
|
|
51
46
|
|
|
52
47
|
const ONBOARDING_COMPLETE_ENDPOINT = "/admin/paypal/onboard-complete"
|
|
53
48
|
const STATUS_ENDPOINT = "/admin/paypal/status"
|
|
@@ -57,9 +52,6 @@ const ENVIRONMENT_ENDPOINT = "/admin/paypal/environment"
|
|
|
57
52
|
|
|
58
53
|
type Env = "sandbox" | "live"
|
|
59
54
|
|
|
60
|
-
// Onboarding URLs are environment-specific and short-lived, so the cache must be
|
|
61
|
-
// keyed per environment — a single shared key let a stale sandbox URL leak into
|
|
62
|
-
// the live flow (and vice-versa) after a reload.
|
|
63
55
|
const cacheKeyFor = (env: Env) => `${CACHE_PREFIX}_${env}`
|
|
64
56
|
|
|
65
57
|
function readCachedUrl(env: Env): string | null {
|
|
@@ -81,11 +73,12 @@ function readCachedUrl(env: Env): string | null {
|
|
|
81
73
|
return null
|
|
82
74
|
}
|
|
83
75
|
|
|
84
|
-
function writeCachedUrl(env: Env, url: string) {
|
|
76
|
+
function writeCachedUrl(env: Env, url: string): boolean {
|
|
85
77
|
try {
|
|
86
78
|
localStorage.setItem(cacheKeyFor(env), JSON.stringify({ url, ts: Date.now() }))
|
|
79
|
+
return readCachedUrl(env) === url
|
|
87
80
|
} catch {
|
|
88
|
-
|
|
81
|
+
return false
|
|
89
82
|
}
|
|
90
83
|
}
|
|
91
84
|
|
|
@@ -102,9 +95,6 @@ function clearCachedUrl(env?: Env) {
|
|
|
102
95
|
}
|
|
103
96
|
}
|
|
104
97
|
|
|
105
|
-
// PayPal delivers the seamless-onboarding result by posting a message to
|
|
106
|
-
// window.opener. We validate the sender is actually a PayPal origin before
|
|
107
|
-
// trusting authCode/sharedId.
|
|
108
98
|
function isPayPalOrigin(origin: string): boolean {
|
|
109
99
|
try {
|
|
110
100
|
const host = new URL(origin).hostname.toLowerCase()
|
|
@@ -119,25 +109,29 @@ function isPayPalOrigin(origin: string): boolean {
|
|
|
119
109
|
}
|
|
120
110
|
}
|
|
121
111
|
|
|
122
|
-
//
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
//
|
|
112
|
+
// Defensively pull authCode/sharedId out of whatever shape PayPal posts back
|
|
113
|
+
// (object, JSON string, or query string; possibly nested). We only act when both
|
|
114
|
+
// are present, which ignores the many unrelated intermediate messages PayPal
|
|
115
|
+
// emits.
|
|
126
116
|
function extractAuth(data: unknown): { authCode?: string; sharedId?: string } {
|
|
127
117
|
if (!data) return {}
|
|
128
118
|
|
|
129
119
|
if (typeof data === "object") {
|
|
130
120
|
const obj = data as Record<string, any>
|
|
131
|
-
const authCode = obj.authCode ?? obj.auth_code ?? obj.authcode
|
|
121
|
+
const authCode = obj.authCode ?? obj.auth_code ?? obj.authcode ?? obj.code
|
|
132
122
|
const sharedId = obj.sharedId ?? obj.shared_id ?? obj.sharedid
|
|
133
123
|
if (authCode && sharedId) {
|
|
134
124
|
return { authCode: String(authCode), sharedId: String(sharedId) }
|
|
135
125
|
}
|
|
136
|
-
for (const key of ["data", "payload", "message", "detail", "body"]) {
|
|
126
|
+
for (const key of ["data", "payload", "message", "detail", "body", "params"]) {
|
|
137
127
|
if (obj[key] && typeof obj[key] === "object") {
|
|
138
128
|
const inner = extractAuth(obj[key])
|
|
139
129
|
if (inner.authCode && inner.sharedId) return inner
|
|
140
130
|
}
|
|
131
|
+
if (typeof obj[key] === "string") {
|
|
132
|
+
const inner = extractAuth(obj[key])
|
|
133
|
+
if (inner.authCode && inner.sharedId) return inner
|
|
134
|
+
}
|
|
141
135
|
}
|
|
142
136
|
return {}
|
|
143
137
|
}
|
|
@@ -145,7 +139,7 @@ function extractAuth(data: unknown): { authCode?: string; sharedId?: string } {
|
|
|
145
139
|
if (typeof data === "string") {
|
|
146
140
|
const s = data.trim()
|
|
147
141
|
if (!s) return {}
|
|
148
|
-
if (s.startsWith("{")) {
|
|
142
|
+
if (s.startsWith("{") || s.startsWith("[")) {
|
|
149
143
|
try {
|
|
150
144
|
return extractAuth(JSON.parse(s))
|
|
151
145
|
} catch {
|
|
@@ -170,9 +164,6 @@ function extractAuth(data: unknown): { authCode?: string; sharedId?: string } {
|
|
|
170
164
|
|
|
171
165
|
export default function PayPalConnectionPage() {
|
|
172
166
|
const [env, setEnv] = useState<Env>("live")
|
|
173
|
-
// We must not generate/fetch an onboarding link until we know which
|
|
174
|
-
// environment the server is actually on, otherwise the first fetch races the
|
|
175
|
-
// GET /environment response and can target the wrong environment.
|
|
176
167
|
const [envReady, setEnvReady] = useState(false)
|
|
177
168
|
|
|
178
169
|
const [connState, setConnState] = useState<
|
|
@@ -198,21 +189,17 @@ export default function PayPalConnectionPage() {
|
|
|
198
189
|
const runIdRef = useRef(0)
|
|
199
190
|
const currentRunId = useRef(0)
|
|
200
191
|
|
|
201
|
-
|
|
202
|
-
|
|
192
|
+
const envRef = useRef<Env>(env)
|
|
193
|
+
envRef.current = env
|
|
203
194
|
const finalUrlRef = useRef(finalUrl)
|
|
204
195
|
finalUrlRef.current = finalUrl
|
|
205
196
|
const connStateRef = useRef(connState)
|
|
206
197
|
connStateRef.current = connState
|
|
207
198
|
const inProgressRef = useRef(onboardingInProgress)
|
|
208
199
|
inProgressRef.current = onboardingInProgress
|
|
209
|
-
const envRef = useRef<Env>(env)
|
|
210
|
-
envRef.current = env
|
|
211
200
|
|
|
212
201
|
const popupRef = useRef<Window | null>(null)
|
|
213
|
-
const
|
|
214
|
-
// Guards against the two redundant completion paths (our own message listener
|
|
215
|
-
// and partner.js's onboardingCallback bridge) both POSTing the same result.
|
|
202
|
+
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
|
216
203
|
const completedRef = useRef(false)
|
|
217
204
|
|
|
218
205
|
const ppBtnMeasureRef = useRef<HTMLAnchorElement | null>(null)
|
|
@@ -227,76 +214,60 @@ export default function PayPalConnectionPage() {
|
|
|
227
214
|
setError(msg)
|
|
228
215
|
}, [])
|
|
229
216
|
|
|
230
|
-
const
|
|
231
|
-
if (
|
|
232
|
-
clearInterval(
|
|
233
|
-
|
|
217
|
+
const stopPoll = useCallback(() => {
|
|
218
|
+
if (pollRef.current) {
|
|
219
|
+
clearInterval(pollRef.current)
|
|
220
|
+
pollRef.current = null
|
|
234
221
|
}
|
|
235
222
|
}, [])
|
|
236
223
|
|
|
237
|
-
// Open
|
|
238
|
-
//
|
|
239
|
-
//
|
|
240
|
-
//
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
224
|
+
// Open PayPal's mini-browser ourselves, synchronously inside the click gesture.
|
|
225
|
+
// This is the only reliable way to get a popup (rather than a new tab, which is
|
|
226
|
+
// what happens when partner.js's asynchronous open misses the user-activation
|
|
227
|
+
// window). Named POPUP_NAME and sized like PayPal's mini-browser.
|
|
228
|
+
const openOnboardingPopup = useCallback((url: string): Window | null => {
|
|
229
|
+
const w = 450
|
|
230
|
+
const h = 600
|
|
231
|
+
const baseLeft =
|
|
232
|
+
typeof window.screenX === "number" ? window.screenX : (window as any).screenLeft || 0
|
|
233
|
+
const baseTop =
|
|
234
|
+
typeof window.screenY === "number" ? window.screenY : (window as any).screenTop || 0
|
|
235
|
+
const outerW = window.outerWidth || document.documentElement.clientWidth || 1024
|
|
236
|
+
const outerH = window.outerHeight || document.documentElement.clientHeight || 768
|
|
237
|
+
const left = Math.round(baseLeft + Math.max(0, (outerW - w) / 2))
|
|
238
|
+
const top = Math.round(baseTop + Math.max(0, (outerH - h) / 2))
|
|
239
|
+
const features = `popup=yes,width=${w},height=${h},left=${left},top=${top},scrollbars=yes,resizable=yes`
|
|
240
|
+
|
|
241
|
+
let popup: Window | null = null
|
|
242
|
+
try {
|
|
243
|
+
popup = window.open(url, POPUP_NAME, features)
|
|
244
|
+
} catch {
|
|
245
|
+
popup = null
|
|
246
|
+
}
|
|
247
|
+
if (popup) {
|
|
248
|
+
popupRef.current = popup
|
|
260
249
|
try {
|
|
261
|
-
popup
|
|
250
|
+
popup.focus()
|
|
262
251
|
} catch {
|
|
263
|
-
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
if (popup) {
|
|
267
|
-
popupRef.current = popup
|
|
268
|
-
try {
|
|
269
|
-
popup.focus()
|
|
270
|
-
} catch {
|
|
271
|
-
/* ignore */
|
|
272
|
-
}
|
|
273
|
-
stopPopupPoll()
|
|
274
|
-
popupPollRef.current = setInterval(() => {
|
|
275
|
-
if (!popupRef.current || popupRef.current.closed) {
|
|
276
|
-
stopPopupPoll()
|
|
277
|
-
}
|
|
278
|
-
}, 1000)
|
|
252
|
+
/* ignore */
|
|
279
253
|
}
|
|
254
|
+
}
|
|
255
|
+
return popup
|
|
256
|
+
}, [])
|
|
280
257
|
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
)
|
|
285
|
-
|
|
286
|
-
// Exchange the seller authCode/sharedId for credentials. Reached from BOTH
|
|
287
|
-
// PayPal's partner.js bridge (window.onboardingCallback) and our own postMessage
|
|
288
|
-
// listener; completedRef makes it idempotent so we only POST once.
|
|
258
|
+
// Exchange authCode/sharedId for seller credentials. Idempotent so the multiple
|
|
259
|
+
// completion signals (our message listener, partner.js's bridge, the status
|
|
260
|
+
// poll) only run it once.
|
|
289
261
|
const completeOnboarding = useCallback(
|
|
290
262
|
async (authCode: string, sharedId: string) => {
|
|
291
263
|
if (!authCode || !sharedId) return
|
|
292
264
|
if (completedRef.current) return
|
|
293
265
|
completedRef.current = true
|
|
294
266
|
|
|
267
|
+
stopPoll()
|
|
295
268
|
try {
|
|
296
269
|
window.onbeforeunload = null
|
|
297
|
-
} catch {
|
|
298
|
-
/* ignore */
|
|
299
|
-
}
|
|
270
|
+
} catch {}
|
|
300
271
|
|
|
301
272
|
const activeEnv: Env = envRef.current === "sandbox" ? "sandbox" : "live"
|
|
302
273
|
|
|
@@ -318,45 +289,34 @@ export default function PayPalConnectionPage() {
|
|
|
318
289
|
throw new Error(txt || `Onboarding exchange failed (${res.status})`)
|
|
319
290
|
}
|
|
320
291
|
|
|
321
|
-
// Close the popup we opened, plus any partner.js-managed flow.
|
|
322
292
|
try {
|
|
323
293
|
popupRef.current?.close()
|
|
324
|
-
} catch {
|
|
325
|
-
/* ignore */
|
|
326
|
-
}
|
|
327
|
-
stopPopupPoll()
|
|
294
|
+
} catch {}
|
|
328
295
|
try {
|
|
329
296
|
const close1 = window.PAYPAL?.apps?.Signup?.MiniBrowser?.closeFlow
|
|
330
297
|
if (typeof close1 === "function") close1()
|
|
331
|
-
} catch {
|
|
332
|
-
/* ignore */
|
|
333
|
-
}
|
|
298
|
+
} catch {}
|
|
334
299
|
try {
|
|
335
300
|
const close2 =
|
|
336
301
|
window.PAYPAL?.apps?.Signup?.miniBrowser &&
|
|
337
302
|
(window.PAYPAL.apps.Signup.miniBrowser as any).closeFlow
|
|
338
303
|
if (typeof close2 === "function") close2()
|
|
339
|
-
} catch {
|
|
340
|
-
/* ignore */
|
|
341
|
-
}
|
|
304
|
+
} catch {}
|
|
342
305
|
|
|
343
306
|
clearCachedUrl(activeEnv)
|
|
344
307
|
|
|
345
308
|
try {
|
|
346
|
-
const statusRes = await fetch(
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
)
|
|
309
|
+
const statusRes = await fetch(`${STATUS_ENDPOINT}?environment=${activeEnv}`, {
|
|
310
|
+
method: "GET",
|
|
311
|
+
credentials: "include",
|
|
312
|
+
})
|
|
350
313
|
const refreshedStatus = await statusRes.json().catch(() => ({}))
|
|
351
314
|
setStatusInfo(refreshedStatus || null)
|
|
352
|
-
} catch {
|
|
353
|
-
/* ignore — still consider it connected below */
|
|
354
|
-
}
|
|
315
|
+
} catch {}
|
|
355
316
|
|
|
356
317
|
setConnState("connected")
|
|
357
318
|
setShowManual(false)
|
|
358
319
|
} catch (e: any) {
|
|
359
|
-
// Allow a retry after a failed exchange.
|
|
360
320
|
completedRef.current = false
|
|
361
321
|
console.error(e)
|
|
362
322
|
setConnState("error")
|
|
@@ -365,58 +325,91 @@ export default function PayPalConnectionPage() {
|
|
|
365
325
|
setOnboardingInProgress(false)
|
|
366
326
|
}
|
|
367
327
|
},
|
|
368
|
-
[
|
|
328
|
+
[stopPoll]
|
|
369
329
|
)
|
|
370
330
|
|
|
371
|
-
|
|
372
|
-
|
|
331
|
+
// After the popup is opened, poll the server. If the credentials get saved by
|
|
332
|
+
// any completion path, the status flips to "connected" and we sync the UI even
|
|
333
|
+
// if a message was missed.
|
|
334
|
+
const startStatusPoll = useCallback(() => {
|
|
335
|
+
stopPoll()
|
|
336
|
+
let ticks = 0
|
|
337
|
+
pollRef.current = setInterval(async () => {
|
|
338
|
+
ticks += 1
|
|
339
|
+
if (completedRef.current || ticks > 150 /* ~5 min */) {
|
|
340
|
+
stopPoll()
|
|
341
|
+
return
|
|
342
|
+
}
|
|
343
|
+
try {
|
|
344
|
+
const r = await fetch(`${STATUS_ENDPOINT}?environment=${envRef.current}`, {
|
|
345
|
+
method: "GET",
|
|
346
|
+
credentials: "include",
|
|
347
|
+
})
|
|
348
|
+
const st = await r.json().catch(() => ({}))
|
|
349
|
+
if (st?.status === "connected" && st?.seller_client_id_present === true) {
|
|
350
|
+
completedRef.current = true
|
|
351
|
+
stopPoll()
|
|
352
|
+
try {
|
|
353
|
+
popupRef.current?.close()
|
|
354
|
+
} catch {}
|
|
355
|
+
clearCachedUrl(envRef.current)
|
|
356
|
+
setStatusInfo(st)
|
|
357
|
+
setConnState("connected")
|
|
358
|
+
setShowManual(false)
|
|
359
|
+
setOnboardingInProgress(false)
|
|
360
|
+
}
|
|
361
|
+
} catch {
|
|
362
|
+
/* keep polling */
|
|
363
|
+
}
|
|
364
|
+
}, 2000)
|
|
365
|
+
}, [stopPoll])
|
|
366
|
+
|
|
367
|
+
const generateLinkAndReload = useCallback(
|
|
368
|
+
async (targetEnv: Env, runId: number) => {
|
|
373
369
|
if (initLoaderRef.current) {
|
|
374
370
|
const loaderText = initLoaderRef.current.querySelector("#loader-text")
|
|
375
|
-
if (loaderText)
|
|
376
|
-
loaderText.textContent = "Generating onboarding session..."
|
|
371
|
+
if (loaderText) loaderText.textContent = "Generating onboarding session..."
|
|
377
372
|
}
|
|
378
373
|
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
products: ["PPCP"],
|
|
385
|
-
// Generate the link for the explicitly selected environment so it can
|
|
386
|
-
// never depend on a racing POST /environment having landed yet.
|
|
387
|
-
environment: targetEnv,
|
|
388
|
-
}),
|
|
389
|
-
})
|
|
390
|
-
.then((r) => {
|
|
391
|
-
if (!r.ok) throw new Error(`Service returned ${r.status}`)
|
|
392
|
-
return r.json()
|
|
374
|
+
try {
|
|
375
|
+
const res = await fetch(SERVICE_URL, {
|
|
376
|
+
method: "POST",
|
|
377
|
+
headers: { "content-type": "application/json" },
|
|
378
|
+
credentials: "include",
|
|
379
|
+
body: JSON.stringify({ products: ["PPCP"], environment: targetEnv }),
|
|
393
380
|
})
|
|
394
|
-
.then((data) => {
|
|
395
|
-
if (runId !== currentRunId.current) return
|
|
396
381
|
|
|
397
|
-
|
|
398
|
-
if (!href) {
|
|
399
|
-
showError("Onboarding URL not returned.")
|
|
400
|
-
return
|
|
401
|
-
}
|
|
382
|
+
if (!res.ok) throw new Error(`Service returned ${res.status}`)
|
|
402
383
|
|
|
403
|
-
|
|
404
|
-
|
|
384
|
+
const data = await res.json()
|
|
385
|
+
if (runId !== currentRunId.current) return
|
|
405
386
|
|
|
406
|
-
|
|
387
|
+
const href = data?.onboarding_url
|
|
388
|
+
if (!href) {
|
|
389
|
+
showError("Onboarding URL not returned.")
|
|
390
|
+
return
|
|
391
|
+
}
|
|
407
392
|
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
393
|
+
const url =
|
|
394
|
+
href + (href.includes("?") ? "&" : "?") + "displayMode=minibrowser"
|
|
395
|
+
|
|
396
|
+
const persisted = writeCachedUrl(targetEnv, url)
|
|
397
|
+
if (persisted) {
|
|
398
|
+
window.location.reload()
|
|
399
|
+
return
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
setFinalUrl(url)
|
|
403
|
+
setConnState("ready")
|
|
404
|
+
} catch {
|
|
405
|
+
if (runId !== currentRunId.current) return
|
|
406
|
+
showError("Unable to connect to service.")
|
|
407
|
+
}
|
|
415
408
|
},
|
|
416
409
|
[showError]
|
|
417
410
|
)
|
|
418
411
|
|
|
419
|
-
// Resolve the server
|
|
412
|
+
// Resolve the server environment first.
|
|
420
413
|
useEffect(() => {
|
|
421
414
|
let alive = true
|
|
422
415
|
fetch(ENVIRONMENT_ENDPOINT, { method: "GET", credentials: "include" })
|
|
@@ -434,14 +427,67 @@ export default function PayPalConnectionPage() {
|
|
|
434
427
|
}
|
|
435
428
|
}, [])
|
|
436
429
|
|
|
437
|
-
//
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
430
|
+
// Page-load flow: connected? -> cached link? -> else generate + cache + reload.
|
|
431
|
+
useEffect(() => {
|
|
432
|
+
if (!envReady) return
|
|
433
|
+
|
|
434
|
+
currentRunId.current = ++runIdRef.current
|
|
435
|
+
const runId = currentRunId.current
|
|
436
|
+
|
|
437
|
+
let cancelled = false
|
|
438
|
+
completedRef.current = false
|
|
439
|
+
|
|
440
|
+
const run = async () => {
|
|
441
|
+
setConnState("loading")
|
|
442
|
+
setError(null)
|
|
443
|
+
setPopupBlocked(false)
|
|
444
|
+
setFinalUrl("")
|
|
445
|
+
|
|
446
|
+
try {
|
|
447
|
+
const r = await fetch(`${STATUS_ENDPOINT}?environment=${env}`, {
|
|
448
|
+
method: "GET",
|
|
449
|
+
credentials: "include",
|
|
450
|
+
})
|
|
451
|
+
const st = await r.json().catch(() => ({}))
|
|
452
|
+
|
|
453
|
+
if (cancelled || runId !== currentRunId.current) return
|
|
454
|
+
|
|
455
|
+
setStatusInfo(st)
|
|
456
|
+
|
|
457
|
+
const isConnected =
|
|
458
|
+
st?.status === "connected" && st?.seller_client_id_present === true
|
|
459
|
+
|
|
460
|
+
if (isConnected) {
|
|
461
|
+
setConnState("connected")
|
|
462
|
+
setShowManual(false)
|
|
463
|
+
return
|
|
464
|
+
}
|
|
465
|
+
} catch (e) {
|
|
466
|
+
console.error(e)
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
if (cancelled || runId !== currentRunId.current) return
|
|
470
|
+
|
|
471
|
+
const cached = readCachedUrl(env)
|
|
472
|
+
if (cached) {
|
|
473
|
+
setFinalUrl(cached)
|
|
474
|
+
setConnState("ready")
|
|
475
|
+
return
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
await generateLinkAndReload(env, runId)
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
run()
|
|
482
|
+
|
|
483
|
+
return () => {
|
|
484
|
+
cancelled = true
|
|
485
|
+
}
|
|
486
|
+
}, [env, envReady, generateLinkAndReload])
|
|
487
|
+
|
|
488
|
+
// Load partner.js + init for its postMessage->onboardingCallback bridge (a
|
|
489
|
+
// secondary completion path next to our own message listener). We open the
|
|
490
|
+
// popup ourselves, so partner.js is NOT relied on to open the window.
|
|
445
491
|
useEffect(() => {
|
|
446
492
|
if (connState !== "ready" || !finalUrl) return
|
|
447
493
|
|
|
@@ -462,16 +508,12 @@ export default function PayPalConnectionPage() {
|
|
|
462
508
|
|
|
463
509
|
if (target?.init) {
|
|
464
510
|
try {
|
|
465
|
-
// Wires partner.js's click interceptor + its postMessage->callback
|
|
466
|
-
// bridge. It will adopt the window we pre-open (same name) rather than
|
|
467
|
-
// open its own.
|
|
468
511
|
target.init()
|
|
469
512
|
} catch (e) {
|
|
470
513
|
console.error("[paypal] partner.js init failed:", e)
|
|
471
514
|
}
|
|
472
515
|
return
|
|
473
516
|
}
|
|
474
|
-
|
|
475
517
|
if (typeof signup?.render === "function") {
|
|
476
518
|
try {
|
|
477
519
|
signup.render()
|
|
@@ -480,19 +522,15 @@ export default function PayPalConnectionPage() {
|
|
|
480
522
|
}
|
|
481
523
|
return
|
|
482
524
|
}
|
|
483
|
-
|
|
484
525
|
if (attempt < 40) {
|
|
485
526
|
pollTimer = setTimeout(() => initPartner(attempt + 1), 50)
|
|
486
527
|
return
|
|
487
528
|
}
|
|
488
|
-
|
|
489
529
|
try {
|
|
490
530
|
document.dispatchEvent(new Event("DOMContentLoaded"))
|
|
491
531
|
} catch {}
|
|
492
532
|
}
|
|
493
533
|
|
|
494
|
-
// Remove any stale copy + namespace so the fresh load re-registers against
|
|
495
|
-
// the current environment (sandbox vs live partner.js differ).
|
|
496
534
|
const existingScript = document.getElementById("paypal-partner-js")
|
|
497
535
|
if (existingScript) {
|
|
498
536
|
existingScript.parentNode?.removeChild(existingScript)
|
|
@@ -512,20 +550,38 @@ export default function PayPalConnectionPage() {
|
|
|
512
550
|
|
|
513
551
|
return () => {
|
|
514
552
|
cancelled = true
|
|
515
|
-
if (pollTimer)
|
|
516
|
-
|
|
517
|
-
}
|
|
518
|
-
if (ppScript.parentNode) {
|
|
519
|
-
ppScript.parentNode.removeChild(ppScript)
|
|
520
|
-
}
|
|
553
|
+
if (pollTimer) clearTimeout(pollTimer)
|
|
554
|
+
if (ppScript.parentNode) ppScript.parentNode.removeChild(ppScript)
|
|
521
555
|
}
|
|
522
556
|
}, [connState, finalUrl, env])
|
|
523
557
|
|
|
524
|
-
//
|
|
525
|
-
//
|
|
526
|
-
//
|
|
558
|
+
// PRIMARY completion path: receive PayPal's onboarding postMessage ourselves.
|
|
559
|
+
// The diagnostic log lets us confirm the exact origin/shape PayPal sends in a
|
|
560
|
+
// real run if a tweak to the parser is ever needed.
|
|
527
561
|
useEffect(() => {
|
|
528
562
|
const onMessage = (ev: MessageEvent) => {
|
|
563
|
+
let serialized = ""
|
|
564
|
+
try {
|
|
565
|
+
serialized =
|
|
566
|
+
typeof ev.data === "string" ? ev.data : JSON.stringify(ev.data)
|
|
567
|
+
} catch {
|
|
568
|
+
serialized = String(ev.data)
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
const looksRelevant =
|
|
572
|
+
isPayPalOrigin(ev.origin) ||
|
|
573
|
+
/auth_?code|shared_?id|onboard|merchantId/i.test(serialized || "")
|
|
574
|
+
|
|
575
|
+
if (looksRelevant) {
|
|
576
|
+
// eslint-disable-next-line no-console
|
|
577
|
+
console.info(
|
|
578
|
+
"[PayPal onboarding] message from",
|
|
579
|
+
ev.origin,
|
|
580
|
+
"·",
|
|
581
|
+
(serialized || "").slice(0, 500)
|
|
582
|
+
)
|
|
583
|
+
}
|
|
584
|
+
|
|
529
585
|
if (!isPayPalOrigin(ev.origin)) return
|
|
530
586
|
const { authCode, sharedId } = extractAuth(ev.data)
|
|
531
587
|
if (authCode && sharedId) {
|
|
@@ -536,66 +592,7 @@ export default function PayPalConnectionPage() {
|
|
|
536
592
|
return () => window.removeEventListener("message", onMessage)
|
|
537
593
|
}, [completeOnboarding])
|
|
538
594
|
|
|
539
|
-
//
|
|
540
|
-
// correct environment. runId guards against a stale response from a previous
|
|
541
|
-
// environment overwriting the current one.
|
|
542
|
-
useEffect(() => {
|
|
543
|
-
if (!envReady) return
|
|
544
|
-
|
|
545
|
-
currentRunId.current = ++runIdRef.current
|
|
546
|
-
const runId = currentRunId.current
|
|
547
|
-
|
|
548
|
-
let cancelled = false
|
|
549
|
-
completedRef.current = false
|
|
550
|
-
|
|
551
|
-
const run = async () => {
|
|
552
|
-
setConnState("loading")
|
|
553
|
-
setError(null)
|
|
554
|
-
setPopupBlocked(false)
|
|
555
|
-
setFinalUrl("")
|
|
556
|
-
|
|
557
|
-
try {
|
|
558
|
-
const r = await fetch(`${STATUS_ENDPOINT}?environment=${env}`, {
|
|
559
|
-
method: "GET",
|
|
560
|
-
credentials: "include",
|
|
561
|
-
})
|
|
562
|
-
const st = await r.json().catch(() => ({}))
|
|
563
|
-
|
|
564
|
-
if (cancelled || runId !== currentRunId.current) return
|
|
565
|
-
|
|
566
|
-
setStatusInfo(st)
|
|
567
|
-
|
|
568
|
-
const isConnected =
|
|
569
|
-
st?.status === "connected" && st?.seller_client_id_present === true
|
|
570
|
-
|
|
571
|
-
if (isConnected) {
|
|
572
|
-
setConnState("connected")
|
|
573
|
-
setShowManual(false)
|
|
574
|
-
return
|
|
575
|
-
}
|
|
576
|
-
} catch (e) {
|
|
577
|
-
console.error(e)
|
|
578
|
-
}
|
|
579
|
-
|
|
580
|
-
if (cancelled || runId !== currentRunId.current) return
|
|
581
|
-
|
|
582
|
-
const cached = readCachedUrl(env)
|
|
583
|
-
if (cached) {
|
|
584
|
-
setFinalUrl(cached)
|
|
585
|
-
setConnState("ready")
|
|
586
|
-
} else {
|
|
587
|
-
fetchFreshLink(runId, env)
|
|
588
|
-
}
|
|
589
|
-
}
|
|
590
|
-
|
|
591
|
-
run()
|
|
592
|
-
|
|
593
|
-
return () => {
|
|
594
|
-
cancelled = true
|
|
595
|
-
}
|
|
596
|
-
}, [env, envReady, fetchFreshLink])
|
|
597
|
-
|
|
598
|
-
// Bridge partner.js's callback into our single idempotent completion handler.
|
|
595
|
+
// SECONDARY completion path: partner.js's bridge calls this by name.
|
|
599
596
|
useLayoutEffect(() => {
|
|
600
597
|
window.onboardingCallback = (authCode: string, sharedId: string) => {
|
|
601
598
|
completeOnboarding(authCode, sharedId)
|
|
@@ -605,12 +602,44 @@ export default function PayPalConnectionPage() {
|
|
|
605
602
|
}
|
|
606
603
|
}, [completeOnboarding])
|
|
607
604
|
|
|
608
|
-
//
|
|
605
|
+
// Capture-phase opener: runs before partner.js, opens the popup synchronously,
|
|
606
|
+
// and stops partner.js's (unreliable, async) open + the native anchor so we get
|
|
607
|
+
// exactly one popup and never a new tab.
|
|
609
608
|
useEffect(() => {
|
|
610
|
-
|
|
611
|
-
|
|
609
|
+
const onCaptureClick = (e: MouseEvent) => {
|
|
610
|
+
const btn = paypalButtonRef.current
|
|
611
|
+
if (!btn) return
|
|
612
|
+
const target = e.target as Node | null
|
|
613
|
+
if (!target || !btn.contains(target)) return
|
|
614
|
+
|
|
615
|
+
e.preventDefault()
|
|
616
|
+
e.stopImmediatePropagation()
|
|
617
|
+
|
|
618
|
+
if (
|
|
619
|
+
connStateRef.current !== "ready" ||
|
|
620
|
+
!finalUrlRef.current ||
|
|
621
|
+
inProgressRef.current
|
|
622
|
+
) {
|
|
623
|
+
return
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
completedRef.current = false
|
|
627
|
+
const popup = openOnboardingPopup(finalUrlRef.current)
|
|
628
|
+
if (!popup) {
|
|
629
|
+
setPopupBlocked(true)
|
|
630
|
+
return
|
|
631
|
+
}
|
|
632
|
+
setPopupBlocked(false)
|
|
633
|
+
startStatusPoll()
|
|
612
634
|
}
|
|
613
|
-
|
|
635
|
+
|
|
636
|
+
document.addEventListener("click", onCaptureClick, true)
|
|
637
|
+
return () => document.removeEventListener("click", onCaptureClick, true)
|
|
638
|
+
}, [openOnboardingPopup, startStatusPoll])
|
|
639
|
+
|
|
640
|
+
useEffect(() => {
|
|
641
|
+
return () => stopPoll()
|
|
642
|
+
}, [stopPoll])
|
|
614
643
|
|
|
615
644
|
useLayoutEffect(() => {
|
|
616
645
|
const el = ppBtnMeasureRef.current
|
|
@@ -637,53 +666,8 @@ export default function PayPalConnectionPage() {
|
|
|
637
666
|
}
|
|
638
667
|
}, [connState, env, finalUrl])
|
|
639
668
|
|
|
640
|
-
//
|
|
641
|
-
//
|
|
642
|
-
// It runs on `document` in the CAPTURE phase, i.e. BEFORE the event reaches
|
|
643
|
-
// the anchor where partner.js binds its own (target-phase) handler. That
|
|
644
|
-
// ordering is the whole point:
|
|
645
|
-
// 1. We synchronously open the mini-browser popup here, inside the user
|
|
646
|
-
// gesture, so the browser always renders a real popup (never a blocked
|
|
647
|
-
// window, never a new tab).
|
|
648
|
-
// 2. We do NOT stop propagation, so partner.js's handler still runs a beat
|
|
649
|
-
// later. Since it opens with the same window name (POPUP_NAME), it adopts
|
|
650
|
-
// the window we just opened instead of asynchronously spawning its own
|
|
651
|
-
// (which the browser was blocking — "multiple popups ... lack of user
|
|
652
|
-
// activation") — and it still wires its postMessage->onboardingCallback
|
|
653
|
-
// bridge that auto-closes the popup and saves credentials.
|
|
654
|
-
// 3. We always preventDefault so the anchor's href can never open a tab.
|
|
655
|
-
//
|
|
656
|
-
// Completion is handled by partner.js's bridge AND by our own postMessage
|
|
657
|
-
// listener (see below), funnelling through one idempotent completeOnboarding.
|
|
658
|
-
useEffect(() => {
|
|
659
|
-
const onCaptureClick = (e: MouseEvent) => {
|
|
660
|
-
const btn = paypalButtonRef.current
|
|
661
|
-
if (!btn) return
|
|
662
|
-
const target = e.target as Node | null
|
|
663
|
-
if (!target || !btn.contains(target)) return
|
|
664
|
-
|
|
665
|
-
e.preventDefault()
|
|
666
|
-
|
|
667
|
-
if (
|
|
668
|
-
connStateRef.current !== "ready" ||
|
|
669
|
-
!finalUrlRef.current ||
|
|
670
|
-
inProgressRef.current
|
|
671
|
-
) {
|
|
672
|
-
return
|
|
673
|
-
}
|
|
674
|
-
|
|
675
|
-
completedRef.current = false
|
|
676
|
-
const popup = openOnboardingPopup(finalUrlRef.current)
|
|
677
|
-
setPopupBlocked(!popup)
|
|
678
|
-
}
|
|
679
|
-
|
|
680
|
-
document.addEventListener("click", onCaptureClick, true)
|
|
681
|
-
return () => document.removeEventListener("click", onCaptureClick, true)
|
|
682
|
-
}, [openOnboardingPopup])
|
|
683
|
-
|
|
684
|
-
// Defensive only: the capture-phase listener above already opened the popup
|
|
685
|
-
// and prevented the default. If for some reason it didn't run, make sure the
|
|
686
|
-
// native target still can't open a tab.
|
|
669
|
+
// Defensive: the capture listener handles the click; this just guarantees the
|
|
670
|
+
// native href can never navigate.
|
|
687
671
|
const handleConnectClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
|
|
688
672
|
e.preventDefault()
|
|
689
673
|
}
|
|
@@ -756,11 +740,10 @@ export default function PayPalConnectionPage() {
|
|
|
756
740
|
}
|
|
757
741
|
|
|
758
742
|
clearCachedUrl(env)
|
|
759
|
-
|
|
760
743
|
completedRef.current = false
|
|
744
|
+
|
|
761
745
|
currentRunId.current = ++runIdRef.current
|
|
762
|
-
|
|
763
|
-
fetchFreshLink(runId, env)
|
|
746
|
+
await generateLinkAndReload(env, currentRunId.current)
|
|
764
747
|
} catch (e: any) {
|
|
765
748
|
console.error(e)
|
|
766
749
|
setConnState("error")
|
|
@@ -772,17 +755,12 @@ export default function PayPalConnectionPage() {
|
|
|
772
755
|
|
|
773
756
|
const handleEnvChange = async (e: React.ChangeEvent<HTMLSelectElement>) => {
|
|
774
757
|
const next = e.target.value as Env
|
|
758
|
+
if (next === env || onboardingInProgress) return
|
|
775
759
|
|
|
776
760
|
completedRef.current = false
|
|
777
|
-
setPopupBlocked(false)
|
|
778
|
-
// Drop both cached URLs so neither environment can serve a stale link.
|
|
779
761
|
clearCachedUrl()
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
// explicit environment) and persist the choice. Because the link/status
|
|
783
|
-
// fetches are environment-explicit, correctness no longer depends on this
|
|
784
|
-
// POST winning a race.
|
|
785
|
-
setEnv(next)
|
|
762
|
+
setPopupBlocked(false)
|
|
763
|
+
setConnState("loading")
|
|
786
764
|
|
|
787
765
|
try {
|
|
788
766
|
await fetch(ENVIRONMENT_ENDPOINT, {
|
|
@@ -792,6 +770,8 @@ export default function PayPalConnectionPage() {
|
|
|
792
770
|
body: JSON.stringify({ environment: next }),
|
|
793
771
|
})
|
|
794
772
|
} catch {}
|
|
773
|
+
|
|
774
|
+
setEnv(next)
|
|
795
775
|
}
|
|
796
776
|
|
|
797
777
|
return (
|