@easypayment/medusa-payment-paypal 0.9.21 → 0.9.24

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 (40) hide show
  1. package/.medusa/server/src/admin/index.js +258 -214
  2. package/.medusa/server/src/admin/index.mjs +258 -214
  3. package/.medusa/server/src/admin/routes/settings/paypal/connection/page.d.ts.map +1 -1
  4. package/.medusa/server/src/admin/routes/settings/paypal/connection/page.js +210 -223
  5. package/.medusa/server/src/admin/routes/settings/paypal/connection/page.js.map +1 -1
  6. package/.medusa/server/src/api/admin/payment-collections/[id]/payment-sessions/route.d.ts.map +1 -1
  7. package/.medusa/server/src/api/admin/payment-collections/[id]/payment-sessions/route.js +6 -8
  8. package/.medusa/server/src/api/admin/payment-collections/[id]/payment-sessions/route.js.map +1 -1
  9. package/.medusa/server/src/api/admin/paypal/environment/route.d.ts.map +1 -1
  10. package/.medusa/server/src/api/admin/paypal/environment/route.js +11 -3
  11. package/.medusa/server/src/api/admin/paypal/environment/route.js.map +1 -1
  12. package/.medusa/server/src/api/admin/paypal/onboarding-status/route.d.ts.map +1 -1
  13. package/.medusa/server/src/api/admin/paypal/onboarding-status/route.js +4 -1
  14. package/.medusa/server/src/api/admin/paypal/onboarding-status/route.js.map +1 -1
  15. package/.medusa/server/src/api/store/payment-collections/[id]/payment-sessions/route.d.ts.map +1 -1
  16. package/.medusa/server/src/api/store/payment-collections/[id]/payment-sessions/route.js +6 -8
  17. package/.medusa/server/src/api/store/payment-collections/[id]/payment-sessions/route.js.map +1 -1
  18. package/.medusa/server/src/api/store/paypal/capture-order/route.d.ts.map +1 -1
  19. package/.medusa/server/src/api/store/paypal/capture-order/route.js +11 -0
  20. package/.medusa/server/src/api/store/paypal/capture-order/route.js.map +1 -1
  21. package/.medusa/server/src/api/store/paypal-complete/route.d.ts.map +1 -1
  22. package/.medusa/server/src/api/store/paypal-complete/route.js +2 -4
  23. package/.medusa/server/src/api/store/paypal-complete/route.js.map +1 -1
  24. package/.medusa/server/src/modules/paypal/payment-provider/card-service.d.ts.map +1 -1
  25. package/.medusa/server/src/modules/paypal/payment-provider/card-service.js +55 -34
  26. package/.medusa/server/src/modules/paypal/payment-provider/card-service.js.map +1 -1
  27. package/.medusa/server/src/modules/paypal/utils/core-workflow.d.ts +32 -0
  28. package/.medusa/server/src/modules/paypal/utils/core-workflow.d.ts.map +1 -0
  29. package/.medusa/server/src/modules/paypal/utils/core-workflow.js +40 -0
  30. package/.medusa/server/src/modules/paypal/utils/core-workflow.js.map +1 -0
  31. package/package.json +1 -1
  32. package/src/admin/routes/settings/paypal/connection/page.tsx +273 -293
  33. package/src/api/admin/payment-collections/[id]/payment-sessions/route.ts +6 -8
  34. package/src/api/admin/paypal/environment/route.ts +10 -3
  35. package/src/api/admin/paypal/onboarding-status/route.ts +4 -1
  36. package/src/api/store/payment-collections/[id]/payment-sessions/route.ts +6 -8
  37. package/src/api/store/paypal/capture-order/route.ts +15 -0
  38. package/src/api/store/paypal-complete/route.ts +6 -4
  39. package/src/modules/paypal/payment-provider/card-service.ts +72 -38
  40. package/src/modules/paypal/utils/core-workflow.ts +46 -0
@@ -17,26 +17,17 @@ 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
- // We open the mini-browser popup ourselves, synchronously inside the click
21
- // gesture (see openOnboardingPopup) — the only reliable way to get a popup
22
- // rather than a blocked window or a new tab. The window name matches the
23
- // anchor's `target` and PayPal's conventional "PPFrame": partner.js opens the
24
- // flow with `window.open(url, target, ...)`, so when it runs right after us it
25
- // *reuses this already-open window* (a named-window navigation needs no user
26
- // activation) instead of trying — and failing, asynchronously — to spawn its
27
- // own. That lets partner.js still wire its completion bridge without ever
28
- // opening a second/late window.
20
+ // PayPal's conventional mini-browser window name (also the anchor target).
29
21
  const POPUP_NAME = "PPFrame";
22
+ // The onboarding link is cached in the browser for 6 hours so it is always
23
+ // present for the mini-browser flow without regenerating on every load.
30
24
  const CACHE_PREFIX = "pp_onboard_cache";
31
- const CACHE_EXPIRY = 10 * 60 * 1000;
25
+ const CACHE_EXPIRY = 6 * 60 * 60 * 1000; // 6 hours
32
26
  const ONBOARDING_COMPLETE_ENDPOINT = "/admin/paypal/onboard-complete";
33
27
  const STATUS_ENDPOINT = "/admin/paypal/status";
34
28
  const SAVE_CREDENTIALS_ENDPOINT = "/admin/paypal/save-credentials";
35
29
  const DISCONNECT_ENDPOINT = "/admin/paypal/disconnect";
36
30
  const ENVIRONMENT_ENDPOINT = "/admin/paypal/environment";
37
- // Onboarding URLs are environment-specific and short-lived, so the cache must be
38
- // keyed per environment — a single shared key let a stale sandbox URL leak into
39
- // the live flow (and vice-versa) after a reload.
40
31
  const cacheKeyFor = (env) => `${CACHE_PREFIX}_${env}`;
41
32
  function readCachedUrl(env) {
42
33
  if (typeof window === "undefined")
@@ -60,9 +51,10 @@ function readCachedUrl(env) {
60
51
  function writeCachedUrl(env, url) {
61
52
  try {
62
53
  localStorage.setItem(cacheKeyFor(env), JSON.stringify({ url, ts: Date.now() }));
54
+ return readCachedUrl(env) === url;
63
55
  }
64
56
  catch {
65
- /* ignore quota / disabled storage */
57
+ return false;
66
58
  }
67
59
  }
68
60
  function clearCachedUrl(env) {
@@ -79,9 +71,6 @@ function clearCachedUrl(env) {
79
71
  /* ignore */
80
72
  }
81
73
  }
82
- // PayPal delivers the seamless-onboarding result by posting a message to
83
- // window.opener. We validate the sender is actually a PayPal origin before
84
- // trusting authCode/sharedId.
85
74
  function isPayPalOrigin(origin) {
86
75
  try {
87
76
  const host = new URL(origin).hostname.toLowerCase();
@@ -94,26 +83,31 @@ function isPayPalOrigin(origin) {
94
83
  return false;
95
84
  }
96
85
  }
97
- // The completion message shape is not strongly documented, so extract
98
- // authCode/sharedId defensively from objects, JSON strings, or query strings.
99
- // We only ever act when BOTH values are present, which keeps the many unrelated
100
- // intermediate postMessages PayPal emits from triggering a false completion.
86
+ // Defensively pull authCode/sharedId out of whatever shape PayPal posts back
87
+ // (object, JSON string, or query string; possibly nested). We only act when both
88
+ // are present, which ignores the many unrelated intermediate messages PayPal
89
+ // emits.
101
90
  function extractAuth(data) {
102
91
  if (!data)
103
92
  return {};
104
93
  if (typeof data === "object") {
105
94
  const obj = data;
106
- const authCode = obj.authCode ?? obj.auth_code ?? obj.authcode;
95
+ const authCode = obj.authCode ?? obj.auth_code ?? obj.authcode ?? obj.code;
107
96
  const sharedId = obj.sharedId ?? obj.shared_id ?? obj.sharedid;
108
97
  if (authCode && sharedId) {
109
98
  return { authCode: String(authCode), sharedId: String(sharedId) };
110
99
  }
111
- for (const key of ["data", "payload", "message", "detail", "body"]) {
100
+ for (const key of ["data", "payload", "message", "detail", "body", "params"]) {
112
101
  if (obj[key] && typeof obj[key] === "object") {
113
102
  const inner = extractAuth(obj[key]);
114
103
  if (inner.authCode && inner.sharedId)
115
104
  return inner;
116
105
  }
106
+ if (typeof obj[key] === "string") {
107
+ const inner = extractAuth(obj[key]);
108
+ if (inner.authCode && inner.sharedId)
109
+ return inner;
110
+ }
117
111
  }
118
112
  return {};
119
113
  }
@@ -121,7 +115,7 @@ function extractAuth(data) {
121
115
  const s = data.trim();
122
116
  if (!s)
123
117
  return {};
124
- if (s.startsWith("{")) {
118
+ if (s.startsWith("{") || s.startsWith("[")) {
125
119
  try {
126
120
  return extractAuth(JSON.parse(s));
127
121
  }
@@ -146,9 +140,6 @@ function extractAuth(data) {
146
140
  }
147
141
  function PayPalConnectionPage() {
148
142
  const [env, setEnv] = (0, react_1.useState)("live");
149
- // We must not generate/fetch an onboarding link until we know which
150
- // environment the server is actually on, otherwise the first fetch races the
151
- // GET /environment response and can target the wrong environment.
152
143
  const [envReady, setEnvReady] = (0, react_1.useState)(false);
153
144
  const [connState, setConnState] = (0, react_1.useState)("loading");
154
145
  const [error, setError] = (0, react_1.useState)(null);
@@ -164,20 +155,16 @@ function PayPalConnectionPage() {
164
155
  const errorLogRef = (0, react_1.useRef)(null);
165
156
  const runIdRef = (0, react_1.useRef)(0);
166
157
  const currentRunId = (0, react_1.useRef)(0);
167
- // Live mirrors of state so the document-level capture click listener (set up
168
- // once) and the stable completion callback always see current values.
158
+ const envRef = (0, react_1.useRef)(env);
159
+ envRef.current = env;
169
160
  const finalUrlRef = (0, react_1.useRef)(finalUrl);
170
161
  finalUrlRef.current = finalUrl;
171
162
  const connStateRef = (0, react_1.useRef)(connState);
172
163
  connStateRef.current = connState;
173
164
  const inProgressRef = (0, react_1.useRef)(onboardingInProgress);
174
165
  inProgressRef.current = onboardingInProgress;
175
- const envRef = (0, react_1.useRef)(env);
176
- envRef.current = env;
177
166
  const popupRef = (0, react_1.useRef)(null);
178
- const popupPollRef = (0, react_1.useRef)(null);
179
- // Guards against the two redundant completion paths (our own message listener
180
- // and partner.js's onboardingCallback bridge) both POSTing the same result.
167
+ const pollRef = (0, react_1.useRef)(null);
181
168
  const completedRef = (0, react_1.useRef)(false);
182
169
  const ppBtnMeasureRef = (0, react_1.useRef)(null);
183
170
  const [ppBtnWidth, setPpBtnWidth] = (0, react_1.useState)(null);
@@ -188,18 +175,16 @@ function PayPalConnectionPage() {
188
175
  setConnState("error");
189
176
  setError(msg);
190
177
  }, []);
191
- const stopPopupPoll = (0, react_1.useCallback)(() => {
192
- if (popupPollRef.current) {
193
- clearInterval(popupPollRef.current);
194
- popupPollRef.current = null;
178
+ const stopPoll = (0, react_1.useCallback)(() => {
179
+ if (pollRef.current) {
180
+ clearInterval(pollRef.current);
181
+ pollRef.current = null;
195
182
  }
196
183
  }, []);
197
- // Open the PayPal mini-browser ourselves, synchronously, inside the click
198
- // gesture. This is the crux of the fix: browsers only render window.open() as
199
- // a real popup (instead of a tab or blocking it) when it runs synchronously
200
- // in a user gesture. Because finalUrl is already in state at click time, we
201
- // never need an await before opening, and we never depend on winning a race
202
- // with partner.js's asynchronous click binding.
184
+ // Open PayPal's mini-browser ourselves, synchronously inside the click gesture.
185
+ // This is the only reliable way to get a popup (rather than a new tab, which is
186
+ // what happens when partner.js's asynchronous open misses the user-activation
187
+ // window). Named POPUP_NAME and sized like PayPal's mini-browser.
203
188
  const openOnboardingPopup = (0, react_1.useCallback)((url) => {
204
189
  const w = 450;
205
190
  const h = 600;
@@ -225,30 +210,23 @@ function PayPalConnectionPage() {
225
210
  catch {
226
211
  /* ignore */
227
212
  }
228
- stopPopupPoll();
229
- popupPollRef.current = setInterval(() => {
230
- if (!popupRef.current || popupRef.current.closed) {
231
- stopPopupPoll();
232
- }
233
- }, 1000);
234
213
  }
235
214
  return popup;
236
- }, [stopPopupPoll]);
237
- // Exchange the seller authCode/sharedId for credentials. Reached from BOTH
238
- // PayPal's partner.js bridge (window.onboardingCallback) and our own postMessage
239
- // listener; completedRef makes it idempotent so we only POST once.
215
+ }, []);
216
+ // Exchange authCode/sharedId for seller credentials. Idempotent so the multiple
217
+ // completion signals (our message listener, partner.js's bridge, the status
218
+ // poll) only run it once.
240
219
  const completeOnboarding = (0, react_1.useCallback)(async (authCode, sharedId) => {
241
220
  if (!authCode || !sharedId)
242
221
  return;
243
222
  if (completedRef.current)
244
223
  return;
245
224
  completedRef.current = true;
225
+ stopPoll();
246
226
  try {
247
227
  window.onbeforeunload = null;
248
228
  }
249
- catch {
250
- /* ignore */
251
- }
229
+ catch { }
252
230
  const activeEnv = envRef.current === "sandbox" ? "sandbox" : "live";
253
231
  setOnboardingInProgress(true);
254
232
  setConnState("loading");
@@ -265,45 +243,37 @@ function PayPalConnectionPage() {
265
243
  const txt = await res.text().catch(() => "");
266
244
  throw new Error(txt || `Onboarding exchange failed (${res.status})`);
267
245
  }
268
- // Close the popup we opened, plus any partner.js-managed flow.
269
246
  try {
270
247
  popupRef.current?.close();
271
248
  }
272
- catch {
273
- /* ignore */
274
- }
275
- stopPopupPoll();
249
+ catch { }
276
250
  try {
277
251
  const close1 = window.PAYPAL?.apps?.Signup?.MiniBrowser?.closeFlow;
278
252
  if (typeof close1 === "function")
279
253
  close1();
280
254
  }
281
- catch {
282
- /* ignore */
283
- }
255
+ catch { }
284
256
  try {
285
257
  const close2 = window.PAYPAL?.apps?.Signup?.miniBrowser &&
286
258
  window.PAYPAL.apps.Signup.miniBrowser.closeFlow;
287
259
  if (typeof close2 === "function")
288
260
  close2();
289
261
  }
290
- catch {
291
- /* ignore */
292
- }
262
+ catch { }
293
263
  clearCachedUrl(activeEnv);
294
264
  try {
295
- const statusRes = await fetch(`${STATUS_ENDPOINT}?environment=${activeEnv}`, { method: "GET", credentials: "include" });
265
+ const statusRes = await fetch(`${STATUS_ENDPOINT}?environment=${activeEnv}`, {
266
+ method: "GET",
267
+ credentials: "include",
268
+ });
296
269
  const refreshedStatus = await statusRes.json().catch(() => ({}));
297
270
  setStatusInfo(refreshedStatus || null);
298
271
  }
299
- catch {
300
- /* ignore — still consider it connected below */
301
- }
272
+ catch { }
302
273
  setConnState("connected");
303
274
  setShowManual(false);
304
275
  }
305
276
  catch (e) {
306
- // Allow a retry after a failed exchange.
307
277
  completedRef.current = false;
308
278
  console.error(e);
309
279
  setConnState("error");
@@ -312,30 +282,60 @@ function PayPalConnectionPage() {
312
282
  finally {
313
283
  setOnboardingInProgress(false);
314
284
  }
315
- }, [stopPopupPoll]);
316
- const fetchFreshLink = (0, react_1.useCallback)((runId, targetEnv) => {
285
+ }, [stopPoll]);
286
+ // After the popup is opened, poll the server. If the credentials get saved by
287
+ // any completion path, the status flips to "connected" and we sync the UI even
288
+ // if a message was missed.
289
+ const startStatusPoll = (0, react_1.useCallback)(() => {
290
+ stopPoll();
291
+ let ticks = 0;
292
+ pollRef.current = setInterval(async () => {
293
+ ticks += 1;
294
+ if (completedRef.current || ticks > 150 /* ~5 min */) {
295
+ stopPoll();
296
+ return;
297
+ }
298
+ try {
299
+ const r = await fetch(`${STATUS_ENDPOINT}?environment=${envRef.current}`, {
300
+ method: "GET",
301
+ credentials: "include",
302
+ });
303
+ const st = await r.json().catch(() => ({}));
304
+ if (st?.status === "connected" && st?.seller_client_id_present === true) {
305
+ completedRef.current = true;
306
+ stopPoll();
307
+ try {
308
+ popupRef.current?.close();
309
+ }
310
+ catch { }
311
+ clearCachedUrl(envRef.current);
312
+ setStatusInfo(st);
313
+ setConnState("connected");
314
+ setShowManual(false);
315
+ setOnboardingInProgress(false);
316
+ }
317
+ }
318
+ catch {
319
+ /* keep polling */
320
+ }
321
+ }, 2000);
322
+ }, [stopPoll]);
323
+ const generateLinkAndReload = (0, react_1.useCallback)(async (targetEnv, runId) => {
317
324
  if (initLoaderRef.current) {
318
325
  const loaderText = initLoaderRef.current.querySelector("#loader-text");
319
326
  if (loaderText)
320
327
  loaderText.textContent = "Generating onboarding session...";
321
328
  }
322
- fetch(SERVICE_URL, {
323
- method: "POST",
324
- headers: { "content-type": "application/json" },
325
- credentials: "include",
326
- body: JSON.stringify({
327
- products: ["PPCP"],
328
- // Generate the link for the explicitly selected environment so it can
329
- // never depend on a racing POST /environment having landed yet.
330
- environment: targetEnv,
331
- }),
332
- })
333
- .then((r) => {
334
- if (!r.ok)
335
- throw new Error(`Service returned ${r.status}`);
336
- return r.json();
337
- })
338
- .then((data) => {
329
+ try {
330
+ const res = await fetch(SERVICE_URL, {
331
+ method: "POST",
332
+ headers: { "content-type": "application/json" },
333
+ credentials: "include",
334
+ body: JSON.stringify({ products: ["PPCP"], environment: targetEnv }),
335
+ });
336
+ if (!res.ok)
337
+ throw new Error(`Service returned ${res.status}`);
338
+ const data = await res.json();
339
339
  if (runId !== currentRunId.current)
340
340
  return;
341
341
  const href = data?.onboarding_url;
@@ -344,17 +344,21 @@ function PayPalConnectionPage() {
344
344
  return;
345
345
  }
346
346
  const url = href + (href.includes("?") ? "&" : "?") + "displayMode=minibrowser";
347
- writeCachedUrl(targetEnv, url);
347
+ const persisted = writeCachedUrl(targetEnv, url);
348
+ if (persisted) {
349
+ window.location.reload();
350
+ return;
351
+ }
348
352
  setFinalUrl(url);
349
353
  setConnState("ready");
350
- })
351
- .catch(() => {
354
+ }
355
+ catch {
352
356
  if (runId !== currentRunId.current)
353
357
  return;
354
358
  showError("Unable to connect to service.");
355
- });
359
+ }
356
360
  }, [showError]);
357
- // Resolve the server's current environment before doing anything else.
361
+ // Resolve the server environment first.
358
362
  (0, react_1.useEffect)(() => {
359
363
  let alive = true;
360
364
  fetch(ENVIRONMENT_ENDPOINT, { method: "GET", credentials: "include" })
@@ -373,14 +377,56 @@ function PayPalConnectionPage() {
373
377
  alive = false;
374
378
  };
375
379
  }, []);
376
- // partner.js is loaded ONLY for its postMessage->callback bridge
377
- // (window.onboardingCallback), as a redundant path next to our own message
378
- // listener below. It is NO LONGER responsible for opening the window — we do
379
- // that synchronously in the click handler. We still init it so its message
380
- // bridge registers inside this React SPA (its own DOMContentLoaded hook fired
381
- // long before this route mounted). If partner.js also intercepts the click,
382
- // the shared POPUP_NAME means it re-targets our single popup rather than
383
- // opening a second window.
380
+ // Page-load flow: connected? -> cached link? -> else generate + cache + reload.
381
+ (0, react_1.useEffect)(() => {
382
+ if (!envReady)
383
+ return;
384
+ currentRunId.current = ++runIdRef.current;
385
+ const runId = currentRunId.current;
386
+ let cancelled = false;
387
+ completedRef.current = false;
388
+ const run = async () => {
389
+ setConnState("loading");
390
+ setError(null);
391
+ setPopupBlocked(false);
392
+ setFinalUrl("");
393
+ try {
394
+ const r = await fetch(`${STATUS_ENDPOINT}?environment=${env}`, {
395
+ method: "GET",
396
+ credentials: "include",
397
+ });
398
+ const st = await r.json().catch(() => ({}));
399
+ if (cancelled || runId !== currentRunId.current)
400
+ return;
401
+ setStatusInfo(st);
402
+ const isConnected = st?.status === "connected" && st?.seller_client_id_present === true;
403
+ if (isConnected) {
404
+ setConnState("connected");
405
+ setShowManual(false);
406
+ return;
407
+ }
408
+ }
409
+ catch (e) {
410
+ console.error(e);
411
+ }
412
+ if (cancelled || runId !== currentRunId.current)
413
+ return;
414
+ const cached = readCachedUrl(env);
415
+ if (cached) {
416
+ setFinalUrl(cached);
417
+ setConnState("ready");
418
+ return;
419
+ }
420
+ await generateLinkAndReload(env, runId);
421
+ };
422
+ run();
423
+ return () => {
424
+ cancelled = true;
425
+ };
426
+ }, [env, envReady, generateLinkAndReload]);
427
+ // Load partner.js + init for its postMessage->onboardingCallback bridge (a
428
+ // secondary completion path next to our own message listener). We open the
429
+ // popup ourselves, so partner.js is NOT relied on to open the window.
384
430
  (0, react_1.useEffect)(() => {
385
431
  if (connState !== "ready" || !finalUrl)
386
432
  return;
@@ -398,9 +444,6 @@ function PayPalConnectionPage() {
398
444
  : null;
399
445
  if (target?.init) {
400
446
  try {
401
- // Wires partner.js's click interceptor + its postMessage->callback
402
- // bridge. It will adopt the window we pre-open (same name) rather than
403
- // open its own.
404
447
  target.init();
405
448
  }
406
449
  catch (e) {
@@ -426,8 +469,6 @@ function PayPalConnectionPage() {
426
469
  }
427
470
  catch { }
428
471
  };
429
- // Remove any stale copy + namespace so the fresh load re-registers against
430
- // the current environment (sandbox vs live partner.js differ).
431
472
  const existingScript = document.getElementById("paypal-partner-js");
432
473
  if (existingScript) {
433
474
  existingScript.parentNode?.removeChild(existingScript);
@@ -446,19 +487,31 @@ function PayPalConnectionPage() {
446
487
  document.body.appendChild(ppScript);
447
488
  return () => {
448
489
  cancelled = true;
449
- if (pollTimer) {
490
+ if (pollTimer)
450
491
  clearTimeout(pollTimer);
451
- }
452
- if (ppScript.parentNode) {
492
+ if (ppScript.parentNode)
453
493
  ppScript.parentNode.removeChild(ppScript);
454
- }
455
494
  };
456
495
  }, [connState, finalUrl, env]);
457
- // Primary completion path: receive PayPal's seamless-onboarding postMessage
458
- // ourselves. Independent of partner.js, so it works even when we opened the
459
- // popup directly.
496
+ // PRIMARY completion path: receive PayPal's onboarding postMessage ourselves.
497
+ // The diagnostic log lets us confirm the exact origin/shape PayPal sends in a
498
+ // real run if a tweak to the parser is ever needed.
460
499
  (0, react_1.useEffect)(() => {
461
500
  const onMessage = (ev) => {
501
+ let serialized = "";
502
+ try {
503
+ serialized =
504
+ typeof ev.data === "string" ? ev.data : JSON.stringify(ev.data);
505
+ }
506
+ catch {
507
+ serialized = String(ev.data);
508
+ }
509
+ const looksRelevant = isPayPalOrigin(ev.origin) ||
510
+ /auth_?code|shared_?id|onboard|merchantId/i.test(serialized || "");
511
+ if (looksRelevant) {
512
+ // eslint-disable-next-line no-console
513
+ console.info("[PayPal onboarding] message from", ev.origin, "·", (serialized || "").slice(0, 500));
514
+ }
462
515
  if (!isPayPalOrigin(ev.origin))
463
516
  return;
464
517
  const { authCode, sharedId } = extractAuth(ev.data);
@@ -469,57 +522,7 @@ function PayPalConnectionPage() {
469
522
  window.addEventListener("message", onMessage);
470
523
  return () => window.removeEventListener("message", onMessage);
471
524
  }, [completeOnboarding]);
472
- // Status check + link generation. Gated on envReady so we always use the
473
- // correct environment. runId guards against a stale response from a previous
474
- // environment overwriting the current one.
475
- (0, react_1.useEffect)(() => {
476
- if (!envReady)
477
- return;
478
- currentRunId.current = ++runIdRef.current;
479
- const runId = currentRunId.current;
480
- let cancelled = false;
481
- completedRef.current = false;
482
- const run = async () => {
483
- setConnState("loading");
484
- setError(null);
485
- setPopupBlocked(false);
486
- setFinalUrl("");
487
- try {
488
- const r = await fetch(`${STATUS_ENDPOINT}?environment=${env}`, {
489
- method: "GET",
490
- credentials: "include",
491
- });
492
- const st = await r.json().catch(() => ({}));
493
- if (cancelled || runId !== currentRunId.current)
494
- return;
495
- setStatusInfo(st);
496
- const isConnected = st?.status === "connected" && st?.seller_client_id_present === true;
497
- if (isConnected) {
498
- setConnState("connected");
499
- setShowManual(false);
500
- return;
501
- }
502
- }
503
- catch (e) {
504
- console.error(e);
505
- }
506
- if (cancelled || runId !== currentRunId.current)
507
- return;
508
- const cached = readCachedUrl(env);
509
- if (cached) {
510
- setFinalUrl(cached);
511
- setConnState("ready");
512
- }
513
- else {
514
- fetchFreshLink(runId, env);
515
- }
516
- };
517
- run();
518
- return () => {
519
- cancelled = true;
520
- };
521
- }, [env, envReady, fetchFreshLink]);
522
- // Bridge partner.js's callback into our single idempotent completion handler.
525
+ // SECONDARY completion path: partner.js's bridge calls this by name.
523
526
  (0, react_1.useLayoutEffect)(() => {
524
527
  window.onboardingCallback = (authCode, sharedId) => {
525
528
  completeOnboarding(authCode, sharedId);
@@ -528,12 +531,39 @@ function PayPalConnectionPage() {
528
531
  window.onboardingCallback = undefined;
529
532
  };
530
533
  }, [completeOnboarding]);
531
- // Clean up the popup poll on unmount.
534
+ // Capture-phase opener: runs before partner.js, opens the popup synchronously,
535
+ // and stops partner.js's (unreliable, async) open + the native anchor so we get
536
+ // exactly one popup and never a new tab.
532
537
  (0, react_1.useEffect)(() => {
533
- return () => {
534
- stopPopupPoll();
538
+ const onCaptureClick = (e) => {
539
+ const btn = paypalButtonRef.current;
540
+ if (!btn)
541
+ return;
542
+ const target = e.target;
543
+ if (!target || !btn.contains(target))
544
+ return;
545
+ e.preventDefault();
546
+ e.stopImmediatePropagation();
547
+ if (connStateRef.current !== "ready" ||
548
+ !finalUrlRef.current ||
549
+ inProgressRef.current) {
550
+ return;
551
+ }
552
+ completedRef.current = false;
553
+ const popup = openOnboardingPopup(finalUrlRef.current);
554
+ if (!popup) {
555
+ setPopupBlocked(true);
556
+ return;
557
+ }
558
+ setPopupBlocked(false);
559
+ startStatusPoll();
535
560
  };
536
- }, [stopPopupPoll]);
561
+ document.addEventListener("click", onCaptureClick, true);
562
+ return () => document.removeEventListener("click", onCaptureClick, true);
563
+ }, [openOnboardingPopup, startStatusPoll]);
564
+ (0, react_1.useEffect)(() => {
565
+ return () => stopPoll();
566
+ }, [stopPoll]);
537
567
  (0, react_1.useLayoutEffect)(() => {
538
568
  const el = ppBtnMeasureRef.current;
539
569
  if (!el)
@@ -559,48 +589,8 @@ function PayPalConnectionPage() {
559
589
  window.removeEventListener("resize", update);
560
590
  };
561
591
  }, [connState, env, finalUrl]);
562
- // Capture-phase click handler the single, reliable opener.
563
- //
564
- // It runs on `document` in the CAPTURE phase, i.e. BEFORE the event reaches
565
- // the anchor where partner.js binds its own (target-phase) handler. That
566
- // ordering is the whole point:
567
- // 1. We synchronously open the mini-browser popup here, inside the user
568
- // gesture, so the browser always renders a real popup (never a blocked
569
- // window, never a new tab).
570
- // 2. We do NOT stop propagation, so partner.js's handler still runs a beat
571
- // later. Since it opens with the same window name (POPUP_NAME), it adopts
572
- // the window we just opened instead of asynchronously spawning its own
573
- // (which the browser was blocking — "multiple popups ... lack of user
574
- // activation") — and it still wires its postMessage->onboardingCallback
575
- // bridge that auto-closes the popup and saves credentials.
576
- // 3. We always preventDefault so the anchor's href can never open a tab.
577
- //
578
- // Completion is handled by partner.js's bridge AND by our own postMessage
579
- // listener (see below), funnelling through one idempotent completeOnboarding.
580
- (0, react_1.useEffect)(() => {
581
- const onCaptureClick = (e) => {
582
- const btn = paypalButtonRef.current;
583
- if (!btn)
584
- return;
585
- const target = e.target;
586
- if (!target || !btn.contains(target))
587
- return;
588
- e.preventDefault();
589
- if (connStateRef.current !== "ready" ||
590
- !finalUrlRef.current ||
591
- inProgressRef.current) {
592
- return;
593
- }
594
- completedRef.current = false;
595
- const popup = openOnboardingPopup(finalUrlRef.current);
596
- setPopupBlocked(!popup);
597
- };
598
- document.addEventListener("click", onCaptureClick, true);
599
- return () => document.removeEventListener("click", onCaptureClick, true);
600
- }, [openOnboardingPopup]);
601
- // Defensive only: the capture-phase listener above already opened the popup
602
- // and prevented the default. If for some reason it didn't run, make sure the
603
- // native target still can't open a tab.
592
+ // Defensive: the capture listener handles the click; this just guarantees the
593
+ // native href can never navigate.
604
594
  const handleConnectClick = (e) => {
605
595
  e.preventDefault();
606
596
  };
@@ -669,8 +659,7 @@ function PayPalConnectionPage() {
669
659
  clearCachedUrl(env);
670
660
  completedRef.current = false;
671
661
  currentRunId.current = ++runIdRef.current;
672
- const runId = currentRunId.current;
673
- fetchFreshLink(runId, env);
662
+ await generateLinkAndReload(env, currentRunId.current);
674
663
  }
675
664
  catch (e) {
676
665
  console.error(e);
@@ -683,15 +672,12 @@ function PayPalConnectionPage() {
683
672
  };
684
673
  const handleEnvChange = async (e) => {
685
674
  const next = e.target.value;
675
+ if (next === env || onboardingInProgress)
676
+ return;
686
677
  completedRef.current = false;
687
- setPopupBlocked(false);
688
- // Drop both cached URLs so neither environment can serve a stale link.
689
678
  clearCachedUrl();
690
- // Update local state immediately (drives the status/link refetch with the
691
- // explicit environment) and persist the choice. Because the link/status
692
- // fetches are environment-explicit, correctness no longer depends on this
693
- // POST winning a race.
694
- setEnv(next);
679
+ setPopupBlocked(false);
680
+ setConnState("loading");
695
681
  try {
696
682
  await fetch(ENVIRONMENT_ENDPOINT, {
697
683
  method: "POST",
@@ -701,6 +687,7 @@ function PayPalConnectionPage() {
701
687
  });
702
688
  }
703
689
  catch { }
690
+ setEnv(next);
704
691
  };
705
692
  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
706
693
  ? "Configuring connection to PayPal…"