From 99f7b8b9eeb20703b75d159b5fee89b240560141 Mon Sep 17 00:00:00 2001 From: Sidney Gomes Date: Sat, 25 Jul 2026 11:24:51 -0300 Subject: [PATCH] =?UTF-8?q?Implementa=20c=C3=ADrculos=20de=20progresso=20c?= =?UTF-8?q?om=20%=20no=20centro=20e=20curtidas=20de=20aula=20por=20aluno?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- prisma/schema.prisma | 16 +++++ server.ts | 61 +++++++++++++++- src/components/AdminDashboard.tsx | 114 +++++++++++++++++++++++++++++- src/components/CourseView.tsx | 60 +++++++++++++--- src/components/VideoPlayer.tsx | 25 ++++++- src/types.ts | 5 ++ 6 files changed, 263 insertions(+), 18 deletions(-) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b15265f..49d0926 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -76,6 +76,7 @@ model User { certificates Certificate[] unlockedCourses String[] @default([]) @map("unlocked_courses") supportTickets SupportTicket[] + lessonReactions LessonReaction[] @@map("users") } @@ -127,6 +128,7 @@ model Lesson { module Module @relation(fields: [moduleId], references: [id], onDelete: Cascade) notes Note[] progress Progress[] + reactions LessonReaction[] @@map("lessons") } @@ -160,6 +162,20 @@ model Progress { @@map("progress") } +model LessonReaction { + id String @id @default(uuid()) + userId String @map("user_id") + lessonId String @map("lesson_id") + type String // 'LIKE' or 'DISLIKE' + createdAt DateTime @default(now()) @map("created_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + lesson Lesson @relation(fields: [lessonId], references: [id], onDelete: Cascade) + + @@unique([userId, lessonId]) + @@map("lesson_reactions") +} + model Payment { id String @id userId String @map("user_id") diff --git a/server.ts b/server.ts index 3023a18..c77d80f 100644 --- a/server.ts +++ b/server.ts @@ -631,10 +631,15 @@ app.get('/api/courses/:id', authenticateToken, async (req: AuthenticatedRequest, where: { userId, lesson: { moduleId: { in: course.modules.map(m => m.id) } } } }); + const reactionRecords = await prisma.lessonReaction.findMany({ + where: { userId, lesson: { moduleId: { in: course.modules.map(m => m.id) } } } + }); + const modulesWithLessons = course.modules.map(mod => { const lessons = mod.lessons.map(lesson => { const prog = progressRecords.find(p => p.lessonId === lesson.id); const note = noteRecords.find(n => n.lessonId === lesson.id); + const reaction = reactionRecords.find(r => r.lessonId === lesson.id); return { ...lesson, @@ -642,7 +647,8 @@ app.get('/api/courses/:id', authenticateToken, async (req: AuthenticatedRequest, watchedPercentage: prog.watchedPercentage, completed: prog.completed } : { watchedPercentage: 0, completed: false }, - note: note ? note.content : '' + note: note ? note.content : '', + reaction: reaction ? reaction.type : null }; }); @@ -870,6 +876,49 @@ app.post('/api/lessons/:id/progress', authenticateToken, async (req: Authenticat } }); +// Lesson Reaction (Like / Dislike) +app.post('/api/lessons/:id/react', authenticateToken, async (req: AuthenticatedRequest, res: Response) => { + const { type } = req.body; // 'LIKE' or 'DISLIKE' or null (to remove) + const lessonId = req.params.id; + const userId = req.user!.id; + + try { + const existing = await prisma.lessonReaction.findUnique({ + where: { userId_lessonId: { userId, lessonId } } + }); + + if (!type) { + if (existing) { + await prisma.lessonReaction.delete({ + where: { id: existing.id } + }); + } + res.json({ success: true, action: 'removed' }); + return; + } + + if (existing) { + const updated = await prisma.lessonReaction.update({ + where: { id: existing.id }, + data: { type } + }); + res.json({ success: true, action: 'updated', reaction: updated }); + } else { + const created = await prisma.lessonReaction.create({ + data: { + userId, + lessonId, + type + } + }); + res.json({ success: true, action: 'created', reaction: created }); + } + } catch (err: any) { + console.error('Error on /api/lessons/:id/react:', err); + res.status(500).json({ error: 'Erro ao processar reação' }); + } +}); + // 7. Lesson Notes and Doubts app.get('/api/lessons/:id/notes', authenticateToken, async (req: AuthenticatedRequest, res: Response) => { const lessonId = req.params.id; @@ -1237,7 +1286,9 @@ app.get('/api/admin/students', authenticateToken, requireAdmin, async (req: Auth include: { payments: true, moduleGrades: true, - certificates: true + certificates: true, + lessonReactions: true, + progress: true } }); @@ -1258,7 +1309,11 @@ app.get('/api/admin/students', authenticateToken, requireAdmin, async (req: Auth unlockedCourses: u.unlockedCourses || [], moduleGrades: u.moduleGrades, certificates: u.certificates, - payments: u.payments.map(p => ({ ...p, value: Number(p.value) })) + payments: u.payments.map(p => ({ ...p, value: Number(p.value) })), + totalLikes: u.lessonReactions?.filter(r => r.type === 'LIKE').length || 0, + totalDislikes: u.lessonReactions?.filter(r => r.type === 'DISLIKE').length || 0, + lessonReactions: u.lessonReactions || [], + progress: u.progress || [] }; }); diff --git a/src/components/AdminDashboard.tsx b/src/components/AdminDashboard.tsx index b97f541..fea8de6 100644 --- a/src/components/AdminDashboard.tsx +++ b/src/components/AdminDashboard.tsx @@ -3,7 +3,7 @@ import { useModal } from '../contexts/ModalContext'; import { TrendingUp, Users, AlertTriangle, XCircle, Search, Settings, CheckCircle2, RefreshCw, Link as LinkIcon, ShieldCheck, HelpCircle, Eye, DollarSign, - Shield, Layers, LogOut, Menu, X, GraduationCap, Mail + Shield, Layers, LogOut, Menu, X, GraduationCap, Mail, ThumbsUp, ThumbsDown } from 'lucide-react'; import { LineChart, Line, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip as RechartsTooltip, ResponsiveContainer, Legend, Cell, PieChart, Pie } from 'recharts'; import { SubscriptionStatus } from '../types'; @@ -82,7 +82,8 @@ export default function AdminDashboard({ token, adminUser, onLogout }: AdminDash const [activeTab, setActiveTab] = useState<'dashboard' | 'students' | 'courses' | 'plans' | 'payments' | 'messages' | 'asaas_settings' | 'whatsapp_settings' | 'webhooks_logs' | 'saas_settings' | 'smtp_settings'>('dashboard'); const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); const [isLoading, setIsLoading] = useState(true); - const [actionLoading, setActionLoading] = useState(null); + const [activeCourseId, setActiveCourseId] = useState(null); + const [selectedStudentDetails, setSelectedStudentDetails] = useState(null); // Settings State const [settings, setSettings] = useState(null); @@ -800,6 +801,27 @@ export default function AdminDashboard({ token, adminUser, onLogout }: AdminDash

Este aluno não realizou nenhuma atividade ainda.

)} +
+

Engajamento & Avaliações

+
+
+ + {student.totalLikes || 0} +
+
+ + {student.totalDislikes || 0} +
+ +
+
+

Histórico de Transações

{student.payments && student.payments.length > 0 ? ( @@ -1520,6 +1542,94 @@ export default function AdminDashboard({ token, adminUser, onLogout }: AdminDash
)} + {/* Custom Modals */} + {selectedStudentDetails && ( +
+
+
+

+ + Detalhes das Aulas: {selectedStudentDetails.name} +

+ +
+
+ {courses.map(course => { + const isUnlocked = !course.isLocked || selectedStudentDetails.unlockedCourses?.includes(course.id); + if (!isUnlocked) return null; + + const courseLessons = course.modules.flatMap(m => m.lessons) || []; + const studentReactions = selectedStudentDetails.lessonReactions || []; + const studentProgress = selectedStudentDetails.progress || []; + + const lessonsWithData = courseLessons.map(l => { + const reaction = studentReactions.find((r: any) => r.lessonId === l.id); + const prog = studentProgress.find((p: any) => p.lessonId === l.id); + return { ...l, reaction, progress: prog }; + }); + + if (lessonsWithData.length === 0) return null; + + return ( +
+

+ + {course.title} +

+
+ {lessonsWithData.map(lesson => ( +
+
+ {lesson.title} +
+
+ {/* Progresso */} +
+ {lesson.progress?.completed ? ( + + ) : ( +
+ )} + {lesson.progress?.watchedPercentage || 0}% +
+ + {/* Reaction */} +
+ {lesson.reaction?.type === 'LIKE' ? ( + + ) : lesson.reaction?.type === 'DISLIKE' ? ( + + ) : ( + - + )} +
+
+
+ ))} +
+
+ ); + })} + + {courses.every(c => c.isLocked && !selectedStudentDetails.unlockedCourses?.includes(c.id)) && ( +
+ Nenhum curso liberado para este aluno ainda. +
+ )} +
+
+ +
+
+
+ )} ); } diff --git a/src/components/CourseView.tsx b/src/components/CourseView.tsx index cea1aad..242ee53 100644 --- a/src/components/CourseView.tsx +++ b/src/components/CourseView.tsx @@ -131,6 +131,38 @@ export default function CourseView({ courseId, token, onBack, initialLessonId }: const completedLessonsCount = modules.reduce((acc, mod) => acc + mod.lessons.filter(l => l.progress?.completed).length, 0); const overallProgress = totalLessonsCount === 0 ? 0 : Math.round((completedLessonsCount / totalLessonsCount) * 100); + const handleReact = async (type: string | null) => { + if (!activeLesson) return; + + try { + const res = await fetch(`/api/lessons/${activeLesson.id}/react`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + }, + body: JSON.stringify({ type }) + }); + + if (res.ok) { + // Update local state for lesson reaction + setModules(prev => prev.map(mod => { + return { + ...mod, + lessons: mod.lessons.map(lsn => { + if (lsn.id === activeLesson.id) { + return { ...lsn, reaction: type }; + } + return lsn; + }) + }; + })); + } + } catch (err) { + console.error('Error saving reaction:', err); + } + }; + const handleProgressUpdate = async (watchedPercentage: number) => { if (!activeLesson) return; @@ -295,6 +327,8 @@ export default function CourseView({ courseId, token, onBack, initialLessonId }: lessonId={activeLesson.id} onProgressUpdate={handleProgressUpdate} initialProgress={activeLesson.progress?.watchedPercentage || 0} + reaction={activeLesson.reaction} + onReact={handleReact} /> {/* Lesson Description Content */} @@ -373,12 +407,15 @@ export default function CourseView({ courseId, token, onBack, initialLessonId }: {/* Overall Progress Block */}
- - - - +
+ + + + + {overallProgress}% +
-

Meu progresso - {overallProgress}%

+

Meu progresso

{completedLessonsCount} de {totalLessonsCount} {totalLessonsCount === 1 ? 'aula' : 'aulas'}

@@ -390,6 +427,7 @@ export default function CourseView({ courseId, token, onBack, initialLessonId }: const moduleTotal = mod.lessons.length; const moduleCompleted = mod.lessons.filter(l => l.progress?.completed).length; const isModuleFullyCompleted = moduleTotal > 0 && moduleTotal === moduleCompleted; + const moduleProgressPercent = moduleTotal > 0 ? Math.round((moduleCompleted / moduleTotal) * 100) : 0; return (
- {isModuleFullyCompleted ? ( - - ) : ( - - )} +
+ + + + + {moduleProgressPercent}% +

{mod.title} diff --git a/src/components/VideoPlayer.tsx b/src/components/VideoPlayer.tsx index ce0c89b..dd203c3 100644 --- a/src/components/VideoPlayer.tsx +++ b/src/components/VideoPlayer.tsx @@ -1,5 +1,5 @@ import React, { useRef, useState, useEffect } from 'react'; -import { Play, Pause, RotateCcw, Volume2, Maximize, CheckCircle, ExternalLink } from 'lucide-react'; +import { Play, Pause, RotateCcw, Volume2, Maximize, CheckCircle, ExternalLink, ThumbsUp, ThumbsDown } from 'lucide-react'; interface VideoPlayerProps { videoUrl: string; @@ -7,9 +7,11 @@ interface VideoPlayerProps { lessonId: string; onProgressUpdate: (percentage: number) => void; initialProgress?: number; + reaction?: string | null; + onReact?: (type: string | null) => void; } -export default function VideoPlayer({ videoUrl, videoType, lessonId, onProgressUpdate, initialProgress = 0 }: VideoPlayerProps) { +export default function VideoPlayer({ videoUrl, videoType, lessonId, onProgressUpdate, initialProgress = 0, reaction, onReact }: VideoPlayerProps) { const videoRef = useRef(null); const [isPlaying, setIsPlaying] = useState(false); const [currentTime, setCurrentTime] = useState(0); @@ -221,7 +223,24 @@ export default function VideoPlayer({ videoUrl, videoType, lessonId, onProgressU />

-
+
+ {onReact && ( +
+ + +
+ )} + {!isCompleted ? (