diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 631c5c3..0254c5e 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -81,16 +81,17 @@ model User { } model Course { - id String @id @default(uuid()) - title String - description String @db.Text - thumbnail String - category String - certificateMode CertificateMode @default(FULL_COURSE) @map("certificate_mode") - isLocked Boolean @default(false) @map("is_locked") - price Float? - order Int @default(0) - createdAt DateTime @default(now()) @map("created_at") + id String @id @default(uuid()) + title String + description String @db.Text + thumbnail String + category String + certificateMode CertificateMode @default(FULL_COURSE) @map("certificate_mode") + isLocked Boolean @default(false) @map("is_locked") + price Float? + order Int @default(0) + studyMaterialUrl String? @map("study_material_url") @db.Text + createdAt DateTime @default(now()) @map("created_at") modules Module[] payments Payment[] diff --git a/server.ts b/server.ts index 58de0c2..303982a 100644 --- a/server.ts +++ b/server.ts @@ -539,21 +539,28 @@ app.get('/api/courses', authenticateToken, async (req: AuthenticatedRequest, res }); const userProgress = await prisma.progress.findMany({ - where: { userId, completed: true } + where: { userId } }); - const userProgressLessonIds = userProgress.map(p => p.lessonId); + const userProgressLessonIds = userProgress.filter(p => p.completed).map(p => p.lessonId); const coursesWithProgress = courses.map(course => { const sortedModules = [...course.modules].sort((a, b) => a.order - b.order); const courseLessons = sortedModules.flatMap(m => { const sortedLessons = [...m.lessons].sort((a, b) => a.order - b.order); - return sortedLessons.map(l => ({ - id: l.id, - title: l.title, - thumbnailUrl: l.thumbnailUrl, - order: l.order, - moduleId: m.id - })); + return sortedLessons.map(l => { + const prog = userProgress.find(p => p.lessonId === l.id); + return { + id: l.id, + title: l.title, + thumbnailUrl: l.thumbnailUrl, + order: l.order, + moduleId: m.id, + progress: prog ? { + watchedPercentage: prog.watchedPercentage, + completed: prog.completed + } : { watchedPercentage: 0, completed: false } + }; + }); }); const totalLessons = courseLessons.length; @@ -573,6 +580,7 @@ app.get('/api/courses', authenticateToken, async (req: AuthenticatedRequest, res isLocked: course.isLocked, price: course.price, category: course.category, + studyMaterialUrl: course.studyMaterialUrl, totalLessons, completedLessons, progress: progressPercentage, @@ -648,6 +656,7 @@ app.get('/api/courses/:id', authenticateToken, async (req: AuthenticatedRequest, res.json({ course: { ...courseData, + studyMaterialUrl: course.studyMaterialUrl, isUnlocked }, modules: modulesWithLessons @@ -1616,6 +1625,60 @@ app.delete('/api/admin/courses/:id', authenticateToken, requireAdmin, async (req } }); +// Upload PDF material de estudo para curso +app.post('/api/admin/courses/:id/upload-material', authenticateToken, requireAdmin, upload.single('file'), async (req: AuthenticatedRequest, res: Response) => { + const courseId = req.params.id; + const file = req.file; + + if (!file) { + res.status(400).json({ error: 'Nenhum arquivo enviado.' }); + return; + } + + const ext = path.extname(file.originalname).toLowerCase(); + if (ext !== '.pdf') { + res.status(400).json({ error: 'Apenas arquivos PDF são permitidos.' }); + return; + } + + try { + const fileName = `materials/${courseId}-${Date.now()}${ext}`; + await s3.send(new PutObjectCommand({ + Bucket: 'microtecflix', + Key: fileName, + Body: file.buffer, + ContentType: 'application/pdf', + })); + + const publicUrl = `https://s3-estudo.microtecinformaticacurso.com.br/microtecflix/${fileName}`; + + const updated = await prisma.course.update({ + where: { id: courseId }, + data: { studyMaterialUrl: publicUrl } + }); + + res.json({ success: true, studyMaterialUrl: updated.studyMaterialUrl }); + } catch (err: any) { + console.error('Erro ao fazer upload do material:', err); + res.status(500).json({ error: 'Erro ao enviar arquivo para o servidor.' }); + } +}); + +// Remove material de estudo do curso +app.delete('/api/admin/courses/:id/remove-material', authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res: Response) => { + const courseId = req.params.id; + try { + await prisma.course.update({ + where: { id: courseId }, + data: { studyMaterialUrl: null } + }); + res.json({ success: true }); + } catch (err) { + res.status(500).json({ error: 'Erro ao remover material.' }); + } +}); + + // 4. CRUD Modules app.post('/api/admin/modules', authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res: Response) => { const { courseId, title, order } = req.body; diff --git a/src/App.tsx b/src/App.tsx index fc6f9d4..4356a95 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -258,7 +258,7 @@ export default function App() { setTimeout(() => { const el = lessonShelfRefs.current[courseId]; if (el) { - el.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + el.scrollIntoView({ behavior: 'smooth', block: 'center' }); } }, 80); } @@ -359,9 +359,22 @@ export default function App() { referrerPolicy="no-referrer" />
-

Aulas do Curso

-

{expandedCourse.title}

+

+ Aulas do Curso: {expandedCourse.title} +

{expandedCourse.lessons.length} aulas disponíveis

+ + {(expandedCourse as any).studyMaterialUrl && ( + + + Baixar Material (PDF) + + )}
+ + ) : ( +

Nenhum material de estudo anexado.

+ )} + +
+ + Máx. recomendado: 50MB • Formato: PDF +
+ + )} + +
-
- +
+ setSettings({ ...settings, enableBoleto: v })} + label="Habilitar Boleto Bancário (Permite pagamentos à vista)" + />
-
- +
+ setSettings({ ...settings, enableInstallmentBoleto: v })} + label="Habilitar Boleto Parcelado (Permite gerar boletos parcelados)" + />
{settings.enableInstallmentBoleto && ( @@ -1460,17 +1447,13 @@ export default function AdminDashboard({ token, adminUser, onLogout }: AdminDash />
-
- -

Marque se a porta for 465. Para 587 (STARTTLS), normalmente fica desmarcado.

+
+ setSettings({...settings, smtpSecure: v})} + label="Usar Conexão Segura (SSL/TLS)" + /> +

Marque se a porta for 465. Para 587 (STARTTLS), normalmente fica desmarcado.

diff --git a/src/components/AdminPricingManager.tsx b/src/components/AdminPricingManager.tsx index 20bea2d..c2994f3 100644 --- a/src/components/AdminPricingManager.tsx +++ b/src/components/AdminPricingManager.tsx @@ -2,6 +2,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'; +import ToggleSwitch from './ToggleSwitch'; interface AdminPricingManagerProps { courses: Course[]; @@ -374,18 +375,13 @@ export const AdminPricingManager: React.FC = ({ course
- + {/* Course Selection */} @@ -400,41 +396,34 @@ export const AdminPricingManager: React.FC = ({ course
- +
OU ESCOLHA ESPECÍFICOS
{courses.map(course => ( - + ))} diff --git a/src/components/CourseView.tsx b/src/components/CourseView.tsx index 819b56a..fd62dea 100644 --- a/src/components/CourseView.tsx +++ b/src/components/CourseView.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect } from 'react'; -import { ArrowLeft, BookOpen, ChevronRight, FileText, CheckCircle2, Circle, MessageSquare, AlertCircle, Award } from 'lucide-react'; +import { ArrowLeft, BookOpen, ChevronRight, FileText, CheckCircle2, Circle, MessageSquare, AlertCircle, Award, Download } from 'lucide-react'; import VideoPlayer from './VideoPlayer'; import CourseActivity from './CourseActivity'; @@ -34,6 +34,7 @@ interface Course { description: string; thumbnail: string; category: string; + studyMaterialUrl?: string; } interface CourseViewProps { @@ -331,9 +332,23 @@ export default function CourseView({ courseId, token, onBack, initialLessonId }: {/* Right Hand: Interactive Modules Accordion */}
-
-

Conteúdo do Curso

-

{course.title}

+
+
+

Conteúdo do Curso

+

{course.title}

+
+ + {course.studyMaterialUrl && ( + + + Baixar Material de Estudo + + )}
diff --git a/src/components/HeroBanner.tsx b/src/components/HeroBanner.tsx index bf50682..02fb954 100644 --- a/src/components/HeroBanner.tsx +++ b/src/components/HeroBanner.tsx @@ -91,13 +91,23 @@ export default function HeroBanner({ course, onPlay, onDetail, subscriptionStatu )} - + {course.progress > 0 ? ( + + ) : ( + + )}
diff --git a/src/components/ToggleSwitch.tsx b/src/components/ToggleSwitch.tsx new file mode 100644 index 0000000..2dae8d0 --- /dev/null +++ b/src/components/ToggleSwitch.tsx @@ -0,0 +1,53 @@ +import React from 'react'; + +interface ToggleSwitchProps { + checked: boolean; + onChange: (checked: boolean) => void; + label?: string; + disabled?: boolean; + id?: string; +} + +export default function ToggleSwitch({ checked, onChange, label, disabled = false, id }: ToggleSwitchProps) { + const uniqueId = id || `toggle-${Math.random().toString(36).slice(2, 9)}`; + return ( +