Construyendo un formulario de checkout consciente de LATAM
El formulario de checkout es el lugar donde la fricción de la UX se traduce directamente en conversión perdida. En LATAM, gran parte de esa fricción viene de validadores rígidos que rechazan inputs estructuralmente correctos solo porque el formato no calza con la regex que el dev usó. Un CPF copiado desde un email viene con puntos. Un CUIT pegado desde una factura tiene guiones. Un teléfono ingresado en un teclado mobile tiene espacios involuntarios. Todos esos son inputs perfectamente válidos a nivel humano y a nivel matemático — pero un validador que solo acepta el formato canónico los descarta. Este post muestra cómo armar un formulario de checkout para LATAM que sea permisivo en el input, estricto en la validación y comunicativo en los errores, usando Normadata como capa de validación async.
El problema UX: validadores rígidos rechazan inputs LATAM válidos
Cuando un usuario tipea o pega su CPF, no lo está pensando como un string a normalizar. Lo está pensando como su CPF: el número que vio en su carteira de identidade hace una hora, copiado tal cual desde un PDF de la Receita Federal, formateado con puntos y guiones porque así se imprimió. Un validador que rechaza ese input con un genérico "formato inválido" obliga al usuario a borrar y reescribir — lo cual aumenta la probabilidad de typo y la frustración. La regla mental para una UX permisiva es: aceptá cualquier representación textual que represente inequívocamente el mismo identificador, normalizá del lado del servidor (o cliente) y dale al usuario feedback positivo cuando el input ya es válido, sin obligarlo a re-formatearlo.
Casos típicos de input en LATAM
Estos son los patrones más comunes que vemos en formularios de checkout LATAM:
- CPF con puntos copy-paste — 111.444.777-35 pegado desde un email. Tu validador debe aceptarlo igual que 11144477735.
- CUIT con o sin guiones — Tanto 30-50001091-2 como 30500010912 son representaciones válidas del mismo CUIT.
- RUT chileno con K mayúscula o minúscula — 11.222.333-K debería tratarse igual que 11222333k. Convertir a mayúscula antes de validar.
- Teléfono mobile con espacios involuntarios — El usuario tipea +54 11 4321 5678 vs +5491143215678. Tu form debe stripear separadores.
- Teléfono sin código de país — En un form ya geocontextualizado, 11 4321 5678 sin +54 es razonable. Inferí el código del país del checkout.
- Email con tildes en el dominio (IDN) — Aunque raro, los dominios LATAM internacionales (.com.ar, .com.br) ocasionalmente reciben tildes en la TLD vía punycode.
- Dirección con caracteres especiales — Av. Corrientes 1234 - 4°B contiene un signo grado válido.
- Keyboard mobile inserta caracteres no visibles — Tap-and-hold puede insertar spaces, soft-hyphens o zero-width chars.
El patrón Normadata: on-blur + debounce 300ms
El patrón que recomendamos es validar on-blur (cuando el usuario sale del campo) con un debounce de 300ms para evitar requests intermedios mientras el usuario sigue tipeando. Validar on-change en cada keystroke genera demasiado tráfico; validar solo on-submit deja al usuario sin feedback hasta el final. El sweet spot es validar cuando el usuario indicó que terminó (blur) más un debounce mínimo para coalescer eventos rápidos. Veamos el patrón en React con useDeferredValue + un hook custom:
import { useState, useEffect, useRef } from "react";
type ValidationState =
| { status: "idle" }
| { status: "validating" }
| { status: "valid"; formatted: string }
| { status: "invalid"; reason: string };
function useTaxIdValidator(country: string, type: string) {
const [state, setState] = useState<ValidationState>({ status: "idle" });
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
const validate = (value: string) => {
clearTimeout(debounceRef.current);
if (!value) {
setState({ status: "idle" });
return;
}
debounceRef.current = setTimeout(async () => {
setState({ status: "validating" });
try {
const res = await fetch("/api/validate-tax-id", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ country, type, value }),
});
const data = await res.json();
if (data.valid) {
setState({ status: "valid", formatted: data.value.formatted });
} else {
setState({
status: "invalid",
reason: data.metadata?.reason ?? "format_invalid",
});
}
} catch {
setState({ status: "idle" });
}
}, 300);
};
return { state, validate };
}
export function CpfField() {
const [value, setValue] = useState("");
const { state, validate } = useTaxIdValidator("BR", "cpf");
useEffect(() => {
if (state.status === "valid") {
setValue(state.formatted);
}
}, [state]);
return (
<div>
<label htmlFor="cpf">CPF</label>
<input
id="cpf"
type="text"
value={value}
onChange={(e) => setValue(e.target.value)}
onBlur={() => validate(value)}
aria-invalid={state.status === "invalid"}
/>
{state.status === "valid" && <span role="status">CPF valido</span>}
{state.status === "invalid" && (
<span role="alert">{mapReasonToMessage(state.reason)}</span>
)}
</div>
);
}
function mapReasonToMessage(reason: string): string {
switch (reason) {
case "all_equal_digits":
return "Ese CPF tiene todos los digitos iguales — no es un CPF valido.";
case "invalid_check_digit":
return "El digito verificador no coincide. Quizas un typo?";
case "invalid_length":
return "Un CPF debe tener 11 digitos.";
default:
return "Ese CPF no parece valido. Revisalo.";
}
}Mostrar el normalized al usuario como positive feedback
Cuando el input pasa la validación, mostrar el valor normalizado en el field (no solo un check verde) le da al usuario dos beneficios: confirma visualmente que el sistema entendió correctamente su input y le ofrece una versión canónica para copiar a otro lado si la necesita. El truco es no sobrescribir el valor mientras el usuario sigue editando — solo después de un onBlur exitoso. Ejemplo con un campo de CUIT:
import { useState, useRef } from "react";
export function CuitField() {
const [value, setValue] = useState("");
const [validated, setValidated] = useState<{
valid: boolean;
formatted?: string;
reason?: string;
} | null>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
const handleBlur = () => {
clearTimeout(debounceRef.current);
if (!value.trim()) {
setValidated(null);
return;
}
debounceRef.current = setTimeout(async () => {
const res = await fetch("/api/validate-tax-id", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
country: "AR",
type: "cuit",
value,
}),
});
const data = await res.json();
setValidated({
valid: data.valid,
formatted: data.value?.formatted,
reason: data.metadata?.reason,
});
// Mostrar el formato canonico — el usuario ve que el sistema entendio.
if (data.valid && data.value?.formatted) {
setValue(data.value.formatted);
}
}, 300);
};
return (
<div>
<label htmlFor="cuit">CUIT</label>
<input
id="cuit"
type="text"
inputMode="numeric"
autoComplete="off"
value={value}
onChange={(e) => setValue(e.target.value)}
onBlur={handleBlur}
aria-describedby="cuit-status"
aria-invalid={validated?.valid === false}
/>
<div id="cuit-status" role="status">
{validated?.valid === true && <span>CUIT valido</span>}
{validated?.valid === false && (
<span role="alert">CUIT invalido — revisalo.</span>
)}
</div>
</div>
);
}Error UX: mapeá warnings del response a mensajes específicos
Un "formato inválido" genérico no le sirve a nadie. La API de Normadata devuelve un metadata.reason o warnings con el motivo específico del rechazo. Mapear esos códigos a mensajes accionables transforma un dead-end en una guía. Los códigos típicos que vas a ver:
- invalid_length — "Un CPF tiene 11 dígitos. Vos ingresaste N."
- invalid_check_digit — "Hay un typo en el dígito verificador. Revisá los últimos dígitos."
- all_equal_digits — "Ese CPF tiene todos los dígitos iguales y no es un CPF emitido. Revisá si pegaste el correcto."
- invalid_prefix — (CUIT) "El prefijo del CUIT debe ser 20, 23, 24, 27, 30, 33 o 34."
- invalid_character — "El identificador contiene caracteres inválidos (espacios, símbolos)."
- invalid_check_char — (RUT CL) "El carácter verificador debe ser un dígito o K."
- unknown_country_type — "No reconocemos este combo país/tipo. Verificá la configuración del form."
curl -X POST https://api.normadata.io/v1/verify/tax-id \
-H "X-API-Key: nd_your_key_here" \
-H "Content-Type: application/json" \
-d {
"value": " 111.444.777-35 ",
"country": "BR",
"type": "cpf"
}Performance: regex client-side como first-gate, Normadata como check exhaustivo
No tiene sentido mandar a Normadata un input que claramente no puede ser un CPF (por ejemplo, una cadena con letras o de longitud claramente equivocada). Una regex client-side simple actúa como primer gate y solo dispara la llamada async cuando el input tiene forma plausible. Esto reduce ruido en tu rate-limit y le da al usuario feedback instantáneo en casos obvios. La regla: la regex valida estructura superficial (longitud, charset). Normadata valida estructura completa (longitud, charset, dígito verificador, patrones inválidos como all-equal, prefijos válidos). La regex no hace dígito verificador — esa parte es lo que justamente queremos que sea trabajo del backend. Para CPF, por ejemplo: la regex `^[\d.\s-]{11,14}$` y luego strip + length === 11 ya filtra el 90% del ruido sin tocar la red.
Conclusión
Un formulario de checkout LATAM bueno es permisivo en input, claro en feedback y exhaustivo en validación. La combinación de regex local como primer filtro + Normadata async on-blur con debounce + mensajes de error mapeados desde el metadata del response da una UX que acepta cualquier representación válida del identificador, le confirma al usuario que el sistema entendió, y le dice específicamente qué corregir cuando no entendió. Para la referencia del endpoint, ver /docs/api/verify-tax-id. Para el caso de uso completo con patrones adicionales (multi-país, fallback offline, integraciones con form libraries), ver /use-cases/checkout-form-validation. Para acceso a la API, /waitlist.