Merge branch 'develop' into feat/shopify-app-store-install-flow
This commit is contained in:
@@ -42,6 +42,12 @@ class Inboxes extends CacheEnabledApiClient {
|
||||
getCSATTemplateStatus(inboxId) {
|
||||
return axios.get(`${this.url}/${inboxId}/csat_template`);
|
||||
}
|
||||
|
||||
analyzeCSATTemplateUtility(inboxId, template) {
|
||||
return axios.post(`${this.url}/${inboxId}/csat_template/analyze`, {
|
||||
template,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default new Inboxes();
|
||||
|
||||
+3
-17
@@ -158,21 +158,7 @@ const isAnyDropdownActive = computed(() => {
|
||||
|
||||
const handleContactSearch = value => {
|
||||
showContactsDropdown.value = true;
|
||||
const query = typeof value === 'string' ? value.trim() : '';
|
||||
const hasAlphabet = Array.from(query).some(char => {
|
||||
const lower = char.toLowerCase();
|
||||
const upper = char.toUpperCase();
|
||||
return lower !== upper;
|
||||
});
|
||||
const isEmailLike = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(query);
|
||||
|
||||
const keys = ['email', 'phone_number', 'name'].filter(key => {
|
||||
if (key === 'phone_number' && hasAlphabet) return false;
|
||||
if (key === 'name' && isEmailLike) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
emit('searchContacts', { keys, query: value });
|
||||
emit('searchContacts', value);
|
||||
};
|
||||
|
||||
const handleDropdownUpdate = (type, value) => {
|
||||
@@ -187,12 +173,12 @@ const handleDropdownUpdate = (type, value) => {
|
||||
|
||||
const searchCcEmails = value => {
|
||||
showCcEmailsDropdown.value = true;
|
||||
emit('searchContacts', { keys: ['email'], query: value });
|
||||
emit('searchContacts', value);
|
||||
};
|
||||
|
||||
const searchBccEmails = value => {
|
||||
showBccEmailsDropdown.value = true;
|
||||
emit('searchContacts', { keys: ['email'], query: value });
|
||||
emit('searchContacts', value);
|
||||
};
|
||||
|
||||
const setSelectedContact = async ({ value, action, ...rest }) => {
|
||||
|
||||
+10
-8
@@ -44,14 +44,16 @@ const bccEmailsArray = computed(() =>
|
||||
);
|
||||
|
||||
const contactEmailsList = computed(() => {
|
||||
return props.contacts?.map(({ name, id, email }) => ({
|
||||
id,
|
||||
label: email,
|
||||
email,
|
||||
thumbnail: { name: name, src: '' },
|
||||
value: id,
|
||||
action: 'email',
|
||||
}));
|
||||
return props.contacts
|
||||
?.filter(contact => contact.email)
|
||||
.map(({ name, id, email }) => ({
|
||||
id,
|
||||
label: email,
|
||||
email,
|
||||
thumbnail: { name: name, src: '' },
|
||||
value: id,
|
||||
action: 'email',
|
||||
}));
|
||||
});
|
||||
|
||||
// Handle updates from TagInput and convert array back to string
|
||||
|
||||
+5
-23
@@ -176,32 +176,14 @@ export const prepareWhatsAppMessagePayload = ({
|
||||
};
|
||||
};
|
||||
|
||||
export const generateContactQuery = ({ keys = ['email'], query }) => {
|
||||
return {
|
||||
payload: keys.map(key => {
|
||||
const filterPayload = {
|
||||
attribute_key: key,
|
||||
filter_operator: 'contains',
|
||||
values: [query],
|
||||
attribute_model: 'standard',
|
||||
};
|
||||
if (keys.findIndex(k => k === key) !== keys.length - 1) {
|
||||
filterPayload.query_operator = 'or';
|
||||
}
|
||||
return filterPayload;
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
// API Calls
|
||||
export const searchContacts = async ({ keys, query }) => {
|
||||
export const searchContacts = async query => {
|
||||
const trimmed = typeof query === 'string' ? query.trim() : '';
|
||||
if (!trimmed) return [];
|
||||
|
||||
const {
|
||||
data: { payload },
|
||||
} = await ContactAPI.filter(
|
||||
undefined,
|
||||
'name',
|
||||
generateContactQuery({ keys, query })
|
||||
);
|
||||
} = await ContactAPI.search(trimmed);
|
||||
const camelCasedPayload = camelcaseKeys(payload, { deep: true });
|
||||
// Filter contacts that have either phone_number or email
|
||||
const filteredPayload = camelCasedPayload?.filter(
|
||||
|
||||
+8
-96
@@ -336,70 +336,6 @@ describe('composeConversationHelper', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateContactQuery', () => {
|
||||
it('generates correct query structure for contact search', () => {
|
||||
const query = 'test@example.com';
|
||||
const expected = {
|
||||
payload: [
|
||||
{
|
||||
attribute_key: 'email',
|
||||
filter_operator: 'contains',
|
||||
values: [query],
|
||||
attribute_model: 'standard',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(helpers.generateContactQuery({ keys: ['email'], query })).toEqual(
|
||||
expected
|
||||
);
|
||||
});
|
||||
|
||||
it('handles empty query', () => {
|
||||
const expected = {
|
||||
payload: [
|
||||
{
|
||||
attribute_key: 'email',
|
||||
filter_operator: 'contains',
|
||||
values: [''],
|
||||
attribute_model: 'standard',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(
|
||||
helpers.generateContactQuery({ keys: ['email'], query: '' })
|
||||
).toEqual(expected);
|
||||
});
|
||||
|
||||
it('handles mutliple keys', () => {
|
||||
const expected = {
|
||||
payload: [
|
||||
{
|
||||
attribute_key: 'email',
|
||||
filter_operator: 'contains',
|
||||
values: ['john'],
|
||||
attribute_model: 'standard',
|
||||
query_operator: 'or',
|
||||
},
|
||||
{
|
||||
attribute_key: 'phone_number',
|
||||
filter_operator: 'contains',
|
||||
values: ['john'],
|
||||
attribute_model: 'standard',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(
|
||||
helpers.generateContactQuery({
|
||||
keys: ['email', 'phone_number'],
|
||||
query: 'john',
|
||||
})
|
||||
).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('API calls', () => {
|
||||
describe('searchContacts', () => {
|
||||
it('searches contacts and returns camelCase results', async () => {
|
||||
@@ -413,14 +349,11 @@ describe('composeConversationHelper', () => {
|
||||
},
|
||||
];
|
||||
|
||||
ContactAPI.filter.mockResolvedValue({
|
||||
ContactAPI.search.mockResolvedValue({
|
||||
data: { payload: mockPayload },
|
||||
});
|
||||
|
||||
const result = await helpers.searchContacts({
|
||||
keys: ['email'],
|
||||
query: 'john',
|
||||
});
|
||||
const result = await helpers.searchContacts('john');
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
@@ -432,16 +365,7 @@ describe('composeConversationHelper', () => {
|
||||
},
|
||||
]);
|
||||
|
||||
expect(ContactAPI.filter).toHaveBeenCalledWith(undefined, 'name', {
|
||||
payload: [
|
||||
{
|
||||
attribute_key: 'email',
|
||||
filter_operator: 'contains',
|
||||
values: ['john'],
|
||||
attribute_model: 'standard',
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(ContactAPI.search).toHaveBeenCalledWith('john');
|
||||
});
|
||||
|
||||
it('searches contacts and returns only contacts with email or phone number', async () => {
|
||||
@@ -469,14 +393,11 @@ describe('composeConversationHelper', () => {
|
||||
},
|
||||
];
|
||||
|
||||
ContactAPI.filter.mockResolvedValue({
|
||||
ContactAPI.search.mockResolvedValue({
|
||||
data: { payload: mockPayload },
|
||||
});
|
||||
|
||||
const result = await helpers.searchContacts({
|
||||
keys: ['email'],
|
||||
query: 'john',
|
||||
});
|
||||
const result = await helpers.searchContacts('john');
|
||||
|
||||
// Should only return contacts with either email or phone number
|
||||
expect(result).toEqual([
|
||||
@@ -496,20 +417,11 @@ describe('composeConversationHelper', () => {
|
||||
},
|
||||
]);
|
||||
|
||||
expect(ContactAPI.filter).toHaveBeenCalledWith(undefined, 'name', {
|
||||
payload: [
|
||||
{
|
||||
attribute_key: 'email',
|
||||
filter_operator: 'contains',
|
||||
values: ['john'],
|
||||
attribute_model: 'standard',
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(ContactAPI.search).toHaveBeenCalledWith('john');
|
||||
});
|
||||
|
||||
it('handles empty search results', async () => {
|
||||
ContactAPI.filter.mockResolvedValue({
|
||||
ContactAPI.search.mockResolvedValue({
|
||||
data: { payload: [] },
|
||||
});
|
||||
|
||||
@@ -536,7 +448,7 @@ describe('composeConversationHelper', () => {
|
||||
},
|
||||
];
|
||||
|
||||
ContactAPI.filter.mockResolvedValue({
|
||||
ContactAPI.search.mockResolvedValue({
|
||||
data: { payload: mockPayload },
|
||||
});
|
||||
|
||||
|
||||
@@ -129,6 +129,7 @@ const props = defineProps({
|
||||
inReplyTo: { type: Object, default: null }, // eslint-disable-line vue/no-unused-properties
|
||||
isEmailInbox: { type: Boolean, default: false },
|
||||
private: { type: Boolean, default: false },
|
||||
additionalAttributes: { type: Object, default: () => ({}) }, // eslint-disable-line vue/no-unused-properties
|
||||
sender: { type: Object, default: null },
|
||||
senderId: { type: Number, default: null },
|
||||
senderType: { type: String, default: null },
|
||||
@@ -172,7 +173,10 @@ const variant = computed(() => {
|
||||
return MESSAGE_VARIANTS.AGENT;
|
||||
}
|
||||
|
||||
const isBot = !props.sender || props.sender.type === SENDER_TYPES.AGENT_BOT;
|
||||
const isBot =
|
||||
props.sender?.type === SENDER_TYPES.AGENT_BOT ||
|
||||
props.senderType === SENDER_TYPES.AGENT_BOT ||
|
||||
(!props.sender && !props.additionalAttributes?.senderName);
|
||||
if (isBot && props.messageType === MESSAGE_TYPES.OUTGOING) {
|
||||
return MESSAGE_VARIANTS.BOT;
|
||||
}
|
||||
@@ -450,12 +454,13 @@ const avatarInfo = computed(() => {
|
||||
};
|
||||
}
|
||||
|
||||
// If no sender, return bot info
|
||||
// If no sender, check for Slack (or other integration) sender info
|
||||
if (!props.sender) {
|
||||
return {
|
||||
name: t('CONVERSATION.BOT'),
|
||||
src: '',
|
||||
};
|
||||
const { senderName, senderAvatarUrl } = props.additionalAttributes || {};
|
||||
if (senderName) {
|
||||
return { name: senderName, src: senderAvatarUrl ?? '' };
|
||||
}
|
||||
return { name: t('CONVERSATION.BOT'), src: '' };
|
||||
}
|
||||
|
||||
const { sender } = props;
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import RadioCard from '../RadioCard.vue';
|
||||
import RadioCard from './RadioCard.vue';
|
||||
|
||||
const selectedOption = ref('round_robin');
|
||||
|
||||
@@ -17,10 +17,7 @@ import {
|
||||
useFunctionGetter,
|
||||
} from 'dashboard/composables/store.js';
|
||||
|
||||
// [VITE] [TODO] We are using vue-virtual-scroll for now, since that seemed the simplest way to migrate
|
||||
// from the current one. But we should consider using tanstack virtual in the future
|
||||
// https://tanstack.com/virtual/latest/docs/framework/vue/examples/variable
|
||||
import { DynamicScroller, DynamicScrollerItem } from 'vue-virtual-scroller';
|
||||
import { Virtualizer } from 'virtua/vue';
|
||||
import ChatListHeader from './ChatListHeader.vue';
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
import ConversationFilter from 'next/filter/ConversationFilter.vue';
|
||||
@@ -29,9 +26,9 @@ import ChatTypeTabs from './widgets/ChatTypeTabs.vue';
|
||||
import ConversationItem from './ConversationItem.vue';
|
||||
import DeleteCustomViews from 'dashboard/routes/dashboard/customviews/DeleteCustomViews.vue';
|
||||
import ConversationBulkActions from './widgets/conversation/conversationBulkActions/Index.vue';
|
||||
import IntersectionObserver from './IntersectionObserver.vue';
|
||||
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import IntersectionObserver from 'dashboard/components/IntersectionObserver.vue';
|
||||
import ConversationResolveAttributesModal from 'dashboard/components-next/ConversationWorkflow/ConversationResolveAttributesModal.vue';
|
||||
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
@@ -46,7 +43,6 @@ import {
|
||||
useSnakeCase,
|
||||
} from 'dashboard/composables/useTransformKeys';
|
||||
import { useEmitter } from 'dashboard/composables/emitter';
|
||||
import { useEventListener } from '@vueuse/core';
|
||||
import { useConversationRequiredAttributes } from 'dashboard/composables/useConversationRequiredAttributes';
|
||||
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
@@ -70,8 +66,6 @@ import { matchesFilters } from '../store/modules/conversations/helpers/filterHel
|
||||
import { CONVERSATION_EVENTS } from '../helper/AnalyticsHelper/events';
|
||||
import { ASSIGNEE_TYPE_TAB_PERMISSIONS } from 'dashboard/constants/permissions.js';
|
||||
|
||||
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css';
|
||||
|
||||
const props = defineProps({
|
||||
conversationInbox: { type: [String, Number], default: 0 },
|
||||
teamId: { type: [String, Number], default: 0 },
|
||||
@@ -91,9 +85,9 @@ const store = useStore();
|
||||
|
||||
const resolveAttributesModalRef = ref(null);
|
||||
const conversationListRef = ref(null);
|
||||
const conversationDynamicScroller = ref(null);
|
||||
const virtualListRef = ref(null);
|
||||
|
||||
provide('contextMenuElementTarget', conversationDynamicScroller);
|
||||
provide('contextMenuElementTarget', virtualListRef);
|
||||
|
||||
const activeAssigneeTab = ref(wootConstants.ASSIGNEE_TYPE.ME);
|
||||
const activeStatus = ref(wootConstants.STATUS_TYPE.OPEN);
|
||||
@@ -161,12 +155,6 @@ const {
|
||||
const { checkMissingAttributes } = useConversationRequiredAttributes();
|
||||
|
||||
// computed
|
||||
const intersectionObserverOptions = computed(() => {
|
||||
return {
|
||||
root: conversationListRef.value,
|
||||
rootMargin: '100px 0px 100px 0px',
|
||||
};
|
||||
});
|
||||
|
||||
const hasAppliedFilters = computed(() => {
|
||||
return appliedFilters.value.length !== 0;
|
||||
@@ -384,18 +372,6 @@ function setFiltersFromUISettings() {
|
||||
|
||||
function emitConversationLoaded() {
|
||||
emit('conversationLoad');
|
||||
// [VITE] removing this since the library has changed
|
||||
// nextTick(() => {
|
||||
// // Addressing a known issue in the virtual list library where dynamically added items
|
||||
// // might not render correctly. This workaround involves a slight manual adjustment
|
||||
// // to the scroll position, triggering the list to refresh its rendering.
|
||||
// const virtualList = conversationListRef.value;
|
||||
// const scrollToOffset = virtualList?.scrollToOffset;
|
||||
// const currentOffset = virtualList?.getOffset() || 0;
|
||||
// if (scrollToOffset) {
|
||||
// scrollToOffset(currentOffset + 1);
|
||||
// }
|
||||
// });
|
||||
}
|
||||
|
||||
function fetchFilteredConversations(payload) {
|
||||
@@ -607,16 +583,13 @@ function loadMoreConversations() {
|
||||
}
|
||||
}
|
||||
|
||||
// Add a method to handle scroll events
|
||||
function handleScroll() {
|
||||
const scroller = conversationDynamicScroller.value;
|
||||
if (scroller && scroller.hasScrollbar) {
|
||||
const { scrollTop, scrollHeight, clientHeight } = scroller.$el;
|
||||
if (scrollHeight - (scrollTop + clientHeight) < 100) {
|
||||
loadMoreConversations();
|
||||
}
|
||||
}
|
||||
}
|
||||
// Use IntersectionObserver instead of @scroll since Virtualizer only emits on user scroll.
|
||||
// If the list doesn’t fill the viewport, loading can stall.
|
||||
// IntersectionObserver triggers as soon as the sentinel is visible.
|
||||
const intersectionObserverOptions = computed(() => ({
|
||||
root: conversationListRef.value,
|
||||
rootMargin: '100px 0px 100px 0px',
|
||||
}));
|
||||
|
||||
function updateAssigneeTab(selectedTab) {
|
||||
if (activeAssigneeTab.value !== selectedTab) {
|
||||
@@ -822,8 +795,6 @@ useEmitter('fetch_conversation_stats', () => {
|
||||
store.dispatch('conversationStats/get', conversationFilters.value);
|
||||
});
|
||||
|
||||
useEventListener(conversationDynamicScroller, 'scroll', handleScroll);
|
||||
|
||||
onMounted(() => {
|
||||
store.dispatch('setChatListFilters', conversationFilters.value);
|
||||
setFiltersFromUISettings();
|
||||
@@ -977,61 +948,40 @@ watch(conversationFilters, (newVal, oldVal) => {
|
||||
/>
|
||||
<div
|
||||
ref="conversationListRef"
|
||||
class="overflow-hidden flex-1 conversations-list hover:overflow-y-auto"
|
||||
:class="{ 'overflow-hidden': isContextMenuOpen }"
|
||||
class="flex-1 min-h-0 overflow-y-auto conversations-list"
|
||||
:class="{ '!overflow-hidden': isContextMenuOpen }"
|
||||
>
|
||||
<DynamicScroller
|
||||
ref="conversationDynamicScroller"
|
||||
:items="conversationList"
|
||||
:min-item-size="24"
|
||||
class="overflow-auto w-full h-full"
|
||||
<Virtualizer
|
||||
ref="virtualListRef"
|
||||
v-slot="{ item, index }"
|
||||
:data="conversationList"
|
||||
>
|
||||
<template #default="{ item, index, active }">
|
||||
<!--
|
||||
If we encounter resizing issues, we can set the `watchData` prop to true
|
||||
this will deeply watch the entire object instead of just size dependencies
|
||||
But it can impact performance
|
||||
-->
|
||||
<DynamicScrollerItem
|
||||
:item="item"
|
||||
:active="active"
|
||||
:data-index="index"
|
||||
:size-dependencies="[
|
||||
item.messages,
|
||||
item.labels,
|
||||
item.uuid,
|
||||
item.inbox_id,
|
||||
]"
|
||||
>
|
||||
<ConversationItem
|
||||
:source="item"
|
||||
:label="label"
|
||||
:team-id="teamId"
|
||||
:folders-id="foldersId"
|
||||
:conversation-type="conversationType"
|
||||
:show-assignee="showAssigneeInConversationCard"
|
||||
@select-conversation="selectConversation"
|
||||
@de-select-conversation="deSelectConversation"
|
||||
/>
|
||||
</DynamicScrollerItem>
|
||||
</template>
|
||||
<template #after>
|
||||
<div v-if="chatListLoading" class="flex justify-center my-4">
|
||||
<Spinner class="text-n-brand" />
|
||||
</div>
|
||||
<p
|
||||
v-else-if="showEndOfListMessage"
|
||||
class="p-4 text-center text-n-slate-11"
|
||||
>
|
||||
{{ $t('CHAT_LIST.EOF') }}
|
||||
</p>
|
||||
<IntersectionObserver
|
||||
v-else
|
||||
:options="intersectionObserverOptions"
|
||||
@observed="loadMoreConversations"
|
||||
/>
|
||||
</template>
|
||||
</DynamicScroller>
|
||||
<ConversationItem
|
||||
:source="item"
|
||||
:label="label"
|
||||
:team-id="teamId"
|
||||
:folders-id="foldersId"
|
||||
:conversation-type="conversationType"
|
||||
:show-assignee="showAssigneeInConversationCard"
|
||||
:data-index="index"
|
||||
@select-conversation="selectConversation"
|
||||
@de-select-conversation="deSelectConversation"
|
||||
/>
|
||||
</Virtualizer>
|
||||
<div v-if="chatListLoading" class="flex justify-center my-4">
|
||||
<Spinner class="text-n-brand" />
|
||||
</div>
|
||||
<p
|
||||
v-else-if="showEndOfListMessage"
|
||||
class="p-4 text-center text-n-slate-11"
|
||||
>
|
||||
{{ $t('CHAT_LIST.EOF') }}
|
||||
</p>
|
||||
<IntersectionObserver
|
||||
v-else
|
||||
:options="intersectionObserverOptions"
|
||||
@observed="loadMoreConversations"
|
||||
/>
|
||||
</div>
|
||||
<Dialog
|
||||
ref="deleteConversationDialogRef"
|
||||
|
||||
@@ -50,7 +50,6 @@ export default {
|
||||
|
||||
<template>
|
||||
<ConversationCard
|
||||
:key="source.id"
|
||||
:active-label="label"
|
||||
:team-id="teamId"
|
||||
:folders-id="foldersId"
|
||||
|
||||
@@ -24,6 +24,10 @@ export default {
|
||||
type: [String, Date, Number],
|
||||
default: '',
|
||||
},
|
||||
conversationId: {
|
||||
type: [String, Number],
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -74,6 +78,15 @@ export default {
|
||||
createdAtTimestamp() {
|
||||
this.createdAtTimeAgo = dynamicTime(this.createdAtTimestamp);
|
||||
},
|
||||
conversationId() {
|
||||
// Reset display values and timer when the row is recycled to a different conversation.
|
||||
this.lastActivityAtTimeAgo = dynamicTime(this.lastActivityTimestamp);
|
||||
this.createdAtTimeAgo = dynamicTime(this.createdAtTimestamp);
|
||||
if (this.isAutoRefreshEnabled) {
|
||||
clearTimeout(this.timer);
|
||||
this.createTimer();
|
||||
}
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
if (this.isAutoRefreshEnabled) {
|
||||
@@ -111,7 +124,6 @@ export default {
|
||||
v-tooltip.top="{
|
||||
content: tooltipText,
|
||||
delay: { show: 1000, hide: 0 },
|
||||
hideOnClick: true,
|
||||
}"
|
||||
class="ml-auto leading-4 text-xxs text-n-slate-10 hover:text-n-slate-11"
|
||||
>
|
||||
|
||||
@@ -189,7 +189,7 @@ export default {
|
||||
},
|
||||
showAudioRecorderButton() {
|
||||
if (this.isEditorDisabled) return false;
|
||||
if (this.isALineChannel) {
|
||||
if (this.isALineChannel || this.isATiktokChannel) {
|
||||
return false;
|
||||
}
|
||||
// Disable audio recorder for safari browser as recording is not supported
|
||||
@@ -380,7 +380,11 @@ export default {
|
||||
@click="$emit('selectContentTemplate')"
|
||||
/>
|
||||
<VideoCallButton
|
||||
v-if="(isAWebWidgetInbox || isAPIInbox) && !isOnPrivateNote"
|
||||
v-if="
|
||||
(isAWebWidgetInbox || isAPIInbox) &&
|
||||
!isOnPrivateNote &&
|
||||
!isEditorDisabled
|
||||
"
|
||||
:conversation-id="conversationId"
|
||||
/>
|
||||
<transition name="modal-fade">
|
||||
|
||||
@@ -86,6 +86,10 @@ const chatSortOptions = computed(() => [
|
||||
label: t('CHAT_LIST.SORT_ORDER_ITEMS.priority_asc.TEXT'),
|
||||
value: 'priority_asc',
|
||||
},
|
||||
{
|
||||
label: t('CHAT_LIST.SORT_ORDER_ITEMS.priority_desc_created_at_asc.TEXT'),
|
||||
value: 'priority_desc_created_at_asc',
|
||||
},
|
||||
{
|
||||
label: t('CHAT_LIST.SORT_ORDER_ITEMS.waiting_since_asc.TEXT'),
|
||||
value: 'waiting_since_asc',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import { getLastMessage } from 'dashboard/helper/conversationHelper';
|
||||
@@ -50,10 +50,21 @@ const store = useStore();
|
||||
|
||||
const hovered = ref(false);
|
||||
const showContextMenu = ref(false);
|
||||
const contextMenu = ref({
|
||||
x: null,
|
||||
y: null,
|
||||
});
|
||||
const contextMenu = ref({ x: null, y: null });
|
||||
|
||||
// Reset UI state when conversation changes at same index (no :key, instance reused on reorder)
|
||||
// This prevents context menu/hover state from leaking to a different conversation
|
||||
// Emit contextMenuToggle(false) to sync parent state if menu was open during recycling
|
||||
const resetState = () => {
|
||||
if (showContextMenu.value) {
|
||||
emit('contextMenuToggle', false);
|
||||
}
|
||||
hovered.value = false;
|
||||
showContextMenu.value = false;
|
||||
contextMenu.value = { x: null, y: null };
|
||||
};
|
||||
|
||||
watch(() => props.chat.id, resetState);
|
||||
|
||||
const currentChat = useMapGetter('getSelectedChat');
|
||||
const inboxesList = useMapGetter('inboxes/getInboxes');
|
||||
@@ -352,6 +363,7 @@ const deleteConversation = () => {
|
||||
<TimeAgo
|
||||
:last-activity-timestamp="chat.timestamp"
|
||||
:created-at-timestamp="chat.created_at"
|
||||
:conversation-id="chat.id"
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
|
||||
@@ -85,7 +85,12 @@ export default {
|
||||
useAlert(this.$t('EMAIL_TRANSCRIPT.SEND_EMAIL_SUCCESS'));
|
||||
this.onCancel();
|
||||
} catch (error) {
|
||||
useAlert(this.$t('EMAIL_TRANSCRIPT.SEND_EMAIL_ERROR'));
|
||||
const status = error?.response?.status;
|
||||
if (status === 402) {
|
||||
useAlert(this.$t('EMAIL_TRANSCRIPT.SEND_EMAIL_PAYMENT_REQUIRED'));
|
||||
} else {
|
||||
useAlert(this.$t('EMAIL_TRANSCRIPT.SEND_EMAIL_ERROR'));
|
||||
}
|
||||
} finally {
|
||||
this.isSubmitting = false;
|
||||
}
|
||||
|
||||
@@ -36,7 +36,6 @@ export default {
|
||||
v-tooltip="{
|
||||
content: tooltipText,
|
||||
delay: { show: 1500, hide: 0 },
|
||||
hideOnClick: true,
|
||||
}"
|
||||
class="shrink-0 rounded-sm inline-flex items-center justify-center w-3.5 h-3.5"
|
||||
:class="{
|
||||
|
||||
@@ -200,9 +200,13 @@ export default {
|
||||
},
|
||||
messagePlaceHolder() {
|
||||
if (this.isEditorDisabled) {
|
||||
return this.isAWhatsAppChannel
|
||||
? this.$t('CONVERSATION.FOOTER.MESSAGING_RESTRICTED_WHATSAPP')
|
||||
: this.$t('CONVERSATION.FOOTER.MESSAGING_RESTRICTED');
|
||||
if (this.isAWhatsAppChannel) {
|
||||
return this.$t('CONVERSATION.FOOTER.MESSAGING_RESTRICTED_WHATSAPP');
|
||||
}
|
||||
if (this.isAPIInbox) {
|
||||
return this.$t('CONVERSATION.FOOTER.MESSAGING_RESTRICTED_API');
|
||||
}
|
||||
return this.$t('CONVERSATION.FOOTER.MESSAGING_RESTRICTED');
|
||||
}
|
||||
return this.isPrivate
|
||||
? this.$t('CONVERSATION.FOOTER.PRIVATE_MSG_INPUT')
|
||||
@@ -279,7 +283,8 @@ export default {
|
||||
this.isASmsInbox ||
|
||||
this.isATelegramChannel ||
|
||||
this.isALineChannel ||
|
||||
this.isAnInstagramChannel
|
||||
this.isAnInstagramChannel ||
|
||||
this.isATiktokChannel
|
||||
);
|
||||
},
|
||||
replyButtonLabel() {
|
||||
@@ -426,7 +431,7 @@ export default {
|
||||
},
|
||||
isEditorDisabled() {
|
||||
return (
|
||||
this.isAWhatsAppChannel &&
|
||||
(this.isAWhatsAppChannel || this.isAPIInbox) &&
|
||||
!this.isOnPrivateNote &&
|
||||
!this.currentChat.can_reply
|
||||
);
|
||||
@@ -751,11 +756,13 @@ export default {
|
||||
this.isATwilioWhatsAppChannel ||
|
||||
this.isAWhatsAppCloudChannel ||
|
||||
this.is360DialogWhatsAppChannel;
|
||||
// When users send messages containing both text and attachments on Instagram, Instagram treats them as separate messages.
|
||||
// Although Chatwoot combines these into a single message, Instagram sends separate echo events for each component.
|
||||
// This can create duplicate messages in Chatwoot. To prevent this issue, we'll handle text and attachments as separate messages.
|
||||
// Instagram and TikTok do not support sending text and attachments in the same message.
|
||||
// For Instagram, combining them causes duplicate messages due to separate echo events per component.
|
||||
// For TikTok, the API rejects messages that mix text and media.
|
||||
// To handle both cases, text and attachments are always sent as separate messages.
|
||||
const isOnInstagram = this.isAnInstagramChannel;
|
||||
if ((isOnWhatsApp || isOnInstagram) && !this.isPrivate) {
|
||||
const isOnTiktok = this.isATiktokChannel;
|
||||
if ((isOnWhatsApp || isOnInstagram || isOnTiktok) && !this.isPrivate) {
|
||||
this.sendMessageAsMultipleMessages(
|
||||
this.message,
|
||||
copilotAcceptedMessage
|
||||
@@ -1069,7 +1076,8 @@ export default {
|
||||
const multipleMessagePayload = [];
|
||||
|
||||
if (this.attachedFiles && this.attachedFiles.length) {
|
||||
let caption = this.isAnInstagramChannel ? '' : message;
|
||||
let caption =
|
||||
this.isAnInstagramChannel || this.isATiktokChannel ? '' : message;
|
||||
this.attachedFiles.forEach(attachment => {
|
||||
const attachedFile = this.globalConfig.directUploadsEnabled
|
||||
? attachment.blobSignedId
|
||||
@@ -1091,11 +1099,13 @@ export default {
|
||||
|
||||
const hasNoAttachments =
|
||||
!this.attachedFiles || !this.attachedFiles.length;
|
||||
// For Instagram, we need a separate text message
|
||||
// For WhatsApp, we only need a text message if there are no attachments
|
||||
// For Instagram and TikTok, text must always be sent as a separate message (no captions on attachments).
|
||||
// For WhatsApp, we only need a text message if there are no attachments.
|
||||
if (
|
||||
(this.isAnInstagramChannel && this.message) ||
|
||||
(!this.isAnInstagramChannel && hasNoAttachments)
|
||||
((this.isAnInstagramChannel || this.isATiktokChannel) &&
|
||||
this.message) ||
|
||||
(!(this.isAnInstagramChannel || this.isATiktokChannel) &&
|
||||
hasNoAttachments)
|
||||
) {
|
||||
let messagePayload = {
|
||||
conversationId: this.currentChat.id,
|
||||
|
||||
@@ -21,6 +21,7 @@ export default {
|
||||
PRIORITY_DESC: 'priority_desc',
|
||||
WAITING_SINCE_ASC: 'waiting_since_asc',
|
||||
WAITING_SINCE_DESC: 'waiting_since_desc',
|
||||
PRIORITY_DESC_CREATED_AT_ASC: 'priority_desc_created_at_asc',
|
||||
},
|
||||
ARTICLE_STATUS_TYPES: {
|
||||
DRAFT: 0,
|
||||
|
||||
@@ -21,10 +21,8 @@ export const FEATURE_FLAGS = {
|
||||
AUDIT_LOGS: 'audit_logs',
|
||||
INBOX_VIEW: 'inbox_view',
|
||||
SLA: 'sla',
|
||||
RESPONSE_BOT: 'response_bot',
|
||||
CHANNEL_EMAIL: 'channel_email',
|
||||
CHANNEL_FACEBOOK: 'channel_facebook',
|
||||
CHANNEL_TWITTER: 'channel_twitter',
|
||||
CHANNEL_WEBSITE: 'channel_website',
|
||||
CUSTOM_REPLY_DOMAIN: 'custom_reply_domain',
|
||||
CUSTOM_REPLY_EMAIL: 'custom_reply_email',
|
||||
@@ -36,7 +34,6 @@ export const FEATURE_FLAGS = {
|
||||
CAPTAIN: 'captain_integration',
|
||||
CUSTOM_ROLES: 'custom_roles',
|
||||
CHATWOOT_V4: 'chatwoot_v4',
|
||||
REPORT_V4: 'report_v4',
|
||||
CHANNEL_INSTAGRAM: 'channel_instagram',
|
||||
CHANNEL_TIKTOK: 'channel_tiktok',
|
||||
CONTACT_CHATWOOT_SUPPORT_TEAM: 'contact_chatwoot_support_team',
|
||||
|
||||
@@ -13,7 +13,6 @@ const FEATURE_HELP_URLS = {
|
||||
integrations: 'https://chwt.app/hc/integrations',
|
||||
labels: 'https://chwt.app/hc/labels',
|
||||
macros: 'https://chwt.app/hc/macros',
|
||||
message_reply_to: 'https://chwt.app/hc/reply-to',
|
||||
reports: 'https://chwt.app/hc/reports',
|
||||
sla: 'https://chwt.app/hc/sla',
|
||||
team_management: 'https://chwt.app/hc/teams',
|
||||
|
||||
@@ -76,6 +76,9 @@
|
||||
},
|
||||
"waiting_since_desc": {
|
||||
"TEXT": "Pending Response: Shortest first"
|
||||
},
|
||||
"priority_desc_created_at_asc": {
|
||||
"TEXT": "Priority: Highest first, Created: Oldest first"
|
||||
}
|
||||
},
|
||||
"ATTACHMENTS": {
|
||||
|
||||
@@ -192,6 +192,7 @@
|
||||
"PRIVATE_MSG_INPUT": "Shift + enter for new line. This will be visible only to Agents",
|
||||
"MESSAGING_RESTRICTED": "You cannot reply to this conversation",
|
||||
"MESSAGING_RESTRICTED_WHATSAPP": "You can only reply using a template message due to 24-hour message window restriction",
|
||||
"MESSAGING_RESTRICTED_API": "You can only reply using a template message due to message window restriction",
|
||||
"MESSAGE_SIGNATURE_NOT_CONFIGURED": "Message signature is not configured, please configure it in profile settings.",
|
||||
"COPILOT_MSG_INPUT": "Give copilot additional prompts, or ask anything else... Press enter to send follow-up",
|
||||
"CLICK_HERE": "Click here to update",
|
||||
@@ -305,6 +306,7 @@
|
||||
"CANCEL": "Cancel",
|
||||
"SEND_EMAIL_SUCCESS": "The chat transcript was sent successfully",
|
||||
"SEND_EMAIL_ERROR": "There was an error, please try again",
|
||||
"SEND_EMAIL_PAYMENT_REQUIRED": "Email transcript is not available on your current plan. Please upgrade to use this feature.",
|
||||
"FORM": {
|
||||
"SEND_TO_CONTACT": "Send the transcript to the customer",
|
||||
"SEND_TO_AGENT": "Send the transcript to the assigned agent",
|
||||
|
||||
@@ -592,8 +592,10 @@
|
||||
"DISABLED": "Disabled"
|
||||
},
|
||||
"LOCK_TO_SINGLE_CONVERSATION": {
|
||||
"ENABLED": "Enabled",
|
||||
"DISABLED": "Disabled"
|
||||
"ENABLED": "Reopen same conversation",
|
||||
"DISABLED": "Create new conversations",
|
||||
"ENABLED_DESCRIPTION": "When a contact messages again, the previous conversation will be reopened.",
|
||||
"DISABLED_DESCRIPTION": "A new conversation will be created each time after the previous one is resolved."
|
||||
},
|
||||
"ENABLE_HMAC": {
|
||||
"LABEL": "Enable"
|
||||
@@ -713,8 +715,8 @@
|
||||
"SENDER_NAME_SECTION_TEXT": "Enable/Disable showing Agent's name in email, if disabled it will show business name",
|
||||
"ENABLE_CONTINUITY_VIA_EMAIL": "Enable conversation continuity via email",
|
||||
"ENABLE_CONTINUITY_VIA_EMAIL_SUB_TEXT": "Conversations will continue over email if the contact email address is available.",
|
||||
"LOCK_TO_SINGLE_CONVERSATION": "Lock to single conversation",
|
||||
"LOCK_TO_SINGLE_CONVERSATION_SUB_TEXT": "Enable or disable multiple conversations for the same contact in this inbox",
|
||||
"LOCK_TO_SINGLE_CONVERSATION": "Conversation Routing",
|
||||
"LOCK_TO_SINGLE_CONVERSATION_SUB_TEXT": "Configure conversation creation for existing contacts",
|
||||
"INBOX_UPDATE_TITLE": "Inbox Settings",
|
||||
"INBOX_UPDATE_SUB_TEXT": "Update your inbox settings",
|
||||
"AUTO_ASSIGNMENT_SUB_TEXT": "Enable or disable the automatic assignment of new conversations to the agents added to this inbox.",
|
||||
@@ -890,6 +892,20 @@
|
||||
"CONFIRM": "Create new template",
|
||||
"CANCEL": "Go back"
|
||||
},
|
||||
"UTILITY_ANALYZER": {
|
||||
"ACTION": "Check utility fit",
|
||||
"HELPER_NOTE": "Check this message before submission to improve Utility fit. The system creates a dedicated CSAT template with buttons for reporting and submits it as Utility; Meta may still reclassify it as Marketing based on content.",
|
||||
"RESULT_LABEL": "Meta category prediction",
|
||||
"GUIDANCE_NOTE": "This is a guidance check, not a guarantee of Meta approval.",
|
||||
"SUGGESTION_LABEL": "Suggested utility-safe rewrite",
|
||||
"APPLY": "Use this rewrite",
|
||||
"ERROR_MESSAGE": "Couldn't analyze the message. Please try again.",
|
||||
"CLASSIFICATION": {
|
||||
"LIKELY_UTILITY": "Likely Utility",
|
||||
"LIKELY_MARKETING": "Likely Marketing",
|
||||
"UNCLEAR": "Needs clarification"
|
||||
}
|
||||
},
|
||||
"SURVEY_RULE": {
|
||||
"LABEL": "Survey rule",
|
||||
"DESCRIPTION_PREFIX": "Send the survey if the conversation",
|
||||
@@ -901,7 +917,7 @@
|
||||
"SELECT_PLACEHOLDER": "select labels"
|
||||
},
|
||||
"NOTE": "Note: CSAT surveys are sent only once per conversation",
|
||||
"WHATSAPP_NOTE": "Note: We will create a template and send it for WhatsApp approval. After being approved, surveys will be sent only once per conversation as per the survey rule.",
|
||||
"WHATSAPP_NOTE": "Note: When you save, the system creates a dedicated CSAT template in WhatsApp (used to capture rating and feedback in reports) and submits it as Utility for approval. Meta may still classify it as Marketing based on content. After approval, surveys are sent only once per conversation as per the survey rule.",
|
||||
"API": {
|
||||
"SUCCESS_MESSAGE": "CSAT settings updated successfully",
|
||||
"ERROR_MESSAGE": "We couldn't update CSAT settings. Please try again later."
|
||||
|
||||
@@ -40,6 +40,14 @@
|
||||
"WEBHOOK": {
|
||||
"SUBSCRIBED_EVENTS": "Subscribed Events",
|
||||
"LEARN_MORE": "Learn more about webhooks",
|
||||
"SECRET": {
|
||||
"LABEL": "Secret",
|
||||
"COPY": "Copy secret to clipboard",
|
||||
"COPY_SUCCESS": "Secret copied to clipboard",
|
||||
"TOGGLE": "Toggle secret visibility",
|
||||
"CREATED_DESC": "Your webhook has been created. Use the secret below to verify webhook signatures. Please copy it now — you can also find it later in the webhook edit form.",
|
||||
"DONE": "Done"
|
||||
},
|
||||
"COUNT": "{n} webhook | {n} webhooks",
|
||||
"SEARCH_PLACEHOLDER": "Search webhooks...",
|
||||
"NO_RESULTS": "No webhooks found matching your search",
|
||||
|
||||
@@ -119,10 +119,7 @@ const debouncedSearch = debounce(async query => {
|
||||
}
|
||||
|
||||
try {
|
||||
const contacts = await searchContacts({
|
||||
keys: ['name', 'email', 'phone_number'],
|
||||
query,
|
||||
});
|
||||
const contacts = await searchContacts(query);
|
||||
|
||||
// Add selected contact to top if not already in results
|
||||
const allContacts = selectedContact.value
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ import { useI18n } from 'vue-i18n';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import BaseInfo from 'dashboard/components-next/AssignmentPolicy/components/BaseInfo.vue';
|
||||
import RadioCard from 'dashboard/components-next/AssignmentPolicy/components/RadioCard.vue';
|
||||
import RadioCard from 'dashboard/components-next/radioCard/RadioCard.vue';
|
||||
import FairDistribution from 'dashboard/components-next/AssignmentPolicy/components/FairDistribution.vue';
|
||||
import DataTable from 'dashboard/components-next/AssignmentPolicy/components/DataTable.vue';
|
||||
import AddDataDropdown from 'dashboard/components-next/AssignmentPolicy/components/AddDataDropdown.vue';
|
||||
|
||||
@@ -45,7 +45,10 @@ const records = computed(() =>
|
||||
const filteredRecords = computed(() => {
|
||||
const query = searchQuery.value.trim();
|
||||
if (!query) return records.value;
|
||||
return picoSearch(records.value, query, ['short_code', 'content']);
|
||||
return picoSearch(records.value, query, [
|
||||
{ name: 'short_code', weight: 4 },
|
||||
'content',
|
||||
]);
|
||||
});
|
||||
const uiFlags = computed(() => getters.getUIFlags.value);
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ import BotConfiguration from './components/BotConfiguration.vue';
|
||||
import AccountHealth from './components/AccountHealth.vue';
|
||||
import { FEATURE_FLAGS } from '../../../../featureFlags';
|
||||
import SenderNameExamplePreview from './components/SenderNameExamplePreview.vue';
|
||||
import LockToSingleConversationPreview from './components/LockToSingleConversationPreview.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import SpinnerLoader from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||
@@ -53,6 +54,7 @@ export default {
|
||||
SettingsAccordion,
|
||||
WeeklyAvailability,
|
||||
SenderNameExamplePreview,
|
||||
LockToSingleConversationPreview,
|
||||
MicrosoftReauthorize,
|
||||
GoogleReauthorize,
|
||||
NextButton,
|
||||
@@ -246,6 +248,9 @@ export default {
|
||||
this.isAWhatsAppChannel ||
|
||||
this.isAFacebookInbox ||
|
||||
this.isAPIInbox ||
|
||||
this.isAnInstagramChannel ||
|
||||
this.isALineChannel ||
|
||||
this.isATiktokChannel ||
|
||||
this.isATelegramChannel
|
||||
);
|
||||
},
|
||||
@@ -536,6 +541,9 @@ export default {
|
||||
hideBusinessNameInput() {
|
||||
this.showBusinessNameInput = false;
|
||||
},
|
||||
toggleLockToSingleConversation(value) {
|
||||
this.locktoSingleConversation = value;
|
||||
},
|
||||
},
|
||||
validations: {
|
||||
webhookUrl: {
|
||||
@@ -731,6 +739,21 @@ export default {
|
||||
/>
|
||||
</SettingsFieldSection>
|
||||
|
||||
<SettingsFieldSection
|
||||
v-if="canLocktoSingleConversation"
|
||||
:label="
|
||||
$t('INBOX_MGMT.SETTINGS_POPUP.LOCK_TO_SINGLE_CONVERSATION')
|
||||
"
|
||||
class="[&>div>div]:justify-end [&>div>div]:flex lg:[&>div:first-child]:h-12 [&>div:first-child]:h-16"
|
||||
>
|
||||
<template #extra>
|
||||
<LockToSingleConversationPreview
|
||||
:lock-to-single-conversation="locktoSingleConversation"
|
||||
@update="toggleLockToSingleConversation"
|
||||
/>
|
||||
</template>
|
||||
</SettingsFieldSection>
|
||||
|
||||
<SettingsFieldSection
|
||||
v-if="isAWebWidgetInbox || isAnEmailChannel"
|
||||
:label="$t('INBOX_MGMT.EDIT.SENDER_NAME_SECTION.TITLE')"
|
||||
@@ -1074,19 +1097,6 @@ export default {
|
||||
)
|
||||
"
|
||||
/>
|
||||
|
||||
<SettingsToggleSection
|
||||
v-if="canLocktoSingleConversation"
|
||||
v-model="locktoSingleConversation"
|
||||
:header="
|
||||
$t('INBOX_MGMT.SETTINGS_POPUP.LOCK_TO_SINGLE_CONVERSATION')
|
||||
"
|
||||
:description="
|
||||
$t(
|
||||
'INBOX_MGMT.SETTINGS_POPUP.LOCK_TO_SINGLE_CONVERSATION_SUB_TEXT'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</SettingsAccordion>
|
||||
|
||||
<div class="w-full flex justify-end items-center py-4 mt-2">
|
||||
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
<script setup>
|
||||
import RadioCard from 'dashboard/components-next/radioCard/RadioCard.vue';
|
||||
|
||||
defineProps({
|
||||
lockToSingleConversation: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
defineEmits(['update']);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col sm:flex-row md:flex-col xl:flex-row items-start gap-4 mt-3 min-w-0"
|
||||
>
|
||||
<RadioCard
|
||||
id="disabled"
|
||||
:label="$t('INBOX_MGMT.EDIT.LOCK_TO_SINGLE_CONVERSATION.DISABLED')"
|
||||
:description="
|
||||
$t('INBOX_MGMT.EDIT.LOCK_TO_SINGLE_CONVERSATION.DISABLED_DESCRIPTION')
|
||||
"
|
||||
:is-active="!lockToSingleConversation"
|
||||
class="flex-1"
|
||||
@select="$emit('update', false)"
|
||||
/>
|
||||
|
||||
<RadioCard
|
||||
id="enabled"
|
||||
:label="$t('INBOX_MGMT.EDIT.LOCK_TO_SINGLE_CONVERSATION.ENABLED')"
|
||||
:description="
|
||||
$t('INBOX_MGMT.EDIT.LOCK_TO_SINGLE_CONVERSATION.ENABLED_DESCRIPTION')
|
||||
"
|
||||
:is-active="lockToSingleConversation"
|
||||
class="flex-1"
|
||||
@select="$emit('update', true)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import Avatar from 'next/avatar/Avatar.vue';
|
||||
import RadioCard from 'dashboard/components-next/AssignmentPolicy/components/RadioCard.vue';
|
||||
import RadioCard from 'dashboard/components-next/radioCard/RadioCard.vue';
|
||||
|
||||
const props = defineProps({
|
||||
senderNameType: {
|
||||
|
||||
+151
-1
@@ -4,6 +4,7 @@ import { useI18n } from 'vue-i18n';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import { useInbox } from 'dashboard/composables/useInbox';
|
||||
import { useCaptain } from 'dashboard/composables/useCaptain';
|
||||
import { CSAT_DISPLAY_TYPES } from 'shared/constants/messages';
|
||||
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
@@ -26,6 +27,7 @@ const props = defineProps({
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
const labels = useMapGetter('labels/getLabels');
|
||||
const { captainEnabled } = useCaptain();
|
||||
|
||||
const { isAWhatsAppChannel, isATwilioWhatsAppChannel } = useInbox(
|
||||
props.inbox?.id
|
||||
@@ -37,6 +39,8 @@ const isAnyWhatsAppChannel = computed(
|
||||
);
|
||||
|
||||
const isUpdating = ref(false);
|
||||
const utilityAnalysisLoading = ref(false);
|
||||
const utilityAnalysisResult = ref(null);
|
||||
const selectedLabelValues = ref([]);
|
||||
const currentLabel = ref('');
|
||||
|
||||
@@ -46,7 +50,7 @@ const state = reactive({
|
||||
message: '',
|
||||
templateButtonText: 'Please rate us',
|
||||
surveyRuleOperator: 'contains',
|
||||
templateLanguage: '',
|
||||
templateLanguage: 'en',
|
||||
});
|
||||
|
||||
const templateStatus = ref(null);
|
||||
@@ -89,6 +93,9 @@ const messagePreviewData = computed(() => ({
|
||||
const shouldShowTemplateStatus = computed(
|
||||
() => templateStatus.value && !templateLoading.value
|
||||
);
|
||||
const showUtilityAnalyzer = computed(
|
||||
() => isAnyWhatsAppChannel.value && captainEnabled.value
|
||||
);
|
||||
|
||||
const templateApprovalStatus = computed(() => {
|
||||
const statusMap = {
|
||||
@@ -218,6 +225,85 @@ const updateDisplayType = type => {
|
||||
state.displayType = type;
|
||||
};
|
||||
|
||||
const resetUtilityAnalysis = () => {
|
||||
utilityAnalysisResult.value = null;
|
||||
};
|
||||
|
||||
const analyzeTemplateUtility = async () => {
|
||||
if (!showUtilityAnalyzer.value || !state.message?.trim()) return;
|
||||
|
||||
utilityAnalysisLoading.value = true;
|
||||
resetUtilityAnalysis();
|
||||
|
||||
try {
|
||||
const response = await store.dispatch(
|
||||
'inboxes/analyzeCSATTemplateUtility',
|
||||
{
|
||||
inboxId: props.inbox.id,
|
||||
template: {
|
||||
message: state.message,
|
||||
button_text: state.templateButtonText,
|
||||
language: state.templateLanguage,
|
||||
},
|
||||
}
|
||||
);
|
||||
utilityAnalysisResult.value = response;
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error.response?.data?.error ||
|
||||
t('INBOX_MGMT.CSAT.UTILITY_ANALYZER.ERROR_MESSAGE');
|
||||
useAlert(errorMessage);
|
||||
} finally {
|
||||
utilityAnalysisLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const applyUtilitySuggestion = () => {
|
||||
const suggestion = utilityAnalysisResult.value?.optimized_message;
|
||||
if (!suggestion) return;
|
||||
|
||||
state.message = suggestion;
|
||||
resetUtilityAnalysis();
|
||||
};
|
||||
|
||||
watch(
|
||||
() => [state.message, state.templateButtonText, state.templateLanguage],
|
||||
(newValues, oldValues) => {
|
||||
if (!oldValues || !utilityAnalysisResult.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const changed = newValues.some(
|
||||
(value, index) => value !== oldValues[index]
|
||||
);
|
||||
if (changed) {
|
||||
resetUtilityAnalysis();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const getUtilityClassificationLabel = classification => {
|
||||
if (classification === 'LIKELY_UTILITY') {
|
||||
return t('INBOX_MGMT.CSAT.UTILITY_ANALYZER.CLASSIFICATION.LIKELY_UTILITY');
|
||||
}
|
||||
if (classification === 'LIKELY_MARKETING') {
|
||||
return t(
|
||||
'INBOX_MGMT.CSAT.UTILITY_ANALYZER.CLASSIFICATION.LIKELY_MARKETING'
|
||||
);
|
||||
}
|
||||
return t('INBOX_MGMT.CSAT.UTILITY_ANALYZER.CLASSIFICATION.UNCLEAR');
|
||||
};
|
||||
|
||||
const getUtilityClassificationClass = classification => {
|
||||
if (classification === 'LIKELY_UTILITY') {
|
||||
return 'bg-n-teal-3 text-n-teal-11';
|
||||
}
|
||||
if (classification === 'LIKELY_MARKETING') {
|
||||
return 'bg-n-ruby-3 text-n-ruby-11';
|
||||
}
|
||||
return 'bg-n-amber-3 text-n-amber-11';
|
||||
};
|
||||
|
||||
const updateSurveyRuleOperator = operator => {
|
||||
state.surveyRuleOperator = operator;
|
||||
};
|
||||
@@ -450,6 +536,70 @@ const handleConfirmTemplateUpdate = async () => {
|
||||
class="w-full"
|
||||
/>
|
||||
</WithLabel>
|
||||
<div v-if="showUtilityAnalyzer" class="flex flex-col gap-2">
|
||||
<NextButton
|
||||
sm
|
||||
slate
|
||||
:label="$t('INBOX_MGMT.CSAT.UTILITY_ANALYZER.ACTION')"
|
||||
:is-loading="utilityAnalysisLoading"
|
||||
:disabled="!state.message?.trim()"
|
||||
@click="analyzeTemplateUtility"
|
||||
/>
|
||||
<p class="text-xs text-n-slate-11">
|
||||
{{ $t('INBOX_MGMT.CSAT.UTILITY_ANALYZER.HELPER_NOTE') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="utilityAnalysisResult"
|
||||
class="flex flex-col gap-3 p-3 rounded-xl outline outline-1 outline-n-weak bg-n-alpha-1"
|
||||
>
|
||||
<div class="flex gap-2 items-center">
|
||||
<span class="text-sm font-medium text-n-slate-12">
|
||||
{{ $t('INBOX_MGMT.CSAT.UTILITY_ANALYZER.RESULT_LABEL') }}
|
||||
</span>
|
||||
<span
|
||||
class="px-2 py-0.5 text-xs font-medium rounded-full"
|
||||
:class="
|
||||
getUtilityClassificationClass(
|
||||
utilityAnalysisResult.classification
|
||||
)
|
||||
"
|
||||
>
|
||||
{{
|
||||
getUtilityClassificationLabel(
|
||||
utilityAnalysisResult.classification
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-xs text-n-slate-11">
|
||||
{{ $t('INBOX_MGMT.CSAT.UTILITY_ANALYZER.GUIDANCE_NOTE') }}
|
||||
</p>
|
||||
<div
|
||||
v-if="
|
||||
utilityAnalysisResult.optimized_message &&
|
||||
utilityAnalysisResult.classification !== 'LIKELY_UTILITY'
|
||||
"
|
||||
class="flex flex-col gap-2"
|
||||
>
|
||||
<p class="text-xs font-medium text-n-slate-12">
|
||||
{{
|
||||
$t('INBOX_MGMT.CSAT.UTILITY_ANALYZER.SUGGESTION_LABEL')
|
||||
}}
|
||||
</p>
|
||||
<p class="text-sm text-n-slate-12">
|
||||
{{ utilityAnalysisResult.optimized_message }}
|
||||
</p>
|
||||
<NextButton
|
||||
sm
|
||||
faded
|
||||
slate
|
||||
:label="$t('INBOX_MGMT.CSAT.UTILITY_ANALYZER.APPLY')"
|
||||
@click="applyUtilitySuggestion"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Input
|
||||
v-model="state.templateButtonText"
|
||||
:label="$t('INBOX_MGMT.CSAT.BUTTON_TEXT.LABEL')"
|
||||
|
||||
@@ -58,6 +58,7 @@ export default {
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.$store.dispatch('integrations/get', 'webhook');
|
||||
this.$store.dispatch('webhooks/get');
|
||||
},
|
||||
methods: {
|
||||
|
||||
+86
-48
@@ -1,60 +1,98 @@
|
||||
<script>
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStore } from 'vuex';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useBranding } from 'shared/composables/useBranding';
|
||||
import { mapGetters } from 'vuex';
|
||||
import { copyTextToClipboard } from 'shared/helpers/clipboard';
|
||||
import WebhookForm from './WebhookForm.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
export default {
|
||||
components: { WebhookForm },
|
||||
props: {
|
||||
onClose: {
|
||||
type: Function,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
const { replaceInstallationName } = useBranding();
|
||||
return {
|
||||
replaceInstallationName,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
uiFlags: 'webhooks/getUIFlags',
|
||||
}),
|
||||
},
|
||||
methods: {
|
||||
async onSubmit(webhook) {
|
||||
try {
|
||||
await this.$store.dispatch('webhooks/create', { webhook });
|
||||
useAlert(
|
||||
this.$t('INTEGRATION_SETTINGS.WEBHOOK.ADD.API.SUCCESS_MESSAGE')
|
||||
);
|
||||
this.onClose();
|
||||
} catch (error) {
|
||||
const message =
|
||||
error.response.data.message ||
|
||||
this.$t('INTEGRATION_SETTINGS.WEBHOOK.EDIT.API.ERROR_MESSAGE');
|
||||
useAlert(message);
|
||||
}
|
||||
},
|
||||
const props = defineProps({
|
||||
onClose: {
|
||||
type: Function,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
const { replaceInstallationName } = useBranding();
|
||||
|
||||
const createdWebhook = ref(null);
|
||||
|
||||
const uiFlags = computed(() => store.getters['webhooks/getUIFlags']);
|
||||
|
||||
const onSubmit = async webhook => {
|
||||
try {
|
||||
const result = await store.dispatch('webhooks/create', { webhook });
|
||||
createdWebhook.value = result;
|
||||
} catch (error) {
|
||||
const message =
|
||||
error.response.data.message ||
|
||||
t('INTEGRATION_SETTINGS.WEBHOOK.EDIT.API.ERROR_MESSAGE');
|
||||
useAlert(message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopySecret = async () => {
|
||||
await copyTextToClipboard(createdWebhook.value.secret);
|
||||
useAlert(t('INTEGRATION_SETTINGS.WEBHOOK.SECRET.COPY_SUCCESS'));
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-auto overflow-auto flex flex-col">
|
||||
<woot-modal-header
|
||||
:header-title="$t('INTEGRATION_SETTINGS.WEBHOOK.ADD.TITLE')"
|
||||
:header-content="
|
||||
replaceInstallationName($t('INTEGRATION_SETTINGS.WEBHOOK.FORM.DESC'))
|
||||
"
|
||||
/>
|
||||
<WebhookForm
|
||||
:is-submitting="uiFlags.creatingItem"
|
||||
:submit-label="$t('INTEGRATION_SETTINGS.WEBHOOK.FORM.ADD_SUBMIT')"
|
||||
@submit="onSubmit"
|
||||
@cancel="onClose"
|
||||
/>
|
||||
<template v-if="createdWebhook">
|
||||
<woot-modal-header
|
||||
:header-title="
|
||||
t('INTEGRATION_SETTINGS.WEBHOOK.ADD.API.SUCCESS_MESSAGE')
|
||||
"
|
||||
/>
|
||||
<div class="px-8 pb-6">
|
||||
<p class="text-sm text-n-slate-11 mb-4">
|
||||
{{ t('INTEGRATION_SETTINGS.WEBHOOK.SECRET.CREATED_DESC') }}
|
||||
</p>
|
||||
<label>
|
||||
{{ t('INTEGRATION_SETTINGS.WEBHOOK.SECRET.LABEL') }}
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
:value="createdWebhook.secret"
|
||||
type="text"
|
||||
readonly
|
||||
class="!mb-0 font-mono"
|
||||
/>
|
||||
<NextButton
|
||||
v-tooltip.top="t('INTEGRATION_SETTINGS.WEBHOOK.SECRET.COPY')"
|
||||
icon="i-lucide-copy"
|
||||
slate
|
||||
faded
|
||||
@click="handleCopySecret"
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
<div class="flex justify-end mt-4">
|
||||
<NextButton
|
||||
blue
|
||||
:label="t('INTEGRATION_SETTINGS.WEBHOOK.SECRET.DONE')"
|
||||
@click="props.onClose()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<woot-modal-header
|
||||
:header-title="t('INTEGRATION_SETTINGS.WEBHOOK.ADD.TITLE')"
|
||||
:header-content="
|
||||
replaceInstallationName(t('INTEGRATION_SETTINGS.WEBHOOK.FORM.DESC'))
|
||||
"
|
||||
/>
|
||||
<WebhookForm
|
||||
:is-submitting="uiFlags.creatingItem"
|
||||
:submit-label="t('INTEGRATION_SETTINGS.WEBHOOK.FORM.ADD_SUBMIT')"
|
||||
@submit="onSubmit"
|
||||
@cancel="props.onClose()"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
+39
@@ -3,6 +3,8 @@ import { useVuelidate } from '@vuelidate/core';
|
||||
import { required, url, minLength } from '@vuelidate/validators';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
import { getI18nKey } from 'dashboard/routes/dashboard/settings/helper/settingsHelper';
|
||||
import { copyTextToClipboard } from 'shared/helpers/clipboard';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const { EXAMPLE_WEBHOOK_URL } = wootConstants;
|
||||
@@ -57,10 +59,14 @@ export default {
|
||||
url: this.value.url || '',
|
||||
name: this.value.name || '',
|
||||
subscriptions: this.value.subscriptions || [],
|
||||
secretVisible: false,
|
||||
supportedWebhookEvents: SUPPORTED_WEBHOOK_EVENTS,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
hasSecret() {
|
||||
return !!this.value.secret;
|
||||
},
|
||||
webhookURLInputPlaceholder() {
|
||||
return this.$t(
|
||||
'INTEGRATION_SETTINGS.WEBHOOK.FORM.END_POINT.PLACEHOLDER',
|
||||
@@ -81,6 +87,10 @@ export default {
|
||||
subscriptions: this.subscriptions,
|
||||
});
|
||||
},
|
||||
async copySecret() {
|
||||
await copyTextToClipboard(this.value.secret);
|
||||
useAlert(this.$t('INTEGRATION_SETTINGS.WEBHOOK.SECRET.COPY_SUCCESS'));
|
||||
},
|
||||
getI18nKey,
|
||||
},
|
||||
};
|
||||
@@ -111,6 +121,35 @@ export default {
|
||||
:placeholder="webhookNameInputPlaceholder"
|
||||
/>
|
||||
</label>
|
||||
<label v-if="hasSecret" class="mb-4">
|
||||
{{ $t('INTEGRATION_SETTINGS.WEBHOOK.SECRET.LABEL') }}
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
:value="
|
||||
secretVisible ? value.secret : '••••••••••••••••••••••••••••••••'
|
||||
"
|
||||
type="text"
|
||||
readonly
|
||||
class="!mb-0 font-mono"
|
||||
/>
|
||||
<NextButton
|
||||
v-tooltip.top="$t('INTEGRATION_SETTINGS.WEBHOOK.SECRET.TOGGLE')"
|
||||
type="button"
|
||||
:icon="secretVisible ? 'i-lucide-eye-off' : 'i-lucide-eye'"
|
||||
slate
|
||||
faded
|
||||
@click="secretVisible = !secretVisible"
|
||||
/>
|
||||
<NextButton
|
||||
v-tooltip.top="$t('INTEGRATION_SETTINGS.WEBHOOK.SECRET.COPY')"
|
||||
type="button"
|
||||
icon="i-lucide-copy"
|
||||
slate
|
||||
faded
|
||||
@click="copySecret"
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
<label :class="{ error: v$.url.$error }" class="mb-2">
|
||||
{{ $t('INTEGRATION_SETTINGS.WEBHOOK.FORM.SUBSCRIPTIONS.LABEL') }}
|
||||
</label>
|
||||
|
||||
@@ -32,7 +32,10 @@ const records = computed(() => getters['labels/getLabels'].value);
|
||||
const filteredRecords = computed(() => {
|
||||
const query = searchQuery.value.trim();
|
||||
if (!query) return records.value;
|
||||
return picoSearch(records.value, query, ['title', 'description']);
|
||||
return picoSearch(records.value, query, [
|
||||
{ name: 'title', weight: 4 },
|
||||
'description',
|
||||
]);
|
||||
});
|
||||
const uiFlags = computed(() => getters['labels/getUIFlags'].value);
|
||||
|
||||
|
||||
@@ -457,11 +457,7 @@ const actions = {
|
||||
},
|
||||
|
||||
sendEmailTranscript: async (_, { conversationId, email }) => {
|
||||
try {
|
||||
await ConversationApi.sendEmailTranscript({ conversationId, email });
|
||||
} catch (error) {
|
||||
throw new Error(error);
|
||||
}
|
||||
await ConversationApi.sendEmailTranscript({ conversationId, email });
|
||||
},
|
||||
|
||||
updateCustomAttributes: async (
|
||||
|
||||
@@ -116,6 +116,7 @@ const SORT_OPTIONS = {
|
||||
priority_desc: ['sortOnPriority', 'desc'],
|
||||
waiting_since_asc: ['sortOnWaitingSince', 'asc'],
|
||||
waiting_since_desc: ['sortOnWaitingSince', 'desc'],
|
||||
priority_desc_created_at_asc: ['sortOnPriorityCreatedAt', 'desc'],
|
||||
};
|
||||
const sortAscending = (valueA, valueB) => valueA - valueB;
|
||||
const sortDescending = (valueA, valueB) => valueB - valueA;
|
||||
@@ -139,6 +140,14 @@ const sortConfig = {
|
||||
return getSortOrderFunction(sortDirection)(p1, p2);
|
||||
},
|
||||
|
||||
sortOnPriorityCreatedAt: (a, b) => {
|
||||
const DEFAULT_FOR_NULL = 0;
|
||||
const p1 = CONVERSATION_PRIORITY_ORDER[a.priority] || DEFAULT_FOR_NULL;
|
||||
const p2 = CONVERSATION_PRIORITY_ORDER[b.priority] || DEFAULT_FOR_NULL;
|
||||
if (p1 !== p2) return p2 - p1;
|
||||
return a.created_at - b.created_at;
|
||||
},
|
||||
|
||||
sortOnWaitingSince: (a, b, sortDirection) => {
|
||||
const sortFunc = getSortOrderFunction(sortDirection);
|
||||
if (!a.waiting_since || !b.waiting_since) {
|
||||
|
||||
@@ -360,6 +360,13 @@ export const actions = {
|
||||
const response = await InboxesAPI.getCSATTemplateStatus(inboxId);
|
||||
return response.data;
|
||||
},
|
||||
analyzeCSATTemplateUtility: async (_, { inboxId, template }) => {
|
||||
const response = await InboxesAPI.analyzeCSATTemplateUtility(
|
||||
inboxId,
|
||||
template
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
export const mutations = {
|
||||
|
||||
@@ -42,6 +42,7 @@ export const actions = {
|
||||
} = response.data;
|
||||
commit(types.default.ADD_WEBHOOK, webhook);
|
||||
commit(types.default.SET_WEBHOOK_UI_FLAG, { creatingItem: false });
|
||||
return webhook;
|
||||
} catch (error) {
|
||||
commit(types.default.SET_WEBHOOK_UI_FLAG, { creatingItem: false });
|
||||
throw error;
|
||||
|
||||
@@ -46,9 +46,7 @@ export default {
|
||||
filteredActiveLabels() {
|
||||
if (!this.search) return this.accountLabels;
|
||||
|
||||
return picoSearch(this.accountLabels, this.search, ['title'], {
|
||||
threshold: 0.9,
|
||||
});
|
||||
return picoSearch(this.accountLabels, this.search, ['title']);
|
||||
},
|
||||
|
||||
noResult() {
|
||||
|
||||
@@ -72,6 +72,10 @@ export default {
|
||||
return this.message.sender.available_name || this.message.sender.name;
|
||||
}
|
||||
|
||||
if (this.message.additional_attributes?.sender_name) {
|
||||
return this.message.additional_attributes.sender_name;
|
||||
}
|
||||
|
||||
if (this.useInboxAvatarForBot) {
|
||||
return this.channelConfig.websiteName;
|
||||
}
|
||||
@@ -87,9 +91,13 @@ export default {
|
||||
return displayImage;
|
||||
}
|
||||
|
||||
return this.message.sender
|
||||
? this.message.sender.avatar_url
|
||||
: displayImage;
|
||||
if (this.message.sender) {
|
||||
return this.message.sender.avatar_url;
|
||||
}
|
||||
|
||||
return (
|
||||
this.message.additional_attributes?.sender_avatar_url || displayImage
|
||||
);
|
||||
},
|
||||
hasRecordedResponse() {
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user