diff --git a/messages/de.json b/messages/de.json index 6061798f..91d7f1db 100644 --- a/messages/de.json +++ b/messages/de.json @@ -48,6 +48,7 @@ "description": "Wählen Sie Ihre Region und Sprache. Die Währung wird durch die ausgewählte Region festgelegt.", "region": "Region", "language": "Sprache", + "noSupportedLanguage": "Sprache nicht verfügbar", "updatePreferences": "Einstellungen aktualisieren", "updatingPreferences": "Einstellungen werden aktualisiert...", "updatePreferencesFailed": "Die regionalen Einstellungen konnten nicht aktualisiert werden. Bitte versuchen Sie es erneut." diff --git a/messages/en.json b/messages/en.json index 8c5a8807..58edfe96 100644 --- a/messages/en.json +++ b/messages/en.json @@ -48,6 +48,7 @@ "description": "Choose your region and language. Currency is set by the selected region.", "region": "Region", "language": "Language", + "noSupportedLanguage": "Language unavailable", "updatePreferences": "Update preferences", "updatingPreferences": "Updating preferences...", "updatePreferencesFailed": "We couldn't update your region preferences. Please try again." diff --git a/messages/es.json b/messages/es.json index 9db1c1d4..6d0e74e6 100644 --- a/messages/es.json +++ b/messages/es.json @@ -48,6 +48,7 @@ "description": "Elige tu región e idioma. La moneda depende de la región seleccionada.", "region": "Región", "language": "Idioma", + "noSupportedLanguage": "Idioma no disponible", "updatePreferences": "Actualizar preferencias", "updatingPreferences": "Actualizando preferencias...", "updatePreferencesFailed": "No pudimos actualizar tus preferencias regionales. Inténtalo de nuevo." diff --git a/messages/fr.json b/messages/fr.json index 81e2ecaf..2f7b091a 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -48,6 +48,7 @@ "description": "Choisissez votre région et votre langue. La devise dépend de la région sélectionnée.", "region": "Région", "language": "Langue", + "noSupportedLanguage": "Langue indisponible", "updatePreferences": "Mettre à jour les préférences", "updatingPreferences": "Mise à jour des préférences...", "updatePreferencesFailed": "Impossible de mettre à jour vos préférences régionales. Veuillez réessayer." diff --git a/messages/pl.json b/messages/pl.json index 22ac0a33..5910c2df 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -48,6 +48,7 @@ "description": "Wybierz region i język. Waluta jest ustawiana na podstawie wybranego regionu.", "region": "Region", "language": "Język", + "noSupportedLanguage": "Język niedostępny", "updatePreferences": "Zaktualizuj preferencje", "updatingPreferences": "Aktualizowanie preferencji...", "updatePreferencesFailed": "Nie udało się zaktualizować preferencji regionalnych. Spróbuj ponownie." diff --git a/src/app/[country]/[locale]/layout.test.tsx b/src/app/[country]/[locale]/layout.test.tsx index 615a1c56..1a5eb0d9 100644 --- a/src/app/[country]/[locale]/layout.test.tsx +++ b/src/app/[country]/[locale]/layout.test.tsx @@ -143,6 +143,27 @@ describe("CountryLocaleLayout Market fallback", () => { }); }); + it("renders a Market configured with en-GB at the lowercase route", async () => { + mocks.getMarkets.mockResolvedValue({ + data: [ + market({ + default_locale: "en-GB", + supported_locales: [], + country_isos: ["GB"], + countries: [country("GB")], + }), + ], + }); + + await expect( + CountryLocaleLayoutContent({ + children:
, + params: Promise.resolve({ country: "gb", locale: "en-gb" }), + }), + ).resolves.toBeDefined(); + expect(mocks.redirect).not.toHaveBeenCalled(); + }); + it("redirects an unknown country to the default Market and keeps the page", async () => { mocks.getMarkets.mockResolvedValue({ data: [market()] }); mocks.headers.mockResolvedValue( diff --git a/src/components/layout/RegionPreferences.tsx b/src/components/layout/RegionPreferences.tsx index 4f65444d..4a81fb34 100644 --- a/src/components/layout/RegionPreferences.tsx +++ b/src/components/layout/RegionPreferences.tsx @@ -25,6 +25,8 @@ import { } from "@/components/ui/native-select"; import { type CountryWithMarket, useStore } from "@/contexts/StoreContext"; import { useCountrySwitch } from "@/hooks/useCountrySwitch"; +import { resolveSupportedLocale, type SupportedLocale } from "@/i18n/locales"; +import { toRouteLocale } from "@/i18n/normalize"; import { cn } from "@/lib/utils"; interface RegionPreferencesProps { @@ -65,9 +67,15 @@ function getCountry( } function getSupportedLocales(entry: CountryWithMarket): string[] { - return entry.supported_locales.length > 0 - ? entry.supported_locales - : [entry.default_locale]; + const locales = [entry.default_locale, ...entry.supported_locales]; + + return Array.from( + new Set( + locales + .map((locale) => resolveSupportedLocale(locale)) + .filter((locale): locale is SupportedLocale => locale !== undefined), + ), + ); } export function RegionPreferences({ variant }: RegionPreferencesProps) { @@ -88,7 +96,7 @@ export function RegionPreferences({ variant }: RegionPreferencesProps) { getCountry(countries, draftCountry) ?? getCountry(countries, country); const localeOptions = selectedCountry ? getSupportedLocales(selectedCountry) - : [locale]; + : [toRouteLocale(locale) ?? locale]; const languageDisplayNames = useMemo(() => { try { return new Intl.DisplayNames([locale], { type: "language" }); @@ -111,11 +119,14 @@ export function RegionPreferences({ variant }: RegionPreferencesProps) { if (!entry) return; const supportedLocales = getSupportedLocales(entry); + setDraftCountry(nextCountry); setDraftLocale((currentLocale) => - supportedLocales.includes(currentLocale) - ? currentLocale - : entry.default_locale || supportedLocales[0], + supportedLocales.includes( + toRouteLocale(currentLocale) ?? currentLocale.toLowerCase(), + ) + ? (toRouteLocale(currentLocale) ?? currentLocale.toLowerCase()) + : (supportedLocales[0] ?? ""), ); setSwitchError(false); } @@ -124,7 +135,13 @@ export function RegionPreferences({ variant }: RegionPreferencesProps) { event: FormEvent, ): Promise { event.preventDefault(); - if (!selectedCountry) return; + if ( + !selectedCountry || + localeOptions.length === 0 || + !localeOptions.includes(draftLocale) + ) { + return; + } setSwitchError(false); const switched = await handleCountrySelect(selectedCountry, draftLocale); @@ -139,6 +156,8 @@ export function RegionPreferences({ variant }: RegionPreferencesProps) { } const isHeaderVariant = variant === "header"; + const hasRenderableLocale = + localeOptions.length > 0 && localeOptions.includes(draftLocale); return ( @@ -213,17 +232,24 @@ export function RegionPreferences({ variant }: RegionPreferencesProps) { id="region-preferences-language" className="w-full" value={draftLocale} + disabled={!hasRenderableLocale} onChange={(event) => { setDraftLocale(event.target.value); setSwitchError(false); }} > - {localeOptions.map((localeCode) => ( - - {languageDisplayNames?.of(localeCode) ?? localeCode} ( - {localeCode.toUpperCase()}) + {hasRenderableLocale ? ( + localeOptions.map((localeCode) => ( + + {languageDisplayNames?.of(localeCode) ?? localeCode} ( + {localeCode.toUpperCase()}) + + )) + ) : ( + + {t("noSupportedLanguage")} - ))} + )} @@ -237,7 +263,10 @@ export function RegionPreferences({ variant }: RegionPreferencesProps) { type="submit" className="w-full" disabled={ - !selectedCountry || isCartLoading || isCountryNavigating + !selectedCountry || + !hasRenderableLocale || + isCartLoading || + isCountryNavigating } > {isCountryNavigating diff --git a/src/components/layout/__tests__/RegionPreferences.test.tsx b/src/components/layout/__tests__/RegionPreferences.test.tsx index c1c56d05..89817d6f 100644 --- a/src/components/layout/__tests__/RegionPreferences.test.tsx +++ b/src/components/layout/__tests__/RegionPreferences.test.tsx @@ -17,6 +17,7 @@ vi.mock("next-intl", () => ({ updatePreferences: "Update preferences", updatingPreferences: "Updating preferences...", updatePreferencesFailed: "Could not update preferences.", + noSupportedLanguage: "Language unavailable", })[key] ?? key, })); @@ -45,9 +46,25 @@ const countries = [ name: "Canada", currency: "USD", default_locale: "en", - supported_locales: ["en", "fr"], + supported_locales: ["en", "fr", "it"], marketId: "market-ca", }, + { + iso: "GB", + name: "United Kingdom", + currency: "GBP", + default_locale: "en-GB", + supported_locales: [], + marketId: "market-gb", + }, + { + iso: "JP", + name: "Japan", + currency: "JPY", + default_locale: "ja", + supported_locales: [], + marketId: "market-jp", + }, ] as CountryWithMarket[]; describe("RegionPreferences", () => { @@ -89,6 +106,9 @@ describe("RegionPreferences", () => { await user.selectOptions(screen.getByLabelText("Region"), "ca"); expect(screen.getByLabelText("Region")).toHaveValue("ca"); expect(screen.getByLabelText("Language")).toHaveValue("en"); + expect( + screen.getByLabelText("Language").querySelector('option[value="it"]'), + ).toBeNull(); await user.click( screen.getByRole("button", { name: "Update preferences" }), @@ -97,6 +117,58 @@ describe("RegionPreferences", () => { expect(handleCountrySelect).toHaveBeenCalledWith(countries[1], "en"); }); + it("uses a lowercase regional locale in the language selector", async () => { + const user = userEvent.setup(); + const handleCountrySelect = vi.fn().mockResolvedValue(true); + mockUseCountrySwitch.mockReturnValue({ + handleCountrySelect, + isCartLoading: false, + isCountryNavigating: false, + }); + + render(); + await user.click( + screen.getByRole("button", { name: "Region and language" }), + ); + await user.selectOptions(screen.getByLabelText("Region"), "gb"); + + expect(screen.getByLabelText("Language")).toHaveValue("en-gb"); + + await user.click( + screen.getByRole("button", { name: "Update preferences" }), + ); + + expect(handleCountrySelect).toHaveBeenCalledWith(countries[2], "en-gb"); + }); + + it("allows selecting a country without a renderable language but disables updating", async () => { + const user = userEvent.setup(); + mockUseCountrySwitch.mockReturnValue({ + handleCountrySelect: vi.fn(), + isCartLoading: false, + isCountryNavigating: false, + }); + + render(); + await user.click( + screen.getByRole("button", { name: "Region and language" }), + ); + + const region = screen.getByLabelText("Region"); + expect( + screen.getByRole("option", { name: "Japan (JPY)" }), + ).not.toBeDisabled(); + + await user.selectOptions(region, "jp"); + + expect(region).toHaveValue("jp"); + expect(screen.getByLabelText("Language")).toBeDisabled(); + expect(screen.getByText("Language unavailable")).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Update preferences" }), + ).toBeDisabled(); + }); + it("disables submission while the cart is loading", async () => { const user = userEvent.setup(); mockUseCountrySwitch.mockReturnValue({ diff --git a/src/hooks/__tests__/useCountrySwitch.test.ts b/src/hooks/__tests__/useCountrySwitch.test.ts index 4e575953..e4a1ec3a 100644 --- a/src/hooks/__tests__/useCountrySwitch.test.ts +++ b/src/hooks/__tests__/useCountrySwitch.test.ts @@ -121,6 +121,77 @@ describe("useCountrySwitch", () => { expect(mockAssign).toHaveBeenCalledWith("/de/de/products"); }); + it("normalizes a regional locale to a lowercase route", async () => { + const regionalCountry = { + iso: "GB", + currency: "GBP", + default_locale: "en-GB", + supported_locales: [], + } as unknown as CountryWithMarket; + const { result } = renderHook(() => + useCountrySwitch({ + currentCountry: "us", + currentLocale: "en", + }), + ); + const mockAssign = vi.fn(); + vi.stubGlobal("window", { + location: { assign: mockAssign, hash: "", search: "" }, + }); + + await act(async () => { + await result.current.handleCountrySelect(regionalCountry); + }); + + expect(mockUpdateCartMarket).toHaveBeenCalledWith("cart-1", { + currency: "GBP", + locale: "en-gb", + }); + expect(mockSetStoreCookies).toHaveBeenCalledWith("gb", "en-gb"); + expect(mockAssign).toHaveBeenCalledWith("/gb/en-gb/products"); + }); + + it("updates a cart that still has the legacy regional locale casing", async () => { + const refreshCart = vi.fn().mockResolvedValue(undefined); + const regionalCountry = { + iso: "GB", + currency: "GBP", + default_locale: "en-GB", + supported_locales: [], + } as unknown as CountryWithMarket; + mockUseCart.mockReturnValue({ + cart: { + id: "cart-1", + currency: "GBP", + locale: "en-GB", + } as never, + loading: false, + refreshCart, + } as never); + + const { result } = renderHook(() => + useCountrySwitch({ + currentCountry: "gb", + currentLocale: "en-gb", + }), + ); + const mockAssign = vi.fn(); + vi.stubGlobal("window", { + location: { assign: mockAssign, hash: "", search: "" }, + }); + + await act(async () => { + await result.current.handleCountrySelect(regionalCountry, "en-gb"); + }); + + expect(mockUpdateCartMarket).toHaveBeenCalledWith("cart-1", { + currency: "GBP", + locale: "en-gb", + }); + expect(refreshCart).toHaveBeenCalledOnce(); + expect(mockAssign).toHaveBeenCalledWith("/gb/en-gb/products"); + }); + it("rebases a login return target when switching market", async () => { const { rebaseAccountRedirectSearch } = await import( "@/lib/utils/account-redirect" diff --git a/src/hooks/useCountrySwitch.ts b/src/hooks/useCountrySwitch.ts index 63baea4b..f1c3bdc7 100644 --- a/src/hooks/useCountrySwitch.ts +++ b/src/hooks/useCountrySwitch.ts @@ -4,6 +4,7 @@ import { usePathname } from "next/navigation"; import { useState } from "react"; import { useCart } from "@/contexts/CartContext"; import type { CountryWithMarket } from "@/contexts/StoreContext"; +import { toRouteLocale } from "@/i18n/normalize"; import { updateCartMarket } from "@/lib/data/checkout"; import { rebaseAccountRedirectSearch } from "@/lib/utils/account-redirect"; import { setStoreCookies } from "@/lib/utils/cookies"; @@ -39,13 +40,22 @@ export function useCountrySwitch({ ): Promise => { const nextCountry = entry.iso.toLowerCase(); const activeCountry = currentCountry.toLowerCase(); - const newLocale = locale || entry.default_locale || "en"; + const newLocale = + toRouteLocale(locale || entry.default_locale || "en") ?? "en"; + const activeLocale = toRouteLocale(currentLocale) ?? currentLocale; + const newCurrency = entry.currency; + const cartMatchesTarget = + !cart || (cart.currency === newCurrency && cart.locale === newLocale); if (isCountryNavigating) { return false; } - if (nextCountry === activeCountry && newLocale === currentLocale) { + if ( + nextCountry === activeCountry && + newLocale === activeLocale && + cartMatchesTarget + ) { return true; } @@ -55,10 +65,9 @@ export function useCountrySwitch({ setIsCountryNavigating(true); - const newCurrency = entry.currency; const pathRest = getPathWithoutPrefix(pathname); const newPath = `/${nextCountry}/${newLocale}${pathRest}`; - const currentBasePath = `/${activeCountry}/${currentLocale}`; + const currentBasePath = `/${activeCountry}/${activeLocale}`; const nextBasePath = `/${nextCountry}/${newLocale}`; try { diff --git a/src/i18n/__tests__/locales.test.ts b/src/i18n/__tests__/locales.test.ts index 3b482116..b932d229 100644 --- a/src/i18n/__tests__/locales.test.ts +++ b/src/i18n/__tests__/locales.test.ts @@ -9,11 +9,13 @@ import { matchLocale, negotiateAcceptLanguage, negotiateLocale, + toRouteLocale, } from "@/i18n/normalize"; describe("locale configuration", () => { it("resolves configured locales case-insensitively", () => { expect(resolveSupportedLocale("EN")).toBe("en"); + expect(resolveSupportedLocale("en-GB")).toBe("en-gb"); expect(resolveSupportedLocale("it")).toBeUndefined(); expect(SUPPORTED_LOCALES).toContain(DEFAULT_LOCALE); }); @@ -22,6 +24,7 @@ describe("locale configuration", () => { expect(canonicalizeLocale("zh_cn")).toBe("zh-CN"); expect(canonicalizeLocale("sr_latn_rs")).toBe("sr-Latn-RS"); expect(canonicalizeLocale("not_a_locale_!")).toBeUndefined(); + expect(toRouteLocale("en-GB")).toBe("en-gb"); }); it("preserves configured spelling and negotiates a base language", () => { diff --git a/src/i18n/__tests__/markets.test.ts b/src/i18n/__tests__/markets.test.ts index 093b3caa..84eee77c 100644 --- a/src/i18n/__tests__/markets.test.ts +++ b/src/i18n/__tests__/markets.test.ts @@ -67,4 +67,18 @@ describe("Market locale routes", () => { locale: "en", }); }); + + it("renders a regional Market locale with a lowercase route segment", () => { + const current = market({ + default_locale: "en-GB", + supported_locales: [], + countries: [country("GB")], + }); + + expect(getMarketLocales(current)).toEqual(["en-gb"]); + expect(isLocaleEnabledForMarket(current, "en-GB")).toBe(true); + expect(getMarketLocaleTargets([current])).toEqual([ + { marketId: "market-1", country: "gb", locale: "en-gb" }, + ]); + }); }); diff --git a/src/i18n/__tests__/routing.test.ts b/src/i18n/__tests__/routing.test.ts index e3993fbf..e024e8e2 100644 --- a/src/i18n/__tests__/routing.test.ts +++ b/src/i18n/__tests__/routing.test.ts @@ -18,4 +18,14 @@ describe("localized route fallback", () => { "/us/en", ); }); + + it("normalizes regional locale redirects to lowercase", () => { + expect( + buildLocalizedRedirectPath({ + country: "GB", + locale: "en-GB", + pathname: "/gb/en/products", + }), + ).toBe("/gb/en-gb/products"); + }); }); diff --git a/src/i18n/locales.ts b/src/i18n/locales.ts index f9588e35..ac0bd26f 100644 --- a/src/i18n/locales.ts +++ b/src/i18n/locales.ts @@ -9,6 +9,7 @@ import { canonicalizeLocale, matchLocale } from "@/i18n/normalize"; const MESSAGE_LOADERS = { de: () => import("../../messages/de.json"), en: () => import("../../messages/en.json"), + "en-gb": () => import("../../messages/en.json"), es: () => import("../../messages/es.json"), fr: () => import("../../messages/fr.json"), pl: () => import("../../messages/pl.json"), diff --git a/src/i18n/normalize.ts b/src/i18n/normalize.ts index 62581953..ab1c774e 100644 --- a/src/i18n/normalize.ts +++ b/src/i18n/normalize.ts @@ -11,6 +11,11 @@ export function canonicalizeLocale( } } +/** Convert a locale to the lowercase form used in storefront URL segments. */ +export function toRouteLocale(value: string | undefined): string | undefined { + return canonicalizeLocale(value)?.toLowerCase(); +} + /** Match a locale case-insensitively while preserving the configured spelling. */ export function matchLocale( value: string | undefined, diff --git a/src/i18n/routing.ts b/src/i18n/routing.ts index 3199d2e3..8f707129 100644 --- a/src/i18n/routing.ts +++ b/src/i18n/routing.ts @@ -1,3 +1,5 @@ +import { toRouteLocale } from "@/i18n/normalize"; + export const REQUEST_PATHNAME_HEADER = "x-spree-request-pathname"; export const REQUEST_SEARCH_HEADER = "x-spree-request-search"; @@ -17,7 +19,9 @@ export function buildLocalizedRedirectPath({ pathname, search, }: LocalizedRedirectParams): string { - const prefix = `/${country.toLowerCase()}/${locale}`; + const normalizedLocale = + toRouteLocale(locale) ?? locale.trim().replaceAll("_", "-").toLowerCase(); + const prefix = `/${country.toLowerCase()}/${normalizedLocale}`; const matchedPrefix = pathname?.match(LOCALIZED_PREFIX)?.[0]; const suffix = matchedPrefix ? pathname?.slice(matchedPrefix.length) : ""; const normalizedSearch = search?.startsWith("?") ? search : ""; diff --git a/src/lib/spree/middleware.test.ts b/src/lib/spree/middleware.test.ts index 8183f6d5..d7a27199 100644 --- a/src/lib/spree/middleware.test.ts +++ b/src/lib/spree/middleware.test.ts @@ -19,6 +19,22 @@ describe("Spree locale middleware", () => { ); }); + it("keeps regional default locales in lowercase route segments", () => { + const regionalMiddleware = createSpreeMiddleware({ + defaultCountry: "gb", + defaultLocale: "en-GB", + supportedLocales: ["en", "en-gb"], + }); + + const response = regionalMiddleware( + new NextRequest("https://store.example/"), + ); + + expect(response.headers.get("location")).toBe( + "https://store.example/gb/en-gb", + ); + }); + it("redirects an unsupported storefront locale without dropping the path", () => { const response = middleware( new NextRequest("https://store.example/ar/it/products/coffee"),