Phase 2: Language Context & Selector - Add language dropdown to Header and auth pages

- Create LanguageSelector component with dropdown (shows flag + name)
- Add LanguageSelector to Header (right side, near user email/logout)
- Add LanguageSelector to login, signup, and signup/[code] pages
- Create test-utils.tsx with renderWithProviders helper
- Add vitest.setup.ts to mock localStorage
- Update all test files to use renderWithProviders
- Language selector persists choice in localStorage
- HTML lang attribute updates dynamically based on selected language

All frontend and e2e tests passing.
This commit is contained in:
counterweight 2025-12-25 21:54:19 +01:00
parent f7553df05d
commit f86ec8b62d
Signed by: counterweight
GPG key ID: 883EDBAA726BD96C
11 changed files with 214 additions and 37 deletions

View file

@ -4,6 +4,7 @@ import { useRouter } from "next/navigation";
import { useAuth } from "../auth-context";
import { sharedStyles } from "../styles/shared";
import constants from "../../../shared/constants.json";
import { LanguageSelector } from "./LanguageSelector";
const { ADMIN, REGULAR } = constants.roles;
@ -80,6 +81,7 @@ export function Header({ currentPage }: HeaderProps) {
))}
</div>
<div style={sharedStyles.userInfo}>
<LanguageSelector />
<span style={sharedStyles.userEmail}>{user.email}</span>
<button onClick={handleLogout} style={sharedStyles.logoutBtn}>
Sign out

View file

@ -0,0 +1,105 @@
"use client";
import { useState, useRef, useEffect } from "react";
import { useLanguage, type Locale } from "../hooks/useLanguage";
import { sharedStyles } from "../styles/shared";
const LANGUAGES: Array<{ code: Locale; name: string; flag: string }> = [
{ code: "es", name: "Español", flag: "🇪🇸" },
{ code: "en", name: "English", flag: "🇬🇧" },
{ code: "ca", name: "Català", flag: "🇪🇸" },
];
export function LanguageSelector() {
const { locale, setLocale } = useLanguage();
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
if (isOpen) {
document.addEventListener("mousedown", handleClickOutside);
}
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [isOpen]);
const currentLanguage = LANGUAGES.find((lang) => lang.code === locale);
return (
<div ref={dropdownRef} style={{ position: "relative" }}>
<button
onClick={() => setIsOpen(!isOpen)}
style={{
...sharedStyles.logoutBtn,
marginRight: "0.75rem",
display: "flex",
alignItems: "center",
gap: "0.5rem",
}}
>
<span>{currentLanguage?.flag}</span>
<span>{currentLanguage?.name}</span>
</button>
{isOpen && (
<div
style={{
position: "absolute",
top: "100%",
right: 0,
marginTop: "0.5rem",
backgroundColor: "white",
border: "1px solid #e5e7eb",
borderRadius: "0.5rem",
boxShadow: "0 4px 6px -1px rgba(0, 0, 0, 0.1)",
zIndex: 1000,
minWidth: "150px",
}}
>
{LANGUAGES.map((lang) => (
<button
key={lang.code}
onClick={() => {
setLocale(lang.code);
setIsOpen(false);
}}
style={{
width: "100%",
padding: "0.75rem 1rem",
textAlign: "left",
border: "none",
backgroundColor: locale === lang.code ? "#f3f4f6" : "transparent",
cursor: "pointer",
display: "flex",
alignItems: "center",
gap: "0.5rem",
fontSize: "0.875rem",
}}
onMouseEnter={(e) => {
if (locale !== lang.code) {
e.currentTarget.style.backgroundColor = "#f9fafb";
}
}}
onMouseLeave={(e) => {
if (locale !== lang.code) {
e.currentTarget.style.backgroundColor = "transparent";
}
}}
>
<span>{lang.flag}</span>
<span>{lang.name}</span>
</button>
))}
</div>
)}
</div>
);
}

View file

@ -1,36 +1,39 @@
import { render, screen, cleanup } from "@testing-library/react";
import { screen, cleanup } from "@testing-library/react";
import { expect, test, vi, beforeEach, afterEach } from "vitest";
import LoginPage from "./page";
import { renderWithProviders } from "../test-utils";
const mockPush = vi.fn();
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: mockPush }),
}));
const mockLogin = vi.fn();
vi.mock("../auth-context", () => ({
useAuth: () => ({ login: vi.fn() }),
useAuth: () => ({ login: mockLogin }),
AuthProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));
beforeEach(() => vi.clearAllMocks());
afterEach(() => cleanup());
test("renders login form with title", () => {
render(<LoginPage />);
renderWithProviders(<LoginPage />);
expect(screen.getByText("Welcome back")).toBeDefined();
});
test("renders email and password inputs", () => {
render(<LoginPage />);
renderWithProviders(<LoginPage />);
expect(screen.getByLabelText("Email")).toBeDefined();
expect(screen.getByLabelText("Password")).toBeDefined();
});
test("renders sign in button", () => {
render(<LoginPage />);
renderWithProviders(<LoginPage />);
expect(screen.getByRole("button", { name: "Sign in" })).toBeDefined();
});
test("renders link to signup", () => {
render(<LoginPage />);
renderWithProviders(<LoginPage />);
expect(screen.getByText("Sign up")).toBeDefined();
});

View file

@ -4,6 +4,7 @@ import { useState } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "../auth-context";
import { authFormStyles as styles } from "../styles/auth-form";
import { LanguageSelector } from "../components/LanguageSelector";
export default function LoginPage() {
const [email, setEmail] = useState("");
@ -30,6 +31,9 @@ export default function LoginPage() {
return (
<main style={styles.main}>
<div style={{ position: "absolute", top: "1rem", right: "1rem" }}>
<LanguageSelector />
</div>
<div style={styles.container}>
<div style={styles.card}>
<div style={styles.header}>

View file

@ -1,6 +1,7 @@
import { render, screen, waitFor, cleanup, fireEvent } from "@testing-library/react";
import { screen, waitFor, cleanup, fireEvent } from "@testing-library/react";
import { expect, test, vi, beforeEach, afterEach, describe } from "vitest";
import ProfilePage from "./page";
import { renderWithProviders } from "../test-utils";
// Mock next/navigation
const mockPush = vi.fn();
@ -37,6 +38,7 @@ vi.mock("../auth-context", () => ({
hasRole: mockHasRole,
hasPermission: mockHasPermission,
}),
AuthProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
Permission: {
VIEW_AUDIT: "view_audit",
FETCH_PRICE: "fetch_price",
@ -112,14 +114,14 @@ describe("ProfilePage - Display", () => {
mockIsLoading = true;
mockGetProfile.mockImplementation(() => new Promise(() => {}));
render(<ProfilePage />);
renderWithProviders(<ProfilePage />);
expect(screen.getByText("Loading...")).toBeDefined();
});
test("renders profile page title", async () => {
mockGetProfile.mockResolvedValue(mockProfileData);
render(<ProfilePage />);
renderWithProviders(<ProfilePage />);
await waitFor(() => {
expect(screen.getByRole("heading", { name: "My Profile" })).toBeDefined();
});
@ -128,7 +130,7 @@ describe("ProfilePage - Display", () => {
test("displays login email as read-only", async () => {
mockGetProfile.mockResolvedValue(mockProfileData);
render(<ProfilePage />);
renderWithProviders(<ProfilePage />);
await waitFor(() => {
const loginEmailInput = screen.getByDisplayValue("test@example.com");
expect(loginEmailInput).toBeDefined();
@ -139,7 +141,7 @@ describe("ProfilePage - Display", () => {
test("shows read-only badge for login email", async () => {
mockGetProfile.mockResolvedValue(mockProfileData);
render(<ProfilePage />);
renderWithProviders(<ProfilePage />);
await waitFor(() => {
expect(screen.getByText("Read only")).toBeDefined();
});
@ -148,7 +150,7 @@ describe("ProfilePage - Display", () => {
test("shows hint about login email", async () => {
mockGetProfile.mockResolvedValue(mockProfileData);
render(<ProfilePage />);
renderWithProviders(<ProfilePage />);
await waitFor(() => {
expect(screen.getByText(/cannot be changed/i)).toBeDefined();
});
@ -157,7 +159,7 @@ describe("ProfilePage - Display", () => {
test("displays contact details section hint", async () => {
mockGetProfile.mockResolvedValue(mockProfileData);
render(<ProfilePage />);
renderWithProviders(<ProfilePage />);
await waitFor(() => {
expect(screen.getByText(/communication purposes only/i)).toBeDefined();
});
@ -166,7 +168,7 @@ describe("ProfilePage - Display", () => {
test("displays fetched profile data", async () => {
mockGetProfile.mockResolvedValue(mockProfileData);
render(<ProfilePage />);
renderWithProviders(<ProfilePage />);
await waitFor(() => {
expect(screen.getByDisplayValue("contact@example.com")).toBeDefined();
expect(screen.getByDisplayValue("@testuser")).toBeDefined();
@ -178,7 +180,7 @@ describe("ProfilePage - Display", () => {
test("fetches profile with credentials", async () => {
mockGetProfile.mockResolvedValue(mockProfileData);
render(<ProfilePage />);
renderWithProviders(<ProfilePage />);
await waitFor(() => {
expect(mockGetProfile).toHaveBeenCalled();
@ -193,7 +195,7 @@ describe("ProfilePage - Display", () => {
nostr_npub: null,
});
render(<ProfilePage />);
renderWithProviders(<ProfilePage />);
await waitFor(() => {
// Check that inputs exist with empty values (placeholders shown)
const telegramInput = document.getElementById("telegram") as HTMLInputElement;
@ -206,7 +208,7 @@ describe("ProfilePage - Navigation", () => {
test("shows nav links for regular user", async () => {
mockGetProfile.mockResolvedValue(mockProfileData);
render(<ProfilePage />);
renderWithProviders(<ProfilePage />);
await waitFor(() => {
expect(screen.getByText("Exchange")).toBeDefined();
expect(screen.getByText("My Trades")).toBeDefined();
@ -216,7 +218,7 @@ describe("ProfilePage - Navigation", () => {
test("highlights My Profile in nav", async () => {
mockGetProfile.mockResolvedValue(mockProfileData);
render(<ProfilePage />);
renderWithProviders(<ProfilePage />);
await waitFor(() => {
// My Profile should be visible (as current page indicator)
const navItems = screen.getAllByText("My Profile");
@ -229,7 +231,7 @@ describe("ProfilePage - Access Control", () => {
test("redirects to login when not authenticated", async () => {
mockUser = null;
render(<ProfilePage />);
renderWithProviders(<ProfilePage />);
await waitFor(() => {
expect(mockPush).toHaveBeenCalledWith("/login");
@ -244,7 +246,7 @@ describe("ProfilePage - Access Control", () => {
permissions: ["view_all_exchanges"],
};
render(<ProfilePage />);
renderWithProviders(<ProfilePage />);
await waitFor(() => {
expect(mockPush).toHaveBeenCalledWith("/admin/trades");
@ -259,7 +261,7 @@ describe("ProfilePage - Access Control", () => {
permissions: ["view_audit"],
};
render(<ProfilePage />);
renderWithProviders(<ProfilePage />);
// Give it a moment to potentially fetch
await new Promise((r) => setTimeout(r, 100));
@ -272,7 +274,7 @@ describe("ProfilePage - Loading State", () => {
mockIsLoading = true;
mockUser = null;
render(<ProfilePage />);
renderWithProviders(<ProfilePage />);
expect(mockPush).not.toHaveBeenCalled();
});
@ -280,7 +282,7 @@ describe("ProfilePage - Loading State", () => {
test("shows loading indicator while loading", () => {
mockIsLoading = true;
render(<ProfilePage />);
renderWithProviders(<ProfilePage />);
expect(screen.getByText("Loading...")).toBeDefined();
});
@ -290,7 +292,7 @@ describe("ProfilePage - Form Behavior", () => {
test("submit button is disabled when no changes", async () => {
mockGetProfile.mockResolvedValue(mockProfileData);
render(<ProfilePage />);
renderWithProviders(<ProfilePage />);
await waitFor(() => {
const submitButton = screen.getByRole("button", { name: /save changes/i });
expect(submitButton).toHaveProperty("disabled", true);
@ -300,7 +302,7 @@ describe("ProfilePage - Form Behavior", () => {
test("submit button is enabled after field changes", async () => {
mockGetProfile.mockResolvedValue(mockProfileData);
render(<ProfilePage />);
renderWithProviders(<ProfilePage />);
await waitFor(() => {
expect(screen.getByDisplayValue("@testuser")).toBeDefined();
@ -323,7 +325,7 @@ describe("ProfilePage - Form Behavior", () => {
nostr_npub: null,
});
render(<ProfilePage />);
renderWithProviders(<ProfilePage />);
await waitFor(() => {
expect(screen.getByRole("heading", { name: "My Profile" })).toBeDefined();
@ -345,7 +347,7 @@ describe("ProfilePage - Form Behavior", () => {
nostr_npub: null,
});
render(<ProfilePage />);
renderWithProviders(<ProfilePage />);
await waitFor(() => {
expect(screen.getByRole("heading", { name: "My Profile" })).toBeDefined();
@ -382,7 +384,7 @@ describe("ProfilePage - Form Submission", () => {
nostr_npub: null,
});
render(<ProfilePage />);
renderWithProviders(<ProfilePage />);
await waitFor(() => {
expect(screen.getByRole("heading", { name: "My Profile" })).toBeDefined();
@ -428,7 +430,7 @@ describe("ProfilePage - Form Submission", () => {
})
);
render(<ProfilePage />);
renderWithProviders(<ProfilePage />);
await waitFor(() => {
expect(screen.getByRole("heading", { name: "My Profile" })).toBeDefined();
@ -456,7 +458,7 @@ describe("ProfilePage - Form Submission", () => {
});
mockUpdateProfile.mockRejectedValue(new Error("Network error"));
render(<ProfilePage />);
renderWithProviders(<ProfilePage />);
await waitFor(() => {
expect(screen.getByRole("heading", { name: "My Profile" })).toBeDefined();
@ -499,7 +501,7 @@ describe("ProfilePage - Form Submission", () => {
});
mockUpdateProfile.mockReturnValue(submitPromise);
render(<ProfilePage />);
renderWithProviders(<ProfilePage />);
await waitFor(() => {
expect(screen.getByRole("heading", { name: "My Profile" })).toBeDefined();

View file

@ -3,6 +3,7 @@
import { useEffect } from "react";
import { useRouter, useParams } from "next/navigation";
import { useAuth } from "../../auth-context";
import { LanguageSelector } from "../../components/LanguageSelector";
export default function SignupWithCodePage() {
const params = useParams();
@ -36,6 +37,9 @@ export default function SignupWithCodePage() {
fontFamily: "'DM Sans', system-ui, sans-serif",
}}
>
<div style={{ position: "absolute", top: "1rem", right: "1rem" }}>
<LanguageSelector />
</div>
Redirecting...
</main>
);

View file

@ -1,6 +1,7 @@
import { render, screen, cleanup } from "@testing-library/react";
import { screen, cleanup } from "@testing-library/react";
import { expect, test, vi, beforeEach, afterEach } from "vitest";
import SignupPage from "./page";
import { renderWithProviders } from "../test-utils";
const mockPush = vi.fn();
vi.mock("next/navigation", () => ({
@ -8,30 +9,32 @@ vi.mock("next/navigation", () => ({
useSearchParams: () => ({ get: () => null }),
}));
const mockRegister = vi.fn();
vi.mock("../auth-context", () => ({
useAuth: () => ({ user: null, register: vi.fn() }),
useAuth: () => ({ user: null, register: mockRegister }),
AuthProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));
beforeEach(() => vi.clearAllMocks());
afterEach(() => cleanup());
test("renders signup form with title", () => {
render(<SignupPage />);
renderWithProviders(<SignupPage />);
// Step 1 shows "Join with Invite" title (invite code entry)
expect(screen.getByRole("heading", { name: "Join with Invite" })).toBeDefined();
});
test("renders invite code input", () => {
render(<SignupPage />);
renderWithProviders(<SignupPage />);
expect(screen.getByLabelText("Invite Code")).toBeDefined();
});
test("renders continue button", () => {
render(<SignupPage />);
renderWithProviders(<SignupPage />);
expect(screen.getByRole("button", { name: "Continue" })).toBeDefined();
});
test("renders link to login", () => {
render(<SignupPage />);
renderWithProviders(<SignupPage />);
expect(screen.getByText("Sign in")).toBeDefined();
});

View file

@ -5,6 +5,7 @@ import { useRouter, useSearchParams } from "next/navigation";
import { useAuth } from "../auth-context";
import { invitesApi } from "../api";
import { authFormStyles as styles } from "../styles/auth-form";
import { LanguageSelector } from "../components/LanguageSelector";
function SignupContent() {
const searchParams = useSearchParams();
@ -107,6 +108,9 @@ function SignupContent() {
if (isCheckingInitialCode) {
return (
<main style={styles.main}>
<div style={{ position: "absolute", top: "1rem", right: "1rem" }}>
<LanguageSelector />
</div>
<div style={styles.container}>
<div style={styles.card}>
<div style={{ textAlign: "center", color: "rgba(255,255,255,0.6)" }}>
@ -122,6 +126,9 @@ function SignupContent() {
if (!inviteValid) {
return (
<main style={styles.main}>
<div style={{ position: "absolute", top: "1rem", right: "1rem" }}>
<LanguageSelector />
</div>
<div style={styles.container}>
<div style={styles.card}>
<div style={styles.header}>
@ -189,6 +196,9 @@ function SignupContent() {
// Step 2: Enter email and password
return (
<main style={styles.main}>
<div style={{ position: "absolute", top: "1rem", right: "1rem" }}>
<LanguageSelector />
</div>
<div style={styles.container}>
<div style={styles.card}>
<div style={styles.header}>

View file

@ -0,0 +1,16 @@
import { ReactElement } from "react";
import { render, RenderOptions } from "@testing-library/react";
import { Providers } from "./components/Providers";
import { AuthProvider } from "./auth-context";
function AllProviders({ children }: { children: React.ReactNode }) {
return (
<Providers>
<AuthProvider>{children}</AuthProvider>
</Providers>
);
}
export function renderWithProviders(ui: ReactElement, options?: Omit<RenderOptions, "wrapper">) {
return render(ui, { wrapper: AllProviders, ...options });
}

View file

@ -6,6 +6,7 @@ export default defineConfig({
test: {
environment: "jsdom",
include: ["app/**/*.test.{ts,tsx}"],
setupFiles: ["./vitest.setup.ts"],
coverage: {
provider: "v8",
reporter: ["text", "html"],

27
frontend/vitest.setup.ts Normal file
View file

@ -0,0 +1,27 @@
import { beforeEach } from "vitest";
// Mock localStorage
const localStorageMock = (() => {
let store: Record<string, string> = {};
return {
getItem: (key: string) => store[key] || null,
setItem: (key: string, value: string) => {
store[key] = value.toString();
},
removeItem: (key: string) => {
delete store[key];
},
clear: () => {
store = {};
},
};
})();
Object.defineProperty(window, "localStorage", {
value: localStorageMock,
});
beforeEach(() => {
localStorageMock.clear();
});