Formularios y validación
Stack: react-hook-form + zod + @hookform/resolvers/zod + componentes shadcn/ui Form (ya instalados en el scaffold de KODRA). Un solo esquema zod es la fuente única de verdad para los tipos, la validación del lado del cliente y la validación del lado del servidor.
Reglas
- Primero el esquema. Define un esquema zod; deriva el tipo TypeScript con
z.infer. Coloca los esquemas compartidos ensrc/lib/schemas/<nombre>.tspara que las rutas del servidor puedan importar el mismo archivo. - Valida también en el servidor. La validación del cliente es UX; la ruta del servidor, la función edge o la restricción de la base de datos es el límite de seguridad. Vuelve a analizar el cuerpo con el mismo esquema y devuelve los errores de campo.
- Cada campo tiene una etiqueta visible, una descripción útil cuando el formato no es obvio, y un error en línea debajo del campo.
- Momento de la validación:
mode: "onTouched"(validar después del primer desenfoque, luego al cambiar). No grites a los usuarios mientras escriben por primera vez. - El botón de enviar permanece habilitado hasta que comienza el envío, luego muestra un estado pendiente y se deshabilita para evitar envíos dobles.
- En caso de éxito muestra lo que sucedió (toast o mensaje en línea) y reinicia o navega. En caso de fallo conserva la entrada del usuario, muestra el error cerca del formulario y mueve el foco al primer campo inválido (react-hook-form lo hace por defecto).
- Usa la entrada correcta:
type="email",inputMode="numeric", tokens deautoComplete(name,email,tel,street-address,postal-code,one-time-code,new-password), para que los navegadores y teléfonos ayuden.
Formulario canónico
// src/lib/schemas/contacto.ts
import { z } from "zod";
export const contactoSchema = z.object({
nombre: z.string().trim().min(2, "Ingresa tu nombre completo"),
email: z.string().trim().email("Ingresa un correo válido, como [email protected]"),
telefono: z
.string()
.trim()
.regex(
/^0\d{8,9}$/,
"Ingresa un número de teléfono tailandés que comience con 0 (9-10 dígitos)",
)
.optional()
.or(z.literal("")),
tema: z.enum(["reserva", "facturacion", "otro"], {
required_error: "Elige un tema",
}),
mensaje: z
.string()
.trim()
.min(10, "Cuéntanos un poco más (al menos 10 caracteres)")
.max(2000),
consentimiento: z.boolean().refine((v) => v, "Por favor acepta para continuar"),
});
export type ContactoInput = z.infer<typeof contactoSchema>;
// src/components/FormularioContacto.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 { contactoSchema, type ContactoInput } from "@/lib/schemas/contacto";
export function FormularioContacto() {
const form = useForm<ContactoInput>({
resolver: zodResolver(contactoSchema),
mode: "onTouched",
defaultValues: {
nombre: "",
email: "",
telefono: "",
mensaje: "",
consentimiento: false,
},
});
async function onSubmit(values: ContactoInput) {
const res = await fetch("/api/contacto", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(values),
});
if (res.status === 422) {
const { erroresCampo } = (await res.json()) as {
erroresCampo: Partial<Record<keyof ContactoInput, string>>;
};
for (const [campo, mensaje] of Object.entries(erroresCampo)) {
form.setError(campo as keyof ContactoInput, { type: "server", message: mensaje });
}
return;
}
if (!res.ok) {
form.setError("root", {
message:
"No pudimos enviar tu mensaje. Verifica tu conexión e inténtalo de nuevo.",
});
return;
}
toast.success("Mensaje enviado. Responderemos dentro de un día hábil.");
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="nombre"
render={({ field }) => (
<FormItem>
<FormLabel>Nombre completo</FormLabel>
<FormControl>
<Input autoComplete="name" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Correo electrónico</FormLabel>
<FormControl>
<Input type="email" autoComplete="email" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="tema"
render={({ field }) => (
<FormItem>
<FormLabel>Tema</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Elige un tema" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="reserva">Reserva</SelectItem>
<SelectItem value="facturacion">Facturación</SelectItem>
<SelectItem value="otro">Otro</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="telefono"
render={({ field }) => (
<FormItem>
<FormLabel>Teléfono (opcional)</FormLabel>
<FormControl>
<Input
type="tel"
inputMode="tel"
autoComplete="tel"
{...field}
/>
</FormControl>
<FormDescription>Solo si prefieres que te llamemos.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="mensaje"
render={({ field }) => (
<FormItem className="sm:col-span-2">
<FormLabel>Mensaje</FormLabel>
<FormControl>
<Textarea rows={5} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="consentimiento"
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>
Acepto ser contactado sobre esta solicitud
</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 mensaje"}
</Button>
</div>
</form>
</Form>
);
}
Notas:
noValidatedesactiva las burbujas nativas del navegador para que solo se muestren los mensajes de zod.- Siempre pasa
defaultValues(cadenas vacías, noundefined) para que los inputs estén controlados desde el primer renderizado. - Usa
value={field.value}en Select/RadioGroup (nodefaultValue) para queform.reset()los limpie. - Cuando el formulario se envía a través de una mutación de TanStack Query, usa
mutation.isPendingen lugar deisSubmitting, y llamamutateAsyncenonSubmitpara que react-hook-form espere.
Validación del servidor
Analiza con el mismo esquema y devuelve 422 con errores de campo:
const parsed = contactoSchema.safeParse(body);
if (!parsed.success) {
return Response.json(
{ erroresCampo: parsed.error.flatten().fieldErrors },
{ status: 422 },
);
}
flatten().fieldErrors devuelve string[] por campo; envía el primer mensaje (erroresCampo.email?.[0]) o adapta el bucle del cliente. Las restricciones de la base de datos (email único, claves foráneas) también se mapean a errores de campo: captura la violación de unicidad y devuelve { erroresCampo: { email: "Este correo ya está registrado" } }. En aplicaciones Supabase con acceso solo RLS, valida nuevamente en una restricción de base de datos o una función edge; nunca confíes en el cliente.
Estilo del mensaje de error
- Di qué hacer, no qué salió mal: "Ingresa una fecha posterior a hoy", no "Fecha inválida".
- Usa los términos del usuario y muestra el formato esperado en línea ("DD/MM/AAAA").
- Un mensaje por campo a la vez; no repitas la etiqueta.
- Escribe los mensajes en el idioma de la aplicación; los mensajes de zod son cadenas de texto simples, así que pasa texto traducido (consulta la habilidad de localización al tailandés).
Lista de verificación de estados
- Vacío: etiquetas, placeholders útiles (ejemplos, no instrucciones).
- Escribiendo: sin errores hasta el primer desenfoque.
- Inválido: borde rojo + mensaje debajo del campo (
FormMessage), el foco se mueve al primer error al enviar. - Enviando: botón deshabilitado con spinner y etiqueta de progreso; los inputs permanecen legibles.
- Error del servidor: mensaje arriba del botón de enviar con
role="alert"; la entrada se conserva. - Éxito: toast o confirmación en línea; reinicio del formulario o redirección.
- Cambios no guardados en formularios largos: advertir antes de navegar (
formState.isDirty).