Merge branch 'develop' into fix/cw-4085

This commit is contained in:
Shivam Mishra
2025-08-14 17:13:25 +05:30
committed by GitHub
2700 changed files with 123422 additions and 26647 deletions
@@ -9,20 +9,31 @@ export default {
type: String,
required: true,
},
isComingSoon: {
type: Boolean,
default: false,
},
},
};
</script>
<template>
<button
class="bg-white dark:bg-slate-900 cursor-pointer flex flex-col justify-end transition-all duration-200 ease-in -m-px py-4 px-0 items-center border border-solid border-slate-25 dark:border-slate-800 hover:border-woot-500 dark:hover:border-woot-500 hover:shadow-md hover:z-50 disabled:opacity-60"
class="relative bg-n-background cursor-pointer flex flex-col justify-end transition-all duration-200 ease-in -m-px py-4 px-0 items-center border border-solid border-n-weak hover:border-n-brand hover:shadow-md hover:z-50 disabled:opacity-60"
>
<img :src="src" :alt="title" class="w-1/2 my-4 mx-auto" />
<h3
class="text-slate-800 dark:text-slate-100 text-base text-center capitalize"
>
<img :src="src" :alt="title" draggable="false" class="w-1/2 my-4 mx-auto" />
<h3 class="text-n-slate-12 text-base text-center capitalize">
{{ title }}
</h3>
<div
v-if="isComingSoon"
class="absolute inset-0 flex items-center justify-center backdrop-blur-[2px] rounded-md bg-gradient-to-br from-n-background/90 via-n-background/70 to-n-background/95"
>
<span class="text-n-slate-12 font-medium text-base">
{{ $t('CHANNEL_SELECTOR.COMING_SOON') }} 🚀
</span>
</div>
</button>
</template>
@@ -33,7 +44,7 @@ export default {
}
&:hover {
@apply border-transparent shadow-none cursor-not-allowed;
@apply border-n-strong shadow-none cursor-not-allowed;
}
}
</style>
@@ -22,6 +22,7 @@ import {
// https://tanstack.com/virtual/latest/docs/framework/vue/examples/variable
import { DynamicScroller, DynamicScrollerItem } from 'vue-virtual-scroller';
import ChatListHeader from './ChatListHeader.vue';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import ConversationFilter from 'next/filter/ConversationFilter.vue';
import SaveCustomView from 'next/filter/SaveCustomView.vue';
import ChatTypeTabs from './widgets/ChatTypeTabs.vue';
@@ -29,6 +30,8 @@ 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 { useUISettings } from 'dashboard/composables/useUISettings';
import { useAlert } from 'dashboard/composables';
@@ -81,11 +84,14 @@ const emit = defineEmits(['conversationLoad']);
const { uiSettings } = useUISettings();
const { t } = useI18n();
const router = useRouter();
const route = useRoute();
const store = useStore();
const conversationListRef = ref(null);
const conversationDynamicScroller = ref(null);
provide('contextMenuElementTarget', conversationDynamicScroller);
const activeAssigneeTab = ref(wootConstants.ASSIGNEE_TYPE.ME);
const activeStatus = ref(wootConstants.STATUS_TYPE.OPEN);
const activeSortBy = ref(wootConstants.SORT_BY_TYPE.LAST_ACTIVITY_AT_DESC);
@@ -643,6 +649,30 @@ function openLastItemAfterDeleteInFolder() {
}
}
function redirectToConversationList() {
const {
params: { accountId, inbox_id: inboxId, label, teamId },
name,
} = route;
let conversationType = '';
if (isOnMentionsView({ route: { name } })) {
conversationType = 'mention';
} else if (isOnUnattendedView({ route: { name } })) {
conversationType = 'unattended';
}
router.push(
conversationListPageURL({
accountId,
conversationType: conversationType,
customViewId: props.foldersId,
inboxId,
label,
teamId,
})
);
}
async function assignPriority(priority, conversationId = null) {
store.dispatch('setCurrentChatPriority', {
priority,
@@ -667,26 +697,7 @@ async function markAsUnread(conversationId) {
await store.dispatch('markMessagesUnread', {
id: conversationId,
});
const {
params: { accountId, inbox_id: inboxId, label, teamId },
name,
} = useRoute();
let conversationType = '';
if (isOnMentionsView({ route: { name } })) {
conversationType = 'mention';
} else if (isOnUnattendedView({ route: { name } })) {
conversationType = 'unattended';
}
router.push(
conversationListPageURL({
accountId,
conversationType: conversationType,
customViewId: props.foldersId,
inboxId,
label,
teamId,
})
);
redirectToConversationList();
} catch (error) {
// Ignore error
}
@@ -700,6 +711,7 @@ async function markAsRead(conversationId) {
// Ignore error
}
}
async function onAssignTeam(team, conversationId = null) {
try {
await store.dispatch('assignTeam', {
@@ -745,6 +757,7 @@ function toggleSelectAll(check) {
}
useEmitter('fetch_conversation_stats', () => {
if (hasAppliedFiltersOrActiveFolders.value) return;
store.dispatch('conversationStats/get', conversationFilters.value);
});
@@ -761,6 +774,26 @@ onMounted(() => {
}
});
const deleteConversationDialogRef = ref(null);
const selectedConversationId = ref(null);
async function deleteConversation() {
try {
await store.dispatch('deleteConversation', selectedConversationId.value);
redirectToConversationList();
selectedConversationId.value = null;
deleteConversationDialogRef.value.close();
useAlert(t('CONVERSATION.SUCCESS_DELETE_CONVERSATION'));
} catch (error) {
useAlert(t('CONVERSATION.FAIL_DELETE_CONVERSATION'));
}
}
const handleDelete = conversationId => {
selectedConversationId.value = conversationId;
deleteConversationDialogRef.value.open();
};
provide('selectConversation', selectConversation);
provide('deSelectConversation', deSelectConversation);
provide('assignAgent', onAssignAgent);
@@ -772,6 +805,7 @@ provide('markAsUnread', markAsUnread);
provide('markAsRead', markAsRead);
provide('assignPriority', assignPriority);
provide('isConversationSelected', isConversationSelected);
provide('deleteConversation', handleDelete);
watch(activeTeam, () => resetAndFetchData());
@@ -811,7 +845,7 @@ watch(conversationFilters, (newVal, oldVal) => {
class="flex flex-col flex-shrink-0 bg-n-solid-1 conversations-list-wrap"
:class="[
{ hidden: !showConversationList },
isOnExpandedLayout ? 'basis-full' : 'w-[360px] 2xl:w-[420px]',
isOnExpandedLayout ? 'basis-full' : 'w-[340px] 2xl:w-[412px]',
]"
>
<slot />
@@ -821,6 +855,8 @@ watch(conversationFilters, (newVal, oldVal) => {
:has-active-folders="hasActiveFolders"
:active-status="activeStatus"
:is-on-expanded-layout="isOnExpandedLayout"
:conversation-stats="conversationStats"
:is-list-loading="chatListLoading && !conversationList.length"
@add-folders="onClickOpenAddFoldersModal"
@delete-folders="onClickOpenDeleteFoldersModal"
@filters-modal="onToggleAdvanceFiltersModal"
@@ -828,14 +864,17 @@ watch(conversationFilters, (newVal, oldVal) => {
@basic-filter-change="onBasicFilterChange"
/>
<Teleport v-if="showAddFoldersModal" to="#saveFilterTeleportTarget">
<TeleportWithDirection
v-if="showAddFoldersModal"
to="#saveFilterTeleportTarget"
>
<SaveCustomView
v-model="appliedFilter"
:custom-views-query="foldersQuery"
:open-last-saved-item="openLastSavedItemInFolder"
@close="onCloseAddFoldersModal"
/>
</Teleport>
</TeleportWithDirection>
<DeleteCustomViews
v-if="showDeleteFoldersModal"
@@ -915,12 +954,12 @@ watch(conversationFilters, (newVal, oldVal) => {
</DynamicScrollerItem>
</template>
<template #after>
<div v-if="chatListLoading" class="text-center">
<span class="mt-4 mb-4 spinner" />
<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-slate-400 dark:text-slate-300"
class="p-4 text-center text-n-slate-11"
>
{{ $t('CHAT_LIST.EOF') }}
</p>
@@ -932,7 +971,23 @@ watch(conversationFilters, (newVal, oldVal) => {
</template>
</DynamicScroller>
</div>
<Teleport v-if="showAdvancedFilters" to="#conversationFilterTeleportTarget">
<Dialog
ref="deleteConversationDialogRef"
type="alert"
:title="
$t('CONVERSATION.DELETE_CONVERSATION.TITLE', {
conversationId: selectedConversationId,
})
"
:description="$t('CONVERSATION.DELETE_CONVERSATION.DESCRIPTION')"
:confirm-button-label="$t('CONVERSATION.DELETE_CONVERSATION.CONFIRM')"
@confirm="deleteConversation"
@close="selectedConversationId = null"
/>
<TeleportWithDirection
v-if="showAdvancedFilters"
to="#conversationFilterTeleportTarget"
>
<ConversationFilter
v-model="appliedFilter"
:folder-name="activeFolderName"
@@ -941,6 +996,6 @@ watch(conversationFilters, (newVal, oldVal) => {
@update-folder="onUpdateSavedFilter"
@close="closeAdvanceFiltersModal"
/>
</Teleport>
</TeleportWithDirection>
</div>
</template>
@@ -1,35 +1,21 @@
<script setup>
import { computed } from 'vue';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useMapGetter } from 'dashboard/composables/store.js';
import { formatNumber } from '@chatwoot/utils';
import wootConstants from 'dashboard/constants/globals';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import ConversationBasicFilter from './widgets/conversation/ConversationBasicFilter.vue';
import SwitchLayout from 'dashboard/routes/dashboard/conversation/search/SwitchLayout.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
pageTitle: {
type: String,
required: true,
},
hasAppliedFilters: {
type: Boolean,
required: true,
},
hasActiveFolders: {
type: Boolean,
required: true,
},
activeStatus: {
type: String,
required: true,
},
isOnExpandedLayout: {
type: Boolean,
required: true,
},
pageTitle: { type: String, required: true },
hasAppliedFilters: { type: Boolean, required: true },
hasActiveFolders: { type: Boolean, required: true },
activeStatus: { type: String, required: true },
isOnExpandedLayout: { type: Boolean, required: true },
conversationStats: { type: Object, required: true },
isListLoading: { type: Boolean, required: true },
});
const emit = defineEmits([
@@ -42,11 +28,6 @@ const emit = defineEmits([
const { uiSettings, updateUISettings } = useUISettings();
const currentAccountId = useMapGetter('getCurrentAccountId');
const isFeatureEnabledonAccount = useMapGetter(
'accounts/isFeatureEnabledonAccount'
);
const onBasicFilterChange = (value, type) => {
emit('basicFilterChange', value, type);
};
@@ -55,12 +36,8 @@ const hasAppliedFiltersOrActiveFolders = computed(() => {
return props.hasAppliedFilters || props.hasActiveFolders;
});
const showV4View = computed(() => {
return isFeatureEnabledonAccount.value(
currentAccountId.value,
FEATURE_FLAGS.CHATWOOT_V4
);
});
const allCount = computed(() => props.conversationStats?.allCount || 0);
const formattedAllCount = computed(() => formatNumber(allCount.value));
const toggleConversationLayout = () => {
const { LAYOUT_TYPES } = wootConstants;
@@ -80,20 +57,27 @@ const toggleConversationLayout = () => {
<template>
<div
class="flex items-center justify-between gap-2 px-4"
class="flex items-center justify-between gap-2 px-3 h-12"
:class="{
'pb-3 border-b border-n-strong': hasAppliedFiltersOrActiveFolders,
'pt-3 pb-2': showV4View,
'mb-2 pb-0': !showV4View,
'border-b border-n-strong': hasAppliedFiltersOrActiveFolders,
}"
>
<div class="flex items-center justify-center min-w-0">
<h1
class="text-lg font-medium truncate text-n-slate-12"
class="text-base font-medium truncate text-n-slate-12"
:title="pageTitle"
>
{{ pageTitle }}
</h1>
<span
v-if="
allCount > 0 && hasAppliedFiltersOrActiveFolders && !isListLoading
"
class="px-2 py-1 my-0.5 mx-1 rounded-md capitalize bg-n-slate-3 text-xxs text-n-slate-12 shrink-0"
:title="allCount"
>
{{ formattedAllCount }}
</span>
<span
v-if="!hasAppliedFiltersOrActiveFolders"
class="px-2 py-1 my-0.5 mx-1 rounded-md capitalize bg-n-slate-3 text-xxs text-n-slate-12 shrink-0"
@@ -114,7 +98,7 @@ const toggleConversationLayout = () => {
/>
<div
id="saveFilterTeleportTarget"
class="absolute z-40 mt-2"
class="absolute z-50 mt-2"
:class="{ 'ltr:right-0 rtl:left-0': isOnExpandedLayout }"
/>
</div>
@@ -140,7 +124,7 @@ const toggleConversationLayout = () => {
/>
<div
id="conversationFilterTeleportTarget"
class="absolute z-40 mt-2"
class="absolute z-50 mt-2"
:class="{ 'ltr:right-0 rtl:left-0': isOnExpandedLayout }"
/>
</div>
@@ -166,7 +150,7 @@ const toggleConversationLayout = () => {
/>
<div
id="conversationFilterTeleportTarget"
class="absolute z-40 mt-2"
class="absolute z-50 mt-2"
:class="{ 'ltr:right-0 rtl:left-0': isOnExpandedLayout }"
/>
</div>
@@ -176,7 +160,6 @@ const toggleConversationLayout = () => {
@change-filter="onBasicFilterChange"
/>
<SwitchLayout
v-if="showV4View"
:is-on-expanded-layout="isOnExpandedLayout"
@toggle="toggleConversationLayout"
/>
+9 -2
View File
@@ -61,7 +61,9 @@ const onCopy = async e => {
<template>
<div class="relative text-left">
<div class="top-1.5 absolute right-1.5 flex items-center gap-1">
<div
class="top-1.5 absolute ltr:right-1.5 rtl:left-1.5 flex backdrop-blur-sm rounded-lg items-center gap-1"
>
<form
v-if="enableCodePen"
class="flex items-center"
@@ -86,6 +88,11 @@ const onCopy = async e => {
@click="onCopy"
/>
</div>
<highlightjs v-if="script" :language="lang" :code="scrubbedScript" />
<highlightjs
v-if="script"
:language="lang"
:code="scrubbedScript"
class="[&_code]:text-start"
/>
</div>
</template>
@@ -16,6 +16,7 @@ export default {
'markAsRead',
'assignPriority',
'isConversationSelected',
'deleteConversation',
],
props: {
source: {
@@ -67,5 +68,6 @@ export default {
@mark-as-unread="markAsUnread"
@mark-as-read="markAsRead"
@assign-priority="assignPriority"
@delete-conversation="deleteConversation"
/>
</template>
@@ -49,12 +49,12 @@ export default {
if (this.isAttributeTypeDate) {
return this.value
? new Date(this.value || new Date()).toLocaleDateString()
: '';
: '---';
}
if (this.isAttributeTypeCheckbox) {
return this.value === 'false' ? false : this.value;
}
return this.value;
return this.hasValue ? this.value : '---';
},
formattedValue() {
return this.isAttributeTypeDate
@@ -83,6 +83,9 @@ export default {
isAttributeTypeDate() {
return this.attributeType === 'date';
},
hasValue() {
return this.value !== null && this.value !== '';
},
urlValue() {
return isValidURL(this.value) ? this.value : '---';
},
@@ -223,7 +226,7 @@ export default {
/>
</span>
<NextButton
v-if="showActions && value"
v-if="showActions && hasValue"
v-tooltip.left="$t('CUSTOM_ATTRIBUTES.ACTIONS.DELETE')"
slate
sm
@@ -281,13 +284,13 @@ export default {
v-else
class="group-hover:bg-n-slate-3 group-hover:dark:bg-n-solid-3 inline-block rounded-sm mb-0 break-all py-0.5 px-1"
>
{{ displayValue || '---' }}
{{ displayValue }}
</p>
<div
class="flex items-center max-w-[2rem] gap-1 ml-1 rtl:mr-1 rtl:ml-0"
>
<NextButton
v-if="showActions && value"
v-if="showActions && hasValue"
v-tooltip="$t('CUSTOM_ATTRIBUTES.ACTIONS.COPY')"
xs
slate
@@ -14,13 +14,13 @@ defineProps({
<template>
<div class="flex flex-col items-start w-full gap-6">
<div class="flex flex-col w-full gap-4">
<h4 v-if="title" class="text-lg font-medium text-ash-900">
<h4 v-if="title" class="text-lg font-medium text-n-slate-12">
{{ title }}
</h4>
<div class="flex flex-row items-center justify-between">
<div class="flex-grow h-px bg-ash-200" />
<div class="flex-grow h-px bg-n-weak" />
</div>
<p v-if="description" class="mb-0 text-sm font-normal text-ash-900">
<p v-if="description" class="mb-0 text-sm font-normal text-n-slate-12">
{{ description }}
</p>
</div>
@@ -28,19 +28,19 @@ export default {
<img v-if="headerImage" :src="headerImage" alt="No image" />
<h2
data-test-id="modal-header-title"
class="text-base font-semibold leading-6 text-slate-800 dark:text-slate-50"
class="text-base font-semibold leading-6 text-n-slate-12"
>
{{ headerTitle }}
</h2>
<p
v-if="headerContent"
data-test-id="modal-header-content"
class="w-full mt-2 text-sm leading-5 break-words text-slate-600 dark:text-slate-300"
class="w-full mt-2 text-sm leading-5 break-words text-n-slate-11"
>
{{ headerContent }}
<span
v-if="headerContentValue"
class="text-sm font-semibold text-slate-600 dark:text-slate-300"
class="text-sm font-semibold text-n-slate-11"
>
{{ headerContentValue }}
</span>
@@ -30,15 +30,10 @@ export default {
>
<div class="grid grid-cols-1 lg:grid-cols-8 gap-6">
<div class="col-span-2">
<p
v-if="title"
class="text-base text-woot-500 dark:text-woot-500 mb-0 font-medium"
>
<p v-if="title" class="text-base text-n-brand mb-0 font-medium">
{{ title }}
</p>
<p
class="text-sm mb-2 text-slate-700 dark:text-slate-300 leading-5 tracking-normal mt-2"
>
<p class="text-sm mb-2 text-n-slate-11 leading-5 tracking-normal mt-2">
<slot v-if="subTitle" name="subTitle">
{{ subTitle }}
</slot>
@@ -1,49 +0,0 @@
<script>
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { mapGetters } from 'vuex';
import { emitter } from 'shared/helpers/mitt';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: {
NextButton,
},
props: {
size: {
type: String,
default: 'sm',
},
},
computed: {
...mapGetters({
accountId: 'getCurrentAccountId',
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
}),
hasNextSidebar() {
return this.isFeatureEnabledonAccount(
this.accountId,
FEATURE_FLAGS.CHATWOOT_V4
);
},
},
methods: {
onMenuItemClick() {
emitter.emit(BUS_EVENTS.TOGGLE_SIDEMENU);
},
},
};
</script>
<!-- eslint-disable-next-line vue/no-root-v-if -->
<template>
<NextButton
v-if="!hasNextSidebar"
ghost
slate
:size="size"
icon="i-lucide-menu"
class="-ml-3"
@click="onMenuItemClick"
/>
</template>
@@ -20,7 +20,7 @@ export default {
<template>
<div>
<div
class="shadow-sm bg-slate-800 dark:bg-slate-700 rounded-[4px] items-center gap-3 inline-flex mb-2 max-w-[25rem] min-h-[1.875rem] min-w-[15rem] px-6 py-3 text-left"
class="shadow-sm bg-n-slate-12 dark:bg-n-slate-7 rounded-lg items-center gap-3 inline-flex mb-2 max-w-[25rem] min-h-[1.875rem] min-w-[15rem] px-6 py-3 text-left"
>
<div class="text-sm font-medium text-white dark:text-white">
{{ message }}
@@ -29,7 +29,7 @@ export default {
<router-link
v-if="action.type == 'link'"
:to="action.to"
class="font-medium cursor-pointer select-none text-woot-500 dark:text-woot-500 hover:text-woot-600 dark:hover:text-woot-600"
class="font-medium cursor-pointer select-none text-n-blue-10 hover:text-n-brand"
>
{{ action.message }}
</router-link>
@@ -1,62 +1,72 @@
<script>
<script setup>
import { ref, onMounted, onUnmounted, nextTick } from 'vue';
import WootSnackbar from './Snackbar.vue';
import { emitter } from 'shared/helpers/mitt';
import { useI18n } from 'vue-i18n';
export default {
components: {
WootSnackbar,
},
props: {
duration: {
type: Number,
default: 2500,
},
const props = defineProps({
duration: {
type: Number,
default: 2500,
},
});
data() {
return {
snackMessages: [],
};
},
const { t } = useI18n();
mounted() {
emitter.on('newToastMessage', this.onNewToastMessage);
},
unmounted() {
emitter.off('newToastMessage', this.onNewToastMessage);
},
methods: {
onNewToastMessage({ message: originalMessage, action }) {
// FIX ME: This is a temporary workaround to pass string from functions
// that doesn't have the context of the VueApp.
const usei18n = action?.usei18n;
const duration = action?.duration || this.duration;
const message = usei18n ? this.$t(originalMessage) : originalMessage;
const snackMessages = ref([]);
const snackbarContainer = ref(null);
this.snackMessages.push({
key: new Date().getTime(),
message,
action,
});
window.setTimeout(() => {
this.snackMessages.splice(0, 1);
}, duration);
},
},
const showPopover = () => {
try {
const el = snackbarContainer.value;
if (el?.matches(':popover-open')) {
el.hidePopover();
}
el?.showPopover();
} catch (e) {
// ignore
}
};
const onNewToastMessage = ({ message: originalMessage, action }) => {
const message = action?.usei18n ? t(originalMessage) : originalMessage;
const duration = action?.duration || props.duration;
snackMessages.value.push({
key: Date.now(),
message,
action,
});
nextTick(showPopover);
setTimeout(() => {
snackMessages.value.shift();
}, duration);
};
onMounted(() => {
emitter.on('newToastMessage', onNewToastMessage);
});
onUnmounted(() => {
emitter.off('newToastMessage', onNewToastMessage);
});
</script>
<template>
<transition-group
name="toast-fade"
tag="div"
class="left-0 my-0 mx-auto max-w-[25rem] overflow-hidden absolute right-0 text-center top-4 z-[9999]"
<div
ref="snackbarContainer"
popover="manual"
class="fixed top-4 left-1/2 -translate-x-1/2 max-w-[25rem] w-[calc(100%-2rem)] text-center bg-transparent border-0 p-0 m-0 outline-none overflow-visible"
>
<WootSnackbar
v-for="snackMessage in snackMessages"
:key="snackMessage.key"
:message="snackMessage.message"
:action="snackMessage.action"
/>
</transition-group>
<transition-group name="toast-fade" tag="div">
<WootSnackbar
v-for="snackMessage in snackMessages"
:key="snackMessage.key"
:message="snackMessage.message"
:action="snackMessage.action"
/>
</transition-group>
</div>
</template>
@@ -1,94 +0,0 @@
<script>
import Banner from 'dashboard/components/ui/Banner.vue';
import { mapGetters } from 'vuex';
import { useAccount } from 'dashboard/composables/useAccount';
import { differenceInDays } from 'date-fns';
export default {
components: { Banner },
setup() {
const { accountId } = useAccount();
return {
accountId,
};
},
data() {
return { conversationMeta: {} };
},
computed: {
...mapGetters({
isOnChatwootCloud: 'globalConfig/isOnChatwootCloud',
getAccount: 'accounts/getAccount',
}),
bannerMessage() {
return this.$t('GENERAL_SETTINGS.LIMITS_UPGRADE');
},
actionButtonMessage() {
return this.$t('GENERAL_SETTINGS.OPEN_BILLING');
},
shouldShowBanner() {
if (!this.isOnChatwootCloud) {
return false;
}
if (this.isTrialAccount()) {
return false;
}
return this.isLimitExceeded();
},
},
mounted() {
if (this.isOnChatwootCloud) {
this.fetchLimits();
}
},
methods: {
fetchLimits() {
this.$store.dispatch('accounts/limits');
},
routeToBilling() {
this.$router.push({
name: 'billing_settings_index',
params: { accountId: this.accountId },
});
},
isTrialAccount() {
// check if account is less than 15 days old
const account = this.getAccount(this.accountId);
if (!account) return false;
const createdAt = new Date(account.created_at);
const diffDays = differenceInDays(new Date(), createdAt);
return diffDays <= 15;
},
isLimitExceeded() {
const account = this.getAccount(this.accountId);
if (!account) return false;
const { limits } = account;
if (!limits) return false;
const { conversation, non_web_inboxes: nonWebInboxes } = limits;
return this.testLimit(conversation) || this.testLimit(nonWebInboxes);
},
testLimit({ allowed, consumed }) {
return consumed > allowed;
},
},
};
</script>
<!-- eslint-disable-next-line vue/no-root-v-if -->
<template>
<Banner
v-if="shouldShowBanner"
color-scheme="alert"
:banner-message="bannerMessage"
:action-button-label="actionButtonMessage"
has-action-button
@primary-action="routeToBilling"
/>
</template>
@@ -134,7 +134,7 @@ useEmitter(CMD_RESOLVE_CONVERSATION, onCmdResolveConversation);
<template>
<div class="relative flex items-center justify-end resolve-actions">
<div
class="rounded-lg shadow outline-1 outline"
class="rounded-lg shadow outline-1 outline flex-shrink-0"
:class="!showOpenButton ? 'outline-n-container' : 'outline-transparent'"
>
<Button
@@ -178,7 +178,7 @@ useEmitter(CMD_RESOLVE_CONVERSATION, onCmdResolveConversation);
<div
v-if="showActionsDropdown"
v-on-clickaway="closeDropdown"
class="dropdown-pane dropdown-pane--open left-auto top-full mt-0.5 ltr:right-0 rtl:left-0 max-w-[12.5rem] min-w-[9.75rem]"
class="border rounded-lg shadow-lg border-n-strong dark:border-n-strong box-content p-2 w-fit z-10 bg-n-alpha-3 backdrop-blur-[100px] absolute block left-auto top-full mt-0.5 start-0 xl:start-auto xl:end-0 max-w-[12.5rem] min-w-[9.75rem] [&_ul>li]:mb-0"
>
<WootDropdownMenu class="mb-0">
<WootDropdownItem v-if="!isPending">
@@ -209,11 +209,3 @@ useEmitter(CMD_RESOLVE_CONVERSATION, onCmdResolveConversation);
</div>
</div>
</template>
<style lang="scss" scoped>
.dropdown-pane {
.dropdown-menu__item {
@apply mb-0;
}
}
</style>
@@ -1,30 +1,49 @@
<script setup>
import { ref, computed, onMounted, watchEffect } from 'vue';
import { ref, computed, onMounted } from 'vue';
import { useStore } from 'dashboard/composables/store';
import Copilot from 'dashboard/components-next/copilot/Copilot.vue';
import ConversationAPI from 'dashboard/api/inbox/conversation';
import { useMapGetter } from 'dashboard/composables/store';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useConfig } from 'dashboard/composables/useConfig';
import { useWindowSize } from '@vueuse/core';
import { vOnClickOutside } from '@vueuse/components';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import wootConstants from 'dashboard/constants/globals';
const props = defineProps({
conversationId: {
type: [Number, String],
required: true,
},
defineProps({
conversationInboxType: {
type: String,
required: true,
default: '',
},
});
const store = useStore();
const { uiSettings, updateUISettings } = useUISettings();
const { isEnterprise } = useConfig();
const { width: windowWidth } = useWindowSize();
const currentUser = useMapGetter('getCurrentUser');
const assistants = useMapGetter('captainAssistants/getRecords');
const uiFlags = useMapGetter('captainAssistants/getUIFlags');
const inboxAssistant = useMapGetter('getCopilotAssistant');
const { uiSettings, updateUISettings } = useUISettings();
const currentChat = useMapGetter('getSelectedChat');
const isSmallScreen = computed(
() => windowWidth.value < wootConstants.SMALL_SCREEN_BREAKPOINT
);
const selectedCopilotThreadId = ref(null);
const messages = computed(() =>
store.getters['copilotMessages/getMessagesByThreadId'](
selectedCopilotThreadId.value
)
);
const currentAccountId = useMapGetter('getCurrentAccountId');
const isFeatureEnabledonAccount = useMapGetter(
'accounts/isFeatureEnabledonAccount'
);
const messages = ref([]);
const isCaptainTyping = ref(false);
const selectedAssistantId = ref(null);
const activeAssistant = computed(() => {
@@ -48,6 +67,15 @@ const activeAssistant = computed(() => {
return assistants.value[0];
});
const closeCopilotPanel = () => {
if (isSmallScreen.value && uiSettings.value?.is_copilot_panel_open) {
updateUISettings({
is_contact_sidebar_open: false,
is_copilot_panel_open: false,
});
}
};
const setAssistant = async assistant => {
selectedAssistantId.value = assistant.id;
await updateUISettings({
@@ -55,68 +83,69 @@ const setAssistant = async assistant => {
});
};
const shouldShowCopilotPanel = computed(() => {
if (!isEnterprise) {
return false;
}
const isCaptainEnabled = isFeatureEnabledonAccount.value(
currentAccountId.value,
FEATURE_FLAGS.CAPTAIN
);
const { is_copilot_panel_open: isCopilotPanelOpen } = uiSettings.value;
return isCaptainEnabled && isCopilotPanelOpen && !uiFlags.value.fetchingList;
});
const handleReset = () => {
messages.value = [];
selectedCopilotThreadId.value = null;
};
const sendMessage = async message => {
// Add user message
messages.value.push({
id: messages.value.length + 1,
role: 'user',
content: message,
});
isCaptainTyping.value = true;
try {
const { data } = await ConversationAPI.requestCopilot(
props.conversationId,
{
previous_history: messages.value
.map(m => ({
role: m.role,
content: m.content,
}))
.slice(0, -1),
message,
assistant_id: selectedAssistantId.value,
}
);
messages.value.push({
id: new Date().getTime(),
role: 'assistant',
content: data.message,
if (selectedCopilotThreadId.value) {
await store.dispatch('copilotMessages/create', {
assistant_id: activeAssistant.value.id,
conversation_id: currentChat.value?.id,
threadId: selectedCopilotThreadId.value,
message,
});
} catch (error) {
// eslint-disable-next-line
console.log(error);
} finally {
isCaptainTyping.value = false;
} else {
const response = await store.dispatch('copilotThreads/create', {
assistant_id: activeAssistant.value.id,
conversation_id: currentChat.value?.id,
message,
});
selectedCopilotThreadId.value = response.id;
}
};
onMounted(() => {
store.dispatch('captainAssistants/get');
});
watchEffect(() => {
if (props.conversationId) {
store.dispatch('getInboxCaptainAssistantById', props.conversationId);
selectedAssistantId.value = activeAssistant.value?.id;
if (isEnterprise) {
store.dispatch('captainAssistants/get');
}
});
</script>
<template>
<Copilot
:messages="messages"
:support-agent="currentUser"
:is-captain-typing="isCaptainTyping"
:conversation-inbox-type="conversationInboxType"
:assistants="assistants"
:active-assistant="activeAssistant"
@set-assistant="setAssistant"
@send-message="sendMessage"
@reset="handleReset"
/>
<div
v-if="shouldShowCopilotPanel"
v-on-click-outside="() => closeCopilotPanel()"
class="bg-n-background h-full overflow-hidden flex-col fixed top-0 ltr:right-0 rtl:left-0 z-40 w-full max-w-sm transition-transform duration-300 ease-in-out md:static md:w-[320px] md:min-w-[320px] ltr:border-l rtl:border-r border-n-weak 2xl:min-w-[360px] 2xl:w-[360px] shadow-lg md:shadow-none"
:class="[
{
'md:flex': shouldShowCopilotPanel,
'md:hidden': !shouldShowCopilotPanel,
},
]"
>
<Copilot
:messages="messages"
:support-agent="currentUser"
:conversation-inbox-type="conversationInboxType"
:assistants="assistants"
:active-assistant="activeAssistant"
@set-assistant="setAssistant"
@send-message="sendMessage"
@reset="handleReset"
/>
</div>
<template v-else />
</template>
@@ -1,6 +1,5 @@
// [NOTE][DEPRECATED] This method is to be deprecated, please do not add new components to this file.
/* eslint no-plusplus: 0 */
import AvatarUploader from './widgets/forms/AvatarUploader.vue';
import Code from './Code.vue';
import ColorPicker from './widgets/ColorPicker.vue';
import ConfirmDeleteModal from './widgets/modal/ConfirmDeleteModal.vue';
@@ -15,15 +14,12 @@ import Label from './ui/Label.vue';
import LoadingState from './widgets/LoadingState.vue';
import ModalHeader from './ModalHeader.vue';
import Modal from './Modal.vue';
import SidemenuIcon from './SidemenuIcon.vue';
import Spinner from 'shared/components/Spinner.vue';
import Tabs from './ui/Tabs/Tabs.vue';
import TabsItem from './ui/Tabs/TabsItem.vue';
import Thumbnail from './widgets/Thumbnail.vue';
import DatePicker from './ui/DatePicker/DatePicker.vue';
const WootUIKit = {
AvatarUploader,
Code,
ColorPicker,
ConfirmDeleteModal,
@@ -38,11 +34,9 @@ const WootUIKit = {
LoadingState,
Modal,
ModalHeader,
SidemenuIcon,
Spinner,
Tabs,
TabsItem,
Thumbnail,
DatePicker,
install(Vue) {
const keys = Object.keys(this);
@@ -1,145 +0,0 @@
<script>
import { mapGetters } from 'vuex';
import { useAlert } from 'dashboard/composables';
import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue';
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue';
import WootDropdownHeader from 'shared/components/ui/dropdown/DropdownHeader.vue';
import WootDropdownDivider from 'shared/components/ui/dropdown/DropdownDivider.vue';
import AvailabilityStatusBadge from '../widgets/conversation/AvailabilityStatusBadge.vue';
import wootConstants from 'dashboard/constants/globals';
import NextButton from 'dashboard/components-next/button/Button.vue';
const { AVAILABILITY_STATUS_KEYS } = wootConstants;
export default {
components: {
WootDropdownHeader,
WootDropdownDivider,
WootDropdownMenu,
WootDropdownItem,
AvailabilityStatusBadge,
NextButton,
},
data() {
return {
isStatusMenuOpened: false,
isUpdating: false,
};
},
computed: {
...mapGetters({
getCurrentUserAvailability: 'getCurrentUserAvailability',
currentAccountId: 'getCurrentAccountId',
currentUserAutoOffline: 'getCurrentUserAutoOffline',
}),
statusList() {
return [
this.$t('PROFILE_SETTINGS.FORM.AVAILABILITY.STATUS.ONLINE'),
this.$t('PROFILE_SETTINGS.FORM.AVAILABILITY.STATUS.BUSY'),
this.$t('PROFILE_SETTINGS.FORM.AVAILABILITY.STATUS.OFFLINE'),
];
},
availabilityDisplayLabel() {
const availabilityIndex = AVAILABILITY_STATUS_KEYS.findIndex(
key => key === this.currentUserAvailability
);
return this.statusList[availabilityIndex];
},
currentUserAvailability() {
return this.getCurrentUserAvailability;
},
availabilityStatuses() {
return this.statusList.map((statusLabel, index) => ({
label: statusLabel,
value: AVAILABILITY_STATUS_KEYS[index],
disabled:
this.currentUserAvailability === AVAILABILITY_STATUS_KEYS[index],
}));
},
},
methods: {
openStatusMenu() {
this.isStatusMenuOpened = true;
},
closeStatusMenu() {
this.isStatusMenuOpened = false;
},
updateAutoOffline(autoOffline) {
this.$store.dispatch('updateAutoOffline', {
accountId: this.currentAccountId,
autoOffline,
});
},
changeAvailabilityStatus(availability) {
if (this.isUpdating) {
return;
}
this.isUpdating = true;
try {
this.$store.dispatch('updateAvailability', {
availability,
account_id: this.currentAccountId,
});
} catch (error) {
useAlert(
this.$t('PROFILE_SETTINGS.FORM.AVAILABILITY.SET_AVAILABILITY_ERROR')
);
} finally {
this.isUpdating = false;
}
},
},
};
</script>
<template>
<WootDropdownMenu>
<WootDropdownHeader :title="$t('SIDEBAR.SET_AVAILABILITY_TITLE')" />
<WootDropdownItem
v-for="status in availabilityStatuses"
:key="status.value"
class="flex items-baseline"
>
<NextButton
sm
:color="status.disabled ? 'blue' : 'slate'"
:variant="status.disabled ? 'faded' : 'ghost'"
class="status-change--dropdown-button !w-full !justify-start"
@click="changeAvailabilityStatus(status.value)"
>
<AvailabilityStatusBadge :status="status.value" />
<span class="min-w-0 truncate font-medium text-xs">
{{ status.label }}
</span>
</NextButton>
</WootDropdownItem>
<WootDropdownDivider />
<WootDropdownItem class="flex items-center justify-between px-3 py-2 m-0">
<div class="flex items-center">
<fluent-icon
v-tooltip.right-start="$t('SIDEBAR.SET_AUTO_OFFLINE.INFO_TEXT')"
icon="info"
size="14"
class="mt-px"
/>
<span
class="mx-2 my-0 text-xs font-medium text-slate-600 dark:text-slate-100"
>
{{ $t('SIDEBAR.SET_AUTO_OFFLINE.TEXT') }}
</span>
</div>
<woot-switch
size="small"
class="mx-1 mt-px mb-0"
:model-value="currentUserAutoOffline"
@input="updateAutoOffline"
/>
</WootDropdownItem>
<WootDropdownDivider />
</WootDropdownMenu>
</template>
@@ -1,246 +0,0 @@
<script>
import { mapGetters } from 'vuex';
import { getSidebarItems } from './config/default-sidebar';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { useAccount } from 'dashboard/composables/useAccount';
import { useRoute, useRouter } from 'vue-router';
import PrimarySidebar from './sidebarComponents/Primary.vue';
import SecondarySidebar from './sidebarComponents/Secondary.vue';
import { routesWithPermissions } from '../../routes';
import {
getUserPermissions,
hasPermissions,
} from '../../helper/permissionsHelper';
export default {
components: {
PrimarySidebar,
SecondarySidebar,
},
props: {
showSecondarySidebar: {
type: Boolean,
default: true,
},
},
emits: [
'toggleAccountModal',
'showAddLabelPopup',
'openNotificationPanel',
'closeKeyShortcutModal',
'openKeyShortcutModal',
],
setup(props, { emit }) {
const route = useRoute();
const router = useRouter();
const { accountId } = useAccount();
const toggleKeyShortcutModal = () => {
emit('openKeyShortcutModal');
};
const closeKeyShortcutModal = () => {
emit('closeKeyShortcutModal');
};
const isCurrentRouteSameAsNavigation = routeName => {
return route.name === routeName;
};
const navigateToRoute = routeName => {
if (!isCurrentRouteSameAsNavigation(routeName)) {
router.push({ name: routeName });
}
};
const keyboardEvents = {
'$mod+Slash': {
action: toggleKeyShortcutModal,
},
'$mod+Escape': {
action: closeKeyShortcutModal,
},
'Alt+KeyC': {
action: () => navigateToRoute('home'),
},
'Alt+KeyV': {
action: () => navigateToRoute('contacts_dashboard'),
},
'Alt+KeyR': {
action: () => navigateToRoute('account_overview_reports'),
},
'Alt+KeyS': {
action: () => navigateToRoute('agent_list'),
},
};
useKeyboardEvents(keyboardEvents);
return {
toggleKeyShortcutModal,
accountId,
};
},
data() {
return {
showOptionsMenu: false,
};
},
computed: {
...mapGetters({
currentUser: 'getCurrentUser',
globalConfig: 'globalConfig/get',
inboxes: 'inboxes/getInboxes',
isACustomBrandedInstance: 'globalConfig/isACustomBrandedInstance',
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
isOnChatwootCloud: 'globalConfig/isOnChatwootCloud',
labels: 'labels/getLabelsOnSidebar',
teams: 'teams/getMyTeams',
}),
activeCustomView() {
if (this.activePrimaryMenu.key === 'contacts') {
return 'contact';
}
if (this.activePrimaryMenu.key === 'conversations') {
return 'conversation';
}
return '';
},
customViews() {
if (!this.activeCustomView) {
return [];
}
return this.$store.getters['customViews/getCustomViewsByFilterType'](
this.activeCustomView
);
},
isConversationOrContactActive() {
return (
this.activePrimaryMenu.key === 'contacts' ||
this.activePrimaryMenu.key === 'conversations'
);
},
sideMenuConfig() {
return getSidebarItems(this.accountId);
},
primaryMenuItems() {
const userPermissions = getUserPermissions(
this.currentUser,
this.accountId
);
const menuItems = this.sideMenuConfig.primaryMenu;
return menuItems.filter(menuItem => {
const isAvailableForTheUser = hasPermissions(
routesWithPermissions[menuItem.toStateName],
userPermissions
);
if (!isAvailableForTheUser) {
return false;
}
if (
menuItem.alwaysVisibleOnChatwootInstances &&
!this.isACustomBrandedInstance
) {
return true;
}
if (menuItem.featureFlag) {
return this.isFeatureEnabledonAccount(
this.accountId,
menuItem.featureFlag
);
}
return true;
});
},
activeSecondaryMenu() {
const { secondaryMenu } = this.sideMenuConfig;
const { name: currentRoute } = this.$route;
const activeSecondaryMenu =
secondaryMenu.find(menuItem =>
menuItem.routes.includes(currentRoute)
) || {};
return activeSecondaryMenu;
},
activePrimaryMenu() {
const activePrimaryMenu =
this.primaryMenuItems.find(
menuItem => menuItem.key === this.activeSecondaryMenu.parentNav
) || {};
return activePrimaryMenu;
},
hasSecondaryMenu() {
return (
this.activeSecondaryMenu.menuItems &&
this.activeSecondaryMenu.menuItems.length
);
},
hasSecondarySidebar() {
// if it is explicitly stated to show and it has secondary menu items to show
// showSecondarySidebar corresponds to the UI settings, indicating if the user has toggled it
return this.showSecondarySidebar && this.hasSecondaryMenu;
},
},
watch: {
activeCustomView() {
this.fetchCustomViews();
},
},
mounted() {
this.$store.dispatch('labels/get');
this.$store.dispatch('inboxes/get');
this.$store.dispatch('notifications/unReadCount');
this.$store.dispatch('teams/get');
this.$store.dispatch('attributes/get');
this.fetchCustomViews();
},
methods: {
fetchCustomViews() {
if (this.isConversationOrContactActive) {
this.$store.dispatch('customViews/get', this.activeCustomView);
}
},
toggleSupportChatWindow() {
window.$chatwoot.toggle();
},
toggleAccountModal() {
this.$emit('toggleAccountModal');
},
showAddLabelPopup() {
this.$emit('showAddLabelPopup');
},
openNotificationPanel() {
this.$emit('openNotificationPanel');
},
},
};
</script>
<template>
<aside class="flex h-full">
<PrimarySidebar
:logo-source="globalConfig.logoThumbnail"
:installation-name="globalConfig.installationName"
:is-a-custom-branded-instance="isACustomBrandedInstance"
:account-id="accountId"
:menu-items="primaryMenuItems"
:active-menu-item="activePrimaryMenu.key"
@toggle-accounts="toggleAccountModal"
@open-key-shortcut-modal="toggleKeyShortcutModal"
@open-notification-panel="openNotificationPanel"
/>
<SecondarySidebar
v-if="hasSecondarySidebar"
:account-id="accountId"
:inboxes="inboxes"
:labels="labels"
:teams="teams"
:custom-views="customViews"
:menu-config="activeSecondaryMenu"
:current-user="currentUser"
:is-on-chatwoot-cloud="isOnChatwootCloud"
@add-label="showAddLabelPopup"
@toggle-accounts="toggleAccountModal"
/>
</aside>
</template>
@@ -1,19 +0,0 @@
import conversations from './sidebarItems/conversations';
import contacts from './sidebarItems/contacts';
import reports from './sidebarItems/reports';
import campaigns from './sidebarItems/campaigns';
import settings from './sidebarItems/settings';
import notifications from './sidebarItems/notifications';
import primaryMenu from './sidebarItems/primaryMenu';
export const getSidebarItems = accountId => ({
primaryMenu: primaryMenu(accountId),
secondaryMenu: [
conversations(accountId),
contacts(accountId),
reports(accountId),
campaigns(accountId),
settings(accountId),
notifications(accountId),
],
});
@@ -1,26 +0,0 @@
import { frontendURL } from '../../../../helper/URLHelper';
const campaigns = accountId => ({
parentNav: 'campaigns',
routes: ['campaigns_sms_index', 'campaigns_livechat_index'],
menuItems: [
{
icon: 'arrow-swap',
label: 'LIVE_CHAT',
key: 'ongoingCampaigns',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/campaigns/live_chat`),
toStateName: 'campaigns_livechat_index',
},
{
key: 'oneOffCampaigns',
icon: 'sound-source',
label: 'SMS',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/campaigns/sms`),
toStateName: 'campaigns_sms_index',
},
],
});
export default campaigns;
@@ -1,24 +0,0 @@
import { frontendURL } from '../../../../helper/URLHelper';
const contacts = accountId => ({
parentNav: 'contacts',
routes: [
'contacts_dashboard_index',
'contacts_dashboard_segments_index',
'contacts_dashboard_labels_index',
'contacts_edit',
'contacts_edit_segment',
'contacts_edit_label',
],
menuItems: [
{
icon: 'contact-card-group',
label: 'ALL_CONTACTS',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/contacts?page=1`),
toStateName: 'contacts_dashboard_index',
},
],
});
export default contacts;
@@ -1,50 +0,0 @@
import { frontendURL } from '../../../../helper/URLHelper';
const conversations = accountId => ({
parentNav: 'conversations',
routes: [
'home',
'inbox_dashboard',
'inbox_conversation',
'conversation_through_inbox',
'notifications_dashboard',
'label_conversations',
'conversations_through_label',
'team_conversations',
'conversations_through_team',
'conversation_mentions',
'conversation_through_mentions',
'conversation_participating',
'conversation_through_participating',
'folder_conversations',
'conversations_through_folders',
'conversation_unattended',
'conversation_through_unattended',
],
menuItems: [
{
icon: 'chat',
label: 'ALL_CONVERSATIONS',
key: 'conversations',
toState: frontendURL(`accounts/${accountId}/dashboard`),
toolTip: 'Conversation from all subscribed inboxes',
toStateName: 'home',
},
{
icon: 'mention',
label: 'MENTIONED_CONVERSATIONS',
key: 'conversation_mentions',
toState: frontendURL(`accounts/${accountId}/mentions/conversations`),
toStateName: 'conversation_mentions',
},
{
icon: 'mail-unread',
label: 'UNATTENDED_CONVERSATIONS',
key: 'conversation_unattended',
toState: frontendURL(`accounts/${accountId}/unattended/conversations`),
toStateName: 'conversation_unattended',
},
],
});
export default conversations;
@@ -1,7 +0,0 @@
const notifications = () => ({
parentNav: 'notifications',
routes: ['notifications_index'],
menuItems: [],
});
export default notifications;
@@ -1,70 +0,0 @@
import { FEATURE_FLAGS } from '../../../../featureFlags';
import { frontendURL } from '../../../../helper/URLHelper';
const primaryMenuItems = accountId => [
{
icon: 'mail-inbox',
key: 'inboxView',
label: 'INBOX_VIEW',
featureFlag: FEATURE_FLAGS.INBOX_VIEW,
toState: frontendURL(`accounts/${accountId}/inbox-view`),
toStateName: 'inbox_view',
},
{
icon: 'chat',
key: 'conversations',
label: 'CONVERSATIONS',
toState: frontendURL(`accounts/${accountId}/dashboard`),
toStateName: 'home',
},
{
icon: 'captain',
key: 'captain',
label: 'CAPTAIN',
featureFlag: FEATURE_FLAGS.CAPTAIN,
toState: frontendURL(`accounts/${accountId}/captain/documents`),
toStateName: 'captain',
},
{
icon: 'book-contacts',
key: 'contacts',
label: 'CONTACTS',
featureFlag: FEATURE_FLAGS.CRM,
toState: frontendURL(`accounts/${accountId}/contacts`),
toStateName: 'contacts_dashboard_index',
},
{
icon: 'arrow-trending-lines',
key: 'reports',
label: 'REPORTS',
featureFlag: FEATURE_FLAGS.REPORTS,
toState: frontendURL(`accounts/${accountId}/reports`),
toStateName: 'account_overview_reports',
},
{
icon: 'megaphone',
key: 'campaigns',
label: 'CAMPAIGNS',
featureFlag: FEATURE_FLAGS.CAMPAIGNS,
toState: frontendURL(`accounts/${accountId}/campaigns`),
toStateName: 'campaigns_ongoing_index',
},
{
icon: 'library',
key: 'helpcenter',
label: 'HELP_CENTER.TITLE',
featureFlag: FEATURE_FLAGS.HELP_CENTER,
alwaysVisibleOnChatwootInstances: true,
toState: frontendURL(`accounts/${accountId}/portals/portal_articles_index`),
toStateName: 'portals_index',
},
{
icon: 'settings',
key: 'settings',
label: 'SETTINGS',
toState: frontendURL(`accounts/${accountId}/settings`),
toStateName: 'settings_home',
},
];
export default primaryMenuItems;
@@ -1,7 +0,0 @@
const profileSettings = () => ({
parentNav: 'profileSettings',
routes: ['profile_settings_index'],
menuItems: [],
});
export default profileSettings;
@@ -1,87 +0,0 @@
import { FEATURE_FLAGS } from '../../../../featureFlags';
import { frontendURL } from '../../../../helper/URLHelper';
const reports = accountId => ({
parentNav: 'reports',
routes: [
'account_overview_reports',
'conversation_reports',
'csat_reports',
'bot_reports',
'agent_reports',
'label_reports',
'inbox_reports',
'inbox_reports_show',
'team_reports',
'sla_reports',
],
menuItems: [
{
icon: 'arrow-trending-lines',
label: 'REPORTS_OVERVIEW',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/reports/overview`),
toStateName: 'account_overview_reports',
},
{
icon: 'chat',
label: 'REPORTS_CONVERSATION',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/reports/conversation`),
toStateName: 'conversation_reports',
},
{
icon: 'emoji',
label: 'CSAT',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/reports/csat`),
toStateName: 'csat_reports',
},
{
icon: 'bot',
label: 'REPORTS_BOT',
hasSubMenu: false,
featureFlag: FEATURE_FLAGS.RESPONSE_BOT,
toState: frontendURL(`accounts/${accountId}/reports/bot`),
toStateName: 'bot_reports',
},
{
icon: 'people',
label: 'REPORTS_AGENT',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/reports/agent`),
toStateName: 'agent_reports',
},
{
icon: 'tag',
label: 'REPORTS_LABEL',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/reports/label`),
toStateName: 'label_reports',
},
{
icon: 'mail-inbox-all',
label: 'REPORTS_INBOX',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/reports/inboxes`),
toStateName: 'inbox_reports',
},
{
icon: 'people-team',
label: 'REPORTS_TEAM',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/reports/teams`),
toStateName: 'team_reports',
},
{
icon: 'document-list-clock',
label: 'REPORTS_SLA',
hasSubMenu: false,
featureFlag: FEATURE_FLAGS.SLA,
toState: frontendURL(`accounts/${accountId}/reports/sla`),
toStateName: 'sla_reports',
},
],
});
export default reports;
@@ -1,220 +0,0 @@
import { FEATURE_FLAGS } from '../../../../featureFlags';
import { frontendURL } from '../../../../helper/URLHelper';
const settings = accountId => ({
parentNav: 'settings',
routes: [
'agent_bots',
'agent_list',
'attributes_list',
'automation_list',
'auditlogs_list',
'billing_settings_index',
'canned_list',
'general_settings_index',
'general_settings',
'labels_list',
'macros_edit',
'macros_new',
'macros_wrapper',
'settings_applications_integration',
'settings_applications_webhook',
'settings_applications',
'settings_inbox_finish',
'settings_inbox_list',
'settings_inbox_new',
'settings_inbox_show',
'settings_inboxes_add_agents',
'settings_inboxes_page_channel',
'settings_integrations_dashboard_apps',
'settings_integrations_integration',
'settings_integrations_slack',
'settings_integrations_webhook',
'settings_integrations',
'settings_teams_add_agents',
'settings_teams_edit_finish',
'settings_teams_edit_members',
'settings_teams_edit',
'settings_teams_finish',
'settings_teams_list',
'settings_teams_new',
'sla_list',
'custom_roles_list',
],
menuItems: [
{
icon: 'briefcase',
label: 'ACCOUNT_SETTINGS',
hasSubMenu: false,
meta: {
permissions: ['administrator'],
},
toState: frontendURL(`accounts/${accountId}/settings/general`),
toStateName: 'general_settings_index',
},
{
icon: 'people',
label: 'AGENTS',
hasSubMenu: false,
meta: {
permissions: ['administrator'],
},
toState: frontendURL(`accounts/${accountId}/settings/agents/list`),
toStateName: 'agent_list',
featureFlag: FEATURE_FLAGS.AGENT_MANAGEMENT,
},
{
icon: 'people-team',
label: 'TEAMS',
hasSubMenu: false,
meta: {
permissions: ['administrator'],
},
toState: frontendURL(`accounts/${accountId}/settings/teams/list`),
toStateName: 'settings_teams_list',
featureFlag: FEATURE_FLAGS.TEAM_MANAGEMENT,
},
{
icon: 'mail-inbox-all',
label: 'INBOXES',
hasSubMenu: false,
meta: {
permissions: ['administrator'],
},
toState: frontendURL(`accounts/${accountId}/settings/inboxes/list`),
toStateName: 'settings_inbox_list',
featureFlag: FEATURE_FLAGS.INBOX_MANAGEMENT,
},
{
icon: 'tag',
label: 'LABELS',
hasSubMenu: false,
meta: {
permissions: ['administrator'],
},
toState: frontendURL(`accounts/${accountId}/settings/labels/list`),
toStateName: 'labels_list',
featureFlag: FEATURE_FLAGS.LABELS,
},
{
icon: 'code',
label: 'CUSTOM_ATTRIBUTES',
hasSubMenu: false,
meta: {
permissions: ['administrator'],
},
toState: frontendURL(
`accounts/${accountId}/settings/custom-attributes/list`
),
toStateName: 'attributes_list',
featureFlag: FEATURE_FLAGS.CUSTOM_ATTRIBUTES,
},
{
icon: 'automation',
label: 'AUTOMATION',
hasSubMenu: false,
meta: {
permissions: ['administrator'],
},
toState: frontendURL(`accounts/${accountId}/settings/automation/list`),
toStateName: 'automation_list',
featureFlag: FEATURE_FLAGS.AUTOMATIONS,
},
{
icon: 'bot',
label: 'AGENT_BOTS',
hasSubMenu: false,
meta: {
permissions: ['administrator'],
},
toState: frontendURL(`accounts/${accountId}/settings/agent-bots`),
toStateName: 'agent_bots',
featureFlag: FEATURE_FLAGS.AGENT_BOTS,
},
{
icon: 'flash-settings',
label: 'MACROS',
hasSubMenu: false,
meta: {
permissions: ['administrator', 'agent'],
},
toState: frontendURL(`accounts/${accountId}/settings/macros`),
toStateName: 'macros_wrapper',
featureFlag: FEATURE_FLAGS.MACROS,
},
{
icon: 'chat-multiple',
label: 'CANNED_RESPONSES',
hasSubMenu: false,
meta: {
permissions: ['administrator', 'agent'],
},
toState: frontendURL(
`accounts/${accountId}/settings/canned-response/list`
),
toStateName: 'canned_list',
featureFlag: FEATURE_FLAGS.CANNED_RESPONSES,
},
{
icon: 'flash-on',
label: 'INTEGRATIONS',
hasSubMenu: false,
meta: {
permissions: ['administrator'],
},
toState: frontendURL(`accounts/${accountId}/settings/integrations`),
toStateName: 'settings_applications',
featureFlag: FEATURE_FLAGS.INTEGRATIONS,
},
{
icon: 'key',
label: 'AUDIT_LOGS',
hasSubMenu: false,
meta: {
permissions: ['administrator'],
},
toState: frontendURL(`accounts/${accountId}/settings/audit-logs/list`),
toStateName: 'auditlogs_list',
isEnterpriseOnly: true,
featureFlag: FEATURE_FLAGS.AUDIT_LOGS,
},
{
icon: 'scan-person',
label: 'CUSTOM_ROLES',
hasSubMenu: false,
meta: {
permissions: ['administrator'],
},
toState: frontendURL(`accounts/${accountId}/settings/custom-roles/list`),
toStateName: 'custom_roles_list',
isEnterpriseOnly: true,
beta: true,
},
{
icon: 'document-list-clock',
label: 'SLA',
hasSubMenu: false,
meta: {
permissions: ['administrator'],
},
toState: frontendURL(`accounts/${accountId}/settings/sla/list`),
toStateName: 'sla_list',
isEnterpriseOnly: true,
featureFlag: FEATURE_FLAGS.SLA,
beta: true,
},
{
icon: 'credit-card-person',
label: 'BILLING',
hasSubMenu: false,
meta: {
permissions: ['administrator'],
},
toState: frontendURL(`accounts/${accountId}/settings/billing`),
toStateName: 'billing_settings_index',
showOnlyOnCloud: true,
},
],
});
export default settings;
@@ -1,110 +0,0 @@
<script>
import { mapGetters } from 'vuex';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: {
NextButton,
},
emits: ['toggleAccounts'],
data() {
return { showSwitchButton: false };
},
computed: {
...mapGetters({
account: 'getCurrentAccount',
userAccounts: 'getUserAccounts',
}),
showShowCurrentAccountContext() {
return this.userAccounts.length > 1 && this.account.name;
},
},
methods: {
setShowSwitch() {
this.showSwitchButton = true;
},
resetShowSwitch() {
this.showSwitchButton = false;
},
},
};
</script>
<!-- eslint-disable-next-line vue/no-root-v-if -->
<template>
<div
v-if="showShowCurrentAccountContext"
class="relative px-2 py-2 mt-2 text-xs border rounded-md cursor-pointer text-slate-700 dark:text-slate-200 border-slate-50 dark:border-slate-800/50 hover:bg-slate-50 dark:hover:bg-slate-800"
@mouseover="setShowSwitch"
@mouseleave="resetShowSwitch"
>
{{ $t('SIDEBAR.CURRENTLY_VIEWING_ACCOUNT') }}
<p
class="mb-0 overflow-hidden font-medium text-ellipsis whitespace-nowrap text-slate-800 dark:text-slate-100"
>
{{ account.name }}
</p>
<transition name="fade">
<div
v-if="showSwitchButton"
class="absolute top-0 right-0 flex items-center justify-end w-full h-full rounded-md ltr:overlay-shadow ltr:dark:overlay-shadow-dark rtl:rtl-overlay-shadow rtl:dark:rtl-overlay-shadow-dark"
>
<div class="mx-2 my-0">
<NextButton
ghost
xs
icon="i-lucide-arrow-right-left"
:label="$t('SIDEBAR.SWITCH')"
@click="$emit('toggleAccounts')"
/>
</div>
</div>
</transition>
</div>
</template>
<style scoped>
@tailwind components;
@layer components {
.overlay-shadow {
background-image: linear-gradient(
to right,
rgba(255, 255, 255, 0) 0%,
rgba(255, 255, 255, 1) 50%
);
}
.overlay-shadow-dark {
background-image: linear-gradient(
to right,
rgba(0, 0, 0, 0) 0%,
rgb(21, 23, 24) 50%
);
}
.rtl-overlay-shadow {
background-image: linear-gradient(
to left,
rgba(255, 255, 255, 0) 0%,
rgba(255, 255, 255, 1) 50%
);
}
.rtl-overlay-shadow-dark {
background-image: linear-gradient(
to left,
rgba(0, 0, 0, 0) 0%,
rgb(21, 23, 24) 50%
);
}
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 300ms ease;
}
.fade-enter,
.fade-leave-to {
@apply opacity-0;
}
</style>
@@ -1,88 +0,0 @@
<script>
import { mapGetters } from 'vuex';
export default {
props: {
showAccountModal: {
type: Boolean,
default: true,
},
},
emits: ['closeAccountModal', 'showCreateAccountModal'],
computed: {
...mapGetters({
accountId: 'getCurrentAccountId',
currentUser: 'getCurrentUser',
globalConfig: 'globalConfig/get',
}),
},
methods: {
onChangeAccount(accountId) {
const accountUrl = `/app/accounts/${accountId}/dashboard`;
window.location.href = accountUrl;
},
},
};
</script>
<template>
<woot-modal
:show="showAccountModal"
:on-close="() => $emit('closeAccountModal')"
>
<woot-modal-header
:header-title="$t('SIDEBAR_ITEMS.CHANGE_ACCOUNTS')"
:header-content="$t('SIDEBAR_ITEMS.SELECTOR_SUBTITLE')"
/>
<div class="px-8 py-4">
<div
v-for="account in currentUser.accounts"
:id="`account-${account.id}`"
:key="account.id"
class="pt-0 pb-0"
>
<button
class="flex items-center justify-between w-full px-4 py-3 rounded-lg cursor-pointer expanded clear link hover:underline hover:bg-slate-25 dark:hover:bg-slate-900"
@click="onChangeAccount(account.id)"
>
<span class="w-full">
<label :for="account.name" class="text-left rtl:text-right">
<div
class="text-lg font-medium leading-5 text-slate-700 dark:text-slate-100 hover:underline-offset-4"
>
{{ account.name }}
</div>
<div
class="text-xs font-medium lowercase text-slate-500 dark:text-slate-500 hover:underline-offset-4"
>
{{
account.custom_role_id
? account.custom_role.name
: account.role
}}
</div>
</label>
</span>
<fluent-icon
v-show="account.id === accountId"
class="text-slate-800 dark:text-slate-100"
icon="checkmark-circle"
type="solid"
size="24"
/>
</button>
</div>
</div>
<div
v-if="globalConfig.createNewAccountFromDashboard"
class="flex items-center justify-end gap-2 px-8 pt-4 pb-8"
>
<button
class="w-full button success large expanded nice"
@click="$emit('showCreateAccountModal')"
>
{{ $t('CREATE_ACCOUNT.NEW_ACCOUNT') }}
</button>
</div>
</woot-modal>
</template>
@@ -1,45 +0,0 @@
<script>
import { mapGetters } from 'vuex';
import Thumbnail from '../../widgets/Thumbnail.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: {
Thumbnail,
NextButton,
},
emits: ['toggleMenu'],
computed: {
...mapGetters({
currentUser: 'getCurrentUser',
currentUserAvailability: 'getCurrentUserAvailability',
}),
statusOfAgent() {
return this.currentUserAvailability || 'offline';
},
},
methods: {
handleClick() {
this.$emit('toggleMenu');
},
},
};
</script>
<template>
<NextButton
v-tooltip.right="$t(`SIDEBAR.PROFILE_SETTINGS`)"
link
class="rounded-full"
@click="handleClick"
>
<Thumbnail
:src="currentUser.avatar_url"
:username="currentUser.name"
:status="statusOfAgent"
should-show-status-always
size="32px"
class="flex-shrink-0"
/>
</NextButton>
</template>
@@ -1,33 +0,0 @@
<script>
import { frontendURL } from 'dashboard/helper/URLHelper';
export default {
props: {
source: {
type: String,
default: '',
},
name: {
type: String,
default: '',
},
accountId: {
type: Number,
default: 0,
},
},
computed: {
dashboardPath() {
return frontendURL(`accounts/${this.accountId}/dashboard`);
},
},
};
</script>
<template>
<div class="w-8 h-8">
<router-link :to="dashboardPath" replace>
<img :src="source" :alt="name" />
</router-link>
</div>
</template>
@@ -1,58 +0,0 @@
<script>
import { mapGetters } from 'vuex';
export default {
emits: ['openNotificationPanel'],
computed: {
...mapGetters({
notificationMetadata: 'notifications/getMeta',
}),
unreadCount() {
if (!this.notificationMetadata.unreadCount) {
return '';
}
return this.notificationMetadata.unreadCount < 100
? `${this.notificationMetadata.unreadCount}`
: '99+';
},
isNotificationPanelActive() {
return this.$route.name === 'notifications_index';
},
},
methods: {
openNotificationPanel() {
if (this.$route.name !== 'notifications_index') {
this.$emit('openNotificationPanel');
}
},
},
};
</script>
<template>
<div class="mb-4">
<button
class="relative flex items-center justify-center w-10 h-10 p-0 my-2 rounded-lg text-slate-600 dark:text-slate-100 hover:bg-slate-25 dark:hover:bg-slate-700 dark:hover:text-slate-100 hover:text-slate-600"
:class="{
'bg-woot-50 dark:bg-slate-800 text-woot-500 hover:bg-woot-50':
isNotificationPanelActive,
}"
@click="openNotificationPanel"
>
<fluent-icon
icon="alert"
:class="{
'text-woot-500': isNotificationPanelActive,
}"
/>
<span
v-if="unreadCount"
class="text-black-900 bg-yellow-300 absolute -top-0.5 -right-1 text-xxs min-w-[1rem] rounded-full"
>
{{ unreadCount }}
</span>
</button>
</div>
</template>
@@ -1,204 +0,0 @@
<script>
import { mapGetters } from 'vuex';
import Auth from '../../../api/auth';
import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue';
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue';
import AvailabilityStatus from 'dashboard/components/layout/AvailabilityStatus.vue';
import { FEATURE_FLAGS } from '../../../featureFlags';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: {
WootDropdownMenu,
WootDropdownItem,
AvailabilityStatus,
NextButton,
},
props: {
show: {
type: Boolean,
default: false,
},
},
emits: [
'close',
'openKeyShortcutModal',
'toggleAccounts',
'showSupportChatWindow',
],
computed: {
...mapGetters({
currentUser: 'getCurrentUser',
globalConfig: 'globalConfig/get',
accountId: 'getCurrentAccountId',
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
}),
showChangeAccountOption() {
if (this.globalConfig.createNewAccountFromDashboard) {
return true;
}
const { accounts = [] } = this.currentUser;
return accounts.length > 1;
},
showChatSupport() {
return (
this.isFeatureEnabledonAccount(
this.accountId,
FEATURE_FLAGS.CONTACT_CHATWOOT_SUPPORT_TEAM
) && this.globalConfig.chatwootInboxToken
);
},
},
methods: {
handleProfileSettingClick(e, navigate) {
this.$emit('close');
navigate(e);
},
handleKeyboardHelpClick() {
this.$emit('openKeyShortcutModal');
this.$emit('close');
},
logout() {
Auth.logout();
},
onClickAway() {
if (this.show) this.$emit('close');
},
openAppearanceOptions() {
const ninja = document.querySelector('ninja-keys');
ninja.open({ parent: 'appearance_settings' });
},
},
};
</script>
<template>
<transition name="menu-slide">
<div
v-if="show"
v-on-clickaway="onClickAway"
class="absolute z-30 w-64 px-2 py-2 bg-white border rounded-md shadow-xl left-3 rtl:left-auto rtl:right-3 bottom-16 dark:bg-slate-800 border-slate-25 dark:border-slate-700"
:class="{ 'block visible': show }"
>
<AvailabilityStatus />
<WootDropdownMenu>
<WootDropdownItem v-if="showChangeAccountOption">
<NextButton
ghost
sm
slate
icon="i-lucide-arrow-right-left"
class="!w-full !justify-start"
@click="$emit('toggleAccounts')"
>
<span class="min-w-0 truncate font-medium text-xs">
{{ $t('SIDEBAR_ITEMS.CHANGE_ACCOUNTS') }}
</span>
</NextButton>
</WootDropdownItem>
<WootDropdownItem v-if="showChatSupport">
<NextButton
ghost
sm
slate
icon="i-lucide-message-circle-question"
class="!w-full !justify-start"
@click="$emit('showSupportChatWindow')"
>
<span class="min-w-0 truncate font-medium text-xs">
{{ $t('SIDEBAR_ITEMS.CONTACT_SUPPORT') }}
</span>
</NextButton>
</WootDropdownItem>
<WootDropdownItem>
<NextButton
ghost
sm
slate
icon="i-lucide-keyboard"
class="!w-full !justify-start"
@click="handleKeyboardHelpClick"
>
<span class="min-w-0 truncate font-medium text-xs">
{{ $t('SIDEBAR_ITEMS.KEYBOARD_SHORTCUTS') }}
</span>
</NextButton>
</WootDropdownItem>
<WootDropdownItem>
<router-link
v-slot="{ href, isActive, navigate }"
:to="`/app/accounts/${accountId}/profile/settings`"
custom
>
<a
:href="href"
:class="{ 'is-active': isActive }"
@click="e => handleProfileSettingClick(e, navigate)"
>
<NextButton
ghost
sm
slate
icon="i-lucide-circle-user"
class="!w-full !justify-start"
>
<span class="min-w-0 truncate font-medium text-xs">
{{ $t('SIDEBAR_ITEMS.PROFILE_SETTINGS') }}
</span>
</NextButton>
</a>
</router-link>
</WootDropdownItem>
<WootDropdownItem>
<NextButton
ghost
sm
slate
icon="i-lucide-sun-moon"
class="!w-full !justify-start"
@click="openAppearanceOptions"
>
<span class="min-w-0 truncate font-medium text-xs">
{{ $t('SIDEBAR_ITEMS.APPEARANCE') }}
</span>
</NextButton>
</WootDropdownItem>
<WootDropdownItem v-if="currentUser.type === 'SuperAdmin'">
<a
href="/super_admin"
target="_blank"
rel="noopener nofollow noreferrer"
@click="$emit('close')"
>
<NextButton
ghost
sm
slate
icon="i-lucide-layout-dashboard"
class="!w-full !justify-start"
>
<span class="min-w-0 truncate font-medium text-xs">
{{ $t('SIDEBAR_ITEMS.SUPER_ADMIN_CONSOLE') }}
</span>
</NextButton>
</a>
</WootDropdownItem>
<WootDropdownItem>
<NextButton
ghost
sm
slate
icon="i-lucide-circle-power"
class="!w-full !justify-start"
@click="logout"
>
<span class="min-w-0 truncate font-medium text-xs">
{{ $t('SIDEBAR_ITEMS.LOGOUT') }}
</span>
</NextButton>
</WootDropdownItem>
</WootDropdownMenu>
</div>
</transition>
</template>
@@ -1,116 +0,0 @@
<script>
import Logo from './Logo.vue';
import PrimaryNavItem from './PrimaryNavItem.vue';
import OptionsMenu from './OptionsMenu.vue';
import AgentDetails from './AgentDetails.vue';
import NotificationBell from './NotificationBell.vue';
import wootConstants from 'dashboard/constants/globals';
import { frontendURL } from 'dashboard/helper/URLHelper';
import { ACCOUNT_EVENTS } from '../../../helper/AnalyticsHelper/events';
import { useTrack } from 'dashboard/composables';
export default {
components: {
Logo,
PrimaryNavItem,
OptionsMenu,
AgentDetails,
NotificationBell,
},
props: {
isACustomBrandedInstance: {
type: Boolean,
default: false,
},
logoSource: {
type: String,
default: '',
},
installationName: {
type: String,
default: '',
},
accountId: {
type: Number,
default: 0,
},
menuItems: {
type: Array,
default: () => [],
},
activeMenuItem: {
type: String,
default: '',
},
},
emits: ['toggleAccounts', 'openNotificationPanel', 'openKeyShortcutModal'],
data() {
return {
helpDocsURL: wootConstants.DOCS_URL,
showOptionsMenu: false,
};
},
methods: {
frontendURL,
toggleOptions() {
this.showOptionsMenu = !this.showOptionsMenu;
},
toggleAccountModal() {
this.$emit('toggleAccounts');
},
toggleSupportChatWindow() {
window.$chatwoot.toggle();
},
openNotificationPanel() {
useTrack(ACCOUNT_EVENTS.OPENED_NOTIFICATIONS);
this.$emit('openNotificationPanel');
},
},
};
</script>
<template>
<div
class="flex flex-col justify-between w-16 h-full bg-white border-r dark:bg-slate-900 border-slate-50 dark:border-slate-800/50 rtl:border-l rtl:border-r-0"
>
<div class="flex flex-col items-center">
<Logo
:source="logoSource"
:name="installationName"
:account-id="accountId"
class="m-4 mb-10"
/>
<PrimaryNavItem
v-for="menuItem in menuItems"
:id="menuItem.key"
:key="menuItem.toState"
:icon="menuItem.icon"
:name="menuItem.label"
:to="menuItem.toState"
:is-child-menu-active="menuItem.key === activeMenuItem"
/>
</div>
<div class="flex flex-col items-center justify-end pb-6">
<a
v-if="!isACustomBrandedInstance"
v-tooltip.right="$t(`SIDEBAR.DOCS`)"
:href="helpDocsURL"
class="relative flex items-center justify-center w-10 h-10 my-2 rounded-lg text-slate-700 dark:text-slate-100 hover:bg-slate-25 dark:hover:bg-slate-700 dark:hover:text-slate-100 hover:text-slate-600"
rel="noopener noreferrer nofollow"
target="_blank"
>
<fluent-icon icon="book-open-globe" />
<span class="sr-only">{{ $t(`SIDEBAR.DOCS`) }}</span>
</a>
<NotificationBell @open-notification-panel="openNotificationPanel" />
<AgentDetails @toggle-menu="toggleOptions" />
<OptionsMenu
:show="showOptionsMenu"
@toggle-accounts="toggleAccountModal"
@show-support-chat-window="toggleSupportChatWindow"
@open-key-shortcut-modal="$emit('openKeyShortcutModal')"
@close="toggleOptions"
/>
</div>
</div>
</template>
@@ -1,148 +0,0 @@
<script>
import { OnClickOutside } from '@vueuse/components';
import { HELP_CENTER_MENU_ITEMS } from 'dashboard/helper/portalHelper';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
export default {
components: {
DropdownMenu,
OnClickOutside,
},
props: {
to: {
type: String,
default: '',
},
id: {
type: String,
default: '',
},
name: {
type: String,
default: '',
},
icon: {
type: String,
default: '',
},
count: {
type: String,
default: '',
},
isChildMenuActive: {
type: Boolean,
default: false,
},
openInNewPage: {
type: Boolean,
default: false,
},
},
data() {
return {
helpCenterMenu: HELP_CENTER_MENU_ITEMS,
showHelpCenterMenu: false,
};
},
computed: {
helpCenterMenuItems() {
return this.helpCenterMenu.map(item => ({
...item,
isSelected: this.isSelectedMenuItem(item),
}));
},
isHelpCenter() {
return this.id === 'helpcenter';
},
isHelpCenterSelected() {
const routes = [
'portals_new',
'portals_index',
'portals_articles_index',
'portals_articles_new',
'portals_articles_edit',
'portals_categories_index',
'portals_categories_articles_index',
'portals_categories_articles_edit',
'portals_locales_index',
'portals_settings_index',
];
return routes.includes(this.$route.name);
},
},
methods: {
isSelectedMenuItem(menuItem) {
return menuItem.value.includes(this.$route.name);
},
toggleHelpCenterMenu() {
this.showHelpCenterMenu = !this.showHelpCenterMenu;
},
handleHelpCenterAction({ action }) {
this.$router.push({
name: 'portals_index',
params: {
navigationPath: action,
},
});
},
},
};
</script>
<template>
<OnClickOutside v-if="isHelpCenter" @trigger="showHelpCenterMenu = false">
<button
v-tooltip.top="$t(`SIDEBAR.${name}`)"
class="relative flex items-center justify-center w-10 h-10 my-2 rounded-lg text-slate-700 dark:text-slate-100 hover:!bg-slate-25 dark:hover:!bg-slate-700 dark:hover:text-slate-100 hover:text-slate-600"
:class="{
'bg-woot-50 dark:bg-slate-800 text-woot-500 hover:bg-woot-50':
isHelpCenterSelected,
}"
@click="toggleHelpCenterMenu"
>
<fluent-icon
:icon="icon"
:class="{
'text-woot-500': isHelpCenterSelected,
}"
/>
<DropdownMenu
v-if="showHelpCenterMenu && isHelpCenter"
:menu-items="helpCenterMenuItems"
class="ltr:left-10 rtl:right-10 w-36 z-[100] top-0 overflow-y-auto max-h-52"
@action="handleHelpCenterAction"
/>
</button>
</OnClickOutside>
<router-link v-else v-slot="{ href, isActive, navigate }" :to="to" custom>
<a
v-tooltip.right="$t(`SIDEBAR.${name}`)"
:href="href"
class="relative flex items-center justify-center w-10 h-10 my-2 rounded-lg text-slate-700 dark:text-slate-100 hover:bg-slate-25 dark:hover:bg-slate-700 dark:hover:text-slate-100 hover:text-slate-600"
:class="{
'bg-woot-50 dark:bg-slate-800 text-woot-500 hover:bg-woot-50':
isActive || isChildMenuActive,
}"
:rel="openInNewPage ? 'noopener noreferrer nofollow' : undefined"
:target="openInNewPage ? '_blank' : undefined"
@click="navigate"
>
<fluent-icon
:icon="icon"
:class="{
'text-woot-500': isActive || isChildMenuActive,
}"
/>
<span class="sr-only">{{ name }}</span>
<span
v-if="count"
class="absolute bg-yellow-500 text-black-900 -top-1 -right-1"
>
{{ count }}
</span>
</a>
</router-link>
</template>
@@ -1,265 +0,0 @@
<script>
import { frontendURL } from '../../../helper/URLHelper';
import SecondaryNavItem from './SecondaryNavItem.vue';
import AccountContext from './AccountContext.vue';
import { mapGetters } from 'vuex';
import { FEATURE_FLAGS } from '../../../featureFlags';
import {
getUserPermissions,
hasPermissions,
} from '../../../helper/permissionsHelper';
import { routesWithPermissions } from '../../../routes';
export default {
components: {
AccountContext,
SecondaryNavItem,
},
props: {
accountId: {
type: Number,
default: 0,
},
labels: {
type: Array,
default: () => [],
},
inboxes: {
type: Array,
default: () => [],
},
teams: {
type: Array,
default: () => [],
},
customViews: {
type: Array,
default: () => [],
},
menuConfig: {
type: Object,
default: () => {},
},
currentUser: {
type: Object,
default: () => {},
},
isOnChatwootCloud: {
type: Boolean,
default: false,
},
},
emits: ['addLabel', 'toggleAccounts'],
computed: {
...mapGetters({
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
}),
contactCustomViews() {
return this.customViews.filter(view => view.filter_type === 'contact');
},
accessibleMenuItems() {
const menuItemsFilteredByPermissions = this.menuConfig.menuItems.filter(
menuItem => {
const userPermissions = getUserPermissions(
this.currentUser,
this.accountId
);
return hasPermissions(
routesWithPermissions[menuItem.toStateName],
userPermissions
);
}
);
return menuItemsFilteredByPermissions.filter(item => {
if (item.showOnlyOnCloud) {
return this.isOnChatwootCloud;
}
return true;
});
},
inboxSection() {
return {
icon: 'folder',
label: 'INBOXES',
hasSubMenu: true,
newLink: this.showNewLink(FEATURE_FLAGS.INBOX_MANAGEMENT),
newLinkTag: 'NEW_INBOX',
key: 'inbox',
toState: frontendURL(`accounts/${this.accountId}/settings/inboxes/new`),
toStateName: 'settings_inbox_new',
newLinkRouteName: 'settings_inbox_new',
children: this.inboxes
.map(inbox => ({
id: inbox.id,
label: inbox.name,
truncateLabel: true,
toState: frontendURL(
`accounts/${this.accountId}/inbox/${inbox.id}`
),
type: inbox.channel_type,
phoneNumber: inbox.phone_number,
reauthorizationRequired: inbox.reauthorization_required,
}))
.sort((a, b) =>
a.label.toLowerCase() > b.label.toLowerCase() ? 1 : -1
),
};
},
labelSection() {
return {
icon: 'number-symbol',
label: 'LABELS',
hasSubMenu: true,
newLink: this.showNewLink(FEATURE_FLAGS.TEAM_MANAGEMENT),
newLinkTag: 'NEW_LABEL',
key: 'label',
toState: frontendURL(`accounts/${this.accountId}/settings/labels`),
toStateName: 'labels_list',
showModalForNewItem: true,
modalName: 'AddLabel',
dataTestid: 'sidebar-new-label-button',
children: this.labels.map(label => ({
id: label.id,
label: label.title,
color: label.color,
truncateLabel: true,
toState: frontendURL(
`accounts/${this.accountId}/label/${label.title}`
),
})),
};
},
contactLabelSection() {
return {
icon: 'number-symbol',
label: 'TAGGED_WITH',
hasSubMenu: true,
key: 'labels',
newLink: this.showNewLink(FEATURE_FLAGS.TEAM_MANAGEMENT),
newLinkTag: 'NEW_LABEL',
toState: frontendURL(`accounts/${this.accountId}/settings/labels`),
toStateName: 'labels_list',
showModalForNewItem: true,
modalName: 'AddLabel',
children: this.labels.map(label => ({
id: label.id,
label: label.title,
color: label.color,
truncateLabel: true,
toState: frontendURL(
`accounts/${this.accountId}/contacts/labels/${label.title}`
),
})),
};
},
teamSection() {
return {
icon: 'people-team',
label: 'TEAMS',
hasSubMenu: true,
newLink: this.showNewLink(FEATURE_FLAGS.TEAM_MANAGEMENT),
newLinkTag: 'NEW_TEAM',
key: 'team',
toState: frontendURL(`accounts/${this.accountId}/settings/teams/new`),
toStateName: 'settings_teams_new',
newLinkRouteName: 'settings_teams_new',
children: this.teams.map(team => ({
id: team.id,
label: team.name,
truncateLabel: true,
toState: frontendURL(`accounts/${this.accountId}/team/${team.id}`),
})),
};
},
foldersSection() {
return {
icon: 'folder',
label: 'CUSTOM_VIEWS_FOLDER',
hasSubMenu: true,
key: 'custom_view',
children: this.customViews
.filter(view => view.filter_type === 'conversation')
.map(view => ({
id: view.id,
label: view.name,
truncateLabel: true,
toState: frontendURL(
`accounts/${this.accountId}/custom_view/${view.id}`
),
})),
};
},
contactSegmentsSection() {
return {
icon: 'folder',
label: 'CUSTOM_VIEWS_SEGMENTS',
hasSubMenu: true,
key: 'segments',
children: this.customViews
.filter(view => view.filter_type === 'contact')
.map(view => ({
id: view.id,
label: view.name,
truncateLabel: true,
toState: frontendURL(
`accounts/${this.accountId}/contacts/segments/${view.id}`
),
})),
};
},
additionalSecondaryMenuItems() {
let conversationMenuItems = [this.inboxSection, this.labelSection];
let contactMenuItems = [this.contactLabelSection];
if (this.teams.length) {
conversationMenuItems = [this.teamSection, ...conversationMenuItems];
}
if (this.customViews.length) {
conversationMenuItems = [this.foldersSection, ...conversationMenuItems];
}
if (this.contactCustomViews.length) {
contactMenuItems = [this.contactSegmentsSection, ...contactMenuItems];
}
return {
conversations: conversationMenuItems,
contacts: contactMenuItems,
};
},
},
methods: {
showAddLabelPopup() {
this.$emit('addLabel');
},
toggleAccountModal() {
this.$emit('toggleAccounts');
},
showNewLink(featureFlag) {
return this.isFeatureEnabledonAccount(this.accountId, featureFlag);
},
},
};
</script>
<template>
<div
class="flex flex-col w-48 h-full px-2 pb-8 overflow-auto text-sm bg-white border-r dark:bg-slate-900 dark:border-slate-800/50 rtl:border-r-0 rtl:border-l border-slate-50"
>
<AccountContext @toggle-accounts="toggleAccountModal" />
<transition-group
name="menu-list"
tag="ul"
class="pt-2 list-none reset-base"
>
<SecondaryNavItem
v-for="menuItem in accessibleMenuItems"
:key="menuItem.toState"
:menu-item="menuItem"
/>
<SecondaryNavItem
v-for="menuItem in additionalSecondaryMenuItems[menuConfig.parentNav]"
:key="menuItem.key"
:menu-item="menuItem"
@add-label="showAddLabelPopup"
/>
</transition-group>
</div>
</template>
@@ -1,132 +0,0 @@
<script>
export default {
props: {
to: {
type: String,
default: '',
},
label: {
type: String,
default: '',
},
labelColor: {
type: String,
default: '',
},
shouldTruncate: {
type: Boolean,
default: false,
},
icon: {
type: String,
default: '',
},
warningIcon: {
type: String,
default: '',
},
showChildCount: {
type: Boolean,
default: false,
},
childItemCount: {
type: Number,
default: 0,
},
},
computed: {
showIcon() {
return {
'overflow-hidden whitespace-nowrap text-ellipsis': this.shouldTruncate,
};
},
isCountZero() {
return this.childItemCount === 0;
},
menuTitle() {
return this.shouldTruncate ? this.label : '';
},
},
};
</script>
<template>
<router-link
v-slot="{ href, isActive, navigate }"
:to="to"
custom
active-class="active"
>
<li
class="h-7 my-1 hover:bg-slate-25 hover:text-bg-50 flex items-center px-2 rounded-md dark:hover:bg-slate-800"
:class="{
'bg-woot-25 dark:bg-slate-800': isActive,
'text-ellipsis overflow-hidden whitespace-nowrap max-w-full':
shouldTruncate,
}"
@click="navigate"
>
<a
:href="href"
class="inline-flex text-left max-w-full w-full items-center"
>
<span
v-if="icon"
class="inline-flex items-center justify-center w-4 rounded-sm bg-slate-100 dark:bg-slate-700 p-0.5 mr-1.5 rtl:mr-0 rtl:ml-1.5"
>
<fluent-icon
class="text-xxs text-slate-700 dark:text-slate-200"
:class="{
'text-woot-500 dark:text-woot-500': isActive,
}"
:icon="icon"
size="12"
/>
</span>
<span
v-if="labelColor"
class="inline-flex rounded-sm bg-slate-100 h-3 w-3.5 mr-1.5 rtl:mr-0 rtl:ml-1.5 border border-slate-50 dark:border-slate-900"
:style="{ backgroundColor: labelColor }"
/>
<div
class="items-center flex overflow-hidden whitespace-nowrap text-ellipsis w-full justify-between"
>
<span
:title="menuTitle"
class="text-sm text-slate-700 dark:text-slate-100"
:class="{
'text-woot-500 dark:text-woot-500': isActive,
'text-ellipsis overflow-hidden whitespace-nowrap max-w-full':
shouldTruncate,
}"
>
{{ label }}
</span>
<span
v-if="showChildCount"
class="bg-slate-50 dark:bg-slate-700 rounded-full min-w-[18px] justify-center items-center flex text-xxs mx-1 py-0 px-1"
:class="
isCountZero
? `text-slate-300 dark:text-slate-500`
: `text-slate-700 dark:text-slate-50`
"
>
{{ childItemCount }}
</span>
</div>
<span
v-if="warningIcon"
class="inline-flex mr-1 bg-red-50 dark:bg-red-900 p-0.5 rounded-sm"
>
<fluent-icon
v-tooltip.top-end="$t('SIDEBAR.REAUTHORIZE')"
class="text-xxs text-red-500 dark:text-red-300"
:icon="warningIcon"
size="12"
/>
</span>
</a>
</li>
</router-link>
</template>
@@ -1,277 +0,0 @@
<script>
import { mapGetters } from 'vuex';
import { useAdmin } from 'dashboard/composables/useAdmin';
import { useConfig } from 'dashboard/composables/useConfig';
import {
getInboxClassByType,
getInboxWarningIconClass,
} from 'dashboard/helper/inbox';
import SecondaryChildNavItem from './SecondaryChildNavItem.vue';
import {
isOnMentionsView,
isOnUnattendedView,
} from '../../../store/modules/conversations/helpers/actionHelpers';
import Policy from '../../policy.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: { SecondaryChildNavItem, Policy, NextButton },
props: {
menuItem: {
type: Object,
default: () => ({}),
},
},
emits: ['addLabel', 'open'],
setup() {
const { isAdmin } = useAdmin();
const { isEnterprise } = useConfig();
return {
isAdmin,
isEnterprise,
};
},
computed: {
...mapGetters({
activeInbox: 'getSelectedInbox',
accountId: 'getCurrentAccountId',
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
globalConfig: 'globalConfig/get',
}),
isCountZero() {
return this.menuItem.count === 0;
},
isActiveView() {
return this.computedClass.includes('active-view');
},
hasSubMenu() {
return !!this.menuItem.children;
},
isMenuItemVisible() {
let isFeatureEnabled = true;
if (this.menuItem.featureFlag) {
isFeatureEnabled = this.isFeatureEnabledonAccount(
this.accountId,
this.menuItem.featureFlag
);
}
if (this.menuItem.isEnterpriseOnly) {
if (!this.isEnterprise) return false;
return isFeatureEnabled || this.globalConfig.displayManifest;
}
if (this.menuItem.featureFlag) {
return this.isFeatureEnabledonAccount(
this.accountId,
this.menuItem.featureFlag
);
}
return isFeatureEnabled;
},
isAllConversations() {
return (
this.$store.state.route.name === 'inbox_conversation' &&
this.menuItem.toStateName === 'home'
);
},
isMentions() {
return (
isOnMentionsView({ route: this.$route }) &&
this.menuItem.toStateName === 'conversation_mentions'
);
},
isUnattended() {
return (
isOnUnattendedView({ route: this.$route }) &&
this.menuItem.toStateName === 'conversation_unattended'
);
},
isTeamsSettings() {
return (
this.$store.state.route.name === 'settings_teams_edit' &&
this.menuItem.toStateName === 'settings_teams_list'
);
},
isInboxSettings() {
return (
this.$route.name === 'settings_inbox_show' &&
this.menuItem.toStateName === 'settings_inbox_list'
);
},
isIntegrationsSettings() {
return (
this.$store.state.route.name === 'settings_integrations_webhook' &&
this.menuItem.toStateName === 'settings_integrations'
);
},
isApplicationsSettings() {
return (
this.$store.state.route.name === 'settings_applications_integration' &&
this.menuItem.toStateName === 'settings_applications'
);
},
isContactsDefaultRoute() {
return (
this.menuItem.toStateName === 'contacts_dashboard_index' &&
(this.$store.state.route.name === 'contacts_dashboard_index' ||
this.$store.state.route.name === 'contacts_edit')
);
},
isCurrentRoute() {
return this.$store.state.route.name.includes(this.menuItem.toStateName);
},
computedClass() {
// If active inbox is present, do not highlight conversations
if (this.activeInbox) return ' ';
if (
this.isAllConversations ||
this.isMentions ||
this.isUnattended ||
this.isContactsDefaultRoute ||
this.isCurrentRoute
) {
return 'bg-woot-25 dark:bg-slate-800 text-woot-500 dark:text-woot-500 hover:text-woot-500 dark:hover:text-woot-500 active-view';
}
if (this.hasSubMenu) {
if (
this.isTeamsSettings ||
this.isInboxSettings ||
this.isIntegrationsSettings ||
this.isApplicationsSettings
) {
return 'bg-woot-25 dark:bg-slate-800 text-woot-500 dark:text-woot-500 hover:text-woot-500 dark:hover:text-woot-500 active-view';
}
return 'hover:text-slate-700 dark:hover:text-slate-100';
}
return 'hover:text-slate-700 dark:hover:text-slate-100';
},
},
methods: {
computedInboxClass(child) {
const { type, phoneNumber } = child;
if (!type) return '';
const classByType = getInboxClassByType(type, phoneNumber);
return classByType;
},
computedInboxErrorClass(child) {
const { type, reauthorizationRequired } = child;
if (!type) return '';
const warningClass = getInboxWarningIconClass(
type,
reauthorizationRequired
);
return warningClass;
},
newLinkClick(e, navigate) {
if (this.menuItem.newLinkRouteName) {
navigate(e);
} else if (this.menuItem.showModalForNewItem) {
if (this.menuItem.modalName === 'AddLabel') {
e.preventDefault();
this.$emit('addLabel');
}
}
},
showItem(item) {
return this.isAdmin && !!item.newLink;
},
onClickOpen() {
this.$emit('open');
},
showChildCount(count) {
return Number.isInteger(count);
},
},
};
</script>
<template>
<li v-show="isMenuItemVisible" class="mt-1">
<div v-if="hasSubMenu" class="flex justify-between">
<span
class="px-2 pt-1 my-2 text-sm font-semibold text-slate-700 dark:text-slate-200"
>
{{ $t(`SIDEBAR.${menuItem.label}`) }}
</span>
<div v-if="menuItem.showNewButton" class="flex items-center">
<NextButton ghost xs slate icon="i-lucide-plus" @click="onClickOpen" />
</div>
</div>
<router-link
v-else
class="flex items-center p-2 m-0 text-sm leading-4 rounded-lg text-slate-700 dark:text-slate-100 hover:bg-slate-25 dark:hover:bg-slate-800"
:class="computedClass"
:to="menuItem && menuItem.toState"
>
<fluent-icon
:icon="menuItem.icon"
class="min-w-[1rem] mr-1.5 rtl:mr-0 rtl:ml-1.5"
size="14"
/>
{{ $t(`SIDEBAR.${menuItem.label}`) }}
<span
v-if="showChildCount(menuItem.count)"
class="px-1 py-0 mx-1 rounded-md text-xxs"
:class="{
'text-slate-300 dark:text-slate-600': isCountZero && !isActiveView,
'text-slate-600 dark:text-slate-50': !isCountZero && !isActiveView,
'bg-woot-75 dark:bg-woot-200 text-woot-600 dark:text-woot-600':
isActiveView,
'bg-slate-50 dark:bg-slate-700': !isActiveView,
}"
>
{{ `${menuItem.count}` }}
</span>
<span
v-if="menuItem.beta"
data-view-component="true"
label="Beta"
class="inline-block px-1 mx-1 leading-4 text-green-500 border border-green-400 rounded-lg text-xxs"
>
{{ $t('SIDEBAR.BETA') }}
</span>
</router-link>
<ul v-if="hasSubMenu" class="list-none reset-base">
<SecondaryChildNavItem
v-for="child in menuItem.children"
:key="child.id"
:to="child.toState"
:label="child.label"
:label-color="child.color"
:should-truncate="child.truncateLabel"
:icon="computedInboxClass(child)"
:warning-icon="computedInboxErrorClass(child)"
:show-child-count="showChildCount(child.count)"
:child-item-count="child.count"
/>
<Policy :permissions="['administrator']">
<router-link
v-if="menuItem.newLink"
v-slot="{ href, navigate }"
:to="menuItem.toState"
custom
>
<li class="pl-1">
<a :href="href">
<NextButton
ghost
xs
slate
icon="i-lucide-plus"
:label="$t(`SIDEBAR.${menuItem.newLinkTag}`)"
:data-testid="menuItem.dataTestid"
@click="e => newLinkClick(e, navigate)"
/>
</a>
</li>
</router-link>
</Policy>
</ul>
</li>
</template>
@@ -1,73 +0,0 @@
import { mount } from '@vue/test-utils';
import { createStore } from 'vuex';
import AccountSelector from '../AccountSelector.vue';
import WootModal from 'dashboard/components/Modal.vue';
import WootModalHeader from 'dashboard/components/ModalHeader.vue';
import FluentIcon from 'shared/components/FluentIcon/DashboardIcon.vue';
const store = createStore({
modules: {
auth: {
namespaced: false,
getters: {
getCurrentAccountId: () => 1,
getCurrentUser: () => ({
accounts: [
{ id: 1, name: 'Chatwoot', role: 'administrator' },
{ id: 2, name: 'GitX', role: 'agent' },
],
}),
},
},
globalConfig: {
namespaced: true,
getters: {
get: () => ({ createNewAccountFromDashboard: false }),
},
},
},
});
describe('AccountSelector', () => {
let accountSelector = null;
beforeEach(() => {
accountSelector = mount(AccountSelector, {
global: {
plugins: [store],
components: {
'woot-modal': WootModal,
'woot-modal-header': WootModalHeader,
'fluent-icon': FluentIcon,
},
stubs: {
// override global stub
WootModalHeader: false,
},
},
props: { showAccountModal: true },
});
});
it('title and sub title exist', () => {
const headerComponent = accountSelector.findComponent(WootModalHeader);
const title = headerComponent.find('[data-test-id="modal-header-title"]');
expect(title.text()).toBe('Switch account');
const content = headerComponent.find(
'[data-test-id="modal-header-content"]'
);
expect(content.text()).toBe('Select an account from the following list');
});
it('first account item is checked', () => {
const selectedAccountCheckmark = accountSelector.find(
'#account-1 > button > svg'
);
expect(selectedAccountCheckmark.exists()).toBe(true);
const otherAccountCheckmark = accountSelector.find(
'#account-2 > button > svg'
);
expect(otherAccountCheckmark.exists()).toBe(true);
});
});
@@ -1,61 +0,0 @@
import { shallowMount } from '@vue/test-utils';
import { createStore } from 'vuex';
import AgentDetails from '../AgentDetails.vue';
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
describe('AgentDetails', () => {
const currentUser = {
name: 'Neymar Junior',
avatar_url: '',
availability_status: 'online',
};
const currentRole = 'agent';
let store = null;
let agentDetails = null;
const mockTooltipDirective = {
mounted: (el, binding) => {
// You can mock the behavior here if necessary
el.setAttribute('data-tooltip', binding.value || '');
},
};
beforeEach(() => {
store = createStore({
modules: {
auth: {
namespaced: false,
getters: {
getCurrentUser: () => currentUser,
getCurrentRole: () => currentRole,
getCurrentUserAvailability: () => currentUser.availability_status,
},
},
},
});
agentDetails = shallowMount(AgentDetails, {
global: {
plugins: [store],
components: {
Thumbnail,
NextButton,
},
directives: {
tooltip: mockTooltipDirective, // Mocking the tooltip directive
},
stubs: { NextButton: { template: '<button><slot /></button>' } },
},
});
});
it('shows the correct agent status', () => {
expect(agentDetails.findComponent(Thumbnail).vm.status).toBe('online');
});
it('agent thumbnail exists', () => {
const thumbnailComponent = agentDetails.findComponent(Thumbnail);
expect(thumbnailComponent.exists()).toBe(true);
});
});
@@ -1,88 +0,0 @@
import { shallowMount } from '@vue/test-utils';
import { createStore } from 'vuex';
import FluentIcon from 'shared/components/FluentIcon/DashboardIcon.vue';
import NotificationBell from '../NotificationBell.vue';
const $route = {
name: 'notifications_index',
};
describe('notificationBell', () => {
const accountId = 1;
const notificationMetadata = { unreadCount: 19 };
let store = null;
let actions = null;
let modules = null;
beforeEach(() => {
actions = {
showNotification: vi.fn(),
};
modules = {
auth: {
namespaced: false,
getters: {
getCurrentAccountId: () => accountId,
},
},
notifications: {
namespaced: false,
getters: {
'notifications/getMeta': () => notificationMetadata,
},
},
};
store = createStore({
actions,
modules,
});
});
it('it should return unread count 19', () => {
const wrapper = shallowMount(NotificationBell, {
global: {
plugins: [store],
mocks: {
$route,
},
components: {
'fluent-icon': FluentIcon,
},
},
});
expect(wrapper.vm.unreadCount).toBe('19');
});
it('it should return unread count 99+', async () => {
notificationMetadata.unreadCount = 100;
const wrapper = shallowMount(NotificationBell, {
global: {
plugins: [store],
mocks: {
$route,
},
components: {
'fluent-icon': FluentIcon,
},
},
});
expect(wrapper.vm.unreadCount).toBe('99+');
});
it('isNotificationPanelActive', async () => {
const notificationBell = shallowMount(NotificationBell, {
global: {
plugins: [store],
mocks: {
$route,
},
components: {
'fluent-icon': FluentIcon,
},
},
});
expect(notificationBell.vm.isNotificationPanelActive).toBe(true);
});
});
@@ -1,71 +0,0 @@
import { mount } from '@vue/test-utils';
import { createStore } from 'vuex';
import AvailabilityStatus from '../AvailabilityStatus.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue';
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue';
import WootDropdownHeader from 'shared/components/ui/dropdown/DropdownHeader.vue';
import WootDropdownDivider from 'shared/components/ui/dropdown/DropdownDivider.vue';
import FluentIcon from 'shared/components/FluentIcon/DashboardIcon.vue';
describe('AvailabilityStatus', () => {
const currentAvailability = 'online';
const currentAccountId = '1';
const currentUserAutoOffline = false;
let store = null;
let actions = null;
beforeEach(() => {
actions = {
updateAvailability: vi.fn(() => Promise.resolve()),
};
store = createStore({
modules: {
auth: {
namespaced: false,
getters: {
getCurrentUserAvailability: () => currentAvailability,
getCurrentAccountId: () => currentAccountId,
getCurrentUserAutoOffline: () => currentUserAutoOffline,
},
},
},
actions,
});
});
it('dispatches an action when user changes status', async () => {
const wrapper = mount(AvailabilityStatus, {
global: {
plugins: [store],
components: {
NextButton,
WootDropdownItem,
WootDropdownMenu,
WootDropdownHeader,
WootDropdownDivider,
FluentIcon,
},
stubs: {
WootSwitch: { template: '<button />' },
},
},
});
// Ensure that the dropdown menu is opened
await wrapper.vm.openStatusMenu();
// Simulate the user clicking the 3rd button (offline status)
const buttons = wrapper.findAll('.status-change--dropdown-button');
expect(buttons.length).toBeGreaterThan(0); // Ensure buttons exist
await buttons[2].trigger('click');
expect(actions.updateAvailability).toHaveBeenCalledTimes(1);
expect(actions.updateAvailability.mock.calls[0][1]).toEqual({
availability: 'offline',
account_id: currentAccountId,
});
});
});
@@ -1,31 +0,0 @@
import { shallowMount } from '@vue/test-utils';
import { createStore } from 'vuex';
import SidemenuIcon from '../SidemenuIcon.vue';
const store = createStore({
modules: {
auth: {
namespaced: false,
getters: {
getCurrentAccountId: () => 1,
},
},
accounts: {
namespaced: true,
getters: {
isFeatureEnabledonAccount: () => () => false,
},
},
},
});
describe('SidemenuIcon', () => {
test('matches snapshot', () => {
const wrapper = shallowMount(SidemenuIcon, {
stubs: { NextButton: { template: '<button><slot /></button>' } },
global: { plugins: [store] },
});
expect(wrapper.vm).toBeTruthy();
expect(wrapper.element).toMatchSnapshot();
});
});
@@ -1,14 +0,0 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`SidemenuIcon > matches snapshot 1`] = `
<button
class="-ml-3"
ghost=""
icon="i-lucide-menu"
size="sm"
slate=""
>
</button>
`;
@@ -19,6 +19,6 @@ const isRTL = useMapGetter('accounts/isRTL');
<slot v-if="$slots.default || content">
<template v-if="content">{{ content }}</template>
</slot>
<span v-else class="text-slate-300 dark:text-slate-700"> --- </span>
<span v-else class="text-n-slate-10"> --- </span>
</div>
</template>
@@ -21,7 +21,7 @@ const props = defineProps({
const isRelaxed = computed(() => props.type === 'relaxed');
const headerClass = computed(() =>
isRelaxed.value
? 'first:rounded-bl-lg first:rounded-tl-lg last:rounded-br-lg last:rounded-tr-lg'
? 'ltr:first:rounded-bl-lg ltr:first:rounded-tl-lg ltr:last:rounded-br-lg ltr:last:rounded-tr-lg rtl:first:rounded-br-lg rtl:first:rounded-tr-lg rtl:last:rounded-bl-lg rtl:last:rounded-tl-lg'
: ''
);
</script>
@@ -118,13 +118,13 @@ export default {
<style lang="scss" scoped>
.banner {
&.primary {
@apply bg-woot-500 dark:bg-woot-500;
@apply bg-n-brand;
}
&.secondary {
@apply bg-n-slate-3 dark:bg-n-solid-3 text-n-slate-12;
a {
@apply text-slate-800 dark:text-slate-800;
@apply text-n-slate-12;
}
}
@@ -137,18 +137,18 @@ export default {
}
&.warning {
@apply bg-yellow-500 dark:bg-yellow-500 text-yellow-500 dark:text-yellow-500;
@apply bg-n-amber-5 text-n-amber-12;
a {
@apply text-yellow-500 dark:text-yellow-500;
@apply text-n-amber-12;
}
}
&.gray {
@apply text-black-500 dark:text-black-500;
@apply text-n-gray-10 dark:text-n-gray-10;
}
a {
@apply ml-1 underline text-white dark:text-white text-xs;
@apply ml-1 underline text-n-amber-12 text-xs;
}
.banner-message {
@@ -1,6 +1,15 @@
<script setup>
import { computed, onMounted, nextTick, useTemplateRef } from 'vue';
import { useWindowSize, useElementBounding } from '@vueuse/core';
import {
computed,
onMounted,
nextTick,
onUnmounted,
useTemplateRef,
inject,
} from 'vue';
import { useWindowSize, useElementBounding, useScrollLock } from '@vueuse/core';
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
const props = defineProps({
x: { type: Number, default: 0 },
@@ -9,27 +18,34 @@ const props = defineProps({
const emit = defineEmits(['close']);
const elementToLock = inject('contextMenuElementTarget', null);
const menuRef = useTemplateRef('menuRef');
const scrollLockElement = computed(() => {
if (!elementToLock?.value) return null;
return elementToLock.value?.$el;
});
const isLocked = useScrollLock(scrollLockElement);
const { width: windowWidth, height: windowHeight } = useWindowSize();
const { width: menuWidth, height: menuHeight } = useElementBounding(menuRef);
const calculatePosition = (x, y, menuW, menuH, windowW, windowH) => {
const PADDING = 16;
// Initial position
let left = x;
let top = y;
// Boundary checks
const isOverflowingRight = left + menuW > windowW;
const isOverflowingBottom = top + menuH > windowH;
const isOverflowingRight = left + menuW > windowW - PADDING;
const isOverflowingBottom = top + menuH > windowH - PADDING;
// Adjust position if overflowing
if (isOverflowingRight) left = windowW - menuW;
if (isOverflowingBottom) top = windowH - menuH;
if (isOverflowingRight) left = windowW - menuW - PADDING;
if (isOverflowingBottom) top = windowH - menuH - PADDING;
return {
left: Math.max(0, left),
top: Math.max(0, top),
left: Math.max(PADDING, left),
top: Math.max(PADDING, top),
};
};
@@ -52,20 +68,30 @@ const position = computed(() => {
});
onMounted(() => {
isLocked.value = true;
nextTick(() => menuRef.value?.focus());
});
const handleClose = () => {
isLocked.value = false;
emit('close');
};
onUnmounted(() => {
isLocked.value = false;
});
</script>
<template>
<Teleport to="body">
<TeleportWithDirection to="body">
<div
ref="menuRef"
class="fixed outline-none z-[9999] cursor-pointer"
:style="position"
tabindex="0"
@blur="emit('close')"
@blur="handleClose"
>
<slot />
</div>
</Teleport>
</TeleportWithDirection>
</template>
@@ -55,7 +55,7 @@ const validateDate = () => {
<template>
<div class="h-[82px] flex flex-col items-start px-5 gap-1.5 pt-4 w-full">
<span class="text-sm font-medium text-slate-800 dark:text-slate-50">
<span class="text-sm font-medium text-n-slate-12">
{{
calendarType === START_CALENDAR
? $t('DATE_PICKER.DATE_RANGE_INPUT.START')
@@ -51,21 +51,13 @@ const openDatePicker = () => {
class="inline-flex relative items-center rounded-lg gap-2 py-1.5 px-3 h-8 bg-n-alpha-2 hover:bg-n-alpha-1 active:bg-n-alpha-1"
@click="openDatePicker"
>
<fluent-icon
class="text-slate-800 dark:text-slate-50"
icon="calendar"
size="16"
/>
<span class="text-sm font-medium text-slate-800 dark:text-slate-50">
<fluent-icon class="text-n-slate-12" icon="calendar" size="16" />
<span class="text-sm font-medium text-n-slate-12">
{{ $t(activeDateRange) }}
</span>
<span class="text-sm font-medium text-slate-600 dark:text-slate-200">
<span class="text-sm font-medium text-n-slate-11">
{{ formatDateRange }}
</span>
<fluent-icon
class="text-slate-800 dark:text-slate-50"
icon="chevron-down"
size="14"
/>
<fluent-icon class="text-n-slate-12" icon="chevron-down" size="14" />
</button>
</template>
@@ -12,12 +12,12 @@ defineProps({
<fluent-icon
icon="info"
size="14"
class="mt-0.5 text-slate-600 absolute dark:text-slate-400"
class="mt-0.5 text-n-slate-11 absolute"
/>
<div
class="bg-white dark:bg-slate-900 w-fit ltr:left-4 rtl:right-4 top-0 border p-2.5 group-hover:flex items-center hidden absolute border-slate-75 dark:border-slate-800 rounded-lg shadow-md"
class="bg-n-background w-fit ltr:left-4 rtl:right-4 top-0 border p-2.5 group-hover:flex items-center hidden absolute border-n-weak rounded-lg shadow-md"
>
<p class="text-slate-800 dark:text-slate-75 mb-0 text-xs">
<p class="text-n-slate-12 mb-0 text-xs">
{{ message }}
</p>
</div>
@@ -113,7 +113,7 @@ export default {
<style scoped lang="scss">
.label {
@apply items-center font-medium text-xs rounded-[4px] gap-1 p-1 bg-slate-50 dark:bg-slate-700 text-slate-800 dark:text-slate-100 border border-solid border-n-strong h-6;
@apply items-center font-medium text-xs rounded-[4px] gap-1 p-1 bg-n-slate-3 text-n-slate-12 border border-solid border-n-strong h-6;
&.small {
@apply text-xs py-0.5 px-1 leading-tight h-5;
@@ -133,67 +133,71 @@ export default {
/* Color Schemes */
&.primary {
@apply bg-woot-100 dark:bg-woot-100 text-woot-900 dark:text-woot-900 border border-solid border-woot-200;
@apply bg-n-blue-5 text-n-blue-12 border border-solid border-n-blue-7;
a {
@apply text-woot-900 dark:text-woot-900;
@apply text-n-blue-12;
}
.label-color-dot {
@apply bg-woot-600 dark:bg-woot-600;
@apply bg-n-blue-9;
}
}
&.secondary {
@apply bg-slate-100 dark:bg-slate-700 text-slate-900 dark:text-slate-100 border border-solid border-n-weak;
@apply bg-n-slate-5 text-n-slate-12 border border-solid border-n-slate-7;
a {
@apply text-slate-900 dark:text-slate-100;
@apply text-n-slate-12;
}
.label-color-dot {
@apply bg-slate-600 dark:bg-slate-600;
@apply bg-n-slate-9;
}
}
&.success {
@apply bg-green-100 dark:bg-green-700 text-green-900 dark:text-green-100 border border-solid border-green-200 dark:border-green-600;
@apply bg-n-teal-5 text-n-teal-12 border border-solid border-n-teal-7;
a {
@apply text-green-900 dark:text-green-100;
@apply text-n-teal-12;
}
.label-color-dot {
@apply bg-green-600 dark:bg-green-600;
@apply bg-n-teal-9;
}
}
&.alert {
@apply bg-red-100 dark:bg-red-700 text-red-900 dark:text-red-100 border border-solid border-red-200 dark:border-red-600;
@apply bg-n-ruby-5 text-n-ruby-12 border border-solid border-n-ruby-7;
a {
@apply text-red-900 dark:text-red-100;
@apply text-n-ruby-12;
}
.label-color-dot {
@apply bg-red-600 dark:bg-red-600;
@apply bg-n-ruby-9;
}
}
&.warning {
@apply bg-yellow-100 dark:bg-yellow-700 text-yellow-900 dark:text-yellow-100 border border-solid border-yellow-200 dark:border-yellow-600;
@apply bg-n-amber-5 text-n-amber-12 border border-solid border-n-amber-7;
a {
@apply text-yellow-900 dark:text-yellow-100;
@apply text-n-amber-12;
}
.label-color-dot {
@apply bg-yellow-900 dark:bg-yellow-900;
@apply bg-n-amber-9;
}
}
&.smooth {
@apply bg-transparent text-slate-700 dark:text-slate-100 border border-solid border-n-strong;
@apply bg-transparent text-n-slate-11 dark:text-n-slate-12 border border-solid border-n-strong;
}
&.dashed {
@apply bg-transparent text-slate-700 dark:text-slate-100 border border-dashed border-n-strong;
@apply bg-transparent text-n-slate-11 dark:text-n-slate-12 border border-dashed border-n-strong;
}
}
.label-close--button {
@apply text-slate-800 dark:text-slate-100 -mb-0.5 rounded-sm cursor-pointer flex items-center justify-center hover:bg-slate-100 dark:hover:bg-slate-700;
@apply text-n-slate-11 -mb-0.5 rounded-sm cursor-pointer flex items-center justify-center hover:bg-n-slate-3;
svg {
@apply text-n-slate-11;
}
}
.label-action--button {
@@ -23,16 +23,17 @@ export default {
<template>
<div
class="flex flex-col min-w-[15rem] max-h-[21.25rem] max-w-[23.75rem] rounded-md border border-solid border-n-strong"
class="flex flex-col min-w-[15rem] max-h-[21.25rem] max-w-[23.75rem] rounded-md border border-solid"
:class="{
'bg-woot-25 dark:bg-n-solid-2 border border-solid border-n-blue-border':
active,
'bg-n-blue-1 dark:bg-n-solid-2 border-n-blue-4': active,
'border-n-weak': !active,
}"
>
<div
class="flex justify-between items-center rounded-t-md px-2 w-full h-10 bg-slate-50 dark:bg-slate-900 border-b border-solid border-n-strong"
class="flex justify-between items-center rounded-t-md px-2 w-full h-10 border-b border-solid"
:class="{
'bg-woot-50 border-b border-solid border-n-blue-border': active,
'bg-n-blue-2 border-n-blue-4': active,
'bg-n-slate-2 border-n-weak': !active,
}"
>
<div class="flex items-center p-1 text-sm font-medium">{{ heading }}</div>
@@ -41,23 +42,19 @@ export default {
icon="checkmark-circle"
type="solid"
size="24"
class="text-woot-500 dark:text-woot-500"
class="text-n-brand"
/>
</div>
<div
class="text-slate-700 dark:text-slate-200 text-xs leading-[1.4] px-3 pt-3 pb-0 text-start"
class="text-n-slate-11 text-xs leading-[1.4] px-3 pt-3 pb-0 text-start"
>
{{ content }}
</div>
<div v-if="src" class="p-3">
<img
:src="src"
class="border border-solid rounded-md"
:class="
active
? 'border-woot-75 dark:border-woot-700'
: 'border-slate-50 dark:border-slate-600'
"
class="border rounded-md"
:class="active ? 'border-n-blue-border' : 'border-n-weak'"
/>
</div>
<slot v-else />
@@ -1,84 +0,0 @@
<script>
export default {
props: {
modelValue: { type: Boolean, default: false },
size: { type: String, default: '' },
},
emits: ['update:modelValue', 'input'],
methods: {
onClick() {
this.$emit('update:modelValue', !this.modelValue);
this.$emit('input', !this.modelValue);
},
},
};
</script>
<template>
<button
type="button"
class="toggle-button p-0"
:class="{ active: modelValue, small: size === 'small' }"
role="switch"
:aria-checked="modelValue.toString()"
@click="onClick"
>
<span aria-hidden="true" :class="{ active: modelValue }" />
</button>
</template>
<style lang="scss" scoped>
.toggle-button {
@apply bg-slate-200 dark:bg-slate-600;
--toggle-button-box-shadow: rgb(255, 255, 255) 0px 0px 0px 0px,
rgba(59, 130, 246, 0.5) 0px 0px 0px 0px, rgba(0, 0, 0, 0.1) 0px 1px 3px 0px,
rgba(0, 0, 0, 0.06) 0px 1px 2px 0px;
border-radius: var(--border-radius-large);
border: 2px solid transparent;
cursor: pointer;
display: flex;
flex-shrink: 0;
height: 1.188rem;
position: relative;
transition-duration: 200ms;
transition-property: background-color;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
width: 2.125rem;
&.active {
background-color: var(--w-500);
}
&.small {
width: 1.375rem;
height: 0.875rem;
span {
height: var(--space-one);
width: var(--space-one);
&.active {
transform: translate(var(--space-small), var(--space-zero));
}
}
}
span {
@apply bg-white dark:bg-slate-900;
--space-one-point-five: 0.9375rem;
border-radius: 100%;
box-shadow: var(--toggle-button-box-shadow);
display: inline-block;
height: var(--space-one-point-five);
transform: translate(0, 0);
transition-duration: 200ms;
transition-property: transform;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
width: var(--space-one-point-five);
&.active {
transform: translate(var(--space-one-point-five), var(--space-zero));
}
}
}
</style>
@@ -11,10 +11,6 @@ const props = defineProps({
type: Boolean,
default: true,
},
isCompact: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['change']);
@@ -70,25 +66,28 @@ watch(
<template>
<div
ref="tabsContainer"
:class="{
'tabs--container--with-border': border,
'tabs--container--compact': isCompact,
}"
class="tabs--container"
class="flex"
:class="[border && 'border-b border-b-n-weak']"
>
<button
v-if="hasScroll"
class="tabs--scroll-button button clear secondary button--only-icon"
class="items-center rounded-none cursor-pointer flex h-auto justify-center min-w-8"
@click="onScrollClick('left')"
>
<fluent-icon icon="chevron-left" :size="16" />
</button>
<ul ref="tabsList" :class="{ 'tabs--with-scroll': hasScroll }" class="tabs">
<ul
ref="tabsList"
class="border-r-0 border-l-0 border-t-0 flex min-w-[6.25rem] py-0 px-4 list-none mb-0"
:class="
hasScroll ? 'overflow-hidden py-0 px-1 max-w-[calc(100%-64px)]' : ''
"
>
<slot />
</ul>
<button
v-if="hasScroll"
class="tabs--scroll-button button clear secondary button--only-icon"
class="items-center rounded-none cursor-pointer flex h-auto justify-center min-w-8"
@click="onScrollClick('right')"
>
<fluent-icon icon="chevron-right" :size="16" />
@@ -22,6 +22,10 @@ const props = defineProps({
type: Boolean,
default: true,
},
isCompact: {
type: Boolean,
default: false,
},
});
const activeIndex = inject('activeIndex');
@@ -40,14 +44,28 @@ const onTabClick = event => {
<template>
<li
class="tabs-title"
:class="{
'is-active': active,
}"
class="flex-shrink-0 my-0 mx-2 ltr:first:ml-0 rtl:first:mr-0 ltr:last:mr-0 rtl:last:ml-0 hover:text-n-slate-12"
>
<a @click="onTabClick">
<a
class="flex items-center flex-row border-b select-none cursor-pointer text-sm relative top-[1px] transition-[border-color] duration-[150ms] ease-[cubic-bezier(0.37,0,0.63,1)]"
:class="[
active
? 'border-b border-n-brand text-n-blue-text'
: 'border-transparent text-n-slate-11',
isCompact ? 'py-2 text-sm' : 'text-base py-3',
]"
@click="onTabClick"
>
{{ name }}
<div v-if="showBadge" class="badge min-w-[20px]">
<div
v-if="showBadge"
class="rounded-md h-5 flex items-center justify-center text-xxs font-semibold my-0 mx-1 px-1 py-0 min-w-[20px]"
:class="[
active
? 'bg-n-brand/10 dark:bg-n-brand/20 text-n-blue-text'
: 'bg-n-alpha-black2 dark:bg-n-solid-3 text-n-slate-11',
]"
>
<span>
{{ getItemCount }}
</span>
@@ -113,7 +113,7 @@ export default {
delay: { show: 1000, hide: 0 },
hideOnClick: true,
}"
class="ml-auto leading-4 text-xxs text-n-slate-10 hover:text-slate-11"
class="ml-auto leading-4 text-xxs text-n-slate-10 hover:text-n-slate-11"
>
<span>{{ `${createdAtTime}${lastActivityTime}` }}</span>
</div>
@@ -1,9 +1,5 @@
<script>
/* eslint no-console: 0 */
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
export default {
mixins: [globalConfigMixin],
props: {
items: {
type: Array,
@@ -44,21 +40,18 @@ export default {
>
<div class="flex items-center">
<h3
class="text-slate-800 dark:text-slate-100 text-base font-medium pl-6 overflow-hidden whitespace-nowrap mb-1.5 text-ellipsis leading-tight"
class="text-n-slate-12 text-base font-medium pl-6 overflow-hidden whitespace-nowrap mt-0.5 text-ellipsis leading-tight"
>
{{ item.title }}
</h3>
<span
v-if="isOver(item)"
class="ml-1 text-green-500 dark:text-green-500"
>
<span v-if="isOver(item)" class="mx-1 mt-0.5 text-n-teal-9">
<fluent-icon icon="checkmark" />
</span>
</div>
<span class="step">
{{ items.indexOf(item) + 1 }}
</span>
<p class="pl-6 m-0 text-sm text-slate-600 dark:text-slate-300">
<p class="pl-6 m-0 mt-1.5 text-sm text-n-slate-11">
{{ item.body }}
</p>
</div>
@@ -68,7 +61,7 @@ export default {
<style lang="scss" scoped>
.wizard-box {
.item {
@apply cursor-pointer after:bg-slate-75 before:bg-slate-75 dark:after:bg-slate-600 dark:before:bg-slate-600 py-4 ltr:pr-4 rtl:pl-4 ltr:pl-6 rtl:pr-6 relative before:h-4 before:top-0 last:before:h-0 first:before:h-0 last:after:h-0 before:content-[''] before:absolute before:w-0.5 after:content-[''] after:h-full after:absolute after:top-5 after:w-0.5 rtl:after:left-6 rtl:before:left-6;
@apply cursor-pointer after:bg-n-slate-6 before:bg-n-slate-6 py-4 ltr:pr-4 rtl:pl-4 ltr:pl-6 rtl:pr-6 relative before:h-4 before:top-0 last:before:h-0 first:before:h-0 last:after:h-0 before:content-[''] before:absolute before:w-0.5 after:content-[''] after:h-full after:absolute after:top-5 after:w-0.5 rtl:after:left-6 rtl:before:left-6;
&.active {
h3 {
@@ -97,7 +90,7 @@ export default {
}
.step {
@apply bg-slate-75 dark:bg-slate-600 rounded-2xl font-medium w-4 left-4 leading-4 z-10 absolute text-center text-white dark:text-white text-xxs top-5;
@apply bg-n-slate-7 rounded-2xl font-medium w-4 left-4 leading-4 z-10 absolute text-center text-white dark:text-white text-xxs top-5;
}
}
}
@@ -19,10 +19,10 @@ const onClick = () => {
/>
<div
class="radar-ping-animation absolute top-0 right-0 -mt-1 -mr-1 rounded-full w-3 h-3 bg-woot-500 dark:bg-woot-500"
class="radar-ping-animation absolute top-0 right-0 -mt-1 -mr-1 rounded-full w-3 h-3 bg-n-brand"
/>
<div
class="absolute top-0 right-0 -mt-1 -mr-1 rounded-full w-3 h-3 bg-woot-500 dark:bg-woot-500 opacity-50"
class="absolute top-0 right-0 -mt-1 -mr-1 rounded-full w-3 h-3 bg-n-brand opacity-50"
/>
</div>
</template>
@@ -70,11 +70,11 @@ export default {
@submit.prevent="applyText"
>
<div v-if="draftMessage" class="w-full">
<h4 class="mt-1 text-base text-slate-700 dark:text-slate-100">
<h4 class="mt-1 text-base text-n-slate-12">
{{ $t('INTEGRATION_SETTINGS.OPEN_AI.ASSISTANCE_MODAL.DRAFT_TITLE') }}
</h4>
<p v-dompurify-html="formatMessage(draftMessage, false)" />
<h4 class="mt-1 text-base text-slate-700 dark:text-slate-100">
<h4 class="mt-1 text-base text-n-slate-12">
{{
$t('INTEGRATION_SETTINGS.OPEN_AI.ASSISTANCE_MODAL.GENERATED_TITLE')
}}
@@ -22,23 +22,15 @@
gap: 4px;
.ai-typing--icon {
color: var(--v-500);
@apply text-n-iris-11;
}
}
label {
display: inline-block;
margin-right: var(--space-smaller);
color: var(--v-400);
@apply text-n-iris-11 ltr:mr-1 rtl:ml-1 inline-block;
}
.loader {
display: inline-block;
width: 6px;
height: 6px;
margin-right: var(--space-smaller);
margin-top: var(--space-slab);
background-color: var(--v-300);
border-radius: 50%;
animation: bubble-scale 1.2s infinite;
@apply bg-n-iris-11 inline-block size-1.5 ltr:mr-1 rtl:ml-1 mt-3 rounded-full;
}
.loader:nth-child(2) {
@@ -50,7 +50,7 @@ const fileName = file => {
<div
v-for="(attachment, index) in nonRecordedAudioAttachments"
:key="attachment.id"
class="preview-item flex items-center p-1 bg-slate-50 dark:bg-slate-800 gap-1 rounded-md w-[15rem] mb-1"
class="flex items-center p-1 bg-n-slate-3 gap-1 rounded-md w-[15rem] mb-1"
>
<div class="max-w-[4rem] flex-shrink-0 w-6 flex items-center">
<img
@@ -133,7 +133,11 @@ export default {
:options="dropdownValues"
:allow-empty="false"
:option-height="104"
/>
>
<template #noOptions>
{{ $t('FORMS.MULTISELECT.NO_OPTIONS') }}
</template>
</multiselect>
</div>
<div
v-else-if="inputType === 'multi_select'"
@@ -152,7 +156,11 @@ export default {
:options="dropdownValues"
:allow-empty="false"
:option-height="104"
/>
>
<template #noOptions>
{{ $t('FORMS.MULTISELECT.NO_OPTIONS') }}
</template>
</multiselect>
</div>
<input
v-else-if="inputType === 'email'"
@@ -266,11 +274,11 @@ export default {
@apply flex items-center justify-center relative my-2.5 mx-0;
.operator__line {
@apply absolute w-full border-b border-solid border-slate-75 dark:border-slate-600;
@apply absolute w-full border-b border-solid border-n-weak;
}
.operator__select {
margin-bottom: var(--space-zero) !important;
margin-bottom: 0 !important;
@apply relative w-auto;
}
}
@@ -56,9 +56,9 @@ export default {
<style scoped>
.multiselect {
margin: var(--space-smaller) var(--space-zero);
margin: 0.25rem 0;
}
textarea {
margin-bottom: var(--space-zero);
margin-bottom: 0;
}
</style>
@@ -79,13 +79,13 @@ input[type='file'] {
@apply hidden;
}
.input-wrapper {
@apply flex h-9 bg-white dark:bg-slate-900 py-1 px-2 items-center text-xs cursor-pointer rounded-sm border border-dashed border-woot-100 dark:border-woot-500;
@apply flex h-9 bg-n-background py-1 px-2 items-center text-xs cursor-pointer rounded-sm border border-dashed border-n-strong;
}
.success-icon {
@apply text-green-500 dark:text-green-600 mr-2;
@apply text-n-teal-9 mr-2;
}
.error-icon {
@apply text-red-500 dark:text-red-600 mr-2;
@apply text-n-ruby-9 mr-2;
}
.processing {
@@ -1,55 +0,0 @@
<script>
export default {
name: 'Avatar',
props: {
username: {
type: String,
default: '',
},
size: {
type: Number,
default: 40,
},
},
computed: {
style() {
return {
fontSize: `${Math.floor(this.size / 2.5)}px`,
};
},
userInitial() {
const parts = this.username.split(/[ -]/);
let initials = parts.reduce((acc, curr) => acc + curr.charAt(0), '');
if (initials.length > 2 && initials.search(/[A-Z]/) !== -1) {
initials = initials.replace(/[a-z]+/g, '');
}
initials = initials.substring(0, 2).toUpperCase();
return initials;
},
},
};
</script>
<template>
<div class="avatar-container" :style="style" aria-hidden="true">
<slot>{{ userInitial }}</slot>
</div>
</template>
<style scoped>
@tailwind components;
@layer components {
.avatar-color {
background-image: linear-gradient(to top, #c2e1ff 0%, #d6ebff 100%);
}
.dark-avatar-color {
background-image: linear-gradient(to top, #135899 0%, #135899 100%);
}
}
.avatar-container {
@apply flex leading-[100%] font-medium items-center justify-center text-center cursor-default avatar-color dark:dark-avatar-color text-woot-600 dark:text-woot-200;
}
</style>
@@ -41,6 +41,10 @@ export default {
);
}
if (key === 'voice') {
return this.enabledFeatures.channel_voice;
}
return [
'website',
'twilio',
@@ -50,8 +54,15 @@ export default {
'telegram',
'line',
'instagram',
'voice',
].includes(key);
},
isComingSoon() {
const { key } = this.channel;
// Show "Coming Soon" only if the channel is marked as coming soon
// and the corresponding feature flag is not enabled yet.
return ['voice'].includes(key) && !this.isActive;
},
},
methods: {
getChannelThumbnail() {
@@ -74,6 +85,7 @@ export default {
:class="{ inactive: !isActive }"
:title="channel.name"
:src="getChannelThumbnail()"
:is-coming-soon="isComingSoon"
@click="onItemClick"
/>
</template>
@@ -48,25 +48,17 @@ useKeyboardEvents(keyboardEvents);
<template>
<woot-tabs
:index="activeTabIndex"
class="w-full px-4 py-0 tab--chat-type"
class="w-full px-3 -mt-1 py-0 [&_ul]:p-0"
@change="onTabChange"
>
<woot-tabs-item
v-for="(item, index) in items"
:key="item.key"
class="text-sm [&_a]:font-medium"
:index="index"
:name="item.name"
:count="item.count"
is-compact
/>
</woot-tabs>
</template>
<style scoped lang="scss">
.tab--chat-type {
::v-deep {
.tabs {
@apply p-0;
}
}
}
</style>
@@ -52,19 +52,16 @@ export default {
</template>
<style scoped lang="scss">
@import 'dashboard/assets/scss/variables';
@import 'dashboard/assets/scss/mixins';
.colorpicker {
position: relative;
}
.colorpicker--selected {
@apply border border-solid border-slate-50 dark:border-slate-600 rounded cursor-pointer h-8 w-8 mb-4;
@apply border border-solid border-n-weak rounded cursor-pointer h-8 w-8 mb-4;
}
.colorpicker--chrome.vc-chrome {
@apply shadow-lg -mt-2.5 absolute z-[9999] border border-solid border-slate-75 dark:border-slate-600 rounded;
@apply shadow-lg -mt-2.5 absolute z-[9999] border border-solid border-n-weak rounded;
::v-deep {
input {
@@ -17,7 +17,7 @@ export default {
</h3>
<p
v-if="message"
class="block text-center text-n-slate-11 dark:text-slate-400 my-4 mx-auto w-[90%]"
class="block text-center text-n-slate-11 my-4 mx-auto w-[90%]"
>
{{ message }}
</p>
@@ -203,7 +203,7 @@ export default {
v-model="values"
track-by="id"
label="name"
placeholder="Select"
:placeholder="$t('FORMS.MULTISELECT.SELECT')"
multiple
selected-label
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
@@ -211,7 +211,11 @@ export default {
:max-height="160"
:options="dropdownValues"
:allow-empty="false"
/>
>
<template #noOptions>
{{ $t('FORMS.MULTISELECT.NO_OPTIONS') }}
</template>
</multiselect>
</div>
<div
v-else-if="inputType === 'search_select'"
@@ -221,7 +225,7 @@ export default {
v-model="values"
track-by="id"
label="name"
placeholder="Select"
:placeholder="$t('FORMS.MULTISELECT.SELECT')"
selected-label
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
deselect-label=""
@@ -229,7 +233,11 @@ export default {
:options="dropdownValues"
:allow-empty="false"
:option-height="104"
/>
>
<template #noOptions>
{{ $t('FORMS.MULTISELECT.NO_OPTIONS') }}
</template>
</multiselect>
</div>
<div v-else-if="inputType === 'date'" class="multiselect-wrap--small">
<input
@@ -264,12 +272,10 @@ export default {
v-if="showQueryOperator"
class="flex items-center justify-center relative my-2.5 mx-0"
>
<hr
class="absolute w-full border-b border-solid border-slate-75 dark:border-slate-800"
/>
<hr class="absolute w-full border-b border-solid border-n-weak" />
<select
v-model="query_operator"
class="relative w-auto mb-0 bg-white dark:bg-slate-900 text-slate-800 dark:text-slate-100 border-slate-75 dark:border-slate-600"
class="relative w-auto mb-0 bg-n-background text-n-slate-12 border-n-weak"
>
<option value="and">
{{ $t('FILTER.QUERY_DROPDOWN_LABELS.AND') }}
@@ -285,7 +291,7 @@ export default {
<style lang="scss" scoped>
.filter__answer--wrap {
input {
@apply bg-white dark:bg-slate-900 mb-0 text-slate-800 dark:text-slate-100 border-slate-75 dark:border-slate-600;
@apply bg-n-background mb-0 text-n-slate-12 border-n-weak;
}
}
@@ -1,32 +1,22 @@
<script>
import { getInboxClassByType } from 'dashboard/helper/inbox';
<script setup>
import ChannelIcon from 'dashboard/components-next/icon/ChannelIcon.vue';
export default {
props: {
inbox: {
type: Object,
default: () => {},
},
defineProps({
inbox: {
type: Object,
default: () => {},
},
computed: {
computedInboxClass() {
const { phone_number: phoneNumber, channel_type: type } = this.inbox;
const classByType = getInboxClassByType(type, phoneNumber);
return classByType;
},
},
};
});
</script>
<template>
<div
class="inbox--name inline-flex items-center py-0.5 px-0 leading-3 whitespace-nowrap bg-none text-n-slate-11 text-xs my-0 mx-2.5"
>
<fluent-icon
class="mr-0.5 rtl:ml-0.5 rtl:mr-0"
:icon="computedInboxClass"
size="12"
<div class="flex items-center text-n-slate-11 text-xs min-w-0">
<ChannelIcon
:inbox="inbox"
class="size-3 ltr:mr-0.5 rtl:ml-0.5 flex-shrink-0"
/>
{{ inbox.name }}
<span class="truncate">
{{ inbox.name }}
</span>
</div>
</template>
@@ -1,20 +1,20 @@
<script>
export default {
props: {
message: { type: String, default: '' },
},
};
<script setup>
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
defineProps({
message: { type: String, default: '' },
});
</script>
<template>
<div class="flex items-center justify-center p-8">
<h6
class="flex items-center gap-2 text-base text-center w-100 text-slate-800 dark:text-slate-300"
class="flex items-center gap-3 text-base text-center w-100 text-n-slate-11"
>
<span class="text-base font-medium text-slate-800 dark:text-slate-100">
<span class="text-base font-medium text-n-slate-12">
{{ message }}
</span>
<span class="spinner" />
<Spinner class="text-n-brand" />
</h6>
</div>
</template>
@@ -16,13 +16,10 @@ export default {
<template>
<div class="border-b border-solid border-n-weak/60">
<div class="max-w-6xl w-full mx-auto pt-4 pb-0 px-8">
<h2 class="text-2xl text-slate-800 dark:text-slate-100 mb-1 font-medium">
<h2 class="text-2xl text-n-slate-12 mb-1 font-medium">
{{ headerTitle }}
</h2>
<p
v-if="headerContent"
class="w-full text-slate-600 dark:text-slate-300 text-sm mb-2"
>
<p v-if="headerContent" class="w-full text-n-slate-11 text-sm mb-2">
{{ headerContent }}
</p>
<slot />
@@ -37,7 +37,7 @@ const toggleShowMore = () => {
{{ textToBeDisplayed }}
<button
v-if="text.length > limit"
class="text-woot-500 !p-0 !border-0 align-top"
class="text-n-brand !p-0 !border-0 align-top"
@click="toggleShowMore"
>
{{ buttonLabel }}
@@ -16,7 +16,7 @@ defineProps({
</script>
<template>
<span class="text-sm text-slate-700 dark:text-slate-200 font-medium">
<span class="text-sm text-n-slate-11 font-medium">
{{
$t('GENERAL.SHOWING_RESULTS', {
firstIndex,
@@ -1,48 +0,0 @@
import { mount } from '@vue/test-utils';
import Avatar from './Avatar.vue';
import Thumbnail from './Thumbnail.vue';
describe('Thumbnail.vue', () => {
it('should render the agent thumbnail if valid image is passed', () => {
const wrapper = mount(Thumbnail, {
propsData: {
src: 'https://some_valid_url.com',
},
data() {
return {
hasImageLoaded: true,
imgError: false,
};
},
});
expect(wrapper.find('.user-thumbnail').exists()).toBe(true);
const avatarComponent = wrapper.findComponent(Avatar);
expect(avatarComponent.isVisible()).toBe(false);
});
it('should render the avatar component if invalid image is passed', () => {
const wrapper = mount(Thumbnail, {
propsData: {
src: 'https://some_invalid_url.com',
},
data() {
return {
hasImageLoaded: true,
imgError: true,
};
},
});
expect(wrapper.find('#image').exists()).toBe(false);
const avatarComponent = wrapper.findComponent(Avatar);
expect(avatarComponent.isVisible()).toBe(true);
});
it('should the initial of the name if no image is passed', () => {
const wrapper = mount(Avatar, {
propsData: {
username: 'Angie Rojas',
},
});
expect(wrapper.find('div').text()).toBe('AR');
});
});
@@ -1,228 +0,0 @@
<script>
/**
* Thumbnail Component
* Src - source for round image
* Size - Size of the thumbnail
* Badge - Chat source indication { fb / telegram }
* Username - Username for avatar
*/
import Avatar from './Avatar.vue';
import { removeEmoji } from 'shared/helpers/emoji';
export default {
components: {
Avatar,
},
props: {
src: {
type: String,
default: '',
},
size: {
type: String,
default: '40px',
},
badge: {
type: String,
default: '',
},
username: {
type: String,
default: '',
},
status: {
type: String,
default: '',
},
hasBorder: {
type: Boolean,
default: false,
},
shouldShowStatusAlways: {
type: Boolean,
default: false,
},
title: {
type: String,
default: '',
},
variant: {
type: String,
default: 'circle',
},
},
data() {
return {
hasImageLoaded: false,
imgError: false,
};
},
computed: {
userNameWithoutEmoji() {
return removeEmoji(this.username);
},
showStatusIndicator() {
if (this.shouldShowStatusAlways) return true;
return this.status === 'online' || this.status === 'busy';
},
avatarSize() {
return Number(this.size.replace(/\D+/g, ''));
},
badgeSrc() {
return {
instagram_direct_message: 'instagram-dm',
facebook: 'messenger',
'twitter-tweet': 'twitter-tweet',
'twitter-dm': 'twitter-dm',
whatsapp: 'whatsapp',
sms: 'sms',
'Channel::Line': 'line',
'Channel::Telegram': 'telegram',
'Channel::WebWidget': '',
}[this.badge];
},
badgeStyle() {
const size = Math.floor(this.avatarSize / 3);
const badgeSize = `${size + 2}px`;
const borderRadius = `${size / 2}px`;
return { width: badgeSize, height: badgeSize, borderRadius };
},
statusStyle() {
const statusSize = `${this.avatarSize / 4}px`;
return { width: statusSize, height: statusSize };
},
thumbnailClass() {
const className = this.hasBorder
? 'border border-solid border-white dark:border-slate-700/50'
: '';
const variant =
this.variant === 'circle' ? 'thumbnail-rounded' : 'thumbnail-square';
return `user-thumbnail ${className} ${variant}`;
},
thumbnailBoxClass() {
const boxClass = this.variant === 'circle' ? 'is-rounded' : '';
return `user-thumbnail-box ${boxClass}`;
},
shouldShowImage() {
if (!this.src) {
return false;
}
if (this.hasImageLoaded) {
return !this.imgError;
}
return false;
},
},
watch: {
src(value, oldValue) {
if (value !== oldValue && this.imgError) {
this.imgError = false;
}
},
},
methods: {
onImgError() {
this.imgError = true;
},
onImgLoad() {
this.hasImageLoaded = true;
},
},
};
</script>
<template>
<div
:class="thumbnailBoxClass"
:style="{ height: size, width: size }"
:title="title"
>
<!-- Using v-show instead of v-if to avoid flickering as v-if removes dom elements. -->
<slot>
<img
v-show="shouldShowImage"
:src="src"
draggable="false"
:class="thumbnailClass"
@load="onImgLoad"
@error="onImgError"
/>
<Avatar
v-show="!shouldShowImage"
:username="userNameWithoutEmoji"
:class="thumbnailClass"
:size="avatarSize"
/>
</slot>
<img
v-if="badgeSrc"
class="source-badge z-20"
:style="badgeStyle"
:src="`/integrations/channels/badges/${badgeSrc}.png`"
alt="Badge"
/>
<div
v-if="showStatusIndicator"
class="z-20"
:class="`source-badge user-online-status user-online-status--${status}`"
:style="statusStyle"
/>
</div>
</template>
<style lang="scss" scoped>
.user-thumbnail-box {
flex: 0 0 auto;
max-width: 100%;
position: relative;
&.is-rounded {
border-radius: 50%;
}
.user-thumbnail {
border-radius: 50%;
&.thumbnail-square {
border-radius: var(--border-radius-large);
}
height: 100%;
width: 100%;
box-sizing: border-box;
object-fit: cover;
vertical-align: initial;
}
.source-badge {
border-radius: var(--border-radius-small);
bottom: var(--space-minus-micro);
box-shadow: var(--shadow-small);
height: var(--space-slab);
padding: var(--space-micro);
position: absolute;
right: 0;
width: var(--space-slab);
@apply bg-white dark:bg-slate-900;
}
.user-online-status {
border-radius: 50%;
bottom: var(--space-micro);
&:after {
content: ' ';
}
}
.user-online-status--online {
@apply bg-green-400 dark:bg-green-400;
}
.user-online-status--busy {
@apply bg-yellow-500 dark:bg-yellow-500;
}
.user-online-status--offline {
@apply bg-slate-500 dark:bg-slate-500;
}
}
</style>
@@ -1,90 +1,69 @@
<script>
import Thumbnail from './Thumbnail.vue';
<script setup>
import { computed } from 'vue';
import Avatar from 'next/avatar/Avatar.vue';
export default {
components: {
Thumbnail,
const props = defineProps({
usersList: {
type: Array,
default: () => [],
},
props: {
usersList: {
type: Array,
default: () => [],
},
size: {
type: String,
default: '24px',
},
showMoreThumbnailsCount: {
type: Boolean,
default: false,
},
moreThumbnailsText: {
type: String,
default: '',
},
gap: {
type: String,
default: 'normal',
validator(value) {
// The value must match one of these strings
return ['normal', '', 'tight'].includes(value);
},
size: {
type: Number,
default: 24,
},
showMoreThumbnailsCount: {
type: Boolean,
default: false,
},
moreThumbnailsText: {
type: String,
default: '',
},
gap: {
type: String,
default: 'normal',
validator(value) {
// The value must match one of these strings
return ['normal', 'tight'].includes(value);
},
},
};
});
const gapClass = computed(() => {
if (props.gap === 'tight') {
return 'ltr:[&:not(:first-child)]:-ml-2 rtl:[&:not(:first-child)]:-mr-2';
}
return 'ltr:[&:not(:first-child)]:-ml-1 rtl:[&:not(:first-child)]:-mr-1';
});
const moreThumbnailsClass = computed(() => {
if (props.gap === 'tight') {
return 'ltr:-ml-2 rtl:-mr-2';
}
return 'ltr:-ml-1 rtl:-mr-1';
});
</script>
<template>
<div class="overlapping-thumbnails">
<Thumbnail
<div class="flex">
<Avatar
v-for="user in usersList"
:key="user.id"
v-tooltip="user.name"
:title="user.name"
:src="user.thumbnail"
:username="user.name"
has-border
:name="user.name"
:size="size"
:class="`overlapping-thumbnail gap-${gap}`"
class="[&>span]:outline [&>span]:outline-1 [&>span]:outline-n-background [&>span]:shadow"
:class="gapClass"
rounded-full
/>
<span v-if="showMoreThumbnailsCount" class="thumbnail-more-text">
<span
v-if="showMoreThumbnailsCount"
class="text-n-slate-11 bg-n-slate-4 outline outline-1 outline-n-background text-xs font-medium rounded-full px-2 inline-flex items-center shadow relative"
:class="moreThumbnailsClass"
>
{{ moreThumbnailsText }}
</span>
</div>
</template>
<style lang="scss" scoped>
.overlapping-thumbnails {
display: flex;
}
.overlapping-thumbnail {
position: relative;
box-shadow: var(--shadow-small);
&:not(:first-child) {
margin-left: var(--space-minus-smaller);
}
.gap-tight {
margin-left: var(--space-minus-small);
}
}
.thumbnail-more-text {
display: inline-flex;
align-items: center;
position: relative;
margin-left: var(--space-minus-small);
padding: 0 var(--space-small);
box-shadow: var(--shadow-small);
background: var(--color-background);
border-radius: var(--space-giga);
border: 1px solid var(--white);
color: var(--s-600);
font-size: var(--font-size-mini);
font-weight: var(--font-weight-medium);
}
</style>
@@ -1,5 +1,5 @@
<script setup>
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
import Avatar from 'next/avatar/Avatar.vue';
defineProps({
user: {
@@ -7,8 +7,8 @@ defineProps({
default: () => ({}),
},
size: {
type: String,
default: '20px',
type: Number,
default: 20,
},
textClass: {
type: String,
@@ -19,16 +19,15 @@ defineProps({
<template>
<div class="flex items-center gap-1.5 text-left">
<Thumbnail
<Avatar
:src="user.thumbnail"
:size="size"
:username="user.name"
:name="user.name"
:status="user.availability_status"
hide-offline-status
rounded-full
/>
<span
class="my-0 overflow-hidden whitespace-nowrap text-ellipsis text-capitalize"
:class="textClass"
>
<span class="my-0 truncate text-capitalize" :class="textClass">
{{ user.name }}
</span>
</div>
@@ -15,6 +15,7 @@ import CannedResponse from '../conversation/CannedResponse.vue';
import KeyboardEmojiSelector from './keyboardEmojiSelector.vue';
import TagAgents from '../conversation/TagAgents.vue';
import VariableList from '../conversation/VariableList.vue';
import TagTools from '../conversation/TagTools.vue';
import { useEmitter } from 'dashboard/composables/emitter';
import { useI18n } from 'vue-i18n';
@@ -72,6 +73,7 @@ const props = defineProps({
updateSelectionWith: { type: String, default: '' },
enableVariables: { type: Boolean, default: false },
enableCannedResponses: { type: Boolean, default: true },
enableCaptainTools: { type: Boolean, default: false },
variables: { type: Object, default: () => ({}) },
enabledMenuOptions: { type: Array, default: () => [] },
signature: { type: String, default: '' },
@@ -89,6 +91,7 @@ const emit = defineEmits([
'toggleUserMention',
'toggleCannedMenu',
'toggleVariablesMenu',
'toggleToolsMenu',
'clearSelection',
'blur',
'focus',
@@ -140,7 +143,9 @@ const showUserMentions = ref(false);
const showCannedMenu = ref(false);
const showVariables = ref(false);
const showEmojiMenu = ref(false);
const showToolsMenu = ref(false);
const mentionSearchKey = ref('');
const toolSearchKey = ref('');
const cannedSearchTerm = ref('');
const variableSearchTerm = ref('');
const emojiSearchTerm = ref('');
@@ -216,11 +221,17 @@ const plugins = computed(() => {
}
return [
createSuggestionPlugin({
trigger: '@',
showMenu: showToolsMenu,
searchTerm: toolSearchKey,
isAllowed: () => props.enableCaptainTools,
}),
createSuggestionPlugin({
trigger: '@',
showMenu: showUserMentions,
searchTerm: mentionSearchKey,
isAllowed: () => props.isPrivate,
isAllowed: () => props.isPrivate || !props.enableCaptainTools,
}),
createSuggestionPlugin({
trigger: '/',
@@ -262,6 +273,9 @@ watch(showCannedMenu, updatedValue => {
watch(showVariables, updatedValue => {
emit('toggleVariablesMenu', !props.isPrivate && updatedValue);
});
watch(showToolsMenu, updatedValue => {
emit('toggleToolsMenu', props.enableCaptainTools && updatedValue);
});
function focusEditorInputField(pos = 'end') {
const { tr } = editorView.state;
@@ -538,6 +552,7 @@ function insertSpecialContent(type, content) {
cannedResponse: CONVERSATION_EVENTS.INSERTED_A_CANNED_RESPONSE,
variable: CONVERSATION_EVENTS.INSERTED_A_VARIABLE,
emoji: CONVERSATION_EVENTS.INSERTED_AN_EMOJI,
tool: CONVERSATION_EVENTS.INSERTED_A_TOOL,
};
useTrack(event_map[type]);
@@ -699,6 +714,11 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
:search-key="emojiSearchTerm"
@select-emoji="emoji => insertSpecialContent('emoji', emoji)"
/>
<TagTools
v-if="showToolsMenu"
:search-key="toolSearchKey"
@select-tool="content => insertSpecialContent('tool', content)"
/>
<input
ref="imageUpload"
type="file"
@@ -709,7 +729,7 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
<div ref="editor" />
<div
v-show="isImageNodeSelected && showImageResizeToolbar"
class="absolute shadow-md rounded-[4px] flex gap-1 py-1 px-1 bg-slate-50 dark:bg-slate-700 text-slate-800 dark:text-slate-50"
class="absolute shadow-md rounded-[6px] flex gap-1 py-1 px-1 bg-n-solid-3 outline outline-1 outline-n-weak text-n-slate-12"
:style="{
top: toolbarPosition.top,
left: toolbarPosition.left,
@@ -718,7 +738,7 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
<button
v-for="size in sizes"
:key="size.name"
class="text-xs font-medium rounded-[4px] border border-solid border-slate-200 dark:border-slate-600 px-1.5 py-0.5 hover:bg-slate-100 dark:hover:bg-slate-800"
class="text-xs font-medium rounded-[4px] outline outline-1 outline-n-strong px-1.5 py-0.5 hover:bg-n-slate-5"
@click="setURLWithQueryAndImageSize(size)"
>
{{ size.name }}
@@ -735,16 +755,16 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
@apply flex flex-col;
.ProseMirror-menubar {
min-height: var(--space-two) !important;
min-height: 1.25rem !important;
@apply -ml-2.5 pb-0 bg-transparent text-n-slate-11;
.ProseMirror-menu-active {
@apply bg-slate-75 dark:bg-slate-800;
@apply bg-n-slate-5 dark:bg-n-solid-3;
}
}
> .ProseMirror {
@apply p-0 break-words text-slate-800 dark:text-slate-100;
@apply p-0 break-words text-n-slate-12;
h1,
h2,
@@ -753,14 +773,14 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
h5,
h6,
p {
@apply text-slate-800 dark:text-slate-100;
@apply text-n-slate-12;
}
blockquote {
@apply border-slate-400 dark:border-slate-500;
@apply border-n-slate-7;
p {
@apply text-slate-600 dark:text-slate-400;
@apply text-n-slate-11;
}
}
@@ -812,6 +832,10 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
}
}
.prosemirror-tools-node {
@apply font-medium text-n-slate-12 py-0;
}
.editor-wrap {
@apply mb-4;
}
@@ -825,6 +849,6 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
}
.editor-warning__message {
@apply text-red-400 dark:text-red-400 font-normal text-sm pt-1 pb-0 px-0;
@apply text-n-ruby-9 dark:text-n-ruby-9 font-normal text-sm pt-1 pb-0 px-0;
}
</style>
@@ -94,7 +94,7 @@ export default {
type: Boolean,
default: true,
},
hasWhatsappTemplates: {
enableWhatsAppTemplates: {
type: Boolean,
default: false,
},
@@ -333,7 +333,7 @@ export default {
@click="toggleMessageSignature"
/>
<NextButton
v-if="hasWhatsappTemplates"
v-if="enableWhatsAppTemplates"
v-tooltip.top-end="$t('CONVERSATION.FOOTER.WHATSAPP_TEMPLATES')"
icon="i-ph-whatsapp-logo"
slate
@@ -355,10 +355,10 @@ export default {
<transition name="modal-fade">
<div
v-show="uploadRef && uploadRef.dropActive"
class="fixed top-0 bottom-0 left-0 right-0 z-20 flex flex-col items-center justify-center w-full h-full gap-2 text-slate-900 dark:text-slate-50 bg-modal-backdrop-light dark:bg-modal-backdrop-dark"
class="fixed top-0 bottom-0 left-0 right-0 z-20 flex flex-col items-center justify-center w-full h-full gap-2 text-n-slate-12 bg-modal-backdrop-light dark:bg-modal-backdrop-dark"
>
<fluent-icon icon="cloud-backup" size="40" />
<h4 class="text-2xl break-words text-slate-900 dark:text-slate-50">
<h4 class="text-2xl break-words text-n-slate-12">
{{ $t('CONVERSATION.REPLYBOX.DRAG_DROP') }}
</h4>
</div>
@@ -402,7 +402,7 @@ export default {
}
&:hover button {
@apply dark:bg-slate-800 bg-slate-100;
@apply enabled:bg-n-slate-9/20;
}
}
</style>
@@ -73,7 +73,7 @@ export default {
};
},
charLengthClass() {
return this.charactersRemaining < 0 ? 'text-red-600' : 'text-slate-600';
return this.charactersRemaining < 0 ? 'text-n-ruby-9' : 'text-n-slate-11';
},
characterLengthWarning() {
return this.charactersRemaining < 0
@@ -52,13 +52,13 @@ onMounted(() => {
>
<template #default="{ item, selected }">
<span
class="max-w-full inline-flex items-center gap-0.5 min-w-0 mb-0 text-sm font-medium text-slate-900 dark:text-slate-100 group-hover:text-woot-500 dark:group-hover:text-woot-500 truncate"
class="max-w-full inline-flex items-center gap-0.5 min-w-0 mb-0 text-sm font-medium text-n-slate-12 group-hover:text-n-brand truncate"
>
{{ item.emoji }}
<p
class="relative mb-0 truncate bottom-px"
:class="{
'text-woot-500 dark:text-woot-500': selected,
'text-n-brand': selected,
'font-normal': !selected,
}"
>
@@ -1,27 +0,0 @@
<script>
export default {
props: {
status: { type: String, default: '' },
},
};
</script>
<template>
<div
:class="`status-badge status-badge__${status} rounded-full w-2.5 h-2.5 mr-0.5 rtl:mr-0 rtl:ml-0.5 inline-flex`"
/>
</template>
<style lang="scss" scoped>
.status-badge {
&__online {
@apply bg-green-400;
}
&__offline {
@apply bg-slate-500;
}
&__busy {
@apply bg-yellow-500;
}
}
</style>
@@ -1 +0,0 @@
<!-- // not using this component -->
@@ -1,93 +1,132 @@
<script>
import wootConstants from 'dashboard/constants/globals';
import { mapGetters } from 'vuex';
import FilterItem from './FilterItem.vue';
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useToggle } from '@vueuse/core';
import { vOnClickOutside } from '@vueuse/components';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useMapGetter, useStore } from 'dashboard/composables/store.js';
import wootConstants from 'dashboard/constants/globals';
import SelectMenu from 'dashboard/components-next/selectmenu/SelectMenu.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
const CHAT_STATUS_FILTER_ITEMS = Object.freeze([
'open',
'resolved',
'pending',
'snoozed',
'all',
defineProps({
isOnExpandedLayout: {
type: Boolean,
required: true,
},
});
const emit = defineEmits(['changeFilter']);
const store = useStore();
const { t } = useI18n();
const { updateUISettings } = useUISettings();
const chatStatusFilter = useMapGetter('getChatStatusFilter');
const chatSortFilter = useMapGetter('getChatSortFilter');
const [showActionsDropdown, toggleDropdown] = useToggle();
const currentStatusFilter = computed(() => {
return chatStatusFilter.value || wootConstants.STATUS_TYPE.OPEN;
});
const currentSortBy = computed(() => {
return (
chatSortFilter.value || wootConstants.SORT_BY_TYPE.LAST_ACTIVITY_AT_DESC
);
});
const chatStatusOptions = computed(() => [
{
label: t('CHAT_LIST.CHAT_STATUS_FILTER_ITEMS.open.TEXT'),
value: 'open',
},
{
label: t('CHAT_LIST.CHAT_STATUS_FILTER_ITEMS.resolved.TEXT'),
value: 'resolved',
},
{
label: t('CHAT_LIST.CHAT_STATUS_FILTER_ITEMS.pending.TEXT'),
value: 'pending',
},
{
label: t('CHAT_LIST.CHAT_STATUS_FILTER_ITEMS.snoozed.TEXT'),
value: 'snoozed',
},
{
label: t('CHAT_LIST.CHAT_STATUS_FILTER_ITEMS.all.TEXT'),
value: 'all',
},
]);
const SORT_ORDER_ITEMS = Object.freeze([
'last_activity_at_asc',
'last_activity_at_desc',
'created_at_desc',
'created_at_asc',
'priority_desc',
'priority_asc',
'waiting_since_asc',
'waiting_since_desc',
const chatSortOptions = computed(() => [
{
label: t('CHAT_LIST.SORT_ORDER_ITEMS.last_activity_at_asc.TEXT'),
value: 'last_activity_at_asc',
},
{
label: t('CHAT_LIST.SORT_ORDER_ITEMS.last_activity_at_desc.TEXT'),
value: 'last_activity_at_desc',
},
{
label: t('CHAT_LIST.SORT_ORDER_ITEMS.created_at_desc.TEXT'),
value: 'created_at_desc',
},
{
label: t('CHAT_LIST.SORT_ORDER_ITEMS.created_at_asc.TEXT'),
value: 'created_at_asc',
},
{
label: t('CHAT_LIST.SORT_ORDER_ITEMS.priority_desc.TEXT'),
value: 'priority_desc',
},
{
label: t('CHAT_LIST.SORT_ORDER_ITEMS.priority_asc.TEXT'),
value: 'priority_asc',
},
{
label: t('CHAT_LIST.SORT_ORDER_ITEMS.waiting_since_asc.TEXT'),
value: 'waiting_since_asc',
},
{
label: t('CHAT_LIST.SORT_ORDER_ITEMS.waiting_since_desc.TEXT'),
value: 'waiting_since_desc',
},
]);
export default {
components: {
FilterItem,
NextButton,
},
props: {
isOnExpandedLayout: {
type: Boolean,
required: true,
},
},
emits: ['changeFilter'],
setup() {
const { updateUISettings } = useUISettings();
const activeChatStatusLabel = computed(
() =>
chatStatusOptions.value.find(m => m.value === chatStatusFilter.value)
?.label || ''
);
return {
updateUISettings,
};
},
data() {
return {
showActionsDropdown: false,
chatStatusItems: CHAT_STATUS_FILTER_ITEMS,
chatSortItems: SORT_ORDER_ITEMS,
};
},
computed: {
...mapGetters({
chatStatusFilter: 'getChatStatusFilter',
chatSortFilter: 'getChatSortFilter',
}),
chatStatus() {
return this.chatStatusFilter || wootConstants.STATUS_TYPE.OPEN;
const activeChatSortLabel = computed(
() =>
chatSortOptions.value.find(m => m.value === chatSortFilter.value)?.label ||
''
);
const saveSelectedFilter = (type, value) => {
updateUISettings({
conversations_filter_by: {
status: type === 'status' ? value : currentStatusFilter.value,
order_by: type === 'sort' ? value : currentSortBy.value,
},
sortFilter() {
return (
this.chatSortFilter || wootConstants.SORT_BY_TYPE.LAST_ACTIVITY_AT_DESC
);
},
},
methods: {
onTabChange(value) {
this.$emit('changeFilter', value);
this.closeDropdown();
},
toggleDropdown() {
this.showActionsDropdown = !this.showActionsDropdown;
},
closeDropdown() {
this.showActionsDropdown = false;
},
onChangeFilter(value, type) {
this.$emit('changeFilter', value, type);
this.saveSelectedFilter(type, value);
},
saveSelectedFilter(type, value) {
this.updateUISettings({
conversations_filter_by: {
status: type === 'status' ? value : this.chatStatus,
order_by: type === 'sort' ? value : this.sortFilter,
},
});
},
},
});
};
const handleStatusChange = value => {
emit('changeFilter', value, 'status');
store.dispatch('setChatStatusFilter', value);
saveSelectedFilter('status', value);
};
const handleSortChange = value => {
emit('changeFilter', value, 'sort');
store.dispatch('setChatSortFilter', value);
saveSelectedFilter('sort', value);
};
</script>
@@ -99,39 +138,39 @@ export default {
slate
faded
xs
@click="toggleDropdown"
@click="toggleDropdown()"
/>
<div
v-if="showActionsDropdown"
v-on-clickaway="closeDropdown"
class="mt-1 dropdown-pane dropdown-pane--open !w-52 !p-4 top-6 border !border-n-weak dark:!border-n-weak !bg-n-alpha-3 dark:!bg-n-alpha-3 backdrop-blur-[100px]"
v-on-click-outside="() => toggleDropdown()"
class="mt-1 bg-n-alpha-3 backdrop-blur-[100px] border border-n-weak w-72 rounded-xl p-4 absolute z-40 top-full"
:class="{
'ltr:left-0 rtl:right-0': !isOnExpandedLayout,
'ltr:right-0 rtl:left-0': isOnExpandedLayout,
}"
>
<div class="flex items-center justify-between last:mt-4">
<span class="text-xs font-medium text-n-slate-12">{{
$t('CHAT_LIST.CHAT_SORT.STATUS')
}}</span>
<FilterItem
type="status"
:selected-value="chatStatus"
:items="chatStatusItems"
path-prefix="CHAT_LIST.CHAT_STATUS_FILTER_ITEMS"
@on-change-filter="onChangeFilter"
<div class="flex items-center justify-between last:mt-4 gap-2">
<span class="text-sm truncate text-n-slate-12">
{{ $t('CHAT_LIST.CHAT_SORT.STATUS') }}
</span>
<SelectMenu
:model-value="chatStatusFilter"
:options="chatStatusOptions"
:label="activeChatStatusLabel"
:sub-menu-position="isOnExpandedLayout ? 'left' : 'right'"
@update:model-value="handleStatusChange"
/>
</div>
<div class="flex items-center justify-between last:mt-4">
<span class="text-xs font-medium text-n-slate-12">{{
$t('CHAT_LIST.CHAT_SORT.ORDER_BY')
}}</span>
<FilterItem
type="sort"
:selected-value="sortFilter"
:items="chatSortItems"
path-prefix="CHAT_LIST.SORT_ORDER_ITEMS"
@on-change-filter="onChangeFilter"
<div class="flex items-center justify-between last:mt-4 gap-2">
<span class="text-sm truncate text-n-slate-12">
{{ $t('CHAT_LIST.CHAT_SORT.ORDER_BY') }}
</span>
<SelectMenu
:model-value="chatSortFilter"
:options="chatSortOptions"
:label="activeChatSortLabel"
:sub-menu-position="isOnExpandedLayout ? 'left' : 'right'"
@update:model-value="handleSortChange"
/>
</div>
</div>
@@ -4,17 +4,14 @@ import ConversationHeader from './ConversationHeader.vue';
import DashboardAppFrame from '../DashboardApp/Frame.vue';
import EmptyState from './EmptyState/EmptyState.vue';
import MessagesView from './MessagesView.vue';
import ConversationSidebar from './ConversationSidebar.vue';
export default {
components: {
ConversationSidebar,
ConversationHeader,
DashboardAppFrame,
EmptyState,
MessagesView,
},
props: {
inboxId: {
type: [Number, String],
@@ -34,7 +31,6 @@ export default {
default: true,
},
},
emits: ['contactPanelToggle'],
data() {
return { activeIndex: 0 };
},
@@ -86,9 +82,6 @@ export default {
}
this.$store.dispatch('conversationLabels/get', this.currentChat.id);
},
onToggleContactPanel() {
this.$emit('contactPanelToggle');
},
onDashboardAppTabChange(index) {
this.activeIndex = index;
},
@@ -98,7 +91,7 @@ export default {
<template>
<div
class="conversation-details-wrap bg-n-background"
class="conversation-details-wrap flex flex-col min-w-0 w-full bg-n-background relative"
:class="{
'border-l rtl:border-l-0 rtl:border-r border-n-weak': !isOnExpandedLayout,
}"
@@ -106,15 +99,12 @@ export default {
<ConversationHeader
v-if="currentChat.id"
:chat="currentChat"
:is-inbox-view="isInboxView"
:is-contact-panel-open="isContactPanelOpen"
:show-back-button="isOnExpandedLayout && !isInboxView"
@contact-panel-toggle="onToggleContactPanel"
/>
<woot-tabs
v-if="dashboardApps.length && currentChat.id"
:index="activeIndex"
class="-mt-px bg-white dashboard-app--tabs dark:bg-slate-900"
class="-mt-px border-t border-t-n-background"
@change="onDashboardAppTabChange"
>
<woot-tabs-item
@@ -123,6 +113,8 @@ export default {
:index="tab.index"
:name="tab.name"
:show-badge="false"
is-compact
class="[&_a]:pt-1"
/>
</woot-tabs>
<div v-show="!activeIndex" class="flex h-full min-h-0 m-0">
@@ -130,18 +122,12 @@ export default {
v-if="currentChat.id"
:inbox-id="inboxId"
:is-inbox-view="isInboxView"
:is-contact-panel-open="isContactPanelOpen"
@contact-panel-toggle="onToggleContactPanel"
/>
<EmptyState
v-if="!currentChat.id && !isInboxView"
:is-on-expanded-layout="isOnExpandedLayout"
/>
<ConversationSidebar
v-if="showContactPanel"
:current-chat="currentChat"
@toggle-contact-panel="onToggleContactPanel"
/>
<slot />
</div>
<DashboardAppFrame
v-for="(dashboardApp, index) in dashboardApps"
@@ -154,19 +140,3 @@ export default {
/>
</div>
</template>
<style lang="scss" scoped>
.conversation-details-wrap {
@apply flex flex-col min-w-0 w-full;
}
.dashboard-app--tabs {
::v-deep {
.tabs-title {
a {
@apply pb-2 pt-1;
}
}
}
}
</style>
@@ -1,12 +1,12 @@
<script>
import { mapGetters } from 'vuex';
<script setup>
import { computed, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { getLastMessage } from 'dashboard/helper/conversationHelper';
import Thumbnail from '../Thumbnail.vue';
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
import Avatar from 'next/avatar/Avatar.vue';
import MessagePreview from './MessagePreview.vue';
import router from '../../../routes';
import { frontendURL, conversationUrl } from '../../../helper/URLHelper';
import InboxName from '../InboxName.vue';
import inboxMixin from 'shared/mixins/inboxMixin';
import ConversationContextMenu from './contextMenu/Index.vue';
import TimeAgo from 'dashboard/components/ui/TimeAgo.vue';
import CardLabels from './conversationCardComponents/CardLabels.vue';
@@ -14,242 +14,227 @@ import PriorityMark from './PriorityMark.vue';
import SLACardLabel from './components/SLACardLabel.vue';
import ContextMenu from 'dashboard/components/ui/ContextMenu.vue';
export default {
components: {
CardLabels,
InboxName,
Thumbnail,
ConversationContextMenu,
TimeAgo,
MessagePreview,
PriorityMark,
SLACardLabel,
ContextMenu,
},
mixins: [inboxMixin],
props: {
activeLabel: {
type: String,
default: '',
},
chat: {
type: Object,
default: () => {},
},
hideInboxName: {
type: Boolean,
default: false,
},
hideThumbnail: {
type: Boolean,
default: false,
},
teamId: {
type: [String, Number],
default: 0,
},
foldersId: {
type: [String, Number],
default: 0,
},
showAssignee: {
type: Boolean,
default: false,
},
conversationType: {
type: String,
default: '',
},
selected: {
type: Boolean,
default: false,
},
enableContextMenu: {
type: Boolean,
default: false,
},
},
emits: [
'contextMenuToggle',
'assignAgent',
'assignLabel',
'assignTeam',
'markAsUnread',
'markAsRead',
'assignPriority',
'updateConversationStatus',
],
data() {
return {
hovered: false,
showContextMenu: false,
contextMenu: {
x: null,
y: null,
},
};
},
computed: {
...mapGetters({
currentChat: 'getSelectedChat',
inboxesList: 'inboxes/getInboxes',
activeInbox: 'getSelectedInbox',
accountId: 'getCurrentAccountId',
}),
chatMetadata() {
return this.chat.meta || {};
},
const props = defineProps({
activeLabel: { type: String, default: '' },
chat: { type: Object, default: () => ({}) },
hideInboxName: { type: Boolean, default: false },
hideThumbnail: { type: Boolean, default: false },
teamId: { type: [String, Number], default: 0 },
foldersId: { type: [String, Number], default: 0 },
showAssignee: { type: Boolean, default: false },
conversationType: { type: String, default: '' },
selected: { type: Boolean, default: false },
compact: { type: Boolean, default: false },
enableContextMenu: { type: Boolean, default: false },
allowedContextMenuOptions: { type: Array, default: () => [] },
});
assignee() {
return this.chatMetadata.assignee || {};
},
const emit = defineEmits([
'contextMenuToggle',
'assignAgent',
'assignLabel',
'assignTeam',
'markAsUnread',
'markAsRead',
'assignPriority',
'updateConversationStatus',
'deleteConversation',
'selectConversation',
'deSelectConversation',
]);
currentContact() {
return this.$store.getters['contacts/getContact'](
this.chatMetadata.sender.id
);
},
const router = useRouter();
const store = useStore();
isActiveChat() {
return this.currentChat.id === this.chat.id;
},
const hovered = ref(false);
const showContextMenu = ref(false);
const contextMenu = ref({
x: null,
y: null,
});
unreadCount() {
return this.chat.unread_count;
},
const currentChat = useMapGetter('getSelectedChat');
const inboxesList = useMapGetter('inboxes/getInboxes');
const activeInbox = useMapGetter('getSelectedInbox');
const accountId = useMapGetter('getCurrentAccountId');
hasUnread() {
return this.unreadCount > 0;
},
const chatMetadata = computed(() => props.chat.meta || {});
isInboxNameVisible() {
return !this.activeInbox;
},
const assignee = computed(() => chatMetadata.value.assignee || {});
lastMessageInChat() {
return getLastMessage(this.chat);
},
const senderId = computed(() => chatMetadata.value.sender?.id);
inbox() {
const { inbox_id: inboxId } = this.chat;
const stateInbox = this.$store.getters['inboxes/getInbox'](inboxId);
return stateInbox;
},
const currentContact = computed(() => {
return senderId.value
? store.getters['contacts/getContact'](senderId.value)
: {};
});
showInboxName() {
return (
!this.hideInboxName &&
this.isInboxNameVisible &&
this.inboxesList.length > 1
);
},
inboxName() {
const stateInbox = this.inbox;
return stateInbox.name || '';
},
hasSlaPolicyId() {
return this.chat?.sla_policy_id;
},
},
methods: {
onCardClick(e) {
const { activeInbox, chat } = this;
const path = frontendURL(
conversationUrl({
accountId: this.accountId,
activeInbox,
id: chat.id,
label: this.activeLabel,
teamId: this.teamId,
foldersId: this.foldersId,
conversationType: this.conversationType,
})
);
const isActiveChat = computed(() => {
return currentChat.value.id === props.chat.id;
});
if (e.metaKey || e.ctrlKey) {
window.open(
window.chatwootConfig.hostURL + path,
'_blank',
'noopener noreferrer nofollow'
);
return;
}
if (this.isActiveChat) {
return;
}
const unreadCount = computed(() => props.chat.unread_count);
router.push({ path });
},
onThumbnailHover() {
this.hovered = !this.hideThumbnail;
},
onThumbnailLeave() {
this.hovered = false;
},
onSelectConversation(checked) {
const action = checked ? 'selectConversation' : 'deSelectConversation';
this.$emit(action, this.chat.id, this.inbox.id);
},
openContextMenu(e) {
if (!this.enableContextMenu) return;
e.preventDefault();
this.$emit('contextMenuToggle', true);
this.contextMenu.x = e.pageX || e.clientX;
this.contextMenu.y = e.pageY || e.clientY;
this.showContextMenu = true;
},
closeContextMenu() {
this.$emit('contextMenuToggle', false);
this.showContextMenu = false;
this.contextMenu.x = null;
this.contextMenu.y = null;
},
onUpdateConversation(status, snoozedUntil) {
this.closeContextMenu();
this.$emit(
'updateConversationStatus',
this.chat.id,
status,
snoozedUntil
);
},
async onAssignAgent(agent) {
this.$emit('assignAgent', agent, [this.chat.id]);
this.closeContextMenu();
},
async onAssignLabel(label) {
this.$emit('assignLabel', [label.title], [this.chat.id]);
this.closeContextMenu();
},
async onAssignTeam(team) {
this.$emit('assignTeam', team, this.chat.id);
this.closeContextMenu();
},
async markAsUnread() {
this.$emit('markAsUnread', this.chat.id);
this.closeContextMenu();
},
async markAsRead() {
this.$emit('markAsRead', this.chat.id);
this.closeContextMenu();
},
async assignPriority(priority) {
this.$emit('assignPriority', priority, this.chat.id);
this.closeContextMenu();
},
},
const hasUnread = computed(() => unreadCount.value > 0);
const isInboxNameVisible = computed(() => !activeInbox.value);
const lastMessageInChat = computed(() => getLastMessage(props.chat));
const inboxId = computed(() => props.chat.inbox_id);
const inbox = computed(() => {
return inboxId.value ? store.getters['inboxes/getInbox'](inboxId.value) : {};
});
const showInboxName = computed(() => {
return (
!props.hideInboxName &&
isInboxNameVisible.value &&
inboxesList.value.length > 1
);
});
const showMetaSection = computed(() => {
return (
showInboxName.value ||
(props.showAssignee && assignee.value.name) ||
props.chat.priority
);
});
const hasSlaPolicyId = computed(() => props.chat?.sla_policy_id);
const showLabelsSection = computed(() => {
return props.chat.labels?.length > 0 || hasSlaPolicyId.value;
});
const messagePreviewClass = computed(() => {
return [
hasUnread.value ? 'font-medium text-n-slate-12' : 'text-n-slate-11',
!props.compact && hasUnread.value ? 'ltr:pr-4 rtl:pl-4' : '',
props.compact && hasUnread.value ? 'ltr:pr-6 rtl:pl-6' : '',
];
});
const conversationPath = computed(() => {
return frontendURL(
conversationUrl({
accountId: accountId.value,
activeInbox: activeInbox.value,
id: props.chat.id,
label: props.activeLabel,
teamId: props.teamId,
conversationType: props.conversationType,
foldersId: props.foldersId,
})
);
});
const onCardClick = e => {
const path = conversationPath.value;
if (!path) return;
// Handle Ctrl/Cmd + Click for new tab
if (e.metaKey || e.ctrlKey) {
e.preventDefault();
window.open(
`${window.chatwootConfig.hostURL}${path}`,
'_blank',
'noopener,noreferrer'
);
return;
}
// Skip if already active
if (isActiveChat.value) return;
router.push({ path });
};
const onThumbnailHover = () => {
hovered.value = !props.hideThumbnail;
};
const onThumbnailLeave = () => {
hovered.value = false;
};
const onSelectConversation = checked => {
if (checked) {
emit('selectConversation', props.chat.id, inbox.value.id);
} else {
emit('deSelectConversation', props.chat.id, inbox.value.id);
}
};
const openContextMenu = e => {
if (!props.enableContextMenu) return;
e.preventDefault();
emit('contextMenuToggle', true);
contextMenu.value.x = e.pageX || e.clientX;
contextMenu.value.y = e.pageY || e.clientY;
showContextMenu.value = true;
};
const closeContextMenu = () => {
emit('contextMenuToggle', false);
showContextMenu.value = false;
contextMenu.value.x = null;
contextMenu.value.y = null;
};
const onUpdateConversation = (status, snoozedUntil) => {
closeContextMenu();
emit('updateConversationStatus', props.chat.id, status, snoozedUntil);
};
const onAssignAgent = agent => {
emit('assignAgent', agent, [props.chat.id]);
closeContextMenu();
};
const onAssignLabel = label => {
emit('assignLabel', [label.title], [props.chat.id]);
closeContextMenu();
};
const onAssignTeam = team => {
emit('assignTeam', team, props.chat.id);
closeContextMenu();
};
const markAsUnread = () => {
emit('markAsUnread', props.chat.id);
closeContextMenu();
};
const markAsRead = () => {
emit('markAsRead', props.chat.id);
closeContextMenu();
};
const assignPriority = priority => {
emit('assignPriority', priority, props.chat.id);
closeContextMenu();
};
const deleteConversation = () => {
emit('deleteConversation', props.chat.id);
closeContextMenu();
};
</script>
<template>
<div
class="relative flex items-start flex-grow-0 flex-shrink-0 w-auto max-w-full px-4 py-0 border-t-0 border-b-0 border-l-2 border-r-0 border-transparent border-solid cursor-pointer conversation hover:bg-n-alpha-1 dark:hover:bg-n-alpha-3 group"
class="relative flex items-start flex-grow-0 flex-shrink-0 w-auto max-w-full py-0 border-t-0 border-b-0 border-l-0 border-r-0 border-transparent border-solid cursor-pointer conversation hover:bg-n-alpha-1 dark:hover:bg-n-alpha-3 group"
:class="{
'active animate-card-select bg-n-alpha-1 dark:bg-n-alpha-3 border-n-weak':
isActiveChat,
'unread-chat': hasUnread,
'has-inbox-name': showInboxName,
'conversation-selected': selected,
'bg-n-slate-2 dark:bg-n-slate-3': selected,
'px-0': compact,
'px-3': !compact,
}"
@click="onCardClick"
@contextmenu="openContextMenu($event)"
@@ -259,46 +244,65 @@ export default {
@mouseenter="onThumbnailHover"
@mouseleave="onThumbnailLeave"
>
<label
v-if="hovered || selected"
class="checkbox-wrapper absolute inset-0 z-20 backdrop-blur-[2px]"
@click.stop
>
<input
:value="selected"
:checked="selected"
class="checkbox"
type="checkbox"
@change="onSelectConversation($event.target.checked)"
/>
</label>
<Thumbnail
<Avatar
v-if="!hideThumbnail"
:name="currentContact.name"
:src="currentContact.thumbnail"
:badge="inboxBadge"
:username="currentContact.name"
:size="32"
:status="currentContact.availability_status"
size="40px"
/>
:inbox="inbox"
:class="!showInboxName ? 'mt-4' : 'mt-8'"
hide-offline-status
rounded-full
>
<template #overlay="{ size }">
<label
v-if="hovered || selected"
class="flex items-center justify-center rounded-full cursor-pointer absolute inset-0 z-10 backdrop-blur-[2px]"
:style="{ width: `${size}px`, height: `${size}px` }"
@click.stop
>
<input
:value="selected"
:checked="selected"
class="!m-0 cursor-pointer"
type="checkbox"
@change="onSelectConversation($event.target.checked)"
/>
</label>
</template>
</Avatar>
</div>
<div
class="px-0 py-3 border-b group-hover:border-transparent flex-1 border-n-slate-3 w-[calc(100%-40px)]"
class="px-0 py-3 border-b group-hover:border-transparent flex-1 border-n-slate-3 min-w-0"
>
<div class="flex justify-between conversation-card--meta">
<InboxName v-if="showInboxName" :inbox="inbox" />
<div class="flex gap-2 ml-2 rtl:mr-2 rtl:ml-0">
<div
v-if="showMetaSection"
class="flex items-center min-w-0 gap-1"
:class="{
'ltr:ml-2 rtl:mr-2': !compact,
'mx-2': compact,
}"
>
<InboxName v-if="showInboxName" :inbox="inbox" class="flex-1 min-w-0" />
<div
class="flex items-center gap-2 flex-shrink-0"
:class="{
'flex-1 justify-between': !showInboxName,
}"
>
<span
v-if="showAssignee && assignee.name"
class="text-n-slate-11 text-xs font-medium leading-3 py-0.5 px-0 inline-flex text-ellipsis overflow-hidden whitespace-nowrap"
class="text-n-slate-11 text-xs font-medium leading-3 py-0.5 px-0 inline-flex items-center truncate"
>
<fluent-icon icon="person" size="12" class="text-n-slate-11" />
{{ assignee.name }}
</span>
<PriorityMark :priority="chat.priority" />
<PriorityMark :priority="chat.priority" class="flex-shrink-0" />
</div>
</div>
<h4
class="conversation--user text-sm my-0 mx-2 capitalize pt-0.5 text-ellipsis overflow-hidden whitespace-nowrap w-[calc(100%-70px)] text-n-slate-12"
class="conversation--user text-sm my-0 mx-2 capitalize pt-0.5 text-ellipsis overflow-hidden whitespace-nowrap flex-1 min-w-0 ltr:pr-16 rtl:pl-16 text-n-slate-12"
:class="hasUnread ? 'font-semibold' : 'font-medium'"
>
{{ currentContact.name }}
@@ -306,25 +310,26 @@ export default {
<MessagePreview
v-if="lastMessageInChat"
:message="lastMessageInChat"
class="conversation--message my-0 mx-2 leading-6 h-6 max-w-[96%] w-[16.875rem] text-sm"
:class="hasUnread ? 'font-medium text-n-slate-12' : 'text-n-slate-11'"
class="my-0 mx-2 leading-6 h-6 flex-1 min-w-0 text-sm"
:class="messagePreviewClass"
/>
<p
v-else
class="conversation--message text-n-slate-11 text-sm my-0 mx-2 leading-6 h-6 max-w-[96%] w-[16.875rem] overflow-hidden text-ellipsis whitespace-nowrap"
:class="hasUnread ? 'font-medium text-n-slate-12' : 'text-n-slate-11'"
class="text-n-slate-11 text-sm my-0 mx-2 leading-6 h-6 flex-1 min-w-0 overflow-hidden text-ellipsis whitespace-nowrap"
:class="messagePreviewClass"
>
<fluent-icon
size="16"
class="-mt-0.5 align-middle inline-block text-n-slate-10"
icon="info"
/>
<span>
<span class="mx-0.5">
{{ $t(`CHAT_LIST.NO_MESSAGES`) }}
</span>
</p>
<div
class="absolute flex flex-col conversation--meta ltr:right-4 rtl:left-4 top-4"
class="absolute flex flex-col ltr:right-3 rtl:left-3"
:class="showMetaSection ? 'top-8' : 'top-4'"
>
<span class="ml-auto font-normal leading-4 text-xxs">
<TimeAgo
@@ -333,12 +338,17 @@ export default {
/>
</span>
<span
class="unread shadow-lg rounded-full hidden text-xxs font-semibold h-4 leading-4 ml-auto mt-1 min-w-[1rem] px-1 py-0 text-center text-white bg-n-teal-9"
class="shadow-lg rounded-full text-xxs font-semibold h-4 leading-4 ltr:ml-auto rtl:mr-auto mt-1 min-w-[1rem] px-1 py-0 text-center text-white bg-n-teal-9"
:class="hasUnread ? 'block' : 'hidden'"
>
{{ unreadCount > 9 ? '9+' : unreadCount }}
</span>
</div>
<CardLabels :conversation-labels="chat.labels" class="mt-0.5 mx-2 mb-0">
<CardLabels
v-if="showLabelsSection"
:conversation-labels="chat.labels"
class="mt-0.5 mx-2 mb-0"
>
<template v-if="hasSlaPolicyId" #before>
<SLACardLabel :chat="chat" class="ltr:mr-1 rtl:ml-1" />
</template>
@@ -356,6 +366,8 @@ export default {
:priority="chat.priority"
:chat-id="chat.id"
:has-unread-messages="hasUnread"
:conversation-url="conversationPath"
:allowed-options="allowedContextMenuOptions"
@update-conversation="onUpdateConversation"
@assign-agent="onAssignAgent"
@assign-label="onAssignLabel"
@@ -363,59 +375,9 @@ export default {
@mark-as-unread="markAsUnread"
@mark-as-read="markAsRead"
@assign-priority="assignPriority"
@delete-conversation="deleteConversation"
@close="closeContextMenu"
/>
</ContextMenu>
</div>
</template>
<style lang="scss" scoped>
.conversation {
&.unread-chat {
.unread {
@apply block;
}
}
&.compact {
@apply pl-0;
.conversation-card--meta {
@apply ltr:pr-4 rtl:pl-4;
}
.conversation--details {
@apply rounded-sm ml-0 pl-5 pr-2;
}
}
&::v-deep .user-thumbnail-box {
@apply mt-4;
}
&.conversation-selected {
@apply bg-n-slate-2 dark:bg-n-slate-3;
}
&.has-inbox-name {
&::v-deep .user-thumbnail-box {
@apply mt-8;
}
.checkbox-wrapper {
@apply mt-8;
}
.conversation--meta {
@apply mt-4;
}
}
.checkbox-wrapper {
@apply flex items-center justify-center rounded-full cursor-pointer mt-4;
input[type='checkbox'] {
@apply m-0 cursor-pointer;
}
}
}
</style>
@@ -1,214 +1,157 @@
<script>
import { mapGetters } from 'vuex';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
<script setup>
import { computed, ref } from 'vue';
import { useRoute } from 'vue-router';
import { useStore } from 'vuex';
import { useElementSize } from '@vueuse/core';
import BackButton from '../BackButton.vue';
import inboxMixin from 'shared/mixins/inboxMixin';
import InboxName from '../InboxName.vue';
import MoreActions from './MoreActions.vue';
import Thumbnail from '../Thumbnail.vue';
import Avatar from 'next/avatar/Avatar.vue';
import SLACardLabel from './components/SLACardLabel.vue';
import wootConstants from 'dashboard/constants/globals';
import { conversationListPageURL } from 'dashboard/helper/URLHelper';
import { snoozedReopenTime } from 'dashboard/helper/snoozeHelpers';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import Linear from './linear/index.vue';
import { useInbox } from 'dashboard/composables/useInbox';
import { useI18n } from 'vue-i18n';
import NextButton from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
chat: {
type: Object,
default: () => ({}),
},
showBackButton: {
type: Boolean,
default: false,
},
});
export default {
components: {
BackButton,
InboxName,
MoreActions,
Thumbnail,
SLACardLabel,
Linear,
NextButton,
},
mixins: [inboxMixin],
props: {
chat: {
type: Object,
default: () => {},
},
isContactPanelOpen: {
type: Boolean,
default: false,
},
showBackButton: {
type: Boolean,
default: false,
},
isInboxView: {
type: Boolean,
default: false,
},
},
emits: ['contactPanelToggle'],
setup(props, { emit }) {
const keyboardEvents = {
'Alt+KeyO': {
action: () => emit('contactPanelToggle'),
},
};
useKeyboardEvents(keyboardEvents);
},
computed: {
...mapGetters({
currentChat: 'getSelectedChat',
accountId: 'getCurrentAccountId',
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
appIntegrations: 'integrations/getAppIntegrations',
}),
chatMetadata() {
return this.chat.meta;
},
backButtonUrl() {
const {
params: { accountId, inbox_id: inboxId, label, teamId },
name,
} = this.$route;
return conversationListPageURL({
accountId,
inboxId,
label,
teamId,
conversationType: name === 'conversation_mentions' ? 'mention' : '',
});
},
isHMACVerified() {
if (!this.isAWebWidgetInbox) {
return true;
}
return this.chatMetadata.hmac_verified;
},
currentContact() {
return this.$store.getters['contacts/getContact'](
this.chat.meta.sender.id
);
},
isSnoozed() {
return this.currentChat.status === wootConstants.STATUS_TYPE.SNOOZED;
},
snoozedDisplayText() {
const { snoozed_until: snoozedUntil } = this.currentChat;
if (snoozedUntil) {
return `${this.$t(
'CONVERSATION.HEADER.SNOOZED_UNTIL'
)} ${snoozedReopenTime(snoozedUntil)}`;
}
return this.$t('CONVERSATION.HEADER.SNOOZED_UNTIL_NEXT_REPLY');
},
contactPanelToggleText() {
return `${
this.isContactPanelOpen
? this.$t('CONVERSATION.HEADER.CLOSE')
: this.$t('CONVERSATION.HEADER.OPEN')
} ${this.$t('CONVERSATION.HEADER.DETAILS')}`;
},
inbox() {
const { inbox_id: inboxId } = this.chat;
return this.$store.getters['inboxes/getInbox'](inboxId);
},
hasMultipleInboxes() {
return this.$store.getters['inboxes/getInboxes'].length > 1;
},
hasSlaPolicyId() {
return this.chat?.sla_policy_id;
},
isLinearIntegrationEnabled() {
return this.appIntegrations.find(
integration => integration.id === 'linear' && !!integration.hooks.length
);
},
isLinearFeatureEnabled() {
return this.isFeatureEnabledonAccount(
this.accountId,
FEATURE_FLAGS.LINEAR
);
},
},
};
const { t } = useI18n();
const store = useStore();
const route = useRoute();
const conversationHeader = ref(null);
const { width } = useElementSize(conversationHeader);
const { isAWebWidgetInbox } = useInbox();
const currentChat = computed(() => store.getters.getSelectedChat);
const accountId = computed(() => store.getters.getCurrentAccountId);
const chatMetadata = computed(() => props.chat.meta);
const backButtonUrl = computed(() => {
const {
params: { inbox_id: inboxId, label, teamId, id: customViewId },
name,
} = route;
const conversationTypeMap = {
conversation_through_mentions: 'mention',
conversation_through_unattended: 'unattended',
};
return conversationListPageURL({
accountId: accountId.value,
inboxId,
label,
teamId,
conversationType: conversationTypeMap[name],
customViewId,
});
});
const isHMACVerified = computed(() => {
if (!isAWebWidgetInbox.value) {
return true;
}
return chatMetadata.value.hmac_verified;
});
const currentContact = computed(() =>
store.getters['contacts/getContact'](props.chat.meta.sender.id)
);
const isSnoozed = computed(
() => currentChat.value.status === wootConstants.STATUS_TYPE.SNOOZED
);
const snoozedDisplayText = computed(() => {
const { snoozed_until: snoozedUntil } = currentChat.value;
if (snoozedUntil) {
return `${t('CONVERSATION.HEADER.SNOOZED_UNTIL')} ${snoozedReopenTime(snoozedUntil)}`;
}
return t('CONVERSATION.HEADER.SNOOZED_UNTIL_NEXT_REPLY');
});
const inbox = computed(() => {
const { inbox_id: inboxId } = props.chat;
return store.getters['inboxes/getInbox'](inboxId);
});
const hasMultipleInboxes = computed(
() => store.getters['inboxes/getInboxes'].length > 1
);
const hasSlaPolicyId = computed(() => props.chat?.sla_policy_id);
</script>
<template>
<div
class="flex flex-col items-center justify-between px-4 py-2 border-b bg-n-background border-n-weak md:flex-row"
ref="conversationHeader"
class="flex flex-col gap-3 items-center justify-between flex-1 w-full min-w-0 xl:flex-row px-3 py-2 border-b bg-n-background border-n-weak h-24 xl:h-12"
>
<div
class="flex flex-col items-center justify-center flex-1 w-full min-w-0"
:class="isInboxView ? 'sm:flex-row' : 'md:flex-row'"
class="flex items-center justify-start w-full xl:w-auto max-w-full min-w-0 xl:flex-1"
>
<div class="flex items-center justify-start max-w-full min-w-0 w-fit">
<BackButton
v-if="showBackButton"
:back-url="backButtonUrl"
class="ltr:mr-2 rtl:ml-2"
/>
<Thumbnail
:src="currentContact.thumbnail"
:badge="inboxBadge"
:username="currentContact.name"
:status="currentContact.availability_status"
/>
<div
class="flex flex-col items-start min-w-0 ml-2 overflow-hidden rtl:ml-0 rtl:mr-2 w-fit"
>
<div
class="flex flex-row items-center max-w-full gap-1 p-0 m-0 w-fit"
<BackButton
v-if="showBackButton"
:back-url="backButtonUrl"
class="ltr:mr-2 rtl:ml-2"
/>
<Avatar
:name="currentContact.name"
:src="currentContact.thumbnail"
:size="32"
:status="currentContact.availability_status"
hide-offline-status
rounded-full
/>
<div
class="flex flex-col items-start min-w-0 ml-2 overflow-hidden rtl:ml-0 rtl:mr-2"
>
<div class="flex flex-row items-center max-w-full gap-1 p-0 m-0">
<span
class="text-sm font-medium truncate leading-tight text-n-slate-12"
>
<NextButton link slate @click.prevent="$emit('contactPanelToggle')">
<span
class="text-base font-medium truncate leading-tight text-n-slate-12"
>
{{ currentContact.name }}
</span>
</NextButton>
<fluent-icon
v-if="!isHMACVerified"
v-tooltip="$t('CONVERSATION.UNVERIFIED_SESSION')"
size="14"
class="text-n-amber-10 my-0 mx-0 min-w-[14px]"
icon="warning"
/>
</div>
{{ currentContact.name }}
</span>
<fluent-icon
v-if="!isHMACVerified"
v-tooltip="$t('CONVERSATION.UNVERIFIED_SESSION')"
size="14"
class="text-n-amber-10 my-0 mx-0 min-w-[14px] flex-shrink-0"
icon="warning"
/>
</div>
<div
class="flex items-center gap-2 overflow-hidden text-xs conversation--header--actions text-ellipsis whitespace-nowrap"
>
<InboxName v-if="hasMultipleInboxes" :inbox="inbox" />
<span v-if="isSnoozed" class="font-medium text-n-amber-10">
{{ snoozedDisplayText }}
</span>
<NextButton
link
xs
blue
:label="contactPanelToggleText"
@click="$emit('contactPanelToggle')"
/>
</div>
<div
class="flex items-center gap-2 overflow-hidden text-xs conversation--header--actions text-ellipsis whitespace-nowrap"
>
<InboxName v-if="hasMultipleInboxes" :inbox="inbox" class="!mx-0" />
<span v-if="isSnoozed" class="font-medium text-n-amber-10">
{{ snoozedDisplayText }}
</span>
</div>
</div>
<div
class="flex flex-row items-center justify-end flex-grow gap-2 mt-3 header-actions-wrap lg:mt-0"
:class="{ 'justify-end': isContactPanelOpen }"
>
<SLACardLabel v-if="hasSlaPolicyId" :chat="chat" show-extended-info />
<Linear
v-if="isLinearIntegrationEnabled && isLinearFeatureEnabled"
:conversation-id="currentChat.id"
/>
<MoreActions :conversation-id="currentChat.id" />
</div>
</div>
<div
class="flex flex-row items-center justify-start xl:justify-end flex-shrink-0 gap-2 w-full xl:w-auto header-actions-wrap"
>
<SLACardLabel
v-if="hasSlaPolicyId"
:chat="chat"
show-extended-info
:parent-width="width"
class="hidden md:flex"
/>
<MoreActions :conversation-id="currentChat.id" />
</div>
</div>
</template>
<style lang="scss" scoped>
.conversation--header--actions {
::v-deep .inbox--name {
@apply m-0;
}
}
</style>
@@ -1,81 +1,60 @@
<script setup>
import { computed, ref } from 'vue';
import CopilotContainer from '../../copilot/CopilotContainer.vue';
import { computed } from 'vue';
import ContactPanel from 'dashboard/routes/dashboard/conversation/ContactPanel.vue';
import TabBar from 'dashboard/components-next/tabbar/TabBar.vue';
import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store';
import { FEATURE_FLAGS } from '../../../featureFlags';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useWindowSize } from '@vueuse/core';
import { vOnClickOutside } from '@vueuse/components';
import wootConstants from 'dashboard/constants/globals';
const props = defineProps({
defineProps({
currentChat: {
required: true,
type: Object,
},
});
const emit = defineEmits(['toggleContactPanel']);
const { uiSettings, updateUISettings } = useUISettings();
const { width: windowWidth } = useWindowSize();
const { t } = useI18n();
const activeTab = computed(() => {
const { is_contact_sidebar_open: isContactSidebarOpen } = uiSettings.value;
const channelType = computed(() => props.currentChat?.meta?.channel || '');
const CONTACT_TABS_OPTIONS = [
{ key: 'CONTACT', value: 'contact' },
{ key: 'COPILOT', value: 'copilot' },
];
const tabs = computed(() => {
return CONTACT_TABS_OPTIONS.map(tab => ({
label: t(`CONVERSATION.SIDEBAR.${tab.key}`),
value: tab.value,
}));
if (isContactSidebarOpen) {
return 0;
}
return null;
});
const activeTab = ref(0);
const toggleContactPanel = () => {
emit('toggleContactPanel');
};
const handleTabChange = selectedTab => {
activeTab.value = tabs.value.findIndex(
tabItem => tabItem.value === selectedTab.value
);
};
const currentAccountId = useMapGetter('getCurrentAccountId');
const isFeatureEnabledonAccount = useMapGetter(
'accounts/isFeatureEnabledonAccount'
const isSmallScreen = computed(
() => windowWidth.value < wootConstants.SMALL_SCREEN_BREAKPOINT
);
const showCopilotTab = computed(() =>
isFeatureEnabledonAccount.value(currentAccountId.value, FEATURE_FLAGS.CAPTAIN)
);
const closeContactPanel = () => {
if (isSmallScreen.value && uiSettings.value?.is_contact_sidebar_open) {
updateUISettings({
is_contact_sidebar_open: false,
is_copilot_panel_open: false,
});
}
};
</script>
<template>
<div
class="ltr:border-l rtl:border-r border-n-weak h-full overflow-hidden z-10 w-80 min-w-80 2xl:min-w-96 2xl:w-96 flex flex-col bg-n-background"
v-on-click-outside="() => closeContactPanel()"
class="bg-n-background h-full overflow-hidden flex flex-col fixed top-0 z-40 w-full max-w-sm transition-transform duration-300 ease-in-out ltr:right-0 rtl:left-0 md:static md:w-[320px] md:min-w-[320px] ltr:border-l rtl:border-r border-n-weak 2xl:min-w-[360px] 2xl:w-[360px] shadow-lg md:shadow-none"
:class="[
{
'md:flex': activeTab === 0,
'md:hidden': activeTab !== 0,
},
]"
>
<div v-if="showCopilotTab" class="p-2">
<TabBar
:tabs="tabs"
:initial-active-tab="activeTab"
class="w-full [&>button]:w-full"
@tab-changed="handleTabChange"
/>
</div>
<div class="flex flex-1 overflow-auto">
<ContactPanel
v-if="!activeTab"
v-show="activeTab === 0"
:conversation-id="currentChat.id"
:inbox-id="currentChat.inbox_id"
:on-toggle="toggleContactPanel"
/>
<CopilotContainer
v-else-if="activeTab === 1 && showCopilotTab"
:key="currentChat.id"
:conversation-inbox-type="channelType"
:conversation-id="currentChat.id"
class="flex-1"
/>
</div>
</div>
@@ -23,9 +23,7 @@ export default {
src="dashboard/assets/images/no-chat.svg"
alt="No Chat"
/>
<span
class="text-sm text-slate-800 dark:text-slate-200 font-medium text-center"
>
<span class="text-sm text-n-slate-12 font-medium text-center">
{{ message }}
<br />
</span>
@@ -1,791 +0,0 @@
<script>
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import BubbleActions from './bubble/Actions.vue';
import BubbleContact from './bubble/Contact.vue';
import BubbleFile from './bubble/File.vue';
import BubbleImageAudioVideo from './bubble/ImageAudioVideo.vue';
import BubbleIntegration from './bubble/Integration.vue';
import BubbleLocation from './bubble/Location.vue';
import BubbleMailHead from './bubble/MailHead.vue';
import BubbleReplyTo from './bubble/ReplyTo.vue';
import BubbleText from './bubble/Text.vue';
import ContextMenu from 'dashboard/modules/conversations/components/MessageContextMenu.vue';
import InstagramStory from './bubble/InstagramStory.vue';
import InstagramStoryReply from './bubble/InstagramStoryReply.vue';
import Spinner from 'shared/components/Spinner.vue';
import { CONTENT_TYPES } from 'shared/constants/contentType';
import { MESSAGE_TYPE, MESSAGE_STATUS } from 'shared/constants/messages';
import { generateBotMessageContent } from './helpers/botMessageContentHelper';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { ACCOUNT_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import { LocalStorage } from 'shared/helpers/localStorage';
import { getDayDifferenceFromNow } from 'shared/helpers/DateHelper';
import * as Sentry from '@sentry/vue';
import { useTrack } from 'dashboard/composables';
import { emitter } from 'shared/helpers/mitt';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: {
BubbleActions,
BubbleContact,
BubbleFile,
BubbleImageAudioVideo,
BubbleIntegration,
BubbleLocation,
BubbleMailHead,
BubbleReplyTo,
BubbleText,
ContextMenu,
InstagramStory,
InstagramStoryReply,
Spinner,
NextButton,
},
props: {
data: {
type: Object,
required: true,
},
isATweet: {
type: Boolean,
default: false,
},
isAFacebookInbox: {
type: Boolean,
default: false,
},
isInstagram: {
type: Boolean,
default: false,
},
isAWhatsAppChannel: {
type: Boolean,
default: false,
},
isAnEmailInbox: {
type: Boolean,
default: false,
},
inboxSupportsReplyTo: {
type: Object,
default: () => ({}),
},
inReplyTo: {
type: Object,
default: () => ({}),
},
},
setup() {
const { formatMessage } = useMessageFormatter();
return {
formatMessage,
};
},
data() {
return {
showContextMenu: false,
hasMediaLoadError: false,
contextMenuPosition: {},
showBackgroundHighlight: false,
};
},
computed: {
attachments() {
// Here it is used to get sender and created_at for each attachment
return this.data?.attachments.map(attachment => ({
...attachment,
sender: this.data.sender || {},
created_at: this.data.created_at || '',
}));
},
hasOneDayPassed() {
// Disable retry button if the message is failed and the message is older than 24 hours
return getDayDifferenceFromNow(new Date(), this.data?.created_at) >= 1;
},
shouldRenderMessage() {
return (
this.hasAttachments ||
this.data.content ||
this.isEmailContentType ||
this.isUnsupported ||
this.isAnIntegrationMessage
);
},
emailMessageContent() {
const {
html_content: { full: fullHTMLContent } = {},
text_content: { full: fullTextContent } = {},
} = this.contentAttributes.email || {};
if (fullHTMLContent) {
return fullHTMLContent;
}
if (fullTextContent) {
return fullTextContent.replace(/\n/g, '<br>');
}
return '';
},
displayQuotedButton() {
if (this.emailMessageContent.includes('<blockquote')) {
return true;
}
if (!this.isIncoming) {
return false;
}
return false;
},
message() {
// If the message is an email, emailMessageContent would be present
// In that case, we would use letter package to render the email
if (this.emailMessageContent && this.isIncoming) {
return this.emailMessageContent;
}
const botMessageContent = generateBotMessageContent(
this.contentType,
this.contentAttributes,
{
noResponseText: this.$t('CONVERSATION.NO_RESPONSE'),
csat: {
ratingTitle: this.$t('CONVERSATION.RATING_TITLE'),
feedbackTitle: this.$t('CONVERSATION.FEEDBACK_TITLE'),
},
}
);
if (this.contentType === 'input_csat') {
return this.$t('CONVERSATION.CSAT_REPLY_MESSAGE') + botMessageContent;
}
return (
this.formatMessage(
this.data.content,
this.isATweet,
this.data.private
) + botMessageContent
);
},
inReplyToMessageId() {
// Why not use the inReplyTo object directly?
// Glad you asked! The inReplyTo object may or may not be available
// depending on the current scroll position of the message list
// since old messages are only loaded when the user scrolls up
return this.data.content_attributes?.in_reply_to;
},
isAnInstagramStory() {
return this.contentAttributes.image_type === 'story_mention';
},
contextMenuEnabledOptions() {
return {
copy: this.hasText,
delete: this.hasText || this.hasAttachments,
cannedResponse: this.isOutgoing && this.hasText,
replyTo: !this.data.private && this.inboxSupportsReplyTo.outgoing,
};
},
contentAttributes() {
return this.data.content_attributes || {};
},
externalError() {
return this.contentAttributes.external_error || '';
},
sender() {
return this.data.sender || {};
},
status() {
return this.data.status;
},
storySender() {
return this.contentAttributes.story_sender || null;
},
storyId() {
return this.contentAttributes.story_id || null;
},
storyUrl() {
return this.contentAttributes.story_url || null;
},
contentType() {
const {
data: { content_type: contentType },
} = this;
return contentType;
},
twitterProfileLink() {
const additionalAttributes = this.sender.additional_attributes || {};
const { screen_name: screenName } = additionalAttributes;
return `https://twitter.com/${screenName}`;
},
alignBubble() {
const { message_type: messageType } = this.data;
const isCentered = messageType === MESSAGE_TYPE.ACTIVITY;
const isLeftAligned = messageType === MESSAGE_TYPE.INCOMING;
const isRightAligned =
messageType === MESSAGE_TYPE.OUTGOING ||
messageType === MESSAGE_TYPE.TEMPLATE;
return {
center: isCentered,
left: isLeftAligned,
right: isRightAligned,
'has-context-menu': this.showContextMenu,
// this handles the offset required to align the context menu button
// extra alignment is required since a tweet message has a the user name and avatar below it
'has-tweet-menu': this.isATweet,
'has-bg': this.showBackgroundHighlight,
};
},
createdAt() {
return this.contentAttributes.external_created_at || this.data.created_at;
},
isBubble() {
return [0, 1, 3].includes(this.data.message_type);
},
isIncoming() {
return this.data.message_type === MESSAGE_TYPE.INCOMING;
},
isOutgoing() {
return this.data.message_type === MESSAGE_TYPE.OUTGOING;
},
isTemplate() {
return this.data.message_type === MESSAGE_TYPE.TEMPLATE;
},
isAnIntegrationMessage() {
return this.contentType === 'integrations';
},
emailHeadAttributes() {
return {
email: this.contentAttributes.email,
cc: this.contentAttributes.cc_emails,
bcc: this.contentAttributes.bcc_emails,
};
},
hasAttachments() {
return !!(this.data.attachments && this.data.attachments.length > 0);
},
isMessageDeleted() {
return this.contentAttributes.deleted;
},
hasText() {
return !!this.data.content;
},
tooltipForSender() {
const name = this.senderNameForAvatar;
const { message_type: messageType } = this.data;
const showTooltip =
messageType === MESSAGE_TYPE.OUTGOING ||
messageType === MESSAGE_TYPE.TEMPLATE;
return showTooltip
? {
content: `${this.$t('CONVERSATION.SENT_BY')} ${name}`,
}
: false;
},
errorMessageTooltip() {
if (this.isFailed) {
return this.externalError || this.$t(`CONVERSATION.SEND_FAILED`);
}
return '';
},
wrapClass() {
return {
wrap: this.isBubble,
'activity-wrap': !this.isBubble,
'is-pending': this.isPending,
'is-failed': this.isFailed,
'is-email': this.isEmailContentType,
};
},
bubbleClass() {
return {
bubble: this.isBubble,
'is-private': this.data.private,
'is-unsupported': this.isUnsupported,
'is-image': this.hasMediaAttachment('image'),
'is-video': this.hasMediaAttachment('video'),
'is-text': this.hasText,
'is-from-bot': this.isSentByBot,
'is-failed': this.isFailed,
'is-email': this.isEmailContentType,
};
},
isUnsupported() {
return this.contentAttributes.is_unsupported ?? false;
},
isPending() {
return this.data.status === MESSAGE_STATUS.PROGRESS;
},
isFailed() {
return this.data.status === MESSAGE_STATUS.FAILED;
},
isSentByBot() {
if (this.isPending || this.isFailed) return false;
return !this.sender.type || this.sender.type === 'agent_bot';
},
shouldShowContextMenu() {
return !(this.isFailed || this.isPending || this.isUnsupported);
},
showAvatar() {
if (this.isOutgoing || this.isTemplate) {
return true;
}
return this.isATweet && this.isIncoming && this.sender;
},
senderNameForAvatar() {
if (this.isOutgoing || this.isTemplate) {
const { name = this.$t('CONVERSATION.BOT') } = this.sender || {};
return name;
}
return '';
},
isEmailContentType() {
return this.contentType === CONTENT_TYPES.INCOMING_EMAIL;
},
},
watch: {
data() {
this.hasMediaLoadError = false;
},
},
mounted() {
this.hasMediaLoadError = false;
emitter.on(BUS_EVENTS.ON_MESSAGE_LIST_SCROLL, this.closeContextMenu);
this.setupHighlightTimer();
},
unmounted() {
emitter.off(BUS_EVENTS.ON_MESSAGE_LIST_SCROLL, this.closeContextMenu);
clearTimeout(this.higlightTimeout);
},
methods: {
isAttachmentImageVideoAudio(fileType) {
return ['image', 'audio', 'video', 'story_mention', 'ig_reel'].includes(
fileType
);
},
hasMediaAttachment(type) {
if (this.hasAttachments && this.data.attachments.length > 0) {
return this.compareMessageFileType(this.data, type);
}
return false;
},
compareMessageFileType(messageData, type) {
try {
const { attachments = [{}] } = messageData;
const { file_type: fileType } = attachments[0];
return fileType === type && !this.hasMediaLoadError;
} catch (err) {
Sentry.setContext('attachment-parsing-error', {
messageData,
type,
hasMediaLoadError: this.hasMediaLoadError,
});
Sentry.captureException(err);
return false;
}
},
handleContextMenuClick() {
this.showContextMenu = !this.showContextMenu;
},
async retrySendMessage() {
await this.$store.dispatch('sendMessageWithData', this.data);
},
onMediaLoadError() {
this.hasMediaLoadError = true;
},
openContextMenu(e) {
const shouldSkipContextMenu =
e.target?.classList.contains('skip-context-menu') ||
e.target?.tagName.toLowerCase() === 'a';
if (shouldSkipContextMenu || getSelection().toString()) {
return;
}
e.preventDefault();
if (e.type === 'contextmenu') {
useTrack(ACCOUNT_EVENTS.OPEN_MESSAGE_CONTEXT_MENU);
}
this.contextMenuPosition = {
x: e.pageX || e.clientX,
y: e.pageY || e.clientY,
};
this.showContextMenu = true;
},
closeContextMenu() {
this.showContextMenu = false;
this.contextMenuPosition = { x: null, y: null };
},
handleReplyTo() {
const replyStorageKey = LOCAL_STORAGE_KEYS.MESSAGE_REPLY_TO;
const { conversation_id: conversationId, id: replyTo } = this.data;
LocalStorage.updateJsonStore(replyStorageKey, conversationId, replyTo);
emitter.emit(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE, this.data);
},
setupHighlightTimer() {
if (Number(this.$route.query.messageId) !== Number(this.data.id)) {
return;
}
this.showBackgroundHighlight = true;
const HIGHLIGHT_TIMER = 1000;
this.higlightTimeout = setTimeout(() => {
this.showBackgroundHighlight = false;
}, HIGHLIGHT_TIMER);
},
},
};
</script>
<!-- eslint-disable-next-line vue/no-root-v-if -->
<template>
<li
v-if="shouldRenderMessage"
:id="`message${data.id}`"
class="group/context-menu"
:class="[alignBubble]"
>
<div :class="wrapClass">
<div
v-if="isFailed && !hasOneDayPassed && !isAnEmailInbox"
class="message-failed--alert"
>
<NextButton
v-tooltip.top-end="$t('CONVERSATION.TRY_AGAIN')"
ghost
xs
ruby
icon="i-lucide-refresh-ccw"
@click="retrySendMessage"
/>
</div>
<div :class="bubbleClass" @contextmenu="openContextMenu($event)">
<BubbleMailHead
:email-attributes="contentAttributes.email"
:cc="emailHeadAttributes.cc"
:bcc="emailHeadAttributes.bcc"
:is-incoming="isIncoming"
/>
<InstagramStoryReply v-if="storyUrl" :story-url="storyUrl" />
<BubbleReplyTo
v-if="inReplyToMessageId && inboxSupportsReplyTo.incoming"
:message="inReplyTo"
:message-type="data.message_type"
:parent-has-attachments="hasAttachments"
/>
<div v-if="isUnsupported">
<template v-if="isAFacebookInbox && isInstagram">
{{ $t('CONVERSATION.UNSUPPORTED_MESSAGE_INSTAGRAM') }}
</template>
<template v-else-if="isAFacebookInbox">
{{ $t('CONVERSATION.UNSUPPORTED_MESSAGE_FACEBOOK') }}
</template>
<template v-else>
{{ $t('CONVERSATION.UNSUPPORTED_MESSAGE') }}
</template>
</div>
<BubbleText
v-else-if="data.content"
:message="message"
:is-email="isEmailContentType"
:display-quoted-button="displayQuotedButton"
/>
<BubbleIntegration
:message-id="data.id"
:content-attributes="contentAttributes"
:inbox-id="data.inbox_id"
/>
<span
v-if="isPending && hasAttachments"
class="chat-bubble has-attachment agent"
>
{{ $t('CONVERSATION.UPLOADING_ATTACHMENTS') }}
</span>
<div v-if="!isPending && hasAttachments">
<div v-for="attachment in attachments" :key="attachment.id">
<InstagramStory
v-if="isAnInstagramStory"
:story-url="attachment.data_url"
@error="onMediaLoadError"
/>
<BubbleImageAudioVideo
v-else-if="isAttachmentImageVideoAudio(attachment.file_type)"
:attachment="attachment"
@error="onMediaLoadError"
/>
<BubbleLocation
v-else-if="attachment.file_type === 'location'"
:latitude="attachment.coordinates_lat"
:longitude="attachment.coordinates_long"
:name="attachment.fallback_title"
/>
<BubbleContact
v-else-if="attachment.file_type === 'contact'"
:name="data.content"
:phone-number="attachment.fallback_title"
/>
<BubbleFile v-else :url="attachment.data_url" />
</div>
</div>
<BubbleActions
:id="data.id"
:sender="data.sender"
:story-sender="storySender"
:external-error="errorMessageTooltip"
:story-id="`${storyId}`"
:is-a-tweet="isATweet"
:is-a-whatsapp-channel="isAWhatsAppChannel"
:is-email="isEmailContentType"
:is-private="data.private"
:message-type="data.message_type"
:message-status="status"
:source-id="data.source_id"
:inbox-id="data.inbox_id"
:created-at="createdAt"
/>
</div>
<Spinner v-if="isPending" size="tiny" />
<div
v-if="showAvatar"
v-tooltip.left="tooltipForSender"
class="sender--info"
>
<woot-thumbnail
:src="sender.thumbnail"
:username="senderNameForAvatar"
size="16px"
/>
<a
v-if="isATweet && isIncoming"
class="sender--available-name"
:href="twitterProfileLink"
target="_blank"
rel="noopener noreferrer nofollow"
>
{{ sender.name }}
</a>
</div>
</div>
<div v-if="shouldShowContextMenu" class="context-menu-wrap">
<ContextMenu
v-if="isBubble && !isMessageDeleted"
:context-menu-position="contextMenuPosition"
:is-open="showContextMenu"
:enabled-options="contextMenuEnabledOptions"
:message="data"
@open="openContextMenu"
@close="closeContextMenu"
@reply-to="handleReplyTo"
/>
</div>
</li>
</template>
<style lang="scss">
.wrap {
> .bubble {
@apply min-w-[128px];
&.is-unsupported {
@apply text-xs max-w-[300px] border-dashed border border-slate-200 text-slate-600 dark:text-slate-200 bg-slate-50 dark:bg-slate-700 dark:border-slate-500;
.message-text--metadata .time {
@apply text-slate-400 dark:text-slate-300;
}
}
&.is-image,
&.is-video {
@apply p-0 overflow-hidden;
.image,
.video {
@apply max-w-[20rem] p-0.5;
> img,
> video {
/** ensure that the bubble radius and image radius match*/
@apply rounded-[0.4rem];
}
> video {
@apply h-full w-full object-cover;
}
}
.video {
@apply h-[11.25rem];
}
}
&.is-image.is-text > .message-text__wrap,
&.is-video.is-text > .message-text__wrap {
@apply max-w-[20rem] py-2 px-4;
}
&.is-private .file.message-text__wrap {
.file--icon {
@apply text-woot-400 dark:text-woot-400;
}
.attachment-name {
@apply text-slate-700 dark:text-slate-200;
}
.download.button {
@apply text-woot-400 dark:text-woot-400;
}
}
&.is-private.is-text > .message-text__wrap .link {
@apply text-woot-600 dark:text-woot-200;
}
&.is-private.is-text > .message-text__wrap .prosemirror-mention-node {
@apply font-bold bg-none rounded-sm p-0 bg-yellow-100 dark:bg-yellow-700 text-slate-700 dark:text-slate-25 underline;
}
&.is-from-bot {
@apply bg-violet-400 dark:bg-violet-400;
.message-text--metadata .time {
@apply text-violet-50 dark:text-violet-50;
}
&.is-private .message-text--metadata .time {
@apply text-slate-400 dark:text-slate-400;
}
}
&.is-failed {
@apply bg-n-ruby-4 dark:bg-n-ruby-4 text-n-slate-12;
.message-text--metadata .time {
@apply text-n-ruby-12 dark:text-n-ruby-12;
}
}
}
&.is-pending {
@apply relative opacity-80;
.spinner {
@apply absolute bottom-1 right-1;
}
> .is-image.is-text.bubble > .message-text__wrap {
@apply p-0;
}
}
}
.wrap.is-email {
--bubble-max-width: 84% !important;
}
.sender--info {
@apply items-center text-black-700 dark:text-black-100 inline-flex py-1 px-0;
.sender--available-name {
@apply text-xs ml-1;
}
}
.message-failed--alert {
@apply text-red-900 dark:text-red-900 flex-grow text-right mt-1 mr-1 mb-0 ml-0;
}
li.left,
li.right {
@apply flex items-end;
}
li.left.has-tweet-menu .context-menu {
// this handles the offset required to align the context menu button
// extra alignment is required since a tweet message has a the user name and avatar below it
@apply mb-6;
}
li.has-bg {
@apply bg-woot-75 dark:bg-woot-600;
}
li.right .context-menu-wrap {
@apply ml-auto;
}
li.right {
@apply flex-row-reverse justify-end;
.wrap.is-pending {
@apply ml-auto;
}
.wrap.is-failed {
@apply flex items-end ltr:ml-auto rtl:mr-auto;
}
}
.has-context-menu {
@apply bg-slate-50 dark:bg-slate-700;
}
.context-menu {
@apply relative;
}
/* Markdown styling */
.bubble .text-content {
p code {
@apply bg-slate-75 dark:bg-slate-700 inline-block leading-none rounded-sm p-1;
}
ol li {
@apply list-item list-decimal;
}
pre {
@apply bg-slate-75 dark:bg-slate-700 block border-slate-75 dark:border-slate-700 text-slate-800 dark:text-slate-100 rounded-md p-2 mt-1 mb-2 leading-relaxed whitespace-pre-wrap;
code {
@apply bg-transparent text-slate-800 dark:text-slate-100 p-0;
}
}
blockquote {
@apply border-l-4 mx-0 my-1 pt-2 pr-2 pb-0 pl-4 border-slate-75 border-solid dark:border-slate-600 text-slate-800 dark:text-slate-100;
p {
@apply text-slate-800 dark:text-slate-300;
}
}
}
.right .bubble .text-content {
p code {
@apply bg-woot-600 dark:bg-woot-600 text-white dark:text-white;
}
pre {
@apply bg-woot-800 dark:bg-woot-800 border-woot-700 dark:border-woot-700 text-white dark:text-white;
code {
@apply bg-transparent text-white dark:text-white;
}
}
blockquote {
@apply border-l-4 border-solid border-woot-400 dark:border-woot-400 text-white dark:text-white;
p {
@apply text-woot-75 dark:text-woot-75;
}
}
}
</style>
@@ -66,26 +66,26 @@ export default {
<fluent-icon
v-if="isMessagePrivate"
size="16"
class="-mt-0.5 align-middle text-slate-600 dark:text-slate-300 inline-block"
class="-mt-0.5 align-middle text-n-slate-11 inline-block"
icon="lock-closed"
/>
<fluent-icon
v-else-if="messageByAgent"
size="16"
class="-mt-0.5 align-middle text-slate-600 dark:text-slate-300 inline-block"
class="-mt-0.5 align-middle text-n-slate-11 inline-block"
icon="arrow-reply"
/>
<fluent-icon
v-else-if="isMessageAnActivity"
size="16"
class="-mt-0.5 align-middle text-slate-600 dark:text-slate-300 inline-block"
class="-mt-0.5 align-middle text-n-slate-11 inline-block"
icon="info"
/>
</template>
<span v-if="message.content && isMessageSticker">
<fluent-icon
size="16"
class="-mt-0.5 align-middle inline-block text-slate-600 dark:text-slate-300"
class="-mt-0.5 align-middle inline-block text-n-slate-11"
icon="image"
/>
{{ $t('CHAT_LIST.ATTACHMENTS.image.CONTENT') }}
@@ -97,7 +97,7 @@ export default {
<fluent-icon
v-if="attachmentIcon && showMessageType"
size="16"
class="-mt-0.5 align-middle inline-block text-slate-600 dark:text-slate-300"
class="-mt-0.5 align-middle inline-block text-n-slate-11"
:icon="attachmentIcon"
/>
{{ $t(`${attachmentMessageContent}`) }}
@@ -11,7 +11,7 @@ const openProfileSettings = () => {
<template>
<div
class="my-0 mx-4 px-1 flex max-h-[8vh] items-baseline justify-between hover:bg-slate-25 dark:hover:bg-slate-800 border border-dashed border-slate-100 dark:border-slate-700 rounded-sm overflow-auto"
class="my-0 mx-4 px-1 flex max-h-[8vh] items-baseline justify-between hover:bg-n-slate-1 border border-dashed border-n-weak rounded-sm overflow-auto"
>
<p class="w-fit !m-0">
{{ $t('CONVERSATION.FOOTER.MESSAGE_SIGNATURE_NOT_CONFIGURED') }}
@@ -1,19 +1,19 @@
<script>
import { ref } from 'vue';
import { ref, provide } from 'vue';
// composable
import { useConfig } from 'dashboard/composables/useConfig';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { useAI } from 'dashboard/composables/useAI';
import { useMapGetter } from 'dashboard/composables/store';
// components
import ReplyBox from './ReplyBox.vue';
import Message from './Message.vue';
import NextMessageList from 'next/message/MessageList.vue';
import TypingIndicator from 'next/Conversation/Chips/TypingIndicator.vue';
import UnreadIndicator from 'next/Conversation/Chips/UnreadIndicator.vue';
import MessageList from 'next/message/MessageList.vue';
import ConversationLabelSuggestion from './conversation/LabelSuggestion.vue';
import Banner from 'dashboard/components/ui/Banner.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
// stores and apis
import { mapGetters } from 'vuex';
@@ -37,49 +37,29 @@ import { BUS_EVENTS } from 'shared/constants/busEvents';
import { REPLY_POLICY } from 'shared/constants/links';
import wootConstants from 'dashboard/constants/globals';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import { FEATURE_FLAGS } from '../../../featureFlags';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: {
Message,
NextMessageList,
MessageList,
ReplyBox,
TypingIndicator,
UnreadIndicator,
Banner,
ConversationLabelSuggestion,
NextButton,
Spinner,
},
mixins: [inboxMixin],
props: {
isContactPanelOpen: {
type: Boolean,
default: false,
},
isInboxView: {
type: Boolean,
default: false,
},
},
emits: ['contactPanelToggle'],
setup() {
const isPopOutReplyBox = ref(false);
const conversationPanelRef = ref(null);
const { isEnterprise } = useConfig();
const closePopOutReplyBox = () => {
isPopOutReplyBox.value = false;
};
const showPopOutReplyBox = () => {
isPopOutReplyBox.value = !isPopOutReplyBox.value;
};
const keyboardEvents = {
Escape: {
action: closePopOutReplyBox,
action: () => {
isPopOutReplyBox.value = false;
},
},
};
@@ -106,18 +86,18 @@ export default {
currentAccountId.value,
FEATURE_FLAGS.CHATWOOT_V4
);
provide('contextMenuElementTarget', conversationPanelRef);
return {
isEnterprise,
isPopOutReplyBox,
closePopOutReplyBox,
showPopOutReplyBox,
isAIIntegrationEnabled,
isLabelSuggestionFeatureEnabled,
fetchIntegrationsIfRequired,
fetchLabelSuggestions,
useNewScrollBehavior,
showNextBubbles,
conversationPanelRef,
};
},
data() {
@@ -184,26 +164,6 @@ export default {
(!this.listLoadingStatus && this.isLoadingPrevious)
);
},
conversationType() {
const { additional_attributes: additionalAttributes } = this.currentChat;
const type = additionalAttributes ? additionalAttributes.type : '';
return type || '';
},
isATweet() {
return this.conversationType === 'tweet';
},
isRightOrLeftIcon() {
if (this.isContactPanelOpen) {
return 'arrow-chevron-right';
}
return 'arrow-chevron-left';
},
getLastSeenAt() {
const { contact_last_seen_at: contactLastSeenAt } = this.currentChat;
return contactLastSeenAt;
},
// Check there is a instagram inbox exists with the same instagram_id
hasDuplicateInstagramInbox() {
const instagramId = this.inbox.instagram_id;
@@ -285,9 +245,6 @@ export default {
count: formatter.format(this.unreadMessageCount),
});
},
isInstagramDM() {
return this.conversationType === 'instagram_direct_message';
},
inboxSupportsReplyTo() {
const incoming = this.inboxHasFeature(INBOX_FEATURES.REPLY_TO);
const outgoing =
@@ -487,9 +444,6 @@ export default {
relevantMessages
);
},
onToggleContactPanel() {
this.$emit('contactPanelToggle');
},
setScrollParams() {
this.heightBeforeLoad = this.conversationPanel.scrollHeight;
this.scrollTopBeforeLoad = this.conversationPanel.scrollTop;
@@ -598,22 +552,9 @@ export default {
class="mx-2 mt-2 overflow-hidden rounded-lg"
:banner-message="$t('CONVERSATION.OLD_INSTAGRAM_INBOX_REPLY_BANNER')"
/>
<div class="flex justify-end">
<NextButton
faded
xs
slate
class="!rounded-r-none rtl:rotate-180 !rounded-2xl !fixed z-10"
:icon="
isContactPanelOpen ? 'i-ph-caret-right-fill' : 'i-ph-caret-left-fill'
"
:class="isInboxView ? 'top-52 md:top-40' : 'top-32'"
@click="onToggleContactPanel"
/>
</div>
<NextMessageList
v-if="showNextBubbles"
class="conversation-panel"
<MessageList
ref="conversationPanelRef"
class="conversation-panel flex-shrink flex-grow basis-px flex flex-col overflow-y-auto relative h-full m-0 pb-4"
:current-user-id="currentUserId"
:first-unread-id="unReadMessages[0]?.id"
:is-an-email-channel="isAnEmailChannel"
@@ -623,8 +564,10 @@ export default {
<template #beforeAll>
<transition name="slide-up">
<!-- eslint-disable-next-line vue/require-toggle-inside-transition -->
<li class="min-h-[4rem]">
<span v-if="shouldShowSpinner" class="spinner message" />
<li
class="min-h-[4rem] flex flex-shrink-0 flex-grow-0 items-center flex-auto justify-center max-w-full mt-0 mr-0 mb-1 ml-0 relative first:mt-auto last:mb-0"
>
<Spinner v-if="shouldShowSpinner" class="text-n-brand" />
</li>
</transition>
</template>
@@ -646,63 +589,12 @@ export default {
:conversation-id="currentChat.id"
/>
</template>
</NextMessageList>
<ul v-else class="conversation-panel">
<transition name="slide-up">
<!-- eslint-disable-next-line vue/require-toggle-inside-transition -->
<li class="min-h-[4rem]">
<span v-if="shouldShowSpinner" class="spinner message" />
</li>
</transition>
<Message
v-for="message in readMessages"
:key="message.id"
class="message--read ph-no-capture"
data-clarity-mask="True"
:data="message"
:is-a-tweet="isATweet"
:is-a-whatsapp-channel="isAWhatsAppChannel"
:is-web-widget-inbox="isAWebWidgetInbox"
:is-a-facebook-inbox="isAFacebookInbox"
:is-an-email-inbox="isAnEmailChannel"
:is-instagram="isInstagramDM"
:inbox-supports-reply-to="inboxSupportsReplyTo"
:in-reply-to="getInReplyToMessage(message)"
/>
<li v-show="unreadMessageCount != 0" class="unread--toast">
<UnreadIndicator
:label="unreadMessageLabel"
variant="primary"
class="mx-auto"
@intersect="onUnreadBadgeIntersect"
/>
</li>
<Message
v-for="message in unReadMessages"
:key="message.id"
class="message--unread ph-no-capture"
data-clarity-mask="True"
:data="message"
:is-a-tweet="isATweet"
:is-a-whatsapp-channel="isAWhatsAppChannel"
:is-web-widget-inbox="isAWebWidgetInbox"
:is-a-facebook-inbox="isAFacebookInbox"
:is-instagram-dm="isInstagramDM"
:inbox-supports-reply-to="inboxSupportsReplyTo"
:in-reply-to="getInReplyToMessage(message)"
/>
<ConversationLabelSuggestion
v-if="shouldShowLabelSuggestions"
:suggested-labels="labelSuggestions"
:chat-labels="currentChat.labels"
:conversation-id="currentChat.id"
/>
</ul>
</MessageList>
<div
class="conversation-footer"
class="flex relative flex-col"
:class="{
'modal-mask': isPopOutReplyBox,
'bg-n-background': showNextBubbles && !isPopOutReplyBox,
'bg-n-background': !isPopOutReplyBox,
}"
>
<div
@@ -718,8 +610,8 @@ export default {
/>
</div>
<ReplyBox
v-model:popout-reply-box="isPopOutReplyBox"
@toggle-popout="showPopOutReplyBox"
:pop-out-reply-box="isPopOutReplyBox"
@update:pop-out-reply-box="isPopOutReplyBox = $event"
/>
</div>
</div>
@@ -727,7 +619,7 @@ export default {
<style scoped lang="scss">
.modal-mask {
@apply absolute;
@apply fixed;
&::v-deep {
.ProseMirror-woot-style {
@@ -751,7 +643,7 @@ export default {
}
.emoji-dialog {
@apply absolute left-auto bottom-1;
@apply absolute ltr:left-auto rtl:right-auto bottom-1;
}
}
}
@@ -1,10 +1,14 @@
<script>
import { mapGetters } from 'vuex';
<script setup>
import { computed, onUnmounted } from 'vue';
import { useToggle } from '@vueuse/core';
import { useStore } from 'vuex';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import { emitter } from 'shared/helpers/mitt';
import EmailTranscriptModal from './EmailTranscriptModal.vue';
import ResolveAction from '../../buttons/ResolveAction.vue';
import ButtonV4 from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import {
CMD_MUTE_CONVERSATION,
@@ -12,97 +16,111 @@ import {
CMD_UNMUTE_CONVERSATION,
} from 'dashboard/helper/commandbar/events';
export default {
components: {
EmailTranscriptModal,
ResolveAction,
ButtonV4,
},
data() {
return {
showEmailActionsModal: false,
};
},
computed: {
...mapGetters({ currentChat: 'getSelectedChat' }),
},
mounted() {
emitter.on(CMD_MUTE_CONVERSATION, this.mute);
emitter.on(CMD_UNMUTE_CONVERSATION, this.unmute);
emitter.on(CMD_SEND_TRANSCRIPT, this.toggleEmailActionsModal);
},
unmounted() {
emitter.off(CMD_MUTE_CONVERSATION, this.mute);
emitter.off(CMD_UNMUTE_CONVERSATION, this.unmute);
emitter.off(CMD_SEND_TRANSCRIPT, this.toggleEmailActionsModal);
},
methods: {
mute() {
this.$store.dispatch('muteConversation', this.currentChat.id);
useAlert(this.$t('CONTACT_PANEL.MUTED_SUCCESS'));
},
unmute() {
this.$store.dispatch('unmuteConversation', this.currentChat.id);
useAlert(this.$t('CONTACT_PANEL.UNMUTED_SUCCESS'));
},
toggleEmailActionsModal() {
this.showEmailActionsModal = !this.showEmailActionsModal;
},
},
// No props needed as we're getting currentChat from the store directly
const store = useStore();
const { t } = useI18n();
const [showEmailActionsModal, toggleEmailModal] = useToggle(false);
const [showActionsDropdown, toggleDropdown] = useToggle(false);
const currentChat = computed(() => store.getters.getSelectedChat);
const actionMenuItems = computed(() => {
const items = [];
if (!currentChat.value.muted) {
items.push({
icon: 'i-lucide-volume-off',
label: t('CONTACT_PANEL.MUTE_CONTACT'),
action: 'mute',
value: 'mute',
});
} else {
items.push({
icon: 'i-lucide-volume-1',
label: t('CONTACT_PANEL.UNMUTE_CONTACT'),
action: 'unmute',
value: 'unmute',
});
}
items.push({
icon: 'i-lucide-share',
label: t('CONTACT_PANEL.SEND_TRANSCRIPT'),
action: 'send_transcript',
value: 'send_transcript',
});
return items;
});
const handleActionClick = ({ action }) => {
toggleDropdown(false);
if (action === 'mute') {
store.dispatch('muteConversation', currentChat.value.id);
useAlert(t('CONTACT_PANEL.MUTED_SUCCESS'));
} else if (action === 'unmute') {
store.dispatch('unmuteConversation', currentChat.value.id);
useAlert(t('CONTACT_PANEL.UNMUTED_SUCCESS'));
} else if (action === 'send_transcript') {
toggleEmailModal();
}
};
// These functions are needed for the event listeners
const mute = () => {
store.dispatch('muteConversation', currentChat.value.id);
useAlert(t('CONTACT_PANEL.MUTED_SUCCESS'));
};
const unmute = () => {
store.dispatch('unmuteConversation', currentChat.value.id);
useAlert(t('CONTACT_PANEL.UNMUTED_SUCCESS'));
};
emitter.on(CMD_MUTE_CONVERSATION, mute);
emitter.on(CMD_UNMUTE_CONVERSATION, unmute);
emitter.on(CMD_SEND_TRANSCRIPT, toggleEmailModal);
onUnmounted(() => {
emitter.off(CMD_MUTE_CONVERSATION, mute);
emitter.off(CMD_UNMUTE_CONVERSATION, unmute);
emitter.off(CMD_SEND_TRANSCRIPT, toggleEmailModal);
});
</script>
<template>
<div class="relative flex items-center gap-2 actions--container">
<ButtonV4
v-if="!currentChat.muted"
v-tooltip="$t('CONTACT_PANEL.MUTE_CONTACT')"
size="sm"
variant="ghost"
color="slate"
icon="i-lucide-volume-off"
@click="mute"
/>
<ButtonV4
v-else
v-tooltip.left="$t('CONTACT_PANEL.UNMUTE_CONTACT')"
size="sm"
variant="ghost"
color="slate"
icon="i-lucide-volume-1"
@click="unmute"
/>
<ButtonV4
v-tooltip="$t('CONTACT_PANEL.SEND_TRANSCRIPT')"
size="sm"
variant="ghost"
color="slate"
icon="i-lucide-share"
@click="toggleEmailActionsModal"
/>
<ResolveAction
:conversation-id="currentChat.id"
:status="currentChat.status"
/>
<div
v-on-clickaway="() => toggleDropdown(false)"
class="relative flex items-center group"
>
<ButtonV4
v-tooltip="$t('CONVERSATION.HEADER.MORE_ACTIONS')"
size="sm"
variant="ghost"
color="slate"
icon="i-lucide-more-vertical"
class="rounded-md group-hover:bg-n-alpha-2"
@click="toggleDropdown()"
/>
<DropdownMenu
v-if="showActionsDropdown"
:menu-items="actionMenuItems"
class="mt-1 ltr:right-0 rtl:left-0 top-full"
@action="handleActionClick"
/>
</div>
<EmailTranscriptModal
v-if="showEmailActionsModal"
:show="showEmailActionsModal"
:current-chat="currentChat"
@cancel="toggleEmailActionsModal"
@cancel="toggleEmailModal"
/>
</div>
</template>
<style scoped lang="scss">
.more--button {
@apply items-center flex ml-2 rtl:ml-0 rtl:mr-2;
}
.dropdown-pane {
@apply -right-2 top-12;
}
.icon {
@apply mr-1 rtl:mr-0 rtl:ml-1 min-w-[1rem];
}
</style>
@@ -29,24 +29,24 @@ defineProps({
<template>
<div
class="h-full w-full bg-white dark:bg-slate-900 border border-slate-100 dark:border-white/10 rounded-lg p-4 flex flex-col"
class="h-full w-full bg-n-background border border-n-weak rounded-lg p-4 flex flex-col"
>
<div class="flex-1 flex items-center justify-center">
<img :src="imageSrc" :alt="imageAlt" class="h-36 w-auto mx-auto" />
</div>
<div class="mt-auto">
<p
class="text-base text-slate-800 dark:text-slate-100 font-interDisplay font-semibold tracking-[0.3px]"
class="text-base text-n-slate-12 font-interDisplay font-semibold tracking-[0.3px]"
>
{{ title }}
</p>
<p class="text-slate-600 dark:text-slate-400 text-sm">
<p class="text-n-slate-11 text-sm">
{{ description }}
</p>
<router-link
v-if="to"
:to="{ name: to }"
class="no-underline text-woot-500 text-sm font-medium"
class="no-underline text-n-brand text-sm font-medium"
>
<span>{{ linkText }}</span>
<span class="ml-2">{{ `` }}</span>
@@ -32,11 +32,11 @@ const greetingMessage = computed(() => {
>
<div class="col-span-full self-start">
<p
class="text-xl font-semibold text-slate-900 dark:text-white font-interDisplay tracking-[0.3px]"
class="text-xl font-semibold text-n-slate-12 font-interDisplay tracking-[0.3px]"
>
{{ greetingMessage }}
</p>
<p class="text-slate-600 dark:text-slate-400 max-w-2xl text-base">
<p class="text-n-slate-11 max-w-2xl text-base">
{{
$t('ONBOARDING.DESCRIPTION', {
installationName: globalConfig.installationName,

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