1286 lines
41 KiB
Svelte
1286 lines
41 KiB
Svelte
<script lang="ts">
|
|
import {
|
|
chatAberto,
|
|
chatMinimizado,
|
|
conversaAtiva,
|
|
fecharChat,
|
|
minimizarChat,
|
|
maximizarChat,
|
|
abrirChat,
|
|
abrirConversa
|
|
} from '$lib/stores/chatStore';
|
|
import { useQuery } from 'convex-svelte';
|
|
import { api } from '@sgse-app/backend/convex/_generated/api';
|
|
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
|
import ChatList from './ChatList.svelte';
|
|
import ChatWindow from './ChatWindow.svelte';
|
|
|
|
import { SvelteSet } from 'svelte/reactivity';
|
|
|
|
const count = useQuery(api.chat.contarNotificacoesNaoLidas, {});
|
|
|
|
// Query para verificar o ID do usuário logado (usar como referência)
|
|
const meuPerfilQuery = useQuery(api.usuarios.obterPerfil, {});
|
|
// Usuário atual
|
|
const currentUser = useQuery(api.auth.getCurrentUser, {});
|
|
|
|
let isOpen = $derived(false);
|
|
let isMinimized = $derived(false);
|
|
let activeConversation = $state<string | null>(null);
|
|
|
|
// Função para obter a URL do avatar/foto do usuário logado
|
|
const avatarUrlDoUsuario = $derived(() => {
|
|
const usuario = currentUser?.data;
|
|
if (!usuario) return null;
|
|
|
|
// Prioridade: fotoPerfilUrl > avatar > fallback com nome
|
|
if (usuario.fotoPerfilUrl) {
|
|
return usuario.fotoPerfilUrl;
|
|
}
|
|
|
|
// Fallback: retornar null para usar o ícone User do Lucide
|
|
return null;
|
|
});
|
|
|
|
// Posição do widget (arrastável)
|
|
// Inicializar posição como null para indicar que precisa ser calculada
|
|
let position = $state<{ x: number; y: number } | null>(null);
|
|
let isDragging = $state(false);
|
|
let dragStart = $state({ x: 0, y: 0 });
|
|
let isAnimating = $state(false);
|
|
let dragThreshold = $state(5); // Distância mínima em pixels para considerar arrastar
|
|
let hasMoved = $state(false); // Flag para verificar se houve movimento durante o arrastar
|
|
let shouldPreventClick = $state(false); // Flag para prevenir clique após arrastar
|
|
|
|
// Tamanho da janela (redimensionável)
|
|
const MIN_WIDTH = 300;
|
|
const MAX_WIDTH = 1200;
|
|
const MIN_HEIGHT = 400;
|
|
const MAX_HEIGHT = 900;
|
|
const DEFAULT_WIDTH = 440;
|
|
const DEFAULT_HEIGHT = 680;
|
|
|
|
// Carregar tamanho salvo do localStorage ou usar padrão
|
|
function getSavedSize() {
|
|
if (typeof window === 'undefined') return { width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT };
|
|
const saved = localStorage.getItem('chat-window-size');
|
|
if (saved) {
|
|
try {
|
|
const parsed = JSON.parse(saved);
|
|
return {
|
|
width: Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, parsed.width || DEFAULT_WIDTH)),
|
|
height: Math.max(MIN_HEIGHT, Math.min(MAX_HEIGHT, parsed.height || DEFAULT_HEIGHT))
|
|
};
|
|
} catch {
|
|
return { width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT };
|
|
}
|
|
}
|
|
return { width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT };
|
|
}
|
|
|
|
let windowSize = $state(getSavedSize());
|
|
let isMaximized = $state(false);
|
|
let previousSize = $state({ width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT });
|
|
let previousPosition = $state<{ x: number; y: number } | null>(null);
|
|
|
|
// Dimensões da janela (reativo)
|
|
let windowDimensions = $state({ width: 0, height: 0 });
|
|
|
|
// Atualizar dimensões da janela
|
|
function updateWindowDimensions() {
|
|
if (typeof window !== 'undefined') {
|
|
windowDimensions = {
|
|
width: window.innerWidth,
|
|
height: window.innerHeight
|
|
};
|
|
}
|
|
}
|
|
|
|
// Inicializar e atualizar dimensões da janela
|
|
$effect(() => {
|
|
if (typeof window === 'undefined') return;
|
|
|
|
updateWindowDimensions();
|
|
|
|
// Inicializar posição apenas uma vez quando as dimensões estiverem disponíveis
|
|
if (position === null) {
|
|
const saved = localStorage.getItem('chat-widget-position');
|
|
if (saved) {
|
|
try {
|
|
const parsed = JSON.parse(saved);
|
|
position = parsed;
|
|
} catch {
|
|
// Se falhar ao parsear, usar posição padrão no canto inferior direito
|
|
position = {
|
|
x: window.innerWidth - 72 - 24,
|
|
y: window.innerHeight - 72 - 24
|
|
};
|
|
}
|
|
} else {
|
|
// Posição padrão: canto inferior direito
|
|
position = {
|
|
x: window.innerWidth - 72 - 24,
|
|
y: window.innerHeight - 72 - 24
|
|
};
|
|
}
|
|
savePosition(); // Salvar posição inicial
|
|
}
|
|
|
|
const handleResize = () => {
|
|
updateWindowDimensions();
|
|
// Ajustar posição quando a janela redimensionar
|
|
if (position) {
|
|
ajustarPosicao();
|
|
}
|
|
};
|
|
|
|
window.addEventListener('resize', handleResize);
|
|
|
|
return () => {
|
|
window.removeEventListener('resize', handleResize);
|
|
};
|
|
});
|
|
|
|
// Salvar posição no localStorage
|
|
function savePosition() {
|
|
if (typeof window !== 'undefined' && position) {
|
|
localStorage.setItem('chat-widget-position', JSON.stringify(position));
|
|
}
|
|
}
|
|
|
|
// Salvar tamanho no localStorage
|
|
function saveSize() {
|
|
if (typeof window !== 'undefined') {
|
|
localStorage.setItem('chat-window-size', JSON.stringify(windowSize));
|
|
}
|
|
}
|
|
|
|
// Redimensionamento
|
|
let isResizing = $state(false);
|
|
let resizeStart = $state({ x: 0, y: 0, width: 0, height: 0 });
|
|
let resizeDirection = $state<string | null>(null); // 'n', 's', 'e', 'w', 'ne', 'nw', 'se', 'sw'
|
|
|
|
function handleResizeStart(e: MouseEvent | KeyboardEvent, direction: string) {
|
|
if (!(e instanceof MouseEvent) || e.button !== 0) return;
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
isResizing = true;
|
|
resizeDirection = direction;
|
|
resizeStart = {
|
|
x: e.clientX,
|
|
y: e.clientY,
|
|
width: windowSize.width,
|
|
height: windowSize.height
|
|
};
|
|
document.body.classList.add('resizing');
|
|
}
|
|
|
|
function handleResizeMove(e: MouseEvent) {
|
|
if (!isResizing || !resizeDirection || !position) return;
|
|
|
|
const deltaX = e.clientX - resizeStart.x;
|
|
const deltaY = e.clientY - resizeStart.y;
|
|
|
|
let newWidth = resizeStart.width;
|
|
let newHeight = resizeStart.height;
|
|
let newX = position.x;
|
|
let newY = position.y;
|
|
|
|
// Redimensionar baseado na direção
|
|
if (resizeDirection.includes('e')) {
|
|
newWidth = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, resizeStart.width + deltaX));
|
|
}
|
|
if (resizeDirection.includes('w')) {
|
|
const calculatedWidth = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, resizeStart.width - deltaX));
|
|
const widthDelta = resizeStart.width - calculatedWidth;
|
|
newWidth = calculatedWidth;
|
|
newX = position.x + widthDelta;
|
|
}
|
|
if (resizeDirection.includes('s')) {
|
|
newHeight = Math.max(MIN_HEIGHT, Math.min(MAX_HEIGHT, resizeStart.height + deltaY));
|
|
}
|
|
if (resizeDirection.includes('n')) {
|
|
const calculatedHeight = Math.max(
|
|
MIN_HEIGHT,
|
|
Math.min(MAX_HEIGHT, resizeStart.height - deltaY)
|
|
);
|
|
const heightDelta = resizeStart.height - calculatedHeight;
|
|
newHeight = calculatedHeight;
|
|
newY = position.y + heightDelta;
|
|
}
|
|
|
|
windowSize = { width: newWidth, height: newHeight };
|
|
position = { x: newX, y: newY };
|
|
}
|
|
|
|
function handleResizeEnd() {
|
|
if (isResizing) {
|
|
isResizing = false;
|
|
resizeDirection = null;
|
|
document.body.classList.remove('resizing');
|
|
saveSize();
|
|
ajustarPosicao();
|
|
}
|
|
}
|
|
|
|
// Sincronizar com stores
|
|
$effect(() => {
|
|
isOpen = $chatAberto;
|
|
});
|
|
|
|
$effect(() => {
|
|
isMinimized = $chatMinimizado;
|
|
});
|
|
|
|
$effect(() => {
|
|
activeConversation = $conversaAtiva;
|
|
|
|
// Quando uma conversa é aberta, marcar suas mensagens como visualizadas
|
|
// para evitar notificações repetidas quando a conversa já está aberta
|
|
if (activeConversation && todasConversas?.data && currentUser?.data?._id) {
|
|
const conversas = todasConversas.data as ConversaComTimestamp[];
|
|
const conversaAberta = conversas.find((c) => String(c._id) === String(activeConversation));
|
|
|
|
if (conversaAberta && conversaAberta.ultimaMensagemTimestamp) {
|
|
const mensagemId = `${conversaAberta._id}-${conversaAberta.ultimaMensagemTimestamp}`;
|
|
if (!mensagensNotificadasGlobal.has(mensagemId)) {
|
|
mensagensNotificadasGlobal.add(mensagemId);
|
|
salvarMensagensNotificadasGlobal();
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
// Ajustar posição quando a janela é aberta pela primeira vez
|
|
let wasPreviouslyClosed = $state(true);
|
|
$effect(() => {
|
|
if (isOpen && !isMinimized && position && wasPreviouslyClosed) {
|
|
// Quando a janela é aberta, recalcular posição para garantir que fique visível
|
|
const winHeight =
|
|
windowDimensions.height || (typeof window !== 'undefined' ? window.innerHeight : 0);
|
|
const winWidth =
|
|
windowDimensions.width || (typeof window !== 'undefined' ? window.innerWidth : 0);
|
|
const widgetHeight = windowSize.height;
|
|
const widgetWidth = windowSize.width;
|
|
|
|
// Calcular limites válidos para a janela grande
|
|
const minY = -(widgetHeight - 100);
|
|
const maxY = Math.max(0, winHeight - 100);
|
|
const minX = -(widgetWidth - 100);
|
|
const maxX = Math.max(0, winWidth - 100);
|
|
|
|
// Recalcular posição Y: tentar manter próximo ao canto inferior direito mas ajustar se necessário
|
|
let newY = position.y;
|
|
// Se a posição Y estava calculada para um botão pequeno (72px), ajustar para janela grande
|
|
// Ajustar para manter aproximadamente a mesma distância do canto inferior
|
|
if (position.y > maxY || position.y < minY) {
|
|
// Se estava muito baixo (valor grande), ajustar para uma posição válida
|
|
newY = Math.max(minY, Math.min(maxY, winHeight - widgetHeight - 24));
|
|
}
|
|
|
|
// Garantir que X também está dentro dos limites
|
|
let newX = Math.max(minX, Math.min(maxX, position.x));
|
|
|
|
// Aplicar novos valores apenas se necessário
|
|
if (newX !== position.x || newY !== position.y) {
|
|
position = { x: newX, y: newY };
|
|
savePosition();
|
|
// Forçar ajuste imediatamente
|
|
ajustarPosicao();
|
|
}
|
|
|
|
wasPreviouslyClosed = false;
|
|
} else if (!isOpen || isMinimized) {
|
|
wasPreviouslyClosed = true;
|
|
}
|
|
});
|
|
|
|
// Tipos para conversas
|
|
type ConversaComTimestamp = {
|
|
_id: string;
|
|
ultimaMensagemTimestamp?: number;
|
|
ultimaMensagemRemetenteId?: string; // ID do remetente da última mensagem
|
|
ultimaMensagem?: string;
|
|
nome?: string;
|
|
outroUsuario?: { nome: string };
|
|
};
|
|
|
|
// Detectar novas mensagens globalmente (mesmo quando chat está fechado/minimizado)
|
|
const todasConversas = useQuery(api.chat.listarConversas, {});
|
|
let mensagensNotificadasGlobal = new SvelteSet<string>();
|
|
let showGlobalNotificationPopup = $state(false);
|
|
let globalNotificationMessage = $state<{
|
|
remetente: string;
|
|
conteudo: string;
|
|
conversaId: string;
|
|
} | null>(null);
|
|
let globalNotificationTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
// Carregar mensagens já notificadas do localStorage ao montar
|
|
let mensagensCarregadasGlobal = $state(false);
|
|
|
|
$effect(() => {
|
|
if (typeof window !== 'undefined' && !mensagensCarregadasGlobal) {
|
|
const saved = localStorage.getItem('chat-mensagens-notificadas-global');
|
|
if (saved) {
|
|
try {
|
|
const ids = JSON.parse(saved) as string[];
|
|
mensagensNotificadasGlobal = new SvelteSet(ids);
|
|
} catch {
|
|
mensagensNotificadasGlobal = new SvelteSet();
|
|
}
|
|
}
|
|
mensagensCarregadasGlobal = true;
|
|
|
|
// Marcar todas as mensagens atuais como já visualizadas (não tocar beep ao abrir)
|
|
if (todasConversas?.data) {
|
|
const conversas = todasConversas.data as ConversaComTimestamp[];
|
|
conversas.forEach((conv) => {
|
|
if (conv.ultimaMensagemTimestamp) {
|
|
const mensagemId = `${conv._id}-${conv.ultimaMensagemTimestamp}`;
|
|
mensagensNotificadasGlobal.add(mensagemId);
|
|
}
|
|
});
|
|
salvarMensagensNotificadasGlobal();
|
|
}
|
|
}
|
|
});
|
|
|
|
// Salvar mensagens notificadas no localStorage
|
|
function salvarMensagensNotificadasGlobal() {
|
|
if (typeof window !== 'undefined') {
|
|
const ids = Array.from(mensagensNotificadasGlobal);
|
|
// Limitar a 1000 IDs para não encher o localStorage
|
|
const idsLimitados = ids.slice(-1000);
|
|
localStorage.setItem('chat-mensagens-notificadas-global', JSON.stringify(idsLimitados));
|
|
}
|
|
}
|
|
|
|
// Função para tocar som de notificação
|
|
function tocarSomNotificacaoGlobal() {
|
|
try {
|
|
const AudioContextClass =
|
|
window.AudioContext ||
|
|
(window as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
|
|
if (!AudioContextClass) return;
|
|
|
|
const audioContext = new AudioContextClass();
|
|
if (audioContext.state === 'suspended') {
|
|
audioContext
|
|
.resume()
|
|
.then(() => {
|
|
const oscillator = audioContext.createOscillator();
|
|
const gainNode = audioContext.createGain();
|
|
oscillator.connect(gainNode);
|
|
gainNode.connect(audioContext.destination);
|
|
oscillator.frequency.value = 800;
|
|
oscillator.type = 'sine';
|
|
gainNode.gain.setValueAtTime(0.2, audioContext.currentTime);
|
|
gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.3);
|
|
oscillator.start(audioContext.currentTime);
|
|
oscillator.stop(audioContext.currentTime + 0.3);
|
|
})
|
|
.catch(() => {});
|
|
} else {
|
|
const oscillator = audioContext.createOscillator();
|
|
const gainNode = audioContext.createGain();
|
|
oscillator.connect(gainNode);
|
|
gainNode.connect(audioContext.destination);
|
|
oscillator.frequency.value = 800;
|
|
oscillator.type = 'sine';
|
|
gainNode.gain.setValueAtTime(0.2, audioContext.currentTime);
|
|
gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.3);
|
|
oscillator.start(audioContext.currentTime);
|
|
oscillator.stop(audioContext.currentTime + 0.3);
|
|
}
|
|
} catch {
|
|
// Ignorar erro de áudio
|
|
}
|
|
}
|
|
|
|
$effect(() => {
|
|
if (todasConversas?.data && currentUser?.data?._id) {
|
|
const conversas = todasConversas.data as ConversaComTimestamp[];
|
|
|
|
// Encontrar conversas com novas mensagens
|
|
// Obter ID do usuário logado de forma robusta
|
|
// Prioridade: usar query do Convex (mais confiável) > authStore
|
|
const usuarioLogado = currentUser?.data;
|
|
const perfilConvex = meuPerfilQuery?.data;
|
|
|
|
// Usar ID do Convex se disponível, caso contrário usar authStore
|
|
let meuId: string | null = null;
|
|
|
|
if (perfilConvex && perfilConvex._id) {
|
|
// Usar ID retornado pela query do Convex (mais confiável)
|
|
meuId = String(perfilConvex._id).trim();
|
|
} else if (usuarioLogado && usuarioLogado._id) {
|
|
// Fallback para authStore
|
|
meuId = String(usuarioLogado._id).trim();
|
|
}
|
|
|
|
if (!meuId) {
|
|
console.warn('⚠️ [ChatWidget] Não foi possível identificar o ID do usuário logado:', {
|
|
currentUser: !!usuarioLogado,
|
|
currentUserId: usuarioLogado?._id,
|
|
convexPerfil: !!perfilConvex,
|
|
convexId: perfilConvex?._id
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Log para debug (apenas em desenvolvimento)
|
|
if (import.meta.env.DEV) {
|
|
console.log('🔍 [ChatWidget] Usuário logado identificado:', {
|
|
id: meuId,
|
|
fonte: perfilConvex ? 'Convex Query' : 'CurrentUser',
|
|
nome: usuarioLogado?.nome || perfilConvex?.nome,
|
|
email: usuarioLogado?.email
|
|
});
|
|
}
|
|
|
|
conversas.forEach((conv) => {
|
|
if (!conv.ultimaMensagemTimestamp) return;
|
|
|
|
// Verificar se a última mensagem foi enviada pelo usuário atual
|
|
// Comparação mais robusta: normalizar ambos os IDs para string e comparar
|
|
const remetenteIdStr = conv.ultimaMensagemRemetenteId
|
|
? String(conv.ultimaMensagemRemetenteId).trim()
|
|
: null;
|
|
|
|
// Log para debug da comparação (apenas em desenvolvimento)
|
|
if (import.meta.env.DEV && remetenteIdStr) {
|
|
const ehMinhaMensagem = remetenteIdStr === meuId;
|
|
if (ehMinhaMensagem) {
|
|
console.log('✅ [ChatWidget] Mensagem identificada como própria (ignorada):', {
|
|
conversaId: conv._id,
|
|
meuId,
|
|
remetenteId: remetenteIdStr,
|
|
mensagem: conv.ultimaMensagem?.substring(0, 50)
|
|
});
|
|
}
|
|
}
|
|
|
|
// Se a mensagem foi enviada pelo próprio usuário, ignorar completamente
|
|
if (remetenteIdStr && remetenteIdStr === meuId) {
|
|
// Mensagem enviada pelo próprio usuário - não tocar beep nem mostrar notificação
|
|
// Marcar como notificada para evitar processamento futuro
|
|
const mensagemId = `${conv._id}-${conv.ultimaMensagemTimestamp}`;
|
|
if (!mensagensNotificadasGlobal.has(mensagemId)) {
|
|
mensagensNotificadasGlobal.add(mensagemId);
|
|
salvarMensagensNotificadasGlobal();
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Criar ID único para esta mensagem: conversaId-timestamp
|
|
const mensagemId = `${conv._id}-${conv.ultimaMensagemTimestamp}`;
|
|
|
|
// Verificar se já foi notificada
|
|
if (mensagensNotificadasGlobal.has(mensagemId)) return;
|
|
|
|
const conversaAtivaId = activeConversation ? String(activeConversation).trim() : null;
|
|
const conversaIdStr = String(conv._id).trim();
|
|
const estaConversaEstaAberta = conversaAtivaId === conversaIdStr;
|
|
|
|
// Só mostrar notificação se:
|
|
// 1. O chat não está aberto OU
|
|
// 2. O chat está aberto mas não estamos vendo essa conversa específica
|
|
if (!isOpen || !estaConversaEstaAberta) {
|
|
// Marcar como notificada ANTES de mostrar notificação (evita duplicação)
|
|
mensagensNotificadasGlobal.add(mensagemId);
|
|
salvarMensagensNotificadasGlobal();
|
|
|
|
// Tocar som de notificação (apenas uma vez)
|
|
tocarSomNotificacaoGlobal();
|
|
|
|
// Mostrar popup de notificação
|
|
globalNotificationMessage = {
|
|
remetente: conv.outroUsuario?.nome || conv.nome || 'Usuário',
|
|
conteudo: conv.ultimaMensagem || '',
|
|
conversaId: conv._id
|
|
};
|
|
showGlobalNotificationPopup = true;
|
|
|
|
// Ocultar popup após 5 segundos
|
|
if (globalNotificationTimeout) {
|
|
clearTimeout(globalNotificationTimeout);
|
|
}
|
|
globalNotificationTimeout = setTimeout(() => {
|
|
showGlobalNotificationPopup = false;
|
|
globalNotificationMessage = null;
|
|
}, 5000);
|
|
} else {
|
|
// Chat está aberto e estamos vendo essa conversa - marcar como visualizada
|
|
// mas não mostrar notificação nem tocar beep
|
|
mensagensNotificadasGlobal.add(mensagemId);
|
|
salvarMensagensNotificadasGlobal();
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
function handleToggle() {
|
|
if (isOpen && !isMinimized) {
|
|
minimizarChat();
|
|
} else {
|
|
abrirChat();
|
|
}
|
|
}
|
|
|
|
function handleClose() {
|
|
fecharChat();
|
|
}
|
|
|
|
function handleMinimize() {
|
|
minimizarChat();
|
|
}
|
|
|
|
function handleMaximize() {
|
|
if (!position) return;
|
|
|
|
if (isMaximized) {
|
|
// Restaurar tamanho anterior
|
|
windowSize = previousSize;
|
|
if (previousPosition) {
|
|
position = previousPosition;
|
|
}
|
|
isMaximized = false;
|
|
saveSize();
|
|
ajustarPosicao();
|
|
} else {
|
|
// Salvar tamanho e posição atuais
|
|
previousSize = { ...windowSize };
|
|
previousPosition = { ...position };
|
|
|
|
// Maximizar completamente: usar toda a largura e altura da tela
|
|
const winWidth =
|
|
windowDimensions.width ||
|
|
(typeof window !== 'undefined' ? window.innerWidth : DEFAULT_WIDTH);
|
|
const winHeight =
|
|
windowDimensions.height ||
|
|
(typeof window !== 'undefined' ? window.innerHeight : DEFAULT_HEIGHT);
|
|
|
|
windowSize = {
|
|
width: winWidth,
|
|
height: winHeight
|
|
};
|
|
position = {
|
|
x: 0,
|
|
y: 0
|
|
};
|
|
isMaximized = true;
|
|
saveSize();
|
|
ajustarPosicao();
|
|
}
|
|
maximizarChat();
|
|
}
|
|
|
|
// Funcionalidade de arrastar
|
|
function handleMouseDown(e: MouseEvent) {
|
|
if (e.button !== 0 || !position) return; // Apenas botão esquerdo
|
|
hasMoved = false;
|
|
shouldPreventClick = false;
|
|
isDragging = true;
|
|
|
|
// Calcular offset do clique dentro do elemento (considerando a posição atual)
|
|
// Isso garante que o arrasto comece exatamente onde o usuário clicou
|
|
dragStart = {
|
|
x: e.clientX - position.x,
|
|
y: e.clientY - position.y
|
|
};
|
|
|
|
document.body.classList.add('dragging');
|
|
e.preventDefault();
|
|
}
|
|
|
|
// Handler específico para o botão flutuante (evita conflito com clique)
|
|
function handleButtonMouseDown(e: MouseEvent) {
|
|
if (e.button !== 0 || !position) return;
|
|
// Resetar flags de movimento e clique
|
|
hasMoved = false;
|
|
shouldPreventClick = false;
|
|
isDragging = true;
|
|
|
|
// Calcular offset do clique exatamente onde o mouse está
|
|
dragStart = {
|
|
x: e.clientX - position.x,
|
|
y: e.clientY - position.y
|
|
};
|
|
|
|
document.body.classList.add('dragging');
|
|
// Não prevenir default para permitir clique funcionar se não houver movimento
|
|
}
|
|
|
|
function handleMouseMove(e: MouseEvent) {
|
|
if (isResizing) {
|
|
handleResizeMove(e);
|
|
return;
|
|
}
|
|
|
|
if (!isDragging || !position) return;
|
|
|
|
// Calcular nova posição baseada no offset do clique
|
|
const newX = e.clientX - dragStart.x;
|
|
const newY = e.clientY - dragStart.y;
|
|
|
|
// Verificar se houve movimento significativo desde o último frame
|
|
const deltaX = Math.abs(newX - position.x);
|
|
const deltaY = Math.abs(newY - position.y);
|
|
|
|
// Se houve qualquer movimento (mesmo pequeno), marcar como movido
|
|
if (deltaX > 0 || deltaY > 0) {
|
|
// Marcar como movido se passar do threshold
|
|
if (deltaX > dragThreshold || deltaY > dragThreshold) {
|
|
hasMoved = true;
|
|
shouldPreventClick = true; // Prevenir clique se houve movimento
|
|
}
|
|
|
|
// Dimensões do widget
|
|
const widgetWidth = isOpen && !isMinimized ? windowSize.width : 72;
|
|
const widgetHeight = isOpen && !isMinimized ? windowSize.height : 72;
|
|
|
|
// Usar dimensões reativas da janela
|
|
const winWidth =
|
|
windowDimensions.width || (typeof window !== 'undefined' ? window.innerWidth : 0);
|
|
const winHeight =
|
|
windowDimensions.height || (typeof window !== 'undefined' ? window.innerHeight : 0);
|
|
|
|
// Limites da tela com margem de segurança
|
|
const minX = -(widgetWidth - 100); // Permitir até 100px visíveis
|
|
const maxX = Math.max(0, winWidth - 100); // Manter 100px dentro da tela
|
|
const minY = -(widgetHeight - 100);
|
|
const maxY = Math.max(0, winHeight - 100);
|
|
|
|
// Atualizar posição imediatamente - garantir suavidade
|
|
position = {
|
|
x: Math.max(minX, Math.min(newX, maxX)),
|
|
y: Math.max(minY, Math.min(newY, maxY))
|
|
};
|
|
}
|
|
}
|
|
|
|
function handleMouseUp(e?: MouseEvent) {
|
|
const hadMoved = hasMoved;
|
|
|
|
if (isDragging) {
|
|
isDragging = false;
|
|
|
|
// Se estava arrastando e houve movimento, prevenir clique
|
|
if (hadMoved && e) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
}
|
|
|
|
// Garantir que está dentro dos limites ao soltar
|
|
ajustarPosicao();
|
|
|
|
// Salvar posição após arrastar
|
|
savePosition();
|
|
|
|
// Aguardar um pouco antes de resetar as flags para garantir que o onclick não seja executado
|
|
setTimeout(() => {
|
|
hasMoved = false;
|
|
shouldPreventClick = false;
|
|
}, 100);
|
|
|
|
document.body.classList.remove('dragging');
|
|
}
|
|
handleResizeEnd();
|
|
|
|
return !hadMoved; // Retorna true se não houve movimento (permite clique)
|
|
}
|
|
|
|
function ajustarPosicao() {
|
|
if (!position) return;
|
|
|
|
isAnimating = true;
|
|
|
|
// Dimensões do widget
|
|
const widgetWidth = isOpen && !isMinimized ? windowSize.width : 72;
|
|
const widgetHeight = isOpen && !isMinimized ? windowSize.height : 72;
|
|
|
|
// Usar dimensões reativas da janela
|
|
const winWidth =
|
|
windowDimensions.width || (typeof window !== 'undefined' ? window.innerWidth : 0);
|
|
const winHeight =
|
|
windowDimensions.height || (typeof window !== 'undefined' ? window.innerHeight : 0);
|
|
|
|
// Verificar se está fora dos limites
|
|
let newX = position.x;
|
|
let newY = position.y;
|
|
|
|
// Ajustar X - garantir que pelo menos 100px fiquem visíveis
|
|
const minX = -(widgetWidth - 100);
|
|
const maxX = Math.max(0, winWidth - 100);
|
|
|
|
if (newX < minX) {
|
|
newX = minX;
|
|
} else if (newX > maxX) {
|
|
newX = maxX;
|
|
}
|
|
|
|
// Ajustar Y - garantir que pelo menos 100px fiquem visíveis
|
|
const minY = -(widgetHeight - 100);
|
|
const maxY = Math.max(0, winHeight - 100);
|
|
|
|
if (newY < minY) {
|
|
newY = minY;
|
|
} else if (newY > maxY) {
|
|
newY = maxY;
|
|
}
|
|
|
|
position = { x: newX, y: newY };
|
|
|
|
// Salvar posição após ajuste
|
|
savePosition();
|
|
|
|
setTimeout(() => {
|
|
isAnimating = false;
|
|
}, 300);
|
|
}
|
|
|
|
// Event listeners globais com cleanup adequado
|
|
$effect(() => {
|
|
if (typeof window === 'undefined') return;
|
|
|
|
window.addEventListener('mousemove', handleMouseMove);
|
|
window.addEventListener('mouseup', handleMouseUp);
|
|
|
|
return () => {
|
|
window.removeEventListener('mousemove', handleMouseMove);
|
|
window.removeEventListener('mouseup', handleMouseUp);
|
|
};
|
|
});
|
|
</script>
|
|
|
|
<!-- Botão flutuante MODERNO E ARRASTÁVEL -->
|
|
{#if (!isOpen || isMinimized) && position}
|
|
{@const winWidth =
|
|
windowDimensions.width || (typeof window !== 'undefined' ? window.innerWidth : 0)}
|
|
{@const winHeight =
|
|
windowDimensions.height || (typeof window !== 'undefined' ? window.innerHeight : 0)}
|
|
{@const bottomPos = `${Math.max(0, winHeight - position.y - 72)}px`}
|
|
{@const rightPos = `${Math.max(0, winWidth - position.x - 72)}px`}
|
|
<button
|
|
type="button"
|
|
class="group fixed border-0 backdrop-blur-xl"
|
|
style="
|
|
z-index: 99999 !important;
|
|
width: 4.5rem;
|
|
height: 4.5rem;
|
|
bottom: {bottomPos};
|
|
right: {rightPos};
|
|
position: fixed !important;
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 50%, #f093fb 100%);
|
|
box-shadow:
|
|
0 20px 60px -10px rgba(102, 126, 234, 0.5),
|
|
0 10px 30px -5px rgba(118, 75, 162, 0.4),
|
|
0 0 0 1px rgba(255, 255, 255, 0.1) inset;
|
|
border-radius: 50%;
|
|
cursor: {isDragging ? 'grabbing' : 'grab'};
|
|
transform: {isDragging ? 'scale(1.05)' : 'scale(1)'};
|
|
transition: {isAnimating
|
|
? 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)'
|
|
: 'transform 0.2s, box-shadow 0.3s'};
|
|
"
|
|
onmousedown={handleButtonMouseDown}
|
|
onmouseup={(e) => {
|
|
handleMouseUp(e);
|
|
}}
|
|
onclick={(e) => {
|
|
// Só executar toggle se não houve movimento durante o arrastar
|
|
if (!shouldPreventClick && !hasMoved) {
|
|
handleToggle();
|
|
} else {
|
|
// Prevenir clique se houve movimento
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
shouldPreventClick = false; // Resetar após prevenir
|
|
}
|
|
}}
|
|
aria-label="Abrir chat"
|
|
>
|
|
<!-- Anel de brilho rotativo -->
|
|
<div
|
|
class="absolute inset-0 rounded-full opacity-0 transition-opacity duration-500 group-hover:opacity-100"
|
|
style="background: conic-gradient(from 0deg, transparent 0%, rgba(255,255,255,0.3) 50%, transparent 100%); animation: rotate 3s linear infinite;"
|
|
></div>
|
|
|
|
<!-- Ondas de pulso -->
|
|
<div
|
|
class="absolute inset-0 rounded-full"
|
|
style="animation: pulse-ring 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;"
|
|
></div>
|
|
|
|
<!-- Ícone de chat moderno com efeito 3D -->
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="2"
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
class="relative z-10 h-7 w-7 text-white transition-all duration-500 group-hover:scale-110"
|
|
style="filter: drop-shadow(0 4px 12px rgba(0,0,0,0.4));"
|
|
>
|
|
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
|
<circle cx="9" cy="10" r="1" fill="currentColor" />
|
|
<circle cx="12" cy="10" r="1" fill="currentColor" />
|
|
<circle cx="15" cy="10" r="1" fill="currentColor" />
|
|
</svg>
|
|
|
|
<!-- Badge ULTRA PREMIUM com gradiente e brilho -->
|
|
{#if count?.data && count.data > 0}
|
|
<span
|
|
class="absolute -top-1 -right-1 z-20 flex h-8 w-8 items-center justify-center rounded-full text-xs font-black text-white"
|
|
style="
|
|
background: linear-gradient(135deg, #ff416c, #ff4b2b);
|
|
box-shadow:
|
|
0 8px 24px -4px rgba(255, 65, 108, 0.6),
|
|
0 4px 12px -2px rgba(255, 75, 43, 0.4),
|
|
0 0 0 3px rgba(255, 255, 255, 0.3),
|
|
0 0 0 5px rgba(255, 65, 108, 0.2);
|
|
animation: badge-bounce 2s ease-in-out infinite;
|
|
"
|
|
>
|
|
{count.data > 9 ? '9+' : count.data}
|
|
</span>
|
|
{/if}
|
|
|
|
<!-- Indicador de arrastável -->
|
|
<div
|
|
class="absolute -bottom-2 left-1/2 flex -translate-x-1/2 transform gap-1 opacity-50 transition-opacity group-hover:opacity-100"
|
|
>
|
|
<div class="h-1 w-1 rounded-full bg-white"></div>
|
|
<div class="h-1 w-1 rounded-full bg-white"></div>
|
|
<div class="h-1 w-1 rounded-full bg-white"></div>
|
|
</div>
|
|
</button>
|
|
{/if}
|
|
|
|
<!-- Janela do Chat ULTRA MODERNA E ARRASTÁVEL -->
|
|
{#if isOpen && !isMinimized && position}
|
|
{@const winWidth =
|
|
windowDimensions.width || (typeof window !== 'undefined' ? window.innerWidth : 0)}
|
|
{@const winHeight =
|
|
windowDimensions.height || (typeof window !== 'undefined' ? window.innerHeight : 0)}
|
|
{@const bottomPos = `${Math.max(0, winHeight - position.y - windowSize.height)}px`}
|
|
{@const rightPos = `${Math.max(0, winWidth - position.x - windowSize.width)}px`}
|
|
<div
|
|
class="fixed flex flex-col overflow-hidden backdrop-blur-2xl"
|
|
style="
|
|
z-index: 99999 !important;
|
|
bottom: {bottomPos};
|
|
right: {rightPos};
|
|
width: {windowSize.width}px;
|
|
height: {windowSize.height}px;
|
|
max-width: calc(100vw - 3rem);
|
|
max-height: calc(100vh - 3rem);
|
|
position: fixed !important;
|
|
background: linear-gradient(135deg, rgba(255,255,255,0.95) 0%, rgba(249,250,251,0.98) 100%);
|
|
border-radius: 24px;
|
|
box-shadow:
|
|
0 32px 64px -12px rgba(0, 0, 0, 0.15),
|
|
0 16px 32px -8px rgba(0, 0, 0, 0.1),
|
|
0 0 0 1px rgba(0, 0, 0, 0.05),
|
|
0 0 0 1px rgba(255, 255, 255, 0.5) inset;
|
|
animation: slideInScale 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
|
|
transition: {isAnimating ? 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)' : 'none'};
|
|
"
|
|
>
|
|
<!-- Header ULTRA PREMIUM com gradiente glassmorphism -->
|
|
<div
|
|
class="relative flex items-center justify-between overflow-hidden px-6 py-5 text-white"
|
|
style="
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 50%, #f093fb 100%);
|
|
box-shadow: 0 8px 32px -4px rgba(102, 126, 234, 0.3);
|
|
cursor: {isDragging ? 'grabbing' : 'grab'};
|
|
"
|
|
onmousedown={handleMouseDown}
|
|
role="button"
|
|
tabindex="0"
|
|
aria-label="Arrastar janela do chat"
|
|
>
|
|
<!-- Efeitos de fundo animados -->
|
|
<div
|
|
class="absolute inset-0 opacity-30"
|
|
style="background: radial-gradient(circle at 20% 50%, rgba(255,255,255,0.3) 0%, transparent 50%);"
|
|
></div>
|
|
<div
|
|
class="absolute inset-0 opacity-20"
|
|
style="background: linear-gradient(45deg, transparent 30%, rgba(255,255,255,0.1) 50%, transparent 70%); animation: shimmer 3s infinite;"
|
|
></div>
|
|
<!-- Título com avatar/foto do usuário logado -->
|
|
<h2 class="relative z-10 flex items-center gap-3 text-xl font-bold">
|
|
<!-- Avatar/Foto do usuário logado com efeito glassmorphism -->
|
|
<div
|
|
class="relative flex h-10 w-10 items-center justify-center overflow-hidden rounded-xl"
|
|
style="background: rgba(255,255,255,0.2); backdrop-filter: blur(10px); box-shadow: 0 4px 12px rgba(0,0,0,0.1), 0 0 0 1px rgba(255,255,255,0.2) inset;"
|
|
>
|
|
{#if avatarUrlDoUsuario()}
|
|
<img
|
|
src={avatarUrlDoUsuario()}
|
|
alt={currentUser?.data?.nome || 'Usuário'}
|
|
class="h-full w-full object-cover"
|
|
/>
|
|
{:else}
|
|
<!-- Fallback: ícone de chat genérico -->
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="2"
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
class="h-5 w-5"
|
|
style="filter: drop-shadow(0 2px 6px rgba(0,0,0,0.2));"
|
|
>
|
|
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
|
<line x1="9" y1="10" x2="15" y2="10" />
|
|
<line x1="9" y1="14" x2="13" y2="14" />
|
|
</svg>
|
|
{/if}
|
|
</div>
|
|
<span
|
|
class="font-extrabold tracking-wide"
|
|
style="text-shadow: 0 2px 8px rgba(0,0,0,0.3); letter-spacing: 0.02em;">Mensagens</span
|
|
>
|
|
</h2>
|
|
|
|
<!-- Botões de controle modernos -->
|
|
<div class="relative z-10 flex items-center gap-2">
|
|
<!-- Botão minimizar MODERNO -->
|
|
<button
|
|
type="button"
|
|
class="group relative flex h-10 w-10 items-center justify-center overflow-hidden rounded-xl transition-all duration-300"
|
|
style="background: rgba(255,255,255,0.15); backdrop-filter: blur(10px); border: 1px solid rgba(255,255,255,0.2);"
|
|
onclick={handleMinimize}
|
|
aria-label="Minimizar"
|
|
>
|
|
<div
|
|
class="absolute inset-0 bg-white/0 transition-colors duration-300 group-hover:bg-white/20"
|
|
></div>
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="2.5"
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
class="relative z-10 h-5 w-5 transition-transform duration-300 group-hover:scale-110"
|
|
style="filter: drop-shadow(0 2px 4px rgba(0,0,0,0.2));"
|
|
>
|
|
<line x1="5" y1="12" x2="19" y2="12" />
|
|
</svg>
|
|
</button>
|
|
|
|
<!-- Botão maximizar MODERNO -->
|
|
<button
|
|
type="button"
|
|
class="group relative flex h-10 w-10 items-center justify-center overflow-hidden rounded-xl transition-all duration-300"
|
|
style="background: rgba(255,255,255,0.15); backdrop-filter: blur(10px); border: 1px solid rgba(255,255,255,0.2);"
|
|
onclick={handleMaximize}
|
|
aria-label="Maximizar"
|
|
>
|
|
<div
|
|
class="absolute inset-0 bg-white/0 transition-colors duration-300 group-hover:bg-white/20"
|
|
></div>
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="2.5"
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
class="relative z-10 h-5 w-5 transition-transform duration-300 group-hover:scale-110"
|
|
style="filter: drop-shadow(0 2px 4px rgba(0,0,0,0.2));"
|
|
>
|
|
<path
|
|
d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
|
|
<!-- Botão fechar MODERNO -->
|
|
<button
|
|
type="button"
|
|
class="group relative flex h-10 w-10 items-center justify-center overflow-hidden rounded-xl transition-all duration-300"
|
|
style="background: rgba(255,255,255,0.15); backdrop-filter: blur(10px); border: 1px solid rgba(255,255,255,0.2);"
|
|
onclick={handleClose}
|
|
aria-label="Fechar"
|
|
>
|
|
<div
|
|
class="absolute inset-0 bg-red-500/0 transition-colors duration-300 group-hover:bg-red-500/30"
|
|
></div>
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="2.5"
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
class="relative z-10 h-5 w-5 transition-all duration-300 group-hover:scale-110 group-hover:rotate-90"
|
|
style="filter: drop-shadow(0 2px 4px rgba(0,0,0,0.2));"
|
|
>
|
|
<line x1="18" y1="6" x2="6" y2="18" />
|
|
<line x1="6" y1="6" x2="18" y2="18" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Conteúdo -->
|
|
<div class="relative flex-1 overflow-hidden">
|
|
{#if !activeConversation}
|
|
<ChatList />
|
|
{:else}
|
|
<ChatWindow conversaId={activeConversation} />
|
|
{/if}
|
|
|
|
<!-- Resize Handles -->
|
|
<!-- Top -->
|
|
<div
|
|
role="button"
|
|
tabindex="0"
|
|
aria-label="Redimensionar janela pela borda superior"
|
|
class="hover:bg-primary/20 absolute top-0 right-0 left-0 z-50 h-2 cursor-ns-resize transition-colors"
|
|
onmousedown={(e) => handleResizeStart(e, 'n')}
|
|
onkeydown={(e) => e.key === 'Enter' && handleResizeStart(e, 'n')}
|
|
style="border-radius: 24px 24px 0 0;"
|
|
></div>
|
|
<!-- Bottom -->
|
|
<div
|
|
role="button"
|
|
tabindex="0"
|
|
aria-label="Redimensionar janela pela borda inferior"
|
|
class="hover:bg-primary/20 absolute right-0 bottom-0 left-0 z-50 h-2 cursor-ns-resize transition-colors"
|
|
onmousedown={(e) => handleResizeStart(e, 's')}
|
|
onkeydown={(e) => e.key === 'Enter' && handleResizeStart(e, 's')}
|
|
style="border-radius: 0 0 24px 24px;"
|
|
></div>
|
|
<!-- Left -->
|
|
<div
|
|
role="button"
|
|
tabindex="0"
|
|
aria-label="Redimensionar janela pela borda esquerda"
|
|
class="hover:bg-primary/20 absolute top-0 bottom-0 left-0 z-50 w-2 cursor-ew-resize transition-colors"
|
|
onmousedown={(e) => handleResizeStart(e, 'w')}
|
|
onkeydown={(e) => e.key === 'Enter' && handleResizeStart(e, 'w')}
|
|
style="border-radius: 24px 0 0 24px;"
|
|
></div>
|
|
<!-- Right -->
|
|
<div
|
|
role="button"
|
|
tabindex="0"
|
|
aria-label="Redimensionar janela pela borda direita"
|
|
class="hover:bg-primary/20 absolute top-0 right-0 bottom-0 z-50 w-2 cursor-ew-resize transition-colors"
|
|
onmousedown={(e) => handleResizeStart(e, 'e')}
|
|
onkeydown={(e) => e.key === 'Enter' && handleResizeStart(e, 'e')}
|
|
style="border-radius: 0 24px 24px 0;"
|
|
></div>
|
|
<!-- Corners -->
|
|
<div
|
|
role="button"
|
|
tabindex="0"
|
|
aria-label="Redimensionar janela pelo canto superior esquerdo"
|
|
class="hover:bg-primary/20 absolute top-0 left-0 z-50 h-4 w-4 cursor-nwse-resize transition-colors"
|
|
onmousedown={(e) => handleResizeStart(e, 'nw')}
|
|
onkeydown={(e) => e.key === 'Enter' && handleResizeStart(e, 'nw')}
|
|
style="border-radius: 24px 0 0 0;"
|
|
></div>
|
|
<div
|
|
role="button"
|
|
tabindex="0"
|
|
aria-label="Redimensionar janela pelo canto superior direito"
|
|
class="hover:bg-primary/20 absolute top-0 right-0 z-50 h-4 w-4 cursor-nesw-resize transition-colors"
|
|
onmousedown={(e) => handleResizeStart(e, 'ne')}
|
|
onkeydown={(e) => e.key === 'Enter' && handleResizeStart(e, 'ne')}
|
|
style="border-radius: 0 24px 0 0;"
|
|
></div>
|
|
<div
|
|
role="button"
|
|
tabindex="0"
|
|
aria-label="Redimensionar janela pelo canto inferior esquerdo"
|
|
class="hover:bg-primary/20 absolute bottom-0 left-0 z-50 h-4 w-4 cursor-nesw-resize transition-colors"
|
|
onmousedown={(e) => handleResizeStart(e, 'sw')}
|
|
onkeydown={(e) => e.key === 'Enter' && handleResizeStart(e, 'sw')}
|
|
style="border-radius: 0 0 0 24px;"
|
|
></div>
|
|
<div
|
|
role="button"
|
|
tabindex="0"
|
|
aria-label="Redimensionar janela pelo canto inferior direito"
|
|
class="hover:bg-primary/20 absolute right-0 bottom-0 z-50 h-4 w-4 cursor-nwse-resize transition-colors"
|
|
onmousedown={(e) => handleResizeStart(e, 'se')}
|
|
onkeydown={(e) => e.key === 'Enter' && handleResizeStart(e, 'se')}
|
|
style="border-radius: 0 0 24px 0;"
|
|
></div>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Popup Global de Notificação de Nova Mensagem (quando chat está fechado/minimizado) -->
|
|
{#if showGlobalNotificationPopup && globalNotificationMessage}
|
|
{@const notificationMsg = globalNotificationMessage}
|
|
<div
|
|
role="button"
|
|
tabindex="0"
|
|
aria-label="Abrir conversa: Nova mensagem de {notificationMsg.remetente}"
|
|
class="bg-base-100 border-primary/20 fixed top-4 right-4 z-1000 max-w-sm cursor-pointer rounded-lg border p-4 shadow-2xl"
|
|
style="box-shadow: 0 10px 40px -10px rgba(0,0,0,0.3); animation: slideInRight 0.3s ease-out;"
|
|
onclick={() => {
|
|
const conversaIdToOpen = notificationMsg?.conversaId;
|
|
showGlobalNotificationPopup = false;
|
|
globalNotificationMessage = null;
|
|
if (globalNotificationTimeout) {
|
|
clearTimeout(globalNotificationTimeout);
|
|
}
|
|
// Abrir chat e conversa ao clicar
|
|
if (conversaIdToOpen) {
|
|
abrirChat();
|
|
abrirConversa(conversaIdToOpen as Id<'conversas'>);
|
|
}
|
|
}}
|
|
onkeydown={(e) => {
|
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
e.preventDefault();
|
|
const conversaIdToOpen = notificationMsg?.conversaId;
|
|
showGlobalNotificationPopup = false;
|
|
globalNotificationMessage = null;
|
|
if (globalNotificationTimeout) {
|
|
clearTimeout(globalNotificationTimeout);
|
|
}
|
|
if (conversaIdToOpen) {
|
|
abrirChat();
|
|
abrirConversa(conversaIdToOpen as Id<'conversas'>);
|
|
}
|
|
}
|
|
}}
|
|
>
|
|
<div class="flex items-start gap-3">
|
|
<div class="bg-primary/20 flex h-10 w-10 shrink-0 items-center justify-center rounded-full">
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke-width="2"
|
|
stroke="currentColor"
|
|
class="text-primary h-5 w-5"
|
|
>
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
d="M14.857 17.082a23.848 23.848 0 0 0 5.454-1.31A8.967 8.967 0 0 1 18 9.75V9A6 6 0 0 0 6 9v.75a8.967 8.967 0 0 1-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 0 1-5.714 0m5.714 0a3 3 0 1 1-5.714 0"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
<div class="min-w-0 flex-1">
|
|
<p class="text-base-content mb-1 text-sm font-semibold">
|
|
Nova mensagem de {notificationMsg.remetente}
|
|
</p>
|
|
<p class="text-base-content/70 line-clamp-2 text-xs">
|
|
{notificationMsg.conteudo}
|
|
</p>
|
|
<p class="text-primary mt-1 text-xs">Clique para abrir</p>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
aria-label="Fechar notificação"
|
|
class="hover:bg-base-200 flex h-6 w-6 shrink-0 items-center justify-center rounded-full transition-colors"
|
|
onclick={(e) => {
|
|
e.stopPropagation();
|
|
showGlobalNotificationPopup = false;
|
|
globalNotificationMessage = null;
|
|
if (globalNotificationTimeout) {
|
|
clearTimeout(globalNotificationTimeout);
|
|
}
|
|
}}
|
|
>
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke-width="2"
|
|
stroke="currentColor"
|
|
class="h-4 w-4"
|
|
>
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<style>
|
|
/* Animação do badge com bounce suave */
|
|
@keyframes badge-bounce {
|
|
0%,
|
|
100% {
|
|
transform: scale(1) translateY(0);
|
|
}
|
|
50% {
|
|
transform: scale(1.08) translateY(-2px);
|
|
}
|
|
}
|
|
|
|
/* Animação de entrada da janela com escala e bounce */
|
|
@keyframes slideInScale {
|
|
0% {
|
|
opacity: 0;
|
|
transform: translateY(30px) scale(0.9);
|
|
}
|
|
60% {
|
|
transform: translateY(-5px) scale(1.02);
|
|
}
|
|
100% {
|
|
opacity: 1;
|
|
transform: translateY(0) scale(1);
|
|
}
|
|
}
|
|
|
|
/* Ondas de pulso para o botão flutuante */
|
|
@keyframes pulse-ring {
|
|
0% {
|
|
box-shadow: 0 0 0 0 rgba(102, 126, 234, 0.5);
|
|
}
|
|
50% {
|
|
box-shadow: 0 0 0 15px rgba(102, 126, 234, 0);
|
|
}
|
|
100% {
|
|
box-shadow: 0 0 0 0 rgba(102, 126, 234, 0);
|
|
}
|
|
}
|
|
|
|
/* Rotação para anel de brilho */
|
|
@keyframes rotate {
|
|
from {
|
|
transform: rotate(0deg);
|
|
}
|
|
to {
|
|
transform: rotate(360deg);
|
|
}
|
|
}
|
|
|
|
/* Efeito shimmer para o header */
|
|
@keyframes shimmer {
|
|
0% {
|
|
transform: translateX(-100%);
|
|
}
|
|
100% {
|
|
transform: translateX(100%);
|
|
}
|
|
}
|
|
|
|
/* Suavizar transições */
|
|
* {
|
|
-webkit-font-smoothing: antialiased;
|
|
-moz-osx-font-smoothing: grayscale;
|
|
}
|
|
</style>
|