Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
21 changes: 21 additions & 0 deletions src/app/[country]/[locale]/layout.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: <main />,
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(
Expand Down
28 changes: 21 additions & 7 deletions src/components/layout/RegionPreferences.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
} from "@/components/ui/native-select";
import { type CountryWithMarket, useStore } from "@/contexts/StoreContext";
import { useCountrySwitch } from "@/hooks/useCountrySwitch";
import { toRouteLocale } from "@/i18n/normalize";
import { cn } from "@/lib/utils";

interface RegionPreferencesProps {
Expand Down Expand Up @@ -65,9 +66,20 @@ function getCountry(
}

function getSupportedLocales(entry: CountryWithMarket): string[] {
return entry.supported_locales.length > 0
? entry.supported_locales
: [entry.default_locale];
const locales =
entry.supported_locales.length > 0
? entry.supported_locales
: [entry.default_locale];

return Array.from(
new Set(
locales.map(
(locale) =>
toRouteLocale(locale) ??
locale.trim().replaceAll("_", "-").toLowerCase(),
),
),
);
}

export function RegionPreferences({ variant }: RegionPreferencesProps) {
Expand All @@ -88,7 +100,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" });
Expand All @@ -113,9 +125,11 @@ export function RegionPreferences({ variant }: RegionPreferencesProps) {
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);
}
Expand Down
32 changes: 32 additions & 0 deletions src/components/layout/__tests__/RegionPreferences.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,14 @@ const countries = [
supported_locales: ["en", "fr"],
marketId: "market-ca",
},
{
iso: "GB",
name: "United Kingdom",
currency: "GBP",
default_locale: "en-GB",
supported_locales: [],
marketId: "market-gb",
},
] as CountryWithMarket[];

describe("RegionPreferences", () => {
Expand Down Expand Up @@ -97,6 +105,30 @@ 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(<RegionPreferences variant="header" />);
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("disables submission while the cart is loading", async () => {
const user = userEvent.setup();
mockUseCountrySwitch.mockReturnValue({
Expand Down
30 changes: 30 additions & 0 deletions src/hooks/__tests__/useCountrySwitch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,36 @@ 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("rebases a login return target when switching market", async () => {
const { rebaseAccountRedirectSearch } = await import(
"@/lib/utils/account-redirect"
Expand Down
9 changes: 6 additions & 3 deletions src/hooks/useCountrySwitch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -39,13 +40,15 @@ export function useCountrySwitch({
): Promise<boolean> => {
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;

if (isCountryNavigating) {
return false;
}

if (nextCountry === activeCountry && newLocale === currentLocale) {
if (nextCountry === activeCountry && newLocale === activeLocale) {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
return true;
}

Expand All @@ -58,7 +61,7 @@ export function useCountrySwitch({
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 {
Expand Down
3 changes: 3 additions & 0 deletions src/i18n/__tests__/locales.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand All @@ -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", () => {
Expand Down
14 changes: 14 additions & 0 deletions src/i18n/__tests__/markets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
]);
});
});
10 changes: 10 additions & 0 deletions src/i18n/__tests__/routing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});
1 change: 1 addition & 0 deletions src/i18n/locales.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
5 changes: 5 additions & 0 deletions src/i18n/normalize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
6 changes: 5 additions & 1 deletion src/i18n/routing.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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 : "";
Expand Down
16 changes: 16 additions & 0 deletions src/lib/spree/middleware.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
Loading