@easypayment/medusa-paypal-ui 1.2.0 → 1.2.3

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.
@@ -1,445 +1,489 @@
1
- "use client"
2
- import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
3
- import { PayPalButtons, usePayPalScriptReducer } from "@paypal/react-paypal-js"
4
- import { createPayPalStoreApi, markPaymentComplete } from "../client/paypal"
5
- import type { PayPalConfig } from "../client/types"
6
- import {
7
- clearCartCaptured,
8
- markCartCaptured,
9
- wasCartCaptured,
10
- } from "../utils/captured-state"
11
- import { isNextRouterError } from "../utils/next-errors"
12
- import { showProcessingOverlay, hideProcessingOverlay } from "../utils/processing-overlay"
13
-
14
- const SPIN_STYLE = `
15
- @keyframes _pp_spin { to { transform: rotate(360deg) } }
16
- @keyframes _pp_progress { 0% { transform: translateX(-100%) } 100% { transform: translateX(350%) } }
17
- `
18
-
19
- const BUTTON_WIDTH_MAP: Record<string, string> = {
20
- small: "300px",
21
- medium: "400px",
22
- large: "500px",
23
- responsive: "100%",
24
- }
25
-
26
- export function PayPalSmartButtons(props: {
27
- baseUrl: string
28
- publishableApiKey?: string
29
- cartId: string
30
- config: PayPalConfig
31
- onPaid?: (result: Record<string, unknown>) => void
32
- onError?: (message: string) => void
33
- }) {
34
- const { baseUrl, publishableApiKey, cartId, config, onPaid, onError } = props
35
- const api = useMemo(
36
- () => createPayPalStoreApi({ baseUrl, publishableApiKey }),
37
- [baseUrl, publishableApiKey]
38
- )
39
-
40
- const [error, setError] = useState<string | null>(null)
41
- const [processing, setProcessing] = useState(false)
42
- const [buttonsReady, setButtonsReady] = useState(false)
43
- const [loadTimedOut, setLoadTimedOut] = useState(false)
44
- // Once a capture succeeded, the buyer's money is taken — any later failure
45
- // (order finalization, consumer callback) must retry ONLY the finalization,
46
- // never a fresh createOrder/capture, or a retry click would charge again.
47
- const capturedRef = useRef<Record<string, unknown> | null>(null)
48
- const [completionPending, setCompletionPending] = useState(false)
49
- const [{ isPending, isResolved, isRejected }] = usePayPalScriptReducer()
50
-
51
- const handleInit = useCallback(() => {
52
- setButtonsReady(true)
53
- }, [])
54
-
55
- // bfcache restore (Back button after a redirect) revives this page with the
56
- // old React state — clear the processing flag so the buyer isn't stuck
57
- // behind the in-component overlay.
58
- useEffect(() => {
59
- const onPageShow = (e: PageTransitionEvent) => {
60
- if (e.persisted) setProcessing(false)
61
- }
62
- window.addEventListener("pageshow", onPageShow)
63
- return () => window.removeEventListener("pageshow", onPageShow)
64
- }, [])
65
-
66
- // A reload/crash between capture and finalization loses the in-memory
67
- // capturedRef — restore it from the persisted per-cart flag so the buyer
68
- // gets the finalize-only Retry button instead of payment buttons that would
69
- // fail confusingly against an already-captured PayPal order.
70
- useEffect(() => {
71
- if (capturedRef.current || !wasCartCaptured(cartId)) return
72
- capturedRef.current = {}
73
- setCompletionPending(true)
74
- const msg =
75
- "Your payment was already received for this cart. Please use the button below to finish placing your order — do not pay again."
76
- setError(msg)
77
- }, [cartId])
78
-
79
- // The PayPal SDK never calls onInit when the buttons are ineligible (wrong
80
- // country/currency/funding) — it silently renders nothing. Without a
81
- // fallback the buyer stares at "Loading PayPal…" forever.
82
- useEffect(() => {
83
- if (!isResolved || buttonsReady) {
84
- return
85
- }
86
- const t = setTimeout(() => setLoadTimedOut(true), 10_000)
87
- return () => clearTimeout(t)
88
- }, [isResolved, buttonsReady])
89
-
90
- const finalizeCapturedPayment = useCallback(async () => {
91
- const captured = capturedRef.current
92
- if (!captured) return
93
- setProcessing(true)
94
- showProcessingOverlay()
95
- setError(null)
96
- try {
97
- const completeResult = await markPaymentComplete(baseUrl, cartId, publishableApiKey)
98
- // The order is finalized — the persisted "captured" flag has served its
99
- // purpose. Clear it before the consumer callback (which may redirect).
100
- clearCartCaptured(cartId)
101
- await onPaid?.({ ...captured, ...completeResult })
102
- setCompletionPending(false)
103
- } catch (e: unknown) {
104
- if (isNextRouterError(e)) return
105
- hideProcessingOverlay()
106
- setProcessing(false)
107
- setCompletionPending(true)
108
- const msg =
109
- "Your payment was received, but finalizing your order failed. Please use the Retry button below — do not pay again."
110
- setError(msg)
111
- onError?.(msg)
112
- }
113
- }, [baseUrl, cartId, publishableApiKey, onPaid, onError])
114
-
115
- const showSpinner = (isPending || (isResolved && !buttonsReady)) && !loadTimedOut
116
-
117
- if (!config.currency_supported) return null
118
-
119
- const containerWidth = BUTTON_WIDTH_MAP[config.button_width ?? "responsive"] ?? "100%"
120
-
121
- return (
122
- <div style={{ width: containerWidth, position: "relative" }}>
123
- <style>{SPIN_STYLE}</style>
124
-
125
- {processing && (
126
- <div
127
- role="status"
128
- aria-label="Processing payment"
129
- style={{
130
- position: "fixed",
131
- inset: 0,
132
- zIndex: 9999,
133
- background: "rgba(255,255,255,0.96)",
134
- display: "flex",
135
- flexDirection: "column",
136
- alignItems: "center",
137
- justifyContent: "center",
138
- gap: 20,
139
- }}
140
- >
141
- <div style={{ position: "relative", width: 56, height: 56 }}>
142
- <div
143
- style={{
144
- position: "absolute",
145
- inset: 0,
146
- borderRadius: "50%",
147
- border: "3px solid #e5e7eb",
148
- }}
149
- />
150
- <div
151
- style={{
152
- position: "absolute",
153
- inset: 0,
154
- borderRadius: "50%",
155
- border: "3px solid transparent",
156
- borderTopColor: "#0070ba",
157
- animation: "_pp_spin .8s linear infinite",
158
- }}
159
- />
160
- <svg
161
- viewBox="0 0 24 24"
162
- fill="none"
163
- stroke="#0070ba"
164
- strokeWidth="1.8"
165
- strokeLinecap="round"
166
- strokeLinejoin="round"
167
- style={{
168
- position: "absolute",
169
- top: "50%",
170
- left: "50%",
171
- transform: "translate(-50%, -50%)",
172
- width: 24,
173
- height: 24,
174
- }}
175
- >
176
- <path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
177
- </svg>
178
- </div>
179
- <div style={{ textAlign: "center" }}>
180
- <div style={{ fontSize: 18, fontWeight: 600, color: "#111827", letterSpacing: "-0.01em" }}>
181
- Processing your payment
182
- </div>
183
- <div style={{ fontSize: 14, color: "#6b7280", marginTop: 6, lineHeight: 1.5 }}>
184
- This may take a few moments. Please do not close<br />
185
- or refresh this page.
186
- </div>
187
- </div>
188
- <div
189
- style={{
190
- width: 200,
191
- height: 3,
192
- background: "#e5e7eb",
193
- borderRadius: 3,
194
- overflow: "hidden",
195
- marginTop: 4,
196
- }}
197
- >
198
- <div
199
- style={{
200
- width: "40%",
201
- height: "100%",
202
- background: "linear-gradient(90deg, #0070ba, #003087)",
203
- borderRadius: 3,
204
- animation: "_pp_progress 1.5s ease-in-out infinite",
205
- }}
206
- />
207
- </div>
208
- </div>
209
- )}
210
-
211
- {showSpinner && (
212
- <div
213
- role="status"
214
- aria-label="Loading PayPal"
215
- style={{
216
- display: "flex",
217
- alignItems: "center",
218
- justifyContent: "center",
219
- gap: 10,
220
- padding: "18px 16px",
221
- background: "#f9fafb",
222
- border: "1px solid #e5e7eb",
223
- borderRadius: 8,
224
- minHeight: 55,
225
- }}
226
- >
227
- <div
228
- style={{
229
- width: 22,
230
- height: 22,
231
- borderRadius: "50%",
232
- border: "2.5px solid #e5e7eb",
233
- borderTopColor: "#0070ba",
234
- animation: "_pp_spin .7s linear infinite",
235
- flexShrink: 0,
236
- }}
237
- />
238
- <div style={{ fontSize: 13, fontWeight: 500, color: "#6b7280" }}>
239
- Loading PayPal…
240
- </div>
241
- </div>
242
- )}
243
-
244
- {loadTimedOut && !buttonsReady && !isRejected && (
245
- <div
246
- role="alert"
247
- style={{
248
- display: "flex",
249
- alignItems: "flex-start",
250
- gap: 8,
251
- padding: "10px 14px",
252
- background: "#fef2f2",
253
- border: "1px solid #fecaca",
254
- borderRadius: 8,
255
- fontSize: 13,
256
- color: "#b91c1c",
257
- lineHeight: 1.5,
258
- }}
259
- >
260
- <span style={{ flexShrink: 0, fontSize: 15 }}>⚠️</span>
261
- <span>
262
- PayPal is not available for this purchase. Please try a different
263
- payment method.
264
- </span>
265
- </div>
266
- )}
267
-
268
- {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 failed to load. Please refresh the page or try a different
287
- payment method.
288
- </span>
289
- </div>
290
- )}
291
-
292
- {config.environment === "sandbox" && buttonsReady && (
293
- <div
294
- style={{
295
- display: "flex",
296
- alignItems: "flex-start",
297
- gap: 8,
298
- padding: "10px 14px",
299
- background: "#eff6ff",
300
- border: "1px solid #bfdbfe",
301
- borderRadius: 8,
302
- fontSize: 13,
303
- color: "#1e40af",
304
- lineHeight: 1.5,
305
- marginBottom: 10,
306
- }}
307
- >
308
- <svg
309
- width="16"
310
- height="16"
311
- viewBox="0 0 24 24"
312
- fill="none"
313
- stroke="#1e40af"
314
- strokeWidth="2"
315
- strokeLinecap="round"
316
- style={{ flexShrink: 0, marginTop: 1 }}
317
- >
318
- <circle cx="12" cy="12" r="10" />
319
- <path d="M12 8v4M12 16h.01" />
320
- </svg>
321
- <span>
322
- Sandbox mode — you will not be charged. Use your PayPal sandbox
323
- account to complete the payment.
324
- </span>
325
- </div>
326
- )}
327
-
328
- {isResolved && !completionPending && (
329
- <div style={buttonsReady ? undefined : { position: "absolute", left: -9999, opacity: 0, pointerEvents: "none" }}>
330
- <PayPalButtons
331
- forceReRender={[config.currency, config.intent, cartId]}
332
- onInit={handleInit}
333
- style={{
334
- layout: "vertical",
335
- color: config.button_color,
336
- shape: config.button_shape,
337
- label: config.button_label,
338
- height: config.button_height,
339
- }}
340
- createOrder={async () => {
341
- if (processing) throw new Error("Payment already processing")
342
- if (capturedRef.current) {
343
- // Money was already captured for this cart — retry the order
344
- // finalization instead of creating (and charging) a new order.
345
- void finalizeCapturedPayment()
346
- throw new Error("Your payment was already received — finalizing your order.")
347
- }
348
- setError(null)
349
- setProcessing(true)
350
- try {
351
- const r = await api.createOrder(cartId)
352
- return r.id
353
- } catch (e: unknown) {
354
- setProcessing(false)
355
- throw e
356
- }
357
- }}
358
- onApprove={async (data: { orderID?: string }) => {
359
- try {
360
- setProcessing(true)
361
- showProcessingOverlay()
362
- setError(null)
363
- const orderId = String(data?.orderID || "")
364
- if (!orderId) throw new Error("PayPal order ID is missing from approval response")
365
- const result = await api.captureOrder(cartId, orderId)
366
- capturedRef.current = (result || {}) as Record<string, unknown>
367
- // Persist across reloads: from here on, only finalization may
368
- // ever be retried for this cart — never a fresh payment.
369
- markCartCaptured(cartId)
370
- } catch (e: unknown) {
371
- if (isNextRouterError(e)) return
372
- hideProcessingOverlay()
373
- setProcessing(false)
374
- const msg = e instanceof Error ? e.message : "Payment capture failed"
375
- setError(msg)
376
- onError?.(msg)
377
- return
378
- }
379
- // Past this point the buyer has been charged: completion failures
380
- // surface a finalize-only Retry, never a fresh payment.
381
- await finalizeCapturedPayment()
382
- }}
383
- onCancel={() => {
384
- hideProcessingOverlay()
385
- setProcessing(false)
386
- }}
387
- onError={(err: Error | { message?: string }) => {
388
- hideProcessingOverlay()
389
- setProcessing(false)
390
- const msg = err instanceof Error ? err.message : err?.message || "PayPal error"
391
- setError(msg)
392
- onError?.(msg)
393
- }}
394
- />
395
- </div>
396
- )}
397
-
398
- {completionPending && (
399
- <button
400
- type="button"
401
- onClick={() => void finalizeCapturedPayment()}
402
- disabled={processing}
403
- aria-busy={processing}
404
- style={{
405
- width: "100%",
406
- padding: "12px 16px",
407
- marginTop: 4,
408
- background: "#0070ba",
409
- color: "#ffffff",
410
- border: "none",
411
- borderRadius: 8,
412
- fontSize: 14,
413
- fontWeight: 600,
414
- cursor: processing ? "not-allowed" : "pointer",
415
- opacity: processing ? 0.7 : 1,
416
- }}
417
- >
418
- Retry — finish placing my order
419
- </button>
420
- )}
421
-
422
- {error ? (
423
- <div
424
- role="alert"
425
- style={{
426
- marginTop: 10,
427
- display: "flex",
428
- alignItems: "flex-start",
429
- gap: 8,
430
- padding: "10px 14px",
431
- background: "#fef2f2",
432
- border: "1px solid #fecaca",
433
- borderRadius: 8,
434
- fontSize: 13,
435
- color: "#b91c1c",
436
- lineHeight: 1.5,
437
- }}
438
- >
439
- <span style={{ flexShrink: 0, fontSize: 15 }}>⚠️</span>
440
- <span>{error}</span>
441
- </div>
442
- ) : null}
443
- </div>
444
- )
445
- }
1
+ "use client"
2
+ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
3
+ import { PayPalButtons, usePayPalScriptReducer } from "@paypal/react-paypal-js"
4
+ import { createPayPalStoreApi, markPaymentComplete } from "../client/paypal"
5
+ import type { PayPalConfig } from "../client/types"
6
+ import {
7
+ clearCartCaptured,
8
+ markCartCaptured,
9
+ wasCartCaptured,
10
+ } from "../utils/captured-state"
11
+ import { isNextRouterError } from "../utils/next-errors"
12
+ import {
13
+ showProcessingOverlay,
14
+ hideProcessingOverlay,
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
+ `
21
+
22
+ const BUTTON_WIDTH_MAP: Record<string, string> = {
23
+ small: "300px",
24
+ medium: "400px",
25
+ large: "500px",
26
+ responsive: "100%",
27
+ }
28
+
29
+ export function PayPalSmartButtons(props: {
30
+ baseUrl: string
31
+ publishableApiKey?: string
32
+ cartId: string
33
+ config: PayPalConfig
34
+ onPaid?: (result: Record<string, unknown>) => void
35
+ onError?: (message: string) => void
36
+ }) {
37
+ const { baseUrl, publishableApiKey, cartId, config, onPaid, onError } = props
38
+ const api = useMemo(
39
+ () => createPayPalStoreApi({ baseUrl, publishableApiKey }),
40
+ [baseUrl, publishableApiKey]
41
+ )
42
+
43
+ const [error, setError] = useState<string | null>(null)
44
+ const [processing, setProcessing] = useState(false)
45
+ const [buttonsReady, setButtonsReady] = useState(false)
46
+ const [loadTimedOut, setLoadTimedOut] = useState(false)
47
+ // Once a capture succeeded, the buyer's money is taken — any later failure
48
+ // (order finalization, consumer callback) must retry ONLY the finalization,
49
+ // never a fresh createOrder/capture, or a retry click would charge again.
50
+ const capturedRef = useRef<Record<string, unknown> | null>(null)
51
+ const [completionPending, setCompletionPending] = useState(false)
52
+ const [{ isPending, isResolved, isRejected }] = usePayPalScriptReducer()
53
+
54
+ const handleInit = useCallback(() => {
55
+ setButtonsReady(true)
56
+ }, [])
57
+
58
+ // bfcache restore (Back button after a redirect) revives this page with the
59
+ // old React state — clear the processing flag so the buyer isn't stuck
60
+ // behind the in-component overlay.
61
+ useEffect(() => {
62
+ const onPageShow = (e: PageTransitionEvent) => {
63
+ if (e.persisted) setProcessing(false)
64
+ }
65
+ window.addEventListener("pageshow", onPageShow)
66
+ return () => window.removeEventListener("pageshow", onPageShow)
67
+ }, [])
68
+
69
+ // A reload/crash between capture and finalization loses the in-memory
70
+ // capturedRef — restore it from the persisted per-cart flag so the buyer
71
+ // gets the finalize-only Retry button instead of payment buttons that would
72
+ // fail confusingly against an already-captured PayPal order.
73
+ useEffect(() => {
74
+ if (capturedRef.current || !wasCartCaptured(cartId)) return
75
+ capturedRef.current = {}
76
+ setCompletionPending(true)
77
+ const msg =
78
+ "Your payment was already received for this cart. Please use the button below to finish placing your order — do not pay again."
79
+ setError(msg)
80
+ }, [cartId])
81
+
82
+ // The PayPal SDK never calls onInit when the buttons are ineligible (wrong
83
+ // country/currency/funding) — it silently renders nothing. Without a
84
+ // fallback the buyer stares at "Loading PayPal…" forever.
85
+ useEffect(() => {
86
+ if (!isResolved || buttonsReady) {
87
+ return
88
+ }
89
+ const t = setTimeout(() => setLoadTimedOut(true), 10_000)
90
+ return () => clearTimeout(t)
91
+ }, [isResolved, buttonsReady])
92
+
93
+ const finalizeCapturedPayment = useCallback(async () => {
94
+ const captured = capturedRef.current
95
+ if (!captured) return
96
+ setProcessing(true)
97
+ showProcessingOverlay()
98
+ setError(null)
99
+ try {
100
+ const completeResult = await markPaymentComplete(
101
+ baseUrl,
102
+ cartId,
103
+ publishableApiKey
104
+ )
105
+ // The order is finalized — the persisted "captured" flag has served its
106
+ // purpose. Clear it before the consumer callback (which may redirect).
107
+ clearCartCaptured(cartId)
108
+ await onPaid?.({ ...captured, ...completeResult })
109
+ setCompletionPending(false)
110
+ } catch (e: unknown) {
111
+ if (isNextRouterError(e)) return
112
+ hideProcessingOverlay()
113
+ setProcessing(false)
114
+ setCompletionPending(true)
115
+ const msg =
116
+ "Your payment was received, but finalizing your order failed. Please use the Retry button below — do not pay again."
117
+ setError(msg)
118
+ onError?.(msg)
119
+ }
120
+ }, [baseUrl, cartId, publishableApiKey, onPaid, onError])
121
+
122
+ const showSpinner =
123
+ (isPending || (isResolved && !buttonsReady)) && !loadTimedOut
124
+
125
+ if (!config.currency_supported) return null
126
+
127
+ const containerWidth =
128
+ BUTTON_WIDTH_MAP[config.button_width ?? "responsive"] ?? "100%"
129
+
130
+ return (
131
+ <div style={{ width: containerWidth, position: "relative" }}>
132
+ <style>{SPIN_STYLE}</style>
133
+
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>
233
+ )}
234
+
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
+ )}
267
+
268
+ {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>
290
+ )}
291
+
292
+ {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>
314
+ )}
315
+
316
+ {config.environment === "sandbox" && buttonsReady && (
317
+ <div
318
+ style={{
319
+ display: "flex",
320
+ alignItems: "flex-start",
321
+ gap: 8,
322
+ padding: "10px 14px",
323
+ background: "#eff6ff",
324
+ border: "1px solid #bfdbfe",
325
+ borderRadius: 8,
326
+ fontSize: 13,
327
+ color: "#1e40af",
328
+ lineHeight: 1.5,
329
+ marginBottom: 10,
330
+ }}
331
+ >
332
+ <svg
333
+ width="16"
334
+ height="16"
335
+ viewBox="0 0 24 24"
336
+ fill="none"
337
+ stroke="#1e40af"
338
+ strokeWidth="2"
339
+ strokeLinecap="round"
340
+ style={{ flexShrink: 0, marginTop: 1 }}
341
+ >
342
+ <circle cx="12" cy="12" r="10" />
343
+ <path d="M12 8v4M12 16h.01" />
344
+ </svg>
345
+ <span>
346
+ Sandbox mode — you will not be charged. Use your PayPal sandbox
347
+ account to complete the payment.
348
+ </span>
349
+ </div>
350
+ )}
351
+
352
+ {isResolved && !completionPending && (
353
+ <div
354
+ style={
355
+ buttonsReady
356
+ ? undefined
357
+ : {
358
+ position: "absolute",
359
+ left: -9999,
360
+ opacity: 0,
361
+ pointerEvents: "none",
362
+ }
363
+ }
364
+ >
365
+ <PayPalButtons
366
+ forceReRender={[config.currency, config.intent, cartId]}
367
+ onInit={handleInit}
368
+ style={{
369
+ layout: "vertical",
370
+ color: config.button_color,
371
+ shape: config.button_shape,
372
+ label: config.button_label,
373
+ height: config.button_height,
374
+ }}
375
+ createOrder={async () => {
376
+ if (processing) throw new Error("Payment already processing")
377
+ if (capturedRef.current) {
378
+ // Money was already captured for this cart — retry the order
379
+ // finalization instead of creating (and charging) a new order.
380
+ void finalizeCapturedPayment()
381
+ throw new Error(
382
+ "Your payment was already received — finalizing your order."
383
+ )
384
+ }
385
+ setError(null)
386
+ setProcessing(true)
387
+ try {
388
+ const r = await api.createOrder(cartId)
389
+ return r.id
390
+ } catch (e: unknown) {
391
+ setProcessing(false)
392
+ throw e
393
+ }
394
+ }}
395
+ onApprove={async (data: { orderID?: string }) => {
396
+ try {
397
+ setProcessing(true)
398
+ showProcessingOverlay()
399
+ setError(null)
400
+ const orderId = String(data?.orderID || "")
401
+ if (!orderId)
402
+ throw new Error(
403
+ "PayPal order ID is missing from approval response"
404
+ )
405
+ const result = await api.captureOrder(cartId, orderId)
406
+ capturedRef.current = (result || {}) as Record<string, unknown>
407
+ // Persist across reloads: from here on, only finalization may
408
+ // ever be retried for this cart — never a fresh payment.
409
+ markCartCaptured(cartId)
410
+ } catch (e: unknown) {
411
+ if (isNextRouterError(e)) return
412
+ hideProcessingOverlay()
413
+ setProcessing(false)
414
+ const msg =
415
+ e instanceof Error ? e.message : "Payment capture failed"
416
+ setError(msg)
417
+ onError?.(msg)
418
+ return
419
+ }
420
+ // Past this point the buyer has been charged: completion failures
421
+ // surface a finalize-only Retry, never a fresh payment.
422
+ await finalizeCapturedPayment()
423
+ }}
424
+ onCancel={() => {
425
+ hideProcessingOverlay()
426
+ setProcessing(false)
427
+ }}
428
+ onError={(err: Error | { message?: string }) => {
429
+ hideProcessingOverlay()
430
+ setProcessing(false)
431
+ const msg =
432
+ err instanceof Error
433
+ ? err.message
434
+ : err?.message || "PayPal error"
435
+ setError(msg)
436
+ onError?.(msg)
437
+ }}
438
+ />
439
+ </div>
440
+ )}
441
+
442
+ {completionPending && (
443
+ <button
444
+ type="button"
445
+ onClick={() => void finalizeCapturedPayment()}
446
+ disabled={processing}
447
+ aria-busy={processing}
448
+ style={{
449
+ width: "100%",
450
+ padding: "12px 16px",
451
+ marginTop: 4,
452
+ background: "#0070ba",
453
+ color: "#ffffff",
454
+ border: "none",
455
+ borderRadius: 8,
456
+ fontSize: 14,
457
+ fontWeight: 600,
458
+ cursor: processing ? "not-allowed" : "pointer",
459
+ opacity: processing ? 0.7 : 1,
460
+ }}
461
+ >
462
+ Retry — finish placing my order
463
+ </button>
464
+ )}
465
+
466
+ {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>
486
+ ) : null}
487
+ </div>
488
+ )
489
+ }