@easypayment/medusa-paypal-ui 1.0.50 → 1.0.52

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,10 +1,15 @@
1
1
  "use client"
2
- import React, { useMemo, useState } from "react"
3
- import { PayPalButtons } from "@paypal/react-paypal-js"
2
+ import React, { useCallback, useMemo, useState } from "react"
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 { isNextRouterError } from "../utils/next-errors"
7
+ import { showProcessingOverlay, hideProcessingOverlay } from "../utils/processing-overlay"
6
8
 
7
- const SPIN_STYLE = `@keyframes _pp_spin { to { transform: rotate(360deg) } }`
9
+ const SPIN_STYLE = `
10
+ @keyframes _pp_spin { to { transform: rotate(360deg) } }
11
+ @keyframes _pp_progress { 0% { transform: translateX(-100%) } 100% { transform: translateX(350%) } }
12
+ `
8
13
 
9
14
  const BUTTON_WIDTH_MAP: Record<string, string> = {
10
15
  small: "300px",
@@ -29,6 +34,14 @@ export function PayPalSmartButtons(props: {
29
34
 
30
35
  const [error, setError] = useState<string | null>(null)
31
36
  const [processing, setProcessing] = useState(false)
37
+ const [buttonsReady, setButtonsReady] = useState(false)
38
+ const [{ isPending, isResolved, isRejected }] = usePayPalScriptReducer()
39
+
40
+ const handleInit = useCallback(() => {
41
+ setButtonsReady(true)
42
+ }, [])
43
+
44
+ const showSpinner = isPending || (isResolved && !buttonsReady)
32
45
 
33
46
  if (!config.currency_supported) return null
34
47
 
@@ -40,42 +53,148 @@ export function PayPalSmartButtons(props: {
40
53
 
41
54
  {processing && (
42
55
  <div
56
+ role="status"
57
+ aria-label="Processing payment"
43
58
  style={{
44
- position: "absolute",
59
+ position: "fixed",
45
60
  inset: 0,
46
- zIndex: 10,
47
- background: "rgba(255,255,255,0.90)",
48
- borderRadius: 8,
61
+ zIndex: 9999,
62
+ background: "rgba(255,255,255,0.96)",
49
63
  display: "flex",
50
64
  flexDirection: "column",
51
65
  alignItems: "center",
52
66
  justifyContent: "center",
67
+ gap: 20,
68
+ }}
69
+ >
70
+ <div style={{ position: "relative", width: 56, height: 56 }}>
71
+ <div
72
+ style={{
73
+ position: "absolute",
74
+ inset: 0,
75
+ borderRadius: "50%",
76
+ border: "3px solid #e5e7eb",
77
+ }}
78
+ />
79
+ <div
80
+ style={{
81
+ position: "absolute",
82
+ inset: 0,
83
+ borderRadius: "50%",
84
+ border: "3px solid transparent",
85
+ borderTopColor: "#0070ba",
86
+ animation: "_pp_spin .8s linear infinite",
87
+ }}
88
+ />
89
+ <svg
90
+ viewBox="0 0 24 24"
91
+ fill="none"
92
+ stroke="#0070ba"
93
+ strokeWidth="1.8"
94
+ strokeLinecap="round"
95
+ strokeLinejoin="round"
96
+ style={{
97
+ position: "absolute",
98
+ top: "50%",
99
+ left: "50%",
100
+ transform: "translate(-50%, -50%)",
101
+ width: 24,
102
+ height: 24,
103
+ }}
104
+ >
105
+ <path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
106
+ </svg>
107
+ </div>
108
+ <div style={{ textAlign: "center" }}>
109
+ <div style={{ fontSize: 18, fontWeight: 600, color: "#111827", letterSpacing: "-0.01em" }}>
110
+ Processing your payment
111
+ </div>
112
+ <div style={{ fontSize: 14, color: "#6b7280", marginTop: 6, lineHeight: 1.5 }}>
113
+ This may take a few moments. Please do not close<br />
114
+ or refresh this page.
115
+ </div>
116
+ </div>
117
+ <div
118
+ style={{
119
+ width: 200,
120
+ height: 3,
121
+ background: "#e5e7eb",
122
+ borderRadius: 3,
123
+ overflow: "hidden",
124
+ marginTop: 4,
125
+ }}
126
+ >
127
+ <div
128
+ style={{
129
+ width: "40%",
130
+ height: "100%",
131
+ background: "linear-gradient(90deg, #0070ba, #003087)",
132
+ borderRadius: 3,
133
+ animation: "_pp_progress 1.5s ease-in-out infinite",
134
+ }}
135
+ />
136
+ </div>
137
+ </div>
138
+ )}
139
+
140
+ {showSpinner && (
141
+ <div
142
+ role="status"
143
+ aria-label="Loading PayPal"
144
+ style={{
145
+ display: "flex",
146
+ alignItems: "center",
147
+ justifyContent: "center",
53
148
  gap: 10,
54
- minHeight: 60,
149
+ padding: "18px 16px",
150
+ background: "#f9fafb",
151
+ border: "1px solid #e5e7eb",
152
+ borderRadius: 8,
153
+ minHeight: 55,
55
154
  }}
56
155
  >
57
156
  <div
58
157
  style={{
59
- width: 28,
60
- height: 28,
158
+ width: 22,
159
+ height: 22,
61
160
  borderRadius: "50%",
62
161
  border: "2.5px solid #e5e7eb",
63
162
  borderTopColor: "#0070ba",
64
163
  animation: "_pp_spin .7s linear infinite",
164
+ flexShrink: 0,
65
165
  }}
66
166
  />
67
- <div style={{ textAlign: "center" }}>
68
- <div style={{ fontSize: 13, color: "#374151", fontWeight: 500 }}>
69
- Processing your payment…
70
- </div>
71
- <div style={{ fontSize: 12, color: "#6b7280", marginTop: 4 }}>
72
- Please do not close or refresh this page
73
- </div>
167
+ <div style={{ fontSize: 13, fontWeight: 500, color: "#6b7280" }}>
168
+ Loading PayPal…
74
169
  </div>
75
170
  </div>
76
171
  )}
77
172
 
78
- {config.environment === "sandbox" && (
173
+ {isRejected && (
174
+ <div
175
+ role="alert"
176
+ style={{
177
+ display: "flex",
178
+ alignItems: "flex-start",
179
+ gap: 8,
180
+ padding: "10px 14px",
181
+ background: "#fef2f2",
182
+ border: "1px solid #fecaca",
183
+ borderRadius: 8,
184
+ fontSize: 13,
185
+ color: "#b91c1c",
186
+ lineHeight: 1.5,
187
+ }}
188
+ >
189
+ <span style={{ flexShrink: 0, fontSize: 15 }}>⚠️</span>
190
+ <span>
191
+ PayPal failed to load. Please refresh the page or try a different
192
+ payment method.
193
+ </span>
194
+ </div>
195
+ )}
196
+
197
+ {config.environment === "sandbox" && buttonsReady && (
79
198
  <div
80
199
  style={{
81
200
  display: "flex",
@@ -111,50 +230,69 @@ export function PayPalSmartButtons(props: {
111
230
  </div>
112
231
  )}
113
232
 
114
- <PayPalButtons
115
- style={{
116
- layout: "vertical",
117
- color: config.button_color,
118
- shape: config.button_shape,
119
- label: config.button_label,
120
- height: config.button_height,
121
- }}
122
- createOrder={async () => {
123
- setError(null)
124
- const r = await api.createOrder(cartId)
125
- return r.id
126
- }}
127
- onApprove={async (data: { orderID?: string }) => {
128
- try {
129
- setProcessing(true)
233
+ {isResolved && (
234
+ <div style={buttonsReady ? undefined : { position: "absolute", left: -9999, opacity: 0, pointerEvents: "none" }}>
235
+ <PayPalButtons
236
+ forceReRender={[config.currency, config.intent, cartId]}
237
+ onInit={handleInit}
238
+ style={{
239
+ layout: "vertical",
240
+ color: config.button_color,
241
+ shape: config.button_shape,
242
+ label: config.button_label,
243
+ height: config.button_height,
244
+ }}
245
+ createOrder={async () => {
246
+ if (processing) throw new Error("Payment already processing")
130
247
  setError(null)
131
- const orderId = String(data?.orderID || "")
132
- const result = await api.captureOrder(cartId, orderId)
248
+ setProcessing(true)
249
+ try {
250
+ const r = await api.createOrder(cartId)
251
+ return r.id
252
+ } catch (e: unknown) {
253
+ setProcessing(false)
254
+ throw e
255
+ }
256
+ }}
257
+ onApprove={async (data: { orderID?: string }) => {
258
+ try {
259
+ setProcessing(true)
260
+ showProcessingOverlay()
261
+ setError(null)
262
+ const orderId = String(data?.orderID || "")
263
+ if (!orderId) throw new Error("PayPal order ID is missing from approval response")
264
+ const result = await api.captureOrder(cartId, orderId)
133
265
 
134
- const completeResult = await markPaymentComplete(baseUrl, cartId, publishableApiKey)
266
+ const completeResult = await markPaymentComplete(baseUrl, cartId, publishableApiKey)
135
267
 
136
- onPaid?.({ ...result, ...completeResult })
137
- } catch (e: unknown) {
138
- const msg = e instanceof Error ? e.message : "Payment capture failed"
268
+ await onPaid?.({ ...result, ...completeResult })
269
+ } catch (e: unknown) {
270
+ if (isNextRouterError(e)) throw e
271
+ hideProcessingOverlay()
272
+ setProcessing(false)
273
+ const msg = e instanceof Error ? e.message : "Payment capture failed"
274
+ setError(msg)
275
+ onError?.(msg)
276
+ }
277
+ }}
278
+ onCancel={() => {
279
+ hideProcessingOverlay()
280
+ setProcessing(false)
281
+ }}
282
+ onError={(err: Error | { message?: string }) => {
283
+ hideProcessingOverlay()
284
+ setProcessing(false)
285
+ const msg = err instanceof Error ? err.message : err?.message || "PayPal error"
139
286
  setError(msg)
140
287
  onError?.(msg)
141
- } finally {
142
- setProcessing(false)
143
- }
144
- }}
145
- onCancel={() => {
146
- setProcessing(false)
147
- }}
148
- onError={(err: Error | { message?: string }) => {
149
- setProcessing(false)
150
- const msg = err instanceof Error ? err.message : err?.message || "PayPal error"
151
- setError(msg)
152
- onError?.(msg)
153
- }}
154
- />
288
+ }}
289
+ />
290
+ </div>
291
+ )}
155
292
 
156
293
  {error ? (
157
294
  <div
295
+ role="alert"
158
296
  style={{
159
297
  marginTop: 10,
160
298
  display: "flex",
@@ -11,9 +11,18 @@ type Args = {
11
11
  enabled?: boolean
12
12
  }
13
13
 
14
+ const MAX_CACHE_ENTRIES = 50
14
15
  const _cache = new Map<string, { config: PayPalConfig; at: number }>()
15
16
  const CACHE_TTL = 5 * 60 * 1000
16
17
 
18
+ function cacheSet(key: string, value: { config: PayPalConfig; at: number }) {
19
+ if (_cache.size >= MAX_CACHE_ENTRIES) {
20
+ const oldest = _cache.keys().next().value
21
+ if (oldest !== undefined) _cache.delete(oldest)
22
+ }
23
+ _cache.set(key, value)
24
+ }
25
+
17
26
  function cacheKey(baseUrl: string, cartId?: string) {
18
27
  return `${baseUrl}::${cartId ?? ""}`
19
28
  }
@@ -66,12 +75,12 @@ export function usePayPalConfig({
66
75
  try {
67
76
  const cfg = await api.getConfig(cartId, controller.signal)
68
77
  if (!mounted || id !== fetchIdRef.current) return
69
- _cache.set(k, { config: cfg, at: Date.now() })
78
+ cacheSet(k, { config: cfg, at: Date.now() })
70
79
  setConfig(cfg)
71
- } catch (e: any) {
72
- if (e?.name === "AbortError") return
80
+ } catch (e: unknown) {
81
+ if (e instanceof Error && e.name === "AbortError") return
73
82
  if (!mounted || id !== fetchIdRef.current) return
74
- setError(e?.message || "Failed to load PayPal config")
83
+ setError(e instanceof Error ? e.message : "Failed to load PayPal config")
75
84
  } finally {
76
85
  if (mounted && id === fetchIdRef.current) setLoading(false)
77
86
  }
@@ -19,9 +19,18 @@ type Result = {
19
19
  loading: boolean
20
20
  }
21
21
 
22
+ const MAX_CACHE_ENTRIES = 50
22
23
  const _cache = new Map<string, { result: Result; at: number }>()
23
24
  const CACHE_TTL = 5 * 60 * 1000
24
25
 
26
+ function cacheSet(key: string, value: { result: Result; at: number }) {
27
+ if (_cache.size >= MAX_CACHE_ENTRIES) {
28
+ const oldest = _cache.keys().next().value
29
+ if (oldest !== undefined) _cache.delete(oldest)
30
+ }
31
+ _cache.set(key, value)
32
+ }
33
+
25
34
  function cacheKey(baseUrl: string, cartId?: string) {
26
35
  return `ppm::${baseUrl}::${cartId ?? ""}`
27
36
  }
@@ -90,13 +99,14 @@ export function usePayPalPaymentMethods({
90
99
  loading: false,
91
100
  }
92
101
 
93
- _cache.set(k, { result: next, at: Date.now() })
102
+ cacheSet(k, { result: next, at: Date.now() })
94
103
  setResult(next)
95
- } catch (e: any) {
96
- if (e?.name === "AbortError") return
104
+ } catch (e: unknown) {
105
+ const msg = e instanceof Error ? e.message : ""
106
+ if (e instanceof Error && e.name === "AbortError") return
97
107
  if (!mounted || id !== fetchIdRef.current) return
98
108
 
99
- if (e?.message?.includes("403") || e?.message?.includes("Forbidden")) {
109
+ if (msg.includes("403") || msg.includes("Forbidden")) {
100
110
  const disabled: Result = {
101
111
  ...DEFAULT_RESULT,
102
112
  paypalEnabled: false,
package/src/index.ts CHANGED
@@ -8,3 +8,4 @@ export * from "./components/PayPalAdvancedCard"
8
8
  export * from "./adapters/MedusaNextPayPalAdapter"
9
9
  export * from "./components/PayPalPaymentSection"
10
10
  export * from "./hooks/usePayPalPaymentMethods"
11
+ export { showProcessingOverlay, hideProcessingOverlay } from "./utils/processing-overlay"
package/src/order.ts CHANGED
@@ -101,13 +101,26 @@ export async function fetchPayPalConfig(opts: {
101
101
  return {}
102
102
  }
103
103
  try {
104
- const res = await fetch(`${baseUrl.replace(/\/$/, "")}/store/paypal/config`, {
105
- headers: {
106
- accept: "application/json",
107
- ...(publishableApiKey ? { "x-publishable-api-key": publishableApiKey } : {}),
108
- },
109
- signal,
110
- })
104
+ const timeoutMs = 30_000
105
+ let effectiveSignal = signal
106
+ let timeoutId: ReturnType<typeof setTimeout> | undefined
107
+ if (!signal) {
108
+ const controller = new AbortController()
109
+ timeoutId = setTimeout(() => controller.abort(), timeoutMs)
110
+ effectiveSignal = controller.signal
111
+ }
112
+ let res: Response
113
+ try {
114
+ res = await fetch(`${baseUrl.replace(/\/$/, "")}/store/paypal/config`, {
115
+ headers: {
116
+ accept: "application/json",
117
+ ...(publishableApiKey ? { "x-publishable-api-key": publishableApiKey } : {}),
118
+ },
119
+ signal: effectiveSignal,
120
+ })
121
+ } finally {
122
+ if (timeoutId !== undefined) clearTimeout(timeoutId)
123
+ }
111
124
  if (!res.ok) {
112
125
  return {}
113
126
  }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Detect Next.js router errors (redirect / notFound) that must be re-thrown.
3
+ *
4
+ * Next.js implements redirect() and notFound() by throwing special errors with
5
+ * a `digest` property starting with "NEXT_REDIRECT" or "NEXT_NOT_FOUND".
6
+ * Catching these in user-land try/catch blocks prevents the navigation from
7
+ * happening. Because this package can't import from `next` (it's a peer dep),
8
+ * we detect them by duck-typing the digest.
9
+ */
10
+ export function isNextRouterError(e: unknown): boolean {
11
+ if (typeof e !== "object" || e === null || !("digest" in e)) return false
12
+ const digest = (e as { digest: unknown }).digest
13
+ return (
14
+ typeof digest === "string" &&
15
+ (digest.startsWith("NEXT_REDIRECT") || digest.startsWith("NEXT_NOT_FOUND"))
16
+ )
17
+ }
@@ -0,0 +1,55 @@
1
+ const OVERLAY_ID = "__pp_processing_overlay"
2
+
3
+ export function showProcessingOverlay() {
4
+ if (typeof document === "undefined") return
5
+ if (document.getElementById(OVERLAY_ID)) return
6
+
7
+ const overlay = document.createElement("div")
8
+ overlay.id = OVERLAY_ID
9
+ overlay.setAttribute("role", "status")
10
+ overlay.setAttribute("aria-label", "Processing payment")
11
+
12
+ overlay.innerHTML = `
13
+ <style>
14
+ @keyframes _pp_spin { to { transform: rotate(360deg) } }
15
+ @keyframes _pp_progress { 0% { transform: translateX(-100%) } 100% { transform: translateX(350%) } }
16
+ </style>
17
+ <div style="position:relative;width:56px;height:56px">
18
+ <div style="position:absolute;inset:0;border-radius:50%;border:3px solid #e5e7eb"></div>
19
+ <div style="position:absolute;inset:0;border-radius:50%;border:3px solid transparent;border-top-color:#0070ba;animation:_pp_spin .8s linear infinite"></div>
20
+ <svg viewBox="0 0 24 24" fill="none" stroke="#0070ba" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"
21
+ style="position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);width:24px;height:24px">
22
+ <path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
23
+ </svg>
24
+ </div>
25
+ <div style="text-align:center">
26
+ <div style="font-size:18px;font-weight:600;color:#111827;letter-spacing:-0.01em">Processing your payment</div>
27
+ <div style="font-size:14px;color:#6b7280;margin-top:6px;line-height:1.5">
28
+ This may take a few moments. Please do not close<br>or refresh this page.
29
+ </div>
30
+ </div>
31
+ <div style="width:200px;height:3px;background:#e5e7eb;border-radius:3px;overflow:hidden;margin-top:4px">
32
+ <div style="width:40%;height:100%;background:linear-gradient(90deg,#0070ba,#003087);border-radius:3px;animation:_pp_progress 1.5s ease-in-out infinite"></div>
33
+ </div>
34
+ `
35
+
36
+ Object.assign(overlay.style, {
37
+ position: "fixed",
38
+ inset: "0",
39
+ zIndex: "99999",
40
+ background: "rgba(255,255,255,0.96)",
41
+ display: "flex",
42
+ flexDirection: "column",
43
+ alignItems: "center",
44
+ justifyContent: "center",
45
+ gap: "20px",
46
+ fontFamily: "-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif",
47
+ })
48
+
49
+ document.body.appendChild(overlay)
50
+ }
51
+
52
+ export function hideProcessingOverlay() {
53
+ if (typeof document === "undefined") return
54
+ document.getElementById(OVERLAY_ID)?.remove()
55
+ }