@easypayment/medusa-payment-paypal 0.9.17 → 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.
Files changed (44) hide show
  1. package/.medusa/server/src/admin/index.js +326 -130
  2. package/.medusa/server/src/admin/index.mjs +326 -130
  3. package/.medusa/server/src/admin/routes/settings/paypal/connection/page.d.ts +4 -1
  4. package/.medusa/server/src/admin/routes/settings/paypal/connection/page.d.ts.map +1 -1
  5. package/.medusa/server/src/admin/routes/settings/paypal/connection/page.js +434 -113
  6. package/.medusa/server/src/admin/routes/settings/paypal/connection/page.js.map +1 -1
  7. package/.medusa/server/src/api/admin/paypal/onboarding-link/route.d.ts.map +1 -1
  8. package/.medusa/server/src/api/admin/paypal/onboarding-link/route.js +4 -0
  9. package/.medusa/server/src/api/admin/paypal/onboarding-link/route.js.map +1 -1
  10. package/.medusa/server/src/api/middlewares.d.ts.map +1 -1
  11. package/.medusa/server/src/api/middlewares.js +9 -0
  12. package/.medusa/server/src/api/middlewares.js.map +1 -1
  13. package/.medusa/server/src/api/store/paypal/webhook/route.d.ts.map +1 -1
  14. package/.medusa/server/src/api/store/paypal/webhook/route.js +13 -8
  15. package/.medusa/server/src/api/store/paypal/webhook/route.js.map +1 -1
  16. package/.medusa/server/src/modules/paypal/payment-provider/service.d.ts.map +1 -1
  17. package/.medusa/server/src/modules/paypal/payment-provider/service.js +54 -31
  18. package/.medusa/server/src/modules/paypal/payment-provider/service.js.map +1 -1
  19. package/.medusa/server/src/modules/paypal/payment-provider/status-utils.d.ts +32 -0
  20. package/.medusa/server/src/modules/paypal/payment-provider/status-utils.d.ts.map +1 -0
  21. package/.medusa/server/src/modules/paypal/payment-provider/status-utils.js +65 -0
  22. package/.medusa/server/src/modules/paypal/payment-provider/status-utils.js.map +1 -0
  23. package/.medusa/server/src/modules/paypal/service.d.ts +13 -1
  24. package/.medusa/server/src/modules/paypal/service.d.ts.map +1 -1
  25. package/.medusa/server/src/modules/paypal/service.js +19 -4
  26. package/.medusa/server/src/modules/paypal/service.js.map +1 -1
  27. package/.medusa/server/src/modules/paypal/utils/secret-crypto.d.ts +2 -1
  28. package/.medusa/server/src/modules/paypal/utils/secret-crypto.d.ts.map +1 -1
  29. package/.medusa/server/src/modules/paypal/utils/secret-crypto.js +82 -26
  30. package/.medusa/server/src/modules/paypal/utils/secret-crypto.js.map +1 -1
  31. package/.medusa/server/src/modules/paypal/utils/webhook-verify.d.ts +28 -0
  32. package/.medusa/server/src/modules/paypal/utils/webhook-verify.d.ts.map +1 -0
  33. package/.medusa/server/src/modules/paypal/utils/webhook-verify.js +58 -0
  34. package/.medusa/server/src/modules/paypal/utils/webhook-verify.js.map +1 -0
  35. package/package.json +1 -1
  36. package/src/admin/routes/settings/paypal/connection/page.tsx +471 -127
  37. package/src/api/admin/paypal/onboarding-link/route.ts +6 -0
  38. package/src/api/middlewares.ts +9 -0
  39. package/src/api/store/paypal/webhook/route.ts +22 -8
  40. package/src/modules/paypal/payment-provider/service.ts +73 -29
  41. package/src/modules/paypal/payment-provider/status-utils.ts +59 -0
  42. package/src/modules/paypal/service.ts +24 -5
  43. package/src/modules/paypal/utils/secret-crypto.ts +90 -27
  44. package/src/modules/paypal/utils/webhook-verify.ts +61 -0
@@ -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
- const CACHE_KEY = "pp_onboard_cache";
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
- let cachedUrl = null;
27
- if (typeof window !== "undefined") {
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 cached = localStorage.getItem(CACHE_KEY);
30
- if (cached) {
31
- const data = JSON.parse(cached);
32
- if (new Date().getTime() - data.ts < CACHE_EXPIRY) {
33
- cachedUrl = data.url;
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
- catch (e) {
38
- console.error("Cache read error:", e);
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
- (0, react_1.useEffect)(() => {
44
- fetch("/admin/paypal/environment", { method: "GET", credentials: "include" })
45
- .then((r) => r.json())
46
- .then((d) => {
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 fetchFreshLink = (0, react_1.useCallback)((runId) => {
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
- localStorage.setItem(CACHE_KEY, JSON.stringify({ url, ts: Date.now() }));
358
+ writeCachedUrl(targetEnv, url);
103
359
  setFinalUrl(url);
104
360
  setConnState("ready");
105
361
  })
@@ -108,36 +364,133 @@ function PayPalConnectionPage() {
108
364
  return;
109
365
  showError("Unable to connect to service.");
110
366
  });
111
- }, [env, showError]);
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];
399
+ let cancelled = false;
400
+ let pollTimer;
401
+ const initPartner = (attempt = 0) => {
402
+ if (cancelled)
403
+ return;
404
+ const signup = window.PAYPAL?.apps?.Signup;
405
+ const target = signup?.miniBrowser && typeof signup.miniBrowser.init === "function"
406
+ ? signup.miniBrowser
407
+ : signup?.MiniBrowser && typeof signup.MiniBrowser.init === "function"
408
+ ? signup.MiniBrowser
409
+ : null;
410
+ if (target?.init) {
411
+ try {
412
+ target.init();
413
+ }
414
+ catch (e) {
415
+ console.error("[paypal] partner.js init failed:", e);
416
+ }
417
+ return;
418
+ }
419
+ if (typeof signup?.render === "function") {
420
+ try {
421
+ signup.render();
422
+ }
423
+ catch (e) {
424
+ console.error("[paypal] partner.js render failed:", e);
425
+ }
426
+ return;
427
+ }
428
+ if (attempt < 40) {
429
+ pollTimer = setTimeout(() => initPartner(attempt + 1), 50);
430
+ return;
431
+ }
432
+ try {
433
+ document.dispatchEvent(new Event("DOMContentLoaded"));
434
+ }
435
+ catch { }
436
+ };
437
+ // Remove any stale copy + namespace so the fresh load re-registers against
438
+ // the current environment (sandbox vs live partner.js differ).
116
439
  const existingScript = document.getElementById("paypal-partner-js");
117
440
  if (existingScript) {
118
441
  existingScript.parentNode?.removeChild(existingScript);
119
442
  }
120
443
  if (window.PAYPAL?.apps?.Signup) {
121
- delete window.PAYPAL.apps.Signup;
444
+ try {
445
+ delete window.PAYPAL.apps.Signup;
446
+ }
447
+ catch { }
122
448
  }
123
449
  const ppScript = document.createElement("script");
124
450
  ppScript.id = "paypal-partner-js";
125
451
  ppScript.src = scriptUrl;
126
452
  ppScript.async = true;
453
+ ppScript.onload = () => initPartner();
127
454
  document.body.appendChild(ppScript);
128
455
  return () => {
456
+ cancelled = true;
457
+ if (pollTimer) {
458
+ clearTimeout(pollTimer);
459
+ }
129
460
  if (ppScript.parentNode) {
130
461
  ppScript.parentNode.removeChild(ppScript);
131
462
  }
132
463
  };
133
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.
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.
134
483
  (0, react_1.useEffect)(() => {
484
+ if (!envReady)
485
+ return;
135
486
  currentRunId.current = ++runIdRef.current;
136
487
  const runId = currentRunId.current;
137
488
  let cancelled = false;
489
+ completedRef.current = false;
138
490
  const run = async () => {
139
491
  setConnState("loading");
140
492
  setError(null);
493
+ setPopupBlocked(false);
141
494
  setFinalUrl("");
142
495
  try {
143
496
  const r = await fetch(`${STATUS_ENDPOINT}?environment=${env}`, {
@@ -158,89 +511,57 @@ function PayPalConnectionPage() {
158
511
  catch (e) {
159
512
  console.error(e);
160
513
  }
161
- if (cachedUrl) {
162
- setFinalUrl(cachedUrl);
514
+ if (cancelled || runId !== currentRunId.current)
515
+ return;
516
+ const cached = readCachedUrl(env);
517
+ if (cached) {
518
+ setFinalUrl(cached);
163
519
  setConnState("ready");
164
520
  }
165
521
  else {
166
- fetchFreshLink(runId);
522
+ fetchFreshLink(runId, env);
167
523
  }
168
524
  };
169
525
  run();
170
526
  return () => {
171
527
  cancelled = true;
172
- currentRunId.current = 0;
173
528
  };
174
- }, [env, fetchFreshLink]);
529
+ }, [env, envReady, fetchFreshLink]);
530
+ // Bridge partner.js's callback into our single idempotent completion handler.
175
531
  (0, react_1.useLayoutEffect)(() => {
176
- window.onboardingCallback = async function (authCode, sharedId) {
177
- try {
178
- window.onbeforeunload = null;
179
- }
180
- catch { }
181
- setOnboardingInProgress(true);
182
- setConnState("loading");
183
- setError(null);
184
- const payload = {
185
- authCode,
186
- sharedId,
187
- env: env === "sandbox" ? "sandbox" : "live",
188
- };
189
- try {
190
- const res = await fetch(ONBOARDING_COMPLETE_ENDPOINT, {
191
- method: "POST",
192
- headers: { "content-type": "application/json" },
193
- credentials: "include",
194
- body: JSON.stringify(payload),
195
- });
196
- if (!res.ok) {
197
- const txt = await res.text().catch(() => "");
198
- throw new Error(txt || `Onboarding exchange failed (${res.status})`);
199
- }
200
- try {
201
- const close1 = window.PAYPAL?.apps?.Signup?.MiniBrowser?.closeFlow;
202
- if (typeof close1 === "function")
203
- close1();
204
- }
205
- catch { }
206
- try {
207
- const close2 = window.PAYPAL?.apps?.Signup?.miniBrowser &&
208
- window.PAYPAL.apps.Signup.miniBrowser.closeFlow;
209
- if (typeof close2 === "function")
210
- close2();
211
- }
212
- catch { }
213
- try {
214
- localStorage.removeItem(CACHE_KEY);
215
- }
216
- catch { }
217
- try {
218
- const statusRes = await fetch(`${STATUS_ENDPOINT}?environment=${env}`, {
219
- method: "GET",
220
- credentials: "include",
221
- });
222
- const refreshedStatus = await statusRes.json().catch(() => ({}));
223
- setStatusInfo(refreshedStatus || null);
224
- setConnState("connected");
225
- setShowManual(false);
226
- }
227
- catch {
228
- setConnState("connected");
229
- setShowManual(false);
230
- }
231
- setOnboardingInProgress(false);
232
- }
233
- catch (e) {
234
- console.error(e);
235
- setConnState("error");
236
- setError(e?.message || "Exchange failed while saving credentials.");
237
- setOnboardingInProgress(false);
238
- }
532
+ window.onboardingCallback = (authCode, sharedId) => {
533
+ completeOnboarding(authCode, sharedId);
239
534
  };
240
535
  return () => {
241
536
  window.onboardingCallback = undefined;
242
537
  };
243
- }, [env]);
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]);
244
565
  (0, react_1.useLayoutEffect)(() => {
245
566
  const el = ppBtnMeasureRef.current;
246
567
  if (!el)
@@ -266,10 +587,12 @@ function PayPalConnectionPage() {
266
587
  window.removeEventListener("resize", update);
267
588
  };
268
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.
269
593
  const handleConnectClick = (e) => {
270
- if (connState !== "ready" || !finalUrl || onboardingInProgress) {
271
- e.preventDefault();
272
- }
594
+ e.preventDefault();
595
+ openConnect();
273
596
  };
274
597
  const handleSaveManual = async () => {
275
598
  if (!canSaveManual || onboardingInProgress)
@@ -300,10 +623,7 @@ function PayPalConnectionPage() {
300
623
  setConnState("connected");
301
624
  setStatusInfo(refreshedStatus || null);
302
625
  setShowManual(false);
303
- try {
304
- localStorage.removeItem(CACHE_KEY);
305
- }
306
- catch { }
626
+ clearCachedUrl(env);
307
627
  }
308
628
  catch (e) {
309
629
  console.error(e);
@@ -336,13 +656,11 @@ function PayPalConnectionPage() {
336
656
  const t = await res.text().catch(() => "");
337
657
  throw new Error(t || `Disconnect failed (${res.status})`);
338
658
  }
339
- try {
340
- localStorage.removeItem(CACHE_KEY);
341
- }
342
- catch { }
659
+ clearCachedUrl(env);
660
+ completedRef.current = false;
343
661
  currentRunId.current = ++runIdRef.current;
344
662
  const runId = currentRunId.current;
345
- fetchFreshLink(runId);
663
+ fetchFreshLink(runId, env);
346
664
  }
347
665
  catch (e) {
348
666
  console.error(e);
@@ -355,10 +673,17 @@ function PayPalConnectionPage() {
355
673
  };
356
674
  const handleEnvChange = async (e) => {
357
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.
358
684
  setEnv(next);
359
- cachedUrl = null;
360
685
  try {
361
- await fetch("/admin/paypal/environment", {
686
+ await fetch(ENVIRONMENT_ENDPOINT, {
362
687
  method: "POST",
363
688
  headers: { "content-type": "application/json" },
364
689
  credentials: "include",
@@ -366,21 +691,17 @@ function PayPalConnectionPage() {
366
691
  });
367
692
  }
368
693
  catch { }
369
- try {
370
- localStorage.removeItem(CACHE_KEY);
371
- }
372
- catch { }
373
694
  };
374
- 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.jsxs)("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.jsx)("a", { target: "_blank", "data-paypal-button": "true", "data-paypal-onboard-complete": "onboardingCallback", href: "#", style: { display: "none" }, children: "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
+ 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
375
696
  ? "Configuring connection to PayPal…"
376
697
  : "Checking connection..." })] }), (0, jsx_runtime_1.jsxs)("div", { className: `${connState === "ready" ? "block" : "hidden"}`, children: [(0, jsx_runtime_1.jsx)("a", { ref: (node) => {
377
698
  paypalButtonRef.current = node;
378
699
  ppBtnMeasureRef.current = node;
379
- }, id: "paypal-button", target: "_blank", "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: {
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: {
380
701
  cursor: onboardingInProgress ? "not-allowed" : "pointer",
381
702
  opacity: onboardingInProgress ? 0.6 : 1,
382
703
  pointerEvents: onboardingInProgress ? "none" : "auto",
383
- }, 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: {
384
705
  width: ppBtnWidth ? `${ppBtnWidth}px` : "auto",
385
706
  marginTop: "20px",
386
707
  marginBottom: "10px",