From fc17ccc49912a3295a85371bb4d45f30e56e193f Mon Sep 17 00:00:00 2001 From: Sidney Gomes Date: Mon, 20 Jul 2026 20:20:57 -0300 Subject: [PATCH] feat: Adiciona campos de cadastro e configuracoes do Asaas Sandbox --- index.html | 3 +- prisma/schema.prisma | 66 +++++- server.ts | 283 ++++++++++++++++++++++++- src/App.tsx | 36 +++- src/components/AdminContentManager.tsx | 120 ++++++++++- src/components/AdminDashboard.tsx | 269 +++++++++++++++++++++-- src/components/CertificateView.tsx | 121 +++++++++++ src/components/CertificatesTab.tsx | 75 +++++++ src/components/CourseActivity.tsx | 201 ++++++++++++++++++ src/components/CourseView.tsx | 33 ++- src/components/Navbar.tsx | 14 +- src/db/db.ts | 28 ++- src/services/asaas.ts | 11 +- src/types.ts | 40 ++++ 14 files changed, 1250 insertions(+), 50 deletions(-) create mode 100644 src/components/CertificateView.tsx create mode 100644 src/components/CertificatesTab.tsx create mode 100644 src/components/CourseActivity.tsx diff --git a/index.html b/index.html index 21dfe69..2da8ca1 100644 --- a/index.html +++ b/index.html @@ -3,7 +3,8 @@ - My Google AI Studio App + MicrotecFlix +
diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 03f6b44..9d2acd0 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -40,6 +40,11 @@ enum BillingType { BOLETO } +enum CertificateMode { + PER_MODULE + FULL_COURSE +} + model User { id String @id @default(uuid()) name String @@ -53,19 +58,22 @@ model User { notes Note[] progress Progress[] payments Payment[] + moduleGrades ModuleGrade[] + certificates Certificate[] @@map("users") } model Course { - id String @id @default(uuid()) - title String - description String @db.Text - thumbnail String - category String - 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") + createdAt DateTime @default(now()) @map("created_at") - modules Module[] + modules Module[] @@map("courses") } @@ -79,6 +87,7 @@ model Module { course Course @relation(fields: [courseId], references: [id], onDelete: Cascade) lessons Lesson[] + activity ModuleActivity? @@map("modules") } @@ -153,3 +162,46 @@ model WebhookLog { @@map("webhook_logs") } + +model ModuleActivity { + id String @id @default(uuid()) + moduleId String @unique @map("module_id") + questions Json // e.g. [{ question: "...", options: ["A", "B"], correctIndex: 0 }] + createdAt DateTime @default(now()) @map("created_at") + + module Module @relation(fields: [moduleId], references: [id], onDelete: Cascade) + grades ModuleGrade[] + + @@map("module_activities") +} + +model ModuleGrade { + id String @id @default(uuid()) + userId String @map("user_id") + activityId String @map("activity_id") + score Float + passed Boolean @default(false) + createdAt DateTime @default(now()) @map("created_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + activity ModuleActivity @relation(fields: [activityId], references: [id], onDelete: Cascade) + + @@unique([userId, activityId]) + @@map("module_grades") +} + +model Certificate { + id String @id @default(uuid()) + userId String @map("user_id") + courseId String? @map("course_id") + moduleId String? @map("module_id") + certificateType String @map("certificate_type") // 'COURSE' or 'MODULE' + unlockedAt DateTime @default(now()) @map("unlocked_at") + finalGrade Float? @map("final_grade") + courseName String @map("course_name") // Snapshot of course title + moduleName String? @map("module_name") // Snapshot of module title + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@map("certificates") +} diff --git a/server.ts b/server.ts index 1a5c47b..16f930f 100644 --- a/server.ts +++ b/server.ts @@ -56,9 +56,9 @@ function requireAdmin(req: AuthenticatedRequest, res: Response, next: NextFuncti // 1. Auth: Register app.post('/api/auth/register', async (req: Request, res: Response) => { - const { name, email, password } = req.body; + const { name, email, password, birthDate, whatsapp } = req.body; - if (!name || !email || !password) { + if (!name || !email || !password || !birthDate || !whatsapp) { res.status(400).json({ error: 'Preencha todos os campos obrigatórios' }); return; } @@ -78,6 +78,13 @@ app.post('/api/auth/register', async (req: Request, res: Response) => { const salt = bcrypt.genSaltSync(10); const passwordHash = bcrypt.hashSync(password, salt); + const createdAt = new Date(); + + // Calculate trial ends at + const trialDays = db.settings?.trialDurationDays || 7; + const trialEndsAt = new Date(createdAt); + trialEndsAt.setDate(trialEndsAt.getDate() + trialDays); + const newUser: User = { id: `usr_${Math.random().toString(36).substring(2, 9)}`, name, @@ -86,7 +93,10 @@ app.post('/api/auth/register', async (req: Request, res: Response) => { role: 'student', subscriptionStatus: 'TRIAL', // Defaults to trial asaasCustomerId, - createdAt: new Date().toISOString() + birthDate, + whatsapp, + trialEndsAt: trialEndsAt.toISOString(), + createdAt: createdAt.toISOString() }; db.users.push(newUser); @@ -535,6 +545,8 @@ app.get('/api/admin/students', authenticateToken, requireAdmin, (req: Authentica .filter(u => u.role === 'student') .map(u => { const studentPayments = db.payments.filter(p => p.userId === u.id); + const studentGrades = db.moduleGrades.filter(g => g.userId === u.id); + const studentCertificates = db.certificates.filter(c => c.userId === u.id); return { id: u.id, name: u.name, @@ -542,10 +554,16 @@ app.get('/api/admin/students', authenticateToken, requireAdmin, (req: Authentica subscriptionStatus: u.subscriptionStatus, asaasCustomerId: u.asaasCustomerId, createdAt: u.createdAt, + birthDate: u.birthDate, + whatsapp: u.whatsapp, + trialEndsAt: u.trialEndsAt, totalPaid: studentPayments .filter(p => p.status === 'CONFIRMED' || p.status === 'RECEIVED') .reduce((acc, curr) => acc + curr.value, 0), - unlockedCourses: u.unlockedCourses || [] + unlockedCourses: u.unlockedCourses || [], + moduleGrades: studentGrades, + certificates: studentCertificates, + payments: studentPayments }; }); @@ -577,6 +595,31 @@ app.post('/api/admin/students/:id/status', authenticateToken, requireAdmin, (req res.json({ success: true, status: student.subscriptionStatus }); }); +// Admin System Settings +app.get('/api/admin/settings', authenticateToken, requireAdmin, (req: AuthenticatedRequest, res: Response) => { + const db = loadDb(); + res.json(db.settings || { + trialDurationDays: 7, + asaasEnvironment: 'sandbox', + asaasApiKey: '', + asaasWebhookUrl: '', + asaasWebhookSecret: '' + }); +}); + +app.post('/api/admin/settings', authenticateToken, requireAdmin, (req: AuthenticatedRequest, res: Response) => { + const newSettings = req.body; + const db = loadDb(); + + db.settings = { + ...db.settings, + ...newSettings + }; + + saveDb(db); + res.json({ success: true, settings: db.settings }); +}); + // Admin manually unlock or lock a course for a student app.post('/api/admin/students/:id/unlock-course', authenticateToken, requireAdmin, (req: AuthenticatedRequest, res: Response) => { const { courseId, unlocked } = req.body; @@ -937,6 +980,238 @@ app.post('/api/webhooks/asaas', (req: Request, res: Response) => { res.status(200).json({ received: true }); }); +// --- ACTIVITIES & CERTIFICATES API ENDPOINTS --- + +// Admin: Save or update module activity +app.post('/api/admin/modules/:id/activity', authenticateToken, requireAdmin, (req: AuthenticatedRequest, res: Response) => { + const moduleId = req.params.id; + const { questions } = req.body; + + if (!questions || !Array.isArray(questions)) { + res.status(400).json({ error: 'Lista de perguntas inválida' }); + return; + } + + const db = loadDb(); + let activity = db.activities.find(a => a.moduleId === moduleId); + + if (activity) { + activity.questions = questions; + } else { + activity = { + id: `act_${Math.random().toString(36).substring(2, 9)}`, + moduleId, + questions, + createdAt: new Date().toISOString() + }; + db.activities.push(activity); + } + + saveDb(db); + res.json({ success: true, activity }); +}); + +// Admin/Student: Get module activity +app.get('/api/modules/:id/activity', authenticateToken, (req: AuthenticatedRequest, res: Response) => { + const moduleId = req.params.id; + const db = loadDb(); + + const activity = db.activities.find(a => a.moduleId === moduleId); + if (!activity) { + res.status(404).json({ error: 'Atividade não encontrada' }); + return; + } + + // If student, remove correctIndex so they can't cheat easily by inspecting the network request + if (req.user?.role === 'student') { + const safeQuestions = activity.questions.map((q: any) => ({ + question: q.question, + options: q.options + })); + res.json({ id: activity.id, moduleId: activity.moduleId, questions: safeQuestions }); + } else { + res.json(activity); + } +}); + +// Admin: Change Course Certificate Mode +app.post('/api/admin/courses/:id/certificate-mode', authenticateToken, requireAdmin, (req: AuthenticatedRequest, res: Response) => { + const courseId = req.params.id; + const { mode } = req.body; // 'PER_MODULE' or 'FULL_COURSE' + + if (mode !== 'PER_MODULE' && mode !== 'FULL_COURSE') { + res.status(400).json({ error: 'Modo inválido' }); + return; + } + + const db = loadDb(); + const course = db.courses.find(c => c.id === courseId); + if (!course) { + res.status(404).json({ error: 'Curso não encontrado' }); + return; + } + + course.certificateMode = mode; + saveDb(db); + res.json({ success: true, course }); +}); + +// Admin: Manually Unlock Certificate +app.post('/api/admin/students/:userId/unlock-certificate', authenticateToken, requireAdmin, (req: AuthenticatedRequest, res: Response) => { + const { userId } = req.params; + const { type, targetId, courseName, moduleName } = req.body; // type: 'COURSE' or 'MODULE' + + const db = loadDb(); + + const certId = `cert_${Math.random().toString(36).substring(2, 9)}`; + const certificate = { + id: certId, + userId, + courseId: type === 'COURSE' ? targetId : null, + moduleId: type === 'MODULE' ? targetId : null, + certificateType: type, + unlockedAt: new Date().toISOString(), + finalGrade: 10, // Default manual grade + courseName, + moduleName: moduleName || null + }; + + db.certificates.push(certificate); + saveDb(db); + + res.json({ success: true, certificate }); +}); + +// Student: Submit Activity Answers +app.post('/api/modules/:id/submit-activity', authenticateToken, (req: AuthenticatedRequest, res: Response) => { + const moduleId = req.params.id; + const { answers } = req.body; // Array of selected indexes + const userId = req.user!.id; + + const db = loadDb(); + const activity = db.activities.find(a => a.moduleId === moduleId); + + if (!activity) { + res.status(404).json({ error: 'Atividade não encontrada' }); + return; + } + + // Calculate Grade + let correctCount = 0; + const totalQuestions = activity.questions.length; + + activity.questions.forEach((q: any, index: number) => { + if (answers[index] === q.correctIndex) { + correctCount++; + } + }); + + const score = totalQuestions > 0 ? (correctCount / totalQuestions) * 10 : 0; + const passed = score >= 6; // Configurable passing score + + let gradeIdx = db.moduleGrades.findIndex(g => g.userId === userId && g.activityId === activity.id); + if (gradeIdx >= 0) { + db.moduleGrades[gradeIdx].score = score; + db.moduleGrades[gradeIdx].passed = passed; + } else { + db.moduleGrades.push({ + id: `grd_${Math.random().toString(36).substring(2, 9)}`, + userId, + activityId: activity.id, + score, + passed, + createdAt: new Date().toISOString() + }); + } + + // --- Automatic Certificate Generation Logic --- + const moduleInfo = db.modules.find(m => m.id === moduleId); + if (passed && moduleInfo) { + const course = db.courses.find(c => c.id === moduleInfo.courseId); + if (course) { + const mode = course.certificateMode || 'FULL_COURSE'; + + if (mode === 'PER_MODULE') { + // Check if certificate already exists + const exists = db.certificates.find(c => c.userId === userId && c.moduleId === moduleId && c.certificateType === 'MODULE'); + if (!exists) { + db.certificates.push({ + id: `cert_${Math.random().toString(36).substring(2, 9)}`, + userId, + courseId: course.id, + moduleId: moduleInfo.id, + certificateType: 'MODULE', + unlockedAt: new Date().toISOString(), + finalGrade: score, + courseName: course.title, + moduleName: moduleInfo.title + }); + } + } else { + // FULL_COURSE mode + // Check if student has passed all modules of this course + const allCourseModules = db.modules.filter(m => m.courseId === course.id); + let allPassed = true; + let totalScore = 0; + let evaluatedModules = 0; + + for (const m of allCourseModules) { + const act = db.activities.find(a => a.moduleId === m.id); + if (act) { + const grade = db.moduleGrades.find(g => g.activityId === act.id && g.userId === userId); + if (!grade || !grade.passed) { + allPassed = false; + break; + } + totalScore += grade.score; + evaluatedModules++; + } else { + // If a module has no activity, we might consider it "passed" automatically, but let's just ignore it for grades + // Or require manual progress? Let's assume if it has no activity, we just skip grade check for it + // Actually, wait, let's require 100% watched percentage for modules without activities. + const lessons = db.lessons.filter(l => l.moduleId === m.id); + for(const lsn of lessons) { + const prog = db.progress.find(p => p.lessonId === lsn.id && p.userId === userId); + if (!prog || !prog.completed) { + allPassed = false; + break; + } + } + } + } + + if (allPassed) { + const exists = db.certificates.find(c => c.userId === userId && c.courseId === course.id && c.certificateType === 'COURSE'); + if (!exists) { + const finalGrade = evaluatedModules > 0 ? (totalScore / evaluatedModules) : null; + db.certificates.push({ + id: `cert_${Math.random().toString(36).substring(2, 9)}`, + userId, + courseId: course.id, + moduleId: null, + certificateType: 'COURSE', + unlockedAt: new Date().toISOString(), + finalGrade, + courseName: course.title, + moduleName: null + }); + } + } + } + } + } + + saveDb(db); + res.json({ success: true, score, passed }); +}); + +// Student: Get my certificates +app.get('/api/certificates', authenticateToken, (req: AuthenticatedRequest, res: Response) => { + const db = loadDb(); + const myCerts = db.certificates.filter(c => c.userId === req.user!.id); + res.json(myCerts); +}); + // --- VITE MIDDLEWARE / SPA FALLBACK --- async function startServer() { if (process.env.NODE_ENV !== 'production') { diff --git a/src/App.tsx b/src/App.tsx index ddfaca6..fa803c9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -9,6 +9,7 @@ import CourseCarousel from './components/CourseCarousel'; import CourseView from './components/CourseView'; import SubscriptionView from './components/SubscriptionView'; import CourseUnlockModal from './components/CourseUnlockModal'; +import CertificatesTab from './components/CertificatesTab'; import { User as UserType, Course as CourseType, SubscriptionStatus } from './types'; export default function App() { @@ -32,6 +33,8 @@ export default function App() { const [regName, setRegName] = useState(''); const [regEmail, setRegEmail] = useState(''); const [regPassword, setRegPassword] = useState(''); + const [regBirthDate, setRegBirthDate] = useState(''); + const [regWhatsapp, setRegWhatsapp] = useState(''); const [authError, setAuthError] = useState(null); const [authSuccess, setAuthSuccess] = useState(null); const [authLoading, setAuthLoading] = useState(false); @@ -129,7 +132,7 @@ export default function App() { const res = await fetch('/api/auth/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name: regName, email: regEmail, password: regPassword }) + body: JSON.stringify({ name: regName, email: regEmail, password: regPassword, birthDate: regBirthDate, whatsapp: regWhatsapp }) }); const data = await res.json(); @@ -250,6 +253,14 @@ export default function App() { /> )} + {/* RENDER VIEW: CERTIFICATES */} + {activeView === 'certificates' && ( + + )} + {/* Unlock Modal */} @@ -326,6 +337,29 @@ export default function App() { /> +
+ + setRegBirthDate(e.target.value)} + className="w-full glass-input rounded-lg p-3 text-xs text-white focus:outline-none" + /> +
+ +
+ + setRegWhatsapp(e.target.value)} + placeholder="(11) 99999-9999" + className="w-full glass-input rounded-lg p-3 text-xs text-white focus:outline-none" + /> +
+
(null); const [courseForm, setCourseForm] = useState({ - title: '', description: '', thumbnail: '', category: '', isLocked: false, price: 0 + title: '', description: '', thumbnail: '', category: '', isLocked: false, price: 0, certificateMode: 'FULL_COURSE' as 'FULL_COURSE' | 'PER_MODULE' }); const [showModuleForm, setShowModuleForm] = useState(false); @@ -62,6 +62,10 @@ export default function AdminContentManager({ token }: AdminContentManagerProps) title: '', videoUrl: '', videoType: 'youtube' as 'youtube' | 'direct', order: 1, content: '' }); + const [showActivityModal, setShowActivityModal] = useState(false); + const [activeModuleForActivity, setActiveModuleForActivity] = useState(null); + const [activityQuestions, setActivityQuestions] = useState<{question: string, options: string[], correctIndex: number}[]>([]); + useEffect(() => { fetchCourses(); }, []); @@ -125,7 +129,7 @@ export default function AdminContentManager({ token }: AdminContentManagerProps) await fetchCourses(); setShowCourseForm(false); setEditingCourse(null); - setCourseForm({ title: '', description: '', thumbnail: '', category: '', isLocked: false, price: 0 }); + setCourseForm({ title: '', description: '', thumbnail: '', category: '', isLocked: false, price: 0, certificateMode: 'FULL_COURSE' }); } else { const errData = await res.json(); alert(`Erro ao salvar curso: ${errData.error || 'Falha na resposta do servidor'}`); @@ -144,7 +148,8 @@ export default function AdminContentManager({ token }: AdminContentManagerProps) thumbnail: course.thumbnail, category: course.category, isLocked: course.isLocked || false, - price: course.price || 0 + price: course.price || 0, + certificateMode: course.certificateMode || 'FULL_COURSE' }); setShowCourseForm(true); setTimeout(() => { @@ -399,14 +404,26 @@ export default function AdminContentManager({ token }: AdminContentManagerProps) setCourseForm({ ...courseForm, price: Number(e.target.value) })} - placeholder="Ex: 89.90" - className="w-full glass-input rounded-lg p-3 text-sm text-white focus:outline-none font-mono" + className="w-full glass-input rounded-lg p-3 text-sm text-white focus:outline-none" />
-
+
+ + +
+ +
)} + + {/* --- ACTIVITY MODAL --- */} + {showActivityModal && ( +
+
+

Gerenciar Atividades do Módulo

+ +
+ {activityQuestions.map((q, qIndex) => ( +
+
+

Pergunta {qIndex + 1}

+ +
+ { + const newQ = [...activityQuestions]; + newQ[qIndex].question = e.target.value; + setActivityQuestions(newQ); + }} + placeholder="Digite a pergunta..." + className="w-full glass-input rounded-lg p-3 text-sm text-white focus:outline-none" + /> + +
+ + {q.options.map((opt, optIndex) => ( +
+ { + const newQ = [...activityQuestions]; + newQ[qIndex].correctIndex = optIndex; + setActivityQuestions(newQ); + }} + className="w-4 h-4" + /> + { + const newQ = [...activityQuestions]; + newQ[qIndex].options[optIndex] = e.target.value; + setActivityQuestions(newQ); + }} + placeholder={`Opção ${optIndex + 1}`} + className="w-full bg-zinc-800/50 border border-white/5 rounded p-2 text-xs text-white focus:outline-none" + /> +
+ ))} +
+
+ ))} + + +
+ +
+ + +
+
+
+ )}
); } diff --git a/src/components/AdminDashboard.tsx b/src/components/AdminDashboard.tsx index 8a8a849..05eac23 100644 --- a/src/components/AdminDashboard.tsx +++ b/src/components/AdminDashboard.tsx @@ -21,6 +21,12 @@ interface Student { createdAt: string; totalPaid: number; unlockedCourses?: string[]; + moduleGrades?: { id: string, activityId: string, score: number, passed: boolean }[]; + certificates?: { id: string, courseId: string | null, moduleId: string | null, certificateType: string, unlockedAt: string, finalGrade: number | null, courseName: string, moduleName: string | null }[]; + birthDate?: string; + whatsapp?: string; + trialEndsAt?: string | null; + payments?: PaymentLog[]; } interface PaymentLog { @@ -58,16 +64,59 @@ export default function AdminDashboard({ token }: AdminDashboardProps) { const [expandedStudentId, setExpandedStudentId] = useState(null); const [searchQuery, setSearchQuery] = useState(''); - const [activeTab, setActiveTab] = useState<'finance' | 'students' | 'webhooks'>('finance'); + const [activeTab, setActiveTab] = useState<'finance' | 'students' | 'webhooks' | 'settings'>('finance'); const [isLoading, setIsLoading] = useState(true); const [actionLoading, setActionLoading] = useState(null); + // Settings State + const [settings, setSettings] = useState(null); + const [isSavingSettings, setIsSavingSettings] = useState(false); + useEffect(() => { fetchDashboardData(); fetchStudents(); fetchCourses(); + fetchSettings(); }, []); + const fetchSettings = async () => { + try { + const res = await fetch('/api/admin/settings', { + headers: { 'Authorization': `Bearer ${token}` } + }); + if (res.ok) { + const data = await res.json(); + setSettings(data); + } + } catch (err) { + console.error('Error fetching settings:', err); + } + }; + + const handleSaveSettings = async (e: React.FormEvent) => { + e.preventDefault(); + setIsSavingSettings(true); + try { + const res = await fetch('/api/admin/settings', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + }, + body: JSON.stringify(settings) + }); + if (res.ok) { + alert('Configurações salvas com sucesso!'); + } else { + alert('Erro ao salvar configurações.'); + } + } catch (err) { + console.error('Error saving settings:', err); + } finally { + setIsSavingSettings(false); + } + }; + const fetchCourses = async () => { try { const res = await fetch('/api/courses', { @@ -160,6 +209,30 @@ export default function AdminDashboard({ token }: AdminDashboardProps) { } }; + const handleUnlockCertificate = async (studentId: string, type: 'COURSE' | 'MODULE', targetId: string, courseName: string, moduleName?: string) => { + setActionLoading(`cert_${studentId}_${targetId}`); + try { + const res = await fetch(`/api/admin/students/${studentId}/unlock-certificate`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + }, + body: JSON.stringify({ type, targetId, courseName, moduleName }) + }); + if (res.ok) { + await fetchStudents(); + alert('Certificado liberado manualmente!'); + } else { + alert('Erro ao liberar certificado.'); + } + } catch (err) { + console.error('Error unlocking certificate:', err); + } finally { + setActionLoading(null); + } + }; + const getStatusTag = (status: SubscriptionStatus) => { switch (status) { case 'ACTIVE': @@ -288,23 +361,34 @@ export default function AdminDashboard({ token }: AdminDashboardProps) { {/* TABS CONTAINER */}
- - -
@@ -415,16 +499,14 @@ export default function AdminDashboard({ token }: AdminDashboardProps) { filteredStudents.map(student => ( - -
{student.name}
-
{student.email}
- {student.unlockedCourses && student.unlockedCourses.length > 0 && ( -
- - {student.unlockedCourses.length} {student.unlockedCourses.length === 1 ? 'Curso Avulso' : 'Cursos Avulsos'} - + +
+
+
{student.name}
+
{student.email}
+ {student.whatsapp &&
WA: {student.whatsapp}
}
- )} +
{student.asaasCustomerId || Sem vínculo} @@ -531,6 +613,55 @@ export default function AdminDashboard({ token }: AdminDashboardProps) { })}
)} +
+ + {/* --- CERTIFICATE MANAGEMENT --- */} +
+
+

+ Certificados & Desempenho +

+
+ +
+ {/* Grades Table */} +
+

Notas do Aluno

+ {(!student.moduleGrades || student.moduleGrades.length === 0) ? ( +

Nenhuma avaliação realizada ainda.

+ ) : ( +
+ {student.moduleGrades.map((grade, idx) => ( +
+ Avaliação {grade.activityId.substring(0, 8)} + + Nota: {grade.score.toFixed(1)} + +
+ ))} +
+ )} +
+ + {/* Certificates Manager */} +
+

Desbloqueio de Certificados

+ {courses.map(course => ( +
+
+ {course.title} + +
+
+ ))} +
+
+
@@ -607,6 +738,104 @@ export default function AdminDashboard({ token }: AdminDashboardProps) { )} + {/* SETTINGS TAB */} + {activeTab === 'settings' && settings && ( +
+
+ +

Configurações do Sistema & Asaas (Sandbox)

+
+ +
+
+ + {/* Período de Teste */} +
+

Geral

+ +
+ + setSettings({...settings, trialDurationDays: parseInt(e.target.value) || 7})} + className="w-full bg-black/40 border border-white/10 rounded-lg p-3 text-sm text-white focus:outline-none focus:border-primary transition-colors" + required + min="1" + /> +

Dias grátis que um novo aluno ganha ao se cadastrar.

+
+
+ + {/* Integração Asaas */} +
+

Integração Asaas

+ +
+ + +
+ +
+ + setSettings({...settings, asaasApiKey: e.target.value})} + className="w-full bg-black/40 border border-white/10 rounded-lg p-3 text-sm text-white focus:outline-none focus:border-primary transition-colors" + placeholder="$aact_..." + /> +
+ +
+ + setSettings({...settings, asaasWebhookUrl: e.target.value})} + className="w-full bg-black/40 border border-white/10 rounded-lg p-3 text-sm text-white focus:outline-none focus:border-primary transition-colors" + placeholder="https://seu-dominio.com.br/api/webhooks/asaas" + /> +
+ +
+ + setSettings({...settings, asaasWebhookSecret: e.target.value})} + className="w-full bg-black/40 border border-white/10 rounded-lg p-3 text-sm text-white focus:outline-none focus:border-primary transition-colors" + placeholder="Token gerado no Asaas" + /> +
+ +
+
+ +
+ +
+
+
+ )} ); diff --git a/src/components/CertificateView.tsx b/src/components/CertificateView.tsx new file mode 100644 index 0000000..d6d2774 --- /dev/null +++ b/src/components/CertificateView.tsx @@ -0,0 +1,121 @@ +import React, { useRef } from 'react'; +import { Download, Award, ShieldCheck } from 'lucide-react'; +// @ts-ignore +// html2pdf is loaded globally via CDN in index.html + +interface CertificateViewProps { + certificate: { + id: string; + courseName: string; + moduleName?: string | null; + certificateType: 'COURSE' | 'MODULE'; + unlockedAt: string; + finalGrade: number | null; + }; + studentName: string; +} + +export default function CertificateView({ certificate, studentName }: CertificateViewProps) { + const certRef = useRef(null); + + const handleDownload = () => { + if (certRef.current) { + const opt = { + margin: 0, + filename: `Certificado_${certificate.courseName.replace(/\s+/g, '_')}.pdf`, + image: { type: 'jpeg', quality: 1 }, + html2canvas: { scale: 2, useCORS: true }, + jsPDF: { unit: 'in', format: 'letter', orientation: 'landscape' } + }; + + // @ts-ignore + html2pdf().set(opt).from(certRef.current).save(); + } + }; + + return ( +
+
+

+ + Seu Certificado +

+ +
+ +
+ {/* Certificate Container (Will be converted to PDF) */} +
+ {/* Decorative Borders */} +
+
+
+ +
+
+ + {/* Content */} +
+ +
+ +
+ +

+ Certificado de Conclusão +

+

+ MicrotecFlix - Plataforma de Estudos +

+ +

Certificamos que

+ +

+ {studentName} +

+ +

+ concluiu com êxito o {certificate.certificateType === 'COURSE' ? 'curso' : 'módulo'}: +

+ +

+ {certificate.certificateType === 'COURSE' + ? certificate.courseName + : `${certificate.moduleName} (${certificate.courseName})`} +

+ +
+
+

Data de Conclusão

+

{new Date(certificate.unlockedAt).toLocaleDateString('pt-BR')}

+
+ + {certificate.finalGrade !== null && ( +
+

Nota Final

+

{certificate.finalGrade.toFixed(1)} / 10

+
+ )} + +
+

Código de Validação

+

{certificate.id.toUpperCase()}

+
+
+ +
+
+
+
+ ); +} diff --git a/src/components/CertificatesTab.tsx b/src/components/CertificatesTab.tsx new file mode 100644 index 0000000..bc912da --- /dev/null +++ b/src/components/CertificatesTab.tsx @@ -0,0 +1,75 @@ +import React, { useState, useEffect } from 'react'; +import CertificateView from './CertificateView'; +import { Award, RefreshCw } from 'lucide-react'; + +interface CertificatesTabProps { + token: string | null; + studentName: string; +} + +export default function CertificatesTab({ token, studentName }: CertificatesTabProps) { + const [certificates, setCertificates] = useState([]); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + fetchCertificates(); + }, []); + + const fetchCertificates = async () => { + setIsLoading(true); + try { + const res = await fetch('/api/certificates', { + headers: { 'Authorization': `Bearer ${token}` } + }); + if (res.ok) { + const data = await res.json(); + setCertificates(data); + } + } catch (err) { + console.error('Error fetching certificates:', err); + } finally { + setIsLoading(false); + } + }; + + if (isLoading) { + return ( +
+ +

Carregando seus certificados...

+
+ ); + } + + if (certificates.length === 0) { + return ( +
+ +

Nenhum certificado ainda

+

+ Complete os cursos e as avaliações dos módulos para desbloquear seus certificados. +

+
+ ); + } + + return ( +
+
+

+ + Meus Certificados +

+

+ Você possui {certificates.length} {certificates.length === 1 ? 'certificado' : 'certificados'} desbloqueados. +

+
+ +
+ {certificates.map(cert => ( + + ))} +
+
+ ); +} diff --git a/src/components/CourseActivity.tsx b/src/components/CourseActivity.tsx new file mode 100644 index 0000000..bc2f702 --- /dev/null +++ b/src/components/CourseActivity.tsx @@ -0,0 +1,201 @@ +import React, { useState, useEffect } from 'react'; +import { ArrowLeft, CheckCircle, XCircle, Award } from 'lucide-react'; + +interface CourseActivityProps { + token: string | null; + moduleId: string; + onBack: () => void; + onCompleted: (passed: boolean) => void; +} + +export default function CourseActivity({ token, moduleId, onBack, onCompleted }: CourseActivityProps) { + const [activity, setActivity] = useState(null); + const [answers, setAnswers] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [result, setResult] = useState<{ score: number, passed: boolean } | null>(null); + + useEffect(() => { + fetchActivity(); + }, [moduleId]); + + const fetchActivity = async () => { + setIsLoading(true); + try { + const res = await fetch(`/api/modules/${moduleId}/activity`, { + headers: { 'Authorization': `Bearer ${token}` } + }); + if (res.ok) { + const data = await res.json(); + setActivity(data); + setAnswers(new Array(data.questions.length).fill(-1)); + } else { + setActivity(null); + } + } catch (err) { + console.error('Error fetching activity:', err); + } finally { + setIsLoading(false); + } + }; + + const handleSelectAnswer = (qIndex: number, optIndex: number) => { + const newAnswers = [...answers]; + newAnswers[qIndex] = optIndex; + setAnswers(newAnswers); + }; + + const handleSubmit = async () => { + if (answers.includes(-1)) { + alert('Por favor, responda todas as perguntas.'); + return; + } + + setIsLoading(true); + try { + const res = await fetch(`/api/modules/${moduleId}/submit-activity`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + }, + body: JSON.stringify({ answers }) + }); + + if (res.ok) { + const data = await res.json(); + setResult({ score: data.score, passed: data.passed }); + if (data.passed) { + onCompleted(true); + } + } else { + alert('Erro ao enviar atividade.'); + } + } catch (err) { + console.error('Error submitting activity:', err); + } finally { + setIsLoading(false); + } + }; + + if (isLoading) { + return ( +
+
+

Carregando atividade...

+
+ ); + } + + if (!activity || !activity.questions || activity.questions.length === 0) { + return ( +
+ +

Nenhuma avaliação disponível

+

Este módulo ainda não possui uma atividade cadastrada.

+ +
+ ); + } + + if (result) { + return ( +
+
+
+ + {result.passed ? ( +
+
+ +
+

Parabéns!

+

Você foi aprovado nesta atividade.

+
+

Sua Nota: {result.score.toFixed(1)} / 10

+
+
+ ) : ( +
+
+ +
+

Não foi dessa vez.

+

Sua nota não atingiu a média mínima de 6.0.

+
+

Sua Nota: {result.score.toFixed(1)} / 10

+
+

Revise o conteúdo e tente novamente.

+
+ )} + +
+ + {!result.passed && ( + + )} +
+
+
+ ); + } + + return ( +
+
+ +

Avaliação do Módulo

+
+ +
+ {activity.questions.map((q: any, qIndex: number) => ( +
+

+ {qIndex + 1}. + {q.question} +

+
+ {q.options.map((opt: string, optIndex: number) => ( + + ))} +
+
+ ))} + +
+ +
+
+
+ ); +} diff --git a/src/components/CourseView.tsx b/src/components/CourseView.tsx index 3c54f78..c413296 100644 --- a/src/components/CourseView.tsx +++ b/src/components/CourseView.tsx @@ -1,6 +1,7 @@ import React, { useState, useEffect } from 'react'; -import { ArrowLeft, BookOpen, ChevronRight, FileText, CheckCircle2, Circle, MessageSquare, AlertCircle } from 'lucide-react'; +import { ArrowLeft, BookOpen, ChevronRight, FileText, CheckCircle2, Circle, MessageSquare, AlertCircle, Award } from 'lucide-react'; import VideoPlayer from './VideoPlayer'; +import CourseActivity from './CourseActivity'; interface LessonProgress { watchedPercentage: number; @@ -49,6 +50,7 @@ export default function CourseView({ courseId, token, onBack }: CourseViewProps) const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle'); + const [activeActivityModuleId, setActiveActivityModuleId] = useState(null); useEffect(() => { fetchCourseDetails(); @@ -209,6 +211,22 @@ export default function CourseView({ courseId, token, onBack }: CourseViewProps) ); } + if (activeActivityModuleId) { + return ( + setActiveActivityModuleId(null)} + onCompleted={(passed) => { + if (passed) { + // Re-fetch course details to update any unlocked certificates if applicable + fetchCourseDetails(); + } + }} + /> + ); + } + return (
{/* Back Header */} @@ -367,6 +385,19 @@ export default function CourseView({ courseId, token, onBack }: CourseViewProps) ); }) )} + + {/* Module Activity Button */} + {mod.lessons.length > 0 && ( +
+ +
+ )}
))} diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx index 9948278..d39d46e 100644 --- a/src/components/Navbar.tsx +++ b/src/components/Navbar.tsx @@ -62,10 +62,18 @@ export default function Navbar({ user, activeView, setView, onLogout }: NavbarPr Cursos + diff --git a/src/db/db.ts b/src/db/db.ts index 413d47d..c040878 100644 --- a/src/db/db.ts +++ b/src/db/db.ts @@ -14,6 +14,10 @@ export interface DatabaseSchema { progress: LessonProgress[]; webhookLogs: WebhookLog[]; payments: SubscriptionPayment[]; + activities: any[]; + moduleGrades: any[]; + certificates: any[]; + settings: any; // SystemSettings } const DEFAULT_DB: DatabaseSchema = { @@ -24,7 +28,17 @@ const DEFAULT_DB: DatabaseSchema = { notes: [], progress: [], webhookLogs: [], - payments: [] + payments: [], + activities: [], + moduleGrades: [], + certificates: [], + settings: { + trialDurationDays: 7, + asaasEnvironment: 'sandbox', + asaasApiKey: '', + asaasWebhookUrl: '', + asaasWebhookSecret: '' + } }; // Helper to load db @@ -395,6 +409,16 @@ function createInitialDb(): DatabaseSchema { notes, progress, webhookLogs, - payments + payments, + activities: [], + moduleGrades: [], + certificates: [], + settings: { + trialDurationDays: 7, + asaasEnvironment: 'sandbox', + asaasApiKey: '', + asaasWebhookUrl: '', + asaasWebhookSecret: '' + } }; } diff --git a/src/services/asaas.ts b/src/services/asaas.ts index a1b50ac..0542597 100644 --- a/src/services/asaas.ts +++ b/src/services/asaas.ts @@ -1,15 +1,16 @@ import { SubscriptionPayment } from '../types'; +import { loadDb } from '../db/db'; export class AsaasService { private static getApiKey(): string | null { - return process.env.ASAAS_API_KEY || null; + const db = loadDb(); + return db.settings?.asaasApiKey || process.env.ASAAS_API_KEY || null; } private static getApiUrl(): string { - // If we have an API key, check if it looks like a production or sandbox one. - // Standard sandbox keys often start with '$' or are long hashes, let's allow configuring via env or default to sandbox - const isProd = process.env.ASAAS_ENV === 'production'; - return isProd ? 'https://api.asaas.com/v3' : 'https://sandbox.asaas.com/v3'; + const db = loadDb(); + const env = db.settings?.asaasEnvironment || 'sandbox'; + return env === 'production' ? 'https://api.asaas.com/v3' : 'https://sandbox.asaas.com/v3'; } private static getHeaders() { diff --git a/src/types.ts b/src/types.ts index 5132c3e..e158fe3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -10,15 +10,27 @@ export interface User { subscriptionStatus: SubscriptionStatus; asaasCustomerId: string | null; unlockedCourses?: string[]; // IDs of courses unlocked individually + birthDate?: string; + whatsapp?: string; + trialEndsAt?: string | null; createdAt: string; } +export interface SystemSettings { + trialDurationDays: number; + asaasEnvironment: 'sandbox' | 'production'; + asaasApiKey: string; + asaasWebhookUrl: string; + asaasWebhookSecret: string; +} + export interface Course { id: string; title: string; description: string; thumbnail: string; category: string; + certificateMode?: 'PER_MODULE' | 'FULL_COURSE'; isLocked?: boolean; // If true, requires payment/unlock price?: number; // Cost to unlock if locked (e.g. 89.90) createdAt: string; @@ -85,3 +97,31 @@ export interface AdminDashboardStats { overdueSubscribers: number; canceledSubscribers: number; } + +export interface ModuleActivity { + id: string; + moduleId: string; + questions: any[]; + createdAt: string; +} + +export interface ModuleGrade { + id: string; + userId: string; + activityId: string; + score: number; + passed: boolean; + createdAt: string; +} + +export interface Certificate { + id: string; + userId: string; + courseId: string | null; + moduleId: string | null; + certificateType: 'COURSE' | 'MODULE'; + unlockedAt: string; + finalGrade: number | null; + courseName: string; + moduleName: string | null; +}