Files
scoreodonto.com/frontend/services/realtime.ts
T
VPS 4 Builder ba062e92d0 fix(login): render standalone views edge-to-edge e esconde scrollbar do carrossel de onboarding
- App.tsx: wrappers de padding (p-4/md:p-8) e max-w-7xl/mx-auto agora só se aplicam quando não é tela standalone; login/landing/public renderizam full-bleed
- OnboardingChat.tsx: classe ob-noscroll esconde a barra de rolagem do slide intro (mantém scroll), substituindo a custom-scrollbar indefinida no fluxo de login

Inclui também demais alterações pendentes do working tree (snapshot do estado atual de dev).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 13:23:19 +02:00

75 lines
3.2 KiB
TypeScript

// ─────────────────────────────────────────────────────────────────────────────
// Tempo real via WebSocket (singleton, por clínica).
// O backend empurra { type: 'agenda' | 'notificacao' | 'connected', ... } quando
// algo muda na clínica ativa → os componentes assinam e refazem o fetch na hora.
// Reconexão automática com backoff; autentica via token+clinicaId na query string.
// ─────────────────────────────────────────────────────────────────────────────
type RTHandler = (msg: any) => void;
const handlers = new Set<RTHandler>();
let ws: WebSocket | null = null;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
let backoff = 1000;
let currentUrl = '';
function buildUrl(): string | null {
const token = localStorage.getItem('SCOREODONTO_AUTH_TOKEN');
let clinicaId = '';
try { clinicaId = JSON.parse(localStorage.getItem('SCOREODONTO_ACTIVE_WORKSPACE') || '{}')?.id || ''; } catch { /* ignore */ }
if (!token || !clinicaId) return null;
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
return `${proto}://${location.host}/api/ws?token=${encodeURIComponent(token)}&clinicaId=${encodeURIComponent(clinicaId)}`;
}
function scheduleReconnect() {
if (reconnectTimer || handlers.size === 0) return;
reconnectTimer = setTimeout(() => { reconnectTimer = null; connect(); }, backoff);
backoff = Math.min(backoff * 2, 15000);
}
function connect() {
const url = buildUrl();
if (!url) return;
currentUrl = url;
let sock: WebSocket;
try { sock = new WebSocket(url); } catch { scheduleReconnect(); return; }
ws = sock;
sock.onopen = () => { backoff = 1000; };
sock.onmessage = (ev) => {
let msg: any;
try { msg = JSON.parse(ev.data); } catch { return; }
handlers.forEach(h => { try { h(msg); } catch { /* ignore */ } });
};
sock.onclose = () => { if (ws === sock) ws = null; scheduleReconnect(); };
sock.onerror = () => { try { sock.close(); } catch { /* ignore */ } };
}
function ensureConnected() {
const url = buildUrl();
if (!url) return;
// Clínica/token mudou → reconecta no canal certo.
if (ws && url !== currentUrl) { try { ws.close(); } catch { /* ignore */ } ws = null; }
if (!ws || ws.readyState === WebSocket.CLOSING || ws.readyState === WebSocket.CLOSED) connect();
}
export const Realtime = {
/** Assina mensagens de tempo real. Retorna função de cancelamento. */
subscribe(handler: RTHandler): () => void {
handlers.add(handler);
ensureConnected();
return () => {
handlers.delete(handler);
if (handlers.size === 0) {
if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
if (ws) { try { ws.close(); } catch { /* ignore */ } ws = null; }
}
};
},
/** Força reconexão (ex.: ao trocar de workspace). */
reconnect() {
if (ws) { try { ws.close(); } catch { /* ignore */ } ws = null; }
backoff = 1000;
ensureConnected();
},
};