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
|
|
@ -90,6 +90,7 @@ model Course {
|
|||
isLocked Boolean @default(false) @map("is_locked")
|
||||
price Float?
|
||||
order Int @default(0)
|
||||
studyMaterialUrl String? @map("study_material_url") @db.Text
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
modules Module[]
|
||||
|
|
|
|||
73
server.ts
73
server.ts
|
|
@ -539,21 +539,28 @@ app.get('/api/courses', authenticateToken, async (req: AuthenticatedRequest, res
|
|||
});
|
||||
|
||||
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 sortedModules = [...course.modules].sort((a, b) => a.order - b.order);
|
||||
const courseLessons = sortedModules.flatMap(m => {
|
||||
const sortedLessons = [...m.lessons].sort((a, b) => a.order - b.order);
|
||||
return sortedLessons.map(l => ({
|
||||
return sortedLessons.map(l => {
|
||||
const prog = userProgress.find(p => p.lessonId === l.id);
|
||||
return {
|
||||
id: l.id,
|
||||
title: l.title,
|
||||
thumbnailUrl: l.thumbnailUrl,
|
||||
order: l.order,
|
||||
moduleId: m.id
|
||||
}));
|
||||
moduleId: m.id,
|
||||
progress: prog ? {
|
||||
watchedPercentage: prog.watchedPercentage,
|
||||
completed: prog.completed
|
||||
} : { watchedPercentage: 0, completed: false }
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
const totalLessons = courseLessons.length;
|
||||
|
|
@ -573,6 +580,7 @@ app.get('/api/courses', authenticateToken, async (req: AuthenticatedRequest, res
|
|||
isLocked: course.isLocked,
|
||||
price: course.price,
|
||||
category: course.category,
|
||||
studyMaterialUrl: course.studyMaterialUrl,
|
||||
totalLessons,
|
||||
completedLessons,
|
||||
progress: progressPercentage,
|
||||
|
|
@ -648,6 +656,7 @@ app.get('/api/courses/:id', authenticateToken, async (req: AuthenticatedRequest,
|
|||
res.json({
|
||||
course: {
|
||||
...courseData,
|
||||
studyMaterialUrl: course.studyMaterialUrl,
|
||||
isUnlocked
|
||||
},
|
||||
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
|
||||
app.post('/api/admin/modules', authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res: Response) => {
|
||||
const { courseId, title, order } = req.body;
|
||||
|
|
|
|||
29
src/App.tsx
29
src/App.tsx
|
|
@ -258,7 +258,7 @@ export default function App() {
|
|||
setTimeout(() => {
|
||||
const el = lessonShelfRefs.current[courseId];
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
}, 80);
|
||||
}
|
||||
|
|
@ -359,9 +359,22 @@ export default function App() {
|
|||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-[10px] font-extrabold text-[#E50914] uppercase tracking-widest">Aulas do Curso</h3>
|
||||
<p className="text-sm md:text-base font-extrabold text-white leading-tight line-clamp-2">{expandedCourse.title}</p>
|
||||
<h3 className="text-[10px] font-extrabold text-[#E50914] uppercase tracking-widest">
|
||||
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>
|
||||
|
||||
{(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>
|
||||
<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">
|
||||
#{lesson.order}
|
||||
</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>
|
||||
|
||||
{/* Info below */}
|
||||
|
|
|
|||
|
|
@ -2,9 +2,10 @@ import React, { useState, useEffect } from 'react';
|
|||
import {
|
||||
GraduationCap, Plus, Edit, Trash2, FolderPlus, FilePlus, ChevronRight,
|
||||
ArrowLeft, Film, Youtube, RefreshCw, Layers, CheckCircle, CheckCircle2,
|
||||
GripVertical
|
||||
GripVertical, FileText, Download, X
|
||||
} from 'lucide-react';
|
||||
import { useModal } from '../contexts/ModalContext';
|
||||
import ToggleSwitch from './ToggleSwitch';
|
||||
|
||||
interface Lesson {
|
||||
id: string;
|
||||
|
|
@ -35,6 +36,7 @@ interface Course {
|
|||
price?: number;
|
||||
certificateMode?: string;
|
||||
order?: number;
|
||||
studyMaterialUrl?: string;
|
||||
}
|
||||
|
||||
interface AdminContentManagerProps {
|
||||
|
|
@ -75,6 +77,8 @@ export default function AdminContentManager({ token }: AdminContentManagerProps)
|
|||
const [activityQuestions, setActivityQuestions] = useState<{question: string, options: string[], correctIndex: number}[]>([]);
|
||||
|
||||
const [uploadingImage, setUploadingImage] = useState(false);
|
||||
const [uploadingMaterial, setUploadingMaterial] = useState(false);
|
||||
const [editingCourseMaterialUrl, setEditingCourseMaterialUrl] = useState<string | null>(null);
|
||||
|
||||
// Drag and Drop States
|
||||
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',
|
||||
order: course.order || 0
|
||||
});
|
||||
setEditingCourseMaterialUrl(course.studyMaterialUrl || null);
|
||||
setShowCourseForm(true);
|
||||
setTimeout(() => {
|
||||
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">
|
||||
<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
|
||||
type="checkbox"
|
||||
<ToggleSwitch
|
||||
checked={courseForm.isLocked}
|
||||
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"
|
||||
onChange={(v) => setCourseForm({ ...courseForm, isLocked: v })}
|
||||
label="Curso Trancado (Requer pagamento ou desbloqueio manual)"
|
||||
/>
|
||||
<span className="text-sm font-semibold text-zinc-300">Curso Trancado (Requer pagamento ou desbloqueio manual)</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2 space-y-2">
|
||||
|
|
@ -731,6 +732,91 @@ export default function AdminContentManager({ token }: AdminContentManagerProps)
|
|||
/>
|
||||
</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">
|
||||
<button
|
||||
type="submit"
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { AdminPricingManager } from './AdminPricingManager';
|
|||
import { WhatsappConnectCard } from './WhatsappConnectCard';
|
||||
import AdminContentManager from './AdminContentManager';
|
||||
import AdminMessagesView from './AdminMessagesView';
|
||||
import ToggleSwitch from './ToggleSwitch';
|
||||
|
||||
interface WebhookLog {
|
||||
id: string;
|
||||
|
|
@ -986,34 +987,20 @@ export default function AdminDashboard({ token, adminUser, onLogout }: AdminDash
|
|||
/>
|
||||
</div>
|
||||
|
||||
<div className="pt-2 md:col-span-2 space-y-1">
|
||||
<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">
|
||||
<input
|
||||
type="checkbox"
|
||||
<div className="pt-2 md:col-span-2 space-y-1 flex flex-col justify-center">
|
||||
<ToggleSwitch
|
||||
checked={settings.enableBoleto !== false}
|
||||
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"
|
||||
onChange={(v) => setSettings({ ...settings, enableBoleto: v })}
|
||||
label="Habilitar Boleto Bancário (Permite pagamentos à vista)"
|
||||
/>
|
||||
<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 className="pt-2 md:col-span-2 space-y-1">
|
||||
<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">
|
||||
<input
|
||||
type="checkbox"
|
||||
<div className="pt-2 md:col-span-2 space-y-1 flex flex-col justify-center">
|
||||
<ToggleSwitch
|
||||
checked={settings.enableInstallmentBoleto === true}
|
||||
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"
|
||||
onChange={(v) => setSettings({ ...settings, enableInstallmentBoleto: v })}
|
||||
label="Habilitar Boleto Parcelado (Permite gerar boletos parcelados)"
|
||||
/>
|
||||
<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>
|
||||
|
||||
{settings.enableInstallmentBoleto && (
|
||||
|
|
@ -1460,17 +1447,13 @@ export default function AdminDashboard({ token, adminUser, onLogout }: AdminDash
|
|||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 flex flex-col justify-center">
|
||||
<label className="flex items-center space-x-3 cursor-pointer mt-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
<div className="space-y-2 flex flex-col justify-center mt-4">
|
||||
<ToggleSwitch
|
||||
checked={settings.smtpSecure || false}
|
||||
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"
|
||||
onChange={(v) => setSettings({...settings, smtpSecure: v})}
|
||||
label="Usar Conexão Segura (SSL/TLS)"
|
||||
/>
|
||||
<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>
|
||||
<p className="text-[10px] text-zinc-500 mt-1 pl-14">Marque se a porta for 465. Para 587 (STARTTLS), normalmente fica desmarcado.</p>
|
||||
</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 { Course, Plan } from '../types';
|
||||
import { useModal } from '../contexts/ModalContext';
|
||||
import ToggleSwitch from './ToggleSwitch';
|
||||
|
||||
interface AdminPricingManagerProps {
|
||||
courses: Course[];
|
||||
|
|
@ -374,18 +375,13 @@ export const AdminPricingManager: React.FC<AdminPricingManagerProps> = ({ course
|
|||
</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">
|
||||
<input
|
||||
type="checkbox"
|
||||
<div className="flex flex-col justify-center">
|
||||
<ToggleSwitch
|
||||
checked={planForm.active}
|
||||
onChange={e => setPlanForm({...planForm, active: e.target.checked})}
|
||||
className="w-5 h-5 rounded border-zinc-600 text-purple-600 focus:ring-purple-600 focus:ring-offset-zinc-900 bg-zinc-800"
|
||||
onChange={v => setPlanForm({...planForm, active: v})}
|
||||
label="Plano Ativo (Visível para os alunos comprarem)"
|
||||
/>
|
||||
<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>
|
||||
|
||||
{/* Course Selection */}
|
||||
|
|
@ -400,41 +396,34 @@ export const AdminPricingManager: React.FC<AdminPricingManagerProps> = ({ course
|
|||
</div>
|
||||
|
||||
<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
|
||||
? '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
|
||||
type="checkbox"
|
||||
<ToggleSwitch
|
||||
checked={planForm.includedCourses?.length === 0}
|
||||
onChange={() => setPlanForm({...planForm, includedCourses: []})}
|
||||
className="w-5 h-5 rounded-full border-zinc-600 text-purple-600 focus:ring-purple-600 bg-zinc-800"
|
||||
onChange={(v) => { if(v) setPlanForm({...planForm, includedCourses: []}) }}
|
||||
label="Acesso Global (All-Access) - Libera todos os cursos, presentes e futuros."
|
||||
/>
|
||||
<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">
|
||||
<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>
|
||||
|
||||
{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)
|
||||
? '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
|
||||
type="checkbox"
|
||||
checked={planForm.includedCourses?.includes(course.id)}
|
||||
<ToggleSwitch
|
||||
checked={planForm.includedCourses?.includes(course.id) || false}
|
||||
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>
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
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 CourseActivity from './CourseActivity';
|
||||
|
||||
|
|
@ -34,6 +34,7 @@ interface Course {
|
|||
description: string;
|
||||
thumbnail: string;
|
||||
category: string;
|
||||
studyMaterialUrl?: string;
|
||||
}
|
||||
|
||||
interface CourseViewProps {
|
||||
|
|
@ -331,9 +332,23 @@ export default function CourseView({ courseId, token, onBack, initialLessonId }:
|
|||
|
||||
{/* Right Hand: Interactive Modules Accordion */}
|
||||
<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">
|
||||
<div>
|
||||
<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">{course.title}</p>
|
||||
<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 className="space-y-3">
|
||||
|
|
|
|||
|
|
@ -91,6 +91,15 @@ export default function HeroBanner({ course, onPlay, onDetail, subscriptionStatu
|
|||
</button>
|
||||
)}
|
||||
|
||||
{course.progress > 0 ? (
|
||||
<button
|
||||
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>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"
|
||||
|
|
@ -98,6 +107,7 @@ export default function HeroBanner({ course, onPlay, onDetail, subscriptionStatu
|
|||
<Info className="w-5 h-5 text-zinc-300" />
|
||||
<span>Ver Detalhes</span>
|
||||
</button>
|
||||
)}
|
||||
</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