@easypayment/medusa-paypal-ui 1.0.49 → 1.0.50

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.
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Server-safe helpers for displaying PayPal payments on order / confirmation
3
+ * pages.
4
+ *
5
+ * This module is intentionally free of React and any client-only code, so it
6
+ * can be imported from Next.js **server components** (e.g. the order
7
+ * confirmation page) without pulling in the checkout UI.
8
+ *
9
+ * @example
10
+ * import { getPaymentLabel, fetchPayPalConfig } from "@easypayment/medusa-paypal-ui/order"
11
+ *
12
+ * // In the (async) server component that renders the payment method:
13
+ * const titles = await fetchPayPalConfig({
14
+ * baseUrl: process.env.NEXT_PUBLIC_MEDUSA_BACKEND_URL!,
15
+ * publishableApiKey: process.env.NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY,
16
+ * })
17
+ *
18
+ * <Text>
19
+ * {getPaymentLabel(
20
+ * payment.provider_id,
21
+ * paymentInfoMap[payment.provider_id]?.title,
22
+ * titles
23
+ * )}
24
+ * </Text>
25
+ */
26
+ declare const PAYPAL_WALLET_PROVIDER_ID = "pp_paypal_paypal";
27
+ declare const PAYPAL_CARD_PROVIDER_ID = "pp_paypal_card_paypal_card";
28
+ type PayPalPaymentInfo = {
29
+ title: string;
30
+ };
31
+ /** Built-in fallback titles, used only when admin titles aren't supplied. */
32
+ declare const paypalPaymentInfoMap: Record<string, PayPalPaymentInfo>;
33
+ /** Admin-configured titles, as returned by `GET /store/paypal/config`. */
34
+ type PayPalConfigTitles = {
35
+ paypal_title?: string | null;
36
+ card_title?: string | null;
37
+ };
38
+ /** True when the provider id belongs to this PayPal plugin. */
39
+ declare function isPayPalProviderId(providerId?: string | null): boolean;
40
+ /**
41
+ * Null-safe payment label resolver for order / confirmation pages.
42
+ *
43
+ * Precedence for the PayPal providers: **admin-configured title** (`titles`,
44
+ * from {@link fetchPayPalConfig}) → built-in default. For any other provider:
45
+ * `fallback` → raw id. Never throws on an unknown / missing provider, which is
46
+ * what causes the common `Cannot read properties of undefined (reading 'title')`
47
+ * crash on the order confirmation page.
48
+ *
49
+ * @param titles Admin titles so the order page shows the same labels configured
50
+ * in Medusa Admin → Settings → PayPal (e.g. a custom "Credit or Debit Card").
51
+ */
52
+ declare function getPaymentLabel(providerId: string | undefined | null, fallback?: string, titles?: PayPalConfigTitles): string;
53
+ /**
54
+ * Fetch the admin-configured PayPal titles from the storefront API
55
+ * (`GET /store/paypal/config`). Server-safe (uses the global `fetch`).
56
+ *
57
+ * Returns `{}` on any error/network failure so callers can fall back to the
58
+ * built-in defaults without their own try/catch.
59
+ */
60
+ declare function fetchPayPalConfig(opts: {
61
+ baseUrl: string;
62
+ publishableApiKey?: string;
63
+ signal?: AbortSignal;
64
+ }): Promise<PayPalConfigTitles>;
65
+
66
+ export { PAYPAL_CARD_PROVIDER_ID, PAYPAL_WALLET_PROVIDER_ID, type PayPalConfigTitles, type PayPalPaymentInfo, fetchPayPalConfig, getPaymentLabel, isPayPalProviderId, paypalPaymentInfoMap };
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Server-safe helpers for displaying PayPal payments on order / confirmation
3
+ * pages.
4
+ *
5
+ * This module is intentionally free of React and any client-only code, so it
6
+ * can be imported from Next.js **server components** (e.g. the order
7
+ * confirmation page) without pulling in the checkout UI.
8
+ *
9
+ * @example
10
+ * import { getPaymentLabel, fetchPayPalConfig } from "@easypayment/medusa-paypal-ui/order"
11
+ *
12
+ * // In the (async) server component that renders the payment method:
13
+ * const titles = await fetchPayPalConfig({
14
+ * baseUrl: process.env.NEXT_PUBLIC_MEDUSA_BACKEND_URL!,
15
+ * publishableApiKey: process.env.NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY,
16
+ * })
17
+ *
18
+ * <Text>
19
+ * {getPaymentLabel(
20
+ * payment.provider_id,
21
+ * paymentInfoMap[payment.provider_id]?.title,
22
+ * titles
23
+ * )}
24
+ * </Text>
25
+ */
26
+ declare const PAYPAL_WALLET_PROVIDER_ID = "pp_paypal_paypal";
27
+ declare const PAYPAL_CARD_PROVIDER_ID = "pp_paypal_card_paypal_card";
28
+ type PayPalPaymentInfo = {
29
+ title: string;
30
+ };
31
+ /** Built-in fallback titles, used only when admin titles aren't supplied. */
32
+ declare const paypalPaymentInfoMap: Record<string, PayPalPaymentInfo>;
33
+ /** Admin-configured titles, as returned by `GET /store/paypal/config`. */
34
+ type PayPalConfigTitles = {
35
+ paypal_title?: string | null;
36
+ card_title?: string | null;
37
+ };
38
+ /** True when the provider id belongs to this PayPal plugin. */
39
+ declare function isPayPalProviderId(providerId?: string | null): boolean;
40
+ /**
41
+ * Null-safe payment label resolver for order / confirmation pages.
42
+ *
43
+ * Precedence for the PayPal providers: **admin-configured title** (`titles`,
44
+ * from {@link fetchPayPalConfig}) → built-in default. For any other provider:
45
+ * `fallback` → raw id. Never throws on an unknown / missing provider, which is
46
+ * what causes the common `Cannot read properties of undefined (reading 'title')`
47
+ * crash on the order confirmation page.
48
+ *
49
+ * @param titles Admin titles so the order page shows the same labels configured
50
+ * in Medusa Admin → Settings → PayPal (e.g. a custom "Credit or Debit Card").
51
+ */
52
+ declare function getPaymentLabel(providerId: string | undefined | null, fallback?: string, titles?: PayPalConfigTitles): string;
53
+ /**
54
+ * Fetch the admin-configured PayPal titles from the storefront API
55
+ * (`GET /store/paypal/config`). Server-safe (uses the global `fetch`).
56
+ *
57
+ * Returns `{}` on any error/network failure so callers can fall back to the
58
+ * built-in defaults without their own try/catch.
59
+ */
60
+ declare function fetchPayPalConfig(opts: {
61
+ baseUrl: string;
62
+ publishableApiKey?: string;
63
+ signal?: AbortSignal;
64
+ }): Promise<PayPalConfigTitles>;
65
+
66
+ export { PAYPAL_CARD_PROVIDER_ID, PAYPAL_WALLET_PROVIDER_ID, type PayPalConfigTitles, type PayPalPaymentInfo, fetchPayPalConfig, getPaymentLabel, isPayPalProviderId, paypalPaymentInfoMap };
package/dist/order.mjs ADDED
@@ -0,0 +1,56 @@
1
+ // src/order.ts
2
+ var PAYPAL_WALLET_PROVIDER_ID = "pp_paypal_paypal";
3
+ var PAYPAL_CARD_PROVIDER_ID = "pp_paypal_card_paypal_card";
4
+ var paypalPaymentInfoMap = {
5
+ [PAYPAL_WALLET_PROVIDER_ID]: { title: "PayPal" },
6
+ [PAYPAL_CARD_PROVIDER_ID]: { title: "Credit or Debit Card" }
7
+ };
8
+ function isPayPalProviderId(providerId) {
9
+ return providerId === PAYPAL_WALLET_PROVIDER_ID || providerId === PAYPAL_CARD_PROVIDER_ID;
10
+ }
11
+ function getPaymentLabel(providerId, fallback, titles) {
12
+ if (!providerId) {
13
+ return fallback ?? "";
14
+ }
15
+ if (providerId === PAYPAL_WALLET_PROVIDER_ID) {
16
+ return titles?.paypal_title?.trim() || paypalPaymentInfoMap[PAYPAL_WALLET_PROVIDER_ID].title;
17
+ }
18
+ if (providerId === PAYPAL_CARD_PROVIDER_ID) {
19
+ return titles?.card_title?.trim() || paypalPaymentInfoMap[PAYPAL_CARD_PROVIDER_ID].title;
20
+ }
21
+ return fallback ?? providerId;
22
+ }
23
+ async function fetchPayPalConfig(opts) {
24
+ const { baseUrl, publishableApiKey, signal } = opts;
25
+ if (!baseUrl) {
26
+ return {};
27
+ }
28
+ try {
29
+ const res = await fetch(`${baseUrl.replace(/\/$/, "")}/store/paypal/config`, {
30
+ headers: {
31
+ accept: "application/json",
32
+ ...publishableApiKey ? { "x-publishable-api-key": publishableApiKey } : {}
33
+ },
34
+ signal
35
+ });
36
+ if (!res.ok) {
37
+ return {};
38
+ }
39
+ const cfg = await res.json();
40
+ return {
41
+ paypal_title: typeof cfg.paypal_title === "string" ? cfg.paypal_title : void 0,
42
+ card_title: typeof cfg.card_title === "string" ? cfg.card_title : void 0
43
+ };
44
+ } catch {
45
+ return {};
46
+ }
47
+ }
48
+ export {
49
+ PAYPAL_CARD_PROVIDER_ID,
50
+ PAYPAL_WALLET_PROVIDER_ID,
51
+ fetchPayPalConfig,
52
+ getPaymentLabel,
53
+ isPayPalProviderId,
54
+ paypalPaymentInfoMap
55
+ };
56
+ //# sourceMappingURL=order.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/order.ts"],"sourcesContent":["/**\r\n * Server-safe helpers for displaying PayPal payments on order / confirmation\r\n * pages.\r\n *\r\n * This module is intentionally free of React and any client-only code, so it\r\n * can be imported from Next.js **server components** (e.g. the order\r\n * confirmation page) without pulling in the checkout UI.\r\n *\r\n * @example\r\n * import { getPaymentLabel, fetchPayPalConfig } from \"@easypayment/medusa-paypal-ui/order\"\r\n *\r\n * // In the (async) server component that renders the payment method:\r\n * const titles = await fetchPayPalConfig({\r\n * baseUrl: process.env.NEXT_PUBLIC_MEDUSA_BACKEND_URL!,\r\n * publishableApiKey: process.env.NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY,\r\n * })\r\n *\r\n * <Text>\r\n * {getPaymentLabel(\r\n * payment.provider_id,\r\n * paymentInfoMap[payment.provider_id]?.title,\r\n * titles\r\n * )}\r\n * </Text>\r\n */\r\n\r\nexport const PAYPAL_WALLET_PROVIDER_ID = \"pp_paypal_paypal\"\r\nexport const PAYPAL_CARD_PROVIDER_ID = \"pp_paypal_card_paypal_card\"\r\n\r\nexport type PayPalPaymentInfo = { title: string }\r\n\r\n/** Built-in fallback titles, used only when admin titles aren't supplied. */\r\nexport const paypalPaymentInfoMap: Record<string, PayPalPaymentInfo> = {\r\n [PAYPAL_WALLET_PROVIDER_ID]: { title: \"PayPal\" },\r\n [PAYPAL_CARD_PROVIDER_ID]: { title: \"Credit or Debit Card\" },\r\n}\r\n\r\n/** Admin-configured titles, as returned by `GET /store/paypal/config`. */\r\nexport type PayPalConfigTitles = {\r\n paypal_title?: string | null\r\n card_title?: string | null\r\n}\r\n\r\n/** True when the provider id belongs to this PayPal plugin. */\r\nexport function isPayPalProviderId(providerId?: string | null): boolean {\r\n return (\r\n providerId === PAYPAL_WALLET_PROVIDER_ID ||\r\n providerId === PAYPAL_CARD_PROVIDER_ID\r\n )\r\n}\r\n\r\n/**\r\n * Null-safe payment label resolver for order / confirmation pages.\r\n *\r\n * Precedence for the PayPal providers: **admin-configured title** (`titles`,\r\n * from {@link fetchPayPalConfig}) → built-in default. For any other provider:\r\n * `fallback` → raw id. Never throws on an unknown / missing provider, which is\r\n * what causes the common `Cannot read properties of undefined (reading 'title')`\r\n * crash on the order confirmation page.\r\n *\r\n * @param titles Admin titles so the order page shows the same labels configured\r\n * in Medusa Admin → Settings → PayPal (e.g. a custom \"Credit or Debit Card\").\r\n */\r\nexport function getPaymentLabel(\r\n providerId: string | undefined | null,\r\n fallback?: string,\r\n titles?: PayPalConfigTitles\r\n): string {\r\n if (!providerId) {\r\n return fallback ?? \"\"\r\n }\r\n if (providerId === PAYPAL_WALLET_PROVIDER_ID) {\r\n return (\r\n titles?.paypal_title?.trim() ||\r\n paypalPaymentInfoMap[PAYPAL_WALLET_PROVIDER_ID].title\r\n )\r\n }\r\n if (providerId === PAYPAL_CARD_PROVIDER_ID) {\r\n return (\r\n titles?.card_title?.trim() ||\r\n paypalPaymentInfoMap[PAYPAL_CARD_PROVIDER_ID].title\r\n )\r\n }\r\n return fallback ?? providerId\r\n}\r\n\r\n/**\r\n * Fetch the admin-configured PayPal titles from the storefront API\r\n * (`GET /store/paypal/config`). Server-safe (uses the global `fetch`).\r\n *\r\n * Returns `{}` on any error/network failure so callers can fall back to the\r\n * built-in defaults without their own try/catch.\r\n */\r\nexport async function fetchPayPalConfig(opts: {\r\n baseUrl: string\r\n publishableApiKey?: string\r\n signal?: AbortSignal\r\n}): Promise<PayPalConfigTitles> {\r\n const { baseUrl, publishableApiKey, signal } = opts\r\n if (!baseUrl) {\r\n return {}\r\n }\r\n try {\r\n const res = await fetch(`${baseUrl.replace(/\\/$/, \"\")}/store/paypal/config`, {\r\n headers: {\r\n accept: \"application/json\",\r\n ...(publishableApiKey ? { \"x-publishable-api-key\": publishableApiKey } : {}),\r\n },\r\n signal,\r\n })\r\n if (!res.ok) {\r\n return {}\r\n }\r\n const cfg = (await res.json()) as Record<string, unknown>\r\n return {\r\n paypal_title:\r\n typeof cfg.paypal_title === \"string\" ? cfg.paypal_title : undefined,\r\n card_title:\r\n typeof cfg.card_title === \"string\" ? cfg.card_title : undefined,\r\n }\r\n } catch {\r\n return {}\r\n }\r\n}\r\n"],"mappings":";AA0BO,IAAM,4BAA4B;AAClC,IAAM,0BAA0B;AAKhC,IAAM,uBAA0D;AAAA,EACrE,CAAC,yBAAyB,GAAG,EAAE,OAAO,SAAS;AAAA,EAC/C,CAAC,uBAAuB,GAAG,EAAE,OAAO,uBAAuB;AAC7D;AASO,SAAS,mBAAmB,YAAqC;AACtE,SACE,eAAe,6BACf,eAAe;AAEnB;AAcO,SAAS,gBACd,YACA,UACA,QACQ;AACR,MAAI,CAAC,YAAY;AACf,WAAO,YAAY;AAAA,EACrB;AACA,MAAI,eAAe,2BAA2B;AAC5C,WACE,QAAQ,cAAc,KAAK,KAC3B,qBAAqB,yBAAyB,EAAE;AAAA,EAEpD;AACA,MAAI,eAAe,yBAAyB;AAC1C,WACE,QAAQ,YAAY,KAAK,KACzB,qBAAqB,uBAAuB,EAAE;AAAA,EAElD;AACA,SAAO,YAAY;AACrB;AASA,eAAsB,kBAAkB,MAIR;AAC9B,QAAM,EAAE,SAAS,mBAAmB,OAAO,IAAI;AAC/C,MAAI,CAAC,SAAS;AACZ,WAAO,CAAC;AAAA,EACV;AACA,MAAI;AACF,UAAM,MAAM,MAAM,MAAM,GAAG,QAAQ,QAAQ,OAAO,EAAE,CAAC,wBAAwB;AAAA,MAC3E,SAAS;AAAA,QACP,QAAQ;AAAA,QACR,GAAI,oBAAoB,EAAE,yBAAyB,kBAAkB,IAAI,CAAC;AAAA,MAC5E;AAAA,MACA;AAAA,IACF,CAAC;AACD,QAAI,CAAC,IAAI,IAAI;AACX,aAAO,CAAC;AAAA,IACV;AACA,UAAM,MAAO,MAAM,IAAI,KAAK;AAC5B,WAAO;AAAA,MACL,cACE,OAAO,IAAI,iBAAiB,WAAW,IAAI,eAAe;AAAA,MAC5D,YACE,OAAO,IAAI,eAAe,WAAW,IAAI,aAAa;AAAA,IAC1D;AAAA,EACF,QAAQ;AACN,WAAO,CAAC;AAAA,EACV;AACF;","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@easypayment/medusa-paypal-ui",
3
- "version": "1.0.49",
3
+ "version": "1.0.50",
4
4
  "description": "Enterprise Gold PayPal UI module for Medusa v2 storefront (Next.js)",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -20,6 +20,11 @@
20
20
  "import": "./dist/index.mjs",
21
21
  "require": "./dist/index.cjs"
22
22
  },
23
+ "./order": {
24
+ "types": "./dist/order.d.ts",
25
+ "import": "./dist/order.mjs",
26
+ "require": "./dist/order.cjs"
27
+ },
23
28
  "./package.json": "./package.json"
24
29
  },
25
30
  "peerDependencies": {
@@ -6,6 +6,7 @@ import { PayPalAdvancedCard } from "../components/PayPalAdvancedCard"
6
6
  import { PayPalProvider } from "../components/PayPalProvider"
7
7
  import { PayPalSmartButtons } from "../components/PayPalSmartButtons"
8
8
  import { usePayPalConfig } from "../hooks/usePayPalConfig"
9
+ import { isNextRedirectError } from "../client/paypal"
9
10
 
10
11
  const DEFAULT_PAYPAL_PROVIDER_ID = "pp_paypal_paypal"
11
12
  const DEFAULT_PAYPAL_CARD_PROVIDER_ID = "pp_paypal_card_paypal_card"
@@ -109,9 +110,19 @@ export function MedusaNextPayPalAdapter(props: MedusaNextPayPalAdapterProps) {
109
110
  const handlePaid = useCallback(
110
111
  (captureResult: unknown) => {
111
112
  onPaid?.(captureResult)
112
- onSuccess?.(cartId)
113
+ // onSuccess usually calls placeOrder(), which redirects on success. Run it
114
+ // without blocking the spinner, but surface real failures so a captured
115
+ // payment never ends silently with no order and no error shown.
116
+ Promise.resolve(onSuccess?.(cartId)).catch((e: unknown) => {
117
+ if (isNextRedirectError(e)) return
118
+ onError?.(
119
+ e instanceof Error
120
+ ? e.message
121
+ : "Your payment was taken but the order could not be finalized. Please contact support before paying again."
122
+ )
123
+ })
113
124
  },
114
- [cartId, onPaid, onSuccess]
125
+ [cartId, onPaid, onSuccess, onError]
115
126
  )
116
127
 
117
128
  if (!shouldRender) return null
@@ -19,6 +19,8 @@ function toHeaderRecord(headers?: RequestInit["headers"]): Record<string, string
19
19
  return { ...headers }
20
20
  }
21
21
 
22
+ const DEFAULT_TIMEOUT_MS = 30000
23
+
22
24
  export function createHttpClient(opts: HttpOptions) {
23
25
  const base = opts.baseUrl.replace(/\/+$/, "")
24
26
 
@@ -33,8 +35,36 @@ export function createHttpClient(opts: HttpOptions) {
33
35
  headers["x-publishable-api-key"] = opts.publishableApiKey
34
36
  }
35
37
 
36
- const res = await fetch(url, { ...init, headers, credentials: "include" })
37
- const text = await res.text().catch(() => "")
38
+ // Always enforce a timeout so a hung backend can't leave the checkout
39
+ // spinner up forever, while still honouring a caller-provided abort signal.
40
+ const controller = new AbortController()
41
+ let timedOut = false
42
+ const timer = setTimeout(() => {
43
+ timedOut = true
44
+ controller.abort()
45
+ }, DEFAULT_TIMEOUT_MS)
46
+ const callerSignal = init?.signal
47
+ if (callerSignal) {
48
+ if (callerSignal.aborted) {
49
+ controller.abort()
50
+ } else {
51
+ callerSignal.addEventListener("abort", () => controller.abort(), { once: true })
52
+ }
53
+ }
54
+
55
+ let res: Response
56
+ let text: string
57
+ try {
58
+ res = await fetch(url, { ...init, headers, credentials: "include", signal: controller.signal })
59
+ text = await res.text().catch(() => "")
60
+ } catch (e) {
61
+ if (timedOut) {
62
+ throw new Error("[PayPal] Request timed out. Please check your connection and try again.")
63
+ }
64
+ throw e
65
+ } finally {
66
+ clearTimeout(timer)
67
+ }
38
68
 
39
69
  if (!res.ok) {
40
70
  if (res.status === 401) {
@@ -47,9 +77,25 @@ export function createHttpClient(opts: HttpOptions) {
47
77
  "[PayPal] Forbidden (403) — this request is not allowed. Check your CORS and API key settings."
48
78
  )
49
79
  }
50
- throw new Error(
51
- text.slice(0, 500).replace(/<[^>]*>/g, "") || `Request failed (${res.status})`
52
- )
80
+ // Prefer the backend's clean { message, request_id } JSON. For 5xx, do not
81
+ // surface raw server internals to end users — log them and show a generic
82
+ // message (with a correlation id when available).
83
+ type ErrBody = { message?: unknown; request_id?: unknown }
84
+ let parsed: ErrBody | null = null
85
+ try {
86
+ parsed = text ? (JSON.parse(text) as ErrBody) : null
87
+ } catch {
88
+ parsed = null
89
+ }
90
+ const backendMessage = typeof parsed?.message === "string" ? parsed.message : ""
91
+ const requestId = typeof parsed?.request_id === "string" ? parsed.request_id : ""
92
+ if (res.status >= 500) {
93
+ console.error(`[PayPal] ${path} failed (${res.status})`, text.slice(0, 1000))
94
+ throw new Error(
95
+ `Payment service error${requestId ? ` (ref ${requestId})` : ""}. Please try again or contact support.`
96
+ )
97
+ }
98
+ throw new Error(backendMessage || `Request failed (${res.status})`)
53
99
  }
54
100
  if (!text) {
55
101
  throw new Error(`[PayPal] Empty response body from ${path} (${res.status})`)
@@ -1,11 +1,27 @@
1
1
  import type { PayPalConfig, PayPalSettingsResponse } from "./types"
2
2
  import { createHttpClient, type HttpOptions } from "./http"
3
3
 
4
+ /**
5
+ * True when an error is a Next.js navigation signal (`redirect()` / `notFound()`
6
+ * from a server action). These MUST be re-thrown / ignored, never shown as an
7
+ * error — a successful `placeOrder()` redirect surfaces as one of these.
8
+ */
9
+ export function isNextRedirectError(e: unknown): boolean {
10
+ if (!e || typeof e !== "object") {
11
+ return false
12
+ }
13
+ const digest = (e as { digest?: unknown }).digest
14
+ return (
15
+ typeof digest === "string" &&
16
+ (digest.startsWith("NEXT_REDIRECT") || digest === "NEXT_NOT_FOUND")
17
+ )
18
+ }
19
+
4
20
  export async function markPaymentComplete(
5
21
  baseUrl: string,
6
22
  cartId: string,
7
23
  publishableApiKey?: string
8
- ): Promise<Record<string, any>> {
24
+ ): Promise<{ ok: boolean } & Record<string, any>> {
9
25
  try {
10
26
  const resp = await fetch(`${baseUrl.replace(/\/$/, "")}/store/paypal-complete`, {
11
27
  method: "POST",
@@ -17,10 +33,14 @@ export async function markPaymentComplete(
17
33
  credentials: "include",
18
34
  })
19
35
  const data = await resp.json().catch(() => ({}))
20
- return data || {}
36
+ if (!resp.ok) {
37
+ console.warn("[PayPal] paypal-complete returned", resp.status, data)
38
+ return { ok: false, ...(data || {}) }
39
+ }
40
+ return { ok: true, ...(data || {}) }
21
41
  } catch (e) {
22
42
  console.warn("[PayPal] paypal-complete call failed:", e)
23
- return {}
43
+ return { ok: false }
24
44
  }
25
45
  }
26
46
 
@@ -62,10 +62,12 @@ function SubmitButton({
62
62
  disabled,
63
63
  label,
64
64
  onSubmit,
65
+ onSubmitError,
65
66
  }: {
66
67
  disabled: boolean
67
68
  label: string
68
69
  onSubmit: () => void
70
+ onSubmitError: (message: string) => void
69
71
  }) {
70
72
  const { cardFieldsForm } = usePayPalCardFields()
71
73
  const isDisabled = disabled || !cardFieldsForm
@@ -76,7 +78,17 @@ function SubmitButton({
76
78
  disabled={isDisabled}
77
79
  onClick={() => {
78
80
  onSubmit()
79
- cardFieldsForm?.submit()
81
+ // submit() can reject (network / tokenization / 3DS) WITHOUT firing the
82
+ // provider onError, which would otherwise leave the "Processing…"
83
+ // overlay stuck forever. Always catch it and surface the error.
84
+ const submitted = cardFieldsForm?.submit()
85
+ if (submitted && typeof submitted.catch === "function") {
86
+ submitted.catch((e: unknown) => {
87
+ onSubmitError(
88
+ e instanceof Error ? e.message : "Card payment failed. Please try again."
89
+ )
90
+ })
91
+ }
80
92
  }}
81
93
  style={{
82
94
  width: "100%",
@@ -321,6 +333,11 @@ export function PayPalAdvancedCard(props: {
321
333
  setError(null)
322
334
  setSubmitting(true)
323
335
  }}
336
+ onSubmitError={(msg) => {
337
+ setError(msg)
338
+ onError?.(msg)
339
+ setSubmitting(false)
340
+ }}
324
341
  />
325
342
 
326
343
  {error && (
@@ -6,6 +6,7 @@ import { PayPalAdvancedCard } from "./PayPalAdvancedCard"
6
6
  import { PayPalProvider } from "./PayPalProvider"
7
7
  import { PayPalSmartButtons } from "./PayPalSmartButtons"
8
8
  import { usePayPalConfig } from "../hooks/usePayPalConfig"
9
+ import { isNextRedirectError } from "../client/paypal"
9
10
 
10
11
  export const PAYPAL_WALLET_PROVIDER_ID = "pp_paypal_paypal" as const
11
12
  export const PAYPAL_CARD_PROVIDER_ID = "pp_paypal_card_paypal_card" as const
@@ -142,9 +143,19 @@ export function PayPalPaymentSection({
142
143
  const handlePaid = useCallback(
143
144
  (captureResult: unknown) => {
144
145
  onPaid?.(captureResult)
145
- onSuccess?.(cartId)
146
+ // onSuccess usually calls placeOrder(), which redirects on success. Run it
147
+ // without blocking the spinner, but surface real failures so a captured
148
+ // payment never ends silently with no order and no error shown.
149
+ Promise.resolve(onSuccess?.(cartId)).catch((e: unknown) => {
150
+ if (isNextRedirectError(e)) return
151
+ onError?.(
152
+ e instanceof Error
153
+ ? e.message
154
+ : "Your payment was taken but the order could not be finalized. Please contact support before paying again."
155
+ )
156
+ })
146
157
  },
147
- [cartId, onPaid, onSuccess]
158
+ [cartId, onPaid, onSuccess, onError]
148
159
  )
149
160
 
150
161
  if (!shouldRender) return null
package/src/order.ts ADDED
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Server-safe helpers for displaying PayPal payments on order / confirmation
3
+ * pages.
4
+ *
5
+ * This module is intentionally free of React and any client-only code, so it
6
+ * can be imported from Next.js **server components** (e.g. the order
7
+ * confirmation page) without pulling in the checkout UI.
8
+ *
9
+ * @example
10
+ * import { getPaymentLabel, fetchPayPalConfig } from "@easypayment/medusa-paypal-ui/order"
11
+ *
12
+ * // In the (async) server component that renders the payment method:
13
+ * const titles = await fetchPayPalConfig({
14
+ * baseUrl: process.env.NEXT_PUBLIC_MEDUSA_BACKEND_URL!,
15
+ * publishableApiKey: process.env.NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY,
16
+ * })
17
+ *
18
+ * <Text>
19
+ * {getPaymentLabel(
20
+ * payment.provider_id,
21
+ * paymentInfoMap[payment.provider_id]?.title,
22
+ * titles
23
+ * )}
24
+ * </Text>
25
+ */
26
+
27
+ export const PAYPAL_WALLET_PROVIDER_ID = "pp_paypal_paypal"
28
+ export const PAYPAL_CARD_PROVIDER_ID = "pp_paypal_card_paypal_card"
29
+
30
+ export type PayPalPaymentInfo = { title: string }
31
+
32
+ /** Built-in fallback titles, used only when admin titles aren't supplied. */
33
+ export const paypalPaymentInfoMap: Record<string, PayPalPaymentInfo> = {
34
+ [PAYPAL_WALLET_PROVIDER_ID]: { title: "PayPal" },
35
+ [PAYPAL_CARD_PROVIDER_ID]: { title: "Credit or Debit Card" },
36
+ }
37
+
38
+ /** Admin-configured titles, as returned by `GET /store/paypal/config`. */
39
+ export type PayPalConfigTitles = {
40
+ paypal_title?: string | null
41
+ card_title?: string | null
42
+ }
43
+
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
+ /**
53
+ * Null-safe payment label resolver for order / confirmation pages.
54
+ *
55
+ * Precedence for the PayPal providers: **admin-configured title** (`titles`,
56
+ * from {@link fetchPayPalConfig}) → built-in default. For any other provider:
57
+ * `fallback` → raw id. Never throws on an unknown / missing provider, which is
58
+ * what causes the common `Cannot read properties of undefined (reading 'title')`
59
+ * crash on the order confirmation page.
60
+ *
61
+ * @param titles Admin titles so the order page shows the same labels configured
62
+ * in Medusa Admin → Settings → PayPal (e.g. a custom "Credit or Debit Card").
63
+ */
64
+ export function getPaymentLabel(
65
+ providerId: string | undefined | null,
66
+ fallback?: string,
67
+ titles?: PayPalConfigTitles
68
+ ): string {
69
+ if (!providerId) {
70
+ return fallback ?? ""
71
+ }
72
+ if (providerId === PAYPAL_WALLET_PROVIDER_ID) {
73
+ return (
74
+ titles?.paypal_title?.trim() ||
75
+ paypalPaymentInfoMap[PAYPAL_WALLET_PROVIDER_ID].title
76
+ )
77
+ }
78
+ if (providerId === PAYPAL_CARD_PROVIDER_ID) {
79
+ return (
80
+ titles?.card_title?.trim() ||
81
+ paypalPaymentInfoMap[PAYPAL_CARD_PROVIDER_ID].title
82
+ )
83
+ }
84
+ return fallback ?? providerId
85
+ }
86
+
87
+ /**
88
+ * Fetch the admin-configured PayPal titles from the storefront API
89
+ * (`GET /store/paypal/config`). Server-safe (uses the global `fetch`).
90
+ *
91
+ * Returns `{}` on any error/network failure so callers can fall back to the
92
+ * built-in defaults without their own try/catch.
93
+ */
94
+ export async function fetchPayPalConfig(opts: {
95
+ baseUrl: string
96
+ publishableApiKey?: string
97
+ signal?: AbortSignal
98
+ }): Promise<PayPalConfigTitles> {
99
+ const { baseUrl, publishableApiKey, signal } = opts
100
+ if (!baseUrl) {
101
+ return {}
102
+ }
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
+ })
111
+ if (!res.ok) {
112
+ return {}
113
+ }
114
+ const cfg = (await res.json()) as Record<string, unknown>
115
+ return {
116
+ paypal_title:
117
+ typeof cfg.paypal_title === "string" ? cfg.paypal_title : undefined,
118
+ card_title:
119
+ typeof cfg.card_title === "string" ? cfg.card_title : undefined,
120
+ }
121
+ } catch {
122
+ return {}
123
+ }
124
+ }