feat(i18n): add language switcher

This commit is contained in:
2026-06-11 16:16:06 +02:00
parent 18a192a069
commit c3cf4182ad
11 changed files with 319 additions and 2 deletions
+33
View File
@@ -0,0 +1,33 @@
"use server"
import { cookies } from "next/headers"
import {
isLocale,
LOCALE_COOKIE_MAX_AGE_SECONDS,
LOCALE_COOKIE_NAME,
type Locale,
} from "@/i18n/locales"
export type SetLocaleActionResult =
| { success: true; locale: Locale }
| { success: false; error: "UNSUPPORTED_LOCALE" }
export async function setLocaleAction(
requestedLocale: string,
): Promise<SetLocaleActionResult> {
if (!isLocale(requestedLocale)) {
return { success: false, error: "UNSUPPORTED_LOCALE" }
}
const cookieStore = await cookies()
cookieStore.set(LOCALE_COOKIE_NAME, requestedLocale, {
path: "/",
sameSite: "lax",
maxAge: LOCALE_COOKIE_MAX_AGE_SECONDS,
httpOnly: true,
secure: process.env.NODE_ENV === "production",
})
return { success: true, locale: requestedLocale }
}
+9 -2
View File
@@ -1,5 +1,6 @@
import { redirect } from "next/navigation" import { redirect } from "next/navigation"
import { LanguageSwitcher } from "@/components/i18n/language-switcher"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { getI18n } from "@/i18n/server" import { getI18n } from "@/i18n/server"
import { auth } from "@/lib/auth" import { auth } from "@/lib/auth"
@@ -11,12 +12,18 @@ export default async function LoginPage() {
if (session) redirect("/") if (session) redirect("/")
const { dictionary } = await getI18n() const { dictionary, locale } = await getI18n()
const copy = dictionary.login const copy = dictionary.login
return ( return (
<div className="flex min-h-svh w-full items-center justify-center p-6 md:p-10"> <div className="flex min-h-svh w-full items-center justify-center p-6 md:p-10">
<div className="w-full max-w-sm"> <div className="w-full max-w-sm space-y-3">
<div className="flex justify-end">
<LanguageSwitcher
activeLocale={locale}
copy={dictionary.common.languageSwitcher}
/>
</div>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle> <CardTitle>
+87
View File
@@ -0,0 +1,87 @@
"use client"
import { useRouter } from "next/navigation"
import { useTransition } from "react"
import { setLocaleAction } from "@/actions/i18n.actions"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { isLocale, type Locale, SUPPORTED_LOCALES } from "@/i18n/locales"
import { cn } from "@/lib/utils"
export type LanguageSwitcherCopy = {
label: string
options: Record<Locale, string>
}
type LanguageSwitcherProps = {
activeLocale: Locale
copy: LanguageSwitcherCopy
className?: string
}
export function LanguageSwitcher({
activeLocale,
copy,
className,
}: LanguageSwitcherProps) {
const router = useRouter()
const [isPending, startTransition] = useTransition()
const activeLabel = copy.options[activeLocale]
const accessibleName = `${copy.label}: ${activeLabel}`
function handleLocaleChange(nextLocale: string) {
if (isPending || nextLocale === activeLocale || !isLocale(nextLocale)) {
return
}
startTransition(async () => {
const result = await setLocaleAction(nextLocale)
if (result.success) {
router.refresh()
}
})
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
aria-label={accessibleName}
className={cn("min-w-12", className)}
disabled={isPending}
size="xs"
type="button"
variant="outline"
>
{activeLocale.toUpperCase()}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>{copy.label}</DropdownMenuLabel>
<DropdownMenuRadioGroup
onValueChange={handleLocaleChange}
value={activeLocale}
>
{SUPPORTED_LOCALES.map((locale) => (
<DropdownMenuRadioItem
disabled={isPending}
key={locale}
value={locale}
>
{copy.options[locale]}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
)
}
+8
View File
@@ -1,5 +1,6 @@
import { redirect } from "next/navigation" import { redirect } from "next/navigation"
import { LanguageSwitcher } from "@/components/i18n/language-switcher"
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@@ -8,6 +9,7 @@ import {
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu" } from "@/components/ui/dropdown-menu"
import { getI18n } from "@/i18n/server"
import { auth } from "@/lib/auth" import { auth } from "@/lib/auth"
import { SIGN_IN_URL } from "@/lib/constants" import { SIGN_IN_URL } from "@/lib/constants"
@@ -20,11 +22,17 @@ export default async function Navbar() {
if (!session) redirect(SIGN_IN_URL) if (!session) redirect(SIGN_IN_URL)
const { dictionary, locale } = await getI18n()
return ( return (
<nav className="flex items-center justify-between border-b p-4"> <nav className="flex items-center justify-between border-b p-4">
<SidebarTrigger /> <SidebarTrigger />
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<AddMenu /> <AddMenu />
<LanguageSwitcher
activeLocale={locale}
copy={dictionary.common.languageSwitcher}
/>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger> <DropdownMenuTrigger>
+9
View File
@@ -1,4 +1,13 @@
export const en = { export const en = {
common: {
languageSwitcher: {
label: "Language",
options: {
en: "English",
es: "Spanish",
},
},
},
login: { login: {
title: "Sign In", title: "Sign In",
usernameLabel: "Username", usernameLabel: "Username",
+9
View File
@@ -1,6 +1,15 @@
import type { Dictionary } from "./en" import type { Dictionary } from "./en"
export const es = { export const es = {
common: {
languageSwitcher: {
label: "Idioma",
options: {
en: "Inglés",
es: "Español",
},
},
},
login: { login: {
title: "Iniciar sesión", title: "Iniciar sesión",
usernameLabel: "Usuario", usernameLabel: "Usuario",
+1
View File
@@ -5,6 +5,7 @@ export type Locale = (typeof SUPPORTED_LOCALES)[number]
export const FALLBACK_LOCALE: Locale = "en" export const FALLBACK_LOCALE: Locale = "en"
export const DEFAULT_LOCALE_ENV_VAR = "STOCK_MANAGER_DEFAULT_LOCALE" export const DEFAULT_LOCALE_ENV_VAR = "STOCK_MANAGER_DEFAULT_LOCALE"
export const LOCALE_COOKIE_NAME = "stock-manager-locale" export const LOCALE_COOKIE_NAME = "stock-manager-locale"
export const LOCALE_COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 365
export function isLocale(value: unknown): value is Locale { export function isLocale(value: unknown): value is Locale {
return ( return (
+93
View File
@@ -0,0 +1,93 @@
import { expect, type Page, test } from "@playwright/test"
async function setLocaleCookie(
page: Page,
locale: "en" | "es",
baseURL?: string,
) {
await page.context().addCookies([
{
name: "stock-manager-locale",
value: locale,
url: baseURL ?? "http://127.0.0.1:3100",
},
])
}
async function expectLocaleCookie(page: Page, locale: "en" | "es") {
await expect
.poll(async () => {
const cookies = await page.context().cookies()
return cookies.find((cookie) => cookie.name === "stock-manager-locale")
?.value
})
.toBe(locale)
}
async function signInAsAdmin(page: Page, baseURL?: string) {
await setLocaleCookie(page, "en", baseURL)
await page.goto("/login")
await page.getByLabel("Username").fill("admin")
await page.getByLabel("Password").fill("admin-password")
await page.getByRole("button", { name: "Sign In" }).click()
await expect(page).toHaveURL("/")
}
test.describe("language switcher", () => {
test("switches the login page language in place through the locale cookie", async ({
baseURL,
page,
}) => {
await setLocaleCookie(page, "en", baseURL)
await page.goto("/login")
await expect(page).toHaveURL(/\/login$/)
await expect(page.locator("html")).toHaveAttribute("lang", "en")
await expect(page.getByRole("heading", { name: "Sign In" })).toBeVisible()
await page.getByRole("button", { name: "Language: English" }).click()
const options = page.getByRole("menuitemradio")
await expect(options).toHaveCount(2)
await expect(
page.getByRole("menuitemradio", { name: "English" }),
).toBeVisible()
await page.getByRole("menuitemradio", { name: "Spanish" }).click()
await expect(page).toHaveURL(/\/login$/)
await expect(page.locator("html")).toHaveAttribute("lang", "es")
await expect(
page.getByRole("heading", { name: "Iniciar sesión" }),
).toBeVisible()
await expect(page.getByLabel("Usuario")).toBeVisible()
await expect(page.getByLabel("Contraseña")).toBeVisible()
await expect(
page.getByRole("button", { name: "Iniciar sesión" }),
).toBeVisible()
await expectLocaleCookie(page, "es")
})
test("switches the authenticated dashboard language from the navbar", async ({
baseURL,
page,
}) => {
await signInAsAdmin(page, baseURL)
await expect(page.locator("html")).toHaveAttribute("lang", "en")
await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible()
await expect(page.getByText("E2E Admin")).toBeVisible()
await page.getByRole("button", { name: "Language: English" }).click()
await page.getByRole("menuitemradio", { name: "Spanish" }).click()
await expect(page).toHaveURL("/")
await expect(page.locator("html")).toHaveAttribute("lang", "es")
await expect(
page.getByRole("heading", { name: "Panel de control" }),
).toBeVisible()
await expect(page.getByText("E2E Admin")).toBeVisible()
await expect(page.getByText("admin@example.test")).toBeVisible()
await expectLocaleCookie(page, "es")
})
})
+50
View File
@@ -0,0 +1,50 @@
import { beforeEach, describe, expect, it, vi } from "vitest"
import { LOCALE_COOKIE_MAX_AGE_SECONDS } from "@/i18n/locales"
const headersMocks = vi.hoisted(() => ({
cookieSet: vi.fn(),
cookies: vi.fn(),
}))
vi.mock("next/headers", () => ({
cookies: headersMocks.cookies,
}))
import { setLocaleAction } from "@/actions/i18n.actions"
describe("setLocaleAction", () => {
beforeEach(() => {
headersMocks.cookieSet.mockReset()
headersMocks.cookies.mockReset()
headersMocks.cookies.mockResolvedValue({
set: headersMocks.cookieSet,
})
})
it("writes a validated supported locale to the locale cookie", async () => {
const result = await setLocaleAction("es")
expect(result).toEqual({ success: true, locale: "es" })
expect(headersMocks.cookies).toHaveBeenCalledOnce()
expect(headersMocks.cookieSet).toHaveBeenCalledWith(
"stock-manager-locale",
"es",
{
path: "/",
sameSite: "lax",
maxAge: LOCALE_COOKIE_MAX_AGE_SECONDS,
httpOnly: true,
secure: false,
},
)
})
it("rejects unsupported locales without writing a cookie", async () => {
const result = await setLocaleAction("fr")
expect(result).toEqual({ success: false, error: "UNSUPPORTED_LOCALE" })
expect(headersMocks.cookies).not.toHaveBeenCalled()
expect(headersMocks.cookieSet).not.toHaveBeenCalled()
})
})
+18
View File
@@ -26,6 +26,24 @@ describe("i18n dictionaries", () => {
}) })
}) })
it("provides localized language switcher copy for English and Spanish", () => {
expect(getDictionary("en").common.languageSwitcher).toEqual({
label: "Language",
options: {
en: "English",
es: "Spanish",
},
})
expect(getDictionary("es").common.languageSwitcher).toEqual({
label: "Idioma",
options: {
en: "Inglés",
es: "Español",
},
})
})
it("keeps dashboard home dictionary keys aligned across locales", () => { it("keeps dashboard home dictionary keys aligned across locales", () => {
expect(getDictionary("en").dashboardHome).toEqual({ expect(getDictionary("en").dashboardHome).toEqual({
heading: "Dashboard", heading: "Dashboard",
+2
View File
@@ -4,6 +4,7 @@ import {
DEFAULT_LOCALE_ENV_VAR, DEFAULT_LOCALE_ENV_VAR,
FALLBACK_LOCALE, FALLBACK_LOCALE,
isLocale, isLocale,
LOCALE_COOKIE_MAX_AGE_SECONDS,
LOCALE_COOKIE_NAME, LOCALE_COOKIE_NAME,
resolveDefaultLocale, resolveDefaultLocale,
resolveLocale, resolveLocale,
@@ -16,6 +17,7 @@ describe("i18n locales", () => {
expect(FALLBACK_LOCALE).toBe("en") expect(FALLBACK_LOCALE).toBe("en")
expect(DEFAULT_LOCALE_ENV_VAR).toBe("STOCK_MANAGER_DEFAULT_LOCALE") expect(DEFAULT_LOCALE_ENV_VAR).toBe("STOCK_MANAGER_DEFAULT_LOCALE")
expect(LOCALE_COOKIE_NAME).toBe("stock-manager-locale") expect(LOCALE_COOKIE_NAME).toBe("stock-manager-locale")
expect(LOCALE_COOKIE_MAX_AGE_SECONDS).toBe(60 * 60 * 24 * 365)
}) })
it("accepts only exact supported locale codes", () => { it("accepts only exact supported locale codes", () => {