@easypayment/medusa-paypal-ui 1.1.0 → 1.2.0

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.
@@ -12,6 +12,11 @@ import {
12
12
 
13
13
  import { createPayPalStoreApi, markPaymentComplete } from "../client/paypal"
14
14
  import type { PayPalConfig } from "../client/types"
15
+ import {
16
+ clearCartCaptured,
17
+ markCartCaptured,
18
+ wasCartCaptured,
19
+ } from "../utils/captured-state"
15
20
  import { isNextRouterError } from "../utils/next-errors"
16
21
  import { showProcessingOverlay, hideProcessingOverlay } from "../utils/processing-overlay"
17
22
 
@@ -68,10 +73,12 @@ function SubmitButton({
68
73
  disabled,
69
74
  label,
70
75
  onSubmit,
76
+ onSubmitError,
71
77
  }: {
72
78
  disabled: boolean
73
79
  label: string
74
80
  onSubmit: () => void
81
+ onSubmitError: (message: string) => void
75
82
  }) {
76
83
  const { cardFieldsForm } = usePayPalCardFields()
77
84
  const isDisabled = disabled || !cardFieldsForm
@@ -83,11 +90,18 @@ function SubmitButton({
83
90
  aria-busy={disabled}
84
91
  onClick={() => {
85
92
  onSubmit()
86
- try {
87
- cardFieldsForm?.submit()
88
- } catch {
89
- // submit() error is handled by PayPalCardFieldsProvider onError
90
- }
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
+ })
91
105
  }}
92
106
  style={{
93
107
  width: "100%",
@@ -150,8 +164,71 @@ export function PayPalAdvancedCard(props: {
150
164
  const [submitting, setSubmitting] = useState(false)
151
165
  const submittingRef = useRef(false)
152
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)
153
171
  const [{ isPending, isResolved, isRejected }] = usePayPalScriptReducer()
154
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
+
155
232
  if (!config.currency_supported) return null
156
233
 
157
234
  if (isRejected) {
@@ -331,6 +408,12 @@ export function PayPalAdvancedCard(props: {
331
408
  style={cardStyle}
332
409
  createOrder={async () => {
333
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
+ }
334
417
  creatingOrderRef.current = true
335
418
  try {
336
419
  setError(null)
@@ -346,21 +429,23 @@ export function PayPalAdvancedCard(props: {
346
429
  setError(null)
347
430
  showProcessingOverlay()
348
431
  const orderId = String(data?.orderID || "")
432
+ if (!orderId) throw new Error("PayPal order ID is missing from approval response")
349
433
  const result = await api.captureOrder(cartId, orderId)
350
-
351
- const completeResult = await markPaymentComplete(baseUrl, cartId, publishableApiKey)
352
-
353
- await onPaid?.({ ...result, ...completeResult })
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)
354
438
  } catch (e: unknown) {
355
439
  if (isNextRouterError(e)) return
356
- hideProcessingOverlay()
357
- creatingOrderRef.current = false
358
- submittingRef.current = false
359
- setSubmitting(false)
440
+ resetSubmitState()
360
441
  const msg = e instanceof Error ? e.message : "Card payment failed"
361
442
  setError(msg)
362
443
  onError?.(msg)
444
+ return
363
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()
364
449
  }}
365
450
  onCancel={() => {
366
451
  hideProcessingOverlay()
@@ -458,17 +543,46 @@ export function PayPalAdvancedCard(props: {
458
543
  <span>Your payment is secured with 256-bit SSL encryption</span>
459
544
  </div>
460
545
 
461
- <SubmitButton
462
- disabled={submitting}
463
- label={submitting ? "Processing…" : "Pay by Card"}
464
- onSubmit={() => {
465
- if (submittingRef.current) return
466
- submittingRef.current = true
467
- setError(null)
468
- setSubmitting(true)
469
- showProcessingOverlay()
470
- }}
471
- />
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
+ )}
472
586
 
473
587
  {error && (
474
588
  <div
@@ -1,202 +1,95 @@
1
- "use client"
2
-
3
- import React, { useCallback } from "react"
4
- import { PayPalCurrencyNotice } from "./PayPalCurrencyNotice"
5
- import { PayPalAdvancedCard } from "./PayPalAdvancedCard"
6
- import { PayPalProvider } from "./PayPalProvider"
7
- import { PayPalSmartButtons } from "./PayPalSmartButtons"
8
- import { usePayPalConfig } from "../hooks/usePayPalConfig"
9
-
10
- export const PAYPAL_WALLET_PROVIDER_ID = "pp_paypal_paypal" as const
11
- export const PAYPAL_CARD_PROVIDER_ID = "pp_paypal_card_paypal_card" as const
12
-
13
- const PAYPAL_PROVIDER_IDS = [
14
- PAYPAL_WALLET_PROVIDER_ID,
15
- PAYPAL_CARD_PROVIDER_ID,
16
- ] as const
17
-
18
- export function isPayPalProviderId(id?: string | null): boolean {
19
- if (!id) return false
20
- return PAYPAL_PROVIDER_IDS.includes(id as (typeof PAYPAL_PROVIDER_IDS)[number])
21
- }
22
-
23
- const SPIN_STYLE = `@keyframes _pp_section_spin { to { transform: rotate(360deg) } }`
24
-
25
- function SessionInitCard() {
26
- return (
27
- <div
28
- role="status"
29
- aria-label="Setting up payment"
30
- style={{
31
- display: "flex",
32
- alignItems: "center",
33
- gap: 12,
34
- padding: "14px 16px",
35
- marginTop: 8,
36
- background: "#f9fafb",
37
- border: "1px solid #e5e7eb",
38
- borderRadius: 10,
39
- }}
40
- >
41
- <style>{SPIN_STYLE}</style>
42
- <div
43
- style={{
44
- width: 20,
45
- height: 20,
46
- borderRadius: "50%",
47
- border: "2.5px solid #e5e7eb",
48
- borderTopColor: "#0070ba",
49
- animation: "_pp_section_spin .7s linear infinite",
50
- flexShrink: 0,
51
- }}
52
- />
53
- <div style={{ fontSize: 13, fontWeight: 500, color: "#111827" }}>
54
- Setting up payment…
55
- </div>
56
- </div>
57
- )
58
- }
59
-
60
- function ConfigLoadingCard() {
61
- return (
62
- <div
63
- role="status"
64
- aria-label="Connecting to PayPal"
65
- style={{
66
- display: "flex",
67
- alignItems: "center",
68
- gap: 12,
69
- padding: "14px 16px",
70
- background: "#f9fafb",
71
- border: "1px solid #e5e7eb",
72
- borderRadius: 10,
73
- }}
74
- >
75
- <style>{SPIN_STYLE}</style>
76
- <div
77
- style={{
78
- width: 22,
79
- height: 22,
80
- borderRadius: "50%",
81
- border: "2.5px solid #e5e7eb",
82
- borderTopColor: "#0070ba",
83
- animation: "_pp_section_spin .7s linear infinite",
84
- flexShrink: 0,
85
- }}
86
- />
87
- <div>
88
- <div style={{ fontSize: 13, fontWeight: 500, color: "#111827" }}>
89
- Connecting to PayPal…
90
- </div>
91
- <div style={{ fontSize: 12, color: "#6b7280", marginTop: 2 }}>
92
- Setting up secure payment
93
- </div>
94
- </div>
95
- </div>
96
- )
97
- }
98
-
99
- function ErrorCard({ message }: { message: string }) {
100
- return (
101
- <div
102
- role="alert"
103
- style={{
104
- padding: "12px 16px",
105
- background: "#fef2f2",
106
- border: "1px solid #fecaca",
107
- borderRadius: 10,
108
- fontSize: 13,
109
- color: "#b91c1c",
110
- }}
111
- >
112
- {message}
113
- </div>
114
- )
115
- }
116
-
117
- export type PayPalPaymentSectionProps = {
118
- cartId: string
119
- selectedProviderId: string | null | undefined
120
- baseUrl: string
121
- publishableApiKey?: string
122
- sessionLoading?: boolean
123
- onSuccess?: (cartId: string) => void
124
- onError?: (message: string) => void
125
- onPaid?: (result: unknown) => void
126
- }
127
-
128
- export function PayPalPaymentSection({
129
- cartId,
130
- selectedProviderId,
131
- baseUrl,
132
- publishableApiKey,
133
- sessionLoading = false,
134
- onSuccess,
135
- onError,
136
- onPaid,
137
- }: PayPalPaymentSectionProps) {
138
- const shouldRender = isPayPalProviderId(selectedProviderId)
139
-
140
- const { config, loading, error } = usePayPalConfig({
141
- baseUrl,
142
- publishableApiKey,
143
- cartId,
144
- enabled: shouldRender,
145
- })
146
-
147
- const handlePaid = useCallback(
148
- async (captureResult: unknown) => {
149
- onPaid?.(captureResult)
150
- await onSuccess?.(cartId)
151
- },
152
- [cartId, onPaid, onSuccess]
153
- )
154
-
155
- if (!shouldRender) return null
156
- if (sessionLoading) return <SessionInitCard />
157
- if (loading) return <ConfigLoadingCard />
158
- if (error) return <ErrorCard message={error} />
159
- if (!config) return null
160
-
161
- if (config.paypal_enabled === false && selectedProviderId === PAYPAL_WALLET_PROVIDER_ID) {
162
- return null
163
- }
164
-
165
- const isCardProvider = selectedProviderId === PAYPAL_CARD_PROVIDER_ID
166
- if (isCardProvider && config.card_enabled === false) return null
167
-
168
- const disableFunding = Array.isArray(config.disable_buttons)
169
- ? config.disable_buttons.join(",")
170
- : undefined
171
-
172
- return (
173
- <div key={selectedProviderId} style={{ display: "grid", gap: 12 }}>
174
- <PayPalCurrencyNotice config={config} />
175
- <PayPalProvider
176
- config={config}
177
- intent={config.intent === "authorize" ? "authorize" : "capture"}
178
- disableFunding={disableFunding}
179
- >
180
- {isCardProvider ? (
181
- <PayPalAdvancedCard
182
- baseUrl={baseUrl}
183
- publishableApiKey={publishableApiKey}
184
- cartId={cartId}
185
- config={config}
186
- onPaid={handlePaid}
187
- onError={onError}
188
- />
189
- ) : (
190
- <PayPalSmartButtons
191
- baseUrl={baseUrl}
192
- publishableApiKey={publishableApiKey}
193
- cartId={cartId}
194
- config={config}
195
- onPaid={handlePaid}
196
- onError={onError}
197
- />
198
- )}
199
- </PayPalProvider>
200
- </div>
201
- )
202
- }
1
+ "use client"
2
+
3
+ import React from "react"
4
+ import { MedusaNextPayPalAdapter } from "../adapters/MedusaNextPayPalAdapter"
5
+ import {
6
+ PAYPAL_WALLET_PROVIDER_ID,
7
+ PAYPAL_CARD_PROVIDER_ID,
8
+ isPayPalProviderId,
9
+ } from "../constants"
10
+
11
+ // Re-exported for backward compatibility — these have always been part of this
12
+ // module's public API. The canonical definitions live in ../constants.
13
+ export { PAYPAL_WALLET_PROVIDER_ID, PAYPAL_CARD_PROVIDER_ID, isPayPalProviderId }
14
+
15
+ const SPIN_STYLE = `@keyframes _pp_section_spin { to { transform: rotate(360deg) } }`
16
+
17
+ function SessionInitCard() {
18
+ return (
19
+ <div
20
+ role="status"
21
+ aria-label="Setting up payment"
22
+ style={{
23
+ display: "flex",
24
+ alignItems: "center",
25
+ gap: 12,
26
+ padding: "14px 16px",
27
+ marginTop: 8,
28
+ background: "#f9fafb",
29
+ border: "1px solid #e5e7eb",
30
+ borderRadius: 10,
31
+ }}
32
+ >
33
+ <style>{SPIN_STYLE}</style>
34
+ <div
35
+ style={{
36
+ width: 20,
37
+ height: 20,
38
+ borderRadius: "50%",
39
+ border: "2.5px solid #e5e7eb",
40
+ borderTopColor: "#0070ba",
41
+ animation: "_pp_section_spin .7s linear infinite",
42
+ flexShrink: 0,
43
+ }}
44
+ />
45
+ <div style={{ fontSize: 13, fontWeight: 500, color: "#111827" }}>
46
+ Setting up payment…
47
+ </div>
48
+ </div>
49
+ )
50
+ }
51
+
52
+ export type PayPalPaymentSectionProps = {
53
+ cartId: string
54
+ selectedProviderId: string | null | undefined
55
+ baseUrl: string
56
+ publishableApiKey?: string
57
+ sessionLoading?: boolean
58
+ onSuccess?: (cartId: string) => void
59
+ onError?: (message: string) => void
60
+ onPaid?: (result: unknown) => void
61
+ }
62
+
63
+ /**
64
+ * Drop-in PayPal payment section for a Medusa checkout.
65
+ *
66
+ * A thin wrapper over {@link MedusaNextPayPalAdapter} (they previously carried
67
+ * two ~90%-identical render paths that had to be edited in lockstep): this
68
+ * component pins the default provider ids and adds the `sessionLoading` gate
69
+ * shown while the storefront is still creating the payment session.
70
+ */
71
+ export function PayPalPaymentSection({
72
+ cartId,
73
+ selectedProviderId,
74
+ baseUrl,
75
+ publishableApiKey,
76
+ sessionLoading = false,
77
+ onSuccess,
78
+ onError,
79
+ onPaid,
80
+ }: PayPalPaymentSectionProps) {
81
+ if (!isPayPalProviderId(selectedProviderId)) return null
82
+ if (sessionLoading) return <SessionInitCard />
83
+
84
+ return (
85
+ <MedusaNextPayPalAdapter
86
+ cartId={cartId}
87
+ selectedProviderId={selectedProviderId}
88
+ baseUrl={baseUrl}
89
+ publishableApiKey={publishableApiKey}
90
+ onSuccess={onSuccess}
91
+ onError={onError}
92
+ onPaid={onPaid}
93
+ />
94
+ )
95
+ }