feat: suporte modal de tickets, aba de mensagens no admin, refatoracoes ui devflix e correcoes uppercase do brandName
Build and Deploy (Gitea) / build-and-deploy (push) Successful in 1m4s Details

This commit is contained in:
Sidney Gomes 2026-07-24 18:58:48 -03:00
parent 8e19edb4f8
commit be3e39e62d
12 changed files with 561 additions and 44 deletions

View File

@ -67,6 +67,7 @@ model User {
moduleGrades ModuleGrade[] moduleGrades ModuleGrade[]
certificates Certificate[] certificates Certificate[]
unlockedCourses String[] @default([]) @map("unlocked_courses") unlockedCourses String[] @default([]) @map("unlocked_courses")
supportTickets SupportTicket[]
@@map("users") @@map("users")
} }
@ -281,3 +282,15 @@ model SystemSettings {
@@map("system_settings") @@map("system_settings")
} }
model SupportTicket {
id String @id @default(uuid())
userId String @map("user_id")
content String @db.Text
status String @default("OPEN") // OPEN, REPLIED
createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("support_tickets")
}

125
server.ts
View File

@ -2211,16 +2211,123 @@ async function startServer() {
console.error('❌ Falha ao inicializar banco de dados:', dbError); console.error('❌ Falha ao inicializar banco de dados:', dbError);
} }
if (process.env.NODE_ENV !== 'production') { // --- Suporte / Mensagens ---
const { createServer: createViteServer } = await import('vite');
const vite = await createViteServer({ // Aluno envia um chamado de suporte/ajuda
server: { middlewareMode: true }, app.post('/api/support-tickets', authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
appType: 'spa', try {
const { content } = req.body;
if (!content) return res.status(400).json({ error: 'Conteúdo é obrigatório' });
const ticket = await prisma.supportTicket.create({
data: {
userId: req.user!.id,
content,
}
}); });
app.use(vite.middlewares);
} else { res.status(201).json(ticket);
const distPath = path.join(process.cwd(), 'dist'); } catch (error) {
app.use(express.static(distPath)); console.error('Error creating support ticket:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Admin puxa todos os chamados e anotações
app.get('/api/admin/messages', authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res: Response) => {
try {
const tickets = await prisma.supportTicket.findMany({
include: {
user: { select: { id: true, name: true, email: true, avatarUrl: true, whatsapp: true } }
},
orderBy: { createdAt: 'desc' }
});
const notes = await prisma.note.findMany({
include: {
user: { select: { id: true, name: true, email: true, avatarUrl: true, whatsapp: true } },
lesson: { select: { title: true, module: { select: { course: { select: { title: true } } } } } }
},
orderBy: { createdAt: 'desc' }
});
res.json({ tickets, notes });
} catch (error) {
console.error('Error fetching admin messages:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Admin responde um chamado ou anotação
app.post('/api/admin/reply-message', authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res: Response) => {
try {
const { userId, message, ticketId } = req.body;
if (!userId || !message) return res.status(400).json({ error: 'userId e message são obrigatórios' });
// 1. Criar notificação no portal (Sininho)
await prisma.notification.create({
data: {
userId,
title: 'Nova mensagem de Suporte',
message,
type: 'info'
}
});
if (ticketId) {
await prisma.supportTicket.update({
where: { id: ticketId },
data: { status: 'REPLIED' }
});
}
// 2. Enviar WhatsApp via Evolution API se configurado
const settings = await prisma.systemSettings.findUnique({ where: { id: 'default' } });
const user = await prisma.user.findUnique({ where: { id: userId } });
if (settings?.whatsappConnected && settings.evolutionApiUrl && settings.evolutionApiKey && user?.whatsapp) {
try {
const cleanPhone = user.whatsapp.replace(/\D/g, '');
if (cleanPhone.length >= 10) {
const numberId = `${cleanPhone}@s.whatsapp.net`;
const url = `${settings.evolutionApiUrl}/message/sendText/${settings.evolutionInstance}`;
await axios.post(url, {
number: numberId,
text: message,
options: { delay: 1200, linkPreview: true }
}, {
headers: {
'apikey': settings.evolutionApiKey,
'Content-Type': 'application/json'
}
});
console.log(`WhatsApp reply sent to ${cleanPhone}`);
}
} catch (waError: any) {
console.error('Failed to send WhatsApp reply:', waError?.response?.data || waError.message);
}
}
res.json({ success: true });
} catch (error) {
console.error('Error replying to message:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
if (process.env.NODE_ENV !== 'production') {
const { createServer: createViteServer } = await import('vite');
const vite = await createViteServer({
server: { middlewareMode: true },
appType: 'spa',
});
app.use(vite.middlewares);
} else {
const distPath = path.join(process.cwd(), 'dist');
app.use(express.static(distPath));
// Fallback para SPA - Isso DEVE estar no final
app.get('*', (req: Request, res: Response) => { app.get('*', (req: Request, res: Response) => {
res.sendFile(path.join(distPath, 'index.html')); res.sendFile(path.join(distPath, 'index.html'));
}); });

View File

@ -7,7 +7,7 @@ import AdminDashboard from './components/AdminDashboard';
import AdminContentManager from './components/AdminContentManager'; import AdminContentManager from './components/AdminContentManager';
export default function AdminApp() { export default function AdminApp() {
const [token, setToken] = useState<string | null>(localStorage.getItem('devflix_admin_token')); const [token, setToken] = useState<string | null>(localStorage.getItem('admin_token'));
const [adminUser, setAdminUser] = useState<{ const [adminUser, setAdminUser] = useState<{
name: string; name: string;
email: string; email: string;
@ -88,7 +88,7 @@ export default function AdminApp() {
throw new Error('Acesso recusado. Apenas administradores podem acessar esta área.'); throw new Error('Acesso recusado. Apenas administradores podem acessar esta área.');
} }
localStorage.setItem('devflix_admin_token', data.token); localStorage.setItem('admin_token', data.token);
setToken(data.token); setToken(data.token);
setAdminUser(data.user); setAdminUser(data.user);
setLoginEmail(''); setLoginEmail('');
@ -101,7 +101,7 @@ export default function AdminApp() {
}; };
const handleLogout = () => { const handleLogout = () => {
localStorage.removeItem('devflix_admin_token'); localStorage.removeItem('admin_token');
setToken(null); setToken(null);
setAdminUser(null); setAdminUser(null);
}; };
@ -131,7 +131,7 @@ export default function AdminApp() {
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<h1 className="font-display font-black text-2xl tracking-tighter text-white"> <h1 className="font-display font-black text-2xl tracking-tighter text-white">
{(settings?.brandName || 'MICROTEC').toUpperCase()}<span className="text-[#E50914]">FLIX</span> <span className="text-zinc-500 font-medium text-lg">ADMIN</span> {(settings?.brandName || 'Plataforma')}<span className="text-[#E50914]">FLIX</span> <span className="text-zinc-500 font-medium text-lg">ADMIN</span>
</h1> </h1>
<p className="text-[11px] text-zinc-400 font-medium">Autenticação obrigatória para gestores, administradores e moderadores.</p> <p className="text-[11px] text-zinc-400 font-medium">Autenticação obrigatória para gestores, administradores e moderadores.</p>
</div> </div>
@ -156,7 +156,7 @@ export default function AdminApp() {
required required
value={loginEmail} value={loginEmail}
onChange={(e) => setLoginEmail(e.target.value)} onChange={(e) => setLoginEmail(e.target.value)}
placeholder="email.admin@devflix.com" placeholder="admin@email.com"
className="w-full glass-input rounded-xl p-3.5 pl-11 text-xs text-white focus:outline-none" className="w-full glass-input rounded-xl p-3.5 pl-11 text-xs text-white focus:outline-none"
/> />
</div> </div>

View File

@ -15,7 +15,7 @@ import HelpView from './components/HelpView';
import { User as UserType, Course as CourseType, SubscriptionStatus } from './types'; import { User as UserType, Course as CourseType, SubscriptionStatus } from './types';
export default function App() { export default function App() {
const [token, setToken] = useState<string | null>(localStorage.getItem('devflix_token')); const [token, setToken] = useState<string | null>(localStorage.getItem('app_token'));
const [user, setUser] = useState<{ const [user, setUser] = useState<{
name: string; name: string;
email: string; email: string;
@ -66,7 +66,7 @@ export default function App() {
.then(data => { .then(data => {
setSettings(data); setSettings(data);
if (data.brandName) { if (data.brandName) {
document.title = `${data.brandName.charAt(0).toUpperCase() + data.brandName.slice(1)}Flix - Plataforma de Estudos`; document.title = `${data.brandName}FLIX - Plataforma de Estudos`;
} }
}) })
.catch(err => console.error('Error fetching settings:', err)); .catch(err => console.error('Error fetching settings:', err));
@ -188,7 +188,7 @@ export default function App() {
throw new Error(data.error || 'Erro ao efetuar login'); throw new Error(data.error || 'Erro ao efetuar login');
} }
localStorage.setItem('devflix_token', data.token); localStorage.setItem('app_token', data.token);
setToken(data.token); setToken(data.token);
setUser(data.user); setUser(data.user);
setLoginEmail(''); setLoginEmail('');
@ -218,7 +218,7 @@ export default function App() {
throw new Error(data.error || 'Erro ao efetuar cadastro'); throw new Error(data.error || 'Erro ao efetuar cadastro');
} }
localStorage.setItem('devflix_token', data.token); localStorage.setItem('app_token', data.token);
setToken(data.token); setToken(data.token);
setUser(data.user); setUser(data.user);
setRegName(''); setRegName('');
@ -232,7 +232,7 @@ export default function App() {
}; };
const handleLogout = () => { const handleLogout = () => {
localStorage.removeItem('devflix_token'); localStorage.removeItem('app_token');
setToken(null); setToken(null);
setUser(null); setUser(null);
setView('home'); setView('home');
@ -456,12 +456,12 @@ export default function App() {
{/* Footer */} {/* Footer */}
<footer className="border-t border-zinc-900 bg-zinc-950 py-8 px-4 text-center text-xs text-zinc-500 space-y-2 mt-12"> <footer className="border-t border-zinc-900 bg-zinc-950 py-8 px-4 text-center text-xs text-zinc-500 space-y-2 mt-12">
<p className="font-semibold text-zinc-400">{(settings?.brandName || 'MICROTEC').toUpperCase()}FLIX - Plataforma de Cursos Profissionalizantes</p> <p className="font-semibold text-zinc-400">{(settings?.brandName || 'Plataforma')}FLIX - Plataforma de Cursos Profissionalizantes</p>
<p className="max-w-md mx-auto">Desenvolvido com foco em alta experiência de estudo para Windows, Pacote Office (Word, Excel, PowerPoint) e informática profissionalizante.</p> <p className="max-w-md mx-auto">Desenvolvido com foco em alta experiência de estudo para Windows, Pacote Office (Word, Excel, PowerPoint) e informática profissionalizante.</p>
{settings?.cnpj && ( {settings?.cnpj && (
<p className="text-[11px] text-zinc-650">CNPJ: {settings.cnpj} Endereço: {settings.address}</p> <p className="text-[11px] text-zinc-650">CNPJ: {settings.cnpj} Endereço: {settings.address}</p>
)} )}
<p className="text-zinc-600">© 2026 {(settings?.brandName ? settings.brandName.charAt(0).toUpperCase() + settings.brandName.slice(1) : 'Microtec')}Flix Inc. Todos os direitos reservados. Integrado oficialmente com a API Asaas.</p> <p className="text-zinc-600">© 2026 {(settings?.brandName || 'Plataforma')}FLIX Inc. Todos os direitos reservados. Integrado oficialmente com a API Asaas.</p>
</footer> </footer>
</> </>
) : ( ) : (
@ -478,7 +478,7 @@ export default function App() {
<Play className="fill-white w-6 h-6" /> <Play className="fill-white w-6 h-6" />
</div> </div>
<h1 className="font-display font-black text-2xl tracking-tighter text-[#E50914]"> <h1 className="font-display font-black text-2xl tracking-tighter text-[#E50914]">
{(settings?.brandName || 'MICROTEC').toUpperCase()}<span className="text-white">FLIX</span> {(settings?.brandName || 'Plataforma')}<span className="text-white">FLIX</span>
</h1> </h1>
<p className="text-xs text-zinc-400 font-medium"> <p className="text-xs text-zinc-400 font-medium">
{settings?.brandSlogan || 'Acelere seus estudos em informática e pacote Office no estilo Netflix.'} {settings?.brandSlogan || 'Acelere seus estudos em informática e pacote Office no estilo Netflix.'}

View File

@ -9,6 +9,7 @@ import { SubscriptionStatus } from '../types';
import { AdminPricingManager } from './AdminPricingManager'; import { AdminPricingManager } from './AdminPricingManager';
import { WhatsappConnectCard } from './WhatsappConnectCard'; import { WhatsappConnectCard } from './WhatsappConnectCard';
import AdminContentManager from './AdminContentManager'; import AdminContentManager from './AdminContentManager';
import AdminMessagesView from './AdminMessagesView';
interface WebhookLog { interface WebhookLog {
id: string; id: string;
@ -75,7 +76,7 @@ export default function AdminDashboard({ token, adminUser, onLogout }: AdminDash
const [expandedStudentId, setExpandedStudentId] = useState<string | null>(null); const [expandedStudentId, setExpandedStudentId] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [activeTab, setActiveTab] = useState<'dashboard' | 'students' | 'courses' | 'plans' | 'payments' | 'asaas_settings' | 'whatsapp_settings' | 'webhooks_logs' | 'saas_settings' | 'smtp_settings'>('dashboard'); const [activeTab, setActiveTab] = useState<'dashboard' | 'students' | 'courses' | 'plans' | 'payments' | 'messages' | 'asaas_settings' | 'whatsapp_settings' | 'webhooks_logs' | 'saas_settings' | 'smtp_settings'>('dashboard');
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [actionLoading, setActionLoading] = useState<string | null>(null); const [actionLoading, setActionLoading] = useState<string | null>(null);
@ -357,7 +358,7 @@ export default function AdminDashboard({ token, adminUser, onLogout }: AdminDash
<Shield className="w-4 h-4" /> <Shield className="w-4 h-4" />
</div> </div>
<span className="font-display font-black text-base tracking-tighter text-[#E50914]"> <span className="font-display font-black text-base tracking-tighter text-[#E50914]">
{(settings?.brandName || 'MICROTEC').toUpperCase()}<span className="text-white">FLIX</span> {(settings?.brandName || 'Plataforma')}<span className="text-white">FLIX</span>
</span> </span>
</div> </div>
<button <button
@ -379,7 +380,7 @@ export default function AdminDashboard({ token, adminUser, onLogout }: AdminDash
</div> </div>
<div className="flex flex-col"> <div className="flex flex-col">
<span className="font-display font-black text-lg md:text-xl leading-none tracking-tighter text-[#E50914]"> <span className="font-display font-black text-lg md:text-xl leading-none tracking-tighter text-[#E50914]">
{(settings?.brandName || 'MICROTEC').toUpperCase()}<span className="text-white">FLIX</span> {(settings?.brandName || 'Plataforma')}<span className="text-white">FLIX</span>
</span> </span>
<span className="text-[9px] text-[#E50914] font-extrabold uppercase tracking-widest mt-0.5 leading-none"> <span className="text-[9px] text-[#E50914] font-extrabold uppercase tracking-widest mt-0.5 leading-none">
Admin Portal Admin Portal
@ -433,6 +434,13 @@ export default function AdminDashboard({ token, adminUser, onLogout }: AdminDash
<DollarSign size={16} /> <DollarSign size={16} />
<span>Pagamentos</span> <span>Pagamentos</span>
</button> </button>
<button
onClick={() => { setActiveTab('messages'); setIsMobileMenuOpen(false); }}
className={`w-full flex items-center space-x-2.5 px-3 py-2 rounded-lg text-xs font-bold transition-all text-left ${activeTab === 'messages' ? 'bg-[#E50914]/10 text-white border-l-2 border-[#E50914]' : 'text-zinc-400 hover:text-white hover:bg-white/5'}`}
>
<Mail size={16} />
<span>Mensagens</span>
</button>
</div> </div>
{/* SISTEMA */} {/* SISTEMA */}
@ -931,6 +939,11 @@ export default function AdminDashboard({ token, adminUser, onLogout }: AdminDash
</div> </div>
)} )}
{/* TAB 5.5: MENSAGENS */}
{activeTab === 'messages' && (
<AdminMessagesView />
)}
{/* TAB 6: ASAAS INTEGRATION CONFIG */} {/* TAB 6: ASAAS INTEGRATION CONFIG */}
{activeTab === 'asaas_settings' && ( {activeTab === 'asaas_settings' && (
<div className="glass-card rounded-xl border border-white/10 p-6 shadow-2xl space-y-6"> <div className="glass-card rounded-xl border border-white/10 p-6 shadow-2xl space-y-6">

View File

@ -0,0 +1,291 @@
import React, { useState, useEffect } from 'react';
import { Mail, MessageSquare, BookOpen, Send, User, Reply } from 'lucide-react';
interface SupportTicket {
id: string;
content: string;
status: string;
createdAt: string;
user: {
id: string;
name: string;
email: string;
avatarUrl: string | null;
whatsapp: string | null;
};
}
interface Note {
id: string;
content: string;
createdAt: string;
user: {
id: string;
name: string;
email: string;
avatarUrl: string | null;
whatsapp: string | null;
};
lesson: {
title: string;
module: {
course: {
title: string;
}
}
};
}
export default function AdminMessagesView() {
const [activeTab, setActiveTab] = useState<'tickets' | 'notes'>('tickets');
const [tickets, setTickets] = useState<SupportTicket[]>([]);
const [notes, setNotes] = useState<Note[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [replyText, setReplyText] = useState('');
const [replyingTo, setReplyingTo] = useState<string | null>(null);
const [isSending, setIsSending] = useState(false);
useEffect(() => {
fetchMessages();
}, []);
const fetchMessages = async () => {
setIsLoading(true);
try {
const token = localStorage.getItem('admin_token');
const res = await fetch('/api/admin/messages', {
headers: { 'Authorization': `Bearer ${token}` }
});
if (res.ok) {
const data = await res.json();
setTickets(data.tickets);
setNotes(data.notes);
}
} catch (error) {
console.error('Error fetching messages:', error);
} finally {
setIsLoading(false);
}
};
const handleReply = async (userId: string, ticketId?: string) => {
if (!replyText.trim()) return;
setIsSending(true);
try {
const token = localStorage.getItem('admin_token');
const res = await fetch('/api/admin/reply-message', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ userId, message: replyText, ticketId })
});
if (res.ok) {
setReplyText('');
setReplyingTo(null);
fetchMessages();
alert('Resposta enviada com sucesso!');
} else {
alert('Erro ao enviar resposta.');
}
} catch (error) {
console.error('Error replying:', error);
alert('Erro interno ao enviar resposta.');
} finally {
setIsSending(false);
}
};
return (
<div className="space-y-6">
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
<div>
<h2 className="text-2xl font-bold text-white flex items-center space-x-2">
<Mail className="w-6 h-6 text-[#E50914]" />
<span>Mensagens e Comentários</span>
</h2>
<p className="text-zinc-400 text-sm mt-1">Gerencie dúvidas, anotações de aulas e chamados de suporte dos alunos.</p>
</div>
</div>
<div className="flex space-x-4 border-b border-white/10">
<button
onClick={() => setActiveTab('tickets')}
className={`pb-3 px-1 text-sm font-bold flex items-center space-x-2 border-b-2 transition-colors ${activeTab === 'tickets' ? 'border-[#E50914] text-white' : 'border-transparent text-zinc-500 hover:text-zinc-300'}`}
>
<MessageSquare className="w-4 h-4" />
<span>Comentários / Ajuda ({tickets.length})</span>
</button>
<button
onClick={() => setActiveTab('notes')}
className={`pb-3 px-1 text-sm font-bold flex items-center space-x-2 border-b-2 transition-colors ${activeTab === 'notes' ? 'border-[#E50914] text-white' : 'border-transparent text-zinc-500 hover:text-zinc-300'}`}
>
<BookOpen className="w-4 h-4" />
<span>Anotações de Aulas ({notes.length})</span>
</button>
</div>
{isLoading ? (
<div className="flex justify-center items-center py-20">
<div className="w-8 h-8 border-4 border-[#E50914] border-t-transparent rounded-full animate-spin"></div>
</div>
) : (
<div className="space-y-4">
{activeTab === 'tickets' && (
tickets.length === 0 ? (
<div className="text-center py-12 text-zinc-500">Nenhum comentário recebido.</div>
) : (
tickets.map(ticket => (
<div key={ticket.id} className="bg-zinc-900 border border-white/10 rounded-xl p-5 space-y-4 relative">
{ticket.status === 'REPLIED' && (
<div className="absolute top-4 right-4 text-[10px] uppercase font-bold text-emerald-500 bg-emerald-500/10 px-2 py-1 rounded">
Respondido
</div>
)}
<div className="flex items-start space-x-4">
<div className="w-10 h-10 rounded-full bg-zinc-800 flex items-center justify-center flex-shrink-0 overflow-hidden">
{ticket.user.avatarUrl ? (
<img src={ticket.user.avatarUrl} alt={ticket.user.name} className="w-full h-full object-cover" />
) : (
<User className="w-5 h-5 text-zinc-400" />
)}
</div>
<div>
<h4 className="font-bold text-white text-sm">{ticket.user.name}</h4>
<p className="text-xs text-zinc-500">{ticket.user.email} {ticket.user.whatsapp && `• WA: ${ticket.user.whatsapp}`}</p>
<p className="text-[10px] text-zinc-600 mt-0.5">{new Date(ticket.createdAt).toLocaleString('pt-BR')}</p>
</div>
</div>
<div className="bg-black/20 p-4 rounded-lg text-zinc-300 text-sm whitespace-pre-wrap">
{ticket.content}
</div>
{replyingTo === ticket.id ? (
<div className="mt-4 space-y-3">
<textarea
value={replyText}
onChange={(e) => setReplyText(e.target.value)}
placeholder="Escreva sua resposta (será enviada via Notificação e WhatsApp se ativado)..."
className="w-full h-24 bg-black/40 border border-white/10 rounded-lg p-3 text-white text-sm focus:outline-none focus:border-[#E50914] resize-none"
/>
<div className="flex justify-end space-x-2">
<button
onClick={() => { setReplyingTo(null); setReplyText(''); }}
className="px-3 py-1.5 text-xs text-zinc-400 hover:text-white transition-colors"
>
Cancelar
</button>
<button
onClick={() => handleReply(ticket.user.id, ticket.id)}
disabled={!replyText.trim() || isSending}
className="bg-[#E50914] hover:bg-red-700 text-white px-4 py-1.5 rounded text-xs font-bold transition-colors disabled:opacity-50 flex items-center space-x-2"
>
{isSending ? (
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
) : (
<>
<Send className="w-3 h-3" />
<span>Enviar Resposta</span>
</>
)}
</button>
</div>
</div>
) : (
<button
onClick={() => { setReplyingTo(ticket.id); setReplyText(''); }}
className="text-xs text-[#E50914] hover:text-red-400 transition-colors font-bold flex items-center space-x-1"
>
<Reply className="w-3 h-3" />
<span>Responder</span>
</button>
)}
</div>
))
)
)}
{activeTab === 'notes' && (
notes.length === 0 ? (
<div className="text-center py-12 text-zinc-500">Nenhuma anotação registrada.</div>
) : (
notes.map(note => (
<div key={note.id} className="bg-zinc-900 border border-white/10 rounded-xl p-5 space-y-4">
<div className="flex items-start justify-between">
<div className="flex items-start space-x-4">
<div className="w-10 h-10 rounded-full bg-zinc-800 flex items-center justify-center flex-shrink-0 overflow-hidden">
{note.user.avatarUrl ? (
<img src={note.user.avatarUrl} alt={note.user.name} className="w-full h-full object-cover" />
) : (
<User className="w-5 h-5 text-zinc-400" />
)}
</div>
<div>
<h4 className="font-bold text-white text-sm">{note.user.name}</h4>
<p className="text-xs text-zinc-500">{note.user.email}</p>
<p className="text-[10px] text-zinc-600 mt-0.5">{new Date(note.createdAt).toLocaleString('pt-BR')}</p>
</div>
</div>
<div className="text-right max-w-[200px]">
<span className="text-[10px] uppercase font-bold text-blue-500 bg-blue-500/10 px-2 py-1 rounded block truncate">
{note.lesson.module.course.title}
</span>
<span className="text-xs text-zinc-400 block mt-1 truncate">{note.lesson.title}</span>
</div>
</div>
<div className="bg-black/20 p-4 rounded-lg text-zinc-300 text-sm whitespace-pre-wrap">
{note.content}
</div>
{replyingTo === note.id ? (
<div className="mt-4 space-y-3">
<textarea
value={replyText}
onChange={(e) => setReplyText(e.target.value)}
placeholder="Escreva sua resposta para esta anotação..."
className="w-full h-24 bg-black/40 border border-white/10 rounded-lg p-3 text-white text-sm focus:outline-none focus:border-[#E50914] resize-none"
/>
<div className="flex justify-end space-x-2">
<button
onClick={() => { setReplyingTo(null); setReplyText(''); }}
className="px-3 py-1.5 text-xs text-zinc-400 hover:text-white transition-colors"
>
Cancelar
</button>
<button
onClick={() => handleReply(note.user.id)}
disabled={!replyText.trim() || isSending}
className="bg-[#E50914] hover:bg-red-700 text-white px-4 py-1.5 rounded text-xs font-bold transition-colors disabled:opacity-50 flex items-center space-x-2"
>
{isSending ? (
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
) : (
<>
<Send className="w-3 h-3" />
<span>Enviar Resposta</span>
</>
)}
</button>
</div>
</div>
) : (
<button
onClick={() => { setReplyingTo(note.id); setReplyText(''); }}
className="text-xs text-[#E50914] hover:text-red-400 transition-colors font-bold flex items-center space-x-1"
>
<Reply className="w-3 h-3" />
<span>Responder</span>
</button>
)}
</div>
))
)
)}
</div>
)}
</div>
);
}

View File

@ -88,7 +88,7 @@ export const AdminPricingManager: React.FC<AdminPricingManagerProps> = ({ course
const [showPlanModal, setShowPlanModal] = useState(false); const [showPlanModal, setShowPlanModal] = useState(false);
const [planForm, setPlanForm] = useState<Partial<Plan>>({ cycle: 'LIFETIME', active: true, includedCourses: [] }); const [planForm, setPlanForm] = useState<Partial<Plan>>({ cycle: 'LIFETIME', active: true, includedCourses: [] });
const token = localStorage.getItem('devflix_admin_token'); const token = localStorage.getItem('admin_token');
const fetchPlans = async () => { const fetchPlans = async () => {
setLoading(true); setLoading(true);

View File

@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { ArrowLeft, HelpCircle } from 'lucide-react'; import { ArrowLeft, HelpCircle, MessageSquare, X, Send } from 'lucide-react';
interface HelpViewProps { interface HelpViewProps {
token: string | null; token: string | null;
@ -9,6 +9,10 @@ interface HelpViewProps {
export default function HelpView({ token, onBack }: HelpViewProps) { export default function HelpView({ token, onBack }: HelpViewProps) {
const [helpText, setHelpText] = useState<string>(''); const [helpText, setHelpText] = useState<string>('');
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [showModal, setShowModal] = useState(false);
const [ticketContent, setTicketContent] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [submitSuccess, setSubmitSuccess] = useState(false);
useEffect(() => { useEffect(() => {
fetchHelpText(); fetchHelpText();
@ -31,6 +35,33 @@ export default function HelpView({ token, onBack }: HelpViewProps) {
} }
}; };
const handleSubmitTicket = async () => {
if (!ticketContent.trim()) return;
setIsSubmitting(true);
try {
const res = await fetch('/api/support-tickets', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ content: ticketContent })
});
if (res.ok) {
setSubmitSuccess(true);
setTicketContent('');
setTimeout(() => {
setShowModal(false);
setSubmitSuccess(false);
}, 3000);
}
} catch (err) {
console.error('Error creating support ticket:', err);
} finally {
setIsSubmitting(false);
}
};
return ( return (
<div className="space-y-6 max-w-4xl mx-auto"> <div className="space-y-6 max-w-4xl mx-auto">
<button <button
@ -42,9 +73,18 @@ export default function HelpView({ token, onBack }: HelpViewProps) {
</button> </button>
<div className="glass-card rounded-2xl overflow-hidden shadow-2xl border border-white/10 p-8 min-h-[50vh]"> <div className="glass-card rounded-2xl overflow-hidden shadow-2xl border border-white/10 p-8 min-h-[50vh]">
<div className="flex items-center space-x-3 mb-8 border-b border-white/10 pb-4"> <div className="flex items-center justify-between mb-8 border-b border-white/10 pb-4">
<HelpCircle className="w-8 h-8 text-[#E50914]" /> <div className="flex items-center space-x-3">
<h2 className="text-2xl font-bold text-white">Central de Ajuda</h2> <HelpCircle className="w-8 h-8 text-[#E50914]" />
<h2 className="text-2xl font-bold text-white">Central de Ajuda</h2>
</div>
<button
onClick={() => setShowModal(true)}
className="flex items-center space-x-2 bg-[#E50914] text-white px-4 py-2 rounded-lg font-bold hover:bg-red-700 transition-colors text-sm"
>
<MessageSquare className="w-4 h-4" />
<span>Falar com o Suporte</span>
</button>
</div> </div>
{isLoading ? ( {isLoading ? (
@ -57,6 +97,52 @@ export default function HelpView({ token, onBack }: HelpViewProps) {
</div> </div>
)} )}
</div> </div>
{showModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm">
<div className="bg-zinc-900 border border-white/10 p-6 rounded-2xl w-full max-w-md">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-bold text-white">Enviar Comentário ou Dúvida</h3>
<button
onClick={() => setShowModal(false)}
className="text-zinc-400 hover:text-white transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{submitSuccess ? (
<div className="bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 p-4 rounded-xl text-center">
<p className="font-bold">Mensagem enviada com sucesso!</p>
<p className="text-sm mt-1">Nossa equipe responderá em breve.</p>
</div>
) : (
<div className="space-y-4">
<textarea
value={ticketContent}
onChange={(e) => setTicketContent(e.target.value)}
placeholder="Escreva aqui sua dúvida, comentário ou solicitação de ajuda..."
className="w-full h-32 bg-zinc-800 border border-white/10 rounded-xl p-4 text-white text-sm focus:outline-none focus:border-[#E50914] resize-none"
/>
<button
onClick={handleSubmitTicket}
disabled={!ticketContent.trim() || isSubmitting}
className="w-full flex justify-center items-center space-x-2 bg-[#E50914] hover:bg-red-700 text-white font-bold py-3 px-4 rounded-xl transition-colors disabled:opacity-50"
>
{isSubmitting ? (
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
) : (
<>
<span>Enviar Mensagem</span>
<Send className="w-4 h-4 ml-2" />
</>
)}
</button>
</div>
)}
</div>
</div>
)}
</div> </div>
); );
} }

View File

@ -103,16 +103,23 @@ export default function Navbar({ user, activeView, setView, onLogout, settings }
<nav className="sticky top-0 z-50 glass-navbar px-4 py-3 md:px-8 flex items-center justify-between"> <nav className="sticky top-0 z-50 glass-navbar px-4 py-3 md:px-8 flex items-center justify-between">
<div className="flex items-center space-x-8"> <div className="flex items-center space-x-8">
{/* LOGO */} {/* LOGO */}
<div <div className="flex flex-col">
onClick={() => setView('home')} <div
className="flex items-center space-x-2 cursor-pointer select-none group" onClick={() => setView('home')}
> className="flex items-center space-x-2 cursor-pointer select-none group"
<div className="bg-[#E50914] text-white p-1.5 rounded flex items-center justify-center group-hover:scale-105 transition-transform"> >
<Play className="fill-white w-5 h-5" /> <div className="bg-[#E50914] text-white p-1.5 rounded flex items-center justify-center group-hover:scale-105 transition-transform">
<Play className="fill-white w-5 h-5" />
</div>
<span className="font-sans font-black text-xl md:text-2xl tracking-tighter text-[#E50914]">
{(settings?.brandName || 'Plataforma')}<span className="text-white">FLIX</span>
</span>
</div> </div>
<span className="font-sans font-black text-xl md:text-2xl tracking-tighter text-[#E50914]"> {settings?.brandSlogan && (
{(settings?.brandName || 'MICROTEC').toUpperCase()}<span className="text-white">FLIX</span> <span className="text-[10px] text-zinc-400 font-medium ml-10 mt-0.5 max-w-[200px] truncate hidden md:block">
</span> {settings.brandSlogan}
</span>
)}
</div> </div>
{/* NAVIGATION LINKS */} {/* NAVIGATION LINKS */}
@ -226,7 +233,7 @@ export default function Navbar({ user, activeView, setView, onLogout, settings }
className="w-full px-4 py-2 text-left flex items-center space-x-2 text-sm text-zinc-300 hover:bg-zinc-800 hover:text-white transition-colors" className="w-full px-4 py-2 text-left flex items-center space-x-2 text-sm text-zinc-300 hover:bg-zinc-800 hover:text-white transition-colors"
> >
<GraduationCap className="w-4 h-4" /> <GraduationCap className="w-4 h-4" />
<span>Meu Certificado</span> <span>Meus Certificados</span>
</button> </button>
<button <button
onClick={() => { setView('help'); setShowProfileMenu(false); }} onClick={() => { setView('help'); setShowProfileMenu(false); }}

View File

@ -42,7 +42,7 @@ export default function ProfileView({ user, onBack, onUpdateUser }: ProfileViewP
const handleSave = async () => { const handleSave = async () => {
setIsSaving(true); setIsSaving(true);
try { try {
const token = localStorage.getItem('devflix_token'); const token = localStorage.getItem('app_token');
const res = await fetch('/api/users/profile', { const res = await fetch('/api/users/profile', {
method: 'PUT', method: 'PUT',
headers: { headers: {
@ -74,7 +74,7 @@ export default function ProfileView({ user, onBack, onUpdateUser }: ProfileViewP
formData.append('avatar', file); formData.append('avatar', file);
try { try {
const token = localStorage.getItem('devflix_token'); const token = localStorage.getItem('app_token');
const res = await fetch('/api/users/avatar', { const res = await fetch('/api/users/avatar', {
method: 'POST', method: 'POST',
headers: { headers: {

View File

@ -268,7 +268,7 @@ export default function SubscriptionView({ user, token, onStatusUpdate }: Subscr
<div className="space-y-1"> <div className="space-y-1">
<h3 className="font-display font-extrabold text-lg text-white tracking-tight">Assine o Plano Ilimitado</h3> <h3 className="font-display font-extrabold text-lg text-white tracking-tight">Assine o Plano Ilimitado</h3>
<p className="text-xs text-zinc-500 font-semibold flex items-center space-x-1"> <p className="text-xs text-zinc-500 font-semibold flex items-center space-x-1">
<span>Trilha completa DevFlix por apenas</span> <span>Trilha completa da Plataforma por apenas</span>
<span className="text-[#E50914] font-black text-sm">R$ 49,90/mês</span> <span className="text-[#E50914] font-black text-sm">R$ 49,90/mês</span>
</p> </p>
</div> </div>

View File

@ -154,7 +154,7 @@ export class AsaasService {
billingType: 'PIX' | 'CREDIT_CARD' | 'BOLETO', billingType: 'PIX' | 'CREDIT_CARD' | 'BOLETO',
creditCardInfo?: any, creditCardInfo?: any,
cycle: 'MONTHLY' | 'YEARLY' = 'MONTHLY', cycle: 'MONTHLY' | 'YEARLY' = 'MONTHLY',
description: string = 'Assinatura Mensal DevFlix' description: string = 'Assinatura Mensal da Plataforma'
): Promise<{ subscriptionId: string; payment: Partial<SubscriptionPayment> }> { ): Promise<{ subscriptionId: string; payment: Partial<SubscriptionPayment> }> {
const apiKey = await this.getApiKey(); const apiKey = await this.getApiKey();
if (!apiKey) { if (!apiKey) {