@easypayment/medusa-payment-paypal 0.9.25 → 0.9.27
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 +183 -297
- package/.medusa/server/src/admin/index.mjs +183 -297
- package/.medusa/server/src/admin/routes/settings/paypal/connection/page.d.ts +0 -1
- 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 +204 -368
- package/.medusa/server/src/admin/routes/settings/paypal/connection/page.js.map +1 -1
- package/.medusa/server/src/api/store/paypal/onboard-return/route.d.ts +11 -9
- package/.medusa/server/src/api/store/paypal/onboard-return/route.d.ts.map +1 -1
- package/.medusa/server/src/api/store/paypal/onboard-return/route.js +28 -9
- package/.medusa/server/src/api/store/paypal/onboard-return/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 +67 -55
- package/.medusa/server/src/modules/paypal/payment-provider/card-service.js.map +1 -1
- package/.medusa/server/src/modules/paypal/payment-provider/service.d.ts.map +1 -1
- package/.medusa/server/src/modules/paypal/payment-provider/service.js +15 -5
- package/.medusa/server/src/modules/paypal/payment-provider/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 +32 -6
- package/.medusa/server/src/modules/paypal/service.js.map +1 -1
- package/.medusa/server/src/modules/paypal/utils/amounts.d.ts +10 -0
- package/.medusa/server/src/modules/paypal/utils/amounts.d.ts.map +1 -1
- package/.medusa/server/src/modules/paypal/utils/amounts.js +36 -0
- package/.medusa/server/src/modules/paypal/utils/amounts.js.map +1 -1
- package/package.json +1 -1
- package/src/admin/routes/settings/paypal/connection/page.tsx +230 -389
- package/src/api/store/paypal/onboard-return/route.ts +34 -9
- package/src/modules/paypal/payment-provider/card-service.ts +85 -65
- package/src/modules/paypal/payment-provider/service.ts +22 -7
- package/src/modules/paypal/service.ts +34 -7
- package/src/modules/paypal/utils/amounts.ts +36 -0
|
@@ -13,11 +13,16 @@ export const config = defineRouteConfig({
|
|
|
13
13
|
label: "PayPal Connection",
|
|
14
14
|
})
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
16
|
+
// Let partner.js own the entire onboarding flow: it opens PayPal's mini-browser
|
|
17
|
+
// itself (synchronously inside the click, so no new tab / no popup block), it
|
|
18
|
+
// receives PayPal's completion postMessage, and it invokes the callback named in
|
|
19
|
+
// `data-paypal-onboard-complete` with (authCode, sharedId). This is PayPal's
|
|
20
|
+
// documented integration and matches the official @easypayment WooCommerce
|
|
21
|
+
// plugin. Opening the popup ourselves (a previous attempt at fixing the "opens a
|
|
22
|
+
// new tab" issue) stopped partner.js from receiving completion, which left the
|
|
23
|
+
// popup stranded on PayPal's "isuDone" page and never saved credentials.
|
|
24
|
+
const PARTNER_JS_URL =
|
|
25
|
+
"https://www.paypal.com/webapps/merchantboarding/js/lib/lightbox/partner.js"
|
|
21
26
|
|
|
22
27
|
declare global {
|
|
23
28
|
interface Window {
|
|
@@ -26,7 +31,6 @@ declare global {
|
|
|
26
31
|
Signup?: {
|
|
27
32
|
miniBrowser?: { init?: () => void; closeFlow?: () => void }
|
|
28
33
|
MiniBrowser?: { init?: () => void; closeFlow?: () => void }
|
|
29
|
-
render?: () => void
|
|
30
34
|
}
|
|
31
35
|
}
|
|
32
36
|
}
|
|
@@ -34,13 +38,7 @@ declare global {
|
|
|
34
38
|
}
|
|
35
39
|
}
|
|
36
40
|
|
|
37
|
-
|
|
38
41
|
const SERVICE_URL = "/admin/paypal/onboarding-link"
|
|
39
|
-
// PayPal's conventional mini-browser window name (also the anchor target).
|
|
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.
|
|
44
42
|
const CACHE_PREFIX = "pp_onboard_cache"
|
|
45
43
|
const CACHE_EXPIRY = 6 * 60 * 60 * 1000 // 6 hours
|
|
46
44
|
|
|
@@ -73,12 +71,11 @@ function readCachedUrl(env: Env): string | null {
|
|
|
73
71
|
return null
|
|
74
72
|
}
|
|
75
73
|
|
|
76
|
-
function writeCachedUrl(env: Env, url: string)
|
|
74
|
+
function writeCachedUrl(env: Env, url: string) {
|
|
77
75
|
try {
|
|
78
76
|
localStorage.setItem(cacheKeyFor(env), JSON.stringify({ url, ts: Date.now() }))
|
|
79
|
-
return readCachedUrl(env) === url
|
|
80
77
|
} catch {
|
|
81
|
-
|
|
78
|
+
/* ignore */
|
|
82
79
|
}
|
|
83
80
|
}
|
|
84
81
|
|
|
@@ -95,73 +92,6 @@ function clearCachedUrl(env?: Env) {
|
|
|
95
92
|
}
|
|
96
93
|
}
|
|
97
94
|
|
|
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
|
-
|
|
164
|
-
|
|
165
95
|
export default function PayPalConnectionPage() {
|
|
166
96
|
const [env, setEnv] = useState<Env>("live")
|
|
167
97
|
const [envReady, setEnvReady] = useState(false)
|
|
@@ -170,7 +100,6 @@ export default function PayPalConnectionPage() {
|
|
|
170
100
|
"loading" | "ready" | "connected" | "error"
|
|
171
101
|
>("loading")
|
|
172
102
|
const [error, setError] = useState<string | null>(null)
|
|
173
|
-
const [popupBlocked, setPopupBlocked] = useState(false)
|
|
174
103
|
const [finalUrl, setFinalUrl] = useState<string>("")
|
|
175
104
|
const [showManual, setShowManual] = useState(false)
|
|
176
105
|
const [clientId, setClientId] = useState("")
|
|
@@ -188,19 +117,15 @@ export default function PayPalConnectionPage() {
|
|
|
188
117
|
const errorLogRef = useRef<HTMLDivElement>(null)
|
|
189
118
|
const runIdRef = useRef(0)
|
|
190
119
|
const currentRunId = useRef(0)
|
|
120
|
+
const completedRef = useRef(false)
|
|
191
121
|
|
|
122
|
+
// Long-lived listeners/timers read the current env through a ref so they never
|
|
123
|
+
// capture a stale value from the render that registered them.
|
|
192
124
|
const envRef = useRef<Env>(env)
|
|
193
125
|
envRef.current = env
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
const
|
|
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)
|
|
203
|
-
const completedRef = useRef(false)
|
|
126
|
+
|
|
127
|
+
const pollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
128
|
+
const pollAttemptsRef = useRef(0)
|
|
204
129
|
|
|
205
130
|
const ppBtnMeasureRef = useRef<HTMLAnchorElement | null>(null)
|
|
206
131
|
const [ppBtnWidth, setPpBtnWidth] = useState<number | null>(null)
|
|
@@ -214,57 +139,89 @@ export default function PayPalConnectionPage() {
|
|
|
214
139
|
setError(msg)
|
|
215
140
|
}, [])
|
|
216
141
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
142
|
+
// Close PayPal's mini-browser via partner.js's API (works under both the
|
|
143
|
+
// `miniBrowser` and `MiniBrowser` casings PayPal has shipped over time).
|
|
144
|
+
const closeMiniBrowser = useCallback(() => {
|
|
145
|
+
try {
|
|
146
|
+
const close1 = window.PAYPAL?.apps?.Signup?.MiniBrowser?.closeFlow
|
|
147
|
+
if (typeof close1 === "function") close1()
|
|
148
|
+
} catch {}
|
|
149
|
+
try {
|
|
150
|
+
const close2 = window.PAYPAL?.apps?.Signup?.miniBrowser?.closeFlow
|
|
151
|
+
if (typeof close2 === "function") close2()
|
|
152
|
+
} catch {}
|
|
222
153
|
}, [])
|
|
223
154
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
|
155
|
+
const stopStatusPolling = useCallback(() => {
|
|
156
|
+
if (pollTimerRef.current) {
|
|
157
|
+
clearTimeout(pollTimerRef.current)
|
|
158
|
+
pollTimerRef.current = null
|
|
246
159
|
}
|
|
247
|
-
|
|
248
|
-
|
|
160
|
+
pollAttemptsRef.current = 0
|
|
161
|
+
}, [])
|
|
162
|
+
|
|
163
|
+
// Fetch status for an env and, if the seller credentials are present, flip the
|
|
164
|
+
// UI to "connected" and close the PayPal popup. Returns true once connected.
|
|
165
|
+
const refreshStatusAndMaybeConnect = useCallback(
|
|
166
|
+
async (activeEnv: Env): Promise<boolean> => {
|
|
249
167
|
try {
|
|
250
|
-
|
|
168
|
+
const res = await fetch(`${STATUS_ENDPOINT}?environment=${activeEnv}`, {
|
|
169
|
+
method: "GET",
|
|
170
|
+
credentials: "include",
|
|
171
|
+
})
|
|
172
|
+
const st = await res.json().catch(() => ({}))
|
|
173
|
+
const connected =
|
|
174
|
+
st?.status === "connected" && st?.seller_client_id_present === true
|
|
175
|
+
if (connected) {
|
|
176
|
+
completedRef.current = true
|
|
177
|
+
setStatusInfo(st)
|
|
178
|
+
setConnState("connected")
|
|
179
|
+
setShowManual(false)
|
|
180
|
+
setOnboardingInProgress(false)
|
|
181
|
+
clearCachedUrl(activeEnv)
|
|
182
|
+
closeMiniBrowser()
|
|
183
|
+
stopStatusPolling()
|
|
184
|
+
}
|
|
185
|
+
return connected
|
|
251
186
|
} catch {
|
|
252
|
-
|
|
187
|
+
return false
|
|
188
|
+
}
|
|
189
|
+
},
|
|
190
|
+
[closeMiniBrowser, stopStatusPolling]
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
// While a connect attempt is in flight, the seller may complete onboarding via
|
|
194
|
+
// a path that never reaches the opener (e.g. partner.js fails to fire its
|
|
195
|
+
// callback, or the popup's completion message is blocked). The return_url
|
|
196
|
+
// bridge still saves credentials server-side, so poll status as the reliable
|
|
197
|
+
// fallback: the moment credentials land, this flips to "connected" and closes
|
|
198
|
+
// the stranded popup.
|
|
199
|
+
const startStatusPolling = useCallback(() => {
|
|
200
|
+
stopStatusPolling()
|
|
201
|
+
const MAX_ATTEMPTS = 100 // ~5 min at 3s intervals
|
|
202
|
+
const tick = async () => {
|
|
203
|
+
pollAttemptsRef.current += 1
|
|
204
|
+
const connected = await refreshStatusAndMaybeConnect(envRef.current)
|
|
205
|
+
if (connected || completedRef.current) return
|
|
206
|
+
if (pollAttemptsRef.current >= MAX_ATTEMPTS) {
|
|
207
|
+
stopStatusPolling()
|
|
208
|
+
return
|
|
253
209
|
}
|
|
210
|
+
pollTimerRef.current = setTimeout(tick, 3000)
|
|
254
211
|
}
|
|
255
|
-
|
|
256
|
-
}, [])
|
|
212
|
+
pollTimerRef.current = setTimeout(tick, 3000)
|
|
213
|
+
}, [refreshStatusAndMaybeConnect, stopStatusPolling])
|
|
257
214
|
|
|
258
|
-
//
|
|
259
|
-
//
|
|
260
|
-
//
|
|
215
|
+
// Shared completion: exchange authCode/sharedId for seller credentials, then
|
|
216
|
+
// close the popup and refresh status. Invoked by partner.js's onboardingCallback
|
|
217
|
+
// and by the return_url bridge's postMessage. Guarded + server-idempotent so
|
|
218
|
+
// redundant invocations are harmless.
|
|
261
219
|
const completeOnboarding = useCallback(
|
|
262
220
|
async (authCode: string, sharedId: string) => {
|
|
263
221
|
if (!authCode || !sharedId) return
|
|
264
222
|
if (completedRef.current) return
|
|
265
223
|
completedRef.current = true
|
|
266
224
|
|
|
267
|
-
stopPoll()
|
|
268
225
|
try {
|
|
269
226
|
window.onbeforeunload = null
|
|
270
227
|
} catch {}
|
|
@@ -274,7 +231,6 @@ export default function PayPalConnectionPage() {
|
|
|
274
231
|
setOnboardingInProgress(true)
|
|
275
232
|
setConnState("loading")
|
|
276
233
|
setError(null)
|
|
277
|
-
setPopupBlocked(false)
|
|
278
234
|
|
|
279
235
|
try {
|
|
280
236
|
const res = await fetch(ONBOARDING_COMPLETE_ENDPOINT, {
|
|
@@ -289,82 +245,114 @@ export default function PayPalConnectionPage() {
|
|
|
289
245
|
throw new Error(txt || `Onboarding exchange failed (${res.status})`)
|
|
290
246
|
}
|
|
291
247
|
|
|
292
|
-
|
|
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
|
-
|
|
248
|
+
closeMiniBrowser()
|
|
306
249
|
clearCachedUrl(activeEnv)
|
|
307
250
|
|
|
308
251
|
try {
|
|
309
|
-
const statusRes = await fetch(
|
|
310
|
-
|
|
311
|
-
credentials: "include"
|
|
312
|
-
|
|
252
|
+
const statusRes = await fetch(
|
|
253
|
+
`${STATUS_ENDPOINT}?environment=${activeEnv}`,
|
|
254
|
+
{ method: "GET", credentials: "include" }
|
|
255
|
+
)
|
|
313
256
|
const refreshedStatus = await statusRes.json().catch(() => ({}))
|
|
314
257
|
setStatusInfo(refreshedStatus || null)
|
|
315
258
|
} catch {}
|
|
316
259
|
|
|
317
260
|
setConnState("connected")
|
|
318
261
|
setShowManual(false)
|
|
262
|
+
stopStatusPolling()
|
|
319
263
|
} catch (e: any) {
|
|
320
264
|
completedRef.current = false
|
|
321
265
|
console.error(e)
|
|
266
|
+
// The bridge may have already saved credentials server-side; if so the
|
|
267
|
+
// poll will surface "connected". Don't strand the user on an error.
|
|
322
268
|
setConnState("error")
|
|
323
269
|
setError(e?.message || "Exchange failed while saving credentials.")
|
|
324
270
|
} finally {
|
|
325
271
|
setOnboardingInProgress(false)
|
|
326
272
|
}
|
|
327
273
|
},
|
|
328
|
-
[
|
|
274
|
+
[closeMiniBrowser, stopStatusPolling]
|
|
329
275
|
)
|
|
330
276
|
|
|
331
|
-
//
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
ticks += 1
|
|
339
|
-
if (completedRef.current || ticks > 150 /* ~5 min */) {
|
|
340
|
-
stopPoll()
|
|
341
|
-
return
|
|
342
|
-
}
|
|
277
|
+
// Bind partner.js to the connect button once it has a real onboarding URL.
|
|
278
|
+
const initPartner = useCallback(() => {
|
|
279
|
+
const signup = window.PAYPAL?.apps?.Signup
|
|
280
|
+
const init =
|
|
281
|
+
(signup?.miniBrowser && signup.miniBrowser.init) ||
|
|
282
|
+
(signup?.MiniBrowser && signup.MiniBrowser.init)
|
|
283
|
+
if (typeof init === "function") {
|
|
343
284
|
try {
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
285
|
+
init()
|
|
286
|
+
} catch (e) {
|
|
287
|
+
console.error("[paypal] partner.js init failed:", e)
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}, [])
|
|
291
|
+
|
|
292
|
+
// Load partner.js once for the lifetime of the page.
|
|
293
|
+
useEffect(() => {
|
|
294
|
+
const existingScript = document.getElementById("paypal-partner-js")
|
|
295
|
+
if (existingScript) return
|
|
296
|
+
|
|
297
|
+
const preloadHref = PARTNER_JS_URL
|
|
298
|
+
let preloadLink: HTMLLinkElement | null = null
|
|
299
|
+
if (!document.head.querySelector(`link[rel="preload"][href="${preloadHref}"]`)) {
|
|
300
|
+
preloadLink = document.createElement("link")
|
|
301
|
+
preloadLink.rel = "preload"
|
|
302
|
+
preloadLink.href = preloadHref
|
|
303
|
+
preloadLink.as = "script"
|
|
304
|
+
document.head.appendChild(preloadLink)
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const ppScript = document.createElement("script")
|
|
308
|
+
ppScript.id = "paypal-partner-js"
|
|
309
|
+
ppScript.src = preloadHref
|
|
310
|
+
ppScript.async = true
|
|
311
|
+
document.head.appendChild(ppScript)
|
|
312
|
+
|
|
313
|
+
return () => {
|
|
314
|
+
if (preloadLink?.parentNode) preloadLink.parentNode.removeChild(preloadLink)
|
|
315
|
+
if (ppScript.parentNode) ppScript.parentNode.removeChild(ppScript)
|
|
316
|
+
}
|
|
317
|
+
}, [])
|
|
318
|
+
|
|
319
|
+
// Put the (cached or freshly generated) onboarding URL on the button, then wait
|
|
320
|
+
// for partner.js to be present before showing the button + initializing it. The
|
|
321
|
+
// button is only revealed once partner.js is ready, which is what guarantees
|
|
322
|
+
// the first click opens the mini-browser popup (never a new tab).
|
|
323
|
+
const activatePayPal = useCallback(
|
|
324
|
+
(url: string, runId: number) => {
|
|
325
|
+
if (paypalButtonRef.current) {
|
|
326
|
+
paypalButtonRef.current.href = url
|
|
327
|
+
}
|
|
328
|
+
setFinalUrl(url)
|
|
329
|
+
|
|
330
|
+
let attempts = 0
|
|
331
|
+
const MAX_ATTEMPTS = 200 // ~10s
|
|
332
|
+
|
|
333
|
+
const tryInit = () => {
|
|
334
|
+
if (runId !== currentRunId.current) return
|
|
335
|
+
if (window.PAYPAL?.apps?.Signup) {
|
|
336
|
+
initPartner()
|
|
337
|
+
setConnState("ready")
|
|
338
|
+
return
|
|
360
339
|
}
|
|
361
|
-
|
|
362
|
-
|
|
340
|
+
attempts++
|
|
341
|
+
if (attempts >= MAX_ATTEMPTS) {
|
|
342
|
+
showError(
|
|
343
|
+
"PayPal partner script failed to load. Please refresh and try again."
|
|
344
|
+
)
|
|
345
|
+
return
|
|
346
|
+
}
|
|
347
|
+
setTimeout(tryInit, 50)
|
|
363
348
|
}
|
|
364
|
-
}, 2000)
|
|
365
|
-
}, [stopPoll])
|
|
366
349
|
|
|
367
|
-
|
|
350
|
+
tryInit()
|
|
351
|
+
},
|
|
352
|
+
[initPartner, showError]
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
const fetchFreshLink = useCallback(
|
|
368
356
|
async (targetEnv: Env, runId: number) => {
|
|
369
357
|
if (initLoaderRef.current) {
|
|
370
358
|
const loaderText = initLoaderRef.current.querySelector("#loader-text")
|
|
@@ -393,20 +381,14 @@ export default function PayPalConnectionPage() {
|
|
|
393
381
|
const url =
|
|
394
382
|
href + (href.includes("?") ? "&" : "?") + "displayMode=minibrowser"
|
|
395
383
|
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
window.location.reload()
|
|
399
|
-
return
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
setFinalUrl(url)
|
|
403
|
-
setConnState("ready")
|
|
384
|
+
writeCachedUrl(targetEnv, url)
|
|
385
|
+
activatePayPal(url, runId)
|
|
404
386
|
} catch {
|
|
405
387
|
if (runId !== currentRunId.current) return
|
|
406
388
|
showError("Unable to connect to service.")
|
|
407
389
|
}
|
|
408
390
|
},
|
|
409
|
-
[showError]
|
|
391
|
+
[activatePayPal, showError]
|
|
410
392
|
)
|
|
411
393
|
|
|
412
394
|
// Resolve the server environment first.
|
|
@@ -427,7 +409,7 @@ export default function PayPalConnectionPage() {
|
|
|
427
409
|
}
|
|
428
410
|
}, [])
|
|
429
411
|
|
|
430
|
-
// Page-load flow: connected? -> cached link? -> else generate
|
|
412
|
+
// Page-load flow: connected? -> cached link? -> else generate a fresh link.
|
|
431
413
|
useEffect(() => {
|
|
432
414
|
if (!envReady) return
|
|
433
415
|
|
|
@@ -440,7 +422,6 @@ export default function PayPalConnectionPage() {
|
|
|
440
422
|
const run = async () => {
|
|
441
423
|
setConnState("loading")
|
|
442
424
|
setError(null)
|
|
443
|
-
setPopupBlocked(false)
|
|
444
425
|
setFinalUrl("")
|
|
445
426
|
|
|
446
427
|
try {
|
|
@@ -470,12 +451,11 @@ export default function PayPalConnectionPage() {
|
|
|
470
451
|
|
|
471
452
|
const cached = readCachedUrl(env)
|
|
472
453
|
if (cached) {
|
|
473
|
-
|
|
474
|
-
setConnState("ready")
|
|
454
|
+
activatePayPal(cached, runId)
|
|
475
455
|
return
|
|
476
456
|
}
|
|
477
457
|
|
|
478
|
-
await
|
|
458
|
+
await fetchFreshLink(env, runId)
|
|
479
459
|
}
|
|
480
460
|
|
|
481
461
|
run()
|
|
@@ -483,198 +463,51 @@ export default function PayPalConnectionPage() {
|
|
|
483
463
|
return () => {
|
|
484
464
|
cancelled = true
|
|
485
465
|
}
|
|
486
|
-
}, [env, envReady,
|
|
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.
|
|
491
|
-
useEffect(() => {
|
|
492
|
-
if (connState !== "ready" || !finalUrl) return
|
|
493
|
-
|
|
494
|
-
const scriptUrl = PARTNER_JS_URLS[env]
|
|
495
|
-
let cancelled = false
|
|
496
|
-
let pollTimer: ReturnType<typeof setTimeout> | undefined
|
|
497
|
-
|
|
498
|
-
const initPartner = (attempt = 0) => {
|
|
499
|
-
if (cancelled) return
|
|
500
|
-
|
|
501
|
-
const signup = window.PAYPAL?.apps?.Signup
|
|
502
|
-
const target =
|
|
503
|
-
signup?.miniBrowser && typeof signup.miniBrowser.init === "function"
|
|
504
|
-
? signup.miniBrowser
|
|
505
|
-
: signup?.MiniBrowser && typeof signup.MiniBrowser.init === "function"
|
|
506
|
-
? signup.MiniBrowser
|
|
507
|
-
: null
|
|
508
|
-
|
|
509
|
-
if (target?.init) {
|
|
510
|
-
try {
|
|
511
|
-
target.init()
|
|
512
|
-
} catch (e) {
|
|
513
|
-
console.error("[paypal] partner.js init failed:", e)
|
|
514
|
-
}
|
|
515
|
-
return
|
|
516
|
-
}
|
|
517
|
-
if (typeof signup?.render === "function") {
|
|
518
|
-
try {
|
|
519
|
-
signup.render()
|
|
520
|
-
} catch (e) {
|
|
521
|
-
console.error("[paypal] partner.js render failed:", e)
|
|
522
|
-
}
|
|
523
|
-
return
|
|
524
|
-
}
|
|
525
|
-
if (attempt < 40) {
|
|
526
|
-
pollTimer = setTimeout(() => initPartner(attempt + 1), 50)
|
|
527
|
-
return
|
|
528
|
-
}
|
|
529
|
-
try {
|
|
530
|
-
document.dispatchEvent(new Event("DOMContentLoaded"))
|
|
531
|
-
} catch {}
|
|
532
|
-
}
|
|
533
|
-
|
|
534
|
-
const existingScript = document.getElementById("paypal-partner-js")
|
|
535
|
-
if (existingScript) {
|
|
536
|
-
existingScript.parentNode?.removeChild(existingScript)
|
|
537
|
-
}
|
|
538
|
-
if (window.PAYPAL?.apps?.Signup) {
|
|
539
|
-
try {
|
|
540
|
-
delete (window.PAYPAL.apps as any).Signup
|
|
541
|
-
} catch {}
|
|
542
|
-
}
|
|
543
|
-
|
|
544
|
-
const ppScript = document.createElement("script")
|
|
545
|
-
ppScript.id = "paypal-partner-js"
|
|
546
|
-
ppScript.src = scriptUrl
|
|
547
|
-
ppScript.async = true
|
|
548
|
-
ppScript.onload = () => initPartner()
|
|
549
|
-
document.body.appendChild(ppScript)
|
|
550
|
-
|
|
551
|
-
return () => {
|
|
552
|
-
cancelled = true
|
|
553
|
-
if (pollTimer) clearTimeout(pollTimer)
|
|
554
|
-
if (ppScript.parentNode) ppScript.parentNode.removeChild(ppScript)
|
|
555
|
-
}
|
|
556
|
-
}, [connState, finalUrl, env])
|
|
557
|
-
|
|
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 = ""
|
|
564
|
-
try {
|
|
565
|
-
serialized =
|
|
566
|
-
typeof ev.data === "string" ? ev.data : JSON.stringify(ev.data)
|
|
567
|
-
} catch {
|
|
568
|
-
serialized = String(ev.data)
|
|
569
|
-
}
|
|
466
|
+
}, [env, envReady, fetchFreshLink, activatePayPal])
|
|
570
467
|
|
|
571
|
-
|
|
572
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
607
|
-
|
|
608
|
-
if (!isPayPalOrigin(ev.origin)) return
|
|
609
|
-
const { authCode, sharedId } = extractAuth(ev.data)
|
|
610
|
-
if (authCode && sharedId) {
|
|
611
|
-
completeOnboarding(authCode, sharedId)
|
|
612
|
-
}
|
|
613
|
-
}
|
|
614
|
-
window.addEventListener("message", onMessage)
|
|
615
|
-
return () => window.removeEventListener("message", onMessage)
|
|
616
|
-
}, [completeOnboarding, startStatusPoll])
|
|
617
|
-
|
|
618
|
-
// SECONDARY completion path: partner.js's bridge calls this by name.
|
|
468
|
+
// partner.js invokes this by name (data-paypal-onboard-complete) once PayPal
|
|
469
|
+
// returns the seller's authCode + sharedId.
|
|
619
470
|
useLayoutEffect(() => {
|
|
620
471
|
window.onboardingCallback = (authCode: string, sharedId: string) => {
|
|
621
|
-
completeOnboarding(authCode, sharedId)
|
|
472
|
+
void completeOnboarding(authCode, sharedId)
|
|
622
473
|
}
|
|
474
|
+
|
|
623
475
|
return () => {
|
|
624
476
|
window.onboardingCallback = undefined
|
|
625
477
|
}
|
|
626
478
|
}, [completeOnboarding])
|
|
627
479
|
|
|
628
|
-
//
|
|
629
|
-
//
|
|
630
|
-
//
|
|
631
|
-
//
|
|
632
|
-
//
|
|
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).
|
|
480
|
+
// The return_url bridge (/store/paypal/onboard-return), which runs inside the
|
|
481
|
+
// popup after PayPal redirects to it, posts its params back here. The bridge has
|
|
482
|
+
// already exchanged credentials server-side, so we just refresh status to flip
|
|
483
|
+
// to "connected" and close the popup. If for some reason credentials aren't yet
|
|
484
|
+
// saved but PayPal forwarded an authCode/sharedId, complete it from here too.
|
|
642
485
|
useEffect(() => {
|
|
643
|
-
const
|
|
644
|
-
const
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
if (!
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
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()
|
|
486
|
+
const onMessage = (event: MessageEvent) => {
|
|
487
|
+
const data = event?.data as
|
|
488
|
+
| { source?: string; params?: Record<string, string> }
|
|
489
|
+
| undefined
|
|
490
|
+
if (!data || data.source !== "paypal-onboarding-return") return
|
|
491
|
+
|
|
492
|
+
const params = data.params || {}
|
|
493
|
+
const authCode = params.authCode || params.auth_code
|
|
494
|
+
const sharedId = params.sharedId || params.shared_id
|
|
495
|
+
const activeEnv = envRef.current
|
|
496
|
+
|
|
497
|
+
void (async () => {
|
|
498
|
+
const connected = await refreshStatusAndMaybeConnect(activeEnv)
|
|
499
|
+
if (!connected && authCode && sharedId) {
|
|
500
|
+
await completeOnboarding(authCode, sharedId)
|
|
501
|
+
}
|
|
502
|
+
})()
|
|
669
503
|
}
|
|
670
504
|
|
|
671
|
-
|
|
672
|
-
return () =>
|
|
673
|
-
}, [
|
|
505
|
+
window.addEventListener("message", onMessage)
|
|
506
|
+
return () => window.removeEventListener("message", onMessage)
|
|
507
|
+
}, [completeOnboarding, refreshStatusAndMaybeConnect])
|
|
674
508
|
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
}, [stopPoll])
|
|
509
|
+
// Always stop polling when the component unmounts.
|
|
510
|
+
useEffect(() => stopStatusPolling, [stopStatusPolling])
|
|
678
511
|
|
|
679
512
|
useLayoutEffect(() => {
|
|
680
513
|
const el = ppBtnMeasureRef.current
|
|
@@ -701,10 +534,17 @@ export default function PayPalConnectionPage() {
|
|
|
701
534
|
}
|
|
702
535
|
}, [connState, env, finalUrl])
|
|
703
536
|
|
|
704
|
-
//
|
|
705
|
-
//
|
|
537
|
+
// Only block the click while we are not ready; when ready, let the native click
|
|
538
|
+
// through so partner.js can open the mini-browser. Start polling status so we
|
|
539
|
+
// detect completion (and close the popup) even if no completion message reaches
|
|
540
|
+
// this page.
|
|
706
541
|
const handleConnectClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
|
|
707
|
-
|
|
542
|
+
if (connState !== "ready" || !finalUrl || onboardingInProgress) {
|
|
543
|
+
e.preventDefault()
|
|
544
|
+
return
|
|
545
|
+
}
|
|
546
|
+
completedRef.current = false
|
|
547
|
+
startStatusPolling()
|
|
708
548
|
}
|
|
709
549
|
|
|
710
550
|
const handleSaveManual = async () => {
|
|
@@ -754,6 +594,7 @@ export default function PayPalConnectionPage() {
|
|
|
754
594
|
if (onboardingInProgress) return
|
|
755
595
|
if (!window.confirm("Disconnect PayPal for this environment?")) return
|
|
756
596
|
|
|
597
|
+
stopStatusPolling()
|
|
757
598
|
setOnboardingInProgress(true)
|
|
758
599
|
setConnState("loading")
|
|
759
600
|
setError(null)
|
|
@@ -778,7 +619,7 @@ export default function PayPalConnectionPage() {
|
|
|
778
619
|
completedRef.current = false
|
|
779
620
|
|
|
780
621
|
currentRunId.current = ++runIdRef.current
|
|
781
|
-
await
|
|
622
|
+
await fetchFreshLink(env, currentRunId.current)
|
|
782
623
|
} catch (e: any) {
|
|
783
624
|
console.error(e)
|
|
784
625
|
setConnState("error")
|
|
@@ -792,9 +633,9 @@ export default function PayPalConnectionPage() {
|
|
|
792
633
|
const next = e.target.value as Env
|
|
793
634
|
if (next === env || onboardingInProgress) return
|
|
794
635
|
|
|
636
|
+
stopStatusPolling()
|
|
795
637
|
completedRef.current = false
|
|
796
638
|
clearCachedUrl()
|
|
797
|
-
setPopupBlocked(false)
|
|
798
639
|
setConnState("loading")
|
|
799
640
|
|
|
800
641
|
try {
|
|
@@ -844,6 +685,14 @@ export default function PayPalConnectionPage() {
|
|
|
844
685
|
<div>
|
|
845
686
|
<div className="text-sm text-green-600 bg-green-50 p-3 rounded border border-green-200">
|
|
846
687
|
✅ Successfully connected to PayPal!
|
|
688
|
+
<a
|
|
689
|
+
data-paypal-button="true"
|
|
690
|
+
data-paypal-onboard-complete="onboardingCallback"
|
|
691
|
+
href="#"
|
|
692
|
+
style={{ display: "none" }}
|
|
693
|
+
>
|
|
694
|
+
PayPal
|
|
695
|
+
</a>
|
|
847
696
|
</div>
|
|
848
697
|
<div className="mt-3 rounded-md border border-ui-border-base bg-ui-bg-subtle p-3 text-xs text-ui-fg-subtle">
|
|
849
698
|
<div className="font-medium text-ui-fg-base">
|
|
@@ -891,7 +740,6 @@ export default function PayPalConnectionPage() {
|
|
|
891
740
|
ppBtnMeasureRef.current = node
|
|
892
741
|
}}
|
|
893
742
|
id="paypal-button"
|
|
894
|
-
target={POPUP_NAME}
|
|
895
743
|
data-paypal-button="true"
|
|
896
744
|
href={finalUrl || "#"}
|
|
897
745
|
data-paypal-onboard-complete="onboardingCallback"
|
|
@@ -906,13 +754,6 @@ export default function PayPalConnectionPage() {
|
|
|
906
754
|
Connect to PayPal
|
|
907
755
|
</a>
|
|
908
756
|
|
|
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
|
-
|
|
916
757
|
<div
|
|
917
758
|
className="mt-2"
|
|
918
759
|
style={{
|