diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3ac9321..0e324eb 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,5 @@ -import { Route, Routes } from "react-router-dom"; +import { useEffect } from "react"; +import { Navigate, Outlet, Route, Routes, useLocation, useParams } from "react-router-dom"; import "./App.css"; import Home from "./pages/public/Home"; @@ -14,6 +15,135 @@ import ProtectedRoute from "./pages/ProtectedRoute"; import Profile from "./pages/authenticated/Profile"; import Forums from "./pages/public/travel-advice/Forums"; import EssentialInformation from "./pages/public/travel-advice/EssentialInformation"; +import { useTranslation } from "react-i18next"; +import { + DEFAULT_LOCALE, + normalizeLocale, + resolvePathLocaleSegment, +} from "./i18n/locales"; +import { localizePath, parseLocaleFromPathname } from "./utils/localeRouting"; + +const KNOWN_UNLOCALIZED_PATH_PATTERNS = [ + /^\/$/, + /^\/events(?:\/[^/]+)?\/?$/, + /^\/neighbourhoods\/?$/, + /^\/auth\/?$/, + /^\/travel-advice\/pre-travel-checklist\/?$/, + /^\/travel-advice\/forums(?:\/[^/]+)?\/?$/, + /^\/travel-advice\/essential-information\/?$/, + /^\/profile\/(?:me|[^/]+)\/?$/, +]; + +const isKnownUnlocalizedPath = (pathname: string) => { + return KNOWN_UNLOCALIZED_PATH_PATTERNS.some((pattern) => + pattern.test(pathname), + ); +}; + +const resolveLangQuery = (search: string) => { + const searchParams = new URLSearchParams(search); + const langParam = searchParams.get("lang"); + + if (!langParam) { + return null; + } + + const resolvedLocale = + resolvePathLocaleSegment(langParam)?.locale ?? + normalizeLocale(langParam) ?? + DEFAULT_LOCALE; + + searchParams.delete("lang"); + const nextSearchParams = searchParams.toString(); + + return { + locale: resolvedLocale, + search: nextSearchParams ? `?${nextSearchParams}` : "", + }; +}; + +const MissingLocaleRedirect = () => { + const location = useLocation(); + const langQuery = resolveLangQuery(location.search); + const nextLocale = langQuery?.locale ?? DEFAULT_LOCALE; + const nextSearch = langQuery?.search ?? location.search; + + return ( + + ); +}; + +const LocaleGate = () => { + const { locale } = useParams<{ locale: string }>(); + const location = useLocation(); + const { i18n } = useTranslation(); + const parsedLocation = parseLocaleFromPathname(location.pathname); + const resolvedLocale = resolvePathLocaleSegment(locale); + const langQuery = resolveLangQuery(location.search); + + const isValidLocale = Boolean(resolvedLocale); + const canonicalLocaleSegment = resolvedLocale?.canonicalSegment; + const localeValue = resolvedLocale?.locale; + const shouldCanonicalRedirect = + Boolean(canonicalLocaleSegment) && parsedLocation.needsCanonicalRedirect; + + useEffect(() => { + if (localeValue && i18n.resolvedLanguage !== localeValue) { + void i18n.changeLanguage(localeValue); + } + }, [i18n, localeValue]); + + if (langQuery && isValidLocale) { + const nextPathname = + parsedLocation.pathnameWithoutLocale === "/" + ? "/" + : parsedLocation.pathnameWithoutLocale; + + return ( + + ); + } + + if (!isValidLocale) { + if (langQuery && isKnownUnlocalizedPath(location.pathname)) { + return ( + + ); + } + + return ( + + ); + } + + if (shouldCanonicalRedirect && canonicalLocaleSegment) { + const suffix = + parsedLocation.pathnameWithoutLocale === "/" + ? "" + : parsedLocation.pathnameWithoutLocale; + + return ( + + ); + } + + return ; +}; function App() { return ( @@ -21,25 +151,37 @@ function App() { - } /> - } /> - } /> - } /> - } /> - - } - /> - } /> - } /> - } /> - + } /> + + }> + } /> + } /> + } /> + } /> + } /> - }> - } /> - } /> + + } + /> + } /> + } /> + } + /> + + + }> + } /> + } /> + + + } /> + + } /> diff --git a/frontend/src/components/i18n/LangToggler.tsx b/frontend/src/components/i18n/LangToggler.tsx index 5908d80..1f79539 100644 --- a/frontend/src/components/i18n/LangToggler.tsx +++ b/frontend/src/components/i18n/LangToggler.tsx @@ -2,9 +2,14 @@ import { CiGlobe } from "react-icons/ci"; import { useTranslation } from "react-i18next"; import { useState, useRef, useEffect } from "react"; +import { useLocation, useNavigate } from "react-router-dom"; +import type { AppLocale } from "../../i18n/locales"; +import { replaceLocaleInPathname } from "../../utils/localeRouting"; const LangToggler = () => { const { i18n, t } = useTranslation(); + const location = useLocation(); + const navigate = useNavigate(); const [open, setOpen] = useState(false); const dropdownRef = useRef(null); @@ -24,8 +29,10 @@ const LangToggler = () => { }; }, [open]); - const changeLang = (lng: string) => { - i18n.changeLanguage(lng); + const changeLang = async (lng: AppLocale) => { + const nextPathname = replaceLocaleInPathname(location.pathname, lng); + await i18n.changeLanguage(lng); + navigate(`${nextPathname}${location.search}${location.hash}`); setOpen(false); }; diff --git a/frontend/src/components/navigation/NavMegamenu.tsx b/frontend/src/components/navigation/NavMegamenu.tsx index dff6c23..85ca564 100644 --- a/frontend/src/components/navigation/NavMegamenu.tsx +++ b/frontend/src/components/navigation/NavMegamenu.tsx @@ -1,9 +1,13 @@ import { type SubItem, type MenuProps } from "./types"; import { useTranslation } from "react-i18next"; import { createPortal } from "react-dom"; +import { Link, useLocation } from "react-router-dom"; +import { getLocaleFromPathname, localizePath } from "../../utils/localeRouting"; const NavMegamenu = ({ item, isOpen, onToggle }: MenuProps) => { const { t } = useTranslation(); + const location = useLocation(); + const locale = getLocaleFromPathname(location.pathname); const items = item.items; return ( @@ -25,14 +29,14 @@ const NavMegamenu = ({ item, isOpen, onToggle }: MenuProps) => { {/* Desktop: portal so it escapes z-index/overflow constraints and spans full width */} {createPortal(
- +
, document.body )} {/* Mobile version (below md) */}
- +
); @@ -42,9 +46,11 @@ const NavMegamenu = ({ item, isOpen, onToggle }: MenuProps) => { const ExpandedMenu = ({ items, isOpen, + locale, }: { items: SubItem[]; isOpen: boolean; + locale: Parameters[1]; }) => { const { t } = useTranslation(); @@ -62,9 +68,9 @@ const ExpandedMenu = ({ {/* Inner constrained grid — matches Flowbite's max-w-screen-xl centering */} @@ -84,9 +90,11 @@ const ExpandedMenu = ({ const MobileExpandedMenu = ({ items, isOpen, + locale, }: { items: SubItem[]; isOpen: boolean; + locale: Parameters[1]; }) => { const { t } = useTranslation(); @@ -100,8 +108,8 @@ const MobileExpandedMenu = ({ > {items.map((item) => (
  • -
  • ))} diff --git a/frontend/src/components/navigation/Navbar.tsx b/frontend/src/components/navigation/Navbar.tsx index b271afc..e946423 100644 --- a/frontend/src/components/navigation/Navbar.tsx +++ b/frontend/src/components/navigation/Navbar.tsx @@ -3,6 +3,7 @@ import { RxHamburgerMenu } from "react-icons/rx"; import logo from "../../assets/logo.png"; import { useState, useRef, useEffect } from "react"; +import { Link, useLocation } from "react-router-dom"; import { useTranslation } from "react-i18next"; import LangToggler from "../i18n/LangToggler"; @@ -11,10 +12,13 @@ import { useAuth } from "../../context/AuthContext"; import { NavCenterItems, type MenuType } from "./menu"; import NavMegamenu from "./NavMegamenu"; +import { getLocaleFromPathname, localizePath } from "../../utils/localeRouting"; const ProfileDropdown = () => { const { profile, logout } = useAuth(); const { t } = useTranslation(); + const location = useLocation(); + const locale = getLocaleFromPathname(location.pathname); const [open, setOpen] = useState(false); const ref = useRef(null); @@ -56,20 +60,20 @@ const ProfileDropdown = () => {

    {profile.username}

    - setOpen(false)} > {t("navigation:profileMenu.profile")} - - + setOpen(false)} > {t("navigation:profileMenu.settings")} - +