feat: Adiciona campos de cadastro e configuracoes do Asaas Sandbox
Build and Deploy (Gitea) / build-and-deploy (push) Failing after 29s
Details
Build and Deploy (Gitea) / build-and-deploy (push) Failing after 29s
Details
This commit is contained in:
parent
4c71df1c9a
commit
fc17ccc499
|
|
@ -3,7 +3,8 @@
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>My Google AI Studio App</title>
|
<title>MicrotecFlix</title>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/html2pdf.js/0.10.1/html2pdf.bundle.min.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,11 @@ enum BillingType {
|
||||||
BOLETO
|
BOLETO
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum CertificateMode {
|
||||||
|
PER_MODULE
|
||||||
|
FULL_COURSE
|
||||||
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
name String
|
name String
|
||||||
|
|
@ -53,19 +58,22 @@ model User {
|
||||||
notes Note[]
|
notes Note[]
|
||||||
progress Progress[]
|
progress Progress[]
|
||||||
payments Payment[]
|
payments Payment[]
|
||||||
|
moduleGrades ModuleGrade[]
|
||||||
|
certificates Certificate[]
|
||||||
|
|
||||||
@@map("users")
|
@@map("users")
|
||||||
}
|
}
|
||||||
|
|
||||||
model Course {
|
model Course {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
title String
|
title String
|
||||||
description String @db.Text
|
description String @db.Text
|
||||||
thumbnail String
|
thumbnail String
|
||||||
category String
|
category String
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
certificateMode CertificateMode @default(FULL_COURSE) @map("certificate_mode")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
modules Module[]
|
modules Module[]
|
||||||
|
|
||||||
@@map("courses")
|
@@map("courses")
|
||||||
}
|
}
|
||||||
|
|
@ -79,6 +87,7 @@ model Module {
|
||||||
|
|
||||||
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
|
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
|
||||||
lessons Lesson[]
|
lessons Lesson[]
|
||||||
|
activity ModuleActivity?
|
||||||
|
|
||||||
@@map("modules")
|
@@map("modules")
|
||||||
}
|
}
|
||||||
|
|
@ -153,3 +162,46 @@ model WebhookLog {
|
||||||
|
|
||||||
@@map("webhook_logs")
|
@@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")
|
||||||
|
}
|
||||||
|
|
|
||||||
283
server.ts
283
server.ts
|
|
@ -56,9 +56,9 @@ function requireAdmin(req: AuthenticatedRequest, res: Response, next: NextFuncti
|
||||||
|
|
||||||
// 1. Auth: Register
|
// 1. Auth: Register
|
||||||
app.post('/api/auth/register', async (req: Request, res: Response) => {
|
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' });
|
res.status(400).json({ error: 'Preencha todos os campos obrigatórios' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -78,6 +78,13 @@ app.post('/api/auth/register', async (req: Request, res: Response) => {
|
||||||
const salt = bcrypt.genSaltSync(10);
|
const salt = bcrypt.genSaltSync(10);
|
||||||
const passwordHash = bcrypt.hashSync(password, salt);
|
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 = {
|
const newUser: User = {
|
||||||
id: `usr_${Math.random().toString(36).substring(2, 9)}`,
|
id: `usr_${Math.random().toString(36).substring(2, 9)}`,
|
||||||
name,
|
name,
|
||||||
|
|
@ -86,7 +93,10 @@ app.post('/api/auth/register', async (req: Request, res: Response) => {
|
||||||
role: 'student',
|
role: 'student',
|
||||||
subscriptionStatus: 'TRIAL', // Defaults to trial
|
subscriptionStatus: 'TRIAL', // Defaults to trial
|
||||||
asaasCustomerId,
|
asaasCustomerId,
|
||||||
createdAt: new Date().toISOString()
|
birthDate,
|
||||||
|
whatsapp,
|
||||||
|
trialEndsAt: trialEndsAt.toISOString(),
|
||||||
|
createdAt: createdAt.toISOString()
|
||||||
};
|
};
|
||||||
|
|
||||||
db.users.push(newUser);
|
db.users.push(newUser);
|
||||||
|
|
@ -535,6 +545,8 @@ app.get('/api/admin/students', authenticateToken, requireAdmin, (req: Authentica
|
||||||
.filter(u => u.role === 'student')
|
.filter(u => u.role === 'student')
|
||||||
.map(u => {
|
.map(u => {
|
||||||
const studentPayments = db.payments.filter(p => p.userId === u.id);
|
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 {
|
return {
|
||||||
id: u.id,
|
id: u.id,
|
||||||
name: u.name,
|
name: u.name,
|
||||||
|
|
@ -542,10 +554,16 @@ app.get('/api/admin/students', authenticateToken, requireAdmin, (req: Authentica
|
||||||
subscriptionStatus: u.subscriptionStatus,
|
subscriptionStatus: u.subscriptionStatus,
|
||||||
asaasCustomerId: u.asaasCustomerId,
|
asaasCustomerId: u.asaasCustomerId,
|
||||||
createdAt: u.createdAt,
|
createdAt: u.createdAt,
|
||||||
|
birthDate: u.birthDate,
|
||||||
|
whatsapp: u.whatsapp,
|
||||||
|
trialEndsAt: u.trialEndsAt,
|
||||||
totalPaid: studentPayments
|
totalPaid: studentPayments
|
||||||
.filter(p => p.status === 'CONFIRMED' || p.status === 'RECEIVED')
|
.filter(p => p.status === 'CONFIRMED' || p.status === 'RECEIVED')
|
||||||
.reduce((acc, curr) => acc + curr.value, 0),
|
.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 });
|
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
|
// Admin manually unlock or lock a course for a student
|
||||||
app.post('/api/admin/students/:id/unlock-course', authenticateToken, requireAdmin, (req: AuthenticatedRequest, res: Response) => {
|
app.post('/api/admin/students/:id/unlock-course', authenticateToken, requireAdmin, (req: AuthenticatedRequest, res: Response) => {
|
||||||
const { courseId, unlocked } = req.body;
|
const { courseId, unlocked } = req.body;
|
||||||
|
|
@ -937,6 +980,238 @@ app.post('/api/webhooks/asaas', (req: Request, res: Response) => {
|
||||||
res.status(200).json({ received: true });
|
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 ---
|
// --- VITE MIDDLEWARE / SPA FALLBACK ---
|
||||||
async function startServer() {
|
async function startServer() {
|
||||||
if (process.env.NODE_ENV !== 'production') {
|
if (process.env.NODE_ENV !== 'production') {
|
||||||
|
|
|
||||||
36
src/App.tsx
36
src/App.tsx
|
|
@ -9,6 +9,7 @@ import CourseCarousel from './components/CourseCarousel';
|
||||||
import CourseView from './components/CourseView';
|
import CourseView from './components/CourseView';
|
||||||
import SubscriptionView from './components/SubscriptionView';
|
import SubscriptionView from './components/SubscriptionView';
|
||||||
import CourseUnlockModal from './components/CourseUnlockModal';
|
import CourseUnlockModal from './components/CourseUnlockModal';
|
||||||
|
import CertificatesTab from './components/CertificatesTab';
|
||||||
import { User as UserType, Course as CourseType, SubscriptionStatus } from './types';
|
import { User as UserType, Course as CourseType, SubscriptionStatus } from './types';
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
|
|
@ -32,6 +33,8 @@ export default function App() {
|
||||||
const [regName, setRegName] = useState('');
|
const [regName, setRegName] = useState('');
|
||||||
const [regEmail, setRegEmail] = useState('');
|
const [regEmail, setRegEmail] = useState('');
|
||||||
const [regPassword, setRegPassword] = useState('');
|
const [regPassword, setRegPassword] = useState('');
|
||||||
|
const [regBirthDate, setRegBirthDate] = useState('');
|
||||||
|
const [regWhatsapp, setRegWhatsapp] = useState('');
|
||||||
const [authError, setAuthError] = useState<string | null>(null);
|
const [authError, setAuthError] = useState<string | null>(null);
|
||||||
const [authSuccess, setAuthSuccess] = useState<string | null>(null);
|
const [authSuccess, setAuthSuccess] = useState<string | null>(null);
|
||||||
const [authLoading, setAuthLoading] = useState(false);
|
const [authLoading, setAuthLoading] = useState(false);
|
||||||
|
|
@ -129,7 +132,7 @@ export default function App() {
|
||||||
const res = await fetch('/api/auth/register', {
|
const res = await fetch('/api/auth/register', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
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();
|
const data = await res.json();
|
||||||
|
|
||||||
|
|
@ -250,6 +253,14 @@ export default function App() {
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* RENDER VIEW: CERTIFICATES */}
|
||||||
|
{activeView === 'certificates' && (
|
||||||
|
<CertificatesTab
|
||||||
|
token={token}
|
||||||
|
studentName={user.name}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{/* Unlock Modal */}
|
{/* Unlock Modal */}
|
||||||
|
|
@ -326,6 +337,29 @@ export default function App() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-[10px] text-zinc-400 font-bold uppercase tracking-wider">Data de Nascimento</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
required
|
||||||
|
value={regBirthDate}
|
||||||
|
onChange={(e) => setRegBirthDate(e.target.value)}
|
||||||
|
className="w-full glass-input rounded-lg p-3 text-xs text-white focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-[10px] text-zinc-400 font-bold uppercase tracking-wider">WhatsApp</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={regWhatsapp}
|
||||||
|
onChange={(e) => setRegWhatsapp(e.target.value)}
|
||||||
|
placeholder="(11) 99999-9999"
|
||||||
|
className="w-full glass-input rounded-lg p-3 text-xs text-white focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<label className="text-[10px] text-zinc-400 font-bold uppercase tracking-wider">Crie uma Senha</label>
|
<label className="text-[10px] text-zinc-400 font-bold uppercase tracking-wider">Crie uma Senha</label>
|
||||||
<input
|
<input
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,7 @@ export default function AdminContentManager({ token }: AdminContentManagerProps)
|
||||||
const [showCourseForm, setShowCourseForm] = useState(false);
|
const [showCourseForm, setShowCourseForm] = useState(false);
|
||||||
const [editingCourse, setEditingCourse] = useState<Course | null>(null);
|
const [editingCourse, setEditingCourse] = useState<Course | null>(null);
|
||||||
const [courseForm, setCourseForm] = useState({
|
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);
|
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: ''
|
title: '', videoUrl: '', videoType: 'youtube' as 'youtube' | 'direct', order: 1, content: ''
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [showActivityModal, setShowActivityModal] = useState(false);
|
||||||
|
const [activeModuleForActivity, setActiveModuleForActivity] = useState<string | null>(null);
|
||||||
|
const [activityQuestions, setActivityQuestions] = useState<{question: string, options: string[], correctIndex: number}[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchCourses();
|
fetchCourses();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
@ -125,7 +129,7 @@ export default function AdminContentManager({ token }: AdminContentManagerProps)
|
||||||
await fetchCourses();
|
await fetchCourses();
|
||||||
setShowCourseForm(false);
|
setShowCourseForm(false);
|
||||||
setEditingCourse(null);
|
setEditingCourse(null);
|
||||||
setCourseForm({ title: '', description: '', thumbnail: '', category: '', isLocked: false, price: 0 });
|
setCourseForm({ title: '', description: '', thumbnail: '', category: '', isLocked: false, price: 0, certificateMode: 'FULL_COURSE' });
|
||||||
} else {
|
} else {
|
||||||
const errData = await res.json();
|
const errData = await res.json();
|
||||||
alert(`Erro ao salvar curso: ${errData.error || 'Falha na resposta do servidor'}`);
|
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,
|
thumbnail: course.thumbnail,
|
||||||
category: course.category,
|
category: course.category,
|
||||||
isLocked: course.isLocked || false,
|
isLocked: course.isLocked || false,
|
||||||
price: course.price || 0
|
price: course.price || 0,
|
||||||
|
certificateMode: course.certificateMode || 'FULL_COURSE'
|
||||||
});
|
});
|
||||||
setShowCourseForm(true);
|
setShowCourseForm(true);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
|
@ -399,14 +404,26 @@ export default function AdminContentManager({ token }: AdminContentManagerProps)
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
step="0.01"
|
step="0.01"
|
||||||
|
min="0"
|
||||||
value={courseForm.price}
|
value={courseForm.price}
|
||||||
onChange={(e) => setCourseForm({ ...courseForm, price: Number(e.target.value) })}
|
onChange={(e) => 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"
|
||||||
className="w-full glass-input rounded-lg p-3 text-sm text-white focus:outline-none font-mono"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2 flex flex-col justify-center">
|
<div className="space-y-2">
|
||||||
|
<label className="text-xs text-zinc-400 font-bold uppercase tracking-wider">Modo do Certificado</label>
|
||||||
|
<select
|
||||||
|
value={courseForm.certificateMode}
|
||||||
|
onChange={(e) => setCourseForm({ ...courseForm, certificateMode: e.target.value as 'FULL_COURSE' | 'PER_MODULE' })}
|
||||||
|
className="w-full glass-input rounded-lg p-3 text-sm text-white focus:outline-none appearance-none"
|
||||||
|
>
|
||||||
|
<option value="FULL_COURSE">Por Curso Completo (Ao finalizar tudo)</option>
|
||||||
|
<option value="PER_MODULE">Por Módulo (Certificado para cada módulo)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="md:col-span-2 space-y-2 flex flex-col justify-center">
|
||||||
<label className="text-xs text-zinc-400 font-bold uppercase tracking-wider mb-2">Status de Bloqueio</label>
|
<label className="text-xs text-zinc-400 font-bold uppercase tracking-wider mb-2">Status de Bloqueio</label>
|
||||||
<label className="inline-flex items-center space-x-3 cursor-pointer">
|
<label className="inline-flex items-center space-x-3 cursor-pointer">
|
||||||
<input
|
<input
|
||||||
|
|
@ -636,6 +653,15 @@ export default function AdminContentManager({ token }: AdminContentManagerProps)
|
||||||
<span>Nova Aula</span>
|
<span>Nova Aula</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{/* Manage Activity */}
|
||||||
|
<button
|
||||||
|
onClick={() => handleManageActivity(mod.id)}
|
||||||
|
className="bg-zinc-800 text-zinc-300 hover:text-white hover:bg-zinc-700 text-[10px] font-bold px-2 py-1 rounded flex items-center space-x-1 border border-zinc-700 transition-colors"
|
||||||
|
>
|
||||||
|
<CheckCircle className="w-3 h-3 text-emerald-500" />
|
||||||
|
<span>Gerenciar Atividade</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
{/* Edit Module */}
|
{/* Edit Module */}
|
||||||
<button
|
<button
|
||||||
onClick={() => handleEditModule(mod)}
|
onClick={() => handleEditModule(mod)}
|
||||||
|
|
@ -759,6 +785,88 @@ export default function AdminContentManager({ token }: AdminContentManagerProps)
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* --- ACTIVITY MODAL --- */}
|
||||||
|
{showActivityModal && (
|
||||||
|
<div className="fixed inset-0 bg-black/80 flex items-center justify-center z-50 p-4 overflow-y-auto">
|
||||||
|
<div className="bg-zinc-900 border border-white/10 rounded-2xl w-full max-w-4xl p-6 relative flex flex-col max-h-[90vh]">
|
||||||
|
<h3 className="text-xl font-bold text-white mb-4">Gerenciar Atividades do Módulo</h3>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto space-y-6 pr-2">
|
||||||
|
{activityQuestions.map((q, qIndex) => (
|
||||||
|
<div key={qIndex} className="bg-black/50 border border-white/5 rounded-xl p-4 space-y-4">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<h4 className="text-sm font-bold text-zinc-300">Pergunta {qIndex + 1}</h4>
|
||||||
|
<button
|
||||||
|
onClick={() => setActivityQuestions(activityQuestions.filter((_, i) => i !== qIndex))}
|
||||||
|
className="text-red-400 hover:text-red-300 text-xs"
|
||||||
|
>
|
||||||
|
Remover Pergunta
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={q.question}
|
||||||
|
onChange={(e) => {
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="space-y-2 pl-4 border-l-2 border-white/10">
|
||||||
|
<label className="text-xs font-bold text-zinc-500 uppercase">Opções (Marque a correta)</label>
|
||||||
|
{q.options.map((opt, optIndex) => (
|
||||||
|
<div key={optIndex} className="flex items-center space-x-2">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name={`correct-${qIndex}`}
|
||||||
|
checked={q.correctIndex === optIndex}
|
||||||
|
onChange={() => {
|
||||||
|
const newQ = [...activityQuestions];
|
||||||
|
newQ[qIndex].correctIndex = optIndex;
|
||||||
|
setActivityQuestions(newQ);
|
||||||
|
}}
|
||||||
|
className="w-4 h-4"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={opt}
|
||||||
|
onChange={(e) => {
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setActivityQuestions([...activityQuestions, { question: '', options: ['', '', '', ''], correctIndex: 0 }])}
|
||||||
|
className="w-full py-3 border border-dashed border-white/20 text-zinc-400 hover:text-white hover:border-white/50 rounded-xl text-sm font-bold transition-colors"
|
||||||
|
>
|
||||||
|
+ Adicionar Pergunta
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 flex justify-end space-x-4 border-t border-white/10 pt-4">
|
||||||
|
<button onClick={() => setShowActivityModal(false)} className="px-4 py-2 text-sm text-zinc-400 hover:text-white">
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button onClick={handleSaveActivity} className="glass-btn-primary px-6 py-2 rounded-lg text-sm font-bold text-white">
|
||||||
|
Salvar Atividade
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,12 @@ interface Student {
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
totalPaid: number;
|
totalPaid: number;
|
||||||
unlockedCourses?: string[];
|
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 {
|
interface PaymentLog {
|
||||||
|
|
@ -58,16 +64,59 @@ export default function AdminDashboard({ token }: AdminDashboardProps) {
|
||||||
const [expandedStudentId, setExpandedStudentId] = useState<string | null>(null);
|
const [expandedStudentId, setExpandedStudentId] = useState<string | null>(null);
|
||||||
|
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
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 [isLoading, setIsLoading] = useState(true);
|
||||||
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Settings State
|
||||||
|
const [settings, setSettings] = useState<any>(null);
|
||||||
|
const [isSavingSettings, setIsSavingSettings] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchDashboardData();
|
fetchDashboardData();
|
||||||
fetchStudents();
|
fetchStudents();
|
||||||
fetchCourses();
|
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 () => {
|
const fetchCourses = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/courses', {
|
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) => {
|
const getStatusTag = (status: SubscriptionStatus) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'ACTIVE':
|
case 'ACTIVE':
|
||||||
|
|
@ -290,21 +363,32 @@ export default function AdminDashboard({ token }: AdminDashboardProps) {
|
||||||
<div className="flex border-b border-white/10">
|
<div className="flex border-b border-white/10">
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab('finance')}
|
onClick={() => setActiveTab('finance')}
|
||||||
className={`px-5 py-3 font-semibold text-sm transition-colors border-b-2 cursor-pointer ${activeTab === 'finance' ? 'border-[#E50914] text-white' : 'border-transparent text-zinc-500 hover:text-zinc-300'}`}
|
className={`flex items-center gap-2 px-4 py-2 border-b-2 font-medium text-sm transition-colors ${
|
||||||
|
activeTab === 'finance' ? 'border-[#E50914] text-white' : 'border-transparent text-zinc-400 hover:text-white hover:border-white/20'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
Faturamento Recorrente
|
<TrendingUp size={16} />
|
||||||
|
Visão Geral
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab('students')}
|
onClick={() => setActiveTab('students')}
|
||||||
className={`px-5 py-3 font-semibold text-sm transition-colors border-b-2 cursor-pointer ${activeTab === 'students' ? 'border-[#E50914] text-white' : 'border-transparent text-zinc-500 hover:text-zinc-300'}`}
|
className={`flex items-center gap-2 px-4 py-2 border-b-2 font-medium text-sm transition-colors ${
|
||||||
|
activeTab === 'students' ? 'border-[#E50914] text-white' : 'border-transparent text-zinc-400 hover:text-white hover:border-white/20'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
Gestão de Alunos ({students.length})
|
<Users size={16} />
|
||||||
|
Alunos
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab('webhooks')}
|
onClick={() => setActiveTab('settings')}
|
||||||
className={`px-5 py-3 font-semibold text-sm transition-colors border-b-2 cursor-pointer ${activeTab === 'webhooks' ? 'border-[#E50914] text-white' : 'border-transparent text-zinc-500 hover:text-zinc-300'}`}
|
className={`flex items-center gap-2 px-4 py-2 border-b-2 font-medium text-sm transition-colors ${
|
||||||
|
activeTab === 'settings' ? 'border-[#E50914] text-white' : 'border-transparent text-zinc-400 hover:text-white hover:border-white/20'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
Auditoria de Webhooks Asaas
|
<Settings size={16} />
|
||||||
|
Configurações Globais
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -415,16 +499,14 @@ export default function AdminDashboard({ token }: AdminDashboardProps) {
|
||||||
filteredStudents.map(student => (
|
filteredStudents.map(student => (
|
||||||
<React.Fragment key={student.id}>
|
<React.Fragment key={student.id}>
|
||||||
<tr className="hover:bg-zinc-900/20 transition-colors">
|
<tr className="hover:bg-zinc-900/20 transition-colors">
|
||||||
<td className="px-6 py-4">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
<div className="font-semibold text-white">{student.name}</div>
|
<div className="flex items-center">
|
||||||
<div className="text-xs text-zinc-500">{student.email}</div>
|
<div className="ml-4">
|
||||||
{student.unlockedCourses && student.unlockedCourses.length > 0 && (
|
<div className="text-sm font-medium text-white">{student.name}</div>
|
||||||
<div className="mt-1 flex items-center gap-1">
|
<div className="text-sm text-zinc-400">{student.email}</div>
|
||||||
<span className="bg-[#E50914]/25 text-[#E50914] text-[9px] font-extrabold px-1.5 py-0.5 rounded uppercase tracking-wider">
|
{student.whatsapp && <div className="text-xs text-zinc-500">WA: {student.whatsapp}</div>}
|
||||||
{student.unlockedCourses.length} {student.unlockedCourses.length === 1 ? 'Curso Avulso' : 'Cursos Avulsos'}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 text-xs font-mono text-zinc-400">
|
<td className="px-6 py-4 text-xs font-mono text-zinc-400">
|
||||||
{student.asaasCustomerId || <span className="text-zinc-600 italic">Sem vínculo</span>}
|
{student.asaasCustomerId || <span className="text-zinc-600 italic">Sem vínculo</span>}
|
||||||
|
|
@ -531,6 +613,55 @@ export default function AdminDashboard({ token }: AdminDashboardProps) {
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* --- CERTIFICATE MANAGEMENT --- */}
|
||||||
|
<div className="mt-4 pt-4 border-t border-white/5 space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h4 className="text-xs font-bold text-zinc-300 uppercase tracking-wider flex items-center gap-1.5">
|
||||||
|
<span>Certificados & Desempenho</span>
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{/* Grades Table */}
|
||||||
|
<div className="bg-zinc-900/80 border border-white/5 rounded-lg p-3">
|
||||||
|
<p className="text-xs font-bold text-zinc-400 mb-2 border-b border-white/5 pb-2">Notas do Aluno</p>
|
||||||
|
{(!student.moduleGrades || student.moduleGrades.length === 0) ? (
|
||||||
|
<p className="text-[10px] text-zinc-500 italic">Nenhuma avaliação realizada ainda.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{student.moduleGrades.map((grade, idx) => (
|
||||||
|
<div key={idx} className="flex justify-between items-center text-[10px]">
|
||||||
|
<span className="text-zinc-300">Avaliação {grade.activityId.substring(0, 8)}</span>
|
||||||
|
<span className={`font-bold ${grade.passed ? 'text-emerald-500' : 'text-red-500'}`}>
|
||||||
|
Nota: {grade.score.toFixed(1)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Certificates Manager */}
|
||||||
|
<div className="bg-zinc-900/80 border border-white/5 rounded-lg p-3 space-y-3">
|
||||||
|
<p className="text-xs font-bold text-zinc-400 mb-2 border-b border-white/5 pb-2">Desbloqueio de Certificados</p>
|
||||||
|
{courses.map(course => (
|
||||||
|
<div key={course.id} className="flex flex-col space-y-2">
|
||||||
|
<div className="flex justify-between items-center text-[10px]">
|
||||||
|
<span className="text-zinc-300 font-bold truncate pr-2">{course.title}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => handleUnlockCertificate(student.id, 'COURSE', course.id, course.title)}
|
||||||
|
className="bg-zinc-800 hover:bg-[#E50914] text-white px-2 py-1 rounded transition-colors"
|
||||||
|
>
|
||||||
|
Liberar (Curso)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -607,6 +738,104 @@ export default function AdminDashboard({ token }: AdminDashboardProps) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{/* SETTINGS TAB */}
|
||||||
|
{activeTab === 'settings' && settings && (
|
||||||
|
<div className="glass-card rounded-xl border border-white/10 p-6 shadow-2xl">
|
||||||
|
<div className="flex items-center gap-3 mb-6">
|
||||||
|
<Settings className="text-primary" size={24} />
|
||||||
|
<h2 className="text-xl font-bold text-white">Configurações do Sistema & Asaas (Sandbox)</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSaveSettings} className="space-y-6">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
|
||||||
|
{/* Período de Teste */}
|
||||||
|
<div className="glass-card p-4 rounded-lg border border-white/5 space-y-4 bg-white/5">
|
||||||
|
<h3 className="text-sm font-bold text-white uppercase tracking-wider mb-2">Geral</h3>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs text-zinc-400 font-bold">Dias de Período de Teste (Trial)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={settings.trialDurationDays}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
<p className="text-[10px] text-zinc-500">Dias grátis que um novo aluno ganha ao se cadastrar.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Integração Asaas */}
|
||||||
|
<div className="glass-card p-4 rounded-lg border border-white/5 space-y-4 bg-white/5">
|
||||||
|
<h3 className="text-sm font-bold text-white uppercase tracking-wider mb-2">Integração Asaas</h3>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs text-zinc-400 font-bold">Ambiente</label>
|
||||||
|
<select
|
||||||
|
value={settings.asaasEnvironment}
|
||||||
|
onChange={(e) => setSettings({...settings, asaasEnvironment: 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 appearance-none"
|
||||||
|
>
|
||||||
|
<option value="sandbox">Sandbox (Testes)</option>
|
||||||
|
<option value="production">Produção (Oficial)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs text-zinc-400 font-bold">API Key</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={settings.asaasApiKey}
|
||||||
|
onChange={(e) => 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_..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs text-zinc-400 font-bold">Webhook URL</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
value={settings.asaasWebhookUrl}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs text-zinc-400 font-bold">Webhook Secret (Token)</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={settings.asaasWebhookSecret}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end mt-6">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSavingSettings}
|
||||||
|
className="bg-primary hover:bg-primary-hover text-black font-bold py-3 px-8 rounded-lg transition-colors flex items-center gap-2"
|
||||||
|
>
|
||||||
|
{isSavingSettings ? (
|
||||||
|
<RefreshCw className="animate-spin" size={18} />
|
||||||
|
) : (
|
||||||
|
<CheckCircle2 size={18} />
|
||||||
|
)}
|
||||||
|
{isSavingSettings ? 'Salvando...' : 'Salvar Configurações'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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<HTMLDivElement>(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 (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-xl font-bold text-white flex items-center gap-2">
|
||||||
|
<Award className="w-6 h-6 text-amber-400" />
|
||||||
|
<span>Seu Certificado</span>
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={handleDownload}
|
||||||
|
className="glass-btn-primary flex items-center space-x-2 px-4 py-2 rounded-lg text-white font-bold text-sm"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4" />
|
||||||
|
<span>Baixar PDF</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto p-4 bg-zinc-950 rounded-2xl border border-white/5 flex justify-center">
|
||||||
|
{/* Certificate Container (Will be converted to PDF) */}
|
||||||
|
<div
|
||||||
|
ref={certRef}
|
||||||
|
className="relative bg-white w-[800px] h-[600px] flex-shrink-0 text-zinc-800 overflow-hidden shadow-2xl"
|
||||||
|
style={{ backgroundImage: 'radial-gradient(circle at center, #ffffff 0%, #f3f4f6 100%)' }}
|
||||||
|
>
|
||||||
|
{/* Decorative Borders */}
|
||||||
|
<div className="absolute inset-4 border-2 border-amber-600/20 p-2">
|
||||||
|
<div className="absolute inset-0 border border-amber-600/40"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="absolute top-0 left-0 w-32 h-32 bg-amber-500/10 rounded-br-full"></div>
|
||||||
|
<div className="absolute bottom-0 right-0 w-32 h-32 bg-[#E50914]/10 rounded-tl-full"></div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="relative z-10 w-full h-full flex flex-col items-center justify-center p-16 text-center">
|
||||||
|
|
||||||
|
<div className="mb-6">
|
||||||
|
<ShieldCheck className="w-16 h-16 mx-auto text-amber-500" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="text-4xl font-display font-black text-zinc-900 tracking-widest uppercase mb-2">
|
||||||
|
Certificado de Conclusão
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm font-semibold tracking-[0.2em] text-zinc-500 uppercase mb-10">
|
||||||
|
MicrotecFlix - Plataforma de Estudos
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p className="text-lg text-zinc-600 italic mb-4">Certificamos que</p>
|
||||||
|
|
||||||
|
<h2 className="text-5xl font-display font-bold text-zinc-900 mb-6 border-b border-amber-500/30 pb-4 px-12 inline-block">
|
||||||
|
{studentName}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p className="text-lg text-zinc-600 mb-2">
|
||||||
|
concluiu com êxito o {certificate.certificateType === 'COURSE' ? 'curso' : 'módulo'}:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3 className="text-2xl font-bold text-zinc-800 mb-4 max-w-2xl leading-tight">
|
||||||
|
{certificate.certificateType === 'COURSE'
|
||||||
|
? certificate.courseName
|
||||||
|
: `${certificate.moduleName} (${certificate.courseName})`}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="mt-8 flex justify-between w-full max-w-lg px-8 border-t border-zinc-200 pt-6">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-xs text-zinc-400 font-bold uppercase tracking-wider mb-1">Data de Conclusão</p>
|
||||||
|
<p className="font-semibold text-zinc-800">{new Date(certificate.unlockedAt).toLocaleDateString('pt-BR')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{certificate.finalGrade !== null && (
|
||||||
|
<div className="text-center border-l border-zinc-200 pl-8">
|
||||||
|
<p className="text-xs text-zinc-400 font-bold uppercase tracking-wider mb-1">Nota Final</p>
|
||||||
|
<p className="font-semibold text-zinc-800">{certificate.finalGrade.toFixed(1)} / 10</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="text-center border-l border-zinc-200 pl-8">
|
||||||
|
<p className="text-xs text-zinc-400 font-bold uppercase tracking-wider mb-1">Código de Validação</p>
|
||||||
|
<p className="font-mono text-[10px] text-zinc-500">{certificate.id.toUpperCase()}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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<any[]>([]);
|
||||||
|
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 (
|
||||||
|
<div className="flex flex-col items-center justify-center min-h-[50vh] text-zinc-400 space-y-4">
|
||||||
|
<RefreshCw className="w-8 h-8 text-[#E50914] animate-spin" />
|
||||||
|
<p className="text-sm font-semibold">Carregando seus certificados...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (certificates.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center min-h-[50vh] text-zinc-400 space-y-4 glass-card rounded-3xl p-8 max-w-2xl mx-auto text-center mt-12">
|
||||||
|
<Award className="w-16 h-16 text-zinc-600 mb-2" />
|
||||||
|
<h2 className="text-2xl font-bold text-white">Nenhum certificado ainda</h2>
|
||||||
|
<p className="text-sm text-zinc-500 max-w-sm">
|
||||||
|
Complete os cursos e as avaliações dos módulos para desbloquear seus certificados.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-12">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-display font-extrabold text-white tracking-tight flex items-center space-x-2 mb-2">
|
||||||
|
<Award className="w-8 h-8 text-[#E50914]" />
|
||||||
|
<span>Meus Certificados</span>
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-zinc-400">
|
||||||
|
Você possui {certificates.length} {certificates.length === 1 ? 'certificado' : 'certificados'} desbloqueados.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-16">
|
||||||
|
{certificates.map(cert => (
|
||||||
|
<CertificateView key={cert.id} certificate={cert} studentName={studentName} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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<any>(null);
|
||||||
|
const [answers, setAnswers] = useState<number[]>([]);
|
||||||
|
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 (
|
||||||
|
<div className="flex flex-col items-center justify-center min-h-[50vh] text-zinc-400 space-y-4">
|
||||||
|
<div className="w-8 h-8 border-4 border-[#E50914] border-t-transparent rounded-full animate-spin"></div>
|
||||||
|
<p className="text-sm font-semibold">Carregando atividade...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!activity || !activity.questions || activity.questions.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="p-8 text-center text-zinc-400 glass-card rounded-2xl max-w-2xl mx-auto mt-10">
|
||||||
|
<Award className="w-12 h-12 mx-auto text-zinc-600 mb-4" />
|
||||||
|
<h2 className="text-xl font-bold text-white mb-2">Nenhuma avaliação disponível</h2>
|
||||||
|
<p className="text-sm">Este módulo ainda não possui uma atividade cadastrada.</p>
|
||||||
|
<button onClick={onBack} className="mt-6 glass-btn-primary px-6 py-2 rounded-lg text-white font-bold text-sm">
|
||||||
|
Voltar para as Aulas
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl mx-auto mt-10 space-y-6">
|
||||||
|
<div className="glass-card rounded-3xl p-10 text-center relative overflow-hidden">
|
||||||
|
<div className="absolute -top-24 -right-24 w-64 h-64 bg-[#E50914] rounded-full mix-blend-screen filter blur-[100px] opacity-20"></div>
|
||||||
|
|
||||||
|
{result.passed ? (
|
||||||
|
<div className="space-y-6 relative z-10">
|
||||||
|
<div className="w-24 h-24 bg-emerald-500/20 rounded-full flex items-center justify-center mx-auto shadow-[0_0_50px_rgba(16,185,129,0.3)]">
|
||||||
|
<CheckCircle className="w-12 h-12 text-emerald-500" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-3xl font-display font-black text-white">Parabéns!</h2>
|
||||||
|
<p className="text-zinc-400 text-lg">Você foi aprovado nesta atividade.</p>
|
||||||
|
<div className="bg-emerald-950/40 border border-emerald-900/50 p-4 rounded-xl inline-block">
|
||||||
|
<p className="text-emerald-400 font-bold">Sua Nota: {result.score.toFixed(1)} / 10</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-6 relative z-10">
|
||||||
|
<div className="w-24 h-24 bg-red-500/20 rounded-full flex items-center justify-center mx-auto">
|
||||||
|
<XCircle className="w-12 h-12 text-red-500" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-3xl font-display font-black text-white">Não foi dessa vez.</h2>
|
||||||
|
<p className="text-zinc-400 text-lg">Sua nota não atingiu a média mínima de 6.0.</p>
|
||||||
|
<div className="bg-red-950/40 border border-red-900/50 p-4 rounded-xl inline-block">
|
||||||
|
<p className="text-red-400 font-bold">Sua Nota: {result.score.toFixed(1)} / 10</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-zinc-500">Revise o conteúdo e tente novamente.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-10 flex justify-center space-x-4">
|
||||||
|
<button onClick={onBack} className="glass-btn-secondary px-6 py-3 rounded-xl text-white font-bold text-sm">
|
||||||
|
Voltar para as Aulas
|
||||||
|
</button>
|
||||||
|
{!result.passed && (
|
||||||
|
<button onClick={() => { setResult(null); setAnswers(new Array(activity.questions.length).fill(-1)); }} className="glass-btn-primary px-6 py-3 rounded-xl text-white font-bold text-sm">
|
||||||
|
Tentar Novamente
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto pb-20">
|
||||||
|
<div className="flex items-center space-x-4 mb-8">
|
||||||
|
<button onClick={onBack} className="text-zinc-400 hover:text-white transition-colors">
|
||||||
|
<ArrowLeft className="w-6 h-6" />
|
||||||
|
</button>
|
||||||
|
<h2 className="text-2xl font-display font-extrabold text-white">Avaliação do Módulo</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
{activity.questions.map((q: any, qIndex: number) => (
|
||||||
|
<div key={qIndex} className="glass-card rounded-2xl p-6 md:p-8 space-y-6">
|
||||||
|
<h3 className="text-lg font-semibold text-white">
|
||||||
|
<span className="text-[#E50914] font-black mr-2">{qIndex + 1}.</span>
|
||||||
|
{q.question}
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{q.options.map((opt: string, optIndex: number) => (
|
||||||
|
<button
|
||||||
|
key={optIndex}
|
||||||
|
onClick={() => handleSelectAnswer(qIndex, optIndex)}
|
||||||
|
className={`w-full text-left p-4 rounded-xl border transition-all duration-200 flex items-center space-x-3 ${
|
||||||
|
answers[qIndex] === optIndex
|
||||||
|
? 'bg-zinc-800 border-[#E50914] shadow-[0_0_15px_rgba(229,9,20,0.15)]'
|
||||||
|
: 'bg-zinc-900/50 border-white/5 hover:bg-zinc-800 hover:border-white/10'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className={`w-5 h-5 rounded-full border-2 flex items-center justify-center flex-shrink-0 ${
|
||||||
|
answers[qIndex] === optIndex ? 'border-[#E50914]' : 'border-zinc-600'
|
||||||
|
}`}>
|
||||||
|
{answers[qIndex] === optIndex && <div className="w-2.5 h-2.5 bg-[#E50914] rounded-full" />}
|
||||||
|
</div>
|
||||||
|
<span className={`text-sm ${answers[qIndex] === optIndex ? 'text-white font-medium' : 'text-zinc-400'}`}>
|
||||||
|
{opt}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className="flex justify-end pt-6">
|
||||||
|
<button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={answers.includes(-1)}
|
||||||
|
className="glass-btn-primary px-8 py-4 rounded-xl text-white font-bold text-base disabled:opacity-50 disabled:cursor-not-allowed shadow-[0_4px_20px_rgba(229,9,20,0.4)]"
|
||||||
|
>
|
||||||
|
Finalizar Avaliação
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import React, { useState, useEffect } from 'react';
|
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 VideoPlayer from './VideoPlayer';
|
||||||
|
import CourseActivity from './CourseActivity';
|
||||||
|
|
||||||
interface LessonProgress {
|
interface LessonProgress {
|
||||||
watchedPercentage: number;
|
watchedPercentage: number;
|
||||||
|
|
@ -49,6 +50,7 @@ export default function CourseView({ courseId, token, onBack }: CourseViewProps)
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
|
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
|
||||||
|
const [activeActivityModuleId, setActiveActivityModuleId] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchCourseDetails();
|
fetchCourseDetails();
|
||||||
|
|
@ -209,6 +211,22 @@ export default function CourseView({ courseId, token, onBack }: CourseViewProps)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (activeActivityModuleId) {
|
||||||
|
return (
|
||||||
|
<CourseActivity
|
||||||
|
token={token}
|
||||||
|
moduleId={activeActivityModuleId}
|
||||||
|
onBack={() => setActiveActivityModuleId(null)}
|
||||||
|
onCompleted={(passed) => {
|
||||||
|
if (passed) {
|
||||||
|
// Re-fetch course details to update any unlocked certificates if applicable
|
||||||
|
fetchCourseDetails();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Back Header */}
|
{/* Back Header */}
|
||||||
|
|
@ -367,6 +385,19 @@ export default function CourseView({ courseId, token, onBack }: CourseViewProps)
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Module Activity Button */}
|
||||||
|
{mod.lessons.length > 0 && (
|
||||||
|
<div className="p-3 border-t border-white/5 bg-black/20">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveActivityModuleId(mod.id)}
|
||||||
|
className="w-full flex items-center justify-center space-x-2 bg-zinc-800 hover:bg-[#E50914] hover:text-white text-zinc-300 transition-colors py-2.5 rounded-lg text-xs font-bold uppercase tracking-wider group"
|
||||||
|
>
|
||||||
|
<Award className="w-4 h-4 text-[#E50914] group-hover:text-white transition-colors" />
|
||||||
|
<span>Avaliação do Módulo</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -62,10 +62,18 @@ export default function Navbar({ user, activeView, setView, onLogout }: NavbarPr
|
||||||
Cursos
|
Cursos
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setView('subscription')}
|
onClick={() => setView('certificates')}
|
||||||
className={`font-medium transition-colors hover:text-[#E50914] ${activeView === 'subscription' ? 'text-white' : 'text-zinc-400'}`}
|
className={`font-medium transition-colors hover:text-[#E50914] flex items-center space-x-1 ${activeView === 'certificates' ? 'text-white' : 'text-zinc-400'}`}
|
||||||
>
|
>
|
||||||
Minha Assinatura
|
<GraduationCap className="w-4 h-4" />
|
||||||
|
<span>Meu Certificado</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setView('subscription')}
|
||||||
|
className={`font-medium transition-colors hover:text-[#E50914] flex items-center space-x-1 ${activeView === 'subscription' ? 'text-white' : 'text-zinc-400'}`}
|
||||||
|
>
|
||||||
|
<DollarSign className="w-4 h-4" />
|
||||||
|
<span>Minha Assinatura</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
28
src/db/db.ts
28
src/db/db.ts
|
|
@ -14,6 +14,10 @@ export interface DatabaseSchema {
|
||||||
progress: LessonProgress[];
|
progress: LessonProgress[];
|
||||||
webhookLogs: WebhookLog[];
|
webhookLogs: WebhookLog[];
|
||||||
payments: SubscriptionPayment[];
|
payments: SubscriptionPayment[];
|
||||||
|
activities: any[];
|
||||||
|
moduleGrades: any[];
|
||||||
|
certificates: any[];
|
||||||
|
settings: any; // SystemSettings
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_DB: DatabaseSchema = {
|
const DEFAULT_DB: DatabaseSchema = {
|
||||||
|
|
@ -24,7 +28,17 @@ const DEFAULT_DB: DatabaseSchema = {
|
||||||
notes: [],
|
notes: [],
|
||||||
progress: [],
|
progress: [],
|
||||||
webhookLogs: [],
|
webhookLogs: [],
|
||||||
payments: []
|
payments: [],
|
||||||
|
activities: [],
|
||||||
|
moduleGrades: [],
|
||||||
|
certificates: [],
|
||||||
|
settings: {
|
||||||
|
trialDurationDays: 7,
|
||||||
|
asaasEnvironment: 'sandbox',
|
||||||
|
asaasApiKey: '',
|
||||||
|
asaasWebhookUrl: '',
|
||||||
|
asaasWebhookSecret: ''
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper to load db
|
// Helper to load db
|
||||||
|
|
@ -395,6 +409,16 @@ function createInitialDb(): DatabaseSchema {
|
||||||
notes,
|
notes,
|
||||||
progress,
|
progress,
|
||||||
webhookLogs,
|
webhookLogs,
|
||||||
payments
|
payments,
|
||||||
|
activities: [],
|
||||||
|
moduleGrades: [],
|
||||||
|
certificates: [],
|
||||||
|
settings: {
|
||||||
|
trialDurationDays: 7,
|
||||||
|
asaasEnvironment: 'sandbox',
|
||||||
|
asaasApiKey: '',
|
||||||
|
asaasWebhookUrl: '',
|
||||||
|
asaasWebhookSecret: ''
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,16 @@
|
||||||
import { SubscriptionPayment } from '../types';
|
import { SubscriptionPayment } from '../types';
|
||||||
|
import { loadDb } from '../db/db';
|
||||||
|
|
||||||
export class AsaasService {
|
export class AsaasService {
|
||||||
private static getApiKey(): string | null {
|
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 {
|
private static getApiUrl(): string {
|
||||||
// If we have an API key, check if it looks like a production or sandbox one.
|
const db = loadDb();
|
||||||
// Standard sandbox keys often start with '$' or are long hashes, let's allow configuring via env or default to sandbox
|
const env = db.settings?.asaasEnvironment || 'sandbox';
|
||||||
const isProd = process.env.ASAAS_ENV === 'production';
|
return env === 'production' ? 'https://api.asaas.com/v3' : 'https://sandbox.asaas.com/v3';
|
||||||
return isProd ? 'https://api.asaas.com/v3' : 'https://sandbox.asaas.com/v3';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static getHeaders() {
|
private static getHeaders() {
|
||||||
|
|
|
||||||
40
src/types.ts
40
src/types.ts
|
|
@ -10,15 +10,27 @@ export interface User {
|
||||||
subscriptionStatus: SubscriptionStatus;
|
subscriptionStatus: SubscriptionStatus;
|
||||||
asaasCustomerId: string | null;
|
asaasCustomerId: string | null;
|
||||||
unlockedCourses?: string[]; // IDs of courses unlocked individually
|
unlockedCourses?: string[]; // IDs of courses unlocked individually
|
||||||
|
birthDate?: string;
|
||||||
|
whatsapp?: string;
|
||||||
|
trialEndsAt?: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SystemSettings {
|
||||||
|
trialDurationDays: number;
|
||||||
|
asaasEnvironment: 'sandbox' | 'production';
|
||||||
|
asaasApiKey: string;
|
||||||
|
asaasWebhookUrl: string;
|
||||||
|
asaasWebhookSecret: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Course {
|
export interface Course {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
thumbnail: string;
|
thumbnail: string;
|
||||||
category: string;
|
category: string;
|
||||||
|
certificateMode?: 'PER_MODULE' | 'FULL_COURSE';
|
||||||
isLocked?: boolean; // If true, requires payment/unlock
|
isLocked?: boolean; // If true, requires payment/unlock
|
||||||
price?: number; // Cost to unlock if locked (e.g. 89.90)
|
price?: number; // Cost to unlock if locked (e.g. 89.90)
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
|
@ -85,3 +97,31 @@ export interface AdminDashboardStats {
|
||||||
overdueSubscribers: number;
|
overdueSubscribers: number;
|
||||||
canceledSubscribers: 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;
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue