omnichannel-frontend/src/modules/chat/components/ChatWindow.jsx

611 lines
17 KiB
React
Raw Normal View History

import { useEffect, useMemo, useRef } from 'react';
function getMediaUrl(media) {
if (!media?.data || !media?.mimetype) return '';
return `data:${media.mimetype};base64,${media.data}`;
}
function parseMessageText(text) {
const rawText = String(text || '');
const match = rawText.match(/^\*(Atendente(?: virtual)?:\s*[^*]+)\*\s*\n+/i);
if (!match) {
return {
senderLabel: null,
body: rawText,
};
}
return {
senderLabel: match[1],
body: rawText.slice(match[0].length),
};
}
function MediaRenderer({ message, contactId, onLoadMedia, isAgent }) {
const mediaUrl = useMemo(() => getMediaUrl(message.media), [message.media]);
const mimetype = message.media?.mimetype || '';
const filename = message.media?.filename || 'arquivo';
useEffect(() => {
if (!message.hasMedia || message.media?.data || message.mediaLoading || message.mediaError) {
return;
}
onLoadMedia?.(contactId, message.id);
}, [contactId, message, onLoadMedia]);
if (!message.hasMedia && !message.media) return null;
if (message.mediaLoading || (!message.media?.data && !message.mediaError)) {
return (
<div
style={{
width: 260,
maxWidth: '100%',
height: 150,
borderRadius: 14,
background: isAgent ? 'rgba(255,255,255,0.18)' : 'rgba(0,49,80,0.08)',
display: 'grid',
placeItems: 'center',
color: isAgent ? '#fff' : 'var(--color-text-soft)',
fontWeight: 700,
}}
>
Carregando midia...
</div>
);
}
if (message.mediaError) {
return (
<span style={{ color: isAgent ? '#fff' : 'var(--color-text-soft)', fontWeight: 700 }}>
Nao foi possivel carregar a midia.
</span>
);
}
if (mimetype.startsWith('image/')) {
return (
<a href={mediaUrl} target="_blank" rel="noreferrer" style={{ display: 'block' }}>
<img
src={mediaUrl}
alt={filename}
style={{
display: 'block',
width: 280,
maxWidth: '100%',
maxHeight: 340,
objectFit: 'cover',
borderRadius: 14,
boxShadow: '0 14px 30px rgba(0,0,0,0.18)',
transition: 'transform 160ms ease',
}}
onMouseEnter={(event) => {
event.currentTarget.style.transform = 'scale(1.015)';
}}
onMouseLeave={(event) => {
event.currentTarget.style.transform = 'scale(1)';
}}
/>
</a>
);
}
if (mimetype.startsWith('video/')) {
return (
<video
src={mediaUrl}
controls
style={{
width: 320,
maxWidth: '100%',
borderRadius: 14,
background: '#111',
}}
/>
);
}
if (mimetype.startsWith('audio/') || mimetype.includes('ogg')) {
return <audio src={mediaUrl} controls style={{ width: 280, maxWidth: '100%' }} />;
}
return (
<a
href={mediaUrl}
download={filename}
style={{
display: 'grid',
gridTemplateColumns: 'auto 1fr',
gap: '0.75rem',
alignItems: 'center',
padding: '0.85rem',
borderRadius: 14,
background: isAgent ? 'rgba(255,255,255,0.16)' : '#fff',
color: isAgent ? '#fff' : 'var(--color-primary)',
fontWeight: 700,
}}
>
<span aria-hidden="true">📄</span>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{filename}</span>
</a>
);
}
function AttachmentPreview({ file, onRemove }) {
if (!file) return null;
const mediaUrl = getMediaUrl({ data: file.data, mimetype: file.type });
return (
<div
style={{
border: '1px solid var(--color-border)',
borderRadius: 16,
padding: '0.75rem',
display: 'grid',
gridTemplateColumns: 'auto 1fr auto',
gap: '0.75rem',
alignItems: 'center',
background: '#fff',
}}
>
{file.type?.startsWith('image/') ? (
<img
src={mediaUrl}
alt={file.name}
style={{ width: 54, height: 54, objectFit: 'cover', borderRadius: 12 }}
/>
) : (
<span
style={{
width: 54,
height: 54,
borderRadius: 12,
display: 'grid',
placeItems: 'center',
background: 'rgba(0,49,80,0.08)',
}}
aria-hidden="true"
>
📎
</span>
)}
<div style={{ minWidth: 0 }}>
<strong style={{ display: 'block', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{file.name}
</strong>
<span style={{ color: 'var(--color-text-soft)', fontSize: '0.86rem' }}>{file.type || 'arquivo'}</span>
</div>
<button
type="button"
onClick={onRemove}
title="Remover anexo"
style={{
border: 'none',
borderRadius: 12,
width: 36,
height: 36,
background: 'rgba(214, 40, 40, 0.1)',
color: '#b42318',
fontWeight: 900,
}}
>
x
</button>
</div>
);
}
function ContactPresence({ contact }) {
if (!contact) {
return null;
}
const status = contact.status || 'offline';
const color =
status === 'online'
? '#16a34a'
: status === 'away'
? '#e5a22a'
: '#dc2626';
const label = status === 'online' ? 'Online agora' : contact.lastSeen || 'Offline';
return (
<span
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '0.5rem',
color: 'var(--color-text-soft)',
}}
>
<span
aria-hidden="true"
style={{
width: 10,
height: 10,
borderRadius: 999,
background: color,
boxShadow: `0 0 0 3px ${color}22`,
}}
/>
{label}
</span>
);
}
export function ChatWindow({
contact,
messages,
selectedArea,
setSelectedArea,
draft,
setDraft,
attachedFile,
onAttachFile,
onRemoveAttachedFile,
onLoadMedia,
onSend,
onToggleTransfer,
onAssumeChat,
onReleaseChat,
canAssumeChat = false,
canReply = true,
assignmentLabel,
isReplying,
isMobile = false,
}) {
const messagesRef = useRef(null);
const safeContact = contact || {
id: '',
name: 'Nenhuma conversa ativa',
status: 'offline',
lastSeen: 'Aguardando fila do Omnino',
};
useEffect(() => {
const container = messagesRef.current;
if (!container) {
return;
}
container.scrollTo({
top: container.scrollHeight,
behavior: 'smooth',
});
}, [messages, isReplying]);
return (
<section
style={{
background: '#fff',
border: '1px solid var(--color-border)',
borderRadius: '28px',
overflow: 'hidden',
display: 'grid',
gridTemplateRows: 'auto minmax(0, 1fr) auto',
height: isMobile ? 'auto' : 'min(760px, calc(100vh - 190px))',
minHeight: isMobile ? 640 : 0,
minWidth: 0,
}}
>
<header
style={{
padding: '1.25rem 1.5rem',
borderBottom: '1px solid var(--color-border)',
display: 'grid',
gridTemplateColumns: isMobile ? '1fr' : 'minmax(0, 1fr) auto',
gap: '1rem',
alignItems: 'center',
}}
>
<div>
<strong style={{ display: 'block', fontSize: '1.15rem' }}>{safeContact.name}</strong>
<ContactPresence contact={safeContact} />
</div>
<div
style={{
display: 'flex',
gap: '0.7rem',
flexWrap: 'wrap',
justifyContent: isMobile ? 'stretch' : 'flex-end',
}}
>
<select
value={selectedArea}
onChange={(event) => setSelectedArea(event.target.value)}
disabled
style={{
border: '1px solid var(--color-border)',
borderRadius: '14px',
padding: '0.8rem 0.95rem',
background: '#fff',
fontWeight: 600,
}}
>
<option>{selectedArea}</option>
<option>Suporte</option>
<option>Financeiro</option>
<option>Comercial</option>
</select>
{canAssumeChat ? (
<button
type="button"
onClick={onAssumeChat}
style={{
border: 'none',
borderRadius: '14px',
padding: '0.8rem 1rem',
background: 'linear-gradient(135deg, var(--color-primary), #0b5a86)',
color: '#fff',
fontWeight: 700,
}}
>
Assumir atendimento
</button>
) : null}
{canReply ? (
<button
type="button"
onClick={onReleaseChat}
style={{
border: '1px solid var(--color-border)',
borderRadius: '14px',
padding: '0.8rem 1rem',
background: '#fff',
color: 'var(--color-primary)',
fontWeight: 700,
}}
>
Sair do atendimento
</button>
) : null}
<button
type="button"
onClick={onToggleTransfer}
disabled={!canReply}
style={{
border: 'none',
borderRadius: '14px',
padding: '0.8rem 1rem',
background: 'rgba(0, 49, 80, 0.08)',
color: 'var(--color-primary)',
fontWeight: 700,
opacity: canReply ? 1 : 0.55,
}}
>
Transferir
</button>
</div>
</header>
<div
ref={messagesRef}
style={{
padding: '1.5rem',
display: 'grid',
gap: '0.9rem',
alignContent: 'start',
overflowY: 'auto',
minHeight: 0,
background:
'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) => {
const isAgent = message.sender === 'agent';
const isSystem = message.sender === 'system';
const parsedText = parseMessageText(message.text);
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>
);
}
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}
</div>
);
})}
{messages.length === 0 ? (
<div
style={{
justifySelf: 'center',
padding: '0.8rem 1rem',
borderRadius: 16,
background: 'rgba(0,49,80,0.06)',
color: 'var(--color-text-soft)',
fontWeight: 700,
}}
>
Nenhuma mensagem carregada.
</div>
) : null}
{isReplying ? (
<div
style={{
justifySelf: 'start',
padding: '0.8rem 0.95rem',
borderRadius: '18px 18px 18px 6px',
background: '#edf1f5',
color: 'var(--color-text-soft)',
fontWeight: 600,
}}
>
Digitando...
</div>
) : null}
</div>
<footer
style={{
padding: '1rem 1.25rem 1.25rem',
borderTop: '1px solid var(--color-border)',
display: 'grid',
gridTemplateColumns: '1fr',
gap: '0.75rem',
}}
>
<AttachmentPreview file={attachedFile} onRemove={onRemoveAttachedFile} />
{!canReply ? (
<div
style={{
border: '1px solid var(--color-border)',
borderRadius: 16,
padding: '0.8rem 1rem',
background: 'rgba(0, 49, 80, 0.04)',
color: 'var(--color-text-soft)',
fontWeight: 700,
}}
>
{canAssumeChat
? 'Este atendimento esta na fila. Assuma para responder, ou envie uma mensagem para assumir automaticamente.'
: assignmentLabel || 'Este atendimento esta atribuido a outro usuario.'}
</div>
) : null}
<div
style={{
display: 'grid',
gridTemplateColumns: isMobile ? 'auto 1fr' : 'auto 1fr auto',
gap: '0.75rem',
alignItems: 'center',
}}
>
<label
title="Anexar arquivo"
style={{
border: '1px solid var(--color-border)',
borderRadius: 16,
width: 48,
height: 48,
display: 'grid',
placeItems: 'center',
background: '#fff',
cursor: 'pointer',
fontWeight: 900,
}}
>
📎
<input
type="file"
accept="image/png,image/jpeg,image/jpg,image/webp,video/mp4,video/webm,audio/mp3,audio/mpeg,audio/ogg,audio/wav,application/pdf"
onChange={(event) => {
onAttachFile?.(event.target.files?.[0]);
event.target.value = '';
}}
style={{ display: 'none' }}
disabled={!safeContact.id}
/>
</label>
<input
type="text"
value={draft}
onChange={(event) => setDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
onSend();
}
}}
disabled={!safeContact.id || (!canReply && !canAssumeChat)}
placeholder={
!safeContact.id
? 'Aguardando conversa entrar em uma fila'
: canReply || canAssumeChat
? 'Escreva sua mensagem...'
: 'Atendimento bloqueado para resposta'
}
style={{
border: '1px solid var(--color-border)',
borderRadius: '18px',
padding: '0.95rem 1rem',
background: '#fff',
outline: 'none',
minWidth: 0,
opacity: safeContact.id && (canReply || canAssumeChat) ? 1 : 0.6,
}}
/>
<button
type="button"
onClick={onSend}
disabled={!safeContact.id || (!canReply && !canAssumeChat)}
style={{
border: 'none',
borderRadius: '18px',
padding: '0.95rem 1.2rem',
background: 'linear-gradient(135deg, var(--color-primary), #0b5a86)',
color: '#fff',
fontWeight: 700,
gridColumn: isMobile ? '1 / -1' : 'auto',
opacity: safeContact.id && (canReply || canAssumeChat) ? 1 : 0.6,
}}
>
Enviar
</button>
</div>
</footer>
</section>
);
}