Formulários e validação
Stack: react-hook-form + zod + @hookform/resolvers/zod + shadcn/ui
Componentes Form (já instalados no scaffold KODRA). Um único schema zod é
a fonte única da verdade para tipos, validação no cliente e validação no servidor.
Regras
- Primeiro o schema. Defina um schema zod; derive o tipo TypeScript com
z.infer. Coloque schemas compartilhados emsrc/lib/schemas/<nome>.tspara que as rotas do servidor possam importar o mesmo arquivo. - Valide também no servidor. A validação no cliente é UX; a rota do servidor, função de borda ou restrição de banco de dados é o limite de segurança. Re-parseie o corpo com o mesmo schema e retorne erros de campo.
- Todo campo tem um label visível, uma descrição útil quando o formato não é óbvio e um erro inline abaixo do campo.
- Tempo de validação:
mode: "onTouched"(validar após o primeiro blur, depois na mudança). Não grite com os usuários enquanto eles ainda estão digitando a primeira vez. - Botão de envio permanece habilitado até o envio começar, então mostra um estado pendente e é desabilitado para evitar envios duplicados.
- Ao sucesso, mostre o que aconteceu (toast ou mensagem inline) e redefina ou navegue. Ao falhar, mantenha a entrada do usuário, mostre o erro próximo ao formulário e mova o foco para o primeiro campo inválido (react-hook-form faz isso por padrão).
- Use a entrada correta:
type="email",inputMode="numeric", tokensautoComplete(name,email,tel,street-address,postal-code,one-time-code,new-password), para que navegadores e celulares ajudem.
Formulário canônico
// src/lib/schemas/contact.ts
import { z } from "zod";
export const contactSchema = z.object({
name: z.string().trim().min(2, "Digite seu nome completo"),
email: z.string().trim().email("Digite um email válido, como [email protected]"),
phone: z
.string()
.trim()
.regex(
/^0\d{8,9}$/,
"Digite um número de telefone tailandês começando com 0 (9-10 dígitos)",
)
.optional()
.or(z.literal("")),
topic: z.enum(["booking", "billing", "other"], {
required_error: "Escolha um assunto",
}),
message: z
.string()
.trim()
.min(10, "Conte-nos um pouco mais (pelo menos 10 caracteres)")
.max(2000),
consent: z.boolean().refine((v) => v, "Por favor, aceite para continuar"),
});
export type ContactInput = z.infer<typeof contactSchema>;
// src/components/ContactForm.tsx
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { toast } from "sonner";
import { Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Checkbox } from "@/components/ui/checkbox";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { contactSchema, type ContactInput } from "@/lib/schemas/contact";
export function ContactForm() {
const form = useForm<ContactInput>({
resolver: zodResolver(contactSchema),
mode: "onTouched",
defaultValues: {
name: "",
email: "",
phone: "",
message: "",
consent: false,
},
});
async function onSubmit(values: ContactInput) {
const res = await fetch("/api/contact", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(values),
});
if (res.status === 422) {
const { fieldErrors } = (await res.json()) as {
fieldErrors: Partial<Record<keyof ContactInput, string>>;
};
for (const [field, message] of Object.entries(fieldErrors)) {
form.setError(field as keyof ContactInput, { type: "server", message });
}
return;
}
if (!res.ok) {
form.setError("root", {
message:
"Não foi possível enviar sua mensagem. Verifique sua conexão e tente novamente.",
});
return;
}
toast.success("Mensagem enviada. Responderemos em até um dia útil.");
form.reset();
}
const { isSubmitting, errors } = form.formState;
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
noValidate
className="grid gap-5 sm:grid-cols-2"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Nome completo</FormLabel>
<FormControl>
<Input autoComplete="name" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input type="email" autoComplete="email" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="topic"
render={({ field }) => (
<FormItem>
<FormLabel>Assunto</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Escolha um assunto" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="booking">Reserva</SelectItem>
<SelectItem value="billing">Faturamento</SelectItem>
<SelectItem value="other">Outro</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="phone"
render={({ field }) => (
<FormItem>
<FormLabel>Telefone (opcional)</FormLabel>
<FormControl>
<Input
type="tel"
inputMode="tel"
autoComplete="tel"
{...field}
/>
</FormControl>
<FormDescription>Apenas se preferir uma ligação de volta.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="message"
render={({ field }) => (
<FormItem className="sm:col-span-2">
<FormLabel>Mensagem</FormLabel>
<FormControl>
<Textarea rows={5} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="consent"
render={({ field }) => (
<FormItem className="flex items-start gap-3 sm:col-span-2">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel>
Aceito ser contatado sobre esta solicitação
</FormLabel>
<FormMessage />
</div>
</FormItem>
)}
/>
{errors.root && (
<p role="alert" className="text-sm text-destructive sm:col-span-2">
{errors.root.message}
</p>
)}
<div className="sm:col-span-2">
<Button
type="submit"
disabled={isSubmitting}
className="w-full sm:w-auto"
>
{isSubmitting && (
<Loader2
className="mr-2 h-4 w-4 animate-spin"
aria-hidden="true"
/>
)}
{isSubmitting ? "Enviando..." : "Enviar mensagem"}
</Button>
</div>
</form>
</Form>
);
}
Notas:
noValidatedesliga os balões nativos do navegador para que apenas as mensagens zod sejam exibidas.- Sempre passe
defaultValues(strings vazias, nãoundefined) para que as entradas sejam controladas desde a primeira renderização. - Use
value={field.value}em Select/RadioGroup (nãodefaultValue) para queform.reset()os limpe. - Quando o formulário for enviado por uma mutação TanStack Query, use
mutation.isPendingem vez deisSubmitting, e chamemutateAsyncemonSubmitpara que o react-hook-form aguarde.
Validação no servidor
Parseie com o mesmo schema e retorne 422 com erros de campo:
const parsed = contactSchema.safeParse(body);
if (!parsed.success) {
return Response.json(
{ fieldErrors: parsed.error.flatten().fieldErrors },
{ status: 422 },
);
}
flatten().fieldErrors fornece string[] por campo; envie a primeira mensagem
(fieldErrors.email?.[0]) ou adapte o loop do cliente. Restrições de banco de dados (email
único, chaves estrangeiras) também são mapeadas para erros de campo: capture a violação
única e retorne { fieldErrors: { email: "Este email já está registrado" } }.
Em apps Supabase com acesso somente RLS, valide novamente em uma restrição de
banco de dados ou em uma função de borda; nunca confie no cliente.
Estilo da mensagem de erro
- Diga o que fazer, não o que deu errado: "Digite uma data posterior a hoje", não "Data inválida".
- Use os termos do usuário e mostre o formato esperado inline ("DD/MM/AAAA").
- Uma mensagem por campo por vez; não repita o label.
- Escreva mensagens no idioma do app; mensagens zod são strings simples, então passe texto traduzido (veja o skill de localização para tailandês).
Checklist de estados
- Vazio: labels, placeholders úteis (exemplos, não instruções).
- Digitando: sem erros até o primeiro blur.
- Inválido: borda vermelha + mensagem abaixo do campo (
FormMessage), foco move para o primeiro erro ao enviar. - Enviando: botão desabilitado com spinner e label de progresso; entradas permanecem legíveis.
- Erro do servidor: mensagem acima do botão de envio com
role="alert"; entrada preservada. - Sucesso: toast ou confirmação inline; formulário redefinido ou redirecionado.
- Alterações não salvas em formulários longos: avise antes de navegar (
formState.isDirty).