feat: updated the contact details page layout
This commit is contained in:
+30
-18
@@ -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>
|
||||
|
||||
+19
-13
@@ -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"
|
||||
|
||||
+36
-34
@@ -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>
|
||||
|
||||
+73
-36
@@ -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>
|
||||
|
||||
+43
@@ -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>
|
||||
Reference in New Issue
Block a user