Changes include: - Updated architecture documentation - Enhanced module definitions (OQI-001 to OQI-008) - ML integration documentation updates - Trading strategies documentation - Orchestration and inventory updates - Docker configuration updates 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1468 lines
44 KiB
Markdown
1468 lines
44 KiB
Markdown
---
|
|
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 (
|
|
<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.
|
|
|
|
```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 <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.
|
|
|
|
```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 <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.
|
|
|
|
```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 (
|
|
<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.
|
|
|
|
```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<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.
|
|
|
|
```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<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.
|
|
|
|
```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<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.
|
|
|
|
```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<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.
|
|
|
|
```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<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.
|
|
|
|
```typescript
|
|
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.
|
|
|
|
```typescript
|
|
import { create } from 'zustand';
|
|
import { devtools, persist } from 'zustand/middleware';
|
|
import { GamificationProfile, Achievement } from '@/types/gamification';
|
|
|
|
interface GamificationState {
|
|
// State
|
|
profile: GamificationProfile | null;
|
|
achievements: Achievement[];
|
|
showLevelUpModal: boolean;
|
|
showAchievementModal: Achievement | null;
|
|
|
|
// Actions
|
|
setProfile: (profile: GamificationProfile) => void;
|
|
addXP: (amount: number) => void;
|
|
addAchievement: (achievement: Achievement) => void;
|
|
setShowLevelUpModal: (show: boolean) => void;
|
|
setShowAchievementModal: (achievement: Achievement | null) => void;
|
|
}
|
|
|
|
export const useGamificationStore = create<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
|
|
|
|
```bash
|
|
# API
|
|
VITE_API_URL=https://api.orbiquant.ai/v1
|
|
VITE_API_TIMEOUT=30000
|
|
|
|
# CDN
|
|
VITE_CDN_URL=https://cdn.orbiquant.ai
|
|
VITE_VIMEO_PLAYER_ID=xxxxx
|
|
|
|
# Features
|
|
VITE_ENABLE_GAMIFICATION=true
|
|
VITE_ENABLE_CERTIFICATES=true
|
|
|
|
# Analytics
|
|
VITE_GA_MEASUREMENT_ID=G-XXXXXXXXXX
|
|
```
|
|
|
|
### TailwindCSS Config
|
|
|
|
```javascript
|
|
// tailwind.config.js
|
|
module.exports = {
|
|
content: ['./src/**/*.{js,jsx,ts,tsx}'],
|
|
theme: {
|
|
extend: {
|
|
colors: {
|
|
purple: {
|
|
50: '#faf5ff',
|
|
// ... resto de colores
|
|
600: '#9333ea',
|
|
700: '#7e22ce'
|
|
}
|
|
},
|
|
animation: {
|
|
'fade-in': 'fadeIn 0.3s ease-in-out',
|
|
'slide-up': 'slideUp 0.4s ease-out',
|
|
'bounce-slow': 'bounce 3s infinite'
|
|
},
|
|
keyframes: {
|
|
fadeIn: {
|
|
'0%': { opacity: '0' },
|
|
'100%': { opacity: '1' }
|
|
},
|
|
slideUp: {
|
|
'0%': { transform: 'translateY(20px)', opacity: '0' },
|
|
'100%': { transform: 'translateY(0)', opacity: '1' }
|
|
}
|
|
}
|
|
}
|
|
},
|
|
plugins: [
|
|
require('@tailwindcss/typography'),
|
|
require('@tailwindcss/forms'),
|
|
require('@tailwindcss/aspect-ratio')
|
|
]
|
|
};
|
|
```
|
|
|
|
---
|
|
|
|
## Dependencias
|
|
|
|
```json
|
|
{
|
|
"dependencies": {
|
|
"react": "^18.2.0",
|
|
"react-dom": "^18.2.0",
|
|
"react-router-dom": "^6.21.0",
|
|
"zustand": "^4.4.7",
|
|
"@tanstack/react-query": "^5.17.9",
|
|
"axios": "^1.6.5",
|
|
"clsx": "^2.1.0",
|
|
"tailwind-merge": "^2.2.0",
|
|
"@headlessui/react": "^1.7.17",
|
|
"@heroicons/react": "^2.1.1",
|
|
"date-fns": "^3.0.6",
|
|
"react-hot-toast": "^2.4.1",
|
|
"react-player": "^2.14.1"
|
|
},
|
|
"devDependencies": {
|
|
"@types/react": "^18.2.47",
|
|
"@types/react-dom": "^18.2.18",
|
|
"@vitejs/plugin-react": "^4.2.1",
|
|
"typescript": "^5.3.3",
|
|
"vite": "^5.0.11",
|
|
"tailwindcss": "^3.4.1",
|
|
"autoprefixer": "^10.4.16",
|
|
"postcss": "^8.4.33",
|
|
"@tailwindcss/typography": "^0.5.10",
|
|
"@tailwindcss/forms": "^0.5.7",
|
|
"@tailwindcss/aspect-ratio": "^0.4.2"
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Consideraciones de Seguridad
|
|
|
|
### 1. Sanitización de HTML
|
|
|
|
```typescript
|
|
import DOMPurify from 'dompurify';
|
|
|
|
// Sanitizar contenido antes de usar dangerouslySetInnerHTML
|
|
const sanitizeHtml = (html: string) => {
|
|
return DOMPurify.sanitize(html, {
|
|
ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'u', 'a', 'ul', 'ol', 'li'],
|
|
ALLOWED_ATTR: ['href', 'target', 'rel']
|
|
});
|
|
};
|
|
|
|
// Uso:
|
|
<div dangerouslySetInnerHTML={{ __html: sanitizeHtml(content) }} />
|
|
```
|
|
|
|
### 2. Protección de Rutas
|
|
|
|
```typescript
|
|
// ProtectedRoute.tsx
|
|
import { Navigate } from 'react-router-dom';
|
|
import { useAuth } from '@/hooks/useAuth';
|
|
|
|
export const ProtectedRoute: React.FC<{ children: React.ReactNode }> = ({
|
|
children
|
|
}) => {
|
|
const { isAuthenticated, isLoading } = useAuth();
|
|
|
|
if (isLoading) return <LoadingSpinner />;
|
|
if (!isAuthenticated) return <Navigate to="/login" replace />;
|
|
|
|
return <>{children}</>;
|
|
};
|
|
```
|
|
|
|
### 3. Validación de Tokens
|
|
|
|
```typescript
|
|
// api/client.ts
|
|
import axios from 'axios';
|
|
|
|
const apiClient = axios.create({
|
|
baseURL: import.meta.env.VITE_API_URL
|
|
});
|
|
|
|
apiClient.interceptors.request.use((config) => {
|
|
const token = localStorage.getItem('auth_token');
|
|
if (token) {
|
|
config.headers.Authorization = `Bearer ${token}`;
|
|
}
|
|
return config;
|
|
});
|
|
|
|
apiClient.interceptors.response.use(
|
|
(response) => response,
|
|
(error) => {
|
|
if (error.response?.status === 401) {
|
|
// Token expirado - redirect a login
|
|
localStorage.removeItem('auth_token');
|
|
window.location.href = '/login';
|
|
}
|
|
return Promise.reject(error);
|
|
}
|
|
);
|
|
```
|
|
|
|
---
|
|
|
|
## Testing
|
|
|
|
### Estrategia
|
|
|
|
1. **Unit Tests**: Componentes y hooks
|
|
2. **Integration Tests**: Flujos completos
|
|
3. **E2E Tests**: Cypress
|
|
|
|
### Ejemplo de Test
|
|
|
|
```typescript
|
|
import { render, screen, fireEvent } from '@testing-library/react';
|
|
import { CourseCard } from './CourseCard';
|
|
import { mockCourse } from '@/mocks/courses';
|
|
|
|
describe('CourseCard', () => {
|
|
it('renders course information correctly', () => {
|
|
render(<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**
|