@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 +36 -0
- package/dist/index.cjs +224 -29
- 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 +220 -35
- package/dist/index.mjs.map +1 -1
- package/package.json +17 -7
- package/src/client/http.ts +55 -11
- 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/src/client/http.ts
CHANGED
|
@@ -35,6 +35,21 @@ function isRetryableError(err: unknown): boolean {
|
|
|
35
35
|
|
|
36
36
|
const wait = (ms: number) => new Promise((r) => setTimeout(r, ms))
|
|
37
37
|
|
|
38
|
+
/** Best-effort extraction of a `{ message: string }` payload from a response body. */
|
|
39
|
+
function extractJsonMessage(text: string): string | null {
|
|
40
|
+
if (!text) return null
|
|
41
|
+
try {
|
|
42
|
+
const parsed = JSON.parse(text)
|
|
43
|
+
const message = parsed?.message
|
|
44
|
+
if (typeof message === "string" && message.trim()) {
|
|
45
|
+
return message.trim().slice(0, 500)
|
|
46
|
+
}
|
|
47
|
+
} catch {
|
|
48
|
+
// not JSON — caller falls back to the raw text
|
|
49
|
+
}
|
|
50
|
+
return null
|
|
51
|
+
}
|
|
52
|
+
|
|
38
53
|
function toHeaderRecord(headers?: RequestInit["headers"]): Record<string, string> {
|
|
39
54
|
if (!headers) {
|
|
40
55
|
return {}
|
|
@@ -65,41 +80,70 @@ export function createHttpClient(opts: HttpOptions) {
|
|
|
65
80
|
headers["x-publishable-api-key"] = opts.publishableApiKey
|
|
66
81
|
}
|
|
67
82
|
|
|
83
|
+
// Always arm the timeout — a caller-supplied signal composes with it
|
|
84
|
+
// rather than replacing it. Without this, hook-issued requests (which
|
|
85
|
+
// always pass an abort signal) could hang forever on a stalled
|
|
86
|
+
// connection. The timer also covers reading the body: a server that sends
|
|
87
|
+
// headers then stalls the response stream would otherwise hang the
|
|
88
|
+
// request indefinitely with the old header-only timeout.
|
|
68
89
|
const timeoutMs = 30_000
|
|
69
|
-
let timeoutId: ReturnType<typeof setTimeout> | undefined
|
|
70
90
|
const controller = new AbortController()
|
|
71
|
-
|
|
72
|
-
|
|
91
|
+
const timeoutId = setTimeout(() => controller.abort(), timeoutMs)
|
|
92
|
+
const callerSignal = init?.signal ?? null
|
|
93
|
+
const onCallerAbort = () => controller.abort()
|
|
94
|
+
if (callerSignal) {
|
|
95
|
+
if (callerSignal.aborted) controller.abort()
|
|
96
|
+
else callerSignal.addEventListener("abort", onCallerAbort)
|
|
73
97
|
}
|
|
74
|
-
const effectiveSignal = init?.signal || controller.signal
|
|
75
98
|
let res: Response
|
|
99
|
+
let text: string
|
|
76
100
|
try {
|
|
77
|
-
res = await fetch(url, {
|
|
101
|
+
res = await fetch(url, {
|
|
102
|
+
...init,
|
|
103
|
+
headers,
|
|
104
|
+
credentials: "include",
|
|
105
|
+
signal: controller.signal,
|
|
106
|
+
})
|
|
107
|
+
text = await res.text().catch(() => "")
|
|
78
108
|
} catch (err) {
|
|
79
|
-
if (
|
|
109
|
+
if (
|
|
110
|
+
err instanceof Error &&
|
|
111
|
+
err.name === "AbortError" &&
|
|
112
|
+
!(callerSignal && callerSignal.aborted)
|
|
113
|
+
) {
|
|
80
114
|
throw new Error(`[PayPal] Request to ${path} timed out after ${timeoutMs / 1000}s`)
|
|
81
115
|
}
|
|
82
116
|
throw err
|
|
83
117
|
} finally {
|
|
84
|
-
|
|
118
|
+
clearTimeout(timeoutId)
|
|
119
|
+
if (callerSignal) callerSignal.removeEventListener("abort", onCallerAbort)
|
|
85
120
|
}
|
|
86
|
-
const text = await res.text().catch(() => "")
|
|
87
121
|
|
|
88
122
|
if (!res.ok) {
|
|
123
|
+
// Backend routes return structured JSON errors ({ message }) written for
|
|
124
|
+
// the buyer (e.g. unsupported currency, rate-limit, "payment processed
|
|
125
|
+
// but order not finalized"). Prefer that message over raw response text
|
|
126
|
+
// or a generic hint so the user sees something actionable instead of a
|
|
127
|
+
// JSON blob.
|
|
128
|
+
const parsedMessage = extractJsonMessage(text)
|
|
89
129
|
if (res.status === 401) {
|
|
90
130
|
throw new HttpError(
|
|
91
|
-
|
|
131
|
+
parsedMessage ||
|
|
132
|
+
"[PayPal] Unauthorized (401) — check that your publishable API key is correct and set in NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY",
|
|
92
133
|
401
|
|
93
134
|
)
|
|
94
135
|
}
|
|
95
136
|
if (res.status === 403) {
|
|
96
137
|
throw new HttpError(
|
|
97
|
-
|
|
138
|
+
parsedMessage ||
|
|
139
|
+
"[PayPal] Forbidden (403) — this request is not allowed. Check your CORS and API key settings.",
|
|
98
140
|
403
|
|
99
141
|
)
|
|
100
142
|
}
|
|
101
143
|
throw new HttpError(
|
|
102
|
-
|
|
144
|
+
parsedMessage ||
|
|
145
|
+
text.slice(0, 500).replace(/<[^>]*>/g, "") ||
|
|
146
|
+
`Request failed (${res.status})`,
|
|
103
147
|
res.status
|
|
104
148
|
)
|
|
105
149
|
}
|
package/src/client/types.ts
CHANGED
|
@@ -21,6 +21,16 @@ export type PayPalConfig = {
|
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
export type PayPalSettingsResponse = {
|
|
24
|
+
/**
|
|
25
|
+
* What `GET /store/paypal/settings` actually returns: the configured
|
|
26
|
+
* payment action and whether advanced card fields are enabled.
|
|
27
|
+
*/
|
|
28
|
+
paymentAction?: "capture" | "authorize"
|
|
29
|
+
advancedCardEnabled?: boolean
|
|
30
|
+
/**
|
|
31
|
+
* @deprecated The settings endpoint has never returned a `data` envelope —
|
|
32
|
+
* kept only so existing (already-null-safe) consumer code keeps compiling.
|
|
33
|
+
*/
|
|
24
34
|
data?: {
|
|
25
35
|
api_details?: { currency_code?: string }
|
|
26
36
|
paypal_settings?: Record<string, unknown>
|
|
@@ -68,10 +68,12 @@ function SubmitButton({
|
|
|
68
68
|
disabled,
|
|
69
69
|
label,
|
|
70
70
|
onSubmit,
|
|
71
|
+
onSubmitError,
|
|
71
72
|
}: {
|
|
72
73
|
disabled: boolean
|
|
73
74
|
label: string
|
|
74
75
|
onSubmit: () => void
|
|
76
|
+
onSubmitError: (message: string) => void
|
|
75
77
|
}) {
|
|
76
78
|
const { cardFieldsForm } = usePayPalCardFields()
|
|
77
79
|
const isDisabled = disabled || !cardFieldsForm
|
|
@@ -83,11 +85,18 @@ function SubmitButton({
|
|
|
83
85
|
aria-busy={disabled}
|
|
84
86
|
onClick={() => {
|
|
85
87
|
onSubmit()
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
88
|
+
// submit() returns a promise and its rejection is NOT routed to the
|
|
89
|
+
// provider's onError — PayPal's contract is that the caller catches
|
|
90
|
+
// it. A sync try/catch here would miss it entirely, leaving the buyer
|
|
91
|
+
// stuck under the full-screen processing overlay forever (e.g. on a
|
|
92
|
+
// card-field validation error).
|
|
93
|
+
Promise.resolve(cardFieldsForm?.submit()).catch((e: unknown) => {
|
|
94
|
+
const msg =
|
|
95
|
+
e instanceof Error && e.message
|
|
96
|
+
? e.message
|
|
97
|
+
: "Card payment could not be submitted. Please check your card details."
|
|
98
|
+
onSubmitError(msg)
|
|
99
|
+
})
|
|
91
100
|
}}
|
|
92
101
|
style={{
|
|
93
102
|
width: "100%",
|
|
@@ -150,8 +159,55 @@ export function PayPalAdvancedCard(props: {
|
|
|
150
159
|
const [submitting, setSubmitting] = useState(false)
|
|
151
160
|
const submittingRef = useRef(false)
|
|
152
161
|
const creatingOrderRef = useRef(false)
|
|
162
|
+
// Once a capture succeeded the buyer's money is taken: any later failure
|
|
163
|
+
// must retry ONLY the order finalization, never a fresh order/capture.
|
|
164
|
+
const capturedRef = useRef<Record<string, unknown> | null>(null)
|
|
165
|
+
const [completionPending, setCompletionPending] = useState(false)
|
|
153
166
|
const [{ isPending, isResolved, isRejected }] = usePayPalScriptReducer()
|
|
154
167
|
|
|
168
|
+
const resetSubmitState = React.useCallback(() => {
|
|
169
|
+
hideProcessingOverlay()
|
|
170
|
+
creatingOrderRef.current = false
|
|
171
|
+
submittingRef.current = false
|
|
172
|
+
setSubmitting(false)
|
|
173
|
+
}, [])
|
|
174
|
+
|
|
175
|
+
// bfcache restore (Back button after a redirect) revives this page with the
|
|
176
|
+
// old React state — clear the submitting flag so the buyer isn't stuck
|
|
177
|
+
// behind the in-component overlay.
|
|
178
|
+
React.useEffect(() => {
|
|
179
|
+
const onPageShow = (e: PageTransitionEvent) => {
|
|
180
|
+
if (e.persisted) {
|
|
181
|
+
creatingOrderRef.current = false
|
|
182
|
+
submittingRef.current = false
|
|
183
|
+
setSubmitting(false)
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
window.addEventListener("pageshow", onPageShow)
|
|
187
|
+
return () => window.removeEventListener("pageshow", onPageShow)
|
|
188
|
+
}, [])
|
|
189
|
+
|
|
190
|
+
const finalizeCapturedPayment = React.useCallback(async () => {
|
|
191
|
+
const captured = capturedRef.current
|
|
192
|
+
if (!captured) return
|
|
193
|
+
setSubmitting(true)
|
|
194
|
+
showProcessingOverlay()
|
|
195
|
+
setError(null)
|
|
196
|
+
try {
|
|
197
|
+
const completeResult = await markPaymentComplete(baseUrl, cartId, publishableApiKey)
|
|
198
|
+
await onPaid?.({ ...captured, ...completeResult })
|
|
199
|
+
setCompletionPending(false)
|
|
200
|
+
} catch (e: unknown) {
|
|
201
|
+
if (isNextRouterError(e)) return
|
|
202
|
+
resetSubmitState()
|
|
203
|
+
setCompletionPending(true)
|
|
204
|
+
const msg =
|
|
205
|
+
"Your payment was received, but finalizing your order failed. Please use the Retry button below — do not pay again."
|
|
206
|
+
setError(msg)
|
|
207
|
+
onError?.(msg)
|
|
208
|
+
}
|
|
209
|
+
}, [baseUrl, cartId, publishableApiKey, onPaid, onError, resetSubmitState])
|
|
210
|
+
|
|
155
211
|
if (!config.currency_supported) return null
|
|
156
212
|
|
|
157
213
|
if (isRejected) {
|
|
@@ -331,6 +387,12 @@ export function PayPalAdvancedCard(props: {
|
|
|
331
387
|
style={cardStyle}
|
|
332
388
|
createOrder={async () => {
|
|
333
389
|
if (creatingOrderRef.current) throw new Error("Payment already processing")
|
|
390
|
+
if (capturedRef.current) {
|
|
391
|
+
// Money was already captured for this cart — retry the order
|
|
392
|
+
// finalization instead of creating (and charging) a new order.
|
|
393
|
+
void finalizeCapturedPayment()
|
|
394
|
+
throw new Error("Your payment was already received — finalizing your order.")
|
|
395
|
+
}
|
|
334
396
|
creatingOrderRef.current = true
|
|
335
397
|
try {
|
|
336
398
|
setError(null)
|
|
@@ -346,21 +408,20 @@ export function PayPalAdvancedCard(props: {
|
|
|
346
408
|
setError(null)
|
|
347
409
|
showProcessingOverlay()
|
|
348
410
|
const orderId = String(data?.orderID || "")
|
|
411
|
+
if (!orderId) throw new Error("PayPal order ID is missing from approval response")
|
|
349
412
|
const result = await api.captureOrder(cartId, orderId)
|
|
350
|
-
|
|
351
|
-
const completeResult = await markPaymentComplete(baseUrl, cartId, publishableApiKey)
|
|
352
|
-
|
|
353
|
-
await onPaid?.({ ...result, ...completeResult })
|
|
413
|
+
capturedRef.current = (result || {}) as Record<string, unknown>
|
|
354
414
|
} catch (e: unknown) {
|
|
355
415
|
if (isNextRouterError(e)) return
|
|
356
|
-
|
|
357
|
-
creatingOrderRef.current = false
|
|
358
|
-
submittingRef.current = false
|
|
359
|
-
setSubmitting(false)
|
|
416
|
+
resetSubmitState()
|
|
360
417
|
const msg = e instanceof Error ? e.message : "Card payment failed"
|
|
361
418
|
setError(msg)
|
|
362
419
|
onError?.(msg)
|
|
420
|
+
return
|
|
363
421
|
}
|
|
422
|
+
// Past this point the buyer has been charged: completion failures
|
|
423
|
+
// surface a finalize-only Retry, never a fresh payment.
|
|
424
|
+
await finalizeCapturedPayment()
|
|
364
425
|
}}
|
|
365
426
|
onCancel={() => {
|
|
366
427
|
hideProcessingOverlay()
|
|
@@ -458,17 +519,46 @@ export function PayPalAdvancedCard(props: {
|
|
|
458
519
|
<span>Your payment is secured with 256-bit SSL encryption</span>
|
|
459
520
|
</div>
|
|
460
521
|
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
522
|
+
{completionPending ? (
|
|
523
|
+
<button
|
|
524
|
+
type="button"
|
|
525
|
+
onClick={() => void finalizeCapturedPayment()}
|
|
526
|
+
disabled={submitting}
|
|
527
|
+
aria-busy={submitting}
|
|
528
|
+
style={{
|
|
529
|
+
width: "100%",
|
|
530
|
+
height: 48,
|
|
531
|
+
padding: "0 20px",
|
|
532
|
+
borderRadius: 8,
|
|
533
|
+
border: "none",
|
|
534
|
+
background: "#0070ba",
|
|
535
|
+
color: "#ffffff",
|
|
536
|
+
fontSize: 15,
|
|
537
|
+
fontWeight: 600,
|
|
538
|
+
cursor: submitting ? "not-allowed" : "pointer",
|
|
539
|
+
opacity: submitting ? 0.7 : 1,
|
|
540
|
+
}}
|
|
541
|
+
>
|
|
542
|
+
Retry — finish placing my order
|
|
543
|
+
</button>
|
|
544
|
+
) : (
|
|
545
|
+
<SubmitButton
|
|
546
|
+
disabled={submitting}
|
|
547
|
+
label={submitting ? "Processing…" : "Pay by Card"}
|
|
548
|
+
onSubmit={() => {
|
|
549
|
+
if (submittingRef.current) return
|
|
550
|
+
submittingRef.current = true
|
|
551
|
+
setError(null)
|
|
552
|
+
setSubmitting(true)
|
|
553
|
+
showProcessingOverlay()
|
|
554
|
+
}}
|
|
555
|
+
onSubmitError={(msg) => {
|
|
556
|
+
resetSubmitState()
|
|
557
|
+
setError(msg)
|
|
558
|
+
onError?.(msg)
|
|
559
|
+
}}
|
|
560
|
+
/>
|
|
561
|
+
)}
|
|
472
562
|
|
|
473
563
|
{error && (
|
|
474
564
|
<div
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
"use client"
|
|
2
|
-
import React, { useCallback, useMemo, useState } from "react"
|
|
2
|
+
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
|
3
3
|
import { PayPalButtons, usePayPalScriptReducer } from "@paypal/react-paypal-js"
|
|
4
4
|
import { createPayPalStoreApi, markPaymentComplete } from "../client/paypal"
|
|
5
5
|
import type { PayPalConfig } from "../client/types"
|
|
@@ -35,13 +35,63 @@ export function PayPalSmartButtons(props: {
|
|
|
35
35
|
const [error, setError] = useState<string | null>(null)
|
|
36
36
|
const [processing, setProcessing] = useState(false)
|
|
37
37
|
const [buttonsReady, setButtonsReady] = useState(false)
|
|
38
|
+
const [loadTimedOut, setLoadTimedOut] = useState(false)
|
|
39
|
+
// Once a capture succeeded, the buyer's money is taken — any later failure
|
|
40
|
+
// (order finalization, consumer callback) must retry ONLY the finalization,
|
|
41
|
+
// never a fresh createOrder/capture, or a retry click would charge again.
|
|
42
|
+
const capturedRef = useRef<Record<string, unknown> | null>(null)
|
|
43
|
+
const [completionPending, setCompletionPending] = useState(false)
|
|
38
44
|
const [{ isPending, isResolved, isRejected }] = usePayPalScriptReducer()
|
|
39
45
|
|
|
40
46
|
const handleInit = useCallback(() => {
|
|
41
47
|
setButtonsReady(true)
|
|
42
48
|
}, [])
|
|
43
49
|
|
|
44
|
-
|
|
50
|
+
// bfcache restore (Back button after a redirect) revives this page with the
|
|
51
|
+
// old React state — clear the processing flag so the buyer isn't stuck
|
|
52
|
+
// behind the in-component overlay.
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
const onPageShow = (e: PageTransitionEvent) => {
|
|
55
|
+
if (e.persisted) setProcessing(false)
|
|
56
|
+
}
|
|
57
|
+
window.addEventListener("pageshow", onPageShow)
|
|
58
|
+
return () => window.removeEventListener("pageshow", onPageShow)
|
|
59
|
+
}, [])
|
|
60
|
+
|
|
61
|
+
// The PayPal SDK never calls onInit when the buttons are ineligible (wrong
|
|
62
|
+
// country/currency/funding) — it silently renders nothing. Without a
|
|
63
|
+
// fallback the buyer stares at "Loading PayPal…" forever.
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
if (!isResolved || buttonsReady) {
|
|
66
|
+
return
|
|
67
|
+
}
|
|
68
|
+
const t = setTimeout(() => setLoadTimedOut(true), 10_000)
|
|
69
|
+
return () => clearTimeout(t)
|
|
70
|
+
}, [isResolved, buttonsReady])
|
|
71
|
+
|
|
72
|
+
const finalizeCapturedPayment = useCallback(async () => {
|
|
73
|
+
const captured = capturedRef.current
|
|
74
|
+
if (!captured) return
|
|
75
|
+
setProcessing(true)
|
|
76
|
+
showProcessingOverlay()
|
|
77
|
+
setError(null)
|
|
78
|
+
try {
|
|
79
|
+
const completeResult = await markPaymentComplete(baseUrl, cartId, publishableApiKey)
|
|
80
|
+
await onPaid?.({ ...captured, ...completeResult })
|
|
81
|
+
setCompletionPending(false)
|
|
82
|
+
} catch (e: unknown) {
|
|
83
|
+
if (isNextRouterError(e)) return
|
|
84
|
+
hideProcessingOverlay()
|
|
85
|
+
setProcessing(false)
|
|
86
|
+
setCompletionPending(true)
|
|
87
|
+
const msg =
|
|
88
|
+
"Your payment was received, but finalizing your order failed. Please use the Retry button below — do not pay again."
|
|
89
|
+
setError(msg)
|
|
90
|
+
onError?.(msg)
|
|
91
|
+
}
|
|
92
|
+
}, [baseUrl, cartId, publishableApiKey, onPaid, onError])
|
|
93
|
+
|
|
94
|
+
const showSpinner = (isPending || (isResolved && !buttonsReady)) && !loadTimedOut
|
|
45
95
|
|
|
46
96
|
if (!config.currency_supported) return null
|
|
47
97
|
|
|
@@ -170,6 +220,30 @@ export function PayPalSmartButtons(props: {
|
|
|
170
220
|
</div>
|
|
171
221
|
)}
|
|
172
222
|
|
|
223
|
+
{loadTimedOut && !buttonsReady && !isRejected && (
|
|
224
|
+
<div
|
|
225
|
+
role="alert"
|
|
226
|
+
style={{
|
|
227
|
+
display: "flex",
|
|
228
|
+
alignItems: "flex-start",
|
|
229
|
+
gap: 8,
|
|
230
|
+
padding: "10px 14px",
|
|
231
|
+
background: "#fef2f2",
|
|
232
|
+
border: "1px solid #fecaca",
|
|
233
|
+
borderRadius: 8,
|
|
234
|
+
fontSize: 13,
|
|
235
|
+
color: "#b91c1c",
|
|
236
|
+
lineHeight: 1.5,
|
|
237
|
+
}}
|
|
238
|
+
>
|
|
239
|
+
<span style={{ flexShrink: 0, fontSize: 15 }}>⚠️</span>
|
|
240
|
+
<span>
|
|
241
|
+
PayPal is not available for this purchase. Please try a different
|
|
242
|
+
payment method.
|
|
243
|
+
</span>
|
|
244
|
+
</div>
|
|
245
|
+
)}
|
|
246
|
+
|
|
173
247
|
{isRejected && (
|
|
174
248
|
<div
|
|
175
249
|
role="alert"
|
|
@@ -230,7 +304,7 @@ export function PayPalSmartButtons(props: {
|
|
|
230
304
|
</div>
|
|
231
305
|
)}
|
|
232
306
|
|
|
233
|
-
{isResolved && (
|
|
307
|
+
{isResolved && !completionPending && (
|
|
234
308
|
<div style={buttonsReady ? undefined : { position: "absolute", left: -9999, opacity: 0, pointerEvents: "none" }}>
|
|
235
309
|
<PayPalButtons
|
|
236
310
|
forceReRender={[config.currency, config.intent, cartId]}
|
|
@@ -244,6 +318,12 @@ export function PayPalSmartButtons(props: {
|
|
|
244
318
|
}}
|
|
245
319
|
createOrder={async () => {
|
|
246
320
|
if (processing) throw new Error("Payment already processing")
|
|
321
|
+
if (capturedRef.current) {
|
|
322
|
+
// Money was already captured for this cart — retry the order
|
|
323
|
+
// finalization instead of creating (and charging) a new order.
|
|
324
|
+
void finalizeCapturedPayment()
|
|
325
|
+
throw new Error("Your payment was already received — finalizing your order.")
|
|
326
|
+
}
|
|
247
327
|
setError(null)
|
|
248
328
|
setProcessing(true)
|
|
249
329
|
try {
|
|
@@ -262,10 +342,7 @@ export function PayPalSmartButtons(props: {
|
|
|
262
342
|
const orderId = String(data?.orderID || "")
|
|
263
343
|
if (!orderId) throw new Error("PayPal order ID is missing from approval response")
|
|
264
344
|
const result = await api.captureOrder(cartId, orderId)
|
|
265
|
-
|
|
266
|
-
const completeResult = await markPaymentComplete(baseUrl, cartId, publishableApiKey)
|
|
267
|
-
|
|
268
|
-
await onPaid?.({ ...result, ...completeResult })
|
|
345
|
+
capturedRef.current = (result || {}) as Record<string, unknown>
|
|
269
346
|
} catch (e: unknown) {
|
|
270
347
|
if (isNextRouterError(e)) return
|
|
271
348
|
hideProcessingOverlay()
|
|
@@ -273,7 +350,11 @@ export function PayPalSmartButtons(props: {
|
|
|
273
350
|
const msg = e instanceof Error ? e.message : "Payment capture failed"
|
|
274
351
|
setError(msg)
|
|
275
352
|
onError?.(msg)
|
|
353
|
+
return
|
|
276
354
|
}
|
|
355
|
+
// Past this point the buyer has been charged: completion failures
|
|
356
|
+
// surface a finalize-only Retry, never a fresh payment.
|
|
357
|
+
await finalizeCapturedPayment()
|
|
277
358
|
}}
|
|
278
359
|
onCancel={() => {
|
|
279
360
|
hideProcessingOverlay()
|
|
@@ -290,6 +371,30 @@ export function PayPalSmartButtons(props: {
|
|
|
290
371
|
</div>
|
|
291
372
|
)}
|
|
292
373
|
|
|
374
|
+
{completionPending && (
|
|
375
|
+
<button
|
|
376
|
+
type="button"
|
|
377
|
+
onClick={() => void finalizeCapturedPayment()}
|
|
378
|
+
disabled={processing}
|
|
379
|
+
aria-busy={processing}
|
|
380
|
+
style={{
|
|
381
|
+
width: "100%",
|
|
382
|
+
padding: "12px 16px",
|
|
383
|
+
marginTop: 4,
|
|
384
|
+
background: "#0070ba",
|
|
385
|
+
color: "#ffffff",
|
|
386
|
+
border: "none",
|
|
387
|
+
borderRadius: 8,
|
|
388
|
+
fontSize: 14,
|
|
389
|
+
fontWeight: 600,
|
|
390
|
+
cursor: processing ? "not-allowed" : "pointer",
|
|
391
|
+
opacity: processing ? 0.7 : 1,
|
|
392
|
+
}}
|
|
393
|
+
>
|
|
394
|
+
Retry — finish placing my order
|
|
395
|
+
</button>
|
|
396
|
+
)}
|
|
397
|
+
|
|
293
398
|
{error ? (
|
|
294
399
|
<div
|
|
295
400
|
role="alert"
|
package/src/utils/next-errors.ts
CHANGED
|
@@ -12,6 +12,10 @@ export function isNextRouterError(e: unknown): boolean {
|
|
|
12
12
|
const digest = (e as { digest: unknown }).digest
|
|
13
13
|
return (
|
|
14
14
|
typeof digest === "string" &&
|
|
15
|
-
(digest.startsWith("NEXT_REDIRECT") ||
|
|
15
|
+
(digest.startsWith("NEXT_REDIRECT") ||
|
|
16
|
+
digest.startsWith("NEXT_NOT_FOUND") ||
|
|
17
|
+
// Next.js 15: notFound()/forbidden()/unauthorized() throw
|
|
18
|
+
// HTTPAccessFallbackError with digest "NEXT_HTTP_ERROR_FALLBACK;<status>".
|
|
19
|
+
digest.startsWith("NEXT_HTTP_ERROR_FALLBACK"))
|
|
16
20
|
)
|
|
17
21
|
}
|
|
@@ -69,8 +69,20 @@ export function showProcessingOverlay() {
|
|
|
69
69
|
onNav()
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
+
// bfcache restore: the overlay intentionally survives the unload of a
|
|
73
|
+
// full-page navigation (that's its purpose), but when the buyer presses
|
|
74
|
+
// Back the browser restores this page from the back/forward cache with the
|
|
75
|
+
// overlay element still in the DOM and the URL equal to startUrl — no nav
|
|
76
|
+
// listener ever fires, leaving checkout permanently covered. `pageshow`
|
|
77
|
+
// with `persisted: true` is exactly that restore.
|
|
78
|
+
const onPageShow = (e: PageTransitionEvent) => {
|
|
79
|
+
if (e.persisted) hideProcessingOverlay()
|
|
80
|
+
}
|
|
81
|
+
window.addEventListener("pageshow", onPageShow)
|
|
82
|
+
|
|
72
83
|
cleanupNavListener = () => {
|
|
73
84
|
window.removeEventListener("popstate", onNav)
|
|
85
|
+
window.removeEventListener("pageshow", onPageShow)
|
|
74
86
|
history.pushState = origPush
|
|
75
87
|
history.replaceState = origReplace
|
|
76
88
|
}
|