@easypayment/medusa-paypal-ui 1.2.2 → 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.
- package/CHANGELOG.md +7 -0
- package/dist/index.cjs +197 -88
- package/dist/index.cjs.map +1 -1
- package/dist/index.mjs +197 -88
- package/dist/index.mjs.map +1 -1
- package/dist/order.cjs.map +1 -1
- package/dist/order.mjs.map +1 -1
- package/package.json +1 -1
- package/src/adapters/MedusaNextPayPalAdapter.tsx +196 -195
- package/src/client/http.ts +214 -204
- package/src/client/paypal.ts +91 -88
- package/src/client/types.ts +40 -40
- package/src/components/PayPalAdvancedCard.tsx +646 -611
- package/src/components/PayPalCurrencyNotice.tsx +24 -19
- package/src/components/PayPalPaymentSection.tsx +5 -1
- package/src/components/PayPalProvider.tsx +44 -38
- package/src/components/PayPalSmartButtons.tsx +489 -445
- package/src/hooks/usePayPalConfig.ts +100 -96
- package/src/hooks/usePayPalPaymentMethods.ts +146 -145
- package/src/index.ts +19 -16
- package/src/order.ts +140 -134
- package/src/utils/next-errors.ts +21 -21
- package/src/utils/processing-overlay.ts +96 -96
|
@@ -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 {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
`
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
const [
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
//
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
</
|
|
187
|
-
</div>
|
|
188
|
-
<div
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
}}
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
</
|
|
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
|
|
287
|
-
payment method.
|
|
288
|
-
</span>
|
|
289
|
-
</div>
|
|
290
|
-
)}
|
|
291
|
-
|
|
292
|
-
{
|
|
293
|
-
<div
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
}}
|
|
307
|
-
>
|
|
308
|
-
<
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
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
|
+
}
|