feat(v4): Update the design for the contacts list page (#10501)

---------
Co-authored-by: Pranav <pranavrajs@gmail.com>
Co-authored-by: Pranav <pranav@chatwoot.com>
This commit is contained in:
Sivin Varghese
2024-11-28 09:37:20 +05:30
committed by GitHub
parent 25c61aba25
commit a50e4f1748
29 changed files with 1517 additions and 115 deletions

View File

@@ -1,10 +1,12 @@
<script setup> <script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import CardLayout from 'dashboard/components-next/CardLayout.vue'; import CardLayout from 'dashboard/components-next/CardLayout.vue';
import ContactsForm from 'dashboard/components-next/Contacts/ContactsForm/ContactsForm.vue'; import ContactsForm from 'dashboard/components-next/Contacts/ContactsForm/ContactsForm.vue';
import Button from 'dashboard/components-next/button/Button.vue'; import Button from 'dashboard/components-next/button/Button.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue'; import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import countries from 'shared/constants/countries';
const props = defineProps({ const props = defineProps({
id: { type: Number, required: true }, id: { type: Number, required: true },
@@ -14,46 +16,108 @@ const props = defineProps({
phoneNumber: { type: String, default: '' }, phoneNumber: { type: String, default: '' },
thumbnail: { type: String, default: '' }, thumbnail: { type: String, default: '' },
isExpanded: { type: Boolean, default: false }, isExpanded: { type: Boolean, default: false },
isUpdating: { type: Boolean, default: false },
}); });
const emit = defineEmits(['toggle', 'updateContact', 'showContact']); const emit = defineEmits(['toggle', 'updateContact', 'showContact']);
const { t } = useI18n(); const { t } = useI18n();
const contactsFormRef = ref(null);
const getInitialContactData = () => ({
id: props.id,
name: props.name,
email: props.email,
phoneNumber: props.phoneNumber,
additionalAttributes: props.additionalAttributes,
});
const contactData = ref(getInitialContactData());
const isFormInvalid = computed(() => contactsFormRef.value?.isFormInvalid);
const countriesMap = computed(() => {
return countries.reduce((acc, country) => {
acc[country.code] = country;
acc[country.id] = country;
return acc;
}, {});
});
const countryDetails = computed(() => {
const attributes = props.additionalAttributes || {};
const { country, countryCode, city } = attributes;
if (!country && !countryCode) return null;
const activeCountry =
countriesMap.value[country] || countriesMap.value[countryCode];
if (!activeCountry) return null;
const parts = [
activeCountry.emoji,
city ? `${city},` : null,
activeCountry.name,
].filter(Boolean);
return parts.length ? parts.join(' ') : null;
});
const handleFormUpdate = updatedData => { const handleFormUpdate = updatedData => {
emit('updateContact', { id: props.id, updatedData }); Object.assign(contactData.value, updatedData);
}; };
const onClickViewDetails = async () => { const handleUpdateContact = () => {
emit('showContact', props.id); emit('updateContact', contactData.value);
}; };
const onClickExpand = () => {
emit('toggle');
contactData.value = getInitialContactData();
};
const onClickViewDetails = () => emit('showContact', props.id);
</script> </script>
<template> <template>
<CardLayout :key="id" layout="row"> <CardLayout :key="id" layout="row">
<div class="flex items-center justify-between gap-4"> <div class="flex items-center justify-start flex-1 gap-4">
<Avatar :name="name" :src="thumbnail" :size="48" rounded-full /> <Avatar :name="name" :src="thumbnail" :size="48" rounded-full />
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-0.5 flex-1">
<div class="flex items-center gap-2"> <div class="flex flex-wrap items-center gap-x-4 gap-y-1">
<span class="text-sm font-medium truncate text-n-slate-12"> <span class="text-base font-medium truncate text-n-slate-12">
{{ name }} {{ name }}
</span> </span>
<template v-if="additionalAttributes?.companyName"> <span class="inline-flex items-center gap-1">
<span class="text-sm text-n-slate-11"> <span
{{ t('CONTACTS_LAYOUT.CARD.OF') }} v-if="additionalAttributes?.companyName"
</span> class="i-ph-building-light size-4 text-n-slate-10 mb-0.5"
<span class="text-sm font-medium truncate text-n-slate-12"> />
<span
v-if="additionalAttributes?.companyName"
class="text-sm truncate text-n-slate-11"
>
{{ additionalAttributes.companyName }} {{ additionalAttributes.companyName }}
</span> </span>
</template> </span>
</div> </div>
<div class="flex items-center gap-3"> <div class="flex flex-wrap items-center justify-start gap-x-3 gap-y-1">
<span v-if="email" class="text-sm text-n-slate-11">{{ email }}</span> <div v-if="email" class="truncate max-w-72" :title="email">
<div v-if="email" class="w-px h-3 bg-n-slate-6" /> <span class="text-sm text-n-slate-11">
<span v-if="phoneNumber" class="text-sm text-n-slate-11"> {{ email }}
</span>
</div>
<div v-if="email" class="w-px h-3 truncate bg-n-slate-6" />
<span v-if="phoneNumber" class="text-sm truncate text-n-slate-11">
{{ phoneNumber }} {{ phoneNumber }}
</span> </span>
<div v-if="phoneNumber" class="w-px h-3 bg-n-slate-6" /> <div v-if="phoneNumber" class="w-px h-3 truncate bg-n-slate-6" />
<span v-if="countryDetails" class="text-sm truncate text-n-slate-11">
{{ countryDetails }}
</span>
<div v-if="countryDetails" class="w-px h-3 truncate bg-n-slate-6" />
<Button <Button
:label="t('CONTACTS_LAYOUT.CARD.VIEW_DETAILS')" :label="t('CONTACTS_LAYOUT.CARD.VIEW_DETAILS')"
variant="link" variant="link"
@@ -70,7 +134,7 @@ const onClickViewDetails = async () => {
color="slate" color="slate"
size="xs" size="xs"
:class="{ 'rotate-180': isExpanded }" :class="{ 'rotate-180': isExpanded }"
@click="emit('toggle')" @click="onClickExpand"
/> />
<template #after> <template #after>
@@ -78,22 +142,28 @@ const onClickViewDetails = async () => {
enter-active-class="overflow-hidden transition-all duration-300 ease-out" enter-active-class="overflow-hidden transition-all duration-300 ease-out"
leave-active-class="overflow-hidden transition-all duration-300 ease-in" leave-active-class="overflow-hidden transition-all duration-300 ease-in"
enter-from-class="overflow-hidden opacity-0 max-h-0" enter-from-class="overflow-hidden opacity-0 max-h-0"
enter-to-class="opacity-100 max-h-[360px]" enter-to-class="opacity-100 max-h-[690px] sm:max-h-[470px] md:max-h-[410px]"
leave-from-class="opacity-100 max-h-[360px]" leave-from-class="opacity-100 max-h-[690px] sm:max-h-[470px] md:max-h-[410px]"
leave-to-class="overflow-hidden opacity-0 max-h-0" leave-to-class="overflow-hidden opacity-0 max-h-0"
> >
<div v-show="isExpanded" class="w-full"> <div v-show="isExpanded" class="w-full">
<div class="p-6 border-t border-n-strong"> <div class="flex flex-col gap-6 p-6 border-t border-n-strong">
<ContactsForm <ContactsForm
:contact-data="{ ref="contactsFormRef"
id, :contact-data="contactData"
name,
email,
phoneNumber,
additionalAttributes,
}"
@update="handleFormUpdate" @update="handleFormUpdate"
/> />
<div>
<Button
:label="
t('CONTACTS_LAYOUT.CARD.EDIT_DETAILS_FORM.UPDATE_BUTTON')
"
size="sm"
:is-loading="isUpdating"
:disabled="isUpdating || isFormInvalid"
@click="handleUpdateContact"
/>
</div>
</div> </div>
</div> </div>
</transition> </transition>

View File

@@ -0,0 +1,66 @@
<script setup>
import { ref, computed } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import filterQueryGenerator from 'dashboard/helper/filterQueryGenerator';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
const emit = defineEmits(['export']);
const { t } = useI18n();
const route = useRoute();
const dialogRef = ref(null);
const segments = useMapGetter('customViews/getContactCustomViews');
const appliedFilters = useMapGetter('contacts/getAppliedContactFilters');
const uiFlags = useMapGetter('contacts/getUIFlags');
const isExportingContact = computed(() => uiFlags.value.isExporting);
const activeSegmentId = computed(() => route.params.segmentId);
const activeSegment = computed(() =>
activeSegmentId.value
? segments.value.find(view => view.id === Number(activeSegmentId.value))
: undefined
);
const exportContacts = async () => {
let query = { payload: [] };
if (activeSegmentId.value && activeSegment.value) {
query = activeSegment.value.query;
} else if (Object.keys(appliedFilters.value).length > 0) {
query = filterQueryGenerator(appliedFilters.value);
}
emit('export', {
...query,
label: route.params.label || '',
});
};
const handleDialogConfirm = async () => {
await exportContacts();
dialogRef.value?.close();
};
defineExpose({ dialogRef });
</script>
<template>
<Dialog
ref="dialogRef"
:title="t('CONTACTS_LAYOUT.HEADER.ACTIONS.EXPORT_CONTACT.TITLE')"
:description="
t('CONTACTS_LAYOUT.HEADER.ACTIONS.EXPORT_CONTACT.DESCRIPTION')
"
:confirm-button-label="
t('CONTACTS_LAYOUT.HEADER.ACTIONS.EXPORT_CONTACT.CONFIRM')
"
:is-loading="isExportingContact"
:disable-confirm-button="isExportingContact"
@confirm="handleDialogConfirm"
/>
</template>

View File

@@ -0,0 +1,134 @@
<script setup>
import { ref, computed } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const emit = defineEmits(['import']);
const { t } = useI18n();
const uiFlags = useMapGetter('contacts/getUIFlags');
const isImportingContact = computed(() => uiFlags.value.isImporting);
const dialogRef = ref(null);
const fileInput = ref(null);
const hasSelectedFile = ref(null);
const selectedFileName = ref('');
const csvUrl = '/downloads/import-contacts-sample.csv';
const handleFileClick = () => fileInput.value?.click();
const processFileName = fileName => {
const lastDotIndex = fileName.lastIndexOf('.');
const extension = fileName.slice(lastDotIndex);
const baseName = fileName.slice(0, lastDotIndex);
return baseName.length > 20
? `${baseName.slice(0, 20)}...${extension}`
: fileName;
};
const handleFileChange = () => {
const file = fileInput.value?.files[0];
hasSelectedFile.value = file;
selectedFileName.value = file ? processFileName(file.name) : '';
};
const handleRemoveFile = () => {
hasSelectedFile.value = null;
if (fileInput.value) {
fileInput.value.value = null;
}
selectedFileName.value = '';
};
const uploadFile = async () => {
if (!hasSelectedFile.value) return;
emit('import', hasSelectedFile.value);
};
defineExpose({ dialogRef });
</script>
<template>
<Dialog
ref="dialogRef"
:title="t('CONTACTS_LAYOUT.HEADER.ACTIONS.IMPORT_CONTACT.TITLE')"
:confirm-button-label="
t('CONTACTS_LAYOUT.HEADER.ACTIONS.IMPORT_CONTACT.IMPORT')
"
:is-loading="isImportingContact"
:disable-confirm-button="isImportingContact"
@confirm="uploadFile"
>
<template #description>
<p class="mb-0 text-sm text-n-slate-11">
{{ t('CONTACTS_LAYOUT.HEADER.ACTIONS.IMPORT_CONTACT.DESCRIPTION') }}
<a
:href="csvUrl"
target="_blank"
rel="noopener noreferrer"
download="import-contacts-sample.csv"
class="text-n-blue-text"
>
{{
t('CONTACTS_LAYOUT.HEADER.ACTIONS.IMPORT_CONTACT.DOWNLOAD_LABEL')
}}
</a>
</p>
</template>
<div class="flex flex-col gap-2">
<div class="flex items-center gap-2">
<label class="text-sm text-n-slate-12 whitespace-nowrap">
{{ t('CONTACTS_LAYOUT.HEADER.ACTIONS.IMPORT_CONTACT.LABEL') }}
</label>
<div class="flex items-center justify-between w-full gap-2">
<span v-if="hasSelectedFile" class="text-sm text-n-slate-12">
{{ selectedFileName }}
</span>
<Button
v-if="!hasSelectedFile"
:label="
t('CONTACTS_LAYOUT.HEADER.ACTIONS.IMPORT_CONTACT.CHOOSE_FILE')
"
icon="i-lucide-upload"
color="slate"
variant="ghost"
size="sm"
class="!w-fit"
@click="handleFileClick"
/>
<div v-else class="flex items-center gap-1">
<Button
:label="t('CONTACTS_LAYOUT.HEADER.ACTIONS.IMPORT_CONTACT.CHANGE')"
color="slate"
variant="ghost"
size="sm"
@click="handleFileClick"
/>
<div class="w-px h-3 bg-n-strong" />
<Button
icon="i-lucide-trash"
color="slate"
variant="ghost"
size="sm"
@click="handleRemoveFile"
/>
</div>
</div>
</div>
</div>
<input
ref="fileInput"
type="file"
accept="text/csv"
class="hidden"
@change="handleFileChange"
/>
</Dialog>
</template>

View File

@@ -3,7 +3,7 @@ import { computed, reactive, watch } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { required, email, minLength } from '@vuelidate/validators'; import { required, email, minLength } from '@vuelidate/validators';
import { useVuelidate } from '@vuelidate/core'; import { useVuelidate } from '@vuelidate/core';
import { splitName } from '@chatwoot/utils';
import countries from 'shared/constants/countries.js'; import countries from 'shared/constants/countries.js';
import Input from 'dashboard/components-next/input/Input.vue'; import Input from 'dashboard/components-next/input/Input.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue'; import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
@@ -80,6 +80,8 @@ const validationRules = {
const v$ = useVuelidate(validationRules, state); const v$ = useVuelidate(validationRules, state);
const isFormInvalid = computed(() => v$.value.$invalid);
const prepareStateBasedOnProps = () => { const prepareStateBasedOnProps = () => {
if (props.isNewContact) { if (props.isNewContact) {
return; // Added to prevent state update for new contact form return; // Added to prevent state update for new contact form
@@ -92,8 +94,7 @@ const prepareStateBasedOnProps = () => {
phoneNumber, phoneNumber,
additionalAttributes = {}, additionalAttributes = {},
} = props.contactData || {}; } = props.contactData || {};
const { firstName, lastName } = splitName(name);
const [firstName = '', lastName = ''] = name.split(' ');
const { const {
description, description,
companyName, companyName,
@@ -203,6 +204,16 @@ const getMessageType = key => {
: 'info'; : 'info';
}; };
const handleCountrySelection = value => {
const selectedCountry = countries.find(option => option.name === value);
state.additionalAttributes.countryCode = selectedCountry?.id || '';
emit('update', state);
};
const resetValidation = () => {
v$.value.$reset();
};
watch(() => props.contactData, prepareStateBasedOnProps, { watch(() => props.contactData, prepareStateBasedOnProps, {
immediate: true, immediate: true,
deep: true, deep: true,
@@ -211,6 +222,8 @@ watch(() => props.contactData, prepareStateBasedOnProps, {
// Expose state to parent component for avatar upload // Expose state to parent component for avatar upload
defineExpose({ defineExpose({
state, state,
resetValidation,
isFormInvalid,
}); });
</script> </script>
@@ -220,7 +233,7 @@ defineExpose({
<span class="py-1 text-sm font-medium text-n-slate-12"> <span class="py-1 text-sm font-medium text-n-slate-12">
{{ t('CONTACTS_LAYOUT.CARD.EDIT_DETAILS_FORM.TITLE') }} {{ t('CONTACTS_LAYOUT.CARD.EDIT_DETAILS_FORM.TITLE') }}
</span> </span>
<div class="grid w-full grid-cols-2 gap-4"> <div class="grid w-full grid-cols-1 gap-4 sm:grid-cols-2">
<template v-for="item in editDetailsForm" :key="item.key"> <template v-for="item in editDetailsForm" :key="item.key">
<ComboBox <ComboBox
v-if="item.key === 'COUNTRY'" v-if="item.key === 'COUNTRY'"
@@ -234,7 +247,7 @@ defineExpose({
'[&>div>button]:!outline-n-weak [&>div>button]:hover:!outline-n-strong [&>div>button]:!bg-n-alpha-black2': '[&>div>button]:!outline-n-weak [&>div>button]:hover:!outline-n-strong [&>div>button]:!bg-n-alpha-black2':
isDetailsView, isDetailsView,
}" }"
@update:model-value="emit('update', state)" @update:model-value="handleCountrySelection"
/> />
<PhoneNumberInput <PhoneNumberInput
v-else-if="item.key === 'PHONE_NUMBER'" v-else-if="item.key === 'PHONE_NUMBER'"

View File

@@ -0,0 +1,63 @@
<script setup>
import { ref, computed } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import ContactsForm from 'dashboard/components-next/Contacts/ContactsForm/ContactsForm.vue';
const emit = defineEmits(['create']);
const { t } = useI18n();
const dialogRef = ref(null);
const contactsFormRef = ref(null);
const contact = ref(null);
const uiFlags = useMapGetter('contacts/getUIFlags');
const isCreatingContact = computed(() => uiFlags.value.isCreating);
const createNewContact = contactItem => {
contact.value = contactItem;
};
const handleDialogConfirm = async () => {
if (!contact.value) return;
emit('create', contact.value);
};
const closeDialog = () => {
dialogRef.value.close();
};
defineExpose({ dialogRef, contactsFormRef });
</script>
<template>
<Dialog ref="dialogRef" width="3xl" @confirm="handleDialogConfirm">
<ContactsForm
ref="contactsFormRef"
is-new-contact
@update="createNewContact"
/>
<template #footer>
<div class="flex items-center justify-between w-full gap-3">
<Button
:label="t('DIALOG.BUTTONS.CANCEL')"
variant="link"
class="h-10 hover:!no-underline hover:text-n-brand"
@click="closeDialog"
/>
<Button
:label="
t('CONTACTS_LAYOUT.HEADER.ACTIONS.CONTACT_CREATION.SAVE_CONTACT')
"
color="blue"
:is-loading="isCreatingContact"
@click="handleDialogConfirm"
/>
</div>
</template>
</Dialog>
</template>

View File

@@ -0,0 +1,71 @@
<script setup>
import { ref, reactive, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store';
import { useVuelidate } from '@vuelidate/core';
import { required } from '@vuelidate/validators';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import Input from 'dashboard/components-next/input/Input.vue';
const emit = defineEmits(['create']);
const FILTER_TYPE_CONTACT = 1;
const { t } = useI18n();
const uiFlags = useMapGetter('customViews/getUIFlags');
const isCreating = computed(() => uiFlags.value.isCreating);
const dialogRef = ref(null);
const state = reactive({
name: '',
});
const validationRules = {
name: { required },
};
const v$ = useVuelidate(validationRules, state);
const handleDialogConfirm = async () => {
const isNameValid = await v$.value.$validate();
if (!isNameValid) return;
emit('create', {
name: state.name,
filter_type: FILTER_TYPE_CONTACT,
});
state.name = '';
v$.value.$reset();
};
defineExpose({ dialogRef });
</script>
<template>
<Dialog
ref="dialogRef"
:title="t('CONTACTS_LAYOUT.HEADER.ACTIONS.FILTERS.CREATE_SEGMENT.TITLE')"
:confirm-button-label="
t('CONTACTS_LAYOUT.HEADER.ACTIONS.FILTERS.CREATE_SEGMENT.CONFIRM')
"
:is-loading="isCreating"
:disable-confirm-button="isCreating"
@confirm="handleDialogConfirm"
>
<Input
v-model="state.name"
:label="t('CONTACTS_LAYOUT.HEADER.ACTIONS.FILTERS.CREATE_SEGMENT.LABEL')"
:placeholder="
t('CONTACTS_LAYOUT.HEADER.ACTIONS.FILTERS.CREATE_SEGMENT.PLACEHOLDER')
"
:message="
v$.name.$error
? t('CONTACTS_LAYOUT.HEADER.ACTIONS.FILTERS.CREATE_SEGMENT.ERROR')
: ''
"
:message-type="v$.name.$error ? 'error' : 'info'"
/>
</Dialog>
</template>

View File

@@ -0,0 +1,43 @@
<script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
const emit = defineEmits(['delete']);
const FILTER_TYPE_CONTACT = 'contact';
const { t } = useI18n();
const uiFlags = useMapGetter('customViews/getUIFlags');
const isDeleting = computed(() => uiFlags.value.isDeleting);
const dialogRef = ref(null);
const handleDialogConfirm = async () => {
emit('delete', {
filterType: FILTER_TYPE_CONTACT,
});
};
defineExpose({ dialogRef });
</script>
<template>
<Dialog
ref="dialogRef"
type="alert"
:title="t('CONTACTS_LAYOUT.HEADER.ACTIONS.FILTERS.DELETE_SEGMENT.TITLE')"
:description="
t('CONTACTS_LAYOUT.HEADER.ACTIONS.FILTERS.DELETE_SEGMENT.DESCRIPTION')
"
:confirm-button-label="
t('CONTACTS_LAYOUT.HEADER.ACTIONS.FILTERS.DELETE_SEGMENT.CONFIRM')
"
:is-loading="isDeleting"
:disable-confirm-button="isDeleting"
@confirm="handleDialogConfirm"
/>
</template>

View File

@@ -18,10 +18,10 @@ defineProps({
type: String, type: String,
required: true, required: true,
}, },
buttonLabel: { // buttonLabel: {
type: String, // type: String,
required: true, // default: '',
}, // },
activeSort: { activeSort: {
type: String, type: String,
default: 'last_activity_at', default: 'last_activity_at',
@@ -30,16 +30,26 @@ defineProps({
type: String, type: String,
default: '', default: '',
}, },
isSegmentsView: {
type: Boolean,
default: false,
},
hasActiveFilters: {
type: Boolean,
default: false,
},
}); });
const emit = defineEmits([ const emit = defineEmits([
'search', 'search',
'filter', 'filter',
'update:sort', 'update:sort',
'message', // 'message',
'add', 'add',
'import', 'import',
'export', 'export',
'createSegment',
'deleteSegment',
]); ]);
</script> </script>
@@ -72,11 +82,37 @@ const emit = defineEmits([
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Button <Button
icon="i-lucide-list-filter" :icon="
isSegmentsView ? 'i-lucide-pen-line' : 'i-lucide-list-filter'
"
color="slate" color="slate"
size="sm" size="sm"
class="relative"
variant="ghost" variant="ghost"
@click="emit('filter')" @click="emit('filter')"
>
<div
v-if="hasActiveFilters && !isSegmentsView"
class="absolute top-0 right-0 w-2 h-2 rounded-full bg-n-brand"
/>
</Button>
<Button
v-if="hasActiveFilters && !isSegmentsView"
icon="i-lucide-save"
color="slate"
size="sm"
class="relative"
variant="ghost"
@click="emit('createSegment')"
/>
<Button
v-if="isSegmentsView"
icon="i-lucide-trash"
color="slate"
size="sm"
class="relative"
variant="ghost"
@click="emit('deleteSegment')"
/> />
<ContactSortMenu <ContactSortMenu
:active-sort="activeSort" :active-sort="activeSort"
@@ -89,8 +125,9 @@ const emit = defineEmits([
@export="emit('export')" @export="emit('export')"
/> />
</div> </div>
<div class="w-px h-4 bg-n-strong" /> <!-- TODO: Add this when we enabling message feature -->
<Button :label="buttonLabel" size="sm" @click="emit('message')" /> <!-- <div class="w-px h-4 bg-n-strong" /> -->
<!-- <Button :label="buttonLabel" size="sm" @click="emit('message')" /> -->
</div> </div>
</div> </div>
</header> </header>

View File

@@ -0,0 +1,276 @@
<script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore } from 'dashboard/composables/store';
import { useRouter } from 'vue-router';
import { useAlert, useTrack } from 'dashboard/composables';
import { CONTACTS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import filterQueryGenerator from 'dashboard/helper/filterQueryGenerator';
import contactFilterItems from 'dashboard/routes/dashboard/contacts/contactFilterItems';
import { generateValuesForEditCustomViews } from 'dashboard/helper/customViewsHelper';
import countries from 'shared/constants/countries';
import ContactsHeader from 'dashboard/components-next/Contacts/ContactsHeader/ContactHeader.vue';
import CreateNewContactDialog from 'dashboard/components-next/Contacts/ContactsForm/CreateNewContactDialog.vue';
import ContactExportDialog from 'dashboard/components-next/Contacts/ContactsForm/ContactExportDialog.vue';
import ContactImportDialog from 'dashboard/components-next/Contacts/ContactsForm/ContactImportDialog.vue';
import CreateSegmentDialog from 'dashboard/components-next/Contacts/ContactsForm/CreateSegmentDialog.vue';
import DeleteSegmentDialog from 'dashboard/components-next/Contacts/ContactsForm/DeleteSegmentDialog.vue';
import ContactsAdvancedFilters from 'dashboard/routes/dashboard/contacts/components/ContactsAdvancedFilters.vue';
const props = defineProps({
showSearch: {
type: Boolean,
default: true,
},
searchValue: {
type: String,
default: '',
},
activeSort: {
type: String,
default: 'last_activity_at',
},
activeOrdering: {
type: String,
default: '',
},
headerTitle: {
type: String,
default: '',
},
segmentsId: {
type: [String, Number],
default: 0,
},
activeSegment: {
type: Object,
default: null,
},
hasAppliedFilters: {
type: Boolean,
default: false,
},
});
const emit = defineEmits([
'update:sort',
'search',
'applyFilter',
'clearFilters',
]);
const { t } = useI18n();
const store = useStore();
const router = useRouter();
const createNewContactDialogRef = ref(null);
const contactExportDialogRef = ref(null);
const contactImportDialogRef = ref(null);
const createSegmentDialogRef = ref(null);
const deleteSegmentDialogRef = ref(null);
const showFiltersModal = ref(false);
const appliedFilter = ref([]);
const segmentsQuery = ref({});
const hasActiveSegments = computed(
() => props.activeSegment && props.segmentsId !== 0
);
const activeSegmentName = computed(() => props.activeSegment?.name);
const contactFilterItemsList = computed(() =>
contactFilterItems.map(filter => ({
...filter,
attributeName: t(`CONTACTS_FILTER.ATTRIBUTES.${filter.attributeI18nKey}`),
}))
);
const openCreateNewContactDialog = async () => {
await createNewContactDialogRef.value?.contactsFormRef.resetValidation();
createNewContactDialogRef.value?.dialogRef.open();
};
const openContactImportDialog = () =>
contactImportDialogRef.value?.dialogRef.open();
const openContactExportDialog = () =>
contactExportDialogRef.value?.dialogRef.open();
const openCreateSegmentDialog = () =>
createSegmentDialogRef.value?.dialogRef.open();
const openDeleteSegmentDialog = () =>
deleteSegmentDialogRef.value?.dialogRef.open();
const onCreate = async contact => {
await store.dispatch('contacts/create', contact);
createNewContactDialogRef.value?.dialogRef.close();
};
const onImport = async file => {
try {
await store.dispatch('contacts/import', file);
contactImportDialogRef.value?.dialogRef.close();
useAlert(t('IMPORT_CONTACTS.SUCCESS_MESSAGE'));
useTrack(CONTACTS_EVENTS.IMPORT_SUCCESS);
} catch (error) {
useAlert(error.message ?? t('IMPORT_CONTACTS.ERROR_MESSAGE'));
useTrack(CONTACTS_EVENTS.IMPORT_FAILURE);
}
};
const onExport = async query => {
try {
await store.dispatch('contacts/export', query);
useAlert(
t('CONTACTS_LAYOUT.HEADER.ACTIONS.EXPORT_CONTACT.SUCCESS_MESSAGE')
);
} catch (error) {
useAlert(
error.message ||
t('CONTACTS_LAYOUT.HEADER.ACTIONS.EXPORT_CONTACT.ERROR_MESSAGE')
);
}
};
const onCreateSegment = async payload => {
try {
const payloadData = {
...payload,
query: segmentsQuery.value,
};
await store.dispatch('customViews/create', payloadData);
createSegmentDialogRef.value?.dialogRef.close();
useAlert(
t('CONTACTS_LAYOUT.HEADER.ACTIONS.FILTERS.CREATE_SEGMENT.SUCCESS_MESSAGE')
);
} catch {
useAlert(
t('CONTACTS_LAYOUT.HEADER.ACTIONS.FILTERS.CREATE_SEGMENT.ERROR_MESSAGE')
);
}
};
const onDeleteSegment = async payload => {
try {
await store.dispatch('customViews/delete', {
id: Number(props.segmentsId),
...payload,
});
router.push({
name: 'contacts_dashboard_index',
query: {
page: 1,
},
});
deleteSegmentDialogRef.value?.dialogRef.close();
useAlert(
t('CONTACTS_LAYOUT.HEADER.ACTIONS.FILTERS.DELETE_SEGMENT.SUCCESS_MESSAGE')
);
} catch (error) {
useAlert(
t('CONTACTS_LAYOUT.HEADER.ACTIONS.FILTERS.DELETE_SEGMENT.ERROR_MESSAGE')
);
}
};
const closeAdvanceFiltersModal = () => {
showFiltersModal.value = false;
appliedFilter.value = [];
};
const clearFilters = async () => {
await store.dispatch('contacts/clearContactFilters');
emit('clearFilters');
};
const onApplyFilter = async payload => {
segmentsQuery.value = filterQueryGenerator(payload);
emit('applyFilter', filterQueryGenerator(payload));
showFiltersModal.value = false;
};
const onUpdateSegment = async (payload, segmentName) => {
const payloadData = {
...props.activeSegment,
name: segmentName,
query: filterQueryGenerator(payload),
};
await store.dispatch('customViews/update', payloadData);
closeAdvanceFiltersModal();
};
const setParamsForEditSegmentModal = () => {
return {
countries,
filterTypes: contactFilterItems,
allCustomAttributes:
store.getters['attributes/getAttributesByModel']('contact_attribute'),
};
};
const initializeSegmentToFilterModal = segment => {
const query = segment?.query?.payload;
if (!Array.isArray(query)) return;
appliedFilter.value = query.map(filter => ({
attribute_key: filter.attribute_key,
attribute_model: filter.attribute_model,
filter_operator: filter.filter_operator,
values: Array.isArray(filter.values)
? generateValuesForEditCustomViews(filter, setParamsForEditSegmentModal())
: [],
query_operator: filter.query_operator,
custom_attribute_type: filter.custom_attribute_type,
}));
};
const onToggleFilters = () => {
appliedFilter.value = [];
if (hasActiveSegments.value) {
initializeSegmentToFilterModal(props.activeSegment);
}
showFiltersModal.value = true;
};
</script>
<template>
<ContactsHeader
:show-search="showSearch"
:search-value="searchValue"
:active-sort="activeSort"
:active-ordering="activeOrdering"
:header-title="headerTitle"
:is-segments-view="hasActiveSegments"
:has-active-filters="hasAppliedFilters"
:button-label="t('CONTACTS_LAYOUT.HEADER.MESSAGE_BUTTON')"
@search="emit('search', $event)"
@update:sort="emit('update:sort', $event)"
@add="openCreateNewContactDialog"
@import="openContactImportDialog"
@export="openContactExportDialog"
@filter="onToggleFilters"
@create-segment="openCreateSegmentDialog"
@delete-segment="openDeleteSegmentDialog"
/>
<CreateNewContactDialog ref="createNewContactDialogRef" @create="onCreate" />
<ContactExportDialog ref="contactExportDialogRef" @export="onExport" />
<ContactImportDialog ref="contactImportDialogRef" @import="onImport" />
<CreateSegmentDialog ref="createSegmentDialogRef" @create="onCreateSegment" />
<DeleteSegmentDialog ref="deleteSegmentDialogRef" @delete="onDeleteSegment" />
<woot-modal
v-model:show="showFiltersModal"
:on-close="closeAdvanceFiltersModal"
size="medium"
>
<ContactsAdvancedFilters
v-if="showFiltersModal"
:on-close="closeAdvanceFiltersModal"
:initial-filter-types="contactFilterItemsList"
:initial-applied-filters="appliedFilter"
:active-segment-name="activeSegmentName"
:is-segments-view="hasActiveSegments"
@apply-filter="onApplyFilter"
@update-segment="onUpdateSegment"
@clear-filters="clearFilters"
/>
</woot-modal>
</template>

View File

@@ -0,0 +1,112 @@
<script setup>
import { computed } from 'vue';
import { useRoute } from 'vue-router';
import ContactListHeaderWrapper from 'dashboard/components-next/Contacts/ContactsHeader/ContactListHeaderWrapper.vue';
import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue';
defineProps({
searchValue: {
type: String,
default: '',
},
headerTitle: {
type: String,
default: '',
},
showPaginationFooter: {
type: Boolean,
default: true,
},
currentPage: {
type: Number,
default: 1,
},
totalItems: {
type: Number,
default: 100,
},
itemsPerPage: {
type: Number,
default: 15,
},
activeSort: {
type: String,
default: '',
},
activeOrdering: {
type: String,
default: '',
},
activeSegment: {
type: Object,
default: null,
},
segmentsId: {
type: [String, Number],
default: 0,
},
hasAppliedFilters: {
type: Boolean,
default: false,
},
});
const emit = defineEmits([
'update:currentPage',
'update:sort',
'search',
'applyFilter',
'clearFilters',
]);
const route = useRoute();
const isNotSegmentView = computed(() => {
return route.name !== 'contacts_dashboard_segments_index';
});
const updateCurrentPage = page => {
emit('update:currentPage', page);
};
</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">
<ContactListHeaderWrapper
:show-search="isNotSegmentView"
:search-value="searchValue"
:active-sort="activeSort"
:active-ordering="activeOrdering"
:header-title="headerTitle"
:active-segment="activeSegment"
:segments-id="segmentsId"
:has-applied-filters="hasAppliedFilters"
@update:sort="emit('update:sort', $event)"
@search="emit('search', $event)"
@apply-filter="emit('applyFilter', $event)"
@clear-filters="emit('clearFilters')"
/>
<main class="flex-1 px-6 overflow-y-auto xl:px-px">
<div class="w-full mx-auto max-w-[960px]">
<slot name="default" />
</div>
</main>
<footer
v-if="showPaginationFooter"
class="sticky bottom-0 z-10 px-4 pb-4"
>
<PaginationFooter
current-page-info="CONTACTS_LAYOUT.PAGINATION_FOOTER.SHOWING"
:current-page="currentPage"
:total-items="totalItems"
:items-per-page="itemsPerPage"
@update:current-page="updateCurrentPage"
/>
</footer>
</div>
</section>
</template>

View File

@@ -1,5 +1,8 @@
<script setup> <script setup>
import { ref } from 'vue';
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue'; import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
import CreateNewContactDialog from 'dashboard/components-next/Contacts/ContactsForm/CreateNewContactDialog.vue';
import Button from 'dashboard/components-next/button/Button.vue'; import Button from 'dashboard/components-next/button/Button.vue';
import ContactsCard from 'dashboard/components-next/Contacts/ContactsCard/ContactsCard.vue'; import ContactsCard from 'dashboard/components-next/Contacts/ContactsCard/ContactsCard.vue';
import contactContent from 'dashboard/components-next/Contacts/EmptyState/contactEmptyStateContent'; import contactContent from 'dashboard/components-next/Contacts/EmptyState/contactEmptyStateContent';
@@ -22,6 +25,14 @@ defineProps({
default: '', default: '',
}, },
}); });
const emit = defineEmits(['create']);
const createNewContactDialogRef = ref(null);
const onClick = () => {
createNewContactDialogRef.value?.dialogRef.open();
};
</script> </script>
<template> <template>
@@ -45,6 +56,10 @@ defineProps({
<template #actions> <template #actions>
<div v-if="showButton"> <div v-if="showButton">
<Button :label="buttonLabel" icon="i-lucide-plus" @click="onClick" /> <Button :label="buttonLabel" icon="i-lucide-plus" @click="onClick" />
<CreateNewContactDialog
ref="createNewContactDialogRef"
@create="emit('create', $event)"
/>
</div> </div>
</template> </template>
</EmptyStateLayout> </EmptyStateLayout>

View File

@@ -0,0 +1,78 @@
<script setup>
import { ref, computed } from 'vue';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import { useRouter, useRoute } from 'vue-router';
import {
DuplicateContactException,
ExceptionWithMessage,
} from 'shared/helpers/CustomErrors';
import ContactsCard from 'dashboard/components-next/Contacts/ContactsCard/ContactsCard.vue';
defineProps({ contacts: { type: Array, required: true } });
const { t } = useI18n();
const store = useStore();
const router = useRouter();
const route = useRoute();
const uiFlags = useMapGetter('contacts/getUIFlags');
const isUpdating = computed(() => uiFlags.value.isUpdating);
const expandedCardId = ref(null);
const updateContact = async updatedData => {
try {
await store.dispatch('contacts/update', updatedData);
useAlert(t('CONTACTS_LAYOUT.CARD.EDIT_DETAILS_FORM.SUCCESS_MESSAGE'));
} catch (error) {
const i18nPrefix = 'CONTACTS_LAYOUT.CARD.EDIT_DETAILS_FORM.FORM';
if (error instanceof DuplicateContactException) {
if (error.data.includes('email')) {
useAlert(t(`${i18nPrefix}.EMAIL_ADDRESS.DUPLICATE`));
} else if (error.data.includes('phone_number')) {
useAlert(t(`${i18nPrefix}.PHONE_NUMBER.DUPLICATE`));
}
} else if (error instanceof ExceptionWithMessage) {
useAlert(error.data);
} else {
useAlert(t(`${i18nPrefix}.ERROR_MESSAGE`));
}
}
};
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;
}
await router.push({ name: 'contacts_edit', params, query: route.query });
};
const toggleExpanded = id => {
expandedCardId.value = expandedCardId.value === id ? null : id;
};
</script>
<template>
<div class="flex flex-col gap-4 p-6">
<ContactsCard
v-for="contact in contacts"
:id="contact.id"
:key="contact.id"
:name="contact.name"
:email="contact.email"
:thumbnail="contact.thumbnail"
:phone-number="contact.phoneNumber"
:additional-attributes="contact.additionalAttributes"
:is-expanded="expandedCardId === contact.id"
:is-updating="isUpdating"
@toggle="toggleExpanded(contact.id)"
@update-contact="updateContact"
@show-contact="onClickViewDetails"
/>
</div>
</template>

View File

@@ -132,7 +132,7 @@ const iconStyles = computed(() => ({
})); }));
const initialsStyles = computed(() => ({ const initialsStyles = computed(() => ({
fontSize: `${props.size / 2}px`, fontSize: `${props.size > 32 ? 16 : props.size / 2}px`,
})); }));
const invalidateCurrentImage = () => { const invalidateCurrentImage = () => {

View File

@@ -17,6 +17,10 @@ const props = defineProps({
type: Number, type: Number,
default: 16, default: 16,
}, },
currentPageInfo: {
type: String,
default: '',
},
}); });
const emit = defineEmits(['update:currentPage']); const emit = defineEmits(['update:currentPage']);
const { t } = useI18n(); const { t } = useI18n();
@@ -39,11 +43,14 @@ const changePage = newPage => {
}; };
const currentPageInformation = computed(() => { const currentPageInformation = computed(() => {
return t('PAGINATION_FOOTER.SHOWING', { return t(
startItem: startItem.value, props.currentPageInfo ? props.currentPageInfo : 'PAGINATION_FOOTER.SHOWING',
endItem: endItem.value, {
totalItems: props.totalItems, startItem: startItem.value,
}); endItem: endItem.value,
totalItems: props.totalItems,
}
);
}); });
const pageInfo = computed(() => { const pageInfo = computed(() => {
@@ -84,7 +91,7 @@ const pageInfo = computed(() => {
<span class="px-3 tabular-nums py-0.5 bg-n-alpha-black2 rounded-md"> <span class="px-3 tabular-nums py-0.5 bg-n-alpha-black2 rounded-md">
{{ currentPage }} {{ currentPage }}
</span> </span>
<span>{{ pageInfo }}</span> <span class="truncate">{{ pageInfo }}</span>
</div> </div>
<Button <Button
icon="i-lucide-chevron-right" icon="i-lucide-chevron-right"

View File

@@ -197,7 +197,18 @@ const menuItems = computed(() => {
{ {
name: 'All Contacts', name: 'All Contacts',
label: t('SIDEBAR.ALL_CONTACTS'), label: t('SIDEBAR.ALL_CONTACTS'),
to: accountScopedRoute('contacts_dashboard'), to: accountScopedRoute(
'contacts_dashboard_index',
{},
{
page: 1,
search: undefined,
}
),
activeOn: [
'contacts_dashboard_index',
'contacts_dashboard_edit_index',
],
}, },
{ {
name: 'Segments', name: 'Segments',
@@ -206,9 +217,16 @@ const menuItems = computed(() => {
children: contactCustomViews.value.map(view => ({ children: contactCustomViews.value.map(view => ({
name: `${view.name}-${view.id}`, name: `${view.name}-${view.id}`,
label: view.name, label: view.name,
to: accountScopedRoute('contacts_segments_dashboard', { to: accountScopedRoute(
id: view.id, 'contacts_dashboard_segments_index',
}), {
segmentId: view.id,
},
{
page: 1,
}
),
activeOn: ['contacts_dashboard_segments_index'],
})), })),
}, },
{ {
@@ -222,9 +240,17 @@ const menuItems = computed(() => {
class: `size-[12px] ring-1 ring-n-alpha-1 dark:ring-white/20 ring-inset rounded-sm`, class: `size-[12px] ring-1 ring-n-alpha-1 dark:ring-white/20 ring-inset rounded-sm`,
style: { backgroundColor: label.color }, style: { backgroundColor: label.color },
}), }),
to: accountScopedRoute('contacts_labels_dashboard', { to: accountScopedRoute(
label: label.title, 'contacts_dashboard_labels_index',
}), {
label: label.title,
},
{
page: 1,
search: undefined,
}
),
activeOn: ['contacts_dashboard_labels_index'],
})), })),
}, },
], ],
@@ -344,18 +370,6 @@ const menuItems = computed(() => {
}), }),
}, },
], ],
activeOn: [
'portals_new',
'portals_index',
'portals_articles_index',
'portals_articles_new',
'portals_articles_edit',
'portals_categories_index',
'portals_categories_articles_index',
'portals_categories_articles_edit',
'portals_locales_index',
'portals_settings_index',
],
}, },
{ {
name: 'Settings', name: 'Settings',

View File

@@ -66,14 +66,35 @@ const activeChild = computed(() => {
); );
if (pathSame) return pathSame; if (pathSame) return pathSame;
const pathSatrtsWith = navigableChildren.value.find( // Rank the activeOn Prop higher than the path match
child => child.to && route.path.startsWith(resolvePath(child.to)) // There will be cases where the path name is the same but the params are different
); // So we need to rank them based on the params
if (pathSatrtsWith) return pathSatrtsWith; // For example, contacts segment list in the sidebar effectively has the same name
// But the params are different
return navigableChildren.value.find(child => const activeOnPages = navigableChildren.value.filter(child =>
child.activeOn?.includes(route.name) child.activeOn?.includes(route.name)
); );
if (activeOnPages.length > 0) {
const rankedPage = activeOnPages.find(child => {
return Object.keys(child.to.params)
.map(key => {
return String(child.to.params[key]) === String(route.params[key]);
})
.every(match => match);
});
// If there is no ranked page, return the first activeOn page anyway
// Since this takes higher precedence over the path match
// This is not perfect, ideally we should rank each route based on all the techniques
// and then return the highest ranked one
// But this is good enough for now
return rankedPage ?? activeOnPages[0];
}
return navigableChildren.value.find(
child => child.to && route.path.startsWith(resolvePath(child.to))
);
}); });
const hasActiveChild = computed(() => { const hasActiveChild = computed(() => {

View File

@@ -4,7 +4,7 @@ const contacts = accountId => ({
parentNav: 'contacts', parentNav: 'contacts',
routes: [ routes: [
'contacts_dashboard', 'contacts_dashboard',
'contact_profile_dashboard', 'contacts_edit',
'contacts_segments_dashboard', 'contacts_segments_dashboard',
'contacts_labels_dashboard', 'contacts_labels_dashboard',
], ],

View File

@@ -91,10 +91,11 @@ describe('useAccount', () => {
it('returns an account-scoped route', () => { it('returns an account-scoped route', () => {
const wrapper = mount(createComponent(), mountParams); const wrapper = mount(createComponent(), mountParams);
const { accountScopedRoute } = wrapper.vm; const { accountScopedRoute } = wrapper.vm;
const result = accountScopedRoute('accountDetail', { userId: 456 }); const result = accountScopedRoute('accountDetail', { userId: 456 }, {});
expect(result).toEqual({ expect(result).toEqual({
name: 'accountDetail', name: 'accountDetail',
params: { accountId: 123, userId: 456 }, params: { accountId: 123, userId: 456 },
query: {},
}); });
}); });

View File

@@ -28,10 +28,11 @@ export function useAccount() {
return `/app/accounts/${accountId.value}/${url}`; return `/app/accounts/${accountId.value}/${url}`;
}; };
const accountScopedRoute = (name, params) => { const accountScopedRoute = (name, params, query) => {
return { return {
name, name,
params: { accountId: accountId.value, ...params }, params: { accountId: accountId.value, ...params },
query: { ...query },
}; };
}; };

View File

@@ -441,9 +441,31 @@
"ASCENDING": "Ascending", "ASCENDING": "Ascending",
"DESCENDING": "Descending" "DESCENDING": "Descending"
} }
},
"FILTERS": {
"CREATE_SEGMENT": {
"TITLE": "Do you want to save this filter?",
"CONFIRM": "Save filter",
"LABEL": "Name",
"PLACEHOLDER": "Enter the name of the filter",
"ERROR": "Enter a valid name",
"SUCCESS_MESSAGE": "Filter saved successfully",
"ERROR_MESSAGE": "Unable to save filter. Please try again later."
},
"DELETE_SEGMENT": {
"TITLE": "Confirm Deletion",
"DESCRIPTION": "Are you sure you want to delete this filter?",
"CONFIRM": "Yes, Delete",
"CANCEL": "No, Cancel",
"SUCCESS_MESSAGE": "Filter deleted successfully",
"ERROR_MESSAGE": "Unable to delete filter. Please try again later."
}
} }
} }
}, },
"PAGINATION_FOOTER": {
"SHOWING": "Showing {startItem} - {endItem} of {totalItems} contacts"
},
"CARD": { "CARD": {
"OF": "of", "OF": "of",
"VIEW_DETAILS": "View details", "VIEW_DETAILS": "View details",
@@ -457,10 +479,12 @@
"PLACEHOLDER": "Enter the last name" "PLACEHOLDER": "Enter the last name"
}, },
"EMAIL_ADDRESS": { "EMAIL_ADDRESS": {
"PLACEHOLDER": "Enter the email address" "PLACEHOLDER": "Enter the email address",
"DUPLICATE": "This email address is in use for another contact."
}, },
"PHONE_NUMBER": { "PHONE_NUMBER": {
"PLACEHOLDER": "Enter the phone number" "PLACEHOLDER": "Enter the phone number",
"DUPLICATE": "This phone number is in use for another contact."
}, },
"CITY": { "CITY": {
"PLACEHOLDER": "Enter the city name" "PLACEHOLDER": "Enter the city name"
@@ -474,7 +498,10 @@
"COMPANY_NAME": { "COMPANY_NAME": {
"PLACEHOLDER": "Enter the company name" "PLACEHOLDER": "Enter the company name"
} }
} },
"UPDATE_BUTTON": "Update contact",
"SUCCESS_MESSAGE": "Contact updated successfully",
"ERROR_MESSAGE": "Unable to update contact. Please try again later."
}, },
"SOCIAL_MEDIA": { "SOCIAL_MEDIA": {
"TITLE": "Edit social links", "TITLE": "Edit social links",
@@ -550,6 +577,13 @@
"SAVE": "Save note", "SAVE": "Save note",
"EMPTY_STATE": "There are no notes associated to this contact. You can add a note by typing in the box above." "EMPTY_STATE": "There are no notes associated to this contact. You can add a note by typing in the box above."
} }
},
"EMPTY_STATE": {
"TITLE": "No contacts found in this account",
"SUBTITLE": "Start adding new contacts by clicking on the button below",
"BUTTON_LABEL": "Add contact",
"SEARCH_EMPTY_STATE_TITLE": "No contacts matches your search 🔍",
"LIST_EMPTY_STATE_TITLE": "No contacts available in this view 📋"
} }
} }
} }

View File

@@ -83,7 +83,7 @@ export default {
this.filterTypes = [...this.filterTypes, ...filterTypes]; this.filterTypes = [...this.filterTypes, ...filterTypes];
this.filterGroups = filterGroups; this.filterGroups = filterGroups;
if (this.getAppliedContactFilters.length) { if (this.getAppliedContactFilters.length && !this.isSegmentsView) {
this.appliedFilters = [...this.getAppliedContactFilters]; this.appliedFilters = [...this.getAppliedContactFilters];
} else if (!this.isSegmentsView) { } else if (!this.isSegmentsView) {
this.appliedFilters.push({ this.appliedFilters.push({
@@ -318,7 +318,7 @@ export default {
@reset-filter="resetFilter(i, appliedFilters[i])" @reset-filter="resetFilter(i, appliedFilters[i])"
@remove-filter="removeFilter(i)" @remove-filter="removeFilter(i)"
/> />
<div class="mt-4"> <div class="flex items-center gap-2 mt-4">
<woot-button <woot-button
icon="add" icon="add"
color-scheme="success" color-scheme="success"

View File

@@ -45,6 +45,9 @@ export default {
return this.$store.getters['contacts/getContact'](this.contactId); return this.$store.getters['contacts/getContact'](this.contactId);
}, },
backUrl() { backUrl() {
if (window.history.state?.back || window.history.length > 1) {
return '';
}
return `/app/accounts/${this.$route.params.accountId}/contacts`; return `/app/accounts/${this.$route.params.accountId}/contacts`;
}, },
}, },

View File

@@ -0,0 +1,298 @@
<script setup>
import { onMounted, computed, ref, reactive, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { debounce } from '@chatwoot/utils';
import { useUISettings } from 'dashboard/composables/useUISettings';
import filterQueryGenerator from 'dashboard/helper/filterQueryGenerator';
import ContactsListLayout from 'dashboard/components-next/Contacts/ContactsListLayout.vue';
import ContactsList from 'dashboard/components-next/Contacts/Pages/ContactsList.vue';
import ContactEmptyState from 'dashboard/components-next/Contacts/EmptyState/ContactEmptyState.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
const DEFAULT_SORT_FIELD = 'last_activity_at';
const DEBOUNCE_DELAY = 300;
const store = useStore();
const route = useRoute();
const router = useRouter();
const { t } = useI18n();
const { updateUISettings, uiSettings } = useUISettings();
const contacts = useMapGetter('contacts/getContactsList');
const uiFlags = useMapGetter('contacts/getUIFlags');
const customViewsUiFlags = useMapGetter('customViews/getUIFlags');
const segments = useMapGetter('customViews/getContactCustomViews');
const appliedFilters = useMapGetter('contacts/getAppliedContactFilters');
const meta = useMapGetter('contacts/getMeta');
const searchQuery = computed(() => route.query?.search);
const searchValue = ref(searchQuery.value || '');
const pageNumber = computed(() => Number(route.query?.page) || 1);
const parseSortSettings = (sortString = '') => {
const hasDescending = sortString.startsWith('-');
const sortField = hasDescending ? sortString.slice(1) : sortString;
return {
sort: sortField || DEFAULT_SORT_FIELD,
order: hasDescending ? '-' : '',
};
};
const { contacts_sort_by: contactSortBy = '' } = uiSettings.value ?? {};
const { sort: initialSort, order: initialOrder } =
parseSortSettings(contactSortBy);
const sortState = reactive({
activeSort: initialSort,
activeOrdering: initialOrder,
});
const activeLabel = computed(() => route.params.label);
const activeSegmentId = computed(() => route.params.segmentId);
const isFetchingList = computed(
() => uiFlags.value.isFetching || customViewsUiFlags.value.isFetching
);
const currentPage = computed(() => Number(meta.value?.currentPage));
const totalItems = computed(() => meta.value?.count);
const activeSegment = computed(() => {
if (!activeSegmentId.value) return undefined;
return segments.value.find(view => view.id === Number(activeSegmentId.value));
});
const hasContacts = computed(() => contacts.value.length > 0);
const isContactIndexView = computed(
() => route.name === 'contacts_dashboard_index' && pageNumber.value === 1
);
const hasAppliedFilters = computed(() => {
return appliedFilters.value.length > 0;
});
const showEmptyStateLayout = computed(() => {
return (
!searchQuery.value &&
!hasContacts.value &&
isContactIndexView.value &&
!hasAppliedFilters.value
);
});
const showEmptyText = computed(() => {
return (
(searchQuery.value ||
hasAppliedFilters.value ||
!isContactIndexView.value) &&
!hasContacts.value
);
});
const headerTitle = computed(() => {
if (searchQuery.value) return t('CONTACTS_LAYOUT.HEADER.SEARCH_TITLE');
if (activeSegmentId.value) return activeSegment.value?.name;
if (activeLabel.value) return `#${activeLabel.value}`;
return t('CONTACTS_LAYOUT.HEADER.TITLE');
});
const updatePageParam = (page, search = '') => {
const query = {
...route.query,
page: page.toString(),
...(search ? { search } : {}),
};
if (!search) {
delete query.search;
}
router.replace({ query });
};
const buildSortAttr = () =>
`${sortState.activeOrdering}${sortState.activeSort}`;
const getCommonFetchParams = (page = 1) => ({
page,
sortAttr: buildSortAttr(),
label: activeLabel.value,
});
const fetchContacts = async (page = 1) => {
await store.dispatch('contacts/get', getCommonFetchParams(page));
updatePageParam(page);
};
const fetchSavedOrAppliedFilteredContact = async (payload, page = 1) => {
if (!activeSegmentId.value && !hasAppliedFilters.value) return;
await store.dispatch('contacts/filter', {
...getCommonFetchParams(page),
queryPayload: payload,
});
updatePageParam(page);
};
const searchContacts = debounce(async (value, page = 1) => {
searchValue.value = value;
if (!value) {
updatePageParam(page);
await fetchContacts(page);
return;
}
updatePageParam(page, value);
await store.dispatch('contacts/search', {
...getCommonFetchParams(page),
search: encodeURIComponent(value),
});
}, DEBOUNCE_DELAY);
const fetchContactsBasedOnContext = async page => {
updatePageParam(page, searchValue.value);
if (isFetchingList.value) return;
if (searchQuery.value) {
await searchContacts(searchQuery.value, page);
return;
}
// Reset the search value when we change the view
searchValue.value = '';
// If there are applied filters or active segment with query
if (
(hasAppliedFilters.value || activeSegment.value?.query) &&
!activeLabel.value
) {
const queryPayload =
activeSegment.value?.query || filterQueryGenerator(appliedFilters.value);
await fetchSavedOrAppliedFilteredContact(queryPayload, page);
return;
}
// Default case: fetch regular contacts + label
await fetchContacts(page);
};
const handleSort = async ({ sort, order }) => {
Object.assign(sortState, { activeSort: sort, activeOrdering: order });
await updateUISettings({
contacts_sort_by: buildSortAttr(),
});
if (searchQuery.value) {
await searchContacts(searchValue.value);
return;
}
await (activeSegmentId.value || hasAppliedFilters.value
? fetchSavedOrAppliedFilteredContact(
activeSegmentId.value
? activeSegment.value?.query
: filterQueryGenerator(appliedFilters.value)
)
: fetchContacts());
};
const createContact = async contact => {
await store.dispatch('contacts/create', contact);
};
watch(
() => uiSettings.value?.contacts_sort_by,
newSortBy => {
if (newSortBy) {
const { sort, order } = parseSortSettings(newSortBy);
sortState.activeSort = sort;
sortState.activeOrdering = order;
}
},
{ immediate: true }
);
watch(
[activeLabel, activeSegment],
() => {
fetchContactsBasedOnContext(pageNumber.value);
},
{ deep: true }
);
watch(searchQuery, value => {
if (isFetchingList.value) return;
searchValue.value = value || '';
// Reset the view if there is search query when we click on the sidebar group
if (value === undefined) {
fetchContacts();
}
});
onMounted(async () => {
if (!activeSegmentId.value) {
if (searchQuery.value) {
await searchContacts(searchQuery.value, pageNumber.value);
return;
}
await fetchContacts(pageNumber.value);
} else if (activeSegment.value && activeSegmentId.value) {
await fetchSavedOrAppliedFilteredContact(
activeSegment.value.query,
pageNumber.value
);
}
});
</script>
<template>
<div
class="flex flex-col justify-between flex-1 h-full m-0 overflow-auto bg-n-background"
>
<ContactsListLayout
:search-value="searchValue"
:header-title="headerTitle"
:current-page="currentPage"
:total-items="totalItems"
:show-pagination-footer="!isFetchingList && hasContacts"
:active-sort="sortState.activeSort"
:active-ordering="sortState.activeOrdering"
:active-segment="activeSegment"
:segments-id="activeSegmentId"
:has-applied-filters="hasAppliedFilters"
@update:current-page="fetchContactsBasedOnContext"
@search="searchContacts"
@update:sort="handleSort"
@apply-filter="fetchSavedOrAppliedFilteredContact"
@clear-filters="fetchContacts"
>
<div
v-if="isFetchingList"
class="flex items-center justify-center py-10 text-n-slate-11"
>
<Spinner />
</div>
<template v-else>
<ContactEmptyState
v-if="showEmptyStateLayout"
class="pt-14"
:title="t('CONTACTS_LAYOUT.EMPTY_STATE.TITLE')"
:subtitle="t('CONTACTS_LAYOUT.EMPTY_STATE.SUBTITLE')"
:button-label="t('CONTACTS_LAYOUT.EMPTY_STATE.BUTTON_LABEL')"
@create="createContact"
/>
<div
v-else-if="showEmptyText"
class="flex items-center justify-center py-10"
>
<span class="text-base text-n-slate-11">
{{
searchQuery || !hasAppliedFilters
? t('CONTACTS_LAYOUT.EMPTY_STATE.SEARCH_EMPTY_STATE_TITLE')
: t('CONTACTS_LAYOUT.EMPTY_STATE.LIST_EMPTY_STATE_TITLE')
}}
</span>
</div>
<ContactsList v-else :contacts="contacts" />
</template>
</ContactsListLayout>
</div>
</template>

View File

@@ -1,42 +1,38 @@
/* eslint arrow-body-style: 0 */ /* eslint arrow-body-style: 0 */
import { frontendURL } from '../../../helper/URLHelper'; import { frontendURL } from '../../../helper/URLHelper';
import ContactsView from './components/ContactsView.vue'; import ContactsIndex from './pages/ContactsIndex.vue';
import ContactManageView from './pages/ContactManageView.vue'; import ContactManageView from './pages/ContactManageView.vue';
export const routes = [ export const routes = [
{ {
path: frontendURL('accounts/:accountId/contacts'), path: frontendURL('accounts/:accountId/contacts'),
name: 'contacts_dashboard', component: ContactsIndex,
name: 'contacts_dashboard_index',
meta: { meta: {
permissions: ['administrator', 'agent', 'contact_manage'], permissions: ['administrator', 'agent', 'contact_manage'],
}, },
component: ContactsView,
},
{
path: frontendURL('accounts/:accountId/contacts/custom_view/:id'),
name: 'contacts_segments_dashboard',
meta: {
permissions: ['administrator', 'agent', 'contact_manage'],
},
component: ContactsView,
props: route => {
return { segmentsId: route.params.id };
},
}, },
{ {
path: frontendURL('accounts/:accountId/labels/:label/contacts'), path: frontendURL('accounts/:accountId/contacts/segments/:segmentId'),
name: 'contacts_labels_dashboard', component: ContactsIndex,
name: 'contacts_dashboard_segments_index',
meta: { meta: {
permissions: ['administrator', 'agent', 'contact_manage'], permissions: ['administrator', 'agent', 'contact_manage'],
}, },
component: ContactsView, },
props: route => {
return { label: route.params.label }; {
path: frontendURL('accounts/:accountId/contacts/labels/:label'),
component: ContactsIndex,
name: 'contacts_dashboard_labels_index',
meta: {
permissions: ['administrator', 'agent', 'contact_manage'],
}, },
}, },
{ {
path: frontendURL('accounts/:accountId/contacts/:contactId'), path: frontendURL('accounts/:accountId/contacts/:contactId'),
name: 'contact_profile_dashboard', name: 'contacts_edit',
meta: { meta: {
permissions: ['administrator', 'agent', 'contact_manage'], permissions: ['administrator', 'agent', 'contact_manage'],
}, },

View File

@@ -4,6 +4,7 @@ import {
} from 'shared/helpers/CustomErrors'; } from 'shared/helpers/CustomErrors';
import types from '../../mutation-types'; import types from '../../mutation-types';
import ContactAPI from '../../../api/contacts'; import ContactAPI from '../../../api/contacts';
import decamelizeKeys from 'decamelize-keys';
import AccountActionsAPI from '../../../api/accountActions'; import AccountActionsAPI from '../../../api/accountActions';
import AnalyticsHelper from '../../../helper/AnalyticsHelper'; import AnalyticsHelper from '../../../helper/AnalyticsHelper';
import { CONTACTS_EVENTS } from '../../../helper/AnalyticsHelper/events'; import { CONTACTS_EVENTS } from '../../../helper/AnalyticsHelper/events';
@@ -90,11 +91,16 @@ export const actions = {
}, },
update: async ({ commit }, { id, isFormData = false, ...contactParams }) => { update: async ({ commit }, { id, isFormData = false, ...contactParams }) => {
const decamelizedContactParams = decamelizeKeys(contactParams, {
deep: true,
});
commit(types.SET_CONTACT_UI_FLAG, { isUpdating: true }); commit(types.SET_CONTACT_UI_FLAG, { isUpdating: true });
try { try {
const response = await ContactAPI.update( const response = await ContactAPI.update(
id, id,
isFormData ? buildContactFormData(contactParams) : contactParams isFormData
? buildContactFormData(decamelizedContactParams)
: decamelizedContactParams
); );
commit(types.EDIT_CONTACT, response.data.payload); commit(types.EDIT_CONTACT, response.data.payload);
commit(types.SET_CONTACT_UI_FLAG, { isUpdating: false }); commit(types.SET_CONTACT_UI_FLAG, { isUpdating: false });
@@ -109,10 +115,15 @@ export const actions = {
}, },
create: async ({ commit }, { isFormData = false, ...contactParams }) => { create: async ({ commit }, { isFormData = false, ...contactParams }) => {
const decamelizedContactParams = decamelizeKeys(contactParams, {
deep: true,
});
commit(types.SET_CONTACT_UI_FLAG, { isCreating: true }); commit(types.SET_CONTACT_UI_FLAG, { isCreating: true });
try { try {
const response = await ContactAPI.create( const response = await ContactAPI.create(
isFormData ? buildContactFormData(contactParams) : contactParams isFormData
? buildContactFormData(decamelizedContactParams)
: decamelizedContactParams
); );
AnalyticsHelper.track(CONTACTS_EVENTS.CREATE_CONTACT); AnalyticsHelper.track(CONTACTS_EVENTS.CREATE_CONTACT);
@@ -126,12 +137,12 @@ export const actions = {
}, },
import: async ({ commit }, file) => { import: async ({ commit }, file) => {
commit(types.SET_CONTACT_UI_FLAG, { isCreating: true }); commit(types.SET_CONTACT_UI_FLAG, { isImporting: true });
try { try {
await ContactAPI.importContacts(file); await ContactAPI.importContacts(file);
commit(types.SET_CONTACT_UI_FLAG, { isCreating: false }); commit(types.SET_CONTACT_UI_FLAG, { isImporting: false });
} catch (error) { } catch (error) {
commit(types.SET_CONTACT_UI_FLAG, { isCreating: false }); commit(types.SET_CONTACT_UI_FLAG, { isImporting: false });
if (error.response?.data?.message) { if (error.response?.data?.message) {
throw new ExceptionWithMessage(error.response.data.message); throw new ExceptionWithMessage(error.response.data.message);
} }
@@ -139,12 +150,13 @@ export const actions = {
}, },
export: async ({ commit }, { payload, label }) => { export: async ({ commit }, { payload, label }) => {
commit(types.SET_CONTACT_UI_FLAG, { isExporting: true });
try { try {
await ContactAPI.exportContacts({ payload, label }); await ContactAPI.exportContacts({ payload, label });
commit(types.SET_CONTACT_UI_FLAG, { isCreating: false }); commit(types.SET_CONTACT_UI_FLAG, { isExporting: false });
} catch (error) { } catch (error) {
commit(types.SET_CONTACT_UI_FLAG, { isCreating: false }); commit(types.SET_CONTACT_UI_FLAG, { isExporting: false });
if (error.response?.data?.message) { if (error.response?.data?.message) {
throw new Error(error.response.data.message); throw new Error(error.response.data.message);
} else { } else {

View File

@@ -1,7 +1,15 @@
import camelcaseKeys from 'camelcase-keys';
export const getters = { export const getters = {
getContacts($state) { getContacts($state) {
return $state.sortOrder.map(contactId => $state.records[contactId]); return $state.sortOrder.map(contactId => $state.records[contactId]);
}, },
getContactsList($state) {
const contacts = $state.sortOrder.map(
contactId => $state.records[contactId]
);
return camelcaseKeys(contacts, { deep: true });
},
getUIFlags($state) { getUIFlags($state) {
return $state.uiFlags; return $state.uiFlags;
}, },

View File

@@ -15,6 +15,8 @@ const state = {
isUpdating: false, isUpdating: false,
isMerging: false, isMerging: false,
isDeleting: false, isDeleting: false,
isExporting: false,
isImporting: false,
}, },
sortOrder: [], sortOrder: [],
appliedFilters: [], appliedFilters: [],

View File

@@ -34,7 +34,7 @@
"@chatwoot/captain": "0.0.3-alpha.4", "@chatwoot/captain": "0.0.3-alpha.4",
"@chatwoot/ninja-keys": "1.2.3", "@chatwoot/ninja-keys": "1.2.3",
"@chatwoot/prosemirror-schema": "1.1.1-next", "@chatwoot/prosemirror-schema": "1.1.1-next",
"@chatwoot/utils": "^0.0.25", "@chatwoot/utils": "^0.0.30",
"@formkit/core": "^1.6.7", "@formkit/core": "^1.6.7",
"@formkit/vue": "^1.6.7", "@formkit/vue": "^1.6.7",
"@hcaptcha/vue3-hcaptcha": "^1.3.0", "@hcaptcha/vue3-hcaptcha": "^1.3.0",
@@ -65,6 +65,7 @@
"countries-and-timezones": "^3.6.0", "countries-and-timezones": "^3.6.0",
"date-fns": "2.21.1", "date-fns": "2.21.1",
"date-fns-tz": "^1.3.3", "date-fns-tz": "^1.3.3",
"decamelize-keys": "^2.0.1",
"dompurify": "3.1.6", "dompurify": "3.1.6",
"floating-vue": "^5.2.2", "floating-vue": "^5.2.2",
"highlight.js": "^11.10.0", "highlight.js": "^11.10.0",

36
pnpm-lock.yaml generated
View File

@@ -26,8 +26,8 @@ importers:
specifier: 1.1.1-next specifier: 1.1.1-next
version: 1.1.1-next version: 1.1.1-next
'@chatwoot/utils': '@chatwoot/utils':
specifier: ^0.0.25 specifier: ^0.0.30
version: 0.0.25 version: 0.0.30
'@formkit/core': '@formkit/core':
specifier: ^1.6.7 specifier: ^1.6.7
version: 1.6.7 version: 1.6.7
@@ -118,6 +118,9 @@ importers:
date-fns-tz: date-fns-tz:
specifier: ^1.3.3 specifier: ^1.3.3
version: 1.3.8(date-fns@2.21.1) version: 1.3.8(date-fns@2.21.1)
decamelize-keys:
specifier: ^2.0.1
version: 2.0.1
dompurify: dompurify:
specifier: 3.1.6 specifier: 3.1.6
version: 3.1.6 version: 3.1.6
@@ -401,8 +404,8 @@ packages:
'@chatwoot/prosemirror-schema@1.1.1-next': '@chatwoot/prosemirror-schema@1.1.1-next':
resolution: {integrity: sha512-/M2qZ+ZF7GlQNt1riwVP499fvp3hxSqd5iy8hxyF9pkj9qQ+OKYn5JK+v3qwwqQY3IxhmNOn1Lp6tm7vstrd9Q==} resolution: {integrity: sha512-/M2qZ+ZF7GlQNt1riwVP499fvp3hxSqd5iy8hxyF9pkj9qQ+OKYn5JK+v3qwwqQY3IxhmNOn1Lp6tm7vstrd9Q==}
'@chatwoot/utils@0.0.25': '@chatwoot/utils@0.0.30':
resolution: {integrity: sha512-2bGfRewHu0Bra47vHwJa3SdZ0VqXK/2q2ampcc6TT3z8ojSfZqpfMdb+1RKJ/Q1tQEYn/0rJTdWtLDRjePF92A==} resolution: {integrity: sha512-UfKn2GUV/9PF7zoj17dAoyx5RZkJihjjclhWTtG0SHRfRizKS/pg0SpXSt9ToAEDeNeRtqmD7RrVMUaso8TZxw==}
engines: {node: '>=10'} engines: {node: '>=10'}
'@codemirror/commands@6.7.0': '@codemirror/commands@6.7.0':
@@ -2437,6 +2440,14 @@ packages:
supports-color: supports-color:
optional: true optional: true
decamelize-keys@2.0.1:
resolution: {integrity: sha512-nrNeSCtU2gV3Apcmn/EZ+aR20zKDuNDStV67jPiupokD3sOAFeMzslLMCFdKv1sPqzwoe5ZUhsSW9IAVgKSL/Q==}
engines: {node: '>=14.16'}
decamelize@6.0.0:
resolution: {integrity: sha512-Fv96DCsdOgB6mdGl67MT5JaTNKRzrzill5OH5s8bjYJXVlcXyPYGyPsUkWyGV5p1TXI5esYIYMMeDJL0hEIwaA==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
decimal.js@10.4.3: decimal.js@10.4.3:
resolution: {integrity: sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==} resolution: {integrity: sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==}
@@ -4676,6 +4687,10 @@ packages:
resolution: {integrity: sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==} resolution: {integrity: sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==}
engines: {node: '>=10'} engines: {node: '>=10'}
type-fest@3.13.1:
resolution: {integrity: sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g==}
engines: {node: '>=14.16'}
type-fest@4.26.1: type-fest@4.26.1:
resolution: {integrity: sha512-yOGpmOAL7CkKe/91I5O3gPICmJNLJ1G4zFYVAsRHg7M64biSnPtRj0WNQt++bRkjYOqjWXrhnUw1utzmVErAdg==} resolution: {integrity: sha512-yOGpmOAL7CkKe/91I5O3gPICmJNLJ1G4zFYVAsRHg7M64biSnPtRj0WNQt++bRkjYOqjWXrhnUw1utzmVErAdg==}
engines: {node: '>=16'} engines: {node: '>=16'}
@@ -5235,7 +5250,7 @@ snapshots:
prosemirror-utils: 1.2.2(prosemirror-model@1.22.3)(prosemirror-state@1.4.3) prosemirror-utils: 1.2.2(prosemirror-model@1.22.3)(prosemirror-state@1.4.3)
prosemirror-view: 1.34.1 prosemirror-view: 1.34.1
'@chatwoot/utils@0.0.25': '@chatwoot/utils@0.0.30':
dependencies: dependencies:
date-fns: 2.29.3 date-fns: 2.29.3
@@ -7513,6 +7528,15 @@ snapshots:
dependencies: dependencies:
ms: 2.1.3 ms: 2.1.3
decamelize-keys@2.0.1:
dependencies:
decamelize: 6.0.0
map-obj: 4.3.0
quick-lru: 6.1.2
type-fest: 3.13.1
decamelize@6.0.0: {}
decimal.js@10.4.3: {} decimal.js@10.4.3: {}
deep-eql@5.0.2: {} deep-eql@5.0.2: {}
@@ -10110,6 +10134,8 @@ snapshots:
type-fest@1.4.0: {} type-fest@1.4.0: {}
type-fest@3.13.1: {}
type-fest@4.26.1: {} type-fest@4.26.1: {}
typed-array-buffer@1.0.0: typed-array-buffer@1.0.0: