feat: Update conversation side panel layout and styling (#12983)

This commit is contained in:
Sivin Varghese
2025-12-04 13:55:07 +05:30
committed by GitHub
parent 832d61c004
commit 604d4a9c30
68 changed files with 2336 additions and 2553 deletions
@@ -0,0 +1,264 @@
<script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { vOnClickOutside } from '@vueuse/components';
import { useToggle } from '@vueuse/core';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { useDropdownPosition } from 'dashboard/composables/useDropdownPosition';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import Button from 'dashboard/components-next/button/Button.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
const props = defineProps({
agentsList: {
type: Array,
default: () => [],
},
});
const { t } = useI18n();
const store = useStore();
const triggerRef = ref(null);
const dropdownRef = ref(null);
const [openAgentsList, toggleAgentsList] = useToggle(false);
const { positionClasses } = useDropdownPosition(
triggerRef,
dropdownRef,
openAgentsList
);
const keyboardEvents = {
Escape: {
action: () => {
if (openAgentsList.value) {
toggleAgentsList();
}
},
allowOnFocusedInput: true,
},
};
useKeyboardEvents(keyboardEvents);
const currentChat = useMapGetter('getSelectedChat');
const currentUser = useMapGetter('getCurrentUser');
const assignedAgent = computed({
get() {
return currentChat.value?.meta?.assignee;
},
set(agent) {
const agentId = agent ? agent.id : 0;
store.dispatch('setCurrentChatAssignee', agent);
store
.dispatch('assignAgent', {
conversationId: currentChat.value.id,
agentId,
})
.then(() => {
useAlert(t('CONVERSATION.CHANGE_AGENT'));
});
},
});
const showSelfAssign = computed(() => {
if (!assignedAgent.value) {
return true;
}
if (assignedAgent.value.id !== currentUser.value.id) {
return true;
}
return false;
});
const assignedAgentName = computed(() => {
return (
assignedAgent.value?.name ||
assignedAgent.value?.available_name ||
t('AGENT_MGMT.MULTI_SELECTOR.PLACEHOLDER')
);
});
const assignedAgentThumbnail = computed(() => {
return assignedAgent.value?.thumbnail;
});
const agentMenuItems = computed(() => {
const items = [];
// Add "Self Assign" option if applicable
if (showSelfAssign.value) {
items.push({
label: t('CONVERSATION_SIDEBAR.SELF_ASSIGN'),
value: currentUser.value.id,
icon: 'i-lucide-user-round-check',
isSelected: false,
action: 'selfAssign',
});
}
// Map all agents and sort with "None" (id: 0) first, then selected, then alphabetically
const agentItems = props.agentsList
.map(agent => ({
label: agent.name || agent.available_name,
value: agent.id,
thumbnail: {
name: agent.name || agent.available_name,
src: agent.thumbnail,
},
isSelected: assignedAgent.value?.id === agent.id,
action: agent.id === 0 ? 'unassignAgent' : 'assignAgent',
}))
.toSorted((a, b) => {
// "None" option (id: 0) always first
if (a.value === 0) return -1;
if (b.value === 0) return 1;
// Then sort by selection
if (a.isSelected !== b.isSelected) {
return Number(b.isSelected) - Number(a.isSelected);
}
// Finally sort alphabetically
return a.label.localeCompare(b.label);
});
return [...items, ...agentItems];
});
const onSelfAssign = () => {
const {
account_id,
availability_status,
available_name,
email,
id,
name,
role,
avatar_url,
} = currentUser.value;
const selfAssign = {
account_id,
availability_status,
available_name,
email,
id,
name,
role,
thumbnail: avatar_url,
};
assignedAgent.value = selfAssign;
toggleAgentsList(false);
};
const handleAgentAction = ({ action, value }) => {
if (action === 'unassignAgent') {
assignedAgent.value = null;
toggleAgentsList(false);
} else if (action === 'selfAssign') {
// Self assign current user
onSelfAssign();
} else if (action === 'assignAgent') {
// Assign selected agent
const selectedAgent = props.agentsList.find(agent => agent.id === value);
if (assignedAgent.value && assignedAgent.value.id === value) {
assignedAgent.value = null;
} else {
assignedAgent.value = selectedAgent;
}
toggleAgentsList(false);
}
};
</script>
<template>
<div class="grid grid-cols-[30%_1fr] gap-3 w-full items-center h-9">
<span class="text-sm font-420 text-n-slate-11 truncate whitespace-nowrap">
{{ $t('CONVERSATION_SIDEBAR.ASSIGNEE_LABEL') }}
</span>
<div
v-on-click-outside="() => toggleAgentsList(false)"
class="relative w-fit"
>
<Button
ref="triggerRef"
slate
:variant="openAgentsList ? 'faded' : 'ghost'"
:label="assignedAgentName"
no-animation
class="!px-1 !py-1 h-7 !rounded-lg !font-420 !gap-1.5 w-fit !justify-start"
@click="toggleAgentsList()"
>
<template #icon>
<Avatar
v-if="assignedAgent"
:name="assignedAgentName"
:src="assignedAgentThumbnail"
:size="16"
rounded-full
/>
</template>
<div class="grid grid-cols-[1fr_auto] items-center gap-1.5 min-w-0">
<span class="truncate min-w-0 font-420">
{{ assignedAgentName }}
</span>
<Icon
:icon="
openAgentsList ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'
"
class="size-4 text-n-slate-11 flex-shrink-0"
/>
</div>
</Button>
<DropdownMenu
v-if="openAgentsList"
ref="dropdownRef"
:menu-items="agentMenuItems"
show-search
:thumbnail-size="16"
:rounded-thumbnail="false"
:search-placeholder="
$t('AGENT_MGMT.MULTI_SELECTOR.SEARCH.PLACEHOLDER.AGENT')
"
class="z-[100] w-52 overflow-y-auto max-h-60"
:class="positionClasses"
@action="handleAgentAction"
>
<template #icon="{ item }">
<Icon
v-if="item.icon"
:icon="item.icon"
class="flex-shrink-0 size-4 font-420"
:class="
item.action === 'selfAssign'
? 'text-n-blue-11'
: 'text-n-slate-11'
"
/>
</template>
<template #label="{ item }">
<span
v-if="item.label"
class="min-w-0 text-sm truncate"
:class="item.action === 'selfAssign' ? 'text-n-blue-11' : ''"
>
{{ item.label }}
</span>
</template>
<template #trailing-icon="{ item }">
<Icon
v-if="item.isSelected"
icon="i-lucide-check"
class="size-4 text-n-blue-11 flex-shrink-0"
/>
</template>
</DropdownMenu>
</div>
</div>
</template>
@@ -3,6 +3,7 @@ import { computed, watch, onMounted } from 'vue';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import ConversationCard from 'dashboard/components-next/Conversation/ConversationCard/ConversationCard.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import SidePanelEmptyState from 'dashboard/routes/dashboard/conversation/SidePanelEmptyState.vue';
const props = defineProps({
contactId: {
@@ -47,13 +48,14 @@ onMounted(() => {
</script>
<template>
<div v-if="!uiFlags.isFetching" class="max-h-96 overflow-y-auto">
<div v-if="!previousConversations.length" class="no-label-message px-4 p-3">
<span>
{{ $t('CONTACT_PANEL.CONVERSATIONS.NO_RECORDS_FOUND') }}
</span>
<div v-if="!uiFlags.isFetching" class="max-h-96 overflow-y-auto px-3">
<div v-if="!previousConversations.length" class="mt-2">
<SidePanelEmptyState
:message="$t('CONTACT_PANEL.CONVERSATIONS.NO_RECORDS_FOUND')"
/>
</div>
<div v-else class="px-3 divide-y divide-n-weak">
<div v-else>
<ConversationCard
v-for="conversation in previousConversations"
:key="conversation.id"
@@ -71,9 +73,3 @@ onMounted(() => {
<Spinner />
</div>
</template>
<style lang="scss" scoped>
.no-label-message {
@apply text-n-slate-11 mb-4;
}
</style>
@@ -1,21 +1,24 @@
<script>
export default {
props: {
title: { type: String, required: true },
value: { type: [String, Number], default: '' },
compact: { type: Boolean, default: false },
},
};
<script setup>
import Icon from 'dashboard/components-next/icon/Icon.vue';
defineProps({
title: { type: String, default: '' },
icon: { type: String, default: '' },
value: { type: [String, Number], default: '' },
});
</script>
<template>
<div class="overflow-auto" :class="compact ? 'py-0 px-0' : 'py-3 px-4'">
<div class="items-center flex justify-between mb-1.5">
<span class="text-sm font-medium text-n-slate-12">
{{ title }}
</span>
<slot name="button" />
</div>
<div class="flex items-start gap-2 py-2 min-h-9">
<Icon
v-if="icon"
v-tooltip.top="{
content: title,
delay: { show: 500, hide: 0 },
}"
:icon="icon"
class="text-n-slate-11 size-4 flex-shrink-0 mt-0.5"
/>
<div v-if="value" class="break-words">
<slot>
{{ value }}
@@ -7,15 +7,16 @@ import {
} from 'dashboard/composables/store';
import { useUISettings } from 'dashboard/composables/useUISettings';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import AccordionItem from 'dashboard/components/Accordion/AccordionItem.vue';
import ContactConversations from './ContactConversations.vue';
import ConversationAction from './ConversationAction.vue';
import ConversationParticipant from './ConversationParticipant.vue';
import ContactInfo from './contact/ContactInfo.vue';
import ContactNotes from './contact/ContactNotes.vue';
import ConversationInfo from './ConversationInfo.vue';
import CustomAttributes from './customAttributes/CustomAttributes.vue';
import Draggable from 'vuedraggable';
import InboxName from 'dashboard/components-next/Conversation/InboxName.vue';
import MacrosList from './Macros/List.vue';
import ShopifyOrdersList from 'dashboard/components/widgets/conversation/ShopifyOrdersList.vue';
import SidebarActionsHeader from 'dashboard/components-next/SidebarActionsHeader.vue';
@@ -83,6 +84,9 @@ const contactAdditionalAttributes = computed(
() => contact.value.additional_attributes || {}
);
const inboxGetter = useMapGetter('inboxes/getInbox');
const inbox = computed(() => inboxGetter.value(props.inboxId));
const getContactDetails = () => {
if (contactId.value) {
store.dispatch('contacts/show', { id: contactId.value });
@@ -122,17 +126,36 @@ onMounted(() => {
<div class="w-full">
<SidebarActionsHeader
:title="$t('CONVERSATION.SIDEBAR.CONTACT')"
class="border-b-0"
@close="closeContactPanel"
/>
>
<div class="flex items-center gap-1.5">
<div
class="ltr:pl-1.5 ltr:pr-2 rtl:pl-2 rtl:pr-1.5 h-6 rounded-md bg-n-button-color outline outline-1 outline-n-container flex items-center"
>
<InboxName
v-if="inbox?.id"
:inbox="inbox"
class="gap-1 [&>span]:text-n-slate-12"
/>
</div>
<div
class="px-1.5 h-6 rounded-md bg-n-button-color outline outline-1 outline-n-container flex items-center gap-0.6"
>
<Icon icon="i-lucide-hash" class="size-3.5 text-n-slate-10" />
<span class="text-xs font-440">{{ conversationId }}</span>
</div>
</div>
</SidebarActionsHeader>
<ContactInfo :contact="contact" :channel-type="channelType" />
<div class="px-2 pb-8 list-group">
<div class="pb-8 list-group border-t border-n-weak">
<Draggable
:list="conversationSidebarItems"
animation="200"
ghost-class="ghost"
handle=".drag-handle"
item-key="name"
class="flex flex-col gap-3"
class="flex flex-col divide-y divide-n-weak"
@start="dragging = true"
@end="onDragEnd"
>
@@ -154,29 +177,10 @@ onMounted(() => {
/>
</AccordionItem>
</div>
<div
v-else-if="element.name === 'conversation_participants'"
class="conversation--actions"
>
<AccordionItem
:title="$t('CONVERSATION_PARTICIPANTS.SIDEBAR_TITLE')"
:is-open="isContactSidebarItemOpen('is_conv_participants_open')"
@toggle="
value =>
toggleSidebarUIState('is_conv_participants_open', value)
"
>
<ConversationParticipant
:conversation-id="conversationId"
:inbox-id="inboxId"
/>
</AccordionItem>
</div>
<div v-else-if="element.name === 'conversation_info'">
<AccordionItem
:title="$t('CONVERSATION_SIDEBAR.ACCORDION.CONVERSATION_INFO')"
:is-open="isContactSidebarItemOpen('is_conv_details_open')"
compact
@toggle="
value => toggleSidebarUIState('is_conv_details_open', value)
"
@@ -191,7 +195,6 @@ onMounted(() => {
<AccordionItem
:title="$t('CONVERSATION_SIDEBAR.ACCORDION.CONTACT_ATTRIBUTES')"
:is-open="isContactSidebarItemOpen('is_contact_attributes_open')"
compact
@toggle="
value =>
toggleSidebarUIState('is_contact_attributes_open', value)
@@ -1,285 +1,33 @@
<!-- eslint-disable vue/v-slot-style -->
<script>
import { mapGetters } from 'vuex';
import { useAlert } from 'dashboard/composables';
<script setup>
import { computed } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
import { useAgentsList } from 'dashboard/composables/useAgentsList';
import ContactDetailsItem from './ContactDetailsItem.vue';
import MultiselectDropdown from 'shared/components/ui/MultiselectDropdown.vue';
import ConversationLabels from './labels/LabelBox.vue';
import { CONVERSATION_PRIORITY } from '../../../../shared/constants/messages';
import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events';
import { useTrack } from 'dashboard/composables';
import NextButton from 'dashboard/components-next/button/Button.vue';
import AgentAssignment from './AgentAssignment.vue';
import TeamAssignment from './TeamAssignment.vue';
import PriorityAssignment from './PriorityAssignment.vue';
import ParticipantAssignment from './ParticipantAssignment.vue';
import LabelAssignment from './LabelAssignment.vue';
export default {
components: {
ContactDetailsItem,
MultiselectDropdown,
ConversationLabels,
NextButton,
defineProps({
conversationId: {
type: [Number, String],
required: true,
},
props: {
conversationId: {
type: [Number, String],
required: true,
},
},
setup() {
const { agentsList } = useAgentsList();
return {
agentsList,
};
},
data() {
return {
priorityOptions: [
{
id: null,
name: this.$t('CONVERSATION.PRIORITY.OPTIONS.NONE'),
thumbnail: `/assets/images/dashboard/priority/none.svg`,
},
{
id: CONVERSATION_PRIORITY.URGENT,
name: this.$t('CONVERSATION.PRIORITY.OPTIONS.URGENT'),
thumbnail: `/assets/images/dashboard/priority/${CONVERSATION_PRIORITY.URGENT}.svg`,
},
{
id: CONVERSATION_PRIORITY.HIGH,
name: this.$t('CONVERSATION.PRIORITY.OPTIONS.HIGH'),
thumbnail: `/assets/images/dashboard/priority/${CONVERSATION_PRIORITY.HIGH}.svg`,
},
{
id: CONVERSATION_PRIORITY.MEDIUM,
name: this.$t('CONVERSATION.PRIORITY.OPTIONS.MEDIUM'),
thumbnail: `/assets/images/dashboard/priority/${CONVERSATION_PRIORITY.MEDIUM}.svg`,
},
{
id: CONVERSATION_PRIORITY.LOW,
name: this.$t('CONVERSATION.PRIORITY.OPTIONS.LOW'),
thumbnail: `/assets/images/dashboard/priority/${CONVERSATION_PRIORITY.LOW}.svg`,
},
],
};
},
computed: {
...mapGetters({
currentChat: 'getSelectedChat',
currentUser: 'getCurrentUser',
teams: 'teams/getTeams',
}),
hasAnAssignedTeam() {
return !!this.currentChat?.meta?.team;
},
teamsList() {
if (this.hasAnAssignedTeam) {
return [
{ id: 0, name: this.$t('TEAMS_SETTINGS.LIST.NONE') },
...this.teams,
];
}
return this.teams;
},
assignedAgent: {
get() {
return this.currentChat.meta.assignee;
},
set(agent) {
const agentId = agent ? agent.id : null;
this.$store.dispatch('setCurrentChatAssignee', agent);
this.$store
.dispatch('assignAgent', {
conversationId: this.currentChat.id,
agentId,
})
.then(() => {
useAlert(this.$t('CONVERSATION.CHANGE_AGENT'));
});
},
},
assignedTeam: {
get() {
return this.currentChat.meta.team;
},
set(team) {
const conversationId = this.currentChat.id;
const teamId = team ? team.id : 0;
this.$store.dispatch('setCurrentChatTeam', { team, conversationId });
this.$store
.dispatch('assignTeam', { conversationId, teamId })
.then(() => {
useAlert(this.$t('CONVERSATION.CHANGE_TEAM'));
});
},
},
assignedPriority: {
get() {
const selectedOption = this.priorityOptions.find(
opt => opt.id === this.currentChat.priority
);
});
return selectedOption || this.priorityOptions[0];
},
set(priorityItem) {
const conversationId = this.currentChat.id;
const oldValue = this.currentChat?.priority;
const priority = priorityItem ? priorityItem.id : null;
const { agentsList } = useAgentsList();
this.$store.dispatch('setCurrentChatPriority', {
priority,
conversationId,
});
this.$store
.dispatch('assignPriority', { conversationId, priority })
.then(() => {
useTrack(CONVERSATION_EVENTS.CHANGE_PRIORITY, {
oldValue,
newValue: priority,
from: 'Conversation Sidebar',
});
useAlert(
this.$t('CONVERSATION.PRIORITY.CHANGE_PRIORITY.SUCCESSFUL', {
priority: priorityItem.name,
conversationId,
})
);
});
},
},
showSelfAssign() {
if (!this.assignedAgent) {
return true;
}
if (this.assignedAgent.id !== this.currentUser.id) {
return true;
}
return false;
},
},
methods: {
onSelfAssign() {
const {
account_id,
availability_status,
available_name,
email,
id,
name,
role,
avatar_url,
} = this.currentUser;
const selfAssign = {
account_id,
availability_status,
available_name,
email,
id,
name,
role,
thumbnail: avatar_url,
};
this.assignedAgent = selfAssign;
},
onClickAssignAgent(selectedItem) {
if (this.assignedAgent && this.assignedAgent.id === selectedItem.id) {
this.assignedAgent = null;
} else {
this.assignedAgent = selectedItem;
}
},
const teams = useMapGetter('teams/getTeams');
onClickAssignTeam(selectedItemTeam) {
if (this.assignedTeam && this.assignedTeam.id === selectedItemTeam.id) {
this.assignedTeam = null;
} else {
this.assignedTeam = selectedItemTeam;
}
},
onClickAssignPriority(selectedPriorityItem) {
const isSamePriority =
this.assignedPriority &&
this.assignedPriority.id === selectedPriorityItem.id;
this.assignedPriority = isSamePriority ? null : selectedPriorityItem;
},
},
};
const teamsList = computed(() => teams.value);
</script>
<template>
<div>
<div class="multiselect-wrap--small">
<ContactDetailsItem
compact
:title="$t('CONVERSATION_SIDEBAR.ASSIGNEE_LABEL')"
>
<template #button>
<NextButton
v-if="showSelfAssign"
link
xs
icon="i-lucide-arrow-right"
class="!gap-1"
:label="$t('CONVERSATION_SIDEBAR.SELF_ASSIGN')"
@click="onSelfAssign"
/>
</template>
</ContactDetailsItem>
<MultiselectDropdown
:options="agentsList"
:selected-item="assignedAgent"
:multiselector-title="$t('AGENT_MGMT.MULTI_SELECTOR.TITLE.AGENT')"
:multiselector-placeholder="$t('AGENT_MGMT.MULTI_SELECTOR.PLACEHOLDER')"
:no-search-result="
$t('AGENT_MGMT.MULTI_SELECTOR.SEARCH.NO_RESULTS.AGENT')
"
:input-placeholder="
$t('AGENT_MGMT.MULTI_SELECTOR.SEARCH.PLACEHOLDER.AGENT')
"
@select="onClickAssignAgent"
/>
</div>
<div class="multiselect-wrap--small">
<ContactDetailsItem
compact
:title="$t('CONVERSATION_SIDEBAR.TEAM_LABEL')"
/>
<MultiselectDropdown
:options="teamsList"
:selected-item="assignedTeam"
:multiselector-title="$t('AGENT_MGMT.MULTI_SELECTOR.TITLE.TEAM')"
:multiselector-placeholder="$t('AGENT_MGMT.MULTI_SELECTOR.PLACEHOLDER')"
:no-search-result="
$t('AGENT_MGMT.MULTI_SELECTOR.SEARCH.NO_RESULTS.TEAM')
"
:input-placeholder="
$t('AGENT_MGMT.MULTI_SELECTOR.SEARCH.PLACEHOLDER.TEAM')
"
@select="onClickAssignTeam"
/>
</div>
<div class="multiselect-wrap--small">
<ContactDetailsItem compact :title="$t('CONVERSATION.PRIORITY.TITLE')" />
<MultiselectDropdown
:options="priorityOptions"
:selected-item="assignedPriority"
:multiselector-title="$t('CONVERSATION.PRIORITY.TITLE')"
:multiselector-placeholder="
$t('CONVERSATION.PRIORITY.CHANGE_PRIORITY.SELECT_PLACEHOLDER')
"
:no-search-result="
$t('CONVERSATION.PRIORITY.CHANGE_PRIORITY.NO_RESULTS')
"
:input-placeholder="
$t('CONVERSATION.PRIORITY.CHANGE_PRIORITY.INPUT_PLACEHOLDER')
"
@select="onClickAssignPriority"
/>
</div>
<ContactDetailsItem
compact
:title="$t('CONVERSATION_SIDEBAR.ACCORDION.CONVERSATION_LABELS')"
/>
<ConversationLabels :conversation-id="conversationId" />
<AgentAssignment :agents-list="agentsList" />
<TeamAssignment :teams-list="teamsList" />
<PriorityAssignment />
<ParticipantAssignment :conversation-id="conversationId" />
<LabelAssignment />
</div>
</template>
@@ -3,6 +3,7 @@ import { computed } from 'vue';
import { getLanguageName } from 'dashboard/components/widgets/conversation/advancedFilterItems/languages';
import ContactDetailsItem from './ContactDetailsItem.vue';
import CustomAttributes from './customAttributes/CustomAttributes.vue';
import SidePanelEmptyState from 'dashboard/routes/dashboard/conversation/SidePanelEmptyState.vue';
const props = defineProps({
conversationAttributes: {
@@ -49,66 +50,79 @@ const staticElements = computed(() =>
title: 'CONTACT_PANEL.INITIATED_AT',
key: 'static-initiated-at',
type: 'static_attribute',
icon: 'i-lucide-timer',
},
{
content: browserLanguage,
title: 'CONTACT_PANEL.BROWSER_LANGUAGE',
key: 'static-browser-language',
type: 'static_attribute',
icon: 'i-lucide-languages',
},
{
content: referer,
title: 'CONTACT_PANEL.INITIATED_FROM',
key: 'static-referer',
type: 'static_attribute',
icon: 'i-lucide-link',
},
{
content: browserName,
title: 'CONTACT_PANEL.BROWSER',
key: 'static-browser',
type: 'static_attribute',
icon: 'i-woot-website',
},
{
content: platformName,
title: 'CONTACT_PANEL.OS',
key: 'static-platform',
type: 'static_attribute',
icon: 'i-woot-monitor',
},
{
content: createdAtIp,
title: 'CONTACT_PANEL.IP_ADDRESS',
key: 'static-ip-address',
type: 'static_attribute',
icon: 'i-woot-ip-address',
},
].filter(attribute => !!attribute.content.value)
);
</script>
<template>
<div class="conversation--details">
<CustomAttributes
:static-elements="staticElements"
attribute-class="conversation--attribute"
attribute-from="conversation_panel"
attribute-type="conversation_attribute"
>
<template #staticItem="{ element }">
<ContactDetailsItem
:key="element.title"
:title="$t(element.title)"
:value="element.content.value"
<!-- Static Conversation Attributes -->
<div v-if="staticElements.length > 0" class="mt-2">
<div v-for="element in staticElements" :key="element.key">
<ContactDetailsItem
:title="$t(element.title)"
:value="element.content.value"
:icon="element.icon"
>
<a
v-if="element.key === 'static-referer'"
:href="element.content.value"
rel="noopener noreferrer nofollow"
target="_blank"
class="text-n-brand"
>
<a
v-if="element.key === 'static-referer'"
:href="element.content.value"
rel="noopener noreferrer nofollow"
target="_blank"
class="text-n-brand"
>
{{ element.content.value }}
</a>
</ContactDetailsItem>
</template>
</CustomAttributes>
{{ element.content.value }}
</a>
</ContactDetailsItem>
</div>
</div>
<div v-else class="mt-2">
<SidePanelEmptyState
:message="$t('CONVERSATION_ATTRIBUTES.NO_RECORDS_FOUND')"
/>
</div>
<!-- Custom Attributes -->
<CustomAttributes
attribute-from="conversation_panel"
attribute-type="conversation_attribute"
:empty-state-message="$t('CONVERSATION_CUSTOM_ATTRIBUTES.NO_RECORDS_FOUND')"
show-title
/>
</template>
@@ -1,229 +0,0 @@
<script>
import Spinner from 'shared/components/Spinner.vue';
import { useAlert } from 'dashboard/composables';
import { mapGetters } from 'vuex';
import { useAgentsList } from 'dashboard/composables/useAgentsList';
import ThumbnailGroup from 'dashboard/components/widgets/ThumbnailGroup.vue';
import MultiselectDropdownItems from 'shared/components/ui/MultiselectDropdownItems.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: {
Spinner,
ThumbnailGroup,
MultiselectDropdownItems,
NextButton,
},
props: {
conversationId: {
type: [Number, String],
required: true,
},
},
setup() {
const { agentsList } = useAgentsList(false);
return {
agentsList,
};
},
data() {
return {
selectedWatchers: [],
showDropDown: false,
};
},
computed: {
...mapGetters({
watchersUiFlas: 'conversationWatchers/getUIFlags',
currentUser: 'getCurrentUser',
}),
watchersFromStore() {
return this.$store.getters['conversationWatchers/getByConversationId'](
this.conversationId
);
},
watchersList: {
get() {
return this.selectedWatchers;
},
set(participants) {
this.selectedWatchers = [...participants];
const userIds = participants.map(el => el.id);
this.updateParticipant(userIds);
},
},
isUserWatching() {
return this.selectedWatchers.some(
watcher => watcher.id === this.currentUser.id
);
},
thumbnailList() {
return this.selectedWatchers.slice(0, 4);
},
moreAgentCount() {
const maxThumbnailCount = 4;
return this.watchersList.length - maxThumbnailCount;
},
moreThumbnailsText() {
if (this.moreAgentCount > 1) {
return this.$t('CONVERSATION_PARTICIPANTS.REMANING_PARTICIPANTS_TEXT', {
count: this.moreAgentCount,
});
}
return this.$t('CONVERSATION_PARTICIPANTS.REMANING_PARTICIPANT_TEXT', {
count: 1,
});
},
showMoreThumbs() {
return this.moreAgentCount > 0;
},
totalWatchersText() {
if (this.selectedWatchers.length > 1) {
return this.$t('CONVERSATION_PARTICIPANTS.TOTAL_PARTICIPANTS_TEXT', {
count: this.selectedWatchers.length,
});
}
return this.$t('CONVERSATION_PARTICIPANTS.TOTAL_PARTICIPANT_TEXT', {
count: 1,
});
},
},
watch: {
conversationId() {
this.fetchParticipants();
},
watchersFromStore(participants = []) {
this.selectedWatchers = [...participants];
},
},
mounted() {
this.fetchParticipants();
this.$store.dispatch('agents/get');
},
methods: {
fetchParticipants() {
const conversationId = this.conversationId;
this.$store.dispatch('conversationWatchers/show', { conversationId });
},
async updateParticipant(userIds) {
const conversationId = this.conversationId;
let alertMessage = this.$t(
'CONVERSATION_PARTICIPANTS.API.SUCCESS_MESSAGE'
);
try {
await this.$store.dispatch('conversationWatchers/update', {
conversationId,
userIds,
});
} catch (error) {
alertMessage =
error?.message ||
this.$t('CONVERSATION_PARTICIPANTS.API.ERROR_MESSAGE');
} finally {
useAlert(alertMessage);
}
this.fetchParticipants();
},
onOpenDropdown() {
this.showDropDown = true;
},
onCloseDropdown() {
this.showDropDown = false;
},
onClickItem(agent) {
const isAgentSelected = this.watchersList.some(
participant => participant.id === agent.id
);
if (isAgentSelected) {
const updatedList = this.watchersList.filter(
participant => participant.id !== agent.id
);
this.watchersList = [...updatedList];
} else {
this.watchersList = [...this.watchersList, agent];
}
},
onSelfAssign() {
this.watchersList = [...this.selectedWatchers, this.currentUser];
},
},
};
</script>
<template>
<div class="relative">
<div class="flex justify-between">
<div class="flex justify-between w-full mb-1">
<div>
<p v-if="watchersList.length" class="m-0 text-sm total-watchers">
<Spinner v-if="watchersUiFlas.isFetching" size="tiny" />
{{ totalWatchersText }}
</p>
<p v-else class="m-0 text-sm text-n-slate-10">
{{ $t('CONVERSATION_PARTICIPANTS.NO_PARTICIPANTS_TEXT') }}
</p>
</div>
<NextButton
v-tooltip.left="$t('CONVERSATION_PARTICIPANTS.ADD_PARTICIPANTS')"
slate
ghost
sm
icon="i-lucide-settings"
class="relative -top-1"
:title="$t('CONVERSATION_PARTICIPANTS.ADD_PARTICIPANTS')"
@click="onOpenDropdown"
/>
</div>
</div>
<div class="flex items-center justify-between">
<ThumbnailGroup
:more-thumbnails-text="moreThumbnailsText"
:show-more-thumbnails-count="showMoreThumbs"
:users-list="thumbnailList"
/>
<p v-if="isUserWatching" class="m-0 text-sm text-n-slate-10">
{{ $t('CONVERSATION_PARTICIPANTS.YOU_ARE_WATCHING') }}
</p>
<NextButton
v-else
link
xs
icon="i-lucide-arrow-right"
class="!gap-1"
:label="$t('CONVERSATION_PARTICIPANTS.WATCH_CONVERSATION')"
@click="onSelfAssign"
/>
</div>
<div
v-on-clickaway="
() => {
onCloseDropdown();
}
"
:class="{
'block visible': showDropDown,
'hidden invisible': !showDropDown,
}"
class="border rounded-lg shadow-lg bg-n-alpha-3 absolute backdrop-blur-[100px] border-n-strong dark:border-n-strong p-2 z-[9999] box-border top-8 w-full"
>
<div class="flex items-center justify-between mb-1">
<h4
class="m-0 overflow-hidden text-sm whitespace-nowrap text-ellipsis text-n-slate-12"
>
{{ $t('CONVERSATION_PARTICIPANTS.ADD_PARTICIPANTS') }}
</h4>
<NextButton ghost slate xs icon="i-lucide-x" @click="onCloseDropdown" />
</div>
<MultiselectDropdownItems
:options="agentsList"
:selected-items="selectedWatchers"
has-thumbnail
@select="onClickItem"
/>
</div>
</div>
</template>
@@ -0,0 +1,213 @@
<script setup>
import { ref, computed, watch, nextTick } from 'vue';
import { vOnClickOutside } from '@vueuse/components';
import { useToggle } from '@vueuse/core';
import { useI18n } from 'vue-i18n';
import { useConversationLabels } from 'dashboard/composables/useConversationLabels';
import { useDropdownPosition } from 'dashboard/composables/useDropdownPosition';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { useMapGetter } from 'dashboard/composables/store';
import { useAdmin } from 'dashboard/composables/useAdmin';
import Button from 'dashboard/components-next/button/Button.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import AddLabelModal from 'dashboard/routes/dashboard/settings/labels/AddLabel.vue';
const { t } = useI18n();
const {
activeLabels,
accountLabels,
addLabelToConversation,
removeLabelFromConversation,
} = useConversationLabels();
const { isAdmin } = useAdmin();
const conversationUiFlags = useMapGetter('conversationLabels/getUIFlags');
const triggerRef = ref(null);
const dropdownRef = ref(null);
const searchQuery = ref('');
const [openLabelsList, toggleLabels] = useToggle(false);
const [createModalVisible, toggleCreateModal] = useToggle(false);
const [hasEmptySearchResults, setEmptySearchResults] = useToggle(false);
const { positionClasses, updatePosition } = useDropdownPosition(
triggerRef,
dropdownRef,
openLabelsList
);
// Update position of dropdown when labels change
watch(
activeLabels,
async () => {
if (openLabelsList.value) {
await nextTick();
updatePosition();
}
},
{ deep: true }
);
const keyboardEvents = {
KeyL: {
action: e => {
e.preventDefault();
toggleLabels();
},
},
Escape: {
action: () => {
if (openLabelsList.value) {
toggleLabels();
}
},
allowOnFocusedInput: true,
},
};
useKeyboardEvents(keyboardEvents);
const labelMenuItems = computed(() => {
return accountLabels.value.map(label => ({
label: label.title,
value: label.id,
color: label.color,
isSelected: activeLabels.value.some(active => active.id === label.id),
action: 'toggleLabel',
}));
});
const shouldShowCreateButton = computed(() => {
return isAdmin.value && searchQuery.value && hasEmptySearchResults.value;
});
const handleLabelAction = ({ value }) => {
const label = accountLabels.value.find(l => l.id === value);
if (!label) return;
const isSelected = activeLabels.value.some(active => active.id === value);
if (isSelected) {
removeLabelFromConversation(label.title);
} else {
addLabelToConversation(label);
}
};
const handleSearchUpdate = query => {
searchQuery.value = query;
setEmptySearchResults(false);
};
const handleEmptyResults = () => {
setEmptySearchResults(true);
};
const showCreateModal = () => {
toggleCreateModal(true);
};
const hideCreateModal = () => {
toggleCreateModal(false);
};
</script>
<template>
<div class="flex flex-wrap gap-3 w-full items-start pt-3">
<Spinner
v-if="conversationUiFlags.isFetching"
:size="22"
class="text-n-slate-10"
/>
<div v-else class="flex flex-wrap gap-2.5">
<div
v-for="(label, index) in activeLabels"
:key="label ? label.id : index"
data-label
:title="label.description"
class="bg-n-button-color px-2.5 h-8 gap-1.5 rounded-lg -outline-offset-1 outline outline-1 outline-n-container inline-flex items-center flex-shrink-0"
>
<span
class="rounded-sm size-2 flex-shrink-0"
:style="{ background: label.color }"
/>
<span class="font-420 text-sm text-n-slate-12 whitespace-nowrap">
{{ label.title }}
</span>
</div>
<div
v-on-click-outside="() => toggleLabels(false)"
class="relative w-fit"
>
<Button
ref="triggerRef"
:label="$t('CONTACT_PANEL.LABELS.CONVERSATION.ADD_BUTTON')"
slate
sm
icon="i-lucide-plus"
:variant="openLabelsList ? 'faded' : 'solid'"
class="font-460 !-outline-offset-1"
@click="toggleLabels()"
/>
<DropdownMenu
v-if="openLabelsList"
ref="dropdownRef"
:menu-items="labelMenuItems"
show-search
:search-placeholder="
$t('CONTACT_PANEL.LABELS.LABEL_SELECT.PLACEHOLDER')
"
class="z-[100] w-56 overflow-y-auto max-h-60"
:class="positionClasses"
@action="handleLabelAction"
@search="handleSearchUpdate"
@empty="handleEmptyResults"
>
<template #thumbnail="{ item }">
<span
class="rounded-sm size-2 flex-shrink-0"
:style="{ background: item.color }"
/>
</template>
<template #trailing-icon="{ item }">
<Icon
v-if="item.isSelected"
icon="i-lucide-check"
class="size-4 text-n-blue-11 flex-shrink-0"
/>
</template>
<template #footer>
<div
v-if="shouldShowCreateButton"
class="flex pt-1 w-full border-t border-n-weak"
>
<Button
icon="i-lucide-plus"
slate
sm
ghost
:label="`${t('CONTACT_PANEL.LABELS.LABEL_SELECT.CREATE_LABEL')}: ${searchQuery}`"
class="w-full"
@click="showCreateModal"
/>
</div>
</template>
</DropdownMenu>
<woot-modal
v-model:show="createModalVisible"
:on-close="hideCreateModal"
>
<AddLabelModal
:prefill-title="searchQuery"
@close="hideCreateModal"
/>
</woot-modal>
</div>
</div>
</div>
</template>
@@ -8,6 +8,7 @@ import Draggable from 'vuedraggable';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import MacroItem from './MacroItem.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import SidePanelEmptyState from 'dashboard/routes/dashboard/conversation/SidePanelEmptyState.vue';
defineProps({
conversationId: {
@@ -68,16 +69,14 @@ onMounted(() => {
<template>
<div>
<div v-if="!uiFlags.isFetching && !macros.length" class="p-3">
<p class="flex flex-col items-center justify-center h-full">
{{ $t('MACROS.LIST.404') }}
</p>
<SidePanelEmptyState :message="$t('MACROS.LIST.404')" class="mb-2" />
<router-link :to="accountScopedUrl('settings/macros')">
<NextButton
faded
xs
link
sm
icon="i-lucide-plus"
class="mt-1"
:label="$t('MACROS.HEADER_BTN_TXT')"
class="!text-n-slate-11 hover:!text-n-slate-12 hover:!no-underline !py-2"
/>
</router-link>
</div>
@@ -91,7 +90,6 @@ onMounted(() => {
<Draggable
v-if="!uiFlags.isFetching && macros.length"
v-model="orderedMacros"
class="p-1"
animation="200"
ghost-class="ghost"
handle=".drag-handle"
@@ -5,6 +5,7 @@ import { useAlert } from 'dashboard/composables';
import { useStore } from 'dashboard/composables/store';
import { CONVERSATION_EVENTS } from '../../../../helper/AnalyticsHelper/events';
import { useTrack } from 'dashboard/composables';
import { useDropdownPosition } from 'dashboard/composables/useDropdownPosition';
import NextButton from 'dashboard/components-next/button/Button.vue';
import MacroPreview from './MacroPreview.vue';
@@ -25,6 +26,14 @@ const { t } = useI18n();
const isExecuting = ref(false);
const showPreview = ref(false);
const triggerRef = ref(null);
const dropdownRef = ref(null);
const { positionClasses } = useDropdownPosition(
triggerRef,
dropdownRef,
showPreview
);
const executeMacro = async macro => {
try {
@@ -53,38 +62,42 @@ const closeMacroPreview = () => {
<template>
<div
class="relative flex items-center justify-between leading-4 rounded-md h-10 pl-3 pr-2"
class="relative flex items-center justify-between leading-4 rounded-md h-10 ltr:pl-1.5 ltr:pr-3 rtl:pl-3 rtl:pr-1.5"
:class="showPreview ? 'cursor-default' : 'drag-handle cursor-grab'"
>
<span
class="overflow-hidden whitespace-nowrap text-ellipsis font-medium text-n-slate-12"
>
{{ macro.name }}
</span>
<div class="flex items-center gap-1 justify-end">
<div class="flex items-center justify-start gap-1">
<NextButton
v-tooltip.left-start="$t('MACROS.EXECUTE.PREVIEW')"
ref="triggerRef"
v-tooltip.top-start="$t('MACROS.EXECUTE.PREVIEW')"
icon="i-lucide-info"
slate
faded
:variant="!showPreview ? 'ghost' : 'faded'"
xs
class="[&>span]:size-3.5"
@click="toggleMacroPreview"
/>
<NextButton
v-tooltip.left-start="$t('MACROS.EXECUTE.BUTTON_TOOLTIP')"
icon="i-lucide-play"
slate
faded
xs
:is-loading="isExecuting"
@click="executeMacro(macro)"
/>
<span class="font-420 text-ellipsis text-n-slate-12 truncate">
{{ macro.name }}
</span>
</div>
<NextButton
:label="$t('MACROS.EXECUTE.RUN')"
slate
link
sm
class="hover:!no-underline !text-n-slate-11 !py-2"
:is-loading="isExecuting"
@click="executeMacro(macro)"
/>
<transition name="menu-slide">
<MacroPreview
v-if="showPreview"
ref="dropdownRef"
v-on-clickaway="closeMacroPreview"
:macro="macro"
class="ltr:ml-8 rtl:mr-8 !-mt-8"
:class="positionClasses"
/>
</transition>
</div>
@@ -8,6 +8,8 @@ import {
resolveAgents,
} from 'dashboard/routes/dashboard/settings/macros/macroHelper';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
macro: {
type: Object,
@@ -47,37 +49,34 @@ const resolvedMacro = computed(() => {
<template>
<div
class="macro-preview absolute border border-n-weak max-h-[22.5rem] z-50 w-64 rounded-md bg-n-alpha-3 backdrop-blur-[100px] shadow-lg bottom-8 right-8 overflow-y-auto p-4 text-left rtl:text-right"
class="macro-preview absolute w-72 outline outline-n-weak max-h-72 z-50 rounded-xl bg-n-alpha-3 backdrop-blur-[50px] shadow-lg overflow-y-auto px-3 py-2"
>
<h6 class="mb-4 text-sm text-n-slate-12">
{{ macro.name }}
</h6>
<div class="flex items-center gap-3 h-9">
<Icon icon="i-lucide-zap" class="text-n-slate-11 size-4 text-base" />
<span class="text-sm font-medium text-n-slate-12">
{{ macro.name }}
</span>
</div>
<div
v-for="(action, i) in resolvedMacro"
:key="i"
class="relative pl-4 macro-block"
class="relative ltr:pl-7 rtl:pr-7 py-3 after:content-[''] after:absolute ltr:after:left-1.5 rtl:after:right-1.5 after:w-px after:bg-n-weak"
:class="{
'after:top-1 after:h-3.5': resolvedMacro.length === 1,
'after:top-2 after:-bottom-5':
resolvedMacro.length > 1 && i !== resolvedMacro.length - 1,
}"
>
<div
v-if="i !== macro.actions.length - 1"
class="top-[0.390625rem] absolute -bottom-1 left-0 w-px bg-n-slate-6"
class="absolute ltr:left-[2.5px] rtl:right-[2.5px] top-[18px] w-2 h-2 rounded-full bg-n-surface-1 border-2 border-n-weak z-10"
/>
<div
class="absolute -left-[0.21875rem] top-[0.2734375rem] w-2 h-2 rounded-full bg-n-solid-1 border-2 border-solid border-n-weak dark:border-n-slate-6"
/>
<p class="mb-1 text-xs text-n-slate-11">
<p class="mb-1 text-sm font-medium text-n-slate-11">
{{ $t(`MACROS.ACTIONS.${action.actionName}`) }}
</p>
<p class="text-n-slate-12 text-sm">{{ action.actionValue }}</p>
<p class="text-n-slate-12 text-sm font-420">{{ action.actionValue }}</p>
</div>
</div>
</template>
<style lang="scss" scoped>
.macro-preview {
.macro-block {
&:not(:last-child) {
@apply pb-2;
}
}
}
</style>
@@ -0,0 +1,249 @@
<script setup>
import { ref, computed, watch, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { vOnClickOutside } from '@vueuse/components';
import { useToggle } from '@vueuse/core';
import { useAlert } from 'dashboard/composables';
import { useAgentsList } from 'dashboard/composables/useAgentsList';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useDropdownPosition } from 'dashboard/composables/useDropdownPosition';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import Button from 'dashboard/components-next/button/Button.vue';
import AvatarGroup from 'dashboard/components-next/avatar/AvatarGroup.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
const props = defineProps({
conversationId: {
type: [Number, String],
required: true,
},
});
const { t } = useI18n();
const store = useStore();
const { agentsList } = useAgentsList(false);
const selectedParticipants = ref([]);
const triggerRef = ref(null);
const dropdownRef = ref(null);
const [openParticipantsList, toggleParticipantsList] = useToggle(false);
const { positionClasses } = useDropdownPosition(
triggerRef,
dropdownRef,
openParticipantsList
);
const keyboardEvents = {
Escape: {
action: () => {
if (openParticipantsList.value) {
toggleParticipantsList();
}
},
allowOnFocusedInput: true,
},
};
useKeyboardEvents(keyboardEvents);
const currentUser = useMapGetter('getCurrentUser');
const watchersFromStore = computed(() => {
return store.getters['conversationWatchers/getByConversationId'](
props.conversationId
);
});
const participantsLabel = computed(() => {
const count = selectedParticipants.value.length;
if (count === 0) {
return t('CONVERSATION_PARTICIPANTS.NO_PARTICIPANTS_TEXT');
}
return t('CONVERSATION_PARTICIPANTS.TOTAL_PARTICIPANT_TEXT', { n: count });
});
const thumbnailList = computed(() => {
return selectedParticipants.value.slice(0, 3);
});
const moreParticipantsCount = computed(() => {
const maxThumbnailCount = 3;
return selectedParticipants.value.length - maxThumbnailCount;
});
const moreParticipantsText = computed(() => {
if (moreParticipantsCount.value > 0) {
return `+${moreParticipantsCount.value}`;
}
return '';
});
const isUserWatching = computed(() => {
return selectedParticipants.value.some(
participant => participant.id === currentUser.value.id
);
});
const participantMenuItems = computed(() => {
return agentsList.value.map(agent => ({
label: agent.name || agent.available_name,
value: agent.id,
thumbnail: { name: agent.name, src: agent.thumbnail },
isSelected: selectedParticipants.value.some(p => p.id === agent.id),
action: 'toggleParticipant',
}));
});
const fetchParticipants = () => {
store.dispatch('conversationWatchers/show', {
conversationId: props.conversationId,
});
};
const updateParticipants = async userIds => {
let alertMessage = t('CONVERSATION_PARTICIPANTS.API.SUCCESS_MESSAGE');
try {
await store.dispatch('conversationWatchers/update', {
conversationId: props.conversationId,
userIds,
});
} catch (error) {
alertMessage =
error?.message || t('CONVERSATION_PARTICIPANTS.API.ERROR_MESSAGE');
} finally {
useAlert(alertMessage);
}
fetchParticipants();
};
const handleParticipantAction = ({ value }) => {
const isSelected = selectedParticipants.value.some(p => p.id === value);
if (isSelected) {
// Remove participant
selectedParticipants.value = selectedParticipants.value.filter(
p => p.id !== value
);
} else {
// Add participant
const agent = agentsList.value.find(a => a.id === value);
if (agent) {
selectedParticipants.value = [...selectedParticipants.value, agent];
}
}
const userIds = selectedParticipants.value.map(p => p.id);
updateParticipants(userIds);
};
const onSelfAssign = () => {
if (!isUserWatching.value) {
selectedParticipants.value = [
...selectedParticipants.value,
currentUser.value,
];
const userIds = selectedParticipants.value.map(p => p.id);
updateParticipants(userIds);
}
};
watch(
() => props.conversationId,
() => {
fetchParticipants();
}
);
watch(watchersFromStore, participants => {
selectedParticipants.value = [...(participants || [])];
});
onMounted(() => {
fetchParticipants();
store.dispatch('agents/get');
});
</script>
<template>
<div class="grid grid-cols-[30%_1fr] gap-3 w-full items-center h-9">
<span class="text-sm font-420 text-n-slate-11 truncate whitespace-nowrap">
{{ $t('CONVERSATION_SIDEBAR.PARTICIPANTS_LABEL') }}
</span>
<div
v-on-click-outside="() => toggleParticipantsList(false)"
class="relative w-fit"
>
<div class="flex items-center gap-1">
<Button
ref="triggerRef"
slate
:variant="openParticipantsList ? 'faded' : 'ghost'"
:label="participantsLabel"
no-animation
class="!px-1 !py-1 h-7 !rounded-lg !font-420 !gap-1.5 w-fit !justify-start"
@click="toggleParticipantsList()"
>
<template #icon>
<AvatarGroup
v-if="selectedParticipants.length > 0"
:users-list="thumbnailList"
:size="16"
:show-more-count="moreParticipantsCount > 0"
:more-count-text="moreParticipantsText"
gap="tight"
/>
</template>
<div class="grid grid-cols-[1fr_auto] items-center gap-1.5 min-w-0">
<span class="truncate min-w-0 font-420">
{{ participantsLabel }}
</span>
<Icon
:icon="
openParticipantsList
? 'i-lucide-chevron-up'
: 'i-lucide-chevron-down'
"
class="size-4 text-n-slate-11 flex-shrink-0"
/>
</div>
</Button>
<div
v-if="!isUserWatching"
class="w-px mx-1 h-3 bg-n-weak rounded-lg flex-shrink-0"
/>
<Button
v-if="!isUserWatching"
link
:label="$t('CONVERSATION_PARTICIPANTS.JOIN')"
sm
class="flex-shrink-0 !text-n-blue-11 hover:!no-underline"
@click="onSelfAssign"
/>
</div>
<DropdownMenu
v-if="openParticipantsList"
ref="dropdownRef"
:menu-items="participantMenuItems"
show-search
:search-placeholder="
$t('AGENT_MGMT.MULTI_SELECTOR.SEARCH.PLACEHOLDER.AGENT')
"
class="z-[100] w-52 overflow-y-auto max-h-60"
:class="positionClasses"
@action="handleParticipantAction"
>
<template #trailing-icon="{ item }">
<Icon
v-if="item.isSelected"
icon="i-lucide-check"
class="size-4 text-n-blue-11 flex-shrink-0"
/>
</template>
</DropdownMenu>
</div>
</div>
</template>
@@ -0,0 +1,195 @@
<script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { vOnClickOutside } from '@vueuse/components';
import { useToggle } from '@vueuse/core';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { useTrack } from 'dashboard/composables';
import { useDropdownPosition } from 'dashboard/composables/useDropdownPosition';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { CONVERSATION_PRIORITY } from 'shared/constants/messages';
import { CONVERSATION_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import Button from 'dashboard/components-next/button/Button.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import CardPriorityIcon from 'dashboard/components-next/Conversation/ConversationCard/CardPriorityIcon.vue';
const { t } = useI18n();
const store = useStore();
const triggerRef = ref(null);
const dropdownRef = ref(null);
const [openPriorityList, togglePriorityList] = useToggle(false);
const { positionClasses } = useDropdownPosition(
triggerRef,
dropdownRef,
openPriorityList
);
const keyboardEvents = {
Escape: {
action: () => {
if (openPriorityList.value) {
togglePriorityList();
}
},
allowOnFocusedInput: true,
},
};
useKeyboardEvents(keyboardEvents);
const currentChat = useMapGetter('getSelectedChat');
const priorityOptions = [
{
id: 0,
name: t('CONVERSATION.PRIORITY.OPTIONS.NONE'),
priority: null,
},
{
id: CONVERSATION_PRIORITY.URGENT,
name: t('CONVERSATION.PRIORITY.OPTIONS.URGENT'),
priority: CONVERSATION_PRIORITY.URGENT,
},
{
id: CONVERSATION_PRIORITY.HIGH,
name: t('CONVERSATION.PRIORITY.OPTIONS.HIGH'),
priority: CONVERSATION_PRIORITY.HIGH,
},
{
id: CONVERSATION_PRIORITY.MEDIUM,
name: t('CONVERSATION.PRIORITY.OPTIONS.MEDIUM'),
priority: CONVERSATION_PRIORITY.MEDIUM,
},
{
id: CONVERSATION_PRIORITY.LOW,
name: t('CONVERSATION.PRIORITY.OPTIONS.LOW'),
priority: CONVERSATION_PRIORITY.LOW,
},
];
const assignedPriority = computed({
get() {
const selectedOption = priorityOptions.find(
opt => opt.priority === currentChat.value?.priority
);
return selectedOption || priorityOptions[0];
},
set(priorityItem) {
const conversationId = currentChat.value.id;
const oldValue = currentChat.value?.priority;
const priority = priorityItem ? priorityItem.priority : null;
store.dispatch('setCurrentChatPriority', {
priority,
conversationId,
});
store.dispatch('assignPriority', { conversationId, priority }).then(() => {
useTrack(CONVERSATION_EVENTS.CHANGE_PRIORITY, {
oldValue,
newValue: priority,
from: 'Conversation Sidebar',
});
useAlert(
t('CONVERSATION.PRIORITY.CHANGE_PRIORITY.SUCCESSFUL', {
priority: priorityItem.name,
conversationId,
})
);
});
},
});
const assignedPriorityName = computed(() => {
return (
assignedPriority.value?.name || t('CONVERSATION.PRIORITY.OPTIONS.NONE')
);
});
const priorityMenuItems = computed(() => {
return priorityOptions.map(option => ({
label: option.name,
value: option.id,
priority: option.priority,
isSelected: assignedPriority.value?.id === option.id,
action: 'assignPriority',
}));
});
const handlePriorityAction = ({ value }) => {
const selectedPriority = priorityOptions.find(opt => opt.id === value);
if (assignedPriority.value?.id === value) {
assignedPriority.value = priorityOptions[0]; // Set to "None"
} else {
assignedPriority.value = selectedPriority;
}
togglePriorityList(false);
};
</script>
<template>
<div class="grid grid-cols-[30%_1fr] gap-3 w-full items-center h-9">
<span class="text-sm font-420 text-n-slate-11 truncate whitespace-nowrap">
{{ $t('CONVERSATION.PRIORITY.TITLE') }}
</span>
<div
v-on-click-outside="() => togglePriorityList(false)"
class="relative w-fit"
>
<Button
ref="triggerRef"
slate
:variant="openPriorityList ? 'faded' : 'ghost'"
:label="assignedPriorityName"
no-animation
class="!px-1 !py-1 h-7 !rounded-lg !font-420 !gap-1.5 w-fit !justify-start"
@click="togglePriorityList()"
>
<template #icon>
<CardPriorityIcon :priority="assignedPriority.priority" show-empty />
</template>
<div class="grid grid-cols-[1fr_auto] items-center gap-1.5 min-w-0">
<span class="truncate min-w-0 font-420">
{{ assignedPriorityName }}
</span>
<Icon
:icon="
openPriorityList ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'
"
class="size-4 text-n-slate-11 flex-shrink-0"
/>
</div>
</Button>
<DropdownMenu
v-if="openPriorityList"
ref="dropdownRef"
:menu-items="priorityMenuItems"
:search-placeholder="
$t('CONVERSATION.PRIORITY.CHANGE_PRIORITY.INPUT_PLACEHOLDER')
"
class="z-[100] w-48 overflow-y-auto max-h-60"
:class="positionClasses"
@action="handlePriorityAction"
>
<template #icon="{ item }">
<CardPriorityIcon
:priority="item.priority"
show-empty
class="flex-shrink-0"
/>
</template>
<template #trailing-icon="{ item }">
<Icon
v-if="item.isSelected"
icon="i-lucide-check"
class="size-4 text-n-blue-11 flex-shrink-0"
/>
</template>
</DropdownMenu>
</div>
</div>
</template>
@@ -0,0 +1,14 @@
<script setup>
defineProps({
message: {
type: String,
default: '',
},
});
</script>
<template>
<div class="custom-dashed-border p-4 rounded-xl">
<p class="text-center text-n-slate-10 font-420">{{ message }}</p>
</div>
</template>
@@ -0,0 +1,181 @@
<script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { vOnClickOutside } from '@vueuse/components';
import { useToggle } from '@vueuse/core';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { useDropdownPosition } from 'dashboard/composables/useDropdownPosition';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import Button from 'dashboard/components-next/button/Button.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
const props = defineProps({
teamsList: {
type: Array,
default: () => [],
},
});
const { t } = useI18n();
const store = useStore();
const triggerRef = ref(null);
const dropdownRef = ref(null);
const [openTeamsList, toggleTeamsList] = useToggle(false);
const { positionClasses } = useDropdownPosition(
triggerRef,
dropdownRef,
openTeamsList
);
const keyboardEvents = {
Escape: {
action: () => {
if (openTeamsList.value) {
toggleTeamsList();
}
},
allowOnFocusedInput: true,
},
};
useKeyboardEvents(keyboardEvents);
const currentChat = useMapGetter('getSelectedChat');
const assignedTeam = computed({
get() {
return currentChat.value?.meta?.team;
},
set(team) {
const conversationId = currentChat.value.id;
const teamId = team ? team.id : 0;
store.dispatch('setCurrentChatTeam', { team, conversationId });
store.dispatch('assignTeam', { conversationId, teamId }).then(() => {
useAlert(t('CONVERSATION.CHANGE_TEAM'));
});
},
});
const assignedTeamName = computed(() => {
return assignedTeam.value?.name || t('AGENT_MGMT.MULTI_SELECTOR.PLACEHOLDER');
});
const teamMenuItems = computed(() => {
const items = [];
// Add "None" option as first item
items.push({
label: t('AGENT_MGMT.MULTI_SELECTOR.PLACEHOLDER'),
value: 0,
isSelected: !assignedTeam.value,
thumbnail: { name: t('AGENT_MGMT.MULTI_SELECTOR.PLACEHOLDER') },
action: 'unassignTeam',
});
// Add all teams, sorted by selection and then by name
const teamItems = props.teamsList
.filter(team => team.id !== 0) // Filter out the "None" option if it exists in teamsList
.map(team => ({
label: team.name,
value: team.id,
thumbnail: { name: team.name },
isSelected: assignedTeam.value?.id === team.id,
action: 'assignTeam',
}))
.toSorted((a, b) => {
if (a.isSelected !== b.isSelected) {
return Number(b.isSelected) - Number(a.isSelected);
}
return a.label.localeCompare(b.label);
});
return [...items, ...teamItems];
});
const handleTeamAction = ({ action, value }) => {
if (action === 'unassignTeam') {
// Unassign team (set to null)
assignedTeam.value = null;
toggleTeamsList(false);
} else if (action === 'assignTeam') {
// Assign selected team
const selectedTeam = props.teamsList.find(team => team.id === value);
if (assignedTeam.value && assignedTeam.value.id === value) {
assignedTeam.value = null;
} else {
assignedTeam.value = selectedTeam;
}
toggleTeamsList(false);
}
};
</script>
<template>
<div class="grid grid-cols-[30%_1fr] gap-3 w-full items-center h-9">
<span class="text-sm font-420 text-n-slate-11 truncate whitespace-nowrap">
{{ $t('CONVERSATION_SIDEBAR.TEAM_LABEL') }}
</span>
<div
v-on-click-outside="() => toggleTeamsList(false)"
class="relative w-fit"
>
<Button
ref="triggerRef"
slate
:variant="openTeamsList ? 'faded' : 'ghost'"
:label="assignedTeamName"
no-animation
class="!px-1 !py-1 h-7 !rounded-lg !font-420 !gap-1.5 w-fit !justify-start"
@click="toggleTeamsList()"
>
<template #icon>
<Avatar
v-if="assignedTeam"
:name="assignedTeamName"
:size="16"
rounded-full
/>
</template>
<div class="grid grid-cols-[1fr_auto] items-center gap-1.5 min-w-0">
<span class="truncate min-w-0 font-420">
{{ assignedTeamName }}
</span>
<Icon
:icon="
openTeamsList ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'
"
class="size-4 text-n-slate-11 flex-shrink-0"
/>
</div>
</Button>
<DropdownMenu
v-if="openTeamsList"
ref="dropdownRef"
:menu-items="teamMenuItems"
show-search
:thumbnail-size="16"
:rounded-thumbnail="false"
:search-placeholder="
$t('AGENT_MGMT.MULTI_SELECTOR.SEARCH.PLACEHOLDER.TEAM')
"
class="z-[100] w-52 overflow-y-auto max-h-60"
:class="positionClasses"
@action="handleTeamAction"
>
<template #trailing-icon="{ item }">
<Icon
v-if="item.isSelected"
icon="i-lucide-check"
class="size-4 text-n-blue-11 flex-shrink-0"
/>
</template>
</DropdownMenu>
</div>
</div>
</template>
@@ -1,11 +1,15 @@
<script>
import { mapGetters } from 'vuex';
<script setup>
import { computed, watch } from 'vue';
import { useToggle } from '@vueuse/core';
import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router';
import { useMapGetter, useStore } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { dynamicTime } from 'shared/helpers/timeHelper';
import { useAdmin } from 'dashboard/composables/useAdmin';
import ContactInfoRow from './ContactInfoRow.vue';
import Avatar from 'next/avatar/Avatar.vue';
import SocialIcons from './SocialIcons.vue';
import Icon from 'next/icon/Icon.vue';
import EditContact from './EditContact.vue';
import ContactMergeModal from 'dashboard/modules/contact/ContactMergeModal.vue';
import ComposeConversation from 'dashboard/components-next/NewConversation/ComposeConversation.vue';
@@ -17,253 +21,220 @@ import {
isAConversationRoute,
isAInboxViewRoute,
getConversationDashboardRoute,
} from '../../../../helper/routeHelpers';
} from 'dashboard/helper/routeHelpers';
import { emitter } from 'shared/helpers/mitt';
export default {
components: {
NextButton,
ContactInfoRow,
EditContact,
Avatar,
ComposeConversation,
SocialIcons,
ContactMergeModal,
VoiceCallButton,
const props = defineProps({
contact: {
type: Object,
default: () => ({}),
},
props: {
contact: {
type: Object,
default: () => ({}),
},
showAvatar: {
type: Boolean,
default: true,
},
showAvatar: {
type: Boolean,
default: true,
},
emits: ['panelClose'],
setup() {
const { isAdmin } = useAdmin();
return {
isAdmin,
};
},
data() {
return {
showEditModal: false,
showMergeModal: false,
showDeleteModal: false,
};
},
computed: {
...mapGetters({ uiFlags: 'contacts/getUIFlags' }),
contactProfileLink() {
return `/app/accounts/${this.$route.params.accountId}/contacts/${this.contact.id}`;
},
additionalAttributes() {
return this.contact.additional_attributes || {};
},
location() {
const {
country = '',
city = '',
country_code: countryCode,
} = this.additionalAttributes;
const cityAndCountry = [city, country].filter(item => !!item).join(', ');
});
if (!cityAndCountry) {
return '';
}
return this.findCountryFlag(countryCode, cityAndCountry);
},
socialProfiles() {
const {
social_profiles: socialProfiles,
screen_name: twitterScreenName,
social_telegram_user_name: telegramUsername,
} = this.additionalAttributes;
return {
twitter: twitterScreenName,
telegram: telegramUsername,
...(socialProfiles || {}),
};
},
// Delete Modal
confirmDeleteMessage() {
return ` ${this.contact.name}?`;
},
},
watch: {
'contact.id': {
handler(id) {
this.$store.dispatch('contacts/fetchContactableInbox', id);
},
immediate: true,
},
},
methods: {
dynamicTime,
toggleEditModal() {
this.showEditModal = !this.showEditModal;
},
openComposeConversationModal(toggleFn) {
toggleFn();
// Flag to prevent triggering drag n drop,
// When compose modal is active
emitter.emit(BUS_EVENTS.NEW_CONVERSATION_MODAL, true);
},
closeComposeConversationModal() {
// Flag to enable drag n drop,
// When compose modal is closed
emitter.emit(BUS_EVENTS.NEW_CONVERSATION_MODAL, false);
},
toggleDeleteModal() {
this.showDeleteModal = !this.showDeleteModal;
},
confirmDeletion() {
this.deleteContact(this.contact);
this.closeDelete();
},
closeDelete() {
this.showDeleteModal = false;
this.showEditModal = false;
},
findCountryFlag(countryCode, cityAndCountry) {
try {
if (!countryCode) {
return `${cityAndCountry} 🌎`;
}
const emit = defineEmits(['panelClose']);
const code = countryCode?.toLowerCase();
return `${cityAndCountry} <span class="fi fi-${code} size-3.5"></span>`;
} catch (error) {
return '';
}
},
async deleteContact({ id }) {
try {
await this.$store.dispatch('contacts/delete', id);
this.$emit('panelClose');
useAlert(this.$t('DELETE_CONTACT.API.SUCCESS_MESSAGE'));
const route = useRoute();
const router = useRouter();
const store = useStore();
const { t } = useI18n();
const { isAdmin } = useAdmin();
if (isAConversationRoute(this.$route.name)) {
this.$router.push({
name: getConversationDashboardRoute(this.$route.name),
});
} else if (isAInboxViewRoute(this.$route.name)) {
this.$router.push({
name: 'inbox_view',
});
} else if (this.$route.name !== 'contacts_dashboard') {
this.$router.push({
name: 'contacts_dashboard',
});
}
} catch (error) {
useAlert(
error.message
? error.message
: this.$t('DELETE_CONTACT.API.ERROR_MESSAGE')
);
}
},
closeMergeModal() {
this.showMergeModal = false;
},
openMergeModal() {
this.showMergeModal = true;
},
},
const [showEditModal, toggleEditModal] = useToggle(false);
const [showMergeModal, toggleMergeModal] = useToggle(false);
const [showDeleteModal, toggleDeleteModal] = useToggle(false);
const uiFlags = useMapGetter('contacts/getUIFlags');
const contactProfileLink = computed(
() => `/app/accounts/${route.params.accountId}/contacts/${props.contact.id}`
);
const additionalAttributes = computed(
() => props.contact.additional_attributes || {}
);
const findCountryFlag = (countryCode, cityAndCountry) => {
try {
if (!countryCode) {
return `${cityAndCountry} 🌎`;
}
const code = countryCode?.toLowerCase();
return `${cityAndCountry} <span class="fi fi-${code} size-3.5"></span>`;
} catch (error) {
return '';
}
};
const deleteContact = async ({ id }) => {
try {
await store.dispatch('contacts/delete', id);
emit('panelClose');
useAlert(t('DELETE_CONTACT.API.SUCCESS_MESSAGE'));
if (isAConversationRoute(route.name)) {
router.push({
name: getConversationDashboardRoute(route.name),
});
} else if (isAInboxViewRoute(route.name)) {
router.push({
name: 'inbox_view',
});
} else if (route.name !== 'contacts_dashboard') {
router.push({
name: 'contacts_dashboard',
});
}
} catch (error) {
useAlert(
error.message ? error.message : t('DELETE_CONTACT.API.ERROR_MESSAGE')
);
}
};
const location = computed(() => {
const {
country = '',
city = '',
country_code: countryCode,
} = additionalAttributes.value;
const cityAndCountry = [city, country].filter(item => !!item).join(', ');
if (!cityAndCountry) {
return '';
}
return findCountryFlag(countryCode, cityAndCountry);
});
watch(
() => props.contact.id,
id => store.dispatch('contacts/fetchContactableInbox', id),
{ immediate: true }
);
const openComposeConversationModal = toggleFn => {
toggleFn();
// Flag to prevent triggering drag n drop,
// When compose modal is active
emitter.emit(BUS_EVENTS.NEW_CONVERSATION_MODAL, true);
};
const closeComposeConversationModal = () => {
// Flag to enable drag n drop,
// When compose modal is closed
emitter.emit(BUS_EVENTS.NEW_CONVERSATION_MODAL, false);
};
const closeDelete = () => {
toggleDeleteModal(false);
toggleEditModal(false);
};
const confirmDeletion = () => {
deleteContact(props.contact);
closeDelete();
};
const closeMergeModal = () => {
toggleMergeModal(false);
};
const openMergeModal = () => {
toggleMergeModal(true);
};
</script>
<template>
<div class="relative items-center w-full p-4">
<div class="flex flex-col w-full gap-2 text-left rtl:text-right">
<div class="flex flex-row justify-between">
<div class="relative items-center w-full px-4 pb-4 pt-3">
<div class="flex flex-col w-full gap-3 text-start">
<div class="flex flex-row gap-4 md:gap-8">
<Avatar
v-if="showAvatar"
:src="contact.thumbnail"
:name="contact.name"
:status="contact.availability_status"
:size="48"
:size="64"
hide-offline-status
rounded-full
/>
</div>
<div class="flex flex-col items-start gap-1.5 min-w-0 w-full">
<div v-if="showAvatar" class="flex items-center w-full min-w-0 gap-3">
<h3
class="flex-shrink max-w-full min-w-0 my-0 text-base capitalize break-words text-n-slate-12"
>
{{ contact.name }}
</h3>
<div class="flex flex-row items-center gap-2">
<span
v-if="contact.created_at"
v-tooltip.left="
`${$t('CONTACT_PANEL.CREATED_AT_LABEL')} ${dynamicTime(
contact.created_at
)}`
"
class="i-lucide-info text-sm text-n-slate-10"
/>
<div
v-if="showAvatar"
class="flex flex-col justify-center min-w-0 flex-1"
>
<div class="flex items-center gap-1">
<h3
:title="contact.name"
class="flex-shrink font-medium max-w-full min-w-0 my-0 text-base capitalize break-words text-n-slate-12 line-clamp-2"
>
{{ contact.name }}
</h3>
<div class="w-px h-2 bg-n-strong rounded-md ltr:ml-1 rtl:mr-1" />
<a
:href="contactProfileLink"
target="_blank"
rel="noopener nofollow noreferrer"
class="leading-3"
class="flex-shrink-0 flex items-center"
>
<span class="i-lucide-external-link text-sm text-n-slate-10" />
<Icon
icon="i-lucide-arrow-up-right"
class="size-4 text-n-slate-10 hover:text-n-slate-12 transition-colors"
/>
</a>
</div>
<p
v-if="contact"
class="text-sm text-n-slate-11 font-420 m-0 truncate"
>
{{ contact.email || contact.phone_number || '---' }}
</p>
</div>
</div>
<p v-if="additionalAttributes.description" class="break-words mb-0.5">
<div class="flex flex-col items-start gap-3 min-w-0 w-full">
<p
v-if="additionalAttributes.description"
class="break-words text-sm text-n-slate-11 font-420 mb-0"
>
{{ additionalAttributes.description }}
</p>
<div class="flex flex-col items-start w-full gap-2">
<div class="flex flex-col items-start w-full">
<ContactInfoRow
:href="contact.email ? `mailto:${contact.email}` : ''"
:value="contact.email"
icon="mail"
emoji="✉️"
:title="$t('CONTACT_PANEL.EMAIL_ADDRESS')"
:title="t('CONTACT_PANEL.EMAIL_ADDRESS')"
show-copy
/>
<ContactInfoRow
:value="additionalAttributes.company_name"
:title="t('CONTACT_PANEL.COMPANY')"
/>
<ContactInfoRow
:href="contact.phone_number ? `tel:${contact.phone_number}` : ''"
:value="contact.phone_number"
icon="call"
emoji="📞"
:title="$t('CONTACT_PANEL.PHONE_NUMBER')"
:title="t('CONTACT_PANEL.PHONE_NUMBER')"
show-copy
/>
<ContactInfoRow
v-if="contact.identifier"
:value="contact.identifier"
icon="contact-identify"
emoji="🪪"
:title="$t('CONTACT_PANEL.IDENTIFIER')"
/>
<ContactInfoRow
:value="additionalAttributes.company_name"
icon="building-bank"
emoji="🏢"
:title="$t('CONTACT_PANEL.COMPANY')"
:title="t('CONTACT_PANEL.IDENTIFIER')"
/>
<ContactInfoRow
v-if="location || additionalAttributes.location"
:value="location || additionalAttributes.location"
icon="map"
emoji="🌍"
:title="$t('CONTACT_PANEL.LOCATION')"
:title="t('CONTACT_PANEL.LOCATION')"
/>
<ContactInfoRow
v-if="contact.created_at"
:value="dynamicTime(contact.created_at)"
:title="t('CONTACT_PANEL.CREATED_AT_LABEL')"
/>
<SocialIcons :social-profiles="socialProfiles" />
</div>
</div>
<div class="flex items-center w-full mt-0.5 gap-2">
<div class="flex items-center w-full mb-1 gap-3">
<ComposeConversation
:contact-id="String(contact.id)"
is-modal
@@ -271,11 +242,10 @@ export default {
>
<template #trigger="{ toggle }">
<NextButton
v-tooltip.top-end="$t('CONTACT_PANEL.NEW_MESSAGE')"
icon="i-ph-chat-circle-dots"
:label="t('CONTACT_PANEL.BUTTON_LABEL.NEW_MESSAGE')"
slate
faded
sm
class="!px-2"
@click="openComposeConversationModal(toggle)"
/>
</template>
@@ -283,46 +253,42 @@ export default {
<VoiceCallButton
:phone="contact.phone_number"
:contact-id="contact.id"
icon="i-ri-phone-fill"
size="sm"
:tooltip-label="$t('CONTACT_PANEL.CALL')"
:label="t('CONTACT_PANEL.BUTTON_LABEL.CALL')"
slate
faded
class="!px-2"
/>
<NextButton
v-tooltip.top-end="$t('EDIT_CONTACT.BUTTON_LABEL')"
icon="i-ph-pencil-simple"
:label="t('CONTACT_PANEL.BUTTON_LABEL.EDIT')"
slate
faded
sm
@click="toggleEditModal"
class="!px-2"
@click="() => toggleEditModal()"
/>
<NextButton
v-tooltip.top-end="$t('CONTACT_PANEL.MERGE_CONTACT')"
icon="i-ph-arrows-merge"
:label="t('CONTACT_PANEL.BUTTON_LABEL.MERGE')"
slate
faded
sm
class="!px-2"
:disabled="uiFlags.isMerging"
@click="openMergeModal"
/>
<NextButton
v-if="isAdmin"
v-tooltip.top-end="$t('DELETE_CONTACT.BUTTON_LABEL')"
icon="i-ph-trash"
:label="t('CONTACT_PANEL.BUTTON_LABEL.DELETE')"
slate
faded
sm
ruby
class="!px-2"
:disabled="uiFlags.isDeleting"
@click="toggleDeleteModal"
@click="() => toggleDeleteModal()"
/>
</div>
<EditContact
v-if="showEditModal"
:show="showEditModal"
:contact="contact"
@cancel="toggleEditModal"
@cancel="() => toggleEditModal()"
/>
<ContactMergeModal
v-if="showMergeModal"
@@ -336,11 +302,11 @@ export default {
v-model:show="showDeleteModal"
:on-close="closeDelete"
:on-confirm="confirmDeletion"
:title="$t('DELETE_CONTACT.CONFIRM.TITLE')"
:message="$t('DELETE_CONTACT.CONFIRM.MESSAGE')"
:message-value="confirmDeleteMessage"
:confirm-text="$t('DELETE_CONTACT.CONFIRM.YES')"
:reject-text="$t('DELETE_CONTACT.CONFIRM.NO')"
:title="t('DELETE_CONTACT.CONFIRM.TITLE')"
:message="t('DELETE_CONTACT.CONFIRM.MESSAGE')"
:message-value="contact?.name || ''"
:confirm-text="t('DELETE_CONTACT.CONFIRM.YES')"
:reject-text="t('DELETE_CONTACT.CONFIRM.NO')"
/>
</div>
</template>
@@ -1,95 +1,76 @@
<script>
<script setup>
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import EmojiOrIcon from 'shared/components/EmojiOrIcon.vue';
import { copyTextToClipboard } from 'shared/helpers/clipboard';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: {
EmojiOrIcon,
NextButton,
const props = defineProps({
href: {
type: String,
default: '',
},
props: {
href: {
type: String,
default: '',
},
icon: {
type: String,
required: true,
},
emoji: {
type: String,
required: true,
},
value: {
type: String,
default: '',
},
showCopy: {
type: Boolean,
default: false,
},
title: {
type: String,
required: true,
},
methods: {
async onCopy(e) {
e.preventDefault();
await copyTextToClipboard(this.value);
useAlert(this.$t('CONTACT_PANEL.COPY_SUCCESSFUL'));
},
value: {
type: String,
default: '',
},
showCopy: {
type: Boolean,
default: false,
},
});
const { t } = useI18n();
const onCopy = async e => {
e.preventDefault();
await copyTextToClipboard(props.value);
useAlert(t('CONTACT_PANEL.COPY_SUCCESSFUL'));
};
</script>
<template>
<div class="w-full h-5 ltr:-ml-1 rtl:-mr-1">
<a
v-if="href"
:href="href"
class="flex items-center gap-2 text-n-slate-11 hover:underline"
>
<EmojiOrIcon
:icon="icon"
:emoji="emoji"
icon-size="14"
class="flex-shrink-0 ltr:ml-1 rtl:mr-1"
/>
<span
v-if="value"
class="overflow-hidden text-sm whitespace-nowrap text-ellipsis"
<div class="grid grid-cols-[30%_1fr] gap-4 w-full items-center h-9">
<span class="text-sm font-420 text-n-slate-11 truncate whitespace-nowrap">
{{ title }}
</span>
<div class="min-w-0 flex items-center gap-1">
<a
v-if="href"
:href="href"
class="hover:underline min-w-0 truncate"
:title="value"
>
{{ value }}
</span>
<span v-else class="text-sm text-n-slate-11">
{{ $t('CONTACT_PANEL.NOT_AVAILABLE') }}
</span>
<span v-if="value" class="text-sm font-420 text-n-slate-12">
{{ value }}
</span>
<span v-else class="text-sm text-n-slate-10">
{{ '---' }}
</span>
</a>
<div v-else class="text-n-slate-12 min-w-0 truncate">
<span
v-if="value"
v-dompurify-html="value"
class="text-sm font-420 text-n-slate-12 [&>span]:ltr:ml-1.5 [&>span]:rtl:mr-1.5"
/>
<span v-else class="text-sm text-n-slate-10">{{ '---' }}</span>
</div>
<NextButton
v-if="showCopy"
v-if="showCopy && value"
ghost
xs
slate
class="ltr:-ml-1 rtl:-mr-1"
icon="i-lucide-clipboard"
class="flex-shrink-0"
@click="onCopy"
/>
</a>
<div v-else class="flex items-center gap-2 text-n-slate-11">
<EmojiOrIcon
:icon="icon"
:emoji="emoji"
icon-size="14"
class="flex-shrink-0 ltr:ml-1 rtl:mr-1"
/>
<span
v-if="value"
v-dompurify-html="value"
class="overflow-hidden text-sm whitespace-nowrap text-ellipsis"
/>
<span v-else class="text-sm text-n-slate-11">
{{ $t('CONTACT_PANEL.NOT_AVAILABLE') }}
</span>
</div>
</div>
</template>
@@ -8,6 +8,7 @@ import Editor from 'dashboard/components-next/Editor/Editor.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import ContactNoteItem from 'next/Contacts/ContactsSidebar/components/ContactNoteItem.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import SidePanelEmptyState from 'dashboard/routes/dashboard/conversation/SidePanelEmptyState.vue';
const props = defineProps({
contactId: { type: [String, Number], required: true },
@@ -98,10 +99,10 @@ watch(
<template>
<div>
<div class="px-4 pt-3 pb-2">
<div class="px-3 pt-3 pb-2">
<NextButton
ghost
xs
sm
slate
icon="i-lucide-plus"
:label="$t('CONTACTS_LAYOUT.SIDEBAR.NOTES.ADD_NOTE')"
:disabled="!contactId || isFetchingNotes"
@@ -117,12 +118,12 @@ watch(
</div>
<div
v-else-if="notes.length"
class="flex flex-col max-h-[300px] overflow-y-auto"
class="flex flex-col px-3 max-h-[300px] overflow-y-auto"
>
<ContactNoteItem
v-for="note in notes"
:key="note.id"
class="py-4 last-of-type:border-b-0 px-4"
class="pb-3 last-of-type:border-b-0 !border-0"
:note="note"
:written-by="getWrittenBy(note)"
allow-delete
@@ -130,9 +131,11 @@ watch(
@delete="onDelete"
/>
</div>
<p v-else class="px-6 py-6 text-sm leading-6 text-center text-n-slate-11">
{{ t('CONTACTS_LAYOUT.SIDEBAR.NOTES.CONVERSATION_EMPTY_STATE') }}
</p>
<div v-else class="mt-2 px-3">
<SidePanelEmptyState
:message="t('CONTACTS_LAYOUT.SIDEBAR.NOTES.CONVERSATION_EMPTY_STATE')"
/>
</div>
<woot-modal
v-model:show="shouldShowCreateModal"
@@ -7,9 +7,14 @@ import { useStore, useStoreGetters } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { copyTextToClipboard } from 'shared/helpers/clipboard';
import CustomAttribute from 'dashboard/components/CustomAttribute.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import HelperTextPopup from 'dashboard/components/ui/HelperTextPopup.vue';
import ListAttribute from 'dashboard/components-next/CustomAttributes/ListAttribute.vue';
import CheckboxAttribute from 'dashboard/components-next/CustomAttributes/CheckboxAttribute.vue';
import DateAttribute from 'dashboard/components-next/CustomAttributes/DateAttribute.vue';
import OtherAttribute from 'dashboard/components-next/CustomAttributes/OtherAttribute.vue';
import SidePanelEmptyState from 'dashboard/routes/dashboard/conversation/SidePanelEmptyState.vue';
const props = defineProps({
attributeType: {
@@ -25,11 +30,9 @@ const props = defineProps({
type: String,
default: '',
},
// Combine static elements with custom attributes components
// To allow for custom ordering
staticElements: {
type: Array,
default: () => [],
showTitle: {
type: Boolean,
default: false,
},
});
@@ -82,110 +85,84 @@ const filteredCustomAttributes = computed(() =>
);
return {
...attribute,
id: attribute.id,
type: 'custom_attribute',
key: attribute.attribute_key,
attributeKey: attribute.attribute_key,
attributeDisplayName: attribute.attribute_display_name,
attributeDisplayType: attribute.attribute_display_type,
attributeValues: attribute.attribute_values,
attributeDescription: attribute.attribute_description,
regexPattern: attribute.regex_pattern,
regexCue: attribute.regex_cue,
// Set value from customAttributes if it exists, otherwise use ''
value: hasValue ? customAttributes.value[attribute.attribute_key] : '',
};
})
);
// Order key name for UI settings
const componentMap = {
list: ListAttribute,
checkbox: CheckboxAttribute,
date: DateAttribute,
default: OtherAttribute,
};
const getAttributeComponent = attributeType =>
componentMap[attributeType] || componentMap.default;
const orderKey = computed(
() => `conversation_elements_order_${props.attributeFrom}`
);
const combinedElements = computed(() => {
// Get saved order from UI settings
const orderedCustomAttributes = computed(() => {
const savedOrder = uiSettings.value[orderKey.value] ?? [];
const allElements = [
...props.staticElements,
...filteredCustomAttributes.value,
];
if (!savedOrder.length) return filteredCustomAttributes.value;
// If no saved order exists, return in default order
if (!savedOrder.length) return allElements;
return allElements.sort((a, b) => {
// Find positions of elements in saved order
const aPosition = savedOrder.indexOf(a.key);
const bPosition = savedOrder.indexOf(b.key);
// Handle cases where elements are not in saved order:
// - New elements (not in saved order) go to the end
// - If both elements are new, maintain their relative order
if (aPosition === -1 && bPosition === -1) return 0;
if (aPosition === -1) return 1;
if (bPosition === -1) return -1;
return aPosition - bPosition;
return [...filteredCustomAttributes.value].sort((a, b) => {
const aPos = savedOrder.indexOf(a.key);
const bPos = savedOrder.indexOf(b.key);
// Both new: maintain relative order, new items go to end, otherwise sort by saved position
if (aPos === -1 && bPos === -1) return 0;
if (aPos === -1) return 1;
if (bPos === -1) return -1;
return aPos - bPos;
});
});
const displayedElements = computed(() => {
if (showAllAttributes.value || combinedElements.value.length <= 5) {
return combinedElements.value;
}
const displayedCustomAttributes = computed(() =>
showAllAttributes.value || orderedCustomAttributes.value.length <= 5
? orderedCustomAttributes.value
: orderedCustomAttributes.value.slice(0, 5)
);
// Show first 5 elements in the order they appear
return combinedElements.value.slice(0, 5);
const localOrder = ref([]);
const draggableList = computed({
get() {
const saved = uiSettings.value[orderKey.value] ?? [];
if (localOrder.value.length && saved.length) {
return localOrder.value;
}
return displayedCustomAttributes.value;
},
set(newOrder) {
localOrder.value = newOrder;
},
});
// Reorder elements with static elements position preserved
// There is case where all the static elements will not be available (API, Email channels, etc).
// In that case, we need to preserve the order of the static elements and
// insert them in the correct position.
const reorderElementsWithStaticPreservation = (
savedOrder = [],
currentOrder = []
) => {
const finalOrder = [...currentOrder];
const visibleKeys = new Set(currentOrder);
// Process hidden static elements from saved order
savedOrder
// Find static elements that aren't currently visible
.filter(key => key.startsWith('static-') && !visibleKeys.has(key))
.forEach(staticKey => {
// Find next visible element after this static element in saved order
const nextVisible = savedOrder
.slice(savedOrder.indexOf(staticKey))
.find(key => visibleKeys.has(key));
// If next visible element found, insert before it; otherwise add to end
if (nextVisible) {
finalOrder.splice(finalOrder.indexOf(nextVisible), 0, staticKey);
} else {
finalOrder.push(staticKey);
}
});
return finalOrder;
};
const onDragEnd = () => {
dragging.value = false;
// Get the saved and current saved order
const savedOrder = uiSettings.value[orderKey.value] ?? [];
const currentOrder = combinedElements.value.map(({ key }) => key);
const finalOrder = reorderElementsWithStaticPreservation(
savedOrder,
currentOrder
);
updateUISettings({
[orderKey.value]: finalOrder,
[orderKey.value]: localOrder.value.map(({ key }) => key),
});
localOrder.value = [];
};
const initializeSettings = () => {
const currentOrder = uiSettings.value[orderKey.value];
if (!currentOrder) {
const initialOrder = combinedElements.value.map(element => element.key);
if (!uiSettings.value[orderKey.value]) {
updateUISettings({
[orderKey.value]: initialOrder,
[orderKey.value]: orderedCustomAttributes.value.map(({ key }) => key),
});
}
@@ -209,7 +186,7 @@ const onUpdate = async (key, value) => {
customAttributes: updatedAttributes,
});
} else {
store.dispatch('contacts/update', {
await store.dispatch('contacts/update', {
id: props.contactId,
customAttributes: updatedAttributes,
});
@@ -231,7 +208,7 @@ const onDelete = async key => {
customAttributes: updatedAttributes,
});
} else {
store.dispatch('contacts/deleteCustomAttributes', {
await store.dispatch('contacts/deleteCustomAttributes', {
id: props.contactId,
customAttributes: [key],
});
@@ -244,89 +221,89 @@ const onDelete = async key => {
}
};
const onCopy = async attributeValue => {
await copyTextToClipboard(attributeValue);
useAlert(t('CUSTOM_ATTRIBUTES.COPY_SUCCESSFUL'));
};
onMounted(() => {
initializeSettings();
});
const evenClass = [
'[&>*:nth-child(odd)]:!bg-n-surface-1 [&>*:nth-child(even)]:!bg-n-slate-1',
'dark:[&>*:nth-child(odd)]:!bg-n-surface-2 dark:[&>*:nth-child(even)]:!bg-n-surface-1',
];
</script>
<template>
<div class="conversation--details">
<div
v-if="displayedCustomAttributes.length > 0"
:class="{ 'mt-2': !showTitle }"
>
<div v-if="showTitle" class="py-4 flex items-center gap-2">
<h3 class="text-xs font-medium uppercase text-n-slate-10">
{{ $t('CUSTOM_ATTRIBUTES.TITLE') }}
</h3>
<div class="flex-1 border-b border-dashed border-n-strong" />
</div>
<Draggable
:list="displayedElements"
v-model="draggableList"
:disabled="!showAllAttributes"
animation="200"
ghost-class="ghost"
handle=".drag-handle"
item-key="key"
class="last:rounded-b-lg"
:class="evenClass"
class="mb-1"
@start="dragging = true"
@end="onDragEnd"
>
<template #item="{ element }">
<div
class="drag-handle relative border-b border-n-weak/50 dark:border-n-weak/90"
class="drag-handle relative"
:class="{
'cursor-grab': showAllAttributes,
'last:border-transparent dark:last:border-transparent':
combinedElements.length <= 5,
}"
>
<template v-if="element.type === 'static_attribute'">
<slot name="staticItem" :element="element" />
</template>
<div
class="grid grid-cols-[140px,1fr] group/attribute items-center w-full gap-2 min-h-10"
>
<div class="flex items-center gap-1.5 min-w-0">
<HelperTextPopup
v-if="element.attributeDescription"
:message="element.attributeDescription"
/>
<span class="text-sm font-420 truncate text-n-slate-12">
{{ element.attributeDisplayName }}
</span>
</div>
<template v-else>
<CustomAttribute
:key="element.id"
:attribute-key="element.attribute_key"
:attribute-type="element.attribute_display_type"
:values="element.attribute_values"
:label="element.attribute_display_name"
:description="element.attribute_description"
:value="element.value"
show-actions
:attribute-regex="element.regex_pattern"
:regex-cue="element.regex_cue"
:contact-id="contactId"
@update="onUpdate"
@delete="onDelete"
@copy="onCopy"
<component
:is="getAttributeComponent(element.attributeDisplayType)"
:attribute="element"
is-editing-view
@update="value => onUpdate(element.attributeKey, value)"
@delete="onDelete(element.attributeKey)"
/>
</template>
</div>
</div>
</template>
</Draggable>
<p
v-if="!displayedElements.length && emptyStateMessage"
class="p-3 text-center"
<!-- Show more and show less buttons -->
<div
v-if="orderedCustomAttributes.length > 5"
class="flex items-center h-10"
>
{{ emptyStateMessage }}
</p>
<!-- Show more and show less buttons show it if the combinedElements length is greater than 5 -->
<div v-if="combinedElements.length > 5" class="flex items-center px-2 py-2">
<NextButton
ghost
xs
link
sm
:icon="
showAllAttributes ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'
"
:label="toggleButtonText"
class="!text-n-slate-11 hover:!text-n-slate-12 hover:!no-underline !py-2"
@click="onClickToggle"
/>
</div>
</div>
<!-- Empty state -->
<SidePanelEmptyState
v-if="!displayedCustomAttributes.length && emptyStateMessage"
:message="emptyStateMessage"
/>
</template>
<style lang="scss" scoped>
@@ -1,139 +0,0 @@
<script>
import { ref } from 'vue';
import { mapGetters } from 'vuex';
import { useAdmin } from 'dashboard/composables/useAdmin';
import { useConversationLabels } from 'dashboard/composables/useConversationLabels';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import Spinner from 'shared/components/Spinner.vue';
import LabelDropdown from 'shared/components/ui/label/LabelDropdown.vue';
import AddLabel from 'shared/components/ui/dropdown/AddLabel.vue';
export default {
components: {
Spinner,
LabelDropdown,
AddLabel,
},
setup() {
const { isAdmin } = useAdmin();
const {
savedLabels,
activeLabels,
accountLabels,
addLabelToConversation,
removeLabelFromConversation,
} = useConversationLabels();
const showSearchDropdownLabel = ref(false);
const toggleLabels = () => {
showSearchDropdownLabel.value = !showSearchDropdownLabel.value;
};
const closeDropdownLabel = () => {
showSearchDropdownLabel.value = false;
};
const keyboardEvents = {
KeyL: {
action: e => {
e.preventDefault();
toggleLabels();
},
},
Escape: {
action: () => {
if (showSearchDropdownLabel.value) {
toggleLabels();
}
},
allowOnFocusedInput: true,
},
};
useKeyboardEvents(keyboardEvents);
return {
isAdmin,
savedLabels,
activeLabels,
accountLabels,
addLabelToConversation,
removeLabelFromConversation,
showSearchDropdownLabel,
closeDropdownLabel,
toggleLabels,
};
},
data() {
return {
selectedLabels: [],
};
},
computed: {
...mapGetters({
conversationUiFlags: 'conversationLabels/getUIFlags',
}),
},
};
</script>
<template>
<div class="sidebar-labels-wrap">
<div
v-if="!conversationUiFlags.isFetching"
class="contact-conversation--list"
>
<div
v-on-clickaway="closeDropdownLabel"
class="label-wrap flex flex-wrap"
@keyup.esc="closeDropdownLabel"
>
<AddLabel @add="toggleLabels" />
<woot-label
v-for="label in activeLabels"
:key="label.id"
:title="label.title"
:description="label.description"
show-close
:color="label.color"
variant="smooth"
class="max-w-[calc(100%-0.5rem)]"
@remove="removeLabelFromConversation"
/>
<div
:class="{
'block visible': showSearchDropdownLabel,
'hidden invisible': !showSearchDropdownLabel,
}"
class="border rounded-lg bg-n-alpha-3 top-6 backdrop-blur-[100px] absolute w-full shadow-lg border-n-strong dark:border-n-strong p-2 box-border z-[9999]"
>
<LabelDropdown
v-if="showSearchDropdownLabel"
:account-labels="accountLabels"
:selected-labels="savedLabels"
:allow-creation="isAdmin"
@add="addLabelToConversation"
@remove="removeLabelFromConversation"
/>
</div>
</div>
</div>
<Spinner v-else />
</div>
</template>
<style lang="scss" scoped>
.sidebar-labels-wrap {
margin-bottom: 0;
}
.contact-conversation--list {
width: 100%;
.label-wrap {
line-height: 1.5rem;
position: relative;
}
}
</style>
@@ -153,9 +153,8 @@ export default {
attribute_display_type: this.attributeType,
attribute_key: this.attributeKey,
attribute_values: this.attributeListValues,
regex_pattern: this.regexPattern
? new RegExp(this.regexPattern).toString()
: null,
// Store as plain string - new RegExp().toString() causes double escaping
regex_pattern: this.regexPattern || null,
regex_cue: this.regexCue,
});
this.alertMessage = this.$t('ATTRIBUTES_MGMT.ADD.API.SUCCESS_MESSAGE');
@@ -151,9 +151,8 @@ export default {
attribute_description: this.description,
attribute_display_name: this.displayName,
attribute_values: this.updatedAttributeListValues,
regex_pattern: this.regexPattern
? new RegExp(this.regexPattern).toString()
: null,
// Store as plain string - new RegExp().toString() causes double escaping
regex_pattern: this.regexPattern || null,
regex_cue: this.regexCue,
});
this.alertMessage = this.$t('ATTRIBUTES_MGMT.EDIT.API.SUCCESS_MESSAGE');