From 2ebc048c40e865e8fa7cbb77f8923455904c47ba Mon Sep 17 00:00:00 2001 From: Danielobito009 Date: Sat, 24 Oct 2026 16:26:21 +0100 Subject: [PATCH] feat: add language switcher persistence to LocalStorage - Create storage.ts utility with SSR-safe LocalStorage helpers - Add useLocaleStorage hook for managing locale preferences - Create LocaleInitializer component to restore saved locale on app load - Update LanguageSwitcher to persist selected language - Update root layout to initialize locale on mount Implements #455: Language preferences now persist across sessions and survive page reloads. Invalid/unsupported values are ignored and fall back to the default locale. --- frontend/app/layout.tsx | 4 +- frontend/components/LanguageSwitcher.tsx | 6 +- frontend/components/LocaleInitializer.tsx | 37 +++++++++++++ frontend/hooks/useLocaleStorage.ts | 54 ++++++++++++++++++ frontend/lib/storage.ts | 67 +++++++++++++++++++++++ 5 files changed, 166 insertions(+), 2 deletions(-) create mode 100644 frontend/components/LocaleInitializer.tsx create mode 100644 frontend/hooks/useLocaleStorage.ts create mode 100644 frontend/lib/storage.ts diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index be28e8a..e82d4d2 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -3,6 +3,7 @@ import type { Metadata } from "next"; import { NextIntlClientProvider } from "next-intl"; import { getLocale, getMessages } from "next-intl/server"; import Navbar from "../components/Navbar"; +import LocaleInitializer from "../components/LocaleInitializer"; import ServiceWorkerRegister from "../components/ServiceWorkerRegister"; import { AuthProvider } from "./context/AuthContext"; import ThemeProvider from "./context/ThemeProvider"; @@ -65,9 +66,10 @@ export default async function RootLayout({ return ( - + + {children} diff --git a/frontend/components/LanguageSwitcher.tsx b/frontend/components/LanguageSwitcher.tsx index a18f84b..87cb77a 100644 --- a/frontend/components/LanguageSwitcher.tsx +++ b/frontend/components/LanguageSwitcher.tsx @@ -5,13 +5,15 @@ * * Uses next-intl's locale-aware router so navigation preserves the current * page while switching locale (e.g. `/wallet` → `/yo/wallet`). The choice is - * persisted in the `NEXT_LOCALE` cookie by the i18n middleware. + * persisted to LocalStorage via the storage helper for client-side persistence + * across sessions. */ import { useState, useRef, useEffect, useTransition } from "react"; import { useLocale } from "next-intl"; import { useRouter, usePathname } from "../i18n/navigation"; import { routing } from "../i18n/routing"; +import { saveLocale } from "../lib/storage"; const LOCALE_LABELS: Record<(typeof routing.locales)[number], string> = { en: "English", @@ -45,6 +47,8 @@ export default function LanguageSwitcher() { return; } setOpen(false); + // Persist the selected locale to LocalStorage + saveLocale(next); startTransition(() => { router.replace(pathname, { locale: next }); }); diff --git a/frontend/components/LocaleInitializer.tsx b/frontend/components/LocaleInitializer.tsx new file mode 100644 index 0000000..e040da8 --- /dev/null +++ b/frontend/components/LocaleInitializer.tsx @@ -0,0 +1,37 @@ +/** + * LocaleInitializer — client component that restores the user's saved locale + * preference from LocalStorage on app load. + * + * Runs once on mount to check for a saved locale and redirect to it if: + * 1. A locale is saved in LocalStorage + * 2. It differs from the current locale + * 3. It's a valid, supported locale + * + * This ensures minimal FOUC (Flash of Unstyled Content) or locale mismatch + * after a page reload or new session. + */ + +"use client"; + +import { useEffect } from "react"; +import { useLocale } from "next-intl"; +import { useRouter, usePathname } from "../i18n/navigation"; +import { getSavedLocale } from "../hooks/useLocaleStorage"; + +export default function LocaleInitializer() { + const locale = useLocale(); + const router = useRouter(); + const pathname = usePathname(); + + useEffect(() => { + const saved = getSavedLocale(); + + // Redirect to saved locale if it differs from current + if (saved !== locale) { + router.replace(pathname, { locale: saved }); + } + }, [locale, router, pathname]); + + // Invisible component — renders nothing + return null; +} diff --git a/frontend/hooks/useLocaleStorage.ts b/frontend/hooks/useLocaleStorage.ts new file mode 100644 index 0000000..09ac2b3 --- /dev/null +++ b/frontend/hooks/useLocaleStorage.ts @@ -0,0 +1,54 @@ +/** + * Hook for managing locale persistence with LocalStorage. + * + * Provides utilities for saving, loading, and clearing the user's locale + * preference from client-side storage. + */ + +"use client"; + +import { loadLocale, saveLocale, clearLocale } from "../lib/storage"; +import { routing, type AppLocale } from "../i18n/routing"; + +/** + * Validates that a locale is supported by the application. + */ +function isValidLocale(locale: unknown): locale is AppLocale { + return ( + typeof locale === "string" && + (routing.locales as readonly string[]).includes(locale) + ); +} + +/** + * Gets the saved locale from LocalStorage, if available and valid. + * Falls back to the default locale if not found or invalid. + * + * @returns The saved locale if valid, otherwise the default locale. + */ +export function getSavedLocale(): AppLocale { + const saved = loadLocale(); + if (!saved || !isValidLocale(saved)) { + return routing.defaultLocale as AppLocale; + } + return saved; +} + +/** + * Saves a locale to LocalStorage after validating it. + * Silently ignores invalid locales. + * + * @param locale - The locale to save + */ +export function persistLocale(locale: string): void { + if (isValidLocale(locale)) { + saveLocale(locale); + } +} + +/** + * Clears the saved locale preference, reverting to default on next load. + */ +export function resetLocalePreference(): boolean { + return clearLocale(); +} diff --git a/frontend/lib/storage.ts b/frontend/lib/storage.ts new file mode 100644 index 0000000..067f7de --- /dev/null +++ b/frontend/lib/storage.ts @@ -0,0 +1,67 @@ +/** + * Local storage helpers with SSR/hydration safety. + * + * LocalStorage is only available on the browser, so we guard against access + * during server rendering or before hydration. + */ + +const LOCALE_KEY = "airflex_locale"; + +/** + * Checks if LocalStorage is available (browser environment). + */ +function isLocalStorageAvailable(): boolean { + if (typeof window === "undefined") return false; + try { + const test = "__test__"; + window.localStorage.setItem(test, test); + window.localStorage.removeItem(test); + return true; + } catch { + return false; + } +} + +/** + * Saves the selected locale to LocalStorage. + * Silently fails in SSR/non-browser environments. + */ +export function saveLocale(locale: string): void { + if (!isLocalStorageAvailable()) return; + try { + window.localStorage.setItem(LOCALE_KEY, locale); + } catch (error) { + // LocalStorage quota exceeded or private browsing mode — fail silently + console.warn("[storage] Failed to save locale to LocalStorage", error); + } +} + +/** + * Reads the saved locale from LocalStorage. + * Returns null if not found or unavailable. + */ +export function loadLocale(): string | null { + if (!isLocalStorageAvailable()) return null; + try { + return window.localStorage.getItem(LOCALE_KEY); + } catch (error) { + // Private browsing mode or other storage errors — fail silently + console.warn("[storage] Failed to read locale from LocalStorage", error); + return null; + } +} + +/** + * Clears the saved locale from LocalStorage. + * Returns true if successful, false otherwise. + */ +export function clearLocale(): boolean { + if (!isLocalStorageAvailable()) return false; + try { + window.localStorage.removeItem(LOCALE_KEY); + return true; + } catch (error) { + console.warn("[storage] Failed to clear locale from LocalStorage", error); + return false; + } +}