[OQI-002] feat: Add education UX components
- CertificatePreview: Display earned certificates with download/share - CourseReviews: Reviews section with rating summary and filtering - LessonNotes: Note-taking with timestamps and auto-save - RecommendedCourses: Course recommendations with multiple layouts Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
442051f93b
commit
fc0ab528c3
193
src/modules/education/components/CertificatePreview.tsx
Normal file
193
src/modules/education/components/CertificatePreview.tsx
Normal file
@ -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<CertificatePreviewProps> = ({
|
||||||
|
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 (
|
||||||
|
<div className="flex items-center gap-4 p-4 bg-gradient-to-r from-yellow-900/20 to-amber-900/20 border border-yellow-700/30 rounded-xl">
|
||||||
|
<div className="p-3 bg-yellow-500/20 rounded-full">
|
||||||
|
<Award className="w-6 h-6 text-yellow-400" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-medium text-white truncate">{courseTitle}</p>
|
||||||
|
<p className="text-sm text-gray-400">Completado el {formattedDate}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{certificateUrl && (
|
||||||
|
<a
|
||||||
|
href={certificateUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="p-2 text-gray-400 hover:text-white hover:bg-gray-700 rounded-lg transition-colors"
|
||||||
|
title="Ver certificado"
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-4 h-4" />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{onDownload && (
|
||||||
|
<button
|
||||||
|
onClick={onDownload}
|
||||||
|
className="p-2 text-gray-400 hover:text-white hover:bg-gray-700 rounded-lg transition-colors"
|
||||||
|
title="Descargar"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-gradient-to-br from-gray-800 via-gray-800 to-gray-900 rounded-xl border border-gray-700 overflow-hidden">
|
||||||
|
{/* Certificate Header */}
|
||||||
|
<div className="relative bg-gradient-to-r from-yellow-600/20 via-amber-500/20 to-yellow-600/20 p-6 border-b border-yellow-700/30">
|
||||||
|
{/* Decorative elements */}
|
||||||
|
<div className="absolute top-0 left-0 w-full h-full opacity-10">
|
||||||
|
<div className="absolute top-2 left-4 w-8 h-8 border-2 border-yellow-400 rounded-full" />
|
||||||
|
<div className="absolute bottom-2 right-4 w-6 h-6 border-2 border-yellow-400 rounded-full" />
|
||||||
|
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-32 h-32 border border-yellow-400/30 rounded-full" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative flex items-center justify-center gap-4">
|
||||||
|
<Award className="w-12 h-12 text-yellow-400" />
|
||||||
|
<div className="text-center">
|
||||||
|
<h3 className="text-xl font-bold text-yellow-400">Certificado de Finalizacion</h3>
|
||||||
|
<p className="text-yellow-400/70 text-sm">Trading Academy</p>
|
||||||
|
</div>
|
||||||
|
<Award className="w-12 h-12 text-yellow-400" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Certificate Body */}
|
||||||
|
<div className="p-6 text-center space-y-4">
|
||||||
|
{/* Student Name */}
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-400 mb-1">Este certificado se otorga a</p>
|
||||||
|
<h2 className="text-2xl font-bold text-white">{studentName}</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Course Info */}
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-400 mb-2">Por completar exitosamente el curso</p>
|
||||||
|
<div className="flex items-center justify-center gap-4">
|
||||||
|
{courseThumbnail && (
|
||||||
|
<img
|
||||||
|
src={courseThumbnail}
|
||||||
|
alt={courseTitle}
|
||||||
|
className="w-16 h-10 rounded object-cover"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<h3 className="text-xl font-semibold text-blue-400">{courseTitle}</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Instructor */}
|
||||||
|
<div className="pt-2">
|
||||||
|
<p className="text-sm text-gray-500">Impartido por</p>
|
||||||
|
<p className="text-white font-medium">{instructorName}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Completion Info */}
|
||||||
|
<div className="flex items-center justify-center gap-6 pt-4">
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<Calendar className="w-4 h-4 text-gray-400" />
|
||||||
|
<span className="text-gray-400">{formattedDate}</span>
|
||||||
|
</div>
|
||||||
|
{xpEarned && (
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<CheckCircle className="w-4 h-4 text-green-400" />
|
||||||
|
<span className="text-green-400">+{xpEarned} XP</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Certificate ID */}
|
||||||
|
{certificateId && (
|
||||||
|
<p className="text-xs text-gray-600 pt-2">
|
||||||
|
ID: {certificateId}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex items-center justify-center gap-3 p-4 bg-gray-900/50 border-t border-gray-700">
|
||||||
|
{certificateUrl && (
|
||||||
|
<a
|
||||||
|
href={certificateUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded-lg transition-colors text-sm"
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-4 h-4" />
|
||||||
|
Ver Completo
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{onDownload && (
|
||||||
|
<button
|
||||||
|
onClick={onDownload}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg transition-colors text-sm"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4" />
|
||||||
|
Descargar PDF
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{onShare && (
|
||||||
|
<button
|
||||||
|
onClick={onShare}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded-lg transition-colors text-sm"
|
||||||
|
>
|
||||||
|
<Share2 className="w-4 h-4" />
|
||||||
|
Compartir
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CertificatePreview;
|
||||||
322
src/modules/education/components/CourseReviews.tsx
Normal file
322
src/modules/education/components/CourseReviews.tsx
Normal file
@ -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<CourseReviewsProps> = ({
|
||||||
|
courseId,
|
||||||
|
reviews,
|
||||||
|
ratingSummary,
|
||||||
|
onLoadMore,
|
||||||
|
onMarkHelpful,
|
||||||
|
onReport,
|
||||||
|
hasMore = false,
|
||||||
|
loading = false,
|
||||||
|
}) => {
|
||||||
|
const [filterRating, setFilterRating] = useState<number | null>(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 (
|
||||||
|
<div className="flex items-center gap-0.5">
|
||||||
|
{[1, 2, 3, 4, 5].map((star) => (
|
||||||
|
<Star
|
||||||
|
key={star}
|
||||||
|
className={`${sizeClass} ${
|
||||||
|
star <= rating
|
||||||
|
? 'text-yellow-400 fill-yellow-400'
|
||||||
|
: 'text-gray-600'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateString: string) => {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
return date.toLocaleDateString('es-ES', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Rating Summary */}
|
||||||
|
<div className="bg-gray-800 rounded-xl border border-gray-700 p-6">
|
||||||
|
<h3 className="text-xl font-bold text-white mb-6">Resenas del Curso</h3>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
{/* Average Rating */}
|
||||||
|
<div className="flex items-center gap-6">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-5xl font-bold text-white">
|
||||||
|
{ratingSummary.average.toFixed(1)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2">{renderStars(Math.round(ratingSummary.average), 'md')}</div>
|
||||||
|
<p className="text-sm text-gray-400 mt-1">
|
||||||
|
{ratingSummary.total.toLocaleString()} resenas
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Rating Distribution */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{[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 (
|
||||||
|
<button
|
||||||
|
key={rating}
|
||||||
|
onClick={() => setFilterRating(filterRating === rating ? null : rating)}
|
||||||
|
className={`w-full flex items-center gap-3 p-1 rounded hover:bg-gray-700/50 transition-colors ${
|
||||||
|
filterRating === rating ? 'bg-gray-700/50' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1 w-20">
|
||||||
|
<span className="text-sm text-gray-400">{rating}</span>
|
||||||
|
<Star className="w-4 h-4 text-yellow-400 fill-yellow-400" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 h-2 bg-gray-700 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-yellow-400 rounded-full transition-all"
|
||||||
|
style={{ width: `${percentage}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-gray-500 w-12 text-right">
|
||||||
|
{count.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Filter className="w-4 h-4 text-gray-400" />
|
||||||
|
<span className="text-sm text-gray-400">
|
||||||
|
{filteredReviews.length} resenas
|
||||||
|
{filterRating && ` con ${filterRating} estrellas`}
|
||||||
|
</span>
|
||||||
|
{filterRating && (
|
||||||
|
<button
|
||||||
|
onClick={() => setFilterRating(null)}
|
||||||
|
className="text-sm text-blue-400 hover:text-blue-300"
|
||||||
|
>
|
||||||
|
Limpiar filtro
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<select
|
||||||
|
value={sortBy}
|
||||||
|
onChange={(e) => setSortBy(e.target.value as typeof sortBy)}
|
||||||
|
className="px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm text-white focus:outline-none focus:border-blue-500"
|
||||||
|
>
|
||||||
|
<option value="recent">Mas recientes</option>
|
||||||
|
<option value="helpful">Mas utiles</option>
|
||||||
|
<option value="rating">Mayor calificacion</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Reviews List */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
{sortedReviews.length === 0 ? (
|
||||||
|
<div className="text-center py-12 bg-gray-800 rounded-xl border border-gray-700">
|
||||||
|
<MessageSquare className="w-12 h-12 text-gray-500 mx-auto mb-4" />
|
||||||
|
<h4 className="font-medium text-white mb-2">Sin resenas</h4>
|
||||||
|
<p className="text-gray-400 text-sm">
|
||||||
|
{filterRating
|
||||||
|
? `No hay resenas con ${filterRating} estrellas`
|
||||||
|
: 'Se el primero en dejar una resena'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
sortedReviews.map((review) => (
|
||||||
|
<ReviewCard
|
||||||
|
key={review.id}
|
||||||
|
review={review}
|
||||||
|
onMarkHelpful={() => onMarkHelpful?.(review.id)}
|
||||||
|
onReport={() => onReport?.(review.id)}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Load More */}
|
||||||
|
{hasMore && (
|
||||||
|
<div className="text-center">
|
||||||
|
<button
|
||||||
|
onClick={onLoadMore}
|
||||||
|
disabled={loading}
|
||||||
|
className="inline-flex items-center gap-2 px-6 py-3 bg-gray-700 hover:bg-gray-600 text-white rounded-lg transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="w-5 h-5" />
|
||||||
|
)}
|
||||||
|
Cargar mas resenas
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Individual Review Card
|
||||||
|
interface ReviewCardProps {
|
||||||
|
review: Review;
|
||||||
|
onMarkHelpful?: () => void;
|
||||||
|
onReport?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ReviewCard: React.FC<ReviewCardProps> = ({ review, onMarkHelpful, onReport }) => {
|
||||||
|
const renderStars = (rating: number) => (
|
||||||
|
<div className="flex items-center gap-0.5">
|
||||||
|
{[1, 2, 3, 4, 5].map((star) => (
|
||||||
|
<Star
|
||||||
|
key={star}
|
||||||
|
className={`w-4 h-4 ${
|
||||||
|
star <= rating ? 'text-yellow-400 fill-yellow-400' : 'text-gray-600'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const formatDate = (dateString: string) => {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
return date.toLocaleDateString('es-ES', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-gray-800 rounded-xl border border-gray-700 p-5">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-start justify-between mb-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{review.userAvatar ? (
|
||||||
|
<img
|
||||||
|
src={review.userAvatar}
|
||||||
|
alt={review.userName}
|
||||||
|
className="w-10 h-10 rounded-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-10 h-10 rounded-full bg-gray-700 flex items-center justify-center">
|
||||||
|
<User className="w-5 h-5 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium text-white">{review.userName}</span>
|
||||||
|
{review.verified && (
|
||||||
|
<span className="px-2 py-0.5 bg-green-500/20 text-green-400 text-xs rounded">
|
||||||
|
Verificado
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 mt-0.5">
|
||||||
|
{renderStars(review.rating)}
|
||||||
|
<span className="text-xs text-gray-500">{formatDate(review.createdAt)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
{review.title && (
|
||||||
|
<h4 className="font-medium text-white mb-2">{review.title}</h4>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Comment */}
|
||||||
|
<p className="text-gray-300 text-sm leading-relaxed">{review.comment}</p>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex items-center gap-4 mt-4 pt-4 border-t border-gray-700">
|
||||||
|
<button
|
||||||
|
onClick={onMarkHelpful}
|
||||||
|
className="flex items-center gap-2 text-sm text-gray-400 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<ThumbsUp className="w-4 h-4" />
|
||||||
|
<span>Util ({review.helpful})</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onReport}
|
||||||
|
className="text-sm text-gray-500 hover:text-gray-400 transition-colors"
|
||||||
|
>
|
||||||
|
Reportar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CourseReviews;
|
||||||
341
src/modules/education/components/LessonNotes.tsx
Normal file
341
src/modules/education/components/LessonNotes.tsx
Normal file
@ -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<void>;
|
||||||
|
onUpdateNote: (noteId: string, content: string) => Promise<void>;
|
||||||
|
onDeleteNote: (noteId: string) => Promise<void>;
|
||||||
|
onSeekToTimestamp?: (timestamp: number) => void;
|
||||||
|
onExportNotes?: () => void;
|
||||||
|
collapsed?: boolean;
|
||||||
|
onToggleCollapse?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LessonNotes: React.FC<LessonNotesProps> = ({
|
||||||
|
lessonId,
|
||||||
|
courseId,
|
||||||
|
notes,
|
||||||
|
currentVideoTime,
|
||||||
|
onSaveNote,
|
||||||
|
onUpdateNote,
|
||||||
|
onDeleteNote,
|
||||||
|
onSeekToTimestamp,
|
||||||
|
onExportNotes,
|
||||||
|
collapsed = false,
|
||||||
|
onToggleCollapse,
|
||||||
|
}) => {
|
||||||
|
const [newNote, setNewNote] = useState('');
|
||||||
|
const [editingNoteId, setEditingNoteId] = useState<string | null>(null);
|
||||||
|
const [editContent, setEditContent] = useState('');
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [lastSaved, setLastSaved] = useState<Date | null>(null);
|
||||||
|
const [includeTimestamp, setIncludeTimestamp] = useState(true);
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement>(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<HTMLTextAreaElement>) => {
|
||||||
|
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 (
|
||||||
|
<div className="bg-gray-800 rounded-xl border border-gray-700 overflow-hidden">
|
||||||
|
{/* Header */}
|
||||||
|
<button
|
||||||
|
onClick={onToggleCollapse}
|
||||||
|
className="w-full flex items-center justify-between p-4 hover:bg-gray-700/50 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<StickyNote className="w-5 h-5 text-yellow-400" />
|
||||||
|
<span className="font-medium text-white">Mis Notas</span>
|
||||||
|
<span className="px-2 py-0.5 bg-gray-700 text-gray-400 text-xs rounded-full">
|
||||||
|
{notes.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{lastSaved && (
|
||||||
|
<span className="text-xs text-gray-500 flex items-center gap-1">
|
||||||
|
<CheckCircle className="w-3 h-3 text-green-400" />
|
||||||
|
Guardado
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{collapsed ? (
|
||||||
|
<ChevronDown className="w-5 h-5 text-gray-400" />
|
||||||
|
) : (
|
||||||
|
<ChevronUp className="w-5 h-5 text-gray-400" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{!collapsed && (
|
||||||
|
<div className="p-4 pt-0 space-y-4">
|
||||||
|
{/* New Note Input */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="relative">
|
||||||
|
<textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
value={newNote}
|
||||||
|
onChange={(e) => {
|
||||||
|
setNewNote(e.target.value);
|
||||||
|
adjustTextareaHeight(e.target);
|
||||||
|
}}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder="Escribe una nota... (Ctrl+Enter para guardar)"
|
||||||
|
className="w-full px-4 py-3 bg-gray-900 border border-gray-700 rounded-lg text-white placeholder-gray-500 resize-none focus:outline-none focus:border-blue-500 min-h-[80px]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{currentVideoTime !== undefined && (
|
||||||
|
<label className="flex items-center gap-2 text-sm text-gray-400 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={includeTimestamp}
|
||||||
|
onChange={(e) => setIncludeTimestamp(e.target.checked)}
|
||||||
|
className="rounded border-gray-600 bg-gray-700 text-blue-500 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
<Clock className="w-4 h-4" />
|
||||||
|
<span>Incluir timestamp ({formatTimestamp(currentVideoTime)})</span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{onExportNotes && notes.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={onExportNotes}
|
||||||
|
className="p-2 text-gray-400 hover:text-white hover:bg-gray-700 rounded-lg transition-colors"
|
||||||
|
title="Exportar notas"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={handleSaveNote}
|
||||||
|
disabled={!newNote.trim() || saving}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed text-sm"
|
||||||
|
>
|
||||||
|
{saving ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<BookmarkPlus className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
Guardar Nota
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Notes List */}
|
||||||
|
{sortedNotes.length > 0 && (
|
||||||
|
<div className="space-y-3 pt-4 border-t border-gray-700">
|
||||||
|
{sortedNotes.map((note) => (
|
||||||
|
<div
|
||||||
|
key={note.id}
|
||||||
|
className="p-3 bg-gray-900/50 rounded-lg group"
|
||||||
|
>
|
||||||
|
{editingNoteId === note.id ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<textarea
|
||||||
|
value={editContent}
|
||||||
|
onChange={(e) => setEditContent(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 bg-gray-800 border border-gray-600 rounded-lg text-white resize-none focus:outline-none focus:border-blue-500 min-h-[60px]"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<button
|
||||||
|
onClick={cancelEditing}
|
||||||
|
className="px-3 py-1.5 text-gray-400 hover:text-white text-sm transition-colors"
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleUpdateNote(note.id)}
|
||||||
|
disabled={!editContent.trim() || saving}
|
||||||
|
className="flex items-center gap-1 px-3 py-1.5 bg-blue-600 hover:bg-blue-500 text-white rounded text-sm transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{saving && <Loader2 className="w-3 h-3 animate-spin" />}
|
||||||
|
Guardar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Timestamp */}
|
||||||
|
{note.timestamp !== undefined && onSeekToTimestamp && (
|
||||||
|
<button
|
||||||
|
onClick={() => onSeekToTimestamp(note.timestamp!)}
|
||||||
|
className="inline-flex items-center gap-1 px-2 py-0.5 bg-blue-500/20 text-blue-400 text-xs rounded mb-2 hover:bg-blue-500/30 transition-colors"
|
||||||
|
>
|
||||||
|
<Clock className="w-3 h-3" />
|
||||||
|
{formatTimestamp(note.timestamp)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Note Content */}
|
||||||
|
<p className="text-gray-300 text-sm whitespace-pre-wrap">
|
||||||
|
{note.content}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex items-center justify-between mt-2 pt-2 border-t border-gray-700/50">
|
||||||
|
<span className="text-xs text-gray-500">
|
||||||
|
{new Date(note.updatedAt).toLocaleDateString('es-ES', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<button
|
||||||
|
onClick={() => startEditing(note)}
|
||||||
|
className="p-1.5 text-gray-400 hover:text-white hover:bg-gray-700 rounded transition-colors"
|
||||||
|
title="Editar"
|
||||||
|
>
|
||||||
|
<Save className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeleteNote(note.id)}
|
||||||
|
className="p-1.5 text-gray-400 hover:text-red-400 hover:bg-gray-700 rounded transition-colors"
|
||||||
|
title="Eliminar"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Empty State */}
|
||||||
|
{notes.length === 0 && (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<StickyNote className="w-10 h-10 text-gray-600 mx-auto mb-3" />
|
||||||
|
<p className="text-gray-500 text-sm">
|
||||||
|
Aun no tienes notas para esta leccion
|
||||||
|
</p>
|
||||||
|
<p className="text-gray-600 text-xs mt-1">
|
||||||
|
Escribe arriba para agregar tu primera nota
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LessonNotes;
|
||||||
330
src/modules/education/components/RecommendedCourses.tsx
Normal file
330
src/modules/education/components/RecommendedCourses.tsx
Normal file
@ -0,0 +1,330 @@
|
|||||||
|
/**
|
||||||
|
* RecommendedCourses Component
|
||||||
|
* Displays recommended courses based on user progress and interests
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
BookOpen,
|
||||||
|
Clock,
|
||||||
|
Star,
|
||||||
|
Users,
|
||||||
|
ChevronRight,
|
||||||
|
Sparkles,
|
||||||
|
TrendingUp,
|
||||||
|
Zap,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import type { CourseListItem } from '../../../types/education.types';
|
||||||
|
|
||||||
|
interface RecommendedCoursesProps {
|
||||||
|
courses: CourseListItem[];
|
||||||
|
title?: string;
|
||||||
|
subtitle?: string;
|
||||||
|
reason?: 'similar' | 'popular' | 'new' | 'continue' | 'trending';
|
||||||
|
maxItems?: number;
|
||||||
|
layout?: 'horizontal' | 'vertical' | 'compact';
|
||||||
|
showViewAll?: boolean;
|
||||||
|
viewAllLink?: string;
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const difficultyColors = {
|
||||||
|
beginner: 'bg-green-500/20 text-green-400',
|
||||||
|
intermediate: 'bg-yellow-500/20 text-yellow-400',
|
||||||
|
advanced: 'bg-red-500/20 text-red-400',
|
||||||
|
};
|
||||||
|
|
||||||
|
const difficultyLabels = {
|
||||||
|
beginner: 'Principiante',
|
||||||
|
intermediate: 'Intermedio',
|
||||||
|
advanced: 'Avanzado',
|
||||||
|
};
|
||||||
|
|
||||||
|
const reasonIcons = {
|
||||||
|
similar: <BookOpen className="w-4 h-4" />,
|
||||||
|
popular: <TrendingUp className="w-4 h-4" />,
|
||||||
|
new: <Sparkles className="w-4 h-4" />,
|
||||||
|
continue: <ChevronRight className="w-4 h-4" />,
|
||||||
|
trending: <Zap className="w-4 h-4" />,
|
||||||
|
};
|
||||||
|
|
||||||
|
const reasonLabels = {
|
||||||
|
similar: 'Similar a tus cursos',
|
||||||
|
popular: 'Mas populares',
|
||||||
|
new: 'Nuevos cursos',
|
||||||
|
continue: 'Continua aprendiendo',
|
||||||
|
trending: 'En tendencia',
|
||||||
|
};
|
||||||
|
|
||||||
|
const RecommendedCourses: React.FC<RecommendedCoursesProps> = ({
|
||||||
|
courses,
|
||||||
|
title = 'Cursos Recomendados',
|
||||||
|
subtitle,
|
||||||
|
reason = 'popular',
|
||||||
|
maxItems = 4,
|
||||||
|
layout = 'horizontal',
|
||||||
|
showViewAll = true,
|
||||||
|
viewAllLink = '/education/courses',
|
||||||
|
loading = false,
|
||||||
|
}) => {
|
||||||
|
const displayCourses = courses.slice(0, maxItems);
|
||||||
|
|
||||||
|
const formatDuration = (minutes: number) => {
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
const mins = minutes % 60;
|
||||||
|
if (hours === 0) return `${mins}m`;
|
||||||
|
if (mins === 0) return `${hours}h`;
|
||||||
|
return `${hours}h ${mins}m`;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="h-7 w-48 bg-gray-700 rounded animate-pulse" />
|
||||||
|
<div className="h-5 w-20 bg-gray-700 rounded animate-pulse" />
|
||||||
|
</div>
|
||||||
|
<div className={`grid gap-4 ${layout === 'horizontal' ? 'grid-cols-1 md:grid-cols-2 lg:grid-cols-4' : 'grid-cols-1'}`}>
|
||||||
|
{[...Array(maxItems)].map((_, i) => (
|
||||||
|
<div key={i} className="bg-gray-800 rounded-xl border border-gray-700 p-4 animate-pulse">
|
||||||
|
<div className="aspect-video bg-gray-700 rounded-lg mb-4" />
|
||||||
|
<div className="h-5 bg-gray-700 rounded w-3/4 mb-2" />
|
||||||
|
<div className="h-4 bg-gray-700 rounded w-1/2" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (courses.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-blue-400">{reasonIcons[reason]}</span>
|
||||||
|
<h3 className="text-lg font-bold text-white">{title}</h3>
|
||||||
|
</div>
|
||||||
|
{subtitle && <p className="text-sm text-gray-400 mt-0.5">{subtitle}</p>}
|
||||||
|
</div>
|
||||||
|
{showViewAll && (
|
||||||
|
<Link
|
||||||
|
to={viewAllLink}
|
||||||
|
className="flex items-center gap-1 text-sm text-blue-400 hover:text-blue-300 transition-colors"
|
||||||
|
>
|
||||||
|
Ver todos
|
||||||
|
<ChevronRight className="w-4 h-4" />
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Reason Badge */}
|
||||||
|
<div className="inline-flex items-center gap-2 px-3 py-1 bg-blue-500/10 border border-blue-500/20 rounded-full">
|
||||||
|
{reasonIcons[reason]}
|
||||||
|
<span className="text-xs text-blue-400">{reasonLabels[reason]}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Courses Grid */}
|
||||||
|
{layout === 'compact' ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{displayCourses.map((course) => (
|
||||||
|
<CompactCourseCard key={course.id} course={course} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : layout === 'vertical' ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{displayCourses.map((course) => (
|
||||||
|
<VerticalCourseCard key={course.id} course={course} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
{displayCourses.map((course) => (
|
||||||
|
<HorizontalCourseCard key={course.id} course={course} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Horizontal Card (Default)
|
||||||
|
const HorizontalCourseCard: React.FC<{ course: CourseListItem }> = ({ course }) => {
|
||||||
|
const formatDuration = (minutes: number) => {
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
return hours > 0 ? `${hours}h` : `${minutes}m`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
to={`/education/courses/${course.slug}`}
|
||||||
|
className="group bg-gray-800 rounded-xl border border-gray-700 overflow-hidden hover:border-gray-600 transition-all hover:shadow-lg"
|
||||||
|
>
|
||||||
|
{/* Thumbnail */}
|
||||||
|
<div className="relative aspect-video bg-gray-900">
|
||||||
|
{course.thumbnailUrl ? (
|
||||||
|
<img
|
||||||
|
src={course.thumbnailUrl}
|
||||||
|
alt={course.title}
|
||||||
|
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full flex items-center justify-center">
|
||||||
|
<BookOpen className="w-10 h-10 text-gray-600" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{course.isFree && (
|
||||||
|
<span className="absolute top-2 right-2 px-2 py-0.5 bg-green-500 text-white text-xs font-medium rounded">
|
||||||
|
GRATIS
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div className="absolute bottom-2 right-2 px-2 py-0.5 bg-black/70 text-white text-xs rounded">
|
||||||
|
{formatDuration(course.totalDuration)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="p-4">
|
||||||
|
<span className={`inline-block px-2 py-0.5 text-xs rounded mb-2 ${difficultyColors[course.difficultyLevel]}`}>
|
||||||
|
{difficultyLabels[course.difficultyLevel]}
|
||||||
|
</span>
|
||||||
|
<h4 className="font-medium text-white line-clamp-2 mb-2 group-hover:text-blue-400 transition-colors">
|
||||||
|
{course.title}
|
||||||
|
</h4>
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<div className="flex items-center gap-1 text-yellow-400">
|
||||||
|
<Star className="w-4 h-4 fill-yellow-400" />
|
||||||
|
<span>{course.avgRating.toFixed(1)}</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-gray-500">{course.totalLessons} lecciones</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Vertical Card (List view)
|
||||||
|
const VerticalCourseCard: React.FC<{ course: CourseListItem }> = ({ course }) => {
|
||||||
|
const formatDuration = (minutes: number) => {
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
const mins = minutes % 60;
|
||||||
|
if (hours === 0) return `${mins} min`;
|
||||||
|
return `${hours}h ${mins}m`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
to={`/education/courses/${course.slug}`}
|
||||||
|
className="group flex gap-4 p-4 bg-gray-800 rounded-xl border border-gray-700 hover:border-gray-600 transition-all"
|
||||||
|
>
|
||||||
|
{/* Thumbnail */}
|
||||||
|
<div className="relative w-40 h-24 flex-shrink-0 rounded-lg overflow-hidden bg-gray-900">
|
||||||
|
{course.thumbnailUrl ? (
|
||||||
|
<img
|
||||||
|
src={course.thumbnailUrl}
|
||||||
|
alt={course.title}
|
||||||
|
className="w-full h-full object-cover group-hover:scale-105 transition-transform"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full flex items-center justify-center">
|
||||||
|
<BookOpen className="w-8 h-8 text-gray-600" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{course.isFree && (
|
||||||
|
<span className="absolute top-1 left-1 px-1.5 py-0.5 bg-green-500 text-white text-xs font-medium rounded">
|
||||||
|
GRATIS
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<h4 className="font-medium text-white line-clamp-2 group-hover:text-blue-400 transition-colors">
|
||||||
|
{course.title}
|
||||||
|
</h4>
|
||||||
|
<span className={`flex-shrink-0 px-2 py-0.5 text-xs rounded ${difficultyColors[course.difficultyLevel]}`}>
|
||||||
|
{difficultyLabels[course.difficultyLevel]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{course.shortDescription && (
|
||||||
|
<p className="text-sm text-gray-400 line-clamp-1 mt-1">{course.shortDescription}</p>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-4 mt-2 text-sm text-gray-500">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Clock className="w-4 h-4" />
|
||||||
|
{formatDuration(course.totalDuration)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<BookOpen className="w-4 h-4" />
|
||||||
|
{course.totalLessons} lecciones
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 text-yellow-400">
|
||||||
|
<Star className="w-4 h-4 fill-yellow-400" />
|
||||||
|
{course.avgRating.toFixed(1)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Users className="w-4 h-4" />
|
||||||
|
{course.totalEnrollments.toLocaleString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Compact Card (Sidebar)
|
||||||
|
const CompactCourseCard: React.FC<{ course: CourseListItem }> = ({ course }) => (
|
||||||
|
<Link
|
||||||
|
to={`/education/courses/${course.slug}`}
|
||||||
|
className="flex items-center gap-3 p-2 rounded-lg hover:bg-gray-700/50 transition-colors group"
|
||||||
|
>
|
||||||
|
{/* Thumbnail */}
|
||||||
|
<div className="w-12 h-12 flex-shrink-0 rounded-lg overflow-hidden bg-gray-700">
|
||||||
|
{course.thumbnailUrl ? (
|
||||||
|
<img
|
||||||
|
src={course.thumbnailUrl}
|
||||||
|
alt={course.title}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full flex items-center justify-center">
|
||||||
|
<BookOpen className="w-5 h-5 text-gray-500" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h4 className="font-medium text-sm text-white truncate group-hover:text-blue-400 transition-colors">
|
||||||
|
{course.title}
|
||||||
|
</h4>
|
||||||
|
<div className="flex items-center gap-2 mt-0.5">
|
||||||
|
<div className="flex items-center gap-0.5 text-yellow-400">
|
||||||
|
<Star className="w-3 h-3 fill-yellow-400" />
|
||||||
|
<span className="text-xs">{course.avgRating.toFixed(1)}</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-gray-500">{course.totalLessons} lecciones</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Price/Free badge */}
|
||||||
|
{course.isFree ? (
|
||||||
|
<span className="px-2 py-0.5 bg-green-500/20 text-green-400 text-xs rounded">
|
||||||
|
Gratis
|
||||||
|
</span>
|
||||||
|
) : course.priceUsd ? (
|
||||||
|
<span className="text-sm font-medium text-white">
|
||||||
|
${course.priceUsd}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default RecommendedCourses;
|
||||||
9
src/modules/education/components/index.ts
Normal file
9
src/modules/education/components/index.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* Education Components Index
|
||||||
|
* Export all education-related components
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { default as CertificatePreview } from './CertificatePreview';
|
||||||
|
export { default as CourseReviews } from './CourseReviews';
|
||||||
|
export { default as LessonNotes } from './LessonNotes';
|
||||||
|
export { default as RecommendedCourses } from './RecommendedCourses';
|
||||||
Loading…
Reference in New Issue
Block a user