@easypayment/medusa-payment-paypal 0.9.18 → 0.9.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.medusa/server/src/admin/index.js +287 -118
- package/.medusa/server/src/admin/index.mjs +287 -118
- 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 +386 -127
- 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 +419 -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,21 @@ 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);
|
|
65
177
|
const ppBtnMeasureRef = (0, react_1.useRef)(null);
|
|
66
178
|
const [ppBtnWidth, setPpBtnWidth] = (0, react_1.useState)(null);
|
|
67
179
|
const canSaveManual = (0, react_1.useMemo)(() => {
|
|
@@ -71,7 +183,148 @@ function PayPalConnectionPage() {
|
|
|
71
183
|
setConnState("error");
|
|
72
184
|
setError(msg);
|
|
73
185
|
}, []);
|
|
74
|
-
const
|
|
186
|
+
const stopPopupPoll = (0, react_1.useCallback)(() => {
|
|
187
|
+
if (popupPollRef.current) {
|
|
188
|
+
clearInterval(popupPollRef.current);
|
|
189
|
+
popupPollRef.current = null;
|
|
190
|
+
}
|
|
191
|
+
}, []);
|
|
192
|
+
// Open the PayPal mini-browser ourselves, synchronously, inside the click
|
|
193
|
+
// gesture. This is the crux of the fix: browsers only render window.open() as
|
|
194
|
+
// a real popup (instead of a tab — or blocking it) when it runs synchronously
|
|
195
|
+
// in a user gesture. Because finalUrl is already in state at click time, we
|
|
196
|
+
// never need an await before opening, and we never depend on winning a race
|
|
197
|
+
// with partner.js's asynchronous click binding.
|
|
198
|
+
const openOnboardingPopup = (0, react_1.useCallback)((url) => {
|
|
199
|
+
const w = 450;
|
|
200
|
+
const h = 600;
|
|
201
|
+
const baseLeft = typeof window.screenX === "number" ? window.screenX : window.screenLeft || 0;
|
|
202
|
+
const baseTop = typeof window.screenY === "number" ? window.screenY : window.screenTop || 0;
|
|
203
|
+
const outerW = window.outerWidth || document.documentElement.clientWidth || 1024;
|
|
204
|
+
const outerH = window.outerHeight || document.documentElement.clientHeight || 768;
|
|
205
|
+
const left = Math.round(baseLeft + Math.max(0, (outerW - w) / 2));
|
|
206
|
+
const top = Math.round(baseTop + Math.max(0, (outerH - h) / 2));
|
|
207
|
+
const features = `popup=yes,width=${w},height=${h},left=${left},top=${top},scrollbars=yes,resizable=yes`;
|
|
208
|
+
let popup = null;
|
|
209
|
+
try {
|
|
210
|
+
popup = window.open(url, POPUP_NAME, features);
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
popup = null;
|
|
214
|
+
}
|
|
215
|
+
if (popup) {
|
|
216
|
+
popupRef.current = popup;
|
|
217
|
+
try {
|
|
218
|
+
popup.focus();
|
|
219
|
+
}
|
|
220
|
+
catch {
|
|
221
|
+
/* ignore */
|
|
222
|
+
}
|
|
223
|
+
stopPopupPoll();
|
|
224
|
+
popupPollRef.current = setInterval(() => {
|
|
225
|
+
if (!popupRef.current || popupRef.current.closed) {
|
|
226
|
+
stopPopupPoll();
|
|
227
|
+
}
|
|
228
|
+
}, 1000);
|
|
229
|
+
}
|
|
230
|
+
return popup;
|
|
231
|
+
}, [stopPopupPoll]);
|
|
232
|
+
// Single entry point for "Connect to PayPal". Hard-guards on a real, ready
|
|
233
|
+
// URL so a half-loaded button can never open anything.
|
|
234
|
+
const openConnect = (0, react_1.useCallback)(() => {
|
|
235
|
+
if (connStateRef.current !== "ready" ||
|
|
236
|
+
!finalUrlRef.current ||
|
|
237
|
+
inProgressRef.current) {
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
completedRef.current = false;
|
|
241
|
+
const popup = openOnboardingPopup(finalUrlRef.current);
|
|
242
|
+
if (!popup) {
|
|
243
|
+
setPopupBlocked(true);
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
setPopupBlocked(false);
|
|
247
|
+
}, [openOnboardingPopup]);
|
|
248
|
+
// Exchange the seller authCode/sharedId for credentials. Reached from BOTH
|
|
249
|
+
// PayPal's partner.js bridge (window.onboardingCallback) and our own postMessage
|
|
250
|
+
// listener; completedRef makes it idempotent so we only POST once.
|
|
251
|
+
const completeOnboarding = (0, react_1.useCallback)(async (authCode, sharedId) => {
|
|
252
|
+
if (!authCode || !sharedId)
|
|
253
|
+
return;
|
|
254
|
+
if (completedRef.current)
|
|
255
|
+
return;
|
|
256
|
+
completedRef.current = true;
|
|
257
|
+
try {
|
|
258
|
+
window.onbeforeunload = null;
|
|
259
|
+
}
|
|
260
|
+
catch {
|
|
261
|
+
/* ignore */
|
|
262
|
+
}
|
|
263
|
+
const activeEnv = envRef.current === "sandbox" ? "sandbox" : "live";
|
|
264
|
+
setOnboardingInProgress(true);
|
|
265
|
+
setConnState("loading");
|
|
266
|
+
setError(null);
|
|
267
|
+
setPopupBlocked(false);
|
|
268
|
+
try {
|
|
269
|
+
const res = await fetch(ONBOARDING_COMPLETE_ENDPOINT, {
|
|
270
|
+
method: "POST",
|
|
271
|
+
headers: { "content-type": "application/json" },
|
|
272
|
+
credentials: "include",
|
|
273
|
+
body: JSON.stringify({ authCode, sharedId, env: activeEnv }),
|
|
274
|
+
});
|
|
275
|
+
if (!res.ok) {
|
|
276
|
+
const txt = await res.text().catch(() => "");
|
|
277
|
+
throw new Error(txt || `Onboarding exchange failed (${res.status})`);
|
|
278
|
+
}
|
|
279
|
+
// Close the popup we opened, plus any partner.js-managed flow.
|
|
280
|
+
try {
|
|
281
|
+
popupRef.current?.close();
|
|
282
|
+
}
|
|
283
|
+
catch {
|
|
284
|
+
/* ignore */
|
|
285
|
+
}
|
|
286
|
+
stopPopupPoll();
|
|
287
|
+
try {
|
|
288
|
+
const close1 = window.PAYPAL?.apps?.Signup?.MiniBrowser?.closeFlow;
|
|
289
|
+
if (typeof close1 === "function")
|
|
290
|
+
close1();
|
|
291
|
+
}
|
|
292
|
+
catch {
|
|
293
|
+
/* ignore */
|
|
294
|
+
}
|
|
295
|
+
try {
|
|
296
|
+
const close2 = window.PAYPAL?.apps?.Signup?.miniBrowser &&
|
|
297
|
+
window.PAYPAL.apps.Signup.miniBrowser.closeFlow;
|
|
298
|
+
if (typeof close2 === "function")
|
|
299
|
+
close2();
|
|
300
|
+
}
|
|
301
|
+
catch {
|
|
302
|
+
/* ignore */
|
|
303
|
+
}
|
|
304
|
+
clearCachedUrl(activeEnv);
|
|
305
|
+
try {
|
|
306
|
+
const statusRes = await fetch(`${STATUS_ENDPOINT}?environment=${activeEnv}`, { method: "GET", credentials: "include" });
|
|
307
|
+
const refreshedStatus = await statusRes.json().catch(() => ({}));
|
|
308
|
+
setStatusInfo(refreshedStatus || null);
|
|
309
|
+
}
|
|
310
|
+
catch {
|
|
311
|
+
/* ignore — still consider it connected below */
|
|
312
|
+
}
|
|
313
|
+
setConnState("connected");
|
|
314
|
+
setShowManual(false);
|
|
315
|
+
}
|
|
316
|
+
catch (e) {
|
|
317
|
+
// Allow a retry after a failed exchange.
|
|
318
|
+
completedRef.current = false;
|
|
319
|
+
console.error(e);
|
|
320
|
+
setConnState("error");
|
|
321
|
+
setError(e?.message || "Exchange failed while saving credentials.");
|
|
322
|
+
}
|
|
323
|
+
finally {
|
|
324
|
+
setOnboardingInProgress(false);
|
|
325
|
+
}
|
|
326
|
+
}, [stopPopupPoll]);
|
|
327
|
+
const fetchFreshLink = (0, react_1.useCallback)((runId, targetEnv) => {
|
|
75
328
|
if (initLoaderRef.current) {
|
|
76
329
|
const loaderText = initLoaderRef.current.querySelector("#loader-text");
|
|
77
330
|
if (loaderText)
|
|
@@ -83,6 +336,9 @@ function PayPalConnectionPage() {
|
|
|
83
336
|
credentials: "include",
|
|
84
337
|
body: JSON.stringify({
|
|
85
338
|
products: ["PPCP"],
|
|
339
|
+
// Generate the link for the explicitly selected environment so it can
|
|
340
|
+
// never depend on a racing POST /environment having landed yet.
|
|
341
|
+
environment: targetEnv,
|
|
86
342
|
}),
|
|
87
343
|
})
|
|
88
344
|
.then((r) => {
|
|
@@ -99,7 +355,7 @@ function PayPalConnectionPage() {
|
|
|
99
355
|
return;
|
|
100
356
|
}
|
|
101
357
|
const url = href + (href.includes("?") ? "&" : "?") + "displayMode=minibrowser";
|
|
102
|
-
|
|
358
|
+
writeCachedUrl(targetEnv, url);
|
|
103
359
|
setFinalUrl(url);
|
|
104
360
|
setConnState("ready");
|
|
105
361
|
})
|
|
@@ -108,22 +364,40 @@ function PayPalConnectionPage() {
|
|
|
108
364
|
return;
|
|
109
365
|
showError("Unable to connect to service.");
|
|
110
366
|
});
|
|
111
|
-
}, [
|
|
367
|
+
}, [showError]);
|
|
368
|
+
// Resolve the server's current environment before doing anything else.
|
|
369
|
+
(0, react_1.useEffect)(() => {
|
|
370
|
+
let alive = true;
|
|
371
|
+
fetch(ENVIRONMENT_ENDPOINT, { method: "GET", credentials: "include" })
|
|
372
|
+
.then((r) => r.json())
|
|
373
|
+
.then((d) => {
|
|
374
|
+
if (!alive)
|
|
375
|
+
return;
|
|
376
|
+
setEnv(d?.environment === "sandbox" ? "sandbox" : "live");
|
|
377
|
+
})
|
|
378
|
+
.catch(() => { })
|
|
379
|
+
.finally(() => {
|
|
380
|
+
if (alive)
|
|
381
|
+
setEnvReady(true);
|
|
382
|
+
});
|
|
383
|
+
return () => {
|
|
384
|
+
alive = false;
|
|
385
|
+
};
|
|
386
|
+
}, []);
|
|
387
|
+
// partner.js is loaded ONLY for its postMessage->callback bridge
|
|
388
|
+
// (window.onboardingCallback), as a redundant path next to our own message
|
|
389
|
+
// listener below. It is NO LONGER responsible for opening the window — we do
|
|
390
|
+
// that synchronously in the click handler. We still init it so its message
|
|
391
|
+
// bridge registers inside this React SPA (its own DOMContentLoaded hook fired
|
|
392
|
+
// long before this route mounted). If partner.js also intercepts the click,
|
|
393
|
+
// the shared POPUP_NAME means it re-targets our single popup rather than
|
|
394
|
+
// opening a second window.
|
|
112
395
|
(0, react_1.useEffect)(() => {
|
|
113
396
|
if (connState !== "ready" || !finalUrl)
|
|
114
397
|
return;
|
|
115
398
|
const scriptUrl = PARTNER_JS_URLS[env];
|
|
116
399
|
let cancelled = false;
|
|
117
400
|
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
401
|
const initPartner = (attempt = 0) => {
|
|
128
402
|
if (cancelled)
|
|
129
403
|
return;
|
|
@@ -151,22 +425,17 @@ function PayPalConnectionPage() {
|
|
|
151
425
|
}
|
|
152
426
|
return;
|
|
153
427
|
}
|
|
154
|
-
// The namespace may not be populated the instant onload fires; retry
|
|
155
|
-
// briefly before giving up.
|
|
156
428
|
if (attempt < 40) {
|
|
157
429
|
pollTimer = setTimeout(() => initPartner(attempt + 1), 50);
|
|
158
430
|
return;
|
|
159
431
|
}
|
|
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
432
|
try {
|
|
164
433
|
document.dispatchEvent(new Event("DOMContentLoaded"));
|
|
165
434
|
}
|
|
166
435
|
catch { }
|
|
167
436
|
};
|
|
168
|
-
// Remove any stale copy + namespace so the fresh load re-
|
|
169
|
-
//
|
|
437
|
+
// Remove any stale copy + namespace so the fresh load re-registers against
|
|
438
|
+
// the current environment (sandbox vs live partner.js differ).
|
|
170
439
|
const existingScript = document.getElementById("paypal-partner-js");
|
|
171
440
|
if (existingScript) {
|
|
172
441
|
existingScript.parentNode?.removeChild(existingScript);
|
|
@@ -193,13 +462,35 @@ function PayPalConnectionPage() {
|
|
|
193
462
|
}
|
|
194
463
|
};
|
|
195
464
|
}, [connState, finalUrl, env]);
|
|
465
|
+
// Primary completion path: receive PayPal's seamless-onboarding postMessage
|
|
466
|
+
// ourselves. Independent of partner.js, so it works even when we opened the
|
|
467
|
+
// popup directly.
|
|
196
468
|
(0, react_1.useEffect)(() => {
|
|
469
|
+
const onMessage = (ev) => {
|
|
470
|
+
if (!isPayPalOrigin(ev.origin))
|
|
471
|
+
return;
|
|
472
|
+
const { authCode, sharedId } = extractAuth(ev.data);
|
|
473
|
+
if (authCode && sharedId) {
|
|
474
|
+
completeOnboarding(authCode, sharedId);
|
|
475
|
+
}
|
|
476
|
+
};
|
|
477
|
+
window.addEventListener("message", onMessage);
|
|
478
|
+
return () => window.removeEventListener("message", onMessage);
|
|
479
|
+
}, [completeOnboarding]);
|
|
480
|
+
// Status check + link generation. Gated on envReady so we always use the
|
|
481
|
+
// correct environment. runId guards against a stale response from a previous
|
|
482
|
+
// environment overwriting the current one.
|
|
483
|
+
(0, react_1.useEffect)(() => {
|
|
484
|
+
if (!envReady)
|
|
485
|
+
return;
|
|
197
486
|
currentRunId.current = ++runIdRef.current;
|
|
198
487
|
const runId = currentRunId.current;
|
|
199
488
|
let cancelled = false;
|
|
489
|
+
completedRef.current = false;
|
|
200
490
|
const run = async () => {
|
|
201
491
|
setConnState("loading");
|
|
202
492
|
setError(null);
|
|
493
|
+
setPopupBlocked(false);
|
|
203
494
|
setFinalUrl("");
|
|
204
495
|
try {
|
|
205
496
|
const r = await fetch(`${STATUS_ENDPOINT}?environment=${env}`, {
|
|
@@ -220,89 +511,57 @@ function PayPalConnectionPage() {
|
|
|
220
511
|
catch (e) {
|
|
221
512
|
console.error(e);
|
|
222
513
|
}
|
|
223
|
-
if (
|
|
224
|
-
|
|
514
|
+
if (cancelled || runId !== currentRunId.current)
|
|
515
|
+
return;
|
|
516
|
+
const cached = readCachedUrl(env);
|
|
517
|
+
if (cached) {
|
|
518
|
+
setFinalUrl(cached);
|
|
225
519
|
setConnState("ready");
|
|
226
520
|
}
|
|
227
521
|
else {
|
|
228
|
-
fetchFreshLink(runId);
|
|
522
|
+
fetchFreshLink(runId, env);
|
|
229
523
|
}
|
|
230
524
|
};
|
|
231
525
|
run();
|
|
232
526
|
return () => {
|
|
233
527
|
cancelled = true;
|
|
234
|
-
currentRunId.current = 0;
|
|
235
528
|
};
|
|
236
|
-
}, [env, fetchFreshLink]);
|
|
529
|
+
}, [env, envReady, fetchFreshLink]);
|
|
530
|
+
// Bridge partner.js's callback into our single idempotent completion handler.
|
|
237
531
|
(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
|
-
}
|
|
532
|
+
window.onboardingCallback = (authCode, sharedId) => {
|
|
533
|
+
completeOnboarding(authCode, sharedId);
|
|
301
534
|
};
|
|
302
535
|
return () => {
|
|
303
536
|
window.onboardingCallback = undefined;
|
|
304
537
|
};
|
|
305
|
-
}, [
|
|
538
|
+
}, [completeOnboarding]);
|
|
539
|
+
// Clean up the popup poll on unmount.
|
|
540
|
+
(0, react_1.useEffect)(() => {
|
|
541
|
+
return () => {
|
|
542
|
+
stopPopupPoll();
|
|
543
|
+
};
|
|
544
|
+
}, [stopPopupPoll]);
|
|
545
|
+
// Capture-phase click interceptor: guarantees WE own the popup. Running in the
|
|
546
|
+
// capture phase on the document means we execute before partner.js's own click
|
|
547
|
+
// handler, so we can suppress it (no duplicate window) and open the popup
|
|
548
|
+
// ourselves within the same user gesture. This is what makes the first click
|
|
549
|
+
// after a cold load reliably open a popup instead of a new tab.
|
|
550
|
+
(0, react_1.useEffect)(() => {
|
|
551
|
+
const onCaptureClick = (e) => {
|
|
552
|
+
const btn = paypalButtonRef.current;
|
|
553
|
+
if (!btn)
|
|
554
|
+
return;
|
|
555
|
+
const target = e.target;
|
|
556
|
+
if (!target || !btn.contains(target))
|
|
557
|
+
return;
|
|
558
|
+
e.preventDefault();
|
|
559
|
+
e.stopImmediatePropagation();
|
|
560
|
+
openConnect();
|
|
561
|
+
};
|
|
562
|
+
document.addEventListener("click", onCaptureClick, true);
|
|
563
|
+
return () => document.removeEventListener("click", onCaptureClick, true);
|
|
564
|
+
}, [openConnect]);
|
|
306
565
|
(0, react_1.useLayoutEffect)(() => {
|
|
307
566
|
const el = ppBtnMeasureRef.current;
|
|
308
567
|
if (!el)
|
|
@@ -328,10 +587,12 @@ function PayPalConnectionPage() {
|
|
|
328
587
|
window.removeEventListener("resize", update);
|
|
329
588
|
};
|
|
330
589
|
}, [connState, env, finalUrl]);
|
|
590
|
+
// Defensive fallback: the capture listener above normally handles the click
|
|
591
|
+
// and stops propagation before React sees it. If for any reason it didn't, we
|
|
592
|
+
// still prevent the native target from opening a tab and open the popup here.
|
|
331
593
|
const handleConnectClick = (e) => {
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
}
|
|
594
|
+
e.preventDefault();
|
|
595
|
+
openConnect();
|
|
335
596
|
};
|
|
336
597
|
const handleSaveManual = async () => {
|
|
337
598
|
if (!canSaveManual || onboardingInProgress)
|
|
@@ -362,10 +623,7 @@ function PayPalConnectionPage() {
|
|
|
362
623
|
setConnState("connected");
|
|
363
624
|
setStatusInfo(refreshedStatus || null);
|
|
364
625
|
setShowManual(false);
|
|
365
|
-
|
|
366
|
-
localStorage.removeItem(CACHE_KEY);
|
|
367
|
-
}
|
|
368
|
-
catch { }
|
|
626
|
+
clearCachedUrl(env);
|
|
369
627
|
}
|
|
370
628
|
catch (e) {
|
|
371
629
|
console.error(e);
|
|
@@ -398,13 +656,11 @@ function PayPalConnectionPage() {
|
|
|
398
656
|
const t = await res.text().catch(() => "");
|
|
399
657
|
throw new Error(t || `Disconnect failed (${res.status})`);
|
|
400
658
|
}
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
}
|
|
404
|
-
catch { }
|
|
659
|
+
clearCachedUrl(env);
|
|
660
|
+
completedRef.current = false;
|
|
405
661
|
currentRunId.current = ++runIdRef.current;
|
|
406
662
|
const runId = currentRunId.current;
|
|
407
|
-
fetchFreshLink(runId);
|
|
663
|
+
fetchFreshLink(runId, env);
|
|
408
664
|
}
|
|
409
665
|
catch (e) {
|
|
410
666
|
console.error(e);
|
|
@@ -417,10 +673,17 @@ function PayPalConnectionPage() {
|
|
|
417
673
|
};
|
|
418
674
|
const handleEnvChange = async (e) => {
|
|
419
675
|
const next = e.target.value;
|
|
676
|
+
completedRef.current = false;
|
|
677
|
+
setPopupBlocked(false);
|
|
678
|
+
// Drop both cached URLs so neither environment can serve a stale link.
|
|
679
|
+
clearCachedUrl();
|
|
680
|
+
// Update local state immediately (drives the status/link refetch with the
|
|
681
|
+
// explicit environment) and persist the choice. Because the link/status
|
|
682
|
+
// fetches are environment-explicit, correctness no longer depends on this
|
|
683
|
+
// POST winning a race.
|
|
420
684
|
setEnv(next);
|
|
421
|
-
cachedUrl = null;
|
|
422
685
|
try {
|
|
423
|
-
await fetch(
|
|
686
|
+
await fetch(ENVIRONMENT_ENDPOINT, {
|
|
424
687
|
method: "POST",
|
|
425
688
|
headers: { "content-type": "application/json" },
|
|
426
689
|
credentials: "include",
|
|
@@ -428,21 +691,17 @@ function PayPalConnectionPage() {
|
|
|
428
691
|
});
|
|
429
692
|
}
|
|
430
693
|
catch { }
|
|
431
|
-
try {
|
|
432
|
-
localStorage.removeItem(CACHE_KEY);
|
|
433
|
-
}
|
|
434
|
-
catch { }
|
|
435
694
|
};
|
|
436
695
|
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
696
|
? "Configuring connection to PayPal…"
|
|
438
697
|
: "Checking connection..." })] }), (0, jsx_runtime_1.jsxs)("div", { className: `${connState === "ready" ? "block" : "hidden"}`, children: [(0, jsx_runtime_1.jsx)("a", { ref: (node) => {
|
|
439
698
|
paypalButtonRef.current = node;
|
|
440
699
|
ppBtnMeasureRef.current = node;
|
|
441
|
-
}, id: "paypal-button", target:
|
|
700
|
+
}, 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
701
|
cursor: onboardingInProgress ? "not-allowed" : "pointer",
|
|
443
702
|
opacity: onboardingInProgress ? 0.6 : 1,
|
|
444
703
|
pointerEvents: onboardingInProgress ? "none" : "auto",
|
|
445
|
-
}, children: "Connect to PayPal" }), (0, jsx_runtime_1.jsx)("div", { className: "mt-2", style: {
|
|
704
|
+
}, 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
705
|
width: ppBtnWidth ? `${ppBtnWidth}px` : "auto",
|
|
447
706
|
marginTop: "20px",
|
|
448
707
|
marginBottom: "10px",
|