@easypayment/medusa-payment-paypal 0.9.18 → 0.9.20
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 +270 -114
- package/.medusa/server/src/admin/index.mjs +270 -114
- 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 +384 -126
- package/.medusa/server/src/admin/routes/settings/paypal/connection/page.js.map +1 -1
- package/.medusa/server/src/api/admin/paypal/onboarding-link/route.d.ts.map +1 -1
- package/.medusa/server/src/api/admin/paypal/onboarding-link/route.js +4 -0
- package/.medusa/server/src/api/admin/paypal/onboarding-link/route.js.map +1 -1
- package/.medusa/server/src/modules/paypal/service.d.ts +1 -0
- package/.medusa/server/src/modules/paypal/service.d.ts.map +1 -1
- package/.medusa/server/src/modules/paypal/service.js +5 -1
- package/.medusa/server/src/modules/paypal/service.js.map +1 -1
- package/package.json +1 -1
- package/src/admin/routes/settings/paypal/connection/page.tsx +420 -131
- package/src/api/admin/paypal/onboarding-link/route.ts +6 -0
- package/src/modules/paypal/service.ts +7 -2
|
@@ -17,40 +17,137 @@ 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
|
-
|
|
20
|
+
// The mini-browser popup is opened by us (see openOnboardingPopup). We give it a
|
|
21
|
+
// stable window name so that, even if PayPal's partner.js *also* intercepts the
|
|
22
|
+
// click, window.open() with the same name re-targets the one window instead of
|
|
23
|
+
// spawning a second one.
|
|
24
|
+
const POPUP_NAME = "PPMiniBrowser";
|
|
25
|
+
const CACHE_PREFIX = "pp_onboard_cache";
|
|
21
26
|
const CACHE_EXPIRY = 10 * 60 * 1000;
|
|
22
27
|
const ONBOARDING_COMPLETE_ENDPOINT = "/admin/paypal/onboard-complete";
|
|
23
28
|
const STATUS_ENDPOINT = "/admin/paypal/status";
|
|
24
29
|
const SAVE_CREDENTIALS_ENDPOINT = "/admin/paypal/save-credentials";
|
|
25
30
|
const DISCONNECT_ENDPOINT = "/admin/paypal/disconnect";
|
|
26
|
-
|
|
27
|
-
|
|
31
|
+
const ENVIRONMENT_ENDPOINT = "/admin/paypal/environment";
|
|
32
|
+
// Onboarding URLs are environment-specific and short-lived, so the cache must be
|
|
33
|
+
// keyed per environment — a single shared key let a stale sandbox URL leak into
|
|
34
|
+
// the live flow (and vice-versa) after a reload.
|
|
35
|
+
const cacheKeyFor = (env) => `${CACHE_PREFIX}_${env}`;
|
|
36
|
+
function readCachedUrl(env) {
|
|
37
|
+
if (typeof window === "undefined")
|
|
38
|
+
return null;
|
|
28
39
|
try {
|
|
29
|
-
const
|
|
30
|
-
if (
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
40
|
+
const raw = localStorage.getItem(cacheKeyFor(env));
|
|
41
|
+
if (!raw)
|
|
42
|
+
return null;
|
|
43
|
+
const data = JSON.parse(raw);
|
|
44
|
+
if (data &&
|
|
45
|
+
typeof data.url === "string" &&
|
|
46
|
+
Date.now() - (Number(data.ts) || 0) < CACHE_EXPIRY) {
|
|
47
|
+
return data.url;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
/* ignore malformed cache */
|
|
52
|
+
}
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
function writeCachedUrl(env, url) {
|
|
56
|
+
try {
|
|
57
|
+
localStorage.setItem(cacheKeyFor(env), JSON.stringify({ url, ts: Date.now() }));
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
/* ignore quota / disabled storage */
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
function clearCachedUrl(env) {
|
|
64
|
+
try {
|
|
65
|
+
if (env) {
|
|
66
|
+
localStorage.removeItem(cacheKeyFor(env));
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
localStorage.removeItem(cacheKeyFor("sandbox"));
|
|
70
|
+
localStorage.removeItem(cacheKeyFor("live"));
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
/* ignore */
|
|
75
|
+
}
|
|
76
|
+
}
|
|
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;
|
|
34
111
|
}
|
|
35
112
|
}
|
|
113
|
+
return {};
|
|
36
114
|
}
|
|
37
|
-
|
|
38
|
-
|
|
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
|
+
}
|
|
39
139
|
}
|
|
140
|
+
return {};
|
|
40
141
|
}
|
|
41
142
|
function PayPalConnectionPage() {
|
|
42
143
|
const [env, setEnv] = (0, react_1.useState)("live");
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
const v = d?.environment === "sandbox" ? "sandbox" : "live";
|
|
48
|
-
setEnv(v);
|
|
49
|
-
})
|
|
50
|
-
.catch(() => { });
|
|
51
|
-
}, []);
|
|
144
|
+
// We must not generate/fetch an onboarding link until we know which
|
|
145
|
+
// environment the server is actually on, otherwise the first fetch races the
|
|
146
|
+
// GET /environment response and can target the wrong environment.
|
|
147
|
+
const [envReady, setEnvReady] = (0, react_1.useState)(false);
|
|
52
148
|
const [connState, setConnState] = (0, react_1.useState)("loading");
|
|
53
149
|
const [error, setError] = (0, react_1.useState)(null);
|
|
150
|
+
const [popupBlocked, setPopupBlocked] = (0, react_1.useState)(false);
|
|
54
151
|
const [finalUrl, setFinalUrl] = (0, react_1.useState)("");
|
|
55
152
|
const [showManual, setShowManual] = (0, react_1.useState)(false);
|
|
56
153
|
const [clientId, setClientId] = (0, react_1.useState)("");
|
|
@@ -62,6 +159,27 @@ function PayPalConnectionPage() {
|
|
|
62
159
|
const errorLogRef = (0, react_1.useRef)(null);
|
|
63
160
|
const runIdRef = (0, react_1.useRef)(0);
|
|
64
161
|
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
|
+
const envRef = (0, react_1.useRef)(env);
|
|
171
|
+
envRef.current = env;
|
|
172
|
+
const popupRef = (0, react_1.useRef)(null);
|
|
173
|
+
const popupPollRef = (0, react_1.useRef)(null);
|
|
174
|
+
// Guards against the two redundant completion paths (our own message listener
|
|
175
|
+
// and partner.js's onboardingCallback bridge) both POSTing the same result.
|
|
176
|
+
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);
|
|
65
183
|
const ppBtnMeasureRef = (0, react_1.useRef)(null);
|
|
66
184
|
const [ppBtnWidth, setPpBtnWidth] = (0, react_1.useState)(null);
|
|
67
185
|
const canSaveManual = (0, react_1.useMemo)(() => {
|
|
@@ -71,7 +189,132 @@ function PayPalConnectionPage() {
|
|
|
71
189
|
setConnState("error");
|
|
72
190
|
setError(msg);
|
|
73
191
|
}, []);
|
|
74
|
-
const
|
|
192
|
+
const stopPopupPoll = (0, react_1.useCallback)(() => {
|
|
193
|
+
if (popupPollRef.current) {
|
|
194
|
+
clearInterval(popupPollRef.current);
|
|
195
|
+
popupPollRef.current = null;
|
|
196
|
+
}
|
|
197
|
+
}, []);
|
|
198
|
+
// Open the PayPal mini-browser ourselves, synchronously, inside the click
|
|
199
|
+
// gesture. This is the crux of the fix: browsers only render window.open() as
|
|
200
|
+
// a real popup (instead of a tab — or blocking it) when it runs synchronously
|
|
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 */
|
|
228
|
+
}
|
|
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
|
+
}
|
|
253
|
+
const activeEnv = envRef.current === "sandbox" ? "sandbox" : "live";
|
|
254
|
+
setOnboardingInProgress(true);
|
|
255
|
+
setConnState("loading");
|
|
256
|
+
setError(null);
|
|
257
|
+
setPopupBlocked(false);
|
|
258
|
+
try {
|
|
259
|
+
const res = await fetch(ONBOARDING_COMPLETE_ENDPOINT, {
|
|
260
|
+
method: "POST",
|
|
261
|
+
headers: { "content-type": "application/json" },
|
|
262
|
+
credentials: "include",
|
|
263
|
+
body: JSON.stringify({ authCode, sharedId, env: activeEnv }),
|
|
264
|
+
});
|
|
265
|
+
if (!res.ok) {
|
|
266
|
+
const txt = await res.text().catch(() => "");
|
|
267
|
+
throw new Error(txt || `Onboarding exchange failed (${res.status})`);
|
|
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) => {
|
|
75
318
|
if (initLoaderRef.current) {
|
|
76
319
|
const loaderText = initLoaderRef.current.querySelector("#loader-text");
|
|
77
320
|
if (loaderText)
|
|
@@ -83,6 +326,9 @@ function PayPalConnectionPage() {
|
|
|
83
326
|
credentials: "include",
|
|
84
327
|
body: JSON.stringify({
|
|
85
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,
|
|
86
332
|
}),
|
|
87
333
|
})
|
|
88
334
|
.then((r) => {
|
|
@@ -99,7 +345,7 @@ function PayPalConnectionPage() {
|
|
|
99
345
|
return;
|
|
100
346
|
}
|
|
101
347
|
const url = href + (href.includes("?") ? "&" : "?") + "displayMode=minibrowser";
|
|
102
|
-
|
|
348
|
+
writeCachedUrl(targetEnv, url);
|
|
103
349
|
setFinalUrl(url);
|
|
104
350
|
setConnState("ready");
|
|
105
351
|
})
|
|
@@ -108,22 +354,40 @@ function PayPalConnectionPage() {
|
|
|
108
354
|
return;
|
|
109
355
|
showError("Unable to connect to service.");
|
|
110
356
|
});
|
|
111
|
-
}, [
|
|
357
|
+
}, [showError]);
|
|
358
|
+
// Resolve the server's current environment before doing anything else.
|
|
359
|
+
(0, react_1.useEffect)(() => {
|
|
360
|
+
let alive = true;
|
|
361
|
+
fetch(ENVIRONMENT_ENDPOINT, { method: "GET", credentials: "include" })
|
|
362
|
+
.then((r) => r.json())
|
|
363
|
+
.then((d) => {
|
|
364
|
+
if (!alive)
|
|
365
|
+
return;
|
|
366
|
+
setEnv(d?.environment === "sandbox" ? "sandbox" : "live");
|
|
367
|
+
})
|
|
368
|
+
.catch(() => { })
|
|
369
|
+
.finally(() => {
|
|
370
|
+
if (alive)
|
|
371
|
+
setEnvReady(true);
|
|
372
|
+
});
|
|
373
|
+
return () => {
|
|
374
|
+
alive = false;
|
|
375
|
+
};
|
|
376
|
+
}, []);
|
|
377
|
+
// partner.js is loaded ONLY for its postMessage->callback bridge
|
|
378
|
+
// (window.onboardingCallback), as a redundant path next to our own message
|
|
379
|
+
// listener below. It is NO LONGER responsible for opening the window — we do
|
|
380
|
+
// that synchronously in the click handler. We still init it so its message
|
|
381
|
+
// bridge registers inside this React SPA (its own DOMContentLoaded hook fired
|
|
382
|
+
// long before this route mounted). If partner.js also intercepts the click,
|
|
383
|
+
// the shared POPUP_NAME means it re-targets our single popup rather than
|
|
384
|
+
// opening a second window.
|
|
112
385
|
(0, react_1.useEffect)(() => {
|
|
113
386
|
if (connState !== "ready" || !finalUrl)
|
|
114
387
|
return;
|
|
115
388
|
const scriptUrl = PARTNER_JS_URLS[env];
|
|
116
389
|
let cancelled = false;
|
|
117
390
|
let pollTimer;
|
|
118
|
-
// PayPal's partner.js wires the mini-browser handler to its own
|
|
119
|
-
// DOMContentLoaded listener. Inside the Medusa admin (a React SPA) that
|
|
120
|
-
// event fired long before this route mounted, so partner.js never scans the
|
|
121
|
-
// button and the click falls through to the plain target="_blank" anchor —
|
|
122
|
-
// which opens PayPal onboarding in a NEW TAB instead of the popup.
|
|
123
|
-
//
|
|
124
|
-
// To fix this we load partner.js only after the anchor exists with its real
|
|
125
|
-
// href, then explicitly run PayPal's initializer so it discovers the button
|
|
126
|
-
// and intercepts the click into the mini-browser popup.
|
|
127
391
|
const initPartner = (attempt = 0) => {
|
|
128
392
|
if (cancelled)
|
|
129
393
|
return;
|
|
@@ -136,6 +400,10 @@ function PayPalConnectionPage() {
|
|
|
136
400
|
if (target?.init) {
|
|
137
401
|
try {
|
|
138
402
|
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;
|
|
139
407
|
}
|
|
140
408
|
catch (e) {
|
|
141
409
|
console.error("[paypal] partner.js init failed:", e);
|
|
@@ -145,28 +413,27 @@ function PayPalConnectionPage() {
|
|
|
145
413
|
if (typeof signup?.render === "function") {
|
|
146
414
|
try {
|
|
147
415
|
signup.render();
|
|
416
|
+
partnerBoundRef.current = true;
|
|
148
417
|
}
|
|
149
418
|
catch (e) {
|
|
150
419
|
console.error("[paypal] partner.js render failed:", e);
|
|
151
420
|
}
|
|
152
421
|
return;
|
|
153
422
|
}
|
|
154
|
-
// The namespace may not be populated the instant onload fires; retry
|
|
155
|
-
// briefly before giving up.
|
|
156
423
|
if (attempt < 40) {
|
|
157
424
|
pollTimer = setTimeout(() => initPartner(attempt + 1), 50);
|
|
158
425
|
return;
|
|
159
426
|
}
|
|
160
|
-
// Last resort: partner.js exposed no initializer we recognise. Re-fire
|
|
161
|
-
// DOMContentLoaded so any listener it registered runs against the
|
|
162
|
-
// now-present button.
|
|
163
427
|
try {
|
|
164
428
|
document.dispatchEvent(new Event("DOMContentLoaded"));
|
|
165
429
|
}
|
|
166
430
|
catch { }
|
|
167
431
|
};
|
|
168
|
-
//
|
|
169
|
-
//
|
|
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
|
+
// Remove any stale copy + namespace so the fresh load re-registers against
|
|
436
|
+
// the current environment (sandbox vs live partner.js differ).
|
|
170
437
|
const existingScript = document.getElementById("paypal-partner-js");
|
|
171
438
|
if (existingScript) {
|
|
172
439
|
existingScript.parentNode?.removeChild(existingScript);
|
|
@@ -193,13 +460,35 @@ function PayPalConnectionPage() {
|
|
|
193
460
|
}
|
|
194
461
|
};
|
|
195
462
|
}, [connState, finalUrl, env]);
|
|
463
|
+
// Primary completion path: receive PayPal's seamless-onboarding postMessage
|
|
464
|
+
// ourselves. Independent of partner.js, so it works even when we opened the
|
|
465
|
+
// popup directly.
|
|
196
466
|
(0, react_1.useEffect)(() => {
|
|
467
|
+
const onMessage = (ev) => {
|
|
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)
|
|
483
|
+
return;
|
|
197
484
|
currentRunId.current = ++runIdRef.current;
|
|
198
485
|
const runId = currentRunId.current;
|
|
199
486
|
let cancelled = false;
|
|
487
|
+
completedRef.current = false;
|
|
200
488
|
const run = async () => {
|
|
201
489
|
setConnState("loading");
|
|
202
490
|
setError(null);
|
|
491
|
+
setPopupBlocked(false);
|
|
203
492
|
setFinalUrl("");
|
|
204
493
|
try {
|
|
205
494
|
const r = await fetch(`${STATUS_ENDPOINT}?environment=${env}`, {
|
|
@@ -220,89 +509,37 @@ function PayPalConnectionPage() {
|
|
|
220
509
|
catch (e) {
|
|
221
510
|
console.error(e);
|
|
222
511
|
}
|
|
223
|
-
if (
|
|
224
|
-
|
|
512
|
+
if (cancelled || runId !== currentRunId.current)
|
|
513
|
+
return;
|
|
514
|
+
const cached = readCachedUrl(env);
|
|
515
|
+
if (cached) {
|
|
516
|
+
setFinalUrl(cached);
|
|
225
517
|
setConnState("ready");
|
|
226
518
|
}
|
|
227
519
|
else {
|
|
228
|
-
fetchFreshLink(runId);
|
|
520
|
+
fetchFreshLink(runId, env);
|
|
229
521
|
}
|
|
230
522
|
};
|
|
231
523
|
run();
|
|
232
524
|
return () => {
|
|
233
525
|
cancelled = true;
|
|
234
|
-
currentRunId.current = 0;
|
|
235
526
|
};
|
|
236
|
-
}, [env, fetchFreshLink]);
|
|
527
|
+
}, [env, envReady, fetchFreshLink]);
|
|
528
|
+
// Bridge partner.js's callback into our single idempotent completion handler.
|
|
237
529
|
(0, react_1.useLayoutEffect)(() => {
|
|
238
|
-
window.onboardingCallback =
|
|
239
|
-
|
|
240
|
-
window.onbeforeunload = null;
|
|
241
|
-
}
|
|
242
|
-
catch { }
|
|
243
|
-
setOnboardingInProgress(true);
|
|
244
|
-
setConnState("loading");
|
|
245
|
-
setError(null);
|
|
246
|
-
const payload = {
|
|
247
|
-
authCode,
|
|
248
|
-
sharedId,
|
|
249
|
-
env: env === "sandbox" ? "sandbox" : "live",
|
|
250
|
-
};
|
|
251
|
-
try {
|
|
252
|
-
const res = await fetch(ONBOARDING_COMPLETE_ENDPOINT, {
|
|
253
|
-
method: "POST",
|
|
254
|
-
headers: { "content-type": "application/json" },
|
|
255
|
-
credentials: "include",
|
|
256
|
-
body: JSON.stringify(payload),
|
|
257
|
-
});
|
|
258
|
-
if (!res.ok) {
|
|
259
|
-
const txt = await res.text().catch(() => "");
|
|
260
|
-
throw new Error(txt || `Onboarding exchange failed (${res.status})`);
|
|
261
|
-
}
|
|
262
|
-
try {
|
|
263
|
-
const close1 = window.PAYPAL?.apps?.Signup?.MiniBrowser?.closeFlow;
|
|
264
|
-
if (typeof close1 === "function")
|
|
265
|
-
close1();
|
|
266
|
-
}
|
|
267
|
-
catch { }
|
|
268
|
-
try {
|
|
269
|
-
const close2 = window.PAYPAL?.apps?.Signup?.miniBrowser &&
|
|
270
|
-
window.PAYPAL.apps.Signup.miniBrowser.closeFlow;
|
|
271
|
-
if (typeof close2 === "function")
|
|
272
|
-
close2();
|
|
273
|
-
}
|
|
274
|
-
catch { }
|
|
275
|
-
try {
|
|
276
|
-
localStorage.removeItem(CACHE_KEY);
|
|
277
|
-
}
|
|
278
|
-
catch { }
|
|
279
|
-
try {
|
|
280
|
-
const statusRes = await fetch(`${STATUS_ENDPOINT}?environment=${env}`, {
|
|
281
|
-
method: "GET",
|
|
282
|
-
credentials: "include",
|
|
283
|
-
});
|
|
284
|
-
const refreshedStatus = await statusRes.json().catch(() => ({}));
|
|
285
|
-
setStatusInfo(refreshedStatus || null);
|
|
286
|
-
setConnState("connected");
|
|
287
|
-
setShowManual(false);
|
|
288
|
-
}
|
|
289
|
-
catch {
|
|
290
|
-
setConnState("connected");
|
|
291
|
-
setShowManual(false);
|
|
292
|
-
}
|
|
293
|
-
setOnboardingInProgress(false);
|
|
294
|
-
}
|
|
295
|
-
catch (e) {
|
|
296
|
-
console.error(e);
|
|
297
|
-
setConnState("error");
|
|
298
|
-
setError(e?.message || "Exchange failed while saving credentials.");
|
|
299
|
-
setOnboardingInProgress(false);
|
|
300
|
-
}
|
|
530
|
+
window.onboardingCallback = (authCode, sharedId) => {
|
|
531
|
+
completeOnboarding(authCode, sharedId);
|
|
301
532
|
};
|
|
302
533
|
return () => {
|
|
303
534
|
window.onboardingCallback = undefined;
|
|
304
535
|
};
|
|
305
|
-
}, [
|
|
536
|
+
}, [completeOnboarding]);
|
|
537
|
+
// Clean up the popup poll on unmount.
|
|
538
|
+
(0, react_1.useEffect)(() => {
|
|
539
|
+
return () => {
|
|
540
|
+
stopPopupPoll();
|
|
541
|
+
};
|
|
542
|
+
}, [stopPopupPoll]);
|
|
306
543
|
(0, react_1.useLayoutEffect)(() => {
|
|
307
544
|
const el = ppBtnMeasureRef.current;
|
|
308
545
|
if (!el)
|
|
@@ -328,10 +565,33 @@ function PayPalConnectionPage() {
|
|
|
328
565
|
window.removeEventListener("resize", update);
|
|
329
566
|
};
|
|
330
567
|
}, [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.
|
|
331
581
|
const handleConnectClick = (e) => {
|
|
332
|
-
|
|
333
|
-
|
|
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;
|
|
334
591
|
}
|
|
592
|
+
completedRef.current = false;
|
|
593
|
+
const popup = openOnboardingPopup(finalUrlRef.current);
|
|
594
|
+
setPopupBlocked(!popup);
|
|
335
595
|
};
|
|
336
596
|
const handleSaveManual = async () => {
|
|
337
597
|
if (!canSaveManual || onboardingInProgress)
|
|
@@ -362,10 +622,7 @@ function PayPalConnectionPage() {
|
|
|
362
622
|
setConnState("connected");
|
|
363
623
|
setStatusInfo(refreshedStatus || null);
|
|
364
624
|
setShowManual(false);
|
|
365
|
-
|
|
366
|
-
localStorage.removeItem(CACHE_KEY);
|
|
367
|
-
}
|
|
368
|
-
catch { }
|
|
625
|
+
clearCachedUrl(env);
|
|
369
626
|
}
|
|
370
627
|
catch (e) {
|
|
371
628
|
console.error(e);
|
|
@@ -398,13 +655,11 @@ function PayPalConnectionPage() {
|
|
|
398
655
|
const t = await res.text().catch(() => "");
|
|
399
656
|
throw new Error(t || `Disconnect failed (${res.status})`);
|
|
400
657
|
}
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
}
|
|
404
|
-
catch { }
|
|
658
|
+
clearCachedUrl(env);
|
|
659
|
+
completedRef.current = false;
|
|
405
660
|
currentRunId.current = ++runIdRef.current;
|
|
406
661
|
const runId = currentRunId.current;
|
|
407
|
-
fetchFreshLink(runId);
|
|
662
|
+
fetchFreshLink(runId, env);
|
|
408
663
|
}
|
|
409
664
|
catch (e) {
|
|
410
665
|
console.error(e);
|
|
@@ -417,10 +672,17 @@ function PayPalConnectionPage() {
|
|
|
417
672
|
};
|
|
418
673
|
const handleEnvChange = async (e) => {
|
|
419
674
|
const next = e.target.value;
|
|
675
|
+
completedRef.current = false;
|
|
676
|
+
setPopupBlocked(false);
|
|
677
|
+
// Drop both cached URLs so neither environment can serve a stale link.
|
|
678
|
+
clearCachedUrl();
|
|
679
|
+
// Update local state immediately (drives the status/link refetch with the
|
|
680
|
+
// explicit environment) and persist the choice. Because the link/status
|
|
681
|
+
// fetches are environment-explicit, correctness no longer depends on this
|
|
682
|
+
// POST winning a race.
|
|
420
683
|
setEnv(next);
|
|
421
|
-
cachedUrl = null;
|
|
422
684
|
try {
|
|
423
|
-
await fetch(
|
|
685
|
+
await fetch(ENVIRONMENT_ENDPOINT, {
|
|
424
686
|
method: "POST",
|
|
425
687
|
headers: { "content-type": "application/json" },
|
|
426
688
|
credentials: "include",
|
|
@@ -428,21 +690,17 @@ function PayPalConnectionPage() {
|
|
|
428
690
|
});
|
|
429
691
|
}
|
|
430
692
|
catch { }
|
|
431
|
-
try {
|
|
432
|
-
localStorage.removeItem(CACHE_KEY);
|
|
433
|
-
}
|
|
434
|
-
catch { }
|
|
435
693
|
};
|
|
436
694
|
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
|
|
437
695
|
? "Configuring connection to PayPal…"
|
|
438
696
|
: "Checking connection..." })] }), (0, jsx_runtime_1.jsxs)("div", { className: `${connState === "ready" ? "block" : "hidden"}`, children: [(0, jsx_runtime_1.jsx)("a", { ref: (node) => {
|
|
439
697
|
paypalButtonRef.current = node;
|
|
440
698
|
ppBtnMeasureRef.current = node;
|
|
441
|
-
}, id: "paypal-button", target:
|
|
699
|
+
}, id: "paypal-button", target: POPUP_NAME, "data-paypal-button": "true", href: finalUrl || "#", "data-paypal-onboard-complete": "onboardingCallback", onClick: handleConnectClick, 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: {
|
|
442
700
|
cursor: onboardingInProgress ? "not-allowed" : "pointer",
|
|
443
701
|
opacity: onboardingInProgress ? 0.6 : 1,
|
|
444
702
|
pointerEvents: onboardingInProgress ? "none" : "auto",
|
|
445
|
-
}, children: "Connect to PayPal" }), (0, jsx_runtime_1.jsx)("div", { className: "mt-2", style: {
|
|
703
|
+
}, children: "Connect to PayPal" }), popupBlocked && ((0, jsx_runtime_1.jsx)("div", { className: "mt-3 text-left text-xs bg-amber-50 text-amber-700 p-3 border border-amber-200 rounded", children: "Your browser blocked the PayPal popup. Please allow popups for this site, then click \u201CConnect to PayPal\u201D again." })), (0, jsx_runtime_1.jsx)("div", { className: "mt-2", style: {
|
|
446
704
|
width: ppBtnWidth ? `${ppBtnWidth}px` : "auto",
|
|
447
705
|
marginTop: "20px",
|
|
448
706
|
marginBottom: "10px",
|