@easypayment/medusa-payment-paypal 0.9.21 → 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,26 +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
- // 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 used as the anchor's
21
+ // `target` so partner.js opens the flow in a popup.
29
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.
30
29
  const CACHE_PREFIX = "pp_onboard_cache";
31
- const CACHE_EXPIRY = 10 * 60 * 1000;
30
+ const CACHE_EXPIRY = 6 * 60 * 60 * 1000; // 6 hours
32
31
  const ONBOARDING_COMPLETE_ENDPOINT = "/admin/paypal/onboard-complete";
33
32
  const STATUS_ENDPOINT = "/admin/paypal/status";
34
33
  const SAVE_CREDENTIALS_ENDPOINT = "/admin/paypal/save-credentials";
35
34
  const DISCONNECT_ENDPOINT = "/admin/paypal/disconnect";
36
35
  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.
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).
40
39
  const cacheKeyFor = (env) => `${CACHE_PREFIX}_${env}`;
41
40
  function readCachedUrl(env) {
42
41
  if (typeof window === "undefined")
@@ -57,12 +56,15 @@ function readCachedUrl(env) {
57
56
  }
58
57
  return null;
59
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.
60
61
  function writeCachedUrl(env, url) {
61
62
  try {
62
63
  localStorage.setItem(cacheKeyFor(env), JSON.stringify({ url, ts: Date.now() }));
64
+ return readCachedUrl(env) === url;
63
65
  }
64
66
  catch {
65
- /* ignore quota / disabled storage */
67
+ return false;
66
68
  }
67
69
  }
68
70
  function clearCachedUrl(env) {
@@ -79,80 +81,18 @@ function clearCachedUrl(env) {
79
81
  /* ignore */
80
82
  }
81
83
  }
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
- function isPayPalOrigin(origin) {
86
- try {
87
- const host = new URL(origin).hostname.toLowerCase();
88
- return (host === "www.paypal.com" ||
89
- host === "www.sandbox.paypal.com" ||
90
- host.endsWith(".paypal.com") ||
91
- host.endsWith(".paypalobjects.com"));
92
- }
93
- catch {
94
- return false;
95
- }
96
- }
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.
101
- function extractAuth(data) {
102
- if (!data)
103
- return {};
104
- if (typeof data === "object") {
105
- const obj = data;
106
- const authCode = obj.authCode ?? obj.auth_code ?? obj.authcode;
107
- const sharedId = obj.sharedId ?? obj.shared_id ?? obj.sharedid;
108
- if (authCode && sharedId) {
109
- return { authCode: String(authCode), sharedId: String(sharedId) };
110
- }
111
- for (const key of ["data", "payload", "message", "detail", "body"]) {
112
- if (obj[key] && typeof obj[key] === "object") {
113
- const inner = extractAuth(obj[key]);
114
- if (inner.authCode && inner.sharedId)
115
- return inner;
116
- }
117
- }
118
- return {};
119
- }
120
- if (typeof data === "string") {
121
- const s = data.trim();
122
- if (!s)
123
- return {};
124
- if (s.startsWith("{")) {
125
- try {
126
- return extractAuth(JSON.parse(s));
127
- }
128
- catch {
129
- /* not JSON */
130
- }
131
- }
132
- if (/auth_?code/i.test(s) && /shared_?id/i.test(s)) {
133
- try {
134
- const sp = new URLSearchParams(s.replace(/^[?#]/, ""));
135
- const authCode = sp.get("authCode") || sp.get("auth_code") || undefined;
136
- const sharedId = sp.get("sharedId") || sp.get("shared_id") || undefined;
137
- if (authCode && sharedId)
138
- return { authCode, sharedId };
139
- }
140
- catch {
141
- /* not a query string */
142
- }
143
- }
144
- }
145
- return {};
146
- }
147
84
  function PayPalConnectionPage() {
148
85
  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.
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.
152
88
  const [envReady, setEnvReady] = (0, react_1.useState)(false);
153
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);
154
95
  const [error, setError] = (0, react_1.useState)(null);
155
- const [popupBlocked, setPopupBlocked] = (0, react_1.useState)(false);
156
96
  const [finalUrl, setFinalUrl] = (0, react_1.useState)("");
157
97
  const [showManual, setShowManual] = (0, react_1.useState)(false);
158
98
  const [clientId, setClientId] = (0, react_1.useState)("");
@@ -160,24 +100,13 @@ function PayPalConnectionPage() {
160
100
  const [statusInfo, setStatusInfo] = (0, react_1.useState)(null);
161
101
  const [onboardingInProgress, setOnboardingInProgress] = (0, react_1.useState)(false);
162
102
  const initLoaderRef = (0, react_1.useRef)(null);
163
- const paypalButtonRef = (0, react_1.useRef)(null);
164
103
  const errorLogRef = (0, react_1.useRef)(null);
165
104
  const runIdRef = (0, react_1.useRef)(0);
166
105
  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.
169
- const finalUrlRef = (0, react_1.useRef)(finalUrl);
170
- finalUrlRef.current = finalUrl;
171
- const connStateRef = (0, react_1.useRef)(connState);
172
- connStateRef.current = connState;
173
- const inProgressRef = (0, react_1.useRef)(onboardingInProgress);
174
- inProgressRef.current = onboardingInProgress;
175
106
  const envRef = (0, react_1.useRef)(env);
176
107
  envRef.current = env;
177
- 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.
108
+ // Guards against partner.js's bridge invoking the completion callback more
109
+ // than once.
181
110
  const completedRef = (0, react_1.useRef)(false);
182
111
  const ppBtnMeasureRef = (0, react_1.useRef)(null);
183
112
  const [ppBtnWidth, setPpBtnWidth] = (0, react_1.useState)(null);
@@ -188,154 +117,27 @@ function PayPalConnectionPage() {
188
117
  setConnState("error");
189
118
  setError(msg);
190
119
  }, []);
191
- const stopPopupPoll = (0, react_1.useCallback)(() => {
192
- if (popupPollRef.current) {
193
- clearInterval(popupPollRef.current);
194
- popupPollRef.current = null;
195
- }
196
- }, []);
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.
203
- const openOnboardingPopup = (0, react_1.useCallback)((url) => {
204
- const w = 450;
205
- const h = 600;
206
- const baseLeft = typeof window.screenX === "number" ? window.screenX : window.screenLeft || 0;
207
- const baseTop = typeof window.screenY === "number" ? window.screenY : window.screenTop || 0;
208
- const outerW = window.outerWidth || document.documentElement.clientWidth || 1024;
209
- const outerH = window.outerHeight || document.documentElement.clientHeight || 768;
210
- const left = Math.round(baseLeft + Math.max(0, (outerW - w) / 2));
211
- const top = Math.round(baseTop + Math.max(0, (outerH - h) / 2));
212
- const features = `popup=yes,width=${w},height=${h},left=${left},top=${top},scrollbars=yes,resizable=yes`;
213
- let popup = null;
214
- try {
215
- popup = window.open(url, POPUP_NAME, features);
216
- }
217
- catch {
218
- popup = null;
219
- }
220
- if (popup) {
221
- popupRef.current = popup;
222
- try {
223
- popup.focus();
224
- }
225
- catch {
226
- /* 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...";
227
129
  }
228
- stopPopupPoll();
229
- popupPollRef.current = setInterval(() => {
230
- if (!popupRef.current || popupRef.current.closed) {
231
- stopPopupPoll();
232
- }
233
- }, 1000);
234
- }
235
- 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.
240
- const completeOnboarding = (0, react_1.useCallback)(async (authCode, sharedId) => {
241
- if (!authCode || !sharedId)
242
- return;
243
- if (completedRef.current)
244
- return;
245
- completedRef.current = true;
246
- try {
247
- window.onbeforeunload = null;
248
- }
249
- catch {
250
- /* ignore */
251
130
  }
252
- const activeEnv = envRef.current === "sandbox" ? "sandbox" : "live";
253
- setOnboardingInProgress(true);
254
- setConnState("loading");
255
- setError(null);
256
- setPopupBlocked(false);
257
131
  try {
258
- const res = await fetch(ONBOARDING_COMPLETE_ENDPOINT, {
132
+ const res = await fetch(SERVICE_URL, {
259
133
  method: "POST",
260
134
  headers: { "content-type": "application/json" },
261
135
  credentials: "include",
262
- body: JSON.stringify({ authCode, sharedId, env: activeEnv }),
136
+ body: JSON.stringify({ products: ["PPCP"], environment: targetEnv }),
263
137
  });
264
- if (!res.ok) {
265
- const txt = await res.text().catch(() => "");
266
- throw new Error(txt || `Onboarding exchange failed (${res.status})`);
267
- }
268
- // Close the popup we opened, plus any partner.js-managed flow.
269
- try {
270
- popupRef.current?.close();
271
- }
272
- catch {
273
- /* ignore */
274
- }
275
- stopPopupPoll();
276
- try {
277
- const close1 = window.PAYPAL?.apps?.Signup?.MiniBrowser?.closeFlow;
278
- if (typeof close1 === "function")
279
- close1();
280
- }
281
- catch {
282
- /* ignore */
283
- }
284
- try {
285
- const close2 = window.PAYPAL?.apps?.Signup?.miniBrowser &&
286
- window.PAYPAL.apps.Signup.miniBrowser.closeFlow;
287
- if (typeof close2 === "function")
288
- close2();
289
- }
290
- catch {
291
- /* ignore */
292
- }
293
- clearCachedUrl(activeEnv);
294
- try {
295
- const statusRes = await fetch(`${STATUS_ENDPOINT}?environment=${activeEnv}`, { method: "GET", credentials: "include" });
296
- const refreshedStatus = await statusRes.json().catch(() => ({}));
297
- setStatusInfo(refreshedStatus || null);
298
- }
299
- catch {
300
- /* ignore — still consider it connected below */
301
- }
302
- setConnState("connected");
303
- setShowManual(false);
304
- }
305
- catch (e) {
306
- // Allow a retry after a failed exchange.
307
- completedRef.current = false;
308
- console.error(e);
309
- setConnState("error");
310
- setError(e?.message || "Exchange failed while saving credentials.");
311
- }
312
- finally {
313
- setOnboardingInProgress(false);
314
- }
315
- }, [stopPopupPoll]);
316
- const fetchFreshLink = (0, react_1.useCallback)((runId, targetEnv) => {
317
- if (initLoaderRef.current) {
318
- const loaderText = initLoaderRef.current.querySelector("#loader-text");
319
- if (loaderText)
320
- loaderText.textContent = "Generating onboarding session...";
321
- }
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) => {
138
+ if (!res.ok)
139
+ throw new Error(`Service returned ${res.status}`);
140
+ const data = await res.json();
339
141
  if (runId !== currentRunId.current)
340
142
  return;
341
143
  const href = data?.onboarding_url;
@@ -344,17 +146,25 @@ function PayPalConnectionPage() {
344
146
  return;
345
147
  }
346
148
  const url = href + (href.includes("?") ? "&" : "?") + "displayMode=minibrowser";
347
- 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.
348
158
  setFinalUrl(url);
349
159
  setConnState("ready");
350
- })
351
- .catch(() => {
160
+ }
161
+ catch {
352
162
  if (runId !== currentRunId.current)
353
163
  return;
354
164
  showError("Unable to connect to service.");
355
- });
165
+ }
356
166
  }, [showError]);
357
- // Resolve the server's current environment before doing anything else.
167
+ // Resolve the server environment first.
358
168
  (0, react_1.useEffect)(() => {
359
169
  let alive = true;
360
170
  fetch(ENVIRONMENT_ENDPOINT, { method: "GET", credentials: "include" })
@@ -373,20 +183,80 @@ function PayPalConnectionPage() {
373
183
  alive = false;
374
184
  };
375
185
  }, []);
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.
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.
384
242
  (0, react_1.useEffect)(() => {
385
243
  if (connState !== "ready" || !finalUrl)
386
244
  return;
387
245
  const scriptUrl = PARTNER_JS_URLS[env];
388
246
  let cancelled = false;
389
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
+ };
390
260
  const initPartner = (attempt = 0) => {
391
261
  if (cancelled)
392
262
  return;
@@ -398,14 +268,12 @@ function PayPalConnectionPage() {
398
268
  : null;
399
269
  if (target?.init) {
400
270
  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
271
  target.init();
405
272
  }
406
273
  catch (e) {
407
274
  console.error("[paypal] partner.js init failed:", e);
408
275
  }
276
+ markReady();
409
277
  return;
410
278
  }
411
279
  if (typeof signup?.render === "function") {
@@ -415,8 +283,11 @@ function PayPalConnectionPage() {
415
283
  catch (e) {
416
284
  console.error("[paypal] partner.js render failed:", e);
417
285
  }
286
+ markReady();
418
287
  return;
419
288
  }
289
+ // The namespace may not be populated the instant onload fires; retry
290
+ // briefly before giving up.
420
291
  if (attempt < 40) {
421
292
  pollTimer = setTimeout(() => initPartner(attempt + 1), 50);
422
293
  return;
@@ -425,6 +296,7 @@ function PayPalConnectionPage() {
425
296
  document.dispatchEvent(new Event("DOMContentLoaded"));
426
297
  }
427
298
  catch { }
299
+ markReady();
428
300
  };
429
301
  // Remove any stale copy + namespace so the fresh load re-registers against
430
302
  // the current environment (sandbox vs live partner.js differ).
@@ -443,9 +315,11 @@ function PayPalConnectionPage() {
443
315
  ppScript.src = scriptUrl;
444
316
  ppScript.async = true;
445
317
  ppScript.onload = () => initPartner();
318
+ ppScript.onerror = () => markReady();
446
319
  document.body.appendChild(ppScript);
447
320
  return () => {
448
321
  cancelled = true;
322
+ clearTimeout(readyFallback);
449
323
  if (pollTimer) {
450
324
  clearTimeout(pollTimer);
451
325
  }
@@ -454,72 +328,72 @@ function PayPalConnectionPage() {
454
328
  }
455
329
  };
456
330
  }, [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.
460
- (0, react_1.useEffect)(() => {
461
- const onMessage = (ev) => {
462
- if (!isPayPalOrigin(ev.origin))
463
- return;
464
- const { authCode, sharedId } = extractAuth(ev.data);
465
- if (authCode && sharedId) {
466
- completeOnboarding(authCode, sharedId);
467
- }
468
- };
469
- window.addEventListener("message", onMessage);
470
- return () => window.removeEventListener("message", onMessage);
471
- }, [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)
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)
477
336
  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("");
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.
487
360
  try {
488
- 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}`, {
489
377
  method: "GET",
490
378
  credentials: "include",
491
379
  });
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);
380
+ const refreshedStatus = await statusRes.json().catch(() => ({}));
381
+ setStatusInfo(refreshedStatus || null);
515
382
  }
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.
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
+ }, []);
523
397
  (0, react_1.useLayoutEffect)(() => {
524
398
  window.onboardingCallback = (authCode, sharedId) => {
525
399
  completeOnboarding(authCode, sharedId);
@@ -528,12 +402,6 @@ function PayPalConnectionPage() {
528
402
  window.onboardingCallback = undefined;
529
403
  };
530
404
  }, [completeOnboarding]);
531
- // Clean up the popup poll on unmount.
532
- (0, react_1.useEffect)(() => {
533
- return () => {
534
- stopPopupPoll();
535
- };
536
- }, [stopPopupPoll]);
537
405
  (0, react_1.useLayoutEffect)(() => {
538
406
  const el = ppBtnMeasureRef.current;
539
407
  if (!el)
@@ -559,51 +427,6 @@ function PayPalConnectionPage() {
559
427
  window.removeEventListener("resize", update);
560
428
  };
561
429
  }, [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.
604
- const handleConnectClick = (e) => {
605
- e.preventDefault();
606
- };
607
430
  const handleSaveManual = async () => {
608
431
  if (!canSaveManual || onboardingInProgress)
609
432
  return;
@@ -668,9 +491,10 @@ function PayPalConnectionPage() {
668
491
  }
669
492
  clearCachedUrl(env);
670
493
  completedRef.current = false;
494
+ // Generate a fresh link for the now-disconnected environment and reload so
495
+ // the onboarding button is ready again.
671
496
  currentRunId.current = ++runIdRef.current;
672
- const runId = currentRunId.current;
673
- fetchFreshLink(runId, env);
497
+ await generateLinkAndReload(env, currentRunId.current);
674
498
  }
675
499
  catch (e) {
676
500
  console.error(e);
@@ -683,15 +507,13 @@ function PayPalConnectionPage() {
683
507
  };
684
508
  const handleEnvChange = async (e) => {
685
509
  const next = e.target.value;
510
+ if (next === env || onboardingInProgress)
511
+ return;
686
512
  completedRef.current = false;
687
- setPopupBlocked(false);
688
- // Drop both cached URLs so neither environment can serve a stale link.
689
513
  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);
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.
695
517
  try {
696
518
  await fetch(ENVIRONMENT_ENDPOINT, {
697
519
  method: "POST",
@@ -701,17 +523,24 @@ function PayPalConnectionPage() {
701
523
  });
702
524
  }
703
525
  catch { }
526
+ setEnv(next);
704
527
  };
705
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
706
529
  ? "Configuring connection to PayPal…"
707
530
  : "Checking connection..." })] }), (0, jsx_runtime_1.jsxs)("div", { className: `${connState === "ready" ? "block" : "hidden"}`, children: [(0, jsx_runtime_1.jsx)("a", { ref: (node) => {
708
- paypalButtonRef.current = node;
709
531
  ppBtnMeasureRef.current = node;
710
- }, 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: {
711
- cursor: onboardingInProgress ? "not-allowed" : "pointer",
712
- opacity: onboardingInProgress ? 0.6 : 1,
713
- pointerEvents: onboardingInProgress ? "none" : "auto",
714
- }, 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: {
715
544
  width: ppBtnWidth ? `${ppBtnWidth}px` : "auto",
716
545
  marginTop: "20px",
717
546
  marginBottom: "10px",