Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions .github/workflows/gh-pages.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 📦
Expand All @@ -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/

Expand All @@ -64,4 +64,4 @@ jobs:
steps:
- name: Deploy to GitHub Pages 🚀
id: deployment
uses: actions/deploy-pages@v4
uses: actions/deploy-pages@v5
6 changes: 3 additions & 3 deletions .github/workflows/pgk-pr-new.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
10 changes: 6 additions & 4 deletions .github/workflows/publish.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 📦
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion lerna.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion packages/react-components/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -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(
<CommerceLayerContext.Provider value={{ accessToken: "test-token" }}>
<OrderContext.Provider
value={{
...defaultOrderContext,
orderId: order.id,
order,
updateOrder: vi.fn(),
getOrderByFields: vi.fn().mockResolvedValue({
status: "pending",
payment_status: "unpaid",
}),
}}
>
<CustomerContext.Provider value={{}}>
<PlaceOrderContext.Provider value={defaultPlaceOrderContext}>
<PaymentMethodContext.Provider
value={
{
...defaultPaymentMethodContext,
_isProvided: true as const,
paymentSource: PAYMENT_SOURCE,
currentPaymentMethodType: "scheme",
setPaymentSource,
setPaymentMethodErrors: vi.fn(),
setPaymentRef: vi.fn(),
errors: [],
// biome-ignore lint/suspicious/noExplicitAny: test cast
} as any
}
>
<AdyenPayment
clientKey="test_CLIENTKEY"
config={
shopperLocaleConfig
? { shopperLocale: shopperLocaleConfig }
: {}
}
/>
</PaymentMethodContext.Provider>
</PlaceOrderContext.Provider>
</CustomerContext.Provider>
</OrderContext.Provider>
</CommerceLayerContext.Provider>,
)
})
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")
})
})
36 changes: 36 additions & 0 deletions packages/react-components/specs/utils/adyenShopperLocale.spec.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
Loading
Loading