Ir al contenido

Componentes React específicos para el sistema de cursos, ubicados en app/src/courses/components/.

Tarjeta para mostrar un curso en el catalogo.

courses/components/CourseCard.tsx
import { Card, CardContent, CardFooter } from "../../client/components/ui/card";
import { Badge } from "../../client/components/ui/badge";
import { Button } from "../../client/components/ui/button";
import type { Course } from "wasp/entities";
interface CourseCardProps {
course: Course & { lessons: { id: number }[] };
enrollment?: { completedAt: Date | null };
}
export function CourseCard({ course, enrollment }: CourseCardProps) {
const lessonCount = course.lessons.length;
const isEnrolled = !!enrollment;
return (
<Card className="overflow-hidden hover:shadow-lg transition-shadow">
{/* Thumbnail */}
<div className="aspect-video relative">
<img
src={course.thumbnail || "/placeholder-course.jpg"}
alt={course.title}
className="object-cover w-full h-full"
/>
<Badge className="absolute top-2 right-2">{course.difficulty}</Badge>
</div>
<CardContent className="p-4">
{/* Categoría */}
<p className="text-sm text-muted-foreground mb-1">{course.category}</p>
{/* Título */}
<h3 className="font-semibold text-lg line-clamp-2 mb-2">{course.title}</h3>
{/* descripción */}
<p className="text-sm text-muted-foreground line-clamp-2 mb-3">{course.description}</p>
{/* Metadatos */}
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<span>{lessonCount} lecciones</span>
<span></span>
<span>{formatDuration(course.totalDuration)}</span>
</div>
</CardContent>
<CardFooter className="p-4 pt-0 flex justify-between items-center">
{isEnrolled ? (
<Button asChild className="w-full">
<Link to={`/courses/${course.slug}/learn`}>Continuar</Link>
</Button>
) : (
<>
<span className="font-bold text-lg">${(course.price / 100).toFixed(2)}</span>
<Button asChild>
<Link to={`/courses/${course.slug}`}>Ver Curso</Link>
</Button>
</>
)}
</CardFooter>
</Card>
);
}

Grid responsivo para mostrar cursos.

courses/components/CourseGrid.tsx
import { CourseCard } from "./CourseCard";
import type { Course, Enrollment } from "wasp/entities";
interface CourseGridProps {
courses: (Course & { lessons: { id: number }[] })[];
enrollments?: Map<number, Enrollment>;
emptyMessage?: string;
}
export function CourseGrid({
courses,
enrollments,
emptyMessage = "No hay cursos disponibles",
}: CourseGridProps) {
if (courses.length === 0) {
return (
<div className="text-center py-12">
<p className="text-muted-foreground">{emptyMessage}</p>
</div>
);
}
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{courses.map(course => (
<CourseCard key={course.id} course={course} enrollment={enrollments?.get(course.id)} />
))}
</div>
);
}

Indicador circular de progreso.

courses/components/ProgressRing.tsx
interface ProgressRingProps {
progress: number; // 0-100
size?: number;
strokeWidth?: number;
className?: string;
}
export function ProgressRing({
progress,
size = 60,
strokeWidth = 4,
className,
}: ProgressRingProps) {
const radius = (size - strokeWidth) / 2;
const circumference = radius * 2 * Math.PI;
const offset = circumference - (progress / 100) * circumference;
return (
<div className={cn("relative", className)} style={{ width: size, height: size }}>
<svg className="transform -rotate-90" width={size} height={size}>
{/* Background circle */}
<circle
className="text-muted stroke-current"
strokeWidth={strokeWidth}
fill="none"
r={radius}
cx={size / 2}
cy={size / 2}
/>
{/* Progress circle */}
<circle
className="text-primary stroke-current transition-all duration-300"
strokeWidth={strokeWidth}
strokeLinecap="round"
fill="none"
r={radius}
cx={size / 2}
cy={size / 2}
style={{
strokeDasharray: circumference,
strokeDashoffset: offset,
}}
/>
</svg>
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-sm font-medium">{Math.round(progress)}%</span>
</div>
</div>
);
}

Reproductor de video con tracking de progreso.

courses/components/VideoPlayer.tsx
import { useRef, useEffect } from "react";
import { updateLessonProgress } from "wasp/client/operations";
import type { Lesson } from "wasp/entities";
interface VideoPlayerProps {
lesson: Lesson;
signedUrl: string;
initialTime?: number;
onComplete?: () => void;
}
export function VideoPlayer({ lesson, signedUrl, initialTime = 0, onComplete }: VideoPlayerProps) {
const videoRef = useRef<HTMLVideoElement>(null);
const lastSavedTime = useRef(initialTime);
// Guardar progreso cada 10 segundos
useEffect(() => {
const video = videoRef.current;
if (!video) return;
const handleTimeUpdate = async () => {
const currentTime = Math.floor(video.currentTime);
// Solo guardar si han pasado al menos 10 segundos
if (currentTime - lastSavedTime.current >= 10) {
lastSavedTime.current = currentTime;
await updateLessonProgress({
lessonId: lesson.id,
watchedSeconds: currentTime,
isCompleted: false,
});
}
};
const handleEnded = async () => {
await updateLessonProgress({
lessonId: lesson.id,
watchedSeconds: lesson.duration,
isCompleted: true,
});
onComplete?.();
};
video.addEventListener("timeupdate", handleTimeUpdate);
video.addEventListener("ended", handleEnded);
return () => {
video.removeEventListener("timeupdate", handleTimeUpdate);
video.removeEventListener("ended", handleEnded);
};
}, [lesson.id, lesson.duration, onComplete]);
// Restaurar posicion inicial
useEffect(() => {
const video = videoRef.current;
if (video && initialTime > 0) {
video.currentTime = initialTime;
}
}, [initialTime]);
return (
<div className="aspect-video bg-black rounded-lg overflow-hidden">
<video
ref={videoRef}
src={signedUrl}
controls
className="w-full h-full"
controlsList="nodownload"
>
Tu navegador no soporta video HTML5.
</video>
</div>
);
}

Sidebar con lista de lecciones y progreso.

courses/components/LessonSidebar.tsx
import { CheckCircle, Circle, Play, Lock } from "lucide-react";
import { cn } from "../../shared/utils";
import type { Lesson, LessonProgress } from "wasp/entities";
interface LessonSidebarProps {
lessons: Lesson[];
progress: Map<number, LessonProgress>;
currentLessonId: number;
hasAccess: boolean;
onLessonSelect: (lesson: Lesson) => void;
}
export function LessonSidebar({
lessons,
progress,
currentLessonId,
hasAccess,
onLessonSelect,
}: LessonSidebarProps) {
return (
<div className="w-80 border-l bg-card overflow-y-auto">
<div className="p-4 border-b">
<h3 className="font-semibold">Contenido del Curso</h3>
<p className="text-sm text-muted-foreground">
{progress.size} / {lessons.length} completadas
</p>
</div>
<div className="divide-y">
{lessons.map(lesson => {
const lessonProgress = progress.get(lesson.id);
const isCompleted = lessonProgress?.isCompleted;
const isCurrent = lesson.id === currentLessonId;
const isLocked = !hasAccess && !lesson.isPreview;
return (
<button
key={lesson.id}
onClick={() => !isLocked && onLessonSelect(lesson)}
disabled={isLocked}
className={cn(
"w-full p-4 text-left hover:bg-accent transition-colors",
isCurrent && "bg-accent",
isLocked && "opacity-50 cursor-not-allowed"
)}
>
<div className="flex items-start gap-3">
{/* Icono de estado */}
<div className="mt-0.5">
{isLocked ? (
<Lock className="w-5 h-5 text-muted-foreground" />
) : isCompleted ? (
<CheckCircle className="w-5 h-5 text-green-500" />
) : isCurrent ? (
<Play className="w-5 h-5 text-primary" />
) : (
<Circle className="w-5 h-5 text-muted-foreground" />
)}
</div>
{/* Info de la leccion */}
<div className="flex-1 min-w-0">
<p className={cn("font-medium truncate", isCurrent && "text-primary")}>
{lesson.order}. {lesson.title}
</p>
<p className="text-sm text-muted-foreground">
{formatDuration(lesson.duration)}
{lesson.isPreview && <span className="ml-2 text-green-600">Gratis</span>}
</p>
</div>
</div>
</button>
);
})}
</div>
</div>
);
}
function formatDuration(seconds: number): string {
const minutes = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${minutes}:${secs.toString().padStart(2, "0")}`;
}

Botón de inscripción con estados.

courses/components/EnrollButton.tsx
import { useState } from "react";
import { Button } from "../../client/components/ui/button";
import { createCourseCheckout } from "wasp/client/operations";
import type { Course } from "wasp/entities";
interface EnrollButtonProps {
course: Course;
isEnrolled: boolean;
hasSubscription: boolean;
}
export function EnrollButton({ course, isEnrolled, hasSubscription }: EnrollButtonProps) {
const [isLoading, setIsLoading] = useState(false);
if (isEnrolled) {
return (
<Button asChild size="lg" className="w-full">
<Link to={`/courses/${course.slug}/learn`}>Continuar Aprendiendo</Link>
</Button>
);
}
if (hasSubscription) {
// Usuario con suscripción - enrollment automatico
return (
<Button size="lg" className="w-full" onClick={handleAutoEnroll} disabled={isLoading}>
{isLoading ? "Inscribiendo..." : "Comenzar Curso"}
</Button>
);
}
// Usuario sin acceso - mostrar precio
const handleCheckout = async () => {
setIsLoading(true);
try {
const { url } = await createCourseCheckout({ courseId: course.id });
window.location.href = url;
} catch (error) {
console.error("Error creating checkout:", error);
} finally {
setIsLoading(false);
}
};
return (
<div className="space-y-3">
<Button size="lg" className="w-full" onClick={handleCheckout} disabled={isLoading}>
{isLoading ? "Procesando..." : `Comprar por $${(course.price / 100).toFixed(2)}`}
</Button>
<p className="text-sm text-center text-muted-foreground">
o{" "}
<Link to="/pricing" className="underline">
suscribete
</Link>{" "}
para acceso ilimitado
</p>
</div>
);
}

Filtro de categorias para el catalogo.

courses/components/CategoryFilter.tsx
import { Button } from "../../client/components/ui/button";
import { cn } from "../../shared/utils";
interface CategoryFilterProps {
categories: string[];
selected: string | null;
onSelect: (category: string | null) => void;
}
export function CategoryFilter({ categories, selected, onSelect }: CategoryFilterProps) {
return (
<div className="flex flex-wrap gap-2">
<Button
variant={selected === null ? "default" : "outline"}
size="sm"
onClick={() => onSelect(null)}
>
Todos
</Button>
{categories.map(category => (
<Button
key={category}
variant={selected === category ? "default" : "outline"}
size="sm"
onClick={() => onSelect(category)}
>
{category}
</Button>
))}
</div>
);
}
courses/CoursesPage.tsx
import { useQuery, getCourses } from "wasp/client/operations";
import { CourseGrid } from "./components/CourseGrid";
import { CategoryFilter } from "./components/CategoryFilter";
export function CoursesPage() {
const { data: courses, isLoading } = useQuery(getCourses);
const [category, setCategory] = useState<string | null>(null);
const filteredCourses = category ? courses?.filter(c => c.category === category) : courses;
const categories = [...new Set(courses?.map(c => c.category) || [])];
return (
<div className="container mx-auto py-8">
<h1 className="text-3xl font-bold mb-2">Cursos</h1>
<p className="text-muted-foreground mb-6">
Aprende Data Engineering e IA con nuestros cursos
</p>
<CategoryFilter categories={categories} selected={category} onSelect={setCategory} />
<div className="mt-6">
{isLoading ? <CourseGridSkeleton /> : <CourseGrid courses={filteredCourses || []} />}
</div>
</div>
);
}