@easypayment/medusa-paypal-ui 1.2.8 → 1.3.1

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,52 +1,64 @@
1
1
  "use client"
2
2
 
3
+ /* eslint-disable react-hooks/set-state-in-effect --
4
+ This hook mirrors an external module-level cache into React state inside
5
+ its fetch effect (cache seed, enabled gate, fetch results). Each sync set
6
+ runs at most once per key change, not per render, so no render cascade. */
7
+
3
8
  import { useEffect, useMemo, useRef, useState } from "react"
4
9
  import { createPayPalStoreApi } from "../client/paypal"
10
+ import {
11
+ configCacheKey,
12
+ fetchPayPalConfigShared,
13
+ getCachedPayPalConfig,
14
+ } from "../client/config-cache"
5
15
  import type { PayPalConfig } from "../client/types"
6
16
 
7
17
  type Args = {
8
18
  baseUrl: string
9
19
  publishableApiKey?: string
10
20
  cartId?: string
21
+ /**
22
+ * The cart's current currency code (e.g. "usd"). Optional, but recommended:
23
+ * when provided it becomes part of the cache key, so a region/currency
24
+ * switch on the same cart refetches the config instead of serving a stale
25
+ * one for up to 5 minutes.
26
+ */
27
+ currencyCode?: string
28
+ credentials?: RequestCredentials
11
29
  enabled?: boolean
12
30
  }
13
31
 
14
- const MAX_CACHE_ENTRIES = 50
15
- const _cache = new Map<string, { config: PayPalConfig; at: number }>()
16
- const CACHE_TTL = 5 * 60 * 1000
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
-
26
- function cacheKey(baseUrl: string, cartId?: string) {
27
- return `${baseUrl}::${cartId ?? ""}`
28
- }
29
-
30
32
  export function usePayPalConfig({
31
33
  baseUrl,
32
34
  publishableApiKey,
33
35
  cartId,
36
+ currencyCode,
37
+ credentials,
34
38
  enabled = true,
35
39
  }: Args) {
36
40
  const api = useMemo(
37
- () => createPayPalStoreApi({ baseUrl, publishableApiKey }),
38
- [baseUrl, publishableApiKey]
41
+ () => createPayPalStoreApi({ baseUrl, publishableApiKey, credentials }),
42
+ [baseUrl, publishableApiKey, credentials]
39
43
  )
40
44
 
41
- const key = cacheKey(baseUrl, cartId)
42
- const hit = _cache.get(key)
43
- const seedConfig = hit && Date.now() - hit.at < CACHE_TTL ? hit.config : null
45
+ const key = configCacheKey({
46
+ baseUrl,
47
+ publishableApiKey,
48
+ cartId,
49
+ currencyCode,
50
+ })
51
+ const seedConfig = getCachedPayPalConfig(key)
44
52
 
45
53
  const [config, setConfig] = useState<PayPalConfig | null>(seedConfig)
46
54
  const [loading, setLoading] = useState<boolean>(
47
55
  seedConfig === null && enabled
48
56
  )
49
57
  const [error, setError] = useState<string | null>(null)
58
+ // Bumping this re-runs the effect after an error, bypassing nothing (the
59
+ // failed fetch was never cached) — gives consumers a retry() without
60
+ // tearing down the hook.
61
+ const [retryToken, setRetryToken] = useState(0)
50
62
 
51
63
  const fetchIdRef = useRef(0)
52
64
 
@@ -57,10 +69,9 @@ export function usePayPalConfig({
57
69
  return
58
70
  }
59
71
 
60
- const k = cacheKey(baseUrl, cartId)
61
- const cached = _cache.get(k)
62
- if (cached && Date.now() - cached.at < CACHE_TTL) {
63
- setConfig(cached.config)
72
+ const cached = getCachedPayPalConfig(key)
73
+ if (cached) {
74
+ setConfig(cached)
64
75
  setLoading(false)
65
76
  setError(null)
66
77
  return
@@ -68,18 +79,17 @@ export function usePayPalConfig({
68
79
 
69
80
  const id = ++fetchIdRef.current
70
81
  let mounted = true
71
- const controller = new AbortController()
72
82
 
73
83
  setLoading(true)
74
84
  setError(null)
75
85
  ;(async () => {
76
86
  try {
77
- const cfg = await api.getConfig(cartId, controller.signal)
87
+ const cfg = await fetchPayPalConfigShared(key, () =>
88
+ api.getConfig(cartId)
89
+ )
78
90
  if (!mounted || id !== fetchIdRef.current) return
79
- cacheSet(k, { config: cfg, at: Date.now() })
80
91
  setConfig(cfg)
81
92
  } catch (e: unknown) {
82
- if (e instanceof Error && e.name === "AbortError") return
83
93
  if (!mounted || id !== fetchIdRef.current) return
84
94
  setError(
85
95
  e instanceof Error ? e.message : "Failed to load PayPal config"
@@ -91,9 +101,10 @@ export function usePayPalConfig({
91
101
 
92
102
  return () => {
93
103
  mounted = false
94
- controller.abort()
95
104
  }
96
- }, [api, baseUrl, cartId, enabled])
105
+ }, [api, key, cartId, enabled, retryToken])
106
+
107
+ const retry = useMemo(() => () => setRetryToken((t) => t + 1), [])
97
108
 
98
- return { config, loading, error }
109
+ return { config, loading, error, retry }
99
110
  }
@@ -1,12 +1,27 @@
1
1
  "use client"
2
2
 
3
+ /* eslint-disable react-hooks/set-state-in-effect --
4
+ This hook mirrors an external module-level cache into React state inside
5
+ its fetch effect (cache seed, enabled gate, fetch results). Each sync set
6
+ runs at most once per key change, not per render, so no render cascade. */
7
+
3
8
  import { useEffect, useMemo, useRef, useState } from "react"
4
9
  import { createPayPalStoreApi } from "../client/paypal"
10
+ import { HttpError } from "../client/http"
11
+ import {
12
+ configCacheKey,
13
+ fetchPayPalConfigShared,
14
+ getCachedPayPalConfig,
15
+ } from "../client/config-cache"
16
+ import type { PayPalConfig } from "../client/types"
5
17
 
6
18
  type Args = {
7
19
  baseUrl: string
8
20
  publishableApiKey?: string
9
21
  cartId?: string
22
+ /** See `usePayPalConfig` — include to bust the cache on currency switches. */
23
+ currencyCode?: string
24
+ credentials?: RequestCredentials
10
25
  enabled?: boolean
11
26
  }
12
27
 
@@ -25,22 +40,6 @@ type Result = {
25
40
  error: string | null
26
41
  }
27
42
 
28
- const MAX_CACHE_ENTRIES = 50
29
- const _cache = new Map<string, { result: Result; at: number }>()
30
- const CACHE_TTL = 5 * 60 * 1000
31
-
32
- function cacheSet(key: string, value: { result: Result; at: number }) {
33
- if (_cache.size >= MAX_CACHE_ENTRIES) {
34
- const oldest = _cache.keys().next().value
35
- if (oldest !== undefined) _cache.delete(oldest)
36
- }
37
- _cache.set(key, value)
38
- }
39
-
40
- function cacheKey(baseUrl: string, cartId?: string) {
41
- return `ppm::${baseUrl}::${cartId ?? ""}`
42
- }
43
-
44
43
  const DEFAULT_RESULT: Result = {
45
44
  paypalEnabled: true,
46
45
  paypalTitle: "PayPal",
@@ -50,23 +49,46 @@ const DEFAULT_RESULT: Result = {
50
49
  error: null,
51
50
  }
52
51
 
52
+ function toResult(cfg: PayPalConfig): Result {
53
+ return {
54
+ paypalEnabled: cfg.paypal_enabled !== false,
55
+ paypalTitle:
56
+ typeof cfg.paypal_title === "string" && cfg.paypal_title
57
+ ? cfg.paypal_title
58
+ : "PayPal",
59
+ cardEnabled: cfg.card_enabled !== false,
60
+ cardTitle:
61
+ typeof cfg.card_title === "string" && cfg.card_title
62
+ ? cfg.card_title
63
+ : "Credit or Debit Card",
64
+ loading: false,
65
+ error: null,
66
+ }
67
+ }
68
+
53
69
  export function usePayPalPaymentMethods({
54
70
  baseUrl,
55
71
  publishableApiKey,
56
72
  cartId,
73
+ currencyCode,
74
+ credentials,
57
75
  enabled = true,
58
76
  }: Args): Result {
59
77
  const api = useMemo(
60
- () => createPayPalStoreApi({ baseUrl, publishableApiKey }),
61
- [baseUrl, publishableApiKey]
78
+ () => createPayPalStoreApi({ baseUrl, publishableApiKey, credentials }),
79
+ [baseUrl, publishableApiKey, credentials]
62
80
  )
63
81
 
64
- const key = cacheKey(baseUrl, cartId)
65
- const hit = _cache.get(key)
66
- const seed = hit && Date.now() - hit.at < CACHE_TTL ? hit.result : null
82
+ const key = configCacheKey({
83
+ baseUrl,
84
+ publishableApiKey,
85
+ cartId,
86
+ currencyCode,
87
+ })
88
+ const seed = getCachedPayPalConfig(key)
67
89
 
68
90
  const [result, setResult] = useState<Result>(
69
- seed ?? { ...DEFAULT_RESULT, loading: enabled }
91
+ seed ? toResult(seed) : { ...DEFAULT_RESULT, loading: enabled }
70
92
  )
71
93
  const fetchIdRef = useRef(0)
72
94
 
@@ -76,54 +98,38 @@ export function usePayPalPaymentMethods({
76
98
  return
77
99
  }
78
100
 
79
- const k = cacheKey(baseUrl, cartId)
80
- const cached = _cache.get(k)
81
- if (cached && Date.now() - cached.at < CACHE_TTL) {
82
- setResult(cached.result)
101
+ const cached = getCachedPayPalConfig(key)
102
+ if (cached) {
103
+ setResult(toResult(cached))
83
104
  return
84
105
  }
85
106
 
86
107
  const id = ++fetchIdRef.current
87
108
  let mounted = true
88
- const controller = new AbortController()
89
109
 
90
110
  setResult((prev) => ({ ...prev, loading: true }))
91
111
  ;(async () => {
92
112
  try {
93
- const cfg = await api.getConfig(cartId, controller.signal)
113
+ const cfg = await fetchPayPalConfigShared(key, () =>
114
+ api.getConfig(cartId)
115
+ )
94
116
  if (!mounted || id !== fetchIdRef.current) return
95
-
96
- const next: Result = {
97
- paypalEnabled: cfg.paypal_enabled !== false,
98
- paypalTitle:
99
- typeof cfg.paypal_title === "string" && cfg.paypal_title
100
- ? cfg.paypal_title
101
- : "PayPal",
102
- cardEnabled: cfg.card_enabled !== false,
103
- cardTitle:
104
- typeof cfg.card_title === "string" && cfg.card_title
105
- ? cfg.card_title
106
- : "Credit or Debit Card",
107
- loading: false,
108
- error: null,
109
- }
110
-
111
- cacheSet(k, { result: next, at: Date.now() })
112
- setResult(next)
117
+ setResult(toResult(cfg))
113
118
  } catch (e: unknown) {
114
- const msg = e instanceof Error ? e.message : ""
115
- if (e instanceof Error && e.name === "AbortError") return
116
119
  if (!mounted || id !== fetchIdRef.current) return
120
+ const msg = e instanceof Error ? e.message : ""
117
121
 
118
- if (msg.includes("403") || msg.includes("Forbidden")) {
119
- const disabled: Result = {
122
+ // 403 means the backend explicitly refused (plugin disabled for this
123
+ // sales channel / key). Branch on the status code, not message text —
124
+ // the backend's JSON message rarely contains the literal "403".
125
+ if (e instanceof HttpError && e.status === 403) {
126
+ setResult({
120
127
  ...DEFAULT_RESULT,
121
128
  paypalEnabled: false,
122
129
  cardEnabled: false,
123
130
  loading: false,
124
131
  error: msg || "PayPal is disabled",
125
- }
126
- setResult(disabled)
132
+ })
127
133
  return
128
134
  }
129
135
 
@@ -137,9 +143,8 @@ export function usePayPalPaymentMethods({
137
143
 
138
144
  return () => {
139
145
  mounted = false
140
- controller.abort()
141
146
  }
142
- }, [api, baseUrl, cartId, enabled])
147
+ }, [api, key, cartId, enabled])
143
148
 
144
149
  return result
145
150
  }
package/src/index.ts CHANGED
@@ -1,5 +1,9 @@
1
1
  export * from "./client/types"
2
2
  export * from "./client/paypal"
3
+ export { createHttpClient, HttpError } from "./client/http"
4
+ export type { HttpOptions, RequestOptions } from "./client/http"
5
+ export { invalidatePayPalConfigCache } from "./client/config-cache"
6
+ export * from "./constants"
3
7
  export * from "./hooks/usePayPalConfig"
4
8
  export * from "./components/PayPalProvider"
5
9
  export * from "./components/PayPalCurrencyNotice"
package/src/order.ts CHANGED
@@ -27,14 +27,18 @@
27
27
  import {
28
28
  PAYPAL_WALLET_PROVIDER_ID,
29
29
  PAYPAL_CARD_PROVIDER_ID,
30
+ PAYPAL_PROVIDER_IDS,
30
31
  isPayPalProviderId,
31
32
  } from "./constants"
33
+ import type { PayPalProviderId } from "./constants"
32
34
 
33
35
  export {
34
36
  PAYPAL_WALLET_PROVIDER_ID,
35
37
  PAYPAL_CARD_PROVIDER_ID,
38
+ PAYPAL_PROVIDER_IDS,
36
39
  isPayPalProviderId,
37
40
  }
41
+ export type { PayPalProviderId }
38
42
 
39
43
  export type PayPalPaymentInfo = { title: string }
40
44
 
@@ -102,13 +106,16 @@ export async function fetchPayPalConfig(opts: {
102
106
  return {}
103
107
  }
104
108
  try {
109
+ // Always arm the timeout — a caller-supplied signal composes with it
110
+ // rather than replacing it (matching the client-side HTTP client), so a
111
+ // signal that never fires can't leave the request hanging forever.
105
112
  const timeoutMs = 30_000
106
- let effectiveSignal = signal
107
- let timeoutId: ReturnType<typeof setTimeout> | undefined
108
- if (!signal) {
109
- const controller = new AbortController()
110
- timeoutId = setTimeout(() => controller.abort(), timeoutMs)
111
- effectiveSignal = controller.signal
113
+ const controller = new AbortController()
114
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs)
115
+ const onCallerAbort = () => controller.abort()
116
+ if (signal) {
117
+ if (signal.aborted) controller.abort()
118
+ else signal.addEventListener("abort", onCallerAbort)
112
119
  }
113
120
  let res: Response
114
121
  try {
@@ -119,10 +126,11 @@ export async function fetchPayPalConfig(opts: {
119
126
  ? { "x-publishable-api-key": publishableApiKey }
120
127
  : {}),
121
128
  },
122
- signal: effectiveSignal,
129
+ signal: controller.signal,
123
130
  })
124
131
  } finally {
125
- if (timeoutId !== undefined) clearTimeout(timeoutId)
132
+ clearTimeout(timeoutId)
133
+ if (signal) signal.removeEventListener("abort", onCallerAbort)
126
134
  }
127
135
  if (!res.ok) {
128
136
  return {}
@@ -2,23 +2,35 @@ const OVERLAY_ID = "__pp_processing_overlay"
2
2
 
3
3
  let cleanupNavListener: (() => void) | null = null
4
4
 
5
+ /** True while the shared DOM overlay is mounted. */
6
+ export function isProcessingOverlayVisible(): boolean {
7
+ if (typeof document === "undefined") return false
8
+ return !!document.getElementById(OVERLAY_ID)
9
+ }
10
+
5
11
  export function showProcessingOverlay() {
6
12
  if (typeof document === "undefined") return
7
13
  if (document.getElementById(OVERLAY_ID)) return
8
14
 
9
15
  const overlay = document.createElement("div")
10
16
  overlay.id = OVERLAY_ID
17
+ // role="status" announces the visible text politely; no aria-label so the
18
+ // text itself (which explains what's happening) is what gets read.
11
19
  overlay.setAttribute("role", "status")
12
- overlay.setAttribute("aria-label", "Processing payment")
13
20
 
14
21
  overlay.innerHTML = `
15
22
  <style>
16
- @keyframes _pp_spin { to { transform: rotate(360deg) } }
17
- @keyframes _pp_progress { 0% { transform: translateX(-100%) } 100% { transform: translateX(350%) } }
23
+ @keyframes _pp_ov_spin { to { transform: rotate(360deg) } }
24
+ @keyframes _pp_ov_progress { 0% { transform: translateX(-100%) } 100% { transform: translateX(350%) } }
25
+ ._pp-ov-spin { animation: _pp_ov_spin .8s linear infinite }
26
+ ._pp-ov-progress { animation: _pp_ov_progress 1.5s ease-in-out infinite }
27
+ @media (prefers-reduced-motion: reduce) {
28
+ ._pp-ov-spin, ._pp-ov-progress { animation: none }
29
+ }
18
30
  </style>
19
- <div style="position:relative;width:56px;height:56px">
31
+ <div style="position:relative;width:56px;height:56px" aria-hidden="true">
20
32
  <div style="position:absolute;inset:0;border-radius:50%;border:3px solid #e5e7eb"></div>
21
- <div style="position:absolute;inset:0;border-radius:50%;border:3px solid transparent;border-top-color:#0070ba;animation:_pp_spin .8s linear infinite"></div>
33
+ <div class="_pp-ov-spin" style="position:absolute;inset:0;border-radius:50%;border:3px solid transparent;border-top-color:#0070ba"></div>
22
34
  <svg viewBox="0 0 24 24" fill="none" stroke="#0070ba" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"
23
35
  style="position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);width:24px;height:24px">
24
36
  <path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
@@ -30,8 +42,8 @@ export function showProcessingOverlay() {
30
42
  This may take a few moments. Please do not close<br>or refresh this page.
31
43
  </div>
32
44
  </div>
33
- <div style="width:200px;height:3px;background:#e5e7eb;border-radius:3px;overflow:hidden;margin-top:4px">
34
- <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>
45
+ <div style="width:200px;height:3px;background:#e5e7eb;border-radius:3px;overflow:hidden;margin-top:4px" aria-hidden="true">
46
+ <div class="_pp-ov-progress" style="width:40%;height:100%;background:linear-gradient(90deg,#0070ba,#003087);border-radius:3px"></div>
35
47
  </div>
36
48
  `
37
49
 
@@ -51,23 +63,16 @@ export function showProcessingOverlay() {
51
63
  document.body.appendChild(overlay)
52
64
 
53
65
  // Auto-remove on client-side navigation (Next.js App Router uses
54
- // pushState/replaceState for soft navigations, not full reloads).
66
+ // pushState/replaceState for soft navigations, not full reloads). The URL is
67
+ // polled instead of monkeypatching history.pushState — patching fights with
68
+ // the Next router and any other library that wraps history, and a stale
69
+ // restore on cleanup could undo THEIR patch.
55
70
  const startUrl = location.href
56
71
  const onNav = () => {
57
72
  if (location.href !== startUrl) hideProcessingOverlay()
58
73
  }
59
74
  window.addEventListener("popstate", onNav)
60
-
61
- const origPush = history.pushState.bind(history)
62
- const origReplace = history.replaceState.bind(history)
63
- history.pushState = function (...args) {
64
- origPush(...args)
65
- onNav()
66
- }
67
- history.replaceState = function (...args) {
68
- origReplace(...args)
69
- onNav()
70
- }
75
+ const urlWatcher = window.setInterval(onNav, 250)
71
76
 
72
77
  // bfcache restore: the overlay intentionally survives the unload of a
73
78
  // full-page navigation (that's its purpose), but when the buyer presses
@@ -83,8 +88,7 @@ export function showProcessingOverlay() {
83
88
  cleanupNavListener = () => {
84
89
  window.removeEventListener("popstate", onNav)
85
90
  window.removeEventListener("pageshow", onPageShow)
86
- history.pushState = origPush
87
- history.replaceState = origReplace
91
+ window.clearInterval(urlWatcher)
88
92
  }
89
93
  }
90
94