@easypayment/medusa-payment-paypal 0.9.22 → 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 +262 -92
- package/.medusa/server/src/admin/index.mjs +262 -92
- 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 +304 -146
- 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 +330 -161
- 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,16 +36,11 @@ declare global {
|
|
|
36
36
|
|
|
37
37
|
|
|
38
38
|
const SERVICE_URL = "/admin/paypal/onboarding-link"
|
|
39
|
-
// PayPal's conventional mini-browser window name
|
|
40
|
-
// `target` so partner.js opens the flow in a popup.
|
|
39
|
+
// PayPal's conventional mini-browser window name (also the anchor target).
|
|
41
40
|
const POPUP_NAME = "PPFrame"
|
|
42
41
|
|
|
43
|
-
// The onboarding link is cached in the browser for 6 hours
|
|
44
|
-
//
|
|
45
|
-
// callback if the anchor already carries a valid onboarding href when partner.js
|
|
46
|
-
// initializes. By caching the link (and reloading once to populate the cache on
|
|
47
|
-
// a cold start) the link is always present on page load, so partner.js can take
|
|
48
|
-
// over the click and run its flow exactly as documented.
|
|
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
45
|
const CACHE_EXPIRY = 6 * 60 * 60 * 1000 // 6 hours
|
|
51
46
|
|
|
@@ -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, so the cache is keyed per
|
|
61
|
-
// environment — a single shared key let a stale sandbox URL leak into the live
|
|
62
|
-
// flow (and vice-versa).
|
|
63
55
|
const cacheKeyFor = (env: Env) => `${CACHE_PREFIX}_${env}`
|
|
64
56
|
|
|
65
57
|
function readCachedUrl(env: Env): string | null {
|
|
@@ -81,8 +73,6 @@ function readCachedUrl(env: Env): string | null {
|
|
|
81
73
|
return null
|
|
82
74
|
}
|
|
83
75
|
|
|
84
|
-
// Returns true only if the value was actually persisted (storage enabled), which
|
|
85
|
-
// lets the caller decide whether reloading to pick up the cache is safe.
|
|
86
76
|
function writeCachedUrl(env: Env, url: string): boolean {
|
|
87
77
|
try {
|
|
88
78
|
localStorage.setItem(cacheKeyFor(env), JSON.stringify({ url, ts: Date.now() }))
|
|
@@ -105,22 +95,82 @@ function clearCachedUrl(env?: Env) {
|
|
|
105
95
|
}
|
|
106
96
|
}
|
|
107
97
|
|
|
98
|
+
function isPayPalOrigin(origin: string): boolean {
|
|
99
|
+
try {
|
|
100
|
+
const host = new URL(origin).hostname.toLowerCase()
|
|
101
|
+
return (
|
|
102
|
+
host === "www.paypal.com" ||
|
|
103
|
+
host === "www.sandbox.paypal.com" ||
|
|
104
|
+
host.endsWith(".paypal.com") ||
|
|
105
|
+
host.endsWith(".paypalobjects.com")
|
|
106
|
+
)
|
|
107
|
+
} catch {
|
|
108
|
+
return false
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
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.
|
|
116
|
+
function extractAuth(data: unknown): { authCode?: string; sharedId?: string } {
|
|
117
|
+
if (!data) return {}
|
|
118
|
+
|
|
119
|
+
if (typeof data === "object") {
|
|
120
|
+
const obj = data as Record<string, any>
|
|
121
|
+
const authCode = obj.authCode ?? obj.auth_code ?? obj.authcode ?? obj.code
|
|
122
|
+
const sharedId = obj.sharedId ?? obj.shared_id ?? obj.sharedid
|
|
123
|
+
if (authCode && sharedId) {
|
|
124
|
+
return { authCode: String(authCode), sharedId: String(sharedId) }
|
|
125
|
+
}
|
|
126
|
+
for (const key of ["data", "payload", "message", "detail", "body", "params"]) {
|
|
127
|
+
if (obj[key] && typeof obj[key] === "object") {
|
|
128
|
+
const inner = extractAuth(obj[key])
|
|
129
|
+
if (inner.authCode && inner.sharedId) return inner
|
|
130
|
+
}
|
|
131
|
+
if (typeof obj[key] === "string") {
|
|
132
|
+
const inner = extractAuth(obj[key])
|
|
133
|
+
if (inner.authCode && inner.sharedId) return inner
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return {}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (typeof data === "string") {
|
|
140
|
+
const s = data.trim()
|
|
141
|
+
if (!s) return {}
|
|
142
|
+
if (s.startsWith("{") || s.startsWith("[")) {
|
|
143
|
+
try {
|
|
144
|
+
return extractAuth(JSON.parse(s))
|
|
145
|
+
} catch {
|
|
146
|
+
/* not JSON */
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
if (/auth_?code/i.test(s) && /shared_?id/i.test(s)) {
|
|
150
|
+
try {
|
|
151
|
+
const sp = new URLSearchParams(s.replace(/^[?#]/, ""))
|
|
152
|
+
const authCode = sp.get("authCode") || sp.get("auth_code") || undefined
|
|
153
|
+
const sharedId = sp.get("sharedId") || sp.get("shared_id") || undefined
|
|
154
|
+
if (authCode && sharedId) return { authCode, sharedId }
|
|
155
|
+
} catch {
|
|
156
|
+
/* not a query string */
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return {}
|
|
162
|
+
}
|
|
163
|
+
|
|
108
164
|
|
|
109
165
|
export default function PayPalConnectionPage() {
|
|
110
166
|
const [env, setEnv] = useState<Env>("live")
|
|
111
|
-
// We must know the server's environment before generating/looking up a link so
|
|
112
|
-
// the per-environment cache and the generated link always match.
|
|
113
167
|
const [envReady, setEnvReady] = useState(false)
|
|
114
168
|
|
|
115
169
|
const [connState, setConnState] = useState<
|
|
116
170
|
"loading" | "ready" | "connected" | "error"
|
|
117
171
|
>("loading")
|
|
118
|
-
// True once partner.js has loaded and bound its click interceptor to the
|
|
119
|
-
// button. The button stays in the DOM but is not clickable until then, so a
|
|
120
|
-
// click can never fall through to the native anchor (which would open a tab
|
|
121
|
-
// without partner.js's onboarding flow / completion callback).
|
|
122
|
-
const [partnerReady, setPartnerReady] = useState(false)
|
|
123
172
|
const [error, setError] = useState<string | null>(null)
|
|
173
|
+
const [popupBlocked, setPopupBlocked] = useState(false)
|
|
124
174
|
const [finalUrl, setFinalUrl] = useState<string>("")
|
|
125
175
|
const [showManual, setShowManual] = useState(false)
|
|
126
176
|
const [clientId, setClientId] = useState("")
|
|
@@ -134,15 +184,22 @@ export default function PayPalConnectionPage() {
|
|
|
134
184
|
const [onboardingInProgress, setOnboardingInProgress] = useState(false)
|
|
135
185
|
|
|
136
186
|
const initLoaderRef = useRef<HTMLDivElement>(null)
|
|
187
|
+
const paypalButtonRef = useRef<HTMLAnchorElement | null>(null)
|
|
137
188
|
const errorLogRef = useRef<HTMLDivElement>(null)
|
|
138
189
|
const runIdRef = useRef(0)
|
|
139
190
|
const currentRunId = useRef(0)
|
|
140
191
|
|
|
141
192
|
const envRef = useRef<Env>(env)
|
|
142
193
|
envRef.current = env
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
194
|
+
const finalUrlRef = useRef(finalUrl)
|
|
195
|
+
finalUrlRef.current = finalUrl
|
|
196
|
+
const connStateRef = useRef(connState)
|
|
197
|
+
connStateRef.current = connState
|
|
198
|
+
const inProgressRef = useRef(onboardingInProgress)
|
|
199
|
+
inProgressRef.current = onboardingInProgress
|
|
200
|
+
|
|
201
|
+
const popupRef = useRef<Window | null>(null)
|
|
202
|
+
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
|
146
203
|
const completedRef = useRef(false)
|
|
147
204
|
|
|
148
205
|
const ppBtnMeasureRef = useRef<HTMLAnchorElement | null>(null)
|
|
@@ -157,17 +214,161 @@ export default function PayPalConnectionPage() {
|
|
|
157
214
|
setError(msg)
|
|
158
215
|
}, [])
|
|
159
216
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
217
|
+
const stopPoll = useCallback(() => {
|
|
218
|
+
if (pollRef.current) {
|
|
219
|
+
clearInterval(pollRef.current)
|
|
220
|
+
pollRef.current = null
|
|
221
|
+
}
|
|
222
|
+
}, [])
|
|
223
|
+
|
|
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
|
|
249
|
+
try {
|
|
250
|
+
popup.focus()
|
|
251
|
+
} catch {
|
|
252
|
+
/* ignore */
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return popup
|
|
256
|
+
}, [])
|
|
257
|
+
|
|
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.
|
|
261
|
+
const completeOnboarding = useCallback(
|
|
262
|
+
async (authCode: string, sharedId: string) => {
|
|
263
|
+
if (!authCode || !sharedId) return
|
|
264
|
+
if (completedRef.current) return
|
|
265
|
+
completedRef.current = true
|
|
266
|
+
|
|
267
|
+
stopPoll()
|
|
268
|
+
try {
|
|
269
|
+
window.onbeforeunload = null
|
|
270
|
+
} catch {}
|
|
271
|
+
|
|
272
|
+
const activeEnv: Env = envRef.current === "sandbox" ? "sandbox" : "live"
|
|
273
|
+
|
|
274
|
+
setOnboardingInProgress(true)
|
|
275
|
+
setConnState("loading")
|
|
276
|
+
setError(null)
|
|
277
|
+
setPopupBlocked(false)
|
|
278
|
+
|
|
279
|
+
try {
|
|
280
|
+
const res = await fetch(ONBOARDING_COMPLETE_ENDPOINT, {
|
|
281
|
+
method: "POST",
|
|
282
|
+
headers: { "content-type": "application/json" },
|
|
283
|
+
credentials: "include",
|
|
284
|
+
body: JSON.stringify({ authCode, sharedId, env: activeEnv }),
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
if (!res.ok) {
|
|
288
|
+
const txt = await res.text().catch(() => "")
|
|
289
|
+
throw new Error(txt || `Onboarding exchange failed (${res.status})`)
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
try {
|
|
293
|
+
popupRef.current?.close()
|
|
294
|
+
} catch {}
|
|
295
|
+
try {
|
|
296
|
+
const close1 = window.PAYPAL?.apps?.Signup?.MiniBrowser?.closeFlow
|
|
297
|
+
if (typeof close1 === "function") close1()
|
|
298
|
+
} catch {}
|
|
299
|
+
try {
|
|
300
|
+
const close2 =
|
|
301
|
+
window.PAYPAL?.apps?.Signup?.miniBrowser &&
|
|
302
|
+
(window.PAYPAL.apps.Signup.miniBrowser as any).closeFlow
|
|
303
|
+
if (typeof close2 === "function") close2()
|
|
304
|
+
} catch {}
|
|
305
|
+
|
|
306
|
+
clearCachedUrl(activeEnv)
|
|
307
|
+
|
|
308
|
+
try {
|
|
309
|
+
const statusRes = await fetch(`${STATUS_ENDPOINT}?environment=${activeEnv}`, {
|
|
310
|
+
method: "GET",
|
|
311
|
+
credentials: "include",
|
|
312
|
+
})
|
|
313
|
+
const refreshedStatus = await statusRes.json().catch(() => ({}))
|
|
314
|
+
setStatusInfo(refreshedStatus || null)
|
|
315
|
+
} catch {}
|
|
316
|
+
|
|
317
|
+
setConnState("connected")
|
|
318
|
+
setShowManual(false)
|
|
319
|
+
} catch (e: any) {
|
|
320
|
+
completedRef.current = false
|
|
321
|
+
console.error(e)
|
|
322
|
+
setConnState("error")
|
|
323
|
+
setError(e?.message || "Exchange failed while saving credentials.")
|
|
324
|
+
} finally {
|
|
325
|
+
setOnboardingInProgress(false)
|
|
326
|
+
}
|
|
327
|
+
},
|
|
328
|
+
[stopPoll]
|
|
329
|
+
)
|
|
330
|
+
|
|
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
|
+
|
|
164
367
|
const generateLinkAndReload = useCallback(
|
|
165
368
|
async (targetEnv: Env, runId: number) => {
|
|
166
369
|
if (initLoaderRef.current) {
|
|
167
370
|
const loaderText = initLoaderRef.current.querySelector("#loader-text")
|
|
168
|
-
if (loaderText)
|
|
169
|
-
loaderText.textContent = "Generating onboarding session..."
|
|
170
|
-
}
|
|
371
|
+
if (loaderText) loaderText.textContent = "Generating onboarding session..."
|
|
171
372
|
}
|
|
172
373
|
|
|
173
374
|
try {
|
|
@@ -193,16 +394,11 @@ export default function PayPalConnectionPage() {
|
|
|
193
394
|
href + (href.includes("?") ? "&" : "?") + "displayMode=minibrowser"
|
|
194
395
|
|
|
195
396
|
const persisted = writeCachedUrl(targetEnv, url)
|
|
196
|
-
|
|
197
397
|
if (persisted) {
|
|
198
|
-
// The cache survived; reload so the link is present at first render and
|
|
199
|
-
// partner.js initializes against it. The post-reload load finds the
|
|
200
|
-
// cache (no regeneration), so this can't loop.
|
|
201
398
|
window.location.reload()
|
|
202
399
|
return
|
|
203
400
|
}
|
|
204
401
|
|
|
205
|
-
// Storage disabled — use the link directly without reloading.
|
|
206
402
|
setFinalUrl(url)
|
|
207
403
|
setConnState("ready")
|
|
208
404
|
} catch {
|
|
@@ -231,11 +427,7 @@ export default function PayPalConnectionPage() {
|
|
|
231
427
|
}
|
|
232
428
|
}, [])
|
|
233
429
|
|
|
234
|
-
// Page-load flow:
|
|
235
|
-
// 1. If already connected, show the connected state.
|
|
236
|
-
// 2. Else if a valid cached link exists, use it (partner.js will bind it).
|
|
237
|
-
// 3. Else generate a link, cache it for 6h, and reload so the cached link is
|
|
238
|
-
// present for partner.js on the next load.
|
|
430
|
+
// Page-load flow: connected? -> cached link? -> else generate + cache + reload.
|
|
239
431
|
useEffect(() => {
|
|
240
432
|
if (!envReady) return
|
|
241
433
|
|
|
@@ -248,6 +440,7 @@ export default function PayPalConnectionPage() {
|
|
|
248
440
|
const run = async () => {
|
|
249
441
|
setConnState("loading")
|
|
250
442
|
setError(null)
|
|
443
|
+
setPopupBlocked(false)
|
|
251
444
|
setFinalUrl("")
|
|
252
445
|
|
|
253
446
|
try {
|
|
@@ -292,12 +485,9 @@ export default function PayPalConnectionPage() {
|
|
|
292
485
|
}
|
|
293
486
|
}, [env, envReady, generateLinkAndReload])
|
|
294
487
|
|
|
295
|
-
// Load partner.js
|
|
296
|
-
//
|
|
297
|
-
//
|
|
298
|
-
// ourselves for it to discover the button and take over the click into the
|
|
299
|
-
// mini-browser — opening the popup, and wiring its postMessage bridge that
|
|
300
|
-
// invokes window.onboardingCallback and closes the popup on completion.
|
|
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.
|
|
301
491
|
useEffect(() => {
|
|
302
492
|
if (connState !== "ready" || !finalUrl) return
|
|
303
493
|
|
|
@@ -305,19 +495,6 @@ export default function PayPalConnectionPage() {
|
|
|
305
495
|
let cancelled = false
|
|
306
496
|
let pollTimer: ReturnType<typeof setTimeout> | undefined
|
|
307
497
|
|
|
308
|
-
// The button is gated on partnerReady; reset it for this (re)load.
|
|
309
|
-
setPartnerReady(false)
|
|
310
|
-
|
|
311
|
-
// Safety net: if partner.js can't load (e.g. blocked), enable the button
|
|
312
|
-
// anyway after a few seconds rather than leaving it permanently disabled.
|
|
313
|
-
const readyFallback = setTimeout(() => {
|
|
314
|
-
if (!cancelled) setPartnerReady(true)
|
|
315
|
-
}, 6000)
|
|
316
|
-
|
|
317
|
-
const markReady = () => {
|
|
318
|
-
if (!cancelled) setPartnerReady(true)
|
|
319
|
-
}
|
|
320
|
-
|
|
321
498
|
const initPartner = (attempt = 0) => {
|
|
322
499
|
if (cancelled) return
|
|
323
500
|
|
|
@@ -335,35 +512,25 @@ export default function PayPalConnectionPage() {
|
|
|
335
512
|
} catch (e) {
|
|
336
513
|
console.error("[paypal] partner.js init failed:", e)
|
|
337
514
|
}
|
|
338
|
-
markReady()
|
|
339
515
|
return
|
|
340
516
|
}
|
|
341
|
-
|
|
342
517
|
if (typeof signup?.render === "function") {
|
|
343
518
|
try {
|
|
344
519
|
signup.render()
|
|
345
520
|
} catch (e) {
|
|
346
521
|
console.error("[paypal] partner.js render failed:", e)
|
|
347
522
|
}
|
|
348
|
-
markReady()
|
|
349
523
|
return
|
|
350
524
|
}
|
|
351
|
-
|
|
352
|
-
// The namespace may not be populated the instant onload fires; retry
|
|
353
|
-
// briefly before giving up.
|
|
354
525
|
if (attempt < 40) {
|
|
355
526
|
pollTimer = setTimeout(() => initPartner(attempt + 1), 50)
|
|
356
527
|
return
|
|
357
528
|
}
|
|
358
|
-
|
|
359
529
|
try {
|
|
360
530
|
document.dispatchEvent(new Event("DOMContentLoaded"))
|
|
361
531
|
} catch {}
|
|
362
|
-
markReady()
|
|
363
532
|
}
|
|
364
533
|
|
|
365
|
-
// Remove any stale copy + namespace so the fresh load re-registers against
|
|
366
|
-
// the current environment (sandbox vs live partner.js differ).
|
|
367
534
|
const existingScript = document.getElementById("paypal-partner-js")
|
|
368
535
|
if (existingScript) {
|
|
369
536
|
existingScript.parentNode?.removeChild(existingScript)
|
|
@@ -379,88 +546,53 @@ export default function PayPalConnectionPage() {
|
|
|
379
546
|
ppScript.src = scriptUrl
|
|
380
547
|
ppScript.async = true
|
|
381
548
|
ppScript.onload = () => initPartner()
|
|
382
|
-
ppScript.onerror = () => markReady()
|
|
383
549
|
document.body.appendChild(ppScript)
|
|
384
550
|
|
|
385
551
|
return () => {
|
|
386
552
|
cancelled = true
|
|
387
|
-
clearTimeout(
|
|
388
|
-
if (
|
|
389
|
-
clearTimeout(pollTimer)
|
|
390
|
-
}
|
|
391
|
-
if (ppScript.parentNode) {
|
|
392
|
-
ppScript.parentNode.removeChild(ppScript)
|
|
393
|
-
}
|
|
553
|
+
if (pollTimer) clearTimeout(pollTimer)
|
|
554
|
+
if (ppScript.parentNode) ppScript.parentNode.removeChild(ppScript)
|
|
394
555
|
}
|
|
395
556
|
}, [connState, finalUrl, env])
|
|
396
557
|
|
|
397
|
-
//
|
|
398
|
-
//
|
|
399
|
-
//
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
completedRef.current = true
|
|
404
|
-
|
|
405
|
-
try {
|
|
406
|
-
window.onbeforeunload = null
|
|
407
|
-
} catch {}
|
|
408
|
-
|
|
409
|
-
const activeEnv: Env = envRef.current === "sandbox" ? "sandbox" : "live"
|
|
410
|
-
|
|
411
|
-
setOnboardingInProgress(true)
|
|
412
|
-
setConnState("loading")
|
|
413
|
-
setError(null)
|
|
414
|
-
|
|
415
|
-
try {
|
|
416
|
-
const res = await fetch(ONBOARDING_COMPLETE_ENDPOINT, {
|
|
417
|
-
method: "POST",
|
|
418
|
-
headers: { "content-type": "application/json" },
|
|
419
|
-
credentials: "include",
|
|
420
|
-
body: JSON.stringify({ authCode, sharedId, env: activeEnv }),
|
|
421
|
-
})
|
|
422
|
-
|
|
423
|
-
if (!res.ok) {
|
|
424
|
-
const txt = await res.text().catch(() => "")
|
|
425
|
-
throw new Error(txt || `Onboarding exchange failed (${res.status})`)
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
// Close the partner.js mini-browser.
|
|
429
|
-
try {
|
|
430
|
-
const close1 = window.PAYPAL?.apps?.Signup?.MiniBrowser?.closeFlow
|
|
431
|
-
if (typeof close1 === "function") close1()
|
|
432
|
-
} catch {}
|
|
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.
|
|
561
|
+
useEffect(() => {
|
|
562
|
+
const onMessage = (ev: MessageEvent) => {
|
|
563
|
+
let serialized = ""
|
|
433
564
|
try {
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
// The link has been consumed; drop it so a future flow regenerates one.
|
|
441
|
-
clearCachedUrl(activeEnv)
|
|
565
|
+
serialized =
|
|
566
|
+
typeof ev.data === "string" ? ev.data : JSON.stringify(ev.data)
|
|
567
|
+
} catch {
|
|
568
|
+
serialized = String(ev.data)
|
|
569
|
+
}
|
|
442
570
|
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
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
|
+
}
|
|
451
584
|
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
setConnState("error")
|
|
458
|
-
setError(e?.message || "Exchange failed while saving credentials.")
|
|
459
|
-
} finally {
|
|
460
|
-
setOnboardingInProgress(false)
|
|
585
|
+
if (!isPayPalOrigin(ev.origin)) return
|
|
586
|
+
const { authCode, sharedId } = extractAuth(ev.data)
|
|
587
|
+
if (authCode && sharedId) {
|
|
588
|
+
completeOnboarding(authCode, sharedId)
|
|
589
|
+
}
|
|
461
590
|
}
|
|
462
|
-
|
|
591
|
+
window.addEventListener("message", onMessage)
|
|
592
|
+
return () => window.removeEventListener("message", onMessage)
|
|
593
|
+
}, [completeOnboarding])
|
|
463
594
|
|
|
595
|
+
// SECONDARY completion path: partner.js's bridge calls this by name.
|
|
464
596
|
useLayoutEffect(() => {
|
|
465
597
|
window.onboardingCallback = (authCode: string, sharedId: string) => {
|
|
466
598
|
completeOnboarding(authCode, sharedId)
|
|
@@ -470,6 +602,45 @@ export default function PayPalConnectionPage() {
|
|
|
470
602
|
}
|
|
471
603
|
}, [completeOnboarding])
|
|
472
604
|
|
|
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.
|
|
608
|
+
useEffect(() => {
|
|
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()
|
|
634
|
+
}
|
|
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])
|
|
643
|
+
|
|
473
644
|
useLayoutEffect(() => {
|
|
474
645
|
const el = ppBtnMeasureRef.current
|
|
475
646
|
if (!el) return
|
|
@@ -495,6 +666,12 @@ export default function PayPalConnectionPage() {
|
|
|
495
666
|
}
|
|
496
667
|
}, [connState, env, finalUrl])
|
|
497
668
|
|
|
669
|
+
// Defensive: the capture listener handles the click; this just guarantees the
|
|
670
|
+
// native href can never navigate.
|
|
671
|
+
const handleConnectClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
|
|
672
|
+
e.preventDefault()
|
|
673
|
+
}
|
|
674
|
+
|
|
498
675
|
const handleSaveManual = async () => {
|
|
499
676
|
if (!canSaveManual || onboardingInProgress) return
|
|
500
677
|
setOnboardingInProgress(true)
|
|
@@ -565,8 +742,6 @@ export default function PayPalConnectionPage() {
|
|
|
565
742
|
clearCachedUrl(env)
|
|
566
743
|
completedRef.current = false
|
|
567
744
|
|
|
568
|
-
// Generate a fresh link for the now-disconnected environment and reload so
|
|
569
|
-
// the onboarding button is ready again.
|
|
570
745
|
currentRunId.current = ++runIdRef.current
|
|
571
746
|
await generateLinkAndReload(env, currentRunId.current)
|
|
572
747
|
} catch (e: any) {
|
|
@@ -584,10 +759,9 @@ export default function PayPalConnectionPage() {
|
|
|
584
759
|
|
|
585
760
|
completedRef.current = false
|
|
586
761
|
clearCachedUrl()
|
|
762
|
+
setPopupBlocked(false)
|
|
587
763
|
setConnState("loading")
|
|
588
764
|
|
|
589
|
-
// Persist the environment BEFORE switching local state so the post-reload
|
|
590
|
-
// GET /environment returns the same value the cache was keyed under.
|
|
591
765
|
try {
|
|
592
766
|
await fetch(ENVIRONMENT_ENDPOINT, {
|
|
593
767
|
method: "POST",
|
|
@@ -676,12 +850,9 @@ export default function PayPalConnectionPage() {
|
|
|
676
850
|
</div>
|
|
677
851
|
|
|
678
852
|
<div className={`${connState === "ready" ? "block" : "hidden"}`}>
|
|
679
|
-
{/* Standard PayPal partner onboarding button. partner.js binds
|
|
680
|
-
this anchor (it carries the cached onboarding href) and
|
|
681
|
-
takes over the click into the mini-browser; on completion
|
|
682
|
-
it calls window.onboardingCallback. */}
|
|
683
853
|
<a
|
|
684
854
|
ref={(node) => {
|
|
855
|
+
paypalButtonRef.current = node
|
|
685
856
|
ppBtnMeasureRef.current = node
|
|
686
857
|
}}
|
|
687
858
|
id="paypal-button"
|
|
@@ -689,26 +860,24 @@ export default function PayPalConnectionPage() {
|
|
|
689
860
|
data-paypal-button="true"
|
|
690
861
|
href={finalUrl || "#"}
|
|
691
862
|
data-paypal-onboard-complete="onboardingCallback"
|
|
692
|
-
|
|
863
|
+
onClick={handleConnectClick}
|
|
693
864
|
className="transition-fg relative inline-flex w-fit items-center justify-center overflow-hidden rounded-md outline-none no-underline shadow-buttons-neutral text-ui-fg-base bg-ui-button-neutral after:transition-fg after:absolute after:inset-0 after:content-[''] after:button-neutral-gradient hover:bg-ui-button-neutral-hover hover:after:button-neutral-hover-gradient active:bg-ui-button-neutral-pressed active:after:button-neutral-pressed-gradient focus-visible:shadow-buttons-neutral-focus disabled:bg-ui-bg-disabled disabled:border-ui-border-base disabled:text-ui-fg-disabled disabled:shadow-buttons-neutral disabled:after:hidden txt-compact-small-plus px-3 py-1.5"
|
|
694
865
|
style={{
|
|
695
|
-
cursor:
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
: "pointer",
|
|
699
|
-
opacity: onboardingInProgress || !partnerReady ? 0.6 : 1,
|
|
700
|
-
// Keep the anchor mounted (partner.js must find it to bind)
|
|
701
|
-
// but un-clickable until partner.js has taken over the
|
|
702
|
-
// click — so it can never open a tab via the native href.
|
|
703
|
-
pointerEvents:
|
|
704
|
-
onboardingInProgress || !partnerReady ? "none" : "auto",
|
|
866
|
+
cursor: onboardingInProgress ? "not-allowed" : "pointer",
|
|
867
|
+
opacity: onboardingInProgress ? 0.6 : 1,
|
|
868
|
+
pointerEvents: onboardingInProgress ? "none" : "auto",
|
|
705
869
|
}}
|
|
706
870
|
>
|
|
707
|
-
|
|
708
|
-
? "Connect to PayPal"
|
|
709
|
-
: "Preparing PayPal…"}
|
|
871
|
+
Connect to PayPal
|
|
710
872
|
</a>
|
|
711
873
|
|
|
874
|
+
{popupBlocked && (
|
|
875
|
+
<div className="mt-3 text-left text-xs bg-amber-50 text-amber-700 p-3 border border-amber-200 rounded">
|
|
876
|
+
Your browser blocked the PayPal popup. Please allow popups
|
|
877
|
+
for this site, then click “Connect to PayPal” again.
|
|
878
|
+
</div>
|
|
879
|
+
)}
|
|
880
|
+
|
|
712
881
|
<div
|
|
713
882
|
className="mt-2"
|
|
714
883
|
style={{
|