@easypayment/medusa-paypal-ui 1.1.1 → 1.2.2

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,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
+ }
@@ -3,6 +3,11 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
3
3
  import { PayPalButtons, usePayPalScriptReducer } from "@paypal/react-paypal-js"
4
4
  import { createPayPalStoreApi, markPaymentComplete } from "../client/paypal"
5
5
  import type { PayPalConfig } from "../client/types"
6
+ import {
7
+ clearCartCaptured,
8
+ markCartCaptured,
9
+ wasCartCaptured,
10
+ } from "../utils/captured-state"
6
11
  import { isNextRouterError } from "../utils/next-errors"
7
12
  import { showProcessingOverlay, hideProcessingOverlay } from "../utils/processing-overlay"
8
13
 
@@ -58,6 +63,19 @@ export function PayPalSmartButtons(props: {
58
63
  return () => window.removeEventListener("pageshow", onPageShow)
59
64
  }, [])
60
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
+
61
79
  // The PayPal SDK never calls onInit when the buttons are ineligible (wrong
62
80
  // country/currency/funding) — it silently renders nothing. Without a
63
81
  // fallback the buyer stares at "Loading PayPal…" forever.
@@ -77,6 +95,9 @@ export function PayPalSmartButtons(props: {
77
95
  setError(null)
78
96
  try {
79
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)
80
101
  await onPaid?.({ ...captured, ...completeResult })
81
102
  setCompletionPending(false)
82
103
  } catch (e: unknown) {
@@ -343,6 +364,9 @@ export function PayPalSmartButtons(props: {
343
364
  if (!orderId) throw new Error("PayPal order ID is missing from approval response")
344
365
  const result = await api.captureOrder(cartId, orderId)
345
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)
346
370
  } catch (e: unknown) {
347
371
  if (isNextRouterError(e)) return
348
372
  hideProcessingOverlay()
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Provider ids registered by `@easypayment/medusa-payment-paypal`.
3
+ *
4
+ * Single source of truth — `PayPalPaymentSection`, `MedusaNextPayPalAdapter`,
5
+ * and the server-safe `order` entrypoint all re-export from here so the ids
6
+ * can never drift between entrypoints.
7
+ */
8
+ export const PAYPAL_WALLET_PROVIDER_ID = "pp_paypal_paypal" as const
9
+ export const PAYPAL_CARD_PROVIDER_ID = "pp_paypal_card_paypal_card" as const
10
+
11
+ export const PAYPAL_PROVIDER_IDS = [
12
+ PAYPAL_WALLET_PROVIDER_ID,
13
+ PAYPAL_CARD_PROVIDER_ID,
14
+ ] as const
15
+
16
+ /** True when the provider id belongs to this PayPal plugin. */
17
+ export function isPayPalProviderId(providerId?: string | null): boolean {
18
+ if (!providerId) return false
19
+ return PAYPAL_PROVIDER_IDS.includes(
20
+ providerId as (typeof PAYPAL_PROVIDER_IDS)[number]
21
+ )
22
+ }
@@ -17,6 +17,13 @@ type Result = {
17
17
  cardEnabled: boolean
18
18
  cardTitle: string
19
19
  loading: boolean
20
+ /**
21
+ * Non-null when the config fetch failed and the returned flags are the
22
+ * optimistic defaults rather than the merchant's real settings. Storefronts
23
+ * can use this to hide or annotate the PayPal options instead of advertising
24
+ * methods that will fail once selected.
25
+ */
26
+ error: string | null
20
27
  }
21
28
 
22
29
  const MAX_CACHE_ENTRIES = 50
@@ -41,6 +48,7 @@ const DEFAULT_RESULT: Result = {
41
48
  cardEnabled: true,
42
49
  cardTitle: "Credit or Debit Card",
43
50
  loading: false,
51
+ error: null,
44
52
  }
45
53
 
46
54
  export function usePayPalPaymentMethods({
@@ -97,6 +105,7 @@ export function usePayPalPaymentMethods({
97
105
  ? cfg.card_title
98
106
  : "Credit or Debit Card",
99
107
  loading: false,
108
+ error: null,
100
109
  }
101
110
 
102
111
  cacheSet(k, { result: next, at: Date.now() })
@@ -112,12 +121,17 @@ export function usePayPalPaymentMethods({
112
121
  paypalEnabled: false,
113
122
  cardEnabled: false,
114
123
  loading: false,
124
+ error: msg || "PayPal is disabled",
115
125
  }
116
126
  setResult(disabled)
117
127
  return
118
128
  }
119
129
 
120
- setResult({ ...DEFAULT_RESULT, loading: false })
130
+ setResult({
131
+ ...DEFAULT_RESULT,
132
+ loading: false,
133
+ error: msg || "Failed to load PayPal payment methods",
134
+ })
121
135
  }
122
136
  })()
123
137
 
package/src/index.ts CHANGED
@@ -9,3 +9,8 @@ export * from "./adapters/MedusaNextPayPalAdapter"
9
9
  export * from "./components/PayPalPaymentSection"
10
10
  export * from "./hooks/usePayPalPaymentMethods"
11
11
  export { showProcessingOverlay, hideProcessingOverlay } from "./utils/processing-overlay"
12
+ export {
13
+ markCartCaptured,
14
+ wasCartCaptured,
15
+ clearCartCaptured,
16
+ } from "./utils/captured-state"
package/src/order.ts CHANGED
@@ -24,8 +24,13 @@
24
24
  * </Text>
25
25
  */
26
26
 
27
- export const PAYPAL_WALLET_PROVIDER_ID = "pp_paypal_paypal"
28
- export const PAYPAL_CARD_PROVIDER_ID = "pp_paypal_card_paypal_card"
27
+ import {
28
+ PAYPAL_WALLET_PROVIDER_ID,
29
+ PAYPAL_CARD_PROVIDER_ID,
30
+ isPayPalProviderId,
31
+ } from "./constants"
32
+
33
+ export { PAYPAL_WALLET_PROVIDER_ID, PAYPAL_CARD_PROVIDER_ID, isPayPalProviderId }
29
34
 
30
35
  export type PayPalPaymentInfo = { title: string }
31
36
 
@@ -41,14 +46,6 @@ export type PayPalConfigTitles = {
41
46
  card_title?: string | null
42
47
  }
43
48
 
44
- /** True when the provider id belongs to this PayPal plugin. */
45
- export function isPayPalProviderId(providerId?: string | null): boolean {
46
- return (
47
- providerId === PAYPAL_WALLET_PROVIDER_ID ||
48
- providerId === PAYPAL_CARD_PROVIDER_ID
49
- )
50
- }
51
-
52
49
  /**
53
50
  * Null-safe payment label resolver for order / confirmation pages.
54
51
  *
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Per-cart "money was captured" flag, persisted in sessionStorage.
3
+ *
4
+ * The in-memory `capturedRef` protects a buyer within one page lifetime, but a
5
+ * reload/crash between the capture and the order finalization loses it — the
6
+ * buyer would then see fresh payment buttons for a cart whose money is already
7
+ * taken. Persisting the flag lets the components restore the finalize-only
8
+ * Retry path after a reload instead of offering a second payment.
9
+ *
10
+ * sessionStorage is deliberately chosen over localStorage: it is scoped to the
11
+ * tab/session, so an abandoned flag can't leak into a future visit after the
12
+ * cart id is reused, and it needs no expiry logic. Every access is guarded —
13
+ * private browsing modes and storage-blocking settings throw on access, and a
14
+ * storage failure must never break the payment flow.
15
+ */
16
+
17
+ const KEY_PREFIX = "__pp_captured::"
18
+
19
+ export function markCartCaptured(cartId: string): void {
20
+ if (!cartId) return
21
+ try {
22
+ window.sessionStorage.setItem(`${KEY_PREFIX}${cartId}`, String(Date.now()))
23
+ } catch {
24
+ // storage unavailable — in-memory state still covers the common path
25
+ }
26
+ }
27
+
28
+ export function wasCartCaptured(cartId: string): boolean {
29
+ if (!cartId) return false
30
+ try {
31
+ return window.sessionStorage.getItem(`${KEY_PREFIX}${cartId}`) !== null
32
+ } catch {
33
+ return false
34
+ }
35
+ }
36
+
37
+ export function clearCartCaptured(cartId: string): void {
38
+ if (!cartId) return
39
+ try {
40
+ window.sessionStorage.removeItem(`${KEY_PREFIX}${cartId}`)
41
+ } catch {
42
+ // ignore
43
+ }
44
+ }