feat: integrate Better Auth and enhance authentication flow
- Added Better Auth integration to the web application, allowing for dual login support with both custom and Better Auth systems. - Updated authentication client configuration to dynamically set the base URL based on the environment. - Enhanced chat components to utilize user authentication status, improving user experience and security. - Refactored various components to support Better Auth, including error handling and user identity management. - Improved notification handling and user feedback mechanisms during authentication processes.
This commit is contained in:
@@ -1,7 +1,21 @@
|
||||
import { createAuthClient } from "better-auth/client";
|
||||
/**
|
||||
* Cliente Better Auth para frontend SvelteKit
|
||||
*
|
||||
* Configurado para trabalhar com Convex via plugin convexClient.
|
||||
* Este cliente será usado para autenticação quando Better Auth estiver ativo.
|
||||
*/
|
||||
import { createAuthClient } from "better-auth/svelte";
|
||||
import { convexClient } from "@convex-dev/better-auth/client/plugins";
|
||||
|
||||
export const authClient = createAuthClient({
|
||||
baseURL: "http://localhost:5173",
|
||||
plugins: [convexClient()],
|
||||
// Base URL da API Better Auth (mesma do app)
|
||||
baseURL: typeof window !== "undefined"
|
||||
? window.location.origin // Usar origem atual em produção
|
||||
: "http://localhost:5173", // Fallback para desenvolvimento
|
||||
plugins: [
|
||||
// Plugin Convex integra Better Auth com Convex backend
|
||||
convexClient({
|
||||
convexUrl: import.meta.env.PUBLIC_CONVEX_URL || "",
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
@@ -113,14 +113,29 @@
|
||||
showAboutModal = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* FASE 2: Login dual - tenta Better Auth primeiro, fallback para sistema customizado
|
||||
*/
|
||||
async function handleLogin(e: Event) {
|
||||
e.preventDefault();
|
||||
erroLogin = "";
|
||||
carregandoLogin = true;
|
||||
|
||||
try {
|
||||
// Usar mutation normal com WebRTC para capturar IP
|
||||
// getBrowserInfo() tenta obter o IP local via WebRTC
|
||||
// FASE 2: Por enquanto, sistema customizado funciona normalmente
|
||||
// Quando Better Auth estiver configurado, tentaremos primeiro:
|
||||
//
|
||||
// try {
|
||||
// await authStore.loginWithBetterAuth(matricula, senha);
|
||||
// closeLoginModal();
|
||||
// goto("/");
|
||||
// return;
|
||||
// } catch (betterAuthError) {
|
||||
// // Fallback para sistema customizado
|
||||
// console.log("Better Auth falhou, usando sistema customizado");
|
||||
// }
|
||||
|
||||
// Sistema customizado (atual e funcionando)
|
||||
const browserInfo = await getBrowserInfo();
|
||||
|
||||
const resultado = await convex.mutation(api.autenticacao.login, {
|
||||
|
||||
@@ -18,6 +18,9 @@
|
||||
import { getAvatarUrl } from "$lib/utils/avatarGenerator";
|
||||
|
||||
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, {});
|
||||
|
||||
let isOpen = $state(false);
|
||||
let isMinimized = $state(false);
|
||||
@@ -40,12 +43,14 @@
|
||||
});
|
||||
|
||||
// Posição do widget (arrastável)
|
||||
let position = $state({ x: 0, y: 0 });
|
||||
// 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;
|
||||
@@ -76,7 +81,7 @@
|
||||
let windowSize = $state(getSavedSize());
|
||||
let isMaximized = $state(false);
|
||||
let previousSize = $state({ width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT });
|
||||
let previousPosition = $state({ x: 0, y: 0 });
|
||||
let previousPosition = $state<{ x: number; y: number } | null>(null);
|
||||
|
||||
// Dimensões da janela (reativo)
|
||||
let windowDimensions = $state({ width: 0, height: 0 });
|
||||
@@ -97,10 +102,36 @@
|
||||
|
||||
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
|
||||
ajustarPosicao();
|
||||
if (position) {
|
||||
ajustarPosicao();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
@@ -109,6 +140,13 @@
|
||||
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() {
|
||||
@@ -138,7 +176,7 @@
|
||||
}
|
||||
|
||||
function handleResizeMove(e: MouseEvent) {
|
||||
if (!isResizing || !resizeDirection) return;
|
||||
if (!isResizing || !resizeDirection || !position) return;
|
||||
|
||||
const deltaX = e.clientX - resizeStart.x;
|
||||
const deltaY = e.clientY - resizeStart.y;
|
||||
@@ -193,6 +231,63 @@
|
||||
|
||||
$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 && authStore.usuario?._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
|
||||
@@ -294,15 +389,73 @@
|
||||
const conversas = todasConversas.data as ConversaComTimestamp[];
|
||||
|
||||
// Encontrar conversas com novas mensagens
|
||||
const meuId = String(authStore.usuario._id);
|
||||
// Obter ID do usuário logado de forma robusta
|
||||
// Prioridade: usar query do Convex (mais confiável) > authStore
|
||||
const usuarioLogado = authStore.usuario;
|
||||
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:", {
|
||||
authStore: !!usuarioLogado,
|
||||
authStoreId: 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" : "AuthStore",
|
||||
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
|
||||
const remetenteIdStr = conv.ultimaMensagemRemetenteId ? String(conv.ultimaMensagemRemetenteId) : null;
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -312,12 +465,15 @@
|
||||
// Verificar se já foi notificada
|
||||
if (mensagensNotificadasGlobal.has(mensagemId)) return;
|
||||
|
||||
const conversaAtivaId = activeConversation ? String(activeConversation) : null;
|
||||
const conversaIdStr = String(conv._id);
|
||||
const conversaAtivaId = activeConversation ? String(activeConversation).trim() : null;
|
||||
const conversaIdStr = String(conv._id).trim();
|
||||
const estaConversaEstaAberta = conversaAtivaId === conversaIdStr;
|
||||
|
||||
// Só mostrar notificação se não estamos vendo essa conversa
|
||||
if (!isOpen || conversaAtivaId !== conversaIdStr) {
|
||||
// Marcar como notificada antes de tocar som (evita duplicação)
|
||||
// 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();
|
||||
|
||||
@@ -340,6 +496,11 @@
|
||||
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();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -362,10 +523,14 @@
|
||||
}
|
||||
|
||||
function handleMaximize() {
|
||||
if (!position) return;
|
||||
|
||||
if (isMaximized) {
|
||||
// Restaurar tamanho anterior
|
||||
windowSize = previousSize;
|
||||
position = previousPosition;
|
||||
if (previousPosition) {
|
||||
position = previousPosition;
|
||||
}
|
||||
isMaximized = false;
|
||||
saveSize();
|
||||
ajustarPosicao();
|
||||
@@ -395,27 +560,36 @@
|
||||
|
||||
// Funcionalidade de arrastar
|
||||
function handleMouseDown(e: MouseEvent) {
|
||||
if (e.button !== 0) return; // Apenas botão esquerdo
|
||||
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) return;
|
||||
// Resetar flag de movimento
|
||||
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
|
||||
}
|
||||
@@ -426,45 +600,52 @@
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isDragging) 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
|
||||
// Verificar se houve movimento significativo desde o último frame
|
||||
const deltaX = Math.abs(newX - position.x);
|
||||
const deltaY = Math.abs(newY - position.y);
|
||||
if (deltaX > dragThreshold || deltaY > dragThreshold) {
|
||||
hasMoved = true;
|
||||
|
||||
// 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)),
|
||||
};
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
||||
position = {
|
||||
x: Math.max(minX, Math.min(newX, maxX)),
|
||||
y: Math.max(minY, Math.min(newY, maxY)),
|
||||
};
|
||||
}
|
||||
|
||||
function handleMouseUp(e?: MouseEvent) {
|
||||
const hadMoved = hasMoved;
|
||||
const shouldPrevent = shouldPreventClick;
|
||||
|
||||
if (isDragging) {
|
||||
isDragging = false;
|
||||
hasMoved = false;
|
||||
document.body.classList.remove('dragging');
|
||||
|
||||
// Se estava arrastando e houve movimento, prevenir clique
|
||||
if (hadMoved && e) {
|
||||
@@ -474,6 +655,17 @@
|
||||
|
||||
// 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();
|
||||
|
||||
@@ -481,6 +673,8 @@
|
||||
}
|
||||
|
||||
function ajustarPosicao() {
|
||||
if (!position) return;
|
||||
|
||||
isAnimating = true;
|
||||
|
||||
// Dimensões do widget
|
||||
@@ -517,6 +711,9 @@
|
||||
|
||||
position = { x: newX, y: newY };
|
||||
|
||||
// Salvar posição após ajuste
|
||||
savePosition();
|
||||
|
||||
setTimeout(() => {
|
||||
isAnimating = false;
|
||||
}, 300);
|
||||
@@ -537,11 +734,11 @@
|
||||
</script>
|
||||
|
||||
<!-- Botão flutuante MODERNO E ARRASTÁVEL -->
|
||||
{#if !isOpen || isMinimized}
|
||||
{#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 = position.y === 0 ? '1.5rem' : `${Math.max(0, winHeight - position.y - 72)}px`}
|
||||
{@const rightPos = position.x === 0 ? '1.5rem' : `${Math.max(0, winWidth - position.x - 72)}px`}
|
||||
{@const bottomPos = `${Math.max(0, winHeight - position.y - 72)}px`}
|
||||
{@const rightPos = `${Math.max(0, winWidth - position.x - 72)}px`}
|
||||
<button
|
||||
type="button"
|
||||
class="fixed group relative border-0 backdrop-blur-xl"
|
||||
@@ -564,22 +761,17 @@
|
||||
"
|
||||
onmousedown={handleButtonMouseDown}
|
||||
onmouseup={(e) => {
|
||||
const hadMovedBefore = hasMoved;
|
||||
handleMouseUp(e);
|
||||
// Se houve movimento, prevenir o clique
|
||||
if (hadMovedBefore) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
}}
|
||||
onclick={(e) => {
|
||||
// Só executar toggle se não houve movimento
|
||||
if (!hasMoved) {
|
||||
// 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"
|
||||
@@ -638,11 +830,11 @@
|
||||
{/if}
|
||||
|
||||
<!-- Janela do Chat ULTRA MODERNA E ARRASTÁVEL -->
|
||||
{#if isOpen && !isMinimized}
|
||||
{#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 = position.y === 0 ? '1.5rem' : `${Math.max(0, winHeight - position.y - windowSize.height)}px`}
|
||||
{@const rightPos = position.x === 0 ? '1.5rem' : `${Math.max(0, winWidth - position.x - windowSize.width)}px`}
|
||||
{@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="
|
||||
@@ -801,47 +993,79 @@
|
||||
<!-- Resize Handles -->
|
||||
<!-- Top -->
|
||||
<div
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label="Redimensionar janela pela borda superior"
|
||||
class="absolute top-0 left-0 right-0 h-2 cursor-ns-resize hover:bg-primary/20 transition-colors z-50"
|
||||
onmousedown={(e) => handleResizeStart(e, 'n')}
|
||||
onkeydown={(e) => e.key === 'Enter' && handleResizeStart(e as any, 'n')}
|
||||
style="border-radius: 24px 24px 0 0;"
|
||||
></div>
|
||||
<!-- Bottom -->
|
||||
<div
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label="Redimensionar janela pela borda inferior"
|
||||
class="absolute bottom-0 left-0 right-0 h-2 cursor-ns-resize hover:bg-primary/20 transition-colors z-50"
|
||||
onmousedown={(e) => handleResizeStart(e, 's')}
|
||||
onkeydown={(e) => e.key === 'Enter' && handleResizeStart(e as any, 's')}
|
||||
style="border-radius: 0 0 24px 24px;"
|
||||
></div>
|
||||
<!-- Left -->
|
||||
<div
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label="Redimensionar janela pela borda esquerda"
|
||||
class="absolute top-0 bottom-0 left-0 w-2 cursor-ew-resize hover:bg-primary/20 transition-colors z-50"
|
||||
onmousedown={(e) => handleResizeStart(e, 'w')}
|
||||
onkeydown={(e) => e.key === 'Enter' && handleResizeStart(e as any, 'w')}
|
||||
style="border-radius: 24px 0 0 24px;"
|
||||
></div>
|
||||
<!-- Right -->
|
||||
<div
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label="Redimensionar janela pela borda direita"
|
||||
class="absolute top-0 bottom-0 right-0 w-2 cursor-ew-resize hover:bg-primary/20 transition-colors z-50"
|
||||
onmousedown={(e) => handleResizeStart(e, 'e')}
|
||||
onkeydown={(e) => e.key === 'Enter' && handleResizeStart(e as any, 'e')}
|
||||
style="border-radius: 0 24px 24px 0;"
|
||||
></div>
|
||||
<!-- Corners -->
|
||||
<div
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label="Redimensionar janela pelo canto superior esquerdo"
|
||||
class="absolute top-0 left-0 w-4 h-4 cursor-nwse-resize hover:bg-primary/20 transition-colors z-50"
|
||||
onmousedown={(e) => handleResizeStart(e, 'nw')}
|
||||
onkeydown={(e) => e.key === 'Enter' && handleResizeStart(e as any, 'nw')}
|
||||
style="border-radius: 24px 0 0 0;"
|
||||
></div>
|
||||
<div
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label="Redimensionar janela pelo canto superior direito"
|
||||
class="absolute top-0 right-0 w-4 h-4 cursor-nesw-resize hover:bg-primary/20 transition-colors z-50"
|
||||
onmousedown={(e) => handleResizeStart(e, 'ne')}
|
||||
onkeydown={(e) => e.key === 'Enter' && handleResizeStart(e as any, 'ne')}
|
||||
style="border-radius: 0 24px 0 0;"
|
||||
></div>
|
||||
<div
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label="Redimensionar janela pelo canto inferior esquerdo"
|
||||
class="absolute bottom-0 left-0 w-4 h-4 cursor-nesw-resize hover:bg-primary/20 transition-colors z-50"
|
||||
onmousedown={(e) => handleResizeStart(e, 'sw')}
|
||||
onkeydown={(e) => e.key === 'Enter' && handleResizeStart(e as any, 'sw')}
|
||||
style="border-radius: 0 0 0 24px;"
|
||||
></div>
|
||||
<div
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label="Redimensionar janela pelo canto inferior direito"
|
||||
class="absolute bottom-0 right-0 w-4 h-4 cursor-nwse-resize hover:bg-primary/20 transition-colors z-50"
|
||||
onmousedown={(e) => handleResizeStart(e, 'se')}
|
||||
onkeydown={(e) => e.key === 'Enter' && handleResizeStart(e as any, 'se')}
|
||||
style="border-radius: 0 0 24px 0;"
|
||||
></div>
|
||||
</div>
|
||||
@@ -850,18 +1074,40 @@
|
||||
|
||||
<!-- 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="fixed top-4 right-4 z-[1000] bg-base-100 rounded-lg shadow-2xl border border-primary/20 p-4 max-w-sm cursor-pointer"
|
||||
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
|
||||
abrirChat();
|
||||
abrirConversa(globalNotificationMessage.conversaId as Id<"conversas">);
|
||||
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">
|
||||
@@ -878,12 +1124,13 @@
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="font-semibold text-base-content text-sm mb-1">Nova mensagem de {globalNotificationMessage.remetente}</p>
|
||||
<p class="text-xs text-base-content/70 line-clamp-2">{globalNotificationMessage.conteudo}</p>
|
||||
<p class="font-semibold text-base-content text-sm mb-1">Nova mensagem de {notificationMsg.remetente}</p>
|
||||
<p class="text-xs text-base-content/70 line-clamp-2">{notificationMsg.conteudo}</p>
|
||||
<p class="text-xs text-primary mt-1">Clique para abrir</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Fechar notificação"
|
||||
class="flex-shrink-0 w-6 h-6 rounded-full hover:bg-base-200 flex items-center justify-center transition-colors"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
import SalaReuniaoManager from "./SalaReuniaoManager.svelte";
|
||||
import { getAvatarUrl } from "$lib/utils/avatarGenerator";
|
||||
import { authStore } from "$lib/stores/auth.svelte";
|
||||
import { setupConvexAuth } from "$lib/hooks/convexAuth";
|
||||
import { Bell, X, ArrowLeft, LogOut, MoreVertical, Users, Clock, XCircle } from "lucide-svelte";
|
||||
|
||||
interface Props {
|
||||
@@ -21,13 +22,15 @@
|
||||
|
||||
const client = useConvexClient();
|
||||
|
||||
// Token é passado automaticamente via interceptadores em +layout.svelte
|
||||
|
||||
let showScheduleModal = $state(false);
|
||||
let showSalaManager = $state(false);
|
||||
let showAdminMenu = $state(false);
|
||||
let showNotificacaoModal = $state(false);
|
||||
|
||||
const conversas = useQuery(api.chat.listarConversas, {});
|
||||
const isAdmin = useQuery(api.chat.verificarSeEhAdmin, { conversaId: conversaId as any });
|
||||
const isAdmin = useQuery(api.chat.verificarSeEhAdmin, { conversaId: conversaId as Id<"conversas"> });
|
||||
|
||||
const conversa = $derived(() => {
|
||||
console.log("🔍 [ChatWindow] Buscando conversa ID:", conversaId);
|
||||
@@ -91,7 +94,7 @@
|
||||
|
||||
try {
|
||||
const resultado = await client.mutation(api.chat.sairGrupoOuSala, {
|
||||
conversaId: conversaId as any,
|
||||
conversaId: conversaId as Id<"conversas">,
|
||||
});
|
||||
|
||||
if (resultado.sucesso) {
|
||||
@@ -99,9 +102,10 @@
|
||||
} else {
|
||||
alert(resultado.erro || "Erro ao sair da conversa");
|
||||
}
|
||||
} catch (error: any) {
|
||||
} catch (error) {
|
||||
console.error("Erro ao sair da conversa:", error);
|
||||
alert(error.message || "Erro ao sair da conversa");
|
||||
const errorMessage = error instanceof Error ? error.message : "Erro ao sair da conversa";
|
||||
alert(errorMessage);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -282,7 +286,7 @@
|
||||
if (!confirm("Tem certeza que deseja encerrar esta reunião? Todos os participantes serão removidos.")) return;
|
||||
try {
|
||||
const resultado = await client.mutation(api.chat.encerrarReuniao, {
|
||||
conversaId: conversaId as any,
|
||||
conversaId: conversaId as Id<"conversas">,
|
||||
});
|
||||
if (resultado.sucesso) {
|
||||
alert("Reunião encerrada com sucesso!");
|
||||
@@ -290,8 +294,9 @@
|
||||
} else {
|
||||
alert(resultado.erro || "Erro ao encerrar reunião");
|
||||
}
|
||||
} catch (error: any) {
|
||||
alert(error.message || "Erro ao encerrar reunião");
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Erro ao encerrar reunião";
|
||||
alert(errorMessage);
|
||||
}
|
||||
showAdminMenu = false;
|
||||
})();
|
||||
@@ -384,7 +389,7 @@
|
||||
|
||||
try {
|
||||
const resultado = await client.mutation(api.chat.enviarNotificacaoReuniao, {
|
||||
conversaId: conversaId as any,
|
||||
conversaId: conversaId as Id<"conversas">,
|
||||
titulo: titulo.trim(),
|
||||
mensagem: mensagem.trim(),
|
||||
});
|
||||
@@ -395,8 +400,9 @@
|
||||
} else {
|
||||
alert(resultado.erro || "Erro ao enviar notificação");
|
||||
}
|
||||
} catch (error: any) {
|
||||
alert(error.message || "Erro ao enviar notificação");
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Erro ao enviar notificação";
|
||||
alert(errorMessage);
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -2,8 +2,11 @@
|
||||
import { useConvexClient } from "convex-svelte";
|
||||
import { api } from "@sgse-app/backend/convex/_generated/api";
|
||||
import { onMount } from "svelte";
|
||||
import { authStore } from "$lib/stores/auth.svelte";
|
||||
|
||||
const client = useConvexClient();
|
||||
|
||||
// Token é passado automaticamente via interceptadores em +layout.svelte
|
||||
|
||||
let heartbeatInterval: ReturnType<typeof setInterval> | null = null;
|
||||
let inactivityTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
26
apps/web/src/lib/hooks/convexAuth.ts
Normal file
26
apps/web/src/lib/hooks/convexAuth.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Hook para garantir que o cliente Convex tenha o token configurado
|
||||
*
|
||||
* NOTA: O token é passado automaticamente via monkey patch no +layout.svelte
|
||||
* Este hook existe apenas para compatibilidade, mas não faz nada agora.
|
||||
* O token é injetado via headers nas requisições HTTP através do monkey patch.
|
||||
*/
|
||||
|
||||
import { authStore } from "$lib/stores/auth.svelte";
|
||||
|
||||
/**
|
||||
* Configura o token no cliente Convex
|
||||
*
|
||||
* IMPORTANTE: O token agora é passado automaticamente via monkey patch global.
|
||||
* Este hook é mantido para compatibilidade mas não precisa ser chamado.
|
||||
*
|
||||
* @param client - Cliente Convex retornado por useConvexClient()
|
||||
*/
|
||||
export function setupConvexAuth(client: unknown) {
|
||||
// Token é passado automaticamente via monkey patch em +layout.svelte
|
||||
// Não precisamos fazer nada aqui, apenas manter compatibilidade
|
||||
if (import.meta.env.DEV && client && authStore.token) {
|
||||
console.log("✅ [setupConvexAuth] Token disponível (gerenciado via monkey patch):", authStore.token.substring(0, 20) + "...");
|
||||
}
|
||||
}
|
||||
|
||||
45
apps/web/src/lib/hooks/useConvexWithAuth.ts
Normal file
45
apps/web/src/lib/hooks/useConvexWithAuth.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Hook personalizado que garante autenticação no Convex
|
||||
*
|
||||
* Este hook substitui useConvexClient e garante que o token seja sempre passado
|
||||
*
|
||||
* NOTA: Este hook deve ser usado dentro de componentes Svelte com $effect
|
||||
*/
|
||||
|
||||
import { useConvexClient } from "convex-svelte";
|
||||
import { authStore } from "$lib/stores/auth.svelte";
|
||||
|
||||
interface ConvexClientWithAuth {
|
||||
setAuth?: (token: string) => void;
|
||||
clearAuth?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook que retorna cliente Convex com autenticação configurada automaticamente
|
||||
*
|
||||
* IMPORTANTE: Use $effect() no componente para chamar esta função:
|
||||
* ```svelte
|
||||
* $effect(() => {
|
||||
* useConvexWithAuth();
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function useConvexWithAuth() {
|
||||
const client = useConvexClient();
|
||||
const token = authStore.token;
|
||||
const clientWithAuth = client as ConvexClientWithAuth;
|
||||
|
||||
// Configurar token se disponível
|
||||
if (clientWithAuth && typeof clientWithAuth.setAuth === "function" && token) {
|
||||
try {
|
||||
clientWithAuth.setAuth(token);
|
||||
if (import.meta.env.DEV) {
|
||||
console.log("✅ [useConvexWithAuth] Token configurado:", token.substring(0, 20) + "...");
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("⚠️ [useConvexWithAuth] Erro ao configurar token:", e);
|
||||
}
|
||||
}
|
||||
|
||||
return client;
|
||||
}
|
||||
@@ -67,6 +67,10 @@ class AuthStore {
|
||||
return this.state.usuario?.role.nome === "rh" || this.isAdmin;
|
||||
}
|
||||
|
||||
/**
|
||||
* FASE 2: Login dual - suporta tanto sistema customizado quanto Better Auth
|
||||
* Por enquanto, mantém sistema customizado. Better Auth será adicionado depois.
|
||||
*/
|
||||
login(usuario: Usuario, token: string) {
|
||||
this.state.usuario = usuario;
|
||||
this.state.token = token;
|
||||
@@ -75,8 +79,33 @@ class AuthStore {
|
||||
if (browser) {
|
||||
localStorage.setItem("auth_token", token);
|
||||
localStorage.setItem("auth_usuario", JSON.stringify(usuario));
|
||||
|
||||
// FASE 2: Preparar para Better Auth (ainda não ativo)
|
||||
// Quando Better Auth estiver configurado, também salvaremos sessão do Better Auth aqui
|
||||
if (import.meta.env.DEV) {
|
||||
console.log("✅ [AuthStore] Login realizado:", {
|
||||
usuario: usuario.nome,
|
||||
email: usuario.email,
|
||||
sistema: "customizado" // Será "better-auth" quando migrado
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* FASE 2: Login via Better Auth (preparado para futuro)
|
||||
* Por enquanto não implementado, será usado quando Better Auth estiver completo
|
||||
*/
|
||||
async loginWithBetterAuth(email: string, senha: string) {
|
||||
// TODO: Implementar quando Better Auth estiver pronto
|
||||
// const { authClient } = await import("$lib/auth");
|
||||
// const result = await authClient.signIn.email({ email, password: senha });
|
||||
// if (result.data) {
|
||||
// // Obter perfil do usuário do Convex
|
||||
// // this.login(usuario, result.data.session.token);
|
||||
// }
|
||||
throw new Error("Better Auth ainda não configurado. Use login customizado.");
|
||||
}
|
||||
|
||||
logout() {
|
||||
this.state.usuario = null;
|
||||
|
||||
64
apps/web/src/lib/stores/convexAuth.ts
Normal file
64
apps/web/src/lib/stores/convexAuth.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Helper para garantir que o token seja passado para todas requisições Convex
|
||||
*
|
||||
* Este store reativa garante que quando o token mudar no authStore,
|
||||
* todos os clientes Convex sejam atualizados automaticamente.
|
||||
*/
|
||||
|
||||
import { authStore } from "./auth.svelte";
|
||||
import { browser } from "$app/environment";
|
||||
import { PUBLIC_CONVEX_URL } from "$env/static/public";
|
||||
|
||||
let convexClients = new Set<any>();
|
||||
|
||||
/**
|
||||
* Registrar um cliente Convex para receber atualizações de token
|
||||
*/
|
||||
export function registerConvexClient(client: any) {
|
||||
if (!browser) return;
|
||||
|
||||
convexClients.add(client);
|
||||
|
||||
// Configurar token inicial
|
||||
if (authStore.token && client.setAuth) {
|
||||
client.setAuth(authStore.token);
|
||||
}
|
||||
|
||||
// Retornar função de limpeza
|
||||
return () => {
|
||||
convexClients.delete(client);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Atualizar token em todos clientes registrados
|
||||
*/
|
||||
function updateAllClients() {
|
||||
if (!browser) return;
|
||||
|
||||
const token = authStore.token;
|
||||
convexClients.forEach((client) => {
|
||||
if (client && typeof client.setAuth === "function") {
|
||||
if (token) {
|
||||
client.setAuth(token);
|
||||
} else {
|
||||
client.clearAuth?.();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Observar mudanças no token e atualizar clientes
|
||||
if (browser) {
|
||||
// Usar uma abordagem reativa simples
|
||||
let lastToken: string | null = null;
|
||||
|
||||
setInterval(() => {
|
||||
const currentToken = authStore.token;
|
||||
if (currentToken !== lastToken) {
|
||||
lastToken = currentToken;
|
||||
updateAllClients();
|
||||
}
|
||||
}, 500); // Verificar a cada 500ms
|
||||
}
|
||||
|
||||
@@ -1,266 +1,266 @@
|
||||
/**
|
||||
* Solicita permissão para notificações desktop
|
||||
*/
|
||||
export async function requestNotificationPermission(): Promise<NotificationPermission> {
|
||||
if (!("Notification" in window)) {
|
||||
console.warn("Este navegador não suporta notificações desktop");
|
||||
return "denied";
|
||||
}
|
||||
|
||||
if (Notification.permission === "granted") {
|
||||
return "granted";
|
||||
}
|
||||
|
||||
if (Notification.permission !== "denied") {
|
||||
return await Notification.requestPermission();
|
||||
}
|
||||
|
||||
return Notification.permission;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mostra uma notificação desktop
|
||||
*/
|
||||
export function showNotification(title: string, options?: NotificationOptions): Notification | null {
|
||||
if (!("Notification" in window)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (Notification.permission !== "granted") {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return new Notification(title, {
|
||||
icon: "/favicon.png",
|
||||
badge: "/favicon.png",
|
||||
...options,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Erro ao exibir notificação:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toca o som de notificação
|
||||
*/
|
||||
export function playNotificationSound() {
|
||||
try {
|
||||
const audio = new Audio("/sounds/notification.mp3");
|
||||
audio.volume = 0.5;
|
||||
audio.play().catch((err) => {
|
||||
console.warn("Não foi possível reproduzir o som de notificação:", err);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Erro ao tocar som de notificação:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifica se o usuário está na aba ativa
|
||||
*/
|
||||
export function isTabActive(): boolean {
|
||||
return !document.hidden;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registrar service worker para push notifications
|
||||
*/
|
||||
export async function registrarServiceWorker(): Promise<ServiceWorkerRegistration | null> {
|
||||
if (!("serviceWorker" in navigator)) {
|
||||
console.warn("Service Workers não são suportados neste navegador");
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// Verificar se já existe um Service Worker ativo antes de registrar
|
||||
const existingRegistration = await navigator.serviceWorker.getRegistration("/");
|
||||
if (existingRegistration?.active) {
|
||||
return existingRegistration;
|
||||
}
|
||||
|
||||
// Registrar com timeout para evitar travamentos
|
||||
const registerPromise = navigator.serviceWorker.register("/sw.js", {
|
||||
scope: "/",
|
||||
});
|
||||
|
||||
const timeoutPromise = new Promise<ServiceWorkerRegistration | null>((resolve) =>
|
||||
setTimeout(() => resolve(null), 3000)
|
||||
);
|
||||
|
||||
const registration = await Promise.race([registerPromise, timeoutPromise]);
|
||||
|
||||
if (registration) {
|
||||
// Log apenas em desenvolvimento
|
||||
if (import.meta.env.DEV) {
|
||||
console.log("Service Worker registrado:", registration);
|
||||
}
|
||||
}
|
||||
|
||||
return registration;
|
||||
} catch (error) {
|
||||
// Ignorar erros silenciosamente para evitar spam no console
|
||||
// especialmente erros relacionados a message channel
|
||||
if (error instanceof Error) {
|
||||
const errorMessage = error.message.toLowerCase();
|
||||
if (
|
||||
!errorMessage.includes("message channel") &&
|
||||
!errorMessage.includes("registration") &&
|
||||
import.meta.env.DEV
|
||||
) {
|
||||
console.error("Erro ao registrar Service Worker:", error);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Solicitar subscription de push notification
|
||||
*/
|
||||
export async function solicitarPushSubscription(): Promise<PushSubscription | null> {
|
||||
try {
|
||||
// Registrar service worker primeiro com timeout
|
||||
const registrationPromise = registrarServiceWorker();
|
||||
const timeoutPromise = new Promise<null>((resolve) =>
|
||||
setTimeout(() => resolve(null), 3000)
|
||||
);
|
||||
|
||||
const registration = await Promise.race([registrationPromise, timeoutPromise]);
|
||||
if (!registration) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Verificar se push está disponível
|
||||
if (!("PushManager" in window)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Solicitar permissão com timeout
|
||||
const permissionPromise = requestNotificationPermission();
|
||||
const permissionTimeoutPromise = new Promise<NotificationPermission>((resolve) =>
|
||||
setTimeout(() => resolve("denied"), 3000)
|
||||
);
|
||||
|
||||
const permission = await Promise.race([permissionPromise, permissionTimeoutPromise]);
|
||||
if (permission !== "granted") {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Obter subscription existente ou criar nova com timeout
|
||||
const getSubscriptionPromise = registration.pushManager.getSubscription();
|
||||
const getSubscriptionTimeoutPromise = new Promise<PushSubscription | null>((resolve) =>
|
||||
setTimeout(() => resolve(null), 3000)
|
||||
);
|
||||
|
||||
let subscription = await Promise.race([getSubscriptionPromise, getSubscriptionTimeoutPromise]);
|
||||
|
||||
if (!subscription) {
|
||||
// VAPID public key deve vir do backend ou config
|
||||
const vapidPublicKey = import.meta.env.VITE_VAPID_PUBLIC_KEY || "";
|
||||
|
||||
if (!vapidPublicKey) {
|
||||
// Não logar warning para evitar spam no console
|
||||
return null;
|
||||
}
|
||||
|
||||
// Converter chave para formato Uint8Array
|
||||
const applicationServerKey = urlBase64ToUint8Array(vapidPublicKey);
|
||||
|
||||
// Subscribe com timeout
|
||||
const subscribePromise = registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey,
|
||||
});
|
||||
|
||||
const subscribeTimeoutPromise = new Promise<PushSubscription | null>((resolve) =>
|
||||
setTimeout(() => resolve(null), 5000)
|
||||
);
|
||||
|
||||
subscription = await Promise.race([subscribePromise, subscribeTimeoutPromise]);
|
||||
}
|
||||
|
||||
return subscription;
|
||||
} catch (error) {
|
||||
// Ignorar erros relacionados a message channel ou service worker
|
||||
if (error instanceof Error) {
|
||||
const errorMessage = error.message.toLowerCase();
|
||||
if (
|
||||
errorMessage.includes("message channel") ||
|
||||
errorMessage.includes("service worker") ||
|
||||
errorMessage.includes("registration")
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converter chave VAPID de base64 URL-safe para Uint8Array
|
||||
*/
|
||||
function urlBase64ToUint8Array(base64String: string): Uint8Array {
|
||||
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
|
||||
const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
|
||||
|
||||
const rawData = window.atob(base64);
|
||||
const outputArray = new Uint8Array(rawData.length);
|
||||
|
||||
for (let i = 0; i < rawData.length; ++i) {
|
||||
outputArray[i] = rawData.charCodeAt(i);
|
||||
}
|
||||
return outputArray;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converter PushSubscription para formato serializável
|
||||
*/
|
||||
export function subscriptionToJSON(subscription: PushSubscription): {
|
||||
endpoint: string;
|
||||
keys: { p256dh: string; auth: string };
|
||||
} {
|
||||
const key = subscription.getKey("p256dh");
|
||||
const auth = subscription.getKey("auth");
|
||||
|
||||
if (!key || !auth) {
|
||||
throw new Error("Chaves de subscription não encontradas");
|
||||
}
|
||||
|
||||
return {
|
||||
endpoint: subscription.endpoint,
|
||||
keys: {
|
||||
p256dh: arrayBufferToBase64(key),
|
||||
auth: arrayBufferToBase64(auth),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Converter ArrayBuffer para base64
|
||||
*/
|
||||
function arrayBufferToBase64(buffer: ArrayBuffer): string {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
let binary = "";
|
||||
for (let i = 0; i < bytes.byteLength; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return window.btoa(binary);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remover subscription de push notification
|
||||
*/
|
||||
export async function removerPushSubscription(): Promise<boolean> {
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
const subscription = await registration.pushManager.getSubscription();
|
||||
|
||||
if (subscription) {
|
||||
await subscription.unsubscribe();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Solicita permissão para notificações desktop
|
||||
*/
|
||||
export async function requestNotificationPermission(): Promise<NotificationPermission> {
|
||||
if (!("Notification" in window)) {
|
||||
console.warn("Este navegador não suporta notificações desktop");
|
||||
return "denied";
|
||||
}
|
||||
|
||||
if (Notification.permission === "granted") {
|
||||
return "granted";
|
||||
}
|
||||
|
||||
if (Notification.permission !== "denied") {
|
||||
return await Notification.requestPermission();
|
||||
}
|
||||
|
||||
return Notification.permission;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mostra uma notificação desktop
|
||||
*/
|
||||
export function showNotification(title: string, options?: NotificationOptions): Notification | null {
|
||||
if (!("Notification" in window)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (Notification.permission !== "granted") {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return new Notification(title, {
|
||||
icon: "/favicon.png",
|
||||
badge: "/favicon.png",
|
||||
...options,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Erro ao exibir notificação:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toca o som de notificação
|
||||
*/
|
||||
export function playNotificationSound() {
|
||||
try {
|
||||
const audio = new Audio("/sounds/notification.mp3");
|
||||
audio.volume = 0.5;
|
||||
audio.play().catch((err) => {
|
||||
console.warn("Não foi possível reproduzir o som de notificação:", err);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Erro ao tocar som de notificação:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifica se o usuário está na aba ativa
|
||||
*/
|
||||
export function isTabActive(): boolean {
|
||||
return !document.hidden;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registrar service worker para push notifications
|
||||
*/
|
||||
export async function registrarServiceWorker(): Promise<ServiceWorkerRegistration | null> {
|
||||
if (!("serviceWorker" in navigator)) {
|
||||
console.warn("Service Workers não são suportados neste navegador");
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// Verificar se já existe um Service Worker ativo antes de registrar
|
||||
const existingRegistration = await navigator.serviceWorker.getRegistration("/");
|
||||
if (existingRegistration?.active) {
|
||||
return existingRegistration;
|
||||
}
|
||||
|
||||
// Registrar com timeout para evitar travamentos
|
||||
const registerPromise = navigator.serviceWorker.register("/sw.js", {
|
||||
scope: "/",
|
||||
});
|
||||
|
||||
const timeoutPromise = new Promise<ServiceWorkerRegistration | null>((resolve) =>
|
||||
setTimeout(() => resolve(null), 3000)
|
||||
);
|
||||
|
||||
const registration = await Promise.race([registerPromise, timeoutPromise]);
|
||||
|
||||
if (registration) {
|
||||
// Log apenas em desenvolvimento
|
||||
if (import.meta.env.DEV) {
|
||||
console.log("Service Worker registrado:", registration);
|
||||
}
|
||||
}
|
||||
|
||||
return registration;
|
||||
} catch (error) {
|
||||
// Ignorar erros silenciosamente para evitar spam no console
|
||||
// especialmente erros relacionados a message channel
|
||||
if (error instanceof Error) {
|
||||
const errorMessage = error.message.toLowerCase();
|
||||
if (
|
||||
!errorMessage.includes("message channel") &&
|
||||
!errorMessage.includes("registration") &&
|
||||
import.meta.env.DEV
|
||||
) {
|
||||
console.error("Erro ao registrar Service Worker:", error);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Solicitar subscription de push notification
|
||||
*/
|
||||
export async function solicitarPushSubscription(): Promise<PushSubscription | null> {
|
||||
try {
|
||||
// Registrar service worker primeiro com timeout
|
||||
const registrationPromise = registrarServiceWorker();
|
||||
const timeoutPromise = new Promise<null>((resolve) =>
|
||||
setTimeout(() => resolve(null), 3000)
|
||||
);
|
||||
|
||||
const registration = await Promise.race([registrationPromise, timeoutPromise]);
|
||||
if (!registration) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Verificar se push está disponível
|
||||
if (!("PushManager" in window)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Solicitar permissão com timeout
|
||||
const permissionPromise = requestNotificationPermission();
|
||||
const permissionTimeoutPromise = new Promise<NotificationPermission>((resolve) =>
|
||||
setTimeout(() => resolve("denied"), 3000)
|
||||
);
|
||||
|
||||
const permission = await Promise.race([permissionPromise, permissionTimeoutPromise]);
|
||||
if (permission !== "granted") {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Obter subscription existente ou criar nova com timeout
|
||||
const getSubscriptionPromise = registration.pushManager.getSubscription();
|
||||
const getSubscriptionTimeoutPromise = new Promise<PushSubscription | null>((resolve) =>
|
||||
setTimeout(() => resolve(null), 3000)
|
||||
);
|
||||
|
||||
let subscription = await Promise.race([getSubscriptionPromise, getSubscriptionTimeoutPromise]);
|
||||
|
||||
if (!subscription) {
|
||||
// VAPID public key deve vir do backend ou config
|
||||
const vapidPublicKey = import.meta.env.VITE_VAPID_PUBLIC_KEY || "";
|
||||
|
||||
if (!vapidPublicKey) {
|
||||
// Não logar warning para evitar spam no console
|
||||
return null;
|
||||
}
|
||||
|
||||
// Converter chave para formato Uint8Array
|
||||
const applicationServerKey = urlBase64ToUint8Array(vapidPublicKey);
|
||||
|
||||
// Subscribe com timeout
|
||||
const subscribePromise = registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey,
|
||||
});
|
||||
|
||||
const subscribeTimeoutPromise = new Promise<PushSubscription | null>((resolve) =>
|
||||
setTimeout(() => resolve(null), 5000)
|
||||
);
|
||||
|
||||
subscription = await Promise.race([subscribePromise, subscribeTimeoutPromise]);
|
||||
}
|
||||
|
||||
return subscription;
|
||||
} catch (error) {
|
||||
// Ignorar erros relacionados a message channel ou service worker
|
||||
if (error instanceof Error) {
|
||||
const errorMessage = error.message.toLowerCase();
|
||||
if (
|
||||
errorMessage.includes("message channel") ||
|
||||
errorMessage.includes("service worker") ||
|
||||
errorMessage.includes("registration")
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converter chave VAPID de base64 URL-safe para Uint8Array
|
||||
*/
|
||||
function urlBase64ToUint8Array(base64String: string): Uint8Array {
|
||||
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
|
||||
const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
|
||||
|
||||
const rawData = window.atob(base64);
|
||||
const outputArray = new Uint8Array(rawData.length);
|
||||
|
||||
for (let i = 0; i < rawData.length; ++i) {
|
||||
outputArray[i] = rawData.charCodeAt(i);
|
||||
}
|
||||
return outputArray;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converter PushSubscription para formato serializável
|
||||
*/
|
||||
export function subscriptionToJSON(subscription: PushSubscription): {
|
||||
endpoint: string;
|
||||
keys: { p256dh: string; auth: string };
|
||||
} {
|
||||
const key = subscription.getKey("p256dh");
|
||||
const auth = subscription.getKey("auth");
|
||||
|
||||
if (!key || !auth) {
|
||||
throw new Error("Chaves de subscription não encontradas");
|
||||
}
|
||||
|
||||
return {
|
||||
endpoint: subscription.endpoint,
|
||||
keys: {
|
||||
p256dh: arrayBufferToBase64(key),
|
||||
auth: arrayBufferToBase64(auth),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Converter ArrayBuffer para base64
|
||||
*/
|
||||
function arrayBufferToBase64(buffer: ArrayBuffer): string {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
let binary = "";
|
||||
for (let i = 0; i < bytes.byteLength; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return window.btoa(binary);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remover subscription de push notification
|
||||
*/
|
||||
export async function removerPushSubscription(): Promise<boolean> {
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
const subscription = await registration.pushManager.getSubscription();
|
||||
|
||||
if (subscription) {
|
||||
await subscription.unsubscribe();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user