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
Build and Deploy (Gitea) / build-and-deploy (push) Successful in 58s
Details
This commit is contained in:
parent
f1dde389d2
commit
99f7b8b9ee
|
|
@ -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")
|
||||||
|
|
|
||||||
61
server.ts
61
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) } } }
|
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 || []
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
<svg viewBox="0 0 36 36" className="w-10 h-10 -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" />
|
<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" />
|
||||||
<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-[#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>
|
</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}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue