--- id: "ET-EDU-003" title: "Frontend Components Education" type: "Specification" status: "Done" rf_parent: "RF-EDU-002" epic: "OQI-002" version: "1.0" created_date: "2025-12-05" updated_date: "2026-01-04" --- # ET-EDU-003: Componentes Frontend - React + TypeScript **Versión:** 1.0.0 **Fecha:** 2025-12-05 **Épica:** OQI-002 - Módulo Educativo **Componente:** Frontend --- ## Descripción Define la arquitectura frontend del módulo educativo usando React 18, TypeScript, Zustand para state management, TailwindCSS para estilos, y React Query para data fetching. Incluye páginas, componentes, hooks personalizados y stores. --- ## Arquitectura ``` ┌─────────────────────────────────────────────────────────────────┐ │ Frontend Architecture │ ├─────────────────────────────────────────────────────────────────┤ │ │ │ ┌──────────────────────────────────────────────────────────┐ │ │ │ Pages │ │ │ │ - CoursesPage (lista/catálogo) │ │ │ │ - CourseDetailPage (detalle del curso) │ │ │ │ - LessonPage (reproductor de lección) │ │ │ │ - ProgressPage (dashboard de progreso) │ │ │ │ - QuizPage (evaluaciones) │ │ │ │ - CertificatesPage (certificados) │ │ │ │ - AchievementsPage (logros y gamificación) │ │ │ └──────────────────┬───────────────────────────────────────┘ │ │ │ │ │ v │ │ ┌──────────────────────────────────────────────────────────┐ │ │ │ Components │ │ │ │ Courses: │ │ │ │ - CourseCard, CourseGrid, CourseFilters │ │ │ │ - CourseHeader, CourseCurriculum, CourseInstructor │ │ │ │ Lessons: │ │ │ │ - LessonPlayer, LessonNavigation, LessonResources │ │ │ │ Progress: │ │ │ │ - ProgressBar, ProgressStats, ModuleProgress │ │ │ │ Quizzes: │ │ │ │ - QuizQuestion, QuizResults, QuizTimer │ │ │ │ Gamification: │ │ │ │ - XPBar, LevelBadge, AchievementCard, Leaderboard │ │ │ └──────────────────┬───────────────────────────────────────┘ │ │ │ │ │ v │ │ ┌──────────────────────────────────────────────────────────┐ │ │ │ Custom Hooks │ │ │ │ - useCourses, useCourseDetail │ │ │ │ - useEnrollment, useProgress │ │ │ │ - useQuiz, useQuizAttempt │ │ │ │ - useGamification, useAchievements │ │ │ │ - useVideoPlayer, useVideoProgress │ │ │ └──────────────────┬───────────────────────────────────────┘ │ │ │ │ │ v │ │ ┌──────────────────────────────────────────────────────────┐ │ │ │ Zustand Stores │ │ │ │ - courseStore (cursos y catálogo) │ │ │ │ - enrollmentStore (enrollments del usuario) │ │ │ │ - progressStore (progreso de lecciones) │ │ │ │ - quizStore (estado de quizzes) │ │ │ │ - gamificationStore (XP, nivel, logros) │ │ │ └──────────────────┬───────────────────────────────────────┘ │ │ │ │ │ v │ │ ┌──────────────────────────────────────────────────────────┐ │ │ │ API Client │ │ │ │ - axios instance con interceptors │ │ │ │ - React Query para caching │ │ │ └──────────────────┬───────────────────────────────────────┘ │ │ │ │ │ v │ │ ┌──────────────────────────────────────────────────────────┐ │ │ │ Backend API (REST) │ │ │ └──────────────────────────────────────────────────────────┘ │ │ │ └─────────────────────────────────────────────────────────────────┘ ``` --- ## Especificación Detallada ### 1. PÁGINAS #### CoursesPage.tsx Catálogo de cursos con filtros y búsqueda. ```typescript import React from 'react'; import { useCourses } from '@/hooks/useCourses'; import { CourseGrid } from '@/components/courses/CourseGrid'; import { CourseFilters } from '@/components/courses/CourseFilters'; import { SearchBar } from '@/components/common/SearchBar'; import { Pagination } from '@/components/common/Pagination'; export const CoursesPage: React.FC = () => { const [filters, setFilters] = React.useState({ category: '', difficulty: '', is_free: undefined, search: '', sort_by: 'newest' as const, page: 1, limit: 20 }); const { data, isLoading, error } = useCourses(filters); const handleFilterChange = (key: string, value: any) => { setFilters(prev => ({ ...prev, [key]: value, page: 1 })); }; const handlePageChange = (page: number) => { setFilters(prev => ({ ...prev, page })); }; return (
{/* Header */}

Cursos de Trading

Aprende trading profesional con nuestros cursos especializados

{/* Search */}
handleFilterChange('search', value)} placeholder="Buscar cursos..." />
{/* Filters and Grid */}
{/* Sidebar Filters */}
{/* Course Grid */}
{isLoading && } {error && } {data && ( <>

{data.pagination.total} cursos encontrados

handleFilterChange('sort_by', value)} />
{data.pagination.total_pages > 1 && (
)} )}
); }; ``` #### CourseDetailPage.tsx Página de detalle del curso con curriculum y opción de enrollment. ```typescript import React from 'react'; import { useParams } from 'react-router-dom'; import { useCourseDetail } from '@/hooks/useCourseDetail'; import { useEnrollment } from '@/hooks/useEnrollment'; import { CourseHeader } from '@/components/courses/CourseHeader'; import { CourseCurriculum } from '@/components/courses/CourseCurriculum'; import { CourseInstructor } from '@/components/courses/CourseInstructor'; import { CourseSidebar } from '@/components/courses/CourseSidebar'; export const CourseDetailPage: React.FC = () => { const { slug } = useParams<{ slug: string }>(); const { data: course, isLoading } = useCourseDetail(slug!); const { enrollMutation } = useEnrollment(); const handleEnroll = async () => { if (!course) return; try { await enrollMutation.mutateAsync({ course_id: course.id }); // Redirect to first lesson } catch (error) { // Show error toast } }; if (isLoading) return ; if (!course) return ; return (
{/* Course Header */}
{/* Main Content */}
{/* Description */}

Descripción

{/* Learning Objectives */}

¿Qué aprenderás?

    {course.learning_objectives.map((objective, index) => (
  • {objective}
  • ))}
{/* Curriculum */}

Contenido del curso

{/* Instructor */}

Instructor

{/* Sidebar */}
); }; ``` #### LessonPage.tsx Reproductor de lección con tracking de progreso. ```typescript import React from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import { useLessonDetail } from '@/hooks/useLessonDetail'; import { useVideoProgress } from '@/hooks/useVideoProgress'; import { LessonPlayer } from '@/components/lessons/LessonPlayer'; import { LessonNavigation } from '@/components/lessons/LessonNavigation'; import { LessonResources } from '@/components/lessons/LessonResources'; import { ProgressBar } from '@/components/progress/ProgressBar'; export const LessonPage: React.FC = () => { const { lessonId } = useParams<{ lessonId: string }>(); const navigate = useNavigate(); const { data: lesson, isLoading } = useLessonDetail(lessonId!); const { updateProgress, markComplete } = useVideoProgress(lessonId!); const handleProgressUpdate = (position: number, percentage: number) => { updateProgress({ last_position_seconds: position, watch_percentage: percentage }); }; const handleLessonComplete = async () => { await markComplete(); // Show completion animation/toast }; const handleNavigation = (direction: 'prev' | 'next') => { const targetLesson = direction === 'next' ? lesson?.next_lesson : lesson?.previous_lesson; if (targetLesson) { navigate(`/lessons/${targetLesson.id}`); } }; if (isLoading) return ; if (!lesson) return ; return (
{/* Header con progreso del curso */}

{lesson.title}

{/* Main Player */}
{/* Lesson Description */}

Descripción

{lesson.description}

{/* Resources */} {lesson.attachments && lesson.attachments.length > 0 && (

Recursos

)}
{/* Sidebar Navigation */}
); }; ``` #### ProgressPage.tsx Dashboard de progreso del usuario. ```typescript import React from 'react'; import { useEnrollments } from '@/hooks/useEnrollments'; import { useGamification } from '@/hooks/useGamification'; import { ProgressStats } from '@/components/progress/ProgressStats'; import { CourseProgressCard } from '@/components/progress/CourseProgressCard'; import { XPBar } from '@/components/gamification/XPBar'; import { AchievementsList } from '@/components/gamification/AchievementsList'; export const ProgressPage: React.FC = () => { const { data: enrollments } = useEnrollments({ status: 'active' }); const { data: gamification } = useGamification(); return (

Mi Progreso

{/* Gamification Overview */} {gamification && (

Nivel Actual

{gamification.current_level}

Total XP

{gamification.total_xp.toLocaleString()}

Racha Actual

{gamification.current_streak_days} días

)} {/* Stats Overview */} {/* Active Courses */}

Cursos en Progreso

{enrollments?.data .filter(e => e.status === 'active') .map(enrollment => ( ))}
{/* Recent Achievements */} {gamification && (

Logros Recientes

)}
); }; ``` --- ### 2. COMPONENTES PRINCIPALES #### CourseCard.tsx Tarjeta de curso para grid. ```typescript import React from 'react'; import { Link } from 'react-router-dom'; import { CourseListItem } from '@/types/course'; import { DifficultyBadge } from './DifficultyBadge'; import { StarRating } from '@/components/common/StarRating'; interface CourseCardProps { course: CourseListItem; } export const CourseCard: React.FC = ({ course }) => { return ( {/* Thumbnail */}
{course.title}
{!course.is_free && (
${course.price_usd}
)}
{/* Content */}
{/* Category */}

{course.category.name}

{/* Title */}

{course.title}

{/* Description */}

{course.short_description}

{/* Meta */}
({course.total_reviews})
{course.total_lessons} lecciones
{/* Instructor */}

Por {course.instructor_name}

{/* XP Reward */}
+{course.xp_reward} XP
); }; ``` #### LessonPlayer.tsx Reproductor de video con tracking. ```typescript import React, { useRef, useEffect, useState } from 'react'; import { LessonDetail } from '@/types/lesson'; interface LessonPlayerProps { lesson: LessonDetail; onProgressUpdate: (position: number, percentage: number) => void; onComplete: () => void; initialPosition?: number; } export const LessonPlayer: React.FC = ({ lesson, onProgressUpdate, onComplete, initialPosition = 0 }) => { const playerRef = useRef(null); const [isPlaying, setIsPlaying] = useState(false); const [hasCompleted, setHasCompleted] = useState(false); useEffect(() => { if (playerRef.current && initialPosition > 0) { playerRef.current.currentTime = initialPosition; } }, [initialPosition]); const handleTimeUpdate = () => { if (!playerRef.current) return; const position = Math.floor(playerRef.current.currentTime); const duration = playerRef.current.duration; const percentage = (position / duration) * 100; // Update progress every 5 seconds if (position % 5 === 0) { onProgressUpdate(position, percentage); } // Mark as complete at 95% if (percentage >= 95 && !hasCompleted) { setHasCompleted(true); onComplete(); } }; const handlePlay = () => setIsPlaying(true); const handlePause = () => setIsPlaying(false); if (lesson.content_type === 'video') { return (
{/* Custom Controls Overlay (opcional) */} {!isPlaying && (
)}
); } // Article content if (lesson.content_type === 'article') { return (
); } return null; }; ``` #### ProgressBar.tsx Barra de progreso reutilizable. ```typescript import React from 'react'; import { cn } from '@/utils/cn'; interface ProgressBarProps { percentage: number; size?: 'sm' | 'md' | 'lg'; showLabel?: boolean; color?: 'purple' | 'green' | 'blue'; className?: string; } export const ProgressBar: React.FC = ({ percentage, size = 'md', showLabel = true, color = 'purple', className }) => { const heights = { sm: 'h-1', md: 'h-2', lg: 'h-3' }; const colors = { purple: 'bg-purple-600', green: 'bg-green-600', blue: 'bg-blue-600' }; const clampedPercentage = Math.min(Math.max(percentage, 0), 100); return (
{showLabel && (
Progreso {Math.round(clampedPercentage)}%
)}
); }; ``` #### QuizQuestion.tsx Componente de pregunta de quiz. ```typescript import React, { useState } from 'react'; import { QuizQuestion as QuizQuestionType } from '@/types/quiz'; interface QuizQuestionProps { question: QuizQuestionType; questionNumber: number; totalQuestions: number; onAnswer: (answer: string | string[]) => void; showResult?: boolean; userAnswer?: string | string[]; isCorrect?: boolean; } export const QuizQuestion: React.FC = ({ question, questionNumber, totalQuestions, onAnswer, showResult = false, userAnswer, isCorrect }) => { const [selectedAnswer, setSelectedAnswer] = useState( userAnswer || (question.question_type === 'multiple_select' ? [] : '') ); const handleOptionSelect = (optionId: string) => { if (showResult) return; let newAnswer: string | string[]; if (question.question_type === 'multiple_select') { const current = selectedAnswer as string[]; newAnswer = current.includes(optionId) ? current.filter(id => id !== optionId) : [...current, optionId]; } else { newAnswer = optionId; } setSelectedAnswer(newAnswer); onAnswer(newAnswer); }; return (
{/* Header */}

Pregunta {questionNumber} de {totalQuestions}

{question.question_text}

{question.points} pts
{/* Image */} {question.image_url && (
Question
)} {/* Code Snippet */} {question.code_snippet && (
            {question.code_snippet}
          
)} {/* Options */}
{question.options?.map(option => { const isSelected = Array.isArray(selectedAnswer) ? selectedAnswer.includes(option.id) : selectedAnswer === option.id; const getOptionClass = () => { if (!showResult) { return isSelected ? 'border-purple-500 bg-purple-50' : 'border-gray-300 hover:border-gray-400'; } if (option.isCorrect) { return 'border-green-500 bg-green-50'; } if (isSelected && !option.isCorrect) { return 'border-red-500 bg-red-50'; } return 'border-gray-300 opacity-50'; }; return ( ); })}
{/* Result Explanation */} {showResult && question.explanation && (
{isCorrect ? ( ) : ( )}

{isCorrect ? '¡Correcto!' : 'Incorrecto'}

{question.explanation}

)}
); }; ``` --- ### 3. CUSTOM HOOKS #### useCourses.ts Hook para obtener lista de cursos. ```typescript import { useQuery } from '@tanstack/react-query'; import { coursesApi } from '@/api/courses'; import { CourseFilters } from '@/types/course'; export const useCourses = (filters: CourseFilters) => { return useQuery({ queryKey: ['courses', filters], queryFn: () => coursesApi.getCourses(filters), keepPreviousData: true, staleTime: 5 * 60 * 1000 // 5 minutos }); }; ``` #### useEnrollment.ts Hook para gestión de enrollments. ```typescript import { useMutation, useQueryClient } from '@tanstack/react-query'; import { enrollmentsApi } from '@/api/enrollments'; import { useToast } from '@/hooks/useToast'; export const useEnrollment = () => { const queryClient = useQueryClient(); const { toast } = useToast(); const enrollMutation = useMutation({ mutationFn: (data: { course_id: string }) => enrollmentsApi.enroll(data), onSuccess: () => { queryClient.invalidateQueries(['enrollments']); toast({ title: '¡Inscripción exitosa!', description: 'Ya puedes comenzar el curso', variant: 'success' }); }, onError: (error: any) => { toast({ title: 'Error al inscribirse', description: error.response?.data?.error?.message || 'Intenta de nuevo', variant: 'error' }); } }); return { enrollMutation }; }; ``` #### useVideoProgress.ts Hook para tracking de progreso de video. ```typescript import { useMutation, useQueryClient } from '@tanstack/react-query'; import { progressApi } from '@/api/progress'; import { useDebounce } from '@/hooks/useDebounce'; import { useEnrollmentStore } from '@/stores/enrollmentStore'; export const useVideoProgress = (lessonId: string) => { const queryClient = useQueryClient(); const currentEnrollment = useEnrollmentStore(state => state.getCurrentEnrollment() ); const updateProgressMutation = useMutation({ mutationFn: (data: { last_position_seconds?: number; watch_percentage?: number; is_completed?: boolean; }) => progressApi.updateProgress({ lesson_id: lessonId, enrollment_id: currentEnrollment!.id, ...data }), onSuccess: () => { queryClient.invalidateQueries(['progress', lessonId]); queryClient.invalidateQueries(['enrollment', currentEnrollment?.id]); } }); // Debounce progress updates const debouncedUpdate = useDebounce( (data: any) => updateProgressMutation.mutate(data), 2000 ); const updateProgress = (data: { last_position_seconds: number; watch_percentage: number; }) => { debouncedUpdate(data); }; const markComplete = async () => { await updateProgressMutation.mutateAsync({ is_completed: true, watch_percentage: 100 }); }; return { updateProgress, markComplete, isUpdating: updateProgressMutation.isLoading }; }; ``` --- ### 4. ZUSTAND STORES #### courseStore.ts Store para cursos y catálogo. ```typescript import { create } from 'zustand'; import { devtools, persist } from 'zustand/middleware'; import { CourseListItem, CourseDetail } from '@/types/course'; interface CourseState { // State courses: CourseListItem[]; currentCourse: CourseDetail | null; filters: { category: string; difficulty: string; search: string; }; // Actions setCourses: (courses: CourseListItem[]) => void; setCurrentCourse: (course: CourseDetail | null) => void; updateFilters: (filters: Partial) => void; clearFilters: () => void; } export const useCourseStore = create()( devtools( persist( (set) => ({ courses: [], currentCourse: null, filters: { category: '', difficulty: '', search: '' }, setCourses: (courses) => set({ courses }), setCurrentCourse: (course) => set({ currentCourse: course }), updateFilters: (newFilters) => set((state) => ({ filters: { ...state.filters, ...newFilters } })), clearFilters: () => set({ filters: { category: '', difficulty: '', search: '' } }) }), { name: 'course-storage', partialize: (state) => ({ filters: state.filters }) } ) ) ); ``` #### progressStore.ts Store para progreso de usuario. ```typescript import { create } from 'zustand'; import { devtools } from 'zustand/middleware'; import { Progress } from '@/types/progress'; interface ProgressState { // State progressByLesson: Map; currentLessonProgress: Progress | null; // Actions setLessonProgress: (lessonId: string, progress: Progress) => void; setCurrentLessonProgress: (progress: Progress | null) => void; updateLessonProgress: ( lessonId: string, updates: Partial ) => void; clearProgress: () => void; } export const useProgressStore = create()( devtools((set) => ({ progressByLesson: new Map(), currentLessonProgress: null, setLessonProgress: (lessonId, progress) => set((state) => ({ progressByLesson: new Map(state.progressByLesson).set( lessonId, progress ) })), setCurrentLessonProgress: (progress) => set({ currentLessonProgress: progress }), updateLessonProgress: (lessonId, updates) => set((state) => { const current = state.progressByLesson.get(lessonId); if (!current) return state; const updated = { ...current, ...updates }; const newMap = new Map(state.progressByLesson); newMap.set(lessonId, updated); return { progressByLesson: newMap }; }), clearProgress: () => set({ progressByLesson: new Map(), currentLessonProgress: null }) })) ); ``` #### gamificationStore.ts Store para XP, niveles y logros. ```typescript import { create } from 'zustand'; import { devtools, persist } from 'zustand/middleware'; import { GamificationProfile, Achievement } from '@/types/gamification'; interface GamificationState { // State profile: GamificationProfile | null; achievements: Achievement[]; showLevelUpModal: boolean; showAchievementModal: Achievement | null; // Actions setProfile: (profile: GamificationProfile) => void; addXP: (amount: number) => void; addAchievement: (achievement: Achievement) => void; setShowLevelUpModal: (show: boolean) => void; setShowAchievementModal: (achievement: Achievement | null) => void; } export const useGamificationStore = create()( devtools( persist( (set) => ({ profile: null, achievements: [], showLevelUpModal: false, showAchievementModal: null, setProfile: (profile) => set({ profile }), addXP: (amount) => set((state) => { if (!state.profile) return state; const newTotalXP = state.profile.total_xp + amount; const oldLevel = state.profile.current_level; // Calcular nuevo nivel (fórmula definida en ET-EDU-006) const newLevel = Math.floor(Math.sqrt(newTotalXP / 100)); const leveledUp = newLevel > oldLevel; return { profile: { ...state.profile, total_xp: newTotalXP, current_level: newLevel }, showLevelUpModal: leveledUp }; }), addAchievement: (achievement) => set((state) => ({ achievements: [achievement, ...state.achievements], showAchievementModal: achievement })), setShowLevelUpModal: (show) => set({ showLevelUpModal: show }), setShowAchievementModal: (achievement) => set({ showAchievementModal: achievement }) }), { name: 'gamification-storage', partialize: (state) => ({ profile: state.profile, achievements: state.achievements }) } ) ) ); ``` --- ## Interfaces/Tipos Ver archivo: `/src/types/index.ts` --- ## Configuración ### Variables de Entorno ```bash # API VITE_API_URL=https://api.trading.ai/v1 VITE_API_TIMEOUT=30000 # CDN VITE_CDN_URL=https://cdn.trading.ai VITE_VIMEO_PLAYER_ID=xxxxx # Features VITE_ENABLE_GAMIFICATION=true VITE_ENABLE_CERTIFICATES=true # Analytics VITE_GA_MEASUREMENT_ID=G-XXXXXXXXXX ``` ### TailwindCSS Config ```javascript // tailwind.config.js module.exports = { content: ['./src/**/*.{js,jsx,ts,tsx}'], theme: { extend: { colors: { purple: { 50: '#faf5ff', // ... resto de colores 600: '#9333ea', 700: '#7e22ce' } }, animation: { 'fade-in': 'fadeIn 0.3s ease-in-out', 'slide-up': 'slideUp 0.4s ease-out', 'bounce-slow': 'bounce 3s infinite' }, keyframes: { fadeIn: { '0%': { opacity: '0' }, '100%': { opacity: '1' } }, slideUp: { '0%': { transform: 'translateY(20px)', opacity: '0' }, '100%': { transform: 'translateY(0)', opacity: '1' } } } } }, plugins: [ require('@tailwindcss/typography'), require('@tailwindcss/forms'), require('@tailwindcss/aspect-ratio') ] }; ``` --- ## Dependencias ```json { "dependencies": { "react": "^18.2.0", "react-dom": "^18.2.0", "react-router-dom": "^6.21.0", "zustand": "^4.4.7", "@tanstack/react-query": "^5.17.9", "axios": "^1.6.5", "clsx": "^2.1.0", "tailwind-merge": "^2.2.0", "@headlessui/react": "^1.7.17", "@heroicons/react": "^2.1.1", "date-fns": "^3.0.6", "react-hot-toast": "^2.4.1", "react-player": "^2.14.1" }, "devDependencies": { "@types/react": "^18.2.47", "@types/react-dom": "^18.2.18", "@vitejs/plugin-react": "^4.2.1", "typescript": "^5.3.3", "vite": "^5.0.11", "tailwindcss": "^3.4.1", "autoprefixer": "^10.4.16", "postcss": "^8.4.33", "@tailwindcss/typography": "^0.5.10", "@tailwindcss/forms": "^0.5.7", "@tailwindcss/aspect-ratio": "^0.4.2" } } ``` --- ## Consideraciones de Seguridad ### 1. Sanitización de HTML ```typescript import DOMPurify from 'dompurify'; // Sanitizar contenido antes de usar dangerouslySetInnerHTML const sanitizeHtml = (html: string) => { return DOMPurify.sanitize(html, { ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'u', 'a', 'ul', 'ol', 'li'], ALLOWED_ATTR: ['href', 'target', 'rel'] }); }; // Uso:
``` ### 2. Protección de Rutas ```typescript // ProtectedRoute.tsx import { Navigate } from 'react-router-dom'; import { useAuth } from '@/hooks/useAuth'; export const ProtectedRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => { const { isAuthenticated, isLoading } = useAuth(); if (isLoading) return ; if (!isAuthenticated) return ; return <>{children}; }; ``` ### 3. Validación de Tokens ```typescript // api/client.ts import axios from 'axios'; const apiClient = axios.create({ baseURL: import.meta.env.VITE_API_URL }); apiClient.interceptors.request.use((config) => { const token = localStorage.getItem('auth_token'); if (token) { config.headers.Authorization = `Bearer ${token}`; } return config; }); apiClient.interceptors.response.use( (response) => response, (error) => { if (error.response?.status === 401) { // Token expirado - redirect a login localStorage.removeItem('auth_token'); window.location.href = '/login'; } return Promise.reject(error); } ); ``` --- ## Testing ### Estrategia 1. **Unit Tests**: Componentes y hooks 2. **Integration Tests**: Flujos completos 3. **E2E Tests**: Cypress ### Ejemplo de Test ```typescript import { render, screen, fireEvent } from '@testing-library/react'; import { CourseCard } from './CourseCard'; import { mockCourse } from '@/mocks/courses'; describe('CourseCard', () => { it('renders course information correctly', () => { render(); expect(screen.getByText(mockCourse.title)).toBeInTheDocument(); expect(screen.getByText(mockCourse.short_description)).toBeInTheDocument(); expect(screen.getByText(mockCourse.instructor_name)).toBeInTheDocument(); }); it('navigates to course detail on click', () => { const { container } = render(); const link = container.querySelector('a'); expect(link).toHaveAttribute('href', `/courses/${mockCourse.slug}`); }); it('displays price for paid courses', () => { const paidCourse = { ...mockCourse, is_free: false, price_usd: 99 }; render(); expect(screen.getByText('$99')).toBeInTheDocument(); }); }); ``` --- **Fin de Especificación ET-EDU-003**