@easypayment/medusa-paypal-ui 1.2.8 → 1.3.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 +1 @@
1
- {"version":3,"sources":["../src/order.ts","../src/constants.ts"],"sourcesContent":["/**\n * Server-safe helpers for displaying PayPal payments on order / confirmation\n * pages.\n *\n * This module is intentionally free of React and any client-only code, so it\n * can be imported from Next.js **server components** (e.g. the order\n * confirmation page) without pulling in the checkout UI.\n *\n * @example\n * import { getPaymentLabel, fetchPayPalConfig } from \"@easypayment/medusa-paypal-ui/order\"\n *\n * // In the (async) server component that renders the payment method:\n * const titles = await fetchPayPalConfig({\n * baseUrl: process.env.NEXT_PUBLIC_MEDUSA_BACKEND_URL!,\n * publishableApiKey: process.env.NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY,\n * })\n *\n * <Text>\n * {getPaymentLabel(\n * payment.provider_id,\n * paymentInfoMap[payment.provider_id]?.title,\n * titles\n * )}\n * </Text>\n */\n\nimport {\n PAYPAL_WALLET_PROVIDER_ID,\n PAYPAL_CARD_PROVIDER_ID,\n isPayPalProviderId,\n} from \"./constants\"\n\nexport {\n PAYPAL_WALLET_PROVIDER_ID,\n PAYPAL_CARD_PROVIDER_ID,\n isPayPalProviderId,\n}\n\nexport type PayPalPaymentInfo = { title: string }\n\n/** Built-in fallback titles, used only when admin titles aren't supplied. */\nexport const paypalPaymentInfoMap: Record<string, PayPalPaymentInfo> = {\n [PAYPAL_WALLET_PROVIDER_ID]: { title: \"PayPal\" },\n [PAYPAL_CARD_PROVIDER_ID]: { title: \"Credit or Debit Card\" },\n}\n\n/** Admin-configured titles, as returned by `GET /store/paypal/config`. */\nexport type PayPalConfigTitles = {\n paypal_title?: string | null\n card_title?: string | null\n}\n\n/**\n * Null-safe payment label resolver for order / confirmation pages.\n *\n * Precedence for the PayPal providers: **admin-configured title** (`titles`,\n * from {@link fetchPayPalConfig}) → built-in default. For any other provider:\n * `fallback` → raw id. Never throws on an unknown / missing provider, which is\n * what causes the common `Cannot read properties of undefined (reading 'title')`\n * crash on the order confirmation page.\n *\n * @param titles Admin titles so the order page shows the same labels configured\n * in Medusa Admin → Settings → PayPal (e.g. a custom \"Credit or Debit Card\").\n */\nexport function getPaymentLabel(\n providerId: string | undefined | null,\n fallback?: string,\n titles?: PayPalConfigTitles\n): string {\n if (!providerId) {\n return fallback ?? \"\"\n }\n if (providerId === PAYPAL_WALLET_PROVIDER_ID) {\n return (\n titles?.paypal_title?.trim() ||\n paypalPaymentInfoMap[PAYPAL_WALLET_PROVIDER_ID].title\n )\n }\n if (providerId === PAYPAL_CARD_PROVIDER_ID) {\n return (\n titles?.card_title?.trim() ||\n paypalPaymentInfoMap[PAYPAL_CARD_PROVIDER_ID].title\n )\n }\n return fallback ?? providerId\n}\n\n/**\n * Fetch the admin-configured PayPal titles from the storefront API\n * (`GET /store/paypal/config`). Server-safe (uses the global `fetch`).\n *\n * Returns `{}` on any error/network failure so callers can fall back to the\n * built-in defaults without their own try/catch.\n */\nexport async function fetchPayPalConfig(opts: {\n baseUrl: string\n publishableApiKey?: string\n signal?: AbortSignal\n}): Promise<PayPalConfigTitles> {\n const { baseUrl, publishableApiKey, signal } = opts\n if (!baseUrl) {\n return {}\n }\n try {\n const timeoutMs = 30_000\n let effectiveSignal = signal\n let timeoutId: ReturnType<typeof setTimeout> | undefined\n if (!signal) {\n const controller = new AbortController()\n timeoutId = setTimeout(() => controller.abort(), timeoutMs)\n effectiveSignal = controller.signal\n }\n let res: Response\n try {\n res = await fetch(`${baseUrl.replace(/\\/$/, \"\")}/store/paypal/config`, {\n headers: {\n accept: \"application/json\",\n ...(publishableApiKey\n ? { \"x-publishable-api-key\": publishableApiKey }\n : {}),\n },\n signal: effectiveSignal,\n })\n } finally {\n if (timeoutId !== undefined) clearTimeout(timeoutId)\n }\n if (!res.ok) {\n return {}\n }\n const cfg = (await res.json()) as Record<string, unknown>\n return {\n paypal_title:\n typeof cfg.paypal_title === \"string\" ? cfg.paypal_title : undefined,\n card_title:\n typeof cfg.card_title === \"string\" ? cfg.card_title : undefined,\n }\n } catch {\n return {}\n }\n}\n","/**\n * Provider ids registered by `@easypayment/medusa-payment-paypal`.\n *\n * Single source of truth — `PayPalPaymentSection`, `MedusaNextPayPalAdapter`,\n * and the server-safe `order` entrypoint all re-export from here so the ids\n * can never drift between entrypoints.\n */\nexport const PAYPAL_WALLET_PROVIDER_ID = \"pp_paypal_paypal\" as const\nexport const PAYPAL_CARD_PROVIDER_ID = \"pp_paypal_card_paypal_card\" as const\n\nexport const PAYPAL_PROVIDER_IDS = [\n PAYPAL_WALLET_PROVIDER_ID,\n PAYPAL_CARD_PROVIDER_ID,\n] as const\n\nexport type PayPalProviderId = (typeof PAYPAL_PROVIDER_IDS)[number]\n\n/** True when the provider id belongs to this PayPal plugin. */\nexport function isPayPalProviderId(\n providerId?: string | null\n): providerId is PayPalProviderId {\n if (!providerId) return false\n return PAYPAL_PROVIDER_IDS.includes(providerId as PayPalProviderId)\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;;ACOO,IAAM,4BAA4B;AAClC,IAAM,0BAA0B;AAEhC,IAAM,sBAAsB;AAAA,EACjC;AAAA,EACA;AACF;AAKO,SAAS,mBACd,YACgC;AAChC,MAAI,CAAC,WAAY,QAAO;AACxB,SAAO,oBAAoB,SAAS,UAA8B;AACpE;;;ADkBO,IAAM,uBAA0D;AAAA,EACrE,CAAC,yBAAyB,GAAG,EAAE,OAAO,SAAS;AAAA,EAC/C,CAAC,uBAAuB,GAAG,EAAE,OAAO,uBAAuB;AAC7D;AAoBO,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,YAAY;AAClB,QAAI,kBAAkB;AACtB,QAAI;AACJ,QAAI,CAAC,QAAQ;AACX,YAAM,aAAa,IAAI,gBAAgB;AACvC,kBAAY,WAAW,MAAM,WAAW,MAAM,GAAG,SAAS;AAC1D,wBAAkB,WAAW;AAAA,IAC/B;AACA,QAAI;AACJ,QAAI;AACF,YAAM,MAAM,MAAM,GAAG,QAAQ,QAAQ,OAAO,EAAE,CAAC,wBAAwB;AAAA,QACrE,SAAS;AAAA,UACP,QAAQ;AAAA,UACR,GAAI,oBACA,EAAE,yBAAyB,kBAAkB,IAC7C,CAAC;AAAA,QACP;AAAA,QACA,QAAQ;AAAA,MACV,CAAC;AAAA,IACH,UAAE;AACA,UAAI,cAAc,OAAW,cAAa,SAAS;AAAA,IACrD;AACA,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":[]}
1
+ {"version":3,"sources":["../src/order.ts","../src/constants.ts"],"sourcesContent":["/**\n * Server-safe helpers for displaying PayPal payments on order / confirmation\n * pages.\n *\n * This module is intentionally free of React and any client-only code, so it\n * can be imported from Next.js **server components** (e.g. the order\n * confirmation page) without pulling in the checkout UI.\n *\n * @example\n * import { getPaymentLabel, fetchPayPalConfig } from \"@easypayment/medusa-paypal-ui/order\"\n *\n * // In the (async) server component that renders the payment method:\n * const titles = await fetchPayPalConfig({\n * baseUrl: process.env.NEXT_PUBLIC_MEDUSA_BACKEND_URL!,\n * publishableApiKey: process.env.NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY,\n * })\n *\n * <Text>\n * {getPaymentLabel(\n * payment.provider_id,\n * paymentInfoMap[payment.provider_id]?.title,\n * titles\n * )}\n * </Text>\n */\n\nimport {\n PAYPAL_WALLET_PROVIDER_ID,\n PAYPAL_CARD_PROVIDER_ID,\n PAYPAL_PROVIDER_IDS,\n isPayPalProviderId,\n} from \"./constants\"\nimport type { PayPalProviderId } from \"./constants\"\n\nexport {\n PAYPAL_WALLET_PROVIDER_ID,\n PAYPAL_CARD_PROVIDER_ID,\n PAYPAL_PROVIDER_IDS,\n isPayPalProviderId,\n}\nexport type { PayPalProviderId }\n\nexport type PayPalPaymentInfo = { title: string }\n\n/** Built-in fallback titles, used only when admin titles aren't supplied. */\nexport const paypalPaymentInfoMap: Record<string, PayPalPaymentInfo> = {\n [PAYPAL_WALLET_PROVIDER_ID]: { title: \"PayPal\" },\n [PAYPAL_CARD_PROVIDER_ID]: { title: \"Credit or Debit Card\" },\n}\n\n/** Admin-configured titles, as returned by `GET /store/paypal/config`. */\nexport type PayPalConfigTitles = {\n paypal_title?: string | null\n card_title?: string | null\n}\n\n/**\n * Null-safe payment label resolver for order / confirmation pages.\n *\n * Precedence for the PayPal providers: **admin-configured title** (`titles`,\n * from {@link fetchPayPalConfig}) → built-in default. For any other provider:\n * `fallback` → raw id. Never throws on an unknown / missing provider, which is\n * what causes the common `Cannot read properties of undefined (reading 'title')`\n * crash on the order confirmation page.\n *\n * @param titles Admin titles so the order page shows the same labels configured\n * in Medusa Admin → Settings → PayPal (e.g. a custom \"Credit or Debit Card\").\n */\nexport function getPaymentLabel(\n providerId: string | undefined | null,\n fallback?: string,\n titles?: PayPalConfigTitles\n): string {\n if (!providerId) {\n return fallback ?? \"\"\n }\n if (providerId === PAYPAL_WALLET_PROVIDER_ID) {\n return (\n titles?.paypal_title?.trim() ||\n paypalPaymentInfoMap[PAYPAL_WALLET_PROVIDER_ID].title\n )\n }\n if (providerId === PAYPAL_CARD_PROVIDER_ID) {\n return (\n titles?.card_title?.trim() ||\n paypalPaymentInfoMap[PAYPAL_CARD_PROVIDER_ID].title\n )\n }\n return fallback ?? providerId\n}\n\n/**\n * Fetch the admin-configured PayPal titles from the storefront API\n * (`GET /store/paypal/config`). Server-safe (uses the global `fetch`).\n *\n * Returns `{}` on any error/network failure so callers can fall back to the\n * built-in defaults without their own try/catch.\n */\nexport async function fetchPayPalConfig(opts: {\n baseUrl: string\n publishableApiKey?: string\n signal?: AbortSignal\n}): Promise<PayPalConfigTitles> {\n const { baseUrl, publishableApiKey, signal } = opts\n if (!baseUrl) {\n return {}\n }\n try {\n // Always arm the timeout — a caller-supplied signal composes with it\n // rather than replacing it (matching the client-side HTTP client), so a\n // signal that never fires can't leave the request hanging forever.\n const timeoutMs = 30_000\n const controller = new AbortController()\n const timeoutId = setTimeout(() => controller.abort(), timeoutMs)\n const onCallerAbort = () => controller.abort()\n if (signal) {\n if (signal.aborted) controller.abort()\n else signal.addEventListener(\"abort\", onCallerAbort)\n }\n let res: Response\n try {\n res = await fetch(`${baseUrl.replace(/\\/$/, \"\")}/store/paypal/config`, {\n headers: {\n accept: \"application/json\",\n ...(publishableApiKey\n ? { \"x-publishable-api-key\": publishableApiKey }\n : {}),\n },\n signal: controller.signal,\n })\n } finally {\n clearTimeout(timeoutId)\n if (signal) signal.removeEventListener(\"abort\", onCallerAbort)\n }\n if (!res.ok) {\n return {}\n }\n const cfg = (await res.json()) as Record<string, unknown>\n return {\n paypal_title:\n typeof cfg.paypal_title === \"string\" ? cfg.paypal_title : undefined,\n card_title:\n typeof cfg.card_title === \"string\" ? cfg.card_title : undefined,\n }\n } catch {\n return {}\n }\n}\n","/**\n * Provider ids registered by `@easypayment/medusa-payment-paypal`.\n *\n * Single source of truth — `PayPalPaymentSection`, `MedusaNextPayPalAdapter`,\n * and the server-safe `order` entrypoint all re-export from here so the ids\n * can never drift between entrypoints.\n */\nexport const PAYPAL_WALLET_PROVIDER_ID = \"pp_paypal_paypal\" as const\nexport const PAYPAL_CARD_PROVIDER_ID = \"pp_paypal_card_paypal_card\" as const\n\nexport const PAYPAL_PROVIDER_IDS = [\n PAYPAL_WALLET_PROVIDER_ID,\n PAYPAL_CARD_PROVIDER_ID,\n] as const\n\nexport type PayPalProviderId = (typeof PAYPAL_PROVIDER_IDS)[number]\n\n/** True when the provider id belongs to this PayPal plugin. */\nexport function isPayPalProviderId(\n providerId?: string | null\n): providerId is PayPalProviderId {\n if (!providerId) return false\n return PAYPAL_PROVIDER_IDS.includes(providerId as PayPalProviderId)\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;;ACOO,IAAM,4BAA4B;AAClC,IAAM,0BAA0B;AAEhC,IAAM,sBAAsB;AAAA,EACjC;AAAA,EACA;AACF;AAKO,SAAS,mBACd,YACgC;AAChC,MAAI,CAAC,WAAY,QAAO;AACxB,SAAO,oBAAoB,SAAS,UAA8B;AACpE;;;ADsBO,IAAM,uBAA0D;AAAA,EACrE,CAAC,yBAAyB,GAAG,EAAE,OAAO,SAAS;AAAA,EAC/C,CAAC,uBAAuB,GAAG,EAAE,OAAO,uBAAuB;AAC7D;AAoBO,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;AAIF,UAAM,YAAY;AAClB,UAAM,aAAa,IAAI,gBAAgB;AACvC,UAAM,YAAY,WAAW,MAAM,WAAW,MAAM,GAAG,SAAS;AAChE,UAAM,gBAAgB,MAAM,WAAW,MAAM;AAC7C,QAAI,QAAQ;AACV,UAAI,OAAO,QAAS,YAAW,MAAM;AAAA,UAChC,QAAO,iBAAiB,SAAS,aAAa;AAAA,IACrD;AACA,QAAI;AACJ,QAAI;AACF,YAAM,MAAM,MAAM,GAAG,QAAQ,QAAQ,OAAO,EAAE,CAAC,wBAAwB;AAAA,QACrE,SAAS;AAAA,UACP,QAAQ;AAAA,UACR,GAAI,oBACA,EAAE,yBAAyB,kBAAkB,IAC7C,CAAC;AAAA,QACP;AAAA,QACA,QAAQ,WAAW;AAAA,MACrB,CAAC;AAAA,IACH,UAAE;AACA,mBAAa,SAAS;AACtB,UAAI,OAAQ,QAAO,oBAAoB,SAAS,aAAa;AAAA,IAC/D;AACA,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/dist/order.d.cts CHANGED
@@ -74,4 +74,4 @@ declare function fetchPayPalConfig(opts: {
74
74
  signal?: AbortSignal;
75
75
  }): Promise<PayPalConfigTitles>;
76
76
 
77
- export { PAYPAL_CARD_PROVIDER_ID, PAYPAL_WALLET_PROVIDER_ID, type PayPalConfigTitles, type PayPalPaymentInfo, fetchPayPalConfig, getPaymentLabel, isPayPalProviderId, paypalPaymentInfoMap };
77
+ export { PAYPAL_CARD_PROVIDER_ID, PAYPAL_PROVIDER_IDS, PAYPAL_WALLET_PROVIDER_ID, type PayPalConfigTitles, type PayPalPaymentInfo, type PayPalProviderId, fetchPayPalConfig, getPaymentLabel, isPayPalProviderId, paypalPaymentInfoMap };
package/dist/order.d.ts CHANGED
@@ -74,4 +74,4 @@ declare function fetchPayPalConfig(opts: {
74
74
  signal?: AbortSignal;
75
75
  }): Promise<PayPalConfigTitles>;
76
76
 
77
- export { PAYPAL_CARD_PROVIDER_ID, PAYPAL_WALLET_PROVIDER_ID, type PayPalConfigTitles, type PayPalPaymentInfo, fetchPayPalConfig, getPaymentLabel, isPayPalProviderId, paypalPaymentInfoMap };
77
+ export { PAYPAL_CARD_PROVIDER_ID, PAYPAL_PROVIDER_IDS, PAYPAL_WALLET_PROVIDER_ID, type PayPalConfigTitles, type PayPalPaymentInfo, type PayPalProviderId, fetchPayPalConfig, getPaymentLabel, isPayPalProviderId, paypalPaymentInfoMap };
package/dist/order.mjs CHANGED
@@ -34,12 +34,12 @@ async function fetchPayPalConfig(opts) {
34
34
  }
35
35
  try {
36
36
  const timeoutMs = 3e4;
37
- let effectiveSignal = signal;
38
- let timeoutId;
39
- if (!signal) {
40
- const controller = new AbortController();
41
- timeoutId = setTimeout(() => controller.abort(), timeoutMs);
42
- effectiveSignal = controller.signal;
37
+ const controller = new AbortController();
38
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
39
+ const onCallerAbort = () => controller.abort();
40
+ if (signal) {
41
+ if (signal.aborted) controller.abort();
42
+ else signal.addEventListener("abort", onCallerAbort);
43
43
  }
44
44
  let res;
45
45
  try {
@@ -48,10 +48,11 @@ async function fetchPayPalConfig(opts) {
48
48
  accept: "application/json",
49
49
  ...publishableApiKey ? { "x-publishable-api-key": publishableApiKey } : {}
50
50
  },
51
- signal: effectiveSignal
51
+ signal: controller.signal
52
52
  });
53
53
  } finally {
54
- if (timeoutId !== void 0) clearTimeout(timeoutId);
54
+ clearTimeout(timeoutId);
55
+ if (signal) signal.removeEventListener("abort", onCallerAbort);
55
56
  }
56
57
  if (!res.ok) {
57
58
  return {};
@@ -67,6 +68,7 @@ async function fetchPayPalConfig(opts) {
67
68
  }
68
69
  export {
69
70
  PAYPAL_CARD_PROVIDER_ID,
71
+ PAYPAL_PROVIDER_IDS,
70
72
  PAYPAL_WALLET_PROVIDER_ID,
71
73
  fetchPayPalConfig,
72
74
  getPaymentLabel,
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/constants.ts","../src/order.ts"],"sourcesContent":["/**\n * Provider ids registered by `@easypayment/medusa-payment-paypal`.\n *\n * Single source of truth — `PayPalPaymentSection`, `MedusaNextPayPalAdapter`,\n * and the server-safe `order` entrypoint all re-export from here so the ids\n * can never drift between entrypoints.\n */\nexport const PAYPAL_WALLET_PROVIDER_ID = \"pp_paypal_paypal\" as const\nexport const PAYPAL_CARD_PROVIDER_ID = \"pp_paypal_card_paypal_card\" as const\n\nexport const PAYPAL_PROVIDER_IDS = [\n PAYPAL_WALLET_PROVIDER_ID,\n PAYPAL_CARD_PROVIDER_ID,\n] as const\n\nexport type PayPalProviderId = (typeof PAYPAL_PROVIDER_IDS)[number]\n\n/** True when the provider id belongs to this PayPal plugin. */\nexport function isPayPalProviderId(\n providerId?: string | null\n): providerId is PayPalProviderId {\n if (!providerId) return false\n return PAYPAL_PROVIDER_IDS.includes(providerId as PayPalProviderId)\n}\n","/**\n * Server-safe helpers for displaying PayPal payments on order / confirmation\n * pages.\n *\n * This module is intentionally free of React and any client-only code, so it\n * can be imported from Next.js **server components** (e.g. the order\n * confirmation page) without pulling in the checkout UI.\n *\n * @example\n * import { getPaymentLabel, fetchPayPalConfig } from \"@easypayment/medusa-paypal-ui/order\"\n *\n * // In the (async) server component that renders the payment method:\n * const titles = await fetchPayPalConfig({\n * baseUrl: process.env.NEXT_PUBLIC_MEDUSA_BACKEND_URL!,\n * publishableApiKey: process.env.NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY,\n * })\n *\n * <Text>\n * {getPaymentLabel(\n * payment.provider_id,\n * paymentInfoMap[payment.provider_id]?.title,\n * titles\n * )}\n * </Text>\n */\n\nimport {\n PAYPAL_WALLET_PROVIDER_ID,\n PAYPAL_CARD_PROVIDER_ID,\n isPayPalProviderId,\n} from \"./constants\"\n\nexport {\n PAYPAL_WALLET_PROVIDER_ID,\n PAYPAL_CARD_PROVIDER_ID,\n isPayPalProviderId,\n}\n\nexport type PayPalPaymentInfo = { title: string }\n\n/** Built-in fallback titles, used only when admin titles aren't supplied. */\nexport const paypalPaymentInfoMap: Record<string, PayPalPaymentInfo> = {\n [PAYPAL_WALLET_PROVIDER_ID]: { title: \"PayPal\" },\n [PAYPAL_CARD_PROVIDER_ID]: { title: \"Credit or Debit Card\" },\n}\n\n/** Admin-configured titles, as returned by `GET /store/paypal/config`. */\nexport type PayPalConfigTitles = {\n paypal_title?: string | null\n card_title?: string | null\n}\n\n/**\n * Null-safe payment label resolver for order / confirmation pages.\n *\n * Precedence for the PayPal providers: **admin-configured title** (`titles`,\n * from {@link fetchPayPalConfig}) → built-in default. For any other provider:\n * `fallback` → raw id. Never throws on an unknown / missing provider, which is\n * what causes the common `Cannot read properties of undefined (reading 'title')`\n * crash on the order confirmation page.\n *\n * @param titles Admin titles so the order page shows the same labels configured\n * in Medusa Admin → Settings → PayPal (e.g. a custom \"Credit or Debit Card\").\n */\nexport function getPaymentLabel(\n providerId: string | undefined | null,\n fallback?: string,\n titles?: PayPalConfigTitles\n): string {\n if (!providerId) {\n return fallback ?? \"\"\n }\n if (providerId === PAYPAL_WALLET_PROVIDER_ID) {\n return (\n titles?.paypal_title?.trim() ||\n paypalPaymentInfoMap[PAYPAL_WALLET_PROVIDER_ID].title\n )\n }\n if (providerId === PAYPAL_CARD_PROVIDER_ID) {\n return (\n titles?.card_title?.trim() ||\n paypalPaymentInfoMap[PAYPAL_CARD_PROVIDER_ID].title\n )\n }\n return fallback ?? providerId\n}\n\n/**\n * Fetch the admin-configured PayPal titles from the storefront API\n * (`GET /store/paypal/config`). Server-safe (uses the global `fetch`).\n *\n * Returns `{}` on any error/network failure so callers can fall back to the\n * built-in defaults without their own try/catch.\n */\nexport async function fetchPayPalConfig(opts: {\n baseUrl: string\n publishableApiKey?: string\n signal?: AbortSignal\n}): Promise<PayPalConfigTitles> {\n const { baseUrl, publishableApiKey, signal } = opts\n if (!baseUrl) {\n return {}\n }\n try {\n const timeoutMs = 30_000\n let effectiveSignal = signal\n let timeoutId: ReturnType<typeof setTimeout> | undefined\n if (!signal) {\n const controller = new AbortController()\n timeoutId = setTimeout(() => controller.abort(), timeoutMs)\n effectiveSignal = controller.signal\n }\n let res: Response\n try {\n res = await fetch(`${baseUrl.replace(/\\/$/, \"\")}/store/paypal/config`, {\n headers: {\n accept: \"application/json\",\n ...(publishableApiKey\n ? { \"x-publishable-api-key\": publishableApiKey }\n : {}),\n },\n signal: effectiveSignal,\n })\n } finally {\n if (timeoutId !== undefined) clearTimeout(timeoutId)\n }\n if (!res.ok) {\n return {}\n }\n const cfg = (await res.json()) as Record<string, unknown>\n return {\n paypal_title:\n typeof cfg.paypal_title === \"string\" ? cfg.paypal_title : undefined,\n card_title:\n typeof cfg.card_title === \"string\" ? cfg.card_title : undefined,\n }\n } catch {\n return {}\n }\n}\n"],"mappings":";AAOO,IAAM,4BAA4B;AAClC,IAAM,0BAA0B;AAEhC,IAAM,sBAAsB;AAAA,EACjC;AAAA,EACA;AACF;AAKO,SAAS,mBACd,YACgC;AAChC,MAAI,CAAC,WAAY,QAAO;AACxB,SAAO,oBAAoB,SAAS,UAA8B;AACpE;;;ACkBO,IAAM,uBAA0D;AAAA,EACrE,CAAC,yBAAyB,GAAG,EAAE,OAAO,SAAS;AAAA,EAC/C,CAAC,uBAAuB,GAAG,EAAE,OAAO,uBAAuB;AAC7D;AAoBO,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,YAAY;AAClB,QAAI,kBAAkB;AACtB,QAAI;AACJ,QAAI,CAAC,QAAQ;AACX,YAAM,aAAa,IAAI,gBAAgB;AACvC,kBAAY,WAAW,MAAM,WAAW,MAAM,GAAG,SAAS;AAC1D,wBAAkB,WAAW;AAAA,IAC/B;AACA,QAAI;AACJ,QAAI;AACF,YAAM,MAAM,MAAM,GAAG,QAAQ,QAAQ,OAAO,EAAE,CAAC,wBAAwB;AAAA,QACrE,SAAS;AAAA,UACP,QAAQ;AAAA,UACR,GAAI,oBACA,EAAE,yBAAyB,kBAAkB,IAC7C,CAAC;AAAA,QACP;AAAA,QACA,QAAQ;AAAA,MACV,CAAC;AAAA,IACH,UAAE;AACA,UAAI,cAAc,OAAW,cAAa,SAAS;AAAA,IACrD;AACA,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":[]}
1
+ {"version":3,"sources":["../src/constants.ts","../src/order.ts"],"sourcesContent":["/**\n * Provider ids registered by `@easypayment/medusa-payment-paypal`.\n *\n * Single source of truth — `PayPalPaymentSection`, `MedusaNextPayPalAdapter`,\n * and the server-safe `order` entrypoint all re-export from here so the ids\n * can never drift between entrypoints.\n */\nexport const PAYPAL_WALLET_PROVIDER_ID = \"pp_paypal_paypal\" as const\nexport const PAYPAL_CARD_PROVIDER_ID = \"pp_paypal_card_paypal_card\" as const\n\nexport const PAYPAL_PROVIDER_IDS = [\n PAYPAL_WALLET_PROVIDER_ID,\n PAYPAL_CARD_PROVIDER_ID,\n] as const\n\nexport type PayPalProviderId = (typeof PAYPAL_PROVIDER_IDS)[number]\n\n/** True when the provider id belongs to this PayPal plugin. */\nexport function isPayPalProviderId(\n providerId?: string | null\n): providerId is PayPalProviderId {\n if (!providerId) return false\n return PAYPAL_PROVIDER_IDS.includes(providerId as PayPalProviderId)\n}\n","/**\n * Server-safe helpers for displaying PayPal payments on order / confirmation\n * pages.\n *\n * This module is intentionally free of React and any client-only code, so it\n * can be imported from Next.js **server components** (e.g. the order\n * confirmation page) without pulling in the checkout UI.\n *\n * @example\n * import { getPaymentLabel, fetchPayPalConfig } from \"@easypayment/medusa-paypal-ui/order\"\n *\n * // In the (async) server component that renders the payment method:\n * const titles = await fetchPayPalConfig({\n * baseUrl: process.env.NEXT_PUBLIC_MEDUSA_BACKEND_URL!,\n * publishableApiKey: process.env.NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY,\n * })\n *\n * <Text>\n * {getPaymentLabel(\n * payment.provider_id,\n * paymentInfoMap[payment.provider_id]?.title,\n * titles\n * )}\n * </Text>\n */\n\nimport {\n PAYPAL_WALLET_PROVIDER_ID,\n PAYPAL_CARD_PROVIDER_ID,\n PAYPAL_PROVIDER_IDS,\n isPayPalProviderId,\n} from \"./constants\"\nimport type { PayPalProviderId } from \"./constants\"\n\nexport {\n PAYPAL_WALLET_PROVIDER_ID,\n PAYPAL_CARD_PROVIDER_ID,\n PAYPAL_PROVIDER_IDS,\n isPayPalProviderId,\n}\nexport type { PayPalProviderId }\n\nexport type PayPalPaymentInfo = { title: string }\n\n/** Built-in fallback titles, used only when admin titles aren't supplied. */\nexport const paypalPaymentInfoMap: Record<string, PayPalPaymentInfo> = {\n [PAYPAL_WALLET_PROVIDER_ID]: { title: \"PayPal\" },\n [PAYPAL_CARD_PROVIDER_ID]: { title: \"Credit or Debit Card\" },\n}\n\n/** Admin-configured titles, as returned by `GET /store/paypal/config`. */\nexport type PayPalConfigTitles = {\n paypal_title?: string | null\n card_title?: string | null\n}\n\n/**\n * Null-safe payment label resolver for order / confirmation pages.\n *\n * Precedence for the PayPal providers: **admin-configured title** (`titles`,\n * from {@link fetchPayPalConfig}) → built-in default. For any other provider:\n * `fallback` → raw id. Never throws on an unknown / missing provider, which is\n * what causes the common `Cannot read properties of undefined (reading 'title')`\n * crash on the order confirmation page.\n *\n * @param titles Admin titles so the order page shows the same labels configured\n * in Medusa Admin → Settings → PayPal (e.g. a custom \"Credit or Debit Card\").\n */\nexport function getPaymentLabel(\n providerId: string | undefined | null,\n fallback?: string,\n titles?: PayPalConfigTitles\n): string {\n if (!providerId) {\n return fallback ?? \"\"\n }\n if (providerId === PAYPAL_WALLET_PROVIDER_ID) {\n return (\n titles?.paypal_title?.trim() ||\n paypalPaymentInfoMap[PAYPAL_WALLET_PROVIDER_ID].title\n )\n }\n if (providerId === PAYPAL_CARD_PROVIDER_ID) {\n return (\n titles?.card_title?.trim() ||\n paypalPaymentInfoMap[PAYPAL_CARD_PROVIDER_ID].title\n )\n }\n return fallback ?? providerId\n}\n\n/**\n * Fetch the admin-configured PayPal titles from the storefront API\n * (`GET /store/paypal/config`). Server-safe (uses the global `fetch`).\n *\n * Returns `{}` on any error/network failure so callers can fall back to the\n * built-in defaults without their own try/catch.\n */\nexport async function fetchPayPalConfig(opts: {\n baseUrl: string\n publishableApiKey?: string\n signal?: AbortSignal\n}): Promise<PayPalConfigTitles> {\n const { baseUrl, publishableApiKey, signal } = opts\n if (!baseUrl) {\n return {}\n }\n try {\n // Always arm the timeout — a caller-supplied signal composes with it\n // rather than replacing it (matching the client-side HTTP client), so a\n // signal that never fires can't leave the request hanging forever.\n const timeoutMs = 30_000\n const controller = new AbortController()\n const timeoutId = setTimeout(() => controller.abort(), timeoutMs)\n const onCallerAbort = () => controller.abort()\n if (signal) {\n if (signal.aborted) controller.abort()\n else signal.addEventListener(\"abort\", onCallerAbort)\n }\n let res: Response\n try {\n res = await fetch(`${baseUrl.replace(/\\/$/, \"\")}/store/paypal/config`, {\n headers: {\n accept: \"application/json\",\n ...(publishableApiKey\n ? { \"x-publishable-api-key\": publishableApiKey }\n : {}),\n },\n signal: controller.signal,\n })\n } finally {\n clearTimeout(timeoutId)\n if (signal) signal.removeEventListener(\"abort\", onCallerAbort)\n }\n if (!res.ok) {\n return {}\n }\n const cfg = (await res.json()) as Record<string, unknown>\n return {\n paypal_title:\n typeof cfg.paypal_title === \"string\" ? cfg.paypal_title : undefined,\n card_title:\n typeof cfg.card_title === \"string\" ? cfg.card_title : undefined,\n }\n } catch {\n return {}\n }\n}\n"],"mappings":";AAOO,IAAM,4BAA4B;AAClC,IAAM,0BAA0B;AAEhC,IAAM,sBAAsB;AAAA,EACjC;AAAA,EACA;AACF;AAKO,SAAS,mBACd,YACgC;AAChC,MAAI,CAAC,WAAY,QAAO;AACxB,SAAO,oBAAoB,SAAS,UAA8B;AACpE;;;ACsBO,IAAM,uBAA0D;AAAA,EACrE,CAAC,yBAAyB,GAAG,EAAE,OAAO,SAAS;AAAA,EAC/C,CAAC,uBAAuB,GAAG,EAAE,OAAO,uBAAuB;AAC7D;AAoBO,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;AAIF,UAAM,YAAY;AAClB,UAAM,aAAa,IAAI,gBAAgB;AACvC,UAAM,YAAY,WAAW,MAAM,WAAW,MAAM,GAAG,SAAS;AAChE,UAAM,gBAAgB,MAAM,WAAW,MAAM;AAC7C,QAAI,QAAQ;AACV,UAAI,OAAO,QAAS,YAAW,MAAM;AAAA,UAChC,QAAO,iBAAiB,SAAS,aAAa;AAAA,IACrD;AACA,QAAI;AACJ,QAAI;AACF,YAAM,MAAM,MAAM,GAAG,QAAQ,QAAQ,OAAO,EAAE,CAAC,wBAAwB;AAAA,QACrE,SAAS;AAAA,UACP,QAAQ;AAAA,UACR,GAAI,oBACA,EAAE,yBAAyB,kBAAkB,IAC7C,CAAC;AAAA,QACP;AAAA,QACA,QAAQ,WAAW;AAAA,MACrB,CAAC;AAAA,IACH,UAAE;AACA,mBAAa,SAAS;AACtB,UAAI,OAAQ,QAAO,oBAAoB,SAAS,aAAa;AAAA,IAC/D;AACA,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,7 +1,7 @@
1
1
  {
2
2
  "name": "@easypayment/medusa-paypal-ui",
3
- "version": "1.2.8",
4
- "description": "Enterprise Gold PayPal UI module for Medusa v2 storefront (Next.js)",
3
+ "version": "1.3.0",
4
+ "description": "PayPal checkout UI for Medusa v2 storefronts — PayPal Buttons, hosted card fields, and server-safe order helpers for Next.js and React. Companion to @easypayment/medusa-payment-paypal.",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "sideEffects": false,
@@ -52,13 +52,21 @@
52
52
  "@paypal/react-paypal-js": "^9.2.0"
53
53
  },
54
54
  "devDependencies": {
55
+ "@eslint/js": "^9.39.5",
55
56
  "@testing-library/jest-dom": "^6.5.0",
56
57
  "@testing-library/react": "^16.0.0",
57
58
  "@types/react": "^19.2.14",
58
59
  "@types/react-dom": "^19.2.3",
60
+ "eslint": "^9.39.5",
61
+ "eslint-plugin-jsx-a11y": "^6.10.2",
62
+ "eslint-plugin-react-hooks": "^7.1.1",
59
63
  "jsdom": "^24.0.0",
64
+ "prettier": "^3.6.2",
65
+ "react": "^19.3.0",
66
+ "react-dom": "^19.3.0",
60
67
  "tsup": "^8.0.0",
61
68
  "typescript": "^5.0.0",
69
+ "typescript-eslint": "^8.71.1",
62
70
  "vitest": "^2.0.5"
63
71
  },
64
72
  "scripts": {
@@ -67,10 +75,41 @@
67
75
  "test": "vitest run",
68
76
  "test:watch": "vitest",
69
77
  "prepublishOnly": "npm run build",
70
- "typecheck": "tsc --noEmit"
78
+ "typecheck": "tsc -p tsconfig.typecheck.json --noEmit",
79
+ "format": "prettier --write .",
80
+ "format:check": "prettier --check .",
81
+ "lint": "eslint src test"
71
82
  },
72
83
  "repository": {
73
84
  "type": "git",
74
85
  "url": "https://github.com/easypaymentplugins/medusa-paypal-ui"
75
- }
86
+ },
87
+ "homepage": "https://github.com/easypaymentplugins/medusa-paypal-ui#readme",
88
+ "bugs": {
89
+ "url": "https://github.com/easypaymentplugins/medusa-paypal-ui/issues"
90
+ },
91
+ "engines": {
92
+ "node": ">=18"
93
+ },
94
+ "publishConfig": {
95
+ "access": "public"
96
+ },
97
+ "keywords": [
98
+ "medusa",
99
+ "medusajs",
100
+ "medusa-v2",
101
+ "medusa-plugin",
102
+ "medusa-storefront",
103
+ "paypal",
104
+ "paypal-checkout",
105
+ "paypal-buttons",
106
+ "card-fields",
107
+ "payment",
108
+ "payments",
109
+ "checkout",
110
+ "ecommerce",
111
+ "nextjs",
112
+ "react",
113
+ "credit-card"
114
+ ]
76
115
  }
@@ -10,14 +10,12 @@ import {
10
10
  PAYPAL_CARD_PROVIDER_ID as DEFAULT_PAYPAL_CARD_PROVIDER_ID,
11
11
  } from "../constants"
12
12
  import { usePayPalConfig } from "../hooks/usePayPalConfig"
13
-
14
- const SPIN_STYLE = `@keyframes _pp_spin { to { transform: rotate(360deg) } }`
13
+ import { AnimationStyles } from "../components/internal/ui"
15
14
 
16
15
  function PayPalLoadingCard() {
17
16
  return (
18
17
  <div
19
18
  role="status"
20
- aria-label="Connecting to PayPal"
21
19
  style={{
22
20
  display: "flex",
23
21
  alignItems: "center",
@@ -28,15 +26,16 @@ function PayPalLoadingCard() {
28
26
  borderRadius: 10,
29
27
  }}
30
28
  >
31
- <style>{SPIN_STYLE}</style>
29
+ <AnimationStyles />
32
30
  <div
31
+ className="_pp-spin-sm"
32
+ aria-hidden="true"
33
33
  style={{
34
34
  width: 22,
35
35
  height: 22,
36
36
  borderRadius: "50%",
37
37
  border: "2.5px solid #e5e7eb",
38
38
  borderTopColor: "#0070ba",
39
- animation: "_pp_spin .7s linear infinite",
40
39
  flexShrink: 0,
41
40
  }}
42
41
  />
@@ -52,7 +51,13 @@ function PayPalLoadingCard() {
52
51
  )
53
52
  }
54
53
 
55
- function PayPalErrorCard({ message }: { message: string }) {
54
+ function PayPalErrorCard({
55
+ message,
56
+ onRetry,
57
+ }: {
58
+ message: string
59
+ onRetry?: () => void
60
+ }) {
56
61
  return (
57
62
  <div
58
63
  role="alert"
@@ -66,6 +71,26 @@ function PayPalErrorCard({ message }: { message: string }) {
66
71
  }}
67
72
  >
68
73
  {message}
74
+ {onRetry && (
75
+ <button
76
+ type="button"
77
+ onClick={onRetry}
78
+ style={{
79
+ display: "block",
80
+ marginTop: 8,
81
+ padding: "6px 14px",
82
+ background: "#ffffff",
83
+ color: "#b91c1c",
84
+ border: "1px solid #fecaca",
85
+ borderRadius: 6,
86
+ fontSize: 13,
87
+ fontWeight: 500,
88
+ cursor: "pointer",
89
+ }}
90
+ >
91
+ Try again
92
+ </button>
93
+ )}
69
94
  </div>
70
95
  )
71
96
  }
@@ -99,13 +124,20 @@ export type MedusaNextPayPalAdapterProps = {
99
124
  selectedProviderId: string | null | undefined
100
125
  baseUrl: string
101
126
  publishableApiKey?: string
127
+ /** Forwarded to every API call — see `HttpOptions.credentials`. */
128
+ credentials?: RequestCredentials
129
+ /**
130
+ * The cart's currency code (e.g. "usd"). Recommended: busts the config
131
+ * cache when the buyer switches region/currency on the same cart.
132
+ */
133
+ currencyCode?: string
102
134
  providerIds?: {
103
135
  paypal?: string
104
136
  paypalCard?: string
105
137
  }
106
- onSuccess?: (cartId: string) => void
138
+ onSuccess?: (cartId: string) => void | Promise<void>
107
139
  onError?: (message: string) => void
108
- onPaid?: (result: unknown) => void
140
+ onPaid?: (result: unknown) => void | Promise<void>
109
141
  }
110
142
 
111
143
  export function MedusaNextPayPalAdapter(props: MedusaNextPayPalAdapterProps) {
@@ -114,6 +146,8 @@ export function MedusaNextPayPalAdapter(props: MedusaNextPayPalAdapterProps) {
114
146
  selectedProviderId,
115
147
  baseUrl,
116
148
  publishableApiKey,
149
+ credentials,
150
+ currencyCode,
117
151
  providerIds,
118
152
  onSuccess,
119
153
  onError,
@@ -128,10 +162,12 @@ export function MedusaNextPayPalAdapter(props: MedusaNextPayPalAdapterProps) {
128
162
  selectedProviderId === paypalProviderId ||
129
163
  selectedProviderId === paypalCardProviderId
130
164
 
131
- const { config, loading, error } = usePayPalConfig({
165
+ const { config, loading, error, retry } = usePayPalConfig({
132
166
  baseUrl,
133
167
  publishableApiKey,
168
+ credentials,
134
169
  cartId,
170
+ currencyCode,
135
171
  enabled: shouldRender,
136
172
  })
137
173
 
@@ -147,7 +183,7 @@ export function MedusaNextPayPalAdapter(props: MedusaNextPayPalAdapterProps) {
147
183
 
148
184
  if (loading) return <PayPalLoadingCard />
149
185
 
150
- if (error) return <PayPalErrorCard message={error} />
186
+ if (error) return <PayPalErrorCard message={error} onRetry={retry} />
151
187
 
152
188
  if (!config) return null
153
189
 
@@ -175,6 +211,7 @@ export function MedusaNextPayPalAdapter(props: MedusaNextPayPalAdapterProps) {
175
211
  <PayPalAdvancedCard
176
212
  baseUrl={baseUrl}
177
213
  publishableApiKey={publishableApiKey}
214
+ credentials={credentials}
178
215
  cartId={cartId}
179
216
  config={config}
180
217
  onPaid={handlePaid}
@@ -184,6 +221,7 @@ export function MedusaNextPayPalAdapter(props: MedusaNextPayPalAdapterProps) {
184
221
  <PayPalSmartButtons
185
222
  baseUrl={baseUrl}
186
223
  publishableApiKey={publishableApiKey}
224
+ credentials={credentials}
187
225
  cartId={cartId}
188
226
  config={config}
189
227
  onPaid={handlePaid}
@@ -0,0 +1,96 @@
1
+ import type { PayPalConfig } from "./types"
2
+
3
+ /**
4
+ * Shared module-level cache for `/store/paypal/config` responses.
5
+ *
6
+ * Both `usePayPalConfig` and `usePayPalPaymentMethods` read the same endpoint;
7
+ * sharing one cache (and one in-flight promise per key) means a checkout page
8
+ * that mounts both hooks issues a single network request instead of two.
9
+ *
10
+ * The key includes the publishable API key and an optional currency code so a
11
+ * region/currency switch on the same cart busts the cache instead of serving
12
+ * a stale config for up to the TTL.
13
+ */
14
+
15
+ type Entry = { config: PayPalConfig; at: number }
16
+
17
+ const MAX_CACHE_ENTRIES = 50
18
+ const CACHE_TTL_MS = 5 * 60 * 1000
19
+
20
+ /** Separator that cannot appear in URLs, API keys, cart ids, or currencies. */
21
+ const SEP = "\u0001"
22
+
23
+ const cache = new Map<string, Entry>()
24
+ const inflight = new Map<string, Promise<PayPalConfig>>()
25
+
26
+ export function configCacheKey(args: {
27
+ baseUrl: string
28
+ publishableApiKey?: string
29
+ cartId?: string
30
+ currencyCode?: string
31
+ }): string {
32
+ return [
33
+ args.baseUrl,
34
+ args.publishableApiKey ?? "",
35
+ args.cartId ?? "",
36
+ (args.currencyCode ?? "").toLowerCase(),
37
+ ].join(SEP)
38
+ }
39
+
40
+ export function getCachedPayPalConfig(key: string): PayPalConfig | null {
41
+ const hit = cache.get(key)
42
+ if (!hit) return null
43
+ if (Date.now() - hit.at >= CACHE_TTL_MS) {
44
+ cache.delete(key)
45
+ return null
46
+ }
47
+ return hit.config
48
+ }
49
+
50
+ /**
51
+ * Drop cached configs — all of them, or only the entries for one cart.
52
+ * Call after anything that changes what the config endpoint would return
53
+ * (region/currency switch, plugin settings change).
54
+ */
55
+ export function invalidatePayPalConfigCache(cartId?: string): void {
56
+ if (cartId === undefined) {
57
+ cache.clear()
58
+ return
59
+ }
60
+ for (const key of [...cache.keys()]) {
61
+ if (key.split(SEP)[2] === cartId) cache.delete(key)
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Fetch a config, deduplicating concurrent requests for the same key.
67
+ *
68
+ * The fetcher deliberately receives no caller AbortSignal: the resulting
69
+ * promise may be shared by several mounted hooks, so one unmounting must not
70
+ * cancel the others. Callers discard the result after unmount instead; the
71
+ * HTTP client's own timeout bounds the request.
72
+ */
73
+ export function fetchPayPalConfigShared(
74
+ key: string,
75
+ fetcher: () => Promise<PayPalConfig>
76
+ ): Promise<PayPalConfig> {
77
+ const existing = inflight.get(key)
78
+ if (existing) return existing
79
+
80
+ const p = (async () => {
81
+ try {
82
+ const config = await fetcher()
83
+ if (cache.size >= MAX_CACHE_ENTRIES && !cache.has(key)) {
84
+ const oldest = cache.keys().next().value
85
+ if (oldest !== undefined) cache.delete(oldest)
86
+ }
87
+ cache.set(key, { config, at: Date.now() })
88
+ return config
89
+ } finally {
90
+ inflight.delete(key)
91
+ }
92
+ })()
93
+
94
+ inflight.set(key, p)
95
+ return p
96
+ }
@@ -9,6 +9,12 @@ export type HttpOptions = {
9
9
  * credentialed requests against a wildcard CORS origin outright.
10
10
  */
11
11
  credentials?: RequestCredentials
12
+ /**
13
+ * Per-request timeout in milliseconds. Covers the full request including
14
+ * reading the body. Defaults to 30 000. A caller-supplied AbortSignal
15
+ * composes with (never replaces) this timeout.
16
+ */
17
+ timeoutMs?: number
12
18
  }
13
19
 
14
20
  export type RequestOptions = {
@@ -21,7 +27,13 @@ export type RequestOptions = {
21
27
  retries?: number
22
28
  }
23
29
 
24
- class HttpError extends Error {
30
+ /**
31
+ * Error thrown for HTTP-level failures (non-2xx responses and malformed
32
+ * response bodies). `status` carries the HTTP status code so callers can
33
+ * branch on it (e.g. 403 → "PayPal is disabled") instead of matching
34
+ * localized/backend-authored message text.
35
+ */
36
+ export class HttpError extends Error {
25
37
  status: number
26
38
  constructor(message: string, status: number) {
27
39
  super(message)
@@ -96,7 +108,7 @@ export function createHttpClient(opts: HttpOptions) {
96
108
  // connection. The timer also covers reading the body: a server that sends
97
109
  // headers then stalls the response stream would otherwise hang the
98
110
  // request indefinitely with the old header-only timeout.
99
- const timeoutMs = 30_000
111
+ const timeoutMs = opts.timeoutMs ?? 30_000
100
112
  const controller = new AbortController()
101
113
  const timeoutId = setTimeout(() => controller.abort(), timeoutMs)
102
114
  const callerSignal = init?.signal ?? null
@@ -166,17 +178,24 @@ export function createHttpClient(opts: HttpOptions) {
166
178
  )
167
179
  }
168
180
 
181
+ // Malformed success responses are HttpError (carrying the 2xx status) so
182
+ // the retry logic treats them as non-transient — a server answering 200
183
+ // with HTML will answer the same way on a retry.
169
184
  const contentType = res.headers.get("content-type") || ""
170
185
  if (!contentType.includes("application/json")) {
171
- throw new Error(
172
- `[PayPal] Unexpected non-JSON response (${contentType}) from ${path}`
186
+ throw new HttpError(
187
+ `[PayPal] Unexpected non-JSON response (${contentType}) from ${path}`,
188
+ res.status
173
189
  )
174
190
  }
175
191
 
176
192
  try {
177
193
  return JSON.parse(text) as T
178
194
  } catch {
179
- throw new Error(`[PayPal] Failed to parse JSON response from ${path}`)
195
+ throw new HttpError(
196
+ `[PayPal] Failed to parse JSON response from ${path}`,
197
+ res.status
198
+ )
180
199
  }
181
200
  }
182
201
 
@@ -21,12 +21,29 @@ export function generateIdempotencyKey(): string {
21
21
  return `pp-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 12)}`
22
22
  }
23
23
 
24
+ export type MarkPaymentCompleteOptions = {
25
+ publishableApiKey?: string
26
+ credentials?: RequestCredentials
27
+ timeoutMs?: number
28
+ signal?: AbortSignal
29
+ }
30
+
24
31
  export async function markPaymentComplete(
25
32
  baseUrl: string,
26
33
  cartId: string,
27
- publishableApiKey?: string
34
+ /** Publishable API key string (legacy signature) or an options object. */
35
+ options?: string | MarkPaymentCompleteOptions
28
36
  ): Promise<Record<string, unknown>> {
29
- const http = createHttpClient({ baseUrl, publishableApiKey })
37
+ const opts: MarkPaymentCompleteOptions =
38
+ typeof options === "string"
39
+ ? { publishableApiKey: options }
40
+ : (options ?? {})
41
+ const http = createHttpClient({
42
+ baseUrl,
43
+ publishableApiKey: opts.publishableApiKey,
44
+ credentials: opts.credentials,
45
+ timeoutMs: opts.timeoutMs,
46
+ })
30
47
  // Retry transient failures: /store/paypal-complete is idempotent (it returns
31
48
  // already_completed for a finished cart), so a network blip right after
32
49
  // capture — the classic "captured but cart not completed" gap — is retried
@@ -37,6 +54,7 @@ export async function markPaymentComplete(
37
54
  method: "POST",
38
55
  headers: { "Content-Type": "application/json" },
39
56
  body: JSON.stringify({ cart_id: cartId }),
57
+ signal: opts.signal,
40
58
  },
41
59
  { retries: 3 }
42
60
  )
@@ -51,11 +69,13 @@ export function createPayPalStoreApi(opts: HttpOptions) {
51
69
  return http.request<PayPalConfig>(`/store/paypal/config${q}`, { signal })
52
70
  },
53
71
 
54
- getSettings() {
55
- return http.request<PayPalSettingsResponse>(`/store/paypal/settings`)
72
+ getSettings(signal?: AbortSignal) {
73
+ return http.request<PayPalSettingsResponse>(`/store/paypal/settings`, {
74
+ signal,
75
+ })
56
76
  },
57
77
 
58
- createOrder(cartId: string, isCardPayment = false) {
78
+ createOrder(cartId: string, isCardPayment = false, signal?: AbortSignal) {
59
79
  return http.request<{ id: string }>(`/store/paypal/create-order`, {
60
80
  method: "POST",
61
81
  headers: {
@@ -69,10 +89,11 @@ export function createPayPalStoreApi(opts: HttpOptions) {
69
89
  cart_id: cartId,
70
90
  is_card_payment: isCardPayment,
71
91
  }),
92
+ signal,
72
93
  })
73
94
  },
74
95
 
75
- captureOrder(cartId: string, orderId: string) {
96
+ captureOrder(cartId: string, orderId: string, signal?: AbortSignal) {
76
97
  // Retry transient failures: capture-order is idempotent server-side (it
77
98
  // short-circuits on an existing capture and PayPal dedupes by request id),
78
99
  // so a network blip during capture doesn't leave the buyer's funds
@@ -83,6 +104,7 @@ export function createPayPalStoreApi(opts: HttpOptions) {
83
104
  method: "POST",
84
105
  headers: { "Content-Type": "application/json" },
85
106
  body: JSON.stringify({ cart_id: cartId, order_id: orderId }),
107
+ signal,
86
108
  },
87
109
  { retries: 2 }
88
110
  )