ML Engine Updates: - Updated BTCUSD with Polygon API data (2024-2025): 215,699 new records - Re-trained all ML models: Attention (R²: 0.223), Base, Metamodel (87.3% confidence) - Backtest results: +176.71R profit with aggressive_filter strategy Documentation Consolidation: - Created docs/99-analisis/_MAP.md index with 13 new analysis documents - Consolidated inventories: removed duplicates from orchestration/inventarios/ - Updated ML_INVENTORY.yml with BTCUSD metrics and training results - Added execution reports: FASE11-BTCUSD, correction issues, alignment validation Architecture & Integration: - Updated all module documentation with NEXUS v3.4 frontmatter - Fixed _MAP.md indexes across all folders - Updated orchestration plans and traces Files: 229 changed, 5064 insertions(+), 1872 deletions(-) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
44 KiB
| id | title | type | status | rf_parent | epic | version | created_date | updated_date |
|---|---|---|---|---|---|---|---|---|
| ET-EDU-003 | Frontend Components Education | Specification | Done | RF-EDU-002 | OQI-002 | 1.0 | 2025-12-05 | 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.
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 (
<div className="container mx-auto px-4 py-8">
{/* Header */}
<div className="mb-8">
<h1 className="text-4xl font-bold mb-4">Cursos de Trading</h1>
<p className="text-gray-600">
Aprende trading profesional con nuestros cursos especializados
</p>
</div>
{/* Search */}
<div className="mb-6">
<SearchBar
value={filters.search}
onChange={(value) => handleFilterChange('search', value)}
placeholder="Buscar cursos..."
/>
</div>
{/* Filters and Grid */}
<div className="grid grid-cols-1 lg:grid-cols-4 gap-8">
{/* Sidebar Filters */}
<div className="lg:col-span-1">
<CourseFilters
filters={filters}
onChange={handleFilterChange}
/>
</div>
{/* Course Grid */}
<div className="lg:col-span-3">
{isLoading && <LoadingSpinner />}
{error && <ErrorMessage error={error} />}
{data && (
<>
<div className="mb-4 flex justify-between items-center">
<p className="text-gray-600">
{data.pagination.total} cursos encontrados
</p>
<SortDropdown
value={filters.sort_by}
onChange={(value) => handleFilterChange('sort_by', value)}
/>
</div>
<CourseGrid courses={data.data} />
{data.pagination.total_pages > 1 && (
<div className="mt-8">
<Pagination
currentPage={data.pagination.page}
totalPages={data.pagination.total_pages}
onPageChange={handlePageChange}
/>
</div>
)}
</>
)}
</div>
</div>
</div>
);
};
CourseDetailPage.tsx
Página de detalle del curso con curriculum y opción de enrollment.
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 <LoadingSpinner />;
if (!course) return <NotFound />;
return (
<div className="min-h-screen bg-gray-50">
{/* Course Header */}
<CourseHeader course={course} />
<div className="container mx-auto px-4 py-8">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Main Content */}
<div className="lg:col-span-2 space-y-8">
{/* Description */}
<section className="bg-white rounded-lg shadow p-6">
<h2 className="text-2xl font-bold mb-4">Descripción</h2>
<div
className="prose max-w-none"
dangerouslySetInnerHTML={{ __html: course.full_description }}
/>
</section>
{/* Learning Objectives */}
<section className="bg-white rounded-lg shadow p-6">
<h2 className="text-2xl font-bold mb-4">
¿Qué aprenderás?
</h2>
<ul className="space-y-3">
{course.learning_objectives.map((objective, index) => (
<li key={index} className="flex items-start">
<CheckCircleIcon className="w-5 h-5 text-green-500 mr-3 mt-0.5" />
<span>{objective}</span>
</li>
))}
</ul>
</section>
{/* Curriculum */}
<section className="bg-white rounded-lg shadow p-6">
<h2 className="text-2xl font-bold mb-4">
Contenido del curso
</h2>
<CourseCurriculum
modules={course.modules}
userEnrollment={course.user_enrollment}
/>
</section>
{/* Instructor */}
<section className="bg-white rounded-lg shadow p-6">
<h2 className="text-2xl font-bold mb-4">Instructor</h2>
<CourseInstructor instructor={course.instructor} />
</section>
</div>
{/* Sidebar */}
<div className="lg:col-span-1">
<CourseSidebar
course={course}
onEnroll={handleEnroll}
isEnrolling={enrollMutation.isLoading}
/>
</div>
</div>
</div>
</div>
);
};
LessonPage.tsx
Reproductor de lección con tracking de progreso.
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 <LoadingSpinner />;
if (!lesson) return <NotFound />;
return (
<div className="min-h-screen bg-gray-900">
{/* Header con progreso del curso */}
<div className="bg-gray-800 border-b border-gray-700">
<div className="container mx-auto px-4 py-3">
<div className="flex items-center justify-between">
<div className="flex-1">
<h1 className="text-white font-semibold truncate">
{lesson.title}
</h1>
<ProgressBar
percentage={lesson.user_progress?.watch_percentage || 0}
size="sm"
showLabel={false}
/>
</div>
<button
onClick={() => navigate(-1)}
className="ml-4 text-gray-400 hover:text-white"
>
<XIcon className="w-6 h-6" />
</button>
</div>
</div>
</div>
<div className="container mx-auto px-4 py-6">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Main Player */}
<div className="lg:col-span-2">
<LessonPlayer
lesson={lesson}
onProgressUpdate={handleProgressUpdate}
onComplete={handleLessonComplete}
initialPosition={lesson.user_progress?.last_position_seconds}
/>
{/* Lesson Description */}
<div className="mt-6 bg-gray-800 rounded-lg p-6">
<h2 className="text-xl font-bold text-white mb-4">
Descripción
</h2>
<p className="text-gray-300">{lesson.description}</p>
</div>
{/* Resources */}
{lesson.attachments && lesson.attachments.length > 0 && (
<div className="mt-6 bg-gray-800 rounded-lg p-6">
<h2 className="text-xl font-bold text-white mb-4">
Recursos
</h2>
<LessonResources attachments={lesson.attachments} />
</div>
)}
</div>
{/* Sidebar Navigation */}
<div className="lg:col-span-1">
<LessonNavigation
currentLesson={lesson}
onNavigate={handleNavigation}
/>
</div>
</div>
</div>
</div>
);
};
ProgressPage.tsx
Dashboard de progreso del usuario.
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 (
<div className="container mx-auto px-4 py-8">
<h1 className="text-4xl font-bold mb-8">Mi Progreso</h1>
{/* Gamification Overview */}
{gamification && (
<div className="mb-8 bg-gradient-to-r from-purple-500 to-indigo-600 rounded-lg shadow-lg p-6 text-white">
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div>
<p className="text-sm opacity-90">Nivel Actual</p>
<p className="text-4xl font-bold">
{gamification.current_level}
</p>
</div>
<div>
<p className="text-sm opacity-90">Total XP</p>
<p className="text-4xl font-bold">
{gamification.total_xp.toLocaleString()}
</p>
</div>
<div>
<p className="text-sm opacity-90">Racha Actual</p>
<p className="text-4xl font-bold">
{gamification.current_streak_days} días
</p>
</div>
</div>
<div className="mt-6">
<XPBar
currentXP={gamification.total_xp}
nextLevelXP={gamification.xp_for_next_level}
percentage={gamification.xp_progress_percentage}
/>
</div>
</div>
)}
{/* Stats Overview */}
<ProgressStats enrollments={enrollments?.data || []} />
{/* Active Courses */}
<section className="mt-8">
<h2 className="text-2xl font-bold mb-4">Cursos en Progreso</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{enrollments?.data
.filter(e => e.status === 'active')
.map(enrollment => (
<CourseProgressCard
key={enrollment.id}
enrollment={enrollment}
/>
))}
</div>
</section>
{/* Recent Achievements */}
{gamification && (
<section className="mt-8">
<h2 className="text-2xl font-bold mb-4">Logros Recientes</h2>
<AchievementsList
achievements={gamification.recent_achievements}
/>
</section>
)}
</div>
);
};
2. COMPONENTES PRINCIPALES
CourseCard.tsx
Tarjeta de curso para grid.
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<CourseCardProps> = ({ course }) => {
return (
<Link
to={`/courses/${course.slug}`}
className="group bg-white rounded-lg shadow hover:shadow-xl transition-shadow overflow-hidden"
>
{/* Thumbnail */}
<div className="relative aspect-video overflow-hidden bg-gray-200">
<img
src={course.thumbnail_url}
alt={course.title}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
/>
<div className="absolute top-3 right-3">
<DifficultyBadge level={course.difficulty_level} />
</div>
{!course.is_free && (
<div className="absolute bottom-3 left-3 bg-yellow-400 text-gray-900 px-3 py-1 rounded-full font-bold text-sm">
${course.price_usd}
</div>
)}
</div>
{/* Content */}
<div className="p-4">
{/* Category */}
<p className="text-xs text-purple-600 font-semibold uppercase tracking-wide mb-2">
{course.category.name}
</p>
{/* Title */}
<h3 className="text-lg font-bold text-gray-900 mb-2 line-clamp-2 group-hover:text-purple-600 transition-colors">
{course.title}
</h3>
{/* Description */}
<p className="text-sm text-gray-600 mb-3 line-clamp-2">
{course.short_description}
</p>
{/* Meta */}
<div className="flex items-center justify-between text-sm text-gray-500">
<div className="flex items-center">
<StarRating rating={course.avg_rating} size="sm" />
<span className="ml-1">({course.total_reviews})</span>
</div>
<span>{course.total_lessons} lecciones</span>
</div>
{/* Instructor */}
<div className="mt-3 pt-3 border-t border-gray-100">
<p className="text-sm text-gray-600">
Por <span className="font-semibold">{course.instructor_name}</span>
</p>
</div>
{/* XP Reward */}
<div className="mt-2 flex items-center text-sm text-purple-600">
<SparklesIcon className="w-4 h-4 mr-1" />
<span className="font-semibold">+{course.xp_reward} XP</span>
</div>
</div>
</Link>
);
};
LessonPlayer.tsx
Reproductor de video con tracking.
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<LessonPlayerProps> = ({
lesson,
onProgressUpdate,
onComplete,
initialPosition = 0
}) => {
const playerRef = useRef<HTMLVideoElement>(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 (
<div className="relative aspect-video bg-black rounded-lg overflow-hidden">
<video
ref={playerRef}
src={lesson.video_url}
controls
className="w-full h-full"
onTimeUpdate={handleTimeUpdate}
onPlay={handlePlay}
onPause={handlePause}
onEnded={onComplete}
>
<track kind="captions" />
</video>
{/* Custom Controls Overlay (opcional) */}
{!isPlaying && (
<div className="absolute inset-0 flex items-center justify-center bg-black bg-opacity-30">
<button
onClick={() => playerRef.current?.play()}
className="w-20 h-20 flex items-center justify-center bg-white bg-opacity-90 rounded-full hover:bg-opacity-100 transition-all"
>
<PlayIcon className="w-10 h-10 text-purple-600 ml-1" />
</button>
</div>
)}
</div>
);
}
// Article content
if (lesson.content_type === 'article') {
return (
<div className="bg-white rounded-lg p-8">
<div
className="prose max-w-none"
dangerouslySetInnerHTML={{ __html: lesson.article_content || '' }}
/>
</div>
);
}
return null;
};
ProgressBar.tsx
Barra de progreso reutilizable.
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<ProgressBarProps> = ({
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 (
<div className={cn('w-full', className)}>
{showLabel && (
<div className="flex justify-between items-center mb-1">
<span className="text-sm font-medium text-gray-700">
Progreso
</span>
<span className="text-sm font-bold text-gray-900">
{Math.round(clampedPercentage)}%
</span>
</div>
)}
<div className={cn('w-full bg-gray-200 rounded-full overflow-hidden', heights[size])}>
<div
className={cn(
'h-full rounded-full transition-all duration-500 ease-out',
colors[color]
)}
style={{ width: `${clampedPercentage}%` }}
/>
</div>
</div>
);
};
QuizQuestion.tsx
Componente de pregunta de quiz.
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<QuizQuestionProps> = ({
question,
questionNumber,
totalQuestions,
onAnswer,
showResult = false,
userAnswer,
isCorrect
}) => {
const [selectedAnswer, setSelectedAnswer] = useState<string | string[]>(
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 (
<div className="bg-white rounded-lg shadow-lg p-8">
{/* Header */}
<div className="mb-6">
<p className="text-sm text-gray-500 mb-2">
Pregunta {questionNumber} de {totalQuestions}
</p>
<div className="flex items-start justify-between">
<h3 className="text-xl font-bold text-gray-900 flex-1">
{question.question_text}
</h3>
<span className="ml-4 px-3 py-1 bg-purple-100 text-purple-700 rounded-full text-sm font-semibold">
{question.points} pts
</span>
</div>
</div>
{/* Image */}
{question.image_url && (
<div className="mb-6">
<img
src={question.image_url}
alt="Question"
className="max-w-full h-auto rounded-lg"
/>
</div>
)}
{/* Code Snippet */}
{question.code_snippet && (
<div className="mb-6">
<pre className="bg-gray-900 text-gray-100 p-4 rounded-lg overflow-x-auto">
<code>{question.code_snippet}</code>
</pre>
</div>
)}
{/* Options */}
<div className="space-y-3">
{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 (
<button
key={option.id}
onClick={() => handleOptionSelect(option.id)}
disabled={showResult}
className={cn(
'w-full text-left p-4 border-2 rounded-lg transition-all',
getOptionClass(),
!showResult && 'cursor-pointer'
)}
>
<div className="flex items-center">
<div className={cn(
'w-6 h-6 rounded-full border-2 flex items-center justify-center mr-3',
isSelected ? 'border-purple-500 bg-purple-500' : 'border-gray-400'
)}>
{isSelected && (
<CheckIcon className="w-4 h-4 text-white" />
)}
</div>
<span className="font-medium">{option.text}</span>
</div>
</button>
);
})}
</div>
{/* Result Explanation */}
{showResult && question.explanation && (
<div className={cn(
'mt-6 p-4 rounded-lg',
isCorrect ? 'bg-green-50 border border-green-200' : 'bg-red-50 border border-red-200'
)}>
<div className="flex items-start">
{isCorrect ? (
<CheckCircleIcon className="w-5 h-5 text-green-600 mr-2 mt-0.5" />
) : (
<XCircleIcon className="w-5 h-5 text-red-600 mr-2 mt-0.5" />
)}
<div>
<p className={cn(
'font-semibold mb-1',
isCorrect ? 'text-green-800' : 'text-red-800'
)}>
{isCorrect ? '¡Correcto!' : 'Incorrecto'}
</p>
<p className="text-sm text-gray-700">
{question.explanation}
</p>
</div>
</div>
</div>
)}
</div>
);
};
3. CUSTOM HOOKS
useCourses.ts
Hook para obtener lista de cursos.
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.
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.
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.
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<CourseState['filters']>) => void;
clearFilters: () => void;
}
export const useCourseStore = create<CourseState>()(
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.
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
import { Progress } from '@/types/progress';
interface ProgressState {
// State
progressByLesson: Map<string, Progress>;
currentLessonProgress: Progress | null;
// Actions
setLessonProgress: (lessonId: string, progress: Progress) => void;
setCurrentLessonProgress: (progress: Progress | null) => void;
updateLessonProgress: (
lessonId: string,
updates: Partial<Progress>
) => void;
clearProgress: () => void;
}
export const useProgressStore = create<ProgressState>()(
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.
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<GamificationState>()(
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
# 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
// 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
{
"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
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:
<div dangerouslySetInnerHTML={{ __html: sanitizeHtml(content) }} />
2. Protección de Rutas
// 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 <LoadingSpinner />;
if (!isAuthenticated) return <Navigate to="/login" replace />;
return <>{children}</>;
};
3. Validación de Tokens
// 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
- Unit Tests: Componentes y hooks
- Integration Tests: Flujos completos
- E2E Tests: Cypress
Ejemplo de Test
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(<CourseCard course={mockCourse} />);
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(<CourseCard course={mockCourse} />);
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(<CourseCard course={paidCourse} />);
expect(screen.getByText('$99')).toBeInTheDocument();
});
});
Fin de Especificación ET-EDU-003