From c3cf4182ade84d56bad7feb60859dfb736d64d6c Mon Sep 17 00:00:00 2001 From: Asis Ferrer Date: Thu, 11 Jun 2026 16:16:06 +0200 Subject: [PATCH] feat(i18n): add language switcher --- src/actions/i18n.actions.ts | 33 ++++++++ src/app/(auth)/login/page.tsx | 11 ++- src/components/i18n/language-switcher.tsx | 87 +++++++++++++++++++++ src/components/layout/navbar.tsx | 8 ++ src/i18n/dictionaries/en.ts | 9 +++ src/i18n/dictionaries/es.ts | 9 +++ src/i18n/locales.ts | 1 + tests/e2e/language-switcher.spec.ts | 93 +++++++++++++++++++++++ tests/unit/actions/i18n.actions.test.ts | 50 ++++++++++++ tests/unit/i18n/dictionaries.test.ts | 18 +++++ tests/unit/i18n/locales.test.ts | 2 + 11 files changed, 319 insertions(+), 2 deletions(-) create mode 100644 src/actions/i18n.actions.ts create mode 100644 src/components/i18n/language-switcher.tsx create mode 100644 tests/e2e/language-switcher.spec.ts create mode 100644 tests/unit/actions/i18n.actions.test.ts diff --git a/src/actions/i18n.actions.ts b/src/actions/i18n.actions.ts new file mode 100644 index 0000000..6ecf763 --- /dev/null +++ b/src/actions/i18n.actions.ts @@ -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 { + 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 } +} diff --git a/src/app/(auth)/login/page.tsx b/src/app/(auth)/login/page.tsx index 5c6b717..7321860 100644 --- a/src/app/(auth)/login/page.tsx +++ b/src/app/(auth)/login/page.tsx @@ -1,5 +1,6 @@ import { redirect } from "next/navigation" +import { LanguageSwitcher } from "@/components/i18n/language-switcher" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { getI18n } from "@/i18n/server" import { auth } from "@/lib/auth" @@ -11,12 +12,18 @@ export default async function LoginPage() { if (session) redirect("/") - const { dictionary } = await getI18n() + const { dictionary, locale } = await getI18n() const copy = dictionary.login return (
-
+
+
+ +
diff --git a/src/components/i18n/language-switcher.tsx b/src/components/i18n/language-switcher.tsx new file mode 100644 index 0000000..98eef6f --- /dev/null +++ b/src/components/i18n/language-switcher.tsx @@ -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 +} + +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 ( + + + + + + {copy.label} + + {SUPPORTED_LOCALES.map((locale) => ( + + {copy.options[locale]} + + ))} + + + + ) +} diff --git a/src/components/layout/navbar.tsx b/src/components/layout/navbar.tsx index 8e1b86d..d527145 100644 --- a/src/components/layout/navbar.tsx +++ b/src/components/layout/navbar.tsx @@ -1,5 +1,6 @@ import { redirect } from "next/navigation" +import { LanguageSwitcher } from "@/components/i18n/language-switcher" import { DropdownMenu, DropdownMenuContent, @@ -8,6 +9,7 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu" +import { getI18n } from "@/i18n/server" import { auth } from "@/lib/auth" import { SIGN_IN_URL } from "@/lib/constants" @@ -20,11 +22,17 @@ export default async function Navbar() { if (!session) redirect(SIGN_IN_URL) + const { dictionary, locale } = await getI18n() + return (