Componentes de Cursos
Sección titulada «Componentes de Cursos»Componentes React específicos para el sistema de cursos, ubicados en app/src/courses/components/.
CourseCard
Sección titulada «CourseCard»Tarjeta para mostrar un curso en el catalogo.
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> );}CourseGrid
Sección titulada «CourseGrid»Grid responsivo para mostrar cursos.
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> );}ProgressRing
Sección titulada «ProgressRing»Indicador circular de progreso.
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> );}VideoPlayer
Sección titulada «VideoPlayer»Reproductor de video con tracking de progreso.
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> );}LessonSidebar
Sección titulada «LessonSidebar»Sidebar con lista de lecciones y progreso.
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")}`;}EnrollButton
Sección titulada «EnrollButton»Botón de inscripción con estados.
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> );}CategoryFilter
Sección titulada «CategoryFilter»Filtro de categorias para el catalogo.
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> );}Uso en Paginas
Sección titulada «Uso en Paginas»CoursesPage
Sección titulada «CoursesPage»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> );}