Files
clube67_newwhats.local/newwhats.clube67.com/newwhats.local/frontend/pages/baileys/index.tsx
T
VPS 4 Deploy Agent 6d08a5b044 feat(baileys): oculta termo "Baileys" na página para não-admin
Título e textos da página /baileys ficam genéricos ("Monitor de Eventos")
para usuários comuns; "Baileys Engine"/"servidor Baileys" só p/ admin ou
ruibto@gmail.com.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 05:57:02 +02:00

797 lines
37 KiB
TypeScript

import React, { useState, useEffect, useRef, useCallback } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
Zap, X, ChevronDown, ChevronRight, Wifi, WifiOff, MessageSquare,
Send, Image, FileText, Video, Music, Phone, Users, RefreshCw,
CheckCheck, AlertCircle, QrCode, Link, Unlink, Trash2, Activity,
ArrowDownLeft, ArrowUpRight, Filter, LayoutGrid, ShieldCheck, Clock
} from 'lucide-react';
import { io, Socket } from 'socket.io-client';
import { useAuthStore } from '../../store/authStore';
// ─── Types ────────────────────────────────────────────────────────────────────
type EventCategory = 'all' | 'connection' | 'received' | 'sent' | 'media' | 'chat' | 'system';
interface BaileysEvent {
id: string;
timestamp: number;
sessionId: string;
event: string;
data: any;
category: EventCategory;
mediaType?: 'image' | 'video' | 'audio' | 'document' | 'sticker';
fromMe?: boolean;
}
interface SessionStats {
received: number;
sent: number;
media: number;
chats: number;
}
interface SessionInfo {
id: string;
name?: string;
status: 'connected' | 'disconnected' | 'connecting' | 'qr';
lastSeen: number;
eventCount: number;
events: BaileysEvent[];
engine?: 'infinite' | 'official';
dbStats?: SessionStats; // contagens do banco (carregadas no mount)
liveStats?: Partial<SessionStats>; // incrementos ao vivo (Socket.IO)
}
// ─── Método de envio: labels para o usuário final ─────────────────────────────
const METODOS = [
{
value: 'infinite' as const,
label: 'Método 1',
desc: 'Com botões',
hint: 'Instável',
icon: <LayoutGrid size={11} />,
color: 'text-amber-400',
bg: 'bg-amber-500/10',
border: 'border-amber-500/30',
dot: 'bg-amber-400',
},
{
value: 'official' as const,
label: 'Método 2',
desc: 'Sem botões',
hint: 'Estável',
icon: <ShieldCheck size={11} />,
color: 'text-emerald-400',
bg: 'bg-emerald-500/10',
border: 'border-emerald-500/30',
dot: 'bg-emerald-400',
},
];
// ─── Helpers ──────────────────────────────────────────────────────────────────
function fmtCount(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(n >= 10_000 ? 0 : 1)}k`;
return String(n);
}
function categorizeEvent(event: string, data: any): { category: EventCategory; mediaType?: BaileysEvent['mediaType']; fromMe?: boolean } {
const e = event.toLowerCase();
if (e.includes('connection') || e.includes('qr') || e.includes('connect') || e.includes('disconnect') || e.includes('open') || e.includes('close')) {
return { category: 'connection' };
}
if (e.includes('message') || e.includes('msg')) {
const fromMe = data?.fromMe ?? data?.key?.fromMe ?? false;
let mediaType: BaileysEvent['mediaType'] | undefined;
if (data?.imageMessage || data?.message?.imageMessage) mediaType = 'image';
else if (data?.videoMessage || data?.message?.videoMessage) mediaType = 'video';
else if (data?.audioMessage || data?.message?.audioMessage) mediaType = 'audio';
else if (data?.documentMessage || data?.message?.documentMessage) mediaType = 'document';
else if (data?.stickerMessage || data?.message?.stickerMessage) mediaType = 'sticker';
if (mediaType) return { category: 'media', mediaType, fromMe };
return { category: fromMe ? 'sent' : 'received', fromMe };
}
if (e.includes('chat')) return { category: 'chat' };
return { category: 'system' };
}
function parseData(raw: any): any {
if (typeof raw === 'string') {
try { return JSON.parse(raw); } catch { return raw; }
}
return raw;
}
const EVENT_META: Record<EventCategory, { color: string; bg: string; label: string }> = {
all: { color: 'text-slate-300', bg: 'bg-slate-500/20', label: 'Todos' },
connection: { color: 'text-emerald-400', bg: 'bg-emerald-500/20', label: 'Conexão' },
received: { color: 'text-blue-400', bg: 'bg-blue-500/20', label: 'Recebidas' },
sent: { color: 'text-brand-400', bg: 'bg-brand-500/20', label: 'Enviadas' },
media: { color: 'text-purple-400', bg: 'bg-purple-500/20', label: 'Mídia' },
chat: { color: 'text-yellow-400', bg: 'bg-yellow-500/20', label: 'Chats' },
system: { color: 'text-slate-400', bg: 'bg-slate-500/20', label: 'Sistema' },
};
function EventIcon({ ev }: { ev: BaileysEvent }) {
const cls = 'w-4 h-4 shrink-0';
if (ev.category === 'connection') {
if (ev.event.toLowerCase().includes('qr')) return <QrCode className={`${cls} text-yellow-400`} />;
if (ev.event.toLowerCase().includes('disconnect') || ev.event.toLowerCase().includes('close')) return <Unlink className={`${cls} text-red-400`} />;
return <Link className={`${cls} text-emerald-400`} />;
}
if (ev.category === 'sent') return <ArrowUpRight className={`${cls} text-brand-400`} />;
if (ev.category === 'received') return <ArrowDownLeft className={`${cls} text-blue-400`} />;
if (ev.category === 'media') {
if (ev.mediaType === 'image') return <Image className={`${cls} text-purple-400`} />;
if (ev.mediaType === 'video') return <Video className={`${cls} text-pink-400`} />;
if (ev.mediaType === 'audio') return <Music className={`${cls} text-orange-400`} />;
if (ev.mediaType === 'document') return <FileText className={`${cls} text-cyan-400`} />;
return <Image className={`${cls} text-purple-400`} />;
}
if (ev.category === 'chat') return <MessageSquare className={`${cls} text-yellow-400`} />;
return <Activity className={`${cls} text-slate-400`} />;
}
function eventSummary(ev: BaileysEvent): string {
const d = ev.data;
if (!d) return ev.event;
if (ev.category === 'connection') {
if (d.connection) return `connection → ${d.connection}`;
if (typeof d === 'string') return d;
}
if (ev.category === 'sent' || ev.category === 'received') {
const text = d.body || d.message?.conversation || d.message?.extendedTextMessage?.text || d.text;
if (text) return String(text).slice(0, 80);
const jid = d.key?.remoteJid || d.remoteJid || '';
return jid ? `${jid}` : ev.event;
}
if (ev.category === 'media') {
const jid = d.key?.remoteJid || d.remoteJid || '';
return `${ev.mediaType ?? 'media'} ${jid ? `${jid}` : ''}`;
}
if (ev.category === 'chat') {
return d.id || d.jid || ev.event;
}
return ev.event;
}
// ─── EventRow ─────────────────────────────────────────────────────────────────
function EventRow({ ev, index }: { ev: BaileysEvent; index: number }) {
const [open, setOpen] = useState(false);
const meta = EVENT_META[ev.category];
return (
<motion.div
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.15, delay: index < 20 ? index * 0.01 : 0 }}
className="border-b border-white/[0.04] last:border-0"
>
<button
onClick={() => setOpen(o => !o)}
className="w-full flex items-center gap-3 px-4 py-2.5 hover:bg-white/[0.03] transition-colors text-left group"
>
{/* Index */}
<span className="text-[10px] font-mono text-slate-600 w-6 shrink-0 text-right">{index + 1}</span>
{/* Icon */}
<EventIcon ev={ev} />
{/* Event name badge */}
<span className={`text-[10px] font-black uppercase tracking-wider px-2 py-0.5 rounded-full shrink-0 ${meta.bg} ${meta.color}`}>
{ev.event}
</span>
{/* Summary */}
<span className="text-sm text-slate-400 truncate flex-1 font-mono">
{eventSummary(ev)}
</span>
{/* Time */}
<span className="text-[10px] text-slate-600 shrink-0">
{new Date(ev.timestamp).toLocaleTimeString('pt-BR')}
</span>
{/* Chevron */}
<span className="shrink-0 text-slate-600 group-hover:text-slate-400 transition-colors">
{open ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</span>
</button>
<AnimatePresence>
{open && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="overflow-hidden"
>
<div className="mx-4 mb-3 bg-black/60 rounded-xl border border-white/5 overflow-hidden">
<div className="flex items-center gap-2 px-3 py-2 border-b border-white/5 bg-white/[0.02]">
<span className="text-[10px] font-black uppercase tracking-widest text-slate-500">Payload</span>
<span className="text-[10px] text-slate-600 ml-auto">{new Date(ev.timestamp).toLocaleString('pt-BR')}</span>
</div>
<pre className="p-3 text-[11px] font-mono text-slate-300 overflow-x-auto whitespace-pre-wrap leading-relaxed max-h-60">
{JSON.stringify(ev.data, null, 2)}
</pre>
</div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
);
}
// ─── Session Modal ─────────────────────────────────────────────────────────────
function SessionModal({ session, onClose }: { session: SessionInfo; onClose: () => void }) {
const [filter, setFilter] = useState<EventCategory>('all');
const filtered = filter === 'all' ? session.events : session.events.filter(e => e.category === filter);
const counts = session.events.reduce<Record<string, number>>((acc, e) => {
acc[e.category] = (acc[e.category] || 0) + 1;
return acc;
}, {});
const categories: EventCategory[] = ['all', 'connection', 'received', 'sent', 'media', 'chat', 'system'];
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-50 flex items-center justify-center p-4"
onClick={onClose}
>
<div className="absolute inset-0 bg-black/80 backdrop-blur-sm" />
<motion.div
initial={{ scale: 0.95, opacity: 0, y: 10 }}
animate={{ scale: 1, opacity: 1, y: 0 }}
exit={{ scale: 0.95, opacity: 0, y: 10 }}
transition={{ duration: 0.2 }}
className="relative w-full max-w-4xl max-h-[85vh] bg-[#0d0f18] border border-white/10 rounded-2xl shadow-2xl shadow-black/60 flex flex-col overflow-hidden"
onClick={e => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center gap-3 px-5 py-4 border-b border-white/[0.06] bg-white/[0.02] shrink-0">
<div className={`w-2.5 h-2.5 rounded-full shrink-0 ${
session.status === 'connected' ? 'bg-emerald-500 shadow-[0_0_8px_#10b981]' :
session.status === 'qr' ? 'bg-yellow-400 shadow-[0_0_8px_#facc15]' :
'bg-red-500'
}`} />
<div>
<h2 className="text-white font-bold text-sm">{session.name || session.id.slice(0, 8)}</h2>
<p className="text-[10px] text-slate-500 uppercase tracking-widest font-black">
{session.status} · {session.eventCount} eventos
</p>
</div>
<button
onClick={onClose}
className="ml-auto p-1.5 hover:bg-white/10 rounded-lg text-slate-500 hover:text-white transition-colors"
>
<X size={16} />
</button>
</div>
{/* Filter Tabs */}
<div className="flex items-center gap-1 px-4 py-2 border-b border-white/[0.06] bg-black/20 overflow-x-auto shrink-0">
<Filter size={12} className="text-slate-600 mr-1 shrink-0" />
{categories.map(cat => {
const meta = EVENT_META[cat];
const count = cat === 'all' ? session.events.length : (counts[cat] || 0);
if (count === 0 && cat !== 'all') return null;
return (
<button
key={cat}
onClick={() => setFilter(cat)}
className={`flex items-center gap-1.5 px-3 py-1 rounded-full text-[10px] font-black uppercase tracking-wider transition-all whitespace-nowrap ${
filter === cat
? `${meta.bg} ${meta.color} border border-current/20`
: 'text-slate-500 hover:text-slate-300 hover:bg-white/5'
}`}
>
{meta.label}
<span className={`px-1.5 py-0.5 rounded-full text-[9px] ${filter === cat ? 'bg-black/30' : 'bg-white/5'}`}>
{count}
</span>
</button>
);
})}
</div>
{/* Events List */}
<div className="flex-1 overflow-y-auto">
{filtered.length === 0 ? (
<div className="flex flex-col items-center justify-center h-40 text-slate-600">
<Activity size={24} className="mb-2 opacity-40" />
<span className="text-sm">Nenhum evento nesta categoria</span>
</div>
) : (
<div>
{[...filtered].reverse().map((ev, i) => (
<EventRow key={ev.id} ev={ev} index={i} />
))}
</div>
)}
</div>
{/* Footer */}
<div className="px-5 py-2.5 border-t border-white/[0.06] bg-black/20 shrink-0 flex items-center gap-2">
<span className="text-[10px] text-slate-600 font-mono">
Última atividade: {new Date(session.lastSeen).toLocaleString('pt-BR')}
</span>
<span className="ml-auto text-[10px] text-slate-600">
Exibindo {filtered.length} de {session.eventCount} eventos
</span>
</div>
</motion.div>
</motion.div>
);
}
// ─── Session Card ──────────────────────────────────────────────────────────────
function SessionCard({
session,
onClick,
onEngineChange,
}: {
session: SessionInfo;
onClick: () => void;
onEngineChange: (instanceId: string, engine: 'infinite' | 'official') => Promise<void>;
}) {
const [switching, setSwitching] = useState(false);
const [cooldownSec, setCooldownSec] = useState(0);
// Soma contagens do banco (base) + eventos ao vivo da sessão atual
const liveCounts = session.events.reduce<Record<string, number>>((acc, e) => {
acc[e.category] = (acc[e.category] || 0) + 1;
return acc;
}, {});
const db = session.dbStats;
const counts = {
received: (db?.received ?? 0) + (liveCounts.received ?? 0),
sent: (db?.sent ?? 0) + (liveCounts.sent ?? 0),
media: (db?.media ?? 0) + (liveCounts.media ?? 0),
chat: (db?.chats ?? 0) + (liveCounts.chat ?? 0),
};
const statusColor = session.status === 'connected'
? 'text-emerald-400 bg-emerald-500/10 border-emerald-500/20'
: session.status === 'qr'
? 'text-yellow-400 bg-yellow-500/10 border-yellow-500/20'
: 'text-red-400 bg-red-500/10 border-red-500/20';
const dotColor = session.status === 'connected'
? 'bg-emerald-500 shadow-[0_0_8px_#10b981] animate-pulse'
: session.status === 'qr'
? 'bg-yellow-400 shadow-[0_0_8px_#facc15] animate-pulse'
: 'bg-red-500';
const currentEngine = session.engine ?? 'infinite';
const handleMethod = async (e: React.MouseEvent, value: 'infinite' | 'official') => {
e.stopPropagation();
if (value === currentEngine || switching || cooldownSec > 0) return;
setSwitching(true);
try {
await onEngineChange(session.id, value);
} catch (err: any) {
// Extrai segundos do cooldown se retornou 429
const match = String(err?.message ?? '').match(/(\d+)s/);
if (match) {
let secs = parseInt(match[1]);
setCooldownSec(secs);
const tick = setInterval(() => {
secs--;
setCooldownSec(secs);
if (secs <= 0) clearInterval(tick);
}, 1000);
}
} finally {
setSwitching(false);
}
};
return (
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
whileHover={{ y: -2, borderColor: 'rgba(255,255,255,0.12)' }}
className="w-full text-left bg-white/[0.03] border border-white/[0.06] rounded-xl p-4 hover:bg-white/[0.05] transition-all group"
>
{/* Header — clicável para abrir modal */}
<button onClick={onClick} className="w-full text-left">
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-2 min-w-0">
<div className={`w-2 h-2 rounded-full shrink-0 ${dotColor}`} />
<span className="text-white font-bold text-sm truncate">{session.name || session.id.slice(0, 8)}</span>
</div>
<span className={`text-[9px] font-black uppercase tracking-widest px-2 py-0.5 rounded-full border shrink-0 ml-2 ${statusColor}`}>
{session.status}
</span>
</div>
{/* Stats row */}
<div className="grid grid-cols-4 gap-1.5 mb-3">
{[
{ icon: <ArrowDownLeft size={10} />, val: counts.received, color: 'text-blue-400' },
{ icon: <ArrowUpRight size={10} />, val: counts.sent, color: 'text-brand-400' },
{ icon: <Image size={10} />, val: counts.media, color: 'text-purple-400' },
{ icon: <MessageSquare size={10} />, val: counts.chat, color: 'text-yellow-400' },
].map((s, i) => (
<div key={i} className="bg-black/40 rounded-lg py-1.5 px-2 flex flex-col items-center gap-0.5">
<span className={s.color}>{s.icon}</span>
<span className="text-[11px] font-black text-white">{fmtCount(s.val)}</span>
</div>
))}
</div>
</button>
{/* Método de envio */}
<div className="mt-2">
<p className="text-[10px] font-black uppercase tracking-widest text-slate-500 mb-2">
Modo de envio
</p>
<div className="flex gap-2">
{METODOS.map(m => {
const isActive = currentEngine === m.value;
return (
<button
key={m.value}
onClick={(e) => handleMethod(e, m.value)}
disabled={switching || cooldownSec > 0}
title={isActive ? 'Método atual' : `Trocar para ${m.label}`}
className={`flex-1 flex flex-col gap-1 px-3 py-2.5 rounded-xl border text-left transition-all
${isActive
? `${m.bg} ${m.border} ${m.color}`
: 'bg-white/[0.02] border-white/[0.06] text-slate-500 hover:border-white/20 hover:text-slate-300'
}
${(switching || cooldownSec > 0) ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer'}
`}
>
<div className="flex items-center gap-1.5">
{switching && !isActive
? <RefreshCw size={12} className="animate-spin" />
: m.icon
}
<span className="text-[11px] font-black leading-none">{m.label}</span>
{isActive && (
<span className={`w-1.5 h-1.5 rounded-full ${m.dot} ml-auto shrink-0`} />
)}
</div>
<span className="text-[11px] leading-tight font-semibold">{m.desc}</span>
<span className={`text-[10px] leading-tight ${isActive ? 'opacity-90 font-bold' : 'opacity-50'}`}>
{m.hint}
</span>
</button>
);
})}
</div>
{/* Cooldown warning */}
{cooldownSec > 0 && (
<div className="mt-2 flex items-center gap-1.5 text-[10px] text-amber-500/80">
<Clock size={10} />
<span>Aguarde {cooldownSec}s para trocar novamente</span>
</div>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-between mt-2 pt-2 border-t border-white/[0.04]">
<span className="text-[10px] text-slate-600 font-mono">
{session.eventCount} eventos
</span>
<span className="text-[10px] text-slate-600 font-mono">
{new Date(session.lastSeen).toLocaleTimeString('pt-BR')}
</span>
</div>
</motion.div>
);
}
// ─── Main Page ─────────────────────────────────────────────────────────────────
export default function BaileysPage() {
const user = useAuthStore((s) => s.user);
// Termo técnico "Baileys" só para admin ou ruibto@gmail.com.
const canSeeTech = user?.role === 'ADMIN' || user?.email === 'ruibto@gmail.com';
const [sessions, setSessions] = useState<Map<string, SessionInfo>>(new Map());
const [selectedSession, setSelectedSession] = useState<SessionInfo | null>(null);
const [listening, setListening] = useState(true);
const socketRef = useRef<Socket | null>(null);
const handleEngineChange = useCallback(async (instanceId: string, engine: 'infinite' | 'official') => {
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8008';
const token = typeof window !== 'undefined' ? localStorage.getItem('token') : null;
const res = await fetch(`${API_URL}/api/instances/${instanceId}/engine`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ engine }),
});
if (res.status === 429) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error ?? 'Cooldown ativo');
}
if (!res.ok) throw new Error('Falha ao trocar engine');
// Atualiza engine na sessão local imediatamente
setSessions(prev => {
const next = new Map(prev);
const existing = next.get(instanceId);
if (existing) next.set(instanceId, { ...existing, engine });
return next;
});
}, []);
const addEvent = useCallback((raw: Omit<BaileysEvent, 'id' | 'category' | 'mediaType' | 'fromMe'> & { data: any }) => {
const data = parseData(raw.data);
const { category, mediaType, fromMe } = categorizeEvent(raw.event, data);
const ev: BaileysEvent = {
// crypto.randomUUID garante unicidade — evita colisões nos keys do React
id: typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`,
timestamp: raw.timestamp ?? Date.now(),
sessionId: raw.sessionId,
event: raw.event,
data,
category,
mediaType,
fromMe,
};
setSessions(prev => {
const next = new Map(prev);
const existing = next.get(ev.sessionId) ?? {
id: ev.sessionId,
status: 'connecting' as const,
lastSeen: ev.timestamp,
eventCount: 0,
events: [],
};
let status = existing.status;
if (category === 'connection') {
const e = ev.event.toLowerCase();
const connVal = data?.connection;
if (connVal === 'open' || e.includes('connected')) status = 'connected';
else if (connVal === 'close' || e.includes('disconnected')) status = 'disconnected';
else if (e.includes('qr')) status = 'qr';
}
const events = [...existing.events, ev].slice(-300);
const updated: SessionInfo = {
...existing,
status,
lastSeen: ev.timestamp,
eventCount: existing.eventCount + 1,
events,
};
next.set(ev.sessionId, updated);
return next;
});
}, []);
// Mantém o modal sincronizado com o estado mais recente da sessão selecionada.
// Feito via useEffect (fora do updater de setSessions) para evitar o anti-pattern
// de chamar setState dentro de outro setState updater.
useEffect(() => {
if (!selectedSession) return;
setSelectedSession(sel => {
if (!sel) return sel;
const fresh = sessions.get(sel.id);
return fresh ?? sel;
});
}, [sessions]);
// Carrega estado inicial das instâncias via API
useEffect(() => {
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8008';
const token = typeof window !== 'undefined' ? localStorage.getItem('token') : null;
if (!token) return;
const headers = { Authorization: `Bearer ${token}` };
fetch(`${API_URL}/api/instances`, { headers })
.then(r => r.json())
.then(async (instances: any[]) => {
if (!Array.isArray(instances)) return;
// Busca stats de cada instância em paralelo
const statsResults = await Promise.allSettled(
instances.map(inst =>
fetch(`${API_URL}/api/instances/${inst.id}/stats`, { headers })
.then(r => r.ok ? r.json() : null)
.catch(() => null)
)
);
setSessions(prev => {
const next = new Map(prev);
const statusMap: Record<string, SessionInfo['status']> = {
CONNECTED: 'connected', DISCONNECTED: 'disconnected',
CONNECTING: 'connecting', QR_PENDING: 'qr', BANNED: 'disconnected',
};
instances.forEach((inst, idx) => {
const existing = next.get(inst.id);
const engine: 'infinite' | 'official' = inst.engine === 'official' ? 'official' : 'infinite';
const statsResult = statsResults[idx];
const dbStats: SessionStats | undefined =
statsResult.status === 'fulfilled' && statsResult.value
? statsResult.value
: undefined;
if (existing) {
next.set(inst.id, {
...existing,
...(inst.name ? { name: inst.name } : {}),
engine,
...(dbStats ? { dbStats } : {}),
});
} else {
next.set(inst.id, {
id: inst.id,
name: inst.name || undefined,
status: statusMap[inst.status] ?? 'disconnected',
lastSeen: Date.now(),
eventCount: 0,
events: [],
engine,
dbStats,
});
}
});
return next;
});
})
.catch(() => {});
}, []);
useEffect(() => {
if (!listening) return;
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8008';
const token = typeof window !== 'undefined' ? localStorage.getItem('token') : null;
if (!token) return;
let socket: ReturnType<typeof io> | null = null;
// Defer connection so React StrictMode's synchronous unmount/remount
// cycle clears the timer before the WebSocket handshake starts.
const timer = setTimeout(() => {
const socketUrl = (() => {
try { return new URL(API_URL).origin } catch { return API_URL }
})()
socket = io(socketUrl, {
transports: ['websocket', 'polling'],
auth: { token },
});
socketRef.current = socket;
socket.on('connect_error', (err) => {
console.warn('[Baileys] Socket connect error:', err.message);
});
socket.on('baileys:log', (log: any) => {
addEvent({
timestamp: log.timestamp ? new Date(log.timestamp).getTime() : Date.now(),
sessionId: log.sessionId || 'unknown',
event: log.event || 'unknown',
data: log.data,
});
});
}, 0);
return () => {
clearTimeout(timer);
socket?.disconnect();
socketRef.current = null;
};
}, [listening, addEvent]);
const sessionList = Array.from(sessions.values()).sort((a, b) => b.lastSeen - a.lastSeen);
return (
<div className="p-8 max-w-7xl mx-auto h-full overflow-y-auto pb-20">
{/* Header */}
<motion.div initial={{ opacity: 0, y: -10 }} animate={{ opacity: 1, y: 0 }} className="flex items-center justify-between mb-8">
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-2xl bg-brand-500/10 border border-brand-500/20 flex items-center justify-center">
<Zap size={24} className="text-brand-400" />
</div>
<div>
<h1 className="text-2xl font-black text-white tracking-tight">{canSeeTech ? 'Baileys Engine' : 'Monitor de Eventos'}</h1>
<p className="text-slate-500 text-xs mt-0.5">
{sessionList.length} {sessionList.length === 1 ? 'sessão' : 'sessões'} monitoradas
</p>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => { setSessions(new Map()); setSelectedSession(null); }}
className="p-2 hover:bg-white/5 rounded-lg text-slate-500 hover:text-white transition-colors"
title="Limpar"
>
<Trash2 size={16} />
</button>
<button
onClick={() => setListening(l => !l)}
className={`flex items-center gap-2 px-3 py-1.5 rounded-full text-[11px] font-black uppercase tracking-wider border transition-all ${
listening
? 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20'
: 'bg-red-500/10 text-red-400 border-red-500/20'
}`}
>
<span className={`w-1.5 h-1.5 rounded-full ${listening ? 'bg-emerald-400 animate-pulse' : 'bg-red-400'}`} />
{listening ? 'Live' : 'Paused'}
</button>
</div>
</motion.div>
{/* Sessions Grid */}
{sessionList.length === 0 ? (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="flex flex-col items-center justify-center py-24 text-slate-600"
>
<div className="w-16 h-16 rounded-2xl bg-white/[0.02] border border-white/5 flex items-center justify-center mb-4">
<Zap size={28} className="opacity-30" />
</div>
<p className="text-sm font-semibold mb-1">Nenhuma sessão detectada</p>
<p className="text-xs text-slate-700">Aguardando eventos do servidor{canSeeTech ? ' Baileys' : ''}...</p>
</motion.div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-4 gap-4">
<AnimatePresence>
{sessionList.map(session => (
<SessionCard
key={session.id}
session={session}
onClick={() => setSelectedSession(session)}
onEngineChange={handleEngineChange}
/>
))}
</AnimatePresence>
</div>
)}
{/* Legend */}
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ delay: 0.3 }} className="mt-8 flex flex-wrap gap-3">
{[
{ icon: <ArrowDownLeft size={12} />, label: 'Recebida', color: 'text-blue-400' },
{ icon: <ArrowUpRight size={12} />, label: 'Enviada', color: 'text-brand-400' },
{ icon: <Image size={12} />, label: 'Imagem', color: 'text-purple-400' },
{ icon: <Video size={12} />, label: 'Vídeo', color: 'text-pink-400' },
{ icon: <Music size={12} />, label: 'Áudio', color: 'text-orange-400' },
{ icon: <FileText size={12} />, label: 'Documento', color: 'text-cyan-400' },
{ icon: <MessageSquare size={12} />, label: 'Chat', color: 'text-yellow-400' },
{ icon: <Link size={12} />, label: 'Conexão', color: 'text-emerald-400' },
].map((item, i) => (
<div key={i} className={`flex items-center gap-1.5 text-[11px] ${item.color} bg-white/[0.03] px-2.5 py-1 rounded-full border border-white/5`}>
{item.icon}
<span className="font-semibold">{item.label}</span>
</div>
))}
</motion.div>
{/* Modal */}
<AnimatePresence>
{selectedSession && (
<SessionModal
session={selectedSession}
onClose={() => setSelectedSession(null)}
/>
)}
</AnimatePresence>
</div>
);
}