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
Build and Deploy (Gitea) / build-and-deploy (push) Successful in 59s
Details
This commit is contained in:
parent
4f4fceab29
commit
4cc23a9e5b
96
server.ts
96
server.ts
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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 já 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, é só 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>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue