feat: Update contact details page UI (#13041)

This commit is contained in:
Sivin Varghese
2025-12-10 20:14:24 +05:30
committed by GitHub
parent 8d191a70b1
commit 417f28ce4a
14 changed files with 164 additions and 109 deletions
@@ -76,12 +76,12 @@ const closeMobileSidebar = () => {
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"
class="flex flex-col w-full h-full transition-all duration-300 ltr:3xl:ml-56 rtl:3xl:mr-56"
>
<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-4xl">
<div
class="flex flex-col xs:flex-row items-start xs:items-center justify-between w-full py-7 gap-2"
class="flex flex-col xs:flex-row items-start xs:items-center justify-between w-full py-4 gap-2"
>
<Breadcrumb
:items="breadcrumbItems"
@@ -94,23 +94,20 @@ const closeMobileSidebar = () => {
? $t('CONTACTS_LAYOUT.HEADER.BLOCK_CONTACT')
: $t('CONTACTS_LAYOUT.HEADER.UNBLOCK_CONTACT')
"
size="sm"
slate
:is-loading="isUpdating"
:disabled="isUpdating"
@click="toggleBlock"
/>
<VoiceCallButton
:phone="selectedContact?.phoneNumber"
:contact-id="contactId"
size="md"
:label="$t('CONTACT_PANEL.CALL')"
size="sm"
/>
<ComposeConversation :contact-id="contactId">
<template #trigger="{ toggle }">
<Button
:label="$t('CONTACTS_LAYOUT.HEADER.SEND_MESSAGE')"
size="sm"
@click="toggle"
/>
</template>
@@ -120,7 +117,7 @@ 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 pt-2.5 pb-4 mx-auto max-w-4xl">
<slot name="default" />
</div>
</main>
@@ -129,7 +126,7 @@ const closeMobileSidebar = () => {
<!-- Desktop sidebar -->
<div
v-if="slots.sidebar"
class="hidden lg:block overflow-y-auto justify-end min-w-52 w-full py-6 max-w-md border-l border-n-weak bg-n-solid-2"
class="hidden lg:block overflow-y-auto justify-end min-w-52 w-full py-5 max-w-md border-l border-n-weak bg-n-surface-2"
>
<slot name="sidebar" />
</div>
@@ -1,7 +1,8 @@
<script setup>
import { useI18n } from 'vue-i18n';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
import { useI18n } from 'vue-i18n';
defineProps({
selectedContact: {
@@ -37,13 +38,13 @@ const { t } = useI18n();
<template>
<div class="flex flex-col">
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between h-5 gap-2">
<div class="flex flex-col gap-3">
<div class="flex items-center justify-between h-6 gap-2">
<label class="text-sm text-n-slate-12">
{{ t('CONTACTS_LAYOUT.SIDEBAR.MERGE.PRIMARY') }}
</label>
<span
class="flex items-center justify-center w-24 h-5 text-xs rounded-md text-n-teal-11 bg-n-alpha-2"
class="flex items-center justify-center px-1.5 h-6 font-420 text-xs rounded-md text-n-teal-11 outline outline-1 outline-n-weak"
>
{{ t('CONTACTS_LAYOUT.SIDEBAR.MERGE.PRIMARY_HELP_LABEL') }}
</span>
@@ -69,7 +70,7 @@ const { t } = useI18n();
@search="query => emit('search', query)"
/>
</div>
<div class="relative flex justify-center gap-2 top-4">
<div class="relative flex justify-center gap-2 mt-4 mb-3">
<div v-for="i in 3" :key="i" class="relative w-4 h-8">
<div
class="absolute w-0 h-0 border-l-[4px] border-r-[4px] border-b-[6px] border-l-transparent border-r-transparent border-n-strong ltr:translate-x-[4px] rtl:-translate-x-[4px] -translate-y-[4px]"
@@ -79,13 +80,13 @@ const { t } = useI18n();
/>
</div>
</div>
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between h-5 gap-2">
<div class="flex flex-col gap-3">
<div class="flex items-center justify-between h-6 gap-2">
<label class="text-sm text-n-slate-12">
{{ t('CONTACTS_LAYOUT.SIDEBAR.MERGE.PARENT') }}
</label>
<span
class="flex items-center justify-center w-24 h-5 text-xs rounded-md text-n-ruby-11 bg-n-alpha-2"
class="flex items-center justify-center px-1.5 h-6 font-420 text-xs rounded-md text-n-ruby-11 outline outline-1 outline-n-weak"
>
{{ t('CONTACTS_LAYOUT.SIDEBAR.MERGE.PARENT_HELP_LABEL') }}
</span>
@@ -4,11 +4,11 @@ import { useI18n } from 'vue-i18n';
import { required, email } from '@vuelidate/validators';
import { useVuelidate } from '@vuelidate/core';
import { splitName } from '@chatwoot/utils';
import countries from 'shared/constants/countries.js';
import Input from 'dashboard/components-next/input/Input.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
import CountryDropdown from 'dashboard/components-next/Countries/CountryDropdown.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import PhoneNumberInput from 'dashboard/components-next/phonenumberinput/PhoneNumberInput.vue';
import CompaniesDropdown from 'dashboard/components-next/Companies/CompaniesDropdown.vue';
const props = defineProps({
contactData: {
@@ -122,10 +122,6 @@ const prepareStateBasedOnProps = () => {
});
};
const countryOptions = computed(() =>
countries.map(({ name, id }) => ({ label: name, value: id }))
);
const editDetailsForm = computed(() =>
Object.keys(FORM_CONFIG).map(key => ({
key,
@@ -204,9 +200,14 @@ const getMessageType = key => {
: 'info';
};
const handleCountrySelection = value => {
const selectedCountry = countries.find(option => option.id === value);
state.additionalAttributes.country = selectedCountry?.name || '';
const handleCountryChange = country => {
state.additionalAttributes.countryCode = country?.id || '';
state.additionalAttributes.country = country?.name || '';
emit('update', state);
};
const handleCompanyChange = company => {
state.additionalAttributes.companyName = company?.name || '';
emit('update', state);
};
@@ -236,41 +237,49 @@ defineExpose({
</script>
<template>
<div class="flex flex-col gap-6">
<div class="flex flex-col items-start gap-2">
<span class="py-1 text-sm font-medium text-n-slate-12">
<div class="flex flex-col gap-6 w-full">
<div class="flex flex-col items-start gap-5 w-full">
<span class="text-sm font-medium text-n-slate-12">
{{ t('CONTACTS_LAYOUT.CARD.EDIT_DETAILS_FORM.TITLE') }}
</span>
<div class="grid w-full grid-cols-1 gap-4 sm:grid-cols-2">
<template v-for="item in editDetailsForm" :key="item.key">
<ComboBox
<CountryDropdown
v-if="item.key === 'COUNTRY'"
v-model="state.additionalAttributes.countryCode"
:options="countryOptions"
:placeholder="item.placeholder"
class="[&>div>button]:h-8"
:class="{
'[&>div>button]:bg-n-alpha-black2 [&>div>button:not(.focused)]:!outline-transparent':
!isDetailsView,
'[&>div>button]:!bg-n-alpha-black2': isDetailsView,
'[&>div>button]:h-8': !isDetailsView,
'[&_button]:!h-10 [&_button]:!rounded-[10px]': isDetailsView,
}"
@update:model-value="handleCountrySelection"
@change="handleCountryChange"
/>
<PhoneNumberInput
v-else-if="item.key === 'PHONE_NUMBER'"
v-model="getFormBinding(item.key).value"
:placeholder="item.placeholder"
:show-border="isDetailsView"
:compact="!isDetailsView"
/>
<CompaniesDropdown
v-else-if="item.key === 'COMPANY_NAME'"
:placeholder="item.placeholder"
:selected-company-name="state.additionalAttributes.companyName"
:class="{
'[&>div>button]:h-8': !isDetailsView,
'[&_button]:!h-10 [&_button]:!rounded-[10px]': isDetailsView,
}"
@change="handleCompanyChange"
/>
<Input
v-else
v-model="getFormBinding(item.key).value"
:placeholder="item.placeholder"
:message-type="getMessageType(item.key)"
:custom-input-class="`h-8 !pt-1 !pb-1 ${
:custom-input-class="`!pt-1 !pb-1 ${
!isDetailsView
? '[&:not(.error,.focus)]:!outline-transparent'
: ''
? '[&:not(.error,.focus)]:!outline-transparent h-8'
: 'h-10 !rounded-[10px]'
}`"
class="w-full"
@input="
@@ -285,18 +294,19 @@ defineExpose({
</template>
</div>
</div>
<div class="flex flex-col items-start gap-2">
<span class="py-1 text-sm font-medium text-n-slate-12">
<div class="flex flex-col items-start gap-5">
<span class="text-sm font-medium text-n-slate-12">
{{ t('CONTACTS_LAYOUT.CARD.SOCIAL_MEDIA.TITLE') }}
</span>
<div class="flex flex-wrap gap-2">
<div
v-for="item in socialProfilesForm"
:key="item.key"
class="flex items-center h-8 gap-2 px-2 rounded-lg"
class="flex items-center h-8 gap-2 px-2 bg-n-alpha-2 dark:bg-n-solid-2"
:class="{
'bg-n-alpha-2 dark:bg-n-solid-2': isDetailsView,
'bg-n-alpha-2 dark:bg-n-solid-3': !isDetailsView,
'rounded-[10px] outline-1 outline outline-n-weak -outline-offset-1 hover:outline-n-slate-6':
isDetailsView,
'rounded-lg': !isDetailsView,
}"
>
<Icon
@@ -9,6 +9,7 @@ import ListAttribute from 'dashboard/components-next/CustomAttributes/ListAttrib
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 HelperTextPopup from 'dashboard/components/ui/HelperTextPopup.vue';
const props = defineProps({
attribute: {
@@ -75,11 +76,15 @@ const CurrentAttributeComponent = computed(() => {
<template>
<div
class="grid grid-cols-[140px,1fr] group/attribute items-center w-full gap-2"
class="grid grid-cols-[auto,auto] group/attribute items-center w-full gap-4"
:class="isEditingView ? 'min-h-10' : 'min-h-11'"
>
<div class="flex items-center justify-between truncate">
<span class="text-sm font-medium truncate text-n-slate-12">
<div class="flex items-center gap-1.5 min-w-0">
<HelperTextPopup
v-if="attribute.attributeDescription"
:message="attribute.attributeDescription"
/>
<span class="text-sm font-420 truncate text-n-slate-12">
{{ attribute.attributeDisplayName }}
</span>
</div>
@@ -108,7 +108,7 @@ const hasNoUsedAttributes = computed(() => usedAttributes.value.length === 0);
</script>
<template>
<div v-if="hasContactAttributes" class="flex flex-col gap-6 px-6 py-6">
<div v-if="hasContactAttributes" class="flex flex-col gap-4 px-5 pt-4 pb-6">
<div v-if="!hasNoUsedAttributes" class="flex flex-col gap-2">
<ContactCustomAttributeItem
v-for="attribute in usedAttributes"
@@ -117,16 +117,16 @@ const hasNoUsedAttributes = computed(() => usedAttributes.value.length === 0);
:attribute="attribute"
/>
</div>
<div v-if="!hasNoUnusedAttributes" class="flex items-center gap-3">
<div v-if="!hasNoUnusedAttributes" class="flex items-center gap-2">
<div class="flex-1 h-[1px] bg-n-slate-5" />
<span class="text-sm font-medium text-n-slate-10">{{
<span class="text-xs font-420 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 class="flex flex-col gap-4">
<div v-if="!hasNoUnusedAttributes" class="relative">
<span class="absolute i-lucide-search size-3.5 top-2 left-3" />
<input
@@ -135,7 +135,7 @@ const hasNoUsedAttributes = computed(() => usedAttributes.value.length === 0);
: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 ltr:pl-10 ltr:pr-2 rtl:pr-10 rtl:pl-2 mb-0 reset-base outline-n-weak outline-1 outline hover:outline-n-slate-6 text-sm border-none rounded-lg bg-n-alpha-black2 dark:bg-n-solid-1 text-n-slate-12 outline-offset-0"
/>
</div>
<div
@@ -32,7 +32,7 @@ const contactConversations = computed(() =>
</div>
<div
v-else-if="contactConversations.length > 0"
class="px-6 py-4 divide-y divide-n-weak"
class="px-6 pt-4 pb-6 divide-y divide-n-weak"
>
<ConversationCard
v-for="conversation in contactConversations"
@@ -103,12 +103,12 @@ const onMergeContacts = async () => {
</script>
<template>
<div class="flex flex-col gap-8 px-6 py-6">
<div class="flex flex-col gap-2">
<h4 class="text-base text-n-slate-12">
<div class="flex flex-col gap-6 px-5 pb-6 pt-4">
<div class="flex flex-col gap-2 mt-4">
<h4 class="text-base font-medium text-n-slate-12">
{{ t('CONTACTS_LAYOUT.SIDEBAR.MERGE.TITLE') }}
</h4>
<p class="text-sm text-n-slate-11">
<p class="text-sm font-420 text-n-slate-11">
{{ t('CONTACTS_LAYOUT.SIDEBAR.MERGE.DESCRIPTION') }}
</p>
</div>
@@ -126,13 +126,6 @@ const onMergeContacts = async () => {
@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-text hover:bg-n-alpha-3"
@click="resetState"
/>
<Button
:label="t('CONTACTS_LAYOUT.SIDEBAR.MERGE.BUTTONS.CONFIRM')"
class="w-full"
@@ -140,6 +133,12 @@ const onMergeContacts = async () => {
:disabled="isMergingContact"
@click="onMergeContacts"
/>
<Button
slate
:label="t('CONTACTS_LAYOUT.SIDEBAR.MERGE.BUTTONS.CANCEL')"
class="w-full"
@click="resetState"
/>
</div>
</div>
</template>
@@ -55,12 +55,12 @@ useKeyboardEvents(keyboardEvents);
</script>
<template>
<div class="flex flex-col gap-6 py-6">
<div class="flex flex-col gap-4 pt-4 pb-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"
class="[&>div]:px-4 [&>div]:py-4 px-5"
>
<template #actions>
<div class="flex items-center gap-3">
@@ -87,7 +87,7 @@ useKeyboardEvents(keyboardEvents);
<ContactNoteItem
v-for="note in notes"
:key="note.id"
class="mx-6 py-4"
class="mx-5 py-4"
:note="note"
:written-by="getWrittenBy(note)"
allow-delete
@@ -11,6 +11,7 @@ import ContactLabels from 'dashboard/components-next/Contacts/ContactLabels/Cont
import ContactsForm from 'dashboard/components-next/Contacts/ContactsForm/ContactsForm.vue';
import ConfirmContactDeleteDialog from 'dashboard/components-next/Contacts/ContactsForm/ConfirmContactDeleteDialog.vue';
import Policy from 'dashboard/components/policy.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
selectedContact: {
@@ -121,17 +122,17 @@ 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 items-start gap-6 pb-6 w-full">
<div class="flex flex-col items-start gap-3 w-full">
<Avatar
:src="avatarSrc || ''"
:name="selectedContact?.name || ''"
:size="72"
:size="64"
allow-upload
@upload="handleAvatarUpload"
@delete="handleAvatarDelete"
/>
<div class="flex flex-col gap-1">
<div class="flex flex-col gap-2">
<h3 class="text-base font-medium text-n-slate-12">
{{ selectedContact?.name }}
</h3>
@@ -140,27 +141,41 @@ const handleAvatarDelete = async () => {
v-if="selectedContact?.identifier"
class="inline-flex items-center gap-1 text-sm text-n-slate-11"
>
<span class="i-ph-user-gear text-n-slate-10 size-4" />
<Icon
icon="i-ph-user-gear"
class="text-n-slate-10 size-4 flex-shrink-0"
/>
{{ selectedContact?.identifier }}
</span>
<span class="inline-flex items-center gap-1 text-sm text-n-slate-11">
<span
<span class="inline-flex items-start gap-1 text-sm text-n-slate-11">
<Icon
v-if="selectedContact?.identifier"
class="i-ph-activity text-n-slate-10 size-4"
icon="i-ph-activity"
class="text-n-slate-10 size-4 flex-shrink-0 mt-0.5"
/>
{{ $t('CONTACTS_LAYOUT.DETAILS.CREATED_AT', { date: createdAt }) }}
{{
$t('CONTACTS_LAYOUT.DETAILS.LAST_ACTIVITY', {
date: lastActivityAt,
})
}}
<div
class="flex flex-col sm:flex-row sm:inline-flex items-start sm:items-center gap-1 sm:gap-3"
>
<span>
{{
$t('CONTACTS_LAYOUT.DETAILS.CREATED_AT', { date: createdAt })
}}
</span>
<div class="w-px h-3 rounded-xl bg-n-weak hidden sm:block" />
<span>
{{
$t('CONTACTS_LAYOUT.DETAILS.LAST_ACTIVITY', {
date: lastActivityAt,
})
}}
</span>
</div>
</span>
</div>
</div>
<ContactLabels :contact-id="selectedContact?.id" />
</div>
<div class="flex flex-col items-start gap-6">
<div class="flex flex-col items-start gap-6 w-full">
<ContactsForm
ref="contactsFormRef"
:contact-data="contactData"
@@ -175,21 +190,23 @@ const handleAvatarDelete = async () => {
@click="updateContact"
/>
</div>
<Policy :permissions="['administrator']">
<Policy :permissions="['administrator']" class="w-full">
<div class="w-full h-px bg-n-slate-3 mt-1 mb-7" />
<div
class="flex flex-col items-start w-full gap-4 pt-6 border-t border-n-strong"
class="flex items-end justify-between w-full gap-4 outline outline-1 outline-n-weak rounded-2xl p-4"
>
<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">
<span class="text-sm font-420 text-n-slate-11">
{{ t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT_DESCRIPTION') }}
</span>
</div>
<Button
:label="t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT')"
color="ruby"
ruby
class="flex-shrink-0"
@click="openConfirmDeleteContactDialog"
/>
</div>
@@ -101,7 +101,6 @@ const onPickInbox = async inbox => {
:label="label"
:icon="icon"
:size="size"
class="!px-2"
@click="onClick"
/>