@easypayment/medusa-payment-paypal 0.9.17 → 0.9.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.medusa/server/src/admin/index.js +326 -130
- package/.medusa/server/src/admin/index.mjs +326 -130
- package/.medusa/server/src/admin/routes/settings/paypal/connection/page.d.ts +4 -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 +434 -113
- package/.medusa/server/src/admin/routes/settings/paypal/connection/page.js.map +1 -1
- package/.medusa/server/src/api/admin/paypal/onboarding-link/route.d.ts.map +1 -1
- package/.medusa/server/src/api/admin/paypal/onboarding-link/route.js +4 -0
- package/.medusa/server/src/api/admin/paypal/onboarding-link/route.js.map +1 -1
- package/.medusa/server/src/api/middlewares.d.ts.map +1 -1
- package/.medusa/server/src/api/middlewares.js +9 -0
- package/.medusa/server/src/api/middlewares.js.map +1 -1
- package/.medusa/server/src/api/store/paypal/webhook/route.d.ts.map +1 -1
- package/.medusa/server/src/api/store/paypal/webhook/route.js +13 -8
- package/.medusa/server/src/api/store/paypal/webhook/route.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 +54 -31
- package/.medusa/server/src/modules/paypal/payment-provider/service.js.map +1 -1
- package/.medusa/server/src/modules/paypal/payment-provider/status-utils.d.ts +32 -0
- package/.medusa/server/src/modules/paypal/payment-provider/status-utils.d.ts.map +1 -0
- package/.medusa/server/src/modules/paypal/payment-provider/status-utils.js +65 -0
- package/.medusa/server/src/modules/paypal/payment-provider/status-utils.js.map +1 -0
- package/.medusa/server/src/modules/paypal/service.d.ts +13 -1
- package/.medusa/server/src/modules/paypal/service.d.ts.map +1 -1
- package/.medusa/server/src/modules/paypal/service.js +19 -4
- package/.medusa/server/src/modules/paypal/service.js.map +1 -1
- package/.medusa/server/src/modules/paypal/utils/secret-crypto.d.ts +2 -1
- package/.medusa/server/src/modules/paypal/utils/secret-crypto.d.ts.map +1 -1
- package/.medusa/server/src/modules/paypal/utils/secret-crypto.js +82 -26
- package/.medusa/server/src/modules/paypal/utils/secret-crypto.js.map +1 -1
- package/.medusa/server/src/modules/paypal/utils/webhook-verify.d.ts +28 -0
- package/.medusa/server/src/modules/paypal/utils/webhook-verify.d.ts.map +1 -0
- package/.medusa/server/src/modules/paypal/utils/webhook-verify.js +58 -0
- package/.medusa/server/src/modules/paypal/utils/webhook-verify.js.map +1 -0
- package/package.json +1 -1
- package/src/admin/routes/settings/paypal/connection/page.tsx +471 -127
- package/src/api/admin/paypal/onboarding-link/route.ts +6 -0
- package/src/api/middlewares.ts +9 -0
- package/src/api/store/paypal/webhook/route.ts +22 -8
- package/src/modules/paypal/payment-provider/service.ts +73 -29
- package/src/modules/paypal/payment-provider/status-utils.ts +59 -0
- package/src/modules/paypal/service.ts +24 -5
- package/src/modules/paypal/utils/secret-crypto.ts +90 -27
- package/src/modules/paypal/utils/webhook-verify.ts +61 -0
|
@@ -24,8 +24,9 @@ declare global {
|
|
|
24
24
|
PAYPAL?: {
|
|
25
25
|
apps?: {
|
|
26
26
|
Signup?: {
|
|
27
|
-
miniBrowser?: { init
|
|
28
|
-
MiniBrowser?: { closeFlow?: () => void }
|
|
27
|
+
miniBrowser?: { init?: () => void; closeFlow?: () => void }
|
|
28
|
+
MiniBrowser?: { init?: () => void; closeFlow?: () => void }
|
|
29
|
+
render?: () => void
|
|
29
30
|
}
|
|
30
31
|
}
|
|
31
32
|
}
|
|
@@ -35,47 +36,145 @@ declare global {
|
|
|
35
36
|
|
|
36
37
|
|
|
37
38
|
const SERVICE_URL = "/admin/paypal/onboarding-link"
|
|
38
|
-
|
|
39
|
+
// The mini-browser popup is opened by us (see openOnboardingPopup). We give it a
|
|
40
|
+
// stable window name so that, even if PayPal's partner.js *also* intercepts the
|
|
41
|
+
// click, window.open() with the same name re-targets the one window instead of
|
|
42
|
+
// spawning a second one.
|
|
43
|
+
const POPUP_NAME = "PPMiniBrowser"
|
|
44
|
+
const CACHE_PREFIX = "pp_onboard_cache"
|
|
39
45
|
const CACHE_EXPIRY = 10 * 60 * 1000
|
|
40
46
|
|
|
41
47
|
const ONBOARDING_COMPLETE_ENDPOINT = "/admin/paypal/onboard-complete"
|
|
42
48
|
const STATUS_ENDPOINT = "/admin/paypal/status"
|
|
43
49
|
const SAVE_CREDENTIALS_ENDPOINT = "/admin/paypal/save-credentials"
|
|
44
50
|
const DISCONNECT_ENDPOINT = "/admin/paypal/disconnect"
|
|
51
|
+
const ENVIRONMENT_ENDPOINT = "/admin/paypal/environment"
|
|
45
52
|
|
|
46
|
-
|
|
47
|
-
|
|
53
|
+
type Env = "sandbox" | "live"
|
|
54
|
+
|
|
55
|
+
// Onboarding URLs are environment-specific and short-lived, so the cache must be
|
|
56
|
+
// keyed per environment — a single shared key let a stale sandbox URL leak into
|
|
57
|
+
// the live flow (and vice-versa) after a reload.
|
|
58
|
+
const cacheKeyFor = (env: Env) => `${CACHE_PREFIX}_${env}`
|
|
59
|
+
|
|
60
|
+
function readCachedUrl(env: Env): string | null {
|
|
61
|
+
if (typeof window === "undefined") return null
|
|
62
|
+
try {
|
|
63
|
+
const raw = localStorage.getItem(cacheKeyFor(env))
|
|
64
|
+
if (!raw) return null
|
|
65
|
+
const data = JSON.parse(raw)
|
|
66
|
+
if (
|
|
67
|
+
data &&
|
|
68
|
+
typeof data.url === "string" &&
|
|
69
|
+
Date.now() - (Number(data.ts) || 0) < CACHE_EXPIRY
|
|
70
|
+
) {
|
|
71
|
+
return data.url
|
|
72
|
+
}
|
|
73
|
+
} catch {
|
|
74
|
+
/* ignore malformed cache */
|
|
75
|
+
}
|
|
76
|
+
return null
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function writeCachedUrl(env: Env, url: string) {
|
|
48
80
|
try {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
81
|
+
localStorage.setItem(cacheKeyFor(env), JSON.stringify({ url, ts: Date.now() }))
|
|
82
|
+
} catch {
|
|
83
|
+
/* ignore quota / disabled storage */
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function clearCachedUrl(env?: Env) {
|
|
88
|
+
try {
|
|
89
|
+
if (env) {
|
|
90
|
+
localStorage.removeItem(cacheKeyFor(env))
|
|
91
|
+
} else {
|
|
92
|
+
localStorage.removeItem(cacheKeyFor("sandbox"))
|
|
93
|
+
localStorage.removeItem(cacheKeyFor("live"))
|
|
94
|
+
}
|
|
95
|
+
} catch {
|
|
96
|
+
/* ignore */
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// PayPal delivers the seamless-onboarding result by posting a message to
|
|
101
|
+
// window.opener. We validate the sender is actually a PayPal origin before
|
|
102
|
+
// trusting authCode/sharedId.
|
|
103
|
+
function isPayPalOrigin(origin: string): boolean {
|
|
104
|
+
try {
|
|
105
|
+
const host = new URL(origin).hostname.toLowerCase()
|
|
106
|
+
return (
|
|
107
|
+
host === "www.paypal.com" ||
|
|
108
|
+
host === "www.sandbox.paypal.com" ||
|
|
109
|
+
host.endsWith(".paypal.com") ||
|
|
110
|
+
host.endsWith(".paypalobjects.com")
|
|
111
|
+
)
|
|
112
|
+
} catch {
|
|
113
|
+
return false
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// The completion message shape is not strongly documented, so extract
|
|
118
|
+
// authCode/sharedId defensively from objects, JSON strings, or query strings.
|
|
119
|
+
// We only ever act when BOTH values are present, which keeps the many unrelated
|
|
120
|
+
// intermediate postMessages PayPal emits from triggering a false completion.
|
|
121
|
+
function extractAuth(data: unknown): { authCode?: string; sharedId?: string } {
|
|
122
|
+
if (!data) return {}
|
|
123
|
+
|
|
124
|
+
if (typeof data === "object") {
|
|
125
|
+
const obj = data as Record<string, any>
|
|
126
|
+
const authCode = obj.authCode ?? obj.auth_code ?? obj.authcode
|
|
127
|
+
const sharedId = obj.sharedId ?? obj.shared_id ?? obj.sharedid
|
|
128
|
+
if (authCode && sharedId) {
|
|
129
|
+
return { authCode: String(authCode), sharedId: String(sharedId) }
|
|
130
|
+
}
|
|
131
|
+
for (const key of ["data", "payload", "message", "detail", "body"]) {
|
|
132
|
+
if (obj[key] && typeof obj[key] === "object") {
|
|
133
|
+
const inner = extractAuth(obj[key])
|
|
134
|
+
if (inner.authCode && inner.sharedId) return inner
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return {}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (typeof data === "string") {
|
|
141
|
+
const s = data.trim()
|
|
142
|
+
if (!s) return {}
|
|
143
|
+
if (s.startsWith("{")) {
|
|
144
|
+
try {
|
|
145
|
+
return extractAuth(JSON.parse(s))
|
|
146
|
+
} catch {
|
|
147
|
+
/* not JSON */
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if (/auth_?code/i.test(s) && /shared_?id/i.test(s)) {
|
|
151
|
+
try {
|
|
152
|
+
const sp = new URLSearchParams(s.replace(/^[?#]/, ""))
|
|
153
|
+
const authCode = sp.get("authCode") || sp.get("auth_code") || undefined
|
|
154
|
+
const sharedId = sp.get("sharedId") || sp.get("shared_id") || undefined
|
|
155
|
+
if (authCode && sharedId) return { authCode, sharedId }
|
|
156
|
+
} catch {
|
|
157
|
+
/* not a query string */
|
|
54
158
|
}
|
|
55
159
|
}
|
|
56
|
-
} catch (e) {
|
|
57
|
-
console.error("Cache read error:", e)
|
|
58
160
|
}
|
|
161
|
+
|
|
162
|
+
return {}
|
|
59
163
|
}
|
|
60
164
|
|
|
61
165
|
|
|
62
166
|
export default function PayPalConnectionPage() {
|
|
63
|
-
const [env, setEnv] = useState<
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
.then((d) => {
|
|
69
|
-
const v = d?.environment === "sandbox" ? "sandbox" : "live"
|
|
70
|
-
setEnv(v)
|
|
71
|
-
})
|
|
72
|
-
.catch(() => {})
|
|
73
|
-
}, [])
|
|
167
|
+
const [env, setEnv] = useState<Env>("live")
|
|
168
|
+
// We must not generate/fetch an onboarding link until we know which
|
|
169
|
+
// environment the server is actually on, otherwise the first fetch races the
|
|
170
|
+
// GET /environment response and can target the wrong environment.
|
|
171
|
+
const [envReady, setEnvReady] = useState(false)
|
|
74
172
|
|
|
75
173
|
const [connState, setConnState] = useState<
|
|
76
174
|
"loading" | "ready" | "connected" | "error"
|
|
77
175
|
>("loading")
|
|
78
176
|
const [error, setError] = useState<string | null>(null)
|
|
177
|
+
const [popupBlocked, setPopupBlocked] = useState(false)
|
|
79
178
|
const [finalUrl, setFinalUrl] = useState<string>("")
|
|
80
179
|
const [showManual, setShowManual] = useState(false)
|
|
81
180
|
const [clientId, setClientId] = useState("")
|
|
@@ -94,6 +193,23 @@ export default function PayPalConnectionPage() {
|
|
|
94
193
|
const runIdRef = useRef(0)
|
|
95
194
|
const currentRunId = useRef(0)
|
|
96
195
|
|
|
196
|
+
// Live mirrors of state so the document-level capture click listener (set up
|
|
197
|
+
// once) and the stable completion callback always see current values.
|
|
198
|
+
const finalUrlRef = useRef(finalUrl)
|
|
199
|
+
finalUrlRef.current = finalUrl
|
|
200
|
+
const connStateRef = useRef(connState)
|
|
201
|
+
connStateRef.current = connState
|
|
202
|
+
const inProgressRef = useRef(onboardingInProgress)
|
|
203
|
+
inProgressRef.current = onboardingInProgress
|
|
204
|
+
const envRef = useRef<Env>(env)
|
|
205
|
+
envRef.current = env
|
|
206
|
+
|
|
207
|
+
const popupRef = useRef<Window | null>(null)
|
|
208
|
+
const popupPollRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
|
209
|
+
// Guards against the two redundant completion paths (our own message listener
|
|
210
|
+
// and partner.js's onboardingCallback bridge) both POSTing the same result.
|
|
211
|
+
const completedRef = useRef(false)
|
|
212
|
+
|
|
97
213
|
const ppBtnMeasureRef = useRef<HTMLAnchorElement | null>(null)
|
|
98
214
|
const [ppBtnWidth, setPpBtnWidth] = useState<number | null>(null)
|
|
99
215
|
|
|
@@ -106,8 +222,168 @@ export default function PayPalConnectionPage() {
|
|
|
106
222
|
setError(msg)
|
|
107
223
|
}, [])
|
|
108
224
|
|
|
225
|
+
const stopPopupPoll = useCallback(() => {
|
|
226
|
+
if (popupPollRef.current) {
|
|
227
|
+
clearInterval(popupPollRef.current)
|
|
228
|
+
popupPollRef.current = null
|
|
229
|
+
}
|
|
230
|
+
}, [])
|
|
231
|
+
|
|
232
|
+
// Open the PayPal mini-browser ourselves, synchronously, inside the click
|
|
233
|
+
// gesture. This is the crux of the fix: browsers only render window.open() as
|
|
234
|
+
// a real popup (instead of a tab — or blocking it) when it runs synchronously
|
|
235
|
+
// in a user gesture. Because finalUrl is already in state at click time, we
|
|
236
|
+
// never need an await before opening, and we never depend on winning a race
|
|
237
|
+
// with partner.js's asynchronous click binding.
|
|
238
|
+
const openOnboardingPopup = useCallback(
|
|
239
|
+
(url: string): Window | null => {
|
|
240
|
+
const w = 450
|
|
241
|
+
const h = 600
|
|
242
|
+
const baseLeft =
|
|
243
|
+
typeof window.screenX === "number" ? window.screenX : (window as any).screenLeft || 0
|
|
244
|
+
const baseTop =
|
|
245
|
+
typeof window.screenY === "number" ? window.screenY : (window as any).screenTop || 0
|
|
246
|
+
const outerW =
|
|
247
|
+
window.outerWidth || document.documentElement.clientWidth || 1024
|
|
248
|
+
const outerH =
|
|
249
|
+
window.outerHeight || document.documentElement.clientHeight || 768
|
|
250
|
+
const left = Math.round(baseLeft + Math.max(0, (outerW - w) / 2))
|
|
251
|
+
const top = Math.round(baseTop + Math.max(0, (outerH - h) / 2))
|
|
252
|
+
const features = `popup=yes,width=${w},height=${h},left=${left},top=${top},scrollbars=yes,resizable=yes`
|
|
253
|
+
|
|
254
|
+
let popup: Window | null = null
|
|
255
|
+
try {
|
|
256
|
+
popup = window.open(url, POPUP_NAME, features)
|
|
257
|
+
} catch {
|
|
258
|
+
popup = null
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (popup) {
|
|
262
|
+
popupRef.current = popup
|
|
263
|
+
try {
|
|
264
|
+
popup.focus()
|
|
265
|
+
} catch {
|
|
266
|
+
/* ignore */
|
|
267
|
+
}
|
|
268
|
+
stopPopupPoll()
|
|
269
|
+
popupPollRef.current = setInterval(() => {
|
|
270
|
+
if (!popupRef.current || popupRef.current.closed) {
|
|
271
|
+
stopPopupPoll()
|
|
272
|
+
}
|
|
273
|
+
}, 1000)
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return popup
|
|
277
|
+
},
|
|
278
|
+
[stopPopupPoll]
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
// Single entry point for "Connect to PayPal". Hard-guards on a real, ready
|
|
282
|
+
// URL so a half-loaded button can never open anything.
|
|
283
|
+
const openConnect = useCallback(() => {
|
|
284
|
+
if (
|
|
285
|
+
connStateRef.current !== "ready" ||
|
|
286
|
+
!finalUrlRef.current ||
|
|
287
|
+
inProgressRef.current
|
|
288
|
+
) {
|
|
289
|
+
return
|
|
290
|
+
}
|
|
291
|
+
completedRef.current = false
|
|
292
|
+
const popup = openOnboardingPopup(finalUrlRef.current)
|
|
293
|
+
if (!popup) {
|
|
294
|
+
setPopupBlocked(true)
|
|
295
|
+
return
|
|
296
|
+
}
|
|
297
|
+
setPopupBlocked(false)
|
|
298
|
+
}, [openOnboardingPopup])
|
|
299
|
+
|
|
300
|
+
// Exchange the seller authCode/sharedId for credentials. Reached from BOTH
|
|
301
|
+
// PayPal's partner.js bridge (window.onboardingCallback) and our own postMessage
|
|
302
|
+
// listener; completedRef makes it idempotent so we only POST once.
|
|
303
|
+
const completeOnboarding = useCallback(
|
|
304
|
+
async (authCode: string, sharedId: string) => {
|
|
305
|
+
if (!authCode || !sharedId) return
|
|
306
|
+
if (completedRef.current) return
|
|
307
|
+
completedRef.current = true
|
|
308
|
+
|
|
309
|
+
try {
|
|
310
|
+
window.onbeforeunload = null
|
|
311
|
+
} catch {
|
|
312
|
+
/* ignore */
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const activeEnv: Env = envRef.current === "sandbox" ? "sandbox" : "live"
|
|
316
|
+
|
|
317
|
+
setOnboardingInProgress(true)
|
|
318
|
+
setConnState("loading")
|
|
319
|
+
setError(null)
|
|
320
|
+
setPopupBlocked(false)
|
|
321
|
+
|
|
322
|
+
try {
|
|
323
|
+
const res = await fetch(ONBOARDING_COMPLETE_ENDPOINT, {
|
|
324
|
+
method: "POST",
|
|
325
|
+
headers: { "content-type": "application/json" },
|
|
326
|
+
credentials: "include",
|
|
327
|
+
body: JSON.stringify({ authCode, sharedId, env: activeEnv }),
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
if (!res.ok) {
|
|
331
|
+
const txt = await res.text().catch(() => "")
|
|
332
|
+
throw new Error(txt || `Onboarding exchange failed (${res.status})`)
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Close the popup we opened, plus any partner.js-managed flow.
|
|
336
|
+
try {
|
|
337
|
+
popupRef.current?.close()
|
|
338
|
+
} catch {
|
|
339
|
+
/* ignore */
|
|
340
|
+
}
|
|
341
|
+
stopPopupPoll()
|
|
342
|
+
try {
|
|
343
|
+
const close1 = window.PAYPAL?.apps?.Signup?.MiniBrowser?.closeFlow
|
|
344
|
+
if (typeof close1 === "function") close1()
|
|
345
|
+
} catch {
|
|
346
|
+
/* ignore */
|
|
347
|
+
}
|
|
348
|
+
try {
|
|
349
|
+
const close2 =
|
|
350
|
+
window.PAYPAL?.apps?.Signup?.miniBrowser &&
|
|
351
|
+
(window.PAYPAL.apps.Signup.miniBrowser as any).closeFlow
|
|
352
|
+
if (typeof close2 === "function") close2()
|
|
353
|
+
} catch {
|
|
354
|
+
/* ignore */
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
clearCachedUrl(activeEnv)
|
|
358
|
+
|
|
359
|
+
try {
|
|
360
|
+
const statusRes = await fetch(
|
|
361
|
+
`${STATUS_ENDPOINT}?environment=${activeEnv}`,
|
|
362
|
+
{ method: "GET", credentials: "include" }
|
|
363
|
+
)
|
|
364
|
+
const refreshedStatus = await statusRes.json().catch(() => ({}))
|
|
365
|
+
setStatusInfo(refreshedStatus || null)
|
|
366
|
+
} catch {
|
|
367
|
+
/* ignore — still consider it connected below */
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
setConnState("connected")
|
|
371
|
+
setShowManual(false)
|
|
372
|
+
} catch (e: any) {
|
|
373
|
+
// Allow a retry after a failed exchange.
|
|
374
|
+
completedRef.current = false
|
|
375
|
+
console.error(e)
|
|
376
|
+
setConnState("error")
|
|
377
|
+
setError(e?.message || "Exchange failed while saving credentials.")
|
|
378
|
+
} finally {
|
|
379
|
+
setOnboardingInProgress(false)
|
|
380
|
+
}
|
|
381
|
+
},
|
|
382
|
+
[stopPopupPoll]
|
|
383
|
+
)
|
|
384
|
+
|
|
109
385
|
const fetchFreshLink = useCallback(
|
|
110
|
-
(runId: number) => {
|
|
386
|
+
(runId: number, targetEnv: Env) => {
|
|
111
387
|
if (initLoaderRef.current) {
|
|
112
388
|
const loaderText = initLoaderRef.current.querySelector("#loader-text")
|
|
113
389
|
if (loaderText)
|
|
@@ -120,6 +396,9 @@ export default function PayPalConnectionPage() {
|
|
|
120
396
|
credentials: "include",
|
|
121
397
|
body: JSON.stringify({
|
|
122
398
|
products: ["PPCP"],
|
|
399
|
+
// Generate the link for the explicitly selected environment so it can
|
|
400
|
+
// never depend on a racing POST /environment having landed yet.
|
|
401
|
+
environment: targetEnv,
|
|
123
402
|
}),
|
|
124
403
|
})
|
|
125
404
|
.then((r) => {
|
|
@@ -138,10 +417,7 @@ export default function PayPalConnectionPage() {
|
|
|
138
417
|
const url =
|
|
139
418
|
href + (href.includes("?") ? "&" : "?") + "displayMode=minibrowser"
|
|
140
419
|
|
|
141
|
-
|
|
142
|
-
CACHE_KEY,
|
|
143
|
-
JSON.stringify({ url, ts: Date.now() })
|
|
144
|
-
)
|
|
420
|
+
writeCachedUrl(targetEnv, url)
|
|
145
421
|
|
|
146
422
|
setFinalUrl(url)
|
|
147
423
|
setConnState("ready")
|
|
@@ -151,44 +427,142 @@ export default function PayPalConnectionPage() {
|
|
|
151
427
|
showError("Unable to connect to service.")
|
|
152
428
|
})
|
|
153
429
|
},
|
|
154
|
-
[
|
|
430
|
+
[showError]
|
|
155
431
|
)
|
|
156
432
|
|
|
433
|
+
// Resolve the server's current environment before doing anything else.
|
|
434
|
+
useEffect(() => {
|
|
435
|
+
let alive = true
|
|
436
|
+
fetch(ENVIRONMENT_ENDPOINT, { method: "GET", credentials: "include" })
|
|
437
|
+
.then((r) => r.json())
|
|
438
|
+
.then((d) => {
|
|
439
|
+
if (!alive) return
|
|
440
|
+
setEnv(d?.environment === "sandbox" ? "sandbox" : "live")
|
|
441
|
+
})
|
|
442
|
+
.catch(() => {})
|
|
443
|
+
.finally(() => {
|
|
444
|
+
if (alive) setEnvReady(true)
|
|
445
|
+
})
|
|
446
|
+
return () => {
|
|
447
|
+
alive = false
|
|
448
|
+
}
|
|
449
|
+
}, [])
|
|
450
|
+
|
|
451
|
+
// partner.js is loaded ONLY for its postMessage->callback bridge
|
|
452
|
+
// (window.onboardingCallback), as a redundant path next to our own message
|
|
453
|
+
// listener below. It is NO LONGER responsible for opening the window — we do
|
|
454
|
+
// that synchronously in the click handler. We still init it so its message
|
|
455
|
+
// bridge registers inside this React SPA (its own DOMContentLoaded hook fired
|
|
456
|
+
// long before this route mounted). If partner.js also intercepts the click,
|
|
457
|
+
// the shared POPUP_NAME means it re-targets our single popup rather than
|
|
458
|
+
// opening a second window.
|
|
157
459
|
useEffect(() => {
|
|
158
460
|
if (connState !== "ready" || !finalUrl) return
|
|
159
461
|
|
|
160
462
|
const scriptUrl = PARTNER_JS_URLS[env]
|
|
463
|
+
let cancelled = false
|
|
464
|
+
let pollTimer: ReturnType<typeof setTimeout> | undefined
|
|
465
|
+
|
|
466
|
+
const initPartner = (attempt = 0) => {
|
|
467
|
+
if (cancelled) return
|
|
468
|
+
|
|
469
|
+
const signup = window.PAYPAL?.apps?.Signup
|
|
470
|
+
const target =
|
|
471
|
+
signup?.miniBrowser && typeof signup.miniBrowser.init === "function"
|
|
472
|
+
? signup.miniBrowser
|
|
473
|
+
: signup?.MiniBrowser && typeof signup.MiniBrowser.init === "function"
|
|
474
|
+
? signup.MiniBrowser
|
|
475
|
+
: null
|
|
476
|
+
|
|
477
|
+
if (target?.init) {
|
|
478
|
+
try {
|
|
479
|
+
target.init()
|
|
480
|
+
} catch (e) {
|
|
481
|
+
console.error("[paypal] partner.js init failed:", e)
|
|
482
|
+
}
|
|
483
|
+
return
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
if (typeof signup?.render === "function") {
|
|
487
|
+
try {
|
|
488
|
+
signup.render()
|
|
489
|
+
} catch (e) {
|
|
490
|
+
console.error("[paypal] partner.js render failed:", e)
|
|
491
|
+
}
|
|
492
|
+
return
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
if (attempt < 40) {
|
|
496
|
+
pollTimer = setTimeout(() => initPartner(attempt + 1), 50)
|
|
497
|
+
return
|
|
498
|
+
}
|
|
161
499
|
|
|
500
|
+
try {
|
|
501
|
+
document.dispatchEvent(new Event("DOMContentLoaded"))
|
|
502
|
+
} catch {}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// Remove any stale copy + namespace so the fresh load re-registers against
|
|
506
|
+
// the current environment (sandbox vs live partner.js differ).
|
|
162
507
|
const existingScript = document.getElementById("paypal-partner-js")
|
|
163
508
|
if (existingScript) {
|
|
164
509
|
existingScript.parentNode?.removeChild(existingScript)
|
|
165
510
|
}
|
|
166
511
|
if (window.PAYPAL?.apps?.Signup) {
|
|
167
|
-
|
|
512
|
+
try {
|
|
513
|
+
delete (window.PAYPAL.apps as any).Signup
|
|
514
|
+
} catch {}
|
|
168
515
|
}
|
|
169
516
|
|
|
170
517
|
const ppScript = document.createElement("script")
|
|
171
518
|
ppScript.id = "paypal-partner-js"
|
|
172
519
|
ppScript.src = scriptUrl
|
|
173
520
|
ppScript.async = true
|
|
521
|
+
ppScript.onload = () => initPartner()
|
|
174
522
|
document.body.appendChild(ppScript)
|
|
175
523
|
|
|
176
524
|
return () => {
|
|
525
|
+
cancelled = true
|
|
526
|
+
if (pollTimer) {
|
|
527
|
+
clearTimeout(pollTimer)
|
|
528
|
+
}
|
|
177
529
|
if (ppScript.parentNode) {
|
|
178
530
|
ppScript.parentNode.removeChild(ppScript)
|
|
179
531
|
}
|
|
180
532
|
}
|
|
181
533
|
}, [connState, finalUrl, env])
|
|
182
534
|
|
|
535
|
+
// Primary completion path: receive PayPal's seamless-onboarding postMessage
|
|
536
|
+
// ourselves. Independent of partner.js, so it works even when we opened the
|
|
537
|
+
// popup directly.
|
|
183
538
|
useEffect(() => {
|
|
539
|
+
const onMessage = (ev: MessageEvent) => {
|
|
540
|
+
if (!isPayPalOrigin(ev.origin)) return
|
|
541
|
+
const { authCode, sharedId } = extractAuth(ev.data)
|
|
542
|
+
if (authCode && sharedId) {
|
|
543
|
+
completeOnboarding(authCode, sharedId)
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
window.addEventListener("message", onMessage)
|
|
547
|
+
return () => window.removeEventListener("message", onMessage)
|
|
548
|
+
}, [completeOnboarding])
|
|
549
|
+
|
|
550
|
+
// Status check + link generation. Gated on envReady so we always use the
|
|
551
|
+
// correct environment. runId guards against a stale response from a previous
|
|
552
|
+
// environment overwriting the current one.
|
|
553
|
+
useEffect(() => {
|
|
554
|
+
if (!envReady) return
|
|
555
|
+
|
|
184
556
|
currentRunId.current = ++runIdRef.current
|
|
185
557
|
const runId = currentRunId.current
|
|
186
558
|
|
|
187
559
|
let cancelled = false
|
|
560
|
+
completedRef.current = false
|
|
188
561
|
|
|
189
562
|
const run = async () => {
|
|
190
563
|
setConnState("loading")
|
|
191
564
|
setError(null)
|
|
565
|
+
setPopupBlocked(false)
|
|
192
566
|
setFinalUrl("")
|
|
193
567
|
|
|
194
568
|
try {
|
|
@@ -214,11 +588,14 @@ export default function PayPalConnectionPage() {
|
|
|
214
588
|
console.error(e)
|
|
215
589
|
}
|
|
216
590
|
|
|
217
|
-
if (
|
|
218
|
-
|
|
591
|
+
if (cancelled || runId !== currentRunId.current) return
|
|
592
|
+
|
|
593
|
+
const cached = readCachedUrl(env)
|
|
594
|
+
if (cached) {
|
|
595
|
+
setFinalUrl(cached)
|
|
219
596
|
setConnState("ready")
|
|
220
597
|
} else {
|
|
221
|
-
fetchFreshLink(runId)
|
|
598
|
+
fetchFreshLink(runId, env)
|
|
222
599
|
}
|
|
223
600
|
}
|
|
224
601
|
|
|
@@ -226,80 +603,46 @@ export default function PayPalConnectionPage() {
|
|
|
226
603
|
|
|
227
604
|
return () => {
|
|
228
605
|
cancelled = true
|
|
229
|
-
currentRunId.current = 0
|
|
230
606
|
}
|
|
231
|
-
}, [env, fetchFreshLink])
|
|
607
|
+
}, [env, envReady, fetchFreshLink])
|
|
232
608
|
|
|
609
|
+
// Bridge partner.js's callback into our single idempotent completion handler.
|
|
233
610
|
useLayoutEffect(() => {
|
|
234
|
-
window.onboardingCallback =
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
setError(null)
|
|
242
|
-
|
|
243
|
-
const payload = {
|
|
244
|
-
authCode,
|
|
245
|
-
sharedId,
|
|
246
|
-
env: env === "sandbox" ? "sandbox" : "live",
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
try {
|
|
250
|
-
const res = await fetch(ONBOARDING_COMPLETE_ENDPOINT, {
|
|
251
|
-
method: "POST",
|
|
252
|
-
headers: { "content-type": "application/json" },
|
|
253
|
-
credentials: "include",
|
|
254
|
-
body: JSON.stringify(payload),
|
|
255
|
-
})
|
|
256
|
-
|
|
257
|
-
if (!res.ok) {
|
|
258
|
-
const txt = await res.text().catch(() => "")
|
|
259
|
-
throw new Error(txt || `Onboarding exchange failed (${res.status})`)
|
|
260
|
-
}
|
|
611
|
+
window.onboardingCallback = (authCode: string, sharedId: string) => {
|
|
612
|
+
completeOnboarding(authCode, sharedId)
|
|
613
|
+
}
|
|
614
|
+
return () => {
|
|
615
|
+
window.onboardingCallback = undefined
|
|
616
|
+
}
|
|
617
|
+
}, [completeOnboarding])
|
|
261
618
|
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
window.PAYPAL?.apps?.Signup?.miniBrowser &&
|
|
269
|
-
(window.PAYPAL.apps.Signup.miniBrowser as any).closeFlow
|
|
270
|
-
if (typeof close2 === "function") close2()
|
|
271
|
-
} catch {}
|
|
619
|
+
// Clean up the popup poll on unmount.
|
|
620
|
+
useEffect(() => {
|
|
621
|
+
return () => {
|
|
622
|
+
stopPopupPoll()
|
|
623
|
+
}
|
|
624
|
+
}, [stopPopupPoll])
|
|
272
625
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
626
|
+
// Capture-phase click interceptor: guarantees WE own the popup. Running in the
|
|
627
|
+
// capture phase on the document means we execute before partner.js's own click
|
|
628
|
+
// handler, so we can suppress it (no duplicate window) and open the popup
|
|
629
|
+
// ourselves within the same user gesture. This is what makes the first click
|
|
630
|
+
// after a cold load reliably open a popup instead of a new tab.
|
|
631
|
+
useEffect(() => {
|
|
632
|
+
const onCaptureClick = (e: MouseEvent) => {
|
|
633
|
+
const btn = paypalButtonRef.current
|
|
634
|
+
if (!btn) return
|
|
635
|
+
const target = e.target as Node | null
|
|
636
|
+
if (!target || !btn.contains(target)) return
|
|
276
637
|
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
credentials: "include",
|
|
281
|
-
})
|
|
282
|
-
const refreshedStatus = await statusRes.json().catch(() => ({}))
|
|
283
|
-
setStatusInfo(refreshedStatus || null)
|
|
284
|
-
setConnState("connected")
|
|
285
|
-
setShowManual(false)
|
|
286
|
-
} catch {
|
|
287
|
-
setConnState("connected")
|
|
288
|
-
setShowManual(false)
|
|
289
|
-
}
|
|
290
|
-
setOnboardingInProgress(false)
|
|
291
|
-
} catch (e: any) {
|
|
292
|
-
console.error(e)
|
|
293
|
-
setConnState("error")
|
|
294
|
-
setError(e?.message || "Exchange failed while saving credentials.")
|
|
295
|
-
setOnboardingInProgress(false)
|
|
296
|
-
}
|
|
638
|
+
e.preventDefault()
|
|
639
|
+
e.stopImmediatePropagation()
|
|
640
|
+
openConnect()
|
|
297
641
|
}
|
|
298
642
|
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
}, [env])
|
|
643
|
+
document.addEventListener("click", onCaptureClick, true)
|
|
644
|
+
return () => document.removeEventListener("click", onCaptureClick, true)
|
|
645
|
+
}, [openConnect])
|
|
303
646
|
|
|
304
647
|
useLayoutEffect(() => {
|
|
305
648
|
const el = ppBtnMeasureRef.current
|
|
@@ -326,10 +669,12 @@ export default function PayPalConnectionPage() {
|
|
|
326
669
|
}
|
|
327
670
|
}, [connState, env, finalUrl])
|
|
328
671
|
|
|
672
|
+
// Defensive fallback: the capture listener above normally handles the click
|
|
673
|
+
// and stops propagation before React sees it. If for any reason it didn't, we
|
|
674
|
+
// still prevent the native target from opening a tab and open the popup here.
|
|
329
675
|
const handleConnectClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
}
|
|
676
|
+
e.preventDefault()
|
|
677
|
+
openConnect()
|
|
333
678
|
}
|
|
334
679
|
|
|
335
680
|
const handleSaveManual = async () => {
|
|
@@ -365,9 +710,7 @@ export default function PayPalConnectionPage() {
|
|
|
365
710
|
setStatusInfo(refreshedStatus || null)
|
|
366
711
|
setShowManual(false)
|
|
367
712
|
|
|
368
|
-
|
|
369
|
-
localStorage.removeItem(CACHE_KEY)
|
|
370
|
-
} catch {}
|
|
713
|
+
clearCachedUrl(env)
|
|
371
714
|
} catch (e: any) {
|
|
372
715
|
console.error(e)
|
|
373
716
|
setConnState("error")
|
|
@@ -401,14 +744,12 @@ export default function PayPalConnectionPage() {
|
|
|
401
744
|
throw new Error(t || `Disconnect failed (${res.status})`)
|
|
402
745
|
}
|
|
403
746
|
|
|
404
|
-
|
|
405
|
-
localStorage.removeItem(CACHE_KEY)
|
|
406
|
-
|
|
407
|
-
} catch {}
|
|
747
|
+
clearCachedUrl(env)
|
|
408
748
|
|
|
749
|
+
completedRef.current = false
|
|
409
750
|
currentRunId.current = ++runIdRef.current
|
|
410
751
|
const runId = currentRunId.current
|
|
411
|
-
fetchFreshLink(runId)
|
|
752
|
+
fetchFreshLink(runId, env)
|
|
412
753
|
} catch (e: any) {
|
|
413
754
|
console.error(e)
|
|
414
755
|
setConnState("error")
|
|
@@ -419,22 +760,27 @@ export default function PayPalConnectionPage() {
|
|
|
419
760
|
}
|
|
420
761
|
|
|
421
762
|
const handleEnvChange = async (e: React.ChangeEvent<HTMLSelectElement>) => {
|
|
422
|
-
const next = e.target.value as
|
|
763
|
+
const next = e.target.value as Env
|
|
764
|
+
|
|
765
|
+
completedRef.current = false
|
|
766
|
+
setPopupBlocked(false)
|
|
767
|
+
// Drop both cached URLs so neither environment can serve a stale link.
|
|
768
|
+
clearCachedUrl()
|
|
769
|
+
|
|
770
|
+
// Update local state immediately (drives the status/link refetch with the
|
|
771
|
+
// explicit environment) and persist the choice. Because the link/status
|
|
772
|
+
// fetches are environment-explicit, correctness no longer depends on this
|
|
773
|
+
// POST winning a race.
|
|
423
774
|
setEnv(next)
|
|
424
|
-
cachedUrl = null
|
|
425
775
|
|
|
426
776
|
try {
|
|
427
|
-
await fetch(
|
|
777
|
+
await fetch(ENVIRONMENT_ENDPOINT, {
|
|
428
778
|
method: "POST",
|
|
429
779
|
headers: { "content-type": "application/json" },
|
|
430
780
|
credentials: "include",
|
|
431
781
|
body: JSON.stringify({ environment: next }),
|
|
432
782
|
})
|
|
433
783
|
} catch {}
|
|
434
|
-
|
|
435
|
-
try {
|
|
436
|
-
localStorage.removeItem(CACHE_KEY)
|
|
437
|
-
} catch {}
|
|
438
784
|
}
|
|
439
785
|
|
|
440
786
|
return (
|
|
@@ -472,15 +818,6 @@ export default function PayPalConnectionPage() {
|
|
|
472
818
|
<div>
|
|
473
819
|
<div className="text-sm text-green-600 bg-green-50 p-3 rounded border border-green-200">
|
|
474
820
|
✅ Successfully connected to PayPal!
|
|
475
|
-
<a
|
|
476
|
-
target="_blank"
|
|
477
|
-
data-paypal-button="true"
|
|
478
|
-
data-paypal-onboard-complete="onboardingCallback"
|
|
479
|
-
href="#"
|
|
480
|
-
style={{ display: "none" }}
|
|
481
|
-
>
|
|
482
|
-
PayPal
|
|
483
|
-
</a>
|
|
484
821
|
</div>
|
|
485
822
|
<div className="mt-3 rounded-md border border-ui-border-base bg-ui-bg-subtle p-3 text-xs text-ui-fg-subtle">
|
|
486
823
|
<div className="font-medium text-ui-fg-base">
|
|
@@ -528,7 +865,7 @@ export default function PayPalConnectionPage() {
|
|
|
528
865
|
ppBtnMeasureRef.current = node
|
|
529
866
|
}}
|
|
530
867
|
id="paypal-button"
|
|
531
|
-
target=
|
|
868
|
+
target={POPUP_NAME}
|
|
532
869
|
data-paypal-button="true"
|
|
533
870
|
href={finalUrl || "#"}
|
|
534
871
|
data-paypal-onboard-complete="onboardingCallback"
|
|
@@ -543,6 +880,13 @@ export default function PayPalConnectionPage() {
|
|
|
543
880
|
Connect to PayPal
|
|
544
881
|
</a>
|
|
545
882
|
|
|
883
|
+
{popupBlocked && (
|
|
884
|
+
<div className="mt-3 text-left text-xs bg-amber-50 text-amber-700 p-3 border border-amber-200 rounded">
|
|
885
|
+
Your browser blocked the PayPal popup. Please allow popups
|
|
886
|
+
for this site, then click “Connect to PayPal” again.
|
|
887
|
+
</div>
|
|
888
|
+
)}
|
|
889
|
+
|
|
546
890
|
<div
|
|
547
891
|
className="mt-2"
|
|
548
892
|
style={{
|