FEAT: Refatorado componentes dos chats, removido mock e adicionado marcador de data

This commit is contained in:
Rafael Alves Lopes 2026-05-22 14:38:16 -03:00
parent fbdbca7f20
commit 3343a12548
5 changed files with 306 additions and 223 deletions

View File

@ -110,7 +110,7 @@ function UnreadBadge({ count }) {
);
}
function SavedContactLabel({ contact }) {
function SavedContactIcon({ contact }) {
const profile = contact.contactProfile;
const hasSavedContact = Boolean(profile?.created_at || profile?.name || profile?.company || profile?.note);
if (!hasSavedContact) return null;
@ -118,12 +118,23 @@ function SavedContactLabel({ contact }) {
return (
<span
title="Contato salvo na agenda"
aria-label="Contato salvo na agenda"
style={{
width: 24,
height: 24,
borderRadius: '50%',
border: '1px solid rgba(183, 121, 31, 0.28)',
background: 'rgba(183, 121, 31, 0.1)',
backgroundImage:
"url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23b7791f' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M16 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2'/%3E%3Ccircle cx='10' cy='7' r='4'/%3E%3Cpath d='m17 11 2 2 4-4'/%3E%3C/svg%3E\")",
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat',
backgroundSize: 14,
color: '#b7791f',
flex: '0 0 auto',
fontSize: '0.72rem',
fontWeight: 800,
lineHeight: 1,
display: 'inline-grid',
placeItems: 'center',
fontSize: 0,
}}
>
Salvo
@ -213,9 +224,9 @@ export function ChatConversationList({
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '0.75rem' }}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '0.45rem', minWidth: 0 }}>
<SavedContactIcon contact={contact} />
<ChannelBadge channel={contact.channel} />
<SpecialtyBadge contact={contact} />
<SavedContactLabel contact={contact} />
</span>
<UnreadBadge count={contact.unread} />
</div>

View File

@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef } from 'react';
import { Fragment, useEffect, useMemo, useRef } from 'react';
function getMediaUrl(media) {
if (!media?.data || !media?.mimetype) return '';
@ -29,6 +29,68 @@ function formatMessageTime(timestamp) {
return date.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' });
}
function getMessageDate(timestamp) {
if (!timestamp) return null;
const numericTimestamp = Number(timestamp);
const date = new Date(numericTimestamp > 1000000000000 ? numericTimestamp : numericTimestamp * 1000);
if (Number.isNaN(date.getTime())) return null;
return date;
}
function getDateKey(timestamp) {
const date = getMessageDate(timestamp);
if (!date) return '';
return date.toISOString().slice(0, 10);
}
function formatDateSeparator(timestamp) {
const date = getMessageDate(timestamp);
if (!date) return '';
const today = new Date();
const isToday =
date.getFullYear() === today.getFullYear() &&
date.getMonth() === today.getMonth() &&
date.getDate() === today.getDate();
if (isToday) return 'Hoje';
return date.toLocaleDateString('pt-BR');
}
function DateSeparator({ label }) {
if (!label) return null;
return (
<div
style={{
width: '100%',
display: 'grid',
gridTemplateColumns: '1fr auto 1fr',
gap: '0.75rem',
alignItems: 'center',
color: 'var(--color-text-soft)',
fontSize: '0.78rem',
fontWeight: 800,
textTransform: 'uppercase',
}}
>
<span style={{ height: 1, background: 'var(--color-border)' }} />
<span
style={{
border: '1px solid var(--color-border)',
borderRadius: 999,
padding: '0.28rem 0.7rem',
background: 'rgba(255,255,255,0.88)',
}}
>
{label}
</span>
<span style={{ height: 1, background: 'var(--color-border)' }} />
</div>
);
}
function MediaRenderer({ message, contactId, onLoadMedia, isAgent }) {
const mediaUrl = useMemo(() => getMediaUrl(message.media), [message.media]);
const mimetype = message.media?.mimetype || '';
@ -414,91 +476,99 @@ export function ChatWindow({
'radial-gradient(circle at top left, rgba(0, 164, 183, 0.06), transparent 22%), linear-gradient(180deg, rgba(245, 248, 251, 0.8), rgba(255, 255, 255, 0.95))',
}}
>
{messages.map((message) => {
{messages.map((message, index) => {
const isAgent = message.sender === 'agent';
const isSystem = message.sender === 'system';
const parsedText = parseMessageText(message.text);
const messageTime = formatMessageTime(message.timestamp);
const dateKey = getDateKey(message.timestamp);
const previousDateKey = index > 0 ? getDateKey(messages[index - 1]?.timestamp) : '';
const shouldShowDateSeparator = dateKey && dateKey !== previousDateKey;
const dateSeparator = formatDateSeparator(message.timestamp);
if (isSystem) {
return (
<div
key={message.id}
style={{
justifySelf: 'center',
padding: '0.7rem 1rem',
borderRadius: '999px',
background: 'rgba(0, 49, 80, 0.08)',
color: 'var(--color-primary)',
fontSize: '0.88rem',
fontWeight: 600,
}}
>
{message.text}
</div>
<Fragment key={message.id}>
{shouldShowDateSeparator ? <DateSeparator label={dateSeparator} /> : null}
<div
style={{
justifySelf: 'center',
padding: '0.7rem 1rem',
borderRadius: '999px',
background: 'rgba(0, 49, 80, 0.08)',
color: 'var(--color-primary)',
fontSize: '0.88rem',
fontWeight: 600,
}}
>
{message.text}
</div>
</Fragment>
);
}
return (
<div
key={message.id}
style={{
justifySelf: isAgent ? 'end' : 'start',
maxWidth: isMobile ? '88%' : '72%',
padding: '0.95rem 1rem',
borderRadius: isAgent ? '18px 18px 6px 18px' : '18px 18px 18px 6px',
background: isAgent ? 'var(--color-primary)' : '#edf1f5',
color: isAgent ? '#fff' : 'var(--color-text)',
boxShadow: 'var(--shadow-md)',
display: 'grid',
gap: '0.65rem',
}}
>
<MediaRenderer
message={message}
contactId={safeContact.id}
onLoadMedia={onLoadMedia}
isAgent={isAgent}
/>
{parsedText.senderLabel ? (
<strong
style={{
display: 'block',
fontSize: '0.78rem',
lineHeight: 1.2,
letterSpacing: '0.02em',
textTransform: 'uppercase',
color: isAgent ? 'rgba(255,255,255,0.78)' : 'var(--color-primary)',
}}
>
{parsedText.senderLabel}
</strong>
) : null}
{parsedText.body ? (
<span
style={{
display: 'block',
whiteSpace: 'pre-wrap',
lineHeight: 1.45,
overflowWrap: 'anywhere',
}}
>
{parsedText.body}
</span>
) : null}
{messageTime ? (
<span
style={{
justifySelf: 'end',
fontSize: '0.72rem',
lineHeight: 1,
color: isAgent ? 'rgba(255,255,255,0.7)' : 'var(--color-text-soft)',
}}
>
{messageTime}
</span>
) : null}
</div>
<Fragment key={message.id}>
{shouldShowDateSeparator ? <DateSeparator label={dateSeparator} /> : null}
<div
style={{
justifySelf: isAgent ? 'end' : 'start',
maxWidth: isMobile ? '88%' : '72%',
padding: '0.95rem 1rem',
borderRadius: isAgent ? '18px 18px 6px 18px' : '18px 18px 18px 6px',
background: isAgent ? 'var(--color-primary)' : '#edf1f5',
color: isAgent ? '#fff' : 'var(--color-text)',
boxShadow: 'var(--shadow-md)',
display: 'grid',
gap: '0.65rem',
}}
>
<MediaRenderer
message={message}
contactId={safeContact.id}
onLoadMedia={onLoadMedia}
isAgent={isAgent}
/>
{parsedText.senderLabel ? (
<strong
style={{
display: 'block',
fontSize: '0.78rem',
lineHeight: 1.2,
letterSpacing: '0.02em',
textTransform: 'uppercase',
color: isAgent ? 'rgba(255,255,255,0.78)' : 'var(--color-primary)',
}}
>
{parsedText.senderLabel}
</strong>
) : null}
{parsedText.body ? (
<span
style={{
display: 'block',
whiteSpace: 'pre-wrap',
lineHeight: 1.45,
overflowWrap: 'anywhere',
}}
>
{parsedText.body}
</span>
) : null}
{messageTime ? (
<span
style={{
justifySelf: 'end',
fontSize: '0.72rem',
lineHeight: 1,
color: isAgent ? 'rgba(255,255,255,0.7)' : 'var(--color-text-soft)',
}}
>
{messageTime}
</span>
) : null}
</div>
</Fragment>
);
})}
@ -606,7 +676,7 @@ export function ChatWindow({
onChange={(event) => setDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
onSend();
onSend?.(draft);
}
}}
disabled={!safeContact.id || !canReply}
@ -633,7 +703,7 @@ export function ChatWindow({
/>
<button
type="button"
onClick={onSend}
onClick={() => onSend?.(draft)}
disabled={!safeContact.id || !canReply}
style={{
border: 'none',

View File

@ -3,14 +3,9 @@ import { useWhatsappSocket } from '../../../shared/hooks/useWhatsappSocket';
import { API_BASE_URL } from '../../../shared/services/apiConfig';
import { getAccessOptions, getAccessUsers } from '../../management/services/adminAccessService';
import { getCurrentUser, getCurrentUserProfile } from '../../auth/services/sessionService';
import { chatContacts, transferAreas as fallbackTransferAreas } from '../services/chatMocks';
import { transferAreas as fallbackTransferAreas } from '../services/chatMocks';
function buildInitialMessages() {
return chatContacts.reduce((acc, contact) => {
acc[contact.id] = contact.messages;
return acc;
}, {});
}
const MAX_ATTACHMENT_SIZE_BYTES = 15 * 1024 * 1024;
function getLastMessageFromMe(messages = []) {
const lastMessage = [...messages].reverse().find(isDisplayableMessage);
@ -145,15 +140,6 @@ function fileToBase64(file) {
});
}
function buildFallbackContacts() {
return chatContacts.map((contact) => ({
...contact,
assignment: null,
areaId: null,
lastMessageFromMe: getLastMessageFromMe(contact.messages),
}));
}
function normalizeComparableContact(contact) {
return {
id: contact.id,
@ -217,14 +203,14 @@ export function useChat() {
const currentUserAreas = getUserAreas(currentUser);
const isAdminUser = currentUserProfile === 'admin';
const { status: whatsappStatus, incomingMessage, clearIncomingMessage } = useWhatsappSocket();
const [contacts, setContacts] = useState(buildFallbackContacts);
const [activeContactId, setActiveContactId] = useState(chatContacts[0].id);
const [messagesByContact, setMessagesByContact] = useState(buildInitialMessages);
const [contacts, setContacts] = useState([]);
const [activeContactId, setActiveContactId] = useState('');
const [messagesByContact, setMessagesByContact] = useState({});
const [draft, setDraft] = useState('');
const [attachedFile, setAttachedFile] = useState(null);
const [areaOptions, setAreaOptions] = useState([]);
const [accessUsers, setAccessUsers] = useState([]);
const [selectedArea, setSelectedArea] = useState(chatContacts[0].area);
const [selectedArea, setSelectedArea] = useState('Sem fila');
const [isTransferOpen, setIsTransferOpen] = useState(false);
const [transferArea, setTransferArea] = useState(currentUserAreas[0] || 'Suporte');
const [transferAttendant, setTransferAttendant] = useState('');
@ -329,11 +315,9 @@ export function useChat() {
async function loadChats({ showLoading = false } = {}) {
if (whatsappStatus !== 'CONNECTED') {
const fallbackContacts = buildFallbackContacts();
setContacts((current) => (areContactListsEqual(current, fallbackContacts) ? current : fallbackContacts));
setActiveContactId((current) =>
fallbackContacts.some((contact) => contact.id === current) ? current : fallbackContacts[0]?.id,
);
setContacts((current) => (current.length ? [] : current));
setActiveContactId('');
setMessagesByContact({});
setIsLoadingChats(false);
return;
}
@ -502,12 +486,18 @@ export function useChat() {
async function attachFile(file) {
if (!file) return;
if (file.size > MAX_ATTACHMENT_SIZE_BYTES) {
setApiError('Arquivo muito grande. Envie uma mídia de até 15 MB.');
return;
}
const data = await fileToBase64(file);
setAttachedFile({
name: file.name,
type: file.type || 'application/octet-stream',
data,
});
setApiError(null);
}
function removeAttachedFile() {
@ -597,7 +587,8 @@ export function useChat() {
}
async function sendMessage(messageText = draft, contactId = activeContactId) {
const trimmed = String(messageText || '').trim();
const rawMessage = typeof messageText === 'string' ? messageText : draft;
const trimmed = rawMessage.trim();
if (!trimmed && !attachedFile) return;
const targetContact = contacts.find((contact) => contact.id === contactId) || activeContact;
@ -650,7 +641,7 @@ export function useChat() {
if (!contactId.includes('@')) return;
try {
await fetch(`${API_BASE_URL}/whatsapp/send`, {
const response = await fetch(`${API_BASE_URL}/whatsapp/send`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@ -660,6 +651,15 @@ export function useChat() {
media,
}),
});
if (!response.ok) {
const message =
response.status === 413
? 'Arquivo muito grande para envio. Tente uma mídia menor.'
: 'Não foi possível enviar a mensagem.';
throw new Error(message);
}
setApiError(null);
updateContact(contactId, (contact) => ({
...contact,

View File

@ -1,74 +1,7 @@
export const chatContacts = [
{
id: 'maria-souza',
name: 'Maria Souza',
channel: 'WhatsApp',
status: 'away',
area: 'Suporte',
lastSeen: 'Última atividade as 09:42',
preview: 'Preciso atualizar o cadastro do meu pedido.',
time: '09:42',
unread: 2,
messages: [
{ id: 1, sender: 'customer', text: 'Oi, bom dia! Preciso de ajuda com meu pedido.' },
{ id: 2, sender: 'agent', text: 'Bom dia, Maria! Claro, me conta o que aconteceu.' },
{ id: 3, sender: 'customer', text: 'Quero confirmar se o endereço foi alterado.' },
{ id: 4, sender: 'agent', text: 'Estou verificando aqui e te atualizo em instantes.' },
],
},
{
id: 'joao-pedro',
name: 'João Pedro',
channel: 'SMS',
status: 'offline',
area: 'Financeiro',
lastSeen: 'Última atividade as 08:15',
preview: 'Pode me ligar em 10 minutos?',
time: '08:15',
unread: 1,
messages: [
{ id: 1, sender: 'customer', text: 'Recebi a cobrança em duplicidade.' },
{ id: 2, sender: 'agent', text: 'Vou analisar isso agora para você.' },
{ id: 3, sender: 'customer', text: 'Pode me ligar em 10 minutos?' },
],
},
{
id: 'empresa-alpha',
name: 'Empresa Alpha',
channel: 'Email',
status: 'offline',
area: 'Comercial',
lastSeen: 'Visto ontem',
preview: 'Aguardando retorno sobre a proposta comercial.',
time: 'Ontem',
unread: 0,
messages: [
{ id: 1, sender: 'customer', text: 'Precisamos rever os valores da ultima proposta.' },
{ id: 2, sender: 'agent', text: 'Perfeito, vou encaminhar para o time comercial.' },
],
},
];
export const transferAreas = ['Suporte', 'Financeiro', 'Comercial'];
export const attendantsByArea = {
Suporte: ['Ana Camolesi', 'Rafael Lopes', 'Romero Britto'],
Financeiro: ['Roberto Pêra', 'Monica Limoeira', 'Edson Arantes'],
Comercial: ['Natasha Homanoff', 'Helena Pêra', 'Pedro Parque'],
};
export const quickReplies = [
'Recebi sua mensagem e já vou verificar.',
'Consegue me confirmar o número do protocolo?',
'Posso seguir com essa atualização por aqui.',
'Recebi sua mensagem e ja vou verificar.',
'Consegue me confirmar o numero do protocolo?',
'Posso seguir com essa atualizacao por aqui.',
];
export function getMockReply(contactName) {
const replies = [
`Perfeito, obrigado pelo retorno, ${contactName.split(' ')[0]}.`,
'Tudo bem, fico no aguardo dessa confirmação.',
'Entendi. Se precisar, posso encaminhar para a especialidade responsável.',
];
return replies[Math.floor(Math.random() * replies.length)];
}

View File

@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react';
import { Fragment, useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { createAgentNote, deleteAgentNote, listAgentNotes } from '../services/agentNotesService';
import { getCurrentUser } from '../../auth/services/sessionService';
@ -123,6 +123,68 @@ function formatMessageTime(timestamp) {
return date.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' });
}
function getMessageDate(timestamp) {
if (!timestamp) return null;
const numericTimestamp = Number(timestamp);
const date = new Date(numericTimestamp > 1000000000000 ? numericTimestamp : numericTimestamp * 1000);
if (Number.isNaN(date.getTime())) return null;
return date;
}
function getDateKey(timestamp) {
const date = getMessageDate(timestamp);
if (!date) return '';
return date.toISOString().slice(0, 10);
}
function formatDateSeparator(timestamp) {
const date = getMessageDate(timestamp);
if (!date) return '';
const today = new Date();
const isToday =
date.getFullYear() === today.getFullYear() &&
date.getMonth() === today.getMonth() &&
date.getDate() === today.getDate();
if (isToday) return 'Hoje';
return date.toLocaleDateString('pt-BR');
}
function DateSeparator({ label }) {
if (!label) return null;
return (
<div
style={{
width: '100%',
display: 'grid',
gridTemplateColumns: '1fr auto 1fr',
gap: '0.7rem',
alignItems: 'center',
color: 'var(--color-text-soft)',
fontSize: '0.74rem',
fontWeight: 800,
textTransform: 'uppercase',
}}
>
<span style={{ height: 1, background: 'var(--color-border)' }} />
<span
style={{
border: '1px solid var(--color-border)',
borderRadius: 999,
padding: '0.24rem 0.62rem',
background: 'rgba(255,255,255,0.9)',
}}
>
{label}
</span>
<span style={{ height: 1, background: 'var(--color-border)' }} />
</div>
);
}
function getUserId(user) {
const value = user?.databaseId || user?.id;
const numeric = Number(value);
@ -408,62 +470,69 @@ export function MessagesWorkspace({
'linear-gradient(180deg, rgba(245, 248, 251, 0.45), rgba(255, 255, 255, 0.9))',
}}
>
{safeActiveConversation.messages.map((message) => {
{safeActiveConversation.messages.map((message, index) => {
const isAgent = message.from === 'agent';
const parsedText = parseMessageText(message.text);
const messageTime = formatMessageTime(message.timestamp);
const dateKey = getDateKey(message.timestamp);
const previousDateKey =
index > 0 ? getDateKey(safeActiveConversation.messages[index - 1]?.timestamp) : '';
const shouldShowDateSeparator = dateKey && dateKey !== previousDateKey;
const dateSeparator = formatDateSeparator(message.timestamp);
return (
<div
key={message.id}
style={{
justifySelf: isAgent ? 'end' : 'start',
maxWidth: '72%',
padding: '0.95rem 1rem',
borderRadius: isAgent ? '18px 18px 6px 18px' : '18px 18px 18px 6px',
background: isAgent ? 'var(--color-primary)' : '#edf1f5',
color: isAgent ? '#fff' : 'var(--color-text)',
boxShadow: 'var(--shadow-md)',
display: 'grid',
gap: '0.55rem',
}}
>
{parsedText.senderLabel ? (
<strong
style={{
display: 'block',
fontSize: '0.76rem',
lineHeight: 1.2,
letterSpacing: '0.02em',
textTransform: 'uppercase',
color: isAgent ? 'rgba(255,255,255,0.78)' : 'var(--color-primary)',
}}
>
{parsedText.senderLabel}
</strong>
) : null}
<span
<Fragment key={message.id}>
{shouldShowDateSeparator ? <DateSeparator label={dateSeparator} /> : null}
<div
style={{
whiteSpace: 'pre-wrap',
lineHeight: 1.45,
overflowWrap: 'anywhere',
justifySelf: isAgent ? 'end' : 'start',
maxWidth: '72%',
padding: '0.95rem 1rem',
borderRadius: isAgent ? '18px 18px 6px 18px' : '18px 18px 18px 6px',
background: isAgent ? 'var(--color-primary)' : '#edf1f5',
color: isAgent ? '#fff' : 'var(--color-text)',
boxShadow: 'var(--shadow-md)',
display: 'grid',
gap: '0.55rem',
}}
>
{parsedText.body}
</span>
{messageTime ? (
{parsedText.senderLabel ? (
<strong
style={{
display: 'block',
fontSize: '0.76rem',
lineHeight: 1.2,
letterSpacing: '0.02em',
textTransform: 'uppercase',
color: isAgent ? 'rgba(255,255,255,0.78)' : 'var(--color-primary)',
}}
>
{parsedText.senderLabel}
</strong>
) : null}
<span
style={{
justifySelf: 'end',
fontSize: '0.72rem',
lineHeight: 1,
color: isAgent ? 'rgba(255,255,255,0.7)' : 'var(--color-text-soft)',
whiteSpace: 'pre-wrap',
lineHeight: 1.45,
overflowWrap: 'anywhere',
}}
>
{messageTime}
{parsedText.body}
</span>
) : null}
</div>
{messageTime ? (
<span
style={{
justifySelf: 'end',
fontSize: '0.72rem',
lineHeight: 1,
color: isAgent ? 'rgba(255,255,255,0.7)' : 'var(--color-text-soft)',
}}
>
{messageTime}
</span>
) : null}
</div>
</Fragment>
);
})}
</div>