Skip to content

Componentes React especificos 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">
{/* 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>
);
}

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')}`;
}

Boton de inscripcion 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={`/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>
);
}

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>
);
}