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 <noreply@anthropic.com>
This commit is contained in:
Matthieu MALVACHE
2026-01-08 03:59:29 +01:00
co-authored by Claude Sonnet 4.5
parent 4032692700
commit a4e2fcf81b
15 changed files with 151 additions and 68 deletions
+2 -3
View File
@@ -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")}
</p>
<div className="flex gap-3 justify-center">
<Button variant="outline" onClick={() => router.push(`/${params.locale}`)}>
<Button variant="outline" onClick={() => router.push('/')}>
<Home className="w-4 h-4 mr-2" />
{t("go_home")}
</Button>
+4 -4
View File
@@ -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({
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<NextIntlClientProvider locale={locale} messages={messages}>
<IntlProvider locale={locale} messages={messages}>
<ThemeProvider>
{children}
</ThemeProvider>
</NextIntlClientProvider>
</IntlProvider>
</body>
</html>
);
+4 -5
View File
@@ -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('/');
}
};
+4 -5
View File
@@ -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) => {
+2 -3
View File
@@ -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<Tab>('appearance');
@@ -35,7 +34,7 @@ export default function SettingsPage() {
<Button
variant="ghost"
size="sm"
onClick={() => router.push(`/${params.locale}`)}
onClick={() => router.push('/')}
className="w-full justify-start"
>
<ArrowLeft className="w-4 h-4 mr-2" />
+2 -3
View File
@@ -2,7 +2,7 @@
import { useState, useEffect } from "react";
import { useTranslations } from "next-intl";
import { useParams, useRouter } from "next/navigation";
import { useRouter } from "@/i18n/navigation";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
@@ -226,7 +226,6 @@ export function Sidebar({
useEffect(() => {
setSearchQuery(activeSearchQuery);
}, [activeSearchQuery]);
const params = useParams();
const router = useRouter();
// Load expanded folders from localStorage on mount
@@ -437,7 +436,7 @@ export function Sidebar({
<div className="border-t border-border mt-2 pt-2">
{/* Settings */}
<button
onClick={() => router.push(`/${params.locale}/settings`)}
onClick={() => router.push('/settings')}
className="w-full px-4 py-2 flex items-center justify-between hover:bg-muted transition-colors text-sm"
>
<span className="flex items-center gap-2">
+49
View File
@@ -0,0 +1,49 @@
"use client";
import { useEffect, useState } from 'react';
import { NextIntlClientProvider } from 'next-intl';
import { useLocaleStore } from '@/stores/locale-store';
import enMessages from '@/locales/en/common.json';
import frMessages from '@/locales/fr/common.json';
// Pre-loaded translations (loaded at build time, not runtime)
const ALL_MESSAGES = {
en: enMessages,
fr: frMessages,
};
interface IntlProviderProps {
locale: string;
messages: any;
children: React.ReactNode;
}
export function IntlProvider({ locale: initialLocale, children }: IntlProviderProps) {
const currentLocale = useLocaleStore((state) => state.locale);
const setLocale = useLocaleStore((state) => state.setLocale);
const [activeLocale, setActiveLocale] = useState(currentLocale || initialLocale);
// Sync initial locale with store on first mount only
useEffect(() => {
if (!currentLocale) {
setLocale(initialLocale);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Switch locale immediately when store changes
useEffect(() => {
if (currentLocale) {
setActiveLocale(currentLocale);
}
}, [currentLocale]);
return (
<NextIntlClientProvider
locale={activeLocale}
messages={ALL_MESSAGES[activeLocale as keyof typeof ALL_MESSAGES]}
>
{children}
</NextIntlClientProvider>
);
}
@@ -3,6 +3,7 @@
import { useTranslations } from 'next-intl';
import { useThemeStore } from '@/stores/theme-store';
import { useSettingsStore } from '@/stores/settings-store';
import { LanguageSwitcher } from '@/components/ui/language-switcher';
import { SettingsSection, SettingItem, RadioGroup, ToggleSwitch } from './settings-section';
export function AppearanceSettings() {
@@ -25,6 +26,11 @@ export function AppearanceSettings() {
/>
</SettingItem>
{/* Language */}
<SettingItem label={t('language.label')} description={t('language.description')}>
<LanguageSwitcher />
</SettingItem>
{/* Font Size */}
<SettingItem label={t('font_size.label')} description={t('font_size.description')}>
<RadioGroup
+32 -26
View File
@@ -1,46 +1,52 @@
"use client";
import { useParams, usePathname, useRouter } from 'next/navigation';
import { useTranslations } from 'next-intl';
import { useLocale, useTranslations } from 'next-intl';
import { cn } from '@/lib/utils';
import { locales } from '@/i18n/request';
import { useLocaleStore } from '@/stores/locale-store';
export function LanguageSwitcher({ className }: { className?: string }) {
const router = useRouter();
const pathname = usePathname();
const params = useParams();
const currentLocale = useLocale();
const t = useTranslations('language');
const currentLocale = params.locale as string;
const setLocale = useLocaleStore((state) => state.setLocale);
const handleLanguageChange = (newLocale: string) => {
// Get the path without the locale prefix
const pathWithoutLocale = pathname.replace(`/${currentLocale}`, '');
if (newLocale === currentLocale) return;
// Navigate to the same page with the new locale
router.push(`/${newLocale}${pathWithoutLocale}`);
// Update locale in store (persisted to localStorage via Zustand)
// IntlProvider handles the translation switch
setLocale(newLocale);
};
const languages = [
{ value: 'en', label: 'English' },
{ value: 'fr', label: 'Français' }
];
return (
<div className={cn("flex items-center gap-1 p-1 bg-muted rounded-lg", className)}>
{locales.map((locale) => (
<div
className={cn("flex gap-2", className)}
role="radiogroup"
aria-label={t('select_language')}
>
{languages.map((lang) => (
<button
key={locale}
onClick={() => handleLanguageChange(locale)}
key={lang.value}
type="button"
role="radio"
aria-checked={currentLocale === lang.value}
aria-label={t(lang.value === 'en' ? 'switch_to_english' : 'switch_to_french')}
onClick={() => handleLanguageChange(lang.value)}
className={cn(
"flex-1 flex items-center justify-center gap-1.5 px-2 py-1.5 rounded transition-all text-xs",
"text-foreground",
currentLocale === locale
? "bg-background shadow-sm font-medium"
: "hover:bg-accent/50"
"px-3 py-1.5 text-xs rounded transition-colors",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
currentLocale === lang.value
? "bg-primary text-primary-foreground"
: "bg-muted hover:bg-accent text-foreground"
)}
title={t(locale === 'en' ? 'english' : 'french')}
>
{locale === 'en' ? '🇬🇧' : '🇫🇷'}
<span className="hidden sm:inline">
{locale.toUpperCase()}
</span>
{lang.label}
</button>
))}
</div>
);
}
}
+4
View File
@@ -0,0 +1,4 @@
import { createNavigation } from 'next-intl/navigation';
import { routing } from './routing';
export const { Link, redirect, usePathname, useRouter } = createNavigation(routing);
+4 -9
View File
@@ -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
+11
View File
@@ -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];
+12 -1
View File
@@ -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",
+12 -1
View File
@@ -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é",
+3 -8
View File
@@ -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