Módulo del sitio · Tarjeta de servicio

La Tarjeta de servicio: la cara breve del catálogo

El módulo que presenta UN servicio como una ficha breve dentro del catálogo: un ícono SVG (o una foto 16:9), un badge opcional, título H3, descripción y un CTA dual —enlace a la ficha L4 o botón verde directo a WhatsApp para servicios que se cotizan al vuelo—.

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

Con dos particularidades que conviene marcar desde el principio. Primera: la card es presentación pura, sin JSON-LD propio. El schema Service vive centralizado en lib/seo.ts (serviceSchema) y solo lo invoca la ficha L4 vía ServiceLayout; el GRID del catálogo emitirá ItemList vía directorySchema cuando exista /servicios/index.astro. Regla B3 dura: un único emisor por página. Segunda: el CTA es DUAL —enlace estándar a la ficha O botón WhatsApp verde para servicios consultivos—. La elección no es estética: depende de si el siguiente paso real es leer o chatear.

Definición

¿Qué es la tarjeta de servicio?

El componente que presenta UN servicio del catálogo como una ficha breve dentro de una vitrina: un ícono SVG (o una foto opcional), badge corto, título H3, descripción de 1-2 líneas y CTA inline o WhatsApp directo.

La tarjeta de servicio (ServiceCard.astro) es el módulo que toma un servicio del catálogo —un archivo Markdown en src/content/servicios/— y lo presenta en formato vitrina: por defecto un ÍCONO SVG en caja roja clara (no foto), porque las fotos de servicios suelen ser genéricas (stock de manos sobre laptop) y un ícono comunica mejor con menos peso. Cuando la foto aporta algo concreto (un evento montado, una instalación terminada), la prop `image` reemplaza el ícono y la card pasa a modo vitrina como la de producto.

En este proyecto vive en un único componente con una API pequeña (title, description, href, icon?, image?, imageAlt?, badge?, ctaLabel?, whatsapp?). La descripción es obligatoria; icon, image y badge son opcionales pero la card se ve mejor con al menos uno de los dos visuales. El consumidor real (/servicios/index.astro) aún no existe en este repo: la collection servicios y los slugs en TAXONOMY están definidos, pero el grid se documenta antes de pintarse —porque el módulo está listo para usarse y necesita su página de detalle como referencia—.

Función e importancia

¿Para qué sirve?

Hace tres trabajos a la vez: convierte la collection de servicios en un grid uniforme, ofrece una cara natural sin foto (ícono SVG) y abre la puerta al chat con un CTA WhatsApp opcional —sin acoplar la card al schema—.

Su función es ser la cara breve del catálogo de servicios: cuando un visitante entra a /servicios, no quiere leer 8 fichas completas —quiere escanear, comparar y entrar a una—. La card resume cada servicio en cuatro trazos (ícono o imagen, badge opcional, título, descripción, CTA) con la misma jerarquía visual en TODO el grid, así la comparación es justa. Cuando el servicio merece más detalle, la ficha L4 (ServiceLayout, schema-driven con 10 bloques opcionales) 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 servicio, alt descriptivo en la imagen (cuando hay) y enlace interno a la ficha —el padre del grid emitirá ItemList (CollectionPage) vía directorySchema, así Google ve la lista entera, no card por card—. Para la accesibilidad, la card es un único <article> 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 ícono, el botón verde o la jerarquía se hace en un archivo y se propaga.

Una sola card para TODO el catálogo de servicios

En lugar de diseñar una ficha por tipo de servicio (consultoría, implementación, soporte, mantenimiento), todos usan la misma ServiceCard. Cambiar el radio, la sombra o el color del CTA se hace en un archivo y se propaga al catálogo entero. La consistencia visual no es disciplina del equipo —es construcción—.

Icono por defecto: cero dependencia de foto

La card NO obliga a tener foto. Su modo natural es ícono SVG (caja 56×56, rojo claro de marca) + título + descripción + CTA. Útil para catálogos donde una foto «de servicio» se ve genérica (stock de manos sobre laptop). La foto solo se usa cuando aporta —servicios con resultado visible: instalación, eventos, transformación—.

CTA dual: leer la ficha O chatear directo

Por defecto el CTA lleva a la ficha L4 («Ver servicio»). Pero la prop `whatsapp={true}` muta el botón a verde con el ícono de WhatsApp y abre wa.me con mensaje pre-armado. Útil para servicios de venta consultiva (cotización a la medida) donde la ficha agrega fricción y el siguiente paso real es la conversación.

Anatomía

¿Qué lleva la card?

Cuatro piezas dentro de un único <article>: visual (ícono SVG por defecto, o imagen 16:9 si se pasa), badge opcional (inline o sobre la imagen), título H3 + descripción corta, y CTA dual (inline estándar o botón verde de WhatsApp).

Cada pieza cumple un papel claro. El visual abre con el ícono SVG en caja roja clara (56×56) —la cara natural del catálogo de servicios— o, si pasas `image`, con una foto 16:9 con overlay y badge absoluto sobre la esquina. El título en H3 da identidad —jerarquía respetada: H1 hero, H2 sección, H3 card—; la descripción de 1-2 líneas (obligatoria, validada Zod 70-280) mata la duda antes del clic; y el CTA cierra el bloque, ya sea como enlace inline que anima en hover o como botón verde estático que abre wa.me.

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 renderizando cards reales del componente (no mockups) en la sección «En vivo» para que el espécimen y el componente convivan en la misma página.

1

Visual de apertura: icono O imagen 16:9

La cara visible de la card. Por defecto, un ÍCONO SVG inline en una caja de 56×56 con fondo rojo claro (la marca para servicios). Si el padre pasa `image`, esta cabecera se reemplaza por una FOTO 16:9 con overlay decorativo y el badge encima —la card pasa a modo «vitrina» como la de producto—. Son mutuamente excluyentes: image gana si está presente.

Dato props icon (SVG string) · image · imageAlt

2

Badge opcional (norma, gancho o etiqueta)

Una etiqueta corta en MAYÚSCULAS para señalar una norma («NOM-035»), una categoría («Consultoría») o un gancho de promoción («Popular», «Nuevo»). Cuando HAY imagen, el badge se posiciona absoluto sobre la esquina superior izquierda de la foto. Cuando NO hay imagen, el badge cae INLINE arriba del título —misma jerarquía visual, distinta posición—.

Dato prop badge → .scard__badge / .scard__badge--inline

3

Título (H3) + descripción corta

El nombre del servicio 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 «Servicios»). Debajo, una línea-y-media de venta que mata la duda más común antes del clic («incluye soporte», «entrega en 24 h», «sin costo de visita»). La descripción es OBLIGATORIA en este componente (no es opcional como en ProductCard).

Dato props title · description

4

CTA inline («Ver servicio») o WhatsApp directo

El cierre de la card. Por defecto, un enlace inline rojo con label «Ver servicio» + flecha SVG y micro-animación en hover (gap + color). Si pasas `whatsapp={true}`, el CTA muta a un BOTÓN VERDE con el ícono de WhatsApp y abre en nueva pestaña —para servicios donde el siguiente paso es chatear, no leer la ficha—. La animación se desactiva bajo prefers-reduced-motion.

Dato props ctaLabel (default «Ver servicio») · href · whatsapp

Variantes

Otros diseños y aplicaciones

La de esta plantilla es la canónica (ícono + título + descripción + CTA), pero la card cambia de cara según lo que la página padre quiera mostrar: con badge destacado, con imagen 16:9 al modo vitrina, con CTA WhatsApp directo, o —como roadmap— deshabilitada o como paquete combinado.

No hay un único modelo: hay una misma idea —una sola card para una sola promesa— que cada tipo de servicio ajusta. El catálogo principal usa íconos (cara natural); 1-2 servicios pueden llevar badge «POPULAR»; los servicios con resultado visible se pintan con foto 16:9; los servicios consultivos cambian el CTA a botón verde de WhatsApp.

Abajo, seis variantes —cuatro son configuraciones REALES del componente actual (combinaciones de las props que ya existen), y dos son extensiones propuestas (servicio sin disponibilidad y paquete combinado) 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 con ícono (catálogo por defecto)

    Catálogo de servicios · Vitrina principal

    La cara natural del componente: ícono SVG en caja roja clara (56×56), título H3, descripción 1-2 líneas y CTA inline «Ver servicio» + flecha. Sin imagen, sin badge. Configuración real: pasar icon + title + description + href, dejar todo lo demás por defecto.

  • Con badge inline («Popular», «Nuevo»)

    Destacar 1-2 servicios del catálogo

    Igual que la canónica, pero con un badge corto inline encima del título —«POPULAR», «NUEVO», «PROMO»—. Útil para señalar el servicio más vendido o uno recién lanzado, sin romper la jerarquía del grid. Configuración real: añadir prop badge al render.

  • Hero con imagen 16:9 (modo vitrina)

    Servicios con resultado visible · Eventos · Instalaciones

    Para servicios donde la foto aporta —un evento montado, una instalación terminada, una transformación física— pasar `image` reemplaza el ícono por una cabecera 16:9 con overlay decorativo y el badge absoluto sobre la esquina. La card pasa a comportarse como una ProductCard. Configuración real: pasar image + imageAlt + badge (opcional).

  • CTA WhatsApp directo (botón verde)

    Venta consultiva · Cotización al vuelo

    Para servicios donde el siguiente paso real NO es leer la ficha sino chatear —cotización a la medida, urgencia, agenda—. La prop `whatsapp={true}` muta el CTA a un BOTÓN VERDE con el ícono de WhatsApp, abre wa.me en pestaña nueva y NO anima el gap en hover (el botón es estático). Configuración real: whatsapp={true} + href={waUrl(WA_MESSAGES.cotizar)}.

  • Servicio agotado / sin disponibilidad (deshabilitada)

    Servicio sin agenda · Pausa temporal · Roadmap

    Extensión propuesta: la prop `disabled` NO existe en ServiceCard (sí en CategoryCard). Para marcar un servicio sin disponibilidad 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 servicios sin agenda antes del .map, pero pierdes la señal visual de «existe pero no hoy».

  • Paquete combinado (servicio + add-ons)

    Bundles · Servicio principal + extras

    Extensión propuesta: un paquete («Consultoría + Implementación + 3 meses de soporte») no encaja en una sola line de descripción. Habría que añadir una prop `subItems: { label, included }[]` que pinte una lista compacta de chips o checks bajo la descripción, antes del CTA —preservando la altura uniforme del grid—. Hoy se documenta el paquete en una descripción larga, pero rompe la rejilla.

Responsive y móvil

Cómo se comporta en el teléfono

La card no se encoge: la rejilla decide. Default propuesto para servicios es 1 → 2 → 3 (los servicios son menos que los productos, no auto-fill); a partir de ahí, tres extensiones opcionales: scroll horizontal con snap, carrusel con dots, y stack a ancho completo con CTA sticky de WhatsApp.

En escritorio, la vitrina propuesta para servicios (.grid) usa 3 columnas en ≥1024px y 2 en ≥640px —servicios suelen ser 3-9, no 20+, así que auto-fill con cards muy estrechas pierde el contexto—. 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 (8-9 servicios), un scroll horizontal con snap evita el scroll vertical eterno. Para vitrinas curadas (3-5 servicios destacados), un carrusel con dots da contexto sobre cuántos hay. Para vitrinas cortas (3-4) en mobile, apilar a ancho completo con un CTA sticky verde de WhatsApp es más enganchador —el botón sticky es el alma misma del módulo: chatear sin volver al header—.

1 · De 3 columnas a una (default)

Lo que haría /servicios/index.astro al pintarse. La rejilla declara una columna en móvil y crece por breakpoints explícitos: 2 a ≥640px, 3 a ≥1024px. Mobile-first puro: cero breakpoints intermedios, cero JS. La card no se toca: el responsive vive en la rejilla del padre.

CSS · grid 1 → 2 → 3 mobile-first (default servicios)
/* MÓVIL · DE 1 A 2 A 3 (el patrón de servicios)
   Los servicios suelen ser MENOS que los productos (3-9 vs 20+),
   así que la rejilla termina en 3 columnas, no en auto-fill.
   Mobile-first: arranca en una columna y crece por breakpoints
   explícitos —da más control sobre cuándo aparece la 2ª y la 3ª—. */

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

@media (min-width: 640px) {
  .grid { grid-template-columns: repeat(2, 1fr); }    /* tablet: 2 */
}

@media (min-width: 1024px) {
  .grid { grid-template-columns: repeat(3, 1fr); }    /* escritorio: 3 */
}

2 · Scroll horizontal con snap (extensión)

Para catálogos largos (8-9 servicios), 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)
   Cuando el catálogo de servicios crece (6-9 en mobile) el stack
   vertical se hace largo. Una fila horizontal con snap deja al
   visitante deslizar 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);
    scrollbar-width: none;                /* Firefox */
  }
  .grid::-webkit-scrollbar { display: none; }  /* WebKit */

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

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

Para vitrinas curadas (3-5 servicios 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 card visible se manejan con un
   IntersectionObserver de 10 líneas. Sirve para «3 servicios
   destacados» —el 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: observe cada card y marca el dot correspondiente. */

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

Para vitrinas cortas (3-4 servicios) en móvil, apilar cada card a ancho completo —cada servicio como «hero»— y dejar un CTA sticky verde de WhatsApp 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. Es el patrón natural para servicios consultivos —donde la conversación pesa más que la lectura—.

CSS · stack + CTA sticky WhatsApp (móvil)
/* MÓVIL · STACK A ANCHO COMPLETO + CTA STICKY (extensión)
   Para catálogos cortos (3-4 servicios) en móvil, apilar cada
   card a ancho completo —cada servicio como «hero»— y dejar un
   CTA sticky al fondo (un botón verde de WhatsApp) recoge la
   intención sin obligar a volver al header. El sticky vive
   en el wrapper del padre, no en la card. */

@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: #25D366;                    /* verde WhatsApp */
  color: #fff;
  border-radius: var(--radius-md);
  font-family: var(--font-heading);
  font-weight: var(--weight-semibold);
  /* Safe-area en iOS: no quedar bajo el home indicator. */
  padding-bottom: max(var(--sp-3), env(safe-area-inset-bottom));
}

Posición

¿Dónde se coloca?

Una vez por servicio, siempre dentro de un .grid (1 → 2 → 3) en la futura vitrina del catálogo /servicios. NO se usa en la ficha L4 (/servicios/<slug>) para mostrar «relacionados» —ese papel lo cumple RelatedLinks, un componente aparte—.

A diferencia del CategoryDetail (que se apila N veces por página) o del Hero (uno por página), la ServiceCard aparece tantas veces como servicios haya en la vitrina —y SOLO en la vitrina—. Hoy el consumidor /servicios/index.astro NO existe en este repo (la collection servicios y los slugs en TAXONOMY ya están definidos, pero el grid se documenta antes de pintarse). Cuando se cree, cada card irá dentro del wrapper <code>.grid</code> propuesto (mobile-first: 1 columna → 2 a ≥640px → 3 a ≥1024px).

Lo que NO hace la card, por diseño: no aparece en la ficha L4 (ServiceLayout) para mostrar servicios relacionados. La ficha L4 usa <code>RelatedLinks</code>, un componente genérico tipo «más enlaces» que recibe <code>links: { label, href, desc }[]</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 ServiceCard 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, description, href (obligatorias), icon?, image?, imageAlt?, badge?, ctaLabel?, whatsapp?. Devuelve un <article> con un único <a> envolvente que contiene el visual (ícono O imagen 16:9), el badge opcional, el título H3, la descripción y el CTA inline o WhatsApp.

El componente vive en ServiceCard.astro y expone ocho props a propósito: title, description y href (obligatorias; href no está guardado, pasarlo vacío genera un enlace muerto silencioso), icon (SVG string opcional para la cabecera), image + imageAlt (imagen opcional que reemplaza al icono; el alt cae a title si no se pasa), badge (etiqueta superior, opcional), ctaLabel (default «Ver servicio») y whatsapp (default false; true → botón verde con ícono de WhatsApp + target=_blank). Cero JavaScript en el componente: HTML + CSS estático generado en build.

En el sitio futuro, lo más común será alimentarlo desde getCollection('servicios'). La página /servicios/index.astro filtraría drafts, ordenaría por data.order y mapearía cada servicio a una ServiceCard pasando un ícono pre-cargado por categoría (mapa SERVICES → ICONS) o cayendo al ícono por defecto. El padre del grid emitirá ItemList vía directorySchema (CollectionPage + lista), NO Service —Service es de la ficha L4—. La tercera receta (integración con RelatedLinks) documenta el caso particular de la ficha L4, donde NO se usa ServiceCard sino el componente de cross-linking genérico.

Astro · uso básico (hard-coded, modo ícono + modo WhatsApp)
---
// USO BÁSICO · una sola card hard-coded. La card requiere title +
// description + href. icon, image, badge y ctaLabel son opcionales.
// Si pasas whatsapp={true}, el CTA muta a botón verde y abre wa.me.
// Reglas duras: SVG con stroke="currentColor" + waUrl() para WhatsApp.
import ServiceCard from '@components/ServiceCard.astro'
import { waUrl, WA_MESSAGES } from '@config/site'

const iconShield = `<svg viewBox="0 0 24 24" width="28" height="28"
  fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
  <path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
  <path d="m9 12 2 2 4-4"/></svg>`
---

<section class="section">
  <div class="container">
    <!-- Modo ícono (catálogo por defecto) -->
    <ServiceCard
      icon={iconShield}
      title="Consultoría de seguridad industrial"
      description="Diagnóstico y acompañamiento técnico para alinear tu operación a la NOM-035-STPS. Respuesta en 24 h."
      href="/servicios/consultoria"
      badge="POPULAR"
      ctaLabel="Ver servicio"
    />

    <!-- Modo WhatsApp directo (venta consultiva) -->
    <ServiceCard
      icon={iconShield}
      title="Cotización urgente"
      description="¿Necesitas el alcance hoy? Cotizamos por chat sin pasar por formulario."
      href={waUrl(WA_MESSAGES.urgente)}
      ctaLabel="Cotizar por WhatsApp"
      whatsapp
    />
  </div>
</section>
Astro · data-driven desde getCollection('servicios') (vitrina del catálogo)
---
// DATA-DRIVEN DESDE getCollection · cómo lo consumiría
// /servicios/index.astro (no existe aún; la collection y el schema sí).
// La fuente única es la colección `servicios` (src/content/servicios/*.md),
// validada por Zod en src/content.config.ts. Filtrá drafts, ordená por
// order ASC. Cada servicio puede traer su ícono pre-cargado en data.icon
// (string SVG) o caer al ícono por categoría del SERVICES de site.ts.
import { getCollection, getEntry } from 'astro:content'
import ServiceCard from '@components/ServiceCard.astro'
import { SERVICES } from '@config/site'

// Mapa de íconos por categoría — el fallback cuando un servicio no trae el
// suyo. SERVICES vive en TAXONOMY (site.ts) → no se hardcodea aquí.
const ICONS: Record<string, string> = {
  consultoria:    iconShield,
  implementacion: iconRocket,
  soporte:        iconWrench,
}

// getEntry hidrata relaciones (relatedServices, hero) si se necesitan; aquí
// con la collection basta para el grid.
const servicios = (await getCollection('servicios', ({ data }) => !data.draft))
  .sort((a, b) => (a.data.order ?? 0) - (b.data.order ?? 0))
---

<div class="grid">
  {servicios.map((s) => (
    <ServiceCard
      title={s.data.title}
      description={s.data.description}
      href={`/servicios/${s.id}`}
      icon={ICONS[s.data.category]}
      image={s.data.image}
      imageAlt={s.data.title}
      badge={s.data.featured ? 'POPULAR' : undefined}
    />
  ))}
</div>

<style>
  /* Vitrina del catálogo de servicios: 1 → 2 → 3 por fila (servicios suelen
     ser menos que productos; 3 columnas es el sweet spot). */
  .grid { display: grid; grid-template-columns: 1fr; gap: var(--sp-5); }
  @media (min-width: 640px)  { .grid { grid-template-columns: repeat(2, 1fr); } }
  @media (min-width: 1024px) { .grid { grid-template-columns: repeat(3, 1fr); } }
</style>
Astro · integración con RelatedLinks (cross-linking dentro de la ficha L4)
---
// INTEGRACIÓN CON RelatedLinks · servicios relacionados dentro de una FICHA L4
// (ServiceLayout). En la ficha L4 NO se usan ServiceCard para «relacionados»:
// se usa RelatedLinks, un componente genérico de cross-linking (PROYECTORED:
// CategoriasRelacionadas.astro) que toma una lista plana de { label, href, desc }
// y los pinta como tiles compactos. Evita el bucle «card de catálogo dentro de
// ficha de catálogo» y deja a ServiceCard cumpliendo un solo papel: vitrina.
import { getEntry } from 'astro:content'
import RelatedLinks from '@components/RelatedLinks.astro'

// El servicio actual y sus relacionados (referencias tipadas vía Zod).
const servicio = await getEntry('servicios', Astro.params.slug as string)
if (!servicio) return Astro.redirect('/404')

// relatedServices es array<reference('servicios')>; getEntry resuelve cada uno.
const relacionados = await Promise.all(
  (servicio.data.relatedServices ?? []).map((ref) => getEntry(ref))
)

const links = relacionados
  .filter((s): s is NonNullable<typeof s> => !!s)
  .map((s) => ({
    label: s.data.title,
    href: `/servicios/${s.id}`,
    desc: s.data.description,
  }))
---

<RelatedLinks
  title="Servicios relacionados"
  desc="Otros servicios que suelen combinarse con éste."
  links={links}
  limit={3}
/>

En concreto: el componente recibe las props y emite un <article class="scard" aria-label={title}> que contiene (si hay image) un <div class="scard__media"> con la <img> 640×360 + overlay + badge absoluto; o (si NO hay image pero hay icon) un <div class="scard__icon"> con el SVG inline y la caja de 56×56 fondo rojo claro; luego un <div class="scard__body"> con el badge inline (si no hubo imagen y se pasó badge), el <h3> del título, la <p> de descripción y el CTA: un <a> inline rojo con flecha SVG en hover-anim, O —si whatsapp=true— un <a> con clase .scard__cta--wa que es botón verde con ícono de WhatsApp y abre en pestaña nueva.

La accesibilidad es de fábrica: el contenedor <article> lleva aria-label con el título; el overlay, el ícono y la flecha son aria-hidden; el foco visible vive en .scard__cta:focus-visible con outline de marca. La única transición —color y gap del CTA en hover— se desactiva bajo prefers-reduced-motion: reduce; el CTA WhatsApp NO anima el gap (es estático, solo cambia el verde en hover). 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 Service vive en lib/seo.ts y solo lo invoca la ficha L4 (ServiceLayout); el grid emitirá ItemList vía directorySchema cuando exista. Un único emisor por página (regla B3), cero schema duplicado.

Buenas prácticas

Qué hacer y qué evitar

La diferencia entre una vitrina de servicios coherente y una que se siente «hecha por cards distintas» cabe en un puñado de hábitos —empezando por usar íconos por defecto (no fotos genéricas) y por reservar el CTA verde para servicios consultivos—.

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: Service schema vive en lib/seo.ts y solo la ficha L4 lo emite; el grid emitirá ItemList. Pasar todo por la collection (Zod) y filtrar drafts antes del map asegura que solo lleguen servicios completos a la vitrina.

La buena noticia es que casi todo se sostiene solo cuando se respeta la collection: la descripción se valida 70-280 chars, los íconos se mapean por categoría desde SERVICES, y el CTA WhatsApp se arma con waUrl() + WA_MESSAGES (regla D4). 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

  • Usa ÍCONOS por defecto: pásale a `icon` un SVG inline (string) curado por categoría —escudo para seguridad, llave para soporte, cohete para implementación—. Es la cara natural del catálogo de servicios.
  • Pasa `image` solo cuando una foto aporta algo concreto: el resultado visible de un servicio (un evento montado, una instalación terminada). Si la foto es genérica (laptop + manos), mejor déjala fuera —el ícono comunica más con menos peso—.
  • Reusa `badge` para señales cortas: una norma («NOM-035-STPS»), una categoría («Implementación») o un gancho («Popular», «Nuevo»). En MAYÚSCULAS, ≤ 18 chars; se aplica igual sobre imagen o inline arriba del título.
  • Mantén la `description` entre 70 y 280 caracteres (lo que valida Zod en la collection servicios); más allá la card crece de altura y rompe la rejilla.
  • Pasa `whatsapp={true}` SOLO cuando el siguiente paso real es chatear (cotización, urgencia, agenda). Para servicios donde primero conviene leer la ficha (catálogo público, descripción larga), deja el CTA estándar a la ficha L4.
  • Cuando uses `whatsapp={true}`, arma el href con `waUrl(WA_MESSAGES.<intencion>)` —NUNCA un número hardcodeado (regla D4)—. El componente abre automáticamente con `target="_blank"` + `rel="noopener noreferrer"`.

Mejor evita

  • NO pases `href=""`: el enlace queda muerto sin warning. Si el servicio aún no tiene ficha L4, filtrá antes en el .map del padre o pasa `whatsapp={true}` con un href real de WhatsApp.
  • NO mezclés en la misma rejilla cards CON imagen y cards CON ícono: las alturas se desigualan y la rejilla pierde ritmo. Decide UNA política por catálogo —todo ícono o todo foto— y aplícala a la fila entera.
  • NO uses la card para mostrar pricing detallado, comparativas o protocolos: para eso existe la ficha L4 (ServiceLayout, schema-driven). La card es promo de un solo clic.
  • NO emitas Service JSON-LD en la página padre del grid: el grid emite ItemList vía directorySchema (regla B3 — un solo emisor por página). Service solo lo emite la ficha L4.
  • NO pongas íconos con stroke fijo en negro: el componente fuerza `color` del ícono a la marca vía CSS (`var(--color-red)`), así que el SVG debe usar `stroke="currentColor"` para heredarlo —si pasas un stroke duro, el ícono no respeta el tema—.
  • NO uses la variante WhatsApp para todo: si TODAS las cards llevan botón verde, el patrón se gasta y deja de leerse como «atajo a chat». Reservala para 1-2 servicios destacados del catálogo.

En vivo

El componente, en su uso real

Las galerías de variantes son mockups a escala; estas cards NO. Aquí se renderizan tres ServiceCard reales con sus props llenas, idénticos a los que verás en /servicios cuando se pinte el grid.

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 tres cards cubren los tres modos del componente: modo ÍCONO con badge, modo ÍCONO con CTA WhatsApp directo, y modo IMAGEN 16:9 (vitrina). Los hrefs apuntan a rutas del catálogo (que aún no existen como L4 publicadas; pasar un href vacío sería el antipatrón documentado en §6). La rejilla replica el .grid propuesto (1 → 2 → 3).

POPULAR

Consultoría de seguridad industrial

Diagnóstico y acompañamiento técnico para alinear tu operación a la NOM-035-STPS. Respuesta en 24 h.

Ver servicio

Cotización urgente por WhatsApp

¿Necesitas el alcance hoy? Cotizamos por chat sin pasar por formulario. Respuesta en minutos.

Implementación llave en mano de un sitio Astro profesional IMPLEMENTACIÓN

Implementación llave en mano

Construcción completa del sitio con tu contenido y tu diseño. Entregamos publicado y con respaldo.

Ver servicio
¿Necesitas ayuda?