@easypayment/medusa-payment-paypal 0.9.21 → 0.9.22
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 +200 -326
- package/.medusa/server/src/admin/index.mjs +200 -326
- 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 +208 -379
- package/.medusa/server/src/admin/routes/settings/paypal/connection/page.js.map +1 -1
- package/package.json +1 -1
- package/src/admin/routes/settings/paypal/connection/page.tsx +234 -423
|
@@ -36,18 +36,18 @@ declare global {
|
|
|
36
36
|
|
|
37
37
|
|
|
38
38
|
const SERVICE_URL = "/admin/paypal/onboarding-link"
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
// rather than a blocked window or a new tab. The window name matches the
|
|
42
|
-
// anchor's `target` and PayPal's conventional "PPFrame": partner.js opens the
|
|
43
|
-
// flow with `window.open(url, target, ...)`, so when it runs right after us it
|
|
44
|
-
// *reuses this already-open window* (a named-window navigation needs no user
|
|
45
|
-
// activation) instead of trying — and failing, asynchronously — to spawn its
|
|
46
|
-
// own. That lets partner.js still wire its completion bridge without ever
|
|
47
|
-
// opening a second/late window.
|
|
39
|
+
// PayPal's conventional mini-browser window name; also used as the anchor's
|
|
40
|
+
// `target` so partner.js opens the flow in a popup.
|
|
48
41
|
const POPUP_NAME = "PPFrame"
|
|
42
|
+
|
|
43
|
+
// The onboarding link is cached in the browser for 6 hours. This is the heart of
|
|
44
|
+
// the integration: partner.js can only wire the mini-browser + completion
|
|
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.
|
|
49
49
|
const CACHE_PREFIX = "pp_onboard_cache"
|
|
50
|
-
const CACHE_EXPIRY =
|
|
50
|
+
const CACHE_EXPIRY = 6 * 60 * 60 * 1000 // 6 hours
|
|
51
51
|
|
|
52
52
|
const ONBOARDING_COMPLETE_ENDPOINT = "/admin/paypal/onboard-complete"
|
|
53
53
|
const STATUS_ENDPOINT = "/admin/paypal/status"
|
|
@@ -57,9 +57,9 @@ const ENVIRONMENT_ENDPOINT = "/admin/paypal/environment"
|
|
|
57
57
|
|
|
58
58
|
type Env = "sandbox" | "live"
|
|
59
59
|
|
|
60
|
-
// Onboarding URLs are environment-specific
|
|
61
|
-
//
|
|
62
|
-
//
|
|
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
63
|
const cacheKeyFor = (env: Env) => `${CACHE_PREFIX}_${env}`
|
|
64
64
|
|
|
65
65
|
function readCachedUrl(env: Env): string | null {
|
|
@@ -81,11 +81,14 @@ function readCachedUrl(env: Env): string | null {
|
|
|
81
81
|
return null
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
-
|
|
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
|
+
function writeCachedUrl(env: Env, url: string): boolean {
|
|
85
87
|
try {
|
|
86
88
|
localStorage.setItem(cacheKeyFor(env), JSON.stringify({ url, ts: Date.now() }))
|
|
89
|
+
return readCachedUrl(env) === url
|
|
87
90
|
} catch {
|
|
88
|
-
|
|
91
|
+
return false
|
|
89
92
|
}
|
|
90
93
|
}
|
|
91
94
|
|
|
@@ -102,84 +105,22 @@ function clearCachedUrl(env?: Env) {
|
|
|
102
105
|
}
|
|
103
106
|
}
|
|
104
107
|
|
|
105
|
-
// PayPal delivers the seamless-onboarding result by posting a message to
|
|
106
|
-
// window.opener. We validate the sender is actually a PayPal origin before
|
|
107
|
-
// trusting authCode/sharedId.
|
|
108
|
-
function isPayPalOrigin(origin: string): boolean {
|
|
109
|
-
try {
|
|
110
|
-
const host = new URL(origin).hostname.toLowerCase()
|
|
111
|
-
return (
|
|
112
|
-
host === "www.paypal.com" ||
|
|
113
|
-
host === "www.sandbox.paypal.com" ||
|
|
114
|
-
host.endsWith(".paypal.com") ||
|
|
115
|
-
host.endsWith(".paypalobjects.com")
|
|
116
|
-
)
|
|
117
|
-
} catch {
|
|
118
|
-
return false
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
// The completion message shape is not strongly documented, so extract
|
|
123
|
-
// authCode/sharedId defensively from objects, JSON strings, or query strings.
|
|
124
|
-
// We only ever act when BOTH values are present, which keeps the many unrelated
|
|
125
|
-
// intermediate postMessages PayPal emits from triggering a false completion.
|
|
126
|
-
function extractAuth(data: unknown): { authCode?: string; sharedId?: string } {
|
|
127
|
-
if (!data) return {}
|
|
128
|
-
|
|
129
|
-
if (typeof data === "object") {
|
|
130
|
-
const obj = data as Record<string, any>
|
|
131
|
-
const authCode = obj.authCode ?? obj.auth_code ?? obj.authcode
|
|
132
|
-
const sharedId = obj.sharedId ?? obj.shared_id ?? obj.sharedid
|
|
133
|
-
if (authCode && sharedId) {
|
|
134
|
-
return { authCode: String(authCode), sharedId: String(sharedId) }
|
|
135
|
-
}
|
|
136
|
-
for (const key of ["data", "payload", "message", "detail", "body"]) {
|
|
137
|
-
if (obj[key] && typeof obj[key] === "object") {
|
|
138
|
-
const inner = extractAuth(obj[key])
|
|
139
|
-
if (inner.authCode && inner.sharedId) return inner
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
return {}
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
if (typeof data === "string") {
|
|
146
|
-
const s = data.trim()
|
|
147
|
-
if (!s) return {}
|
|
148
|
-
if (s.startsWith("{")) {
|
|
149
|
-
try {
|
|
150
|
-
return extractAuth(JSON.parse(s))
|
|
151
|
-
} catch {
|
|
152
|
-
/* not JSON */
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
if (/auth_?code/i.test(s) && /shared_?id/i.test(s)) {
|
|
156
|
-
try {
|
|
157
|
-
const sp = new URLSearchParams(s.replace(/^[?#]/, ""))
|
|
158
|
-
const authCode = sp.get("authCode") || sp.get("auth_code") || undefined
|
|
159
|
-
const sharedId = sp.get("sharedId") || sp.get("shared_id") || undefined
|
|
160
|
-
if (authCode && sharedId) return { authCode, sharedId }
|
|
161
|
-
} catch {
|
|
162
|
-
/* not a query string */
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
return {}
|
|
168
|
-
}
|
|
169
|
-
|
|
170
108
|
|
|
171
109
|
export default function PayPalConnectionPage() {
|
|
172
110
|
const [env, setEnv] = useState<Env>("live")
|
|
173
|
-
// We must
|
|
174
|
-
//
|
|
175
|
-
// GET /environment response and can target the wrong environment.
|
|
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.
|
|
176
113
|
const [envReady, setEnvReady] = useState(false)
|
|
177
114
|
|
|
178
115
|
const [connState, setConnState] = useState<
|
|
179
116
|
"loading" | "ready" | "connected" | "error"
|
|
180
117
|
>("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)
|
|
181
123
|
const [error, setError] = useState<string | null>(null)
|
|
182
|
-
const [popupBlocked, setPopupBlocked] = useState(false)
|
|
183
124
|
const [finalUrl, setFinalUrl] = useState<string>("")
|
|
184
125
|
const [showManual, setShowManual] = useState(false)
|
|
185
126
|
const [clientId, setClientId] = useState("")
|
|
@@ -193,26 +134,15 @@ export default function PayPalConnectionPage() {
|
|
|
193
134
|
const [onboardingInProgress, setOnboardingInProgress] = useState(false)
|
|
194
135
|
|
|
195
136
|
const initLoaderRef = useRef<HTMLDivElement>(null)
|
|
196
|
-
const paypalButtonRef = useRef<HTMLAnchorElement | null>(null)
|
|
197
137
|
const errorLogRef = useRef<HTMLDivElement>(null)
|
|
198
138
|
const runIdRef = useRef(0)
|
|
199
139
|
const currentRunId = useRef(0)
|
|
200
140
|
|
|
201
|
-
// Live mirrors of state so the document-level capture click listener (set up
|
|
202
|
-
// once) and the stable completion callback always see current values.
|
|
203
|
-
const finalUrlRef = useRef(finalUrl)
|
|
204
|
-
finalUrlRef.current = finalUrl
|
|
205
|
-
const connStateRef = useRef(connState)
|
|
206
|
-
connStateRef.current = connState
|
|
207
|
-
const inProgressRef = useRef(onboardingInProgress)
|
|
208
|
-
inProgressRef.current = onboardingInProgress
|
|
209
141
|
const envRef = useRef<Env>(env)
|
|
210
142
|
envRef.current = env
|
|
211
143
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
// Guards against the two redundant completion paths (our own message listener
|
|
215
|
-
// and partner.js's onboardingCallback bridge) both POSTing the same result.
|
|
144
|
+
// Guards against partner.js's bridge invoking the completion callback more
|
|
145
|
+
// than once.
|
|
216
146
|
const completedRef = useRef(false)
|
|
217
147
|
|
|
218
148
|
const ppBtnMeasureRef = useRef<HTMLAnchorElement | null>(null)
|
|
@@ -227,196 +157,63 @@ export default function PayPalConnectionPage() {
|
|
|
227
157
|
setError(msg)
|
|
228
158
|
}, [])
|
|
229
159
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
// in a user gesture. Because finalUrl is already in state at click time, we
|
|
241
|
-
// never need an await before opening, and we never depend on winning a race
|
|
242
|
-
// with partner.js's asynchronous click binding.
|
|
243
|
-
const openOnboardingPopup = useCallback(
|
|
244
|
-
(url: string): Window | null => {
|
|
245
|
-
const w = 450
|
|
246
|
-
const h = 600
|
|
247
|
-
const baseLeft =
|
|
248
|
-
typeof window.screenX === "number" ? window.screenX : (window as any).screenLeft || 0
|
|
249
|
-
const baseTop =
|
|
250
|
-
typeof window.screenY === "number" ? window.screenY : (window as any).screenTop || 0
|
|
251
|
-
const outerW =
|
|
252
|
-
window.outerWidth || document.documentElement.clientWidth || 1024
|
|
253
|
-
const outerH =
|
|
254
|
-
window.outerHeight || document.documentElement.clientHeight || 768
|
|
255
|
-
const left = Math.round(baseLeft + Math.max(0, (outerW - w) / 2))
|
|
256
|
-
const top = Math.round(baseTop + Math.max(0, (outerH - h) / 2))
|
|
257
|
-
const features = `popup=yes,width=${w},height=${h},left=${left},top=${top},scrollbars=yes,resizable=yes`
|
|
258
|
-
|
|
259
|
-
let popup: Window | null = null
|
|
260
|
-
try {
|
|
261
|
-
popup = window.open(url, POPUP_NAME, features)
|
|
262
|
-
} catch {
|
|
263
|
-
popup = null
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
if (popup) {
|
|
267
|
-
popupRef.current = popup
|
|
268
|
-
try {
|
|
269
|
-
popup.focus()
|
|
270
|
-
} catch {
|
|
271
|
-
/* ignore */
|
|
160
|
+
// Generate a fresh onboarding link, cache it for 6 hours, then reload the page
|
|
161
|
+
// so that on the next load the cached link is already present for partner.js.
|
|
162
|
+
// If browser storage is unavailable we fall back to using the link in-place
|
|
163
|
+
// (no reload) so we can never get stuck in a reload loop.
|
|
164
|
+
const generateLinkAndReload = useCallback(
|
|
165
|
+
async (targetEnv: Env, runId: number) => {
|
|
166
|
+
if (initLoaderRef.current) {
|
|
167
|
+
const loaderText = initLoaderRef.current.querySelector("#loader-text")
|
|
168
|
+
if (loaderText) {
|
|
169
|
+
loaderText.textContent = "Generating onboarding session..."
|
|
272
170
|
}
|
|
273
|
-
stopPopupPoll()
|
|
274
|
-
popupPollRef.current = setInterval(() => {
|
|
275
|
-
if (!popupRef.current || popupRef.current.closed) {
|
|
276
|
-
stopPopupPoll()
|
|
277
|
-
}
|
|
278
|
-
}, 1000)
|
|
279
171
|
}
|
|
280
172
|
|
|
281
|
-
return popup
|
|
282
|
-
},
|
|
283
|
-
[stopPopupPoll]
|
|
284
|
-
)
|
|
285
|
-
|
|
286
|
-
// Exchange the seller authCode/sharedId for credentials. Reached from BOTH
|
|
287
|
-
// PayPal's partner.js bridge (window.onboardingCallback) and our own postMessage
|
|
288
|
-
// listener; completedRef makes it idempotent so we only POST once.
|
|
289
|
-
const completeOnboarding = useCallback(
|
|
290
|
-
async (authCode: string, sharedId: string) => {
|
|
291
|
-
if (!authCode || !sharedId) return
|
|
292
|
-
if (completedRef.current) return
|
|
293
|
-
completedRef.current = true
|
|
294
|
-
|
|
295
|
-
try {
|
|
296
|
-
window.onbeforeunload = null
|
|
297
|
-
} catch {
|
|
298
|
-
/* ignore */
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
const activeEnv: Env = envRef.current === "sandbox" ? "sandbox" : "live"
|
|
302
|
-
|
|
303
|
-
setOnboardingInProgress(true)
|
|
304
|
-
setConnState("loading")
|
|
305
|
-
setError(null)
|
|
306
|
-
setPopupBlocked(false)
|
|
307
|
-
|
|
308
173
|
try {
|
|
309
|
-
const res = await fetch(
|
|
174
|
+
const res = await fetch(SERVICE_URL, {
|
|
310
175
|
method: "POST",
|
|
311
176
|
headers: { "content-type": "application/json" },
|
|
312
177
|
credentials: "include",
|
|
313
|
-
body: JSON.stringify({
|
|
178
|
+
body: JSON.stringify({ products: ["PPCP"], environment: targetEnv }),
|
|
314
179
|
})
|
|
315
180
|
|
|
316
|
-
if (!res.ok) {
|
|
317
|
-
const txt = await res.text().catch(() => "")
|
|
318
|
-
throw new Error(txt || `Onboarding exchange failed (${res.status})`)
|
|
319
|
-
}
|
|
181
|
+
if (!res.ok) throw new Error(`Service returned ${res.status}`)
|
|
320
182
|
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
popupRef.current?.close()
|
|
324
|
-
} catch {
|
|
325
|
-
/* ignore */
|
|
326
|
-
}
|
|
327
|
-
stopPopupPoll()
|
|
328
|
-
try {
|
|
329
|
-
const close1 = window.PAYPAL?.apps?.Signup?.MiniBrowser?.closeFlow
|
|
330
|
-
if (typeof close1 === "function") close1()
|
|
331
|
-
} catch {
|
|
332
|
-
/* ignore */
|
|
333
|
-
}
|
|
334
|
-
try {
|
|
335
|
-
const close2 =
|
|
336
|
-
window.PAYPAL?.apps?.Signup?.miniBrowser &&
|
|
337
|
-
(window.PAYPAL.apps.Signup.miniBrowser as any).closeFlow
|
|
338
|
-
if (typeof close2 === "function") close2()
|
|
339
|
-
} catch {
|
|
340
|
-
/* ignore */
|
|
341
|
-
}
|
|
183
|
+
const data = await res.json()
|
|
184
|
+
if (runId !== currentRunId.current) return
|
|
342
185
|
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
`${STATUS_ENDPOINT}?environment=${activeEnv}`,
|
|
348
|
-
{ method: "GET", credentials: "include" }
|
|
349
|
-
)
|
|
350
|
-
const refreshedStatus = await statusRes.json().catch(() => ({}))
|
|
351
|
-
setStatusInfo(refreshedStatus || null)
|
|
352
|
-
} catch {
|
|
353
|
-
/* ignore — still consider it connected below */
|
|
186
|
+
const href = data?.onboarding_url
|
|
187
|
+
if (!href) {
|
|
188
|
+
showError("Onboarding URL not returned.")
|
|
189
|
+
return
|
|
354
190
|
}
|
|
355
191
|
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
} catch (e: any) {
|
|
359
|
-
// Allow a retry after a failed exchange.
|
|
360
|
-
completedRef.current = false
|
|
361
|
-
console.error(e)
|
|
362
|
-
setConnState("error")
|
|
363
|
-
setError(e?.message || "Exchange failed while saving credentials.")
|
|
364
|
-
} finally {
|
|
365
|
-
setOnboardingInProgress(false)
|
|
366
|
-
}
|
|
367
|
-
},
|
|
368
|
-
[stopPopupPoll]
|
|
369
|
-
)
|
|
370
|
-
|
|
371
|
-
const fetchFreshLink = useCallback(
|
|
372
|
-
(runId: number, targetEnv: Env) => {
|
|
373
|
-
if (initLoaderRef.current) {
|
|
374
|
-
const loaderText = initLoaderRef.current.querySelector("#loader-text")
|
|
375
|
-
if (loaderText)
|
|
376
|
-
loaderText.textContent = "Generating onboarding session..."
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
fetch(SERVICE_URL, {
|
|
380
|
-
method: "POST",
|
|
381
|
-
headers: { "content-type": "application/json" },
|
|
382
|
-
credentials: "include",
|
|
383
|
-
body: JSON.stringify({
|
|
384
|
-
products: ["PPCP"],
|
|
385
|
-
// Generate the link for the explicitly selected environment so it can
|
|
386
|
-
// never depend on a racing POST /environment having landed yet.
|
|
387
|
-
environment: targetEnv,
|
|
388
|
-
}),
|
|
389
|
-
})
|
|
390
|
-
.then((r) => {
|
|
391
|
-
if (!r.ok) throw new Error(`Service returned ${r.status}`)
|
|
392
|
-
return r.json()
|
|
393
|
-
})
|
|
394
|
-
.then((data) => {
|
|
395
|
-
if (runId !== currentRunId.current) return
|
|
192
|
+
const url =
|
|
193
|
+
href + (href.includes("?") ? "&" : "?") + "displayMode=minibrowser"
|
|
396
194
|
|
|
397
|
-
|
|
398
|
-
if (!href) {
|
|
399
|
-
showError("Onboarding URL not returned.")
|
|
400
|
-
return
|
|
401
|
-
}
|
|
195
|
+
const persisted = writeCachedUrl(targetEnv, url)
|
|
402
196
|
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
197
|
+
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
|
+
window.location.reload()
|
|
202
|
+
return
|
|
203
|
+
}
|
|
407
204
|
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
205
|
+
// Storage disabled — use the link directly without reloading.
|
|
206
|
+
setFinalUrl(url)
|
|
207
|
+
setConnState("ready")
|
|
208
|
+
} catch {
|
|
209
|
+
if (runId !== currentRunId.current) return
|
|
210
|
+
showError("Unable to connect to service.")
|
|
211
|
+
}
|
|
415
212
|
},
|
|
416
213
|
[showError]
|
|
417
214
|
)
|
|
418
215
|
|
|
419
|
-
// Resolve the server
|
|
216
|
+
// Resolve the server environment first.
|
|
420
217
|
useEffect(() => {
|
|
421
218
|
let alive = true
|
|
422
219
|
fetch(ENVIRONMENT_ENDPOINT, { method: "GET", credentials: "include" })
|
|
@@ -434,14 +231,73 @@ export default function PayPalConnectionPage() {
|
|
|
434
231
|
}
|
|
435
232
|
}, [])
|
|
436
233
|
|
|
437
|
-
//
|
|
438
|
-
//
|
|
439
|
-
//
|
|
440
|
-
//
|
|
441
|
-
//
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
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.
|
|
239
|
+
useEffect(() => {
|
|
240
|
+
if (!envReady) return
|
|
241
|
+
|
|
242
|
+
currentRunId.current = ++runIdRef.current
|
|
243
|
+
const runId = currentRunId.current
|
|
244
|
+
|
|
245
|
+
let cancelled = false
|
|
246
|
+
completedRef.current = false
|
|
247
|
+
|
|
248
|
+
const run = async () => {
|
|
249
|
+
setConnState("loading")
|
|
250
|
+
setError(null)
|
|
251
|
+
setFinalUrl("")
|
|
252
|
+
|
|
253
|
+
try {
|
|
254
|
+
const r = await fetch(`${STATUS_ENDPOINT}?environment=${env}`, {
|
|
255
|
+
method: "GET",
|
|
256
|
+
credentials: "include",
|
|
257
|
+
})
|
|
258
|
+
const st = await r.json().catch(() => ({}))
|
|
259
|
+
|
|
260
|
+
if (cancelled || runId !== currentRunId.current) return
|
|
261
|
+
|
|
262
|
+
setStatusInfo(st)
|
|
263
|
+
|
|
264
|
+
const isConnected =
|
|
265
|
+
st?.status === "connected" && st?.seller_client_id_present === true
|
|
266
|
+
|
|
267
|
+
if (isConnected) {
|
|
268
|
+
setConnState("connected")
|
|
269
|
+
setShowManual(false)
|
|
270
|
+
return
|
|
271
|
+
}
|
|
272
|
+
} catch (e) {
|
|
273
|
+
console.error(e)
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (cancelled || runId !== currentRunId.current) return
|
|
277
|
+
|
|
278
|
+
const cached = readCachedUrl(env)
|
|
279
|
+
if (cached) {
|
|
280
|
+
setFinalUrl(cached)
|
|
281
|
+
setConnState("ready")
|
|
282
|
+
return
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
await generateLinkAndReload(env, runId)
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
run()
|
|
289
|
+
|
|
290
|
+
return () => {
|
|
291
|
+
cancelled = true
|
|
292
|
+
}
|
|
293
|
+
}, [env, envReady, generateLinkAndReload])
|
|
294
|
+
|
|
295
|
+
// Load partner.js once the anchor exists with its real (cached) href, then run
|
|
296
|
+
// PayPal's initializer. In a React SPA partner.js's own DOMContentLoaded hook
|
|
297
|
+
// already fired before this route mounted, so we must call init()/render()
|
|
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.
|
|
445
301
|
useEffect(() => {
|
|
446
302
|
if (connState !== "ready" || !finalUrl) return
|
|
447
303
|
|
|
@@ -449,6 +305,19 @@ export default function PayPalConnectionPage() {
|
|
|
449
305
|
let cancelled = false
|
|
450
306
|
let pollTimer: ReturnType<typeof setTimeout> | undefined
|
|
451
307
|
|
|
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
|
+
|
|
452
321
|
const initPartner = (attempt = 0) => {
|
|
453
322
|
if (cancelled) return
|
|
454
323
|
|
|
@@ -462,13 +331,11 @@ export default function PayPalConnectionPage() {
|
|
|
462
331
|
|
|
463
332
|
if (target?.init) {
|
|
464
333
|
try {
|
|
465
|
-
// Wires partner.js's click interceptor + its postMessage->callback
|
|
466
|
-
// bridge. It will adopt the window we pre-open (same name) rather than
|
|
467
|
-
// open its own.
|
|
468
334
|
target.init()
|
|
469
335
|
} catch (e) {
|
|
470
336
|
console.error("[paypal] partner.js init failed:", e)
|
|
471
337
|
}
|
|
338
|
+
markReady()
|
|
472
339
|
return
|
|
473
340
|
}
|
|
474
341
|
|
|
@@ -478,9 +345,12 @@ export default function PayPalConnectionPage() {
|
|
|
478
345
|
} catch (e) {
|
|
479
346
|
console.error("[paypal] partner.js render failed:", e)
|
|
480
347
|
}
|
|
348
|
+
markReady()
|
|
481
349
|
return
|
|
482
350
|
}
|
|
483
351
|
|
|
352
|
+
// The namespace may not be populated the instant onload fires; retry
|
|
353
|
+
// briefly before giving up.
|
|
484
354
|
if (attempt < 40) {
|
|
485
355
|
pollTimer = setTimeout(() => initPartner(attempt + 1), 50)
|
|
486
356
|
return
|
|
@@ -489,6 +359,7 @@ export default function PayPalConnectionPage() {
|
|
|
489
359
|
try {
|
|
490
360
|
document.dispatchEvent(new Event("DOMContentLoaded"))
|
|
491
361
|
} catch {}
|
|
362
|
+
markReady()
|
|
492
363
|
}
|
|
493
364
|
|
|
494
365
|
// Remove any stale copy + namespace so the fresh load re-registers against
|
|
@@ -508,10 +379,12 @@ export default function PayPalConnectionPage() {
|
|
|
508
379
|
ppScript.src = scriptUrl
|
|
509
380
|
ppScript.async = true
|
|
510
381
|
ppScript.onload = () => initPartner()
|
|
382
|
+
ppScript.onerror = () => markReady()
|
|
511
383
|
document.body.appendChild(ppScript)
|
|
512
384
|
|
|
513
385
|
return () => {
|
|
514
386
|
cancelled = true
|
|
387
|
+
clearTimeout(readyFallback)
|
|
515
388
|
if (pollTimer) {
|
|
516
389
|
clearTimeout(pollTimer)
|
|
517
390
|
}
|
|
@@ -521,81 +394,73 @@ export default function PayPalConnectionPage() {
|
|
|
521
394
|
}
|
|
522
395
|
}, [connState, finalUrl, env])
|
|
523
396
|
|
|
524
|
-
//
|
|
525
|
-
//
|
|
526
|
-
//
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
if (authCode && sharedId) {
|
|
532
|
-
completeOnboarding(authCode, sharedId)
|
|
533
|
-
}
|
|
534
|
-
}
|
|
535
|
-
window.addEventListener("message", onMessage)
|
|
536
|
-
return () => window.removeEventListener("message", onMessage)
|
|
537
|
-
}, [completeOnboarding])
|
|
538
|
-
|
|
539
|
-
// Status check + link generation. Gated on envReady so we always use the
|
|
540
|
-
// correct environment. runId guards against a stale response from a previous
|
|
541
|
-
// environment overwriting the current one.
|
|
542
|
-
useEffect(() => {
|
|
543
|
-
if (!envReady) return
|
|
544
|
-
|
|
545
|
-
currentRunId.current = ++runIdRef.current
|
|
546
|
-
const runId = currentRunId.current
|
|
547
|
-
|
|
548
|
-
let cancelled = false
|
|
549
|
-
completedRef.current = false
|
|
397
|
+
// partner.js calls this (by the name in data-paypal-onboard-complete) when the
|
|
398
|
+
// seller finishes onboarding, passing the authCode + sharedId to exchange for
|
|
399
|
+
// seller credentials.
|
|
400
|
+
const completeOnboarding = useCallback(async (authCode: string, sharedId: string) => {
|
|
401
|
+
if (!authCode || !sharedId) return
|
|
402
|
+
if (completedRef.current) return
|
|
403
|
+
completedRef.current = true
|
|
550
404
|
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
setPopupBlocked(false)
|
|
555
|
-
setFinalUrl("")
|
|
556
|
-
|
|
557
|
-
try {
|
|
558
|
-
const r = await fetch(`${STATUS_ENDPOINT}?environment=${env}`, {
|
|
559
|
-
method: "GET",
|
|
560
|
-
credentials: "include",
|
|
561
|
-
})
|
|
562
|
-
const st = await r.json().catch(() => ({}))
|
|
405
|
+
try {
|
|
406
|
+
window.onbeforeunload = null
|
|
407
|
+
} catch {}
|
|
563
408
|
|
|
564
|
-
|
|
409
|
+
const activeEnv: Env = envRef.current === "sandbox" ? "sandbox" : "live"
|
|
565
410
|
|
|
566
|
-
|
|
411
|
+
setOnboardingInProgress(true)
|
|
412
|
+
setConnState("loading")
|
|
413
|
+
setError(null)
|
|
567
414
|
|
|
568
|
-
|
|
569
|
-
|
|
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
|
+
})
|
|
570
422
|
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
return
|
|
575
|
-
}
|
|
576
|
-
} catch (e) {
|
|
577
|
-
console.error(e)
|
|
423
|
+
if (!res.ok) {
|
|
424
|
+
const txt = await res.text().catch(() => "")
|
|
425
|
+
throw new Error(txt || `Onboarding exchange failed (${res.status})`)
|
|
578
426
|
}
|
|
579
427
|
|
|
580
|
-
|
|
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 {}
|
|
433
|
+
try {
|
|
434
|
+
const close2 =
|
|
435
|
+
window.PAYPAL?.apps?.Signup?.miniBrowser &&
|
|
436
|
+
(window.PAYPAL.apps.Signup.miniBrowser as any).closeFlow
|
|
437
|
+
if (typeof close2 === "function") close2()
|
|
438
|
+
} catch {}
|
|
581
439
|
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
setFinalUrl(cached)
|
|
585
|
-
setConnState("ready")
|
|
586
|
-
} else {
|
|
587
|
-
fetchFreshLink(runId, env)
|
|
588
|
-
}
|
|
589
|
-
}
|
|
440
|
+
// The link has been consumed; drop it so a future flow regenerates one.
|
|
441
|
+
clearCachedUrl(activeEnv)
|
|
590
442
|
|
|
591
|
-
|
|
443
|
+
try {
|
|
444
|
+
const statusRes = await fetch(`${STATUS_ENDPOINT}?environment=${activeEnv}`, {
|
|
445
|
+
method: "GET",
|
|
446
|
+
credentials: "include",
|
|
447
|
+
})
|
|
448
|
+
const refreshedStatus = await statusRes.json().catch(() => ({}))
|
|
449
|
+
setStatusInfo(refreshedStatus || null)
|
|
450
|
+
} catch {}
|
|
592
451
|
|
|
593
|
-
|
|
594
|
-
|
|
452
|
+
setConnState("connected")
|
|
453
|
+
setShowManual(false)
|
|
454
|
+
} catch (e: any) {
|
|
455
|
+
completedRef.current = false
|
|
456
|
+
console.error(e)
|
|
457
|
+
setConnState("error")
|
|
458
|
+
setError(e?.message || "Exchange failed while saving credentials.")
|
|
459
|
+
} finally {
|
|
460
|
+
setOnboardingInProgress(false)
|
|
595
461
|
}
|
|
596
|
-
}, [
|
|
462
|
+
}, [])
|
|
597
463
|
|
|
598
|
-
// Bridge partner.js's callback into our single idempotent completion handler.
|
|
599
464
|
useLayoutEffect(() => {
|
|
600
465
|
window.onboardingCallback = (authCode: string, sharedId: string) => {
|
|
601
466
|
completeOnboarding(authCode, sharedId)
|
|
@@ -605,13 +470,6 @@ export default function PayPalConnectionPage() {
|
|
|
605
470
|
}
|
|
606
471
|
}, [completeOnboarding])
|
|
607
472
|
|
|
608
|
-
// Clean up the popup poll on unmount.
|
|
609
|
-
useEffect(() => {
|
|
610
|
-
return () => {
|
|
611
|
-
stopPopupPoll()
|
|
612
|
-
}
|
|
613
|
-
}, [stopPopupPoll])
|
|
614
|
-
|
|
615
473
|
useLayoutEffect(() => {
|
|
616
474
|
const el = ppBtnMeasureRef.current
|
|
617
475
|
if (!el) return
|
|
@@ -637,57 +495,6 @@ export default function PayPalConnectionPage() {
|
|
|
637
495
|
}
|
|
638
496
|
}, [connState, env, finalUrl])
|
|
639
497
|
|
|
640
|
-
// Capture-phase click handler — the single, reliable opener.
|
|
641
|
-
//
|
|
642
|
-
// It runs on `document` in the CAPTURE phase, i.e. BEFORE the event reaches
|
|
643
|
-
// the anchor where partner.js binds its own (target-phase) handler. That
|
|
644
|
-
// ordering is the whole point:
|
|
645
|
-
// 1. We synchronously open the mini-browser popup here, inside the user
|
|
646
|
-
// gesture, so the browser always renders a real popup (never a blocked
|
|
647
|
-
// window, never a new tab).
|
|
648
|
-
// 2. We do NOT stop propagation, so partner.js's handler still runs a beat
|
|
649
|
-
// later. Since it opens with the same window name (POPUP_NAME), it adopts
|
|
650
|
-
// the window we just opened instead of asynchronously spawning its own
|
|
651
|
-
// (which the browser was blocking — "multiple popups ... lack of user
|
|
652
|
-
// activation") — and it still wires its postMessage->onboardingCallback
|
|
653
|
-
// bridge that auto-closes the popup and saves credentials.
|
|
654
|
-
// 3. We always preventDefault so the anchor's href can never open a tab.
|
|
655
|
-
//
|
|
656
|
-
// Completion is handled by partner.js's bridge AND by our own postMessage
|
|
657
|
-
// listener (see below), funnelling through one idempotent completeOnboarding.
|
|
658
|
-
useEffect(() => {
|
|
659
|
-
const onCaptureClick = (e: MouseEvent) => {
|
|
660
|
-
const btn = paypalButtonRef.current
|
|
661
|
-
if (!btn) return
|
|
662
|
-
const target = e.target as Node | null
|
|
663
|
-
if (!target || !btn.contains(target)) return
|
|
664
|
-
|
|
665
|
-
e.preventDefault()
|
|
666
|
-
|
|
667
|
-
if (
|
|
668
|
-
connStateRef.current !== "ready" ||
|
|
669
|
-
!finalUrlRef.current ||
|
|
670
|
-
inProgressRef.current
|
|
671
|
-
) {
|
|
672
|
-
return
|
|
673
|
-
}
|
|
674
|
-
|
|
675
|
-
completedRef.current = false
|
|
676
|
-
const popup = openOnboardingPopup(finalUrlRef.current)
|
|
677
|
-
setPopupBlocked(!popup)
|
|
678
|
-
}
|
|
679
|
-
|
|
680
|
-
document.addEventListener("click", onCaptureClick, true)
|
|
681
|
-
return () => document.removeEventListener("click", onCaptureClick, true)
|
|
682
|
-
}, [openOnboardingPopup])
|
|
683
|
-
|
|
684
|
-
// Defensive only: the capture-phase listener above already opened the popup
|
|
685
|
-
// and prevented the default. If for some reason it didn't run, make sure the
|
|
686
|
-
// native target still can't open a tab.
|
|
687
|
-
const handleConnectClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
|
|
688
|
-
e.preventDefault()
|
|
689
|
-
}
|
|
690
|
-
|
|
691
498
|
const handleSaveManual = async () => {
|
|
692
499
|
if (!canSaveManual || onboardingInProgress) return
|
|
693
500
|
setOnboardingInProgress(true)
|
|
@@ -756,11 +563,12 @@ export default function PayPalConnectionPage() {
|
|
|
756
563
|
}
|
|
757
564
|
|
|
758
565
|
clearCachedUrl(env)
|
|
759
|
-
|
|
760
566
|
completedRef.current = false
|
|
567
|
+
|
|
568
|
+
// Generate a fresh link for the now-disconnected environment and reload so
|
|
569
|
+
// the onboarding button is ready again.
|
|
761
570
|
currentRunId.current = ++runIdRef.current
|
|
762
|
-
|
|
763
|
-
fetchFreshLink(runId, env)
|
|
571
|
+
await generateLinkAndReload(env, currentRunId.current)
|
|
764
572
|
} catch (e: any) {
|
|
765
573
|
console.error(e)
|
|
766
574
|
setConnState("error")
|
|
@@ -772,18 +580,14 @@ export default function PayPalConnectionPage() {
|
|
|
772
580
|
|
|
773
581
|
const handleEnvChange = async (e: React.ChangeEvent<HTMLSelectElement>) => {
|
|
774
582
|
const next = e.target.value as Env
|
|
583
|
+
if (next === env || onboardingInProgress) return
|
|
775
584
|
|
|
776
585
|
completedRef.current = false
|
|
777
|
-
setPopupBlocked(false)
|
|
778
|
-
// Drop both cached URLs so neither environment can serve a stale link.
|
|
779
586
|
clearCachedUrl()
|
|
587
|
+
setConnState("loading")
|
|
780
588
|
|
|
781
|
-
//
|
|
782
|
-
//
|
|
783
|
-
// fetches are environment-explicit, correctness no longer depends on this
|
|
784
|
-
// POST winning a race.
|
|
785
|
-
setEnv(next)
|
|
786
|
-
|
|
589
|
+
// Persist the environment BEFORE switching local state so the post-reload
|
|
590
|
+
// GET /environment returns the same value the cache was keyed under.
|
|
787
591
|
try {
|
|
788
592
|
await fetch(ENVIRONMENT_ENDPOINT, {
|
|
789
593
|
method: "POST",
|
|
@@ -792,6 +596,8 @@ export default function PayPalConnectionPage() {
|
|
|
792
596
|
body: JSON.stringify({ environment: next }),
|
|
793
597
|
})
|
|
794
598
|
} catch {}
|
|
599
|
+
|
|
600
|
+
setEnv(next)
|
|
795
601
|
}
|
|
796
602
|
|
|
797
603
|
return (
|
|
@@ -870,9 +676,12 @@ export default function PayPalConnectionPage() {
|
|
|
870
676
|
</div>
|
|
871
677
|
|
|
872
678
|
<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. */}
|
|
873
683
|
<a
|
|
874
684
|
ref={(node) => {
|
|
875
|
-
paypalButtonRef.current = node
|
|
876
685
|
ppBtnMeasureRef.current = node
|
|
877
686
|
}}
|
|
878
687
|
id="paypal-button"
|
|
@@ -880,24 +689,26 @@ export default function PayPalConnectionPage() {
|
|
|
880
689
|
data-paypal-button="true"
|
|
881
690
|
href={finalUrl || "#"}
|
|
882
691
|
data-paypal-onboard-complete="onboardingCallback"
|
|
883
|
-
|
|
692
|
+
aria-disabled={onboardingInProgress || !partnerReady}
|
|
884
693
|
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"
|
|
885
694
|
style={{
|
|
886
|
-
cursor:
|
|
887
|
-
|
|
888
|
-
|
|
695
|
+
cursor:
|
|
696
|
+
onboardingInProgress || !partnerReady
|
|
697
|
+
? "not-allowed"
|
|
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",
|
|
889
705
|
}}
|
|
890
706
|
>
|
|
891
|
-
|
|
707
|
+
{partnerReady || onboardingInProgress
|
|
708
|
+
? "Connect to PayPal"
|
|
709
|
+
: "Preparing PayPal…"}
|
|
892
710
|
</a>
|
|
893
711
|
|
|
894
|
-
{popupBlocked && (
|
|
895
|
-
<div className="mt-3 text-left text-xs bg-amber-50 text-amber-700 p-3 border border-amber-200 rounded">
|
|
896
|
-
Your browser blocked the PayPal popup. Please allow popups
|
|
897
|
-
for this site, then click “Connect to PayPal” again.
|
|
898
|
-
</div>
|
|
899
|
-
)}
|
|
900
|
-
|
|
901
712
|
<div
|
|
902
713
|
className="mt-2"
|
|
903
714
|
style={{
|