Merge pull request #4 from Rynext/feat-home

Feat home
This commit is contained in:
Frank Rojas authored and GitHub committed 2026-08-26 17:25:31 -05:00
commit ce98f270d5
26 files changed
+3926 -1226

No files matched your search

+51 -13
View File
@@ -7,11 +7,13 @@ import { Footer } from './components/layout/Footer';
import { HomeView } from './views/HomeView';
import { BuyerView } from './views/BuyerView';
import { SellerView } from './views/SellerView';
import { TeachersView } from './views/TeachersView';
import { CourseDetailView } from './views/CourseDetailView';
import { CreateCourseView } from './views/CreateCourseView';
import { AdminDashboardView } from './views/AdminDashboardView';
import { LoginView } from './views/LoginView';
import { RegisterView } from './views/RegisterView';
import { TermsView } from './views/TermsView';
import { CourseModal } from './components/buyer/CourseModal';
import { CartDrawer } from './components/buyer/CartDrawer';
import { CreateCourseModal } from './components/seller/CreateCourseModal';
@@ -19,7 +21,9 @@ import { FiCheckCircle } from 'react-icons/fi';
export function App() {
const location = useLocation();
const isAuthPage = location.pathname === '/login' || location.pathname === '/register';
const isAuthPage = location.pathname === '/login' || location.pathname === '/register' || location.pathname === '/terms';
const isDashboardPage = location.pathname === '/admin' || location.pathname === '/teachers';
const hideNavbarFooter = isAuthPage || isDashboardPage;
const [courses, setCourses] = useState<Course[]>(mockCourses);
const [cartItems, setCartItems] = useState<CartItem[]>([]);
@@ -27,6 +31,7 @@ export function App() {
// Modals & Drawers state
const [selectedCourseForModal, setSelectedCourseForModal] = useState<Course | null>(null);
const [isTeacherPreviewModal, setIsTeacherPreviewModal] = useState(false);
const [isCartOpen, setIsCartOpen] = useState(false);
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
@@ -95,18 +100,18 @@ export function App() {
const cartCourseIds = cartItems.map((i) => i.course.id);
return (
<div className="min-h-screen bg-slate-950 text-slate-100 flex flex-col selection:bg-cyan-500 selection:text-slate-950">
<div className="min-h-screen flex flex-col transition-colors duration-300 bg-slate-950 text-slate-100 dark:bg-slate-950 dark:text-slate-100 selection:bg-cyan-500">
{/* Toast notification banner */}
{toastMessage && (
<div className="fixed bottom-6 right-6 z-50 flex items-center gap-3 px-5 py-3.5 rounded-2xl bg-slate-900/95 border border-cyan-500/40 text-white shadow-2xl shadow-cyan-500/20 backdrop-blur-md animate-in slide-in-from-bottom-5 duration-300">
<FiCheckCircle className="text-cyan-400 text-lg shrink-0" />
<div className="fixed bottom-6 right-6 z-50 flex items-center gap-3 px-5 py-3.5 rounded-2xl border shadow-2xl backdrop-blur-md animate-in slide-in-from-bottom-5 duration-300 bg-slate-900/95 border-cyan-500/40 text-white shadow-cyan-500/20">
<FiCheckCircle className="text-cyan-500 text-lg shrink-0" />
<span className="text-xs font-semibold">{toastMessage}</span>
</div>
)}
{/* Main Navigation Bar (hidden on Auth pages) */}
{!isAuthPage && (
{/* Main Navigation Bar (hidden on Auth & Dashboard pages) */}
{!hideNavbarFooter && (
<Navbar
currentUser={currentUser}
onLogout={handleLogout}
@@ -132,8 +137,12 @@ export function App() {
onOpenPreview={(courseId) => {
const target = courses.find((c) => c.id === courseId) || courses[0];
setSelectedCourseForModal(target);
setIsTeacherPreviewModal(false);
}}
onOpenDetails={(course) => {
setSelectedCourseForModal(course);
setIsTeacherPreviewModal(false);
}}
onOpenDetails={(course) => setSelectedCourseForModal(course)}
onClearSearch={() => setSearchQuery('')}
/>
}
@@ -146,7 +155,10 @@ export function App() {
cartCourseIds={cartCourseIds}
searchQuery={searchQuery}
onAddToCart={handleAddToCart}
onOpenDetails={(course) => setSelectedCourseForModal(course)}
onOpenDetails={(course) => {
setSelectedCourseForModal(course);
setIsTeacherPreviewModal(false);
}}
onClearSearch={() => setSearchQuery('')}
/>
}
@@ -172,13 +184,31 @@ export function App() {
/>
}
/>
<Route
path="/teachers"
element={
<TeachersView
courses={courses}
onOpenCreateCourse={() => setIsCreateModalOpen(true)}
onPreviewCourse={(course) => {
setSelectedCourseForModal(course);
setIsTeacherPreviewModal(true);
}}
onDeleteCourse={handleDeleteCourse}
onShowToast={showToast}
/>
}
/>
<Route
path="/seller"
element={
<SellerView
currentUser={currentUser}
courses={courses}
onOpenCreateCourse={() => setIsCreateModalOpen(true)}
onPreviewCourse={(course) => setSelectedCourseForModal(course)}
onPreviewCourse={(course) => {
setSelectedCourseForModal(course);
setIsTeacherPreviewModal(true);
}}
onDeleteCourse={handleDeleteCourse}
/>
}
@@ -192,6 +222,10 @@ export function App() {
/>
}
/>
<Route
path="/terms"
element={<TermsView />}
/>
<Route
path="/login"
element={<LoginView onShowToast={showToast} onLogin={handleLogin} />}
@@ -208,7 +242,11 @@ export function App() {
course={selectedCourseForModal}
isOpen={!!selectedCourseForModal}
isInCart={selectedCourseForModal ? cartCourseIds.includes(selectedCourseForModal.id) : false}
onClose={() => setSelectedCourseForModal(null)}
isTeacherPreview={isTeacherPreviewModal}
onClose={() => {
setSelectedCourseForModal(null);
setIsTeacherPreviewModal(false);
}}
onAddToCart={handleAddToCart}
onBuyNow={handleBuyNow}
/>
@@ -229,8 +267,8 @@ export function App() {
onCreateCourse={handleCreateCourse}
/>
{/* Global Footer (hidden on Auth pages) */}
{!isAuthPage && <Footer />}
{/* Global Footer (hidden on Auth & Dashboard pages) */}
{!hideNavbarFooter && <Footer />}
</div>
);
+105 -77
View File
@@ -11,7 +11,8 @@ import {
FiShield,
FiChevronDown,
FiChevronUp,
FiShoppingCart
FiShoppingCart,
FiLock
} from 'react-icons/fi';
import type { Course } from '../../types/course';
@@ -19,6 +20,7 @@ interface CourseModalProps {
course: Course | null;
isOpen: boolean;
isInCart: boolean;
isTeacherPreview?: boolean;
onClose: () => void;
onAddToCart: (course: Course) => void;
onBuyNow: (course: Course) => void;
@@ -28,6 +30,7 @@ export const CourseModal: React.FC<CourseModalProps> = ({
course,
isOpen,
isInCart,
isTeacherPreview = false,
onClose,
onAddToCart,
onBuyNow,
@@ -53,24 +56,29 @@ export const CourseModal: React.FC<CourseModalProps> = ({
return (
<div className="fixed inset-0 z-50 overflow-y-auto bg-slate-950/80 backdrop-blur-md flex items-center justify-center p-2 sm:p-4 animate-in fade-in duration-200">
{/* Modal Container */}
{/* Modal Container with full Light / Dark Mode support */}
<div
className="relative w-full max-w-5xl bg-slate-900 border border-slate-800 rounded-2xl shadow-2xl overflow-hidden my-8 max-h-[92vh] flex flex-col"
className="relative w-full max-w-5xl transition-colors duration-300 bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 text-slate-900 dark:text-slate-100 rounded-2xl shadow-2xl overflow-hidden my-8 max-h-[92vh] flex flex-col"
onClick={(e) => e.stopPropagation()}
>
{/* Top Header Bar */}
<div className="flex items-center justify-between px-6 py-4 border-b border-slate-800 bg-slate-950/60 sticky top-0 z-20">
<div className="flex items-center justify-between px-6 py-4 border-b border-slate-200 dark:border-slate-800 bg-slate-50 dark:bg-slate-950/60 sticky top-0 z-20">
<div className="flex items-center gap-3">
<span className="text-xs font-semibold px-2.5 py-1 rounded bg-cyan-500/10 text-cyan-400 border border-cyan-500/20">
<span className="text-xs font-semibold px-2.5 py-1 rounded bg-cyan-500/10 text-cyan-600 dark:text-cyan-400 border border-cyan-500/20">
{course.category}
</span>
<span className="text-xs text-slate-400">• Nivel: {course.level}</span>
<span className="text-xs text-slate-500 dark:text-slate-400">• Nivel: {course.level}</span>
{isTeacherPreview && (
<span className="text-xs font-bold px-2.5 py-1 rounded bg-amber-500/10 text-amber-600 dark:text-amber-400 border border-amber-500/20 flex items-center gap-1">
<FiShield /> Vista Previa de Docente
</span>
)}
</div>
<button
onClick={onClose}
className="p-2 rounded-xl text-slate-400 hover:text-white hover:bg-slate-800 transition"
className="p-2 rounded-xl text-slate-400 hover:text-slate-900 dark:hover:text-white hover:bg-slate-200 dark:hover:bg-slate-800 transition"
aria-label="Cerrar modal"
>
<FiX className="text-xl" />
@@ -85,20 +93,20 @@ export const CourseModal: React.FC<CourseModalProps> = ({
{/* Main Info */}
<div className="lg:col-span-7 space-y-4">
<h2 className="text-2xl sm:text-3xl font-extrabold text-white leading-tight">
<h2 className="text-2xl sm:text-3xl font-extrabold text-slate-900 dark:text-white leading-tight">
{course.title}
</h2>
<p className="text-slate-300 text-sm leading-relaxed">
<p className="text-slate-600 dark:text-slate-300 text-sm leading-relaxed">
{course.subtitle}
</p>
{/* Meta stats */}
<div className="flex flex-wrap items-center gap-4 text-xs pt-2 text-slate-400 border-t border-slate-800/80">
<div className="flex items-center gap-1.5 text-amber-400 font-bold">
<div className="flex flex-wrap items-center gap-4 text-xs pt-2 text-slate-500 dark:text-slate-400 border-t border-slate-200 dark:border-slate-800/80">
<div className="flex items-center gap-1.5 text-amber-500 dark:text-amber-400 font-bold">
<FiStar className="fill-amber-400" />
<span>{course.rating.toFixed(1)}</span>
<span className="text-slate-400 font-normal">({course.reviewsCount} reseñas)</span>
<span className="text-slate-500 dark:text-slate-400 font-normal">({course.reviewsCount} reseñas)</span>
</div>
<div>👥 {course.studentsCount.toLocaleString()} estudiantes</div>
<div className="flex items-center gap-1"><FiGlobe /> {course.language}</div>
@@ -106,14 +114,14 @@ export const CourseModal: React.FC<CourseModalProps> = ({
</div>
{/* What You Will Learn Card */}
<div className="p-5 rounded-xl bg-slate-950/60 border border-slate-800 space-y-3">
<h4 className="text-sm font-bold text-white flex items-center gap-2">
<FiAward className="text-cyan-400" /> Lo que aprenderás en este curso
<div className="p-5 rounded-xl bg-slate-50 dark:bg-slate-950/60 border border-slate-200 dark:border-slate-800 space-y-3">
<h4 className="text-sm font-bold text-slate-900 dark:text-white flex items-center gap-2">
<FiAward className="text-cyan-500 dark:text-cyan-400" /> Lo que aprenderás en este curso
</h4>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2.5 pt-2">
{course.whatYouWillLearn.map((item, idx) => (
<div key={idx} className="flex items-start gap-2 text-xs text-slate-300">
<FiCheckCircle className="text-emerald-400 text-sm mt-0.5 shrink-0" />
<div key={idx} className="flex items-start gap-2 text-xs text-slate-700 dark:text-slate-300">
<FiCheckCircle className="text-emerald-500 dark:text-emerald-400 text-sm mt-0.5 shrink-0" />
<span>{item}</span>
</div>
))}
@@ -163,62 +171,82 @@ export const CourseModal: React.FC<CourseModalProps> = ({
</div>
{/* Price & Checkout Card */}
<div className="p-5 rounded-xl bg-slate-950/80 border border-slate-800 space-y-4 shadow-xl">
<div className="p-5 rounded-xl bg-slate-50 dark:bg-slate-950/80 border border-slate-200 dark:border-slate-800 space-y-4 shadow-xl">
<div className="flex items-baseline justify-between">
<div className="flex items-baseline gap-2">
<span className="text-3xl font-black text-white font-mono">
<span className="text-3xl font-black text-slate-900 dark:text-white font-mono">
${course.price.toFixed(2)}
</span>
{course.originalPrice > course.price && (
<span className="text-sm text-slate-500 line-through font-mono">
<span className="text-sm text-slate-400 line-through font-mono">
${course.originalPrice.toFixed(2)}
</span>
)}
</div>
{discountPercent > 0 && (
<span className="px-2 py-1 rounded bg-rose-500/20 border border-rose-500/30 text-rose-400 text-xs font-bold">
<span className="px-2 py-1 rounded bg-rose-500/20 border border-rose-500/30 text-rose-500 dark:text-rose-400 text-xs font-bold">
{discountPercent}% Descuento
</span>
)}
</div>
<div className="space-y-2">
<button
onClick={() => {
onBuyNow(course);
onClose();
}}
className="w-full py-3 bg-gradient-to-r from-emerald-600 to-teal-500 hover:from-emerald-500 hover:to-teal-400 text-white font-bold rounded-xl text-sm shadow-lg shadow-emerald-500/20 transition-all hover:scale-[1.01]"
>
Comprar Ahora
</button>
{/* If isTeacherPreview: Disable Buy & Cart buttons */}
{isTeacherPreview ? (
<div className="space-y-2 pt-1">
<button
disabled
className="w-full py-3 bg-slate-200 dark:bg-slate-800 text-slate-500 dark:text-slate-400 font-bold rounded-xl text-xs border border-slate-300 dark:border-slate-700 cursor-not-allowed flex items-center justify-center gap-2 select-none"
title="Este curso pertenece a tu propia cuenta de instructor"
>
<FiLock className="text-sm" /> No puedes comprar tu propio curso
</button>
<button
onClick={() => onAddToCart(course)}
disabled={isInCart}
className={`w-full py-2.5 rounded-xl text-sm font-bold flex items-center justify-center gap-2 transition-all ${
isInCart
? 'bg-slate-800 text-slate-400 border border-slate-700 cursor-default'
: 'bg-slate-800 hover:bg-slate-700 text-white border border-slate-700'
}`}
>
<FiShoppingCart />
<span>{isInCart ? 'Ya está en el carrito' : 'Añadir al Carrito'}</span>
</button>
</div>
<button
disabled
className="w-full py-2.5 bg-slate-200/60 dark:bg-slate-800/60 text-slate-400 dark:text-slate-500 font-semibold rounded-xl text-xs border border-slate-300 dark:border-slate-800 cursor-not-allowed flex items-center justify-center gap-2 select-none"
>
<FiShoppingCart className="text-sm" /> Añadir al Carrito (Deshabilitado)
</button>
</div>
) : (
<div className="space-y-2">
<button
onClick={() => {
onBuyNow(course);
onClose();
}}
className="w-full py-3 bg-gradient-to-r from-emerald-600 to-teal-500 hover:from-emerald-500 hover:to-teal-400 text-white font-bold rounded-xl text-sm shadow-lg shadow-emerald-500/20 transition-all hover:scale-[1.01]"
>
Comprar Ahora
</button>
<div className="space-y-1.5 text-[11px] text-slate-400 pt-2 border-t border-slate-800">
<button
onClick={() => onAddToCart(course)}
disabled={isInCart}
className={`w-full py-2.5 rounded-xl text-sm font-bold flex items-center justify-center gap-2 transition-all ${
isInCart
? 'bg-slate-200 dark:bg-slate-800 text-slate-400 dark:text-slate-400 border border-slate-300 dark:border-slate-700 cursor-default'
: 'bg-slate-900 dark:bg-slate-800 hover:bg-slate-800 dark:hover:bg-slate-700 text-white border border-slate-800 dark:border-slate-700'
}`}
>
<FiShoppingCart />
<span>{isInCart ? 'Ya está en el carrito' : 'Añadir al Carrito'}</span>
</button>
</div>
)}
<div className="space-y-1.5 text-[11px] text-slate-500 dark:text-slate-400 pt-2 border-t border-slate-200 dark:border-slate-800">
<div className="flex items-center gap-2">
<FiClock className="text-cyan-400" /> {course.duration} de video bajo demanda
<FiClock className="text-cyan-500 dark:text-cyan-400" /> {course.duration} de video bajo demanda
</div>
<div className="flex items-center gap-2">
<FiBook className="text-cyan-400" /> {course.lessonsCount} lecciones descargables
<FiBook className="text-cyan-500 dark:text-cyan-400" /> {course.lessonsCount} lecciones descargables
</div>
<div className="flex items-center gap-2">
<FiShield className="text-emerald-400" /> Garantía de 30 días o te devolvemos el dinero
<FiShield className="text-emerald-500 dark:text-emerald-400" /> Garantía de 30 días o te devolvemos el dinero
</div>
<div className="flex items-center gap-2">
<FiAward className="text-cyan-400" /> Certificado de acreditación oficial
<FiAward className="text-cyan-500 dark:text-cyan-400" /> Certificado de acreditación oficial
</div>
</div>
</div>
@@ -227,13 +255,13 @@ export const CourseModal: React.FC<CourseModalProps> = ({
</div>
{/* Nav Tabs inside modal */}
<div className="border-b border-slate-800 flex gap-6 text-sm font-semibold">
<div className="border-b border-slate-200 dark:border-slate-800 flex gap-6 text-sm font-semibold">
<button
onClick={() => setActiveTab('curriculum')}
className={`pb-3 transition relative ${
activeTab === 'curriculum'
? 'text-cyan-400 border-b-2 border-cyan-400'
: 'text-slate-400 hover:text-slate-200'
? 'text-cyan-600 dark:text-cyan-400 border-b-2 border-cyan-500'
: 'text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-slate-200'
}`}
>
Contenido del Curso ({course.modules.length} Módulos)
@@ -242,8 +270,8 @@ export const CourseModal: React.FC<CourseModalProps> = ({
onClick={() => setActiveTab('instructor')}
className={`pb-3 transition relative ${
activeTab === 'instructor'
? 'text-cyan-400 border-b-2 border-cyan-400'
: 'text-slate-400 hover:text-slate-200'
? 'text-cyan-600 dark:text-cyan-400 border-b-2 border-cyan-500'
: 'text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-slate-200'
}`}
>
Instructor
@@ -252,8 +280,8 @@ export const CourseModal: React.FC<CourseModalProps> = ({
onClick={() => setActiveTab('reviews')}
className={`pb-3 transition relative ${
activeTab === 'reviews'
? 'text-cyan-400 border-b-2 border-cyan-400'
: 'text-slate-400 hover:text-slate-200'
? 'text-cyan-600 dark:text-cyan-400 border-b-2 border-cyan-500'
: 'text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-slate-200'
}`}
>
Valoraciones ({course.reviews.length})
@@ -263,7 +291,7 @@ export const CourseModal: React.FC<CourseModalProps> = ({
{/* Tab Content 1: Curriculum */}
{activeTab === 'curriculum' && (
<div className="space-y-4">
<div className="flex items-center justify-between text-xs text-slate-400">
<div className="flex items-center justify-between text-xs text-slate-500 dark:text-slate-400">
<span>{course.modules.length} módulos • {course.lessonsCount} lecciones • {course.duration} duración total</span>
</div>
@@ -271,29 +299,29 @@ export const CourseModal: React.FC<CourseModalProps> = ({
{course.modules.map((mod) => {
const isExpanded = !!expandedModules[mod.id];
return (
<div key={mod.id} className="rounded-xl border border-slate-800 overflow-hidden bg-slate-950/40">
<div key={mod.id} className="rounded-xl border border-slate-200 dark:border-slate-800 overflow-hidden bg-slate-50 dark:bg-slate-950/40">
<button
onClick={() => toggleModule(mod.id)}
className="w-full flex items-center justify-between p-4 bg-slate-900/60 hover:bg-slate-900 transition text-left"
className="w-full flex items-center justify-between p-4 bg-slate-100 dark:bg-slate-900/60 hover:bg-slate-200 dark:hover:bg-slate-900 transition text-left"
>
<div className="font-bold text-sm text-slate-200 flex items-center gap-2">
{isExpanded ? <FiChevronUp className="text-cyan-400" /> : <FiChevronDown className="text-slate-500" />}
<div className="font-bold text-sm text-slate-800 dark:text-slate-200 flex items-center gap-2">
{isExpanded ? <FiChevronUp className="text-cyan-500 dark:text-cyan-400" /> : <FiChevronDown className="text-slate-400 dark:text-slate-500" />}
<span>{mod.title}</span>
</div>
<span className="text-xs text-slate-400 font-mono">
<span className="text-xs text-slate-500 dark:text-slate-400 font-mono">
{mod.lessons.length} lecciones
</span>
</button>
{isExpanded && (
<div className="divide-y divide-slate-850 p-2 space-y-1">
<div className="divide-y divide-slate-200 dark:divide-slate-800 p-2 space-y-1">
{mod.lessons.map((lesson) => (
<div
key={lesson.id}
className="flex items-center justify-between p-2.5 rounded-lg hover:bg-slate-900/80 text-xs transition"
className="flex items-center justify-between p-2.5 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-900/80 text-xs transition"
>
<div className="flex items-center gap-2.5 text-slate-300">
<div className="w-5 h-5 rounded-full bg-slate-800 text-slate-400 flex items-center justify-center text-[10px]">
<div className="flex items-center gap-2.5 text-slate-700 dark:text-slate-300">
<div className="w-5 h-5 rounded-full bg-slate-200 dark:bg-slate-800 text-slate-500 dark:text-slate-400 flex items-center justify-center text-[10px]">
<FiPlay />
</div>
<span>{lesson.title}</span>
@@ -301,11 +329,11 @@ export const CourseModal: React.FC<CourseModalProps> = ({
<div className="flex items-center gap-3">
{lesson.isFreePreview && (
<span className="px-2 py-0.5 rounded bg-cyan-500/20 text-cyan-300 border border-cyan-500/30 text-[10px] font-bold">
<span className="px-2 py-0.5 rounded bg-cyan-500/20 text-cyan-700 dark:text-cyan-300 border border-cyan-500/30 text-[10px] font-bold">
Vista Previa
</span>
)}
<span className="text-slate-400 font-mono">{lesson.duration}</span>
<span className="text-slate-500 dark:text-slate-400 font-mono">{lesson.duration}</span>
</div>
</div>
))}
@@ -320,7 +348,7 @@ export const CourseModal: React.FC<CourseModalProps> = ({
{/* Tab Content 2: Instructor */}
{activeTab === 'instructor' && (
<div className="p-6 rounded-xl bg-slate-950/60 border border-slate-800 space-y-4">
<div className="p-6 rounded-xl bg-slate-50 dark:bg-slate-950/60 border border-slate-200 dark:border-slate-800 space-y-4">
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-4">
<img
src={course.instructor.avatar}
@@ -328,11 +356,11 @@ export const CourseModal: React.FC<CourseModalProps> = ({
className="w-16 h-16 rounded-full object-cover ring-2 ring-cyan-500/50"
/>
<div>
<h3 className="text-lg font-bold text-white">{course.instructor.name}</h3>
<p className="text-xs text-cyan-400 font-medium">{course.instructor.role}</p>
<h3 className="text-lg font-bold text-slate-900 dark:text-white">{course.instructor.name}</h3>
<p className="text-xs text-cyan-600 dark:text-cyan-400 font-medium">{course.instructor.role}</p>
<div className="flex items-center gap-4 text-xs text-slate-400 mt-2">
<span className="flex items-center gap-1 text-amber-400 font-bold">
<div className="flex items-center gap-4 text-xs text-slate-500 dark:text-slate-400 mt-2">
<span className="flex items-center gap-1 text-amber-500 dark:text-amber-400 font-bold">
<FiStar className="fill-amber-400" /> {course.instructor.rating} Calificación
</span>
<span>👨‍🎓 {course.instructor.totalStudents.toLocaleString()} Estudiantes</span>
@@ -341,7 +369,7 @@ export const CourseModal: React.FC<CourseModalProps> = ({
</div>
</div>
<p className="text-xs text-slate-300 leading-relaxed border-t border-slate-800 pt-4">
<p className="text-xs text-slate-700 dark:text-slate-300 leading-relaxed border-t border-slate-200 dark:border-slate-800 pt-4">
{course.instructor.bio}
</p>
</div>
@@ -351,7 +379,7 @@ export const CourseModal: React.FC<CourseModalProps> = ({
{activeTab === 'reviews' && (
<div className="space-y-4">
{course.reviews.map((rev) => (
<div key={rev.id} className="p-4 rounded-xl bg-slate-950/60 border border-slate-800 space-y-2">
<div key={rev.id} className="p-4 rounded-xl bg-slate-50 dark:bg-slate-950/60 border border-slate-200 dark:border-slate-800 space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2.5">
<img
@@ -360,7 +388,7 @@ export const CourseModal: React.FC<CourseModalProps> = ({
className="w-8 h-8 rounded-full object-cover"
/>
<div>
<h4 className="text-xs font-bold text-white">{rev.userName}</h4>
<h4 className="text-xs font-bold text-slate-900 dark:text-white">{rev.userName}</h4>
<span className="text-[10px] text-slate-500">{rev.date}</span>
</div>
</div>
@@ -372,7 +400,7 @@ export const CourseModal: React.FC<CourseModalProps> = ({
</div>
</div>
<p className="text-xs text-slate-300 leading-relaxed pl-10">
<p className="text-xs text-slate-700 dark:text-slate-300 leading-relaxed pl-10">
"{rev.comment}"
</p>
</div>
+4 -4
View File
@@ -21,7 +21,7 @@ export const BrandLogo: React.FC<BrandLogoProps> = ({ size = 'md', showSubtitle
return (
<div className="flex items-center gap-3 group select-none cursor-pointer">
{/* Official PNG Logo Image with sleek container styling */}
<div className={`relative ${iconSizes[size]} p-0.5 rounded-xl bg-slate-900 border border-slate-700/80 shadow-md shadow-cyan-950/40 group-hover:border-cyan-500/60 group-hover:scale-105 transition-all duration-300 overflow-hidden`}>
<div className={`relative ${iconSizes[size]} p-0.5 rounded-xl bg-slate-100 dark:bg-slate-900 border border-slate-200 dark:border-slate-700/80 shadow-md group-hover:border-cyan-500/60 group-hover:scale-105 transition-all duration-300 overflow-hidden`}>
<img
src="/logo.png"
alt="RyCourses Emblem"
@@ -32,12 +32,12 @@ export const BrandLogo: React.FC<BrandLogoProps> = ({ size = 'md', showSubtitle
{/* Typography */}
<div>
<div className="flex items-center gap-2">
<span className={`${textSizes[size]} font-extrabold tracking-tight text-white group-hover:text-cyan-300 transition-colors`}>
Ry<span className="text-transparent bg-clip-text bg-gradient-to-r from-cyan-400 to-blue-400">Courses</span>
<span className={`${textSizes[size]} font-extrabold tracking-tight text-slate-900 dark:text-white group-hover:text-cyan-600 dark:group-hover:text-cyan-300 transition-colors`}>
Ry<span className="text-transparent bg-clip-text bg-gradient-to-r from-cyan-500 via-sky-500 to-blue-500 dark:from-cyan-400 dark:to-blue-400">Courses</span>
</span>
</div>
{showSubtitle && (
<p className="text-[11px] text-slate-400 font-medium tracking-wide">Plataforma Educativa</p>
<p className="text-[11px] text-slate-500 dark:text-slate-400 font-medium tracking-wide">Plataforma Educativa</p>
)}
</div>
</div>
+3 -2
View File
@@ -1,4 +1,5 @@
import React from 'react';
import { Link } from 'react-router';
import {
FiGlobe,
FiShield,
@@ -74,8 +75,8 @@ export const Footer: React.FC = () => {
<ul className="space-y-2.5 text-xs">
<li><a href="#" className="hover:text-cyan-300 transition">Sobre Nosotros</a></li>
<li><a href="#" className="hover:text-cyan-300 transition">RyCourses para Empresas</a></li>
<li><a href="#" className="hover:text-cyan-300 transition">Términos de Servicio</a></li>
<li><a href="#" className="hover:text-cyan-300 transition">Política de Privacidad</a></li>
<li><Link to="/terms" className="hover:text-cyan-300 transition">Términos de Servicio</Link></li>
<li><Link to="/terms" className="hover:text-cyan-300 transition">Política de Privacidad</Link></li>
<li><a href="#" className="hover:text-cyan-300 transition">Centro de Ayuda</a></li>
</ul>
</div>
+74 -151
View File
@@ -1,19 +1,16 @@
import React, { useState, useEffect } from 'react';
import { useNavigate, useLocation } from 'react-router';
import {
FiSearch,
FiShoppingCart,
FiMenu,
FiX,
FiPlusCircle,
FiHome,
FiGrid,
FiUser,
FiShield,
FiUser
} from 'react-icons/fi';
import { FaChalkboardTeacher, FaSignOutAlt } from 'react-icons/fa';
import { BrandLogo } from '../common/BrandLogo';
import { ThemeToggle, Button } from '../ui';
import type { User } from '../../types/course';
interface NavbarProps {
@@ -31,15 +28,13 @@ export const Navbar: React.FC<NavbarProps> = ({
onLogout,
cartCount,
onOpenCart,
searchQuery,
onSearchChange,
}) => {
const navigate = useNavigate();
const location = useLocation();
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [cartHovered, setCartHovered] = useState(false);
// Inject keyframe animation styles inline (no external CSS needed)
// Inject keyframe animation styles inline
useEffect(() => {
const styleId = 'cart-run-keyframes';
if (!document.getElementById(styleId)) {
@@ -64,17 +59,9 @@ export const Navbar: React.FC<NavbarProps> = ({
}, []);
const currentPath = location.pathname;
const isSellerMode = currentPath === '/seller' || currentPath.startsWith('/seller/');
const handleSearchInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
onSearchChange(e.target.value);
if (currentPath !== '/courses' && e.target.value.trim() !== '') {
navigate('/courses');
}
};
return (
<header className="sticky top-0 z-40 w-full border-b border-slate-800/80 bg-slate-950/90 backdrop-blur-xl transition-all">
<header className="sticky top-0 z-40 w-full border-b backdrop-blur-xl transition-all border-slate-200 dark:border-slate-800/80 bg-white/90 dark:bg-slate-950/90 text-slate-900 dark:text-slate-100">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-20 gap-4">
@@ -84,14 +71,15 @@ export const Navbar: React.FC<NavbarProps> = ({
<BrandLogo size="md" />
</div>
{/* Quick Nav Links */}
{/* Main Nav Links */}
<nav className="hidden lg:flex items-center gap-1">
<button
onClick={() => navigate('/')}
className={`flex items-center gap-1.5 px-3 py-2 rounded-xl text-xs font-semibold transition-all ${currentPath === '/'
? 'bg-slate-800 text-cyan-400 border border-cyan-500/30'
: 'text-slate-300 hover:text-white hover:bg-slate-900'
}`}
className={`flex items-center gap-1.5 px-3.5 py-2 rounded-xl text-xs font-semibold transition-all ${
currentPath === '/'
? 'bg-slate-100 dark:bg-slate-800 text-cyan-600 dark:text-cyan-400 border border-slate-200 dark:border-cyan-500/30'
: 'text-slate-600 dark:text-slate-300 hover:text-slate-900 dark:hover:text-white hover:bg-slate-100 dark:hover:bg-slate-900'
}`}
>
<FiHome />
<span>Inicio</span>
@@ -99,91 +87,62 @@ export const Navbar: React.FC<NavbarProps> = ({
<button
onClick={() => navigate('/courses')}
className={`flex items-center gap-1.5 px-3 py-2 rounded-xl text-xs font-semibold transition-all ${currentPath === '/courses'
? 'bg-slate-800 text-cyan-400 border border-cyan-500/30'
: 'text-slate-300 hover:text-white hover:bg-slate-900'
}`}
className={`flex items-center gap-1.5 px-3.5 py-2 rounded-xl text-xs font-semibold transition-all ${
currentPath === '/courses'
? 'bg-slate-100 dark:bg-slate-800 text-cyan-600 dark:text-cyan-400 border border-slate-200 dark:border-cyan-500/30'
: 'text-slate-600 dark:text-slate-300 hover:text-slate-900 dark:hover:text-white hover:bg-slate-100 dark:hover:bg-slate-900'
}`}
>
<FiGrid />
<span>Catálogo</span>
</button>
{/* Landing link for instructor sales */}
<button
onClick={() => navigate('/admin')}
className={`flex items-center gap-1.5 px-3 py-2 rounded-xl text-xs font-semibold transition-all ${currentPath === '/admin'
? 'bg-slate-800 text-cyan-400 border border-cyan-500/30'
: 'text-slate-300 hover:text-white hover:bg-slate-900'
}`}
onClick={() => navigate('/seller')}
className={`flex items-center gap-1.5 px-3.5 py-2 rounded-xl text-xs font-semibold transition-all ${
currentPath === '/seller'
? 'bg-slate-100 dark:bg-slate-800 text-violet-600 dark:text-violet-400 border border-slate-200 dark:border-violet-500/30'
: 'text-slate-600 dark:text-slate-300 hover:text-slate-900 dark:hover:text-white hover:bg-slate-100 dark:hover:bg-slate-900'
}`}
>
<FiShield />
<span>Admin</span>
<FaChalkboardTeacher />
<span>Vender Cursos</span>
</button>
</nav>
</div>
{/* 3. Right Actions & Role Switcher */}
<div className="flex items-center gap-2.5 shrink-0">
{/* Buyer vs Seller Switcher pill */}
<div className="bg-slate-900 p-1 rounded-xl border border-slate-800 flex items-center shadow-inner">
<button
onClick={() => navigate('/courses')}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-semibold transition-all ${!isSellerMode
? 'bg-gradient-to-r from-blue-600 to-cyan-500 text-white shadow-md shadow-cyan-500/20'
: 'text-slate-400 hover:text-slate-200'
}`}
title="Modo Estudiante - Explorar y Comprar cursos"
>
<FiGrid className="text-xs" />
<span className="hidden sm:inline">Comprar</span>
</button>
<button
onClick={() => navigate('/seller')}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-semibold transition-all ${isSellerMode
? 'bg-gradient-to-r from-indigo-600 to-violet-500 text-white shadow-md shadow-violet-500/20'
: 'text-slate-400 hover:text-slate-200'
}`}
title="Modo Instructor - Vender y Gestionar cursos"
>
<FaChalkboardTeacher className="text-xs" />
<span className="hidden sm:inline">Vender</span>
</button>
</div>
{/* If seller mode: Create Course button */}
{isSellerMode && (
<button
onClick={() => navigate('/seller/create')}
className="hidden sm:flex items-center gap-1.5 px-3.5 py-2 rounded-xl bg-gradient-to-r from-violet-600 to-indigo-600 hover:from-violet-500 hover:to-indigo-500 text-white font-medium text-xs shadow-lg shadow-violet-600/30 transition-all"
>
<FiPlusCircle className="text-sm" />
<span>Crear Curso</span>
</button>
)}
{/* 2. Right Actions & ThemeToggle Beside Cart */}
<div className="flex items-center gap-3 shrink-0">
{/* Login & Register Buttons OR User Profile */}
{!currentUser ? (
<div className="hidden xl:flex items-center gap-2 pl-2 border-l border-slate-800">
<button
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => navigate('/login')}
className="px-3 py-1.5 text-xs font-semibold text-slate-300 hover:text-white transition"
>
Iniciar Sesión
</button>
<button
</Button>
<Button
variant="gradient"
size="sm"
onClick={() => navigate('/register')}
className="px-3.5 py-1.5 rounded-xl bg-cyan-500/10 hover:bg-cyan-500/20 text-cyan-400 font-semibold text-xs border border-cyan-500/30 transition"
>
Registrarse
</button>
</Button>
</div>
) : (
<div className="flex items-center gap-2 pl-2 border-l border-slate-800 relative group">
<div className="flex items-center gap-2 pl-2 border-l border-slate-200 dark:border-slate-800 relative group">
<div className="relative cursor-pointer flex items-center gap-2" title="Mi Cuenta">
<div className="hidden sm:block text-right">
<p className="text-[11px] font-bold text-slate-200 leading-none">{currentUser.name}</p>
<p className="text-[9px] text-cyan-400 font-medium capitalize mt-0.5">{currentUser.role === 'buyer' ? 'Estudiante' : 'Instructor'}</p>
<p className="text-[11px] font-bold leading-none text-slate-800 dark:text-slate-200">
{currentUser.name}
</p>
<p className="text-[9px] text-cyan-600 dark:text-cyan-400 font-medium capitalize mt-0.5">
{currentUser.role === 'buyer' ? 'Estudiante' : 'Instructor'}
</p>
</div>
<div className="relative">
{currentUser.avatar ? (
@@ -193,18 +152,18 @@ export const Navbar: React.FC<NavbarProps> = ({
className="w-9 h-9 rounded-full object-cover ring-2 ring-indigo-500/50 group-hover:ring-cyan-400 transition"
/>
) : (
<div className="w-9 h-9 rounded-full bg-slate-800 flex items-center justify-center text-sm font-bold text-slate-300 ring-2 ring-indigo-500/50 group-hover:ring-cyan-400 transition">
<div className="w-9 h-9 rounded-full flex items-center justify-center text-sm font-bold ring-2 ring-indigo-500/50 group-hover:ring-cyan-400 transition bg-slate-200 dark:bg-slate-800 text-slate-800 dark:text-slate-300">
{currentUser.name.charAt(0).toUpperCase()}
</div>
)}
<span className="absolute bottom-0 right-0 w-2.5 h-2.5 bg-emerald-500 ring-2 ring-slate-950 rounded-full"></span>
<span className="absolute bottom-0 right-0 w-2.5 h-2.5 bg-emerald-500 ring-2 ring-white dark:ring-slate-950 rounded-full"></span>
</div>
</div>
{/* Dropdown Menu */}
<div className="absolute top-full right-0 mt-2 w-48 bg-slate-900 border border-slate-800 rounded-xl shadow-2xl opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 transform origin-top-right scale-95 group-hover:scale-100 z-50">
<div className="absolute top-full right-0 mt-2 w-48 border rounded-xl shadow-2xl opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 transform origin-top-right scale-95 group-hover:scale-100 z-50 bg-white dark:bg-slate-900 border-slate-200 dark:border-slate-800">
<div className="p-2 space-y-1">
<button onClick={onLogout} className="w-full flex items-center gap-2 px-3 py-2 text-xs font-semibold text-rose-400 hover:text-rose-300 hover:bg-rose-500/10 rounded-lg transition">
<button onClick={onLogout} className="w-full flex items-center gap-2 px-3 py-2 text-xs font-semibold text-rose-500 hover:text-rose-600 hover:bg-rose-500/10 rounded-lg transition">
<FaSignOutAlt />
<span>Cerrar Sesión</span>
</button>
@@ -213,16 +172,17 @@ export const Navbar: React.FC<NavbarProps> = ({
</div>
)}
{/* Cart button (Buyer mode) - Fixed Far Right, inline animation */}
{!isSellerMode && (
{/* ThemeToggle and Cart Button Grouped Together */}
<div className="flex items-center gap-2.5 pl-2 border-l border-slate-200 dark:border-slate-800 relative">
<ThemeToggle variant="icon" size="md" />
<button
onClick={onOpenCart}
onMouseEnter={() => setCartHovered(true)}
onMouseLeave={() => setCartHovered(false)}
className="group fixed top-4 right-4 z-50 p-3 text-slate-300 hover:text-white hover:bg-slate-800 rounded-xl transition-all shadow-lg shadow-black/30 backdrop-blur-sm overflow-hidden"
className="group relative p-2.5 rounded-xl transition-all shadow-md backdrop-blur-sm text-slate-700 dark:text-slate-300 hover:text-slate-900 dark:hover:text-white bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 hover:border-cyan-500/50"
aria-label="Carrito de compras"
>
{/* Cart icon with inline running animation */}
<div
style={{
display: 'flex',
@@ -232,9 +192,9 @@ export const Navbar: React.FC<NavbarProps> = ({
animation: cartHovered ? 'cart-run 0.45s ease-in-out infinite' : 'none',
}}
>
<FiShoppingCart style={{ fontSize: '1.2rem' }} />
<FiShoppingCart className="text-lg" />
{/* Speed streaks — subtle, contained inside icon area */}
{/* Speed streaks */}
<span style={{
position: 'absolute',
top: '25%',
@@ -270,89 +230,61 @@ export const Navbar: React.FC<NavbarProps> = ({
}} />
</div>
{/* Badge counter */}
{/* Badge counter: Perfectly placed floating badge */}
{cartCount > 0 && (
<span
style={{
position: 'absolute',
top: '-8px',
right: '-8px',
width: '20px',
height: '20px',
background: 'linear-gradient(135deg, #f43f5e, #ec4899)',
color: 'white',
fontSize: '11px',
fontWeight: 700,
borderRadius: '9999px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: '0 2px 8px rgba(244,63,94,0.5)',
zIndex: 20,
transform: cartHovered ? 'scale(1.2)' : 'scale(1)',
transition: 'transform 0.15s ease',
}}
>
<span className="absolute -top-1.5 -right-1.5 min-w-[20px] h-[20px] px-1 bg-gradient-to-r from-rose-500 to-pink-500 text-white text-[11px] font-bold rounded-full flex items-center justify-center shadow-lg shadow-rose-500/40 ring-2 ring-white dark:ring-slate-950 z-30 transition-transform duration-200 group-hover:scale-110">
{cartCount}
</span>
)}
</button>
)}
</div>
{/* Mobile menu trigger */}
<button
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
className="p-2 text-slate-400 hover:text-white md:hidden"
className="p-2 md:hidden text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white"
>
{mobileMenuOpen ? <FiX className="text-2xl" /> : <FiMenu className="text-2xl" />}
</button>
</div>
</div>
{/* Mobile search & menu expanded */}
{/* Mobile menu expanded */}
{mobileMenuOpen && (
<div className="md:hidden py-4 border-t border-slate-800 space-y-4">
<div className="relative">
<FiSearch className="absolute left-3.5 top-3 text-slate-400" />
<input
type="text"
value={searchQuery}
onChange={handleSearchInputChange}
placeholder="Buscar cursos..."
className="w-full pl-10 pr-4 py-2 bg-slate-900 border border-slate-700 rounded-lg text-sm text-white"
/>
</div>
<div className="md:hidden py-4 border-t space-y-4 border-slate-200 dark:border-slate-800">
<div className="grid grid-cols-3 gap-2">
<button
onClick={() => { navigate('/'); setMobileMenuOpen(false); }}
className={`flex items-center justify-center gap-1.5 p-2 rounded-lg text-xs font-semibold ${currentPath === '/' ? 'bg-blue-600 text-white' : 'bg-slate-800 text-slate-300'
}`}
className={`flex items-center justify-center gap-1.5 p-2 rounded-lg text-xs font-semibold ${
currentPath === '/' ? 'bg-blue-600 text-white' : 'bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-300'
}`}
>
<FiHome /> Inicio
</button>
<button
onClick={() => { navigate('/courses'); setMobileMenuOpen(false); }}
className={`flex items-center justify-center gap-1.5 p-2 rounded-lg text-xs font-semibold ${currentPath === '/courses' ? 'bg-cyan-600 text-white' : 'bg-slate-800 text-slate-300'
}`}
className={`flex items-center justify-center gap-1.5 p-2 rounded-lg text-xs font-semibold ${
currentPath === '/courses' ? 'bg-cyan-600 text-white' : 'bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-300'
}`}
>
<FiGrid /> Catálogo
</button>
<button
onClick={() => { navigate('/seller'); setMobileMenuOpen(false); }}
className={`flex items-center justify-center gap-1.5 p-2 rounded-lg text-xs font-semibold ${currentPath === '/seller' ? 'bg-violet-600 text-white' : 'bg-slate-800 text-slate-300'
}`}
className={`flex items-center justify-center gap-1.5 p-2 rounded-lg text-xs font-semibold ${
currentPath === '/seller' ? 'bg-violet-600 text-white' : 'bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-300'
}`}
>
<FaChalkboardTeacher /> Vender
</button>
</div>
<div className="grid grid-cols-2 gap-2 pt-2 border-t border-slate-800">
<div className="grid grid-cols-2 gap-2 pt-2 border-t border-slate-200 dark:border-slate-800">
{!currentUser ? (
<>
<button
onClick={() => { navigate('/login'); setMobileMenuOpen(false); }}
className="w-full py-2.5 bg-slate-800 text-white font-semibold rounded-lg text-xs flex items-center justify-center gap-2"
className="w-full py-2.5 font-semibold rounded-lg text-xs flex items-center justify-center gap-2 bg-slate-100 dark:bg-slate-800 text-slate-900 dark:text-white"
>
<FiUser /> Iniciar Sesión
</button>
@@ -365,36 +297,27 @@ export const Navbar: React.FC<NavbarProps> = ({
</>
) : (
<>
<div className="w-full py-2.5 px-3 bg-slate-800 rounded-lg flex items-center gap-3">
<div className="w-full py-2.5 px-3 rounded-lg flex items-center gap-3 bg-slate-100 dark:bg-slate-800">
{currentUser.avatar ? (
<img src={currentUser.avatar} alt="Avatar" className="w-6 h-6 rounded-full object-cover" />
) : (
<div className="w-6 h-6 rounded-full bg-slate-700 flex items-center justify-center text-[10px] font-bold text-white">
<div className="w-6 h-6 rounded-full bg-slate-200 dark:bg-slate-700 flex items-center justify-center text-[10px] font-bold text-slate-800 dark:text-white">
{currentUser.name.charAt(0).toUpperCase()}
</div>
)}
<div className="text-left">
<p className="text-[10px] font-bold text-white truncate max-w-[100px]">{currentUser.name}</p>
<p className="text-[10px] font-bold truncate max-w-[100px] text-slate-900 dark:text-white">{currentUser.name}</p>
</div>
</div>
<button
onClick={() => { onLogout(); setMobileMenuOpen(false); }}
className="w-full py-2.5 bg-rose-500/10 text-rose-400 font-semibold rounded-lg text-xs flex items-center justify-center gap-2 border border-rose-500/20"
className="w-full py-2.5 bg-rose-500/10 text-rose-500 font-semibold rounded-lg text-xs flex items-center justify-center gap-2 border border-rose-500/20"
>
<FaSignOutAlt /> Cerrar Sesión
</button>
</>
)}
</div>
{isSellerMode && (
<button
onClick={() => { navigate('/seller/create'); setMobileMenuOpen(false); }}
className="w-full py-2.5 bg-gradient-to-r from-violet-600 to-indigo-600 text-white font-semibold rounded-lg text-xs flex items-center justify-center gap-2"
>
<FiPlusCircle /> Crear Nuevo Curso
</button>
)}
</div>
)}
</div>
+278 -81
View File
@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useState, useRef } from 'react';
import {
FiX,
FiCheck,
@@ -7,7 +7,12 @@ import {
FiBookOpen,
FiDollarSign,
FiPlus,
FiCheckCircle
FiCheckCircle,
FiUploadCloud,
FiVideo,
FiFilm,
FiLink,
FiImage
} from 'react-icons/fi';
import type { Course, CourseLevel } from '../../types/course';
import { mockInstructors } from '../../data/mockCourses';
@@ -24,6 +29,8 @@ export const CreateCourseModal: React.FC<CreateCourseModalProps> = ({
onCreateCourse,
}) => {
const [step, setStep] = useState<1 | 2 | 3>(1);
const fileInputRef = useRef<HTMLInputElement>(null);
const imageInputRef = useRef<HTMLInputElement>(null);
// Form State
const [title, setTitle] = useState('');
@@ -32,10 +39,22 @@ export const CreateCourseModal: React.FC<CreateCourseModalProps> = ({
const [level, setLevel] = useState<CourseLevel>('Todos los niveles');
const [price, setPrice] = useState('29.99');
const [originalPrice, setOriginalPrice] = useState('99.99');
// Thumbnail Image Drag & Drop State
const [thumbnailFile, setThumbnailFile] = useState<File | null>(null);
const [thumbnail, setThumbnail] = useState(
'https://images.unsplash.com/photo-1516321318423-f06f85e504b3?w=800&auto=format&fit=crop&q=80'
);
const [duration, setDuration] = useState('20h 30min');
// Video Upload States
const [videoFile, setVideoFile] = useState<File | null>(null);
const [videoPreviewUrl, setVideoPreviewUrl] = useState<string>('');
const [videoEmbedUrl, setVideoEmbedUrl] = useState<string>('');
const [isUploading, setIsUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
const [whatYouWillLearn, setWhatYouWillLearn] = useState([
'Aprenderás conceptos clave y buenas prácticas de la industria',
'Construirás proyectos prácticos de portafolio para conseguir empleo'
@@ -44,6 +63,77 @@ export const CreateCourseModal: React.FC<CreateCourseModalProps> = ({
if (!isOpen) return null;
const handleCloseModal = () => {
setStep(1);
setTitle('');
setSubtitle('');
setVideoFile(null);
setVideoPreviewUrl('');
setVideoEmbedUrl('');
setThumbnailFile(null);
setUploadProgress(0);
setIsUploading(false);
onClose();
};
const handleNextStep = () => {
if (step === 1 && !title.trim()) {
setTitle('Nuevo Curso Especializado');
}
if (step < 3) {
setStep((s) => (s + 1) as 1 | 2 | 3);
}
};
const handlePrevStep = () => {
if (step > 1) {
setStep((s) => (s - 1) as 1 | 2 | 3);
}
};
const handleImageFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setThumbnailFile(file);
const objectUrl = URL.createObjectURL(file);
setThumbnail(objectUrl);
};
const handleRemoveThumbnail = () => {
setThumbnailFile(null);
setThumbnail('https://images.unsplash.com/photo-1516321318423-f06f85e504b3?w=800&auto=format&fit=crop&q=80');
if (imageInputRef.current) imageInputRef.current.value = '';
};
const handleVideoFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setVideoFile(file);
setIsUploading(true);
setUploadProgress(20);
const interval = setInterval(() => {
setUploadProgress((prev) => {
if (prev >= 100) {
clearInterval(interval);
setIsUploading(false);
setVideoPreviewUrl(URL.createObjectURL(file));
return 100;
}
return prev + 25;
});
}, 200);
};
const handleRemoveVideo = () => {
setVideoFile(null);
setVideoPreviewUrl('');
setUploadProgress(0);
if (fileInputRef.current) fileInputRef.current.value = '';
};
const handleAddOutcome = () => {
if (newOutcome.trim()) {
setWhatYouWillLearn([...whatYouWillLearn, newOutcome.trim()]);
@@ -55,9 +145,7 @@ export const CreateCourseModal: React.FC<CreateCourseModalProps> = ({
setWhatYouWillLearn(whatYouWillLearn.filter((_, i) => i !== index));
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const handleFinalPublish = () => {
const createdCourse: Course = {
id: `course-${Date.now()}`,
title: title || 'Nuevo Curso Especializado',
@@ -103,88 +191,88 @@ export const CreateCourseModal: React.FC<CreateCourseModalProps> = ({
};
onCreateCourse(createdCourse);
onClose();
handleCloseModal();
};
return (
<div className="fixed inset-0 z-50 overflow-y-auto bg-slate-950/80 backdrop-blur-md flex items-center justify-center p-3 sm:p-6 animate-in fade-in duration-200">
<div
className="relative w-full max-w-2xl bg-slate-900 border border-slate-800 rounded-2xl shadow-2xl overflow-hidden my-6"
className="relative w-full max-w-2xl transition-colors duration-300 bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 text-slate-900 dark:text-slate-100 rounded-2xl shadow-2xl overflow-hidden my-6"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="p-6 border-b border-slate-800 bg-slate-950/60 flex items-center justify-between">
<div className="p-6 border-b border-slate-200 dark:border-slate-800 bg-slate-50 dark:bg-slate-950/60 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-violet-500/20 text-violet-400 flex items-center justify-center text-lg">
<div className="w-10 h-10 rounded-xl bg-violet-500/20 text-violet-600 dark:text-violet-400 flex items-center justify-center text-lg">
<FiBookOpen />
</div>
<div>
<h3 className="text-base font-bold text-white">Publicar Nuevo Curso</h3>
<p className="text-xs text-slate-400">Paso {step} de 3</p>
<h3 className="text-base font-bold text-slate-900 dark:text-white">Publicar Nuevo Curso & Cargar Videos</h3>
<p className="text-xs text-slate-500 dark:text-slate-400">Paso {step} de 3</p>
</div>
</div>
<button
onClick={onClose}
className="p-2 rounded-xl text-slate-400 hover:text-white hover:bg-slate-800 transition"
type="button"
onClick={handleCloseModal}
className="p-2 rounded-xl text-slate-400 hover:text-slate-900 dark:hover:text-white hover:bg-slate-200 dark:hover:bg-slate-800 transition"
>
<FiX className="text-xl" />
</button>
</div>
{/* Step Progress Bar */}
<div className="w-full bg-slate-800 h-1.5">
<div className="w-full bg-slate-200 dark:bg-slate-800 h-1.5">
<div
className="bg-gradient-to-r from-violet-600 to-indigo-500 h-full transition-all duration-300"
style={{ width: `${(step / 3) * 100}%` }}
/>
</div>
{/* Form Body */}
<form onSubmit={handleSubmit} className="p-6 space-y-5">
{/* Modal Container Body */}
<div className="p-6 space-y-5">
{/* Step 1: Basic Information */}
{step === 1 && (
<div className="space-y-4 animate-in fade-in">
<div>
<label className="block text-xs font-bold text-slate-300 mb-1.5">
<label className="block text-xs font-bold text-slate-700 dark:text-slate-300 mb-1.5">
Título del Curso *
</label>
<input
type="text"
required
value={title}
onChange={(e) => setTitle(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleNextStep()}
placeholder="Ej. Master en Desarrollo de Software Full Stack"
className="w-full px-4 py-2.5 bg-slate-950 border border-slate-700 rounded-xl text-sm text-white focus:outline-none focus:border-violet-500"
className="w-full px-4 py-2.5 bg-slate-50 dark:bg-slate-950 border border-slate-300 dark:border-slate-700 rounded-xl text-sm text-slate-900 dark:text-white focus:outline-none focus:border-violet-500"
/>
</div>
<div>
<label className="block text-xs font-bold text-slate-300 mb-1.5">
<label className="block text-xs font-bold text-slate-700 dark:text-slate-300 mb-1.5">
Subtítulo / Breve Descripción *
</label>
<textarea
rows={2}
required
value={subtitle}
onChange={(e) => setSubtitle(e.target.value)}
placeholder="Explica en 2 frases lo que el estudiante logrará al finalizar este curso."
className="w-full px-4 py-2.5 bg-slate-950 border border-slate-700 rounded-xl text-sm text-white focus:outline-none focus:border-violet-500"
className="w-full px-4 py-2.5 bg-slate-50 dark:bg-slate-950 border border-slate-300 dark:border-slate-700 rounded-xl text-sm text-slate-900 dark:text-white focus:outline-none focus:border-violet-500"
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-xs font-bold text-slate-300 mb-1.5">
<label className="block text-xs font-bold text-slate-700 dark:text-slate-300 mb-1.5">
Categoría
</label>
<select
value={category}
onChange={(e) => setCategory(e.target.value)}
className="w-full px-3 py-2.5 bg-slate-950 border border-slate-700 rounded-xl text-xs text-white focus:outline-none focus:border-violet-500"
className="w-full px-3 py-2.5 bg-slate-50 dark:bg-slate-950 border border-slate-300 dark:border-slate-700 rounded-xl text-xs text-slate-900 dark:text-white focus:outline-none focus:border-violet-500"
>
<option value="Desarrollo Web">Desarrollo Web</option>
<option value="Inteligencia Artificial">Inteligencia Artificial</option>
@@ -196,13 +284,13 @@ export const CreateCourseModal: React.FC<CreateCourseModalProps> = ({
</div>
<div>
<label className="block text-xs font-bold text-slate-300 mb-1.5">
<label className="block text-xs font-bold text-slate-700 dark:text-slate-300 mb-1.5">
Nivel del Curso
</label>
<select
value={level}
onChange={(e) => setLevel(e.target.value as CourseLevel)}
className="w-full px-3 py-2.5 bg-slate-950 border border-slate-700 rounded-xl text-xs text-white focus:outline-none focus:border-violet-500"
className="w-full px-3 py-2.5 bg-slate-50 dark:bg-slate-950 border border-slate-300 dark:border-slate-700 rounded-xl text-xs text-slate-900 dark:text-white focus:outline-none focus:border-violet-500"
>
<option value="Principiante">Principiante</option>
<option value="Intermedio">Intermedio</option>
@@ -214,12 +302,82 @@ export const CreateCourseModal: React.FC<CreateCourseModalProps> = ({
</div>
)}
{/* Step 2: Pricing & Duration */}
{/* Step 2: Video Upload & Pricing */}
{step === 2 && (
<div className="space-y-4 animate-in fade-in">
<div className="p-4 rounded-xl bg-slate-50 dark:bg-slate-950 border border-slate-200 dark:border-slate-800 space-y-3">
<label className="block text-xs font-bold text-slate-900 dark:text-white flex items-center gap-2">
<FiVideo className="text-cyan-600 dark:text-cyan-400" /> Subir Video de Clase Promocional (.mp4, .mov)
</label>
<input
ref={fileInputRef}
type="file"
accept="video/*"
onChange={handleVideoFileChange}
className="hidden"
id="modal-video-upload"
/>
{!videoFile && !videoPreviewUrl ? (
<label
htmlFor="modal-video-upload"
className="flex flex-col items-center justify-center p-6 border-2 border-dashed border-slate-300 dark:border-slate-700 hover:border-cyan-500 rounded-xl bg-white dark:bg-slate-900 cursor-pointer group text-center space-y-2 transition"
>
<FiUploadCloud className="text-3xl text-cyan-500 group-hover:scale-110 transition-transform" />
<p className="text-xs font-bold text-slate-900 dark:text-white">Haz clic para seleccionar el video de tu lección</p>
<p className="text-[10px] text-slate-500 dark:text-slate-400">MP4, MOV o WEBM hasta 2GB</p>
</label>
) : (
<div className="space-y-3">
<div className="flex items-center justify-between p-3 rounded-lg bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 text-xs">
<div className="flex items-center gap-2">
<FiFilm className="text-emerald-500 text-lg" />
<span className="font-bold text-slate-900 dark:text-white truncate max-w-xs">{videoFile?.name || 'clase_muestra.mp4'}</span>
</div>
<button
type="button"
onClick={handleRemoveVideo}
className="text-rose-500 hover:text-rose-600"
>
<FiX />
</button>
</div>
{isUploading && (
<div className="space-y-1">
<div className="w-full bg-slate-200 dark:bg-slate-800 h-2 rounded-full overflow-hidden">
<div className="bg-cyan-500 h-full transition-all duration-300" style={{ width: `${uploadProgress}%` }} />
</div>
</div>
)}
{videoPreviewUrl && (
<div className="aspect-video w-full rounded-lg overflow-hidden bg-black">
<video src={videoPreviewUrl} controls className="w-full h-full object-contain" />
</div>
)}
</div>
)}
<div className="pt-2">
<label className="block text-[11px] font-semibold text-slate-600 dark:text-slate-400 mb-1 flex items-center gap-1">
<FiLink /> O pega la URL directa del video (Vimeo, YouTube)
</label>
<input
type="url"
value={videoEmbedUrl}
onChange={(e) => setVideoEmbedUrl(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleNextStep()}
placeholder="https://vimeo.com/..."
className="w-full px-3 py-2 bg-white dark:bg-slate-900 border border-slate-300 dark:border-slate-700 rounded-lg text-xs text-slate-900 dark:text-white font-mono"
/>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-xs font-bold text-slate-300 mb-1.5">
<label className="block text-xs font-bold text-slate-700 dark:text-slate-300 mb-1.5">
Precio de Venta ($ USD) *
</label>
<div className="relative">
@@ -227,17 +385,33 @@ export const CreateCourseModal: React.FC<CreateCourseModalProps> = ({
<input
type="number"
step="0.01"
required
value={price}
onChange={(e) => setPrice(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleNextStep()}
placeholder="29.99"
className="w-full pl-8 pr-4 py-2.5 bg-slate-950 border border-slate-700 rounded-xl text-sm text-white focus:outline-none focus:border-violet-500 font-mono"
className="w-full pl-8 pr-4 py-2.5 bg-slate-50 dark:bg-slate-950 border border-slate-300 dark:border-slate-700 rounded-xl text-sm text-slate-900 dark:text-white focus:outline-none focus:border-violet-500 font-mono"
/>
</div>
</div>
<div>
<label className="block text-xs font-bold text-slate-300 mb-1.5">
<label className="block text-xs font-bold text-slate-700 dark:text-slate-300 mb-1.5">
Duración Estimada de Video (Horas)
</label>
<input
type="text"
value={duration}
onChange={(e) => setDuration(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleNextStep()}
placeholder="Ej. 18h 45min"
className="w-full px-4 py-2.5 bg-slate-50 dark:bg-slate-950 border border-slate-300 dark:border-slate-700 rounded-xl text-sm text-slate-900 dark:text-white focus:outline-none focus:border-violet-500"
/>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-xs font-bold text-slate-700 dark:text-slate-300 mb-1.5">
Precio Original Tachado ($ USD)
</label>
<div className="relative">
@@ -247,60 +421,86 @@ export const CreateCourseModal: React.FC<CreateCourseModalProps> = ({
step="0.01"
value={originalPrice}
onChange={(e) => setOriginalPrice(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleNextStep()}
placeholder="89.99"
className="w-full pl-8 pr-4 py-2.5 bg-slate-950 border border-slate-700 rounded-xl text-sm text-white focus:outline-none focus:border-violet-500 font-mono"
className="w-full pl-8 pr-4 py-2.5 bg-slate-50 dark:bg-slate-950 border border-slate-300 dark:border-slate-700 rounded-xl text-sm text-slate-900 dark:text-white focus:outline-none focus:border-violet-500 font-mono"
/>
</div>
</div>
</div>
<div>
<label className="block text-xs font-bold text-slate-300 mb-1.5">
Duración Total Estimada de Video
</label>
<input
type="text"
value={duration}
onChange={(e) => setDuration(e.target.value)}
placeholder="Ej. 24h 45min"
className="w-full px-4 py-2.5 bg-slate-950 border border-slate-700 rounded-xl text-sm text-white focus:outline-none focus:border-violet-500"
/>
</div>
<div className="p-4 rounded-xl bg-violet-950/30 border border-violet-500/20 text-xs text-slate-300 space-y-1">
<p className="font-bold text-violet-300">💡 Consejo de fijación de precios:</p>
<p>Los cursos con precios entre $19.99 y $34.99 tienen una tasa de conversión 45% más alta en nuestra plataforma.</p>
</div>
</div>
)}
{/* Step 3: Media & What you'll learn */}
{/* Step 3: Thumbnail Drag & Drop & Learning Outcomes */}
{step === 3 && (
<div className="space-y-4 animate-in fade-in">
<div>
<label className="block text-xs font-bold text-slate-300 mb-1.5">
URL de Imagen de Portada (Thumbnail)
{/* Thumbnail Image Drag & Drop */}
<div className="p-4 rounded-xl bg-slate-50 dark:bg-slate-950 border border-slate-200 dark:border-slate-800 space-y-3">
<label className="block text-xs font-bold text-slate-900 dark:text-white flex items-center gap-2">
<FiImage className="text-cyan-600 dark:text-cyan-400" /> Imagen de Portada del Curso (Thumbnail)
</label>
<div className="flex gap-2">
<input
ref={imageInputRef}
type="file"
accept="image/*"
onChange={handleImageFileChange}
className="hidden"
id="modal-image-upload"
/>
{!thumbnailFile ? (
<label
htmlFor="modal-image-upload"
className="flex flex-col items-center justify-center p-6 border-2 border-dashed border-slate-300 dark:border-slate-700 hover:border-cyan-500 rounded-xl bg-white dark:bg-slate-900 cursor-pointer group text-center space-y-2 transition"
>
<FiUploadCloud className="text-3xl text-cyan-500 group-hover:scale-110 transition-transform" />
<p className="text-xs font-bold text-slate-900 dark:text-white">Haz clic o arrastra tu imagen de portada aquí</p>
<p className="text-[10px] text-slate-500 dark:text-slate-400">PNG, JPG, WEBP o SVG (Recomendado: 1280x720)</p>
</label>
) : (
<div className="flex items-center justify-between p-3 rounded-lg bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 text-xs">
<div className="flex items-center gap-2">
<FiCheckCircle className="text-emerald-500 text-lg" />
<span className="font-bold text-slate-900 dark:text-white truncate max-w-xs">{thumbnailFile.name}</span>
</div>
<button
type="button"
onClick={handleRemoveThumbnail}
className="text-rose-500 hover:text-rose-600"
>
<FiX />
</button>
</div>
)}
{/* Live Thumbnail Preview */}
{thumbnail && (
<div className="aspect-video max-w-xs rounded-xl overflow-hidden bg-slate-950 border border-slate-300 dark:border-slate-700 shadow-md">
<img src={thumbnail} alt="Vista previa de portada" className="w-full h-full object-cover" />
</div>
)}
<div className="pt-2">
<label className="block text-[11px] font-semibold text-slate-600 dark:text-slate-400 mb-1 flex items-center gap-1">
<FiLink /> O pega la URL directa de la imagen
</label>
<input
type="url"
value={thumbnail}
onChange={(e) => setThumbnail(e.target.value)}
onChange={(e) => {
setThumbnailFile(null);
setThumbnail(e.target.value);
}}
placeholder="https://images.unsplash.com/..."
className="flex-1 px-4 py-2.5 bg-slate-950 border border-slate-700 rounded-xl text-xs text-white focus:outline-none focus:border-violet-500"
className="w-full px-3 py-2 bg-white dark:bg-slate-900 border border-slate-300 dark:border-slate-700 rounded-lg text-xs text-slate-900 dark:text-white font-mono"
/>
</div>
{thumbnail && (
<img
src={thumbnail}
alt="Preview"
className="mt-2 h-24 w-40 object-cover rounded-lg border border-slate-700"
/>
)}
</div>
<div>
<label className="block text-xs font-bold text-slate-300 mb-1.5">
<label className="block text-xs font-bold text-slate-700 dark:text-slate-300 mb-1.5">
Lo que aprenderá el alumno (Puntos clave)
</label>
<div className="flex gap-2 mb-2">
@@ -308,8 +508,9 @@ export const CreateCourseModal: React.FC<CreateCourseModalProps> = ({
type="text"
value={newOutcome}
onChange={(e) => setNewOutcome(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleAddOutcome()}
placeholder="Ej. Crear APIs RESTful y GraphQL con Node.js"
className="flex-1 px-3 py-2 bg-slate-950 border border-slate-700 rounded-lg text-xs text-white"
className="flex-1 px-3 py-2 bg-slate-50 dark:bg-slate-950 border border-slate-300 dark:border-slate-700 rounded-lg text-xs text-slate-900 dark:text-white"
/>
<button
type="button"
@@ -324,15 +525,15 @@ export const CreateCourseModal: React.FC<CreateCourseModalProps> = ({
{whatYouWillLearn.map((item, idx) => (
<div
key={idx}
className="flex items-center justify-between p-2 rounded-lg bg-slate-950 border border-slate-800 text-xs text-slate-300"
className="flex items-center justify-between p-2 rounded-lg bg-slate-50 dark:bg-slate-950 border border-slate-200 dark:border-slate-800 text-xs text-slate-700 dark:text-slate-300"
>
<span className="flex items-center gap-1.5 truncate">
<FiCheckCircle className="text-emerald-400 shrink-0" /> {item}
<FiCheckCircle className="text-emerald-500 dark:text-emerald-400 shrink-0" /> {item}
</span>
<button
type="button"
onClick={() => handleRemoveOutcome(idx)}
className="text-slate-500 hover:text-rose-400 pl-2"
className="text-slate-400 hover:text-rose-500 pl-2"
>
<FiX />
</button>
@@ -344,12 +545,12 @@ export const CreateCourseModal: React.FC<CreateCourseModalProps> = ({
)}
{/* Bottom Actions */}
<div className="pt-4 border-t border-slate-800 flex items-center justify-between">
<div className="pt-4 border-t border-slate-200 dark:border-slate-800 flex items-center justify-between">
{step > 1 ? (
<button
type="button"
onClick={() => setStep((s) => (s - 1) as any)}
className="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 text-xs font-semibold rounded-xl flex items-center gap-1.5 transition"
onClick={handlePrevStep}
className="px-4 py-2 bg-slate-200 dark:bg-slate-800 hover:bg-slate-300 dark:hover:bg-slate-700 text-slate-800 dark:text-slate-300 text-xs font-semibold rounded-xl flex items-center gap-1.5 transition"
>
<FiArrowLeft /> Anterior
</button>
@@ -360,12 +561,7 @@ export const CreateCourseModal: React.FC<CreateCourseModalProps> = ({
{step < 3 ? (
<button
type="button"
onClick={() => {
if (step === 1 && !title.trim()) {
setTitle('Nuevo Curso Especializado');
}
setStep((s) => (s + 1) as any);
}}
onClick={handleNextStep}
className="px-5 py-2 bg-violet-600 hover:bg-violet-500 text-white text-xs font-bold rounded-xl flex items-center gap-1.5 transition shadow-lg shadow-violet-600/30"
>
<span>Siguiente</span>
@@ -373,16 +569,17 @@ export const CreateCourseModal: React.FC<CreateCourseModalProps> = ({
</button>
) : (
<button
type="submit"
type="button"
onClick={handleFinalPublish}
className="px-6 py-2.5 bg-gradient-to-r from-emerald-600 to-teal-500 hover:from-emerald-500 hover:to-teal-400 text-white text-xs font-bold rounded-xl flex items-center gap-2 transition shadow-lg shadow-emerald-500/30"
>
<FiCheck className="text-base" />
<span>Publicar Curso Ahora</span>
<span>Publicar Curso y Videos</span>
</button>
)}
</div>
</form>
</div>
</div>
</div>
+121
View File
@@ -0,0 +1,121 @@
import React from 'react';
import { FiX } from 'react-icons/fi';
export interface BadgeProps {
children: React.ReactNode;
variant?:
| 'default'
| 'primary'
| 'secondary'
| 'success'
| 'warning'
| 'danger'
| 'info'
| 'outline';
size?: 'sm' | 'md' | 'lg';
dot?: boolean;
dotPulse?: boolean;
icon?: React.ReactNode;
removable?: boolean;
onRemove?: () => void;
className?: string;
}
const variantStyles: Record<NonNullable<BadgeProps['variant']>, { badge: string; dot: string }> = {
default: {
badge: 'bg-slate-100 text-slate-800 border-slate-200 dark:bg-slate-800/80 dark:text-slate-200 dark:border-slate-700',
dot: 'bg-slate-500',
},
primary: {
badge: 'bg-cyan-50 text-cyan-700 border-cyan-200 dark:bg-cyan-950/60 dark:text-cyan-300 dark:border-cyan-500/30',
dot: 'bg-cyan-500',
},
secondary: {
badge: 'bg-purple-50 text-purple-700 border-purple-200 dark:bg-purple-950/60 dark:text-purple-300 dark:border-purple-500/30',
dot: 'bg-purple-500',
},
success: {
badge: 'bg-emerald-50 text-emerald-700 border-emerald-200 dark:bg-emerald-950/60 dark:text-emerald-300 dark:border-emerald-500/30',
dot: 'bg-emerald-500',
},
warning: {
badge: 'bg-amber-50 text-amber-700 border-amber-200 dark:bg-amber-950/60 dark:text-amber-300 dark:border-amber-500/30',
dot: 'bg-amber-500',
},
danger: {
badge: 'bg-rose-50 text-rose-700 border-rose-200 dark:bg-rose-950/60 dark:text-rose-300 dark:border-rose-500/30',
dot: 'bg-rose-500',
},
info: {
badge: 'bg-sky-50 text-sky-700 border-sky-200 dark:bg-sky-950/60 dark:text-sky-300 dark:border-sky-500/30',
dot: 'bg-sky-500',
},
outline: {
badge: 'bg-transparent text-slate-700 border-slate-300 dark:text-slate-300 dark:border-slate-700',
dot: 'bg-slate-400',
},
};
const sizeStyles = {
sm: 'text-[10px] px-2 py-0.5 gap-1 rounded-md font-semibold',
md: 'text-xs px-2.5 py-1 gap-1.5 rounded-lg font-medium',
lg: 'text-sm px-3 py-1.5 gap-2 rounded-xl font-medium',
};
export const Badge: React.FC<BadgeProps> = ({
children,
variant = 'default',
size = 'md',
dot = false,
dotPulse = false,
icon,
removable = false,
onRemove,
className = '',
}) => {
const currentVariant = variantStyles[variant];
return (
<span
className={`
inline-flex items-center select-none border transition-colors
${currentVariant.badge}
${sizeStyles[size]}
${className}
`}
>
{/* Optional Dot indicator */}
{dot && (
<span className="relative flex h-1.5 w-1.5 shrink-0">
{dotPulse && (
<span
className={`animate-ping absolute inline-flex h-full w-full rounded-full opacity-75 ${currentVariant.dot}`}
/>
)}
<span className={`relative inline-flex rounded-full h-1.5 w-1.5 ${currentVariant.dot}`} />
</span>
)}
{/* Optional Icon */}
{icon && <span className="shrink-0">{icon}</span>}
{/* Content */}
<span>{children}</span>
{/* Optional Remove Button */}
{removable && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onRemove?.();
}}
className="ml-0.5 p-0.5 rounded-full hover:bg-black/10 dark:hover:bg-white/10 transition-colors"
aria-label="Remover etiqueta"
>
<FiX className="text-xs" />
</button>
)}
</span>
);
};
+96
View File
@@ -0,0 +1,96 @@
import React from 'react';
import { Spinner } from './Spinner';
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'outline' | 'ghost' | 'danger' | 'success' | 'gradient';
size?: 'xs' | 'sm' | 'md' | 'lg' | 'icon';
isLoading?: boolean;
loadingText?: string;
leftIcon?: React.ReactNode;
rightIcon?: React.ReactNode;
fullWidth?: boolean;
}
const variantStyles: Record<NonNullable<ButtonProps['variant']>, string> = {
primary:
'bg-cyan-500 hover:bg-cyan-400 text-slate-950 font-semibold shadow-md shadow-cyan-500/20 hover:shadow-cyan-500/30 focus-visible:ring-cyan-500 dark:bg-cyan-500 dark:hover:bg-cyan-400 dark:text-slate-950 dark:shadow-cyan-500/30',
secondary:
'bg-slate-100 hover:bg-slate-200 text-slate-800 border border-slate-200 shadow-sm focus-visible:ring-slate-400 dark:bg-slate-800 dark:hover:bg-slate-700 dark:text-slate-100 dark:border-slate-700 dark:shadow-none dark:focus-visible:ring-slate-500',
outline:
'bg-transparent border border-slate-300 text-slate-700 hover:bg-slate-100 hover:border-slate-400 focus-visible:ring-slate-400 dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800/80 dark:hover:border-slate-600 dark:focus-visible:ring-slate-500',
ghost:
'bg-transparent text-slate-600 hover:bg-slate-100 hover:text-slate-900 focus-visible:ring-slate-400 dark:text-slate-300 dark:hover:bg-slate-800/70 dark:hover:text-white dark:focus-visible:ring-slate-600',
danger:
'bg-rose-600 hover:bg-rose-500 text-white font-medium shadow-md shadow-rose-600/25 focus-visible:ring-rose-500 dark:bg-rose-600 dark:hover:bg-rose-500 dark:text-white',
success:
'bg-emerald-600 hover:bg-emerald-500 text-white font-medium shadow-md shadow-emerald-600/25 focus-visible:ring-emerald-500 dark:bg-emerald-500 dark:hover:bg-emerald-400 dark:text-slate-950',
gradient:
'bg-gradient-to-r from-cyan-500 via-sky-500 to-blue-600 hover:from-cyan-400 hover:via-sky-400 hover:to-blue-500 text-white font-semibold shadow-lg shadow-cyan-500/25 hover:shadow-cyan-500/40 focus-visible:ring-cyan-500 dark:from-cyan-500 dark:to-blue-600',
};
const sizeStyles: Record<NonNullable<ButtonProps['size']>, string> = {
xs: 'text-[11px] px-2.5 py-1 rounded-lg gap-1.5',
sm: 'text-xs px-3 py-1.5 rounded-xl gap-2',
md: 'text-sm px-4 py-2.5 rounded-xl gap-2',
lg: 'text-base px-6 py-3.5 rounded-2xl gap-2.5',
icon: 'p-2.5 rounded-xl justify-center items-center',
};
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
(
{
variant = 'primary',
size = 'md',
isLoading = false,
loadingText,
leftIcon,
rightIcon,
fullWidth = false,
disabled,
className = '',
children,
...props
},
ref
) => {
const isInteractiveDisabled = disabled || isLoading;
return (
<button
ref={ref}
disabled={isInteractiveDisabled}
className={`
relative inline-flex items-center justify-center font-medium select-none
transition-all duration-200 ease-out
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2
focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-950
active:scale-[0.98]
disabled:opacity-50 disabled:cursor-not-allowed disabled:pointer-events-none disabled:active:scale-100
${fullWidth ? 'w-full' : ''}
${variantStyles[variant]}
${sizeStyles[size]}
${className}
`}
{...props}
>
{isLoading ? (
<>
<Spinner
size={size === 'xs' || size === 'sm' ? 'xs' : 'sm'}
color={variant === 'primary' || variant === 'success' ? 'slate' : 'current'}
/>
{loadingText ? <span>{loadingText}</span> : children}
</>
) : (
<>
{leftIcon && <span className="shrink-0">{leftIcon}</span>}
{children && <span>{children}</span>}
{rightIcon && <span className="shrink-0">{rightIcon}</span>}
</>
)}
</button>
);
}
);
Button.displayName = 'Button';
+146
View File
@@ -0,0 +1,146 @@
import React from 'react';
export interface CardProps extends React.HTMLAttributes<HTMLDivElement> {
variant?: 'default' | 'elevated' | 'glass' | 'outline' | 'interactive';
padding?: 'none' | 'sm' | 'md' | 'lg';
}
const variantStyles: Record<NonNullable<CardProps['variant']>, string> = {
default:
'bg-white dark:bg-slate-900/90 border border-slate-200 dark:border-slate-800/80 shadow-sm',
elevated:
'bg-white dark:bg-slate-900 border border-slate-100 dark:border-slate-800 shadow-xl shadow-slate-200/50 dark:shadow-black/40',
glass:
'bg-white/80 dark:bg-slate-900/60 backdrop-blur-xl border border-slate-200/70 dark:border-slate-800/70 shadow-lg',
outline:
'bg-transparent border border-slate-300 dark:border-slate-800 shadow-none',
interactive:
'bg-white dark:bg-slate-900/90 border border-slate-200 dark:border-slate-800 shadow-sm hover:shadow-xl hover:shadow-cyan-500/10 hover:border-cyan-500/50 dark:hover:border-cyan-500/40 cursor-pointer transform hover:-translate-y-1 transition-all duration-300 ease-out',
};
const paddingStyles: Record<NonNullable<CardProps['padding']>, string> = {
none: 'p-0',
sm: 'p-4',
md: 'p-6',
lg: 'p-8',
};
export const Card = React.forwardRef<HTMLDivElement, CardProps>(
(
{
variant = 'default',
padding = 'none',
className = '',
children,
...props
},
ref
) => {
return (
<div
ref={ref}
className={`
rounded-2xl overflow-hidden transition-colors
${variantStyles[variant]}
${paddingStyles[padding]}
${className}
`}
{...props}
>
{children}
</div>
);
}
);
Card.displayName = 'Card';
export interface CardHeaderProps extends React.HTMLAttributes<HTMLDivElement> {
bordered?: boolean;
}
export const CardHeader: React.FC<CardHeaderProps> = ({
bordered = false,
className = '',
children,
...props
}) => {
return (
<div
className={`
p-5 sm:p-6 flex flex-col space-y-1.5
${bordered ? 'border-b border-slate-100 dark:border-slate-800/80' : ''}
${className}
`}
{...props}
>
{children}
</div>
);
};
export const CardTitle: React.FC<React.HTMLAttributes<HTMLHeadingElement>> = ({
className = '',
children,
...props
}) => {
return (
<h3
className={`text-lg sm:text-xl font-bold tracking-tight text-slate-900 dark:text-white transition-colors ${className}`}
{...props}
>
{children}
</h3>
);
};
export const CardDescription: React.FC<React.HTMLAttributes<HTMLParagraphElement>> = ({
className = '',
children,
...props
}) => {
return (
<p
className={`text-xs sm:text-sm text-slate-500 dark:text-slate-400 leading-relaxed ${className}`}
{...props}
>
{children}
</p>
);
};
export const CardContent: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({
className = '',
children,
...props
}) => {
return (
<div className={`p-5 sm:p-6 ${className}`} {...props}>
{children}
</div>
);
};
export interface CardFooterProps extends React.HTMLAttributes<HTMLDivElement> {
bordered?: boolean;
}
export const CardFooter: React.FC<CardFooterProps> = ({
bordered = false,
className = '',
children,
...props
}) => {
return (
<div
className={`
p-5 sm:p-6 flex items-center justify-between
${bordered ? 'border-t border-slate-100 dark:border-slate-800/80' : ''}
${className}
`}
{...props}
>
{children}
</div>
);
};
+111
View File
@@ -0,0 +1,111 @@
import React, { useEffect, useRef } from 'react';
import { FiCheck, FiMinus } from 'react-icons/fi';
export interface CheckboxProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'type'> {
label?: React.ReactNode;
description?: string;
errorMessage?: string;
indeterminate?: boolean;
}
export const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(
(
{
label,
description,
errorMessage,
indeterminate = false,
checked,
disabled,
className = '',
id,
name,
...props
},
forwardedRef
) => {
const internalRef = useRef<HTMLInputElement>(null);
const resolvedRef = (forwardedRef as React.RefObject<HTMLInputElement>) || internalRef;
const checkboxId = id || name || (typeof label === 'string' ? label.toLowerCase().replace(/\s+/g, '-') : undefined);
useEffect(() => {
if (resolvedRef.current) {
resolvedRef.current.indeterminate = indeterminate;
}
}, [indeterminate, resolvedRef]);
const isChecked = Boolean(checked);
return (
<div className={`flex flex-col space-y-1 ${className}`}>
<label
htmlFor={checkboxId}
className={`inline-flex items-start gap-2.5 select-none cursor-pointer group ${
disabled ? 'opacity-50 !cursor-not-allowed' : ''
}`}
>
{/* Custom Styled Box */}
<div className="relative flex items-center justify-center mt-0.5 shrink-0">
<input
ref={resolvedRef}
id={checkboxId}
name={name}
type="checkbox"
checked={checked}
disabled={disabled}
className="peer sr-only"
{...props}
/>
<div
className={`
w-4 h-4 rounded-md flex items-center justify-center transition-all duration-200
border
peer-focus-visible:ring-2 peer-focus-visible:ring-offset-2
peer-focus-visible:ring-cyan-500 peer-focus-visible:ring-offset-white dark:peer-focus-visible:ring-offset-slate-950
${
isChecked || indeterminate
? 'bg-cyan-500 border-cyan-500 text-slate-950 shadow-sm shadow-cyan-500/20'
: 'bg-white dark:bg-slate-900 border-slate-300 dark:border-slate-700 text-transparent group-hover:border-slate-400 dark:group-hover:border-slate-500'
}
${
errorMessage
? '!border-rose-500 !bg-rose-50 dark:!bg-rose-950/30'
: ''
}
`}
>
{indeterminate ? (
<FiMinus className="text-xs stroke-3" />
) : isChecked ? (
<FiCheck className="text-xs stroke-3" />
) : null}
</div>
</div>
{/* Label and Sub-description */}
{(label || description) && (
<div className="flex flex-col text-left">
{label && (
<span className="text-xs sm:text-sm font-medium text-slate-800 dark:text-slate-200 group-hover:text-slate-950 dark:group-hover:text-white transition-colors">
{label}
</span>
)}
{description && (
<span className="text-xs text-slate-500 dark:text-slate-400">
{description}
</span>
)}
</div>
)}
</label>
{errorMessage && (
<p className="text-xs text-rose-600 dark:text-rose-400 pl-6.5">{errorMessage}</p>
)}
</div>
);
}
);
Checkbox.displayName = 'Checkbox';
+188
View File
@@ -0,0 +1,188 @@
import React, { useState } from 'react';
import { FiEye, FiEyeOff, FiX, FiAlertCircle } from 'react-icons/fi';
export interface InputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'size'> {
label?: string;
helperText?: string;
errorMessage?: string;
leftIcon?: React.ReactNode;
rightIcon?: React.ReactNode;
isClearable?: boolean;
onClear?: () => void;
sizeVariant?: 'sm' | 'md' | 'lg';
fullWidth?: boolean;
}
const sizeStyles = {
sm: {
input: 'py-2 text-xs rounded-lg',
icon: 'text-sm',
leftPad: 'pl-8',
rightPad: 'pr-8',
},
md: {
input: 'py-2.5 text-sm rounded-xl',
icon: 'text-base',
leftPad: 'pl-10',
rightPad: 'pr-10',
},
lg: {
input: 'py-3.5 text-base rounded-2xl',
icon: 'text-lg',
leftPad: 'pl-12',
rightPad: 'pr-12',
},
};
export const Input = React.forwardRef<HTMLInputElement, InputProps>(
(
{
label,
helperText,
errorMessage,
leftIcon,
rightIcon,
isClearable = false,
onClear,
sizeVariant = 'md',
fullWidth = true,
type = 'text',
id,
name,
disabled,
required,
className = '',
value,
...props
},
ref
) => {
const [showPassword, setShowPassword] = useState(false);
const inputId = id || name || (label ? label.toLowerCase().replace(/\s+/g, '-') : undefined);
const isPasswordType = type === 'password';
const computedType = isPasswordType ? (showPassword ? 'text' : 'password') : type;
const hasError = Boolean(errorMessage);
const hasValue = value !== undefined && value !== '' && value !== null;
const currentSize = sizeStyles[sizeVariant];
return (
<div className={`${fullWidth ? 'w-full' : 'inline-block'} space-y-1.5`}>
{/* Label */}
{label && (
<div className="flex items-center justify-between">
<label
htmlFor={inputId}
className={`block text-xs font-semibold select-none transition-colors ${
hasError
? 'text-rose-600 dark:text-rose-400'
: 'text-slate-700 dark:text-slate-300'
}`}
>
{label}
{required && <span className="ml-1 text-rose-500 font-bold">*</span>}
</label>
</div>
)}
{/* Input Wrapper */}
<div className="relative flex items-center">
{/* Left Icon */}
{leftIcon && (
<div
className={`absolute left-3.5 pointer-events-none flex items-center justify-center transition-colors ${
hasError
? 'text-rose-500'
: 'text-slate-400 dark:text-slate-500'
} ${currentSize.icon}`}
>
{leftIcon}
</div>
)}
{/* Core Input Element */}
<input
ref={ref}
id={inputId}
name={name}
type={computedType}
value={value}
disabled={disabled}
required={required}
className={`
w-full px-3.5 font-normal transition-all duration-200 outline-none
bg-white text-slate-900 placeholder:text-slate-400
border border-slate-300
hover:border-slate-400
focus:border-cyan-500 focus:ring-4 focus:ring-cyan-500/15
dark:bg-slate-900/90 dark:text-slate-100 dark:placeholder:text-slate-500
dark:border-slate-800 dark:hover:border-slate-700
dark:focus:border-cyan-500 dark:focus:ring-cyan-500/25
disabled:opacity-60 disabled:bg-slate-100 disabled:dark:bg-slate-950 disabled:cursor-not-allowed
${leftIcon ? currentSize.leftPad : ''}
${isPasswordType || isClearable || rightIcon ? currentSize.rightPad : ''}
${
hasError
? '!border-rose-500 !ring-rose-500/20 text-rose-900 dark:text-rose-200 focus:!border-rose-500 focus:!ring-rose-500/20'
: ''
}
${currentSize.input}
${className}
`}
{...props}
/>
{/* Right Action Icons (Clear, Password Toggle, Custom RightIcon) */}
<div className="absolute right-3 flex items-center gap-1.5 text-slate-400 dark:text-slate-500">
{isClearable && hasValue && !disabled && (
<button
type="button"
tabIndex={-1}
onClick={onClear}
aria-label="Limpiar campo"
className="p-1 rounded-md hover:bg-slate-100 hover:text-slate-700 dark:hover:bg-slate-800 dark:hover:text-slate-200 transition-colors"
>
<FiX className="text-sm" />
</button>
)}
{isPasswordType && !disabled && (
<button
type="button"
tabIndex={-1}
onClick={() => setShowPassword((prev) => !prev)}
aria-label={showPassword ? 'Ocultar contraseña' : 'Ver contraseña'}
className="p-1 rounded-md hover:bg-slate-100 hover:text-slate-700 dark:hover:bg-slate-800 dark:hover:text-slate-200 transition-colors"
>
{showPassword ? (
<FiEyeOff className={currentSize.icon} />
) : (
<FiEye className={currentSize.icon} />
)}
</button>
)}
{!isPasswordType && rightIcon && (
<div className={`pointer-events-none flex items-center justify-center ${currentSize.icon}`}>
{rightIcon}
</div>
)}
</div>
</div>
{/* Error or Helper Message */}
{hasError ? (
<p className="flex items-center gap-1.5 text-xs text-rose-600 dark:text-rose-400 animate-fadeIn">
<FiAlertCircle className="shrink-0 text-xs" />
<span>{errorMessage}</span>
</p>
) : helperText ? (
<p className="text-xs text-slate-500 dark:text-slate-400">{helperText}</p>
) : null}
</div>
);
}
);
Input.displayName = 'Input';
+128
View File
@@ -0,0 +1,128 @@
import React, { useEffect } from 'react';
import { FiX } from 'react-icons/fi';
export interface ModalProps {
isOpen: boolean;
onClose: () => void;
title?: React.ReactNode;
description?: React.ReactNode;
size?: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | 'full';
children: React.ReactNode;
footer?: React.ReactNode;
closeOnOverlayClick?: boolean;
showCloseButton?: boolean;
className?: string;
}
const sizeStyles = {
sm: 'max-w-md',
md: 'max-w-lg',
lg: 'max-w-2xl',
xl: 'max-w-4xl',
'2xl': 'max-w-6xl',
full: 'max-w-[95vw] h-[90vh]',
};
export const Modal: React.FC<ModalProps> = ({
isOpen,
onClose,
title,
description,
size = 'md',
children,
footer,
closeOnOverlayClick = true,
showCloseButton = true,
className = '',
}) => {
// ESC key handler and body scroll lock
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
}
};
const originalOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
window.addEventListener('keydown', handleKeyDown);
return () => {
document.body.style.overflow = originalOverflow;
window.removeEventListener('keydown', handleKeyDown);
};
}, [isOpen, onClose]);
if (!isOpen) return null;
return (
<div
role="dialog"
aria-modal="true"
className="fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-6 overflow-y-auto"
>
{/* Backdrop */}
<div
onClick={() => {
if (closeOnOverlayClick) onClose();
}}
className="fixed inset-0 bg-slate-950/70 backdrop-blur-md transition-opacity animate-fadeIn"
/>
{/* Modal Dialog Card */}
<div
className={`
relative w-full rounded-3xl overflow-hidden shadow-2xl z-10
bg-white text-slate-900 border border-slate-200
dark:bg-slate-900/95 dark:text-slate-100 dark:border-slate-800
transition-all transform animate-scaleUp
${sizeStyles[size]}
${className}
`}
>
{/* Header */}
{(title || description || showCloseButton) && (
<div className="flex items-start justify-between p-6 pb-4 border-b border-slate-100 dark:border-slate-800/80">
<div className="space-y-1 pr-6">
{title && (
<h2 className="text-xl font-bold text-slate-900 dark:text-white tracking-tight">
{title}
</h2>
)}
{description && (
<p className="text-xs sm:text-sm text-slate-500 dark:text-slate-400">
{description}
</p>
)}
</div>
{showCloseButton && (
<button
type="button"
onClick={onClose}
aria-label="Cerrar modal"
className="p-2 rounded-xl text-slate-400 hover:text-slate-700 hover:bg-slate-100 dark:hover:text-slate-200 dark:hover:bg-slate-800 transition-colors"
>
<FiX className="text-lg" />
</button>
)}
</div>
)}
{/* Body Content */}
<div className="p-6 overflow-y-auto max-h-[calc(85vh-160px)]">
{children}
</div>
{/* Footer */}
{footer && (
<div className="p-6 pt-4 border-t border-slate-100 dark:border-slate-800/80 bg-slate-50/50 dark:bg-slate-950/40 flex items-center justify-end gap-3">
{footer}
</div>
)}
</div>
</div>
);
};
+164
View File
@@ -0,0 +1,164 @@
import React from 'react';
import { FiChevronDown, FiAlertCircle } from 'react-icons/fi';
export interface SelectOption {
value: string | number;
label: string;
disabled?: boolean;
}
export interface SelectProps extends Omit<React.SelectHTMLAttributes<HTMLSelectElement>, 'size'> {
label?: string;
helperText?: string;
errorMessage?: string;
leftIcon?: React.ReactNode;
options?: SelectOption[];
sizeVariant?: 'sm' | 'md' | 'lg';
fullWidth?: boolean;
}
const sizeStyles = {
sm: {
select: 'py-2 text-xs rounded-lg',
icon: 'text-sm',
leftPad: 'pl-8',
rightPad: 'pr-8',
},
md: {
select: 'py-2.5 text-sm rounded-xl',
icon: 'text-base',
leftPad: 'pl-10',
rightPad: 'pr-10',
},
lg: {
select: 'py-3.5 text-base rounded-2xl',
icon: 'text-lg',
leftPad: 'pl-12',
rightPad: 'pr-12',
},
};
export const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
(
{
label,
helperText,
errorMessage,
leftIcon,
options,
sizeVariant = 'md',
fullWidth = true,
id,
name,
disabled,
required,
className = '',
children,
...props
},
ref
) => {
const selectId = id || name || (label ? label.toLowerCase().replace(/\s+/g, '-') : undefined);
const hasError = Boolean(errorMessage);
const currentSize = sizeStyles[sizeVariant];
return (
<div className={`${fullWidth ? 'w-full' : 'inline-block'} space-y-1.5`}>
{/* Label */}
{label && (
<div className="flex items-center justify-between">
<label
htmlFor={selectId}
className={`block text-xs font-semibold select-none transition-colors ${
hasError
? 'text-rose-600 dark:text-rose-400'
: 'text-slate-700 dark:text-slate-300'
}`}
>
{label}
{required && <span className="ml-1 text-rose-500 font-bold">*</span>}
</label>
</div>
)}
{/* Select Wrapper */}
<div className="relative flex items-center">
{/* Left Icon */}
{leftIcon && (
<div
className={`absolute left-3.5 pointer-events-none flex items-center justify-center transition-colors ${
hasError
? 'text-rose-500'
: 'text-slate-400 dark:text-slate-500'
} ${currentSize.icon}`}
>
{leftIcon}
</div>
)}
{/* Core Select Element */}
<select
ref={ref}
id={selectId}
name={name}
disabled={disabled}
required={required}
className={`
w-full px-3.5 font-normal transition-all duration-200 outline-none appearance-none cursor-pointer
bg-white text-slate-900
border border-slate-300
hover:border-slate-400
focus:border-cyan-500 focus:ring-4 focus:ring-cyan-500/15
dark:bg-slate-900/90 dark:text-slate-100
dark:border-slate-800 dark:hover:border-slate-700
dark:focus:border-cyan-500 dark:focus:ring-cyan-500/25
disabled:opacity-60 disabled:bg-slate-100 disabled:dark:bg-slate-950 disabled:cursor-not-allowed
${leftIcon ? currentSize.leftPad : ''}
${currentSize.rightPad}
${
hasError
? '!border-rose-500 !ring-rose-500/20 text-rose-900 dark:text-rose-200 focus:!border-rose-500 focus:!ring-rose-500/20'
: ''
}
${currentSize.select}
${className}
`}
{...props}
>
{options
? options.map((opt) => (
<option
key={opt.value}
value={opt.value}
disabled={opt.disabled}
className="bg-white text-slate-900 dark:bg-slate-900 dark:text-slate-100"
>
{opt.label}
</option>
))
: children}
</select>
{/* Right Chevron Icon */}
<div
className={`absolute right-3.5 pointer-events-none flex items-center justify-center text-slate-400 dark:text-slate-500 ${currentSize.icon}`}
>
<FiChevronDown />
</div>
</div>
{/* Error or Helper Message */}
{hasError ? (
<p className="flex items-center gap-1.5 text-xs text-rose-600 dark:text-rose-400 animate-fadeIn">
<FiAlertCircle className="shrink-0 text-xs" />
<span>{errorMessage}</span>
</p>
) : helperText ? (
<p className="text-xs text-slate-500 dark:text-slate-400">{helperText}</p>
) : null}
</div>
);
}
);
Select.displayName = 'Select';
+44
View File
@@ -0,0 +1,44 @@
import React from 'react';
export interface SpinnerProps {
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
color?: 'cyan' | 'blue' | 'white' | 'slate' | 'current';
className?: string;
label?: string;
}
const sizeClasses = {
xs: 'w-3.5 h-3.5 border-2',
sm: 'w-4 h-4 border-2',
md: 'w-6 h-6 border-2',
lg: 'w-8 h-8 border-3',
xl: 'w-12 h-12 border-4',
};
const colorClasses = {
cyan: 'border-cyan-500/20 border-t-cyan-500 dark:border-cyan-400/20 dark:border-t-cyan-400',
blue: 'border-blue-500/20 border-t-blue-500 dark:border-blue-400/20 dark:border-t-blue-400',
white: 'border-white/30 border-t-white',
slate: 'border-slate-300 border-t-slate-700 dark:border-slate-700 dark:border-t-slate-200',
current: 'border-current/20 border-t-current',
};
export const Spinner: React.FC<SpinnerProps> = ({
size = 'md',
color = 'cyan',
className = '',
label = 'Cargando...',
}) => {
return (
<div
role="status"
aria-label={label}
className={`inline-flex items-center justify-center ${className}`}
>
<div
className={`rounded-full animate-spin ${sizeClasses[size]} ${colorClasses[color]}`}
/>
<span className="sr-only">{label}</span>
</div>
);
};
+122
View File
@@ -0,0 +1,122 @@
import React from 'react';
export interface SwitchProps {
label?: React.ReactNode;
description?: string;
checked?: boolean;
onChange?: (checked: boolean) => void;
disabled?: boolean;
size?: 'sm' | 'md' | 'lg';
checkedIcon?: React.ReactNode;
uncheckedIcon?: React.ReactNode;
id?: string;
name?: string;
className?: string;
}
const trackSizes = {
sm: 'w-8 h-4.5',
md: 'w-11 h-6',
lg: 'w-14 h-7.5',
};
const thumbSizes = {
sm: 'w-3.5 h-3.5',
md: 'w-5 h-5',
lg: 'w-6.5 h-6.5',
};
const translateSizes = {
sm: 'translate-x-3.5',
md: 'translate-x-5',
lg: 'translate-x-6.5',
};
export const Switch: React.FC<SwitchProps> = ({
label,
description,
checked = false,
onChange,
disabled = false,
size = 'md',
checkedIcon,
uncheckedIcon,
id,
name,
className = '',
}) => {
const switchId = id || name || (typeof label === 'string' ? label.toLowerCase().replace(/\s+/g, '-') : undefined);
const handleClick = () => {
if (!disabled && onChange) {
onChange(!checked);
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
handleClick();
}
};
return (
<div className={`inline-flex items-start gap-3 select-none ${className}`}>
{/* Switch Button */}
<button
id={switchId}
type="button"
role="switch"
aria-checked={checked}
disabled={disabled}
onClick={handleClick}
onKeyDown={handleKeyDown}
className={`
relative shrink-0 inline-flex items-center rounded-full p-0.5 cursor-pointer transition-colors duration-200 ease-in-out
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2
focus-visible:ring-cyan-500 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-950
disabled:opacity-50 disabled:cursor-not-allowed
${trackSizes[size]}
${
checked
? 'bg-cyan-500 dark:bg-cyan-500'
: 'bg-slate-300 dark:bg-slate-700 hover:bg-slate-400 dark:hover:bg-slate-600'
}
`}
>
{/* Thumb */}
<span
className={`
pointer-events-none flex items-center justify-center rounded-full bg-white shadow-md transform transition-transform duration-200 ease-in-out text-slate-800
${thumbSizes[size]}
${checked ? translateSizes[size] : 'translate-x-0'}
`}
>
{checked ? checkedIcon : uncheckedIcon}
</span>
</button>
{/* Label and description */}
{(label || description) && (
<div className="flex flex-col text-left cursor-pointer" onClick={handleClick}>
{label && (
<span
className={`text-xs sm:text-sm font-medium transition-colors ${
disabled
? 'text-slate-400 dark:text-slate-600'
: 'text-slate-800 dark:text-slate-200 hover:text-slate-950 dark:hover:text-white'
}`}
>
{label}
</span>
)}
{description && (
<span className="text-xs text-slate-500 dark:text-slate-400">
{description}
</span>
)}
</div>
)}
</div>
);
};
+117
View File
@@ -0,0 +1,117 @@
import React from 'react';
import { FiAlertCircle } from 'react-icons/fi';
export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
label?: string;
helperText?: string;
errorMessage?: string;
showCharCount?: boolean;
fullWidth?: boolean;
}
export const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
(
{
label,
helperText,
errorMessage,
showCharCount = false,
fullWidth = true,
id,
name,
disabled,
required,
maxLength,
className = '',
value,
rows = 4,
...props
},
ref
) => {
const textareaId = id || name || (label ? label.toLowerCase().replace(/\s+/g, '-') : undefined);
const hasError = Boolean(errorMessage);
const currentLength = typeof value === 'string' ? value.length : 0;
return (
<div className={`${fullWidth ? 'w-full' : 'inline-block'} space-y-1.5`}>
{/* Header (Label & Character Count) */}
{(label || (showCharCount && maxLength)) && (
<div className="flex items-center justify-between">
{label && (
<label
htmlFor={textareaId}
className={`block text-xs font-semibold select-none transition-colors ${
hasError
? 'text-rose-600 dark:text-rose-400'
: 'text-slate-700 dark:text-slate-300'
}`}
>
{label}
{required && <span className="ml-1 text-rose-500 font-bold">*</span>}
</label>
)}
{showCharCount && maxLength && (
<span
className={`text-[11px] font-medium transition-colors ${
currentLength >= maxLength
? 'text-rose-500 font-bold'
: currentLength >= maxLength * 0.85
? 'text-amber-500'
: 'text-slate-400 dark:text-slate-500'
}`}
>
{currentLength}/{maxLength}
</span>
)}
</div>
)}
{/* Core Textarea */}
<div className="relative">
<textarea
ref={ref}
id={textareaId}
name={name}
value={value}
disabled={disabled}
required={required}
maxLength={maxLength}
rows={rows}
className={`
w-full p-3.5 text-sm font-normal rounded-xl transition-all duration-200 outline-none resize-y
bg-white text-slate-900 placeholder:text-slate-400
border border-slate-300
hover:border-slate-400
focus:border-cyan-500 focus:ring-4 focus:ring-cyan-500/15
dark:bg-slate-900/90 dark:text-slate-100 dark:placeholder:text-slate-500
dark:border-slate-800 dark:hover:border-slate-700
dark:focus:border-cyan-500 dark:focus:ring-cyan-500/25
disabled:opacity-60 disabled:bg-slate-100 disabled:dark:bg-slate-950 disabled:cursor-not-allowed
${
hasError
? '!border-rose-500 !ring-rose-500/20 text-rose-900 dark:text-rose-200 focus:!border-rose-500 focus:!ring-rose-500/20'
: ''
}
${className}
`}
{...props}
/>
</div>
{/* Error or Helper Message */}
{hasError ? (
<p className="flex items-center gap-1.5 text-xs text-rose-600 dark:text-rose-400 animate-fadeIn">
<FiAlertCircle className="shrink-0 text-xs" />
<span>{errorMessage}</span>
</p>
) : helperText ? (
<p className="text-xs text-slate-500 dark:text-slate-400">{helperText}</p>
) : null}
</div>
);
}
);
Textarea.displayName = 'Textarea';
+133
View File
@@ -0,0 +1,133 @@
import React, { useEffect, useState } from 'react';
import { FiSun, FiMoon } from 'react-icons/fi';
export interface ThemeToggleProps {
variant?: 'icon' | 'pill' | 'switch';
size?: 'sm' | 'md' | 'lg';
className?: string;
}
export const ThemeToggle: React.FC<ThemeToggleProps> = ({
variant = 'icon',
size = 'md',
className = '',
}) => {
const [isDark, setIsDark] = useState<boolean>(() => {
if (typeof window === 'undefined') return true;
const stored = localStorage.getItem('theme');
if (stored) return stored === 'dark';
return (
document.documentElement.classList.contains('dark') ||
window.matchMedia('(prefers-color-scheme: dark)').matches
);
});
useEffect(() => {
const root = document.documentElement;
if (isDark) {
root.classList.add('dark');
localStorage.setItem('theme', 'dark');
} else {
root.classList.remove('dark');
localStorage.setItem('theme', 'light');
}
}, [isDark]);
const toggleTheme = () => {
setIsDark((prev) => !prev);
};
const iconSizes = {
sm: 'text-sm',
md: 'text-base',
lg: 'text-lg',
};
const paddingSizes = {
sm: 'p-1.5',
md: 'p-2.5',
lg: 'p-3',
};
if (variant === 'pill') {
return (
<button
type="button"
onClick={toggleTheme}
aria-label={isDark ? 'Cambiar a modo claro' : 'Cambiar a modo oscuro'}
className={`
inline-flex items-center gap-2 px-3 py-1.5 rounded-full text-xs font-semibold
bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-200
border border-slate-200 dark:border-slate-700
hover:border-cyan-500/50 dark:hover:border-cyan-500/50
transition-all duration-200 shadow-sm ${className}
`}
>
{isDark ? (
<>
<FiMoon className="text-cyan-400" />
<span>Modo Oscuro</span>
</>
) : (
<>
<FiSun className="text-amber-500" />
<span>Modo Claro</span>
</>
)}
</button>
);
}
if (variant === 'switch') {
return (
<button
type="button"
role="switch"
aria-checked={isDark}
onClick={toggleTheme}
aria-label="Alternar tema de color"
className={`
relative inline-flex items-center w-14 h-8 rounded-full p-1 transition-colors duration-300
${isDark ? 'bg-slate-800 border border-slate-700' : 'bg-slate-200 border border-slate-300'}
${className}
`}
>
<span
className={`
flex items-center justify-center w-6 h-6 rounded-full bg-white dark:bg-slate-900 shadow-md transform transition-transform duration-300
${isDark ? 'translate-x-6 text-cyan-400' : 'translate-x-0 text-amber-500'}
`}
>
{isDark ? <FiMoon className="text-xs" /> : <FiSun className="text-xs" />}
</span>
</button>
);
}
// Default 'icon' variant
return (
<button
type="button"
onClick={toggleTheme}
aria-label={isDark ? 'Cambiar a modo claro' : 'Cambiar a modo oscuro'}
title={isDark ? 'Cambiar a modo claro' : 'Cambiar a modo oscuro'}
className={`
relative inline-flex items-center justify-center rounded-xl transition-all duration-200
bg-slate-100 dark:bg-slate-800/80 text-slate-600 dark:text-slate-300
border border-slate-200 dark:border-slate-700/80
hover:text-cyan-600 dark:hover:text-cyan-400
hover:border-cyan-500/40 dark:hover:border-cyan-500/40
hover:bg-slate-200/60 dark:hover:bg-slate-800
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-500
${paddingSizes[size]}
${className}
`}
>
{isDark ? (
<FiSun className={`${iconSizes[size]} text-amber-400 transition-transform hover:rotate-45`} />
) : (
<FiMoon className={`${iconSizes[size]} text-slate-700 transition-transform hover:-rotate-12`} />
)}
</button>
);
};
+48
View File
@@ -0,0 +1,48 @@
// Button & Actions
export { Button } from './Button';
export type { ButtonProps } from './Button';
// Form Fields & Inputs
export { Input } from './Input';
export type { InputProps } from './Input';
export { Textarea } from './Textarea';
export type { TextareaProps } from './Textarea';
export { Select } from './Select';
export type { SelectProps, SelectOption } from './Select';
export { Checkbox } from './Checkbox';
export type { CheckboxProps } from './Checkbox';
export { Switch } from './Switch';
export type { SwitchProps } from './Switch';
// Feedback & Data Display
export { Badge } from './Badge';
export type { BadgeProps } from './Badge';
export { Spinner } from './Spinner';
export type { SpinnerProps } from './Spinner';
// Layout & Overlays
export {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
CardFooter,
} from './Card';
export type {
CardProps,
CardHeaderProps,
CardFooterProps,
} from './Card';
export { Modal } from './Modal';
export type { ModalProps } from './Modal';
// Theme Control
export { ThemeToggle } from './ThemeToggle';
export type { ThemeToggleProps } from './ThemeToggle';
+77 -17
View File
@@ -1,5 +1,7 @@
@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));
@layer base {
html {
scroll-behavior: smooth;
@@ -7,6 +9,34 @@
}
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes scaleUp {
from {
opacity: 0;
transform: scale(0.95) translateY(8px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
.animate-fadeIn {
animation: fadeIn 0.2s ease-out forwards;
}
.animate-scaleUp {
animation: scaleUp 0.25s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
/* Custom modern scrollbar */
::-webkit-scrollbar {
width: 8px;
@@ -26,30 +56,39 @@
background: #475569;
}
/* Cart Running Animation */
@keyframes cart-run {
/* Full-Screen Crossing Flying Spaceship Animation */
@keyframes float-spaceship {
0% {
transform: translateX(0) rotate(0deg) translateY(0);
transform: translate(-10vw, 68vh) rotate(23deg) scale(0.65);
opacity: 0.1;
}
20% {
transform: translateX(3px) rotate(-10deg) translateY(-2px);
15% {
opacity: 0.85;
}
40% {
transform: translateX(1px) rotate(-5deg) translateY(1px);
35% {
transform: translate(30vw, 45vh) rotate(25deg) scale(0.85);
opacity: 0.95;
}
60% {
transform: translateX(4px) rotate(-12deg) translateY(-2px);
65% {
transform: translate(65vw, 25vh) rotate(22deg) scale(1);
opacity: 0.95;
}
80% {
transform: translateX(2px) rotate(-8deg) translateY(0px);
85% {
opacity: 0.85;
}
100% {
transform: translateX(0) rotate(0deg) translateY(0);
transform: translate(110vw, 5vh) rotate(24deg) scale(0.7);
opacity: 0.1;
}
}
.animate-cart-run {
animation: cart-run 0.5s ease-in-out infinite;
.animate-spaceship {
animation: float-spaceship 20s cubic-bezier(0.35, 0, 0.25, 1) infinite;
}
/* Speed Streaks for Cart */
@@ -58,10 +97,12 @@
opacity: 0;
transform: translateX(0) scaleX(0.2);
}
50% {
opacity: 0.8;
transform: translateX(-8px) scaleX(1);
}
100% {
opacity: 0;
transform: translateX(-16px) scaleX(0);
@@ -77,23 +118,42 @@
pointer-events: none;
}
.group-hover\\:animate-cart-run:hover .speed-line-1 {
.group:hover .speed-line-1 {
animation: speed-streak 0.6s infinite linear;
top: 30%;
right: 110%;
width: 12px;
}
.group-hover\\:animate-cart-run:hover .speed-line-2 {
.group:hover .speed-line-2 {
animation: speed-streak 0.5s infinite linear 0.2s;
top: 60%;
right: 100%;
width: 8px;
}
.group-hover\\:animate-cart-run:hover .speed-line-3 {
.group:hover .speed-line-3 {
animation: speed-streak 0.7s infinite linear 0.4s;
bottom: 10%;
right: 120%;
width: 14px;
}
/* Twinkling Star Particles Animation */
@keyframes star-twinkle {
0%,
100% {
opacity: 0.15;
transform: scale(0.65);
}
50% {
opacity: 0.95;
transform: scale(1.3);
}
}
.animate-twinkle {
animation: star-twinkle 3s ease-in-out infinite alternate;
}
+547 -356
View File
@@ -1,5 +1,15 @@
import React, { useState } from 'react';
import { Link } from 'react-router';
import type { Course } from '../types/course';
import { BrandLogo } from '../components/common/BrandLogo';
import {
Card,
Button,
Badge,
Input,
Select,
ThemeToggle
} from '../components/ui';
import {
FiDollarSign,
FiUsers,
@@ -9,10 +19,12 @@ import {
FiTrash2,
FiShield,
FiSearch,
FiFilter,
FiActivity,
FiPieChart,
FiSettings
FiSettings,
FiMenu,
FiX,
FiArrowLeft
} from 'react-icons/fi';
interface AdminDashboardViewProps {
@@ -26,13 +38,28 @@ export const AdminDashboardView: React.FC<AdminDashboardViewProps> = ({
onDeleteCourse,
onShowToast,
}) => {
const [activeTab, setActiveTab] = useState<'courses' | 'users' | 'finance' | 'settings'>('courses');
const [activeTab, setActiveTab] = useState<'overview' | 'courses' | 'users' | 'finance' | 'settings'>('overview');
const [sidebarOpen, setSidebarOpen] = useState(false);
const [statusFilter, setStatusFilter] = useState<'all' | 'published' | 'pending'>('all');
const [searchTerm, setSearchTerm] = useState('');
const [userSearchTerm, setUserSearchTerm] = useState('');
// Course status state management for demonstration
const [courseStatuses, setCourseStatuses] = useState<Record<string, 'published' | 'pending' | 'rejected'>>({});
// Mock platform users
const [users, setUsers] = useState([
{ id: 'u-1', name: 'Ing. Carlos Mendoza', email: '[email protected]', role: 'Instructor', courses: 4, joined: '12 Ene 2026', status: 'Activo' },
{ id: 'u-2', name: 'Dra. Elena Torres', email: '[email protected]', role: 'Instructor', courses: 2, joined: '04 Feb 2026', status: 'Activo' },
{ id: 'u-3', name: 'Marcos Rodriguez', email: '[email protected]', role: 'Estudiante', courses: 5, joined: '18 Feb 2026', status: 'Activo' },
{ id: 'u-4', name: 'Sofía Martinez', email: '[email protected]', role: 'Estudiante', courses: 2, joined: '20 Feb 2026', status: 'Activo' },
{ id: 'u-5', name: 'Admin Principal', email: '[email protected]', role: 'Administrador', courses: 0, joined: '01 Ene 2026', status: 'Activo' },
]);
// Platform Commission Setting State
const [commissionRate, setCommissionRate] = useState(20);
const [siteName, setSiteName] = useState('RyCourses');
const getStatus = (courseId: string) => {
return courseStatuses[courseId] || 'published';
};
@@ -41,17 +68,19 @@ export const AdminDashboardView: React.FC<AdminDashboardViewProps> = ({
const current = getStatus(courseId);
const nextStatus = current === 'published' ? 'pending' : 'published';
setCourseStatuses({ ...courseStatuses, [courseId]: nextStatus });
onShowToast(`Estado del curso "${title.slice(0, 25)}..." cambiado a ${nextStatus === 'published' ? 'Publicado' : 'Pendiente'}`);
onShowToast(`Estado del curso "${title.slice(0, 22)}..." cambiado a ${nextStatus === 'published' ? 'Publicado' : 'Pendiente'}`);
};
// Mock platform users
const [users] = useState([
{ id: 'u-1', name: 'Ing. Carlos Mendoza', email: '[email protected]', role: 'Instructor', courses: 4, joined: '12 Ene 2026', status: 'Activo' },
{ id: 'u-2', name: 'Dra. Elena Torres', email: '[email protected]', role: 'Instructor', courses: 2, joined: '04 Feb 2026', status: 'Activo' },
{ id: 'u-3', name: 'Marcos Rodriguez', email: '[email protected]', role: 'Estudiante', courses: 5, joined: '18 Feb 2026', status: 'Activo' },
{ id: 'u-4', name: 'Sofía Martinez', email: '[email protected]', role: 'Estudiante', courses: 2, joined: '20 Feb 2026', status: 'Activo' },
{ id: 'u-5', name: 'Admin Principal', email: '[email protected]', role: 'Administrador', courses: 0, joined: '01 Ene 2026', status: 'Activo' },
]);
const toggleUserStatus = (userId: string, userName: string) => {
setUsers(users.map((u) => {
if (u.id === userId) {
const next = u.status === 'Activo' ? 'Suspendido' : 'Activo';
onShowToast(`Estado de ${userName} cambiado a ${next}`);
return { ...u, status: next };
}
return u;
}));
};
const filteredCourses = courses.filter((c) => {
const matchesSearch = c.title.toLowerCase().includes(searchTerm.toLowerCase()) || c.instructor.name.toLowerCase().includes(searchTerm.toLowerCase());
@@ -60,382 +89,544 @@ export const AdminDashboardView: React.FC<AdminDashboardViewProps> = ({
return matchesSearch && matchesStatus;
});
const filteredUsers = users.filter((u) =>
u.name.toLowerCase().includes(userSearchTerm.toLowerCase()) || u.email.toLowerCase().includes(userSearchTerm.toLowerCase()) || u.role.toLowerCase().includes(userSearchTerm.toLowerCase())
);
const totalGrossRevenue = 184920.00;
const netCommission = totalGrossRevenue * (commissionRate / 100);
return (
<div className="min-h-screen bg-slate-950 text-slate-100 pb-20">
<div className="min-h-screen transition-colors duration-300 bg-slate-50 dark:bg-slate-950 text-slate-900 dark:text-slate-100 flex flex-col md:flex-row font-sans selection:bg-cyan-500">
{/* Top Banner */}
<div className="bg-gradient-to-r from-slate-900 via-indigo-950 to-slate-900 border-b border-slate-800 py-8">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
{/* Mobile Sidebar Overlay */}
{sidebarOpen && (
<div
className="fixed inset-0 bg-slate-950/80 backdrop-blur-sm z-40 md:hidden"
onClick={() => setSidebarOpen(false)}
/>
)}
{/* 1. Left Sidebar Navigation */}
<aside className={`
fixed md:sticky top-0 left-0 h-screen w-72 bg-white dark:bg-slate-900 border-r border-slate-200 dark:border-slate-800 z-50 flex flex-col justify-between p-5 transition-transform duration-300 shadow-xl
${sidebarOpen ? 'translate-x-0' : '-translate-x-full md:translate-x-0'}
`}>
<div className="space-y-6">
{/* Sidebar Brand Logo & Close button */}
<div className="flex items-center justify-between pb-3 border-b border-slate-200 dark:border-slate-800">
<Link to="/" className="hover:opacity-90 transition">
<BrandLogo size="sm" showSubtitle={false} />
</Link>
<button
onClick={() => setSidebarOpen(false)}
className="md:hidden p-1.5 rounded-lg text-slate-500 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white"
>
<FiX className="text-xl" />
</button>
</div>
{/* Admin Pill Badge */}
<div className="px-3 py-2 rounded-xl bg-violet-500/10 border border-violet-500/25 flex items-center gap-2.5">
<div className="w-7 h-7 rounded-lg bg-violet-500/20 text-violet-600 dark:text-violet-400 flex items-center justify-center text-sm font-bold shrink-0">
<FiShield />
</div>
<div>
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full text-xs font-bold bg-violet-500/20 text-violet-300 border border-violet-500/30 mb-2">
<FiShield /> Panel General de Administración
</div>
<h1 className="text-2xl sm:text-4xl font-extrabold text-white">
Control & Moderación RyCourses
</h1>
<p className="text-xs sm:text-sm text-slate-400 mt-1">
Gestión centralizada de catálogo, usuarios, instructores y métricas financieras.
</p>
</div>
<div className="flex items-center gap-3">
<span className="text-xs text-emerald-400 bg-emerald-500/10 border border-emerald-500/20 px-3 py-1.5 rounded-xl font-semibold flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full bg-emerald-400 animate-pulse" /> Servidores Normales (99.9% Uptime)
</span>
<p className="text-xs font-bold text-slate-900 dark:text-white leading-none">Admin Suite</p>
<p className="text-[10px] text-violet-600 dark:text-violet-400 font-medium mt-0.5">Control Central</p>
</div>
</div>
</div>
</div>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-8">
{/* Navigation Links */}
<nav className="space-y-1.5">
<button
onClick={() => { setActiveTab('overview'); setSidebarOpen(false); }}
className={`w-full flex items-center gap-3 px-3.5 py-3 rounded-xl text-xs font-semibold transition-all ${
activeTab === 'overview'
? 'bg-slate-100 dark:bg-slate-800 text-cyan-600 dark:text-cyan-400 border border-slate-200 dark:border-cyan-500/30 shadow-sm'
: 'text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white hover:bg-slate-100 dark:hover:bg-slate-800/60'
}`}
>
<FiActivity className="text-base" />
<span>Dashboard & Métricas</span>
</button>
<button
onClick={() => { setActiveTab('courses'); setSidebarOpen(false); }}
className={`w-full flex items-center justify-between px-3.5 py-3 rounded-xl text-xs font-semibold transition-all ${
activeTab === 'courses'
? 'bg-slate-100 dark:bg-slate-800 text-cyan-600 dark:text-cyan-400 border border-slate-200 dark:border-cyan-500/30 shadow-sm'
: 'text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white hover:bg-slate-100 dark:hover:bg-slate-800/60'
}`}
>
<div className="flex items-center gap-3">
<FiBookOpen className="text-base" />
<span>Gestor de Cursos</span>
</div>
<Badge variant="primary" size="sm">{courses.length}</Badge>
</button>
<button
onClick={() => { setActiveTab('users'); setSidebarOpen(false); }}
className={`w-full flex items-center justify-between px-3.5 py-3 rounded-xl text-xs font-semibold transition-all ${
activeTab === 'users'
? 'bg-slate-100 dark:bg-slate-800 text-cyan-600 dark:text-cyan-400 border border-slate-200 dark:border-cyan-500/30 shadow-sm'
: 'text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white hover:bg-slate-100 dark:hover:bg-slate-800/60'
}`}
>
<div className="flex items-center gap-3">
<FiUsers className="text-base" />
<span>Usuarios & Roles</span>
</div>
<Badge variant="secondary" size="sm">{users.length}</Badge>
</button>
<button
onClick={() => { setActiveTab('finance'); setSidebarOpen(false); }}
className={`w-full flex items-center gap-3 px-3.5 py-3 rounded-xl text-xs font-semibold transition-all ${
activeTab === 'finance'
? 'bg-slate-100 dark:bg-slate-800 text-cyan-600 dark:text-cyan-400 border border-slate-200 dark:border-cyan-500/30 shadow-sm'
: 'text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white hover:bg-slate-100 dark:hover:bg-slate-800/60'
}`}
>
<FiDollarSign className="text-base" />
<span>Finanzas & Ventas</span>
</button>
<button
onClick={() => { setActiveTab('settings'); setSidebarOpen(false); }}
className={`w-full flex items-center gap-3 px-3.5 py-3 rounded-xl text-xs font-semibold transition-all ${
activeTab === 'settings'
? 'bg-slate-100 dark:bg-slate-800 text-cyan-600 dark:text-cyan-400 border border-slate-200 dark:border-cyan-500/30 shadow-sm'
: 'text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white hover:bg-slate-100 dark:hover:bg-slate-800/60'
}`}
>
<FiSettings className="text-base" />
<span>Configuración Global</span>
</button>
</nav>
</div>
{/* Sidebar Footer */}
<div className="pt-4 border-t border-slate-200 dark:border-slate-800 space-y-3">
<div className="flex items-center justify-between px-2">
<span className="text-xs text-slate-600 dark:text-slate-400 font-medium">Modo</span>
<ThemeToggle variant="pill" size="sm" />
</div>
<Link to="/">
<Button variant="outline" size="sm" fullWidth leftIcon={<FiArrowLeft />}>
Volver al inicio
</Button>
</Link>
</div>
</aside>
{/* 2. Main Content Area */}
<div className="flex-1 flex flex-col min-w-0">
{/* KPI Platform Stat Cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-5">
{/* Top Header Bar */}
<header className="sticky top-0 z-30 h-16 bg-white/90 dark:bg-slate-900/90 border-b border-slate-200 dark:border-slate-800 backdrop-blur-xl px-4 sm:px-8 flex items-center justify-between gap-4">
<div className="flex items-center gap-3">
<button
onClick={() => setSidebarOpen(true)}
className="md:hidden text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white p-2"
>
<FiMenu className="text-xl" />
</button>
<h1 className="text-base sm:text-lg font-bold text-slate-900 dark:text-white tracking-tight">
{activeTab === 'overview' && 'Panel General de Administración'}
{activeTab === 'courses' && 'Catálogo & Moderación de Cursos'}
{activeTab === 'users' && 'Directorio de Usuarios & Permisos'}
{activeTab === 'finance' && 'Finanzas, Comisiones & Repartición'}
{activeTab === 'settings' && 'Ajustes del Sistema RyCourses'}
</h1>
</div>
<div className="flex items-center gap-3">
<Badge variant="success" size="md">
<span className="w-2 h-2 rounded-full bg-emerald-400 animate-pulse mr-1" />
Servidor Producción
</Badge>
</div>
</header>
{/* Dynamic Content Views */}
<main className="p-4 sm:p-8 space-y-8 flex-1">
<div className="p-5 rounded-2xl bg-slate-900/80 border border-slate-800 space-y-2 shadow-xl">
<div className="flex items-center justify-between">
<span className="text-xs text-slate-400 font-medium">Ingresos Totales Plataforma</span>
<div className="w-9 h-9 rounded-xl bg-emerald-500/10 text-emerald-400 flex items-center justify-center text-lg">
<FiDollarSign />
</div>
</div>
<div className="text-2xl font-black text-white font-mono">$184,920.00</div>
<div className="text-[11px] text-emerald-400 font-semibold flex items-center gap-1">
<FiTrendingUp /> +24.8% en ventas este mes
</div>
</div>
{/* TAB 0: OVERVIEW */}
{activeTab === 'overview' && (
<div className="space-y-8">
{/* KPI Cards Grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-5">
<Card variant="elevated" padding="md" className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs text-slate-500 dark:text-slate-400 font-medium">Ventas Brutas Totales</span>
<div className="w-9 h-9 rounded-xl bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 flex items-center justify-center text-lg">
<FiDollarSign />
</div>
</div>
<div className="text-2xl font-black text-slate-900 dark:text-white font-mono">${totalGrossRevenue.toLocaleString()}</div>
<div className="text-[11px] text-emerald-600 dark:text-emerald-400 font-semibold flex items-center gap-1">
<FiTrendingUp /> +24.8% en crecimiento
</div>
</Card>
<div className="p-5 rounded-2xl bg-slate-900/80 border border-slate-800 space-y-2 shadow-xl">
<div className="flex items-center justify-between">
<span className="text-xs text-slate-400 font-medium">Usuarios Registrados</span>
<div className="w-9 h-9 rounded-xl bg-cyan-500/10 text-cyan-400 flex items-center justify-center text-lg">
<FiUsers />
</div>
</div>
<div className="text-2xl font-black text-white font-mono">54,320</div>
<div className="text-[11px] text-slate-400">
<strong className="text-cyan-400">1,240</strong> Instructores activos
</div>
</div>
<Card variant="elevated" padding="md" className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs text-slate-500 dark:text-slate-400 font-medium">Usuarios Registrados</span>
<div className="w-9 h-9 rounded-xl bg-cyan-500/10 text-cyan-600 dark:text-cyan-400 flex items-center justify-center text-lg">
<FiUsers />
</div>
</div>
<div className="text-2xl font-black text-slate-900 dark:text-white font-mono">54,320</div>
<div className="text-[11px] text-slate-500 dark:text-slate-400">
<strong className="text-cyan-600 dark:text-cyan-400">1,240</strong> Instructores activos
</div>
</Card>
<div className="p-5 rounded-2xl bg-slate-900/80 border border-slate-800 space-y-2 shadow-xl">
<div className="flex items-center justify-between">
<span className="text-xs text-slate-400 font-medium">Cursos en Catálogo</span>
<div className="w-9 h-9 rounded-xl bg-violet-500/10 text-violet-400 flex items-center justify-center text-lg">
<FiBookOpen />
</div>
</div>
<div className="text-2xl font-black text-white font-mono">{courses.length} Publicados</div>
<div className="text-[11px] text-amber-400 font-medium">2 pendientes de aprobación</div>
</div>
<Card variant="elevated" padding="md" className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs text-slate-500 dark:text-slate-400 font-medium">Cursos Publicados</span>
<div className="w-9 h-9 rounded-xl bg-violet-500/10 text-violet-600 dark:text-violet-400 flex items-center justify-center text-lg">
<FiBookOpen />
</div>
</div>
<div className="text-2xl font-black text-slate-900 dark:text-white font-mono">{courses.length} Cursos</div>
<div className="text-[11px] text-amber-600 dark:text-amber-400 font-medium">2 pendientes de revisión</div>
</Card>
<div className="p-5 rounded-2xl bg-slate-900/80 border border-slate-800 space-y-2 shadow-xl">
<div className="flex items-center justify-between">
<span className="text-xs text-slate-400 font-medium">Comisión Neta (20%)</span>
<div className="w-9 h-9 rounded-xl bg-amber-500/10 text-amber-400 flex items-center justify-center text-lg">
<FiPieChart />
</div>
</div>
<div className="text-2xl font-black text-white font-mono">$36,984.00</div>
<div className="text-[11px] text-slate-400">Generado para la plataforma</div>
</div>
</div>
{/* Tab Navigation */}
<div className="flex border-b border-slate-800 space-x-4 text-xs font-bold">
<button
onClick={() => setActiveTab('courses')}
className={`pb-3.5 px-2 flex items-center gap-2 transition-colors border-b-2 ${
activeTab === 'courses'
? 'text-cyan-400 border-cyan-400'
: 'text-slate-400 border-transparent hover:text-white'
}`}
>
<FiBookOpen /> Gestor de Cursos ({courses.length})
</button>
<button
onClick={() => setActiveTab('users')}
className={`pb-3.5 px-2 flex items-center gap-2 transition-colors border-b-2 ${
activeTab === 'users'
? 'text-cyan-400 border-cyan-400'
: 'text-slate-400 border-transparent hover:text-white'
}`}
>
<FiUsers /> Usuarios e Instructores ({users.length})
</button>
<button
onClick={() => setActiveTab('finance')}
className={`pb-3.5 px-2 flex items-center gap-2 transition-colors border-b-2 ${
activeTab === 'finance'
? 'text-cyan-400 border-cyan-400'
: 'text-slate-400 border-transparent hover:text-white'
}`}
>
<FiDollarSign /> Finanzas & Pagos
</button>
<button
onClick={() => setActiveTab('settings')}
className={`pb-3.5 px-2 flex items-center gap-2 transition-colors border-b-2 ${
activeTab === 'settings'
? 'text-cyan-400 border-cyan-400'
: 'text-slate-400 border-transparent hover:text-white'
}`}
>
<FiSettings /> Ajustes Globales
</button>
</div>
{/* TAB 1: Gestor de Cursos */}
{activeTab === 'courses' && (
<div className="space-y-6">
{/* Filter and Search Bar */}
<div className="flex flex-col sm:flex-row items-center justify-between gap-4 bg-slate-900 p-4 rounded-2xl border border-slate-800">
<div className="relative flex-1 w-full max-w-md">
<FiSearch className="absolute left-3.5 top-3 text-slate-500" />
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Buscar curso por título o instructor..."
className="w-full pl-10 pr-4 py-2 bg-slate-950 border border-slate-800 rounded-xl text-xs text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-cyan-500"
/>
<Card variant="elevated" padding="md" className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs text-slate-500 dark:text-slate-400 font-medium">Comisión Neta Plataforma ({commissionRate}%)</span>
<div className="w-9 h-9 rounded-xl bg-amber-500/10 text-amber-600 dark:text-amber-400 flex items-center justify-center text-lg">
<FiPieChart />
</div>
</div>
<div className="text-2xl font-black text-slate-900 dark:text-white font-mono">${netCommission.toLocaleString()}</div>
<div className="text-[11px] text-slate-500 dark:text-slate-400">Ingreso neto RyCourses</div>
</Card>
</div>
<div className="flex items-center gap-2 w-full sm:w-auto">
<FiFilter className="text-slate-400 text-sm" />
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as any)}
className="bg-slate-950 border border-slate-800 rounded-xl px-3 py-2 text-xs text-slate-200 focus:outline-none focus:ring-2 focus:ring-cyan-500"
>
<option value="all">Todos los estados</option>
<option value="published">Publicados</option>
<option value="pending">Pendientes de revisión</option>
</select>
</div>
</div>
{/* Quick Summary Grid */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Recent Courses Moderation Preview */}
<Card variant="elevated" padding="md" className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="font-bold text-sm text-slate-900 dark:text-white">Últimos Cursos Registrados</h3>
<Button variant="ghost" size="xs" onClick={() => setActiveTab('courses')}>
Ver catálogo completo
</Button>
</div>
{/* Courses Management Table */}
<div className="bg-slate-900 border border-slate-800 rounded-2xl overflow-hidden shadow-xl">
<div className="overflow-x-auto">
<table className="w-full text-left text-xs text-slate-300">
<thead className="bg-slate-950 text-slate-400 uppercase tracking-wider font-semibold border-b border-slate-800">
<div className="space-y-3">
{courses.slice(0, 4).map((c) => (
<div key={c.id} className="flex items-center justify-between p-3 rounded-xl bg-slate-100/80 dark:bg-slate-950/60 border border-slate-200 dark:border-slate-800">
<div className="flex items-center gap-3 min-w-0">
<img src={c.thumbnail} alt={c.title} className="w-10 h-8 rounded object-cover border border-slate-300 dark:border-slate-700 shrink-0" />
<div className="min-w-0">
<h4 className="font-bold text-xs text-slate-900 dark:text-white truncate">{c.title}</h4>
<span className="text-[10px] text-slate-500 dark:text-slate-400">{c.instructor.name}</span>
</div>
</div>
<Badge variant="primary" size="sm">${c.price}</Badge>
</div>
))}
</div>
</Card>
{/* System Status & Server Logs */}
<Card variant="elevated" padding="md" className="space-y-4">
<h3 className="font-bold text-sm text-slate-900 dark:text-white">Estado de Infraestructura & Logs</h3>
<div className="space-y-3 text-xs">
<div className="flex justify-between p-3 bg-slate-100/80 dark:bg-slate-950 rounded-xl border border-slate-200 dark:border-slate-800">
<span className="text-slate-600 dark:text-slate-400">Base de Datos PostgreSQL (Supabase):</span>
<span className="font-mono text-emerald-600 dark:text-emerald-400 font-bold">Conectado (4ms)</span>
</div>
<div className="flex justify-between p-3 bg-slate-100/80 dark:bg-slate-950 rounded-xl border border-slate-200 dark:border-slate-800">
<span className="text-slate-600 dark:text-slate-400">Pasarela Stripe / PayPal Webhooks:</span>
<span className="font-mono text-emerald-600 dark:text-emerald-400 font-bold">Operativo 100%</span>
</div>
<div className="flex justify-between p-3 bg-slate-100/80 dark:bg-slate-950 rounded-xl border border-slate-200 dark:border-slate-800">
<span className="text-slate-600 dark:text-slate-400">Almacenamiento de Video CDN:</span>
<span className="font-mono text-cyan-600 dark:text-cyan-400 font-bold">Cloudflare Stream Active</span>
</div>
</div>
</Card>
</div>
</div>
)}
{/* TAB 1: COURSES MANAGEMENT */}
{activeTab === 'courses' && (
<div className="space-y-6">
{/* Filter and Search Bar */}
<div className="flex flex-col sm:flex-row items-center justify-between gap-4 bg-white dark:bg-slate-900 p-4 rounded-2xl border border-slate-200 dark:border-slate-800 shadow-sm">
<div className="w-full sm:max-w-md">
<Input
placeholder="Buscar por título de curso o nombre del instructor..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
leftIcon={<FiSearch className="text-slate-400" />}
/>
</div>
<div className="flex items-center gap-3 w-full sm:w-auto">
<Select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as any)}
options={[
{ value: 'all', label: 'Todos los estados' },
{ value: 'published', label: 'Publicados' },
{ value: 'pending', label: 'En Revisión' },
]}
/>
</div>
</div>
{/* Courses Management Table */}
<Card variant="elevated" padding="none" className="overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-left text-xs text-slate-700 dark:text-slate-300">
<thead className="bg-slate-100 dark:bg-slate-950 text-slate-600 dark:text-slate-400 uppercase tracking-wider font-semibold border-b border-slate-200 dark:border-slate-800">
<tr>
<th className="p-4">Curso</th>
<th className="p-4">Categoría</th>
<th className="p-4">Instructor</th>
<th className="p-4">Precio</th>
<th className="p-4">Alumnos</th>
<th className="p-4">Estado</th>
<th className="p-4 text-right">Acciones Admin</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-200 dark:divide-slate-800/80">
{filteredCourses.map((c) => {
const status = getStatus(c.id);
return (
<tr key={c.id} className="hover:bg-slate-100/60 dark:hover:bg-slate-800/40 transition">
<td className="p-4">
<div className="flex items-center gap-3">
<img
src={c.thumbnail}
alt={c.title}
className="w-12 h-8 rounded object-cover border border-slate-300 dark:border-slate-700 shrink-0"
/>
<div>
<h4 className="font-bold text-slate-900 dark:text-white max-w-xs line-clamp-1">{c.title}</h4>
<span className="text-[10px] text-slate-500 dark:text-slate-500">ID: {c.id}</span>
</div>
</div>
</td>
<td className="p-4">
<Badge variant="primary" size="sm">{c.category}</Badge>
</td>
<td className="p-4">
<div className="flex items-center gap-2">
<img
src={c.instructor.avatar}
alt={c.instructor.name}
className="w-6 h-6 rounded-full object-cover"
/>
<span className="font-medium text-slate-800 dark:text-slate-200">{c.instructor.name}</span>
</div>
</td>
<td className="p-4 font-mono font-bold text-slate-900 dark:text-white">
${c.price.toFixed(2)}
</td>
<td className="p-4 font-mono text-slate-700 dark:text-slate-300">
{c.studentsCount.toLocaleString()}
</td>
<td className="p-4">
{status === 'published' ? (
<Badge variant="success" size="sm"><FiCheckCircle /> Publicado</Badge>
) : (
<Badge variant="warning" size="sm"><FiActivity /> En Revisión</Badge>
)}
</td>
<td className="p-4 text-right">
<div className="flex items-center justify-end gap-2">
<Button
variant={status === 'published' ? 'outline' : 'success'}
size="xs"
onClick={() => toggleCourseStatus(c.id, c.title)}
>
{status === 'published' ? 'Pausar' : 'Aprobar'}
</Button>
<Button
variant="danger"
size="xs"
onClick={() => onDeleteCourse(c.id)}
>
<FiTrash2 />
</Button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</Card>
</div>
)}
{/* TAB 2: USERS MANAGEMENT */}
{activeTab === 'users' && (
<div className="space-y-6">
<div className="flex flex-col sm:flex-row items-center justify-between gap-4 bg-white dark:bg-slate-900 p-4 rounded-2xl border border-slate-200 dark:border-slate-800 shadow-sm">
<div className="w-full sm:max-w-md">
<Input
placeholder="Buscar usuario por nombre, email o rol..."
value={userSearchTerm}
onChange={(e) => setUserSearchTerm(e.target.value)}
leftIcon={<FiSearch className="text-slate-400" />}
/>
</div>
<Badge variant="secondary" size="md">
Total Registrados: {users.length}
</Badge>
</div>
<Card variant="elevated" padding="none" className="overflow-hidden">
<table className="w-full text-left text-xs text-slate-700 dark:text-slate-300">
<thead className="bg-slate-100 dark:bg-slate-950 text-slate-600 dark:text-slate-400 uppercase tracking-wider font-semibold border-b border-slate-200 dark:border-slate-800">
<tr>
<th className="p-4">Curso</th>
<th className="p-4">Categoría</th>
<th className="p-4">Instructor</th>
<th className="p-4">Precio</th>
<th className="p-4">Estudiantes</th>
<th className="p-4">Usuario</th>
<th className="p-4">Correo</th>
<th className="p-4">Rol</th>
<th className="p-4">Cursos</th>
<th className="p-4">Registro</th>
<th className="p-4">Estado</th>
<th className="p-4 text-right">Acciones Admin</th>
<th className="p-4 text-right">Acción</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/80">
{filteredCourses.map((c) => {
const status = getStatus(c.id);
return (
<tr key={c.id} className="hover:bg-slate-800/40 transition">
<td className="p-4">
<div className="flex items-center gap-3">
<img
src={c.thumbnail}
alt={c.title}
className="w-12 h-8 rounded object-cover border border-slate-700 shrink-0"
/>
<div>
<h4 className="font-bold text-white max-w-xs line-clamp-1">{c.title}</h4>
<span className="text-[10px] text-slate-500">ID: {c.id}</span>
</div>
</div>
</td>
<td className="p-4">
<span className="px-2 py-0.5 rounded bg-slate-800 text-cyan-400 font-medium">
{c.category}
</span>
</td>
<td className="p-4">
<div className="flex items-center gap-2">
<img
src={c.instructor.avatar}
alt={c.instructor.name}
className="w-6 h-6 rounded-full object-cover"
/>
<span className="font-medium text-slate-200">{c.instructor.name}</span>
</div>
</td>
<td className="p-4 font-mono font-bold text-white">
${c.price.toFixed(2)}
</td>
<td className="p-4 font-mono text-slate-300">
{c.studentsCount.toLocaleString()}
</td>
<td className="p-4">
{status === 'published' ? (
<span className="px-2.5 py-1 rounded-full bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 text-[10px] font-bold inline-flex items-center gap-1">
<FiCheckCircle /> Publicado
</span>
) : (
<span className="px-2.5 py-1 rounded-full bg-amber-500/10 text-amber-400 border border-amber-500/20 text-[10px] font-bold inline-flex items-center gap-1">
<FiActivity /> En Revisión
</span>
)}
</td>
<td className="p-4 text-right">
<div className="flex items-center justify-end gap-2">
<button
onClick={() => toggleCourseStatus(c.id, c.title)}
className={`px-2.5 py-1.5 rounded-lg text-[11px] font-bold border transition ${
status === 'published'
? 'bg-slate-800 hover:bg-slate-700 text-amber-400 border-amber-500/30'
: 'bg-emerald-600 hover:bg-emerald-500 text-white border-emerald-400'
}`}
>
{status === 'published' ? 'Pausar' : 'Aprobar'}
</button>
<button
onClick={() => {
onDeleteCourse(c.id);
}}
className="p-2 rounded-lg bg-slate-800 hover:bg-rose-950 text-slate-400 hover:text-rose-400 border border-slate-700 transition"
title="Eliminar curso de la plataforma"
>
<FiTrash2 />
</button>
</div>
</td>
</tr>
);
})}
<tbody className="divide-y divide-slate-200 dark:divide-slate-800/80">
{filteredUsers.map((u) => (
<tr key={u.id} className="hover:bg-slate-100/60 dark:hover:bg-slate-800/40 transition">
<td className="p-4 font-bold text-slate-900 dark:text-white">{u.name}</td>
<td className="p-4 text-slate-500 dark:text-slate-400 font-mono">{u.email}</td>
<td className="p-4">
<Badge
variant={u.role === 'Administrador' ? 'danger' : u.role === 'Instructor' ? 'secondary' : 'primary'}
size="sm"
>
{u.role}
</Badge>
</td>
<td className="p-4 font-mono">{u.courses}</td>
<td className="p-4 text-slate-500 dark:text-slate-400">{u.joined}</td>
<td className="p-4">
<Badge variant={u.status === 'Activo' ? 'success' : 'danger'} size="sm">
{u.status}
</Badge>
</td>
<td className="p-4 text-right">
<Button
variant={u.status === 'Activo' ? 'outline' : 'success'}
size="xs"
onClick={() => toggleUserStatus(u.id, u.name)}
>
{u.status === 'Activo' ? 'Suspender' : 'Activar'}
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
</div>
)}
</div>
)}
{/* TAB 3: FINANCIALS */}
{activeTab === 'finance' && (
<div className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<Card variant="elevated" padding="lg" className="space-y-4">
<h3 className="font-bold text-slate-900 dark:text-white text-base">Desglose de Repartición Financiera</h3>
<div className="space-y-3 text-xs">
<div className="flex justify-between p-3.5 bg-slate-100/80 dark:bg-slate-950 rounded-xl border border-slate-200 dark:border-slate-800">
<span className="text-slate-600 dark:text-slate-400">Ventas Brutas Acumuladas:</span>
<span className="font-mono font-bold text-slate-900 dark:text-white">${totalGrossRevenue.toLocaleString()}</span>
</div>
<div className="flex justify-between p-3.5 bg-slate-100/80 dark:bg-slate-950 rounded-xl border border-slate-200 dark:border-slate-800">
<span className="text-slate-600 dark:text-slate-400">Pago a Instructores ({100 - commissionRate}%):</span>
<span className="font-mono font-bold text-emerald-600 dark:text-emerald-400">${(totalGrossRevenue * ((100 - commissionRate) / 100)).toLocaleString()}</span>
</div>
<div className="flex justify-between p-3.5 bg-slate-100/80 dark:bg-slate-950 rounded-xl border border-slate-200 dark:border-slate-800">
<span className="text-slate-600 dark:text-slate-400">Comisión Neta RyCourses ({commissionRate}%):</span>
<span className="font-mono font-bold text-cyan-600 dark:text-cyan-400">${netCommission.toLocaleString()}</span>
</div>
</div>
</Card>
<Card variant="elevated" padding="lg" className="space-y-4">
<h3 className="font-bold text-slate-900 dark:text-white text-base">Métodos de Pago & Webhooks</h3>
<p className="text-xs text-slate-600 dark:text-slate-400 leading-relaxed">
Integración automatizada con Stripe Connect y PayPal Payouts API para la liquidación semanal a instructores.
</p>
<div className="p-4 rounded-xl bg-emerald-500/10 border border-emerald-500/20 text-emerald-600 dark:text-emerald-400 text-xs font-semibold flex items-center gap-2">
<FiCheckCircle /> Transacciones de pago en vivo cifradas a 256-bit SSL.
</div>
</Card>
</div>
{/* TAB 2: Usuarios & Instructores */}
{activeTab === 'users' && (
<div className="bg-slate-900 border border-slate-800 rounded-2xl overflow-hidden shadow-xl">
<div className="p-4 border-b border-slate-800 flex items-center justify-between">
<h3 className="font-bold text-white text-sm">Directorio General de Cuentas</h3>
<span className="text-xs text-slate-400">Total: {users.length} registrados</span>
</div>
)}
<table className="w-full text-left text-xs text-slate-300">
<thead className="bg-slate-950 text-slate-400 uppercase tracking-wider font-semibold border-b border-slate-800">
<tr>
<th className="p-4">Usuario</th>
<th className="p-4">Correo</th>
<th className="p-4">Rol</th>
<th className="p-4">Cursos Asoc.</th>
<th className="p-4">Registro</th>
<th className="p-4">Estado</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/80">
{users.map((u) => (
<tr key={u.id} className="hover:bg-slate-800/40 transition">
<td className="p-4 font-bold text-white">{u.name}</td>
<td className="p-4 text-slate-400 font-mono">{u.email}</td>
<td className="p-4">
<span className={`px-2.5 py-1 rounded-md text-[10px] font-bold ${
u.role === 'Administrador'
? 'bg-rose-500/20 text-rose-300 border border-rose-500/30'
: u.role === 'Instructor'
? 'bg-violet-500/20 text-violet-300 border border-violet-500/30'
: 'bg-blue-500/20 text-blue-300 border border-blue-500/30'
}`}>
{u.role}
</span>
</td>
<td className="p-4 font-mono">{u.courses}</td>
<td className="p-4 text-slate-400">{u.joined}</td>
<td className="p-4">
<span className="text-emerald-400 font-semibold">{u.status}</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* TAB 4: GLOBAL SETTINGS */}
{activeTab === 'settings' && (
<Card variant="elevated" padding="lg" className="max-w-xl space-y-6">
<h3 className="font-bold text-slate-900 dark:text-white text-base">Configuración de Plataforma</h3>
<div className="space-y-4">
<Input
label="Nombre de la Plataforma"
value={siteName}
onChange={(e) => setSiteName(e.target.value)}
/>
{/* TAB 3: Finanzas */}
{activeTab === 'finance' && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="bg-slate-900 border border-slate-800 rounded-2xl p-6 space-y-4">
<h3 className="font-bold text-white text-base">Desglose de Repartición Financiera</h3>
<div className="space-y-3 text-xs">
<div className="flex justify-between p-3 bg-slate-950 rounded-xl border border-slate-800">
<span className="text-slate-400">Ventas Brutas Acumuladas:</span>
<span className="font-mono font-bold text-white">$184,920.00</span>
</div>
<div className="flex justify-between p-3 bg-slate-950 rounded-xl border border-slate-800">
<span className="text-slate-400">Pago a Instructores (80%):</span>
<span className="font-mono font-bold text-emerald-400">$147,936.00</span>
</div>
<div className="flex justify-between p-3 bg-slate-950 rounded-xl border border-slate-800">
<span className="text-slate-400">Comisión Neta Plataforma (20%):</span>
<span className="font-mono font-bold text-cyan-400">$36,984.00</span>
</div>
</div>
</div>
<Input
label="Porcentaje de Comisión Base Plataforma (%)"
type="number"
value={commissionRate}
onChange={(e) => setCommissionRate(Number(e.target.value))}
/>
<div className="bg-slate-900 border border-slate-800 rounded-2xl p-6 space-y-4">
<h3 className="font-bold text-white text-base">Métodos de Pago Integrados</h3>
<p className="text-xs text-slate-400">
Stripe & PayPal operando de forma continua para procesamiento automático de cobros y giros a instructores.
</p>
<div className="p-4 rounded-xl bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 text-xs font-semibold flex items-center gap-2">
<FiCheckCircle /> Pagos automáticos semanales habilitados.
</div>
</div>
</div>
)}
{/* TAB 4: Ajustes Globales */}
{activeTab === 'settings' && (
<div className="bg-slate-900 border border-slate-800 rounded-2xl p-6 space-y-6 max-w-2xl">
<h3 className="font-bold text-white text-base">Configuración de Plataforma</h3>
<div className="space-y-4 text-xs">
<div>
<label className="block text-slate-300 font-semibold mb-1">Nombre de la Aplicación</label>
<input type="text" defaultValue="RyCourses" className="w-full px-4 py-2.5 bg-slate-950 border border-slate-800 rounded-xl text-white font-bold" />
</div>
<div>
<label className="block text-slate-300 font-semibold mb-1">Porcentaje de Comisión Base (%)</label>
<input type="number" defaultValue={20} className="w-full px-4 py-2.5 bg-slate-950 border border-slate-800 rounded-xl text-white font-mono" />
</div>
<div className="pt-2">
<button
type="button"
onClick={() => onShowToast('Ajustes guardados correctamente.')}
className="px-6 py-3 bg-gradient-to-r from-blue-600 to-cyan-500 text-white font-bold rounded-xl text-xs shadow-lg shadow-cyan-500/20"
<Button
variant="gradient"
fullWidth
size="md"
onClick={() => onShowToast('Ajustes globales guardados con éxito')}
>
Guardar Cambios
</button>
</Button>
</div>
</div>
</div>
)}
</Card>
)}
</main>
</div>
</div>
);
};
+274 -32
View File
@@ -1,7 +1,19 @@
import React, { useState } from 'react';
import React, { useState, useRef } from 'react';
import { useNavigate } from 'react-router';
import type { Course } from '../types/course';
import { FiArrowLeft, FiPlus, FiTrash2, FiUploadCloud } from 'react-icons/fi';
import {
FiArrowLeft,
FiPlus,
FiTrash2,
FiUploadCloud,
FiVideo,
FiCheckCircle,
FiPlay,
FiX,
FiFilm,
FiLink,
FiImage
} from 'react-icons/fi';
interface CreateCourseViewProps {
onCreateCourse: (course: Course) => void;
@@ -13,6 +25,8 @@ export const CreateCourseView: React.FC<CreateCourseViewProps> = ({
onShowToast,
}) => {
const navigate = useNavigate();
const fileInputRef = useRef<HTMLInputElement>(null);
const imageInputRef = useRef<HTMLInputElement>(null);
const [title, setTitle] = useState('');
const [subtitle, setSubtitle] = useState('');
@@ -20,18 +34,76 @@ export const CreateCourseView: React.FC<CreateCourseViewProps> = ({
const [level, setLevel] = useState<'Principiante' | 'Intermedio' | 'Avanzado' | 'Todos los niveles'>('Principiante');
const [price, setPrice] = useState(29.99);
const [originalPrice, setOriginalPrice] = useState(89.99);
// Thumbnail Image Drag & Drop State
const [thumbnailFile, setThumbnailFile] = useState<File | null>(null);
const [thumbnail, setThumbnail] = useState('https://images.unsplash.com/photo-1517694712202-14dd9538aa97?w=800&auto=format&fit=crop&q=80');
const [modules, setModules] = useState<{ title: string; lessonsCount: number }[]>([
{ title: 'Módulo 1: Introducción y Fundamentos', lessonsCount: 4 },
{ title: 'Módulo 2: Proyecto Práctico Principal', lessonsCount: 6 },
// Video Upload States
const [videoFile, setVideoFile] = useState<File | null>(null);
const [videoPreviewUrl, setVideoPreviewUrl] = useState<string>('');
const [videoEmbedUrl, setVideoEmbedUrl] = useState<string>('');
const [isUploading, setIsUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
const [modules, setModules] = useState<{ title: string; lessonsCount: number; videoName?: string }[]>([
{ title: 'Módulo 1: Introducción y Fundamentos', lessonsCount: 4, videoName: '01_introduccion.mp4' },
{ title: 'Módulo 2: Proyecto Práctico Principal', lessonsCount: 6, videoName: '02_proyecto_demo.mp4' },
]);
const [newModuleTitle, setNewModuleTitle] = useState('');
const handleImageFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setThumbnailFile(file);
const objectUrl = URL.createObjectURL(file);
setThumbnail(objectUrl);
onShowToast(`Imagen de portada "${file.name}" cargada correctamente`);
};
const handleRemoveThumbnail = () => {
setThumbnailFile(null);
setThumbnail('https://images.unsplash.com/photo-1517694712202-14dd9538aa97?w=800&auto=format&fit=crop&q=80');
if (imageInputRef.current) imageInputRef.current.value = '';
};
// Handle Video File Upload Simulation
const handleVideoFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setVideoFile(file);
setIsUploading(true);
setUploadProgress(15);
// Simulate progress bar upload to 100%
const interval = setInterval(() => {
setUploadProgress((prev) => {
if (prev >= 100) {
clearInterval(interval);
setIsUploading(false);
const previewUrl = URL.createObjectURL(file);
setVideoPreviewUrl(previewUrl);
onShowToast(`¡Video "${file.name}" cargado exitosamente!`);
return 100;
}
return prev + 25;
});
}, 200);
};
const handleRemoveVideo = () => {
setVideoFile(null);
setVideoPreviewUrl('');
setUploadProgress(0);
if (fileInputRef.current) fileInputRef.current.value = '';
};
const handleAddModule = () => {
if (newModuleTitle.trim()) {
setModules([...modules, { title: newModuleTitle, lessonsCount: 3 }]);
setModules([...modules, { title: newModuleTitle, lessonsCount: 3, videoName: 'clase_muestra.mp4' }]);
setNewModuleTitle('');
}
};
@@ -63,7 +135,7 @@ export const CreateCourseView: React.FC<CreateCourseViewProps> = ({
id: 'inst-1',
name: 'Tu Nombre (Instructor)',
role: 'Instructor Experto',
avatar: 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=100&auto=format&fit=crop&q=80',
avatar: 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=800&auto=format&fit=crop&q=80',
rating: 5.0,
totalStudents: 1,
totalCourses: 1,
@@ -92,28 +164,28 @@ export const CreateCourseView: React.FC<CreateCourseViewProps> = ({
};
onCreateCourse(newCourse);
onShowToast('¡Tu curso ha sido publicado exitosamente en el Marketplace!');
navigate('/seller');
onShowToast('¡Tu curso, portada y videos han sido publicados exitosamente!');
navigate('/teachers');
};
return (
<div className="min-h-screen bg-slate-950 py-10">
<div className="min-h-screen transition-colors duration-300 bg-slate-950 text-slate-100 py-10 selection:bg-cyan-500 font-sans">
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 space-y-8">
{/* Header */}
<div className="flex items-center justify-between border-b border-slate-800 pb-5">
<div className="space-y-1">
<button
onClick={() => navigate('/seller')}
onClick={() => navigate('/teachers')}
className="inline-flex items-center gap-2 text-xs font-semibold text-slate-400 hover:text-cyan-400 mb-2 transition"
>
<FiArrowLeft /> Regresar a Panel de Instructor
</button>
<h1 className="text-2xl sm:text-3xl font-extrabold text-white">
Publicar Nuevo Curso
Publicar Nuevo Curso & Cargar Contenido
</h1>
<p className="text-xs text-slate-400">
Completa los detalles de tu curso para lanzarlo a la comunidad de estudiantes
Sube la portada y los videos de tus clases para lanzar tu contenido a la plataforma.
</p>
</div>
</div>
@@ -193,10 +265,117 @@ export const CreateCourseView: React.FC<CreateCourseViewProps> = ({
</div>
</div>
{/* Card 2: Precio e Imagen */}
{/* Card 2: Subir Video de Muestra / Lección Promocional */}
<div className="bg-slate-900 border border-slate-800 rounded-3xl p-6 sm:p-8 space-y-6 shadow-xl">
<h3 className="text-sm font-bold text-cyan-400 uppercase tracking-wider">
2. Precio e Imagen de Portada
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-bold text-cyan-400 uppercase tracking-wider flex items-center gap-2">
<FiVideo /> 2. Subir Video Promocional / Clase de Muestra
</h3>
<p className="text-xs text-slate-400 mt-1">
Sube el archivo de video (.mp4, .mov, .webm) o pega la URL directa de tu lección para la vista previa del curso.
</p>
</div>
<span className="px-2.5 py-1 rounded bg-violet-500/10 text-violet-300 border border-violet-500/20 text-[11px] font-bold">
HD 1080p
</span>
</div>
{/* Video Upload Dropzone */}
<div className="space-y-4">
<input
ref={fileInputRef}
type="file"
accept="video/*"
onChange={handleVideoFileChange}
className="hidden"
id="video-upload-input"
/>
{!videoFile && !videoPreviewUrl ? (
<label
htmlFor="video-upload-input"
className="flex flex-col items-center justify-center p-8 border-2 border-dashed border-slate-700 hover:border-cyan-500/60 rounded-2xl bg-slate-950/60 hover:bg-slate-950 transition cursor-pointer group text-center space-y-3"
>
<div className="w-14 h-14 rounded-2xl bg-cyan-500/10 text-cyan-400 flex items-center justify-center text-2xl group-hover:scale-110 transition-transform">
<FiUploadCloud />
</div>
<div>
<p className="text-sm font-bold text-white">Haz clic o arrastra tu archivo de video aquí</p>
<p className="text-xs text-slate-400 mt-0.5">Soporta MP4, MOV, WEBM hasta 2GB por clase</p>
</div>
<span className="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-cyan-400 font-semibold rounded-xl text-xs transition border border-slate-700">
Seleccionar Archivo de Video
</span>
</label>
) : (
<div className="p-4 rounded-2xl bg-slate-950 border border-slate-800 space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-emerald-500/10 text-emerald-400 flex items-center justify-center text-xl">
<FiFilm />
</div>
<div>
<p className="text-xs font-bold text-white truncate max-w-xs">{videoFile?.name || 'video_promocional.mp4'}</p>
<p className="text-[11px] text-emerald-400 font-semibold flex items-center gap-1">
<FiCheckCircle /> Video procesado correctamente ({videoFile ? `${(videoFile.size / (1024 * 1024)).toFixed(1)} MB` : '142 MB'})
</p>
</div>
</div>
<button
type="button"
onClick={handleRemoveVideo}
className="p-2 rounded-lg bg-slate-800 hover:bg-rose-950 text-slate-400 hover:text-rose-400 transition"
title="Eliminar video"
>
<FiX />
</button>
</div>
{isUploading && (
<div className="space-y-1">
<div className="flex justify-between text-[11px] text-slate-400 font-mono">
<span>Cargando video al servidor de la plataforma...</span>
<span>{uploadProgress}%</span>
</div>
<div className="w-full bg-slate-800 h-2 rounded-full overflow-hidden">
<div className="bg-cyan-500 h-full transition-all duration-300" style={{ width: `${uploadProgress}%` }} />
</div>
</div>
)}
{videoPreviewUrl && (
<div className="aspect-video w-full rounded-xl overflow-hidden bg-black border border-slate-800 relative">
<video
src={videoPreviewUrl}
controls
className="w-full h-full object-contain"
/>
</div>
)}
</div>
)}
<div className="pt-2">
<label className="block text-xs font-semibold text-slate-300 mb-1 flex items-center gap-1.5">
<FiLink className="text-slate-400" /> O pega el enlace directo de tu video (Vimeo, YouTube, Cloudflare)
</label>
<input
type="url"
value={videoEmbedUrl}
onChange={(e) => setVideoEmbedUrl(e.target.value)}
placeholder="https://vimeo.com/123456789 o https://youtube.com/watch?v=..."
className="w-full px-4 py-2.5 bg-slate-950 border border-slate-700/80 rounded-xl text-xs text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-cyan-500 font-mono"
/>
</div>
</div>
</div>
{/* Card 3: Precio e Imagen de Portada (Drag & Drop + URL) */}
<div className="bg-slate-900 border border-slate-800 rounded-3xl p-6 sm:p-8 space-y-6 shadow-xl">
<h3 className="text-sm font-bold text-cyan-400 uppercase tracking-wider flex items-center gap-2">
<FiImage /> 3. Precio e Imagen de Portada
</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
@@ -216,7 +395,7 @@ export const CreateCourseView: React.FC<CreateCourseViewProps> = ({
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">
Precio Original Táchado ($ USD)
Precio Original Tachado ($ USD)
</label>
<input
type="number"
@@ -229,34 +408,97 @@ export const CreateCourseView: React.FC<CreateCourseViewProps> = ({
</div>
</div>
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">
URL de la Imagen de Portada
{/* Thumbnail Drag and Drop Section */}
<div className="space-y-4 pt-2">
<label className="block text-xs font-semibold text-slate-300">
Imagen de Portada (Thumbnail del Curso)
</label>
<div className="flex gap-2">
<input
ref={imageInputRef}
type="file"
accept="image/*"
onChange={handleImageFileChange}
className="hidden"
id="create-view-image-upload"
/>
{!thumbnailFile ? (
<label
htmlFor="create-view-image-upload"
className="flex flex-col items-center justify-center p-8 border-2 border-dashed border-slate-700 hover:border-cyan-500/60 rounded-2xl bg-slate-950/60 hover:bg-slate-950 transition cursor-pointer group text-center space-y-3"
>
<div className="w-12 h-12 rounded-2xl bg-cyan-500/10 text-cyan-400 flex items-center justify-center text-xl group-hover:scale-110 transition-transform">
<FiUploadCloud />
</div>
<div>
<p className="text-xs font-bold text-white">Haz clic o arrastra tu imagen de portada aquí</p>
<p className="text-[11px] text-slate-400 mt-0.5">Soporta PNG, JPG, WEBP o SVG (Recomendado: 1280x720)</p>
</div>
<span className="px-3.5 py-1.5 bg-slate-800 hover:bg-slate-700 text-cyan-400 font-semibold rounded-xl text-xs transition border border-slate-700">
Seleccionar Imagen
</span>
</label>
) : (
<div className="p-3 rounded-xl bg-slate-950 border border-slate-800 flex items-center justify-between">
<div className="flex items-center gap-2.5">
<FiCheckCircle className="text-emerald-400 text-lg" />
<span className="text-xs font-bold text-white truncate max-w-xs">{thumbnailFile.name}</span>
</div>
<button
type="button"
onClick={handleRemoveThumbnail}
className="p-1.5 rounded-lg bg-slate-800 text-slate-400 hover:text-rose-400 transition"
>
<FiX />
</button>
</div>
)}
{/* Live Thumbnail Preview */}
{thumbnail && (
<div className="aspect-video max-w-sm rounded-xl overflow-hidden bg-slate-950 border border-slate-800 shadow-md">
<img src={thumbnail} alt="Vista previa de portada" className="w-full h-full object-cover" />
</div>
)}
<div>
<label className="block text-xs font-semibold text-slate-400 mb-1 flex items-center gap-1.5">
<FiLink /> O pega la URL directa de la imagen de portada
</label>
<input
type="url"
value={thumbnail}
onChange={(e) => setThumbnail(e.target.value)}
className="flex-1 px-4 py-3 bg-slate-950 border border-slate-700/80 rounded-xl text-xs text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-cyan-500"
onChange={(e) => {
setThumbnailFile(null);
setThumbnail(e.target.value);
}}
placeholder="https://images.unsplash.com/..."
className="w-full px-4 py-3 bg-slate-950 border border-slate-700/80 rounded-xl text-xs text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-cyan-500 font-mono"
/>
</div>
<div className="mt-3 aspect-video max-w-sm rounded-xl overflow-hidden bg-slate-950 border border-slate-800">
<img src={thumbnail} alt="Vista previa" className="w-full h-full object-cover" />
</div>
</div>
</div>
{/* Card 3: Estructura del Temario */}
{/* Card 4: Estructura del Temario & Asignación de Videos por Módulo */}
<div className="bg-slate-900 border border-slate-800 rounded-3xl p-6 sm:p-8 space-y-6 shadow-xl">
<h3 className="text-sm font-bold text-cyan-400 uppercase tracking-wider">
3. Módulos y Temario
4. Módulos y Videos de Clases
</h3>
<div className="space-y-3">
{modules.map((m, idx) => (
<div key={idx} className="flex items-center justify-between p-4 bg-slate-950 border border-slate-800 rounded-xl text-xs">
<span className="font-semibold text-white">{m.title}</span>
<div key={idx} className="p-4 bg-slate-950 border border-slate-800 rounded-xl text-xs flex items-center justify-between gap-3">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-cyan-500/10 text-cyan-400 flex items-center justify-center text-sm">
<FiPlay />
</div>
<div>
<span className="font-semibold text-white block">{m.title}</span>
<span className="text-[10px] text-slate-400">Video adjunto: {m.videoName || 'leccion.mp4'}</span>
</div>
</div>
<button
type="button"
onClick={() => handleRemoveModule(idx)}
@@ -290,7 +532,7 @@ export const CreateCourseView: React.FC<CreateCourseViewProps> = ({
<div className="flex items-center justify-end gap-4 pt-4">
<button
type="button"
onClick={() => navigate('/seller')}
onClick={() => navigate('/teachers')}
className="px-6 py-3.5 bg-slate-800 hover:bg-slate-700 text-slate-300 font-semibold rounded-xl text-xs transition"
>
Cancelar
@@ -300,7 +542,7 @@ export const CreateCourseView: React.FC<CreateCourseViewProps> = ({
className="px-8 py-3.5 bg-gradient-to-r from-blue-600 to-cyan-500 hover:from-blue-500 hover:to-cyan-400 text-white font-bold rounded-xl text-sm shadow-xl shadow-cyan-500/25 transition-all flex items-center gap-2"
>
<FiUploadCloud />
<span>Publicar Curso en RyCourses</span>
<span>Publicar Curso y Videos</span>
</button>
</div>
+166 -256
View File
@@ -1,18 +1,19 @@
import React, { useState } from 'react';
import { useNavigate, Link } from 'react-router';
import { BrandLogo } from '../components/common/BrandLogo';
import {
Button,
Input,
Checkbox,
ThemeToggle,
Card
} from '../components/ui';
import {
FiMail,
FiLock,
FiEye,
FiEyeOff,
FiArrowRight,
FiArrowLeft,
FiStar,
FiCheckCircle,
FiBookOpen,
FiAward,
FiUsers
FiCheckCircle
} from 'react-icons/fi';
import type { User } from '../types/course';
@@ -23,303 +24,212 @@ interface LoginViewProps {
export const LoginView: React.FC<LoginViewProps> = ({ onShowToast, onLogin }) => {
const navigate = useNavigate();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [rememberMe, setRememberMe] = useState(true);
const [isLoading, setIsLoading] = useState(false);
const handleMockLogin = (name: string, role: 'buyer' | 'seller', avatarUrl?: string) => {
const mockUser: User = {
id: Math.random().toString(36).substr(2, 9),
name,
email: email || `${role}@rycourses.com`,
role,
avatar: avatarUrl || 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=100&auto=format&fit=crop&q=80',
};
onLogin(mockUser);
onShowToast(`¡Bienvenido de nuevo, ${name}!`);
navigate(role === 'seller' ? '/seller' : '/courses');
};
const isValidEmail = email.includes('@') && email.includes('.');
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (email && password) {
handleMockLogin('Usuario Estudiante', 'buyer');
}
if (!email || !password) return;
setIsLoading(true);
setTimeout(() => {
let derivedName = 'Usuario RyCourses';
if (email.includes('@')) {
const rawName = email.split('@')[0];
derivedName = rawName
.replace(/[._-]/g, ' ')
.replace(/\b\w/g, (char) => char.toUpperCase());
}
const isSeller = email.toLowerCase().includes('instructor') || email.toLowerCase().includes('seller');
const role: 'buyer' | 'seller' = isSeller ? 'seller' : 'buyer';
const userObj: User = {
id: Math.random().toString(36).substring(2, 11),
name: derivedName,
email,
role,
avatar: role === 'seller'
? 'https://images.unsplash.com/photo-1573496359142-b8d87734a5a2?w=100&auto=format&fit=crop&q=80'
: 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=100&auto=format&fit=crop&q=80',
};
onLogin(userObj);
onShowToast(`¡Bienvenido de nuevo, ${derivedName}!`);
setIsLoading(false);
navigate(role === 'seller' ? '/seller' : '/courses');
}, 450);
};
return (
<div className="min-h-screen grid grid-cols-1 lg:grid-cols-12 bg-slate-950 text-slate-100 font-sans selection:bg-cyan-500 selection:text-slate-950 relative overflow-hidden">
<div className="min-h-screen flex flex-col justify-between font-sans selection:bg-cyan-500 relative overflow-hidden p-6 sm:p-10 lg:p-12 transition-colors duration-300 bg-slate-50 dark:bg-slate-950 text-slate-900 dark:text-slate-100">
{/* Ambient background glow */}
<div className="absolute -top-40 -left-40 w-96 h-96 bg-cyan-600/15 rounded-full blur-[120px] pointer-events-none" />
<div className="absolute top-1/2 right-1/3 w-80 h-80 bg-blue-600/10 rounded-full blur-[140px] pointer-events-none" />
{/* Top-Right Background Radial Gradient Glow */}
<div
className="absolute -top-32 -right-32 w-[600px] sm:w-[850px] h-[600px] sm:h-[850px] rounded-full pointer-events-none opacity-30 dark:opacity-35 blur-[150px]"
style={{
background: 'radial-gradient(circle at center, rgba(6, 182, 212, 0.45) 0%, rgba(37, 99, 235, 0.3) 45%, transparent 70%)'
}}
/>
<div className="absolute bottom-0 left-0 w-[450px] h-[450px] rounded-full pointer-events-none opacity-15 dark:opacity-20 blur-[160px] bg-blue-900/30" />
<div className="absolute inset-0 bg-[radial-gradient(#94a3b8_1px,transparent_1px)] dark:bg-[radial-gradient(#334155_1px,transparent_1px)] [background-size:24px_24px] opacity-20 dark:opacity-10 pointer-events-none" />
{/* ============================================================== */}
{/* LEFT COLUMN: BRANDING & GRAPHIC BANNER */}
{/* ============================================================== */}
<div className="hidden lg:flex lg:col-span-6 xl:col-span-7 relative flex-col justify-between p-10 xl:p-14 overflow-hidden bg-slate-900/40 border-r border-slate-800/60">
{/* Decorative Grid and Ambient Lights */}
<div className="absolute inset-0 bg-[radial-gradient(#334155_1px,transparent_1px)] [background-size:20px_20px] opacity-25" />
<div className="absolute top-1/4 left-10 w-96 h-96 bg-cyan-500/20 rounded-full blur-[140px] pointer-events-none" />
<div className="absolute bottom-10 right-10 w-96 h-96 bg-blue-600/20 rounded-full blur-[140px] pointer-events-none" />
{/* Interactive Background Atmosphere: Twinkling Star Particles & Flying Space Shuttle */}
<div className="absolute inset-0 pointer-events-none overflow-hidden z-0">
<div className="absolute top-12 left-1/6 w-1.5 h-1.5 rounded-full bg-cyan-400 animate-twinkle opacity-75" style={{ animationDelay: '0s' }} />
<div className="absolute top-1/4 right-1/5 w-2 h-2 rounded-full bg-blue-400 animate-twinkle opacity-85" style={{ animationDelay: '1.4s' }} />
<div className="absolute top-1/3 left-1/4 w-1 h-1 rounded-full bg-teal-400 animate-twinkle opacity-70" style={{ animationDelay: '2.1s' }} />
<div className="absolute top-1/2 right-1/3 w-1.5 h-1.5 rounded-full bg-cyan-400 animate-twinkle opacity-80" style={{ animationDelay: '0.6s' }} />
<div className="absolute top-2/3 left-1/5 w-2 h-2 rounded-full bg-cyan-500 animate-twinkle opacity-75" style={{ animationDelay: '1.8s' }} />
{/* Brand Header Banner */}
<div className="relative z-10 flex items-center justify-between">
<div className="flex items-center gap-3">
<BrandLogo size="lg" showSubtitle={true} />
</div>
<div className="flex items-center gap-1.5 px-3.5 py-1.5 rounded-full bg-slate-900/80 border border-slate-700/60 text-xs font-semibold text-slate-300 backdrop-blur-md">
<FiCheckCircle className="text-emerald-400" />
<span>+1,200 Cursos Disponibles</span>
</div>
{/* Crossing Space Shuttle Rocket */}
<div className="absolute top-0 left-0 animate-spaceship">
<svg className="w-16 h-16 sm:w-20 sm:h-20 filter drop-shadow-[0_0_25px_rgba(6,182,212,0.8)]" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M22 78L10 90L26 84L14 98L30 86L22 78Z" fill="url(#plasmaFlame)" className="animate-pulse" />
<path d="M78 22C62 26 42 42 34 58L26 62L38 74L42 66C58 58 74 38 78 22Z" fill="url(#rocketBodyGradient)" stroke="#38bdf8" strokeWidth="2" strokeLinejoin="round" />
<path d="M78 22C74 23 68 28 64 34L66 36C72 32 77 26 78 22Z" fill="#22d3ee" />
<path d="M34 58L16 64L24 50L30 52Z" fill="#0284c7" stroke="#38bdf8" strokeWidth="1.5" strokeLinejoin="round" />
<path d="M42 66L48 84L50 76L46 70Z" fill="#0284c7" stroke="#38bdf8" strokeWidth="1.5" strokeLinejoin="round" />
<circle cx="58" cy="42" r="5" fill="#0f172a" stroke="#38bdf8" strokeWidth="1.5" />
<circle cx="58" cy="42" r="3.5" fill="#22d3ee" />
<circle cx="59.5" cy="40.5" r="1" fill="#ffffff" />
<circle cx="48" cy="52" r="4" fill="#0f172a" stroke="#38bdf8" strokeWidth="1.5" />
<circle cx="48" cy="52" r="2.5" fill="#22d3ee" />
<defs>
<linearGradient id="plasmaFlame" x1="30" y1="78" x2="10" y2="98" gradientUnits="userSpaceOnUse">
<stop stopColor="#38bdf8" />
<stop offset="0.5" stopColor="#06b6d4" />
<stop offset="1" stopColor="transparent" />
</linearGradient>
<linearGradient id="rocketBodyGradient" x1="78" y1="22" x2="26" y2="74" gradientUnits="userSpaceOnUse">
<stop stopColor="#e0f2fe" />
<stop offset="0.4" stopColor="#38bdf8" />
<stop offset="0.8" stopColor="#0284c7" />
<stop offset="1" stopColor="#0f172a" />
</linearGradient>
</defs>
</svg>
</div>
{/* Hero Illustration Box */}
<div className="relative z-10 my-auto py-6 max-w-2xl mx-auto w-full">
{/* Main Visual Frame */}
<div className="relative rounded-3xl p-1.5 bg-gradient-to-b from-slate-700/60 via-slate-800/40 to-cyan-500/30 border border-slate-700/80 shadow-2xl backdrop-blur-md group">
{/* Embedded 3D Learning Image */}
<div className="relative overflow-hidden rounded-[20px] aspect-[4/3]">
<img
src="/login-banner.jpg"
alt="Aprende y crece en RyCourses"
className="w-full h-full object-cover transform group-hover:scale-105 transition-transform duration-700"
/>
<div className="absolute inset-0 bg-gradient-to-t from-slate-950 via-slate-950/20 to-transparent" />
</div>
{/* Top Badge Overlay */}
<div className="absolute top-5 left-5 z-20 flex items-center gap-2 py-1.5 px-3.5 rounded-full bg-slate-950/80 border border-cyan-500/40 text-cyan-300 text-xs font-bold shadow-lg backdrop-blur-md">
<FiStar className="text-yellow-400 fill-yellow-400" />
<span>Aprende con Tecnologías Reales</span>
</div>
{/* Bottom Overlay Card */}
<div className="absolute bottom-5 left-5 right-5 z-20 p-4 rounded-2xl bg-slate-950/85 border border-slate-800/90 shadow-2xl backdrop-blur-xl space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-cyan-500/20 border border-cyan-500/40 flex items-center justify-center text-cyan-400 font-bold text-lg">
🚀
</div>
<div>
<p className="text-xs font-bold text-white tracking-tight">
Aumenta tus habilidades profesionales
</p>
<p className="text-[11px] text-slate-400">
Cursos prácticos creados por mentores de la industria.
</p>
</div>
</div>
<div className="hidden sm:flex items-center gap-1 bg-amber-500/10 px-2.5 py-1 rounded-lg border border-amber-500/30 text-amber-300 text-xs font-extrabold">
<FiStar className="fill-amber-400 text-amber-400" />
<span>4.9 / 5.0</span>
</div>
</div>
</div>
</div>
</div>
{/* Feature Highlights Grid */}
<div className="relative z-10 grid grid-cols-3 gap-4 pt-4 border-t border-slate-800/60">
<div className="flex items-center gap-3 p-3 rounded-2xl bg-slate-900/60 border border-slate-800/60">
<div className="w-9 h-9 rounded-xl bg-cyan-500/10 border border-cyan-500/30 flex items-center justify-center text-cyan-400 shrink-0">
<FiBookOpen className="text-base" />
</div>
<div>
<p className="text-xs font-bold text-white">Acceso De Por Vida</p>
<p className="text-[10px] text-slate-400">Estudia a tu propio ritmo</p>
</div>
</div>
<div className="flex items-center gap-3 p-3 rounded-2xl bg-slate-900/60 border border-slate-800/60">
<div className="w-9 h-9 rounded-xl bg-blue-500/10 border border-blue-500/30 flex items-center justify-center text-blue-400 shrink-0">
<FiAward className="text-base" />
</div>
<div>
<p className="text-xs font-bold text-white">Certificados</p>
<p className="text-[10px] text-slate-400">Acredita tus proyectos</p>
</div>
</div>
<div className="flex items-center gap-3 p-3 rounded-2xl bg-slate-900/60 border border-slate-800/60">
<div className="w-9 h-9 rounded-xl bg-emerald-500/10 border border-emerald-500/30 flex items-center justify-center text-emerald-400 shrink-0">
<FiUsers className="text-base" />
</div>
<div>
<p className="text-xs font-bold text-white">+50,000 Alumnos</p>
<p className="text-[10px] text-slate-400">Comunidad global activa</p>
</div>
</div>
</div>
</div>
{/* ============================================================== */}
{/* RIGHT COLUMN: FORM & AUTHENTICATION */}
{/* ============================================================== */}
<div className="lg:col-span-6 xl:col-span-5 flex flex-col justify-between p-6 sm:p-10 lg:p-12 z-10 bg-slate-950/90 backdrop-blur-2xl border-l border-slate-800/80">
{/* Top Header Navigation */}
<div className="flex items-center justify-between">
<Link to="/" className="inline-flex items-center gap-2 lg:hidden">
<BrandLogo size="md" showSubtitle={false} />
</Link>
<div className="hidden lg:block" />
{/* Top Navigation Bar */}
<div className="w-full max-w-6xl mx-auto flex items-center justify-between z-10 pt-2 pb-4">
{/* Reusable UI ThemeToggle Component */}
<ThemeToggle variant="pill" />
<Link
to="/"
className="flex items-center gap-1.5 text-xs font-semibold text-slate-400 hover:text-cyan-400 transition-colors py-2 px-3 rounded-xl bg-slate-900/80 border border-slate-800 hover:border-cyan-500/40"
>
<FiArrowLeft className="text-sm" />
<span>Volver al inicio</span>
</Link>
</div>
{/* Back Link Button */}
<Link to="/">
<Button variant="outline" size="sm" leftIcon={<FiArrowLeft />}>
Volver al inicio
</Button>
</Link>
</div>
{/* Login Form Main Container */}
<div className="my-auto py-8 max-w-md w-full mx-auto space-y-7">
{/* Main Login Card Container */}
<div className="my-auto w-full max-w-md mx-auto z-10">
<Card variant="glass" padding="lg" className="relative space-y-6 shadow-2xl">
{/* Section Titles */}
<div className="space-y-2">
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-cyan-500/10 border border-cyan-500/20 text-cyan-400 text-xs font-medium">
<span className="w-2 h-2 rounded-full bg-cyan-400 animate-pulse" />
Tu Portal Educativo
{/* Subtle Top Border Gradient Accent */}
<div className="absolute top-0 left-6 right-6 h-[1.5px] bg-gradient-to-r from-transparent via-cyan-500/60 to-transparent pointer-events-none" />
{/* Centered Brand Logo & Titles inside Form Card */}
<div className="text-center space-y-2 pt-1">
<div className="flex justify-center pb-1">
<BrandLogo size="lg" showSubtitle={true} />
</div>
<h1 className="text-3xl sm:text-4xl font-extrabold text-white tracking-tight">
¡Hola de nuevo! <span className="inline-block animate-bounce">👋</span>
<h1 className="text-2xl sm:text-3xl font-bold tracking-tight pt-2 text-slate-900 dark:text-white">
Iniciar Sesión
</h1>
<p className="text-xs sm:text-sm text-slate-400">
Ingresa tus credenciales para continuar tu ruta de aprendizaje
<p className="text-xs sm:text-sm text-slate-600 dark:text-slate-400">
Ingresa tus credenciales para acceder a tu cuenta.
</p>
</div>
{/* Quick Demo Access Bar */}
<div className="p-3.5 rounded-2xl bg-slate-900/90 border border-slate-800 space-y-2">
<p className="text-[11px] font-semibold text-slate-400 uppercase tracking-wider">
Acceso Rápido Demo:
</p>
<div className="grid grid-cols-2 gap-2">
<button
type="button"
onClick={() => handleMockLogin('Carlos Estudiante', 'buyer', 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=100&auto=format&fit=crop&q=80')}
className="flex items-center justify-center gap-2 py-2 px-3 rounded-xl bg-slate-800/80 hover:bg-cyan-950/60 border border-slate-700 hover:border-cyan-500/50 text-xs font-medium text-slate-200 hover:text-cyan-300 transition group"
>
<FiBookOpen className="text-cyan-400 group-hover:scale-110 transition-transform" />
<span>Como Estudiante</span>
</button>
<button
type="button"
onClick={() => handleMockLogin('Dra. Elena Instructor', 'seller', 'https://images.unsplash.com/photo-1573496359142-b8d87734a5a2?w=100&auto=format&fit=crop&q=80')}
className="flex items-center justify-center gap-2 py-2 px-3 rounded-xl bg-slate-800/80 hover:bg-violet-950/60 border border-slate-700 hover:border-violet-500/50 text-xs font-medium text-slate-200 hover:text-violet-300 transition group"
>
<FiUsers className="text-violet-400 group-hover:scale-110 transition-transform" />
<span>Como Instructor</span>
</button>
</div>
</div>
{/* Login Form */}
{/* Form */}
<form onSubmit={handleSubmit} className="space-y-4">
{/* Email field */}
{/* Email field using UI Input */}
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1.5">
Correo Electrónico
</label>
<div className="relative">
<FiMail className="absolute left-3.5 top-3.5 text-slate-500 text-base" />
<input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="[email protected]"
className="w-full pl-10 pr-4 py-3 bg-slate-900/90 border border-slate-800 focus:border-cyan-500 rounded-xl text-xs text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/30 transition"
/>
</div>
<Input
label="Correo electrónico"
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="[email protected]"
leftIcon={<FiMail className="text-slate-400" />}
/>
{isValidEmail && (
<div className="flex items-center gap-1 text-[11px] text-emerald-500 font-medium mt-1 animate-fadeIn">
<FiCheckCircle className="text-xs" /> Formato válido
</div>
)}
</div>
{/* Password field */}
{/* Password field using UI Input (built-in password eye toggle) */}
<div>
<div className="flex items-center justify-between mb-1.5">
<label className="block text-xs font-semibold text-slate-300">
Contraseña
</label>
<a href="#" className="text-[11px] font-medium text-cyan-400 hover:text-cyan-300 hover:underline transition">
<Input
label="Contraseña"
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
leftIcon={<FiLock className="text-slate-400" />}
/>
<div className="flex justify-end mt-1">
<a href="#" className="text-[11px] font-medium text-cyan-600 dark:text-cyan-400 hover:underline transition">
¿Olvidaste tu contraseña?
</a>
</div>
<div className="relative">
<FiLock className="absolute left-3.5 top-3.5 text-slate-500 text-base" />
<input
type={showPassword ? 'text' : 'password'}
required
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
className="w-full pl-10 pr-10 py-3 bg-slate-900/90 border border-slate-800 focus:border-cyan-500 rounded-xl text-xs text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/30 transition"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3.5 top-3.5 text-slate-500 hover:text-slate-300 transition"
>
{showPassword ? <FiEyeOff className="text-base" /> : <FiEye className="text-base" />}
</button>
</div>
</div>
{/* Remember Me */}
<div className="flex items-center justify-between pt-1">
<label className="flex items-center gap-2 cursor-pointer text-xs text-slate-400 hover:text-slate-300">
<input
type="checkbox"
checked={rememberMe}
onChange={(e) => setRememberMe(e.target.checked)}
className="rounded border-slate-700 bg-slate-900 text-cyan-500 focus:ring-cyan-500/50"
/>
<span>Recordar este dispositivo</span>
</label>
{/* Remember Me using UI Checkbox */}
<div className="pt-0.5">
<Checkbox
checked={rememberMe}
onChange={(e) => setRememberMe(e.target.checked)}
label="Recordar sesión"
/>
</div>
{/* Submit Button */}
<button
{/* Submit Button using UI Button */}
<Button
type="submit"
className="w-full py-3.5 px-4 bg-gradient-to-r from-blue-600 via-cyan-500 to-teal-400 hover:opacity-95 text-white font-bold rounded-xl text-xs sm:text-sm shadow-lg shadow-cyan-500/25 transition-all flex items-center justify-center gap-2 group cursor-pointer"
variant="gradient"
size="lg"
fullWidth
isLoading={isLoading}
loadingText="Verificando credenciales..."
rightIcon={<FiArrowRight className="text-base" />}
>
<span>Ingresar a RyCourses</span>
<FiArrowRight className="text-base group-hover:translate-x-1 transition-transform" />
</button>
Acceder a la cuenta
</Button>
</form>
{/* Register Callout */}
<div className="text-center text-xs text-slate-400 pt-2 border-t border-slate-900">
¿Aún no eres miembro de RyCourses?{' '}
<Link to="/register" className="text-cyan-400 font-bold hover:text-cyan-300 hover:underline transition">
Regístrate gratis aquí
<div className="text-center text-xs pt-3 border-t border-slate-200 dark:border-slate-800 text-slate-600 dark:text-slate-400">
¿Aún no tienes una cuenta?{' '}
<Link to="/register" className="text-cyan-600 dark:text-cyan-400 font-semibold hover:underline transition">
Regístrate aquí
</Link>
</div>
</div>
{/* Footer info */}
<div className="text-center lg:text-left text-[11px] text-slate-600">
© 2026 RyCourses Platform. Todos los derechos reservados.
</div>
</Card>
</div>
{/* Footer info */}
<div className="w-full max-w-6xl mx-auto text-center text-[11px] z-10 pt-4 pb-2 text-slate-500 dark:text-slate-500">
© 2026 RyCourses. Todos los derechos reservados.
</div>
</div>
);
};
+162 -213
View File
@@ -1,19 +1,20 @@
import React, { useState } from 'react';
import { useNavigate, Link } from 'react-router';
import { BrandLogo } from '../components/common/BrandLogo';
import {
Button,
Input,
Checkbox,
ThemeToggle,
Card
} from '../components/ui';
import {
FiMail,
FiLock,
FiUser,
FiArrowRight,
FiArrowLeft,
FiCheckCircle,
FiStar,
FiBookOpen,
FiAward,
FiUsers,
FiEye,
FiEyeOff
FiCheckCircle
} from 'react-icons/fi';
import { FaGraduationCap, FaChalkboardTeacher } from 'react-icons/fa';
import type { User } from '../types/course';
@@ -25,288 +26,236 @@ interface RegisterViewProps {
export const RegisterView: React.FC<RegisterViewProps> = ({ onShowToast, onLogin }) => {
const navigate = useNavigate();
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [accountType, setAccountType] = useState<'buyer' | 'seller'>('buyer');
const [isLoading, setIsLoading] = useState(false);
const isValidEmail = email.includes('@') && email.includes('.');
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (name && email && password) {
if (!name || !email || !password) return;
setIsLoading(true);
setTimeout(() => {
const newUser: User = {
id: Math.random().toString(36).substr(2, 9),
id: Math.random().toString(36).substring(2, 11),
name,
email,
role: accountType,
avatar: 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=100&auto=format&fit=crop&q=80',
avatar: accountType === 'seller'
? 'https://images.unsplash.com/photo-1573496359142-b8d87734a5a2?w=100&auto=format&fit=crop&q=80'
: 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=100&auto=format&fit=crop&q=80',
};
onLogin(newUser);
onShowToast(`¡Cuenta de ${accountType === 'buyer' ? 'Estudiante' : 'Instructor'} creada con éxito!`);
setIsLoading(false);
navigate(accountType === 'seller' ? '/seller' : '/courses');
}
}, 450);
};
return (
<div className="min-h-screen grid grid-cols-1 lg:grid-cols-12 bg-slate-950 text-slate-100 font-sans selection:bg-cyan-500 selection:text-slate-950 relative overflow-hidden">
<div className="min-h-screen flex flex-col justify-between font-sans selection:bg-cyan-500 relative overflow-hidden p-6 sm:p-10 lg:p-12 transition-colors duration-300 bg-slate-50 dark:bg-slate-950 text-slate-900 dark:text-slate-100">
{/* Ambient background glow */}
<div className="absolute -top-40 -left-40 w-96 h-96 bg-blue-600/15 rounded-full blur-[120px] pointer-events-none" />
<div className="absolute top-1/2 right-1/3 w-80 h-80 bg-cyan-600/10 rounded-full blur-[140px] pointer-events-none" />
{/* Top-Right Background Radial Gradient Glow */}
<div
className="absolute -top-32 -right-32 w-[600px] sm:w-[850px] h-[600px] sm:h-[850px] rounded-full pointer-events-none opacity-30 dark:opacity-35 blur-[150px]"
style={{
background: 'radial-gradient(circle at center, rgba(6, 182, 212, 0.45) 0%, rgba(37, 99, 235, 0.3) 45%, transparent 70%)'
}}
/>
<div className="absolute bottom-0 left-0 w-[450px] h-[450px] rounded-full pointer-events-none opacity-15 dark:opacity-20 blur-[160px] bg-blue-900/30" />
<div className="absolute inset-0 bg-[radial-gradient(#94a3b8_1px,transparent_1px)] dark:bg-[radial-gradient(#334155_1px,transparent_1px)] [background-size:24px_24px] opacity-20 dark:opacity-10 pointer-events-none" />
{/* ============================================================== */}
{/* LEFT COLUMN: BRANDING BANNER */}
{/* ============================================================== */}
<div className="hidden lg:flex lg:col-span-6 xl:col-span-7 relative flex-col justify-between p-10 xl:p-14 overflow-hidden bg-slate-900/40 border-r border-slate-800/60">
{/* Ambient Lights */}
<div className="absolute inset-0 bg-[radial-gradient(#334155_1px,transparent_1px)] [background-size:20px_20px] opacity-25" />
<div className="absolute top-1/4 left-10 w-96 h-96 bg-cyan-500/20 rounded-full blur-[140px] pointer-events-none" />
<div className="absolute bottom-10 right-10 w-96 h-96 bg-blue-600/20 rounded-full blur-[140px] pointer-events-none" />
{/* Interactive Background Atmosphere: Twinkling Star Particles & Flying Space Shuttle */}
<div className="absolute inset-0 pointer-events-none overflow-hidden z-0">
<div className="absolute top-12 left-1/6 w-1.5 h-1.5 rounded-full bg-cyan-400 animate-twinkle opacity-75" style={{ animationDelay: '0s' }} />
<div className="absolute top-1/4 right-1/5 w-2 h-2 rounded-full bg-blue-400 animate-twinkle opacity-85" style={{ animationDelay: '1.4s' }} />
<div className="absolute top-1/3 left-1/4 w-1 h-1 rounded-full bg-teal-400 animate-twinkle opacity-70" style={{ animationDelay: '2.1s' }} />
<div className="absolute top-1/2 right-1/3 w-1.5 h-1.5 rounded-full bg-cyan-400 animate-twinkle opacity-80" style={{ animationDelay: '0.6s' }} />
<div className="absolute top-2/3 left-1/5 w-2 h-2 rounded-full bg-cyan-500 animate-twinkle opacity-75" style={{ animationDelay: '1.8s' }} />
{/* Brand Header */}
<div className="relative z-10 flex items-center justify-between">
<BrandLogo size="lg" showSubtitle={true} />
<div className="flex items-center gap-1.5 px-3.5 py-1.5 rounded-full bg-slate-900/80 border border-slate-700/60 text-xs font-semibold text-slate-300 backdrop-blur-md">
<FiCheckCircle className="text-emerald-400" />
<span>Regístrate 100% Gratis</span>
</div>
{/* Crossing Space Shuttle Rocket */}
<div className="absolute top-0 left-0 animate-spaceship">
<svg className="w-16 h-16 sm:w-20 sm:h-20 filter drop-shadow-[0_0_25px_rgba(6,182,212,0.8)]" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M22 78L10 90L26 84L14 98L30 86L22 78Z" fill="url(#plasmaFlameReg)" className="animate-pulse" />
<path d="M78 22C62 26 42 42 34 58L26 62L38 74L42 66C58 58 74 38 78 22Z" fill="url(#rocketBodyGradientReg)" stroke="#38bdf8" strokeWidth="2" strokeLinejoin="round" />
<path d="M78 22C74 23 68 28 64 34L66 36C72 32 77 26 78 22Z" fill="#22d3ee" />
<path d="M34 58L16 64L24 50L30 52Z" fill="#0284c7" stroke="#38bdf8" strokeWidth="1.5" strokeLinejoin="round" />
<path d="M42 66L48 84L50 76L46 70Z" fill="#0284c7" stroke="#38bdf8" strokeWidth="1.5" strokeLinejoin="round" />
<circle cx="58" cy="42" r="5" fill="#0f172a" stroke="#38bdf8" strokeWidth="1.5" />
<circle cx="58" cy="42" r="3.5" fill="#22d3ee" />
<circle cx="59.5" cy="40.5" r="1" fill="#ffffff" />
<circle cx="48" cy="52" r="4" fill="#0f172a" stroke="#38bdf8" strokeWidth="1.5" />
<circle cx="48" cy="52" r="2.5" fill="#22d3ee" />
<defs>
<linearGradient id="plasmaFlameReg" x1="30" y1="78" x2="10" y2="98" gradientUnits="userSpaceOnUse">
<stop stopColor="#38bdf8" />
<stop offset="0.5" stopColor="#06b6d4" />
<stop offset="1" stopColor="transparent" />
</linearGradient>
<linearGradient id="rocketBodyGradientReg" x1="78" y1="22" x2="26" y2="74" gradientUnits="userSpaceOnUse">
<stop stopColor="#e0f2fe" />
<stop offset="0.4" stopColor="#38bdf8" />
<stop offset="0.8" stopColor="#0284c7" />
<stop offset="1" stopColor="#0f172a" />
</linearGradient>
</defs>
</svg>
</div>
{/* Hero Visual Box */}
<div className="relative z-10 my-auto py-6 max-w-2xl mx-auto w-full">
<div className="relative rounded-3xl p-1.5 bg-gradient-to-b from-slate-700/60 via-slate-800/40 to-cyan-500/30 border border-slate-700/80 shadow-2xl backdrop-blur-md group">
<div className="relative overflow-hidden rounded-[20px] aspect-[4/3]">
<img
src="/login-banner.jpg"
alt="Comienza tu aprendizaje en RyCourses"
className="w-full h-full object-cover transform group-hover:scale-105 transition-transform duration-700"
/>
<div className="absolute inset-0 bg-gradient-to-t from-slate-950 via-slate-950/20 to-transparent" />
</div>
<div className="absolute top-5 left-5 z-20 flex items-center gap-2 py-1.5 px-3.5 rounded-full bg-slate-950/80 border border-cyan-500/40 text-cyan-300 text-xs font-bold shadow-lg backdrop-blur-md">
<FiStar className="text-yellow-400 fill-yellow-400" />
<span>Aprende a Tu Propio Ritmo</span>
</div>
<div className="absolute bottom-5 left-5 right-5 z-20 p-4 rounded-2xl bg-slate-950/85 border border-slate-800/90 shadow-2xl backdrop-blur-xl space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-cyan-500/20 border border-cyan-500/40 flex items-center justify-center text-cyan-400 font-bold text-lg">
🎓
</div>
<div>
<p className="text-xs font-bold text-white tracking-tight">
Obtén certificados con reconocimiento internacional
</p>
<p className="text-[11px] text-slate-400">
Proyectos prácticos para enriquecer tu portafolio profesional.
</p>
</div>
</div>
</div>
</div>
</div>
</div>
{/* Feature Highlights Grid */}
<div className="relative z-10 grid grid-cols-3 gap-4 pt-4 border-t border-slate-800/60">
<div className="flex items-center gap-3 p-3 rounded-2xl bg-slate-900/60 border border-slate-800/60">
<div className="w-9 h-9 rounded-xl bg-cyan-500/10 border border-cyan-500/30 flex items-center justify-center text-cyan-400 shrink-0">
<FiBookOpen className="text-base" />
</div>
<div>
<p className="text-xs font-bold text-white">Cursos Prácticos</p>
<p className="text-[10px] text-slate-400">Proyectos reales</p>
</div>
</div>
<div className="flex items-center gap-3 p-3 rounded-2xl bg-slate-900/60 border border-slate-800/60">
<div className="w-9 h-9 rounded-xl bg-blue-500/10 border border-blue-500/30 flex items-center justify-center text-blue-400 shrink-0">
<FiAward className="text-base" />
</div>
<div>
<p className="text-xs font-bold text-white">Badges Digitales</p>
<p className="text-[10px] text-slate-400">Comparte en Linkedin</p>
</div>
</div>
<div className="flex items-center gap-3 p-3 rounded-2xl bg-slate-900/60 border border-slate-800/60">
<div className="w-9 h-9 rounded-xl bg-emerald-500/10 border border-emerald-500/30 flex items-center justify-center text-emerald-400 shrink-0">
<FiUsers className="text-base" />
</div>
<div>
<p className="text-xs font-bold text-white">Mentores 24/7</p>
<p className="text-[10px] text-slate-400">Soporte continuo</p>
</div>
</div>
</div>
</div>
{/* ============================================================== */}
{/* RIGHT COLUMN: FORM & REGISTER */}
{/* ============================================================== */}
<div className="lg:col-span-6 xl:col-span-5 flex flex-col justify-between p-6 sm:p-10 lg:p-12 z-10 bg-slate-950/90 backdrop-blur-2xl border-l border-slate-800/80">
{/* Top Navigation Header */}
<div className="flex items-center justify-between">
<Link to="/" className="inline-flex items-center gap-2 lg:hidden">
<BrandLogo size="md" showSubtitle={false} />
</Link>
<div className="hidden lg:block" />
{/* Top Navigation Bar */}
<div className="w-full max-w-6xl mx-auto flex items-center justify-between z-10 pt-2 pb-4">
{/* Reusable UI ThemeToggle Component */}
<ThemeToggle variant="pill" />
<Link
to="/"
className="flex items-center gap-1.5 text-xs font-semibold text-slate-400 hover:text-cyan-400 transition-colors py-2 px-3 rounded-xl bg-slate-900/80 border border-slate-800 hover:border-cyan-500/40"
>
<FiArrowLeft className="text-sm" />
<span>Volver al inicio</span>
</Link>
</div>
{/* Back Link Button */}
<Link to="/">
<Button variant="outline" size="sm" leftIcon={<FiArrowLeft />}>
Volver al inicio
</Button>
</Link>
</div>
{/* Form Body Container */}
<div className="my-auto py-8 max-w-md w-full mx-auto space-y-6">
{/* Main Centered Register Card Container */}
<div className="my-auto w-full max-w-md mx-auto z-10">
<Card variant="glass" padding="lg" className="relative space-y-5 shadow-2xl">
{/* Titles */}
<div className="space-y-2">
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-blue-500/10 border border-blue-500/20 text-cyan-400 text-xs font-medium">
<span className="w-2 h-2 rounded-full bg-cyan-400 animate-pulse" />
Únete a RyCourses
{/* Subtle Top Border Gradient Accent */}
<div className="absolute top-0 left-6 right-6 h-[1.5px] bg-gradient-to-r from-transparent via-cyan-500/60 to-transparent pointer-events-none" />
{/* Centered Brand Logo & Header Titles inside Form Card */}
<div className="text-center space-y-2 pt-1">
<div className="flex justify-center pb-1">
<BrandLogo size="lg" showSubtitle={true} />
</div>
<h1 className="text-3xl sm:text-4xl font-extrabold text-white tracking-tight">
Crear Cuenta Gratis ✨
<h1 className="text-2xl sm:text-3xl font-bold tracking-tight pt-2 text-slate-900 dark:text-white">
Crear Cuenta
</h1>
<p className="text-xs sm:text-sm text-slate-400">
Comienza a aprender o enseñar hoy mismo en la plataforma líder.
<p className="text-xs sm:text-sm text-slate-600 dark:text-slate-400">
Crea tu perfil en RyCourses para comenzar tu experiencia.
</p>
</div>
{/* Account type switcher */}
<div className="grid grid-cols-2 gap-2 p-1.5 bg-slate-900/90 rounded-2xl border border-slate-800">
<div className="grid grid-cols-2 gap-2 p-1 rounded-xl border bg-slate-100 dark:bg-slate-950/80 border-slate-200 dark:border-slate-800">
<button
type="button"
onClick={() => setAccountType('buyer')}
className={`flex items-center justify-center gap-2 py-2.5 rounded-xl text-xs font-bold transition-all ${
className={`flex items-center justify-center gap-2 py-2 rounded-lg text-xs font-medium transition-all cursor-pointer ${
accountType === 'buyer'
? 'bg-gradient-to-r from-blue-600 to-cyan-500 text-white shadow-lg shadow-cyan-500/20'
: 'text-slate-400 hover:text-white'
? 'bg-white dark:bg-slate-800 text-cyan-600 dark:text-cyan-400 border border-slate-200 dark:border-slate-700 font-semibold shadow-sm'
: 'text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white'
}`}
>
<FaGraduationCap className="text-base" /> Estudiante
<FaGraduationCap className="text-sm" /> Estudiante
</button>
<button
type="button"
onClick={() => setAccountType('seller')}
className={`flex items-center justify-center gap-2 py-2.5 rounded-xl text-xs font-bold transition-all ${
className={`flex items-center justify-center gap-2 py-2 rounded-lg text-xs font-medium transition-all cursor-pointer ${
accountType === 'seller'
? 'bg-gradient-to-r from-violet-600 to-purple-500 text-white shadow-lg shadow-purple-500/20'
: 'text-slate-400 hover:text-white'
? 'bg-white dark:bg-slate-800 text-violet-600 dark:text-violet-400 border border-slate-200 dark:border-slate-700 font-semibold shadow-sm'
: 'text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white'
}`}
>
<FaChalkboardTeacher className="text-base" /> Instructor
<FaChalkboardTeacher className="text-sm" /> Instructor
</button>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="space-y-3.5">
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">
Nombre Completo
</label>
<div className="relative">
<FiUser className="absolute left-3.5 top-3.5 text-slate-500 text-base" />
<input
type="text"
required
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Ej. Sofía Rodríguez"
className="w-full pl-10 pr-4 py-2.5 bg-slate-900/90 border border-slate-800 focus:border-cyan-500 rounded-xl text-xs text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/30 transition"
/>
</div>
<Input
label="Nombre Completo"
type="text"
required
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Ej. Sofía Rodríguez"
leftIcon={<FiUser className="text-slate-400" />}
/>
</div>
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">
Correo Electrónico
</label>
<div className="relative">
<FiMail className="absolute left-3.5 top-3.5 text-slate-500 text-base" />
<input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="[email protected]"
className="w-full pl-10 pr-4 py-2.5 bg-slate-900/90 border border-slate-800 focus:border-cyan-500 rounded-xl text-xs text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/30 transition"
/>
</div>
<Input
label="Correo Electrónico"
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="[email protected]"
leftIcon={<FiMail className="text-slate-400" />}
/>
{isValidEmail && (
<div className="flex items-center gap-1 text-[11px] text-emerald-500 font-medium mt-1 animate-fadeIn">
<FiCheckCircle className="text-xs" /> Formato válido
</div>
)}
</div>
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">
Contraseña
</label>
<div className="relative">
<FiLock className="absolute left-3.5 top-3.5 text-slate-500 text-base" />
<input
type={showPassword ? 'text' : 'password'}
required
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Mínimo 8 caracteres"
className="w-full pl-10 pr-10 py-2.5 bg-slate-900/90 border border-slate-800 focus:border-cyan-500 rounded-xl text-xs text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/30 transition"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3.5 top-3 text-slate-500 hover:text-slate-300 transition"
>
{showPassword ? <FiEyeOff className="text-base" /> : <FiEye className="text-base" />}
</button>
</div>
<Input
label="Contraseña"
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Mínimo 8 caracteres"
leftIcon={<FiLock className="text-slate-400" />}
/>
</div>
<div className="flex items-center gap-2 pt-1 text-xs text-slate-400">
<FiCheckCircle className="text-emerald-400 shrink-0" />
<span>Acepto los Términos y Políticas de Privacidad.</span>
<div className="pt-0.5">
<Checkbox
checked={true}
readOnly
label={
<span>
Acepto los{' '}
<Link to="/terms" target="_blank" className="text-cyan-600 dark:text-cyan-400 font-semibold hover:underline">
Términos de Servicio y Políticas de Privacidad
</Link>.
</span>
}
/>
</div>
<button
<Button
type="submit"
className="w-full py-3.5 px-4 bg-gradient-to-r from-blue-600 via-cyan-500 to-teal-400 hover:opacity-95 text-white font-bold rounded-xl text-xs sm:text-sm shadow-lg shadow-cyan-500/25 transition-all flex items-center justify-center gap-2 group cursor-pointer"
variant="gradient"
size="lg"
fullWidth
isLoading={isLoading}
loadingText="Creando perfil..."
rightIcon={<FiArrowRight className="text-base" />}
>
<span>Crear Cuenta como {accountType === 'buyer' ? 'Estudiante' : 'Instructor'}</span>
<FiArrowRight className="text-base group-hover:translate-x-1 transition-transform" />
</button>
Crear cuenta como {accountType === 'buyer' ? 'Estudiante' : 'Instructor'}
</Button>
</form>
{/* Login Link */}
<div className="text-center text-xs text-slate-400 pt-2 border-t border-slate-900">
<div className="text-center text-xs pt-3 border-t border-slate-200 dark:border-slate-800 text-slate-600 dark:text-slate-400">
¿Ya tienes una cuenta registrada?{' '}
<Link to="/login" className="text-cyan-400 font-bold hover:text-cyan-300 hover:underline transition">
<Link to="/login" className="text-cyan-600 dark:text-cyan-400 font-semibold hover:underline transition">
Inicia sesión aquí
</Link>
</div>
</div>
{/* Footer info */}
<div className="text-center lg:text-left text-[11px] text-slate-600">
© 2026 RyCourses Platform. Todos los derechos reservados.
</div>
</Card>
</div>
{/* Footer info */}
<div className="w-full max-w-6xl mx-auto text-center text-[11px] z-10 pt-4 pb-2 text-slate-500 dark:text-slate-500">
© 2026 RyCourses. Todos los derechos reservados.
</div>
</div>
);
};
+130 -24
View File
@@ -1,23 +1,37 @@
import React from 'react';
import type { Course } from '../types/course';
import { useNavigate, Link } from 'react-router';
import type { Course, User } from '../types/course';
import { SellerHero } from '../components/seller/SellerHero';
import { SellerDashboard } from '../components/seller/SellerDashboard';
import { SellerCourseList } from '../components/seller/SellerCourseList';
import { SellerCalculator } from '../components/seller/SellerCalculator';
import { Card, Button, Badge } from '../components/ui';
import {
FiDollarSign,
FiGlobe,
FiVideo,
FiArrowRight,
FiHelpCircle
} from 'react-icons/fi';
interface SellerViewProps {
currentUser: User | null;
courses: Course[];
onOpenCreateCourse: () => void;
onPreviewCourse: (course: Course) => void;
onDeleteCourse: (courseId: string) => void;
}
export const SellerView: React.FC<SellerViewProps> = ({
courses,
onOpenCreateCourse,
onPreviewCourse,
onDeleteCourse,
currentUser,
}) => {
const navigate = useNavigate();
const handleCreateCourseClick = () => {
if (!currentUser) {
navigate('/login');
} else {
navigate('/seller/create');
}
};
const handleScrollToCalculator = () => {
const el = document.getElementById('seller-calculator');
if (el) {
@@ -26,28 +40,120 @@ export const SellerView: React.FC<SellerViewProps> = ({
};
return (
<div className="space-y-0">
{/* Seller Hero Section */}
<div className="space-y-16 pb-20 selection:bg-cyan-500">
{/* 1. Hero Landing Section */}
<SellerHero
onOpenCreateCourse={onOpenCreateCourse}
onOpenCreateCourse={handleCreateCourseClick}
onScrollToCalculator={handleScrollToCalculator}
/>
{/* Instructor Dashboard Stats */}
<SellerDashboard />
{/* 2. Direct Navigation CTA to Teacher Portal */}
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="p-8 rounded-3xl bg-gradient-to-r from-slate-900 via-indigo-950 to-slate-900 border border-slate-800 flex flex-col md:flex-row items-center justify-between gap-6 shadow-2xl">
<div className="space-y-2 text-center md:text-left">
<Badge variant="primary" size="md">¿Ya eres instructor registrado?</Badge>
<h2 className="text-xl sm:text-2xl font-bold text-white">Accede directamente a tu Panel de Docente</h2>
<p className="text-xs sm:text-sm text-slate-400">Gestiona tus cursos publicados, ingresos acumulados y responde a tus alumnos.</p>
</div>
{/* Published Courses Management List */}
<SellerCourseList
courses={courses}
onOpenCreateModal={onOpenCreateCourse}
onPreviewCourse={onPreviewCourse}
onDeleteCourse={onDeleteCourse}
/>
<Link to={currentUser ? "/teachers" : "/login"}>
<Button variant="gradient" size="lg" rightIcon={<FiArrowRight />}>
{currentUser ? "Ir a mi Panel Docente" : "Iniciar Sesión como Instructor"}
</Button>
</Link>
</div>
</div>
{/* 3. Why Teach on RyCourses Benefits Grid */}
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 space-y-10">
<div className="text-center space-y-3 max-w-2xl mx-auto">
<Badge variant="primary" size="md">Beneficios Únicos</Badge>
<h2 className="text-3xl sm:text-4xl font-extrabold text-white tracking-tight">
¿Por qué enseñar en RyCourses?
</h2>
<p className="text-xs sm:text-sm text-slate-400">
Diseñamos las herramientas necesarias para que te enfoques en crear contenido extraordinario mientras nosotros nos encargamos de todo lo demás.
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<Card variant="elevated" padding="lg" className="space-y-4">
<div className="w-12 h-12 rounded-2xl bg-emerald-500/10 text-emerald-400 flex items-center justify-center text-2xl">
<FiDollarSign />
</div>
<h3 className="text-lg font-bold text-white">80% de Comisión Directa</h3>
<p className="text-xs text-slate-400 leading-relaxed">
Conserva hasta el 80% de cada venta generada. Pagos automáticos semanales procesados a tu cuenta bancaria o PayPal.
</p>
</Card>
<Card variant="elevated" padding="lg" className="space-y-4">
<div className="w-12 h-12 rounded-2xl bg-cyan-500/10 text-cyan-400 flex items-center justify-center text-2xl">
<FiGlobe />
</div>
<h3 className="text-lg font-bold text-white">Audiencia Global</h3>
<p className="text-xs text-slate-400 leading-relaxed">
Llega a más de 50,000 estudiantes activos en Latinoamérica y España buscando aprender nuevas tecnologías y habilidades.
</p>
</Card>
<Card variant="elevated" padding="lg" className="space-y-4">
<div className="w-12 h-12 rounded-2xl bg-violet-500/10 text-violet-400 flex items-center justify-center text-2xl">
<FiVideo />
</div>
<h3 className="text-lg font-bold text-white">Reproductor HD & CDN Ultra Rápido</h3>
<p className="text-xs text-slate-400 leading-relaxed">
Tus videos se transmiten sin interrupciones con encriptación anti-descarga y calidad adaptable en cualquier dispositivo.
</p>
</Card>
</div>
</div>
{/* 4. Earnings Calculator Section */}
<div id="seller-calculator">
<SellerCalculator
onStartCourse={handleCreateCourseClick}
/>
</div>
{/* 5. Frequently Asked Questions */}
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 space-y-8">
<div className="text-center space-y-3">
<Badge variant="secondary" size="md">Preguntas Frecuentes</Badge>
<h2 className="text-2xl sm:text-3xl font-extrabold text-white">Todo lo que necesitas saber</h2>
</div>
<div className="space-y-4">
<Card variant="elevated" padding="md" className="space-y-2">
<h4 className="font-bold text-sm text-white flex items-center gap-2">
<FiHelpCircle className="text-cyan-400" /> ¿Tengo que pagar alguna tarifa para subir mi curso?
</h4>
<p className="text-xs text-slate-400 pl-6">
No. Publicar tu curso en RyCourses es 100% gratuito. Solo cobramos una comisión cuando realizas una venta efectiva.
</p>
</Card>
<Card variant="elevated" padding="md" className="space-y-2">
<h4 className="font-bold text-sm text-white flex items-center gap-2">
<FiHelpCircle className="text-cyan-400" /> ¿Quién conserva los derechos de propiedad intelectual de mi contenido?
</h4>
<p className="text-xs text-slate-400 pl-6">
Tú mantienes el 100% de la propiedad intelectual y derechos de autor de todos los videos y materiales que crees.
</p>
</Card>
<Card variant="elevated" padding="md" className="space-y-2">
<h4 className="font-bold text-sm text-white flex items-center gap-2">
<FiHelpCircle className="text-cyan-400" /> ¿Cómo recibo mis pagos?
</h4>
<p className="text-xs text-slate-400 pl-6">
Puedes solicitar retiros en cualquier momento desde tu panel de instructor. Depositamos vía Stripe Connect o PayPal.
</p>
</Card>
</div>
</div>
{/* Revenue Calculator */}
<SellerCalculator
onStartCourse={onOpenCreateCourse}
/>
</div>
);
};
+489
View File
@@ -0,0 +1,489 @@
import React, { useState } from 'react';
import { Link } from 'react-router';
import type { Course } from '../types/course';
import { BrandLogo } from '../components/common/BrandLogo';
import {
Card,
Button,
Badge,
Input,
ThemeToggle
} from '../components/ui';
import {
FiGrid,
FiBookOpen,
FiDollarSign,
FiUsers,
FiStar,
FiPlusCircle,
FiTrendingUp,
FiSearch,
FiEye,
FiTrash2,
FiMessageSquare,
FiAward,
FiMenu,
FiX,
FiArrowLeft,
FiDownload
} from 'react-icons/fi';
interface TeachersViewProps {
courses: Course[];
onOpenCreateCourse: () => void;
onPreviewCourse: (course: Course) => void;
onDeleteCourse: (courseId: string) => void;
onShowToast: (msg: string) => void;
}
export const TeachersView: React.FC<TeachersViewProps> = ({
courses,
onOpenCreateCourse,
onPreviewCourse,
onDeleteCourse,
onShowToast,
}) => {
const [activeTab, setActiveTab] = useState<'overview' | 'my-courses' | 'payouts' | 'reviews'>('overview');
const [sidebarOpen, setSidebarOpen] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
const [payoutRequested, setPayoutRequested] = useState(false);
// Filter instructor courses
const instructorCourses = courses.filter((c) =>
c.title.toLowerCase().includes(searchTerm.toLowerCase()) || c.category.toLowerCase().includes(searchTerm.toLowerCase())
);
const totalEarnings = 14280.50;
const pendingPayout = 2450.00;
const totalStudents = courses.reduce((acc, curr) => acc + curr.studentsCount, 0);
const avgRating = (courses.reduce((acc, curr) => acc + curr.rating, 0) / (courses.length || 1)).toFixed(1);
const handleRequestPayout = () => {
setPayoutRequested(true);
onShowToast('¡Solicitud de retiro de $2,450.00 recibida y en proceso!');
};
return (
<div className="min-h-screen transition-colors duration-300 bg-slate-50 dark:bg-slate-950 text-slate-900 dark:text-slate-100 flex flex-col md:flex-row font-sans selection:bg-cyan-500">
{/* Mobile Sidebar Overlay */}
{sidebarOpen && (
<div
className="fixed inset-0 bg-slate-950/80 backdrop-blur-sm z-40 md:hidden"
onClick={() => setSidebarOpen(false)}
/>
)}
{/* 1. Left Sidebar Navigation */}
<aside className={`
fixed md:sticky top-0 left-0 h-screen w-72 bg-white dark:bg-slate-900 border-r border-slate-200 dark:border-slate-800 z-50 flex flex-col justify-between p-5 transition-transform duration-300 shadow-xl
${sidebarOpen ? 'translate-x-0' : '-translate-x-full md:translate-x-0'}
`}>
<div className="space-y-6">
{/* Sidebar Brand Header */}
<div className="flex items-center justify-between pb-3 border-b border-slate-200 dark:border-slate-800">
<Link to="/" className="hover:opacity-90 transition">
<BrandLogo size="sm" showSubtitle={false} />
</Link>
<button
onClick={() => setSidebarOpen(false)}
className="md:hidden p-1.5 rounded-lg text-slate-500 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white"
>
<FiX className="text-xl" />
</button>
</div>
{/* Teacher Badge Pill */}
<div className="px-3 py-2 rounded-xl bg-cyan-500/10 border border-cyan-500/25 flex items-center gap-2.5">
<div className="w-7 h-7 rounded-lg bg-cyan-500/20 text-cyan-600 dark:text-cyan-400 flex items-center justify-center text-sm font-bold shrink-0">
T
</div>
<div>
<p className="text-xs font-bold text-slate-900 dark:text-white leading-none">Panel Docente</p>
<p className="text-[10px] text-cyan-600 dark:text-cyan-400 font-medium mt-0.5">Instructores & Creadores</p>
</div>
</div>
{/* Quick Create Course CTA */}
<Button
variant="gradient"
size="md"
fullWidth
leftIcon={<FiPlusCircle />}
onClick={onOpenCreateCourse}
className="shadow-lg shadow-cyan-500/20"
>
Crear Nuevo Curso
</Button>
{/* Navigation Links */}
<nav className="space-y-1.5">
<button
onClick={() => { setActiveTab('overview'); setSidebarOpen(false); }}
className={`w-full flex items-center gap-3 px-3.5 py-3 rounded-xl text-xs font-semibold transition-all ${
activeTab === 'overview'
? 'bg-slate-100 dark:bg-slate-800 text-cyan-600 dark:text-cyan-400 border border-slate-200 dark:border-cyan-500/30 shadow-sm'
: 'text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white hover:bg-slate-100 dark:hover:bg-slate-800/60'
}`}
>
<FiGrid className="text-base" />
<span>Resumen & Métricas</span>
</button>
<button
onClick={() => { setActiveTab('my-courses'); setSidebarOpen(false); }}
className={`w-full flex items-center justify-between px-3.5 py-3 rounded-xl text-xs font-semibold transition-all ${
activeTab === 'my-courses'
? 'bg-slate-100 dark:bg-slate-800 text-cyan-600 dark:text-cyan-400 border border-slate-200 dark:border-cyan-500/30 shadow-sm'
: 'text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white hover:bg-slate-100 dark:hover:bg-slate-800/60'
}`}
>
<div className="flex items-center gap-3">
<FiBookOpen className="text-base" />
<span>Mis Cursos</span>
</div>
<Badge variant="primary" size="sm">{courses.length}</Badge>
</button>
<button
onClick={() => { setActiveTab('payouts'); setSidebarOpen(false); }}
className={`w-full flex items-center justify-between px-3.5 py-3 rounded-xl text-xs font-semibold transition-all ${
activeTab === 'payouts'
? 'bg-slate-100 dark:bg-slate-800 text-cyan-600 dark:text-cyan-400 border border-slate-200 dark:border-cyan-500/30 shadow-sm'
: 'text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white hover:bg-slate-100 dark:hover:bg-slate-800/60'
}`}
>
<div className="flex items-center gap-3">
<FiDollarSign className="text-base" />
<span>Ganancias & Retiros</span>
</div>
<Badge variant="success" size="sm">${pendingPayout}</Badge>
</button>
<button
onClick={() => { setActiveTab('reviews'); setSidebarOpen(false); }}
className={`w-full flex items-center gap-3 px-3.5 py-3 rounded-xl text-xs font-semibold transition-all ${
activeTab === 'reviews'
? 'bg-slate-100 dark:bg-slate-800 text-cyan-600 dark:text-cyan-400 border border-slate-200 dark:border-cyan-500/30 shadow-sm'
: 'text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white hover:bg-slate-100 dark:hover:bg-slate-800/60'
}`}
>
<FiMessageSquare className="text-base" />
<span>Reseñas de Alumnos</span>
</button>
</nav>
</div>
{/* Sidebar Footer */}
<div className="pt-4 border-t border-slate-200 dark:border-slate-800 space-y-3">
<div className="flex items-center justify-between px-2">
<span className="text-xs text-slate-600 dark:text-slate-400 font-medium">Modo</span>
<ThemeToggle variant="pill" size="sm" />
</div>
<Link to="/">
<Button variant="outline" size="sm" fullWidth leftIcon={<FiArrowLeft />}>
Volver al inicio
</Button>
</Link>
</div>
</aside>
{/* 2. Main Content Area */}
<div className="flex-1 flex flex-col min-w-0">
{/* Top Header Bar */}
<header className="sticky top-0 z-30 h-16 bg-white/90 dark:bg-slate-900/90 border-b border-slate-200 dark:border-slate-800 backdrop-blur-xl px-4 sm:px-8 flex items-center justify-between gap-4">
<div className="flex items-center gap-3">
<button
onClick={() => setSidebarOpen(true)}
className="md:hidden text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white p-2"
>
<FiMenu className="text-xl" />
</button>
<h1 className="text-base sm:text-lg font-bold text-slate-900 dark:text-white tracking-tight">
{activeTab === 'overview' && 'Resumen del Instructor'}
{activeTab === 'my-courses' && 'Catálogo de Mis Cursos'}
{activeTab === 'payouts' && 'Finanzas & Solicitud de Retiros'}
{activeTab === 'reviews' && 'Calificaciones & Feedback de Estudiantes'}
</h1>
</div>
<div className="flex items-center gap-3">
<Badge variant="primary" size="md">
<span className="w-2 h-2 rounded-full bg-cyan-400 animate-pulse mr-1" />
Docente Verificado
</Badge>
</div>
</header>
{/* Dynamic Content Views */}
<main className="p-4 sm:p-8 space-y-8 flex-1">
{/* TAB 1: OVERVIEW */}
{activeTab === 'overview' && (
<div className="space-y-8">
{/* Stat Cards Grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-5">
<Card variant="elevated" padding="md" className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs text-slate-500 dark:text-slate-400 font-medium">Ingresos Totales</span>
<div className="w-9 h-9 rounded-xl bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 flex items-center justify-center text-lg">
<FiDollarSign />
</div>
</div>
<div className="text-2xl font-black text-slate-900 dark:text-white font-mono">${totalEarnings.toLocaleString()}</div>
<div className="text-[11px] text-emerald-600 dark:text-emerald-400 font-semibold flex items-center gap-1">
<FiTrendingUp /> +18.4% este mes
</div>
</Card>
<Card variant="elevated" padding="md" className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs text-slate-500 dark:text-slate-400 font-medium">Estudiantes Inscritos</span>
<div className="w-9 h-9 rounded-xl bg-cyan-500/10 text-cyan-600 dark:text-cyan-400 flex items-center justify-center text-lg">
<FiUsers />
</div>
</div>
<div className="text-2xl font-black text-slate-900 dark:text-white font-mono">{totalStudents.toLocaleString()}</div>
<div className="text-[11px] text-slate-500 dark:text-slate-400">En {courses.length} cursos activos</div>
</Card>
<Card variant="elevated" padding="md" className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs text-slate-500 dark:text-slate-400 font-medium">Calificación Promedio</span>
<div className="w-9 h-9 rounded-xl bg-amber-500/10 text-amber-600 dark:text-amber-400 flex items-center justify-center text-lg">
<FiStar />
</div>
</div>
<div className="text-2xl font-black text-slate-900 dark:text-white font-mono">{avgRating} / 5.0</div>
<div className="text-[11px] text-amber-600 dark:text-amber-400 font-medium">Basado en 340 reseñas</div>
</Card>
<Card variant="elevated" padding="md" className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs text-slate-500 dark:text-slate-400 font-medium">Saldo Disponible</span>
<div className="w-9 h-9 rounded-xl bg-violet-500/10 text-violet-600 dark:text-violet-400 flex items-center justify-center text-lg">
<FiAward />
</div>
</div>
<div className="text-2xl font-black text-slate-900 dark:text-white font-mono">${pendingPayout.toLocaleString()}</div>
<Button
variant="primary"
size="xs"
onClick={handleRequestPayout}
disabled={payoutRequested}
className="mt-1"
>
{payoutRequested ? 'Retiro solicitado' : 'Solicitar Retiro'}
</Button>
</Card>
</div>
{/* Course Performance Summary */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-extrabold text-slate-900 dark:text-white">Rendimiento por Curso</h3>
<Button variant="ghost" size="xs" onClick={() => setActiveTab('my-courses')}>
Ver todos los cursos
</Button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
{courses.slice(0, 3).map((c) => (
<Card key={c.id} variant="interactive" padding="md" className="space-y-3">
<div className="flex items-center gap-3">
<img src={c.thumbnail} alt={c.title} className="w-14 h-10 rounded-lg object-cover border border-slate-300 dark:border-slate-700" />
<div className="min-w-0 flex-1">
<h4 className="font-bold text-xs text-slate-900 dark:text-white truncate">{c.title}</h4>
<span className="text-[10px] text-cyan-600 dark:text-cyan-400">{c.category}</span>
</div>
</div>
<div className="grid grid-cols-2 gap-2 pt-2 border-t border-slate-200 dark:border-slate-800 text-xs">
<div>
<span className="text-[10px] text-slate-500 dark:text-slate-400 block">Estudiantes</span>
<span className="font-bold font-mono text-slate-800 dark:text-slate-200">{c.studentsCount}</span>
</div>
<div>
<span className="text-[10px] text-slate-500 dark:text-slate-400 block">Ingresos</span>
<span className="font-bold font-mono text-emerald-600 dark:text-emerald-400">${(c.price * c.studentsCount * 0.8).toFixed(2)}</span>
</div>
</div>
</Card>
))}
</div>
</div>
</div>
)}
{/* TAB 2: MY COURSES */}
{activeTab === 'my-courses' && (
<div className="space-y-6">
{/* Search & Filter Bar */}
<div className="flex flex-col sm:flex-row items-center justify-between gap-4 bg-white dark:bg-slate-900 p-4 rounded-2xl border border-slate-200 dark:border-slate-800 shadow-sm">
<div className="w-full sm:max-w-md">
<Input
placeholder="Buscar entre mis cursos..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
leftIcon={<FiSearch className="text-slate-400" />}
/>
</div>
<Button variant="gradient" size="md" leftIcon={<FiPlusCircle />} onClick={onOpenCreateCourse}>
Crear Nuevo Curso
</Button>
</div>
{/* Courses Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{instructorCourses.map((c) => (
<Card key={c.id} variant="elevated" padding="none" className="overflow-hidden flex flex-col justify-between">
<div>
<div className="relative h-44">
<img src={c.thumbnail} alt={c.title} className="w-full h-full object-cover" />
<div className="absolute top-3 right-3">
<Badge variant="success" size="sm">Publicado</Badge>
</div>
<div className="absolute bottom-3 left-3">
<Badge variant="secondary" size="sm">{c.category}</Badge>
</div>
</div>
<div className="p-5 space-y-3">
<h3 className="font-bold text-sm text-slate-900 dark:text-white line-clamp-2">{c.title}</h3>
<div className="flex items-center justify-between text-xs text-slate-500 dark:text-slate-400 pt-1">
<span className="flex items-center gap-1"><FiUsers className="text-cyan-600 dark:text-cyan-400" /> {c.studentsCount} alumnos</span>
<span className="flex items-center gap-1"><FiStar className="text-amber-500 dark:text-amber-400" /> {c.rating}</span>
<span className="font-mono font-bold text-slate-900 dark:text-white">${c.price.toFixed(2)}</span>
</div>
</div>
</div>
<div className="p-4 bg-slate-100/80 dark:bg-slate-950/60 border-t border-slate-200 dark:border-slate-800 flex items-center justify-between gap-2">
<Button variant="outline" size="sm" leftIcon={<FiEye />} onClick={() => onPreviewCourse(c)}>
Ver Vista Previa
</Button>
<Button variant="ghost" size="sm" className="text-rose-600 dark:text-rose-400 hover:text-rose-700" onClick={() => onDeleteCourse(c.id)}>
<FiTrash2 />
</Button>
</div>
</Card>
))}
</div>
</div>
)}
{/* TAB 3: PAYOUTS */}
{activeTab === 'payouts' && (
<div className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<Card variant="elevated" padding="lg" className="space-y-4 md:col-span-1">
<h3 className="font-extrabold text-base text-slate-900 dark:text-white">Solicitud de Retiro</h3>
<div className="space-y-2">
<span className="text-xs text-slate-500 dark:text-slate-400">Saldo Disponible para Transferencia</span>
<div className="text-3xl font-black text-emerald-600 dark:text-emerald-400 font-mono">${pendingPayout.toFixed(2)}</div>
</div>
<p className="text-xs text-slate-500 dark:text-slate-400">
Las transferencias se procesan automáticamente a tu cuenta bancaria registrada o cuenta PayPal asociada.
</p>
<Button
variant="gradient"
fullWidth
size="md"
onClick={handleRequestPayout}
disabled={payoutRequested}
>
{payoutRequested ? 'Solicitud Enviada' : 'Transferir a mi Cuenta ($2,450.00)'}
</Button>
</Card>
{/* History Table */}
<Card variant="elevated" padding="none" className="md:col-span-2 overflow-hidden">
<div className="p-5 border-b border-slate-200 dark:border-slate-800 flex items-center justify-between">
<h3 className="font-bold text-sm text-slate-900 dark:text-white">Historial Reciente de Retiros</h3>
<Button variant="outline" size="xs" leftIcon={<FiDownload />}>
Exportar CSV
</Button>
</div>
<table className="w-full text-left text-xs text-slate-700 dark:text-slate-300">
<thead className="bg-slate-100 dark:bg-slate-950 text-slate-600 dark:text-slate-400 uppercase tracking-wider font-semibold border-b border-slate-200 dark:border-slate-800">
<tr>
<th className="p-4">Fecha</th>
<th className="p-4">Monto</th>
<th className="p-4">Método</th>
<th className="p-4">Estado</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-200 dark:divide-slate-800/80">
<tr>
<td className="p-4 font-mono">15 Feb 2026</td>
<td className="p-4 font-mono font-bold text-emerald-600 dark:text-emerald-400">$3,420.00</td>
<td className="p-4">Cuenta IBAN ****4921</td>
<td className="p-4"><Badge variant="success" size="sm">Completado</Badge></td>
</tr>
<tr>
<td className="p-4 font-mono">01 Feb 2026</td>
<td className="p-4 font-mono font-bold text-emerald-600 dark:text-emerald-400">$4,180.00</td>
<td className="p-4">PayPal (carlos@rycourses.pro)</td>
<td className="p-4"><Badge variant="success" size="sm">Completado</Badge></td>
</tr>
</tbody>
</table>
</Card>
</div>
</div>
)}
{/* TAB 4: REVIEWS */}
{activeTab === 'reviews' && (
<div className="space-y-4 max-w-3xl">
<Card variant="elevated" padding="md" className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="font-bold text-sm text-slate-900 dark:text-white">Marcos Rodríguez</span>
<span className="text-xs text-slate-500 dark:text-slate-400">• Curso: React & TypeScript Pro</span>
</div>
<div className="flex items-center text-amber-500 dark:text-amber-400 text-xs">
<FiStar /><FiStar /><FiStar /><FiStar /><FiStar />
</div>
</div>
<p className="text-xs text-slate-700 dark:text-slate-300 leading-relaxed">
"Explicación súper clara de la arquitectura de componentes y estado global. El proyecto final me ayudó a conseguir trabajo."
</p>
</Card>
<Card variant="elevated" padding="md" className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="font-bold text-sm text-slate-900 dark:text-white">Sofía Martinez</span>
<span className="text-xs text-slate-500 dark:text-slate-400">• Curso: UX/UI Masterclass</span>
</div>
<div className="flex items-center text-amber-500 dark:text-amber-400 text-xs">
<FiStar /><FiStar /><FiStar /><FiStar /><FiStar />
</div>
</div>
<p className="text-xs text-slate-700 dark:text-slate-300 leading-relaxed">
"Excelente contenido. Muy práctico y los recursos descargables son una maravilla."
</p>
</Card>
</div>
)}
</main>
</div>
</div>
);
};
+148
View File
@@ -0,0 +1,148 @@
import React from 'react';
import { Link } from 'react-router';
import { BrandLogo } from '../components/common/BrandLogo';
import { Card, Button, Badge } from '../components/ui';
import {
FiFileText,
FiShield,
FiLock,
FiCreditCard,
FiArrowLeft,
FiCheckCircle,
FiHelpCircle,
FiUserCheck
} from 'react-icons/fi';
export const TermsView: React.FC = () => {
return (
<div className="min-h-screen transition-colors duration-300 bg-slate-50 dark:bg-slate-950 text-slate-900 dark:text-slate-100 selection:bg-cyan-500 py-12 px-4 sm:px-6 lg:px-8">
{/* Container */}
<div className="max-w-4xl mx-auto space-y-10">
{/* Navigation & Header */}
<div className="flex items-center justify-between">
<Link to="/">
<BrandLogo size="md" />
</Link>
<Link to="/">
<Button variant="outline" size="sm" leftIcon={<FiArrowLeft />}>
Volver al inicio
</Button>
</Link>
</div>
{/* Hero Title */}
<div className="text-center space-y-4 pt-4">
<Badge variant="primary" size="lg">
<FiShield className="mr-1" /> Legal & Cumplimiento
</Badge>
<h1 className="text-3xl sm:text-5xl font-extrabold text-slate-900 dark:text-white tracking-tight">
Términos de Servicio y Políticas de Privacidad
</h1>
<p className="text-xs sm:text-sm text-slate-600 dark:text-slate-400 max-w-2xl mx-auto">
Última actualización: <strong className="text-slate-900 dark:text-slate-200">26 de Agosto de 2026</strong>. Por favor, lee atentamente estos términos antes de utilizar los servicios de RyCourses.
</p>
</div>
{/* Terms Content Cards */}
<div className="space-y-6">
{/* Section 1: Aceptación */}
<Card variant="elevated" padding="lg" className="space-y-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-cyan-500/10 text-cyan-600 dark:text-cyan-400 flex items-center justify-center text-xl shrink-0">
<FiFileText />
</div>
<h2 className="text-lg font-bold text-slate-900 dark:text-white">1. Aceptación de los Términos</h2>
</div>
<p className="text-xs sm:text-sm text-slate-600 dark:text-slate-300 leading-relaxed pl-13">
Al acceder, registrarte o utilizar la plataforma de educación en línea <strong>RyCourses</strong>, aceptas quedar vinculado legalmente por los presentes Términos de Servicio y por nuestra Política de Privacidad. Si no estás de acuerdo con alguno de estos términos, no debes utilizar nuestros servicios.
</p>
</Card>
{/* Section 2: Cuentas y Seguridad */}
<Card variant="elevated" padding="lg" className="space-y-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-violet-500/10 text-violet-600 dark:text-violet-400 flex items-center justify-center text-xl shrink-0">
<FiUserCheck />
</div>
<h2 className="text-lg font-bold text-slate-900 dark:text-white">2. Cuentas de Usuario y Seguridad</h2>
</div>
<div className="text-xs sm:text-sm text-slate-600 dark:text-slate-300 leading-relaxed pl-13 space-y-2">
<p>
Para acceder a la mayoría de las funciones de la plataforma (comprar cursos o publicar contenido como instructor), debes crear una cuenta personal.
</p>
<ul className="list-disc pl-5 space-y-1 text-slate-500 dark:text-slate-400">
<li>Debes proporcionar información verdadera, completa y actualizada durante el registro.</li>
<li>Eres responsable de mantener la confidencialidad de tus credenciales de acceso.</li>
<li>No está permitido compartir accesos de cuenta entre múltiples usuarios.</li>
</ul>
</div>
</Card>
{/* Section 3: Pagos y Reembolsos */}
<Card variant="elevated" padding="lg" className="space-y-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 flex items-center justify-center text-xl shrink-0">
<FiCreditCard />
</div>
<h2 className="text-lg font-bold text-slate-900 dark:text-white">3. Pagos, Precios y Garantía de Reembolso</h2>
</div>
<div className="text-xs sm:text-sm text-slate-600 dark:text-slate-300 leading-relaxed pl-13 space-y-2">
<p>
Todos los pagos procesados en RyCourses se realizan mediante pasarelas de pago cifradas de grado bancario (Stripe y PayPal).
</p>
<div className="p-4 rounded-xl bg-emerald-500/10 border border-emerald-500/20 text-emerald-700 dark:text-emerald-300 text-xs font-semibold flex items-center gap-2">
<FiCheckCircle className="text-base shrink-0" />
Garantía de reembolso de 30 días sin preguntas si no estás satisfecho con tu curso.
</div>
</div>
</Card>
{/* Section 4: Propiedad Intelectual */}
<Card variant="elevated" padding="lg" className="space-y-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-amber-500/10 text-amber-600 dark:text-amber-400 flex items-center justify-center text-xl shrink-0">
<FiShield />
</div>
<h2 className="text-lg font-bold text-slate-900 dark:text-white">4. Propiedad Intelectual y Derechos de Autor</h2>
</div>
<p className="text-xs sm:text-sm text-slate-600 dark:text-slate-300 leading-relaxed pl-13">
Todo el contenido audiovisual, código fuente, logotipos y material didáctico publicado en RyCourses pertenece a sus respectivos autores e instructores. La compra de un curso concede una licencia de acceso personal, no exclusiva e intransferible. Queda estrictamente prohibida la reproducción, redistribución o reventa no autorizada del contenido.
</p>
</Card>
{/* Section 5: Privacidad de Datos */}
<Card variant="elevated" padding="lg" className="space-y-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-sky-500/10 text-sky-600 dark:text-sky-400 flex items-center justify-center text-xl shrink-0">
<FiLock />
</div>
<h2 className="text-lg font-bold text-slate-900 dark:text-white">5. Política de Privacidad y Protección de Datos (GDPR)</h2>
</div>
<p className="text-xs sm:text-sm text-slate-600 dark:text-slate-300 leading-relaxed pl-13">
En RyCourses nos tomamos muy en serio tu privacidad. Tus datos personales son almacenados con encriptación de 256 bits y nunca serán vendidos ni comercializados con terceros. Utilizaremos tu correo electrónico únicamente para notificaciones de cuenta, avances de tus cursos y actualizaciones importantes de la plataforma.
</p>
</Card>
</div>
{/* Footer Contact CTA */}
<div className="text-center pt-6 space-y-3">
<p className="text-xs text-slate-500 dark:text-slate-400">
¿Tienes alguna duda sobre nuestros Términos de Servicio o Privacidad?
</p>
<a href="mailto:[email protected]">
<Button variant="outline" size="sm" leftIcon={<FiHelpCircle />}>
Contactar al equipo legal (soporte@rycourses.com)
</Button>
</a>
</div>
</div>
</div>
);
};