@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
|
@@ -17,21 +17,25 @@ const PARTNER_JS_URLS = {
|
|
|
17
17
|
live: "https://www.paypal.com/webapps/merchantboarding/js/lib/lightbox/partner.js",
|
|
18
18
|
};
|
|
19
19
|
const SERVICE_URL = "/admin/paypal/onboarding-link";
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
|
|
23
|
-
//
|
|
24
|
-
|
|
20
|
+
// PayPal's conventional mini-browser window name; also used as the anchor's
|
|
21
|
+
// `target` so partner.js opens the flow in a popup.
|
|
22
|
+
const POPUP_NAME = "PPFrame";
|
|
23
|
+
// The onboarding link is cached in the browser for 6 hours. This is the heart of
|
|
24
|
+
// the integration: partner.js can only wire the mini-browser + completion
|
|
25
|
+
// callback if the anchor already carries a valid onboarding href when partner.js
|
|
26
|
+
// initializes. By caching the link (and reloading once to populate the cache on
|
|
27
|
+
// a cold start) the link is always present on page load, so partner.js can take
|
|
28
|
+
// over the click and run its flow exactly as documented.
|
|
25
29
|
const CACHE_PREFIX = "pp_onboard_cache";
|
|
26
|
-
const CACHE_EXPIRY =
|
|
30
|
+
const CACHE_EXPIRY = 6 * 60 * 60 * 1000; // 6 hours
|
|
27
31
|
const ONBOARDING_COMPLETE_ENDPOINT = "/admin/paypal/onboard-complete";
|
|
28
32
|
const STATUS_ENDPOINT = "/admin/paypal/status";
|
|
29
33
|
const SAVE_CREDENTIALS_ENDPOINT = "/admin/paypal/save-credentials";
|
|
30
34
|
const DISCONNECT_ENDPOINT = "/admin/paypal/disconnect";
|
|
31
35
|
const ENVIRONMENT_ENDPOINT = "/admin/paypal/environment";
|
|
32
|
-
// Onboarding URLs are environment-specific
|
|
33
|
-
//
|
|
34
|
-
//
|
|
36
|
+
// Onboarding URLs are environment-specific, so the cache is keyed per
|
|
37
|
+
// environment — a single shared key let a stale sandbox URL leak into the live
|
|
38
|
+
// flow (and vice-versa).
|
|
35
39
|
const cacheKeyFor = (env) => `${CACHE_PREFIX}_${env}`;
|
|
36
40
|
function readCachedUrl(env) {
|
|
37
41
|
if (typeof window === "undefined")
|
|
@@ -52,12 +56,15 @@ function readCachedUrl(env) {
|
|
|
52
56
|
}
|
|
53
57
|
return null;
|
|
54
58
|
}
|
|
59
|
+
// Returns true only if the value was actually persisted (storage enabled), which
|
|
60
|
+
// lets the caller decide whether reloading to pick up the cache is safe.
|
|
55
61
|
function writeCachedUrl(env, url) {
|
|
56
62
|
try {
|
|
57
63
|
localStorage.setItem(cacheKeyFor(env), JSON.stringify({ url, ts: Date.now() }));
|
|
64
|
+
return readCachedUrl(env) === url;
|
|
58
65
|
}
|
|
59
66
|
catch {
|
|
60
|
-
|
|
67
|
+
return false;
|
|
61
68
|
}
|
|
62
69
|
}
|
|
63
70
|
function clearCachedUrl(env) {
|
|
@@ -74,80 +81,18 @@ function clearCachedUrl(env) {
|
|
|
74
81
|
/* ignore */
|
|
75
82
|
}
|
|
76
83
|
}
|
|
77
|
-
// PayPal delivers the seamless-onboarding result by posting a message to
|
|
78
|
-
// window.opener. We validate the sender is actually a PayPal origin before
|
|
79
|
-
// trusting authCode/sharedId.
|
|
80
|
-
function isPayPalOrigin(origin) {
|
|
81
|
-
try {
|
|
82
|
-
const host = new URL(origin).hostname.toLowerCase();
|
|
83
|
-
return (host === "www.paypal.com" ||
|
|
84
|
-
host === "www.sandbox.paypal.com" ||
|
|
85
|
-
host.endsWith(".paypal.com") ||
|
|
86
|
-
host.endsWith(".paypalobjects.com"));
|
|
87
|
-
}
|
|
88
|
-
catch {
|
|
89
|
-
return false;
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
// The completion message shape is not strongly documented, so extract
|
|
93
|
-
// authCode/sharedId defensively from objects, JSON strings, or query strings.
|
|
94
|
-
// We only ever act when BOTH values are present, which keeps the many unrelated
|
|
95
|
-
// intermediate postMessages PayPal emits from triggering a false completion.
|
|
96
|
-
function extractAuth(data) {
|
|
97
|
-
if (!data)
|
|
98
|
-
return {};
|
|
99
|
-
if (typeof data === "object") {
|
|
100
|
-
const obj = data;
|
|
101
|
-
const authCode = obj.authCode ?? obj.auth_code ?? obj.authcode;
|
|
102
|
-
const sharedId = obj.sharedId ?? obj.shared_id ?? obj.sharedid;
|
|
103
|
-
if (authCode && sharedId) {
|
|
104
|
-
return { authCode: String(authCode), sharedId: String(sharedId) };
|
|
105
|
-
}
|
|
106
|
-
for (const key of ["data", "payload", "message", "detail", "body"]) {
|
|
107
|
-
if (obj[key] && typeof obj[key] === "object") {
|
|
108
|
-
const inner = extractAuth(obj[key]);
|
|
109
|
-
if (inner.authCode && inner.sharedId)
|
|
110
|
-
return inner;
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
return {};
|
|
114
|
-
}
|
|
115
|
-
if (typeof data === "string") {
|
|
116
|
-
const s = data.trim();
|
|
117
|
-
if (!s)
|
|
118
|
-
return {};
|
|
119
|
-
if (s.startsWith("{")) {
|
|
120
|
-
try {
|
|
121
|
-
return extractAuth(JSON.parse(s));
|
|
122
|
-
}
|
|
123
|
-
catch {
|
|
124
|
-
/* not JSON */
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
if (/auth_?code/i.test(s) && /shared_?id/i.test(s)) {
|
|
128
|
-
try {
|
|
129
|
-
const sp = new URLSearchParams(s.replace(/^[?#]/, ""));
|
|
130
|
-
const authCode = sp.get("authCode") || sp.get("auth_code") || undefined;
|
|
131
|
-
const sharedId = sp.get("sharedId") || sp.get("shared_id") || undefined;
|
|
132
|
-
if (authCode && sharedId)
|
|
133
|
-
return { authCode, sharedId };
|
|
134
|
-
}
|
|
135
|
-
catch {
|
|
136
|
-
/* not a query string */
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
return {};
|
|
141
|
-
}
|
|
142
84
|
function PayPalConnectionPage() {
|
|
143
85
|
const [env, setEnv] = (0, react_1.useState)("live");
|
|
144
|
-
// We must
|
|
145
|
-
//
|
|
146
|
-
// GET /environment response and can target the wrong environment.
|
|
86
|
+
// We must know the server's environment before generating/looking up a link so
|
|
87
|
+
// the per-environment cache and the generated link always match.
|
|
147
88
|
const [envReady, setEnvReady] = (0, react_1.useState)(false);
|
|
148
89
|
const [connState, setConnState] = (0, react_1.useState)("loading");
|
|
90
|
+
// True once partner.js has loaded and bound its click interceptor to the
|
|
91
|
+
// button. The button stays in the DOM but is not clickable until then, so a
|
|
92
|
+
// click can never fall through to the native anchor (which would open a tab
|
|
93
|
+
// without partner.js's onboarding flow / completion callback).
|
|
94
|
+
const [partnerReady, setPartnerReady] = (0, react_1.useState)(false);
|
|
149
95
|
const [error, setError] = (0, react_1.useState)(null);
|
|
150
|
-
const [popupBlocked, setPopupBlocked] = (0, react_1.useState)(false);
|
|
151
96
|
const [finalUrl, setFinalUrl] = (0, react_1.useState)("");
|
|
152
97
|
const [showManual, setShowManual] = (0, react_1.useState)(false);
|
|
153
98
|
const [clientId, setClientId] = (0, react_1.useState)("");
|
|
@@ -155,31 +100,14 @@ function PayPalConnectionPage() {
|
|
|
155
100
|
const [statusInfo, setStatusInfo] = (0, react_1.useState)(null);
|
|
156
101
|
const [onboardingInProgress, setOnboardingInProgress] = (0, react_1.useState)(false);
|
|
157
102
|
const initLoaderRef = (0, react_1.useRef)(null);
|
|
158
|
-
const paypalButtonRef = (0, react_1.useRef)(null);
|
|
159
103
|
const errorLogRef = (0, react_1.useRef)(null);
|
|
160
104
|
const runIdRef = (0, react_1.useRef)(0);
|
|
161
105
|
const currentRunId = (0, react_1.useRef)(0);
|
|
162
|
-
// Live mirrors of state so the document-level capture click listener (set up
|
|
163
|
-
// once) and the stable completion callback always see current values.
|
|
164
|
-
const finalUrlRef = (0, react_1.useRef)(finalUrl);
|
|
165
|
-
finalUrlRef.current = finalUrl;
|
|
166
|
-
const connStateRef = (0, react_1.useRef)(connState);
|
|
167
|
-
connStateRef.current = connState;
|
|
168
|
-
const inProgressRef = (0, react_1.useRef)(onboardingInProgress);
|
|
169
|
-
inProgressRef.current = onboardingInProgress;
|
|
170
106
|
const envRef = (0, react_1.useRef)(env);
|
|
171
107
|
envRef.current = env;
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
// Guards against the two redundant completion paths (our own message listener
|
|
175
|
-
// and partner.js's onboardingCallback bridge) both POSTing the same result.
|
|
108
|
+
// Guards against partner.js's bridge invoking the completion callback more
|
|
109
|
+
// than once.
|
|
176
110
|
const completedRef = (0, react_1.useRef)(false);
|
|
177
|
-
// True once partner.js has initialized and bound its click interceptor to the
|
|
178
|
-
// button. When bound, partner.js opens the mini-browser AND wires its
|
|
179
|
-
// postMessage->onboardingCallback bridge (which auto-closes the popup and
|
|
180
|
-
// saves credentials), so we must NOT open a second window. Until it is bound
|
|
181
|
-
// (cold first click), we open the popup ourselves so the click still works.
|
|
182
|
-
const partnerBoundRef = (0, react_1.useRef)(false);
|
|
183
111
|
const ppBtnMeasureRef = (0, react_1.useRef)(null);
|
|
184
112
|
const [ppBtnWidth, setPpBtnWidth] = (0, react_1.useState)(null);
|
|
185
113
|
const canSaveManual = (0, react_1.useMemo)(() => {
|
|
@@ -189,154 +117,27 @@ function PayPalConnectionPage() {
|
|
|
189
117
|
setConnState("error");
|
|
190
118
|
setError(msg);
|
|
191
119
|
}, []);
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
// in a user gesture. Because finalUrl is already in state at click time, we
|
|
202
|
-
// never need an await before opening, and we never depend on winning a race
|
|
203
|
-
// with partner.js's asynchronous click binding.
|
|
204
|
-
const openOnboardingPopup = (0, react_1.useCallback)((url) => {
|
|
205
|
-
const w = 450;
|
|
206
|
-
const h = 600;
|
|
207
|
-
const baseLeft = typeof window.screenX === "number" ? window.screenX : window.screenLeft || 0;
|
|
208
|
-
const baseTop = typeof window.screenY === "number" ? window.screenY : window.screenTop || 0;
|
|
209
|
-
const outerW = window.outerWidth || document.documentElement.clientWidth || 1024;
|
|
210
|
-
const outerH = window.outerHeight || document.documentElement.clientHeight || 768;
|
|
211
|
-
const left = Math.round(baseLeft + Math.max(0, (outerW - w) / 2));
|
|
212
|
-
const top = Math.round(baseTop + Math.max(0, (outerH - h) / 2));
|
|
213
|
-
const features = `popup=yes,width=${w},height=${h},left=${left},top=${top},scrollbars=yes,resizable=yes`;
|
|
214
|
-
let popup = null;
|
|
215
|
-
try {
|
|
216
|
-
popup = window.open(url, POPUP_NAME, features);
|
|
217
|
-
}
|
|
218
|
-
catch {
|
|
219
|
-
popup = null;
|
|
220
|
-
}
|
|
221
|
-
if (popup) {
|
|
222
|
-
popupRef.current = popup;
|
|
223
|
-
try {
|
|
224
|
-
popup.focus();
|
|
225
|
-
}
|
|
226
|
-
catch {
|
|
227
|
-
/* ignore */
|
|
120
|
+
// Generate a fresh onboarding link, cache it for 6 hours, then reload the page
|
|
121
|
+
// so that on the next load the cached link is already present for partner.js.
|
|
122
|
+
// If browser storage is unavailable we fall back to using the link in-place
|
|
123
|
+
// (no reload) so we can never get stuck in a reload loop.
|
|
124
|
+
const generateLinkAndReload = (0, react_1.useCallback)(async (targetEnv, runId) => {
|
|
125
|
+
if (initLoaderRef.current) {
|
|
126
|
+
const loaderText = initLoaderRef.current.querySelector("#loader-text");
|
|
127
|
+
if (loaderText) {
|
|
128
|
+
loaderText.textContent = "Generating onboarding session...";
|
|
228
129
|
}
|
|
229
|
-
stopPopupPoll();
|
|
230
|
-
popupPollRef.current = setInterval(() => {
|
|
231
|
-
if (!popupRef.current || popupRef.current.closed) {
|
|
232
|
-
stopPopupPoll();
|
|
233
|
-
}
|
|
234
|
-
}, 1000);
|
|
235
|
-
}
|
|
236
|
-
return popup;
|
|
237
|
-
}, [stopPopupPoll]);
|
|
238
|
-
// Exchange the seller authCode/sharedId for credentials. Reached from BOTH
|
|
239
|
-
// PayPal's partner.js bridge (window.onboardingCallback) and our own postMessage
|
|
240
|
-
// listener; completedRef makes it idempotent so we only POST once.
|
|
241
|
-
const completeOnboarding = (0, react_1.useCallback)(async (authCode, sharedId) => {
|
|
242
|
-
if (!authCode || !sharedId)
|
|
243
|
-
return;
|
|
244
|
-
if (completedRef.current)
|
|
245
|
-
return;
|
|
246
|
-
completedRef.current = true;
|
|
247
|
-
try {
|
|
248
|
-
window.onbeforeunload = null;
|
|
249
|
-
}
|
|
250
|
-
catch {
|
|
251
|
-
/* ignore */
|
|
252
130
|
}
|
|
253
|
-
const activeEnv = envRef.current === "sandbox" ? "sandbox" : "live";
|
|
254
|
-
setOnboardingInProgress(true);
|
|
255
|
-
setConnState("loading");
|
|
256
|
-
setError(null);
|
|
257
|
-
setPopupBlocked(false);
|
|
258
131
|
try {
|
|
259
|
-
const res = await fetch(
|
|
132
|
+
const res = await fetch(SERVICE_URL, {
|
|
260
133
|
method: "POST",
|
|
261
134
|
headers: { "content-type": "application/json" },
|
|
262
135
|
credentials: "include",
|
|
263
|
-
body: JSON.stringify({
|
|
136
|
+
body: JSON.stringify({ products: ["PPCP"], environment: targetEnv }),
|
|
264
137
|
});
|
|
265
|
-
if (!res.ok)
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
}
|
|
269
|
-
// Close the popup we opened, plus any partner.js-managed flow.
|
|
270
|
-
try {
|
|
271
|
-
popupRef.current?.close();
|
|
272
|
-
}
|
|
273
|
-
catch {
|
|
274
|
-
/* ignore */
|
|
275
|
-
}
|
|
276
|
-
stopPopupPoll();
|
|
277
|
-
try {
|
|
278
|
-
const close1 = window.PAYPAL?.apps?.Signup?.MiniBrowser?.closeFlow;
|
|
279
|
-
if (typeof close1 === "function")
|
|
280
|
-
close1();
|
|
281
|
-
}
|
|
282
|
-
catch {
|
|
283
|
-
/* ignore */
|
|
284
|
-
}
|
|
285
|
-
try {
|
|
286
|
-
const close2 = window.PAYPAL?.apps?.Signup?.miniBrowser &&
|
|
287
|
-
window.PAYPAL.apps.Signup.miniBrowser.closeFlow;
|
|
288
|
-
if (typeof close2 === "function")
|
|
289
|
-
close2();
|
|
290
|
-
}
|
|
291
|
-
catch {
|
|
292
|
-
/* ignore */
|
|
293
|
-
}
|
|
294
|
-
clearCachedUrl(activeEnv);
|
|
295
|
-
try {
|
|
296
|
-
const statusRes = await fetch(`${STATUS_ENDPOINT}?environment=${activeEnv}`, { method: "GET", credentials: "include" });
|
|
297
|
-
const refreshedStatus = await statusRes.json().catch(() => ({}));
|
|
298
|
-
setStatusInfo(refreshedStatus || null);
|
|
299
|
-
}
|
|
300
|
-
catch {
|
|
301
|
-
/* ignore — still consider it connected below */
|
|
302
|
-
}
|
|
303
|
-
setConnState("connected");
|
|
304
|
-
setShowManual(false);
|
|
305
|
-
}
|
|
306
|
-
catch (e) {
|
|
307
|
-
// Allow a retry after a failed exchange.
|
|
308
|
-
completedRef.current = false;
|
|
309
|
-
console.error(e);
|
|
310
|
-
setConnState("error");
|
|
311
|
-
setError(e?.message || "Exchange failed while saving credentials.");
|
|
312
|
-
}
|
|
313
|
-
finally {
|
|
314
|
-
setOnboardingInProgress(false);
|
|
315
|
-
}
|
|
316
|
-
}, [stopPopupPoll]);
|
|
317
|
-
const fetchFreshLink = (0, react_1.useCallback)((runId, targetEnv) => {
|
|
318
|
-
if (initLoaderRef.current) {
|
|
319
|
-
const loaderText = initLoaderRef.current.querySelector("#loader-text");
|
|
320
|
-
if (loaderText)
|
|
321
|
-
loaderText.textContent = "Generating onboarding session...";
|
|
322
|
-
}
|
|
323
|
-
fetch(SERVICE_URL, {
|
|
324
|
-
method: "POST",
|
|
325
|
-
headers: { "content-type": "application/json" },
|
|
326
|
-
credentials: "include",
|
|
327
|
-
body: JSON.stringify({
|
|
328
|
-
products: ["PPCP"],
|
|
329
|
-
// Generate the link for the explicitly selected environment so it can
|
|
330
|
-
// never depend on a racing POST /environment having landed yet.
|
|
331
|
-
environment: targetEnv,
|
|
332
|
-
}),
|
|
333
|
-
})
|
|
334
|
-
.then((r) => {
|
|
335
|
-
if (!r.ok)
|
|
336
|
-
throw new Error(`Service returned ${r.status}`);
|
|
337
|
-
return r.json();
|
|
338
|
-
})
|
|
339
|
-
.then((data) => {
|
|
138
|
+
if (!res.ok)
|
|
139
|
+
throw new Error(`Service returned ${res.status}`);
|
|
140
|
+
const data = await res.json();
|
|
340
141
|
if (runId !== currentRunId.current)
|
|
341
142
|
return;
|
|
342
143
|
const href = data?.onboarding_url;
|
|
@@ -345,17 +146,25 @@ function PayPalConnectionPage() {
|
|
|
345
146
|
return;
|
|
346
147
|
}
|
|
347
148
|
const url = href + (href.includes("?") ? "&" : "?") + "displayMode=minibrowser";
|
|
348
|
-
writeCachedUrl(targetEnv, url);
|
|
149
|
+
const persisted = writeCachedUrl(targetEnv, url);
|
|
150
|
+
if (persisted) {
|
|
151
|
+
// The cache survived; reload so the link is present at first render and
|
|
152
|
+
// partner.js initializes against it. The post-reload load finds the
|
|
153
|
+
// cache (no regeneration), so this can't loop.
|
|
154
|
+
window.location.reload();
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
// Storage disabled — use the link directly without reloading.
|
|
349
158
|
setFinalUrl(url);
|
|
350
159
|
setConnState("ready");
|
|
351
|
-
}
|
|
352
|
-
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
353
162
|
if (runId !== currentRunId.current)
|
|
354
163
|
return;
|
|
355
164
|
showError("Unable to connect to service.");
|
|
356
|
-
}
|
|
165
|
+
}
|
|
357
166
|
}, [showError]);
|
|
358
|
-
// Resolve the server
|
|
167
|
+
// Resolve the server environment first.
|
|
359
168
|
(0, react_1.useEffect)(() => {
|
|
360
169
|
let alive = true;
|
|
361
170
|
fetch(ENVIRONMENT_ENDPOINT, { method: "GET", credentials: "include" })
|
|
@@ -374,20 +183,80 @@ function PayPalConnectionPage() {
|
|
|
374
183
|
alive = false;
|
|
375
184
|
};
|
|
376
185
|
}, []);
|
|
377
|
-
//
|
|
378
|
-
//
|
|
379
|
-
//
|
|
380
|
-
//
|
|
381
|
-
//
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
186
|
+
// Page-load flow:
|
|
187
|
+
// 1. If already connected, show the connected state.
|
|
188
|
+
// 2. Else if a valid cached link exists, use it (partner.js will bind it).
|
|
189
|
+
// 3. Else generate a link, cache it for 6h, and reload so the cached link is
|
|
190
|
+
// present for partner.js on the next load.
|
|
191
|
+
(0, react_1.useEffect)(() => {
|
|
192
|
+
if (!envReady)
|
|
193
|
+
return;
|
|
194
|
+
currentRunId.current = ++runIdRef.current;
|
|
195
|
+
const runId = currentRunId.current;
|
|
196
|
+
let cancelled = false;
|
|
197
|
+
completedRef.current = false;
|
|
198
|
+
const run = async () => {
|
|
199
|
+
setConnState("loading");
|
|
200
|
+
setError(null);
|
|
201
|
+
setFinalUrl("");
|
|
202
|
+
try {
|
|
203
|
+
const r = await fetch(`${STATUS_ENDPOINT}?environment=${env}`, {
|
|
204
|
+
method: "GET",
|
|
205
|
+
credentials: "include",
|
|
206
|
+
});
|
|
207
|
+
const st = await r.json().catch(() => ({}));
|
|
208
|
+
if (cancelled || runId !== currentRunId.current)
|
|
209
|
+
return;
|
|
210
|
+
setStatusInfo(st);
|
|
211
|
+
const isConnected = st?.status === "connected" && st?.seller_client_id_present === true;
|
|
212
|
+
if (isConnected) {
|
|
213
|
+
setConnState("connected");
|
|
214
|
+
setShowManual(false);
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
catch (e) {
|
|
219
|
+
console.error(e);
|
|
220
|
+
}
|
|
221
|
+
if (cancelled || runId !== currentRunId.current)
|
|
222
|
+
return;
|
|
223
|
+
const cached = readCachedUrl(env);
|
|
224
|
+
if (cached) {
|
|
225
|
+
setFinalUrl(cached);
|
|
226
|
+
setConnState("ready");
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
await generateLinkAndReload(env, runId);
|
|
230
|
+
};
|
|
231
|
+
run();
|
|
232
|
+
return () => {
|
|
233
|
+
cancelled = true;
|
|
234
|
+
};
|
|
235
|
+
}, [env, envReady, generateLinkAndReload]);
|
|
236
|
+
// Load partner.js once the anchor exists with its real (cached) href, then run
|
|
237
|
+
// PayPal's initializer. In a React SPA partner.js's own DOMContentLoaded hook
|
|
238
|
+
// already fired before this route mounted, so we must call init()/render()
|
|
239
|
+
// ourselves for it to discover the button and take over the click into the
|
|
240
|
+
// mini-browser — opening the popup, and wiring its postMessage bridge that
|
|
241
|
+
// invokes window.onboardingCallback and closes the popup on completion.
|
|
385
242
|
(0, react_1.useEffect)(() => {
|
|
386
243
|
if (connState !== "ready" || !finalUrl)
|
|
387
244
|
return;
|
|
388
245
|
const scriptUrl = PARTNER_JS_URLS[env];
|
|
389
246
|
let cancelled = false;
|
|
390
247
|
let pollTimer;
|
|
248
|
+
// The button is gated on partnerReady; reset it for this (re)load.
|
|
249
|
+
setPartnerReady(false);
|
|
250
|
+
// Safety net: if partner.js can't load (e.g. blocked), enable the button
|
|
251
|
+
// anyway after a few seconds rather than leaving it permanently disabled.
|
|
252
|
+
const readyFallback = setTimeout(() => {
|
|
253
|
+
if (!cancelled)
|
|
254
|
+
setPartnerReady(true);
|
|
255
|
+
}, 6000);
|
|
256
|
+
const markReady = () => {
|
|
257
|
+
if (!cancelled)
|
|
258
|
+
setPartnerReady(true);
|
|
259
|
+
};
|
|
391
260
|
const initPartner = (attempt = 0) => {
|
|
392
261
|
if (cancelled)
|
|
393
262
|
return;
|
|
@@ -400,26 +269,25 @@ function PayPalConnectionPage() {
|
|
|
400
269
|
if (target?.init) {
|
|
401
270
|
try {
|
|
402
271
|
target.init();
|
|
403
|
-
// partner.js scans the DOM and binds the button synchronously inside
|
|
404
|
-
// init(), so from here a click is intercepted by partner.js (popup +
|
|
405
|
-
// callback bridge) and we should not also open our own window.
|
|
406
|
-
partnerBoundRef.current = true;
|
|
407
272
|
}
|
|
408
273
|
catch (e) {
|
|
409
274
|
console.error("[paypal] partner.js init failed:", e);
|
|
410
275
|
}
|
|
276
|
+
markReady();
|
|
411
277
|
return;
|
|
412
278
|
}
|
|
413
279
|
if (typeof signup?.render === "function") {
|
|
414
280
|
try {
|
|
415
281
|
signup.render();
|
|
416
|
-
partnerBoundRef.current = true;
|
|
417
282
|
}
|
|
418
283
|
catch (e) {
|
|
419
284
|
console.error("[paypal] partner.js render failed:", e);
|
|
420
285
|
}
|
|
286
|
+
markReady();
|
|
421
287
|
return;
|
|
422
288
|
}
|
|
289
|
+
// The namespace may not be populated the instant onload fires; retry
|
|
290
|
+
// briefly before giving up.
|
|
423
291
|
if (attempt < 40) {
|
|
424
292
|
pollTimer = setTimeout(() => initPartner(attempt + 1), 50);
|
|
425
293
|
return;
|
|
@@ -428,10 +296,8 @@ function PayPalConnectionPage() {
|
|
|
428
296
|
document.dispatchEvent(new Event("DOMContentLoaded"));
|
|
429
297
|
}
|
|
430
298
|
catch { }
|
|
299
|
+
markReady();
|
|
431
300
|
};
|
|
432
|
-
// This load hasn't bound the button yet; until init() runs, a click should
|
|
433
|
-
// be handled by us (cold path) rather than assumed to reach partner.js.
|
|
434
|
-
partnerBoundRef.current = false;
|
|
435
301
|
// Remove any stale copy + namespace so the fresh load re-registers against
|
|
436
302
|
// the current environment (sandbox vs live partner.js differ).
|
|
437
303
|
const existingScript = document.getElementById("paypal-partner-js");
|
|
@@ -449,9 +315,11 @@ function PayPalConnectionPage() {
|
|
|
449
315
|
ppScript.src = scriptUrl;
|
|
450
316
|
ppScript.async = true;
|
|
451
317
|
ppScript.onload = () => initPartner();
|
|
318
|
+
ppScript.onerror = () => markReady();
|
|
452
319
|
document.body.appendChild(ppScript);
|
|
453
320
|
return () => {
|
|
454
321
|
cancelled = true;
|
|
322
|
+
clearTimeout(readyFallback);
|
|
455
323
|
if (pollTimer) {
|
|
456
324
|
clearTimeout(pollTimer);
|
|
457
325
|
}
|
|
@@ -460,72 +328,72 @@ function PayPalConnectionPage() {
|
|
|
460
328
|
}
|
|
461
329
|
};
|
|
462
330
|
}, [connState, finalUrl, env]);
|
|
463
|
-
//
|
|
464
|
-
//
|
|
465
|
-
//
|
|
466
|
-
(0, react_1.
|
|
467
|
-
|
|
468
|
-
if (!isPayPalOrigin(ev.origin))
|
|
469
|
-
return;
|
|
470
|
-
const { authCode, sharedId } = extractAuth(ev.data);
|
|
471
|
-
if (authCode && sharedId) {
|
|
472
|
-
completeOnboarding(authCode, sharedId);
|
|
473
|
-
}
|
|
474
|
-
};
|
|
475
|
-
window.addEventListener("message", onMessage);
|
|
476
|
-
return () => window.removeEventListener("message", onMessage);
|
|
477
|
-
}, [completeOnboarding]);
|
|
478
|
-
// Status check + link generation. Gated on envReady so we always use the
|
|
479
|
-
// correct environment. runId guards against a stale response from a previous
|
|
480
|
-
// environment overwriting the current one.
|
|
481
|
-
(0, react_1.useEffect)(() => {
|
|
482
|
-
if (!envReady)
|
|
331
|
+
// partner.js calls this (by the name in data-paypal-onboard-complete) when the
|
|
332
|
+
// seller finishes onboarding, passing the authCode + sharedId to exchange for
|
|
333
|
+
// seller credentials.
|
|
334
|
+
const completeOnboarding = (0, react_1.useCallback)(async (authCode, sharedId) => {
|
|
335
|
+
if (!authCode || !sharedId)
|
|
483
336
|
return;
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
337
|
+
if (completedRef.current)
|
|
338
|
+
return;
|
|
339
|
+
completedRef.current = true;
|
|
340
|
+
try {
|
|
341
|
+
window.onbeforeunload = null;
|
|
342
|
+
}
|
|
343
|
+
catch { }
|
|
344
|
+
const activeEnv = envRef.current === "sandbox" ? "sandbox" : "live";
|
|
345
|
+
setOnboardingInProgress(true);
|
|
346
|
+
setConnState("loading");
|
|
347
|
+
setError(null);
|
|
348
|
+
try {
|
|
349
|
+
const res = await fetch(ONBOARDING_COMPLETE_ENDPOINT, {
|
|
350
|
+
method: "POST",
|
|
351
|
+
headers: { "content-type": "application/json" },
|
|
352
|
+
credentials: "include",
|
|
353
|
+
body: JSON.stringify({ authCode, sharedId, env: activeEnv }),
|
|
354
|
+
});
|
|
355
|
+
if (!res.ok) {
|
|
356
|
+
const txt = await res.text().catch(() => "");
|
|
357
|
+
throw new Error(txt || `Onboarding exchange failed (${res.status})`);
|
|
358
|
+
}
|
|
359
|
+
// Close the partner.js mini-browser.
|
|
493
360
|
try {
|
|
494
|
-
const
|
|
361
|
+
const close1 = window.PAYPAL?.apps?.Signup?.MiniBrowser?.closeFlow;
|
|
362
|
+
if (typeof close1 === "function")
|
|
363
|
+
close1();
|
|
364
|
+
}
|
|
365
|
+
catch { }
|
|
366
|
+
try {
|
|
367
|
+
const close2 = window.PAYPAL?.apps?.Signup?.miniBrowser &&
|
|
368
|
+
window.PAYPAL.apps.Signup.miniBrowser.closeFlow;
|
|
369
|
+
if (typeof close2 === "function")
|
|
370
|
+
close2();
|
|
371
|
+
}
|
|
372
|
+
catch { }
|
|
373
|
+
// The link has been consumed; drop it so a future flow regenerates one.
|
|
374
|
+
clearCachedUrl(activeEnv);
|
|
375
|
+
try {
|
|
376
|
+
const statusRes = await fetch(`${STATUS_ENDPOINT}?environment=${activeEnv}`, {
|
|
495
377
|
method: "GET",
|
|
496
378
|
credentials: "include",
|
|
497
379
|
});
|
|
498
|
-
const
|
|
499
|
-
|
|
500
|
-
return;
|
|
501
|
-
setStatusInfo(st);
|
|
502
|
-
const isConnected = st?.status === "connected" && st?.seller_client_id_present === true;
|
|
503
|
-
if (isConnected) {
|
|
504
|
-
setConnState("connected");
|
|
505
|
-
setShowManual(false);
|
|
506
|
-
return;
|
|
507
|
-
}
|
|
508
|
-
}
|
|
509
|
-
catch (e) {
|
|
510
|
-
console.error(e);
|
|
511
|
-
}
|
|
512
|
-
if (cancelled || runId !== currentRunId.current)
|
|
513
|
-
return;
|
|
514
|
-
const cached = readCachedUrl(env);
|
|
515
|
-
if (cached) {
|
|
516
|
-
setFinalUrl(cached);
|
|
517
|
-
setConnState("ready");
|
|
518
|
-
}
|
|
519
|
-
else {
|
|
520
|
-
fetchFreshLink(runId, env);
|
|
380
|
+
const refreshedStatus = await statusRes.json().catch(() => ({}));
|
|
381
|
+
setStatusInfo(refreshedStatus || null);
|
|
521
382
|
}
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
383
|
+
catch { }
|
|
384
|
+
setConnState("connected");
|
|
385
|
+
setShowManual(false);
|
|
386
|
+
}
|
|
387
|
+
catch (e) {
|
|
388
|
+
completedRef.current = false;
|
|
389
|
+
console.error(e);
|
|
390
|
+
setConnState("error");
|
|
391
|
+
setError(e?.message || "Exchange failed while saving credentials.");
|
|
392
|
+
}
|
|
393
|
+
finally {
|
|
394
|
+
setOnboardingInProgress(false);
|
|
395
|
+
}
|
|
396
|
+
}, []);
|
|
529
397
|
(0, react_1.useLayoutEffect)(() => {
|
|
530
398
|
window.onboardingCallback = (authCode, sharedId) => {
|
|
531
399
|
completeOnboarding(authCode, sharedId);
|
|
@@ -534,12 +402,6 @@ function PayPalConnectionPage() {
|
|
|
534
402
|
window.onboardingCallback = undefined;
|
|
535
403
|
};
|
|
536
404
|
}, [completeOnboarding]);
|
|
537
|
-
// Clean up the popup poll on unmount.
|
|
538
|
-
(0, react_1.useEffect)(() => {
|
|
539
|
-
return () => {
|
|
540
|
-
stopPopupPoll();
|
|
541
|
-
};
|
|
542
|
-
}, [stopPopupPoll]);
|
|
543
405
|
(0, react_1.useLayoutEffect)(() => {
|
|
544
406
|
const el = ppBtnMeasureRef.current;
|
|
545
407
|
if (!el)
|
|
@@ -565,34 +427,6 @@ function PayPalConnectionPage() {
|
|
|
565
427
|
window.removeEventListener("resize", update);
|
|
566
428
|
};
|
|
567
429
|
}, [connState, env, finalUrl]);
|
|
568
|
-
// Click handling for "Connect to PayPal".
|
|
569
|
-
//
|
|
570
|
-
// We always preventDefault so the native target can never open a new tab.
|
|
571
|
-
//
|
|
572
|
-
// - If partner.js has bound the button, its own click handler already ran (in
|
|
573
|
-
// the capture/target phase, before this React bubble handler) and opened the
|
|
574
|
-
// mini-browser AND wired its postMessage->onboardingCallback bridge — which
|
|
575
|
-
// auto-closes the popup and saves credentials. We must NOT open a second
|
|
576
|
-
// window, so we just return.
|
|
577
|
-
// - Otherwise (cold first click before partner.js finished initializing) we
|
|
578
|
-
// open the popup ourselves so the click still works. Completion is then
|
|
579
|
-
// handled by our own postMessage listener, and also by partner.js's bridge
|
|
580
|
-
// once it finishes initializing a moment later.
|
|
581
|
-
const handleConnectClick = (e) => {
|
|
582
|
-
e.preventDefault();
|
|
583
|
-
if (connStateRef.current !== "ready" ||
|
|
584
|
-
!finalUrlRef.current ||
|
|
585
|
-
inProgressRef.current) {
|
|
586
|
-
return;
|
|
587
|
-
}
|
|
588
|
-
if (partnerBoundRef.current) {
|
|
589
|
-
// partner.js owns this click (popup + auto-close + callback bridge).
|
|
590
|
-
return;
|
|
591
|
-
}
|
|
592
|
-
completedRef.current = false;
|
|
593
|
-
const popup = openOnboardingPopup(finalUrlRef.current);
|
|
594
|
-
setPopupBlocked(!popup);
|
|
595
|
-
};
|
|
596
430
|
const handleSaveManual = async () => {
|
|
597
431
|
if (!canSaveManual || onboardingInProgress)
|
|
598
432
|
return;
|
|
@@ -657,9 +491,10 @@ function PayPalConnectionPage() {
|
|
|
657
491
|
}
|
|
658
492
|
clearCachedUrl(env);
|
|
659
493
|
completedRef.current = false;
|
|
494
|
+
// Generate a fresh link for the now-disconnected environment and reload so
|
|
495
|
+
// the onboarding button is ready again.
|
|
660
496
|
currentRunId.current = ++runIdRef.current;
|
|
661
|
-
|
|
662
|
-
fetchFreshLink(runId, env);
|
|
497
|
+
await generateLinkAndReload(env, currentRunId.current);
|
|
663
498
|
}
|
|
664
499
|
catch (e) {
|
|
665
500
|
console.error(e);
|
|
@@ -672,15 +507,13 @@ function PayPalConnectionPage() {
|
|
|
672
507
|
};
|
|
673
508
|
const handleEnvChange = async (e) => {
|
|
674
509
|
const next = e.target.value;
|
|
510
|
+
if (next === env || onboardingInProgress)
|
|
511
|
+
return;
|
|
675
512
|
completedRef.current = false;
|
|
676
|
-
setPopupBlocked(false);
|
|
677
|
-
// Drop both cached URLs so neither environment can serve a stale link.
|
|
678
513
|
clearCachedUrl();
|
|
679
|
-
|
|
680
|
-
//
|
|
681
|
-
//
|
|
682
|
-
// POST winning a race.
|
|
683
|
-
setEnv(next);
|
|
514
|
+
setConnState("loading");
|
|
515
|
+
// Persist the environment BEFORE switching local state so the post-reload
|
|
516
|
+
// GET /environment returns the same value the cache was keyed under.
|
|
684
517
|
try {
|
|
685
518
|
await fetch(ENVIRONMENT_ENDPOINT, {
|
|
686
519
|
method: "POST",
|
|
@@ -690,17 +523,24 @@ function PayPalConnectionPage() {
|
|
|
690
523
|
});
|
|
691
524
|
}
|
|
692
525
|
catch { }
|
|
526
|
+
setEnv(next);
|
|
693
527
|
};
|
|
694
528
|
return ((0, jsx_runtime_1.jsxs)("div", { className: "p-6", children: [(0, jsx_runtime_1.jsxs)("div", { className: "flex flex-col gap-6", children: [(0, jsx_runtime_1.jsx)("div", { className: "flex items-start justify-between gap-4", children: (0, jsx_runtime_1.jsx)("div", { children: (0, jsx_runtime_1.jsx)("h1", { className: "text-xl font-semibold text-ui-fg-base", children: "PayPal Gateway By Easy Payment" }) }) }), (0, jsx_runtime_1.jsx)(Tabs_1.default, {}), (0, jsx_runtime_1.jsx)("div", { className: "rounded-xl border border-ui-border-base bg-ui-bg-base shadow-sm", children: (0, jsx_runtime_1.jsxs)("div", { className: "grid grid-cols-1 gap-y-6 p-4 md:grid-cols-[260px_1fr] md:items-start", children: [(0, jsx_runtime_1.jsx)("div", { className: "text-sm font-medium pt-2", children: "Environment" }), (0, jsx_runtime_1.jsx)("div", { className: "max-w-xl", children: (0, jsx_runtime_1.jsxs)("select", { value: env, onChange: handleEnvChange, disabled: onboardingInProgress, className: "w-full rounded-md border border-ui-border-base bg-transparent px-3 py-2 text-sm", children: [(0, jsx_runtime_1.jsx)("option", { value: "sandbox", children: "Sandbox (Test Mode)" }), (0, jsx_runtime_1.jsx)("option", { value: "live", children: "Live (Production)" })] }) }), (0, jsx_runtime_1.jsx)("div", { className: "text-sm font-medium pt-2", children: env === "sandbox" ? "Connect to PayPal (Sandbox)" : "Connect to PayPal" }), (0, jsx_runtime_1.jsx)("div", { className: "max-w-xl", children: connState === "connected" ? ((0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsx)("div", { className: "text-sm text-green-600 bg-green-50 p-3 rounded border border-green-200", children: "\u2705 Successfully connected to PayPal!" }), (0, jsx_runtime_1.jsxs)("div", { className: "mt-3 rounded-md border border-ui-border-base bg-ui-bg-subtle p-3 text-xs text-ui-fg-subtle", children: [(0, jsx_runtime_1.jsx)("div", { className: "font-medium text-ui-fg-base", children: "Connected PayPal account" }), (0, jsx_runtime_1.jsxs)("div", { className: "mt-1", children: ["Email:", " ", (0, jsx_runtime_1.jsx)("span", { className: "font-mono text-ui-fg-base", children: statusInfo?.seller_email || "Unavailable" })] })] }), (0, jsx_runtime_1.jsx)("div", { className: "mt-3 flex items-center gap-2", children: (0, jsx_runtime_1.jsx)("button", { type: "button", onClick: handleDisconnect, disabled: onboardingInProgress, className: "transition-fg relative inline-flex w-fit items-center justify-center overflow-hidden rounded-md outline-none 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", children: "Disconnect" }) })] })) : ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsxs)("div", { ref: initLoaderRef, id: "init-loader", className: `status-msg mb-4 ${connState !== "loading" ? "hidden" : "block"}`, children: [(0, jsx_runtime_1.jsx)("div", { className: "loader inline-block align-middle mr-2" }), (0, jsx_runtime_1.jsx)("span", { id: "loader-text", className: "text-sm", children: onboardingInProgress
|
|
695
529
|
? "Configuring connection to PayPal…"
|
|
696
530
|
: "Checking connection..." })] }), (0, jsx_runtime_1.jsxs)("div", { className: `${connState === "ready" ? "block" : "hidden"}`, children: [(0, jsx_runtime_1.jsx)("a", { ref: (node) => {
|
|
697
|
-
paypalButtonRef.current = node;
|
|
698
531
|
ppBtnMeasureRef.current = node;
|
|
699
|
-
}, id: "paypal-button", target: POPUP_NAME, "data-paypal-button": "true", href: finalUrl || "#", "data-paypal-onboard-complete": "onboardingCallback",
|
|
700
|
-
cursor: onboardingInProgress
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
532
|
+
}, id: "paypal-button", target: POPUP_NAME, "data-paypal-button": "true", href: finalUrl || "#", "data-paypal-onboard-complete": "onboardingCallback", "aria-disabled": onboardingInProgress || !partnerReady, 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", style: {
|
|
533
|
+
cursor: onboardingInProgress || !partnerReady
|
|
534
|
+
? "not-allowed"
|
|
535
|
+
: "pointer",
|
|
536
|
+
opacity: onboardingInProgress || !partnerReady ? 0.6 : 1,
|
|
537
|
+
// Keep the anchor mounted (partner.js must find it to bind)
|
|
538
|
+
// but un-clickable until partner.js has taken over the
|
|
539
|
+
// click — so it can never open a tab via the native href.
|
|
540
|
+
pointerEvents: onboardingInProgress || !partnerReady ? "none" : "auto",
|
|
541
|
+
}, children: partnerReady || onboardingInProgress
|
|
542
|
+
? "Connect to PayPal"
|
|
543
|
+
: "Preparing PayPal…" }), (0, jsx_runtime_1.jsx)("div", { className: "mt-2", style: {
|
|
704
544
|
width: ppBtnWidth ? `${ppBtnWidth}px` : "auto",
|
|
705
545
|
marginTop: "20px",
|
|
706
546
|
marginBottom: "10px",
|