Batch_Melhorias
Build and Deploy (Gitea) / build-and-deploy (push) Successful in 59s
Details
Build and Deploy (Gitea) / build-and-deploy (push) Successful in 59s
Details
This commit is contained in:
parent
d4b216b0b6
commit
2bfdfc0240
|
|
@ -81,16 +81,17 @@ model User {
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
||||||
certificateMode CertificateMode @default(FULL_COURSE) @map("certificate_mode")
|
certificateMode CertificateMode @default(FULL_COURSE) @map("certificate_mode")
|
||||||
isLocked Boolean @default(false) @map("is_locked")
|
isLocked Boolean @default(false) @map("is_locked")
|
||||||
price Float?
|
price Float?
|
||||||
order Int @default(0)
|
order Int @default(0)
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
studyMaterialUrl String? @map("study_material_url") @db.Text
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
modules Module[]
|
modules Module[]
|
||||||
payments Payment[]
|
payments Payment[]
|
||||||
|
|
|
||||||
81
server.ts
81
server.ts
|
|
@ -539,21 +539,28 @@ app.get('/api/courses', authenticateToken, async (req: AuthenticatedRequest, res
|
||||||
});
|
});
|
||||||
|
|
||||||
const userProgress = await prisma.progress.findMany({
|
const userProgress = await prisma.progress.findMany({
|
||||||
where: { userId, completed: true }
|
where: { userId }
|
||||||
});
|
});
|
||||||
const userProgressLessonIds = userProgress.map(p => p.lessonId);
|
const userProgressLessonIds = userProgress.filter(p => p.completed).map(p => p.lessonId);
|
||||||
|
|
||||||
const coursesWithProgress = courses.map(course => {
|
const coursesWithProgress = courses.map(course => {
|
||||||
const sortedModules = [...course.modules].sort((a, b) => a.order - b.order);
|
const sortedModules = [...course.modules].sort((a, b) => a.order - b.order);
|
||||||
const courseLessons = sortedModules.flatMap(m => {
|
const courseLessons = sortedModules.flatMap(m => {
|
||||||
const sortedLessons = [...m.lessons].sort((a, b) => a.order - b.order);
|
const sortedLessons = [...m.lessons].sort((a, b) => a.order - b.order);
|
||||||
return sortedLessons.map(l => ({
|
return sortedLessons.map(l => {
|
||||||
id: l.id,
|
const prog = userProgress.find(p => p.lessonId === l.id);
|
||||||
title: l.title,
|
return {
|
||||||
thumbnailUrl: l.thumbnailUrl,
|
id: l.id,
|
||||||
order: l.order,
|
title: l.title,
|
||||||
moduleId: m.id
|
thumbnailUrl: l.thumbnailUrl,
|
||||||
}));
|
order: l.order,
|
||||||
|
moduleId: m.id,
|
||||||
|
progress: prog ? {
|
||||||
|
watchedPercentage: prog.watchedPercentage,
|
||||||
|
completed: prog.completed
|
||||||
|
} : { watchedPercentage: 0, completed: false }
|
||||||
|
};
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const totalLessons = courseLessons.length;
|
const totalLessons = courseLessons.length;
|
||||||
|
|
@ -573,6 +580,7 @@ app.get('/api/courses', authenticateToken, async (req: AuthenticatedRequest, res
|
||||||
isLocked: course.isLocked,
|
isLocked: course.isLocked,
|
||||||
price: course.price,
|
price: course.price,
|
||||||
category: course.category,
|
category: course.category,
|
||||||
|
studyMaterialUrl: course.studyMaterialUrl,
|
||||||
totalLessons,
|
totalLessons,
|
||||||
completedLessons,
|
completedLessons,
|
||||||
progress: progressPercentage,
|
progress: progressPercentage,
|
||||||
|
|
@ -648,6 +656,7 @@ app.get('/api/courses/:id', authenticateToken, async (req: AuthenticatedRequest,
|
||||||
res.json({
|
res.json({
|
||||||
course: {
|
course: {
|
||||||
...courseData,
|
...courseData,
|
||||||
|
studyMaterialUrl: course.studyMaterialUrl,
|
||||||
isUnlocked
|
isUnlocked
|
||||||
},
|
},
|
||||||
modules: modulesWithLessons
|
modules: modulesWithLessons
|
||||||
|
|
@ -1616,6 +1625,60 @@ app.delete('/api/admin/courses/:id', authenticateToken, requireAdmin, async (req
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Upload PDF material de estudo para curso
|
||||||
|
app.post('/api/admin/courses/:id/upload-material', authenticateToken, requireAdmin, upload.single('file'), async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
const courseId = req.params.id;
|
||||||
|
const file = req.file;
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
res.status(400).json({ error: 'Nenhum arquivo enviado.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ext = path.extname(file.originalname).toLowerCase();
|
||||||
|
if (ext !== '.pdf') {
|
||||||
|
res.status(400).json({ error: 'Apenas arquivos PDF são permitidos.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const fileName = `materials/${courseId}-${Date.now()}${ext}`;
|
||||||
|
await s3.send(new PutObjectCommand({
|
||||||
|
Bucket: 'microtecflix',
|
||||||
|
Key: fileName,
|
||||||
|
Body: file.buffer,
|
||||||
|
ContentType: 'application/pdf',
|
||||||
|
}));
|
||||||
|
|
||||||
|
const publicUrl = `https://s3-estudo.microtecinformaticacurso.com.br/microtecflix/${fileName}`;
|
||||||
|
|
||||||
|
const updated = await prisma.course.update({
|
||||||
|
where: { id: courseId },
|
||||||
|
data: { studyMaterialUrl: publicUrl }
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ success: true, studyMaterialUrl: updated.studyMaterialUrl });
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Erro ao fazer upload do material:', err);
|
||||||
|
res.status(500).json({ error: 'Erro ao enviar arquivo para o servidor.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove material de estudo do curso
|
||||||
|
app.delete('/api/admin/courses/:id/remove-material', authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
const courseId = req.params.id;
|
||||||
|
try {
|
||||||
|
await prisma.course.update({
|
||||||
|
where: { id: courseId },
|
||||||
|
data: { studyMaterialUrl: null }
|
||||||
|
});
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: 'Erro ao remover material.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
// 4. CRUD Modules
|
// 4. CRUD Modules
|
||||||
app.post('/api/admin/modules', authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res: Response) => {
|
app.post('/api/admin/modules', authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res: Response) => {
|
||||||
const { courseId, title, order } = req.body;
|
const { courseId, title, order } = req.body;
|
||||||
|
|
|
||||||
29
src/App.tsx
29
src/App.tsx
|
|
@ -258,7 +258,7 @@ export default function App() {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const el = lessonShelfRefs.current[courseId];
|
const el = lessonShelfRefs.current[courseId];
|
||||||
if (el) {
|
if (el) {
|
||||||
el.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
}
|
}
|
||||||
}, 80);
|
}, 80);
|
||||||
}
|
}
|
||||||
|
|
@ -359,9 +359,22 @@ export default function App() {
|
||||||
referrerPolicy="no-referrer"
|
referrerPolicy="no-referrer"
|
||||||
/>
|
/>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<h3 className="text-[10px] font-extrabold text-[#E50914] uppercase tracking-widest">Aulas do Curso</h3>
|
<h3 className="text-[10px] font-extrabold text-[#E50914] uppercase tracking-widest">
|
||||||
<p className="text-sm md:text-base font-extrabold text-white leading-tight line-clamp-2">{expandedCourse.title}</p>
|
Aulas do Curso: <span className="text-white">{expandedCourse.title}</span>
|
||||||
|
</h3>
|
||||||
<p className="text-[10px] text-zinc-500 mt-0.5">{expandedCourse.lessons.length} aulas disponíveis</p>
|
<p className="text-[10px] text-zinc-500 mt-0.5">{expandedCourse.lessons.length} aulas disponíveis</p>
|
||||||
|
|
||||||
|
{(expandedCourse as any).studyMaterialUrl && (
|
||||||
|
<a
|
||||||
|
href={(expandedCourse as any).studyMaterialUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="inline-flex items-center gap-1.5 mt-2 bg-[#E50914]/10 hover:bg-[#E50914]/20 border border-[#E50914]/30 text-[#E50914] text-[10px] font-bold px-2.5 py-1 rounded-md transition-colors"
|
||||||
|
>
|
||||||
|
<svg className="w-3 h-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line></svg>
|
||||||
|
Baixar Material (PDF)
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
|
|
@ -414,6 +427,16 @@ export default function App() {
|
||||||
<span className="absolute top-2 left-2 bg-black/70 text-[#E50914] text-[9px] font-extrabold px-1.5 py-0.5 rounded backdrop-blur-sm border border-white/10">
|
<span className="absolute top-2 left-2 bg-black/70 text-[#E50914] text-[9px] font-extrabold px-1.5 py-0.5 rounded backdrop-blur-sm border border-white/10">
|
||||||
#{lesson.order}
|
#{lesson.order}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
{/* Progress Bar Overlay */}
|
||||||
|
{(lesson as any).progress !== undefined && (
|
||||||
|
<div className="absolute bottom-0 left-0 w-full h-1.5 bg-black/80">
|
||||||
|
<div
|
||||||
|
className="h-full bg-[#E50914] transition-all duration-300"
|
||||||
|
style={{ width: `${(lesson as any).progress.watchedPercentage}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Info below */}
|
{/* Info below */}
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,10 @@ import React, { useState, useEffect } from 'react';
|
||||||
import {
|
import {
|
||||||
GraduationCap, Plus, Edit, Trash2, FolderPlus, FilePlus, ChevronRight,
|
GraduationCap, Plus, Edit, Trash2, FolderPlus, FilePlus, ChevronRight,
|
||||||
ArrowLeft, Film, Youtube, RefreshCw, Layers, CheckCircle, CheckCircle2,
|
ArrowLeft, Film, Youtube, RefreshCw, Layers, CheckCircle, CheckCircle2,
|
||||||
GripVertical
|
GripVertical, FileText, Download, X
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useModal } from '../contexts/ModalContext';
|
import { useModal } from '../contexts/ModalContext';
|
||||||
|
import ToggleSwitch from './ToggleSwitch';
|
||||||
|
|
||||||
interface Lesson {
|
interface Lesson {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -35,6 +36,7 @@ interface Course {
|
||||||
price?: number;
|
price?: number;
|
||||||
certificateMode?: string;
|
certificateMode?: string;
|
||||||
order?: number;
|
order?: number;
|
||||||
|
studyMaterialUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AdminContentManagerProps {
|
interface AdminContentManagerProps {
|
||||||
|
|
@ -75,6 +77,8 @@ export default function AdminContentManager({ token }: AdminContentManagerProps)
|
||||||
const [activityQuestions, setActivityQuestions] = useState<{question: string, options: string[], correctIndex: number}[]>([]);
|
const [activityQuestions, setActivityQuestions] = useState<{question: string, options: string[], correctIndex: number}[]>([]);
|
||||||
|
|
||||||
const [uploadingImage, setUploadingImage] = useState(false);
|
const [uploadingImage, setUploadingImage] = useState(false);
|
||||||
|
const [uploadingMaterial, setUploadingMaterial] = useState(false);
|
||||||
|
const [editingCourseMaterialUrl, setEditingCourseMaterialUrl] = useState<string | null>(null);
|
||||||
|
|
||||||
// Drag and Drop States
|
// Drag and Drop States
|
||||||
const [draggedCourseIndex, setDraggedCourseIndex] = useState<number | null>(null);
|
const [draggedCourseIndex, setDraggedCourseIndex] = useState<number | null>(null);
|
||||||
|
|
@ -347,6 +351,7 @@ export default function AdminContentManager({ token }: AdminContentManagerProps)
|
||||||
certificateMode: (course.certificateMode || 'FULL_COURSE') as 'PER_MODULE' | 'FULL_COURSE',
|
certificateMode: (course.certificateMode || 'FULL_COURSE') as 'PER_MODULE' | 'FULL_COURSE',
|
||||||
order: course.order || 0
|
order: course.order || 0
|
||||||
});
|
});
|
||||||
|
setEditingCourseMaterialUrl(course.studyMaterialUrl || null);
|
||||||
setShowCourseForm(true);
|
setShowCourseForm(true);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
document.getElementById('admin-content-top')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
document.getElementById('admin-content-top')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
|
@ -709,15 +714,11 @@ export default function AdminContentManager({ token }: AdminContentManagerProps)
|
||||||
|
|
||||||
<div className="md:col-span-2 space-y-2 flex flex-col justify-center">
|
<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">
|
<ToggleSwitch
|
||||||
<input
|
checked={courseForm.isLocked}
|
||||||
type="checkbox"
|
onChange={(v) => setCourseForm({ ...courseForm, isLocked: v })}
|
||||||
checked={courseForm.isLocked}
|
label="Curso Trancado (Requer pagamento ou desbloqueio manual)"
|
||||||
onChange={(e) => setCourseForm({ ...courseForm, isLocked: e.target.checked })}
|
/>
|
||||||
className="rounded bg-zinc-900 border-white/10 text-[#E50914] focus:ring-[#E50914] focus:ring-offset-0 w-4 h-4"
|
|
||||||
/>
|
|
||||||
<span className="text-sm font-semibold text-zinc-300">Curso Trancado (Requer pagamento ou desbloqueio manual)</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="md:col-span-2 space-y-2">
|
<div className="md:col-span-2 space-y-2">
|
||||||
|
|
@ -731,6 +732,91 @@ export default function AdminContentManager({ token }: AdminContentManagerProps)
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Material de Estudo PDF - só aparece ao editar */}
|
||||||
|
{editingCourse && (
|
||||||
|
<div className="md:col-span-2 space-y-3 glass-card rounded-xl p-4 border border-white/5">
|
||||||
|
<label className="text-xs text-zinc-400 font-bold uppercase tracking-wider flex items-center gap-2">
|
||||||
|
<FileText className="w-3.5 h-3.5 text-[#E50914]" />
|
||||||
|
Material de Estudo (PDF)
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{editingCourseMaterialUrl ? (
|
||||||
|
<div className="flex items-center gap-3 bg-zinc-900/50 rounded-lg p-3 border border-white/5">
|
||||||
|
<FileText className="w-5 h-5 text-[#E50914] flex-shrink-0" />
|
||||||
|
<a
|
||||||
|
href={editingCourseMaterialUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="text-xs text-zinc-300 hover:text-white truncate flex-1 underline underline-offset-2"
|
||||||
|
>
|
||||||
|
{editingCourseMaterialUrl.split('/').pop()}
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={async () => {
|
||||||
|
await fetch(`/api/admin/courses/${editingCourse.id}/remove-material`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
setEditingCourseMaterialUrl(null);
|
||||||
|
showSuccess('Material removido!');
|
||||||
|
}}
|
||||||
|
className="text-zinc-500 hover:text-red-400 transition-colors p-1"
|
||||||
|
title="Remover material"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-zinc-600 italic">Nenhum material de estudo anexado.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<label
|
||||||
|
className={`flex items-center gap-2 cursor-pointer glass-btn-secondary text-white text-xs font-bold px-4 py-2 rounded-lg transition-all hover:scale-105 ${
|
||||||
|
uploadingMaterial ? 'opacity-60 pointer-events-none' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<FileText className="w-4 h-4" />
|
||||||
|
{uploadingMaterial ? 'Enviando PDF...' : 'Upload PDF'}
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".pdf"
|
||||||
|
className="hidden"
|
||||||
|
disabled={uploadingMaterial}
|
||||||
|
onChange={async (e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
setUploadingMaterial(true);
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
const res = await fetch(`/api/admin/courses/${editingCourse.id}/upload-material`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` },
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok) {
|
||||||
|
setEditingCourseMaterialUrl(data.studyMaterialUrl);
|
||||||
|
showSuccess('Material de estudo enviado com sucesso!');
|
||||||
|
} else {
|
||||||
|
showError(data.error || 'Erro ao enviar PDF.');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
showError('Erro de conexão ao enviar PDF.');
|
||||||
|
} finally {
|
||||||
|
setUploadingMaterial(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<span className="text-[10px] text-zinc-600">Máx. recomendado: 50MB • Formato: PDF</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
|
||||||
<div className="md:col-span-2 flex justify-end">
|
<div className="md:col-span-2 flex justify-end">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import { AdminPricingManager } from './AdminPricingManager';
|
||||||
import { WhatsappConnectCard } from './WhatsappConnectCard';
|
import { WhatsappConnectCard } from './WhatsappConnectCard';
|
||||||
import AdminContentManager from './AdminContentManager';
|
import AdminContentManager from './AdminContentManager';
|
||||||
import AdminMessagesView from './AdminMessagesView';
|
import AdminMessagesView from './AdminMessagesView';
|
||||||
|
import ToggleSwitch from './ToggleSwitch';
|
||||||
|
|
||||||
interface WebhookLog {
|
interface WebhookLog {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -986,34 +987,20 @@ export default function AdminDashboard({ token, adminUser, onLogout }: AdminDash
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="pt-2 md:col-span-2 space-y-1">
|
<div className="pt-2 md:col-span-2 space-y-1 flex flex-col justify-center">
|
||||||
<label className="flex items-center space-x-3 cursor-pointer bg-black/40 p-3 rounded-lg border border-white/10 hover:border-white/20 transition-colors">
|
<ToggleSwitch
|
||||||
<input
|
checked={settings.enableBoleto !== false}
|
||||||
type="checkbox"
|
onChange={(v) => setSettings({ ...settings, enableBoleto: v })}
|
||||||
checked={settings.enableBoleto !== false}
|
label="Habilitar Boleto Bancário (Permite pagamentos à vista)"
|
||||||
onChange={(e) => setSettings({ ...settings, enableBoleto: e.target.checked })}
|
/>
|
||||||
className="rounded bg-zinc-900 border-white/10 text-[#E50914] focus:ring-[#E50914] focus:ring-offset-0 w-4 h-4 cursor-pointer"
|
|
||||||
/>
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span className="text-xs font-bold text-white">Habilitar Boleto Bancário</span>
|
|
||||||
<span className="text-[10px] text-zinc-400">Permite pagamentos à vista via boleto bancário.</span>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="pt-2 md:col-span-2 space-y-1">
|
<div className="pt-2 md:col-span-2 space-y-1 flex flex-col justify-center">
|
||||||
<label className="flex items-center space-x-3 cursor-pointer bg-black/40 p-3 rounded-lg border border-white/10 hover:border-white/20 transition-colors">
|
<ToggleSwitch
|
||||||
<input
|
checked={settings.enableInstallmentBoleto === true}
|
||||||
type="checkbox"
|
onChange={(v) => setSettings({ ...settings, enableInstallmentBoleto: v })}
|
||||||
checked={settings.enableInstallmentBoleto === true}
|
label="Habilitar Boleto Parcelado (Permite gerar boletos parcelados)"
|
||||||
onChange={(e) => setSettings({ ...settings, enableInstallmentBoleto: e.target.checked })}
|
/>
|
||||||
className="rounded bg-zinc-900 border-white/10 text-[#E50914] focus:ring-[#E50914] focus:ring-offset-0 w-4 h-4 cursor-pointer"
|
|
||||||
/>
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span className="text-xs font-bold text-white">Habilitar Boleto Parcelado</span>
|
|
||||||
<span className="text-[10px] text-zinc-400">Permite gerar boletos parcelados para compra de cursos.</span>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{settings.enableInstallmentBoleto && (
|
{settings.enableInstallmentBoleto && (
|
||||||
|
|
@ -1460,17 +1447,13 @@ export default function AdminDashboard({ token, adminUser, onLogout }: AdminDash
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2 flex flex-col justify-center">
|
<div className="space-y-2 flex flex-col justify-center mt-4">
|
||||||
<label className="flex items-center space-x-3 cursor-pointer mt-4">
|
<ToggleSwitch
|
||||||
<input
|
checked={settings.smtpSecure || false}
|
||||||
type="checkbox"
|
onChange={(v) => setSettings({...settings, smtpSecure: v})}
|
||||||
checked={settings.smtpSecure || false}
|
label="Usar Conexão Segura (SSL/TLS)"
|
||||||
onChange={(e) => setSettings({...settings, smtpSecure: e.target.checked})}
|
/>
|
||||||
className="form-checkbox h-5 w-5 text-[#E50914] rounded border-white/10 bg-black/40 focus:ring-[#E50914] focus:ring-offset-black"
|
<p className="text-[10px] text-zinc-500 mt-1 pl-14">Marque se a porta for 465. Para 587 (STARTTLS), normalmente fica desmarcado.</p>
|
||||||
/>
|
|
||||||
<span className="text-sm font-bold text-white">Usar Conexão Segura (SSL/TLS)</span>
|
|
||||||
</label>
|
|
||||||
<p className="text-[10px] text-zinc-500 mt-1 pl-8">Marque se a porta for 465. Para 587 (STARTTLS), normalmente fica desmarcado.</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react';
|
||||||
import { RefreshCw, Save, Plus, Trash2, Edit, CheckCircle2, AlertTriangle, BookOpen, Layers, DollarSign } from 'lucide-react';
|
import { RefreshCw, Save, Plus, Trash2, Edit, CheckCircle2, AlertTriangle, BookOpen, Layers, DollarSign } from 'lucide-react';
|
||||||
import { Course, Plan } from '../types';
|
import { Course, Plan } from '../types';
|
||||||
import { useModal } from '../contexts/ModalContext';
|
import { useModal } from '../contexts/ModalContext';
|
||||||
|
import ToggleSwitch from './ToggleSwitch';
|
||||||
|
|
||||||
interface AdminPricingManagerProps {
|
interface AdminPricingManagerProps {
|
||||||
courses: Course[];
|
courses: Course[];
|
||||||
|
|
@ -374,18 +375,13 @@ export const AdminPricingManager: React.FC<AdminPricingManagerProps> = ({ course
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label className="flex items-center gap-3 cursor-pointer p-3 bg-black/20 rounded-lg border border-white/5 hover:bg-black/40 transition-colors">
|
<div className="flex flex-col justify-center">
|
||||||
<input
|
<ToggleSwitch
|
||||||
type="checkbox"
|
|
||||||
checked={planForm.active}
|
checked={planForm.active}
|
||||||
onChange={e => setPlanForm({...planForm, active: e.target.checked})}
|
onChange={v => setPlanForm({...planForm, active: v})}
|
||||||
className="w-5 h-5 rounded border-zinc-600 text-purple-600 focus:ring-purple-600 focus:ring-offset-zinc-900 bg-zinc-800"
|
label="Plano Ativo (Visível para os alunos comprarem)"
|
||||||
/>
|
/>
|
||||||
<div>
|
</div>
|
||||||
<span className="text-sm font-bold text-white block">Plano Ativo (Visível)</span>
|
|
||||||
<span className="text-xs text-zinc-500">Alunos poderão comprar este plano.</span>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Course Selection */}
|
{/* Course Selection */}
|
||||||
|
|
@ -400,41 +396,34 @@ export const AdminPricingManager: React.FC<AdminPricingManagerProps> = ({ course
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-4 overflow-y-auto space-y-2 flex-grow custom-scrollbar">
|
<div className="p-4 overflow-y-auto space-y-2 flex-grow custom-scrollbar">
|
||||||
<label className={`flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition-all ${
|
<div className={`p-3 rounded-lg border transition-all ${
|
||||||
planForm.includedCourses?.length === 0
|
planForm.includedCourses?.length === 0
|
||||||
? 'bg-purple-900/20 border-purple-500/50'
|
? 'bg-purple-900/20 border-purple-500/50'
|
||||||
: 'bg-zinc-900/50 border-white/5 hover:border-white/20'
|
: 'bg-zinc-900/50 border-white/5'
|
||||||
}`}>
|
}`}>
|
||||||
<input
|
<ToggleSwitch
|
||||||
type="checkbox"
|
|
||||||
checked={planForm.includedCourses?.length === 0}
|
checked={planForm.includedCourses?.length === 0}
|
||||||
onChange={() => setPlanForm({...planForm, includedCourses: []})}
|
onChange={(v) => { if(v) setPlanForm({...planForm, includedCourses: []}) }}
|
||||||
className="w-5 h-5 rounded-full border-zinc-600 text-purple-600 focus:ring-purple-600 bg-zinc-800"
|
label="Acesso Global (All-Access) - Libera todos os cursos, presentes e futuros."
|
||||||
/>
|
/>
|
||||||
<div>
|
</div>
|
||||||
<span className="text-sm font-bold text-white block">Acesso Global (All-Access)</span>
|
|
||||||
<span className="text-xs text-zinc-400">Libera todos os cursos, presentes e futuros.</span>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div className="my-4 border-t border-white/10 relative">
|
<div className="my-4 border-t border-white/10 relative">
|
||||||
<span className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 bg-black px-2 text-xs font-bold text-zinc-600">OU ESCOLHA ESPECÍFICOS</span>
|
<span className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 bg-black px-2 text-xs font-bold text-zinc-600">OU ESCOLHA ESPECÍFICOS</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{courses.map(course => (
|
{courses.map(course => (
|
||||||
<label key={course.id} className={`flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition-all ${
|
<div key={course.id} className={`p-3 rounded-lg border transition-all ${
|
||||||
planForm.includedCourses?.includes(course.id)
|
planForm.includedCourses?.includes(course.id)
|
||||||
? 'bg-purple-900/20 border-purple-500/50'
|
? 'bg-purple-900/20 border-purple-500/50'
|
||||||
: 'bg-zinc-900/50 border-white/5 hover:border-white/20'
|
: 'bg-zinc-900/50 border-white/5'
|
||||||
}`}>
|
}`}>
|
||||||
<input
|
<ToggleSwitch
|
||||||
type="checkbox"
|
checked={planForm.includedCourses?.includes(course.id) || false}
|
||||||
checked={planForm.includedCourses?.includes(course.id)}
|
|
||||||
onChange={() => toggleCourseInPlan(course.id)}
|
onChange={() => toggleCourseInPlan(course.id)}
|
||||||
className="w-5 h-5 rounded border-zinc-600 text-purple-600 focus:ring-purple-600 bg-zinc-800"
|
label={course.title}
|
||||||
/>
|
/>
|
||||||
<span className="text-sm font-medium text-zinc-300 truncate">{course.title}</span>
|
</div>
|
||||||
</label>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { ArrowLeft, BookOpen, ChevronRight, FileText, CheckCircle2, Circle, MessageSquare, AlertCircle, Award } from 'lucide-react';
|
import { ArrowLeft, BookOpen, ChevronRight, FileText, CheckCircle2, Circle, MessageSquare, AlertCircle, Award, Download } from 'lucide-react';
|
||||||
import VideoPlayer from './VideoPlayer';
|
import VideoPlayer from './VideoPlayer';
|
||||||
import CourseActivity from './CourseActivity';
|
import CourseActivity from './CourseActivity';
|
||||||
|
|
||||||
|
|
@ -34,6 +34,7 @@ interface Course {
|
||||||
description: string;
|
description: string;
|
||||||
thumbnail: string;
|
thumbnail: string;
|
||||||
category: string;
|
category: string;
|
||||||
|
studyMaterialUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CourseViewProps {
|
interface CourseViewProps {
|
||||||
|
|
@ -331,9 +332,23 @@ export default function CourseView({ courseId, token, onBack, initialLessonId }:
|
||||||
|
|
||||||
{/* Right Hand: Interactive Modules Accordion */}
|
{/* Right Hand: Interactive Modules Accordion */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="glass-card p-4 rounded-xl space-y-1">
|
<div className="glass-card p-4 rounded-xl space-y-3">
|
||||||
<h3 className="font-sans font-extrabold text-sm text-white tracking-tight">Conteúdo do Curso</h3>
|
<div>
|
||||||
<p className="text-xs text-zinc-500 font-medium">{course.title}</p>
|
<h3 className="font-sans font-extrabold text-sm text-white tracking-tight">Conteúdo do Curso</h3>
|
||||||
|
<p className="text-xs text-zinc-500 font-medium mt-1">{course.title}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{course.studyMaterialUrl && (
|
||||||
|
<a
|
||||||
|
href={course.studyMaterialUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="flex items-center justify-center gap-2 w-full glass-btn-secondary text-[#E50914] text-xs font-bold py-2 px-3 rounded-lg hover:scale-[1.02] transition-all"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4" />
|
||||||
|
Baixar Material de Estudo
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
|
|
||||||
|
|
@ -91,13 +91,23 @@ export default function HeroBanner({ course, onPlay, onDetail, subscriptionStatu
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button
|
{course.progress > 0 ? (
|
||||||
onClick={() => onDetail(course.id)}
|
<button
|
||||||
className="glass-btn-secondary text-white font-medium px-5 py-2.5 rounded-lg flex items-center space-x-2 cursor-pointer"
|
onClick={() => onPlay(course.id)}
|
||||||
>
|
className="glass-btn-secondary text-white font-medium px-5 py-2.5 rounded-lg flex items-center space-x-2 cursor-pointer"
|
||||||
<Info className="w-5 h-5 text-zinc-300" />
|
>
|
||||||
<span>Ver Detalhes</span>
|
<Info className="w-5 h-5 text-zinc-300" />
|
||||||
</button>
|
<span>Continuar Treinamento</span>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => onDetail(course.id)}
|
||||||
|
className="glass-btn-secondary text-white font-medium px-5 py-2.5 rounded-lg flex items-center space-x-2 cursor-pointer"
|
||||||
|
>
|
||||||
|
<Info className="w-5 h-5 text-zinc-300" />
|
||||||
|
<span>Ver Detalhes</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface ToggleSwitchProps {
|
||||||
|
checked: boolean;
|
||||||
|
onChange: (checked: boolean) => void;
|
||||||
|
label?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ToggleSwitch({ checked, onChange, label, disabled = false, id }: ToggleSwitchProps) {
|
||||||
|
const uniqueId = id || `toggle-${Math.random().toString(36).slice(2, 9)}`;
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
htmlFor={uniqueId}
|
||||||
|
className={`inline-flex items-center gap-2.5 ${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}`}
|
||||||
|
>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
id={uniqueId}
|
||||||
|
type="checkbox"
|
||||||
|
className="sr-only peer"
|
||||||
|
checked={checked}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) => onChange(e.target.checked)}
|
||||||
|
/>
|
||||||
|
{/* Track */}
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
w-11 h-6 rounded-full border transition-all duration-300
|
||||||
|
${checked
|
||||||
|
? 'bg-[#E50914] border-[#E50914] shadow-[0_0_10px_rgba(229,9,20,0.35)]'
|
||||||
|
: 'bg-zinc-800 border-zinc-700'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
/>
|
||||||
|
{/* Thumb */}
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
absolute top-0.5 left-0.5 w-5 h-5 rounded-full bg-white shadow-md
|
||||||
|
transition-all duration-300 transform
|
||||||
|
${checked ? 'translate-x-5' : 'translate-x-0'}
|
||||||
|
`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{label && (
|
||||||
|
<span className="text-sm font-medium text-zinc-300 select-none">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue