feat(i18n): add locale dictionaries and pilot surfaces
This commit is contained in:
@@ -14,6 +14,7 @@ DEMO_MODE=false
|
|||||||
DOMAIN=localhost
|
DOMAIN=localhost
|
||||||
AUTH_TRUST_HOST="http://localhost"
|
AUTH_TRUST_HOST="http://localhost"
|
||||||
AUTH_SECRET=your_secret_key_here
|
AUTH_SECRET=your_secret_key_here
|
||||||
|
STOCK_MANAGER_DEFAULT_LOCALE=en
|
||||||
|
|
||||||
# ADMIN BOOTSTRAP
|
# ADMIN BOOTSTRAP
|
||||||
ADMIN_BOOTSTRAP_ENABLED=true
|
ADMIN_BOOTSTRAP_ENABLED=true
|
||||||
|
|||||||
@@ -6,9 +6,14 @@ import { useState } from "react"
|
|||||||
import { useForm } from "react-hook-form"
|
import { useForm } from "react-hook-form"
|
||||||
import { signInAction } from "@/actions/auth.actions"
|
import { signInAction } from "@/actions/auth.actions"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
|
import type { Dictionary } from "@/i18n/dictionaries"
|
||||||
import { type SignInFormType, signInSchema } from "@/schemas/auth.schema"
|
import { type SignInFormType, signInSchema } from "@/schemas/auth.schema"
|
||||||
|
|
||||||
export default function SignInForm() {
|
type SignInFormProps = {
|
||||||
|
copy: Dictionary["login"]
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SignInForm({ copy }: SignInFormProps) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
const callbackUrl = searchParams.get("callbackUrl")
|
const callbackUrl = searchParams.get("callbackUrl")
|
||||||
@@ -37,7 +42,7 @@ export default function SignInForm() {
|
|||||||
return (
|
return (
|
||||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit(onSubmit)}>
|
<form className="flex flex-col gap-4" onSubmit={handleSubmit(onSubmit)}>
|
||||||
<label className="flex flex-col gap-1">
|
<label className="flex flex-col gap-1">
|
||||||
Username
|
{copy.usernameLabel}
|
||||||
<input
|
<input
|
||||||
{...register("username")}
|
{...register("username")}
|
||||||
name="username"
|
name="username"
|
||||||
@@ -49,7 +54,7 @@ export default function SignInForm() {
|
|||||||
)}
|
)}
|
||||||
</label>
|
</label>
|
||||||
<label className="flex flex-col gap-1">
|
<label className="flex flex-col gap-1">
|
||||||
Password
|
{copy.passwordLabel}
|
||||||
<input
|
<input
|
||||||
{...register("password")}
|
{...register("password")}
|
||||||
name="password"
|
name="password"
|
||||||
@@ -61,7 +66,7 @@ export default function SignInForm() {
|
|||||||
)}
|
)}
|
||||||
</label>
|
</label>
|
||||||
{error && <p className="text-error">{error}</p>}
|
{error && <p className="text-error">{error}</p>}
|
||||||
<Button type="submit">Sign In</Button>
|
<Button type="submit">{copy.submitLabel}</Button>
|
||||||
</form>
|
</form>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { redirect } from "next/navigation"
|
import { redirect } from "next/navigation"
|
||||||
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
import { getI18n } from "@/i18n/server"
|
||||||
import { auth } from "@/lib/auth"
|
import { auth } from "@/lib/auth"
|
||||||
|
|
||||||
import SignInForm from "./_components/login-form"
|
import SignInForm from "./_components/login-form"
|
||||||
@@ -10,15 +11,20 @@ export default async function LoginPage() {
|
|||||||
|
|
||||||
if (session) redirect("/")
|
if (session) redirect("/")
|
||||||
|
|
||||||
|
const { dictionary } = await getI18n()
|
||||||
|
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">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Sign In</CardTitle>
|
<CardTitle>
|
||||||
|
<h1>{copy.title}</h1>
|
||||||
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<SignInForm />
|
<SignInForm copy={copy} />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,11 +3,13 @@ import Link from "next/link"
|
|||||||
export default function Card({
|
export default function Card({
|
||||||
title,
|
title,
|
||||||
total,
|
total,
|
||||||
|
countLabel,
|
||||||
icon,
|
icon,
|
||||||
href,
|
href,
|
||||||
}: {
|
}: {
|
||||||
title: string
|
title: string
|
||||||
total: number
|
total: number
|
||||||
|
countLabel: string
|
||||||
icon: React.ReactNode
|
icon: React.ReactNode
|
||||||
href: string
|
href: string
|
||||||
}) {
|
}) {
|
||||||
@@ -18,7 +20,9 @@ export default function Card({
|
|||||||
<div className="mr-4">{icon}</div>
|
<div className="mr-4">{icon}</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-medium">{title}</h3>
|
<h3 className="text-lg font-medium">{title}</h3>
|
||||||
<p className="text-muted-foreground mt-2 text-sm">Total: {total}</p>
|
<p className="text-muted-foreground mt-2 text-sm">
|
||||||
|
{countLabel}: {total}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { getI18n } from "@/i18n/server"
|
||||||
import { AssetService } from "@/services/asset.service"
|
import { AssetService } from "@/services/asset.service"
|
||||||
import { ItemService } from "@/services/item.service"
|
import { ItemService } from "@/services/item.service"
|
||||||
import { RecipientService } from "@/services/recipient.service"
|
import { RecipientService } from "@/services/recipient.service"
|
||||||
@@ -5,17 +6,20 @@ import { RecipientService } from "@/services/recipient.service"
|
|||||||
import Card from "./_components/card"
|
import Card from "./_components/card"
|
||||||
|
|
||||||
export default async function Home() {
|
export default async function Home() {
|
||||||
|
const { dictionary } = await getI18n()
|
||||||
|
const copy = dictionary.dashboardHome
|
||||||
const totalItems = await ItemService.findAllItemsCount()
|
const totalItems = await ItemService.findAllItemsCount()
|
||||||
const totalAssets = await AssetService.findAllAssetsCount()
|
const totalAssets = await AssetService.findAllAssetsCount()
|
||||||
const totalRecipients = await RecipientService.findAllRecipientsCount()
|
const totalRecipients = await RecipientService.findAllRecipientsCount()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto p-4">
|
<div className="container mx-auto p-4">
|
||||||
<h1 className="mb-4 text-2xl font-bold">Dashboard</h1>
|
<h1 className="mb-4 text-2xl font-bold">{copy.heading}</h1>
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||||
<Card
|
<Card
|
||||||
title="Total Items"
|
title={copy.cards.items.title}
|
||||||
total={totalItems}
|
total={totalItems}
|
||||||
|
countLabel={copy.cards.items.countLabel}
|
||||||
href="/inventory/items"
|
href="/inventory/items"
|
||||||
icon={
|
icon={
|
||||||
<svg
|
<svg
|
||||||
@@ -37,8 +41,9 @@ export default async function Home() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Card
|
<Card
|
||||||
title="Total Assets"
|
title={copy.cards.assets.title}
|
||||||
total={totalAssets}
|
total={totalAssets}
|
||||||
|
countLabel={copy.cards.assets.countLabel}
|
||||||
href="/inventory/assets"
|
href="/inventory/assets"
|
||||||
icon={
|
icon={
|
||||||
<svg
|
<svg
|
||||||
@@ -60,8 +65,9 @@ export default async function Home() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Card
|
<Card
|
||||||
title="Total Recipients"
|
title={copy.cards.recipients.title}
|
||||||
total={totalRecipients}
|
total={totalRecipients}
|
||||||
|
countLabel={copy.cards.recipients.countLabel}
|
||||||
href="/recipients"
|
href="/recipients"
|
||||||
icon={
|
icon={
|
||||||
<svg
|
<svg
|
||||||
|
|||||||
+5
-2
@@ -2,6 +2,7 @@ import "@/styles/globals.css"
|
|||||||
|
|
||||||
import type { Metadata } from "next"
|
import type { Metadata } from "next"
|
||||||
import { Geist, Geist_Mono } from "next/font/google"
|
import { Geist, Geist_Mono } from "next/font/google"
|
||||||
|
import { getI18n } from "@/i18n/server"
|
||||||
|
|
||||||
const geistSans = Geist({
|
const geistSans = Geist({
|
||||||
variable: "--font-geist-sans",
|
variable: "--font-geist-sans",
|
||||||
@@ -18,13 +19,15 @@ export const metadata: Metadata = {
|
|||||||
description: "Manage your inventory with ease",
|
description: "Manage your inventory with ease",
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RootLayout({
|
export default async function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
}>) {
|
}>) {
|
||||||
|
const { locale } = await getI18n()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang={locale}>
|
||||||
<body
|
<body
|
||||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
export const en = {
|
||||||
|
login: {
|
||||||
|
title: "Sign In",
|
||||||
|
usernameLabel: "Username",
|
||||||
|
passwordLabel: "Password",
|
||||||
|
submitLabel: "Sign In",
|
||||||
|
},
|
||||||
|
dashboardHome: {
|
||||||
|
heading: "Dashboard",
|
||||||
|
cards: {
|
||||||
|
items: {
|
||||||
|
title: "Total Items",
|
||||||
|
countLabel: "Total",
|
||||||
|
},
|
||||||
|
assets: {
|
||||||
|
title: "Total Assets",
|
||||||
|
countLabel: "Total",
|
||||||
|
},
|
||||||
|
recipients: {
|
||||||
|
title: "Total Recipients",
|
||||||
|
countLabel: "Total",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Dictionary = typeof en
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import type { Dictionary } from "./en"
|
||||||
|
|
||||||
|
export const es = {
|
||||||
|
login: {
|
||||||
|
title: "Iniciar sesión",
|
||||||
|
usernameLabel: "Usuario",
|
||||||
|
passwordLabel: "Contraseña",
|
||||||
|
submitLabel: "Iniciar sesión",
|
||||||
|
},
|
||||||
|
dashboardHome: {
|
||||||
|
heading: "Panel de control",
|
||||||
|
cards: {
|
||||||
|
items: {
|
||||||
|
title: "Total de artículos",
|
||||||
|
countLabel: "Total",
|
||||||
|
},
|
||||||
|
assets: {
|
||||||
|
title: "Total de activos",
|
||||||
|
countLabel: "Total",
|
||||||
|
},
|
||||||
|
recipients: {
|
||||||
|
title: "Total de destinatarios",
|
||||||
|
countLabel: "Total",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} satisfies Dictionary
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import type { Locale } from "../locales"
|
||||||
|
|
||||||
|
import { type Dictionary, en } from "./en"
|
||||||
|
import { es } from "./es"
|
||||||
|
|
||||||
|
export type { Dictionary }
|
||||||
|
|
||||||
|
export const dictionaries = {
|
||||||
|
en,
|
||||||
|
es,
|
||||||
|
} satisfies Record<Locale, Dictionary>
|
||||||
|
|
||||||
|
export function getDictionary(locale: Locale): Dictionary {
|
||||||
|
return dictionaries[locale]
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
export const SUPPORTED_LOCALES = ["en", "es"] as const
|
||||||
|
|
||||||
|
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 function isLocale(value: unknown): value is Locale {
|
||||||
|
return (
|
||||||
|
typeof value === "string" && SUPPORTED_LOCALES.includes(value as Locale)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveDefaultLocale(value: unknown): Locale {
|
||||||
|
return isLocale(value) ? value : FALLBACK_LOCALE
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveLocale(value: unknown, fallback: Locale): Locale {
|
||||||
|
return isLocale(value) ? value : fallback
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import "server-only"
|
||||||
|
|
||||||
|
import { cookies } from "next/headers"
|
||||||
|
|
||||||
|
import { getDictionary } from "./dictionaries"
|
||||||
|
import {
|
||||||
|
DEFAULT_LOCALE_ENV_VAR,
|
||||||
|
LOCALE_COOKIE_NAME,
|
||||||
|
resolveDefaultLocale,
|
||||||
|
resolveLocale,
|
||||||
|
} from "./locales"
|
||||||
|
|
||||||
|
export async function getI18n() {
|
||||||
|
const cookieStore = await cookies()
|
||||||
|
const defaultLocale = resolveDefaultLocale(
|
||||||
|
process.env[DEFAULT_LOCALE_ENV_VAR],
|
||||||
|
)
|
||||||
|
const locale = resolveLocale(
|
||||||
|
cookieStore.get(LOCALE_COOKIE_NAME)?.value,
|
||||||
|
defaultLocale,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
locale,
|
||||||
|
dictionary: getDictionary(locale),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,17 @@
|
|||||||
import { expect, type Page, test } from "@playwright/test"
|
import { expect, type Page, test } from "@playwright/test"
|
||||||
|
|
||||||
async function signInAsAdmin(page: Page) {
|
async function setEnglishLocaleCookie(page: Page, baseURL?: string) {
|
||||||
|
await page.context().addCookies([
|
||||||
|
{
|
||||||
|
name: "stock-manager-locale",
|
||||||
|
value: "en",
|
||||||
|
url: baseURL ?? "http://127.0.0.1:3100",
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
async function signInAsAdmin(page: Page, baseURL?: string) {
|
||||||
|
await setEnglishLocaleCookie(page, baseURL)
|
||||||
await page.goto("/login")
|
await page.goto("/login")
|
||||||
await page.getByLabel("Username").fill("admin")
|
await page.getByLabel("Username").fill("admin")
|
||||||
await page.getByLabel("Password").fill("admin-password")
|
await page.getByLabel("Password").fill("admin-password")
|
||||||
@@ -9,7 +20,12 @@ async function signInAsAdmin(page: Page) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
test.describe("main app smoke", () => {
|
test.describe("main app smoke", () => {
|
||||||
test("redirects unauthenticated users to login", async ({ page }) => {
|
test("redirects unauthenticated users to login", async ({
|
||||||
|
baseURL,
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await setEnglishLocaleCookie(page, baseURL)
|
||||||
|
|
||||||
await page.goto("/admin/users")
|
await page.goto("/admin/users")
|
||||||
|
|
||||||
await expect(page).toHaveURL(/\/login/)
|
await expect(page).toHaveURL(/\/login/)
|
||||||
@@ -17,8 +33,11 @@ test.describe("main app smoke", () => {
|
|||||||
await expect(page.getByRole("button", { name: "Sign In" })).toBeVisible()
|
await expect(page.getByRole("button", { name: "Sign In" })).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|
||||||
test("signs in as seeded admin and opens the dashboard", async ({ page }) => {
|
test("signs in as seeded admin and opens the dashboard", async ({
|
||||||
await signInAsAdmin(page)
|
baseURL,
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await signInAsAdmin(page, baseURL)
|
||||||
|
|
||||||
await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible()
|
await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible()
|
||||||
await expect(page.getByText("E2E Admin")).toBeVisible()
|
await expect(page.getByText("E2E Admin")).toBeVisible()
|
||||||
@@ -27,8 +46,11 @@ test.describe("main app smoke", () => {
|
|||||||
).toBeVisible()
|
).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|
||||||
test("admin can open users and inventory pages", async ({ page }) => {
|
test("admin can open users and inventory pages", async ({
|
||||||
await signInAsAdmin(page)
|
baseURL,
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await signInAsAdmin(page, baseURL)
|
||||||
|
|
||||||
await page.getByRole("link", { name: "Users" }).click()
|
await page.getByRole("link", { name: "Users" }).click()
|
||||||
await expect(page).toHaveURL(/\/admin\/users/)
|
await expect(page).toHaveURL(/\/admin\/users/)
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { expect, type Page, test } from "@playwright/test"
|
||||||
|
|
||||||
|
async function setSpanishLocaleCookie(page: Page, baseURL?: string) {
|
||||||
|
await page.context().addCookies([
|
||||||
|
{
|
||||||
|
name: "stock-manager-locale",
|
||||||
|
value: "es",
|
||||||
|
url: baseURL ?? "http://127.0.0.1:3100",
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("i18n locale cookie", () => {
|
||||||
|
test("renders Spanish pilot copy on login and dashboard home", async ({
|
||||||
|
baseURL,
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await setSpanishLocaleCookie(page, baseURL)
|
||||||
|
|
||||||
|
await page.goto("/login")
|
||||||
|
await expect(
|
||||||
|
page.getByRole("heading", { name: "Iniciar sesión" }),
|
||||||
|
).toBeVisible()
|
||||||
|
await page.getByLabel("Usuario").fill("admin")
|
||||||
|
await page.getByLabel("Contraseña").fill("admin-password")
|
||||||
|
await page.getByRole("button", { name: "Iniciar sesión" }).click()
|
||||||
|
|
||||||
|
await expect(page).toHaveURL("/")
|
||||||
|
await expect(
|
||||||
|
page.getByRole("heading", { name: "Panel de control" }),
|
||||||
|
).toBeVisible()
|
||||||
|
await expect(page.locator("html")).toHaveAttribute("lang", "es")
|
||||||
|
await expect(page.getByText("Total de artículos")).toBeVisible()
|
||||||
|
await expect(page.getByText("Total de activos")).toBeVisible()
|
||||||
|
await expect(page.getByText("Total de destinatarios")).toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { describe, expect, it } from "vitest"
|
||||||
|
|
||||||
|
import { dictionaries, getDictionary } from "@/i18n/dictionaries"
|
||||||
|
import { SUPPORTED_LOCALES } from "@/i18n/locales"
|
||||||
|
|
||||||
|
describe("i18n dictionaries", () => {
|
||||||
|
it("provides dictionaries for every supported locale and no extra locales", () => {
|
||||||
|
expect(Object.keys(dictionaries).sort()).toEqual(
|
||||||
|
[...SUPPORTED_LOCALES].sort(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns localized login copy for English and Spanish", () => {
|
||||||
|
expect(getDictionary("en").login).toEqual({
|
||||||
|
title: "Sign In",
|
||||||
|
usernameLabel: "Username",
|
||||||
|
passwordLabel: "Password",
|
||||||
|
submitLabel: "Sign In",
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(getDictionary("es").login).toEqual({
|
||||||
|
title: "Iniciar sesión",
|
||||||
|
usernameLabel: "Usuario",
|
||||||
|
passwordLabel: "Contraseña",
|
||||||
|
submitLabel: "Iniciar sesión",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("keeps dashboard home dictionary keys aligned across locales", () => {
|
||||||
|
expect(getDictionary("en").dashboardHome).toEqual({
|
||||||
|
heading: "Dashboard",
|
||||||
|
cards: {
|
||||||
|
items: {
|
||||||
|
title: "Total Items",
|
||||||
|
countLabel: "Total",
|
||||||
|
},
|
||||||
|
assets: {
|
||||||
|
title: "Total Assets",
|
||||||
|
countLabel: "Total",
|
||||||
|
},
|
||||||
|
recipients: {
|
||||||
|
title: "Total Recipients",
|
||||||
|
countLabel: "Total",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(getDictionary("es").dashboardHome).toEqual({
|
||||||
|
heading: "Panel de control",
|
||||||
|
cards: {
|
||||||
|
items: {
|
||||||
|
title: "Total de artículos",
|
||||||
|
countLabel: "Total",
|
||||||
|
},
|
||||||
|
assets: {
|
||||||
|
title: "Total de activos",
|
||||||
|
countLabel: "Total",
|
||||||
|
},
|
||||||
|
recipients: {
|
||||||
|
title: "Total de destinatarios",
|
||||||
|
countLabel: "Total",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("has exact structural parity between English and Spanish dictionaries", () => {
|
||||||
|
expect(extractKeyPaths(getDictionary("es"))).toEqual(
|
||||||
|
extractKeyPaths(getDictionary("en")),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
function extractKeyPaths(value: unknown, prefix = ""): string[] {
|
||||||
|
if (!isPlainObject(value)) return [prefix]
|
||||||
|
|
||||||
|
return Object.keys(value)
|
||||||
|
.sort()
|
||||||
|
.flatMap((key) =>
|
||||||
|
extractKeyPaths(value[key], prefix ? `${prefix}.${key}` : key),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { describe, expect, it } from "vitest"
|
||||||
|
|
||||||
|
import {
|
||||||
|
DEFAULT_LOCALE_ENV_VAR,
|
||||||
|
FALLBACK_LOCALE,
|
||||||
|
isLocale,
|
||||||
|
LOCALE_COOKIE_NAME,
|
||||||
|
resolveDefaultLocale,
|
||||||
|
resolveLocale,
|
||||||
|
SUPPORTED_LOCALES,
|
||||||
|
} from "@/i18n/locales"
|
||||||
|
|
||||||
|
describe("i18n locales", () => {
|
||||||
|
it("defines exactly English and Spanish with an env-configured default", () => {
|
||||||
|
expect(SUPPORTED_LOCALES).toEqual(["en", "es"])
|
||||||
|
expect(FALLBACK_LOCALE).toBe("en")
|
||||||
|
expect(DEFAULT_LOCALE_ENV_VAR).toBe("STOCK_MANAGER_DEFAULT_LOCALE")
|
||||||
|
expect(LOCALE_COOKIE_NAME).toBe("stock-manager-locale")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("accepts only exact supported locale codes", () => {
|
||||||
|
expect(isLocale("en")).toBe(true)
|
||||||
|
expect(isLocale("es")).toBe(true)
|
||||||
|
|
||||||
|
for (const value of ["", "ES", "es-MX", "fr", undefined, null, 1]) {
|
||||||
|
expect(isLocale(value)).toBe(false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it("resolves valid locale cookie values and falls back to the configured default for invalid values", () => {
|
||||||
|
expect(resolveLocale("es", "en")).toBe("es")
|
||||||
|
expect(resolveLocale("en", "es")).toBe("en")
|
||||||
|
|
||||||
|
for (const value of ["", "ES", "es-MX", "fr", undefined, null, 1]) {
|
||||||
|
expect(resolveLocale(value, "es")).toBe("es")
|
||||||
|
expect(resolveLocale(value, "en")).toBe("en")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it("resolves the configured default locale from env-like values with a safe English fallback", () => {
|
||||||
|
expect(resolveDefaultLocale("es")).toBe("es")
|
||||||
|
expect(resolveDefaultLocale("en")).toBe("en")
|
||||||
|
|
||||||
|
for (const value of ["", "ES", "es-MX", "fr", undefined, null, 1]) {
|
||||||
|
expect(resolveDefaultLocale(value)).toBe("en")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user