From 761e6b3b8e1cbbb58bf207c701ea50b04b24c8b6 Mon Sep 17 00:00:00 2001 From: stephen leong Date: Sat, 18 Apr 2026 16:41:36 +0100 Subject: [PATCH 1/3] feat: implement locale-based routing and internationalization support across the application --- frontend/src/App.tsx | 93 +++++++++++++++---- frontend/src/components/i18n/LangToggler.tsx | 11 ++- .../src/components/navigation/NavMegamenu.tsx | 24 +++-- frontend/src/components/navigation/Navbar.tsx | 34 ++++--- frontend/src/i18n/locales.ts | 43 +++++++++ frontend/src/pages/ProtectedRoute.tsx | 10 +- frontend/src/pages/public/Auth.tsx | 17 +++- .../src/pages/public/travel-advice/Forums.tsx | 15 +-- .../travel-advice/forums/ThreadDetail.tsx | 7 +- .../travel-advice/forums/ThreadsList.tsx | 7 +- frontend/src/utils/i18n.ts | 15 +-- frontend/src/utils/localeRouting.ts | 77 +++++++++++++++ 12 files changed, 289 insertions(+), 64 deletions(-) create mode 100644 frontend/src/i18n/locales.ts create mode 100644 frontend/src/utils/localeRouting.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3ac9321..8bda858 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,50 @@ 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, + getPreferredLocale, + isSupportedLocale, +} from "./i18n/locales"; +import { localizePath } from "./utils/localeRouting"; + +const MissingLocaleRedirect = () => { + const location = useLocation(); + const preferredLocale = getPreferredLocale(); + + return ( + + ); +}; + +const LocaleGate = () => { + const { locale } = useParams<{ locale: string }>(); + const location = useLocation(); + const { i18n } = useTranslation(); + + const isValidLocale = isSupportedLocale(locale); + + useEffect(() => { + if (isValidLocale && i18n.resolvedLanguage !== locale) { + void i18n.changeLanguage(locale); + } + }, [i18n, isValidLocale, locale]); + + if (!isValidLocale) { + return ( + + ); + } + + return ; +}; function App() { return ( @@ -21,25 +66,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")} - +