Merge remote-tracking branch 'origin' into feat-pedidos

This commit is contained in:
2025-12-11 10:08:12 -03:00
194 changed files with 30374 additions and 10247 deletions

View File

@@ -11,29 +11,46 @@
conversaAtiva,
fecharChat,
maximizarChat,
minimizarChat
minimizarChat,
notificacaoAtiva
} from '$lib/stores/chatStore';
import ChatList from './ChatList.svelte';
import ChatWindow from './ChatWindow.svelte';
import { MessageSquare, Minus, Maximize2, X, Bell } from 'lucide-svelte';
const count = useQuery(api.chat.contarNotificacoesNaoLidas, {});
// Query para verificar o ID do usuário logado (usar como referência)
// 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, {});
// Usuário atual
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
// Função para obter a URL do avatar/foto do usuário logado (otimizada)
let avatarUrlDoUsuario = $derived(() => {
const usuario = currentUser?.data;
if (!usuario) return null;
// Priorizar perfil (tem mais informações)
const perfil = meuPerfilQuery?.data;
if (perfil?.fotoPerfilUrl) {
return perfil.fotoPerfilUrl;
}
// Prioridade: fotoPerfilUrl > avatar > fallback com nome
if (usuario.fotoPerfilUrl) {
// Fallback para currentUser
const usuario = currentUser?.data;
if (usuario?.fotoPerfilUrl) {
return usuario.fotoPerfilUrl;
}
@@ -50,6 +67,13 @@
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;
@@ -397,47 +421,29 @@
}
}
// Throttle para evitar execuções muito frequentes do effect
let ultimaExecucaoNotificacao = $state(0);
const THROTTLE_NOTIFICACAO_MS = 1000; // 1 segundo entre execuções
$effect(() => {
if (todasConversas?.data && currentUser?.data?._id) {
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();
// 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
});
if (!meuIdAtual) {
console.warn('⚠️ [ChatWidget] Não foi possível identificar o ID do usuário logado');
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;
@@ -447,21 +453,8 @@
? 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) {
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}`;
@@ -482,14 +475,29 @@
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
if (!isOpen || !estaConversaEstaAberta) {
// 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();
@@ -501,13 +509,17 @@
};
showGlobalNotificationPopup = true;
// Ocultar popup após 5 segundos
// 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
@@ -517,6 +529,14 @@
}
});
}
// Cleanup: limpar timeout quando o effect for desmontado
return () => {
if (globalNotificationTimeout) {
clearTimeout(globalNotificationTimeout);
globalNotificationTimeout = null;
}
};
});
function handleToggle() {
@@ -575,6 +595,56 @@
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
@@ -611,6 +681,136 @@
// 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);
@@ -745,10 +945,14 @@
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>
@@ -787,9 +991,16 @@
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) {
if (!shouldPreventClick && !hasMoved && !isTouching) {
handleToggle();
} else {
// Prevenir clique se houve movimento
@@ -798,30 +1009,54 @@
shouldPreventClick = false; // Resetar após prevenir
}
}}
aria-label="Abrir chat"
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 -->
<!-- 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.3) 50%, transparent 100%); animation: rotate 3s linear infinite;"
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 cursor-pointer rounded-lg opacity-0 transition-opacity duration-700 group-hover:opacity-60"
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 animate-pulse rounded-full opacity-30"
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 -->
<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"
<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));"
>
<path
d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"
/>
</svg>
strokeWidth={2}
/>
<!-- Badge ULTRA PREMIUM com gradiente e brilho -->
{#if count?.data && count.data > 0}
@@ -914,26 +1149,16 @@
{#if avatarUrlDoUsuario()}
<img
src={avatarUrlDoUsuario()}
alt={currentUser?.data?.nome || 'Usuário'}
alt={meuPerfilQuery?.data?.nome || 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"
<MessageSquare
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>
strokeWidth={2}
/>
{/if}
</div>
<span
@@ -955,19 +1180,11 @@
<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"
<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));"
>
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
strokeWidth={2.5}
/>
</button>
<!-- Botão maximizar MODERNO -->
@@ -981,21 +1198,11 @@
<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"
<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));"
>
<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>
strokeWidth={2.5}
/>
</button>
<!-- Botão fechar MODERNO -->
@@ -1009,20 +1216,11 @@
<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"
<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));"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
strokeWidth={2.5}
/>
</button>
</div>
</div>
@@ -1117,6 +1315,8 @@
</div>
{/if}
<!-- Indicador de Conexão -->
<!-- Popup Global de Notificação de Nova Mensagem (quando chat está fechado/minimizado) -->
{#if showGlobalNotificationPopup && globalNotificationMessage}
{@const notificationMsg = globalNotificationMessage}
@@ -1157,20 +1357,7 @@
>
<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>
<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">
@@ -1194,16 +1381,7 @@
}
}}
>
<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>
<X class="h-4 w-4" strokeWidth={2} />
</button>
</div>
</div>
@@ -1249,7 +1427,7 @@
}
}
/* Rotação para anel de brilho */
/* Rotação para anel de brilho - suavizada */
@keyframes rotate {
from {
transform: rotate(0deg);
@@ -1259,6 +1437,19 @@
}
}
/* 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% {