@easypayment/medusa-payment-paypal 0.9.20 → 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 +148 -270
- package/.medusa/server/src/admin/index.mjs +148 -270
- 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 +209 -369
- 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 +235 -414
|
@@ -36,13 +36,18 @@ declare global {
|
|
|
36
36
|
|
|
37
37
|
|
|
38
38
|
const SERVICE_URL = "/admin/paypal/onboarding-link"
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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.
|
|
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.
|
|
44
49
|
const CACHE_PREFIX = "pp_onboard_cache"
|
|
45
|
-
const CACHE_EXPIRY =
|
|
50
|
+
const CACHE_EXPIRY = 6 * 60 * 60 * 1000 // 6 hours
|
|
46
51
|
|
|
47
52
|
const ONBOARDING_COMPLETE_ENDPOINT = "/admin/paypal/onboard-complete"
|
|
48
53
|
const STATUS_ENDPOINT = "/admin/paypal/status"
|
|
@@ -52,9 +57,9 @@ const ENVIRONMENT_ENDPOINT = "/admin/paypal/environment"
|
|
|
52
57
|
|
|
53
58
|
type Env = "sandbox" | "live"
|
|
54
59
|
|
|
55
|
-
// Onboarding URLs are environment-specific
|
|
56
|
-
//
|
|
57
|
-
//
|
|
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).
|
|
58
63
|
const cacheKeyFor = (env: Env) => `${CACHE_PREFIX}_${env}`
|
|
59
64
|
|
|
60
65
|
function readCachedUrl(env: Env): string | null {
|
|
@@ -76,11 +81,14 @@ function readCachedUrl(env: Env): string | null {
|
|
|
76
81
|
return null
|
|
77
82
|
}
|
|
78
83
|
|
|
79
|
-
|
|
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 {
|
|
80
87
|
try {
|
|
81
88
|
localStorage.setItem(cacheKeyFor(env), JSON.stringify({ url, ts: Date.now() }))
|
|
89
|
+
return readCachedUrl(env) === url
|
|
82
90
|
} catch {
|
|
83
|
-
|
|
91
|
+
return false
|
|
84
92
|
}
|
|
85
93
|
}
|
|
86
94
|
|
|
@@ -97,84 +105,22 @@ function clearCachedUrl(env?: Env) {
|
|
|
97
105
|
}
|
|
98
106
|
}
|
|
99
107
|
|
|
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 */
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
return {}
|
|
163
|
-
}
|
|
164
|
-
|
|
165
108
|
|
|
166
109
|
export default function PayPalConnectionPage() {
|
|
167
110
|
const [env, setEnv] = useState<Env>("live")
|
|
168
|
-
// We must
|
|
169
|
-
//
|
|
170
|
-
// 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.
|
|
171
113
|
const [envReady, setEnvReady] = useState(false)
|
|
172
114
|
|
|
173
115
|
const [connState, setConnState] = useState<
|
|
174
116
|
"loading" | "ready" | "connected" | "error"
|
|
175
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)
|
|
176
123
|
const [error, setError] = useState<string | null>(null)
|
|
177
|
-
const [popupBlocked, setPopupBlocked] = useState(false)
|
|
178
124
|
const [finalUrl, setFinalUrl] = useState<string>("")
|
|
179
125
|
const [showManual, setShowManual] = useState(false)
|
|
180
126
|
const [clientId, setClientId] = useState("")
|
|
@@ -188,33 +134,16 @@ export default function PayPalConnectionPage() {
|
|
|
188
134
|
const [onboardingInProgress, setOnboardingInProgress] = useState(false)
|
|
189
135
|
|
|
190
136
|
const initLoaderRef = useRef<HTMLDivElement>(null)
|
|
191
|
-
const paypalButtonRef = useRef<HTMLAnchorElement | null>(null)
|
|
192
137
|
const errorLogRef = useRef<HTMLDivElement>(null)
|
|
193
138
|
const runIdRef = useRef(0)
|
|
194
139
|
const currentRunId = useRef(0)
|
|
195
140
|
|
|
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
141
|
const envRef = useRef<Env>(env)
|
|
205
142
|
envRef.current = env
|
|
206
143
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
// Guards against the two redundant completion paths (our own message listener
|
|
210
|
-
// 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.
|
|
211
146
|
const completedRef = useRef(false)
|
|
212
|
-
// True once partner.js has initialized and bound its click interceptor to the
|
|
213
|
-
// button. When bound, partner.js opens the mini-browser AND wires its
|
|
214
|
-
// postMessage->onboardingCallback bridge (which auto-closes the popup and
|
|
215
|
-
// saves credentials), so we must NOT open a second window. Until it is bound
|
|
216
|
-
// (cold first click), we open the popup ourselves so the click still works.
|
|
217
|
-
const partnerBoundRef = useRef(false)
|
|
218
147
|
|
|
219
148
|
const ppBtnMeasureRef = useRef<HTMLAnchorElement | null>(null)
|
|
220
149
|
const [ppBtnWidth, setPpBtnWidth] = useState<number | null>(null)
|
|
@@ -228,196 +157,63 @@ export default function PayPalConnectionPage() {
|
|
|
228
157
|
setError(msg)
|
|
229
158
|
}, [])
|
|
230
159
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
// in a user gesture. Because finalUrl is already in state at click time, we
|
|
242
|
-
// never need an await before opening, and we never depend on winning a race
|
|
243
|
-
// with partner.js's asynchronous click binding.
|
|
244
|
-
const openOnboardingPopup = useCallback(
|
|
245
|
-
(url: string): Window | null => {
|
|
246
|
-
const w = 450
|
|
247
|
-
const h = 600
|
|
248
|
-
const baseLeft =
|
|
249
|
-
typeof window.screenX === "number" ? window.screenX : (window as any).screenLeft || 0
|
|
250
|
-
const baseTop =
|
|
251
|
-
typeof window.screenY === "number" ? window.screenY : (window as any).screenTop || 0
|
|
252
|
-
const outerW =
|
|
253
|
-
window.outerWidth || document.documentElement.clientWidth || 1024
|
|
254
|
-
const outerH =
|
|
255
|
-
window.outerHeight || document.documentElement.clientHeight || 768
|
|
256
|
-
const left = Math.round(baseLeft + Math.max(0, (outerW - w) / 2))
|
|
257
|
-
const top = Math.round(baseTop + Math.max(0, (outerH - h) / 2))
|
|
258
|
-
const features = `popup=yes,width=${w},height=${h},left=${left},top=${top},scrollbars=yes,resizable=yes`
|
|
259
|
-
|
|
260
|
-
let popup: Window | null = null
|
|
261
|
-
try {
|
|
262
|
-
popup = window.open(url, POPUP_NAME, features)
|
|
263
|
-
} catch {
|
|
264
|
-
popup = null
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
if (popup) {
|
|
268
|
-
popupRef.current = popup
|
|
269
|
-
try {
|
|
270
|
-
popup.focus()
|
|
271
|
-
} catch {
|
|
272
|
-
/* 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..."
|
|
273
170
|
}
|
|
274
|
-
stopPopupPoll()
|
|
275
|
-
popupPollRef.current = setInterval(() => {
|
|
276
|
-
if (!popupRef.current || popupRef.current.closed) {
|
|
277
|
-
stopPopupPoll()
|
|
278
|
-
}
|
|
279
|
-
}, 1000)
|
|
280
171
|
}
|
|
281
172
|
|
|
282
|
-
return popup
|
|
283
|
-
},
|
|
284
|
-
[stopPopupPoll]
|
|
285
|
-
)
|
|
286
|
-
|
|
287
|
-
// Exchange the seller authCode/sharedId for credentials. Reached from BOTH
|
|
288
|
-
// PayPal's partner.js bridge (window.onboardingCallback) and our own postMessage
|
|
289
|
-
// listener; completedRef makes it idempotent so we only POST once.
|
|
290
|
-
const completeOnboarding = useCallback(
|
|
291
|
-
async (authCode: string, sharedId: string) => {
|
|
292
|
-
if (!authCode || !sharedId) return
|
|
293
|
-
if (completedRef.current) return
|
|
294
|
-
completedRef.current = true
|
|
295
|
-
|
|
296
|
-
try {
|
|
297
|
-
window.onbeforeunload = null
|
|
298
|
-
} catch {
|
|
299
|
-
/* ignore */
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
const activeEnv: Env = envRef.current === "sandbox" ? "sandbox" : "live"
|
|
303
|
-
|
|
304
|
-
setOnboardingInProgress(true)
|
|
305
|
-
setConnState("loading")
|
|
306
|
-
setError(null)
|
|
307
|
-
setPopupBlocked(false)
|
|
308
|
-
|
|
309
173
|
try {
|
|
310
|
-
const res = await fetch(
|
|
174
|
+
const res = await fetch(SERVICE_URL, {
|
|
311
175
|
method: "POST",
|
|
312
176
|
headers: { "content-type": "application/json" },
|
|
313
177
|
credentials: "include",
|
|
314
|
-
body: JSON.stringify({
|
|
178
|
+
body: JSON.stringify({ products: ["PPCP"], environment: targetEnv }),
|
|
315
179
|
})
|
|
316
180
|
|
|
317
|
-
if (!res.ok) {
|
|
318
|
-
const txt = await res.text().catch(() => "")
|
|
319
|
-
throw new Error(txt || `Onboarding exchange failed (${res.status})`)
|
|
320
|
-
}
|
|
181
|
+
if (!res.ok) throw new Error(`Service returned ${res.status}`)
|
|
321
182
|
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
popupRef.current?.close()
|
|
325
|
-
} catch {
|
|
326
|
-
/* ignore */
|
|
327
|
-
}
|
|
328
|
-
stopPopupPoll()
|
|
329
|
-
try {
|
|
330
|
-
const close1 = window.PAYPAL?.apps?.Signup?.MiniBrowser?.closeFlow
|
|
331
|
-
if (typeof close1 === "function") close1()
|
|
332
|
-
} catch {
|
|
333
|
-
/* ignore */
|
|
334
|
-
}
|
|
335
|
-
try {
|
|
336
|
-
const close2 =
|
|
337
|
-
window.PAYPAL?.apps?.Signup?.miniBrowser &&
|
|
338
|
-
(window.PAYPAL.apps.Signup.miniBrowser as any).closeFlow
|
|
339
|
-
if (typeof close2 === "function") close2()
|
|
340
|
-
} catch {
|
|
341
|
-
/* ignore */
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
clearCachedUrl(activeEnv)
|
|
183
|
+
const data = await res.json()
|
|
184
|
+
if (runId !== currentRunId.current) return
|
|
345
185
|
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
)
|
|
351
|
-
const refreshedStatus = await statusRes.json().catch(() => ({}))
|
|
352
|
-
setStatusInfo(refreshedStatus || null)
|
|
353
|
-
} catch {
|
|
354
|
-
/* ignore — still consider it connected below */
|
|
186
|
+
const href = data?.onboarding_url
|
|
187
|
+
if (!href) {
|
|
188
|
+
showError("Onboarding URL not returned.")
|
|
189
|
+
return
|
|
355
190
|
}
|
|
356
191
|
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
} catch (e: any) {
|
|
360
|
-
// Allow a retry after a failed exchange.
|
|
361
|
-
completedRef.current = false
|
|
362
|
-
console.error(e)
|
|
363
|
-
setConnState("error")
|
|
364
|
-
setError(e?.message || "Exchange failed while saving credentials.")
|
|
365
|
-
} finally {
|
|
366
|
-
setOnboardingInProgress(false)
|
|
367
|
-
}
|
|
368
|
-
},
|
|
369
|
-
[stopPopupPoll]
|
|
370
|
-
)
|
|
371
|
-
|
|
372
|
-
const fetchFreshLink = useCallback(
|
|
373
|
-
(runId: number, targetEnv: Env) => {
|
|
374
|
-
if (initLoaderRef.current) {
|
|
375
|
-
const loaderText = initLoaderRef.current.querySelector("#loader-text")
|
|
376
|
-
if (loaderText)
|
|
377
|
-
loaderText.textContent = "Generating onboarding session..."
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
fetch(SERVICE_URL, {
|
|
381
|
-
method: "POST",
|
|
382
|
-
headers: { "content-type": "application/json" },
|
|
383
|
-
credentials: "include",
|
|
384
|
-
body: JSON.stringify({
|
|
385
|
-
products: ["PPCP"],
|
|
386
|
-
// Generate the link for the explicitly selected environment so it can
|
|
387
|
-
// never depend on a racing POST /environment having landed yet.
|
|
388
|
-
environment: targetEnv,
|
|
389
|
-
}),
|
|
390
|
-
})
|
|
391
|
-
.then((r) => {
|
|
392
|
-
if (!r.ok) throw new Error(`Service returned ${r.status}`)
|
|
393
|
-
return r.json()
|
|
394
|
-
})
|
|
395
|
-
.then((data) => {
|
|
396
|
-
if (runId !== currentRunId.current) return
|
|
397
|
-
|
|
398
|
-
const href = data?.onboarding_url
|
|
399
|
-
if (!href) {
|
|
400
|
-
showError("Onboarding URL not returned.")
|
|
401
|
-
return
|
|
402
|
-
}
|
|
192
|
+
const url =
|
|
193
|
+
href + (href.includes("?") ? "&" : "?") + "displayMode=minibrowser"
|
|
403
194
|
|
|
404
|
-
|
|
405
|
-
href + (href.includes("?") ? "&" : "?") + "displayMode=minibrowser"
|
|
195
|
+
const persisted = writeCachedUrl(targetEnv, url)
|
|
406
196
|
|
|
407
|
-
|
|
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
|
+
}
|
|
408
204
|
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
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
|
+
}
|
|
416
212
|
},
|
|
417
213
|
[showError]
|
|
418
214
|
)
|
|
419
215
|
|
|
420
|
-
// Resolve the server
|
|
216
|
+
// Resolve the server environment first.
|
|
421
217
|
useEffect(() => {
|
|
422
218
|
let alive = true
|
|
423
219
|
fetch(ENVIRONMENT_ENDPOINT, { method: "GET", credentials: "include" })
|
|
@@ -435,14 +231,73 @@ export default function PayPalConnectionPage() {
|
|
|
435
231
|
}
|
|
436
232
|
}, [])
|
|
437
233
|
|
|
438
|
-
//
|
|
439
|
-
//
|
|
440
|
-
//
|
|
441
|
-
//
|
|
442
|
-
//
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
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.
|
|
446
301
|
useEffect(() => {
|
|
447
302
|
if (connState !== "ready" || !finalUrl) return
|
|
448
303
|
|
|
@@ -450,6 +305,19 @@ export default function PayPalConnectionPage() {
|
|
|
450
305
|
let cancelled = false
|
|
451
306
|
let pollTimer: ReturnType<typeof setTimeout> | undefined
|
|
452
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
|
+
|
|
453
321
|
const initPartner = (attempt = 0) => {
|
|
454
322
|
if (cancelled) return
|
|
455
323
|
|
|
@@ -464,26 +332,25 @@ export default function PayPalConnectionPage() {
|
|
|
464
332
|
if (target?.init) {
|
|
465
333
|
try {
|
|
466
334
|
target.init()
|
|
467
|
-
// partner.js scans the DOM and binds the button synchronously inside
|
|
468
|
-
// init(), so from here a click is intercepted by partner.js (popup +
|
|
469
|
-
// callback bridge) and we should not also open our own window.
|
|
470
|
-
partnerBoundRef.current = true
|
|
471
335
|
} catch (e) {
|
|
472
336
|
console.error("[paypal] partner.js init failed:", e)
|
|
473
337
|
}
|
|
338
|
+
markReady()
|
|
474
339
|
return
|
|
475
340
|
}
|
|
476
341
|
|
|
477
342
|
if (typeof signup?.render === "function") {
|
|
478
343
|
try {
|
|
479
344
|
signup.render()
|
|
480
|
-
partnerBoundRef.current = true
|
|
481
345
|
} catch (e) {
|
|
482
346
|
console.error("[paypal] partner.js render failed:", e)
|
|
483
347
|
}
|
|
348
|
+
markReady()
|
|
484
349
|
return
|
|
485
350
|
}
|
|
486
351
|
|
|
352
|
+
// The namespace may not be populated the instant onload fires; retry
|
|
353
|
+
// briefly before giving up.
|
|
487
354
|
if (attempt < 40) {
|
|
488
355
|
pollTimer = setTimeout(() => initPartner(attempt + 1), 50)
|
|
489
356
|
return
|
|
@@ -492,12 +359,9 @@ export default function PayPalConnectionPage() {
|
|
|
492
359
|
try {
|
|
493
360
|
document.dispatchEvent(new Event("DOMContentLoaded"))
|
|
494
361
|
} catch {}
|
|
362
|
+
markReady()
|
|
495
363
|
}
|
|
496
364
|
|
|
497
|
-
// This load hasn't bound the button yet; until init() runs, a click should
|
|
498
|
-
// be handled by us (cold path) rather than assumed to reach partner.js.
|
|
499
|
-
partnerBoundRef.current = false
|
|
500
|
-
|
|
501
365
|
// Remove any stale copy + namespace so the fresh load re-registers against
|
|
502
366
|
// the current environment (sandbox vs live partner.js differ).
|
|
503
367
|
const existingScript = document.getElementById("paypal-partner-js")
|
|
@@ -515,10 +379,12 @@ export default function PayPalConnectionPage() {
|
|
|
515
379
|
ppScript.src = scriptUrl
|
|
516
380
|
ppScript.async = true
|
|
517
381
|
ppScript.onload = () => initPartner()
|
|
382
|
+
ppScript.onerror = () => markReady()
|
|
518
383
|
document.body.appendChild(ppScript)
|
|
519
384
|
|
|
520
385
|
return () => {
|
|
521
386
|
cancelled = true
|
|
387
|
+
clearTimeout(readyFallback)
|
|
522
388
|
if (pollTimer) {
|
|
523
389
|
clearTimeout(pollTimer)
|
|
524
390
|
}
|
|
@@ -528,81 +394,73 @@ export default function PayPalConnectionPage() {
|
|
|
528
394
|
}
|
|
529
395
|
}, [connState, finalUrl, env])
|
|
530
396
|
|
|
531
|
-
//
|
|
532
|
-
//
|
|
533
|
-
//
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
if (authCode && sharedId) {
|
|
539
|
-
completeOnboarding(authCode, sharedId)
|
|
540
|
-
}
|
|
541
|
-
}
|
|
542
|
-
window.addEventListener("message", onMessage)
|
|
543
|
-
return () => window.removeEventListener("message", onMessage)
|
|
544
|
-
}, [completeOnboarding])
|
|
545
|
-
|
|
546
|
-
// Status check + link generation. Gated on envReady so we always use the
|
|
547
|
-
// correct environment. runId guards against a stale response from a previous
|
|
548
|
-
// environment overwriting the current one.
|
|
549
|
-
useEffect(() => {
|
|
550
|
-
if (!envReady) return
|
|
551
|
-
|
|
552
|
-
currentRunId.current = ++runIdRef.current
|
|
553
|
-
const runId = currentRunId.current
|
|
554
|
-
|
|
555
|
-
let cancelled = false
|
|
556
|
-
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
|
|
557
404
|
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
setPopupBlocked(false)
|
|
562
|
-
setFinalUrl("")
|
|
563
|
-
|
|
564
|
-
try {
|
|
565
|
-
const r = await fetch(`${STATUS_ENDPOINT}?environment=${env}`, {
|
|
566
|
-
method: "GET",
|
|
567
|
-
credentials: "include",
|
|
568
|
-
})
|
|
569
|
-
const st = await r.json().catch(() => ({}))
|
|
405
|
+
try {
|
|
406
|
+
window.onbeforeunload = null
|
|
407
|
+
} catch {}
|
|
570
408
|
|
|
571
|
-
|
|
409
|
+
const activeEnv: Env = envRef.current === "sandbox" ? "sandbox" : "live"
|
|
572
410
|
|
|
573
|
-
|
|
411
|
+
setOnboardingInProgress(true)
|
|
412
|
+
setConnState("loading")
|
|
413
|
+
setError(null)
|
|
574
414
|
|
|
575
|
-
|
|
576
|
-
|
|
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
|
+
})
|
|
577
422
|
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
return
|
|
582
|
-
}
|
|
583
|
-
} catch (e) {
|
|
584
|
-
console.error(e)
|
|
423
|
+
if (!res.ok) {
|
|
424
|
+
const txt = await res.text().catch(() => "")
|
|
425
|
+
throw new Error(txt || `Onboarding exchange failed (${res.status})`)
|
|
585
426
|
}
|
|
586
427
|
|
|
587
|
-
|
|
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 {}
|
|
588
439
|
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
setFinalUrl(cached)
|
|
592
|
-
setConnState("ready")
|
|
593
|
-
} else {
|
|
594
|
-
fetchFreshLink(runId, env)
|
|
595
|
-
}
|
|
596
|
-
}
|
|
440
|
+
// The link has been consumed; drop it so a future flow regenerates one.
|
|
441
|
+
clearCachedUrl(activeEnv)
|
|
597
442
|
|
|
598
|
-
|
|
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 {}
|
|
599
451
|
|
|
600
|
-
|
|
601
|
-
|
|
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)
|
|
602
461
|
}
|
|
603
|
-
}, [
|
|
462
|
+
}, [])
|
|
604
463
|
|
|
605
|
-
// Bridge partner.js's callback into our single idempotent completion handler.
|
|
606
464
|
useLayoutEffect(() => {
|
|
607
465
|
window.onboardingCallback = (authCode: string, sharedId: string) => {
|
|
608
466
|
completeOnboarding(authCode, sharedId)
|
|
@@ -612,13 +470,6 @@ export default function PayPalConnectionPage() {
|
|
|
612
470
|
}
|
|
613
471
|
}, [completeOnboarding])
|
|
614
472
|
|
|
615
|
-
// Clean up the popup poll on unmount.
|
|
616
|
-
useEffect(() => {
|
|
617
|
-
return () => {
|
|
618
|
-
stopPopupPoll()
|
|
619
|
-
}
|
|
620
|
-
}, [stopPopupPoll])
|
|
621
|
-
|
|
622
473
|
useLayoutEffect(() => {
|
|
623
474
|
const el = ppBtnMeasureRef.current
|
|
624
475
|
if (!el) return
|
|
@@ -644,40 +495,6 @@ export default function PayPalConnectionPage() {
|
|
|
644
495
|
}
|
|
645
496
|
}, [connState, env, finalUrl])
|
|
646
497
|
|
|
647
|
-
// Click handling for "Connect to PayPal".
|
|
648
|
-
//
|
|
649
|
-
// We always preventDefault so the native target can never open a new tab.
|
|
650
|
-
//
|
|
651
|
-
// - If partner.js has bound the button, its own click handler already ran (in
|
|
652
|
-
// the capture/target phase, before this React bubble handler) and opened the
|
|
653
|
-
// mini-browser AND wired its postMessage->onboardingCallback bridge — which
|
|
654
|
-
// auto-closes the popup and saves credentials. We must NOT open a second
|
|
655
|
-
// window, so we just return.
|
|
656
|
-
// - Otherwise (cold first click before partner.js finished initializing) we
|
|
657
|
-
// open the popup ourselves so the click still works. Completion is then
|
|
658
|
-
// handled by our own postMessage listener, and also by partner.js's bridge
|
|
659
|
-
// once it finishes initializing a moment later.
|
|
660
|
-
const handleConnectClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
|
|
661
|
-
e.preventDefault()
|
|
662
|
-
|
|
663
|
-
if (
|
|
664
|
-
connStateRef.current !== "ready" ||
|
|
665
|
-
!finalUrlRef.current ||
|
|
666
|
-
inProgressRef.current
|
|
667
|
-
) {
|
|
668
|
-
return
|
|
669
|
-
}
|
|
670
|
-
|
|
671
|
-
if (partnerBoundRef.current) {
|
|
672
|
-
// partner.js owns this click (popup + auto-close + callback bridge).
|
|
673
|
-
return
|
|
674
|
-
}
|
|
675
|
-
|
|
676
|
-
completedRef.current = false
|
|
677
|
-
const popup = openOnboardingPopup(finalUrlRef.current)
|
|
678
|
-
setPopupBlocked(!popup)
|
|
679
|
-
}
|
|
680
|
-
|
|
681
498
|
const handleSaveManual = async () => {
|
|
682
499
|
if (!canSaveManual || onboardingInProgress) return
|
|
683
500
|
setOnboardingInProgress(true)
|
|
@@ -746,11 +563,12 @@ export default function PayPalConnectionPage() {
|
|
|
746
563
|
}
|
|
747
564
|
|
|
748
565
|
clearCachedUrl(env)
|
|
749
|
-
|
|
750
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.
|
|
751
570
|
currentRunId.current = ++runIdRef.current
|
|
752
|
-
|
|
753
|
-
fetchFreshLink(runId, env)
|
|
571
|
+
await generateLinkAndReload(env, currentRunId.current)
|
|
754
572
|
} catch (e: any) {
|
|
755
573
|
console.error(e)
|
|
756
574
|
setConnState("error")
|
|
@@ -762,18 +580,14 @@ export default function PayPalConnectionPage() {
|
|
|
762
580
|
|
|
763
581
|
const handleEnvChange = async (e: React.ChangeEvent<HTMLSelectElement>) => {
|
|
764
582
|
const next = e.target.value as Env
|
|
583
|
+
if (next === env || onboardingInProgress) return
|
|
765
584
|
|
|
766
585
|
completedRef.current = false
|
|
767
|
-
setPopupBlocked(false)
|
|
768
|
-
// Drop both cached URLs so neither environment can serve a stale link.
|
|
769
586
|
clearCachedUrl()
|
|
587
|
+
setConnState("loading")
|
|
770
588
|
|
|
771
|
-
//
|
|
772
|
-
//
|
|
773
|
-
// fetches are environment-explicit, correctness no longer depends on this
|
|
774
|
-
// POST winning a race.
|
|
775
|
-
setEnv(next)
|
|
776
|
-
|
|
589
|
+
// Persist the environment BEFORE switching local state so the post-reload
|
|
590
|
+
// GET /environment returns the same value the cache was keyed under.
|
|
777
591
|
try {
|
|
778
592
|
await fetch(ENVIRONMENT_ENDPOINT, {
|
|
779
593
|
method: "POST",
|
|
@@ -782,6 +596,8 @@ export default function PayPalConnectionPage() {
|
|
|
782
596
|
body: JSON.stringify({ environment: next }),
|
|
783
597
|
})
|
|
784
598
|
} catch {}
|
|
599
|
+
|
|
600
|
+
setEnv(next)
|
|
785
601
|
}
|
|
786
602
|
|
|
787
603
|
return (
|
|
@@ -860,9 +676,12 @@ export default function PayPalConnectionPage() {
|
|
|
860
676
|
</div>
|
|
861
677
|
|
|
862
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. */}
|
|
863
683
|
<a
|
|
864
684
|
ref={(node) => {
|
|
865
|
-
paypalButtonRef.current = node
|
|
866
685
|
ppBtnMeasureRef.current = node
|
|
867
686
|
}}
|
|
868
687
|
id="paypal-button"
|
|
@@ -870,24 +689,26 @@ export default function PayPalConnectionPage() {
|
|
|
870
689
|
data-paypal-button="true"
|
|
871
690
|
href={finalUrl || "#"}
|
|
872
691
|
data-paypal-onboard-complete="onboardingCallback"
|
|
873
|
-
|
|
692
|
+
aria-disabled={onboardingInProgress || !partnerReady}
|
|
874
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"
|
|
875
694
|
style={{
|
|
876
|
-
cursor:
|
|
877
|
-
|
|
878
|
-
|
|
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",
|
|
879
705
|
}}
|
|
880
706
|
>
|
|
881
|
-
|
|
707
|
+
{partnerReady || onboardingInProgress
|
|
708
|
+
? "Connect to PayPal"
|
|
709
|
+
: "Preparing PayPal…"}
|
|
882
710
|
</a>
|
|
883
711
|
|
|
884
|
-
{popupBlocked && (
|
|
885
|
-
<div className="mt-3 text-left text-xs bg-amber-50 text-amber-700 p-3 border border-amber-200 rounded">
|
|
886
|
-
Your browser blocked the PayPal popup. Please allow popups
|
|
887
|
-
for this site, then click “Connect to PayPal” again.
|
|
888
|
-
</div>
|
|
889
|
-
)}
|
|
890
|
-
|
|
891
712
|
<div
|
|
892
713
|
className="mt-2"
|
|
893
714
|
style={{
|