Compare commits

...
Author SHA1 Message Date
Sivin VargheseandGitHub 1652b477c8 Merge branch 'feat/contacts-redesign' into feat/compose-new-conversation 2024-11-27 22:29:11 +05:30
Pranav 39a6532874 Remove show.llm 2024-11-25 18:45:44 -08:00
Pranav b0504c5c04 Merge branch 'develop' into feat/contacts-redesign 2024-11-25 18:45:10 -08:00
Pranav 8efb760f97 Merge branch 'develop' into feat/contacts-redesign 2024-11-24 19:48:09 -08:00
Sivin VargheseandGitHub ba6d291e3b Merge branch 'feat/contacts-redesign' into feat/compose-new-conversation 2024-11-22 10:30:45 +05:30
iamsivin 7eac4f241b chore: Fix build 2024-11-22 09:37:23 +05:30
Sivin VargheseandGitHub 137f5616bd Merge branch 'develop' into feat/contacts-redesign 2024-11-22 09:28:44 +05:30
iamsivin f375d962ab chore: Minor fix 2024-11-22 09:28:15 +05:30
iamsivin 7c94da3bc3 Revert "feat: Add Label Input components (#10480)" This reverts commit 2dae4b22a2. 2024-11-22 09:26:32 +05:30
Sivin VargheseandGitHub 6b5b53a722 chore: Fix code climate 2024-11-22 09:09:53 +05:30
Sivin VargheseandGitHub 4156881dec Merge branch 'feat/contacts-redesign' into feat/compose-new-conversation 2024-11-22 09:04:27 +05:30
iamsivin 93d1e2a032 chore: Update contact card with company name 2024-11-22 09:03:46 +05:30
iamsivin ca05be1c1a chore: Decamelize keys on contact update 2024-11-22 09:03:07 +05:30
Sivin VargheseandGitHub 7c09c42bde Merge branch 'feat/contacts-redesign' into feat/compose-new-conversation 2024-11-22 08:13:07 +05:30
Sivin VargheseandGitHub ceaeba84ee Merge branch 'develop' into feat/contacts-redesign 2024-11-22 08:09:28 +05:30
Sivin VargheseandGitHub 67fba33005 Merge branch 'feat/contacts-redesign' into feat/compose-new-conversation 2024-11-21 22:55:50 +05:30
Sivin VargheseandGitHub 92190ec81b Merge branch 'develop' into feat/contacts-redesign 2024-11-21 22:55:36 +05:30
iamsivin 2d0c813300 chore: Minor fix 2024-11-21 15:54:01 +05:30
iamsivin 7083137c5a chore: Label components 2024-11-21 15:48:43 +05:30
iamsivin b9b3c43d63 feat: Contact note item component 2024-11-21 15:25:06 +05:30
iamsivin 2a724cad7c chore: Create contact merge component 2024-11-21 14:52:19 +05:30
iamsivin ef51083b3c chore: Minor fix 2024-11-21 13:34:42 +05:30
iamsivin edfb1029a7 chore: Minor fix 2024-11-21 12:43:54 +05:30
Sivin VargheseandGitHub 75cf5322c9 Merge branch 'feat/contacts-redesign' into feat/compose-new-conversation 2024-11-21 12:12:03 +05:30
Sivin VargheseandGitHub 634a31a7ab Merge branch 'develop' into feat/contacts-redesign 2024-11-21 12:11:35 +05:30
iamsivin ee55e8e8ff chore: Minor fix 2024-11-21 10:58:09 +05:30
iamsivin 1a4f44555f chore: Minor fix 2024-11-21 10:37:20 +05:30
iamsivin b919afa182 chore: Minor fix 2024-11-21 00:53:45 +05:30
Sivin VargheseandGitHub 189aa06fa3 Merge branch 'feat/contacts-redesign' into feat/compose-new-conversation 2024-11-21 00:50:28 +05:30
iamsivin 257a5ccf83 chore: Minor fix 2024-11-21 00:42:01 +05:30
iamsivin e3f50da277 chore: Minor fix 2024-11-21 00:37:12 +05:30
iamsivin edcc15fab2 chore: Custom attributes story 2024-11-21 00:24:08 +05:30
iamsivin 525a979283 chore: Minor fix 2024-11-21 00:08:03 +05:30
iamsivin 7021a85da5 chore: Minor fix 2024-11-20 23:33:17 +05:30
Sivin VargheseandGitHub 93cd88253d Merge branch 'feat/contacts-redesign' into feat/compose-new-conversation 2024-11-20 22:36:58 +05:30
iamsivin 2852f01026 chore: Minor fix 2024-11-20 22:34:49 +05:30
Sivin VargheseandGitHub 2dcb6ec997 Merge branch 'develop' into feat/contacts-redesign 2024-11-20 22:33:04 +05:30
Sivin VargheseandGitHub 2538e161a2 Merge branch 'develop' into feat/contacts-redesign 2024-11-20 22:32:01 +05:30
iamsivin 6f4b37a6cd chore: Minor fix 2024-11-20 20:31:07 +05:30
Sivin VargheseandGitHub b9cb4cc65e Merge branch 'develop' into feat/contacts-redesign 2024-11-20 20:26:27 +05:30
iamsivin e46d704573 chore: Minor fix 2024-11-20 17:03:50 +05:30
Sivin VargheseandGitHub 8f7589f173 Merge branch 'feat/contacts-redesign' into feat/compose-new-conversation 2024-11-20 08:15:51 +05:30
Sivin VargheseandGitHub f5a72c58db Merge branch 'develop' into feat/contacts-redesign 2024-11-20 08:15:33 +05:30
Sivin VargheseandGitHub 62f357fc7f Merge branch 'develop' into feat/contacts-redesign 2024-11-20 07:07:10 +05:30
Sivin VargheseandGitHub 4fda057afb Merge branch 'feat/contacts-redesign' into feat/compose-new-conversation 2024-11-19 23:01:53 +05:30
Sivin VargheseandGitHub fccfa5adba Merge branch 'develop' into feat/contacts-redesign 2024-11-19 22:47:59 +05:30
iamsivin 60fdab4bbe chore: More story case 2024-11-19 22:36:03 +05:30
iamsivin 93eb295152 chore: Adds story 2024-11-19 22:31:51 +05:30
iamsivin 1982cb159c chore: Clean up 2024-11-19 22:03:54 +05:30
Sivin VargheseandGitHub ca46ed9bca Merge branch 'feat/contacts-redesign' into feat/compose-new-conversation 2024-11-19 08:56:25 +05:30
Sivin VargheseandGitHub e591f14729 Merge branch 'develop' into feat/contacts-redesign 2024-11-19 08:56:02 +05:30
Sivin VargheseandGitHub 7d0bfabb49 Merge branch 'feat/contacts-redesign' into feat/compose-new-conversation 2024-11-19 08:28:34 +05:30
Sivin VargheseandGitHub aef2ffc3a9 Merge branch 'develop' into feat/contacts-redesign 2024-11-19 08:28:24 +05:30
iamsivin c3cf73de85 chore: Minor fix 2024-11-19 00:06:23 +05:30
iamsivin 610b05ed5a feat: Attachment preview 2024-11-18 23:47:58 +05:30
iamsivin 2e30d4b825 chore: Minor fix 2024-11-18 22:10:23 +05:30
Sivin VargheseandGitHub 99c762404e Merge branch 'feat/contacts-redesign' into feat/compose-new-conversation 2024-11-18 21:04:19 +05:30
Sivin VargheseandGitHub b6ad0211e3 Merge branch 'develop' into feat/contacts-redesign 2024-11-18 21:03:53 +05:30
iamsivin 15470506bf chore: Minor fixes 2024-11-18 17:18:58 +05:30
Sivin VargheseandGitHub f15c7a8df1 Merge branch 'feat/contacts-redesign' into feat/compose-new-conversation 2024-11-15 23:00:49 +05:30
Sivin VargheseandGitHub dceb616cc4 Merge branch 'develop' into feat/contacts-redesign 2024-11-15 23:00:34 +05:30
iamsivin 14e7950186 feat: Inbox empty state warning 2024-11-15 22:56:35 +05:30
iamsivin e3602acf68 chore: Created file upload composable and use it in compose view 2024-11-15 22:12:16 +05:30
iamsivin b97ee9675e chore: Fix build 2024-11-15 18:27:24 +05:30
iamsivin 1b7317b71c chore: Adds translations 2024-11-15 18:21:39 +05:30
iamsivin 7cc0d6d457 chore: Clean up 2024-11-15 17:22:48 +05:30
iamsivin 28481c45cb feat: Code clean up 2024-11-15 17:12:39 +05:30
iamsivin 7790b88725 chore: Fix whats app template 2024-11-15 14:30:46 +05:30
iamsivin 9ea992fe45 feat(v4): Compose new conversation 2024-11-15 02:06:20 +05:30
Sivin VargheseandGitHub bf59285041 Merge branch 'develop' into feat/contacts-redesign 2024-11-14 18:41:22 +05:30
iamsivin 90162ad39b feat: Conversation card click 2024-11-14 18:40:47 +05:30
Sivin VargheseandGitHub bd691d2e2b Merge branch 'develop' into feat/contacts-redesign 2024-11-13 16:57:50 +05:30
iamsivin 94142acb93 chore: Minor fix 2024-11-13 15:55:16 +05:30
iamsivin 78e6f995eb chore: Filter enhancements 2024-11-13 10:05:06 +05:30
Sivin VargheseandGitHub 4cb9abaf8c Merge branch 'develop' into feat/contacts-redesign 2024-11-13 08:19:24 +05:30
iamsivin b75afc2b29 chore: Minor fix 2024-11-12 22:38:49 +05:30
iamsivin 3f608ece5a fix: Phone number input 2024-11-12 22:18:55 +05:30
iamsivin 72496ac4a0 chore: Minor fix 2024-11-12 22:06:08 +05:30
iamsivin 909755c340 chore: Clean up 2024-11-12 22:02:26 +05:30
iamsivin 42a9e8dc82 chore: Minor fix 2024-11-12 21:26:33 +05:30
iamsivin f0c5d214e7 chore: Minor fix 2024-11-12 21:11:37 +05:30
iamsivin cc6ac082b1 feat: Phone input 2024-11-12 21:03:56 +05:30
Sivin VargheseandGitHub d8bc4ae40f Merge branch 'develop' into feat/contacts-redesign 2024-11-12 11:40:26 +05:30
iamsivin 7aa22eabf8 chore: Fix sidebar activeOn issue 2024-11-12 11:39:59 +05:30
iamsivin 1dd690e8f9 chore: Minor fix 2024-11-12 09:14:27 +05:30
iamsivin 6caf7ded2d chore: Minor fix 2024-11-12 09:08:21 +05:30
iamsivin b39e25f9e2 chore: Fix empty state 2024-11-11 23:31:32 +05:30
iamsivin bda9f0208a chore: Fix build 2024-11-11 23:11:37 +05:30
iamsivin 74a90c0173 chore: Fix sidebar 2024-11-11 23:09:30 +05:30
iamsivin 9210d2cecd chore: Minor fix 2024-11-11 22:49:16 +05:30
iamsivin fd4b1f2d21 feat: Contact Empty state 2024-11-11 22:38:08 +05:30
iamsivin ae804baf9b chore: More changes 2024-11-11 21:51:32 +05:30
iamsivin e2c1e6ff00 chore: Fix warning 2024-11-11 21:26:21 +05:30
iamsivin 2dbe5bd1c0 chore: Fix validations for custom attributes 2024-11-11 21:24:34 +05:30
iamsivin 0fe6d404b9 feat: Contact attributes 2024-11-11 21:07:12 +05:30
iamsivin d6cc5422d0 feat: Add avatar upload and merge EditableAvatar.vue with Avatar.vue 2024-11-09 15:30:52 +05:30
iamsivin 271b322761 feat: Contact exporting 2024-11-09 14:06:33 +05:30
iamsivin 1b013544a8 chore: Fix sidebar filter issue and sort reactivity issue 2024-11-09 13:39:22 +05:30
iamsivin d53c60363b feat: Contact filtering by labels and segments 2024-11-08 15:44:10 +05:30
iamsivin 3d6d6e2afb feat: Import contact 2024-11-08 10:30:14 +05:30
Sivin VargheseandGitHub 430c6d2526 Merge branch 'develop' into feat/contacts-redesign 2024-11-08 10:16:07 +05:30
iamsivin 8320ecd92b feat: Create contact 2024-11-08 02:22:22 +05:30
iamsivin ecd8b265bc feat: Contact sort 2024-11-08 01:27:51 +05:30
iamsivin c5974f97cd chore: Update the usage with camel case 2024-11-07 20:46:29 +05:30
iamsivin 314a33e053 chore: Use camel case 2024-11-07 10:32:38 +05:30
iamsivin 4b85a9045a chore: Minor fix 2024-11-06 23:22:52 +05:30
iamsivin d41857abc4 feat: Contact search 2024-11-06 23:20:00 +05:30
iamsivin 816c65df2d chore: Sort labels 2024-11-06 23:04:53 +05:30
iamsivin 91ee5fc2f0 chore: Form validations 2024-11-06 21:35:05 +05:30
iamsivin 28cbdb6133 feat: Contact merge 2024-11-06 21:08:14 +05:30
iamsivin 703b36e38c chore: Add or remove labels 2024-11-06 18:42:35 +05:30
iamsivin 51cf4df3e3 chore: Code clean up 2024-11-06 18:08:14 +05:30
Sivin VargheseandGitHub caaf8d55c6 Merge branch 'develop' into feat/contacts-redesign 2024-11-06 17:52:52 +05:30
iamsivin d5a1c7f49b chore: Fix build 2024-11-06 17:51:42 +05:30
iamsivin db6f08c12b feat: Contact history 2024-11-06 17:51:03 +05:30
iamsivin 7021e96ad4 chore: More changes 2024-11-06 17:34:04 +05:30
iamsivin c254c44994 chore: Minor fix 2024-11-06 00:06:02 +05:30
iamsivin 05b76f7182 chore: Fix build 2024-11-06 00:03:16 +05:30
iamsivin ae1c6e3f98 chore: Replace thumbnail component with avatar 2024-11-05 23:56:11 +05:30
iamsivin 3c58d6af59 chore: Minor fix 2024-11-05 21:35:19 +05:30
iamsivin fa8a7db612 feat: COntact sidebar 2024-11-05 21:34:05 +05:30
iamsivin 759ce1229d chore: Minor color fix 2024-11-04 23:37:13 +05:30
iamsivin b92805933f chore: fix build 2024-11-04 23:26:04 +05:30
iamsivin b71a05f790 feat: Contact details screen 2024-11-04 19:46:02 +05:30
iamsivin dd9e9286b3 feat: Contact card with expand view 2024-11-04 15:15:17 +05:30
iamsivin f341d159c2 feat: Route view 2024-11-04 15:14:43 +05:30
iamsivin a7bf762bcd feat: Contacts layout 2024-11-04 15:14:01 +05:30
66 changed files with 5807 additions and 197 deletions
@@ -0,0 +1,46 @@
<script setup>
import Button from 'dashboard/components-next/button/Button.vue';
import ContactSortMenu from './ContactSortMenu.vue';
import ContactMoreActions from './ContactMoreActions.vue';
defineProps({
isDetailView: {
type: Boolean,
default: false,
},
activeSort: {
type: String,
default: 'last_activity_at',
},
activeOrdering: {
type: String,
default: '',
},
isEmptyState: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['filter', 'update:sort']);
</script>
<template>
<div class="flex items-center gap-2">
<Button
v-if="!isDetailView && !isEmptyState"
icon="i-lucide-list-filter"
color="slate"
size="sm"
variant="ghost"
@click="emit('filter')"
/>
<ContactSortMenu
v-if="!isDetailView && !isEmptyState"
:active-sort="activeSort"
:active-ordering="activeOrdering"
@update:sort="emit('update:sort', $event)"
/>
<ContactMoreActions v-if="!isDetailView" />
</div>
</template>
@@ -0,0 +1,76 @@
<script setup>
import { ref, computed } from 'vue';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import filterQueryGenerator from 'dashboard/helper/filterQueryGenerator';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
const { t } = useI18n();
const store = useStore();
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);
}
try {
store.dispatch('contacts/export', {
...query,
label: route.params.label || '',
});
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 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>
@@ -0,0 +1,148 @@
<script setup>
import { ref, computed } from 'vue';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import { useAlert, useTrack } from 'dashboard/composables';
import { CONTACTS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const { t } = useI18n();
const store = useStore();
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;
try {
await store.dispatch('contacts/import', hasSelectedFile.value);
dialogRef.value?.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);
}
};
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"
class="text-n-blue-text"
>
{{
t('CONTACTS_LAYOUT.HEADER.ACTIONS.IMPORT_CONTACT.DOWNLOAD_LABEL')
}}
</a>
</p>
</template>
<template #form>
<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-download"
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"
/>
</template>
</Dialog>
</template>
@@ -0,0 +1,70 @@
<script setup>
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import Button from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import CreateNewContactDialog from 'dashboard/components-next/Contacts/ContactsForm/CreateNewContactDialog.vue';
import ContactImportDialog from 'dashboard/components-next/Contacts/ContactHeader/ContactImportDialog.vue';
import ContactExportDialog from 'dashboard/components-next/Contacts/ContactHeader/ContactExportDialog.vue';
const { t } = useI18n();
const contactMenuItems = [
{
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.CONTACT_CREATION.ADD_CONTACT'),
action: 'add',
value: 'add',
icon: 'i-lucide-plus',
},
{
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.CONTACT_CREATION.EXPORT_CONTACT'),
action: 'export',
value: 'export',
icon: 'i-lucide-upload',
},
{
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.CONTACT_CREATION.IMPORT_CONTACT'),
action: 'import',
value: 'import',
icon: 'i-lucide-download',
},
];
const showActionsDropdown = ref(false);
const createNewContactDialogRef = ref(null);
const contactImportDialogRef = ref(null);
const contactExportDialogRef = ref(null);
const handleContactAction = ({ action }) => {
if (action === 'add') {
createNewContactDialogRef.value?.dialogRef.open();
} else if (action === 'import') {
contactImportDialogRef.value?.dialogRef.open();
} else if (action === 'export') {
contactExportDialogRef.value?.dialogRef.open();
}
};
</script>
<template>
<div v-on-clickaway="() => (showActionsDropdown = false)" class="relative">
<Button
icon="i-lucide-ellipsis-vertical"
color="slate"
variant="ghost"
size="sm"
:class="showActionsDropdown ? 'bg-n-alpha-2' : ''"
@click="showActionsDropdown = !showActionsDropdown"
/>
<DropdownMenu
v-if="showActionsDropdown"
:menu-items="contactMenuItems"
class="right-0 mt-1 w-52 top-full"
@action="handleContactAction($event)"
/>
</div>
<CreateNewContactDialog ref="createNewContactDialogRef" />
<ContactImportDialog ref="contactImportDialogRef" />
<ContactExportDialog ref="contactExportDialogRef" />
</template>
@@ -0,0 +1,134 @@
<script setup>
import { ref, computed, toRef } from 'vue';
import { useI18n } from 'vue-i18n';
import Button from 'dashboard/components-next/button/Button.vue';
import SelectMenu from 'dashboard/components-next/selectmenu/SelectMenu.vue';
const props = defineProps({
activeSort: {
type: String,
default: 'last_activity_at',
},
activeOrdering: {
type: String,
default: '',
},
});
const emit = defineEmits(['update:sort']);
const { t } = useI18n();
const isMenuOpen = ref(false);
const sortMenus = [
{
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.SORT_BY.OPTIONS.NAME'),
value: 'name',
},
{
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.SORT_BY.OPTIONS.EMAIL'),
value: 'email',
},
{
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.SORT_BY.OPTIONS.PHONE_NUMBER'),
value: 'phone_number',
},
{
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.SORT_BY.OPTIONS.COMPANY'),
value: 'company_name',
},
{
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.SORT_BY.OPTIONS.COUNTRY'),
value: 'country',
},
{
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.SORT_BY.OPTIONS.CITY'),
value: 'city',
},
{
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.SORT_BY.OPTIONS.LAST_ACTIVITY'),
value: 'last_activity_at',
},
{
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.SORT_BY.OPTIONS.CREATED_AT'),
value: 'created_at',
},
];
const orderingMenus = [
{
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.ORDER.OPTIONS.ASCENDING'),
value: '',
},
{
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.ORDER.OPTIONS.DESCENDING'),
value: '-',
},
];
// Converted the props to refs for better reactivity
const activeSort = toRef(props, 'activeSort');
const activeOrdering = toRef(props, 'activeOrdering');
const activeSortLabel = computed(() => {
const selectedMenu = sortMenus.find(menu => menu.value === activeSort.value);
return (
selectedMenu?.label || t('CONTACTS_LAYOUT.HEADER.ACTIONS.SORT_BY.LABEL')
);
});
const activeOrderingLabel = computed(() => {
const selectedMenu = orderingMenus.find(
menu => menu.value === activeOrdering.value
);
return selectedMenu?.label || t('CONTACTS_LAYOUT.HEADER.ACTIONS.ORDER.LABEL');
});
const handleSortChange = value => {
emit('update:sort', { sort: value, order: props.activeOrdering });
};
const handleOrderChange = value => {
emit('update:sort', { sort: props.activeSort, order: value });
};
</script>
<template>
<div class="relative">
<Button
icon="i-lucide-arrow-down-up"
color="slate"
size="sm"
variant="ghost"
:class="isMenuOpen ? 'bg-n-alpha-2' : ''"
@click="isMenuOpen = !isMenuOpen"
/>
<div
v-if="isMenuOpen"
v-on-clickaway="() => (isMenuOpen = false)"
class="absolute top-full mt-1 right-0 flex flex-col gap-4 bg-n-alpha-3 backdrop-blur-[100px] border border-n-weak w-72 rounded-xl p-4"
>
<div class="flex items-center justify-between gap-2">
<span class="text-sm text-n-slate-12">
{{ t('CONTACTS_LAYOUT.HEADER.ACTIONS.SORT_BY.LABEL') }}
</span>
<SelectMenu
:model-value="activeSort"
:options="sortMenus"
:label="activeSortLabel"
@update:model-value="handleSortChange"
/>
</div>
<div class="flex items-center justify-between gap-2">
<span class="text-sm text-n-slate-12">
{{ t('CONTACTS_LAYOUT.HEADER.ACTIONS.ORDER.LABEL') }}
</span>
<SelectMenu
:model-value="activeOrdering"
:options="orderingMenus"
:label="activeOrderingLabel"
@update:model-value="handleOrderChange"
/>
</div>
</div>
</div>
</template>
@@ -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>
@@ -38,14 +38,16 @@ const onClickViewDetails = async () => {
<span class="text-sm font-medium truncate text-n-slate-12">
{{ name }}
</span>
<template v-if="additionalAttributes?.companyName">
<span class="text-sm text-n-slate-11">
{{ t('CONTACTS_LAYOUT.CARD.OF') }}
</span>
<span class="text-sm font-medium truncate text-n-slate-12">
{{ additionalAttributes.companyName }}
</span>
</template>
<span
v-if="additionalAttributes?.companyName"
class="i-lucide-briefcase-business size-4 text-n-slate-11"
/>
<span
v-if="additionalAttributes?.companyName"
class="text-sm truncate text-n-slate-11"
>
{{ additionalAttributes.companyName }}
</span>
</div>
<div class="flex items-center gap-3">
<span v-if="email" class="text-sm text-n-slate-11">{{ email }}</span>
@@ -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>
@@ -0,0 +1,58 @@
<script setup>
import { ref, computed } from 'vue';
import { useStore, 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 { t } = useI18n();
const store = useStore();
const dialogRef = 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;
await store.dispatch('contacts/create', contact.value);
dialogRef.value.close();
};
const closeDialog = () => {
dialogRef.value.close();
};
defineExpose({ dialogRef });
</script>
<template>
<Dialog ref="dialogRef" width="3xl" @confirm="handleDialogConfirm">
<ContactsForm 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>
@@ -0,0 +1,209 @@
<script setup>
import { computed, useSlots } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import Button from 'dashboard/components-next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue';
import Breadcrumb from 'dashboard/components-next/breadcrumb/Breadcrumb.vue';
import ContactActions from 'dashboard/components-next/Contacts/ContactHeader/ContactActions.vue';
import ComposeConversation from 'dashboard/components-next/NewConversation/ComposeConversation.vue';
const props = defineProps({
searchValue: {
type: String,
default: '',
},
headerTitle: {
type: String,
default: '',
},
buttonLabel: {
type: String,
default: '',
},
showPaginationFooter: {
type: Boolean,
default: true,
},
currentPage: {
type: Number,
default: 1,
},
totalItems: {
type: Number,
default: 100,
},
itemsPerPage: {
type: Number,
default: 15,
},
isDetailView: {
type: Boolean,
default: false,
},
selectedContact: {
type: Object,
default: () => ({}),
},
activeSort: {
type: String,
default: '',
},
activeOrdering: {
type: String,
default: '',
},
isEmptyState: {
type: Boolean,
default: false,
},
});
const emit = defineEmits([
'filter',
'more',
'update:currentPage',
'goToContactsList',
'search',
'sort',
]);
const { t } = useI18n();
const slots = useSlots();
const route = useRoute();
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 hasSidebar = computed(() => {
return slots.sidebar && props.isDetailView;
});
const isNotSegmentView = computed(() => {
return route.name !== 'contacts_dashboard_segments_index';
});
const handleBreadcrumbClick = () => {
emit('goToContactsList');
};
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"
:class="hasSidebar && `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"
:class="hasSidebar ? `max-w-[650px]` : `max-w-[900px]`"
>
<div class="flex items-center justify-between w-full h-20 gap-2">
<span
v-if="!isDetailView"
class="text-xl font-medium text-n-slate-12"
>
{{ headerTitle }}
</span>
<Breadcrumb
v-else
:items="breadcrumbItems"
@click="handleBreadcrumbClick"
/>
<div class="flex items-center gap-4">
<div
v-if="!isDetailView && !isEmptyState && isNotSegmentView"
class="flex items-center gap-2"
>
<Input
:model-value="searchValue"
type="search"
:placeholder="$t('CONTACTS_LAYOUT.HEADER.SEARCH_PLACEHOLDER')"
:custom-input-class="[
'h-8 [&:not(.focus)]:!border-transparent bg-n-alpha-2 dark:bg-n-solid-1 ltr:!pl-8 !py-1 rtl:!pr-8',
]"
@input="emit('search', $event.target.value)"
>
<template #prefix>
<Icon
icon="i-lucide-search"
class="absolute -translate-y-1/2 text-n-slate-11 size-4 top-1/2 ltr:left-2 rtl:right-2"
/>
</template>
</Input>
</div>
<ContactActions
:is-empty-state="isEmptyState"
:is-detail-view="isDetailView"
:active-sort="activeSort"
:active-ordering="activeOrdering"
@filter="emit('filter')"
@update:sort="emit('sort', $event)"
@more="emit('more')"
/>
<div v-if="!isDetailView" class="w-px h-4 bg-n-strong" />
<ComposeConversation>
<template #trigger="{ toggle }">
<Button :label="buttonLabel" size="sm" @click="toggle" />
</template>
</ComposeConversation>
</div>
</div>
</div>
</header>
<main class="flex-1 px-6 overflow-y-auto xl:px-px">
<div
class="w-full py-4 mx-auto"
:class="hasSidebar ? `max-w-[650px]` : `max-w-[900px]`"
>
<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>
<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>
@@ -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,
custom_attributes: {
[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-1"
:class="isEditingView ? 'min-h-10' : 'min-h-11'"
>
<div class="flex items-center justify-between truncate">
<span class="text-sm font-medium text-n-slate-12">
{{ attribute.attributeDisplayName }}
</span>
</div>
<component
:is="CurrentAttributeComponent"
:attribute="attribute"
:is-editing-view="isEditingView"
@update="handleUpdate"
@delete="handleDelete"
/>
</div>
</template>
@@ -0,0 +1,140 @@
<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 attributes = useMapGetter('attributes/getAttributesByModelType');
const contactAttributes = computed(() => attributes.value('contact_attribute'));
const hasContactAttributes = computed(
() => contactAttributes.value?.length > 0
);
// Convert attribute key value from snake_case to camelCase
const toCamelCase = str =>
str.toLowerCase().replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
const usedAttributes = computed(() => {
if (!contactAttributes.value || !props.selectedContact?.customAttributes) {
return [];
}
return contactAttributes.value
.filter(attribute => {
const camelKey = toCamelCase(attribute.attributeKey);
return props.selectedContact.customAttributes[camelKey] !== undefined;
})
.map(attribute => ({
...attribute,
value:
props.selectedContact.customAttributes[
toCamelCase(attribute.attributeKey)
] ?? '',
}));
});
const unusedAttributes = computed(() => {
if (!contactAttributes.value || !props.selectedContact?.customAttributes) {
return [];
}
return contactAttributes.value
.filter(attribute => {
const camelKey = toCamelCase(attribute.attributeKey);
const attributeValue = props.selectedContact.customAttributes[camelKey];
// For checkbox type, consider both true/false as valid values
if (attribute.attributeDisplayType === 'checkbox') {
return typeof attributeValue !== 'boolean';
}
// For other types, check if value doesn't exist or is null/undefined
return attributeValue === undefined || attributeValue === null;
})
.map(attribute => ({
...attribute,
value:
props.selectedContact.customAttributes[
toCamelCase(attribute.attributeKey)
] ?? '',
}));
});
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>
@@ -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>
@@ -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>
@@ -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>
@@ -0,0 +1,61 @@
<script setup>
import { ref } from 'vue';
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import ContactsCard from 'dashboard/components-next/Contacts/ContactsCard/ContactsCard.vue';
import contactContent from 'dashboard/components-next/Contacts/EmptyState/contactEmptyStateContent';
import CreateNewContactDialog from 'dashboard/components-next/Contacts/ContactsForm/CreateNewContactDialog.vue';
defineProps({
title: {
type: String,
default: '',
},
subtitle: {
type: String,
default: '',
},
showButton: {
type: Boolean,
default: true,
},
buttonLabel: {
type: String,
default: '',
},
});
const createNewContactDialogRef = ref(null);
const onClick = () => {
createNewContactDialogRef.value?.dialogRef.open();
};
</script>
<template>
<EmptyStateLayout :title="title" :subtitle="subtitle">
<template #empty-state-item>
<div class="grid grid-cols-1 gap-4 p-px overflow-hidden">
<ContactsCard
v-for="contact in contactContent.slice(0, 5)"
: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="0 === contact.id"
@toggle="toggleExpanded(contact.id)"
/>
</div>
</template>
<template #actions>
<div v-if="showButton">
<Button :label="buttonLabel" icon="i-lucide-plus" @click="onClick" />
</div>
<CreateNewContactDialog ref="createNewContactDialogRef" />
</template>
</EmptyStateLayout>
</template>
@@ -0,0 +1,228 @@
export default [
{
additionalAttributes: {
city: 'Los Angeles',
country: 'United States',
description:
"I'm Candice, a developer focusing on building web solutions. Currently, Im working as a Product Developer at Chatwoot.",
companyName: 'Chatwoot',
countryCode: 'US',
socialProfiles: {
github: 'candice-dev',
twitter: 'candice_w_dev',
facebook: 'candice.dev',
linkedin: 'candice-matherson',
instagram: 'candice.codes',
},
},
availabilityStatus: 'offline',
email: 'candice.matherson@chatwoot.com',
id: 22,
name: 'Candice Matherson',
phoneNumber: '+14155552671',
identifier: null,
thumbnail: '',
customAttributes: {
dateContact: '2024-11-11T11:53:09.299Z',
linkContact: 'https://example.com',
listContact: 'Follow-Up',
textContact: 'Hi there!',
numberContact: '42',
checkboxContact: false,
},
lastActivityAt: 1712123233,
createdAt: 1712123233,
},
{
additionalAttributes: {
city: 'San Francisco',
country: 'United States',
description: 'Passionate about design and user experience.',
companyName: 'Designify',
countryCode: 'US',
socialProfiles: {
github: 'ophelia-folkard',
twitter: 'oph_designs',
facebook: 'ophelia.folkard',
linkedin: 'ophelia-folkard',
instagram: 'ophelia.design',
},
},
availabilityStatus: 'offline',
email: 'ophelia.folkard@designify.com',
id: 21,
name: 'Ophelia Folkard',
phoneNumber: '+14155552672',
identifier: null,
thumbnail: '',
customAttributes: {
dateContact: '2024-10-05T10:12:34.567Z',
linkContact: 'https://designify.com',
listContact: 'Prospects',
textContact: 'Looking forward to connecting!',
},
lastActivityAt: 1712123233,
createdAt: 1712123233,
},
{
additionalAttributes: {
city: 'Austin',
country: 'United States',
description: 'Avid coder and tech enthusiast.',
companyName: 'CodeHub',
countryCode: 'US',
socialProfiles: {
github: 'willy_castelot',
twitter: 'willy_code',
facebook: 'willy.castelot',
linkedin: 'willy-castelot',
instagram: 'willy.coder',
},
},
availabilityStatus: 'offline',
email: 'willy.castelot@codehub.io',
id: 20,
name: 'Willy Castelot',
phoneNumber: '+14155552673',
identifier: null,
thumbnail: '',
customAttributes: {
textContact: 'Lets collaborate!',
checkboxContact: true,
},
lastActivityAt: 1712123233,
createdAt: 1712123233,
},
{
additionalAttributes: {
city: 'Seattle',
country: 'United States',
description: 'Product manager with a love for innovation.',
companyName: 'InnovaTech',
countryCode: 'US',
socialProfiles: {
github: 'elisabeth-d',
twitter: 'elisabeth_innova',
facebook: 'elisabeth.derington',
linkedin: 'elisabeth-derington',
instagram: 'elisabeth.innovates',
},
},
availabilityStatus: 'offline',
email: 'elisabeth.derington@innova.com',
id: 19,
name: 'Elisabeth Derington',
phoneNumber: '+14155552674',
identifier: null,
thumbnail: '',
customAttributes: {
textContact: 'Lets schedule a call.',
},
lastActivityAt: 1712123232,
createdAt: 1712123232,
},
{
additionalAttributes: {
city: 'Chicago',
country: 'United States',
description: 'Marketing specialist and content creator.',
companyName: 'Contently',
countryCode: 'US',
socialProfiles: {
github: 'olia-olenchenko',
twitter: 'olia_content',
facebook: 'olia.olenchenko',
linkedin: 'olia-olenchenko',
instagram: 'olia.creates',
},
},
availabilityStatus: 'offline',
email: 'olia.olenchenko@contently.com',
id: 18,
name: 'Olia Olenchenko',
phoneNumber: '+14155552675',
identifier: null,
thumbnail: '',
customAttributes: {},
lastActivityAt: 1712123232,
createdAt: 1712123232,
},
{
additionalAttributes: {
city: 'Boston',
country: 'United States',
description: 'SEO expert and analytics enthusiast.',
companyName: 'OptiSearch',
countryCode: 'US',
socialProfiles: {
github: 'nate-vannuchi',
twitter: 'nate_seo',
facebook: 'nathaniel.vannuchi',
linkedin: 'nathaniel-vannuchi',
instagram: 'nate.optimizes',
},
},
availabilityStatus: 'offline',
email: 'nathaniel.vannuchi@optisearch.com',
id: 17,
name: 'Nathaniel Vannuchi',
phoneNumber: '+14155552676',
identifier: null,
thumbnail: '',
customAttributes: {},
lastActivityAt: 1712123232,
createdAt: 1712123232,
},
{
additionalAttributes: {
city: 'Denver',
country: 'United States',
description: 'UI/UX designer with a flair for minimalist designs.',
companyName: 'Minimal Designs',
countryCode: 'US',
socialProfiles: {
github: 'merrile-petruk',
twitter: 'merrile_ux',
facebook: 'merrile.petruk',
linkedin: 'merrile-petruk',
instagram: 'merrile.designs',
},
},
availabilityStatus: 'offline',
email: 'merrile.petruk@minimal.com',
id: 16,
name: 'Merrile Petruk',
phoneNumber: '+14155552677',
identifier: null,
thumbnail: '',
customAttributes: {},
lastActivityAt: 1712123232,
createdAt: 1712123232,
},
{
additionalAttributes: {
city: 'Miami',
country: 'United States',
description: 'Entrepreneur with a background in e-commerce.',
companyName: 'Ecom Solutions',
countryCode: 'US',
socialProfiles: {
github: 'cordell-d',
twitter: 'cordell_entrepreneur',
facebook: 'cordell.dalinder',
linkedin: 'cordell-dalinder',
instagram: 'cordell.ecom',
},
},
availabilityStatus: 'offline',
email: 'cordell.dalinder@ecomsolutions.com',
id: 15,
name: 'Cordell Dalinder',
phoneNumber: '+14155552678',
identifier: null,
thumbnail: '',
customAttributes: {},
lastActivityAt: 1712123232,
createdAt: 1712123232,
},
];
@@ -0,0 +1,164 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { dynamicTime } from 'shared/helpers/timeHelper';
import { debounce } from '@chatwoot/utils';
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 selectedContactData = computed(() => {
if (!props.selectedContact) return {};
return props.selectedContact;
});
const createdAt = computed(() => {
return props.selectedContact?.createdAt
? dynamicTime(props.selectedContact.createdAt)
: '';
});
const lastActivityAt = computed(() => {
return props.selectedContact?.lastActivityAt
? dynamicTime(props.selectedContact.lastActivityAt)
: '';
});
const avatarSrc = computed(() => {
return avatarUrl.value ? avatarUrl.value : props.selectedContact?.thumbnail;
});
const updateContact = async updatedData => {
await store.dispatch('contacts/update', updatedData);
await store.dispatch(
'contacts/fetchContactableInbox',
props.selectedContact?.id
);
};
const handleFormUpdate = debounce(
updatedData => updateContact(updatedData),
600,
false
);
const openConfirmDeleteContactDialog = () => {
confirmDeleteContactDialogRef.value?.dialogRef.open();
};
const handleAvatarUpload = async ({ file, url }) => {
avatarFile.value = file;
avatarUrl.value = url;
try {
await updateContact({
...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 = '';
} 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>
<ContactsForm
ref="contactsFormRef"
:contact-data="selectedContactData"
is-details-view
@update="handleFormUpdate"
/>
<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>
@@ -0,0 +1,76 @@
<script setup>
import { ref } from 'vue';
import { useStore } from 'dashboard/composables/store';
import { debounce } from '@chatwoot/utils';
import { useRouter, useRoute } from 'vue-router';
import ContactsCard from 'dashboard/components-next/Contacts/ContactsCard/ContactsCard.vue';
defineProps({
contacts: {
type: Array,
required: true,
},
});
const store = useStore();
const router = useRouter();
const route = useRoute();
// Manage expanded state here
const expandedCardId = ref(null);
const updateContact = async ({ id, updatedData }) => {
await store.dispatch('contacts/update', updatedData);
await store.dispatch('contacts/fetchContactableInbox', id);
};
const handleFormUpdate = debounce(
({ id, updatedData }) => updateContact({ id, updatedData }),
600,
false
);
const ROUTE_MAPPINGS = {
contacts_dashboard_labels_index: 'contacts_dashboard_labels_edit_index',
contacts_dashboard_segments_index: 'contacts_dashboard_segments_edit_index',
};
const onClickViewDetails = async id => {
const dynamicRouteName =
ROUTE_MAPPINGS[route.name] || 'contacts_dashboard_edit_index';
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: dynamicRouteName, params, query: route.query });
};
const toggleExpanded = id => {
expandedCardId.value = expandedCardId.value === id ? null : id;
};
</script>
<template>
<div class="flex flex-col gap-4">
<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"
@toggle="toggleExpanded(contact.id)"
@update-contact="handleFormUpdate"
@show-contact="onClickViewDetails"
/>
</div>
</template>
@@ -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
@@ -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;
}
}
}
@@ -110,7 +110,7 @@ onMounted(() => {
custom-label-class="min-w-[120px]"
/>
</div>
<div class="flex justify-between w-full gap-2 py-2">
<div class="flex justify-between w-full gap-3 py-2">
<label
class="text-sm font-medium whitespace-nowrap min-w-[120px] text-slate-900 dark:text-slate-50"
>
@@ -0,0 +1,194 @@
<script setup>
import { ref, computed, onMounted, watch } from 'vue';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import { useAlert } from 'dashboard/composables';
import { ExceptionWithMessage } from 'shared/helpers/CustomErrors';
import { debounce } from '@chatwoot/utils';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import {
searchContacts,
createNewContact,
fetchContactableInboxes,
processContactableInboxes,
} from 'dashboard/components-next/NewConversation/helpers/composeConversationHelper.js';
import ComposeNewConversationForm from 'dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue';
const route = useRoute();
const store = useStore();
const { t } = useI18n();
const contacts = ref([]);
const selectedContact = ref(null);
const targetInbox = ref(null);
const isCreatingContact = ref(false);
const isFetchingInboxes = ref(false);
const isSearching = ref(false);
const showComposeNewConversation = ref(false);
const contactById = useMapGetter('contacts/getContactById');
const contactsUiFlags = useMapGetter('contacts/getUIFlags');
const currentUser = useMapGetter('getCurrentUser');
const globalConfig = useMapGetter('globalConfig/get');
const directUploadsEnabled = computed(
() => globalConfig.value.directUploadsEnabled
);
const uiFlags = useMapGetter('contactConversations/getUIFlags');
const contactId = computed(() => route.params.contactId || null);
const activeContact = computed(() => contactById.value(contactId.value));
const onContactSearch = debounce(
async query => {
isSearching.value = true;
contacts.value = [];
try {
contacts.value = await searchContacts(query);
isSearching.value = false;
} catch (error) {
useAlert(t('COMPOSE_NEW_CONVERSATION.CONTACT_SEARCH.ERROR_MESSAGE'));
} finally {
isSearching.value = false;
}
},
300,
false
);
const handleSelectedContact = async ({ value, action, ...rest }) => {
let contact;
if (action === 'create') {
isCreatingContact.value = true;
try {
contact = await createNewContact(value);
isCreatingContact.value = false;
} catch (error) {
isCreatingContact.value = false;
return;
}
} else {
contact = rest;
}
selectedContact.value = contact;
if (contact?.id) {
isFetchingInboxes.value = true;
try {
const contactableInboxes = await fetchContactableInboxes(contact.id);
selectedContact.value.contactInboxes = contactableInboxes;
isFetchingInboxes.value = false;
} catch (error) {
isFetchingInboxes.value = false;
}
}
};
const handleTargetInbox = inbox => {
targetInbox.value = inbox;
onContactSearch('');
};
const clearSelectedContact = () => {
selectedContact.value = null;
targetInbox.value = null;
};
const closeCompose = () => {
showComposeNewConversation.value = false;
selectedContact.value = null;
targetInbox.value = null;
};
const createConversation = async ({ payload, isFromWhatsApp }) => {
try {
const data = await store.dispatch('contactConversations/create', {
params: payload,
isFromWhatsApp,
});
const action = {
type: 'link',
to: `/app/accounts/${data.account_id}/conversations/${data.id}`,
message: t('COMPOSE_NEW_CONVERSATION.FORM.GO_TO_CONVERSATION'),
};
closeCompose();
useAlert(t('COMPOSE_NEW_CONVERSATION.FORM.SUCCESS_MESSAGE'), action);
return true; // Return success
} catch (error) {
useAlert(
error instanceof ExceptionWithMessage
? error.data
: t('COMPOSE_NEW_CONVERSATION.FORM.ERROR_MESSAGE')
);
return false; // Return failure
}
};
const toggle = () => {
showComposeNewConversation.value = !showComposeNewConversation.value;
};
watch(
activeContact,
() => {
if (activeContact.value && contactId.value) {
// Add null check for contactInboxes
const contactInboxes = activeContact.value?.contactInboxes || [];
selectedContact.value = {
...activeContact.value,
contactInboxes: processContactableInboxes(contactInboxes),
};
}
},
{ immediate: true, deep: true }
);
onMounted(() => {
onContactSearch('');
});
const keyboardEvents = {
Escape: {
action: () => {
if (showComposeNewConversation.value) {
showComposeNewConversation.value = false;
}
},
},
};
useKeyboardEvents(keyboardEvents);
</script>
<template>
<div class="relative">
<slot
name="trigger"
:is-open="showComposeNewConversation"
:toggle="toggle"
/>
<ComposeNewConversationForm
v-if="showComposeNewConversation"
:contacts="contacts"
:contact-id="contactId"
:is-loading="isSearching"
:current-user="currentUser"
:selected-contact="selectedContact"
:target-inbox="targetInbox"
:is-creating-contact="isCreatingContact"
:is-fetching-inboxes="isFetchingInboxes"
:is-direct-uploads-enabled="directUploadsEnabled"
:contact-conversations-ui-flags="uiFlags"
:contacts-ui-flags="contactsUiFlags"
@search-contacts="onContactSearch"
@update-selected-contact="handleSelectedContact"
@update-target-inbox="handleTargetInbox"
@clear-selected-contact="clearSelectedContact"
@create-conversation="createConversation"
@discard="closeCompose"
/>
</div>
</template>
@@ -0,0 +1,251 @@
<script setup>
import { defineAsyncComponent, ref, computed } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useFileUpload } from 'dashboard/composables/useFileUpload';
import { ALLOWED_FILE_TYPES } from 'shared/constants/messages';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import FileUpload from 'vue-upload-component';
import Button from 'dashboard/components-next/button/Button.vue';
import WhatsAppOptions from './WhatsAppOptions.vue';
const props = defineProps({
attachedFiles: {
type: Array,
default: () => [],
},
isWhatsappInbox: {
type: Boolean,
default: false,
},
isEmailOrWebWidgetInbox: {
type: Boolean,
default: false,
},
isTwilioSmsInbox: {
type: Boolean,
default: false,
},
messageTemplates: {
type: Array,
default: () => [],
},
channelType: {
type: String,
default: '',
},
isLoading: {
type: Boolean,
default: false,
},
disableSendButton: {
type: Boolean,
default: false,
},
hasNoInbox: {
type: Boolean,
default: false,
},
isDropdownActive: {
type: Boolean,
default: false,
},
});
const emit = defineEmits([
'discard',
'sendMessage',
'sendWhatsappMessage',
'insertEmoji',
'addSignature',
'removeSignature',
'attachFile',
]);
const { t } = useI18n();
const uploadAttachment = ref(null);
const isEmojiPickerOpen = ref(false);
const EmojiInput = defineAsyncComponent(
() => import('shared/components/emoji/EmojiInput.vue')
);
const messageSignature = useMapGetter('getMessageSignature');
const signatureToApply = computed(() => messageSignature.value);
const {
fetchSignatureFlagFromUISettings,
setSignatureFlagForInbox,
isEditorHotKeyEnabled,
} = useUISettings();
const sendWithSignature = computed(() => {
return fetchSignatureFlagFromUISettings(props.channelType);
});
const isSignatureEnabledForInbox = computed(() => {
return props.isEmailOrWebWidgetInbox && sendWithSignature.value;
});
const setSignature = () => {
if (signatureToApply.value) {
if (isSignatureEnabledForInbox.value) {
emit('addSignature', signatureToApply.value);
} else {
emit('removeSignature', signatureToApply.value);
}
}
};
const toggleMessageSignature = () => {
setSignatureFlagForInbox(props.channelType, !sendWithSignature.value);
setSignature();
};
const onClickInsertEmoji = emoji => {
emit('insertEmoji', emoji);
};
const { onFileUpload } = useFileUpload({
isATwilioSMSChannel: props.isTwilioSmsInbox,
attachFile: ({ blob, file }) => {
if (!file) return;
const reader = new FileReader();
reader.readAsDataURL(file.file);
reader.onloadend = () => {
const newFile = {
resource: blob || file,
isPrivate: false,
thumb: reader.result,
blobSignedId: blob?.signed_id,
};
emit('attachFile', [...props.attachedFiles, newFile]);
};
},
});
const sendButtonLabel = computed(() => {
const keyCode = isEditorHotKeyEnabled('cmd_enter') ? '⌘ + ↵' : '↵';
return t('COMPOSE_NEW_CONVERSATION.FORM.ACTION_BUTTONS.SEND', {
keyCode,
});
});
const keyboardEvents = {
Enter: {
action: () => {
if (
isEditorHotKeyEnabled('enter') &&
!props.isWhatsappInbox &&
!props.isDropdownActive
) {
emit('sendMessage');
}
},
},
'$mod+Enter': {
action: () => {
if (
isEditorHotKeyEnabled('cmd_enter') &&
!props.isWhatsappInbox &&
!props.isDropdownActive
) {
emit('sendMessage');
}
},
},
};
useKeyboardEvents(keyboardEvents);
</script>
<template>
<div
class="flex items-center justify-between w-full h-[52px] gap-2 px-4 py-3"
>
<div class="flex items-center gap-2">
<WhatsAppOptions
v-if="isWhatsappInbox"
:message-templates="messageTemplates"
@send-message="emit('sendWhatsappMessage', $event)"
/>
<div
v-if="!isWhatsappInbox && !hasNoInbox"
v-on-clickaway="() => (isEmojiPickerOpen = false)"
class="relative"
>
<Button
icon="i-lucide-smile-plus"
color="slate"
size="sm"
class="!w-10"
@click="isEmojiPickerOpen = !isEmojiPickerOpen"
/>
<EmojiInput
v-if="isEmojiPickerOpen"
class="left-0 top-full mt-1.5"
:on-click="onClickInsertEmoji"
/>
</div>
<FileUpload
v-if="isEmailOrWebWidgetInbox"
ref="uploadAttachment"
input-id="composeNewConversationAttachment"
:size="4096 * 4096"
:accept="ALLOWED_FILE_TYPES"
multiple
:drop-directory="false"
:data="{
direct_upload_url: '/rails/active_storage/direct_uploads',
direct_upload: true,
}"
class="p-px"
@input-file="onFileUpload"
>
<Button
icon="i-lucide-plus"
color="slate"
size="sm"
class="!w-10 relative"
/>
</FileUpload>
<Button
v-if="isEmailOrWebWidgetInbox"
icon="i-lucide-signature"
color="slate"
size="sm"
class="!w-10"
@click="toggleMessageSignature"
/>
</div>
<div class="flex items-center gap-2">
<Button
:label="t('COMPOSE_NEW_CONVERSATION.FORM.ACTION_BUTTONS.DISCARD')"
variant="faded"
color="slate"
size="sm"
class="!text-xs font-medium"
@click="emit('discard')"
/>
<Button
v-if="!isWhatsappInbox"
:label="sendButtonLabel"
size="sm"
class="!text-xs font-medium"
:disabled="isLoading || disableSendButton"
:is-loading="isLoading"
@click="emit('sendMessage')"
/>
</div>
</div>
</template>
<style scoped lang="scss">
.emoji-dialog::before {
@apply hidden;
}
</style>
@@ -0,0 +1,88 @@
<script setup>
import { computed } from 'vue';
import { fileNameWithEllipsis } from '@chatwoot/utils';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
attachments: {
type: Array,
required: true,
},
});
const emit = defineEmits(['update:attachments']);
const isTypeImage = file => {
const type = file.content_type || file.type;
return type.includes('image');
};
const filteredImageAttachments = computed(() => {
return props.attachments.filter(attachment =>
isTypeImage(attachment.resource)
);
});
const filteredNonImageAttachments = computed(() => {
return props.attachments.filter(
attachment => !isTypeImage(attachment.resource)
);
});
const removeAttachment = id => {
const updatedAttachments = props.attachments.filter(
attachment => attachment.resource.id !== id
);
emit('update:attachments', updatedAttachments);
};
</script>
<template>
<div class="flex flex-col gap-4 p-4">
<div
v-if="filteredImageAttachments.length > 0"
class="flex flex-wrap gap-3"
>
<div
v-for="attachment in filteredImageAttachments"
:key="attachment.id"
class="relative group/image w-[72px] h-[72px]"
>
<img
class="object-cover w-[72px] h-[72px] rounded-lg"
:src="attachment.thumb"
/>
<Button
variant="ghost"
icon="i-lucide-trash"
color="slate"
class="absolute top-1 right-1 !w-5 !h-5 transition-opacity duration-150 ease-in-out opacity-0 group-hover/image:opacity-100"
@click="removeAttachment(attachment.resource.id)"
/>
</div>
</div>
<div
v-if="filteredNonImageAttachments.length > 0"
class="flex flex-wrap gap-3"
>
<div
v-for="attachment in filteredNonImageAttachments"
:key="attachment.id"
class="max-w-[300px] inline-flex items-center h-8 min-w-0 bg-n-alpha-2 dark:bg-n-solid-3 rounded-lg gap-3 ltr:pl-3 rtl:pr-3 ltr:pr-2 rtl:pl-2"
>
<span class="text-sm font-medium text-n-slate-11">
{{ fileNameWithEllipsis(attachment.resource) }}
</span>
<Button
variant="ghost"
icon="i-lucide-x"
color="slate"
size="xs"
class="shrink-0 !h-5 !w-5"
@click="removeAttachment(attachment.resource.id)"
/>
</div>
</div>
</div>
</template>
@@ -0,0 +1,170 @@
<script setup>
import { ref } from 'vue';
import ComposeNewConversationForm from './ComposeNewConversationForm.vue';
const contacts = [
{
additionalAttributes: {
city: 'kerala',
country: 'India',
description: 'Curious about the web. ',
companyName: 'Chatwoot',
countryCode: '',
socialProfiles: {
github: 'abozler',
twitter: 'ozler',
facebook: 'abozler',
linkedin: 'abozler',
instagram: 'ozler',
},
},
availabilityStatus: 'offline',
email: 'ozler@chatwoot.com',
id: 29,
name: 'Abraham Ozlers',
phoneNumber: '+246232222222',
identifier: null,
thumbnail:
'https://sivin-tunnel.chatwoot.dev/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBc0FCIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--c20b627b384f5981112e949b8414cd4d3e5912ee/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdCem9MWm05eWJXRjBTU0lJY0c1bkJqb0dSVlE2RTNKbGMybDZaVjkwYjE5bWFXeHNXd2RwQWZvdyIsImV4cCI6bnVsbCwicHVyIjoidmFyaWF0aW9uIn19--ebe60765d222d11ade39165eae49cc4b2de18d89/Avatar%201.20.41%E2%80%AFAM.png',
customAttributes: {
dateContact: '2024-02-01T00:00:00.000Z',
linkContact: 'https://staging.chatwoot.com/app/accounts/3/contacts-new',
listContact: 'Not spam',
numberContact: '12',
},
lastActivityAt: 1712127410,
createdAt: 1712127389,
},
];
const selectedContact = ref({
email: 'ozler@chatwoot.com',
id: 29,
label: 'Abraham Ozlers (ozler@chatwoot.com)',
name: 'Abraham Ozlers',
thumbnail: {
name: 'Abraham Ozlers',
src: 'https://sivin-tunnel.chatwoot.dev/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBc0FCIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--c20b627b384f5981112e949b8414cd4d3e5912ee/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdCem9MWm05eWJXRjBTU0lJY0c1bkJqb0dSVlE2RTNKbGMybDZaVjkwYjE5bWFXeHNXd2RwQWZvdyIsImV4cCI6bnVsbCwicHVyIjoidmFyaWF0aW9uIn19--ebe60765d222d11ade39165eae49cc4b2de18d89/Avatar%201.20.41%E2%80%AFAM.png',
},
contactInboxes: [
{
id: 7,
label: 'PaperLayer Email (testba@paperlayer.test)',
name: 'PaperLayer Email',
email: 'testba@paperlayer.test',
channelType: 'Channel::Email',
},
{
id: 8,
label: 'PaperLayer WhatsApp',
name: 'PaperLayer WhatsApp',
email: '+1223233434',
channelType: 'Channel::Whatsapp',
},
{
id: 9,
label: 'PaperLayer API',
name: 'PaperLayer API',
email: '',
channelType: 'Channel::Api',
},
],
});
const targetInbox = ref({
id: 7,
label: 'PaperLayer Email (testba@paperlayer.test)',
name: 'PaperLayer Email',
email: 'testba@paperlayer.test',
channelType: 'Channel::Email',
});
const currentUser = {
id: 1,
name: 'John Doe',
email: 'john@example.com',
};
// Event handlers
const onSearchContacts = query => {
console.log('Searching contacts:', query);
};
const onUpdateSelectedContact = contact => {
console.log('Selected contact updated:', contact);
};
const onUpdateTargetInbox = inbox => {
targetInbox.value = inbox;
console.log('Target inbox updated:', inbox);
};
const onClearSelectedContact = () => {
console.log('Contact cleared');
};
const onCreateConversation = payload => {
console.log('Creating conversation:', payload);
};
const onDiscard = () => {
console.log('Form discarded');
};
</script>
<template>
<Story
title="Components/Compose/ComposeNewConversationForm"
:layout="{ type: 'grid', width: '800px' }"
>
<Variant title="With all props">
<div class="h-[600px] w-full relative">
<ComposeNewConversationForm
:contacts="contacts"
contact-id=""
:is-loading="false"
:current-user="currentUser"
:selected-contact="selectedContact"
:target-inbox="targetInbox"
:is-creating-contact="false"
is-fetching-inboxes
is-direct-uploads-enabled
:contact-conversations-ui-flags="{ isCreating: false }"
:contacts-ui-flags="{ isFetching: false }"
class="!top-0"
@search-contacts="onSearchContacts"
@update-selected-contact="onUpdateSelectedContact"
@update-target-inbox="onUpdateTargetInbox"
@clear-selected-contact="onClearSelectedContact"
@create-conversation="onCreateConversation"
@discard="onDiscard"
/>
</div>
</Variant>
<Variant title="With no target inbox">
<div class="h-[200px] w-full relative">
<ComposeNewConversationForm
:contacts="contacts"
contact-id=""
:is-loading="false"
:current-user="currentUser"
:selected-contact="{ ...selectedContact, contactInboxes: [] }"
:target-inbox="null"
:is-creating-contact="false"
:is-fetching-inboxes="false"
is-direct-uploads-enabled
:contact-conversations-ui-flags="{ isCreating: false }"
:contacts-ui-flags="{ isFetching: false }"
class="!top-0"
@search-contacts="onSearchContacts"
@update-selected-contact="onUpdateSelectedContact"
@update-target-inbox="onUpdateTargetInbox"
@clear-selected-contact="onClearSelectedContact"
@create-conversation="onCreateConversation"
@discard="onDiscard"
/>
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,340 @@
<script setup>
import { reactive, ref, computed } from 'vue';
import { useVuelidate } from '@vuelidate/core';
import { required, requiredIf } from '@vuelidate/validators';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
import {
appendSignature,
removeSignature,
} from 'dashboard/helper/editorHelper';
import {
buildContactableInboxesList,
prepareNewMessagePayload,
prepareWhatsAppMessagePayload,
} from 'dashboard/components-next/NewConversation/helpers/composeConversationHelper.js';
import ContactSelector from './ContactSelector.vue';
import InboxSelector from './InboxSelector.vue';
import EmailOptions from './EmailOptions.vue';
import MessageEditor from './MessageEditor.vue';
import ActionButtons from './ActionButtons.vue';
import InboxEmptyState from './InboxEmptyState.vue';
import AttachmentPreviews from './AttachmentPreviews.vue';
const props = defineProps({
contacts: { type: Array, default: () => [] },
contactId: { type: String, default: null },
selectedContact: { type: Object, default: null },
targetInbox: { type: Object, default: null },
currentUser: { type: Object, default: null },
isCreatingContact: { type: Boolean, default: false },
isFetchingInboxes: { type: Boolean, default: false },
isLoading: { type: Boolean, default: false },
isDirectUploadsEnabled: { type: Boolean, default: false },
contactConversationsUiFlags: { type: Object, default: null },
contactsUiFlags: { type: Object, default: null },
});
const emit = defineEmits([
'searchContacts',
'discard',
'updateSelectedContact',
'updateTargetInbox',
'clearSelectedContact',
'createConversation',
]);
const showContactsDropdown = ref(false);
const showInboxesDropdown = ref(false);
const showCcEmailsDropdown = ref(false);
const showBccEmailsDropdown = ref(false);
const isCreating = computed(() => props.contactConversationsUiFlags.isCreating);
const state = reactive({
message: '',
subject: '',
ccEmails: '',
bccEmails: '',
attachedFiles: [],
});
const showBccInput = ref(false);
const inboxTypes = computed(() => ({
isEmail: props.targetInbox?.channelType === INBOX_TYPES.EMAIL,
isTwilio: props.targetInbox?.channelType === INBOX_TYPES.TWILIO,
isWhatsapp: props.targetInbox?.channelType === INBOX_TYPES.WHATSAPP,
isWebWidget: props.targetInbox?.channelType === INBOX_TYPES.WEB,
isApi: props.targetInbox?.channelType === INBOX_TYPES.API,
isEmailOrWebWidget:
props.targetInbox?.channelType === INBOX_TYPES.EMAIL ||
props.targetInbox?.channelType === INBOX_TYPES.WEB,
isTwilioSMS:
props.targetInbox?.channelType === INBOX_TYPES.TWILIO &&
props.targetInbox?.medium === 'sms',
}));
const whatsappMessageTemplates = computed(() =>
Object.keys(props.targetInbox?.messageTemplates || {}).length
? props.targetInbox.messageTemplates
: []
);
const inboxChannelType = computed(() => props.targetInbox?.channelType || '');
const validationRules = computed(() => ({
selectedContact: { required },
targetInbox: { required },
message: { required: requiredIf(!inboxTypes.value.isWhatsapp) },
subject: { required: requiredIf(inboxTypes.value.isEmail) },
}));
const v$ = useVuelidate(validationRules, {
selectedContact: computed(() => props.selectedContact),
targetInbox: computed(() => props.targetInbox),
message: computed(() => state.message),
subject: computed(() => state.subject),
});
const validationStates = computed(() => ({
isContactInvalid:
v$.value.selectedContact.$dirty && v$.value.selectedContact.$invalid,
isInboxInvalid: v$.value.targetInbox.$dirty && v$.value.targetInbox.$invalid,
isSubjectInvalid: v$.value.subject.$dirty && v$.value.subject.$invalid,
isMessageInvalid: v$.value.message.$dirty && v$.value.message.$invalid,
}));
const newMessagePayload = () => {
const { message, subject, ccEmails, bccEmails, attachedFiles } = state;
return prepareNewMessagePayload({
targetInbox: props.targetInbox,
selectedContact: props.selectedContact,
message,
subject,
ccEmails,
bccEmails,
currentUser: props.currentUser,
attachedFiles,
directUploadsEnabled: props.isDirectUploadsEnabled,
});
};
const contactableInboxesList = computed(() => {
return buildContactableInboxesList(props.selectedContact?.contactInboxes);
});
const showNoInboxAlert = computed(() => {
return (
props.selectedContact &&
contactableInboxesList.value.length === 0 &&
!props.contactsUiFlags.isFetchingInboxes &&
!props.isFetchingInboxes
);
});
const isAnyDropdownActive = computed(() => {
return (
showContactsDropdown.value ||
showInboxesDropdown.value ||
showCcEmailsDropdown.value ||
showBccEmailsDropdown.value
);
});
const handleContactSearch = value => {
emit('searchContacts', value);
};
const handleDropdownUpdate = (type, value) => {
if (type === 'cc') {
showCcEmailsDropdown.value = value;
} else if (type === 'bcc') {
showBccEmailsDropdown.value = value;
} else {
showContactsDropdown.value = value;
}
};
const toggleBccInput = () => {
showBccInput.value = !showBccInput.value;
};
const searchCcEmails = value => {
showCcEmailsDropdown.value = true;
emit('searchContacts', value);
};
const searchBccEmails = value => {
showBccEmailsDropdown.value = true;
emit('searchContacts', value);
};
const setSelectedContact = async ({ value, action, ...rest }) => {
v$.value.$reset();
emit('updateSelectedContact', { value, action, ...rest });
showContactsDropdown.value = false;
showInboxesDropdown.value = true;
};
const handleInboxAction = ({ value, action, ...rest }) => {
v$.value.$reset();
emit('updateTargetInbox', { ...rest });
showInboxesDropdown.value = false;
state.attachedFiles = [];
};
const removeTargetInbox = value => {
v$.value.$reset();
emit('updateTargetInbox', value);
state.attachedFiles = [];
};
const clearSelectedContact = () => {
emit('clearSelectedContact');
state.attachedFiles = [];
};
const onClickInsertEmoji = emoji => {
state.message += emoji;
};
const handleAddSignature = signature => {
state.message = appendSignature(state.message, signature);
};
const handleRemoveSignature = signature => {
state.message = removeSignature(state.message, signature);
};
const handleAttachFile = files => {
state.attachedFiles = files;
};
const clearForm = () => {
Object.assign(state, {
message: '',
subject: '',
ccEmails: '',
bccEmails: '',
attachedFiles: [],
});
v$.value.$reset();
};
const handleSendMessage = async () => {
const isValid = await v$.value.$validate();
if (!isValid) return;
try {
const success = await emit('createConversation', {
payload: newMessagePayload(),
isFromWhatsApp: false,
});
if (success) {
clearForm();
}
} catch (error) {
// Form will not be cleared if conversation creation fails
}
};
const handleSendWhatsappMessage = async ({ message, templateParams }) => {
const whatsappMessagePayload = prepareWhatsAppMessagePayload({
targetInbox: props.targetInbox,
selectedContact: props.selectedContact,
message,
templateParams,
currentUser: props.currentUser,
});
await emit('createConversation', {
payload: whatsappMessagePayload,
isFromWhatsApp: true,
});
};
</script>
<template>
<div
class="absolute right-0 w-[670px] mt-2 divide-y divide-n-strong overflow-visible transition-all duration-300 ease-in-out top-full justify-between flex flex-col bg-n-alpha-3 border border-n-strong shadow-sm backdrop-blur-[100px] rounded-xl"
>
<ContactSelector
:contacts="contacts"
:selected-contact="selectedContact"
:show-contacts-dropdown="showContactsDropdown"
:is-loading="isLoading"
:is-creating-contact="isCreatingContact"
:contact-id="contactId"
:contactable-inboxes-list="contactableInboxesList"
:show-inboxes-dropdown="showInboxesDropdown"
:has-errors="validationStates.isContactInvalid"
@search-contacts="handleContactSearch"
@set-selected-contact="setSelectedContact"
@clear-selected-contact="clearSelectedContact"
@update-dropdown="handleDropdownUpdate"
/>
<InboxEmptyState v-if="showNoInboxAlert" />
<InboxSelector
v-else
:target-inbox="targetInbox"
:selected-contact="selectedContact"
:show-inboxes-dropdown="showInboxesDropdown"
:contactable-inboxes-list="contactableInboxesList"
:has-errors="validationStates.isInboxInvalid"
@update-inbox="removeTargetInbox"
@toggle-dropdown="showInboxesDropdown = $event"
@handle-inbox-action="handleInboxAction"
/>
<EmailOptions
v-if="inboxTypes.isEmail"
v-model:cc-emails="state.ccEmails"
v-model:bcc-emails="state.bccEmails"
v-model:subject="state.subject"
:contacts="contacts"
:show-cc-emails-dropdown="showCcEmailsDropdown"
:show-bcc-emails-dropdown="showBccEmailsDropdown"
:show-bcc-input="showBccInput"
:is-loading="isLoading"
:has-errors="validationStates.isSubjectInvalid"
@search-cc-emails="searchCcEmails"
@search-bcc-emails="searchBccEmails"
@toggle-bcc="toggleBccInput"
@update-dropdown="handleDropdownUpdate"
/>
<MessageEditor
v-if="!inboxTypes.isWhatsapp && !showNoInboxAlert"
v-model="state.message"
:is-email-or-web-widget-inbox="inboxTypes.isEmailOrWebWidget"
:has-errors="validationStates.isMessageInvalid"
:has-attachments="state.attachedFiles.length > 0"
/>
<AttachmentPreviews
v-if="state.attachedFiles.length > 0"
:attachments="state.attachedFiles"
@update:attachments="state.attachedFiles = $event"
/>
<ActionButtons
:attached-files="state.attachedFiles"
:is-whatsapp-inbox="inboxTypes.isWhatsapp"
:is-email-or-web-widget-inbox="inboxTypes.isEmailOrWebWidget"
:is-twilio-sms-inbox="inboxTypes.isTwilioSMS"
:message-templates="whatsappMessageTemplates"
:channel-type="inboxChannelType"
:is-loading="isCreating"
:disable-send-button="isCreating"
:has-no-inbox="showNoInboxAlert"
:is-dropdown-active="isAnyDropdownActive"
@insert-emoji="onClickInsertEmoji"
@add-signature="handleAddSignature"
@remove-signature="handleRemoveSignature"
@attach-file="handleAttachFile"
@discard="$emit('discard')"
@send-message="handleSendMessage"
@send-whatsapp-message="handleSendWhatsappMessage"
/>
</div>
</template>
@@ -0,0 +1,141 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import TagInput from 'dashboard/components-next/taginput/TagInput.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
contacts: {
type: Array,
required: true,
},
selectedContact: {
type: Object,
default: null,
},
showContactsDropdown: {
type: Boolean,
required: true,
},
isLoading: {
type: Boolean,
required: true,
},
isCreatingContact: {
type: Boolean,
required: true,
},
contactId: {
type: String,
default: null,
},
contactableInboxesList: {
type: Array,
default: () => [],
},
showInboxesDropdown: {
type: Boolean,
required: true,
},
hasErrors: {
type: Boolean,
default: false,
},
});
const emit = defineEmits([
'searchContacts',
'setSelectedContact',
'clearSelectedContact',
'updateDropdown',
]);
const { t } = useI18n();
const contactsList = computed(() => {
return props.contacts?.map(({ name, id, thumbnail, email, ...rest }) => ({
id,
label: `${name} (${email})`,
value: id,
thumbnail: { name, src: thumbnail },
...rest,
name,
email,
action: 'contact',
}));
});
const selectedContactLabel = computed(() => {
return `${props.selectedContact?.name} (${props.selectedContact?.email})`;
});
</script>
<template>
<div class="relative flex-1 px-4 py-3 overflow-y-visible">
<div class="flex items-baseline w-full gap-3 min-h-7">
<label class="text-sm font-medium text-n-slate-11 whitespace-nowrap">
{{ t('COMPOSE_NEW_CONVERSATION.FORM.CONTACT_SELECTOR.LABEL') }}
</label>
<div
v-if="isCreatingContact"
class="flex items-center gap-1.5 rounded-md bg-n-alpha-2 px-3 min-h-7 min-w-0"
>
<span class="text-sm truncate text-n-slate-12">
{{
t('COMPOSE_NEW_CONVERSATION.FORM.CONTACT_SELECTOR.CONTACT_CREATING')
}}
</span>
</div>
<div
v-else-if="selectedContact"
class="flex items-center gap-1.5 rounded-md bg-n-alpha-2 px-3 min-h-7 min-w-0"
>
<span class="text-sm truncate text-n-slate-12">
{{
isCreatingContact
? t(
'COMPOSE_NEW_CONVERSATION.FORM.CONTACT_SELECTOR.CONTACT_CREATING'
)
: selectedContactLabel
}}
</span>
<Button
variant="ghost"
icon="i-lucide-x"
color="slate"
:disabled="contactId"
size="xs"
@click="emit('clearSelectedContact')"
/>
</div>
<TagInput
v-else
:placeholder="
t(
'COMPOSE_NEW_CONVERSATION.FORM.CONTACT_SELECTOR.TAG_INPUT_PLACEHOLDER'
)
"
mode="single"
:menu-items="contactsList"
:show-dropdown="showContactsDropdown"
:is-loading="isLoading"
:disabled="contactableInboxesList?.length > 0 && showInboxesDropdown"
allow-create
type="email"
class="flex-1 min-h-7"
:class="
hasErrors
? '[&_input]:placeholder:!text-n-ruby-9 [&_input]:dark:placeholder:!text-n-ruby-9'
: ''
"
@focus="emit('updateDropdown', 'contacts', true)"
@input="emit('searchContacts', $event)"
@on-click-outside="emit('updateDropdown', 'contacts', false)"
@add="emit('setSelectedContact', $event)"
@remove="emit('clearSelectedContact')"
/>
</div>
</div>
</template>
@@ -0,0 +1,137 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import TagInput from 'dashboard/components-next/taginput/TagInput.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import InlineInput from 'dashboard/components-next/inline-input/InlineInput.vue';
const props = defineProps({
contacts: { type: Array, required: true },
showCcEmailsDropdown: { type: Boolean, required: false },
showBccEmailsDropdown: { type: Boolean, required: false },
showBccInput: { type: Boolean, default: false },
isLoading: { type: Boolean, default: false },
hasErrors: { type: Boolean, default: false },
});
const emit = defineEmits([
'searchCcEmails',
'searchBccEmails',
'toggleBcc',
'updateDropdown',
]);
const subject = defineModel('subject', { type: String, default: '' });
const ccEmails = defineModel('ccEmails', { type: String, default: '' });
const bccEmails = defineModel('bccEmails', { type: String, default: '' });
const { t } = useI18n();
// Convert string to array for TagInput
const ccEmailsArray = computed(() =>
props.ccEmails ? props.ccEmails.split(',').map(email => email.trim()) : []
);
const bccEmailsArray = computed(() =>
props.bccEmails ? props.bccEmails.split(',').map(email => email.trim()) : []
);
const contactEmailsList = computed(() => {
return props.contacts?.map(({ name, id, email }) => ({
id,
label: email,
email,
thumbnail: { name: name, src: '' },
value: id,
action: 'email',
}));
});
// Handle updates from TagInput and convert array back to string
const handleCcUpdate = value => {
ccEmails.value = value.join(',');
};
const handleBccUpdate = value => {
bccEmails.value = value.join(',');
};
</script>
<template>
<div class="flex flex-col divide-y divide-n-strong">
<div class="flex items-baseline flex-1 w-full h-8 gap-3 px-4 py-3">
<InlineInput
v-model="subject"
:placeholder="
t('COMPOSE_NEW_CONVERSATION.FORM.EMAIL_OPTIONS.SUBJECT_PLACEHOLDER')
"
:label="t('COMPOSE_NEW_CONVERSATION.FORM.EMAIL_OPTIONS.SUBJECT_LABEL')"
focus-on-mount
:custom-input-class="
hasErrors
? 'placeholder:!text-n-ruby-9 dark:placeholder:!text-n-ruby-9'
: ''
"
/>
</div>
<div class="flex items-baseline flex-1 w-full gap-3 px-4 py-3 min-h-8">
<label
class="mb-0.5 text-sm font-medium whitespace-nowrap text-n-slate-11"
>
{{ t('COMPOSE_NEW_CONVERSATION.FORM.EMAIL_OPTIONS.CC_LABEL') }}
</label>
<div class="flex items-center w-full gap-3 min-h-7">
<TagInput
:model-value="ccEmailsArray"
:placeholder="
t('COMPOSE_NEW_CONVERSATION.FORM.EMAIL_OPTIONS.CC_PLACEHOLDER')
"
:menu-items="contactEmailsList"
:show-dropdown="showCcEmailsDropdown"
:is-loading="isLoading"
type="email"
class="flex-1 min-h-7"
@focus="emit('updateDropdown', 'cc', true)"
@input="emit('searchCcEmails', $event)"
@on-click-outside="emit('updateDropdown', 'cc', false)"
@update:model-value="handleCcUpdate"
/>
<Button
:label="t('COMPOSE_NEW_CONVERSATION.FORM.EMAIL_OPTIONS.BCC_BUTTON')"
variant="ghost"
size="sm"
color="slate"
class="flex-shrink-0"
@click="emit('toggleBcc')"
/>
</div>
</div>
<div
v-if="showBccInput"
class="flex items-baseline flex-1 w-full gap-3 px-4 py-3 min-h-8"
>
<label
class="mb-0.5 text-sm font-medium whitespace-nowrap text-n-slate-11"
>
{{ t('COMPOSE_NEW_CONVERSATION.FORM.EMAIL_OPTIONS.BCC_LABEL') }}
</label>
<TagInput
:model-value="bccEmailsArray"
:placeholder="
t('COMPOSE_NEW_CONVERSATION.FORM.EMAIL_OPTIONS.BCC_PLACEHOLDER')
"
:menu-items="contactEmailsList"
:show-dropdown="showBccEmailsDropdown"
:is-loading="isLoading"
type="email"
class="flex-1 min-h-7"
focus-on-mount
@focus="emit('updateDropdown', 'bcc', true)"
@input="emit('searchBccEmails', $event)"
@on-click-outside="emit('updateDropdown', 'bcc', false)"
@update:model-value="handleBccUpdate"
/>
</div>
</div>
</template>
@@ -0,0 +1,9 @@
<template>
<div
class="flex items-center w-full px-4 py-3 dark:bg-n-amber-11/15 bg-n-amber-3"
>
<span class="text-sm dark:text-n-amber-11 text-n-amber-11">
{{ $t('COMPOSE_NEW_CONVERSATION.FORM.NO_INBOX_ALERT') }}
</span>
</div>
</template>
@@ -0,0 +1,90 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { generateLabelForContactableInboxesList } from 'dashboard/components-next/NewConversation/helpers/composeConversationHelper.js';
import Button from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
const props = defineProps({
targetInbox: {
type: Object,
default: null,
},
selectedContact: {
type: Object,
default: null,
},
showInboxesDropdown: {
type: Boolean,
required: true,
},
contactableInboxesList: {
type: Array,
default: () => [],
},
hasErrors: {
type: Boolean,
default: false,
},
});
const emit = defineEmits([
'updateInbox',
'toggleDropdown',
'handleInboxAction',
]);
const { t } = useI18n();
const targetInboxLabel = computed(() => {
return generateLabelForContactableInboxesList(props.targetInbox);
});
</script>
<template>
<div
class="flex items-center flex-1 w-full gap-3 px-4 py-3 overflow-y-visible"
>
<label class="mb-0.5 text-sm font-medium text-n-slate-11 whitespace-nowrap">
{{ t('COMPOSE_NEW_CONVERSATION.FORM.INBOX_SELECTOR.LABEL') }}
</label>
<div
v-if="targetInbox"
class="flex items-center gap-1.5 rounded-md bg-n-alpha-2 truncate px-3 h-7 min-w-0"
>
<span class="text-sm text-n-slate-12">
{{ targetInboxLabel }}
</span>
<Button
variant="ghost"
icon="i-lucide-x"
color="slate"
size="xs"
class="flex-shrink-0"
@click="emit('updateInbox', null)"
/>
</div>
<div
v-else
v-on-clickaway="() => emit('toggleDropdown', false)"
class="relative flex items-center h-7"
>
<Button
:label="t('COMPOSE_NEW_CONVERSATION.FORM.INBOX_SELECTOR.BUTTON')"
variant="link"
size="sm"
:color="hasErrors ? 'ruby' : 'slate'"
:disabled="!selectedContact"
class="hover:!no-underline"
@click="emit('toggleDropdown', !showInboxesDropdown)"
/>
<DropdownMenu
v-if="contactableInboxesList?.length > 0 && showInboxesDropdown"
:menu-items="contactableInboxesList"
class="left-0 z-[100] top-8 overflow-y-auto max-h-60 w-fit max-w-sm dark:!outline-n-slate-5"
@action="emit('handleInboxAction', $event)"
/>
</div>
</div>
</template>
@@ -0,0 +1,65 @@
<script setup>
import { useI18n } from 'vue-i18n';
import Editor from 'dashboard/components-next/Editor/Editor.vue';
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
defineProps({
isEmailOrWebWidgetInbox: {
type: Boolean,
required: true,
},
hasErrors: {
type: Boolean,
default: false,
},
hasAttachments: {
type: Boolean,
default: false,
},
});
const { t } = useI18n();
const modelValue = defineModel({
type: String,
default: '',
});
</script>
<template>
<div
v-if="isEmailOrWebWidgetInbox"
class="flex-1 h-full"
:class="!hasAttachments && 'min-h-[200px]'"
>
<Editor
v-model="modelValue"
:placeholder="
t('COMPOSE_NEW_CONVERSATION.FORM.MESSAGE_EDITOR.PLACEHOLDER')
"
class="[&>div]:!border-transparent [&>div]:px-4 [&>div]:py-4 [&>div]:!bg-transparent h-full [&_.ProseMirror-woot-style]:!max-h-[200px]"
:class="
hasErrors
? '[&_.empty-node]:before:!text-n-ruby-9 [&_.empty-node]:dark:before:!text-n-ruby-9'
: ''
"
:show-character-count="false"
/>
</div>
<div v-else class="flex-1 h-full" :class="!hasAttachments && 'min-h-[200px]'">
<TextArea
v-model="modelValue"
:placeholder="
t('COMPOSE_NEW_CONVERSATION.FORM.MESSAGE_EDITOR.PLACEHOLDER')
"
class="!px-0 [&>div]:!px-4 [&>div]:!border-transparent [&>div]:!bg-transparent"
auto-height
:custom-text-area-class="
hasErrors
? 'placeholder:!text-n-ruby-9 dark:placeholder:!text-n-ruby-9'
: ''
"
/>
</div>
</template>
@@ -0,0 +1,126 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import Button from 'dashboard/components-next/button/Button.vue';
import WhatsappTemplateParser from './WhatsappTemplateParser.vue';
const props = defineProps({
messageTemplates: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(['sendMessage']);
const { t } = useI18n();
// TODO: Remove this when we support all formats
const formatsToRemove = ['DOCUMENT', 'IMAGE', 'VIDEO'];
const searchQuery = ref('');
const selectedTemplate = ref(null);
const showTemplatesMenu = ref(false);
const whatsAppTemplateMessages = computed(() => {
// Add null check and ensure it's an array
const templates = Array.isArray(props.messageTemplates)
? props.messageTemplates
: [];
// TODO: Remove the last filter when we support all formats
return templates
.filter(template => template?.status?.toLowerCase() === 'approved')
.filter(template => {
return template?.components?.every(component => {
return !formatsToRemove.includes(component.format);
});
});
});
const filteredTemplates = computed(() => {
return whatsAppTemplateMessages.value.filter(template =>
template.name.toLowerCase().includes(searchQuery.value.toLowerCase())
);
});
const getTemplateBody = template => {
return template.components.find(component => component.type === 'BODY').text;
};
const handleTriggerClick = () => {
searchQuery.value = '';
showTemplatesMenu.value = !showTemplatesMenu.value;
};
const handleTemplateClick = template => {
selectedTemplate.value = template;
showTemplatesMenu.value = false;
};
const handleBack = () => {
selectedTemplate.value = null;
showTemplatesMenu.value = true;
};
const handleSendMessage = template => {
emit('sendMessage', template);
selectedTemplate.value = null;
};
</script>
<template>
<div class="relative">
<Button
icon="i-ri-whatsapp-line"
:label="t('COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.LABEL')"
color="slate"
size="sm"
:disabled="selectedTemplate"
class="!text-xs font-medium"
@click="handleTriggerClick"
/>
<div
v-if="showTemplatesMenu"
class="absolute top-full mt-1.5 max-h-96 overflow-y-auto left-0 flex flex-col gap-2 p-4 items-center w-[350px] h-auto bg-n-solid-2 border border-n-strong shadow-sm rounded-lg"
>
<div class="relative w-full">
<span class="absolute i-lucide-search size-3.5 top-2 left-3" />
<input
v-model="searchQuery"
type="search"
:placeholder="
t(
'COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.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-for="template in filteredTemplates"
:key="template.id"
class="flex flex-col w-full gap-2 p-2 rounded-lg cursor-pointer dark:hover:bg-n-alpha-3 hover:bg-n-alpha-1"
@click="handleTemplateClick(template)"
>
<span class="text-sm text-n-slate-12">{{ template.name }}</span>
<p class="mb-0 text-xs leading-5 text-n-slate-11 line-clamp-2">
{{ getTemplateBody(template) }}
</p>
</div>
<template v-if="filteredTemplates.length === 0">
<p class="w-full pt-2 text-sm text-n-slate-11">
{{ t('COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.EMPTY_STATE') }}
</p>
</template>
</div>
<WhatsappTemplateParser
v-if="selectedTemplate"
:template="selectedTemplate"
@send-message="handleSendMessage"
@back="handleBack"
/>
</div>
</template>
@@ -0,0 +1,176 @@
<script setup>
import { computed, ref, onMounted } from 'vue';
import { useVuelidate } from '@vuelidate/core';
import { requiredIf } from '@vuelidate/validators';
import { useI18n } from 'vue-i18n';
import Input from 'dashboard/components-next/input/Input.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
template: {
type: Object,
required: true,
},
});
const emit = defineEmits(['sendMessage', 'back']);
const { t } = useI18n();
const processedParams = ref({});
const templateName = computed(() => {
return props.template?.name || '';
});
const templateString = computed(() => {
return props.template?.components?.find(
component => component.type === 'BODY'
).text;
});
const processVariable = str => {
return str.replace(/{{|}}/g, '');
};
const processedString = computed(() => {
return templateString.value.replace(/{{([^}]+)}}/g, (match, variable) => {
const variableKey = processVariable(variable);
return processedParams.value[variableKey] || `{{${variable}}}`;
});
});
const processedStringWithVariableHighlight = computed(() => {
const variables = templateString.value.match(/{{([^}]+)}}/g) || [];
return variables.reduce((result, variable) => {
const variableKey = processVariable(variable);
const value = processedParams.value[variableKey] || variable;
return result.replace(
variable,
`<span class="break-all text-n-slate-12">${value}</span>`
);
}, templateString.value);
});
const rules = computed(() => {
const paramRules = {};
Object.keys(processedParams.value).forEach(key => {
paramRules[key] = { required: requiredIf(true) };
});
return {
processedParams: paramRules,
};
});
const v$ = useVuelidate(rules, { processedParams });
const getFieldErrorType = key => {
if (!v$.value.processedParams[key]?.$error) return 'info';
return 'error';
};
const generateVariables = () => {
const matchedVariables = templateString.value.match(/{{([^}]+)}}/g);
if (!matchedVariables) return;
const finalVars = matchedVariables.map(i => processVariable(i));
processedParams.value = finalVars.reduce((acc, variable) => {
acc[variable] = '';
return acc;
}, {});
};
const sendMessage = async () => {
const isValid = await v$.value.$validate();
if (!isValid) return;
const payload = {
message: processedString.value,
templateParams: {
name: props.template.name,
category: props.template.category,
language: props.template.language,
namespace: props.template.namespace,
processed_params: processedParams.value,
},
};
emit('sendMessage', payload);
};
onMounted(() => {
generateVariables();
});
</script>
<template>
<div
class="absolute top-full mt-1.5 max-h-[500px] overflow-y-auto left-0 flex flex-col gap-4 px-4 pt-6 pb-5 items-start w-[460px] h-auto bg-n-solid-2 border border-n-strong shadow-sm rounded-lg"
>
<span class="text-sm text-n-slate-12">
{{
t(
'COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.TEMPLATE_PARSER.TEMPLATE_NAME',
{ templateName: templateName }
)
}}
</span>
<p
class="mb-0 text-sm text-n-slate-11"
v-html="processedStringWithVariableHighlight"
/>
<span
v-if="Object.keys(processedParams).length"
class="text-sm font-medium text-n-slate-12"
>
{{
t(
'COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.TEMPLATE_PARSER.VARIABLES'
)
}}
</span>
<div
v-for="(variable, key) in processedParams"
:key="key"
class="flex items-center w-full gap-2"
>
<span
class="flex items-center h-8 text-sm min-w-6 ltr:text-left rtl:text-right text-n-slate-10"
>
{{ key }}
</span>
<Input
v-model="processedParams[key]"
custom-input-class="!h-8 w-full !bg-transparent"
class="w-full"
:message-type="getFieldErrorType(key)"
/>
</div>
<div class="flex items-end justify-between w-full gap-3 h-14">
<Button
:label="
t(
'COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.TEMPLATE_PARSER.BACK'
)
"
color="slate"
variant="faded"
class="w-full font-medium"
@click="emit('back')"
/>
<Button
:label="
t(
'COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.TEMPLATE_PARSER.SEND_MESSAGE'
)
"
class="w-full font-medium"
@click="sendMessage"
/>
</div>
</div>
</template>
@@ -0,0 +1,183 @@
import { INBOX_TYPES } from 'dashboard/helper/inbox';
import camelcaseKeys from 'camelcase-keys';
import ContactAPI from 'dashboard/api/contacts';
export const convertChannelTypeToLabel = channelType => {
const [, type] = channelType.split('::');
return type ? type.charAt(0).toUpperCase() + type.slice(1) : channelType;
};
export const generateLabelForContactableInboxesList = ({
name,
email,
channelType,
phoneNumber,
}) => {
if (channelType === INBOX_TYPES.EMAIL) {
return `${name} (${email})`;
}
if (
channelType === INBOX_TYPES.TWILIO ||
channelType === INBOX_TYPES.WHATSAPP
) {
return `${name} (${phoneNumber})`;
}
return `${name} (${convertChannelTypeToLabel(channelType)})`;
};
export const buildContactableInboxesList = contactInboxes => {
if (!contactInboxes) return [];
return contactInboxes.map(
({ name, id, email, channelType, phoneNumber, ...rest }) => ({
id,
label: generateLabelForContactableInboxesList({
name,
email,
channelType,
phoneNumber,
}),
action: 'inbox',
value: id,
name,
email,
phoneNumber,
channelType,
...rest,
})
);
};
export const getCapitalizedNameFromEmail = email => {
const name = email.match(/^([^@]*)@/)?.[1] || email.split('@')[0];
return name.charAt(0).toUpperCase() + name.slice(1);
};
export const processContactableInboxes = inboxes => {
return inboxes.map(inbox => ({
...inbox.inbox,
sourceId: inbox.sourceId,
}));
};
export const prepareAttachmentPayload = (
attachedFiles,
directUploadsEnabled
) => {
const files = [];
attachedFiles.forEach(attachment => {
if (directUploadsEnabled) {
files.push(attachment.blobSignedId);
} else {
files.push(attachment.resource.file);
}
});
return files;
};
export const prepareNewMessagePayload = ({
targetInbox,
selectedContact,
message,
subject,
ccEmails,
bccEmails,
currentUser,
attachedFiles = [],
directUploadsEnabled = false,
}) => {
const payload = {
inboxId: targetInbox.id,
sourceId: targetInbox.sourceId,
contactId: Number(selectedContact.id),
message: { content: message },
assigneeId: currentUser.id,
};
if (attachedFiles?.length) {
payload.files = prepareAttachmentPayload(
attachedFiles,
directUploadsEnabled
);
}
if (subject) {
payload.mailSubject = subject;
}
if (ccEmails) {
payload.message.cc_emails = ccEmails;
}
if (bccEmails) {
payload.message.bcc_emails = bccEmails;
}
return payload;
};
export const prepareWhatsAppMessagePayload = ({
targetInbox,
selectedContact,
message,
templateParams,
currentUser,
}) => {
return {
inboxId: targetInbox.id,
sourceId: targetInbox.sourceId,
contactId: selectedContact.id,
message: { content: message, template_params: templateParams },
assigneeId: currentUser.id,
};
};
export const generateContactQuery = ({ query }) => {
return {
payload: [
{
attribute_key: 'email',
filter_operator: 'contains',
values: [query],
attribute_model: 'standard',
custom_attribute_type: '',
},
],
};
};
// API Calls
export const searchContacts = async query => {
const {
data: { payload },
} = await ContactAPI.filter(
undefined,
'name',
generateContactQuery({ query })
);
return camelcaseKeys(payload, { deep: true });
};
export const createNewContact = async email => {
const payload = {
name: getCapitalizedNameFromEmail(email),
email,
};
const {
data: {
payload: { contact: newContact },
},
} = await ContactAPI.create(payload);
return camelcaseKeys(newContact, { deep: true });
};
export const fetchContactableInboxes = async contactId => {
const {
data: { payload: inboxes = [] },
} = await ContactAPI.getContactableInboxes(contactId);
const convertInboxesToCamelKeys = camelcaseKeys(inboxes, { deep: true });
return processContactableInboxes(convertInboxesToCamelKeys);
};
@@ -0,0 +1,372 @@
import { describe, it, expect, vi } from 'vitest';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
import ContactAPI from 'dashboard/api/contacts';
import * as helpers from '../composeConversationHelper';
vi.mock('dashboard/api/contacts');
describe('composeConversationHelper', () => {
describe('convertChannelTypeToLabel', () => {
it('converts channel type with namespace to capitalized label', () => {
expect(helpers.convertChannelTypeToLabel('Channel::Email')).toBe('Email');
expect(helpers.convertChannelTypeToLabel('Channel::Whatsapp')).toBe(
'Whatsapp'
);
});
it('returns original value if no namespace found', () => {
expect(helpers.convertChannelTypeToLabel('email')).toBe('email');
});
});
describe('generateLabelForContactableInboxesList', () => {
const contact = {
name: 'John Doe',
email: 'john@example.com',
phoneNumber: '+1234567890',
};
it('generates label for email inbox', () => {
expect(
helpers.generateLabelForContactableInboxesList({
...contact,
channelType: INBOX_TYPES.EMAIL,
})
).toBe('John Doe (john@example.com)');
});
it('generates label for twilio inbox', () => {
expect(
helpers.generateLabelForContactableInboxesList({
...contact,
channelType: INBOX_TYPES.TWILIO,
})
).toBe('John Doe (+1234567890)');
});
it('generates label for whatsapp inbox', () => {
expect(
helpers.generateLabelForContactableInboxesList({
...contact,
channelType: INBOX_TYPES.WHATSAPP,
})
).toBe('John Doe (+1234567890)');
});
it('generates label for other inbox types', () => {
expect(
helpers.generateLabelForContactableInboxesList({
...contact,
channelType: 'Channel::Api',
})
).toBe('John Doe (Api)');
});
});
describe('buildContactableInboxesList', () => {
it('returns empty array if no contact inboxes', () => {
expect(helpers.buildContactableInboxesList(null)).toEqual([]);
expect(helpers.buildContactableInboxesList(undefined)).toEqual([]);
});
it('builds list of contactable inboxes with correct format', () => {
const inboxes = [
{
id: 1,
name: 'Email Inbox',
email: 'support@example.com',
channelType: INBOX_TYPES.EMAIL,
phoneNumber: null,
},
];
const result = helpers.buildContactableInboxesList(inboxes);
expect(result[0]).toMatchObject({
id: 1,
label: 'Email Inbox (support@example.com)',
action: 'inbox',
value: 1,
name: 'Email Inbox',
email: 'support@example.com',
channelType: INBOX_TYPES.EMAIL,
});
});
});
describe('getCapitalizedNameFromEmail', () => {
it('extracts and capitalizes name from email', () => {
expect(helpers.getCapitalizedNameFromEmail('john.doe@example.com')).toBe(
'John.doe'
);
expect(helpers.getCapitalizedNameFromEmail('jane@example.com')).toBe(
'Jane'
);
});
});
describe('processContactableInboxes', () => {
it('processes inboxes with correct structure', () => {
const inboxes = [
{
inbox: { id: 1, name: 'Inbox 1' },
sourceId: 'source1',
},
];
const result = helpers.processContactableInboxes(inboxes);
expect(result[0]).toEqual({
id: 1,
name: 'Inbox 1',
sourceId: 'source1',
});
});
});
describe('prepareAttachmentPayload', () => {
it('prepares direct upload files', () => {
const files = [{ blobSignedId: 'signed1' }];
expect(helpers.prepareAttachmentPayload(files, true)).toEqual([
'signed1',
]);
});
it('prepares regular files', () => {
const files = [{ resource: { file: 'file1' } }];
expect(helpers.prepareAttachmentPayload(files, false)).toEqual(['file1']);
});
});
describe('prepareNewMessagePayload', () => {
const baseParams = {
targetInbox: { id: 1, sourceId: 'source1' },
selectedContact: { id: '2' },
message: 'Hello',
currentUser: { id: 3 },
};
it('prepares basic message payload', () => {
const result = helpers.prepareNewMessagePayload(baseParams);
expect(result).toEqual({
inboxId: 1,
sourceId: 'source1',
contactId: 2,
message: { content: 'Hello' },
assigneeId: 3,
});
});
it('includes optional fields when provided', () => {
const result = helpers.prepareNewMessagePayload({
...baseParams,
subject: 'Test',
ccEmails: 'cc@test.com',
bccEmails: 'bcc@test.com',
attachedFiles: [{ blobSignedId: 'file1' }],
directUploadsEnabled: true,
});
expect(result).toMatchObject({
mailSubject: 'Test',
message: {
content: 'Hello',
cc_emails: 'cc@test.com',
bcc_emails: 'bcc@test.com',
},
files: ['file1'],
});
});
});
describe('prepareWhatsAppMessagePayload', () => {
it('prepares whatsapp message payload', () => {
const params = {
targetInbox: { id: 1, sourceId: 'source1' },
selectedContact: { id: 2 },
message: 'Hello',
templateParams: { param1: 'value1' },
currentUser: { id: 3 },
};
const result = helpers.prepareWhatsAppMessagePayload(params);
expect(result).toEqual({
inboxId: 1,
sourceId: 'source1',
contactId: 2,
message: {
content: 'Hello',
template_params: { param1: 'value1' },
},
assigneeId: 3,
});
});
});
describe('generateContactQuery', () => {
it('generates correct query structure for contact search', () => {
const query = 'test@example.com';
const expected = {
payload: [
{
attribute_key: 'email',
filter_operator: 'contains',
values: [query],
attribute_model: 'standard',
custom_attribute_type: '',
},
],
};
expect(helpers.generateContactQuery({ query })).toEqual(expected);
});
it('handles empty query', () => {
const expected = {
payload: [
{
attribute_key: 'email',
filter_operator: 'contains',
values: [''],
attribute_model: 'standard',
custom_attribute_type: '',
},
],
};
expect(helpers.generateContactQuery({ query: '' })).toEqual(expected);
});
});
describe('API calls', () => {
describe('searchContacts', () => {
it('searches contacts and returns camelCase results', async () => {
const mockPayload = [
{
id: 1,
name: 'John Doe',
email: 'john@example.com',
phone_number: '+1234567890',
created_at: '2023-01-01',
},
];
ContactAPI.filter.mockResolvedValue({
data: { payload: mockPayload },
});
const result = await helpers.searchContacts('john');
expect(result).toEqual([
{
id: 1,
name: 'John Doe',
email: 'john@example.com',
phoneNumber: '+1234567890',
createdAt: '2023-01-01',
},
]);
expect(ContactAPI.filter).toHaveBeenCalledWith(
undefined,
'name',
helpers.generateContactQuery({ query: 'john' })
);
});
it('handles empty search results', async () => {
ContactAPI.filter.mockResolvedValue({
data: { payload: [] },
});
const result = await helpers.searchContacts('nonexistent');
expect(result).toEqual([]);
});
it('transforms nested objects to camelCase', async () => {
const mockPayload = [
{
id: 1,
contact_inboxes: [
{
inbox_id: 1,
source_id: 'source1',
created_at: '2023-01-01',
},
],
custom_attributes: {
custom_field_name: 'value',
},
},
];
ContactAPI.filter.mockResolvedValue({
data: { payload: mockPayload },
});
const result = await helpers.searchContacts('test');
expect(result).toEqual([
{
id: 1,
contactInboxes: [
{
inboxId: 1,
sourceId: 'source1',
createdAt: '2023-01-01',
},
],
customAttributes: {
customFieldName: 'value',
},
},
]);
});
});
describe('createNewContact', () => {
it('creates new contact with capitalized name', async () => {
const mockContact = { id: 1, name: 'John', email: 'john@example.com' };
ContactAPI.create.mockResolvedValue({
data: { payload: { contact: mockContact } },
});
const result = await helpers.createNewContact('john@example.com');
expect(result).toEqual(mockContact);
expect(ContactAPI.create).toHaveBeenCalledWith({
name: 'John',
email: 'john@example.com',
});
});
});
describe('fetchContactableInboxes', () => {
it('fetches and processes contactable inboxes', async () => {
const mockInboxes = [
{
inbox: { id: 1, name: 'Inbox 1' },
sourceId: 'source1',
},
];
ContactAPI.getContactableInboxes.mockResolvedValue({
data: { payload: mockInboxes },
});
const result = await helpers.fetchContactableInboxes(1);
expect(result[0]).toEqual({
id: 1,
name: 'Inbox 1',
sourceId: 'source1',
});
expect(ContactAPI.getContactableInboxes).toHaveBeenCalledWith(1);
});
it('returns empty array when no inboxes found', async () => {
ContactAPI.getContactableInboxes.mockResolvedValue({
data: { payload: [] },
});
const result = await helpers.fetchContactableInboxes(1);
expect(result).toEqual([]);
});
});
});
});
@@ -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')
);
},
},
});
@@ -29,7 +29,7 @@ const props = defineProps({
},
});
const emit = defineEmits(['update:searchValue', 'select', 'search']);
const emit = defineEmits(['select', 'search']);
const { t } = useI18n();
@@ -25,6 +25,10 @@ const props = defineProps({
type: String,
default: '',
},
isSearching: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['action']);
@@ -81,7 +85,6 @@ onMounted(() => {
}"
:disabled="item.disabled"
@click="handleAction(item)"
@keydown.enter="handleAction(item)"
>
<slot name="thumbnail" :item="item">
<Avatar
@@ -102,7 +105,11 @@ onMounted(() => {
v-if="filteredMenuItems.length === 0"
class="text-sm text-n-slate-11 px-2 py-1.5"
>
{{ t('DROPDOWN_MENU.EMPTY_STATE') }}
{{
isSearching
? t('DROPDOWN_MENU.SEARCHING')
: t('DROPDOWN_MENU.EMPTY_STATE')
}}
</div>
</div>
</template>
@@ -1,9 +1,7 @@
<script setup>
defineProps({
modelValue: {
type: [String, Number],
default: '',
},
import { ref, onMounted, nextTick } from 'vue';
const props = defineProps({
type: {
type: String,
default: 'text',
@@ -32,13 +30,45 @@ defineProps({
type: Boolean,
default: false,
},
focusOnMount: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['update:modelValue', 'enterPress']);
const emit = defineEmits(['enterPress', 'input', 'blur']);
const modelValue = defineModel({
type: [String, Number],
default: '',
});
const inlineInputRef = ref(null);
const onEnterPress = () => {
emit('enterPress');
};
const handleInput = event => {
emit('input', event.target.value);
modelValue.value = event.target.value;
};
const handleBlur = event => {
emit('blur', event.target.value);
};
onMounted(() => {
nextTick(() => {
if (props.focusOnMount) {
inlineInputRef.value?.focus();
}
});
});
defineExpose({
focus: () => inlineInputRef.value?.focus(),
});
</script>
<template>
@@ -49,7 +79,7 @@ const onEnterPress = () => {
v-if="label"
:for="id"
:class="customLabelClass"
class="mb-0.5 text-sm font-medium text-gray-900 dark:text-gray-50"
class="mb-0.5 text-sm font-medium text-n-slate-11"
>
{{ label }}
</label>
@@ -57,13 +87,15 @@ const onEnterPress = () => {
<slot name="prefix" />
<input
:id="id"
:value="modelValue"
ref="inlineInputRef"
v-model="modelValue"
:type="type"
:placeholder="placeholder"
:disabled="disabled"
:class="customInputClass"
class="flex w-full reset-base text-sm h-6 !mb-0 border-0 rounded-lg bg-transparent dark:bg-transparent placeholder:text-slate-200 dark:placeholder:text-slate-500 disabled:cursor-not-allowed disabled:opacity-50 text-slate-900 dark:text-white transition-all duration-500 ease-in-out"
@input="$emit('update:modelValue', $event.target.value)"
class="flex w-full reset-base text-sm h-6 !mb-0 border-0 rounded-none bg-transparent dark:bg-transparent placeholder:text-n-slate-10 dark:placeholder:text-n-slate-10 disabled:cursor-not-allowed disabled:opacity-50 text-n-slate-12 dark:text-n-slate-12 transition-all duration-500 ease-in-out"
@input="handleInput"
@blur="handleBlur"
@keydown.enter.prevent="onEnterPress"
/>
</div>
@@ -17,6 +17,10 @@ const props = defineProps({
type: Number,
default: 16,
},
currentPageInfo: {
type: String,
default: '',
},
});
const emit = defineEmits(['update:currentPage']);
const { t } = useI18n();
@@ -39,11 +43,14 @@ const changePage = newPage => {
};
const currentPageInformation = computed(() => {
return t('PAGINATION_FOOTER.SHOWING', {
startItem: startItem.value,
endItem: endItem.value,
totalItems: props.totalItems,
});
return t(
props.currentPageInfo ? props.currentPageInfo : 'PAGINATION_FOOTER.SHOWING',
{
startItem: startItem.value,
endItem: endItem.value,
totalItems: props.totalItems,
}
);
});
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">
{{ currentPage }}
</span>
<span>{{ pageInfo }}</span>
<span class="truncate">{{ pageInfo }}</span>
</div>
<Button
icon="i-lucide-chevron-right"
@@ -7,6 +7,7 @@ import { useMapGetter } from 'dashboard/composables/store';
import { useStore } from 'vuex';
import { useI18n } from 'vue-i18n';
import { useStorage } from '@vueuse/core';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { useSidebarKeyboardShortcuts } from './useSidebarKeyboardShortcuts';
import SidebarGroup from './SidebarGroup.vue';
@@ -59,7 +60,11 @@ provideSidebarContext({
const inboxes = useMapGetter('inboxes/getInboxes');
const labels = useMapGetter('labels/getLabelsOnSidebar');
const teams = useMapGetter('teams/getMyTeams');
const currentAccountId = useMapGetter('getCurrentAccountId');
const contactCustomViews = useMapGetter('customViews/getContactCustomViews');
const isFeatureEnabledonAccount = useMapGetter(
'accounts/isFeatureEnabledonAccount'
);
const conversationCustomViews = useMapGetter(
'customViews/getConversationCustomViews'
);
@@ -78,6 +83,13 @@ const sortedInboxes = computed(() =>
inboxes.value.slice().sort((a, b) => a.name.localeCompare(b.name))
);
const showV4Feature = computed(() => {
return isFeatureEnabledonAccount.value(
currentAccountId.value,
FEATURE_FLAGS.CHATWOOT_V4
);
});
const menuItems = computed(() => {
return [
{
@@ -189,46 +201,119 @@ const menuItems = computed(() => {
},
],
},
{
name: 'Contacts',
label: t('SIDEBAR.CONTACTS'),
icon: 'i-lucide-contact',
children: [
{
name: 'All Contacts',
label: t('SIDEBAR.ALL_CONTACTS'),
to: accountScopedRoute('contacts_dashboard'),
!showV4Feature.value
? {
name: 'Contacts',
label: t('SIDEBAR.CONTACTS'),
icon: 'i-lucide-contact',
children: [
{
name: 'All Contacts',
label: t('SIDEBAR.ALL_CONTACTS'),
to: accountScopedRoute('contacts_dashboard'),
},
{
name: 'Segments',
icon: 'i-lucide-group',
label: t('SIDEBAR.CUSTOM_VIEWS_SEGMENTS'),
children: contactCustomViews.value.map(view => ({
name: `${view.name}-${view.id}`,
label: view.name,
to: accountScopedRoute('contacts_segments_dashboard', {
id: view.id,
}),
})),
},
{
name: 'Tagged With',
icon: 'i-lucide-tag',
label: t('SIDEBAR.TAGGED_WITH'),
children: labels.value.map(label => ({
name: `${label.title}-${label.id}`,
label: label.title,
icon: h('span', {
class: `size-[12px] ring-1 ring-n-alpha-1 dark:ring-white/20 ring-inset rounded-sm`,
style: { backgroundColor: label.color },
}),
to: accountScopedRoute('contacts_labels_dashboard', {
label: label.title,
}),
})),
},
],
}
: {
name: 'Contacts',
label: t('SIDEBAR.CONTACTS'),
icon: 'i-lucide-contact',
children: [
{
name: 'All Contacts',
label: t('SIDEBAR.ALL_CONTACTS'),
to: accountScopedRoute(
'contacts_dashboard_index',
{},
{
page: 1,
search: undefined,
}
),
activeOn: [
'contacts_dashboard_index',
'contacts_dashboard_edit_index',
],
},
{
name: 'Segments',
icon: 'i-lucide-group',
label: t('SIDEBAR.CUSTOM_VIEWS_SEGMENTS'),
children: contactCustomViews.value.map(view => ({
name: `${view.name}-${view.id}`,
label: view.name,
to: accountScopedRoute(
'contacts_dashboard_segments_index',
{
segmentId: view.id,
},
{
page: 1,
}
),
activeOn: [
'contacts_dashboard_segments_index',
'contacts_dashboard_segments_edit_index',
],
})),
},
{
name: 'Tagged With',
icon: 'i-lucide-tag',
label: t('SIDEBAR.TAGGED_WITH'),
children: labels.value.map(label => ({
name: `${label.title}-${label.id}`,
label: label.title,
icon: h('span', {
class: `size-[12px] ring-1 ring-n-alpha-1 dark:ring-white/20 ring-inset rounded-sm`,
style: { backgroundColor: label.color },
}),
to: accountScopedRoute(
'contacts_dashboard_labels_index',
{
label: label.title,
},
{
page: 1,
search: undefined,
}
),
activeOn: [
'contacts_dashboard_labels_index',
'contacts_dashboard_labels_edit_index',
],
})),
},
],
},
{
name: 'Segments',
icon: 'i-lucide-group',
label: t('SIDEBAR.CUSTOM_VIEWS_SEGMENTS'),
children: contactCustomViews.value.map(view => ({
name: `${view.name}-${view.id}`,
label: view.name,
to: accountScopedRoute('contacts_segments_dashboard', {
id: view.id,
}),
})),
},
{
name: 'Tagged With',
icon: 'i-lucide-tag',
label: t('SIDEBAR.TAGGED_WITH'),
children: labels.value.map(label => ({
name: `${label.title}-${label.id}`,
label: label.title,
icon: h('span', {
class: `size-[12px] ring-1 ring-n-alpha-1 dark:ring-white/20 ring-inset rounded-sm`,
style: { backgroundColor: label.color },
}),
to: accountScopedRoute('contacts_labels_dashboard', {
label: label.title,
}),
})),
},
],
},
{
name: 'Reports',
label: t('SIDEBAR.REPORTS'),
@@ -344,18 +429,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',
@@ -66,14 +66,35 @@ const activeChild = computed(() => {
);
if (pathSame) return pathSame;
const pathSatrtsWith = navigableChildren.value.find(
child => child.to && route.path.startsWith(resolvePath(child.to))
);
if (pathSatrtsWith) return pathSatrtsWith;
return navigableChildren.value.find(child =>
// Rank the activeOn Prop higher than the path match
// 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
// For example, contacts segment list in the sidebar effectively has the same name
// But the params are different
const activeOnPages = navigableChildren.value.filter(child =>
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(() => {
@@ -1,51 +1,166 @@
<script setup>
import { ref, computed, watch } from 'vue';
import { OnClickOutside } from '@vueuse/components';
import InlineInput from 'dashboard/components-next/inline-input/InlineInput.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import { email } from '@vuelidate/validators';
import { useVuelidate } from '@vuelidate/core';
const props = defineProps({
modelValue: {
placeholder: { type: String, default: '' },
disabled: { type: Boolean, default: false },
type: { type: String, default: 'text' },
isLoading: { type: Boolean, default: false },
menuItems: {
type: Array,
default: () => [],
validator: value =>
value.every(
({ action, value: tagValue, label }) => action && tagValue && label
),
},
placeholder: {
showDropdown: { type: Boolean, default: false },
mode: {
type: String,
default: '',
default: 'multiple',
validator: value => ['single', 'multiple'].includes(value),
},
focusOnMount: { type: Boolean, default: false },
allowCreate: { type: Boolean, default: false },
});
const emit = defineEmits(['update:modelValue']);
const emit = defineEmits([
'update:modelValue',
'input',
'blur',
'focus',
'onClickOutside',
'add',
'remove',
]);
const modelValue = defineModel({
type: Array,
default: () => [],
});
const MODE = {
SINGLE: 'single',
MULTIPLE: 'multiple',
};
const tagInputRef = ref(null);
const tags = ref(props.modelValue);
const newTag = ref('');
const isFocused = ref(false);
const isFocused = ref(true);
const showInput = computed(() => isFocused.value || tags.value.length === 0);
const rules = computed(() => ({
newTag: props.type === 'email' ? { email } : {},
}));
const addTag = () => {
if (newTag.value.trim()) {
tags.value.push(newTag.value.trim());
newTag.value = '';
emit('update:modelValue', tags.value);
const v$ = useVuelidate(rules, { newTag });
const isNewTagInValidType = computed(() => v$.value.$invalid);
const showInput = computed(() =>
props.mode === MODE.SINGLE
? isFocused.value && !tags.value.length
: isFocused.value || !tags.value.length
);
const showDropdownMenu = computed(() =>
props.mode === MODE.SINGLE && tags.value.length >= 1
? false
: props.showDropdown
);
const filteredMenuItems = computed(() => {
if (props.mode === MODE.SINGLE && tags.value.length >= 1) return [];
const availableMenuItems = props.menuItems.filter(
item => !tags.value.includes(item.label)
);
// Only show typed value as suggestion if:
// 1. There's a value being typed
// 2. The value isn't already in the tags
// 3. There are no menu items available
const trimmedNewTag = newTag.value.trim();
if (
trimmedNewTag &&
!tags.value.includes(trimmedNewTag) &&
!availableMenuItems.length &&
!props.isLoading
) {
return [
{
label: trimmedNewTag,
value: trimmedNewTag,
email: trimmedNewTag,
thumbnail: { name: trimmedNewTag, src: '' },
action: 'create',
},
];
}
return availableMenuItems;
});
const emitDataOnAdd = emailValue => {
const matchingMenuItem = props.menuItems.find(
item => item.email === emailValue
);
return matchingMenuItem
? emit('add', { email: emailValue, ...matchingMenuItem })
: emit('add', { value: emailValue, action: 'create' });
};
const addTag = async () => {
const trimmedTag = newTag.value.trim();
if (!trimmedTag) return;
if (props.mode === MODE.SINGLE && tags.value.length >= 1) {
newTag.value = '';
return;
}
if (props.type === 'email' || props.allowCreate) {
if (!(await v$.value.$validate())) return;
emitDataOnAdd(trimmedTag);
}
tags.value.push(trimmedTag);
newTag.value = '';
modelValue.value = tags.value;
tagInputRef.value?.focus();
};
const removeTag = index => {
tags.value.splice(index, 1);
emit('update:modelValue', tags.value);
modelValue.value = tags.value;
emit('remove');
};
const handleDropdownAction = async ({ email: emailAddress, ...rest }) => {
if (props.mode === MODE.SINGLE && tags.value.length >= 1) return;
if (props.type === 'email' && props.showDropdown) {
newTag.value = emailAddress;
if (!(await v$.value.$validate())) return;
emit('add', { email: emailAddress, ...rest });
}
tags.value.push(emailAddress);
newTag.value = '';
modelValue.value = tags.value;
tagInputRef.value?.focus();
};
const handleFocus = () => {
emit('focus');
tagInputRef.value?.focus();
isFocused.value = true;
};
const handleClickOutside = () => {
if (tags.value.length > 0) {
isFocused.value = false;
}
};
const handleKeydown = event => {
if (event.key === ',') {
event.preventDefault();
@@ -53,43 +168,76 @@ const handleKeydown = event => {
}
};
const handleClickOutside = () => {
if (tags.value.length) isFocused.value = false;
emit('onClickOutside');
};
watch(
() => props.modelValue,
newValue => {
tags.value = newValue;
}
);
watch(
() => newTag.value,
async newValue => {
if (props.type === 'email' && newValue.trim()?.length > 2) {
await v$.value.$validate();
}
}
);
const handleInput = e => emit('input', e);
const handleBlur = e => emit('blur', e);
</script>
<template>
<OnClickOutside @trigger="handleClickOutside">
<div
v-on-clickaway="() => handleClickOutside()"
class="flex flex-wrap w-full gap-2 border border-transparent focus:outline-none"
tabindex="0"
@focus="handleFocus"
@click="handleFocus"
>
<div
class="flex flex-wrap w-full gap-2 border border-transparent focus:outline-none"
tabindex="0"
@focus="handleFocus"
@click="handleFocus"
v-for="(tag, index) in tags"
:key="index"
class="flex items-center justify-center max-w-full gap-1 px-3 py-1 rounded-lg h-7 bg-n-alpha-2"
>
<div
v-for="(tag, index) in tags"
:key="index"
class="flex items-center justify-center max-w-full gap-1 px-3 py-1 rounded-lg h-7 bg-n-alpha-2"
>
<span class="flex-grow min-w-0 text-sm truncate text-n-slate-12">
{{ tag }}
</span>
<span
class="flex-shrink-0 cursor-pointer i-lucide-x size-3.5 text-n-slate-11"
@click.stop="removeTag(index)"
/>
</div>
<InlineInput
v-if="showInput"
v-model="newTag"
:placeholder="placeholder"
custom-input-class="flex-grow"
@enter-press="addTag"
@keydown="handleKeydown"
<span class="flex-grow min-w-0 text-sm truncate text-n-slate-12">{{
tag
}}</span>
<span
class="flex-shrink-0 cursor-pointer i-lucide-x size-3.5 text-n-slate-11"
@click.stop="removeTag(index)"
/>
</div>
</OnClickOutside>
<div class="relative flex items-center gap-2 flex-1 min-w-[200px] w-full">
<InlineInput
v-if="showInput"
ref="tagInputRef"
v-model="newTag"
:placeholder="placeholder"
:type="type"
:disabled="disabled"
class="w-full"
:focus-on-mount="focusOnMount"
:custom-input-class="`w-full ${isNewTagInValidType ? '!text-n-ruby-9 dark:!text-n-ruby-9' : ''}`"
@enter-press="addTag"
@focus="handleFocus"
@input="handleInput"
@blur="handleBlur"
@keydown="handleKeydown"
/>
<DropdownMenu
v-if="showDropdownMenu"
:menu-items="filteredMenuItems"
:is-searching="isLoading"
class="left-0 z-[100] top-8 overflow-y-auto max-h-60 w-[inherit] max-w-md dark:!outline-n-slate-5"
@action="handleDropdownAction"
/>
</div>
</div>
</template>
@@ -179,7 +179,7 @@ onMounted(() => {
}"
:disabled="disabled"
rows="1"
class="flex w-full reset-base text-sm p-0 !rounded-none !bg-transparent dark:!bg-transparent !border-0 !mb-0 placeholder:text-n-slate-11 dark:placeholder:text-n-slate-11 text-n-slate-12 dark:text-n-slate-12 disabled:cursor-not-allowed disabled:opacity-50 disabled:bg-slate-25 dark:disabled:bg-slate-900"
class="flex w-full reset-base text-sm p-0 !rounded-none !bg-transparent dark:!bg-transparent !border-0 !mb-0 placeholder:text-n-slate-10 dark:placeholder:text-n-slate-10 text-n-slate-12 dark:text-n-slate-12 disabled:cursor-not-allowed disabled:opacity-50 disabled:bg-slate-25 dark:disabled:bg-slate-900"
@input="handleInput"
@focus="handleFocus"
@blur="handleBlur"
@@ -91,10 +91,11 @@ describe('useAccount', () => {
it('returns an account-scoped route', () => {
const wrapper = mount(createComponent(), mountParams);
const { accountScopedRoute } = wrapper.vm;
const result = accountScopedRoute('accountDetail', { userId: 456 });
const result = accountScopedRoute('accountDetail', { userId: 456 }, {});
expect(result).toEqual({
name: 'accountDetail',
params: { accountId: 123, userId: 456 },
query: {},
});
});
@@ -0,0 +1,146 @@
import { useFileUpload } from '../useFileUpload';
import { useMapGetter } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import { DirectUpload } from 'activestorage';
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
import { MAXIMUM_FILE_UPLOAD_SIZE_TWILIO_SMS_CHANNEL } from 'shared/constants/messages';
vi.mock('dashboard/composables/store');
vi.mock('dashboard/composables', () => ({
useAlert: vi.fn(message => message),
}));
vi.mock('vue-i18n');
vi.mock('activestorage');
vi.mock('shared/helpers/FileHelper');
describe('useFileUpload', () => {
const mockAttachFile = vi.fn();
const mockTranslate = vi.fn();
const mockFile = {
file: new File(['test'], 'test.jpg', { type: 'image/jpeg' }),
};
beforeEach(() => {
vi.clearAllMocks();
useMapGetter.mockImplementation(getter => {
const getterMap = {
getCurrentAccountId: { value: '123' },
getCurrentUser: { value: { access_token: 'test-token' } },
getSelectedChat: { value: { id: '456' } },
'globalConfig/get': { value: { directUploadsEnabled: true } },
};
return getterMap[getter];
});
useI18n.mockReturnValue({ t: mockTranslate });
checkFileSizeLimit.mockReturnValue(true);
});
it('should handle direct file upload when enabled', () => {
const { onFileUpload } = useFileUpload({
isATwilioSMSChannel: false,
attachFile: mockAttachFile,
});
const mockBlob = { signed_id: 'test-blob' };
DirectUpload.mockImplementation(() => ({
create: callback => callback(null, mockBlob),
}));
onFileUpload(mockFile);
expect(DirectUpload).toHaveBeenCalledWith(
mockFile.file,
'/api/v1/accounts/123/conversations/456/direct_uploads',
expect.any(Object)
);
expect(mockAttachFile).toHaveBeenCalledWith({
file: mockFile,
blob: mockBlob,
});
});
it('should handle indirect file upload when direct upload is disabled', () => {
useMapGetter.mockImplementation(getter => {
const getterMap = {
getCurrentAccountId: { value: '123' },
getCurrentUser: { value: { access_token: 'test-token' } },
getSelectedChat: { value: { id: '456' } },
'globalConfig/get': { value: { directUploadsEnabled: false } },
};
return getterMap[getter];
});
const { onFileUpload } = useFileUpload({
isATwilioSMSChannel: false,
attachFile: mockAttachFile,
});
onFileUpload(mockFile);
expect(DirectUpload).not.toHaveBeenCalled();
expect(mockAttachFile).toHaveBeenCalledWith({ file: mockFile });
});
it('should show alert when file size exceeds limit', () => {
checkFileSizeLimit.mockReturnValue(false);
mockTranslate.mockReturnValue('File size exceeds limit');
const { onFileUpload } = useFileUpload({
isATwilioSMSChannel: false,
attachFile: mockAttachFile,
});
onFileUpload(mockFile);
expect(useAlert).toHaveBeenCalledWith('File size exceeds limit');
expect(mockAttachFile).not.toHaveBeenCalled();
});
it('should use different max file size for Twilio SMS channel', () => {
const { onFileUpload } = useFileUpload({
isATwilioSMSChannel: true,
attachFile: mockAttachFile,
});
onFileUpload(mockFile);
expect(checkFileSizeLimit).toHaveBeenCalledWith(
mockFile,
MAXIMUM_FILE_UPLOAD_SIZE_TWILIO_SMS_CHANNEL
);
});
it('should handle direct upload errors', () => {
const mockError = 'Upload failed';
DirectUpload.mockImplementation(() => ({
create: callback => callback(mockError, null),
}));
const { onFileUpload } = useFileUpload({
isATwilioSMSChannel: false,
attachFile: mockAttachFile,
});
onFileUpload(mockFile);
expect(useAlert).toHaveBeenCalledWith(mockError);
expect(mockAttachFile).not.toHaveBeenCalled();
});
it('should do nothing when file is null', () => {
const { onFileUpload } = useFileUpload({
isATwilioSMSChannel: false,
attachFile: mockAttachFile,
});
onFileUpload(null);
expect(checkFileSizeLimit).not.toHaveBeenCalled();
expect(mockAttachFile).not.toHaveBeenCalled();
expect(useAlert).not.toHaveBeenCalled();
});
});
@@ -28,10 +28,11 @@ export function useAccount() {
return `/app/accounts/${accountId.value}/${url}`;
};
const accountScopedRoute = (name, params) => {
const accountScopedRoute = (name, params, query) => {
return {
name,
params: { accountId: accountId.value, ...params },
query: { ...query },
};
};
@@ -0,0 +1,91 @@
import { computed } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import { DirectUpload } from 'activestorage';
import {
MAXIMUM_FILE_UPLOAD_SIZE,
MAXIMUM_FILE_UPLOAD_SIZE_TWILIO_SMS_CHANNEL,
} from 'shared/constants/messages';
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
/**
* Composable for handling file uploads in conversations
* @param {Object} options - Configuration options
* @param {boolean} options.isATwilioSMSChannel - Whether the current channel is Twilio SMS
* @param {Function} options.attachFile - Callback function to handle file attachment
* @returns {Object} File upload methods and utilities
*/
export const useFileUpload = ({ isATwilioSMSChannel, attachFile }) => {
const { t } = useI18n();
const accountId = useMapGetter('getCurrentAccountId');
const currentUser = useMapGetter('getCurrentUser');
const currentChat = useMapGetter('getSelectedChat');
const globalConfig = useMapGetter('globalConfig/get');
const maxFileSize = computed(() =>
isATwilioSMSChannel
? MAXIMUM_FILE_UPLOAD_SIZE_TWILIO_SMS_CHANNEL
: MAXIMUM_FILE_UPLOAD_SIZE
);
const handleDirectFileUpload = file => {
if (!file) return;
if (checkFileSizeLimit(file, maxFileSize.value)) {
const upload = new DirectUpload(
file.file,
`/api/v1/accounts/${accountId.value}/conversations/${currentChat.value.id}/direct_uploads`,
{
directUploadWillCreateBlobWithXHR: xhr => {
xhr.setRequestHeader(
'api_access_token',
currentUser.value.access_token
);
},
}
);
upload.create((error, blob) => {
if (error) {
useAlert(error);
} else {
attachFile({ file, blob });
}
});
} else {
useAlert(
t('CONVERSATION.FILE_SIZE_LIMIT', {
MAXIMUM_SUPPORTED_FILE_UPLOAD_SIZE: maxFileSize.value,
})
);
}
};
const handleIndirectFileUpload = file => {
if (!file) return;
if (checkFileSizeLimit(file, maxFileSize.value)) {
attachFile({ file });
} else {
useAlert(
t('CONVERSATION.FILE_SIZE_LIMIT', {
MAXIMUM_SUPPORTED_FILE_UPLOAD_SIZE: maxFileSize.value,
})
);
}
};
const onFileUpload = file => {
if (globalConfig.value.directUploadsEnabled) {
handleDirectFileUpload(file);
} else {
handleIndirectFileUpload(file);
}
};
return {
onFileUpload,
};
};
@@ -12,7 +12,8 @@
},
"DROPDOWN_MENU": {
"SEARCH_PLACEHOLDER": "Search...",
"EMPTY_STATE": "No results found."
"EMPTY_STATE": "No results found.",
"SEARCHING": "Searching..."
},
"DIALOG": {
"BUTTONS": {
@@ -388,8 +388,67 @@
},
"CONTACTS_LAYOUT": {
"HEADER": {
"TITLE": "Contacts",
"SEARCH_TITLE": "Search contacts",
"SEARCH_PLACEHOLDER": "Search...",
"MESSAGE_BUTTON": "Message",
"SEND_MESSAGE": "Send message",
"BREADCRUMB": {
"CONTACTS": "Contacts"
},
"ACTIONS": {
"CONTACT_CREATION": {
"ADD_CONTACT": "Add contact",
"EXPORT_CONTACT": "Export contacts",
"IMPORT_CONTACT": "Import contacts",
"SAVE_CONTACT": "Save contact"
},
"IMPORT_CONTACT": {
"TITLE": "Import contacts",
"DESCRIPTION": "Import contacts through a CSV file.",
"DOWNLOAD_LABEL": "Download a sample csv.",
"LABEL": "CSV File:",
"CHOOSE_FILE": "Choose file",
"CHANGE": "Change",
"CANCEL": "Cancel",
"IMPORT": "Import",
"SUCCESS_MESSAGE": "You will be notified via email when the import is complete.",
"ERROR_MESSAGE": "There was an error, please try again"
},
"EXPORT_CONTACT": {
"TITLE": "Export contacts",
"DESCRIPTION": "Quickly export a csv file with comprehensive details of your contacts",
"CONFIRM": "Export",
"SUCCESS_MESSAGE": "Export is in progress. You will be notified on email when the export file is ready to download.",
"ERROR_MESSAGE": "There was an error, please try again"
},
"SORT_BY": {
"LABEL": "Sort by",
"OPTIONS": {
"NAME": "Name",
"EMAIL": "Email",
"PHONE_NUMBER": "Phone number",
"COMPANY": "Company",
"COUNTRY": "Country",
"CITY": "City",
"LAST_ACTIVITY": "Last activity",
"CREATED_AT": "Created at"
}
},
"ORDER": {
"LABEL": "Ordering",
"OPTIONS": {
"ASCENDING": "Ascending",
"DESCENDING": "Descending"
}
}
}
},
"PAGINATION_FOOTER": {
"SHOWING": "Showing {startItem} - {endItem} of {totalItems} contacts"
},
"CARD": {
"OF": "of",
"VIEW_DETAILS": "View details",
"EDIT_DETAILS_FORM": {
"TITLE": "Edit contact details",
@@ -441,7 +500,68 @@
}
}
},
"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"
},
"NOTES": {
"PLACEHOLDER": "Add a note",
"WROTE": "wrote",
"YOU": "You",
"SAVE": "Save note",
"EMPTY_STATE": "There are no notes associated to this contact. You can add a note by typing in the box above."
},
"HISTORY": {
"EMPTY_STATE": "There are no previous conversations associated to this contact"
},
"MERGE": {
"TITLE": "Merge contact",
"DESCRIPTION": "Combine two profiles into one, including all attributes and conversations. In case of conflict, the primary contacts attributes will take precedence.",
"PRIMARY": "Primary contact",
"PRIMARY_HELP_LABEL": "To be saved",
"PRIMARY_REQUIRED_ERROR": "Please select a contact to merge with before proceeding",
"PARENT": "To be merged",
"PARENT_HELP_LABEL": "To be deleted",
"EMPTY_STATE": "No contacts found",
"PLACEHOLDER": "Search for primary contact",
"SEARCH_PLACEHOLDER": "Search for a contact",
"SEARCH_ERROR_MESSAGE": "Could not search for contacts. Please try again later.",
"SUCCESS_MESSAGE": "Contact merged successfully",
"ERROR_MESSAGE": "Could not merge contacts, try again!",
"IS_SEARCHING": "Searching...",
"BUTTONS": {
"CANCEL": "Cancel",
"CONFIRM": "Merge contact"
}
},
"ATTRIBUTES": {
"SEARCH_PLACEHOLDER": "Search for attributes",
"UNUSED_ATTRIBUTES": "{count} Used attribute | {count} Unused attributes",
@@ -466,33 +586,61 @@
"UPDATE_ERROR": "Unable to update attribute. Please try again later",
"DELETE_ERROR": "Unable to delete attribute. Please try again later"
}
}
},
"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 📋"
}
},
"COMPOSE_NEW_CONVERSATION": {
"CONTACT_SEARCH": {
"ERROR_MESSAGE": "Could not search for contacts. Please try again later."
},
"FORM": {
"GO_TO_CONVERSATION": "View",
"SUCCESS_MESSAGE": "Conversation created successfully",
"ERROR_MESSAGE": "Could not create conversation. Please try again later.",
"NO_INBOX_ALERT": "Couldn't find an inbox to initiate a new conversation with this contact.",
"CONTACT_SELECTOR": {
"LABEL": "To :",
"TAG_INPUT_PLACEHOLDER": "Search or enter email and press Enter",
"CONTACT_CREATING": "Creating contact..."
},
"MERGE": {
"TITLE": "Merge contact",
"DESCRIPTION": "Combine two profiles into one, including all attributes and conversations. In case of conflict, the primary contacts attributes will take precedence.",
"PRIMARY": "Primary contact",
"PRIMARY_HELP_LABEL": "To be saved",
"PRIMARY_REQUIRED_ERROR": "Please select a contact to merge with before proceeding",
"PARENT": "To be merged",
"PARENT_HELP_LABEL": "To be deleted",
"EMPTY_STATE": "No contacts found",
"PLACEHOLDER": "Search for primary contact",
"SEARCH_PLACEHOLDER": "Search for a contact",
"SEARCH_ERROR_MESSAGE": "Could not search for contacts. Please try again later.",
"SUCCESS_MESSAGE": "Contact merged successfully",
"ERROR_MESSAGE": "Could not merge contacts, try again!",
"IS_SEARCHING": "Searching...",
"BUTTONS": {
"CANCEL": "Cancel",
"CONFIRM": "Merge contact"
"INBOX_SELECTOR": {
"LABEL": "Via inbox :",
"BUTTON": "Show inboxes"
},
"EMAIL_OPTIONS": {
"SUBJECT_LABEL": "Subject :",
"SUBJECT_PLACEHOLDER": "Enter your email subject here",
"CC_LABEL": "Cc :",
"CC_PLACEHOLDER": "Search or enter email and press Enter",
"BCC_LABEL": "Bcc :",
"BCC_PLACEHOLDER": "Search or enter email and press Enter",
"BCC_BUTTON": "Bcc"
},
"MESSAGE_EDITOR": {
"PLACEHOLDER": "Write your message here..."
},
"WHATSAPP_OPTIONS": {
"LABEL": "Select template",
"SEARCH_PLACEHOLDER": "Search templates",
"EMPTY_STATE": "No templates found",
"TEMPLATE_PARSER": {
"TEMPLATE_NAME": "WhatsApp template: {templateName}",
"VARIABLES": "Variables",
"BACK": "Go back",
"SEND_MESSAGE": "Send message"
}
},
"NOTES": {
"PLACEHOLDER": "Add a note",
"WROTE": "wrote",
"YOU": "You",
"SAVE": "Save note",
"EMPTY_STATE": "There are no notes associated to this contact. You can add a note by typing in the box above."
"ACTION_BUTTONS": {
"DISCARD": "Discard",
"SEND": "Send ({keyCode})"
}
}
}
@@ -0,0 +1,63 @@
import { frontendURL } from 'dashboard/helper/URLHelper.js';
import ContactsPageRouteView from './pages/ContactsPageRouteView.vue';
import EditContactsPageRouteView from './pages/EditContactsPageRouteView.vue';
const contactsRoutes = {
routes: [
{
path: frontendURL('accounts/:accountId/contacts-new'),
component: ContactsPageRouteView,
name: 'contacts_dashboard_index',
meta: {
permissions: ['administrator', 'agent', 'contact_manage'],
},
},
{
path: frontendURL('accounts/:accountId/contacts-new/:contactId'),
component: EditContactsPageRouteView,
name: 'contacts_dashboard_edit_index',
meta: {
permissions: ['administrator', 'agent', 'contact_manage'],
},
},
{
path: frontendURL('accounts/:accountId/contacts-new/segments/:segmentId'),
component: ContactsPageRouteView,
name: 'contacts_dashboard_segments_index',
meta: {
permissions: ['administrator', 'agent', 'contact_manage'],
},
},
{
path: frontendURL(
'accounts/:accountId/contacts-new/:contactId/segments/:segmentId'
),
component: EditContactsPageRouteView,
name: 'contacts_dashboard_segments_edit_index',
meta: {
permissions: ['administrator', 'agent', 'contact_manage'],
},
},
{
path: frontendURL('accounts/:accountId/contacts-new/labels/:label'),
component: ContactsPageRouteView,
name: 'contacts_dashboard_labels_index',
meta: {
permissions: ['administrator', 'agent', 'contact_manage'],
},
},
{
path: frontendURL(
'accounts/:accountId/contacts-new/:contactId/labels/:label'
),
component: EditContactsPageRouteView,
name: 'contacts_dashboard_labels_edit_index',
meta: {
permissions: ['administrator', 'agent', 'contact_manage'],
},
},
],
};
export default contactsRoutes;
@@ -0,0 +1,276 @@
<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 ContactsLayout from 'dashboard/components-next/Contacts/ContactsLayout.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 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(() =>
activeSegmentId.value
? segments.value.find(view => view.id === Number(activeSegmentId.value))
: undefined
);
const hasContacts = computed(() => contacts.value.length > 0);
const isContactIndexView = computed(
() => route.name === 'contacts_dashboard_index' && pageNumber.value === 1
);
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 = searchValue.value) => {
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 fetchSavedFilteredContact = async (payload, page = 1) => {
if (!activeSegmentId.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 handleSort = async ({ sort, order }) => {
Object.assign(sortState, { activeSort: sort, activeOrdering: order });
await updateUISettings({
contacts_sort_by: buildSortAttr(),
});
if (searchQuery.value) {
await searchContacts(searchValue.value, pageNumber.value);
return;
}
await (activeSegmentId.value
? fetchSavedFilteredContact(activeSegment.value?.query, pageNumber.value)
: fetchContacts(pageNumber.value));
};
watch(
() => uiSettings.value?.contacts_sort_by,
newSortBy => {
if (newSortBy) {
const { sort, order } = parseSortSettings(newSortBy);
sortState.activeSort = sort;
sortState.activeOrdering = order;
}
},
{ immediate: true }
);
watch(activeLabel, () => {
if (searchQuery.value) return;
searchValue.value = '';
if (!activeSegmentId.value) {
fetchContacts();
}
});
watch(activeSegment, () => {
if (searchQuery.value) return;
searchValue.value = '';
if (activeSegment.value && activeSegmentId.value) {
fetchSavedFilteredContact(activeSegment.value.query, pageNumber.value);
} else if (!activeLabel.value) {
fetchContacts(pageNumber.value);
}
});
watch(pageNumber, async page => {
if (isFetchingList.value) return;
if (searchQuery.value) {
await searchContacts(searchQuery.value, page);
return;
}
if (activeSegmentId.value) {
await fetchSavedFilteredContact(activeSegment.value.query, page);
} else {
await fetchContacts(page);
}
});
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 fetchSavedFilteredContact(
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"
>
<ContactsLayout
:search-value="searchValue"
:header-title="headerTitle"
:button-label="$t('CONTACTS_LAYOUT.HEADER.MESSAGE_BUTTON')"
:current-page="currentPage"
:total-items="totalItems"
:show-pagination-footer="!isFetchingList && hasContacts"
:active-sort="sortState.activeSort"
:active-ordering="sortState.activeOrdering"
:is-empty-state="!searchQuery && !hasContacts && isContactIndexView"
@update:current-page="page => updatePageParam(page, searchValue)"
@search="searchContacts"
@sort="handleSort"
>
<div
v-if="isFetchingList"
class="flex items-center justify-center py-10 text-n-slate-11"
>
<Spinner />
</div>
<template v-else>
<ContactEmptyState
v-if="!searchQuery && !hasContacts && isContactIndexView"
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')"
/>
<div
v-else-if="(searchQuery || !isContactIndexView) && !hasContacts"
class="flex items-center justify-center py-10"
>
<span class="text-base text-n-slate-11">
{{
searchQuery
? 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>
</ContactsLayout>
</div>
</template>
@@ -0,0 +1,168 @@
<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';
import ContactsLayout from 'dashboard/components-next/Contacts/ContactsLayout.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import ContactDetails from 'dashboard/components-next/Contacts/Pages/ContactDetails.vue';
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 ROUTE_CONFIG = {
segments: ['segmentId', 'contacts_dashboard_segments_index'],
labels: ['label', 'contacts_dashboard_labels_index'],
default: [null, 'contacts_dashboard_index'],
};
const goToContactsList = () => {
const routeType =
Object.keys(ROUTE_CONFIG).find(
type => route.params[ROUTE_CONFIG[type][0]]
) || 'default';
const [paramKey, routeName] = ROUTE_CONFIG[routeType];
router.push({
name: routeName,
...(paramKey && {
params: { [paramKey]: route.params[paramKey] },
}),
query: route.query,
});
};
const fetchActiveContact = async () => {
if (route.params.contactId) {
await store.dispatch('contacts/show', { id: route.params.contactId });
await store.dispatch(
'contacts/fetchContactableInbox',
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 flex-col justify-between flex-1 h-full m-0 overflow-auto bg-n-background"
>
<ContactsLayout
:button-label="t('CONTACTS_LAYOUT.HEADER.SEND_MESSAGE')"
:selected-contact="selectedContact"
is-detail-view
:show-pagination-footer="false"
@go-to-contacts-list="goToContactsList"
>
<div
v-if="showSpinner"
class="flex items-center justify-center py-10 text-n-slate-11"
>
<Spinner />
</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>
</ContactsLayout>
</div>
</template>
@@ -138,7 +138,7 @@ export default {
},
selectedInbox: {
get() {
const inboxList = this.contact.contactableInboxes || [];
const inboxList = this.contact.contact_inboxes || [];
return (
inboxList.find(inbox => {
return inbox.inbox?.id && inbox.inbox?.id === this.targetInbox?.id;
@@ -152,7 +152,7 @@ export default {
},
},
showNoInboxAlert() {
if (!this.contact.contactableInboxes) {
if (!this.contact.contact_inboxes) {
return false;
}
return this.inboxes.length === 0 && !this.uiFlags.isFetchingInboxes;
@@ -166,7 +166,7 @@ export default {
: this.$t('CONVERSATION.FOOTER.ENABLE_SIGN_TOOLTIP');
},
inboxes() {
const inboxList = this.contact.contactableInboxes || [];
const inboxList = this.contact.contact_inboxes || [];
return inboxList.map(inbox => ({
...inbox.inbox,
sourceId: inbox.source_id,
@@ -7,6 +7,7 @@ import { routes as inboxRoutes } from './inbox/routes';
import { frontendURL } from '../../helper/URLHelper';
import helpcenterRoutes from './helpcenter/helpcenter.routes';
import campaignsRoutes from './campaigns/campaigns.routes';
import contactsRoutes from './contact/contact.routes';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
@@ -38,6 +39,7 @@ export default {
...notificationRoutes,
...helpcenterRoutes.routes,
...campaignsRoutes.routes,
...contactsRoutes.routes,
],
},
{
@@ -1,6 +1,7 @@
import * as MutationHelpers from 'shared/helpers/vuex/mutationHelpers';
import types from '../mutation-types';
import AttributeAPI from '../../api/attributes';
import camelcaseKeys from 'camelcase-keys';
export const state = {
records: [],
@@ -24,6 +25,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 = {
@@ -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 = {
@@ -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 = {
@@ -4,6 +4,7 @@ import {
} from 'shared/helpers/CustomErrors';
import types from '../../mutation-types';
import ContactAPI from '../../../api/contacts';
import decamelizeKeys from 'decamelize-keys';
import AccountActionsAPI from '../../../api/accountActions';
import AnalyticsHelper from '../../../helper/AnalyticsHelper';
import { CONTACTS_EVENTS } from '../../../helper/AnalyticsHelper/events';
@@ -90,11 +91,16 @@ export const actions = {
},
update: async ({ commit }, { id, isFormData = false, ...contactParams }) => {
const decamelizedContactParams = decamelizeKeys(contactParams, {
deep: true,
});
commit(types.SET_CONTACT_UI_FLAG, { isUpdating: true });
try {
const response = await ContactAPI.update(
id,
isFormData ? buildContactFormData(contactParams) : contactParams
isFormData
? buildContactFormData(decamelizedContactParams)
: decamelizedContactParams
);
commit(types.EDIT_CONTACT, response.data.payload);
commit(types.SET_CONTACT_UI_FLAG, { isUpdating: false });
@@ -126,12 +132,12 @@ export const actions = {
},
import: async ({ commit }, file) => {
commit(types.SET_CONTACT_UI_FLAG, { isCreating: true });
commit(types.SET_CONTACT_UI_FLAG, { isImporting: true });
try {
await ContactAPI.importContacts(file);
commit(types.SET_CONTACT_UI_FLAG, { isCreating: false });
commit(types.SET_CONTACT_UI_FLAG, { isImporting: false });
} catch (error) {
commit(types.SET_CONTACT_UI_FLAG, { isCreating: false });
commit(types.SET_CONTACT_UI_FLAG, { isImporting: false });
if (error.response?.data?.message) {
throw new ExceptionWithMessage(error.response.data.message);
}
@@ -139,12 +145,13 @@ export const actions = {
},
export: async ({ commit }, { payload, label }) => {
commit(types.SET_CONTACT_UI_FLAG, { isExporting: true });
try {
await ContactAPI.exportContacts({ payload, label });
commit(types.SET_CONTACT_UI_FLAG, { isCreating: false });
commit(types.SET_CONTACT_UI_FLAG, { isExporting: false });
} catch (error) {
commit(types.SET_CONTACT_UI_FLAG, { isCreating: false });
commit(types.SET_CONTACT_UI_FLAG, { isExporting: false });
if (error.response?.data?.message) {
throw new Error(error.response.data.message);
} else {
@@ -194,8 +201,8 @@ export const actions = {
try {
const response = await ContactAPI.getContactableInboxes(id);
const contact = {
id,
contactableInboxes: response.data.payload,
id: Number(id),
contact_inboxes: response.data.payload,
};
commit(types.SET_CONTACT_ITEM, contact);
} catch (error) {
@@ -1,7 +1,15 @@
import camelcaseKeys from 'camelcase-keys';
export const getters = {
getContacts($state) {
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) {
return $state.uiFlags;
},
@@ -9,6 +17,10 @@ export const getters = {
const contact = $state.records[id];
return contact || {};
},
getContactById: $state => id => {
const contact = $state.records[id];
return camelcaseKeys(contact || {}, { deep: true });
},
getMeta: $state => {
return $state.meta;
},
@@ -15,6 +15,8 @@ const state = {
isUpdating: false,
isMerging: false,
isDeleting: false,
isExporting: false,
isImporting: false,
},
sortOrder: [],
appliedFilters: [],
@@ -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;
},
+8
View File
@@ -3,6 +3,8 @@ import { defineSetupVue3 } from '@histoire/plugin-vue';
import i18nMessages from 'dashboard/i18n';
import { createI18n } from 'vue-i18n';
import { vResizeObserver } from '@vueuse/components';
import { directive as onClickaway } from 'vue3-click-away';
import FloatingVue from 'floating-vue';
import store from 'dashboard/store';
import VueDOMPurifyHTML from 'vue-dompurify-html';
import { domPurifyConfig } from 'shared/helpers/HTMLSanitizer.js';
@@ -17,5 +19,11 @@ export const setupVue3 = defineSetupVue3(({ app }) => {
app.use(store);
app.use(i18n);
app.directive('resize', vResizeObserver);
app.directive('onClickaway', onClickaway);
app.use(FloatingVue, {
instantMove: true,
arrowOverflow: false,
disposeTimeout: 5000000,
});
app.use(VueDOMPurifyHTML, domPurifyConfig);
});
+2 -1
View File
@@ -34,7 +34,7 @@
"@chatwoot/captain": "0.0.3-alpha.4",
"@chatwoot/ninja-keys": "1.2.3",
"@chatwoot/prosemirror-schema": "1.1.1-next",
"@chatwoot/utils": "^0.0.25",
"@chatwoot/utils": "^0.0.28",
"@formkit/core": "^1.6.7",
"@formkit/vue": "^1.6.7",
"@hcaptcha/vue3-hcaptcha": "^1.3.0",
@@ -65,6 +65,7 @@
"countries-and-timezones": "^3.6.0",
"date-fns": "2.21.1",
"date-fns-tz": "^1.3.3",
"decamelize-keys": "^2.0.1",
"dompurify": "3.1.6",
"floating-vue": "^5.2.2",
"highlight.js": "^11.10.0",
+43 -9
View File
@@ -26,8 +26,8 @@ importers:
specifier: 1.1.1-next
version: 1.1.1-next
'@chatwoot/utils':
specifier: ^0.0.25
version: 0.0.25
specifier: ^0.0.28
version: 0.0.28
'@formkit/core':
specifier: ^1.6.7
version: 1.6.7
@@ -118,6 +118,9 @@ importers:
date-fns-tz:
specifier: ^1.3.3
version: 1.3.8(date-fns@2.21.1)
decamelize-keys:
specifier: ^2.0.1
version: 2.0.1
dompurify:
specifier: 3.1.6
version: 3.1.6
@@ -398,8 +401,8 @@ packages:
'@chatwoot/prosemirror-schema@1.1.1-next':
resolution: {integrity: sha512-/M2qZ+ZF7GlQNt1riwVP499fvp3hxSqd5iy8hxyF9pkj9qQ+OKYn5JK+v3qwwqQY3IxhmNOn1Lp6tm7vstrd9Q==}
'@chatwoot/utils@0.0.25':
resolution: {integrity: sha512-2bGfRewHu0Bra47vHwJa3SdZ0VqXK/2q2ampcc6TT3z8ojSfZqpfMdb+1RKJ/Q1tQEYn/0rJTdWtLDRjePF92A==}
'@chatwoot/utils@0.0.28':
resolution: {integrity: sha512-LLBAqTofVLR9nR2hDihwvJNgPMZmOkvule7QBeDVISyubNR7lSc9Ow4GHJxsftXVSme0ZVvvuWntbBi2C/vRqw==}
engines: {node: '>=10'}
'@codemirror/commands@6.7.0':
@@ -2372,8 +2375,8 @@ packages:
resolution: {integrity: sha512-m1WR0xGiC6j6jNFAyW4Nvh4WxAi4JF4w9jRJwSI8nBmNcyZXPcP9VUQG+6gHQXAmqaGEKDKhOqAtENDC941UkA==}
engines: {node: '>=0.11'}
date-fns@2.29.3:
resolution: {integrity: sha512-dDCnyH2WnnKusqvZZ6+jA1O51Ibt8ZMRNkDZdyAyK4YfbDwa/cEmuztzG5pk6hqlp9aSBPYcjOlktquahGwGeA==}
date-fns@2.30.0:
resolution: {integrity: sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==}
engines: {node: '>=0.11'}
date-fns@3.6.0:
@@ -2434,6 +2437,14 @@ packages:
supports-color:
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:
resolution: {integrity: sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==}
@@ -3485,6 +3496,10 @@ packages:
resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==}
engines: {node: '>=10'}
map-obj@4.3.0:
resolution: {integrity: sha512-hdN1wVrZbb29eBGiGjJbeP8JbKjq1urkHJ/LIP/NY48MZ1QVXUsQBV1G1zvYFHn1XE06cwjBsOI2K3Ulnj1YXQ==}
engines: {node: '>=8'}
map-obj@5.0.0:
resolution: {integrity: sha512-2L3MIgJynYrZ3TYMriLDLWocz15okFakV6J12HXvMXDHui2x/zgChzg1u9mFFGbbGWE+GsLpQByt4POb9Or+uA==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
@@ -4665,6 +4680,10 @@ packages:
resolution: {integrity: sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==}
engines: {node: '>=10'}
type-fest@3.13.1:
resolution: {integrity: sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g==}
engines: {node: '>=14.16'}
type-fest@4.26.1:
resolution: {integrity: sha512-yOGpmOAL7CkKe/91I5O3gPICmJNLJ1G4zFYVAsRHg7M64biSnPtRj0WNQt++bRkjYOqjWXrhnUw1utzmVErAdg==}
engines: {node: '>=16'}
@@ -5224,9 +5243,9 @@ snapshots:
prosemirror-utils: 1.2.2(prosemirror-model@1.22.3)(prosemirror-state@1.4.3)
prosemirror-view: 1.34.1
'@chatwoot/utils@0.0.25':
'@chatwoot/utils@0.0.28':
dependencies:
date-fns: 2.29.3
date-fns: 2.30.0
'@codemirror/commands@6.7.0':
dependencies:
@@ -7472,7 +7491,9 @@ snapshots:
date-fns@2.21.1: {}
date-fns@2.29.3: {}
date-fns@2.30.0:
dependencies:
'@babel/runtime': 7.25.6
date-fns@3.6.0: {}
@@ -7502,6 +7523,15 @@ snapshots:
dependencies:
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: {}
deep-eql@5.0.2: {}
@@ -8814,6 +8844,8 @@ snapshots:
dependencies:
semver: 7.6.3
map-obj@4.3.0: {}
map-obj@5.0.0: {}
markdown-it-anchor@8.6.7(@types/markdown-it@12.2.3)(markdown-it@12.3.2):
@@ -10091,6 +10123,8 @@ snapshots:
type-fest@1.4.0: {}
type-fest@3.13.1: {}
type-fest@4.26.1: {}
typed-array-buffer@1.0.0: