2026-03-19 18:22:18 -03:00
|
|
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
2026-05-18 17:34:23 -03:00
|
|
|
import { useWhatsappSocket } from '../../../shared/hooks/useWhatsappSocket';
|
2026-05-19 09:27:02 -03:00
|
|
|
import { API_BASE_URL } from '../../../shared/services/apiConfig';
|
2026-03-19 18:22:18 -03:00
|
|
|
import {
|
|
|
|
|
attendantsByArea,
|
|
|
|
|
chatContacts,
|
|
|
|
|
transferAreas,
|
|
|
|
|
} from '../services/chatMocks';
|
|
|
|
|
|
|
|
|
|
function buildInitialMessages() {
|
|
|
|
|
return chatContacts.reduce((acc, contact) => {
|
|
|
|
|
acc[contact.id] = contact.messages;
|
|
|
|
|
return acc;
|
|
|
|
|
}, {});
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-18 17:34:23 -03:00
|
|
|
function getSerializedId(value) {
|
|
|
|
|
if (!value) return '';
|
|
|
|
|
if (typeof value === 'string') return value;
|
|
|
|
|
return value._serialized || `${value.user || ''}@${value.server || 'c.us'}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function formatTime(timestamp) {
|
|
|
|
|
if (!timestamp) return '';
|
|
|
|
|
const date = new Date(timestamp * 1000);
|
|
|
|
|
return date.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getContactName(chat) {
|
|
|
|
|
const serializedId = getSerializedId(chat.id);
|
|
|
|
|
return chat.name || chat.pushname || serializedId.split('@')[0] || 'Contato';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getPreviewFromMessage(message) {
|
|
|
|
|
if (message?.body) return message.body;
|
|
|
|
|
if (message?.text) return message.text;
|
|
|
|
|
if (message?.hasMedia || message?.media) return '[Midia]';
|
|
|
|
|
return '';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function normalizeChat(chat) {
|
|
|
|
|
const id = getSerializedId(chat.id);
|
2026-05-19 09:45:00 -03:00
|
|
|
const lastActivitySeconds = chat.timestamp ? Math.floor(Date.now() / 1000) - chat.timestamp : null;
|
|
|
|
|
const isRecentlyActive = lastActivitySeconds !== null && lastActivitySeconds < 300;
|
|
|
|
|
|
2026-05-18 17:34:23 -03:00
|
|
|
return {
|
|
|
|
|
id,
|
|
|
|
|
name: getContactName(chat),
|
|
|
|
|
channel: 'WhatsApp',
|
2026-05-19 09:45:00 -03:00
|
|
|
status: isRecentlyActive ? 'online' : 'away',
|
2026-05-18 17:34:23 -03:00
|
|
|
area: chat.assignment?.area_id ? String(chat.assignment.area_id) : 'Suporte',
|
2026-05-19 09:45:00 -03:00
|
|
|
lastSeen: isRecentlyActive
|
|
|
|
|
? 'Online agora'
|
|
|
|
|
: chat.timestamp
|
|
|
|
|
? `Visto as ${formatTime(chat.timestamp)}`
|
|
|
|
|
: 'Sem atividade recente',
|
2026-05-18 17:34:23 -03:00
|
|
|
preview: chat.preview || chat.lastMessage?.body || '',
|
|
|
|
|
time: formatTime(chat.timestamp) || 'Agora',
|
|
|
|
|
unread: chat.unreadCount || 0,
|
|
|
|
|
assignment: chat.assignment || null,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function normalizeMessage(message) {
|
|
|
|
|
const id = getSerializedId(message.id) || message.id || `msg-${Date.now()}`;
|
|
|
|
|
const sender = message.sender || (message.fromMe ? 'agent' : 'customer');
|
|
|
|
|
return {
|
|
|
|
|
id,
|
|
|
|
|
chatId: message.from || message.to || message.chatId,
|
|
|
|
|
sender,
|
|
|
|
|
text: message.body ?? message.text ?? '',
|
|
|
|
|
timestamp: message.timestamp,
|
|
|
|
|
hasMedia: Boolean(message.hasMedia || message.media),
|
|
|
|
|
media: message.media || null,
|
|
|
|
|
mediaLoading: false,
|
|
|
|
|
mediaError: null,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function fileToBase64(file) {
|
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
|
const reader = new FileReader();
|
|
|
|
|
reader.onload = () => {
|
|
|
|
|
const result = String(reader.result || '');
|
|
|
|
|
resolve(result.includes(',') ? result.split(',')[1] : result);
|
|
|
|
|
};
|
|
|
|
|
reader.onerror = reject;
|
|
|
|
|
reader.readAsDataURL(file);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function buildFallbackContacts() {
|
|
|
|
|
return chatContacts.map((contact) => ({ ...contact }));
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-19 18:22:18 -03:00
|
|
|
export function useChat() {
|
2026-05-18 17:34:23 -03:00
|
|
|
const { incomingMessage, clearIncomingMessage } = useWhatsappSocket();
|
|
|
|
|
const [contacts, setContacts] = useState(buildFallbackContacts);
|
2026-03-19 18:22:18 -03:00
|
|
|
const [activeContactId, setActiveContactId] = useState(chatContacts[0].id);
|
|
|
|
|
const [messagesByContact, setMessagesByContact] = useState(buildInitialMessages);
|
|
|
|
|
const [draft, setDraft] = useState('');
|
2026-05-18 17:34:23 -03:00
|
|
|
const [attachedFile, setAttachedFile] = useState(null);
|
2026-03-19 18:22:18 -03:00
|
|
|
const [selectedArea, setSelectedArea] = useState(chatContacts[0].area);
|
|
|
|
|
const [isTransferOpen, setIsTransferOpen] = useState(false);
|
|
|
|
|
const [transferArea, setTransferArea] = useState('Suporte');
|
|
|
|
|
const [transferAttendant, setTransferAttendant] = useState(attendantsByArea.Suporte[0]);
|
|
|
|
|
const [transferNote, setTransferNote] = useState('');
|
2026-05-18 17:34:23 -03:00
|
|
|
const [isReplying] = useState(false);
|
|
|
|
|
const [isLoadingChats, setIsLoadingChats] = useState(false);
|
|
|
|
|
const [isLoadingMessages, setIsLoadingMessages] = useState(false);
|
|
|
|
|
const [apiError, setApiError] = useState(null);
|
|
|
|
|
const activeContactRef = useRef(activeContactId);
|
2026-03-19 18:22:18 -03:00
|
|
|
|
|
|
|
|
const activeContact = useMemo(
|
|
|
|
|
() => contacts.find((contact) => contact.id === activeContactId) || contacts[0],
|
|
|
|
|
[contacts, activeContactId],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const messages = messagesByContact[activeContactId] || [];
|
|
|
|
|
const attendants = attendantsByArea[transferArea] || [];
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
setSelectedArea(activeContact.area);
|
|
|
|
|
}, [activeContact]);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
setTransferAttendant(attendants[0] || '');
|
|
|
|
|
}, [transferArea]);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
2026-05-18 17:34:23 -03:00
|
|
|
activeContactRef.current = activeContactId;
|
|
|
|
|
}, [activeContactId]);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
let isMounted = true;
|
|
|
|
|
|
|
|
|
|
async function loadChats() {
|
|
|
|
|
setIsLoadingChats(true);
|
|
|
|
|
try {
|
|
|
|
|
const response = await fetch(`${API_BASE_URL}/whatsapp/chats`);
|
|
|
|
|
if (!response.ok) throw new Error('Falha ao carregar chats do WhatsApp.');
|
|
|
|
|
const data = await response.json();
|
|
|
|
|
if (!isMounted || !Array.isArray(data) || data.length === 0) return;
|
|
|
|
|
|
|
|
|
|
const nextContacts = data.map(normalizeChat);
|
|
|
|
|
setContacts(nextContacts);
|
|
|
|
|
setActiveContactId((current) =>
|
|
|
|
|
nextContacts.some((contact) => contact.id === current) ? current : nextContacts[0].id,
|
|
|
|
|
);
|
|
|
|
|
setApiError(null);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
if (isMounted) setApiError(error.message);
|
|
|
|
|
} finally {
|
|
|
|
|
if (isMounted) setIsLoadingChats(false);
|
2026-03-19 18:22:18 -03:00
|
|
|
}
|
2026-05-18 17:34:23 -03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
loadChats();
|
|
|
|
|
const intervalId = window.setInterval(loadChats, 30000);
|
|
|
|
|
return () => {
|
|
|
|
|
isMounted = false;
|
|
|
|
|
window.clearInterval(intervalId);
|
2026-03-19 18:22:18 -03:00
|
|
|
};
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-05-18 17:34:23 -03:00
|
|
|
useEffect(() => {
|
|
|
|
|
if (!activeContactId) return;
|
|
|
|
|
let isMounted = true;
|
|
|
|
|
|
|
|
|
|
async function loadMessages() {
|
|
|
|
|
if (!activeContactId.includes('@')) return;
|
|
|
|
|
setIsLoadingMessages(true);
|
|
|
|
|
try {
|
|
|
|
|
const response = await fetch(`${API_BASE_URL}/whatsapp/messages/${encodeURIComponent(activeContactId)}`);
|
|
|
|
|
if (!response.ok) throw new Error('Falha ao carregar mensagens do WhatsApp.');
|
|
|
|
|
const data = await response.json();
|
|
|
|
|
if (!isMounted || !Array.isArray(data)) return;
|
|
|
|
|
setMessagesByContact((current) => ({
|
|
|
|
|
...current,
|
|
|
|
|
[activeContactId]: data.map((message) => ({
|
|
|
|
|
...normalizeMessage(message),
|
|
|
|
|
chatId: activeContactId,
|
|
|
|
|
})),
|
|
|
|
|
}));
|
|
|
|
|
setApiError(null);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
if (isMounted) setApiError(error.message);
|
|
|
|
|
} finally {
|
|
|
|
|
if (isMounted) setIsLoadingMessages(false);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
loadMessages();
|
|
|
|
|
return () => {
|
|
|
|
|
isMounted = false;
|
|
|
|
|
};
|
|
|
|
|
}, [activeContactId]);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!incomingMessage) return;
|
|
|
|
|
const contactId = incomingMessage.from || incomingMessage.to || incomingMessage.chatId;
|
|
|
|
|
if (!contactId) return;
|
|
|
|
|
|
|
|
|
|
const message = {
|
|
|
|
|
...normalizeMessage(incomingMessage),
|
|
|
|
|
chatId: contactId,
|
|
|
|
|
};
|
|
|
|
|
const preview = getPreviewFromMessage(message);
|
|
|
|
|
|
|
|
|
|
setMessagesByContact((current) => {
|
|
|
|
|
const currentMessages = current[contactId] || [];
|
|
|
|
|
if (currentMessages.some((item) => item.id === message.id)) return current;
|
|
|
|
|
return {
|
|
|
|
|
...current,
|
|
|
|
|
[contactId]: [...currentMessages, message],
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
setContacts((current) => {
|
|
|
|
|
const existing = current.find((contact) => contact.id === contactId);
|
|
|
|
|
const nextContact = {
|
|
|
|
|
...(existing || {
|
|
|
|
|
id: contactId,
|
|
|
|
|
name: incomingMessage.notifyName || contactId.split('@')[0],
|
|
|
|
|
channel: 'WhatsApp',
|
|
|
|
|
status: 'online',
|
|
|
|
|
area: 'Suporte',
|
|
|
|
|
lastSeen: 'Online agora',
|
|
|
|
|
unread: 0,
|
|
|
|
|
}),
|
|
|
|
|
preview,
|
|
|
|
|
time: 'Agora',
|
|
|
|
|
unread:
|
|
|
|
|
incomingMessage.fromMe || contactId === activeContactRef.current
|
|
|
|
|
? 0
|
|
|
|
|
: (existing?.unread || 0) + 1,
|
|
|
|
|
};
|
|
|
|
|
return [nextContact, ...current.filter((contact) => contact.id !== contactId)];
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
clearIncomingMessage();
|
|
|
|
|
}, [incomingMessage, clearIncomingMessage]);
|
|
|
|
|
|
|
|
|
|
function updateContactPreview(contactId, preview, media) {
|
2026-03-19 18:22:18 -03:00
|
|
|
setContacts((current) =>
|
|
|
|
|
current.map((contact) =>
|
2026-05-18 17:34:23 -03:00
|
|
|
contact.id === contactId
|
|
|
|
|
? { ...contact, preview: media ? `[Midia: ${media.filename || 'Arquivo'}]` : preview, time: 'Agora', unread: 0 }
|
|
|
|
|
: contact,
|
2026-03-19 18:22:18 -03:00
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-18 17:34:23 -03:00
|
|
|
async function attachFile(file) {
|
|
|
|
|
if (!file) return;
|
|
|
|
|
const data = await fileToBase64(file);
|
|
|
|
|
setAttachedFile({
|
|
|
|
|
name: file.name,
|
|
|
|
|
type: file.type || 'application/octet-stream',
|
|
|
|
|
data,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function removeAttachedFile() {
|
|
|
|
|
setAttachedFile(null);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function hydrateMessageMedia(contactId, messageId) {
|
|
|
|
|
if (!contactId || !messageId) return;
|
|
|
|
|
|
|
|
|
|
setMessagesByContact((current) => ({
|
|
|
|
|
...current,
|
|
|
|
|
[contactId]: (current[contactId] || []).map((message) =>
|
|
|
|
|
message.id === messageId ? { ...message, mediaLoading: true, mediaError: null } : message,
|
|
|
|
|
),
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const response = await fetch(
|
|
|
|
|
`${API_BASE_URL}/whatsapp/media/${encodeURIComponent(contactId)}/${encodeURIComponent(messageId)}`,
|
|
|
|
|
);
|
|
|
|
|
if (!response.ok) throw new Error('Falha ao carregar midia.');
|
|
|
|
|
const media = await response.json();
|
|
|
|
|
setMessagesByContact((current) => ({
|
|
|
|
|
...current,
|
|
|
|
|
[contactId]: (current[contactId] || []).map((message) =>
|
|
|
|
|
message.id === messageId ? { ...message, media, mediaLoading: false } : message,
|
|
|
|
|
),
|
|
|
|
|
}));
|
|
|
|
|
} catch (error) {
|
|
|
|
|
setMessagesByContact((current) => ({
|
|
|
|
|
...current,
|
|
|
|
|
[contactId]: (current[contactId] || []).map((message) =>
|
|
|
|
|
message.id === messageId
|
|
|
|
|
? { ...message, mediaLoading: false, mediaError: error.message || 'Erro ao carregar midia.' }
|
|
|
|
|
: message,
|
|
|
|
|
),
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function sendMessage() {
|
2026-03-19 18:22:18 -03:00
|
|
|
const trimmed = draft.trim();
|
2026-05-18 17:34:23 -03:00
|
|
|
if (!trimmed && !attachedFile) {
|
2026-03-19 18:22:18 -03:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-18 17:34:23 -03:00
|
|
|
const media = attachedFile
|
|
|
|
|
? {
|
|
|
|
|
data: attachedFile.data,
|
|
|
|
|
mimetype: attachedFile.type,
|
|
|
|
|
filename: attachedFile.name,
|
|
|
|
|
}
|
|
|
|
|
: null;
|
2026-03-19 18:22:18 -03:00
|
|
|
const newMessage = {
|
2026-05-18 17:34:23 -03:00
|
|
|
id: `temp-${Date.now()}`,
|
|
|
|
|
chatId: activeContactId,
|
2026-03-19 18:22:18 -03:00
|
|
|
sender: 'agent',
|
|
|
|
|
text: trimmed,
|
2026-05-18 17:34:23 -03:00
|
|
|
hasMedia: Boolean(media),
|
|
|
|
|
media,
|
2026-03-19 18:22:18 -03:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
setMessagesByContact((current) => ({
|
|
|
|
|
...current,
|
|
|
|
|
[activeContactId]: [...(current[activeContactId] || []), newMessage],
|
|
|
|
|
}));
|
2026-05-18 17:34:23 -03:00
|
|
|
updateContactPreview(activeContactId, trimmed || '[Midia]', media);
|
2026-03-19 18:22:18 -03:00
|
|
|
setDraft('');
|
2026-05-18 17:34:23 -03:00
|
|
|
setAttachedFile(null);
|
2026-03-19 18:22:18 -03:00
|
|
|
|
2026-05-18 17:34:23 -03:00
|
|
|
if (!activeContactId.includes('@')) return;
|
2026-03-19 18:22:18 -03:00
|
|
|
|
2026-05-18 17:34:23 -03:00
|
|
|
try {
|
|
|
|
|
await fetch(`${API_BASE_URL}/whatsapp/send`, {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
to: activeContactId,
|
|
|
|
|
message: trimmed,
|
|
|
|
|
media,
|
|
|
|
|
}),
|
|
|
|
|
});
|
|
|
|
|
setApiError(null);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
setApiError(error.message);
|
|
|
|
|
}
|
2026-03-19 18:22:18 -03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function submitTransfer() {
|
|
|
|
|
const note = transferNote.trim();
|
|
|
|
|
const transferMessage = note
|
|
|
|
|
? `Transferencia solicitada para ${transferArea} com ${transferAttendant}. Obs: ${note}`
|
|
|
|
|
: `Transferencia solicitada para ${transferArea} com ${transferAttendant}.`;
|
|
|
|
|
|
|
|
|
|
setMessagesByContact((current) => ({
|
|
|
|
|
...current,
|
|
|
|
|
[activeContactId]: [
|
|
|
|
|
...(current[activeContactId] || []),
|
|
|
|
|
{ id: Date.now() + 2, sender: 'system', text: transferMessage },
|
|
|
|
|
],
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
setContacts((current) =>
|
|
|
|
|
current.map((contact) =>
|
|
|
|
|
contact.id === activeContactId ? { ...contact, area: transferArea } : contact,
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
setSelectedArea(transferArea);
|
|
|
|
|
setIsTransferOpen(false);
|
|
|
|
|
setTransferNote('');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
contacts,
|
|
|
|
|
activeContact,
|
|
|
|
|
activeContactId,
|
|
|
|
|
setActiveContactId,
|
|
|
|
|
messages,
|
|
|
|
|
draft,
|
|
|
|
|
setDraft,
|
2026-05-18 17:34:23 -03:00
|
|
|
attachedFile,
|
|
|
|
|
attachFile,
|
|
|
|
|
removeAttachedFile,
|
2026-03-19 18:22:18 -03:00
|
|
|
sendMessage,
|
2026-05-18 17:34:23 -03:00
|
|
|
hydrateMessageMedia,
|
2026-03-19 18:22:18 -03:00
|
|
|
isReplying,
|
2026-05-18 17:34:23 -03:00
|
|
|
isLoadingChats,
|
|
|
|
|
isLoadingMessages,
|
|
|
|
|
apiError,
|
2026-03-19 18:22:18 -03:00
|
|
|
selectedArea,
|
|
|
|
|
setSelectedArea,
|
|
|
|
|
isTransferOpen,
|
|
|
|
|
setIsTransferOpen,
|
|
|
|
|
transferArea,
|
|
|
|
|
setTransferArea,
|
|
|
|
|
transferAreas,
|
|
|
|
|
attendants,
|
|
|
|
|
transferAttendant,
|
|
|
|
|
setTransferAttendant,
|
|
|
|
|
transferNote,
|
|
|
|
|
setTransferNote,
|
|
|
|
|
submitTransfer,
|
|
|
|
|
};
|
|
|
|
|
}
|