Fecha publicación: 24 jun 2025

07. Listado de cursos con diseño

Ajuste del modelo de datos

Vamos a por el diseño, o también lo que yo llamo el martillo fino, podemos plantear tener un card por cada curso, y aquí es donde empiezan a salir temas que no tuvimos en cuenta, por ejemplo: tal y como hemos planteado el sitio, al pinchar en el card de curso deberíamos de navegar al curso, en concreto a la primera lección del mismo.

Para ver esta problemática vamos a depurar y ver que nos trae la llamada a la API de cursos, de paso aprovechamos y aprendemos a depurar código de servidor en Astro:

  • Abrimos terminal de JavaScript debug terminal, y vemos ue la llamada para traernos los trainings nos devuelve algo así como:
[
  {
    id: "6841b7748d728cb210857459",
    language: "es",
    title: "Curso Zustand",
    thumbnail: {
      name: "logozustand.jpeg",
      url: "https://d2gr4gsp182xcm.cloudfront.net/exampleorg%2Fcampus%2Ftraining%2Fcurso-zustand%2Flogozustand.jpeg",
    },
    overview: `¿Quieres aprender a gestionar ...`,
    lessons: [
      "684186108d728cb21085743a",
      "684186fe8d728cb21085743e",
      "684187898d728cb210857442",
    ],
    slug: "zustand",
  },
  // (...)
];

Es decir, cada curso trae un array de ids de lecciones, pero no trae los datos de las lecciones, sólo los ids.

Esto lo podríamos solucionar de varias maneras:

  • Una podría ser incluir el slug(fragmento amigable url que identifica a una lección) de la primera lección directamente en la entidad curso, esto es muy óptimo, pero tenemos que tener cuidado de que si cambiamos el slug de la lección, también tenemos que cambiarlo en el curso.

  • Otra podría ser traernos los datos de la primera lección que tenemos en el array de ids de lecciones que proporciona el curso.

  • Por último podemos traernos todos los datos de las lecciones de un curso.

Si pones mente de desarrollador puedes pensar que la tercer opción es la peor, peeeeroooo:

  • Por un lado estamos trabajando con Static Site Generation (SSG), con lo que esto sólo afecta al tiempo de build. del proyecto.

  • Por otro lado, podemos reaprovechar esta llamada para la página de detalle de un curso.

Yo diría que salvo que tengamos un número muy elevado de lecciones por curso, la tercera opción tiene un buen balance entre complejidad y rendimiento, y si un día necesitamos optimizarlo, siempre podemos volver y usar la primera o segunda opción.

Así que vamos a:

Traernos de Content Island el interfaz que define una lección y ponerlo en la definición del modelo (podemos pinchar en el icono de TypeScript que hay en la entidad Lesson para que nos genere el código).

Nota: Añadir el código que viene a continuación al final del archivo src/api/model.ts el interfaz Lesson que define una lección.

./api/model.ts

export interface Lesson {
  id: string;
  language: string;
  title: string;
  slug: string;
  content: string;
  video: string;
}

Y ahora vamos a crear una entidad que vamos a llamar TrainingWithLessons, que va heredar de Training y vamos a cambiar el tipo de lessons para que sea un array de Lesson en vez de un array de string.

Añadir este código al final del archivo model.ts

./api/model.ts

export interface TrainingWithLessons extends Omit<Training, "lessons"> {
  lessons: Lesson[];
}

Y ahora volvemos a la API de cursos.

Lo primero creamos una función para traernos el contenido de una lista de lecciones:

./api/training.api.ts

- import type { Training} from "./model";
+ import type { Training, TrainingWithLessons, Lesson } from "./model";

./api/training.api.ts

export async function getLessons(ids: string[]): Promise<Lesson[]> {
  return await client.getContentList<Lesson>({
    contentType: "Lesson",
    id: { in: ids },
  });
}

Otra alternativa para escribir esta función, es con fat arrow:

No copiar este código o machacar el anterior, es una alternativa.

export const getLessons = (ids: string[]): Promise<Lesson[]> =>
  client.getContentList<Lesson>({
    contentType: "lesson",
    id: { in: ids },
  });

Y ahora cambiamos la firma de la función getTrainings para que devuelva un array de TrainingWithLessons, y construyamos para cada curso un objeto que incluya las lecciones completas en vez de sólo los ids.:

./api/training.api.ts

- export async function getTrainings(): Promise<Training[]> {
+ export async function getTrainings(): Promise<TrainingWithLessons[]> {
  // Fetch the list of trainings from the API
-  return await client.getContentList<Training>({ contentType: "training" });
+  const trainings = await client.getContentList<Training>({
+    contentType: "training",
+
+  // For each training, fetch its lessons and return the enriched object
+  const trainingsWithLessons = await Promise.all(
+    trainings.map(async (training) => {
+      // Fetch the lesson objects using the stored lesson IDs
+      const lessons = await getLessons(training.lessons);
+
+      // Return a new object that includes full lesson data
+      return {
+        ...training,
+        lessons,
+      };
+    }),
+  );
+
+  // Return the final list of trainings with full lessons
+  return trainingsWithLessons;
}

Añadiendo diseño con Tailwind

Vamos a organizar un poco el código y a aplicar diseño con Tailwind.

Lo primero que vamos a hacer es componentizar la lista de cursos, vamos a crear un componente card que nos sirva para mostrar cada curso.

OJO este componente no lo podemos colocar debajo de pages porque lo tomaría como una página, así que lo colocamos en src/components.

./src/components/trainings/training-card.astro

---
import type { TrainingWithLessons } from "@/api";

export interface Props {
  training: TrainingWithLessons;
}

const { training } = Astro.props;
---

<a
  href={`/training/${training.slug}/${training.lessons[0].slug}`}
  class="group block transform transition-transform duration-300 hover:scale-[1.02] focus:outline-none focus:ring-2 focus:ring-blue-500"
>
  <div
    class="relative bg-white rounded-3xl overflow-hidden shadow-md hover:shadow-2xl transition-shadow duration-300 w-full border border-gray-100"
  >
    <div class="overflow-hidden relative">
      <img
        src={training.thumbnail.url}
        alt={training.title}
        class="w-full h-48 object-cover transition-transform duration-500 group-hover:scale-105"
      />
      <div
        class="absolute inset-0 bg-gradient-to-t from-black/50 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300"
      >
      </div>
    </div>

    <div class="p-5">
      <h2 class="text-xl font-bold text-gray-900 leading-snug line-clamp-2">
        {training.title}
      </h2>
    </div>
  </div>
</a>

Y vamos a mostrarlo en la página de cursos.

./src/index.astro

---
import Layout from "../layouts/Layout.astro";
+ import TrainingCard from "@/components/trainings/training-card.astro";
import { getTrainings } from "./trainings";

const trainings = await getTrainings();
---

Y sustituimos todo el HTML

<Layout title="Listado de Cursos">
  <div class="container mx-auto px-4">
    <h1 class="text-2xl font-bold mb-4">Cursos Disponibles</h1>
    <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
      {trainings.map((training) => <TrainingCard training={training} />)}
    </div>
  </div>
</Layout>

Aquí lo que hacemos es crear un layout que nos da un contenedor y un título, y dentro de este layout mostramos una cuadrícula de tarjetas de cursos, usando el componente TrainingCard que acabamos de crear (iteramos sobre el array de trainings y por cada uno renderizamos una tarjeta).

Ya lo tenemos, pero si pinchamos en un curso nos da un 404, ¿Qué está pasando? Que nos hace falta crear la página de detalle del curso, así que en el siguiente vídeo veremos cómo crear la página de detalle de un curso, donde mostraremos, el video, las lecciones y la guía en texto que acompaña al curso.