@easypayment/medusa-paypal-ui 1.2.8 → 1.3.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.
@@ -13,11 +13,12 @@ import {
13
13
  showProcessingOverlay,
14
14
  hideProcessingOverlay,
15
15
  } from "../utils/processing-overlay"
16
-
17
- const SPIN_STYLE = `
18
- @keyframes _pp_spin { to { transform: rotate(360deg) } }
19
- @keyframes _pp_progress { 0% { transform: translateX(-100%) } 100% { transform: translateX(350%) } }
20
- `
16
+ import {
17
+ AnimationStyles,
18
+ ErrorBanner,
19
+ LoadingCard,
20
+ ProcessingOverlayView,
21
+ } from "./internal/ui"
21
22
 
22
23
  const BUTTON_WIDTH_MAP: Record<string, string> = {
23
24
  small: "300px",
@@ -29,19 +30,35 @@ const BUTTON_WIDTH_MAP: Record<string, string> = {
29
30
  export function PayPalSmartButtons(props: {
30
31
  baseUrl: string
31
32
  publishableApiKey?: string
33
+ /** Forwarded to every API call — see `HttpOptions.credentials`. */
34
+ credentials?: RequestCredentials
32
35
  cartId: string
33
36
  config: PayPalConfig
34
- onPaid?: (result: Record<string, unknown>) => void
37
+ onPaid?: (result: Record<string, unknown>) => void | Promise<void>
35
38
  onError?: (message: string) => void
36
39
  }) {
37
- const { baseUrl, publishableApiKey, cartId, config, onPaid, onError } = props
40
+ const {
41
+ baseUrl,
42
+ publishableApiKey,
43
+ credentials,
44
+ cartId,
45
+ config,
46
+ onPaid,
47
+ onError,
48
+ } = props
38
49
  const api = useMemo(
39
- () => createPayPalStoreApi({ baseUrl, publishableApiKey }),
40
- [baseUrl, publishableApiKey]
50
+ () => createPayPalStoreApi({ baseUrl, publishableApiKey, credentials }),
51
+ [baseUrl, publishableApiKey, credentials]
41
52
  )
42
53
 
43
54
  const [error, setError] = useState<string | null>(null)
44
55
  const [processing, setProcessing] = useState(false)
56
+ // Synchronous mirror of `processing` — React state updates are async, so a
57
+ // second click in the same tick would slip past a state-based guard.
58
+ const processingRef = useRef(false)
59
+ // True while the shared full-page DOM overlay is mounted, so the component
60
+ // doesn't also render its own copy on top of it.
61
+ const [domOverlayUp, setDomOverlayUp] = useState(false)
45
62
  const [buttonsReady, setButtonsReady] = useState(false)
46
63
  const [loadTimedOut, setLoadTimedOut] = useState(false)
47
64
  // Once a capture succeeded, the buyer's money is taken — any later failure
@@ -51,6 +68,23 @@ export function PayPalSmartButtons(props: {
51
68
  const [completionPending, setCompletionPending] = useState(false)
52
69
  const [{ isPending, isResolved, isRejected }] = usePayPalScriptReducer()
53
70
 
71
+ const startProcessing = useCallback(() => {
72
+ processingRef.current = true
73
+ setProcessing(true)
74
+ }, [])
75
+
76
+ const stopProcessing = useCallback(() => {
77
+ hideProcessingOverlay()
78
+ setDomOverlayUp(false)
79
+ processingRef.current = false
80
+ setProcessing(false)
81
+ }, [])
82
+
83
+ const showOverlay = useCallback(() => {
84
+ showProcessingOverlay()
85
+ setDomOverlayUp(true)
86
+ }, [])
87
+
54
88
  const handleInit = useCallback(() => {
55
89
  setButtonsReady(true)
56
90
  }, [])
@@ -60,7 +94,11 @@ export function PayPalSmartButtons(props: {
60
94
  // behind the in-component overlay.
61
95
  useEffect(() => {
62
96
  const onPageShow = (e: PageTransitionEvent) => {
63
- if (e.persisted) setProcessing(false)
97
+ if (e.persisted) {
98
+ processingRef.current = false
99
+ setProcessing(false)
100
+ setDomOverlayUp(false)
101
+ }
64
102
  }
65
103
  window.addEventListener("pageshow", onPageShow)
66
104
  return () => window.removeEventListener("pageshow", onPageShow)
@@ -93,15 +131,14 @@ export function PayPalSmartButtons(props: {
93
131
  const finalizeCapturedPayment = useCallback(async () => {
94
132
  const captured = capturedRef.current
95
133
  if (!captured) return
96
- setProcessing(true)
97
- showProcessingOverlay()
134
+ startProcessing()
135
+ showOverlay()
98
136
  setError(null)
99
137
  try {
100
- const completeResult = await markPaymentComplete(
101
- baseUrl,
102
- cartId,
103
- publishableApiKey
104
- )
138
+ const completeResult = await markPaymentComplete(baseUrl, cartId, {
139
+ publishableApiKey,
140
+ credentials,
141
+ })
105
142
  // The order is finalized — the persisted "captured" flag has served its
106
143
  // purpose. Clear it before the consumer callback (which may redirect).
107
144
  clearCartCaptured(cartId)
@@ -109,15 +146,24 @@ export function PayPalSmartButtons(props: {
109
146
  setCompletionPending(false)
110
147
  } catch (e: unknown) {
111
148
  if (isNextRouterError(e)) return
112
- hideProcessingOverlay()
113
- setProcessing(false)
149
+ stopProcessing()
114
150
  setCompletionPending(true)
115
151
  const msg =
116
152
  "Your payment was received, but finalizing your order failed. Please use the Retry button below — do not pay again."
117
153
  setError(msg)
118
154
  onError?.(msg)
119
155
  }
120
- }, [baseUrl, cartId, publishableApiKey, onPaid, onError])
156
+ }, [
157
+ baseUrl,
158
+ cartId,
159
+ publishableApiKey,
160
+ credentials,
161
+ onPaid,
162
+ onError,
163
+ startProcessing,
164
+ showOverlay,
165
+ stopProcessing,
166
+ ])
121
167
 
122
168
  const showSpinner =
123
169
  (isPending || (isResolved && !buttonsReady)) && !loadTimedOut
@@ -129,188 +175,26 @@ export function PayPalSmartButtons(props: {
129
175
 
130
176
  return (
131
177
  <div style={{ width: containerWidth, position: "relative" }}>
132
- <style>{SPIN_STYLE}</style>
178
+ <AnimationStyles />
133
179
 
134
- {processing && (
135
- <div
136
- role="status"
137
- aria-label="Processing payment"
138
- style={{
139
- position: "fixed",
140
- inset: 0,
141
- zIndex: 9999,
142
- background: "rgba(255,255,255,0.96)",
143
- display: "flex",
144
- flexDirection: "column",
145
- alignItems: "center",
146
- justifyContent: "center",
147
- gap: 20,
148
- }}
149
- >
150
- <div style={{ position: "relative", width: 56, height: 56 }}>
151
- <div
152
- style={{
153
- position: "absolute",
154
- inset: 0,
155
- borderRadius: "50%",
156
- border: "3px solid #e5e7eb",
157
- }}
158
- />
159
- <div
160
- style={{
161
- position: "absolute",
162
- inset: 0,
163
- borderRadius: "50%",
164
- border: "3px solid transparent",
165
- borderTopColor: "#0070ba",
166
- animation: "_pp_spin .8s linear infinite",
167
- }}
168
- />
169
- <svg
170
- viewBox="0 0 24 24"
171
- fill="none"
172
- stroke="#0070ba"
173
- strokeWidth="1.8"
174
- strokeLinecap="round"
175
- strokeLinejoin="round"
176
- style={{
177
- position: "absolute",
178
- top: "50%",
179
- left: "50%",
180
- transform: "translate(-50%, -50%)",
181
- width: 24,
182
- height: 24,
183
- }}
184
- >
185
- <path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
186
- </svg>
187
- </div>
188
- <div style={{ textAlign: "center" }}>
189
- <div
190
- style={{
191
- fontSize: 18,
192
- fontWeight: 600,
193
- color: "#111827",
194
- letterSpacing: "-0.01em",
195
- }}
196
- >
197
- Processing your payment
198
- </div>
199
- <div
200
- style={{
201
- fontSize: 14,
202
- color: "#6b7280",
203
- marginTop: 6,
204
- lineHeight: 1.5,
205
- }}
206
- >
207
- This may take a few moments. Please do not close
208
- <br />
209
- or refresh this page.
210
- </div>
211
- </div>
212
- <div
213
- style={{
214
- width: 200,
215
- height: 3,
216
- background: "#e5e7eb",
217
- borderRadius: 3,
218
- overflow: "hidden",
219
- marginTop: 4,
220
- }}
221
- >
222
- <div
223
- style={{
224
- width: "40%",
225
- height: "100%",
226
- background: "linear-gradient(90deg, #0070ba, #003087)",
227
- borderRadius: 3,
228
- animation: "_pp_progress 1.5s ease-in-out infinite",
229
- }}
230
- />
231
- </div>
232
- </div>
180
+ {processing && !domOverlayUp && (
181
+ <ProcessingOverlayView accent="#0070ba" />
233
182
  )}
234
183
 
235
- {showSpinner && (
236
- <div
237
- role="status"
238
- aria-label="Loading PayPal"
239
- style={{
240
- display: "flex",
241
- alignItems: "center",
242
- justifyContent: "center",
243
- gap: 10,
244
- padding: "18px 16px",
245
- background: "#f9fafb",
246
- border: "1px solid #e5e7eb",
247
- borderRadius: 8,
248
- minHeight: 55,
249
- }}
250
- >
251
- <div
252
- style={{
253
- width: 22,
254
- height: 22,
255
- borderRadius: "50%",
256
- border: "2.5px solid #e5e7eb",
257
- borderTopColor: "#0070ba",
258
- animation: "_pp_spin .7s linear infinite",
259
- flexShrink: 0,
260
- }}
261
- />
262
- <div style={{ fontSize: 13, fontWeight: 500, color: "#6b7280" }}>
263
- Loading PayPal…
264
- </div>
265
- </div>
266
- )}
184
+ {showSpinner && <LoadingCard label="Loading PayPal…" />}
267
185
 
268
186
  {loadTimedOut && !buttonsReady && !isRejected && (
269
- <div
270
- role="alert"
271
- style={{
272
- display: "flex",
273
- alignItems: "flex-start",
274
- gap: 8,
275
- padding: "10px 14px",
276
- background: "#fef2f2",
277
- border: "1px solid #fecaca",
278
- borderRadius: 8,
279
- fontSize: 13,
280
- color: "#b91c1c",
281
- lineHeight: 1.5,
282
- }}
283
- >
284
- <span style={{ flexShrink: 0, fontSize: 15 }}>⚠️</span>
285
- <span>
286
- PayPal is not available for this purchase. Please try a different
287
- payment method.
288
- </span>
289
- </div>
187
+ <ErrorBanner>
188
+ PayPal is not available for this purchase. Please try a different
189
+ payment method.
190
+ </ErrorBanner>
290
191
  )}
291
192
 
292
193
  {isRejected && (
293
- <div
294
- role="alert"
295
- style={{
296
- display: "flex",
297
- alignItems: "flex-start",
298
- gap: 8,
299
- padding: "10px 14px",
300
- background: "#fef2f2",
301
- border: "1px solid #fecaca",
302
- borderRadius: 8,
303
- fontSize: 13,
304
- color: "#b91c1c",
305
- lineHeight: 1.5,
306
- }}
307
- >
308
- <span style={{ flexShrink: 0, fontSize: 15 }}>⚠️</span>
309
- <span>
310
- PayPal failed to load. Please refresh the page or try a different
311
- payment method.
312
- </span>
313
- </div>
194
+ <ErrorBanner>
195
+ PayPal failed to load. Please refresh the page or try a different
196
+ payment method.
197
+ </ErrorBanner>
314
198
  )}
315
199
 
316
200
  {config.environment === "sandbox" && buttonsReady && (
@@ -337,6 +221,7 @@ export function PayPalSmartButtons(props: {
337
221
  stroke="#1e40af"
338
222
  strokeWidth="2"
339
223
  strokeLinecap="round"
224
+ aria-hidden="true"
340
225
  style={{ flexShrink: 0, marginTop: 1 }}
341
226
  >
342
227
  <circle cx="12" cy="12" r="10" />
@@ -373,7 +258,11 @@ export function PayPalSmartButtons(props: {
373
258
  height: config.button_height,
374
259
  }}
375
260
  createOrder={async () => {
376
- if (processing) throw new Error("Payment already processing")
261
+ // Ref, not state: a double click lands before React applies the
262
+ // state update, so only the synchronous ref reliably blocks the
263
+ // second createOrder (and its duplicate PayPal order).
264
+ if (processingRef.current)
265
+ throw new Error("Payment already processing")
377
266
  if (capturedRef.current) {
378
267
  // Money was already captured for this cart — retry the order
379
268
  // finalization instead of creating (and charging) a new order.
@@ -383,19 +272,19 @@ export function PayPalSmartButtons(props: {
383
272
  )
384
273
  }
385
274
  setError(null)
386
- setProcessing(true)
275
+ startProcessing()
387
276
  try {
388
277
  const r = await api.createOrder(cartId)
389
278
  return r.id
390
279
  } catch (e: unknown) {
391
- setProcessing(false)
280
+ stopProcessing()
392
281
  throw e
393
282
  }
394
283
  }}
395
284
  onApprove={async (data: { orderID?: string }) => {
396
285
  try {
397
- setProcessing(true)
398
- showProcessingOverlay()
286
+ startProcessing()
287
+ showOverlay()
399
288
  setError(null)
400
289
  const orderId = String(data?.orderID || "")
401
290
  if (!orderId)
@@ -409,8 +298,7 @@ export function PayPalSmartButtons(props: {
409
298
  markCartCaptured(cartId)
410
299
  } catch (e: unknown) {
411
300
  if (isNextRouterError(e)) return
412
- hideProcessingOverlay()
413
- setProcessing(false)
301
+ stopProcessing()
414
302
  const msg =
415
303
  e instanceof Error ? e.message : "Payment capture failed"
416
304
  setError(msg)
@@ -422,12 +310,10 @@ export function PayPalSmartButtons(props: {
422
310
  await finalizeCapturedPayment()
423
311
  }}
424
312
  onCancel={() => {
425
- hideProcessingOverlay()
426
- setProcessing(false)
313
+ stopProcessing()
427
314
  }}
428
315
  onError={(err: Error | { message?: string }) => {
429
- hideProcessingOverlay()
430
- setProcessing(false)
316
+ stopProcessing()
431
317
  const msg =
432
318
  err instanceof Error
433
319
  ? err.message
@@ -464,25 +350,7 @@ export function PayPalSmartButtons(props: {
464
350
  )}
465
351
 
466
352
  {error ? (
467
- <div
468
- role="alert"
469
- style={{
470
- marginTop: 10,
471
- display: "flex",
472
- alignItems: "flex-start",
473
- gap: 8,
474
- padding: "10px 14px",
475
- background: "#fef2f2",
476
- border: "1px solid #fecaca",
477
- borderRadius: 8,
478
- fontSize: 13,
479
- color: "#b91c1c",
480
- lineHeight: 1.5,
481
- }}
482
- >
483
- <span style={{ flexShrink: 0, fontSize: 15 }}>⚠️</span>
484
- <span>{error}</span>
485
- </div>
353
+ <ErrorBanner style={{ marginTop: 10 }}>{error}</ErrorBanner>
486
354
  ) : null}
487
355
  </div>
488
356
  )
@@ -0,0 +1,221 @@
1
+ "use client"
2
+
3
+ import React from "react"
4
+
5
+ /**
6
+ * Shared presentational pieces for the payment components. One definition of
7
+ * the keyframes/animation CSS (previously copied into four files) and the
8
+ * overlay/spinner/error markup used by both PayPalSmartButtons and
9
+ * PayPalAdvancedCard.
10
+ *
11
+ * Animations are applied through class names so a single
12
+ * `prefers-reduced-motion` rule can disable them for buyers who ask for less
13
+ * motion, without a global selector leaking into the host page.
14
+ */
15
+ export const ANIMATION_CSS = `
16
+ @keyframes _pp_spin { to { transform: rotate(360deg) } }
17
+ @keyframes _pp_progress { 0% { transform: translateX(-100%) } 100% { transform: translateX(350%) } }
18
+ ._pp-spin { animation: _pp_spin .8s linear infinite }
19
+ ._pp-spin-sm { animation: _pp_spin .7s linear infinite }
20
+ ._pp-progress { animation: _pp_progress 1.5s ease-in-out infinite }
21
+ @media (prefers-reduced-motion: reduce) {
22
+ ._pp-spin, ._pp-spin-sm, ._pp-progress { animation: none }
23
+ }
24
+ `
25
+
26
+ export function AnimationStyles() {
27
+ return <style>{ANIMATION_CSS}</style>
28
+ }
29
+
30
+ export function ShieldIcon({
31
+ color,
32
+ size = 24,
33
+ style,
34
+ }: {
35
+ color: string
36
+ size?: number
37
+ style?: React.CSSProperties
38
+ }) {
39
+ return (
40
+ <svg
41
+ viewBox="0 0 24 24"
42
+ fill="none"
43
+ stroke={color}
44
+ strokeWidth="1.8"
45
+ strokeLinecap="round"
46
+ strokeLinejoin="round"
47
+ aria-hidden="true"
48
+ style={{ width: size, height: size, ...style }}
49
+ >
50
+ <path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
51
+ </svg>
52
+ )
53
+ }
54
+
55
+ /**
56
+ * Full-screen "Processing your payment" overlay. role="status" makes screen
57
+ * readers announce the visible text — deliberately no aria-label, which would
58
+ * override that text.
59
+ */
60
+ export function ProcessingOverlayView({ accent }: { accent: string }) {
61
+ return (
62
+ <div
63
+ role="status"
64
+ style={{
65
+ position: "fixed",
66
+ inset: 0,
67
+ zIndex: 9999,
68
+ background: "rgba(255,255,255,0.96)",
69
+ display: "flex",
70
+ flexDirection: "column",
71
+ alignItems: "center",
72
+ justifyContent: "center",
73
+ gap: 20,
74
+ }}
75
+ >
76
+ <div style={{ position: "relative", width: 56, height: 56 }}>
77
+ <div
78
+ style={{
79
+ position: "absolute",
80
+ inset: 0,
81
+ borderRadius: "50%",
82
+ border: "3px solid #e5e7eb",
83
+ }}
84
+ />
85
+ <div
86
+ className="_pp-spin"
87
+ style={{
88
+ position: "absolute",
89
+ inset: 0,
90
+ borderRadius: "50%",
91
+ border: "3px solid transparent",
92
+ borderTopColor: accent,
93
+ }}
94
+ />
95
+ <ShieldIcon
96
+ color={accent}
97
+ style={{
98
+ position: "absolute",
99
+ top: "50%",
100
+ left: "50%",
101
+ transform: "translate(-50%, -50%)",
102
+ }}
103
+ />
104
+ </div>
105
+ <div style={{ textAlign: "center" }}>
106
+ <div
107
+ style={{
108
+ fontSize: 18,
109
+ fontWeight: 600,
110
+ color: "#111827",
111
+ letterSpacing: "-0.01em",
112
+ }}
113
+ >
114
+ Processing your payment
115
+ </div>
116
+ <div
117
+ style={{
118
+ fontSize: 14,
119
+ color: "#6b7280",
120
+ marginTop: 6,
121
+ lineHeight: 1.5,
122
+ }}
123
+ >
124
+ This may take a few moments. Please do not close
125
+ <br />
126
+ or refresh this page.
127
+ </div>
128
+ </div>
129
+ <div
130
+ style={{
131
+ width: 200,
132
+ height: 3,
133
+ background: "#e5e7eb",
134
+ borderRadius: 3,
135
+ overflow: "hidden",
136
+ marginTop: 4,
137
+ }}
138
+ aria-hidden="true"
139
+ >
140
+ <div
141
+ className="_pp-progress"
142
+ style={{
143
+ width: "40%",
144
+ height: "100%",
145
+ background: `linear-gradient(90deg, ${accent}, #003087)`,
146
+ borderRadius: 3,
147
+ }}
148
+ />
149
+ </div>
150
+ </div>
151
+ )
152
+ }
153
+
154
+ /** Small inline loading card ("Loading PayPal…", "Loading card fields…"). */
155
+ export function LoadingCard({ label }: { label: string }) {
156
+ return (
157
+ <div
158
+ role="status"
159
+ style={{
160
+ display: "flex",
161
+ alignItems: "center",
162
+ justifyContent: "center",
163
+ gap: 10,
164
+ padding: "18px 16px",
165
+ background: "#f9fafb",
166
+ border: "1px solid #e5e7eb",
167
+ borderRadius: 8,
168
+ minHeight: 55,
169
+ }}
170
+ >
171
+ <div
172
+ className="_pp-spin-sm"
173
+ aria-hidden="true"
174
+ style={{
175
+ width: 22,
176
+ height: 22,
177
+ borderRadius: "50%",
178
+ border: "2.5px solid #e5e7eb",
179
+ borderTopColor: "#0070ba",
180
+ flexShrink: 0,
181
+ }}
182
+ />
183
+ <div style={{ fontSize: 13, fontWeight: 500, color: "#6b7280" }}>
184
+ {label}
185
+ </div>
186
+ </div>
187
+ )
188
+ }
189
+
190
+ /** Red role="alert" banner with a decorative warning icon. */
191
+ export function ErrorBanner({
192
+ children,
193
+ style,
194
+ }: {
195
+ children: React.ReactNode
196
+ style?: React.CSSProperties
197
+ }) {
198
+ return (
199
+ <div
200
+ role="alert"
201
+ style={{
202
+ display: "flex",
203
+ alignItems: "flex-start",
204
+ gap: 8,
205
+ padding: "10px 14px",
206
+ background: "#fef2f2",
207
+ border: "1px solid #fecaca",
208
+ borderRadius: 8,
209
+ fontSize: 13,
210
+ color: "#b91c1c",
211
+ lineHeight: 1.5,
212
+ ...style,
213
+ }}
214
+ >
215
+ <span aria-hidden="true" style={{ flexShrink: 0, fontSize: 15 }}>
216
+ ⚠️
217
+ </span>
218
+ <span>{children}</span>
219
+ </div>
220
+ )
221
+ }