feat(newwhats): frontend do plugin (Inbox/Sessions/Secretária) em tela cheia

- /wa-inbox, /wa-sessions e /wa-secretaria rodam sem a sidebar do scoreodonto
  (isStandaloneView); cada tela tem seu "Voltar" (Secretária ganhou botão na ThinNav).
- SessionsView: banner "Você já possui acesso ao WhatsApp" quando há sessão ativa.
- avatares: usa a URL do CDN (contactAvatarUrl/instance.avatar) direto; Avatar
  exibe sem depender de version; self-heal no onError re-busca a foto atual.
- deps: framer-motion, immer. Dev override com HMR (docker-compose.dev.yml).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
VPS 4 Builder
2026-07-01 20:54:24 +02:00
parent 6593993b3d
commit 3042ddca38
54 changed files with 12641 additions and 34 deletions
@@ -0,0 +1,111 @@
// Shim do socketService do motor sobre o túnel WS do satélite
// (/api/nw/v1/stream?token=). Traduz os eventos do ext stream para os nomes que
// as páginas do motor escutam. joinInstance/joinChat/leaveChat são no-ops: o
// stream já é do tenant inteiro; as páginas filtram por instanceId/chatId no
// cliente. Mantém a MESMA interface do services/socketService.ts do motor.
import { nw, nwToken } from './nwClient';
type Listener = (data: any) => void;
// ext status (minúsculo) → enum do motor (maiúsculo).
function normStatus(s: string): string {
const m: Record<string, string> = {
connected: 'CONNECTED', disconnected: 'DISCONNECTED',
connecting: 'CONNECTING', qr: 'QR_PENDING', banned: 'BANNED',
};
return m[String(s).toLowerCase()] ?? String(s).toUpperCase();
}
class SocketShim {
private ws: WebSocket | null = null;
private listeners = new Map<string, Set<Listener>>();
private retry: ReturnType<typeof setTimeout> | null = null;
private closedByUser = false;
connect(_token?: string) {
if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) return;
this.closedByUser = false;
const token = nwToken();
if (!token) return;
const base = nw.base();
const abs = base.startsWith('http') ? base : `${window.location.origin}${base}`;
let u: URL;
try { u = new URL(`${abs}/nw/v1/stream`); } catch { return; }
u.protocol = u.protocol === 'https:' ? 'wss:' : 'ws:';
u.searchParams.set('token', token);
let ws: WebSocket;
try { ws = new WebSocket(u.toString()); } catch { return; }
this.ws = ws;
ws.onopen = () => this._emit('connected', { id: 'nw' });
ws.onerror = () => { try { ws.close(); } catch { /* ignore */ } };
ws.onclose = () => {
this._emit('disconnected', {});
if (!this.closedByUser) this.retry = setTimeout(() => this.connect(), 3000);
};
ws.onmessage = (ev) => {
let env: any;
try { env = JSON.parse(ev.data); } catch { return; }
const { event, data } = env || {};
if (!event) return;
switch (event) {
case 'session.status':
this._emit('instance:status', { instanceId: data?.instanceId, status: normStatus(data?.status) });
break;
case 'session.qr':
this._emit('qr', { instanceId: data?.instanceId, qrBase64: data?.qrBase64 });
break;
case 'message.new': {
// ext envia { instanceId, chatId, message, timestamp }; o chatStore
// espera o objeto de mensagem direto (com chatId).
// OBS: o motor também emite ext:message.new numa variante SEM `message`
// (só { jid, text }) para acionar a Secretária IA — ignoramos aqui,
// senão o chatStore sobrescreve o preview do chat com undefined.
if (!data?.message) break;
const m = { ...data.message, chatId: data.message.chatId ?? data.chatId };
this._emit('message:new', m);
break;
}
case 'message.update': {
const m = data?.message ?? data;
if (m?.messageId && m?.status) this._emit('message:status', { messageId: m.messageId, status: m.status });
this._emit('message:update', m);
break;
}
case 'conversation.handoff': this._emit('handoff', data); break;
case 'conversation.escalated': this._emit('escalated', data); break;
default: break;
}
this._emit('*', { event, data });
};
}
disconnect() {
this.closedByUser = true;
if (this.retry) clearTimeout(this.retry);
try { this.ws?.close(); } catch { /* ignore */ }
this.ws = null;
}
// Satélite não envia comandos pelo stream (é read-only); mantidos p/ compat.
emit(_event: string, ..._args: any[]) { /* no-op */ }
joinInstance(_instanceId: string) { /* no-op — stream é tenant-wide */ }
joinChat(_chatId: string) { /* no-op */ }
leaveChat(_chatId: string) { /* no-op */ }
on(event: string, cb: Listener) {
if (!this.listeners.has(event)) this.listeners.set(event, new Set());
this.listeners.get(event)!.add(cb);
}
off(event: string, cb: Listener) { this.listeners.get(event)?.delete(cb); }
get connected() { return this.ws?.readyState === WebSocket.OPEN; }
getSocket() { return this.ws; }
private _emit(event: string, data: any) {
this.listeners.get(event)?.forEach((cb) => { try { cb(data); } catch { /* ignore */ } });
}
}
export const socketService = new SocketShim();