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>
|
||||
<meta charset="UTF-8" />
|
||||
<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>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
|
|
@ -40,6 +40,11 @@ enum BillingType {
|
|||
BOLETO
|
||||
}
|
||||
|
||||
enum CertificateMode {
|
||||
PER_MODULE
|
||||
FULL_COURSE
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(uuid())
|
||||
name String
|
||||
|
|
@ -53,6 +58,8 @@ model User {
|
|||
notes Note[]
|
||||
progress Progress[]
|
||||
payments Payment[]
|
||||
moduleGrades ModuleGrade[]
|
||||
certificates Certificate[]
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
|
|
@ -63,6 +70,7 @@ model Course {
|
|||
description String @db.Text
|
||||
thumbnail String
|
||||
category String
|
||||
certificateMode CertificateMode @default(FULL_COURSE) @map("certificate_mode")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
modules Module[]
|
||||
|
|
@ -79,6 +87,7 @@ model Module {
|
|||
|
||||
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
|
||||
lessons Lesson[]
|
||||
activity ModuleActivity?
|
||||
|
||||
@@map("modules")
|
||||
}
|
||||
|
|
@ -153,3 +162,46 @@ model WebhookLog {
|
|||
|
||||
@@map("webhook_logs")
|
||||
}
|
||||
|
||||
model ModuleActivity {
|
||||
id String @id @default(uuid())
|
||||
moduleId String @unique @map("module_id")
|
||||
questions Json // e.g. [{ question: "...", options: ["A", "B"], correctIndex: 0 }]
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
module Module @relation(fields: [moduleId], references: [id], onDelete: Cascade)
|
||||
grades ModuleGrade[]
|
||||
|
||||
@@map("module_activities")
|
||||
}
|
||||
|
||||
model ModuleGrade {
|
||||
id String @id @default(uuid())
|
||||
userId String @map("user_id")
|
||||
activityId String @map("activity_id")
|
||||
score Float
|
||||
passed Boolean @default(false)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
activity ModuleActivity @relation(fields: [activityId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([userId, activityId])
|
||||
@@map("module_grades")
|
||||
}
|
||||
|
||||
model Certificate {
|
||||
id String @id @default(uuid())
|
||||
userId String @map("user_id")
|
||||
courseId String? @map("course_id")
|
||||
moduleId String? @map("module_id")
|
||||
certificateType String @map("certificate_type") // 'COURSE' or 'MODULE'
|
||||
unlockedAt DateTime @default(now()) @map("unlocked_at")
|
||||
finalGrade Float? @map("final_grade")
|
||||
courseName String @map("course_name") // Snapshot of course title
|
||||
moduleName String? @map("module_name") // Snapshot of module title
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("certificates")
|
||||
}
|
||||
|
|
|
|||
283
server.ts
283
server.ts
|
|
@ -56,9 +56,9 @@ function requireAdmin(req: AuthenticatedRequest, res: Response, next: NextFuncti
|
|||
|
||||
// 1. Auth: Register
|
||||
app.post('/api/auth/register', async (req: Request, res: Response) => {
|
||||
const { name, email, password } = req.body;
|
||||
const { name, email, password, birthDate, whatsapp } = req.body;
|
||||
|
||||
if (!name || !email || !password) {
|
||||
if (!name || !email || !password || !birthDate || !whatsapp) {
|
||||
res.status(400).json({ error: 'Preencha todos os campos obrigatórios' });
|
||||
return;
|
||||
}
|
||||
|
|
@ -78,6 +78,13 @@ app.post('/api/auth/register', async (req: Request, res: Response) => {
|
|||
const salt = bcrypt.genSaltSync(10);
|
||||
const passwordHash = bcrypt.hashSync(password, salt);
|
||||
|
||||
const createdAt = new Date();
|
||||
|
||||
// Calculate trial ends at
|
||||
const trialDays = db.settings?.trialDurationDays || 7;
|
||||
const trialEndsAt = new Date(createdAt);
|
||||
trialEndsAt.setDate(trialEndsAt.getDate() + trialDays);
|
||||
|
||||
const newUser: User = {
|
||||
id: `usr_${Math.random().toString(36).substring(2, 9)}`,
|
||||
name,
|
||||
|
|
@ -86,7 +93,10 @@ app.post('/api/auth/register', async (req: Request, res: Response) => {
|
|||
role: 'student',
|
||||
subscriptionStatus: 'TRIAL', // Defaults to trial
|
||||
asaasCustomerId,
|
||||
createdAt: new Date().toISOString()
|
||||
birthDate,
|
||||
whatsapp,
|
||||
trialEndsAt: trialEndsAt.toISOString(),
|
||||
createdAt: createdAt.toISOString()
|
||||
};
|
||||
|
||||
db.users.push(newUser);
|
||||
|
|
@ -535,6 +545,8 @@ app.get('/api/admin/students', authenticateToken, requireAdmin, (req: Authentica
|
|||
.filter(u => u.role === 'student')
|
||||
.map(u => {
|
||||
const studentPayments = db.payments.filter(p => p.userId === u.id);
|
||||
const studentGrades = db.moduleGrades.filter(g => g.userId === u.id);
|
||||
const studentCertificates = db.certificates.filter(c => c.userId === u.id);
|
||||
return {
|
||||
id: u.id,
|
||||
name: u.name,
|
||||
|
|
@ -542,10 +554,16 @@ app.get('/api/admin/students', authenticateToken, requireAdmin, (req: Authentica
|
|||
subscriptionStatus: u.subscriptionStatus,
|
||||
asaasCustomerId: u.asaasCustomerId,
|
||||
createdAt: u.createdAt,
|
||||
birthDate: u.birthDate,
|
||||
whatsapp: u.whatsapp,
|
||||
trialEndsAt: u.trialEndsAt,
|
||||
totalPaid: studentPayments
|
||||
.filter(p => p.status === 'CONFIRMED' || p.status === 'RECEIVED')
|
||||
.reduce((acc, curr) => acc + curr.value, 0),
|
||||
unlockedCourses: u.unlockedCourses || []
|
||||
unlockedCourses: u.unlockedCourses || [],
|
||||
moduleGrades: studentGrades,
|
||||
certificates: studentCertificates,
|
||||
payments: studentPayments
|
||||
};
|
||||
});
|
||||
|
||||
|
|
@ -577,6 +595,31 @@ app.post('/api/admin/students/:id/status', authenticateToken, requireAdmin, (req
|
|||
res.json({ success: true, status: student.subscriptionStatus });
|
||||
});
|
||||
|
||||
// Admin System Settings
|
||||
app.get('/api/admin/settings', authenticateToken, requireAdmin, (req: AuthenticatedRequest, res: Response) => {
|
||||
const db = loadDb();
|
||||
res.json(db.settings || {
|
||||
trialDurationDays: 7,
|
||||
asaasEnvironment: 'sandbox',
|
||||
asaasApiKey: '',
|
||||
asaasWebhookUrl: '',
|
||||
asaasWebhookSecret: ''
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/api/admin/settings', authenticateToken, requireAdmin, (req: AuthenticatedRequest, res: Response) => {
|
||||
const newSettings = req.body;
|
||||
const db = loadDb();
|
||||
|
||||
db.settings = {
|
||||
...db.settings,
|
||||
...newSettings
|
||||
};
|
||||
|
||||
saveDb(db);
|
||||
res.json({ success: true, settings: db.settings });
|
||||
});
|
||||
|
||||
// Admin manually unlock or lock a course for a student
|
||||
app.post('/api/admin/students/:id/unlock-course', authenticateToken, requireAdmin, (req: AuthenticatedRequest, res: Response) => {
|
||||
const { courseId, unlocked } = req.body;
|
||||
|
|
@ -937,6 +980,238 @@ app.post('/api/webhooks/asaas', (req: Request, res: Response) => {
|
|||
res.status(200).json({ received: true });
|
||||
});
|
||||
|
||||
// --- ACTIVITIES & CERTIFICATES API ENDPOINTS ---
|
||||
|
||||
// Admin: Save or update module activity
|
||||
app.post('/api/admin/modules/:id/activity', authenticateToken, requireAdmin, (req: AuthenticatedRequest, res: Response) => {
|
||||
const moduleId = req.params.id;
|
||||
const { questions } = req.body;
|
||||
|
||||
if (!questions || !Array.isArray(questions)) {
|
||||
res.status(400).json({ error: 'Lista de perguntas inválida' });
|
||||
return;
|
||||
}
|
||||
|
||||
const db = loadDb();
|
||||
let activity = db.activities.find(a => a.moduleId === moduleId);
|
||||
|
||||
if (activity) {
|
||||
activity.questions = questions;
|
||||
} else {
|
||||
activity = {
|
||||
id: `act_${Math.random().toString(36).substring(2, 9)}`,
|
||||
moduleId,
|
||||
questions,
|
||||
createdAt: new Date().toISOString()
|
||||
};
|
||||
db.activities.push(activity);
|
||||
}
|
||||
|
||||
saveDb(db);
|
||||
res.json({ success: true, activity });
|
||||
});
|
||||
|
||||
// Admin/Student: Get module activity
|
||||
app.get('/api/modules/:id/activity', authenticateToken, (req: AuthenticatedRequest, res: Response) => {
|
||||
const moduleId = req.params.id;
|
||||
const db = loadDb();
|
||||
|
||||
const activity = db.activities.find(a => a.moduleId === moduleId);
|
||||
if (!activity) {
|
||||
res.status(404).json({ error: 'Atividade não encontrada' });
|
||||
return;
|
||||
}
|
||||
|
||||
// If student, remove correctIndex so they can't cheat easily by inspecting the network request
|
||||
if (req.user?.role === 'student') {
|
||||
const safeQuestions = activity.questions.map((q: any) => ({
|
||||
question: q.question,
|
||||
options: q.options
|
||||
}));
|
||||
res.json({ id: activity.id, moduleId: activity.moduleId, questions: safeQuestions });
|
||||
} else {
|
||||
res.json(activity);
|
||||
}
|
||||
});
|
||||
|
||||
// Admin: Change Course Certificate Mode
|
||||
app.post('/api/admin/courses/:id/certificate-mode', authenticateToken, requireAdmin, (req: AuthenticatedRequest, res: Response) => {
|
||||
const courseId = req.params.id;
|
||||
const { mode } = req.body; // 'PER_MODULE' or 'FULL_COURSE'
|
||||
|
||||
if (mode !== 'PER_MODULE' && mode !== 'FULL_COURSE') {
|
||||
res.status(400).json({ error: 'Modo inválido' });
|
||||
return;
|
||||
}
|
||||
|
||||
const db = loadDb();
|
||||
const course = db.courses.find(c => c.id === courseId);
|
||||
if (!course) {
|
||||
res.status(404).json({ error: 'Curso não encontrado' });
|
||||
return;
|
||||
}
|
||||
|
||||
course.certificateMode = mode;
|
||||
saveDb(db);
|
||||
res.json({ success: true, course });
|
||||
});
|
||||
|
||||
// Admin: Manually Unlock Certificate
|
||||
app.post('/api/admin/students/:userId/unlock-certificate', authenticateToken, requireAdmin, (req: AuthenticatedRequest, res: Response) => {
|
||||
const { userId } = req.params;
|
||||
const { type, targetId, courseName, moduleName } = req.body; // type: 'COURSE' or 'MODULE'
|
||||
|
||||
const db = loadDb();
|
||||
|
||||
const certId = `cert_${Math.random().toString(36).substring(2, 9)}`;
|
||||
const certificate = {
|
||||
id: certId,
|
||||
userId,
|
||||
courseId: type === 'COURSE' ? targetId : null,
|
||||
moduleId: type === 'MODULE' ? targetId : null,
|
||||
certificateType: type,
|
||||
unlockedAt: new Date().toISOString(),
|
||||
finalGrade: 10, // Default manual grade
|
||||
courseName,
|
||||
moduleName: moduleName || null
|
||||
};
|
||||
|
||||
db.certificates.push(certificate);
|
||||
saveDb(db);
|
||||
|
||||
res.json({ success: true, certificate });
|
||||
});
|
||||
|
||||
// Student: Submit Activity Answers
|
||||
app.post('/api/modules/:id/submit-activity', authenticateToken, (req: AuthenticatedRequest, res: Response) => {
|
||||
const moduleId = req.params.id;
|
||||
const { answers } = req.body; // Array of selected indexes
|
||||
const userId = req.user!.id;
|
||||
|
||||
const db = loadDb();
|
||||
const activity = db.activities.find(a => a.moduleId === moduleId);
|
||||
|
||||
if (!activity) {
|
||||
res.status(404).json({ error: 'Atividade não encontrada' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate Grade
|
||||
let correctCount = 0;
|
||||
const totalQuestions = activity.questions.length;
|
||||
|
||||
activity.questions.forEach((q: any, index: number) => {
|
||||
if (answers[index] === q.correctIndex) {
|
||||
correctCount++;
|
||||
}
|
||||
});
|
||||
|
||||
const score = totalQuestions > 0 ? (correctCount / totalQuestions) * 10 : 0;
|
||||
const passed = score >= 6; // Configurable passing score
|
||||
|
||||
let gradeIdx = db.moduleGrades.findIndex(g => g.userId === userId && g.activityId === activity.id);
|
||||
if (gradeIdx >= 0) {
|
||||
db.moduleGrades[gradeIdx].score = score;
|
||||
db.moduleGrades[gradeIdx].passed = passed;
|
||||
} else {
|
||||
db.moduleGrades.push({
|
||||
id: `grd_${Math.random().toString(36).substring(2, 9)}`,
|
||||
userId,
|
||||
activityId: activity.id,
|
||||
score,
|
||||
passed,
|
||||
createdAt: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
// --- Automatic Certificate Generation Logic ---
|
||||
const moduleInfo = db.modules.find(m => m.id === moduleId);
|
||||
if (passed && moduleInfo) {
|
||||
const course = db.courses.find(c => c.id === moduleInfo.courseId);
|
||||
if (course) {
|
||||
const mode = course.certificateMode || 'FULL_COURSE';
|
||||
|
||||
if (mode === 'PER_MODULE') {
|
||||
// Check if certificate already exists
|
||||
const exists = db.certificates.find(c => c.userId === userId && c.moduleId === moduleId && c.certificateType === 'MODULE');
|
||||
if (!exists) {
|
||||
db.certificates.push({
|
||||
id: `cert_${Math.random().toString(36).substring(2, 9)}`,
|
||||
userId,
|
||||
courseId: course.id,
|
||||
moduleId: moduleInfo.id,
|
||||
certificateType: 'MODULE',
|
||||
unlockedAt: new Date().toISOString(),
|
||||
finalGrade: score,
|
||||
courseName: course.title,
|
||||
moduleName: moduleInfo.title
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// FULL_COURSE mode
|
||||
// Check if student has passed all modules of this course
|
||||
const allCourseModules = db.modules.filter(m => m.courseId === course.id);
|
||||
let allPassed = true;
|
||||
let totalScore = 0;
|
||||
let evaluatedModules = 0;
|
||||
|
||||
for (const m of allCourseModules) {
|
||||
const act = db.activities.find(a => a.moduleId === m.id);
|
||||
if (act) {
|
||||
const grade = db.moduleGrades.find(g => g.activityId === act.id && g.userId === userId);
|
||||
if (!grade || !grade.passed) {
|
||||
allPassed = false;
|
||||
break;
|
||||
}
|
||||
totalScore += grade.score;
|
||||
evaluatedModules++;
|
||||
} else {
|
||||
// If a module has no activity, we might consider it "passed" automatically, but let's just ignore it for grades
|
||||
// Or require manual progress? Let's assume if it has no activity, we just skip grade check for it
|
||||
// Actually, wait, let's require 100% watched percentage for modules without activities.
|
||||
const lessons = db.lessons.filter(l => l.moduleId === m.id);
|
||||
for(const lsn of lessons) {
|
||||
const prog = db.progress.find(p => p.lessonId === lsn.id && p.userId === userId);
|
||||
if (!prog || !prog.completed) {
|
||||
allPassed = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (allPassed) {
|
||||
const exists = db.certificates.find(c => c.userId === userId && c.courseId === course.id && c.certificateType === 'COURSE');
|
||||
if (!exists) {
|
||||
const finalGrade = evaluatedModules > 0 ? (totalScore / evaluatedModules) : null;
|
||||
db.certificates.push({
|
||||
id: `cert_${Math.random().toString(36).substring(2, 9)}`,
|
||||
userId,
|
||||
courseId: course.id,
|
||||
moduleId: null,
|
||||
certificateType: 'COURSE',
|
||||
unlockedAt: new Date().toISOString(),
|
||||
finalGrade,
|
||||
courseName: course.title,
|
||||
moduleName: null
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
saveDb(db);
|
||||
res.json({ success: true, score, passed });
|
||||
});
|
||||
|
||||
// Student: Get my certificates
|
||||
app.get('/api/certificates', authenticateToken, (req: AuthenticatedRequest, res: Response) => {
|
||||
const db = loadDb();
|
||||
const myCerts = db.certificates.filter(c => c.userId === req.user!.id);
|
||||
res.json(myCerts);
|
||||
});
|
||||
|
||||
// --- VITE MIDDLEWARE / SPA FALLBACK ---
|
||||
async function startServer() {
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
|
|
|
|||
36
src/App.tsx
36
src/App.tsx
|
|
@ -9,6 +9,7 @@ import CourseCarousel from './components/CourseCarousel';
|
|||
import CourseView from './components/CourseView';
|
||||
import SubscriptionView from './components/SubscriptionView';
|
||||
import CourseUnlockModal from './components/CourseUnlockModal';
|
||||
import CertificatesTab from './components/CertificatesTab';
|
||||
import { User as UserType, Course as CourseType, SubscriptionStatus } from './types';
|
||||
|
||||
export default function App() {
|
||||
|
|
@ -32,6 +33,8 @@ export default function App() {
|
|||
const [regName, setRegName] = useState('');
|
||||
const [regEmail, setRegEmail] = useState('');
|
||||
const [regPassword, setRegPassword] = useState('');
|
||||
const [regBirthDate, setRegBirthDate] = useState('');
|
||||
const [regWhatsapp, setRegWhatsapp] = useState('');
|
||||
const [authError, setAuthError] = useState<string | null>(null);
|
||||
const [authSuccess, setAuthSuccess] = useState<string | null>(null);
|
||||
const [authLoading, setAuthLoading] = useState(false);
|
||||
|
|
@ -129,7 +132,7 @@ export default function App() {
|
|||
const res = await fetch('/api/auth/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: regName, email: regEmail, password: regPassword })
|
||||
body: JSON.stringify({ name: regName, email: regEmail, password: regPassword, birthDate: regBirthDate, whatsapp: regWhatsapp })
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
|
|
@ -250,6 +253,14 @@ export default function App() {
|
|||
/>
|
||||
)}
|
||||
|
||||
{/* RENDER VIEW: CERTIFICATES */}
|
||||
{activeView === 'certificates' && (
|
||||
<CertificatesTab
|
||||
token={token}
|
||||
studentName={user.name}
|
||||
/>
|
||||
)}
|
||||
|
||||
</main>
|
||||
|
||||
{/* Unlock Modal */}
|
||||
|
|
@ -326,6 +337,29 @@ export default function App() {
|
|||
/>
|
||||
</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">
|
||||
<label className="text-[10px] text-zinc-400 font-bold uppercase tracking-wider">Crie uma Senha</label>
|
||||
<input
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ export default function AdminContentManager({ token }: AdminContentManagerProps)
|
|||
const [showCourseForm, setShowCourseForm] = useState(false);
|
||||
const [editingCourse, setEditingCourse] = useState<Course | null>(null);
|
||||
const [courseForm, setCourseForm] = useState({
|
||||
title: '', description: '', thumbnail: '', category: '', isLocked: false, price: 0
|
||||
title: '', description: '', thumbnail: '', category: '', isLocked: false, price: 0, certificateMode: 'FULL_COURSE' as 'FULL_COURSE' | 'PER_MODULE'
|
||||
});
|
||||
|
||||
const [showModuleForm, setShowModuleForm] = useState(false);
|
||||
|
|
@ -62,6 +62,10 @@ export default function AdminContentManager({ token }: AdminContentManagerProps)
|
|||
title: '', videoUrl: '', videoType: 'youtube' as 'youtube' | 'direct', order: 1, content: ''
|
||||
});
|
||||
|
||||
const [showActivityModal, setShowActivityModal] = useState(false);
|
||||
const [activeModuleForActivity, setActiveModuleForActivity] = useState<string | null>(null);
|
||||
const [activityQuestions, setActivityQuestions] = useState<{question: string, options: string[], correctIndex: number}[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchCourses();
|
||||
}, []);
|
||||
|
|
@ -125,7 +129,7 @@ export default function AdminContentManager({ token }: AdminContentManagerProps)
|
|||
await fetchCourses();
|
||||
setShowCourseForm(false);
|
||||
setEditingCourse(null);
|
||||
setCourseForm({ title: '', description: '', thumbnail: '', category: '', isLocked: false, price: 0 });
|
||||
setCourseForm({ title: '', description: '', thumbnail: '', category: '', isLocked: false, price: 0, certificateMode: 'FULL_COURSE' });
|
||||
} else {
|
||||
const errData = await res.json();
|
||||
alert(`Erro ao salvar curso: ${errData.error || 'Falha na resposta do servidor'}`);
|
||||
|
|
@ -144,7 +148,8 @@ export default function AdminContentManager({ token }: AdminContentManagerProps)
|
|||
thumbnail: course.thumbnail,
|
||||
category: course.category,
|
||||
isLocked: course.isLocked || false,
|
||||
price: course.price || 0
|
||||
price: course.price || 0,
|
||||
certificateMode: course.certificateMode || 'FULL_COURSE'
|
||||
});
|
||||
setShowCourseForm(true);
|
||||
setTimeout(() => {
|
||||
|
|
@ -399,14 +404,26 @@ export default function AdminContentManager({ token }: AdminContentManagerProps)
|
|||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={courseForm.price}
|
||||
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 font-mono"
|
||||
className="w-full glass-input rounded-lg p-3 text-sm text-white focus:outline-none"
|
||||
/>
|
||||
</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="inline-flex items-center space-x-3 cursor-pointer">
|
||||
<input
|
||||
|
|
@ -636,6 +653,15 @@ export default function AdminContentManager({ token }: AdminContentManagerProps)
|
|||
<span>Nova Aula</span>
|
||||
</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 */}
|
||||
<button
|
||||
onClick={() => handleEditModule(mod)}
|
||||
|
|
@ -759,6 +785,88 @@ export default function AdminContentManager({ token }: AdminContentManagerProps)
|
|||
))}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,12 @@ interface Student {
|
|||
createdAt: string;
|
||||
totalPaid: number;
|
||||
unlockedCourses?: string[];
|
||||
moduleGrades?: { id: string, activityId: string, score: number, passed: boolean }[];
|
||||
certificates?: { id: string, courseId: string | null, moduleId: string | null, certificateType: string, unlockedAt: string, finalGrade: number | null, courseName: string, moduleName: string | null }[];
|
||||
birthDate?: string;
|
||||
whatsapp?: string;
|
||||
trialEndsAt?: string | null;
|
||||
payments?: PaymentLog[];
|
||||
}
|
||||
|
||||
interface PaymentLog {
|
||||
|
|
@ -58,16 +64,59 @@ export default function AdminDashboard({ token }: AdminDashboardProps) {
|
|||
const [expandedStudentId, setExpandedStudentId] = useState<string | null>(null);
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [activeTab, setActiveTab] = useState<'finance' | 'students' | 'webhooks'>('finance');
|
||||
const [activeTab, setActiveTab] = useState<'finance' | 'students' | 'webhooks' | 'settings'>('finance');
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
||||
|
||||
// Settings State
|
||||
const [settings, setSettings] = useState<any>(null);
|
||||
const [isSavingSettings, setIsSavingSettings] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchDashboardData();
|
||||
fetchStudents();
|
||||
fetchCourses();
|
||||
fetchSettings();
|
||||
}, []);
|
||||
|
||||
const fetchSettings = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/admin/settings', {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setSettings(data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching settings:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveSettings = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsSavingSettings(true);
|
||||
try {
|
||||
const res = await fetch('/api/admin/settings', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify(settings)
|
||||
});
|
||||
if (res.ok) {
|
||||
alert('Configurações salvas com sucesso!');
|
||||
} else {
|
||||
alert('Erro ao salvar configurações.');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error saving settings:', err);
|
||||
} finally {
|
||||
setIsSavingSettings(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchCourses = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/courses', {
|
||||
|
|
@ -160,6 +209,30 @@ export default function AdminDashboard({ token }: AdminDashboardProps) {
|
|||
}
|
||||
};
|
||||
|
||||
const handleUnlockCertificate = async (studentId: string, type: 'COURSE' | 'MODULE', targetId: string, courseName: string, moduleName?: string) => {
|
||||
setActionLoading(`cert_${studentId}_${targetId}`);
|
||||
try {
|
||||
const res = await fetch(`/api/admin/students/${studentId}/unlock-certificate`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({ type, targetId, courseName, moduleName })
|
||||
});
|
||||
if (res.ok) {
|
||||
await fetchStudents();
|
||||
alert('Certificado liberado manualmente!');
|
||||
} else {
|
||||
alert('Erro ao liberar certificado.');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error unlocking certificate:', err);
|
||||
} finally {
|
||||
setActionLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusTag = (status: SubscriptionStatus) => {
|
||||
switch (status) {
|
||||
case 'ACTIVE':
|
||||
|
|
@ -290,21 +363,32 @@ export default function AdminDashboard({ token }: AdminDashboardProps) {
|
|||
<div className="flex border-b border-white/10">
|
||||
<button
|
||||
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
|
||||
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
|
||||
onClick={() => setActiveTab('webhooks')}
|
||||
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>
|
||||
</div>
|
||||
|
||||
|
|
@ -415,16 +499,14 @@ export default function AdminDashboard({ token }: AdminDashboardProps) {
|
|||
filteredStudents.map(student => (
|
||||
<React.Fragment key={student.id}>
|
||||
<tr className="hover:bg-zinc-900/20 transition-colors">
|
||||
<td className="px-6 py-4">
|
||||
<div className="font-semibold text-white">{student.name}</div>
|
||||
<div className="text-xs text-zinc-500">{student.email}</div>
|
||||
{student.unlockedCourses && student.unlockedCourses.length > 0 && (
|
||||
<div className="mt-1 flex items-center gap-1">
|
||||
<span className="bg-[#E50914]/25 text-[#E50914] text-[9px] font-extrabold px-1.5 py-0.5 rounded uppercase tracking-wider">
|
||||
{student.unlockedCourses.length} {student.unlockedCourses.length === 1 ? 'Curso Avulso' : 'Cursos Avulsos'}
|
||||
</span>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
<div className="ml-4">
|
||||
<div className="text-sm font-medium text-white">{student.name}</div>
|
||||
<div className="text-sm text-zinc-400">{student.email}</div>
|
||||
{student.whatsapp && <div className="text-xs text-zinc-500">WA: {student.whatsapp}</div>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<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>}
|
||||
|
|
@ -532,6 +614,55 @@ export default function AdminDashboard({ token }: AdminDashboardProps) {
|
|||
</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>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
|
|
@ -607,6 +738,104 @@ export default function AdminDashboard({ token }: AdminDashboardProps) {
|
|||
</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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 { ArrowLeft, BookOpen, ChevronRight, FileText, CheckCircle2, Circle, MessageSquare, AlertCircle } from 'lucide-react';
|
||||
import { ArrowLeft, BookOpen, ChevronRight, FileText, CheckCircle2, Circle, MessageSquare, AlertCircle, Award } from 'lucide-react';
|
||||
import VideoPlayer from './VideoPlayer';
|
||||
import CourseActivity from './CourseActivity';
|
||||
|
||||
interface LessonProgress {
|
||||
watchedPercentage: number;
|
||||
|
|
@ -49,6 +50,7 @@ export default function CourseView({ courseId, token, onBack }: CourseViewProps)
|
|||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
|
||||
const [activeActivityModuleId, setActiveActivityModuleId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
{/* 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>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -62,10 +62,18 @@ export default function Navbar({ user, activeView, setView, onLogout }: NavbarPr
|
|||
Cursos
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setView('subscription')}
|
||||
className={`font-medium transition-colors hover:text-[#E50914] ${activeView === 'subscription' ? 'text-white' : 'text-zinc-400'}`}
|
||||
onClick={() => setView('certificates')}
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
28
src/db/db.ts
28
src/db/db.ts
|
|
@ -14,6 +14,10 @@ export interface DatabaseSchema {
|
|||
progress: LessonProgress[];
|
||||
webhookLogs: WebhookLog[];
|
||||
payments: SubscriptionPayment[];
|
||||
activities: any[];
|
||||
moduleGrades: any[];
|
||||
certificates: any[];
|
||||
settings: any; // SystemSettings
|
||||
}
|
||||
|
||||
const DEFAULT_DB: DatabaseSchema = {
|
||||
|
|
@ -24,7 +28,17 @@ const DEFAULT_DB: DatabaseSchema = {
|
|||
notes: [],
|
||||
progress: [],
|
||||
webhookLogs: [],
|
||||
payments: []
|
||||
payments: [],
|
||||
activities: [],
|
||||
moduleGrades: [],
|
||||
certificates: [],
|
||||
settings: {
|
||||
trialDurationDays: 7,
|
||||
asaasEnvironment: 'sandbox',
|
||||
asaasApiKey: '',
|
||||
asaasWebhookUrl: '',
|
||||
asaasWebhookSecret: ''
|
||||
}
|
||||
};
|
||||
|
||||
// Helper to load db
|
||||
|
|
@ -395,6 +409,16 @@ function createInitialDb(): DatabaseSchema {
|
|||
notes,
|
||||
progress,
|
||||
webhookLogs,
|
||||
payments
|
||||
payments,
|
||||
activities: [],
|
||||
moduleGrades: [],
|
||||
certificates: [],
|
||||
settings: {
|
||||
trialDurationDays: 7,
|
||||
asaasEnvironment: 'sandbox',
|
||||
asaasApiKey: '',
|
||||
asaasWebhookUrl: '',
|
||||
asaasWebhookSecret: ''
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,16 @@
|
|||
import { SubscriptionPayment } from '../types';
|
||||
import { loadDb } from '../db/db';
|
||||
|
||||
export class AsaasService {
|
||||
private static getApiKey(): string | null {
|
||||
return process.env.ASAAS_API_KEY || null;
|
||||
const db = loadDb();
|
||||
return db.settings?.asaasApiKey || process.env.ASAAS_API_KEY || null;
|
||||
}
|
||||
|
||||
private static getApiUrl(): string {
|
||||
// If we have an API key, check if it looks like a production or sandbox one.
|
||||
// Standard sandbox keys often start with '$' or are long hashes, let's allow configuring via env or default to sandbox
|
||||
const isProd = process.env.ASAAS_ENV === 'production';
|
||||
return isProd ? 'https://api.asaas.com/v3' : 'https://sandbox.asaas.com/v3';
|
||||
const db = loadDb();
|
||||
const env = db.settings?.asaasEnvironment || 'sandbox';
|
||||
return env === 'production' ? 'https://api.asaas.com/v3' : 'https://sandbox.asaas.com/v3';
|
||||
}
|
||||
|
||||
private static getHeaders() {
|
||||
|
|
|
|||
40
src/types.ts
40
src/types.ts
|
|
@ -10,15 +10,27 @@ export interface User {
|
|||
subscriptionStatus: SubscriptionStatus;
|
||||
asaasCustomerId: string | null;
|
||||
unlockedCourses?: string[]; // IDs of courses unlocked individually
|
||||
birthDate?: string;
|
||||
whatsapp?: string;
|
||||
trialEndsAt?: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface SystemSettings {
|
||||
trialDurationDays: number;
|
||||
asaasEnvironment: 'sandbox' | 'production';
|
||||
asaasApiKey: string;
|
||||
asaasWebhookUrl: string;
|
||||
asaasWebhookSecret: string;
|
||||
}
|
||||
|
||||
export interface Course {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
thumbnail: string;
|
||||
category: string;
|
||||
certificateMode?: 'PER_MODULE' | 'FULL_COURSE';
|
||||
isLocked?: boolean; // If true, requires payment/unlock
|
||||
price?: number; // Cost to unlock if locked (e.g. 89.90)
|
||||
createdAt: string;
|
||||
|
|
@ -85,3 +97,31 @@ export interface AdminDashboardStats {
|
|||
overdueSubscribers: number;
|
||||
canceledSubscribers: number;
|
||||
}
|
||||
|
||||
export interface ModuleActivity {
|
||||
id: string;
|
||||
moduleId: string;
|
||||
questions: any[];
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface ModuleGrade {
|
||||
id: string;
|
||||
userId: string;
|
||||
activityId: string;
|
||||
score: number;
|
||||
passed: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface Certificate {
|
||||
id: string;
|
||||
userId: string;
|
||||
courseId: string | null;
|
||||
moduleId: string | null;
|
||||
certificateType: 'COURSE' | 'MODULE';
|
||||
unlockedAt: string;
|
||||
finalGrade: number | null;
|
||||
courseName: string;
|
||||
moduleName: string | null;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue