diff --git a/.env.example b/.env.example
index 96102bb..bb911f7 100644
--- a/.env.example
+++ b/.env.example
@@ -14,6 +14,7 @@ DEMO_MODE=false
DOMAIN=localhost
AUTH_TRUST_HOST="http://localhost"
AUTH_SECRET=your_secret_key_here
+STOCK_MANAGER_DEFAULT_LOCALE=en
# ADMIN BOOTSTRAP
ADMIN_BOOTSTRAP_ENABLED=true
diff --git a/src/app/(auth)/login/_components/login-form.tsx b/src/app/(auth)/login/_components/login-form.tsx
index dd31495..d342327 100644
--- a/src/app/(auth)/login/_components/login-form.tsx
+++ b/src/app/(auth)/login/_components/login-form.tsx
@@ -6,9 +6,14 @@ import { useState } from "react"
import { useForm } from "react-hook-form"
import { signInAction } from "@/actions/auth.actions"
import { Button } from "@/components/ui/button"
+import type { Dictionary } from "@/i18n/dictionaries"
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 searchParams = useSearchParams()
const callbackUrl = searchParams.get("callbackUrl")
@@ -37,7 +42,7 @@ export default function SignInForm() {
return (
-
Dashboard
+
{copy.heading}
) {
+ const { locale } = await getI18n()
+
return (
-
+
diff --git a/src/i18n/dictionaries/en.ts b/src/i18n/dictionaries/en.ts
new file mode 100644
index 0000000..f9c0d0e
--- /dev/null
+++ b/src/i18n/dictionaries/en.ts
@@ -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
diff --git a/src/i18n/dictionaries/es.ts b/src/i18n/dictionaries/es.ts
new file mode 100644
index 0000000..b22cbdd
--- /dev/null
+++ b/src/i18n/dictionaries/es.ts
@@ -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
diff --git a/src/i18n/dictionaries/index.ts b/src/i18n/dictionaries/index.ts
new file mode 100644
index 0000000..23ed014
--- /dev/null
+++ b/src/i18n/dictionaries/index.ts
@@ -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
+
+export function getDictionary(locale: Locale): Dictionary {
+ return dictionaries[locale]
+}
diff --git a/src/i18n/locales.ts b/src/i18n/locales.ts
new file mode 100644
index 0000000..45989ab
--- /dev/null
+++ b/src/i18n/locales.ts
@@ -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
+}
diff --git a/src/i18n/server.ts b/src/i18n/server.ts
new file mode 100644
index 0000000..25f4801
--- /dev/null
+++ b/src/i18n/server.ts
@@ -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),
+ }
+}
diff --git a/tests/e2e/app.smoke.spec.ts b/tests/e2e/app.smoke.spec.ts
index 405a739..2de9f00 100644
--- a/tests/e2e/app.smoke.spec.ts
+++ b/tests/e2e/app.smoke.spec.ts
@@ -1,6 +1,17 @@
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.getByLabel("Username").fill("admin")
await page.getByLabel("Password").fill("admin-password")
@@ -9,7 +20,12 @@ async function signInAsAdmin(page: Page) {
}
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 expect(page).toHaveURL(/\/login/)
@@ -17,8 +33,11 @@ test.describe("main app smoke", () => {
await expect(page.getByRole("button", { name: "Sign In" })).toBeVisible()
})
- test("signs in as seeded admin and opens the dashboard", async ({ page }) => {
- await signInAsAdmin(page)
+ test("signs in as seeded admin and opens the dashboard", async ({
+ baseURL,
+ page,
+ }) => {
+ await signInAsAdmin(page, baseURL)
await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible()
await expect(page.getByText("E2E Admin")).toBeVisible()
@@ -27,8 +46,11 @@ test.describe("main app smoke", () => {
).toBeVisible()
})
- test("admin can open users and inventory pages", async ({ page }) => {
- await signInAsAdmin(page)
+ test("admin can open users and inventory pages", async ({
+ baseURL,
+ page,
+ }) => {
+ await signInAsAdmin(page, baseURL)
await page.getByRole("link", { name: "Users" }).click()
await expect(page).toHaveURL(/\/admin\/users/)
diff --git a/tests/e2e/i18n-cookie.spec.ts b/tests/e2e/i18n-cookie.spec.ts
new file mode 100644
index 0000000..4bc18b0
--- /dev/null
+++ b/tests/e2e/i18n-cookie.spec.ts
@@ -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()
+ })
+})
diff --git a/tests/unit/i18n/dictionaries.test.ts b/tests/unit/i18n/dictionaries.test.ts
new file mode 100644
index 0000000..2f732e2
--- /dev/null
+++ b/tests/unit/i18n/dictionaries.test.ts
@@ -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 {
+ return typeof value === "object" && value !== null && !Array.isArray(value)
+}
diff --git a/tests/unit/i18n/locales.test.ts b/tests/unit/i18n/locales.test.ts
new file mode 100644
index 0000000..362cfc8
--- /dev/null
+++ b/tests/unit/i18n/locales.test.ts
@@ -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")
+ }
+ })
+})