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
4 changes: 3 additions & 1 deletion frontend/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -65,9 +66,10 @@ export default async function RootLayout({
return (
<html lang={locale} suppressHydrationWarning>
<body className="bg-gray-50 text-gray-900 antialiased dark:bg-gray-900 dark:text-gray-100">
<NextIntlClientProvider messages={messages}>
<NextIntlClientProvider messages={messages}>
<ThemeProvider>
<AuthProvider>
<LocaleInitializer />
<Navbar />
{children}
</AuthProvider>
Expand Down
6 changes: 5 additions & 1 deletion frontend/components/LanguageSwitcher.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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 });
});
Expand Down
37 changes: 37 additions & 0 deletions frontend/components/LocaleInitializer.tsx
Original file line number Diff line number Diff line change
@@ -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;
}
54 changes: 54 additions & 0 deletions frontend/hooks/useLocaleStorage.ts
Original file line number Diff line number Diff line change
@@ -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();
}
67 changes: 67 additions & 0 deletions frontend/lib/storage.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}