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
1,117 changes: 1,117 additions & 0 deletions frontend/scripts/generate-locales.mjs

Large diffs are not rendered by default.

9 changes: 2 additions & 7 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { lazy, Suspense } from "react";
import { Routes, Route } from "react-router-dom";
import Layout from "./components/Layout";
import { LoadingFallback } from "./components/LoadingFallback";
import { GlobalErrorBoundary } from "./components/ErrorBoundary";
import { NotificationProvider } from "./context/NotificationContext";
import { useNotifications } from "./hooks/useNotifications";
Expand Down Expand Up @@ -54,13 +55,7 @@ function App() {
<GlobalErrorBoundary>
<NotificationProvider>
<NotificationInitializer />
<Suspense
fallback={
<div className="min-h-screen bg-stellar-dark flex items-center justify-center text-stellar-text-secondary">
Loading page...
</div>
}
>
<Suspense fallback={<LoadingFallback />}>
<Routes>
<Route path="/" element={<Landing />} />

Expand Down
81 changes: 81 additions & 0 deletions frontend/src/components/LanguageSwitcher.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { Suspense } from "react";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { LanguageSwitcher } from "./LanguageSwitcher";
import i18n from "../i18n/config";

function renderLanguageSwitcher() {
return render(
<Suspense fallback={null}>
<LanguageSwitcher />
</Suspense>,
);
}

function getLanguageSwitcherButton() {
return screen.getByRole("button", {
name: /change language|cambiar idioma|changer de langue|sprache ändern|更改语言|言語を変更|언어 변경|تغيير اللغة/i,
});
}

describe("LanguageSwitcher", () => {
beforeEach(async () => {
localStorage.clear();
document.documentElement.dir = "ltr";
document.documentElement.lang = "en";
await i18n.changeLanguage("en");
});

it("lists all supported languages", async () => {
const user = userEvent.setup();
renderLanguageSwitcher();

await user.click(getLanguageSwitcherButton());

expect(screen.getByRole("button", { name: /Español/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /Français/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /Deutsch/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /中文/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /日本語/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /한국어/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /العربية/i })).toBeInTheDocument();
});

it("changes the active locale and persists it to localStorage", async () => {
const user = userEvent.setup();
renderLanguageSwitcher();

await user.click(getLanguageSwitcherButton());
await user.click(screen.getByRole("button", { name: /Español/i }));

expect(i18n.language).toMatch(/^es/);
expect(localStorage.getItem("i18nextLng")).toMatch(/^es/);
expect(screen.getByRole("button", { name: /change language|cambiar idioma/i })).toHaveTextContent(
"Español",
);
});

it("applies RTL direction for Arabic", async () => {
const user = userEvent.setup();
renderLanguageSwitcher();

await user.click(getLanguageSwitcherButton());
await user.click(screen.getByRole("button", { name: /العربية/i }));

expect(document.documentElement.dir).toBe("rtl");
expect(document.documentElement.lang).toBe("ar");
});

it("restores LTR direction when switching back to English", async () => {
const user = userEvent.setup();
renderLanguageSwitcher();

await user.click(getLanguageSwitcherButton());
await user.click(screen.getByRole("button", { name: /العربية/i }));
await user.click(getLanguageSwitcherButton());
await user.click(screen.getByRole("button", { name: /^English/i }));

expect(document.documentElement.dir).toBe("ltr");
expect(document.documentElement.lang).toBe("en");
});
});
16 changes: 5 additions & 11 deletions frontend/src/components/LanguageSwitcher.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,31 +8,25 @@ import { useTranslation } from "react-i18next";
import { SUPPORTED_LANGUAGES, SupportedLanguage } from "../i18n/config";

export function LanguageSwitcher() {
const { i18n } = useTranslation();
const { i18n, t } = useTranslation();
const [isOpen, setIsOpen] = useState(false);

const activeLanguageCode = i18n.language.split("-")[0];
const currentLanguage =
SUPPORTED_LANGUAGES.find((lang) => lang.code === i18n.language) ||
SUPPORTED_LANGUAGES.find((lang) => lang.code === activeLanguageCode) ||
SUPPORTED_LANGUAGES[0];

const handleLanguageChange = (languageCode: SupportedLanguage) => {
i18n.changeLanguage(languageCode);
void i18n.changeLanguage(languageCode);
setIsOpen(false);

// Update document direction for RTL languages
const language = SUPPORTED_LANGUAGES.find(
(lang) => lang.code === languageCode,
);
document.documentElement.dir =
language && "rtl" in language && language.rtl === true ? "rtl" : "ltr";
};

return (
<div className="relative">
<button
onClick={() => setIsOpen(!isOpen)}
className="flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
aria-label="Change language"
aria-label={t("settings.changeLanguage")}
>
<svg
className="w-5 h-5"
Expand Down
11 changes: 11 additions & 0 deletions frontend/src/components/LoadingFallback.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { useTranslation } from "react-i18next";

export function LoadingFallback() {
const { t } = useTranslation();

return (
<div className="min-h-screen bg-stellar-dark flex items-center justify-center text-stellar-text-secondary">
{t("app.loadingPage")}
</div>
);
}
3 changes: 2 additions & 1 deletion frontend/src/components/MobileNav/MobileMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ import { useEffect, useMemo, useRef, useState, type TouchEvent } from "react";
import { Link } from "react-router-dom";
import {
isNavItemActive,
navGroups,
type NavGroup,
} from "./navigation";
import { useTranslatedNavGroups } from "../../hooks/useTranslatedNav";

interface MobileMenuProps {
open: boolean;
Expand All @@ -20,6 +20,7 @@ export default function MobileMenu({
pathname,
onClose,
}: MobileMenuProps) {
const navGroups = useTranslatedNavGroups();
const [expandedGroups, setExpandedGroups] = useState<Record<string, boolean>>(
() =>
navGroups.reduce<Record<string, boolean>>((accumulator, group) => {
Expand Down
11 changes: 7 additions & 4 deletions frontend/src/components/MobileNav/navigation.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
export interface NavItem {
to: string;
label: string;
/** i18n key under the translation namespace (e.g. nav.dashboard) */
labelKey?: string;
description: string;
}

Expand All @@ -15,14 +17,15 @@ export const navGroups: NavGroup[] = [
id: "monitoring",
label: "Monitoring",
items: [
{ to: "/dashboard", label: "Dashboard", description: "Real-time asset health overview" },
{ to: "/dashboard", label: "Dashboard", labelKey: "nav.dashboard", description: "Real-time asset health overview" },
{ to: "/incidents", label: "Incidents", description: "Incident heatmap and clustering" },
{ to: "/incidents/replay/demo", label: "Incident Replay", description: "Replay incident event timelines" },
{ to: "/bridges", label: "Bridges", description: "Bridge performance and incidents" },
{ to: "/bridges", label: "Bridges", labelKey: "nav.bridges", description: "Bridge performance and incidents" },
{ to: "/bridge-topology", label: "Topology", description: "Explore bridge network graph and connections" },
{ to: "/transactions", label: "Transactions", description: "Recent bridge transfer activity" },
{ to: "/reconciliation", label: "Reconciliation", description: "Supply drift and reserve backing triage" },
{ to: "/cross-chain-verification", label: "State Verification", description: "Cryptographic cross-chain state proof validation" },
{ to: "/analytics", label: "Analytics", labelKey: "nav.analytics", description: "Trend analysis and health scoring" },
{ to: "/freshness", label: "Freshness", description: "Data freshness and staleness status for monitored sources" },
{ to: "/liquidity-dashboard", label: "Liquidity", description: "Aggregated liquidity depth across SDEX, StellarX AMM, and Phoenix" },
{ to: "/schema-drift", label: "Schema Drift", description: "Monitor field-level schema drift across upstream data sources" },
Expand All @@ -40,7 +43,7 @@ export const navGroups: NavGroup[] = [
label: "Operations",
items: [
{ to: "/help", label: "Help Center", description: "Search docs, FAQ, and support workflows" },
{ to: "/api-docs", label: "API Docs", description: "Interactive API documentation and explorer" },
{ to: "/api-docs", label: "API Docs", labelKey: "nav.docs", description: "Interactive API documentation and explorer" },
{ to: "/admin/api-keys", label: "API Keys", description: "Manage integrator credentials" },
{
to: "/admin/alert-routing",
Expand All @@ -57,7 +60,7 @@ export const navGroups: NavGroup[] = [
label: "Access Audit",
description: "Review operator roles, permissions, and access history",
},
{ to: "/settings", label: "Settings", description: "Notification and dashboard preferences" },
{ to: "/settings", label: "Settings", labelKey: "nav.settings", description: "Notification and dashboard preferences" },
{ to: "/export-scheduler", label: "Export Scheduler", description: "Schedule recurring report exports" },
{ to: "/metrics-sidebar", label: "Pinned Metrics", description: "Pin and manage frequently viewed metrics" },
],
Expand Down
4 changes: 3 additions & 1 deletion frontend/src/components/Navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@ import { selectUnreadCount, useNotificationStore } from "../stores/notificationS
import EntitySwitcher from "./EntitySwitcher";
import HamburgerButton from "./MobileNav/HamburgerButton";
import MobileMenu from "./MobileNav/MobileMenu";
import { desktopNavItems, isNavItemActive } from "./MobileNav/navigation";
import { isNavItemActive } from "./MobileNav/navigation";
import { useTranslatedDesktopNavItems } from "../hooks/useTranslatedNav";
import NotificationsDrawer from "./NotificationsDrawer";
import GlobalSearch from "./search/GlobalSearch";
import UnreadCountBadge from "./UnreadCountBadge";

export default function Navbar() {
const location = useLocation();
const desktopNavItems = useTranslatedDesktopNavItems();
const { activeSymbols } = useWatchlist();
const [isNotificationsOpen, setIsNotificationsOpen] = useState(false);
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
Expand Down
34 changes: 34 additions & 0 deletions frontend/src/hooks/useTranslatedNav.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import {
navGroups,
type NavGroup,
type NavItem,
} from "../components/MobileNav/navigation";

function translateNavItem(item: NavItem, t: (key: string) => string): NavItem {
return {
...item,
label: item.labelKey ? t(item.labelKey) : item.label,
};
}

/** Returns navigation groups with translated labels where labelKey is defined. */
export function useTranslatedNavGroups(): NavGroup[] {
const { t } = useTranslation();

return useMemo(
() =>
navGroups.map((group) => ({
...group,
items: group.items.map((item) => translateNavItem(item, t)),
})),
[t],
);
}

/** Flat list of desktop nav items with translated labels. */
export function useTranslatedDesktopNavItems(): NavItem[] {
const groups = useTranslatedNavGroups();
return useMemo(() => groups.flatMap((group) => group.items), [groups]);
}
48 changes: 33 additions & 15 deletions frontend/src/i18n/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,17 @@
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import LanguageDetector from "i18next-browser-languagedetector";
import { applyDocumentDirection } from "./documentDirection";

// Import translations
import enTranslations from "./locales/en.json";
import esTranslations from "./locales/es.json";
import frTranslations from "./locales/fr.json";
import deTranslations from "./locales/de.json";
import zhTranslations from "./locales/zh.json";
import jaTranslations from "./locales/ja.json";
import koTranslations from "./locales/ko.json";
import arTranslations from "./locales/ar.json";

// Available languages
export const SUPPORTED_LANGUAGES = [
{ code: "en", name: "English", nativeName: "English" },
{ code: "es", name: "Spanish", nativeName: "Español" },
Expand All @@ -24,38 +30,42 @@ export const SUPPORTED_LANGUAGES = [

export type SupportedLanguage = (typeof SUPPORTED_LANGUAGES)[number]["code"];

// i18n configuration
export const LOCALE_RESOURCES = {
en: { translation: enTranslations },
es: { translation: esTranslations },
fr: { translation: frTranslations },
de: { translation: deTranslations },
zh: { translation: zhTranslations },
ja: { translation: jaTranslations },
ko: { translation: koTranslations },
ar: { translation: arTranslations },
} as const;

i18n
.use(LanguageDetector) // Detect user language
.use(initReactI18next) // Pass i18n to react-i18next
.use(LanguageDetector)
.use(initReactI18next)
.init({
resources: {
en: { translation: enTranslations },
},
resources: LOCALE_RESOURCES,
fallbackLng: "en",
defaultNS: "translation",
supportedLngs: SUPPORTED_LANGUAGES.map((lang) => lang.code),

// Language detection options
detection: {
order: ["localStorage", "navigator", "htmlTag"],
caches: ["localStorage"],
lookupLocalStorage: "i18nextLng",
},

// Interpolation options
interpolation: {
escapeValue: false, // React already escapes
escapeValue: false,
},

// React options
react: {
useSuspense: true,
},

// Performance
load: "languageOnly", // Load only language code (en, not en-US)
load: "languageOnly",

// Missing key handling
saveMissing: process.env.NODE_ENV === "development",
missingKeyHandler: (lng: readonly string[], ns: string, key: string) => {
if (process.env.NODE_ENV === "development") {
Expand All @@ -64,4 +74,12 @@ i18n
},
});

i18n.on("initialized", () => {
applyDocumentDirection(i18n.language);
});

i18n.on("languageChanged", (languageCode) => {
applyDocumentDirection(languageCode);
});

export default i18n;
12 changes: 12 additions & 0 deletions frontend/src/i18n/documentDirection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { SUPPORTED_LANGUAGES } from "./config";

/** Apply LTR/RTL document direction for the active locale. */
export function applyDocumentDirection(languageCode: string): void {
const baseCode = languageCode.split("-")[0];
const language = SUPPORTED_LANGUAGES.find((lang) => lang.code === baseCode);
const isRtl =
language !== undefined && "rtl" in language && language.rtl === true;

document.documentElement.dir = isRtl ? "rtl" : "ltr";
document.documentElement.lang = baseCode;
}
Loading
Loading