@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.
- package/CHANGELOG.md +97 -25
- package/README.md +781 -759
- package/dist/index.cjs +366 -170
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +74 -2
- package/dist/index.d.ts +74 -2
- package/dist/index.mjs +352 -170
- package/dist/index.mjs.map +1 -1
- package/dist/order.cjs +14 -3
- package/dist/order.cjs.map +1 -1
- package/dist/order.d.cts +13 -4
- package/dist/order.d.ts +13 -4
- package/dist/order.mjs +13 -4
- package/dist/order.mjs.map +1 -1
- package/package.json +76 -65
- package/src/adapters/MedusaNextPayPalAdapter.tsx +34 -5
- package/src/client/http.ts +63 -11
- package/src/client/paypal.ts +27 -1
- package/src/client/types.ts +10 -0
- package/src/components/PayPalAdvancedCard.tsx +138 -24
- package/src/components/PayPalPaymentSection.tsx +95 -202
- package/src/components/PayPalSmartButtons.tsx +136 -7
- package/src/constants.ts +22 -0
- package/src/hooks/usePayPalPaymentMethods.ts +15 -1
- package/src/index.ts +5 -0
- package/src/order.ts +7 -10
- package/src/utils/captured-state.ts +44 -0
- package/src/utils/next-errors.ts +5 -1
- package/src/utils/processing-overlay.ts +12 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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"
|
package/src/constants.ts
ADDED
|
@@ -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({
|
|
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
|
-
|
|
28
|
-
|
|
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
|
+
}
|
package/src/utils/next-errors.ts
CHANGED
|
@@ -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") ||
|
|
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
|
}
|