Skip to content
Merged
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
178 changes: 160 additions & 18 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -14,32 +15,173 @@ 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 (
<Navigate
to={`${localizePath("/", nextLocale)}${nextSearch}${location.hash}`}
replace
/>
);
};

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 (
<Navigate
to={`${localizePath(nextPathname, langQuery.locale)}${langQuery.search}${location.hash}`}
replace
/>
);
}

if (!isValidLocale) {
if (langQuery && isKnownUnlocalizedPath(location.pathname)) {
return (
<Navigate
to={`${localizePath(location.pathname, langQuery.locale)}${langQuery.search}${location.hash}`}
replace
/>
);
}

return (
<Navigate
to={`${localizePath("/", langQuery?.locale ?? DEFAULT_LOCALE)}${langQuery?.search ?? ""}${location.hash}`}
replace
/>
);
}

if (shouldCanonicalRedirect && canonicalLocaleSegment) {
const suffix =
parsedLocation.pathnameWithoutLocale === "/"
? ""
: parsedLocation.pathnameWithoutLocale;

return (
<Navigate
to={`/${canonicalLocaleSegment}${suffix}${location.search}${location.hash}`}
replace
/>
);
}

return <Outlet />;
};

function App() {
return (
<AuthProvider>
<LocationProvider>
<CurrencyProvider>
<Routes>
<Route path="/*" element={<Home />} />
<Route path="/events" element={<Events />} />
<Route path="/events/:id" element={<Events />} />
<Route path="/neighbourhoods" element={<Neighbourhoods />} />
<Route path="/auth" element={<Auth />} />
<Route path="/travel-advice">
<Route
path="pre-travel-checklist"
element={<PreTravelChecklist />}
/>
<Route path="forums" element={<Forums />} />
<Route path="forums/:threadId" element={<Forums />} />
<Route path="essential-information" element={<EssentialInformation />} />
</Route>
<Route path="/" element={<MissingLocaleRedirect />} />

<Route path="/:locale" element={<LocaleGate />}>
<Route index element={<Home />} />
<Route path="events" element={<Events />} />
<Route path="events/:id" element={<Events />} />
<Route path="neighbourhoods" element={<Neighbourhoods />} />
<Route path="auth" element={<Auth />} />

<Route element={<ProtectedRoute />}>
<Route path="/profile/me" element={<Profile />} />
<Route path="/profile/:id" element={<Profile />} />
<Route path="travel-advice">
<Route
path="pre-travel-checklist"
element={<PreTravelChecklist />}
/>
<Route path="forums" element={<Forums />} />
<Route path="forums/:threadId" element={<Forums />} />
<Route
path="essential-information"
element={<EssentialInformation />}
/>
</Route>

<Route element={<ProtectedRoute />}>
<Route path="profile/me" element={<Profile />} />
<Route path="profile/:id" element={<Profile />} />
</Route>

<Route path="*" element={<Home />} />
</Route>

<Route path="*" element={<MissingLocaleRedirect />} />
</Routes>
</CurrencyProvider>
</LocationProvider>
Expand Down
11 changes: 9 additions & 2 deletions frontend/src/components/i18n/LangToggler.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLDivElement>(null);

Expand All @@ -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);
};

Expand Down
24 changes: 16 additions & 8 deletions frontend/src/components/navigation/NavMegamenu.tsx
Original file line number Diff line number Diff line change
@@ -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 (
Expand All @@ -25,14 +29,14 @@ const NavMegamenu = ({ item, isOpen, onToggle }: MenuProps) => {
{/* Desktop: portal so it escapes z-index/overflow constraints and spans full width */}
{createPortal(
<div className="hidden md:block">
<ExpandedMenu items={items} isOpen={isOpen} />
<ExpandedMenu items={items} isOpen={isOpen} locale={locale} />
</div>,
document.body
)}

{/* Mobile version (below md) */}
<div className="md:hidden">
<MobileExpandedMenu items={items} isOpen={isOpen} />
<MobileExpandedMenu items={items} isOpen={isOpen} locale={locale} />
</div>
</div>
);
Expand All @@ -42,9 +46,11 @@ const NavMegamenu = ({ item, isOpen, onToggle }: MenuProps) => {
const ExpandedMenu = ({
items,
isOpen,
locale,
}: {
items: SubItem[];
isOpen: boolean;
locale: Parameters<typeof localizePath>[1];
}) => {
const { t } = useTranslation();

Expand All @@ -62,9 +68,9 @@ const ExpandedMenu = ({
{/* Inner constrained grid — matches Flowbite's max-w-screen-xl centering */}
<div className="mx-auto grid max-w-7xl px-4 py-5 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 md:px-6">
{items.map((item) => (
<a
<Link
key={item.title}
href={item.link ?? "#"}
to={localizePath(item.link ?? "/", locale)}
className="block rounded-lg p-3 hover:bg-gray-50 transition-colors duration-200"
>
<div className="font-semibold text-gray-900">{t(item.title)}</div>
Expand All @@ -73,7 +79,7 @@ const ExpandedMenu = ({
{t(item.description)}
</span>
)}
</a>
</Link>
))}
</div>
</div>
Expand All @@ -84,9 +90,11 @@ const ExpandedMenu = ({
const MobileExpandedMenu = ({
items,
isOpen,
locale,
}: {
items: SubItem[];
isOpen: boolean;
locale: Parameters<typeof localizePath>[1];
}) => {
const { t } = useTranslation();

Expand All @@ -100,8 +108,8 @@ const MobileExpandedMenu = ({
>
{items.map((item) => (
<li key={item.title}>
<a
href={item.link ?? "#"}
<Link
to={localizePath(item.link ?? "/", locale)}
className="block rounded-lg px-3 py-2 hover:bg-gray-50 transition-colors duration-200"
>
<div className="font-semibold text-gray-800 text-sm">
Expand All @@ -112,7 +120,7 @@ const MobileExpandedMenu = ({
{t(item.description)}
</span>
)}
</a>
</Link>
</li>
))}
</ul>
Expand Down
Loading
Loading