@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.
@@ -1,8 +1,13 @@
1
1
  "use client"
2
- import React, { useCallback, useMemo, useState } from "react"
2
+ 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
 
@@ -35,13 +40,79 @@ export function PayPalSmartButtons(props: {
35
40
  const [error, setError] = useState<string | null>(null)
36
41
  const [processing, setProcessing] = useState(false)
37
42
  const [buttonsReady, setButtonsReady] = useState(false)
43
+ const [loadTimedOut, setLoadTimedOut] = useState(false)
44
+ // Once a capture succeeded, the buyer's money is taken — any later failure
45
+ // (order finalization, consumer callback) must retry ONLY the finalization,
46
+ // never a fresh createOrder/capture, or a retry click would charge again.
47
+ const capturedRef = useRef<Record<string, unknown> | null>(null)
48
+ const [completionPending, setCompletionPending] = useState(false)
38
49
  const [{ isPending, isResolved, isRejected }] = usePayPalScriptReducer()
39
50
 
40
51
  const handleInit = useCallback(() => {
41
52
  setButtonsReady(true)
42
53
  }, [])
43
54
 
44
- const showSpinner = isPending || (isResolved && !buttonsReady)
55
+ // bfcache restore (Back button after a redirect) revives this page with the
56
+ // old React state — clear the processing flag so the buyer isn't stuck
57
+ // behind the in-component overlay.
58
+ useEffect(() => {
59
+ const onPageShow = (e: PageTransitionEvent) => {
60
+ if (e.persisted) setProcessing(false)
61
+ }
62
+ window.addEventListener("pageshow", onPageShow)
63
+ return () => window.removeEventListener("pageshow", onPageShow)
64
+ }, [])
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
+
79
+ // The PayPal SDK never calls onInit when the buttons are ineligible (wrong
80
+ // country/currency/funding) — it silently renders nothing. Without a
81
+ // fallback the buyer stares at "Loading PayPal…" forever.
82
+ useEffect(() => {
83
+ if (!isResolved || buttonsReady) {
84
+ return
85
+ }
86
+ const t = setTimeout(() => setLoadTimedOut(true), 10_000)
87
+ return () => clearTimeout(t)
88
+ }, [isResolved, buttonsReady])
89
+
90
+ const finalizeCapturedPayment = useCallback(async () => {
91
+ const captured = capturedRef.current
92
+ if (!captured) return
93
+ setProcessing(true)
94
+ showProcessingOverlay()
95
+ setError(null)
96
+ try {
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)
101
+ await onPaid?.({ ...captured, ...completeResult })
102
+ setCompletionPending(false)
103
+ } catch (e: unknown) {
104
+ if (isNextRouterError(e)) return
105
+ hideProcessingOverlay()
106
+ setProcessing(false)
107
+ setCompletionPending(true)
108
+ const msg =
109
+ "Your payment was received, but finalizing your order failed. Please use the Retry button below — do not pay again."
110
+ setError(msg)
111
+ onError?.(msg)
112
+ }
113
+ }, [baseUrl, cartId, publishableApiKey, onPaid, onError])
114
+
115
+ const showSpinner = (isPending || (isResolved && !buttonsReady)) && !loadTimedOut
45
116
 
46
117
  if (!config.currency_supported) return null
47
118
 
@@ -170,6 +241,30 @@ export function PayPalSmartButtons(props: {
170
241
  </div>
171
242
  )}
172
243
 
244
+ {loadTimedOut && !buttonsReady && !isRejected && (
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 is not available for this purchase. Please try a different
263
+ payment method.
264
+ </span>
265
+ </div>
266
+ )}
267
+
173
268
  {isRejected && (
174
269
  <div
175
270
  role="alert"
@@ -230,7 +325,7 @@ export function PayPalSmartButtons(props: {
230
325
  </div>
231
326
  )}
232
327
 
233
- {isResolved && (
328
+ {isResolved && !completionPending && (
234
329
  <div style={buttonsReady ? undefined : { position: "absolute", left: -9999, opacity: 0, pointerEvents: "none" }}>
235
330
  <PayPalButtons
236
331
  forceReRender={[config.currency, config.intent, cartId]}
@@ -244,6 +339,12 @@ export function PayPalSmartButtons(props: {
244
339
  }}
245
340
  createOrder={async () => {
246
341
  if (processing) throw new Error("Payment already processing")
342
+ if (capturedRef.current) {
343
+ // Money was already captured for this cart — retry the order
344
+ // finalization instead of creating (and charging) a new order.
345
+ void finalizeCapturedPayment()
346
+ throw new Error("Your payment was already received — finalizing your order.")
347
+ }
247
348
  setError(null)
248
349
  setProcessing(true)
249
350
  try {
@@ -262,10 +363,10 @@ export function PayPalSmartButtons(props: {
262
363
  const orderId = String(data?.orderID || "")
263
364
  if (!orderId) throw new Error("PayPal order ID is missing from approval response")
264
365
  const result = await api.captureOrder(cartId, orderId)
265
-
266
- const completeResult = await markPaymentComplete(baseUrl, cartId, publishableApiKey)
267
-
268
- await onPaid?.({ ...result, ...completeResult })
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)
269
370
  } catch (e: unknown) {
270
371
  if (isNextRouterError(e)) return
271
372
  hideProcessingOverlay()
@@ -273,7 +374,11 @@ export function PayPalSmartButtons(props: {
273
374
  const msg = e instanceof Error ? e.message : "Payment capture failed"
274
375
  setError(msg)
275
376
  onError?.(msg)
377
+ return
276
378
  }
379
+ // Past this point the buyer has been charged: completion failures
380
+ // surface a finalize-only Retry, never a fresh payment.
381
+ await finalizeCapturedPayment()
277
382
  }}
278
383
  onCancel={() => {
279
384
  hideProcessingOverlay()
@@ -290,6 +395,30 @@ export function PayPalSmartButtons(props: {
290
395
  </div>
291
396
  )}
292
397
 
398
+ {completionPending && (
399
+ <button
400
+ type="button"
401
+ onClick={() => void finalizeCapturedPayment()}
402
+ disabled={processing}
403
+ aria-busy={processing}
404
+ style={{
405
+ width: "100%",
406
+ padding: "12px 16px",
407
+ marginTop: 4,
408
+ background: "#0070ba",
409
+ color: "#ffffff",
410
+ border: "none",
411
+ borderRadius: 8,
412
+ fontSize: 14,
413
+ fontWeight: 600,
414
+ cursor: processing ? "not-allowed" : "pointer",
415
+ opacity: processing ? 0.7 : 1,
416
+ }}
417
+ >
418
+ Retry — finish placing my order
419
+ </button>
420
+ )}
421
+
293
422
  {error ? (
294
423
  <div
295
424
  role="alert"
@@ -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
+ }
@@ -12,6 +12,10 @@ export function isNextRouterError(e: unknown): boolean {
12
12
  const digest = (e as { digest: unknown }).digest
13
13
  return (
14
14
  typeof digest === "string" &&
15
- (digest.startsWith("NEXT_REDIRECT") || digest.startsWith("NEXT_NOT_FOUND"))
15
+ (digest.startsWith("NEXT_REDIRECT") ||
16
+ digest.startsWith("NEXT_NOT_FOUND") ||
17
+ // Next.js 15: notFound()/forbidden()/unauthorized() throw
18
+ // HTTPAccessFallbackError with digest "NEXT_HTTP_ERROR_FALLBACK;<status>".
19
+ digest.startsWith("NEXT_HTTP_ERROR_FALLBACK"))
16
20
  )
17
21
  }
@@ -69,8 +69,20 @@ export function showProcessingOverlay() {
69
69
  onNav()
70
70
  }
71
71
 
72
+ // bfcache restore: the overlay intentionally survives the unload of a
73
+ // full-page navigation (that's its purpose), but when the buyer presses
74
+ // Back the browser restores this page from the back/forward cache with the
75
+ // overlay element still in the DOM and the URL equal to startUrl — no nav
76
+ // listener ever fires, leaving checkout permanently covered. `pageshow`
77
+ // with `persisted: true` is exactly that restore.
78
+ const onPageShow = (e: PageTransitionEvent) => {
79
+ if (e.persisted) hideProcessingOverlay()
80
+ }
81
+ window.addEventListener("pageshow", onPageShow)
82
+
72
83
  cleanupNavListener = () => {
73
84
  window.removeEventListener("popstate", onNav)
85
+ window.removeEventListener("pageshow", onPageShow)
74
86
  history.pushState = origPush
75
87
  history.replaceState = origReplace
76
88
  }