@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.
@@ -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,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 fetchFreshLink = (0, react_1.useCallback)((runId) => {
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
- localStorage.setItem(CACHE_KEY, JSON.stringify({ url, ts: Date.now() }));
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
- }, [env, showError]);
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
- // Remove any stale copy + namespace so the fresh load re-scans the current
169
- // button (href/environment may have changed since the last render).
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 (cachedUrl) {
224
- setFinalUrl(cachedUrl);
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 = async function (authCode, sharedId) {
239
- try {
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
- }, [env]);
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
- if (connState !== "ready" || !finalUrl || onboardingInProgress) {
333
- e.preventDefault();
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
- try {
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
- try {
402
- localStorage.removeItem(CACHE_KEY);
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("/admin/paypal/environment", {
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: "_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: {
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",