@easypayment/medusa-payment-paypal 0.9.22 → 0.9.25
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 +264 -80
- package/.medusa/server/src/admin/index.mjs +264 -80
- 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 +340 -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/onboard-complete/route.d.ts +7 -0
- package/.medusa/server/src/api/admin/paypal/onboard-complete/route.d.ts.map +1 -1
- package/.medusa/server/src/api/admin/paypal/onboard-complete/route.js +7 -0
- package/.medusa/server/src/api/admin/paypal/onboard-complete/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/onboard-return/route.d.ts +23 -0
- package/.medusa/server/src/api/store/paypal/onboard-return/route.d.ts.map +1 -0
- package/.medusa/server/src/api/store/paypal/onboard-return/route.js +63 -0
- package/.medusa/server/src/api/store/paypal/onboard-return/route.js.map +1 -0
- 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/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/.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 +364 -160
- 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/onboard-complete/route.ts +7 -0
- 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/onboard-return/route.ts +63 -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/service.ts +5 -1
- 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,76 @@ 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
|
-
}
|
|
565
|
+
serialized =
|
|
566
|
+
typeof ev.data === "string" ? ev.data : JSON.stringify(ev.data)
|
|
567
|
+
} catch {
|
|
568
|
+
serialized = String(ev.data)
|
|
569
|
+
}
|
|
439
570
|
|
|
440
|
-
|
|
441
|
-
|
|
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
|
+
}
|
|
442
584
|
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
585
|
+
// TERTIARY completion path: our own return_url bridge page (served by
|
|
586
|
+
// GET /store/paypal/onboard-return) posts this from inside the popup when
|
|
587
|
+
// PayPal redirects there at the end of onboarding. It runs on our backend
|
|
588
|
+
// origin (not paypal.com), so it is handled before the PayPal-origin gate
|
|
589
|
+
// below. It guarantees the popup is closed and the status is re-checked even
|
|
590
|
+
// if every PayPal postMessage was missed.
|
|
591
|
+
const d = ev.data as any
|
|
592
|
+
if (d && typeof d === "object" && d.source === "paypal-onboarding-return") {
|
|
593
|
+
try {
|
|
594
|
+
popupRef.current?.close()
|
|
595
|
+
} catch {}
|
|
596
|
+
const fromReturn = extractAuth(d.params)
|
|
597
|
+
if (fromReturn.authCode && fromReturn.sharedId) {
|
|
598
|
+
completeOnboarding(fromReturn.authCode, fromReturn.sharedId)
|
|
599
|
+
} else if (!completedRef.current) {
|
|
600
|
+
// No authCode/sharedId on the return URL (the usual case): credentials
|
|
601
|
+
// are exchanged via the postMessage/onboardingCallback path. Poll the
|
|
602
|
+
// server so the UI flips to "connected" as soon as that lands.
|
|
603
|
+
startStatusPoll()
|
|
604
|
+
}
|
|
605
|
+
return
|
|
606
|
+
}
|
|
451
607
|
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
setConnState("error")
|
|
458
|
-
setError(e?.message || "Exchange failed while saving credentials.")
|
|
459
|
-
} finally {
|
|
460
|
-
setOnboardingInProgress(false)
|
|
608
|
+
if (!isPayPalOrigin(ev.origin)) return
|
|
609
|
+
const { authCode, sharedId } = extractAuth(ev.data)
|
|
610
|
+
if (authCode && sharedId) {
|
|
611
|
+
completeOnboarding(authCode, sharedId)
|
|
612
|
+
}
|
|
461
613
|
}
|
|
462
|
-
|
|
614
|
+
window.addEventListener("message", onMessage)
|
|
615
|
+
return () => window.removeEventListener("message", onMessage)
|
|
616
|
+
}, [completeOnboarding, startStatusPoll])
|
|
463
617
|
|
|
618
|
+
// SECONDARY completion path: partner.js's bridge calls this by name.
|
|
464
619
|
useLayoutEffect(() => {
|
|
465
620
|
window.onboardingCallback = (authCode: string, sharedId: string) => {
|
|
466
621
|
completeOnboarding(authCode, sharedId)
|
|
@@ -470,6 +625,57 @@ export default function PayPalConnectionPage() {
|
|
|
470
625
|
}
|
|
471
626
|
}, [completeOnboarding])
|
|
472
627
|
|
|
628
|
+
// Capture-phase opener: runs before partner.js and opens the popup
|
|
629
|
+
// synchronously inside the user gesture so the mini-browser reliably opens as a
|
|
630
|
+
// popup (never a blocked window or a new tab). We open it under PayPal's
|
|
631
|
+
// conventional window name (POPUP_NAME / "PPFrame"); because partner.js also
|
|
632
|
+
// targets that same name, letting its click handler run next makes it *adopt*
|
|
633
|
+
// this very window instead of opening a second one. That adoption is what lets
|
|
634
|
+
// partner.js receive PayPal's completion postMessage, invoke onboardingCallback
|
|
635
|
+
// and auto-close the popup.
|
|
636
|
+
//
|
|
637
|
+
// IMPORTANT: we only preventDefault() (to stop the native anchor from opening a
|
|
638
|
+
// new tab) and deliberately DO NOT stopImmediatePropagation(): stopping it was
|
|
639
|
+
// what previously prevented partner.js from tracking the window, which broke
|
|
640
|
+
// onboarding completion (the popup never closed and credentials were never
|
|
641
|
+
// exchanged).
|
|
642
|
+
useEffect(() => {
|
|
643
|
+
const onCaptureClick = (e: MouseEvent) => {
|
|
644
|
+
const btn = paypalButtonRef.current
|
|
645
|
+
if (!btn) return
|
|
646
|
+
const target = e.target as Node | null
|
|
647
|
+
if (!target || !btn.contains(target)) return
|
|
648
|
+
|
|
649
|
+
// Stop the native anchor navigation only. Let the event keep propagating so
|
|
650
|
+
// partner.js's own click handler still runs and adopts our popup window.
|
|
651
|
+
e.preventDefault()
|
|
652
|
+
|
|
653
|
+
if (
|
|
654
|
+
connStateRef.current !== "ready" ||
|
|
655
|
+
!finalUrlRef.current ||
|
|
656
|
+
inProgressRef.current
|
|
657
|
+
) {
|
|
658
|
+
return
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
completedRef.current = false
|
|
662
|
+
const popup = openOnboardingPopup(finalUrlRef.current)
|
|
663
|
+
if (!popup) {
|
|
664
|
+
setPopupBlocked(true)
|
|
665
|
+
return
|
|
666
|
+
}
|
|
667
|
+
setPopupBlocked(false)
|
|
668
|
+
startStatusPoll()
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
document.addEventListener("click", onCaptureClick, true)
|
|
672
|
+
return () => document.removeEventListener("click", onCaptureClick, true)
|
|
673
|
+
}, [openOnboardingPopup, startStatusPoll])
|
|
674
|
+
|
|
675
|
+
useEffect(() => {
|
|
676
|
+
return () => stopPoll()
|
|
677
|
+
}, [stopPoll])
|
|
678
|
+
|
|
473
679
|
useLayoutEffect(() => {
|
|
474
680
|
const el = ppBtnMeasureRef.current
|
|
475
681
|
if (!el) return
|
|
@@ -495,6 +701,12 @@ export default function PayPalConnectionPage() {
|
|
|
495
701
|
}
|
|
496
702
|
}, [connState, env, finalUrl])
|
|
497
703
|
|
|
704
|
+
// Defensive: the capture listener handles the click; this just guarantees the
|
|
705
|
+
// native href can never navigate.
|
|
706
|
+
const handleConnectClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
|
|
707
|
+
e.preventDefault()
|
|
708
|
+
}
|
|
709
|
+
|
|
498
710
|
const handleSaveManual = async () => {
|
|
499
711
|
if (!canSaveManual || onboardingInProgress) return
|
|
500
712
|
setOnboardingInProgress(true)
|
|
@@ -565,8 +777,6 @@ export default function PayPalConnectionPage() {
|
|
|
565
777
|
clearCachedUrl(env)
|
|
566
778
|
completedRef.current = false
|
|
567
779
|
|
|
568
|
-
// Generate a fresh link for the now-disconnected environment and reload so
|
|
569
|
-
// the onboarding button is ready again.
|
|
570
780
|
currentRunId.current = ++runIdRef.current
|
|
571
781
|
await generateLinkAndReload(env, currentRunId.current)
|
|
572
782
|
} catch (e: any) {
|
|
@@ -584,10 +794,9 @@ export default function PayPalConnectionPage() {
|
|
|
584
794
|
|
|
585
795
|
completedRef.current = false
|
|
586
796
|
clearCachedUrl()
|
|
797
|
+
setPopupBlocked(false)
|
|
587
798
|
setConnState("loading")
|
|
588
799
|
|
|
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
800
|
try {
|
|
592
801
|
await fetch(ENVIRONMENT_ENDPOINT, {
|
|
593
802
|
method: "POST",
|
|
@@ -676,12 +885,9 @@ export default function PayPalConnectionPage() {
|
|
|
676
885
|
</div>
|
|
677
886
|
|
|
678
887
|
<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
888
|
<a
|
|
684
889
|
ref={(node) => {
|
|
890
|
+
paypalButtonRef.current = node
|
|
685
891
|
ppBtnMeasureRef.current = node
|
|
686
892
|
}}
|
|
687
893
|
id="paypal-button"
|
|
@@ -689,26 +895,24 @@ export default function PayPalConnectionPage() {
|
|
|
689
895
|
data-paypal-button="true"
|
|
690
896
|
href={finalUrl || "#"}
|
|
691
897
|
data-paypal-onboard-complete="onboardingCallback"
|
|
692
|
-
|
|
898
|
+
onClick={handleConnectClick}
|
|
693
899
|
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
900
|
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",
|
|
901
|
+
cursor: onboardingInProgress ? "not-allowed" : "pointer",
|
|
902
|
+
opacity: onboardingInProgress ? 0.6 : 1,
|
|
903
|
+
pointerEvents: onboardingInProgress ? "none" : "auto",
|
|
705
904
|
}}
|
|
706
905
|
>
|
|
707
|
-
|
|
708
|
-
? "Connect to PayPal"
|
|
709
|
-
: "Preparing PayPal…"}
|
|
906
|
+
Connect to PayPal
|
|
710
907
|
</a>
|
|
711
908
|
|
|
909
|
+
{popupBlocked && (
|
|
910
|
+
<div className="mt-3 text-left text-xs bg-amber-50 text-amber-700 p-3 border border-amber-200 rounded">
|
|
911
|
+
Your browser blocked the PayPal popup. Please allow popups
|
|
912
|
+
for this site, then click “Connect to PayPal” again.
|
|
913
|
+
</div>
|
|
914
|
+
)}
|
|
915
|
+
|
|
712
916
|
<div
|
|
713
917
|
className="mt-2"
|
|
714
918
|
style={{
|