---
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 */}
{/* Learning Objectives */}
¿Qué aprenderás?
{course.learning_objectives.map((objective, index) => (
-
{objective}
))}
{/* Curriculum */}
{/* 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 */}
{/* 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 && (
)}
);
};
```
---
### 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.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 && (
)}
{/* 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