@easypayment/medusa-paypal-ui 1.0.56 → 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 ADDED
@@ -0,0 +1,61 @@
1
+ # Changelog
2
+
3
+ All notable changes to `@easypayment/medusa-paypal-ui` are documented here.
4
+
5
+ ## 1.1.0
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
+
43
+ ### Added
44
+ - **Transient-failure retry** in the HTTP client (opt-in `retries`, jittered
45
+ backoff on network errors / timeouts / 429 / 5xx). Wired into the idempotent
46
+ `markPaymentComplete` and `captureOrder` calls so a network blip right after
47
+ capture — the classic "captured but cart not completed" gap — is retried
48
+ instead of stranding a paid-but-unfinalized order.
49
+ - Tests for the retry logic and the capture/complete retry paths.
50
+
51
+ ### Changed
52
+ - Test environment switched from `node` to `jsdom` so component/hook tests can
53
+ run against a DOM (the client tests continue to pass under it).
54
+
55
+ ## 1.0.56
56
+
57
+ ### Added
58
+ - Unit test suite (vitest) for the framework-agnostic client logic: the HTTP
59
+ client (base-url/path normalization, publishable-key header, 401/403 handling,
60
+ HTML-tag stripping, empty-body and non-JSON guards, timeout surfacing) and the
61
+ Next.js router-error detection helper.
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
@@ -40,6 +50,33 @@ __export(index_exports, {
40
50
  module.exports = __toCommonJS(index_exports);
41
51
 
42
52
  // src/client/http.ts
53
+ var HttpError = class extends Error {
54
+ constructor(message, status) {
55
+ super(message);
56
+ this.name = "HttpError";
57
+ this.status = status;
58
+ }
59
+ };
60
+ var RETRY_BASE_DELAY_MS = 400;
61
+ function isRetryableStatus(status) {
62
+ return status === 429 || status >= 500;
63
+ }
64
+ function isRetryableError(err) {
65
+ return err instanceof Error && !(err instanceof HttpError);
66
+ }
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
+ }
43
80
  function toHeaderRecord(headers) {
44
81
  if (!headers) {
45
82
  return {};
@@ -54,7 +91,7 @@ function toHeaderRecord(headers) {
54
91
  }
55
92
  function createHttpClient(opts) {
56
93
  const base = opts.baseUrl.replace(/\/+$/, "");
57
- async function request(path, init) {
94
+ async function attempt(path, init) {
58
95
  const url = `${base}${path.startsWith("/") ? "" : "/"}${path}`;
59
96
  const headers = {
60
97
  Accept: "application/json",
@@ -64,41 +101,54 @@ function createHttpClient(opts) {
64
101
  headers["x-publishable-api-key"] = opts.publishableApiKey;
65
102
  }
66
103
  const timeoutMs = 3e4;
67
- let timeoutId;
68
104
  const controller = new AbortController();
69
- if (!init?.signal) {
70
- 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);
71
111
  }
72
- const effectiveSignal = init?.signal || controller.signal;
73
112
  let res;
113
+ let text;
74
114
  try {
75
- 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(() => "");
76
122
  } catch (err) {
77
- if (err instanceof Error && err.name === "AbortError" && !init?.signal) {
123
+ if (err instanceof Error && err.name === "AbortError" && !(callerSignal && callerSignal.aborted)) {
78
124
  throw new Error(`[PayPal] Request to ${path} timed out after ${timeoutMs / 1e3}s`);
79
125
  }
80
126
  throw err;
81
127
  } finally {
82
- if (timeoutId !== void 0) clearTimeout(timeoutId);
128
+ clearTimeout(timeoutId);
129
+ if (callerSignal) callerSignal.removeEventListener("abort", onCallerAbort);
83
130
  }
84
- const text = await res.text().catch(() => "");
85
131
  if (!res.ok) {
132
+ const parsedMessage = extractJsonMessage(text);
86
133
  if (res.status === 401) {
87
- throw new Error(
88
- "[PayPal] Unauthorized (401) \u2014 check that your publishable API key is correct and set in NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY"
134
+ throw new HttpError(
135
+ parsedMessage || "[PayPal] Unauthorized (401) \u2014 check that your publishable API key is correct and set in NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY",
136
+ 401
89
137
  );
90
138
  }
91
139
  if (res.status === 403) {
92
- throw new Error(
93
- "[PayPal] Forbidden (403) \u2014 this request is not allowed. Check your CORS and API key settings."
140
+ throw new HttpError(
141
+ parsedMessage || "[PayPal] Forbidden (403) \u2014 this request is not allowed. Check your CORS and API key settings.",
142
+ 403
94
143
  );
95
144
  }
96
- throw new Error(
97
- text.slice(0, 500).replace(/<[^>]*>/g, "") || `Request failed (${res.status})`
145
+ throw new HttpError(
146
+ parsedMessage || text.slice(0, 500).replace(/<[^>]*>/g, "") || `Request failed (${res.status})`,
147
+ res.status
98
148
  );
99
149
  }
100
150
  if (!text) {
101
- throw new Error(`[PayPal] Empty response body from ${path} (${res.status})`);
151
+ throw new HttpError(`[PayPal] Empty response body from ${path} (${res.status})`, res.status);
102
152
  }
103
153
  const contentType = res.headers.get("content-type") || "";
104
154
  if (!contentType.includes("application/json")) {
@@ -112,17 +162,39 @@ function createHttpClient(opts) {
112
162
  throw new Error(`[PayPal] Failed to parse JSON response from ${path}`);
113
163
  }
114
164
  }
165
+ async function request(path, init, reqOpts) {
166
+ const retries = Math.max(0, reqOpts?.retries ?? 0);
167
+ let lastError;
168
+ for (let i = 0; i <= retries; i++) {
169
+ try {
170
+ return await attempt(path, init);
171
+ } catch (err) {
172
+ lastError = err;
173
+ const transient = err instanceof HttpError && isRetryableStatus(err.status) || isRetryableError(err);
174
+ const aborted = err instanceof Error && err.name === "AbortError" && !!init?.signal;
175
+ if (i >= retries || !transient || aborted) {
176
+ throw err;
177
+ }
178
+ await wait(RETRY_BASE_DELAY_MS * Math.pow(2, i) + Math.random() * RETRY_BASE_DELAY_MS);
179
+ }
180
+ }
181
+ throw lastError;
182
+ }
115
183
  return { request };
116
184
  }
117
185
 
118
186
  // src/client/paypal.ts
119
187
  async function markPaymentComplete(baseUrl, cartId, publishableApiKey) {
120
188
  const http = createHttpClient({ baseUrl, publishableApiKey });
121
- return http.request(`/store/paypal-complete`, {
122
- method: "POST",
123
- headers: { "Content-Type": "application/json" },
124
- body: JSON.stringify({ cart_id: cartId })
125
- });
189
+ return http.request(
190
+ `/store/paypal-complete`,
191
+ {
192
+ method: "POST",
193
+ headers: { "Content-Type": "application/json" },
194
+ body: JSON.stringify({ cart_id: cartId })
195
+ },
196
+ { retries: 3 }
197
+ );
126
198
  }
127
199
  function createPayPalStoreApi(opts) {
128
200
  const http = createHttpClient(opts);
@@ -142,11 +214,15 @@ function createPayPalStoreApi(opts) {
142
214
  });
143
215
  },
144
216
  captureOrder(cartId, orderId) {
145
- return http.request(`/store/paypal/capture-order`, {
146
- method: "POST",
147
- headers: { "Content-Type": "application/json" },
148
- body: JSON.stringify({ cart_id: cartId, order_id: orderId })
149
- });
217
+ return http.request(
218
+ `/store/paypal/capture-order`,
219
+ {
220
+ method: "POST",
221
+ headers: { "Content-Type": "application/json" },
222
+ body: JSON.stringify({ cart_id: cartId, order_id: orderId })
223
+ },
224
+ { retries: 2 }
225
+ );
150
226
  }
151
227
  };
152
228
  }
@@ -270,7 +346,9 @@ var import_react_paypal_js2 = require("@paypal/react-paypal-js");
270
346
  function isNextRouterError(e) {
271
347
  if (typeof e !== "object" || e === null || !("digest" in e)) return false;
272
348
  const digest = e.digest;
273
- 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"));
274
352
  }
275
353
 
276
354
  // src/utils/processing-overlay.ts
@@ -334,8 +412,13 @@ function showProcessingOverlay() {
334
412
  origReplace(...args);
335
413
  onNav();
336
414
  };
415
+ const onPageShow = (e) => {
416
+ if (e.persisted) hideProcessingOverlay();
417
+ };
418
+ window.addEventListener("pageshow", onPageShow);
337
419
  cleanupNavListener = () => {
338
420
  window.removeEventListener("popstate", onNav);
421
+ window.removeEventListener("pageshow", onPageShow);
339
422
  history.pushState = origPush;
340
423
  history.replaceState = origReplace;
341
424
  };
@@ -368,11 +451,48 @@ function PayPalSmartButtons(props) {
368
451
  const [error, setError] = (0, import_react3.useState)(null);
369
452
  const [processing, setProcessing] = (0, import_react3.useState)(false);
370
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);
371
457
  const [{ isPending, isResolved, isRejected }] = (0, import_react_paypal_js2.usePayPalScriptReducer)();
372
458
  const handleInit = (0, import_react3.useCallback)(() => {
373
459
  setButtonsReady(true);
374
460
  }, []);
375
- 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;
376
496
  if (!config.currency_supported) return null;
377
497
  const containerWidth = BUTTON_WIDTH_MAP[config.button_width ?? "responsive"] ?? "100%";
378
498
  return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { style: { width: containerWidth, position: "relative" }, children: [
@@ -511,6 +631,28 @@ function PayPalSmartButtons(props) {
511
631
  ]
512
632
  }
513
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
+ ),
514
656
  isRejected && /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(
515
657
  "div",
516
658
  {
@@ -571,7 +713,7 @@ function PayPalSmartButtons(props) {
571
713
  ]
572
714
  }
573
715
  ),
574
- 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)(
575
717
  import_react_paypal_js2.PayPalButtons,
576
718
  {
577
719
  forceReRender: [config.currency, config.intent, cartId],
@@ -585,6 +727,10 @@ function PayPalSmartButtons(props) {
585
727
  },
586
728
  createOrder: async () => {
587
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
+ }
588
734
  setError(null);
589
735
  setProcessing(true);
590
736
  try {
@@ -603,8 +749,7 @@ function PayPalSmartButtons(props) {
603
749
  const orderId = String(data?.orderID || "");
604
750
  if (!orderId) throw new Error("PayPal order ID is missing from approval response");
605
751
  const result = await api.captureOrder(cartId, orderId);
606
- const completeResult = await markPaymentComplete(baseUrl, cartId, publishableApiKey);
607
- await onPaid?.({ ...result, ...completeResult });
752
+ capturedRef.current = result || {};
608
753
  } catch (e) {
609
754
  if (isNextRouterError(e)) return;
610
755
  hideProcessingOverlay();
@@ -612,7 +757,9 @@ function PayPalSmartButtons(props) {
612
757
  const msg = e instanceof Error ? e.message : "Payment capture failed";
613
758
  setError(msg);
614
759
  onError?.(msg);
760
+ return;
615
761
  }
762
+ await finalizeCapturedPayment();
616
763
  },
617
764
  onCancel: () => {
618
765
  hideProcessingOverlay();
@@ -627,6 +774,29 @@ function PayPalSmartButtons(props) {
627
774
  }
628
775
  }
629
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
+ ),
630
800
  error ? /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(
631
801
  "div",
632
802
  {
@@ -654,7 +824,7 @@ function PayPalSmartButtons(props) {
654
824
  }
655
825
 
656
826
  // src/components/PayPalAdvancedCard.tsx
657
- var import_react4 = require("react");
827
+ var import_react4 = __toESM(require("react"), 1);
658
828
  var import_react_paypal_js3 = require("@paypal/react-paypal-js");
659
829
  var import_jsx_runtime4 = require("react/jsx-runtime");
660
830
  var SPIN_STYLE2 = `
@@ -706,7 +876,8 @@ var labelStyle = {
706
876
  function SubmitButton({
707
877
  disabled,
708
878
  label,
709
- onSubmit
879
+ onSubmit,
880
+ onSubmitError
710
881
  }) {
711
882
  const { cardFieldsForm } = (0, import_react_paypal_js3.usePayPalCardFields)();
712
883
  const isDisabled = disabled || !cardFieldsForm;
@@ -718,10 +889,10 @@ function SubmitButton({
718
889
  "aria-busy": disabled,
719
890
  onClick: () => {
720
891
  onSubmit();
721
- try {
722
- cardFieldsForm?.submit();
723
- } catch {
724
- }
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
+ });
725
896
  },
726
897
  style: {
727
898
  width: "100%",
@@ -767,7 +938,45 @@ function PayPalAdvancedCard(props) {
767
938
  const [submitting, setSubmitting] = (0, import_react4.useState)(false);
768
939
  const submittingRef = (0, import_react4.useRef)(false);
769
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);
770
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]);
771
980
  if (!config.currency_supported) return null;
772
981
  if (isRejected) {
773
982
  return /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(
@@ -956,6 +1165,10 @@ function PayPalAdvancedCard(props) {
956
1165
  style: cardStyle,
957
1166
  createOrder: async () => {
958
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
+ }
959
1172
  creatingOrderRef.current = true;
960
1173
  try {
961
1174
  setError(null);
@@ -971,19 +1184,18 @@ function PayPalAdvancedCard(props) {
971
1184
  setError(null);
972
1185
  showProcessingOverlay();
973
1186
  const orderId = String(data?.orderID || "");
1187
+ if (!orderId) throw new Error("PayPal order ID is missing from approval response");
974
1188
  const result = await api.captureOrder(cartId, orderId);
975
- const completeResult = await markPaymentComplete(baseUrl, cartId, publishableApiKey);
976
- await onPaid?.({ ...result, ...completeResult });
1189
+ capturedRef.current = result || {};
977
1190
  } catch (e) {
978
1191
  if (isNextRouterError(e)) return;
979
- hideProcessingOverlay();
980
- creatingOrderRef.current = false;
981
- submittingRef.current = false;
982
- setSubmitting(false);
1192
+ resetSubmitState();
983
1193
  const msg = e instanceof Error ? e.message : "Card payment failed";
984
1194
  setError(msg);
985
1195
  onError?.(msg);
1196
+ return;
986
1197
  }
1198
+ await finalizeCapturedPayment();
987
1199
  },
988
1200
  onCancel: () => {
989
1201
  hideProcessingOverlay();
@@ -1090,7 +1302,29 @@ function PayPalAdvancedCard(props) {
1090
1302
  ]
1091
1303
  }
1092
1304
  ),
1093
- /* @__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)(
1094
1328
  SubmitButton,
1095
1329
  {
1096
1330
  disabled: submitting,
@@ -1101,6 +1335,11 @@ function PayPalAdvancedCard(props) {
1101
1335
  setError(null);
1102
1336
  setSubmitting(true);
1103
1337
  showProcessingOverlay();
1338
+ },
1339
+ onSubmitError: (msg) => {
1340
+ resetSubmitState();
1341
+ setError(msg);
1342
+ onError?.(msg);
1104
1343
  }
1105
1344
  }
1106
1345
  ),