fix(adyen): send the shopper locale so redirect pages match the Drop-in language - #824
Merged
acasazza merged 8 commits intoSep 1, 2026
Merged
Conversation
…anguage Port of ddfb9a7, which fixed this on the v4 line and never reached v5. Adyen uses two locales and only one of them was travelling: - `locale` in the Core configuration is client-side only — it selects the Drop-in's translation bundle. - the shopper locale in the payment request is what Adyen uses for the pages it renders itself, in particular the hosted page a redirect method sends the shopper to. `payment_request_data` carried `payment_method`, `return_url`, `origin`, `redirect_from_issuer_method`, `shopper_ip` and `browser_info`, but no shopper locale, so Adyen fell back to the merchant account default or the country code. A Drop-in correctly rendered in English then handed over to a Klarna page in Italian for an IT market. `shopper_locale` is now sent, derived from the same value that drives the Drop-in. Commerce Layer's `language_code` is a bare ISO 639-1 code, so `getAdyenShopperLocale` expands it to the `language-REGION` form Adyen expects (`en` -> `en-US`, `it` -> `it-IT`). When a language cannot be expanded confidently it returns undefined and the field is omitted, preserving Adyen's current fallback rather than sending a locale it may reject. `ca` is expanded too, which ddfb9a7 missed. The attribute is snake_case, like the other Commerce Layer attributes in that payload — it is the API that maps them onto Adyen's camelCase names. ddfb9a7 sent `shopperLocale` instead, which is the name Adyen uses but not the one this payload takes. `AdyenPaymentConfig.shopperLocale` overrides the derived value, camelCase as a component option. Unlike in ddfb9a7 it also moves the Drop-in's own `locale`: an integration that sets it would otherwise get a Klarna page in Italian behind a Drop-in in English, which is the original mismatch chosen on purpose. Nothing changes for an integration that does not set it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two things the shopperLocale work walked past. The `locale` prop defaulted to `en_US`. Adyen's client normalizes that — it replaces the underscore and then matches against its supported list — so it worked, but the payment request has no such normalizer, and the same string in both places would not have. It is now a named `DEFAULT_LOCALE` in the `language-REGION` form, which is also Adyen's own fallback value. `AdyenGateway` cast `order.language_code` to `StripeElementLocale`, importing a Stripe type to feed an Adyen prop. It compiled only because that prop is `string`. `language_code` is `string | null` and the prop is `string | undefined`, so the cast was laundering the null: `?? undefined` does that without claiming the value is something it is not. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`payment_request_data` is a Commerce Layer payload and the API maps its snake_case keys onto Adyen's camelCase names. `shopperInteraction` was written with Adyen's own spelling, so it went into the payload under a name that side does not take — the same mistake the shopper locale nearly shipped with. `Ecommerce` is Adyen's own default for this field, so a payment that has been working was very likely not relying on it arriving. It arrives now. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…accepted
Selecting a payment method could enable `PlaceOrderButton` while the privacy &
terms checkbox was still unchecked.
Acceptance travelled from `<PrivacyAndTermsCheckbox>` to `PlaceOrderButton`
through `localStorage["privacy-terms"]`, and the write that happens at *mount*
notified nobody. A `"true"` left behind by an earlier visit — the unmount
cleanup only runs on a React unmount, never on a tab close or a hard navigation
— was read by `PlaceOrderContainer` as `isPermitted: true`. The checkbox then
mounted and correctly wrote `"false"`, but none of the container's effect
dependencies changed, so `isPermitted` stayed stale. The button's own effect
does re-run on `paymentSource?.id`, i.e. exactly when a payment method is
picked, and it read that stale value.
Acceptance now lives in a module-level store keyed by order id, which notifies
its subscribers. It is deliberately not persisted: a reload starts from
"not accepted", so what the shopper sees can no longer diverge from what gates
the button.
- add `termsAcceptanceStore`: in-memory, per-order, notifying
- add the public `useTermsAndConditions()` hook, so a custom consent control has
a supported channel now that `localStorage` is gone
- `placeOrderPermitted` takes `termsAccepted` as a parameter instead of reading
a global, and reports `termsBlocking`
- warn in development when acceptance is required but no control collects it,
from an effect so a late-mounting checkbox cannot raise a false alarm
- drop `PLACE_ORDER_RECHECK_EVENT`, made redundant by the store
- drop a dead `isFree && !isPermitted` line: `setNotPermitted` is a state
setter, so the branches below always overwrote it in the same effect pass
Two existing tests claimed to cover this and covered nothing: the suite mocks
`getCardDetails` to `{ brand: "" }`, which already falsifies the first factor of
the enabling condition, so they passed on the wrong factor and would have passed
with the gate deleted. Both now run with the condition live.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Selecting a Drop-in method that needs no input — Klarna, PayPal — enabled `PlaceOrderButton` while privacy & terms were still unaccepted, undoing the gate added in 819280f. `onSelect` and `onChange` answered `isValid` by writing `placeOrderButtonRef.current.disabled = false` straight onto the DOM node, which skips `isPermitted` — where the terms check lives — entirely. React never repaired it either: its own `disabled` prop had not changed, so no re-render reconciled the node. Observed on a live checkout as a fiber saying `disabled: true` over a DOM saying `disabled: false`, with the reducer reporting `isPermitted: false, termsBlocking: true`. Both writes were redundant as well as harmful. Each sits right after `ref.current.onsubmit = …` and `setPaymentRef({ ref })`, and `onsubmit` is in the button's own effect dependencies — so the button already re-runs and enables itself, but through `&& isPermitted`, which respects the terms. The two remaining writes in the post-authorization paths are left alone: they precede a programmatic `.click()` and belong to a different flow, worth revisiting on its own terms. - add regression coverage for both entry points, asserting on the DOM node's `disabled` rather than on React state — the blind spot that let the vacuous `place-order.spec.tsx` "disabled" tests pass on the wrong factor - capture the Drop-in's options in the test double, so `onSelect` is reachable Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The two remaining `placeOrderButtonRef.current.disabled = false` writes look like leftovers of the pattern just removed from the `isValid` handlers, and the obvious next cleanup is to delete them for symmetry or swap the `.click()` for `setPlaceOrder`. Both would break paying by redirect. By the time these run Adyen has already authorized the payment and only the order is left to place. Terms acceptance lives in memory and does not survive the reload a redirect method (Klarna, iDEAL) causes, so the button is legitimately disabled on the way back — and `.click()` on a disabled button is a no-op, which would leave the shopper charged for an order that is never placed. The click cannot become `setPlaceOrder` either: `handleClick` additionally guards against already-placed and draft orders, drives the loading state, and fires the integrator's `onClick`. The Apple/Google Pay branch alongside does call `setPlaceOrder` directly, but express payments bypass that logic on purpose. Comments only — no behaviour change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`placeOrderPermitted` arms the gate on `privacyUrl && termsUrl`, so an order carrying exactly one of the two silently requires no acceptance: the checkbox is never rendered and the button enables with nothing ticked. Nothing distinguishes that from a deliberate opt-out, which makes it the last remaining way to reach a live place-order button without accepting anything. The gate itself is left alone. Requiring acceptance on a single URL would be the worse trade: `<PrivacyAndTermsCheckbox>` renders a link for each, so one of them would point nowhere — and it would start blocking checkouts that integrators have had working. The ambiguity is surfaced instead, following the `useMissingTermsCheckboxWarning` precedent: development-only, from an effect, and silent in production. Both URLs absent stays quiet — that is the opt-out, not a mistake. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
fix(place-order): keep the button disabled until privacy & terms are accepted
commit: |
pfferrari
approved these changes
Aug 31, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Targets
fix/adyen-dropin-session-key(#818) rather thanmain, so it lands inside the work already open there.The bug
Adyen uses two locales and only one of them was travelling:
localein the Core configuration is client-side only — it selects the Drop-in's translation bundle.payment_request_datacarriedpayment_method,return_url,origin,redirect_from_issuer_method,shopper_ipandbrowser_info, but no shopper locale, so Adyen fell back to the merchant account default or the country code. A Drop-in correctly rendered in English then handed over to a Klarna page in Italian for an IT market.This is a port of
ddfb9a7a, which fixed it on the v4 line and never reached v5.What changed
shopper_localeis sent, derived from the same value that drives the Drop-in. Commerce Layer'slanguage_codeis a bare ISO 639-1 code, sogetAdyenShopperLocaleexpands it to thelanguage-REGIONform Adyen expects (en→en-US,it→it-IT). A language it cannot expand confidently yieldsundefinedand the key is omitted, preserving Adyen's own fallback rather than sending a locale it may reject.cais expanded too, whichddfb9a7amissed.AdyenPaymentConfig.shopperLocaleoverrides the derived value, camelCase as a component option. Unlike inddfb9a7ait also moves the Drop-in's ownlocale: an integration that sets it would otherwise get a Klarna page in Italian behind a Drop-in in English, which is the original mismatch chosen on purpose. Nothing changes for an integration that does not set it.Attributes are snake_case.
ddfb9a7asentshopperLocale, which is Adyen's name but not the one this payload takes — the API maps snake_case attributes onto Adyen's camelCase.shopperInteractionhad the same problem and is nowshopper_interaction;Ecommerceis Adyen's default for a request carryingbrowser_info, which is why nothing visibly broke while it was not arriving.Two cleanups in their own commit: the
localeprop defaulted toen_US, now a namedDEFAULT_LOCALEin thelanguage-REGIONform Adyen documents (their client normalizes the underscore, the payment request does not); andAdyenGatewayno longer castsorder.language_codeto Stripe'sStripeElementLocaleto feed an Adyen prop —?? undefinedlaunders the null without the lie.Commits
ff111221fix(adyen): send shopper_locale so redirect pages match the Drop-in languagec7ec0082chore(adyen): spell the default locale the way Adyen documents it58844107fix(adyen): send shopper_interaction in snake_case like its neighboursThe port is revertible on its own.
Tests
specs/payment_source/AdyenPayment.shopperLocale.spec.tsx— 6 cases: derived from the order language, not from the country code, config override, the override moving the Drop-in with it, omission when the language cannot be expanded, fallback to thelocaleprop.specs/utils/adyenShopperLocale.spec.ts— 4 cases on the expansion, includingen_USandPT_brnormalization.Full suite green: 1032 tests in react-components, 129 in core-components, 99 in react-hooks-components.
pnpm typecheckunchanged from the baseline on this branch (22 pre-existing errors, none in the touched files).Still unverified
The snake_case attribute name is not documented — the API reference lists only
payment_method,origin,return_urlandbrowser_infoinsidepayment_request_data. If a redirect method still comes up in the wrong language, the attribute name is the first thing to check against a real order.🤖 Generated with Claude Code