Restructure omni services and add Chatwoot research snapshot

This commit is contained in:
Ruslan Bakiev
2026-02-21 11:11:27 +07:00
parent edea7a0034
commit b73babbbf6
7732 changed files with 978203 additions and 32 deletions

View File

@@ -0,0 +1,52 @@
<script>
import { mapGetters } from 'vuex';
import MentionBox from '../mentions/MentionBox.vue';
export default {
components: { MentionBox },
props: {
searchKey: {
type: String,
default: '',
},
},
emits: ['replace'],
computed: {
...mapGetters({
cannedMessages: 'getCannedResponses',
}),
items() {
return this.cannedMessages.map(cannedMessage => ({
label: cannedMessage.short_code,
key: cannedMessage.short_code,
description: cannedMessage.content,
}));
},
},
watch: {
searchKey() {
this.fetchCannedResponses();
},
},
mounted() {
this.fetchCannedResponses();
},
methods: {
fetchCannedResponses() {
this.$store.dispatch('getCannedResponse', { searchKey: this.searchKey });
},
handleMentionClick(item = {}) {
this.$emit('replace', item.description);
},
},
};
</script>
<!-- eslint-disable-next-line vue/no-root-v-if -->
<template>
<MentionBox
v-if="items.length"
:items="items"
@mention-select="handleMentionClick"
/>
</template>

View File

@@ -0,0 +1,97 @@
<script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import TemplatesPicker from './ContentTemplatesPicker.vue';
import TemplateParser from '../../../../components-next/content-templates/ContentTemplateParser.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
show: {
type: Boolean,
default: false,
},
inboxId: {
type: Number,
default: undefined,
},
});
const emit = defineEmits(['onSend', 'cancel', 'update:show']);
const { t } = useI18n();
const selectedContentTemplate = ref(null);
const localShow = computed({
get() {
return props.show;
},
set(value) {
emit('update:show', value);
},
});
const modalHeaderContent = computed(() => {
return selectedContentTemplate.value
? t('CONTENT_TEMPLATES.MODAL.TEMPLATE_SELECTED_SUBTITLE', {
templateName: selectedContentTemplate.value.friendly_name,
})
: t('CONTENT_TEMPLATES.MODAL.SUBTITLE');
});
const pickTemplate = template => {
selectedContentTemplate.value = template;
};
const onResetTemplate = () => {
selectedContentTemplate.value = null;
};
const onSendMessage = message => {
emit('onSend', message);
};
const onClose = () => {
emit('cancel');
};
</script>
<template>
<woot-modal v-model:show="localShow" :on-close="onClose" size="modal-big">
<woot-modal-header
:header-title="$t('CONTENT_TEMPLATES.MODAL.TITLE')"
:header-content="modalHeaderContent"
/>
<div class="px-8 py-6 row">
<TemplatesPicker
v-if="!selectedContentTemplate"
:inbox-id="inboxId"
@on-select="pickTemplate"
/>
<TemplateParser
v-else
:template="selectedContentTemplate"
@reset-template="onResetTemplate"
@send-message="onSendMessage"
>
<template #actions="{ sendMessage, resetTemplate, disabled }">
<div class="flex gap-2 mt-6">
<Button
:label="t('CONTENT_TEMPLATES.PARSER.GO_BACK_LABEL')"
color="slate"
variant="faded"
class="flex-1"
@click="resetTemplate"
/>
<Button
:label="t('CONTENT_TEMPLATES.PARSER.SEND_MESSAGE_LABEL')"
class="flex-1"
:disabled="disabled"
@click="sendMessage"
/>
</div>
</template>
</TemplateParser>
</div>
</woot-modal>
</template>

View File

@@ -0,0 +1,172 @@
<script setup>
import { ref, computed } from 'vue';
import { useAlert } from 'dashboard/composables';
import { useStore } from 'dashboard/composables/store';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import { useI18n } from 'vue-i18n';
import { TWILIO_CONTENT_TEMPLATE_TYPES } from 'shared/constants/messages';
const props = defineProps({
inboxId: {
type: Number,
default: undefined,
},
});
const emit = defineEmits(['onSelect']);
const { t } = useI18n();
const store = useStore();
const query = ref('');
const isRefreshing = ref(false);
const twilioTemplates = computed(() => {
const inbox = store.getters['inboxes/getInbox'](props.inboxId);
return inbox?.content_templates?.templates || [];
});
const filteredTemplateMessages = computed(() =>
twilioTemplates.value.filter(
template =>
template.friendly_name
.toLowerCase()
.includes(query.value.toLowerCase()) && template.status === 'approved'
)
);
const getTemplateType = template => {
if (template.template_type === TWILIO_CONTENT_TEMPLATE_TYPES.MEDIA) {
return t('CONTENT_TEMPLATES.PICKER.TYPES.MEDIA');
}
if (template.template_type === TWILIO_CONTENT_TEMPLATE_TYPES.QUICK_REPLY) {
return t('CONTENT_TEMPLATES.PICKER.TYPES.QUICK_REPLY');
}
if (template.template_type === TWILIO_CONTENT_TEMPLATE_TYPES.CALL_TO_ACTION) {
return t('CONTENT_TEMPLATES.PICKER.TYPES.CALL_TO_ACTION');
}
return t('CONTENT_TEMPLATES.PICKER.TYPES.TEXT');
};
const refreshTemplates = async () => {
isRefreshing.value = true;
try {
await store.dispatch('inboxes/syncTemplates', props.inboxId);
useAlert(t('CONTENT_TEMPLATES.PICKER.REFRESH_SUCCESS'));
} catch (error) {
useAlert(t('CONTENT_TEMPLATES.PICKER.REFRESH_ERROR'));
} finally {
isRefreshing.value = false;
}
};
</script>
<template>
<div class="w-full">
<div class="flex gap-2 mb-2.5">
<div
class="flex flex-1 gap-1 items-center px-2.5 py-0 rounded-lg bg-n-alpha-black2 outline outline-1 outline-n-weak hover:outline-n-slate-6 dark:hover:outline-n-slate-6 focus-within:outline-n-brand dark:focus-within:outline-n-brand"
>
<fluent-icon icon="search" class="text-n-slate-12" size="16" />
<input
v-model="query"
type="search"
:placeholder="t('CONTENT_TEMPLATES.PICKER.SEARCH_PLACEHOLDER')"
class="reset-base w-full h-9 bg-transparent text-n-slate-12 !text-sm !outline-0"
/>
</div>
<button
:disabled="isRefreshing"
class="flex justify-center items-center w-9 h-9 rounded-lg bg-n-alpha-black2 outline outline-1 outline-n-weak hover:outline-n-slate-6 dark:hover:outline-n-slate-6 hover:bg-n-alpha-2 dark:hover:bg-n-solid-2 disabled:opacity-50 disabled:cursor-not-allowed"
:title="t('CONTENT_TEMPLATES.PICKER.REFRESH_BUTTON')"
@click="refreshTemplates"
>
<Icon
icon="i-lucide-refresh-ccw"
class="text-n-slate-12 size-4"
:class="{ 'animate-spin': isRefreshing }"
/>
</button>
</div>
<div
class="bg-n-background outline-n-container outline outline-1 rounded-lg max-h-[18.75rem] overflow-y-auto p-2.5"
>
<div
v-for="(template, i) in filteredTemplateMessages"
:key="template.content_sid"
>
<button
class="block p-2.5 w-full text-left rounded-lg cursor-pointer hover:bg-n-alpha-2 dark:hover:bg-n-solid-2"
@click="emit('onSelect', template)"
>
<div>
<div class="flex justify-between items-center mb-2.5">
<p class="text-sm">
{{ template.friendly_name }}
</p>
<div class="flex gap-2">
<span
class="inline-block px-2 py-1 text-xs leading-none rounded-lg cursor-default bg-n-slate-3 text-n-slate-12"
>
{{ getTemplateType(template) }}
</span>
<span
class="inline-block px-2 py-1 text-xs leading-none rounded-lg cursor-default bg-n-slate-3 text-n-slate-12"
>
{{
`${t('CONTENT_TEMPLATES.PICKER.LABELS.LANGUAGE')}: ${template.language}`
}}
</span>
</div>
</div>
<!-- Body -->
<div>
<p class="text-xs font-medium text-n-slate-11">
{{ t('CONTENT_TEMPLATES.PICKER.BODY') }}
</p>
<p class="text-sm label-body">
{{ template.body || t('CONTENT_TEMPLATES.PICKER.NO_CONTENT') }}
</p>
</div>
<div class="flex justify-between items-center mt-3">
<div>
<p class="text-xs font-medium text-n-slate-11">
{{ t('CONTENT_TEMPLATES.PICKER.LABELS.CATEGORY') }}
</p>
<p class="text-sm">{{ template.category || 'utility' }}</p>
</div>
<div class="text-xs text-n-slate-11">
{{ new Date(template.created_at).toLocaleDateString() }}
</div>
</div>
</div>
</button>
<hr
v-if="i != filteredTemplateMessages.length - 1"
:key="`hr-${i}`"
class="border-b border-solid border-n-weak my-2.5 mx-auto max-w-[95%]"
/>
</div>
<div v-if="!filteredTemplateMessages.length" class="py-8 text-center">
<div v-if="query && twilioTemplates.length">
<p>
{{ t('CONTENT_TEMPLATES.PICKER.NO_TEMPLATES_FOUND') }}
<strong>{{ query }}</strong>
</p>
</div>
<div v-else-if="!twilioTemplates.length" class="space-y-4">
<p class="text-n-slate-11">
{{ t('CONTENT_TEMPLATES.PICKER.NO_TEMPLATES_AVAILABLE') }}
</p>
</div>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.label-body {
font-family: monospace;
}
</style>

View File

@@ -0,0 +1,178 @@
<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';
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 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',
},
]);
const activeChatStatusLabel = computed(
() =>
chatStatusOptions.value.find(m => m.value === chatStatusFilter.value)
?.label || ''
);
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,
},
});
};
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>
<template>
<div class="relative flex">
<NextButton
v-tooltip.right="$t('CHAT_LIST.SORT_TOOLTIP_LABEL')"
icon="i-lucide-arrow-up-down"
slate
faded
xs
@click="toggleDropdown()"
/>
<div
v-if="showActionsDropdown"
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 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 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>
</div>
</template>

View File

@@ -0,0 +1,144 @@
<script>
import { mapGetters } from 'vuex';
import ConversationHeader from './ConversationHeader.vue';
import DashboardAppFrame from '../DashboardApp/Frame.vue';
import EmptyState from './EmptyState/EmptyState.vue';
import MessagesView from './MessagesView.vue';
export default {
components: {
ConversationHeader,
DashboardAppFrame,
EmptyState,
MessagesView,
},
props: {
inboxId: {
type: [Number, String],
default: '',
required: false,
},
isInboxView: {
type: Boolean,
default: false,
},
isContactPanelOpen: {
type: Boolean,
default: true,
},
isOnExpandedLayout: {
type: Boolean,
default: true,
},
},
data() {
return { activeIndex: 0 };
},
computed: {
...mapGetters({
currentChat: 'getSelectedChat',
dashboardApps: 'dashboardApps/getRecords',
}),
dashboardAppTabs() {
return [
{
key: 'messages',
index: 0,
name: this.$t('CONVERSATION.DASHBOARD_APP_TAB_MESSAGES'),
},
...this.dashboardApps.map((dashboardApp, index) => ({
key: `dashboard-${dashboardApp.id}`,
index: index + 1,
name: dashboardApp.title,
})),
];
},
showContactPanel() {
return this.isContactPanelOpen && this.currentChat.id;
},
},
watch: {
'currentChat.inbox_id': {
immediate: true,
handler(inboxId) {
if (inboxId) {
this.$store.dispatch('inboxAssignableAgents/fetch', [inboxId]);
}
},
},
'currentChat.id'() {
this.fetchLabels();
this.activeIndex = 0;
},
},
mounted() {
this.fetchLabels();
this.$store.dispatch('dashboardApps/get');
},
methods: {
fetchLabels() {
if (!this.currentChat.id) {
return;
}
this.$store.dispatch('conversationLabels/get', this.currentChat.id);
},
onDashboardAppTabChange(index) {
this.activeIndex = index;
},
},
};
</script>
<template>
<div
class="conversation-details-wrap flex flex-col min-w-0 w-full bg-n-surface-1 relative"
:class="{
'border-l rtl:border-l-0 rtl:border-r border-n-weak': !isOnExpandedLayout,
}"
>
<ConversationHeader
v-if="currentChat.id"
:chat="currentChat"
:show-back-button="isOnExpandedLayout && !isInboxView"
:class="{
'border-b border-b-n-weak !pt-2': !dashboardApps.length,
}"
/>
<woot-tabs
v-if="dashboardApps.length && currentChat.id"
:index="activeIndex"
class="h-10"
@change="onDashboardAppTabChange"
>
<woot-tabs-item
v-for="tab in dashboardAppTabs"
:key="tab.key"
:index="tab.index"
:name="tab.name"
:show-badge="false"
is-compact
/>
</woot-tabs>
<div v-show="!activeIndex" class="flex h-full min-h-0 m-0">
<MessagesView
v-if="currentChat.id"
:inbox-id="inboxId"
:is-inbox-view="isInboxView"
/>
<EmptyState
v-if="!currentChat.id && !isInboxView"
:is-on-expanded-layout="isOnExpandedLayout"
/>
<slot />
</div>
<DashboardAppFrame
v-for="(dashboardApp, index) in dashboardApps"
v-show="activeIndex - 1 === index"
:key="currentChat.id + '-' + dashboardApp.id"
:is-visible="activeIndex - 1 === index"
:config="dashboardApps[index].content"
:position="index"
:current-chat="currentChat"
/>
</div>
</template>

View File

@@ -0,0 +1,402 @@
<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 { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
import Avatar from 'next/avatar/Avatar.vue';
import MessagePreview from './MessagePreview.vue';
import InboxName from '../InboxName.vue';
import ConversationContextMenu from './contextMenu/Index.vue';
import TimeAgo from 'dashboard/components/ui/TimeAgo.vue';
import CardLabels from './conversationCardComponents/CardLabels.vue';
import PriorityMark from './PriorityMark.vue';
import SLACardLabel from './components/SLACardLabel.vue';
import ContextMenu from 'dashboard/components/ui/ContextMenu.vue';
import VoiceCallStatus from './VoiceCallStatus.vue';
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: () => [] },
});
const emit = defineEmits([
'contextMenuToggle',
'assignAgent',
'assignLabel',
'removeLabel',
'assignTeam',
'markAsUnread',
'markAsRead',
'assignPriority',
'updateConversationStatus',
'deleteConversation',
'selectConversation',
'deSelectConversation',
]);
const router = useRouter();
const store = useStore();
const hovered = ref(false);
const showContextMenu = ref(false);
const contextMenu = ref({
x: null,
y: null,
});
const currentChat = useMapGetter('getSelectedChat');
const inboxesList = useMapGetter('inboxes/getInboxes');
const activeInbox = useMapGetter('getSelectedInbox');
const accountId = useMapGetter('getCurrentAccountId');
const chatMetadata = computed(() => props.chat.meta || {});
const assignee = computed(() => chatMetadata.value.assignee || {});
const senderId = computed(() => chatMetadata.value.sender?.id);
const currentContact = computed(() => {
return senderId.value
? store.getters['contacts/getContact'](senderId.value)
: {};
});
const isActiveChat = computed(() => {
return currentChat.value.id === props.chat.id;
});
const unreadCount = computed(() => props.chat.unread_count);
const hasUnread = computed(() => unreadCount.value > 0);
const isInboxNameVisible = computed(() => !activeInbox.value);
const lastMessageInChat = computed(() => getLastMessage(props.chat));
const voiceCallData = computed(() => ({
status: props.chat.additional_attributes?.call_status,
direction: props.chat.additional_attributes?.call_direction,
}));
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]);
};
const onRemoveLabel = label => {
emit('removeLabel', [label.title], [props.chat.id]);
};
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 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-background border-n-weak': isActiveChat,
'bg-n-slate-2': selected,
'px-0': compact,
'px-3': !compact,
}"
@click="onCardClick"
@contextmenu="openContextMenu($event)"
>
<div
class="relative"
@mouseenter="onThumbnailHover"
@mouseleave="onThumbnailLeave"
>
<Avatar
v-if="!hideThumbnail"
:name="currentContact.name"
:src="currentContact.thumbnail"
:size="32"
:status="currentContact.availability_status"
: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 min-w-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 items-center truncate"
>
<fluent-icon icon="person" size="12" class="text-n-slate-11" />
{{ assignee.name }}
</span>
<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 flex-1 min-w-0 ltr:pr-16 rtl:pl-16 text-n-slate-12"
:class="hasUnread ? 'font-semibold' : 'font-medium'"
>
{{ currentContact.name }}
</h4>
<VoiceCallStatus
v-if="voiceCallData.status"
key="voice-status-row"
:status="voiceCallData.status"
:direction="voiceCallData.direction"
:message-preview-class="messagePreviewClass"
/>
<MessagePreview
v-else-if="lastMessageInChat"
key="message-preview"
:message="lastMessageInChat"
class="my-0 mx-2 leading-6 h-6 flex-1 min-w-0 text-sm"
:class="messagePreviewClass"
/>
<p
v-else
key="no-messages"
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 class="mx-0.5">
{{ $t(`CHAT_LIST.NO_MESSAGES`) }}
</span>
</p>
<div
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
:last-activity-timestamp="chat.timestamp"
:created-at-timestamp="chat.created_at"
/>
</span>
<span
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
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>
</CardLabels>
</div>
<ContextMenu
v-if="showContextMenu"
:x="contextMenu.x"
:y="contextMenu.y"
@close="closeContextMenu"
>
<ConversationContextMenu
:status="chat.status"
:inbox-id="inbox.id"
:priority="chat.priority"
:chat-id="chat.id"
:has-unread-messages="hasUnread"
:conversation-labels="chat.labels"
:conversation-url="conversationPath"
:allowed-options="allowedContextMenuOptions"
@update-conversation="onUpdateConversation"
@assign-agent="onAssignAgent"
@assign-label="onAssignLabel"
@remove-label="onRemoveLabel"
@assign-team="onAssignTeam"
@mark-as-unread="markAsUnread"
@mark-as-read="markAsRead"
@assign-priority="assignPriority"
@delete-conversation="deleteConversation"
@close="closeContextMenu"
/>
</ContextMenu>
</div>
</template>

View File

@@ -0,0 +1,157 @@
<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 InboxName from '../InboxName.vue';
import MoreActions from './MoreActions.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 { useInbox } from 'dashboard/composables/useInbox';
import { useI18n } from 'vue-i18n';
const props = defineProps({
chat: {
type: Object,
default: () => ({}),
},
showBackButton: {
type: Boolean,
default: false,
},
});
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
ref="conversationHeader"
class="flex flex-col gap-3 items-center justify-between flex-1 w-full min-w-0 xl:flex-row px-3 pt-3 pb-2 h-24 xl:h-12"
>
<div
class="flex items-center justify-start w-full xl:w-auto max-w-full min-w-0 xl:flex-1"
>
<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"
>
{{ 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" class="!mx-0" />
<span v-if="isSnoozed" class="font-medium text-n-amber-10">
{{ snoozedDisplayText }}
</span>
</div>
</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>

View File

@@ -0,0 +1,61 @@
<script setup>
import { computed } from 'vue';
import ContactPanel from 'dashboard/routes/dashboard/conversation/ContactPanel.vue';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useWindowSize } from '@vueuse/core';
import { vOnClickOutside } from '@vueuse/components';
import wootConstants from 'dashboard/constants/globals';
defineProps({
currentChat: {
required: true,
type: Object,
},
});
const { uiSettings, updateUISettings } = useUISettings();
const { width: windowWidth } = useWindowSize();
const activeTab = computed(() => {
const { is_contact_sidebar_open: isContactSidebarOpen } = uiSettings.value;
if (isContactSidebarOpen) {
return 0;
}
return null;
});
const isSmallScreen = computed(
() => windowWidth.value < wootConstants.SMALL_SCREEN_BREAKPOINT
);
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
v-on-click-outside="() => closeContactPanel()"
class="bg-n-surface-2 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 class="flex flex-1 overflow-auto">
<ContactPanel
v-show="activeTab === 0"
:conversation-id="currentChat.id"
:inbox-id="currentChat.inbox_id"
/>
</div>
</div>
</template>

View File

@@ -0,0 +1,99 @@
<script setup>
import { ref } from 'vue';
import CopilotEditor from 'dashboard/components/widgets/WootWriter/CopilotEditor.vue';
import CaptainLoader from 'dashboard/components/widgets/conversation/copilot/CaptainLoader.vue';
defineProps({
showCopilotEditor: {
type: Boolean,
default: false,
},
isGeneratingContent: {
type: Boolean,
default: false,
},
generatedContent: {
type: String,
default: '',
},
isPopout: {
type: Boolean,
default: false,
},
});
const emit = defineEmits([
'focus',
'blur',
'clearSelection',
'contentReady',
'send',
]);
const copilotEditorContent = ref('');
const onFocus = () => {
emit('focus');
};
const onBlur = () => {
emit('blur');
};
const clearEditorSelection = () => {
emit('clearSelection');
};
const onSend = () => {
emit('send', copilotEditorContent.value);
copilotEditorContent.value = '';
};
</script>
<template>
<Transition
mode="out-in"
enter-active-class="transition-all duration-300 ease-out"
enter-from-class="opacity-0 translate-y-2 scale-[0.98]"
enter-to-class="opacity-100 translate-y-0 scale-100"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 translate-y-0 scale-100"
leave-to-class="opacity-0 translate-y-2 scale-[0.98]"
@after-enter="emit('contentReady')"
>
<CopilotEditor
v-if="showCopilotEditor && !isGeneratingContent"
key="copilot-editor"
v-model="copilotEditorContent"
class="copilot-editor"
:generated-content="generatedContent"
:min-height="4"
:enabled-menu-options="[]"
:is-popout="isPopout"
@focus="onFocus"
@blur="onBlur"
@clear-selection="clearEditorSelection"
@send="onSend"
/>
<div
v-else-if="isGeneratingContent"
key="loading-state"
class="bg-n-iris-5 rounded min-h-16 w-full mb-4 p-4 flex items-start"
>
<div class="flex items-center gap-2">
<CaptainLoader class="text-n-iris-10 size-4" />
<span class="text-sm text-n-iris-10">
{{ $t('CONVERSATION.REPLYBOX.COPILOT_THINKING') }}
</span>
</div>
</div>
</Transition>
</template>
<style lang="scss">
.copilot-editor {
.ProseMirror-menubar {
display: none;
}
}
</style>

View File

@@ -0,0 +1,176 @@
<script>
import { useVuelidate } from '@vuelidate/core';
import { required, minLength, email } from '@vuelidate/validators';
import { useAlert } from 'dashboard/composables';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: {
NextButton,
},
props: {
show: {
type: Boolean,
default: false,
},
currentChat: {
type: Object,
default: () => ({}),
},
},
emits: ['cancel', 'update:show'],
setup() {
return { v$: useVuelidate() };
},
data() {
return {
email: '',
selectedType: '',
isSubmitting: false,
};
},
validations: {
email: {
required,
email,
minLength: minLength(4),
},
},
computed: {
localShow: {
get() {
return this.show;
},
set(value) {
this.$emit('update:show', value);
},
},
sentToOtherEmailAddress() {
return this.selectedType === 'other_email_address';
},
isFormValid() {
if (this.selectedType) {
if (this.sentToOtherEmailAddress) {
return !!this.email && !this.v$.email.$error;
}
return true;
}
return false;
},
selectedEmailAddress() {
const { meta } = this.currentChat;
switch (this.selectedType) {
case 'contact':
return meta.sender.email;
case 'assignee':
return meta.assignee.email;
case 'other_email_address':
return this.email;
default:
return '';
}
},
},
methods: {
onCancel() {
this.$emit('cancel');
},
async onSubmit() {
this.isSubmitting = false;
try {
await this.$store.dispatch('sendEmailTranscript', {
email: this.selectedEmailAddress,
conversationId: this.currentChat.id,
});
useAlert(this.$t('EMAIL_TRANSCRIPT.SEND_EMAIL_SUCCESS'));
this.onCancel();
} catch (error) {
useAlert(this.$t('EMAIL_TRANSCRIPT.SEND_EMAIL_ERROR'));
} finally {
this.isSubmitting = false;
}
},
},
};
</script>
<template>
<woot-modal v-model:show="localShow" :on-close="onCancel">
<div class="flex flex-col h-auto overflow-auto">
<woot-modal-header
:header-title="$t('EMAIL_TRANSCRIPT.TITLE')"
:header-content="$t('EMAIL_TRANSCRIPT.DESC')"
/>
<form class="w-full" @submit.prevent="onSubmit">
<div class="w-full">
<div
v-if="currentChat.meta.sender && currentChat.meta.sender.email"
class="flex items-center gap-2"
>
<input
id="contact"
v-model="selectedType"
type="radio"
name="selectedType"
value="contact"
/>
<label for="contact">{{
$t('EMAIL_TRANSCRIPT.FORM.SEND_TO_CONTACT')
}}</label>
</div>
<div v-if="currentChat.meta.assignee" class="flex items-center gap-2">
<input
id="assignee"
v-model="selectedType"
type="radio"
name="selectedType"
value="assignee"
/>
<label for="assignee">{{
$t('EMAIL_TRANSCRIPT.FORM.SEND_TO_AGENT')
}}</label>
</div>
<div class="flex items-center gap-2">
<input
id="other_email_address"
v-model="selectedType"
type="radio"
name="selectedType"
value="other_email_address"
/>
<label for="other_email_address">{{
$t('EMAIL_TRANSCRIPT.FORM.SEND_TO_OTHER_EMAIL_ADDRESS')
}}</label>
</div>
<div v-if="sentToOtherEmailAddress" class="w-[50%] mt-1">
<label :class="{ error: v$.email.$error }">
<input
v-model="email"
type="text"
:placeholder="$t('EMAIL_TRANSCRIPT.FORM.EMAIL.PLACEHOLDER')"
@input="v$.email.$touch"
/>
<span v-if="v$.email.$error" class="message">
{{ $t('EMAIL_TRANSCRIPT.FORM.EMAIL.ERROR') }}
</span>
</label>
</div>
</div>
<div class="flex flex-row justify-end w-full gap-2 px-0 py-2">
<NextButton
faded
slate
type="reset"
:label="$t('EMAIL_TRANSCRIPT.CANCEL')"
@click.prevent="onCancel"
/>
<NextButton
type="submit"
:label="$t('EMAIL_TRANSCRIPT.SUBMIT')"
:disabled="!isFormValid"
/>
</div>
</form>
</div>
</woot-modal>
</template>

View File

@@ -0,0 +1,98 @@
<script>
import { mapGetters } from 'vuex';
import { useAdmin } from 'dashboard/composables/useAdmin';
import { useAccount } from 'dashboard/composables/useAccount';
import OnboardingView from '../OnboardingView.vue';
import EmptyStateMessage from './EmptyStateMessage.vue';
export default {
components: {
OnboardingView,
EmptyStateMessage,
},
props: {
isOnExpandedLayout: {
type: Boolean,
default: false,
},
},
setup() {
const { isAdmin } = useAdmin();
const { accountScopedUrl } = useAccount();
return {
isAdmin,
accountScopedUrl,
};
},
computed: {
...mapGetters({
currentChat: 'getSelectedChat',
allConversations: 'getAllConversations',
inboxesList: 'inboxes/getInboxes',
uiFlags: 'inboxes/getUIFlags',
loadingChatList: 'getChatListLoadingStatus',
}),
loadingIndicatorMessage() {
if (this.uiFlags.isFetching) {
return this.$t('CONVERSATION.LOADING_INBOXES');
}
return this.$t('CONVERSATION.LOADING_CONVERSATIONS');
},
conversationMissingMessage() {
if (!this.isOnExpandedLayout) {
return this.$t('CONVERSATION.SELECT_A_CONVERSATION');
}
return this.$t('CONVERSATION.404');
},
newInboxURL() {
return this.accountScopedUrl('settings/inboxes/new');
},
emptyClassName() {
if (
!this.inboxesList.length &&
!this.uiFlags.isFetching &&
!this.loadingChatList &&
this.isAdmin
) {
return 'h-full overflow-auto w-full';
}
return 'flex-1 min-w-0 px-0 flex flex-col items-center justify-center h-full bg-n-surface-1';
},
},
};
</script>
<template>
<div :class="emptyClassName">
<woot-loading-state
v-if="uiFlags.isFetching || loadingChatList"
:message="loadingIndicatorMessage"
/>
<!-- No inboxes attached -->
<div
v-if="!inboxesList.length && !uiFlags.isFetching && !loadingChatList"
class="clearfix mx-auto"
>
<OnboardingView v-if="isAdmin" />
<EmptyStateMessage v-else :message="$t('CONVERSATION.NO_INBOX_AGENT')" />
</div>
<!-- Show empty state images if not loading -->
<div
v-else-if="!uiFlags.isFetching && !loadingChatList"
class="flex flex-col items-center justify-center h-full"
>
<!-- No conversations available -->
<EmptyStateMessage
v-if="!allConversations.length"
:message="$t('CONVERSATION.NO_MESSAGE_1')"
/>
<EmptyStateMessage
v-else-if="allConversations.length && !currentChat.id"
:message="conversationMissingMessage"
/>
</div>
</div>
</template>

View File

@@ -0,0 +1,33 @@
<script>
import FeaturePlaceholder from './FeaturePlaceholder.vue';
export default {
components: { FeaturePlaceholder },
props: {
message: {
type: String,
required: true,
},
},
};
</script>
<template>
<div class="flex flex-col items-center justify-center h-full">
<img
class="m-4 w-32 hidden dark:block"
src="dashboard/assets/images/no-chat-dark.svg"
alt="No Chat dark"
/>
<img
class="m-4 w-32 block dark:hidden"
src="dashboard/assets/images/no-chat.svg"
alt="No Chat"
/>
<span class="text-sm text-n-slate-12 font-medium text-center">
{{ message }}
<br />
</span>
<!-- Cmd bar, keyboard shortcuts placeholder -->
<FeaturePlaceholder />
</div>
</template>

View File

@@ -0,0 +1,49 @@
<script>
import Hotkey from 'dashboard/components/base/Hotkey.vue';
export default {
components: {
Hotkey,
},
data() {
return {
keyShortcuts: [
{
key: 'K',
description: this.$t('CONVERSATION.EMPTY_STATE.CMD_BAR'),
},
{
key: '/',
description: this.$t('CONVERSATION.EMPTY_STATE.KEYBOARD_SHORTCUTS'),
},
],
};
},
};
</script>
<template>
<div class="flex flex-col gap-2 mt-9">
<div
v-for="keyShortcut in keyShortcuts"
:key="keyShortcut.key"
class="flex items-center gap-2"
>
<div class="flex items-center gap-2">
<Hotkey
custom-class="w-8 h-6 text-lg font-medium text-n-slate-12 outline outline-n-container outline-1 bg-n-alpha-3"
>
</Hotkey>
<Hotkey
custom-class="w-8 h-6 text-xs font-medium text-n-slate-12 outline outline-n-container outline-1 bg-n-alpha-3"
>
{{ keyShortcut.key }}
</Hotkey>
</div>
<span class="text-sm font-medium text-center text-n-slate-12">
{{ keyShortcut.description }}
</span>
</div>
</div>
</template>

View File

@@ -0,0 +1,50 @@
<script>
export default {
props: {
selectedValue: {
type: String,
required: true,
},
items: {
type: Array,
required: true,
},
type: {
type: String,
required: true,
},
pathPrefix: {
type: String,
required: true,
},
},
emits: ['onChangeFilter'],
data() {
return {
activeValue: this.selectedValue,
};
},
methods: {
onTabChange() {
if (this.type === 'status') {
this.$store.dispatch('setChatStatusFilter', this.activeValue);
} else {
this.$store.dispatch('setChatSortFilter', this.activeValue);
}
this.$emit('onChangeFilter', this.activeValue, this.type);
},
},
};
</script>
<template>
<select
v-model="activeValue"
class="w-32 h-6 py-0 pl-2 pr-6 mx-1 my-0 text-xs border border-solid bg-n-slate-3 dark:bg-n-solid-3 border-n-weak dark:border-n-weak text-n-slate-12"
@change="onTabChange()"
>
<option v-for="value in items" :key="value" :value="value">
{{ $t(`${pathPrefix}.${value}.TEXT`) }}
</option>
</select>
</template>

View File

@@ -0,0 +1,109 @@
<script>
import { MESSAGE_TYPE } from 'widget/helpers/constants';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import { ATTACHMENT_ICONS } from 'shared/constants/messages';
export default {
name: 'MessagePreview',
props: {
message: {
type: Object,
required: true,
},
showMessageType: {
type: Boolean,
default: true,
},
defaultEmptyMessage: {
type: String,
default: '',
},
},
setup() {
const { getPlainText } = useMessageFormatter();
return {
getPlainText,
};
},
computed: {
messageByAgent() {
const { message_type: messageType } = this.message;
return messageType === MESSAGE_TYPE.OUTGOING;
},
isMessageAnActivity() {
const { message_type: messageType } = this.message;
return messageType === MESSAGE_TYPE.ACTIVITY;
},
isMessagePrivate() {
const { private: isPrivate } = this.message;
return isPrivate;
},
parsedLastMessage() {
const { content_attributes: contentAttributes } = this.message;
const { email: { subject } = {} } = contentAttributes || {};
return this.getPlainText(subject || this.message.content);
},
lastMessageFileType() {
const [{ file_type: fileType } = {}] = this.message.attachments;
return fileType;
},
attachmentIcon() {
return ATTACHMENT_ICONS[this.lastMessageFileType];
},
attachmentMessageContent() {
return `CHAT_LIST.ATTACHMENTS.${this.lastMessageFileType}.CONTENT`;
},
isMessageSticker() {
return this.message && this.message.content_type === 'sticker';
},
},
};
</script>
<template>
<div class="overflow-hidden text-ellipsis whitespace-nowrap">
<template v-if="showMessageType">
<fluent-icon
v-if="isMessagePrivate"
size="16"
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-n-slate-11 inline-block"
icon="arrow-reply"
/>
<fluent-icon
v-else-if="isMessageAnActivity"
size="16"
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-n-slate-11"
icon="image"
/>
{{ $t('CHAT_LIST.ATTACHMENTS.image.CONTENT') }}
</span>
<span v-else-if="message.content">
{{ parsedLastMessage }}
</span>
<span v-else-if="message.attachments">
<fluent-icon
v-if="attachmentIcon && showMessageType"
size="16"
class="-mt-0.5 align-middle inline-block text-n-slate-11"
:icon="attachmentIcon"
/>
{{ $t(`${attachmentMessageContent}`) }}
</span>
<span v-else>
{{ defaultEmptyMessage || $t('CHAT_LIST.NO_CONTENT') }}
</span>
</div>
</template>

View File

@@ -0,0 +1,26 @@
<script setup>
import { useRouter } from 'vue-router';
import Button from 'dashboard/components-next/button/Button.vue';
const router = useRouter();
const openProfileSettings = () => {
return router.push({ name: 'profile_settings_index' });
};
</script>
<template>
<div
class="my-0 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') }}
<Button
link
:label="$t('CONVERSATION.FOOTER.CLICK_HERE')"
@click="openProfileSettings"
/>
</p>
</div>
</template>

View File

@@ -0,0 +1,561 @@
<script>
import { ref, provide } from 'vue';
// composable
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { useLabelSuggestions } from 'dashboard/composables/useLabelSuggestions';
import { useSnakeCase } from 'dashboard/composables/useTransformKeys';
// components
import ReplyBox from './ReplyBox.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';
// mixins
import inboxMixin, { INBOX_FEATURES } from 'shared/mixins/inboxMixin';
// utils
import { emitter } from 'shared/helpers/mitt';
import { getTypingUsersText } from '../../../helper/commons';
import { calculateScrollTop } from './helpers/scrollTopCalculationHelper';
import { LocalStorage } from 'shared/helpers/localStorage';
import {
filterDuplicateSourceMessages,
getReadMessages,
getUnreadMessages,
} from 'dashboard/helper/conversationHelper';
// constants
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 { INBOX_TYPES } from 'dashboard/helper/inbox';
export default {
components: {
MessageList,
ReplyBox,
Banner,
ConversationLabelSuggestion,
Spinner,
},
mixins: [inboxMixin],
setup() {
const isPopOutReplyBox = ref(false);
const conversationPanelRef = ref(null);
const keyboardEvents = {
Escape: {
action: () => {
isPopOutReplyBox.value = false;
},
},
};
useKeyboardEvents(keyboardEvents);
const {
captainTasksEnabled,
isLabelSuggestionFeatureEnabled,
getLabelSuggestions,
} = useLabelSuggestions();
provide('contextMenuElementTarget', conversationPanelRef);
return {
isPopOutReplyBox,
captainTasksEnabled,
getLabelSuggestions,
isLabelSuggestionFeatureEnabled,
conversationPanelRef,
};
},
data() {
return {
isLoadingPrevious: true,
heightBeforeLoad: null,
conversationPanel: null,
hasUserScrolled: false,
isProgrammaticScroll: false,
messageSentSinceOpened: false,
labelSuggestions: [],
};
},
computed: {
...mapGetters({
currentChat: 'getSelectedChat',
currentUserId: 'getCurrentUserID',
listLoadingStatus: 'getAllMessagesLoaded',
currentAccountId: 'getCurrentAccountId',
}),
isOpen() {
return this.currentChat?.status === wootConstants.STATUS_TYPE.OPEN;
},
shouldShowLabelSuggestions() {
return (
this.isOpen &&
this.captainTasksEnabled &&
this.isLabelSuggestionFeatureEnabled &&
!this.messageSentSinceOpened
);
},
inboxId() {
return this.currentChat.inbox_id;
},
inbox() {
return this.$store.getters['inboxes/getInbox'](this.inboxId);
},
typingUsersList() {
const userList = this.$store.getters[
'conversationTypingStatus/getUserList'
](this.currentChat.id);
return userList;
},
isAnyoneTyping() {
const userList = this.typingUsersList;
return userList.length !== 0;
},
typingUserNames() {
const userList = this.typingUsersList;
if (this.isAnyoneTyping) {
const [i18nKey, params] = getTypingUsersText(userList);
return this.$t(i18nKey, params);
}
return '';
},
getMessages() {
const messages = this.currentChat.messages || [];
if (this.isAWhatsAppChannel) {
return filterDuplicateSourceMessages(messages);
}
return messages;
},
readMessages() {
return getReadMessages(
this.getMessages,
this.currentChat.agent_last_seen_at
);
},
unReadMessages() {
return getUnreadMessages(
this.getMessages,
this.currentChat.agent_last_seen_at
);
},
shouldShowSpinner() {
return (
(this.currentChat && this.currentChat.dataFetched === undefined) ||
(!this.listLoadingStatus && this.isLoadingPrevious)
);
},
// Check there is a instagram inbox exists with the same instagram_id
hasDuplicateInstagramInbox() {
const instagramId = this.inbox.instagram_id;
const { additional_attributes: additionalAttributes = {} } = this.inbox;
const instagramInbox =
this.$store.getters['inboxes/getInstagramInboxByInstagramId'](
instagramId
);
return (
this.inbox.channel_type === INBOX_TYPES.FB &&
additionalAttributes.type === 'instagram_direct_message' &&
instagramInbox
);
},
replyWindowBannerMessage() {
if (this.isAWhatsAppChannel) {
return this.$t('CONVERSATION.TWILIO_WHATSAPP_CAN_REPLY');
}
if (this.isAPIInbox) {
const { additional_attributes: additionalAttributes = {} } = this.inbox;
if (additionalAttributes) {
const {
agent_reply_time_window_message: agentReplyTimeWindowMessage,
agent_reply_time_window: agentReplyTimeWindow,
} = additionalAttributes;
return (
agentReplyTimeWindowMessage ||
this.$t('CONVERSATION.API_HOURS_WINDOW', {
hours: agentReplyTimeWindow,
})
);
}
return '';
}
return this.$t('CONVERSATION.CANNOT_REPLY');
},
replyWindowLink() {
if (this.isAFacebookInbox || this.isAnInstagramChannel) {
return REPLY_POLICY.FACEBOOK;
}
if (this.isAWhatsAppCloudChannel) {
return REPLY_POLICY.WHATSAPP_CLOUD;
}
if (this.isATiktokChannel) {
return REPLY_POLICY.TIKTOK;
}
if (!this.isAPIInbox) {
return REPLY_POLICY.TWILIO_WHATSAPP;
}
return '';
},
replyWindowLinkText() {
if (
this.isAWhatsAppChannel ||
this.isAFacebookInbox ||
this.isAnInstagramChannel
) {
return this.$t('CONVERSATION.24_HOURS_WINDOW');
}
if (this.isATiktokChannel) {
return this.$t('CONVERSATION.48_HOURS_WINDOW');
}
if (!this.isAPIInbox) {
return this.$t('CONVERSATION.TWILIO_WHATSAPP_24_HOURS_WINDOW');
}
return '';
},
unreadMessageCount() {
return this.currentChat.unread_count || 0;
},
unreadMessageLabel() {
const count =
this.unreadMessageCount > 9 ? '9+' : this.unreadMessageCount;
const label =
this.unreadMessageCount > 1
? 'CONVERSATION.UNREAD_MESSAGES'
: 'CONVERSATION.UNREAD_MESSAGE';
return `${count} ${this.$t(label)}`;
},
inboxSupportsReplyTo() {
const incoming = this.inboxHasFeature(INBOX_FEATURES.REPLY_TO);
const outgoing =
this.inboxHasFeature(INBOX_FEATURES.REPLY_TO_OUTGOING) &&
!this.is360DialogWhatsAppChannel;
return { incoming, outgoing };
},
},
watch: {
currentChat(newChat, oldChat) {
if (newChat.id === oldChat.id) {
return;
}
this.fetchAllAttachmentsFromCurrentChat();
this.fetchSuggestions();
this.messageSentSinceOpened = false;
},
},
created() {
emitter.on(BUS_EVENTS.SCROLL_TO_MESSAGE, this.onScrollToMessage);
// when a message is sent we set the flag to true this hides the label suggestions,
// until the chat is changed and the flag is reset in the watch for currentChat
emitter.on(BUS_EVENTS.MESSAGE_SENT, () => {
this.messageSentSinceOpened = true;
});
},
mounted() {
this.addScrollListener();
this.fetchAllAttachmentsFromCurrentChat();
this.fetchSuggestions();
},
unmounted() {
this.removeBusListeners();
this.removeScrollListener();
},
methods: {
async fetchSuggestions() {
// start empty, this ensures that the label suggestions are not shown
this.labelSuggestions = [];
if (this.isLabelSuggestionDismissed()) {
return;
}
// Early exit if conversation already has labels - no need to suggest more
const existingLabels = this.currentChat?.labels || [];
if (existingLabels.length > 0) return;
if (!this.captainTasksEnabled || !this.isLabelSuggestionFeatureEnabled) {
return;
}
this.labelSuggestions = await this.getLabelSuggestions();
// once the labels are fetched, we need to scroll to bottom
// but we need to wait for the DOM to be updated
// so we use the nextTick method
this.$nextTick(() => {
// this param is added to route, telling the UI to navigate to the message
// it is triggered by the SCROLL_TO_MESSAGE method
// see setActiveChat on ConversationView.vue for more info
const { messageId } = this.$route.query;
// only trigger the scroll to bottom if the user has not scrolled
// and there's no active messageId that is selected in view
if (!messageId && !this.hasUserScrolled) {
this.scrollToBottom();
}
});
},
isLabelSuggestionDismissed() {
return LocalStorage.getFlag(
LOCAL_STORAGE_KEYS.DISMISSED_LABEL_SUGGESTIONS,
this.currentAccountId,
this.currentChat.id
);
},
fetchAllAttachmentsFromCurrentChat() {
this.$store.dispatch('fetchAllAttachments', this.currentChat.id);
},
removeBusListeners() {
emitter.off(BUS_EVENTS.SCROLL_TO_MESSAGE, this.onScrollToMessage);
},
onScrollToMessage({ messageId = '' } = {}) {
this.$nextTick(() => {
const messageElement = document.getElementById('message' + messageId);
if (messageElement) {
this.isProgrammaticScroll = true;
messageElement.scrollIntoView({ behavior: 'smooth' });
this.fetchPreviousMessages();
} else {
this.scrollToBottom();
}
});
this.makeMessagesRead();
},
addScrollListener() {
this.conversationPanel = this.$el.querySelector('.conversation-panel');
this.setScrollParams();
this.conversationPanel.addEventListener('scroll', this.handleScroll);
this.$nextTick(() => this.scrollToBottom());
this.isLoadingPrevious = false;
},
removeScrollListener() {
this.conversationPanel.removeEventListener('scroll', this.handleScroll);
},
scrollToBottom() {
this.isProgrammaticScroll = true;
let relevantMessages = [];
// label suggestions are not part of the messages list
// so we need to handle them separately
let labelSuggestions =
this.conversationPanel.querySelector('.label-suggestion');
// if there are unread messages, scroll to the first unread message
if (this.unreadMessageCount > 0) {
// capturing only the unread messages
relevantMessages =
this.conversationPanel.querySelectorAll('.message--unread');
} else if (labelSuggestions) {
// when scrolling to the bottom, the label suggestions is below the last message
// so we scroll there if there are no unread messages
// Unread messages always take the highest priority
relevantMessages = [labelSuggestions];
} else {
// if there are no unread messages or label suggestion, scroll to the last message
// capturing last message from the messages list
relevantMessages = Array.from(
this.conversationPanel.querySelectorAll('.message--read')
).slice(-1);
}
this.conversationPanel.scrollTop = calculateScrollTop(
this.conversationPanel.scrollHeight,
this.$el.scrollHeight,
relevantMessages
);
},
setScrollParams() {
this.heightBeforeLoad = this.conversationPanel.scrollHeight;
this.scrollTopBeforeLoad = this.conversationPanel.scrollTop;
},
async fetchPreviousMessages(scrollTop = 0) {
this.setScrollParams();
const shouldLoadMoreMessages =
this.currentChat.dataFetched === true &&
!this.listLoadingStatus &&
!this.isLoadingPrevious;
if (
scrollTop < 100 &&
!this.isLoadingPrevious &&
shouldLoadMoreMessages
) {
this.isLoadingPrevious = true;
try {
await this.$store.dispatch('fetchPreviousMessages', {
conversationId: this.currentChat.id,
before: this.currentChat.messages[0].id,
});
const heightDifference =
this.conversationPanel.scrollHeight - this.heightBeforeLoad;
this.conversationPanel.scrollTop =
this.scrollTopBeforeLoad + heightDifference;
this.setScrollParams();
} catch (error) {
// Ignore Error
} finally {
this.isLoadingPrevious = false;
}
}
},
handleScroll(e) {
if (this.isProgrammaticScroll) {
// Reset the flag
this.isProgrammaticScroll = false;
this.hasUserScrolled = false;
} else {
this.hasUserScrolled = true;
}
emitter.emit(BUS_EVENTS.ON_MESSAGE_LIST_SCROLL);
this.fetchPreviousMessages(e.target.scrollTop);
},
makeMessagesRead() {
this.$store.dispatch('markMessagesRead', { id: this.currentChat.id });
},
async handleMessageRetry(message) {
if (!message) return;
const payload = useSnakeCase(message);
await this.$store.dispatch('sendMessageWithData', payload);
},
},
};
</script>
<template>
<div class="flex flex-col justify-between flex-grow h-full min-w-0 m-0">
<Banner
v-if="!currentChat.can_reply"
color-scheme="alert"
class="mx-2 mt-2 overflow-hidden rounded-lg"
:banner-message="replyWindowBannerMessage"
:href-link="replyWindowLink"
:href-link-text="replyWindowLinkText"
/>
<Banner
v-else-if="hasDuplicateInstagramInbox"
color-scheme="alert"
class="mx-2 mt-2 overflow-hidden rounded-lg"
:banner-message="$t('CONVERSATION.OLD_INSTAGRAM_INBOX_REPLY_BANNER')"
/>
<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"
:inbox-supports-reply-to="inboxSupportsReplyTo"
:messages="getMessages"
@retry="handleMessageRetry"
>
<template #beforeAll>
<transition name="slide-up">
<!-- eslint-disable-next-line vue/require-toggle-inside-transition -->
<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>
<template #unreadBadge>
<li
v-show="unreadMessageCount != 0"
class="list-none flex justify-center items-center"
>
<span
class="shadow-lg rounded-full bg-n-brand text-white text-xs font-medium my-2.5 mx-auto px-2.5 py-1.5"
>
{{ unreadMessageLabel }}
</span>
</li>
</template>
<template #after>
<ConversationLabelSuggestion
v-if="shouldShowLabelSuggestions"
:suggested-labels="labelSuggestions"
:chat-labels="currentChat.labels"
:conversation-id="currentChat.id"
/>
</template>
</MessageList>
<div
class="flex relative flex-col"
:class="{
'modal-mask': isPopOutReplyBox,
'bg-n-surface-1': !isPopOutReplyBox,
}"
>
<div
v-if="isAnyoneTyping"
class="absolute flex items-center w-full h-0 -top-7"
>
<div
class="flex py-2 pr-4 pl-5 shadow-md rounded-full bg-white dark:bg-n-solid-3 text-n-slate-11 text-xs font-semibold my-2.5 mx-auto"
>
{{ typingUserNames }}
<img
class="w-6 ltr:ml-2 rtl:mr-2"
src="assets/images/typing.gif"
alt="Someone is typing"
/>
</div>
</div>
<ReplyBox
:pop-out-reply-box="isPopOutReplyBox"
@update:pop-out-reply-box="isPopOutReplyBox = $event"
/>
</div>
</div>
</template>
<style scoped lang="scss">
.modal-mask {
@apply fixed;
&::v-deep {
.ProseMirror-woot-style {
@apply max-h-[25rem];
}
.reply-box {
@apply border border-n-weak max-w-[75rem] w-[70%];
&.is-private {
@apply dark:border-n-amber-3/30 border-n-amber-12/5;
}
}
.reply-box .reply-box__top {
@apply relative min-h-[27.5rem];
}
.reply-box__top .input {
@apply min-h-[27.5rem];
}
.emoji-dialog {
@apply absolute ltr:left-auto rtl:right-auto bottom-1;
}
}
}
</style>

View File

@@ -0,0 +1,126 @@
<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,
CMD_SEND_TRANSCRIPT,
CMD_UNMUTE_CONVERSATION,
} from 'dashboard/helper/commandbar/events';
// 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">
<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="toggleEmailModal"
/>
</div>
</template>

View File

@@ -0,0 +1,56 @@
<script setup>
defineProps({
imageSrc: {
type: String,
default: '',
},
imageAlt: {
type: String,
default: '',
},
title: {
type: String,
default: '',
},
description: {
type: String,
default: '',
},
to: {
type: String,
default: '',
},
linkText: {
type: String,
default: '',
},
});
</script>
<template>
<div
class="h-full w-full bg-n-surface-2 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-n-slate-12 font-interDisplay font-semibold tracking-[0.3px]"
>
{{ title }}
</p>
<p class="text-n-slate-11 text-sm">
{{ description }}
</p>
<router-link
v-if="to"
:to="{ name: to }"
class="no-underline text-n-brand text-sm font-medium"
>
<span>{{ linkText }}</span>
<span class="ml-2">{{ `` }}</span>
</router-link>
</div>
</div>
</template>

View File

@@ -0,0 +1,80 @@
<script setup>
import OnboardingFeatureCard from './OnboardingFeatureCard.vue';
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStoreGetters } from 'dashboard/composables/store';
const getters = useStoreGetters();
const { t } = useI18n();
const globalConfig = computed(() => getters['globalConfig/get'].value);
const currentUser = computed(() => getters.getCurrentUser.value);
const greetingMessage = computed(() => {
const hours = new Date().getHours();
let translationKey;
if (hours < 12) {
translationKey = 'ONBOARDING.GREETING_MORNING';
} else if (hours < 18) {
translationKey = 'ONBOARDING.GREETING_AFTERNOON';
} else {
translationKey = 'ONBOARDING.GREETING_EVENING';
}
return t(translationKey, {
name: currentUser.value.name,
installationName: globalConfig.value.installationName,
});
});
</script>
<template>
<div
class="min-h-screen lg:max-w-5xl max-w-4xl mx-auto grid grid-cols-2 grid-rows-[auto_1fr_1fr] auto-rows-min gap-4 p-8 w-full font-inter overflow-auto"
>
<div class="col-span-full self-start">
<p
class="text-xl font-semibold text-n-slate-12 font-interDisplay tracking-[0.3px]"
>
{{ greetingMessage }}
</p>
<p class="text-n-slate-11 max-w-2xl text-base">
{{
$t('ONBOARDING.DESCRIPTION', {
installationName: globalConfig.installationName,
})
}}
</p>
</div>
<OnboardingFeatureCard
image-src="/dashboard/images/onboarding/omnichannel-inbox.png"
image-alt="Omnichannel"
to="settings_inbox_new"
:title="$t('ONBOARDING.ALL_CONVERSATION.TITLE')"
:description="$t('ONBOARDING.ALL_CONVERSATION.DESCRIPTION')"
:link-text="$t('ONBOARDING.ALL_CONVERSATION.NEW_LINK')"
/>
<OnboardingFeatureCard
image-src="/dashboard/images/onboarding/teams.png"
image-alt="Teams"
to="settings_teams_new"
:title="$t('ONBOARDING.TEAM_MEMBERS.TITLE')"
:description="$t('ONBOARDING.TEAM_MEMBERS.DESCRIPTION')"
:link-text="$t('ONBOARDING.TEAM_MEMBERS.NEW_LINK')"
/>
<OnboardingFeatureCard
image-src="/dashboard/images/onboarding/canned-responses.png"
image-alt="Canned responses"
to="canned_list"
:title="$t('ONBOARDING.CANNED_RESPONSES.TITLE')"
:description="$t('ONBOARDING.CANNED_RESPONSES.DESCRIPTION')"
:link-text="$t('ONBOARDING.CANNED_RESPONSES.NEW_LINK')"
/>
<OnboardingFeatureCard
image-src="/dashboard/images/onboarding/labels.png"
image-alt="Labels"
to="labels_list"
:title="$t('ONBOARDING.LABELS.TITLE')"
:description="$t('ONBOARDING.LABELS.DESCRIPTION')"
:link-text="$t('ONBOARDING.LABELS.NEW_LINK')"
/>
</div>
</template>

View File

@@ -0,0 +1,54 @@
<script>
import { CONVERSATION_PRIORITY } from '../../../../shared/constants/messages';
export default {
name: 'PriorityMark',
props: {
priority: {
type: String,
default: '',
validate: value =>
[...Object.values(CONVERSATION_PRIORITY), ''].includes(value),
},
},
data() {
return {
CONVERSATION_PRIORITY,
};
},
computed: {
tooltipText() {
return this.$t(
`CONVERSATION.PRIORITY.OPTIONS.${this.priority.toUpperCase()}`
);
},
isUrgent() {
return this.priority === CONVERSATION_PRIORITY.URGENT;
},
},
};
</script>
<!-- eslint-disable-next-line vue/no-root-v-if -->
<template>
<span
v-if="priority"
v-tooltip="{
content: tooltipText,
delay: { show: 1500, hide: 0 },
hideOnClick: true,
}"
class="shrink-0 rounded-sm inline-flex items-center justify-center w-3.5 h-3.5"
:class="{
'bg-n-ruby-4 text-n-ruby-10': isUrgent,
'bg-n-slate-4 text-n-slate-11': !isUrgent,
}"
>
<fluent-icon
:icon="`priority-${priority.toLowerCase()}`"
:size="isUrgent ? 12 : 14"
class="flex-shrink-0"
view-box="0 0 14 14"
/>
</span>
</template>

View File

@@ -0,0 +1,76 @@
<script setup>
import { computed, ref } from 'vue';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import { useI18n } from 'vue-i18n';
import NextButton from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
quotedEmailText: {
type: String,
required: true,
},
previewText: {
type: String,
required: true,
},
});
const emit = defineEmits(['toggle']);
const { t } = useI18n();
const { formatMessage } = useMessageFormatter();
const isExpanded = ref(false);
const formattedQuotedEmailText = computed(() => {
if (!props.quotedEmailText) {
return '';
}
return formatMessage(props.quotedEmailText, false, false, true);
});
const toggleExpand = () => {
isExpanded.value = !isExpanded.value;
};
</script>
<template>
<div class="mt-2">
<div
class="relative rounded-md px-3 py-2 text-xs text-n-slate-12 bg-n-slate-3 dark:bg-n-solid-3"
>
<div class="absolute top-2 right-2 z-10 flex items-center gap-1">
<NextButton
v-tooltip="
isExpanded
? t('CONVERSATION.REPLYBOX.QUOTED_REPLY.COLLAPSE')
: t('CONVERSATION.REPLYBOX.QUOTED_REPLY.EXPAND')
"
ghost
slate
xs
:icon="isExpanded ? 'i-lucide-minimize' : 'i-lucide-maximize'"
@click="toggleExpand"
/>
<NextButton
v-tooltip="t('CONVERSATION.REPLYBOX.QUOTED_REPLY.REMOVE_PREVIEW')"
ghost
slate
xs
icon="i-lucide-x"
@click="emit('toggle')"
/>
</div>
<div
v-dompurify-html="formattedQuotedEmailText"
class="w-full max-w-none break-words prose prose-sm dark:prose-invert cursor-pointer ltr:pr-8 rtl:pl-8"
:class="{
'line-clamp-1': !isExpanded,
'max-h-60 overflow-y-auto': isExpanded,
}"
:title="previewText"
@click="toggleExpand"
/>
</div>
</div>
</template>

View File

@@ -0,0 +1,132 @@
<script setup>
import { computed } from 'vue';
import { useStore } from 'vuex';
import { useMapGetter } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import wootConstants from 'dashboard/constants/globals';
import Banner from 'dashboard/components/ui/Banner.vue';
const props = defineProps({
message: {
type: String,
default: '',
},
isOnPrivateNote: {
type: Boolean,
default: false,
},
});
const store = useStore();
const { t } = useI18n();
const currentChat = useMapGetter('getSelectedChat');
const currentUser = useMapGetter('getCurrentUser');
const assignedAgent = computed({
get() {
return currentChat.value?.meta?.assignee;
},
set(agent) {
const agentId = agent ? agent.id : null;
store.dispatch('setCurrentChatAssignee', {
conversationId: currentChat.value?.id,
assignee: agent,
});
store.dispatch('assignAgent', {
conversationId: currentChat.value?.id,
agentId,
});
},
});
const isUserTyping = computed(
() => props.message !== '' && !props.isOnPrivateNote
);
const isUnassigned = computed(() => !assignedAgent.value);
const isAssignedToOtherAgent = computed(
() => assignedAgent.value?.id !== currentUser.value?.id
);
const showSelfAssignBanner = computed(() => {
return (
isUserTyping.value && (isUnassigned.value || isAssignedToOtherAgent.value)
);
});
const showBotHandoffBanner = computed(
() =>
isUserTyping.value &&
currentChat.value?.status === wootConstants.STATUS_TYPE.PENDING
);
const botHandoffActionLabel = computed(() => {
return assignedAgent.value?.id === currentUser.value?.id
? t('CONVERSATION.BOT_HANDOFF_REOPEN_ACTION')
: t('CONVERSATION.BOT_HANDOFF_ACTION');
});
const selfAssignConversation = async () => {
const { avatar_url, ...rest } = currentUser.value || {};
assignedAgent.value = { ...rest, thumbnail: avatar_url };
};
const needsAssignmentToCurrentUser = computed(() => {
return isUnassigned.value || isAssignedToOtherAgent.value;
});
const onClickSelfAssign = async () => {
try {
await selfAssignConversation();
useAlert(t('CONVERSATION.CHANGE_AGENT'));
} catch (error) {
useAlert(t('CONVERSATION.CHANGE_AGENT_FAILED'));
}
};
const reopenConversation = async () => {
await store.dispatch('toggleStatus', {
conversationId: currentChat.value?.id,
status: wootConstants.STATUS_TYPE.OPEN,
});
};
const onClickBotHandoff = async () => {
try {
await reopenConversation();
if (needsAssignmentToCurrentUser.value) {
await selfAssignConversation();
}
useAlert(t('CONVERSATION.BOT_HANDOFF_SUCCESS'));
} catch (error) {
useAlert(t('CONVERSATION.BOT_HANDOFF_ERROR'));
}
};
</script>
<template>
<Banner
v-if="showSelfAssignBanner && !showBotHandoffBanner"
action-button-variant="ghost"
color-scheme="secondary"
class="mx-2 mb-2 rounded-lg !py-2"
:banner-message="$t('CONVERSATION.NOT_ASSIGNED_TO_YOU')"
has-action-button
:action-button-label="$t('CONVERSATION.ASSIGN_TO_ME')"
@primary-action="onClickSelfAssign"
/>
<Banner
v-if="showBotHandoffBanner"
action-button-variant="ghost"
color-scheme="secondary"
class="mx-2 mb-2 rounded-lg !py-2"
:banner-message="$t('CONVERSATION.BOT_HANDOFF_MESSAGE')"
has-action-button
:action-button-label="botHandoffActionLabel"
@primary-action="onClickBotHandoff"
/>
</template>

View File

@@ -0,0 +1,179 @@
<script>
import { validEmailsByComma } from './helpers/emailHeadHelper';
import { useVuelidate } from '@vuelidate/core';
import ButtonV4 from 'dashboard/components-next/button/Button.vue';
export default {
components: {
ButtonV4,
},
props: {
ccEmails: {
type: String,
default: '',
},
bccEmails: {
type: String,
default: '',
},
toEmails: {
type: String,
default: '',
},
},
emits: ['update:bccEmails', 'update:ccEmails', 'update:toEmails'],
setup() {
return { v$: useVuelidate() };
},
data() {
return {
showBcc: false,
ccEmailsVal: '',
bccEmailsVal: '',
toEmailsVal: '',
};
},
watch: {
bccEmails(newVal) {
if (newVal !== this.bccEmailsVal) {
this.bccEmailsVal = newVal;
}
},
ccEmails(newVal) {
if (newVal !== this.ccEmailsVal) {
this.ccEmailsVal = newVal;
}
},
toEmails(newVal) {
if (newVal !== this.toEmailsVal) {
this.toEmailsVal = newVal;
}
},
},
mounted() {
this.ccEmailsVal = this.ccEmails;
this.bccEmailsVal = this.bccEmails;
this.toEmailsVal = this.toEmails;
},
validations: {
ccEmailsVal: {
hasValidEmails(value) {
return validEmailsByComma(value);
},
},
bccEmailsVal: {
hasValidEmails(value) {
return validEmailsByComma(value);
},
},
toEmailsVal: {
hasValidEmails(value) {
return validEmailsByComma(value);
},
},
},
methods: {
handleAddBcc() {
this.showBcc = true;
},
onBlur() {
this.v$.$touch();
this.$emit('update:bccEmails', this.bccEmailsVal);
this.$emit('update:ccEmails', this.ccEmailsVal);
this.$emit('update:toEmails', this.toEmailsVal);
},
},
};
</script>
<template>
<div>
<div v-if="toEmails">
<div class="input-group small" :class="{ error: v$.toEmailsVal.$error }">
<label class="input-group-label">
{{ $t('CONVERSATION.REPLYBOX.EMAIL_HEAD.TO') }}
</label>
<div class="flex-1 min-w-0 m-0 rounded-none whitespace-nowrap">
<woot-input
v-model="v$.toEmailsVal.$model"
type="text"
class="[&>input]:!mb-0 [&>input]:border-transparent [&>input]:!outline-none [&>input]:h-8 [&>input]:!text-sm [&>input]:!border-0 [&>input]:border-none [&>input]:!bg-transparent dark:[&>input]:!bg-transparent"
:class="{ error: v$.toEmailsVal.$error }"
:placeholder="$t('CONVERSATION.REPLYBOX.EMAIL_HEAD.CC.PLACEHOLDER')"
@blur="onBlur"
/>
</div>
</div>
</div>
<div class="input-group-wrap">
<div class="input-group small" :class="{ error: v$.ccEmailsVal.$error }">
<label class="input-group-label">
{{ $t('CONVERSATION.REPLYBOX.EMAIL_HEAD.CC.LABEL') }}
</label>
<div class="flex-1 min-w-0 m-0 rounded-none whitespace-nowrap">
<woot-input
v-model="v$.ccEmailsVal.$model"
class="[&>input]:!mb-0 [&>input]:border-transparent [&>input]:!outline-none [&>input]:h-8 [&>input]:!text-sm [&>input]:!border-0 [&>input]:border-none [&>input]:!bg-transparent dark:[&>input]:!bg-transparent"
type="text"
:class="{ error: v$.ccEmailsVal.$error }"
:placeholder="$t('CONVERSATION.REPLYBOX.EMAIL_HEAD.CC.PLACEHOLDER')"
@blur="onBlur"
/>
</div>
<ButtonV4
v-if="!showBcc"
:label="$t('CONVERSATION.REPLYBOX.EMAIL_HEAD.ADD_BCC')"
ghost
xs
primary
@click="handleAddBcc"
/>
</div>
<span v-if="v$.ccEmailsVal.$error" class="message">
{{ $t('CONVERSATION.REPLYBOX.EMAIL_HEAD.CC.ERROR') }}
</span>
</div>
<div v-if="showBcc" class="input-group-wrap">
<div class="input-group small" :class="{ error: v$.bccEmailsVal.$error }">
<label class="input-group-label">
{{ $t('CONVERSATION.REPLYBOX.EMAIL_HEAD.BCC.LABEL') }}
</label>
<div class="flex-1 min-w-0 m-0 rounded-none whitespace-nowrap">
<woot-input
v-model="v$.bccEmailsVal.$model"
type="text"
class="[&>input]:!mb-0 [&>input]:border-transparent [&>input]:!outline-none [&>input]:h-8 [&>input]:!text-sm [&>input]:!border-0 [&>input]:border-none [&>input]:!bg-transparent dark:[&>input]:!bg-transparent"
:class="{ error: v$.bccEmailsVal.$error }"
:placeholder="
$t('CONVERSATION.REPLYBOX.EMAIL_HEAD.BCC.PLACEHOLDER')
"
@blur="onBlur"
/>
</div>
</div>
<span v-if="v$.bccEmailsVal.$error" class="message">
{{ $t('CONVERSATION.REPLYBOX.EMAIL_HEAD.BCC.ERROR') }}
</span>
</div>
</div>
</template>
<style lang="scss" scoped>
.input-group-wrap .message {
@apply text-sm text-n-ruby-8;
}
.input-group {
@apply border-b border-solid border-n-weak my-1 flex items-center gap-2;
.input-group-label {
@apply border-transparent bg-transparent text-xs font-semibold pl-0;
}
}
.input-group.error {
@apply border-n-ruby-8;
.input-group-label {
@apply text-n-ruby-8;
}
}
</style>

View File

@@ -0,0 +1,48 @@
<script setup>
import MessagePreview from 'dashboard/components/widgets/conversation/MessagePreview.vue';
import Button from 'dashboard/components-next/button/Button.vue';
defineProps({
message: {
type: Object,
required: true,
},
});
const emit = defineEmits(['dismiss']);
</script>
<template>
<div
class="reply-editor bg-n-slate-9/10 rounded-md py-1 pl-2 pr-1 text-xs tracking-wide mt-2 flex items-center gap-1.5 -mx-2"
>
<fluent-icon class="flex-shrink-0 icon" icon="arrow-reply" size="14" />
<div class="flex-grow gap-1 mt-px text-xs truncate">
{{ $t('CONVERSATION.REPLYBOX.REPLYING_TO') }}
<MessagePreview
:message="message"
:show-message-type="false"
:default-empty-message="$t('CONVERSATION.REPLY_MESSAGE_NOT_FOUND')"
class="inline"
/>
</div>
<Button
v-tooltip="$t('CONVERSATION.REPLYBOX.DISMISS_REPLY')"
ghost
xs
slate
icon="i-lucide-x"
@click.stop="emit('dismiss')"
/>
</div>
</template>
<style lang="scss">
// TODO: Remove this
// override for dashboard/assets/scss/widgets/_reply-box.scss
.reply-editor {
.icon {
margin-right: 0px !important;
}
}
</style>

View File

@@ -0,0 +1,105 @@
<script setup>
import { computed } from 'vue';
import { format } from 'date-fns';
import { useI18n } from 'vue-i18n';
const props = defineProps({
order: {
type: Object,
required: true,
},
});
const { t } = useI18n();
const formatDate = dateString => {
return format(new Date(dateString), 'MMM d, yyyy');
};
const formatCurrency = (amount, currency) => {
return new Intl.NumberFormat('en', {
style: 'currency',
currency: currency || 'USD',
}).format(amount);
};
const getStatusClass = status => {
const classes = {
paid: 'bg-n-teal-5 text-n-teal-12',
};
return classes[status] || 'bg-n-solid-3 text-n-slate-12';
};
const getStatusI18nKey = (type, status = '') => {
return `CONVERSATION_SIDEBAR.SHOPIFY.${type.toUpperCase()}_STATUS.${status.toUpperCase()}`;
};
const fulfillmentStatus = computed(() => {
const { fulfillment_status: status } = props.order;
if (!status) {
return '';
}
return t(getStatusI18nKey('FULFILLMENT', status));
});
const financialStatus = computed(() => {
const { financial_status: status } = props.order;
if (!status) {
return '';
}
return t(getStatusI18nKey('FINANCIAL', status));
});
const getFulfillmentClass = status => {
const classes = {
fulfilled: 'text-n-teal-9',
partial: 'text-n-amber-9',
unfulfilled: 'text-n-ruby-9',
};
return classes[status] || 'text-n-slate-11';
};
</script>
<template>
<div
class="py-3 border-b border-n-weak last:border-b-0 flex flex-col gap-1.5"
>
<div class="flex justify-between items-center">
<div class="font-medium flex">
<a
:href="order.admin_url"
target="_blank"
rel="noopener noreferrer"
class="hover:underline text-n-slate-12 cursor-pointer truncate"
>
{{ $t('CONVERSATION_SIDEBAR.SHOPIFY.ORDER_ID', { id: order.id }) }}
<i class="i-lucide-external-link pl-5" />
</a>
</div>
<div
:class="getStatusClass(order.financial_status)"
class="text-xs px-2 py-1 rounded capitalize truncate"
:title="financialStatus"
>
{{ financialStatus }}
</div>
</div>
<div class="text-sm text-n-slate-12">
<span class="text-n-slate-11 border-r border-n-weak pr-2">
{{ formatDate(order.created_at) }}
</span>
<span class="text-n-slate-11 pl-2">
{{ formatCurrency(order.total_price, order.currency) }}
</span>
</div>
<div v-if="fulfillmentStatus">
<span
:class="getFulfillmentClass(order.fulfillment_status)"
class="capitalize font-medium"
:title="fulfillmentStatus"
>
{{ fulfillmentStatus }}
</span>
</div>
</div>
</template>

View File

@@ -0,0 +1,71 @@
<script setup>
import { ref, watch, computed } from 'vue';
import { useFunctionGetter } from 'dashboard/composables/store';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import ShopifyAPI from '../../../api/integrations/shopify';
import ShopifyOrderItem from './ShopifyOrderItem.vue';
const props = defineProps({
contactId: {
type: [Number, String],
required: true,
},
});
const contact = useFunctionGetter('contacts/getContact', props.contactId);
const hasSearchableInfo = computed(
() => !!contact.value?.email || !!contact.value?.phone_number
);
const orders = ref([]);
const loading = ref(true);
const error = ref('');
const fetchOrders = async () => {
try {
loading.value = true;
const response = await ShopifyAPI.getOrders(props.contactId);
orders.value = response.data.orders;
} catch (e) {
error.value =
e.response?.data?.error || 'CONVERSATION_SIDEBAR.SHOPIFY.ERROR';
} finally {
loading.value = false;
}
};
watch(
() => props.contactId,
() => {
if (hasSearchableInfo.value) {
fetchOrders();
}
},
{ immediate: true }
);
</script>
<template>
<div class="px-4 py-2 text-n-slate-12">
<div v-if="!hasSearchableInfo" class="text-center text-n-slate-12">
{{ $t('CONVERSATION_SIDEBAR.SHOPIFY.NO_SHOPIFY_ORDERS') }}
</div>
<div v-else-if="loading" class="flex justify-center items-center p-4">
<Spinner size="32" class="text-n-brand" />
</div>
<div v-else-if="error" class="text-center text-n-ruby-12">
{{ error }}
</div>
<div v-else-if="!orders.length" class="text-center text-n-slate-12">
{{ $t('CONVERSATION_SIDEBAR.SHOPIFY.NO_SHOPIFY_ORDERS') }}
</div>
<div v-else>
<ShopifyOrderItem
v-for="order in orders"
:key="order.id"
:order="order"
/>
</div>
</div>
</template>

View File

@@ -0,0 +1,181 @@
<script setup>
import Avatar from 'next/avatar/Avatar.vue';
import { ref, computed, watch, nextTick } from 'vue';
import { useStoreGetters, useMapGetter } from 'dashboard/composables/store';
import { useKeyboardNavigableList } from 'dashboard/composables/useKeyboardNavigableList';
import { useI18n } from 'vue-i18n';
const props = defineProps({
searchKey: {
type: String,
default: '',
},
});
const emit = defineEmits(['selectAgent']);
const { t } = useI18n();
const getters = useStoreGetters();
const agents = computed(() => getters['agents/getVerifiedAgents'].value);
const teams = useMapGetter('teams/getTeams');
const tagAgentsRef = ref(null);
const selectedIndex = ref(0);
const items = computed(() => {
const search = props.searchKey?.trim().toLowerCase() || '';
const buildItems = (list, type, infoKey) =>
list
.map(item => ({
...item,
type,
displayName: item.name,
displayInfo: item[infoKey],
}))
.filter(item =>
search ? item.displayName.toLowerCase().includes(search) : true
);
const categories = [
{
title: t('CONVERSATION.MENTION.AGENTS'),
data: buildItems(agents.value, 'user', 'email'),
},
{
title: t('CONVERSATION.MENTION.TEAMS'),
data: buildItems(teams.value, 'team', 'description'),
},
];
return categories.flatMap(({ title, data }) =>
data.length
? [
{ type: 'header', title, id: `${title.toLowerCase()}-header` },
...data,
]
: []
);
});
const selectableItems = computed(() => {
return items.value.filter(item => item.type !== 'header');
});
const getSelectableIndex = item => {
return selectableItems.value.findIndex(
selectableItem =>
selectableItem.type === item.type && selectableItem.id === item.id
);
};
const adjustScroll = () => {
nextTick(() => {
if (tagAgentsRef.value) {
const selectedElement = tagAgentsRef.value.querySelector(
`#mention-item-${selectedIndex.value}`
);
if (selectedElement) {
selectedElement.scrollIntoView({
block: 'nearest',
behavior: 'auto',
});
}
}
});
};
const onSelect = () => {
emit('selectAgent', selectableItems.value[selectedIndex.value]);
};
useKeyboardNavigableList({
items: selectableItems,
onSelect,
adjustScroll,
selectedIndex,
});
watch(selectableItems, newListOfAgents => {
if (newListOfAgents.length < selectedIndex.value + 1) {
selectedIndex.value = 0;
}
});
const onHover = index => {
selectedIndex.value = index;
};
const onAgentSelect = index => {
selectedIndex.value = index;
onSelect();
};
</script>
<template>
<div>
<ul
v-if="items.length"
ref="tagAgentsRef"
class="vertical dropdown menu mention--box bg-n-solid-1 p-1 rounded-xl text-sm overflow-auto absolute w-full z-20 shadow-md left-0 leading-[1.2] bottom-full max-h-[12.5rem] border border-solid border-n-strong"
role="listbox"
>
<li
v-for="item in items"
:id="
item.type === 'header'
? undefined
: `mention-item-${getSelectableIndex(item)}`
"
:key="`${item.type}-${item.id}`"
>
<!-- Section Header -->
<div
v-if="item.type === 'header'"
class="px-2 py-2 text-xs font-medium tracking-wide capitalize text-n-slate-11"
>
{{ item.title }}
</div>
<!-- Selectable Item -->
<div
v-else
:class="{
'bg-n-alpha-black2': getSelectableIndex(item) === selectedIndex,
}"
class="flex items-center px-2 py-1 rounded-md cursor-pointer"
role="option"
@click="onAgentSelect(getSelectableIndex(item))"
@mouseover="onHover(getSelectableIndex(item))"
>
<div class="ltr:mr-2 rtl:ml-2">
<Avatar
:src="item.thumbnail"
:name="item.displayName"
rounded-full
/>
</div>
<div
class="overflow-hidden flex-1 max-w-full whitespace-nowrap text-ellipsis"
>
<h5
class="overflow-hidden mb-0 text-sm capitalize whitespace-nowrap text-n-slate-11 text-ellipsis"
:class="{
'text-n-slate-12': getSelectableIndex(item) === selectedIndex,
}"
>
{{ item.displayName }}
</h5>
<div
class="overflow-hidden text-xs whitespace-nowrap text-ellipsis text-n-slate-10"
:class="{
'text-n-slate-11': getSelectableIndex(item) === selectedIndex,
}"
>
{{ item.displayInfo }}
</div>
</div>
</div>
</li>
</ul>
</div>
</template>

View File

@@ -0,0 +1,56 @@
<script setup>
import { ref, computed, watch } from 'vue';
import ToolsDropdown from 'dashboard/components-next/captain/assistant/ToolsDropdown.vue';
import { useKeyboardNavigableList } from 'dashboard/composables/useKeyboardNavigableList';
import { useMapGetter } from 'dashboard/composables/store.js';
const props = defineProps({
searchKey: {
type: String,
default: '',
},
});
const emit = defineEmits(['selectTool']);
const tools = useMapGetter('captainTools/getRecords');
const selectedIndex = ref(0);
const filteredTools = computed(() => {
const search = props.searchKey?.trim().toLowerCase() || '';
return tools.value.filter(tool => tool.title.toLowerCase().includes(search));
});
const adjustScroll = () => {};
const onSelect = idx => {
if (idx) selectedIndex.value = idx;
emit('selectTool', filteredTools.value[selectedIndex.value]);
};
useKeyboardNavigableList({
items: filteredTools,
onSelect,
adjustScroll,
selectedIndex,
});
watch(filteredTools, newListOfTools => {
if (newListOfTools.length < selectedIndex.value + 1) {
selectedIndex.value = 0;
}
});
</script>
<template>
<ToolsDropdown
v-if="filteredTools.length"
:items="filteredTools"
:selected-index="selectedIndex"
class="bottom-20"
@select="onSelect"
/>
<template v-else />
</template>

View File

@@ -0,0 +1,78 @@
<script>
import { mapGetters } from 'vuex';
import { MESSAGE_VARIABLES } from 'shared/constants/messages';
import { sanitizeVariableSearchKey } from 'dashboard/helper/commons';
import MentionBox from '../mentions/MentionBox.vue';
export default {
components: { MentionBox },
props: {
searchKey: {
type: String,
default: '',
},
},
emits: ['selectVariable'],
computed: {
...mapGetters({
customAttributes: 'attributes/getAttributes',
}),
sanitizedSearchKey() {
return sanitizeVariableSearchKey(this.searchKey);
},
items() {
return [
...this.standardAttributeVariables,
...this.customAttributeVariables,
];
},
standardAttributeVariables() {
return MESSAGE_VARIABLES.filter(variable => {
return (
variable.label.includes(this.sanitizedSearchKey) ||
variable.key.includes(this.sanitizedSearchKey)
);
}).map(variable => ({
label: variable.key,
key: variable.key,
description: variable.label,
}));
},
customAttributeVariables() {
return this.customAttributes.map(attribute => {
const attributePrefix =
attribute.attribute_model === 'conversation_attribute'
? 'conversation'
: 'contact';
return {
label: `${attributePrefix}.custom_attribute.${attribute.attribute_key}`,
key: `${attributePrefix}.custom_attribute.${attribute.attribute_key}`,
description: attribute.attribute_description,
};
});
},
},
methods: {
handleVariableClick(item = {}) {
this.$emit('selectVariable', item.key);
},
},
};
</script>
<!-- eslint-disable-next-line vue/no-root-v-if -->
<template>
<MentionBox
v-if="items.length"
type="variable"
:items="items"
@mention-select="handleVariableClick"
/>
</template>
<style scoped>
.variable--list-label {
font-weight: 600;
}
</style>

View File

@@ -0,0 +1,77 @@
<script setup>
import { computed } from 'vue';
import {
VOICE_CALL_STATUS,
VOICE_CALL_DIRECTION,
} from 'dashboard/components-next/message/constants';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
status: { type: String, default: '' },
direction: { type: String, default: '' },
messagePreviewClass: { type: [String, Array, Object], default: '' },
});
const LABEL_KEYS = {
[VOICE_CALL_STATUS.IN_PROGRESS]: 'CONVERSATION.VOICE_CALL.CALL_IN_PROGRESS',
[VOICE_CALL_STATUS.COMPLETED]: 'CONVERSATION.VOICE_CALL.CALL_ENDED',
};
const ICON_MAP = {
[VOICE_CALL_STATUS.IN_PROGRESS]: 'i-ph-phone-call',
[VOICE_CALL_STATUS.NO_ANSWER]: 'i-ph-phone-x',
[VOICE_CALL_STATUS.FAILED]: 'i-ph-phone-x',
};
const COLOR_MAP = {
[VOICE_CALL_STATUS.IN_PROGRESS]: 'text-n-teal-9',
[VOICE_CALL_STATUS.RINGING]: 'text-n-teal-9',
[VOICE_CALL_STATUS.COMPLETED]: 'text-n-slate-11',
[VOICE_CALL_STATUS.NO_ANSWER]: 'text-n-ruby-9',
[VOICE_CALL_STATUS.FAILED]: 'text-n-ruby-9',
};
const isOutbound = computed(
() => props.direction === VOICE_CALL_DIRECTION.OUTBOUND
);
const isFailed = computed(() =>
[VOICE_CALL_STATUS.NO_ANSWER, VOICE_CALL_STATUS.FAILED].includes(props.status)
);
const labelKey = computed(() => {
if (LABEL_KEYS[props.status]) return LABEL_KEYS[props.status];
if (props.status === VOICE_CALL_STATUS.RINGING) {
return isOutbound.value
? 'CONVERSATION.VOICE_CALL.OUTGOING_CALL'
: 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
}
return isFailed.value
? 'CONVERSATION.VOICE_CALL.MISSED_CALL'
: 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
});
const iconName = computed(() => {
if (ICON_MAP[props.status]) return ICON_MAP[props.status];
return isOutbound.value ? 'i-ph-phone-outgoing' : 'i-ph-phone-incoming';
});
const statusColor = computed(
() => COLOR_MAP[props.status] || 'text-n-slate-11'
);
</script>
<template>
<div
class="my-0 mx-2 leading-6 h-6 flex-1 min-w-0 text-sm overflow-hidden text-ellipsis whitespace-nowrap"
:class="messagePreviewClass"
>
<Icon
class="inline-block -mt-0.5 align-middle size-4"
:icon="iconName"
:class="statusColor"
/>
<span class="mx-1" :class="statusColor">
{{ $t(labelKey) }}
</span>
</div>
</template>

View File

@@ -0,0 +1,85 @@
<script>
import TemplatesPicker from './TemplatesPicker.vue';
import WhatsAppTemplateReply from './WhatsAppTemplateReply.vue';
export default {
components: {
TemplatesPicker,
WhatsAppTemplateReply,
},
props: {
show: {
type: Boolean,
default: false,
},
inboxId: {
type: Number,
default: undefined,
},
},
emits: ['onSend', 'cancel', 'update:show'],
data() {
return {
selectedWaTemplate: null,
};
},
computed: {
localShow: {
get() {
return this.show;
},
set(value) {
this.$emit('update:show', value);
},
},
modalHeaderContent() {
return this.selectedWaTemplate
? this.$t('WHATSAPP_TEMPLATES.MODAL.TEMPLATE_SELECTED_SUBTITLE', {
templateName: this.selectedWaTemplate.name,
})
: this.$t('WHATSAPP_TEMPLATES.MODAL.SUBTITLE');
},
},
methods: {
pickTemplate(template) {
this.selectedWaTemplate = template;
},
onResetTemplate() {
this.selectedWaTemplate = null;
},
onSendMessage(message) {
this.$emit('onSend', message);
},
onClose() {
this.$emit('cancel');
},
},
};
</script>
<template>
<woot-modal v-model:show="localShow" :on-close="onClose" size="modal-big">
<woot-modal-header
:header-title="$t('WHATSAPP_TEMPLATES.MODAL.TITLE')"
:header-content="modalHeaderContent"
/>
<div class="row modal-content">
<TemplatesPicker
v-if="!selectedWaTemplate"
:inbox-id="inboxId"
@on-select="pickTemplate"
/>
<WhatsAppTemplateReply
v-else
:template="selectedWaTemplate"
@reset-template="onResetTemplate"
@send-message="onSendMessage"
/>
</div>
</woot-modal>
</template>
<style scoped>
.modal-content {
padding: 1.5625rem 2rem;
}
</style>

View File

@@ -0,0 +1,212 @@
<script setup>
import { ref, computed, toRef } from 'vue';
import { useAlert } from 'dashboard/composables';
import { useFunctionGetter, useStore } from 'dashboard/composables/store';
import {
COMPONENT_TYPES,
MEDIA_FORMATS,
findComponentByType,
} from 'dashboard/helper/templateHelper';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import { useI18n } from 'vue-i18n';
const props = defineProps({
inboxId: {
type: Number,
default: undefined,
},
});
const emit = defineEmits(['onSelect']);
const { t } = useI18n();
const store = useStore();
const query = ref('');
const isRefreshing = ref(false);
const whatsAppTemplateMessages = useFunctionGetter(
'inboxes/getFilteredWhatsAppTemplates',
toRef(props, 'inboxId')
);
const filteredTemplateMessages = computed(() =>
whatsAppTemplateMessages.value.filter(template =>
template.name.toLowerCase().includes(query.value.toLowerCase())
)
);
const getTemplateBody = template => {
return findComponentByType(template, COMPONENT_TYPES.BODY)?.text || '';
};
const getTemplateHeader = template => {
return findComponentByType(template, COMPONENT_TYPES.HEADER);
};
const getTemplateFooter = template => {
return findComponentByType(template, COMPONENT_TYPES.FOOTER);
};
const getTemplateButtons = template => {
return findComponentByType(template, COMPONENT_TYPES.BUTTONS);
};
const hasMediaContent = template => {
const header = getTemplateHeader(template);
return header && MEDIA_FORMATS.includes(header.format);
};
const refreshTemplates = async () => {
isRefreshing.value = true;
try {
await store.dispatch('inboxes/syncTemplates', props.inboxId);
useAlert(t('WHATSAPP_TEMPLATES.PICKER.REFRESH_SUCCESS'));
} catch (error) {
useAlert(t('WHATSAPP_TEMPLATES.PICKER.REFRESH_ERROR'));
} finally {
isRefreshing.value = false;
}
};
</script>
<template>
<div class="w-full">
<div class="flex gap-2 mb-2.5">
<div
class="flex flex-1 gap-1 items-center px-2.5 py-0 rounded-lg bg-n-alpha-black2 outline outline-1 outline-n-weak hover:outline-n-slate-6 dark:hover:outline-n-slate-6 focus-within:outline-n-brand dark:focus-within:outline-n-brand"
>
<fluent-icon icon="search" class="text-n-slate-12" size="16" />
<input
v-model="query"
type="search"
:placeholder="t('WHATSAPP_TEMPLATES.PICKER.SEARCH_PLACEHOLDER')"
class="reset-base w-full h-9 bg-transparent text-n-slate-12 !text-sm !outline-0"
/>
</div>
<button
:disabled="isRefreshing"
class="flex justify-center items-center w-9 h-9 rounded-lg bg-n-alpha-black2 outline outline-1 outline-n-weak hover:outline-n-slate-6 dark:hover:outline-n-slate-6 hover:bg-n-alpha-2 dark:hover:bg-n-solid-2 disabled:opacity-50 disabled:cursor-not-allowed"
:title="t('WHATSAPP_TEMPLATES.PICKER.REFRESH_BUTTON')"
@click="refreshTemplates"
>
<Icon
icon="i-lucide-refresh-ccw"
class="text-n-slate-12 size-4"
:class="{ 'animate-spin': isRefreshing }"
/>
</button>
</div>
<div
class="bg-n-background outline-n-container outline outline-1 rounded-lg max-h-[18.75rem] overflow-y-auto p-2.5"
>
<div v-for="(template, i) in filteredTemplateMessages" :key="template.id">
<button
class="block p-2.5 w-full text-left rounded-lg cursor-pointer hover:bg-n-alpha-2 dark:hover:bg-n-solid-2"
@click="emit('onSelect', template)"
>
<div>
<div class="flex justify-between items-center mb-2.5">
<p class="text-sm">
{{ template.name }}
</p>
<span
class="inline-block px-2 py-1 text-xs leading-none rounded-lg cursor-default bg-n-slate-3 text-n-slate-12"
>
{{ t('WHATSAPP_TEMPLATES.PICKER.LABELS.LANGUAGE') }}:
{{ template.language }}
</span>
</div>
<!-- Header -->
<div v-if="getTemplateHeader(template)" class="mb-3">
<p class="text-xs font-medium text-n-slate-11">
{{ t('WHATSAPP_TEMPLATES.PICKER.HEADER') || 'HEADER' }}
</p>
<div
v-if="getTemplateHeader(template).format === 'TEXT'"
class="text-sm label-body"
>
{{ getTemplateHeader(template).text }}
</div>
<div
v-else-if="hasMediaContent(template)"
class="text-sm italic text-n-slate-11"
>
{{
t('WHATSAPP_TEMPLATES.PICKER.MEDIA_CONTENT', {
format: getTemplateHeader(template).format,
}) ||
`${getTemplateHeader(template).format} ${t('WHATSAPP_TEMPLATES.PICKER.MEDIA_CONTENT_FALLBACK')}`
}}
</div>
</div>
<!-- Body -->
<div>
<p class="text-xs font-medium text-n-slate-11">
{{ t('WHATSAPP_TEMPLATES.PICKER.BODY') || 'BODY' }}
</p>
<p class="text-sm label-body">{{ getTemplateBody(template) }}</p>
</div>
<!-- Footer -->
<div v-if="getTemplateFooter(template)" class="mt-3">
<p class="text-xs font-medium text-n-slate-11">
{{ t('WHATSAPP_TEMPLATES.PICKER.FOOTER') || 'FOOTER' }}
</p>
<p class="text-sm label-body">
{{ getTemplateFooter(template).text }}
</p>
</div>
<!-- Buttons -->
<div v-if="getTemplateButtons(template)" class="mt-3">
<p class="text-xs font-medium text-n-slate-11">
{{ t('WHATSAPP_TEMPLATES.PICKER.BUTTONS') || 'BUTTONS' }}
</p>
<div class="flex flex-wrap gap-1 mt-1">
<span
v-for="button in getTemplateButtons(template).buttons"
:key="button.text"
class="px-2 py-1 text-xs rounded bg-n-slate-3 text-n-slate-12"
>
{{ button.text }}
</span>
</div>
</div>
<div class="mt-3">
<p class="text-xs font-medium text-n-slate-11">
{{ t('WHATSAPP_TEMPLATES.PICKER.CATEGORY') || 'CATEGORY' }}
</p>
<p class="text-sm">{{ template.category }}</p>
</div>
</div>
</button>
<hr
v-if="i != filteredTemplateMessages.length - 1"
:key="`hr-${i}`"
class="border-b border-solid border-n-weak my-2.5 mx-auto max-w-[95%]"
/>
</div>
<div v-if="!filteredTemplateMessages.length" class="py-8 text-center">
<div v-if="query && whatsAppTemplateMessages.length">
<p>
{{ t('WHATSAPP_TEMPLATES.PICKER.NO_TEMPLATES_FOUND') }}
<strong>{{ query }}</strong>
</p>
</div>
<div v-else-if="!whatsAppTemplateMessages.length" class="space-y-4">
<p class="text-n-slate-11">
{{ t('WHATSAPP_TEMPLATES.PICKER.NO_TEMPLATES_AVAILABLE') }}
</p>
</div>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.label-body {
font-family: monospace;
}
</style>

View File

@@ -0,0 +1,49 @@
<script setup>
import WhatsAppTemplateParser from 'dashboard/components-next/whatsapp/WhatsAppTemplateParser.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
defineProps({
template: {
type: Object,
default: () => ({}),
},
});
const emit = defineEmits(['sendMessage', 'resetTemplate']);
const handleSendMessage = payload => {
emit('sendMessage', payload);
};
const handleResetTemplate = () => {
emit('resetTemplate');
};
</script>
<template>
<div class="w-full">
<WhatsAppTemplateParser
:template="template"
@send-message="handleSendMessage"
@reset-template="handleResetTemplate"
>
<template #actions="{ sendMessage, resetTemplate, disabled }">
<footer class="flex gap-2 justify-end">
<NextButton
faded
slate
type="reset"
:label="$t('WHATSAPP_TEMPLATES.PARSER.GO_BACK_LABEL')"
@click="resetTemplate"
/>
<NextButton
type="button"
:label="$t('WHATSAPP_TEMPLATES.PARSER.SEND_MESSAGE_LABEL')"
:disabled="disabled"
@click="sendMessage"
/>
</footer>
</template>
</WhatsAppTemplateParser>
</div>
</template>

View File

@@ -0,0 +1,174 @@
import {
OPERATOR_TYPES_1,
OPERATOR_TYPES_2,
OPERATOR_TYPES_3,
OPERATOR_TYPES_5,
} from '../../FilterInput/FilterOperatorTypes';
const filterTypes = [
{
attributeKey: 'status',
attributeI18nKey: 'STATUS',
inputType: 'multi_select',
dataType: 'text',
filterOperators: OPERATOR_TYPES_1,
attributeModel: 'standard',
},
{
attributeKey: 'assignee_id',
attributeI18nKey: 'ASSIGNEE_NAME',
inputType: 'search_select',
dataType: 'text',
filterOperators: OPERATOR_TYPES_2,
attributeModel: 'standard',
},
{
attributeKey: 'priority',
attributeI18nKey: 'PRIORITY',
inputType: 'multi_select',
dataType: 'text',
filterOperators: OPERATOR_TYPES_1,
attributeModel: 'standard',
},
{
attributeKey: 'inbox_id',
attributeI18nKey: 'INBOX_NAME',
inputType: 'search_select',
dataType: 'text',
filterOperators: OPERATOR_TYPES_2,
attributeModel: 'standard',
},
{
attributeKey: 'team_id',
attributeI18nKey: 'TEAM_NAME',
inputType: 'search_select',
dataType: 'number',
filterOperators: OPERATOR_TYPES_2,
attributeModel: 'standard',
},
{
attributeKey: 'display_id',
attributeI18nKey: 'CONVERSATION_IDENTIFIER',
inputType: 'plain_text',
dataType: 'Number',
filterOperators: OPERATOR_TYPES_3,
attributeModel: 'standard',
},
{
attributeKey: 'campaign_id',
attributeI18nKey: 'CAMPAIGN_NAME',
inputType: 'search_select',
dataType: 'Number',
filterOperators: OPERATOR_TYPES_2,
attributeModel: 'standard',
},
{
attributeKey: 'labels',
attributeI18nKey: 'LABELS',
inputType: 'multi_select',
dataType: 'text',
filterOperators: OPERATOR_TYPES_2,
attributeModel: 'standard',
},
{
attributeKey: 'browser_language',
attributeI18nKey: 'BROWSER_LANGUAGE',
inputType: 'search_select',
dataType: 'text',
filterOperators: OPERATOR_TYPES_1,
attributeModel: 'additional',
},
{
attributeKey: 'referer',
attributeI18nKey: 'REFERER_LINK',
inputType: 'plain_text',
dataType: 'text',
filterOperators: OPERATOR_TYPES_3,
attributeModel: 'additional',
},
{
attributeKey: 'created_at',
attributeI18nKey: 'CREATED_AT',
inputType: 'date',
dataType: 'text',
filterOperators: OPERATOR_TYPES_5,
attributeModel: 'standard',
},
{
attributeKey: 'last_activity_at',
attributeI18nKey: 'LAST_ACTIVITY',
inputType: 'date',
dataType: 'text',
filterOperators: OPERATOR_TYPES_5,
attributeModel: 'standard',
},
{
attributeKey: 'referer',
attributeI18nKey: 'REFERER_LINK',
inputType: 'plain_text',
dataType: 'text',
filterOperators: OPERATOR_TYPES_5,
attributeModel: 'standard',
},
];
export const filterAttributeGroups = [
{
name: 'Standard Filters',
i18nGroup: 'STANDARD_FILTERS',
attributes: [
{
key: 'status',
i18nKey: 'STATUS',
},
{
key: 'assignee_id',
i18nKey: 'ASSIGNEE_NAME',
},
{
key: 'inbox_id',
i18nKey: 'INBOX_NAME',
},
{
key: 'team_id',
i18nKey: 'TEAM_NAME',
},
{
key: 'display_id',
i18nKey: 'CONVERSATION_IDENTIFIER',
},
{
key: 'campaign_id',
i18nKey: 'CAMPAIGN_NAME',
},
{
key: 'labels',
i18nKey: 'LABELS',
},
{
key: 'created_at',
i18nKey: 'CREATED_AT',
},
{
key: 'last_activity_at',
i18nKey: 'LAST_ACTIVITY',
},
],
},
{
name: 'Additional Filters',
i18nGroup: 'ADDITIONAL_FILTERS',
attributes: [
{
key: 'browser_language',
i18nKey: 'BROWSER_LANGUAGE',
},
{
key: 'referer',
i18nKey: 'REFERER_LINK',
},
],
},
];
export default filterTypes;

View File

@@ -0,0 +1,751 @@
const languages = [
{
name: 'Abkhazian',
id: 'ab',
},
{
name: 'Afar',
id: 'aa',
},
{
name: 'Afrikaans',
id: 'af',
},
{
name: 'Akan',
id: 'ak',
},
{
name: 'Albanian',
id: 'sq',
},
{
name: 'Amharic',
id: 'am',
},
{
name: 'Arabic',
id: 'ar',
},
{
name: 'Aragonese',
id: 'an',
},
{
name: 'Armenian',
id: 'hy',
},
{
name: 'Assamese',
id: 'as',
},
{
name: 'Avaric',
id: 'av',
},
{
name: 'Avestan',
id: 'ae',
},
{
name: 'Aymara',
id: 'ay',
},
{
name: 'Azerbaijani',
id: 'az',
},
{
name: 'Bambara',
id: 'bm',
},
{
name: 'Bashkir',
id: 'ba',
},
{
name: 'Basque',
id: 'eu',
},
{
name: 'Belarusian',
id: 'be',
},
{
name: 'Bengali',
id: 'bn',
},
{
name: 'Bislama',
id: 'bi',
},
{
name: 'Bosnian',
id: 'bs',
},
{
name: 'Breton',
id: 'br',
},
{
name: 'Bulgarian',
id: 'bg',
},
{
name: 'Burmese',
id: 'my',
},
{
name: 'Catalan',
id: 'ca',
},
{
name: 'Chamorro',
id: 'ch',
},
{
name: 'Chechen',
id: 'ce',
},
{
name: 'Chichewa',
id: 'ny',
},
{
name: 'Chinese',
id: 'zh',
},
{
name: 'Church Slavonic',
id: 'cu',
},
{
name: 'Chuvash',
id: 'cv',
},
{
name: 'Cornish',
id: 'kw',
},
{
name: 'Corsican',
id: 'co',
},
{
name: 'Cree',
id: 'cr',
},
{
name: 'Croatian',
id: 'hr',
},
{
name: 'Czech',
id: 'cs',
},
{
name: 'Danish',
id: 'da',
},
{
name: 'Divehi',
id: 'dv',
},
{
name: 'Dutch',
id: 'nl',
},
{
name: 'Dzongkha',
id: 'dz',
},
{
name: 'English',
id: 'en',
},
{
name: 'Esperanto',
id: 'eo',
},
{
name: 'Estonian',
id: 'et',
},
{
name: 'Ewe',
id: 'ee',
},
{
name: 'Faroese',
id: 'fo',
},
{
name: 'Fijian',
id: 'fj',
},
{
name: 'Finnish',
id: 'fi',
},
{
name: 'French',
id: 'fr',
},
{
name: 'Western Frisian',
id: 'fy',
},
{
name: 'Fulah',
id: 'ff',
},
{
name: 'Gaelic',
id: 'gd',
},
{
name: 'Galician',
id: 'gl',
},
{
name: 'Ganda',
id: 'lg',
},
{
name: 'Georgian',
id: 'ka',
},
{
name: 'German',
id: 'de',
},
{
name: 'Greek',
id: 'el',
},
{
name: 'Kalaallisut',
id: 'kl',
},
{
name: 'Guarani',
id: 'gn',
},
{
name: 'Gujarati',
id: 'gu',
},
{
name: 'Haitian',
id: 'ht',
},
{
name: 'Hausa',
id: 'ha',
},
{
name: 'Hebrew',
id: 'he',
},
{
name: 'Herero',
id: 'hz',
},
{
name: 'Hindi',
id: 'hi',
},
{
name: 'Hiri Motu',
id: 'ho',
},
{
name: 'Hungarian',
id: 'hu',
},
{
name: 'Icelandic',
id: 'is',
},
{
name: 'Ido',
id: 'io',
},
{
name: 'Igbo',
id: 'ig',
},
{
name: 'Indonesian',
id: 'id',
},
{
name: 'Interlingua',
id: 'ia',
},
{
name: 'Interlingue',
id: 'ie',
},
{
name: 'Inuktitut',
id: 'iu',
},
{
name: 'Inupiaq',
id: 'ik',
},
{
name: 'Irish',
id: 'ga',
},
{
name: 'Italian',
id: 'it',
},
{
name: 'Japanese',
id: 'ja',
},
{
name: 'Javanese',
id: 'jv',
},
{
name: 'Kannada',
id: 'kn',
},
{
name: 'Kanuri',
id: 'kr',
},
{
name: 'Kashmiri',
id: 'ks',
},
{
name: 'Kazakh',
id: 'kk',
},
{
name: 'Central Khmer',
id: 'km',
},
{
name: 'Kikuyu',
id: 'ki',
},
{
name: 'Kinyarwanda',
id: 'rw',
},
{
name: 'Kirghiz',
id: 'ky',
},
{
name: 'Komi',
id: 'kv',
},
{
name: 'Kongo',
id: 'kg',
},
{
name: 'Korean',
id: 'ko',
},
{
name: 'Kuanyama',
id: 'kj',
},
{
name: 'Kurdish',
id: 'ku',
},
{
name: 'Lao',
id: 'lo',
},
{
name: 'Latin',
id: 'la',
},
{
name: 'Latvian',
id: 'lv',
},
{
name: 'Limburgan',
id: 'li',
},
{
name: 'Lingala',
id: 'ln',
},
{
name: 'Lithuanian',
id: 'lt',
},
{
name: 'Luba-Katanga',
id: 'lu',
},
{
name: 'Luxembourgish',
id: 'lb',
},
{
name: 'Macedonian',
id: 'mk',
},
{
name: 'Malagasy',
id: 'mg',
},
{
name: 'Malay',
id: 'ms',
},
{
name: 'Malayalam',
id: 'ml',
},
{
name: 'Maltese',
id: 'mt',
},
{
name: 'Manx',
id: 'gv',
},
{
name: 'Maori',
id: 'mi',
},
{
name: 'Marathi',
id: 'mr',
},
{
name: 'Marshallese',
id: 'mh',
},
{
name: 'Mongolian',
id: 'mn',
},
{
name: 'Nauru',
id: 'na',
},
{
name: 'Navajo',
id: 'nv',
},
{
name: 'North Ndebele',
id: 'nd',
},
{
name: 'South Ndebele',
id: 'nr',
},
{
name: 'Ndonga',
id: 'ng',
},
{
name: 'Nepali',
id: 'ne',
},
{
name: 'Norwegian',
id: 'no',
},
{
name: 'Norwegian Bokmål',
id: 'nb',
},
{
name: 'Norwegian Nynorsk',
id: 'nn',
},
{
name: 'Sichuan Yi',
id: 'ii',
},
{
name: 'Occitan',
id: 'oc',
},
{
name: 'Ojibwa',
id: 'oj',
},
{
name: 'Oriya',
id: 'or',
},
{
name: 'Oromo',
id: 'om',
},
{
name: 'Ossetian',
id: 'os',
},
{
name: 'Pali',
id: 'pi',
},
{
name: 'Pashto, Pushto',
id: 'ps',
},
{
name: 'Persian',
id: 'fa',
},
{
name: 'Polish',
id: 'pl',
},
{
name: 'Portuguese',
id: 'pt',
},
{
name: 'Portuguese (Brazil)',
id: 'pt_BR',
},
{
name: 'Punjabi',
id: 'pa',
},
{
name: 'Quechua',
id: 'qu',
},
{
name: 'Romanian',
id: 'ro',
},
{
name: 'Romansh',
id: 'rm',
},
{
name: 'Rundi',
id: 'rn',
},
{
name: 'Russian',
id: 'ru',
},
{
name: 'Northern Sami',
id: 'se',
},
{
name: 'Samoan',
id: 'sm',
},
{
name: 'Sango',
id: 'sg',
},
{
name: 'Sanskrit',
id: 'sa',
},
{
name: 'Sardinian',
id: 'sc',
},
{
name: 'Serbian',
id: 'sr',
},
{
name: 'Shona',
id: 'sn',
},
{
name: 'Sindhi',
id: 'sd',
},
{
name: 'Sinhala',
id: 'si',
},
{
name: 'Slovak',
id: 'sk',
},
{
name: 'Slovenian',
id: 'sl',
},
{
name: 'Somali',
id: 'so',
},
{
name: 'Southern Sotho',
id: 'st',
},
{
name: 'Spanish',
id: 'es',
},
{
name: 'Sundanese',
id: 'su',
},
{
name: 'Swahili',
id: 'sw',
},
{
name: 'Swati',
id: 'ss',
},
{
name: 'Swedish',
id: 'sv',
},
{
name: 'Tagalog',
id: 'tl',
},
{
name: 'Tahitian',
id: 'ty',
},
{
name: 'Tajik',
id: 'tg',
},
{
name: 'Tamil',
id: 'ta',
},
{
name: 'Tatar',
id: 'tt',
},
{
name: 'Telugu',
id: 'te',
},
{
name: 'Thai',
id: 'th',
},
{
name: 'Tibetan',
id: 'bo',
},
{
name: 'Tigrinya',
id: 'ti',
},
{
name: 'Tonga',
id: 'to',
},
{
name: 'Tsonga',
id: 'ts',
},
{
name: 'Tswana',
id: 'tn',
},
{
name: 'Turkish',
id: 'tr',
},
{
name: 'Turkmen',
id: 'tk',
},
{
name: 'Twi',
id: 'tw',
},
{
name: 'Uighur',
id: 'ug',
},
{
name: 'Ukrainian',
id: 'uk',
},
{
name: 'Urdu',
id: 'ur',
},
{
name: 'Uzbek',
id: 'uz',
},
{
name: 'Venda',
id: 've',
},
{
name: 'Vietnamese',
id: 'vi',
},
{
name: 'Volapük',
id: 'vo',
},
{
name: 'Walloon',
id: 'wa',
},
{
name: 'Welsh',
id: 'cy',
},
{
name: 'Wolof',
id: 'wo',
},
{
name: 'Xhosa',
id: 'xh',
},
{
name: 'Yiddish',
id: 'yi',
},
{
name: 'Yoruba',
id: 'yo',
},
{
name: 'Zhuang, Chuang',
id: 'za',
},
{
name: 'Zulu',
id: 'zu',
},
];
export const getLanguageName = (languageCode = '') => {
const languageObj =
languages.find(language => language.id === languageCode) || {};
return languageObj.name || '';
};
export const getLanguageDirection = (languageCode = '') => {
const rtlLanguageIds = ['ar', 'as', 'fa', 'he', 'ku', 'ur'];
return rtlLanguageIds.includes(languageCode);
};
export default languages;

View File

@@ -0,0 +1,9 @@
import defaultFilters from '../index';
import { filterAttributeGroups } from '../index';
describe('#filterItems', () => {
it('Matches the correct filterItems', () => {
expect(defaultFilters).toMatchObject(defaultFilters);
expect(filterAttributeGroups).toMatchObject(filterAttributeGroups);
});
});

View File

@@ -0,0 +1,17 @@
import { getLanguageName, getLanguageDirection } from '../languages';
describe('#getLanguageName', () => {
it('Returns correct language name', () => {
expect(getLanguageName('es')).toEqual('Spanish');
expect(getLanguageName()).toEqual('');
expect(getLanguageName('rrr')).toEqual('');
expect(getLanguageName('')).toEqual('');
});
});
describe('#getLanguageDirection', () => {
it('Returns correct language direction', () => {
expect(getLanguageDirection('es')).toEqual(false);
expect(getLanguageDirection('ar')).toEqual(true);
});
});

View File

@@ -0,0 +1,358 @@
<script setup>
import { ref, computed, onMounted, useTemplateRef } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import { useStoreGetters } from 'dashboard/composables/store';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { useImageZoom } from 'dashboard/composables/useImageZoom';
import { messageTimestamp } from 'shared/helpers/timeHelper';
import { downloadFile } from '@chatwoot/utils';
import NextButton from 'dashboard/components-next/button/Button.vue';
import Avatar from 'next/avatar/Avatar.vue';
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
const props = defineProps({
attachment: {
type: Object,
required: true,
},
allAttachments: {
type: Array,
required: true,
},
});
const emit = defineEmits(['close']);
const show = defineModel('show', { type: Boolean, default: false });
const { t } = useI18n();
const getters = useStoreGetters();
const ALLOWED_FILE_TYPES = {
IMAGE: 'image',
VIDEO: 'video',
IG_REEL: 'ig_reel',
AUDIO: 'audio',
};
const isDownloading = ref(false);
const activeAttachment = ref({});
const activeFileType = ref('');
const activeImageIndex = ref(
props.allAttachments.findIndex(
attachment => attachment.message_id === props.attachment.message_id
) || 0
);
const imageRef = useTemplateRef('imageRef');
const {
imageWrapperStyle,
imageStyle,
onRotate,
activeImageRotation,
onZoom,
onDoubleClickZoomImage,
onWheelImageZoom,
onMouseMove,
onMouseLeave,
resetZoomAndRotation,
} = useImageZoom(imageRef);
const currentUser = computed(() => getters.getCurrentUser.value);
const hasMoreThanOneAttachment = computed(
() => props.allAttachments.length > 1
);
const readableTime = computed(() => {
const { created_at: createdAt } = activeAttachment.value;
if (!createdAt) return '';
return messageTimestamp(createdAt, 'LLL d yyyy, h:mm a') || '';
});
const isImage = computed(
() => activeFileType.value === ALLOWED_FILE_TYPES.IMAGE
);
const isVideo = computed(() =>
[ALLOWED_FILE_TYPES.VIDEO, ALLOWED_FILE_TYPES.IG_REEL].includes(
activeFileType.value
)
);
const isAudio = computed(
() => activeFileType.value === ALLOWED_FILE_TYPES.AUDIO
);
const senderDetails = computed(() => {
const {
name,
available_name: availableName,
avatar_url,
thumbnail,
id,
} = activeAttachment.value?.sender || props.attachment?.sender || {};
return {
name: currentUser.value?.id === id ? 'You' : name || availableName || '',
avatar: thumbnail || avatar_url || '',
};
});
const fileNameFromDataUrl = computed(() => {
const { data_url: dataUrl } = activeAttachment.value;
if (!dataUrl) return '';
const fileName = dataUrl.split('/').pop();
return fileName ? decodeURIComponent(fileName) : '';
});
const onClose = () => emit('close');
const setImageAndVideoSrc = attachment => {
const { file_type: type } = attachment;
if (!Object.values(ALLOWED_FILE_TYPES).includes(type)) return;
activeAttachment.value = attachment;
activeFileType.value = type;
};
const onClickChangeAttachment = (attachment, index) => {
if (!attachment) return;
activeImageIndex.value = index;
setImageAndVideoSrc(attachment);
resetZoomAndRotation();
};
const onClickDownload = async () => {
const { file_type: type, data_url: url, extension } = activeAttachment.value;
if (!Object.values(ALLOWED_FILE_TYPES).includes(type)) return;
try {
isDownloading.value = true;
await downloadFile({ url, type, extension });
} catch (error) {
useAlert(t('GALLERY_VIEW.ERROR_DOWNLOADING'));
} finally {
isDownloading.value = false;
}
};
const keyboardEvents = {
Escape: { action: onClose },
ArrowLeft: {
action: () => {
onClickChangeAttachment(
props.allAttachments[activeImageIndex.value - 1],
activeImageIndex.value - 1
);
},
},
ArrowRight: {
action: () => {
onClickChangeAttachment(
props.allAttachments[activeImageIndex.value + 1],
activeImageIndex.value + 1
);
},
},
};
useKeyboardEvents(keyboardEvents);
onMounted(() => {
setImageAndVideoSrc(props.attachment);
});
</script>
<template>
<TeleportWithDirection to="body">
<woot-modal
v-model:show="show"
full-width
:show-close-button="false"
:on-close="onClose"
>
<div
class="bg-n-background flex flex-col h-[inherit] w-[inherit] overflow-hidden select-none"
@click="onClose"
>
<header
class="z-10 flex items-center justify-between w-full h-16 px-6 py-2 bg-n-background border-b border-n-weak"
@click.stop
>
<div
v-if="senderDetails"
class="flex items-center min-w-[15rem] shrink-0"
>
<Avatar
v-if="senderDetails.avatar"
:name="senderDetails.name"
:src="senderDetails.avatar"
:size="40"
rounded-full
class="flex-shrink-0"
/>
<div class="flex flex-col ml-2 rtl:ml-0 rtl:mr-2 overflow-hidden">
<h3 class="text-base leading-5 m-0 font-medium">
<span
class="overflow-hidden text-n-slate-12 whitespace-nowrap text-ellipsis"
>
{{ senderDetails.name }}
</span>
</h3>
<span
class="text-xs text-n-slate-11 whitespace-nowrap text-ellipsis"
>
{{ readableTime }}
</span>
</div>
</div>
<div
class="flex-1 mx-2 px-2 truncate text-sm font-medium text-center text-n-slate-12"
>
<span v-dompurify-html="fileNameFromDataUrl" class="truncate" />
</div>
<div class="flex items-center gap-2 ml-2 shrink-0">
<NextButton
v-if="isImage"
icon="i-lucide-zoom-in"
slate
ghost
@click="onZoom(0.1)"
/>
<NextButton
v-if="isImage"
icon="i-lucide-zoom-out"
slate
ghost
@click="onZoom(-0.1)"
/>
<NextButton
v-if="isImage"
icon="i-lucide-rotate-ccw"
slate
ghost
@click="onRotate('counter-clockwise')"
/>
<NextButton
v-if="isImage"
icon="i-lucide-rotate-cw"
slate
ghost
@click="onRotate('clockwise')"
/>
<NextButton
icon="i-lucide-download"
slate
ghost
:is-loading="isDownloading"
:disabled="isDownloading"
@click="onClickDownload"
/>
<NextButton icon="i-lucide-x" slate ghost @click="onClose" />
</div>
</header>
<main class="flex items-stretch flex-1 h-full overflow-hidden">
<div class="flex items-center justify-center w-16 shrink-0">
<NextButton
v-if="hasMoreThanOneAttachment"
icon="ltr:i-lucide-chevron-left rtl:i-lucide-chevron-right"
class="z-10"
blue
faded
lg
:disabled="activeImageIndex === 0"
@click.stop="
onClickChangeAttachment(
allAttachments[activeImageIndex - 1],
activeImageIndex - 1
)
"
/>
</div>
<div class="flex-1 flex items-center justify-center overflow-hidden">
<div
v-if="isImage"
:style="imageWrapperStyle"
class="flex items-center justify-center origin-center"
:class="{
// Adjust dimensions when rotated 90/270 degrees to maintain visibility
// and prevent image from overflowing container in different aspect ratios
'w-[calc(100dvh-8rem)] h-[calc(100dvw-7rem)]':
activeImageRotation % 180 !== 0,
'size-full': activeImageRotation % 180 === 0,
}"
>
<img
ref="imageRef"
:key="activeAttachment.message_id"
:src="activeAttachment.data_url"
:style="imageStyle"
class="max-h-full max-w-full object-contain duration-100 ease-in-out transform select-none"
@click.stop
@dblclick.stop="onDoubleClickZoomImage"
@wheel.prevent.stop="onWheelImageZoom"
@mousemove="onMouseMove"
@mouseleave="onMouseLeave"
/>
</div>
<video
v-if="isVideo"
:key="activeAttachment.message_id"
:src="activeAttachment.data_url"
controls
playsInline
class="max-h-full max-w-full object-contain"
@click.stop
/>
<audio
v-if="isAudio"
:key="activeAttachment.message_id"
controls
class="w-full max-w-md"
@click.stop
>
<source :src="`${activeAttachment.data_url}?t=${Date.now()}`" />
</audio>
</div>
<div class="flex items-center justify-center w-16 shrink-0">
<NextButton
v-if="hasMoreThanOneAttachment"
icon="ltr:i-lucide-chevron-right rtl:i-lucide-chevron-left"
class="z-10"
blue
faded
lg
:disabled="activeImageIndex === allAttachments.length - 1"
@click.stop="
onClickChangeAttachment(
allAttachments[activeImageIndex + 1],
activeImageIndex + 1
)
"
/>
</div>
</main>
<footer
class="z-10 flex items-center justify-center h-12 border-t border-n-weak"
>
<div
class="rounded-md flex items-center justify-center px-3 py-1 bg-n-slate-3 text-n-slate-12 text-sm font-medium"
>
{{ `${activeImageIndex + 1} / ${allAttachments.length}` }}
</div>
</footer>
</div>
</woot-modal>
</TeleportWithDirection>
</template>

View File

@@ -0,0 +1,140 @@
<script setup>
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { evaluateSLAStatus } from '@chatwoot/utils';
import SLAPopoverCard from './SLAPopoverCard.vue';
const props = defineProps({
chat: {
type: Object,
default: () => ({}),
},
showExtendedInfo: {
type: Boolean,
default: false,
},
parentWidth: {
type: Number,
default: 1000,
},
});
const REFRESH_INTERVAL = 60000;
const { t } = useI18n();
const timer = ref(null);
const slaStatus = ref({
threshold: null,
isSlaMissed: false,
type: null,
icon: null,
});
const appliedSLA = computed(() => props.chat?.applied_sla);
const slaEvents = computed(() => props.chat?.sla_events);
const hasSlaThreshold = computed(() => slaStatus.value?.threshold);
const isSlaMissed = computed(() => slaStatus.value?.isSlaMissed);
const slaTextStyles = computed(() =>
isSlaMissed.value ? 'text-n-ruby-11' : 'text-n-amber-11'
);
const slaStatusText = computed(() => {
const upperCaseType = slaStatus.value?.type?.toUpperCase(); // FRT, NRT, or RT
const statusKey = isSlaMissed.value ? 'MISSED' : 'DUE';
return t(`CONVERSATION.HEADER.SLA_STATUS.${upperCaseType}`, {
status: t(`CONVERSATION.HEADER.SLA_STATUS.${statusKey}`),
});
});
const showSlaPopoverCard = computed(
() => props.showExtendedInfo && slaEvents.value?.length > 0
);
const groupClass = computed(() => {
return props.showExtendedInfo
? 'h-[26px] rounded-lg bg-n-alpha-1'
: 'rounded h-5 border border-n-strong';
});
const updateSlaStatus = () => {
slaStatus.value = evaluateSLAStatus({
appliedSla: appliedSLA.value,
chat: props.chat,
});
};
const createTimer = () => {
timer.value = setTimeout(() => {
updateSlaStatus();
createTimer();
}, REFRESH_INTERVAL);
};
watch(
() => props.chat,
() => {
updateSlaStatus();
}
);
const slaPopoverClass = computed(() => {
return props.showExtendedInfo
? 'ltr:pr-1.5 rtl:pl-1.5 ltr:border-r rtl:border-l border-n-strong'
: '';
});
onMounted(() => {
updateSlaStatus();
createTimer();
});
onUnmounted(() => {
if (timer.value) {
clearTimeout(timer.value);
}
});
</script>
<!-- eslint-disable-next-line vue/no-root-v-if -->
<template>
<div
v-if="hasSlaThreshold"
class="relative flex items-center cursor-pointer min-w-fit group"
:class="groupClass"
>
<div
class="flex items-center w-full truncate px-1.5"
:class="showExtendedInfo ? '' : 'gap-1'"
>
<div class="flex items-center gap-1" :class="slaPopoverClass">
<fluent-icon
size="12"
:icon="slaStatus.icon"
type="outline"
:icon-lib="isSlaMissed ? 'lucide' : 'fluent'"
class="flex-shrink-0"
:class="slaTextStyles"
/>
<span
v-if="showExtendedInfo && parentWidth > 650"
class="text-xs font-medium"
:class="slaTextStyles"
>
{{ slaStatusText }}
</span>
</div>
<span
class="text-xs font-medium"
:class="[slaTextStyles, showExtendedInfo && 'ltr:pl-1.5 rtl:pr-1.5']"
>
{{ slaStatus.threshold }}
</span>
</div>
<SLAPopoverCard
v-if="showSlaPopoverCard"
:sla-missed-events="slaEvents"
class="start-0 xl:start-auto xl:end-0 top-7 hidden group-hover:flex"
/>
</div>
</template>

View File

@@ -0,0 +1,36 @@
<script setup>
import { format, fromUnixTime } from 'date-fns';
defineProps({
label: {
type: String,
required: true,
},
items: {
type: Array,
required: true,
},
});
const formatDate = timestamp =>
format(fromUnixTime(timestamp), 'MMM dd, yyyy, hh:mm a');
</script>
<template>
<div class="flex justify-between w-full">
<span
class="text-sm sticky top-0 h-fit font-normal tracking-[-0.6%] min-w-[140px] truncate text-n-slate-11"
>
{{ label }}
</span>
<div class="flex flex-col w-full gap-2">
<span
v-for="item in items"
:key="item.id"
class="text-sm font-normal text-n-slate-12 text-right tabular-nums"
>
{{ formatDate(item.created_at) }}
</span>
<slot name="showMore" />
</div>
</div>
</template>

View File

@@ -0,0 +1,86 @@
<script setup>
import { ref, computed } from 'vue';
import wootConstants from 'dashboard/constants/globals';
import SLAEventItem from './SLAEventItem.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
slaMissedEvents: {
type: Array,
required: true,
},
});
const { SLA_MISS_TYPES } = wootConstants;
const shouldShowAllNrts = ref(false);
const frtMisses = computed(() =>
props.slaMissedEvents.filter(
slaEvent => slaEvent.event_type === SLA_MISS_TYPES.FRT
)
);
const nrtMisses = computed(() => {
const missedEvents = props.slaMissedEvents.filter(
slaEvent => slaEvent.event_type === SLA_MISS_TYPES.NRT
);
return shouldShowAllNrts.value ? missedEvents : missedEvents.slice(0, 6);
});
const rtMisses = computed(() =>
props.slaMissedEvents.filter(
slaEvent => slaEvent.event_type === SLA_MISS_TYPES.RT
)
);
const shouldShowMoreNRTButton = computed(() => nrtMisses.value.length > 6);
const toggleShowAllNRT = () => {
shouldShowAllNrts.value = !shouldShowAllNrts.value;
};
</script>
<template>
<div
class="absolute flex flex-col items-start border-n-strong bg-n-solid-3 w-96 backdrop-blur-[100px] px-6 py-5 z-50 shadow rounded-xl gap-4 max-h-96 overflow-auto"
>
<span class="text-sm font-medium text-n-slate-12">
{{ $t('SLA.EVENTS.TITLE') }}
</span>
<SLAEventItem
v-if="frtMisses.length"
:label="$t('SLA.EVENTS.FRT')"
:items="frtMisses"
/>
<SLAEventItem
v-if="nrtMisses.length"
:label="$t('SLA.EVENTS.NRT')"
:items="nrtMisses"
>
<template #showMore>
<div
v-if="shouldShowMoreNRTButton"
class="flex flex-col items-end w-full"
>
<Button
link
xs
slate
class="hover:!no-underline"
:icon="!shouldShowAllNrts ? 'i-lucide-plus' : ''"
:label="
shouldShowAllNrts
? $t('SLA.EVENTS.HIDE', { count: nrtMisses.length })
: $t('SLA.EVENTS.SHOW_MORE', { count: nrtMisses.length })
"
@click="toggleShowAllNRT"
/>
</div>
</template>
</SLAEventItem>
<SLAEventItem
v-if="rtMisses.length"
:label="$t('SLA.EVENTS.RT')"
:items="rtMisses"
/>
</div>
</template>

View File

@@ -0,0 +1,407 @@
<script>
import { mapGetters } from 'vuex';
import { useAdmin } from 'dashboard/composables/useAdmin';
import { useAlert } from 'dashboard/composables';
import { copyTextToClipboard } from 'shared/helpers/clipboard';
import {
getSortedAgentsByAvailability,
getAgentsByUpdatedPresence,
} from 'dashboard/helper/agentHelper.js';
import MenuItem from './menuItem.vue';
import MenuItemWithSubmenu from './menuItemWithSubmenu.vue';
import wootConstants from 'dashboard/constants/globals';
import AgentLoadingPlaceholder from './agentLoadingPlaceholder.vue';
const MENU = {
MARK_AS_READ: 'mark-as-read',
MARK_AS_UNREAD: 'mark-as-unread',
PRIORITY: 'priority',
STATUS: 'status',
SNOOZE: 'snooze',
AGENT: 'agent',
TEAM: 'team',
LABEL: 'label',
DELETE: 'delete',
OPEN_NEW_TAB: 'open-new-tab',
COPY_LINK: 'copy-link',
};
export default {
components: {
MenuItem,
MenuItemWithSubmenu,
AgentLoadingPlaceholder,
},
props: {
chatId: {
type: Number,
default: null,
},
status: {
type: String,
default: '',
},
hasUnreadMessages: {
type: Boolean,
default: false,
},
inboxId: {
type: Number,
default: null,
},
priority: {
type: String,
default: null,
},
conversationLabels: {
type: Array,
default: () => [],
},
conversationUrl: {
type: String,
default: '',
},
allowedOptions: {
type: Array,
default: () => [],
},
},
emits: [
'updateConversation',
'assignPriority',
'markAsUnread',
'markAsRead',
'assignAgent',
'assignTeam',
'assignLabel',
'removeLabel',
'deleteConversation',
'close',
],
setup() {
const { isAdmin } = useAdmin();
return {
isAdmin,
};
},
data() {
return {
MENU,
STATUS_TYPE: wootConstants.STATUS_TYPE,
readOption: {
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.MARK_AS_READ'),
icon: 'mail',
},
unreadOption: {
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.MARK_AS_UNREAD'),
icon: 'mail-unread',
},
statusMenuConfig: [
{
key: wootConstants.STATUS_TYPE.RESOLVED,
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.RESOLVED'),
icon: 'checkmark',
},
{
key: wootConstants.STATUS_TYPE.OPEN,
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.REOPEN'),
icon: 'arrow-redo',
},
{
key: wootConstants.STATUS_TYPE.PENDING,
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.PENDING'),
icon: 'book-clock',
},
],
snoozeOption: {
key: wootConstants.STATUS_TYPE.SNOOZED,
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.SNOOZE.TITLE'),
icon: 'snooze',
},
priorityConfig: {
key: MENU.PRIORITY,
label: this.$t('CONVERSATION.PRIORITY.TITLE'),
icon: 'warning',
options: [
{
label: this.$t('CONVERSATION.PRIORITY.OPTIONS.NONE'),
key: null,
},
{
label: this.$t('CONVERSATION.PRIORITY.OPTIONS.URGENT'),
key: 'urgent',
},
{
label: this.$t('CONVERSATION.PRIORITY.OPTIONS.HIGH'),
key: 'high',
},
{
label: this.$t('CONVERSATION.PRIORITY.OPTIONS.MEDIUM'),
key: 'medium',
},
{
label: this.$t('CONVERSATION.PRIORITY.OPTIONS.LOW'),
key: 'low',
},
].filter(item => item.key !== this.priority),
},
labelMenuConfig: {
key: MENU.LABEL,
icon: 'tag',
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.ASSIGN_LABEL'),
},
agentMenuConfig: {
key: MENU.AGENT,
icon: 'person-add',
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.ASSIGN_AGENT'),
},
teamMenuConfig: {
key: MENU.TEAM,
icon: 'people-team-add',
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.ASSIGN_TEAM'),
},
deleteOption: {
key: MENU.DELETE,
icon: 'delete',
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.DELETE'),
},
openInNewTabOption: {
key: MENU.OPEN_NEW_TAB,
icon: 'open',
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.OPEN_IN_NEW_TAB'),
},
copyLinkOption: {
key: MENU.COPY_LINK,
icon: 'copy',
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.COPY_LINK'),
},
};
},
computed: {
...mapGetters({
labels: 'labels/getLabels',
teams: 'teams/getTeams',
assignableAgentsUiFlags: 'inboxAssignableAgents/getUIFlags',
currentUser: 'getCurrentUser',
currentAccountId: 'getCurrentAccountId',
}),
filteredAgentOnAvailability() {
const agents = this.$store.getters[
'inboxAssignableAgents/getAssignableAgents'
](this.inboxId);
const agentsByUpdatedPresence = getAgentsByUpdatedPresence(
agents,
this.currentUser,
this.currentAccountId
);
const filteredAgents = getSortedAgentsByAvailability(
agentsByUpdatedPresence
);
return filteredAgents;
},
assignableAgents() {
return [
{
confirmed: true,
name: 'None',
id: null,
role: 'agent',
account_id: 0,
email: 'None',
},
...this.filteredAgentOnAvailability,
];
},
showSnooze() {
// Don't show snooze if the conversation is already snoozed/resolved/pending
return this.status === wootConstants.STATUS_TYPE.OPEN;
},
},
mounted() {
this.$store.dispatch('inboxAssignableAgents/fetch', [this.inboxId]);
},
methods: {
isAllowed(keys) {
if (!this.allowedOptions.length) return true;
return keys.some(key => this.allowedOptions.includes(key));
},
toggleStatus(status, snoozedUntil) {
this.$emit('updateConversation', status, snoozedUntil);
},
async snoozeConversation() {
await this.$store.dispatch('setContextMenuChatId', this.chatId);
const ninja = document.querySelector('ninja-keys');
ninja.open({ parent: 'snooze_conversation' });
},
assignPriority(priority) {
this.$emit('assignPriority', priority);
},
deleteConversation() {
this.$emit('deleteConversation', this.chatId);
},
openInNewTab() {
if (!this.conversationUrl) return;
const url = `${window.chatwootConfig.hostURL}${this.conversationUrl}`;
window.open(url, '_blank', 'noopener,noreferrer');
this.$emit('close');
},
async copyConversationLink() {
if (!this.conversationUrl) return;
try {
const url = `${window.chatwootConfig.hostURL}${this.conversationUrl}`;
await copyTextToClipboard(url);
useAlert(this.$t('CONVERSATION.CARD_CONTEXT_MENU.COPY_LINK_SUCCESS'));
this.$emit('close');
} catch (error) {
// error
}
},
show(key) {
// If the conversation status is same as the action, then don't display the option
// i.e.: Don't show an option to resolve if the conversation is already resolved.
return this.status !== key;
},
generateMenuLabelConfig(option, type = 'text') {
return {
key: option.id,
...(type === 'icon' && { icon: option.icon }),
...(type === 'label' && { color: option.color }),
...(type === 'agent' && { thumbnail: option.thumbnail }),
...(type === 'agent' && { status: option.availability_status }),
...(type === 'text' && { label: option.label }),
...(type === 'label' && { label: option.title }),
...(type === 'agent' && { label: option.name }),
...(type === 'team' && { label: option.name }),
};
},
},
};
</script>
<template>
<div
class="p-1 rounded-md shadow-xl bg-n-alpha-3/50 backdrop-blur-[100px] outline-1 outline outline-n-weak/50"
>
<template v-if="isAllowed([MENU.MARK_AS_READ, MENU.MARK_AS_UNREAD])">
<MenuItem
v-if="!hasUnreadMessages"
:option="unreadOption"
variant="icon"
@click.stop="$emit('markAsUnread')"
/>
<MenuItem
v-else
:option="readOption"
variant="icon"
@click.stop="$emit('markAsRead')"
/>
<hr class="m-1 rounded border-b border-n-weak dark:border-n-weak" />
</template>
<template v-if="isAllowed([MENU.STATUS, MENU.SNOOZE])">
<template v-for="option in statusMenuConfig">
<MenuItem
v-if="show(option.key) && isAllowed([MENU.STATUS])"
:key="option.key"
:option="option"
variant="icon"
@click.stop="toggleStatus(option.key, null)"
/>
</template>
<MenuItem
v-if="showSnooze && isAllowed([MENU.SNOOZE])"
:option="snoozeOption"
variant="icon"
@click.stop="snoozeConversation()"
/>
<hr class="m-1 rounded border-b border-n-weak dark:border-n-weak" />
</template>
<template
v-if="isAllowed([MENU.PRIORITY, MENU.LABEL, MENU.AGENT, MENU.TEAM])"
>
<MenuItemWithSubmenu
v-if="isAllowed([MENU.PRIORITY])"
:option="priorityConfig"
>
<MenuItem
v-for="(option, i) in priorityConfig.options"
:key="i"
:option="option"
@click.stop="assignPriority(option.key)"
/>
</MenuItemWithSubmenu>
<MenuItemWithSubmenu
v-if="isAllowed([MENU.LABEL])"
:option="labelMenuConfig"
:sub-menu-available="!!labels.length"
>
<MenuItem
v-for="label in labels"
:key="label.id"
:option="generateMenuLabelConfig(label, 'label')"
:variant="
conversationLabels.includes(label.title)
? 'label-assigned'
: 'label'
"
@click.stop="
conversationLabels.includes(label.title)
? $emit('removeLabel', label)
: $emit('assignLabel', label)
"
/>
</MenuItemWithSubmenu>
<MenuItemWithSubmenu
v-if="isAllowed([MENU.AGENT])"
:option="agentMenuConfig"
:sub-menu-available="!!assignableAgents.length"
>
<AgentLoadingPlaceholder v-if="assignableAgentsUiFlags.isFetching" />
<template v-else>
<MenuItem
v-for="agent in assignableAgents"
:key="agent.id"
:option="generateMenuLabelConfig(agent, 'agent')"
variant="agent"
@click.stop="$emit('assignAgent', agent)"
/>
</template>
</MenuItemWithSubmenu>
<MenuItemWithSubmenu
v-if="isAllowed([MENU.TEAM])"
:option="teamMenuConfig"
:sub-menu-available="!!teams.length"
>
<MenuItem
v-for="team in teams"
:key="team.id"
:option="generateMenuLabelConfig(team, 'team')"
@click.stop="$emit('assignTeam', team)"
/>
</MenuItemWithSubmenu>
<hr class="m-1 rounded border-b border-n-weak dark:border-n-weak" />
</template>
<template v-if="isAllowed([MENU.OPEN_NEW_TAB, MENU.COPY_LINK])">
<MenuItem
v-if="isAllowed([MENU.OPEN_NEW_TAB])"
:option="openInNewTabOption"
variant="icon"
@click.stop="openInNewTab"
/>
<MenuItem
v-if="isAllowed([MENU.COPY_LINK])"
:option="copyLinkOption"
variant="icon"
@click.stop="copyConversationLink"
/>
</template>
<template v-if="isAdmin && isAllowed([MENU.DELETE])">
<hr class="m-1 rounded border-b border-n-weak dark:border-n-weak" />
<MenuItem
:option="deleteOption"
variant="icon"
@click.stop="deleteConversation"
/>
</template>
</div>
</template>

View File

@@ -0,0 +1,31 @@
<script>
import Spinner from 'shared/components/Spinner.vue';
export default {
components: {
Spinner,
},
};
</script>
<template>
<div class="agent-placeholder">
<Spinner />
<p>{{ $t('CONVERSATION.CARD_CONTEXT_MENU.AGENTS_LOADING') }}</p>
</div>
</template>
<style scoped lang="scss">
.agent-placeholder {
display: flex;
align-items: center;
flex-direction: column;
justify-content: center;
padding: 1rem 0;
min-width: calc(6.25rem * 2);
p {
margin: 0.5rem 0 0 0;
}
}
</style>

View File

@@ -0,0 +1,72 @@
<script setup>
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
defineProps({
option: {
type: Object,
default: () => {},
},
variant: {
type: String,
default: 'default',
},
});
</script>
<template>
<div class="menu text-n-slate-12 min-h-7 min-w-0" role="button">
<fluent-icon
v-if="variant === 'icon' && option.icon"
:icon="option.icon"
size="14"
class="flex-shrink-0"
/>
<span
v-if="
(variant === 'label' || variant === 'label-assigned') && option.color
"
class="label-pill flex-shrink-0"
:style="{ backgroundColor: option.color }"
/>
<Avatar
v-if="variant === 'agent'"
:name="option.label"
:src="option.thumbnail"
:status="option.status === 'online' ? option.status : null"
:size="20"
class="flex-shrink-0"
/>
<p class="menu-label truncate min-w-0 flex-1">
{{ option.label }}
</p>
<Icon
v-if="variant === 'label-assigned'"
icon="i-lucide-check"
class="flex-shrink-0 size-3.5 mr-1"
/>
</div>
</template>
<style scoped lang="scss">
.menu {
width: calc(6.25rem * 2);
@apply flex items-center flex-nowrap p-1 rounded-md overflow-hidden cursor-pointer;
.menu-label {
@apply my-0 mx-2 text-xs flex-shrink-0;
}
&:hover {
@apply bg-n-brand text-white;
}
}
.agent-thumbnail {
margin-top: 0 !important;
}
.label-pill {
@apply w-4 h-4 rounded-full border border-n-strong border-solid flex-shrink-0;
}
</style>

View File

@@ -0,0 +1,71 @@
<script setup>
import { computed, useTemplateRef } from 'vue';
import { useWindowSize, useElementBounding } from '@vueuse/core';
defineProps({
option: {
type: Object,
default: () => {},
},
subMenuAvailable: {
type: Boolean,
default: true,
},
});
const menuRef = useTemplateRef('menuRef');
const { width: windowWidth, height: windowHeight } = useWindowSize();
const { bottom, right } = useElementBounding(menuRef);
// Vertical position
const verticalPosition = computed(() => {
const SUBMENU_HEIGHT = 240; // 15rem in pixels
const spaceBelow = windowHeight.value - bottom.value;
return spaceBelow < SUBMENU_HEIGHT ? 'bottom-0' : 'top-0';
});
// Horizontal position
const horizontalPosition = computed(() => {
const SUBMENU_WIDTH = 240;
const spaceRight = windowWidth.value - right.value;
return spaceRight < SUBMENU_WIDTH ? 'right-full' : 'left-full';
});
const submenuPosition = computed(() => [
verticalPosition.value,
horizontalPosition.value,
]);
</script>
<template>
<div
ref="menuRef"
class="text-n-slate-12 menu-with-submenu min-width-calc w-full p-1 flex items-center h-7 rounded-md relative bg-n-alpha-3/50 backdrop-blur-[100px] justify-between hover:bg-n-brand/10 cursor-pointer dark:hover:bg-n-solid-3"
:class="!subMenuAvailable ? 'opacity-50 cursor-not-allowed' : ''"
>
<div class="flex items-center h-4">
<fluent-icon :icon="option.icon" size="14" class="menu-icon" />
<p class="my-0 mx-2 text-xs">{{ option.label }}</p>
</div>
<fluent-icon icon="chevron-right" size="12" />
<div
v-if="subMenuAvailable"
class="submenu bg-n-alpha-3 backdrop-blur-[100px] p-1 shadow-lg rounded-md absolute hidden max-h-[15rem] overflow-y-auto overflow-x-hidden cursor-pointer"
:class="submenuPosition"
>
<slot />
</div>
</div>
</template>
<style scoped lang="scss">
.menu-with-submenu {
min-width: calc(6.25rem * 2);
&:hover {
.submenu {
@apply block;
}
}
}
</style>

View File

@@ -0,0 +1,277 @@
<script>
// components
import NextButton from 'dashboard/components-next/button/Button.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import { useBranding } from 'shared/composables/useBranding';
// composables
import { useCaptain } from 'dashboard/composables/useCaptain';
import { useTrack } from 'dashboard/composables';
// store & api
import { mapGetters } from 'vuex';
// utils & constants
import { LocalStorage } from 'shared/helpers/localStorage';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import { CAPTAIN_EVENTS } from '../../../../helper/AnalyticsHelper/events';
export default {
name: 'LabelSuggestion',
components: {
Avatar,
NextButton,
},
props: {
suggestedLabels: {
type: Array,
required: true,
},
chatLabels: {
type: Array,
required: false,
default: () => [],
},
},
setup() {
const { captainTasksEnabled } = useCaptain();
const { replaceInstallationName } = useBranding();
return { captainTasksEnabled, replaceInstallationName };
},
data() {
return {
isDismissed: false,
isHovered: false,
selectedLabels: [],
};
},
computed: {
...mapGetters({
allLabels: 'labels/getLabels',
currentAccountId: 'getCurrentAccountId',
currentChat: 'getSelectedChat',
}),
conversationId() {
return this.currentChat?.id;
},
labelTooltip() {
if (this.preparedLabels.length > 1) {
return this.$t('LABEL_MGMT.SUGGESTIONS.TOOLTIP.MULTIPLE_SUGGESTION');
}
return this.$t('LABEL_MGMT.SUGGESTIONS.TOOLTIP.SINGLE_SUGGESTION');
},
addButtonText() {
if (this.selectedLabels.length === 1) {
return this.$t('LABEL_MGMT.SUGGESTIONS.ADD_SELECTED_LABEL');
}
if (this.selectedLabels.length > 1) {
return this.$t('LABEL_MGMT.SUGGESTIONS.ADD_SELECTED_LABELS');
}
return this.$t('LABEL_MGMT.SUGGESTIONS.ADD_ALL_LABELS');
},
preparedLabels() {
return this.allLabels.filter(label =>
this.suggestedLabels.includes(label.title)
);
},
shouldShowSuggestions() {
if (this.isDismissed) return false;
if (!this.captainTasksEnabled) return false;
return this.preparedLabels.length && this.chatLabels.length === 0;
},
},
watch: {
conversationId: {
immediate: true,
handler() {
this.selectedLabels = [];
this.isDismissed = this.isConversationDismissed();
},
},
},
methods: {
pushOrAddLabel(label) {
if (this.preparedLabels.length === 1) {
this.addAllLabels();
return;
}
if (!this.selectedLabels.includes(label)) {
this.selectedLabels.push(label);
} else {
this.selectedLabels = this.selectedLabels.filter(l => l !== label);
}
},
dismissSuggestions() {
LocalStorage.setFlag(
LOCAL_STORAGE_KEYS.DISMISSED_LABEL_SUGGESTIONS,
this.currentAccountId,
this.conversationId
);
// dismiss this once the values are set
this.isDismissed = true;
this.trackLabelEvent(CAPTAIN_EVENTS.LABEL_SUGGESTION_DISMISSED);
},
isConversationDismissed() {
return LocalStorage.getFlag(
LOCAL_STORAGE_KEYS.DISMISSED_LABEL_SUGGESTIONS,
this.currentAccountId,
this.conversationId
);
},
addAllLabels() {
let labelsToAdd = this.selectedLabels;
if (!labelsToAdd.length) {
labelsToAdd = this.preparedLabels.map(label => label.title);
}
this.$store.dispatch('conversationLabels/update', {
conversationId: this.conversationId,
labels: labelsToAdd,
});
this.trackLabelEvent(CAPTAIN_EVENTS.LABEL_SUGGESTION_APPLIED);
},
trackLabelEvent(event) {
const payload = {
conversationId: this.conversationId,
account: this.currentAccountId,
suggestions: this.suggestedLabels,
labelsApplied: this.selectedLabels.length
? this.selectedLabels
: this.suggestedLabels,
};
useTrack(event, payload);
},
},
};
</script>
<!-- eslint-disable-next-line vue/no-root-v-if -->
<template>
<li
v-if="shouldShowSuggestions"
class="label-suggestion right list-none"
@mouseover="isHovered = true"
@mouseleave="isHovered = false"
>
<div class="wrap">
<div class="label-suggestion--container">
<h6 class="label-suggestion--title">
{{ $t('LABEL_MGMT.SUGGESTIONS.SUGGESTED_LABELS') }}
</h6>
<div class="label-suggestion--options">
<button
v-for="label in preparedLabels"
:key="label.title"
v-tooltip.top="{
content: selectedLabels.includes(label.title)
? $t('LABEL_MGMT.SUGGESTIONS.TOOLTIP.DESELECT')
: labelTooltip,
delay: { show: 600, hide: 0 },
hideOnClick: true,
}"
class="label-suggestion--option !px-0"
@click="pushOrAddLabel(label.title)"
>
<woot-label
variant="dashed"
v-bind="label"
:bg-color="selectedLabels.includes(label.title) ? '#2781F6' : ''"
/>
</button>
<NextButton
v-if="preparedLabels.length === 1"
v-tooltip.top="{
content: $t('LABEL_MGMT.SUGGESTIONS.TOOLTIP.DISMISS'),
delay: { show: 600, hide: 0 },
hideOnClick: true,
}"
faded
xs
icon="i-lucide-x"
class="flex-shrink-0"
:color="isHovered ? 'ruby' : 'blue'"
@click="dismissSuggestions"
/>
</div>
<div
v-if="preparedLabels.length > 1"
class="inline-flex items-center gap-1"
>
<NextButton
xs
icon="i-lucide-plus"
class="flex-shrink-0"
:variant="selectedLabels.length === 0 ? 'faded' : 'solid'"
:label="addButtonText"
@click="addAllLabels"
/>
<NextButton
v-tooltip.top="{
content: $t('LABEL_MGMT.SUGGESTIONS.TOOLTIP.DISMISS'),
delay: { show: 600, hide: 0 },
hideOnClick: true,
}"
faded
xs
icon="i-lucide-x"
class="flex-shrink-0"
:color="isHovered ? 'ruby' : 'blue'"
@click="dismissSuggestions"
/>
</div>
</div>
<div class="sender--info has-tooltip" data-original-title="null">
<Avatar
v-tooltip.top="{
content: replaceInstallationName(
$t('LABEL_MGMT.SUGGESTIONS.POWERED_BY')
),
delay: { show: 600, hide: 0 },
hideOnClick: true,
}"
:size="16"
name="chatwoot-ai"
icon-name="i-lucide-sparkles"
/>
</div>
</div>
</li>
</template>
<style scoped lang="scss">
.wrap {
display: flex;
}
.label-suggestion {
flex-direction: row;
justify-content: flex-end;
margin-top: 1rem;
.label-suggestion--container {
max-width: 300px;
}
.label-suggestion--options {
@apply gap-0.5 text-end flex items-center;
button.label-suggestion--option {
.label {
cursor: pointer;
margin-bottom: 0;
}
}
}
.label-suggestion--title {
@apply text-n-slate-11 mt-0.5 text-xxs;
}
}
</style>

View File

@@ -0,0 +1,254 @@
<script>
import { mapGetters } from 'vuex';
import Avatar from 'next/avatar/Avatar.vue';
import Spinner from 'shared/components/Spinner.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: {
Avatar,
Spinner,
NextButton,
},
props: {
selectedInboxes: {
type: Array,
default: () => [],
},
conversationCount: {
type: Number,
default: 0,
},
},
emits: ['select', 'close'],
data() {
return {
query: '',
selectedAgent: null,
goBackToAgentList: false,
};
},
computed: {
...mapGetters({
uiFlags: 'bulkActions/getUIFlags',
assignableAgentsUiFlags: 'inboxAssignableAgents/getUIFlags',
}),
filteredAgents() {
if (this.query) {
return this.assignableAgents.filter(agent =>
agent.name.toLowerCase().includes(this.query.toLowerCase())
);
}
return [
{
confirmed: true,
name: 'None',
id: null,
role: 'agent',
account_id: 0,
email: 'None',
},
...this.assignableAgents,
];
},
assignableAgents() {
return this.$store.getters['inboxAssignableAgents/getAssignableAgents'](
this.selectedInboxes.join(',')
);
},
conversationLabel() {
return this.conversationCount > 1 ? 'conversations' : 'conversation';
},
},
mounted() {
this.$store.dispatch('inboxAssignableAgents/fetch', this.selectedInboxes);
},
methods: {
submit() {
this.$emit('select', this.selectedAgent);
},
goBack() {
this.goBackToAgentList = true;
this.selectedAgent = null;
},
assignAgent(agent) {
this.selectedAgent = agent;
},
onClose() {
this.$emit('close');
},
onCloseAgentList() {
if (this.selectedAgent === null && !this.goBackToAgentList) {
this.onClose();
}
this.goBackToAgentList = false;
},
},
};
</script>
<template>
<div v-on-clickaway="onCloseAgentList" class="bulk-action__agents">
<div class="triangle">
<svg height="12" viewBox="0 0 24 12" width="24">
<path d="M20 12l-8-8-12 12" fill-rule="evenodd" stroke-width="1px" />
</svg>
</div>
<div class="flex items-center justify-between header">
<span>{{ $t('BULK_ACTION.AGENT_SELECT_LABEL') }}</span>
<NextButton ghost xs slate icon="i-lucide-x" @click="onClose" />
</div>
<div class="container">
<div
v-if="assignableAgentsUiFlags.isFetching"
class="agent__list-loading"
>
<Spinner />
<p>{{ $t('BULK_ACTION.AGENT_LIST_LOADING') }}</p>
</div>
<div v-else class="agent__list-container">
<ul v-if="!selectedAgent">
<li class="search-container">
<div
class="flex items-center justify-between h-8 gap-2 agent-list-search"
>
<fluent-icon icon="search" class="search-icon" size="16" />
<input
v-model="query"
type="search"
:placeholder="$t('BULK_ACTION.SEARCH_INPUT_PLACEHOLDER')"
class="reset-base !outline-0 !text-sm agent--search_input"
/>
</div>
</li>
<li v-for="agent in filteredAgents" :key="agent.id">
<div class="agent-list-item" @click="assignAgent(agent)">
<Avatar
:name="agent.name"
:src="agent.thumbnail"
:status="agent.availability_status"
:size="22"
hide-offline-status
rounded-full
/>
<span class="my-0 text-n-slate-12">
{{ agent.name }}
</span>
</div>
</li>
</ul>
<div v-else class="agent-confirmation-container">
<p v-if="selectedAgent.id">
{{
$t('BULK_ACTION.ASSIGN_CONFIRMATION_LABEL', {
conversationCount,
conversationLabel,
})
}}
<strong>
{{ selectedAgent.name }}
</strong>
<span>?</span>
</p>
<p v-else>
{{
$t('BULK_ACTION.UNASSIGN_CONFIRMATION_LABEL', {
conversationCount,
conversationLabel,
})
}}
</p>
<div class="agent-confirmation-actions">
<NextButton
faded
sm
slate
type="reset"
:label="$t('BULK_ACTION.GO_BACK_LABEL')"
@click="goBack"
/>
<NextButton
sm
type="submit"
:label="$t('BULK_ACTION.YES')"
:is-loading="uiFlags.isUpdating"
@click="submit"
/>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.bulk-action__agents {
@apply max-w-[75%] absolute ltr:right-2 rtl:left-2 top-12 origin-top-right w-auto z-20 min-w-[15rem] bg-n-alpha-3 backdrop-blur-[100px] border-n-weak rounded-lg border border-solid shadow-md;
.header {
@apply p-2.5;
span {
@apply text-sm font-medium;
}
}
.container {
@apply overflow-y-auto max-h-[15rem];
.agent__list-container {
@apply h-full;
}
.agent-list-search {
@apply py-0 px-2.5 bg-n-alpha-black2 border border-solid border-n-strong rounded-md;
.search-icon {
@apply text-n-slate-10;
}
.agent--search_input {
@apply border-0 text-xs m-0 dark:bg-transparent bg-transparent h-[unset] w-full;
}
}
}
.triangle {
@apply block z-10 absolute -top-3 text-left ltr:right-[--triangle-position] rtl:left-[--triangle-position];
svg path {
@apply fill-n-alpha-3 backdrop-blur-[100px] stroke-n-weak;
}
}
}
ul {
@apply m-0 list-none;
li {
&:last-child {
.agent-list-item {
@apply last:rounded-b-lg;
}
}
}
}
.agent-list-item {
@apply flex items-center p-2.5 gap-2 cursor-pointer hover:bg-n-slate-3 dark:hover:bg-n-solid-3;
span {
@apply text-sm;
}
}
.agent-confirmation-container {
@apply flex flex-col h-full p-2.5;
p {
@apply flex-grow;
}
.agent-confirmation-actions {
@apply w-full grid grid-cols-2 gap-2.5;
}
}
.search-container {
@apply py-0 px-2.5 sticky top-0 z-20 bg-n-alpha-3 backdrop-blur-[100px];
}
.agent__list-loading {
@apply m-2.5 rounded-md dark:bg-n-solid-3 bg-n-slate-2 flex items-center justify-center flex-col p-5 h-[calc(95%-6.25rem)];
}
</style>

View File

@@ -0,0 +1,322 @@
<script>
import { getUnixTime } from 'date-fns';
import { findSnoozeTime } from 'dashboard/helper/snoozeHelpers';
import { emitter } from 'shared/helpers/mitt';
import wootConstants from 'dashboard/constants/globals';
import {
CMD_BULK_ACTION_SNOOZE_CONVERSATION,
CMD_BULK_ACTION_REOPEN_CONVERSATION,
CMD_BULK_ACTION_RESOLVE_CONVERSATION,
} from 'dashboard/helper/commandbar/events';
import NextButton from 'dashboard/components-next/button/Button.vue';
import AgentSelector from './AgentSelector.vue';
import UpdateActions from './UpdateActions.vue';
import LabelActions from './LabelActions.vue';
import TeamActions from './TeamActions.vue';
import CustomSnoozeModal from 'dashboard/components/CustomSnoozeModal.vue';
export default {
components: {
AgentSelector,
UpdateActions,
LabelActions,
TeamActions,
CustomSnoozeModal,
NextButton,
},
props: {
conversations: {
type: Array,
default: () => [],
},
allConversationsSelected: {
type: Boolean,
default: false,
},
selectedInboxes: {
type: Array,
default: () => [],
},
showOpenAction: {
type: Boolean,
default: false,
},
showResolvedAction: {
type: Boolean,
default: false,
},
showSnoozedAction: {
type: Boolean,
default: false,
},
},
emits: [
'selectAllConversations',
'assignAgent',
'updateConversations',
'assignLabels',
'assignTeam',
'resolveConversations',
],
data() {
return {
showAgentsList: false,
showUpdateActions: false,
showLabelActions: false,
showTeamsList: false,
popoverPositions: {},
showCustomTimeSnoozeModal: false,
};
},
mounted() {
emitter.on(
CMD_BULK_ACTION_SNOOZE_CONVERSATION,
this.onCmdSnoozeConversation
);
emitter.on(
CMD_BULK_ACTION_REOPEN_CONVERSATION,
this.onCmdReopenConversation
);
emitter.on(
CMD_BULK_ACTION_RESOLVE_CONVERSATION,
this.onCmdResolveConversation
);
},
unmounted() {
emitter.off(
CMD_BULK_ACTION_SNOOZE_CONVERSATION,
this.onCmdSnoozeConversation
);
emitter.off(
CMD_BULK_ACTION_REOPEN_CONVERSATION,
this.onCmdReopenConversation
);
emitter.off(
CMD_BULK_ACTION_RESOLVE_CONVERSATION,
this.onCmdResolveConversation
);
},
methods: {
onCmdSnoozeConversation(snoozeType) {
if (snoozeType === wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME) {
this.showCustomTimeSnoozeModal = true;
} else {
this.updateConversations('snoozed', findSnoozeTime(snoozeType) || null);
}
},
onCmdReopenConversation() {
this.updateConversations('open', null);
},
onCmdResolveConversation() {
this.updateConversations('resolved', null);
},
customSnoozeTime(customSnoozedTime) {
this.showCustomTimeSnoozeModal = false;
if (customSnoozedTime) {
this.updateConversations('snoozed', getUnixTime(customSnoozedTime));
}
},
hideCustomSnoozeModal() {
this.showCustomTimeSnoozeModal = false;
},
selectAll(e) {
this.$emit('selectAllConversations', e.target.checked);
},
submit(agent) {
this.$emit('assignAgent', agent);
},
updateConversations(status, snoozedUntil) {
this.$emit('updateConversations', status, snoozedUntil);
},
assignLabels(labels) {
this.$emit('assignLabels', labels);
},
assignTeam(team) {
this.$emit('assignTeam', team);
},
resolveConversations() {
this.$emit('resolveConversations');
},
toggleUpdateActions() {
this.showUpdateActions = !this.showUpdateActions;
},
toggleLabelActions() {
this.showLabelActions = !this.showLabelActions;
},
toggleAgentList() {
this.showAgentsList = !this.showAgentsList;
},
toggleTeamsList() {
this.showTeamsList = !this.showTeamsList;
},
},
};
</script>
<template>
<div class="bulk-action__container">
<div class="flex items-center justify-between">
<label class="flex items-center justify-between bulk-action__panel">
<input
type="checkbox"
class="checkbox"
:checked="allConversationsSelected"
:indeterminate.prop="!allConversationsSelected"
@change="selectAll($event)"
/>
<span>
{{
$t('BULK_ACTION.CONVERSATIONS_SELECTED', {
conversationCount: conversations.length,
})
}}
</span>
</label>
<div class="flex items-center gap-1 bulk-action__actions">
<NextButton
v-tooltip="$t('BULK_ACTION.LABELS.ASSIGN_LABELS')"
icon="i-lucide-tags"
slate
xs
faded
@click="toggleLabelActions"
/>
<NextButton
v-tooltip="$t('BULK_ACTION.UPDATE.CHANGE_STATUS')"
icon="i-lucide-repeat"
slate
xs
faded
@click="toggleUpdateActions"
/>
<NextButton
v-tooltip="$t('BULK_ACTION.ASSIGN_AGENT_TOOLTIP')"
icon="i-lucide-user-round-plus"
slate
xs
faded
@click="toggleAgentList"
/>
<NextButton
v-tooltip="$t('BULK_ACTION.ASSIGN_TEAM_TOOLTIP')"
icon="i-lucide-users-round"
slate
xs
faded
@click="toggleTeamsList"
/>
</div>
<transition name="popover-animation">
<LabelActions
v-if="showLabelActions"
class="label-actions-box"
@assign="assignLabels"
@close="showLabelActions = false"
/>
</transition>
<transition name="popover-animation">
<UpdateActions
v-if="showUpdateActions"
class="update-actions-box"
:selected-inboxes="selectedInboxes"
:conversation-count="conversations.length"
:show-resolve="!showResolvedAction"
:show-reopen="!showOpenAction"
:show-snooze="!showSnoozedAction"
@update="updateConversations"
@close="showUpdateActions = false"
/>
</transition>
<transition name="popover-animation">
<AgentSelector
v-if="showAgentsList"
class="agent-actions-box"
:selected-inboxes="selectedInboxes"
:conversation-count="conversations.length"
@select="submit"
@close="showAgentsList = false"
/>
</transition>
<transition name="popover-animation">
<TeamActions
v-if="showTeamsList"
class="team-actions-box"
@assign-team="assignTeam"
@close="showTeamsList = false"
/>
</transition>
</div>
<div v-if="allConversationsSelected" class="bulk-action__alert">
{{ $t('BULK_ACTION.ALL_CONVERSATIONS_SELECTED_ALERT') }}
</div>
<woot-modal
v-model:show="showCustomTimeSnoozeModal"
:on-close="hideCustomSnoozeModal"
>
<CustomSnoozeModal
@close="hideCustomSnoozeModal"
@choose-time="customSnoozeTime"
/>
</woot-modal>
</div>
</template>
<style scoped lang="scss">
.bulk-action__container {
@apply p-3 relative border-b border-solid border-n-strong dark:border-n-weak;
}
.bulk-action__panel {
@apply cursor-pointer;
span {
@apply text-xs my-0 mx-1;
}
input[type='checkbox'] {
@apply cursor-pointer m-0;
}
}
.bulk-action__alert {
@apply bg-n-amber-3 text-n-amber-12 rounded text-xs mt-2 py-1 px-2 border border-solid border-n-amber-5;
}
.popover-animation-enter-active,
.popover-animation-leave-active {
transition: transform ease-out 0.1s;
}
.popover-animation-enter {
transform: scale(0.95);
@apply opacity-0;
}
.popover-animation-enter-to {
transform: scale(1);
@apply opacity-100;
}
.popover-animation-leave {
transform: scale(1);
@apply opacity-100;
}
.popover-animation-leave-to {
transform: scale(0.95);
@apply opacity-0;
}
.label-actions-box {
--triangle-position: 5.3125rem;
}
.update-actions-box {
--triangle-position: 3.5rem;
}
.agent-actions-box {
--triangle-position: 1.75rem;
}
.team-actions-box {
--triangle-position: 0.125rem;
}
</style>

View File

@@ -0,0 +1,141 @@
<script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store';
import { vOnClickOutside } from '@vueuse/components';
import NextButton from 'dashboard/components-next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
const emit = defineEmits(['close', 'assign']);
const { t } = useI18n();
const labels = useMapGetter('labels/getLabels');
const query = ref('');
const selectedLabels = ref([]);
const filteredLabels = computed(() => {
if (!query.value) return labels.value;
return labels.value.filter(label =>
label.title.toLowerCase().includes(query.value.toLowerCase())
);
});
const hasLabels = computed(() => labels.value.length > 0);
const hasFilteredLabels = computed(() => filteredLabels.value.length > 0);
const isLabelSelected = label => {
return selectedLabels.value.includes(label);
};
const onClose = () => {
emit('close');
};
const handleAssign = () => {
if (selectedLabels.value.length > 0) {
emit('assign', selectedLabels.value);
}
};
</script>
<template>
<div
v-on-click-outside="onClose"
class="absolute ltr:right-2 rtl:left-2 top-12 origin-top-right z-20 w-60 bg-n-alpha-3 backdrop-blur-[100px] border-n-weak rounded-lg border border-solid shadow-md"
role="dialog"
aria-labelledby="label-dialog-title"
>
<div class="triangle">
<svg height="12" viewBox="0 0 24 12" width="24">
<path d="M20 12l-8-8-12 12" fill-rule="evenodd" stroke-width="1px" />
</svg>
</div>
<div class="flex items-center justify-between p-2.5">
<span class="text-sm font-medium">{{
t('BULK_ACTION.LABELS.ASSIGN_LABELS')
}}</span>
<NextButton ghost xs slate icon="i-lucide-x" @click="onClose" />
</div>
<div class="flex flex-col max-h-60 min-h-0">
<header class="py-2 px-2.5">
<Input
v-model="query"
type="search"
:placeholder="t('BULK_ACTION.SEARCH_INPUT_PLACEHOLDER')"
icon-left="i-lucide-search"
size="sm"
class="w-full"
:aria-label="t('BULK_ACTION.SEARCH_INPUT_PLACEHOLDER')"
/>
</header>
<ul
v-if="hasLabels"
class="flex-1 overflow-y-auto m-0 list-none"
role="listbox"
:aria-label="t('BULK_ACTION.LABELS.ASSIGN_LABELS')"
>
<li v-if="!hasFilteredLabels" class="p-2 text-center">
<span class="text-sm text-n-slate-11">{{
t('BULK_ACTION.LABELS.NO_LABELS_FOUND')
}}</span>
</li>
<li
v-for="label in filteredLabels"
:key="label.id"
class="my-1 mx-0 py-0 px-2.5"
role="option"
:aria-selected="isLabelSelected(label.title)"
>
<label
class="items-center rounded-md cursor-pointer flex py-1 px-2.5 hover:bg-n-slate-3 dark:hover:bg-n-solid-3 has-[:checked]:bg-n-slate-2"
>
<input
v-model="selectedLabels"
type="checkbox"
:value="label.title"
class="my-0 ltr:mr-2.5 rtl:ml-2.5"
:aria-label="label.title"
/>
<span
class="overflow-hidden flex-grow w-full text-sm whitespace-nowrap text-ellipsis"
>
{{ label.title }}
</span>
<span
class="rounded-md h-3 w-3 flex-shrink-0 border border-solid border-n-weak"
:style="{ backgroundColor: label.color }"
/>
</label>
</li>
</ul>
<div v-else class="p-2 text-center">
<span class="text-sm text-n-slate-11">{{
t('CONTACTS_BULK_ACTIONS.NO_LABELS_FOUND')
}}</span>
</div>
<footer class="p-2">
<NextButton
sm
type="submit"
class="w-full"
:label="t('BULK_ACTION.LABELS.ASSIGN_SELECTED_LABELS')"
:disabled="!selectedLabels.length"
@click="handleAssign"
/>
</footer>
</div>
</div>
</template>
<style scoped lang="scss">
.triangle {
@apply block z-10 absolute text-left -top-3 ltr:right-[--triangle-position] rtl:left-[--triangle-position];
svg path {
@apply fill-n-alpha-3 backdrop-blur-[100px] stroke-n-weak;
}
}
</style>

View File

@@ -0,0 +1,145 @@
<script>
import { mapGetters } from 'vuex';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: {
NextButton,
},
emits: ['assignTeam', 'close'],
data() {
return {
query: '',
selectedteams: [],
};
},
computed: {
...mapGetters({ teams: 'teams/getTeams' }),
filteredTeams() {
return [
{ name: 'None', id: 0 },
...this.teams.filter(team =>
team.name.toLowerCase().includes(this.query.toLowerCase())
),
];
},
},
methods: {
assignTeam(key) {
this.$emit('assignTeam', key);
},
onClose() {
this.$emit('close');
},
},
};
</script>
<template>
<div v-on-clickaway="onClose" class="bulk-action__teams">
<div class="triangle">
<svg height="12" viewBox="0 0 24 12" width="24">
<path d="M20 12l-8-8-12 12" fill-rule="evenodd" stroke-width="1px" />
</svg>
</div>
<div class="flex items-center justify-between header">
<span>{{ $t('BULK_ACTION.TEAMS.TEAM_SELECT_LABEL') }}</span>
<NextButton ghost xs slate icon="i-lucide-x" @click="onClose" />
</div>
<div class="container">
<div class="team__list-container">
<ul>
<li class="search-container">
<div
class="flex items-center justify-between h-8 gap-2 agent-list-search"
>
<fluent-icon icon="search" class="search-icon" size="16" />
<input
v-model="query"
type="search"
:placeholder="$t('BULK_ACTION.SEARCH_INPUT_PLACEHOLDER')"
class="reset-base !outline-0 !text-sm agent--search_input"
/>
</div>
</li>
<template v-if="filteredTeams.length">
<li v-for="team in filteredTeams" :key="team.id">
<div class="team__list-item" @click="assignTeam(team)">
<span class="my-0 ltr:ml-2 rtl:mr-2 text-n-slate-12">
{{ team.name }}
</span>
</div>
</li>
</template>
<li v-else>
<div class="team__list-item">
<span class="my-0 ltr:ml-2 rtl:mr-2 text-n-slate-12">
{{ $t('BULK_ACTION.TEAMS.NO_TEAMS_AVAILABLE') }}
</span>
</div>
</li>
</ul>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.bulk-action__teams {
@apply max-w-[75%] absolute ltr:right-2 rtl:left-2 top-12 origin-top-right w-auto z-20 min-w-[15rem] bg-n-alpha-3 backdrop-blur-[100px] border-n-weak rounded-lg border border-solid shadow-md;
.header {
@apply p-2.5;
span {
@apply text-sm font-medium;
}
}
.container {
@apply overflow-y-auto max-h-[15rem];
.team__list-container {
@apply h-full;
}
.agent-list-search {
@apply py-0 px-2.5 bg-n-alpha-black2 border border-solid border-n-strong rounded-md;
.search-icon {
@apply text-n-slate-10;
}
.agent--search_input {
@apply border-0 text-xs m-0 dark:bg-transparent bg-transparent w-full h-[unset];
}
}
}
.triangle {
@apply block z-10 absolute text-left -top-3 ltr:right-[--triangle-position] rtl:left-[--triangle-position];
svg path {
@apply fill-n-alpha-3 backdrop-blur-[100px] stroke-n-weak;
}
}
}
ul {
@apply m-0 list-none;
li {
&:last-child {
.agent-list-item {
@apply last:rounded-b-lg;
}
}
}
}
.team__list-item {
@apply flex items-center p-2.5 cursor-pointer hover:bg-n-slate-3 dark:hover:bg-n-solid-3;
span {
@apply text-sm;
}
}
.search-container {
@apply py-0 px-2.5 sticky top-0 z-20 bg-n-alpha-3 backdrop-blur-[100px];
}
</style>

View File

@@ -0,0 +1,109 @@
<script setup>
import { useI18n } from 'vue-i18n';
import { ref } from 'vue';
import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue';
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
showResolve: {
type: Boolean,
default: true,
},
showReopen: {
type: Boolean,
default: true,
},
showSnooze: {
type: Boolean,
default: true,
},
});
const emit = defineEmits(['update', 'close']);
const { t } = useI18n();
const actions = ref([
{ icon: 'i-lucide-check', key: 'resolved' },
{ icon: 'i-lucide-redo', key: 'open' },
{ icon: 'i-lucide-alarm-clock', key: 'snoozed' },
]);
const updateConversations = key => {
if (key === 'snoozed') {
// If the user clicks on the snooze option from the bulk action change status dropdown.
// Open the snooze option for bulk action in the cmd bar.
const ninja = document.querySelector('ninja-keys');
ninja?.open({ parent: 'bulk_action_snooze_conversation' });
} else {
emit('update', key);
}
};
const onClose = () => {
emit('close');
};
const showAction = key => {
const actionsMap = {
resolved: props.showResolve,
open: props.showReopen,
snoozed: props.showSnooze,
};
return actionsMap[key] || false;
};
const actionLabel = key => {
const labelsMap = {
resolved: t('CONVERSATION.HEADER.RESOLVE_ACTION'),
open: t('CONVERSATION.HEADER.REOPEN_ACTION'),
snoozed: t('BULK_ACTION.UPDATE.SNOOZE_UNTIL'),
};
return labelsMap[key] || '';
};
</script>
<template>
<div
v-on-clickaway="onClose"
class="absolute z-20 w-auto origin-top-right border border-solid rounded-lg shadow-md ltr:right-2 rtl:left-2 top-12 bg-n-alpha-3 backdrop-blur-[100px] border-n-weak"
>
<div
class="right-[var(--triangle-position)] block z-10 absolute text-left -top-3"
>
<svg height="12" viewBox="0 0 24 12" width="24">
<path
d="M20 12l-8-8-12 12"
fill-rule="evenodd"
stroke-width="1px"
class="fill-n-alpha-3 backdrop-blur-[100px] stroke-n-weak"
/>
</svg>
</div>
<div class="p-2.5 flex gap-1 items-center justify-between">
<span class="text-sm font-medium text-n-slate-12">
{{ $t('BULK_ACTION.UPDATE.CHANGE_STATUS') }}
</span>
<Button ghost xs slate icon="i-lucide-x" @click="onClose" />
</div>
<div class="px-2.5 pt-0 pb-2.5">
<WootDropdownMenu class="m-0 list-none">
<template v-for="action in actions">
<WootDropdownItem v-if="showAction(action.key)" :key="action.key">
<Button
ghost
sm
slate
class="!w-full !justify-start"
:icon="action.icon"
:label="actionLabel(action.key)"
@click="updateConversations(action.key)"
/>
</WootDropdownItem>
</template>
</WootDropdownMenu>
</div>
</div>
</template>

View File

@@ -0,0 +1,99 @@
<script setup>
import { ref, computed, watch, onMounted, nextTick, useSlots } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
const props = defineProps({
conversationLabels: {
type: Array,
required: true,
},
});
const slots = useSlots();
const accountLabels = useMapGetter('labels/getLabels');
const activeLabels = computed(() => {
return accountLabels.value.filter(({ title }) =>
props.conversationLabels.includes(title)
);
});
const showAllLabels = ref(false);
const showExpandLabelButton = ref(false);
const labelPosition = ref(-1);
const labelContainer = ref(null);
const computeVisibleLabelPosition = () => {
const beforeSlot = slots.before ? 100 : 0;
if (!labelContainer.value) {
return;
}
const labels = Array.from(labelContainer.value.querySelectorAll('.label'));
let labelOffset = 0;
showExpandLabelButton.value = false;
labels.forEach((label, index) => {
labelOffset += label.offsetWidth + 8;
if (labelOffset < labelContainer.value.clientWidth - beforeSlot) {
labelPosition.value = index;
} else {
showExpandLabelButton.value = labels.length > 1;
}
});
};
watch(activeLabels, () => {
nextTick(() => computeVisibleLabelPosition());
});
onMounted(() => {
computeVisibleLabelPosition();
});
const onShowLabels = e => {
e.stopPropagation();
showAllLabels.value = !showAllLabels.value;
nextTick(() => computeVisibleLabelPosition());
};
</script>
<template>
<div ref="labelContainer" v-resize="computeVisibleLabelPosition">
<div
v-if="activeLabels.length || $slots.before"
class="flex items-end flex-shrink min-w-0 gap-y-1"
:class="{ 'h-auto overflow-visible flex-row flex-wrap': showAllLabels }"
>
<slot name="before" />
<woot-label
v-for="(label, index) in activeLabels"
:key="label ? label.id : index"
:title="label.title"
:description="label.description"
:color="label.color"
variant="smooth"
class="!mb-0 max-w-[calc(100%-0.5rem)]"
small
:class="{
'invisible absolute': !showAllLabels && index > labelPosition,
}"
/>
<button
v-if="showExpandLabelButton"
:title="
showAllLabels
? $t('CONVERSATION.CARD.HIDE_LABELS')
: $t('CONVERSATION.CARD.SHOW_LABELS')
"
class="h-5 py-0 px-1 flex-shrink-0 mr-6 ml-0 rtl:ml-6 rtl:mr-0 rtl:rotate-180 text-n-slate-11 border-n-strong dark:border-n-strong"
@click="onShowLabels"
>
<fluent-icon
:icon="showAllLabels ? 'chevron-left' : 'chevron-right'"
size="12"
/>
</button>
</div>
</div>
</template>

View File

@@ -0,0 +1,33 @@
<script setup>
import Icon from 'next/icon/Icon.vue';
</script>
<template>
<Icon v-once icon="i-woot-captain" class="jumping-logo" />
</template>
<style scoped>
.jumping-logo {
transform-origin: center bottom;
animation: jump 1s cubic-bezier(0.28, 0.84, 0.42, 1) infinite;
will-change: transform;
}
@keyframes jump {
0% {
transform: translateY(0) scale(1, 1);
}
20% {
transform: translateY(0) scale(1.05, 0.95);
}
50% {
transform: translateY(-5px) scale(0.95, 1.05);
}
80% {
transform: translateY(0) scale(1.02, 0.98);
}
100% {
transform: translateY(0) scale(1, 1);
}
}
</style>

View File

@@ -0,0 +1,7 @@
import { email as emailValidator } from '@vuelidate/validators';
export const validEmailsByComma = value => {
if (!value.length) return true;
const emails = value.replace(/\s+/g, '').split(',');
return emails.every(email => emailValidator.$validator(email));
};

View File

@@ -0,0 +1,18 @@
const totalMessageHeight = (total, element) => {
return total + element.scrollHeight;
};
export const calculateScrollTop = (
conversationPanelHeight,
parentHeight,
relevantMessages
) => {
// add up scrollHeight of a `relevantMessages`
let combinedMessageScrollHeight = [...relevantMessages].reduce(
totalMessageHeight,
0
);
return (
conversationPanelHeight - combinedMessageScrollHeight - parentHeight / 2
);
};

View File

@@ -0,0 +1,16 @@
import { validEmailsByComma } from '../emailHeadHelper';
describe('#validEmailsByComma', () => {
it('returns true when empty string is passed', () => {
expect(validEmailsByComma('')).toEqual(true);
});
it('returns true when valid emails separated by comma is passed', () => {
expect(validEmailsByComma('ni@njan.com,po@va.da')).toEqual(true);
});
it('returns false when one of the email passed is invalid', () => {
expect(validEmailsByComma('ni@njan.com,pova.da')).toEqual(false);
});
it('strips spaces between emails before validating', () => {
expect(validEmailsByComma('1@test.com , 2@test.com')).toEqual(true);
});
});

View File

@@ -0,0 +1,18 @@
import { calculateScrollTop } from '../scrollTopCalculationHelper';
describe('#calculateScrollTop', () => {
it('returns calculated value of the scrollTop property', () => {
class DOMElement {
constructor(scrollHeight) {
this.scrollHeight = scrollHeight;
}
}
let count = 3;
let relevantMessages = [];
while (count > 0) {
relevantMessages.push(new DOMElement(100));
count -= 1;
}
expect(calculateScrollTop(1000, 300, relevantMessages)).toEqual(550);
});
});

View File

@@ -0,0 +1,273 @@
<script setup>
import { reactive, computed, onMounted, ref } from 'vue';
import { useVuelidate } from '@vuelidate/core';
import { useI18n } from 'vue-i18n';
import { useTrack } from 'dashboard/composables';
import { useAlert } from 'dashboard/composables';
import LinearAPI from 'dashboard/api/integrations/linear';
import validations from './validations';
import { parseLinearAPIErrorResponse } from 'dashboard/store/utils/api';
import SearchableDropdown from './SearchableDropdown.vue';
import { LINEAR_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
conversationId: {
type: [Number, String],
required: true,
},
title: {
type: String,
default: null,
},
});
const emit = defineEmits(['close']);
const { t } = useI18n();
const teams = ref([]);
const assignees = ref([]);
const projects = ref([]);
const labels = ref([]);
const statuses = ref([]);
const priorities = [
{ id: 0, name: 'No priority' },
{ id: 1, name: 'Urgent' },
{ id: 2, name: 'High' },
{ id: 3, name: 'Normal' },
{ id: 4, name: 'Low' },
];
const statusDesiredOrder = [
'Backlog',
'Todo',
'In Progress',
'Done',
'Canceled',
];
const isCreating = ref(false);
const inputStyles = { borderRadius: '0.75rem', fontSize: '0.875rem' };
const formState = reactive({
title: '',
description: '',
teamId: '',
assigneeId: '',
labelId: '',
stateId: '',
priority: '',
projectId: '',
});
const v$ = useVuelidate(validations, formState);
const isSubmitDisabled = computed(
() => v$.value.title.$invalid || isCreating.value
);
const nameError = computed(() =>
v$.value.title.$error
? t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.TITLE.REQUIRED_ERROR')
: ''
);
const teamError = computed(() =>
v$.value.teamId.$error
? t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.TEAM.REQUIRED_ERROR')
: ''
);
const dropdowns = computed(() => {
return [
{
type: 'teamId',
label: 'INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.TEAM.LABEL',
items: teams.value,
placeholder: 'INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.TEAM.SEARCH',
error: teamError.value,
},
{
type: 'assigneeId',
label: 'INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.ASSIGNEE.LABEL',
items: assignees.value,
placeholder:
'INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.ASSIGNEE.SEARCH',
error: '',
},
{
type: 'labelId',
label: 'INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.LABEL.LABEL',
items: labels.value,
placeholder: 'INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.LABEL.SEARCH',
error: '',
},
{
type: 'priority',
label: 'INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.PRIORITY.LABEL',
items: priorities,
placeholder:
'INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.PRIORITY.SEARCH',
error: '',
},
{
type: 'projectId',
label: 'INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.PROJECT.LABEL',
items: projects.value,
placeholder:
'INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.PROJECT.SEARCH',
error: '',
},
{
type: 'stateId',
label: 'INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.STATUS.LABEL',
items: statuses.value,
placeholder: 'INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.STATUS.SEARCH',
error: '',
},
];
});
const onClose = () => emit('close');
const getTeams = async () => {
try {
const response = await LinearAPI.getTeams();
teams.value = response.data;
} catch (error) {
const errorMessage = parseLinearAPIErrorResponse(
error,
t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.LOADING_TEAM_ERROR')
);
useAlert(errorMessage);
}
};
const getTeamEntities = async () => {
try {
const response = await LinearAPI.getTeamEntities(formState.teamId);
assignees.value = response.data.users;
labels.value = response.data.labels;
projects.value = response.data.projects;
statuses.value = statusDesiredOrder
.map(name => response.data.states.find(status => status.name === name))
.filter(Boolean);
} catch (error) {
const errorMessage = parseLinearAPIErrorResponse(
error,
t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.LOADING_TEAM_ENTITIES_ERROR')
);
useAlert(errorMessage);
}
};
const onChange = (item, type) => {
formState[type] = item.id;
if (type === 'teamId') {
formState.assigneeId = '';
formState.stateId = '';
formState.labelId = '';
formState.projectId = '';
getTeamEntities();
}
};
const createIssue = async () => {
v$.value.$touch();
if (v$.value.$invalid) return;
const payload = {
team_id: formState.teamId,
title: formState.title,
description: formState.description || undefined,
assignee_id: formState.assigneeId || undefined,
project_id: formState.projectId || undefined,
state_id: formState.stateId || undefined,
priority: formState.priority || undefined,
label_ids: formState.labelId ? [formState.labelId] : undefined,
conversation_id: props.conversationId,
};
try {
isCreating.value = true;
const response = await LinearAPI.createIssue(payload);
const { identifier: issueIdentifier } = response.data;
await LinearAPI.link_issue(
props.conversationId,
issueIdentifier,
props.title
);
useAlert(t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.CREATE_SUCCESS'));
useTrack(LINEAR_EVENTS.CREATE_ISSUE);
onClose();
} catch (error) {
const errorMessage = parseLinearAPIErrorResponse(
error,
t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.CREATE_ERROR')
);
useAlert(errorMessage);
} finally {
isCreating.value = false;
}
};
onMounted(getTeams);
</script>
<template>
<div>
<woot-input
v-model="formState.title"
:class="{ error: v$.title.$error }"
class="w-full"
:styles="{ ...inputStyles, padding: '0.375rem 0.75rem' }"
:label="$t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.TITLE.LABEL')"
:placeholder="
$t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.TITLE.PLACEHOLDER')
"
:error="nameError"
@input="v$.title.$touch"
/>
<label>
{{ $t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.DESCRIPTION.LABEL') }}
<textarea
v-model="formState.description"
:style="{ ...inputStyles, padding: '0.5rem 0.75rem' }"
rows="3"
class="text-sm"
:placeholder="
$t(
'INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.DESCRIPTION.PLACEHOLDER'
)
"
/>
</label>
<div class="flex flex-col gap-4">
<SearchableDropdown
v-for="dropdown in dropdowns"
:key="dropdown.type"
:type="dropdown.type"
:value="formState[dropdown.type]"
:label="$t(dropdown.label)"
:items="dropdown.items"
:placeholder="$t(dropdown.placeholder)"
:error="dropdown.error"
@change="onChange"
/>
</div>
<div class="flex items-center justify-end w-full gap-2 mt-8">
<Button
faded
slate
type="reset"
:label="$t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.CANCEL')"
@click.prevent="onClose"
/>
<Button
type="submit"
:label="$t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.CREATE')"
:disabled="isSubmitDisabled"
:is-loading="isCreating"
@click.prevent="createIssue"
/>
</div>
</div>
</template>

View File

@@ -0,0 +1,95 @@
<script setup>
import { useI18n } from 'vue-i18n';
import { computed, ref } from 'vue';
import LinkIssue from './LinkIssue.vue';
import CreateIssue from './CreateIssue.vue';
const props = defineProps({
accountId: {
type: [Number, String],
required: true,
},
conversation: {
type: Object,
required: true,
},
});
const emit = defineEmits(['close']);
const { t } = useI18n();
const selectedTabIndex = ref(0);
const title = computed(() => {
const { meta: { sender: { name = null } = {} } = {} } = props.conversation;
return t('INTEGRATION_SETTINGS.LINEAR.LINK.LINK_TITLE', {
conversationId: props.conversation.id,
name,
});
});
const tabs = ref([
{
key: 0,
name: t('INTEGRATION_SETTINGS.LINEAR.CREATE'),
},
{
key: 1,
name: t('INTEGRATION_SETTINGS.LINEAR.LINK.TITLE'),
},
]);
const onClose = () => {
emit('close');
};
const onClickTabChange = index => {
selectedTabIndex.value = index;
};
</script>
<template>
<div class="flex flex-col h-auto overflow-auto">
<woot-modal-header
:header-title="$t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.TITLE')"
:header-content="
$t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.DESCRIPTION')
"
/>
<div class="flex flex-col h-auto overflow-auto">
<div class="flex flex-col px-8 pb-4 mt-1">
<woot-tabs
class="ltr:[&>ul]:pl-0 rtl:[&>ul]:pr-0 h-10"
:index="selectedTabIndex"
@change="onClickTabChange"
>
<woot-tabs-item
v-for="(tab, index) in tabs"
:key="tab.key"
:index="index"
:name="tab.name"
:show-badge="false"
is-compact
/>
</woot-tabs>
</div>
<div v-if="selectedTabIndex === 0" class="flex flex-col px-8 pb-4">
<CreateIssue
:account-id="accountId"
:conversation-id="conversation.id"
:title="title"
@close="onClose"
/>
</div>
<div v-else class="flex flex-col px-8 pb-4">
<LinkIssue
:conversation-id="conversation.id"
:title="title"
@close="onClose"
/>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,56 @@
<script setup>
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
identifier: {
type: String,
required: true,
},
issueUrl: {
type: String,
required: true,
},
});
const emit = defineEmits(['unlinkIssue']);
const unlinkIssue = () => {
emit('unlinkIssue');
};
const openIssue = () => {
window.open(props.issueUrl, '_blank');
};
</script>
<template>
<div class="flex items-center justify-between">
<div
class="flex items-center gap-2 px-2 py-1.5 border rounded-lg border-n-strong"
>
<div class="flex items-center gap-1">
<fluent-icon
icon="linear"
size="16"
class="text-[#5E6AD2]"
view-box="0 0 19 19"
/>
<span class="text-xs font-medium text-n-slate-12">
{{ identifier }}
</span>
</div>
<span class="w-px h-3 text-n-weak bg-n-weak" />
<Button
link
xs
slate
icon="i-lucide-arrow-up-right"
class="!size-4"
@click="openIssue"
/>
</div>
<Button ghost xs slate icon="i-lucide-unlink" @click="unlinkIssue" />
</div>
</template>

View File

@@ -0,0 +1,132 @@
<script setup>
import { computed, ref, onMounted, watch } from 'vue';
import { useAlert, useTrack } from 'dashboard/composables';
import { useStoreGetters } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import LinearAPI from 'dashboard/api/integrations/linear';
import CreateOrLinkIssue from './CreateOrLinkIssue.vue';
import LinearIssueItem from './LinearIssueItem.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import { LINEAR_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import { parseLinearAPIErrorResponse } from 'dashboard/store/utils/api';
const props = defineProps({
conversationId: {
type: [Number, String],
required: true,
},
});
const { t } = useI18n();
const getters = useStoreGetters();
const linkedIssues = ref([]);
const isLoading = ref(false);
const shouldShowCreateModal = ref(false);
const currentAccountId = getters.getCurrentAccountId;
const conversation = computed(
() => getters.getConversationById.value(props.conversationId) || {}
);
const hasIssues = computed(() => linkedIssues.value.length > 0);
const loadLinkedIssues = async () => {
isLoading.value = true;
linkedIssues.value = [];
try {
const response = await LinearAPI.getLinkedIssue(props.conversationId);
linkedIssues.value = response.data || [];
} catch (error) {
// Silent fail - not critical for UX
} finally {
isLoading.value = false;
}
};
const unlinkIssue = async (linkId, issueIdentifier) => {
try {
await LinearAPI.unlinkIssue(linkId, issueIdentifier, props.conversationId);
useTrack(LINEAR_EVENTS.UNLINK_ISSUE);
linkedIssues.value = linkedIssues.value.filter(
issue => issue.id !== linkId
);
useAlert(t('INTEGRATION_SETTINGS.LINEAR.UNLINK.SUCCESS'));
} catch (error) {
const errorMessage = parseLinearAPIErrorResponse(
error,
t('INTEGRATION_SETTINGS.LINEAR.UNLINK.ERROR')
);
useAlert(errorMessage);
}
};
const openCreateModal = () => {
shouldShowCreateModal.value = true;
};
const closeCreateModal = () => {
shouldShowCreateModal.value = false;
loadLinkedIssues();
};
watch(
() => props.conversationId,
() => {
loadLinkedIssues();
}
);
onMounted(() => {
loadLinkedIssues();
});
</script>
<template>
<div>
<div class="px-4 pt-3 pb-2">
<NextButton
ghost
xs
icon="i-lucide-plus"
:label="$t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK_BUTTON')"
@click="openCreateModal"
/>
</div>
<div v-if="isLoading" class="flex justify-center p-8">
<Spinner />
</div>
<div v-else-if="!hasIssues" class="flex justify-center p-4">
<p class="text-sm text-n-slate-11">
{{ $t('INTEGRATION_SETTINGS.LINEAR.NO_LINKED_ISSUES') }}
</p>
</div>
<div v-else class="max-h-[300px] overflow-y-auto">
<LinearIssueItem
v-for="linkedIssue in linkedIssues"
:key="linkedIssue.id"
class="px-4 pt-3 pb-4 border-b border-n-weak last:border-b-0"
:linked-issue="linkedIssue"
@unlink-issue="unlinkIssue"
/>
</div>
<woot-modal
v-model:show="shouldShowCreateModal"
:on-close="closeCreateModal"
:close-on-backdrop-click="false"
class="!items-start [&>div]:!top-12 [&>div]:sticky"
>
<CreateOrLinkIssue
:conversation="conversation"
:account-id="currentAccountId"
@close="closeCreateModal"
/>
</woot-modal>
</div>
</template>

View File

@@ -0,0 +1,113 @@
<script setup>
import { computed } from 'vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import CardPriorityIcon from 'dashboard/components-next/Conversation/ConversationCard/CardPriorityIcon.vue';
import IssueHeader from './IssueHeader.vue';
const props = defineProps({
linkedIssue: {
type: Object,
required: true,
},
});
const emit = defineEmits(['unlinkIssue']);
const { linkedIssue } = props;
const priorityMap = {
1: 'Urgent',
2: 'High',
3: 'Medium',
4: 'Low',
};
const issue = computed(() => linkedIssue.issue);
const assignee = computed(() => {
const assigneeDetails = issue.value.assignee;
if (!assigneeDetails) return null;
return {
name: assigneeDetails.name,
thumbnail: assigneeDetails.avatarUrl,
};
});
const labels = computed(() => issue.value.labels?.nodes || []);
const priorityLabel = computed(() => priorityMap[issue.value.priority]);
const unlinkIssue = () => {
emit('unlinkIssue', linkedIssue.id, linkedIssue.issue.identifier);
};
</script>
<template>
<div class="flex flex-col gap-4">
<div class="flex flex-col w-full">
<IssueHeader
:identifier="issue.identifier"
:link-id="linkedIssue.id"
:issue-url="issue.url"
@unlink-issue="unlinkIssue"
/>
<h3 class="mt-2 text-sm font-medium text-n-slate-12">
{{ issue.title }}
</h3>
<p
v-if="issue.description"
class="mt-1 text-sm text-n-slate-11 line-clamp-3"
>
{{ issue.description }}
</p>
</div>
<div class="flex flex-col gap-2">
<div class="flex items-center gap-2">
<div v-if="assignee" class="flex items-center gap-1.5">
<Avatar :src="assignee.thumbnail" :name="assignee.name" :size="16" />
<span class="text-xs capitalize truncate text-n-slate-12">
{{ assignee.name }}
</span>
</div>
<div v-if="assignee" class="w-px h-3 bg-n-slate-4" />
<div class="flex items-center gap-1">
<Icon
icon="i-lucide-activity"
class="size-4"
:style="{ color: issue.state?.color }"
/>
<span class="text-xs text-n-slate-12">
{{ issue.state?.name }}
</span>
</div>
<div v-if="priorityLabel" class="w-px h-3 bg-n-slate-4" />
<div v-if="priorityLabel" class="flex items-center gap-1.5">
<CardPriorityIcon :priority="priorityLabel.toLowerCase()" />
<span class="text-xs text-n-slate-12">
{{ priorityLabel }}
</span>
</div>
</div>
<div v-if="labels.length" class="flex flex-wrap">
<woot-label
v-for="label in labels"
:key="label.id"
:title="label.name"
:description="label.description"
:color="label.color"
variant="smooth"
small
/>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,54 @@
<script setup>
import { computed } from 'vue';
import { useStoreGetters } from 'dashboard/composables/store';
import NextButton from 'dashboard/components-next/button/Button.vue';
import { frontendURL } from 'dashboard/helper/URLHelper';
import { useAdmin } from 'dashboard/composables/useAdmin';
const { isAdmin } = useAdmin();
const getters = useStoreGetters();
const accountId = getters.getCurrentAccountId;
const integrationId = 'linear';
const actionURL = computed(() =>
frontendURL(
`accounts/${accountId.value}/settings/integrations/${integrationId}`
)
);
const openLinearAccount = () => {
window.open(actionURL.value, '_blank');
};
</script>
<template>
<div class="flex flex-col p-3">
<div class="w-12 h-12 mb-3">
<img
:src="`/dashboard/images/integrations/${integrationId}.png`"
class="object-contain w-full h-full border rounded-md shadow-sm border-n-weak dark:hidden dark:bg-n-alpha-2"
/>
<img
:src="`/dashboard/images/integrations/${integrationId}-dark.png`"
class="hidden object-contain w-full h-full border rounded-md shadow-sm border-n-weak dark:block"
/>
</div>
<div class="flex-1 mb-4">
<h3 class="mb-1.5 text-sm font-medium text-n-slate-12">
{{ $t('INTEGRATION_SETTINGS.LINEAR.CTA.TITLE') }}
</h3>
<p v-if="isAdmin" class="text-sm text-n-slate-11">
{{ $t('INTEGRATION_SETTINGS.LINEAR.CTA.DESCRIPTION') }}
</p>
<p v-else class="text-sm text-n-slate-11">
{{ $t('INTEGRATION_SETTINGS.LINEAR.CTA.AGENT_DESCRIPTION') }}
</p>
</div>
<NextButton v-if="isAdmin" faded slate @click="openLinearAccount">
{{ $t('INTEGRATION_SETTINGS.LINEAR.CTA.BUTTON_TEXT') }}
</NextButton>
</div>
</template>

View File

@@ -0,0 +1,150 @@
<script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useTrack } from 'dashboard/composables';
import { useAlert } from 'dashboard/composables';
import LinearAPI from 'dashboard/api/integrations/linear';
import FilterButton from 'dashboard/components/ui/Dropdown/DropdownButton.vue';
import FilterListDropdown from 'dashboard/components/ui/Dropdown/DropdownList.vue';
import { parseLinearAPIErrorResponse } from 'dashboard/store/utils/api';
import { LINEAR_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
conversationId: {
type: [Number, String],
required: true,
},
title: {
type: String,
default: null,
},
});
const emit = defineEmits(['close']);
const { t } = useI18n();
const issues = ref([]);
const shouldShowDropdown = ref(false);
const selectedOption = ref({ id: null, name: '' });
const isFetching = ref(false);
const isLinking = ref(false);
const searchQuery = ref('');
const toggleDropdown = () => {
issues.value = [];
shouldShowDropdown.value = !shouldShowDropdown.value;
};
const linkIssueTitle = computed(() => {
return selectedOption.value.id
? selectedOption.value.name
: t('INTEGRATION_SETTINGS.LINEAR.LINK.SELECT');
});
const isSubmitDisabled = computed(() => {
return !selectedOption.value.id || isLinking.value;
});
const onSelectIssue = item => {
selectedOption.value = item;
toggleDropdown();
};
const onClose = () => {
emit('close');
};
const onSearch = async value => {
issues.value = [];
if (!value) return;
searchQuery.value = value;
try {
isFetching.value = true;
const response = await LinearAPI.searchIssues(value);
issues.value = response.data.map(issue => ({
id: issue.identifier,
name: `${issue.identifier} ${issue.title}`,
icon: 'status',
iconColor: issue.state.color,
}));
} catch (error) {
const errorMessage = parseLinearAPIErrorResponse(
error,
t('INTEGRATION_SETTINGS.LINEAR.LINK.ERROR')
);
useAlert(errorMessage);
} finally {
isFetching.value = false;
}
};
const linkIssue = async () => {
const { id: issueId } = selectedOption.value;
try {
isLinking.value = true;
await LinearAPI.link_issue(props.conversationId, issueId, props.title);
useAlert(t('INTEGRATION_SETTINGS.LINEAR.LINK.LINK_SUCCESS'));
searchQuery.value = '';
issues.value = [];
onClose();
useTrack(LINEAR_EVENTS.LINK_ISSUE);
} catch (error) {
const errorMessage = parseLinearAPIErrorResponse(
error,
t('INTEGRATION_SETTINGS.LINEAR.LINK.LINK_ERROR')
);
useAlert(errorMessage);
} finally {
isLinking.value = false;
}
};
</script>
<template>
<div
class="flex flex-col justify-between"
:class="shouldShowDropdown ? 'h-[256px]' : 'gap-2'"
>
<FilterButton
trailing-icon
icon="i-lucide-chevron-down"
:button-text="linkIssueTitle"
class="justify-between w-full h-[2.5rem] py-1.5 px-3 rounded-xl bg-n-alpha-black2 outline outline-1 outline-n-weak dark:outline-n-weak hover:outline-n-slate-6 dark:hover:outline-n-slate-6"
@click="toggleDropdown"
>
<template v-if="shouldShowDropdown" #dropdown>
<FilterListDropdown
v-if="issues"
v-on-clickaway="toggleDropdown"
:show-clear-filter="false"
:list-items="issues"
:active-filter-id="selectedOption.id"
:is-loading="isFetching"
:input-placeholder="$t('INTEGRATION_SETTINGS.LINEAR.LINK.SEARCH')"
:loading-placeholder="$t('INTEGRATION_SETTINGS.LINEAR.LINK.LOADING')"
enable-search
class="left-0 flex flex-col w-full overflow-y-auto h-fit !max-h-[160px] md:left-auto md:right-0 top-10"
@on-search="onSearch"
@select="onSelectIssue"
/>
</template>
</FilterButton>
<div class="flex items-center justify-end w-full gap-2 mt-2">
<Button
faded
slate
type="reset"
:label="$t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.CANCEL')"
@click.prevent="onClose"
/>
<Button
type="submit"
:label="$t('INTEGRATION_SETTINGS.LINEAR.LINK.TITLE')"
:disabled="isSubmitDisabled"
:is-loading="isLinking"
@click.prevent="linkIssue"
/>
</div>
</div>
</template>

View File

@@ -0,0 +1,75 @@
<script setup>
import { ref, computed, defineOptions } from 'vue';
import FilterButton from 'dashboard/components/ui/Dropdown/DropdownButton.vue';
import FilterListDropdown from 'dashboard/components/ui/Dropdown/DropdownList.vue';
const props = defineProps({
type: { type: String, required: true },
label: { type: String, default: null },
items: { type: Array, required: true },
value: { type: [Number, String], default: null },
placeholder: { type: String, default: null },
error: { type: String, default: null },
});
const emit = defineEmits(['change']);
defineOptions({
name: 'SearchableDropdown',
});
const shouldShowDropdown = ref(false);
const toggleDropdown = () => {
shouldShowDropdown.value = !shouldShowDropdown.value;
};
const onSelect = item => {
emit('change', item, props.type);
toggleDropdown();
};
const hasError = computed(() => !!props.error);
const selectedItem = computed(() => {
if (!props.value) return null;
return props.items.find(i => i.id === props.value);
});
const selectedItemName = computed(
() => selectedItem.value?.name || props.placeholder
);
const selectedItemId = computed(() => selectedItem.value?.id || null);
</script>
<template>
<div
class="flex w-full"
:class="type === 'stateId' && shouldShowDropdown ? 'h-[150px]' : 'gap-2'"
>
<label class="w-full" :class="{ error: hasError }">
{{ label }}
<FilterButton
trailing-icon
icon="i-lucide-chevron-down"
:button-text="selectedItemName"
class="justify-between w-full h-[2.5rem] py-1.5 px-3 rounded-xl bg-n-alpha-black2 outline outline-1 outline-n-weak dark:outline-n-weak hover:outline-n-slate-6 dark:hover:outline-n-slate-6"
@click="toggleDropdown"
>
<template v-if="shouldShowDropdown" #dropdown>
<FilterListDropdown
v-on-clickaway="toggleDropdown"
:show-clear-filter="false"
:list-items="items"
:active-filter-id="selectedItemId"
:input-placeholder="placeholder"
enable-search
class="left-0 flex flex-col w-full overflow-y-auto h-fit !max-h-[160px] md:left-auto md:right-0 top-10"
@select="onSelect"
/>
</template>
</FilterButton>
<span v-if="hasError" class="mt-1 message">{{ error }}</span>
</label>
</div>
</template>

View File

@@ -0,0 +1,10 @@
import { required } from '@vuelidate/validators';
export default {
title: {
required,
},
teamId: {
required,
},
};