Implementado envio automatico de WhatsApp por templates, broadcast com anti-spam e emoji picker
Build and Deploy (Gitea) / build-and-deploy (push) Successful in 59s Details

This commit is contained in:
Sidney Gomes 2026-07-25 21:07:56 -03:00
parent 4f4fceab29
commit 4cc23a9e5b
2 changed files with 390 additions and 107 deletions

View File

@ -189,6 +189,16 @@ app.post('/api/auth/register', async (req: Request, res: Response) => {
{ expiresIn: '7d' } { expiresIn: '7d' }
); );
// Send welcome WhatsApp message if template configured
if (whatsapp) {
const welcomeSettings = await prisma.systemSettings.findFirst();
if (welcomeSettings?.whatsappTemplateWelcome) {
const firstName = name.split(' ')[0];
const msg = processTemplate(welcomeSettings.whatsappTemplateWelcome, { nome: firstName });
sendWhatsappNotification(whatsapp, msg).catch(console.error);
}
}
res.status(201).json({ res.status(201).json({
token, token,
user: newUser user: newUser
@ -1558,6 +1568,11 @@ app.post('/api/admin/whatsapp/logout', authenticateToken, requireAdmin, async (r
} }
}); });
export function processTemplate(template: string | null | undefined, vars: Record<string, string>): string {
if (!template) return '';
return template.replace(/\{(\w+)\}/g, (_, key) => vars[key] || `{${key}}`);
}
export async function sendWhatsappNotification(phone: string, text: string) { export async function sendWhatsappNotification(phone: string, text: string) {
try { try {
const settings = await prisma.systemSettings.findFirst(); const settings = await prisma.systemSettings.findFirst();
@ -1569,8 +1584,8 @@ export async function sendWhatsappNotification(phone: string, text: string) {
let cleanPhone = phone.replace(/\D/g, ''); let cleanPhone = phone.replace(/\D/g, '');
if (!cleanPhone) return false; if (!cleanPhone) return false;
// Add DDI 55 for Brazil if not present // Add DDI 55 for Brazil if not present (10 or 11 digits = local number)
if (cleanPhone.length === 10 || cleanPhone.length === 11) { if (cleanPhone.length <= 11) {
cleanPhone = '55' + cleanPhone; cleanPhone = '55' + cleanPhone;
} }
@ -1986,12 +2001,44 @@ app.post('/api/webhooks/asaas', async (req: Request, res: Response) => {
data: { unlockedCourses: newUnlocked } data: { unlockedCourses: newUnlocked }
}); });
console.log(`Webhook: Curso ${courseIdToUnlock} LIBERADO para usuário ${user.email}`); console.log(`Webhook: Curso ${courseIdToUnlock} LIBERADO para usuário ${user.email}`);
// Notify via WhatsApp - Payment confirmed (course)
const pSettings = await prisma.systemSettings.findFirst();
if (pSettings?.whatsappTemplatePayment && user.whatsapp) {
const firstName = user.name.split(' ')[0];
const course = await prisma.course.findUnique({ where: { id: courseIdToUnlock }, select: { title: true } });
const msg = processTemplate(pSettings.whatsappTemplatePayment, {
nome: firstName,
curso: course?.title || 'Curso',
plano: '',
valor: payment.value ? `R$ ${Number(payment.value).toFixed(2).replace('.', ',')}` : ''
});
sendWhatsappNotification(user.whatsapp, msg).catch(console.error);
}
} else { } else {
await prisma.user.update({ await prisma.user.update({
where: { id: user.id }, where: { id: user.id },
data: { subscriptionStatus: 'ACTIVE' } data: { subscriptionStatus: 'ACTIVE' }
}); });
console.log(`Webhook: Assinatura LIBERADA para usuário ${user.email}`); console.log(`Webhook: Assinatura LIBERADA para usuário ${user.email}`);
// Notify via WhatsApp - Payment confirmed (plan)
const pSettings = await prisma.systemSettings.findFirst();
if (pSettings?.whatsappTemplatePayment && user.whatsapp) {
const firstName = user.name.split(' ')[0];
let planTitle = 'Assinatura';
if (existingPayment?.planId) {
const plan = await prisma.plan.findUnique({ where: { id: existingPayment.planId }, select: { name: true } });
planTitle = plan?.name || 'Assinatura';
}
const msg = processTemplate(pSettings.whatsappTemplatePayment, {
nome: firstName,
curso: '',
plano: planTitle,
valor: payment.value ? `R$ ${Number(payment.value).toFixed(2).replace('.', ',')}` : ''
});
sendWhatsappNotification(user.whatsapp, msg).catch(console.error);
}
} }
} else if (isOverdue || isRefunded || event === 'SUBSCRIPTION_DELETED') { } else if (isOverdue || isRefunded || event === 'SUBSCRIPTION_DELETED') {
if (!isCoursePayment) { if (!isCoursePayment) {
@ -2515,6 +2562,51 @@ app.post('/api/admin/reply-message', authenticateToken, requireAdmin, async (req
} }
}); });
// Admin: Broadcast WhatsApp message to all students with anti-spam delay
app.post('/api/admin/whatsapp/broadcast', authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res: Response) => {
try {
const { message, delaySeconds } = req.body;
if (!message) return res.status(400).json({ error: 'message é obrigatório' });
const settings = await prisma.systemSettings.findFirst();
if (!settings?.whatsappConnected || !settings.evolutionApiUrl || !settings.evolutionApiKey) {
return res.status(400).json({ error: 'WhatsApp não está configurado ou conectado.' });
}
const students = await prisma.user.findMany({
where: { role: 'student', NOT: { whatsapp: null } },
select: { id: true, name: true, whatsapp: true }
});
const delayMs = Math.max(5, Math.min(Number(delaySeconds) || 10, 120)) * 1000;
res.json({ success: true, total: students.length, message: `Envio iniciado para ${students.length} alunos com intervalo de ${delayMs / 1000}s.` });
// Fire and forget
(async () => {
let sent = 0;
let failed = 0;
for (const student of students) {
if (!student.whatsapp) continue;
try {
const firstName = student.name.split(' ')[0];
const msg = processTemplate(message, { nome: firstName });
await sendWhatsappNotification(student.whatsapp, msg);
sent++;
} catch (e) {
failed++;
console.error(`Broadcast: failed to send to ${student.whatsapp}`, e);
}
await new Promise(r => setTimeout(r, delayMs));
}
console.log(`[Broadcast WhatsApp] Concluído: ${sent} enviados, ${failed} falhos de ${students.length} alunos.`);
})();
} catch (error) {
console.error('Error broadcasting:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
if (process.env.NODE_ENV !== 'production') { if (process.env.NODE_ENV !== 'production') {
const { createServer: createViteServer } = await import('vite'); const { createServer: createViteServer } = await import('vite');
const vite = await createViteServer({ const vite = await createViteServer({

View File

@ -1,7 +1,6 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import { Mail, MessageSquare, BookOpen, Send, User, Reply, CheckCircle2, AlertCircle } from 'lucide-react'; import { Mail, MessageSquare, BookOpen, Send, User, Reply, CheckCircle2, AlertCircle, Smartphone, Save, Smile, Megaphone, Clock } from 'lucide-react';
import CustomModal from './CustomModal'; import CustomModal from './CustomModal';
import { Smartphone, Save } from 'lucide-react';
interface SupportTicket { interface SupportTicket {
id: string; id: string;
@ -38,6 +37,61 @@ interface Note {
}; };
} }
// Simple emoji picker groups
const EMOJI_LIST = [
'😊','😁','🎉','🙏','💪','🚀','❤️','✅','⭐','🔥','💡','📚','🎓','👏','🤝',
'💰','💳','🛒','📱','📧','🌟','🏆','✨','🎯','📢','👋','😀','🥳','💎','🌈'
];
// Default template texts
const DEFAULT_TEMPLATES: Record<string, string> = {
whatsappTemplatePayment: `Olá, *{nome}*! 🎉
Recebemos a confirmação do seu pagamento com sucesso!
💰 *Valor:* {valor}
📦 *Plano/Curso:* {plano}{curso}
Seu acesso está liberado na plataforma. Qualquer dúvida, estamos à disposição.
Muito obrigado pela sua confiança! 🙏
_Equipe MicrotecFlix_`,
whatsappTemplateWelcome: `Olá, *{nome}*! 👋
Seja muito bem-vindo(a) à *MicrotecFlix*! 🎓🚀
Estamos felizes em ter você conosco. Agora você tem acesso à nossa plataforma de cursos de informática e pacote Office.
Acesse: https://estudo.microtecinformaticacurso.com.br
Se tiver qualquer dúvida, é chamar. 😊
_Equipe MicrotecFlix_`,
whatsappTemplateNewContent: `Oi, *{nome}*! 🎬
Temos uma novidade imperdível para você na plataforma!
*Novo conteúdo disponível:* Acabamos de publicar um novo curso/módulo/aula.
Acesse agora mesmo e aproveite: https://estudo.microtecinformaticacurso.com.br
Bons estudos! 💪
_Equipe MicrotecFlix_`,
whatsappTemplatePromo: `Oi, *{nome}*! 🌟
Temos uma oferta especial para você hoje!
🎯 Aproveite nossa promoção exclusiva e acelere seus estudos de informática e pacote Office.
Não perca essa oportunidade única! Acesse agora:
https://estudo.microtecinformaticacurso.com.br
Qualquer dúvida, estamos aqui. 🙏
_Equipe MicrotecFlix_`
};
export default function AdminMessagesView() { export default function AdminMessagesView() {
const [activeTab, setActiveTab] = useState<'tickets' | 'notes' | 'whatsapp_templates'>('tickets'); const [activeTab, setActiveTab] = useState<'tickets' | 'notes' | 'whatsapp_templates'>('tickets');
const [tickets, setTickets] = useState<SupportTicket[]>([]); const [tickets, setTickets] = useState<SupportTicket[]>([]);
@ -53,29 +107,40 @@ export default function AdminMessagesView() {
const [activeTemplateModal, setActiveTemplateModal] = useState<string | null>(null); const [activeTemplateModal, setActiveTemplateModal] = useState<string | null>(null);
const [currentTemplateText, setCurrentTemplateText] = useState(''); const [currentTemplateText, setCurrentTemplateText] = useState('');
const [isSavingTemplate, setIsSavingTemplate] = useState(false); const [isSavingTemplate, setIsSavingTemplate] = useState(false);
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Broadcast state
const [broadcastText, setBroadcastText] = useState('');
const [broadcastDelay, setBroadcastDelay] = useState(10);
const [isBroadcasting, setIsBroadcasting] = useState(false);
const [showBroadcastEmojiPicker, setShowBroadcastEmojiPicker] = useState(false);
const broadcastRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => { useEffect(() => {
fetchMessages(); fetchData();
}, []); }, []);
const fetchMessages = async () => { const fetchData = async () => {
setIsLoading(true); setIsLoading(true);
try { try {
const token = localStorage.getItem('admin_token'); const token = localStorage.getItem('admin_token');
const res = await fetch('/api/admin/messages', { const [msgRes, settingsRes] = await Promise.all([
headers: { 'Authorization': `Bearer ${token}` } fetch('/api/admin/messages', { headers: { 'Authorization': `Bearer ${token}` } }),
}); fetch('/api/admin/settings', { headers: { 'Authorization': `Bearer ${token}` } })
if (res.ok) { ]);
const data = await res.json(); if (msgRes.ok) {
const data = await msgRes.json();
setTickets(data.tickets); setTickets(data.tickets);
setNotes(data.notes); setNotes(data.notes);
} }
const resSettings = await fetch('/api/admin/settings', { if (settingsRes.ok) {
headers: { 'Authorization': `Bearer ${token}` } const data = await settingsRes.json();
});
if (resSettings.ok) {
const data = await resSettings.json();
setSettings(data); setSettings(data);
// Pre-fill broadcast from template
if (data.whatsappTemplatePromo) {
setBroadcastText(data.whatsappTemplatePromo);
}
} }
} catch (error) { } catch (error) {
console.error('Error fetching data:', error); console.error('Error fetching data:', error);
@ -100,7 +165,7 @@ export default function AdminMessagesView() {
if (res.ok) { if (res.ok) {
setReplyText(''); setReplyText('');
setReplyingTo(null); setReplyingTo(null);
fetchMessages(); fetchData();
setAlertModal({isOpen: true, title: 'Sucesso', message: 'Resposta enviada com sucesso!'}); setAlertModal({isOpen: true, title: 'Sucesso', message: 'Resposta enviada com sucesso!'});
} else { } else {
setAlertModal({isOpen: true, title: 'Erro', message: 'Erro ao enviar resposta.', isError: true}); setAlertModal({isOpen: true, title: 'Erro', message: 'Erro ao enviar resposta.', isError: true});
@ -125,7 +190,7 @@ export default function AdminMessagesView() {
if (res.ok) { if (res.ok) {
setSettings({...settings, [field]: currentTemplateText}); setSettings({...settings, [field]: currentTemplateText});
setActiveTemplateModal(null); setActiveTemplateModal(null);
setAlertModal({isOpen: true, title: 'Sucesso', message: 'Template salvo com sucesso!'}); setAlertModal({isOpen: true, title: 'Sucesso', message: 'Template salvo com sucesso! O envio automático já está ativado.'});
} else { } else {
setAlertModal({isOpen: true, title: 'Erro', message: 'Erro ao salvar template.', isError: true}); setAlertModal({isOpen: true, title: 'Erro', message: 'Erro ao salvar template.', isError: true});
} }
@ -137,11 +202,56 @@ export default function AdminMessagesView() {
} }
}; };
const insertEmoji = (emoji: string, isForBroadcast = false) => {
const ref = isForBroadcast ? broadcastRef : textareaRef;
const setter = isForBroadcast ? setBroadcastText : setCurrentTemplateText;
const current = isForBroadcast ? broadcastText : currentTemplateText;
if (ref.current) {
const start = ref.current.selectionStart;
const end = ref.current.selectionEnd;
const newText = current.substring(0, start) + emoji + current.substring(end);
setter(newText);
setTimeout(() => {
if (ref.current) {
ref.current.focus();
ref.current.setSelectionRange(start + emoji.length, start + emoji.length);
}
}, 0);
} else {
setter(current + emoji);
}
if (isForBroadcast) setShowBroadcastEmojiPicker(false);
else setShowEmojiPicker(false);
};
const handleBroadcast = async () => {
if (!broadcastText.trim()) return;
setIsBroadcasting(true);
try {
const token = localStorage.getItem('admin_token');
const res = await fetch('/api/admin/whatsapp/broadcast', {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ message: broadcastText, delaySeconds: broadcastDelay })
});
const data = await res.json();
if (res.ok) {
setAlertModal({isOpen: true, title: 'Envio Iniciado! 🚀', message: data.message || 'Broadcast iniciado com sucesso!'});
} else {
setAlertModal({isOpen: true, title: 'Erro', message: data.error || 'Erro ao iniciar broadcast.', isError: true});
}
} catch (error) {
console.error('Error broadcasting:', error);
setAlertModal({isOpen: true, title: 'Erro', message: 'Erro interno ao enviar.', isError: true});
} finally {
setIsBroadcasting(false);
}
};
const whatsappTemplates = [ const whatsappTemplates = [
{ id: 'whatsappTemplatePayment', title: 'Pagamento Confirmado', desc: 'Enviada quando o aluno paga um plano ou curso.', icon: '💰' }, { id: 'whatsappTemplatePayment', title: 'Pagamento Confirmado', desc: 'Enviada automaticamente quando o aluno paga um plano ou curso.', icon: '💰', tags: ['{nome}', '{valor}', '{plano}', '{curso}'] },
{ id: 'whatsappTemplateWelcome', title: 'Boas-Vindas (Nova Conta)', desc: 'Enviada quando o aluno cria a conta.', icon: '👋' }, { id: 'whatsappTemplateWelcome', title: 'Boas-Vindas (Nova Conta)', desc: 'Enviada automaticamente quando o aluno cria uma conta.', icon: '👋', tags: ['{nome}'] },
{ id: 'whatsappTemplateNewContent', title: 'Novo Conteúdo (Carrossel)', desc: 'Enviada ao postar curso/módulo/aula.', icon: '🎬' }, { id: 'whatsappTemplateNewContent', title: 'Novo Conteúdo (Carrossel)', desc: 'Enviada manualmente ao postar curso/módulo/aula.', icon: '🎬', tags: ['{nome}'] },
{ id: 'whatsappTemplatePromo', title: 'Promoção em Massa', desc: 'Mensagem para todos os clientes.', icon: '🚀' },
]; ];
return ( return (
@ -150,9 +260,9 @@ export default function AdminMessagesView() {
<div> <div>
<h2 className="text-2xl font-bold text-white flex items-center space-x-2"> <h2 className="text-2xl font-bold text-white flex items-center space-x-2">
<Mail className="w-6 h-6 text-[#E50914]" /> <Mail className="w-6 h-6 text-[#E50914]" />
<span>Mensagens e Comentários</span> <span>Mensagens e Comunicações</span>
</h2> </h2>
<p className="text-zinc-400 text-sm mt-1">Gerencie dúvidas, anotações de aulas e chamados de suporte dos alunos.</p> <p className="text-zinc-400 text-sm mt-1">Gerencie dúvidas, anotações e configure as mensagens automáticas do WhatsApp.</p>
</div> </div>
</div> </div>
@ -176,7 +286,7 @@ export default function AdminMessagesView() {
className={`pb-3 px-1 text-sm font-bold flex items-center space-x-2 border-b-2 transition-colors whitespace-nowrap ${activeTab === 'whatsapp_templates' ? 'border-[#E50914] text-white' : 'border-transparent text-zinc-500 hover:text-zinc-300'}`} className={`pb-3 px-1 text-sm font-bold flex items-center space-x-2 border-b-2 transition-colors whitespace-nowrap ${activeTab === 'whatsapp_templates' ? 'border-[#E50914] text-white' : 'border-transparent text-zinc-500 hover:text-zinc-300'}`}
> >
<Smartphone className="w-4 h-4" /> <Smartphone className="w-4 h-4" />
<span>Modelos de WhatsApp</span> <span>Modelos WhatsApp</span>
</button> </button>
</div> </div>
@ -225,33 +335,18 @@ export default function AdminMessagesView() {
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" 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"> <div className="flex justify-end space-x-2">
<button <button onClick={() => { setReplyingTo(null); setReplyText(''); }} className="px-3 py-1.5 text-xs text-zinc-400 hover:text-white transition-colors">Cancelar</button>
onClick={() => { setReplyingTo(null); setReplyText(''); }}
className="px-3 py-1.5 text-xs text-zinc-400 hover:text-white transition-colors"
>
Cancelar
</button>
<button <button
onClick={() => handleReply(ticket.user.id, ticket.id)} onClick={() => handleReply(ticket.user.id, ticket.id)}
disabled={!replyText.trim() || isSending} 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" 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 ? ( {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></>}
<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> </button>
</div> </div>
</div> </div>
) : ( ) : (
<button <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">
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" /> <Reply className="w-3 h-3" />
<span>Responder</span> <span>Responder</span>
</button> </button>
@ -303,33 +398,18 @@ export default function AdminMessagesView() {
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" 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"> <div className="flex justify-end space-x-2">
<button <button onClick={() => { setReplyingTo(null); setReplyText(''); }} className="px-3 py-1.5 text-xs text-zinc-400 hover:text-white transition-colors">Cancelar</button>
onClick={() => { setReplyingTo(null); setReplyText(''); }}
className="px-3 py-1.5 text-xs text-zinc-400 hover:text-white transition-colors"
>
Cancelar
</button>
<button <button
onClick={() => handleReply(note.user.id)} onClick={() => handleReply(note.user.id)}
disabled={!replyText.trim() || isSending} 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" 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 ? ( {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></>}
<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> </button>
</div> </div>
</div> </div>
) : ( ) : (
<button <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">
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" /> <Reply className="w-3 h-3" />
<span>Responder</span> <span>Responder</span>
</button> </button>
@ -340,49 +420,171 @@ export default function AdminMessagesView() {
)} )}
{activeTab === 'whatsapp_templates' && ( {activeTab === 'whatsapp_templates' && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-6"> <div className="space-y-6 mt-4">
{whatsappTemplates.map(t => ( {/* Automatic Templates */}
<div <div>
key={t.id} <h3 className="text-sm font-bold text-zinc-400 uppercase tracking-wider mb-3 flex items-center gap-2">
className="bg-zinc-900 border border-white/10 rounded-xl p-5 hover:border-white/20 transition-colors cursor-pointer" <Smartphone className="w-4 h-4 text-green-500" />
onClick={() => { setCurrentTemplateText(settings?.[t.id] || ''); setActiveTemplateModal(t.id); }} Mensagens Automáticas
> </h3>
<div className="flex items-center space-x-4 mb-3"> <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="w-12 h-12 bg-black/40 rounded-full flex items-center justify-center text-2xl"> {whatsappTemplates.map(t => (
{t.icon} <div
key={t.id}
className="bg-zinc-900 border border-white/10 rounded-xl p-5 hover:border-white/25 transition-all cursor-pointer group"
onClick={() => {
setCurrentTemplateText(settings?.[t.id] || DEFAULT_TEMPLATES[t.id] || '');
setActiveTemplateModal(t.id);
}}
>
<div className="flex items-start gap-3 mb-3">
<span className="text-2xl">{t.icon}</span>
<div className="flex-1 min-w-0">
<h3 className="text-sm font-bold text-white group-hover:text-[#E50914] transition-colors">{t.title}</h3>
<p className="text-[11px] text-zinc-500 leading-snug mt-0.5">{t.desc}</p>
</div>
</div>
<div className="bg-black/30 p-2.5 rounded-lg text-[11px] text-zinc-400 line-clamp-3 min-h-[50px] whitespace-pre-wrap mb-2">
{settings?.[t.id] || DEFAULT_TEMPLATES[t.id] || 'Clique para configurar...'}
</div>
<div className="flex flex-wrap gap-1">
{t.tags.map(tag => (
<span key={tag} className="text-[9px] bg-blue-500/10 text-blue-300 px-1.5 py-0.5 rounded font-mono">{tag}</span>
))}
</div>
<div className="mt-2 flex items-center gap-1.5">
<div className={`w-2 h-2 rounded-full ${settings?.[t.id] ? 'bg-green-500' : 'bg-zinc-600'}`}></div>
<span className={`text-[10px] font-bold ${settings?.[t.id] ? 'text-green-400' : 'text-zinc-500'}`}>
{settings?.[t.id] ? 'Ativo' : 'Não configurado'}
</span>
</div>
</div> </div>
<div> ))}
<h3 className="text-lg font-bold text-white">{t.title}</h3> </div>
<p className="text-xs text-zinc-500">{t.desc}</p> </div>
</div>
</div> {/* Mass Broadcast */}
<div className="bg-black/30 p-3 rounded-lg text-xs text-zinc-400 line-clamp-2 min-h-[40px] whitespace-pre-wrap"> <div className="bg-zinc-900 border border-amber-500/20 rounded-xl p-6">
{settings?.[t.id] || 'Clique para configurar esta mensagem...'} <div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 bg-amber-500/10 rounded-full flex items-center justify-center text-xl">🚀</div>
<div>
<h3 className="text-lg font-bold text-white flex items-center gap-2">
<Megaphone className="w-5 h-5 text-amber-400" />
Promoção em Massa
</h3>
<p className="text-xs text-zinc-500">Envia para todos os alunos que têm WhatsApp cadastrado.</p>
</div> </div>
</div> </div>
))}
<div className="bg-amber-500/5 border border-amber-500/15 p-3 rounded-lg text-xs text-amber-200 mb-4 space-y-1">
<p><strong> Anti-Spam:</strong> Configure o intervalo de envio para evitar bloqueio pelo WhatsApp.</p>
<p><strong>Tag dinâmica:</strong> Use <code className="bg-black/40 px-1 rounded">{'{nome}'}</code> para personalizar com o primeiro nome do aluno.</p>
</div>
<div className="space-y-3">
<div className="relative">
<textarea
ref={broadcastRef}
value={broadcastText}
onChange={(e) => setBroadcastText(e.target.value)}
className="w-full h-40 bg-black/40 border border-white/10 rounded-lg p-3 pr-12 text-white text-sm focus:outline-none focus:border-amber-500/50 resize-none"
placeholder="Escreva a mensagem de promoção aqui..."
/>
<div className="absolute bottom-3 right-3">
<button
onClick={() => setShowBroadcastEmojiPicker(!showBroadcastEmojiPicker)}
className="w-8 h-8 flex items-center justify-center text-zinc-400 hover:text-amber-400 transition-colors"
title="Inserir emoji"
>
<Smile className="w-5 h-5" />
</button>
</div>
{showBroadcastEmojiPicker && (
<div className="absolute bottom-12 right-3 bg-zinc-800 border border-white/10 rounded-xl p-3 grid grid-cols-6 gap-1.5 z-10 shadow-2xl">
{EMOJI_LIST.map(emoji => (
<button key={emoji} onClick={() => insertEmoji(emoji, true)} className="text-xl hover:scale-125 transition-transform p-1 rounded hover:bg-white/10">
{emoji}
</button>
))}
</div>
)}
</div>
<div className="flex items-center gap-3">
<Clock className="w-4 h-4 text-amber-400 flex-shrink-0" />
<label className="text-xs text-zinc-300 flex-shrink-0">Intervalo entre mensagens:</label>
<input
type="number"
value={broadcastDelay}
onChange={e => setBroadcastDelay(Math.max(5, Math.min(120, Number(e.target.value))))}
min={5}
max={120}
className="w-20 bg-black/40 border border-white/10 rounded-lg px-3 py-1.5 text-white text-sm focus:outline-none focus:border-amber-500/50"
/>
<span className="text-xs text-zinc-500">segundos (mín: 5s / máx: 120s)</span>
</div>
<div className="flex justify-end">
<button
onClick={handleBroadcast}
disabled={!broadcastText.trim() || isBroadcasting}
className="bg-amber-600 hover:bg-amber-500 text-white px-6 py-2.5 rounded-lg font-bold transition-colors disabled:opacity-50 flex items-center space-x-2"
>
{isBroadcasting
? <><div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /><span>Iniciando...</span></>
: <><Megaphone className="w-4 h-4" /><span>Enviar para Todos</span></>
}
</button>
</div>
</div>
</div>
</div> </div>
)} )}
</div> </div>
)} )}
<CustomModal isOpen={!!activeTemplateModal} onClose={() => setActiveTemplateModal(null)} title={whatsappTemplates.find(t => t.id === activeTemplateModal)?.title || 'Editar Template'} isGlass={true}> {/* Template Edit Modal */}
<CustomModal isOpen={!!activeTemplateModal} onClose={() => { setActiveTemplateModal(null); setShowEmojiPicker(false); }} title={whatsappTemplates.find(t => t.id === activeTemplateModal)?.title || 'Editar Template'} isGlass={true}>
<div className="space-y-4"> <div className="space-y-4">
<div className="bg-blue-500/10 border border-blue-500/20 p-3 rounded-lg text-xs text-blue-200 space-y-1"> <div className="bg-blue-500/10 border border-blue-500/20 p-3 rounded-lg text-xs text-blue-200 space-y-1">
<p><strong>Formatação do WhatsApp:</strong> Use <code className="bg-black/40 px-1 rounded">*negrito*</code>, <code className="bg-black/40 px-1 rounded">_itálico_</code>, <code className="bg-black/40 px-1 rounded">~riscado~</code>.</p> <p><strong>Formatação do WhatsApp:</strong> Use <code className="bg-black/40 px-1 rounded">*negrito*</code>, <code className="bg-black/40 px-1 rounded">_itálico_</code>, <code className="bg-black/40 px-1 rounded">~riscado~</code>.</p>
<p><strong>Tags dinâmicas:</strong> Use <code className="bg-black/40 px-1 rounded font-bold text-blue-100">{'{nome}'}</code> para inserir o primeiro nome do aluno.</p> <p><strong>Tags disponíveis:</strong>{' '}
<p>Emojis são suportados nativamente (pressione Win + . no Windows ou Cmd + Ctrl + Espaço no Mac).</p> {whatsappTemplates.find(t => t.id === activeTemplateModal)?.tags.map(tag => (
<code key={tag} className="bg-black/40 px-1 rounded font-bold text-blue-100 mr-1">{tag}</code>
))}
</p>
</div>
<div className="relative">
<textarea
ref={textareaRef}
value={currentTemplateText}
onChange={(e) => setCurrentTemplateText(e.target.value)}
className="w-full h-52 bg-black/40 border border-white/10 rounded-lg p-3 pr-12 text-white text-sm focus:outline-none focus:border-[#E50914] resize-none"
placeholder="Escreva a mensagem aqui..."
/>
<div className="absolute bottom-3 right-3">
<button
onClick={() => setShowEmojiPicker(!showEmojiPicker)}
className="w-8 h-8 flex items-center justify-center text-zinc-400 hover:text-[#E50914] transition-colors"
title="Inserir emoji"
>
<Smile className="w-5 h-5" />
</button>
</div>
{showEmojiPicker && (
<div className="absolute bottom-12 right-3 bg-zinc-800 border border-white/10 rounded-xl p-3 grid grid-cols-6 gap-1.5 z-10 shadow-2xl">
{EMOJI_LIST.map(emoji => (
<button key={emoji} onClick={() => insertEmoji(emoji, false)} className="text-xl hover:scale-125 transition-transform p-1 rounded hover:bg-white/10">
{emoji}
</button>
))}
</div>
)}
</div> </div>
<textarea
value={currentTemplateText}
onChange={(e) => setCurrentTemplateText(e.target.value)}
className="w-full h-48 bg-black/40 border border-white/10 rounded-lg p-3 text-white text-sm focus:outline-none focus:border-[#E50914] resize-none"
placeholder="Escreva a mensagem aqui..."
/>
<div className="flex justify-end space-x-2"> <div className="flex justify-end space-x-2">
<button onClick={() => setActiveTemplateModal(null)} className="px-4 py-2 text-zinc-400 hover:text-white transition-colors text-sm"> <button onClick={() => { setActiveTemplateModal(null); setShowEmojiPicker(false); }} className="px-4 py-2 text-zinc-400 hover:text-white transition-colors text-sm">
Cancelar Cancelar
</button> </button>
<button <button
@ -396,29 +598,18 @@ export default function AdminMessagesView() {
</div> </div>
</CustomModal> </CustomModal>
<CustomModal {/* Alert Modal */}
isOpen={alertModal.isOpen} <CustomModal isOpen={alertModal.isOpen} onClose={() => setAlertModal({...alertModal, isOpen: false})} title={alertModal.title} isGlass={true}>
onClose={() => setAlertModal({...alertModal, isOpen: false})}
title={alertModal.title}
isGlass={true}
>
<div className="flex items-start gap-4"> <div className="flex items-start gap-4">
{alertModal.isError ? ( {alertModal.isError ? (
<div className="bg-red-500/10 p-3 rounded-full shrink-0"> <div className="bg-red-500/10 p-3 rounded-full shrink-0"><AlertCircle className="w-6 h-6 text-red-500" /></div>
<AlertCircle className="w-6 h-6 text-red-500" />
</div>
) : ( ) : (
<div className="bg-green-500/10 p-3 rounded-full shrink-0"> <div className="bg-green-500/10 p-3 rounded-full shrink-0"><CheckCircle2 className="w-6 h-6 text-green-500" /></div>
<CheckCircle2 className="w-6 h-6 text-green-500" />
</div>
)} )}
<div className="flex-1"> <div className="flex-1">
<p className="text-zinc-300 text-base">{alertModal.message}</p> <p className="text-zinc-300 text-base">{alertModal.message}</p>
<div className="mt-6 flex justify-end"> <div className="mt-6 flex justify-end">
<button <button onClick={() => setAlertModal({...alertModal, isOpen: false})} className="px-6 py-2 bg-zinc-800 hover:bg-zinc-700 text-white rounded-lg font-bold transition-colors">
onClick={() => setAlertModal({...alertModal, isOpen: false})}
className="px-6 py-2 bg-zinc-800 hover:bg-zinc-700 text-white rounded-lg font-bold transition-colors"
>
OK OK
</button> </button>
</div> </div>