Visitar URL original
Translate storefront text, starting with French by sbolinger-godaddy · Pull Request #1524 · godaddy/javascript · GitHub
Skip to content

Translate storefront text, starting with French - #1524

Merged
sbolinger-godaddy merged 4 commits into
mainfrom
feat/storefront-i18n
Oct 30, 2026
Merged

sbolinger-godaddy merged 4 commits into
mainfrom
feat/storefront-i18n

Conversation

@sbolinger-godaddy

@sbolinger-godaddy sbolinger-godaddy commented Oct 29, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

The storefront's text can now be translated. French ships first, and prices follow the provider's locale and the store's currency.

import { frFR } from '@godaddy/gd-commerce-storefront/messages/fr-FR';

<CommerceStorefront locale='fr-FR' messages={frFR}>
  • messages is separate from locale. locale formats prices and dates. messages holds the text, and a site can pass only the keys it wants to change. The rest stay English.
  • French is its own subpath export (./messages/fr-FR), so English-only bundles don't include it.
  • Messages are plain data. They use {name} placeholders and plural forms chosen with Intl.PluralRules. This works from JSON or as props from a Next server component, and adds no dependency.
  • Errors are translated when they're shown. Package errors carry a message key. Server failures are matched by their response code to messages.errorCodes. English still shows the server's own text. Other languages show a translated generic message for unknown codes.
  • Error events from onEvent keep the server's code, since message is now translated.
  • New hooks: useMessage() and useMoneyRange() for custom parts. enUS and formatMessage are exported too.
  • Price ranges use Intl.NumberFormat#formatRange, with a fallback for older runtimes.
  • getCartSummaryTotals takes a fallback currency, such as the store's, instead of always assuming USD.

Behavior change

Sites that already set a non-English locale will see price ranges in that locale's style. German, for example, changes from 20,00 € – 25,00 € to 20,00–25,00 €. One existing test was updated for this. English ranges are unchanged.

Screenshots

These come from the local component test app, with locale='fr-FR', messages={frFR} and a mock store in EUR.

Catalog Product page
Catalog Product page
Missing options Cart drawer
Missing options Cart drawer
Sample cart, before a store is connected English, after switching back
Sample cart English

Product names, descriptions and attribute names such as "Color" come from the catalog and stay as the merchant entered them.

For review

  • French wording needs a native speaker's review. Where strings overlap, they follow @godaddy/localizations. Two phrasings are deliberate, because the package can't know a word's grammatical gender:
    • "Choisir : taille", not "Choisir la taille".
    • "Article ajouté à votre panier : {name}".
  • Sample products stay priced in USD.
  • Not included: passing the language to hosted checkout. The checkout field still needs confirming with that team.

Testing

  • Storefront: 159 tests pass, including 26 new ones. Typecheck, lint and build pass. The build emits dist/messages/fr-FR.js, which has no imports. The subpath's types resolve under bundler, nodenext and node (through typesVersions).
  • Server: unchanged; its 256 tests still pass.
  • Component test app, against the packed tarball: all 19 English smoke checks pass. A new French walkthrough passes all 5 checks. It also fails if any English package text appears on a French page, or if the console logs errors.

🤖 Generated with Claude Code

Every string the storefront shows is now a key in a typed message set.
CommerceProvider takes a `messages` prop, separate from `locale`, so a
site that sets `locale` for prices never gets new wording. French ships
as its own import, `@godaddy/gd-commerce-storefront/messages/fr-FR`, so
English-only sites do not load it. Keys a site leaves out stay English.

Messages are plain data, so they can come from JSON or a server
component. `{name}` placeholders are filled in, and a message with a
count picks its plural form with Intl.PluralRules; French counts 0 as
singular. Server failure text is English, so other languages show text
by the response's `code`, or a generic message.

Price ranges use Intl.NumberFormat#formatRange, so each locale writes
them its own way. The sample cart's USD is one constant, and
getCartSummaryTotals takes a fallback currency in both packages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sbolinger-godaddy
sbolinger-godaddy requested a review from a team as a code owner October 29, 2026 23:58
@changeset-bot

changeset-bot Bot commented Oct 29, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: b15cc8a

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@godaddy/gd-commerce-storefront Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

- "Calculées au paiement" agrees with "Livraison, taxes et remises".
- A server code is translated even when the response had no error text.
- Error events carry the server's code, since the message is now translated.
- An override with an empty locale keeps English's, so English server text still shows.
- A price range falls back to joining both prices when formatRange throws.
- typesVersions maps the fr-FR subpath for node10 module resolution.
- Document typing your own translations as Partial<StorefrontMessages>.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@pbennett1-godaddy pbennett1-godaddy left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review notes inline: two correctness issues (stale translated errors on language switch, crash on malformed plural messages), the unused fallbackCurrency, an own-property check in describeError, a couple of edge cases, and some perf/cleanup nits.

const text: string = message(cause);
setError(text);
emit({ type: 'error', operation: kind, message: text });
const shown: string = describeError(cause, messages);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Translated errors go stale when the language changes. The cart error (and storageWarning at lines 492/506) is stored as an already-translated string. If messages switches from frFR to enUS, the existing error/warning stays in French until the next mutation or storage write. Consider storing the cause or message key and calling describeError/formatMessage at render time.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, fixed in 7c627f1. The cart now keeps the failure's cause and a storage-unavailable flag, and translates both on render with describeError and formatMessage. Switching messages from frFR to enUS updates an error already on screen. A new extensibility test covers this: it switches an English cart error to French by rerendering. The catalog, product page and connection errors were already described on render, so this was the last stored string.

export function formatMessage(
messages: StorefrontMessages,
key: StorefrontMessageKey,
values: MessageValues = {},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Malformed message input can crash rendering. If a plural object has no other form (e.g. {"one": "{count} produit"} with count 3), or a value is not a string/object (e.g. "sale": 1), text ends up non-string and text.replace throws. The README invites loading messages from JSON, and an invalid locale already falls back safely; falling back to enUS[key] here would be consistent.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 7c627f1. formatMessage now checks the message's shape, so a malformed one falls back to enUS[key] instead of throwing. A plural with no matching form uses other. If other is missing too, or the value isn't a string or plural object, it uses the English message. Covered by a new test with {"one": "{count} produit"} at count 3, and "sale": 1.


export function getCartSummaryTotals(order: CartOrder | null | undefined): CartSummaryTotals {
/** The cart's totals in minor units. `currencyCode` is the cart's, or `fallbackCurrency`, such as the store's. */
export function getCartSummaryTotals(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fallbackCurrency is never passed. The changeset says this no longer assumes USD, but no caller passes the store currency and the default is still 'USD', so an empty/untotaled EUR cart still reports USD. Either pass the configured currency through from callers or make the parameter required.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, reverted in 7c627f1. Nothing in the server package calls getCartSummaryTotals, and the package's index doesn't export it, so the parameter did nothing there. The server package is now unchanged and dropped from the changeset. I kept the parameter in the storefront: getCartSummaryTotals is public there, so a host can pass config.currencyCode, and the default stays 'USD' for compatibility. The storefront's own cart uses the store currency through cart-state.ts (cart currency ?? config.currencyCode). The changeset now says the fallback is optional, not that USD is gone.

Comment thread packages/commerce-storefront/src/api.ts Outdated
* show the text for the response's `code`, or a generic message.
*/
export function describeError(error: unknown, messages: StorefrontMessages): string {
const coded: string | undefined =

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inherited properties can match error codes. messages.errorCodes[error.code] has no own-property check, so a server code like constructor or toString returns a function (truthy), which then flows into cart error state and the error event. Suggest Object.hasOwn(messages.errorCodes, error.code) or a null-prototype object.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 7c627f1. describeError now uses the entry only if Object.hasOwn(messages.errorCodes, code) and the value is a non-empty string. A test covers code 'constructor': it gets the server text in English and requestFailed in French.

<section aria-labelledby={headingId} {...partProps('related-products', className)}>
<h2 id={headingId} className='mb-6 text-xl font-semibold tracking-tight'>
{title}
{title ?? text('relatedTitle')}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Behavior change for title={null}. The previous default parameter only applied to undefined; title ?? text('relatedTitle') also replaces null, so sites passing title={null} to suppress the heading will now get "You may also like". Consider title === undefined ? text('relatedTitle') : title.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 7c627f1. RelatedProducts uses the default only when title === undefined, so title={null} hides the heading text again, as it did before. I made the same change to CatalogHeader's title and description, which had the same ?? regression, and added a test for null there.

}

/** `useMoney` for a range, such as `$20.00 – $25.00`, written the way the provider's `locale` writes ranges. */
export function useMoneyRange(): (min: number, max: number, currency?: string) => string {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

formatRange with equal min/max returns an approximate value. Intl.NumberFormat#formatRange(2000, 2000) yields ~$20.00 in en-US. ProductCardPrice guards against this, but the exported useMoneyRange/moneyRange don't; returning format.format(min) when min === max would fix it for custom parts too.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 7c627f1. moneyRange (and so useMoneyRange) returns format.format(min) when min === max, before calling formatRange, so custom parts get an exact price. Covered for en-US/USD and fr-FR/EUR.

onEvent,
}: CommerceProviderProps): ReactElement {
const resolvedLocale: string = useMemo(() => resolveLocale(locale), [locale]);
const resolvedMessages: StorefrontMessages = useMemo(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Memo invalidates on every render with the README pattern. resolvedMessages is keyed on messages identity, but the README example messages={{ ...frFR, checkout: 'Commander' }} creates a new object each render, which cascades to every useMessage callback and the sample-cart/product-card memos. Consider a shallow key comparison, or document that messages should be stable (e.g. hoisted or memoized).

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 7c627f1 rather than documented, since the README pattern is the natural way to write it. The provider compares messages by its top-level keys and values, and only rebuilds the resolved messages when one changes. So messages={{ ...frFR, checkout: 'Commander' }} keeps useMessage and the dependent memos stable across renders. A test checks that useMessage returns the same function across rerenders with an inline object, and that the text updates when a value changes. The test fails without the fix. errorCodes is compared by identity, so an inline errorCodes object still rebuilds. I think that's fine, and hoisting it fixes it.

function currencyFormat(currency: string, locale: string): { format: Intl.NumberFormat; scale: number } {
const format: Intl.NumberFormat = new Intl.NumberFormat(locale, { style: 'currency', currency });
return { format, scale: 10 ** (format.resolvedOptions().maximumFractionDigits ?? 2) };
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Uncached Intl.NumberFormat. currencyFormat constructs a new formatter on every money/range call (every card price, line, subtotal, and label per render). messages.ts already caches formatters by locale; the same Map-cache keyed by ${locale}|${currency} would work here.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 7c627f1. currencyFormat caches the formatter and its scale in a Map keyed by ${locale}|${currency}, as messages.ts does for plurals and numbers. Locales and currencies are few per page, so the cache stays small.

/** Sample prices are examples, in this currency whatever the store's. */
export const sampleCurrency: string = 'USD';

export const sampleName = (messages: StorefrontMessages, sample: SampleProduct): string =>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: duplicates useMessage. Components like SampleAction call useCommerce() just for messages to pass to sampleName, next to const text = useMessage(). text('sampleProductName', { number }) does the same; sampleName could be kept internal to SampleCartProvider where there's no hook context.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, done in 7c627f1. SampleAction and ProductCardRoot now use text('sampleProductName', { number }) from useMessage, and no longer pull messages from useCommerce. sampleName is no longer exported and is only used inside SampleCartProvider, which builds the cart items and the added-item name.

const colon = ' :';

/** The package's text in French (France). Pass it to `CommerceProvider` as `messages`, with `locale='fr-FR'`. */
export const frFR: StorefrontMessages = {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit / maintenance: shared phrases ("Ajouter au panier", "Sous-total", "Passer la commande") are copied from @godaddy/localizations. Understood that this is deliberate, but fixes from the native-speaker review will need to be applied in both packages to avoid drift.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed this is the trade-off. I kept the copy rather than adding a dependency, because @godaddy/localizations is shaped for checkout and has many more keys, and the storefront ships French as a standalone subpath with no imports. To limit drift, 7c627f1 adds a line to the storefront notes in AGENTS.md: the shared checkout phrases follow @godaddy/localizations' frFr, and wording fixes should go to both packages. A shared-phrases module could replace this later if more languages make the drift costly.

- Cart errors and the storage warning are described on render, so they follow a change of messages.
- A malformed message from JSON shows the English text instead of throwing.
- Error codes match own keys only, so `constructor` cannot match an inherited property.
- An equal range is one exact price rather than formatRange's approximation.
- `messages` is compared by its contents, so an inline override keeps every memo stable.
- Currency formatters are cached per locale and currency.
- A null CatalogHeader or RelatedProducts title or description stays null.
- Sample names come from useMessage; sampleName stays inside the sample cart.
- Revert the server's unused fallback currency; only the storefront passes one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@pbennett1-godaddy pbennett1-godaddy left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review at 7c627f1: thanks, all 10 earlier comments are addressed and I checked each fix in the code. I've left three small follow-ups inline (nested message overrides, the ref written during render, and describeError with partial messages).

A few more observations that are non-blocking judgment calls, so take them or leave them:

  • title={null} renders an empty heading (RelatedProducts, CatalogHeader). This matches pre-PR behavior, which is what I asked for, but the empty <h2>/<h1> leaves aria-labelledby pointing at an empty heading and trips axe's empty-heading rule. Leaving out the element when the title is null might be nicer; the new CatalogHeader test currently requires the empty <h1>.
  • The sample product name is still built in two ways: text('sampleProductName', { number }) in ProductCardRoot/SampleAction, and the private sampleName() in sample-cart.tsx. A shared helper would stop the card and the cart drifting apart if that message changes.
  • Cart failures are described twice: once for the onEvent payload and again on each render from failure.cause. After a language switch, the event text and the on-screen text differ. That's probably intended; a note in the docs might help.
  • The currencyFormats cache has no size limit. This only matters for long-running SSR with many locale and currency pairs; a small limit would bound it.

return stable.current;
}

function shallowEqual(a: object | undefined, b: object | undefined): boolean {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the fix. One gap: nested overrides still change every render. shallowEqual compares only top-level values with Object.is, so an inline nested override, such as messages={{ ...frFR, errorCodes: { ...frFR.errorCodes, not_found: 'Introuvable' } }} or an inline plural like cartButtonLabel: { one: '…', other: '…' }, is a new reference each render. The check fails, and the merged messages, useMessage's text, and the memos that depend on them are rebuilt every render. The new test only covers a top-level string override. Comparing one level deeper (plain objects only) would cover errorCodes and plural messages.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in b15cc8a. The comparison now goes one level deeper for plain-object values, which covers errorCodes and plural forms. Strings and other values are still compared with Object.is. The test now uses a new plural and a new errorCodes object on every render, and fails with the old top-level comparison.

/** `value` while its own keys and values are unchanged, so an inline object literal does not change every render. */
function useShallowStable<T extends object>(value: T | undefined): T | undefined {
const stable = useRef<T | undefined>(value);
if (!shallowEqual(stable.current, value)) stable.current = value;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Writing stable.current during render isn't safe under concurrent rendering. If React throws away a render (an interrupted transition, or StrictMode's second render), the ref keeps that render's messages, and the next committed render can return a value that was never committed. A safer pattern is to keep the last committed value in state and update it with the "compare during render, then setState" pattern, or to compare against a ref that is updated only in an effect (useLayoutEffect). Since messages is just data, useMemo keyed on a stable serialization of the messages would also work.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, fixed in b15cc8a. The kept value is now state rather than a ref. During render, it's compared with the new messages. If they differ, the hook calls setState and returns the new messages, React's documented pattern for storing information from previous renders. A render React discards leaves nothing behind, because its update is discarded along with it. The stability test now renders inside StrictMode.

Comment thread packages/commerce-storefront/src/api.ts Outdated
export function describeError(error: unknown, messages: StorefrontMessages): string {
// Own keys only, so a code such as `constructor` cannot match an inherited property.
const coded: unknown =
error instanceof ApiError && error.code && Object.hasOwn(messages.errorCodes, error.code)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

describeError can still throw on partial messages. It's exported, and Object.hasOwn(messages.errorCodes, error.code) throws a TypeError when errorCodes is missing, for example when a custom part passes messages loaded from JSON instead of the provider's merged messages. formatMessage now falls back safely, so this is the one remaining path that can crash rendering. Something like const codes = messages.errorCodes ?? enUS.errorCodes; (or guarding with messages.errorCodes &&) would close it.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in b15cc8a. describeError reads messages.errorCodes into a local and checks for undefined before Object.hasOwn, so messages without errorCodes fall through to the generic text instead of throwing. Covered by a new test.

…adings

- Stable messages compare plain-object values one level deeper, so inline errorCodes and plurals stay stable.
- The kept messages are state set during render, not a ref, so a discarded render cannot leave them behind.
- describeError handles messages with no errorCodes, such as from JSON.
- A null CatalogHeader title or description, or RelatedProducts title, leaves the element out.
- One sampleName helper names samples in the card and the cart.
- The currency formatter cache keeps at most 64 locale and currency pairs.
- Document that error events keep the text from the time of the failure.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sbolinger-godaddy

Copy link
Copy Markdown
Collaborator Author

Thanks for the re-review. All three inline follow-ups are fixed in b15cc8a, with replies on each thread. I took all four judgment calls too:

  • Null headings: title={null} on CatalogHeader or RelatedProducts now leaves the heading out, and description={null} leaves the paragraph out. RelatedProducts also drops its aria-labelledby, so it no longer points at an empty heading. CatalogHeader's props were typed string only, so they now allow null too. The test checks that both headings are absent.
  • Sample names: there's one exported sampleName(text, sample) helper now. The card, the sample action and the sample cart all use it.
  • Event text after a language switch: that's intended. The event reports what the shopper saw when the failure happened, while the on-screen notice follows later changes. The README's events table now says so, and suggests logging code to group failures across languages.
  • Formatter cache: it now holds at most 64 locale and currency pairs, dropping the oldest first.

Storefront: all 159 tests pass, plus typecheck, lint and build. The French and English walkthroughs in the local test app still pass.

@sbolinger-godaddy
sbolinger-godaddy merged commit 06e3825 into main Oct 30, 2026
3 checks passed
@sbolinger-godaddy
sbolinger-godaddy deleted the feat/storefront-i18n branch October 30, 2026 19:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants