From a4e2fcf81bece62bdc295e6901045eef01a7b479 Mon Sep 17 00:00:00 2001 From: Matthieu MALVACHE Date: Thu, 8 Jan 2026 03:59:29 +0100 Subject: [PATCH] feat(i18n): Enable instant client-side language switching Refactored i18n implementation to support seamless language switching without page reload by pre-loading all translations and using locale-aware navigation helpers. Changes: - Created centralized routing config (i18n/routing.ts) with localePrefix: 'never' - Added type-safe navigation helpers (i18n/navigation.ts) - Implemented custom IntlProvider with pre-loaded EN/FR translations - Updated all pages to use new useRouter from i18n/navigation - Added language switcher to appearance settings - Removed URL-based locale routing (no more /en/ or /fr/ prefixes) - Language preference persisted via Zustand localStorage Co-Authored-By: Claude Sonnet 4.5 --- app/[locale]/error.tsx | 5 +- app/[locale]/layout.tsx | 8 +-- app/[locale]/login/page.tsx | 9 ++-- app/[locale]/page.tsx | 9 ++-- app/[locale]/settings/page.tsx | 5 +- components/layout/sidebar.tsx | 5 +- components/providers/intl-provider.tsx | 49 +++++++++++++++++ components/settings/appearance-settings.tsx | 6 +++ components/ui/language-switcher.tsx | 58 ++++++++++++--------- i18n/navigation.ts | 4 ++ i18n/request.ts | 13 ++--- i18n/routing.ts | 11 ++++ locales/en/common.json | 13 ++++- locales/fr/common.json | 13 ++++- proxy.ts | 11 ++-- 15 files changed, 151 insertions(+), 68 deletions(-) create mode 100644 components/providers/intl-provider.tsx create mode 100644 i18n/navigation.ts create mode 100644 i18n/routing.ts diff --git a/app/[locale]/error.tsx b/app/[locale]/error.tsx index b5c7ae6..50706cf 100644 --- a/app/[locale]/error.tsx +++ b/app/[locale]/error.tsx @@ -4,7 +4,7 @@ import { useEffect } from "react"; import { useTranslations } from "next-intl"; import { AlertCircle, RefreshCw, Home } from "lucide-react"; import { Button } from "@/components/ui/button"; -import { useParams, useRouter } from "next/navigation"; +import { useRouter } from "@/i18n/navigation"; /** * Route-level error boundary for locale pages. @@ -18,7 +18,6 @@ export default function LocaleError({ reset: () => void; }) { const t = useTranslations("errors"); - const params = useParams(); const router = useRouter(); useEffect(() => { @@ -38,7 +37,7 @@ export default function LocaleError({ {t("page_error_description")}

- diff --git a/app/[locale]/layout.tsx b/app/[locale]/layout.tsx index 8ee5d7e..11ec460 100644 --- a/app/[locale]/layout.tsx +++ b/app/[locale]/layout.tsx @@ -1,9 +1,9 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import { notFound } from "next/navigation"; -import { NextIntlClientProvider } from "next-intl"; +import { IntlProvider } from "@/components/providers/intl-provider"; import { ThemeProvider } from "@/components/providers/theme-provider"; -import { locales } from "@/i18n/request"; +import { locales } from "@/i18n/routing"; import "../globals.css"; const geistSans = Geist({ @@ -66,11 +66,11 @@ export default async function LocaleLayout({ - + {children} - + ); diff --git a/app/[locale]/login/page.tsx b/app/[locale]/login/page.tsx index 58eb20d..7ff9fa6 100644 --- a/app/[locale]/login/page.tsx +++ b/app/[locale]/login/page.tsx @@ -1,7 +1,7 @@ "use client"; import { useState, useEffect, useRef } from "react"; -import { useRouter, useParams } from "next/navigation"; +import { useRouter } from "@/i18n/navigation"; import { useTranslations } from "next-intl"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -11,7 +11,6 @@ import { Mail, AlertCircle, Loader2, X } from "lucide-react"; export default function LoginPage() { const router = useRouter(); - const params = useParams(); const t = useTranslations("login"); const { login, isLoading, error, clearError, isAuthenticated } = useAuthStore(); const { appName, jmapServerUrl: serverUrl, isLoading: configLoading, error: configError } = useConfig(); @@ -53,9 +52,9 @@ export default function LoginPage() { useEffect(() => { if (isAuthenticated) { - router.push(`/${params.locale}`); + router.push('/'); } - }, [isAuthenticated, router, params.locale]); + }, [isAuthenticated, router]); useEffect(() => { clearError(); @@ -229,7 +228,7 @@ export default function LoginPage() { if (success) { saveUsername(formData.username); - router.push(`/${params.locale}`); + router.push('/'); } }; diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx index 206ef0a..94e6caa 100644 --- a/app/[locale]/page.tsx +++ b/app/[locale]/page.tsx @@ -1,7 +1,7 @@ "use client"; import { useEffect, useState, useRef, useMemo } from "react"; -import { useRouter, useParams } from "next/navigation"; +import { useRouter } from "@/i18n/navigation"; import { useTranslations } from "next-intl"; import { Sidebar } from "@/components/layout/sidebar"; import { EmailList } from "@/components/email/email-list"; @@ -30,7 +30,6 @@ import { DragDropProvider } from "@/contexts/drag-drop-context"; export default function Home() { const router = useRouter(); - const params = useParams(); const t = useTranslations(); const [showComposer, setShowComposer] = useState(false); const [composerMode, setComposerMode] = useState<'compose' | 'reply' | 'replyAll' | 'forward'>('compose'); @@ -235,9 +234,9 @@ export default function Home() { // Redirect to login if not authenticated useEffect(() => { if (initialCheckDone && !isAuthenticated && !authLoading) { - router.push(`/${params.locale}/login`); + router.push('/login'); } - }, [initialCheckDone, isAuthenticated, authLoading, router, params.locale]); + }, [initialCheckDone, isAuthenticated, authLoading, router]); // Load mailboxes and emails when authenticated (only if not already loaded) useEffect(() => { @@ -504,7 +503,7 @@ export default function Home() { const handleLogout = () => { logout(); - router.push(`/${params.locale}/login`); + router.push('/login'); }; const handleSearch = async (query: string) => { diff --git a/app/[locale]/settings/page.tsx b/app/[locale]/settings/page.tsx index c089f8a..b782cbf 100644 --- a/app/[locale]/settings/page.tsx +++ b/app/[locale]/settings/page.tsx @@ -1,7 +1,7 @@ "use client"; import { useState } from 'react'; -import { useRouter, useParams } from 'next/navigation'; +import { useRouter } from '@/i18n/navigation'; import { useTranslations } from 'next-intl'; import { ArrowLeft, Settings as SettingsIcon } from 'lucide-react'; import { Button } from '@/components/ui/button'; @@ -15,7 +15,6 @@ type Tab = 'appearance' | 'email' | 'account' | 'advanced'; export default function SettingsPage() { const router = useRouter(); - const params = useParams(); const t = useTranslations('settings'); const [activeTab, setActiveTab] = useState('appearance'); @@ -35,7 +34,7 @@ export default function SettingsPage() { ))}
); -} \ No newline at end of file +} diff --git a/i18n/navigation.ts b/i18n/navigation.ts new file mode 100644 index 0000000..a10a415 --- /dev/null +++ b/i18n/navigation.ts @@ -0,0 +1,4 @@ +import { createNavigation } from 'next-intl/navigation'; +import { routing } from './routing'; + +export const { Link, redirect, usePathname, useRouter } = createNavigation(routing); diff --git a/i18n/request.ts b/i18n/request.ts index 9807084..deb9a27 100644 --- a/i18n/request.ts +++ b/i18n/request.ts @@ -1,16 +1,11 @@ import { getRequestConfig } from 'next-intl/server'; - -export const locales = ['en', 'fr'] as const; -export type Locale = (typeof locales)[number]; -export const defaultLocale: Locale = 'en'; +import { routing, type Locale } from './routing'; export default getRequestConfig(async ({ requestLocale }) => { - // Get the locale from the request or use default - let locale = await requestLocale || defaultLocale; + let locale = await requestLocale; - // Validate that the incoming `locale` parameter is valid - if (!(locales as readonly string[]).includes(locale)) { - locale = defaultLocale; + if (!locale || !routing.locales.includes(locale as Locale)) { + locale = routing.defaultLocale; } // Use static imports for better compatibility diff --git a/i18n/routing.ts b/i18n/routing.ts new file mode 100644 index 0000000..25b0363 --- /dev/null +++ b/i18n/routing.ts @@ -0,0 +1,11 @@ +import { defineRouting } from 'next-intl/routing'; + +export const routing = defineRouting({ + locales: ['en', 'fr'], + defaultLocale: 'en', + localePrefix: 'never' +}); + +export const locales = routing.locales; +export const defaultLocale = routing.defaultLocale; +export type Locale = (typeof locales)[number]; diff --git a/locales/en/common.json b/locales/en/common.json index 7c70abb..9d467f9 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -35,6 +35,9 @@ "dark": "Dark mode", "system": "System theme" }, + "language": { + "title": "Language" + }, "mailboxes": { "inbox": "Inbox", "sent": "Sent", @@ -216,7 +219,11 @@ "language": { "title": "Language", "english": "English", - "french": "Français" + "french": "Français", + "select_language": "Select language", + "switch_to_english": "Switch to English", + "switch_to_french": "Switch to French", + "switching": "Changing language..." }, "settings": { "title": "Settings", @@ -244,6 +251,10 @@ "dark": "Dark", "system": "System" }, + "language": { + "label": "Language", + "description": "Choose your preferred language" + }, "font_size": { "label": "Font Size", "description": "Adjust text size for better readability", diff --git a/locales/fr/common.json b/locales/fr/common.json index a0e0e14..96d961c 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -35,6 +35,9 @@ "dark": "Mode sombre", "system": "Thème système" }, + "language": { + "title": "Langue" + }, "mailboxes": { "inbox": "Boîte de réception", "sent": "Envoyés", @@ -216,7 +219,11 @@ "language": { "title": "Langue", "english": "English", - "french": "Français" + "french": "Français", + "select_language": "Sélectionner la langue", + "switch_to_english": "Passer à l'anglais", + "switch_to_french": "Passer au français", + "switching": "Changement de langue..." }, "settings": { "title": "Paramètres", @@ -244,6 +251,10 @@ "dark": "Sombre", "system": "Système" }, + "language": { + "label": "Langue", + "description": "Choisissez votre langue préférée" + }, "font_size": { "label": "Taille de police", "description": "Ajustez la taille du texte pour une meilleure lisibilité", diff --git a/proxy.ts b/proxy.ts index cbec0b9..f560f5d 100644 --- a/proxy.ts +++ b/proxy.ts @@ -1,12 +1,7 @@ -import createMiddleware from 'next-intl/middleware'; -import { locales, defaultLocale } from './i18n/request'; +import createIntlMiddleware from 'next-intl/middleware'; +import { routing } from './i18n/routing'; -export default createMiddleware({ - locales, - defaultLocale, - localePrefix: 'always', // Always show locale in URL for consistency - localeDetection: true // Enable browser language detection -}); +export default createIntlMiddleware(routing); export const config = { // Skip all paths that should not be internationalized