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 { 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 (
<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>
<CardHeader>
<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 { 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 (
<nav className="flex items-center justify-between border-b p-4">
<SidebarTrigger />
<div className="flex items-center gap-4">
<AddMenu />
<LanguageSwitcher
activeLocale={locale}
copy={dictionary.common.languageSwitcher}
/>
<div className="flex items-center gap-4">
<DropdownMenu>
<DropdownMenuTrigger>
+9
View File
@@ -1,4 +1,13 @@
export const en = {
common: {
languageSwitcher: {
label: "Language",
options: {
en: "English",
es: "Spanish",
},
},
},
login: {
title: "Sign In",
usernameLabel: "Username",
+9
View File
@@ -1,6 +1,15 @@
import type { Dictionary } from "./en"
export const es = {
common: {
languageSwitcher: {
label: "Idioma",
options: {
en: "Inglés",
es: "Español",
},
},
},
login: {
title: "Iniciar sesión",
usernameLabel: "Usuario",
+1
View File
@@ -5,6 +5,7 @@ export type Locale = (typeof SUPPORTED_LOCALES)[number]
export const FALLBACK_LOCALE: Locale = "en"
export const DEFAULT_LOCALE_ENV_VAR = "STOCK_MANAGER_DEFAULT_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 {
return (