feat(v4): Add new contact details screen (#10504)

Co-authored-by: Pranav <pranavrajs@gmail.com>
This commit is contained in:
Sivin Varghese
2024-12-04 10:59:47 +05:30
committed by GitHub
parent d4b6f710bd
commit 769b7171f4
37 changed files with 1353 additions and 221 deletions

View File

@@ -0,0 +1,108 @@
<script setup>
import { computed, watch, onMounted, ref } from 'vue';
import { useRoute } from 'vue-router';
import { useMapGetter, useStore } from 'dashboard/composables/store';
import LabelItem from 'dashboard/components-next/Label/LabelItem.vue';
import AddLabel from 'dashboard/components-next/Label/AddLabel.vue';
const props = defineProps({
contactId: {
type: [String, Number],
default: null,
},
});
const store = useStore();
const route = useRoute();
const showDropdown = ref(false);
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 labelMenuItems = computed(() => {
return allLabels.value
?.map(label => ({
label: label.title,
value: label.id,
thumbnail: { name: label.title, color: label.color },
isSelected: savedLabels.value.some(
savedLabel => savedLabel.id === label.id
),
action: 'addLabel',
}))
.toSorted((a, b) => Number(a.isSelected) - Number(b.isSelected));
});
const fetchLabels = async contactId => {
if (!contactId) {
return;
}
store.dispatch('contactLabels/get', contactId);
};
const handleLabelAction = async ({ action, value }) => {
try {
// Get current label titles
const currentLabels = savedLabels.value.map(label => label.title);
// Find the label title for the ID (value)
const selectedLabel = allLabels.value.find(label => label.id === value);
if (!selectedLabel) return;
let updatedLabels;
if (action === 'addLabel') {
// If label is already selected, remove it (toggle behavior)
if (currentLabels.includes(selectedLabel.title)) {
updatedLabels = currentLabels.filter(
labelTitle => labelTitle !== selectedLabel.title
);
} else {
// Add the new label
updatedLabels = [...currentLabels, selectedLabel.title];
}
}
await store.dispatch('contactLabels/update', {
contactId: props.contactId,
labels: updatedLabels,
});
showDropdown.value = false;
} catch (error) {
// error
}
};
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">
<LabelItem v-for="label in savedLabels" :key="label.id" :label="label" />
<AddLabel
:label-menu-items="labelMenuItems"
@update-label="handleLabelAction"
/>
</div>
</template>

View File

@@ -0,0 +1,83 @@
<script setup>
import { computed, useSlots } from 'vue';
import { useI18n } from 'vue-i18n';
// import Button from 'dashboard/components-next/button/Button.vue';
import Breadcrumb from 'dashboard/components-next/breadcrumb/Breadcrumb.vue';
const props = defineProps({
// buttonLabel: {
// type: String,
// default: '',
// },
selectedContact: {
type: Object,
default: () => ({}),
},
});
const emit = defineEmits([
// 'message',
'goToContactsList',
]);
const { t } = useI18n();
const slots = useSlots();
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');
};
</script>
<template>
<section
class="flex w-full h-full gap-4 overflow-hidden justify-evenly bg-n-background"
>
<div
class="flex flex-col w-full h-full transition-all duration-300 ltr:2xl:ml-56 rtl:2xl:mr-56"
>
<header class="sticky top-0 z-10 px-6 xl:px-0">
<div class="w-full mx-auto max-w-[650px]">
<div class="flex items-center justify-between w-full h-20 gap-2">
<Breadcrumb
:items="breadcrumbItems"
@click="handleBreadcrumbClick"
/>
<!-- <Button :label="buttonLabel" size="sm" @click="emit('message')" /> -->
</div>
</div>
</header>
<main class="flex-1 px-6 overflow-y-auto xl:px-px">
<div class="w-full py-4 mx-auto max-w-[650px]">
<slot name="default" />
</div>
</main>
</div>
<div
v-if="slots.sidebar"
class="overflow-y-auto justify-end min-w-[200px] w-full py-6 max-w-[440px] border-l border-n-weak bg-n-solid-2"
>
<slot name="sidebar" />
</div>
</section>
</template>

View File

@@ -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>

View File

@@ -98,7 +98,7 @@ const { t } = useI18n();
:size="32"
rounded-full
/>
<div class="flex flex-col gap-1">
<div class="flex flex-col w-full min-w-0 gap-1">
<span class="text-sm leading-4 truncate text-n-slate-11">
{{ selectedContact.name }}
</span>

View File

@@ -96,11 +96,11 @@ const prepareStateBasedOnProps = () => {
} = props.contactData || {};
const { firstName, lastName } = splitName(name);
const {
description,
companyName,
countryCode,
country,
city,
description = '',
companyName = '',
countryCode = '',
country = '',
city = '',
socialProfiles = {},
} = additionalAttributes || {};

View File

@@ -38,6 +38,10 @@ defineProps({
type: Boolean,
default: false,
},
isLabelView: {
type: Boolean,
default: false,
},
});
const emit = defineEmits([
@@ -81,7 +85,7 @@ const emit = defineEmits([
</Input>
</div>
<div class="flex items-center gap-2">
<div class="relative">
<div v-if="!isLabelView" class="relative">
<Button
id="toggleContactsFilterButton"
:icon="
@@ -101,7 +105,7 @@ const emit = defineEmits([
<slot name="filter" />
</div>
<Button
v-if="hasActiveFilters && !isSegmentsView"
v-if="hasActiveFilters && !isSegmentsView && !isLabelView"
icon="i-lucide-save"
color="slate"
size="sm"
@@ -110,7 +114,7 @@ const emit = defineEmits([
@click="emit('createSegment')"
/>
<Button
v-if="isSegmentsView"
v-if="isSegmentsView && !isLabelView"
icon="i-lucide-trash"
color="slate"
size="sm"

View File

@@ -35,6 +35,7 @@ const props = defineProps({
segmentsId: { type: [String, Number], default: 0 },
activeSegment: { type: Object, default: null },
hasAppliedFilters: { type: Boolean, default: false },
isLabelView: { type: Boolean, default: false },
});
const emit = defineEmits([
@@ -256,6 +257,10 @@ const onToggleFilters = () => {
}
showFiltersModal.value = true;
};
defineExpose({
onToggleFilters,
});
</script>
<template>
@@ -266,6 +271,7 @@ const onToggleFilters = () => {
:active-ordering="activeOrdering"
:header-title="headerTitle"
:is-segments-view="hasActiveSegments"
:is-label-view="isLabelView"
:has-active-filters="hasAppliedFilters"
:button-label="t('CONTACTS_LAYOUT.HEADER.MESSAGE_BUTTON')"
@search="emit('search', $event)"

View File

@@ -4,7 +4,7 @@ import { useMapGetter } from 'dashboard/composables/store';
import ActiveFilterPreview from 'dashboard/components-next/filter/ActiveFilterPreview.vue';
const emit = defineEmits(['clearFilters']);
const emit = defineEmits(['clearFilters', 'openFilter']);
const { t } = useI18n();
@@ -24,6 +24,7 @@ const appliedFilters = useMapGetter('contacts/getAppliedContactFiltersV4');
t('CONTACTS_LAYOUT.FILTER.ACTIVE_FILTERS.CLEAR_FILTERS')
"
class="max-w-[960px] px-6"
@open-filter="emit('openFilter')"
@clear-filters="emit('clearFilters')"
/>
</template>

View File

@@ -1,5 +1,5 @@
<script setup>
import { computed } from 'vue';
import { computed, ref } from 'vue';
import { useRoute } from 'vue-router';
import ContactListHeaderWrapper from 'dashboard/components-next/Contacts/ContactsHeader/ContactListHeaderWrapper.vue';
@@ -63,13 +63,23 @@ const emit = defineEmits([
const route = useRoute();
const contactListHeaderWrapper = ref(null);
const isNotSegmentView = computed(() => {
return route.name !== 'contacts_dashboard_segments_index';
});
const isLabelView = computed(
() => route.name === 'contacts_dashboard_labels_index'
);
const updateCurrentPage = page => {
emit('update:currentPage', page);
};
const openFilter = () => {
contactListHeaderWrapper.value?.onToggleFilters();
};
</script>
<template>
@@ -78,6 +88,7 @@ const updateCurrentPage = page => {
>
<div class="flex flex-col w-full h-full transition-all duration-300">
<ContactListHeaderWrapper
ref="contactListHeaderWrapper"
:show-search="isNotSegmentView"
:search-value="searchValue"
:active-sort="activeSort"
@@ -86,6 +97,7 @@ const updateCurrentPage = page => {
:active-segment="activeSegment"
:segments-id="segmentsId"
:has-applied-filters="hasAppliedFilters"
:is-label-view="isLabelView"
@update:sort="emit('update:sort', $event)"
@search="emit('search', $event)"
@apply-filter="emit('applyFilter', $event)"
@@ -96,6 +108,7 @@ const updateCurrentPage = page => {
<ContactsActiveFiltersPreview
v-if="hasAppliedFilters && isNotSegmentView"
@clear-filters="emit('clearFilters')"
@open-filter="openFilter"
/>
<slot name="default" />
</div>

View File

@@ -0,0 +1,95 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import { useStore } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
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';
const props = defineProps({
attribute: {
type: Object,
required: true,
},
isEditingView: {
type: Boolean,
default: false,
},
});
const store = useStore();
const { t } = useI18n();
const route = useRoute();
const handleDelete = async () => {
try {
await store.dispatch('contacts/deleteCustomAttributes', {
id: route.params.contactId,
customAttributes: [props.attribute.attributeKey],
});
useAlert(
t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.API.DELETE_SUCCESS_MESSAGE')
);
} catch (error) {
useAlert(
error?.response?.message ||
t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.API.DELETE_ERROR')
);
}
};
const handleUpdate = async value => {
try {
await store.dispatch('contacts/update', {
id: route.params.contactId,
customAttributes: {
[props.attribute.attributeKey]: value,
},
});
useAlert(t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.API.SUCCESS_MESSAGE'));
} catch (error) {
useAlert(
error?.response?.message ||
t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.API.UPDATE_ERROR')
);
}
};
const componentMap = {
list: ListAttribute,
checkbox: CheckboxAttribute,
date: DateAttribute,
default: OtherAttribute,
};
const CurrentAttributeComponent = computed(() => {
return (
componentMap[props.attribute.attributeDisplayType] || componentMap.default
);
});
</script>
<template>
<div
class="grid grid-cols-[140px,1fr] group/attribute items-center w-full gap-2"
: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">
{{ attribute.attributeDisplayName }}
</span>
</div>
<component
:is="CurrentAttributeComponent"
:attribute="attribute"
:is-editing-view="isEditingView"
@update="handleUpdate"
@delete="handleDelete"
/>
</div>
</template>

View File

@@ -0,0 +1,132 @@
<script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store';
import ContactCustomAttributeItem from 'dashboard/components-next/Contacts/ContactsSidebar/ContactCustomAttributeItem.vue';
const props = defineProps({
selectedContact: {
type: Object,
default: null,
},
});
const { t } = useI18n();
const searchQuery = ref('');
const contactAttributes = computed(() => {
const attributes = useMapGetter('attributes/getAttributesByModelType');
return attributes.value('contact_attribute') || [];
});
const hasContactAttributes = computed(
() => contactAttributes.value?.length > 0
);
const processContactAttributes = (
attributes,
customAttributes,
filterCondition
) => {
if (!attributes.length || !customAttributes) {
return [];
}
return attributes.reduce((result, attribute) => {
const { attributeKey } = attribute;
const meetsCondition = filterCondition(attributeKey, customAttributes);
if (meetsCondition) {
result.push({
...attribute,
value: customAttributes[attributeKey] ?? '',
});
}
return result;
}, []);
};
const usedAttributes = computed(() => {
return processContactAttributes(
contactAttributes.value,
props.selectedContact?.customAttributes,
(key, custom) => key in custom
);
});
const unusedAttributes = computed(() => {
return processContactAttributes(
contactAttributes.value,
props.selectedContact?.customAttributes,
(key, custom) => !(key in custom)
);
});
const filteredUnusedAttributes = computed(() => {
return unusedAttributes.value?.filter(attribute =>
attribute.attributeDisplayName
.toLowerCase()
.includes(searchQuery.value.toLowerCase())
);
});
const unusedAttributesCount = computed(() => unusedAttributes.value?.length);
const hasNoUnusedAttributes = computed(() => unusedAttributesCount.value === 0);
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="!hasNoUsedAttributes" class="flex flex-col gap-2">
<ContactCustomAttributeItem
v-for="attribute in usedAttributes"
:key="attribute.id"
is-editing-view
: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" />
<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 border-none rounded-lg bg-n-alpha-black2 dark:bg-n-solid-1 text-n-slate-12"
/>
</div>
<div
v-if="filteredUnusedAttributes.length === 0 && !hasNoUnusedAttributes"
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">
<ContactCustomAttributeItem
v-for="attribute in filteredUnusedAttributes"
:key="attribute.id"
:attribute="attribute"
/>
</div>
</div>
</div>
<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>
</template>

View File

@@ -0,0 +1,57 @@
<script setup>
import { computed } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import ConversationCard from 'dashboard/components-next/Conversation/ConversationCard/ConversationCard.vue';
const { t } = useI18n();
const route = useRoute();
const conversations = useMapGetter(
'contactConversations/getAllConversationsByContactId'
);
const contactsById = useMapGetter('contacts/getContactById');
const stateInbox = useMapGetter('inboxes/getInboxById');
const accountLabels = useMapGetter('labels/getLabels');
const accountLabelsValue = computed(() => accountLabels.value);
const uiFlags = useMapGetter('contactConversations/getUIFlags');
const isFetching = computed(() => uiFlags.value.isFetching);
const contactConversations = computed(() =>
conversations.value(route.params.contactId)
);
</script>
<template>
<div
v-if="isFetching"
class="flex items-center justify-center py-10 text-n-slate-11"
>
<Spinner />
</div>
<div v-else-if="contactConversations.length > 0" class="flex flex-col py-6">
<div
v-for="conversation in contactConversations"
:key="conversation.id"
class="border-b border-n-strong"
>
<ConversationCard
v-if="conversation"
:key="conversation.id"
:conversation="conversation"
:contact="contactsById(conversation.meta.sender.id)"
:state-inbox="stateInbox(conversation.inboxId)"
:account-labels="accountLabelsValue"
class="px-6 !rounded-none dark:hover:bg-n-alpha-3 hover:bg-n-alpha-1"
/>
</div>
</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') }}
</p>
</template>

View File

@@ -0,0 +1,142 @@
<script setup>
import { reactive, computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { required } from '@vuelidate/validators';
import { useVuelidate } from '@vuelidate/core';
import { useRoute } from 'vue-router';
import { useAlert, useTrack } from 'dashboard/composables';
import ContactAPI from 'dashboard/api/contacts';
import { debounce } from '@chatwoot/utils';
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';
const props = defineProps({
selectedContact: {
type: Object,
required: true,
},
});
const emit = defineEmits(['goToContactsList']);
const { t } = useI18n();
const store = useStore();
const route = useRoute();
const state = reactive({
primaryContactId: null,
});
const uiFlags = useMapGetter('contacts/getUIFlags');
const searchResults = ref([]);
const isSearching = ref(false);
const validationRules = {
primaryContactId: { required },
};
const v$ = useVuelidate(validationRules, state);
const isMergingContact = computed(() => uiFlags.value.isMerging);
const primaryContactList = computed(
() =>
searchResults.value?.map(item => ({
value: item.id,
label: `(ID: ${item.id}) ${item.name}`,
})) ?? []
);
const onContactSearch = debounce(
async query => {
isSearching.value = true;
searchResults.value = [];
try {
const {
data: { payload },
} = await ContactAPI.search(query);
searchResults.value = payload.filter(
contact => contact.id !== props.selectedContact.id
);
isSearching.value = false;
} catch (error) {
useAlert(t('CONTACTS_LAYOUT.SIDEBAR.MERGE.SEARCH_ERROR_MESSAGE'));
} finally {
isSearching.value = false;
}
},
300,
false
);
const resetState = () => {
state.primaryContactId = null;
searchResults.value = [];
isSearching.value = false;
};
const onMergeContacts = async () => {
const isFormValid = await v$.value.$validate();
if (!isFormValid) return;
useTrack(CONTACTS_EVENTS.MERGED_CONTACTS);
try {
await store.dispatch('contacts/merge', {
childId: props.selectedContact.id || route.params.contactId,
parentId: state.primaryContactId,
});
emit('goToContactsList');
useAlert(t('CONTACTS_LAYOUT.SIDEBAR.MERGE.SUCCESS_MESSAGE'));
resetState();
} catch (error) {
useAlert(t('CONTACTS_LAYOUT.SIDEBAR.MERGE.ERROR_MESSAGE'));
}
};
</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">
{{ t('CONTACTS_LAYOUT.SIDEBAR.MERGE.TITLE') }}
</h4>
<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 n-blue-text hover:bg-n-alpha-3"
@click="resetState"
/>
<Button
:label="t('CONTACTS_LAYOUT.SIDEBAR.MERGE.BUTTONS.CONFIRM')"
class="w-full"
:is-loading="isMergingContact"
:disabled="isMergingContact"
@click="onMergeContacts"
/>
</div>
</div>
</template>

View File

@@ -0,0 +1,99 @@
<script setup>
import { reactive, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useRoute } from 'vue-router';
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 ContactNoteItem from './components/ContactNoteItem.vue';
const { t } = useI18n();
const store = useStore();
const route = useRoute();
const state = reactive({
message: '',
});
const currentUser = useMapGetter('getCurrentUser');
const notesByContact = useMapGetter('contactNotes/getAllNotesByContactId');
const uiFlags = useMapGetter('contactNotes/getUIFlags');
const isFetchingNotes = computed(() => uiFlags.value.isFetching);
const isCreatingNote = computed(() => uiFlags.value.isCreating);
const notes = computed(() => notesByContact.value(route.params.contactId));
const getWrittenBy = note => {
const isCurrentUser = note?.user?.id === currentUser.value.id;
return isCurrentUser
? t('CONTACTS_LAYOUT.SIDEBAR.NOTES.YOU')
: note.user.name;
};
const onAdd = content => {
if (!content) return;
const { contactId } = route.params;
store.dispatch('contactNotes/create', { content, contactId });
state.message = '';
};
const onDelete = noteId => {
if (!noteId) return;
const { contactId } = route.params;
store.dispatch('contactNotes/delete', { noteId, contactId });
};
const keyboardEvents = {
'$mod+Enter': {
action: () => onAdd(state.message),
allowOnFocusedInput: true,
},
};
useKeyboardEvents(keyboardEvents);
</script>
<template>
<div class="flex flex-col gap-6 py-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>
<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"
:note="note"
:written-by="getWrittenBy(note)"
@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.EMPTY_STATE') }}
</p>
</div>
</template>

View File

@@ -0,0 +1,182 @@
<script setup>
import { computed, ref, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { dynamicTime } from 'shared/helpers/timeHelper';
import Avatar from 'dashboard/components-next/avatar/Avatar.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 avatarFile = ref(null);
const avatarUrl = ref('');
const contactsFormRef = ref(null);
const uiFlags = useMapGetter('contacts/getUIFlags');
const isUpdating = computed(() => uiFlags.value.isUpdating);
const isFormInvalid = computed(() => contactsFormRef.value?.isFormInvalid);
const contactData = ref({});
const getInitialContactData = () => {
if (!props.selectedContact) return {};
return { ...props.selectedContact };
};
onMounted(() => {
Object.assign(contactData.value, getInitialContactData());
});
const createdAt = computed(() => {
return contactData.value?.createdAt
? dynamicTime(contactData.value.createdAt)
: '';
});
const lastActivityAt = computed(() => {
return contactData.value?.lastActivityAt
? dynamicTime(contactData.value.lastActivityAt)
: '';
});
const avatarSrc = computed(() => {
return avatarUrl.value ? avatarUrl.value : contactData.value?.thumbnail;
});
const handleFormUpdate = updatedData => {
Object.assign(contactData.value, updatedData);
};
const updateContact = async () => {
try {
await store.dispatch('contacts/update', contactData.value);
useAlert(t('CONTACTS_LAYOUT.CARD.EDIT_DETAILS_FORM.SUCCESS_MESSAGE'));
} catch (error) {
useAlert(t('CONTACTS_LAYOUT.CARD.EDIT_DETAILS_FORM.ERROR_MESSAGE'));
}
};
const openConfirmDeleteContactDialog = () => {
confirmDeleteContactDialogRef.value?.dialogRef.open();
};
const handleAvatarUpload = async ({ file, url }) => {
avatarFile.value = file;
avatarUrl.value = url;
try {
await store.dispatch('contacts/update', {
...contactsFormRef.value?.state,
avatar: file,
isFormData: true,
});
useAlert(t('CONTACTS_LAYOUT.DETAILS.AVATAR.UPLOAD.SUCCESS_MESSAGE'));
} catch {
useAlert(t('CONTACTS_LAYOUT.DETAILS.AVATAR.UPLOAD.ERROR_MESSAGE'));
}
};
const handleAvatarDelete = async () => {
try {
if (props.selectedContact && props.selectedContact.id) {
await store.dispatch('contacts/deleteAvatar', props.selectedContact.id);
useAlert(t('CONTACTS_LAYOUT.DETAILS.AVATAR.DELETE.SUCCESS_MESSAGE'));
}
avatarFile.value = null;
avatarUrl.value = '';
contactData.value.thumbnail = null;
} catch (error) {
useAlert(
error.message
? error.message
: t('CONTACTS_LAYOUT.DETAILS.AVATAR.DELETE.ERROR_MESSAGE')
);
}
};
</script>
<template>
<div class="flex flex-col items-start gap-8 pb-6">
<div class="flex flex-col items-start gap-3">
<Avatar
:src="avatarSrc || ''"
:name="selectedContact?.name || ''"
:size="72"
allow-upload
@upload="handleAvatarUpload"
@delete="handleAvatarDelete"
/>
<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">
{{ $t('CONTACTS_LAYOUT.DETAILS.CREATED_AT', { date: createdAt }) }}
{{
$t('CONTACTS_LAYOUT.DETAILS.LAST_ACTIVITY', {
date: lastActivityAt,
})
}}
</span>
</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"
/>
<Button
:label="t('CONTACTS_LAYOUT.CARD.EDIT_DETAILS_FORM.UPDATE_BUTTON')"
size="sm"
:is-loading="isUpdating"
:disabled="isUpdating || isFormInvalid"
@click="updateContact"
/>
</div>
<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>
<ConfirmContactDeleteDialog
ref="confirmDeleteContactDialogRef"
:selected-contact="selectedContact"
@go-to-contacts-list="emit('goToContactsList')"
/>
</div>
</template>

View File

@@ -42,14 +42,17 @@ const updateContact = async updatedData => {
};
const onClickViewDetails = async id => {
const params = { contactId: id };
if (route.name.includes('segments')) {
params.segmentId = route.params.segmentId;
} else if (route.name.includes('labels')) {
params.label = route.params.label;
}
const routeTypes = {
contacts_dashboard_segments_index: ['contacts_edit_segment', 'segmentId'],
contacts_dashboard_labels_index: ['contacts_edit_label', 'label'],
};
const [name, paramKey] = routeTypes[route.name] || ['contacts_edit'];
const params = {
contactId: id,
...(paramKey && { [paramKey]: route.params[paramKey] }),
};
await router.push({ name: 'contacts_edit', params, query: route.query });
await router.push({ name, params, query: route.query });
};
const toggleExpanded = id => {

View File

@@ -1,6 +1,7 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
@@ -13,9 +14,15 @@ const props = defineProps({
const { t } = useI18n();
const { getPlainText } = useMessageFormatter();
const lastNonActivityMessageContent = computed(() => {
const { lastNonActivityMessage = {} } = props.conversation;
return lastNonActivityMessage?.content || t('CHAT_LIST.NO_CONTENT');
const { lastNonActivityMessage = {}, customAttributes = {} } =
props.conversation;
const { email: { subject } = {} } = customAttributes;
return getPlainText(
subject || lastNonActivityMessage.content || t('CHAT_LIST.NO_CONTENT')
);
});
const assignee = computed(() => {

View File

@@ -1,6 +1,7 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import CardLabels from 'dashboard/components-next/Conversation/ConversationCard/CardLabels.vue';
@@ -19,9 +20,15 @@ const props = defineProps({
const { t } = useI18n();
const { getPlainText } = useMessageFormatter();
const lastNonActivityMessageContent = computed(() => {
const { lastNonActivityMessage = {} } = props.conversation;
return lastNonActivityMessage?.content || t('CHAT_LIST.NO_CONTENT');
const { lastNonActivityMessage = {}, customAttributes = {} } =
props.conversation;
const { email: { subject } = {} } = customAttributes;
return getPlainText(
subject || lastNonActivityMessage.content || t('CHAT_LIST.NO_CONTENT')
);
});
const assignee = computed(() => {

View File

@@ -1,6 +1,8 @@
<script setup>
import { computed } from 'vue';
import { getInboxIconByType } from 'dashboard/helper/inbox';
import { useRouter, useRoute } from 'vue-router';
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper.js';
import { dynamicTime, shortTimestamp } from 'shared/helpers/timeHelper';
import Icon from 'dashboard/components-next/icon/Icon.vue';
@@ -28,6 +30,9 @@ const props = defineProps({
},
});
const router = useRouter();
const route = useRoute();
const currentContact = computed(() => props.contact);
const currentContactName = computed(() => currentContact.value?.name);
@@ -54,11 +59,31 @@ const showMessagePreviewWithoutMeta = computed(() => {
const { slaPolicyId, labels = [] } = props.conversation;
return !slaPolicyId && labels.length === 0;
});
const onCardClick = e => {
const path = frontendURL(
conversationUrl({
accountId: route.params.accountId,
id: props.conversation.id,
})
);
if (e.metaKey || e.ctrlKey) {
window.open(
window.chatwootConfig.hostURL + path,
'_blank',
'noopener noreferrer nofollow'
);
return;
}
router.push({ path });
};
</script>
<template>
<div
class="flex w-full gap-3 px-3 py-4 transition-colors duration-300 ease-in-out rounded-xl"
class="flex w-full gap-3 px-3 py-4 transition-colors duration-300 ease-in-out cursor-pointer rounded-xl"
@click="onCardClick"
>
<Avatar
:name="currentContactName"
@@ -75,7 +100,7 @@ const showMessagePreviewWithoutMeta = computed(() => {
<div class="flex items-center gap-2">
<CardPriorityIcon :priority="conversation.priority || null" />
<div
v-tooltip.top-start="inboxName"
v-tooltip.left="inboxName"
class="flex items-center justify-center flex-shrink-0 rounded-full bg-n-alpha-2 size-5"
>
<Icon

View File

@@ -88,7 +88,7 @@ const handleInputUpdate = async () => {
:class="{
'cursor-pointer text-n-slate-11 hover:text-n-slate-12 py-2 select-none font-medium':
!isEditingView,
'text-n-slate-12 truncate flex-1': isEditingView,
'text-n-slate-12 truncate': isEditingView,
}"
@click="toggleEditValue(!isEditingView)"
>

View File

@@ -127,9 +127,8 @@ const handleInputUpdate = async () => {
:class="{
'cursor-pointer text-n-slate-11 hover:text-n-slate-12 py-2 select-none font-medium':
!isEditingView,
'text-n-slate-12 truncate flex-1':
isEditingView && !isAttributeTypeLink,
'truncate flex-1 hover:text-n-brand text-n-blue-text':
'text-n-slate-12 truncate': isEditingView && !isAttributeTypeLink,
'truncate hover:text-n-brand text-n-blue-text':
isEditingView && isAttributeTypeLink,
}"
@click="toggleEditValue(!isEditingView)"

View File

@@ -1,5 +1,5 @@
<script setup>
import { computed, ref, watch } from 'vue';
import { computed, ref, watch, useSlots } from 'vue';
import WootEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
@@ -45,6 +45,8 @@ const props = defineProps({
const emit = defineEmits(['update:modelValue']);
const slots = useSlots();
const isFocused = ref(false);
const characterCount = computed(() => props.modelValue.length);
@@ -81,7 +83,7 @@ const handleBlur = () => {
watch(
() => props.modelValue,
newValue => {
if (props.maxLength && props.showCharacterCount) {
if (props.maxLength && props.showCharacterCount && !slots.actions) {
if (characterCount.value >= props.maxLength) {
emit('update:modelValue', newValue.slice(0, props.maxLength));
}
@@ -119,12 +121,16 @@ watch(
@blur="handleBlur"
/>
<div
v-if="showCharacterCount"
v-if="showCharacterCount || slots.actions"
class="flex items-center justify-end h-4 ltr:right-3 rtl:left-3"
>
<span class="text-xs tabular-nums text-n-slate-10">
<span
v-if="showCharacterCount && !slots.actions"
class="text-xs tabular-nums text-n-slate-10"
>
{{ characterCount }} / {{ maxLength }}
</span>
<slot v-else name="actions" />
</div>
</div>
<p
@@ -144,7 +150,7 @@ watch(
@apply gap-2 !important;
.ProseMirror-menubar {
@apply bg-transparent dark:bg-transparent w-fit left-1 pt-0 h-5 !important;
@apply bg-transparent dark:bg-transparent w-fit left-1 pt-0 h-5 !top-0 !relative !important;
.ProseMirror-menuitem {
@apply h-5 !important;
@@ -163,7 +169,7 @@ watch(
@apply m-0 !important;
&::before {
@apply text-n-slate-11 dark:text-n-slate-11 !important;
@apply text-n-slate-10 dark:text-n-slate-10 !important;
}
}
}

View File

@@ -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')
);
},
},
});

View File

@@ -29,7 +29,7 @@ const props = defineProps({
},
});
const emit = defineEmits(['update:searchValue', 'select', 'search']);
const emit = defineEmits(['select', 'search']);
const { t } = useI18n();

View File

@@ -22,7 +22,7 @@ defineProps({
},
});
const emit = defineEmits(['clearFilters']);
const emit = defineEmits(['clearFilters', 'openFilter']);
const shouldCapitalizeFirstLetter = key => {
const lowercaseKeys = ['email'];
@@ -64,7 +64,8 @@ const formatFilterValue = value => {
class="inline-flex items-center gap-2 h-7"
>
<div
class="flex items-center h-full min-w-0 gap-1 px-2 py-1 text-xs border rounded-lg max-w-72 border-n-weak"
class="flex items-center h-full min-w-0 gap-1 px-2 py-1 text-xs border rounded-lg hover:bg-n-solid-2 max-w-72 border-n-weak hover:cursor-pointer"
@click="emit('openFilter')"
>
<span
class="lowercase whitespace-nowrap first-letter:capitalize text-n-slate-12"
@@ -101,7 +102,8 @@ const formatFilterValue = value => {
</template>
<div
v-if="appliedFilters.length > maxVisibleFilters"
class="inline-flex items-center content-center px-1 text-xs rounded-lg text-n-slate-10 h-7"
class="inline-flex items-center content-center px-1 text-xs rounded-lg text-n-slate-10 hover:text-n-slate-11 h-7 hover:cursor-pointer"
@click="emit('openFilter')"
>
{{ moreFiltersLabel }}
</div>

View File

@@ -200,15 +200,9 @@ const menuItems = computed(() => {
to: accountScopedRoute(
'contacts_dashboard_index',
{},
{
page: 1,
search: undefined,
}
{ page: 1, search: undefined }
),
activeOn: [
'contacts_dashboard_index',
'contacts_dashboard_edit_index',
],
activeOn: ['contacts_dashboard_index', 'contacts_edit'],
},
{
name: 'Segments',
@@ -219,14 +213,13 @@ const menuItems = computed(() => {
label: view.name,
to: accountScopedRoute(
'contacts_dashboard_segments_index',
{
segmentId: view.id,
},
{
page: 1,
}
{ segmentId: view.id },
{ page: 1 }
),
activeOn: ['contacts_dashboard_segments_index'],
activeOn: [
'contacts_dashboard_segments_index',
'contacts_edit_segment',
],
})),
},
{
@@ -242,15 +235,13 @@ const menuItems = computed(() => {
}),
to: accountScopedRoute(
'contacts_dashboard_labels_index',
{
label: label.title,
},
{
page: 1,
search: undefined,
}
{ label: label.title },
{ page: 1, search: undefined }
),
activeOn: ['contacts_dashboard_labels_index'],
activeOn: [
'contacts_dashboard_labels_index',
'contacts_edit_label',
],
})),
},
],

View File

@@ -23,3 +23,16 @@ export function useSnakeCase(payload) {
const unrefPayload = unref(payload);
return snakecaseKeys(unrefPayload);
}
/**
* Converts a string from snake_case to camelCase
* @param {string} str - String to convert (can contain letters, numbers, or both)
* Examples: 'hello_world', 'user_123', 'checkbox_2', 'test_string_99'
* @returns {string} Converted string in camelCase
* Examples: 'helloWorld', 'user123', 'checkbox2', 'testString99'
*/
export function toCamelCase(str) {
return str
.toLowerCase()
.replace(/_([a-z0-9])/g, (_, char) => char.toUpperCase());
}

View File

@@ -558,7 +558,41 @@
}
}
},
"DETAILS": {
"CREATED_AT": "Created {date}",
"LAST_ACTIVITY": "Last active {date}",
"DELETE_CONTACT_DESCRIPTION": "Permanently delete this contact. This action is irreversible",
"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."
}
},
"AVATAR": {
"UPLOAD": {
"ERROR_MESSAGE": "Could not upload avatar. Please try again later.",
"SUCCESS_MESSAGE": "Avatar uploaded successfully"
},
"DELETE": {
"SUCCESS_MESSAGE": "Avatar deleted successfully",
"ERROR_MESSAGE": "Could not delete avatar. Please try again later."
}
}
},
"SIDEBAR": {
"TABS": {
"ATTRIBUTES": "Attributes",
"HISTORY": "History",
"NOTES": "Notes",
"MERGE": "Merge"
},
"HISTORY": {
"EMPTY_STATE": "There are no previous conversations associated to this contact"
},
"ATTRIBUTES": {
"SEARCH_PLACEHOLDER": "Search for attributes",
"UNUSED_ATTRIBUTES": "{count} Used attribute | {count} Unused attributes",

View File

@@ -1,123 +1,149 @@
<script>
import { mapGetters } from 'vuex';
import ContactInfoPanel from '../components/ContactInfoPanel.vue';
import ContactNotes from 'dashboard/modules/notes/NotesOnContactPage.vue';
import SettingsHeader from '../../settings/SettingsHeader.vue';
import Spinner from 'shared/components/Spinner.vue';
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
<script setup>
import { onMounted, computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useRoute, useRouter } from 'vue-router';
export default {
components: {
ContactInfoPanel,
ContactNotes,
SettingsHeader,
Spinner,
Thumbnail,
},
props: {
contactId: {
type: [String, Number],
required: true,
},
},
data() {
return {
selectedTabIndex: 0,
};
},
computed: {
...mapGetters({
uiFlags: 'contacts/getUIFlags',
}),
tabs() {
return [
{
key: 0,
name: this.$t('NOTES.HEADER.TITLE'),
},
];
},
showEmptySearchResult() {
const hasEmptyResults = !!this.searchQuery && this.records.length === 0;
return hasEmptyResults;
},
contact() {
return this.$store.getters['contacts/getContact'](this.contactId);
},
backUrl() {
if (window.history.state?.back || window.history.length > 1) {
return '';
}
return `/app/accounts/${this.$route.params.accountId}/contacts`;
},
},
mounted() {
this.fetchContactDetails();
},
methods: {
onClickTabChange(index) {
this.selectedTabIndex = index;
},
fetchContactDetails() {
const { contactId: id } = this;
this.$store.dispatch('contacts/show', { id });
},
},
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 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();
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 selectedContact = computed(() => contact.value(route.params.contactId));
const showSpinner = computed(
() => isFetchingItem.value || isMergingContact.value
);
const { t } = useI18n();
const CONTACT_TABS_OPTIONS = [
{ key: 'ATTRIBUTES', value: 'attributes' },
{ key: 'HISTORY', value: 'history' },
{ key: 'NOTES', value: 'notes' },
{ 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();
} else {
router.push(`/app/accounts/${route.params.accountId}/contacts?page=1`);
}
};
const fetchActiveContact = async () => {
if (route.params.contactId) {
store.dispatch('contacts/show', { id: route.params.contactId });
}
};
const handleTabChange = tab => {
activeTab.value = tab.value;
};
const fetchContactNotes = () => {
const { contactId } = route.params;
if (contactId) store.dispatch('contactNotes/get', { contactId });
};
const fetchContactConversations = () => {
const { contactId } = route.params;
if (contactId) store.dispatch('contactConversations/get', contactId);
};
const fetchAttributes = () => {
store.dispatch('attributes/get');
};
onMounted(() => {
fetchActiveContact();
fetchContactNotes();
fetchContactConversations();
fetchAttributes();
});
</script>
<template>
<div
class="flex justify-between flex-col h-full m-0 flex-1 bg-white dark:bg-slate-900"
class="flex flex-col justify-between flex-1 h-full m-0 overflow-auto bg-n-background"
>
<SettingsHeader
button-route="new"
:header-title="contact.name"
show-back-button
:back-button-label="$t('CONTACT_PROFILE.BACK_BUTTON')"
:back-url="backUrl"
:show-new-button="false"
<ContactsDetailsLayout
:button-label="$t('CONTACTS_LAYOUT.HEADER.MESSAGE_BUTTON')"
:selected-contact="selectedContact"
is-detail-view
:show-pagination-footer="false"
@go-to-contacts-list="goToContactsList"
>
<Thumbnail
v-if="contact.thumbnail"
:src="contact.thumbnail"
:username="contact.name"
size="32px"
class="mr-2 rtl:mr-0 rtl:ml-2"
/>
</SettingsHeader>
<div v-if="uiFlags.isFetchingItem" class="text-center p-4 text-base h-full">
<Spinner size="" />
<span>{{ $t('CONTACT_PROFILE.LOADING') }}</span>
</div>
<div v-else-if="contact.id" class="overflow-hidden flex-1 min-w-0">
<div class="flex flex-wrap ml-auto mr-auto max-w-full h-full">
<ContactInfoPanel
:show-close-button="false"
:show-avatar="false"
:contact="contact"
/>
<div class="w-3/4 h-full">
<woot-tabs :index="selectedTabIndex" @change="onClickTabChange">
<woot-tabs-item
v-for="(tab, index) in tabs"
:key="tab.key"
:index="index"
:name="tab.name"
:show-badge="false"
/>
</woot-tabs>
<div
class="bg-slate-25 dark:bg-slate-800 h-[calc(100%-40px)] p-4 overflow-auto"
>
<ContactNotes
v-if="selectedTabIndex === 0"
:contact-id="Number(contactId)"
/>
</div>
</div>
<div
v-if="showSpinner"
class="flex items-center justify-center py-10 text-n-slate-11"
>
<Spinner />
</div>
</div>
<ContactDetails
v-else-if="selectedContact"
:selected-contact="selectedContact"
@go-to-contacts-list="goToContactsList"
/>
<template #sidebar>
<div class="px-6">
<TabBar
:tabs="tabs"
:initial-active-tab="activeTabIndex"
class="w-full [&>button]:w-full bg-n-alpha-black2"
@tab-changed="handleTabChange"
/>
</div>
<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'" />
<ContactMerge
v-if="activeTab === 'merge'"
ref="contactMergeRef"
:selected-contact="selectedContact"
@go-to-contacts-list="goToContactsList"
/>
</template>
</template>
</ContactsDetailsLayout>
</div>
</template>

View File

@@ -133,6 +133,7 @@ const fetchSavedOrAppliedFilteredContact = async (payload, page = 1) => {
};
const searchContacts = debounce(async (value, page = 1) => {
await store.dispatch('contacts/clearContactFilters');
searchValue.value = value;
if (!value) {

View File

@@ -1,44 +1,60 @@
/* eslint arrow-body-style: 0 */
import { frontendURL } from '../../../helper/URLHelper';
import ContactsIndex from './pages/ContactsIndex.vue';
import ContactManageView from './pages/ContactManageView.vue';
const commonMeta = {
permissions: ['administrator', 'agent', 'contact_manage'],
};
export const routes = [
{
path: frontendURL('accounts/:accountId/contacts'),
component: ContactsIndex,
name: 'contacts_dashboard_index',
meta: {
permissions: ['administrator', 'agent', 'contact_manage'],
},
},
{
path: frontendURL('accounts/:accountId/contacts/segments/:segmentId'),
component: ContactsIndex,
name: 'contacts_dashboard_segments_index',
meta: {
permissions: ['administrator', 'agent', 'contact_manage'],
},
},
{
path: frontendURL('accounts/:accountId/contacts/labels/:label'),
component: ContactsIndex,
name: 'contacts_dashboard_labels_index',
meta: {
permissions: ['administrator', 'agent', 'contact_manage'],
},
meta: commonMeta,
children: [
{
path: '',
name: 'contacts_dashboard_index',
component: ContactsIndex,
meta: commonMeta,
},
{
path: 'segments/:segmentId',
name: 'contacts_dashboard_segments_index',
component: ContactsIndex,
meta: commonMeta,
},
{
path: 'labels/:label',
name: 'contacts_dashboard_labels_index',
component: ContactsIndex,
meta: commonMeta,
},
],
},
{
path: frontendURL('accounts/:accountId/contacts/:contactId'),
name: 'contacts_edit',
meta: {
permissions: ['administrator', 'agent', 'contact_manage'],
},
component: ContactManageView,
props: route => {
return { contactId: route.params.contactId };
},
meta: commonMeta,
children: [
{
path: '',
name: 'contacts_edit',
component: ContactManageView,
meta: commonMeta,
},
{
path: 'segments/:segmentId',
name: 'contacts_edit_segment',
component: ContactManageView,
meta: commonMeta,
},
{
path: 'labels/:label',
name: 'contacts_edit_label',
component: ContactManageView,
meta: commonMeta,
},
],
},
];

View File

@@ -35,6 +35,12 @@ export const getters = {
record => record.attribute_model === attributeModel
);
},
getAttributesByModelType: _state => attributeModel => {
const records = _state.records.filter(
record => record.attribute_model === attributeModel
);
return camelcaseKeys(records, { deep: true });
},
};
export const actions = {

View File

@@ -1,6 +1,7 @@
import * as types from '../mutation-types';
import ContactAPI from '../../api/contacts';
import ConversationApi from '../../api/conversations';
import camelcaseKeys from 'camelcase-keys';
export const createMessagePayload = (payload, message) => {
const { content, cc_emails, bcc_emails } = message;
@@ -74,6 +75,10 @@ export const getters = {
getContactConversation: $state => id => {
return $state.records[Number(id)] || [];
},
getAllConversationsByContactId: $state => id => {
const records = $state.records[Number(id)] || [];
return camelcaseKeys(records, { deep: true });
},
};
export const actions = {

View File

@@ -1,5 +1,6 @@
import types from '../mutation-types';
import ContactNotesAPI from '../../api/contactNotes';
import camelcaseKeys from 'camelcase-keys';
export const state = {
records: {},
@@ -18,6 +19,11 @@ export const getters = {
getUIFlags(_state) {
return _state.uiFlags;
},
getAllNotesByContactId: _state => contactId => {
const records = _state.records[contactId] || [];
const contactNotes = records.sort((r1, r2) => r2.id - r1.id);
return camelcaseKeys(contactNotes);
},
};
export const actions = {

View File

@@ -34,7 +34,7 @@ const buildContactFormData = contactParams => {
return formData;
};
export const raiseContactCreateErrors = error => {
export const handleContactOperationErrors = error => {
if (error.response?.status === 422) {
throw new DuplicateContactException(error.response.data.attributes);
} else if (error.response?.data?.message) {
@@ -91,9 +91,12 @@ export const actions = {
},
update: async ({ commit }, { id, isFormData = false, ...contactParams }) => {
const decamelizedContactParams = decamelizeKeys(contactParams, {
deep: true,
});
const { avatar, customAttributes, ...paramsToDecamelize } = contactParams;
const decamelizedContactParams = {
...decamelizeKeys(paramsToDecamelize),
...(customAttributes && { custom_attributes: customAttributes }),
...(avatar && { avatar }),
};
commit(types.SET_CONTACT_UI_FLAG, { isUpdating: true });
try {
const response = await ContactAPI.update(
@@ -106,11 +109,7 @@ export const actions = {
commit(types.SET_CONTACT_UI_FLAG, { isUpdating: false });
} catch (error) {
commit(types.SET_CONTACT_UI_FLAG, { isUpdating: false });
if (error.response?.status === 422) {
throw new DuplicateContactException(error.response.data.attributes);
} else {
throw new Error(error);
}
handleContactOperationErrors(error);
}
},
@@ -132,7 +131,7 @@ export const actions = {
return response.data.payload.contact;
} catch (error) {
commit(types.SET_CONTACT_UI_FLAG, { isCreating: false });
return raiseContactCreateErrors(error);
return handleContactOperationErrors(error);
}
},

View File

@@ -17,6 +17,13 @@ export const getters = {
const contact = $state.records[id];
return contact || {};
},
getContactById: $state => id => {
const contact = $state.records[id];
return camelcaseKeys(contact || {}, {
deep: true,
stopPaths: ['custom_attributes'],
});
},
getMeta: $state => {
return $state.meta;
},

View File

@@ -7,6 +7,7 @@ import FBChannel from '../../api/channel/fbChannel';
import TwilioChannel from '../../api/channel/twilioChannel';
import { throwErrorMessage } from '../utils/api';
import AnalyticsHelper from '../../helper/AnalyticsHelper';
import camelcaseKeys from 'camelcase-keys';
import { ACCOUNT_EVENTS } from '../../helper/AnalyticsHelper/events';
const buildInboxData = inboxParams => {
@@ -92,6 +93,12 @@ export const getters = {
);
return inbox || {};
},
getInboxById: $state => inboxId => {
const [inbox] = $state.records.filter(
record => record.id === Number(inboxId)
);
return camelcaseKeys(inbox || {}, { deep: true });
},
getUIFlags($state) {
return $state.uiFlags;
},