Módulo del sitio · Preguntas frecuentes

Las Preguntas frecuentes: el acordeón que resuelve objeciones

El módulo que recoge las dudas reales del visitante al cierre de la página: un acordeón nativo (<details>/<summary>) con preguntas escaneables y respuestas que admiten HTML, más un esquema FAQPage centralizado en lib/seo.ts para que los buscadores entiendan la página sin duplicación.

Esta página no es una ficha técnica: es el módulo entero, abierto y explicado. Qué problema resuelve y por qué la FAQ vive al cierre y no al principio, de qué piezas se compone, cómo se comporta en el teléfono, dónde encaja en una página de venta (y dónde NO, porque hay material que no es para esconder) y, al final, cómo está construida por dentro —del criterio de redacción al JSON-LD que sirve al SEO—.

Con dos particularidades que separan al FAQ del resto de los módulos. Primera: es el único L3 cuyo componente PUEDE emitir JSON-LD propio (FAQPage), pero por default NO lo hace —la prop emitSchema=false es deliberada—. El patrón canónico centraliza el schema en lib/seo.ts → faqSchema() → buildSchema(data.faqs), así que la regla B3 se respeta (un único emisor por página). Segunda: a partir de mayo de 2026 Google retiró los rich results de FAQ para la mayoría de los sitios; el schema sigue siendo correcto, pero ya no rinde en la SERP salvo para autoridades de gov/health.

Definición

¿Qué es el módulo de preguntas frecuentes?

El componente que pinta un acordeón accesible de preguntas y respuestas al cierre de la página: HTML nativo (<details>/<summary>), schema FAQPage opcional (centralizado en lib/seo.ts), y un modo bare para incrustarlo dentro de columnas.

Las preguntas frecuentes (FAQAccordion.astro) son el módulo que recoge las dudas reales del visitante —las que llegan por WhatsApp, por correo, por DM— y las presenta como acordeón al cierre de la página. Cada pregunta vive dentro de un <details> nativo del navegador: cero JavaScript de gestión de estado, accesibilidad de fábrica (foco, teclado, screen reader) y compatibilidad universal. El componente quita el triángulo nativo de WebKit y reemplaza el marcador con su propio chevron rotatorio para mantener la consistencia entre navegadores.

En este proyecto vive en un único componente con una API mínima (items[]: {question, answer}, title?, headingId?, emitSchema?, openFirst?, bare?). Los consumidores reales ya son varios: la home lo incrusta en modo bare junto al ContactForm (FAQ a la izquierda + formulario a la derecha), /productos/index lo pinta al cierre con preguntas del catálogo, y los layouts ServiceLayout/ProductLayout lo usan dentro de la sección «Preguntas frecuentes» con las preguntas del frontmatter del servicio o producto. El JSON-LD lo gestiona lib/seo.ts → buildSchema('page', { faqs }), NO el componente —emitSchema=false por default a propósito—.

Función e importancia

¿Para qué sirve?

Hace tres trabajos a la vez: resuelve objeciones antes de que frenen la conversión, baja la carga de soporte respondiendo lo que todos preguntan, y le da a Google una señal estructurada (FAQPage) para entender la página —aun cuando los rich results de FAQ ya no se muestren en la SERP—.

Su función primaria es comercial: la FAQ no decora, convierte. Las dudas que el visitante NO resuelve antes de cerrar la pestaña son las que matan la conversión —«¿cuánto cuesta?», «¿cuánto tarda?», «¿qué garantía tengo?», «¿puedo editar yo después?»—. Responderlas con honestidad al cierre de la página baja la fricción y, de paso, libera al equipo de repetir lo mismo en cada mensaje de WhatsApp. Una buena FAQ se nota en menos preguntas básicas en el chat y en más mensajes que empiezan con «quiero contratar».

Su función secundaria es semántica: el JSON-LD de FAQPage le da a los buscadores una visión estructurada de las dudas y respuestas de la página. Hasta mayo de 2026, Google mostraba esas preguntas como rich results en la SERP —un efecto medible en CTR—. A partir de esa fecha el rich result se retiró para la mayoría de los sitios (solo se conserva en autoridades gov/health), pero emitir el schema sigue siendo correcto: Google lo usa para entender la página y otros buscadores (Bing, DuckDuckGo) aún lo aprovechan. La señal estructurada es barata; el costo de no emitirla, no medible.

Un componente que resuelve objeciones antes del clic

El FAQ no es ornamento: es la red de seguridad que recoge las dudas que frenan la conversión —precio, tiempos, garantía, formas de pago—. Responderlas con honestidad al cierre de la página baja la fricción y libera al equipo de repetir lo mismo en cada mensaje de WhatsApp.

Acordeón nativo: cero JavaScript de gestión de estado

El componente usa <details>/<summary>, dos elementos del HTML que ya saben abrir y cerrar sin JS. Eso significa cero overhead, accesibilidad completa (foco, teclado, screen reader) gratis, y compatibilidad universal —incluso si se desactiva JavaScript, el acordeón sigue funcionando—.

Schema FAQPage centralizado: SEO sin acoplamiento

El JSON-LD vive en lib/seo.ts → faqSchema(items). Cualquier página puede emitirlo pasando data.faqs a buildSchema(), sin que el componente sepa nada del SEO. Esa separación deja al componente cumpliendo un solo papel —presentación accesible— y al SEO viviendo en un solo archivo del sitio.

Anatomía

¿Qué lleva el FAQ?

Tres piezas dentro de una <section aria-labelledby>: la pregunta como <summary> tappable con chevron rotatorio, la respuesta como <div> que admite HTML (set:html), y el schema FAQPage opcional —desactivado por defecto, porque vive en lib/seo.ts y NO se duplica—.

Cada pieza cumple un papel claro. La pregunta abre con un <summary> dentro de un <details> nativo: el navegador maneja open/close, el chevron SVG rota 180° cuando el item está abierto, y el foco visible y la navegación por teclado son automáticos. La respuesta entra debajo con border-top sutil y respeta set:html para permitir enlaces, listas y negritas. Y el schema, cuando aplica, NO sale del componente: la prop emitSchema=false (default) deja la emisión a lib/seo.ts → buildSchema(data.faqs), evitando que el mismo FAQPage aparezca dos veces en la página (regla B3 — un único emisor por página).

Abajo, el ejemplo en vivo —réplica anotada a escala del componente—. Cada punto numerado se desglosa en su tarjeta: qué resuelve y de qué prop sale. La sección termina mostrando el componente real al final de la página (sección «En vivo»), no como mockup, para que el espécimen y el componente convivan en la misma página.

1

Pregunta — el <summary> tappable

El gancho clicable. Cada item es un <details> nativo del navegador (cero JS de gestión de estado) y la pregunta vive dentro del <summary>: la única parte siempre visible. Acompaña un chevron SVG que rota 180° cuando el <details> está abierto —señal visual de «aquí cabe más»—. El componente quita el triángulo nativo de WebKit y reemplaza el marcador con su propio chevron para mantener la consistencia entre navegadores.

Dato props items[].question → <summary class="faq__q">

2

Respuesta — admite HTML (set:html)

El cuerpo desplegable. Se renderiza con set:html sobre answer, así que admite enlaces, listas, negritas y cualquier markup necesario para responder bien. Los enlaces dentro de una respuesta heredan el color de marca por el selector :global(a) del scoped style; los párrafos pierden el margen por defecto para integrarse al ritmo del acordeón.

Dato props items[].answer → <div class="faq__a"><p set:html>

3

Schema FAQPage opcional (centralizado)

El JSON-LD de FAQPage NO es responsabilidad del componente por defecto. La prop emitSchema=false (default) lo deja apagado, porque el patrón canónico centraliza el schema en lib/seo.ts → buildSchema('page', { faqs }), evitando duplicación si la página padre ya pasa data.faqs. Solo se activa con emitSchema={true} cuando esta es la única fuente de las FAQs de la página y seo.ts no las emite —regla B3: un único emisor por página—.

Dato props emitSchema · openFirst · bare · title · headingId

Variantes

Otros diseños y aplicaciones

La canónica es un acordeón con primer item abierto, pero el FAQ cambia de cara según el largo de la lista y la página padre: todos cerrados (FAQs largas), modo bare (incrustado en columna), o —como extensión— lista plana, agrupado por tema, o con búsqueda inline.

No hay un único modelo: hay una misma idea —dudas resueltas con honestidad— que cada tipo de página ajusta. Páginas de venta cortas usan la canónica (4–6 preguntas, primera abierta); soporte y documentación usan todos cerrados para no sesgar; el home incrusta el FAQ en modo bare junto al formulario; los blogs largos podrían beneficiarse de una lista plana sin acordeón para mejor indexación.

Abajo, seis variantes —tres son configuraciones REALES del componente actual (combinaciones de las props que ya existen: openFirst, bare, title), y tres son extensiones propuestas (lista plana, agrupado por tema, búsqueda inline) que requerirían añadir una prop al componente—. Cada una con su réplica en vivo y el tipo de proyecto donde rinde mejor.

  • Acordeón con primer item abierto (canónica)

    Páginas de venta · Servicios · Home

    La cara natural del componente: cuatro a seis preguntas en <details> nativo, la primera abierta para enganchar al visitante con una respuesta visible. Configuración real: pasar items + title, dejar openFirst=true (default).

  • Acordeón con todos cerrados

    FAQs largas · Soporte · Documentación

    Cuando todas las preguntas pesan igual (soporte técnico, FAQ legal, documentación), abrir la primera puede sesgar el foco. Configuración real: pasar openFirst={false}. El lector decide qué abrir.

  • Embebido en columna (modo bare)

    Sección compuesta · Home (FAQ + formulario)

    Para incrustar la FAQ dentro de una columna —el patrón del home, FAQ a la izquierda y ContactForm a la derecha— bare={true} quita la <section> envolvente con padding/centrado y deja al componente ocupar el ancho de su contenedor. Configuración real: bare + title="".

  • Lista plana sin acordeón (Q+A siempre visibles)

    Blog · Artículos largos · SEO-first

    Extensión propuesta: cuando la FAQ vive dentro de un artículo de blog y conviene que las respuestas se indexen sin esperar a la interacción, una lista plana (Q como H3, A como párrafo) puede rendir mejor —especialmente desde mayo 2026, cuando los rich results de FAQ desaparecieron para la mayoría—. Requiere añadir una prop variant='flat' al componente y un branch del template.

  • Agrupado por tema (sub-acordeones)

    FAQs largas · Categorías · Soporte

    Extensión propuesta: para FAQs con 12–20 preguntas, agruparlas por tema («Pagos», «Envíos», «Garantías») mejora el escaneo. Requiere cambiar la shape de items de FAQItem[] a { topic, items: FAQItem[] }[] y renderizar un encabezado por grupo. No rompe el componente; añade una prop opcional groups que reemplaza a items.

  • Con búsqueda inline (filtra a medida que tecleas)

    FAQs largas · Centros de ayuda · Documentación

    Extensión propuesta: una caja de búsqueda arriba del acordeón filtra los items por substring en pregunta/respuesta a medida que el visitante teclea (cero red, JS mínimo). Útil solo cuando la FAQ supera 10 preguntas —para menos, la búsqueda es overkill—. Requiere añadir una prop search={true} y ~20 líneas de JS inline.

Responsive y móvil

Cómo se comporta en el teléfono

El acordeón ya nace mobile-first: stack a ancho completo, <summary> tappable, chevron rotatorio. Default propuesto: stack. Tres extensiones opcionales: dos columnas en desktop, nav lateral sticky para FAQs largas, y todos abiertos en móvil para ahorrar taps.

En escritorio, el componente centra el bloque a 820px de ancho máximo (.faq__inner) —el ancho de lectura cómoda—. En el teléfono, ese max-width queda dentro del container fluido (–-container-max al 100% en ≤768px), así que cada item ocupa todo el ancho del viewport con su gutter natural. El componente NO necesita media queries propias: la cascada de tokens hace el trabajo, los <summary> ya son tappables (~48px de zona por sus paddings) y el chevron rota 180° al abrir, dando señal visual sin coste.

A partir de ahí, tres patrones opcionales según el tipo de FAQ. Para páginas amplias con FAQs cortas (≤4 preguntas), una rejilla de 2 columnas en desktop reduce la altura del bloque —en móvil vuelve a una columna automáticamente—. Para FAQs largas (15–25 preguntas) agrupadas por tema, un nav lateral sticky con anchors a cada grupo permite navegar sin perder el contexto. Y para FAQs cortas en móvil, abrir TODOS los items por defecto ahorra taps —el visitante ve todo de un vistazo y sigue scrolleando—; se logra con CSS puro sobre el <details>, sin JS.

1 · Stack a ancho completo (default del componente)

Lo que hace el componente sin tocar nada. Cada <details> ocupa el ancho del contenedor; los <summary> son tappables por los paddings del .faq__q (~48px); el chevron rota 180° al abrir. Mobile-first puro: cero breakpoints propios, cero JS. Por debajo, los tokens del sitio (--container-max) ajustan el ancho al viewport.

CSS · stack mobile-first (default del componente)
/* MÓVIL · STACK A ANCHO COMPLETO (default del componente)
   El acordeón ya nace mobile-first: cada item ocupa el ancho
   del contenedor, los <summary> son tappables (no fijamos
   min-height — los pads del .faq__q dan ~48px de zona), y el
   chevron rota 180° al abrir. Cero código adicional. */

.faq__inner {
  max-width: 820px;                       /* lectura cómoda en desktop */
  margin-inline: auto;
  padding-inline: var(--container-px);    /* gutter mobile-first */
}
.faq__item {
  border: 1px solid var(--c-border);
  border-radius: var(--radius-lg);
  overflow: hidden;
}
.faq__q {
  display: flex;
  justify-content: space-between;
  align-items: center;
  gap: var(--sp-3);
  padding: var(--sp-4) var(--sp-5);       /* ~48px clickeables */
  cursor: pointer;
}

2 · Dos columnas en desktop (extensión)

Para FAQs cortas (≤4 preguntas) en páginas amplias, una rejilla de 2 columnas en desktop reduce la altura del bloque y deja más preguntas a la vista. En móvil vuelve a una columna automáticamente. Es una extensión sobre el contenedor del padre —no toca el componente—. Cuidado con preguntas muy largas: el <summary> puede necesitar ellipsis en columnas estrechas.

CSS · grid 2 col en desktop, 1 col en móvil
/* DESKTOP · 2 COLUMNAS · MÓVIL · APILADO (extensión)
   Para FAQs cortas (≤4 preguntas) en páginas amplias, una
   rejilla de 2 columnas reduce la altura del bloque y permite
   ver más preguntas a la vez. En móvil vuelve a una columna
   automáticamente. Es una EXTENSIÓN sobre el contenedor —no
   toca el componente—. Cuidado: el contenido del <summary>
   se trunca con ellipsis si la pregunta es muy larga. */

.faq-grid {
  display: grid;
  grid-template-columns: 1fr;             /* móvil: una columna */
  gap: var(--sp-4);
}

@media (min-width: 1024px) {
  .faq-grid {
    grid-template-columns: repeat(2, 1fr);  /* desktop: dos */
    align-items: start;                     /* respetar alturas */
  }
}

/* Cada FAQAccordion va con bare={true} para que NO meta su
   propia <section> centrada — la columna del grid manda. */

3 · Nav lateral sticky (extensión, para FAQs largas)

Para FAQs largas (15–25 preguntas) agrupadas por tema, un <nav> lateral con position: sticky y anchors a cada grupo permite navegar sin perder el contexto. En móvil el nav se oculta (volvería a estorbar) y el visitante usa el scroll normal. Requiere agregar id por grupo en el markup; NO toca el componente. Pareja natural con la variante «agrupado por tema» de §3b.

CSS · nav lateral sticky en desktop, oculto en móvil
/* DESKTOP · NAV LATERAL STICKY + ACORDEÓN (extensión)
   Para FAQs LARGAS (15–25 preguntas) agrupadas por tema, un
   nav lateral sticky con anchors a cada grupo permite navegar
   sin perder el contexto. En móvil el nav se oculta y vuelve
   al stack default. Requiere agregar id por grupo y un <nav>
   con enlaces internos. NO toca el componente. */

@media (min-width: 1024px) {
  .faq-layout {
    display: grid;
    grid-template-columns: 220px 1fr;
    gap: var(--sp-6);
    align-items: start;
  }
  .faq-nav {
    position: sticky;
    top: calc(var(--header-height) + var(--sp-4));
    align-self: start;
  }
  .faq-nav ul { list-style: none; padding: 0; margin: 0; }
  .faq-nav a {
    display: block;
    padding: var(--sp-2) var(--sp-3);
    font-family: var(--font-heading);
    font-size: var(--text-sm);
    color: var(--c-ink-2);
    border-left: 2px solid transparent;
  }
  .faq-nav a:hover { color: var(--c-primary); border-color: var(--c-primary); }
}

/* Móvil: el nav se oculta; el visitante usa el scroll vertical. */
@media (max-width: 1024px) {
  .faq-nav { display: none; }
}

4 · Todos abiertos en móvil (extensión, ahorra taps)

Para FAQs cortas (≤4 preguntas) en móvil, abrir TODOS los items por defecto ahorra al visitante el ir tapeando uno por uno —ve el bloque entero scrolleando, igual que un blog—. Se logra con CSS puro: forzar .faq__a a display: block en ≤640px y ocultar el chevron para que el <summary> deje de leerse como botón. Cero JS, sin tocar el atributo open (que rompería la accesibilidad del teclado).

CSS · forzar todos abiertos en móvil sin tocar open
/* MÓVIL · TODOS ABIERTOS POR DEFECTO (extensión)
   En FAQs cortas (≤4) en móvil, abrir todos los items por
   defecto ahorra taps —el visitante ve todo de un vistazo y
   sigue scrolleando—. En escritorio se mantiene el acordeón
   normal. Requiere una variante CSS o agregar la prop
   openAllOnMobile al componente. La siguiente receta usa CSS
   puro sobre el <details>: cero JS. */

@media (max-width: 640px) {
  /* Forzar <details> abiertos en móvil sin tocar el atributo
     open (que rompería la accesibilidad del teclado). En su
     lugar, mostramos siempre el contenido y ocultamos el
     chevron para que la pregunta deje de leerse como botón. */
  .faq__a {
    display: block !important;            /* respuesta siempre visible */
    border-top: 1px solid var(--c-border);
  }
  .faq__chevron { display: none; }        /* sin señal de "tappable" */
  .faq__q { cursor: default; }
  .faq__q:hover { background: var(--color-gray-50); }
}

/* En escritorio, todo vuelve al acordeón normal por defecto. */

Posición

¿Dónde se coloca?

Casi siempre AL CIERRE de la página, antes del CTA banner y del footer. Resuelve dudas justo cuando el visitante está por decidir. Hoy se usa en la home (FAQ + formulario, modo bare), en /productos/index, y dentro de los layouts ServiceLayout / ProductLayout para servicios y productos individuales.

A diferencia del Hero (uno por página al inicio) o el ProductCard (N por página dentro de una rejilla), el FAQ aparece UNA vez por página y al final del scroll —después de que el visitante consumió el contenido, antes del CTA banner o del footer—. Esa posición no es casual: la FAQ recoge las dudas que sobrevivieron a la lectura. Si la pusieras arriba, robaría atención al hero; abajo, atrapa al visitante justo cuando está por irse o por decidirse.

Lo que NO hace la FAQ, por diseño: no es para anuncios, no es para precios, no es para fichas de producto. Si la «respuesta» es un párrafo largo, mejor un artículo de blog enlazado desde la respuesta; si es una tabla de comparativas, mejor un grid en otra sección. La FAQ es para dudas CONVERSACIONALES cortas (2–4 líneas máximo). Para casos compuestos —FAQ a la izquierda + formulario a la derecha, como el home—, el modo <code>bare</code> permite incrustar el acordeón dentro de una columna sin que el componente meta su propia <code>&lt;section&gt;</code> centrada.

Módulos cercanos al cierre: reseñas (la prueba social que viene antes), CTA banner (lo que sigue), formulario de contacto (el patrón FAQ + form) y footer (donde aterriza el visitante después).

Implementación

Cómo está construido

Un único componente con API mínima: items[] (obligatorio), title?, headingId?, emitSchema?, openFirst?, bare?. Devuelve una <section aria-labelledby> con N <details>/<summary> dentro y, opcionalmente, un <script type="application/ld+json"> con el FAQPage.

El componente vive en FAQAccordion.astro y expone seis props a propósito: items[]: {question, answer} (obligatorio; answer admite HTML vía set:html), title (default «Preguntas frecuentes»), headingId (default «faq-heading», usado por aria-labelledby), emitSchema (default false — el schema vive en seo.ts), openFirst (default true — primer item abierto), bare (default false — sin section/padding/centrado, para incrustar). Cero JavaScript de gestión de estado: el acordeón usa <details>/<summary> nativos del navegador; el chevron es un SVG inline que rota con una transition de CSS.

El schema FAQPage NO se emite por defecto. El patrón canónico centraliza el JSON-LD en lib/seo.ts → faqSchema(items) → buildSchema(data.faqs), que el layout serializa en el grafo de la página. Eso evita duplicar el FAQPage si el componente y el layout lo emiten ambos (regla B3 — un único emisor por página). emitSchema=true solo se activa cuando esta página NO usa buildSchema con data.faqs Y necesitas el FAQPage embebido aquí mismo. CAVEAT mayo 2026: Google retiró el rich result de FAQ para casi todos los sitios; emitir el schema sigue siendo correcto (Bing/DuckDuckGo lo aprovechan, Google lo usa para entender la página), pero no aparecerán las preguntas en la SERP salvo en autoridades gov/health.

Astro · uso básico (hard-coded, emitSchema apagado)
---
// USO BÁSICO · una sola FAQ hardcodeada, fuera de cualquier
// layout schema-driven. emitSchema queda en false (default):
// como esta página NO usa buildSchema con data.faqs, el JSON-LD
// no se va a emitir desde seo.ts; si quisieras el FAQPage embebido
// aquí mismo, pondrías emitSchema={true}.
import FAQAccordion from '@components/FAQAccordion.astro'

const faqs = [
  {
    question: '¿Cuánto cuesta?',
    answer: 'Depende del alcance; cotizamos en <strong>24 h</strong> por WhatsApp.',
  },
  {
    question: '¿Cuánto tarda?',
    answer: 'Entre 7 y 14 días hábiles desde la aprobación del contenido.',
  },
  {
    question: '¿Puedo editar yo después?',
    answer: 'Sí, editas <em>contenido</em> en Markdown sin tocar código.',
  },
]
---

<section class="section section--surface">
  <div class="container">
    <FAQAccordion items={faqs} title="Preguntas frecuentes" openFirst />
  </div>
</section>
Astro · data-driven desde el frontmatter de un servicio
---
// DATA-DRIVEN DESDE EL FRONTMATTER DE UN SERVICIO · cómo lo hace
// ya hoy /servicios/[...slug].astro y /cobertura/[...slug].astro.
// La fuente única es la colección `servicios` (Markdown), validada
// por Zod. Cada servicio puede traer su lista `faqs: { question, answer }[]`;
// si la trae, se pinta el acordeón Y se pasa a buildSchema para
// que se emita FAQPage en el JSON-LD del layout (regla B3).
import { getEntry } from 'astro:content'
import ServiceLayout from '@layouts/ServiceLayout.astro'
import FAQAccordion from '@components/FAQAccordion.astro'

const servicio = await getEntry('servicios', Astro.params.slug as string)
if (!servicio) return Astro.redirect('/404')

// El frontmatter del servicio trae directamente la shape canónica
// {question, answer} (alineada con schema.org y con FAQAccordion).
const items = servicio.data.faqs ?? []
---

<ServiceLayout {...servicio.data}>
  {/* Sección VI del ServiceLayout (origen real del componente) */}
  {items.length > 0 && (
    <section id="preguntas" class="svc-section">
      <h2>Preguntas frecuentes</h2>
      <FAQAccordion items={items} />
    </section>
  )}
</ServiceLayout>

{/* ServiceLayout pasa faqs a buildSchema('service', { faqs }) en su
    propio módulo — emite FAQPage UNA sola vez en el grafo de la página.
    Por eso este FAQAccordion lleva emitSchema=false (default).  */}
Astro · integración con schema FAQPage (lib/seo.ts → buildSchema)
---
// INTEGRACIÓN CON SCHEMA FAQPage (JSON-LD) · DÓNDE VIVE EL EMISOR.
// Regla dura B3: UN único emisor por página. Quien gestiona el JSON-LD
// es lib/seo.ts → faqSchema(items) → buildSchema('page', { faqs }),
// no el componente. BaseLayout/PageLayout serializan el grafo con <JsonLd>.
//
// CAVEAT MAYO 2026: Google retiró los rich results de FAQ para la mayoría
// de los sitios (solo autoridad gov/health los conservan en SERP). Emitir
// el schema sigue siendo correcto —Google lo usa para entender la página—
// pero NO aparecerán las preguntas en los resultados. Documentado en
// docs/VALIDACION-BUENAS-PRACTICAS.md (P1).
import PageLayout from '@layouts/PageLayout.astro'
import FAQAccordion from '@components/FAQAccordion.astro'
import { faqSchema } from '@lib/seo'

const faqs = [
  { question: '¿Cuánto cuesta?',  answer: 'Cotización en 24 h por WhatsApp.' },
  { question: '¿Cuánto tarda?',   answer: '7–14 días hábiles desde la aprobación.' },
  { question: '¿Puedo editar yo?', answer: 'Sí, en Markdown, sin tocar código.' },
]

// Opción A · DEJAR QUE PageLayout lo emita vía buildSchema:
// El layout recibe `faqs` en su prop `data` y emite FAQPage en el grafo.
// El componente NO emite nada (emitSchema=false por default).
---

<PageLayout
  title="Plantilla Astro — FAQ"
  description="Preguntas frecuentes del producto."
  pageType="page"
  data={{ faqs }}
>
  <FAQAccordion items={faqs} title="Preguntas frecuentes" />
</PageLayout>

{/* Opción B · EMITIRLO DESDE EL COMPONENTE (solo si el layout no lo hace):
    <FAQAccordion items={faqs} title="…" emitSchema />
    NUNCA las dos opciones a la vez: se duplicaría FAQPage en la página y
    Google ignoraría el rich result (regla B3).  */}

{/* Opción C (debug) · ARMAR EL NODO A MANO para inspeccionarlo:
    import { faqSchema } from '@lib/seo'
    const node = faqSchema(faqs)
    // node = { '@type': 'FAQPage', mainEntity: [...] }
    // El layout lo envolverá con @context y lo pondrá en el grafo.  */}

En concreto: el componente recibe las props y emite un <section class="faq" aria-labelledby={headingId}> con un <h2> (si hay title), una <div class="faq__list"> contenedora y, dentro, un <details class="faq__item"> por cada item. Cada <details> arranca con un <summary class="faq__q"> que contiene la pregunta y un <svg class="faq__chevron"> que rota 180° cuando el [open] está activo, y una <div class="faq__a"> con la respuesta renderizada con set:html. Si emitSchema=true, al cierre de la sección se inyecta un <script type="application/ld+json"> con el FAQPage; si no, no se emite nada.

La accesibilidad es de fábrica: la <section> lleva aria-labelledby apuntando al headingId; el chevron es aria-hidden; el foco visible y la navegación por teclado los gestiona el <details> nativo. La única transición —la rotación del chevron— se desactiva bajo prefers-reduced-motion: reduce. Los tokens del proyecto (--c-primary, --radius-lg, --sp-4) son la única fuente de estilo: cambiar el design system cambia todos los FAQ del sitio. El schema vive en lib/seo.ts y se emite desde el layout vía buildSchema('page', { faqs }) — el componente NO lo emite por default. Un único emisor por página (regla B3), cero schema duplicado, y el caveat de mayo 2026 documentado en el comentario del componente para que nadie active emitSchema esperando rich results que ya no llegan.

Buenas prácticas

Qué hacer y qué evitar

La diferencia entre una FAQ que convierte y una que decora cabe en un puñado de hábitos —empezando por usar preguntas REALES (no inventadas) y por entender que el schema vive en lib/seo.ts, no en el componente—.

Ninguno de estos hábitos es capricho: salen del diseño actual del componente y del esquema de schema del sitio. La FAQ es presentación + schema opcional: el JSON-LD vive en lib/seo.ts y se emite desde el layout, no desde el componente. Pasar las preguntas por una collection (Markdown + Zod) y dejar el componente solo con el render mantiene la separación de responsabilidades y evita la duplicación.

La buena noticia es que casi todo se sostiene solo cuando se respeta el patrón. Las preguntas vienen del frontmatter del servicio o producto (validadas por Zod), el schema lo gestiona seo.ts, y el componente cumple un solo papel: pintar el acordeón. El detalle a vigilar manualmente: NO emitir el schema dos veces y NO confundir el caveat de mayo 2026 con «el schema ya no sirve» —sigue sirviendo para entender la página, solo dejó de mostrarse en la SERP de Google—.

Sí conviene

  • Usa preguntas REALES que llegan por WhatsApp o por correo: las dudas inventadas se notan a kilómetros y restan credibilidad. Si no tienes 6 preguntas reales, mejor 3 buenas que 8 forzadas.
  • Empieza cada pregunta con el mismo verbo conjugado de tu cliente —«¿cuánto cuesta…?», «¿cuánto tarda…?», «¿puedo…?»— y deja la respuesta CORTA (2–4 líneas máximo): el acordeón es para escanear, no para leer un ensayo.
  • Mantén la prop emitSchema=false (default) cuando la página padre ya pasa data.faqs a buildSchema(): el JSON-LD lo gestiona lib/seo.ts. Activa emitSchema={true} SOLO si esta página NO usa buildSchema con data.faqs y necesitas el FAQPage embebido aquí mismo.
  • Usa la prop bare cuando incrustes el FAQ DENTRO de una columna o de una sección que ya tiene su título y padding propios (patrón del home: FAQ a la izquierda + ContactForm a la derecha). Sin bare, el componente añade su propia <section> con padding y centrado.
  • Mantén openFirst=true (default) cuando la FAQ resuelve UNA duda crítica en la primera pregunta (precio, tiempos): el visitante ve la respuesta sin tener que clicar y queda enganchado al resto. Pásalo a false cuando todas las preguntas pesen igual y prefieras dejar la elección al lector.
  • Cuando uses la prop title, mantenla en sentence case («Preguntas frecuentes», no «PREGUNTAS FRECUENTES») y respeta el headingId default («faq-heading») salvo que haya OTRO H2 con el mismo id en la página —el aria-labelledby vincula la sección al heading—.

Mejor evita

  • NO inventes preguntas para llenar el bloque: una FAQ con 8 preguntas falsas dañará la confianza más que una con 3 reales. Si no tienes el material, posterga la sección antes de fabricarla.
  • NO actives emitSchema={true} si la página padre ya pasa data.faqs a buildSchema() — emitirás DOS FAQPage en la misma URL y Google ignorará el rich result (regla B3 — un único emisor por página).
  • NO esperes rich results de FAQ desde mayo de 2026: Google retiró el FAQPage rich result para casi todos los sitios (solo gov/health autoritarios lo conservan). Emitir el schema sigue siendo correcto —ayuda a entender la página— pero no aparecerán las preguntas en la SERP.
  • NO uses la FAQ como tabla de precios o catálogo: si la «respuesta» es una lista de 12 productos con sus precios, eso es un grid o una tabla, no un acordeón. La FAQ es para dudas conversacionales cortas.
  • NO escondas información crítica DETRÁS del acordeón (precio mínimo, requisitos legales, condiciones de garantía): el visitante puede no abrirlo y perder la señal. Lo crítico va arriba; la FAQ resuelve lo que matiza.
  • NO sobrecargues el answer con HTML complejo: aunque admite set:html, párrafos con enlaces y listas son lo natural. Tablas anidadas, embeds o iframes rompen el ritmo del acordeón y aumentan el CLS.

En vivo

El componente, en su uso real

Las galerías de variantes son mockups a escala; este bloque NO. Aquí se renderiza un FAQAccordion REAL con preguntas reales y emitSchema=false, idéntico al que ya pintan home, /productos y los layouts ServiceLayout/ProductLayout.

La regla del sitio es estricta —el schema vive en seo.ts, no en el componente—. Este bloque cierra la página con el componente real, no con una réplica anotada. Si el componente se rompe, este bloque se rompe a la vista; documentación que se documenta a sí misma. La página padre (esta misma) NO pasa data.faqs a buildSchema, así que aquí emitSchema queda en false: cero schema duplicado, cero FAQPage en una guía técnica que ya tiene su propio WebSite + BreadcrumbList.

Las cuatro preguntas son las que reciben los proyectos del estudio por WhatsApp: precio, tiempos, autonomía de edición y soporte post-lanzamiento. Notar el HTML dentro de las respuestas (<strong>, <em>) renderizado vía set:html, el chevron que rota al abrir, y el primer item abierto por default —openFirst=true—.

Preguntas frecuentes (en vivo)

¿Cuánto cuesta el desarrollo del sitio?

Depende del alcance, pero como referencia: una plantilla con cinco a diez páginas, formulario a WhatsApp y SEO técnico arranca desde el rango de medios. Te enviamos cotización por WhatsApp en menos de 24 horas con el alcance puesto por escrito.

¿Cuánto tarda en estar publicado?

Una vez aprobado el contenido (textos, fotos y datos de contacto), el sitio queda publicado entre 7 y 14 días hábiles. La parte más larga suele ser la curación de contenido, no la programación.

¿Puedo editarlo yo después?

Sí. El sistema está pensado para que edites contenido en Markdown (textos, fotos, datos), sin tocar código. Te entregamos una guía corta y, si quieres, una sesión de acompañamiento de 30 minutos para hacerlo juntos.

¿Qué pasa si necesito cambios después del lanzamiento?

Incluimos 30 días de soporte sin costo después del lanzamiento para ajustes finos. Para cambios mayores hay paquetes mensuales de horas o cotización puntual; lo platicamos por WhatsApp y te mandamos opciones.

¿Necesitas ayuda?