feat(i18n): add language switcher
This commit is contained in:
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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")
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user