wedding-management-system

Internacionalização (i18n)

O Wedding Finance Planner suporta três idiomas desde a v0.5.0: pt-BR (padrão), en (inglês) e es (espanhol). Cada usuário escolhe seu idioma e o sistema (UI, emails, WhatsApp, RSVP) responde no idioma dele.

Stack

Onde fica o quê

Coisa Caminho
Constantes de locale src/i18n/config.ts
Resolução do locale do request src/i18n/request.ts
Helpers de formatação src/i18n/format.ts
Catálogos src/messages/{pt-BR,en,es}/*.json
Shim de compatibilidade src/lib/format.ts
Helper Zod src/lib/zod-i18n.ts
Helper server-only fallback src/i18n/get-locale.ts

Resolução do locale (ordem de precedência)

  1. ?lang= em rotas públicas (RSVP, login, forgot-password, reset-password). Sempre coercido para um locale suportado.
  2. Cookie NEXT_LOCALE (definido pelo profile/onboarding com path: "/", sobrevive a logout).
  3. JWT do Auth.js (session.user.locale, originado de User.locale).
  4. Header Accept-Language (parse simples: pt*pt-BR, en*en, es*es).
  5. Default: pt-BR.

Onde a preferência é persistida

Campo Modelo Default Função
locale User "pt-BR" UI + emails + WhatsApp do usuário autenticado
language Guest null RSVP do convidado; null = EventSettings.defaultLocale
defaultLocale EventSettings "pt-BR" Fallback para RSVP/sistêmicos sem destinatário identificado

Catálogos de mensagens

Estrutura:

src/messages/
├── pt-BR/
│   ├── common.json        navegação, botões, status, dias da semana, zod.*
│   ├── auth.json          login, reset, 2FA
│   ├── dashboard.json     páginas internas (pendente cobertura)
│   ├── actions.json       retornos de Server Actions
│   ├── notifications.json 9 kinds de templates
│   ├── help.json          shell do help center (conteúdo grande continua em pt-BR)
│   ├── changelog.json     stub do histórico
│   └── rsvp.json          páginas públicas RSVP
├── en/   (mesma estrutura)
└── es/   (mesma estrutura)

Padrão de chaves: namespace.area.entity.token — ex. dashboard.vendors.list.empty, actions.passwordReset.invalidEmail, notifications.PAYMENT_DUE.subject.

Plurais com ICU MessageFormat:

"daysOverdue": "{count, plural, one {# dia em atraso} other {# dias em atraso}}"
t("common.daysOverdue", { count: 3 });
// pt-BR → "3 dias em atraso"
// en    → "3 days overdue"
// es    → "3 días de atraso"

Como usar

Server Component

import { getTranslations } from "next-intl/server";

export default async function Page() {
  const t = await getTranslations("dashboard.vendors");
  return <h1>{t("title")}</h1>;
}

Client Component

"use client";
import { useTranslations } from "next-intl";

export function MyForm() {
  const t = useTranslations("dashboard.vendors");
  return <button>{t("create")}</button>;
}

Server Action

"use server";
import { getTranslations } from "next-intl/server";

export async function createVendor(formData: FormData) {
  const t = await getTranslations("actions.vendor");
  const session = await auth();
  if (!session?.user?.id) return { success: false, error: t("unauthorized") };
  // …
}

Cron / webhook / fora de request lifecycle

getLocale() retorna o default fora de um request. Sempre passe o locale do destinatário explicitamente:

const recipients = await prisma.user.findMany({
  select: { id: true, email: true, phone: true, locale: true },
});
for (const u of recipients) {
  await notify(
    { userId: u.id, email: u.email, phone: u.phone, locale: coerceLocale(u.locale) },
    { kind: "PAYMENT_DUE", /* … */ },
  );
}

A função render() em src/lib/notifications/templates.ts é async e recebe locale: Locale em cada variante de RenderInput. ICU plurals funcionam nativamente.

Zod

Não use z.setErrorMap global (é frágil entre versões). Capture o erro e traduza via helper:

import { zodErrorMessage } from "@/lib/zod-i18n";

const tc = await getTranslations("common");
const parsed = Schema.safeParse(input);
if (!parsed.success) {
  return { success: false, error: zodErrorMessage(parsed.error, tc) };
}

Os limites continuam no schema (ex.: .max(120)); só a mensagem é traduzida.

Trocar de idioma

Adicionando uma chave

  1. Adicione em src/messages/pt-BR/<namespace>.json.
  2. Adicione em src/messages/en/<namespace>.json (traduzido).
  3. Adicione em src/messages/es/<namespace>.json (traduzido).
  4. Use no código (t("path.to.key")).
  5. Rode npm run test:run (os testes carregam os JSONs via mock e detectam chaves quebradas em plurais).

Sem placeholders TODO nos catálogos — traduza ou peça revisão.

Adicionando um novo idioma

  1. Abra issue de discussão (regra AGENTS.md §13).
  2. Adicione o código em LOCALES em src/i18n/config.ts.
  3. Adicione entrada em LOCALE_LABELS, LOCALE_NATIVE_LABELS e TIMEZONE_BY_LOCALE.
  4. Adicione caso no parseAcceptLanguage.
  5. Crie 8 arquivos em src/messages/<novo-locale>/*.json espelhando os namespaces existentes.
  6. Adicione caso no array de namespaces do vitest.setup.ts se o nome mudar (não muda nesse fluxo).
  7. Atualize testes em src/i18n/config.test.ts e src/lib/notifications/templates.test.ts.
  8. Atualize este doc e a tela /dashboard/profile.

Troubleshooting

Cobertura atual (v0.5.0)

Traduzido nos 3 idiomas:

Pendente — cai em pt-BR via shim (sistema funciona, mas não traduz):