Skip to content
Open
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
1 change: 1 addition & 0 deletions messages/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
1 change: 1 addition & 0 deletions messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
1 change: 1 addition & 0 deletions messages/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
1 change: 1 addition & 0 deletions messages/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
1 change: 1 addition & 0 deletions messages/pl.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
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
57 changes: 43 additions & 14 deletions src/components/layout/RegionPreferences.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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) {
Expand All @@ -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" });
Expand All @@ -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);
}
Expand All @@ -124,7 +135,13 @@ export function RegionPreferences({ variant }: RegionPreferencesProps) {
event: FormEvent<HTMLFormElement>,
): Promise<void> {
event.preventDefault();
if (!selectedCountry) return;
if (
!selectedCountry ||
localeOptions.length === 0 ||
!localeOptions.includes(draftLocale)
) {
return;
}

setSwitchError(false);
const switched = await handleCountrySelect(selectedCountry, draftLocale);
Expand All @@ -139,6 +156,8 @@ export function RegionPreferences({ variant }: RegionPreferencesProps) {
}

const isHeaderVariant = variant === "header";
const hasRenderableLocale =
localeOptions.length > 0 && localeOptions.includes(draftLocale);

return (
<Dialog open={open} onOpenChange={handleOpenChange}>
Expand Down Expand Up @@ -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) => (
<NativeSelectOption key={localeCode} value={localeCode}>
{languageDisplayNames?.of(localeCode) ?? localeCode} (
{localeCode.toUpperCase()})
{hasRenderableLocale ? (
localeOptions.map((localeCode) => (
<NativeSelectOption key={localeCode} value={localeCode}>
{languageDisplayNames?.of(localeCode) ?? localeCode} (
{localeCode.toUpperCase()})
</NativeSelectOption>
))
) : (
<NativeSelectOption value="" disabled>
{t("noSupportedLanguage")}
</NativeSelectOption>
))}
)}
</NativeSelect>
</Field>

Expand All @@ -237,7 +263,10 @@ export function RegionPreferences({ variant }: RegionPreferencesProps) {
type="submit"
className="w-full"
disabled={
!selectedCountry || isCartLoading || isCountryNavigating
!selectedCountry ||
!hasRenderableLocale ||
isCartLoading ||
isCountryNavigating
}
>
{isCountryNavigating
Expand Down
74 changes: 73 additions & 1 deletion src/components/layout/__tests__/RegionPreferences.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ vi.mock("next-intl", () => ({
updatePreferences: "Update preferences",
updatingPreferences: "Updating preferences...",
updatePreferencesFailed: "Could not update preferences.",
noSupportedLanguage: "Language unavailable",
})[key] ?? key,
}));

Expand Down Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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" }),
Expand All @@ -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(<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("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(<RegionPreferences variant="header" />);
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({
Expand Down
71 changes: 71 additions & 0 deletions src/hooks/__tests__/useCountrySwitch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading
Loading