diff --git a/server.ts b/server.ts index 0ca365b..365aebe 100644 --- a/server.ts +++ b/server.ts @@ -1533,24 +1533,26 @@ app.post('/api/admin/courses', authenticateToken, requireAdmin, async (req: Auth }); app.put('/api/admin/courses/:id', authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res: Response) => { - const { title, description, thumbnail, category, isLocked, price } = req.body; + const { title, description, thumbnail, category, isLocked, price, certificateMode } = req.body; const courseId = req.params.id; try { const course = await prisma.course.update({ where: { id: courseId }, data: { - ...(title && { title }), - ...(description && { description }), + ...(title !== undefined && { title }), + ...(description !== undefined && { description }), ...(thumbnail && { thumbnailUrl: thumbnail }), - ...(category && { category }), + ...(category !== undefined && { category }), ...(isLocked !== undefined && { isLocked: isLocked === true || isLocked === 'true' }), - ...(price !== undefined && { price: Number(price) }) + ...(price !== undefined && { price: Number(price) }), + ...(certificateMode && { certificateMode }), } }); res.json(course); - } catch (error) { - res.status(404).json({ error: 'Curso não encontrado' }); + } catch (error: any) { + console.error('Error updating course:', error); + res.status(400).json({ error: error?.message || 'Erro ao atualizar curso' }); } }); diff --git a/src/Root.tsx b/src/Root.tsx index 8c8e29d..da13049 100644 --- a/src/Root.tsx +++ b/src/Root.tsx @@ -2,6 +2,7 @@ import React, { useState } from 'react'; import App from './App.tsx'; import AdminApp from './AdminApp.tsx'; import { ErrorBoundary } from './components/ErrorBoundary.tsx'; +import { ModalProvider } from './contexts/ModalContext.tsx'; export default function Root() { const [simulatedDomain] = useState<'student' | 'admin'>(() => { @@ -35,9 +36,11 @@ export default function Root() { return ( - - {isAdmin ? : } - + + + {isAdmin ? : } + + ); } diff --git a/src/components/AdminContentManager.tsx b/src/components/AdminContentManager.tsx index c4e4d4c..503fea0 100644 --- a/src/components/AdminContentManager.tsx +++ b/src/components/AdminContentManager.tsx @@ -3,6 +3,7 @@ import { GraduationCap, Plus, Edit, Trash2, FolderPlus, FilePlus, ChevronRight, ArrowLeft, Film, Youtube, RefreshCw, Layers, CheckCircle, CheckCircle2 } from 'lucide-react'; +import { useModal } from '../contexts/ModalContext'; interface Lesson { id: string; @@ -39,6 +40,7 @@ interface AdminContentManagerProps { } export default function AdminContentManager({ token }: AdminContentManagerProps) { + const { alert: showAlert, success: showSuccess, error: showError, confirm: showConfirm } = useModal(); const [courses, setCourses] = useState([]); const [selectedCourse, setSelectedCourse] = useState(null); const [modules, setModules] = useState([]); @@ -92,11 +94,11 @@ export default function AdminContentManager({ token }: AdminContentManagerProps) if (res.ok && data.url) { setCourseForm(prev => ({ ...prev, thumbnail: data.url })); } else { - alert(data.error || 'Erro ao fazer upload da imagem'); + showError(data.error || 'Erro ao fazer upload da imagem'); } } catch (err) { console.error(err); - alert('Erro de conexão ao fazer upload'); + showError('Erro de conexão ao fazer upload'); } finally { setUploadingImage(false); } @@ -124,11 +126,11 @@ export default function AdminContentManager({ token }: AdminContentManagerProps) if (res.ok && data.url) { setLessonForm(prev => ({ ...prev, thumbnailUrl: data.url })); } else { - alert(data.error || 'Erro ao fazer upload da imagem'); + showError(data.error || 'Erro ao fazer upload da imagem'); } } catch (err) { console.error(err); - alert('Erro de conexão ao fazer upload'); + showError('Erro de conexão ao fazer upload'); } finally { setUploadingLessonImage(false); } @@ -198,13 +200,14 @@ export default function AdminContentManager({ token }: AdminContentManagerProps) setShowCourseForm(false); setEditingCourse(null); setCourseForm({ title: '', description: '', thumbnail: '', category: '', isLocked: false, price: 0, certificateMode: 'FULL_COURSE' }); + showSuccess(editingCourse ? 'Curso atualizado com sucesso!' : 'Curso criado com sucesso!'); } else { const errData = await res.json(); - alert(`Erro ao salvar curso: ${errData.error || 'Falha na resposta do servidor'}`); + showError(`Erro ao salvar curso: ${errData.error || 'Falha na resposta do servidor'}`); } } catch (err) { console.error('Error saving course:', err); - alert('Erro de rede ao salvar curso.'); + showError('Erro de rede ao salvar curso.'); } }; @@ -225,25 +228,28 @@ export default function AdminContentManager({ token }: AdminContentManagerProps) }, 50); }; - const handleDeleteCourse = async (courseId: string) => { - if (!window.confirm('Tem certeza que deseja excluir este curso e todo o seu conteúdo (módulos, aulas, anotações)?')) return; - - try { - const res = await fetch(`/api/admin/courses/${courseId}`, { - method: 'DELETE', - headers: { 'Authorization': `Bearer ${token}` } - }); - - if (res.ok) { - fetchCourses(); - if (selectedCourse?.id === courseId) { - setSelectedCourse(null); - setModules([]); + const handleDeleteCourse = (courseId: string) => { + showConfirm( + 'Tem certeza que deseja excluir este curso e todo o seu conteúdo (módulos, aulas, anotações)?', + async () => { + try { + const res = await fetch(`/api/admin/courses/${courseId}`, { + method: 'DELETE', + headers: { 'Authorization': `Bearer ${token}` } + }); + if (res.ok) { + fetchCourses(); + if (selectedCourse?.id === courseId) { + setSelectedCourse(null); + setModules([]); + } + } + } catch (err) { + console.error('Error deleting course:', err); } - } - } catch (err) { - console.error('Error deleting course:', err); - } + }, + { title: 'Excluir Curso', confirmLabel: 'Sim, Excluir', danger: true } + ); }; // --- MODULE CRUD --- @@ -285,22 +291,23 @@ export default function AdminContentManager({ token }: AdminContentManagerProps) }, 50); }; - const handleDeleteModule = async (modId: string) => { - if (!window.confirm('Excluir este módulo apagará todas as suas aulas associadas. Continuar?')) return; + const handleDeleteModule = (modId: string) => { if (!selectedCourse) return; - - try { - const res = await fetch(`/api/admin/modules/${modId}`, { - method: 'DELETE', - headers: { 'Authorization': `Bearer ${token}` } - }); - - if (res.ok) { - fetchCourseModulesAndLessons(selectedCourse.id); - } - } catch (err) { - console.error('Error deleting module:', err); - } + showConfirm( + 'Excluir este módulo apagará todas as suas aulas associadas. Continuar?', + async () => { + try { + const res = await fetch(`/api/admin/modules/${modId}`, { + method: 'DELETE', + headers: { 'Authorization': `Bearer ${token}` } + }); + if (res.ok) fetchCourseModulesAndLessons(selectedCourse.id); + } catch (err) { + console.error('Error deleting module:', err); + } + }, + { title: 'Excluir Módulo', confirmLabel: 'Sim, Excluir', danger: true } + ); }; // --- LESSON CRUD --- @@ -399,34 +406,35 @@ export default function AdminContentManager({ token }: AdminContentManagerProps) body: JSON.stringify({ questions: activityQuestions }) }); if (res.ok) { - alert('Atividade salva com sucesso!'); + showSuccess('Atividade salva com sucesso!'); setShowActivityModal(false); } else { - alert('Erro ao salvar atividade.'); + showError('Erro ao salvar atividade.'); } } catch (err) { console.error('Error saving activity:', err); - alert('Erro ao salvar atividade.'); + showError('Erro ao salvar atividade.'); } }; - const handleDeleteLesson = async (lessonId: string) => { - if (!window.confirm('Excluir esta aula?')) return; + const handleDeleteLesson = (lessonId: string) => { if (!selectedCourse) return; - - try { - const res = await fetch(`/api/admin/lessons/${lessonId}`, { - method: 'DELETE', - headers: { 'Authorization': `Bearer ${token}` } - }); - - if (res.ok) { - fetchCourseModulesAndLessons(selectedCourse.id); - } - } catch (err) { - console.error('Error deleting lesson:', err); - } + showConfirm( + 'Excluir esta aula? Esta ação não pode ser desfeita.', + async () => { + try { + const res = await fetch(`/api/admin/lessons/${lessonId}`, { + method: 'DELETE', + headers: { 'Authorization': `Bearer ${token}` } + }); + if (res.ok) fetchCourseModulesAndLessons(selectedCourse.id); + } catch (err) { + console.error('Error deleting lesson:', err); + } + }, + { title: 'Excluir Aula', confirmLabel: 'Sim, Excluir', danger: true } + ); }; if (isLoading && courses.length === 0) { diff --git a/src/components/AdminDashboard.tsx b/src/components/AdminDashboard.tsx index 515b2fb..c0000ab 100644 --- a/src/components/AdminDashboard.tsx +++ b/src/components/AdminDashboard.tsx @@ -1,4 +1,5 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useRef } from 'react'; +import { useModal } from '../contexts/ModalContext'; import { TrendingUp, Users, AlertTriangle, XCircle, Search, Settings, CheckCircle2, RefreshCw, Link as LinkIcon, ShieldCheck, HelpCircle, Eye, DollarSign, @@ -66,6 +67,7 @@ interface AdminDashboardProps { } export default function AdminDashboard({ token, adminUser, onLogout }: AdminDashboardProps) { + const { alert: showAlert, success: showSuccess, error: showError, confirm: showConfirm } = useModal(); const [stats, setStats] = useState(null); const [payments, setPayments] = useState([]); const [webhookLogs, setWebhookLogs] = useState([]); @@ -128,11 +130,11 @@ export default function AdminDashboard({ token, adminUser, onLogout }: AdminDash if (res.ok) { setShowSaveSuccessModal(true); } else { - alert('Erro ao salvar configurações.'); + showError('Erro ao salvar configurações.'); } } catch (err) { console.error('Error saving settings:', err); - alert('Erro de conexão ao salvar configurações.'); + showError('Erro de conexão ao salvar configurações.'); } finally { setIsSavingSettings(false); } @@ -305,11 +307,12 @@ export default function AdminDashboard({ token, adminUser, onLogout }: AdminDash }, body: JSON.stringify({ type, targetId, courseName, moduleName }) }); - if (token) { + if (res.ok) { fetchDashboardData(); fetchCourses(); + showSuccess('Certificado liberado manualmente!'); } else { - alert('Certificado liberado manualmente!'); + showError('Erro ao liberar certificado.'); } } catch (err) { console.error('Error unlocking certificate:', err); @@ -762,7 +765,7 @@ export default function AdminDashboard({ token, adminUser, onLogout }: AdminDash if (res.ok) { fetchStudents(); } else { - alert('Erro ao atualizar permissão de curso.'); + showError('Erro ao atualizar permissão de curso.'); } } catch (err) { console.error(err); diff --git a/src/components/AdminPricingManager.tsx b/src/components/AdminPricingManager.tsx index 171719e..20bea2d 100644 --- a/src/components/AdminPricingManager.tsx +++ b/src/components/AdminPricingManager.tsx @@ -1,6 +1,7 @@ import React, { useState, useEffect } from 'react'; import { RefreshCw, Save, Plus, Trash2, Edit, CheckCircle2, AlertTriangle, BookOpen, Layers, DollarSign } from 'lucide-react'; import { Course, Plan } from '../types'; +import { useModal } from '../contexts/ModalContext'; interface AdminPricingManagerProps { courses: Course[]; @@ -80,6 +81,7 @@ function CoursePricingCard({ course, saveLoading, onSave }: CoursePricingCardPro } export const AdminPricingManager: React.FC = ({ courses, fetchCourses }) => { + const { confirm: showConfirm, success: showSuccess, error: showError } = useModal(); const [plans, setPlans] = useState([]); const [loading, setLoading] = useState(true); const [saveLoading, setSaveLoading] = useState(null); @@ -155,18 +157,22 @@ export const AdminPricingManager: React.FC = ({ course } }; - const handleDeletePlan = async (planId: string) => { - if (!window.confirm('Tem certeza que deseja excluir este plano?')) return; - - try { - await fetch(`/api/admin/plans/${planId}`, { - method: 'DELETE', - headers: { 'Authorization': `Bearer ${token}` } - }); - fetchPlans(); - } catch (err) { - console.error(err); - } + const handleDeletePlan = (planId: string) => { + showConfirm( + 'Tem certeza que deseja excluir este plano? Alunos vinculados não serão afetados imediatamente.', + async () => { + try { + await fetch(`/api/admin/plans/${planId}`, { + method: 'DELETE', + headers: { 'Authorization': `Bearer ${token}` } + }); + fetchPlans(); + } catch (err) { + console.error(err); + } + }, + { title: 'Excluir Plano', confirmLabel: 'Sim, Excluir', danger: true } + ); }; const toggleCourseInPlan = (courseId: string) => { diff --git a/src/components/CourseActivity.tsx b/src/components/CourseActivity.tsx index bc2f702..38518d1 100644 --- a/src/components/CourseActivity.tsx +++ b/src/components/CourseActivity.tsx @@ -1,5 +1,6 @@ import React, { useState, useEffect } from 'react'; import { ArrowLeft, CheckCircle, XCircle, Award } from 'lucide-react'; +import { useModal } from '../contexts/ModalContext'; interface CourseActivityProps { token: string | null; @@ -9,6 +10,7 @@ interface CourseActivityProps { } export default function CourseActivity({ token, moduleId, onBack, onCompleted }: CourseActivityProps) { + const { alert: showAlert, error: showError } = useModal(); const [activity, setActivity] = useState(null); const [answers, setAnswers] = useState([]); const [isLoading, setIsLoading] = useState(true); @@ -46,7 +48,7 @@ export default function CourseActivity({ token, moduleId, onBack, onCompleted }: const handleSubmit = async () => { if (answers.includes(-1)) { - alert('Por favor, responda todas as perguntas.'); + showAlert('Por favor, responda todas as perguntas antes de enviar.'); return; } @@ -68,7 +70,7 @@ export default function CourseActivity({ token, moduleId, onBack, onCompleted }: onCompleted(true); } } else { - alert('Erro ao enviar atividade.'); + showError('Erro ao enviar atividade. Tente novamente.'); } } catch (err) { console.error('Error submitting activity:', err); diff --git a/src/components/ProfileView.tsx b/src/components/ProfileView.tsx index 756f210..7303df2 100644 --- a/src/components/ProfileView.tsx +++ b/src/components/ProfileView.tsx @@ -1,6 +1,7 @@ import React, { useState, useRef } from 'react'; import { User as UserIcon, Shield, CreditCard, Mail, Phone, Calendar, ArrowLeft, Camera, Edit2, Save, X, Loader2 } from 'lucide-react'; import { User } from '../types'; +import { useModal } from '../contexts/ModalContext'; interface ProfileViewProps { user: User; @@ -9,6 +10,7 @@ interface ProfileViewProps { } export default function ProfileView({ user, onBack, onUpdateUser }: ProfileViewProps) { + const { error: showError, success: showSuccess } = useModal(); const [isEditing, setIsEditing] = useState(false); const [name, setName] = useState(user.name); const [whatsapp, setWhatsapp] = useState(user.whatsapp || ''); @@ -56,10 +58,10 @@ export default function ProfileView({ user, onBack, onUpdateUser }: ProfileViewP onUpdateUser(data.user); setIsEditing(false); } else { - alert(data.error || 'Erro ao atualizar perfil'); + showError(data.error || 'Erro ao atualizar perfil'); } } catch (err) { - alert('Erro de conexão ao salvar perfil'); + showError('Erro de conexão ao salvar perfil'); } finally { setIsSaving(false); } @@ -86,10 +88,10 @@ export default function ProfileView({ user, onBack, onUpdateUser }: ProfileViewP if (data.success && onUpdateUser) { onUpdateUser({ ...user, avatarUrl: data.avatarUrl }); } else { - alert(data.error || 'Erro ao fazer upload da foto'); + showError(data.error || 'Erro ao fazer upload da foto'); } } catch (err) { - alert('Erro de conexão ao enviar foto'); + showError('Erro de conexão ao enviar foto'); } finally { setIsUploading(false); } diff --git a/src/contexts/ModalContext.tsx b/src/contexts/ModalContext.tsx new file mode 100644 index 0000000..adf630c --- /dev/null +++ b/src/contexts/ModalContext.tsx @@ -0,0 +1,203 @@ +import React, { createContext, useContext, useState, useCallback, ReactNode } from 'react'; +import { AlertTriangle, CheckCircle, Info, HelpCircle, X, Trash2 } from 'lucide-react'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type ModalType = 'alert' | 'confirm' | 'success' | 'error' | 'info'; + +interface ModalOptions { + type?: ModalType; + title?: string; + message: string; + confirmLabel?: string; + cancelLabel?: string; + onConfirm?: () => void | Promise; + onCancel?: () => void; + /** Set to true for destructive (delete) confirmations */ + danger?: boolean; +} + +interface ModalContextValue { + showModal: (options: ModalOptions) => void; + alert: (message: string, title?: string) => void; + success: (message: string, title?: string) => void; + error: (message: string, title?: string) => void; + confirm: (message: string, onConfirm: () => void | Promise, options?: Partial) => void; +} + +// --------------------------------------------------------------------------- +// Context +// --------------------------------------------------------------------------- + +const ModalContext = createContext(null); + +export const useModal = () => { + const ctx = useContext(ModalContext); + if (!ctx) throw new Error('useModal must be used inside '); + return ctx; +}; + +// --------------------------------------------------------------------------- +// Provider +// --------------------------------------------------------------------------- + +export const ModalProvider: React.FC<{ children: ReactNode }> = ({ children }) => { + const [modal, setModal] = useState<(ModalOptions & { isOpen: boolean }) | null>(null); + const [isConfirming, setIsConfirming] = useState(false); + + const close = useCallback(() => { + setModal(null); + setIsConfirming(false); + }, []); + + const showModal = useCallback((options: ModalOptions) => { + setModal({ ...options, isOpen: true }); + }, []); + + const alert = useCallback((message: string, title = 'Aviso') => { + showModal({ type: 'alert', title, message }); + }, [showModal]); + + const success = useCallback((message: string, title = 'Sucesso') => { + showModal({ type: 'success', title, message }); + }, [showModal]); + + const error = useCallback((message: string, title = 'Erro') => { + showModal({ type: 'error', title, message }); + }, [showModal]); + + const confirm = useCallback( + (message: string, onConfirm: () => void | Promise, options: Partial = {}) => { + showModal({ + type: 'confirm', + title: options.title || 'Confirmar', + message, + confirmLabel: options.confirmLabel || 'Confirmar', + cancelLabel: options.cancelLabel || 'Cancelar', + danger: options.danger, + onConfirm, + }); + }, + [showModal] + ); + + const handleConfirm = async () => { + if (!modal?.onConfirm) { close(); return; } + setIsConfirming(true); + try { + await modal.onConfirm(); + } finally { + close(); + } + }; + + const handleCancel = () => { + modal?.onCancel?.(); + close(); + }; + + const type = modal?.type || 'alert'; + + const iconMap: Record = { + alert: , + confirm: modal?.danger ? : , + success: , + error: , + info: , + }; + + const bgMap: Record = { + alert: 'bg-amber-500/10 border-amber-500/20', + confirm: modal?.danger ? 'bg-red-500/10 border-red-500/20' : 'bg-blue-500/10 border-blue-500/20', + success: 'bg-emerald-500/10 border-emerald-500/20', + error: 'bg-red-500/10 border-red-500/20', + info: 'bg-blue-500/10 border-blue-500/20', + }; + + const confirmBtnMap: Record = { + alert: 'bg-amber-500 hover:bg-amber-400 text-black', + success: 'bg-emerald-600 hover:bg-emerald-500 text-white', + error: 'bg-red-600 hover:bg-red-500 text-white', + info: 'bg-blue-600 hover:bg-blue-500 text-white', + confirm_normal: 'bg-blue-600 hover:bg-blue-500 text-white', + confirm_danger: 'bg-red-600 hover:bg-red-500 text-white', + }; + + const getConfirmBtnClass = () => { + if (type === 'confirm') return modal?.danger ? confirmBtnMap['confirm_danger'] : confirmBtnMap['confirm_normal']; + return confirmBtnMap[type] || confirmBtnMap['alert']; + }; + + return ( + + {children} + + {/* Modal overlay */} + {modal?.isOpen && ( + { if (e.target === e.currentTarget && type !== 'confirm') handleCancel(); }} + > + + + {/* Top icon stripe */} + + + {iconMap[type]} + + + + {modal.title || 'Aviso'} + + + {type !== 'confirm' && ( + + + + )} + + + {/* Body */} + + + {modal.message} + + + + {/* Actions */} + + {type === 'confirm' && ( + + {modal.cancelLabel || 'Cancelar'} + + )} + + {isConfirming && ( + + + + + )} + {type === 'confirm' ? (modal.confirmLabel || 'Confirmar') : 'OK'} + + + + + )} + + ); +}; + +export default ModalProvider; diff --git a/src/index.css b/src/index.css index 890092c..6c853e8 100644 --- a/src/index.css +++ b/src/index.css @@ -95,3 +95,28 @@ html, body { backdrop-filter: blur(6px); border: 1px solid rgba(255, 255, 255, 0.08); } + +/* ── Modal system animations ── */ +@keyframes fade-in { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes slide-up { + from { opacity: 0; transform: translateY(20px) scale(0.97); } + to { opacity: 1; transform: translateY(0) scale(1); } +} + +.animate-fade-in { + animation: fade-in 0.18s ease-out forwards; +} + +.animate-slide-up { + animation: slide-up 0.22s cubic-bezier(0.4, 0, 0.2, 1) forwards; +} + +/* ── Custom scrollbar ── */ +.custom-scrollbar::-webkit-scrollbar { width: 4px; height: 4px; } +.custom-scrollbar::-webkit-scrollbar-track { background: transparent; } +.custom-scrollbar::-webkit-scrollbar-thumb { background: rgba(229,9,20,0.35); border-radius: 99px; } +.custom-scrollbar::-webkit-scrollbar-thumb:hover { background: rgba(229,9,20,0.6); }
+ {modal.message} +