Implementa círculos de progresso com % no centro e curtidas de aula por aluno
Build and Deploy (Gitea) / build-and-deploy (push) Successful in 58s Details

This commit is contained in:
Sidney Gomes 2026-07-25 11:24:51 -03:00
parent f1dde389d2
commit 99f7b8b9ee
6 changed files with 263 additions and 18 deletions

View File

@ -76,6 +76,7 @@ model User {
certificates Certificate[] certificates Certificate[]
unlockedCourses String[] @default([]) @map("unlocked_courses") unlockedCourses String[] @default([]) @map("unlocked_courses")
supportTickets SupportTicket[] supportTickets SupportTicket[]
lessonReactions LessonReaction[]
@@map("users") @@map("users")
} }
@ -127,6 +128,7 @@ model Lesson {
module Module @relation(fields: [moduleId], references: [id], onDelete: Cascade) module Module @relation(fields: [moduleId], references: [id], onDelete: Cascade)
notes Note[] notes Note[]
progress Progress[] progress Progress[]
reactions LessonReaction[]
@@map("lessons") @@map("lessons")
} }
@ -160,6 +162,20 @@ model Progress {
@@map("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 { model Payment {
id String @id id String @id
userId String @map("user_id") userId String @map("user_id")

View File

@ -631,10 +631,15 @@ app.get('/api/courses/:id', authenticateToken, async (req: AuthenticatedRequest,
where: { userId, lesson: { moduleId: { in: course.modules.map(m => m.id) } } } 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 modulesWithLessons = course.modules.map(mod => {
const lessons = mod.lessons.map(lesson => { const lessons = mod.lessons.map(lesson => {
const prog = progressRecords.find(p => p.lessonId === lesson.id); const prog = progressRecords.find(p => p.lessonId === lesson.id);
const note = noteRecords.find(n => n.lessonId === lesson.id); const note = noteRecords.find(n => n.lessonId === lesson.id);
const reaction = reactionRecords.find(r => r.lessonId === lesson.id);
return { return {
...lesson, ...lesson,
@ -642,7 +647,8 @@ app.get('/api/courses/:id', authenticateToken, async (req: AuthenticatedRequest,
watchedPercentage: prog.watchedPercentage, watchedPercentage: prog.watchedPercentage,
completed: prog.completed completed: prog.completed
} : { watchedPercentage: 0, completed: false }, } : { 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 // 7. Lesson Notes and Doubts
app.get('/api/lessons/:id/notes', authenticateToken, async (req: AuthenticatedRequest, res: Response) => { app.get('/api/lessons/:id/notes', authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
const lessonId = req.params.id; const lessonId = req.params.id;
@ -1237,7 +1286,9 @@ app.get('/api/admin/students', authenticateToken, requireAdmin, async (req: Auth
include: { include: {
payments: true, payments: true,
moduleGrades: 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 || [], unlockedCourses: u.unlockedCourses || [],
moduleGrades: u.moduleGrades, moduleGrades: u.moduleGrades,
certificates: u.certificates, 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 || []
}; };
}); });

View File

@ -3,7 +3,7 @@ import { useModal } from '../contexts/ModalContext';
import { import {
TrendingUp, Users, AlertTriangle, XCircle, Search, TrendingUp, Users, AlertTriangle, XCircle, Search,
Settings, CheckCircle2, RefreshCw, Link as LinkIcon, ShieldCheck, HelpCircle, Eye, DollarSign, 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'; } from 'lucide-react';
import { LineChart, Line, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip as RechartsTooltip, ResponsiveContainer, Legend, Cell, PieChart, Pie } from 'recharts'; import { LineChart, Line, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip as RechartsTooltip, ResponsiveContainer, Legend, Cell, PieChart, Pie } from 'recharts';
import { SubscriptionStatus } from '../types'; 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 [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 [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [actionLoading, setActionLoading] = useState<string | null>(null); const [activeCourseId, setActiveCourseId] = useState<string | null>(null);
const [selectedStudentDetails, setSelectedStudentDetails] = useState<Student | null>(null);
// Settings State // Settings State
const [settings, setSettings] = useState<any>(null); const [settings, setSettings] = useState<any>(null);
@ -800,6 +801,27 @@ export default function AdminDashboard({ token, adminUser, onLogout }: AdminDash
<p className="text-xs text-zinc-500">Este aluno não realizou nenhuma atividade ainda.</p> <p className="text-xs text-zinc-500">Este aluno não realizou nenhuma atividade ainda.</p>
)} )}
<div className="pt-3 pb-1 space-y-2">
<h4 className="text-xs font-bold text-zinc-400 uppercase tracking-wider mb-2">Engajamento & Avaliações</h4>
<div className="flex items-center space-x-3">
<div className="flex items-center space-x-1.5 text-emerald-500 bg-emerald-500/10 px-2.5 py-1 rounded-lg border border-emerald-500/20">
<ThumbsUp className="w-3.5 h-3.5" />
<span className="font-bold text-sm">{student.totalLikes || 0}</span>
</div>
<div className="flex items-center space-x-1.5 text-red-500 bg-red-500/10 px-2.5 py-1 rounded-lg border border-red-500/20">
<ThumbsDown className="w-3.5 h-3.5" />
<span className="font-bold text-sm">{student.totalDislikes || 0}</span>
</div>
<button
onClick={() => setSelectedStudentDetails(student)}
className="ml-auto text-[10px] uppercase font-bold bg-zinc-800 hover:bg-zinc-700 text-white px-3 py-1.5 rounded border border-white/10 transition-colors flex items-center gap-1"
>
<Eye className="w-3.5 h-3.5" />
Ver Detalhes das Aulas
</button>
</div>
</div>
<div className="pt-2 border-t border-white/5 space-y-2"> <div className="pt-2 border-t border-white/5 space-y-2">
<h4 className="text-xs font-bold text-zinc-400 uppercase tracking-wider">Histórico de Transações</h4> <h4 className="text-xs font-bold text-zinc-400 uppercase tracking-wider">Histórico de Transações</h4>
{student.payments && student.payments.length > 0 ? ( {student.payments && student.payments.length > 0 ? (
@ -1520,6 +1542,94 @@ export default function AdminDashboard({ token, adminUser, onLogout }: AdminDash
</div> </div>
</div> </div>
)} )}
{/* Custom Modals */}
{selectedStudentDetails && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm animate-fade-in">
<div className="bg-zinc-900 border border-white/10 rounded-2xl w-full max-w-2xl max-h-[90vh] flex flex-col overflow-hidden animate-slide-up shadow-2xl">
<div className="p-5 border-b border-white/10 flex justify-between items-center bg-black/40">
<h3 className="text-lg font-bold text-white flex items-center gap-2">
<Eye className="w-5 h-5 text-[#E50914]" />
Detalhes das Aulas: {selectedStudentDetails.name}
</h3>
<button onClick={() => setSelectedStudentDetails(null)} className="text-zinc-400 hover:text-white transition-colors">
<X className="w-6 h-6" />
</button>
</div>
<div className="p-5 overflow-y-auto space-y-4">
{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 (
<div key={course.id} className="bg-zinc-800/50 rounded-xl p-4 border border-white/5">
<h4 className="font-bold text-white mb-3 text-sm flex items-center gap-2 border-b border-white/10 pb-2">
<Layers className="w-4 h-4 text-[#E50914]" />
{course.title}
</h4>
<div className="space-y-2">
{lessonsWithData.map(lesson => (
<div key={lesson.id} className="flex items-center justify-between bg-black/30 p-2.5 rounded-lg border border-white/5">
<div className="flex-1 truncate pr-3">
<span className="text-xs font-semibold text-zinc-300 truncate">{lesson.title}</span>
</div>
<div className="flex items-center space-x-4 flex-shrink-0">
{/* Progresso */}
<div className="flex items-center space-x-1.5">
{lesson.progress?.completed ? (
<CheckCircle2 className="w-3.5 h-3.5 text-emerald-500" />
) : (
<div className="w-3.5 h-3.5 rounded-full border-2 border-zinc-600 border-t-[#E50914] animate-spin-slow"></div>
)}
<span className="text-[10px] font-mono text-zinc-400">{lesson.progress?.watchedPercentage || 0}%</span>
</div>
{/* Reaction */}
<div className="flex items-center justify-center w-8">
{lesson.reaction?.type === 'LIKE' ? (
<ThumbsUp className="w-4 h-4 text-emerald-500" />
) : lesson.reaction?.type === 'DISLIKE' ? (
<ThumbsDown className="w-4 h-4 text-red-500" />
) : (
<span className="text-[10px] text-zinc-600">-</span>
)}
</div>
</div>
</div>
))}
</div>
</div>
);
})}
{courses.every(c => c.isLocked && !selectedStudentDetails.unlockedCourses?.includes(c.id)) && (
<div className="text-center p-6 text-zinc-500">
Nenhum curso liberado para este aluno ainda.
</div>
)}
</div>
<div className="p-4 border-t border-white/10 flex justify-end bg-black/40">
<button
onClick={() => setSelectedStudentDetails(null)}
className="px-4 py-2 bg-zinc-800 hover:bg-zinc-700 text-white rounded-lg text-sm font-bold transition-colors"
>
Fechar
</button>
</div>
</div>
</div>
)}
</div> </div>
); );
} }

View File

@ -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 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 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) => { const handleProgressUpdate = async (watchedPercentage: number) => {
if (!activeLesson) return; if (!activeLesson) return;
@ -295,6 +327,8 @@ export default function CourseView({ courseId, token, onBack, initialLessonId }:
lessonId={activeLesson.id} lessonId={activeLesson.id}
onProgressUpdate={handleProgressUpdate} onProgressUpdate={handleProgressUpdate}
initialProgress={activeLesson.progress?.watchedPercentage || 0} initialProgress={activeLesson.progress?.watchedPercentage || 0}
reaction={activeLesson.reaction}
onReact={handleReact}
/> />
{/* Lesson Description Content */} {/* Lesson Description Content */}
@ -373,12 +407,15 @@ export default function CourseView({ courseId, token, onBack, initialLessonId }:
{/* Overall Progress Block */} {/* Overall Progress Block */}
<div className="glass-card rounded-xl p-4 mb-4 border border-white/5 flex items-center justify-between"> <div className="glass-card rounded-xl p-4 mb-4 border border-white/5 flex items-center justify-between">
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
<svg viewBox="0 0 36 36" className="w-10 h-10 -rotate-90"> <div className="relative w-10 h-10 flex items-center justify-center flex-shrink-0">
<path className="text-zinc-800" strokeWidth="3" stroke="currentColor" fill="none" d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831" /> <svg viewBox="0 0 36 36" className="w-10 h-10 -rotate-90 absolute inset-0">
<path className="text-[#E50914] transition-all duration-1000 ease-out" strokeDasharray={`${overallProgress}, 100`} strokeWidth="3" strokeLinecap="round" stroke="currentColor" fill="none" d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831" /> <path className="text-zinc-800" strokeWidth="3" stroke="currentColor" fill="none" d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831" />
</svg> <path className="text-[#E50914] transition-all duration-1000 ease-out" strokeDasharray={`${overallProgress}, 100`} strokeWidth="3" strokeLinecap="round" stroke="currentColor" fill="none" d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831" />
</svg>
<span className="text-[10px] font-bold text-white z-10">{overallProgress}%</span>
</div>
<div> <div>
<h4 className="text-sm font-bold text-white">Meu progresso - {overallProgress}%</h4> <h4 className="text-sm font-bold text-white">Meu progresso</h4>
<p className="text-xs text-zinc-500">{completedLessonsCount} de {totalLessonsCount} {totalLessonsCount === 1 ? 'aula' : 'aulas'}</p> <p className="text-xs text-zinc-500">{completedLessonsCount} de {totalLessonsCount} {totalLessonsCount === 1 ? 'aula' : 'aulas'}</p>
</div> </div>
</div> </div>
@ -390,6 +427,7 @@ export default function CourseView({ courseId, token, onBack, initialLessonId }:
const moduleTotal = mod.lessons.length; const moduleTotal = mod.lessons.length;
const moduleCompleted = mod.lessons.filter(l => l.progress?.completed).length; const moduleCompleted = mod.lessons.filter(l => l.progress?.completed).length;
const isModuleFullyCompleted = moduleTotal > 0 && moduleTotal === moduleCompleted; const isModuleFullyCompleted = moduleTotal > 0 && moduleTotal === moduleCompleted;
const moduleProgressPercent = moduleTotal > 0 ? Math.round((moduleCompleted / moduleTotal) * 100) : 0;
return ( return (
<div <div
@ -402,11 +440,13 @@ export default function CourseView({ courseId, token, onBack, initialLessonId }:
className="w-full text-left px-4 py-3.5 flex items-center justify-between hover:bg-white/5 transition-colors" className="w-full text-left px-4 py-3.5 flex items-center justify-between hover:bg-white/5 transition-colors"
> >
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
{isModuleFullyCompleted ? ( <div className="relative w-8 h-8 flex items-center justify-center flex-shrink-0">
<CheckCircle2 className="w-4 h-4 text-emerald-500 fill-emerald-500/10 flex-shrink-0" /> <svg viewBox="0 0 36 36" className="w-8 h-8 -rotate-90 absolute inset-0">
) : ( <path className="text-zinc-800" strokeWidth="3" stroke="currentColor" fill="none" d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831" />
<Circle className="w-4 h-4 text-[#E50914] flex-shrink-0" /> <path className="text-[#E50914] transition-all duration-1000 ease-out" strokeDasharray={`${moduleProgressPercent}, 100`} strokeWidth="3" strokeLinecap="round" stroke="currentColor" fill="none" d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831" />
)} </svg>
<span className="text-[8px] font-bold text-white z-10">{moduleProgressPercent}%</span>
</div>
<div> <div>
<h4 className="font-sans font-bold text-xs text-zinc-200 tracking-tight leading-snug line-clamp-1"> <h4 className="font-sans font-bold text-xs text-zinc-200 tracking-tight leading-snug line-clamp-1">
{mod.title} {mod.title}

View File

@ -1,5 +1,5 @@
import React, { useRef, useState, useEffect } from 'react'; 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 { interface VideoPlayerProps {
videoUrl: string; videoUrl: string;
@ -7,9 +7,11 @@ interface VideoPlayerProps {
lessonId: string; lessonId: string;
onProgressUpdate: (percentage: number) => void; onProgressUpdate: (percentage: number) => void;
initialProgress?: number; 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<HTMLVideoElement>(null); const videoRef = useRef<HTMLVideoElement>(null);
const [isPlaying, setIsPlaying] = useState(false); const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0); const [currentTime, setCurrentTime] = useState(0);
@ -221,7 +223,24 @@ export default function VideoPlayer({ videoUrl, videoType, lessonId, onProgressU
/> />
</div> </div>
<div className="w-full sm:w-auto flex justify-end"> <div className="w-full sm:w-auto flex justify-end items-center gap-2">
{onReact && (
<div className="flex items-center space-x-1 mr-2 border-r border-white/10 pr-3">
<button
onClick={() => onReact(reaction === 'LIKE' ? null : 'LIKE')}
className={`p-2 rounded-full transition-colors ${reaction === 'LIKE' ? 'bg-[#E50914] text-white' : 'text-zinc-400 hover:text-white hover:bg-white/10'}`}
>
<ThumbsUp className="w-4 h-4" />
</button>
<button
onClick={() => onReact(reaction === 'DISLIKE' ? null : 'DISLIKE')}
className={`p-2 rounded-full transition-colors ${reaction === 'DISLIKE' ? 'bg-[#E50914] text-white' : 'text-zinc-400 hover:text-white hover:bg-white/10'}`}
>
<ThumbsDown className="w-4 h-4" />
</button>
</div>
)}
{!isCompleted ? ( {!isCompleted ? (
<button <button
onClick={triggerManualCompletion} onClick={triggerManualCompletion}

View File

@ -15,6 +15,10 @@ export interface User {
whatsapp?: string; whatsapp?: string;
avatarUrl?: string; avatarUrl?: string;
trialEndsAt?: string | null; trialEndsAt?: string | null;
totalLikes?: number;
totalDislikes?: number;
lessonReactions?: any[];
progress?: any[];
cep?: string; cep?: string;
address?: string; address?: string;
number?: string; number?: string;
@ -94,6 +98,7 @@ export interface Lesson {
content: string; // Additional lesson resources/description content: string; // Additional lesson resources/description
thumbnailUrl?: string; thumbnailUrl?: string;
createdAt: string; createdAt: string;
reaction?: string | null;
} }
export interface Note { export interface Note {