feat: Adiciona campos de cadastro e configuracoes do Asaas Sandbox
Build and Deploy (Gitea) / build-and-deploy (push) Failing after 29s Details

This commit is contained in:
Sidney Gomes 2026-07-20 20:20:57 -03:00
parent 4c71df1c9a
commit fc17ccc499
14 changed files with 1250 additions and 50 deletions

View File

@ -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>

View File

@ -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
View File

@ -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') {

View File

@ -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

View File

@ -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>
); );
} }

View File

@ -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':
@ -288,23 +361,34 @@ export default function AdminDashboard({ token }: AdminDashboardProps) {
{/* TABS CONTAINER */} {/* TABS CONTAINER */}
<div className="space-y-6"> <div className="space-y-6">
<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
onClick={() => setActiveTab('webhooks')} <button
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'}`} onClick={() => setActiveTab('settings')}
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>
); );

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
))} ))}

View File

@ -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>

View File

@ -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: ''
}
}; };
} }

View File

@ -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() {

View File

@ -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;
}