feat: sistema global de modais nativos do app corrige erro salvar curso e remove todos alert/confirm do navegador
Build and Deploy (Gitea) / build-and-deploy (push) Successful in 1m2s Details

This commit is contained in:
Sidney Gomes 2026-07-24 22:12:42 -03:00
parent 0c83b67025
commit 00f12e721d
9 changed files with 345 additions and 91 deletions

View File

@ -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' });
}
});

View File

@ -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 (
<ErrorBoundary>
<div className="relative min-h-screen">
{isAdmin ? <AdminApp /> : <App />}
</div>
<ModalProvider>
<div className="relative min-h-screen">
{isAdmin ? <AdminApp /> : <App />}
</div>
</ModalProvider>
</ErrorBoundary>
);
}

View File

@ -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<Course[]>([]);
const [selectedCourse, setSelectedCourse] = useState<Course | null>(null);
const [modules, setModules] = useState<Module[]>([]);
@ -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) {

View File

@ -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<DashboardStats | null>(null);
const [payments, setPayments] = useState<PaymentLog[]>([]);
const [webhookLogs, setWebhookLogs] = useState<WebhookLog[]>([]);
@ -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);

View File

@ -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<AdminPricingManagerProps> = ({ courses, fetchCourses }) => {
const { confirm: showConfirm, success: showSuccess, error: showError } = useModal();
const [plans, setPlans] = useState<Plan[]>([]);
const [loading, setLoading] = useState(true);
const [saveLoading, setSaveLoading] = useState<string | null>(null);
@ -155,18 +157,22 @@ export const AdminPricingManager: React.FC<AdminPricingManagerProps> = ({ 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) => {

View File

@ -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<any>(null);
const [answers, setAnswers] = useState<number[]>([]);
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);

View File

@ -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);
}

View File

@ -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<void>;
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<void>, options?: Partial<ModalOptions>) => void;
}
// ---------------------------------------------------------------------------
// Context
// ---------------------------------------------------------------------------
const ModalContext = createContext<ModalContextValue | null>(null);
export const useModal = () => {
const ctx = useContext(ModalContext);
if (!ctx) throw new Error('useModal must be used inside <ModalProvider>');
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<void>, options: Partial<ModalOptions> = {}) => {
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<ModalType, React.ReactNode> = {
alert: <AlertTriangle className="w-7 h-7 text-amber-400" />,
confirm: modal?.danger ? <Trash2 className="w-7 h-7 text-red-400" /> : <HelpCircle className="w-7 h-7 text-blue-400" />,
success: <CheckCircle className="w-7 h-7 text-emerald-400" />,
error: <AlertTriangle className="w-7 h-7 text-red-400" />,
info: <Info className="w-7 h-7 text-blue-400" />,
};
const bgMap: Record<ModalType, string> = {
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<string, string> = {
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 (
<ModalContext.Provider value={{ showModal, alert, success, error, confirm }}>
{children}
{/* Modal overlay */}
{modal?.isOpen && (
<div
className="fixed inset-0 z-[9999] flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm animate-fade-in"
onClick={(e) => { if (e.target === e.currentTarget && type !== 'confirm') handleCancel(); }}
>
<div className="relative w-full max-w-md bg-[#1a1a1a] border border-white/10 rounded-2xl shadow-2xl overflow-hidden animate-slide-up">
{/* Top icon stripe */}
<div className={`border-b border-white/5 p-5 flex items-center gap-4 ${bgMap[type]}`}>
<div className="flex-shrink-0">
{iconMap[type]}
</div>
<div>
<h3 className="font-display font-black text-base text-white leading-tight">
{modal.title || 'Aviso'}
</h3>
</div>
{type !== 'confirm' && (
<button
onClick={handleCancel}
className="absolute top-4 right-4 text-zinc-500 hover:text-white transition-colors cursor-pointer"
>
<X className="w-4 h-4" />
</button>
)}
</div>
{/* Body */}
<div className="p-6">
<p className="text-sm text-zinc-300 leading-relaxed whitespace-pre-wrap">
{modal.message}
</p>
</div>
{/* Actions */}
<div className="px-6 pb-6 flex gap-3 justify-end">
{type === 'confirm' && (
<button
onClick={handleCancel}
className="px-5 py-2.5 text-sm font-bold text-zinc-300 bg-zinc-800 hover:bg-zinc-700 border border-white/10 rounded-xl transition-colors cursor-pointer"
>
{modal.cancelLabel || 'Cancelar'}
</button>
)}
<button
onClick={type === 'confirm' ? handleConfirm : handleCancel}
disabled={isConfirming}
className={`px-5 py-2.5 text-sm font-bold rounded-xl transition-colors cursor-pointer disabled:opacity-60 flex items-center gap-2 ${getConfirmBtnClass()}`}
>
{isConfirming && (
<svg className="w-3.5 h-3.5 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"/>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8z"/>
</svg>
)}
{type === 'confirm' ? (modal.confirmLabel || 'Confirmar') : 'OK'}
</button>
</div>
</div>
</div>
)}
</ModalContext.Provider>
);
};
export default ModalProvider;

View File

@ -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); }