Restructure omni services and add Chatwoot research snapshot
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStore } from 'vuex';
|
||||
import { useAlert, useTrack } from 'dashboard/composables';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
|
||||
import MergeContact from 'dashboard/modules/contact/components/MergeContact.vue';
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
import ContactAPI from 'dashboard/api/contacts';
|
||||
import { CONTACTS_EVENTS } from '../../helper/AnalyticsHelper/events';
|
||||
|
||||
const props = defineProps({
|
||||
primaryContact: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['close']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
const uiFlags = useMapGetter('contacts/getUIFlags');
|
||||
|
||||
const dialogRef = ref(null);
|
||||
const isSearching = ref(false);
|
||||
const searchResults = ref([]);
|
||||
|
||||
watch(
|
||||
() => props.primaryContact.id,
|
||||
() => {
|
||||
isSearching.value = false;
|
||||
searchResults.value = [];
|
||||
}
|
||||
);
|
||||
|
||||
const open = () => {
|
||||
dialogRef.value?.open();
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
dialogRef.value?.close();
|
||||
};
|
||||
|
||||
defineExpose({ open, close });
|
||||
|
||||
const onClose = () => {
|
||||
close();
|
||||
emit('close');
|
||||
};
|
||||
|
||||
const onContactSearch = async query => {
|
||||
isSearching.value = true;
|
||||
searchResults.value = [];
|
||||
|
||||
try {
|
||||
const {
|
||||
data: { payload },
|
||||
} = await ContactAPI.search(query);
|
||||
searchResults.value = payload.filter(
|
||||
contact => contact.id !== props.primaryContact.id
|
||||
);
|
||||
} catch (error) {
|
||||
useAlert(t('MERGE_CONTACTS.SEARCH.ERROR_MESSAGE'));
|
||||
} finally {
|
||||
isSearching.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const onMergeContacts = async parentContactId => {
|
||||
useTrack(CONTACTS_EVENTS.MERGED_CONTACTS);
|
||||
try {
|
||||
await store.dispatch('contacts/merge', {
|
||||
childId: props.primaryContact.id,
|
||||
parentId: parentContactId,
|
||||
});
|
||||
useAlert(t('MERGE_CONTACTS.FORM.SUCCESS_MESSAGE'));
|
||||
close();
|
||||
emit('close');
|
||||
} catch (error) {
|
||||
useAlert(t('MERGE_CONTACTS.FORM.ERROR_MESSAGE'));
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
ref="dialogRef"
|
||||
type="edit"
|
||||
width="2xl"
|
||||
:title="$t('MERGE_CONTACTS.TITLE')"
|
||||
:description="$t('MERGE_CONTACTS.DESCRIPTION')"
|
||||
:show-cancel-button="false"
|
||||
:show-confirm-button="false"
|
||||
>
|
||||
<MergeContact
|
||||
:key="primaryContact.id"
|
||||
:primary-contact="primaryContact"
|
||||
:is-searching="isSearching"
|
||||
:is-merging="uiFlags.isMerging"
|
||||
:search-results="searchResults"
|
||||
@search="onContactSearch"
|
||||
@cancel="onClose"
|
||||
@submit="onMergeContacts"
|
||||
/>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -0,0 +1,122 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { required } from '@vuelidate/validators';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import MergeContactSummary from 'dashboard/modules/contact/components/MergeContactSummary.vue';
|
||||
import ContactMergeForm from 'dashboard/components-next/Contacts/ContactsForm/ContactMergeForm.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const props = defineProps({
|
||||
primaryContact: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
isSearching: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isMerging: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
searchResults: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['search', 'submit', 'cancel']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const parentContactId = ref(null);
|
||||
|
||||
const validationRules = {
|
||||
parentContactId: { required },
|
||||
};
|
||||
|
||||
const v$ = useVuelidate(validationRules, { parentContactId });
|
||||
|
||||
const parentContact = computed(() => {
|
||||
if (!parentContactId.value) return null;
|
||||
return props.searchResults.find(
|
||||
contact => contact.id === parentContactId.value
|
||||
);
|
||||
});
|
||||
|
||||
const parentContactName = computed(() => {
|
||||
return parentContact.value ? parentContact.value.name : '';
|
||||
});
|
||||
|
||||
const primaryContactList = computed(() => {
|
||||
return props.searchResults.map(contact => ({
|
||||
id: contact.id,
|
||||
label: contact.name,
|
||||
value: contact.id,
|
||||
meta: {
|
||||
thumbnail: contact.thumbnail,
|
||||
email: contact.email,
|
||||
phoneNumber: contact.phone_number,
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
const hasValidationError = computed(() => v$.value.parentContactId.$error);
|
||||
const validationErrorMessage = computed(() => {
|
||||
if (v$.value.parentContactId.$error) {
|
||||
return t('MERGE_CONTACTS.FORM.CHILD_CONTACT.ERROR');
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
const onSearch = query => {
|
||||
emit('search', query);
|
||||
};
|
||||
|
||||
const onSubmit = () => {
|
||||
v$.value.$touch();
|
||||
if (v$.value.$invalid) {
|
||||
return;
|
||||
}
|
||||
emit('submit', parentContactId.value);
|
||||
};
|
||||
|
||||
const onCancel = () => {
|
||||
emit('cancel');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form @submit.prevent="onSubmit">
|
||||
<ContactMergeForm
|
||||
:selected-contact="primaryContact"
|
||||
:primary-contact-id="parentContactId"
|
||||
:primary-contact-list="primaryContactList"
|
||||
:is-searching="isSearching"
|
||||
:has-error="hasValidationError"
|
||||
:error-message="validationErrorMessage"
|
||||
@update:primary-contact-id="parentContactId = $event"
|
||||
@search="onSearch"
|
||||
/>
|
||||
<MergeContactSummary
|
||||
:primary-contact-name="primaryContact.name"
|
||||
:parent-contact-name="parentContactName"
|
||||
/>
|
||||
<div class="flex justify-end gap-2 mt-6">
|
||||
<NextButton
|
||||
faded
|
||||
slate
|
||||
type="reset"
|
||||
:label="$t('MERGE_CONTACTS.FORM.CANCEL')"
|
||||
@click.prevent="onCancel"
|
||||
/>
|
||||
<NextButton
|
||||
type="submit"
|
||||
:is-loading="isMerging"
|
||||
:label="$t('MERGE_CONTACTS.FORM.SUBMIT')"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
@@ -0,0 +1,49 @@
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
primaryContactName: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
parentContactName: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<!-- eslint-disable-next-line vue/no-root-v-if -->
|
||||
<template>
|
||||
<div
|
||||
v-if="parentContactName"
|
||||
class="my-4 relative p-2.5 border rounded-[4px] text-n-slate-12 border-n-weak bg-n-background"
|
||||
>
|
||||
<h5 class="text-base font-medium text-n-slate-12">
|
||||
{{ $t('MERGE_CONTACTS.SUMMARY.TITLE') }}
|
||||
</h5>
|
||||
<ul class="ml-0 list-none">
|
||||
<li>
|
||||
<span class="inline-block mr-1">❌</span>
|
||||
<span
|
||||
v-dompurify-html="
|
||||
$t('MERGE_CONTACTS.SUMMARY.DELETE_WARNING', {
|
||||
primaryContactName,
|
||||
})
|
||||
"
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<span class="inline-block mr-1">✅</span>
|
||||
<span
|
||||
v-dompurify-html="
|
||||
$t('MERGE_CONTACTS.SUMMARY.ATTRIBUTE_WARNING', {
|
||||
primaryContactName,
|
||||
parentContactName,
|
||||
})
|
||||
"
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,285 @@
|
||||
<script>
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { mapGetters } from 'vuex';
|
||||
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
|
||||
import ContextMenu from 'dashboard/components/ui/ContextMenu.vue';
|
||||
import AddCannedModal from 'dashboard/routes/dashboard/settings/canned/AddCanned.vue';
|
||||
import { useSnakeCase } from 'dashboard/composables/useTransformKeys';
|
||||
import { copyTextToClipboard } from 'shared/helpers/clipboard';
|
||||
import { conversationUrl, frontendURL } from '../../../helper/URLHelper';
|
||||
import {
|
||||
ACCOUNT_EVENTS,
|
||||
CONVERSATION_EVENTS,
|
||||
} from '../../../helper/AnalyticsHelper/events';
|
||||
import MenuItem from '../../../components/widgets/conversation/contextMenu/menuItem.vue';
|
||||
import { useTrack } from 'dashboard/composables';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
AddCannedModal,
|
||||
MenuItem,
|
||||
ContextMenu,
|
||||
NextButton,
|
||||
},
|
||||
props: {
|
||||
message: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
isOpen: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
enabledOptions: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
contextMenuPosition: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
hideButton: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: ['open', 'close', 'replyTo'],
|
||||
setup() {
|
||||
const { getPlainText } = useMessageFormatter();
|
||||
|
||||
return {
|
||||
getPlainText,
|
||||
};
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isCannedResponseModalOpen: false,
|
||||
showDeleteModal: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
getAccount: 'accounts/getAccount',
|
||||
currentAccountId: 'getCurrentAccountId',
|
||||
getUISettings: 'getUISettings',
|
||||
}),
|
||||
plainTextContent() {
|
||||
return this.getPlainText(this.messageContent);
|
||||
},
|
||||
conversationId() {
|
||||
return this.message.conversation_id ?? this.message.conversationId;
|
||||
},
|
||||
messageId() {
|
||||
return this.message.id;
|
||||
},
|
||||
messageContent() {
|
||||
return this.message.content;
|
||||
},
|
||||
contentAttributes() {
|
||||
return useSnakeCase(
|
||||
this.message.content_attributes ?? this.message.contentAttributes
|
||||
);
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
async copyLinkToMessage() {
|
||||
const fullConversationURL =
|
||||
window.chatwootConfig.hostURL +
|
||||
frontendURL(
|
||||
conversationUrl({
|
||||
id: this.conversationId,
|
||||
accountId: this.currentAccountId,
|
||||
})
|
||||
);
|
||||
await copyTextToClipboard(
|
||||
`${fullConversationURL}?messageId=${this.messageId}`
|
||||
);
|
||||
useAlert(this.$t('CONVERSATION.CONTEXT_MENU.LINK_COPIED'));
|
||||
this.handleClose();
|
||||
},
|
||||
async handleCopy() {
|
||||
await copyTextToClipboard(this.plainTextContent);
|
||||
useAlert(this.$t('CONTACT_PANEL.COPY_SUCCESSFUL'));
|
||||
this.handleClose();
|
||||
},
|
||||
showCannedResponseModal() {
|
||||
useTrack(ACCOUNT_EVENTS.ADDED_TO_CANNED_RESPONSE);
|
||||
this.isCannedResponseModalOpen = true;
|
||||
},
|
||||
hideCannedResponseModal() {
|
||||
this.isCannedResponseModalOpen = false;
|
||||
this.handleClose();
|
||||
},
|
||||
handleOpen(e) {
|
||||
this.$emit('open', e);
|
||||
},
|
||||
handleClose(e) {
|
||||
this.$emit('close', e);
|
||||
},
|
||||
handleTranslate() {
|
||||
const { locale: accountLocale } = this.getAccount(this.currentAccountId);
|
||||
const agentLocale = this.getUISettings?.locale;
|
||||
const targetLanguage = agentLocale || accountLocale || 'en';
|
||||
this.$store.dispatch('translateMessage', {
|
||||
conversationId: this.conversationId,
|
||||
messageId: this.messageId,
|
||||
targetLanguage,
|
||||
});
|
||||
useTrack(CONVERSATION_EVENTS.TRANSLATE_A_MESSAGE);
|
||||
this.handleClose();
|
||||
},
|
||||
handleReplyTo() {
|
||||
this.$emit('replyTo', this.message);
|
||||
this.handleClose();
|
||||
},
|
||||
openDeleteModal() {
|
||||
this.handleClose();
|
||||
this.showDeleteModal = true;
|
||||
},
|
||||
async confirmDeletion() {
|
||||
try {
|
||||
await this.$store.dispatch('deleteMessage', {
|
||||
conversationId: this.conversationId,
|
||||
messageId: this.messageId,
|
||||
});
|
||||
useAlert(this.$t('CONVERSATION.SUCCESS_DELETE_MESSAGE'));
|
||||
this.handleClose();
|
||||
} catch (error) {
|
||||
useAlert(this.$t('CONVERSATION.FAIL_DELETE_MESSSAGE'));
|
||||
}
|
||||
},
|
||||
closeDeleteModal() {
|
||||
this.showDeleteModal = false;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="context-menu">
|
||||
<!-- Add To Canned Responses -->
|
||||
<woot-modal
|
||||
v-if="isCannedResponseModalOpen && enabledOptions['cannedResponse']"
|
||||
v-model:show="isCannedResponseModalOpen"
|
||||
:on-close="hideCannedResponseModal"
|
||||
>
|
||||
<AddCannedModal
|
||||
:response-content="plainTextContent"
|
||||
:on-close="hideCannedResponseModal"
|
||||
/>
|
||||
</woot-modal>
|
||||
<!-- Confirm Deletion -->
|
||||
<woot-delete-modal
|
||||
v-if="showDeleteModal && enabledOptions['delete']"
|
||||
v-model:show="showDeleteModal"
|
||||
class="context-menu--delete-modal"
|
||||
:on-close="closeDeleteModal"
|
||||
:on-confirm="confirmDeletion"
|
||||
:title="$t('CONVERSATION.CONTEXT_MENU.DELETE_CONFIRMATION.TITLE')"
|
||||
:message="$t('CONVERSATION.CONTEXT_MENU.DELETE_CONFIRMATION.MESSAGE')"
|
||||
:confirm-text="$t('CONVERSATION.CONTEXT_MENU.DELETE_CONFIRMATION.DELETE')"
|
||||
:reject-text="$t('CONVERSATION.CONTEXT_MENU.DELETE_CONFIRMATION.CANCEL')"
|
||||
/>
|
||||
<NextButton
|
||||
v-if="!hideButton"
|
||||
ghost
|
||||
slate
|
||||
sm
|
||||
icon="i-lucide-ellipsis-vertical"
|
||||
class="invisible group-hover/context-menu:visible"
|
||||
@click="handleOpen"
|
||||
/>
|
||||
<ContextMenu
|
||||
v-if="isOpen && !isCannedResponseModalOpen"
|
||||
:x="contextMenuPosition.x"
|
||||
:y="contextMenuPosition.y"
|
||||
@close="handleClose"
|
||||
>
|
||||
<div class="menu-container">
|
||||
<MenuItem
|
||||
v-if="enabledOptions['replyTo']"
|
||||
:option="{
|
||||
icon: 'arrow-reply',
|
||||
label: $t('CONVERSATION.CONTEXT_MENU.REPLY_TO'),
|
||||
}"
|
||||
variant="icon"
|
||||
@click.stop="handleReplyTo"
|
||||
/>
|
||||
<MenuItem
|
||||
v-if="enabledOptions['copy']"
|
||||
:option="{
|
||||
icon: 'clipboard',
|
||||
label: $t('CONVERSATION.CONTEXT_MENU.COPY'),
|
||||
}"
|
||||
variant="icon"
|
||||
@click.stop="handleCopy"
|
||||
/>
|
||||
<MenuItem
|
||||
v-if="enabledOptions['translate']"
|
||||
:option="{
|
||||
icon: 'translate',
|
||||
label: $t('CONVERSATION.CONTEXT_MENU.TRANSLATE'),
|
||||
}"
|
||||
variant="icon"
|
||||
@click.stop="handleTranslate"
|
||||
/>
|
||||
<hr />
|
||||
<MenuItem
|
||||
v-if="enabledOptions['copyLink']"
|
||||
:option="{
|
||||
icon: 'link',
|
||||
label: $t('CONVERSATION.CONTEXT_MENU.COPY_PERMALINK'),
|
||||
}"
|
||||
variant="icon"
|
||||
@click.stop="copyLinkToMessage"
|
||||
/>
|
||||
<MenuItem
|
||||
v-if="enabledOptions['cannedResponse']"
|
||||
:option="{
|
||||
icon: 'comment-add',
|
||||
label: $t('CONVERSATION.CONTEXT_MENU.CREATE_A_CANNED_RESPONSE'),
|
||||
}"
|
||||
variant="icon"
|
||||
@click.stop="showCannedResponseModal"
|
||||
/>
|
||||
<hr v-if="enabledOptions['delete']" />
|
||||
<MenuItem
|
||||
v-if="enabledOptions['delete']"
|
||||
:option="{
|
||||
icon: 'delete',
|
||||
label: $t('CONVERSATION.CONTEXT_MENU.DELETE'),
|
||||
}"
|
||||
variant="icon"
|
||||
@click.stop="openDeleteModal"
|
||||
/>
|
||||
</div>
|
||||
</ContextMenu>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.menu-container {
|
||||
@apply p-1 bg-n-background shadow-xl rounded-md;
|
||||
|
||||
hr:first-child {
|
||||
@apply hidden;
|
||||
}
|
||||
|
||||
hr {
|
||||
@apply m-1 border-b border-solid border-n-strong;
|
||||
}
|
||||
}
|
||||
|
||||
.context-menu--delete-modal {
|
||||
::v-deep {
|
||||
.modal-container {
|
||||
@apply max-w-[30rem];
|
||||
|
||||
h2 {
|
||||
@apply font-medium text-base;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,115 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
|
||||
import { useExpandableContent } from 'shared/composables/useExpandableContent';
|
||||
|
||||
const props = defineProps({
|
||||
author: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
message: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
searchTerm: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
const { highlightContent } = useMessageFormatter();
|
||||
|
||||
const { contentElement, showReadMore, showReadLess, toggleExpanded } =
|
||||
useExpandableContent();
|
||||
|
||||
const messageContent = computed(() => {
|
||||
// We perform search on either content or email subject or transcribed text
|
||||
if (props.message.content) {
|
||||
return props.message.content;
|
||||
}
|
||||
|
||||
const { content_attributes = {} } = props.message;
|
||||
const { email = {} } = content_attributes || {};
|
||||
if (email.subject) {
|
||||
return email.subject;
|
||||
}
|
||||
|
||||
const audioAttachment = props.message.attachments?.find(
|
||||
attachment => attachment.file_type === 'audio'
|
||||
);
|
||||
return audioAttachment?.transcribed_text || '';
|
||||
});
|
||||
|
||||
const escapeHtml = html => {
|
||||
const wrapper = document.createElement('p');
|
||||
wrapper.textContent = html;
|
||||
return wrapper.textContent;
|
||||
};
|
||||
|
||||
const highlightedContent = computed(() => {
|
||||
const content = messageContent.value || '';
|
||||
const escapedText = escapeHtml(content);
|
||||
return highlightContent(
|
||||
escapedText,
|
||||
props.searchTerm,
|
||||
'searchkey--highlight'
|
||||
);
|
||||
});
|
||||
|
||||
const authorText = computed(() => {
|
||||
const author = props.author || '';
|
||||
const wroteText = t('SEARCH.WROTE');
|
||||
return author ? `${author} ${wroteText} ` : '';
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="contentElement"
|
||||
class="break-words grid items-center text-n-slate-11 text-sm leading-relaxed"
|
||||
:class="showReadMore ? 'grid-cols-[1fr_auto]' : 'grid-cols-1'"
|
||||
>
|
||||
<div
|
||||
class="min-w-0"
|
||||
:class="{
|
||||
'overflow-hidden whitespace-nowrap text-ellipsis': showReadMore,
|
||||
}"
|
||||
>
|
||||
<span v-if="authorText" class="text-n-slate-11 font-medium leading-4">{{
|
||||
authorText
|
||||
}}</span>
|
||||
<span
|
||||
v-dompurify-html="highlightedContent"
|
||||
class="message-content text-n-slate-12 [&_.searchkey--highlight]:text-n-slate-12 [&_.searchkey--highlight]:font-semibold"
|
||||
/>
|
||||
<button
|
||||
v-if="showReadLess"
|
||||
class="text-sm text-n-slate-11 underline cursor-pointer bg-transparent border-0 p-0 hover:text-n-slate-12 font-medium ltr:ml-0.5 rtl:mr-0.5"
|
||||
@click.prevent="toggleExpanded(false)"
|
||||
>
|
||||
{{ t('SEARCH.READ_LESS') }}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
v-if="showReadMore"
|
||||
class="text-sm text-n-slate-11 underline cursor-pointer bg-transparent border-0 p-0 hover:text-n-slate-12 font-medium justify-self-end ltr:ml-0.5 rtl:mr-0.5"
|
||||
@click.prevent="toggleExpanded(true)"
|
||||
>
|
||||
{{ t('SEARCH.READ_MORE') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.message-content::v-deep p {
|
||||
@apply inline;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.message-content::v-deep br {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,116 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { LocalStorage } from 'shared/helpers/localStorage';
|
||||
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
|
||||
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const emit = defineEmits(['selectSearch', 'clearAll']);
|
||||
|
||||
const MAX_RECENT_SEARCHES = 3;
|
||||
const recentSearches = ref([]);
|
||||
|
||||
const loadRecentSearches = () => {
|
||||
const stored = LocalStorage.get(LOCAL_STORAGE_KEYS.RECENT_SEARCHES) || [];
|
||||
recentSearches.value = Array.isArray(stored) ? stored : [];
|
||||
};
|
||||
|
||||
const saveRecentSearches = () => {
|
||||
LocalStorage.set(LOCAL_STORAGE_KEYS.RECENT_SEARCHES, recentSearches.value);
|
||||
};
|
||||
|
||||
const addRecentSearch = query => {
|
||||
if (!query || query.trim().length < 2) return;
|
||||
|
||||
const trimmedQuery = query.trim();
|
||||
|
||||
const existingIndex = recentSearches.value.findIndex(
|
||||
search => search.toLowerCase() === trimmedQuery.toLowerCase()
|
||||
);
|
||||
|
||||
if (existingIndex !== -1) {
|
||||
recentSearches.value.splice(existingIndex, 1);
|
||||
}
|
||||
|
||||
recentSearches.value.unshift(trimmedQuery);
|
||||
|
||||
if (recentSearches.value.length > MAX_RECENT_SEARCHES) {
|
||||
recentSearches.value = recentSearches.value.slice(0, MAX_RECENT_SEARCHES);
|
||||
}
|
||||
|
||||
saveRecentSearches();
|
||||
};
|
||||
|
||||
const clearRecentSearches = () => {
|
||||
recentSearches.value = [];
|
||||
LocalStorage.remove(LOCAL_STORAGE_KEYS.RECENT_SEARCHES);
|
||||
};
|
||||
|
||||
const hasRecentSearches = computed(() => recentSearches.value?.length > 0);
|
||||
|
||||
const onSelectSearch = query => {
|
||||
emit('selectSearch', query);
|
||||
};
|
||||
|
||||
const onClearAll = () => {
|
||||
clearRecentSearches();
|
||||
emit('clearAll');
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
addRecentSearch,
|
||||
loadRecentSearches,
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
loadRecentSearches();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="hasRecentSearches" class="px-4 pb-4 w-full pt-2">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center gap-2.5">
|
||||
<Icon icon="i-lucide-rotate-ccw" class="text-n-slate-10 size-4" />
|
||||
<h3 class="text-base font-medium text-n-slate-10">
|
||||
{{ $t('SEARCH.RECENT_SEARCHES') }}
|
||||
</h3>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
xs
|
||||
slate
|
||||
ghost
|
||||
class="!text-n-slate-10 hover:!text-n-slate-12"
|
||||
@mousedown.prevent
|
||||
@click="onClearAll"
|
||||
>
|
||||
{{ $t('SEARCH.CLEAR_ALL') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-4 items-start">
|
||||
<button
|
||||
v-for="(search, index) in recentSearches"
|
||||
:key="search"
|
||||
type="button"
|
||||
class="w-full flex items-center gap-2.5 text-left text-base text-n-slate-12 rounded-lg transition-all duration-150 group p-0"
|
||||
@mousedown.prevent
|
||||
@click="onSelectSearch(search)"
|
||||
>
|
||||
<Icon
|
||||
icon="i-lucide-search"
|
||||
class="text-n-slate-10 group-hover:text-n-slate-11 transition-colors duration-150 size-4"
|
||||
/>
|
||||
<span class="flex-1 truncate">{{ search }}</span>
|
||||
<span
|
||||
class="text-xs text-n-slate-8 opacity-0 group-hover:opacity-100 transition-opacity duration-150"
|
||||
>
|
||||
{{ index === 0 ? $t('SEARCH.MOST_RECENT') : '' }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<template v-else />
|
||||
</template>
|
||||
@@ -0,0 +1,239 @@
|
||||
<script setup>
|
||||
import { ref, computed, defineModel, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useToggle } from '@vueuse/core';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
import { debounce } from '@chatwoot/utils';
|
||||
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||
import { searchContacts } from 'dashboard/components-next/NewConversation/helpers/composeConversationHelper';
|
||||
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
|
||||
import { fetchContactDetails } from '../helpers/searchHelper';
|
||||
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const props = defineProps({
|
||||
label: { type: String, required: true },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['change']);
|
||||
|
||||
const FROM_TYPE = {
|
||||
CONTACT: 'contact',
|
||||
AGENT: 'agent',
|
||||
};
|
||||
|
||||
const MENU_ACTIONS_SELECT = 'select';
|
||||
|
||||
const modelValue = defineModel({ type: String, default: null });
|
||||
|
||||
const { t } = useI18n();
|
||||
const [showDropdown, toggleDropdown] = useToggle();
|
||||
|
||||
const searchQuery = ref('');
|
||||
const searchedContacts = ref([]);
|
||||
const isSearching = ref(false);
|
||||
const selectedContact = ref(null);
|
||||
|
||||
const agentsList = useMapGetter('agents/getVerifiedAgents');
|
||||
|
||||
const createMenuItem = (item, type, isAgent = false) => {
|
||||
const transformed = useCamelCase(item, { deep: true });
|
||||
const value = `${type}:${transformed.id}`;
|
||||
return {
|
||||
label: transformed.name,
|
||||
value,
|
||||
action: MENU_ACTIONS_SELECT,
|
||||
type,
|
||||
thumbnail: {
|
||||
name: transformed.name,
|
||||
src: isAgent ? transformed.avatarUrl : transformed.thumbnail,
|
||||
},
|
||||
...(isAgent
|
||||
? {}
|
||||
: { description: transformed.email || transformed.phoneNumber }),
|
||||
isSelected: modelValue.value === value,
|
||||
};
|
||||
};
|
||||
|
||||
const agentsSection = computed(() => {
|
||||
const agents =
|
||||
agentsList.value?.map(agent =>
|
||||
createMenuItem(agent, FROM_TYPE.AGENT, true)
|
||||
) || [];
|
||||
return searchQuery.value
|
||||
? agents.filter(agent =>
|
||||
agent.label.toLowerCase().includes(searchQuery.value.toLowerCase())
|
||||
)
|
||||
: agents;
|
||||
});
|
||||
|
||||
const contactsSection = computed(
|
||||
() =>
|
||||
searchedContacts.value?.map(contact =>
|
||||
createMenuItem(contact, FROM_TYPE.CONTACT)
|
||||
) || []
|
||||
);
|
||||
|
||||
const menuSections = computed(() => [
|
||||
{
|
||||
title: t('SEARCH.FILTERS.CONTACTS'),
|
||||
items: contactsSection.value,
|
||||
isLoading: isSearching.value,
|
||||
emptyState: t('SEARCH.FILTERS.NO_CONTACTS'),
|
||||
},
|
||||
{
|
||||
title: t('SEARCH.FILTERS.AGENTS'),
|
||||
items: agentsSection.value,
|
||||
emptyState: t('SEARCH.FILTERS.NO_AGENTS'),
|
||||
},
|
||||
]);
|
||||
|
||||
const selectedLabel = computed(() => {
|
||||
if (!modelValue.value) return props.label;
|
||||
|
||||
const [type, id] = modelValue.value.split(':');
|
||||
const numericId = Number(id);
|
||||
|
||||
if (type === FROM_TYPE.CONTACT) {
|
||||
if (selectedContact.value?.id === numericId) {
|
||||
return `${props.label}: ${selectedContact.value.name}`;
|
||||
}
|
||||
const contact = searchedContacts.value?.find(c => c.id === numericId);
|
||||
if (contact) return `${props.label}: ${contact.name}`;
|
||||
} else if (type === FROM_TYPE.AGENT) {
|
||||
const agent = agentsList.value?.find(a => a.id === numericId);
|
||||
if (agent) return `${props.label}: ${agent.name}`;
|
||||
}
|
||||
|
||||
return `${props.label}: ${numericId}`;
|
||||
});
|
||||
|
||||
const debouncedSearch = debounce(async query => {
|
||||
if (!query) {
|
||||
searchedContacts.value = selectedContact.value
|
||||
? [selectedContact.value]
|
||||
: [];
|
||||
isSearching.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const contacts = await searchContacts({
|
||||
keys: ['name', 'email', 'phone_number'],
|
||||
query,
|
||||
});
|
||||
|
||||
// Add selected contact to top if not already in results
|
||||
const allContacts = selectedContact.value
|
||||
? [
|
||||
selectedContact.value,
|
||||
...contacts.filter(c => c.id !== selectedContact.value.id),
|
||||
]
|
||||
: contacts;
|
||||
|
||||
searchedContacts.value = allContacts;
|
||||
} catch {
|
||||
// Ignore error
|
||||
} finally {
|
||||
isSearching.value = false;
|
||||
}
|
||||
}, 300);
|
||||
|
||||
const performSearch = query => {
|
||||
searchQuery.value = query;
|
||||
if (query) {
|
||||
searchedContacts.value = selectedContact.value
|
||||
? [selectedContact.value]
|
||||
: [];
|
||||
isSearching.value = true;
|
||||
}
|
||||
debouncedSearch(query);
|
||||
};
|
||||
|
||||
const onToggleDropdown = () => {
|
||||
if (!showDropdown.value) {
|
||||
// Reset search when opening dropdown
|
||||
searchQuery.value = '';
|
||||
searchedContacts.value = selectedContact.value
|
||||
? [selectedContact.value]
|
||||
: [];
|
||||
}
|
||||
toggleDropdown();
|
||||
};
|
||||
|
||||
const handleAction = item => {
|
||||
if (modelValue.value === item.value) {
|
||||
modelValue.value = null;
|
||||
selectedContact.value = null;
|
||||
} else {
|
||||
modelValue.value = item.value;
|
||||
|
||||
if (item.type === FROM_TYPE.CONTACT) {
|
||||
const [, id] = item.value.split(':');
|
||||
selectedContact.value = {
|
||||
id: Number(id),
|
||||
name: item.label,
|
||||
thumbnail: item.thumbnail?.src,
|
||||
};
|
||||
} else {
|
||||
selectedContact.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
toggleDropdown(false);
|
||||
emit('change');
|
||||
};
|
||||
|
||||
const resolveContactName = async () => {
|
||||
if (!modelValue.value) return;
|
||||
|
||||
const [type, id] = modelValue.value.split(':');
|
||||
if (type !== FROM_TYPE.CONTACT) return;
|
||||
|
||||
const numericId = Number(id);
|
||||
if (selectedContact.value?.id === numericId) return;
|
||||
|
||||
const contact = await fetchContactDetails(numericId);
|
||||
if (contact) {
|
||||
selectedContact.value = {
|
||||
id: contact.id,
|
||||
name: contact.name,
|
||||
thumbnail: contact.thumbnail,
|
||||
};
|
||||
if (!searchedContacts.value.some(c => c.id === contact.id)) {
|
||||
searchedContacts.value.push(selectedContact.value);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
watch(() => modelValue.value, resolveContactName, { immediate: true });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-on-click-outside="() => toggleDropdown(false)"
|
||||
class="relative flex items-center group min-w-0 max-w-full"
|
||||
>
|
||||
<Button
|
||||
sm
|
||||
:variant="showDropdown ? 'faded' : 'ghost'"
|
||||
slate
|
||||
:label="selectedLabel"
|
||||
trailing-icon
|
||||
icon="i-lucide-chevron-down"
|
||||
class="!px-2 max-w-full"
|
||||
@click="onToggleDropdown"
|
||||
/>
|
||||
<DropdownMenu
|
||||
v-if="showDropdown"
|
||||
:menu-sections="menuSections"
|
||||
show-search
|
||||
disable-local-filtering
|
||||
:is-searching="isSearching"
|
||||
class="mt-1 ltr:left-0 rtl:right-0 top-full w-64 max-h-80 overflow-y-auto"
|
||||
@search="performSearch"
|
||||
@action="handleAction"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,271 @@
|
||||
<script setup>
|
||||
import { computed, ref, defineModel } from 'vue';
|
||||
import { useToggle } from '@vueuse/core';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
import {
|
||||
subDays,
|
||||
subMonths,
|
||||
subYears,
|
||||
startOfDay,
|
||||
endOfDay,
|
||||
format,
|
||||
getUnixTime,
|
||||
fromUnixTime,
|
||||
} from 'date-fns';
|
||||
import { DATE_RANGE_TYPES } from '../helpers/searchHelper';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
|
||||
const emit = defineEmits(['change']);
|
||||
const modelValue = defineModel({
|
||||
type: Object,
|
||||
default: () => ({ type: null, from: null, to: null }),
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
const [showDropdown, toggleDropdown] = useToggle();
|
||||
|
||||
const customFrom = ref('');
|
||||
const customTo = ref('');
|
||||
const rangeType = ref(DATE_RANGE_TYPES.BETWEEN);
|
||||
|
||||
// Calculate min date (90 days ago) for date inputs
|
||||
const minDate = computed(() => format(subDays(new Date(), 90), 'yyyy-MM-dd'));
|
||||
const maxDate = computed(() => format(new Date(), 'yyyy-MM-dd'));
|
||||
|
||||
// Check if both custom date inputs have values
|
||||
const hasCustomDates = computed(() => customFrom.value && customTo.value);
|
||||
|
||||
const DATE_FILTER_ACTIONS = {
|
||||
PRESET: 'preset',
|
||||
SELECT: 'select',
|
||||
};
|
||||
|
||||
const PRESET_RANGES = computed(() => [
|
||||
{
|
||||
label: t('SEARCH.DATE_RANGE.LAST_7_DAYS'),
|
||||
value: DATE_RANGE_TYPES.LAST_7_DAYS,
|
||||
days: 7,
|
||||
},
|
||||
{
|
||||
label: t('SEARCH.DATE_RANGE.LAST_30_DAYS'),
|
||||
value: DATE_RANGE_TYPES.LAST_30_DAYS,
|
||||
days: 30,
|
||||
},
|
||||
{
|
||||
label: t('SEARCH.DATE_RANGE.LAST_60_DAYS'),
|
||||
value: DATE_RANGE_TYPES.LAST_60_DAYS,
|
||||
days: 60,
|
||||
},
|
||||
{
|
||||
label: t('SEARCH.DATE_RANGE.LAST_90_DAYS'),
|
||||
value: DATE_RANGE_TYPES.LAST_90_DAYS,
|
||||
days: 90,
|
||||
},
|
||||
]);
|
||||
|
||||
const computeDateRange = config => {
|
||||
const end = endOfDay(new Date());
|
||||
let start;
|
||||
|
||||
if (config.days) {
|
||||
start = startOfDay(subDays(end, config.days));
|
||||
} else if (config.months) {
|
||||
start = startOfDay(subMonths(end, config.months));
|
||||
} else {
|
||||
start = startOfDay(subYears(end, config.years));
|
||||
}
|
||||
|
||||
return { type: config.value, from: getUnixTime(start), to: getUnixTime(end) };
|
||||
};
|
||||
|
||||
const selectedValue = computed(() => {
|
||||
const { from, to, type } = modelValue.value || {};
|
||||
if (!from && !to && !type) return '';
|
||||
return type || DATE_RANGE_TYPES.CUSTOM;
|
||||
});
|
||||
|
||||
const menuItems = computed(() =>
|
||||
PRESET_RANGES.value.map(item => ({
|
||||
...item,
|
||||
action: DATE_FILTER_ACTIONS.PRESET,
|
||||
isSelected: selectedValue.value === item.value,
|
||||
}))
|
||||
);
|
||||
|
||||
const applySelection = ({ type, from, to }) => {
|
||||
const newValue = { type, from, to };
|
||||
modelValue.value = newValue;
|
||||
emit('change', newValue);
|
||||
};
|
||||
|
||||
const clearFilter = () => {
|
||||
applySelection({ type: null, from: null, to: null });
|
||||
customFrom.value = '';
|
||||
customTo.value = '';
|
||||
toggleDropdown(false);
|
||||
};
|
||||
|
||||
const handlePresetAction = item => {
|
||||
if (selectedValue.value === item.value) {
|
||||
clearFilter();
|
||||
return;
|
||||
}
|
||||
customFrom.value = '';
|
||||
customTo.value = '';
|
||||
applySelection(computeDateRange(item));
|
||||
toggleDropdown(false);
|
||||
};
|
||||
|
||||
const applyCustomRange = () => {
|
||||
const customFromDate = customFrom.value
|
||||
? startOfDay(new Date(customFrom.value))
|
||||
: null;
|
||||
const customToDate = customTo.value
|
||||
? endOfDay(new Date(customTo.value))
|
||||
: null;
|
||||
|
||||
// Only BETWEEN mode - require both dates
|
||||
if (customFromDate && customToDate) {
|
||||
applySelection({
|
||||
type: DATE_RANGE_TYPES.BETWEEN,
|
||||
from: getUnixTime(customFromDate),
|
||||
to: getUnixTime(customToDate),
|
||||
});
|
||||
toggleDropdown(false);
|
||||
}
|
||||
};
|
||||
|
||||
const clearCustomRange = () => {
|
||||
customFrom.value = '';
|
||||
customTo.value = '';
|
||||
};
|
||||
|
||||
const formatDate = timestamp => format(fromUnixTime(timestamp), 'MMM d, yyyy'); // (e.g., "Jan 15, 2024")
|
||||
|
||||
const selectedLabel = computed(() => {
|
||||
const prefix = t('SEARCH.DATE_RANGE.TIME_RANGE');
|
||||
if (!selectedValue.value) return prefix;
|
||||
|
||||
// Check if it's a preset
|
||||
const preset = PRESET_RANGES.value.find(p => p.value === selectedValue.value);
|
||||
if (preset) return `${prefix}: ${preset.label}`;
|
||||
|
||||
// Custom range - only BETWEEN mode with both dates
|
||||
const { from, to } = modelValue.value;
|
||||
if (from && to) return `${prefix}: ${formatDate(from)} - ${formatDate(to)}`;
|
||||
|
||||
return `${prefix}: ${t('SEARCH.DATE_RANGE.CUSTOM_RANGE')}`;
|
||||
});
|
||||
|
||||
const CUSTOM_RANGE_TYPES = [DATE_RANGE_TYPES.BETWEEN, DATE_RANGE_TYPES.CUSTOM];
|
||||
|
||||
const onToggleDropdown = () => {
|
||||
if (!showDropdown.value) {
|
||||
const { type, from, to } = modelValue.value || {};
|
||||
|
||||
rangeType.value = CUSTOM_RANGE_TYPES.includes(type)
|
||||
? type
|
||||
: DATE_RANGE_TYPES.BETWEEN;
|
||||
|
||||
if (CUSTOM_RANGE_TYPES.includes(type)) {
|
||||
try {
|
||||
customFrom.value = from ? format(fromUnixTime(from), 'yyyy-MM-dd') : '';
|
||||
customTo.value = to ? format(fromUnixTime(to), 'yyyy-MM-dd') : '';
|
||||
} catch {
|
||||
customFrom.value = '';
|
||||
customTo.value = '';
|
||||
}
|
||||
} else {
|
||||
customFrom.value = '';
|
||||
customTo.value = '';
|
||||
}
|
||||
}
|
||||
toggleDropdown();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-on-click-outside="() => toggleDropdown(false)"
|
||||
class="relative flex items-center group min-w-0 max-w-full"
|
||||
>
|
||||
<Button
|
||||
sm
|
||||
slate
|
||||
:variant="showDropdown ? 'faded' : 'solid'"
|
||||
:label="selectedLabel"
|
||||
class="group-hover:bg-n-alpha-2 max-w-full"
|
||||
trailing-icon
|
||||
icon="i-lucide-chevron-down"
|
||||
@click="onToggleDropdown()"
|
||||
/>
|
||||
<DropdownMenu
|
||||
v-if="showDropdown"
|
||||
:menu-items="menuItems"
|
||||
class="mt-1 ltr:left-0 rtl:right-0 top-full w-64"
|
||||
@action="handlePresetAction"
|
||||
>
|
||||
<template #footer>
|
||||
<div class="h-px bg-n-strong" />
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center justify-between gap-2 px-1 h-9">
|
||||
<span class="text-sm text-n-slate-11">
|
||||
{{ t('SEARCH.DATE_RANGE.CUSTOM_RANGE') }}
|
||||
</span>
|
||||
<span class="text-sm text-n-slate-12">
|
||||
{{ t('SEARCH.DATE_RANGE.CREATED_BETWEEN') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<input
|
||||
v-model="customFrom"
|
||||
type="date"
|
||||
:min="minDate"
|
||||
:max="customTo || maxDate"
|
||||
class="!w-full !mb-0 !rounded-lg !bg-n-alpha-black2 !outline-n-strong -outline-offset-1 !px-3 !py-2 !text-sm text-n-slate-12 !h-8"
|
||||
/>
|
||||
|
||||
<div class="flex items-center gap-3 h-5 px-1">
|
||||
<div class="flex-1 h-px bg-n-weak" />
|
||||
<span class="text-sm text-n-slate-11">
|
||||
{{ t('SEARCH.DATE_RANGE.AND') }}
|
||||
</span>
|
||||
<div class="flex-1 h-px bg-n-weak" />
|
||||
</div>
|
||||
|
||||
<input
|
||||
v-model="customTo"
|
||||
type="date"
|
||||
:min="customFrom || minDate"
|
||||
:max="maxDate"
|
||||
class="!w-full !mb-0 !rounded-lg !bg-n-alpha-black2 !outline-n-strong -outline-offset-1 !px-3 !py-2 !text-sm text-n-slate-12 !h-8"
|
||||
/>
|
||||
|
||||
<div class="flex items-center gap-2 mt-2">
|
||||
<Button
|
||||
sm
|
||||
slate
|
||||
faded
|
||||
:label="t('SEARCH.DATE_RANGE.CLEAR_FILTER')"
|
||||
:disabled="!hasCustomDates"
|
||||
class="flex-1 justify-center"
|
||||
@click="clearCustomRange"
|
||||
/>
|
||||
<Button
|
||||
sm
|
||||
solid
|
||||
color="blue"
|
||||
:label="t('SEARCH.DATE_RANGE.APPLY')"
|
||||
:disabled="!hasCustomDates"
|
||||
class="flex-1 justify-center"
|
||||
@click="applyCustomRange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,104 @@
|
||||
<script setup>
|
||||
import { computed, defineModel } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import SearchDateRangeSelector from './SearchDateRangeSelector.vue';
|
||||
import SearchContactAgentSelector from './SearchContactAgentSelector.vue';
|
||||
import SearchInboxSelector from './SearchInboxSelector.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const emit = defineEmits(['updateFilters']);
|
||||
const { t } = useI18n();
|
||||
|
||||
const filters = defineModel({
|
||||
type: Object,
|
||||
default: () => ({
|
||||
from: null, // Contact id and Agent id
|
||||
in: null, // Inbox id
|
||||
dateRange: { type: null, from: null, to: null },
|
||||
}),
|
||||
});
|
||||
|
||||
const hasActiveFilters = computed(
|
||||
() =>
|
||||
filters.value.from ||
|
||||
filters.value.in ||
|
||||
filters.value.dateRange?.type ||
|
||||
filters.value.dateRange?.from ||
|
||||
filters.value.dateRange?.to
|
||||
);
|
||||
|
||||
const onFilterChange = () => {
|
||||
emit('updateFilters', filters.value);
|
||||
};
|
||||
|
||||
const clearAllFilters = () => {
|
||||
filters.value = {
|
||||
from: null,
|
||||
in: null,
|
||||
dateRange: { type: null, from: null, to: null },
|
||||
};
|
||||
onFilterChange();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col lg:flex-row items-start lg:items-center gap-3 p-4 w-full min-w-0"
|
||||
>
|
||||
<div class="flex items-center gap-3 min-w-0 max-w-full">
|
||||
<Button
|
||||
v-if="hasActiveFilters"
|
||||
sm
|
||||
slate
|
||||
solid
|
||||
:label="t('SEARCH.DATE_RANGE.CLEAR_FILTER')"
|
||||
icon="i-lucide-x"
|
||||
class="flex-shrink-0 lg:hidden"
|
||||
@click="clearAllFilters"
|
||||
/>
|
||||
|
||||
<SearchDateRangeSelector
|
||||
v-model="filters.dateRange"
|
||||
class="min-w-0 max-w-full"
|
||||
@change="onFilterChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="w-px h-4 bg-n-weak flex-shrink-0 hidden lg:block" />
|
||||
|
||||
<div class="flex items-center gap-1.5 min-w-0 flex-1 max-w-full">
|
||||
<span class="text-sm text-n-slate-10 flex-shrink-0 whitespace-nowrap">
|
||||
{{ t('SEARCH.FILTERS.FILTER_MESSAGE') }}
|
||||
</span>
|
||||
|
||||
<div class="min-w-0">
|
||||
<SearchContactAgentSelector
|
||||
v-model="filters.from"
|
||||
:label="$t('SEARCH.FILTERS.FROM')"
|
||||
@change="onFilterChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="w-px h-3 bg-n-weak rounded-lg flex-shrink-0" />
|
||||
|
||||
<div class="min-w-0">
|
||||
<SearchInboxSelector
|
||||
v-model="filters.in"
|
||||
:label="$t('SEARCH.FILTERS.IN')"
|
||||
@change="onFilterChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
v-if="hasActiveFilters"
|
||||
sm
|
||||
slate
|
||||
solid
|
||||
:label="t('SEARCH.DATE_RANGE.CLEAR_FILTER')"
|
||||
icon="i-lucide-x"
|
||||
class="flex-shrink-0 hidden lg:inline-flex"
|
||||
@click="clearAllFilters"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,69 @@
|
||||
<script setup>
|
||||
import { ref, watch, useTemplateRef, defineModel } from 'vue';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import { INSTALLATION_TYPES } from 'dashboard/constants/installationTypes';
|
||||
import { ROLES } from 'dashboard/constants/permissions';
|
||||
|
||||
import SearchInput from './SearchInput.vue';
|
||||
import SearchFilters from './SearchFilters.vue';
|
||||
import Policy from 'dashboard/components/policy.vue';
|
||||
|
||||
const props = defineProps({
|
||||
initialQuery: { type: String, default: '' },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['search', 'filterChange']);
|
||||
|
||||
const filters = defineModel('filters', { type: Object, default: () => ({}) });
|
||||
|
||||
const searchInputRef = useTemplateRef('searchInputRef');
|
||||
const searchQuery = ref(props.initialQuery);
|
||||
|
||||
const onSearch = query => {
|
||||
if (query?.trim() && searchInputRef.value) {
|
||||
searchInputRef.value.addToRecentSearches(query.trim());
|
||||
}
|
||||
emit('search', query);
|
||||
};
|
||||
|
||||
const onSelectRecentSearch = query => {
|
||||
searchQuery.value = query;
|
||||
onSearch(query);
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.initialQuery,
|
||||
newValue => {
|
||||
if (searchQuery.value !== newValue) {
|
||||
searchQuery.value = newValue;
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-2">
|
||||
<SearchInput
|
||||
ref="searchInputRef"
|
||||
v-model="searchQuery"
|
||||
@search="onSearch"
|
||||
@select-recent-search="onSelectRecentSearch"
|
||||
>
|
||||
<Policy
|
||||
:permissions="ROLES"
|
||||
:installation-types="[
|
||||
INSTALLATION_TYPES.ENTERPRISE,
|
||||
INSTALLATION_TYPES.CLOUD,
|
||||
]"
|
||||
:feature-flag="FEATURE_FLAGS.ADVANCED_SEARCH"
|
||||
class="w-full"
|
||||
>
|
||||
<SearchFilters
|
||||
v-model="filters"
|
||||
@update-filters="$emit('filterChange', $event)"
|
||||
/>
|
||||
</Policy>
|
||||
</SearchInput>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,125 @@
|
||||
<script setup>
|
||||
import { ref, computed, defineModel } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useToggle } from '@vueuse/core';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const props = defineProps({
|
||||
label: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['change']);
|
||||
const modelValue = defineModel({
|
||||
type: [String, Number],
|
||||
default: null,
|
||||
});
|
||||
|
||||
const MENU_ITEM_TYPES = {
|
||||
INBOX: 'inbox',
|
||||
};
|
||||
|
||||
const MENU_ACTIONS = {
|
||||
SELECT: 'select',
|
||||
};
|
||||
|
||||
const { t } = useI18n();
|
||||
const [showDropdown, toggleDropdown] = useToggle();
|
||||
|
||||
const searchQuery = ref('');
|
||||
|
||||
const inboxesList = useMapGetter('inboxes/getInboxes');
|
||||
|
||||
const inboxesSection = computed(() => {
|
||||
const inboxes = inboxesList.value?.map(inbox => {
|
||||
const transformedInbox = useCamelCase(inbox, { deep: true });
|
||||
return {
|
||||
label: transformedInbox.name,
|
||||
value: transformedInbox.id,
|
||||
action: MENU_ACTIONS.SELECT,
|
||||
type: MENU_ITEM_TYPES.INBOX,
|
||||
thumbnail: {
|
||||
name: transformedInbox.name,
|
||||
src: transformedInbox.avatarUrl,
|
||||
},
|
||||
isSelected: modelValue.value === transformedInbox.id,
|
||||
};
|
||||
});
|
||||
|
||||
if (!searchQuery.value) return inboxes;
|
||||
|
||||
return inboxes.filter(inbox =>
|
||||
inbox.label.toLowerCase().includes(searchQuery.value.toLowerCase())
|
||||
);
|
||||
});
|
||||
|
||||
const menuSections = computed(() => {
|
||||
return [
|
||||
{
|
||||
title: t('SEARCH.FILTERS.INBOXES'),
|
||||
items: inboxesSection.value,
|
||||
emptyState: t('SEARCH.FILTERS.NO_INBOXES'),
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
const selectedLabel = computed(() => {
|
||||
if (!modelValue.value) return props.label;
|
||||
|
||||
// Find the selected inbox
|
||||
const inbox = inboxesList.value?.find(i => i.id === modelValue.value);
|
||||
if (inbox) return `${props.label}: ${inbox.name}`;
|
||||
|
||||
return `${props.label}: ${modelValue.value}`;
|
||||
});
|
||||
|
||||
const handleAction = item => {
|
||||
if (modelValue.value === item.value) {
|
||||
modelValue.value = null;
|
||||
} else {
|
||||
modelValue.value = item.value;
|
||||
}
|
||||
toggleDropdown(false);
|
||||
emit('change');
|
||||
};
|
||||
|
||||
const onToggleDropdown = () => {
|
||||
if (!showDropdown.value) {
|
||||
searchQuery.value = '';
|
||||
}
|
||||
toggleDropdown();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-on-click-outside="() => toggleDropdown(false)"
|
||||
class="relative flex items-center group min-w-0 max-w-full"
|
||||
>
|
||||
<Button
|
||||
sm
|
||||
:variant="showDropdown ? 'faded' : 'ghost'"
|
||||
slate
|
||||
:label="selectedLabel"
|
||||
trailing-icon
|
||||
icon="i-lucide-chevron-down"
|
||||
class="!px-2 max-w-full"
|
||||
@click="onToggleDropdown"
|
||||
/>
|
||||
<DropdownMenu
|
||||
v-if="showDropdown"
|
||||
:menu-sections="menuSections"
|
||||
show-search
|
||||
disable-local-filtering
|
||||
class="mt-1 ltr:right-0 rtl:left-0 top-full w-64 max-h-80 overflow-y-auto"
|
||||
@search="searchQuery = $event"
|
||||
@action="handleAction"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,136 @@
|
||||
<script setup>
|
||||
import { ref, useTemplateRef, onMounted, onUnmounted } from 'vue';
|
||||
import { debounce } from '@chatwoot/utils';
|
||||
import RecentSearches from './RecentSearches.vue';
|
||||
|
||||
const emit = defineEmits(['search', 'selectRecentSearch']);
|
||||
|
||||
const searchQuery = defineModel({
|
||||
type: String,
|
||||
default: '',
|
||||
});
|
||||
const isInputFocused = ref(false);
|
||||
const showRecentSearches = ref(false);
|
||||
const searchInput = useTemplateRef('searchInput');
|
||||
const recentSearchesRef = useTemplateRef('recentSearchesRef');
|
||||
|
||||
const handler = e => {
|
||||
if (e.key === '/' && document.activeElement.tagName !== 'INPUT') {
|
||||
e.preventDefault();
|
||||
searchInput.value.focus();
|
||||
} else if (e.key === 'Escape' && document.activeElement.tagName === 'INPUT') {
|
||||
e.preventDefault();
|
||||
searchInput.value.blur();
|
||||
}
|
||||
};
|
||||
|
||||
const debouncedEmit = debounce(
|
||||
value =>
|
||||
emit('search', value.length > 1 || value.match(/^[0-9]+$/) ? value : ''),
|
||||
500
|
||||
);
|
||||
|
||||
const onInput = () => {
|
||||
debouncedEmit(searchQuery.value);
|
||||
|
||||
if (searchQuery.value.trim()) {
|
||||
showRecentSearches.value = false;
|
||||
} else if (isInputFocused.value) {
|
||||
showRecentSearches.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
const onFocus = () => {
|
||||
isInputFocused.value = true;
|
||||
if (!searchQuery.value.trim()) {
|
||||
showRecentSearches.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
const onBlur = () => {
|
||||
isInputFocused.value = false;
|
||||
showRecentSearches.value = false;
|
||||
};
|
||||
|
||||
const onSelectRecentSearch = query => {
|
||||
searchQuery.value = query;
|
||||
emit('selectRecentSearch', query);
|
||||
showRecentSearches.value = false;
|
||||
searchInput.value.focus();
|
||||
};
|
||||
|
||||
const addToRecentSearches = query => {
|
||||
if (recentSearchesRef.value) {
|
||||
recentSearchesRef.value.addRecentSearch(query);
|
||||
}
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
addToRecentSearches,
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
searchInput.value.focus();
|
||||
document.addEventListener('keydown', handler);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('keydown', handler);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="rounded-xl transition-[border-bottom] duration-[0.2s] ease-[ease-in-out] relative flex items-start flex-col border border-solid bg-n-solid-1 divide-y divide-n-strong"
|
||||
:class="{
|
||||
'border-n-brand': isInputFocused,
|
||||
'border-n-strong': !isInputFocused,
|
||||
}"
|
||||
>
|
||||
<div class="flex items-center w-full h-[3.25rem] px-4 gap-2">
|
||||
<div class="flex items-center">
|
||||
<fluent-icon
|
||||
icon="search"
|
||||
class="icon"
|
||||
aria-hidden="true"
|
||||
:class="{
|
||||
'text-n-blue-11': isInputFocused,
|
||||
'text-n-slate-10': !isInputFocused,
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
ref="searchInput"
|
||||
v-model="searchQuery"
|
||||
type="search"
|
||||
class="reset-base outline-none w-full m-0 bg-transparent border-transparent shadow-none text-n-slate-12 dark:text-n-slate-12 active:border-transparent active:shadow-none hover:border-transparent hover:shadow-none focus:border-transparent focus:shadow-none placeholder:text-n-slate-10 text-base"
|
||||
:placeholder="$t('SEARCH.INPUT_PLACEHOLDER')"
|
||||
@focus="onFocus"
|
||||
@blur="onBlur"
|
||||
@input="onInput"
|
||||
/>
|
||||
<span class="text-sm text-n-slate-10 flex-shrink-0">
|
||||
{{ $t('SEARCH.PLACEHOLDER_KEYBINDING') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<slot />
|
||||
|
||||
<div
|
||||
class="transition-all duration-200 ease-out grid overflow-hidden w-full !border-t-0"
|
||||
:class="
|
||||
showRecentSearches
|
||||
? 'grid-rows-[1fr] opacity-100'
|
||||
: 'grid-rows-[0fr] opacity-0'
|
||||
"
|
||||
>
|
||||
<div class="overflow-hidden w-full">
|
||||
<RecentSearches
|
||||
ref="recentSearchesRef"
|
||||
@select-search="onSelectRecentSearch"
|
||||
@clear-all="showRecentSearches = false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,105 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { frontendURL } from 'dashboard/helper/URLHelper';
|
||||
import { dynamicTime } from 'shared/helpers/timeHelper';
|
||||
import { ARTICLE_STATUSES } from 'dashboard/helper/portalHelper';
|
||||
|
||||
import CardLayout from 'dashboard/components-next/CardLayout.vue';
|
||||
import MessageFormatter from 'shared/helpers/MessageFormatter';
|
||||
|
||||
const props = defineProps({
|
||||
id: { type: [String, Number], default: 0 },
|
||||
title: { type: String, default: '' },
|
||||
description: { type: String, default: '' },
|
||||
category: { type: String, default: '' },
|
||||
locale: { type: String, default: '' },
|
||||
content: { type: String, default: '' },
|
||||
portalSlug: { type: String, required: true },
|
||||
accountId: { type: [String, Number], default: 0 },
|
||||
status: { type: String, default: '' },
|
||||
updatedAt: { type: Number, default: 0 },
|
||||
});
|
||||
|
||||
const MAX_LENGTH = 300;
|
||||
|
||||
const navigateTo = computed(() => {
|
||||
return frontendURL(
|
||||
`accounts/${props.accountId}/portals/${props.portalSlug}/${props.locale}/articles/edit/${props.id}`
|
||||
);
|
||||
});
|
||||
|
||||
const updatedAtTime = computed(() => {
|
||||
if (!props.updatedAt) return '';
|
||||
return dynamicTime(props.updatedAt);
|
||||
});
|
||||
|
||||
const truncatedContent = computed(() => {
|
||||
if (!props.content) return props.description || '';
|
||||
|
||||
// Use MessageFormatter to properly convert markdown to plain text
|
||||
const formatter = new MessageFormatter(props.content);
|
||||
const plainText = formatter.plainText.trim();
|
||||
|
||||
return plainText.length > MAX_LENGTH
|
||||
? `${plainText.substring(0, MAX_LENGTH)}...`
|
||||
: plainText;
|
||||
});
|
||||
|
||||
const statusTextColor = computed(() => {
|
||||
switch (props.status) {
|
||||
case ARTICLE_STATUSES.ARCHIVED:
|
||||
return 'text-n-slate-12';
|
||||
case ARTICLE_STATUSES.DRAFT:
|
||||
return 'text-n-amber-11';
|
||||
default:
|
||||
return 'text-n-teal-11';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<router-link :to="navigateTo">
|
||||
<CardLayout
|
||||
layout="col"
|
||||
class="[&>div]:justify-start [&>div]:gap-2 [&>div]:px-4 [&>div]:pt-4 [&>div]:pb-5 [&>div]:items-start hover:bg-n-slate-2 dark:hover:bg-n-solid-3"
|
||||
>
|
||||
<div class="min-w-0 flex-1 flex flex-col items-start gap-2 w-full">
|
||||
<div class="flex items-center min-w-0 justify-between gap-2 w-full">
|
||||
<div class="flex items-center gap-2">
|
||||
<h5
|
||||
class="text-sm font-medium leading-4 truncate min-w-0 text-n-slate-12"
|
||||
>
|
||||
{{ title }}
|
||||
</h5>
|
||||
<div v-if="category" class="w-px h-4 bg-n-strong mx-2" />
|
||||
<span
|
||||
v-if="category"
|
||||
class="text-xs inline-flex items-center font-medium rounded-md whitespace-nowrap capitalize bg-n-alpha-2 px-1.5 h-6 text-n-slate-12"
|
||||
>
|
||||
{{ category }}
|
||||
</span>
|
||||
<span
|
||||
v-if="status"
|
||||
class="text-xs inline-flex items-center font-medium rounded-md whitespace-nowrap capitalize bg-n-alpha-2 px-2 h-6"
|
||||
:class="statusTextColor"
|
||||
>
|
||||
{{ status }}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
v-if="updatedAtTime"
|
||||
class="text-sm font-normal min-w-0 truncate text-n-slate-11"
|
||||
>
|
||||
{{ updatedAtTime }}
|
||||
</span>
|
||||
</div>
|
||||
<p
|
||||
v-if="truncatedContent"
|
||||
class="text-sm leading-6 text-n-slate-11 line-clamp-2"
|
||||
>
|
||||
{{ truncatedContent }}
|
||||
</p>
|
||||
</div>
|
||||
</CardLayout>
|
||||
</router-link>
|
||||
</template>
|
||||
@@ -0,0 +1,54 @@
|
||||
<script setup>
|
||||
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||
|
||||
import SearchResultSection from './SearchResultSection.vue';
|
||||
import SearchResultArticleItem from './SearchResultArticleItem.vue';
|
||||
|
||||
defineProps({
|
||||
articles: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
query: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
isFetching: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showTitle: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
|
||||
const accountId = useMapGetter('getCurrentAccountId');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SearchResultSection
|
||||
:title="$t('SEARCH.SECTION.ARTICLES')"
|
||||
:empty="!articles.length"
|
||||
:query="query"
|
||||
:show-title="showTitle"
|
||||
:is-fetching="isFetching"
|
||||
>
|
||||
<ul v-if="articles.length" class="space-y-3 list-none">
|
||||
<li v-for="article in articles" :key="article.id">
|
||||
<SearchResultArticleItem
|
||||
:id="article.id"
|
||||
:title="article.title"
|
||||
:description="article.description"
|
||||
:content="article.content"
|
||||
:portal-slug="article.portalSlug"
|
||||
:locale="article.locale"
|
||||
:account-id="accountId"
|
||||
:category="article.categoryName"
|
||||
:status="article.status"
|
||||
:updated-at="article.updatedAt"
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
</SearchResultSection>
|
||||
</template>
|
||||
@@ -0,0 +1,154 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { frontendURL } from 'dashboard/helper/URLHelper';
|
||||
import countries from 'shared/constants/countries';
|
||||
import { dynamicTime } from 'shared/helpers/timeHelper';
|
||||
|
||||
import CardLayout from 'dashboard/components-next/CardLayout.vue';
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
import Flag from 'dashboard/components-next/flag/Flag.vue';
|
||||
|
||||
const props = defineProps({
|
||||
id: {
|
||||
type: [String, Number],
|
||||
default: 0,
|
||||
},
|
||||
email: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
phone: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
thumbnail: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
accountId: {
|
||||
type: [String, Number],
|
||||
default: 0,
|
||||
},
|
||||
additionalAttributes: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
updatedAt: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
});
|
||||
|
||||
const navigateTo = computed(() => {
|
||||
return frontendURL(`accounts/${props.accountId}/contacts/${props.id}`);
|
||||
});
|
||||
|
||||
const countriesMap = computed(() => {
|
||||
return countries.reduce((acc, country) => {
|
||||
acc[country.code] = country;
|
||||
acc[country.id] = country;
|
||||
return acc;
|
||||
}, {});
|
||||
});
|
||||
|
||||
const updatedAtTime = computed(() => {
|
||||
if (!props.updatedAt) return '';
|
||||
return dynamicTime(props.updatedAt);
|
||||
});
|
||||
|
||||
const countryDetails = computed(() => {
|
||||
const { country, countryCode, city } = props.additionalAttributes;
|
||||
|
||||
if (!country && !countryCode) return null;
|
||||
|
||||
const activeCountry =
|
||||
countriesMap.value[country] || countriesMap.value[countryCode];
|
||||
|
||||
if (!activeCountry) return null;
|
||||
|
||||
return {
|
||||
countryCode: activeCountry.id,
|
||||
city: city ? `${city},` : null,
|
||||
name: activeCountry.name,
|
||||
};
|
||||
});
|
||||
|
||||
const formattedLocation = computed(() => {
|
||||
if (!countryDetails.value) return '';
|
||||
|
||||
return [countryDetails.value.city, countryDetails.value.name]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<router-link :to="navigateTo">
|
||||
<CardLayout
|
||||
layout="row"
|
||||
class="[&>div]:justify-start [&>div]:px-4 [&>div]:py-3 [&>div]:items-start hover:bg-n-slate-2 dark:hover:bg-n-solid-3"
|
||||
>
|
||||
<Avatar
|
||||
:name="name"
|
||||
:src="thumbnail"
|
||||
:size="24"
|
||||
rounded-full
|
||||
class="mt-1 flex-shrink-0"
|
||||
/>
|
||||
<div class="min-w-0 flex flex-col items-start gap-1.5 w-full">
|
||||
<div class="flex items-center min-w-0 justify-between gap-2 w-full">
|
||||
<h5 class="text-sm font-medium truncate min-w-0 text-n-slate-12 py-1">
|
||||
{{ name }}
|
||||
</h5>
|
||||
<span
|
||||
v-if="updatedAtTime"
|
||||
class="text-sm font-normal min-w-0 truncate text-n-slate-11"
|
||||
>
|
||||
{{ $t('SEARCH.UPDATED_AT', { time: updatedAtTime }) }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="grid items-center gap-3 m-0 text-sm overflow-hidden min-w-0 grid-cols-[minmax(0,max-content)_auto_minmax(0,max-content)_auto_minmax(0,max-content)]"
|
||||
>
|
||||
<span
|
||||
v-if="email"
|
||||
class="truncate text-n-slate-11 min-w-0"
|
||||
:title="email"
|
||||
>
|
||||
{{ email }}
|
||||
</span>
|
||||
|
||||
<div v-if="email && phone" class="w-px h-3 bg-n-slate-6 rounded" />
|
||||
|
||||
<span
|
||||
v-if="phone"
|
||||
:title="phone"
|
||||
class="truncate text-n-slate-11 min-w-0"
|
||||
>
|
||||
{{ phone }}
|
||||
</span>
|
||||
|
||||
<div
|
||||
v-if="(email || phone) && countryDetails"
|
||||
class="w-px h-3 bg-n-slate-6 rounded"
|
||||
/>
|
||||
|
||||
<span
|
||||
v-if="countryDetails"
|
||||
class="truncate text-n-slate-11 flex items-center gap-1 min-w-0"
|
||||
>
|
||||
<Flag
|
||||
:country="countryDetails.countryCode"
|
||||
class="size-3 shrink-0"
|
||||
/>
|
||||
<span class="truncate min-w-0">{{ formattedLocation }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardLayout>
|
||||
</router-link>
|
||||
</template>
|
||||
@@ -0,0 +1,52 @@
|
||||
<script setup>
|
||||
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||
|
||||
import SearchResultSection from './SearchResultSection.vue';
|
||||
import SearchResultContactItem from './SearchResultContactItem.vue';
|
||||
|
||||
defineProps({
|
||||
contacts: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
query: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
isFetching: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showTitle: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
|
||||
const accountId = useMapGetter('getCurrentAccountId');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SearchResultSection
|
||||
:title="$t('SEARCH.SECTION.CONTACTS')"
|
||||
:empty="!contacts.length"
|
||||
:query="query"
|
||||
:show-title="showTitle"
|
||||
:is-fetching="isFetching"
|
||||
>
|
||||
<ul v-if="contacts.length" class="space-y-3 list-none">
|
||||
<li v-for="contact in contacts" :key="contact.id">
|
||||
<SearchResultContactItem
|
||||
:id="contact.id"
|
||||
:name="contact.name"
|
||||
:email="contact.email"
|
||||
:phone="contact.phoneNumber"
|
||||
:additional-attributes="contact.additionalAttributes"
|
||||
:account-id="accountId"
|
||||
:thumbnail="contact.thumbnail"
|
||||
:updated-at="contact.lastActivityAt"
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
</SearchResultSection>
|
||||
</template>
|
||||
@@ -0,0 +1,157 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { frontendURL } from 'dashboard/helper/URLHelper.js';
|
||||
import { dynamicTime } from 'shared/helpers/timeHelper';
|
||||
import { useInbox } from 'dashboard/composables/useInbox';
|
||||
import { getInboxIconByType } from 'dashboard/helper/inbox';
|
||||
|
||||
import CardLayout from 'dashboard/components-next/CardLayout.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
id: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
inbox: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
email: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
accountId: {
|
||||
type: [String, Number],
|
||||
default: '',
|
||||
},
|
||||
createdAt: {
|
||||
type: [String, Date, Number],
|
||||
default: '',
|
||||
},
|
||||
messageId: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
emailSubject: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const { inbox } = useInbox(props.inbox?.id);
|
||||
|
||||
const navigateTo = computed(() => {
|
||||
const params = {};
|
||||
if (props.messageId) {
|
||||
params.messageId = props.messageId;
|
||||
}
|
||||
return frontendURL(
|
||||
`accounts/${props.accountId}/conversations/${props.id}`,
|
||||
params
|
||||
);
|
||||
});
|
||||
|
||||
const createdAtTime = computed(() => {
|
||||
if (!props.createdAt) return '';
|
||||
return dynamicTime(props.createdAt);
|
||||
});
|
||||
|
||||
const infoItems = computed(() => [
|
||||
{
|
||||
label: 'SEARCH.FROM',
|
||||
value: props.name,
|
||||
show: !!props.name,
|
||||
},
|
||||
{
|
||||
label: 'SEARCH.EMAIL',
|
||||
value: props.email,
|
||||
show: !!props.email,
|
||||
},
|
||||
{
|
||||
label: 'SEARCH.EMAIL_SUBJECT',
|
||||
value: props.emailSubject,
|
||||
show: !!props.emailSubject,
|
||||
},
|
||||
]);
|
||||
|
||||
const visibleInfoItems = computed(() =>
|
||||
infoItems.value.filter(item => item.show)
|
||||
);
|
||||
|
||||
const inboxName = computed(() => props.inbox?.name);
|
||||
|
||||
const inboxIcon = computed(() => {
|
||||
if (!inbox.value) return null;
|
||||
const { channelType, medium } = inbox.value;
|
||||
return getInboxIconByType(channelType, medium);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<router-link :to="navigateTo">
|
||||
<CardLayout
|
||||
layout="col"
|
||||
class="[&>div]:justify-start [&>div]:gap-2 [&>div]:px-4 [&>div]:py-3 [&>div]:items-start hover:bg-n-slate-2 dark:hover:bg-n-solid-3"
|
||||
>
|
||||
<div
|
||||
class="flex items-center min-w-0 justify-between gap-2 w-full h-7 mb-1"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center gap-1.5 flex-shrink-0">
|
||||
<Icon
|
||||
icon="i-lucide-hash"
|
||||
class="flex-shrink-0 text-n-slate-11 size-4"
|
||||
/>
|
||||
<span class="text-n-slate-12 text-sm leading-4">
|
||||
{{ id }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="inboxName" class="w-px h-3 bg-n-strong" />
|
||||
<div v-if="inboxName" class="flex items-center gap-1.5 flex-shrink-0">
|
||||
<div
|
||||
v-if="inboxIcon"
|
||||
class="flex items-center justify-center flex-shrink-0 rounded-full bg-n-alpha-2 size-4"
|
||||
>
|
||||
<Icon
|
||||
:icon="inboxIcon"
|
||||
class="flex-shrink-0 text-n-slate-11 size-2.5"
|
||||
/>
|
||||
</div>
|
||||
<span class="text-sm leading-4 text-n-slate-12">
|
||||
{{ inboxName }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
v-if="createdAtTime"
|
||||
class="text-sm font-normal min-w-0 truncate text-n-slate-11"
|
||||
>
|
||||
{{ createdAtTime }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-x-2 gap-y-1.5 items-center">
|
||||
<template
|
||||
v-for="(item, index) in visibleInfoItems"
|
||||
:key="`info-${index}`"
|
||||
>
|
||||
<h5 class="m-0 text-sm min-w-0 text-n-slate-12 truncate">
|
||||
<span class="text-sm leading-4 font-normal text-n-slate-11">
|
||||
{{ $t(item.label) + ':' }}
|
||||
</span>
|
||||
{{ item.value }}
|
||||
</h5>
|
||||
<div
|
||||
v-if="index < visibleInfoItems.length - 1"
|
||||
class="w-px h-3 bg-n-strong"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
<slot />
|
||||
</CardLayout>
|
||||
</router-link>
|
||||
</template>
|
||||
@@ -0,0 +1,62 @@
|
||||
<script setup>
|
||||
import { defineProps, computed } from 'vue';
|
||||
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||
|
||||
import SearchResultSection from './SearchResultSection.vue';
|
||||
import SearchResultConversationItem from './SearchResultConversationItem.vue';
|
||||
|
||||
const props = defineProps({
|
||||
conversations: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
query: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
isFetching: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showTitle: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
|
||||
const accountId = useMapGetter('getCurrentAccountId');
|
||||
|
||||
const conversationsWithSubject = computed(() => {
|
||||
return props.conversations.map(conversation => ({
|
||||
...conversation,
|
||||
mailSubject: conversation.additionalAttributes?.mailSubject || '',
|
||||
}));
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SearchResultSection
|
||||
:title="$t('SEARCH.SECTION.CONVERSATIONS')"
|
||||
:empty="!conversations.length"
|
||||
:query="query"
|
||||
:show-title="showTitle"
|
||||
:is-fetching="isFetching"
|
||||
>
|
||||
<ul v-if="conversations.length" class="space-y-3 list-none">
|
||||
<li
|
||||
v-for="conversation in conversationsWithSubject"
|
||||
:key="conversation.id"
|
||||
>
|
||||
<SearchResultConversationItem
|
||||
:id="conversation.id"
|
||||
:name="conversation.contact.name"
|
||||
:email="conversation.contact.email"
|
||||
:account-id="accountId"
|
||||
:inbox="conversation.inbox"
|
||||
:created-at="conversation.createdAt"
|
||||
:email-subject="conversation.mailSubject"
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
</SearchResultSection>
|
||||
</template>
|
||||
@@ -0,0 +1,169 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { frontendURL } from 'dashboard/helper/URLHelper.js';
|
||||
import { dynamicTime } from 'shared/helpers/timeHelper';
|
||||
import { getInboxIconByType } from 'dashboard/helper/inbox';
|
||||
import { useInbox } from 'dashboard/composables/useInbox';
|
||||
import { ATTACHMENT_TYPES } from 'dashboard/components-next/message/constants.js';
|
||||
|
||||
import CardLayout from 'dashboard/components-next/CardLayout.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import FileChip from 'next/message/chips/File.vue';
|
||||
import AudioChip from 'next/message/chips/Audio.vue';
|
||||
import TranscribedText from './TranscribedText.vue';
|
||||
|
||||
const props = defineProps({
|
||||
id: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
inboxId: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
isPrivate: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
accountId: {
|
||||
type: [String, Number],
|
||||
default: '',
|
||||
},
|
||||
createdAt: {
|
||||
type: [String, Date, Number],
|
||||
default: '',
|
||||
},
|
||||
messageId: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
attachments: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const { inbox } = useInbox(props.inboxId);
|
||||
|
||||
const navigateTo = computed(() => {
|
||||
const params = {};
|
||||
if (props.messageId) {
|
||||
params.messageId = props.messageId;
|
||||
}
|
||||
return frontendURL(
|
||||
`accounts/${props.accountId}/conversations/${props.id}`,
|
||||
params
|
||||
);
|
||||
});
|
||||
|
||||
const createdAtTime = computed(() => {
|
||||
if (!props.createdAt) return '';
|
||||
return dynamicTime(props.createdAt);
|
||||
});
|
||||
|
||||
const inboxName = computed(() => inbox.value?.name);
|
||||
|
||||
const inboxIcon = computed(() => {
|
||||
if (!inbox.value) return null;
|
||||
const { channelType, medium } = inbox.value;
|
||||
return getInboxIconByType(channelType, medium);
|
||||
});
|
||||
|
||||
const fileAttachments = computed(() => {
|
||||
return props.attachments.filter(
|
||||
attachment => attachment.fileType !== ATTACHMENT_TYPES.AUDIO
|
||||
);
|
||||
});
|
||||
|
||||
const audioAttachments = computed(() => {
|
||||
return props.attachments.filter(
|
||||
attachment => attachment.fileType === ATTACHMENT_TYPES.AUDIO
|
||||
);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<router-link :to="navigateTo">
|
||||
<CardLayout
|
||||
layout="col"
|
||||
class="[&>div]:justify-start [&>div]:gap-2 [&>div]:px-4 [&>div]:py-3 [&>div]:items-start hover:bg-n-slate-2 dark:hover:bg-n-solid-3"
|
||||
>
|
||||
<div
|
||||
class="flex items-center min-w-0 justify-between gap-2 w-full h-7 mb-1"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center gap-1.5 flex-shrink-0">
|
||||
<Icon
|
||||
icon="i-lucide-hash"
|
||||
class="flex-shrink-0 text-n-slate-11 size-4"
|
||||
/>
|
||||
<span class="text-n-slate-12 text-sm leading-4">
|
||||
{{ id }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="inboxName" class="w-px h-3 bg-n-strong" />
|
||||
<div v-if="inboxName" class="flex items-center gap-1.5 flex-shrink-0">
|
||||
<div
|
||||
v-if="inboxIcon"
|
||||
class="flex items-center justify-center flex-shrink-0 rounded-full bg-n-alpha-2 size-4"
|
||||
>
|
||||
<Icon
|
||||
:icon="inboxIcon"
|
||||
class="flex-shrink-0 text-n-slate-11 size-2.5"
|
||||
/>
|
||||
</div>
|
||||
<span class="text-sm leading-4 text-n-slate-12">
|
||||
{{ inboxName }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="isPrivate" class="w-px h-3 bg-n-strong" />
|
||||
<div
|
||||
v-if="isPrivate"
|
||||
class="flex items-center text-n-amber-11 gap-1.5 flex-shrink-0"
|
||||
>
|
||||
<Icon icon="i-lucide-lock-keyhole" class="flex-shrink-0 size-3.5" />
|
||||
<span class="text-sm leading-4">
|
||||
{{ $t('SEARCH.PRIVATE') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
v-if="createdAtTime"
|
||||
class="text-sm font-normal min-w-0 truncate text-n-slate-11"
|
||||
>
|
||||
{{ createdAtTime }}
|
||||
</span>
|
||||
</div>
|
||||
<slot />
|
||||
<div v-if="audioAttachments.length" class="mt-1.5 space-y-4 w-full">
|
||||
<div
|
||||
v-for="attachment in audioAttachments"
|
||||
:key="attachment.id"
|
||||
class="w-full"
|
||||
>
|
||||
<AudioChip
|
||||
class="bg-n-alpha-2 dark:bg-n-alpha-2 text-n-slate-12"
|
||||
:attachment="attachment"
|
||||
:show-transcribed-text="false"
|
||||
@click.prevent
|
||||
/>
|
||||
<div v-if="attachment.transcribedText" class="pt-2">
|
||||
<TranscribedText :text="attachment.transcribedText" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="fileAttachments.length"
|
||||
class="flex gap-2 flex-wrap items-center mt-1.5"
|
||||
>
|
||||
<FileChip
|
||||
v-for="attachment in fileAttachments"
|
||||
:key="attachment.id"
|
||||
:attachment="attachment"
|
||||
class="!h-8"
|
||||
@click.stop
|
||||
/>
|
||||
</div>
|
||||
</CardLayout>
|
||||
</router-link>
|
||||
</template>
|
||||
@@ -0,0 +1,66 @@
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||
|
||||
import SearchResultMessageItem from './SearchResultMessageItem.vue';
|
||||
import SearchResultSection from './SearchResultSection.vue';
|
||||
import MessageContent from './MessageContent.vue';
|
||||
|
||||
defineProps({
|
||||
messages: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
query: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
isFetching: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showTitle: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
const { t } = useI18n();
|
||||
|
||||
const accountId = useMapGetter('getCurrentAccountId');
|
||||
|
||||
const getName = message => {
|
||||
return message && message.sender && message.sender.name
|
||||
? message.sender.name
|
||||
: t('SEARCH.BOT_LABEL');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SearchResultSection
|
||||
:title="$t('SEARCH.SECTION.MESSAGES')"
|
||||
:empty="!messages.length"
|
||||
:query="query"
|
||||
:show-title="showTitle"
|
||||
:is-fetching="isFetching"
|
||||
>
|
||||
<ul v-if="messages.length" class="space-y-3 list-none">
|
||||
<li v-for="message in messages" :key="message.id">
|
||||
<SearchResultMessageItem
|
||||
:id="message.conversationId"
|
||||
:account-id="accountId"
|
||||
:inbox-id="message.inboxId"
|
||||
:created-at="message.createdAt"
|
||||
:message-id="message.id"
|
||||
:is-private="message.private"
|
||||
:attachments="message.attachments"
|
||||
>
|
||||
<MessageContent
|
||||
:author="getName(message)"
|
||||
:message="message"
|
||||
:search-term="query"
|
||||
/>
|
||||
</SearchResultMessageItem>
|
||||
</li>
|
||||
</ul>
|
||||
</SearchResultSection>
|
||||
</template>
|
||||
@@ -0,0 +1,58 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
empty: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
query: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
showTitle: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
isFetching: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
|
||||
const titleCase = computed(() => props.title.toLowerCase());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="mx-0 mb-3">
|
||||
<div
|
||||
v-if="showTitle"
|
||||
class="sticky top-0 pt-2 py-3 z-20 bg-gradient-to-b from-n-surface-1 from-80% to-transparent mb-3 -mx-1.5 px-1.5"
|
||||
>
|
||||
<h3 class="text-sm text-n-slate-11">{{ title }}</h3>
|
||||
</div>
|
||||
<slot />
|
||||
<woot-loading-state
|
||||
v-if="isFetching"
|
||||
:message="empty ? $t('SEARCH.SEARCHING_DATA') : $t('SEARCH.LOADING_DATA')"
|
||||
/>
|
||||
<div
|
||||
v-if="empty && !isFetching"
|
||||
class="flex items-start justify-center px-4 py-6 rounded-xl bg-n-slate-2 dark:bg-n-solid-1"
|
||||
>
|
||||
<Icon
|
||||
icon="i-lucide-info"
|
||||
class="text-n-slate-11 size-4 flex-shrink-0 mt-[3px]"
|
||||
/>
|
||||
<p class="mx-2 my-0 text-center text-n-slate-11">
|
||||
{{ $t('SEARCH.EMPTY_STATE', { item: titleCase, query }) }}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,64 @@
|
||||
<script setup>
|
||||
import { computed, watch, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import TabBar from 'dashboard/components-next/tabbar/TabBar.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const props = defineProps({
|
||||
tabs: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
selectedTab: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['tabChange']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const activeTab = ref(props.selectedTab);
|
||||
|
||||
watch(
|
||||
() => props.selectedTab,
|
||||
(value, oldValue) => {
|
||||
if (value !== oldValue) {
|
||||
activeTab.value = props.selectedTab;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const tabBarTabs = computed(() => {
|
||||
return props.tabs.map(tab => ({
|
||||
label: tab.name,
|
||||
count: tab.showBadge ? tab.count : null,
|
||||
}));
|
||||
});
|
||||
|
||||
const onTabChange = selectedTab => {
|
||||
const index = props.tabs.findIndex(tab => tab.name === selectedTab.label);
|
||||
activeTab.value = index;
|
||||
emit('tabChange', props.tabs[index].key);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center justify-between mt-7 mb-4">
|
||||
<TabBar
|
||||
:tabs="tabBarTabs"
|
||||
:initial-active-tab="activeTab"
|
||||
@tab-changed="onTabChange"
|
||||
/>
|
||||
|
||||
<Button
|
||||
:label="t('SEARCH.SORT_BY.RELEVANCE')"
|
||||
sm
|
||||
link
|
||||
slate
|
||||
class="hover:!no-underline pointer-events-none lg:inline-flex hidden"
|
||||
icon="i-lucide-arrow-up-down"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,522 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue';
|
||||
import { useMapGetter, useStore } from 'dashboard/composables/store.js';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { useTrack } from 'dashboard/composables';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
|
||||
import { generateURLParams, parseURLParams } from '../helpers/searchHelper';
|
||||
import {
|
||||
ROLES,
|
||||
CONVERSATION_PERMISSIONS,
|
||||
CONTACT_PERMISSIONS,
|
||||
PORTAL_PERMISSIONS,
|
||||
} from 'dashboard/constants/permissions.js';
|
||||
import { usePolicy } from 'dashboard/composables/usePolicy';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events';
|
||||
|
||||
import Policy from 'dashboard/components/policy.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import SearchHeader from './SearchHeader.vue';
|
||||
import SearchTabs from './SearchTabs.vue';
|
||||
import SearchResultConversationsList from './SearchResultConversationsList.vue';
|
||||
import SearchResultMessagesList from './SearchResultMessagesList.vue';
|
||||
import SearchResultContactsList from './SearchResultContactsList.vue';
|
||||
import SearchResultArticlesList from './SearchResultArticlesList.vue';
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
const PER_PAGE = 15; // Results per page
|
||||
const selectedTab = ref(route.params.tab || 'all');
|
||||
const query = ref(route.query.q || '');
|
||||
const pages = ref({
|
||||
contacts: 1,
|
||||
conversations: 1,
|
||||
messages: 1,
|
||||
articles: 1,
|
||||
});
|
||||
|
||||
const contactRecords = useMapGetter('conversationSearch/getContactRecords');
|
||||
const conversationRecords = useMapGetter(
|
||||
'conversationSearch/getConversationRecords'
|
||||
);
|
||||
const messageRecords = useMapGetter('conversationSearch/getMessageRecords');
|
||||
const articleRecords = useMapGetter('conversationSearch/getArticleRecords');
|
||||
const uiFlags = useMapGetter('conversationSearch/getUIFlags');
|
||||
|
||||
const addTypeToRecords = (records, type) =>
|
||||
records.value.map(item => ({ ...useCamelCase(item, { deep: true }), type }));
|
||||
|
||||
const mappedContacts = computed(() =>
|
||||
addTypeToRecords(contactRecords, 'contact')
|
||||
);
|
||||
const mappedConversations = computed(() =>
|
||||
addTypeToRecords(conversationRecords, 'conversation')
|
||||
);
|
||||
const mappedMessages = computed(() =>
|
||||
addTypeToRecords(messageRecords, 'message')
|
||||
);
|
||||
const mappedArticles = computed(() =>
|
||||
addTypeToRecords(articleRecords, 'article')
|
||||
);
|
||||
|
||||
const isSelectedTabAll = computed(() => selectedTab.value === 'all');
|
||||
|
||||
const searchResultSectionClass = computed(() => ({
|
||||
'mt-4': isSelectedTabAll.value,
|
||||
'mt-0.5': !isSelectedTabAll.value,
|
||||
}));
|
||||
|
||||
const sliceRecordsIfAllTab = items =>
|
||||
isSelectedTabAll.value ? items.value.slice(0, 5) : items.value;
|
||||
|
||||
const contacts = computed(() => sliceRecordsIfAllTab(mappedContacts));
|
||||
const conversations = computed(() => sliceRecordsIfAllTab(mappedConversations));
|
||||
const messages = computed(() => sliceRecordsIfAllTab(mappedMessages));
|
||||
const articles = computed(() => sliceRecordsIfAllTab(mappedArticles));
|
||||
|
||||
const filterByTab = tab =>
|
||||
computed(() => selectedTab.value === tab || isSelectedTabAll.value);
|
||||
|
||||
const filterContacts = filterByTab('contacts');
|
||||
const filterConversations = filterByTab('conversations');
|
||||
const filterMessages = filterByTab('messages');
|
||||
const filterArticles = filterByTab('articles');
|
||||
|
||||
const { shouldShow, isFeatureFlagEnabled } = usePolicy();
|
||||
|
||||
const TABS_CONFIG = {
|
||||
all: {
|
||||
permissions: [
|
||||
CONTACT_PERMISSIONS,
|
||||
...ROLES,
|
||||
...CONVERSATION_PERMISSIONS,
|
||||
PORTAL_PERMISSIONS,
|
||||
],
|
||||
count: () => null, // No count for all tab
|
||||
},
|
||||
contacts: {
|
||||
permissions: [...ROLES, CONTACT_PERMISSIONS],
|
||||
count: () => mappedContacts.value.length,
|
||||
},
|
||||
conversations: {
|
||||
permissions: [...ROLES, ...CONVERSATION_PERMISSIONS],
|
||||
count: () => mappedConversations.value.length,
|
||||
},
|
||||
messages: {
|
||||
permissions: [...ROLES, ...CONVERSATION_PERMISSIONS],
|
||||
count: () => mappedMessages.value.length,
|
||||
},
|
||||
articles: {
|
||||
permissions: [...ROLES, PORTAL_PERMISSIONS],
|
||||
featureFlag: FEATURE_FLAGS.HELP_CENTER,
|
||||
count: () => mappedArticles.value.length,
|
||||
},
|
||||
};
|
||||
|
||||
const tabs = computed(() => {
|
||||
return Object.entries(TABS_CONFIG)
|
||||
.map(([key, config]) => ({
|
||||
key,
|
||||
name: t(`SEARCH.TABS.${key.toUpperCase()}`),
|
||||
count: config.count(),
|
||||
showBadge: key !== 'all',
|
||||
permissions: config.permissions,
|
||||
featureFlag: config.featureFlag,
|
||||
}))
|
||||
.filter(config => {
|
||||
// why the double check, glad you asked.
|
||||
// Some features are marked as premium features, that means
|
||||
// the feature will be visible, but a Paywall will be shown instead
|
||||
// this works for pages and routes, but fails for UI elements like search here
|
||||
// so we explicitly check if the feature is enabled
|
||||
return (
|
||||
shouldShow(config.featureFlag, config.permissions, null) &&
|
||||
isFeatureFlagEnabled(config.featureFlag)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
const totalSearchResultsCount = computed(() => {
|
||||
const permissionCounts = [
|
||||
{
|
||||
permissions: [...ROLES, CONTACT_PERMISSIONS],
|
||||
count: () => contacts.value.length,
|
||||
},
|
||||
{
|
||||
permissions: [...ROLES, ...CONVERSATION_PERMISSIONS],
|
||||
count: () => conversations.value.length + messages.value.length,
|
||||
},
|
||||
{
|
||||
permissions: [...ROLES, PORTAL_PERMISSIONS],
|
||||
featureFlag: FEATURE_FLAGS.HELP_CENTER,
|
||||
count: () => articles.value.length,
|
||||
},
|
||||
];
|
||||
|
||||
return permissionCounts
|
||||
.filter(config => {
|
||||
// why the double check, glad you asked.
|
||||
// Some features are marked as premium features, that means
|
||||
// the feature will be visible, but a Paywall will be shown instead
|
||||
// this works for pages and routes, but fails for UI elements like search here
|
||||
// so we explicitly check if the feature is enabled
|
||||
return (
|
||||
shouldShow(config.featureFlag, config.permissions, null) &&
|
||||
isFeatureFlagEnabled(config.featureFlag)
|
||||
);
|
||||
})
|
||||
.map(config => {
|
||||
return config.count();
|
||||
})
|
||||
.reduce((sum, count) => sum + count, 0);
|
||||
});
|
||||
|
||||
const activeTabIndex = computed(() => {
|
||||
const index = tabs.value.findIndex(tab => tab.key === selectedTab.value);
|
||||
return index >= 0 ? index : 0;
|
||||
});
|
||||
|
||||
const isFetchingAny = computed(() => {
|
||||
const { contact, message, conversation, article, isFetching } = uiFlags.value;
|
||||
return (
|
||||
isFetching ||
|
||||
contact.isFetching ||
|
||||
message.isFetching ||
|
||||
conversation.isFetching ||
|
||||
article.isFetching
|
||||
);
|
||||
});
|
||||
|
||||
const showEmptySearchResults = computed(
|
||||
() =>
|
||||
totalSearchResultsCount.value === 0 &&
|
||||
uiFlags.value.isSearchCompleted &&
|
||||
isSelectedTabAll.value &&
|
||||
!isFetchingAny.value &&
|
||||
query.value
|
||||
);
|
||||
|
||||
const showResultsSection = computed(
|
||||
() =>
|
||||
(uiFlags.value.isSearchCompleted && totalSearchResultsCount.value !== 0) ||
|
||||
isFetchingAny.value ||
|
||||
(!isSelectedTabAll.value && query.value && !isFetchingAny.value)
|
||||
);
|
||||
|
||||
const showLoadMore = computed(() => {
|
||||
if (!query.value || isFetchingAny.value || selectedTab.value === 'all')
|
||||
return false;
|
||||
|
||||
const records = {
|
||||
contacts: mappedContacts.value,
|
||||
conversations: mappedConversations.value,
|
||||
messages: mappedMessages.value,
|
||||
articles: mappedArticles.value,
|
||||
}[selectedTab.value];
|
||||
|
||||
return (
|
||||
records?.length > 0 &&
|
||||
records.length === pages.value[selectedTab.value] * PER_PAGE
|
||||
);
|
||||
});
|
||||
|
||||
const showViewMore = computed(() => ({
|
||||
// Hide view more button if the number of records is less than 5
|
||||
contacts: mappedContacts.value?.length > 5 && isSelectedTabAll.value,
|
||||
conversations:
|
||||
mappedConversations.value?.length > 5 && isSelectedTabAll.value,
|
||||
messages: mappedMessages.value?.length > 5 && isSelectedTabAll.value,
|
||||
articles: mappedArticles.value?.length > 5 && isSelectedTabAll.value,
|
||||
}));
|
||||
|
||||
const filters = ref({
|
||||
from: null,
|
||||
in: null,
|
||||
dateRange: { type: null, from: null, to: null },
|
||||
});
|
||||
|
||||
const clearSearchResult = () => {
|
||||
pages.value = { contacts: 1, conversations: 1, messages: 1, articles: 1 };
|
||||
store.dispatch('conversationSearch/clearSearchResults');
|
||||
};
|
||||
|
||||
const buildSearchPayload = (basePayload = {}, searchType = 'message') => {
|
||||
const payload = { ...basePayload };
|
||||
|
||||
// Only include filters if advanced search is enabled
|
||||
if (isFeatureFlagEnabled(FEATURE_FLAGS.ADVANCED_SEARCH)) {
|
||||
// Date filters apply to all search types
|
||||
if (filters.value.dateRange.from) {
|
||||
payload.since = filters.value.dateRange.from;
|
||||
}
|
||||
if (filters.value.dateRange.to) {
|
||||
payload.until = filters.value.dateRange.to;
|
||||
}
|
||||
|
||||
// Only messages support 'from' and 'inboxId' filters
|
||||
if (searchType === 'message') {
|
||||
if (filters.value.from) payload.from = filters.value.from;
|
||||
if (filters.value.in) payload.inboxId = filters.value.in;
|
||||
}
|
||||
}
|
||||
|
||||
return payload;
|
||||
};
|
||||
|
||||
const updateURL = () => {
|
||||
const params = {
|
||||
accountId: route.params.accountId,
|
||||
...(selectedTab.value !== 'all' && { tab: selectedTab.value }),
|
||||
};
|
||||
|
||||
const queryParams = {
|
||||
...(query.value?.trim() && { q: query.value.trim() }),
|
||||
...generateURLParams(
|
||||
filters.value,
|
||||
isFeatureFlagEnabled(FEATURE_FLAGS.ADVANCED_SEARCH)
|
||||
),
|
||||
};
|
||||
|
||||
router.replace({ name: 'search', params, query: queryParams });
|
||||
};
|
||||
|
||||
const onSearch = q => {
|
||||
query.value = q;
|
||||
clearSearchResult();
|
||||
updateURL();
|
||||
if (!q) return;
|
||||
useTrack(CONVERSATION_EVENTS.SEARCH_CONVERSATION);
|
||||
|
||||
const searchPayload = buildSearchPayload({ q, page: 1 });
|
||||
store.dispatch('conversationSearch/fullSearch', searchPayload);
|
||||
};
|
||||
|
||||
const onFilterChange = () => {
|
||||
onSearch(query.value);
|
||||
};
|
||||
|
||||
const onBack = () => {
|
||||
if (window.history.length > 2) {
|
||||
router.go(-1);
|
||||
} else {
|
||||
router.push({ name: 'home' });
|
||||
}
|
||||
clearSearchResult();
|
||||
};
|
||||
|
||||
const loadMore = () => {
|
||||
const SEARCH_ACTIONS = {
|
||||
contacts: 'conversationSearch/contactSearch',
|
||||
conversations: 'conversationSearch/conversationSearch',
|
||||
messages: 'conversationSearch/messageSearch',
|
||||
articles: 'conversationSearch/articleSearch',
|
||||
};
|
||||
|
||||
if (uiFlags.value.isFetching || selectedTab.value === 'all') return;
|
||||
|
||||
const tab = selectedTab.value;
|
||||
pages.value[tab] += 1;
|
||||
|
||||
const payload = buildSearchPayload(
|
||||
{ q: query.value, page: pages.value[tab] },
|
||||
tab
|
||||
);
|
||||
|
||||
store.dispatch(SEARCH_ACTIONS[tab], payload);
|
||||
};
|
||||
|
||||
const onTabChange = tab => {
|
||||
selectedTab.value = tab;
|
||||
updateURL();
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
store.dispatch('conversationSearch/clearSearchResults');
|
||||
store.dispatch('agents/get');
|
||||
|
||||
const parsedFilters = parseURLParams(
|
||||
route.query,
|
||||
isFeatureFlagEnabled(FEATURE_FLAGS.ADVANCED_SEARCH)
|
||||
);
|
||||
filters.value = parsedFilters;
|
||||
|
||||
// Auto-execute search if query parameter exists
|
||||
if (route.query.q) {
|
||||
onSearch(route.query.q);
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
query.value = '';
|
||||
store.dispatch('conversationSearch/clearSearchResults');
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col w-full h-full bg-n-surface-1">
|
||||
<div class="flex w-full p-4">
|
||||
<NextButton
|
||||
:label="t('GENERAL_SETTINGS.BACK')"
|
||||
icon="i-lucide-chevron-left"
|
||||
faded
|
||||
primary
|
||||
sm
|
||||
@click="onBack"
|
||||
/>
|
||||
</div>
|
||||
<section class="flex flex-col flex-grow w-full h-full overflow-hidden">
|
||||
<div class="w-full max-w-5xl mx-auto z-30">
|
||||
<div class="flex flex-col w-full px-4">
|
||||
<SearchHeader
|
||||
v-model:filters="filters"
|
||||
:initial-query="query"
|
||||
@search="onSearch"
|
||||
@filter-change="onFilterChange"
|
||||
/>
|
||||
<SearchTabs
|
||||
v-if="query"
|
||||
:tabs="tabs"
|
||||
:selected-tab="activeTabIndex"
|
||||
@tab-change="onTabChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-grow w-full h-full overflow-y-auto">
|
||||
<div class="w-full max-w-5xl mx-auto px-4 pb-6">
|
||||
<div v-if="showResultsSection">
|
||||
<Policy
|
||||
:permissions="[...ROLES, CONTACT_PERMISSIONS]"
|
||||
class="flex flex-col justify-center"
|
||||
>
|
||||
<SearchResultContactsList
|
||||
v-if="filterContacts"
|
||||
:is-fetching="uiFlags.contact.isFetching"
|
||||
:contacts="contacts"
|
||||
:query="query"
|
||||
:show-title="isSelectedTabAll"
|
||||
class="mt-0.5"
|
||||
/>
|
||||
<NextButton
|
||||
v-if="showViewMore.contacts"
|
||||
:label="t(`SEARCH.VIEW_MORE`)"
|
||||
icon="i-lucide-eye"
|
||||
slate
|
||||
sm
|
||||
outline
|
||||
@click="selectedTab = 'contacts'"
|
||||
/>
|
||||
</Policy>
|
||||
|
||||
<Policy
|
||||
:permissions="[...ROLES, ...CONVERSATION_PERMISSIONS]"
|
||||
class="flex flex-col justify-center"
|
||||
>
|
||||
<SearchResultMessagesList
|
||||
v-if="filterMessages"
|
||||
:is-fetching="uiFlags.message.isFetching"
|
||||
:messages="messages"
|
||||
:query="query"
|
||||
:show-title="isSelectedTabAll"
|
||||
:class="searchResultSectionClass"
|
||||
/>
|
||||
<NextButton
|
||||
v-if="showViewMore.messages"
|
||||
:label="t(`SEARCH.VIEW_MORE`)"
|
||||
icon="i-lucide-eye"
|
||||
slate
|
||||
sm
|
||||
outline
|
||||
@click="selectedTab = 'messages'"
|
||||
/>
|
||||
</Policy>
|
||||
|
||||
<Policy
|
||||
:permissions="[...ROLES, ...CONVERSATION_PERMISSIONS]"
|
||||
class="flex flex-col justify-center"
|
||||
>
|
||||
<SearchResultConversationsList
|
||||
v-if="filterConversations"
|
||||
:is-fetching="uiFlags.conversation.isFetching"
|
||||
:conversations="conversations"
|
||||
:query="query"
|
||||
:show-title="isSelectedTabAll"
|
||||
:class="searchResultSectionClass"
|
||||
/>
|
||||
<NextButton
|
||||
v-if="showViewMore.conversations"
|
||||
:label="t(`SEARCH.VIEW_MORE`)"
|
||||
icon="i-lucide-eye"
|
||||
slate
|
||||
sm
|
||||
outline
|
||||
@click="selectedTab = 'conversations'"
|
||||
/>
|
||||
</Policy>
|
||||
|
||||
<Policy
|
||||
v-if="isFeatureFlagEnabled(FEATURE_FLAGS.HELP_CENTER)"
|
||||
:permissions="[...ROLES, PORTAL_PERMISSIONS]"
|
||||
:feature-flag="FEATURE_FLAGS.HELP_CENTER"
|
||||
class="flex flex-col justify-center"
|
||||
>
|
||||
<SearchResultArticlesList
|
||||
v-if="filterArticles"
|
||||
:is-fetching="uiFlags.article.isFetching"
|
||||
:articles="articles"
|
||||
:query="query"
|
||||
:show-title="isSelectedTabAll"
|
||||
:class="searchResultSectionClass"
|
||||
/>
|
||||
<NextButton
|
||||
v-if="showViewMore.articles"
|
||||
:label="t(`SEARCH.VIEW_MORE`)"
|
||||
icon="i-lucide-eye"
|
||||
slate
|
||||
sm
|
||||
outline
|
||||
@click="selectedTab = 'articles'"
|
||||
/>
|
||||
</Policy>
|
||||
|
||||
<div v-if="showLoadMore" class="flex justify-center mt-3 mb-6">
|
||||
<NextButton
|
||||
v-if="!isSelectedTabAll"
|
||||
:label="t(`SEARCH.LOAD_MORE`)"
|
||||
icon="i-lucide-cloud-download"
|
||||
slate
|
||||
sm
|
||||
faded
|
||||
@click="loadMore"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="showEmptySearchResults"
|
||||
class="flex flex-col items-center justify-center px-4 py-6 mt-8 rounded-md"
|
||||
>
|
||||
<fluent-icon icon="info" size="16px" class="text-n-slate-11" />
|
||||
<p class="m-2 text-center text-n-slate-11">
|
||||
{{ t('SEARCH.EMPTY_STATE_FULL', { query }) }}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="!query"
|
||||
class="flex flex-col items-center justify-center px-4 py-6 mt-8 text-center rounded-md"
|
||||
>
|
||||
<p class="text-center margin-bottom-0">
|
||||
<fluent-icon icon="search" size="24px" class="text-n-slate-11" />
|
||||
</p>
|
||||
<p class="m-2 text-center text-n-slate-11">
|
||||
{{ t('SEARCH.EMPTY_STATE_DEFAULT') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,45 @@
|
||||
<script setup>
|
||||
import { useExpandableContent } from 'shared/composables/useExpandableContent';
|
||||
|
||||
defineProps({
|
||||
text: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const { contentElement, showReadMore, showReadLess, toggleExpanded } =
|
||||
useExpandableContent({ useResizeObserverForCheck: true });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="py-2 text-xs font-medium">
|
||||
{{ $t('SEARCH.TRANSCRIPT') }}
|
||||
</span>
|
||||
<div
|
||||
class="text-n-slate-11 pt-1 text-sm rounded-lg w-full break-words grid items-center"
|
||||
:class="showReadMore ? 'grid-cols-[1fr_auto]' : 'grid-cols-1'"
|
||||
>
|
||||
<div
|
||||
ref="contentElement"
|
||||
class="min-w-0"
|
||||
:class="{ 'overflow-hidden line-clamp-1': showReadMore }"
|
||||
>
|
||||
{{ text }}
|
||||
<button
|
||||
v-if="showReadLess"
|
||||
class="text-sm text-n-slate-11 underline cursor-pointer bg-transparent border-0 p-0 hover:text-n-slate-12 font-medium ltr:ml-0.5 rtl:mr-0.5"
|
||||
@click.prevent.stop="toggleExpanded(false)"
|
||||
>
|
||||
{{ $t('SEARCH.READ_LESS') }}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
v-if="showReadMore"
|
||||
class="text-sm text-n-slate-11 underline cursor-pointer bg-transparent border-0 p-0 hover:text-n-slate-12 font-medium justify-self-end ltr:ml-0.5 rtl:mr-0.5"
|
||||
@click.prevent.stop="toggleExpanded(true)"
|
||||
>
|
||||
{{ $t('SEARCH.READ_MORE') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,75 @@
|
||||
import ContactAPI from 'dashboard/api/contacts';
|
||||
|
||||
export const DATE_RANGE_TYPES = {
|
||||
LAST_7_DAYS: 'last_7_days',
|
||||
LAST_30_DAYS: 'last_30_days',
|
||||
LAST_60_DAYS: 'last_60_days',
|
||||
LAST_90_DAYS: 'last_90_days',
|
||||
CUSTOM: 'custom',
|
||||
BETWEEN: 'between',
|
||||
};
|
||||
|
||||
export const generateURLParams = (
|
||||
{ from, in: inboxId, dateRange },
|
||||
isAdvancedSearchEnabled = true
|
||||
) => {
|
||||
const params = {};
|
||||
|
||||
// Only include filter params if advanced search is enabled
|
||||
if (isAdvancedSearchEnabled) {
|
||||
if (from) params.from = from;
|
||||
if (inboxId) params.inbox_id = inboxId;
|
||||
|
||||
if (dateRange?.type) {
|
||||
const { type, from: dateFrom, to: dateTo } = dateRange;
|
||||
params.range = type;
|
||||
|
||||
if (dateFrom) params.since = dateFrom;
|
||||
if (dateTo) params.until = dateTo;
|
||||
}
|
||||
}
|
||||
|
||||
return params;
|
||||
};
|
||||
|
||||
export const parseURLParams = (query, isAdvancedSearchEnabled = true) => {
|
||||
// If advanced search is disabled, return empty filters
|
||||
if (!isAdvancedSearchEnabled) {
|
||||
return {
|
||||
from: null,
|
||||
in: null,
|
||||
dateRange: {
|
||||
type: null,
|
||||
from: null,
|
||||
to: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const { from, inbox_id, since, until, range } = query;
|
||||
|
||||
let type = range;
|
||||
if (!type && (since || until)) {
|
||||
type = DATE_RANGE_TYPES.BETWEEN;
|
||||
}
|
||||
|
||||
return {
|
||||
from: from || null,
|
||||
in: inbox_id ? Number(inbox_id) : null,
|
||||
dateRange: {
|
||||
type,
|
||||
from: since ? Number(since) : null,
|
||||
to: until ? Number(until) : null,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const fetchContactDetails = async id => {
|
||||
try {
|
||||
const response = await ContactAPI.show(id);
|
||||
return response.data.payload;
|
||||
} catch (error) {
|
||||
// error
|
||||
return null;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,379 @@
|
||||
import ContactAPI from 'dashboard/api/contacts';
|
||||
import {
|
||||
DATE_RANGE_TYPES,
|
||||
generateURLParams,
|
||||
parseURLParams,
|
||||
fetchContactDetails,
|
||||
} from '../searchHelper';
|
||||
|
||||
// Mock ContactAPI
|
||||
vi.mock('dashboard/api/contacts', () => ({
|
||||
default: {
|
||||
show: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('#generateURLParams', () => {
|
||||
it('returns empty object when no parameters provided', () => {
|
||||
expect(generateURLParams({})).toEqual({});
|
||||
});
|
||||
|
||||
it('generates params with from parameter', () => {
|
||||
const result = generateURLParams({ from: 'agent:123' });
|
||||
expect(result).toEqual({ from: 'agent:123' });
|
||||
});
|
||||
|
||||
it('generates params with inbox_id parameter', () => {
|
||||
const result = generateURLParams({ in: 456 });
|
||||
expect(result).toEqual({ inbox_id: 456 });
|
||||
});
|
||||
|
||||
it('generates params with all basic parameters', () => {
|
||||
const result = generateURLParams({
|
||||
from: 'contact:789',
|
||||
in: 123,
|
||||
});
|
||||
expect(result).toEqual({
|
||||
from: 'contact:789',
|
||||
inbox_id: 123,
|
||||
});
|
||||
});
|
||||
|
||||
describe('with date range', () => {
|
||||
it('generates params with date range type only', () => {
|
||||
const result = generateURLParams({
|
||||
dateRange: { type: DATE_RANGE_TYPES.LAST_7_DAYS },
|
||||
});
|
||||
expect(result).toEqual({
|
||||
range: 'last_7_days',
|
||||
});
|
||||
});
|
||||
|
||||
it('generates params with BETWEEN date range', () => {
|
||||
const result = generateURLParams({
|
||||
dateRange: {
|
||||
type: DATE_RANGE_TYPES.BETWEEN,
|
||||
from: 1640995200,
|
||||
to: 1672531199,
|
||||
},
|
||||
});
|
||||
expect(result).toEqual({
|
||||
range: 'between',
|
||||
since: 1640995200,
|
||||
until: 1672531199,
|
||||
});
|
||||
});
|
||||
|
||||
it('generates params with CUSTOM date range', () => {
|
||||
const result = generateURLParams({
|
||||
dateRange: {
|
||||
type: DATE_RANGE_TYPES.CUSTOM,
|
||||
from: 1640995200,
|
||||
to: 1672531199,
|
||||
},
|
||||
});
|
||||
expect(result).toEqual({
|
||||
range: 'custom',
|
||||
since: 1640995200,
|
||||
until: 1672531199,
|
||||
});
|
||||
});
|
||||
|
||||
it('handles date range with missing from/to values', () => {
|
||||
const result = generateURLParams({
|
||||
dateRange: {
|
||||
type: DATE_RANGE_TYPES.BETWEEN,
|
||||
from: null,
|
||||
to: undefined,
|
||||
},
|
||||
});
|
||||
expect(result).toEqual({
|
||||
range: 'between',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('generates params with all parameters combined', () => {
|
||||
const result = generateURLParams({
|
||||
from: 'agent:456',
|
||||
in: 789,
|
||||
dateRange: {
|
||||
type: DATE_RANGE_TYPES.BETWEEN,
|
||||
from: 1640995200,
|
||||
to: 1672531199,
|
||||
},
|
||||
});
|
||||
expect(result).toEqual({
|
||||
from: 'agent:456',
|
||||
inbox_id: 789,
|
||||
range: 'between',
|
||||
since: 1640995200,
|
||||
until: 1672531199,
|
||||
});
|
||||
});
|
||||
|
||||
describe('when advanced search is disabled', () => {
|
||||
it('returns empty object when isAdvancedSearchEnabled is false', () => {
|
||||
const result = generateURLParams(
|
||||
{
|
||||
from: 'agent:123',
|
||||
in: 456,
|
||||
dateRange: {
|
||||
type: DATE_RANGE_TYPES.BETWEEN,
|
||||
from: 1640995200,
|
||||
to: 1672531199,
|
||||
},
|
||||
},
|
||||
false
|
||||
);
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('strips all filter params when feature flag is disabled', () => {
|
||||
const result = generateURLParams(
|
||||
{
|
||||
from: 'contact:789',
|
||||
in: 123,
|
||||
},
|
||||
false
|
||||
);
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('strips date range params when feature flag is disabled', () => {
|
||||
const result = generateURLParams(
|
||||
{
|
||||
dateRange: {
|
||||
type: DATE_RANGE_TYPES.LAST_7_DAYS,
|
||||
from: 1640995200,
|
||||
to: 1672531199,
|
||||
},
|
||||
},
|
||||
false
|
||||
);
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('#parseURLParams', () => {
|
||||
it('returns default values for empty query', () => {
|
||||
const result = parseURLParams({});
|
||||
expect(result).toEqual({
|
||||
from: null,
|
||||
in: null,
|
||||
dateRange: {
|
||||
type: undefined,
|
||||
from: null,
|
||||
to: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('parses from parameter', () => {
|
||||
const result = parseURLParams({ from: 'agent:123' });
|
||||
expect(result).toEqual({
|
||||
from: 'agent:123',
|
||||
in: null,
|
||||
dateRange: {
|
||||
type: undefined,
|
||||
from: null,
|
||||
to: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('parses inbox_id parameter as number', () => {
|
||||
const result = parseURLParams({ inbox_id: '456' });
|
||||
expect(result).toEqual({
|
||||
from: null,
|
||||
in: 456,
|
||||
dateRange: {
|
||||
type: undefined,
|
||||
from: null,
|
||||
to: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('parses explicit range parameter', () => {
|
||||
const result = parseURLParams({
|
||||
range: 'last_7_days',
|
||||
since: '1640995200',
|
||||
until: '1672531199',
|
||||
});
|
||||
expect(result).toEqual({
|
||||
from: null,
|
||||
in: null,
|
||||
dateRange: {
|
||||
type: 'last_7_days',
|
||||
from: 1640995200,
|
||||
to: 1672531199,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
describe('inferred date range types', () => {
|
||||
it('infers BETWEEN type when both since and until are present', () => {
|
||||
const result = parseURLParams({
|
||||
since: '1640995200',
|
||||
until: '1672531199',
|
||||
});
|
||||
expect(result).toEqual({
|
||||
from: null,
|
||||
in: null,
|
||||
dateRange: {
|
||||
type: 'between',
|
||||
from: 1640995200,
|
||||
to: 1672531199,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('prioritizes explicit range over inferred type', () => {
|
||||
const result = parseURLParams({
|
||||
range: 'custom',
|
||||
since: '1640995200',
|
||||
until: '1672531199',
|
||||
});
|
||||
expect(result).toEqual({
|
||||
from: null,
|
||||
in: null,
|
||||
dateRange: {
|
||||
type: 'custom',
|
||||
from: 1640995200,
|
||||
to: 1672531199,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('parses all parameters combined', () => {
|
||||
const result = parseURLParams({
|
||||
from: 'contact:789',
|
||||
inbox_id: '123',
|
||||
range: 'between',
|
||||
since: '1640995200',
|
||||
until: '1672531199',
|
||||
});
|
||||
expect(result).toEqual({
|
||||
from: 'contact:789',
|
||||
in: 123,
|
||||
dateRange: {
|
||||
type: 'between',
|
||||
from: 1640995200,
|
||||
to: 1672531199,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
describe('when advanced search is disabled', () => {
|
||||
it('returns empty filters when isAdvancedSearchEnabled is false', () => {
|
||||
const result = parseURLParams(
|
||||
{
|
||||
from: 'agent:123',
|
||||
inbox_id: '456',
|
||||
range: 'between',
|
||||
since: '1640995200',
|
||||
until: '1672531199',
|
||||
},
|
||||
false
|
||||
);
|
||||
expect(result).toEqual({
|
||||
from: null,
|
||||
in: null,
|
||||
dateRange: {
|
||||
type: null,
|
||||
from: null,
|
||||
to: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores all filter params from URL when feature flag is disabled', () => {
|
||||
const result = parseURLParams(
|
||||
{
|
||||
from: 'contact:789',
|
||||
inbox_id: '123',
|
||||
},
|
||||
false
|
||||
);
|
||||
expect(result).toEqual({
|
||||
from: null,
|
||||
in: null,
|
||||
dateRange: {
|
||||
type: null,
|
||||
from: null,
|
||||
to: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores date range params from URL when feature flag is disabled', () => {
|
||||
const result = parseURLParams(
|
||||
{
|
||||
range: 'last_7_days',
|
||||
since: '1640995200',
|
||||
until: '1672531199',
|
||||
},
|
||||
false
|
||||
);
|
||||
expect(result).toEqual({
|
||||
from: null,
|
||||
in: null,
|
||||
dateRange: {
|
||||
type: null,
|
||||
from: null,
|
||||
to: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('#fetchContactDetails', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('returns contact data on successful API call', async () => {
|
||||
const mockContactData = {
|
||||
id: 123,
|
||||
name: 'John Doe',
|
||||
email: 'john@example.com',
|
||||
};
|
||||
|
||||
ContactAPI.show.mockResolvedValue({
|
||||
data: {
|
||||
payload: mockContactData,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await fetchContactDetails(123);
|
||||
expect(result).toEqual(mockContactData);
|
||||
expect(ContactAPI.show).toHaveBeenCalledWith(123);
|
||||
});
|
||||
|
||||
it('returns null on API error', async () => {
|
||||
ContactAPI.show.mockRejectedValue(new Error('API Error'));
|
||||
|
||||
const result = await fetchContactDetails(123);
|
||||
expect(result).toBeNull();
|
||||
expect(ContactAPI.show).toHaveBeenCalledWith(123);
|
||||
});
|
||||
|
||||
it('handles different contact ID types', async () => {
|
||||
const mockContactData = { id: 456, name: 'Jane Doe' };
|
||||
ContactAPI.show.mockResolvedValue({
|
||||
data: { payload: mockContactData },
|
||||
});
|
||||
|
||||
// Test with string ID
|
||||
await fetchContactDetails('456');
|
||||
expect(ContactAPI.show).toHaveBeenCalledWith('456');
|
||||
|
||||
// Test with number ID
|
||||
await fetchContactDetails(456);
|
||||
expect(ContactAPI.show).toHaveBeenCalledWith(456);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { frontendURL } from '../../helper/URLHelper';
|
||||
import {
|
||||
ROLES,
|
||||
CONVERSATION_PERMISSIONS,
|
||||
CONTACT_PERMISSIONS,
|
||||
PORTAL_PERMISSIONS,
|
||||
} from 'dashboard/constants/permissions.js';
|
||||
|
||||
import SearchView from './components/SearchView.vue';
|
||||
|
||||
export const routes = [
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/search/:tab?'),
|
||||
name: 'search',
|
||||
meta: {
|
||||
permissions: [
|
||||
...ROLES,
|
||||
...CONVERSATION_PERMISSIONS,
|
||||
CONTACT_PERMISSIONS,
|
||||
PORTAL_PERMISSIONS,
|
||||
],
|
||||
},
|
||||
component: SearchView,
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,329 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useToggle } from '@vueuse/core';
|
||||
import WidgetHead from './WidgetHead.vue';
|
||||
import WidgetBody from './WidgetBody.vue';
|
||||
import WidgetFooter from './WidgetFooter.vue';
|
||||
import TabBar from 'dashboard/components-next/tabbar/TabBar.vue';
|
||||
import Code from 'dashboard/components/Code.vue';
|
||||
import Switch from 'dashboard/components-next/switch/Switch.vue';
|
||||
import { useBranding } from 'shared/composables/useBranding';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
|
||||
const props = defineProps({
|
||||
welcomeHeading: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
welcomeTagline: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
websiteName: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
logo: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
isOnline: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
replyTime: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
color: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
widgetBubblePosition: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
widgetBubbleLauncherTitle: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
widgetBubbleType: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
webWidgetScript: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
const { replaceInstallationName } = useBranding();
|
||||
const globalConfig = useMapGetter('globalConfig/get');
|
||||
|
||||
const isChatMode = ref(false);
|
||||
const [isWidgetVisible, toggleWidget] = useToggle(true);
|
||||
|
||||
const activeTabIndex = ref(0);
|
||||
|
||||
const tabs = computed(() => [
|
||||
{
|
||||
label: t(
|
||||
'INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.WIDGET_VIEW_OPTION.PREVIEW'
|
||||
),
|
||||
},
|
||||
{
|
||||
label: t(
|
||||
'INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.WIDGET_VIEW_OPTION.SCRIPT'
|
||||
),
|
||||
},
|
||||
]);
|
||||
|
||||
const isPreviewTab = computed(() => activeTabIndex.value === 0);
|
||||
|
||||
const widgetScript = computed(() => {
|
||||
if (!props.webWidgetScript) return '';
|
||||
|
||||
const options = {
|
||||
position: props.widgetBubblePosition,
|
||||
type: props.widgetBubbleType,
|
||||
launcherTitle: props.widgetBubbleLauncherTitle,
|
||||
};
|
||||
|
||||
const script = props.webWidgetScript;
|
||||
return (
|
||||
script.substring(0, 13) +
|
||||
t('INBOX_MGMT.WIDGET_BUILDER.SCRIPT_SETTINGS', {
|
||||
options: JSON.stringify(options),
|
||||
}) +
|
||||
script.substring(13)
|
||||
);
|
||||
});
|
||||
|
||||
const replyTimeText = computed(() => {
|
||||
switch (props.replyTime) {
|
||||
case 'in_a_few_minutes':
|
||||
return t('INBOX_MGMT.WIDGET_BUILDER.REPLY_TIME.IN_A_FEW_MINUTES');
|
||||
case 'in_a_day':
|
||||
return t('INBOX_MGMT.WIDGET_BUILDER.REPLY_TIME.IN_A_DAY');
|
||||
default:
|
||||
return t('INBOX_MGMT.WIDGET_BUILDER.REPLY_TIME.IN_A_FEW_HOURS');
|
||||
}
|
||||
});
|
||||
|
||||
const getWidgetConfig = computed(() => ({
|
||||
welcomeHeading: props.welcomeHeading,
|
||||
welcomeTagline: props.welcomeTagline,
|
||||
websiteName: props.websiteName,
|
||||
logo: props.logo,
|
||||
isDefaultScreen: !isChatMode.value,
|
||||
isOnline: props.isOnline,
|
||||
replyTime: replyTimeText.value,
|
||||
color: props.color,
|
||||
}));
|
||||
|
||||
const getBubblePositionStyle = computed(() => ({
|
||||
justifyContent: props.widgetBubblePosition === 'left' ? 'start' : 'end',
|
||||
}));
|
||||
|
||||
const isBubbleExpanded = computed(
|
||||
() => !isWidgetVisible.value && props.widgetBubbleType === 'expanded_bubble'
|
||||
);
|
||||
|
||||
const getWidgetBubbleLauncherTitle = computed(() =>
|
||||
isWidgetVisible.value || props.widgetBubbleType === 'standard'
|
||||
? ' '
|
||||
: props.widgetBubbleLauncherTitle
|
||||
);
|
||||
|
||||
const handleTabChange = tab => {
|
||||
activeTabIndex.value = tabs.value.findIndex(item => item.label === tab.label);
|
||||
};
|
||||
|
||||
const handleToggleWidget = () => {
|
||||
toggleWidget();
|
||||
if (isWidgetVisible.value) {
|
||||
isChatMode.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col h-full min-h-0 flex-1">
|
||||
<div class="flex items-center justify-between mb-6 flex-shrink-0">
|
||||
<TabBar
|
||||
:tabs="tabs"
|
||||
:initial-active-tab="activeTabIndex"
|
||||
@tab-changed="handleTabChange"
|
||||
/>
|
||||
|
||||
<div v-if="isPreviewTab" class="flex items-center gap-2">
|
||||
<span class="text-heading-3 text-n-slate-11">
|
||||
{{ $t('INBOX_MGMT.WIDGET_BUILDER.WIDGET_SCREEN.CHAT') }}
|
||||
</span>
|
||||
<Switch v-model="isChatMode" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-h-0 flex flex-col">
|
||||
<div
|
||||
v-if="isPreviewTab"
|
||||
class="flex-1 flex flex-col items-center justify-end pb-4"
|
||||
>
|
||||
<div
|
||||
v-if="isWidgetVisible"
|
||||
class="widget-wrapper flex flex-1 flex-shrink-0 flex-col justify-between rounded-lg shadow-md bg-n-slate-2 dark:bg-n-solid-1 h-[31.25rem] w-80 mb-4"
|
||||
>
|
||||
<WidgetHead :config="getWidgetConfig" />
|
||||
<div>
|
||||
<WidgetBody
|
||||
v-if="!getWidgetConfig.isDefaultScreen"
|
||||
:config="getWidgetConfig"
|
||||
/>
|
||||
<WidgetFooter :config="getWidgetConfig" />
|
||||
<div class="py-2.5 flex justify-center">
|
||||
<a
|
||||
class="items-center gap-0.5 text-n-slate-11 cursor-pointer flex filter grayscale opacity-90 hover:grayscale-0 hover:opacity-100 text-xxs"
|
||||
>
|
||||
<img
|
||||
class="max-w-2.5 max-h-2.5"
|
||||
:src="globalConfig.logoThumbnail"
|
||||
/>
|
||||
<span>
|
||||
{{
|
||||
replaceInstallationName(
|
||||
$t('INBOX_MGMT.WIDGET_BUILDER.BRANDING_TEXT')
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex w-[320px]" :style="getBubblePositionStyle">
|
||||
<button
|
||||
class="relative flex items-center justify-center rounded-full cursor-pointer"
|
||||
:style="{ background: props.color }"
|
||||
:class="
|
||||
isBubbleExpanded
|
||||
? 'w-auto font-medium text-base text-white dark:text-white h-12 px-4'
|
||||
: 'w-16 h-16'
|
||||
"
|
||||
@click="handleToggleWidget"
|
||||
>
|
||||
<img
|
||||
v-if="!isWidgetVisible"
|
||||
src="~dashboard/assets/images/bubble-logo.svg"
|
||||
alt=""
|
||||
draggable="false"
|
||||
class="w-6 h-6 mx-auto"
|
||||
/>
|
||||
<div v-if="isBubbleExpanded" class="ltr:pl-2.5 rtl:pr-2.5">
|
||||
{{ getWidgetBubbleLauncherTitle }}
|
||||
</div>
|
||||
<div v-if="isWidgetVisible" class="relative">
|
||||
<div
|
||||
class="absolute w-0.5 h-8 rotate-45 -translate-y-1/2 bg-white"
|
||||
/>
|
||||
<div
|
||||
class="absolute w-0.5 h-8 -rotate-45 -translate-y-1/2 bg-white"
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="flex-1 p-3 rounded-lg [&_code]:!bg-n-slate-2 bg-n-slate-2 min-w-0 overflow-auto [&_pre]:whitespace-pre-wrap [&_pre]:break-words [&_code]:whitespace-pre-wrap"
|
||||
>
|
||||
<Code
|
||||
:script="widgetScript"
|
||||
lang="html"
|
||||
class="!text-start"
|
||||
:codepen-title="`${websiteName} - Chatwoot Widget Test`"
|
||||
enable-code-pen
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
// Widget-specific color variables to match actual widget appearance
|
||||
.widget-wrapper {
|
||||
// Light mode - widget colors
|
||||
--slate-1: 252 252 253;
|
||||
--slate-2: 249 249 251;
|
||||
--slate-3: 240 240 243;
|
||||
--slate-4: 232 232 236;
|
||||
--slate-5: 224 225 230;
|
||||
--slate-6: 217 217 224;
|
||||
--slate-7: 205 206 214;
|
||||
--slate-8: 185 187 198;
|
||||
--slate-9: 139 141 152;
|
||||
--slate-10: 128 131 141;
|
||||
--slate-11: 96 100 108;
|
||||
--slate-12: 28 32 36;
|
||||
|
||||
--background-color: 253 253 253;
|
||||
--text-blue: 8 109 224;
|
||||
--border-container: 236 236 236;
|
||||
--border-strong: 235 235 235;
|
||||
--border-weak: 234 234 234;
|
||||
--solid-1: 255 255 255;
|
||||
--solid-2: 255 255 255;
|
||||
--solid-3: 255 255 255;
|
||||
--solid-active: 255 255 255;
|
||||
--solid-amber: 252 232 193;
|
||||
--solid-blue: 218 236 255;
|
||||
--solid-iris: 230 231 255;
|
||||
|
||||
--alpha-1: 67, 67, 67, 0.06;
|
||||
--alpha-2: 201, 202, 207, 0.15;
|
||||
--alpha-3: 255, 255, 255, 0.96;
|
||||
--black-alpha-1: 0, 0, 0, 0.12;
|
||||
--black-alpha-2: 0, 0, 0, 0.04;
|
||||
--border-blue: 39, 129, 246, 0.5;
|
||||
--white-alpha: 255, 255, 255, 0.8;
|
||||
}
|
||||
|
||||
// Dark mode - widget colors
|
||||
.dark .widget-wrapper {
|
||||
--slate-1: 17 17 19;
|
||||
--slate-2: 24 25 27;
|
||||
--slate-3: 33 34 37;
|
||||
--slate-4: 39 42 45;
|
||||
--slate-5: 46 49 53;
|
||||
--slate-6: 54 58 63;
|
||||
--slate-7: 67 72 78;
|
||||
--slate-8: 90 97 105;
|
||||
--slate-9: 105 110 119;
|
||||
--slate-10: 119 123 132;
|
||||
--slate-11: 176 180 186;
|
||||
--slate-12: 237 238 240;
|
||||
|
||||
--background-color: 18 18 19;
|
||||
--border-strong: 52 52 52;
|
||||
--border-weak: 38 38 42;
|
||||
--solid-1: 23 23 26;
|
||||
--solid-2: 29 30 36;
|
||||
--solid-3: 44 45 54;
|
||||
--solid-active: 53 57 66;
|
||||
--solid-amber: 42 37 30;
|
||||
--solid-blue: 16 49 91;
|
||||
--solid-iris: 38 42 101;
|
||||
--text-blue: 126 182 255;
|
||||
|
||||
--alpha-1: 36, 36, 36, 0.8;
|
||||
--alpha-2: 139, 147, 182, 0.15;
|
||||
--alpha-3: 36, 38, 45, 0.9;
|
||||
--black-alpha-1: 0, 0, 0, 0.3;
|
||||
--black-alpha-2: 0, 0, 0, 0.2;
|
||||
--border-blue: 39, 129, 246, 0.5;
|
||||
--border-container: 236, 236, 236, 0;
|
||||
--white-alpha: 255, 255, 255, 0.1;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,41 @@
|
||||
<script setup>
|
||||
import { defineProps } from 'vue';
|
||||
|
||||
defineProps({
|
||||
config: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-[calc(2rem*10)] px-4 overflow-y-auto">
|
||||
<div>
|
||||
<div>
|
||||
<div
|
||||
class="items-end flex justify-end ml-auto mb-1 mt-0 max-w-[85%] text-right"
|
||||
>
|
||||
<div
|
||||
class="rounded-[1.25rem] rounded-br-[0.25rem] text-white dark:text-white text-sm px-4 py-3"
|
||||
:style="{ background: config.color }"
|
||||
>
|
||||
<p class="m-0">
|
||||
{{ $t('INBOX_MGMT.WIDGET_BUILDER.BODY.USER_MESSAGE') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="shadow rounded-[1.25rem] rounded-bl-[0.25rem] px-4 py-3 inline-block text-sm text-n-slate-12 bg-n-background dark:bg-n-solid-3"
|
||||
>
|
||||
<div>
|
||||
<p class="m-0">
|
||||
{{ $t('INBOX_MGMT.WIDGET_BUILDER.BODY.AGENT_MESSAGE') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,81 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import ResizableTextArea from 'shared/components/ResizableTextArea.vue';
|
||||
import Avatar from 'next/avatar/Avatar.vue';
|
||||
import FluentIcon from 'shared/components/FluentIcon/Index.vue';
|
||||
|
||||
const props = defineProps({
|
||||
config: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const isInputFocused = ref(false);
|
||||
|
||||
const getStatusText = computed(() => {
|
||||
return props.config.isOnline
|
||||
? t('INBOX_MGMT.WIDGET_BUILDER.BODY.TEAM_AVAILABILITY.ONLINE')
|
||||
: t('INBOX_MGMT.WIDGET_BUILDER.BODY.TEAM_AVAILABILITY.OFFLINE');
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative flex flex-col w-full px-4">
|
||||
<div
|
||||
v-if="config.isDefaultScreen"
|
||||
class="p-4 rounded-md shadow-sm bg-n-background dark:bg-n-solid-2"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div
|
||||
class="text-sm font-medium leading-4 text-n-slate-12 dark:text-n-slate-50"
|
||||
>
|
||||
{{ getStatusText }}
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-n-slate-11">
|
||||
{{ config.replyTime }}
|
||||
</div>
|
||||
</div>
|
||||
<Avatar name="C" :size="34" rounded-full />
|
||||
</div>
|
||||
<button
|
||||
v-if="config.isDefaultScreen"
|
||||
class="inline-flex items-center justify-between px-2 py-1 mt-1 -ml-2 font-medium leading-6 bg-transparent rounded-md text-n-slate-12 dark:bg-transparent"
|
||||
:style="{ color: config.color }"
|
||||
>
|
||||
<span class="pr-2 text-xs">
|
||||
{{
|
||||
$t(
|
||||
'INBOX_MGMT.WIDGET_BUILDER.FOOTER.START_CONVERSATION_BUTTON_TEXT'
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
<FluentIcon icon="arrow-right" size="14" />
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="flex items-center h-10 bg-white rounded-md dark:!bg-n-slate-3"
|
||||
:class="{ 'ring-2 ring-n-brand dark:ring-n-brand': isInputFocused }"
|
||||
>
|
||||
<ResizableTextArea
|
||||
id="chat-input"
|
||||
:rows="1"
|
||||
:placeholder="
|
||||
$t('INBOX_MGMT.WIDGET_BUILDER.FOOTER.CHAT_INPUT_PLACEHOLDER')
|
||||
"
|
||||
class="flex-grow !bg-white border-0 outline-none !outline-0 border-none h-8 text-sm dark:!bg-n-slate-3 pb-0 !pt-1.5 resize-none px-3 !mb-0 focus:outline-none rounded-md"
|
||||
@focus="isInputFocused = true"
|
||||
@blur="isInputFocused = false"
|
||||
/>
|
||||
<div class="flex items-center gap-2 px-2">
|
||||
<FluentIcon icon="emoji" />
|
||||
<FluentIcon class="icon-send" icon="send" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,67 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
|
||||
|
||||
const props = defineProps({
|
||||
config: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
});
|
||||
|
||||
const { formatMessage } = useMessageFormatter();
|
||||
|
||||
const isDefaultScreen = computed(() => {
|
||||
return (
|
||||
props.config.isDefaultScreen &&
|
||||
((props.config.welcomeHeading &&
|
||||
props.config.welcomeHeading.length !== 0) ||
|
||||
(props.config.welcomeTagLine && props.config.welcomeTagline.length !== 0))
|
||||
);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="rounded-t-lg flex-shrink-0 transition-[max-height] duration-300"
|
||||
:class="
|
||||
isDefaultScreen
|
||||
? 'bg-n-slate-2 dark:bg-n-solid-1 px-4 py-5'
|
||||
: 'bg-n-slate-2 dark:bg-n-solid-1 p-4'
|
||||
"
|
||||
>
|
||||
<div class="relative top-px">
|
||||
<div class="flex items-center justify-start">
|
||||
<img
|
||||
v-if="config.logo"
|
||||
:src="config.logo"
|
||||
class="mr-2 rounded-full logo"
|
||||
:class="!isDefaultScreen ? 'h-8 w-8 mb-1' : 'h-12 w-12 mb-2'"
|
||||
/>
|
||||
<div v-if="!isDefaultScreen">
|
||||
<div class="flex items-center justify-start gap-1">
|
||||
<span class="text-base font-medium leading-3 text-n-slate-12">
|
||||
{{ config.websiteName }}
|
||||
</span>
|
||||
<div
|
||||
v-if="config.isOnline"
|
||||
class="w-2 h-2 bg-n-teal-10 rounded-full"
|
||||
/>
|
||||
</div>
|
||||
<span class="mt-1 text-xs text-n-slate-11">
|
||||
{{ config.replyTime }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="isDefaultScreen" class="overflow-auto max-h-60">
|
||||
<h2 class="mb-2 text-2xl break-words text-n-slate-12">
|
||||
{{ config.welcomeHeading }}
|
||||
</h2>
|
||||
<p
|
||||
v-dompurify-html="formatMessage(config.welcomeTagline)"
|
||||
class="text-sm break-words text-n-slate-11 [&_a]:!text-n-slate-11 [&_a]:underline"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user