Módulo del sitio · Tarjeta de producto

La Tarjeta de producto: la ficha breve del catálogo

El módulo que convierte una colección Markdown en una vitrina: imagen con overlay y badge de norma o categoría, título H3, descripción corta y un CTA inline —todo dentro de un único enlace que vuelve la card entera clic-en-cualquier-lado—.

Esta página no es una ficha técnica: es el módulo entero, abierto y explicado. Qué problema resuelve y por qué se ganó su lugar como vitrina del catálogo, de qué piezas se compone, cómo se comporta en el teléfono, dónde encaja (y dónde NO, porque la ficha L4 ya tiene su propio lenguaje) y, al final, cómo está construido por dentro —del criterio de diseño a la línea de código—.

Con una particularidad que conviene marcar desde el principio: la card es presentación pura, sin JSON-LD propio. El schema Product vive centralizado en lib/seo.ts (productSchema) y solo lo invoca la ficha L4 vía buildSchema('product', …); el GRID del catálogo emite ItemList vía directorySchema. Regla B3 dura: un único emisor de schema por página. La card no se acopla a SEO: si cambia el contenido, cambia un .md; si cambia el schema, cambia un archivo. Cero duplicación.

Definición

¿Qué es la tarjeta de producto?

El componente que presenta UN producto del catálogo como una ficha breve dentro de una vitrina: imagen 16:9 con overlay y badge, título H3, descripción corta y CTA inline, todo envuelto en un único enlace.

La tarjeta de producto (ProductCard.astro) es el módulo que toma un producto del catálogo —un archivo Markdown en src/content/productos/— y lo presenta en formato vitrina: una foto cuadrada-y-media, un badge para señalar la norma o la categoría, el título de venta, una línea de descripción y un CTA «Ver detalles» que lleva a la ficha. Es promo de un solo clic: lo justo para enganchar y mandar al detalle.

En este proyecto vive en un único componente con una API pequeña (title, href, image?, imageAlt?, badge?, description?, ctaLabel?, index?, priority?). Hoy se usa en exactamente un consumidor: /productos/index.astro, la vitrina del catálogo. La ficha L4 (/productos/<slug>) NO la usa para «relacionados» —usa RelatedLinks, un componente aparte—, así que su rol es deliberadamente acotado: vitrina, no detalle.

Función e importancia

¿Para qué sirve?

Hace tres trabajos a la vez: convierte una colección Markdown en un grid uniforme, cuida CLS y LCP por defecto, y mantiene el schema centralizado en la ficha L4 sin acoplarse a SEO.

Su función es ser la cara breve del catálogo: cuando un visitante entra a /productos, no quiere leer 15 fichas completas —quiere escanear, comparar y entrar a una—. La card resume cada producto en cinco trazos (imagen, badge, título, descripción, CTA) con la misma jerarquía visual en TODA la rejilla, así la comparación entre productos es justa. Cuando la categoría se merece más detalle, la ficha L4 (ProductLayout) toma el relevo.

Y por eso pesa fuera de proporción a su altura. Para los buscadores, la card lleva un H3 con el nombre del producto, alt descriptivo en la imagen y enlace interno a la ficha —el padre del grid emite ItemList (CollectionPage) vía directorySchema, así Google ve la lista entera, no card por card—. Para la accesibilidad, todo la card es un único <a> con aria-label igual al título, los iconos son aria-hidden y el foco es visible. Para el equipo que mantiene el sitio, un solo componente para todo el catálogo significa que ajustar el radio, la sombra o la jerarquía se hace en un archivo y se propaga.

Una sola card para todo el catálogo

En lugar de diseñar una ficha por categoría (equipos, accesorios, general), todo el catálogo usa la misma ProductCard. Cambiar el radio, la sombra o la jerarquía del título se hace en un archivo y se propaga a TODOS los productos —no hay que cazar copias dispersas—. La consistencia visual del catálogo es por construcción, no por disciplina.

Anti-CLS por defecto, LCP cuidado

Width y height fijos en la imagen (640×360) reservan el hueco antes de que cargue: cero salto de contenido al pintar. Las 4 primeras cards del grid cargan en eager (la primera además con fetchpriority="high"), las demás en lazy —el visitante ve el catálogo rápido y el resto entra mientras hace scroll—.

Sin schema, sin acoplamiento

La card NO emite JSON-LD: el schema Product vive en lib/seo.ts (productSchema) y lo invoca solo ProductLayout (la ficha L4). El grid emite ItemList vía directorySchema. Un solo emisor por página (regla B3) → cero schema duplicado, cero conflicto entre @id, y se cambia en un archivo para todo el sitio.

Anatomía

¿Qué lleva la card?

Cuatro piezas dentro de un único <a> que envuelve todo: imagen 16:9 con overlay y badge, título H3, descripción corta y CTA inline con flecha. Width y height fijos en la imagen reservan el hueco (cero CLS).

Cada pieza cumple un papel claro. La imagen abre con su overlay decorativo y el badge (norma o categoría) en la esquina superior izquierda; el título en H3 da identidad y, como vive dentro del único <a>, vuelve la card entera clic-en-cualquier-lado; la descripción corta (1-2 líneas) mata la duda más común antes del clic; y el CTA inline —«Ver detalles» + flecha SVG— cierra el bloque y es el único elemento que anima en hover (color + gap), con respeto a prefers-reduced-motion.

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 del componente sale. La sección termina renderizando la card REAL del componente (no un mockup) en la sección «En vivo» para que el espécimen y el componente convivan en la misma página.

1

Imagen + badge (con overlay)

La foto del producto en proporción 16:9 con width="640" height="360" para reservar el hueco (cero CLS). Lleva un overlay decorativo en degradado y, opcionalmente, una etiqueta superior izquierda —pensada para una norma («NOM-115-STPS») o la categoría («Equipos»)—. El loading es eager solo para las primeras 4 cards del grid (o cuando priority=true), lazy para las demás.

Dato props image · imageAlt · badge · index · priority

2

Título (H3) — anchor text completo

El nombre del producto en jerarquía H3 (la página padre ya gastó su H1 en el hero y su H2 en el heading de la sección «catálogo»). Vive dentro del único <a> de la card —por eso TODA la tarjeta es clic-en-cualquier-lado—. El anchor text que cuenta para SEO es la combinación title + description + ctaLabel, no el title aislado.

Dato prop title → <h3 class="pcard__title">

3

Descripción corta (opcional)

Una línea-y-media de venta que mata la duda más común antes del clic («ligero», «certificado», «entrega 24 h»). Es opcional: si no la pasas, la card se compacta y deja respirar al título. Cuando la pasas, viene desde la collection (data.description) con validación Zod 70-280 chars en src/content.config.ts.

Dato prop description (opcional)

4

CTA inline («Ver detalles» + flecha)

El cierre de la card: texto del CTA (default «Ver detalles») y una flecha SVG decorativa. NO es un botón aparte —es un <span> dentro del único <a>—. Es el ÚNICO elemento de la card que anima: en hover cambia el color (rojo → rojo oscuro) y aumenta el gap (.5rem → .65rem). Bajo prefers-reduced-motion la transición se desactiva.

Dato prop ctaLabel (default "Ver detalles")

Variantes

Otros diseños y aplicaciones

La de esta plantilla es la canónica (imagen + badge + título + descripción + CTA), pero la card cambia de cara según lo que la página padre quiera mostrar: vitrina principal, accesorios sin foto, sub-listas densas, LCP priority, B2B sin precio público o producto sin stock.

No hay un único modelo: hay una misma idea —una sola card para una sola promesa— que cada tipo de vitrina ajusta. La vitrina principal lleva todo (la de productos/index.astro); una sub-lista densa omite badge y descripción para caber más cards por fila; un grid de accesorios sin foto colapsa la imagen; el primer producto sobre el pliegue recibe priority para ganar el LCP.

Abajo, seis variantes —cuatro son configuraciones REALES del componente actual (combinaciones de las props que ya existen), y dos son extensiones propuestas (sin precio y agotado) 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.

  • Canónica: imagen + badge + título + descripción + CTA

    Catálogo · Vitrina principal

    La que usa /productos/index.astro hoy: las 5 piezas reales, todas activadas. Imagen 16:9 con overlay, badge de categoría arriba a la izquierda, título H3, descripción corta y CTA «Ver detalles». Configuración real: todas las props llenas.

  • Sin imagen (compacta, texto-primero)

    Accesorios sin foto · Variantes de SKU

    Cuando un producto no tiene foto (un accesorio menor, un repuesto, una variante de SKU), omitir la prop image colapsa todo el bloque del medio: la card queda con título + descripción + CTA, mucho más baja. Útil en grids donde mezclar texto-primero con foto-primero rompería la rejilla (mejor: grid separado). Configuración real: omitir image.

  • Mínima: solo título + CTA (sin badge ni descripción)

    Vitrinas densas · Sub-listas

    La cara más reducida: omitiendo badge y description la card pierde el flair y queda como tile simple. Útil cuando el grid se densifica (8-12 por fila en escritorio, listados largos de un sub-catálogo) y la categoría ya está clara por el heading de la sección. Configuración real: omitir badge + description.

  • LCP priority (primera del grid)

    Sobre el pliegue · Catálogo destacado

    La primera card de la vitrina recibe priority={true}: su imagen carga con fetchpriority="high" + loading="eager", para que sea ella —y no el hero— el LCP medido por Lighthouse. Configuración real: priority=true en la primera del grid; las 4 primeras igualmente quedan eager por index<4.

  • Sin precio (cotización a solicitar)

    B2B · Productos sin tarifa pública

    Extensión propuesta: hoy la card NO muestra precio (no tiene la prop). Para catálogos B2B donde la tarifa va bajo cotización, añadir una prop price (string opcional) que pinte «Desde $X MXN» o «Cotizar» justo encima del CTA mantendría el patrón sin romper otras vitrinas. La collection ya tiene data.price (string opcional, ya validado por Zod), así que el cableado sería directo.

  • Agotado / sin stock (deshabilitada)

    Producto sin disponibilidad · Roadmap

    Extensión propuesta: la prop disabled NO existe en ProductCard (sí en CategoryCard). Para marcar un producto sin stock con la card en gris y el enlace neutralizado (aria-disabled + pointer-events: none) habría que añadirla. Hoy se simula a nivel de página padre filtrando los out-of-stock antes del .map, pero pierdes la señal visual de «existe pero no hoy».

Responsive y móvil

Cómo se comporta en el teléfono

La card no se encoge: la rejilla decide. Default del padre es 1 → auto-fill (mobile-first); a partir de ahí, tres extensiones opcionales para vitrinas que el catálogo simple no cubre: scroll horizontal con snap, stack a ancho completo con CTA sticky, y carrusel curado con dots.

En escritorio, la vitrina (.grid) usa auto-fill con minmax(260px, 1fr): el grid decide cuántas cards caben (típicamente 2-4 por fila). En el teléfono, baja a una sola columna por defecto —cero JS, cero esfuerzo del consumidor del componente—. La card en sí no cambia: las que cambian son la rejilla y, opcionalmente, el modo de presentación.

A partir de ahí, tres patrones opcionales según el tipo de catálogo. Para vitrinas largas (20+ productos), un scroll horizontal con snap evita el scroll vertical eterno. Para vitrinas cortas (3-6 productos) en mobile, apilar a ancho completo con un CTA sticky al fondo es más enganchador. Para vitrinas curadas (3-5 productos destacados), un carrusel con dots da contexto sobre cuántos hay. Las cuatro están abajo, cada una con su vista en el teléfono y una receta comentada lista para adaptar.

1 · De auto-fill a una columna (default)

Es lo que ya hace productos/index.astro hoy. La rejilla declara una columna en móvil y crece con repeat(auto-fill, minmax(260px, 1fr)) en escritorio. Mobile-first puro: cero breakpoints intermedios, cero JS. La card no se toca: el responsive vive en la rejilla del padre.

CSS · grid auto-fill mobile-first (default)
/* MÓVIL · DE 1 A AUTO-FILL (lo que ya hace productos/index.astro)
   La vitrina es mobile-first: arranca en UNA columna en el teléfono
   y crece con auto-fill conforme hay ancho. Sin breakpoints
   intermedios fijos —el grid decide cuántas caben con minmax(260px, 1fr)—.
   La card NO se toca: solo la rejilla. */

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

@media (min-width: 768px) {
  .grid {
    grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
    /* escritorio: 2-4 columnas según ancho disponible */
  }
}

2 · Scroll horizontal con snap (extensión)

Para catálogos largos (20+ productos), una fila horizontal con scroll-snap-type: x mandatory evita el scroll vertical eterno. Cada card ocupa ~85% del viewport y encaja en el snap. La señal visual del «peek» de la siguiente card invita al swipe. Extensión sobre la rejilla; NO toca el componente.

CSS · scroll horizontal con snap (móvil)
/* MÓVIL · SCROLL HORIZONTAL CON SNAP (extensión, opcional)
   Para catálogos largos en mobile (20+ productos), apilar todas
   las cards en columna obliga a un scroll vertical eterno. Una
   alternativa es presentar una fila horizontal con snap suave:
   el visitante desliza con el pulgar y cada card encaja en pantalla.
   Extensión sobre la rejilla; NO requiere tocar el componente. */

@media (max-width: 640px) {
  .grid {
    display: flex;
    overflow-x: auto;
    scroll-snap-type: x mandatory;
    gap: var(--sp-4);
    padding-bottom: var(--sp-3);
    /* oculta el scrollbar nativo pero deja el rebote */
    scrollbar-width: none;
  }
  .grid::-webkit-scrollbar { display: none; }

  /* cada card ocupa ~85% del viewport y encaja en el snap */
  .grid > * {
    flex: 0 0 85%;
    scroll-snap-align: start;
  }
}

3 · Stack ancho completo + CTA sticky (extensión)

Para vitrinas cortas (3-6 productos) en móvil, apilar cada card a ancho completo —cada producto como «hero»— y dejar un CTA sticky al fondo (position: sticky; bottom) recoge la intención sin obligar al visitante a volver al header. Respeta env(safe-area-inset-bottom) en iOS para no quedar bajo el home indicator.

CSS · stack + CTA sticky (móvil)
/* MÓVIL · STACK A ANCHO COMPLETO + CTA STICKY (extensión)
   En vitrinas cortas (3-6 productos), una alternativa al grid es
   apilar las cards a ancho completo —cada producto «hero»— y, al
   final del scroll, dejar un CTA sticky que recoja la intención
   («Cotizar por WhatsApp»). El stack lo hace la rejilla; el sticky
   es un wrapper de la página padre. */

@media (max-width: 640px) {
  .grid {
    display: grid;
    grid-template-columns: 1fr;
    gap: var(--sp-5);
  }
}

/* CTA sticky al fondo, fuera de la rejilla. */
.cta-sticky {
  position: sticky;
  bottom: var(--sp-4);
  z-index: 5;
  display: grid;
  place-items: center;
  padding: var(--sp-3) var(--sp-4);
  margin-top: var(--sp-6);
  background: var(--c-primary);
  color: #fff;
  border-radius: var(--radius-md);
  font-family: var(--font-heading);
  font-weight: var(--weight-semibold);
  /* safe-area en iOS para no quedar bajo el home indicator */
  padding-bottom: max(var(--sp-3), env(safe-area-inset-bottom));
}

4 · Carrusel con dots (extensión, JS mínimo)

Para vitrinas curadas (3-5 productos destacados), un carrusel manual con indicadores: el snap horizontal lo hace CSS; los dots y la card visible se gestionan con un IntersectionObserver de 10 líneas. Útil cuando el dato «hay 4 destacados» comunica algo; antipatrón para catálogos largos —ahí mejor el scroll horizontal sin dots—.

CSS + IntersectionObserver · carrusel con dots
/* MÓVIL · CARRUSEL CON DOTS (extensión, mínimo JS)
   Carrusel manual con indicadores. El snap horizontal lo hace
   CSS; los dots y la posición actual se manejan con un IntersectionObserver
   mínimo que marca la card visible. Sirve para vitrinas curadas
   (3-5 productos destacados) donde un dot le dice al visitante
   cuántos hay. NO usar para catálogos largos. */

.carrusel {
  display: flex;
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  gap: var(--sp-4);
  scrollbar-width: none;
}
.carrusel::-webkit-scrollbar { display: none; }
.carrusel > * { flex: 0 0 88%; scroll-snap-align: center; }

.dots {
  display: flex;
  justify-content: center;
  gap: var(--sp-2);
  margin-top: var(--sp-3);
}
.dots button {
  width: 8px; height: 8px; padding: 0;
  border-radius: var(--radius-full);
  background: var(--c-border);
  border: 0;
}
.dots button[aria-current="true"] {
  background: var(--c-primary);
  width: 18px;
  transition: width var(--transition);
}

/* JS mínimo (Astro client:load o script inline):
   const obs = new IntersectionObserver((entries) => {
     entries.forEach((e) => {
       if (e.isIntersecting) {
         const i = [...e.target.parentElement.children].indexOf(e.target)
         document.querySelectorAll('.dots button').forEach((b, j) =>
           b.setAttribute('aria-current', String(i === j)))
       }
     })
   }, { threshold: 0.6 })
   document.querySelectorAll('.carrusel > *').forEach((el) => obs.observe(el))
*/

Posición

¿Dónde se coloca?

Una vez por producto, siempre dentro de un .grid (auto-fill) en la vitrina del catálogo /productos. NO se usa en la ficha L4 (/productos/<slug>) para mostrar «relacionados» —ese papel lo cumple RelatedLinks—.

A diferencia del CategoryDetail (que se apila N veces por página) o del Hero (uno por página), la ProductCard aparece tantas veces como productos haya en la vitrina —y SOLO en la vitrina—. Hoy se usa en exactamente un consumidor: /productos/index.astro. Cada card va dentro del wrapper <code>.grid</code> (mobile-first: 1 columna → auto-fill con <code>minmax(260px, 1fr)</code> en escritorio).

Lo que NO hace la card, por diseño: no aparece en la ficha L4 (ProductLayout) para mostrar productos relacionados. La ficha L4 usa <code>RelatedLinks</code>, un componente genérico tipo «más enlaces» que recibe <code>links: { label, href, description }[]</code> y los pinta como tiles simples. Esto evita el bucle visual «card de catálogo dentro de ficha de catálogo» y deja a la ProductCard cumpliendo un solo papel: vitrina. Si en el futuro se quisiera reusar la card para «relacionados», sería un cambio de patrón —no un parche al componente—.

Implementación

Cómo está construido

Un único componente con API pequeña: title, href, image?, imageAlt?, badge?, description?, ctaLabel?, index?, priority?. Devuelve un <article> con un único <a> envolvente que contiene la imagen (16:9 con width/height fijos), el badge opcional, el título H3, la descripción opcional y el CTA inline con flecha SVG.

El componente vive en ProductCard.astro y expone nueve props a propósito: title y href (obligatorios; href no está guardado, pasarlo vacío genera un enlace muerto silencioso), image + imageAlt (imagen opcional; el alt cae a title si no se pasa), badge (etiqueta superior, opcional), description (1-2 líneas, opcional), ctaLabel (default «Ver detalles»), index (posición en el grid: las 4 primeras cargan eager) y priority (LCP: la primera del grid sobre el pliegue lleva fetchpriority="high"). Cero JavaScript en el componente: HTML + CSS estático generado en build.

En el sitio, lo más común es alimentarlo desde getCollection('productos'). La página /productos/index.astro filtra los drafts, ordena por data.order y mapea cada producto a una ProductCard pasando index para el lazy automático. El padre del grid emite ItemList vía directorySchema (CollectionPage + lista), NO Product —Product es de la ficha L4—. Las imágenes ya están en /images/productos/*.avif (validadas por Zod, regex que exige la ruta absoluta).

Astro · uso básico (hard-coded, una sola card)
---
// USO BÁSICO · una sola card hard-coded. La card requiere title + href;
// image, badge y description son opcionales. ctaLabel default «Ver
// detalles». Priority solo en la primera card sobre el pliegue (LCP).
import ProductCard from '@components/ProductCard.astro'
---

<section class="section">
  <div class="container">
    <ProductCard
      title="Casco de seguridad industrial NOM-115"
      href="/productos/casco-nom-115"
      image="/images/productos/casco-seguridad-industrial.avif"
      imageAlt="Casco de seguridad industrial rojo certificado NOM-115-STPS"
      badge="NOM-115-STPS"
      description="Casco homologado para industria pesada, con barboquejo ajustable. Stock para entrega 24 h en CDMX."
      ctaLabel="Ver casco"
      priority
    />
  </div>
</section>
Astro · data-driven desde getCollection('productos') (vitrina del catálogo)
---
// DATA-DRIVEN DESDE getCollection · cómo lo hace /productos/index.astro
// hoy. La fuente única es la colección productos (src/content/productos/
// *.md), validada por Zod en src/content.config.ts. Filtrá drafts, ordená
// por order ASC, pasá index para que las 4 primeras carguen eager y
// priority=true a la primera (LCP).
import { getCollection } from 'astro:content'
import ProductCard from '@components/ProductCard.astro'

const productos = (await getCollection('productos', ({ data }) => !data.draft))
  .sort((a, b) => (a.data.order ?? 0) - (b.data.order ?? 0))
---

<div class="grid">
  {productos.map((p, i) => (
    <ProductCard
      title={p.data.title}
      href={`/productos/${p.id}`}
      image={p.data.image}
      imageAlt={p.data.title}
      badge={p.data.category}
      description={p.data.description}
      index={i}
      priority={i === 0}
    />
  ))}
</div>

<style>
  /* Vitrina del catálogo: 1 → auto-fill en escritorio. */
  .grid { display: grid; grid-template-columns: 1fr; gap: var(--sp-5); }
  @media (min-width: 768px) {
    .grid { grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); }
  }
</style>
Astro · schema en el padre (ItemList vía directorySchema; la card no emite)
---
// SCHEMA EN LA PÁGINA PADRE · la card NO emite JSON-LD. El grid emite
// ItemList vía directorySchema (helper en lib/seo.ts), invocado por
// PageLayout con pageType="category". La ficha L4 (ProductLayout) es
// la única que emite Product+Offer, vía buildSchema('product', …).
// Regla B3: un único emisor de schema por página, sin duplicar.
import PageLayout from '@layouts/PageLayout.astro'
import ProductCard from '@components/ProductCard.astro'
import { getCollection } from 'astro:content'

const productos = (await getCollection('productos', ({ data }) => !data.draft))
  .sort((a, b) => (a.data.order ?? 0) - (b.data.order ?? 0))

// Para la card NO se pasa schemaData; para el GRID, el padre arma items
// con { name, path, image, description } y PageLayout llama
// buildSchema('category', { list: { name, description, path, items } })
// → emite CollectionPage + ItemList (no Product).
const items = productos.map((p) => ({
  name: p.data.title,
  path: `/productos/${p.id}`,
  image: p.data.image,
  description: p.data.description,
}))
---

<PageLayout
  title="Catálogo de productos"
  description="Todos los productos del sitio, una card por ficha."
  pageType="category"
  schemaData={{ list: {
    name: 'Catálogo de productos',
    description: 'Productos disponibles, una card por ficha.',
    path: '/productos',
    items,
  } }}
  breadcrumbs={[{ label: 'Productos' }]}
>
  <div class="grid">
    {productos.map((p, i) => (
      <ProductCard
        title={p.data.title}
        href={`/productos/${p.id}`}
        image={p.data.image}
        imageAlt={p.data.title}
        badge={p.data.category}
        description={p.data.description}
        index={i}
        priority={i === 0}
      />
    ))}
  </div>
</PageLayout>

En concreto: el componente recibe las props, computa eager = priority || index < 4 y emite un <article class="pcard" aria-label={title}> con un único <a class="pcard__link"> que contiene (si hay imagen) un <div class="pcard__media"> con la <img> (width=640, height=360, sizes responsive, loading eager/lazy según índice, fetchpriority high solo si priority), el overlay decorativo y el badge si lo hay; y luego un <div class="pcard__body"> con el <h3> del título, la descripción opcional y el <span class="pcard__cta"> con el texto y la flecha SVG. Todo dentro del único <a> → clic-en-cualquier-lado.

La accesibilidad es de fábrica: el contenedor <article> lleva aria-label con el título; el overlay y la flecha son aria-hidden; el foco visible vive en .pcard__link:focus-visible con outline de marca. La única transición —color y gap del CTA en hover— se desactiva bajo prefers-reduced-motion: reduce. Los tokens del proyecto (--c-primary, --radius-xl, --sp-5) son la única fuente de estilo: cambiar el design system cambia todas las cards del catálogo. La card NO emite JSON-LD: el schema Product vive en lib/seo.ts y solo lo invoca la ficha L4 (ProductLayout); el grid emite ItemList vía directorySchema. Un único emisor por página (regla B3), cero schema duplicado.

Buenas prácticas

Qué hacer y qué evitar

La diferencia entre una vitrina que se lee con ritmo y una que se siente «hecha por cards distintas» cabe en un puñado de hábitos —empezando por respetar la regla B3 del schema (un solo emisor por página) y por NO pasar href vacíos—.

Ninguno de estos hábitos es capricho: salen del diseño actual del componente y del esquema de schema del sitio. La card es presentación pura: Product schema vive en lib/seo.ts y solo la ficha L4 lo emite; el grid emite ItemList. Pasar todo por la collection (Zod) y filtrar drafts antes del map asegura que solo lleguen productos completos a la vitrina.

La buena noticia es que casi todo se sostiene solo cuando se respeta la collection: el alt cae a title (subóptimo pero funcional), el lazy es automático para todas menos las 4 primeras, y el LCP se cuida pasando priority a la primera card. El detalle a vigilar manualmente: <code>href</code> NO está guardado y queda en silencio si se pasa vacío. Abajo, lo que conviene y lo que conviene evitar, enfrentados.

Sí conviene

  • Pasa `index={i}` en el .map del grid: las 4 primeras cards cargan en eager, las demás en lazy (auto).
  • Pasa `priority` SOLO a la primera card del grid sobre el pliegue (LCP). Más de una `priority=true` desperdicia el bandwidth inicial.
  • Escribe `imageAlt` descriptivo cuando la collection lo permita —«Casco de seguridad rojo NOM-115»—; el fallback a `title` es funcional pero pobre para SEO.
  • Reusa `badge` para señales cortas: una norma («NOM-115-STPS»), una categoría («Equipos») o una etiqueta de promoción («Nuevo», «Oferta»). En MAYÚSCULAS, ≤ 18 chars.
  • Mantén la `description` entre 70 y 280 caracteres (lo que valida Zod en la collection); más allá la card crece de altura y rompe la rejilla.

Mejor evita

  • NO pases `href=""`: la card lo acepta sin warning y queda un enlace muerto. Si el producto aún no tiene página, NO renderices la card (filtrá antes en el map).
  • NO uses la card para mostrar reseñas, precios destacados o galería: para eso existe la ficha L4 (ProductLayout). La card es promo de un solo clic.
  • NO emitas Product JSON-LD en la página padre del grid: el grid emite ItemList vía directorySchema (regla B3 — un solo emisor por página). Product solo lo emite la ficha L4.
  • NO cambies el aspect-ratio 16:9 sin cambiar también width/height: si los desincronizás, vuelve el CLS que la card cuida por defecto.
  • NO mezclés tarjetas con imagen y tarjetas sin imagen en el mismo grid: las alturas se desigualan y la rejilla pierde ritmo. Si una categoría no tiene fotos, decide UNA política para el grid completo.

En vivo

El componente, en su uso real

Las galerías de variantes son mockups a escala; estas cards NO. Aquí se renderizan dos ProductCard reales con sus props llenas, idénticos a los que verás en /productos.

La regla del sitio es estricta —una sola card para todo el catálogo, presentación pura sin schema propio—. Este bloque cierra la página con una pequeña vitrina alimentada por el componente real, no por una réplica anotada. Si el componente se rompe, este bloque se rompe a la vista; documentación que se documenta a sí misma.

Las imágenes son las mismas fotos demo del proyecto (AVIF optimizadas, lazy salvo la primera —que recibe priority para que sea LCP friendly—). Los hrefs apuntan a fichas reales del catálogo. La rejilla replica el .grid de /productos/index.astro (1 → auto-fill).

¿Necesitas ayuda?