trading-platform/docs/02-definicion-modulos/OQI-002-education/especificaciones/ET-EDU-003-frontend.md
rckrdmrd c1b5081208 feat(ml): Complete FASE 11 - BTCUSD update and comprehensive documentation alignment
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>
2026-01-07 09:31:29 -06:00

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

  1. Unit Tests: Componentes y hooks
  2. Integration Tests: Flujos completos
  3. 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