From 0d68851b636c0299b057e274143fa5ae450a8847 Mon Sep 17 00:00:00 2001 From: Matthieu MALVACHE Date: Thu, 8 Jan 2026 04:29:19 +0100 Subject: [PATCH] feat(i18n): Complete internationalization with timezone fixes and language switching Completes full internationalization coverage for the webmail application with enhanced language support: - Replace all remaining hardcoded English strings with translation keys - Add timezone auto-detection to prevent hydration mismatches - Enable instant client-side language switching without page reload - Add 60+ new translation keys in English and French locales - Improve language switcher component with proper state management - Add health check endpoint for container orchestration All user-facing text now supports English and French with no hardcoded fallbacks, providing a fully localized experience. --- app/[locale]/error.tsx | 5 +- app/[locale]/layout.tsx | 8 +- app/[locale]/login/page.tsx | 9 +- app/[locale]/page.tsx | 97 ++++++--- app/[locale]/settings/page.tsx | 5 +- app/api/health/route.ts | 126 ++++++++++++ components/email/email-composer.tsx | 24 +-- components/email/email-viewer.tsx | 213 +++++++++++++------- components/layout/mobile-header.tsx | 4 +- components/layout/sidebar.tsx | 38 +++- components/providers/intl-provider.tsx | 63 ++++++ 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 | 64 +++++- locales/fr/common.json | 64 +++++- proxy.ts | 11 +- stores/ui-store.ts | 7 + 20 files changed, 638 insertions(+), 192 deletions(-) create mode 100644 app/api/health/route.ts 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 b5c7ae6b..50706cff 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 8ee5d7ea..11ec4607 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 58eb20d3..7ff9fa6a 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 c836dff9..dc961432 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,8 +30,8 @@ import { DragDropProvider } from "@/contexts/drag-drop-context"; export default function Home() { const router = useRouter(); - const params = useParams(); const t = useTranslations(); + const tCommon = useTranslations('common'); const [showComposer, setShowComposer] = useState(false); const [composerMode, setComposerMode] = useState<'compose' | 'reply' | 'replyAll' | 'forward'>('compose'); const [initialCheckDone, setInitialCheckDone] = useState(false); @@ -43,9 +43,9 @@ export default function Home() { const markAsReadTimeoutRef = useRef(null); const { isAuthenticated, client, logout, checkAuth, isLoading: authLoading } = useAuthStore(); - // Mobile responsive hooks - const { isMobile } = useDeviceDetection(); - const { activeView, sidebarOpen, setSidebarOpen, setActiveView } = useUIStore(); + // Mobile/tablet responsive hooks + const { isMobile, isTablet } = useDeviceDetection(); + const { activeView, sidebarOpen, setSidebarOpen, setActiveView, tabletListVisible, setTabletListVisible } = useUIStore(); const { emails, mailboxes, @@ -125,6 +125,9 @@ export default function Home() { if (isMobile) { setActiveView("list"); } + if (isTablet) { + setTabletListVisible(true); + } }, onReply: () => { if (selectedEmail) handleReply(); @@ -180,7 +183,7 @@ export default function Home() { clearSelection(); }, // eslint-disable-next-line react-hooks/exhaustive-deps - }), [emails, selectedEmail, client, selectedMailbox, isMobile]); + }), [emails, selectedEmail, client, selectedMailbox, isMobile, isTablet]); // Initialize keyboard shortcuts useKeyboardShortcuts({ @@ -192,7 +195,7 @@ export default function Home() { // Update page title based on context useEffect(() => { - let title = "Webmail"; + let title = tCommon('app_title'); if (showComposer) { // Composing email @@ -202,11 +205,11 @@ export default function Home() { replyAll: t('email_composer.reply_all'), forward: t('email_composer.forward'), }[composerMode] || t('email_composer.new_message'); - title = `${modeText} - Webmail`; + title = `${modeText} - ${tCommon('app_title')}`; } else if (selectedEmail) { // Reading email const subject = selectedEmail.subject || t('email_viewer.no_subject'); - title = `${subject} - Webmail`; + title = `${subject} - ${tCommon('app_title')}`; } else if (selectedMailbox && mailboxes.length > 0) { // Mailbox view const mailbox = mailboxes.find(mb => mb.id === selectedMailbox); @@ -214,13 +217,13 @@ export default function Home() { const mailboxName = mailbox.name; const unreadCount = mailbox.unreadEmails || 0; title = unreadCount > 0 - ? `${mailboxName} (${unreadCount}) - Webmail` - : `${mailboxName} - Webmail`; + ? `${mailboxName} (${unreadCount}) - ${tCommon('app_title')}` + : `${mailboxName} - ${tCommon('app_title')}`; } } document.title = title; - }, [showComposer, composerMode, selectedEmail, selectedMailbox, mailboxes, t]); + }, [showComposer, composerMode, selectedEmail, selectedMailbox, mailboxes, t, tCommon]); // Check auth on mount useEffect(() => { @@ -232,9 +235,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(() => { @@ -346,6 +349,18 @@ export default function Home() { } }, [newEmailNotification, clearNewEmailNotification]); + // Lock body scroll when sidebar is open on mobile/tablet + useEffect(() => { + if ((isMobile || isTablet) && sidebarOpen) { + document.body.style.overflow = 'hidden'; + } else { + document.body.style.overflow = ''; + } + return () => { + document.body.style.overflow = ''; + }; + }, [isMobile, isTablet, sidebarOpen]); + const handleEmailSend = async (data: { to: string[]; cc: string[]; @@ -472,6 +487,11 @@ export default function Home() { setActiveView("list"); } + // On tablet, show the list again + if (isTablet) { + setTabletListVisible(true); + } + if (client) { // If there's an active search, re-run it in the new mailbox if (searchQuery) { @@ -484,7 +504,7 @@ export default function Home() { const handleLogout = () => { logout(); - router.push(`/${params.locale}/login`); + router.push('/login'); }; const handleSearch = async (query: string) => { @@ -556,6 +576,11 @@ export default function Home() { setActiveView("viewer"); } + // On tablet, hide the list to maximize viewer space + if (isTablet) { + setTabletListVisible(false); + } + // Fetch the full content try { // Find selected mailbox to determine accountId (for shared folders) @@ -629,24 +654,24 @@ export default function Home() { return (
- {/* Mobile Sidebar Overlay Backdrop */} - {isMobile && sidebarOpen && ( + {/* Mobile/Tablet Sidebar Overlay Backdrop */} + {(isMobile || isTablet) && sidebarOpen && (
setSidebarOpen(false)} /> )} - {/* Sidebar - overlay on mobile, fixed on desktop */} + {/* Sidebar - overlay on mobile/tablet, fixed on desktop */}
@@ -660,6 +685,7 @@ export default function Home() { if (isMobile) setSidebarOpen(false); }} onLogout={handleLogout} + onSidebarClose={() => setSidebarOpen(false)} onSearch={handleSearch} onClearSearch={handleClearSearch} activeSearchQuery={searchQuery} @@ -671,15 +697,18 @@ export default function Home() { {/* Main Content Area */}
- {/* Email List - full width on mobile, fixed width on desktop */} + {/* Email List - full width on mobile, fixed width on tablet/desktop */} - {/* Email Viewer - full screen on mobile, flex on desktop */} + {/* Email Viewer - full screen on mobile, flex on tablet/desktop */}
@@ -798,6 +827,10 @@ export default function Home() { }} onDownloadAttachment={handleDownloadAttachment} onQuickReply={handleQuickReply} + onBack={() => { + setTabletListVisible(true); + selectEmail(null); + }} currentUserEmail={client?.["username"]} currentUserName={client?.["username"]?.split("@")[0]} className={isMobile ? "flex-1" : undefined} @@ -810,10 +843,10 @@ export default function Home() { {/* Email Composer Modal */} {showComposer && ( -
+
('appearance'); @@ -35,7 +34,7 @@ export default function SettingsPage() {
@@ -366,7 +366,7 @@ export function EmailComposer({ {t('to')}: setTo(e.target.value)} className="flex-1 border-0 focus-visible:ring-0" @@ -393,10 +393,10 @@ export function EmailComposer({ {showCc && (
- Cc: + {t('cc_label')} setCc(e.target.value)} className="flex-1 border-0 focus-visible:ring-0" @@ -406,10 +406,10 @@ export function EmailComposer({ {showBcc && (
- Bcc: + {t('bcc_label')} setBcc(e.target.value)} className="flex-1 border-0 focus-visible:ring-0" @@ -418,10 +418,10 @@ export function EmailComposer({ )}
- Subject: + {t('subject_label')} setSubject(e.target.value)} className="flex-1 border-0 focus-visible:ring-0" @@ -432,7 +432,7 @@ export function EmailComposer({