@easypayment/medusa-paypal-ui 1.0.50 → 1.0.52

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.
@@ -19,8 +19,6 @@ function toHeaderRecord(headers?: RequestInit["headers"]): Record<string, string
19
19
  return { ...headers }
20
20
  }
21
21
 
22
- const DEFAULT_TIMEOUT_MS = 30000
23
-
24
22
  export function createHttpClient(opts: HttpOptions) {
25
23
  const base = opts.baseUrl.replace(/\/+$/, "")
26
24
 
@@ -35,36 +33,25 @@ export function createHttpClient(opts: HttpOptions) {
35
33
  headers["x-publishable-api-key"] = opts.publishableApiKey
36
34
  }
37
35
 
38
- // Always enforce a timeout so a hung backend can't leave the checkout
39
- // spinner up forever, while still honouring a caller-provided abort signal.
36
+ const timeoutMs = 30_000
37
+ let timeoutId: ReturnType<typeof setTimeout> | undefined
40
38
  const controller = new AbortController()
41
- let timedOut = false
42
- const timer = setTimeout(() => {
43
- timedOut = true
44
- controller.abort()
45
- }, DEFAULT_TIMEOUT_MS)
46
- const callerSignal = init?.signal
47
- if (callerSignal) {
48
- if (callerSignal.aborted) {
49
- controller.abort()
50
- } else {
51
- callerSignal.addEventListener("abort", () => controller.abort(), { once: true })
52
- }
39
+ if (!init?.signal) {
40
+ timeoutId = setTimeout(() => controller.abort(), timeoutMs)
53
41
  }
54
-
42
+ const effectiveSignal = init?.signal || controller.signal
55
43
  let res: Response
56
- let text: string
57
44
  try {
58
- res = await fetch(url, { ...init, headers, credentials: "include", signal: controller.signal })
59
- text = await res.text().catch(() => "")
60
- } catch (e) {
61
- if (timedOut) {
62
- throw new Error("[PayPal] Request timed out. Please check your connection and try again.")
45
+ res = await fetch(url, { ...init, headers, credentials: "include", signal: effectiveSignal })
46
+ } catch (err) {
47
+ if (err instanceof Error && err.name === "AbortError" && !init?.signal) {
48
+ throw new Error(`[PayPal] Request to ${path} timed out after ${timeoutMs / 1000}s`)
63
49
  }
64
- throw e
50
+ throw err
65
51
  } finally {
66
- clearTimeout(timer)
52
+ if (timeoutId !== undefined) clearTimeout(timeoutId)
67
53
  }
54
+ const text = await res.text().catch(() => "")
68
55
 
69
56
  if (!res.ok) {
70
57
  if (res.status === 401) {
@@ -77,25 +64,9 @@ export function createHttpClient(opts: HttpOptions) {
77
64
  "[PayPal] Forbidden (403) — this request is not allowed. Check your CORS and API key settings."
78
65
  )
79
66
  }
80
- // Prefer the backend's clean { message, request_id } JSON. For 5xx, do not
81
- // surface raw server internals to end users — log them and show a generic
82
- // message (with a correlation id when available).
83
- type ErrBody = { message?: unknown; request_id?: unknown }
84
- let parsed: ErrBody | null = null
85
- try {
86
- parsed = text ? (JSON.parse(text) as ErrBody) : null
87
- } catch {
88
- parsed = null
89
- }
90
- const backendMessage = typeof parsed?.message === "string" ? parsed.message : ""
91
- const requestId = typeof parsed?.request_id === "string" ? parsed.request_id : ""
92
- if (res.status >= 500) {
93
- console.error(`[PayPal] ${path} failed (${res.status})`, text.slice(0, 1000))
94
- throw new Error(
95
- `Payment service error${requestId ? ` (ref ${requestId})` : ""}. Please try again or contact support.`
96
- )
97
- }
98
- throw new Error(backendMessage || `Request failed (${res.status})`)
67
+ throw new Error(
68
+ text.slice(0, 500).replace(/<[^>]*>/g, "") || `Request failed (${res.status})`
69
+ )
99
70
  }
100
71
  if (!text) {
101
72
  throw new Error(`[PayPal] Empty response body from ${path} (${res.status})`)
@@ -1,47 +1,17 @@
1
1
  import type { PayPalConfig, PayPalSettingsResponse } from "./types"
2
2
  import { createHttpClient, type HttpOptions } from "./http"
3
3
 
4
- /**
5
- * True when an error is a Next.js navigation signal (`redirect()` / `notFound()`
6
- * from a server action). These MUST be re-thrown / ignored, never shown as an
7
- * error — a successful `placeOrder()` redirect surfaces as one of these.
8
- */
9
- export function isNextRedirectError(e: unknown): boolean {
10
- if (!e || typeof e !== "object") {
11
- return false
12
- }
13
- const digest = (e as { digest?: unknown }).digest
14
- return (
15
- typeof digest === "string" &&
16
- (digest.startsWith("NEXT_REDIRECT") || digest === "NEXT_NOT_FOUND")
17
- )
18
- }
19
-
20
4
  export async function markPaymentComplete(
21
5
  baseUrl: string,
22
6
  cartId: string,
23
7
  publishableApiKey?: string
24
- ): Promise<{ ok: boolean } & Record<string, any>> {
25
- try {
26
- const resp = await fetch(`${baseUrl.replace(/\/$/, "")}/store/paypal-complete`, {
27
- method: "POST",
28
- headers: {
29
- "Content-Type": "application/json",
30
- ...(publishableApiKey ? { "x-publishable-api-key": publishableApiKey } : {}),
31
- },
32
- body: JSON.stringify({ cart_id: cartId }),
33
- credentials: "include",
34
- })
35
- const data = await resp.json().catch(() => ({}))
36
- if (!resp.ok) {
37
- console.warn("[PayPal] paypal-complete returned", resp.status, data)
38
- return { ok: false, ...(data || {}) }
39
- }
40
- return { ok: true, ...(data || {}) }
41
- } catch (e) {
42
- console.warn("[PayPal] paypal-complete call failed:", e)
43
- return { ok: false }
44
- }
8
+ ): Promise<Record<string, unknown>> {
9
+ const http = createHttpClient({ baseUrl, publishableApiKey })
10
+ return http.request<Record<string, unknown>>(`/store/paypal-complete`, {
11
+ method: "POST",
12
+ headers: { "Content-Type": "application/json" },
13
+ body: JSON.stringify({ cart_id: cartId }),
14
+ })
45
15
  }
46
16
 
47
17
  export function createPayPalStoreApi(opts: HttpOptions) {
@@ -1,18 +1,24 @@
1
1
  "use client"
2
2
 
3
- import React, { useMemo, useState } from "react"
3
+ import React, { useMemo, useRef, useState } from "react"
4
4
  import {
5
5
  PayPalCardFieldsProvider,
6
6
  PayPalNumberField,
7
7
  PayPalExpiryField,
8
8
  PayPalCVVField,
9
9
  usePayPalCardFields,
10
+ usePayPalScriptReducer,
10
11
  } from "@paypal/react-paypal-js"
11
12
 
12
13
  import { createPayPalStoreApi, markPaymentComplete } from "../client/paypal"
13
14
  import type { PayPalConfig } from "../client/types"
15
+ import { isNextRouterError } from "../utils/next-errors"
16
+ import { showProcessingOverlay, hideProcessingOverlay } from "../utils/processing-overlay"
14
17
 
15
- const SPIN_STYLE = `@keyframes _pp_spin { to { transform: rotate(360deg) } }`
18
+ const SPIN_STYLE = `
19
+ @keyframes _pp_spin { to { transform: rotate(360deg) } }
20
+ @keyframes _pp_progress { 0% { transform: translateX(-100%) } 100% { transform: translateX(350%) } }
21
+ `
16
22
 
17
23
  const cardStyle = {
18
24
  input: {
@@ -62,12 +68,10 @@ function SubmitButton({
62
68
  disabled,
63
69
  label,
64
70
  onSubmit,
65
- onSubmitError,
66
71
  }: {
67
72
  disabled: boolean
68
73
  label: string
69
74
  onSubmit: () => void
70
- onSubmitError: (message: string) => void
71
75
  }) {
72
76
  const { cardFieldsForm } = usePayPalCardFields()
73
77
  const isDisabled = disabled || !cardFieldsForm
@@ -76,18 +80,13 @@ function SubmitButton({
76
80
  <button
77
81
  type="button"
78
82
  disabled={isDisabled}
83
+ aria-busy={disabled}
79
84
  onClick={() => {
80
85
  onSubmit()
81
- // submit() can reject (network / tokenization / 3DS) WITHOUT firing the
82
- // provider onError, which would otherwise leave the "Processing…"
83
- // overlay stuck forever. Always catch it and surface the error.
84
- const submitted = cardFieldsForm?.submit()
85
- if (submitted && typeof submitted.catch === "function") {
86
- submitted.catch((e: unknown) => {
87
- onSubmitError(
88
- e instanceof Error ? e.message : "Card payment failed. Please try again."
89
- )
90
- })
86
+ try {
87
+ cardFieldsForm?.submit()
88
+ } catch {
89
+ // submit() error is handled by PayPalCardFieldsProvider onError
91
90
  }
92
91
  }}
93
92
  style={{
@@ -149,9 +148,37 @@ export function PayPalAdvancedCard(props: {
149
148
 
150
149
  const [error, setError] = useState<string | null>(null)
151
150
  const [submitting, setSubmitting] = useState(false)
151
+ const submittingRef = useRef(false)
152
+ const [{ isPending, isResolved, isRejected }] = usePayPalScriptReducer()
152
153
 
153
154
  if (!config.currency_supported) return null
154
155
 
156
+ if (isRejected) {
157
+ return (
158
+ <div
159
+ role="alert"
160
+ style={{
161
+ display: "flex",
162
+ alignItems: "flex-start",
163
+ gap: 8,
164
+ padding: "10px 14px",
165
+ background: "#fef2f2",
166
+ border: "1px solid #fecaca",
167
+ borderRadius: 8,
168
+ fontSize: 13,
169
+ color: "#b91c1c",
170
+ lineHeight: 1.5,
171
+ }}
172
+ >
173
+ <span style={{ flexShrink: 0, fontSize: 15 }}>⚠️</span>
174
+ <span>
175
+ PayPal failed to load. Please refresh the page or try a different
176
+ payment method.
177
+ </span>
178
+ </div>
179
+ )
180
+ }
181
+
155
182
  if (!config.client_token) {
156
183
  return (
157
184
  <div
@@ -171,50 +198,138 @@ export function PayPalAdvancedCard(props: {
171
198
 
172
199
  const isSandbox = config.environment === "sandbox"
173
200
 
201
+ if (isPending) {
202
+ return (
203
+ <div
204
+ role="status"
205
+ aria-label="Loading PayPal"
206
+ style={{
207
+ display: "flex",
208
+ alignItems: "center",
209
+ justifyContent: "center",
210
+ gap: 10,
211
+ padding: "18px 16px",
212
+ background: "#f9fafb",
213
+ border: "1px solid #e5e7eb",
214
+ borderRadius: 8,
215
+ minHeight: 55,
216
+ }}
217
+ >
218
+ <style>{SPIN_STYLE}</style>
219
+ <div
220
+ style={{
221
+ width: 22,
222
+ height: 22,
223
+ borderRadius: "50%",
224
+ border: "2.5px solid #e5e7eb",
225
+ borderTopColor: "#0070ba",
226
+ animation: "_pp_spin .7s linear infinite",
227
+ flexShrink: 0,
228
+ }}
229
+ />
230
+ <div style={{ fontSize: 13, fontWeight: 500, color: "#6b7280" }}>
231
+ Loading card fields…
232
+ </div>
233
+ </div>
234
+ )
235
+ }
236
+
237
+ if (!isResolved) return null
238
+
174
239
  return (
175
240
  <div style={{ position: "relative" }}>
176
241
  <style>{SPIN_STYLE}</style>
177
242
 
178
243
  {submitting && (
179
244
  <div
245
+ role="status"
246
+ aria-label="Processing payment"
180
247
  style={{
181
- position: "absolute",
248
+ position: "fixed",
182
249
  inset: 0,
183
- zIndex: 20,
184
- background: "rgba(255,255,255,0.92)",
185
- borderRadius: 12,
250
+ zIndex: 9999,
251
+ background: "rgba(255,255,255,0.96)",
186
252
  display: "flex",
187
253
  flexDirection: "column",
188
254
  alignItems: "center",
189
255
  justifyContent: "center",
190
- gap: 14,
191
- minHeight: 180,
256
+ gap: 20,
192
257
  }}
193
258
  >
194
- <div
195
- style={{
196
- width: 36,
197
- height: 36,
198
- borderRadius: "50%",
199
- border: "3px solid #dbeafe",
200
- borderTopColor: "#2563eb",
201
- animation: "_pp_spin .75s linear infinite",
202
- }}
203
- />
259
+ <div style={{ position: "relative", width: 56, height: 56 }}>
260
+ <div
261
+ style={{
262
+ position: "absolute",
263
+ inset: 0,
264
+ borderRadius: "50%",
265
+ border: "3px solid #e5e7eb",
266
+ }}
267
+ />
268
+ <div
269
+ style={{
270
+ position: "absolute",
271
+ inset: 0,
272
+ borderRadius: "50%",
273
+ border: "3px solid transparent",
274
+ borderTopColor: "#2563eb",
275
+ animation: "_pp_spin .8s linear infinite",
276
+ }}
277
+ />
278
+ <svg
279
+ viewBox="0 0 24 24"
280
+ fill="none"
281
+ stroke="#2563eb"
282
+ strokeWidth="1.8"
283
+ strokeLinecap="round"
284
+ strokeLinejoin="round"
285
+ style={{
286
+ position: "absolute",
287
+ top: "50%",
288
+ left: "50%",
289
+ transform: "translate(-50%, -50%)",
290
+ width: 24,
291
+ height: 24,
292
+ }}
293
+ >
294
+ <path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
295
+ </svg>
296
+ </div>
204
297
  <div style={{ textAlign: "center" }}>
205
- <div style={{ fontSize: 15, fontWeight: 600, color: "#111827" }}>
206
- Processing your payment
298
+ <div style={{ fontSize: 18, fontWeight: 600, color: "#111827", letterSpacing: "-0.01em" }}>
299
+ Processing your payment
207
300
  </div>
208
- <div style={{ fontSize: 13, color: "#6b7280", marginTop: 4 }}>
209
- Please do not close or refresh this page
301
+ <div style={{ fontSize: 14, color: "#6b7280", marginTop: 6, lineHeight: 1.5 }}>
302
+ This may take a few moments. Please do not close<br />
303
+ or refresh this page.
210
304
  </div>
211
305
  </div>
306
+ <div
307
+ style={{
308
+ width: 200,
309
+ height: 3,
310
+ background: "#e5e7eb",
311
+ borderRadius: 3,
312
+ overflow: "hidden",
313
+ marginTop: 4,
314
+ }}
315
+ >
316
+ <div
317
+ style={{
318
+ width: "40%",
319
+ height: "100%",
320
+ background: "linear-gradient(90deg, #2563eb, #1d4ed8)",
321
+ borderRadius: 3,
322
+ animation: "_pp_progress 1.5s ease-in-out infinite",
323
+ }}
324
+ />
325
+ </div>
212
326
  </div>
213
327
  )}
214
328
 
215
329
  <PayPalCardFieldsProvider
216
330
  style={cardStyle}
217
331
  createOrder={async () => {
332
+ if (submittingRef.current) throw new Error("Payment already processing")
218
333
  setError(null)
219
334
  const r = await api.createOrder(cartId, true)
220
335
  return r.id
@@ -222,27 +337,34 @@ export function PayPalAdvancedCard(props: {
222
337
  onApprove={async (data: { orderID?: string }) => {
223
338
  try {
224
339
  setError(null)
340
+ showProcessingOverlay()
225
341
  const orderId = String(data?.orderID || "")
226
342
  const result = await api.captureOrder(cartId, orderId)
227
343
 
228
344
  const completeResult = await markPaymentComplete(baseUrl, cartId, publishableApiKey)
229
345
 
230
- onPaid?.({ ...result, ...completeResult })
346
+ await onPaid?.({ ...result, ...completeResult })
231
347
  } catch (e: unknown) {
348
+ if (isNextRouterError(e)) throw e
349
+ hideProcessingOverlay()
350
+ submittingRef.current = false
351
+ setSubmitting(false)
232
352
  const msg = e instanceof Error ? e.message : "Card payment failed"
233
353
  setError(msg)
234
354
  onError?.(msg)
235
- } finally {
236
- setSubmitting(false)
237
355
  }
238
356
  }}
239
357
  onCancel={() => {
358
+ hideProcessingOverlay()
359
+ submittingRef.current = false
240
360
  setSubmitting(false)
241
361
  }}
242
362
  onError={(e: Error | { message?: string }) => {
243
363
  const msg = e instanceof Error ? e.message : e?.message || "CardFields error"
244
364
  setError(msg)
245
365
  onError?.(msg)
366
+ hideProcessingOverlay()
367
+ submittingRef.current = false
246
368
  setSubmitting(false)
247
369
  }}
248
370
  >
@@ -330,18 +452,17 @@ export function PayPalAdvancedCard(props: {
330
452
  disabled={submitting}
331
453
  label={submitting ? "Processing…" : "Pay by Card"}
332
454
  onSubmit={() => {
455
+ if (submittingRef.current) return
456
+ submittingRef.current = true
333
457
  setError(null)
334
458
  setSubmitting(true)
335
- }}
336
- onSubmitError={(msg) => {
337
- setError(msg)
338
- onError?.(msg)
339
- setSubmitting(false)
459
+ showProcessingOverlay()
340
460
  }}
341
461
  />
342
462
 
343
463
  {error && (
344
464
  <div
465
+ role="alert"
345
466
  style={{
346
467
  display: "flex",
347
468
  alignItems: "flex-start",
@@ -7,7 +7,7 @@ export function PayPalCurrencyNotice({ config }: { config: PayPalConfig }) {
7
7
  if (config.currency_supported) return null
8
8
 
9
9
  return (
10
- <div style={{ padding: 12, border: "1px solid #ddd", borderRadius: 10 }}>
10
+ <div role="alert" style={{ padding: 12, border: "1px solid #ddd", borderRadius: 10 }}>
11
11
  <div style={{ fontWeight: 600, marginBottom: 6 }}>PayPal currency issue</div>
12
12
  <ul style={{ margin: 0, paddingLeft: 18 }}>
13
13
  {(config.currency_errors || []).map((e) => (
@@ -6,7 +6,6 @@ import { PayPalAdvancedCard } from "./PayPalAdvancedCard"
6
6
  import { PayPalProvider } from "./PayPalProvider"
7
7
  import { PayPalSmartButtons } from "./PayPalSmartButtons"
8
8
  import { usePayPalConfig } from "../hooks/usePayPalConfig"
9
- import { isNextRedirectError } from "../client/paypal"
10
9
 
11
10
  export const PAYPAL_WALLET_PROVIDER_ID = "pp_paypal_paypal" as const
12
11
  export const PAYPAL_CARD_PROVIDER_ID = "pp_paypal_card_paypal_card" as const
@@ -26,6 +25,8 @@ const SPIN_STYLE = `@keyframes _pp_section_spin { to { transform: rotate(360deg)
26
25
  function SessionInitCard() {
27
26
  return (
28
27
  <div
28
+ role="status"
29
+ aria-label="Setting up payment"
29
30
  style={{
30
31
  display: "flex",
31
32
  alignItems: "center",
@@ -59,6 +60,8 @@ function SessionInitCard() {
59
60
  function ConfigLoadingCard() {
60
61
  return (
61
62
  <div
63
+ role="status"
64
+ aria-label="Connecting to PayPal"
62
65
  style={{
63
66
  display: "flex",
64
67
  alignItems: "center",
@@ -96,6 +99,7 @@ function ConfigLoadingCard() {
96
99
  function ErrorCard({ message }: { message: string }) {
97
100
  return (
98
101
  <div
102
+ role="alert"
99
103
  style={{
100
104
  padding: "12px 16px",
101
105
  background: "#fef2f2",
@@ -141,21 +145,11 @@ export function PayPalPaymentSection({
141
145
  })
142
146
 
143
147
  const handlePaid = useCallback(
144
- (captureResult: unknown) => {
148
+ async (captureResult: unknown) => {
145
149
  onPaid?.(captureResult)
146
- // onSuccess usually calls placeOrder(), which redirects on success. Run it
147
- // without blocking the spinner, but surface real failures so a captured
148
- // payment never ends silently with no order and no error shown.
149
- Promise.resolve(onSuccess?.(cartId)).catch((e: unknown) => {
150
- if (isNextRedirectError(e)) return
151
- onError?.(
152
- e instanceof Error
153
- ? e.message
154
- : "Your payment was taken but the order could not be finalized. Please contact support before paying again."
155
- )
156
- })
150
+ await onSuccess?.(cartId)
157
151
  },
158
- [cartId, onPaid, onSuccess, onError]
152
+ [cartId, onPaid, onSuccess]
159
153
  )
160
154
 
161
155
  if (!shouldRender) return null
@@ -25,7 +25,7 @@ export function PayPalProvider(props: {
25
25
  "disable-funding": disableFunding,
26
26
  "data-partner-attribution-id": BN_CODE,
27
27
  }
28
- }, [config, intent, disableFunding])
28
+ }, [config.client_id, config.currency, config.client_token, intent, disableFunding])
29
29
 
30
30
  return (
31
31
  <PayPalScriptProvider