diff --git a/README.md b/README.md index e974eb2802..cfe308ca16 100644 --- a/README.md +++ b/README.md @@ -252,6 +252,17 @@ If command "dot" is missing - install graphviz on your OS The generated graph will also be saved in the `artifacts` folder. ## Localization +### Language Flow +- Locale selection checks the URL first - using the supported languages list to tell real locale segments from ordinary path parts (full culture like `fr-FR` or an unambiguous short alias such as `fr`), then your “pinned” locale - your last choice saved in localStorage, then your account’s preferred culture, and finally the store default. + +- When a locale is chosen, useLanguages.initLocale lazy-loads its translation file (`xx-YY.json`; if missing it falls back to `xx.json`, then to `en.json`), wires it into Vue I18n and Yup, updates the `` tag, and rewrites the URL so default or mismatched locale segments vanish. + +- Switching via the header selector stores the new culture, captures the exact slug and culture you were on at that moment (previousCultureSlug in session storage), strips any stale locale prefix from the URL, broadcasts a data refresh, and reloads so the whole app restarts in the new language. + +- After that reload, useSlugInfo notices previousCultureSlug; while you stay on the same path it asks the backend for slug data using the recorded culture, so `/hello` is resolved with `en-US` instead of the new `fr-FR`, preventing empty responses before the localized slug is known. + +- When product, category, brand, or CMS data brings back a localized permalink (updateLocalizedUrl in those page modules), it uses history.pushState to refresh the browser address with the localized path - keeping locale prefix, query string, and hash - so users see the correct URL without triggering router navigation or extra data fetching. + ### Check for missing locale keys ``` yarn check-locales --source en.json -- path/to/locales_folder path/to/**/locales diff --git a/client-app/app-runner.ts b/client-app/app-runner.ts index e09e2b25ff..be77f38941 100644 --- a/client-app/app-runner.ts +++ b/client-app/app-runner.ts @@ -15,7 +15,7 @@ import { extensionPointsPlugin, permissionsPlugin, } from "@/core/plugins"; -import { extractHostname, getBaseUrl, Logger } from "@/core/utilities"; +import { extractHostname, Logger } from "@/core/utilities"; import { createI18n } from "@/i18n"; import { init as initModuleBackInStock } from "@/modules/back-in-stock"; import { init as initCustomerReviews } from "@/modules/customer-reviews"; @@ -66,17 +66,16 @@ export default async () => { app.use(authPlugin); - const { fetchUser, user, twoLetterContactLocale, isAuthenticated } = useUser(); + const { fetchUser, user, isAuthenticated } = useUser(); const { themeContext, addPresetToThemeContext, setThemeContext } = useThemeContext(); const { - detectLocale, currentLanguage, - supportedLocales, + currentMaybeShortLocale, + defaultStoreCulture, initLocale, fetchLocaleMessages, - getLocaleFromUrl, - pinedLocale, - mergeLocales, + mergeLocalesMessages, + resolveLocale, } = useLanguages(); const { currentCurrency } = useCurrency(); const { init: initializeHotjar } = useHotjar(); @@ -104,22 +103,18 @@ export default async () => { setThemeContext(store); - // priority rule: pinedLocale > contactLocale > urlLocale > storeLocale - const twoLetterAppLocale = detectLocale([ - pinedLocale.value, - twoLetterContactLocale.value, - getLocaleFromUrl(), - themeContext.value.defaultLanguage.twoLetterLanguageName, - ]); - /** * Creating plugin instances */ const head = createHead(); - const i18n = createI18n(twoLetterAppLocale, currentCurrency.value.code, fallback); - const router = createRouter({ base: getBaseUrl(supportedLocales.value) }); - await initLocale(i18n, twoLetterAppLocale); + const currentCultureName = resolveLocale(); + const isDefaultLocaleInUse = defaultStoreCulture.value === currentCultureName; + + const i18n = createI18n(currentCultureName, currentCurrency.value.code, fallback); + await initLocale(i18n, currentCultureName); + + const router = createRouter({ base: isDefaultLocaleInUse ? "" : currentMaybeShortLocale.value }); /** * Setting global variables @@ -167,9 +162,9 @@ export default async () => { app.use(configPlugin, themeContext.value); const UIKitMessages = await getUIKitLocales(FALLBACK_LOCALE, currentLanguage.value?.twoLetterLanguageName); - mergeLocales(i18n, currentLanguage.value?.twoLetterLanguageName, UIKitMessages.messages); + mergeLocalesMessages(i18n, currentLanguage.value?.twoLetterLanguageName, UIKitMessages.messages); if (currentLanguage.value?.twoLetterLanguageName !== FALLBACK_LOCALE) { - mergeLocales(i18n, FALLBACK_LOCALE, UIKitMessages.fallbackMessages); + mergeLocalesMessages(i18n, FALLBACK_LOCALE, UIKitMessages.fallbackMessages); } app.use(uiKit); diff --git a/client-app/core/composables/useLanguages.test.ts b/client-app/core/composables/useLanguages.test.ts new file mode 100644 index 0000000000..a8f4185b66 --- /dev/null +++ b/client-app/core/composables/useLanguages.test.ts @@ -0,0 +1,362 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { setLocale as setLocaleForYup } from "yup"; +import { createI18n } from "@/i18n"; +import type { ILanguage } from "@/core/types"; +import type { I18n } from "@/i18n"; + +const hoisted = vi.hoisted(() => { + const enUS: ILanguage = { + cultureName: "en-US", + nativeName: "English", + threeLetterLanguageName: "eng", + twoLetterLanguageName: "en", + twoLetterRegionName: "US", + threeLetterRegionName: "USA", + }; + + const frFR: ILanguage = { + cultureName: "fr-FR", + nativeName: "Français", + threeLetterLanguageName: "fra", + twoLetterLanguageName: "fr", + twoLetterRegionName: "FR", + threeLetterRegionName: "FRA", + }; + + const deDE: ILanguage = { + cultureName: "de-DE", + nativeName: "Deutsch", + threeLetterLanguageName: "deu", + twoLetterLanguageName: "de", + twoLetterRegionName: "DE", + threeLetterRegionName: "DEU", + }; + + const ptBR: ILanguage = { + cultureName: "pt-BR", + nativeName: "Português (Brasil)", + threeLetterLanguageName: "por", + twoLetterLanguageName: "pt", + twoLetterRegionName: "BR", + threeLetterRegionName: "BRA", + }; + + const ptPT: ILanguage = { + cultureName: "pt-PT", + nativeName: "Português (Portugal)", + threeLetterLanguageName: "por", + twoLetterLanguageName: "pt", + twoLetterRegionName: "PT", + threeLetterRegionName: "PRT", + }; + + const themeContext = { + value: { + defaultLanguage: enUS, + availableLanguages: [enUS, frFR, deDE, ptBR, ptPT], + }, + }; + + const contactCultureName = { value: undefined as string | undefined }; + const pinnedLocale = { value: null as string | null }; + const previousCultureSlug = { + value: { cultureName: "", slug: "" } as { cultureName: string; slug: string }, + }; + + return { + langs: { enUS, frFR, deDE, ptBR, ptPT }, + state: { themeContext, contactCultureName, pinnedLocale, previousCultureSlug }, + }; +}); + +vi.mock("@/shared/account/composables/useUser", () => ({ + useUser: () => ({ contactCultureName: hoisted.state.contactCultureName }), +})); + +vi.mock("./useThemeContext", () => ({ + useThemeContext: () => ({ themeContext: hoisted.state.themeContext }), +})); + +vi.mock("@vueuse/core", () => ({ + useLocalStorage: () => hoisted.state.pinnedLocale, + useSessionStorage: () => hoisted.state.previousCultureSlug, +})); + +vi.mock("yup", () => ({ + setLocale: vi.fn(), +})); + +function navigateTo(url: string): void { + history.pushState(null, "", url); +} + +async function importComposable() { + // Always import fresh module state + const mod = await import("@/core/composables/useLanguages"); + return mod; +} + +describe("useLanguages", () => { + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + hoisted.state.pinnedLocale.value = null; + hoisted.state.contactCultureName.value = undefined; + hoisted.state.previousCultureSlug.value = { cultureName: "", slug: "" }; + navigateTo("/"); + // Reset default language and available languages if modified by a test + hoisted.state.themeContext.value = { + defaultLanguage: hoisted.langs.enUS, + availableLanguages: [ + hoisted.langs.enUS, + hoisted.langs.frFR, + hoisted.langs.deDE, + hoisted.langs.ptBR, + hoisted.langs.ptPT, + ], + }; + document.documentElement.lang = ""; + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe("resolveLocale priority", () => { + it("prefers full culture from URL when present", async () => { + navigateTo("/fr-FR/cart"); + const { useLanguages } = await importComposable(); + const languages = useLanguages(); + expect(languages.resolveLocale()).toBe("fr-FR"); + }); + + it("prefers short alias from URL and maps it to culture", async () => { + navigateTo("/fr/cart"); + const { useLanguages } = await importComposable(); + const languages = useLanguages(); + expect(languages.resolveLocale()).toBe("fr-FR"); + }); + + it("falls back to pinned locale when supported and no URL locale", async () => { + hoisted.state.pinnedLocale.value = "de-DE"; + navigateTo("/cart"); + const { useLanguages } = await importComposable(); + const languages = useLanguages(); + expect(languages.resolveLocale()).toBe("de-DE"); + }); + + it("falls back to contactCultureName when supported and no URL/pinned", async () => { + hoisted.state.contactCultureName.value = "fr-FR"; + hoisted.state.pinnedLocale.value = null; + navigateTo("/"); + const { useLanguages } = await importComposable(); + const languages = useLanguages(); + expect(languages.resolveLocale()).toBe("fr-FR"); + }); + + it("falls back to default store culture when nothing else matches", async () => { + hoisted.state.contactCultureName.value = "ru-RU"; // unsupported + hoisted.state.pinnedLocale.value = null; + navigateTo("/"); + const { useLanguages } = await importComposable(); + const languages = useLanguages(); + expect(languages.resolveLocale()).toBe("en-US"); + }); + }); + + describe("URL helpers", () => { + it("getLocaleFromUrl detects full culture", async () => { + navigateTo("/fr-FR/cart"); + const { useLanguages } = await importComposable(); + const languages = useLanguages(); + expect(languages.getLocaleFromUrl()).toBe("fr-FR"); + }); + + it("getLocaleFromUrl detects short alias", async () => { + navigateTo("/fr/cart"); + const { useLanguages } = await importComposable(); + const languages = useLanguages(); + expect(languages.getLocaleFromUrl()).toBe("fr"); + }); + + it("getLocaleFromUrl returns undefined when no locale is present", async () => { + navigateTo("/cart"); + const { useLanguages } = await importComposable(); + const languages = useLanguages(); + expect(languages.getLocaleFromUrl()).toBeUndefined(); + }); + + it("getLocaleFromUrl returns undefined when no supported locale is present", async () => { + navigateTo("/ru-RU/cart"); + const { useLanguages } = await importComposable(); + const languages = useLanguages(); + expect(languages.getLocaleFromUrl()).toBeUndefined(); + }); + + it("removeLocaleFromUrl strips short alias locale prefix and preserves query/hash", async () => { + navigateTo("/fr/cart?x=1#sec"); + const { useLanguages } = await importComposable(); + const languages = useLanguages(); + languages.removeLocaleFromUrl(); + expect(location.pathname).toBe("/cart"); + expect(location.search).toBe("?x=1"); + expect(location.hash).toBe("#sec"); + }); + + it("removeLocaleFromUrl strips full locale prefix and preserves query/hash", async () => { + navigateTo("/fr-FR/cart?x=1#sec"); + const { useLanguages } = await importComposable(); + const languages = useLanguages(); + languages.removeLocaleFromUrl(); + expect(location.pathname).toBe("/cart"); + expect(location.search).toBe("?x=1"); + expect(location.hash).toBe("#sec"); + }); + + it("removeLocaleFromUrl does nothing when no locale is present", async () => { + navigateTo("/cart?x=1#sec"); + const { useLanguages } = await importComposable(); + const languages = useLanguages(); + languages.removeLocaleFromUrl(); + expect(location.pathname).toBe("/cart"); + expect(location.search).toBe("?x=1"); + expect(location.hash).toBe("#sec"); + }); + + it("removeLocaleFromUrl does nothing when no supported locale is present", async () => { + navigateTo("/ru-RU/cart?x=1#sec"); + const { useLanguages } = await importComposable(); + const languages = useLanguages(); + languages.removeLocaleFromUrl(); + expect(location.pathname).toBe("/ru-RU/cart"); + expect(location.search).toBe("?x=1"); + expect(location.hash).toBe("#sec"); + }); + + it("updateLocalizedUrl updates URL with permalink like '/bonjour'", async () => { + navigateTo("/fr/hello?x=1#sec"); + const { useLanguages } = await importComposable(); + const languages = useLanguages(); + languages.updateLocalizedUrl("/bonjour"); + expect(location.pathname).toBe("/fr/bonjour"); + expect(location.search).toBe("?x=1"); + expect(location.hash).toBe("#sec"); + }); + + it("updateLocalizedUrl updates URL with permalink like 'bonjour'", async () => { + navigateTo("/fr/hello?x=1#sec"); + const { useLanguages } = await importComposable(); + const languages = useLanguages(); + languages.updateLocalizedUrl("bonjour"); + expect(location.pathname).toBe("/fr/bonjour"); + expect(location.search).toBe("?x=1"); + expect(location.hash).toBe("#sec"); + }); + + it("updateLocalizedUrl does nothing when permalink is falsy", async () => { + navigateTo("/fr/cart?x=1#sec"); + const { useLanguages } = await importComposable(); + const languages = useLanguages(); + languages.updateLocalizedUrl(""); + expect(location.pathname).toBe("/fr/cart"); + expect(location.search).toBe("?x=1"); + expect(location.hash).toBe("#sec"); + }); + }); + + describe("URL short alias disambiguation", () => { + it("does not match 'pt' when both pt-BR and pt-PT are available fallback to default language", async () => { + navigateTo("/pt/cart"); + const { useLanguages } = await importComposable(); + const languages = useLanguages(); + + expect(languages.getLocaleFromUrl()).toBeUndefined(); + expect(languages.resolveLocale()).toBe("en-US"); + }); + + it("matches full locales 'pt-BR' and 'pt-PT' when both are available", async () => { + navigateTo("/pt-BR/cart"); + const { useLanguages } = await importComposable(); + const languages = useLanguages(); + expect(languages.getLocaleFromUrl()).toBe("pt-BR"); + expect(languages.resolveLocale()).toBe("pt-BR"); + + navigateTo("/pt-PT/cart"); + expect(languages.getLocaleFromUrl()).toBe("pt-PT"); + expect(languages.resolveLocale()).toBe("pt-PT"); + }); + + it("accepts 'fr' short alias when only fr-FR exists for that language", async () => { + navigateTo("/fr/cart"); + const { useLanguages } = await importComposable(); + const languages = useLanguages(); + + expect(languages.getLocaleFromUrl()).toBe("fr"); + expect(languages.resolveLocale()).toBe("fr-FR"); + }); + }); + + describe("initLocale side-effects", () => { + it("loads messages when missing, sets composer locale and document lang, and normalizes URL for default", async () => { + navigateTo("/fr-FR/cart"); + const mod = await importComposable(); + const { useLanguages } = mod; + const languages = useLanguages(); + + const i18n: I18n = createI18n("xx-YY", "USD"); + const setMessageSpy = vi.spyOn(i18n.global, "setLocaleMessage"); + + await languages.initLocale(i18n, "en-US"); + + expect(setMessageSpy).toHaveBeenCalledWith("en-US", expect.any(Object)); + expect(i18n.global.locale.value).toBe("en-US"); + expect(document.documentElement.getAttribute("lang")).toBe("en-US"); + expect(setLocaleForYup).toHaveBeenCalledTimes(1); + expect(location.pathname).toBe("/cart"); + }); + }); + + describe("mergeLocalesMessages", () => { + it("deep merges new messages over existing", async () => { + const { useLanguages } = await importComposable(); + const languages = useLanguages(); + const i18n: I18n = createI18n("en-US", "USD"); + i18n.global.setLocaleMessage("en-US", { a: { b: 1 }, c: 2 }); + const setSpy = vi.spyOn(i18n.global, "setLocaleMessage"); + + languages.mergeLocalesMessages(i18n, "en-US", { a: { d: 3 }, c: 4 }); + + expect(setSpy).toHaveBeenLastCalledWith("en-US", { + a: { b: 1, d: 3 }, + c: 4, + }); + }); + }); + + describe("currentLanguage behavior and pin/unpin", () => { + it("returns default language before init and selected after init; setter throws", async () => { + const { useLanguages } = await importComposable(); + const languages = useLanguages(); + expect(languages.currentLanguage.value.cultureName).toBe("en-US"); + + const i18n: I18n = createI18n("en-US", "USD"); + i18n.global.setLocaleMessage("fr-FR", { some: "msg" }); + + await languages.initLocale(i18n, "fr-FR"); + expect(languages.currentLanguage.value.cultureName).toBe("fr-FR"); + expect(() => { + // Attempt to write to computed through a typed cast to bypass readonly at compile time + (languages as unknown as { currentLanguage: { value: ILanguage } }).currentLanguage.value = hoisted.langs.deDE; + }).toThrowError(/read only/); + }); + + it("pinLocale and unpinLocale update pinned storage ref", async () => { + const { useLanguages } = await importComposable(); + const languages = useLanguages(); + languages.pinLocale("fr-FR"); + expect(languages.pinnedLocale.value).toBe("fr-FR"); + languages.unpinLocale(); + expect(languages.pinnedLocale.value).toBeNull(); + }); + }); +}); diff --git a/client-app/core/composables/useLanguages.ts b/client-app/core/composables/useLanguages.ts index 256e44ff71..3d4ad176b0 100644 --- a/client-app/core/composables/useLanguages.ts +++ b/client-app/core/composables/useLanguages.ts @@ -1,7 +1,8 @@ -import { useLocalStorage } from "@vueuse/core"; +import { useLocalStorage, useSessionStorage } from "@vueuse/core"; import { merge } from "lodash"; import { computed, ref } from "vue"; import { setLocale as setLocaleForYup } from "yup"; +import { useUser } from "@/shared/account/composables/useUser"; import { useThemeContext } from "./useThemeContext"; import type { ILanguage } from "../types"; import type { I18n } from "@/i18n"; @@ -10,38 +11,71 @@ import type { Composer, LocaleMessageValue } from "vue-i18n"; const { themeContext } = useThemeContext(); -const pinedLocale = useLocalStorage("pinedLocale", null); +const pinnedLocale = useLocalStorage("pinnedLocale", null); +const previousCultureSlug = useSessionStorage<{ cultureName: string; slug: string }>("previousCultureSlug", { + cultureName: "", + slug: "", +}); + +const defaultStoreLanguage = computed(() => themeContext.value.defaultLanguage); +const defaultStoreCulture = computed(() => defaultStoreLanguage.value.cultureName); -const defaultLanguage = computed(() => themeContext.value.defaultLanguage); -const defaultLocale = computed(() => defaultLanguage.value.twoLetterLanguageName); const supportedLanguages = computed(() => themeContext.value.availableLanguages); -const supportedLocales = computed(() => supportedLanguages.value.map((item) => item.twoLetterLanguageName)); -const URL_LOCALE_REGEX = /^\/([a-z]{2})(\/|$)/; +const supportedCultureNames = computed(() => supportedLanguages.value.map((language) => language.cultureName)); +const supportedLocalesWithShortAliases = computed(() => + supportedLanguages.value.flatMap((language) => { + const maybeShortLocale = tryShortLocale(language.cultureName); + return maybeShortLocale === language.cultureName + ? [language.cultureName] + : [language.cultureName, maybeShortLocale]; + }), +); + +const supportedLocalesRegex = computed( + () => new RegExp(`^/(?${supportedLocalesWithShortAliases.value.join("|")})(/|$)`, "i"), +); const currentLanguage = ref(); +const currentMaybeShortLocale = computed(() => { + return tryShortLocale(currentLanguage.value?.cultureName ?? ""); +}); + +function tryShortLocale(localeOrCultureName: string) { + const twoLetterLanguageName = localeOrCultureName.slice(0, 2); + + return supportedLanguages.value.filter((language) => language.twoLetterLanguageName === twoLetterLanguageName) + .length === 1 + ? twoLetterLanguageName + : localeOrCultureName; +} function fetchLocaleMessages(locale: string): Promise { - const locales = import.meta.glob("../../../locales/*.json"); - const path = `../../../locales/${locale}.json`; + const localesPathPrefix = "../../../locales"; + + const locales = import.meta.glob("../../../locales/*.json"); // can't use variables in import + const path = `${localesPathPrefix}/${locale}.json`; + const shortPath = `${localesPathPrefix}/${locale.slice(0, 2)}.json`; if (locales[path]) { return locales[path](); + } else if (locale.length > 2 && locales[shortPath]) { + return locales[shortPath](); // try get short locale as a fallback (e.g. en-US.json -> en.json) } - return import("../../../locales/en.json"); + return import("../../../locales/en.json"); // can't use variables in import } -async function initLocale(i18n: I18n, locale: string): Promise { - currentLanguage.value = supportedLanguages.value.find((x) => x.twoLetterLanguageName === locale); +async function initLocale(i18n: I18n, cultureName: string): Promise { + currentLanguage.value = supportedLanguages.value.find((x) => x.cultureName === cultureName); - let messages = i18n.global.getLocaleMessage(locale); + let messages = i18n.global.getLocaleMessage(cultureName); if (!Object.keys(messages).length) { - messages = await fetchLocaleMessages(locale); - i18n.global.setLocaleMessage(locale, messages); + messages = await fetchLocaleMessages(cultureName); + i18n.global.setLocaleMessage(cultureName, messages); } - (i18n.global as unknown as Composer).locale.value = locale; + (i18n.global as unknown as Composer).locale.value = cultureName; setLocaleForYup({ mixed: { @@ -53,15 +87,24 @@ async function initLocale(i18n: I18n, locale: string): Promise { }, }); - document.documentElement.setAttribute("lang", locale); + const localeFromUrl = getLocaleFromUrl(); + const isDefault = defaultStoreLanguage.value.cultureName === cultureName; + + if ((localeFromUrl && currentMaybeShortLocale.value !== localeFromUrl) || isDefault) { + // remove a full locale from the url beforehand in order to avoid case like /fr-FR -> /fr/-FR (when language has short alias) + // remove the default locale e.g. en-US from the url - /en-US/cart -> /cart + history.pushState(null, "", location.href.replace(new RegExp(`/${localeFromUrl}`), "")); + } + + document.documentElement.setAttribute("lang", cultureName); } function getLocaleFromUrl(): string | undefined { - return window.location.pathname.match(URL_LOCALE_REGEX)?.[1]; + return supportedLocalesRegex.value.exec(location.pathname)?.groups?.locale; } function removeLocaleFromUrl() { - const fullPath = window.location.pathname + window.location.search + window.location.hash; + const fullPath = location.pathname + location.search + location.hash; const newUrl = getUrlWithoutLocale(fullPath); if (fullPath !== newUrl) { @@ -70,65 +113,94 @@ function removeLocaleFromUrl() { } function getUrlWithoutLocale(fullPath: string): string { - const locale = fullPath.match(URL_LOCALE_REGEX)?.[1]; + const locale = supportedLocalesRegex.value.exec(fullPath)?.groups?.locale; - if (locale && isLocaleSupported(locale)) { - return fullPath.replace(URL_LOCALE_REGEX, "/"); + if (locale) { + return fullPath.replace(supportedLocalesRegex.value, "/"); } return fullPath; } function pinLocale(locale: string) { - pinedLocale.value = locale; + pinnedLocale.value = locale; } function unpinLocale() { - pinedLocale.value = null; + pinnedLocale.value = null; } -function isLocaleSupported(locale: string): boolean { - return supportedLocales.value.includes(locale); -} - -function detectLocale(locales: unknown[]): string { - const stringLocales = locales - .filter((locale): locale is string => typeof locale === "string" && locale.length === 2) - .filter(isLocaleSupported); - - return stringLocales[0] || defaultLocale.value; -} - -function mergeLocales(i18n: I18n, locale: string, messages: LocaleMessageValue) { +function mergeLocalesMessages(i18n: I18n, locale: string, messages: LocaleMessageValue) { const existingMessages = i18n.global.getLocaleMessage(locale); i18n.global.setLocaleMessage(locale, merge({}, existingMessages, messages)); } export function useLanguages() { + const { contactCultureName } = useUser(); + + function resolveLocale() { + const urlLocale = getLocaleFromUrl(); + + const urlCultureName = supportedLanguages.value.find( + (x) => x.cultureName === urlLocale || x.twoLetterLanguageName === urlLocale, + )?.cultureName; + + if (urlCultureName) { + return urlCultureName; + } + + if (pinnedLocale.value && supportedCultureNames.value.includes(pinnedLocale.value)) { + return pinnedLocale.value; + } + + if (contactCultureName.value && supportedCultureNames.value.includes(contactCultureName.value)) { + return contactCultureName.value; + } + + return defaultStoreCulture.value; + } + + function updateLocalizedUrl(permalink?: string) { + if (!permalink) { + return; + } + + const localeFromUrl = getLocaleFromUrl(); + const normalizedPermalink = permalink.startsWith("/") ? permalink : `/${permalink}`; + const permalinkWithLocale = localeFromUrl ? `/${localeFromUrl}${normalizedPermalink}` : normalizedPermalink; + + history.pushState(history.state, "", `${permalinkWithLocale}${location.search}${location.hash}`); + } + return { - pinedLocale, - defaultLanguage, - defaultLocale, + pinnedLocale, + defaultStoreLanguage, + defaultStoreCulture, supportedLanguages, - supportedLocales, + previousCultureSlug, + currentMaybeShortLocale, currentLanguage: computed({ get() { - return currentLanguage.value || defaultLanguage.value; + return currentLanguage.value || defaultStoreLanguage.value; }, set() { throw new Error("currentLanguage is read only."); }, }), + initLocale, + resolveLocale, fetchLocaleMessages, + mergeLocalesMessages, pinLocale, unpinLocale, - removeLocaleFromUrl, - detectLocale, + getLocaleFromUrl, - mergeLocales, + getUrlWithoutLocale, + removeLocaleFromUrl, + updateLocalizedUrl, }; } diff --git a/client-app/core/constants/locale.ts b/client-app/core/constants/locale.ts index d145e2fa67..b714f7a75e 100644 --- a/client-app/core/constants/locale.ts +++ b/client-app/core/constants/locale.ts @@ -55,6 +55,7 @@ export const languageToCountryMap: Record = { pa: "in", pl: "pl", pt: "pt", + "pt-br": "br", ro: "ro", ru: "ru", si: "lk", diff --git a/client-app/core/utilities/common/index.test.ts b/client-app/core/utilities/common/index.test.ts index 7c936c5755..cfe695acaa 100644 --- a/client-app/core/utilities/common/index.test.ts +++ b/client-app/core/utilities/common/index.test.ts @@ -1,6 +1,5 @@ import { describe, it, expect, afterEach, vi } from "vitest"; import { - getBaseUrl, getReturnUrlValue, extractHostname, truncate, @@ -17,48 +16,6 @@ import { } from "./index"; import type { RouteLocationNormalized } from "vue-router"; -describe("getBaseUrl", () => { - const originalLocation = window.location; - - afterEach(() => { - // Restore the original location after each test - Object.defineProperty(window, "location", { - configurable: true, - value: originalLocation, - }); - }); - - it("should return base URL with locale when locale is in pathname", () => { - const supportedLocales = ["en", "fr", "de"]; - - // Mock location.pathname - Object.defineProperty(window, "location", { - configurable: true, - value: { - pathname: "/en/some/path", - }, - }); - - const result = getBaseUrl(supportedLocales); - expect(result).toBe("/en/"); - }); - - it("should return empty string when locale is not in pathname", () => { - const supportedLocales = ["en", "fr", "de"]; - - // Mock location.pathname - Object.defineProperty(window, "location", { - configurable: true, - value: { - pathname: "/some/path", - }, - }); - - const result = getBaseUrl(supportedLocales); - expect(result).toBe(""); - }); -}); - describe("getReturnUrlValue", () => { const originalLocation = window.location; @@ -577,11 +534,7 @@ describe("buildRedirectUrl", () => { it("should return null when any matched route has redirectable: false", () => { const route = { - matched: [ - { meta: { redirectable: true } }, - { meta: { redirectable: false } }, - { meta: { redirectable: true } }, - ], + matched: [{ meta: { redirectable: true } }, { meta: { redirectable: false } }, { meta: { redirectable: true } }], query: {}, fullPath: "/test/path", } as unknown as RouteLocationNormalized; diff --git a/client-app/core/utilities/common/index.ts b/client-app/core/utilities/common/index.ts index 082727c0fd..4c4cbeecaa 100644 --- a/client-app/core/utilities/common/index.ts +++ b/client-app/core/utilities/common/index.ts @@ -1,11 +1,6 @@ import uniqBy from "lodash/uniqBy"; import type { RouteLocationRaw, RouteLocationNormalizedLoaded, RouteLocationNormalized } from "vue-router"; -export function getBaseUrl(supportedLocales: string[]): string { - const localeInPath = location.pathname.split("/")[1]; - return supportedLocales.includes(localeInPath) ? `/${localeInPath}/` : ""; -} - const RETURN_URL_KEYS = ["returnUrl", "ReturnUrl"] as const; export function getReturnUrlValue(): string | null { @@ -25,8 +20,10 @@ export function getReturnUrlValue(): string | null { return null; } -export function buildRedirectUrl(route: RouteLocationNormalized): { [key in typeof RETURN_URL_KEYS[0]]: string } | null { - if (route.matched.some(r => r.meta?.redirectable === false)) { +export function buildRedirectUrl( + route: RouteLocationNormalized, +): { [key in (typeof RETURN_URL_KEYS)[0]]: string } | null { + if (route.matched.some((r) => r.meta?.redirectable === false)) { return null; } @@ -183,3 +180,15 @@ export function preventNonNumberPaste(event: ClipboardEvent) { } } } + +export function safeDecode(input: string) { + try { + return decodeURIComponent(input); + } catch { + try { + return decodeURI(input); + } catch { + return input; + } + } +} diff --git a/client-app/modules/utils.ts b/client-app/modules/utils.ts index c33e18dae0..c7ce5e8799 100644 --- a/client-app/modules/utils.ts +++ b/client-app/modules/utils.ts @@ -5,7 +5,7 @@ import type { I18n } from "@/i18n"; import type { LocaleMessageValue } from "vue-i18n"; export async function loadModuleLocale(i18n: I18n, moduleName: string): Promise { - const { currentLanguage, mergeLocales } = useLanguages(); + const { currentLanguage, mergeLocalesMessages } = useLanguages(); const locale = currentLanguage.value?.twoLetterLanguageName || FALLBACK_LOCALE; try { @@ -24,10 +24,10 @@ export async function loadModuleLocale(i18n: I18n, moduleName: string): Promise< }), ]); - mergeLocales(i18n, locale, moduleMessages); + mergeLocalesMessages(i18n, locale, moduleMessages); if (locale !== FALLBACK_LOCALE) { - mergeLocales(i18n, FALLBACK_LOCALE, moduleFallbackMessages); + mergeLocalesMessages(i18n, FALLBACK_LOCALE, moduleFallbackMessages); } } catch (error) { Logger.error(`Error loading the ${moduleName} module locale: "${locale}"`, error); diff --git a/client-app/pages/brand.vue b/client-app/pages/brand.vue index 1fbc038e1b..92c9f0370c 100644 --- a/client-app/pages/brand.vue +++ b/client-app/pages/brand.vue @@ -49,10 +49,11 @@