Componentes de Cursos
Section titled “Componentes de Cursos”Componentes React especificos para el sistema de cursos, ubicados en app/src/courses/components/.
CourseCard
Section titled “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"> {/* Categoria */} <p className="text-sm text-muted-foreground mb-1"> {course.category} </p>
{/* Titulo */} <h3 className="font-semibold text-lg line-clamp-2 mb-2"> {course.title} </h3>
{/* Descripcion */} <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={`/curso/${course.slug}/aprender`}> Continuar </Link> </Button> ) : ( <> <span className="font-bold text-lg"> ${(course.price / 100).toFixed(2)} </span> <Button asChild> <Link to={`/curso/${course.slug}`}> Ver Curso </Link> </Button> </> )} </CardFooter> </Card> );}CourseGrid
Section titled “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
Section titled “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
Section titled “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
Section titled “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
Section titled “EnrollButton”Boton de inscripcion 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={`/curso/${course.slug}/aprender`}> Continuar Aprendiendo </Link> </Button> ); }
if (hasSubscription) { // Usuario con suscripcion - 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="/precios" className="underline">suscribete</Link> para acceso ilimitado </p> </div> );}CategoryFilter
Section titled “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
Section titled “Uso en Paginas”CoursesPage
Section titled “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> );}