Compare commits
127
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1652b477c8 | ||
|
|
39a6532874 | ||
|
|
b0504c5c04 | ||
|
|
8efb760f97 | ||
|
|
ba6d291e3b | ||
|
|
7eac4f241b | ||
|
|
137f5616bd | ||
|
|
f375d962ab | ||
|
|
7c94da3bc3 | ||
|
|
6b5b53a722 | ||
|
|
4156881dec | ||
|
|
93d1e2a032 | ||
|
|
ca05be1c1a | ||
|
|
7c09c42bde | ||
|
|
ceaeba84ee | ||
|
|
67fba33005 | ||
|
|
92190ec81b | ||
|
|
2d0c813300 | ||
|
|
7083137c5a | ||
|
|
b9b3c43d63 | ||
|
|
2a724cad7c | ||
|
|
ef51083b3c | ||
|
|
edfb1029a7 | ||
|
|
75cf5322c9 | ||
|
|
634a31a7ab | ||
|
|
ee55e8e8ff | ||
|
|
1a4f44555f | ||
|
|
b919afa182 | ||
|
|
189aa06fa3 | ||
|
|
257a5ccf83 | ||
|
|
e3f50da277 | ||
|
|
edcc15fab2 | ||
|
|
525a979283 | ||
|
|
7021a85da5 | ||
|
|
93cd88253d | ||
|
|
2852f01026 | ||
|
|
2dcb6ec997 | ||
|
|
2538e161a2 | ||
|
|
6f4b37a6cd | ||
|
|
b9cb4cc65e | ||
|
|
e46d704573 | ||
|
|
8f7589f173 | ||
|
|
f5a72c58db | ||
|
|
62f357fc7f | ||
|
|
4fda057afb | ||
|
|
fccfa5adba | ||
|
|
60fdab4bbe | ||
|
|
93eb295152 | ||
|
|
1982cb159c | ||
|
|
ca46ed9bca | ||
|
|
e591f14729 | ||
|
|
7d0bfabb49 | ||
|
|
aef2ffc3a9 | ||
|
|
c3cf73de85 | ||
|
|
610b05ed5a | ||
|
|
2e30d4b825 | ||
|
|
99c762404e | ||
|
|
b6ad0211e3 | ||
|
|
15470506bf | ||
|
|
f15c7a8df1 | ||
|
|
dceb616cc4 | ||
|
|
14e7950186 | ||
|
|
e3602acf68 | ||
|
|
b97ee9675e | ||
|
|
1b7317b71c | ||
|
|
7cc0d6d457 | ||
|
|
28481c45cb | ||
|
|
7790b88725 | ||
|
|
9ea992fe45 | ||
|
|
bf59285041 | ||
|
|
90162ad39b | ||
|
|
bd691d2e2b | ||
|
|
94142acb93 | ||
|
|
78e6f995eb | ||
|
|
4cb9abaf8c | ||
|
|
b75afc2b29 | ||
|
|
3f608ece5a | ||
|
|
72496ac4a0 | ||
|
|
909755c340 | ||
|
|
42a9e8dc82 | ||
|
|
f0c5d214e7 | ||
|
|
cc6ac082b1 | ||
|
|
d8bc4ae40f | ||
|
|
7aa22eabf8 | ||
|
|
1dd690e8f9 | ||
|
|
6caf7ded2d | ||
|
|
b39e25f9e2 | ||
|
|
bda9f0208a | ||
|
|
74a90c0173 | ||
|
|
9210d2cecd | ||
|
|
fd4b1f2d21 | ||
|
|
ae804baf9b | ||
|
|
e2c1e6ff00 | ||
|
|
2dbe5bd1c0 | ||
|
|
0fe6d404b9 | ||
|
|
d6cc5422d0 | ||
|
|
271b322761 | ||
|
|
1b013544a8 | ||
|
|
d53c60363b | ||
|
|
3d6d6e2afb | ||
|
|
430c6d2526 | ||
|
|
8320ecd92b | ||
|
|
ecd8b265bc | ||
|
|
c5974f97cd | ||
|
|
314a33e053 | ||
|
|
4b85a9045a | ||
|
|
d41857abc4 | ||
|
|
816c65df2d | ||
|
|
91ee5fc2f0 | ||
|
|
28cbdb6133 | ||
|
|
703b36e38c | ||
|
|
51cf4df3e3 | ||
|
|
caaf8d55c6 | ||
|
|
d5a1c7f49b | ||
|
|
db6f08c12b | ||
|
|
7021e96ad4 | ||
|
|
c254c44994 | ||
|
|
05b76f7182 | ||
|
|
ae1c6e3f98 | ||
|
|
3c58d6af59 | ||
|
|
fa8a7db612 | ||
|
|
759ce1229d | ||
|
|
b92805933f | ||
|
|
b71a05f790 | ||
|
|
dd9e9286b3 | ||
|
|
f341d159c2 | ||
|
|
a7bf762bcd |
@@ -57,5 +57,3 @@ exclude_patterns:
|
||||
- 'app/javascript/shared/constants/locales.js'
|
||||
- 'app/javascript/dashboard/helper/specs/macrosFixtures.js'
|
||||
- 'app/javascript/dashboard/routes/dashboard/settings/macros/constants.js'
|
||||
- '**/fixtures/**'
|
||||
- '**/*/fixtures.js'
|
||||
|
||||
@@ -49,7 +49,7 @@ gem 'aws-sdk-s3', require: false
|
||||
# original gem isn't maintained actively
|
||||
# we wanted updated version of faraday which is a dependency for slack-ruby-client
|
||||
gem 'azure-storage-blob', git: 'https://github.com/chatwoot/azure-storage-ruby', branch: 'chatwoot', require: false
|
||||
gem 'google-cloud-storage', '>= 1.48.0', require: false
|
||||
gem 'google-cloud-storage', require: false
|
||||
gem 'image_processing'
|
||||
|
||||
##-- gems for database --#
|
||||
|
||||
+24
-23
@@ -209,7 +209,7 @@ GEM
|
||||
devise (> 3.5.2, < 5)
|
||||
rails (>= 4.2.0, < 7.2)
|
||||
diff-lcs (1.5.1)
|
||||
digest-crc (0.6.5)
|
||||
digest-crc (0.6.4)
|
||||
rake (>= 12.0.0, < 14.0.0)
|
||||
docile (1.4.0)
|
||||
domain_name (0.5.20190701)
|
||||
@@ -284,39 +284,39 @@ GEM
|
||||
activesupport (>= 6.1)
|
||||
gmail_xoauth (0.4.3)
|
||||
oauth (>= 0.3.6)
|
||||
google-apis-core (0.15.1)
|
||||
google-apis-core (0.11.0)
|
||||
addressable (~> 2.5, >= 2.5.1)
|
||||
googleauth (~> 1.9)
|
||||
httpclient (>= 2.8.3, < 3.a)
|
||||
googleauth (>= 0.16.2, < 2.a)
|
||||
httpclient (>= 2.8.1, < 3.a)
|
||||
mini_mime (~> 1.0)
|
||||
mutex_m
|
||||
representable (~> 3.0)
|
||||
retriable (>= 2.0, < 4.a)
|
||||
google-apis-iamcredentials_v1 (0.22.0)
|
||||
google-apis-core (>= 0.15.0, < 2.a)
|
||||
google-apis-storage_v1 (0.47.0)
|
||||
google-apis-core (>= 0.15.0, < 2.a)
|
||||
google-cloud-core (1.7.1)
|
||||
google-cloud-env (>= 1.0, < 3.a)
|
||||
rexml
|
||||
webrick
|
||||
google-apis-iamcredentials_v1 (0.17.0)
|
||||
google-apis-core (>= 0.11.0, < 2.a)
|
||||
google-apis-storage_v1 (0.19.0)
|
||||
google-apis-core (>= 0.9.0, < 2.a)
|
||||
google-cloud-core (1.6.0)
|
||||
google-cloud-env (~> 1.0)
|
||||
google-cloud-errors (~> 1.0)
|
||||
google-cloud-dialogflow-v2 (0.31.0)
|
||||
gapic-common (>= 0.20.0, < 2.a)
|
||||
google-cloud-errors (~> 1.0)
|
||||
google-cloud-location (>= 0.4, < 2.a)
|
||||
google-cloud-env (2.2.1)
|
||||
faraday (>= 1.0, < 3.a)
|
||||
google-cloud-env (1.6.0)
|
||||
faraday (>= 0.17.3, < 3.0)
|
||||
google-cloud-errors (1.3.1)
|
||||
google-cloud-location (0.6.0)
|
||||
gapic-common (>= 0.20.0, < 2.a)
|
||||
google-cloud-errors (~> 1.0)
|
||||
google-cloud-storage (1.52.0)
|
||||
google-cloud-storage (1.44.0)
|
||||
addressable (~> 2.8)
|
||||
digest-crc (~> 0.4)
|
||||
google-apis-core (~> 0.13)
|
||||
google-apis-iamcredentials_v1 (~> 0.18)
|
||||
google-apis-storage_v1 (~> 0.38)
|
||||
google-apis-iamcredentials_v1 (~> 0.1)
|
||||
google-apis-storage_v1 (~> 0.19.0)
|
||||
google-cloud-core (~> 1.6)
|
||||
googleauth (~> 1.9)
|
||||
googleauth (>= 0.16.2, < 2.a)
|
||||
mini_mime (~> 1.0)
|
||||
google-cloud-translate-v3 (0.10.0)
|
||||
gapic-common (>= 0.20.0, < 2.a)
|
||||
@@ -331,10 +331,10 @@ GEM
|
||||
grpc (~> 1.41)
|
||||
googleapis-common-protos-types (1.14.0)
|
||||
google-protobuf (~> 3.18)
|
||||
googleauth (1.11.2)
|
||||
faraday (>= 1.0, < 3.a)
|
||||
google-cloud-env (~> 2.1)
|
||||
googleauth (1.5.2)
|
||||
faraday (>= 0.17.3, < 3.a)
|
||||
jwt (>= 1.4, < 3.0)
|
||||
memoist (~> 0.16)
|
||||
multi_json (~> 1.11)
|
||||
os (>= 0.9, < 2.0)
|
||||
signet (>= 0.16, < 2.a)
|
||||
@@ -462,6 +462,7 @@ GEM
|
||||
net-smtp
|
||||
marcel (1.0.4)
|
||||
maxminddb (0.1.22)
|
||||
memoist (0.16.2)
|
||||
meta_request (0.8.3)
|
||||
rack-contrib (>= 1.1, < 3)
|
||||
railties (>= 3.0.0, < 8)
|
||||
@@ -479,7 +480,6 @@ GEM
|
||||
multi_json (1.15.0)
|
||||
multi_xml (0.6.0)
|
||||
multipart-post (2.3.0)
|
||||
mutex_m (0.3.0)
|
||||
neighbor (0.2.3)
|
||||
activerecord (>= 5.2)
|
||||
net-http (0.4.1)
|
||||
@@ -824,6 +824,7 @@ GEM
|
||||
addressable (>= 2.8.0)
|
||||
crack (>= 0.3.2)
|
||||
hashdiff (>= 0.4.0, < 2.0.0)
|
||||
webrick (1.8.2)
|
||||
websocket-driver (0.7.6)
|
||||
websocket-extensions (>= 0.1.0)
|
||||
websocket-extensions (0.1.5)
|
||||
@@ -885,7 +886,7 @@ DEPENDENCIES
|
||||
geocoder
|
||||
gmail_xoauth
|
||||
google-cloud-dialogflow-v2 (>= 0.24.0)
|
||||
google-cloud-storage (>= 1.48.0)
|
||||
google-cloud-storage
|
||||
google-cloud-translate-v3 (>= 0.7.0)
|
||||
groupdate
|
||||
grpc
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
}
|
||||
|
||||
.mx-input {
|
||||
@apply h-[2.5rem] flex border border-solid border-n-weak rounded-md shadow-none;
|
||||
@apply h-[2.5rem] flex border border-solid border-slate-200 dark:border-slate-600 rounded-md shadow-none;
|
||||
}
|
||||
|
||||
.mx-input:disabled,
|
||||
@@ -39,7 +39,7 @@
|
||||
}
|
||||
|
||||
.mx-datepicker-main {
|
||||
@apply border-0 bg-n-solid-2 rounded-xl;
|
||||
@apply border-0 bg-white dark:bg-slate-800;
|
||||
|
||||
.cell {
|
||||
&.disabled {
|
||||
@@ -53,14 +53,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.mx-calendar+.mx-calendar {
|
||||
@apply border-l border-n-weak;
|
||||
}
|
||||
|
||||
.mx-datepicker-footer {
|
||||
@apply border border-n-weak;
|
||||
}
|
||||
|
||||
.mx-time {
|
||||
@apply border-0 bg-white dark:bg-slate-800;
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ const handleButtonClick = () => {
|
||||
<template>
|
||||
<section class="flex flex-col w-full h-full overflow-hidden bg-n-background">
|
||||
<header class="sticky top-0 z-10 px-6 lg:px-0">
|
||||
<div class="w-full max-w-[960px] mx-auto">
|
||||
<div class="w-full max-w-[900px] mx-auto">
|
||||
<div class="flex items-center justify-between w-full h-20 gap-2">
|
||||
<span class="text-xl font-medium text-n-slate-12">
|
||||
{{ headerTitle }}
|
||||
@@ -44,7 +44,7 @@ const handleButtonClick = () => {
|
||||
</div>
|
||||
</header>
|
||||
<main class="flex-1 px-6 overflow-y-auto lg:px-0">
|
||||
<div class="w-full max-w-[960px] mx-auto py-4">
|
||||
<div class="w-full max-w-[900px] mx-auto py-4">
|
||||
<slot name="default" />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -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>
|
||||
+76
@@ -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>
|
||||
+148
@@ -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>
|
||||
+13
-5
@@ -4,8 +4,9 @@ import { useI18n } from 'vue-i18n';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
|
||||
const emit = defineEmits(['add', 'import', 'export']);
|
||||
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();
|
||||
|
||||
@@ -29,15 +30,19 @@ const contactMenuItems = [
|
||||
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') {
|
||||
emit('add');
|
||||
createNewContactDialogRef.value?.dialogRef.open();
|
||||
} else if (action === 'import') {
|
||||
emit('import');
|
||||
contactImportDialogRef.value?.dialogRef.open();
|
||||
} else if (action === 'export') {
|
||||
emit('export');
|
||||
contactExportDialogRef.value?.dialogRef.open();
|
||||
}
|
||||
};
|
||||
</script>
|
||||
@@ -59,4 +64,7 @@ const handleContactAction = ({ action }) => {
|
||||
@action="handleContactAction($event)"
|
||||
/>
|
||||
</div>
|
||||
<CreateNewContactDialog ref="createNewContactDialogRef" />
|
||||
<ContactImportDialog ref="contactImportDialogRef" />
|
||||
<ContactExportDialog ref="contactExportDialogRef" />
|
||||
</template>
|
||||
-4
@@ -1,7 +1,6 @@
|
||||
<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';
|
||||
|
||||
@@ -17,9 +16,7 @@ const props = defineProps({
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:sort']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const isMenuOpen = ref(false);
|
||||
|
||||
const sortMenus = [
|
||||
@@ -70,7 +67,6 @@ const orderingMenus = [
|
||||
|
||||
// Converted the props to refs for better reactivity
|
||||
const activeSort = toRef(props, 'activeSort');
|
||||
|
||||
const activeOrdering = toRef(props, 'activeOrdering');
|
||||
|
||||
const activeSortLabel = computed(() => {
|
||||
@@ -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>
|
||||
|
||||
+58
@@ -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>
|
||||
+58
@@ -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>
|
||||
@@ -1,97 +0,0 @@
|
||||
<script setup>
|
||||
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 ContactSortMenu from './components/ContactSortMenu.vue';
|
||||
import ContactMoreActions from './components/ContactMoreActions.vue';
|
||||
|
||||
defineProps({
|
||||
showSearch: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
searchValue: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
headerTitle: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
buttonLabel: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
activeSort: {
|
||||
type: String,
|
||||
default: 'last_activity_at',
|
||||
},
|
||||
activeOrdering: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
'search',
|
||||
'filter',
|
||||
'update:sort',
|
||||
'message',
|
||||
'add',
|
||||
'import',
|
||||
'export',
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="sticky top-0 z-10 px-6 xl:px-0">
|
||||
<div
|
||||
class="flex items-center justify-between w-full h-20 gap-2 mx-auto max-w-[960px]"
|
||||
>
|
||||
<span class="text-xl font-medium truncate text-n-slate-12">
|
||||
{{ headerTitle }}
|
||||
</span>
|
||||
<div class="flex items-center flex-shrink-0 gap-4">
|
||||
<div v-if="showSearch" 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>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
icon="i-lucide-list-filter"
|
||||
color="slate"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
@click="emit('filter')"
|
||||
/>
|
||||
<ContactSortMenu
|
||||
:active-sort="activeSort"
|
||||
:active-ordering="activeOrdering"
|
||||
@update:sort="emit('update:sort', $event)"
|
||||
/>
|
||||
<ContactMoreActions
|
||||
@add="emit('add')"
|
||||
@import="emit('import')"
|
||||
@export="emit('export')"
|
||||
/>
|
||||
</div>
|
||||
<div class="w-px h-4 bg-n-strong" />
|
||||
<Button :label="buttonLabel" size="sm" @click="emit('message')" />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
-106
@@ -1,106 +0,0 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import ContactHeader from '../ContactHeader.vue';
|
||||
|
||||
// Base state controls
|
||||
const searchValue = ref('');
|
||||
const activeSort = ref('last_activity_at');
|
||||
const activeOrdering = ref('');
|
||||
|
||||
const onSearch = value => {
|
||||
searchValue.value = value;
|
||||
console.log('🔍 Search:', value);
|
||||
};
|
||||
|
||||
const onSort = ({ sort, order }) => {
|
||||
activeSort.value = sort;
|
||||
activeOrdering.value = order;
|
||||
console.log('🔄 Sort changed:', { sort, order });
|
||||
};
|
||||
|
||||
const onFilter = () => {
|
||||
console.log('🏷️ Filter clicked');
|
||||
};
|
||||
|
||||
const onMessage = () => {
|
||||
console.log('💬 Message clicked');
|
||||
};
|
||||
|
||||
const onAdd = () => {
|
||||
console.log('➕ Add contact clicked');
|
||||
};
|
||||
|
||||
const onImport = () => {
|
||||
console.log('📥 Import contacts clicked');
|
||||
};
|
||||
|
||||
const onExport = () => {
|
||||
console.log('📤 Export contacts clicked');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/Contacts/ContactHeader"
|
||||
:layout="{ type: 'grid', width: '900px' }"
|
||||
>
|
||||
<Variant title="Default">
|
||||
<div class="w-full h-[400px]">
|
||||
<ContactHeader
|
||||
header-title="Contacts"
|
||||
button-label="Message"
|
||||
:search-value="searchValue"
|
||||
:active-sort="activeSort"
|
||||
:active-ordering="activeOrdering"
|
||||
@search="onSearch"
|
||||
@filter="onFilter"
|
||||
@update:sort="onSort"
|
||||
@message="onMessage"
|
||||
@add="onAdd"
|
||||
@import="onImport"
|
||||
@export="onExport"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Empty State">
|
||||
<div class="w-full">
|
||||
<ContactHeader
|
||||
:show-search="false"
|
||||
header-title="Contacts"
|
||||
button-label="Message"
|
||||
:search-value="searchValue"
|
||||
:active-sort="activeSort"
|
||||
:active-ordering="activeOrdering"
|
||||
@search="onSearch"
|
||||
@filter="onFilter"
|
||||
@update:sort="onSort"
|
||||
@message="onMessage"
|
||||
@add="onAdd"
|
||||
@import="onImport"
|
||||
@export="onExport"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Segment View">
|
||||
<div class="w-full">
|
||||
<ContactHeader
|
||||
:show-search="false"
|
||||
header-title="Segment: VIP Customers"
|
||||
button-label="Message"
|
||||
:search-value="searchValue"
|
||||
:active-sort="activeSort"
|
||||
:active-ordering="activeOrdering"
|
||||
@search="onSearch"
|
||||
@filter="onFilter"
|
||||
@update:sort="onSort"
|
||||
@message="onMessage"
|
||||
@add="onAdd"
|
||||
@import="onImport"
|
||||
@export="onExport"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</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>
|
||||
+95
@@ -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>
|
||||
+140
@@ -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>
|
||||
-28
@@ -1,28 +0,0 @@
|
||||
<script setup>
|
||||
import ContactEmptyState from './ContactEmptyState.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/Contacts/EmptyState"
|
||||
:layout="{ type: 'grid', width: '900px' }"
|
||||
>
|
||||
<!-- Default Story -->
|
||||
<Variant title="Default">
|
||||
<ContactEmptyState
|
||||
title="No contacts found"
|
||||
subtitle="Create your first contact to get started"
|
||||
button-label="Add Contact"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<!-- Without Button -->
|
||||
<Variant title="Without Button">
|
||||
<ContactEmptyState
|
||||
title="No contacts"
|
||||
subtitle="These are your current contacts"
|
||||
:show-button="false"
|
||||
/>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -1,8 +1,11 @@
|
||||
<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: {
|
||||
@@ -22,6 +25,12 @@ defineProps({
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const createNewContactDialogRef = ref(null);
|
||||
|
||||
const onClick = () => {
|
||||
createNewContactDialogRef.value?.dialogRef.open();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -46,6 +55,7 @@ defineProps({
|
||||
<div v-if="showButton">
|
||||
<Button :label="buttonLabel" icon="i-lucide-plus" @click="onClick" />
|
||||
</div>
|
||||
<CreateNewContactDialog ref="createNewContactDialogRef" />
|
||||
</template>
|
||||
</EmptyStateLayout>
|
||||
</template>
|
||||
|
||||
@@ -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>
|
||||
+27
-2
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ const togglePortalSwitcher = () => {
|
||||
<template>
|
||||
<section class="flex flex-col w-full h-full overflow-hidden bg-n-background">
|
||||
<header class="sticky top-0 z-10 px-6 pb-3 lg:px-0">
|
||||
<div class="w-full max-w-[960px] mx-auto">
|
||||
<div class="w-full max-w-[900px] mx-auto">
|
||||
<div
|
||||
v-if="showHeaderTitle"
|
||||
class="flex items-center justify-start h-20 gap-2"
|
||||
@@ -95,7 +95,7 @@ const togglePortalSwitcher = () => {
|
||||
</div>
|
||||
</header>
|
||||
<main class="flex-1 px-6 overflow-y-auto lg:px-0">
|
||||
<div class="w-full max-w-[960px] mx-auto py-3">
|
||||
<div class="w-full max-w-[900px] mx-auto py-3">
|
||||
<slot name="content" />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
+1
-1
@@ -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>
|
||||
+251
@@ -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>
|
||||
+88
@@ -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>
|
||||
+170
@@ -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>
|
||||
+340
@@ -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>
|
||||
+141
@@ -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>
|
||||
+9
@@ -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>
|
||||
+126
@@ -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>
|
||||
+176
@@ -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>
|
||||
+183
@@ -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);
|
||||
};
|
||||
+372
@@ -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>
|
||||
|
||||
@@ -14,22 +14,22 @@ const menuItems = ref([
|
||||
{
|
||||
label: 'Contact Support',
|
||||
icon: 'i-lucide-life-buoy',
|
||||
click: () => console.log('Contact Support'),
|
||||
click: () => window.alert('Contact Support'),
|
||||
},
|
||||
{
|
||||
label: 'Keyboard Shortcuts',
|
||||
icon: 'i-lucide-keyboard',
|
||||
click: () => console.log('Keyboard Shortcuts'),
|
||||
click: () => window.alert('Keyboard Shortcuts'),
|
||||
},
|
||||
{
|
||||
label: 'Profile Settings',
|
||||
icon: 'i-lucide-user-pen',
|
||||
click: () => console.log('Profile Settings'),
|
||||
click: () => window.alert('Profile Settings'),
|
||||
},
|
||||
{
|
||||
label: 'Change Appearance',
|
||||
icon: 'i-lucide-swatch-book',
|
||||
click: () => console.log('Change Appearance'),
|
||||
click: () => window.alert('Change Appearance'),
|
||||
},
|
||||
{
|
||||
label: 'Open SuperAdmin',
|
||||
@@ -40,7 +40,7 @@ const menuItems = ref([
|
||||
{
|
||||
label: 'Log Out',
|
||||
icon: 'i-lucide-log-out',
|
||||
click: () => console.log('Log Out'),
|
||||
click: () => window.alert('Log Out'),
|
||||
},
|
||||
]);
|
||||
</script>
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import ConditionRow from './ConditionRow.vue';
|
||||
import Button from 'next/button/Button.vue';
|
||||
import { filterTypes } from './fixtures/filterTypes.js';
|
||||
|
||||
const DEFAULT_FILTER = {
|
||||
attributeKey: 'status',
|
||||
filterOperator: 'equal_to',
|
||||
values: [],
|
||||
queryOperator: 'and',
|
||||
};
|
||||
|
||||
const filters = ref([{ ...DEFAULT_FILTER }]);
|
||||
|
||||
const removeFilter = index => {
|
||||
filters.value.splice(index, 1);
|
||||
};
|
||||
|
||||
const showQueryOperator = true;
|
||||
|
||||
const addFilter = () => {
|
||||
filters.value.push({ ...DEFAULT_FILTER });
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/Filters/ConditionRow"
|
||||
:layout="{ type: 'grid', width: '600px' }"
|
||||
>
|
||||
<div class="min-h-[400px] p-2 space-y-2">
|
||||
<template v-for="(filter, index) in filters" :key="`filter-${index}`">
|
||||
<ConditionRow
|
||||
v-if="index === 0"
|
||||
v-model:attribute-key="filter.attributeKey"
|
||||
v-model:filter-operator="filter.filterOperator"
|
||||
v-model:values="filter.values"
|
||||
:show-query-operator="false"
|
||||
:filter-types="filterTypes"
|
||||
@remove="removeFilter(index)"
|
||||
/>
|
||||
|
||||
<ConditionRow
|
||||
v-else
|
||||
v-model:attribute-key="filter.attributeKey"
|
||||
v-model:filter-operator="filter.filterOperator"
|
||||
v-model:values="filter.values"
|
||||
v-model:query-operator="filters[index - 1].queryOperator"
|
||||
:show-query-operator
|
||||
:filter-types="filterTypes"
|
||||
@remove="removeFilter(index)"
|
||||
/>
|
||||
</template>
|
||||
<Button sm label="Add Filter" @click="addFilter" />
|
||||
</div>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -1,197 +0,0 @@
|
||||
<script setup>
|
||||
import { computed, defineModel, h, watch, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import Button from 'next/button/Button.vue';
|
||||
import FilterSelect from './inputs/FilterSelect.vue';
|
||||
import MultiSelect from './inputs/MultiSelect.vue';
|
||||
import SingleSelect from './inputs/SingleSelect.vue';
|
||||
|
||||
import { validateSingleFilter } from 'dashboard/helper/validations.js';
|
||||
|
||||
// filterTypes: import('vue').ComputedRef<FilterType[]>
|
||||
const { filterTypes } = defineProps({
|
||||
showQueryOperator: { type: Boolean, default: false },
|
||||
filterTypes: { type: Array, required: true },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['remove']);
|
||||
const { t } = useI18n();
|
||||
const showErrors = ref(false);
|
||||
|
||||
const attributeKey = defineModel('attributeKey', {
|
||||
type: String,
|
||||
required: true,
|
||||
});
|
||||
|
||||
const values = defineModel('values', {
|
||||
type: [String, Number, Array, Object],
|
||||
required: true,
|
||||
});
|
||||
|
||||
const filterOperator = defineModel('filterOperator', {
|
||||
type: String,
|
||||
required: true,
|
||||
});
|
||||
|
||||
const queryOperator = defineModel('queryOperator', {
|
||||
type: String,
|
||||
required: false,
|
||||
default: undefined,
|
||||
validator: value => ['and', 'or'].includes(value),
|
||||
});
|
||||
|
||||
const getFilterFromFilterTypes = key =>
|
||||
filterTypes.find(filterObj => filterObj.attributeKey === key);
|
||||
|
||||
const currentFilter = computed(() =>
|
||||
getFilterFromFilterTypes(attributeKey.value)
|
||||
);
|
||||
|
||||
const getOperator = (filter, selectedOperator) => {
|
||||
const operatorFromOptions = filter.filterOperators.find(
|
||||
operator => operator.value === selectedOperator
|
||||
);
|
||||
|
||||
if (!operatorFromOptions) {
|
||||
return filter.filterOperators[0];
|
||||
}
|
||||
|
||||
return operatorFromOptions;
|
||||
};
|
||||
|
||||
const currentOperator = computed(() =>
|
||||
getOperator(currentFilter.value, filterOperator.value)
|
||||
);
|
||||
|
||||
const getInputType = (operator, filter) =>
|
||||
operator.inputOverride ?? filter.inputType;
|
||||
|
||||
const inputType = computed(() =>
|
||||
getInputType(currentOperator.value, currentFilter.value)
|
||||
);
|
||||
|
||||
const queryOperatorOptions = computed(() => {
|
||||
return [
|
||||
{
|
||||
label: t(`FILTER.QUERY_DROPDOWN_LABELS.AND`),
|
||||
value: 'and',
|
||||
icon: h('span', { class: 'i-lucide-ampersands !text-n-blue-text' }),
|
||||
},
|
||||
{
|
||||
label: t(`FILTER.QUERY_DROPDOWN_LABELS.OR`),
|
||||
value: 'or',
|
||||
icon: h('span', { class: 'i-woot-logic-or !text-n-blue-text' }),
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
const booleanOptions = computed(() => [
|
||||
{ id: true, name: t('FILTER.ATTRIBUTE_LABELS.TRUE') },
|
||||
{ id: false, name: t('FILTER.ATTRIBUTE_LABELS.FALSE') },
|
||||
]);
|
||||
|
||||
const validationError = computed(() => {
|
||||
return validateSingleFilter({
|
||||
attributeKey: attributeKey.value,
|
||||
filter_operator: filterOperator.value,
|
||||
values: values.value,
|
||||
});
|
||||
});
|
||||
|
||||
const resetModelOnAttributeKeyChange = newAttributeKey => {
|
||||
/**
|
||||
* Resets the filter values and operator when the attribute key changes. This ensures that
|
||||
* the values and operator remain compatible with the new attribute type. For example,
|
||||
* switching from a text field to a multi-select should reset the value from '' (empty string)
|
||||
* to an empty array.
|
||||
*/
|
||||
const filter = getFilterFromFilterTypes(newAttributeKey);
|
||||
const newOperator = getOperator(filter, filterOperator.value);
|
||||
const newInputType = getInputType(newOperator, filter);
|
||||
if (newInputType === 'multiSelect') {
|
||||
values.value = [];
|
||||
} else if (['searchSelect', 'booleanSelect'].includes(newInputType)) {
|
||||
values.value = {};
|
||||
} else {
|
||||
values.value = '';
|
||||
}
|
||||
filterOperator.value = newOperator.value;
|
||||
};
|
||||
|
||||
watch([attributeKey, values, filterOperator], () => {
|
||||
showErrors.value = false;
|
||||
});
|
||||
|
||||
const validate = () => {
|
||||
showErrors.value = true;
|
||||
return !validationError.value;
|
||||
};
|
||||
|
||||
defineExpose({ validate });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<li class="list-none">
|
||||
<div
|
||||
class="flex items-center gap-2 rounded-md"
|
||||
:class="{
|
||||
'animate-wiggle': showErrors && validationError,
|
||||
}"
|
||||
>
|
||||
<FilterSelect
|
||||
v-if="showQueryOperator"
|
||||
v-model="queryOperator"
|
||||
variant="faded"
|
||||
hide-icon
|
||||
class="text-sm"
|
||||
:options="queryOperatorOptions"
|
||||
/>
|
||||
<FilterSelect
|
||||
v-model="attributeKey"
|
||||
variant="faded"
|
||||
:options="filterTypes"
|
||||
@update:model-value="resetModelOnAttributeKeyChange"
|
||||
/>
|
||||
<FilterSelect
|
||||
v-model="filterOperator"
|
||||
variant="ghost"
|
||||
:options="currentFilter.filterOperators"
|
||||
/>
|
||||
<template v-if="currentOperator.hasInput">
|
||||
<MultiSelect
|
||||
v-if="inputType === 'multiSelect'"
|
||||
v-model="values"
|
||||
:options="currentFilter.options"
|
||||
/>
|
||||
<SingleSelect
|
||||
v-else-if="inputType === 'searchSelect'"
|
||||
v-model="values"
|
||||
:options="currentFilter.options"
|
||||
/>
|
||||
<SingleSelect
|
||||
v-else-if="inputType === 'booleanSelect'"
|
||||
v-model="values"
|
||||
disable-search
|
||||
:options="booleanOptions"
|
||||
/>
|
||||
<input
|
||||
v-else
|
||||
v-model="values"
|
||||
:type="inputType === 'date' ? 'date' : 'text'"
|
||||
class="py-1.5 px-3 text-n-slate-12 bg-n-alpha-1 text-sm rounded-lg reset-base"
|
||||
:placeholder="t('FILTER.INPUT_PLACEHOLDER')"
|
||||
/>
|
||||
</template>
|
||||
<Button
|
||||
sm
|
||||
solid
|
||||
slate
|
||||
icon="i-lucide-trash"
|
||||
@click.stop="emit('remove')"
|
||||
/>
|
||||
</div>
|
||||
<span v-if="showErrors && validationError" class="text-sm text-n-ruby-11">
|
||||
{{ t(`FILTER.ERRORS.${validationError}`) }}
|
||||
</span>
|
||||
</li>
|
||||
</template>
|
||||
@@ -1,558 +0,0 @@
|
||||
export const filterTypes = [
|
||||
{
|
||||
attributeKey: 'status',
|
||||
value: 'status',
|
||||
attributeName: 'Status',
|
||||
label: 'Status',
|
||||
inputType: 'multiSelect',
|
||||
options: [
|
||||
{ id: 'open', name: 'Open' },
|
||||
{ id: 'resolved', name: 'Resolved' },
|
||||
{ id: 'pending', name: 'Pending' },
|
||||
{ id: 'snoozed', name: 'Snoozed' },
|
||||
{ id: 'all', name: 'All' },
|
||||
],
|
||||
dataType: 'text',
|
||||
filterOperators: [
|
||||
{
|
||||
value: 'equal_to',
|
||||
label: 'Equal to',
|
||||
hasInput: true,
|
||||
inputOverride: null,
|
||||
icon: 'i-ph-equals-bold',
|
||||
},
|
||||
{
|
||||
value: 'not_equal_to',
|
||||
label: 'Not equal to',
|
||||
hasInput: true,
|
||||
inputOverride: null,
|
||||
icon: 'i-ph-not-equals-bold',
|
||||
},
|
||||
],
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: 'assignee_id',
|
||||
value: 'assignee_id',
|
||||
attributeName: 'Assignee name',
|
||||
label: 'Assignee name',
|
||||
inputType: 'searchSelect',
|
||||
options: [
|
||||
{ id: 14, name: 'Ben Nugent' },
|
||||
{ id: 30, name: 'Bruce' },
|
||||
{ id: 16, name: 'Cathy Simms' },
|
||||
{ id: 7, name: 'Charles Miner' },
|
||||
{ id: 10, name: 'Craig D' },
|
||||
{ id: 9, name: 'Dan Gore' },
|
||||
{ id: 13, name: 'Danny Cordray' },
|
||||
{ id: 3, name: 'David Wallace' },
|
||||
{ id: 4, name: 'Deangelo Vickers' },
|
||||
{ id: 33, name: 'Devon White' },
|
||||
{ id: 8, name: 'Ed Truck' },
|
||||
{ id: 31, name: 'Frank' },
|
||||
{ id: 29, name: 'Gideon' },
|
||||
{ id: 24, name: 'Glenn Max' },
|
||||
],
|
||||
dataType: 'text',
|
||||
filterOperators: [
|
||||
{
|
||||
value: 'equal_to',
|
||||
label: 'Equal to',
|
||||
hasInput: true,
|
||||
inputOverride: null,
|
||||
icon: 'i-ph-equals-bold',
|
||||
},
|
||||
{
|
||||
value: 'not_equal_to',
|
||||
label: 'Not equal to',
|
||||
hasInput: true,
|
||||
inputOverride: null,
|
||||
icon: 'i-ph-not-equals-bold',
|
||||
},
|
||||
{
|
||||
value: 'is_present',
|
||||
label: 'Is present',
|
||||
hasInput: false,
|
||||
inputOverride: null,
|
||||
icon: 'i-ph-member-of-bold',
|
||||
},
|
||||
{
|
||||
value: 'is_not_present',
|
||||
label: 'Is not present',
|
||||
hasInput: false,
|
||||
inputOverride: null,
|
||||
icon: 'i-ph-not-member-of',
|
||||
},
|
||||
],
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: 'team_id',
|
||||
value: 'team_id',
|
||||
attributeName: 'Team name',
|
||||
label: 'Team name',
|
||||
inputType: 'searchSelect',
|
||||
options: [
|
||||
{ id: 223, name: '💰 sales' },
|
||||
{ id: 224, name: '💼 management' },
|
||||
{ id: 225, name: '👩💼 administration' },
|
||||
{ id: 226, name: '🚛 warehouse' },
|
||||
],
|
||||
dataType: 'number',
|
||||
filterOperators: [
|
||||
{
|
||||
value: 'equal_to',
|
||||
label: 'Equal to',
|
||||
hasInput: true,
|
||||
inputOverride: null,
|
||||
icon: 'i-ph-equals-bold',
|
||||
},
|
||||
{
|
||||
value: 'not_equal_to',
|
||||
label: 'Not equal to',
|
||||
hasInput: true,
|
||||
inputOverride: null,
|
||||
icon: 'i-ph-not-equals-bold',
|
||||
},
|
||||
{
|
||||
value: 'is_present',
|
||||
label: 'Is present',
|
||||
hasInput: false,
|
||||
inputOverride: null,
|
||||
icon: 'i-ph-member-of-bold',
|
||||
},
|
||||
{
|
||||
value: 'is_not_present',
|
||||
label: 'Is not present',
|
||||
hasInput: false,
|
||||
inputOverride: null,
|
||||
icon: 'i-ph-not-member-of',
|
||||
},
|
||||
],
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: 'display_id',
|
||||
value: 'display_id',
|
||||
attributeName: 'Conversation identifier',
|
||||
label: 'Conversation identifier',
|
||||
inputType: 'plainText',
|
||||
datatype: 'number',
|
||||
filterOperators: [
|
||||
{
|
||||
value: 'equal_to',
|
||||
label: 'Equal to',
|
||||
hasInput: true,
|
||||
inputOverride: null,
|
||||
icon: 'i-ph-equals-bold',
|
||||
},
|
||||
{
|
||||
value: 'not_equal_to',
|
||||
label: 'Not equal to',
|
||||
hasInput: true,
|
||||
inputOverride: null,
|
||||
icon: 'i-ph-not-equals-bold',
|
||||
},
|
||||
{
|
||||
value: 'contains',
|
||||
label: 'Contains',
|
||||
hasInput: true,
|
||||
inputOverride: null,
|
||||
icon: 'i-ph-superset-of-bold',
|
||||
},
|
||||
{
|
||||
value: 'does_not_contain',
|
||||
label: 'Does not contain',
|
||||
hasInput: true,
|
||||
inputOverride: null,
|
||||
icon: 'i-ph-not-superset-of',
|
||||
},
|
||||
],
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: 'campaign_id',
|
||||
value: 'campaign_id',
|
||||
attributeName: 'Campaign name',
|
||||
label: 'Campaign name',
|
||||
inputType: 'searchSelect',
|
||||
options: [],
|
||||
datatype: 'number',
|
||||
filterOperators: [
|
||||
{
|
||||
value: 'equal_to',
|
||||
label: 'Equal to',
|
||||
hasInput: true,
|
||||
inputOverride: null,
|
||||
icon: 'i-ph-equals-bold',
|
||||
},
|
||||
{
|
||||
value: 'not_equal_to',
|
||||
label: 'Not equal to',
|
||||
hasInput: true,
|
||||
inputOverride: null,
|
||||
icon: 'i-ph-not-equals-bold',
|
||||
},
|
||||
{
|
||||
value: 'is_present',
|
||||
label: 'Is present',
|
||||
hasInput: false,
|
||||
inputOverride: null,
|
||||
icon: 'i-ph-member-of-bold',
|
||||
},
|
||||
{
|
||||
value: 'is_not_present',
|
||||
label: 'Is not present',
|
||||
hasInput: false,
|
||||
inputOverride: null,
|
||||
icon: 'i-ph-not-member-of',
|
||||
},
|
||||
],
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: 'labels',
|
||||
value: 'labels',
|
||||
attributeName: 'Labels',
|
||||
label: 'Labels',
|
||||
inputType: 'multiSelect',
|
||||
options: [
|
||||
{ id: 'billing', name: 'billing' },
|
||||
{ id: 'delivery', name: 'delivery' },
|
||||
{ id: 'lead', name: 'lead' },
|
||||
{ id: 'ops-handover', name: 'ops-handover' },
|
||||
{ id: 'premium-customer', name: 'premium-customer' },
|
||||
{ id: 'software', name: 'software' },
|
||||
],
|
||||
dataType: 'text',
|
||||
filterOperators: [
|
||||
{
|
||||
value: 'equal_to',
|
||||
label: 'Equal to',
|
||||
hasInput: true,
|
||||
inputOverride: null,
|
||||
icon: 'i-ph-equals-bold',
|
||||
},
|
||||
{
|
||||
value: 'not_equal_to',
|
||||
label: 'Not equal to',
|
||||
hasInput: true,
|
||||
inputOverride: null,
|
||||
icon: 'i-ph-not-equals-bold',
|
||||
},
|
||||
{
|
||||
value: 'is_present',
|
||||
label: 'Is present',
|
||||
hasInput: false,
|
||||
inputOverride: null,
|
||||
icon: 'i-ph-member-of-bold',
|
||||
},
|
||||
{
|
||||
value: 'is_not_present',
|
||||
label: 'Is not present',
|
||||
hasInput: false,
|
||||
inputOverride: null,
|
||||
icon: 'i-ph-not-member-of',
|
||||
},
|
||||
],
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: 'referer',
|
||||
value: 'referer',
|
||||
attributeName: 'Referer link',
|
||||
label: 'Referer link',
|
||||
inputType: 'plainText',
|
||||
dataType: 'text',
|
||||
filterOperators: [
|
||||
{
|
||||
value: 'equal_to',
|
||||
label: 'Equal to',
|
||||
hasInput: true,
|
||||
inputOverride: null,
|
||||
icon: 'i-ph-equals-bold',
|
||||
},
|
||||
{
|
||||
value: 'not_equal_to',
|
||||
label: 'Not equal to',
|
||||
hasInput: true,
|
||||
inputOverride: null,
|
||||
icon: 'i-ph-not-equals-bold',
|
||||
},
|
||||
{
|
||||
value: 'contains',
|
||||
label: 'Contains',
|
||||
hasInput: true,
|
||||
inputOverride: null,
|
||||
icon: 'i-ph-superset-of-bold',
|
||||
},
|
||||
{
|
||||
value: 'does_not_contain',
|
||||
label: 'Does not contain',
|
||||
hasInput: true,
|
||||
inputOverride: null,
|
||||
icon: 'i-ph-not-superset-of',
|
||||
},
|
||||
],
|
||||
attributeModel: 'additional',
|
||||
},
|
||||
{
|
||||
attributeKey: 'created_at',
|
||||
value: 'created_at',
|
||||
attributeName: 'Created at',
|
||||
label: 'Created at',
|
||||
inputType: 'date',
|
||||
dataType: 'text',
|
||||
filterOperators: [
|
||||
{
|
||||
value: 'is_greater_than',
|
||||
label: 'Is greater than',
|
||||
hasInput: true,
|
||||
inputOverride: null,
|
||||
icon: 'i-ph-greater-than-bold',
|
||||
},
|
||||
{
|
||||
value: 'is_less_than',
|
||||
label: 'Is lesser than',
|
||||
hasInput: true,
|
||||
inputOverride: null,
|
||||
icon: 'i-ph-less-than-bold',
|
||||
},
|
||||
{
|
||||
value: 'days_before',
|
||||
label: 'Is x days before',
|
||||
hasInput: true,
|
||||
inputOverride: 'plainText',
|
||||
icon: 'i-ph-calendar-minus-bold',
|
||||
},
|
||||
],
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: 'last_activity_at',
|
||||
value: 'last_activity_at',
|
||||
attributeName: 'Last activity',
|
||||
label: 'Last activity',
|
||||
inputType: 'date',
|
||||
dataType: 'text',
|
||||
filterOperators: [
|
||||
{
|
||||
value: 'is_greater_than',
|
||||
label: 'Is greater than',
|
||||
hasInput: true,
|
||||
inputOverride: null,
|
||||
icon: 'i-ph-greater-than-bold',
|
||||
},
|
||||
{
|
||||
value: 'is_less_than',
|
||||
label: 'Is lesser than',
|
||||
hasInput: true,
|
||||
inputOverride: null,
|
||||
icon: 'i-ph-less-than-bold',
|
||||
},
|
||||
{
|
||||
value: 'days_before',
|
||||
label: 'Is x days before',
|
||||
hasInput: true,
|
||||
inputOverride: 'plainText',
|
||||
icon: 'i-ph-calendar-minus-bold',
|
||||
},
|
||||
],
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: 'are_you_a_paid_customer',
|
||||
value: 'are_you_a_paid_customer',
|
||||
attributeName: 'Are you a paid customer?',
|
||||
label: 'Are you a paid customer?',
|
||||
inputType: 'booleanSelect',
|
||||
filterOperators: [
|
||||
{
|
||||
value: 'equal_to',
|
||||
label: 'Equal to',
|
||||
hasInput: true,
|
||||
inputOverride: null,
|
||||
icon: 'i-ph-equals-bold',
|
||||
},
|
||||
{
|
||||
value: 'not_equal_to',
|
||||
label: 'Not equal to',
|
||||
hasInput: true,
|
||||
inputOverride: null,
|
||||
icon: 'i-ph-not-equals-bold',
|
||||
},
|
||||
],
|
||||
options: [],
|
||||
attributeModel: 'customAttributes',
|
||||
},
|
||||
{
|
||||
attributeKey: 'date_of_purchase',
|
||||
value: 'date_of_purchase',
|
||||
attributeName: 'Date of Purchase',
|
||||
label: 'Date of Purchase',
|
||||
inputType: 'date',
|
||||
filterOperators: [
|
||||
{
|
||||
value: 'equal_to',
|
||||
label: 'Equal to',
|
||||
hasInput: true,
|
||||
inputOverride: null,
|
||||
icon: 'i-ph-equals-bold',
|
||||
},
|
||||
{
|
||||
value: 'not_equal_to',
|
||||
label: 'Not equal to',
|
||||
hasInput: true,
|
||||
inputOverride: null,
|
||||
icon: 'i-ph-not-equals-bold',
|
||||
},
|
||||
{
|
||||
value: 'is_present',
|
||||
label: 'Is present',
|
||||
hasInput: false,
|
||||
inputOverride: null,
|
||||
icon: 'i-ph-member-of-bold',
|
||||
},
|
||||
{
|
||||
value: 'is_not_present',
|
||||
label: 'Is not present',
|
||||
hasInput: false,
|
||||
inputOverride: null,
|
||||
icon: 'i-ph-not-member-of',
|
||||
},
|
||||
{
|
||||
value: 'is_greater_than',
|
||||
label: 'Is greater than',
|
||||
hasInput: true,
|
||||
inputOverride: null,
|
||||
icon: 'i-ph-greater-than-bold',
|
||||
},
|
||||
{
|
||||
value: 'is_less_than',
|
||||
label: 'Is lesser than',
|
||||
hasInput: true,
|
||||
inputOverride: null,
|
||||
icon: 'i-ph-less-than-bold',
|
||||
},
|
||||
],
|
||||
options: [],
|
||||
attributeModel: 'customAttributes',
|
||||
},
|
||||
{
|
||||
attributeKey: 'your_website',
|
||||
value: 'your_website',
|
||||
attributeName: 'Your website',
|
||||
label: 'Your website',
|
||||
inputType: 'plainText',
|
||||
filterOperators: [
|
||||
{
|
||||
value: 'equal_to',
|
||||
label: 'Equal to',
|
||||
hasInput: true,
|
||||
inputOverride: null,
|
||||
icon: 'i-ph-equals-bold',
|
||||
},
|
||||
{
|
||||
value: 'not_equal_to',
|
||||
label: 'Not equal to',
|
||||
hasInput: true,
|
||||
inputOverride: null,
|
||||
icon: 'i-ph-not-equals-bold',
|
||||
},
|
||||
],
|
||||
options: [],
|
||||
attributeModel: 'customAttributes',
|
||||
},
|
||||
{
|
||||
attributeKey: 'are_you_residing_in_india',
|
||||
value: 'are_you_residing_in_india',
|
||||
attributeName: 'Are you residing in India?',
|
||||
label: 'Are you residing in India?',
|
||||
inputType: 'booleanSelect',
|
||||
filterOperators: [
|
||||
{
|
||||
value: 'equal_to',
|
||||
label: 'Equal to',
|
||||
hasInput: true,
|
||||
inputOverride: null,
|
||||
icon: 'i-ph-equals-bold',
|
||||
},
|
||||
{
|
||||
value: 'not_equal_to',
|
||||
label: 'Not equal to',
|
||||
hasInput: true,
|
||||
inputOverride: null,
|
||||
icon: 'i-ph-not-equals-bold',
|
||||
},
|
||||
],
|
||||
options: [],
|
||||
attributeModel: 'customAttributes',
|
||||
},
|
||||
{
|
||||
attributeKey: 'cloud',
|
||||
value: 'cloud',
|
||||
attributeName: 'Cloud',
|
||||
label: 'Cloud',
|
||||
inputType: 'booleanSelect',
|
||||
filterOperators: [
|
||||
{
|
||||
value: 'equal_to',
|
||||
label: 'Equal to',
|
||||
hasInput: true,
|
||||
inputOverride: null,
|
||||
icon: 'i-ph-equals-bold',
|
||||
},
|
||||
{
|
||||
value: 'not_equal_to',
|
||||
label: 'Not equal to',
|
||||
hasInput: true,
|
||||
inputOverride: null,
|
||||
icon: 'i-ph-not-equals-bold',
|
||||
},
|
||||
],
|
||||
options: [],
|
||||
attributeModel: 'customAttributes',
|
||||
},
|
||||
{
|
||||
attributeKey: 'license_type',
|
||||
value: 'license_type',
|
||||
attributeName: 'License Type',
|
||||
label: 'License Type',
|
||||
inputType: 'searchSelect',
|
||||
filterOperators: [
|
||||
{
|
||||
value: 'equal_to',
|
||||
label: 'Equal to',
|
||||
hasInput: true,
|
||||
inputOverride: null,
|
||||
icon: 'i-ph-equals-bold',
|
||||
},
|
||||
{
|
||||
value: 'not_equal_to',
|
||||
label: 'Not equal to',
|
||||
hasInput: true,
|
||||
inputOverride: null,
|
||||
icon: 'i-ph-not-equals-bold',
|
||||
},
|
||||
],
|
||||
options: [
|
||||
{
|
||||
id: 'Personal',
|
||||
name: 'Personal',
|
||||
},
|
||||
{
|
||||
id: 'Enterprise',
|
||||
name: 'Enterprise',
|
||||
},
|
||||
{
|
||||
id: 'Teams',
|
||||
name: 'Teams',
|
||||
},
|
||||
{
|
||||
id: 'Professional',
|
||||
name: 'Professional',
|
||||
},
|
||||
],
|
||||
attributeModel: 'customAttributes',
|
||||
},
|
||||
];
|
||||
@@ -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'),
|
||||
@@ -249,6 +334,11 @@ const menuItems = computed(() => {
|
||||
label: t('SIDEBAR.CSAT'),
|
||||
to: accountScopedRoute('csat_reports'),
|
||||
},
|
||||
{
|
||||
name: 'Reports Bot',
|
||||
label: t('SIDEBAR.REPORTS_BOT'),
|
||||
to: accountScopedRoute('bot_reports'),
|
||||
},
|
||||
{
|
||||
name: 'Reports Agent',
|
||||
label: t('SIDEBAR.REPORTS_AGENT'),
|
||||
@@ -274,11 +364,6 @@ const menuItems = computed(() => {
|
||||
label: t('SIDEBAR.REPORTS_SLA'),
|
||||
to: accountScopedRoute('sla_reports'),
|
||||
},
|
||||
{
|
||||
name: 'Reports Bot',
|
||||
label: t('SIDEBAR.REPORTS_BOT'),
|
||||
to: accountScopedRoute('bot_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',
|
||||
|
||||
@@ -54,7 +54,7 @@ const emitNewAccount = () => {
|
||||
/>
|
||||
</button>
|
||||
</template>
|
||||
<DropdownBody class="min-w-80 z-50">
|
||||
<DropdownBody class="min-w-80">
|
||||
<DropdownSection :title="t('SIDEBAR_ITEMS.SWITCH_WORKSPACE')">
|
||||
<DropdownItem
|
||||
v-for="account in currentUser.accounts"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -54,7 +54,7 @@ const end = computed(() => {
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex flex-1 items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-n-slate-11 mb-0">
|
||||
<p class="text-sm text-gray-700">
|
||||
{{ $t('REPORT.PAGINATION.RESULTS', { start, end, total }) }}
|
||||
</p>
|
||||
</div>
|
||||
@@ -62,7 +62,7 @@ const end = computed(() => {
|
||||
<woot-button
|
||||
:disabled="!table.getCanPreviousPage()"
|
||||
variant="clear"
|
||||
class="h-8 border-0 flex items-center"
|
||||
class="size-8 flex items-center border border-slate-50"
|
||||
color-scheme="secondary"
|
||||
@click="table.setPageIndex(0)"
|
||||
>
|
||||
@@ -70,7 +70,7 @@ const end = computed(() => {
|
||||
</woot-button>
|
||||
<woot-button
|
||||
variant="clear"
|
||||
class="h-8 border-0 flex items-center"
|
||||
class="size-8 flex items-center border border-slate-50"
|
||||
color-scheme="secondary"
|
||||
:disabled="!table.getCanPreviousPage()"
|
||||
@click="table.previousPage()"
|
||||
@@ -81,22 +81,22 @@ const end = computed(() => {
|
||||
v-for="page in visiblePages"
|
||||
:key="page"
|
||||
variant="clear"
|
||||
class="h-8 flex items-center justify-center text-xs leading-none text-center"
|
||||
:class="page == currentPage ? 'border-n-brand' : 'border-slate-50'"
|
||||
class="size-8 flex items-center justify-center border text-xs leading-none text-center"
|
||||
:class="page == currentPage ? 'border-woot-500' : 'border-slate-50'"
|
||||
color-scheme="secondary"
|
||||
@click="table.setPageIndex(page - 1)"
|
||||
>
|
||||
<span
|
||||
<div
|
||||
class="text-center"
|
||||
:class="{ 'text-n-brand': page == currentPage }"
|
||||
:class="{ 'text-woot-500': page == currentPage }"
|
||||
>
|
||||
{{ page }}
|
||||
</span>
|
||||
</div>
|
||||
</woot-button>
|
||||
<woot-button
|
||||
:disabled="!table.getCanNextPage()"
|
||||
variant="clear"
|
||||
class="h-8 border-0 flex items-center"
|
||||
class="size-8 flex items-center border border-slate-50"
|
||||
color-scheme="secondary"
|
||||
@click="table.nextPage()"
|
||||
>
|
||||
@@ -105,7 +105,7 @@ const end = computed(() => {
|
||||
<woot-button
|
||||
:disabled="!table.getCanNextPage()"
|
||||
variant="clear"
|
||||
class="h-8 border-0 flex items-center"
|
||||
class="size-8 flex items-center border border-slate-50"
|
||||
color-scheme="secondary"
|
||||
@click="table.setPageIndex(table.getPageCount() - 1)"
|
||||
>
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
<script setup>
|
||||
import { FlexRender } from '@tanstack/vue-table';
|
||||
import SortButton from './SortButton.vue';
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
defineProps({
|
||||
table: {
|
||||
type: Object,
|
||||
required: true,
|
||||
@@ -12,36 +11,22 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
default: 'relaxed',
|
||||
},
|
||||
});
|
||||
|
||||
const isRelaxed = computed(() => props.type === 'relaxed');
|
||||
const headerClass = computed(() =>
|
||||
isRelaxed.value
|
||||
? 'first:rounded-bl-lg first:rounded-tl-lg last:rounded-br-lg last:rounded-tr-lg'
|
||||
: ''
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<table :class="{ 'table-fixed': fixed }">
|
||||
<thead class="sticky top-0 z-10 bg-n-slate-1">
|
||||
<tr
|
||||
v-for="headerGroup in table.getHeaderGroups()"
|
||||
:key="headerGroup.id"
|
||||
class="rounded-xl"
|
||||
>
|
||||
<thead
|
||||
class="sticky top-0 z-10 border-b border-slate-50 dark:border-slate-800 bg-slate-25 dark:bg-slate-800"
|
||||
>
|
||||
<tr v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
|
||||
<th
|
||||
v-for="header in headerGroup.headers"
|
||||
:key="header.id"
|
||||
:style="{
|
||||
width: `${header.getSize()}px`,
|
||||
}"
|
||||
class="text-left py-3 px-5 font-normal text-sm"
|
||||
:class="headerClass"
|
||||
class="text-left py-3 px-5 dark:bg-slate-800 text-slate-800 dark:text-slate-200 font-normal text-xs"
|
||||
@click="header.column.getCanSort() && header.column.toggleSorting()"
|
||||
>
|
||||
<div
|
||||
@@ -58,12 +43,16 @@ const headerClass = computed(() =>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody class="divide-y divide-n-slate-2">
|
||||
<tr v-for="row in table.getRowModel().rows" :key="row.id">
|
||||
<tbody class="divide-y divide-slate-25 dark:divide-slate-900">
|
||||
<tr
|
||||
v-for="row in table.getRowModel().rows"
|
||||
:key="row.id"
|
||||
class="hover:bg-slate-25 dark:hover:bg-slate-800"
|
||||
>
|
||||
<td
|
||||
v-for="cell in row.getVisibleCells()"
|
||||
:key="cell.id"
|
||||
:class="isRelaxed ? 'py-4 px-5' : 'py-2 px-5'"
|
||||
class="py-2 px-5"
|
||||
>
|
||||
<FlexRender
|
||||
:render="cell.column.columnDef.cell"
|
||||
|
||||
@@ -29,7 +29,7 @@ const spanClass = computed(() => {
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex items-center px-0 py-2 text-xs font-medium text-right uppercase text-n-slate-11 rtl:text-left"
|
||||
class="flex items-center px-0 py-2 text-xs font-medium text-left uppercase text-slate-700 dark:text-slate-100 rtl:text-right"
|
||||
:class="spanClass"
|
||||
>
|
||||
<slot>
|
||||
|
||||
@@ -4,7 +4,7 @@ import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
|
||||
defineProps({
|
||||
user: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
default: () => {},
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
@@ -12,7 +12,7 @@ defineProps({
|
||||
},
|
||||
textClass: {
|
||||
type: String,
|
||||
default: 'text-sm text-n-slate-12',
|
||||
default: 'text-xs text-slate-600',
|
||||
},
|
||||
});
|
||||
</script>
|
||||
@@ -25,11 +25,11 @@ defineProps({
|
||||
:username="user.name"
|
||||
:status="user.availability_status"
|
||||
/>
|
||||
<span
|
||||
class="my-0 overflow-hidden whitespace-nowrap text-ellipsis text-capitalize"
|
||||
<h6
|
||||
class="my-0 dark:text-slate-100 overflow-hidden whitespace-nowrap text-ellipsis text-capitalize"
|
||||
:class="textClass"
|
||||
>
|
||||
{{ user.name }}
|
||||
</span>
|
||||
</h6>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
+2
-2
@@ -18,7 +18,7 @@ const formatDate = timestamp =>
|
||||
<template>
|
||||
<div class="flex justify-between w-full">
|
||||
<span
|
||||
class="text-sm sticky top-0 h-fit font-normal tracking-[-0.6%] min-w-[140px] truncate text-n-slate-11"
|
||||
class="text-sm sticky top-0 h-fit font-normal tracking-[-0.6%] min-w-[140px] truncate text-slate-600 dark:text-slate-200"
|
||||
>
|
||||
{{ label }}
|
||||
</span>
|
||||
@@ -26,7 +26,7 @@ const formatDate = timestamp =>
|
||||
<span
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
class="text-sm font-normal text-n-slate-12 text-right tabular-nums"
|
||||
class="text-sm font-normal text-slate-900 dark:text-slate-25 text-right tabular-nums"
|
||||
>
|
||||
{{ formatDate(item.created_at) }}
|
||||
</span>
|
||||
|
||||
+2
-2
@@ -40,9 +40,9 @@ const toggleShowAllNRT = () => {
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="absolute flex flex-col items-start border-n-strong bg-n-solid-3 w-96 backdrop-blur-[100px] px-6 py-5 z-50 shadow rounded-xl gap-4 max-h-96 overflow-auto"
|
||||
class="absolute flex flex-col items-start bg-white dark:bg-slate-800 z-50 p-4 border border-solid border-slate-75 dark:border-slate-700 w-[384px] rounded-xl gap-4 max-h-96 overflow-auto"
|
||||
>
|
||||
<span class="text-sm font-medium text-n-slate-12">
|
||||
<span class="text-sm font-medium text-slate-900 dark:text-slate-25">
|
||||
{{ $t('SLA.EVENTS.TITLE') }}
|
||||
</span>
|
||||
<SLAEventItem
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -20,7 +20,7 @@ export const ATLEAST_ONE_ACTION_REQUIRED = 'ATLEAST_ONE_ACTION_REQUIRED';
|
||||
*
|
||||
* @returns {string|null} An error message if validation fails, or null if validation passes.
|
||||
*/
|
||||
export const validateSingleFilter = filter => {
|
||||
const validateSingleFilter = filter => {
|
||||
if (!filter.attribute_key) {
|
||||
return ATTRIBUTE_KEY_REQUIRED;
|
||||
}
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
},
|
||||
"DROPDOWN_MENU": {
|
||||
"SEARCH_PLACEHOLDER": "Search...",
|
||||
"EMPTY_STATE": "No results found."
|
||||
"EMPTY_STATE": "No results found.",
|
||||
"SEARCHING": "Searching..."
|
||||
},
|
||||
"DIALOG": {
|
||||
"BUTTONS": {
|
||||
|
||||
@@ -393,6 +393,7 @@
|
||||
"SEARCH_TITLE": "Search contacts",
|
||||
"SEARCH_PLACEHOLDER": "Search...",
|
||||
"MESSAGE_BUTTON": "Message",
|
||||
"SEND_MESSAGE": "Send message",
|
||||
"BREADCRUMB": {
|
||||
"CONTACTS": "Contacts"
|
||||
},
|
||||
@@ -444,8 +445,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"PAGINATION_FOOTER": {
|
||||
"SHOWING": "Showing {startItem} - {endItem} of {totalItems} contacts"
|
||||
},
|
||||
"CARD": {
|
||||
"OF": "of",
|
||||
"VIEW_DETAILS": "View details",
|
||||
"EDIT_DETAILS_FORM": {
|
||||
"TITLE": "Edit contact details",
|
||||
@@ -497,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 contact’s 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",
|
||||
@@ -522,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 contact’s 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>
|
||||
+168
@@ -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>
|
||||
@@ -171,7 +171,7 @@ const table = useVueTable({
|
||||
<template>
|
||||
<section class="flex-1 h-full overflow-auto bg-white dark:bg-slate-900">
|
||||
<section class="overflow-x-auto">
|
||||
<Table fixed :table="table" type="compact" />
|
||||
<Table fixed :table="table" />
|
||||
</section>
|
||||
|
||||
<EmptyState
|
||||
|
||||
@@ -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,5 +1,11 @@
|
||||
<script setup>
|
||||
<script>
|
||||
import WootReports from './components/WootReports.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
WootReports,
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -9,6 +15,5 @@ import WootReports from './components/WootReports.vue';
|
||||
getter-key="agents/getAgents"
|
||||
action-key="agents/get"
|
||||
:download-button-label="$t('REPORT.DOWNLOAD_AGENT_REPORTS')"
|
||||
:report-title="$t('AGENT_REPORTS.HEADER')"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -5,13 +5,11 @@ import ReportFilterSelector from './components/FilterSelector.vue';
|
||||
import { GROUP_BY_FILTER } from './constants';
|
||||
import ReportContainer from './ReportContainer.vue';
|
||||
import { REPORTS_EVENTS } from '../../../../helper/AnalyticsHelper/events';
|
||||
import ReportHeader from './components/ReportHeader.vue';
|
||||
|
||||
export default {
|
||||
name: 'BotReports',
|
||||
components: {
|
||||
BotMetrics,
|
||||
ReportHeader,
|
||||
ReportFilterSelector,
|
||||
ReportContainer,
|
||||
},
|
||||
@@ -86,8 +84,7 @@ export default {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ReportHeader :header-title="$t('BOT_REPORTS.HEADER')" />
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex-1 p-4 overflow-auto">
|
||||
<ReportFilterSelector
|
||||
:show-agents-filter="false"
|
||||
show-group-by-filter
|
||||
|
||||
@@ -7,8 +7,6 @@ import ReportFilterSelector from './components/FilterSelector.vue';
|
||||
import { generateFileName } from '../../../../helper/downloadHelper';
|
||||
import { REPORTS_EVENTS } from '../../../../helper/AnalyticsHelper/events';
|
||||
import { FEATURE_FLAGS } from '../../../../featureFlags';
|
||||
import V4Button from 'dashboard/components-next/button/Button.vue';
|
||||
import ReportHeader from './components/ReportHeader.vue';
|
||||
|
||||
export default {
|
||||
name: 'CsatResponses',
|
||||
@@ -16,8 +14,6 @@ export default {
|
||||
CsatMetrics,
|
||||
CsatTable,
|
||||
ReportFilterSelector,
|
||||
ReportHeader,
|
||||
V4Button,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -112,16 +108,7 @@ export default {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ReportHeader :header-title="$t('CSAT_REPORTS.HEADER')">
|
||||
<V4Button
|
||||
:label="$t('CSAT_REPORTS.DOWNLOAD')"
|
||||
icon="i-ph-download-simple"
|
||||
size="sm"
|
||||
@click="downloadReports"
|
||||
/>
|
||||
</ReportHeader>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex-1 p-4 overflow-auto">
|
||||
<ReportFilterSelector
|
||||
show-agents-filter
|
||||
show-inbox-filter
|
||||
@@ -130,7 +117,14 @@ export default {
|
||||
:show-business-hours-switch="false"
|
||||
@filter-change="onFilterChange"
|
||||
/>
|
||||
|
||||
<woot-button
|
||||
color-scheme="success"
|
||||
class-names="button--fixed-top"
|
||||
icon="arrow-download"
|
||||
@click="downloadReports"
|
||||
>
|
||||
{{ $t('CSAT_REPORTS.DOWNLOAD') }}
|
||||
</woot-button>
|
||||
<CsatMetrics :filters="requestPayload" />
|
||||
<CsatTable :page-index="pageIndex" @page-change="onPageNumberChange" />
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
<script setup>
|
||||
<script>
|
||||
import WootReports from './components/WootReports.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
WootReports,
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -9,6 +15,5 @@ import WootReports from './components/WootReports.vue';
|
||||
getter-key="inboxes/getInboxes"
|
||||
action-key="inboxes/get"
|
||||
:download-button-label="$t('INBOX_REPORTS.DOWNLOAD_INBOX_REPORTS')"
|
||||
:report-title="$t('INBOX_REPORTS.HEADER')"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script>
|
||||
import V4Button from 'dashboard/components-next/button/Button.vue';
|
||||
import { useAlert, useTrack } from 'dashboard/composables';
|
||||
import fromUnixTime from 'date-fns/fromUnixTime';
|
||||
import format from 'date-fns/format';
|
||||
@@ -7,7 +6,6 @@ import ReportFilterSelector from './components/FilterSelector.vue';
|
||||
import { GROUP_BY_FILTER } from './constants';
|
||||
import { REPORTS_EVENTS } from '../../../../helper/AnalyticsHelper/events';
|
||||
import ReportContainer from './ReportContainer.vue';
|
||||
import ReportHeader from './components/ReportHeader.vue';
|
||||
|
||||
const REPORTS_KEYS = {
|
||||
CONVERSATIONS: 'conversations_count',
|
||||
@@ -22,10 +20,8 @@ const REPORTS_KEYS = {
|
||||
export default {
|
||||
name: 'ConversationReports',
|
||||
components: {
|
||||
ReportHeader,
|
||||
ReportFilterSelector,
|
||||
ReportContainer,
|
||||
V4Button,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -102,15 +98,15 @@ export default {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ReportHeader :header-title="$t('REPORT.HEADER')">
|
||||
<V4Button
|
||||
:label="$t('REPORT.DOWNLOAD_AGENT_REPORTS')"
|
||||
icon="i-ph-download-simple"
|
||||
size="sm"
|
||||
<div class="flex-1 p-4 overflow-auto">
|
||||
<woot-button
|
||||
color-scheme="success"
|
||||
class-names="button--fixed-top"
|
||||
icon="arrow-download"
|
||||
@click="downloadAgentReports"
|
||||
/>
|
||||
</ReportHeader>
|
||||
<div class="flex flex-col gap-3">
|
||||
>
|
||||
{{ $t('REPORT.DOWNLOAD_AGENT_REPORTS') }}
|
||||
</woot-button>
|
||||
<ReportFilterSelector
|
||||
:show-agents-filter="false"
|
||||
show-group-by-filter
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
<script setup>
|
||||
<script>
|
||||
import WootReports from './components/WootReports.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
WootReports,
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -9,6 +15,5 @@ import WootReports from './components/WootReports.vue';
|
||||
getter-key="labels/getLabels"
|
||||
action-key="labels/get"
|
||||
:download-button-label="$t('LABEL_REPORTS.DOWNLOAD_LABEL_REPORTS')"
|
||||
:report-title="$t('LABEL_REPORTS.HEADER')"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -10,12 +10,10 @@ import getUnixTime from 'date-fns/getUnixTime';
|
||||
import startOfDay from 'date-fns/startOfDay';
|
||||
import subDays from 'date-fns/subDays';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
import ReportHeader from './components/ReportHeader.vue';
|
||||
|
||||
export default {
|
||||
name: 'LiveReports',
|
||||
components: {
|
||||
ReportHeader,
|
||||
AgentTable,
|
||||
MetricCard,
|
||||
ReportHeatmap,
|
||||
@@ -125,9 +123,8 @@ export default {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ReportHeader :header-title="$t('OVERVIEW_REPORTS.HEADER')" />
|
||||
<div class="flex flex-col gap-4 pb-6">
|
||||
<div class="flex flex-col items-center md:flex-row gap-4">
|
||||
<div class="flex-1 p-4 overflow-auto">
|
||||
<div class="flex flex-col items-center md:flex-row">
|
||||
<div
|
||||
class="flex-1 w-full max-w-full md:w-[65%] md:max-w-[65%] conversation-metric"
|
||||
>
|
||||
@@ -143,10 +140,10 @@ export default {
|
||||
:key="index"
|
||||
class="flex-1 min-w-0 pb-2"
|
||||
>
|
||||
<h3 class="text-base text-n-slate-11">
|
||||
<h3 class="text-base text-slate-700 dark:text-slate-100">
|
||||
{{ name }}
|
||||
</h3>
|
||||
<p class="text-n-slate-12 text-3xl mb-0 mt-1">
|
||||
<p class="text-woot-800 dark:text-woot-300 text-3xl mb-0 mt-1">
|
||||
{{ metric }}
|
||||
</p>
|
||||
</div>
|
||||
@@ -159,17 +156,17 @@ export default {
|
||||
:key="index"
|
||||
class="flex-1 min-w-0 pb-2"
|
||||
>
|
||||
<h3 class="text-base text-n-slate-11">
|
||||
<h3 class="text-base text-slate-700 dark:text-slate-100">
|
||||
{{ name }}
|
||||
</h3>
|
||||
<p class="text-n-slate-12 text-3xl mb-0 mt-1">
|
||||
<p class="text-woot-800 dark:text-woot-300 text-3xl mb-0 mt-1">
|
||||
{{ metric }}
|
||||
</p>
|
||||
</div>
|
||||
</MetricCard>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-row flex-wrap max-w-full">
|
||||
<div class="flex flex-row flex-wrap max-w-full ml-auto mr-auto">
|
||||
<MetricCard :header="$t('OVERVIEW_REPORTS.CONVERSATION_HEATMAP.HEADER')">
|
||||
<template #control>
|
||||
<woot-button
|
||||
@@ -188,7 +185,7 @@ export default {
|
||||
/>
|
||||
</MetricCard>
|
||||
</div>
|
||||
<div class="flex flex-row flex-wrap max-w-full">
|
||||
<div class="flex flex-row flex-wrap max-w-full ml-auto mr-auto">
|
||||
<MetricCard :header="$t('OVERVIEW_REPORTS.AGENT_CONVERSATIONS.HEADER')">
|
||||
<AgentTable
|
||||
:agents="agents"
|
||||
|
||||
@@ -135,7 +135,7 @@ export default {
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-2 px-6 py-5 shadow outline-1 outline outline-n-container rounded-xl bg-n-solid-2"
|
||||
class="grid grid-cols-1 p-2 bg-white border rounded-md md:grid-cols-2 lg:grid-cols-3 dark:bg-slate-800 border-slate-100 dark:border-slate-700"
|
||||
>
|
||||
<div
|
||||
v-for="metric in metrics"
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
<script>
|
||||
import V4Button from 'dashboard/components-next/button/Button.vue';
|
||||
import { mapGetters } from 'vuex';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import SLAMetrics from './components/SLA/SLAMetrics.vue';
|
||||
import SLATable from './components/SLA/SLATable.vue';
|
||||
import SLAReportFilters from './components/SLA/SLAReportFilters.vue';
|
||||
import { generateFileName } from 'dashboard/helper/downloadHelper';
|
||||
import ReportHeader from './components/ReportHeader.vue';
|
||||
export default {
|
||||
name: 'SLAReports',
|
||||
components: {
|
||||
V4Button,
|
||||
ReportHeader,
|
||||
SLAMetrics,
|
||||
SLATable,
|
||||
SLAReportFilters,
|
||||
@@ -81,28 +77,30 @@ export default {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ReportHeader :header-title="$t('SLA_REPORTS.HEADER')">
|
||||
<V4Button
|
||||
:label="$t('SLA_REPORTS.DOWNLOAD_SLA_REPORTS')"
|
||||
icon="i-ph-download-simple"
|
||||
size="sm"
|
||||
@click="downloadReports"
|
||||
/>
|
||||
</ReportHeader>
|
||||
<div class="flex flex-col flex-1 gap-6">
|
||||
<div class="flex flex-col flex-1 gap-6 px-4 pt-4 overflow-auto">
|
||||
<SLAReportFilters @filter-change="onFilterChange" />
|
||||
<SLAMetrics
|
||||
:hit-rate="slaMetrics.hitRate"
|
||||
:no-of-breaches="slaMetrics.numberOfSLAMisses"
|
||||
:no-of-conversations="slaMetrics.numberOfConversations"
|
||||
:is-loading="uiFlags.isFetchingMetrics"
|
||||
/>
|
||||
<SLATable
|
||||
:sla-reports="slaReports"
|
||||
:is-loading="uiFlags.isFetching"
|
||||
:current-page="Number(slaMeta.currentPage)"
|
||||
:total-count="Number(slaMeta.count)"
|
||||
@page-change="onPageChange"
|
||||
/>
|
||||
<woot-button
|
||||
color-scheme="success"
|
||||
class-names="button--fixed-top"
|
||||
icon="arrow-download"
|
||||
@click="downloadReports"
|
||||
>
|
||||
{{ $t('SLA_REPORTS.DOWNLOAD_SLA_REPORTS') }}
|
||||
</woot-button>
|
||||
<div class="flex flex-col gap-6">
|
||||
<SLAMetrics
|
||||
:hit-rate="slaMetrics.hitRate"
|
||||
:no-of-breaches="slaMetrics.numberOfSLAMisses"
|
||||
:no-of-conversations="slaMetrics.numberOfConversations"
|
||||
:is-loading="uiFlags.isFetchingMetrics"
|
||||
/>
|
||||
<SLATable
|
||||
:sla-reports="slaReports"
|
||||
:is-loading="uiFlags.isFetching"
|
||||
:current-page="Number(slaMeta.currentPage)"
|
||||
:total-count="Number(slaMeta.count)"
|
||||
@page-change="onPageChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
<script setup>
|
||||
<script>
|
||||
import WootReports from './components/WootReports.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
WootReports,
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -9,6 +15,5 @@ import WootReports from './components/WootReports.vue';
|
||||
getter-key="teams/getTeams"
|
||||
action-key="teams/get"
|
||||
:download-button-label="$t('TEAM_REPORTS.DOWNLOAD_TEAM_REPORTS')"
|
||||
:report-title="$t('TEAM_REPORTS.HEADER')"
|
||||
/>
|
||||
</template>
|
||||
|
||||
+1
-1
@@ -38,7 +38,7 @@ onMounted(fetchMetrics);
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-wrap mx-0 shadow outline-1 outline outline-n-container rounded-xl bg-n-solid-2 px-6 py-5"
|
||||
class="flex flex-wrap mx-0 bg-white dark:bg-slate-800 rounded-[4px] p-4 mb-5 border border-solid border-slate-75 dark:border-slate-700"
|
||||
>
|
||||
<ReportMetricCard
|
||||
:label="$t('BOT_REPORTS.METRIC.TOTAL_CONVERSATIONS.LABEL')"
|
||||
|
||||
+2
-2
@@ -29,11 +29,11 @@ const trendColor = (value, key) => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="text-n-slate-11">
|
||||
<div class="text-slate-900 dark:text-slate-100">
|
||||
<span class="text-sm">
|
||||
{{ metric.NAME }}
|
||||
</span>
|
||||
<div class="flex items-end text-n-slate-12">
|
||||
<div class="flex items-end">
|
||||
<div class="text-xl font-medium">
|
||||
{{ displayMetric(metric.KEY) }}
|
||||
</div>
|
||||
|
||||
+3
-3
@@ -86,7 +86,7 @@ export default {
|
||||
<!-- Added ref for writing specs -->
|
||||
<template>
|
||||
<div
|
||||
class="flex-col lg:flex-row flex flex-wrap mx-0 shadow outline-1 outline outline-n-container rounded-xl bg-n-solid-2 px-6 py-8 gap-4"
|
||||
class="flex-col lg:flex-row flex flex-wrap mx-0 bg-white dark:bg-slate-800 rounded-[4px] p-4 mb-5 border border-solid border-slate-75 dark:border-slate-700"
|
||||
>
|
||||
<CsatMetricCard
|
||||
:label="$t('CSAT_REPORTS.METRIC.TOTAL_RESPONSES.LABEL')"
|
||||
@@ -111,10 +111,10 @@ export default {
|
||||
<div
|
||||
v-if="metrics.totalResponseCount && !ratingFilterEnabled"
|
||||
ref="csatBarChart"
|
||||
class="w-full md:w-1/2 md:max-w-[50%] flex-1 rtl:[direction:initial]"
|
||||
class="w-full md:w-1/2 md:max-w-[50%] flex-1 rtl:[direction:initial] p-4"
|
||||
>
|
||||
<h3
|
||||
class="flex items-center m-0 text-xs font-medium md:text-sm text-n-slate-12"
|
||||
class="flex items-center m-0 text-xs font-medium md:text-sm text-slate-800 dark:text-slate-100"
|
||||
>
|
||||
<div class="flex flex-row-reverse justify-end">
|
||||
<div
|
||||
|
||||
+20
-5
@@ -145,13 +145,14 @@ const table = useVueTable({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="shadow outline-1 outline outline-n-container rounded-xl bg-n-solid-2 px-6 py-5"
|
||||
>
|
||||
<Table :table="table" class="max-h-[calc(100vh-21.875rem)]" />
|
||||
<div class="csat--table-container">
|
||||
<Table
|
||||
:table="table"
|
||||
class="max-h-[calc(100vh-21.875rem)] border bg-white dark:bg-slate-900 border-slate-50 dark:border-slate-800"
|
||||
/>
|
||||
<div
|
||||
v-show="!tableData.length"
|
||||
class="h-48 flex items-center justify-center text-n-slate-12 text-sm"
|
||||
class="csat--empty-records text-slate-600 dark:text-slate-200 bg-white dark:bg-slate-900 border border-t-0 border-solid border-slate-75 dark:border-slate-700"
|
||||
>
|
||||
{{ $t('CSAT_REPORTS.NO_RECORDS') }}
|
||||
</div>
|
||||
@@ -160,3 +161,17 @@ const table = useVueTable({
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.csat--empty-records {
|
||||
align-items: center;
|
||||
// border: 1px solid var(--color-border);
|
||||
border-top: 0;
|
||||
display: flex;
|
||||
font-size: var(--font-size-small);
|
||||
height: 12.5rem;
|
||||
justify-content: center;
|
||||
margin-top: -1px;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
+1
-1
@@ -178,7 +178,7 @@ export default {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col justify-between gap-3 md:flex-row">
|
||||
<div class="flex flex-col justify-between gap-3 mb-4 md:flex-row">
|
||||
<div
|
||||
class="w-full grid gap-y-2 gap-x-1.5 grid-cols-[repeat(auto-fill,minmax(250px,1fr))]"
|
||||
>
|
||||
|
||||
+1
@@ -53,6 +53,7 @@ const closeDropdown = () => emit('closeDropdown');
|
||||
<FilterButton
|
||||
right-icon="chevron-down"
|
||||
:button-text="name"
|
||||
class="bg-slate-50 dark:bg-slate-800 hover:bg-slate-75 dark:hover:bg-slate-800"
|
||||
@click="toggleDropdown"
|
||||
>
|
||||
<template v-if="showMenu && activeFilterType === type" #dropdown>
|
||||
|
||||
@@ -63,7 +63,8 @@ function getDayOfTheWeek(date) {
|
||||
return days[dayIndex];
|
||||
}
|
||||
function getHeatmapLevelClass(value) {
|
||||
if (!value) return 'outline-n-container dark:bg-slate-700/40 bg-slate-50/50';
|
||||
if (!value)
|
||||
return 'outline-slate-100 dark:outline-slate-700 dark:bg-slate-700/40 bg-slate-50/50';
|
||||
|
||||
let level = [...quantileRange.value, Infinity].findIndex(
|
||||
range => value <= range && value > 0
|
||||
@@ -72,7 +73,7 @@ function getHeatmapLevelClass(value) {
|
||||
if (level > 6) level = 5;
|
||||
|
||||
if (level === 0) {
|
||||
return 'outline-n-container dark:bg-slate-700/40 bg-slate-50/50';
|
||||
return 'outline-slate-100 dark:outline-slate-700 dark:bg-slate-700/40 bg-slate-50/50';
|
||||
}
|
||||
|
||||
const classes = [
|
||||
|
||||
-17
@@ -1,17 +0,0 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
headerTitle: {
|
||||
required: true,
|
||||
type: String,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center justify-between w-full h-20 gap-2">
|
||||
<span class="text-xl font-medium text-n-slate-12">
|
||||
{{ headerTitle }}
|
||||
</span>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
+6
-3
@@ -22,23 +22,26 @@ defineProps({
|
||||
<template>
|
||||
<div
|
||||
data-test-id="reportMetricContainer"
|
||||
class="p-4 m-0"
|
||||
:class="{
|
||||
'grayscale pointer-events-none opacity-30': disabled,
|
||||
}"
|
||||
>
|
||||
<h3 class="flex items-center m-0 text-sm font-medium text-n-slate-11">
|
||||
<h3
|
||||
class="flex items-center m-0 text-sm font-medium text-slate-800 dark:text-slate-100"
|
||||
>
|
||||
<span data-test-id="reportMetricLabel">{{ label }}</span>
|
||||
<fluent-icon
|
||||
v-tooltip="infoText"
|
||||
data-test-id="reportMetricInfo"
|
||||
size="14"
|
||||
icon="info"
|
||||
class="text-n-slate-11 my-0 mx-1 mt-0.5"
|
||||
class="text-slate-500 dark:text-slate-200 my-0 mx-1 mt-0.5"
|
||||
/>
|
||||
</h3>
|
||||
<h4
|
||||
data-test-id="reportMetricValue"
|
||||
class="mt-1 mb-0 text-2xl text-n-slate-12"
|
||||
class="mt-1 mb-0 text-3xl font-thin text-slate-700 dark:text-slate-100"
|
||||
>
|
||||
{{ value }}
|
||||
</h4>
|
||||
|
||||
-85
@@ -1,85 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
class="reports--wrapper overflow-auto bg-n-background w-full px-8 xl:px-0"
|
||||
>
|
||||
<div class="max-w-[960px] mx-auto pb-12">
|
||||
<router-view />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.reports--wrapper {
|
||||
::v-deep {
|
||||
.multiselect--disabled {
|
||||
@apply opacity-50 border border-n-weak rounded-md cursor-not-allowed;
|
||||
}
|
||||
|
||||
.multiselect__content-wrapper {
|
||||
@apply bg-n-solid-2 border border-n-weak text-n-slate-12;
|
||||
}
|
||||
|
||||
.multiselect__tags {
|
||||
@apply bg-n-slate-1 border border-n-weak m-0 min-h-[2.875rem] pt-0;
|
||||
|
||||
input[type='text'] {
|
||||
@apply bg-n-alpha-3 border-n-weak !min-h-[2.375rem] !h-[2.375rem] !ps-0.5 !py-0 !text-sm;
|
||||
}
|
||||
}
|
||||
|
||||
.multiselect__placeholder {
|
||||
@apply text-n-slate-11;
|
||||
}
|
||||
|
||||
.multiselect__select {
|
||||
@apply min-h-0;
|
||||
}
|
||||
|
||||
.multiselect__single {
|
||||
@apply bg-n-alpha-3 text-n-slate-11;
|
||||
}
|
||||
|
||||
.multiselect__input {
|
||||
@apply text-sm !h-[2.375rem] mb-0 !py-0;
|
||||
}
|
||||
|
||||
.multiselect__tags,
|
||||
.multiselect__input,
|
||||
.multiselect {
|
||||
@apply bg-n-alpha-3 !border-n-weak text-n-slate-12 rounded-lg text-sm min-h-[2.5rem];
|
||||
}
|
||||
|
||||
.mx-input-wrapper {
|
||||
@apply bg-n-alpha-3 !border-n-weak text-n-slate-12 rounded-lg text-sm;
|
||||
|
||||
input {
|
||||
@apply border-n-weak text-sm;
|
||||
}
|
||||
}
|
||||
|
||||
.multiselect__option {
|
||||
@apply flex items-center;
|
||||
}
|
||||
|
||||
.mx-datepicker {
|
||||
.mx-input {
|
||||
@apply bg-n-alpha-3;
|
||||
}
|
||||
|
||||
.mx-input-wrapper input::placeholder {
|
||||
@apply text-n-slate-11;
|
||||
}
|
||||
|
||||
.mx-input-wrapper input {
|
||||
@apply text-n-slate-11;
|
||||
}
|
||||
}
|
||||
|
||||
.multiselect--active:not(.multiselect--above) .multiselect__current,
|
||||
.multiselect--active:not(.multiselect--above) .multiselect__input,
|
||||
.multiselect--active:not(.multiselect--above) .multiselect__tags {
|
||||
@apply rounded-b-none;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
+4
-4
@@ -24,7 +24,7 @@ export default {
|
||||
<template>
|
||||
<div class="flex flex-col gap-2 items-start justify-center min-w-[10rem]">
|
||||
<span
|
||||
class="inline-flex items-center gap-1 text-sm font-medium text-n-slate-11"
|
||||
class="inline-flex items-center gap-1 text-sm font-medium text-slate-700 dark:text-slate-200"
|
||||
>
|
||||
{{ label }}
|
||||
<fluent-icon
|
||||
@@ -32,15 +32,15 @@ export default {
|
||||
size="14"
|
||||
icon="information"
|
||||
type="outline"
|
||||
class="flex flex-shrink-0 text-sm font-normal sm:font-medium text-n-slate-10"
|
||||
class="flex flex-shrink-0 text-sm font-normal sm:font-medium text-slate-500 dark:text-slate-500"
|
||||
/>
|
||||
</span>
|
||||
<div
|
||||
v-if="isLoading"
|
||||
class="w-12 h-6 mb-0.5 rounded-md bg-n-slate-3 animate-pulse"
|
||||
class="w-12 h-6 mb-0.5 rounded-md bg-slate-50 dark:bg-slate-800 animate-pulse"
|
||||
/>
|
||||
|
||||
<span v-else class="text-2xl font-medium text-n-slate-12">
|
||||
<span v-else class="text-2xl font-medium text-slate-900 dark:text-slate-25">
|
||||
{{ value }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
+7
-3
@@ -22,7 +22,7 @@ defineProps({
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex sm:flex-row flex-col w-full gap-4 sm:gap-14 shadow outline-1 outline outline-n-container rounded-xl bg-n-solid-2 px-6 py-5"
|
||||
class="flex sm:flex-row flex-col w-full gap-4 sm:gap-14 bg-white dark:bg-slate-900 rounded-xl border border-slate-75 dark:border-slate-700/50 px-6 py-4"
|
||||
>
|
||||
<SLAMetricCard
|
||||
:label="$t('SLA_REPORTS.METRICS.HIT_RATE.LABEL')"
|
||||
@@ -31,14 +31,18 @@ defineProps({
|
||||
:is-loading="isLoading"
|
||||
/>
|
||||
|
||||
<div class="w-full sm:w-px bg-n-strong" />
|
||||
<div
|
||||
class="w-full sm:w-px h-full border border-slate-75 dark:border-slate-700/50"
|
||||
/>
|
||||
<SLAMetricCard
|
||||
:label="$t('SLA_REPORTS.METRICS.NO_OF_MISSES.LABEL')"
|
||||
:value="noOfBreaches"
|
||||
:tool-tip="$t('SLA_REPORTS.METRICS.NO_OF_MISSES.TOOLTIP')"
|
||||
:is-loading="isLoading"
|
||||
/>
|
||||
<div class="w-full sm:w-px bg-n-strong" />
|
||||
<div
|
||||
class="w-full sm:w-px h-full border border-slate-75 dark:border-slate-700/50"
|
||||
/>
|
||||
<SLAMetricCard
|
||||
:label="$t('SLA_REPORTS.METRICS.NO_OF_CONVERSATIONS.LABEL')"
|
||||
:value="noOfConversations"
|
||||
|
||||
+6
-7
@@ -31,23 +31,22 @@ const conversationLabels = computed(() => {
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="grid items-center content-center w-full h-16 grid-cols-12 gap-4 px-6 py-0 border-b last:border-b-0 last:rounded-b-xl border-n-weak"
|
||||
class="grid items-center content-center w-full h-16 grid-cols-12 gap-4 px-6 py-0 bg-white border-b last:border-b-0 last:rounded-b-xl border-slate-75 dark:border-slate-800/50 dark:bg-slate-900"
|
||||
>
|
||||
<div
|
||||
class="flex items-center gap-2 col-span-6 px-0 py-2 text-sm tracking-[0.5] text-slate-700 dark:text-slate-100 rtl:text-right"
|
||||
>
|
||||
<span class="text-n-slate-12">
|
||||
<span class="text-slate-700 dark:text-slate-200">
|
||||
{{ `#${conversationId} ` }}
|
||||
</span>
|
||||
<span class="text-slate-11">
|
||||
<span class="text-slate-600 dark:text-slate-300">
|
||||
{{ $t('SLA_REPORTS.WITH') }}
|
||||
</span>
|
||||
<span class="capitalize truncate text-n-slate-12">{{
|
||||
<span class="capitalize truncate text-slate-700 dark:text-slate-200">{{
|
||||
conversation.contact.name
|
||||
}}</span>
|
||||
<CardLabels
|
||||
v-if="conversationLabels.length"
|
||||
class="w-[60%]"
|
||||
class="w-[80%]"
|
||||
:conversation-id="conversationId"
|
||||
:conversation-labels="conversationLabels"
|
||||
/>
|
||||
@@ -62,7 +61,7 @@ const conversationLabels = computed(() => {
|
||||
v-if="conversation.assignee"
|
||||
:user="conversation.assignee"
|
||||
/>
|
||||
<span v-else class="text-n-slate-11"> --- </span>
|
||||
<span v-else class="text-slate-600 dark:text-slate-200"> --- </span>
|
||||
</div>
|
||||
<SLAViewDetails :sla-events="slaEvents" />
|
||||
</div>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user