@easypayment/medusa-paypal-ui 1.2.7 → 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.
- package/CHANGELOG.md +82 -0
- package/LICENSE +21 -1
- package/README.md +8 -2
- package/dist/index.cjs +605 -678
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +101 -26
- package/dist/index.d.ts +101 -26
- package/dist/index.mjs +601 -678
- package/dist/index.mjs.map +1 -1
- package/dist/order.cjs +12 -11
- package/dist/order.cjs.map +1 -1
- package/dist/order.d.cts +4 -2
- package/dist/order.d.ts +4 -2
- package/dist/order.mjs +11 -11
- package/dist/order.mjs.map +1 -1
- package/package.json +43 -4
- package/src/adapters/MedusaNextPayPalAdapter.tsx +48 -10
- package/src/client/config-cache.ts +96 -0
- package/src/client/http.ts +24 -5
- package/src/client/paypal.ts +28 -6
- package/src/components/PayPalAdvancedCard.tsx +88 -214
- package/src/components/PayPalPaymentSection.tsx +17 -6
- package/src/components/PayPalProvider.tsx +4 -1
- package/src/components/PayPalSmartButtons.tsx +92 -224
- package/src/components/internal/ui.tsx +221 -0
- package/src/constants.ts +6 -4
- package/src/hooks/usePayPalConfig.ts +43 -32
- package/src/hooks/usePayPalPaymentMethods.ts +59 -54
- package/src/index.ts +4 -0
- package/src/order.ts +16 -8
- package/src/utils/processing-overlay.ts +25 -21
package/dist/order.cjs.map
CHANGED
|
@@ -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
|
|
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
|
@@ -7,8 +7,10 @@
|
|
|
7
7
|
*/
|
|
8
8
|
declare const PAYPAL_WALLET_PROVIDER_ID: "pp_paypal_paypal";
|
|
9
9
|
declare const PAYPAL_CARD_PROVIDER_ID: "pp_paypal_card_paypal_card";
|
|
10
|
+
declare const PAYPAL_PROVIDER_IDS: readonly ["pp_paypal_paypal", "pp_paypal_card_paypal_card"];
|
|
11
|
+
type PayPalProviderId = (typeof PAYPAL_PROVIDER_IDS)[number];
|
|
10
12
|
/** True when the provider id belongs to this PayPal plugin. */
|
|
11
|
-
declare function isPayPalProviderId(providerId?: string | null):
|
|
13
|
+
declare function isPayPalProviderId(providerId?: string | null): providerId is PayPalProviderId;
|
|
12
14
|
|
|
13
15
|
/**
|
|
14
16
|
* Server-safe helpers for displaying PayPal payments on order / confirmation
|
|
@@ -72,4 +74,4 @@ declare function fetchPayPalConfig(opts: {
|
|
|
72
74
|
signal?: AbortSignal;
|
|
73
75
|
}): Promise<PayPalConfigTitles>;
|
|
74
76
|
|
|
75
|
-
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
|
@@ -7,8 +7,10 @@
|
|
|
7
7
|
*/
|
|
8
8
|
declare const PAYPAL_WALLET_PROVIDER_ID: "pp_paypal_paypal";
|
|
9
9
|
declare const PAYPAL_CARD_PROVIDER_ID: "pp_paypal_card_paypal_card";
|
|
10
|
+
declare const PAYPAL_PROVIDER_IDS: readonly ["pp_paypal_paypal", "pp_paypal_card_paypal_card"];
|
|
11
|
+
type PayPalProviderId = (typeof PAYPAL_PROVIDER_IDS)[number];
|
|
10
12
|
/** True when the provider id belongs to this PayPal plugin. */
|
|
11
|
-
declare function isPayPalProviderId(providerId?: string | null):
|
|
13
|
+
declare function isPayPalProviderId(providerId?: string | null): providerId is PayPalProviderId;
|
|
12
14
|
|
|
13
15
|
/**
|
|
14
16
|
* Server-safe helpers for displaying PayPal payments on order / confirmation
|
|
@@ -72,4 +74,4 @@ declare function fetchPayPalConfig(opts: {
|
|
|
72
74
|
signal?: AbortSignal;
|
|
73
75
|
}): Promise<PayPalConfigTitles>;
|
|
74
76
|
|
|
75
|
-
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
|
@@ -7,9 +7,7 @@ var PAYPAL_PROVIDER_IDS = [
|
|
|
7
7
|
];
|
|
8
8
|
function isPayPalProviderId(providerId) {
|
|
9
9
|
if (!providerId) return false;
|
|
10
|
-
return PAYPAL_PROVIDER_IDS.includes(
|
|
11
|
-
providerId
|
|
12
|
-
);
|
|
10
|
+
return PAYPAL_PROVIDER_IDS.includes(providerId);
|
|
13
11
|
}
|
|
14
12
|
|
|
15
13
|
// src/order.ts
|
|
@@ -36,12 +34,12 @@ async function fetchPayPalConfig(opts) {
|
|
|
36
34
|
}
|
|
37
35
|
try {
|
|
38
36
|
const timeoutMs = 3e4;
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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);
|
|
45
43
|
}
|
|
46
44
|
let res;
|
|
47
45
|
try {
|
|
@@ -50,10 +48,11 @@ async function fetchPayPalConfig(opts) {
|
|
|
50
48
|
accept: "application/json",
|
|
51
49
|
...publishableApiKey ? { "x-publishable-api-key": publishableApiKey } : {}
|
|
52
50
|
},
|
|
53
|
-
signal:
|
|
51
|
+
signal: controller.signal
|
|
54
52
|
});
|
|
55
53
|
} finally {
|
|
56
|
-
|
|
54
|
+
clearTimeout(timeoutId);
|
|
55
|
+
if (signal) signal.removeEventListener("abort", onCallerAbort);
|
|
57
56
|
}
|
|
58
57
|
if (!res.ok) {
|
|
59
58
|
return {};
|
|
@@ -69,6 +68,7 @@ async function fetchPayPalConfig(opts) {
|
|
|
69
68
|
}
|
|
70
69
|
export {
|
|
71
70
|
PAYPAL_CARD_PROVIDER_ID,
|
|
71
|
+
PAYPAL_PROVIDER_IDS,
|
|
72
72
|
PAYPAL_WALLET_PROVIDER_ID,
|
|
73
73
|
fetchPayPalConfig,
|
|
74
74
|
getPaymentLabel,
|
package/dist/order.mjs.map
CHANGED
|
@@ -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\n/** True when the provider id belongs to this PayPal plugin. */\nexport function isPayPalProviderId(providerId?: string | null):
|
|
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.
|
|
4
|
-
"description": "
|
|
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
|
-
<
|
|
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({
|
|
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
|
+
}
|
package/src/client/http.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
195
|
+
throw new HttpError(
|
|
196
|
+
`[PayPal] Failed to parse JSON response from ${path}`,
|
|
197
|
+
res.status
|
|
198
|
+
)
|
|
180
199
|
}
|
|
181
200
|
}
|
|
182
201
|
|
package/src/client/paypal.ts
CHANGED
|
@@ -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
|
-
|
|
34
|
+
/** Publishable API key string (legacy signature) or an options object. */
|
|
35
|
+
options?: string | MarkPaymentCompleteOptions
|
|
28
36
|
): Promise<Record<string, unknown>> {
|
|
29
|
-
const
|
|
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
|
)
|