@easypayment/medusa-paypal-ui 1.1.0 → 1.1.1

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.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,42 @@ All notable changes to `@easypayment/medusa-paypal-ui` are documented here.
4
4
 
5
5
  ## 1.1.0
6
6
 
7
+ ### Fixed (re-audit)
8
+ - **Card submit rejections no longer strand the buyer under the processing
9
+ overlay.** `cardFieldsForm.submit()` returns a promise whose rejection is not
10
+ routed to the provider's `onError`; it is now caught, the overlay/state reset,
11
+ and the validation message shown.
12
+ - **A payment can no longer be taken twice after a finalization failure.** Once
13
+ a capture succeeds, any later failure (order completion, consumer callback)
14
+ now shows a dedicated "Retry — finish placing my order" action that retries
15
+ only the finalization; the PayPal button/card form no longer re-appears to
16
+ invite a second, distinct charge for the same cart.
17
+ - **Back-button (bfcache) restores no longer leave a stuck overlay.** The DOM
18
+ processing overlay and both components' React overlays reset on `pageshow`
19
+ with `persisted: true`.
20
+ - **Requests can no longer hang forever.** The 30s HTTP timeout now also applies
21
+ when a caller passes an `AbortSignal` (hook config fetches), and it covers
22
+ reading the response body, not just the headers.
23
+ - **Buyers now see the backend's error message.** Error bodies are parsed as
24
+ JSON and the backend's buyer-facing `message` (unsupported currency,
25
+ rate-limit, "payment processed but order not finalized", "PayPal is currently
26
+ disabled") is shown instead of a raw JSON blob or a misleading CORS hint.
27
+ - **Ineligible PayPal buttons no longer spin forever.** If the SDK resolves but
28
+ never initializes the buttons (ineligible country/currency/funding), a clear
29
+ "PayPal is not available" message replaces the infinite "Loading PayPal…"
30
+ spinner after 10s.
31
+ - **Missing `orderID` guard added to the card `onApprove`** (matching the
32
+ buttons flow) so an empty id fails fast with a clear message.
33
+ - **Next.js 15 `notFound()` is no longer treated as a payment failure**
34
+ (`NEXT_HTTP_ERROR_FALLBACK` digests are now recognized as router errors).
35
+ - **CJS TypeScript consumers get correct types.** The exports map now serves
36
+ `dist/*.d.cts` under the `require` condition (fixes TS1479 under
37
+ `node16`/`nodenext` resolution).
38
+ - **`PayPalSettingsResponse` now matches the actual endpoint response**
39
+ (`paymentAction`, `advancedCardEnabled`); the old `data` envelope shape is
40
+ kept as deprecated so existing code compiles.
41
+
42
+
7
43
  ### Added
8
44
  - **Transient-failure retry** in the HTTP client (opt-in `retries`, jittered
9
45
  backoff on network errors / timeouts / 429 / 5xx). Wired into the idempotent
package/dist/index.cjs CHANGED
@@ -1,8 +1,10 @@
1
1
  "use client";
2
2
  "use strict";
3
+ var __create = Object.create;
3
4
  var __defProp = Object.defineProperty;
4
5
  var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
6
  var __getOwnPropNames = Object.getOwnPropertyNames;
7
+ var __getProtoOf = Object.getPrototypeOf;
6
8
  var __hasOwnProp = Object.prototype.hasOwnProperty;
7
9
  var __export = (target, all) => {
8
10
  for (var name in all)
@@ -16,6 +18,14 @@ var __copyProps = (to, from, except, desc) => {
16
18
  }
17
19
  return to;
18
20
  };
21
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
22
+ // If the importer is in node compatibility mode or this is not an ESM
23
+ // file that has been converted to a CommonJS file using a Babel-
24
+ // compatible transform (i.e. "__esModule" has not been set), then set
25
+ // "default" to the CommonJS "module.exports" for node compatibility.
26
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
27
+ mod
28
+ ));
19
29
  var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
20
30
 
21
31
  // src/index.ts
@@ -55,6 +65,18 @@ function isRetryableError(err) {
55
65
  return err instanceof Error && !(err instanceof HttpError);
56
66
  }
57
67
  var wait = (ms) => new Promise((r) => setTimeout(r, ms));
68
+ function extractJsonMessage(text) {
69
+ if (!text) return null;
70
+ try {
71
+ const parsed = JSON.parse(text);
72
+ const message = parsed?.message;
73
+ if (typeof message === "string" && message.trim()) {
74
+ return message.trim().slice(0, 500);
75
+ }
76
+ } catch {
77
+ }
78
+ return null;
79
+ }
58
80
  function toHeaderRecord(headers) {
59
81
  if (!headers) {
60
82
  return {};
@@ -79,39 +101,49 @@ function createHttpClient(opts) {
79
101
  headers["x-publishable-api-key"] = opts.publishableApiKey;
80
102
  }
81
103
  const timeoutMs = 3e4;
82
- let timeoutId;
83
104
  const controller = new AbortController();
84
- if (!init?.signal) {
85
- timeoutId = setTimeout(() => controller.abort(), timeoutMs);
105
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
106
+ const callerSignal = init?.signal ?? null;
107
+ const onCallerAbort = () => controller.abort();
108
+ if (callerSignal) {
109
+ if (callerSignal.aborted) controller.abort();
110
+ else callerSignal.addEventListener("abort", onCallerAbort);
86
111
  }
87
- const effectiveSignal = init?.signal || controller.signal;
88
112
  let res;
113
+ let text;
89
114
  try {
90
- res = await fetch(url, { ...init, headers, credentials: "include", signal: effectiveSignal });
115
+ res = await fetch(url, {
116
+ ...init,
117
+ headers,
118
+ credentials: "include",
119
+ signal: controller.signal
120
+ });
121
+ text = await res.text().catch(() => "");
91
122
  } catch (err) {
92
- if (err instanceof Error && err.name === "AbortError" && !init?.signal) {
123
+ if (err instanceof Error && err.name === "AbortError" && !(callerSignal && callerSignal.aborted)) {
93
124
  throw new Error(`[PayPal] Request to ${path} timed out after ${timeoutMs / 1e3}s`);
94
125
  }
95
126
  throw err;
96
127
  } finally {
97
- if (timeoutId !== void 0) clearTimeout(timeoutId);
128
+ clearTimeout(timeoutId);
129
+ if (callerSignal) callerSignal.removeEventListener("abort", onCallerAbort);
98
130
  }
99
- const text = await res.text().catch(() => "");
100
131
  if (!res.ok) {
132
+ const parsedMessage = extractJsonMessage(text);
101
133
  if (res.status === 401) {
102
134
  throw new HttpError(
103
- "[PayPal] Unauthorized (401) \u2014 check that your publishable API key is correct and set in NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY",
135
+ parsedMessage || "[PayPal] Unauthorized (401) \u2014 check that your publishable API key is correct and set in NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY",
104
136
  401
105
137
  );
106
138
  }
107
139
  if (res.status === 403) {
108
140
  throw new HttpError(
109
- "[PayPal] Forbidden (403) \u2014 this request is not allowed. Check your CORS and API key settings.",
141
+ parsedMessage || "[PayPal] Forbidden (403) \u2014 this request is not allowed. Check your CORS and API key settings.",
110
142
  403
111
143
  );
112
144
  }
113
145
  throw new HttpError(
114
- text.slice(0, 500).replace(/<[^>]*>/g, "") || `Request failed (${res.status})`,
146
+ parsedMessage || text.slice(0, 500).replace(/<[^>]*>/g, "") || `Request failed (${res.status})`,
115
147
  res.status
116
148
  );
117
149
  }
@@ -314,7 +346,9 @@ var import_react_paypal_js2 = require("@paypal/react-paypal-js");
314
346
  function isNextRouterError(e) {
315
347
  if (typeof e !== "object" || e === null || !("digest" in e)) return false;
316
348
  const digest = e.digest;
317
- return typeof digest === "string" && (digest.startsWith("NEXT_REDIRECT") || digest.startsWith("NEXT_NOT_FOUND"));
349
+ return typeof digest === "string" && (digest.startsWith("NEXT_REDIRECT") || digest.startsWith("NEXT_NOT_FOUND") || // Next.js 15: notFound()/forbidden()/unauthorized() throw
350
+ // HTTPAccessFallbackError with digest "NEXT_HTTP_ERROR_FALLBACK;<status>".
351
+ digest.startsWith("NEXT_HTTP_ERROR_FALLBACK"));
318
352
  }
319
353
 
320
354
  // src/utils/processing-overlay.ts
@@ -378,8 +412,13 @@ function showProcessingOverlay() {
378
412
  origReplace(...args);
379
413
  onNav();
380
414
  };
415
+ const onPageShow = (e) => {
416
+ if (e.persisted) hideProcessingOverlay();
417
+ };
418
+ window.addEventListener("pageshow", onPageShow);
381
419
  cleanupNavListener = () => {
382
420
  window.removeEventListener("popstate", onNav);
421
+ window.removeEventListener("pageshow", onPageShow);
383
422
  history.pushState = origPush;
384
423
  history.replaceState = origReplace;
385
424
  };
@@ -412,11 +451,48 @@ function PayPalSmartButtons(props) {
412
451
  const [error, setError] = (0, import_react3.useState)(null);
413
452
  const [processing, setProcessing] = (0, import_react3.useState)(false);
414
453
  const [buttonsReady, setButtonsReady] = (0, import_react3.useState)(false);
454
+ const [loadTimedOut, setLoadTimedOut] = (0, import_react3.useState)(false);
455
+ const capturedRef = (0, import_react3.useRef)(null);
456
+ const [completionPending, setCompletionPending] = (0, import_react3.useState)(false);
415
457
  const [{ isPending, isResolved, isRejected }] = (0, import_react_paypal_js2.usePayPalScriptReducer)();
416
458
  const handleInit = (0, import_react3.useCallback)(() => {
417
459
  setButtonsReady(true);
418
460
  }, []);
419
- const showSpinner = isPending || isResolved && !buttonsReady;
461
+ (0, import_react3.useEffect)(() => {
462
+ const onPageShow = (e) => {
463
+ if (e.persisted) setProcessing(false);
464
+ };
465
+ window.addEventListener("pageshow", onPageShow);
466
+ return () => window.removeEventListener("pageshow", onPageShow);
467
+ }, []);
468
+ (0, import_react3.useEffect)(() => {
469
+ if (!isResolved || buttonsReady) {
470
+ return;
471
+ }
472
+ const t = setTimeout(() => setLoadTimedOut(true), 1e4);
473
+ return () => clearTimeout(t);
474
+ }, [isResolved, buttonsReady]);
475
+ const finalizeCapturedPayment = (0, import_react3.useCallback)(async () => {
476
+ const captured = capturedRef.current;
477
+ if (!captured) return;
478
+ setProcessing(true);
479
+ showProcessingOverlay();
480
+ setError(null);
481
+ try {
482
+ const completeResult = await markPaymentComplete(baseUrl, cartId, publishableApiKey);
483
+ await onPaid?.({ ...captured, ...completeResult });
484
+ setCompletionPending(false);
485
+ } catch (e) {
486
+ if (isNextRouterError(e)) return;
487
+ hideProcessingOverlay();
488
+ setProcessing(false);
489
+ setCompletionPending(true);
490
+ const msg = "Your payment was received, but finalizing your order failed. Please use the Retry button below \u2014 do not pay again.";
491
+ setError(msg);
492
+ onError?.(msg);
493
+ }
494
+ }, [baseUrl, cartId, publishableApiKey, onPaid, onError]);
495
+ const showSpinner = (isPending || isResolved && !buttonsReady) && !loadTimedOut;
420
496
  if (!config.currency_supported) return null;
421
497
  const containerWidth = BUTTON_WIDTH_MAP[config.button_width ?? "responsive"] ?? "100%";
422
498
  return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { style: { width: containerWidth, position: "relative" }, children: [
@@ -555,6 +631,28 @@ function PayPalSmartButtons(props) {
555
631
  ]
556
632
  }
557
633
  ),
634
+ loadTimedOut && !buttonsReady && !isRejected && /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(
635
+ "div",
636
+ {
637
+ role: "alert",
638
+ style: {
639
+ display: "flex",
640
+ alignItems: "flex-start",
641
+ gap: 8,
642
+ padding: "10px 14px",
643
+ background: "#fef2f2",
644
+ border: "1px solid #fecaca",
645
+ borderRadius: 8,
646
+ fontSize: 13,
647
+ color: "#b91c1c",
648
+ lineHeight: 1.5
649
+ },
650
+ children: [
651
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("span", { style: { flexShrink: 0, fontSize: 15 }, children: "\u26A0\uFE0F" }),
652
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("span", { children: "PayPal is not available for this purchase. Please try a different payment method." })
653
+ ]
654
+ }
655
+ ),
558
656
  isRejected && /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(
559
657
  "div",
560
658
  {
@@ -615,7 +713,7 @@ function PayPalSmartButtons(props) {
615
713
  ]
616
714
  }
617
715
  ),
618
- isResolved && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("div", { style: buttonsReady ? void 0 : { position: "absolute", left: -9999, opacity: 0, pointerEvents: "none" }, children: /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
716
+ isResolved && !completionPending && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("div", { style: buttonsReady ? void 0 : { position: "absolute", left: -9999, opacity: 0, pointerEvents: "none" }, children: /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
619
717
  import_react_paypal_js2.PayPalButtons,
620
718
  {
621
719
  forceReRender: [config.currency, config.intent, cartId],
@@ -629,6 +727,10 @@ function PayPalSmartButtons(props) {
629
727
  },
630
728
  createOrder: async () => {
631
729
  if (processing) throw new Error("Payment already processing");
730
+ if (capturedRef.current) {
731
+ void finalizeCapturedPayment();
732
+ throw new Error("Your payment was already received \u2014 finalizing your order.");
733
+ }
632
734
  setError(null);
633
735
  setProcessing(true);
634
736
  try {
@@ -647,8 +749,7 @@ function PayPalSmartButtons(props) {
647
749
  const orderId = String(data?.orderID || "");
648
750
  if (!orderId) throw new Error("PayPal order ID is missing from approval response");
649
751
  const result = await api.captureOrder(cartId, orderId);
650
- const completeResult = await markPaymentComplete(baseUrl, cartId, publishableApiKey);
651
- await onPaid?.({ ...result, ...completeResult });
752
+ capturedRef.current = result || {};
652
753
  } catch (e) {
653
754
  if (isNextRouterError(e)) return;
654
755
  hideProcessingOverlay();
@@ -656,7 +757,9 @@ function PayPalSmartButtons(props) {
656
757
  const msg = e instanceof Error ? e.message : "Payment capture failed";
657
758
  setError(msg);
658
759
  onError?.(msg);
760
+ return;
659
761
  }
762
+ await finalizeCapturedPayment();
660
763
  },
661
764
  onCancel: () => {
662
765
  hideProcessingOverlay();
@@ -671,6 +774,29 @@ function PayPalSmartButtons(props) {
671
774
  }
672
775
  }
673
776
  ) }),
777
+ completionPending && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
778
+ "button",
779
+ {
780
+ type: "button",
781
+ onClick: () => void finalizeCapturedPayment(),
782
+ disabled: processing,
783
+ "aria-busy": processing,
784
+ style: {
785
+ width: "100%",
786
+ padding: "12px 16px",
787
+ marginTop: 4,
788
+ background: "#0070ba",
789
+ color: "#ffffff",
790
+ border: "none",
791
+ borderRadius: 8,
792
+ fontSize: 14,
793
+ fontWeight: 600,
794
+ cursor: processing ? "not-allowed" : "pointer",
795
+ opacity: processing ? 0.7 : 1
796
+ },
797
+ children: "Retry \u2014 finish placing my order"
798
+ }
799
+ ),
674
800
  error ? /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(
675
801
  "div",
676
802
  {
@@ -698,7 +824,7 @@ function PayPalSmartButtons(props) {
698
824
  }
699
825
 
700
826
  // src/components/PayPalAdvancedCard.tsx
701
- var import_react4 = require("react");
827
+ var import_react4 = __toESM(require("react"), 1);
702
828
  var import_react_paypal_js3 = require("@paypal/react-paypal-js");
703
829
  var import_jsx_runtime4 = require("react/jsx-runtime");
704
830
  var SPIN_STYLE2 = `
@@ -750,7 +876,8 @@ var labelStyle = {
750
876
  function SubmitButton({
751
877
  disabled,
752
878
  label,
753
- onSubmit
879
+ onSubmit,
880
+ onSubmitError
754
881
  }) {
755
882
  const { cardFieldsForm } = (0, import_react_paypal_js3.usePayPalCardFields)();
756
883
  const isDisabled = disabled || !cardFieldsForm;
@@ -762,10 +889,10 @@ function SubmitButton({
762
889
  "aria-busy": disabled,
763
890
  onClick: () => {
764
891
  onSubmit();
765
- try {
766
- cardFieldsForm?.submit();
767
- } catch {
768
- }
892
+ Promise.resolve(cardFieldsForm?.submit()).catch((e) => {
893
+ const msg = e instanceof Error && e.message ? e.message : "Card payment could not be submitted. Please check your card details.";
894
+ onSubmitError(msg);
895
+ });
769
896
  },
770
897
  style: {
771
898
  width: "100%",
@@ -811,7 +938,45 @@ function PayPalAdvancedCard(props) {
811
938
  const [submitting, setSubmitting] = (0, import_react4.useState)(false);
812
939
  const submittingRef = (0, import_react4.useRef)(false);
813
940
  const creatingOrderRef = (0, import_react4.useRef)(false);
941
+ const capturedRef = (0, import_react4.useRef)(null);
942
+ const [completionPending, setCompletionPending] = (0, import_react4.useState)(false);
814
943
  const [{ isPending, isResolved, isRejected }] = (0, import_react_paypal_js3.usePayPalScriptReducer)();
944
+ const resetSubmitState = import_react4.default.useCallback(() => {
945
+ hideProcessingOverlay();
946
+ creatingOrderRef.current = false;
947
+ submittingRef.current = false;
948
+ setSubmitting(false);
949
+ }, []);
950
+ import_react4.default.useEffect(() => {
951
+ const onPageShow = (e) => {
952
+ if (e.persisted) {
953
+ creatingOrderRef.current = false;
954
+ submittingRef.current = false;
955
+ setSubmitting(false);
956
+ }
957
+ };
958
+ window.addEventListener("pageshow", onPageShow);
959
+ return () => window.removeEventListener("pageshow", onPageShow);
960
+ }, []);
961
+ const finalizeCapturedPayment = import_react4.default.useCallback(async () => {
962
+ const captured = capturedRef.current;
963
+ if (!captured) return;
964
+ setSubmitting(true);
965
+ showProcessingOverlay();
966
+ setError(null);
967
+ try {
968
+ const completeResult = await markPaymentComplete(baseUrl, cartId, publishableApiKey);
969
+ await onPaid?.({ ...captured, ...completeResult });
970
+ setCompletionPending(false);
971
+ } catch (e) {
972
+ if (isNextRouterError(e)) return;
973
+ resetSubmitState();
974
+ setCompletionPending(true);
975
+ const msg = "Your payment was received, but finalizing your order failed. Please use the Retry button below \u2014 do not pay again.";
976
+ setError(msg);
977
+ onError?.(msg);
978
+ }
979
+ }, [baseUrl, cartId, publishableApiKey, onPaid, onError, resetSubmitState]);
815
980
  if (!config.currency_supported) return null;
816
981
  if (isRejected) {
817
982
  return /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(
@@ -1000,6 +1165,10 @@ function PayPalAdvancedCard(props) {
1000
1165
  style: cardStyle,
1001
1166
  createOrder: async () => {
1002
1167
  if (creatingOrderRef.current) throw new Error("Payment already processing");
1168
+ if (capturedRef.current) {
1169
+ void finalizeCapturedPayment();
1170
+ throw new Error("Your payment was already received \u2014 finalizing your order.");
1171
+ }
1003
1172
  creatingOrderRef.current = true;
1004
1173
  try {
1005
1174
  setError(null);
@@ -1015,19 +1184,18 @@ function PayPalAdvancedCard(props) {
1015
1184
  setError(null);
1016
1185
  showProcessingOverlay();
1017
1186
  const orderId = String(data?.orderID || "");
1187
+ if (!orderId) throw new Error("PayPal order ID is missing from approval response");
1018
1188
  const result = await api.captureOrder(cartId, orderId);
1019
- const completeResult = await markPaymentComplete(baseUrl, cartId, publishableApiKey);
1020
- await onPaid?.({ ...result, ...completeResult });
1189
+ capturedRef.current = result || {};
1021
1190
  } catch (e) {
1022
1191
  if (isNextRouterError(e)) return;
1023
- hideProcessingOverlay();
1024
- creatingOrderRef.current = false;
1025
- submittingRef.current = false;
1026
- setSubmitting(false);
1192
+ resetSubmitState();
1027
1193
  const msg = e instanceof Error ? e.message : "Card payment failed";
1028
1194
  setError(msg);
1029
1195
  onError?.(msg);
1196
+ return;
1030
1197
  }
1198
+ await finalizeCapturedPayment();
1031
1199
  },
1032
1200
  onCancel: () => {
1033
1201
  hideProcessingOverlay();
@@ -1134,7 +1302,29 @@ function PayPalAdvancedCard(props) {
1134
1302
  ]
1135
1303
  }
1136
1304
  ),
1137
- /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
1305
+ completionPending ? /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
1306
+ "button",
1307
+ {
1308
+ type: "button",
1309
+ onClick: () => void finalizeCapturedPayment(),
1310
+ disabled: submitting,
1311
+ "aria-busy": submitting,
1312
+ style: {
1313
+ width: "100%",
1314
+ height: 48,
1315
+ padding: "0 20px",
1316
+ borderRadius: 8,
1317
+ border: "none",
1318
+ background: "#0070ba",
1319
+ color: "#ffffff",
1320
+ fontSize: 15,
1321
+ fontWeight: 600,
1322
+ cursor: submitting ? "not-allowed" : "pointer",
1323
+ opacity: submitting ? 0.7 : 1
1324
+ },
1325
+ children: "Retry \u2014 finish placing my order"
1326
+ }
1327
+ ) : /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
1138
1328
  SubmitButton,
1139
1329
  {
1140
1330
  disabled: submitting,
@@ -1145,6 +1335,11 @@ function PayPalAdvancedCard(props) {
1145
1335
  setError(null);
1146
1336
  setSubmitting(true);
1147
1337
  showProcessingOverlay();
1338
+ },
1339
+ onSubmitError: (msg) => {
1340
+ resetSubmitState();
1341
+ setError(msg);
1342
+ onError?.(msg);
1148
1343
  }
1149
1344
  }
1150
1345
  ),