diff --git a/src/modules/education/components/CertificatePreview.tsx b/src/modules/education/components/CertificatePreview.tsx new file mode 100644 index 0000000..2b73f97 --- /dev/null +++ b/src/modules/education/components/CertificatePreview.tsx @@ -0,0 +1,193 @@ +/** + * CertificatePreview Component + * Displays course completion certificate with download option + */ + +import React from 'react'; +import { + Award, + Download, + Share2, + ExternalLink, + Calendar, + CheckCircle, +} from 'lucide-react'; + +interface CertificatePreviewProps { + courseTitle: string; + courseThumbnail?: string; + studentName: string; + instructorName: string; + completedAt: string; + certificateUrl?: string; + certificateId?: string; + xpEarned?: number; + onDownload?: () => void; + onShare?: () => void; + compact?: boolean; +} + +const CertificatePreview: React.FC = ({ + courseTitle, + courseThumbnail, + studentName, + instructorName, + completedAt, + certificateUrl, + certificateId, + xpEarned, + onDownload, + onShare, + compact = false, +}) => { + const formattedDate = new Date(completedAt).toLocaleDateString('es-ES', { + year: 'numeric', + month: 'long', + day: 'numeric', + }); + + if (compact) { + return ( +
+
+ +
+
+

{courseTitle}

+

Completado el {formattedDate}

+
+
+ {certificateUrl && ( + + + + )} + {onDownload && ( + + )} +
+
+ ); + } + + return ( +
+ {/* Certificate Header */} +
+ {/* Decorative elements */} +
+
+
+
+
+ +
+ +
+

Certificado de Finalizacion

+

Trading Academy

+
+ +
+
+ + {/* Certificate Body */} +
+ {/* Student Name */} +
+

Este certificado se otorga a

+

{studentName}

+
+ + {/* Course Info */} +
+

Por completar exitosamente el curso

+
+ {courseThumbnail && ( + {courseTitle} + )} +

{courseTitle}

+
+
+ + {/* Instructor */} +
+

Impartido por

+

{instructorName}

+
+ + {/* Completion Info */} +
+
+ + {formattedDate} +
+ {xpEarned && ( +
+ + +{xpEarned} XP +
+ )} +
+ + {/* Certificate ID */} + {certificateId && ( +

+ ID: {certificateId} +

+ )} +
+ + {/* Actions */} +
+ {certificateUrl && ( + + + Ver Completo + + )} + {onDownload && ( + + )} + {onShare && ( + + )} +
+
+ ); +}; + +export default CertificatePreview; diff --git a/src/modules/education/components/CourseReviews.tsx b/src/modules/education/components/CourseReviews.tsx new file mode 100644 index 0000000..f43df36 --- /dev/null +++ b/src/modules/education/components/CourseReviews.tsx @@ -0,0 +1,322 @@ +/** + * CourseReviews Component + * Displays course reviews with rating summary and individual reviews + */ + +import React, { useState } from 'react'; +import { + Star, + ThumbsUp, + MessageSquare, + ChevronDown, + User, + Filter, +} from 'lucide-react'; + +interface Review { + id: string; + userId: string; + userName: string; + userAvatar?: string; + rating: number; + title?: string; + comment: string; + helpful: number; + createdAt: string; + verified?: boolean; +} + +interface RatingSummary { + average: number; + total: number; + distribution: { + 5: number; + 4: number; + 3: number; + 2: number; + 1: number; + }; +} + +interface CourseReviewsProps { + courseId: string; + reviews: Review[]; + ratingSummary: RatingSummary; + onLoadMore?: () => void; + onMarkHelpful?: (reviewId: string) => void; + onReport?: (reviewId: string) => void; + hasMore?: boolean; + loading?: boolean; +} + +const CourseReviews: React.FC = ({ + courseId, + reviews, + ratingSummary, + onLoadMore, + onMarkHelpful, + onReport, + hasMore = false, + loading = false, +}) => { + const [filterRating, setFilterRating] = useState(null); + const [sortBy, setSortBy] = useState<'recent' | 'helpful' | 'rating'>('recent'); + + const filteredReviews = reviews.filter((review) => { + if (filterRating === null) return true; + return review.rating === filterRating; + }); + + const sortedReviews = [...filteredReviews].sort((a, b) => { + if (sortBy === 'helpful') return b.helpful - a.helpful; + if (sortBy === 'rating') return b.rating - a.rating; + return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); + }); + + const renderStars = (rating: number, size: 'sm' | 'md' = 'sm') => { + const sizeClass = size === 'sm' ? 'w-4 h-4' : 'w-5 h-5'; + return ( +
+ {[1, 2, 3, 4, 5].map((star) => ( + + ))} +
+ ); + }; + + const formatDate = (dateString: string) => { + const date = new Date(dateString); + return date.toLocaleDateString('es-ES', { + year: 'numeric', + month: 'short', + day: 'numeric', + }); + }; + + return ( +
+ {/* Rating Summary */} +
+

Resenas del Curso

+ +
+ {/* Average Rating */} +
+
+
+ {ratingSummary.average.toFixed(1)} +
+
{renderStars(Math.round(ratingSummary.average), 'md')}
+

+ {ratingSummary.total.toLocaleString()} resenas +

+
+
+ + {/* Rating Distribution */} +
+ {[5, 4, 3, 2, 1].map((rating) => { + const count = ratingSummary.distribution[rating as keyof typeof ratingSummary.distribution]; + const percentage = ratingSummary.total > 0 ? (count / ratingSummary.total) * 100 : 0; + + return ( + + ); + })} +
+
+
+ + {/* Filters */} +
+
+ + + {filteredReviews.length} resenas + {filterRating && ` con ${filterRating} estrellas`} + + {filterRating && ( + + )} +
+ + +
+ + {/* Reviews List */} +
+ {sortedReviews.length === 0 ? ( +
+ +

Sin resenas

+

+ {filterRating + ? `No hay resenas con ${filterRating} estrellas` + : 'Se el primero en dejar una resena'} +

+
+ ) : ( + sortedReviews.map((review) => ( + onMarkHelpful?.(review.id)} + onReport={() => onReport?.(review.id)} + /> + )) + )} +
+ + {/* Load More */} + {hasMore && ( +
+ +
+ )} +
+ ); +}; + +// Individual Review Card +interface ReviewCardProps { + review: Review; + onMarkHelpful?: () => void; + onReport?: () => void; +} + +const ReviewCard: React.FC = ({ review, onMarkHelpful, onReport }) => { + const renderStars = (rating: number) => ( +
+ {[1, 2, 3, 4, 5].map((star) => ( + + ))} +
+ ); + + const formatDate = (dateString: string) => { + const date = new Date(dateString); + return date.toLocaleDateString('es-ES', { + year: 'numeric', + month: 'short', + day: 'numeric', + }); + }; + + return ( +
+ {/* Header */} +
+
+ {review.userAvatar ? ( + {review.userName} + ) : ( +
+ +
+ )} +
+
+ {review.userName} + {review.verified && ( + + Verificado + + )} +
+
+ {renderStars(review.rating)} + {formatDate(review.createdAt)} +
+
+
+
+ + {/* Title */} + {review.title && ( +

{review.title}

+ )} + + {/* Comment */} +

{review.comment}

+ + {/* Actions */} +
+ + +
+
+ ); +}; + +export default CourseReviews; diff --git a/src/modules/education/components/LessonNotes.tsx b/src/modules/education/components/LessonNotes.tsx new file mode 100644 index 0000000..e42b03f --- /dev/null +++ b/src/modules/education/components/LessonNotes.tsx @@ -0,0 +1,341 @@ +/** + * LessonNotes Component + * Note-taking interface for lessons with auto-save + */ + +import React, { useState, useEffect, useCallback, useRef } from 'react'; +import { + StickyNote, + Save, + Clock, + Trash2, + Download, + ChevronDown, + ChevronUp, + BookmarkPlus, + CheckCircle, + Loader2, +} from 'lucide-react'; + +interface Note { + id: string; + lessonId: string; + content: string; + timestamp?: number; // Video timestamp in seconds + createdAt: string; + updatedAt: string; +} + +interface LessonNotesProps { + lessonId: string; + courseId: string; + notes: Note[]; + currentVideoTime?: number; + onSaveNote: (content: string, timestamp?: number) => Promise; + onUpdateNote: (noteId: string, content: string) => Promise; + onDeleteNote: (noteId: string) => Promise; + onSeekToTimestamp?: (timestamp: number) => void; + onExportNotes?: () => void; + collapsed?: boolean; + onToggleCollapse?: () => void; +} + +const LessonNotes: React.FC = ({ + lessonId, + courseId, + notes, + currentVideoTime, + onSaveNote, + onUpdateNote, + onDeleteNote, + onSeekToTimestamp, + onExportNotes, + collapsed = false, + onToggleCollapse, +}) => { + const [newNote, setNewNote] = useState(''); + const [editingNoteId, setEditingNoteId] = useState(null); + const [editContent, setEditContent] = useState(''); + const [saving, setSaving] = useState(false); + const [lastSaved, setLastSaved] = useState(null); + const [includeTimestamp, setIncludeTimestamp] = useState(true); + const textareaRef = useRef(null); + + // Auto-resize textarea + const adjustTextareaHeight = useCallback((textarea: HTMLTextAreaElement) => { + textarea.style.height = 'auto'; + textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`; + }, []); + + useEffect(() => { + if (textareaRef.current) { + adjustTextareaHeight(textareaRef.current); + } + }, [newNote, adjustTextareaHeight]); + + const formatTimestamp = (seconds: number): string => { + const mins = Math.floor(seconds / 60); + const secs = Math.floor(seconds % 60); + return `${mins}:${secs.toString().padStart(2, '0')}`; + }; + + const handleSaveNote = async () => { + if (!newNote.trim()) return; + + setSaving(true); + try { + const timestamp = includeTimestamp && currentVideoTime ? Math.floor(currentVideoTime) : undefined; + await onSaveNote(newNote.trim(), timestamp); + setNewNote(''); + setLastSaved(new Date()); + } catch (error) { + console.error('Error saving note:', error); + } finally { + setSaving(false); + } + }; + + const handleUpdateNote = async (noteId: string) => { + if (!editContent.trim()) return; + + setSaving(true); + try { + await onUpdateNote(noteId, editContent.trim()); + setEditingNoteId(null); + setEditContent(''); + setLastSaved(new Date()); + } catch (error) { + console.error('Error updating note:', error); + } finally { + setSaving(false); + } + }; + + const handleDeleteNote = async (noteId: string) => { + if (!confirm('Estas seguro de eliminar esta nota?')) return; + + try { + await onDeleteNote(noteId); + } catch (error) { + console.error('Error deleting note:', error); + } + }; + + const startEditing = (note: Note) => { + setEditingNoteId(note.id); + setEditContent(note.content); + }; + + const cancelEditing = () => { + setEditingNoteId(null); + setEditContent(''); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + handleSaveNote(); + } + }; + + // Sort notes by timestamp or creation date + const sortedNotes = [...notes].sort((a, b) => { + if (a.timestamp !== undefined && b.timestamp !== undefined) { + return a.timestamp - b.timestamp; + } + return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); + }); + + return ( +
+ {/* Header */} + + + {!collapsed && ( +
+ {/* New Note Input */} +
+
+