@easypayment/medusa-payment-paypal 0.9.25 → 0.9.27

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 (30) hide show
  1. package/.medusa/server/src/admin/index.js +183 -297
  2. package/.medusa/server/src/admin/index.mjs +183 -297
  3. package/.medusa/server/src/admin/routes/settings/paypal/connection/page.d.ts +0 -1
  4. package/.medusa/server/src/admin/routes/settings/paypal/connection/page.d.ts.map +1 -1
  5. package/.medusa/server/src/admin/routes/settings/paypal/connection/page.js +204 -368
  6. package/.medusa/server/src/admin/routes/settings/paypal/connection/page.js.map +1 -1
  7. package/.medusa/server/src/api/store/paypal/onboard-return/route.d.ts +11 -9
  8. package/.medusa/server/src/api/store/paypal/onboard-return/route.d.ts.map +1 -1
  9. package/.medusa/server/src/api/store/paypal/onboard-return/route.js +28 -9
  10. package/.medusa/server/src/api/store/paypal/onboard-return/route.js.map +1 -1
  11. package/.medusa/server/src/modules/paypal/payment-provider/card-service.d.ts.map +1 -1
  12. package/.medusa/server/src/modules/paypal/payment-provider/card-service.js +67 -55
  13. package/.medusa/server/src/modules/paypal/payment-provider/card-service.js.map +1 -1
  14. package/.medusa/server/src/modules/paypal/payment-provider/service.d.ts.map +1 -1
  15. package/.medusa/server/src/modules/paypal/payment-provider/service.js +15 -5
  16. package/.medusa/server/src/modules/paypal/payment-provider/service.js.map +1 -1
  17. package/.medusa/server/src/modules/paypal/service.d.ts.map +1 -1
  18. package/.medusa/server/src/modules/paypal/service.js +32 -6
  19. package/.medusa/server/src/modules/paypal/service.js.map +1 -1
  20. package/.medusa/server/src/modules/paypal/utils/amounts.d.ts +10 -0
  21. package/.medusa/server/src/modules/paypal/utils/amounts.d.ts.map +1 -1
  22. package/.medusa/server/src/modules/paypal/utils/amounts.js +36 -0
  23. package/.medusa/server/src/modules/paypal/utils/amounts.js.map +1 -1
  24. package/package.json +1 -1
  25. package/src/admin/routes/settings/paypal/connection/page.tsx +230 -389
  26. package/src/api/store/paypal/onboard-return/route.ts +34 -9
  27. package/src/modules/paypal/payment-provider/card-service.ts +85 -65
  28. package/src/modules/paypal/payment-provider/service.ts +22 -7
  29. package/src/modules/paypal/service.ts +34 -7
  30. package/src/modules/paypal/utils/amounts.ts +36 -0
@@ -12,15 +12,16 @@ const Tabs_1 = __importDefault(require("../_components/Tabs"));
12
12
  exports.config = (0, admin_sdk_1.defineRouteConfig)({
13
13
  label: "PayPal Connection",
14
14
  });
15
- const PARTNER_JS_URLS = {
16
- sandbox: "https://www.sandbox.paypal.com/webapps/merchantboarding/js/lib/lightbox/partner.js",
17
- live: "https://www.paypal.com/webapps/merchantboarding/js/lib/lightbox/partner.js",
18
- };
15
+ // Let partner.js own the entire onboarding flow: it opens PayPal's mini-browser
16
+ // itself (synchronously inside the click, so no new tab / no popup block), it
17
+ // receives PayPal's completion postMessage, and it invokes the callback named in
18
+ // `data-paypal-onboard-complete` with (authCode, sharedId). This is PayPal's
19
+ // documented integration and matches the official @easypayment WooCommerce
20
+ // plugin. Opening the popup ourselves (a previous attempt at fixing the "opens a
21
+ // new tab" issue) stopped partner.js from receiving completion, which left the
22
+ // popup stranded on PayPal's "isuDone" page and never saved credentials.
23
+ const PARTNER_JS_URL = "https://www.paypal.com/webapps/merchantboarding/js/lib/lightbox/partner.js";
19
24
  const SERVICE_URL = "/admin/paypal/onboarding-link";
20
- // PayPal's conventional mini-browser window name (also the anchor target).
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.
24
25
  const CACHE_PREFIX = "pp_onboard_cache";
25
26
  const CACHE_EXPIRY = 6 * 60 * 60 * 1000; // 6 hours
26
27
  const ONBOARDING_COMPLETE_ENDPOINT = "/admin/paypal/onboard-complete";
@@ -51,10 +52,9 @@ function readCachedUrl(env) {
51
52
  function writeCachedUrl(env, url) {
52
53
  try {
53
54
  localStorage.setItem(cacheKeyFor(env), JSON.stringify({ url, ts: Date.now() }));
54
- return readCachedUrl(env) === url;
55
55
  }
56
56
  catch {
57
- return false;
57
+ /* ignore */
58
58
  }
59
59
  }
60
60
  function clearCachedUrl(env) {
@@ -71,79 +71,11 @@ function clearCachedUrl(env) {
71
71
  /* ignore */
72
72
  }
73
73
  }
74
- function isPayPalOrigin(origin) {
75
- try {
76
- const host = new URL(origin).hostname.toLowerCase();
77
- return (host === "www.paypal.com" ||
78
- host === "www.sandbox.paypal.com" ||
79
- host.endsWith(".paypal.com") ||
80
- host.endsWith(".paypalobjects.com"));
81
- }
82
- catch {
83
- return false;
84
- }
85
- }
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.
90
- function extractAuth(data) {
91
- if (!data)
92
- return {};
93
- if (typeof data === "object") {
94
- const obj = data;
95
- const authCode = obj.authCode ?? obj.auth_code ?? obj.authcode ?? obj.code;
96
- const sharedId = obj.sharedId ?? obj.shared_id ?? obj.sharedid;
97
- if (authCode && sharedId) {
98
- return { authCode: String(authCode), sharedId: String(sharedId) };
99
- }
100
- for (const key of ["data", "payload", "message", "detail", "body", "params"]) {
101
- if (obj[key] && typeof obj[key] === "object") {
102
- const inner = extractAuth(obj[key]);
103
- if (inner.authCode && inner.sharedId)
104
- return inner;
105
- }
106
- if (typeof obj[key] === "string") {
107
- const inner = extractAuth(obj[key]);
108
- if (inner.authCode && inner.sharedId)
109
- return inner;
110
- }
111
- }
112
- return {};
113
- }
114
- if (typeof data === "string") {
115
- const s = data.trim();
116
- if (!s)
117
- return {};
118
- if (s.startsWith("{") || s.startsWith("[")) {
119
- try {
120
- return extractAuth(JSON.parse(s));
121
- }
122
- catch {
123
- /* not JSON */
124
- }
125
- }
126
- if (/auth_?code/i.test(s) && /shared_?id/i.test(s)) {
127
- try {
128
- const sp = new URLSearchParams(s.replace(/^[?#]/, ""));
129
- const authCode = sp.get("authCode") || sp.get("auth_code") || undefined;
130
- const sharedId = sp.get("sharedId") || sp.get("shared_id") || undefined;
131
- if (authCode && sharedId)
132
- return { authCode, sharedId };
133
- }
134
- catch {
135
- /* not a query string */
136
- }
137
- }
138
- }
139
- return {};
140
- }
141
74
  function PayPalConnectionPage() {
142
75
  const [env, setEnv] = (0, react_1.useState)("live");
143
76
  const [envReady, setEnvReady] = (0, react_1.useState)(false);
144
77
  const [connState, setConnState] = (0, react_1.useState)("loading");
145
78
  const [error, setError] = (0, react_1.useState)(null);
146
- const [popupBlocked, setPopupBlocked] = (0, react_1.useState)(false);
147
79
  const [finalUrl, setFinalUrl] = (0, react_1.useState)("");
148
80
  const [showManual, setShowManual] = (0, react_1.useState)(false);
149
81
  const [clientId, setClientId] = (0, react_1.useState)("");
@@ -155,17 +87,13 @@ function PayPalConnectionPage() {
155
87
  const errorLogRef = (0, react_1.useRef)(null);
156
88
  const runIdRef = (0, react_1.useRef)(0);
157
89
  const currentRunId = (0, react_1.useRef)(0);
90
+ const completedRef = (0, react_1.useRef)(false);
91
+ // Long-lived listeners/timers read the current env through a ref so they never
92
+ // capture a stale value from the render that registered them.
158
93
  const envRef = (0, react_1.useRef)(env);
159
94
  envRef.current = env;
160
- const finalUrlRef = (0, react_1.useRef)(finalUrl);
161
- finalUrlRef.current = finalUrl;
162
- const connStateRef = (0, react_1.useRef)(connState);
163
- connStateRef.current = connState;
164
- const inProgressRef = (0, react_1.useRef)(onboardingInProgress);
165
- inProgressRef.current = onboardingInProgress;
166
- const popupRef = (0, react_1.useRef)(null);
167
- const pollRef = (0, react_1.useRef)(null);
168
- const completedRef = (0, react_1.useRef)(false);
95
+ const pollTimerRef = (0, react_1.useRef)(null);
96
+ const pollAttemptsRef = (0, react_1.useRef)(0);
169
97
  const ppBtnMeasureRef = (0, react_1.useRef)(null);
170
98
  const [ppBtnWidth, setPpBtnWidth] = (0, react_1.useState)(null);
171
99
  const canSaveManual = (0, react_1.useMemo)(() => {
@@ -175,54 +103,87 @@ function PayPalConnectionPage() {
175
103
  setConnState("error");
176
104
  setError(msg);
177
105
  }, []);
178
- const stopPoll = (0, react_1.useCallback)(() => {
179
- if (pollRef.current) {
180
- clearInterval(pollRef.current);
181
- pollRef.current = null;
106
+ // Close PayPal's mini-browser via partner.js's API (works under both the
107
+ // `miniBrowser` and `MiniBrowser` casings PayPal has shipped over time).
108
+ const closeMiniBrowser = (0, react_1.useCallback)(() => {
109
+ try {
110
+ const close1 = window.PAYPAL?.apps?.Signup?.MiniBrowser?.closeFlow;
111
+ if (typeof close1 === "function")
112
+ close1();
182
113
  }
114
+ catch { }
115
+ try {
116
+ const close2 = window.PAYPAL?.apps?.Signup?.miniBrowser?.closeFlow;
117
+ if (typeof close2 === "function")
118
+ close2();
119
+ }
120
+ catch { }
183
121
  }, []);
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.
188
- const openOnboardingPopup = (0, react_1.useCallback)((url) => {
189
- const w = 450;
190
- const h = 600;
191
- const baseLeft = typeof window.screenX === "number" ? window.screenX : window.screenLeft || 0;
192
- const baseTop = typeof window.screenY === "number" ? window.screenY : window.screenTop || 0;
193
- const outerW = window.outerWidth || document.documentElement.clientWidth || 1024;
194
- const outerH = window.outerHeight || document.documentElement.clientHeight || 768;
195
- const left = Math.round(baseLeft + Math.max(0, (outerW - w) / 2));
196
- const top = Math.round(baseTop + Math.max(0, (outerH - h) / 2));
197
- const features = `popup=yes,width=${w},height=${h},left=${left},top=${top},scrollbars=yes,resizable=yes`;
198
- let popup = null;
122
+ const stopStatusPolling = (0, react_1.useCallback)(() => {
123
+ if (pollTimerRef.current) {
124
+ clearTimeout(pollTimerRef.current);
125
+ pollTimerRef.current = null;
126
+ }
127
+ pollAttemptsRef.current = 0;
128
+ }, []);
129
+ // Fetch status for an env and, if the seller credentials are present, flip the
130
+ // UI to "connected" and close the PayPal popup. Returns true once connected.
131
+ const refreshStatusAndMaybeConnect = (0, react_1.useCallback)(async (activeEnv) => {
199
132
  try {
200
- popup = window.open(url, POPUP_NAME, features);
133
+ const res = await fetch(`${STATUS_ENDPOINT}?environment=${activeEnv}`, {
134
+ method: "GET",
135
+ credentials: "include",
136
+ });
137
+ const st = await res.json().catch(() => ({}));
138
+ const connected = st?.status === "connected" && st?.seller_client_id_present === true;
139
+ if (connected) {
140
+ completedRef.current = true;
141
+ setStatusInfo(st);
142
+ setConnState("connected");
143
+ setShowManual(false);
144
+ setOnboardingInProgress(false);
145
+ clearCachedUrl(activeEnv);
146
+ closeMiniBrowser();
147
+ stopStatusPolling();
148
+ }
149
+ return connected;
201
150
  }
202
151
  catch {
203
- popup = null;
152
+ return false;
204
153
  }
205
- if (popup) {
206
- popupRef.current = popup;
207
- try {
208
- popup.focus();
209
- }
210
- catch {
211
- /* ignore */
154
+ }, [closeMiniBrowser, stopStatusPolling]);
155
+ // While a connect attempt is in flight, the seller may complete onboarding via
156
+ // a path that never reaches the opener (e.g. partner.js fails to fire its
157
+ // callback, or the popup's completion message is blocked). The return_url
158
+ // bridge still saves credentials server-side, so poll status as the reliable
159
+ // fallback: the moment credentials land, this flips to "connected" and closes
160
+ // the stranded popup.
161
+ const startStatusPolling = (0, react_1.useCallback)(() => {
162
+ stopStatusPolling();
163
+ const MAX_ATTEMPTS = 100; // ~5 min at 3s intervals
164
+ const tick = async () => {
165
+ pollAttemptsRef.current += 1;
166
+ const connected = await refreshStatusAndMaybeConnect(envRef.current);
167
+ if (connected || completedRef.current)
168
+ return;
169
+ if (pollAttemptsRef.current >= MAX_ATTEMPTS) {
170
+ stopStatusPolling();
171
+ return;
212
172
  }
213
- }
214
- return popup;
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.
173
+ pollTimerRef.current = setTimeout(tick, 3000);
174
+ };
175
+ pollTimerRef.current = setTimeout(tick, 3000);
176
+ }, [refreshStatusAndMaybeConnect, stopStatusPolling]);
177
+ // Shared completion: exchange authCode/sharedId for seller credentials, then
178
+ // close the popup and refresh status. Invoked by partner.js's onboardingCallback
179
+ // and by the return_url bridge's postMessage. Guarded + server-idempotent so
180
+ // redundant invocations are harmless.
219
181
  const completeOnboarding = (0, react_1.useCallback)(async (authCode, sharedId) => {
220
182
  if (!authCode || !sharedId)
221
183
  return;
222
184
  if (completedRef.current)
223
185
  return;
224
186
  completedRef.current = true;
225
- stopPoll();
226
187
  try {
227
188
  window.onbeforeunload = null;
228
189
  }
@@ -231,7 +192,6 @@ function PayPalConnectionPage() {
231
192
  setOnboardingInProgress(true);
232
193
  setConnState("loading");
233
194
  setError(null);
234
- setPopupBlocked(false);
235
195
  try {
236
196
  const res = await fetch(ONBOARDING_COMPLETE_ENDPOINT, {
237
197
  method: "POST",
@@ -243,84 +203,99 @@ function PayPalConnectionPage() {
243
203
  const txt = await res.text().catch(() => "");
244
204
  throw new Error(txt || `Onboarding exchange failed (${res.status})`);
245
205
  }
246
- try {
247
- popupRef.current?.close();
248
- }
249
- catch { }
250
- try {
251
- const close1 = window.PAYPAL?.apps?.Signup?.MiniBrowser?.closeFlow;
252
- if (typeof close1 === "function")
253
- close1();
254
- }
255
- catch { }
256
- try {
257
- const close2 = window.PAYPAL?.apps?.Signup?.miniBrowser &&
258
- window.PAYPAL.apps.Signup.miniBrowser.closeFlow;
259
- if (typeof close2 === "function")
260
- close2();
261
- }
262
- catch { }
206
+ closeMiniBrowser();
263
207
  clearCachedUrl(activeEnv);
264
208
  try {
265
- const statusRes = await fetch(`${STATUS_ENDPOINT}?environment=${activeEnv}`, {
266
- method: "GET",
267
- credentials: "include",
268
- });
209
+ const statusRes = await fetch(`${STATUS_ENDPOINT}?environment=${activeEnv}`, { method: "GET", credentials: "include" });
269
210
  const refreshedStatus = await statusRes.json().catch(() => ({}));
270
211
  setStatusInfo(refreshedStatus || null);
271
212
  }
272
213
  catch { }
273
214
  setConnState("connected");
274
215
  setShowManual(false);
216
+ stopStatusPolling();
275
217
  }
276
218
  catch (e) {
277
219
  completedRef.current = false;
278
220
  console.error(e);
221
+ // The bridge may have already saved credentials server-side; if so the
222
+ // poll will surface "connected". Don't strand the user on an error.
279
223
  setConnState("error");
280
224
  setError(e?.message || "Exchange failed while saving credentials.");
281
225
  }
282
226
  finally {
283
227
  setOnboardingInProgress(false);
284
228
  }
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
- }
229
+ }, [closeMiniBrowser, stopStatusPolling]);
230
+ // Bind partner.js to the connect button once it has a real onboarding URL.
231
+ const initPartner = (0, react_1.useCallback)(() => {
232
+ const signup = window.PAYPAL?.apps?.Signup;
233
+ const init = (signup?.miniBrowser && signup.miniBrowser.init) ||
234
+ (signup?.MiniBrowser && signup.MiniBrowser.init);
235
+ if (typeof init === "function") {
298
236
  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
- }
237
+ init();
238
+ }
239
+ catch (e) {
240
+ console.error("[paypal] partner.js init failed:", e);
241
+ }
242
+ }
243
+ }, []);
244
+ // Load partner.js once for the lifetime of the page.
245
+ (0, react_1.useEffect)(() => {
246
+ const existingScript = document.getElementById("paypal-partner-js");
247
+ if (existingScript)
248
+ return;
249
+ const preloadHref = PARTNER_JS_URL;
250
+ let preloadLink = null;
251
+ if (!document.head.querySelector(`link[rel="preload"][href="${preloadHref}"]`)) {
252
+ preloadLink = document.createElement("link");
253
+ preloadLink.rel = "preload";
254
+ preloadLink.href = preloadHref;
255
+ preloadLink.as = "script";
256
+ document.head.appendChild(preloadLink);
257
+ }
258
+ const ppScript = document.createElement("script");
259
+ ppScript.id = "paypal-partner-js";
260
+ ppScript.src = preloadHref;
261
+ ppScript.async = true;
262
+ document.head.appendChild(ppScript);
263
+ return () => {
264
+ if (preloadLink?.parentNode)
265
+ preloadLink.parentNode.removeChild(preloadLink);
266
+ if (ppScript.parentNode)
267
+ ppScript.parentNode.removeChild(ppScript);
268
+ };
269
+ }, []);
270
+ // Put the (cached or freshly generated) onboarding URL on the button, then wait
271
+ // for partner.js to be present before showing the button + initializing it. The
272
+ // button is only revealed once partner.js is ready, which is what guarantees
273
+ // the first click opens the mini-browser popup (never a new tab).
274
+ const activatePayPal = (0, react_1.useCallback)((url, runId) => {
275
+ if (paypalButtonRef.current) {
276
+ paypalButtonRef.current.href = url;
277
+ }
278
+ setFinalUrl(url);
279
+ let attempts = 0;
280
+ const MAX_ATTEMPTS = 200; // ~10s
281
+ const tryInit = () => {
282
+ if (runId !== currentRunId.current)
283
+ return;
284
+ if (window.PAYPAL?.apps?.Signup) {
285
+ initPartner();
286
+ setConnState("ready");
287
+ return;
317
288
  }
318
- catch {
319
- /* keep polling */
289
+ attempts++;
290
+ if (attempts >= MAX_ATTEMPTS) {
291
+ showError("PayPal partner script failed to load. Please refresh and try again.");
292
+ return;
320
293
  }
321
- }, 2000);
322
- }, [stopPoll]);
323
- const generateLinkAndReload = (0, react_1.useCallback)(async (targetEnv, runId) => {
294
+ setTimeout(tryInit, 50);
295
+ };
296
+ tryInit();
297
+ }, [initPartner, showError]);
298
+ const fetchFreshLink = (0, react_1.useCallback)(async (targetEnv, runId) => {
324
299
  if (initLoaderRef.current) {
325
300
  const loaderText = initLoaderRef.current.querySelector("#loader-text");
326
301
  if (loaderText)
@@ -344,20 +319,15 @@ function PayPalConnectionPage() {
344
319
  return;
345
320
  }
346
321
  const url = href + (href.includes("?") ? "&" : "?") + "displayMode=minibrowser";
347
- const persisted = writeCachedUrl(targetEnv, url);
348
- if (persisted) {
349
- window.location.reload();
350
- return;
351
- }
352
- setFinalUrl(url);
353
- setConnState("ready");
322
+ writeCachedUrl(targetEnv, url);
323
+ activatePayPal(url, runId);
354
324
  }
355
325
  catch {
356
326
  if (runId !== currentRunId.current)
357
327
  return;
358
328
  showError("Unable to connect to service.");
359
329
  }
360
- }, [showError]);
330
+ }, [activatePayPal, showError]);
361
331
  // Resolve the server environment first.
362
332
  (0, react_1.useEffect)(() => {
363
333
  let alive = true;
@@ -377,7 +347,7 @@ function PayPalConnectionPage() {
377
347
  alive = false;
378
348
  };
379
349
  }, []);
380
- // Page-load flow: connected? -> cached link? -> else generate + cache + reload.
350
+ // Page-load flow: connected? -> cached link? -> else generate a fresh link.
381
351
  (0, react_1.useEffect)(() => {
382
352
  if (!envReady)
383
353
  return;
@@ -388,7 +358,6 @@ function PayPalConnectionPage() {
388
358
  const run = async () => {
389
359
  setConnState("loading");
390
360
  setError(null);
391
- setPopupBlocked(false);
392
361
  setFinalUrl("");
393
362
  try {
394
363
  const r = await fetch(`${STATUS_ENDPOINT}?environment=${env}`, {
@@ -413,193 +382,52 @@ function PayPalConnectionPage() {
413
382
  return;
414
383
  const cached = readCachedUrl(env);
415
384
  if (cached) {
416
- setFinalUrl(cached);
417
- setConnState("ready");
385
+ activatePayPal(cached, runId);
418
386
  return;
419
387
  }
420
- await generateLinkAndReload(env, runId);
388
+ await fetchFreshLink(env, runId);
421
389
  };
422
390
  run();
423
391
  return () => {
424
392
  cancelled = true;
425
393
  };
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.
430
- (0, react_1.useEffect)(() => {
431
- if (connState !== "ready" || !finalUrl)
432
- return;
433
- const scriptUrl = PARTNER_JS_URLS[env];
434
- let cancelled = false;
435
- let pollTimer;
436
- const initPartner = (attempt = 0) => {
437
- if (cancelled)
438
- return;
439
- const signup = window.PAYPAL?.apps?.Signup;
440
- const target = signup?.miniBrowser && typeof signup.miniBrowser.init === "function"
441
- ? signup.miniBrowser
442
- : signup?.MiniBrowser && typeof signup.MiniBrowser.init === "function"
443
- ? signup.MiniBrowser
444
- : null;
445
- if (target?.init) {
446
- try {
447
- target.init();
448
- }
449
- catch (e) {
450
- console.error("[paypal] partner.js init failed:", e);
451
- }
452
- return;
453
- }
454
- if (typeof signup?.render === "function") {
455
- try {
456
- signup.render();
457
- }
458
- catch (e) {
459
- console.error("[paypal] partner.js render failed:", e);
460
- }
461
- return;
462
- }
463
- if (attempt < 40) {
464
- pollTimer = setTimeout(() => initPartner(attempt + 1), 50);
465
- return;
466
- }
467
- try {
468
- document.dispatchEvent(new Event("DOMContentLoaded"));
469
- }
470
- catch { }
471
- };
472
- const existingScript = document.getElementById("paypal-partner-js");
473
- if (existingScript) {
474
- existingScript.parentNode?.removeChild(existingScript);
475
- }
476
- if (window.PAYPAL?.apps?.Signup) {
477
- try {
478
- delete window.PAYPAL.apps.Signup;
479
- }
480
- catch { }
481
- }
482
- const ppScript = document.createElement("script");
483
- ppScript.id = "paypal-partner-js";
484
- ppScript.src = scriptUrl;
485
- ppScript.async = true;
486
- ppScript.onload = () => initPartner();
487
- document.body.appendChild(ppScript);
488
- return () => {
489
- cancelled = true;
490
- if (pollTimer)
491
- clearTimeout(pollTimer);
492
- if (ppScript.parentNode)
493
- ppScript.parentNode.removeChild(ppScript);
494
- };
495
- }, [connState, finalUrl, env]);
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.
499
- (0, react_1.useEffect)(() => {
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
- }
515
- // TERTIARY completion path: our own return_url bridge page (served by
516
- // GET /store/paypal/onboard-return) posts this from inside the popup when
517
- // PayPal redirects there at the end of onboarding. It runs on our backend
518
- // origin (not paypal.com), so it is handled before the PayPal-origin gate
519
- // below. It guarantees the popup is closed and the status is re-checked even
520
- // if every PayPal postMessage was missed.
521
- const d = ev.data;
522
- if (d && typeof d === "object" && d.source === "paypal-onboarding-return") {
523
- try {
524
- popupRef.current?.close();
525
- }
526
- catch { }
527
- const fromReturn = extractAuth(d.params);
528
- if (fromReturn.authCode && fromReturn.sharedId) {
529
- completeOnboarding(fromReturn.authCode, fromReturn.sharedId);
530
- }
531
- else if (!completedRef.current) {
532
- // No authCode/sharedId on the return URL (the usual case): credentials
533
- // are exchanged via the postMessage/onboardingCallback path. Poll the
534
- // server so the UI flips to "connected" as soon as that lands.
535
- startStatusPoll();
536
- }
537
- return;
538
- }
539
- if (!isPayPalOrigin(ev.origin))
540
- return;
541
- const { authCode, sharedId } = extractAuth(ev.data);
542
- if (authCode && sharedId) {
543
- completeOnboarding(authCode, sharedId);
544
- }
545
- };
546
- window.addEventListener("message", onMessage);
547
- return () => window.removeEventListener("message", onMessage);
548
- }, [completeOnboarding, startStatusPoll]);
549
- // SECONDARY completion path: partner.js's bridge calls this by name.
394
+ }, [env, envReady, fetchFreshLink, activatePayPal]);
395
+ // partner.js invokes this by name (data-paypal-onboard-complete) once PayPal
396
+ // returns the seller's authCode + sharedId.
550
397
  (0, react_1.useLayoutEffect)(() => {
551
398
  window.onboardingCallback = (authCode, sharedId) => {
552
- completeOnboarding(authCode, sharedId);
399
+ void completeOnboarding(authCode, sharedId);
553
400
  };
554
401
  return () => {
555
402
  window.onboardingCallback = undefined;
556
403
  };
557
404
  }, [completeOnboarding]);
558
- // Capture-phase opener: runs before partner.js and opens the popup
559
- // synchronously inside the user gesture so the mini-browser reliably opens as a
560
- // popup (never a blocked window or a new tab). We open it under PayPal's
561
- // conventional window name (POPUP_NAME / "PPFrame"); because partner.js also
562
- // targets that same name, letting its click handler run next makes it *adopt*
563
- // this very window instead of opening a second one. That adoption is what lets
564
- // partner.js receive PayPal's completion postMessage, invoke onboardingCallback
565
- // and auto-close the popup.
566
- //
567
- // IMPORTANT: we only preventDefault() (to stop the native anchor from opening a
568
- // new tab) and deliberately DO NOT stopImmediatePropagation(): stopping it was
569
- // what previously prevented partner.js from tracking the window, which broke
570
- // onboarding completion (the popup never closed and credentials were never
571
- // exchanged).
405
+ // The return_url bridge (/store/paypal/onboard-return), which runs inside the
406
+ // popup after PayPal redirects to it, posts its params back here. The bridge has
407
+ // already exchanged credentials server-side, so we just refresh status to flip
408
+ // to "connected" and close the popup. If for some reason credentials aren't yet
409
+ // saved but PayPal forwarded an authCode/sharedId, complete it from here too.
572
410
  (0, react_1.useEffect)(() => {
573
- const onCaptureClick = (e) => {
574
- const btn = paypalButtonRef.current;
575
- if (!btn)
576
- return;
577
- const target = e.target;
578
- if (!target || !btn.contains(target))
579
- return;
580
- // Stop the native anchor navigation only. Let the event keep propagating so
581
- // partner.js's own click handler still runs and adopts our popup window.
582
- e.preventDefault();
583
- if (connStateRef.current !== "ready" ||
584
- !finalUrlRef.current ||
585
- inProgressRef.current) {
586
- return;
587
- }
588
- completedRef.current = false;
589
- const popup = openOnboardingPopup(finalUrlRef.current);
590
- if (!popup) {
591
- setPopupBlocked(true);
411
+ const onMessage = (event) => {
412
+ const data = event?.data;
413
+ if (!data || data.source !== "paypal-onboarding-return")
592
414
  return;
593
- }
594
- setPopupBlocked(false);
595
- startStatusPoll();
415
+ const params = data.params || {};
416
+ const authCode = params.authCode || params.auth_code;
417
+ const sharedId = params.sharedId || params.shared_id;
418
+ const activeEnv = envRef.current;
419
+ void (async () => {
420
+ const connected = await refreshStatusAndMaybeConnect(activeEnv);
421
+ if (!connected && authCode && sharedId) {
422
+ await completeOnboarding(authCode, sharedId);
423
+ }
424
+ })();
596
425
  };
597
- document.addEventListener("click", onCaptureClick, true);
598
- return () => document.removeEventListener("click", onCaptureClick, true);
599
- }, [openOnboardingPopup, startStatusPoll]);
600
- (0, react_1.useEffect)(() => {
601
- return () => stopPoll();
602
- }, [stopPoll]);
426
+ window.addEventListener("message", onMessage);
427
+ return () => window.removeEventListener("message", onMessage);
428
+ }, [completeOnboarding, refreshStatusAndMaybeConnect]);
429
+ // Always stop polling when the component unmounts.
430
+ (0, react_1.useEffect)(() => stopStatusPolling, [stopStatusPolling]);
603
431
  (0, react_1.useLayoutEffect)(() => {
604
432
  const el = ppBtnMeasureRef.current;
605
433
  if (!el)
@@ -625,10 +453,17 @@ function PayPalConnectionPage() {
625
453
  window.removeEventListener("resize", update);
626
454
  };
627
455
  }, [connState, env, finalUrl]);
628
- // Defensive: the capture listener handles the click; this just guarantees the
629
- // native href can never navigate.
456
+ // Only block the click while we are not ready; when ready, let the native click
457
+ // through so partner.js can open the mini-browser. Start polling status so we
458
+ // detect completion (and close the popup) even if no completion message reaches
459
+ // this page.
630
460
  const handleConnectClick = (e) => {
631
- e.preventDefault();
461
+ if (connState !== "ready" || !finalUrl || onboardingInProgress) {
462
+ e.preventDefault();
463
+ return;
464
+ }
465
+ completedRef.current = false;
466
+ startStatusPolling();
632
467
  };
633
468
  const handleSaveManual = async () => {
634
469
  if (!canSaveManual || onboardingInProgress)
@@ -675,6 +510,7 @@ function PayPalConnectionPage() {
675
510
  return;
676
511
  if (!window.confirm("Disconnect PayPal for this environment?"))
677
512
  return;
513
+ stopStatusPolling();
678
514
  setOnboardingInProgress(true);
679
515
  setConnState("loading");
680
516
  setError(null);
@@ -695,7 +531,7 @@ function PayPalConnectionPage() {
695
531
  clearCachedUrl(env);
696
532
  completedRef.current = false;
697
533
  currentRunId.current = ++runIdRef.current;
698
- await generateLinkAndReload(env, currentRunId.current);
534
+ await fetchFreshLink(env, currentRunId.current);
699
535
  }
700
536
  catch (e) {
701
537
  console.error(e);
@@ -710,9 +546,9 @@ function PayPalConnectionPage() {
710
546
  const next = e.target.value;
711
547
  if (next === env || onboardingInProgress)
712
548
  return;
549
+ stopStatusPolling();
713
550
  completedRef.current = false;
714
551
  clearCachedUrl();
715
- setPopupBlocked(false);
716
552
  setConnState("loading");
717
553
  try {
718
554
  await fetch(ENVIRONMENT_ENDPOINT, {
@@ -725,16 +561,16 @@ function PayPalConnectionPage() {
725
561
  catch { }
726
562
  setEnv(next);
727
563
  };
728
- 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
564
+ return ((0, jsx_runtime_1.jsxs)("div", { className: "p-6", children: [(0, jsx_runtime_1.jsxs)("div", { className: "flex flex-col gap-6", children: [(0, jsx_runtime_1.jsx)("div", { className: "flex items-start justify-between gap-4", children: (0, jsx_runtime_1.jsx)("div", { children: (0, jsx_runtime_1.jsx)("h1", { className: "text-xl font-semibold text-ui-fg-base", children: "PayPal Gateway By Easy Payment" }) }) }), (0, jsx_runtime_1.jsx)(Tabs_1.default, {}), (0, jsx_runtime_1.jsx)("div", { className: "rounded-xl border border-ui-border-base bg-ui-bg-base shadow-sm", children: (0, jsx_runtime_1.jsxs)("div", { className: "grid grid-cols-1 gap-y-6 p-4 md:grid-cols-[260px_1fr] md:items-start", children: [(0, jsx_runtime_1.jsx)("div", { className: "text-sm font-medium pt-2", children: "Environment" }), (0, jsx_runtime_1.jsx)("div", { className: "max-w-xl", children: (0, jsx_runtime_1.jsxs)("select", { value: env, onChange: handleEnvChange, disabled: onboardingInProgress, className: "w-full rounded-md border border-ui-border-base bg-transparent px-3 py-2 text-sm", children: [(0, jsx_runtime_1.jsx)("option", { value: "sandbox", children: "Sandbox (Test Mode)" }), (0, jsx_runtime_1.jsx)("option", { value: "live", children: "Live (Production)" })] }) }), (0, jsx_runtime_1.jsx)("div", { className: "text-sm font-medium pt-2", children: env === "sandbox" ? "Connect to PayPal (Sandbox)" : "Connect to PayPal" }), (0, jsx_runtime_1.jsx)("div", { className: "max-w-xl", children: connState === "connected" ? ((0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsxs)("div", { className: "text-sm text-green-600 bg-green-50 p-3 rounded border border-green-200", children: ["\u2705 Successfully connected to PayPal!", (0, jsx_runtime_1.jsx)("a", { "data-paypal-button": "true", "data-paypal-onboard-complete": "onboardingCallback", href: "#", style: { display: "none" }, children: "PayPal" })] }), (0, jsx_runtime_1.jsxs)("div", { className: "mt-3 rounded-md border border-ui-border-base bg-ui-bg-subtle p-3 text-xs text-ui-fg-subtle", children: [(0, jsx_runtime_1.jsx)("div", { className: "font-medium text-ui-fg-base", children: "Connected PayPal account" }), (0, jsx_runtime_1.jsxs)("div", { className: "mt-1", children: ["Email:", " ", (0, jsx_runtime_1.jsx)("span", { className: "font-mono text-ui-fg-base", children: statusInfo?.seller_email || "Unavailable" })] })] }), (0, jsx_runtime_1.jsx)("div", { className: "mt-3 flex items-center gap-2", children: (0, jsx_runtime_1.jsx)("button", { type: "button", onClick: handleDisconnect, disabled: onboardingInProgress, className: "transition-fg relative inline-flex w-fit items-center justify-center overflow-hidden rounded-md outline-none shadow-buttons-neutral text-ui-fg-base bg-ui-button-neutral after:transition-fg after:absolute after:inset-0 after:content-[''] after:button-neutral-gradient hover:bg-ui-button-neutral-hover hover:after:button-neutral-hover-gradient active:bg-ui-button-neutral-pressed active:after:button-neutral-pressed-gradient focus-visible:shadow-buttons-neutral-focus disabled:bg-ui-bg-disabled disabled:border-ui-border-base disabled:text-ui-fg-disabled disabled:shadow-buttons-neutral disabled:after:hidden txt-compact-small-plus px-3 py-1.5", children: "Disconnect" }) })] })) : ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsxs)("div", { ref: initLoaderRef, id: "init-loader", className: `status-msg mb-4 ${connState !== "loading" ? "hidden" : "block"}`, children: [(0, jsx_runtime_1.jsx)("div", { className: "loader inline-block align-middle mr-2" }), (0, jsx_runtime_1.jsx)("span", { id: "loader-text", className: "text-sm", children: onboardingInProgress
729
565
  ? "Configuring connection to PayPal…"
730
566
  : "Checking connection..." })] }), (0, jsx_runtime_1.jsxs)("div", { className: `${connState === "ready" ? "block" : "hidden"}`, children: [(0, jsx_runtime_1.jsx)("a", { ref: (node) => {
731
567
  paypalButtonRef.current = node;
732
568
  ppBtnMeasureRef.current = node;
733
- }, 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: {
569
+ }, id: "paypal-button", "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: {
734
570
  cursor: onboardingInProgress ? "not-allowed" : "pointer",
735
571
  opacity: onboardingInProgress ? 0.6 : 1,
736
572
  pointerEvents: onboardingInProgress ? "none" : "auto",
737
- }, 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: {
573
+ }, children: "Connect to PayPal" }), (0, jsx_runtime_1.jsx)("div", { className: "mt-2", style: {
738
574
  width: ppBtnWidth ? `${ppBtnWidth}px` : "auto",
739
575
  marginTop: "20px",
740
576
  marginBottom: "10px",