@easypayment/medusa-payment-paypal 0.9.20 → 0.9.22

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,21 +17,25 @@ 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
- // 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";
20
+ // PayPal's conventional mini-browser window name; also used as the anchor's
21
+ // `target` so partner.js opens the flow in a popup.
22
+ const POPUP_NAME = "PPFrame";
23
+ // The onboarding link is cached in the browser for 6 hours. This is the heart of
24
+ // the integration: partner.js can only wire the mini-browser + completion
25
+ // callback if the anchor already carries a valid onboarding href when partner.js
26
+ // initializes. By caching the link (and reloading once to populate the cache on
27
+ // a cold start) the link is always present on page load, so partner.js can take
28
+ // over the click and run its flow exactly as documented.
25
29
  const CACHE_PREFIX = "pp_onboard_cache";
26
- const CACHE_EXPIRY = 10 * 60 * 1000;
30
+ const CACHE_EXPIRY = 6 * 60 * 60 * 1000; // 6 hours
27
31
  const ONBOARDING_COMPLETE_ENDPOINT = "/admin/paypal/onboard-complete";
28
32
  const STATUS_ENDPOINT = "/admin/paypal/status";
29
33
  const SAVE_CREDENTIALS_ENDPOINT = "/admin/paypal/save-credentials";
30
34
  const DISCONNECT_ENDPOINT = "/admin/paypal/disconnect";
31
35
  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.
36
+ // Onboarding URLs are environment-specific, so the cache is keyed per
37
+ // environment — a single shared key let a stale sandbox URL leak into the live
38
+ // flow (and vice-versa).
35
39
  const cacheKeyFor = (env) => `${CACHE_PREFIX}_${env}`;
36
40
  function readCachedUrl(env) {
37
41
  if (typeof window === "undefined")
@@ -52,12 +56,15 @@ function readCachedUrl(env) {
52
56
  }
53
57
  return null;
54
58
  }
59
+ // Returns true only if the value was actually persisted (storage enabled), which
60
+ // lets the caller decide whether reloading to pick up the cache is safe.
55
61
  function writeCachedUrl(env, url) {
56
62
  try {
57
63
  localStorage.setItem(cacheKeyFor(env), JSON.stringify({ url, ts: Date.now() }));
64
+ return readCachedUrl(env) === url;
58
65
  }
59
66
  catch {
60
- /* ignore quota / disabled storage */
67
+ return false;
61
68
  }
62
69
  }
63
70
  function clearCachedUrl(env) {
@@ -74,80 +81,18 @@ function clearCachedUrl(env) {
74
81
  /* ignore */
75
82
  }
76
83
  }
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;
111
- }
112
- }
113
- return {};
114
- }
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
- }
139
- }
140
- return {};
141
- }
142
84
  function PayPalConnectionPage() {
143
85
  const [env, setEnv] = (0, react_1.useState)("live");
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.
86
+ // We must know the server's environment before generating/looking up a link so
87
+ // the per-environment cache and the generated link always match.
147
88
  const [envReady, setEnvReady] = (0, react_1.useState)(false);
148
89
  const [connState, setConnState] = (0, react_1.useState)("loading");
90
+ // True once partner.js has loaded and bound its click interceptor to the
91
+ // button. The button stays in the DOM but is not clickable until then, so a
92
+ // click can never fall through to the native anchor (which would open a tab
93
+ // without partner.js's onboarding flow / completion callback).
94
+ const [partnerReady, setPartnerReady] = (0, react_1.useState)(false);
149
95
  const [error, setError] = (0, react_1.useState)(null);
150
- const [popupBlocked, setPopupBlocked] = (0, react_1.useState)(false);
151
96
  const [finalUrl, setFinalUrl] = (0, react_1.useState)("");
152
97
  const [showManual, setShowManual] = (0, react_1.useState)(false);
153
98
  const [clientId, setClientId] = (0, react_1.useState)("");
@@ -155,31 +100,14 @@ function PayPalConnectionPage() {
155
100
  const [statusInfo, setStatusInfo] = (0, react_1.useState)(null);
156
101
  const [onboardingInProgress, setOnboardingInProgress] = (0, react_1.useState)(false);
157
102
  const initLoaderRef = (0, react_1.useRef)(null);
158
- const paypalButtonRef = (0, react_1.useRef)(null);
159
103
  const errorLogRef = (0, react_1.useRef)(null);
160
104
  const runIdRef = (0, react_1.useRef)(0);
161
105
  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
106
  const envRef = (0, react_1.useRef)(env);
171
107
  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.
108
+ // Guards against partner.js's bridge invoking the completion callback more
109
+ // than once.
176
110
  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);
183
111
  const ppBtnMeasureRef = (0, react_1.useRef)(null);
184
112
  const [ppBtnWidth, setPpBtnWidth] = (0, react_1.useState)(null);
185
113
  const canSaveManual = (0, react_1.useMemo)(() => {
@@ -189,154 +117,27 @@ function PayPalConnectionPage() {
189
117
  setConnState("error");
190
118
  setError(msg);
191
119
  }, []);
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 */
120
+ // Generate a fresh onboarding link, cache it for 6 hours, then reload the page
121
+ // so that on the next load the cached link is already present for partner.js.
122
+ // If browser storage is unavailable we fall back to using the link in-place
123
+ // (no reload) so we can never get stuck in a reload loop.
124
+ const generateLinkAndReload = (0, react_1.useCallback)(async (targetEnv, runId) => {
125
+ if (initLoaderRef.current) {
126
+ const loaderText = initLoaderRef.current.querySelector("#loader-text");
127
+ if (loaderText) {
128
+ loaderText.textContent = "Generating onboarding session...";
228
129
  }
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
130
  }
253
- const activeEnv = envRef.current === "sandbox" ? "sandbox" : "live";
254
- setOnboardingInProgress(true);
255
- setConnState("loading");
256
- setError(null);
257
- setPopupBlocked(false);
258
131
  try {
259
- const res = await fetch(ONBOARDING_COMPLETE_ENDPOINT, {
132
+ const res = await fetch(SERVICE_URL, {
260
133
  method: "POST",
261
134
  headers: { "content-type": "application/json" },
262
135
  credentials: "include",
263
- body: JSON.stringify({ authCode, sharedId, env: activeEnv }),
136
+ body: JSON.stringify({ products: ["PPCP"], environment: targetEnv }),
264
137
  });
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) => {
318
- if (initLoaderRef.current) {
319
- const loaderText = initLoaderRef.current.querySelector("#loader-text");
320
- if (loaderText)
321
- loaderText.textContent = "Generating onboarding session...";
322
- }
323
- fetch(SERVICE_URL, {
324
- method: "POST",
325
- headers: { "content-type": "application/json" },
326
- credentials: "include",
327
- body: JSON.stringify({
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,
332
- }),
333
- })
334
- .then((r) => {
335
- if (!r.ok)
336
- throw new Error(`Service returned ${r.status}`);
337
- return r.json();
338
- })
339
- .then((data) => {
138
+ if (!res.ok)
139
+ throw new Error(`Service returned ${res.status}`);
140
+ const data = await res.json();
340
141
  if (runId !== currentRunId.current)
341
142
  return;
342
143
  const href = data?.onboarding_url;
@@ -345,17 +146,25 @@ function PayPalConnectionPage() {
345
146
  return;
346
147
  }
347
148
  const url = href + (href.includes("?") ? "&" : "?") + "displayMode=minibrowser";
348
- writeCachedUrl(targetEnv, url);
149
+ const persisted = writeCachedUrl(targetEnv, url);
150
+ if (persisted) {
151
+ // The cache survived; reload so the link is present at first render and
152
+ // partner.js initializes against it. The post-reload load finds the
153
+ // cache (no regeneration), so this can't loop.
154
+ window.location.reload();
155
+ return;
156
+ }
157
+ // Storage disabled — use the link directly without reloading.
349
158
  setFinalUrl(url);
350
159
  setConnState("ready");
351
- })
352
- .catch(() => {
160
+ }
161
+ catch {
353
162
  if (runId !== currentRunId.current)
354
163
  return;
355
164
  showError("Unable to connect to service.");
356
- });
165
+ }
357
166
  }, [showError]);
358
- // Resolve the server's current environment before doing anything else.
167
+ // Resolve the server environment first.
359
168
  (0, react_1.useEffect)(() => {
360
169
  let alive = true;
361
170
  fetch(ENVIRONMENT_ENDPOINT, { method: "GET", credentials: "include" })
@@ -374,20 +183,80 @@ function PayPalConnectionPage() {
374
183
  alive = false;
375
184
  };
376
185
  }, []);
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.
186
+ // Page-load flow:
187
+ // 1. If already connected, show the connected state.
188
+ // 2. Else if a valid cached link exists, use it (partner.js will bind it).
189
+ // 3. Else generate a link, cache it for 6h, and reload so the cached link is
190
+ // present for partner.js on the next load.
191
+ (0, react_1.useEffect)(() => {
192
+ if (!envReady)
193
+ return;
194
+ currentRunId.current = ++runIdRef.current;
195
+ const runId = currentRunId.current;
196
+ let cancelled = false;
197
+ completedRef.current = false;
198
+ const run = async () => {
199
+ setConnState("loading");
200
+ setError(null);
201
+ setFinalUrl("");
202
+ try {
203
+ const r = await fetch(`${STATUS_ENDPOINT}?environment=${env}`, {
204
+ method: "GET",
205
+ credentials: "include",
206
+ });
207
+ const st = await r.json().catch(() => ({}));
208
+ if (cancelled || runId !== currentRunId.current)
209
+ return;
210
+ setStatusInfo(st);
211
+ const isConnected = st?.status === "connected" && st?.seller_client_id_present === true;
212
+ if (isConnected) {
213
+ setConnState("connected");
214
+ setShowManual(false);
215
+ return;
216
+ }
217
+ }
218
+ catch (e) {
219
+ console.error(e);
220
+ }
221
+ if (cancelled || runId !== currentRunId.current)
222
+ return;
223
+ const cached = readCachedUrl(env);
224
+ if (cached) {
225
+ setFinalUrl(cached);
226
+ setConnState("ready");
227
+ return;
228
+ }
229
+ await generateLinkAndReload(env, runId);
230
+ };
231
+ run();
232
+ return () => {
233
+ cancelled = true;
234
+ };
235
+ }, [env, envReady, generateLinkAndReload]);
236
+ // Load partner.js once the anchor exists with its real (cached) href, then run
237
+ // PayPal's initializer. In a React SPA partner.js's own DOMContentLoaded hook
238
+ // already fired before this route mounted, so we must call init()/render()
239
+ // ourselves for it to discover the button and take over the click into the
240
+ // mini-browser — opening the popup, and wiring its postMessage bridge that
241
+ // invokes window.onboardingCallback and closes the popup on completion.
385
242
  (0, react_1.useEffect)(() => {
386
243
  if (connState !== "ready" || !finalUrl)
387
244
  return;
388
245
  const scriptUrl = PARTNER_JS_URLS[env];
389
246
  let cancelled = false;
390
247
  let pollTimer;
248
+ // The button is gated on partnerReady; reset it for this (re)load.
249
+ setPartnerReady(false);
250
+ // Safety net: if partner.js can't load (e.g. blocked), enable the button
251
+ // anyway after a few seconds rather than leaving it permanently disabled.
252
+ const readyFallback = setTimeout(() => {
253
+ if (!cancelled)
254
+ setPartnerReady(true);
255
+ }, 6000);
256
+ const markReady = () => {
257
+ if (!cancelled)
258
+ setPartnerReady(true);
259
+ };
391
260
  const initPartner = (attempt = 0) => {
392
261
  if (cancelled)
393
262
  return;
@@ -400,26 +269,25 @@ function PayPalConnectionPage() {
400
269
  if (target?.init) {
401
270
  try {
402
271
  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;
407
272
  }
408
273
  catch (e) {
409
274
  console.error("[paypal] partner.js init failed:", e);
410
275
  }
276
+ markReady();
411
277
  return;
412
278
  }
413
279
  if (typeof signup?.render === "function") {
414
280
  try {
415
281
  signup.render();
416
- partnerBoundRef.current = true;
417
282
  }
418
283
  catch (e) {
419
284
  console.error("[paypal] partner.js render failed:", e);
420
285
  }
286
+ markReady();
421
287
  return;
422
288
  }
289
+ // The namespace may not be populated the instant onload fires; retry
290
+ // briefly before giving up.
423
291
  if (attempt < 40) {
424
292
  pollTimer = setTimeout(() => initPartner(attempt + 1), 50);
425
293
  return;
@@ -428,10 +296,8 @@ function PayPalConnectionPage() {
428
296
  document.dispatchEvent(new Event("DOMContentLoaded"));
429
297
  }
430
298
  catch { }
299
+ markReady();
431
300
  };
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
301
  // Remove any stale copy + namespace so the fresh load re-registers against
436
302
  // the current environment (sandbox vs live partner.js differ).
437
303
  const existingScript = document.getElementById("paypal-partner-js");
@@ -449,9 +315,11 @@ function PayPalConnectionPage() {
449
315
  ppScript.src = scriptUrl;
450
316
  ppScript.async = true;
451
317
  ppScript.onload = () => initPartner();
318
+ ppScript.onerror = () => markReady();
452
319
  document.body.appendChild(ppScript);
453
320
  return () => {
454
321
  cancelled = true;
322
+ clearTimeout(readyFallback);
455
323
  if (pollTimer) {
456
324
  clearTimeout(pollTimer);
457
325
  }
@@ -460,72 +328,72 @@ function PayPalConnectionPage() {
460
328
  }
461
329
  };
462
330
  }, [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.
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)
331
+ // partner.js calls this (by the name in data-paypal-onboard-complete) when the
332
+ // seller finishes onboarding, passing the authCode + sharedId to exchange for
333
+ // seller credentials.
334
+ const completeOnboarding = (0, react_1.useCallback)(async (authCode, sharedId) => {
335
+ if (!authCode || !sharedId)
483
336
  return;
484
- currentRunId.current = ++runIdRef.current;
485
- const runId = currentRunId.current;
486
- let cancelled = false;
487
- completedRef.current = false;
488
- const run = async () => {
489
- setConnState("loading");
490
- setError(null);
491
- setPopupBlocked(false);
492
- setFinalUrl("");
337
+ if (completedRef.current)
338
+ return;
339
+ completedRef.current = true;
340
+ try {
341
+ window.onbeforeunload = null;
342
+ }
343
+ catch { }
344
+ const activeEnv = envRef.current === "sandbox" ? "sandbox" : "live";
345
+ setOnboardingInProgress(true);
346
+ setConnState("loading");
347
+ setError(null);
348
+ try {
349
+ const res = await fetch(ONBOARDING_COMPLETE_ENDPOINT, {
350
+ method: "POST",
351
+ headers: { "content-type": "application/json" },
352
+ credentials: "include",
353
+ body: JSON.stringify({ authCode, sharedId, env: activeEnv }),
354
+ });
355
+ if (!res.ok) {
356
+ const txt = await res.text().catch(() => "");
357
+ throw new Error(txt || `Onboarding exchange failed (${res.status})`);
358
+ }
359
+ // Close the partner.js mini-browser.
493
360
  try {
494
- const r = await fetch(`${STATUS_ENDPOINT}?environment=${env}`, {
361
+ const close1 = window.PAYPAL?.apps?.Signup?.MiniBrowser?.closeFlow;
362
+ if (typeof close1 === "function")
363
+ close1();
364
+ }
365
+ catch { }
366
+ try {
367
+ const close2 = window.PAYPAL?.apps?.Signup?.miniBrowser &&
368
+ window.PAYPAL.apps.Signup.miniBrowser.closeFlow;
369
+ if (typeof close2 === "function")
370
+ close2();
371
+ }
372
+ catch { }
373
+ // The link has been consumed; drop it so a future flow regenerates one.
374
+ clearCachedUrl(activeEnv);
375
+ try {
376
+ const statusRes = await fetch(`${STATUS_ENDPOINT}?environment=${activeEnv}`, {
495
377
  method: "GET",
496
378
  credentials: "include",
497
379
  });
498
- const st = await r.json().catch(() => ({}));
499
- if (cancelled || runId !== currentRunId.current)
500
- return;
501
- setStatusInfo(st);
502
- const isConnected = st?.status === "connected" && st?.seller_client_id_present === true;
503
- if (isConnected) {
504
- setConnState("connected");
505
- setShowManual(false);
506
- return;
507
- }
508
- }
509
- catch (e) {
510
- console.error(e);
511
- }
512
- if (cancelled || runId !== currentRunId.current)
513
- return;
514
- const cached = readCachedUrl(env);
515
- if (cached) {
516
- setFinalUrl(cached);
517
- setConnState("ready");
518
- }
519
- else {
520
- fetchFreshLink(runId, env);
380
+ const refreshedStatus = await statusRes.json().catch(() => ({}));
381
+ setStatusInfo(refreshedStatus || null);
521
382
  }
522
- };
523
- run();
524
- return () => {
525
- cancelled = true;
526
- };
527
- }, [env, envReady, fetchFreshLink]);
528
- // Bridge partner.js's callback into our single idempotent completion handler.
383
+ catch { }
384
+ setConnState("connected");
385
+ setShowManual(false);
386
+ }
387
+ catch (e) {
388
+ completedRef.current = false;
389
+ console.error(e);
390
+ setConnState("error");
391
+ setError(e?.message || "Exchange failed while saving credentials.");
392
+ }
393
+ finally {
394
+ setOnboardingInProgress(false);
395
+ }
396
+ }, []);
529
397
  (0, react_1.useLayoutEffect)(() => {
530
398
  window.onboardingCallback = (authCode, sharedId) => {
531
399
  completeOnboarding(authCode, sharedId);
@@ -534,12 +402,6 @@ function PayPalConnectionPage() {
534
402
  window.onboardingCallback = undefined;
535
403
  };
536
404
  }, [completeOnboarding]);
537
- // Clean up the popup poll on unmount.
538
- (0, react_1.useEffect)(() => {
539
- return () => {
540
- stopPopupPoll();
541
- };
542
- }, [stopPopupPoll]);
543
405
  (0, react_1.useLayoutEffect)(() => {
544
406
  const el = ppBtnMeasureRef.current;
545
407
  if (!el)
@@ -565,34 +427,6 @@ function PayPalConnectionPage() {
565
427
  window.removeEventListener("resize", update);
566
428
  };
567
429
  }, [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.
581
- const handleConnectClick = (e) => {
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;
591
- }
592
- completedRef.current = false;
593
- const popup = openOnboardingPopup(finalUrlRef.current);
594
- setPopupBlocked(!popup);
595
- };
596
430
  const handleSaveManual = async () => {
597
431
  if (!canSaveManual || onboardingInProgress)
598
432
  return;
@@ -657,9 +491,10 @@ function PayPalConnectionPage() {
657
491
  }
658
492
  clearCachedUrl(env);
659
493
  completedRef.current = false;
494
+ // Generate a fresh link for the now-disconnected environment and reload so
495
+ // the onboarding button is ready again.
660
496
  currentRunId.current = ++runIdRef.current;
661
- const runId = currentRunId.current;
662
- fetchFreshLink(runId, env);
497
+ await generateLinkAndReload(env, currentRunId.current);
663
498
  }
664
499
  catch (e) {
665
500
  console.error(e);
@@ -672,15 +507,13 @@ function PayPalConnectionPage() {
672
507
  };
673
508
  const handleEnvChange = async (e) => {
674
509
  const next = e.target.value;
510
+ if (next === env || onboardingInProgress)
511
+ return;
675
512
  completedRef.current = false;
676
- setPopupBlocked(false);
677
- // Drop both cached URLs so neither environment can serve a stale link.
678
513
  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.
683
- setEnv(next);
514
+ setConnState("loading");
515
+ // Persist the environment BEFORE switching local state so the post-reload
516
+ // GET /environment returns the same value the cache was keyed under.
684
517
  try {
685
518
  await fetch(ENVIRONMENT_ENDPOINT, {
686
519
  method: "POST",
@@ -690,17 +523,24 @@ function PayPalConnectionPage() {
690
523
  });
691
524
  }
692
525
  catch { }
526
+ setEnv(next);
693
527
  };
694
528
  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
695
529
  ? "Configuring connection to PayPal…"
696
530
  : "Checking connection..." })] }), (0, jsx_runtime_1.jsxs)("div", { className: `${connState === "ready" ? "block" : "hidden"}`, children: [(0, jsx_runtime_1.jsx)("a", { ref: (node) => {
697
- paypalButtonRef.current = node;
698
531
  ppBtnMeasureRef.current = node;
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: {
700
- cursor: onboardingInProgress ? "not-allowed" : "pointer",
701
- opacity: onboardingInProgress ? 0.6 : 1,
702
- pointerEvents: onboardingInProgress ? "none" : "auto",
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: {
532
+ }, id: "paypal-button", target: POPUP_NAME, "data-paypal-button": "true", href: finalUrl || "#", "data-paypal-onboard-complete": "onboardingCallback", "aria-disabled": onboardingInProgress || !partnerReady, 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: {
533
+ cursor: onboardingInProgress || !partnerReady
534
+ ? "not-allowed"
535
+ : "pointer",
536
+ opacity: onboardingInProgress || !partnerReady ? 0.6 : 1,
537
+ // Keep the anchor mounted (partner.js must find it to bind)
538
+ // but un-clickable until partner.js has taken over the
539
+ // click — so it can never open a tab via the native href.
540
+ pointerEvents: onboardingInProgress || !partnerReady ? "none" : "auto",
541
+ }, children: partnerReady || onboardingInProgress
542
+ ? "Connect to PayPal"
543
+ : "Preparing PayPal…" }), (0, jsx_runtime_1.jsx)("div", { className: "mt-2", style: {
704
544
  width: ppBtnWidth ? `${ppBtnWidth}px` : "auto",
705
545
  marginTop: "20px",
706
546
  marginBottom: "10px",