@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/dist/index.mjs CHANGED
@@ -16,6 +16,18 @@ function isRetryableError(err) {
16
16
  return err instanceof Error && !(err instanceof HttpError);
17
17
  }
18
18
  var wait = (ms) => new Promise((r) => setTimeout(r, ms));
19
+ function extractJsonMessage(text) {
20
+ if (!text) return null;
21
+ try {
22
+ const parsed = JSON.parse(text);
23
+ const message = parsed?.message;
24
+ if (typeof message === "string" && message.trim()) {
25
+ return message.trim().slice(0, 500);
26
+ }
27
+ } catch {
28
+ }
29
+ return null;
30
+ }
19
31
  function toHeaderRecord(headers) {
20
32
  if (!headers) {
21
33
  return {};
@@ -40,39 +52,49 @@ function createHttpClient(opts) {
40
52
  headers["x-publishable-api-key"] = opts.publishableApiKey;
41
53
  }
42
54
  const timeoutMs = 3e4;
43
- let timeoutId;
44
55
  const controller = new AbortController();
45
- if (!init?.signal) {
46
- timeoutId = setTimeout(() => controller.abort(), timeoutMs);
56
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
57
+ const callerSignal = init?.signal ?? null;
58
+ const onCallerAbort = () => controller.abort();
59
+ if (callerSignal) {
60
+ if (callerSignal.aborted) controller.abort();
61
+ else callerSignal.addEventListener("abort", onCallerAbort);
47
62
  }
48
- const effectiveSignal = init?.signal || controller.signal;
49
63
  let res;
64
+ let text;
50
65
  try {
51
- res = await fetch(url, { ...init, headers, credentials: "include", signal: effectiveSignal });
66
+ res = await fetch(url, {
67
+ ...init,
68
+ headers,
69
+ credentials: "include",
70
+ signal: controller.signal
71
+ });
72
+ text = await res.text().catch(() => "");
52
73
  } catch (err) {
53
- if (err instanceof Error && err.name === "AbortError" && !init?.signal) {
74
+ if (err instanceof Error && err.name === "AbortError" && !(callerSignal && callerSignal.aborted)) {
54
75
  throw new Error(`[PayPal] Request to ${path} timed out after ${timeoutMs / 1e3}s`);
55
76
  }
56
77
  throw err;
57
78
  } finally {
58
- if (timeoutId !== void 0) clearTimeout(timeoutId);
79
+ clearTimeout(timeoutId);
80
+ if (callerSignal) callerSignal.removeEventListener("abort", onCallerAbort);
59
81
  }
60
- const text = await res.text().catch(() => "");
61
82
  if (!res.ok) {
83
+ const parsedMessage = extractJsonMessage(text);
62
84
  if (res.status === 401) {
63
85
  throw new HttpError(
64
- "[PayPal] Unauthorized (401) \u2014 check that your publishable API key is correct and set in NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY",
86
+ parsedMessage || "[PayPal] Unauthorized (401) \u2014 check that your publishable API key is correct and set in NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY",
65
87
  401
66
88
  );
67
89
  }
68
90
  if (res.status === 403) {
69
91
  throw new HttpError(
70
- "[PayPal] Forbidden (403) \u2014 this request is not allowed. Check your CORS and API key settings.",
92
+ parsedMessage || "[PayPal] Forbidden (403) \u2014 this request is not allowed. Check your CORS and API key settings.",
71
93
  403
72
94
  );
73
95
  }
74
96
  throw new HttpError(
75
- text.slice(0, 500).replace(/<[^>]*>/g, "") || `Request failed (${res.status})`,
97
+ parsedMessage || text.slice(0, 500).replace(/<[^>]*>/g, "") || `Request failed (${res.status})`,
76
98
  res.status
77
99
  );
78
100
  }
@@ -268,14 +290,16 @@ function PayPalCurrencyNotice({ config }) {
268
290
  }
269
291
 
270
292
  // src/components/PayPalSmartButtons.tsx
271
- import { useCallback, useMemo as useMemo3, useState as useState2 } from "react";
293
+ import { useCallback, useEffect as useEffect2, useMemo as useMemo3, useRef as useRef2, useState as useState2 } from "react";
272
294
  import { PayPalButtons, usePayPalScriptReducer } from "@paypal/react-paypal-js";
273
295
 
274
296
  // src/utils/next-errors.ts
275
297
  function isNextRouterError(e) {
276
298
  if (typeof e !== "object" || e === null || !("digest" in e)) return false;
277
299
  const digest = e.digest;
278
- return typeof digest === "string" && (digest.startsWith("NEXT_REDIRECT") || digest.startsWith("NEXT_NOT_FOUND"));
300
+ return typeof digest === "string" && (digest.startsWith("NEXT_REDIRECT") || digest.startsWith("NEXT_NOT_FOUND") || // Next.js 15: notFound()/forbidden()/unauthorized() throw
301
+ // HTTPAccessFallbackError with digest "NEXT_HTTP_ERROR_FALLBACK;<status>".
302
+ digest.startsWith("NEXT_HTTP_ERROR_FALLBACK"));
279
303
  }
280
304
 
281
305
  // src/utils/processing-overlay.ts
@@ -339,8 +363,13 @@ function showProcessingOverlay() {
339
363
  origReplace(...args);
340
364
  onNav();
341
365
  };
366
+ const onPageShow = (e) => {
367
+ if (e.persisted) hideProcessingOverlay();
368
+ };
369
+ window.addEventListener("pageshow", onPageShow);
342
370
  cleanupNavListener = () => {
343
371
  window.removeEventListener("popstate", onNav);
372
+ window.removeEventListener("pageshow", onPageShow);
344
373
  history.pushState = origPush;
345
374
  history.replaceState = origReplace;
346
375
  };
@@ -373,11 +402,48 @@ function PayPalSmartButtons(props) {
373
402
  const [error, setError] = useState2(null);
374
403
  const [processing, setProcessing] = useState2(false);
375
404
  const [buttonsReady, setButtonsReady] = useState2(false);
405
+ const [loadTimedOut, setLoadTimedOut] = useState2(false);
406
+ const capturedRef = useRef2(null);
407
+ const [completionPending, setCompletionPending] = useState2(false);
376
408
  const [{ isPending, isResolved, isRejected }] = usePayPalScriptReducer();
377
409
  const handleInit = useCallback(() => {
378
410
  setButtonsReady(true);
379
411
  }, []);
380
- const showSpinner = isPending || isResolved && !buttonsReady;
412
+ useEffect2(() => {
413
+ const onPageShow = (e) => {
414
+ if (e.persisted) setProcessing(false);
415
+ };
416
+ window.addEventListener("pageshow", onPageShow);
417
+ return () => window.removeEventListener("pageshow", onPageShow);
418
+ }, []);
419
+ useEffect2(() => {
420
+ if (!isResolved || buttonsReady) {
421
+ return;
422
+ }
423
+ const t = setTimeout(() => setLoadTimedOut(true), 1e4);
424
+ return () => clearTimeout(t);
425
+ }, [isResolved, buttonsReady]);
426
+ const finalizeCapturedPayment = useCallback(async () => {
427
+ const captured = capturedRef.current;
428
+ if (!captured) return;
429
+ setProcessing(true);
430
+ showProcessingOverlay();
431
+ setError(null);
432
+ try {
433
+ const completeResult = await markPaymentComplete(baseUrl, cartId, publishableApiKey);
434
+ await onPaid?.({ ...captured, ...completeResult });
435
+ setCompletionPending(false);
436
+ } catch (e) {
437
+ if (isNextRouterError(e)) return;
438
+ hideProcessingOverlay();
439
+ setProcessing(false);
440
+ setCompletionPending(true);
441
+ const msg = "Your payment was received, but finalizing your order failed. Please use the Retry button below \u2014 do not pay again.";
442
+ setError(msg);
443
+ onError?.(msg);
444
+ }
445
+ }, [baseUrl, cartId, publishableApiKey, onPaid, onError]);
446
+ const showSpinner = (isPending || isResolved && !buttonsReady) && !loadTimedOut;
381
447
  if (!config.currency_supported) return null;
382
448
  const containerWidth = BUTTON_WIDTH_MAP[config.button_width ?? "responsive"] ?? "100%";
383
449
  return /* @__PURE__ */ jsxs2("div", { style: { width: containerWidth, position: "relative" }, children: [
@@ -516,6 +582,28 @@ function PayPalSmartButtons(props) {
516
582
  ]
517
583
  }
518
584
  ),
585
+ loadTimedOut && !buttonsReady && !isRejected && /* @__PURE__ */ jsxs2(
586
+ "div",
587
+ {
588
+ role: "alert",
589
+ style: {
590
+ display: "flex",
591
+ alignItems: "flex-start",
592
+ gap: 8,
593
+ padding: "10px 14px",
594
+ background: "#fef2f2",
595
+ border: "1px solid #fecaca",
596
+ borderRadius: 8,
597
+ fontSize: 13,
598
+ color: "#b91c1c",
599
+ lineHeight: 1.5
600
+ },
601
+ children: [
602
+ /* @__PURE__ */ jsx3("span", { style: { flexShrink: 0, fontSize: 15 }, children: "\u26A0\uFE0F" }),
603
+ /* @__PURE__ */ jsx3("span", { children: "PayPal is not available for this purchase. Please try a different payment method." })
604
+ ]
605
+ }
606
+ ),
519
607
  isRejected && /* @__PURE__ */ jsxs2(
520
608
  "div",
521
609
  {
@@ -576,7 +664,7 @@ function PayPalSmartButtons(props) {
576
664
  ]
577
665
  }
578
666
  ),
579
- isResolved && /* @__PURE__ */ jsx3("div", { style: buttonsReady ? void 0 : { position: "absolute", left: -9999, opacity: 0, pointerEvents: "none" }, children: /* @__PURE__ */ jsx3(
667
+ isResolved && !completionPending && /* @__PURE__ */ jsx3("div", { style: buttonsReady ? void 0 : { position: "absolute", left: -9999, opacity: 0, pointerEvents: "none" }, children: /* @__PURE__ */ jsx3(
580
668
  PayPalButtons,
581
669
  {
582
670
  forceReRender: [config.currency, config.intent, cartId],
@@ -590,6 +678,10 @@ function PayPalSmartButtons(props) {
590
678
  },
591
679
  createOrder: async () => {
592
680
  if (processing) throw new Error("Payment already processing");
681
+ if (capturedRef.current) {
682
+ void finalizeCapturedPayment();
683
+ throw new Error("Your payment was already received \u2014 finalizing your order.");
684
+ }
593
685
  setError(null);
594
686
  setProcessing(true);
595
687
  try {
@@ -608,8 +700,7 @@ function PayPalSmartButtons(props) {
608
700
  const orderId = String(data?.orderID || "");
609
701
  if (!orderId) throw new Error("PayPal order ID is missing from approval response");
610
702
  const result = await api.captureOrder(cartId, orderId);
611
- const completeResult = await markPaymentComplete(baseUrl, cartId, publishableApiKey);
612
- await onPaid?.({ ...result, ...completeResult });
703
+ capturedRef.current = result || {};
613
704
  } catch (e) {
614
705
  if (isNextRouterError(e)) return;
615
706
  hideProcessingOverlay();
@@ -617,7 +708,9 @@ function PayPalSmartButtons(props) {
617
708
  const msg = e instanceof Error ? e.message : "Payment capture failed";
618
709
  setError(msg);
619
710
  onError?.(msg);
711
+ return;
620
712
  }
713
+ await finalizeCapturedPayment();
621
714
  },
622
715
  onCancel: () => {
623
716
  hideProcessingOverlay();
@@ -632,6 +725,29 @@ function PayPalSmartButtons(props) {
632
725
  }
633
726
  }
634
727
  ) }),
728
+ completionPending && /* @__PURE__ */ jsx3(
729
+ "button",
730
+ {
731
+ type: "button",
732
+ onClick: () => void finalizeCapturedPayment(),
733
+ disabled: processing,
734
+ "aria-busy": processing,
735
+ style: {
736
+ width: "100%",
737
+ padding: "12px 16px",
738
+ marginTop: 4,
739
+ background: "#0070ba",
740
+ color: "#ffffff",
741
+ border: "none",
742
+ borderRadius: 8,
743
+ fontSize: 14,
744
+ fontWeight: 600,
745
+ cursor: processing ? "not-allowed" : "pointer",
746
+ opacity: processing ? 0.7 : 1
747
+ },
748
+ children: "Retry \u2014 finish placing my order"
749
+ }
750
+ ),
635
751
  error ? /* @__PURE__ */ jsxs2(
636
752
  "div",
637
753
  {
@@ -659,7 +775,7 @@ function PayPalSmartButtons(props) {
659
775
  }
660
776
 
661
777
  // src/components/PayPalAdvancedCard.tsx
662
- import { useMemo as useMemo4, useRef as useRef2, useState as useState3 } from "react";
778
+ import React3, { useMemo as useMemo4, useRef as useRef3, useState as useState3 } from "react";
663
779
  import {
664
780
  PayPalCardFieldsProvider,
665
781
  PayPalNumberField,
@@ -718,7 +834,8 @@ var labelStyle = {
718
834
  function SubmitButton({
719
835
  disabled,
720
836
  label,
721
- onSubmit
837
+ onSubmit,
838
+ onSubmitError
722
839
  }) {
723
840
  const { cardFieldsForm } = usePayPalCardFields();
724
841
  const isDisabled = disabled || !cardFieldsForm;
@@ -730,10 +847,10 @@ function SubmitButton({
730
847
  "aria-busy": disabled,
731
848
  onClick: () => {
732
849
  onSubmit();
733
- try {
734
- cardFieldsForm?.submit();
735
- } catch {
736
- }
850
+ Promise.resolve(cardFieldsForm?.submit()).catch((e) => {
851
+ const msg = e instanceof Error && e.message ? e.message : "Card payment could not be submitted. Please check your card details.";
852
+ onSubmitError(msg);
853
+ });
737
854
  },
738
855
  style: {
739
856
  width: "100%",
@@ -777,9 +894,47 @@ function PayPalAdvancedCard(props) {
777
894
  );
778
895
  const [error, setError] = useState3(null);
779
896
  const [submitting, setSubmitting] = useState3(false);
780
- const submittingRef = useRef2(false);
781
- const creatingOrderRef = useRef2(false);
897
+ const submittingRef = useRef3(false);
898
+ const creatingOrderRef = useRef3(false);
899
+ const capturedRef = useRef3(null);
900
+ const [completionPending, setCompletionPending] = useState3(false);
782
901
  const [{ isPending, isResolved, isRejected }] = usePayPalScriptReducer2();
902
+ const resetSubmitState = React3.useCallback(() => {
903
+ hideProcessingOverlay();
904
+ creatingOrderRef.current = false;
905
+ submittingRef.current = false;
906
+ setSubmitting(false);
907
+ }, []);
908
+ React3.useEffect(() => {
909
+ const onPageShow = (e) => {
910
+ if (e.persisted) {
911
+ creatingOrderRef.current = false;
912
+ submittingRef.current = false;
913
+ setSubmitting(false);
914
+ }
915
+ };
916
+ window.addEventListener("pageshow", onPageShow);
917
+ return () => window.removeEventListener("pageshow", onPageShow);
918
+ }, []);
919
+ const finalizeCapturedPayment = React3.useCallback(async () => {
920
+ const captured = capturedRef.current;
921
+ if (!captured) return;
922
+ setSubmitting(true);
923
+ showProcessingOverlay();
924
+ setError(null);
925
+ try {
926
+ const completeResult = await markPaymentComplete(baseUrl, cartId, publishableApiKey);
927
+ await onPaid?.({ ...captured, ...completeResult });
928
+ setCompletionPending(false);
929
+ } catch (e) {
930
+ if (isNextRouterError(e)) return;
931
+ resetSubmitState();
932
+ setCompletionPending(true);
933
+ const msg = "Your payment was received, but finalizing your order failed. Please use the Retry button below \u2014 do not pay again.";
934
+ setError(msg);
935
+ onError?.(msg);
936
+ }
937
+ }, [baseUrl, cartId, publishableApiKey, onPaid, onError, resetSubmitState]);
783
938
  if (!config.currency_supported) return null;
784
939
  if (isRejected) {
785
940
  return /* @__PURE__ */ jsxs3(
@@ -968,6 +1123,10 @@ function PayPalAdvancedCard(props) {
968
1123
  style: cardStyle,
969
1124
  createOrder: async () => {
970
1125
  if (creatingOrderRef.current) throw new Error("Payment already processing");
1126
+ if (capturedRef.current) {
1127
+ void finalizeCapturedPayment();
1128
+ throw new Error("Your payment was already received \u2014 finalizing your order.");
1129
+ }
971
1130
  creatingOrderRef.current = true;
972
1131
  try {
973
1132
  setError(null);
@@ -983,19 +1142,18 @@ function PayPalAdvancedCard(props) {
983
1142
  setError(null);
984
1143
  showProcessingOverlay();
985
1144
  const orderId = String(data?.orderID || "");
1145
+ if (!orderId) throw new Error("PayPal order ID is missing from approval response");
986
1146
  const result = await api.captureOrder(cartId, orderId);
987
- const completeResult = await markPaymentComplete(baseUrl, cartId, publishableApiKey);
988
- await onPaid?.({ ...result, ...completeResult });
1147
+ capturedRef.current = result || {};
989
1148
  } catch (e) {
990
1149
  if (isNextRouterError(e)) return;
991
- hideProcessingOverlay();
992
- creatingOrderRef.current = false;
993
- submittingRef.current = false;
994
- setSubmitting(false);
1150
+ resetSubmitState();
995
1151
  const msg = e instanceof Error ? e.message : "Card payment failed";
996
1152
  setError(msg);
997
1153
  onError?.(msg);
1154
+ return;
998
1155
  }
1156
+ await finalizeCapturedPayment();
999
1157
  },
1000
1158
  onCancel: () => {
1001
1159
  hideProcessingOverlay();
@@ -1102,7 +1260,29 @@ function PayPalAdvancedCard(props) {
1102
1260
  ]
1103
1261
  }
1104
1262
  ),
1105
- /* @__PURE__ */ jsx4(
1263
+ completionPending ? /* @__PURE__ */ jsx4(
1264
+ "button",
1265
+ {
1266
+ type: "button",
1267
+ onClick: () => void finalizeCapturedPayment(),
1268
+ disabled: submitting,
1269
+ "aria-busy": submitting,
1270
+ style: {
1271
+ width: "100%",
1272
+ height: 48,
1273
+ padding: "0 20px",
1274
+ borderRadius: 8,
1275
+ border: "none",
1276
+ background: "#0070ba",
1277
+ color: "#ffffff",
1278
+ fontSize: 15,
1279
+ fontWeight: 600,
1280
+ cursor: submitting ? "not-allowed" : "pointer",
1281
+ opacity: submitting ? 0.7 : 1
1282
+ },
1283
+ children: "Retry \u2014 finish placing my order"
1284
+ }
1285
+ ) : /* @__PURE__ */ jsx4(
1106
1286
  SubmitButton,
1107
1287
  {
1108
1288
  disabled: submitting,
@@ -1113,6 +1293,11 @@ function PayPalAdvancedCard(props) {
1113
1293
  setError(null);
1114
1294
  setSubmitting(true);
1115
1295
  showProcessingOverlay();
1296
+ },
1297
+ onSubmitError: (msg) => {
1298
+ resetSubmitState();
1299
+ setError(msg);
1300
+ onError?.(msg);
1116
1301
  }
1117
1302
  }
1118
1303
  ),
@@ -1454,7 +1639,7 @@ function PayPalPaymentSection({
1454
1639
  }
1455
1640
 
1456
1641
  // src/hooks/usePayPalPaymentMethods.ts
1457
- import { useEffect as useEffect2, useMemo as useMemo5, useRef as useRef3, useState as useState4 } from "react";
1642
+ import { useEffect as useEffect3, useMemo as useMemo5, useRef as useRef4, useState as useState4 } from "react";
1458
1643
  var MAX_CACHE_ENTRIES2 = 50;
1459
1644
  var _cache2 = /* @__PURE__ */ new Map();
1460
1645
  var CACHE_TTL2 = 5 * 60 * 1e3;
@@ -1489,8 +1674,8 @@ function usePayPalPaymentMethods({
1489
1674
  const hit = _cache2.get(key);
1490
1675
  const seed = hit && Date.now() - hit.at < CACHE_TTL2 ? hit.result : null;
1491
1676
  const [result, setResult] = useState4(seed ?? { ...DEFAULT_RESULT, loading: enabled });
1492
- const fetchIdRef = useRef3(0);
1493
- useEffect2(() => {
1677
+ const fetchIdRef = useRef4(0);
1678
+ useEffect3(() => {
1494
1679
  if (!enabled) {
1495
1680
  setResult((prev) => ({ ...prev, loading: false }));
1496
1681
  return;