Files
sgse-app/apps/web/src/lib/components/chat/ChatWidget.svelte
deyvisonwanderley aa3e3470cd feat: enhance push notification management and error handling
- Implemented error handling for unhandled promise rejections related to message channels, improving stability during push notification operations.
- Updated the PushNotificationManager component to manage push subscription registration with timeouts, preventing application hangs.
- Enhanced the sidebar and chat components to display user avatars, improving user experience and visual consistency.
- Refactored email processing logic to support scheduled email sending, integrating new backend functionalities for better email management.
- Improved overall error handling and logging across components to reduce console spam and enhance debugging capabilities.
2025-11-05 06:14:52 -03:00

442 lines
14 KiB
Svelte

<script lang="ts">
import {
chatAberto,
chatMinimizado,
conversaAtiva,
fecharChat,
minimizarChat,
maximizarChat,
abrirChat,
} from "$lib/stores/chatStore";
import { useQuery } from "convex-svelte";
import { api } from "@sgse-app/backend/convex/_generated/api";
import ChatList from "./ChatList.svelte";
import ChatWindow from "./ChatWindow.svelte";
import { authStore } from "$lib/stores/auth.svelte";
import { getAvatarUrl } from "$lib/utils/avatarGenerator";
const count = useQuery(api.chat.contarNotificacoesNaoLidas, {});
let isOpen = $state(false);
let isMinimized = $state(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 = authStore.usuario;
if (!usuario) return null;
// Prioridade: fotoPerfilUrl > avatar > fallback com nome
if (usuario.fotoPerfilUrl) {
return usuario.fotoPerfilUrl;
}
if (usuario.avatar) {
return getAvatarUrl(usuario.avatar);
}
// Fallback: gerar avatar baseado no nome
return getAvatarUrl(usuario.nome);
});
// Posição do widget (arrastável)
let position = $state({ x: 0, y: 0 });
let isDragging = $state(false);
let dragStart = $state({ x: 0, y: 0 });
let isAnimating = $state(false);
// Sincronizar com stores
$effect(() => {
isOpen = $chatAberto;
});
$effect(() => {
isMinimized = $chatMinimizado;
});
$effect(() => {
activeConversation = $conversaAtiva;
});
function handleToggle() {
if (isOpen && !isMinimized) {
minimizarChat();
} else {
abrirChat();
}
}
function handleClose() {
fecharChat();
}
function handleMinimize() {
minimizarChat();
}
function handleMaximize() {
maximizarChat();
}
// Funcionalidade de arrastar
function handleMouseDown(e: MouseEvent) {
if (e.button !== 0) return; // Apenas botão esquerdo
isDragging = true;
dragStart = {
x: e.clientX - position.x,
y: e.clientY - position.y,
};
document.body.classList.add('dragging');
e.preventDefault();
}
function handleMouseMove(e: MouseEvent) {
if (!isDragging) return;
const newX = e.clientX - dragStart.x;
const newY = e.clientY - dragStart.y;
// Dimensões do widget
const widgetWidth = isOpen && !isMinimized ? 440 : 72;
const widgetHeight = isOpen && !isMinimized ? 680 : 72;
// Limites da tela com margem de segurança
const minX = -(widgetWidth - 100); // Permitir até 100px visíveis
const maxX = window.innerWidth - 100; // Manter 100px dentro da tela
const minY = -(widgetHeight - 100);
const maxY = window.innerHeight - 100;
position = {
x: Math.max(minX, Math.min(newX, maxX)),
y: Math.max(minY, Math.min(newY, maxY)),
};
}
function handleMouseUp() {
if (isDragging) {
isDragging = false;
document.body.classList.remove('dragging');
// Garantir que está dentro dos limites ao soltar
ajustarPosicao();
}
}
function ajustarPosicao() {
isAnimating = true;
// Dimensões do widget
const widgetWidth = isOpen && !isMinimized ? 440 : 72;
const widgetHeight = isOpen && !isMinimized ? 680 : 72;
// Verificar se está fora dos limites
let newX = position.x;
let newY = position.y;
// Ajustar X
if (newX < -(widgetWidth - 100)) {
newX = -(widgetWidth - 100);
} else if (newX > window.innerWidth - 100) {
newX = window.innerWidth - 100;
}
// Ajustar Y
if (newY < -(widgetHeight - 100)) {
newY = -(widgetHeight - 100);
} else if (newY > window.innerHeight - 100) {
newY = window.innerHeight - 100;
}
position = { x: newX, y: newY };
setTimeout(() => {
isAnimating = false;
}, 300);
}
// Event listeners globais
if (typeof window !== 'undefined') {
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseup', handleMouseUp);
}
</script>
<!-- Botão flutuante MODERNO E ARRASTÁVEL -->
{#if !isOpen || isMinimized}
<button
type="button"
class="fixed group relative border-0 backdrop-blur-xl"
style="
z-index: 99999 !important;
width: 4.5rem;
height: 4.5rem;
bottom: {position.y === 0 ? '1.5rem' : `${window.innerHeight - position.y - 72}px`};
right: {position.x === 0 ? '1.5rem' : `${window.innerWidth - position.x - 72}px`};
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'};
"
onclick={handleToggle}
onmousedown={handleMouseDown}
aria-label="Abrir chat"
>
<!-- Anel de brilho rotativo -->
<div class="absolute inset-0 rounded-full opacity-0 group-hover:opacity-100 transition-opacity duration-500"
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="w-7 h-7 relative z-10 text-white group-hover:scale-110 transition-all duration-500"
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 flex h-8 w-8 items-center justify-center rounded-full text-white text-xs font-black z-20"
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 transform -translate-x-1/2 flex gap-1 opacity-50 group-hover:opacity-100 transition-opacity">
<div class="w-1 h-1 rounded-full bg-white"></div>
<div class="w-1 h-1 rounded-full bg-white"></div>
<div class="w-1 h-1 rounded-full bg-white"></div>
</div>
</button>
{/if}
<!-- Janela do Chat ULTRA MODERNA E ARRASTÁVEL -->
{#if isOpen && !isMinimized}
<div
class="fixed flex flex-col overflow-hidden backdrop-blur-2xl"
style="
z-index: 99999 !important;
bottom: {position.y === 0 ? '1.5rem' : `${window.innerHeight - position.y - 680}px`};
right: {position.x === 0 ? '1.5rem' : `${window.innerWidth - position.x - 440}px`};
width: 440px;
height: 680px;
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="flex items-center justify-between px-6 py-5 text-white relative overflow-hidden"
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="text-xl font-bold flex items-center gap-3 relative z-10">
<!-- Avatar/Foto do usuário logado com efeito glassmorphism -->
<div class="relative flex items-center justify-center w-10 h-10 rounded-xl overflow-hidden" 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={authStore.usuario?.nome || "Usuário"}
class="w-full h-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="w-5 h-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="tracking-wide font-extrabold" 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="flex items-center gap-2 relative z-10">
<!-- Botão minimizar MODERNO -->
<button
type="button"
class="flex items-center justify-center w-10 h-10 rounded-xl transition-all duration-300 group relative overflow-hidden"
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 group-hover:bg-white/20 transition-colors duration-300"></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="w-5 h-5 relative z-10 group-hover:scale-110 transition-transform duration-300"
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 fechar MODERNO -->
<button
type="button"
class="flex items-center justify-center w-10 h-10 rounded-xl transition-all duration-300 group relative overflow-hidden"
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 group-hover:bg-red-500/30 transition-colors duration-300"></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="w-5 h-5 relative z-10 group-hover:scale-110 group-hover:rotate-90 transition-all duration-300"
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="flex-1 overflow-hidden">
{#if !activeConversation}
<ChatList />
{:else}
<ChatWindow conversaId={activeConversation} />
{/if}
</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>