Módulo del sitio · Formulario de contacto

El Formulario de contacto: el lead a un toque, accesible y sin spam

El componente que recoge al visitante listo para hablar: tres campos accesibles (WCAG 2.2), envío directo a WhatsApp con waUrl() y CONTACT.whatsapp de site.ts, cero backend en la plantilla y patrón canónico anti-spam (honeypot + consentimiento LFPDPPP + Cloudflare Turnstile + rate-limit al edge) cuando el sitio crece a backend real.

Esta página no es una ficha técnica: es el módulo entero, abierto y explicado. Qué problema resuelve y por qué un formulario público es la pieza con MÁS superficie de accesibilidad rota del sitio (un label mal asociado, un input a 14 px que dispara el zoom de iOS, un foco que se pierde tras el submit), de qué seis piezas se compone (encabezado, nombre, contacto, mensaje, honeypot+consentimiento, botón), cómo se comporta en el teléfono (stack mobile-first, teclados semánticos, touch target 44 px, consentimiento tappable) y, al final, cómo se construye —del HTML5 nativo a la Cloudflare Pages Function con Turnstile—.

Con tres particularidades que separan al formulario del resto de los módulos. Primera: no emite JSON-LD propio (regla B3) —el ContactPoint vive en Organization y se emite UNA vez desde buildSchema en BaseLayout; el helper contactPointSchema() está en lib/seo.ts para subgrafos standalone—. Segunda: el componente actual funciona sin backend (handler que abre WhatsApp con waUrl()) pero le faltan piezas P1 documentadas honestamente en cada sección: honeypot, checkbox de consentimiento LFPDPPP, type=email/tel + inputmode + autocomplete. Las recetas muestran el upgrade exacto. Tercera: la accesibilidad NO es opcional —el checklist canónico vive en docs/MODULOS.md §6.3 y se aplica a todo formulario nuevo del sitio, no solo a este—.

Definición

¿Qué es el módulo Formulario de contacto?

El componente accesible que recoge nombre + asunto + mensaje y, al enviar, abre WhatsApp con el texto pre-cargado usando CONTACT.whatsapp (SSoT). Cero backend, cero correo, cero captcha. Cuando el sitio crece a backend real, el handler se reemplaza por POST a una Cloudflare Pages Function sin tocar el markup; el contrato de accesibilidad (WCAG 2.2) y anti-spam (honeypot + consentimiento LFPDPPP + Turnstile) NO depende del backend.

El formulario de contacto (ContactForm.astro) es el módulo que convierte la intención («quiero hablar con ellos») en una conversación real, con la menor fricción posible. En esta plantilla, esa conversación es WhatsApp: el componente arma un mensaje con los campos completados, encodea con encodeURIComponent y abre wa.me/<CONTACT.whatsapp>?text=… en ventana nueva. El visitante envía el mensaje desde su propio WhatsApp; el negocio responde desde el suyo. Cero correos perdidos en spam, cero respuesta automática «en 48 horas», cero formularios que el usuario abandona porque pidió 11 campos.

La API actual del componente es deliberadamente mínima: tres props opcionales (heading, note, asuntos) y tres campos visibles (nombre, asunto, mensaje). El número de WhatsApp se lee de site.ts (CONTACT.whatsapp) vía data-attribute; el handler de submit hace el encodeURIComponent y abre wa.me. Cero schema, cero hardcoding del número (regla D4), cero JavaScript de validación pesada (HTML5 nativo: required + minlength). Lo que esta página documenta —y lo que las recetas de §5 cubren— es cómo escalar este componente para sitios con backend real, sin romper la accesibilidad ni el patrón anti-spam canónico.

Función e importancia

¿Para qué sirve?

Hace tres trabajos a la vez, todos críticos: convierte la intención del visitante en una conversación real (con la menor fricción posible), cumple WCAG 2.2 sin pedir JavaScript adicional, y deja al sitio listo para escalar a backend real con un upgrade limpio (honeypot + Turnstile + consentimiento LFPDPPP) cuando llegue el momento.

Su función primaria es comercial: el formulario es el cuello de botella donde la mayoría de los sitios pierde leads —placeholders en lugar de labels, inputs a 14 px que zoomean iOS, validaciones genéricas que no dicen qué corregir, captchas que toman 30 segundos resolver, formularios de 10 campos para pedir un presupuesto—. El componente actual elimina TODAS esas fricciones: 3 campos, labels asociados, inputs a 16 px, validación HTML5 nativa, cero captcha, envío directo a WhatsApp. Para el visitante: 30 segundos del primer tap al primer mensaje enviado. Para el negocio: el lead llega al canal donde ya responde, sin bandejas que vigilar.

Su función secundaria es de accesibilidad: el formulario es el módulo con MÁS superficie WCAG del sitio (cada input es 6 reglas: label, contraste, foco, touch target, error, focus management). Cumplirlas no es opcional ni es solo para usuarios con discapacidad —son las mismas reglas que mejoran la experiencia en un teléfono al sol, en una conexión 3G, con guantes invierno, o para alguien que escribe en su segunda lengua—. El componente actual cubre las reglas base; el checklist canónico de docs/MODULOS.md §6.3 más las recetas de §5 cubren el AA enhanced. Y la terciaria es de seguridad: el patrón canónico anti-spam (honeypot + consentimiento LFPDPPP + Turnstile + rate-limit) corta el 99% del spam sin pedirle al humano que resuelva un captcha, y deja al sitio listo para escalar a backend real cuando crezca.

Conversión sin fricción: el contacto a un toque, no a tres

El formulario actual no manda correos ni golpea un backend que falla — arma el mensaje y abre WhatsApp con el texto pre-cargado. Para el visitante: cero campos extra, cero ventanas de «gracias, revisaremos en 48 horas», cero bandeja de spam donde se pierde la respuesta. Para el negocio: el lead llega al canal donde ya responde. Si el sitio crece y necesita backend (correo, CRM, ticketing), el handler se reemplaza por POST a una Cloudflare Pages Function sin tocar el markup.

Accesibilidad de fábrica: WCAG 2.2 sin JS adicional

Cada campo va con label asociado por for/id, foco visible con outline (no solo color), inputs a 16 px (anti-zoom iOS), botón ≥48 px (Apple HIG · WCAG SC 2.5.5), transiciones que respetan prefers-reduced-motion. Lo que falta del checklist canónico —aria-describedby para pistas, aria-invalid + role="alert" para errores, focus management post-submit— se documenta y se receta en §5; el componente actual cubre las reglas base, las recetas habilitan las AA enhanced.

Privacidad y anti-spam por diseño, no como parche

El patrón canónico (en cuanto hay backend) incluye honeypot oculto, checkbox de consentimiento LFPDPPP con enlace a /privacidad antes del submit, validación server-side autoritativa, Cloudflare Turnstile en lugar de reCAPTCHA (sin tracking de Google), rate-limit por IP en el edge, cero PII en URL/GET. Hoy el componente vive sin honeypot ni consentimiento (DEUDA P1 documentada en §6) — la página explica las dos brechas con los snippets exactos para cerrarlas.

Anatomía

¿Qué lleva por dentro?

Seis piezas en orden estricto: encabezado, nombre, contacto (DEUDA P1), mensaje, honeypot + consentimiento (DEUDA P1), botón. Cada ficha cita exactamente la prop o el atributo HTML real del componente, y marca explícito lo que es DEUDA del componente actual con su receta de upgrade en §5.

No todas las piezas existen hoy en el componente. La auditoría honesta: el ContactForm actual cubre las piezas 1, 2, 4 y 6 (encabezado, nombre, mensaje, botón) y trae un select de asunto entre nombre y mensaje. Las piezas 3 (campo de contacto extra: email o teléfono) y 5 (honeypot + consentimiento LFPDPPP) son DEUDA P1 del componente —se documentan aquí con la receta exacta para añadirlas—. Sin esas dos, el componente vive bien para el caso WhatsApp (el visitante manda nombre + mensaje, el negocio lo identifica por su propio número y le responde por WhatsApp); con esas dos, escala a sitios con backend real y cumplimiento de privacidad por diseño.

Las fichas de abajo desglosan cada pieza: qué hace, de qué prop o atributo HTML sale, y dónde está la receta para cerrar la deuda cuando aplica. La sección termina con un ejemplo en vivo del componente real (sección 7 «En vivo» abajo), no con un mockup. Es eat-your-own-dog-food: si el componente se rompe, el ejemplo se rompe a la vista de la persona que lee esta guía.

  1. Encabezado — título + subtítulo del bloque

    Da contexto antes del primer campo. Título corto («Escríbenos», «¿Tienes una duda?»), subtítulo de UNA frase que explica qué pasa al enviar («Se abrirá WhatsApp con tu mensaje listo»). Es la diferencia entre un formulario que parece exigir y uno que parece invitar.

    SSoT: props heading? · subtítulo hardcodeado en el componente actual

  2. Campo de nombre — label asociado + autocomplete

    Un solo input de nombre completo (NO dos campos separados nombre/apellido: rinde menos en LATAM). label asociado por for/id, autocomplete="name", required, enterkeyhint="next" para que el teclado móvil muestre «Siguiente». Touch target del control: padding del label + altura del input dan ~48 px de superficie tappable.

    SSoT: <label for="cf-nombre"> · <input id="cf-nombre" name="nombre" autocomplete="name" required>

  3. Campo de contacto — email o teléfono (DEUDA P1)

    El componente actual NO tiene este campo: el visitante manda su nombre + asunto + mensaje, y el WhatsApp identifica al remitente por su propio número. Si la página vive aparte de WhatsApp (recibe el mensaje por correo o backend), el patrón canónico es UN campo email (type="email" + inputmode="email" + autocomplete="email") o, mejor en MX, type="tel" + inputmode="tel" + autocomplete="tel-national". Receta en §5.

    SSoT: DEUDA P1 · receta en §5 · type/inputmode/autocomplete canónicos

  4. Mensaje — textarea con minlength + describedby

    El cuerpo libre. textarea con rows="4", required, minlength="20" (evita «hola» suelto que sube la fricción del primer reply). aria-describedby apunta a una pista visible («Cuéntanos qué necesitas: producto, plazo, presupuesto orientativo»). El font-size de 16 px del componente evita el zoom de iOS al enfocar.

    SSoT: <textarea id="cf-mensaje" name="mensaje" rows="4" required minlength="20"> · aria-describedby

  5. Honeypot + consentimiento LFPDPPP (DEUDA P1)

    DEUDA del componente actual: NO trae honeypot (input oculto que solo los bots rellenan → se descarta el envío si llega lleno) NI checkbox de consentimiento al envío con enlace a /privacidad (requisito de la Ley Federal de Protección de Datos Personales en Posesión de los Particulares, art. 16). En backend real se suma Cloudflare Turnstile (sustituto moderno de reCAPTCHA, sin tracking) + rate-limit por IP en el edge. Recetas en §5 (uso canónico) y §6 (deuda).

    SSoT: <input name="website" tabindex="-1" autocomplete="off" style="position:absolute;left:-9999px"> · <input type="checkbox" name="consent" required>

  6. Botón de envío — CTA con foco visible

    Verde WhatsApp (#25D366) consistente con el botón flotante de toda la plantilla, ícono SVG inline, full-width en el componente actual. focus-visible con outline de 2 px (no solo cambio de color), transición desactivada bajo prefers-reduced-motion. Type submit nativo: el form se puede mandar con Enter desde el último campo (enterkeyhint="send").

    SSoT: <button class="cform__submit" type="submit"> · :focus-visible · @media (prefers-reduced-motion: reduce)

Otros diseños y aplicaciones

Variantes del Formulario

Seis configuraciones: dos salen del componente actual con props/datos existentes (REAL) y cuatro son EXTENSIÓN (requieren agregar campos, una prop o un componente hermano). Cada réplica pinta el layout en miniatura con clases .cfv (contact-form variant) y deja claro qué se reordena o agrega.

El formulario admite variantes sin perder identidad: cambia el número de campos (3 canónicos vs 5 con email/consentimiento vs 2 inline), la disposición (single-step vs multi-step), el canal de envío (WhatsApp vs correo vs WhatsApp+correo) y el contexto de inserción (sección dedicada vs inline en sidebar vs bottom-sheet móvil). El componente actual cubre la canónica WhatsApp; las EXTENSIÓN documentan cómo escalarlo sin romper la arquitectura de accesibilidad ni el patrón anti-spam.

Cada variante tiene su contexto de aplicación: el negocio local quiere la canónica de 3 campos, el servicio B2B quiere consentimiento + email, el formulario largo quiere multi-step, el sidebar/footer quiere la inline compacta, el servicio creativo quiere archivo adjunto, la landing de campaña quiere bottom-sheet móvil. Reusar el mismo componente (o componer un hermano) con configuraciones distintas es la lección —el formulario NO es seis componentes, es uno con seis caras—.

  • ¿Tienes una duda?
    Nombre
    AsuntoQuiero una cotización
    Mensaje

    Canónica — 3 campos → WhatsApp (REAL hoy)

    Negocio local · Servicios · MVP · Páginas de contacto · Cierre de landing

    La cara natural del componente actual: nombre + asunto (select) + mensaje (textarea) + botón verde WhatsApp. Cero backend, cero correo, cero captcha. El submit handler arma un texto con los tres campos y abre wa.me con el número de CONTACT.whatsapp encodeado. Aplica a la mayoría de los sitios mexicanos de servicios donde el lead llega por WhatsApp y el resto del flujo lo gestiona el negocio en su CRM/agenda.

  • Canónica + email + consentimiento (REAL con props pequeñas)

    Servicios B2B · Cotizaciones formales · Sitios con backup en correo

    REAL con upgrade mínimo: agregar un input type="email" + inputmode/autocomplete antes del mensaje, y un checkbox de consentimiento LFPDPPP con enlace a /privacidad antes del submit. El handler de WhatsApp puede incluir el correo en el texto que arma. NO requiere backend; suma trazabilidad para el negocio (queda registro del correo en su chat de WhatsApp) y cumplimiento privacidad por diseño.

  • 1 2 3
    Paso 2 de 3 · Detalles del proyecto
    Presupuesto orientativo$$ ($30k–$80k)
    Plazo deseado
    ← Atrás

    Multi-step (3 pasos · EXTENSIÓN)

    Formularios largos · Cotizaciones complejas · Onboarding · Lead gen

    EXTENSIÓN: el componente actual es single-step (3 campos visibles). Para flujos largos (10+ campos: producto, presupuesto, plazos, archivos) se reparte en 3 pasos con barra de progreso (Paso 1 de 3) y navegación previa/siguiente. Requiere prop steps?: { title, fields }[] y un poco de JS de gestión de estado. Sube la conversión de formularios largos al partir la fatiga visual; baja la del corto (no usar para 3 campos).

  • Solo email · cotización express

    Compacta inline — solo email + mensaje (REAL parcial)

    Sidebar · Footer · Pre-footer · Newsletter expandido · Hero secundario

    EXTENSIÓN sobre el componente actual: una versión con solo 2 campos (email + mensaje) o 3 inputs en una fila (nombre + email + botón) para incrustar en sidebar, footer o pre-footer sin ocupar todo el ancho. Requiere prop layout?: 'stack' | 'inline' o un componente hermano CompactForm. Tradeoff: menos friction, menos contexto (sin asunto el negocio entra a la conversación con menos información).

  • Nombre
    Correo
    brief-proyecto.pdf · 2.4 MB
    + Adjuntar otro (max 10 MB)
    Mensaje

    Con archivo adjunto (EXTENSIÓN)

    Diseño · Imprenta · Auditoría · Cotizaciones con plano · Reclutamiento

    EXTENSIÓN: el componente actual no acepta archivos. Para servicios donde el lead necesita mandar un plano, una foto del defecto, un brief o un CV, agregar input type="file" multiple accept="image/*,application/pdf" + límite de 10 MB validado en cliente y server. Como el handler actual abre WhatsApp (que no recibe archivos por URL), esta variante requiere backend real (Cloudflare R2 bucket + Pages Function que sube el archivo y avisa al negocio).

  • Hablemos
    Nombre
    Mensaje
    Hablar con un asesor

    Sticky CTA + bottom-sheet en móvil (EXTENSIÓN)

    Landing pages · Funnel agresivo · Mobile-first · Campaña paga

    EXTENSIÓN: en móvil, el form vive plegado en un botón fijo en la zona del pulgar («Hablar con un asesor») y se abre como bottom-sheet (modal anclado al borde inferior) al tap. En escritorio se renderiza normal en su sección. Patrón de landings de campaña paga donde la conversión móvil es la métrica única. Requiere un componente hermano BottomSheetForm o una prop variant='bottom-sheet' + JS de estado.

Responsive y móvil

El formulario, en el teléfono

Cuatro patrones reales: stack mobile-first (default del componente, cero media queries propias), teclados semánticos con inputmode + autocomplete + enterkeyhint, consentimiento LFPDPPP tappable con área extendida, y touch target ≥44 px en botón y checkbox. Todo HTML5 nativo + CSS puro.

El formulario es más sensible al móvil que la mayoría de los módulos: cada campo es una superficie de error potencial (teclado equivocado, zoom de iOS, dedo gordo, autocomplete fallido). El patrón canónico es mobile-first puro: el componente nace en stack a una columna con inputs full-width a 16 px (anti-zoom iOS) y botón ≥48 px; la cascada de tokens del sitio (--container-max al 100% en ≤768) hace el resto. Cero media queries propias del componente para el layout.

A eso se suman tres detalles que pocos formularios cubren: teclados semánticos (cada type/inputmode/autocomplete activa el teclado correcto y autocompleta datos guardados — diferencia 8 segundos en un form de 4 campos), consentimiento LFPDPPP con área tappable extendida (el cuadrito de 16 px nativo NO cumple los 44 px de WCAG SC 2.5.5; el wrapper del label sí), y touch target generoso en botones y checkboxes (Apple HIG 44 pt = WCAG 2.2 AA enhanced 44 CSS px).

1 · Stack mobile-first (default del componente)

El componente ya nace mobile-first: cada campo apila al 100% del ancho del contenedor, inputs a font-size: 16px literal (anti-zoom iOS), botón full-width con min-height: 48px. Cero media queries propias: la cascada de tokens del sitio (--container-max al 100% en ≤768) hace el resto.

CSS · stack mobile-first del componente actual
/* MÓVIL · STACK A UNA COLUMNA (default del componente)
   El componente ya es mobile-first: cada campo apila al 100% del
   ancho del contenedor, el botón es full-width, los inputs van a
   16 px (anti-zoom iOS). Cero media queries propias: la cascada de
   tokens del sitio (--container-max al 100% en ≤768) hace que el
   form coma todo el ancho útil del viewport.

   Si la página padre tiene padding horizontal (la mayoría sí), el
   form respeta ese gutter y no necesita reglas extra. */

.cform {
  display: flex;
  flex-direction: column;
  gap: var(--sp-4);
  /* width: 100% por default del flex column */
}

.cform input,
.cform textarea,
.cform select {
  width: 100%;
  font-size: 16px;       /* ⚠️ literal: 1rem rompe si el root cambia */
  padding: .7em .9em;    /* ~48 px de altura tappable */
}

.cform__submit {
  width: 100%;
  padding: .85em 1.2em;  /* ≥48 px de alto (WCAG SC 2.5.5) */
}

2 · Teclados semánticos (type + inputmode + autocomplete + enterkeyhint)

Cada input le dice al teclado del teléfono qué mostrar y qué acción ofrecer al Enter. type="email" + inputmode="email" abre teclado con @ y . visibles; type="tel" + inputmode="tel" abre el numérico. autocomplete deja que el navegador autorrellene con datos guardados. enterkeyhint="next" / "send" cambia el label del Enter. 8 segundos de ahorro en un form de 4 campos, menos errores de tipeo.

HTML · teclados, autocomplete y enterkeyhint
/* MÓVIL · TIPOS DE TECLADO + AUTOCOMPLETE + ENTERKEYHINT
   Cada input le dice al teclado del teléfono qué mostrar y qué
   acción ofrecer al Enter. La diferencia entre un teclado QWERTY
   abierto en el campo de teléfono y uno numérico con .@-+ visible
   son 2 segundos por campo —en 4 campos, 8 segundos de ahorro y
   menos errores de tipeo—.

   AUTOCOMPLETE: deja que el navegador autocomplete con datos
   guardados (perfil Google/Apple, llaveros). Es una capa de UX
   gratuita que NUNCA debe deshabilitarse con autocomplete="off"
   salvo en campos sensibles (passwords, OTPs). */

<input
  type="text"
  name="nombre"
  autocomplete="name"
  enterkeyhint="next"    /* Enter = «Siguiente» en el teclado móvil */
  required
/>

<input
  type="email"
  name="email"
  inputmode="email"      /* teclado con @ y . visibles */
  autocomplete="email"
  enterkeyhint="next"
  required
/>

<input
  type="tel"
  name="telefono"
  inputmode="tel"        /* teclado numérico con + - ( ) */
  autocomplete="tel-national"  /* MX: nacional, no E.164 */
  enterkeyhint="next"
/>

<textarea
  name="mensaje"
  enterkeyhint="send"    /* Enter = «Enviar» en el último campo */
  required
></textarea>

3 · Consentimiento LFPDPPP tappable (área extendida + required)

El checkbox de consentimiento va ANTES del submit, marcado como required, desmarcado por default (consentimiento pre-marcado = NO es consentimiento) y enlaza al aviso de privacidad. El cuadrito de 16 px nativo NO cumple los 44 px de WCAG SC 2.5.5; el <label> wrapper sí, con min-height: 44px + padding + cursor: pointer. La nota bajo el botón es INFORMATIVA, no sustituye al checkbox.

HTML · consentimiento LFPDPPP con área tappable
/* TODOS los formularios · CONSENTIMIENTO LFPDPPP / GDPR
   El checkbox de consentimiento es REQUISITO legal en MX (LFPDPPP
   art. 16) y EU (GDPR art. 6). Va ANTES del botón submit, marcado
   como required, desmarcado por default (consentimiento pre-marcado
   = NO es consentimiento), y enlaza al aviso de privacidad.

   La nota «al enviar aceptas…» bajo el botón NO sustituye al
   checkbox: ante una queja del INAI o del DPA europeo, la prueba
   es el checkbox marcado + timestamp del submit, no la frase
   decorativa. */

<div class="cform__field cform__field--check">
  <label class="cform__check">
    <input
      type="checkbox"
      name="consent"
      required
      aria-describedby="consent-desc"
    />
    <span>
      He leído y acepto el
      <a href="/privacidad">aviso de privacidad</a>.
    </span>
  </label>
  <p id="consent-desc" class="cform__hint">
    Tus datos solo se usan para responder esta solicitud.
    Política de retención: 90 días, o hasta que pidas borrado.
  </p>
</div>

4 · Touch target ≥44×44 px (WCAG SC 2.5.5 · Apple HIG)

El umbral viene de Apple HIG (44 pt) y WCAG 2.2 AA enhanced (44 CSS px). Para inputs altos basta con el padding del label (~48 px); para botones, min-height: 48px + padding generoso; para checkboxes y radios, agregar un <label> wrapper tappable con min-height: 44px que extienda el área de toque más allá del cuadrito de 16 px nativo. Además: -webkit-tap-highlight-color sutil para feedback al tap.

CSS · touch target 44+ px en botones y checkboxes
/* MÓVIL · TOUCH TARGET ≥ 44×44 PX (WCAG SC 2.5.5)
   El umbral viene de Apple HIG (44 pt) y WCAG 2.2 AA enhanced
   (44 CSS px). Para inputs altos basta con el padding del label
   (~48 px); para botones, padding vertical + min-height; para
   checkboxes y radios, agregar un wrapper tappable que extienda
   el área de toque más allá del cuadrito de 16 px nativo. */

@media (max-width: 1024px) {
  /* Botón con altura mínima y padding generoso. */
  .cform__submit {
    min-height: 48px;
    padding: .9em 1.2em;
  }

  /* Checkbox + label como área tappable extendida. */
  .cform__check {
    display: flex;
    align-items: flex-start;
    gap: var(--sp-3);
    min-height: 44px;
    padding: var(--sp-3) 0;       /* área extra arriba/abajo */
    cursor: pointer;
  }
  .cform__check input[type="checkbox"] {
    width: 20px; height: 20px;     /* cuadrito + área del label = 44+ */
    margin-top: 2px;
    flex-shrink: 0;
  }

  /* Realce táctil al tap, sin animación. */
  a, button, [role="button"], label {
    -webkit-tap-highlight-color: rgba(91, 61, 245, .15);
  }
}

Posición en el layout

¿Dónde va en la página?

Al cierre de páginas de venta (después de las objeciones resueltas por FAQ, antes del footer), en columna junto al FAQ para el patrón del home (FAQ a la izquierda + ContactForm a la derecha), o en una página dedicada /contacto que reúne los canales. NUNCA al inicio: el formulario es la última pieza, no la primera.

A diferencia del Hero (uno por página al inicio) o el Topbar (visible en todas), el formulario aparece UNA vez por página, al cierre, después de que el visitante consumió el contenido y antes del footer. Esa posición no es casual: el formulario recoge al visitante que ya decidió hablar. Si lo pones arriba, robas atención al hero y pides compromiso antes de dar valor; abajo, atrapa a quien está convencido. La excepción son las páginas dedicadas (/contacto): allí el formulario puede ir cerca del top, pero acompañado de los otros canales (WhatsApp directo, teléfono, correo) para respetar la preferencia del visitante.

Lo que NO hace el formulario, por diseño: NO emite JSON-LD propio. El ContactPoint de la entidad vive en Organization (vía buildSchema + organizationSchema.contactPoint con CONTACT.phoneRaw + CONTACT.email + areaServed='MX' + availableLanguage=['es-MX', 'Spanish']) y se emite UNA vez por página desde BaseLayout. Duplicarlo desde el form = dos ContactPoint en el grafo, Google se confunde, regla B3 rota. Para subgrafos standalone donde Organization NO se emite (caso muy raro), el helper contactPointSchema() está en lib/seo.ts. Cross-link al módulo Footer para entender cómo ContactPoint se emite UNA sola vez desde Organization → /modulos/footer §7.

Capa técnica

Cómo está construido

El componente vive en src/components/ContactForm.astro y lee CONTACT.whatsapp de site.ts (SSoT). Tres recetas cubren el espectro de uso: HTML5 nativo sin JS (lo que pinta el componente hoy), upgrade con honeypot + validación es-MX + consentimiento, e integración con Cloudflare Pages Function (POST real + Turnstile + rate-limit + envío vía Resend/Brevo).

La arquitectura es estricta: el componente no decide datos (el número viene de CONTACT.whatsapp), no emite schema (vive en Organization), no hardcodea ni el número ni los mensajes (waUrl() + WA_MESSAGES). Esa separación de responsabilidades mantiene al formulario simple en la plantilla y escalable a sitios reales con backend, sin reescribir el markup.

Las tres recetas son el camino completo: la receta A muestra el uso HTML5 nativo (lo que ya funciona hoy), la B añade honeypot + validación es-MX + consentimiento (sin backend todavía), y la C documenta la integración con Cloudflare Pages Function — el camino para sitios con backend real (envío por correo, CRM, ticketing) sin perder los principios de accesibilidad y anti-spam canónico.

A · Uso básico HTML5 nativo (lo que pinta el componente actual)
---
// USO BÁSICO · HTML5 nativo, sin JavaScript de validación.
// El componente actual ya pinta este shape. La página padre solo
// lo importa y lo monta. Cero schema, cero backend; el handler
// del componente arma el texto y abre wa.me con CONTACT.whatsapp.
import ContactForm from '@components/ContactForm.astro'

// Las props son OPCIONALES — todas tienen default sensato.
//   heading?  → título del bloque (default «¿No está tu duda? Escríbenos»)
//   note?     → nota bajo el botón (default «Respuesta inmediata · Sin compromiso»)
//   asuntos?  → opciones del <select> (default 4 asuntos genéricos)
---

<section class="section section--surface">
  <div class="container">
    <ContactForm
      heading="¿Tienes una duda? Escríbenos"
      asuntos={['Quiero una cotización', 'Soporte técnico', 'Una pregunta general']}
    />
  </div>
</section>

{/* Comportamiento: al submit el componente verifica checkValidity()
    nativo, arma un mensaje con nombre + asunto + mensaje, encodea con
    encodeURIComponent y abre wa.me/<CONTACT.whatsapp>?text=… en ventana
    nueva. NO manda correo, NO golpea backend, NO emite schema. */}
B · Con honeypot + validación es-MX + consentimiento LFPDPPP
---
// CON HONEYPOT + VALIDACIÓN es-MX · upgrade del componente actual
// para sitios con backend real (cuando el form NO va a WhatsApp).
//
// EL HONEYPOT: un input oculto al humano (position:absolute,
// left:-9999px) que SOLO los bots rellenan (los crawlers rellenan
// todo lo que ven). Si llega con valor → 90% probable bot → se
// descarta sin avisar al humano. Cero captcha, cero fricción
// para el visitante real. 4 líneas de HTML + 1 check server.
//
// VALIDACIÓN es-MX: pattern para teléfono nacional (10 dígitos),
// type=email + nativo + check de TLD válido en server.
---

<form method="POST" action="/api/contacto" class="cform">

  <!-- HONEYPOT · oculto al humano, visible al bot -->
  <input
    type="text"
    name="website"
    tabindex="-1"
    autocomplete="off"
    aria-hidden="true"
    style="position:absolute; left:-9999px; opacity:0; pointer-events:none;"
  />

  <div class="cform__field">
    <label for="cf-nombre">Nombre completo</label>
    <input
      id="cf-nombre" name="nombre" type="text"
      autocomplete="name" enterkeyhint="next"
      required minlength="2" maxlength="80"
      aria-describedby="nombre-err"
    />
    <span id="nombre-err" class="cform__err" role="alert"></span>
  </div>

  <div class="cform__field">
    <label for="cf-tel">Teléfono (10 dígitos)</label>
    <input
      id="cf-tel" name="telefono" type="tel"
      inputmode="tel" autocomplete="tel-national"
      enterkeyhint="next"
      required pattern="[0-9]{10}" maxlength="10"
      aria-describedby="tel-err"
    />
    <span id="tel-err" class="cform__err" role="alert"></span>
  </div>

  <div class="cform__field">
    <label for="cf-msg">¿Cómo podemos ayudarte?</label>
    <textarea
      id="cf-msg" name="mensaje" rows="5"
      enterkeyhint="send"
      required minlength="20" maxlength="2000"
      aria-describedby="msg-help msg-err"
    ></textarea>
    <p id="msg-help" class="cform__hint">Cuéntanos qué necesitas: producto, plazo, presupuesto.</p>
    <span id="msg-err" class="cform__err" role="alert"></span>
  </div>

  <!-- CONSENTIMIENTO · requisito LFPDPPP art. 16 -->
  <label class="cform__check">
    <input type="checkbox" name="consent" required />
    <span>He leído y acepto el <a href="/privacidad">aviso de privacidad</a>.</span>
  </label>

  <button type="submit" class="cform__submit">Enviar mensaje</button>

  <div role="status" aria-live="polite" class="cform__status"></div>
</form>
C · Cloudflare Pages Function · POST + Turnstile + rate-limit
// CLOUDFLARE PAGES FUNCTION · POST real con honeypot + Turnstile + rate-limit
// Archivo: functions/api/contacto.ts (sigue la convención Pages Functions).
//
// FLUJO COMPLETO (edge, sin servidor que mantener):
//   1. Lee el body POST.
//   2. Verifica honeypot: si el campo 'website' viene con valor → bot.
//   3. Verifica Cloudflare Turnstile (sustituto moderno de reCAPTCHA, sin tracking).
//   4. Valida shape server-side (NO confíes en el client).
//   5. Rate-limit por IP (10 envíos / hora) usando Cloudflare KV.
//   6. Envía a Brevo / Resend / MailChannels (free en Cloudflare Workers).
//   7. Devuelve { ok: true } o { ok: false, error }.

export const onRequestPost: PagesFunction<{
  TURNSTILE_SECRET: string;
  RATE_LIMIT: KVNamespace;
  RESEND_KEY: string;
}> = async ({ request, env }) => {
  const data = await request.formData();

  // 1. HONEYPOT — si trae valor, es bot. Respondemos 200 OK falso (que el
  //    bot crea que tuvo éxito y no reintente con otro vector).
  if (String(data.get('website') || '').trim() !== '') {
    return Response.json({ ok: true });
  }

  // 2. TURNSTILE — verificación server-side del token del widget.
  const token = String(data.get('cf-turnstile-response') || '');
  const ip = request.headers.get('CF-Connecting-IP') ?? '';
  const verify = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
    method: 'POST',
    headers: { 'content-type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({ secret: env.TURNSTILE_SECRET, response: token, remoteip: ip }),
  }).then((r) => r.json() as Promise<{ success: boolean }>);
  if (!verify.success) {
    return Response.json({ ok: false, error: 'captcha' }, { status: 400 });
  }

  // 3. VALIDACIÓN SERVER · autoritativa (cliente solo es UX).
  const nombre = String(data.get('nombre') || '').trim();
  const email = String(data.get('email') || '').trim();
  const mensaje = String(data.get('mensaje') || '').trim();
  const consent = data.get('consent') === 'on' || data.get('consent') === 'true';
  if (!consent) return Response.json({ ok: false, error: 'consent' }, { status: 400 });
  if (nombre.length < 2 || nombre.length > 80) return Response.json({ ok: false, error: 'nombre' }, { status: 400 });
  if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return Response.json({ ok: false, error: 'email' }, { status: 400 });
  if (mensaje.length < 20 || mensaje.length > 2000) return Response.json({ ok: false, error: 'mensaje' }, { status: 400 });

  // 4. RATE-LIMIT · 10 envíos / hora / IP usando Cloudflare KV.
  const key = `rl:${ip}`;
  const count = Number((await env.RATE_LIMIT.get(key)) || '0');
  if (count >= 10) return Response.json({ ok: false, error: 'rate-limit' }, { status: 429 });
  await env.RATE_LIMIT.put(key, String(count + 1), { expirationTtl: 3600 });

  // 5. ENVÍO · Resend (o Brevo, MailChannels, etc.).
  await fetch('https://api.resend.com/emails', {
    method: 'POST',
    headers: { authorization: `Bearer ${env.RESEND_KEY}`, 'content-type': 'application/json' },
    body: JSON.stringify({
      from: '[email protected]',
      to: '[email protected]',
      subject: `Nuevo contacto: ${nombre}`,
      text: `Nombre: ${nombre}\nCorreo: ${email}\n\n${mensaje}`,
    }),
  });

  return Response.json({ ok: true });
};

Buenas prácticas

Qué hacer y qué evitar

Seis hábitos que mantienen al formulario accesible (WCAG 2.2), seguro (anti-spam canónico) y privacy-by-design (LFPDPPP/GDPR); seis errores que lo degradan a placeholder-as-label, validación-solo-cliente, reCAPTCHA-2026, PII-en-URL, schema-duplicado y consentimiento-decorativo.

La diferencia entre un formulario que convierte y uno que pierde leads cabe en hábitos disciplinarios: label asociado por for/id (no placeholder), type semántico + inputmode + autocomplete (no input genérico), 16 px literal en inputs (no 1rem si el root cambia), focus management post-submit (no foco en botón deshabilitado), honeypot + Turnstile (no reCAPTCHA), consentimiento checkbox (no nota decorativa). Cada regla viene del checklist canónico §6.3 y de docs/VALIDACION-BUENAS-PRACTICAS.md (P1).

Los sí son hábitos de a11y + privacy + UX; los no son tentaciones rápidas que salen caras (en accesibilidad, en spam, en cumplimiento legal, en confianza del visitante). Léelos juntos —cada par sí/no apunta al mismo problema desde dos ángulos—.

  • Asocia cada control a su label con for/id literal. Si el diseño requiere ocultar el label visualmente, usa una clase .sr-only (clip + position:absolute) en lugar de aria-label: los aria-* se traducen peor entre lectores de pantalla y dejan al control sin etiqueta visible cuando aria falla.
  • Usa el type semántico que corresponde al dato: type="email" para correo, type="tel" para teléfono, type="url" para sitio web. Cada uno activa el teclado correcto en móvil y suma una capa de validación nativa del navegador. Acompáñalos siempre con inputmode (email/tel/url) y autocomplete (email/tel-national/url): móviles autocompletan datos guardados sin que escribas.
  • Pon 16 px (literal, no 1rem si el root cambia) en el font-size de inputs/textareas. Safari iOS zoomea al enfocar cualquier campo con font-size < 16 px, y el zoom desencaja el layout y rompe la posición del scroll —el visitante sale del formulario por accidente—. El componente actual ya lo cumple.
  • Maneja el foco después del submit: si hubo error, mueve el foco al primer campo con aria-invalid="true"; si fue éxito, mueve el foco al mensaje de éxito (role="status" + tabindex="-1" + .focus()). Lo natural para un usuario de teclado o lector de pantalla; sin esto, el foco queda en el botón ya deshabilitado y la persona no sabe qué pasó.
  • Incluye honeypot (campo oculto que solo los bots rellenan) en cuanto el envío llegue a un backend o a un correo. Es 4 líneas de HTML + 1 check server-side; corta el 90% del spam sin pedirle al humano que resuelva un captcha. Cuando el honeypot no basta, agrega Cloudflare Turnstile (no reCAPTCHA: Turnstile no perfila al usuario ni envía datos a Google).
  • Marca el consentimiento de privacidad como required y enlaza a /privacidad ANTES del botón submit (LFPDPPP art. 16 en MX, GDPR art. 6 en EU). El checkbox debe estar desmarcado por default (consentimiento pre-marcado = no es consentimiento). El texto del label: «He leído y acepto el [aviso de privacidad]» con el enlace incrustado.

No

  • NO uses placeholder como label («Tu nombre» dentro del input). El placeholder se borra al escribir → el usuario pierde la referencia, los lectores de pantalla lo ignoran o lo leen como label confundiendo el campo, y el contraste de los placeholders suele estar fuera de AA. Label visible + placeholder de ejemplo opcional: nunca uno reemplaza al otro.
  • NO valides solo en cliente. La validación HTML5 (required, pattern, type, minlength) es UX: ayuda al humano a corregir antes de enviar, pero la puede saltar cualquiera deshabilitando JS o haciendo POST a mano. La validación AUTORITATIVA va en el server: si el backend recibe un email malformado, falla en el server, no confía en lo que llegó.
  • NO uses reCAPTCHA v2/v3 si el sitio se publica en 2026 o después. Google reCAPTCHA carga ~600 KB de JS, traza al usuario en todo el sitio (no solo en el form) y rompe la promesa de privacidad de la página. Cloudflare Turnstile hace el mismo trabajo (anti-bot) sin tracking, en ~30 KB, y ya viene en el plan free de Cloudflare Pages —el mismo hosting de este sitio—.
  • NO mandes PII (email, teléfono, nombre) por URL ni en GET. El método del form es POST, siempre. Los logs de Cloudflare/CDN guardan las URLs, y un GET con datos personales filtra PII a esos logs (y al historial del navegador, y al Referer header de la próxima página). POST entra en el body, no en la URL.
  • NO emitas JSON-LD propio desde el formulario. El ContactPoint de la entidad ya vive en Organization (vía buildSchema → organizationSchema.contactPoint, con CONTACT.phoneRaw + CONTACT.email). Duplicarlo desde el form = dos ContactPoint en el grafo, Google se confunde, regla B3 rota. Si necesitas declarar un canal extra (línea técnica, ventas internacionales), usa contactPointSchema() de lib/seo.ts en un subgrafo standalone, no en la página del form.
  • NO escondas el aviso de privacidad detrás de «al enviar aceptas…» en la nota bajo el botón. Si el visitante no marcó un checkbox explícito, NO consintió. La nota es informativa; el consentimiento es interaction. La diferencia es legal: ante una queja del IFAI/INAI o del DPA europeo, la prueba es el checkbox marcado + timestamp, no la frase decorativa.

En vivo

El componente, en su uso real

Las galerías de variantes son mockups a escala; este bloque NO. Aquí se renderiza un ContactForm REAL con sus props default, el mismo que pinta /contacto, idéntico al que pintará cualquier página que lo importe. Si el componente se rompe, este bloque se rompe a la vista.

La regla del sitio es estricta —el componente vive en src/components/ContactForm.astro y se monta sin réplicas anotadas—. Este bloque cierra la página con el componente real, con CONTACT.whatsapp de site.ts. La página padre NO toca el handler de submit: al enviar el form aquí, se abrirá WhatsApp con tu mensaje al número DEMO de la plantilla (525500000000). Documentación que se documenta a sí misma.

Notas honestas: este componente NO tiene honeypot ni checkbox de consentimiento todavía (DEUDA P1 documentada en §6). Si quieres ver el patrón canónico completo, mira las recetas de §7 (B y C). Pero el componente actual ES funcional, accesible (WCAG 2.2 base) y suficiente para sitios de servicios donde el lead llega por WhatsApp.

¿Tienes una duda? Escríbenos

Llena el formulario y se abrirá WhatsApp con tu mensaje listo para enviar.

Respuesta inmediata · Sin compromiso

¿Necesitas ayuda?