Compare commits

...
5 Commits
12 changed files with 449 additions and 316 deletions
@@ -1,8 +1,7 @@
<script setup>
import { computed, useSlots, ref } from 'vue';
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import { vOnClickOutside } from '@vueuse/components';
import Button from 'dashboard/components-next/button/Button.vue';
import Breadcrumb from 'dashboard/components-next/breadcrumb/Breadcrumb.vue';
@@ -23,11 +22,8 @@ const props = defineProps({
const emit = defineEmits(['goToContactsList', 'toggleBlock']);
const { t } = useI18n();
const slots = useSlots();
const route = useRoute();
const isContactSidebarOpen = ref(false);
const contactId = computed(() => route.params.contactId);
const selectedContactName = computed(() => {
@@ -60,26 +56,15 @@ const handleBreadcrumbClick = () => {
const toggleBlock = () => {
emit('toggleBlock', isContactBlocked.value);
};
const handleConversationSidebarToggle = () => {
isContactSidebarOpen.value = !isContactSidebarOpen.value;
};
const closeMobileSidebar = () => {
if (!isContactSidebarOpen.value) return;
isContactSidebarOpen.value = false;
};
</script>
<template>
<section
class="flex w-full h-full overflow-hidden justify-evenly bg-n-surface-1"
>
<div
class="flex flex-col w-full h-full transition-all duration-300 ltr:2xl:ml-56 rtl:2xl:mr-56"
>
<div class="flex flex-col w-full h-full transition-all duration-300">
<header class="sticky top-0 z-10 px-6 3xl:px-0">
<div class="w-full mx-auto max-w-[40.625rem]">
<div class="w-full mx-auto max-w-5xl">
<div
class="flex flex-col xs:flex-row items-start xs:items-center justify-between w-full py-7 gap-2"
>
@@ -119,81 +104,10 @@ const closeMobileSidebar = () => {
</div>
</header>
<main class="flex-1 px-6 overflow-y-auto 3xl:px-px">
<div class="w-full py-4 mx-auto max-w-[40.625rem]">
<div class="w-full py-4 mx-auto max-w-5xl">
<slot name="default" />
</div>
</main>
</div>
<!-- Desktop sidebar -->
<div
v-if="slots.sidebar"
class="hidden lg:flex flex-col min-w-52 w-full max-w-md border-l border-n-weak bg-n-solid-2"
>
<div class="shrink-0">
<slot name="sidebarHeader" />
</div>
<div class="flex-1 min-h-0 overflow-y-auto pb-6 pt-3">
<slot name="sidebar" />
</div>
</div>
<!-- Mobile sidebar container -->
<div
v-if="slots.sidebar"
class="lg:hidden fixed top-0 ltr:right-0 rtl:left-0 h-full z-50 flex justify-end transition-all duration-200 ease-in-out"
:class="isContactSidebarOpen ? 'w-full' : 'w-16'"
>
<!-- Toggle button -->
<div
v-on-click-outside="[
closeMobileSidebar,
{ ignore: ['#contact-sidebar-content'] },
]"
class="flex items-start p-1 w-fit h-fit relative order-1 xs:top-24 top-28 transition-all bg-n-solid-2 border border-n-weak duration-500 ease-in-out"
:class="[
isContactSidebarOpen
? 'justify-end ltr:rounded-l-full rtl:rounded-r-full ltr:rounded-r-none rtl:rounded-l-none'
: 'justify-center rounded-full ltr:mr-6 rtl:ml-6',
]"
>
<Button
ghost
slate
sm
class="!rounded-full rtl:rotate-180"
:class="{ 'bg-n-alpha-2': isContactSidebarOpen }"
:icon="
isContactSidebarOpen
? 'i-lucide-panel-right-close'
: 'i-lucide-panel-right-open'
"
data-contact-sidebar-toggle
@click="handleConversationSidebarToggle"
/>
</div>
<Transition
enter-active-class="transition-transform duration-200 ease-in-out"
leave-active-class="transition-transform duration-200 ease-in-out"
enter-from-class="ltr:translate-x-full rtl:-translate-x-full"
enter-to-class="ltr:translate-x-0 rtl:-translate-x-0"
leave-from-class="ltr:translate-x-0 rtl:-translate-x-0"
leave-to-class="ltr:translate-x-full rtl:-translate-x-full"
>
<div
v-if="isContactSidebarOpen"
id="contact-sidebar-content"
class="order-2 w-[85%] sm:w-[50%] flex flex-col bg-n-solid-2 ltr:border-l rtl:border-r border-n-weak shadow-lg"
>
<div class="shrink-0">
<slot name="sidebarHeader" />
</div>
<div class="flex-1 min-h-0 overflow-y-auto pb-6 pt-3">
<slot name="sidebar" />
</div>
</div>
</Transition>
</div>
</section>
</template>
@@ -5,6 +5,7 @@ import { useMapGetter } from 'dashboard/composables/store';
import { useUISettings } from 'dashboard/composables/useUISettings';
import ContactCustomAttributeItem from 'dashboard/components-next/Contacts/ContactsSidebar/ContactCustomAttributeItem.vue';
import ContactSidebarSection from 'dashboard/components-next/Contacts/ContactsSidebar/ContactSidebarSection.vue';
const props = defineProps({
selectedContact: {
@@ -108,8 +109,12 @@ const hasNoUsedAttributes = computed(() => usedAttributes.value.length === 0);
</script>
<template>
<div v-if="hasContactAttributes" class="flex flex-col gap-6 px-6">
<div v-if="!hasNoUsedAttributes" class="flex flex-col gap-2">
<ContactSidebarSection
v-if="hasContactAttributes"
:title="t('CONTACTS_LAYOUT.SIDEBAR.TABS.ATTRIBUTES')"
body-class="p-0"
>
<div v-if="!hasNoUsedAttributes" class="flex flex-col px-4 py-1.5">
<ContactCustomAttributeItem
v-for="attribute in usedAttributes"
:key="attribute.id"
@@ -117,36 +122,43 @@ const hasNoUsedAttributes = computed(() => usedAttributes.value.length === 0);
:attribute="attribute"
/>
</div>
<div v-if="!hasNoUnusedAttributes" class="flex items-center gap-3">
<div class="flex-1 h-[1px] bg-n-slate-5" />
<span class="text-sm font-medium text-n-slate-10">{{
t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.UNUSED_ATTRIBUTES', {
count: unusedAttributesCount,
})
}}</span>
<div class="flex-1 h-[1px] bg-n-slate-5" />
</div>
<div class="flex flex-col gap-3">
<div v-if="!hasNoUnusedAttributes" class="relative">
<span class="absolute i-lucide-search size-3.5 top-2 left-3" />
<div
v-if="!hasNoUnusedAttributes"
class="flex flex-col gap-3 px-4 py-4"
:class="{ 'border-t border-n-weak': !hasNoUsedAttributes }"
>
<span
class="text-xs font-semibold tracking-wider uppercase text-n-slate-10"
>
{{
t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.UNUSED_ATTRIBUTES', {
count: unusedAttributesCount,
})
}}
</span>
<div class="relative">
<span
class="absolute i-lucide-search size-3.5 top-2.5 left-3 text-n-slate-10"
/>
<input
v-model="searchQuery"
type="search"
:placeholder="
t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.SEARCH_PLACEHOLDER')
"
class="w-full h-8 py-2 pl-10 pr-2 text-sm reset-base outline-none border-none rounded-lg bg-n-alpha-black2 dark:bg-n-solid-1 text-n-slate-12"
class="w-full h-8 py-2 pl-10 pr-2 text-sm border outline-none reset-base rounded-lg border-n-weak bg-n-alpha-black2 dark:bg-n-solid-2 text-n-slate-12"
/>
</div>
<div
v-if="filteredUnusedAttributes.length === 0 && !hasNoUnusedAttributes"
v-if="filteredUnusedAttributes.length === 0"
class="flex items-center justify-start h-11"
>
<p class="text-sm text-n-slate-11">
{{ t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.NO_ATTRIBUTES') }}
</p>
</div>
<div v-if="!hasNoUnusedAttributes" class="flex flex-col gap-2">
<div v-else class="flex flex-col">
<ContactCustomAttributeItem
v-for="attribute in filteredUnusedAttributes"
:key="attribute.id"
@@ -154,7 +166,7 @@ const hasNoUsedAttributes = computed(() => usedAttributes.value.length === 0);
/>
</div>
</div>
</div>
</ContactSidebarSection>
<p v-else class="px-6 py-10 text-sm leading-6 text-center text-n-slate-11">
{{ t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.EMPTY_STATE') }}
</p>
@@ -6,6 +6,7 @@ import { useI18n } from 'vue-i18n';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import ConversationCard from 'dashboard/components-next/Conversation/ConversationCard/ConversationCard.vue';
import ContactSidebarSection from 'dashboard/components-next/Contacts/ContactsSidebar/ContactSidebarSection.vue';
const { t } = useI18n();
const route = useRoute();
@@ -34,19 +35,24 @@ const contactConversations = computed(() =>
>
<Spinner />
</div>
<div
v-else-if="contactConversations.length > 0"
class="px-6 divide-y divide-n-strong [&>*:hover]:!border-y-transparent [&>*:hover+*]:!border-t-transparent"
>
<ConversationCard
v-for="conversation in contactConversations"
:key="conversation.id"
:conversation="conversation"
:contact="contactsById(conversation.meta.sender.id)"
:state-inbox="stateInbox(conversation.inboxId)"
:account-labels="accountLabelsValue"
class="rounded-none hover:rounded-xl hover:bg-n-alpha-1 dark:hover:bg-n-alpha-3"
/>
<div v-else-if="contactConversations.length > 0">
<ContactSidebarSection
:title="t('CONTACTS_LAYOUT.SIDEBAR.TABS.HISTORY')"
:count="contactConversations.length"
body-class="p-1.5 max-h-[60vh] overflow-y-auto"
>
<div class="flex flex-col">
<ConversationCard
v-for="conversation in contactConversations"
:key="conversation.id"
:conversation="conversation"
:contact="contactsById(conversation.meta.sender.id)"
:state-inbox="stateInbox(conversation.inboxId)"
:account-labels="accountLabelsValue"
class="border-0 rounded-xl hover:bg-n-alpha-1 dark:hover:bg-n-alpha-3"
/>
</div>
</ContactSidebarSection>
</div>
<p v-else class="px-6 py-10 text-sm leading-6 text-center text-n-slate-11">
{{ t('CONTACTS_LAYOUT.SIDEBAR.HISTORY.EMPTY_STATE') }}
@@ -12,9 +12,10 @@ import GalleryView from 'dashboard/components/widgets/conversation/components/Ga
import Media from 'dashboard/components-next/SharedAttachments/Media.vue';
import Files from 'dashboard/components-next/SharedAttachments/Files.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import ContactSidebarSection from 'dashboard/components-next/Contacts/ContactsSidebar/ContactSidebarSection.vue';
const MEDIA_PEEK_LIMIT = 12;
const FILES_PEEK_LIMIT = 6;
const MEDIA_PEEK_LIMIT = 10;
const FILES_PEEK_LIMIT = 4;
const route = useRoute();
const router = useRouter();
@@ -73,17 +74,21 @@ onMounted(() => {
</script>
<template>
<div class="px-6">
<div>
<div v-if="isFetching" class="flex justify-center p-3">
<Spinner class="size-5" />
</div>
<p v-else-if="!hasContent" class="p-3 text-sm text-center text-n-slate-11">
{{ t('CONVERSATION_SIDEBAR.SHARED_FILES.EMPTY') }}
</p>
<div v-else class="flex flex-col gap-5">
<ContactSidebarSection
v-else
body-class="flex flex-col gap-5 px-4 py-4 max-h-[60vh] overflow-y-auto"
>
<Media
:attachments="attachments"
:peek-limit="MEDIA_PEEK_LIMIT"
:columns="5"
show-jump-to-message
@select="onMediaSelect"
@jump-to-message="onJumpToMessage"
@@ -95,7 +100,7 @@ onMounted(() => {
@select="onFileSelect"
@jump-to-message="onJumpToMessage"
/>
</div>
</ContactSidebarSection>
<GalleryView
v-if="showGallery && selectedAttachment"
v-model:show="showGallery"
@@ -12,6 +12,7 @@ import { CONTACTS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import Button from 'dashboard/components-next/button/Button.vue';
import ContactMergeForm from 'dashboard/components-next/Contacts/ContactsForm/ContactMergeForm.vue';
import ContactSidebarSection from 'dashboard/components-next/Contacts/ContactsSidebar/ContactSidebarSection.vue';
const props = defineProps({
selectedContact: {
@@ -103,43 +104,44 @@ const onMergeContacts = async () => {
</script>
<template>
<div class="flex flex-col gap-8 px-6">
<div class="flex flex-col gap-2">
<h4 class="text-base text-n-slate-12">
{{ t('CONTACTS_LAYOUT.SIDEBAR.MERGE.TITLE') }}
</h4>
<div>
<ContactSidebarSection
:title="t('CONTACTS_LAYOUT.SIDEBAR.MERGE.TITLE')"
body-class="flex flex-col gap-5 px-4 py-4"
>
<p class="text-sm text-n-slate-11">
{{ t('CONTACTS_LAYOUT.SIDEBAR.MERGE.DESCRIPTION') }}
</p>
</div>
<ContactMergeForm
v-model:primary-contact-id="state.primaryContactId"
:selected-contact="selectedContact"
:primary-contact-list="primaryContactList"
:is-searching="isSearching"
:has-error="!!v$.primaryContactId.$error"
:error-message="
v$.primaryContactId.$error
? t('CONTACTS_LAYOUT.SIDEBAR.MERGE.PRIMARY_REQUIRED_ERROR')
: ''
"
@search="onContactSearch"
/>
<div class="flex items-center justify-between gap-3">
<Button
variant="faded"
color="slate"
:label="t('CONTACTS_LAYOUT.SIDEBAR.MERGE.BUTTONS.CANCEL')"
class="w-full bg-n-alpha-2 text-n-blue-11 hover:bg-n-alpha-3"
@click="resetState"
<ContactMergeForm
v-model:primary-contact-id="state.primaryContactId"
:selected-contact="selectedContact"
:primary-contact-list="primaryContactList"
:is-searching="isSearching"
:has-error="!!v$.primaryContactId.$error"
:error-message="
v$.primaryContactId.$error
? t('CONTACTS_LAYOUT.SIDEBAR.MERGE.PRIMARY_REQUIRED_ERROR')
: ''
"
@search="onContactSearch"
/>
<Button
:label="t('CONTACTS_LAYOUT.SIDEBAR.MERGE.BUTTONS.CONFIRM')"
class="w-full"
:is-loading="isMergingContact"
:disabled="isMergingContact"
@click="onMergeContacts"
/>
</div>
<div class="flex items-center justify-end gap-3">
<Button
variant="faded"
color="slate"
size="sm"
:label="t('CONTACTS_LAYOUT.SIDEBAR.MERGE.BUTTONS.CANCEL')"
class="bg-n-alpha-2 text-n-blue-11 hover:bg-n-alpha-3"
@click="resetState"
/>
<Button
:label="t('CONTACTS_LAYOUT.SIDEBAR.MERGE.BUTTONS.CONFIRM')"
size="sm"
:is-loading="isMergingContact"
:disabled="isMergingContact"
@click="onMergeContacts"
/>
</div>
</ContactSidebarSection>
</div>
</template>
@@ -1,5 +1,5 @@
<script setup>
import { reactive, computed } from 'vue';
import { reactive, computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useRoute } from 'vue-router';
@@ -8,6 +8,7 @@ import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import Editor from 'dashboard/components-next/Editor/Editor.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import ContactSidebarSection from 'dashboard/components-next/Contacts/ContactsSidebar/ContactSidebarSection.vue';
import ContactNoteItem from './components/ContactNoteItem.vue';
const { t } = useI18n();
@@ -25,6 +26,16 @@ const isFetchingNotes = computed(() => uiFlags.value.isFetching);
const isCreatingNote = computed(() => uiFlags.value.isCreating);
const notes = computed(() => notesByContact.value(route.params.contactId));
const searchQuery = ref('');
const filteredNotes = computed(() => {
const query = searchQuery.value.trim().toLowerCase();
if (!query) return notes.value;
return notes.value.filter(note =>
(note.content || '').toLowerCase().includes(query)
);
});
const getWrittenBy = note => {
const isCurrentUser = note?.user?.id === currentUser.value.id;
return isCurrentUser
@@ -55,47 +66,73 @@ useKeyboardEvents(keyboardEvents);
</script>
<template>
<div class="flex flex-col gap-6">
<Editor
v-model="state.message"
:placeholder="t('CONTACTS_LAYOUT.SIDEBAR.NOTES.PLACEHOLDER')"
focus-on-mount
class="[&>div]:!border-transparent [&>div]:px-4 [&>div]:py-4 px-6"
>
<template #actions>
<div class="flex items-center gap-3">
<Button
variant="link"
color="blue"
size="sm"
:label="t('CONTACTS_LAYOUT.SIDEBAR.NOTES.SAVE')"
class="hover:no-underline"
:is-loading="isCreatingNote"
:disabled="!state.message || isCreatingNote"
@click="onAdd(state.message)"
/>
</div>
</template>
</Editor>
<ContactSidebarSection
:title="t('CONTACTS_LAYOUT.PROFILE.SECTIONS.NOTES')"
:count="notes.length || null"
body-class="p-0"
>
<div class="border-b border-n-weak">
<Editor
v-model="state.message"
:placeholder="t('CONTACTS_LAYOUT.SIDEBAR.NOTES.PLACEHOLDER')"
class="[&>div]:!border-transparent [&>div]:!bg-transparent [&>div]:px-4 [&>div]:py-3"
>
<template #actions>
<div class="flex items-center gap-3">
<Button
variant="link"
color="blue"
size="sm"
:label="t('CONTACTS_LAYOUT.SIDEBAR.NOTES.SAVE')"
class="hover:no-underline"
:is-loading="isCreatingNote"
:disabled="!state.message || isCreatingNote"
@click="onAdd(state.message)"
/>
</div>
</template>
</Editor>
</div>
<div
v-if="isFetchingNotes"
class="flex items-center justify-center py-10 text-n-slate-11"
>
<Spinner />
</div>
<div v-else-if="notes.length > 0">
<ContactNoteItem
v-for="note in notes"
:key="note.id"
class="mx-6 py-4"
:note="note"
:written-by="getWrittenBy(note)"
allow-delete
@delete="onDelete"
/>
</div>
<p v-else class="px-6 py-6 text-sm leading-6 text-center text-n-slate-11">
<template v-else-if="notes.length > 0">
<div class="px-4 py-3 border-b border-n-weak">
<div class="relative">
<span
class="absolute i-lucide-search size-3.5 top-2.5 left-3 text-n-slate-10"
/>
<input
v-model="searchQuery"
type="search"
:placeholder="t('CONTACTS_LAYOUT.SIDEBAR.NOTES.SEARCH_PLACEHOLDER')"
class="w-full h-8 py-2 pl-10 pr-2 text-sm border outline-none reset-base rounded-lg border-n-weak bg-n-alpha-black2 dark:bg-n-solid-2 text-n-slate-12"
/>
</div>
</div>
<div class="px-4 max-h-[60vh] overflow-y-auto [&>div]:!border-b-0">
<ContactNoteItem
v-for="note in filteredNotes"
:key="note.id"
class="py-3.5"
:note="note"
:written-by="getWrittenBy(note)"
allow-delete
@delete="onDelete"
/>
<p
v-if="filteredNotes.length === 0"
class="py-6 text-sm leading-6 text-center text-n-slate-11"
>
{{ t('CONTACTS_LAYOUT.SIDEBAR.NOTES.NO_RESULTS') }}
</p>
</div>
</template>
<p v-else class="px-4 py-6 text-sm leading-6 text-center text-n-slate-11">
{{ t('CONTACTS_LAYOUT.SIDEBAR.NOTES.EMPTY_STATE') }}
</p>
</div>
</ContactSidebarSection>
</template>
@@ -0,0 +1,43 @@
<script setup>
defineProps({
title: {
type: String,
default: '',
},
count: {
type: [Number, String],
default: null,
},
bodyClass: {
type: String,
default: 'px-4 py-4',
},
});
</script>
<template>
<section
class="flex flex-col overflow-hidden border rounded-2xl border-n-weak bg-n-solid-1"
>
<header
v-if="title || $slots.action"
class="flex items-center justify-between gap-2 px-5 py-4 border-b border-n-weak"
>
<h4
class="text-xs font-semibold tracking-wider uppercase text-n-slate-10"
>
{{ title }}
<span
v-if="count !== null && count !== ''"
class="font-medium tracking-normal normal-case ms-1 text-n-slate-10"
>
{{ count }}
</span>
</h4>
<slot name="action" />
</header>
<div :class="bodyClass">
<slot />
</div>
</section>
</template>
@@ -12,6 +12,15 @@ import ContactsForm from 'dashboard/components-next/Contacts/ContactsForm/Contac
import ConfirmContactDeleteDialog from 'dashboard/components-next/Contacts/ContactsForm/ConfirmContactDeleteDialog.vue';
import Policy from 'dashboard/components/policy.vue';
import TabBar from 'dashboard/components-next/tabbar/TabBar.vue';
import ContactStats from 'dashboard/components-next/Contacts/Pages/ContactStats.vue';
import ContactSidebarSection from 'dashboard/components-next/Contacts/ContactsSidebar/ContactSidebarSection.vue';
import ContactCustomAttributes from 'dashboard/components-next/Contacts/ContactsSidebar/ContactCustomAttributes.vue';
import ContactHistory from 'dashboard/components-next/Contacts/ContactsSidebar/ContactHistory.vue';
import ContactNotes from 'dashboard/components-next/Contacts/ContactsSidebar/ContactNotes.vue';
import ContactMedia from 'dashboard/components-next/Contacts/ContactsSidebar/ContactMedia.vue';
import ContactMerge from 'dashboard/components-next/Contacts/ContactsSidebar/ContactMerge.vue';
const props = defineProps({
selectedContact: {
type: Object,
@@ -38,6 +47,34 @@ const isFormInvalid = computed(() => contactsFormRef.value?.isFormInvalid);
const contactData = ref({});
const metaSeparator = '•';
const contactId = computed(() => props.selectedContact?.id);
const CONTACT_SECTIONS = [
{ value: 'details', label: 'CONTACTS_LAYOUT.PROFILE.SECTIONS.DETAILS' },
{ value: 'attributes', label: 'CONTACTS_LAYOUT.SIDEBAR.TABS.ATTRIBUTES' },
{ value: 'history', label: 'CONTACTS_LAYOUT.PROFILE.STATS.CONVERSATIONS' },
{ value: 'notes', label: 'CONTACTS_LAYOUT.PROFILE.STATS.NOTES' },
{ value: 'media', label: 'CONTACTS_LAYOUT.PROFILE.STATS.FILES' },
{ value: 'merge', label: 'CONTACTS_LAYOUT.SIDEBAR.TABS.MERGE' },
];
const activeSection = ref('details');
const sectionTabs = computed(() =>
CONTACT_SECTIONS.map(section => ({ label: t(section.label) }))
);
const activeSectionIndex = computed(() =>
CONTACT_SECTIONS.findIndex(section => section.value === activeSection.value)
);
const handleSectionChange = tab => {
const index = sectionTabs.value.findIndex(item => item.label === tab.label);
if (index !== -1) activeSection.value = CONTACT_SECTIONS[index].value;
};
const getInitialContactData = () => {
if (!props.selectedContact) return {};
return { ...props.selectedContact };
@@ -45,6 +82,9 @@ const getInitialContactData = () => {
onMounted(() => {
Object.assign(contactData.value, getInitialContactData());
if (contactId.value) {
store.dispatch('contacts/fetchAttachments', contactId.value);
}
});
const createdAt = computed(() => {
@@ -121,8 +161,8 @@ const handleAvatarDelete = async () => {
</script>
<template>
<div class="flex flex-col items-start gap-8 pb-6">
<div class="flex flex-col items-start gap-3">
<div class="flex flex-col gap-8 pb-10">
<div class="flex items-start gap-5 min-w-0">
<Avatar
:src="avatarSrc || ''"
:name="selectedContact?.name || ''"
@@ -131,73 +171,112 @@ const handleAvatarDelete = async () => {
@upload="handleAvatarUpload"
@delete="handleAvatarDelete"
/>
<div class="flex flex-col gap-1">
<h3 class="text-base font-medium text-n-slate-12">
<div class="flex flex-col gap-2 min-w-0">
<h1
class="text-xl font-semibold leading-tight tracking-tight truncate text-n-slate-12"
>
{{ selectedContact?.name }}
</h3>
<div class="flex flex-col gap-1.5">
</h1>
<div
class="flex flex-wrap items-center text-sm gap-x-2 gap-y-1 text-n-slate-11"
>
<span
v-if="selectedContact?.identifier"
class="inline-flex items-center gap-1 text-sm text-n-slate-11"
class="inline-flex items-center gap-1"
>
<span class="i-ph-user-gear text-n-slate-10 size-4" />
{{ selectedContact?.identifier }}
</span>
<span class="inline-flex items-center gap-1 text-sm text-n-slate-11">
<span
v-if="selectedContact?.identifier"
class="i-ph-activity text-n-slate-10 size-4"
/>
{{ $t('CONTACTS_LAYOUT.DETAILS.CREATED_AT', { date: createdAt }) }}
<span v-if="selectedContact?.identifier" class="text-n-slate-8">
{{ metaSeparator }}
</span>
<span>
{{ t('CONTACTS_LAYOUT.DETAILS.CREATED_AT', { date: createdAt }) }}
</span>
<span class="text-n-slate-8">{{ metaSeparator }}</span>
<span>
{{
$t('CONTACTS_LAYOUT.DETAILS.LAST_ACTIVITY', {
t('CONTACTS_LAYOUT.DETAILS.LAST_ACTIVITY', {
date: lastActivityAt,
})
}}
</span>
</div>
<ContactLabels :contact-id="selectedContact?.id" />
</div>
<ContactLabels :contact-id="selectedContact?.id" />
</div>
<div class="flex flex-col items-start gap-6">
<ContactsForm
ref="contactsFormRef"
:contact-data="contactData"
is-details-view
@update="handleFormUpdate"
<ContactStats :contact-id="contactId" :last-seen="lastActivityAt" />
<div class="flex flex-col gap-4">
<TabBar
:tabs="sectionTabs"
:initial-active-tab="activeSectionIndex"
class="max-w-full bg-n-alpha-black2"
@tab-changed="handleSectionChange"
/>
<Button
:label="t('CONTACTS_LAYOUT.CARD.EDIT_DETAILS_FORM.UPDATE_BUTTON')"
size="sm"
:is-loading="isUpdating"
:disabled="isUpdating || isFormInvalid"
@click="updateContact"
/>
</div>
<Policy :permissions="['administrator']">
<div
class="flex flex-col items-start w-full gap-4 pt-6 border-t border-n-strong"
>
<div class="flex flex-col gap-2">
<h6 class="text-base font-medium text-n-slate-12">
{{ t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT') }}
</h6>
<span class="text-sm text-n-slate-11">
{{ t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT_DESCRIPTION') }}
</span>
</div>
<Button
:label="t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT')"
color="ruby"
@click="openConfirmDeleteContactDialog"
<div>
<template v-if="activeSection === 'details'">
<div class="flex flex-col gap-4">
<ContactSidebarSection
:title="t('CONTACTS_LAYOUT.PROFILE.SECTIONS.DETAILS')"
body-class="px-4 py-4"
>
<div class="flex flex-col items-start gap-6">
<ContactsForm
ref="contactsFormRef"
:contact-data="contactData"
is-details-view
@update="handleFormUpdate"
/>
<Button
:label="
t('CONTACTS_LAYOUT.CARD.EDIT_DETAILS_FORM.UPDATE_BUTTON')
"
size="sm"
:is-loading="isUpdating"
:disabled="isUpdating || isFormInvalid"
@click="updateContact"
/>
</div>
</ContactSidebarSection>
<Policy :permissions="['administrator']">
<ContactSidebarSection
:title="t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT')"
body-class="flex flex-col items-start gap-4 p-4"
>
<span class="text-sm text-n-slate-11">
{{ t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT_DESCRIPTION') }}
</span>
<Button
:label="t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT')"
color="ruby"
size="sm"
@click="openConfirmDeleteContactDialog"
/>
</ContactSidebarSection>
<ConfirmContactDeleteDialog
ref="confirmDeleteContactDialogRef"
:selected-contact="selectedContact"
@go-to-contacts-list="emit('goToContactsList')"
/>
</Policy>
</div>
</template>
<ContactCustomAttributes
v-else-if="activeSection === 'attributes'"
:selected-contact="selectedContact"
/>
<ContactHistory v-else-if="activeSection === 'history'" />
<ContactNotes v-else-if="activeSection === 'notes'" />
<ContactMedia v-else-if="activeSection === 'media'" />
<ContactMerge
v-else-if="activeSection === 'merge'"
:selected-contact="selectedContact"
@go-to-contacts-list="emit('goToContactsList')"
/>
</div>
<ConfirmContactDeleteDialog
ref="confirmDeleteContactDialogRef"
:selected-contact="selectedContact"
@go-to-contacts-list="emit('goToContactsList')"
/>
</Policy>
</div>
</div>
</template>
@@ -0,0 +1,75 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store';
const props = defineProps({
contactId: {
type: [Number, String],
default: null,
},
lastSeen: {
type: String,
default: '',
},
});
const { t } = useI18n();
const conversationsByContact = useMapGetter(
'contactConversations/getAllConversationsByContactId'
);
const notesByContact = useMapGetter('contactNotes/getAllNotesByContactId');
const attachmentsByContact = useMapGetter('contacts/getContactAttachments');
const statTiles = computed(() => [
{
key: 'conversations',
label: t('CONTACTS_LAYOUT.PROFILE.STATS.CONVERSATIONS'),
value: conversationsByContact.value(props.contactId)?.length ?? 0,
},
{
key: 'notes',
label: t('CONTACTS_LAYOUT.PROFILE.STATS.NOTES'),
value: notesByContact.value(props.contactId)?.length ?? 0,
},
{
key: 'files',
label: t('CONTACTS_LAYOUT.PROFILE.STATS.FILES'),
value:
attachmentsByContact.value(props.contactId)?.filter(a => a.data_url)
?.length ?? 0,
},
]);
</script>
<template>
<div
class="grid grid-cols-2 overflow-hidden border sm:grid-cols-4 gap-px rounded-xl border-n-weak bg-n-weak"
>
<div
v-for="tile in statTiles"
:key="tile.key"
class="flex flex-col gap-2 px-5 py-5 bg-n-solid-1"
>
<span
class="text-xs font-semibold tracking-wider uppercase text-n-slate-10"
>
{{ tile.label }}
</span>
<span class="text-3xl font-semibold tabular-nums text-n-slate-12">
{{ tile.value }}
</span>
</div>
<div class="flex flex-col gap-2 px-5 py-5 bg-n-solid-1">
<span
class="text-xs font-semibold tracking-wider uppercase text-n-slate-10"
>
{{ t('CONTACTS_LAYOUT.PROFILE.STATS.LAST_SEEN') }}
</span>
<span class="text-base font-medium truncate text-n-slate-12">
{{ lastSeen || t('CONTACTS_LAYOUT.PROFILE.STATS.NEVER') }}
</span>
</div>
</div>
</template>
@@ -20,10 +20,21 @@ const props = defineProps({
attachments: { type: Array, default: () => [] },
peekLimit: { type: Number, default: 0 },
showJumpToMessage: { type: Boolean, default: false },
columns: { type: Number, default: 3 },
});
const emit = defineEmits(['select', 'jumpToMessage']);
const gridColsClass = computed(
() =>
({
3: 'grid-cols-3',
4: 'grid-cols-4',
5: 'grid-cols-5',
6: 'grid-cols-6',
})[props.columns] || 'grid-cols-3'
);
const { t } = useI18n();
const mediaAttachments = computed(() =>
@@ -177,7 +188,7 @@ const onDownloadFile = async attachment => {
@click="showAll = !showAll"
/>
</header>
<div class="grid grid-cols-3 gap-2">
<div class="grid gap-2" :class="gridColsClass">
<div
v-for="(attachment, index) in visibleMedia"
:key="attachment.id"
@@ -506,6 +506,19 @@
}
}
},
"PROFILE": {
"STATS": {
"CONVERSATIONS": "Conversations",
"NOTES": "Notes",
"FILES": "Files",
"LAST_SEEN": "Last seen",
"NEVER": "Never"
},
"SECTIONS": {
"DETAILS": "Details",
"NOTES": "Notes"
}
},
"SIDEBAR": {
"TABS": {
"ATTRIBUTES": "Attributes",
@@ -564,6 +577,8 @@
},
"NOTES": {
"PLACEHOLDER": "Add a note",
"SEARCH_PLACEHOLDER": "Search notes",
"NO_RESULTS": "No notes match your search",
"WROTE": "wrote",
"YOU": "You",
"SAVE": "Save note",
@@ -1,5 +1,5 @@
<script setup>
import { onMounted, computed, ref } from 'vue';
import { onMounted, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import { useStore, useMapGetter } from 'dashboard/composables/store';
@@ -8,12 +8,6 @@ import { useRoute, useRouter } from 'vue-router';
import ContactsDetailsLayout from 'dashboard/components-next/Contacts/ContactsDetailsLayout.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import ContactDetails from 'dashboard/components-next/Contacts/Pages/ContactDetails.vue';
import TabBar from 'dashboard/components-next/tabbar/TabBar.vue';
import ContactNotes from 'dashboard/components-next/Contacts/ContactsSidebar/ContactNotes.vue';
import ContactHistory from 'dashboard/components-next/Contacts/ContactsSidebar/ContactHistory.vue';
import ContactMedia from 'dashboard/components-next/Contacts/ContactsSidebar/ContactMedia.vue';
import ContactMerge from 'dashboard/components-next/Contacts/ContactsSidebar/ContactMerge.vue';
import ContactCustomAttributes from 'dashboard/components-next/Contacts/ContactsSidebar/ContactCustomAttributes.vue';
const store = useStore();
const route = useRoute();
@@ -22,9 +16,6 @@ const router = useRouter();
const contact = useMapGetter('contacts/getContactById');
const uiFlags = useMapGetter('contacts/getUIFlags');
const activeTab = ref('attributes');
const contactMergeRef = ref(null);
const isFetchingItem = computed(() => uiFlags.value.isFetchingItem);
const isMergingContact = computed(() => uiFlags.value.isMerging);
const isUpdatingContact = computed(() => uiFlags.value.isUpdating);
@@ -37,25 +28,6 @@ const showSpinner = computed(
const { t } = useI18n();
const CONTACT_TABS_OPTIONS = [
{ key: 'ATTRIBUTES', value: 'attributes' },
{ key: 'HISTORY', value: 'history' },
{ key: 'NOTES', value: 'notes' },
{ key: 'MEDIA', value: 'media' },
{ key: 'MERGE', value: 'merge' },
];
const tabs = computed(() => {
return CONTACT_TABS_OPTIONS.map(tab => ({
label: t(`CONTACTS_LAYOUT.SIDEBAR.TABS.${tab.key}`),
value: tab.value,
}));
});
const activeTabIndex = computed(() => {
return CONTACT_TABS_OPTIONS.findIndex(v => v.value === activeTab.value);
});
const goToContactsList = () => {
if (window.history.state?.back || window.history.length > 1) {
router.back();
@@ -74,10 +46,6 @@ const fetchActiveContact = async () => {
}
};
const handleTabChange = tab => {
activeTab.value = tab.value;
};
const fetchContactNotes = () => {
const { contactId } = route.params;
if (contactId) store.dispatch('contactNotes/get', { contactId });
@@ -151,40 +119,6 @@ onMounted(() => {
:selected-contact="selectedContact"
@go-to-contacts-list="goToContactsList"
/>
<template #sidebarHeader>
<div class="px-6 pt-6 pb-3">
<TabBar
:tabs="tabs"
:initial-active-tab="activeTabIndex"
class="w-full [&>button]:w-full bg-n-alpha-black2"
@tab-changed="handleTabChange"
/>
</div>
</template>
<template #sidebar>
<div
v-if="isFetchingItem"
class="flex items-center justify-center py-10 text-n-slate-11"
>
<Spinner />
</div>
<template v-else>
<ContactCustomAttributes
v-if="activeTab === 'attributes'"
:selected-contact="selectedContact"
/>
<ContactNotes v-if="activeTab === 'notes'" />
<ContactHistory v-if="activeTab === 'history'" />
<ContactMedia v-if="activeTab === 'media'" />
<ContactMerge
v-if="activeTab === 'merge'"
ref="contactMergeRef"
:selected-contact="selectedContact"
@go-to-contacts-list="goToContactsList"
@reset-tab="handleTabChange(CONTACT_TABS_OPTIONS[0])"
/>
</template>
</template>
</ContactsDetailsLayout>
</div>
</template>