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
+}