@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 +61 -0
- package/dist/index.cjs +283 -44
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +10 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.mjs +279 -50
- package/dist/index.mjs.map +1 -1
- package/package.json +18 -7
- package/src/client/http.ts +122 -16
- package/src/client/paypal.ts +26 -10
- package/src/client/types.ts +10 -0
- package/src/components/PayPalAdvancedCard.tsx +114 -24
- package/src/components/PayPalSmartButtons.tsx +112 -7
- package/src/utils/next-errors.ts +5 -1
- package/src/utils/processing-overlay.ts +12 -0
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
|
|
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
|
-
|
|
70
|
-
|
|
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, {
|
|
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" && !
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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(
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
722
|
-
|
|
723
|
-
|
|
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
|
-
|
|
976
|
-
await onPaid?.({ ...result, ...completeResult });
|
|
1189
|
+
capturedRef.current = result || {};
|
|
977
1190
|
} catch (e) {
|
|
978
1191
|
if (isNextRouterError(e)) return;
|
|
979
|
-
|
|
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
|
),
|