Files
sgse-app/apps/web/src/lib/components/chat/ChatWidget.svelte

1469 lines
47 KiB
Svelte

<script lang="ts">
import {
chatAberto,
chatMinimizado,
conversaAtiva,
fecharChat,
minimizarChat,
maximizarChat,
abrirChat,
abrirConversa,
notificacaoAtiva
} 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 ConnectionIndicator from './ConnectionIndicator.svelte';
import { MessageSquare, Minus, Maximize2, X, Bell } from 'lucide-svelte';
import { SvelteSet } from 'svelte/reactivity';
const count = useQuery(api.chat.contarNotificacoesNaoLidas, {});
// Query otimizada: usar apenas uma query para obter usuário atual
// Priorizar obterPerfil pois retorna mais informações úteis
const meuPerfilQuery = useQuery(api.usuarios.obterPerfil, {});
const currentUser = useQuery(api.auth.getCurrentUser, {});
// Derivar ID do usuário de forma otimizada (usar perfil primeiro, fallback para currentUser)
const meuId = $derived(() => {
if (meuPerfilQuery?.data?._id) {
return String(meuPerfilQuery.data._id).trim();
}
if (currentUser?.data?._id) {
return String(currentUser.data._id).trim();
}
return null;
});
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 (otimizada)
const avatarUrlDoUsuario = $derived(() => {
// Priorizar perfil (tem mais informações)
const perfil = meuPerfilQuery?.data;
if (perfil?.fotoPerfilUrl) {
return perfil.fotoPerfilUrl;
}
// Fallback para currentUser
const usuario = currentUser?.data;
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
let isDoubleClicking = $state(false); // Flag para prevenir clique simples após duplo clique
// Suporte a gestos touch (swipe)
let touchStart = $state<{ x: number; y: number; time: number } | null>(null);
let touchCurrent = $state<{ x: number; y: number } | null>(null);
let isTouching = $state(false);
let swipeVelocity = $state(0); // Velocidade do swipe para animação
// 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
}
}
// Throttle para evitar execuções muito frequentes do effect
let ultimaExecucaoNotificacao = $state(0);
const THROTTLE_NOTIFICACAO_MS = 1000; // 1 segundo entre execuções
$effect(() => {
const agora = Date.now();
const tempoDesdeUltimaExecucao = agora - ultimaExecucaoNotificacao;
// Throttle: só executar se passou tempo suficiente
if (tempoDesdeUltimaExecucao < THROTTLE_NOTIFICACAO_MS && ultimaExecucaoNotificacao > 0) {
return;
}
if (todasConversas?.data && meuId()) {
ultimaExecucaoNotificacao = agora;
const conversas = todasConversas.data as ConversaComTimestamp[];
const meuIdAtual = meuId();
if (!meuIdAtual) {
console.warn('⚠️ [ChatWidget] Não foi possível identificar o ID do usuário logado');
return;
}
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;
// Se a mensagem foi enviada pelo próprio usuário, ignorar completamente
if (remetenteIdStr && remetenteIdStr === meuIdAtual) {
// 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;
// Verificar se outra notificação já está ativa para esta mensagem
const notificacaoAtual = $notificacaoAtiva;
const jaTemNotificacaoAtiva =
notificacaoAtual &&
notificacaoAtual.conversaId === conversaIdStr &&
notificacaoAtual.mensagemId === mensagemId;
// 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
// 3. E não há outra notificação ativa para esta mensagem
if ((!isOpen || !estaConversaEstaAberta) && !jaTemNotificacaoAtiva) {
// Marcar como notificada ANTES de mostrar notificação (evita duplicação)
mensagensNotificadasGlobal.add(mensagemId);
salvarMensagensNotificadasGlobal();
// Registrar notificação ativa no store global
notificacaoAtiva.set({
conversaId: conversaIdStr,
mensagemId,
componente: 'widget'
});
// 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 - garantir limpeza
if (globalNotificationTimeout) {
clearTimeout(globalNotificationTimeout);
globalNotificationTimeout = null;
}
globalNotificationTimeout = setTimeout(() => {
showGlobalNotificationPopup = false;
globalNotificationMessage = null;
globalNotificationTimeout = null;
// Limpar notificação ativa do store
notificacaoAtiva.set(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();
}
});
}
// Cleanup: limpar timeout quando o effect for desmontado
return () => {
if (globalNotificationTimeout) {
clearTimeout(globalNotificationTimeout);
globalNotificationTimeout = null;
}
};
});
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();
}
// Handler para duplo clique no botão flutuante - abre e maximiza
function handleDoubleClick() {
// Marcar que estamos processando um duplo clique
isDoubleClicking = true;
// Se o chat estiver fechado ou minimizado, abrir e maximizar
if (!isOpen || isMinimized) {
abrirChat();
// Aguardar um pouco para garantir que o chat foi aberto antes de maximizar
setTimeout(() => {
if (position) {
// Salvar tamanho e posição atuais antes de maximizar
previousSize = { ...windowSize };
previousPosition = { ...position };
// Maximizar completamente
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();
}
// Resetar flag após processar
setTimeout(() => {
isDoubleClicking = false;
}, 300);
}, 50);
} else {
// Se já estiver aberto, apenas maximizar
handleMaximize();
setTimeout(() => {
isDoubleClicking = false;
}, 300);
}
}
// 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
}
// Handlers para gestos touch (swipe)
function handleTouchStart(e: TouchEvent) {
if (!position || e.touches.length !== 1) return;
const touch = e.touches[0];
touchStart = {
x: touch.clientX,
y: touch.clientY,
time: Date.now()
};
touchCurrent = { x: touch.clientX, y: touch.clientY };
isTouching = true;
isDragging = true;
dragStart = {
x: touch.clientX - position.x,
y: touch.clientY - position.y
};
hasMoved = false;
shouldPreventClick = false;
document.body.classList.add('dragging');
}
function handleTouchMove(e: TouchEvent) {
if (!isTouching || !touchStart || !position || e.touches.length !== 1) return;
const touch = e.touches[0];
touchCurrent = { x: touch.clientX, y: touch.clientY };
// Calcular velocidade do swipe
const deltaTime = Date.now() - touchStart.time;
const deltaX = touch.clientX - touchStart.x;
const deltaY = touch.clientY - touchStart.y;
const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
if (deltaTime > 0) {
swipeVelocity = distance / deltaTime; // pixels por ms
}
// Calcular nova posição
const newX = touch.clientX - dragStart.x;
const newY = touch.clientY - dragStart.y;
// Verificar se houve movimento significativo
const deltaXAbs = Math.abs(newX - position.x);
const deltaYAbs = Math.abs(newY - position.y);
if (deltaXAbs > dragThreshold || deltaYAbs > dragThreshold) {
hasMoved = true;
shouldPreventClick = true;
}
// Dimensões do widget
const widgetWidth = isOpen && !isMinimized ? windowSize.width : 72;
const widgetHeight = isOpen && !isMinimized ? windowSize.height : 72;
const winWidth =
windowDimensions.width || (typeof window !== 'undefined' ? window.innerWidth : 0);
const winHeight =
windowDimensions.height || (typeof window !== 'undefined' ? window.innerHeight : 0);
const minX = -(widgetWidth - 100);
const maxX = Math.max(0, winWidth - 100);
const minY = -(widgetHeight - 100);
const maxY = Math.max(0, winHeight - 100);
position = {
x: Math.max(minX, Math.min(newX, maxX)),
y: Math.max(minY, Math.min(newY, maxY))
};
}
function handleTouchEnd(e: TouchEvent) {
if (!isTouching || !touchStart || !position) return;
const hadMoved = hasMoved;
// Aplicar momentum se houver velocidade suficiente
if (swipeVelocity > 0.5 && hadMoved) {
const deltaX = touchCurrent ? touchCurrent.x - touchStart.x : 0;
const deltaY = touchCurrent ? touchCurrent.y - touchStart.y : 0;
const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
if (distance > 10) {
// Aplicar momentum suave
const momentum = Math.min(swipeVelocity * 50, 200); // Limitar momentum
const angle = Math.atan2(deltaY, deltaX);
let momentumX = position.x + Math.cos(angle) * momentum;
let momentumY = position.y + Math.sin(angle) * momentum;
// Limitar dentro dos bounds
const widgetWidth = isOpen && !isMinimized ? windowSize.width : 72;
const widgetHeight = isOpen && !isMinimized ? windowSize.height : 72;
const winWidth = windowDimensions.width || (typeof window !== 'undefined' ? window.innerWidth : 0);
const winHeight = windowDimensions.height || (typeof window !== 'undefined' ? window.innerHeight : 0);
const minX = -(widgetWidth - 100);
const maxX = Math.max(0, winWidth - 100);
const minY = -(widgetHeight - 100);
const maxY = Math.max(0, winHeight - 100);
momentumX = Math.max(minX, Math.min(momentumX, maxX));
momentumY = Math.max(minY, Math.min(momentumY, maxY));
position = { x: momentumX, y: momentumY };
isAnimating = true;
setTimeout(() => {
isAnimating = false;
ajustarPosicao();
}, 300);
}
} else {
ajustarPosicao();
}
isDragging = false;
isTouching = false;
touchStart = null;
touchCurrent = null;
swipeVelocity = 0;
document.body.classList.remove('dragging');
setTimeout(() => {
hasMoved = false;
shouldPreventClick = false;
}, 100);
savePosition();
}
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);
window.addEventListener('touchmove', handleTouchMove, { passive: false });
window.addEventListener('touchend', handleTouchEnd);
return () => {
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseUp);
window.removeEventListener('touchmove', handleTouchMove);
window.removeEventListener('touchend', handleTouchEnd);
};
});
</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);
}}
ontouchstart={handleTouchStart}
onclick={(e) => {
// Prevenir clique simples se estamos processando um duplo clique
if (isDoubleClicking) {
e.preventDefault();
e.stopPropagation();
return;
}
// Só executar toggle se não houve movimento durante o arrastar
if (!shouldPreventClick && !hasMoved && !isTouching) {
handleToggle();
} else {
// Prevenir clique se houve movimento
e.preventDefault();
e.stopPropagation();
shouldPreventClick = false; // Resetar após prevenir
}
}}
ondblclick={(e) => {
// Prevenir que o clique simples seja executado após o duplo clique
e.preventDefault();
e.stopPropagation();
// Executar maximização apenas se não houve movimento
if (!shouldPreventClick && !hasMoved && !isTouching) {
handleDoubleClick();
}
}}
aria-label="Abrir chat (duplo clique para maximizar)"
>
<!-- Anel de brilho rotativo melhorado com múltiplas camadas -->
<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.4) 25%, rgba(255,255,255,0.6) 50%, rgba(255,255,255,0.4) 75%, transparent 100%); animation: rotate 3s linear infinite; transform-origin: center;"
></div>
<!-- Segunda camada para efeito de profundidade -->
<div
class="absolute inset-0 rounded-lg opacity-0 transition-opacity duration-700 group-hover:opacity-60 cursor-pointer"
style="background: conic-gradient(from 180deg, transparent 0%, rgba(255,255,255,0.2) 30%, transparent 60%); animation: rotate 4s linear infinite reverse; transform-origin: center;"
onclick={(e) => {
// Propagar o clique para o elemento pai
e.stopPropagation();
if (!isDoubleClicking && !shouldPreventClick && !hasMoved && !isTouching) {
handleToggle();
}
}}
ondblclick={(e) => {
e.stopPropagation();
if (!shouldPreventClick && !hasMoved && !isTouching) {
handleDoubleClick();
}
}}
></div>
<!-- Efeito de brilho pulsante durante arrasto -->
{#if isDragging || isTouching}
<div
class="absolute inset-0 rounded-full opacity-30 animate-pulse"
style="background: radial-gradient(circle at center, rgba(255,255,255,0.4) 0%, transparent 70%); animation: pulse-glow 1.5s ease-in-out infinite;"
></div>
{/if}
<!-- Ícone de chat moderno com efeito 3D -->
<MessageSquare
class="relative z-10 h-10 w-10 text-white transition-all duration-500 group-hover:scale-110"
style="filter: drop-shadow(0 4px 12px rgba(0,0,0,0.4));"
strokeWidth={2}
/>
<!-- 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={meuPerfilQuery?.data?.nome || currentUser?.data?.nome || 'Usuário'}
class="h-full w-full object-cover"
/>
{:else}
<!-- Fallback: ícone de chat genérico -->
<MessageSquare
class="h-5 w-5"
style="filter: drop-shadow(0 2px 6px rgba(0,0,0,0.2));"
strokeWidth={2}
/>
{/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>
<Minus
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));"
strokeWidth={2.5}
/>
</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>
<Maximize2
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));"
strokeWidth={2.5}
/>
</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>
<X
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));"
strokeWidth={2.5}
/>
</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}
<!-- Indicador de Conexão -->
<ConnectionIndicator />
<!-- 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">
<Bell class="text-primary h-5 w-5" strokeWidth={2} />
</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);
}
}}
>
<X class="h-4 w-4" strokeWidth={2} />
</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 - suavizada */
@keyframes rotate {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
/* Efeito de pulso de brilho durante arrasto */
@keyframes pulse-glow {
0%, 100% {
opacity: 0.2;
transform: scale(1);
}
50% {
opacity: 0.4;
transform: scale(1.05);
}
}
/* 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>