Merge branch 'develop' into feat/shopify-app-store-install-flow

This commit is contained in:
Muhsin Keloth
2026-02-28 10:45:31 +04:00
committed by GitHub
118 changed files with 2524 additions and 605 deletions
+6
View File
@@ -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();
@@ -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 }) => {
@@ -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
@@ -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(
@@ -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,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 doesnt 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,
-3
View File
@@ -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
@@ -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">
@@ -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>
@@ -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: {
@@ -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: {
@@ -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>
@@ -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 (