Merge branch 'develop' into chore/update-rails

This commit is contained in:
Sojan Jose
2025-03-13 03:01:02 -07:00
committed by GitHub
106 changed files with 1729 additions and 939 deletions
@@ -2,6 +2,12 @@ class Webhooks::WhatsappController < ActionController::API
include MetaTokenVerifyConcern
def process_payload
if inactive_whatsapp_number?
Rails.logger.warn("Rejected webhook for inactive WhatsApp number: #{params[:phone_number]}")
render json: { error: 'Inactive WhatsApp number' }, status: :unprocessable_entity
return
end
Webhooks::WhatsappEventsJob.perform_later(params.to_unsafe_hash)
head :ok
end
@@ -13,4 +19,15 @@ class Webhooks::WhatsappController < ActionController::API
whatsapp_webhook_verify_token = channel.provider_config['webhook_verify_token'] if channel.present?
token == whatsapp_webhook_verify_token if whatsapp_webhook_verify_token.present?
end
def inactive_whatsapp_number?
phone_number = params[:phone_number]
return false if phone_number.blank?
inactive_numbers = GlobalConfig.get_value('INACTIVE_WHATSAPP_NUMBERS').to_s
return false if inactive_numbers.blank?
inactive_numbers_array = inactive_numbers.split(',').map(&:strip)
inactive_numbers_array.include?(phone_number)
end
end
@@ -0,0 +1,20 @@
/* global axios */
import ApiClient from './ApiClient';
class LiveReportsAPI extends ApiClient {
constructor() {
super('live_reports', { accountScoped: true, apiVersion: 'v2' });
}
getConversationMetric(params = {}) {
return axios.get(`${this.url}/conversation_metrics`, { params });
}
getGroupedConversations({ groupBy } = { groupBy: 'assignee_id' }) {
return axios.get(`${this.url}/grouped_conversation_metrics`, {
params: { group_by: groupBy },
});
}
}
export default new LiveReportsAPI();
@@ -82,7 +82,7 @@ input[type='url']:not(.reset-base) {
}
input[type='file'] {
@apply bg-white dark:bg-n-solid-1 leading-[1.15] mb-4;
@apply bg-n-background leading-[1.15] mb-4;
}
// Select
@@ -29,6 +29,10 @@ const props = defineProps({
type: Boolean,
default: false,
},
labelClass: {
type: String,
default: '',
},
});
const emit = defineEmits(['action']);
@@ -97,9 +101,13 @@ onMounted(() => {
</slot>
<Icon v-if="item.icon" :icon="item.icon" class="flex-shrink-0 size-3.5" />
<span v-if="item.emoji" class="flex-shrink-0">{{ item.emoji }}</span>
<span v-if="item.label" class="min-w-0 text-sm truncate">{{
item.label
}}</span>
<span
v-if="item.label"
class="min-w-0 text-sm truncate"
:class="labelClass"
>
{{ item.label }}
</span>
</button>
<div
v-if="filteredMenuItems.length === 0"
@@ -93,7 +93,7 @@ const hasQuotedMessage = computed(() => {
<template v-else>
<Letter
v-if="showQuotedMessage"
class-name="prose prose-bubble !max-w-none"
class-name="prose prose-bubble !max-w-none letter-render"
:allowed-css-properties="[
...allowedCssProperties,
'transform',
@@ -104,7 +104,7 @@ const hasQuotedMessage = computed(() => {
/>
<Letter
v-else
class-name="prose prose-bubble !max-w-none"
class-name="prose prose-bubble !max-w-none letter-render"
:html="unquotedHTML"
:allowed-css-properties="[
...allowedCssProperties,
@@ -143,3 +143,21 @@ const hasQuotedMessage = computed(() => {
</section>
</BaseBubble>
</template>
<style lang="scss">
// Tailwind resets break the rendering of google drive link in Gmail messages
// This fixes it using https://developer.mozilla.org/en-US/docs/Web/CSS/Attribute_selectors
.letter-render [class*='gmail_drive_chip'] {
box-sizing: initial;
@apply bg-n-slate-4 border-n-slate-6 rounded-md !important;
a {
@apply text-n-slate-12 !important;
img {
display: inline-block;
}
}
}
</style>
@@ -1,5 +1,5 @@
<script setup>
import { computed } from 'vue';
import { computed, ref } from 'vue';
import BaseBubble from 'next/message/bubbles/Base.vue';
import FormattedContent from './FormattedContent.vue';
import AttachmentChips from 'next/message/chips/AttachmentChips.vue';
@@ -9,6 +9,26 @@ import { useMessageContext } from '../../provider.js';
const { content, attachments, contentAttributes, messageType } =
useMessageContext();
const hasTranslations = computed(() => {
const { translations = {} } = contentAttributes.value;
return Object.keys(translations || {}).length > 0;
});
const renderOriginal = ref(false);
const renderContent = computed(() => {
if (renderOriginal.value) {
return content.value;
}
if (hasTranslations.value) {
const translations = contentAttributes.value.translations;
return translations[Object.keys(translations)[0]];
}
return content.value;
});
const isTemplate = computed(() => {
return messageType.value === MESSAGE_TYPES.TEMPLATE;
});
@@ -16,6 +36,16 @@ const isTemplate = computed(() => {
const isEmpty = computed(() => {
return !content.value && !attachments.value?.length;
});
const viewToggleKey = computed(() => {
return renderOriginal.value
? 'CONVERSATION.VIEW_TRANSLATED'
: 'CONVERSATION.VIEW_ORIGINAL';
});
const handleSeeOriginal = () => {
renderOriginal.value = !renderOriginal.value;
};
</script>
<template>
@@ -24,7 +54,16 @@ const isEmpty = computed(() => {
<span v-if="isEmpty" class="text-n-slate-11">
{{ $t('CONVERSATION.NO_CONTENT') }}
</span>
<FormattedContent v-if="content" :content="content" />
<FormattedContent v-if="renderContent" :content="renderContent" />
<span class="-mt-3">
<span
v-if="hasTranslations"
class="text-xs text-n-slate-11 cursor-pointer hover:underline"
@click="handleSeeOriginal"
>
{{ $t(viewToggleKey) }}
</span>
</span>
<AttachmentChips :attachments="attachments" class="gap-2" />
<template v-if="isTemplate">
<div
@@ -97,7 +97,7 @@ export default {
}
.step {
@apply bg-slate-75 dark:bg-slate-600 rounded-2xl font-medium w-4 left-4 leading-4 z-[999] absolute text-center text-white dark:text-white text-xxs top-5;
@apply bg-slate-75 dark:bg-slate-600 rounded-2xl font-medium w-4 left-4 leading-4 z-10 absolute text-center text-white dark:text-white text-xxs top-5;
}
}
}
@@ -1,5 +1,4 @@
<script setup>
import Icon from 'next/icon/Icon.vue';
import router from '../../routes/index';
const props = defineProps({
backUrl: {
@@ -24,24 +23,16 @@ const goBack = () => {
}
};
const buttonStyleClass = props.compact
? 'text-sm text-n-slate-11'
: 'text-base text-n-blue-text';
const buttonStyleClass = props.compact ? 'text-sm' : 'text-base';
</script>
<template>
<button
class="flex items-center p-0 font-normal cursor-pointer gap-1"
class="flex items-center p-0 font-normal cursor-pointer text-n-slate-11"
:class="buttonStyleClass"
@click.capture="goBack"
>
<Icon
icon="i-lucide-chevron-left"
class="ltr:-ml-1 rtl:-mr-1"
:class="
props.compact ? 'text-n-slate-11 size-4' : 'text-n-blue-text size-5'
"
/>
<fluent-icon icon="chevron-left" class="-ml-1" />
{{ buttonLabel || $t('GENERAL_SETTINGS.BACK') }}
</button>
</template>
@@ -1,61 +0,0 @@
<script>
export default {
props: {
contentAttributes: {
type: Object,
default: () => ({}),
},
content: {
type: String,
default: '',
},
},
emits: ['close'],
computed: {
translationsAvailable() {
return !!Object.keys(this.translations).length;
},
translations() {
return this.contentAttributes.translations || {};
},
},
methods: {
onClose() {
this.$emit('close');
},
},
};
</script>
<template>
<woot-modal
modal-type="right-aligned"
class="text-left"
show
:on-close="onClose"
>
<div class="content">
<p>
<b>{{ $t('TRANSLATE_MODAL.ORIGINAL_CONTENT') }}</b>
</p>
<p v-dompurify-html="content" class="mb-0" />
<br />
<hr />
<div v-if="translationsAvailable">
<p>
<b>{{ $t('TRANSLATE_MODAL.TRANSLATED_CONTENT') }}</b>
</p>
<div v-for="(translation, language) in translations" :key="language">
<p>
<strong>{{ language }}:</strong>
</p>
<p v-dompurify-html="translation" />
<br />
</div>
</div>
<p v-else>
{{ $t('TRANSLATE_MODAL.NO_TRANSLATIONS_AVAILABLE') }}
</p>
</div>
</woot-modal>
</template>
@@ -105,6 +105,9 @@
"AGENT": "Select agent",
"TEAM": "Select team"
},
"LIST": {
"NONE": "None"
},
"SEARCH": {
"NO_RESULTS": {
"AGENT": "No agents found",
@@ -105,6 +105,9 @@
"AGENT": "اختر وكيل",
"TEAM": "اختر فريق"
},
"LIST": {
"NONE": "لا شيء"
},
"SEARCH": {
"NO_RESULTS": {
"AGENT": "لم يتم العثور على وكلاء",
@@ -105,6 +105,9 @@
"AGENT": "Select agent",
"TEAM": "Select team"
},
"LIST": {
"NONE": "None"
},
"SEARCH": {
"NO_RESULTS": {
"AGENT": "No agents found",
@@ -105,6 +105,9 @@
"AGENT": "Изберете агент",
"TEAM": "Изберете екип"
},
"LIST": {
"NONE": "Нито един"
},
"SEARCH": {
"NO_RESULTS": {
"AGENT": "Няма намерени агенти",
@@ -3,8 +3,8 @@
"HEADER": "Agents",
"HEADER_BTN_TXT": "Afegir Agent",
"LOADING": "S'està recollint la llista d'agents",
"DESCRIPTION": "An agent is a member of your customer support team who can view and respond to user messages. The list below shows all the agents in your account.",
"LEARN_MORE": "Learn about user roles",
"DESCRIPTION": "",
"LEARN_MORE": "Rakibkazi",
"AGENT_TYPES": {
"ADMINISTRATOR": "Administrador/a",
"AGENT": "Agent"
@@ -105,6 +105,9 @@
"AGENT": "Seleccionar Agent",
"TEAM": "Selecciona equip"
},
"LIST": {
"NONE": "Ningú"
},
"SEARCH": {
"NO_RESULTS": {
"AGENT": "No s'han trobat agents",
@@ -105,6 +105,9 @@
"AGENT": "Vybrat agenta",
"TEAM": "Vybrat tým"
},
"LIST": {
"NONE": "Nic"
},
"SEARCH": {
"NO_RESULTS": {
"AGENT": "Nenalezeni žádní agenti",
@@ -105,6 +105,9 @@
"AGENT": "Vælg agent",
"TEAM": "Vælg hold"
},
"LIST": {
"NONE": "Ingen"
},
"SEARCH": {
"NO_RESULTS": {
"AGENT": "Ingen agenter fundet",
@@ -105,6 +105,9 @@
"AGENT": "Agent auswählen",
"TEAM": "Team auswählen"
},
"LIST": {
"NONE": "Keine"
},
"SEARCH": {
"NO_RESULTS": {
"AGENT": "Keine Agenten gefunden",
@@ -105,6 +105,9 @@
"AGENT": "Επιλογή πράκτορα",
"TEAM": "Επιλογή ομάδας"
},
"LIST": {
"NONE": "Κανένα"
},
"SEARCH": {
"NO_RESULTS": {
"AGENT": "Δεν βρέθηκαν Πράκτορες",
@@ -12,6 +12,8 @@
"NO_INBOX_2": " to get started",
"NO_INBOX_AGENT": "Uh Oh! Looks like you are not part of any inbox. Please contact your administrator",
"SEARCH_MESSAGES": "Search for messages in conversations",
"VIEW_ORIGINAL": "View original",
"VIEW_TRANSLATED": "View translated",
"EMPTY_STATE": {
"CMD_BAR": "to open command menu",
"KEYBOARD_SHORTCUTS": "to view keyboard shortcuts"
@@ -476,6 +476,18 @@
"STATUS": "Status"
}
},
"TEAM_CONVERSATIONS": {
"ALL_TEAMS": "All Teams",
"HEADER": "Conversations by teams",
"LOADING_MESSAGE": "Loading team metrics...",
"NO_TEAMS": "There is no data available",
"TABLE_HEADER": {
"TEAM": "Team",
"OPEN": "Open",
"UNATTENDED": "Unattended",
"STATUS": "Status"
}
},
"AGENT_STATUS": {
"HEADER": "Agent status",
"ONLINE": "Online",
@@ -105,6 +105,9 @@
"AGENT": "Seleccionar agente",
"TEAM": "Seleccionar equipo"
},
"LIST": {
"NONE": "Ninguna"
},
"SEARCH": {
"NO_RESULTS": {
"AGENT": "No se encontraron agentes",
@@ -105,6 +105,9 @@
"AGENT": "انتخاب اپراتور",
"TEAM": "انتخاب تیم"
},
"LIST": {
"NONE": "هیچکدام"
},
"SEARCH": {
"NO_RESULTS": {
"AGENT": "اپراتوری یافت نشد",
@@ -105,6 +105,9 @@
"AGENT": "Select agent",
"TEAM": "Select team"
},
"LIST": {
"NONE": "None"
},
"SEARCH": {
"NO_RESULTS": {
"AGENT": "Edustajia ei löytynyt",
@@ -105,6 +105,9 @@
"AGENT": "Sélectionner un agent",
"TEAM": "Sélectionner une équipe"
},
"LIST": {
"NONE": "Aucun"
},
"SEARCH": {
"NO_RESULTS": {
"AGENT": "Aucun agent trouvé",
@@ -105,6 +105,9 @@
"AGENT": "בחר נציג",
"TEAM": "בחר קבוצה"
},
"LIST": {
"NONE": "כלום"
},
"SEARCH": {
"NO_RESULTS": {
"AGENT": "לא נמצא סוכן",
@@ -105,6 +105,9 @@
"AGENT": "Select agent",
"TEAM": "Select team"
},
"LIST": {
"NONE": "None"
},
"SEARCH": {
"NO_RESULTS": {
"AGENT": "No agents found",
@@ -105,6 +105,9 @@
"AGENT": "Select agent",
"TEAM": "Select team"
},
"LIST": {
"NONE": "Nijedno"
},
"SEARCH": {
"NO_RESULTS": {
"AGENT": "No agents found",
@@ -105,6 +105,9 @@
"AGENT": "Ügynök kiválasztása",
"TEAM": "Csapat kiválasztása"
},
"LIST": {
"NONE": "Nincs"
},
"SEARCH": {
"NO_RESULTS": {
"AGENT": "Nem találunk ügynököt",
@@ -105,6 +105,9 @@
"AGENT": "Select agent",
"TEAM": "Select team"
},
"LIST": {
"NONE": "None"
},
"SEARCH": {
"NO_RESULTS": {
"AGENT": "No agents found",
@@ -105,6 +105,9 @@
"AGENT": "Pilih Agen",
"TEAM": "Pilih tim"
},
"LIST": {
"NONE": "Tidak ada"
},
"SEARCH": {
"NO_RESULTS": {
"AGENT": "Tidak ada agen",
@@ -105,6 +105,9 @@
"AGENT": "Velja þjónustufulltrúa",
"TEAM": "Velja teymi"
},
"LIST": {
"NONE": "Enginn"
},
"SEARCH": {
"NO_RESULTS": {
"AGENT": "Engir þjónustufulltrúar fundust",
@@ -105,6 +105,9 @@
"AGENT": "Seleziona un'agente",
"TEAM": "Seleziona team"
},
"LIST": {
"NONE": "Nessuno"
},
"SEARCH": {
"NO_RESULTS": {
"AGENT": "Nessun agente trovato",
@@ -105,6 +105,9 @@
"AGENT": "担当者を選択",
"TEAM": "チームを選択"
},
"LIST": {
"NONE": "なし"
},
"SEARCH": {
"NO_RESULTS": {
"AGENT": "担当者が見つかりません",
@@ -105,6 +105,9 @@
"AGENT": "Select agent",
"TEAM": "Select team"
},
"LIST": {
"NONE": "None"
},
"SEARCH": {
"NO_RESULTS": {
"AGENT": "No agents found",
@@ -105,6 +105,9 @@
"AGENT": "에이전트 선택",
"TEAM": "팀 선택"
},
"LIST": {
"NONE": "없음"
},
"SEARCH": {
"NO_RESULTS": {
"AGENT": "에이전트를 찾을 수 없음",
@@ -105,6 +105,9 @@
"AGENT": "Pasirinkti agentą",
"TEAM": "Pasirinkite komandą"
},
"LIST": {
"NONE": "Nėra"
},
"SEARCH": {
"NO_RESULTS": {
"AGENT": "Agentų nerasta",
@@ -105,6 +105,9 @@
"AGENT": "Izvēlieties aģentu",
"TEAM": "Izvēlieties komandu"
},
"LIST": {
"NONE": "Nav"
},
"SEARCH": {
"NO_RESULTS": {
"AGENT": "Aģenti nav atrasti",
@@ -105,6 +105,9 @@
"AGENT": "ഏജന്റിനെ തിരഞ്ഞെടുക്കുക",
"TEAM": "ടീം തിരഞ്ഞെടുക്കുക"
},
"LIST": {
"NONE": "ഒന്നുമില്ല"
},
"SEARCH": {
"NO_RESULTS": {
"AGENT": "ഏജന്റകളെ ഒന്നും കണ്ടെത്താൻ സാധിച്ചില്ല",
@@ -105,6 +105,9 @@
"AGENT": "Pilih ejen",
"TEAM": "Pilih pasukan"
},
"LIST": {
"NONE": "Tiada"
},
"SEARCH": {
"NO_RESULTS": {
"AGENT": "Tiada ejen dijumpa",
@@ -105,6 +105,9 @@
"AGENT": "Select agent",
"TEAM": "Select team"
},
"LIST": {
"NONE": "None"
},
"SEARCH": {
"NO_RESULTS": {
"AGENT": "No agents found",
@@ -105,6 +105,9 @@
"AGENT": "Selecteer agent",
"TEAM": "Selecteer team"
},
"LIST": {
"NONE": "Geen"
},
"SEARCH": {
"NO_RESULTS": {
"AGENT": "Geen medewerkers gevonden",
@@ -105,6 +105,9 @@
"AGENT": "Velg agent",
"TEAM": "Velg gruppe"
},
"LIST": {
"NONE": "Ingen"
},
"SEARCH": {
"NO_RESULTS": {
"AGENT": "Ingen agenter funnet",
@@ -105,6 +105,9 @@
"AGENT": "Wybierz konsultanta",
"TEAM": "Wybierz zespół"
},
"LIST": {
"NONE": "Brak"
},
"SEARCH": {
"NO_RESULTS": {
"AGENT": "Nie znaleziono agentów",
@@ -105,6 +105,9 @@
"AGENT": "Escolher agente",
"TEAM": "Escolher equipa"
},
"LIST": {
"NONE": "Nenhuma"
},
"SEARCH": {
"NO_RESULTS": {
"AGENT": "Nenhum agente encontrado",
@@ -105,6 +105,9 @@
"AGENT": "Selecionar agente",
"TEAM": "Selecionar time"
},
"LIST": {
"NONE": "Nenhuma"
},
"SEARCH": {
"NO_RESULTS": {
"AGENT": "Nenhum agente encontrado",
@@ -3,7 +3,7 @@
"HEADER": "Auditoria",
"HEADER_BTN_TXT": "Adicionar Logs de Auditoria",
"LOADING": "Buscando Logs de Auditoria",
"DESCRIPTION": "Logs de Auditoria mantêm um registro de atividades em sua conta, permitindo que você acompanhe e auditoria de sua conta, equipe ou serviços.",
"DESCRIPTION": "Logs de Auditoria mantêm um registro de atividades em sua conta, permitindo que você acompanhe e auditore sua conta, equipe ou serviços.",
"LEARN_MORE": "Saiba mais sobre os logs de auditoria",
"SEARCH_404": "Não existem itens correspondentes a esta consulta",
"SIDEBAR_TXT": "<p><b>Logs de Auditoria</b> </p><p> Os Logs de Auditoria são rastros para eventos e ações em um Sistema Chatwoot. </p>",
@@ -105,6 +105,9 @@
"AGENT": "Selectați agentul",
"TEAM": "Selectați echipa"
},
"LIST": {
"NONE": "Nimic"
},
"SEARCH": {
"NO_RESULTS": {
"AGENT": "Niciun agent găsit",
@@ -105,6 +105,9 @@
"AGENT": "Выбрать Агента",
"TEAM": "Выберите команду"
},
"LIST": {
"NONE": "Ничего"
},
"SEARCH": {
"NO_RESULTS": {
"AGENT": "Операторы не найдены",
@@ -105,6 +105,9 @@
"AGENT": "Select agent",
"TEAM": "Select team"
},
"LIST": {
"NONE": "None"
},
"SEARCH": {
"NO_RESULTS": {
"AGENT": "No agents found",
@@ -105,6 +105,9 @@
"AGENT": "Vybrať agenta",
"TEAM": "Vybrať tím"
},
"LIST": {
"NONE": "Žiadne"
},
"SEARCH": {
"NO_RESULTS": {
"AGENT": "Žiadni agenti neboli nájdení",
@@ -105,6 +105,9 @@
"AGENT": "Select agent",
"TEAM": "Select team"
},
"LIST": {
"NONE": "None"
},
"SEARCH": {
"NO_RESULTS": {
"AGENT": "No agents found",
@@ -105,6 +105,9 @@
"AGENT": "Select agent",
"TEAM": "Select team"
},
"LIST": {
"NONE": "None"
},
"SEARCH": {
"NO_RESULTS": {
"AGENT": "No agents found",
@@ -105,6 +105,9 @@
"AGENT": "Izaberite agenta",
"TEAM": "Izaberite tim"
},
"LIST": {
"NONE": "Niko"
},
"SEARCH": {
"NO_RESULTS": {
"AGENT": "Nema pronađenih agenata",
@@ -105,6 +105,9 @@
"AGENT": "Välj agent",
"TEAM": "Välj team"
},
"LIST": {
"NONE": "Inget"
},
"SEARCH": {
"NO_RESULTS": {
"AGENT": "Inga agenter hittades",
@@ -105,6 +105,9 @@
"AGENT": "Select agent",
"TEAM": "Select team"
},
"LIST": {
"NONE": "None"
},
"SEARCH": {
"NO_RESULTS": {
"AGENT": "No agents found",
@@ -105,6 +105,9 @@
"AGENT": "เลือกพนักงาน",
"TEAM": "เลือกทีม"
},
"LIST": {
"NONE": "ไม่มี"
},
"SEARCH": {
"NO_RESULTS": {
"AGENT": "ไม่พบพนักงาน",
@@ -105,6 +105,9 @@
"AGENT": "Select agent",
"TEAM": "Select team"
},
"LIST": {
"NONE": "None"
},
"SEARCH": {
"NO_RESULTS": {
"AGENT": "No agents found",
@@ -105,6 +105,9 @@
"AGENT": "Temsilci seçin",
"TEAM": "Takım seçin"
},
"LIST": {
"NONE": "Hiç"
},
"SEARCH": {
"NO_RESULTS": {
"AGENT": "Kullanıcı bulunamadı",
@@ -105,6 +105,9 @@
"AGENT": "Виберіть агента",
"TEAM": "Виберіть команду"
},
"LIST": {
"NONE": "Нiчого"
},
"SEARCH": {
"NO_RESULTS": {
"AGENT": "Агентів не знайдено",
@@ -105,6 +105,9 @@
"AGENT": "ایجنٹ منتخب کریں۔",
"TEAM": "ٹیم منتخب کریں۔"
},
"LIST": {
"NONE": "کوئی نہیں۔"
},
"SEARCH": {
"NO_RESULTS": {
"AGENT": "کوئی ایجنٹ نہیں ملا",
@@ -105,6 +105,9 @@
"AGENT": "Select agent",
"TEAM": "Select team"
},
"LIST": {
"NONE": "None"
},
"SEARCH": {
"NO_RESULTS": {
"AGENT": "No agents found",
@@ -105,6 +105,9 @@
"AGENT": "Chọn thành viên",
"TEAM": "Chọn nhóm"
},
"LIST": {
"NONE": "Không có"
},
"SEARCH": {
"NO_RESULTS": {
"AGENT": "Không tìm thấy nhà cung cấp",
@@ -105,6 +105,9 @@
"AGENT": "选择客服代表",
"TEAM": "选择团队"
},
"LIST": {
"NONE": "啥都没有"
},
"SEARCH": {
"NO_RESULTS": {
"AGENT": "未找到客服代表",
@@ -105,6 +105,9 @@
"AGENT": "選擇客服",
"TEAM": "選擇團隊"
},
"LIST": {
"NONE": "無"
},
"SEARCH": {
"NO_RESULTS": {
"AGENT": "查無客服",
@@ -11,14 +11,12 @@ import {
ACCOUNT_EVENTS,
CONVERSATION_EVENTS,
} from '../../../helper/AnalyticsHelper/events';
import TranslateModal from 'dashboard/components/widgets/conversation/bubble/TranslateModal.vue';
import MenuItem from '../../../components/widgets/conversation/contextMenu/menuItem.vue';
import { useTrack } from 'dashboard/composables';
export default {
components: {
AddCannedModal,
TranslateModal,
MenuItem,
ContextMenu,
},
@@ -54,7 +52,6 @@ export default {
data() {
return {
isCannedResponseModalOpen: false,
showTranslateModal: false,
showDeleteModal: false,
};
},
@@ -125,15 +122,11 @@ export default {
});
useTrack(CONVERSATION_EVENTS.TRANSLATE_A_MESSAGE);
this.handleClose();
this.showTranslateModal = true;
},
handleReplyTo() {
this.$emit('replyTo', this.message);
this.handleClose();
},
onCloseTranslateModal() {
this.showTranslateModal = false;
},
openDeleteModal() {
this.handleClose();
this.showDeleteModal = true;
@@ -170,13 +163,6 @@ export default {
:on-close="hideCannedResponseModal"
/>
</woot-modal>
<!-- Translate Content -->
<TranslateModal
v-if="showTranslateModal"
:content="messageContent"
:content-attributes="contentAttributes"
@close="onCloseTranslateModal"
/>
<!-- Confirm Deletion -->
<woot-delete-modal
v-if="showDeleteModal"
@@ -251,7 +251,7 @@ export default {
$t('AGENT_MGMT.MULTI_SELECTOR.SEARCH.NO_RESULTS.TEAM')
"
:input-placeholder="
$t('AGENT_MGMT.MULTI_SELECTOR.SEARCH.PLACEHOLDER.INPUT')
$t('AGENT_MGMT.MULTI_SELECTOR.SEARCH.PLACEHOLDER.TEAM')
"
@select="onClickAssignTeam"
/>
@@ -54,11 +54,9 @@ export default {
<template>
<div
class="flex justify-between items-center h-14 min-h-[3.5rem] px-4 py-2 bg-n-background border-b border-n-weak"
class="flex justify-between items-center h-20 min-h-[3.5rem] px-4 py-2 bg-n-background"
>
<h1
class="flex items-center mb-0 text-2xl text-slate-900 dark:text-slate-100"
>
<h1 class="flex items-center mb-0 text-2xl text-n-slate-12">
<woot-sidemenu-icon v-if="showSidemenuIcon" />
<BackButton
v-if="showBackButton"
@@ -66,21 +64,16 @@ export default {
:back-url="backUrl"
class="ml-2 mr-4"
/>
<fluent-icon
v-if="icon"
:icon="icon"
:class="iconClass"
class="hidden ml-1 mr-2 rtl:ml-2 rtl:mr-1 md:block"
/>
<slot />
<span class="text-2xl font-medium text-slate-900 dark:text-slate-100">
<span class="text-xl font-medium text-slate-900 dark:text-slate-100">
{{ headerTitle }}
</span>
</h1>
<router-link
v-if="showNewButton && isAdmin"
:to="buttonRoute"
class="button success button--fixed-top px-3.5 py-1 rounded-[5px] flex gap-2"
class="button success button--fixed-top rounded-[5px] flex gap-2"
>
<fluent-icon icon="add-circle" />
<span class="button__content">
@@ -17,29 +17,31 @@ const props = defineProps({
const { t } = useI18n();
const showNewButton = computed(
() => props.newButtonRoutes.length !== 0 && !props.showBackButton
() => props.newButtonRoutes.length && !props.showBackButton
);
</script>
<template>
<div
class="flex flex-1 h-full justify-between flex-col m-0 bg-n-background overflow-auto"
>
<SettingsHeader
button-route="new"
:icon="icon"
:header-title="t(headerTitle)"
:button-text="t(headerButtonText)"
:show-back-button="showBackButton"
:back-url="backUrl"
:show-new-button="showNewButton"
:show-sidemenu-icon="showSidemenuIcon"
/>
<router-view v-slot="{ Component }">
<keep-alive v-if="keepAlive">
<component :is="Component" />
</keep-alive>
<component :is="Component" v-else />
</router-view>
<div class="flex flex-1 flex-col m-0 bg-n-background overflow-auto">
<div class="max-w-6xl mx-auto w-full flex flex-col flex-1">
<SettingsHeader
button-route="new"
:icon="icon"
:header-title="t(headerTitle)"
:button-text="t(headerButtonText)"
:show-back-button="showBackButton"
:back-url="backUrl"
:show-new-button="showNewButton"
:show-sidemenu-icon="showSidemenuIcon"
class="sticky top-0 z-20"
/>
<router-view v-slot="{ Component }" class="px-5 flex-1 overflow-hidden">
<component :is="Component" v-if="!keepAlive" :key="$route.fullPath" />
<keep-alive v-else>
<component :is="Component" :key="$route.fullPath" />
</keep-alive>
</router-view>
</div>
</div>
</template>
@@ -9,8 +9,14 @@ import { useAccount } from 'dashboard/composables/useAccount';
import { FEATURE_FLAGS } from '../../../../featureFlags';
import semver from 'semver';
import { getLanguageDirection } from 'dashboard/components/widgets/conversation/advancedFilterItems/languages';
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
import V4Button from 'dashboard/components-next/button/Button.vue';
export default {
components: {
BaseSettingsHeader,
V4Button,
},
setup() {
const { updateUISettings } = useUISettings();
const { enabledLanguages } = useConfig();
@@ -161,131 +167,130 @@ export default {
</script>
<template>
<div class="flex-grow flex-shrink min-w-0 p-6 overflow-auto">
<form v-if="!uiFlags.isFetchingItem" @submit.prevent="updateAccount">
<div
class="flex flex-row p-4 border-b border-slate-25 dark:border-slate-800"
>
<div class="flex flex-col w-full">
<BaseSettingsHeader :title="$t('GENERAL_SETTINGS.TITLE')">
<template #actions>
<V4Button blue :loading="isUpdating" @click="updateAccount">
{{ $t('GENERAL_SETTINGS.SUBMIT') }}
</V4Button>
</template>
</BaseSettingsHeader>
<div class="flex-grow flex-shrink min-w-0 overflow-auto mt-3">
<form v-if="!uiFlags.isFetchingItem" @submit.prevent="updateAccount">
<div
class="flex-grow-0 flex-shrink-0 flex-[25%] min-w-0 py-4 pr-6 pl-0"
class="flex flex-row border-b border-slate-25 dark:border-slate-800"
>
<h4 class="text-lg font-medium text-black-900 dark:text-slate-200">
{{ $t('GENERAL_SETTINGS.FORM.GENERAL_SECTION.TITLE') }}
</h4>
<p>{{ $t('GENERAL_SETTINGS.FORM.GENERAL_SECTION.NOTE') }}</p>
</div>
<div class="p-4 flex-grow-0 flex-shrink-0 flex-[50%]">
<label :class="{ error: v$.name.$error }">
{{ $t('GENERAL_SETTINGS.FORM.NAME.LABEL') }}
<input
v-model="name"
type="text"
:placeholder="$t('GENERAL_SETTINGS.FORM.NAME.PLACEHOLDER')"
@blur="v$.name.$touch"
/>
<span v-if="v$.name.$error" class="message">
{{ $t('GENERAL_SETTINGS.FORM.NAME.ERROR') }}
</span>
</label>
<label :class="{ error: v$.locale.$error }">
{{ $t('GENERAL_SETTINGS.FORM.LANGUAGE.LABEL') }}
<select v-model="locale">
<option
v-for="lang in languagesSortedByCode"
:key="lang.iso_639_1_code"
:value="lang.iso_639_1_code"
>
{{ lang.name }}
</option>
</select>
<span v-if="v$.locale.$error" class="message">
{{ $t('GENERAL_SETTINGS.FORM.LANGUAGE.ERROR') }}
</span>
</label>
<label v-if="featureInboundEmailEnabled">
{{ $t('GENERAL_SETTINGS.FORM.FEATURES.INBOUND_EMAIL_ENABLED') }}
</label>
<label v-if="featureCustomReplyDomainEnabled">
{{
$t('GENERAL_SETTINGS.FORM.FEATURES.CUSTOM_EMAIL_DOMAIN_ENABLED')
}}
</label>
<label v-if="featureCustomReplyDomainEnabled">
{{ $t('GENERAL_SETTINGS.FORM.DOMAIN.LABEL') }}
<input
v-model="domain"
type="text"
:placeholder="$t('GENERAL_SETTINGS.FORM.DOMAIN.PLACEHOLDER')"
/>
</label>
<label v-if="featureCustomReplyEmailEnabled">
{{ $t('GENERAL_SETTINGS.FORM.SUPPORT_EMAIL.LABEL') }}
<input
v-model="supportEmail"
type="text"
:placeholder="
$t('GENERAL_SETTINGS.FORM.SUPPORT_EMAIL.PLACEHOLDER')
"
/>
</label>
<label
v-if="showAutoResolutionConfig"
:class="{ error: v$.autoResolveDuration.$error }"
<div
class="flex-grow-0 flex-shrink-0 flex-[25%] min-w-0 py-4 pr-6 pl-0"
>
{{ $t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_DURATION.LABEL') }}
<input
v-model="autoResolveDuration"
type="number"
:placeholder="
$t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_DURATION.PLACEHOLDER')
"
@blur="v$.autoResolveDuration.$touch"
/>
<span v-if="v$.autoResolveDuration.$error" class="message">
{{ $t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_DURATION.ERROR') }}
</span>
</label>
<h4 class="text-lg font-medium text-black-900 dark:text-slate-200">
{{ $t('GENERAL_SETTINGS.FORM.GENERAL_SECTION.TITLE') }}
</h4>
<p>{{ $t('GENERAL_SETTINGS.FORM.GENERAL_SECTION.NOTE') }}</p>
</div>
<div class="p-4 flex-grow-0 flex-shrink-0 flex-[50%]">
<label :class="{ error: v$.name.$error }">
{{ $t('GENERAL_SETTINGS.FORM.NAME.LABEL') }}
<input
v-model="name"
type="text"
:placeholder="$t('GENERAL_SETTINGS.FORM.NAME.PLACEHOLDER')"
@blur="v$.name.$touch"
/>
<span v-if="v$.name.$error" class="message">
{{ $t('GENERAL_SETTINGS.FORM.NAME.ERROR') }}
</span>
</label>
<label :class="{ error: v$.locale.$error }">
{{ $t('GENERAL_SETTINGS.FORM.LANGUAGE.LABEL') }}
<select v-model="locale">
<option
v-for="lang in languagesSortedByCode"
:key="lang.iso_639_1_code"
:value="lang.iso_639_1_code"
>
{{ lang.name }}
</option>
</select>
<span v-if="v$.locale.$error" class="message">
{{ $t('GENERAL_SETTINGS.FORM.LANGUAGE.ERROR') }}
</span>
</label>
<label v-if="featureInboundEmailEnabled">
{{ $t('GENERAL_SETTINGS.FORM.FEATURES.INBOUND_EMAIL_ENABLED') }}
</label>
<label v-if="featureCustomReplyDomainEnabled">
{{
$t('GENERAL_SETTINGS.FORM.FEATURES.CUSTOM_EMAIL_DOMAIN_ENABLED')
}}
</label>
<label v-if="featureCustomReplyDomainEnabled">
{{ $t('GENERAL_SETTINGS.FORM.DOMAIN.LABEL') }}
<input
v-model="domain"
type="text"
:placeholder="$t('GENERAL_SETTINGS.FORM.DOMAIN.PLACEHOLDER')"
/>
</label>
<label v-if="featureCustomReplyEmailEnabled">
{{ $t('GENERAL_SETTINGS.FORM.SUPPORT_EMAIL.LABEL') }}
<input
v-model="supportEmail"
type="text"
:placeholder="
$t('GENERAL_SETTINGS.FORM.SUPPORT_EMAIL.PLACEHOLDER')
"
/>
</label>
<label
v-if="showAutoResolutionConfig"
:class="{ error: v$.autoResolveDuration.$error }"
>
{{ $t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_DURATION.LABEL') }}
<input
v-model="autoResolveDuration"
type="number"
:placeholder="
$t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_DURATION.PLACEHOLDER')
"
@blur="v$.autoResolveDuration.$touch"
/>
<span v-if="v$.autoResolveDuration.$error" class="message">
{{ $t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_DURATION.ERROR') }}
</span>
</label>
</div>
</div>
</div>
</form>
<div
class="flex flex-row p-4 border-slate-25 dark:border-slate-700 text-black-900 dark:text-slate-300"
>
<div
class="flex-grow-0 flex-shrink-0 flex-[25%] min-w-0 py-4 pr-6 pl-0"
>
<h4 class="text-lg font-medium text-black-900 dark:text-slate-200">
{{ $t('GENERAL_SETTINGS.FORM.ACCOUNT_ID.TITLE') }}
</h4>
<p>
{{ $t('GENERAL_SETTINGS.FORM.ACCOUNT_ID.NOTE') }}
</p>
</div>
<div class="p-4 flex-grow-0 flex-shrink-0 flex-[50%]">
<woot-code :script="getAccountId" />
</div>
</div>
<div class="p-4 text-sm text-center">
<div>{{ `v${globalConfig.appVersion}` }}</div>
<div v-if="hasAnUpdateAvailable && globalConfig.displayManifest">
{{
$t('GENERAL_SETTINGS.UPDATE_CHATWOOT', {
latestChatwootVersion: latestChatwootVersion,
})
}}
</div>
<div class="build-id">
<div>{{ `Build ${globalConfig.gitSha}` }}</div>
</div>
</div>
<woot-loading-state v-if="uiFlags.isFetchingItem" />
</div>
<woot-submit-button
class="button nice success button--fixed-top"
:button-text="$t('GENERAL_SETTINGS.SUBMIT')"
:loading="isUpdating"
/>
</form>
<woot-loading-state v-if="uiFlags.isFetchingItem" />
<div class="flex flex-row">
<div class="flex-grow-0 flex-shrink-0 flex-[25%] min-w-0 py-4 pr-6 pl-0">
<h4 class="text-lg font-medium text-black-900 dark:text-slate-200">
{{ $t('GENERAL_SETTINGS.FORM.ACCOUNT_ID.TITLE') }}
</h4>
<p>
{{ $t('GENERAL_SETTINGS.FORM.ACCOUNT_ID.NOTE') }}
</p>
</div>
<div class="p-4 flex-grow-0 flex-shrink-0 flex-[50%]">
<woot-code :script="getAccountId" />
</div>
</div>
<div class="p-4 text-sm text-center">
<div>{{ `v${globalConfig.appVersion}` }}</div>
<div v-if="hasAnUpdateAvailable && globalConfig.displayManifest">
{{
$t('GENERAL_SETTINGS.UPDATE_CHATWOOT', {
latestChatwootVersion: latestChatwootVersion,
})
}}
</div>
<div class="build-id">
<div>{{ `Build ${globalConfig.gitSha}` }}</div>
</div>
</div>
</div>
</template>
@@ -1,6 +1,6 @@
import { frontendURL } from '../../../../helper/URLHelper';
import SettingsContent from '../Wrapper.vue';
import Index from './Index.vue';
import SettingsWrapper from '../SettingsWrapper.vue';
export default {
routes: [
@@ -9,12 +9,7 @@ export default {
meta: {
permissions: ['administrator'],
},
component: SettingsContent,
props: {
headerTitle: 'GENERAL_SETTINGS.TITLE',
icon: 'briefcase',
showNewButton: false,
},
component: SettingsWrapper,
children: [
{
path: '',
@@ -41,7 +41,7 @@ const openInNewTab = url => {
</script>
<template>
<div class="flex flex-col items-start w-full gap-2 pt-4">
<div class="flex flex-col items-start w-full gap-2">
<BackButton
v-if="backButtonLabel"
compact
@@ -60,13 +60,11 @@ const openInNewTab = url => {
size="14"
:icon="iconName"
type="outline"
class="flex-shrink-0 text-woot-500 dark:text-woot-500"
class="flex-shrink-0 text-n-brand"
/>
</div>
</div>
<h1
class="text-2xl font-semibold font-interDisplay tracking-[0.3px] text-slate-900 dark:text-slate-25"
>
<h1 class="text-xl font-medium tracking-tight text-n-slate-12">
{{ title }}
</h1>
</div>
@@ -75,9 +73,9 @@ const openInNewTab = url => {
<slot name="actions" />
</div>
</div>
<div class="flex flex-col w-full gap-3 text-slate-600 dark:text-slate-300">
<div class="flex flex-col w-full gap-3 text-n-slate-11">
<p
class="mb-0 text-base font-normal line-clamp-5 sm:line-clamp-none max-w-3xl tracking-[-0.1px]"
class="mb-0 text-sm font-normal line-clamp-5 sm:line-clamp-none max-w-3xl"
>
<slot name="description">{{ description }}</slot>
</p>
@@ -87,7 +85,7 @@ const openInNewTab = url => {
:href="helpURL"
target="_blank"
rel="noopener noreferrer"
class="items-center hidden gap-1 text-sm font-medium sm:inline-flex w-fit text-n-brand dark:text-n-brand hover:underline"
class="items-center hidden gap-1 text-sm font-medium sm:inline-flex w-fit text-n-brand hover:underline"
>
{{ linkText }}
<Icon
@@ -13,7 +13,7 @@ defineProps({
<template>
<div
class="flex relative flex-col sm:flex-row p-4 gap-4 sm:p-6 justify-between shadow-sm group bg-white border border-solid rounded-xl dark:bg-slate-800 border-slate-75 dark:border-slate-700/50 w-full"
class="flex relative flex-col sm:flex-row p-4 gap-4 sm:p-6 justify-between group outline outline-n-container outline-1 bg-n-alpha-3 rounded-2xl shadow w-full"
>
<slot name="leftSection">
<div class="flex flex-col min-w-0 items-start gap-3 max-w-[480px] w-full">
@@ -21,7 +21,7 @@ defineProps({
class="flex items-center justify-between w-full gap-3 sm:justify-normal whitespace-nowrap"
>
<h3
class="justify-between text-sm font-medium truncate w-fit sm:justify-normal text-slate-900 dark:text-slate-25"
class="justify-between tracking-tight font-medium truncate w-fit sm:justify-normal text-slate-900 dark:text-slate-25"
>
<slot name="title">
{{ title }}
@@ -40,9 +40,7 @@ export default {
</script>
<template>
<div
class="flex flex-row overflow-auto p-4 h-full bg-n-alpha-2 dark:bg-n-solid-1"
>
<div class="flex flex-row overflow-auto h-full">
<woot-wizard
class="hidden md:block w-1/4"
:global-config="globalConfig"
@@ -360,7 +360,7 @@ export default {
<template>
<div
class="flex-grow flex-shrink w-full min-w-0 pl-0 pr-0 overflow-auto settings bg-n-solid-1"
class="flex-grow flex-shrink w-full min-w-0 pl-0 pr-0 overflow-auto settings"
>
<SettingIntroBanner
:header-image="inbox.avatarUrl"
@@ -1,78 +1,76 @@
<script>
import { mapGetters } from 'vuex';
<script setup>
import { ref, computed } from 'vue';
import { useStore } from 'vuex';
import { useRouter } from 'vue-router';
import { frontendURL } from '../../../../helper/URLHelper';
import { useAlert } from 'dashboard/composables';
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import { useInstallationName } from 'shared/mixins/globalConfigMixin';
export default {
mixins: [globalConfigMixin],
props: {
integrationId: {
type: [String, Number],
required: true,
},
integrationName: { type: String, default: '' },
integrationDescription: { type: String, default: '' },
integrationEnabled: { type: Boolean, default: false },
integrationAction: { type: String, default: '' },
actionButtonText: { type: String, default: '' },
deleteConfirmationText: { type: Object, default: () => ({}) },
},
data() {
return {
showDeleteConfirmationPopup: false,
};
},
computed: {
...mapGetters({
accountId: 'getCurrentAccountId',
globalConfig: 'globalConfig/get',
}),
},
methods: {
frontendURL,
openDeletePopup() {
this.showDeleteConfirmationPopup = true;
},
closeDeletePopup() {
this.showDeleteConfirmationPopup = false;
},
confirmDeletion() {
this.closeDeletePopup();
this.deleteIntegration(this.deleteIntegration);
this.$router.push({ name: 'settings_integrations' });
},
async deleteIntegration() {
try {
await this.$store.dispatch(
'integrations/deleteIntegration',
this.integrationId
);
useAlert(this.$t('INTEGRATION_SETTINGS.DELETE.API.SUCCESS_MESSAGE'));
} catch (error) {
useAlert(
this.$t('INTEGRATION_SETTINGS.WEBHOOK.DELETE.API.ERROR_MESSAGE')
);
}
},
const props = defineProps({
integrationId: {
type: [String, Number],
required: true,
},
integrationName: { type: String, default: '' },
integrationDescription: { type: String, default: '' },
integrationEnabled: { type: Boolean, default: false },
integrationAction: { type: String, default: '' },
actionButtonText: { type: String, default: '' },
deleteConfirmationText: { type: Object, default: () => ({}) },
});
const store = useStore();
const router = useRouter();
const showDeleteConfirmationPopup = ref(false);
const accountId = computed(() => store.getters.getCurrentAccountId);
const globalConfig = computed(() => store.getters['globalConfig/get']);
const openDeletePopup = () => {
showDeleteConfirmationPopup.value = true;
};
const closeDeletePopup = () => {
showDeleteConfirmationPopup.value = false;
};
const deleteIntegration = async () => {
try {
await store.dispatch('integrations/deleteIntegration', props.integrationId);
useAlert('INTEGRATION_SETTINGS.DELETE.API.SUCCESS_MESSAGE');
} catch (error) {
useAlert('INTEGRATION_SETTINGS.WEBHOOK.DELETE.API.ERROR_MESSAGE');
}
};
const confirmDeletion = () => {
closeDeletePopup();
deleteIntegration();
router.push({ name: 'settings_integrations' });
};
</script>
<template>
<div
class="flex flex-col items-start justify-between md:flex-row md:items-center"
class="flex flex-col items-start justify-between md:flex-row md:items-center p-4 outline outline-n-container outline-1 bg-n-alpha-3 rounded-md shadow"
>
<div class="flex items-center justify-start flex-1 m-0 mx-4">
<img
:src="`/dashboard/images/integrations/${integrationId}.png`"
class="w-16 h-16 p-2 mr-4"
/>
<div class="flex items-center justify-start flex-1 m-0 mx-4 gap-6">
<div class="flex h-16 w-16 items-center justify-center">
<img
:src="`/dashboard/images/integrations/${integrationId}.png`"
class="max-w-full rounded-md border border-n-weak shadow-sm block dark:hidden bg-n-alpha-3 dark:bg-n-alpha-2"
/>
<img
:src="`/dashboard/images/integrations/${integrationId}-dark.png`"
class="max-w-full rounded-md border border-n-weak shadow-sm hidden dark:block bg-n-alpha-3 dark:bg-n-alpha-2"
/>
</div>
<div>
<h3 class="mb-1 text-xl font-medium text-slate-800 dark:text-slate-100">
<h3 class="mb-1 text-xl font-medium text-n-slate-12">
{{ integrationName }}
</h3>
<p class="text-slate-700 dark:text-slate-200">
<p class="text-n-slate-11 text-sm leading-6">
{{
useInstallationName(
integrationDescription,
@@ -108,7 +108,9 @@ export default {
</script>
<template>
<div class="overflow-auto p-4 max-w-full my-auto flex flex-wrap h-full">
<div
class="overflow-auto p-4 max-w-6xl mx-auto my-auto flex flex-wrap h-full"
>
<woot-button
v-if="showAddButton"
color-scheme="success"
@@ -37,7 +37,7 @@ const integrationStatus = computed(() =>
);
const integrationStatusColor = computed(() =>
props.enabled ? 'bg-green-500' : 'bg-slate-200'
props.enabled ? 'bg-n-teal-9' : 'bg-n-slate-8'
);
const actionURL = computed(() =>
@@ -47,30 +47,30 @@ const actionURL = computed(() =>
<template>
<div
class="flex flex-col flex-1 p-6 bg-white border border-solid rounded-md dark:bg-slate-800 border-slate-50 dark:border-slate-700/50"
class="flex flex-col flex-1 p-6 m-[1px] outline outline-n-container outline-1 bg-n-alpha-3 rounded-md shadow"
>
<div class="flex items-start justify-between">
<div class="flex h-12 w-12 mb-4">
<img
:src="`/dashboard/images/integrations/${id}.png`"
class="max-w-full rounded-md border border-slate-50 dark:border-slate-700/50 shadow-sm block dark:hidden bg-white dark:bg-slate-900"
class="max-w-full rounded-md border border-n-weak shadow-sm block dark:hidden bg-n-alpha-3 dark:bg-n-alpha-2"
/>
<img
:src="`/dashboard/images/integrations/${id}-dark.png`"
class="max-w-full rounded-md border border-slate-50 dark:border-slate-700/50 shadow-sm hidden dark:block bg-white dark:bg-slate-900"
class="max-w-full rounded-md border border-n-weak shadow-sm hidden dark:block bg-n-alpha-3 dark:bg-n-alpha-2"
/>
</div>
<fluent-icon
<span
v-tooltip="integrationStatus"
size="20"
class="text-white p-0.5 rounded-full"
class="text-white p-0.5 rounded-full w-5 h-5 flex items-center justify-center"
:class="integrationStatusColor"
icon="checkmark"
/>
>
<i class="i-ph-check-bold text-sm" />
</span>
</div>
<div class="flex flex-col m-0 flex-1">
<div
class="font-medium mb-2 text-slate-800 dark:text-slate-100 flex justify-between items-center"
class="font-medium mb-2 text-n-slate-12 flex justify-between items-center"
>
<span class="text-base font-semibold">{{ name }}</span>
<router-link :to="actionURL">
@@ -79,7 +79,7 @@ const actionURL = computed(() =>
</woot-button>
</router-link>
</div>
<p class="text-slate-700 dark:text-slate-200">
<p class="text-n-slate-11">
{{ useInstallationName(description, globalConfig.installationName) }}
</p>
</div>
@@ -35,32 +35,23 @@ onMounted(() => {
</script>
<template>
<div class="flex-grow flex-shrink p-4 overflow-auto">
<div class="flex flex-col">
<div class="flex flex-col">
<div>
<div
v-if="integrationLoaded && !uiFlags.isCreatingLinear"
class="p-4 mb-4 bg-white border border-solid rounded-sm dark:bg-slate-800 border-slate-75 dark:border-slate-700/50"
>
<Integration
:integration-id="integration.id"
:integration-logo="integration.logo"
:integration-name="integration.name"
:integration-description="integration.description"
:integration-enabled="integration.enabled"
:integration-action="integrationAction"
:delete-confirmation-text="{
title: $t('INTEGRATION_SETTINGS.LINEAR.DELETE.TITLE'),
message: $t('INTEGRATION_SETTINGS.LINEAR.DELETE.MESSAGE'),
}"
/>
</div>
<div v-else class="flex items-center justify-center flex-1">
<Spinner size="" color-scheme="primary" />
</div>
</div>
</div>
<div class="flex-grow flex-shrink p-4 overflow-auto max-w-6xl mx-auto">
<div v-if="integrationLoaded && !uiFlags.isCreatingLinear">
<Integration
:integration-id="integration.id"
:integration-logo="integration.logo"
:integration-name="integration.name"
:integration-description="integration.description"
:integration-enabled="integration.enabled"
:integration-action="integrationAction"
:delete-confirmation-text="{
title: $t('INTEGRATION_SETTINGS.LINEAR.DELETE.TITLE'),
message: $t('INTEGRATION_SETTINGS.LINEAR.DELETE.MESSAGE'),
}"
/>
</div>
<div v-else class="flex items-center justify-center flex-1">
<Spinner size="" color-scheme="primary" />
</div>
</div>
</template>
@@ -47,31 +47,19 @@ export default {
</script>
<template>
<div class="flex-grow flex-shrink p-4 overflow-auto">
<div class="flex flex-col">
<div class="flex flex-col">
<div>
<div
v-if="integrationLoaded"
class="p-4 mb-4 bg-white border border-solid rounded-sm dark:bg-slate-800 border-slate-75 dark:border-slate-700/50"
>
<Integration
:integration-id="integration.id"
:integration-logo="integration.logo"
:integration-name="integration.name"
:integration-description="integration.description"
:integration-enabled="integration.enabled"
:integration-action="integrationAction()"
/>
</div>
<div
v-if="integration.enabled"
class="p-4 mb-4 bg-white border border-solid rounded-sm dark:bg-slate-800 border-slate-75 dark:border-slate-700/50"
>
<IntegrationHelpText />
</div>
</div>
</div>
<div class="max-w-6xl">
<div v-if="integrationLoaded">
<Integration
:integration-id="integration.id"
:integration-logo="integration.logo"
:integration-name="integration.name"
:integration-description="integration.description"
:integration-enabled="integration.enabled"
:integration-action="integrationAction()"
/>
</div>
<div v-if="integration.enabled">
<IntegrationHelpText />
</div>
</div>
</template>
@@ -1,63 +1,62 @@
<script>
<script setup>
import { defineProps, defineEmits } from 'vue';
import { useIntegrationHook } from 'dashboard/composables/useIntegrationHook';
export default {
props: {
integrationId: {
type: String,
required: true,
},
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
integrationId: {
type: String,
required: true,
},
emits: ['add', 'delete'],
setup(props) {
const { integration, hasConnectedHooks } = useIntegrationHook(
props.integrationId
);
return { integration, hasConnectedHooks };
},
};
});
defineEmits(['add', 'delete']);
const { integration, hasConnectedHooks } = useIntegrationHook(
props.integrationId
);
</script>
<template>
<div class="flex-shrink flex-grow overflow-auto p-4">
<div class="flex flex-col">
<div
class="bg-white dark:bg-slate-800 border border-solid border-slate-75 dark:border-slate-700/50 rounded-xl mb-4 p-4"
>
<div class="flex">
<div class="flex h-[6.25rem] w-[6.25rem]">
<img
:src="`/dashboard/images/integrations/${integration.id}.png`"
class="max-w-full rounded-md border border-slate-50 dark:border-slate-700/50 shadow-sm block dark:hidden bg-white dark:bg-slate-900"
/>
<img
:src="`/dashboard/images/integrations/${integration.id}-dark.png`"
class="max-w-full rounded-md border border-slate-50 dark:border-slate-700/50 shadow-sm hidden dark:block bg-white dark:bg-slate-900"
<div
class="outline outline-n-container outline-1 bg-n-alpha-3 rounded-md shadow flex-grow overflow-auto p-4"
>
<div class="flex items-center justify-center">
<div class="flex h-16 w-16 items-center justify-center">
<img
:src="`/dashboard/images/integrations/${integrationId}.png`"
class="max-w-full rounded-md border border-n-weak shadow-sm block dark:hidden bg-n-alpha-3 dark:bg-n-alpha-2"
/>
<img
:src="`/dashboard/images/integrations/${integrationId}-dark.png`"
class="max-w-full rounded-md border border-n-weak shadow-sm hidden dark:block bg-n-alpha-3 dark:bg-n-alpha-2"
/>
</div>
<div class="flex flex-col justify-center m-0 mx-4 flex-1">
<h3 class="mb-1 text-xl font-medium text-n-slate-12">
{{ integration.name }}
</h3>
<p class="text-n-slate-11 text-sm leading-6">
{{ integration.description }}
</p>
</div>
<div class="flex justify-center items-center mb-0 w-[15%]">
<div v-if="hasConnectedHooks">
<div @click="$emit('delete', integration.hooks[0])">
<Button
ruby
faded
:label="$t('INTEGRATION_APPS.DISCONNECT.BUTTON_TEXT')"
/>
</div>
<div class="flex flex-col justify-center m-0 mx-4 flex-1">
<h3
class="text-xl font-medium mb-1 text-slate-800 dark:text-slate-100"
>
{{ integration.name }}
</h3>
<p class="text-slate-700 dark:text-slate-200">
{{ integration.description }}
</p>
</div>
<div class="flex justify-center items-center mb-0 w-[15%]">
<div v-if="hasConnectedHooks">
<div @click="$emit('delete', integration.hooks[0])">
<woot-button class="nice alert">
{{ $t('INTEGRATION_APPS.DISCONNECT.BUTTON_TEXT') }}
</woot-button>
</div>
</div>
<div v-else>
<woot-button class="button nice" @click="$emit('add')">
{{ $t('INTEGRATION_APPS.CONNECT.BUTTON_TEXT') }}
</woot-button>
</div>
</div>
</div>
<div v-else>
<Button
blue
faded
:label="$t('INTEGRATION_APPS.CONNECT.BUTTON_TEXT')"
@click="$emit('add')"
/>
</div>
</div>
</div>
@@ -1,102 +1,99 @@
<script>
import { mapGetters } from 'vuex';
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
<script setup>
import { ref, computed, onMounted } from 'vue';
import { useStore } from 'vuex';
import { useRouter, useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import Integration from './Integration.vue';
import SelectChannelWarning from './Slack/SelectChannelWarning.vue';
import SlackIntegrationHelpText from './Slack/SlackIntegrationHelpText.vue';
import Spinner from 'shared/components/Spinner.vue';
export default {
components: {
Spinner,
Integration,
SelectChannelWarning,
SlackIntegrationHelpText,
},
mixins: [globalConfigMixin],
props: {
code: { type: String, default: '' },
},
data() {
return { integrationLoaded: false };
},
computed: {
integration() {
return this.$store.getters['integrations/getIntegration']('slack');
},
areHooksAvailable() {
const { hooks = [] } = this.integration || {};
return !!hooks.length;
},
hook() {
const { hooks = [] } = this.integration || {};
const [hook] = hooks;
return hook || {};
},
isIntegrationHookEnabled() {
return this.hook.status || false;
},
hasConnectedAChannel() {
return !!this.hook.reference_id;
},
selectedChannelName() {
if (this.hook.status) {
const { settings: { channel_name: channelName = '' } = {} } = this.hook;
return channelName || 'customer-conversations';
}
return this.$t('INTEGRATION_SETTINGS.SLACK.HELP_TEXT.SELECTED');
},
...mapGetters({
uiFlags: 'integrations/getUIFlags',
}),
integrationAction() {
if (this.integration.enabled) {
return 'disconnect';
}
return this.integration.action;
},
},
mounted() {
this.intializeSlackIntegration();
},
methods: {
async intializeSlackIntegration() {
await this.$store.dispatch('integrations/get', 'slack');
if (this.code) {
await this.$store.dispatch('integrations/connectSlack', this.code);
// Clear the query param `code` from the URL as the
// subsequent reloads would result in an error
this.$router.replace(this.$route.path);
}
this.integrationLoaded = true;
},
},
const props = defineProps({
code: { type: String, default: '' },
});
const store = useStore();
const router = useRouter();
const route = useRoute();
const { t } = useI18n();
const integrationLoaded = ref(false);
const integration = computed(() => {
return store.getters['integrations/getIntegration']('slack');
});
const areHooksAvailable = computed(() => {
const { hooks = [] } = integration.value || {};
return !!hooks.length;
});
const hook = computed(() => {
const { hooks = [] } = integration.value || {};
const [firstHook] = hooks;
return firstHook || {};
});
const isIntegrationHookEnabled = computed(() => {
return hook.value.status || false;
});
const hasConnectedAChannel = computed(() => {
return !!hook.value.reference_id;
});
const selectedChannelName = computed(() => {
if (hook.value.status) {
const { settings: { channel_name: channelName = '' } = {} } = hook.value;
return channelName || 'customer-conversations';
}
return t('INTEGRATION_SETTINGS.SLACK.HELP_TEXT.SELECTED');
});
const uiFlags = computed(() => store.getters['integrations/getUIFlags']);
const integrationAction = computed(() => {
if (integration.value.enabled) {
return 'disconnect';
}
return integration.value.action;
});
const intializeSlackIntegration = async () => {
await store.dispatch('integrations/get', 'slack');
if (props.code) {
await store.dispatch('integrations/connectSlack', props.code);
// Clear the query param `code` from the URL as the
// subsequent reloads would result in an error
router.replace(route.path);
}
integrationLoaded.value = true;
};
onMounted(() => {
intializeSlackIntegration();
});
</script>
<template>
<div
v-if="integrationLoaded && !uiFlags.isCreatingSlack"
class="flex flex-col flex-1 overflow-auto"
class="flex flex-col flex-1 overflow-auto gap-5 pt-1 pb-10"
>
<div
class="p-4 bg-white border-b border-solid rounded-sm dark:bg-slate-800 border-slate-75 dark:border-slate-700/50"
>
<Integration
:integration-id="integration.id"
:integration-logo="integration.logo"
:integration-name="integration.name"
:integration-description="integration.description"
:integration-enabled="integration.enabled"
:integration-action="integrationAction"
:action-button-text="$t('INTEGRATION_SETTINGS.SLACK.DELETE')"
:delete-confirmation-text="{
title: $t('INTEGRATION_SETTINGS.SLACK.DELETE_CONFIRMATION.TITLE'),
message: $t('INTEGRATION_SETTINGS.SLACK.DELETE_CONFIRMATION.MESSAGE'),
}"
/>
</div>
<div v-if="areHooksAvailable" class="flex-1 p-6">
<Integration
:integration-id="integration.id"
:integration-logo="integration.logo"
:integration-name="integration.name"
:integration-description="integration.description"
:integration-enabled="integration.enabled"
:integration-action="integrationAction"
:action-button-text="$t('INTEGRATION_SETTINGS.SLACK.DELETE')"
:delete-confirmation-text="{
title: $t('INTEGRATION_SETTINGS.SLACK.DELETE_CONFIRMATION.TITLE'),
message: $t('INTEGRATION_SETTINGS.SLACK.DELETE_CONFIRMATION.MESSAGE'),
}"
/>
<div v-if="areHooksAvailable" class="flex-1">
<SelectChannelWarning
v-if="!isIntegrationHookEnabled"
:has-connected-a-channel="hasConnectedAChannel"
@@ -1,109 +1,99 @@
<script>
import { mapGetters } from 'vuex';
<script setup>
import { ref, computed } from 'vue';
import { useStore } from 'vuex';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import { useInstallationName } from 'shared/mixins/globalConfigMixin';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import Button from 'dashboard/components-next/button/Button.vue';
export default {
mixins: [globalConfigMixin],
props: {
hasConnectedAChannel: {
type: Boolean,
default: true,
},
},
setup() {
const { formatMessage } = useMessageFormatter();
return {
formatMessage,
};
},
data() {
return { selectedChannelId: '', availableChannels: [] };
},
computed: {
...mapGetters({
globalConfig: 'globalConfig/get',
uiFlags: 'integrations/getUIFlags',
}),
errorDescription() {
return !this.hasConnectedAChannel
? this.$t('INTEGRATION_SETTINGS.SLACK.SELECT_CHANNEL.DESCRIPTION')
: this.$t('INTEGRATION_SETTINGS.SLACK.SELECT_CHANNEL.EXPIRED');
},
},
methods: {
async fetchChannels() {
try {
this.availableChannels = await this.$store.dispatch(
'integrations/listAllSlackChannels'
);
this.availableChannels.sort((c1, c2) => c1.name - c2.name);
} catch {
this.$t('INTEGRATION_SETTINGS.SLACK.FAILED_TO_FETCH_CHANNELS');
this.availableChannels = [];
}
},
async updateIntegration() {
try {
await this.$store.dispatch('integrations/updateSlack', {
referenceId: this.selectedChannelId,
});
useAlert(this.$t('INTEGRATION_SETTINGS.SLACK.UPDATE_SUCCESS'));
} catch (error) {
useAlert(error.message || 'INTEGRATION_SETTINGS.SLACK.UPDATE_ERROR');
}
},
const props = defineProps({
hasConnectedAChannel: {
type: Boolean,
default: true,
},
});
const store = useStore();
const { t } = useI18n();
const { formatMessage } = useMessageFormatter();
const selectedChannelId = ref('');
const availableChannels = ref([]);
const uiFlags = computed(() => store.getters['integrations/getUIFlags']);
const errorDescription = computed(() => {
return !props.hasConnectedAChannel
? t('INTEGRATION_SETTINGS.SLACK.SELECT_CHANNEL.DESCRIPTION')
: t('INTEGRATION_SETTINGS.SLACK.SELECT_CHANNEL.EXPIRED');
});
const globalConfig = computed(() => store.getters['globalConfig/get']);
const formattedErrorMessage = computed(() => {
return formatMessage(
useInstallationName(
errorDescription.value,
globalConfig.value.installationName
),
false
);
});
const fetchChannels = async () => {
try {
availableChannels.value = await store.dispatch(
'integrations/listAllSlackChannels'
);
availableChannels.value.sort((c1, c2) => c1.name - c2.name);
} catch {
t('INTEGRATION_SETTINGS.SLACK.FAILED_TO_FETCH_CHANNELS');
availableChannels.value = [];
}
};
const updateIntegration = async () => {
try {
await store.dispatch('integrations/updateSlack', {
referenceId: selectedChannelId.value,
});
useAlert(t('INTEGRATION_SETTINGS.SLACK.UPDATE_SUCCESS'));
} catch (error) {
useAlert(error.message || 'INTEGRATION_SETTINGS.SLACK.UPDATE_ERROR');
}
};
</script>
<template>
<div
class="px-6 py-4 mb-4 border border-yellow-200 rounded-md bg-yellow-50 dark:border-slate-700 dark:bg-slate-800"
class="px-6 py-4 mb-4 outline outline-n-container outline-1 bg-n-alpha-3 rounded-md shadow"
>
<div class="flex">
<div class="flex-shrink-0 mt-0.5">
<fluent-icon
icon="alert"
class="text-yellow-500 dark:text-yellow-400"
size="24"
/>
<div class="flex-shrink-0">
<div class="i-lucide-bell text-xl text-n-amber-11 mt-1" />
</div>
<div class="ml-3">
<p
class="mb-1 text-base font-semibold text-yellow-900 dark:text-yellow-500"
>
<p class="mb-1 text-base font-semibold text-n-slate-12">
{{
$t('INTEGRATION_SETTINGS.SLACK.SELECT_CHANNEL.ATTENTION_REQUIRED')
}}
</p>
<div class="mt-2 text-sm text-yellow-800 dark:text-yellow-600">
<p
v-dompurify-html="
formatMessage(
useInstallationName(
errorDescription,
globalConfig.installationName
),
false
)
"
/>
<div class="mt-2 text-sm text-n-slate-11 mb-3">
<p v-dompurify-html="formattedErrorMessage" />
</div>
</div>
</div>
<div v-if="!hasConnectedAChannel" class="mt-2 ml-8">
<woot-submit-button
<div v-if="!hasConnectedAChannel" class="mb-2 mt-1 ml-8">
<Button
v-if="!availableChannels.length"
button-class="smooth small warning"
:loading="uiFlags.isFetchingSlackChannels"
:button-text="
$t('INTEGRATION_SETTINGS.SLACK.SELECT_CHANNEL.BUTTON_TEXT')
"
spinner-class="warning"
amber
sm
:is-loading="uiFlags.isFetchingSlackChannels"
@click="fetchChannels"
/>
>
{{ $t('INTEGRATION_SETTINGS.SLACK.SELECT_CHANNEL.BUTTON_TEXT') }}
</Button>
<div v-else class="inline-flex">
<select
v-model="selectedChannelId"
@@ -120,13 +110,14 @@ export default {
#{{ channel.name }}
</option>
</select>
<woot-submit-button
button-class="smooth small success"
:button-text="$t('INTEGRATION_SETTINGS.SLACK.SELECT_CHANNEL.UPDATE')"
spinner-class="success"
:loading="uiFlags.isUpdatingSlack"
<Button
teal
sm
:is-loading="uiFlags.isUpdatingSlack"
@click="updateIntegration"
/>
>
{{ $t('INTEGRATION_SETTINGS.SLACK.SELECT_CHANNEL.UPDATE') }}
</Button>
</div>
</div>
</div>
@@ -1,41 +1,37 @@
<script>
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
export default {
props: {
selectedChannelName: {
type: String,
required: true,
},
const props = defineProps({
selectedChannelName: {
type: String,
required: true,
},
setup() {
const { formatMessage } = useMessageFormatter();
return {
formatMessage,
};
},
};
});
const { t } = useI18n();
const { formatMessage } = useMessageFormatter();
const formattedHelpText = computed(() => {
return formatMessage(
t('INTEGRATION_SETTINGS.SLACK.HELP_TEXT.BODY', {
selectedChannelName: props.selectedChannelName,
}),
false
);
});
</script>
<template>
<div
class="flex-1 w-full p-6 bg-white rounded-md border border-slate-50 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200"
class="flex-1 w-full px-6 py-5 outline outline-n-container outline-1 bg-n-alpha-3 rounded-md shadow"
>
<div class="prose-lg max-w-5xl">
<h5 class="dark:text-slate-100">
{{ $t('INTEGRATION_SETTINGS.SLACK.HELP_TEXT.TITLE') }}
<h5 class="text-n-slate-12 tracking-tight">
{{ t('INTEGRATION_SETTINGS.SLACK.HELP_TEXT.TITLE') }}
</h5>
<p>
<span
v-dompurify-html="
formatMessage(
$t('INTEGRATION_SETTINGS.SLACK.HELP_TEXT.BODY', {
selectedChannelName: selectedChannelName,
}),
false
)
"
/>
</p>
<div v-dompurify-html="formattedHelpText" class="text-n-slate-11" />
</div>
</div>
</template>
@@ -29,9 +29,7 @@ export default {
</script>
<template>
<div
class="p-3 bg-white dark:bg-slate-900 h-[calc(100vh-3.5rem)] flex flex-col border-l border-n-weak"
>
<div class="p-3 bg-n-background h-full flex flex-col">
<div>
<woot-input
:model-value="macroName"
@@ -1,166 +1,17 @@
<script>
import { mapGetters } from 'vuex';
import AgentTable from './components/overview/AgentTable.vue';
import MetricCard from './components/overview/MetricCard.vue';
import { OVERVIEW_METRICS } from './constants';
import endOfDay from 'date-fns/endOfDay';
import getUnixTime from 'date-fns/getUnixTime';
<script setup>
import ReportHeader from './components/ReportHeader.vue';
import HeatmapContainer from './components/HeatmapContainer.vue';
export const FETCH_INTERVAL = 60000;
export default {
name: 'LiveReports',
components: {
ReportHeader,
AgentTable,
MetricCard,
HeatmapContainer,
},
data() {
return {
// always start with 0, this is to manage the pagination in tanstack table
// when we send the data, we do a +1 to this value
pageIndex: 0,
};
},
computed: {
...mapGetters({
agentStatus: 'agents/getAgentStatus',
agents: 'agents/getAgents',
accountConversationMetric: 'getAccountConversationMetric',
agentConversationMetric: 'getAgentConversationMetric',
uiFlags: 'getOverviewUIFlags',
}),
agentStatusMetrics() {
let metric = {};
Object.keys(this.agentStatus).forEach(key => {
const metricName = this.$t(
`OVERVIEW_REPORTS.AGENT_STATUS.${OVERVIEW_METRICS[key]}`
);
metric[metricName] = this.agentStatus[key];
});
return metric;
},
conversationMetrics() {
let metric = {};
Object.keys(this.accountConversationMetric).forEach(key => {
const metricName = this.$t(
`OVERVIEW_REPORTS.ACCOUNT_CONVERSATIONS.${OVERVIEW_METRICS[key]}`
);
metric[metricName] = this.accountConversationMetric[key];
});
return metric;
},
},
mounted() {
this.$store.dispatch('agents/get');
this.initalizeReport();
},
beforeUnmount() {
if (this.timeoutId) {
clearTimeout(this.timeoutId);
}
},
methods: {
initalizeReport() {
this.fetchAllData();
this.scheduleReportRefresh();
},
scheduleReportRefresh() {
this.timeoutId = setTimeout(async () => {
await this.fetchAllData();
this.scheduleReportRefresh();
}, FETCH_INTERVAL);
},
fetchAllData() {
this.fetchAccountConversationMetric();
this.fetchAgentConversationMetric();
},
downloadHeatmapData() {
let to = endOfDay(new Date());
this.$store.dispatch('downloadAccountConversationHeatmap', {
to: getUnixTime(to),
});
},
fetchAccountConversationMetric() {
this.$store.dispatch('fetchAccountConversationMetric', {
type: 'account',
});
},
fetchAgentConversationMetric() {
this.$store.dispatch('fetchAgentConversationMetric', {
type: 'agent',
page: this.pageIndex + 1,
});
},
onPageNumberChange(pageIndex) {
this.pageIndex = pageIndex;
this.fetchAgentConversationMetric();
},
},
};
import AgentLiveReportContainer from './components/AgentLiveReportContainer.vue';
import TeamLiveReportContainer from './components/TeamLiveReportContainer.vue';
import StatsLiveReportsContainer from './components/StatsLiveReportsContainer.vue';
</script>
<template>
<ReportHeader :header-title="$t('OVERVIEW_REPORTS.HEADER')" />
<div class="flex flex-col gap-4 pb-6">
<div class="flex flex-col items-center md:flex-row gap-4">
<div
class="flex-1 w-full max-w-full md:w-[65%] md:max-w-[65%] conversation-metric"
>
<MetricCard
:header="$t('OVERVIEW_REPORTS.ACCOUNT_CONVERSATIONS.HEADER')"
:is-loading="uiFlags.isFetchingAccountConversationMetric"
:loading-message="
$t('OVERVIEW_REPORTS.ACCOUNT_CONVERSATIONS.LOADING_MESSAGE')
"
>
<div
v-for="(metric, name, index) in conversationMetrics"
:key="index"
class="flex-1 min-w-0 pb-2"
>
<h3 class="text-base text-n-slate-11">
{{ name }}
</h3>
<p class="text-n-slate-12 text-3xl mb-0 mt-1">
{{ metric }}
</p>
</div>
</MetricCard>
</div>
<div class="flex-1 w-full max-w-full md:w-[35%] md:max-w-[35%]">
<MetricCard :header="$t('OVERVIEW_REPORTS.AGENT_STATUS.HEADER')">
<div
v-for="(metric, name, index) in agentStatusMetrics"
:key="index"
class="flex-1 min-w-0 pb-2"
>
<h3 class="text-base text-n-slate-11">
{{ name }}
</h3>
<p class="text-n-slate-12 text-3xl mb-0 mt-1">
{{ metric }}
</p>
</div>
</MetricCard>
</div>
</div>
<StatsLiveReportsContainer />
<HeatmapContainer />
<div class="flex flex-row flex-wrap max-w-full">
<MetricCard :header="$t('OVERVIEW_REPORTS.AGENT_CONVERSATIONS.HEADER')">
<AgentTable
:agents="agents"
:agent-metrics="agentConversationMetric"
:page-index="pageIndex"
:is-loading="uiFlags.isFetchingAgentConversationMetric"
@page-change="onPageNumberChange"
/>
</MetricCard>
</div>
<AgentLiveReportContainer />
<TeamLiveReportContainer />
</div>
</template>
@@ -0,0 +1,36 @@
<script setup>
import { onMounted } from 'vue';
import AgentTable from './overview/AgentTable.vue';
import MetricCard from './overview/MetricCard.vue';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useLiveRefresh } from 'dashboard/composables/useLiveRefresh';
const store = useStore();
const uiFlags = useMapGetter('getOverviewUIFlags');
const agentConversationMetric = useMapGetter('getAgentConversationMetric');
const agents = useMapGetter('agents/getAgents');
const fetchData = () => store.dispatch('fetchAgentConversationMetric');
const { startRefetching } = useLiveRefresh(fetchData);
onMounted(() => {
store.dispatch('agents/get');
fetchData();
startRefetching();
});
</script>
<template>
<div class="flex flex-row flex-wrap max-w-full">
<MetricCard :header="$t('OVERVIEW_REPORTS.AGENT_CONVERSATIONS.HEADER')">
<AgentTable
:agents="agents"
:agent-metrics="agentConversationMetric"
:is-loading="uiFlags.isFetchingAgentConversationMetric"
/>
</MetricCard>
</div>
</template>
@@ -0,0 +1,142 @@
<script setup>
import { computed, onMounted, ref } from 'vue';
import { OVERVIEW_METRICS } from '../constants';
import { useToggle } from '@vueuse/core';
import MetricCard from './overview/MetricCard.vue';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useLiveRefresh } from 'dashboard/composables/useLiveRefresh';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
const uiFlags = useMapGetter('getOverviewUIFlags');
const agentStatus = useMapGetter('agents/getAgentStatus');
const accountConversationMetric = useMapGetter('getAccountConversationMetric');
const store = useStore();
const accounti18nKey = 'OVERVIEW_REPORTS.ACCOUNT_CONVERSATIONS';
const teams = useMapGetter('teams/getTeams');
const teamMenuList = computed(() => {
return [
{ label: t('OVERVIEW_REPORTS.TEAM_CONVERSATIONS.ALL_TEAMS'), value: null },
...teams.value.map(team => ({ label: team.name, value: team.id })),
];
});
const agentStatusMetrics = computed(() => {
let metric = {};
Object.keys(agentStatus.value).forEach(key => {
const metricName = t(
`OVERVIEW_REPORTS.AGENT_STATUS.${OVERVIEW_METRICS[key]}`
);
metric[metricName] = agentStatus.value[key];
});
return metric;
});
const conversationMetrics = computed(() => {
let metric = {};
Object.keys(accountConversationMetric.value).forEach(key => {
const metricName = t(`${accounti18nKey}.${OVERVIEW_METRICS[key]}`);
metric[metricName] = accountConversationMetric.value[key];
});
return metric;
});
const selectedTeam = ref(null);
const selectedTeamLabel = computed(() => {
const team =
teamMenuList.value.find(
menuItem => menuItem.value === selectedTeam.value
) || {};
return team.label;
});
const fetchData = () => {
const params = {};
if (selectedTeam.value) {
params.team_id = selectedTeam.value;
}
store.dispatch('fetchAccountConversationMetric', params);
};
const { startRefetching } = useLiveRefresh(fetchData);
const [showDropdown, toggleDropdown] = useToggle();
const handleAction = ({ value }) => {
toggleDropdown(false);
selectedTeam.value = value;
fetchData();
};
onMounted(() => {
fetchData();
startRefetching();
});
</script>
<template>
<div class="flex flex-col items-center md:flex-row gap-4">
<div
class="flex-1 w-full max-w-full md:w-[65%] md:max-w-[65%] conversation-metric"
>
<MetricCard
:header="t(`${accounti18nKey}.HEADER`)"
:is-loading="uiFlags.isFetchingAccountConversationMetric"
:loading-message="t(`${accounti18nKey}.LOADING_MESSAGE`)"
>
<template v-if="teams.length" #control>
<div
v-on-clickaway="() => toggleDropdown(false)"
class="relative flex items-center group z-50"
>
<Button
sm
slate
faded
:label="selectedTeamLabel"
class="capitalize rounded-md group-hover:bg-n-alpha-2"
@click="toggleDropdown()"
/>
<DropdownMenu
v-if="showDropdown"
:menu-items="teamMenuList"
class="mt-1 ltr:right-0 rtl:left-0 xl:ltr:right-0 xl:rtl:left-0 top-full"
label-class="capitalize"
@action="handleAction($event)"
/>
</div>
</template>
<div
v-for="(metric, name, index) in conversationMetrics"
:key="index"
class="flex-1 min-w-0 pb-2"
>
<h3 class="text-base text-n-slate-11">
{{ name }}
</h3>
<p class="text-n-slate-12 text-3xl mb-0 mt-1">
{{ metric }}
</p>
</div>
</MetricCard>
</div>
<div class="flex-1 w-full max-w-full md:w-[35%] md:max-w-[35%]">
<MetricCard :header="$t('OVERVIEW_REPORTS.AGENT_STATUS.HEADER')">
<div
v-for="(metric, name, index) in agentStatusMetrics"
:key="index"
class="flex-1 min-w-0 pb-2"
>
<h3 class="text-base text-n-slate-11">
{{ name }}
</h3>
<p class="text-n-slate-12 text-3xl mb-0 mt-1">
{{ metric }}
</p>
</div>
</MetricCard>
</div>
</div>
</template>
@@ -0,0 +1,36 @@
<script setup>
import { onMounted } from 'vue';
import MetricCard from './overview/MetricCard.vue';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useLiveRefresh } from 'dashboard/composables/useLiveRefresh';
import TeamTable from './overview/TeamTable.vue';
const store = useStore();
const uiFlags = useMapGetter('getOverviewUIFlags');
const teamConversationMetric = useMapGetter('getTeamConversationMetric');
const teams = useMapGetter('teams/getTeams');
const fetchData = () => store.dispatch('fetchTeamConversationMetric');
const { startRefetching } = useLiveRefresh(fetchData);
onMounted(() => {
store.dispatch('teams/get');
fetchData();
startRefetching();
});
</script>
<template>
<div class="flex flex-row flex-wrap max-w-full">
<MetricCard :header="$t('OVERVIEW_REPORTS.TEAM_CONVERSATIONS.HEADER')">
<TeamTable
:teams="teams"
:team-metrics="teamConversationMetric"
:is-loading="uiFlags.isFetchingTeamConversationMetric"
/>
</MetricCard>
</div>
</template>
@@ -4,6 +4,7 @@ import {
useVueTable,
createColumnHelper,
getCoreRowModel,
getPaginationRowModel,
} from '@tanstack/vue-table';
import { useI18n } from 'vue-i18n';
@@ -13,7 +14,7 @@ import Table from 'dashboard/components/table/Table.vue';
import Pagination from 'dashboard/components/table/Pagination.vue';
import AgentCell from './AgentCell.vue';
const { agents, agentMetrics, pageIndex } = defineProps({
const { agents, agentMetrics } = defineProps({
agents: {
type: Array,
default: () => [],
@@ -26,42 +27,45 @@ const { agents, agentMetrics, pageIndex } = defineProps({
type: Boolean,
default: false,
},
pageIndex: {
type: Number,
default: 1,
},
});
const emit = defineEmits(['pageChange']);
const { t } = useI18n();
function getAgentInformation(id) {
return agents?.find(agent => agent.id === Number(id));
}
const getAgentMetrics = id =>
agentMetrics.find(metrics => metrics.assignee_id === Number(id)) || {};
const totalCount = computed(() => agents.length);
const tableData = computed(() => {
return agentMetrics
.filter(agentMetric => getAgentInformation(agentMetric.id))
const tableData = computed(() =>
agents
.map(agent => {
const agentInformation = getAgentInformation(agent.id);
const metric = getAgentMetrics(agent.id);
return {
agent: agentInformation.name || agentInformation.available_name,
email: agentInformation.email,
thumbnail: agentInformation.thumbnail,
open: agent.metric.open ?? 0,
unattended: agent.metric.unattended ?? 0,
status: agentInformation.availability_status,
agent: agent.available_name || agent.name,
email: agent.email,
thumbnail: agent.thumbnail,
open: metric.open || 0,
unattended: metric.unattended || 0,
status: agent.availability_status,
};
});
});
})
.sort((a, b) => {
// First sort by open tickets (descending)
const openDiff = b.open - a.open;
// If open tickets are equal, sort by name (ascending)
if (openDiff === 0) {
return a.agent.localeCompare(b.agent);
}
return openDiff;
})
);
const defaulSpanRender = cellProps =>
h(
'span',
{
class: cellProps.getValue() ? '' : 'text-slate-300 dark:text-slate-700',
class: cellProps.getValue()
? 'capitalize text-n-slate-12'
: 'capitalize text-n-slate-11',
},
cellProps.getValue() ? cellProps.getValue() : '---'
);
@@ -86,100 +90,33 @@ const columns = [
}),
];
const paginationParams = computed(() => {
return {
pageIndex: pageIndex,
pageSize: 25,
};
});
const table = useVueTable({
get data() {
return tableData.value;
},
columns,
manualPagination: true,
enableSorting: false,
getCoreRowModel: getCoreRowModel(),
get rowCount() {
return totalCount.value;
},
state: {
get pagination() {
return paginationParams.value;
},
},
onPaginationChange: updater => {
const newPagintaion = updater(paginationParams.value);
emit('pageChange', newPagintaion.pageIndex);
},
getPaginationRowModel: getPaginationRowModel(),
});
</script>
<template>
<div class="agent-table-container">
<div class="flex flex-col flex-1">
<Table :table="table" class="max-h-[calc(100vh-21.875rem)]" />
<Pagination class="mt-2" :table="table" />
<div v-if="isLoading" class="agents-loader">
<div
v-if="isLoading"
class="items-center flex text-base justify-center p-8"
>
<Spinner />
<span>{{
$t('OVERVIEW_REPORTS.AGENT_CONVERSATIONS.LOADING_MESSAGE')
}}</span>
<span>
{{ $t('OVERVIEW_REPORTS.AGENT_CONVERSATIONS.LOADING_MESSAGE') }}
</span>
</div>
<EmptyState
v-else-if="!isLoading && !agentMetrics.length"
v-else-if="!isLoading && !agents.length"
:title="$t('OVERVIEW_REPORTS.AGENT_CONVERSATIONS.NO_AGENTS')"
/>
</div>
</template>
<style lang="scss" scoped>
.agent-table-container {
@apply flex flex-col flex-1;
.ve-table {
&::v-deep {
th.ve-table-header-th {
@apply text-sm rounded-xl;
padding: var(--space-small) var(--space-two) !important;
}
td.ve-table-body-td {
padding: var(--space-one) var(--space-two) !important;
}
}
}
&::v-deep .ve-pagination {
@apply bg-transparent dark:bg-transparent;
}
&::v-deep .ve-pagination-select {
@apply hidden;
}
.row-user-block {
@apply items-center flex text-left;
.user-block {
@apply items-start flex flex-col min-w-0 my-0 mx-2;
.title {
@apply text-sm m-0 leading-[1.2] text-slate-800 dark:text-slate-100;
}
.sub-title {
@apply text-xs text-slate-600 dark:text-slate-200;
}
}
}
.table-pagination {
@apply mt-4 text-right;
}
}
.agents-loader {
@apply items-center flex text-base justify-center p-8;
}
</style>
@@ -19,7 +19,7 @@ defineProps({
<template>
<div
class="flex flex-col m-0.5 px-6 py-5 overflow-hidden rounded-xl flex-grow text-n-slate-12 shadow outline-1 outline outline-n-container bg-n-solid-2 min-h-[10rem]"
class="flex flex-col m-0.5 px-6 py-5 rounded-xl flex-grow text-n-slate-12 shadow outline-1 outline outline-n-container bg-n-solid-2 min-h-[10rem]"
>
<div
class="card-header grid w-full mb-6 grid-cols-[repeat(auto-fit,minmax(max-content,50%))] gap-y-2"
@@ -0,0 +1,116 @@
<script setup>
import { computed, h } from 'vue';
import {
useVueTable,
createColumnHelper,
getCoreRowModel,
getPaginationRowModel,
} from '@tanstack/vue-table';
import { useI18n } from 'vue-i18n';
import Spinner from 'shared/components/Spinner.vue';
import EmptyState from 'dashboard/components/widgets/EmptyState.vue';
import Table from 'dashboard/components/table/Table.vue';
import Pagination from 'dashboard/components/table/Pagination.vue';
const { teams, teamMetrics } = defineProps({
teams: {
type: Array,
default: () => [],
},
teamMetrics: {
type: Array,
default: () => [],
},
isLoading: {
type: Boolean,
default: false,
},
});
const { t } = useI18n();
const getTeamMetrics = id =>
teamMetrics.find(metrics => metrics.team_id === Number(id)) || {};
const tableData = computed(() =>
teams
.map(team => {
const metric = getTeamMetrics(team.id);
return {
agent: team.name,
open: metric.open || 0,
unattended: metric.unattended || 0,
};
})
.sort((a, b) => {
// First sort by open tickets (descending)
const openDiff = b.open - a.open;
// If open tickets are equal, sort by name (ascending)
if (openDiff === 0) {
return a.agent.localeCompare(b.agent);
}
return openDiff;
})
);
const defaulSpanRender = cellProps =>
h(
'span',
{
class: cellProps.getValue()
? 'capitalize text-n-slate-12'
: 'capitalize text-n-slate-11',
},
cellProps.getValue() ? cellProps.getValue() : '---'
);
const columnHelper = createColumnHelper();
const columns = [
columnHelper.accessor('agent', {
header: t('OVERVIEW_REPORTS.TEAM_CONVERSATIONS.TABLE_HEADER.TEAM'),
cell: defaulSpanRender,
size: 250,
}),
columnHelper.accessor('open', {
header: t('OVERVIEW_REPORTS.TEAM_CONVERSATIONS.TABLE_HEADER.OPEN'),
cell: defaulSpanRender,
size: 100,
}),
columnHelper.accessor('unattended', {
header: t('OVERVIEW_REPORTS.TEAM_CONVERSATIONS.TABLE_HEADER.UNATTENDED'),
cell: defaulSpanRender,
size: 100,
}),
];
const table = useVueTable({
get data() {
return tableData.value;
},
columns,
enableSorting: false,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
});
</script>
<template>
<div class="flex flex-col flex-1">
<Table :table="table" class="max-h-[calc(100vh-21.875rem)]" />
<Pagination class="mt-2" :table="table" />
<div
v-if="isLoading"
class="items-center flex text-base justify-center p-8"
>
<Spinner />
<span>
{{ $t('OVERVIEW_REPORTS.TEAM_CONVERSATIONS.LOADING_MESSAGE') }}
</span>
</div>
<EmptyState
v-else-if="!isLoading && !teams.length"
:title="$t('OVERVIEW_REPORTS.TEAM_CONVERSATIONS.NO_TEAMS')"
/>
</div>
</template>
@@ -24,7 +24,7 @@ export default {
<template>
<div
class="overflow-auto p-4 max-w-full my-auto flex flex-row flex-nowrap h-full bg-slate-25 dark:bg-slate-800"
class="overflow-auto p-4 max-w-full my-auto flex flex-row flex-nowrap h-full"
>
<woot-wizard class="hidden md:block w-1/4" :items="items" />
<router-view />
@@ -28,7 +28,7 @@ export default {
<template>
<div
class="overflow-auto p-4 max-w-full my-auto flex flex-row flex-nowrap h-full bg-slate-25 dark:bg-slate-800"
class="overflow-auto p-4 max-w-full my-auto flex flex-row flex-nowrap h-full"
>
<woot-wizard class="hidden md:block w-1/4" :items="items" />
<router-view />
@@ -6,6 +6,7 @@ import { MESSAGE_STATUS } from 'shared/constants/messages';
import wootConstants from 'dashboard/constants/globals';
import { BUS_EVENTS } from '../../../../shared/constants/busEvents';
import { emitter } from 'shared/helpers/mitt';
import * as Sentry from '@sentry/vue';
const state = {
allConversations: [],
@@ -209,16 +210,30 @@ export const mutations = {
[types.UPDATE_CONVERSATION](_state, conversation) {
const { allConversations } = _state;
const currentConversationIndex = allConversations.findIndex(
c => c.id === conversation.id
);
if (currentConversationIndex > -1) {
const { messages, ...conversationAttributes } = conversation;
const currentConversation = {
...allConversations[currentConversationIndex],
...conversationAttributes,
};
allConversations[currentConversationIndex] = currentConversation;
const index = allConversations.findIndex(c => c.id === conversation.id);
if (index > -1) {
const selectedConversation = allConversations[index];
// ignore out of order events
if (conversation.updated_at < selectedConversation.updated_at) {
Sentry.withScope(scope => {
scope.setContext('incoming', conversation);
scope.setContext('stored', selectedConversation);
scope.setContext('incoming_meta', conversation.meta);
scope.setContext('stored_meta', selectedConversation.meta);
Sentry.captureMessage('Conversation update mismatch');
});
return;
}
if (conversation.updated_at === selectedConversation.updated_at) {
return;
}
const { messages, ...updates } = conversation;
allConversations[index] = { ...selectedConversation, ...updates };
if (_state.selectedChatId === conversation.id) {
emitter.emit(BUS_EVENTS.FETCH_LABEL_SUGGESTIONS);
emitter.emit(BUS_EVENTS.SCROLL_TO_MESSAGE);
@@ -5,6 +5,7 @@ import { downloadCsvFile, generateFileName } from '../../helper/downloadHelper';
import AnalyticsHelper from '../../helper/AnalyticsHelper';
import { REPORTS_EVENTS } from '../../helper/AnalyticsHelper/events';
import { clampDataBetweenTimeline } from 'shared/helpers/ReportsDataHelper';
import liveReports from '../../api/liveReports';
const state = {
fetchingStatus: false,
@@ -54,10 +55,12 @@ const state = {
isFetchingAccountConversationMetric: false,
isFetchingAccountConversationsHeatmap: false,
isFetchingAgentConversationMetric: false,
isFetchingTeamConversationMetric: false,
},
accountConversationMetric: {},
accountConversationHeatmap: [],
agentConversationMetric: [],
teamConversationMetric: [],
},
};
@@ -80,6 +83,9 @@ const getters = {
getAgentConversationMetric(_state) {
return _state.overview.agentConversationMetric;
},
getTeamConversationMetric(_state) {
return _state.overview.teamConversationMetric;
},
getOverviewUIFlags($state) {
return $state.overview.uiFlags;
},
@@ -145,9 +151,10 @@ export const actions = {
commit(types.default.TOGGLE_ACCOUNT_REPORT_LOADING, false);
});
},
fetchAccountConversationMetric({ commit }, reportObj) {
fetchAccountConversationMetric({ commit }, params = {}) {
commit(types.default.TOGGLE_ACCOUNT_CONVERSATION_METRIC_LOADING, true);
Report.getConversationMetric(reportObj.type)
liveReports
.getConversationMetric(params)
.then(accountConversationMetric => {
commit(
types.default.SET_ACCOUNT_CONVERSATION_METRIC,
@@ -159,9 +166,10 @@ export const actions = {
commit(types.default.TOGGLE_ACCOUNT_CONVERSATION_METRIC_LOADING, false);
});
},
fetchAgentConversationMetric({ commit }, reportObj) {
fetchAgentConversationMetric({ commit }) {
commit(types.default.TOGGLE_AGENT_CONVERSATION_METRIC_LOADING, true);
Report.getConversationMetric(reportObj.type, reportObj.page)
liveReports
.getGroupedConversations({ groupBy: 'assignee_id' })
.then(agentConversationMetric => {
commit(
types.default.SET_AGENT_CONVERSATION_METRIC,
@@ -173,6 +181,18 @@ export const actions = {
commit(types.default.TOGGLE_AGENT_CONVERSATION_METRIC_LOADING, false);
});
},
fetchTeamConversationMetric({ commit }) {
commit(types.default.TOGGLE_TEAM_CONVERSATION_METRIC_LOADING, true);
liveReports
.getGroupedConversations({ groupBy: 'team_id' })
.then(teamMetric => {
commit(types.default.SET_TEAM_CONVERSATION_METRIC, teamMetric.data);
commit(types.default.TOGGLE_TEAM_CONVERSATION_METRIC_LOADING, false);
})
.catch(() => {
commit(types.default.TOGGLE_TEAM_CONVERSATION_METRIC_LOADING, false);
});
},
downloadAgentReports(_, reportObj) {
return Report.getAgentReports(reportObj)
.then(response => {
@@ -278,6 +298,12 @@ const mutations = {
[types.default.TOGGLE_AGENT_CONVERSATION_METRIC_LOADING](_state, flag) {
_state.overview.uiFlags.isFetchingAgentConversationMetric = flag;
},
[types.default.SET_TEAM_CONVERSATION_METRIC](_state, metricData) {
_state.overview.teamConversationMetric = metricData;
},
[types.default.TOGGLE_TEAM_CONVERSATION_METRIC_LOADING](_state, flag) {
_state.overview.uiFlags.isFetchingTeamConversationMetric = flag;
},
};
export default {
@@ -569,4 +569,382 @@ describe('#mutations', () => {
expect(state.copilotAssistant).toEqual(data.assistant);
});
});
describe('#SET_ALL_MESSAGES_LOADED', () => {
it('should set allMessagesLoaded to true on selected chat', () => {
const state = {
allConversations: [{ id: 1, allMessagesLoaded: false }],
selectedChatId: 1,
};
mutations[types.SET_ALL_MESSAGES_LOADED](state);
expect(state.allConversations[0].allMessagesLoaded).toBe(true);
});
});
describe('#CLEAR_ALL_MESSAGES_LOADED', () => {
it('should set allMessagesLoaded to false on selected chat', () => {
const state = {
allConversations: [{ id: 1, allMessagesLoaded: true }],
selectedChatId: 1,
};
mutations[types.CLEAR_ALL_MESSAGES_LOADED](state);
expect(state.allConversations[0].allMessagesLoaded).toBe(false);
});
});
describe('#SET_PREVIOUS_CONVERSATIONS', () => {
it('should prepend messages to conversation messages array', () => {
const state = {
allConversations: [{ id: 1, messages: [{ id: 'msg2' }] }],
};
const payload = { id: 1, data: [{ id: 'msg1' }] };
mutations[types.SET_PREVIOUS_CONVERSATIONS](state, payload);
expect(state.allConversations[0].messages).toEqual([
{ id: 'msg1' },
{ id: 'msg2' },
]);
});
it('should not modify messages if data is empty', () => {
const state = {
allConversations: [{ id: 1, messages: [{ id: 'msg2' }] }],
};
const payload = { id: 1, data: [] };
mutations[types.SET_PREVIOUS_CONVERSATIONS](state, payload);
expect(state.allConversations[0].messages).toEqual([{ id: 'msg2' }]);
});
});
describe('#SET_MISSING_MESSAGES', () => {
it('should replace message array with new data', () => {
const state = {
allConversations: [{ id: 1, messages: [{ id: 'old' }] }],
};
const payload = { id: 1, data: [{ id: 'new' }] };
mutations[types.SET_MISSING_MESSAGES](state, payload);
expect(state.allConversations[0].messages).toEqual([{ id: 'new' }]);
});
it('should do nothing if conversation is not found', () => {
const state = {
allConversations: [],
};
const payload = { id: 1, data: [{ id: 'new' }] };
mutations[types.SET_MISSING_MESSAGES](state, payload);
expect(state.allConversations).toEqual([]);
});
});
describe('#ASSIGN_AGENT', () => {
it('should assign agent to selected conversation', () => {
const assignee = { id: 1, name: 'Agent' };
const state = {
allConversations: [{ id: 1, meta: {} }],
selectedChatId: 1,
};
mutations[types.ASSIGN_AGENT](state, assignee);
expect(state.allConversations[0].meta.assignee).toEqual(assignee);
});
});
describe('#ASSIGN_PRIORITY', () => {
it('should assign priority to conversation', () => {
const priority = { title: 'Urgent', value: 'urgent' };
const state = {
allConversations: [{ id: 1 }],
};
mutations[types.ASSIGN_PRIORITY](state, {
priority,
conversationId: 1,
});
expect(state.allConversations[0].priority).toEqual(priority);
});
});
describe('#MUTE_CONVERSATION', () => {
it('should mute selected conversation', () => {
const state = {
allConversations: [{ id: 1, muted: false }],
selectedChatId: 1,
};
mutations[types.MUTE_CONVERSATION](state);
expect(state.allConversations[0].muted).toBe(true);
});
});
describe('#UNMUTE_CONVERSATION', () => {
it('should unmute selected conversation', () => {
const state = {
allConversations: [{ id: 1, muted: true }],
selectedChatId: 1,
};
mutations[types.UNMUTE_CONVERSATION](state);
expect(state.allConversations[0].muted).toBe(false);
});
});
describe('#UPDATE_CONVERSATION', () => {
it('should update existing conversation', () => {
const state = {
allConversations: [
{
id: 1,
status: 'open',
updated_at: 100,
messages: [{ id: 'msg1' }],
},
],
};
const conversation = {
id: 1,
status: 'resolved',
updated_at: 200,
messages: [{ id: 'msg2' }],
};
mutations[types.UPDATE_CONVERSATION](state, conversation);
expect(state.allConversations[0]).toEqual({
id: 1,
status: 'resolved',
updated_at: 200,
messages: [{ id: 'msg1' }],
});
});
it('should add conversation if not found', () => {
const state = {
allConversations: [],
};
const conversation = {
id: 1,
status: 'open',
};
mutations[types.UPDATE_CONVERSATION](state, conversation);
expect(state.allConversations).toEqual([conversation]);
});
it('should emit events if updating selected conversation', () => {
const state = {
allConversations: [
{
id: 1,
status: 'open',
updated_at: 100,
},
],
selectedChatId: 1,
};
const conversation = {
id: 1,
status: 'resolved',
updated_at: 200,
};
mutations[types.UPDATE_CONVERSATION](state, conversation);
expect(emitter.emit).toHaveBeenCalledWith('FETCH_LABEL_SUGGESTIONS');
expect(emitter.emit).toHaveBeenCalledWith('SCROLL_TO_MESSAGE');
});
it('should ignore updates with older timestamps', () => {
const state = {
allConversations: [
{
id: 1,
status: 'open',
updated_at: 200,
},
],
};
const conversation = {
id: 1,
status: 'resolved',
updated_at: 100,
};
mutations[types.UPDATE_CONVERSATION](state, conversation);
expect(state.allConversations[0].status).toEqual('open');
});
it('should ignore updates with same timestamps', () => {
const state = {
allConversations: [
{
id: 1,
status: 'open',
updated_at: 100,
},
],
};
const conversation = {
id: 1,
status: 'resolved',
updated_at: 100,
};
mutations[types.UPDATE_CONVERSATION](state, conversation);
expect(state.allConversations[0].status).toEqual('open');
});
});
describe('#UPDATE_CONVERSATION_CONTACT', () => {
it('should update conversation contact data', () => {
const state = {
allConversations: [
{ id: 1, meta: { sender: { id: 1, name: 'Old Name' } } },
],
};
const payload = {
conversationId: 1,
id: 1,
name: 'New Name',
};
mutations[types.UPDATE_CONVERSATION_CONTACT](state, payload);
// The mutation extracts all properties except conversationId
const { conversationId, ...contact } = payload;
expect(state.allConversations[0].meta.sender).toEqual(contact);
});
it('should do nothing if conversation is not found', () => {
const state = {
allConversations: [],
};
const payload = {
conversationId: 1,
id: 1,
name: 'New Name',
};
mutations[types.UPDATE_CONVERSATION_CONTACT](state, payload);
expect(state.allConversations).toEqual([]);
});
});
describe('#SET_ACTIVE_INBOX', () => {
it('should set current inbox as integer', () => {
const state = {
currentInbox: null,
};
mutations[types.SET_ACTIVE_INBOX](state, '1');
expect(state.currentInbox).toBe(1);
});
it('should set null if no inbox ID provided', () => {
const state = {
currentInbox: 1,
};
mutations[types.SET_ACTIVE_INBOX](state, null);
expect(state.currentInbox).toBe(null);
});
});
describe('#CLEAR_CONTACT_CONVERSATIONS', () => {
it('should remove all conversations with matching contact ID', () => {
const state = {
allConversations: [
{ id: 1, meta: { sender: { id: 1 } } },
{ id: 2, meta: { sender: { id: 2 } } },
{ id: 3, meta: { sender: { id: 1 } } },
],
};
mutations[types.CLEAR_CONTACT_CONVERSATIONS](state, 1);
expect(state.allConversations).toHaveLength(1);
expect(state.allConversations[0].id).toBe(2);
});
});
describe('#ADD_CONVERSATION', () => {
it('should add a new conversation', () => {
const state = {
allConversations: [],
};
const conversation = { id: 1, messages: [] };
mutations[types.ADD_CONVERSATION](state, conversation);
expect(state.allConversations).toEqual([conversation]);
});
});
describe('#SET_LIST_LOADING_STATUS', () => {
it('should set listLoadingStatus to true', () => {
const state = {
listLoadingStatus: false,
};
mutations[types.SET_LIST_LOADING_STATUS](state);
expect(state.listLoadingStatus).toBe(true);
});
});
describe('#CLEAR_LIST_LOADING_STATUS', () => {
it('should set listLoadingStatus to false', () => {
const state = {
listLoadingStatus: true,
};
mutations[types.CLEAR_LIST_LOADING_STATUS](state);
expect(state.listLoadingStatus).toBe(false);
});
});
describe('#CHANGE_CHAT_STATUS_FILTER', () => {
it('should update chat status filter', () => {
const state = {
chatStatusFilter: 'open',
};
mutations[types.CHANGE_CHAT_STATUS_FILTER](state, 'resolved');
expect(state.chatStatusFilter).toBe('resolved');
});
});
describe('#UPDATE_ASSIGNEE', () => {
it('should update assignee on conversation', () => {
const state = {
allConversations: [{ id: 1, meta: { assignee: null } }],
};
const payload = {
id: 1,
assignee: { id: 1, name: 'Agent' },
};
mutations[types.UPDATE_ASSIGNEE](state, payload);
expect(state.allConversations[0].meta.assignee).toEqual(payload.assignee);
});
});
describe('#SET_LAST_MESSAGE_ID_IN_SYNC_CONVERSATION', () => {
it('should update the sync conversation message ID', () => {
const state = {
syncConversationsMessages: {},
};
mutations[types.SET_LAST_MESSAGE_ID_IN_SYNC_CONVERSATION](state, {
conversationId: 1,
messageId: 100,
});
expect(state.syncConversationsMessages[1]).toBe(100);
});
});
});
@@ -338,4 +338,8 @@ export default {
SET_SLA_REPORTS: 'SET_SLA_REPORTS',
SET_SLA_REPORTS_METRICS: 'SET_SLA_REPORTS_METRICS',
SET_SLA_REPORTS_META: 'SET_SLA_REPORTS_META',
SET_TEAM_CONVERSATION_METRIC: 'SET_TEAM_CONVERSATION_METRIC',
TOGGLE_TEAM_CONVERSATION_METRIC_LOADING:
'TOGGLE_TEAM_CONVERSATION_METRIC_LOADING',
};
+21 -1
View File
@@ -5,13 +5,33 @@ class Webhooks::TelegramEventsJob < ApplicationJob
return unless params[:bot_token]
channel = Channel::Telegram.find_by(bot_token: params[:bot_token])
return unless channel
if channel_is_inactive?(channel)
log_inactive_channel(channel, params)
return
end
process_event_params(channel, params)
end
private
def channel_is_inactive?(channel)
return true if channel.blank?
return true unless channel.account.active?
false
end
def log_inactive_channel(channel, params)
message = if channel&.id
"Account #{channel.account.id} is not active for channel #{channel.id}"
else
"Channel not found for bot_token: #{params[:bot_token]}"
end
Rails.logger.warn("Telegram event discarded: #{message}")
end
def process_event_params(channel, params)
return unless params[:telegram]
+5 -1
View File
@@ -3,7 +3,11 @@ class Webhooks::WhatsappEventsJob < ApplicationJob
def perform(params = {})
channel = find_channel_from_whatsapp_business_payload(params)
return if channel_is_inactive?(channel)
if channel_is_inactive?(channel)
Rails.logger.warn("Inactive WhatsApp channel: #{channel&.phone_number || "unknown - #{params[:phone_number]}"}")
return
end
case channel.provider
when 'whatsapp_cloud'
@@ -25,6 +25,9 @@ class MessageTemplates::HookExecutionService
return false if conversation.tweet?
# should not send for outbound messages
return false unless message.incoming?
# prevents sending out-of-office message if an agent has sent a message in last 5 minutes
# ensures better UX by not interrupting active conversations at the end of business hours
return false if conversation.messages.outgoing.exists?(['created_at > ?', 5.minutes.ago])
inbox.out_of_office? && conversation.messages.today.template.empty? && inbox.out_of_office_message.present?
end

Some files were not shown because too many files have changed in this diff Show More