@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.
@@ -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
- if (!init?.signal) {
72
- timeoutId = setTimeout(() => controller.abort(), timeoutMs)
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, { ...init, headers, credentials: "include", signal: effectiveSignal })
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 (err instanceof Error && err.name === "AbortError" && !init?.signal) {
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
- if (timeoutId !== undefined) clearTimeout(timeoutId)
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
- "[PayPal] Unauthorized (401) — check that your publishable API key is correct and set in NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY",
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
- "[PayPal] Forbidden (403) — this request is not allowed. Check your CORS and API key settings.",
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
- text.slice(0, 500).replace(/<[^>]*>/g, "") || `Request failed (${res.status})`,
144
+ parsedMessage ||
145
+ text.slice(0, 500).replace(/<[^>]*>/g, "") ||
146
+ `Request failed (${res.status})`,
103
147
  res.status
104
148
  )
105
149
  }
@@ -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
- try {
87
- cardFieldsForm?.submit()
88
- } catch {
89
- // submit() error is handled by PayPalCardFieldsProvider onError
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
- hideProcessingOverlay()
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
- <SubmitButton
462
- disabled={submitting}
463
- label={submitting ? "Processing…" : "Pay by Card"}
464
- onSubmit={() => {
465
- if (submittingRef.current) return
466
- submittingRef.current = true
467
- setError(null)
468
- setSubmitting(true)
469
- showProcessingOverlay()
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
- const showSpinner = isPending || (isResolved && !buttonsReady)
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"
@@ -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") || digest.startsWith("NEXT_NOT_FOUND"))
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
  }