diff --git a/.github/workflows/gh-pages.yaml b/.github/workflows/gh-pages.yaml index dec45682e..81fa15cb4 100644 --- a/.github/workflows/gh-pages.yaml +++ b/.github/workflows/gh-pages.yaml @@ -25,17 +25,17 @@ jobs: steps: - name: Checkout 🛎️ - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: pnpm 🧰 - uses: pnpm/action-setup@v3 + uses: pnpm/action-setup@v6 with: version: 8 - name: Node 🧰 - uses: actions/setup-node@v3 + uses: actions/setup-node@v7 with: - node-version: "latest" + node-version: 22.x cache: "pnpm" - name: Install 📦 @@ -45,10 +45,10 @@ jobs: run: pnpm docs:build - name: Setup Pages 🧰 - uses: actions/configure-pages@v4 + uses: actions/configure-pages@v6 - name: Upload artifact 🚀 - uses: actions/upload-pages-artifact@v3 + uses: actions/upload-pages-artifact@v5 with: path: ./packages/docs/dist/ @@ -64,4 +64,4 @@ jobs: steps: - name: Deploy to GitHub Pages 🚀 id: deployment - uses: actions/deploy-pages@v4 + uses: actions/deploy-pages@v5 diff --git a/.github/workflows/pgk-pr-new.yaml b/.github/workflows/pgk-pr-new.yaml index 42cd12f73..df820f898 100644 --- a/.github/workflows/pgk-pr-new.yaml +++ b/.github/workflows/pgk-pr-new.yaml @@ -19,15 +19,15 @@ jobs: steps: - name: Checkout 🛎️ - uses: actions/checkout@v4 + uses: actions/checkout@v7 with: persist-credentials: false - name: pnpm 🧰 - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@v6 - name: Node 🧰 - uses: actions/setup-node@v4 + uses: actions/setup-node@v7 with: node-version: 22.x diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index 266f206cb..bac611c2b 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -14,19 +14,21 @@ jobs: steps: - name: Checkout 🛎️ - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: pnpm 🧰 - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@v6 - name: Node 🧰 - uses: actions/setup-node@v5 + uses: actions/setup-node@v7 with: - node-version: 20.x + node-version: 22.x registry-url: https://registry.npmjs.org cache: 'pnpm' - name: Update npm + # Keep this in step with node-version above: npm 12 requires Node >=22.22.2, so on the + # previous 20.x runner `npm@latest` failed with EBADENGINE. run: npm install -g npm@latest - name: Install 📦 diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 077a5670f..56eb7a449 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -10,10 +10,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout 🛎️ - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Create a draft GitHub release 🎁 - uses: softprops/action-gh-release@v1 + uses: softprops/action-gh-release@v3 with: token: ${{ secrets.COMMERCELAYER_CI_TOKEN }} draft: true diff --git a/lerna.json b/lerna.json index e8678aaab..7fb960290 100644 --- a/lerna.json +++ b/lerna.json @@ -2,7 +2,7 @@ "$schema": "node_modules/lerna/schemas/lerna-schema.json", "useNx": false, "npmClient": "pnpm", - "version": "4.29.7", + "version": "4.29.8-beta.0", "command": { "version": { "preid": "beta" diff --git a/packages/react-components/package.json b/packages/react-components/package.json index 844bf7e79..8710c4ffc 100644 --- a/packages/react-components/package.json +++ b/packages/react-components/package.json @@ -1,6 +1,6 @@ { "name": "@commercelayer/react-components", - "version": "4.29.7", + "version": "4.29.8-beta.0", "description": "The Official Commerce Layer React Components", "main": "lib/cjs/index.js", "module": "lib/esm/index.js", diff --git a/packages/react-components/specs/payment_source/AdyenPayment.shopperLocale.spec.tsx b/packages/react-components/specs/payment_source/AdyenPayment.shopperLocale.spec.tsx new file mode 100644 index 000000000..6f2aea1fb --- /dev/null +++ b/packages/react-components/specs/payment_source/AdyenPayment.shopperLocale.spec.tsx @@ -0,0 +1,206 @@ +// The Drop-in was translated correctly but the Klarna page it redirects to came up in Italian. +// `locale` in the Core configuration is client-side only — it picks the Drop-in's translation +// bundle and never reaches Adyen. The language Adyen uses for the hosted pages it renders +// itself comes from `shopperLocale` in the payment request, which was not being sent. +import { act, render } from "@testing-library/react" +import { AdyenPayment } from "#components/payment_source/AdyenPayment" +import CommerceLayerContext from "#context/CommerceLayerContext" +import CustomerContext from "#context/CustomerContext" +import OrderContext, { defaultOrderContext } from "#context/OrderContext" +import PaymentMethodContext, { + defaultPaymentMethodContext, +} from "#context/PaymentMethodContext" +import PlaceOrderContext, { + defaultPlaceOrderContext, +} from "#context/PlaceOrderContext" + +const adyen = vi.hoisted(() => ({ + // biome-ignore lint/suspicious/noExplicitAny: test cast + captured: { options: null as any }, +})) + +vi.mock("@adyen/adyen-web/auto", () => ({ + // biome-ignore lint/suspicious/noExplicitAny: test cast + AdyenCheckout: vi.fn(async (options: any) => { + adyen.captured.options = options + return { update: vi.fn() } + }), + Dropin: class FakeDropin { + mount(): this { + return this + } + submit(): void {} + remove(): void {} + unmount(): this { + return this + } + handleAction(): void {} + }, +})) + +vi.mock("#utils/getPublicIp", () => ({ + getPublicIP: vi.fn(async () => "127.0.0.1"), +})) + +const PAYMENT_SOURCE = { + id: "ps-1", + type: "adyen_payments", + payment_methods: { + paymentMethods: [{ type: "scheme" }, { type: "klarna_account" }], + }, +} + +/** + * Mounts the component for an order in `languageCode`, submits a card, and returns the + * `payment_request_data` that went to the API. + */ +async function submitAndCapturePaymentRequest({ + languageCode, + shopperLocaleConfig, +}: { + languageCode?: string + shopperLocaleConfig?: string + // biome-ignore lint/suspicious/noExplicitAny: test cast +}): Promise<{ paymentRequestData: any; dropInLocale: string }> { + const setPaymentSource = vi.fn(async () => ({ + ...PAYMENT_SOURCE, + payment_response: {}, + })) + // biome-ignore lint/suspicious/noExplicitAny: test cast + const order: any = { + id: "order-1", + status: "pending", + payment_status: "unpaid", + currency_code: "EUR", + country_code: "IT", + language_code: languageCode, + total_amount_with_taxes_cents: 1000, + line_items: [], + } + + await act(async () => { + render( + + + + + + + + + + + , + ) + }) + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)) + }) + + await act(async () => { + adyen.captured.options.onSubmit( + { data: { paymentMethod: { type: "scheme" } }, isValid: true }, + { mount: vi.fn() }, + { resolve: vi.fn(), reject: vi.fn() }, + ) + await new Promise((resolve) => setTimeout(resolve, 0)) + }) + + const call = setPaymentSource.mock.calls + // biome-ignore lint/suspicious/noExplicitAny: test cast + .map(([args]: any[]) => args) + // biome-ignore lint/suspicious/noExplicitAny: test cast + .find((args: any) => args?.attributes?.payment_request_data != null) + return { + paymentRequestData: call?.attributes?.payment_request_data, + dropInLocale: adyen.captured.options.locale, + } +} + +describe("AdyenPayment shopperLocale", () => { + beforeEach(() => { + vi.clearAllMocks() + adyen.captured.options = null + }) + + it("sends shopperLocale derived from the order language", async () => { + const { paymentRequestData, dropInLocale } = + await submitAndCapturePaymentRequest({ + languageCode: "en", + }) + + // The Drop-in keeps the bare language it always used — this is only about what Adyen gets. + expect(dropInLocale).toBe("en") + expect(paymentRequestData.shopperLocale).toBe("en-US") + }) + + it("does not take the language from the country code", async () => { + // The bug: an English order in an Italian market rendered a Klarna page in Italian, because + // nothing carried the language and Adyen fell back to the country. + const { paymentRequestData } = await submitAndCapturePaymentRequest({ + languageCode: "en", + }) + + expect(paymentRequestData.shopperLocale).not.toContain("it") + }) + + it("lets the config override the derived value", async () => { + const { paymentRequestData } = await submitAndCapturePaymentRequest({ + languageCode: "en", + shopperLocaleConfig: "en-GB", + }) + + expect(paymentRequestData.shopperLocale).toBe("en-GB") + }) + + it("omits shopperLocale when the language cannot be expanded", async () => { + const { paymentRequestData } = await submitAndCapturePaymentRequest({ + languageCode: "xx", + }) + + // Preserves Adyen's existing fallback rather than sending something it may reject. + expect(paymentRequestData).not.toHaveProperty("shopperLocale") + }) + + it("falls back to the locale prop when the order has no language", async () => { + const { paymentRequestData, dropInLocale } = await submitAndCapturePaymentRequest({ + languageCode: undefined, + }) + + // Same source the Drop-in falls back to, so the two stay consistent. + expect(dropInLocale).toBe("en_US") + expect(paymentRequestData.shopperLocale).toBe("en-US") + }) +}) diff --git a/packages/react-components/specs/utils/adyenShopperLocale.spec.ts b/packages/react-components/specs/utils/adyenShopperLocale.spec.ts new file mode 100644 index 000000000..5ffb31c20 --- /dev/null +++ b/packages/react-components/specs/utils/adyenShopperLocale.spec.ts @@ -0,0 +1,36 @@ +// Adyen's `locale` (Drop-in translations, client-side) and `shopperLocale` (payment request, +// used for the hosted pages Adyen renders itself) are different things. Commerce Layer's +// `order.language_code` is a bare ISO 639-1 code, so it has to be expanded for the latter. +import { getAdyenShopperLocale } from "#utils/adyenShopperLocale" + +describe("getAdyenShopperLocale", () => { + it("expands a bare language whose region mirrors it", () => { + expect(getAdyenShopperLocale("it")).toBe("it-IT") + expect(getAdyenShopperLocale("de")).toBe("de-DE") + expect(getAdyenShopperLocale("fr")).toBe("fr-FR") + expect(getAdyenShopperLocale("nl")).toBe("nl-NL") + }) + + it("expands a bare language whose region differs", () => { + // The reported case: an English Drop-in handing over to a Klarna page. `en-EN` is not a + // locale, so it needs the explicit mapping. + expect(getAdyenShopperLocale("en")).toBe("en-US") + expect(getAdyenShopperLocale("sv")).toBe("sv-SE") + expect(getAdyenShopperLocale("cs")).toBe("cs-CZ") + expect(getAdyenShopperLocale("ja")).toBe("ja-JP") + }) + + it("normalises a locale that already carries a region", () => { + expect(getAdyenShopperLocale("en-US")).toBe("en-US") + expect(getAdyenShopperLocale("en_US")).toBe("en-US") + expect(getAdyenShopperLocale("PT_br")).toBe("pt-BR") + }) + + it("returns undefined rather than guessing", () => { + // Preserves Adyen's own fallback instead of sending a locale it may reject. + expect(getAdyenShopperLocale(undefined)).toBeUndefined() + expect(getAdyenShopperLocale(null)).toBeUndefined() + expect(getAdyenShopperLocale("")).toBeUndefined() + expect(getAdyenShopperLocale("xx")).toBeUndefined() + }) +}) diff --git a/packages/react-components/src/components/payment_source/AdyenPayment.tsx b/packages/react-components/src/components/payment_source/AdyenPayment.tsx index 55473ce5d..074fc92bf 100644 --- a/packages/react-components/src/components/payment_source/AdyenPayment.tsx +++ b/packages/react-components/src/components/payment_source/AdyenPayment.tsx @@ -32,6 +32,7 @@ import CustomerContext from "#context/CustomerContext" import OrderContext from "#context/OrderContext" import PaymentMethodContext from "#context/PaymentMethodContext" import PlaceOrderContext from "#context/PlaceOrderContext" +import { getAdyenShopperLocale } from "#utils/adyenShopperLocale" import browserInfo, { cleanUrlBy } from "#utils/browserInfo" import { getPublicIP } from "#utils/getPublicIp" import { hasSubscriptions } from "#utils/hasSubscriptions" @@ -111,6 +112,20 @@ export interface AdyenPaymentConfig { */ onSelect?: (component: UIElement) => void giftcardErrorComponent?: (message: string) => JSX.Element + /** + * The locale Adyen should use for anything **it** renders — in particular the hosted page a + * redirect payment method sends the shopper to (Klarna, iDEAL, …). Sent as `shopperLocale` + * in the payment request. + * + * This is not the same as the Drop-in's `locale`, which only selects the client-side + * translation bundle. Without a `shopperLocale` Adyen falls back to the merchant account + * default or the country code, so a Drop-in in English can hand over to a Klarna page in + * Italian. + * + * @default derived from `order.language_code` (see `getAdyenShopperLocale`) + * @example "en-US" + */ + shopperLocale?: string } interface Props { @@ -138,6 +153,7 @@ export function AdyenPayment({ onReady, onSelect, subscriptionPaymentMethods, + shopperLocale: shopperLocaleConfig, } = { ...defaultConfig, ...config, @@ -162,6 +178,13 @@ export function AdyenPayment({ const { customers } = useContext(CustomerContext) const ref = useRef(null) const dropinRef = useRef(null) + // Two distinct locales, deliberately derived from the same source (see the Core `options` + // below, which uses the first one): `dropInLocale` only selects the Drop-in's client-side + // translations, while `shopperLocale` travels with the payment request and is what Adyen + // uses for the hosted pages it renders itself. + const dropInLocale = order?.language_code ?? locale + const shopperLocale = + shopperLocaleConfig ?? getAdyenShopperLocale(dropInLocale) const handleSubmit = async ( e: FormEvent, ): Promise => { @@ -297,6 +320,11 @@ export function AdyenPayment({ redirect_from_issuer_method: "GET", shopper_ip: shopperIp, shopperInteraction: "Ecommerce", + // The language Adyen renders its own hosted pages in (the Klarna screen a redirect + // method hands over to). The Drop-in's `locale` is client-side only and never reaches + // Adyen, so without this the hosted page falls back to the account default or the + // country code. + ...(shopperLocale != null ? { shopperLocale } : {}), browser_info: { ...browserInfo(), }, @@ -536,7 +564,7 @@ export function AdyenPayment({ : paymentMethodsResponse.paymentMethods } const options = { - locale: order?.language_code ?? locale, + locale: dropInLocale, environment, clientKey, amount: { diff --git a/packages/react-components/src/utils/adyenShopperLocale.ts b/packages/react-components/src/utils/adyenShopperLocale.ts new file mode 100644 index 000000000..a9ce74e31 --- /dev/null +++ b/packages/react-components/src/utils/adyenShopperLocale.ts @@ -0,0 +1,84 @@ +/** + * Adyen uses two different locales, and they are not interchangeable: + * + * - `locale` in the Core configuration is **client-side only** — it picks the Drop-in's + * translation bundle. + * - `shopperLocale` in the payment request is what Adyen uses for anything it renders + * itself, in particular the hosted pages a redirect payment method sends the shopper to + * (Klarna, iDEAL, …). Without it Adyen falls back to the merchant account default or the + * country code, which is why a Drop-in in English could hand over to a Klarna page in + * Italian. + * + * Adyen expects a language code combined with a region (`en-US`, `it-IT`). Commerce Layer's + * `order.language_code` is a bare ISO 639-1 code (`en`, `it`), so it has to be expanded. + */ + +/** + * Region to pair with a bare language code. Only languages whose Adyen-supported locale + * cannot be derived by uppercasing the language itself need an entry here. + */ +const REGION_BY_LANGUAGE: Record = { + en: "US", + zh: "CN", + ar: "AE", + he: "IL", + ja: "JP", + ko: "KR", + uk: "UA", + el: "GR", + cs: "CZ", + da: "DK", + sv: "SE", + nb: "NO", + no: "NO", + sl: "SI", + et: "EE", + be: "BY", +} + +/** Languages for which `xx` → `xx-XX` is the Adyen-supported locale (it → it-IT, and so on). */ +const SELF_REGION_LANGUAGES = new Set([ + "it", + "de", + "fr", + "es", + "pt", + "nl", + "pl", + "fi", + "hu", + "ro", + "ru", + "sk", + "tr", + "hr", + "lt", + "lv", + "bg", + "is", +]) + +/** + * Resolves the `shopperLocale` to send with an Adyen payment request. + * + * @param locale the locale already driving the Drop-in — `order.language_code`, or the + * component's `locale` prop. Accepts a bare language (`en`), or a language and region in + * either separator (`en-US`, `en_US`). + * @returns an Adyen-style `language-REGION` locale, or `undefined` when the language cannot + * be expanded confidently. Returning `undefined` deliberately preserves Adyen's own + * fallback rather than sending a locale it may reject. + */ +export function getAdyenShopperLocale( + locale?: string | null, +): string | undefined { + if (locale == null) return undefined + const [rawLanguage, rawRegion] = locale.replace("_", "-").split("-") + const language = rawLanguage?.toLowerCase() + if (!language) return undefined + // Already carries a region: normalise the separator and casing and trust it. + if (rawRegion) return `${language}-${rawRegion.toUpperCase()}` + const region = + REGION_BY_LANGUAGE[language] ?? + (SELF_REGION_LANGUAGES.has(language) ? language.toUpperCase() : undefined) + return region ? `${language}-${region}` : undefined +}