Módulo del sitio · Reseñas

Las Reseñas: la prueba social que convierte

El módulo que recoge la voz del cliente al cierre de la página: una rejilla de tarjetas con estrellas, cita y autor (nombre + rol + iniciales) que responde la pregunta que el visitante no hace en voz alta —«¿le funcionó a alguien como yo?»— con la voz de un tercero, que pesa más que la tuya.

Esta página no es una ficha técnica: es el módulo entero, abierto y explicado. Qué problema resuelve y por qué la reseña vive justo antes del cierre y no al inicio, de qué piezas se compone (estrellas + cita + autor), cómo se comporta en el teléfono, dónde encaja en una página de venta (y dónde NO, porque inventar testimonios cuesta más caro que no ponerlos) y, al final, cómo está construida por dentro —del criterio de redacción al JSON-LD que sirve al SEO—.

Con tres particularidades que separan a la reseña del resto de los módulos. Primera: es card TEXTO-CÉNTRICA (cita + autor + estrellas), no card de catálogo (foto + badge + CTA); las dos vidas conviven en el sitio porque resuelven trabajos distintos. Segunda: el avatar usa INICIALES (no foto) a propósito —cero peso de imagen, uniforme en todo el grid, profesional incluso con nombres compuestos—. Tercera: el componente NO emite JSON-LD por sí solo, ni siquiera con prop; el schema vive en lib/seo.ts (reviewSchema/emitReviews) y se gatea con SITE.allowSelfReviews porque Google penaliza self-serving reviews y el patrón canónico solo permite emitirlas con reseñas reales verificables.

Definición

¿Qué es el módulo de reseñas?

El componente que pinta una tarjeta de testimonio en una rejilla de prueba social: estrellas + cita + autor (avatar con iniciales, nombre y rol). HTML semántico (<article> + <blockquote> + <cite>), avatar generado desde las iniciales del nombre, y schema Review opcional —centralizado en lib/seo.ts, gateado por SITE.allowSelfReviews—.

Las reseñas (ReviewCard.astro) son el módulo que materializa la prueba social en el sitio: una tarjeta uniforme por opinión, dispuesta en una rejilla de 1 → 2 → 3 cards por fila. Cada tarjeta lleva tres piezas con jerarquía: las estrellas arriba (señal rápida de confianza), la cita en el centro (texto que convence) y el autor abajo (avatar con iniciales + nombre + rol). El HTML es semántico —<article> para la card, <blockquote> para la cita, <cite> para el nombre— y la accesibilidad arranca de fábrica (role="img" y aria-label con la calificación numérica).

En este proyecto vive en un único componente con una API mínima (quote, name, role?, rating?). Hoy el consumidor real es la home (src/pages/index.astro), que renderiza cuatro testimonios después del catálogo y antes del FAQ. La decisión de mostrar SIEMPRE iniciales en vez de foto no es estética: es operativa —pedir foto a cada cliente arrastra fricción y rompe la coherencia visual cuando algunos suben selfies y otros logos—. El JSON-LD lo gestiona lib/seo.ts → reviewSchema({items, aggregate?}) (helper puro, espejo de faqSchema) o emitReviews() (helper interno gateado por SITE.allowSelfReviews dentro de productSchema/serviceSchema), NUNCA el componente.

Función e importancia

¿Para qué sirve?

Hace tres trabajos a la vez: convence en el momento exacto de la duda con la voz de un tercero, mantiene un grid uniforme con cero peso de imagen (avatar con iniciales), y abre la puerta al schema Review/AggregateRating —siempre que las reseñas sean reales y verificables (regla B4)—.

Su función primaria es comercial: la reseña no decora, convence. Llega justo después del catálogo o de los servicios, cuando el visitante ya entendió la oferta pero antes de pedir cotización; ese es el momento de máxima resistencia. La cita CONCRETA («el sitio quedó listo en 9 días y no he tenido que tocar el código una sola vez») vale más que diez genéricas («excelente servicio, muy recomendado»). Los detalles dan credibilidad y demuestran que la reseña no fue redactada por la marca.

Su función secundaria es operativa: el avatar con iniciales mantiene la coherencia visual del grid sin pedirle foto a cada cliente. Las iniciales se generan solas desde la prop name (primera letra de los dos primeros tokens) sobre un degradé de marca —cero peso de imagen, mismo grid uniforme, profesional incluso con nombres compuestos largos—. Y la terciaria es semántica: cuando las reseñas son reales y verificables (Google Business Profile, Trustpilot), reviewSchema() o emitReviews() emiten aggregateRating + review[] al grafo JSON-LD, una señal estructurada barata. Caveat: Google solo pinta estrellas en la SERP para algunos tipos schema (Product/Recipe/Movie/Book); para LocalBusiness/Service el schema sigue siendo válido y útil para entender la entidad, pero no esperes el efecto visual.

Prueba social en el momento exacto de la duda

La reseña no decora: convence. Cuando el visitante está por irse, la voz de un tercero responde la pregunta que NO hace en voz alta —«¿le funcionó a alguien como yo?»—. Llega justo después del catálogo o de los servicios, cuando ya entendió la oferta pero antes de pedir cotización: ese es el momento de máxima resistencia y donde pesa más una cita concreta.

Avatar con iniciales: cero peso de imagen, mismo grid uniforme

La decisión de no usar fotos no es estética: es operativa. Pedirle una foto a cada cliente arrastra fricción (permiso, calidad, tamaño, recorte) y rompe la coherencia visual cuando algunos suben selfies y otros logos. Las iniciales sobre un degradé de marca se generan solas desde el nombre, pesan 0 KB, y dejan al ojo enfocado en lo que importa: la cita y el rating.

Schema Review opcional, gateado por SITE.allowSelfReviews (regla B4)

El esquema vive en lib/seo.ts → reviewSchema({items, aggregate?}), un helper PURO espejo de faqSchema(). El ReviewCard NO emite JSON-LD por sí solo —ni con prop—. La página padre decide si componer el nodo a mano o, dentro de productSchema/serviceSchema, dejar que emitReviews() lo emita con el gate global de SITE.allowSelfReviews. Google penaliza reseñas auto-emitidas (self-serving); el patrón canónico permite emitirlas solo si vienen de terceros verificables.

Anatomía

¿Qué lleva una reseña?

Tres piezas dentro de un <article class='rcard'>: las estrellas arriba (rating como prop, role='img' con aria-label numérico), la cita en el centro (<blockquote> con flex-grow:1 para que todas las cards queden a la misma altura) y el autor abajo (avatar con iniciales generadas desde el nombre, <cite> y rol opcional).

Cada pieza cumple un papel claro. Las estrellas abren con un <div role="img" aria-label="N de 5 estrellas">: el ojo capta el rating antes que cualquier texto —señal rápida de confianza—. Cinco SVG inline, cada uno con clase .is-on (amarillo cálido) o .is-off (color del borde); no hay estrellas medio-rellenadas porque Math.round() en el componente redondea cualquier decimal al entero más cercano. La cita entra debajo con quotes tipográficas («…») generadas por CSS y, sobre todo, flex-grow:1 sobre el quote: ese detalle es el que mantiene todas las cards del grid a la misma altura aunque los textos varíen.

El cierre del card es el autor —el componente lo separa con border-top sutil y lo ancla al fondo con margin-top:auto—. Tres piezas: el avatar con las INICIALES (un círculo de 46×46px con degradé de --c-primary, las dos primeras letras del nombre en mayúsculas, fuente de heading); el nombre en una <cite class="rcard__name"> con font-style:normal (el itálico de cite estorba a la lectura); y el rol opcional debajo en text-sm + --c-muted. 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

Estrellas — la señal rápida de confianza

Es lo primero que ve el ojo. Cinco estrellas SVG inline en una fila <div class="rcard__stars"> con role="img" y aria-label que dicta «N de 5 estrellas» a los lectores de pantalla. El valor llega por la prop rating (1–5, default 5) y se normaliza con Math.max(0, Math.min(5, Math.round(rating))). Cada estrella tiene clase .is-on (color #f5a623, amarillo cálido) o .is-off (color del borde), sin pintarse como rating «medio» —no usa medio-rellenadas porque añaden ruido visual sin ganancia—.

Dato props rating → <div class="rcard__stars" role="img" aria-label>

2

Cita — el texto que convence

El cuerpo. Es la pieza que más pesa para la decisión: una frase CONCRETA («el sitio quedó listo en 9 días y no he tenido que tocar el código una sola vez») vale más que diez genéricas («excelente servicio, muy recomendado»). Vive en un <blockquote class="rcard__quote"> con quotes: \"\u201C\" \"\u201D\" y los pseudo-elementos ::before / ::after pintando las comillas tipográficas. flex-grow:1 sobre el quote empuja al autor al fondo, así todas las cards del grid quedan con la misma altura aunque los textos varíen.

Dato props quote → <blockquote class="rcard__quote">

3

Autor — el avatar con iniciales + nombre + rol

El cierre. Un <footer class="rcard__author"> con tres piezas: el avatar con las INICIALES del nombre (primera letra de los dos primeros tokens, en mayúsculas, sobre un degradé de --c-primary), el nombre en una <cite class="rcard__name"> (semántica de cita; font-style:normal porque el itálico estorba a la lectura) y el rol opcional debajo. Iniciales en vez de foto: cero peso de imagen, uniforme en todas las cards y profesional —no requiere pedir foto al cliente—.

Dato props name + role → <footer><span class="rcard__avatar">·<cite>·<span class="rcard__role">

Variantes

Otros diseños y aplicaciones

La canónica es la card con 5 estrellas y avatar de iniciales, pero la reseña cambia de cara según el tipo de cliente y la marca de la fuente: rating cero (testimonial cualitativo B2B), avatar con foto (cliente con identidad pública), avatar con iniciales (default), enlace a la reseña externa original (Google Business / Trustpilot) y variante oscura sobre superficie --c-ink.

No hay un único modelo: hay una misma idea —voz del cliente que convence— que cada tipo de página ajusta. Servicios B2C usan la canónica (cita + rating + iniciales); testimonios B2B grandes a veces piden rating={0} (el cliente «no califica proveedores»); clientes con identidad pública (marcas, creadores con foto profesional) merecen avatar con imagen; cuando hay reseñas verificables en Google Business Profile, conviene enlazar a la fuente para que Google entienda que son reales y no inventadas.

Abajo, seis variantes —dos son configuraciones REALES del componente actual (combinaciones de las props que ya existen: quote, name, role, rating con default 5), y cuatro son EXTENSIONES propuestas (rating oculto con showRating={false}, avatar con imagen, enlace a fuente externa, variante oscura) que requerirían añadir props al componente—. Cada una con su réplica en vivo y el tipo de proyecto donde rinde mejor.

  • Card canónica con 5 estrellas

    Servicios · Home · Productos

    La cara natural del componente: rating={5} (default), avatar con iniciales (también default), nombre + rol debajo del separador. Configuración REAL: pasar quote + name + role; rating queda en 5 sin tocar nada. La que aparece en el home actual de ejemplos.mx y la base de cualquier sección de prueba social.

  • Sin rating visible (rating={0})

    Testimonios B2B · Casos de éxito · Clientes corporativos

    Para testimonios cualitativos donde la calificación numérica no aplica (clientes B2B grandes que «no califican proveedores»), pasar rating={0} pinta 5 estrellas vacías. Hoy esto se ve como «cero estrellas» (mala señal). Para ocultar el bloque de estrellas hace falta una EXTENSIÓN: añadir prop showRating={false} al componente y un branch que omita el div .rcard__stars.

  • Avatar con imagen real del cliente

    Clientes públicos · Marcas con identidad visual

    EXTENSIÓN: hoy el avatar siempre se genera con iniciales (sin prop image). Para cards con foto de cliente —útil cuando el reseñador es una marca pública con identidad visual reconocible (otra empresa, creador con foto profesional)— hay que añadir prop avatar?: string al componente y un branch que renderice <img loading="lazy"> en vez de las iniciales sobre el degradé.

  • Avatar con iniciales (fallback, default)

    Default · Servicios · Cualquier sección de prueba social

    Configuración REAL y default. Las iniciales se generan solas: name.split(/\s+/).slice(0,2).map(w => w.charAt(0)).join("").toUpperCase(). El círculo lleva un degradé de marca (--c-primary → --c-primary-dark) y la fuente del heading. Cero peso de imagen, uniforme en todo el grid, y se ve profesional incluso con nombres compuestos largos.

  • Con enlace a fuente externa (Google Business / Trustpilot)

    Reseñas verificables · Reglas estrictas de B4 · Auditoría

    EXTENSIÓN: hoy el ReviewCard no acepta sourceUrl. Para enlazar a la reseña ORIGINAL (Google Business Profile, Trustpilot, perfil de LinkedIn) hace falta añadir props sourceUrl?: string + sourceLabel?: string (default «Ver en Google») y un <a class="rcard__source" target="_blank" rel="noopener nofollow ugc"> al footer. Es la mejor evidencia para Google de que las reseñas son reales y no inventadas.

  • Variante oscura sobre superficie

    Secciones --c-ink · Heros con foto · CTAs banner oscuros

    EXTENSIÓN: hoy el ReviewCard usa background var(--c-white) fijo. Para una sección con fondo oscuro (--c-ink), hace falta añadir prop tone?: \"light\" | \"dark\" y un branch CSS que invierta los colores (background, border, color del quote, color del role). El avatar puede mantener el mismo degradé porque ya tiene contraste suficiente sobre fondos oscuros.

Responsive y móvil

Cómo se comporta en el teléfono

El ReviewCard ya nace mobile-first: una columna a ancho completo en móvil (flex-column, gap:--sp-4, padding:--sp-6, alturas iguales por flex-grow). Default propuesto: stack 1 → 2 → 3. Tres extensiones opcionales: carrusel horizontal con snap, acordeón para reseñas largas, y stack + CTA al cierre con safe-area iOS.

En escritorio, el grid padre coloca 3 cards por fila (≥1024px) o 2 (≥640px); en móvil cae a una sola columna. El componente NO necesita media queries propias: flex-direction:column, height:100% y flex-grow:1 en el quote dejan que todas las cards del grid queden a la misma altura, sin importar la longitud de cada cita. Y los paddings (--sp-6) más el gap (--sp-4) dan zonas táctiles cómodas: el card entero se puede tocar (si tuviera href, aunque el ReviewCard actual no es clickable —no es card de catálogo—).

A partir de ahí, tres patrones opcionales según el volumen y la longitud. Para 6+ reseñas, un carrusel horizontal con scroll-snap-x convierte el muro vertical en dos swipes laterales (patrón estándar de e-commerce y stories). Para reseñas LARGAS (5–8 líneas), un acordeón con line-clamp en móvil reduce el scroll inicial y deja al visitante decidir cuánto profundizar; cero JS, solo CSS sobre <details>/<summary> envolvente. Y para secciones que terminan con acción, un CTA full-width al cierre que respeta env(safe-area-inset-bottom) iOS y el ancho 100% mobile.

1 · Stack a ancho completo (default del grid padre)

Lo que hace el grid sin tocar nada. En móvil, grid-template-columns: 1fr; cada card ocupa el ancho del contenedor y deja respiración con gap: var(--sp-4). Por dentro, el componente ya es flex-direction: column con flex-grow:1 sobre la cita y margin-top:auto sobre el autor —todas las cards quedan a la misma altura aunque las citas varíen—. Mobile-first puro: cero breakpoints propios, cero JS.

CSS · stack mobile-first (default del grid padre)
/* MÓVIL · STACK A ANCHO COMPLETO (default del grid padre)
   El grid de reseñas que envuelve a ReviewCard es mobile-first:
   nace en 1 columna y crece a 2 (tablet) y 3 (desktop). El card
   ya es flex-column con height:100%, así que en una sola columna
   ocupa el ancho del contenedor y deja la cita y el autor bien
   espaciados. Cero código adicional. */

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

@media (min-width: 640px)  {
  .reviews-grid { grid-template-columns: repeat(2, 1fr); }
}
@media (min-width: 1024px) {
  .reviews-grid { grid-template-columns: repeat(3, 1fr); }
}

/* El ReviewCard ya nace mobile-friendly:
   .rcard {
     display: flex; flex-direction: column; height: 100%;
     gap: var(--sp-4); padding: var(--sp-6);
   }
   flex-grow:1 en .rcard__quote empuja al autor al fondo
   → todas las cards del grid quedan a la misma altura. */

2 · Carrusel horizontal con snap (extensión, para 6+ reseñas)

Cuando hay 6+ reseñas, apilar 8 cards a una columna se siente eterno. Un carrusel con overflow-x:auto + scroll-snap-type: x mandatory permite revisar todo con dos swipes laterales. La siguiente card asoma a la derecha (hint visual). En desktop vuelve al grid normal automáticamente. Requiere wrapping div con scrollbar-width: none para ocultar la barra y mantener funcional la rueda/trackpad. NO toca el componente.

CSS · carrusel horizontal con snap en móvil, grid en desktop
/* MÓVIL · CARRUSEL HORIZONTAL CON SNAP (extensión)
   Cuando hay 6+ reseñas, apilar 8 cards a una columna se siente
   eterno. Un carrusel horizontal con snap-x permite revisar todo
   con dos swipes laterales —patrón estándar de e-commerce y de
   las stories—. En desktop vuelve al grid normal automáticamente.
   Requiere wrapping div con overflow-x:auto y scroll-snap-type. */

@media (max-width: 1023px) {
  .reviews-scroll {
    display: flex;
    gap: var(--sp-4);
    overflow-x: auto;
    scroll-snap-type: x mandatory;
    scroll-padding-inline: var(--container-px);
    padding-inline: var(--container-px);
    margin-inline: calc(var(--container-px) * -1);  /* romper container */
    /* Ocultar scrollbar manteniendo funcional la rueda/trackpad. */
    scrollbar-width: none;
  }
  .reviews-scroll::-webkit-scrollbar { display: none; }

  .reviews-scroll > .rcard {
    flex: 0 0 calc(100% - var(--sp-6));   /* casi todo el ancho */
    scroll-snap-align: start;
    /* Hint visual: la siguiente card asoma un poquito a la derecha,
       así el usuario sabe que hay más para deslizar. */
  }
}

/* En desktop, el grid normal. */
@media (min-width: 1024px) {
  .reviews-scroll {
    display: grid;
    grid-template-columns: repeat(3, 1fr);
    gap: var(--sp-4);
  }
}

3 · Acordeón para reseñas largas (extensión, ahorra scroll)

Para testimonios largos (5–8 líneas) en móvil, un acordeón con -webkit-line-clamp: 3 recorta la cita a 3 líneas y un «leer más» despliega el resto. Reduce el scroll inicial y deja al visitante decidir cuánto profundizar. Cero JS si se envuelve la card en un <details> con resumen oculto y se usa CSS para alternar el line-clamp. Cuidado SEO: Google indexa la cita COMPLETA porque el DOM la tiene; solo se oculta visualmente.

CSS · line-clamp + leer más sin tocar JS
/* MÓVIL · ACORDEÓN PARA RESEÑAS LARGAS (extensión)
   Si las reseñas son testimonios LARGOS (5–8 líneas), el grid
   apilado en móvil se vuelve un muro. Un acordeón con la cita
   recortada y un «leer más» que despliega el resto reduce el
   scroll inicial y deja al visitante decidir cuánto profundizar.
   Requiere wrap con <details>/<summary> + line-clamp en el
   estado cerrado. Cero JS. Cuidado con el SEO de la cita
   recortada (Google la indexa completa porque el DOM la tiene). */

@media (max-width: 640px) {
  .rcard--collapsible .rcard__quote {
    display: -webkit-box;
    -webkit-line-clamp: 3;             /* 3 líneas visibles */
    -webkit-box-orient: vertical;
    overflow: hidden;
    transition: max-height .25s ease;
  }
  .rcard--collapsible[open] .rcard__quote {
    -webkit-line-clamp: unset;
    overflow: visible;
  }
  .rcard__more {
    display: inline-flex;
    margin-top: var(--sp-2);
    font-size: var(--text-sm);
    color: var(--c-primary);
    cursor: pointer;
  }
  .rcard--collapsible[open] .rcard__more::after { content: ' menos'; }
  .rcard--collapsible:not([open]) .rcard__more::after { content: ' más'; }
}

/* En desktop, la cita se muestra completa sin acordeón. */

4 · Stack + CTA al cierre (default + acción del pulgar)

El patrón más común: las cards apiladas terminan con un botón full-width que convierte la sección en un cierre con acción («Cotizar mi proyecto» o «Ver más reseñas en Google»). El CTA respeta env(safe-area-inset-bottom) para no quedar bajo el gesture bar de iOS, y ocupa width: 100% con min-height: 50px (regla mobile.css). Combinado con el carrusel del patrón 2, ofrece dos modos en la misma sección: deslizar para explorar, tocar para actuar.

CSS · stack mobile + CTA full-width con safe-area iOS
/* MÓVIL · STACK + CTA AL CIERRE (default + acción del pulgar)
   El patrón más común: las cards apiladas a una columna terminan
   con un CTA full-width que convierte la sección en un cierre con
   acción —«Ver más reseñas en Google» o «Cotizar mi proyecto»—.
   El CTA respeta env(safe-area-inset-bottom) para no quedar bajo
   el gesture bar de iOS, y ocupa 100% en móvil (regla mobile.css). */

@media (max-width: 768px) {
  .reviews-grid {
    grid-template-columns: 1fr;
    gap: var(--sp-4);
  }
}

.reviews-cta {
  margin-top: var(--sp-6);
  display: flex;
  justify-content: center;
}

@media (max-width: 640px) {
  .reviews-cta { display: block; }
  .reviews-cta > a {
    display: flex;
    align-items: center;
    justify-content: center;
    width: 100%;
    min-height: 50px;
    padding: var(--sp-3) var(--sp-4);
    /* Respetar el gesture bar iOS. */
    margin-bottom: env(safe-area-inset-bottom);
    background: var(--c-primary);
    color: #fff;
    border-radius: var(--radius-md);
    font-family: var(--font-heading);
    font-weight: var(--weight-bold);
    text-decoration: none;
  }
}

Posición

¿Dónde se coloca?

Casi siempre DESPUÉS del catálogo o de los servicios y ANTES del FAQ y del CTA banner. Resuelve la duda «¿le funcionó a alguien como yo?» justo cuando el visitante ya entendió la oferta pero antes de pedir cotización. Hoy se usa en la home (4 testimonios después del bloque de servicios) y, en sitios reales, conviene también dentro de ServiceLayout/ProductLayout para reforzar la entidad reviewada.

A diferencia del Hero (uno por página al inicio) o el FAQ (uno por página al cierre), las reseñas aparecen en bloque (3–6 cards) y SIEMPRE en una posición intermedia —después del contenido principal pero antes del cierre—. Esa posición no es casual: la prueba social funciona en el momento de máxima resistencia, cuando ya hay deseo y antes de la acción. Demasiado arriba (encima del catálogo) parece autopromoción; demasiado abajo (después del CTA) llega tarde.

Lo que NO hace la reseña, por diseño: no reemplaza al catálogo (las cards de catálogo venden, las reseñas validan), no se usa como decoración (3–6 cards reales son mejores que 20 inventadas), y no acepta foto del cliente por default (avatar con iniciales mantiene la coherencia del grid). Para casos compuestos —prueba social + CTA al cierre, como un cierre con acción—, el patrón móvil 4 (stack + CTA full-width) deja la sección rematada con un solo paso siguiente.

Módulos cercanos: FAQ (resuelve dudas justo después), CTA banner (el cierre que convierte) y tarjeta de categoría (el catálogo que las reseñas validan).

Implementación

Cómo está construido

Un único componente con API mínima: quote (obligatorio), name (obligatorio), role? (opcional), rating? (opcional, default 5). Devuelve un <article class="rcard"> con tres bloques: estrellas, <blockquote>, <footer> con avatar de iniciales + nombre + rol. CERO JavaScript. CERO emisión de JSON-LD (el schema vive en lib/seo.ts).

El componente vive en ReviewCard.astro y expone cuatro props a propósito: quote (texto plano, requerido), name (requerido), role (opcional), rating (1–5, default 5, normalizado con Math.max/min/round). Las iniciales del avatar se generan in-component: name.split(/\s+/).slice(0,2).map(w => w.charAt(0)).join("").toUpperCase() —dos letras como máximo, mayúsculas, fuente del heading sobre degradé de --c-primary—. Las estrellas son SVG inline (un <path> reutilizado por set:html sobre cinco <span>) con clase .is-on (#f5a623, amarillo cálido) o .is-off (--c-border). Cero JavaScript de gestión de estado: la card es estática a propósito (sin hover ni click).

El esquema JSON-LD NO se emite por el componente. Esta es una decisión DELIBERADA distinta a FAQAccordion (que sí acepta emitSchema=true como salida de emergencia): el ReviewCard no expone esa prop porque la regla B4 (Google penaliza self-serving reviews) exige un gate GLOBAL (SITE.allowSelfReviews) que no debe duplicarse a nivel componente. El patrón canónico es: (a) helper PURO reviewSchema({items, aggregate?}) en lib/seo.ts —espejo de faqSchema()—, devuelve el bloque {aggregateRating, review[]} listo para componer en otro nodo; (b) helper INTERNO emitReviews() dentro de productSchema/serviceSchema, gateado por SITE.allowSelfReviews. Un emisor por página (B3), un gate global por sitio (B4). CAVEAT mayo 2026 → presente: Google solo pinta estrellas en SERP para algunos tipos schema (Product/Recipe/Movie/Book); LocalBusiness/Service no rinden visualmente pero siguen siendo válidos.

Astro · uso básico (hard-coded, sin schema)
---
// USO BÁSICO · una sola sección de reseñas hardcodeada, fuera de cualquier
// layout schema-driven. El componente NO emite JSON-LD por sí solo —no hay
// prop emitSchema— así que esta página no añade nada al grafo. Patrón
// idéntico al de la home actual de ejemplos.mx.
import ReviewCard from '@components/ReviewCard.astro'
import SectionHeading from '@components/SectionHeading.astro'

const reviews = [
  {
    quote: 'El sitio quedó listo en 9 días y no he tenido que tocar el código.',
    name: 'María González',
    role: 'Directora · Estudio de arquitectura',
    rating: 5,
  },
  {
    quote: 'Lo que más me sorprendió fue la velocidad y la facilidad de edición.',
    name: 'Luis Hernández',
    role: 'Fundador · Servicios industriales',
    rating: 5,
  },
  {
    quote: 'Pedí algo sencillo y obtuve uno que se ve serio. Mucha confianza.',
    name: 'Ana Patricia Ruiz',
    role: 'Consultora independiente',
    rating: 4,
  },
]
---

<section class="section section--surface">
  <div class="container">
    <SectionHeading
      eyebrow="Prueba social"
      title="Lo que dicen nuestros clientes"
      desc="Reseñas reales de proyectos publicados en los últimos seis meses."
    />
    <div class="reviews-grid">
      {reviews.map((r) => (
        <ReviewCard
          quote={r.quote}
          name={r.name}
          role={r.role}
          rating={r.rating}
        />
      ))}
    </div>
  </div>
</section>

<style>
  .reviews-grid {
    display: grid;
    grid-template-columns: 1fr;
    gap: var(--sp-4);
  }
  @media (min-width: 640px)  { .reviews-grid { grid-template-columns: repeat(2, 1fr); } }
  @media (min-width: 1024px) { .reviews-grid { grid-template-columns: repeat(3, 1fr); } }
</style>
Astro · data-driven desde una collection 'reviews'
---
// DATA-DRIVEN DESDE UNA COLECCIÓN `reviews` · cómo se mantendría con
// reseñas reales sin tocar la página. La fuente única es una colección
// de Content Collections (Markdown frontmatter validado por Zod), igual
// que servicios/ y productos/. Cada reseña vive en un .md con frontmatter
// {quote, name, role, rating, date, source}; si la marca trae permiso por
// escrito, source apunta a la URL de la reseña original (Google Business).
import { getCollection } from 'astro:content'
import ReviewCard from '@components/ReviewCard.astro'

// Define la colección en src/content/config.ts:
//   const reviews = defineCollection({
//     schema: z.object({
//       quote:  z.string().min(10),
//       name:   z.string(),
//       role:   z.string().optional(),
//       rating: z.number().int().min(1).max(5),
//       date:   z.string(),                // ISO 'YYYY-MM-DD'
//       source: z.string().url().optional(),
//     }),
//   })
//   export const collections = { reviews }

// Cargar reseñas, ordenar por fecha desc, tomar las 6 más recientes.
const reviews = (await getCollection('reviews'))
  .sort((a, b) => new Date(b.data.date).getTime() - new Date(a.data.date).getTime())
  .slice(0, 6)
---

<section class="section section--surface">
  <div class="container">
    <div class="reviews-grid">
      {reviews.map((r) => (
        <ReviewCard
          quote={r.data.quote}
          name={r.data.name}
          role={r.data.role}
          rating={r.data.rating}
        />
      ))}
    </div>
  </div>
</section>

{/* Notar: el data del frontmatter incluye `source` (URL externa) y `date`,
    pero el ReviewCard hoy ignora esos campos. Para enchufarlos en la card
    haría falta extender el componente (variante «Con fuente externa»). Para
    el JSON-LD, `date` y `name` son obligatorios en reviewSchema(). */}
Astro · integración con schema Review (lib/seo.ts → reviewSchema + emitReviews)
---
// INTEGRACIÓN CON SCHEMA Review / AggregateRating · DÓNDE VIVE EL EMISOR.
// Regla dura B3: UN único emisor por página. Regla dura B4: NUNCA emitir
// aggregateRating sin reseñas REALES de terceros verificables (Google
// Business Profile, Trustpilot…). Por eso el gate global SITE.allowSelfReviews
// está en false por default y el helper reviewSchema() es una función PURA
// (no gateada) que el llamador decide cuándo usar.
//
// CAVEAT (alineado con faqSchema · mayo 2026): Google solo pinta estrellas
// en la SERP para algunos tipos schema (Product/Recipe/Movie/Book). Para
// LocalBusiness/Service el schema sigue siendo válido y útil para entender
// la entidad, pero no esperes el efecto visual de las amarillas.
import PageLayout from '@layouts/PageLayout.astro'
import ReviewCard from '@components/ReviewCard.astro'
import { reviewSchema, type Review } from '@lib/seo'

// Reseñas reales y verificables (en este ejemplo, importadas de Google
// Business Profile vía export semanal). Mantén la trazabilidad de la fuente.
const reviews: Review[] = [
  { author: 'María González', text: 'El sitio quedó listo en 9 días.', rating: 5, date: '2026-05-14' },
  { author: 'Luis Hernández',  text: 'La velocidad fue la sorpresa.',   rating: 5, date: '2026-04-22' },
  { author: 'Ana Patricia Ruiz', text: 'Sencillo pero se ve serio.',    rating: 4, date: '2026-03-30' },
]

// Opción A · COMPONER UN NODO A MANO con reviewSchema (no enchufado al
// grafo automáticamente — el llamador lo añade a un nodo Service o Product
// en el grafo de la página). Útil cuando la entidad reviewada vive aquí.
const reviewBlock = reviewSchema({ items: reviews })
// reviewBlock = { aggregateRating: {...}, review: [...] }
// Spread dentro de un nodo Service que tú compones aquí, o pásalo como
// extraSchema={[...]} al PageLayout si tu layout lo soporta.
---

<PageLayout
  title="Servicios — desarrollo web profesional"
  description="Sitios Astro listos para producción con reseñas reales."
  pageType="service"
  data={{
    service: {
      name: 'Desarrollo web Astro',
      description: 'Sitios Astro listos para producción.',
      path: '/servicios/desarrollo-web',
      reviews,  // ← productSchema/serviceSchema delegan a emitReviews(),
                //   que gatea por SITE.allowSelfReviews y emite solo si
                //   el gate global está en true. Patrón canónico.
    },
  }}
>
  <section class="section">
    <div class="container">
      {reviews.map((r) => (
        <ReviewCard quote={r.text} name={r.author} role={undefined} rating={r.rating} />
      ))}
    </div>
  </section>
</PageLayout>

{/* Opción B · DEJAR QUE serviceSchema emita aggregateRating + review[]:
    Es el camino canónico: pasar reviews dentro de data.service.reviews y
    activar SITE.allowSelfReviews=true en site.ts si y solo si las reseñas
    son verificables. emitReviews() valida los campos y descarta inválidas.

    Opción C (debug) · ARMAR EL NODO A MANO para inspeccionarlo:
    import { reviewSchema } from '@lib/seo'
    const node = reviewSchema({ items: reviews })
    // node = { aggregateRating: {...}, review: [...] }

    NUNCA dos emisores a la vez (regla B3): si serviceSchema ya emitió
    aggregateRating, no lo emitas otra vez con reviewSchema en el mismo
    nodo —se duplica y Google ignora el rich result—. */}

En concreto: el componente recibe las props y emite un <article class="rcard"> con tres bloques. Arriba, un <div class="rcard__stars" role="img" aria-label="N de 5 estrellas"> con cinco <span> y un SVG inline por estrella (la clase .is-on o .is-off alterna el color). En medio, un <blockquote class="rcard__quote"> con flex-grow:1 (clave para alturas iguales en el grid) y comillas tipográficas pintadas por ::before / ::after. Abajo, un <footer class="rcard__author"> con border-top sutil y margin-top:auto (que lo ancla al fondo), un avatar de iniciales (círculo de 46×46px con degradé), una <cite class="rcard__name"> con font-style:normal, y un <span class="rcard__role"> opcional.

La accesibilidad es de fábrica: la card es <article> con semántica de cita (<blockquote> + <cite>); el rating se anuncia a screen readers vía role="img" + aria-label="N de 5 estrellas"; el avatar lleva aria-hidden porque es decorativo (las iniciales repiten el nombre que ya está debajo). Los tokens del proyecto (--c-primary, --c-primary-dark, --radius-lg, --sp-4, --c-border) son la única fuente de estilo: cambiar el design system cambia todas las reseñas del sitio. Y el JSON-LD vive donde tiene que vivir: en lib/seo.ts (helper puro reviewSchema() + helper interno emitReviews()), con un único gate global SITE.allowSelfReviews que evita la trampa de las auto-reseñas.

Buenas prácticas

Qué hacer y qué evitar

La diferencia entre una sección de prueba social que convierte y una que daña la confianza cabe en un puñado de hábitos —empezando por usar reseñas REALES y verificables (nunca inventadas) y por entender que el schema vive en lib/seo.ts, no en el componente, y siempre gateado por SITE.allowSelfReviews—.

Ninguno de estos hábitos es capricho: salen del diseño actual del componente, de la regla B4 del proyecto (anti self-serving reviews) y del esquema de schema del sitio. La reseña es presentación + schema opcional, y el schema requiere reseñas REALES de terceros. Pasar las reseñas por una collection (Markdown + Zod) y dejar el componente solo con el render mantiene la separación de responsabilidades; tener el gate global SITE.allowSelfReviews evita activar el aggregateRating «por accidente» en datos débiles.

La buena noticia es que casi todo se sostiene solo cuando se respeta el patrón. Las reseñas vienen del frontmatter (validadas por Zod), el schema lo gestiona seo.ts con su gate global, y el componente cumple un solo papel: pintar la card con estrellas + cita + autor. El detalle a vigilar manualmente: NO inventar testimonios, NO mezclar avatares con foto y con iniciales en el mismo grid, y NO esperar rich snippets de Review en LocalBusiness/Service (Google solo los pinta para Product/Recipe/Movie/Book).

Sí conviene

  • Usa reseñas REALES y verificables: pide permiso por escrito al cliente, guarda la fuente (mensaje original, captura de Google Business Profile, correo), y cita textualmente. Si el cliente no autorizó publicación, queda fuera; mejor 3 reseñas legítimas que 8 anónimas o reescritas en tu voz.
  • Prioriza CITAS CONCRETAS: «el sitio quedó listo en 9 días y no he tenido que tocar el código una sola vez» convence más que «excelente servicio, lo recomiendo ampliamente». Los detalles (tiempos, cantidades, problemas resueltos) dan credibilidad y demuestran que la reseña no fue redactada por la marca.
  • Mantén el avatar con iniciales (default): es uniforme, ligero y profesional. Las iniciales se generan solas desde la prop name. Si un cliente ya es una marca pública (otra empresa, un creador con foto de marca conocida), valora un avatar con imagen como extensión —pero entonces TODAS las cards de esa sección llevan foto, no algunas sí y otras no—.
  • Pásale a ReviewCard solo lo necesario (quote + name + role + rating): la API es minimalista a propósito. Si necesitas «sin rating», pasa rating={0} explícitamente —la prop normaliza al rango 0–5 y pinta 5 estrellas vacías—; es un patrón válido para testimonios cualitativos donde la calificación numérica no aplica (clientes B2B grandes que «no califican proveedores»).
  • Si tienes reseñas REALES y verificables (≥5 de fuente externa: Google Business Profile, Trustpilot), activa SITE.allowSelfReviews=true y enchufa reviewSchema({items}) en el nodo del producto/servicio que se reseña. El helper calcula el aggregateRating o lo recibe precomputado por si lo trae Google Business. Documentación: docs/MODULOS.md §3.
  • Coloca las reseñas DESPUÉS del catálogo o de la presentación de servicios y ANTES del FAQ + CTA: el visitante ya entendió la oferta y, antes de tomar acción, busca prueba social. La rejilla canónica es de 1 → 2 → 3 cards por fila (mobile-first); para más de 6 reseñas, mejor un carrusel snap horizontal en móvil que apilar 8 cards verticales.

Mejor evita

  • NO inventes reseñas para llenar el grid: Google penaliza reseñas auto-emitidas (self-serving) y, peor, el visitante las detecta de inmediato (tono uniforme, datos genéricos, nombres genéricos). Una reseña falsa pesa más en contra que diez verdaderas a favor; mejor 0 que inventadas.
  • NO actives reviewSchema()/emitReviews para emitir AggregateRating si NO tienes ≥5 reseñas reales de terceros con autor, fecha y texto verificables. La regla B4 del proyecto es estricta: SITE.allowSelfReviews=false por default, y emitReviews retorna {} si no hay reseñas válidas. Forzar el schema en datos débiles → acción manual de Google.
  • NO mezcles avatares con foto y avatares con iniciales en el mismo grid: rompe la coherencia visual (una card con foto «pesa» más que las otras y desbalancea el grid). Si una sección lleva fotos, TODAS llevan foto; si una sección lleva iniciales, TODAS llevan iniciales. Patrón all-or-nothing.
  • NO uses estrellas «medias» (rating=4.5 con media estrella rellena): añade ruido visual sin ganancia de confianza —y el componente las redondea con Math.round, así que rating=4.5 pinta 5 estrellas llenas—. Si quieres mostrar promedios decimales, hazlo en el AggregateRating (JSON-LD) y deja la card con el entero más cercano.
  • NO esperes RICH RESULTS de Review/AggregateRating en LocalBusiness o Service: desde el endurecimiento de las pautas de Google (Product/Movie/Book/Recipe son los tipos que aún reciben rich snippets), un LocalBusiness con AggregateRating no pinta estrellas en la SERP. El schema sigue siendo válido y útil para entender la entidad, pero no esperes el efecto visual de las amarillas.
  • NO sobrecargues la cita con HTML: el ReviewCard renderiza quote como texto plano dentro del <blockquote>. No hay set:html ni links embebidos a propósito —la cita es una sola frase atómica, no un párrafo con enlaces—. Si necesitas que la cita tenga formato, primero pregúntate si esa reseña no debería ser un artículo de caso de éxito en blog.

En vivo

El componente, en su uso real

Las galerías de variantes son mockups a escala; este bloque NO. Aquí se renderizan cuatro ReviewCard REALES con citas demo (en un sitio real serían reseñas verificadas), en la misma rejilla 1 → 2 → 3 que usa la home y que ofrecerían 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.reviews al PageLayout: cero schema duplicado, cero Review en una guía técnica que ya tiene su propio WebSite + BreadcrumbList.

Las cuatro reseñas son DEMO realistas pensadas para una plantilla de sitios Astro: rating 5 (las tres primeras), rating 4 (la tercera, para ver la fila mixta), nombres compuestos y simples (María González → MG, Luis Hernández → LH, Ana Patricia Ruiz → AP, Carlos Méndez → CM), roles cortos y largos. Notar las iniciales generadas solas, las cinco estrellas que cambian su .is-on / .is-off según el rating, y las alturas IGUALES de las cards aunque las citas varíen (gracias al flex-grow:1 sobre el quote).

El sitio quedó listo en nueve días y no he tenido que tocar el código una sola vez para editar contenido: todo se hace desde Markdown, exactamente como me lo explicaron.
María González Directora · Estudio de arquitectura CDMX
Lo que más me sorprendió fue la velocidad: el sitio anterior tardaba 6 segundos en cargar; este abre en menos de uno en celular. Las cotizaciones por WhatsApp se triplicaron al mes siguiente.
Luis Hernández Fundador · Servicios industriales del Bajío
Pedí un sitio sencillo y obtuve uno que se ve serio. El equipo me explicó cada decisión —por qué Markdown, por qué un solo botón por sección— y eso me dio confianza para no andar pidiendo cambios al voleo.
Ana Patricia Ruiz Consultora independiente
La curva de aprendizaje para editar el sitio fue de una tarde. Llevo seis meses publicando notas en el blog sin ayuda externa, y el SEO técnico sigue intacto: cero regresiones en Search Console.
Carlos Méndez Marketing · PyME de servicios profesionales
¿Necesitas ayuda?