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.
withNextIntl) + handler em
src/i18n/request.ts./[locale]/dashboard.| 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 |
?lang= em rotas públicas (RSVP, login, forgot-password,
reset-password). Sempre coercido para um locale suportado.NEXT_LOCALE (definido pelo profile/onboarding com path: "/",
sobrevive a logout).session.user.locale, originado de User.locale).Accept-Language (parse simples: pt* → pt-BR, en* →
en, es* → es).pt-BR.| 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 |
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"
import { getTranslations } from "next-intl/server";
export default async function Page() {
const t = await getTranslations("dashboard.vendors");
return <h1>{t("title")}</h1>;
}
"use client";
import { useTranslations } from "next-intl";
export function MyForm() {
const t = useTranslations("dashboard.vendors");
return <button>{t("create")}</button>;
}
"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") };
// …
}
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.
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.
User.locale (admin) e EventSettings.defaultLocale./dashboard/profile tem um selector. Submit chama
updateLocale que atualiza User.locale, seta o cookie NEXT_LOCALE e
retorna sucesso. O cliente faz window.location.reload() para reemitir
o JWT (necessário por bug do Auth.js v5 beta com session.update()).?lang=en na URL ou Guest.language no banco.src/messages/pt-BR/<namespace>.json.src/messages/en/<namespace>.json (traduzido).src/messages/es/<namespace>.json (traduzido).t("path.to.key")).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.
AGENTS.md §13).LOCALES em
src/i18n/config.ts.LOCALE_LABELS, LOCALE_NATIVE_LABELS e
TIMEZONE_BY_LOCALE.parseAcceptLanguage.src/messages/<novo-locale>/*.json espelhando os
namespaces existentes.vitest.setup.ts se o nome
mudar (não muda nesse fluxo)./dashboard/profile.dashboard.vendors.title): chave não
existe no catálogo do locale corrente. Adicione nos 3 idiomas.NextIntlClientProvider carrega todas
as mensagens. Para reduzir, mova strings que só rodam em Server
Component para namespaces dedicados e use getTranslations (não chega
ao client).window.location.reload()) após a action.prisma.user.findMany
inclui locale no select e se o notify() propaga o locale ao
target.next-intl/server em
vitest.setup.ts tem um parser de ICU simples;
formas avançadas (zero, two, few, many) são suportadas mas o
output exato pode divergir de Intl.PluralRules em runtime.Traduzido nos 3 idiomas:
Guest.language ou ?lang=).Pendente — cai em pt-BR via shim (sistema funciona, mas não traduz):
vendors, venues, tasks,
payments, income, assets, goals, guests, gifts,
wedding-day, honeymoon, trousseau, insights, reports,
settings).