@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.
@@ -1,611 +1,646 @@
1
- "use client"
2
-
3
- import React, { useMemo, useRef, useState } from "react"
4
- import {
5
- PayPalCardFieldsProvider,
6
- PayPalNumberField,
7
- PayPalExpiryField,
8
- PayPalCVVField,
9
- usePayPalCardFields,
10
- usePayPalScriptReducer,
11
- } from "@paypal/react-paypal-js"
12
-
13
- import { createPayPalStoreApi, markPaymentComplete } from "../client/paypal"
14
- import type { PayPalConfig } from "../client/types"
15
- import {
16
- clearCartCaptured,
17
- markCartCaptured,
18
- wasCartCaptured,
19
- } from "../utils/captured-state"
20
- import { isNextRouterError } from "../utils/next-errors"
21
- import { showProcessingOverlay, hideProcessingOverlay } from "../utils/processing-overlay"
22
-
23
- const SPIN_STYLE = `
24
- @keyframes _pp_spin { to { transform: rotate(360deg) } }
25
- @keyframes _pp_progress { 0% { transform: translateX(-100%) } 100% { transform: translateX(350%) } }
26
- `
27
-
28
- const cardStyle = {
29
- input: {
30
- "font-size": "15px",
31
- "font-family": "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
32
- "font-weight": "400",
33
- color: "#111827",
34
- padding: "0 14px",
35
- height: "42px",
36
- "border-radius": "8px",
37
- border: "1px solid #d1d5db",
38
- background: "#ffffff",
39
- "box-shadow": "none",
40
- transition: "border-color 0.15s ease",
41
- },
42
- "::placeholder": {
43
- color: "#9ca3af",
44
- },
45
- ".invalid": {
46
- color: "#dc2626",
47
- border: "1px solid #fca5a5",
48
- background: "#fff7f7",
49
- "box-shadow": "none",
50
- },
51
- ".valid": {
52
- color: "#111827",
53
- border: "1px solid #d1d5db",
54
- "box-shadow": "none",
55
- },
56
- "input:focus": {
57
- outline: "none",
58
- border: "1px solid #2563eb",
59
- "box-shadow": "0 0 0 3px rgba(37,99,235,0.12)",
60
- },
61
- }
62
-
63
- const labelStyle: React.CSSProperties = {
64
- display: "block",
65
- fontSize: 13,
66
- fontWeight: 500,
67
- color: "#374151",
68
- marginBottom: 6,
69
- letterSpacing: "0.01em",
70
- }
71
-
72
- function SubmitButton({
73
- disabled,
74
- label,
75
- onSubmit,
76
- onSubmitError,
77
- }: {
78
- disabled: boolean
79
- label: string
80
- onSubmit: () => void
81
- onSubmitError: (message: string) => void
82
- }) {
83
- const { cardFieldsForm } = usePayPalCardFields()
84
- const isDisabled = disabled || !cardFieldsForm
85
-
86
- return (
87
- <button
88
- type="button"
89
- disabled={isDisabled}
90
- aria-busy={disabled}
91
- onClick={() => {
92
- onSubmit()
93
- // submit() returns a promise and its rejection is NOT routed to the
94
- // provider's onError — PayPal's contract is that the caller catches
95
- // it. A sync try/catch here would miss it entirely, leaving the buyer
96
- // stuck under the full-screen processing overlay forever (e.g. on a
97
- // card-field validation error).
98
- Promise.resolve(cardFieldsForm?.submit()).catch((e: unknown) => {
99
- const msg =
100
- e instanceof Error && e.message
101
- ? e.message
102
- : "Card payment could not be submitted. Please check your card details."
103
- onSubmitError(msg)
104
- })
105
- }}
106
- style={{
107
- width: "100%",
108
- height: 48,
109
- padding: "0 20px",
110
- borderRadius: 8,
111
- border: "none",
112
- background: isDisabled
113
- ? "#e5e7eb"
114
- : "linear-gradient(180deg, #2563eb 0%, #1d4ed8 100%)",
115
- color: isDisabled ? "#9ca3af" : "#ffffff",
116
- fontSize: 15,
117
- fontWeight: 600,
118
- fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
119
- cursor: isDisabled ? "not-allowed" : "pointer",
120
- letterSpacing: "0.01em",
121
- boxShadow: isDisabled
122
- ? "none"
123
- : "0 1px 3px rgba(37,99,235,0.3), 0 1px 2px rgba(0,0,0,0.06)",
124
- transition: "all 0.15s ease",
125
- }}
126
- onMouseEnter={(e) => {
127
- if (!isDisabled) {
128
- ;(e.target as HTMLButtonElement).style.background =
129
- "linear-gradient(180deg, #1d4ed8 0%, #1e40af 100%)"
130
- ;(e.target as HTMLButtonElement).style.boxShadow =
131
- "0 4px 6px rgba(37,99,235,0.35), 0 2px 4px rgba(0,0,0,0.06)"
132
- }
133
- }}
134
- onMouseLeave={(e) => {
135
- if (!isDisabled) {
136
- ;(e.target as HTMLButtonElement).style.background =
137
- "linear-gradient(180deg, #2563eb 0%, #1d4ed8 100%)"
138
- ;(e.target as HTMLButtonElement).style.boxShadow =
139
- "0 1px 3px rgba(37,99,235,0.3), 0 1px 2px rgba(0,0,0,0.06)"
140
- }
141
- }}
142
- >
143
- {label}
144
- </button>
145
- )
146
- }
147
-
148
- export function PayPalAdvancedCard(props: {
149
- baseUrl: string
150
- publishableApiKey?: string
151
- cartId: string
152
- config: PayPalConfig
153
- onPaid?: (result: Record<string, unknown>) => void
154
- onError?: (message: string) => void
155
- }) {
156
- const { baseUrl, publishableApiKey, cartId, config, onPaid, onError } = props
157
-
158
- const api = useMemo(
159
- () => createPayPalStoreApi({ baseUrl, publishableApiKey }),
160
- [baseUrl, publishableApiKey]
161
- )
162
-
163
- const [error, setError] = useState<string | null>(null)
164
- const [submitting, setSubmitting] = useState(false)
165
- const submittingRef = useRef(false)
166
- const creatingOrderRef = useRef(false)
167
- // Once a capture succeeded the buyer's money is taken: any later failure
168
- // must retry ONLY the order finalization, never a fresh order/capture.
169
- const capturedRef = useRef<Record<string, unknown> | null>(null)
170
- const [completionPending, setCompletionPending] = useState(false)
171
- const [{ isPending, isResolved, isRejected }] = usePayPalScriptReducer()
172
-
173
- const resetSubmitState = React.useCallback(() => {
174
- hideProcessingOverlay()
175
- creatingOrderRef.current = false
176
- submittingRef.current = false
177
- setSubmitting(false)
178
- }, [])
179
-
180
- // bfcache restore (Back button after a redirect) revives this page with the
181
- // old React state — clear the submitting flag so the buyer isn't stuck
182
- // behind the in-component overlay.
183
- React.useEffect(() => {
184
- const onPageShow = (e: PageTransitionEvent) => {
185
- if (e.persisted) {
186
- creatingOrderRef.current = false
187
- submittingRef.current = false
188
- setSubmitting(false)
189
- }
190
- }
191
- window.addEventListener("pageshow", onPageShow)
192
- return () => window.removeEventListener("pageshow", onPageShow)
193
- }, [])
194
-
195
- // A reload/crash between capture and finalization loses the in-memory
196
- // capturedRef — restore it from the persisted per-cart flag so the buyer
197
- // gets the finalize-only Retry button instead of card fields that would
198
- // charge (or confusingly fail) again.
199
- React.useEffect(() => {
200
- if (capturedRef.current || !wasCartCaptured(cartId)) return
201
- capturedRef.current = {}
202
- setCompletionPending(true)
203
- setError(
204
- "Your payment was already received for this cart. Please use the button below to finish placing your order — do not pay again."
205
- )
206
- }, [cartId])
207
-
208
- const finalizeCapturedPayment = React.useCallback(async () => {
209
- const captured = capturedRef.current
210
- if (!captured) return
211
- setSubmitting(true)
212
- showProcessingOverlay()
213
- setError(null)
214
- try {
215
- const completeResult = await markPaymentComplete(baseUrl, cartId, publishableApiKey)
216
- // The order is finalized — the persisted "captured" flag has served its
217
- // purpose. Clear it before the consumer callback (which may redirect).
218
- clearCartCaptured(cartId)
219
- await onPaid?.({ ...captured, ...completeResult })
220
- setCompletionPending(false)
221
- } catch (e: unknown) {
222
- if (isNextRouterError(e)) return
223
- resetSubmitState()
224
- setCompletionPending(true)
225
- const msg =
226
- "Your payment was received, but finalizing your order failed. Please use the Retry button below — do not pay again."
227
- setError(msg)
228
- onError?.(msg)
229
- }
230
- }, [baseUrl, cartId, publishableApiKey, onPaid, onError, resetSubmitState])
231
-
232
- if (!config.currency_supported) return null
233
-
234
- if (isRejected) {
235
- return (
236
- <div
237
- role="alert"
238
- style={{
239
- display: "flex",
240
- alignItems: "flex-start",
241
- gap: 8,
242
- padding: "10px 14px",
243
- background: "#fef2f2",
244
- border: "1px solid #fecaca",
245
- borderRadius: 8,
246
- fontSize: 13,
247
- color: "#b91c1c",
248
- lineHeight: 1.5,
249
- }}
250
- >
251
- <span style={{ flexShrink: 0, fontSize: 15 }}>⚠️</span>
252
- <span>
253
- PayPal failed to load. Please refresh the page or try a different
254
- payment method.
255
- </span>
256
- </div>
257
- )
258
- }
259
-
260
- if (!config.client_token) {
261
- return (
262
- <div
263
- style={{
264
- padding: "12px 16px",
265
- background: "#fefce8",
266
- border: "1px solid #fde68a",
267
- borderRadius: 8,
268
- fontSize: 13,
269
- color: "#92400e",
270
- }}
271
- >
272
- Card fields unavailable — missing client token from backend.
273
- </div>
274
- )
275
- }
276
-
277
- const isSandbox = config.environment === "sandbox"
278
-
279
- if (isPending) {
280
- return (
281
- <div
282
- role="status"
283
- aria-label="Loading PayPal"
284
- style={{
285
- display: "flex",
286
- alignItems: "center",
287
- justifyContent: "center",
288
- gap: 10,
289
- padding: "18px 16px",
290
- background: "#f9fafb",
291
- border: "1px solid #e5e7eb",
292
- borderRadius: 8,
293
- minHeight: 55,
294
- }}
295
- >
296
- <style>{SPIN_STYLE}</style>
297
- <div
298
- style={{
299
- width: 22,
300
- height: 22,
301
- borderRadius: "50%",
302
- border: "2.5px solid #e5e7eb",
303
- borderTopColor: "#0070ba",
304
- animation: "_pp_spin .7s linear infinite",
305
- flexShrink: 0,
306
- }}
307
- />
308
- <div style={{ fontSize: 13, fontWeight: 500, color: "#6b7280" }}>
309
- Loading card fields…
310
- </div>
311
- </div>
312
- )
313
- }
314
-
315
- if (!isResolved) return null
316
-
317
- return (
318
- <div style={{ position: "relative" }}>
319
- <style>{SPIN_STYLE}</style>
320
-
321
- {submitting && (
322
- <div
323
- role="status"
324
- aria-label="Processing payment"
325
- style={{
326
- position: "fixed",
327
- inset: 0,
328
- zIndex: 9999,
329
- background: "rgba(255,255,255,0.96)",
330
- display: "flex",
331
- flexDirection: "column",
332
- alignItems: "center",
333
- justifyContent: "center",
334
- gap: 20,
335
- }}
336
- >
337
- <div style={{ position: "relative", width: 56, height: 56 }}>
338
- <div
339
- style={{
340
- position: "absolute",
341
- inset: 0,
342
- borderRadius: "50%",
343
- border: "3px solid #e5e7eb",
344
- }}
345
- />
346
- <div
347
- style={{
348
- position: "absolute",
349
- inset: 0,
350
- borderRadius: "50%",
351
- border: "3px solid transparent",
352
- borderTopColor: "#2563eb",
353
- animation: "_pp_spin .8s linear infinite",
354
- }}
355
- />
356
- <svg
357
- viewBox="0 0 24 24"
358
- fill="none"
359
- stroke="#2563eb"
360
- strokeWidth="1.8"
361
- strokeLinecap="round"
362
- strokeLinejoin="round"
363
- style={{
364
- position: "absolute",
365
- top: "50%",
366
- left: "50%",
367
- transform: "translate(-50%, -50%)",
368
- width: 24,
369
- height: 24,
370
- }}
371
- >
372
- <path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
373
- </svg>
374
- </div>
375
- <div style={{ textAlign: "center" }}>
376
- <div style={{ fontSize: 18, fontWeight: 600, color: "#111827", letterSpacing: "-0.01em" }}>
377
- Processing your payment
378
- </div>
379
- <div style={{ fontSize: 14, color: "#6b7280", marginTop: 6, lineHeight: 1.5 }}>
380
- This may take a few moments. Please do not close<br />
381
- or refresh this page.
382
- </div>
383
- </div>
384
- <div
385
- style={{
386
- width: 200,
387
- height: 3,
388
- background: "#e5e7eb",
389
- borderRadius: 3,
390
- overflow: "hidden",
391
- marginTop: 4,
392
- }}
393
- >
394
- <div
395
- style={{
396
- width: "40%",
397
- height: "100%",
398
- background: "linear-gradient(90deg, #2563eb, #1d4ed8)",
399
- borderRadius: 3,
400
- animation: "_pp_progress 1.5s ease-in-out infinite",
401
- }}
402
- />
403
- </div>
404
- </div>
405
- )}
406
-
407
- <PayPalCardFieldsProvider
408
- style={cardStyle}
409
- createOrder={async () => {
410
- if (creatingOrderRef.current) throw new Error("Payment already processing")
411
- if (capturedRef.current) {
412
- // Money was already captured for this cart — retry the order
413
- // finalization instead of creating (and charging) a new order.
414
- void finalizeCapturedPayment()
415
- throw new Error("Your payment was already received — finalizing your order.")
416
- }
417
- creatingOrderRef.current = true
418
- try {
419
- setError(null)
420
- const r = await api.createOrder(cartId, true)
421
- return r.id
422
- } catch (e) {
423
- creatingOrderRef.current = false
424
- throw e
425
- }
426
- }}
427
- onApprove={async (data: { orderID?: string }) => {
428
- try {
429
- setError(null)
430
- showProcessingOverlay()
431
- const orderId = String(data?.orderID || "")
432
- if (!orderId) throw new Error("PayPal order ID is missing from approval response")
433
- const result = await api.captureOrder(cartId, orderId)
434
- capturedRef.current = (result || {}) as Record<string, unknown>
435
- // Persist across reloads: from here on, only finalization may
436
- // ever be retried for this cart — never a fresh payment.
437
- markCartCaptured(cartId)
438
- } catch (e: unknown) {
439
- if (isNextRouterError(e)) return
440
- resetSubmitState()
441
- const msg = e instanceof Error ? e.message : "Card payment failed"
442
- setError(msg)
443
- onError?.(msg)
444
- return
445
- }
446
- // Past this point the buyer has been charged: completion failures
447
- // surface a finalize-only Retry, never a fresh payment.
448
- await finalizeCapturedPayment()
449
- }}
450
- onCancel={() => {
451
- hideProcessingOverlay()
452
- creatingOrderRef.current = false
453
- submittingRef.current = false
454
- setSubmitting(false)
455
- }}
456
- onError={(e: Error | { message?: string }) => {
457
- const msg = e instanceof Error ? e.message : e?.message || "CardFields error"
458
- setError(msg)
459
- onError?.(msg)
460
- hideProcessingOverlay()
461
- creatingOrderRef.current = false
462
- submittingRef.current = false
463
- setSubmitting(false)
464
- }}
465
- >
466
- <div
467
- style={{
468
- display: "flex",
469
- flexDirection: "column",
470
- gap: 16,
471
- width: "100%",
472
- }}
473
- >
474
- {isSandbox && (
475
- <div
476
- style={{
477
- display: "flex",
478
- alignItems: "flex-start",
479
- gap: 10,
480
- padding: "10px 14px",
481
- background: "#eff6ff",
482
- border: "1px solid #bfdbfe",
483
- borderRadius: 8,
484
- fontSize: 13,
485
- color: "#1e40af",
486
- lineHeight: 1.55,
487
- }}
488
- >
489
- <span style={{ fontSize: 15, flexShrink: 0, marginTop: 1 }}>🧪</span>
490
- <span>
491
- Sandbox mode — use test card{" "}
492
- <strong style={{ fontFamily: "monospace", letterSpacing: "0.05em" }}>
493
- 4111 1111 1111 1111
494
- </strong>{" "}
495
- with any future date and any CVV.
496
- </span>
497
- </div>
498
- )}
499
-
500
- <div>
501
- <label style={labelStyle}>Card number</label>
502
- <PayPalNumberField />
503
- </div>
504
-
505
- <div
506
- style={{
507
- display: "grid",
508
- gridTemplateColumns: "1fr 1fr",
509
- gap: 16,
510
- }}
511
- >
512
- <div>
513
- <label style={labelStyle}>Expiration date</label>
514
- <PayPalExpiryField />
515
- </div>
516
- <div>
517
- <label style={labelStyle}>Security code</label>
518
- <PayPalCVVField />
519
- </div>
520
- </div>
521
-
522
- <div
523
- style={{
524
- display: "flex",
525
- alignItems: "center",
526
- gap: 6,
527
- fontSize: 12,
528
- color: "#6b7280",
529
- }}
530
- >
531
- <svg
532
- width="14"
533
- height="14"
534
- viewBox="0 0 24 24"
535
- fill="none"
536
- stroke="#10b981"
537
- strokeWidth="2.5"
538
- strokeLinecap="round"
539
- strokeLinejoin="round"
540
- >
541
- <path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
542
- </svg>
543
- <span>Your payment is secured with 256-bit SSL encryption</span>
544
- </div>
545
-
546
- {completionPending ? (
547
- <button
548
- type="button"
549
- onClick={() => void finalizeCapturedPayment()}
550
- disabled={submitting}
551
- aria-busy={submitting}
552
- style={{
553
- width: "100%",
554
- height: 48,
555
- padding: "0 20px",
556
- borderRadius: 8,
557
- border: "none",
558
- background: "#0070ba",
559
- color: "#ffffff",
560
- fontSize: 15,
561
- fontWeight: 600,
562
- cursor: submitting ? "not-allowed" : "pointer",
563
- opacity: submitting ? 0.7 : 1,
564
- }}
565
- >
566
- Retry — finish placing my order
567
- </button>
568
- ) : (
569
- <SubmitButton
570
- disabled={submitting}
571
- label={submitting ? "Processing…" : "Pay by Card"}
572
- onSubmit={() => {
573
- if (submittingRef.current) return
574
- submittingRef.current = true
575
- setError(null)
576
- setSubmitting(true)
577
- showProcessingOverlay()
578
- }}
579
- onSubmitError={(msg) => {
580
- resetSubmitState()
581
- setError(msg)
582
- onError?.(msg)
583
- }}
584
- />
585
- )}
586
-
587
- {error && (
588
- <div
589
- role="alert"
590
- style={{
591
- display: "flex",
592
- alignItems: "flex-start",
593
- gap: 10,
594
- padding: "10px 14px",
595
- background: "#fef2f2",
596
- border: "1px solid #fecaca",
597
- borderRadius: 8,
598
- fontSize: 13,
599
- color: "#b91c1c",
600
- lineHeight: 1.5,
601
- }}
602
- >
603
- <span style={{ flexShrink: 0, marginTop: 1 }}>⚠️</span>
604
- <span>{error}</span>
605
- </div>
606
- )}
607
- </div>
608
- </PayPalCardFieldsProvider>
609
- </div>
610
- )
611
- }
1
+ "use client"
2
+
3
+ import React, { useMemo, useRef, useState } from "react"
4
+ import {
5
+ PayPalCardFieldsProvider,
6
+ PayPalNumberField,
7
+ PayPalExpiryField,
8
+ PayPalCVVField,
9
+ usePayPalCardFields,
10
+ usePayPalScriptReducer,
11
+ } from "@paypal/react-paypal-js"
12
+
13
+ import { createPayPalStoreApi, markPaymentComplete } from "../client/paypal"
14
+ import type { PayPalConfig } from "../client/types"
15
+ import {
16
+ clearCartCaptured,
17
+ markCartCaptured,
18
+ wasCartCaptured,
19
+ } from "../utils/captured-state"
20
+ import { isNextRouterError } from "../utils/next-errors"
21
+ import {
22
+ showProcessingOverlay,
23
+ hideProcessingOverlay,
24
+ } from "../utils/processing-overlay"
25
+
26
+ const SPIN_STYLE = `
27
+ @keyframes _pp_spin { to { transform: rotate(360deg) } }
28
+ @keyframes _pp_progress { 0% { transform: translateX(-100%) } 100% { transform: translateX(350%) } }
29
+ `
30
+
31
+ const cardStyle = {
32
+ input: {
33
+ "font-size": "15px",
34
+ "font-family":
35
+ "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
36
+ "font-weight": "400",
37
+ color: "#111827",
38
+ padding: "0 14px",
39
+ height: "42px",
40
+ "border-radius": "8px",
41
+ border: "1px solid #d1d5db",
42
+ background: "#ffffff",
43
+ "box-shadow": "none",
44
+ transition: "border-color 0.15s ease",
45
+ },
46
+ "::placeholder": {
47
+ color: "#9ca3af",
48
+ },
49
+ ".invalid": {
50
+ color: "#dc2626",
51
+ border: "1px solid #fca5a5",
52
+ background: "#fff7f7",
53
+ "box-shadow": "none",
54
+ },
55
+ ".valid": {
56
+ color: "#111827",
57
+ border: "1px solid #d1d5db",
58
+ "box-shadow": "none",
59
+ },
60
+ "input:focus": {
61
+ outline: "none",
62
+ border: "1px solid #2563eb",
63
+ "box-shadow": "0 0 0 3px rgba(37,99,235,0.12)",
64
+ },
65
+ }
66
+
67
+ const labelStyle: React.CSSProperties = {
68
+ display: "block",
69
+ fontSize: 13,
70
+ fontWeight: 500,
71
+ color: "#374151",
72
+ marginBottom: 6,
73
+ letterSpacing: "0.01em",
74
+ }
75
+
76
+ function SubmitButton({
77
+ disabled,
78
+ label,
79
+ onSubmit,
80
+ onSubmitError,
81
+ }: {
82
+ disabled: boolean
83
+ label: string
84
+ onSubmit: () => void
85
+ onSubmitError: (message: string) => void
86
+ }) {
87
+ const { cardFieldsForm } = usePayPalCardFields()
88
+ const isDisabled = disabled || !cardFieldsForm
89
+
90
+ return (
91
+ <button
92
+ type="button"
93
+ disabled={isDisabled}
94
+ aria-busy={disabled}
95
+ onClick={() => {
96
+ onSubmit()
97
+ // submit() returns a promise and its rejection is NOT routed to the
98
+ // provider's onError — PayPal's contract is that the caller catches
99
+ // it. A sync try/catch here would miss it entirely, leaving the buyer
100
+ // stuck under the full-screen processing overlay forever (e.g. on a
101
+ // card-field validation error).
102
+ Promise.resolve(cardFieldsForm?.submit()).catch((e: unknown) => {
103
+ const msg =
104
+ e instanceof Error && e.message
105
+ ? e.message
106
+ : "Card payment could not be submitted. Please check your card details."
107
+ onSubmitError(msg)
108
+ })
109
+ }}
110
+ style={{
111
+ width: "100%",
112
+ height: 48,
113
+ padding: "0 20px",
114
+ borderRadius: 8,
115
+ border: "none",
116
+ background: isDisabled
117
+ ? "#e5e7eb"
118
+ : "linear-gradient(180deg, #2563eb 0%, #1d4ed8 100%)",
119
+ color: isDisabled ? "#9ca3af" : "#ffffff",
120
+ fontSize: 15,
121
+ fontWeight: 600,
122
+ fontFamily:
123
+ "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
124
+ cursor: isDisabled ? "not-allowed" : "pointer",
125
+ letterSpacing: "0.01em",
126
+ boxShadow: isDisabled
127
+ ? "none"
128
+ : "0 1px 3px rgba(37,99,235,0.3), 0 1px 2px rgba(0,0,0,0.06)",
129
+ transition: "all 0.15s ease",
130
+ }}
131
+ onMouseEnter={(e) => {
132
+ if (!isDisabled) {
133
+ ;(e.target as HTMLButtonElement).style.background =
134
+ "linear-gradient(180deg, #1d4ed8 0%, #1e40af 100%)"
135
+ ;(e.target as HTMLButtonElement).style.boxShadow =
136
+ "0 4px 6px rgba(37,99,235,0.35), 0 2px 4px rgba(0,0,0,0.06)"
137
+ }
138
+ }}
139
+ onMouseLeave={(e) => {
140
+ if (!isDisabled) {
141
+ ;(e.target as HTMLButtonElement).style.background =
142
+ "linear-gradient(180deg, #2563eb 0%, #1d4ed8 100%)"
143
+ ;(e.target as HTMLButtonElement).style.boxShadow =
144
+ "0 1px 3px rgba(37,99,235,0.3), 0 1px 2px rgba(0,0,0,0.06)"
145
+ }
146
+ }}
147
+ >
148
+ {label}
149
+ </button>
150
+ )
151
+ }
152
+
153
+ export function PayPalAdvancedCard(props: {
154
+ baseUrl: string
155
+ publishableApiKey?: string
156
+ cartId: string
157
+ config: PayPalConfig
158
+ onPaid?: (result: Record<string, unknown>) => void
159
+ onError?: (message: string) => void
160
+ }) {
161
+ const { baseUrl, publishableApiKey, cartId, config, onPaid, onError } = props
162
+
163
+ const api = useMemo(
164
+ () => createPayPalStoreApi({ baseUrl, publishableApiKey }),
165
+ [baseUrl, publishableApiKey]
166
+ )
167
+
168
+ const [error, setError] = useState<string | null>(null)
169
+ const [submitting, setSubmitting] = useState(false)
170
+ const submittingRef = useRef(false)
171
+ const creatingOrderRef = useRef(false)
172
+ // Once a capture succeeded the buyer's money is taken: any later failure
173
+ // must retry ONLY the order finalization, never a fresh order/capture.
174
+ const capturedRef = useRef<Record<string, unknown> | null>(null)
175
+ const [completionPending, setCompletionPending] = useState(false)
176
+ const [{ isPending, isResolved, isRejected }] = usePayPalScriptReducer()
177
+
178
+ const resetSubmitState = React.useCallback(() => {
179
+ hideProcessingOverlay()
180
+ creatingOrderRef.current = false
181
+ submittingRef.current = false
182
+ setSubmitting(false)
183
+ }, [])
184
+
185
+ // bfcache restore (Back button after a redirect) revives this page with the
186
+ // old React state — clear the submitting flag so the buyer isn't stuck
187
+ // behind the in-component overlay.
188
+ React.useEffect(() => {
189
+ const onPageShow = (e: PageTransitionEvent) => {
190
+ if (e.persisted) {
191
+ creatingOrderRef.current = false
192
+ submittingRef.current = false
193
+ setSubmitting(false)
194
+ }
195
+ }
196
+ window.addEventListener("pageshow", onPageShow)
197
+ return () => window.removeEventListener("pageshow", onPageShow)
198
+ }, [])
199
+
200
+ // A reload/crash between capture and finalization loses the in-memory
201
+ // capturedRef — restore it from the persisted per-cart flag so the buyer
202
+ // gets the finalize-only Retry button instead of card fields that would
203
+ // charge (or confusingly fail) again.
204
+ React.useEffect(() => {
205
+ if (capturedRef.current || !wasCartCaptured(cartId)) return
206
+ capturedRef.current = {}
207
+ setCompletionPending(true)
208
+ setError(
209
+ "Your payment was already received for this cart. Please use the button below to finish placing your order — do not pay again."
210
+ )
211
+ }, [cartId])
212
+
213
+ const finalizeCapturedPayment = React.useCallback(async () => {
214
+ const captured = capturedRef.current
215
+ if (!captured) return
216
+ setSubmitting(true)
217
+ showProcessingOverlay()
218
+ setError(null)
219
+ try {
220
+ const completeResult = await markPaymentComplete(
221
+ baseUrl,
222
+ cartId,
223
+ publishableApiKey
224
+ )
225
+ // The order is finalized — the persisted "captured" flag has served its
226
+ // purpose. Clear it before the consumer callback (which may redirect).
227
+ clearCartCaptured(cartId)
228
+ await onPaid?.({ ...captured, ...completeResult })
229
+ setCompletionPending(false)
230
+ } catch (e: unknown) {
231
+ if (isNextRouterError(e)) return
232
+ resetSubmitState()
233
+ setCompletionPending(true)
234
+ const msg =
235
+ "Your payment was received, but finalizing your order failed. Please use the Retry button below — do not pay again."
236
+ setError(msg)
237
+ onError?.(msg)
238
+ }
239
+ }, [baseUrl, cartId, publishableApiKey, onPaid, onError, resetSubmitState])
240
+
241
+ if (!config.currency_supported) return null
242
+
243
+ if (isRejected) {
244
+ return (
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 failed to load. Please refresh the page or try a different
263
+ payment method.
264
+ </span>
265
+ </div>
266
+ )
267
+ }
268
+
269
+ if (!config.client_token) {
270
+ return (
271
+ <div
272
+ style={{
273
+ padding: "12px 16px",
274
+ background: "#fefce8",
275
+ border: "1px solid #fde68a",
276
+ borderRadius: 8,
277
+ fontSize: 13,
278
+ color: "#92400e",
279
+ }}
280
+ >
281
+ Card fields unavailable — missing client token from backend.
282
+ </div>
283
+ )
284
+ }
285
+
286
+ const isSandbox = config.environment === "sandbox"
287
+
288
+ if (isPending) {
289
+ return (
290
+ <div
291
+ role="status"
292
+ aria-label="Loading PayPal"
293
+ style={{
294
+ display: "flex",
295
+ alignItems: "center",
296
+ justifyContent: "center",
297
+ gap: 10,
298
+ padding: "18px 16px",
299
+ background: "#f9fafb",
300
+ border: "1px solid #e5e7eb",
301
+ borderRadius: 8,
302
+ minHeight: 55,
303
+ }}
304
+ >
305
+ <style>{SPIN_STYLE}</style>
306
+ <div
307
+ style={{
308
+ width: 22,
309
+ height: 22,
310
+ borderRadius: "50%",
311
+ border: "2.5px solid #e5e7eb",
312
+ borderTopColor: "#0070ba",
313
+ animation: "_pp_spin .7s linear infinite",
314
+ flexShrink: 0,
315
+ }}
316
+ />
317
+ <div style={{ fontSize: 13, fontWeight: 500, color: "#6b7280" }}>
318
+ Loading card fields…
319
+ </div>
320
+ </div>
321
+ )
322
+ }
323
+
324
+ if (!isResolved) return null
325
+
326
+ return (
327
+ <div style={{ position: "relative" }}>
328
+ <style>{SPIN_STYLE}</style>
329
+
330
+ {submitting && (
331
+ <div
332
+ role="status"
333
+ aria-label="Processing payment"
334
+ style={{
335
+ position: "fixed",
336
+ inset: 0,
337
+ zIndex: 9999,
338
+ background: "rgba(255,255,255,0.96)",
339
+ display: "flex",
340
+ flexDirection: "column",
341
+ alignItems: "center",
342
+ justifyContent: "center",
343
+ gap: 20,
344
+ }}
345
+ >
346
+ <div style={{ position: "relative", width: 56, height: 56 }}>
347
+ <div
348
+ style={{
349
+ position: "absolute",
350
+ inset: 0,
351
+ borderRadius: "50%",
352
+ border: "3px solid #e5e7eb",
353
+ }}
354
+ />
355
+ <div
356
+ style={{
357
+ position: "absolute",
358
+ inset: 0,
359
+ borderRadius: "50%",
360
+ border: "3px solid transparent",
361
+ borderTopColor: "#2563eb",
362
+ animation: "_pp_spin .8s linear infinite",
363
+ }}
364
+ />
365
+ <svg
366
+ viewBox="0 0 24 24"
367
+ fill="none"
368
+ stroke="#2563eb"
369
+ strokeWidth="1.8"
370
+ strokeLinecap="round"
371
+ strokeLinejoin="round"
372
+ style={{
373
+ position: "absolute",
374
+ top: "50%",
375
+ left: "50%",
376
+ transform: "translate(-50%, -50%)",
377
+ width: 24,
378
+ height: 24,
379
+ }}
380
+ >
381
+ <path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
382
+ </svg>
383
+ </div>
384
+ <div style={{ textAlign: "center" }}>
385
+ <div
386
+ style={{
387
+ fontSize: 18,
388
+ fontWeight: 600,
389
+ color: "#111827",
390
+ letterSpacing: "-0.01em",
391
+ }}
392
+ >
393
+ Processing your payment
394
+ </div>
395
+ <div
396
+ style={{
397
+ fontSize: 14,
398
+ color: "#6b7280",
399
+ marginTop: 6,
400
+ lineHeight: 1.5,
401
+ }}
402
+ >
403
+ This may take a few moments. Please do not close
404
+ <br />
405
+ or refresh this page.
406
+ </div>
407
+ </div>
408
+ <div
409
+ style={{
410
+ width: 200,
411
+ height: 3,
412
+ background: "#e5e7eb",
413
+ borderRadius: 3,
414
+ overflow: "hidden",
415
+ marginTop: 4,
416
+ }}
417
+ >
418
+ <div
419
+ style={{
420
+ width: "40%",
421
+ height: "100%",
422
+ background: "linear-gradient(90deg, #2563eb, #1d4ed8)",
423
+ borderRadius: 3,
424
+ animation: "_pp_progress 1.5s ease-in-out infinite",
425
+ }}
426
+ />
427
+ </div>
428
+ </div>
429
+ )}
430
+
431
+ <PayPalCardFieldsProvider
432
+ style={cardStyle}
433
+ createOrder={async () => {
434
+ if (creatingOrderRef.current)
435
+ throw new Error("Payment already processing")
436
+ if (capturedRef.current) {
437
+ // Money was already captured for this cart — retry the order
438
+ // finalization instead of creating (and charging) a new order.
439
+ void finalizeCapturedPayment()
440
+ throw new Error(
441
+ "Your payment was already received — finalizing your order."
442
+ )
443
+ }
444
+ creatingOrderRef.current = true
445
+ try {
446
+ setError(null)
447
+ const r = await api.createOrder(cartId, true)
448
+ return r.id
449
+ } catch (e) {
450
+ creatingOrderRef.current = false
451
+ throw e
452
+ }
453
+ }}
454
+ onApprove={async (data: { orderID?: string }) => {
455
+ try {
456
+ setError(null)
457
+ showProcessingOverlay()
458
+ const orderId = String(data?.orderID || "")
459
+ if (!orderId)
460
+ throw new Error(
461
+ "PayPal order ID is missing from approval response"
462
+ )
463
+ const result = await api.captureOrder(cartId, orderId)
464
+ capturedRef.current = (result || {}) as Record<string, unknown>
465
+ // Persist across reloads: from here on, only finalization may
466
+ // ever be retried for this cart — never a fresh payment.
467
+ markCartCaptured(cartId)
468
+ } catch (e: unknown) {
469
+ if (isNextRouterError(e)) return
470
+ resetSubmitState()
471
+ const msg = e instanceof Error ? e.message : "Card payment failed"
472
+ setError(msg)
473
+ onError?.(msg)
474
+ return
475
+ }
476
+ // Past this point the buyer has been charged: completion failures
477
+ // surface a finalize-only Retry, never a fresh payment.
478
+ await finalizeCapturedPayment()
479
+ }}
480
+ onCancel={() => {
481
+ hideProcessingOverlay()
482
+ creatingOrderRef.current = false
483
+ submittingRef.current = false
484
+ setSubmitting(false)
485
+ }}
486
+ onError={(e: Error | { message?: string }) => {
487
+ const msg =
488
+ e instanceof Error ? e.message : e?.message || "CardFields error"
489
+ setError(msg)
490
+ onError?.(msg)
491
+ hideProcessingOverlay()
492
+ creatingOrderRef.current = false
493
+ submittingRef.current = false
494
+ setSubmitting(false)
495
+ }}
496
+ >
497
+ <div
498
+ style={{
499
+ display: "flex",
500
+ flexDirection: "column",
501
+ gap: 16,
502
+ width: "100%",
503
+ }}
504
+ >
505
+ {isSandbox && (
506
+ <div
507
+ style={{
508
+ display: "flex",
509
+ alignItems: "flex-start",
510
+ gap: 10,
511
+ padding: "10px 14px",
512
+ background: "#eff6ff",
513
+ border: "1px solid #bfdbfe",
514
+ borderRadius: 8,
515
+ fontSize: 13,
516
+ color: "#1e40af",
517
+ lineHeight: 1.55,
518
+ }}
519
+ >
520
+ <span style={{ fontSize: 15, flexShrink: 0, marginTop: 1 }}>
521
+ 🧪
522
+ </span>
523
+ <span>
524
+ Sandbox mode — use test card{" "}
525
+ <strong
526
+ style={{ fontFamily: "monospace", letterSpacing: "0.05em" }}
527
+ >
528
+ 4111 1111 1111 1111
529
+ </strong>{" "}
530
+ with any future date and any CVV.
531
+ </span>
532
+ </div>
533
+ )}
534
+
535
+ <div>
536
+ <label style={labelStyle}>Card number</label>
537
+ <PayPalNumberField />
538
+ </div>
539
+
540
+ <div
541
+ style={{
542
+ display: "grid",
543
+ gridTemplateColumns: "1fr 1fr",
544
+ gap: 16,
545
+ }}
546
+ >
547
+ <div>
548
+ <label style={labelStyle}>Expiration date</label>
549
+ <PayPalExpiryField />
550
+ </div>
551
+ <div>
552
+ <label style={labelStyle}>Security code</label>
553
+ <PayPalCVVField />
554
+ </div>
555
+ </div>
556
+
557
+ <div
558
+ style={{
559
+ display: "flex",
560
+ alignItems: "center",
561
+ gap: 6,
562
+ fontSize: 12,
563
+ color: "#6b7280",
564
+ }}
565
+ >
566
+ <svg
567
+ width="14"
568
+ height="14"
569
+ viewBox="0 0 24 24"
570
+ fill="none"
571
+ stroke="#10b981"
572
+ strokeWidth="2.5"
573
+ strokeLinecap="round"
574
+ strokeLinejoin="round"
575
+ >
576
+ <path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
577
+ </svg>
578
+ <span>Your payment is secured with 256-bit SSL encryption</span>
579
+ </div>
580
+
581
+ {completionPending ? (
582
+ <button
583
+ type="button"
584
+ onClick={() => void finalizeCapturedPayment()}
585
+ disabled={submitting}
586
+ aria-busy={submitting}
587
+ style={{
588
+ width: "100%",
589
+ height: 48,
590
+ padding: "0 20px",
591
+ borderRadius: 8,
592
+ border: "none",
593
+ background: "#0070ba",
594
+ color: "#ffffff",
595
+ fontSize: 15,
596
+ fontWeight: 600,
597
+ cursor: submitting ? "not-allowed" : "pointer",
598
+ opacity: submitting ? 0.7 : 1,
599
+ }}
600
+ >
601
+ Retry — finish placing my order
602
+ </button>
603
+ ) : (
604
+ <SubmitButton
605
+ disabled={submitting}
606
+ label={submitting ? "Processing…" : "Pay by Card"}
607
+ onSubmit={() => {
608
+ if (submittingRef.current) return
609
+ submittingRef.current = true
610
+ setError(null)
611
+ setSubmitting(true)
612
+ showProcessingOverlay()
613
+ }}
614
+ onSubmitError={(msg) => {
615
+ resetSubmitState()
616
+ setError(msg)
617
+ onError?.(msg)
618
+ }}
619
+ />
620
+ )}
621
+
622
+ {error && (
623
+ <div
624
+ role="alert"
625
+ style={{
626
+ display: "flex",
627
+ alignItems: "flex-start",
628
+ gap: 10,
629
+ padding: "10px 14px",
630
+ background: "#fef2f2",
631
+ border: "1px solid #fecaca",
632
+ borderRadius: 8,
633
+ fontSize: 13,
634
+ color: "#b91c1c",
635
+ lineHeight: 1.5,
636
+ }}
637
+ >
638
+ <span style={{ flexShrink: 0, marginTop: 1 }}>⚠️</span>
639
+ <span>{error}</span>
640
+ </div>
641
+ )}
642
+ </div>
643
+ </PayPalCardFieldsProvider>
644
+ </div>
645
+ )
646
+ }