diff --git a/app/javascript/dashboard/App.vue b/app/javascript/dashboard/App.vue index 61d7631e7..967fa667d 100644 --- a/app/javascript/dashboard/App.vue +++ b/app/javascript/dashboard/App.vue @@ -5,6 +5,7 @@ import NetworkNotification from './components/NetworkNotification.vue'; import UpdateBanner from './components/app/UpdateBanner.vue'; import PaymentPendingBanner from './components/app/PaymentPendingBanner.vue'; import PendingEmailVerificationBanner from './components/app/PendingEmailVerificationBanner.vue'; +import AggressiveConversationBanner from './components/app/AggressiveConversationBanner.vue'; import vueActionCable from './helper/actionCable'; import { useRouter } from 'vue-router'; import { useStore } from 'dashboard/composables/store'; @@ -30,6 +31,7 @@ export default { PaymentPendingBanner, WootSnackbarBox, PendingEmailVerificationBanner, + AggressiveConversationBanner, }, setup() { const router = useRouter(); @@ -134,6 +136,7 @@ export default { class="flex flex-col w-full h-screen min-h-0 bg-n-background" :dir="isRTL ? 'rtl' : 'ltr'" > + diff --git a/app/javascript/dashboard/components/app/AggressiveConversationBanner.vue b/app/javascript/dashboard/components/app/AggressiveConversationBanner.vue new file mode 100644 index 000000000..76dd19469 --- /dev/null +++ b/app/javascript/dashboard/components/app/AggressiveConversationBanner.vue @@ -0,0 +1,249 @@ + + + + + + {{ + $t('AGGRESSIVE_CONVERSATION_BANNER.ALERT_ICON', '🚨') + }} + {{ bannerLabel }} + + {{ + $t('AGGRESSIVE_CONVERSATION_BANNER.DISMISS_ALL', 'Dispensar todas') + }} + + + + + + {{ + alert.contactName || '—' + }} + + · {{ alert.inboxName }} + + + · + {{ + $t( + `AGGRESSIVE_CONVERSATION_BANNER.FROM_${alert.previousStatus.toUpperCase()}`, + alert.previousStatus + ) + }} + + + + {{ $t('AGGRESSIVE_CONVERSATION_BANNER.CLOSE_ICON', '×') }} + + + + + + + diff --git a/app/javascript/dashboard/helper/actionCable.js b/app/javascript/dashboard/helper/actionCable.js index 78177d563..6713603a8 100644 --- a/app/javascript/dashboard/helper/actionCable.js +++ b/app/javascript/dashboard/helper/actionCable.js @@ -1,6 +1,7 @@ import AuthAPI from '../api/auth'; import BaseActionCableConnector from '../../shared/helpers/BaseActionCableConnector'; import DashboardAudioNotificationHelper from './AudioAlerts/DashboardAudioNotificationHelper'; +import aggressiveAlert from './aggressiveAlert'; import { BUS_EVENTS } from 'shared/constants/busEvents'; import { emitter } from 'shared/helpers/mitt'; import { useImpersonation } from 'dashboard/composables/useImpersonation'; @@ -113,10 +114,37 @@ class ActionCableConnector extends BaseActionCableConnector { onReload = () => window.location.reload(); onStatusChange = data => { + this.maybeTriggerAggressiveAlert(data); this.app.$store.dispatch('updateConversation', data); this.fetchConversationStats(); }; + // Dispara banner + som + push do SO quando conversa transiciona para 'open' + // vindo de outro status (pending/snoozed/resolved). Se já está open, ignora. + maybeTriggerAggressiveAlert = data => { + if (!data || data.status !== 'open') return; + const store = this.app.$store; + const existing = store.getters.getConversationById(data.id); + const previousStatus = existing ? existing.status : null; + // Só alerta se a conversa já existia no store com outro status. + // (Conversa nova em 'open' vai por onConversationCreated; não é reabertura.) + if (!previousStatus || previousStatus === 'open') return; + + const contactName = + data.meta && data.meta.sender ? data.meta.sender.name : ''; + const inbox = store.getters['inboxes/getInbox'] + ? store.getters['inboxes/getInbox'](data.inbox_id) + : null; + const inboxName = inbox && inbox.name ? inbox.name : ''; + + aggressiveAlert.trigger({ + conversationId: data.id, + contactName, + inboxName, + previousStatus, + }); + }; + onConversationUpdated = data => { this.app.$store.dispatch('updateConversation', data); this.fetchConversationStats(); diff --git a/app/javascript/dashboard/helper/aggressiveAlert.js b/app/javascript/dashboard/helper/aggressiveAlert.js new file mode 100644 index 000000000..77bf2e21f --- /dev/null +++ b/app/javascript/dashboard/helper/aggressiveAlert.js @@ -0,0 +1,159 @@ +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; diff --git a/app/javascript/dashboard/i18n/locale/en/aggressiveBanner.json b/app/javascript/dashboard/i18n/locale/en/aggressiveBanner.json new file mode 100644 index 000000000..d24c4f990 --- /dev/null +++ b/app/javascript/dashboard/i18n/locale/en/aggressiveBanner.json @@ -0,0 +1,13 @@ +{ + "AGGRESSIVE_CONVERSATION_BANNER": { + "SINGLE": "Conversation waiting for a response!", + "MULTIPLE": "{count} conversations waiting for a response!", + "DISMISS_ALL": "Dismiss all", + "DISMISS_ONE": "Dismiss", + "CLOSE_ICON": "×", + "ALERT_ICON": "🚨", + "FROM_PENDING": "from pending", + "FROM_SNOOZED": "reopened (snoozed)", + "FROM_RESOLVED": "reopened (resolved)" + } +} diff --git a/app/javascript/dashboard/i18n/locale/en/index.js b/app/javascript/dashboard/i18n/locale/en/index.js index 93009fc6c..cd37eb56b 100644 --- a/app/javascript/dashboard/i18n/locale/en/index.js +++ b/app/javascript/dashboard/i18n/locale/en/index.js @@ -1,4 +1,5 @@ import advancedFilters from './advancedFilters.json'; +import aggressiveBanner from './aggressiveBanner.json'; import agentBots from './agentBots.json'; import agentMgmt from './agentMgmt.json'; import attributesMgmt from './attributesMgmt.json'; @@ -44,6 +45,7 @@ import yearInReview from './yearInReview.json'; export default { ...advancedFilters, + ...aggressiveBanner, ...agentBots, ...agentMgmt, ...attributesMgmt, diff --git a/app/javascript/dashboard/i18n/locale/pt_BR/aggressiveBanner.json b/app/javascript/dashboard/i18n/locale/pt_BR/aggressiveBanner.json new file mode 100644 index 000000000..69bee2174 --- /dev/null +++ b/app/javascript/dashboard/i18n/locale/pt_BR/aggressiveBanner.json @@ -0,0 +1,13 @@ +{ + "AGGRESSIVE_CONVERSATION_BANNER": { + "SINGLE": "Conversa aguardando resposta!", + "MULTIPLE": "{count} conversas aguardando resposta!", + "DISMISS_ALL": "Dispensar todas", + "DISMISS_ONE": "Dispensar", + "CLOSE_ICON": "×", + "ALERT_ICON": "🚨", + "FROM_PENDING": "saiu de pendente", + "FROM_SNOOZED": "reaberta (estava adiada)", + "FROM_RESOLVED": "reaberta (estava resolvida)" + } +} diff --git a/app/javascript/dashboard/i18n/locale/pt_BR/index.js b/app/javascript/dashboard/i18n/locale/pt_BR/index.js index 5fe57020b..c1598af85 100644 --- a/app/javascript/dashboard/i18n/locale/pt_BR/index.js +++ b/app/javascript/dashboard/i18n/locale/pt_BR/index.js @@ -1,4 +1,5 @@ import advancedFilters from './advancedFilters.json'; +import aggressiveBanner from './aggressiveBanner.json'; import agentBots from './agentBots.json'; import agentMgmt from './agentMgmt.json'; import attributesMgmt from './attributesMgmt.json'; @@ -40,6 +41,7 @@ import whatsappTemplates from './whatsappTemplates.json'; export default { ...advancedFilters, + ...aggressiveBanner, ...agentBots, ...agentMgmt, ...attributesMgmt, diff --git a/app/javascript/shared/constants/busEvents.js b/app/javascript/shared/constants/busEvents.js index ef8f24155..6b1bb21d7 100644 --- a/app/javascript/shared/constants/busEvents.js +++ b/app/javascript/shared/constants/busEvents.js @@ -13,4 +13,6 @@ export const BUS_EVENTS = { NEW_CONVERSATION_MODAL: 'newConversationModal', INSERT_INTO_RICH_EDITOR: 'insertIntoRichEditor', INSERT_INTO_NORMAL_EDITOR: 'insertIntoNormalEditor', + AGGRESSIVE_ALERT_TRIGGER: 'AGGRESSIVE_ALERT_TRIGGER', + AGGRESSIVE_ALERT_DISMISS: 'AGGRESSIVE_ALERT_DISMISS', };