feat: Contact details screen

This commit is contained in:
iamsivin
2024-11-04 19:46:02 +05:30
parent dd9e9286b3
commit b71a05f790
11 changed files with 461 additions and 37 deletions
@@ -0,0 +1,74 @@
<script setup>
import { computed, watch, onMounted } from 'vue';
import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useMapGetter, useStore } from 'dashboard/composables/store';
const props = defineProps({
contactId: {
type: [String, Number],
required: true,
},
});
const store = useStore();
const { t } = useI18n();
const route = useRoute();
const allLabels = useMapGetter('labels/getLabels');
const contactLabels = useMapGetter('contactLabels/getContactLabels');
const savedLabels = computed(() => {
const availableContactLabels = contactLabels.value(props.contactId);
return allLabels.value.filter(({ title }) =>
availableContactLabels.includes(title)
);
});
const fetchLabels = async contactId => {
if (!contactId) {
return;
}
store.dispatch('contactLabels/get', contactId);
};
watch(
() => props.contactId,
(newVal, oldVal) => {
if (newVal !== oldVal) {
fetchLabels(newVal);
}
}
);
onMounted(() => {
if (route.params.contactId) {
fetchLabels(route.params.contactId);
}
});
</script>
<template>
<div class="flex flex-wrap items-center gap-2">
<div
v-for="label in savedLabels"
:key="label.id"
class="bg-n-alpha-2 rounded-md flex items-center h-7 w-fit py-1 ltr:pl-1 rtl:pr-1 ltr:pr-1.5 rtl:pl-1.5"
>
<div
class="w-2 h-2 m-1 rounded-sm"
:style="{ backgroundColor: label.color }"
/>
<span class="text-sm text-n-slate-12">
{{ label.title }}
</span>
</div>
<button
class="flex items-center gap-1 px-2 py-1 rounded-md outline-dashed h-[26px] outline-1 outline-n-slate-6 hover:bg-n-alpha-2"
>
<span class="i-lucide-plus" />
<span class="text-sm text-n-slate-11">
{{ t('CONTACTS_LAYOUT.DETAILS.TAG_BUTTON') }}
</span>
</button>
</div>
</template>
@@ -1,6 +1,7 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { useStore } from 'dashboard/composables/store';
import { debounce } from '@chatwoot/utils';
@@ -21,6 +22,8 @@ const props = defineProps({
const emit = defineEmits(['toggle']);
const router = useRouter();
const { t } = useI18n();
const store = useStore();
@@ -39,6 +42,16 @@ const handleFormUpdate = debounce(
400,
false
);
const onClickViewDetails = () => {
store.dispatch('contacts/show', { id: props.id });
router.push({
name: 'contacts_dashboard_edit_index',
params: {
contactId: props.id,
},
});
};
</script>
<template>
@@ -64,15 +77,16 @@ const handleFormUpdate = debounce(
<span v-if="email" class="text-sm text-n-slate-11">{{
email
}}</span>
<div v-if="phoneNumber" class="w-px h-3 bg-n-slate-6" />
<div v-if="email" class="w-px h-3 bg-n-slate-6" />
<span v-if="phoneNumber" class="text-sm text-n-slate-11">{{
phoneNumber
}}</span>
<div v-if="phoneNumber || email" class="w-px h-3 bg-n-slate-6" />
<div v-if="phoneNumber" class="w-px h-3 bg-n-slate-6" />
<Button
:label="t('CONTACTS_LAYOUT.CARD.VIEW_DETAILS')"
variant="link"
size="xs"
@click="onClickViewDetails"
/>
</div>
</div>
@@ -103,7 +117,7 @@ const handleFormUpdate = debounce(
v-show="isExpanded"
class="w-full overflow-visible transition-all duration-200"
>
<div class="border-t border-n-strong">
<div class="p-6 border-t border-n-strong">
<ContactsForm
:contact-data="{
id,
@@ -0,0 +1,58 @@
<script setup>
import { ref } from 'vue';
import { useStore } from 'dashboard/composables/store';
import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
const props = defineProps({
selectedContact: {
type: Object,
default: null,
},
});
const emit = defineEmits(['goToContactsList']);
const { t } = useI18n();
const store = useStore();
const route = useRoute();
const dialogRef = ref(null);
const deleteContact = async id => {
if (!id) return;
try {
await store.dispatch('contacts/delete', id);
useAlert(t('CONTACTS_LAYOUT.DETAILS.DELETE_DIALOG.API.SUCCESS_MESSAGE'));
} catch (error) {
useAlert(t('CONTACTS_LAYOUT.DETAILS.DELETE_DIALOG.API.ERROR_MESSAGE'));
}
};
const handleDialogConfirm = async () => {
emit('goToContactsList');
await deleteContact(route.params.contactId || props.selectedContact.id);
dialogRef.value?.close();
};
defineExpose({ dialogRef });
</script>
<template>
<Dialog
ref="dialogRef"
type="alert"
:title="t('CONTACTS_LAYOUT.DETAILS.DELETE_DIALOG.TITLE')"
:description="
t('CONTACTS_LAYOUT.DETAILS.DELETE_DIALOG.DESCRIPTION', {
contactName: props.selectedContact.name,
})
"
:confirm-button-label="t('CONTACTS_LAYOUT.DETAILS.DELETE_DIALOG.CONFIRM')"
@confirm="handleDialogConfirm"
/>
</template>
@@ -11,6 +11,10 @@ const props = defineProps({
type: Object,
required: true,
},
isDetailsView: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['update']);
@@ -18,8 +22,8 @@ const emit = defineEmits(['update']);
const { t } = useI18n();
const FORM_CONFIG = {
FIRST_NAME: { field: 'name' },
LAST_NAME: { field: 'name' },
FIRST_NAME: { field: 'firstName' },
LAST_NAME: { field: 'lastName' },
EMAIL_ADDRESS: { field: 'email' },
PHONE_NUMBER: { field: 'phone_number' },
CITY: { field: 'additional_attributes.city' },
@@ -40,6 +44,8 @@ const defaultState = {
id: 0,
name: '',
email: '',
firstName: '',
lastName: '',
phone_number: '',
additional_attributes: {
description: '',
@@ -60,9 +66,14 @@ const defaultState = {
const state = reactive({ ...defaultState });
const updateState = () => {
const [firstName = '', lastName = ''] = (props.contactData.name || '').split(
' '
);
Object.assign(state, {
id: props.contactData.id,
name: props.contactData.name,
firstName,
lastName,
email: props.contactData.email,
phone_number: props.contactData.phoneNumber,
additional_attributes: {
@@ -109,6 +120,9 @@ const getFormBinding = key => {
// Example 1 (nested): field = 'contact.name' returns state.contact.name = "John Doe"
// Example 2 (root): field = 'email' returns state.email = "john@example.com"
get: () => {
if (field === 'firstName' || field === 'lastName') {
return state[field];
}
const [base, nested] = field.split('.');
return nested ? state[base][nested] : state[base];
},
@@ -117,11 +131,17 @@ const getFormBinding = key => {
// Example 1 (nested): field = 'contact.name', value = "Jane Doe" → state.contact.name = "Jane Doe"
// Example 2 (root): field = 'email', value = "jane@example.com" → state.email = "jane@example.com"
set: value => {
const [base, nested] = field.split('.');
if (nested) {
state[base][nested] = value;
if (field === 'firstName' || field === 'lastName') {
state[field] = value;
// Combine first and last name into the name field
state.name = `${state.firstName} ${state.lastName}`.trim();
} else {
state[base] = value;
const [base, nested] = field.split('.');
if (nested) {
state[base][nested] = value;
} else {
state[base] = value;
}
}
emit('update', state);
},
@@ -132,8 +152,8 @@ watch(() => props.contactData, updateState, { immediate: true, deep: true });
</script>
<template>
<div class="flex flex-col">
<div class="flex flex-col items-start gap-2 px-6 py-5">
<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">
{{ t('CONTACTS_LAYOUT.CARD.EDIT_DETAILS_FORM.TITLE') }}
</span>
@@ -144,20 +164,26 @@ watch(() => props.contactData, updateState, { immediate: true, deep: true });
v-model="state.additional_attributes.country"
:options="countryOptions"
:placeholder="item.placeholder"
class="[&>div>button]:bg-n-alpha-black2 [&>div>button]:!outline-transparent [&>div>button]:h-8"
class="[&>div>button]:h-8"
:class="{
'[&>div>button]:bg-n-alpha-black2 [&>div>button]:!outline-transparent':
!isDetailsView,
}"
@update:model-value="emit('update', state)"
/>
<Input
v-else
v-model="getFormBinding(item.key).value"
:placeholder="item.placeholder"
custom-input-class="!border-transparent h-8 !py-1"
:custom-input-class="`h-8 !pt-1 !pb-1 ${
!isDetailsView && '!border-transparent'
}`"
class="w-full"
/>
</template>
</div>
</div>
<div class="flex flex-col items-start gap-2 px-6 pb-5">
<div class="flex flex-col items-start gap-2">
<span class="py-1 text-sm font-medium text-n-slate-12">
{{ t('CONTACTS_LAYOUT.CARD.SOCIAL_MEDIA.TITLE') }}
</span>
@@ -1,10 +1,14 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import Button from 'dashboard/components-next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue';
import Breadcrumb from 'dashboard/components-next/breadcrumb/Breadcrumb.vue';
defineProps({
const props = defineProps({
headerTitle: {
type: String,
default: '',
@@ -29,9 +33,48 @@ defineProps({
type: Number,
default: 15,
},
isDetailView: {
type: Boolean,
default: false,
},
selectedContact: {
type: Object,
default: () => ({}),
},
});
const emit = defineEmits(['filter', 'more', 'message', 'update:currentPage']);
const emit = defineEmits([
'filter',
'more',
'message',
'update:currentPage',
'goToContactsList',
]);
const { t } = useI18n();
const selectedContactName = computed(() => {
return props.selectedContact?.name;
});
const breadcrumbItems = computed(() => {
const items = [
{
label: t('CONTACTS_LAYOUT.HEADER.BREADCRUMB.CONTACTS'),
link: '#',
},
];
if (props.selectedContact) {
items.push({
label: selectedContactName.value,
});
}
return items;
});
const handleBreadcrumbClick = () => {
emit('goToContactsList');
};
const updateCurrentPage = page => {
emit('update:currentPage', page);
@@ -43,11 +86,19 @@ const updateCurrentPage = page => {
<header class="sticky top-0 z-10 px-6 lg:px-0">
<div class="w-full max-w-[900px] mx-auto">
<div class="flex items-center justify-between w-full h-20 gap-2">
<span class="text-xl font-medium text-n-slate-12">
<span
v-if="!isDetailView"
class="text-xl font-medium text-n-slate-12"
>
{{ headerTitle }}
</span>
<Breadcrumb
v-else
:items="breadcrumbItems"
@click="handleBreadcrumbClick"
/>
<div class="flex items-center gap-2">
<div class="flex items-center gap-2">
<div v-if="!isDetailView" class="flex items-center gap-2">
<Input
:placeholder="$t('CONTACTS_LAYOUT.HEADER.SEARCH_PLACEHOLDER')"
:custom-input-class="['h-8 ltr:!pl-8 rtl:!pr-8']"
@@ -61,12 +112,14 @@ const updateCurrentPage = page => {
</Input>
</div>
<Button
v-if="!isDetailView"
icon="i-lucide-list-filter"
size="sm"
color="slate"
@click="emit('filter')"
/>
<Button
v-if="!isDetailView"
icon="i-lucide-ellipsis-vertical"
size="sm"
color="slate"
@@ -0,0 +1,115 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore } from 'dashboard/composables/store';
import { dynamicTime } from 'shared/helpers/timeHelper';
import { debounce } from '@chatwoot/utils';
import EditableAvatar from 'dashboard/components-next/avatar/EditableAvatar.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import ContactLabels from 'dashboard/components-next/Contacts/ContactLabels/ContactLabels.vue';
import ContactsForm from 'dashboard/components-next/Contacts/ContactsForm/ContactsForm.vue';
import ConfirmContactDeleteDialog from 'dashboard/components-next/Contacts/ContactsForm/ConfirmContactDeleteDialog.vue';
const props = defineProps({
selectedContact: {
type: Object,
required: true,
},
});
const emit = defineEmits(['goToContactsList']);
const { t } = useI18n();
const store = useStore();
const confirmDeleteContactDialogRef = ref(null);
const selectedContactData = computed(() => {
if (!props.selectedContact) return {};
const {
phone_number: phoneNumber = {},
additional_attributes: additionalAttributes = {},
} = props.selectedContact;
return {
...props.selectedContact,
phoneNumber,
additionalAttributes,
};
});
const createdAt = computed(() => {
return props.selectedContact?.created_at
? dynamicTime(props.selectedContact.created_at)
: '';
});
const lastActivityAt = computed(() => {
return props.selectedContact?.last_activity_at
? dynamicTime(props.selectedContact.last_activity_at)
: '';
});
const updateContact = async updatedData => {
await store.dispatch('contacts/update', updatedData);
await store.dispatch(
'contacts/fetchContactableInbox',
props.selectedContact?.id
);
};
const handleFormUpdate = debounce(
updatedData => updateContact(updatedData),
500,
false
);
const openConfirmDeleteContactDialog = () => {
confirmDeleteContactDialogRef.value?.dialogRef.open();
};
</script>
<template>
<div class="flex flex-col items-start gap-8">
<div class="flex flex-col items-start gap-3">
<EditableAvatar
:src="selectedContact.thumbnail"
:name="selectedContact.name"
/>
<div class="flex flex-col gap-1">
<h3 class="text-base font-medium text-n-slate-12">
{{ selectedContact.name }}
</h3>
<span class="text-sm text-n-slate-11">
{{ createdAt }} {{ lastActivityAt }}
</span>
</div>
<ContactLabels :contact-id="selectedContact.id" />
</div>
<ContactsForm
:contact-data="selectedContactData"
is-details-view
@update="handleFormUpdate"
/>
<div
class="flex items-end justify-end w-full gap-4 border-t h-14 border-n-strong"
>
<Button
color="slate"
:label="t('CONTACTS_LAYOUT.DETAILS.MERGE_CONTACT')"
/>
<Button
:label="t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT')"
color="ruby"
@click="openConfirmDeleteContactDialog"
/>
</div>
<ConfirmContactDeleteDialog
ref="confirmDeleteContactDialogRef"
:selected-contact="selectedContact"
@go-to-contacts-list="emit('goToContactsList')"
/>
</div>
</template>
@@ -8,14 +8,6 @@ defineProps({
items: {
type: Array,
required: true,
validator: value => {
return value.every(
item =>
typeof item.label === 'string' &&
(item.link === undefined || typeof item.link === 'string') &&
(item.count === undefined || typeof item.count === 'number')
);
},
},
});
@@ -391,7 +391,10 @@
"HEADER": {
"TITLE": "Contacts",
"SEARCH_PLACEHOLDER": "Search...",
"MESSAGE_BUTTON": "Message"
"MESSAGE_BUTTON": "Message",
"BREADCRUMB": {
"CONTACTS": "Contacts"
}
},
"CARD": {
"OF": "of",
@@ -445,6 +448,20 @@
}
}
}
},
"DETAILS": {
"TAG_BUTTON": "tag",
"MERGE_CONTACT": "Merge contact",
"DELETE_CONTACT": "Delete contact",
"DELETE_DIALOG": {
"TITLE": "Confirm Deletion",
"DESCRIPTION": "Are you sure you want to delete this {contactName} contact?",
"CONFIRM": "Yes, Delete",
"API": {
"SUCCESS_MESSAGE": "Contact deleted successfully",
"ERROR_MESSAGE": "Could not delete contact. Please try again later."
}
}
}
}
}
@@ -1,21 +1,25 @@
import { frontendURL } from 'dashboard/helper/URLHelper.js';
import ContactsPageRouteView from './pages/ContactsPageRouteView.vue';
import EditContactsPageRouteView from './pages/EditContactsPageRouteView.vue';
const contactsRoutes = {
routes: [
{
path: frontendURL('accounts/:accountId/contacts-new'),
component: ContactsPageRouteView,
children: [
{
path: 'ongoing',
name: 'contacts_dashboard_index',
meta: {
permissions: ['administrator', 'agent', 'contact_manage'],
},
},
],
name: 'contacts_dashboard_index',
meta: {
permissions: ['administrator', 'agent', 'contact_manage'],
},
},
{
path: frontendURL('accounts/:accountId/contacts-new/:contactId'),
component: EditContactsPageRouteView,
name: 'contacts_dashboard_edit_index',
meta: {
permissions: ['administrator', 'agent', 'contact_manage'],
},
},
],
};
@@ -4,6 +4,7 @@ import { useStore, useMapGetter } from 'dashboard/composables/store';
import ContactsLayout from 'dashboard/components-next/Contacts/ContactsLayout.vue';
import ContactsList from 'dashboard/components-next/Contacts/Pages/ContactsList.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
const store = useStore();
@@ -11,6 +12,8 @@ const contacts = useMapGetter('contacts/getContacts');
const uiFlags = useMapGetter('contacts/getUIFlags');
const meta = useMapGetter('contacts/getMeta');
const isFetchingList = computed(() => uiFlags.value.isFetching);
const currentPage = computed(() => Number(meta.value?.currentPage));
const totalItems = computed(() => meta.value?.count);
@@ -38,7 +41,13 @@ const updateCurrentPage = page => {
:total-items="totalItems"
@update:current-page="updateCurrentPage"
>
<ContactsList :contacts="contacts" />
<div
v-if="isFetchingList"
class="flex items-center justify-center py-10 text-n-slate-11"
>
<Spinner />
</div>
<ContactsList v-else :contacts="contacts" />
</ContactsLayout>
</div>
</template>
@@ -0,0 +1,62 @@
<script setup>
import { onMounted, computed } from 'vue';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useRoute, useRouter } from 'vue-router';
import ContactsLayout from 'dashboard/components-next/Contacts/ContactsLayout.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import ContactDetails from 'dashboard/components-next/Contacts/Pages/ContactDetails.vue';
const store = useStore();
const route = useRoute();
const router = useRouter();
const contact = useMapGetter('contacts/getContact');
const uiFlags = useMapGetter('contacts/getUIFlags');
const isFetchingItem = computed(() => uiFlags.value.isFetchingItem);
const selectedContact = computed(() => contact.value(route.params.contactId));
const goToContactsList = () => {
router.push({
name: 'contacts_dashboard_index',
});
};
const fetchActiveContact = async () => {
if (route.params.contactId) {
store.dispatch('contacts/show', { id: route.params.contactId });
}
};
onMounted(() => {
fetchActiveContact();
});
</script>
<template>
<div
class="flex flex-col justify-between flex-1 h-full m-0 overflow-auto bg-n-background"
>
<ContactsLayout
:button-label="$t('CONTACTS_LAYOUT.HEADER.MESSAGE_BUTTON')"
:selected-contact="selectedContact"
is-detail-view
:show-pagination-footer="false"
@go-to-contacts-list="goToContactsList"
>
<div
v-if="isFetchingItem"
class="flex items-center justify-center py-10 text-n-slate-11"
>
<Spinner />
</div>
<ContactDetails
v-else
:selected-contact="selectedContact"
@go-to-contacts-list="goToContactsList"
/>
</ContactsLayout>
</div>
</template>