3e1001b7c7
- wa-sessions sai do isStandaloneView → renderiza com a sidebar (área de administração); wa-inbox/wa-secretaria seguem em tela cheia. - SessionsView convertida do dark para o tema branco/profissional (bg-gray-50/ white, text-gray-*, badges claros, botão Conectar em brand); backdrops de modal e botões coloridos preservados. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
659 lines
29 KiB
TypeScript
659 lines
29 KiB
TypeScript
// SessionsView — página /sessions do motor NewWhats, portada para o satélite
|
|
// scoreodonto. Dados/realtime via shims (ext API + túnel WS). Adaptações: sem
|
|
// next/link (navegação via onNavigate), export nomeado, wrapper de fundo escuro.
|
|
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
|
import { motion, AnimatePresence } from 'framer-motion';
|
|
import {
|
|
Wifi, Plus, RefreshCw, Loader2, QrCode, X,
|
|
CheckCircle2, AlertCircle, Trash2, LogOut, Phone,
|
|
ArrowLeft, Zap, WifiOff, Clock, Terminal, Download,
|
|
ChevronDown, ChevronRight,
|
|
} from 'lucide-react';
|
|
import { Button } from './components/ui';
|
|
import { useSessionsLogic } from './hooks/useSessionsLogic';
|
|
import { socketService } from './services/socketService';
|
|
import type { Instance } from './store/instanceStore';
|
|
|
|
// ─── Status config ────────────────────────────────────────────────────────────
|
|
|
|
const STATUS_CONFIG = {
|
|
CONNECTED: {
|
|
label: 'Conectado',
|
|
dot: 'bg-emerald-400',
|
|
badge: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20',
|
|
icon: <Wifi className="w-4 h-4" />,
|
|
},
|
|
QR_PENDING: {
|
|
label: 'Aguardando QR',
|
|
dot: 'bg-amber-400 animate-pulse',
|
|
badge: 'bg-amber-500/10 text-amber-400 border-amber-500/20',
|
|
icon: <QrCode className="w-4 h-4" />,
|
|
},
|
|
CONNECTING: {
|
|
label: 'Conectando...',
|
|
dot: 'bg-blue-400 animate-pulse',
|
|
badge: 'bg-blue-500/10 text-blue-400 border-blue-500/20',
|
|
icon: <Loader2 className="w-4 h-4 animate-spin" />,
|
|
},
|
|
DISCONNECTED: {
|
|
label: 'Desconectado',
|
|
dot: 'bg-gray-400',
|
|
badge: 'bg-gray-100 text-gray-600 border-gray-300',
|
|
icon: <WifiOff className="w-4 h-4" />,
|
|
},
|
|
BANNED: {
|
|
label: 'Banido',
|
|
dot: 'bg-red-500',
|
|
badge: 'bg-red-500/10 text-red-400 border-red-500/20',
|
|
icon: <AlertCircle className="w-4 h-4" />,
|
|
},
|
|
};
|
|
|
|
// ─── Baileys Log Types ────────────────────────────────────────────────────────
|
|
|
|
interface BaileysLogEntry {
|
|
id: string;
|
|
ts: number;
|
|
event: string;
|
|
data: any;
|
|
}
|
|
|
|
const EVENT_COLOR: Record<string, string> = {
|
|
'connection.update': 'text-emerald-400 bg-emerald-500/15',
|
|
'messages.upsert': 'text-blue-400 bg-blue-500/15',
|
|
'contacts.upsert': 'text-purple-400 bg-purple-500/15',
|
|
'contacts.update': 'text-purple-300 bg-purple-500/10',
|
|
'chats.upsert': 'text-yellow-400 bg-yellow-500/15',
|
|
'chats.update': 'text-yellow-300 bg-yellow-500/10',
|
|
'groups.upsert': 'text-orange-400 bg-orange-500/15',
|
|
'groups.update': 'text-orange-300 bg-orange-500/10',
|
|
'messaging-history.set': 'text-cyan-400 bg-cyan-500/15',
|
|
'message': 'text-blue-300 bg-blue-500/10',
|
|
};
|
|
|
|
function eventColor(event: string): string {
|
|
return EVENT_COLOR[event] ?? 'text-gray-600 bg-gray-100';
|
|
}
|
|
|
|
function logSummary(entry: BaileysLogEntry): string {
|
|
const d = entry.data;
|
|
if (!d) return '—';
|
|
if (entry.event === 'connection.update') {
|
|
if (d.connection) return `→ ${d.connection}`;
|
|
if (d.qr) return 'QR gerado';
|
|
}
|
|
if (entry.event === 'message' || entry.event === 'messages.upsert') {
|
|
const body = d.body ?? d.message?.conversation ?? d.message?.extendedTextMessage?.text;
|
|
if (body) return String(body).slice(0, 60);
|
|
const jid = d.remoteJid ?? d.key?.remoteJid;
|
|
if (jid) return jid;
|
|
}
|
|
if (typeof d === 'object') {
|
|
const count = Array.isArray(d) ? d.length : (d.count ?? d.chats ?? d.contacts ?? d.messages);
|
|
if (count !== undefined) return `${count} item(s)`;
|
|
const keys = Object.keys(d).slice(0, 3).join(', ');
|
|
return keys || '{}';
|
|
}
|
|
return String(d).slice(0, 60);
|
|
}
|
|
|
|
// ─── Log Row (expandable) ─────────────────────────────────────────────────────
|
|
|
|
function LogRow({ entry, index }: { entry: BaileysLogEntry; index: number }) {
|
|
const [open, setOpen] = useState(false);
|
|
const time = new Date(entry.ts).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
|
|
|
return (
|
|
<div className="border-b border-white/[0.04] last:border-0">
|
|
<button
|
|
onClick={() => setOpen(o => !o)}
|
|
className="w-full flex items-center gap-2 px-3 py-1.5 hover:bg-gray-100 text-left group transition-colors"
|
|
>
|
|
<span className="text-[9px] font-mono text-gray-400 w-5 shrink-0 text-right">{index + 1}</span>
|
|
<span className={`text-[9px] font-black uppercase tracking-wider px-1.5 py-0.5 rounded-full shrink-0 whitespace-nowrap ${eventColor(entry.event)}`}>
|
|
{entry.event}
|
|
</span>
|
|
<span className="text-[11px] text-gray-600 truncate flex-1 font-mono">{logSummary(entry)}</span>
|
|
<span className="text-[9px] text-gray-400 shrink-0 font-mono">{time}</span>
|
|
<span className="shrink-0 text-gray-400 group-hover:text-gray-500">
|
|
{open ? <ChevronDown size={11} /> : <ChevronRight size={11} />}
|
|
</span>
|
|
</button>
|
|
|
|
<AnimatePresence>
|
|
{open && (
|
|
<motion.div
|
|
initial={{ height: 0, opacity: 0 }}
|
|
animate={{ height: 'auto', opacity: 1 }}
|
|
exit={{ height: 0, opacity: 0 }}
|
|
transition={{ duration: 0.15 }}
|
|
className="overflow-hidden"
|
|
>
|
|
<pre className="mx-3 mb-2 p-2 bg-gray-100 rounded-lg text-[10px] font-mono text-gray-700 overflow-x-auto whitespace-pre-wrap max-h-48 border border-gray-100">
|
|
{JSON.stringify(entry.data, null, 2)}
|
|
</pre>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── QR Modal ─────────────────────────────────────────────────────────────────
|
|
|
|
function QrModal({
|
|
instanceId,
|
|
instanceName,
|
|
qrBase64,
|
|
onClose,
|
|
onNewQr,
|
|
}: {
|
|
instanceId: string;
|
|
instanceName: string;
|
|
qrBase64: string | null;
|
|
onClose: () => void;
|
|
onNewQr: (id: string) => Promise<void>;
|
|
}) {
|
|
const [expired, setExpired] = useState(false);
|
|
const [logs, setLogs] = useState<BaileysLogEntry[]>([]);
|
|
const logEndRef = useRef<HTMLDivElement>(null);
|
|
|
|
useEffect(() => {
|
|
if (!qrBase64) return;
|
|
setExpired(false);
|
|
const t = setTimeout(() => setExpired(true), 60_000);
|
|
return () => clearTimeout(t);
|
|
}, [qrBase64]);
|
|
|
|
useEffect(() => {
|
|
const handler = (log: any) => {
|
|
if (log.sessionId !== instanceId) return;
|
|
setLogs(prev => {
|
|
const entry: BaileysLogEntry = {
|
|
id: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
ts: log.timestamp ? new Date(log.timestamp).getTime() : Date.now(),
|
|
event: log.event ?? 'unknown',
|
|
data: log.data,
|
|
};
|
|
return [...prev, entry].slice(-300);
|
|
});
|
|
};
|
|
socketService.on('baileys:log', handler);
|
|
return () => socketService.off('baileys:log', handler);
|
|
}, [instanceId]);
|
|
|
|
useEffect(() => {
|
|
logEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
|
}, [logs.length]);
|
|
|
|
const saveJson = useCallback(() => {
|
|
const blob = new Blob([JSON.stringify(logs, null, 2)], { type: 'application/json' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = `baileys-${instanceId}-${Date.now()}.json`;
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
}, [logs, instanceId]);
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-[120] flex items-center justify-center p-4">
|
|
<motion.div
|
|
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
|
|
onClick={onClose}
|
|
className="absolute inset-0 bg-black/80 backdrop-blur-sm cursor-pointer"
|
|
/>
|
|
<motion.div
|
|
initial={{ opacity: 0, scale: 0.9, y: 20 }}
|
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
|
exit={{ opacity: 0, scale: 0.9, y: 20 }}
|
|
className="relative bg-white border border-gray-200 rounded-[2rem] shadow-2xl w-full max-w-3xl max-h-[90vh] flex flex-col overflow-hidden"
|
|
onClick={e => e.stopPropagation()}
|
|
>
|
|
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-100 shrink-0">
|
|
<div>
|
|
<h2 className="text-lg font-black text-gray-800">Conectar WhatsApp</h2>
|
|
<p className="text-gray-500 text-xs mt-0.5">{instanceName}</p>
|
|
</div>
|
|
<button onClick={onClose} className="p-2 text-gray-500 hover:text-gray-800 bg-gray-50 rounded-xl transition-colors">
|
|
<X className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="flex flex-1 min-h-0">
|
|
<div className="w-72 shrink-0 flex flex-col items-center justify-center gap-5 p-6 border-r border-gray-100">
|
|
<div className="bg-gray-50 border border-gray-100 p-5 rounded-3xl w-full flex flex-col items-center justify-center min-h-[260px]">
|
|
{expired ? (
|
|
<div className="flex flex-col items-center gap-4">
|
|
<Clock className="w-10 h-10 text-gray-500" />
|
|
<p className="font-bold text-gray-800 text-sm">QR expirado</p>
|
|
<Button variant="primary" size="sm" onClick={async () => {
|
|
setExpired(false);
|
|
await onNewQr(instanceId);
|
|
}}>
|
|
Novo QR
|
|
</Button>
|
|
</div>
|
|
) : qrBase64 ? (
|
|
<div className="bg-white p-2.5 rounded-2xl shadow-xl">
|
|
<img src={qrBase64.startsWith('data:') ? qrBase64 : `data:image/png;base64,${qrBase64}`} alt="QR Code" className="w-48 h-48 rounded-xl" />
|
|
</div>
|
|
) : (
|
|
<div className="flex flex-col items-center gap-3">
|
|
<Loader2 className="w-10 h-10 animate-spin text-emerald-500" />
|
|
<p className="text-xs text-gray-500 font-semibold uppercase tracking-widest">Gerando QR...</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{!expired && (
|
|
<div className="flex items-center gap-2 text-emerald-400 text-[10px] font-bold uppercase tracking-widest animate-pulse">
|
|
<RefreshCw className="w-3.5 h-3.5 animate-spin" /> Aguardando pareamento...
|
|
</div>
|
|
)}
|
|
|
|
<button onClick={onClose} className="w-full py-2.5 text-gray-500 hover:text-gray-800 font-bold transition-colors text-sm">
|
|
Fechar
|
|
</button>
|
|
</div>
|
|
|
|
<div className="flex-1 flex flex-col min-w-0">
|
|
<div className="flex items-center gap-2 px-4 py-2.5 border-b border-gray-100 bg-gray-50 shrink-0">
|
|
<Terminal className="w-3.5 h-3.5 text-gray-500" />
|
|
<span className="text-[10px] font-black uppercase tracking-widest text-gray-500">Console Baileys</span>
|
|
<span className="ml-1 text-[9px] font-mono text-gray-400 bg-gray-50 px-1.5 py-0.5 rounded-full">
|
|
{logs.length} eventos
|
|
</span>
|
|
<div className="ml-auto flex items-center gap-2">
|
|
<button
|
|
onClick={() => setLogs([])}
|
|
className="text-[9px] font-bold uppercase tracking-wider text-gray-500 hover:text-gray-600 transition-colors px-2 py-0.5 rounded hover:bg-gray-50"
|
|
>
|
|
Limpar
|
|
</button>
|
|
<button
|
|
onClick={saveJson}
|
|
disabled={logs.length === 0}
|
|
className="flex items-center gap-1 text-[9px] font-bold uppercase tracking-wider text-emerald-500 hover:text-emerald-400 disabled:opacity-30 disabled:cursor-not-allowed transition-colors px-2 py-0.5 rounded hover:bg-emerald-500/10"
|
|
>
|
|
<Download size={10} /> Salvar JSON
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex-1 overflow-y-auto">
|
|
{logs.length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center h-full text-gray-400 gap-2 py-16">
|
|
<Terminal size={28} className="opacity-30" />
|
|
<p className="text-xs">Aguardando eventos do Baileys...</p>
|
|
</div>
|
|
) : (
|
|
<div>
|
|
{logs.map((entry, i) => (
|
|
<LogRow key={entry.id} entry={entry} index={i} />
|
|
))}
|
|
<div ref={logEndRef} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="px-4 py-2 border-t border-gray-100 bg-gray-50 shrink-0">
|
|
<div className="flex items-center gap-1.5">
|
|
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse" />
|
|
<span className="text-[9px] font-mono text-gray-500">
|
|
{instanceId.slice(0, 8)}... · live
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Session Card ─────────────────────────────────────────────────────────────
|
|
|
|
const SessionCard = React.forwardRef<HTMLDivElement, {
|
|
inst: Instance;
|
|
onConnect: () => void;
|
|
onDisconnect: () => void;
|
|
onDelete: () => void;
|
|
onShowQr: () => void;
|
|
}>(function SessionCard({
|
|
inst,
|
|
onConnect,
|
|
onDisconnect,
|
|
onDelete,
|
|
onShowQr,
|
|
}, ref) {
|
|
const cfg = STATUS_CONFIG[inst.status] ?? STATUS_CONFIG.DISCONNECTED;
|
|
const isConnected = inst.status === 'CONNECTED';
|
|
const isQrPending = inst.status === 'QR_PENDING';
|
|
|
|
return (
|
|
<motion.div
|
|
ref={ref}
|
|
initial={{ opacity: 0, y: 15 }} animate={{ opacity: 1, y: 0 }}
|
|
className="bg-gray-100 backdrop-blur-xl p-6 rounded-[2rem] shadow-2xl border border-gray-100 flex flex-col gap-5 hover:border-gray-200 transition-all group relative overflow-hidden"
|
|
>
|
|
<div className="absolute top-0 right-0 w-32 h-32 bg-brand-500/5 blur-[60px] rounded-full translate-x-1/2 -translate-y-1/2 pointer-events-none" />
|
|
|
|
<div className="flex items-start justify-between relative z-10">
|
|
<div className="flex items-center gap-4">
|
|
<div className={`w-14 h-14 rounded-2xl overflow-hidden flex items-center justify-center border-2 shadow-lg ${isConnected ? 'bg-emerald-500/10 border-emerald-500/30 text-emerald-400' : 'bg-gray-50 border-gray-200 text-gray-500'}`}>
|
|
{inst.avatar
|
|
? <img src={inst.avatar} alt={inst.name} className="w-full h-full object-cover" onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; (e.currentTarget.nextSibling as HTMLElement)?.style.setProperty('display', 'flex') }} />
|
|
: null}
|
|
<span className={`w-full h-full items-center justify-center ${inst.avatar ? 'hidden' : 'flex'}`}>
|
|
<Phone className="w-6 h-6" />
|
|
</span>
|
|
</div>
|
|
<div>
|
|
<h3 className="font-black text-gray-800 text-lg tracking-tight">{inst.name}</h3>
|
|
<div className="flex items-center gap-2 mt-1 flex-wrap">
|
|
{inst.phone && (
|
|
<span className="text-xs font-mono text-gray-500 bg-gray-50 px-2 py-0.5 rounded border border-gray-100">{inst.phone}</span>
|
|
)}
|
|
<span className={`inline-flex items-center gap-1.5 text-[10px] font-black uppercase tracking-wider px-2 py-0.5 rounded-full border ${cfg.badge}`}>
|
|
<span className={`w-1.5 h-1.5 rounded-full ${cfg.dot}`} />
|
|
{cfg.label}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<button onClick={onDelete} title="Deletar instância"
|
|
className="p-2 text-gray-500 hover:text-red-400 hover:bg-red-500/10 rounded-xl transition-colors shrink-0"
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
|
|
{isQrPending && (
|
|
<button onClick={onShowQr}
|
|
className="flex items-center gap-3 p-3 bg-amber-500/10 border border-amber-500/20 rounded-2xl text-amber-300 text-sm font-semibold hover:bg-amber-500/15 transition-colors w-full"
|
|
>
|
|
<QrCode className="w-5 h-5 shrink-0" />
|
|
<span className="text-left">QR aguardando escaneamento — clique para ver</span>
|
|
</button>
|
|
)}
|
|
|
|
<div className="flex items-center gap-2 mt-auto relative z-10 pt-2 border-t border-gray-100">
|
|
{isConnected ? (
|
|
<>
|
|
<div className="flex items-center gap-2 text-emerald-400 text-xs font-bold uppercase tracking-widest flex-1">
|
|
<RefreshCw className="w-3.5 h-3.5 animate-spin" /> Sessão ativa
|
|
</div>
|
|
<button onClick={onDisconnect}
|
|
className="flex items-center gap-1.5 px-3 py-2 text-xs font-semibold text-gray-600 hover:text-gray-800 bg-gray-50 hover:bg-gray-100 rounded-xl transition-colors border border-gray-100"
|
|
>
|
|
<LogOut className="w-3.5 h-3.5" /> Desconectar
|
|
</button>
|
|
</>
|
|
) : (
|
|
<button onClick={onConnect}
|
|
className="flex-1 flex items-center justify-center gap-2 bg-brand-500 text-white font-black py-3 rounded-xl hover:bg-brand-600 transition-all text-[11px] uppercase tracking-widest"
|
|
>
|
|
<Zap className="w-3.5 h-3.5" fill="currentColor" /> Conectar
|
|
</button>
|
|
)}
|
|
</div>
|
|
</motion.div>
|
|
);
|
|
});
|
|
|
|
// ─── Confirm Delete Modal ─────────────────────────────────────────────────────
|
|
|
|
function ConfirmDeleteModal({
|
|
inst,
|
|
onClose,
|
|
onConfirm,
|
|
loading,
|
|
}: {
|
|
inst: Instance;
|
|
onClose: () => void;
|
|
onConfirm: () => void;
|
|
loading: boolean;
|
|
}) {
|
|
return (
|
|
<div className="fixed inset-0 z-[130] flex items-center justify-center p-4">
|
|
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
|
|
onClick={onClose} className="absolute inset-0 bg-black/80 backdrop-blur-sm cursor-pointer" />
|
|
<motion.div initial={{ opacity: 0, scale: 0.92, y: 20 }} animate={{ opacity: 1, scale: 1, y: 0 }} exit={{ opacity: 0, scale: 0.9 }}
|
|
className="relative bg-white backdrop-blur-2xl border border-red-500/20 border-t-[3px] border-t-red-500 rounded-[28px] p-7 w-full max-w-sm shadow-2xl"
|
|
>
|
|
<div className="flex items-center gap-3 mb-4 mt-2">
|
|
<div className="w-11 h-11 rounded-xl bg-red-500/15 border border-red-500/30 flex items-center justify-center">
|
|
<Trash2 className="w-5 h-5 text-red-500" />
|
|
</div>
|
|
<div>
|
|
<h3 className="font-bold text-gray-800">Deletar instância</h3>
|
|
<p className="text-xs text-gray-500">{inst.name}</p>
|
|
</div>
|
|
</div>
|
|
<p className="text-sm text-gray-600 mb-6 leading-relaxed">
|
|
Isso removerá permanentemente a instância, seus chats e sessão Baileys.<br />
|
|
<strong className="text-red-400">Esta ação é irreversível.</strong>
|
|
</p>
|
|
<div className="flex gap-3">
|
|
<Button variant="ghost" onClick={onClose} className="flex-1">Cancelar</Button>
|
|
<button onClick={onConfirm} disabled={loading}
|
|
className="flex-1 py-3 bg-red-500 hover:bg-red-600 text-white font-bold rounded-xl transition-colors disabled:opacity-50 flex items-center justify-center gap-2 text-sm"
|
|
>
|
|
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Trash2 className="w-4 h-4" />}
|
|
Deletar
|
|
</button>
|
|
</div>
|
|
</motion.div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Main View ────────────────────────────────────────────────────────────────
|
|
|
|
export function SessionsView({ onNavigate }: { onNavigate?: (view: string) => void }) {
|
|
const {
|
|
instances,
|
|
loading,
|
|
toast,
|
|
addOpen,
|
|
newName,
|
|
creating,
|
|
setNewName,
|
|
openAddModal,
|
|
closeAddModal,
|
|
handleCreate,
|
|
qrModal,
|
|
closeQrModal,
|
|
handleNewQr,
|
|
deleteTarget,
|
|
deleting,
|
|
setDeleteTarget,
|
|
closeDeleteModal,
|
|
handleDelete,
|
|
handleConnect,
|
|
handleDisconnect,
|
|
handleShowQr,
|
|
refreshInstances,
|
|
} = useSessionsLogic();
|
|
|
|
// Já existe uma sessão ativa no motor para esta conta → mostra mensagem de
|
|
// sucesso em vez de sugerir novo pareamento.
|
|
const hasActiveSession = instances.some((i) => i.status === 'CONNECTED');
|
|
|
|
return (
|
|
<div className="bg-gray-50 min-h-full text-gray-800">
|
|
<div className="max-w-5xl mx-auto p-6 lg:p-10 pb-20 font-sans space-y-10">
|
|
|
|
<AnimatePresence>
|
|
{toast && (
|
|
<motion.div
|
|
initial={{ opacity: 0, y: -20 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -20 }}
|
|
className={`fixed top-6 left-1/2 -translate-x-1/2 z-[200] flex items-center gap-2.5 px-5 py-3 rounded-2xl shadow-2xl border text-sm font-semibold ${toast.ok ? 'bg-emerald-500/10 border-emerald-500/20 text-emerald-300' : 'bg-red-500/10 border-red-500/20 text-red-300'}`}
|
|
>
|
|
{toast.ok ? <CheckCircle2 className="w-4 h-4" /> : <AlertCircle className="w-4 h-4" />}
|
|
{toast.msg}
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
<header className="flex flex-col sm:flex-row sm:items-center justify-between gap-6 relative">
|
|
<div className="absolute -top-16 -left-16 w-56 h-56 bg-brand-500/10 blur-[100px] rounded-full pointer-events-none" />
|
|
<div className="relative z-10 flex items-start gap-4">
|
|
<button onClick={() => onNavigate?.('dashboard')} className="p-2 hover:bg-gray-50 rounded-xl transition-colors text-gray-600 hover:text-gray-800 mt-1">
|
|
<ArrowLeft className="w-5 h-5" />
|
|
</button>
|
|
<div className="flex-1">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-12 h-12 bg-gradient-to-br from-brand-500 to-brand-700 rounded-xl flex items-center justify-center text-white shadow-glow-brand shrink-0">
|
|
<Wifi className="w-6 h-6" />
|
|
</div>
|
|
<h1 className="text-3xl font-black text-gray-800 tracking-tight">Instâncias</h1>
|
|
</div>
|
|
<p className="text-sm text-gray-500 font-medium mt-1 ml-[60px]">WhatsApp Multi-Device · Baileys</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-3 relative z-10">
|
|
<button onClick={refreshInstances} title="Recarregar"
|
|
className="p-3 bg-gray-50 border border-gray-200 rounded-xl text-gray-600 hover:text-gray-800 transition-all hover:bg-gray-100"
|
|
>
|
|
<RefreshCw className={`w-5 h-5 ${loading ? 'animate-spin' : ''}`} />
|
|
</button>
|
|
<button onClick={openAddModal}
|
|
className="flex items-center gap-2 px-5 py-3 bg-brand-500 hover:bg-brand-600 text-white font-bold rounded-xl transition-all shadow-glow-brand text-sm"
|
|
>
|
|
<Plus className="w-5 h-5" /> Nova Instância
|
|
</button>
|
|
</div>
|
|
</header>
|
|
|
|
{hasActiveSession && (
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }}
|
|
className="flex items-center gap-4 p-5 rounded-2xl bg-emerald-500/10 border border-emerald-500/20"
|
|
>
|
|
<div className="w-11 h-11 rounded-xl bg-emerald-500/15 flex items-center justify-center text-emerald-400 shrink-0">
|
|
<CheckCircle2 className="w-6 h-6" />
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<h2 className="text-base font-black text-emerald-300">Você já possui acesso ao WhatsApp</h2>
|
|
<p className="text-sm text-emerald-400/70 font-medium">Sua sessão está ativa e conectada — não é preciso parear novamente.</p>
|
|
</div>
|
|
{onNavigate && (
|
|
<button onClick={() => onNavigate('wa-inbox')}
|
|
className="shrink-0 flex items-center gap-2 px-4 py-2.5 bg-emerald-500 hover:bg-emerald-600 text-white font-bold rounded-xl transition-all text-sm"
|
|
>
|
|
Abrir conversas
|
|
</button>
|
|
)}
|
|
</motion.div>
|
|
)}
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
|
|
<AnimatePresence mode="popLayout">
|
|
{loading ? (
|
|
Array(3).fill(0).map((_, i) => (
|
|
<div key={i} className="h-56 rounded-[2rem] animate-pulse bg-gray-50" />
|
|
))
|
|
) : instances.length === 0 ? (
|
|
<motion.div key="empty" initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }}
|
|
className="col-span-full py-24 flex flex-col items-center justify-center text-center gap-6"
|
|
>
|
|
<div className="w-20 h-20 bg-gray-100 rounded-full flex items-center justify-center text-gray-500">
|
|
<Phone className="w-10 h-10" />
|
|
</div>
|
|
<div>
|
|
<h2 className="text-xl font-bold text-gray-800">Nenhuma instância</h2>
|
|
<p className="text-gray-500 mt-1 text-sm">Crie uma para começar a usar o WhatsApp.</p>
|
|
</div>
|
|
<button onClick={openAddModal}
|
|
className="flex items-center gap-2 px-6 py-3 bg-gray-50 hover:bg-gray-100 text-gray-800 font-bold rounded-2xl border border-gray-200 transition-all"
|
|
>
|
|
<Plus className="w-5 h-5" /> Criar instância
|
|
</button>
|
|
</motion.div>
|
|
) : (
|
|
instances.map((inst) => (
|
|
<SessionCard
|
|
key={inst.id}
|
|
inst={inst}
|
|
onConnect={() => handleConnect(inst)}
|
|
onDisconnect={() => handleDisconnect(inst)}
|
|
onDelete={() => setDeleteTarget(inst)}
|
|
onShowQr={() => handleShowQr(inst)}
|
|
/>
|
|
))
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
|
|
<AnimatePresence>
|
|
{addOpen && (
|
|
<div className="fixed inset-0 z-[110] flex items-center justify-center p-4">
|
|
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
|
|
onClick={closeAddModal} className="absolute inset-0 bg-black/80 backdrop-blur-sm cursor-pointer" />
|
|
<motion.div initial={{ opacity: 0, scale: 0.9, y: 20 }} animate={{ opacity: 1, scale: 1, y: 0 }} exit={{ opacity: 0, scale: 0.9, y: 20 }}
|
|
className="relative bg-gray-100 backdrop-blur-2xl p-8 rounded-[3rem] border border-gray-200 max-w-sm w-full space-y-6 shadow-2xl"
|
|
>
|
|
<div>
|
|
<h2 className="text-2xl font-black text-gray-800">Nova Instância</h2>
|
|
<p className="text-gray-500 text-sm mt-1">Dê um nome para identificar esta conexão.</p>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<label className="text-[10px] font-bold text-gray-500 uppercase tracking-widest">Nome</label>
|
|
<input
|
|
type="text"
|
|
value={newName}
|
|
onChange={(e) => setNewName(e.target.value)}
|
|
onKeyDown={(e) => { if (e.key === 'Enter') handleCreate(); }}
|
|
placeholder="ex: principal, comercial, suporte..."
|
|
autoFocus
|
|
className="w-full bg-gray-50 border border-gray-200 rounded-2xl px-4 py-3 text-gray-800 placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-brand-500/40 text-sm"
|
|
/>
|
|
</div>
|
|
<div className="flex gap-3">
|
|
<Button variant="ghost" onClick={closeAddModal} className="flex-1">
|
|
Cancelar
|
|
</Button>
|
|
<button onClick={handleCreate} disabled={creating || !newName.trim()}
|
|
className="flex-1 py-3 bg-emerald-500 hover:bg-emerald-600 text-white font-bold rounded-2xl transition-all disabled:opacity-50 flex items-center justify-center gap-2 text-sm"
|
|
>
|
|
{creating ? <Loader2 className="w-4 h-4 animate-spin" /> : <Plus className="w-4 h-4" />}
|
|
Criar e conectar
|
|
</button>
|
|
</div>
|
|
</motion.div>
|
|
</div>
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
<AnimatePresence>
|
|
{qrModal && (
|
|
<QrModal
|
|
instanceId={qrModal.instanceId}
|
|
instanceName={qrModal.instanceName}
|
|
qrBase64={qrModal.qrBase64}
|
|
onClose={closeQrModal}
|
|
onNewQr={handleNewQr}
|
|
/>
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
<AnimatePresence>
|
|
{deleteTarget && (
|
|
<ConfirmDeleteModal
|
|
inst={deleteTarget}
|
|
onClose={closeDeleteModal}
|
|
onConfirm={handleDelete}
|
|
loading={deleting}
|
|
/>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default SessionsView;
|