iachat/app/javascript/dashboard/helper/aggressiveAlert.js
Rodribm10 2b9ada259e feat(dashboard): aggressive alert on conversation reopening
Banner persistente + som em loop + OS notification + title flash
+ vibração mobile quando conversa transiciona pra 'open' vindo de
pending/snoozed/resolved. Exige interação pra dismissar — atendente
não perde evento de reabertura.

- AggressiveConversationBanner.vue: banner full-width no topo,
  dismissable, mostra nome do contato + inbox + status anterior
- aggressiveAlert.js: manager do som (loop infinito), title flash
  (intervalo 1s), Notification API (requireInteraction: true),
  navigator.vibrate (padrão 500-200-500-200-500)
- actionCable.onStatusChange: detecta transição pra 'open' e dispara
  trigger via BUS_EVENTS (só se status anterior ≠ open, pra não
  alertar conversa nova criada já em open)
- i18n pt_BR + en: chaves de notificação (title/body/dismiss)
- busEvents: AGGRESSIVE_ALERT_TRIGGER + AGGRESSIVE_ALERT_DISMISS

Camada 1 da feature. Camada 2 (escalation SMS/WhatsApp se não
dismissar em X segundos) fica pra outro PR.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 18:03:26 -03:00

160 lines
4.0 KiB
JavaScript

import { emitter } from 'shared/helpers/mitt';
import { BUS_EVENTS } from 'shared/constants/busEvents';
const ALERT_AUDIO_PATH = '/audio/dashboard/bell.mp3';
const VIBRATION_PATTERN = [500, 200, 500, 200, 500];
const TITLE_FLASH_INTERVAL_MS = 1000;
const NOTIFICATION_TAG = 'chatwoot-aggressive-alert';
const showOSNotification = (title, body) => {
if (typeof window === 'undefined' || !('Notification' in window)) return;
if (Notification.permission !== 'granted') return;
try {
// eslint-disable-next-line no-new
new Notification(title, {
body,
tag: NOTIFICATION_TAG,
requireInteraction: true,
renotify: true,
});
} catch (e) {
// Safari iOS lança TypeError no construtor; banner visual + som cobrem.
}
};
const vibrateDevice = () => {
if (
typeof navigator !== 'undefined' &&
typeof navigator.vibrate === 'function'
) {
navigator.vibrate(VIBRATION_PATTERN);
}
};
class AggressiveAlertManager {
constructor() {
this.audio = null;
this.titleInterval = null;
this.originalTitle = typeof document !== 'undefined' ? document.title : '';
this.activeConversations = new Map();
}
ensureAudio() {
if (this.audio) return;
this.audio = new Audio(ALERT_AUDIO_PATH);
this.audio.loop = true;
}
playSound() {
this.ensureAudio();
const playPromise = this.audio.play();
if (playPromise && typeof playPromise.catch === 'function') {
playPromise.catch(() => {
// Autoplay bloqueado pelo browser — banner visual permanece.
});
}
}
stopSound() {
if (!this.audio) return;
this.audio.pause();
this.audio.currentTime = 0;
}
updateTitleTick(toggle) {
const count = this.activeConversations.size;
if (count === 0) {
document.title = this.originalTitle;
return;
}
document.title = toggle
? `🚨 (${count}) CONVERSA ABERTA`
: this.originalTitle;
}
startTitleFlash() {
if (this.titleInterval) return;
let toggle = false;
this.updateTitleTick(true);
this.titleInterval = setInterval(() => {
toggle = !toggle;
this.updateTitleTick(toggle);
}, TITLE_FLASH_INTERVAL_MS);
}
stopTitleFlash() {
if (this.titleInterval) {
clearInterval(this.titleInterval);
this.titleInterval = null;
}
document.title = this.originalTitle;
}
trigger({ conversationId, contactName, inboxName, previousStatus }) {
if (!conversationId) return;
if (this.activeConversations.has(conversationId)) return;
this.activeConversations.set(conversationId, {
contactName: contactName || '—',
inboxName: inboxName || '',
previousStatus: previousStatus || '',
triggeredAt: Date.now(),
});
this.playSound();
showOSNotification(
'🚨 Conversa aberta aguardando resposta',
`${contactName || 'Cliente'}${inboxName || ''}`.trim()
);
vibrateDevice();
this.startTitleFlash();
emitter.emit(BUS_EVENTS.AGGRESSIVE_ALERT_TRIGGER, {
conversationId,
contactName,
inboxName,
previousStatus,
total: this.activeConversations.size,
});
}
dismiss(conversationId) {
if (!this.activeConversations.has(conversationId)) return;
this.activeConversations.delete(conversationId);
if (this.activeConversations.size === 0) {
this.stopSound();
this.stopTitleFlash();
} else {
this.updateTitleTick(true);
}
emitter.emit(BUS_EVENTS.AGGRESSIVE_ALERT_DISMISS, {
conversationId,
total: this.activeConversations.size,
});
}
dismissAll() {
if (this.activeConversations.size === 0) return;
this.activeConversations.clear();
this.stopSound();
this.stopTitleFlash();
emitter.emit(BUS_EVENTS.AGGRESSIVE_ALERT_DISMISS, {
conversationId: null,
total: 0,
});
}
getActiveConversations() {
return Array.from(this.activeConversations.entries()).map(([id, data]) => ({
id,
...data,
}));
}
}
const aggressiveAlert = new AggressiveAlertManager();
export default aggressiveAlert;