mirror of
				https://github.com/lingble/chatwoot.git
				synced 2025-10-31 02:57:57 +00:00 
			
		
		
		
	feat: Add compose conversation components (#10457)
Co-authored-by: Pranav <pranav@chatwoot.com> Co-authored-by: Pranav <pranavrajs@gmail.com>
This commit is contained in:
		| @@ -169,7 +169,7 @@ watch( | |||||||
|           @apply m-0 !important; |           @apply m-0 !important; | ||||||
|  |  | ||||||
|           &::before { |           &::before { | ||||||
|             @apply text-n-slate-10 dark:text-n-slate-10 !important; |             @apply text-n-slate-11 dark:text-n-slate-11; | ||||||
|           } |           } | ||||||
|         } |         } | ||||||
|       } |       } | ||||||
|   | |||||||
| @@ -111,7 +111,7 @@ onMounted(() => { | |||||||
|             custom-label-class="min-w-[120px]" |             custom-label-class="min-w-[120px]" | ||||||
|           /> |           /> | ||||||
|         </div> |         </div> | ||||||
|         <div class="flex justify-between w-full gap-2 py-2"> |         <div class="flex justify-between w-full gap-3 py-2"> | ||||||
|           <label |           <label | ||||||
|             class="text-sm font-medium whitespace-nowrap min-w-[120px] text-slate-900 dark:text-slate-50" |             class="text-sm font-medium whitespace-nowrap min-w-[120px] text-slate-900 dark:text-slate-50" | ||||||
|           > |           > | ||||||
|   | |||||||
| @@ -0,0 +1,260 @@ | |||||||
|  | <script setup> | ||||||
|  | import { defineAsyncComponent, ref, computed } from 'vue'; | ||||||
|  | import { useMapGetter } from 'dashboard/composables/store'; | ||||||
|  | import { useI18n } from 'vue-i18n'; | ||||||
|  | import { useUISettings } from 'dashboard/composables/useUISettings'; | ||||||
|  | // import { useFileUpload } from 'dashboard/composables/useFileUpload'; | ||||||
|  | import { vOnClickOutside } from '@vueuse/components'; | ||||||
|  | import { ALLOWED_FILE_TYPES } from 'shared/constants/messages'; | ||||||
|  | import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents'; | ||||||
|  | import FileUpload from 'vue-upload-component'; | ||||||
|  |  | ||||||
|  | import Button from 'dashboard/components-next/button/Button.vue'; | ||||||
|  | import WhatsAppOptions from './WhatsAppOptions.vue'; | ||||||
|  |  | ||||||
|  | const props = defineProps({ | ||||||
|  |   attachedFiles: { | ||||||
|  |     type: Array, | ||||||
|  |     default: () => [], | ||||||
|  |   }, | ||||||
|  |   isWhatsappInbox: { | ||||||
|  |     type: Boolean, | ||||||
|  |     default: false, | ||||||
|  |   }, | ||||||
|  |   isEmailOrWebWidgetInbox: { | ||||||
|  |     type: Boolean, | ||||||
|  |     default: false, | ||||||
|  |   }, | ||||||
|  |   isTwilioSmsInbox: { | ||||||
|  |     type: Boolean, | ||||||
|  |     default: false, | ||||||
|  |   }, | ||||||
|  |   messageTemplates: { | ||||||
|  |     type: Array, | ||||||
|  |     default: () => [], | ||||||
|  |   }, | ||||||
|  |   channelType: { | ||||||
|  |     type: String, | ||||||
|  |     default: '', | ||||||
|  |   }, | ||||||
|  |   isLoading: { | ||||||
|  |     type: Boolean, | ||||||
|  |     default: false, | ||||||
|  |   }, | ||||||
|  |   disableSendButton: { | ||||||
|  |     type: Boolean, | ||||||
|  |     default: false, | ||||||
|  |   }, | ||||||
|  |   hasNoInbox: { | ||||||
|  |     type: Boolean, | ||||||
|  |     default: false, | ||||||
|  |   }, | ||||||
|  |   isDropdownActive: { | ||||||
|  |     type: Boolean, | ||||||
|  |     default: false, | ||||||
|  |   }, | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const emit = defineEmits([ | ||||||
|  |   'discard', | ||||||
|  |   'sendMessage', | ||||||
|  |   'sendWhatsappMessage', | ||||||
|  |   'insertEmoji', | ||||||
|  |   'addSignature', | ||||||
|  |   'removeSignature', | ||||||
|  |   'attachFile', | ||||||
|  | ]); | ||||||
|  |  | ||||||
|  | const { t } = useI18n(); | ||||||
|  |  | ||||||
|  | const uploadAttachment = ref(null); | ||||||
|  | const isEmojiPickerOpen = ref(false); | ||||||
|  |  | ||||||
|  | const EmojiInput = defineAsyncComponent( | ||||||
|  |   () => import('shared/components/emoji/EmojiInput.vue') | ||||||
|  | ); | ||||||
|  |  | ||||||
|  | const messageSignature = useMapGetter('getMessageSignature'); | ||||||
|  | const signatureToApply = computed(() => messageSignature.value); | ||||||
|  |  | ||||||
|  | const { | ||||||
|  |   fetchSignatureFlagFromUISettings, | ||||||
|  |   setSignatureFlagForInbox, | ||||||
|  |   isEditorHotKeyEnabled, | ||||||
|  | } = useUISettings(); | ||||||
|  |  | ||||||
|  | const sendWithSignature = computed(() => { | ||||||
|  |   return fetchSignatureFlagFromUISettings(props.channelType); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const isSignatureEnabledForInbox = computed(() => { | ||||||
|  |   return props.isEmailOrWebWidgetInbox && sendWithSignature.value; | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const setSignature = () => { | ||||||
|  |   if (signatureToApply.value) { | ||||||
|  |     if (isSignatureEnabledForInbox.value) { | ||||||
|  |       emit('addSignature', signatureToApply.value); | ||||||
|  |     } else { | ||||||
|  |       emit('removeSignature', signatureToApply.value); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const toggleMessageSignature = () => { | ||||||
|  |   setSignatureFlagForInbox(props.channelType, !sendWithSignature.value); | ||||||
|  |   setSignature(); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const onClickInsertEmoji = emoji => { | ||||||
|  |   emit('insertEmoji', emoji); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const useFileUpload = () => { | ||||||
|  |   // Empty function for testing purposes | ||||||
|  |   // TODO: Will use useFileUpload composable later | ||||||
|  |   return { | ||||||
|  |     onFileUpload: () => {}, | ||||||
|  |   }; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const { onFileUpload } = useFileUpload({ | ||||||
|  |   isATwilioSMSChannel: props.isTwilioSmsInbox, | ||||||
|  |   attachFile: ({ blob, file }) => { | ||||||
|  |     if (!file) return; | ||||||
|  |  | ||||||
|  |     const reader = new FileReader(); | ||||||
|  |     reader.readAsDataURL(file.file); | ||||||
|  |     reader.onloadend = () => { | ||||||
|  |       const newFile = { | ||||||
|  |         resource: blob || file, | ||||||
|  |         isPrivate: false, | ||||||
|  |         thumb: reader.result, | ||||||
|  |         blobSignedId: blob?.signed_id, | ||||||
|  |       }; | ||||||
|  |       emit('attachFile', [...props.attachedFiles, newFile]); | ||||||
|  |     }; | ||||||
|  |   }, | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const sendButtonLabel = computed(() => { | ||||||
|  |   const keyCode = isEditorHotKeyEnabled('cmd_enter') ? '⌘ + ↵' : '↵'; | ||||||
|  |   return t('COMPOSE_NEW_CONVERSATION.FORM.ACTION_BUTTONS.SEND', { | ||||||
|  |     keyCode, | ||||||
|  |   }); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const keyboardEvents = { | ||||||
|  |   Enter: { | ||||||
|  |     action: () => { | ||||||
|  |       if ( | ||||||
|  |         isEditorHotKeyEnabled('enter') && | ||||||
|  |         !props.isWhatsappInbox && | ||||||
|  |         !props.isDropdownActive | ||||||
|  |       ) { | ||||||
|  |         emit('sendMessage'); | ||||||
|  |       } | ||||||
|  |     }, | ||||||
|  |   }, | ||||||
|  |   '$mod+Enter': { | ||||||
|  |     action: () => { | ||||||
|  |       if ( | ||||||
|  |         isEditorHotKeyEnabled('cmd_enter') && | ||||||
|  |         !props.isWhatsappInbox && | ||||||
|  |         !props.isDropdownActive | ||||||
|  |       ) { | ||||||
|  |         emit('sendMessage'); | ||||||
|  |       } | ||||||
|  |     }, | ||||||
|  |   }, | ||||||
|  | }; | ||||||
|  | useKeyboardEvents(keyboardEvents); | ||||||
|  | </script> | ||||||
|  |  | ||||||
|  | <template> | ||||||
|  |   <div | ||||||
|  |     class="flex items-center justify-between w-full h-[52px] gap-2 px-4 py-3" | ||||||
|  |   > | ||||||
|  |     <div class="flex items-center gap-2"> | ||||||
|  |       <WhatsAppOptions | ||||||
|  |         v-if="isWhatsappInbox" | ||||||
|  |         :message-templates="messageTemplates" | ||||||
|  |         @send-message="emit('sendWhatsappMessage', $event)" | ||||||
|  |       /> | ||||||
|  |       <div | ||||||
|  |         v-if="!isWhatsappInbox && !hasNoInbox" | ||||||
|  |         v-on-click-outside="() => (isEmojiPickerOpen = false)" | ||||||
|  |         class="relative" | ||||||
|  |       > | ||||||
|  |         <Button | ||||||
|  |           icon="i-lucide-smile-plus" | ||||||
|  |           color="slate" | ||||||
|  |           size="sm" | ||||||
|  |           class="!w-10" | ||||||
|  |           @click="isEmojiPickerOpen = !isEmojiPickerOpen" | ||||||
|  |         /> | ||||||
|  |         <EmojiInput | ||||||
|  |           v-if="isEmojiPickerOpen" | ||||||
|  |           class="left-0 top-full mt-1.5" | ||||||
|  |           :on-click="onClickInsertEmoji" | ||||||
|  |         /> | ||||||
|  |       </div> | ||||||
|  |       <FileUpload | ||||||
|  |         v-if="isEmailOrWebWidgetInbox" | ||||||
|  |         ref="uploadAttachment" | ||||||
|  |         input-id="composeNewConversationAttachment" | ||||||
|  |         :size="4096 * 4096" | ||||||
|  |         :accept="ALLOWED_FILE_TYPES" | ||||||
|  |         multiple | ||||||
|  |         :drop-directory="false" | ||||||
|  |         :data="{ | ||||||
|  |           direct_upload_url: '/rails/active_storage/direct_uploads', | ||||||
|  |           direct_upload: true, | ||||||
|  |         }" | ||||||
|  |         class="p-px" | ||||||
|  |         @input-file="onFileUpload" | ||||||
|  |       > | ||||||
|  |         <Button | ||||||
|  |           icon="i-lucide-plus" | ||||||
|  |           color="slate" | ||||||
|  |           size="sm" | ||||||
|  |           class="!w-10 relative" | ||||||
|  |         /> | ||||||
|  |       </FileUpload> | ||||||
|  |       <Button | ||||||
|  |         v-if="isEmailOrWebWidgetInbox" | ||||||
|  |         icon="i-lucide-signature" | ||||||
|  |         color="slate" | ||||||
|  |         size="sm" | ||||||
|  |         class="!w-10" | ||||||
|  |         @click="toggleMessageSignature" | ||||||
|  |       /> | ||||||
|  |     </div> | ||||||
|  |  | ||||||
|  |     <div class="flex items-center gap-2"> | ||||||
|  |       <Button | ||||||
|  |         :label="t('COMPOSE_NEW_CONVERSATION.FORM.ACTION_BUTTONS.DISCARD')" | ||||||
|  |         variant="faded" | ||||||
|  |         color="slate" | ||||||
|  |         size="sm" | ||||||
|  |         class="!text-xs font-medium" | ||||||
|  |         @click="emit('discard')" | ||||||
|  |       /> | ||||||
|  |       <Button | ||||||
|  |         v-if="!isWhatsappInbox" | ||||||
|  |         :label="sendButtonLabel" | ||||||
|  |         size="sm" | ||||||
|  |         class="!text-xs font-medium" | ||||||
|  |         :disabled="isLoading || disableSendButton" | ||||||
|  |         :is-loading="isLoading" | ||||||
|  |         @click="emit('sendMessage')" | ||||||
|  |       /> | ||||||
|  |     </div> | ||||||
|  |   </div> | ||||||
|  | </template> | ||||||
|  |  | ||||||
|  | <style scoped lang="scss"> | ||||||
|  | .emoji-dialog::before { | ||||||
|  |   @apply hidden; | ||||||
|  | } | ||||||
|  | </style> | ||||||
| @@ -0,0 +1,88 @@ | |||||||
|  | <script setup> | ||||||
|  | import { computed } from 'vue'; | ||||||
|  | import { fileNameWithEllipsis } from '@chatwoot/utils'; | ||||||
|  |  | ||||||
|  | import Button from 'dashboard/components-next/button/Button.vue'; | ||||||
|  |  | ||||||
|  | const props = defineProps({ | ||||||
|  |   attachments: { | ||||||
|  |     type: Array, | ||||||
|  |     required: true, | ||||||
|  |   }, | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const emit = defineEmits(['update:attachments']); | ||||||
|  |  | ||||||
|  | const isTypeImage = file => { | ||||||
|  |   const type = file.content_type || file.type; | ||||||
|  |   return type.includes('image'); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const filteredImageAttachments = computed(() => { | ||||||
|  |   return props.attachments.filter(attachment => | ||||||
|  |     isTypeImage(attachment.resource) | ||||||
|  |   ); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const filteredNonImageAttachments = computed(() => { | ||||||
|  |   return props.attachments.filter( | ||||||
|  |     attachment => !isTypeImage(attachment.resource) | ||||||
|  |   ); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const removeAttachment = id => { | ||||||
|  |   const updatedAttachments = props.attachments.filter( | ||||||
|  |     attachment => attachment.resource.id !== id | ||||||
|  |   ); | ||||||
|  |   emit('update:attachments', updatedAttachments); | ||||||
|  | }; | ||||||
|  | </script> | ||||||
|  |  | ||||||
|  | <template> | ||||||
|  |   <div class="flex flex-col gap-4 p-4"> | ||||||
|  |     <div | ||||||
|  |       v-if="filteredImageAttachments.length > 0" | ||||||
|  |       class="flex flex-wrap gap-3" | ||||||
|  |     > | ||||||
|  |       <div | ||||||
|  |         v-for="attachment in filteredImageAttachments" | ||||||
|  |         :key="attachment.id" | ||||||
|  |         class="relative group/image w-[72px] h-[72px]" | ||||||
|  |       > | ||||||
|  |         <img | ||||||
|  |           class="object-cover w-[72px] h-[72px] rounded-lg" | ||||||
|  |           :src="attachment.thumb" | ||||||
|  |         /> | ||||||
|  |         <Button | ||||||
|  |           variant="ghost" | ||||||
|  |           icon="i-lucide-trash" | ||||||
|  |           color="slate" | ||||||
|  |           class="absolute top-1 right-1 !w-5 !h-5 transition-opacity duration-150 ease-in-out opacity-0 group-hover/image:opacity-100" | ||||||
|  |           @click="removeAttachment(attachment.resource.id)" | ||||||
|  |         /> | ||||||
|  |       </div> | ||||||
|  |     </div> | ||||||
|  |     <div | ||||||
|  |       v-if="filteredNonImageAttachments.length > 0" | ||||||
|  |       class="flex flex-wrap gap-3" | ||||||
|  |     > | ||||||
|  |       <div | ||||||
|  |         v-for="attachment in filteredNonImageAttachments" | ||||||
|  |         :key="attachment.id" | ||||||
|  |         class="max-w-[300px] inline-flex items-center h-8 min-w-0 bg-n-alpha-2 dark:bg-n-solid-3 rounded-lg gap-3 ltr:pl-3 rtl:pr-3 ltr:pr-2 rtl:pl-2" | ||||||
|  |       > | ||||||
|  |         <span class="text-sm font-medium text-n-slate-11"> | ||||||
|  |           {{ fileNameWithEllipsis(attachment.resource) }} | ||||||
|  |         </span> | ||||||
|  |         <Button | ||||||
|  |           variant="ghost" | ||||||
|  |           icon="i-lucide-x" | ||||||
|  |           color="slate" | ||||||
|  |           size="xs" | ||||||
|  |           class="shrink-0 !h-5 !w-5" | ||||||
|  |           @click="removeAttachment(attachment.resource.id)" | ||||||
|  |         /> | ||||||
|  |       </div> | ||||||
|  |     </div> | ||||||
|  |   </div> | ||||||
|  | </template> | ||||||
| @@ -0,0 +1,332 @@ | |||||||
|  | <script setup> | ||||||
|  | import { reactive, ref, computed } from 'vue'; | ||||||
|  | import { useVuelidate } from '@vuelidate/core'; | ||||||
|  | import { required, requiredIf } from '@vuelidate/validators'; | ||||||
|  | import { INBOX_TYPES } from 'dashboard/helper/inbox'; | ||||||
|  | import { | ||||||
|  |   appendSignature, | ||||||
|  |   removeSignature, | ||||||
|  | } from 'dashboard/helper/editorHelper'; | ||||||
|  | import { | ||||||
|  |   buildContactableInboxesList, | ||||||
|  |   prepareNewMessagePayload, | ||||||
|  |   prepareWhatsAppMessagePayload, | ||||||
|  | } from 'dashboard/components-next/NewConversation/helpers/composeConversationHelper.js'; | ||||||
|  |  | ||||||
|  | import ContactSelector from './ContactSelector.vue'; | ||||||
|  | import InboxSelector from './InboxSelector.vue'; | ||||||
|  | import EmailOptions from './EmailOptions.vue'; | ||||||
|  | import MessageEditor from './MessageEditor.vue'; | ||||||
|  | import ActionButtons from './ActionButtons.vue'; | ||||||
|  | import InboxEmptyState from './InboxEmptyState.vue'; | ||||||
|  | import AttachmentPreviews from './AttachmentPreviews.vue'; | ||||||
|  |  | ||||||
|  | const props = defineProps({ | ||||||
|  |   contacts: { type: Array, default: () => [] }, | ||||||
|  |   contactId: { type: String, default: null }, | ||||||
|  |   selectedContact: { type: Object, default: null }, | ||||||
|  |   targetInbox: { type: Object, default: null }, | ||||||
|  |   currentUser: { type: Object, default: null }, | ||||||
|  |   isCreatingContact: { type: Boolean, default: false }, | ||||||
|  |   isFetchingInboxes: { type: Boolean, default: false }, | ||||||
|  |   isLoading: { type: Boolean, default: false }, | ||||||
|  |   isDirectUploadsEnabled: { type: Boolean, default: false }, | ||||||
|  |   contactConversationsUiFlags: { type: Object, default: null }, | ||||||
|  |   contactsUiFlags: { type: Object, default: null }, | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const emit = defineEmits([ | ||||||
|  |   'searchContacts', | ||||||
|  |   'discard', | ||||||
|  |   'updateSelectedContact', | ||||||
|  |   'updateTargetInbox', | ||||||
|  |   'clearSelectedContact', | ||||||
|  |   'createConversation', | ||||||
|  | ]); | ||||||
|  |  | ||||||
|  | const showContactsDropdown = ref(false); | ||||||
|  | const showInboxesDropdown = ref(false); | ||||||
|  | const showCcEmailsDropdown = ref(false); | ||||||
|  | const showBccEmailsDropdown = ref(false); | ||||||
|  |  | ||||||
|  | const isCreating = computed(() => props.contactConversationsUiFlags.isCreating); | ||||||
|  |  | ||||||
|  | const state = reactive({ | ||||||
|  |   message: '', | ||||||
|  |   subject: '', | ||||||
|  |   ccEmails: '', | ||||||
|  |   bccEmails: '', | ||||||
|  |   attachedFiles: [], | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const inboxTypes = computed(() => ({ | ||||||
|  |   isEmail: props.targetInbox?.channelType === INBOX_TYPES.EMAIL, | ||||||
|  |   isTwilio: props.targetInbox?.channelType === INBOX_TYPES.TWILIO, | ||||||
|  |   isWhatsapp: props.targetInbox?.channelType === INBOX_TYPES.WHATSAPP, | ||||||
|  |   isWebWidget: props.targetInbox?.channelType === INBOX_TYPES.WEB, | ||||||
|  |   isApi: props.targetInbox?.channelType === INBOX_TYPES.API, | ||||||
|  |   isEmailOrWebWidget: | ||||||
|  |     props.targetInbox?.channelType === INBOX_TYPES.EMAIL || | ||||||
|  |     props.targetInbox?.channelType === INBOX_TYPES.WEB, | ||||||
|  |   isTwilioSMS: | ||||||
|  |     props.targetInbox?.channelType === INBOX_TYPES.TWILIO && | ||||||
|  |     props.targetInbox?.medium === 'sms', | ||||||
|  | })); | ||||||
|  |  | ||||||
|  | const whatsappMessageTemplates = computed(() => | ||||||
|  |   Object.keys(props.targetInbox?.messageTemplates || {}).length | ||||||
|  |     ? props.targetInbox.messageTemplates | ||||||
|  |     : [] | ||||||
|  | ); | ||||||
|  |  | ||||||
|  | const inboxChannelType = computed(() => props.targetInbox?.channelType || ''); | ||||||
|  |  | ||||||
|  | const validationRules = computed(() => ({ | ||||||
|  |   selectedContact: { required }, | ||||||
|  |   targetInbox: { required }, | ||||||
|  |   message: { required: requiredIf(!inboxTypes.value.isWhatsapp) }, | ||||||
|  |   subject: { required: requiredIf(inboxTypes.value.isEmail) }, | ||||||
|  | })); | ||||||
|  |  | ||||||
|  | const v$ = useVuelidate(validationRules, { | ||||||
|  |   selectedContact: computed(() => props.selectedContact), | ||||||
|  |   targetInbox: computed(() => props.targetInbox), | ||||||
|  |   message: computed(() => state.message), | ||||||
|  |   subject: computed(() => state.subject), | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const validationStates = computed(() => ({ | ||||||
|  |   isContactInvalid: | ||||||
|  |     v$.value.selectedContact.$dirty && v$.value.selectedContact.$invalid, | ||||||
|  |   isInboxInvalid: v$.value.targetInbox.$dirty && v$.value.targetInbox.$invalid, | ||||||
|  |   isSubjectInvalid: v$.value.subject.$dirty && v$.value.subject.$invalid, | ||||||
|  |   isMessageInvalid: v$.value.message.$dirty && v$.value.message.$invalid, | ||||||
|  | })); | ||||||
|  |  | ||||||
|  | const newMessagePayload = () => { | ||||||
|  |   const { message, subject, ccEmails, bccEmails, attachedFiles } = state; | ||||||
|  |   return prepareNewMessagePayload({ | ||||||
|  |     targetInbox: props.targetInbox, | ||||||
|  |     selectedContact: props.selectedContact, | ||||||
|  |     message, | ||||||
|  |     subject, | ||||||
|  |     ccEmails, | ||||||
|  |     bccEmails, | ||||||
|  |     currentUser: props.currentUser, | ||||||
|  |     attachedFiles, | ||||||
|  |     directUploadsEnabled: props.isDirectUploadsEnabled, | ||||||
|  |   }); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const contactableInboxesList = computed(() => { | ||||||
|  |   return buildContactableInboxesList(props.selectedContact?.contactInboxes); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const showNoInboxAlert = computed(() => { | ||||||
|  |   return ( | ||||||
|  |     props.selectedContact && | ||||||
|  |     contactableInboxesList.value.length === 0 && | ||||||
|  |     !props.contactsUiFlags.isFetchingInboxes && | ||||||
|  |     !props.isFetchingInboxes | ||||||
|  |   ); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const isAnyDropdownActive = computed(() => { | ||||||
|  |   return ( | ||||||
|  |     showContactsDropdown.value || | ||||||
|  |     showInboxesDropdown.value || | ||||||
|  |     showCcEmailsDropdown.value || | ||||||
|  |     showBccEmailsDropdown.value | ||||||
|  |   ); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const handleContactSearch = value => { | ||||||
|  |   emit('searchContacts', value); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const handleDropdownUpdate = (type, value) => { | ||||||
|  |   if (type === 'cc') { | ||||||
|  |     showCcEmailsDropdown.value = value; | ||||||
|  |   } else if (type === 'bcc') { | ||||||
|  |     showBccEmailsDropdown.value = value; | ||||||
|  |   } else { | ||||||
|  |     showContactsDropdown.value = value; | ||||||
|  |   } | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const searchCcEmails = value => { | ||||||
|  |   showCcEmailsDropdown.value = true; | ||||||
|  |   emit('searchContacts', value); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const searchBccEmails = value => { | ||||||
|  |   showBccEmailsDropdown.value = true; | ||||||
|  |   emit('searchContacts', value); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const setSelectedContact = async ({ value, action, ...rest }) => { | ||||||
|  |   v$.value.$reset(); | ||||||
|  |   emit('updateSelectedContact', { value, action, ...rest }); | ||||||
|  |   showContactsDropdown.value = false; | ||||||
|  |   showInboxesDropdown.value = true; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const handleInboxAction = ({ value, action, ...rest }) => { | ||||||
|  |   v$.value.$reset(); | ||||||
|  |   emit('updateTargetInbox', { ...rest }); | ||||||
|  |   showInboxesDropdown.value = false; | ||||||
|  |   state.attachedFiles = []; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const removeTargetInbox = value => { | ||||||
|  |   v$.value.$reset(); | ||||||
|  |   emit('updateTargetInbox', value); | ||||||
|  |   state.attachedFiles = []; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const clearSelectedContact = () => { | ||||||
|  |   emit('clearSelectedContact'); | ||||||
|  |   state.attachedFiles = []; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const onClickInsertEmoji = emoji => { | ||||||
|  |   state.message += emoji; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const handleAddSignature = signature => { | ||||||
|  |   state.message = appendSignature(state.message, signature); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const handleRemoveSignature = signature => { | ||||||
|  |   state.message = removeSignature(state.message, signature); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const handleAttachFile = files => { | ||||||
|  |   state.attachedFiles = files; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const clearForm = () => { | ||||||
|  |   Object.assign(state, { | ||||||
|  |     message: '', | ||||||
|  |     subject: '', | ||||||
|  |     ccEmails: '', | ||||||
|  |     bccEmails: '', | ||||||
|  |     attachedFiles: [], | ||||||
|  |   }); | ||||||
|  |   v$.value.$reset(); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const handleSendMessage = async () => { | ||||||
|  |   const isValid = await v$.value.$validate(); | ||||||
|  |   if (!isValid) return; | ||||||
|  |  | ||||||
|  |   try { | ||||||
|  |     const success = await emit('createConversation', { | ||||||
|  |       payload: newMessagePayload(), | ||||||
|  |       isFromWhatsApp: false, | ||||||
|  |     }); | ||||||
|  |     if (success) { | ||||||
|  |       clearForm(); | ||||||
|  |     } | ||||||
|  |   } catch (error) { | ||||||
|  |     // Form will not be cleared if conversation creation fails | ||||||
|  |   } | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const handleSendWhatsappMessage = async ({ message, templateParams }) => { | ||||||
|  |   const whatsappMessagePayload = prepareWhatsAppMessagePayload({ | ||||||
|  |     targetInbox: props.targetInbox, | ||||||
|  |     selectedContact: props.selectedContact, | ||||||
|  |     message, | ||||||
|  |     templateParams, | ||||||
|  |     currentUser: props.currentUser, | ||||||
|  |   }); | ||||||
|  |   await emit('createConversation', { | ||||||
|  |     payload: whatsappMessagePayload, | ||||||
|  |     isFromWhatsApp: true, | ||||||
|  |   }); | ||||||
|  | }; | ||||||
|  | </script> | ||||||
|  |  | ||||||
|  | <template> | ||||||
|  |   <div | ||||||
|  |     class="absolute right-0 w-[670px] mt-2 divide-y divide-n-strong overflow-visible transition-all duration-300 ease-in-out top-full justify-between flex flex-col bg-n-alpha-3 border border-n-strong shadow-sm backdrop-blur-[100px] rounded-xl" | ||||||
|  |   > | ||||||
|  |     <ContactSelector | ||||||
|  |       :contacts="contacts" | ||||||
|  |       :selected-contact="selectedContact" | ||||||
|  |       :show-contacts-dropdown="showContactsDropdown" | ||||||
|  |       :is-loading="isLoading" | ||||||
|  |       :is-creating-contact="isCreatingContact" | ||||||
|  |       :contact-id="contactId" | ||||||
|  |       :contactable-inboxes-list="contactableInboxesList" | ||||||
|  |       :show-inboxes-dropdown="showInboxesDropdown" | ||||||
|  |       :has-errors="validationStates.isContactInvalid" | ||||||
|  |       @search-contacts="handleContactSearch" | ||||||
|  |       @set-selected-contact="setSelectedContact" | ||||||
|  |       @clear-selected-contact="clearSelectedContact" | ||||||
|  |       @update-dropdown="handleDropdownUpdate" | ||||||
|  |     /> | ||||||
|  |     <InboxEmptyState v-if="showNoInboxAlert" /> | ||||||
|  |     <InboxSelector | ||||||
|  |       v-else | ||||||
|  |       :target-inbox="targetInbox" | ||||||
|  |       :selected-contact="selectedContact" | ||||||
|  |       :show-inboxes-dropdown="showInboxesDropdown" | ||||||
|  |       :contactable-inboxes-list="contactableInboxesList" | ||||||
|  |       :has-errors="validationStates.isInboxInvalid" | ||||||
|  |       @update-inbox="removeTargetInbox" | ||||||
|  |       @toggle-dropdown="showInboxesDropdown = $event" | ||||||
|  |       @handle-inbox-action="handleInboxAction" | ||||||
|  |     /> | ||||||
|  |  | ||||||
|  |     <EmailOptions | ||||||
|  |       v-if="inboxTypes.isEmail" | ||||||
|  |       v-model:cc-emails="state.ccEmails" | ||||||
|  |       v-model:bcc-emails="state.bccEmails" | ||||||
|  |       v-model:subject="state.subject" | ||||||
|  |       :contacts="contacts" | ||||||
|  |       :show-cc-emails-dropdown="showCcEmailsDropdown" | ||||||
|  |       :show-bcc-emails-dropdown="showBccEmailsDropdown" | ||||||
|  |       :is-loading="isLoading" | ||||||
|  |       :has-errors="validationStates.isSubjectInvalid" | ||||||
|  |       @search-cc-emails="searchCcEmails" | ||||||
|  |       @search-bcc-emails="searchBccEmails" | ||||||
|  |       @update-dropdown="handleDropdownUpdate" | ||||||
|  |     /> | ||||||
|  |  | ||||||
|  |     <MessageEditor | ||||||
|  |       v-if="!inboxTypes.isWhatsapp && !showNoInboxAlert" | ||||||
|  |       v-model="state.message" | ||||||
|  |       :is-email-or-web-widget-inbox="inboxTypes.isEmailOrWebWidget" | ||||||
|  |       :has-errors="validationStates.isMessageInvalid" | ||||||
|  |       :has-attachments="state.attachedFiles.length > 0" | ||||||
|  |     /> | ||||||
|  |  | ||||||
|  |     <AttachmentPreviews | ||||||
|  |       v-if="state.attachedFiles.length > 0" | ||||||
|  |       :attachments="state.attachedFiles" | ||||||
|  |       @update:attachments="state.attachedFiles = $event" | ||||||
|  |     /> | ||||||
|  |  | ||||||
|  |     <ActionButtons | ||||||
|  |       :attached-files="state.attachedFiles" | ||||||
|  |       :is-whatsapp-inbox="inboxTypes.isWhatsapp" | ||||||
|  |       :is-email-or-web-widget-inbox="inboxTypes.isEmailOrWebWidget" | ||||||
|  |       :is-twilio-sms-inbox="inboxTypes.isTwilioSMS" | ||||||
|  |       :message-templates="whatsappMessageTemplates" | ||||||
|  |       :channel-type="inboxChannelType" | ||||||
|  |       :is-loading="isCreating" | ||||||
|  |       :disable-send-button="isCreating" | ||||||
|  |       :has-no-inbox="showNoInboxAlert" | ||||||
|  |       :is-dropdown-active="isAnyDropdownActive" | ||||||
|  |       @insert-emoji="onClickInsertEmoji" | ||||||
|  |       @add-signature="handleAddSignature" | ||||||
|  |       @remove-signature="handleRemoveSignature" | ||||||
|  |       @attach-file="handleAttachFile" | ||||||
|  |       @discard="$emit('discard')" | ||||||
|  |       @send-message="handleSendMessage" | ||||||
|  |       @send-whatsapp-message="handleSendWhatsappMessage" | ||||||
|  |     /> | ||||||
|  |   </div> | ||||||
|  | </template> | ||||||
| @@ -0,0 +1,141 @@ | |||||||
|  | <script setup> | ||||||
|  | import { computed } from 'vue'; | ||||||
|  | import { useI18n } from 'vue-i18n'; | ||||||
|  |  | ||||||
|  | import TagInput from 'dashboard/components-next/taginput/TagInput.vue'; | ||||||
|  | import Button from 'dashboard/components-next/button/Button.vue'; | ||||||
|  |  | ||||||
|  | const props = defineProps({ | ||||||
|  |   contacts: { | ||||||
|  |     type: Array, | ||||||
|  |     required: true, | ||||||
|  |   }, | ||||||
|  |   selectedContact: { | ||||||
|  |     type: Object, | ||||||
|  |     default: null, | ||||||
|  |   }, | ||||||
|  |   showContactsDropdown: { | ||||||
|  |     type: Boolean, | ||||||
|  |     required: true, | ||||||
|  |   }, | ||||||
|  |   isLoading: { | ||||||
|  |     type: Boolean, | ||||||
|  |     required: true, | ||||||
|  |   }, | ||||||
|  |   isCreatingContact: { | ||||||
|  |     type: Boolean, | ||||||
|  |     required: true, | ||||||
|  |   }, | ||||||
|  |   contactId: { | ||||||
|  |     type: String, | ||||||
|  |     default: null, | ||||||
|  |   }, | ||||||
|  |   contactableInboxesList: { | ||||||
|  |     type: Array, | ||||||
|  |     default: () => [], | ||||||
|  |   }, | ||||||
|  |   showInboxesDropdown: { | ||||||
|  |     type: Boolean, | ||||||
|  |     required: true, | ||||||
|  |   }, | ||||||
|  |   hasErrors: { | ||||||
|  |     type: Boolean, | ||||||
|  |     default: false, | ||||||
|  |   }, | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const emit = defineEmits([ | ||||||
|  |   'searchContacts', | ||||||
|  |   'setSelectedContact', | ||||||
|  |   'clearSelectedContact', | ||||||
|  |   'updateDropdown', | ||||||
|  | ]); | ||||||
|  |  | ||||||
|  | const { t } = useI18n(); | ||||||
|  |  | ||||||
|  | const contactsList = computed(() => { | ||||||
|  |   return props.contacts?.map(({ name, id, thumbnail, email, ...rest }) => ({ | ||||||
|  |     id, | ||||||
|  |     label: `${name} (${email})`, | ||||||
|  |     value: id, | ||||||
|  |     thumbnail: { name, src: thumbnail }, | ||||||
|  |     ...rest, | ||||||
|  |     name, | ||||||
|  |     email, | ||||||
|  |     action: 'contact', | ||||||
|  |   })); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const selectedContactLabel = computed(() => { | ||||||
|  |   return `${props.selectedContact?.name} (${props.selectedContact?.email})`; | ||||||
|  | }); | ||||||
|  | </script> | ||||||
|  |  | ||||||
|  | <template> | ||||||
|  |   <div class="relative flex-1 px-4 py-3 overflow-y-visible"> | ||||||
|  |     <div class="flex items-baseline w-full gap-3 min-h-7"> | ||||||
|  |       <label class="text-sm font-medium text-n-slate-11 whitespace-nowrap"> | ||||||
|  |         {{ t('COMPOSE_NEW_CONVERSATION.FORM.CONTACT_SELECTOR.LABEL') }} | ||||||
|  |       </label> | ||||||
|  |  | ||||||
|  |       <div | ||||||
|  |         v-if="isCreatingContact" | ||||||
|  |         class="flex items-center gap-1.5 rounded-md bg-n-alpha-2 px-3 min-h-7 min-w-0" | ||||||
|  |       > | ||||||
|  |         <span class="text-sm truncate text-n-slate-12"> | ||||||
|  |           {{ | ||||||
|  |             t('COMPOSE_NEW_CONVERSATION.FORM.CONTACT_SELECTOR.CONTACT_CREATING') | ||||||
|  |           }} | ||||||
|  |         </span> | ||||||
|  |       </div> | ||||||
|  |       <div | ||||||
|  |         v-else-if="selectedContact" | ||||||
|  |         class="flex items-center gap-1.5 rounded-md bg-n-alpha-2 px-3 min-h-7 min-w-0" | ||||||
|  |       > | ||||||
|  |         <span class="text-sm truncate text-n-slate-12"> | ||||||
|  |           {{ | ||||||
|  |             isCreatingContact | ||||||
|  |               ? t( | ||||||
|  |                   'COMPOSE_NEW_CONVERSATION.FORM.CONTACT_SELECTOR.CONTACT_CREATING' | ||||||
|  |                 ) | ||||||
|  |               : selectedContactLabel | ||||||
|  |           }} | ||||||
|  |         </span> | ||||||
|  |         <Button | ||||||
|  |           variant="ghost" | ||||||
|  |           icon="i-lucide-x" | ||||||
|  |           color="slate" | ||||||
|  |           :disabled="contactId" | ||||||
|  |           size="xs" | ||||||
|  |           @click="emit('clearSelectedContact')" | ||||||
|  |         /> | ||||||
|  |       </div> | ||||||
|  |       <TagInput | ||||||
|  |         v-else | ||||||
|  |         :placeholder=" | ||||||
|  |           t( | ||||||
|  |             'COMPOSE_NEW_CONVERSATION.FORM.CONTACT_SELECTOR.TAG_INPUT_PLACEHOLDER' | ||||||
|  |           ) | ||||||
|  |         " | ||||||
|  |         mode="single" | ||||||
|  |         :menu-items="contactsList" | ||||||
|  |         :show-dropdown="showContactsDropdown" | ||||||
|  |         :is-loading="isLoading" | ||||||
|  |         :disabled="contactableInboxesList?.length > 0 && showInboxesDropdown" | ||||||
|  |         allow-create | ||||||
|  |         type="email" | ||||||
|  |         class="flex-1 min-h-7" | ||||||
|  |         :class=" | ||||||
|  |           hasErrors | ||||||
|  |             ? '[&_input]:placeholder:!text-n-ruby-9 [&_input]:dark:placeholder:!text-n-ruby-9' | ||||||
|  |             : '' | ||||||
|  |         " | ||||||
|  |         @focus="emit('updateDropdown', 'contacts', true)" | ||||||
|  |         @input="emit('searchContacts', $event)" | ||||||
|  |         @on-click-outside="emit('updateDropdown', 'contacts', false)" | ||||||
|  |         @add="emit('setSelectedContact', $event)" | ||||||
|  |         @remove="emit('clearSelectedContact')" | ||||||
|  |       /> | ||||||
|  |     </div> | ||||||
|  |   </div> | ||||||
|  | </template> | ||||||
| @@ -0,0 +1,139 @@ | |||||||
|  | <script setup> | ||||||
|  | import { computed, ref } from 'vue'; | ||||||
|  | import { useI18n } from 'vue-i18n'; | ||||||
|  |  | ||||||
|  | import TagInput from 'dashboard/components-next/taginput/TagInput.vue'; | ||||||
|  | import Button from 'dashboard/components-next/button/Button.vue'; | ||||||
|  | import InlineInput from 'dashboard/components-next/inline-input/InlineInput.vue'; | ||||||
|  |  | ||||||
|  | const props = defineProps({ | ||||||
|  |   contacts: { type: Array, required: true }, | ||||||
|  |   showCcEmailsDropdown: { type: Boolean, required: false }, | ||||||
|  |   showBccEmailsDropdown: { type: Boolean, required: false }, | ||||||
|  |   isLoading: { type: Boolean, default: false }, | ||||||
|  |   hasErrors: { type: Boolean, default: false }, | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const emit = defineEmits([ | ||||||
|  |   'searchCcEmails', | ||||||
|  |   'searchBccEmails', | ||||||
|  |   'updateDropdown', | ||||||
|  | ]); | ||||||
|  |  | ||||||
|  | const i18nPrefix = `COMPOSE_NEW_CONVERSATION.FORM.EMAIL_OPTIONS`; | ||||||
|  |  | ||||||
|  | const showBccInput = ref(false); | ||||||
|  |  | ||||||
|  | const toggleBccInput = () => { | ||||||
|  |   showBccInput.value = !showBccInput.value; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const subject = defineModel('subject', { type: String, default: '' }); | ||||||
|  | const ccEmails = defineModel('ccEmails', { type: String, default: '' }); | ||||||
|  | const bccEmails = defineModel('bccEmails', { type: String, default: '' }); | ||||||
|  |  | ||||||
|  | const { t } = useI18n(); | ||||||
|  |  | ||||||
|  | // Convert string to array for TagInput | ||||||
|  | const ccEmailsArray = computed(() => | ||||||
|  |   props.ccEmails ? props.ccEmails.split(',').map(email => email.trim()) : [] | ||||||
|  | ); | ||||||
|  |  | ||||||
|  | const bccEmailsArray = computed(() => | ||||||
|  |   props.bccEmails ? props.bccEmails.split(',').map(email => email.trim()) : [] | ||||||
|  | ); | ||||||
|  |  | ||||||
|  | const contactEmailsList = computed(() => { | ||||||
|  |   return props.contacts?.map(({ name, id, email }) => ({ | ||||||
|  |     id, | ||||||
|  |     label: email, | ||||||
|  |     email, | ||||||
|  |     thumbnail: { name: name, src: '' }, | ||||||
|  |     value: id, | ||||||
|  |     action: 'email', | ||||||
|  |   })); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | // Handle updates from TagInput and convert array back to string | ||||||
|  | const handleCcUpdate = value => { | ||||||
|  |   ccEmails.value = value.join(','); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const handleBccUpdate = value => { | ||||||
|  |   bccEmails.value = value.join(','); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const inputClass = computed(() => { | ||||||
|  |   return props.hasErrors | ||||||
|  |     ? 'placeholder:!text-n-ruby-9 dark:placeholder:!text-n-ruby-9' | ||||||
|  |     : ''; | ||||||
|  | }); | ||||||
|  | </script> | ||||||
|  |  | ||||||
|  | <template> | ||||||
|  |   <div class="flex flex-col divide-y divide-n-strong"> | ||||||
|  |     <div class="flex items-baseline flex-1 w-full h-8 gap-3 px-4 py-3"> | ||||||
|  |       <InlineInput | ||||||
|  |         v-model="subject" | ||||||
|  |         :placeholder="t(`${i18nPrefix}.SUBJECT_PLACEHOLDER`)" | ||||||
|  |         :label="t(`${i18nPrefix}.SUBJECT_LABEL`)" | ||||||
|  |         focus-on-mount | ||||||
|  |         :custom-input-class="inputClass" | ||||||
|  |       /> | ||||||
|  |     </div> | ||||||
|  |     <div class="flex items-baseline flex-1 w-full gap-3 px-4 py-3 min-h-8"> | ||||||
|  |       <label | ||||||
|  |         class="mb-0.5 text-sm font-medium whitespace-nowrap text-n-slate-11" | ||||||
|  |       > | ||||||
|  |         {{ t(`${i18nPrefix}.CC_LABEL`) }} | ||||||
|  |       </label> | ||||||
|  |       <div class="flex items-center w-full gap-3 min-h-7"> | ||||||
|  |         <TagInput | ||||||
|  |           :model-value="ccEmailsArray" | ||||||
|  |           :placeholder="t(`${i18nPrefix}.CC_PLACEHOLDER`)" | ||||||
|  |           :menu-items="contactEmailsList" | ||||||
|  |           :show-dropdown="showCcEmailsDropdown" | ||||||
|  |           :is-loading="isLoading" | ||||||
|  |           type="email" | ||||||
|  |           class="flex-1 min-h-7" | ||||||
|  |           @focus="emit('updateDropdown', 'cc', true)" | ||||||
|  |           @input="emit('searchCcEmails', $event)" | ||||||
|  |           @on-click-outside="emit('updateDropdown', 'cc', false)" | ||||||
|  |           @update:model-value="handleCcUpdate" | ||||||
|  |         /> | ||||||
|  |         <Button | ||||||
|  |           :label="t(`${i18nPrefix}.BCC_BUTTON`)" | ||||||
|  |           variant="ghost" | ||||||
|  |           size="sm" | ||||||
|  |           color="slate" | ||||||
|  |           class="flex-shrink-0" | ||||||
|  |           @click="toggleBccInput" | ||||||
|  |         /> | ||||||
|  |       </div> | ||||||
|  |     </div> | ||||||
|  |     <div | ||||||
|  |       v-if="showBccInput" | ||||||
|  |       class="flex items-baseline flex-1 w-full gap-3 px-4 py-3 min-h-8" | ||||||
|  |     > | ||||||
|  |       <label | ||||||
|  |         class="mb-0.5 text-sm font-medium whitespace-nowrap text-n-slate-11" | ||||||
|  |       > | ||||||
|  |         {{ t(`${i18nPrefix}.BCC_LABEL`) }} | ||||||
|  |       </label> | ||||||
|  |       <TagInput | ||||||
|  |         :model-value="bccEmailsArray" | ||||||
|  |         :placeholder="t(`${i18nPrefix}.BCC_PLACEHOLDER`)" | ||||||
|  |         :menu-items="contactEmailsList" | ||||||
|  |         :show-dropdown="showBccEmailsDropdown" | ||||||
|  |         :is-loading="isLoading" | ||||||
|  |         type="email" | ||||||
|  |         class="flex-1 min-h-7" | ||||||
|  |         focus-on-mount | ||||||
|  |         @focus="emit('updateDropdown', 'bcc', true)" | ||||||
|  |         @input="emit('searchBccEmails', $event)" | ||||||
|  |         @on-click-outside="emit('updateDropdown', 'bcc', false)" | ||||||
|  |         @update:model-value="handleBccUpdate" | ||||||
|  |       /> | ||||||
|  |     </div> | ||||||
|  |   </div> | ||||||
|  | </template> | ||||||
| @@ -0,0 +1,9 @@ | |||||||
|  | <template> | ||||||
|  |   <div | ||||||
|  |     class="flex items-center w-full px-4 py-3 dark:bg-n-amber-11/15 bg-n-amber-3" | ||||||
|  |   > | ||||||
|  |     <span class="text-sm dark:text-n-amber-11 text-n-amber-11"> | ||||||
|  |       {{ $t('COMPOSE_NEW_CONVERSATION.FORM.NO_INBOX_ALERT') }} | ||||||
|  |     </span> | ||||||
|  |   </div> | ||||||
|  | </template> | ||||||
| @@ -0,0 +1,91 @@ | |||||||
|  | <script setup> | ||||||
|  | import { computed } from 'vue'; | ||||||
|  | import { useI18n } from 'vue-i18n'; | ||||||
|  | import { vOnClickOutside } from '@vueuse/components'; | ||||||
|  | import { generateLabelForContactableInboxesList } from 'dashboard/components-next/NewConversation/helpers/composeConversationHelper.js'; | ||||||
|  |  | ||||||
|  | import Button from 'dashboard/components-next/button/Button.vue'; | ||||||
|  | import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue'; | ||||||
|  |  | ||||||
|  | const props = defineProps({ | ||||||
|  |   targetInbox: { | ||||||
|  |     type: Object, | ||||||
|  |     default: null, | ||||||
|  |   }, | ||||||
|  |   selectedContact: { | ||||||
|  |     type: Object, | ||||||
|  |     default: null, | ||||||
|  |   }, | ||||||
|  |   showInboxesDropdown: { | ||||||
|  |     type: Boolean, | ||||||
|  |     required: true, | ||||||
|  |   }, | ||||||
|  |   contactableInboxesList: { | ||||||
|  |     type: Array, | ||||||
|  |     default: () => [], | ||||||
|  |   }, | ||||||
|  |   hasErrors: { | ||||||
|  |     type: Boolean, | ||||||
|  |     default: false, | ||||||
|  |   }, | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const emit = defineEmits([ | ||||||
|  |   'updateInbox', | ||||||
|  |   'toggleDropdown', | ||||||
|  |   'handleInboxAction', | ||||||
|  | ]); | ||||||
|  |  | ||||||
|  | const { t } = useI18n(); | ||||||
|  |  | ||||||
|  | const targetInboxLabel = computed(() => { | ||||||
|  |   return generateLabelForContactableInboxesList(props.targetInbox); | ||||||
|  | }); | ||||||
|  | </script> | ||||||
|  |  | ||||||
|  | <template> | ||||||
|  |   <div | ||||||
|  |     class="flex items-center flex-1 w-full gap-3 px-4 py-3 overflow-y-visible" | ||||||
|  |   > | ||||||
|  |     <label class="mb-0.5 text-sm font-medium text-n-slate-11 whitespace-nowrap"> | ||||||
|  |       {{ t('COMPOSE_NEW_CONVERSATION.FORM.INBOX_SELECTOR.LABEL') }} | ||||||
|  |     </label> | ||||||
|  |     <div | ||||||
|  |       v-if="targetInbox" | ||||||
|  |       class="flex items-center gap-1.5 rounded-md bg-n-alpha-2 truncate px-3 h-7 min-w-0" | ||||||
|  |     > | ||||||
|  |       <span class="text-sm text-n-slate-12"> | ||||||
|  |         {{ targetInboxLabel }} | ||||||
|  |       </span> | ||||||
|  |       <Button | ||||||
|  |         variant="ghost" | ||||||
|  |         icon="i-lucide-x" | ||||||
|  |         color="slate" | ||||||
|  |         size="xs" | ||||||
|  |         class="flex-shrink-0" | ||||||
|  |         @click="emit('updateInbox', null)" | ||||||
|  |       /> | ||||||
|  |     </div> | ||||||
|  |     <div | ||||||
|  |       v-else | ||||||
|  |       v-on-click-outside="() => emit('toggleDropdown', false)" | ||||||
|  |       class="relative flex items-center h-7" | ||||||
|  |     > | ||||||
|  |       <Button | ||||||
|  |         :label="t('COMPOSE_NEW_CONVERSATION.FORM.INBOX_SELECTOR.BUTTON')" | ||||||
|  |         variant="link" | ||||||
|  |         size="sm" | ||||||
|  |         :color="hasErrors ? 'ruby' : 'slate'" | ||||||
|  |         :disabled="!selectedContact" | ||||||
|  |         class="hover:!no-underline" | ||||||
|  |         @click="emit('toggleDropdown', !showInboxesDropdown)" | ||||||
|  |       /> | ||||||
|  |       <DropdownMenu | ||||||
|  |         v-if="contactableInboxesList?.length > 0 && showInboxesDropdown" | ||||||
|  |         :menu-items="contactableInboxesList" | ||||||
|  |         class="left-0 z-[100] top-8 overflow-y-auto max-h-60 w-fit max-w-sm dark:!outline-n-slate-5" | ||||||
|  |         @action="emit('handleInboxAction', $event)" | ||||||
|  |       /> | ||||||
|  |     </div> | ||||||
|  |   </div> | ||||||
|  | </template> | ||||||
| @@ -0,0 +1,65 @@ | |||||||
|  | <script setup> | ||||||
|  | import { useI18n } from 'vue-i18n'; | ||||||
|  |  | ||||||
|  | import Editor from 'dashboard/components-next/Editor/Editor.vue'; | ||||||
|  | import TextArea from 'dashboard/components-next/textarea/TextArea.vue'; | ||||||
|  |  | ||||||
|  | defineProps({ | ||||||
|  |   isEmailOrWebWidgetInbox: { | ||||||
|  |     type: Boolean, | ||||||
|  |     required: true, | ||||||
|  |   }, | ||||||
|  |   hasErrors: { | ||||||
|  |     type: Boolean, | ||||||
|  |     default: false, | ||||||
|  |   }, | ||||||
|  |   hasAttachments: { | ||||||
|  |     type: Boolean, | ||||||
|  |     default: false, | ||||||
|  |   }, | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const { t } = useI18n(); | ||||||
|  |  | ||||||
|  | const modelValue = defineModel({ | ||||||
|  |   type: String, | ||||||
|  |   default: '', | ||||||
|  | }); | ||||||
|  | </script> | ||||||
|  |  | ||||||
|  | <template> | ||||||
|  |   <div | ||||||
|  |     v-if="isEmailOrWebWidgetInbox" | ||||||
|  |     class="flex-1 h-full" | ||||||
|  |     :class="!hasAttachments && 'min-h-[200px]'" | ||||||
|  |   > | ||||||
|  |     <Editor | ||||||
|  |       v-model="modelValue" | ||||||
|  |       :placeholder=" | ||||||
|  |         t('COMPOSE_NEW_CONVERSATION.FORM.MESSAGE_EDITOR.PLACEHOLDER') | ||||||
|  |       " | ||||||
|  |       class="[&>div]:!border-transparent [&>div]:px-4 [&>div]:py-4 [&>div]:!bg-transparent h-full [&_.ProseMirror-woot-style]:!max-h-[200px]" | ||||||
|  |       :class=" | ||||||
|  |         hasErrors | ||||||
|  |           ? '[&_.empty-node]:before:!text-n-ruby-9 [&_.empty-node]:dark:before:!text-n-ruby-9' | ||||||
|  |           : '' | ||||||
|  |       " | ||||||
|  |       :show-character-count="false" | ||||||
|  |     /> | ||||||
|  |   </div> | ||||||
|  |   <div v-else class="flex-1 h-full" :class="!hasAttachments && 'min-h-[200px]'"> | ||||||
|  |     <TextArea | ||||||
|  |       v-model="modelValue" | ||||||
|  |       :placeholder=" | ||||||
|  |         t('COMPOSE_NEW_CONVERSATION.FORM.MESSAGE_EDITOR.PLACEHOLDER') | ||||||
|  |       " | ||||||
|  |       class="!px-0 [&>div]:!px-4 [&>div]:!border-transparent [&>div]:!bg-transparent" | ||||||
|  |       auto-height | ||||||
|  |       :custom-text-area-class=" | ||||||
|  |         hasErrors | ||||||
|  |           ? 'placeholder:!text-n-ruby-9 dark:placeholder:!text-n-ruby-9' | ||||||
|  |           : '' | ||||||
|  |       " | ||||||
|  |     /> | ||||||
|  |   </div> | ||||||
|  | </template> | ||||||
| @@ -0,0 +1,126 @@ | |||||||
|  | <script setup> | ||||||
|  | import { computed, ref } from 'vue'; | ||||||
|  | import { useI18n } from 'vue-i18n'; | ||||||
|  |  | ||||||
|  | import Button from 'dashboard/components-next/button/Button.vue'; | ||||||
|  | import WhatsappTemplateParser from './WhatsappTemplateParser.vue'; | ||||||
|  |  | ||||||
|  | const props = defineProps({ | ||||||
|  |   messageTemplates: { | ||||||
|  |     type: Array, | ||||||
|  |     default: () => [], | ||||||
|  |   }, | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const emit = defineEmits(['sendMessage']); | ||||||
|  |  | ||||||
|  | const { t } = useI18n(); | ||||||
|  |  | ||||||
|  | // TODO: Remove this when we support all formats | ||||||
|  | const formatsToRemove = ['DOCUMENT', 'IMAGE', 'VIDEO']; | ||||||
|  |  | ||||||
|  | const searchQuery = ref(''); | ||||||
|  | const selectedTemplate = ref(null); | ||||||
|  |  | ||||||
|  | const showTemplatesMenu = ref(false); | ||||||
|  |  | ||||||
|  | const whatsAppTemplateMessages = computed(() => { | ||||||
|  |   // Add null check and ensure it's an array | ||||||
|  |   const templates = Array.isArray(props.messageTemplates) | ||||||
|  |     ? props.messageTemplates | ||||||
|  |     : []; | ||||||
|  |  | ||||||
|  |   // TODO: Remove the last filter when we support all formats | ||||||
|  |   return templates | ||||||
|  |     .filter(template => template?.status?.toLowerCase() === 'approved') | ||||||
|  |     .filter(template => { | ||||||
|  |       return template?.components?.every(component => { | ||||||
|  |         return !formatsToRemove.includes(component.format); | ||||||
|  |       }); | ||||||
|  |     }); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const filteredTemplates = computed(() => { | ||||||
|  |   return whatsAppTemplateMessages.value.filter(template => | ||||||
|  |     template.name.toLowerCase().includes(searchQuery.value.toLowerCase()) | ||||||
|  |   ); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const getTemplateBody = template => { | ||||||
|  |   return template.components.find(component => component.type === 'BODY').text; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const handleTriggerClick = () => { | ||||||
|  |   searchQuery.value = ''; | ||||||
|  |   showTemplatesMenu.value = !showTemplatesMenu.value; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const handleTemplateClick = template => { | ||||||
|  |   selectedTemplate.value = template; | ||||||
|  |   showTemplatesMenu.value = false; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const handleBack = () => { | ||||||
|  |   selectedTemplate.value = null; | ||||||
|  |   showTemplatesMenu.value = true; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const handleSendMessage = template => { | ||||||
|  |   emit('sendMessage', template); | ||||||
|  |   selectedTemplate.value = null; | ||||||
|  | }; | ||||||
|  | </script> | ||||||
|  |  | ||||||
|  | <template> | ||||||
|  |   <div class="relative"> | ||||||
|  |     <Button | ||||||
|  |       icon="i-ri-whatsapp-line" | ||||||
|  |       :label="t('COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.LABEL')" | ||||||
|  |       color="slate" | ||||||
|  |       size="sm" | ||||||
|  |       :disabled="selectedTemplate" | ||||||
|  |       class="!text-xs font-medium" | ||||||
|  |       @click="handleTriggerClick" | ||||||
|  |     /> | ||||||
|  |     <div | ||||||
|  |       v-if="showTemplatesMenu" | ||||||
|  |       class="absolute top-full mt-1.5 max-h-96 overflow-y-auto left-0 flex flex-col gap-2 p-4 items-center w-[350px] h-auto bg-n-solid-2 border border-n-strong shadow-sm rounded-lg" | ||||||
|  |     > | ||||||
|  |       <div class="relative w-full"> | ||||||
|  |         <span class="absolute i-lucide-search size-3.5 top-2 left-3" /> | ||||||
|  |         <input | ||||||
|  |           v-model="searchQuery" | ||||||
|  |           type="search" | ||||||
|  |           :placeholder=" | ||||||
|  |             t( | ||||||
|  |               'COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.SEARCH_PLACEHOLDER' | ||||||
|  |             ) | ||||||
|  |           " | ||||||
|  |           class="w-full h-8 py-2 pl-10 pr-2 text-sm border-none rounded-lg bg-n-alpha-black2 dark:bg-n-solid-1 text-n-slate-12" | ||||||
|  |         /> | ||||||
|  |       </div> | ||||||
|  |       <div | ||||||
|  |         v-for="template in filteredTemplates" | ||||||
|  |         :key="template.id" | ||||||
|  |         class="flex flex-col w-full gap-2 p-2 rounded-lg cursor-pointer dark:hover:bg-n-alpha-3 hover:bg-n-alpha-1" | ||||||
|  |         @click="handleTemplateClick(template)" | ||||||
|  |       > | ||||||
|  |         <span class="text-sm text-n-slate-12">{{ template.name }}</span> | ||||||
|  |         <p class="mb-0 text-xs leading-5 text-n-slate-11 line-clamp-2"> | ||||||
|  |           {{ getTemplateBody(template) }} | ||||||
|  |         </p> | ||||||
|  |       </div> | ||||||
|  |       <template v-if="filteredTemplates.length === 0"> | ||||||
|  |         <p class="w-full pt-2 text-sm text-n-slate-11"> | ||||||
|  |           {{ t('COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.EMPTY_STATE') }} | ||||||
|  |         </p> | ||||||
|  |       </template> | ||||||
|  |     </div> | ||||||
|  |     <WhatsappTemplateParser | ||||||
|  |       v-if="selectedTemplate" | ||||||
|  |       :template="selectedTemplate" | ||||||
|  |       @send-message="handleSendMessage" | ||||||
|  |       @back="handleBack" | ||||||
|  |     /> | ||||||
|  |   </div> | ||||||
|  | </template> | ||||||
| @@ -0,0 +1,176 @@ | |||||||
|  | <script setup> | ||||||
|  | import { computed, ref, onMounted } from 'vue'; | ||||||
|  | import { useVuelidate } from '@vuelidate/core'; | ||||||
|  | import { requiredIf } from '@vuelidate/validators'; | ||||||
|  | import { useI18n } from 'vue-i18n'; | ||||||
|  |  | ||||||
|  | import Input from 'dashboard/components-next/input/Input.vue'; | ||||||
|  | import Button from 'dashboard/components-next/button/Button.vue'; | ||||||
|  |  | ||||||
|  | const props = defineProps({ | ||||||
|  |   template: { | ||||||
|  |     type: Object, | ||||||
|  |     required: true, | ||||||
|  |   }, | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const emit = defineEmits(['sendMessage', 'back']); | ||||||
|  |  | ||||||
|  | const { t } = useI18n(); | ||||||
|  |  | ||||||
|  | const processedParams = ref({}); | ||||||
|  |  | ||||||
|  | const templateName = computed(() => { | ||||||
|  |   return props.template?.name || ''; | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const templateString = computed(() => { | ||||||
|  |   return props.template?.components?.find( | ||||||
|  |     component => component.type === 'BODY' | ||||||
|  |   ).text; | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const processVariable = str => { | ||||||
|  |   return str.replace(/{{|}}/g, ''); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const processedString = computed(() => { | ||||||
|  |   return templateString.value.replace(/{{([^}]+)}}/g, (match, variable) => { | ||||||
|  |     const variableKey = processVariable(variable); | ||||||
|  |     return processedParams.value[variableKey] || `{{${variable}}}`; | ||||||
|  |   }); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const processedStringWithVariableHighlight = computed(() => { | ||||||
|  |   const variables = templateString.value.match(/{{([^}]+)}}/g) || []; | ||||||
|  |  | ||||||
|  |   return variables.reduce((result, variable) => { | ||||||
|  |     const variableKey = processVariable(variable); | ||||||
|  |     const value = processedParams.value[variableKey] || variable; | ||||||
|  |     return result.replace( | ||||||
|  |       variable, | ||||||
|  |       `<span class="break-all text-n-slate-12">${value}</span>` | ||||||
|  |     ); | ||||||
|  |   }, templateString.value); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const rules = computed(() => { | ||||||
|  |   const paramRules = {}; | ||||||
|  |   Object.keys(processedParams.value).forEach(key => { | ||||||
|  |     paramRules[key] = { required: requiredIf(true) }; | ||||||
|  |   }); | ||||||
|  |   return { | ||||||
|  |     processedParams: paramRules, | ||||||
|  |   }; | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const v$ = useVuelidate(rules, { processedParams }); | ||||||
|  |  | ||||||
|  | const getFieldErrorType = key => { | ||||||
|  |   if (!v$.value.processedParams[key]?.$error) return 'info'; | ||||||
|  |   return 'error'; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const generateVariables = () => { | ||||||
|  |   const matchedVariables = templateString.value.match(/{{([^}]+)}}/g); | ||||||
|  |   if (!matchedVariables) return; | ||||||
|  |  | ||||||
|  |   const finalVars = matchedVariables.map(i => processVariable(i)); | ||||||
|  |   processedParams.value = finalVars.reduce((acc, variable) => { | ||||||
|  |     acc[variable] = ''; | ||||||
|  |     return acc; | ||||||
|  |   }, {}); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const sendMessage = async () => { | ||||||
|  |   const isValid = await v$.value.$validate(); | ||||||
|  |   if (!isValid) return; | ||||||
|  |  | ||||||
|  |   const payload = { | ||||||
|  |     message: processedString.value, | ||||||
|  |     templateParams: { | ||||||
|  |       name: props.template.name, | ||||||
|  |       category: props.template.category, | ||||||
|  |       language: props.template.language, | ||||||
|  |       namespace: props.template.namespace, | ||||||
|  |       processed_params: processedParams.value, | ||||||
|  |     }, | ||||||
|  |   }; | ||||||
|  |   emit('sendMessage', payload); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | onMounted(() => { | ||||||
|  |   generateVariables(); | ||||||
|  | }); | ||||||
|  | </script> | ||||||
|  |  | ||||||
|  | <template> | ||||||
|  |   <div | ||||||
|  |     class="absolute top-full mt-1.5 max-h-[500px] overflow-y-auto left-0 flex flex-col gap-4 px-4 pt-6 pb-5 items-start w-[460px] h-auto bg-n-solid-2 border border-n-strong shadow-sm rounded-lg" | ||||||
|  |   > | ||||||
|  |     <span class="text-sm text-n-slate-12"> | ||||||
|  |       {{ | ||||||
|  |         t( | ||||||
|  |           'COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.TEMPLATE_PARSER.TEMPLATE_NAME', | ||||||
|  |           { templateName: templateName } | ||||||
|  |         ) | ||||||
|  |       }} | ||||||
|  |     </span> | ||||||
|  |     <p | ||||||
|  |       class="mb-0 text-sm text-n-slate-11" | ||||||
|  |       v-html="processedStringWithVariableHighlight" | ||||||
|  |     /> | ||||||
|  |  | ||||||
|  |     <span | ||||||
|  |       v-if="Object.keys(processedParams).length" | ||||||
|  |       class="text-sm font-medium text-n-slate-12" | ||||||
|  |     > | ||||||
|  |       {{ | ||||||
|  |         t( | ||||||
|  |           'COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.TEMPLATE_PARSER.VARIABLES' | ||||||
|  |         ) | ||||||
|  |       }} | ||||||
|  |     </span> | ||||||
|  |  | ||||||
|  |     <div | ||||||
|  |       v-for="(variable, key) in processedParams" | ||||||
|  |       :key="key" | ||||||
|  |       class="flex items-center w-full gap-2" | ||||||
|  |     > | ||||||
|  |       <span | ||||||
|  |         class="flex items-center h-8 text-sm min-w-6 ltr:text-left rtl:text-right text-n-slate-10" | ||||||
|  |       > | ||||||
|  |         {{ key }} | ||||||
|  |       </span> | ||||||
|  |       <Input | ||||||
|  |         v-model="processedParams[key]" | ||||||
|  |         custom-input-class="!h-8 w-full !bg-transparent" | ||||||
|  |         class="w-full" | ||||||
|  |         :message-type="getFieldErrorType(key)" | ||||||
|  |       /> | ||||||
|  |     </div> | ||||||
|  |  | ||||||
|  |     <div class="flex items-end justify-between w-full gap-3 h-14"> | ||||||
|  |       <Button | ||||||
|  |         :label=" | ||||||
|  |           t( | ||||||
|  |             'COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.TEMPLATE_PARSER.BACK' | ||||||
|  |           ) | ||||||
|  |         " | ||||||
|  |         color="slate" | ||||||
|  |         variant="faded" | ||||||
|  |         class="w-full font-medium" | ||||||
|  |         @click="emit('back')" | ||||||
|  |       /> | ||||||
|  |       <Button | ||||||
|  |         :label=" | ||||||
|  |           t( | ||||||
|  |             'COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.TEMPLATE_PARSER.SEND_MESSAGE' | ||||||
|  |           ) | ||||||
|  |         " | ||||||
|  |         class="w-full font-medium" | ||||||
|  |         @click="sendMessage" | ||||||
|  |       /> | ||||||
|  |     </div> | ||||||
|  |   </div> | ||||||
|  | </template> | ||||||
| @@ -0,0 +1,92 @@ | |||||||
|  | <script setup> | ||||||
|  | import { ref } from 'vue'; | ||||||
|  | import { contacts, activeContact, emailInbox, currentUser } from './fixtures'; | ||||||
|  | import ComposeNewConversationForm from '../ComposeNewConversationForm.vue'; | ||||||
|  |  | ||||||
|  | const selectedContact = ref(activeContact); | ||||||
|  | const targetInbox = ref(emailInbox); | ||||||
|  |  | ||||||
|  | // Event handlers | ||||||
|  | const onSearchContacts = query => { | ||||||
|  |   console.log('Searching contacts:', query); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const onUpdateSelectedContact = contact => { | ||||||
|  |   console.log('Selected contact updated:', contact); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const onUpdateTargetInbox = inbox => { | ||||||
|  |   console.log('Target inbox updated:', inbox); | ||||||
|  |   targetInbox.value = inbox; | ||||||
|  |   console.log('Target inbox updated:', inbox); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const onClearSelectedContact = () => { | ||||||
|  |   console.log('Contact cleared'); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const onCreateConversation = payload => { | ||||||
|  |   console.log('Creating conversation:', payload); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const onDiscard = () => { | ||||||
|  |   console.log('Form discarded'); | ||||||
|  | }; | ||||||
|  | </script> | ||||||
|  |  | ||||||
|  | <template> | ||||||
|  |   <Story | ||||||
|  |     title="Components/Compose/ComposeNewConversationForm" | ||||||
|  |     :layout="{ type: 'grid', width: '800px' }" | ||||||
|  |   > | ||||||
|  |     <Variant title="With all props"> | ||||||
|  |       <div class="h-[600px] w-full relative"> | ||||||
|  |         <ComposeNewConversationForm | ||||||
|  |           :contacts="contacts" | ||||||
|  |           contact-id="" | ||||||
|  |           :is-loading="false" | ||||||
|  |           :current-user="currentUser" | ||||||
|  |           :selected-contact="selectedContact" | ||||||
|  |           :target-inbox="targetInbox" | ||||||
|  |           :is-creating-contact="false" | ||||||
|  |           is-fetching-inboxes | ||||||
|  |           is-direct-uploads-enabled | ||||||
|  |           :contact-conversations-ui-flags="{ isCreating: false }" | ||||||
|  |           :contacts-ui-flags="{ isFetching: false }" | ||||||
|  |           class="!top-0" | ||||||
|  |           @search-contacts="onSearchContacts" | ||||||
|  |           @update-selected-contact="onUpdateSelectedContact" | ||||||
|  |           @update-target-inbox="onUpdateTargetInbox" | ||||||
|  |           @clear-selected-contact="onClearSelectedContact" | ||||||
|  |           @create-conversation="onCreateConversation" | ||||||
|  |           @discard="onDiscard" | ||||||
|  |         /> | ||||||
|  |       </div> | ||||||
|  |     </Variant> | ||||||
|  |  | ||||||
|  |     <Variant title="With no target inbox"> | ||||||
|  |       <div class="h-[200px] w-full relative"> | ||||||
|  |         <ComposeNewConversationForm | ||||||
|  |           :contacts="contacts" | ||||||
|  |           contact-id="" | ||||||
|  |           :is-loading="false" | ||||||
|  |           :current-user="currentUser" | ||||||
|  |           :selected-contact="{ ...selectedContact, contactInboxes: [] }" | ||||||
|  |           :target-inbox="null" | ||||||
|  |           :is-creating-contact="false" | ||||||
|  |           :is-fetching-inboxes="false" | ||||||
|  |           is-direct-uploads-enabled | ||||||
|  |           :contact-conversations-ui-flags="{ isCreating: false }" | ||||||
|  |           :contacts-ui-flags="{ isFetching: false }" | ||||||
|  |           class="!top-0" | ||||||
|  |           @search-contacts="onSearchContacts" | ||||||
|  |           @update-selected-contact="onUpdateSelectedContact" | ||||||
|  |           @update-target-inbox="onUpdateTargetInbox" | ||||||
|  |           @clear-selected-contact="onClearSelectedContact" | ||||||
|  |           @create-conversation="onCreateConversation" | ||||||
|  |           @discard="onDiscard" | ||||||
|  |         /> | ||||||
|  |       </div> | ||||||
|  |     </Variant> | ||||||
|  |   </Story> | ||||||
|  | </template> | ||||||
| @@ -0,0 +1,168 @@ | |||||||
|  | export const contacts = [ | ||||||
|  |   { | ||||||
|  |     additionalAttributes: { | ||||||
|  |       city: 'kerala', | ||||||
|  |       country: 'India', | ||||||
|  |       description: 'Curious about the web. ', | ||||||
|  |       companyName: 'Chatwoot', | ||||||
|  |       countryCode: '', | ||||||
|  |       socialProfiles: { | ||||||
|  |         github: 'abozler', | ||||||
|  |         twitter: 'ozler', | ||||||
|  |         facebook: 'abozler', | ||||||
|  |         linkedin: 'abozler', | ||||||
|  |         instagram: 'ozler', | ||||||
|  |       }, | ||||||
|  |     }, | ||||||
|  |     availabilityStatus: 'offline', | ||||||
|  |     email: 'ozler@chatwoot.com', | ||||||
|  |     id: 29, | ||||||
|  |     name: 'Abraham Ozlers', | ||||||
|  |     phoneNumber: '+246232222222', | ||||||
|  |     identifier: null, | ||||||
|  |     thumbnail: | ||||||
|  |       'https://sivin-tunnel.chatwoot.dev/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBc0FCIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--c20b627b384f5981112e949b8414cd4d3e5912ee/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdCem9MWm05eWJXRjBTU0lJY0c1bkJqb0dSVlE2RTNKbGMybDZaVjkwYjE5bWFXeHNXd2RwQWZvdyIsImV4cCI6bnVsbCwicHVyIjoidmFyaWF0aW9uIn19--ebe60765d222d11ade39165eae49cc4b2de18d89/Avatar%201.20.41%E2%80%AFAM.png', | ||||||
|  |     customAttributes: { | ||||||
|  |       dateContact: '2024-02-01T00:00:00.000Z', | ||||||
|  |       linkContact: 'https://staging.chatwoot.com/app/accounts/3/contacts-new', | ||||||
|  |       listContact: 'Not spam', | ||||||
|  |       numberContact: '12', | ||||||
|  |     }, | ||||||
|  |     lastActivityAt: 1712127410, | ||||||
|  |     createdAt: 1712127389, | ||||||
|  |   }, | ||||||
|  | ]; | ||||||
|  |  | ||||||
|  | export const activeContact = { | ||||||
|  |   email: 'ozler@chatwoot.com', | ||||||
|  |   id: 29, | ||||||
|  |   label: 'Abraham Ozlers (ozler@chatwoot.com)', | ||||||
|  |   name: 'Abraham Ozlers', | ||||||
|  |   thumbnail: { | ||||||
|  |     name: 'Abraham Ozlers', | ||||||
|  |     src: 'https://sivin-tunnel.chatwoot.dev/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBc0FCIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--c20b627b384f5981112e949b8414cd4d3e5912ee/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdCem9MWm05eWJXRjBTU0lJY0c1bkJqb0dSVlE2RTNKbGMybDZaVjkwYjE5bWFXeHNXd2RwQWZvdyIsImV4cCI6bnVsbCwicHVyIjoidmFyaWF0aW9uIn19--ebe60765d222d11ade39165eae49cc4b2de18d89/Avatar%201.20.41%E2%80%AFAM.png', | ||||||
|  |   }, | ||||||
|  |   contactInboxes: [ | ||||||
|  |     { | ||||||
|  |       id: 7, | ||||||
|  |       label: 'PaperLayer Email (testba@paperlayer.test)', | ||||||
|  |       name: 'PaperLayer Email', | ||||||
|  |       email: 'testba@paperlayer.test', | ||||||
|  |       channelType: 'Channel::Email', | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       id: 8, | ||||||
|  |       label: 'PaperLayer WhatsApp', | ||||||
|  |       name: 'PaperLayer WhatsApp', | ||||||
|  |       sourceId: '123456', | ||||||
|  |       phoneNumber: '+1223233434', | ||||||
|  |       channelType: 'Channel::Whatsapp', | ||||||
|  |       messageTemplates: [ | ||||||
|  |         { | ||||||
|  |           id: '1', | ||||||
|  |           name: 'shipment_confirmation', | ||||||
|  |           status: 'APPROVED', | ||||||
|  |           category: 'UTILITY', | ||||||
|  |           language: 'en_US', | ||||||
|  |           components: [ | ||||||
|  |             { | ||||||
|  |               text: '{{1}}, great news! Your order {{2}} has shipped.\n\nTracking #: {{3}}\nEstimated delivery: {{4}}\n\nWe will provide updates until delivery.', | ||||||
|  |               type: 'BODY', | ||||||
|  |               example: { | ||||||
|  |                 bodyText: [['John', '#12345', 'ZK4539O2311J', 'Jan 1, 2024']], | ||||||
|  |               }, | ||||||
|  |             }, | ||||||
|  |             { | ||||||
|  |               type: 'BUTTONS', | ||||||
|  |               buttons: [ | ||||||
|  |                 { | ||||||
|  |                   url: 'https://www.example.com/', | ||||||
|  |                   text: 'Track shipment', | ||||||
|  |                   type: 'URL', | ||||||
|  |                 }, | ||||||
|  |               ], | ||||||
|  |             }, | ||||||
|  |           ], | ||||||
|  |           parameterFormat: 'POSITIONAL', | ||||||
|  |           libraryTemplateName: 'shipment_confirmation_2', | ||||||
|  |         }, | ||||||
|  |         { | ||||||
|  |           id: '2', | ||||||
|  |           name: 'otp_test', | ||||||
|  |           status: 'APPROVED', | ||||||
|  |           category: 'AUTHENTICATION', | ||||||
|  |           language: 'en_US', | ||||||
|  |           components: [ | ||||||
|  |             { | ||||||
|  |               text: 'Use code *{{1}}* to authorize your transaction.', | ||||||
|  |               type: 'BODY', | ||||||
|  |               example: { | ||||||
|  |                 bodyText: [['123456']], | ||||||
|  |               }, | ||||||
|  |             }, | ||||||
|  |             { | ||||||
|  |               type: 'BUTTONS', | ||||||
|  |               buttons: [ | ||||||
|  |                 { | ||||||
|  |                   url: 'https://www.example.com/otp/code/?otp_type=ZERO_TAP&cta_display_name=Autofill&package_name=com.app&signature_hash=weew&code=otp{{1}}', | ||||||
|  |                   text: 'Copy code', | ||||||
|  |                   type: 'URL', | ||||||
|  |                   example: [ | ||||||
|  |                     'https://www.example.com/otp/code/?otp_type=ZERO_TAP&cta_display_name=Autofill&package_name=com.app&signature_hash=weew&code=otp123456', | ||||||
|  |                   ], | ||||||
|  |                 }, | ||||||
|  |               ], | ||||||
|  |             }, | ||||||
|  |           ], | ||||||
|  |           parameterFormat: 'POSITIONAL', | ||||||
|  |           libraryTemplateName: 'verify_transaction_1', | ||||||
|  |           messageSendTtlSeconds: 900, | ||||||
|  |         }, | ||||||
|  |         { | ||||||
|  |           id: '3', | ||||||
|  |           name: 'hello_world', | ||||||
|  |           status: 'APPROVED', | ||||||
|  |           category: 'UTILITY', | ||||||
|  |           language: 'en_US', | ||||||
|  |           components: [ | ||||||
|  |             { | ||||||
|  |               text: 'Hello World', | ||||||
|  |               type: 'HEADER', | ||||||
|  |               format: 'TEXT', | ||||||
|  |             }, | ||||||
|  |             { | ||||||
|  |               text: 'Welcome and congratulations!! This message demonstrates your ability to send a WhatsApp message notification from the Cloud API, hosted by Meta. Thank you for taking the time to test with us.', | ||||||
|  |               type: 'BODY', | ||||||
|  |             }, | ||||||
|  |             { | ||||||
|  |               text: 'WhatsApp Business Platform sample message', | ||||||
|  |               type: 'FOOTER', | ||||||
|  |             }, | ||||||
|  |           ], | ||||||
|  |           parameterFormat: 'POSITIONAL', | ||||||
|  |         }, | ||||||
|  |       ], | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       id: 9, | ||||||
|  |       label: 'PaperLayer API', | ||||||
|  |       name: 'PaperLayer API', | ||||||
|  |       email: '', | ||||||
|  |       channelType: 'Channel::Api', | ||||||
|  |     }, | ||||||
|  |   ], | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export const emailInbox = { | ||||||
|  |   id: 7, | ||||||
|  |   label: 'PaperLayer Email (testba@paperlayer.test)', | ||||||
|  |   name: 'PaperLayer Email', | ||||||
|  |   email: 'testba@paperlayer.test', | ||||||
|  |   channelType: 'Channel::Email', | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export const currentUser = { | ||||||
|  |   id: 1, | ||||||
|  |   name: 'John Doe', | ||||||
|  |   email: 'john@example.com', | ||||||
|  | }; | ||||||
| @@ -0,0 +1,118 @@ | |||||||
|  | import { INBOX_TYPES } from 'dashboard/helper/inbox'; | ||||||
|  |  | ||||||
|  | export const convertChannelTypeToLabel = channelType => { | ||||||
|  |   const [, type] = channelType.split('::'); | ||||||
|  |   return type ? type.charAt(0).toUpperCase() + type.slice(1) : channelType; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export const generateLabelForContactableInboxesList = ({ | ||||||
|  |   name, | ||||||
|  |   email, | ||||||
|  |   channelType, | ||||||
|  |   phoneNumber, | ||||||
|  | }) => { | ||||||
|  |   if (channelType === INBOX_TYPES.EMAIL) { | ||||||
|  |     return `${name} (${email})`; | ||||||
|  |   } | ||||||
|  |   if ( | ||||||
|  |     channelType === INBOX_TYPES.TWILIO || | ||||||
|  |     channelType === INBOX_TYPES.WHATSAPP | ||||||
|  |   ) { | ||||||
|  |     return `${name} (${phoneNumber})`; | ||||||
|  |   } | ||||||
|  |   return `${name} (${convertChannelTypeToLabel(channelType)})`; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export const buildContactableInboxesList = contactInboxes => { | ||||||
|  |   if (!contactInboxes) return []; | ||||||
|  |   return contactInboxes.map( | ||||||
|  |     ({ name, id, email, channelType, phoneNumber, ...rest }) => ({ | ||||||
|  |       id, | ||||||
|  |       label: generateLabelForContactableInboxesList({ | ||||||
|  |         name, | ||||||
|  |         email, | ||||||
|  |         channelType, | ||||||
|  |         phoneNumber, | ||||||
|  |       }), | ||||||
|  |       action: 'inbox', | ||||||
|  |       value: id, | ||||||
|  |       name, | ||||||
|  |       email, | ||||||
|  |       phoneNumber, | ||||||
|  |       channelType, | ||||||
|  |       ...rest, | ||||||
|  |     }) | ||||||
|  |   ); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export const prepareAttachmentPayload = ( | ||||||
|  |   attachedFiles, | ||||||
|  |   directUploadsEnabled | ||||||
|  | ) => { | ||||||
|  |   const files = []; | ||||||
|  |   attachedFiles.forEach(attachment => { | ||||||
|  |     if (directUploadsEnabled) { | ||||||
|  |       files.push(attachment.blobSignedId); | ||||||
|  |     } else { | ||||||
|  |       files.push(attachment.resource.file); | ||||||
|  |     } | ||||||
|  |   }); | ||||||
|  |   return files; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export const prepareNewMessagePayload = ({ | ||||||
|  |   targetInbox, | ||||||
|  |   selectedContact, | ||||||
|  |   message, | ||||||
|  |   subject, | ||||||
|  |   ccEmails, | ||||||
|  |   bccEmails, | ||||||
|  |   currentUser, | ||||||
|  |   attachedFiles = [], | ||||||
|  |   directUploadsEnabled = false, | ||||||
|  | }) => { | ||||||
|  |   const payload = { | ||||||
|  |     inboxId: targetInbox.id, | ||||||
|  |     sourceId: targetInbox.sourceId, | ||||||
|  |     contactId: Number(selectedContact.id), | ||||||
|  |     message: { content: message }, | ||||||
|  |     assigneeId: currentUser.id, | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   if (attachedFiles?.length) { | ||||||
|  |     payload.files = prepareAttachmentPayload( | ||||||
|  |       attachedFiles, | ||||||
|  |       directUploadsEnabled | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   if (subject) { | ||||||
|  |     payload.mailSubject = subject; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   if (ccEmails) { | ||||||
|  |     payload.message.cc_emails = ccEmails; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   if (bccEmails) { | ||||||
|  |     payload.message.bcc_emails = bccEmails; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   return payload; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export const prepareWhatsAppMessagePayload = ({ | ||||||
|  |   targetInbox, | ||||||
|  |   selectedContact, | ||||||
|  |   message, | ||||||
|  |   templateParams, | ||||||
|  |   currentUser, | ||||||
|  | }) => { | ||||||
|  |   return { | ||||||
|  |     inboxId: targetInbox.id, | ||||||
|  |     sourceId: targetInbox.sourceId, | ||||||
|  |     contactId: selectedContact.id, | ||||||
|  |     message: { content: message, template_params: templateParams }, | ||||||
|  |     assigneeId: currentUser.id, | ||||||
|  |   }; | ||||||
|  | }; | ||||||
| @@ -0,0 +1,171 @@ | |||||||
|  | import { describe, it, expect } from 'vitest'; | ||||||
|  | import { INBOX_TYPES } from 'dashboard/helper/inbox'; | ||||||
|  | import * as helpers from '../composeConversationHelper'; | ||||||
|  |  | ||||||
|  | describe('composeConversationHelper', () => { | ||||||
|  |   describe('convertChannelTypeToLabel', () => { | ||||||
|  |     it('converts channel type with namespace to capitalized label', () => { | ||||||
|  |       expect(helpers.convertChannelTypeToLabel('Channel::Email')).toBe('Email'); | ||||||
|  |       expect(helpers.convertChannelTypeToLabel('Channel::Whatsapp')).toBe( | ||||||
|  |         'Whatsapp' | ||||||
|  |       ); | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     it('returns original value if no namespace found', () => { | ||||||
|  |       expect(helpers.convertChannelTypeToLabel('email')).toBe('email'); | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   describe('generateLabelForContactableInboxesList', () => { | ||||||
|  |     const contact = { | ||||||
|  |       name: 'John Doe', | ||||||
|  |       email: 'john@example.com', | ||||||
|  |       phoneNumber: '+1234567890', | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  |     it('generates label for email inbox', () => { | ||||||
|  |       expect( | ||||||
|  |         helpers.generateLabelForContactableInboxesList({ | ||||||
|  |           ...contact, | ||||||
|  |           channelType: INBOX_TYPES.EMAIL, | ||||||
|  |         }) | ||||||
|  |       ).toBe('John Doe (john@example.com)'); | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     it('generates label for twilio inbox', () => { | ||||||
|  |       expect( | ||||||
|  |         helpers.generateLabelForContactableInboxesList({ | ||||||
|  |           ...contact, | ||||||
|  |           channelType: INBOX_TYPES.TWILIO, | ||||||
|  |         }) | ||||||
|  |       ).toBe('John Doe (+1234567890)'); | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     it('generates label for whatsapp inbox', () => { | ||||||
|  |       expect( | ||||||
|  |         helpers.generateLabelForContactableInboxesList({ | ||||||
|  |           ...contact, | ||||||
|  |           channelType: INBOX_TYPES.WHATSAPP, | ||||||
|  |         }) | ||||||
|  |       ).toBe('John Doe (+1234567890)'); | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     it('generates label for other inbox types', () => { | ||||||
|  |       expect( | ||||||
|  |         helpers.generateLabelForContactableInboxesList({ | ||||||
|  |           ...contact, | ||||||
|  |           channelType: 'Channel::Api', | ||||||
|  |         }) | ||||||
|  |       ).toBe('John Doe (Api)'); | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   describe('buildContactableInboxesList', () => { | ||||||
|  |     it('returns empty array if no contact inboxes', () => { | ||||||
|  |       expect(helpers.buildContactableInboxesList(null)).toEqual([]); | ||||||
|  |       expect(helpers.buildContactableInboxesList(undefined)).toEqual([]); | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     it('builds list of contactable inboxes with correct format', () => { | ||||||
|  |       const inboxes = [ | ||||||
|  |         { | ||||||
|  |           id: 1, | ||||||
|  |           name: 'Email Inbox', | ||||||
|  |           email: 'support@example.com', | ||||||
|  |           channelType: INBOX_TYPES.EMAIL, | ||||||
|  |           phoneNumber: null, | ||||||
|  |         }, | ||||||
|  |       ]; | ||||||
|  |  | ||||||
|  |       const result = helpers.buildContactableInboxesList(inboxes); | ||||||
|  |       expect(result[0]).toMatchObject({ | ||||||
|  |         id: 1, | ||||||
|  |         label: 'Email Inbox (support@example.com)', | ||||||
|  |         action: 'inbox', | ||||||
|  |         value: 1, | ||||||
|  |         name: 'Email Inbox', | ||||||
|  |         email: 'support@example.com', | ||||||
|  |         channelType: INBOX_TYPES.EMAIL, | ||||||
|  |       }); | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   describe('prepareAttachmentPayload', () => { | ||||||
|  |     it('prepares direct upload files', () => { | ||||||
|  |       const files = [{ blobSignedId: 'signed1' }]; | ||||||
|  |       expect(helpers.prepareAttachmentPayload(files, true)).toEqual([ | ||||||
|  |         'signed1', | ||||||
|  |       ]); | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     it('prepares regular files', () => { | ||||||
|  |       const files = [{ resource: { file: 'file1' } }]; | ||||||
|  |       expect(helpers.prepareAttachmentPayload(files, false)).toEqual(['file1']); | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   describe('prepareNewMessagePayload', () => { | ||||||
|  |     const baseParams = { | ||||||
|  |       targetInbox: { id: 1, sourceId: 'source1' }, | ||||||
|  |       selectedContact: { id: '2' }, | ||||||
|  |       message: 'Hello', | ||||||
|  |       currentUser: { id: 3 }, | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  |     it('prepares basic message payload', () => { | ||||||
|  |       const result = helpers.prepareNewMessagePayload(baseParams); | ||||||
|  |       expect(result).toEqual({ | ||||||
|  |         inboxId: 1, | ||||||
|  |         sourceId: 'source1', | ||||||
|  |         contactId: 2, | ||||||
|  |         message: { content: 'Hello' }, | ||||||
|  |         assigneeId: 3, | ||||||
|  |       }); | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     it('includes optional fields when provided', () => { | ||||||
|  |       const result = helpers.prepareNewMessagePayload({ | ||||||
|  |         ...baseParams, | ||||||
|  |         subject: 'Test', | ||||||
|  |         ccEmails: 'cc@test.com', | ||||||
|  |         bccEmails: 'bcc@test.com', | ||||||
|  |         attachedFiles: [{ blobSignedId: 'file1' }], | ||||||
|  |         directUploadsEnabled: true, | ||||||
|  |       }); | ||||||
|  |  | ||||||
|  |       expect(result).toMatchObject({ | ||||||
|  |         mailSubject: 'Test', | ||||||
|  |         message: { | ||||||
|  |           content: 'Hello', | ||||||
|  |           cc_emails: 'cc@test.com', | ||||||
|  |           bcc_emails: 'bcc@test.com', | ||||||
|  |         }, | ||||||
|  |         files: ['file1'], | ||||||
|  |       }); | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   describe('prepareWhatsAppMessagePayload', () => { | ||||||
|  |     it('prepares whatsapp message payload', () => { | ||||||
|  |       const params = { | ||||||
|  |         targetInbox: { id: 1, sourceId: 'source1' }, | ||||||
|  |         selectedContact: { id: 2 }, | ||||||
|  |         message: 'Hello', | ||||||
|  |         templateParams: { param1: 'value1' }, | ||||||
|  |         currentUser: { id: 3 }, | ||||||
|  |       }; | ||||||
|  |  | ||||||
|  |       const result = helpers.prepareWhatsAppMessagePayload(params); | ||||||
|  |       expect(result).toEqual({ | ||||||
|  |         inboxId: 1, | ||||||
|  |         sourceId: 'source1', | ||||||
|  |         contactId: 2, | ||||||
|  |         message: { | ||||||
|  |           content: 'Hello', | ||||||
|  |           template_params: { param1: 'value1' }, | ||||||
|  |         }, | ||||||
|  |         assigneeId: 3, | ||||||
|  |       }); | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
|  | }); | ||||||
| @@ -25,6 +25,10 @@ const props = defineProps({ | |||||||
|     type: String, |     type: String, | ||||||
|     default: '', |     default: '', | ||||||
|   }, |   }, | ||||||
|  |   isSearching: { | ||||||
|  |     type: Boolean, | ||||||
|  |     default: false, | ||||||
|  |   }, | ||||||
| }); | }); | ||||||
|  |  | ||||||
| const emit = defineEmits(['action']); | const emit = defineEmits(['action']); | ||||||
| @@ -81,7 +85,6 @@ onMounted(() => { | |||||||
|       }" |       }" | ||||||
|       :disabled="item.disabled" |       :disabled="item.disabled" | ||||||
|       @click="handleAction(item)" |       @click="handleAction(item)" | ||||||
|       @keydown.enter="handleAction(item)" |  | ||||||
|     > |     > | ||||||
|       <slot name="thumbnail" :item="item"> |       <slot name="thumbnail" :item="item"> | ||||||
|         <Avatar |         <Avatar | ||||||
| @@ -102,7 +105,11 @@ onMounted(() => { | |||||||
|       v-if="filteredMenuItems.length === 0" |       v-if="filteredMenuItems.length === 0" | ||||||
|       class="text-sm text-n-slate-11 px-2 py-1.5" |       class="text-sm text-n-slate-11 px-2 py-1.5" | ||||||
|     > |     > | ||||||
|       {{ t('DROPDOWN_MENU.EMPTY_STATE') }} |       {{ | ||||||
|  |         isSearching | ||||||
|  |           ? t('DROPDOWN_MENU.SEARCHING') | ||||||
|  |           : t('DROPDOWN_MENU.EMPTY_STATE') | ||||||
|  |       }} | ||||||
|     </div> |     </div> | ||||||
|   </div> |   </div> | ||||||
| </template> | </template> | ||||||
|   | |||||||
| @@ -1,9 +1,7 @@ | |||||||
| <script setup> | <script setup> | ||||||
| defineProps({ | import { ref, onMounted, nextTick } from 'vue'; | ||||||
|   modelValue: { |  | ||||||
|     type: [String, Number], | const props = defineProps({ | ||||||
|     default: '', |  | ||||||
|   }, |  | ||||||
|   type: { |   type: { | ||||||
|     type: String, |     type: String, | ||||||
|     default: 'text', |     default: 'text', | ||||||
| @@ -32,13 +30,49 @@ defineProps({ | |||||||
|     type: Boolean, |     type: Boolean, | ||||||
|     default: false, |     default: false, | ||||||
|   }, |   }, | ||||||
|  |   focusOnMount: { | ||||||
|  |     type: Boolean, | ||||||
|  |     default: false, | ||||||
|  |   }, | ||||||
| }); | }); | ||||||
|  |  | ||||||
| const emit = defineEmits(['update:modelValue', 'enterPress']); | const emit = defineEmits(['enterPress', 'input', 'blur', 'focus']); | ||||||
|  |  | ||||||
|  | const modelValue = defineModel({ | ||||||
|  |   type: [String, Number], | ||||||
|  |   default: '', | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const inlineInputRef = ref(null); | ||||||
|  |  | ||||||
| const onEnterPress = () => { | const onEnterPress = () => { | ||||||
|   emit('enterPress'); |   emit('enterPress'); | ||||||
| }; | }; | ||||||
|  |  | ||||||
|  | const handleInput = event => { | ||||||
|  |   emit('input', event.target.value); | ||||||
|  |   modelValue.value = event.target.value; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const handleBlur = event => { | ||||||
|  |   emit('blur', event.target.value); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const handleFocus = event => { | ||||||
|  |   emit('focus', event.target.value); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | onMounted(() => { | ||||||
|  |   nextTick(() => { | ||||||
|  |     if (props.focusOnMount) { | ||||||
|  |       inlineInputRef.value?.focus(); | ||||||
|  |     } | ||||||
|  |   }); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | defineExpose({ | ||||||
|  |   focus: () => inlineInputRef.value?.focus(), | ||||||
|  | }); | ||||||
| </script> | </script> | ||||||
|  |  | ||||||
| <template> | <template> | ||||||
| @@ -49,7 +83,7 @@ const onEnterPress = () => { | |||||||
|       v-if="label" |       v-if="label" | ||||||
|       :for="id" |       :for="id" | ||||||
|       :class="customLabelClass" |       :class="customLabelClass" | ||||||
|       class="mb-0.5 text-sm font-medium text-gray-900 dark:text-gray-50" |       class="mb-0.5 text-sm font-medium text-n-slate-11" | ||||||
|     > |     > | ||||||
|       {{ label }} |       {{ label }} | ||||||
|     </label> |     </label> | ||||||
| @@ -57,13 +91,16 @@ const onEnterPress = () => { | |||||||
|     <slot name="prefix" /> |     <slot name="prefix" /> | ||||||
|     <input |     <input | ||||||
|       :id="id" |       :id="id" | ||||||
|       :value="modelValue" |       ref="inlineInputRef" | ||||||
|  |       v-model="modelValue" | ||||||
|       :type="type" |       :type="type" | ||||||
|       :placeholder="placeholder" |       :placeholder="placeholder" | ||||||
|       :disabled="disabled" |       :disabled="disabled" | ||||||
|       :class="customInputClass" |       :class="customInputClass" | ||||||
|       class="flex w-full reset-base text-sm h-6 !mb-0 border-0 rounded-lg bg-transparent dark:bg-transparent placeholder:text-slate-200 dark:placeholder:text-slate-500 disabled:cursor-not-allowed disabled:opacity-50 text-slate-900 dark:text-white transition-all duration-500 ease-in-out" |       class="flex w-full reset-base text-sm h-6 !mb-0 border-0 rounded-none bg-transparent dark:bg-transparent placeholder:text-n-slate-10 dark:placeholder:text-n-slate-10 disabled:cursor-not-allowed disabled:opacity-50 text-n-slate-12 dark:text-n-slate-12 transition-all duration-500 ease-in-out" | ||||||
|       @input="$emit('update:modelValue', $event.target.value)" |       @input="handleInput" | ||||||
|  |       @focus="handleFocus" | ||||||
|  |       @blur="handleBlur" | ||||||
|       @keydown.enter.prevent="onEnterPress" |       @keydown.enter.prevent="onEnterPress" | ||||||
|     /> |     /> | ||||||
|   </div> |   </div> | ||||||
|   | |||||||
| @@ -1,51 +1,168 @@ | |||||||
| <script setup> | <script setup> | ||||||
| import { ref, computed, watch } from 'vue'; | import { ref, computed, watch } from 'vue'; | ||||||
| import { OnClickOutside } from '@vueuse/components'; | import { vOnClickOutside } from '@vueuse/components'; | ||||||
|  | import { email } from '@vuelidate/validators'; | ||||||
|  | import { useVuelidate } from '@vuelidate/core'; | ||||||
|  |  | ||||||
| import InlineInput from 'dashboard/components-next/inline-input/InlineInput.vue'; | import InlineInput from 'dashboard/components-next/inline-input/InlineInput.vue'; | ||||||
|  | import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue'; | ||||||
|  |  | ||||||
| const props = defineProps({ | const props = defineProps({ | ||||||
|   modelValue: { |   placeholder: { type: String, default: '' }, | ||||||
|  |   disabled: { type: Boolean, default: false }, | ||||||
|  |   type: { type: String, default: 'text' }, | ||||||
|  |   isLoading: { type: Boolean, default: false }, | ||||||
|  |   menuItems: { | ||||||
|     type: Array, |     type: Array, | ||||||
|     default: () => [], |     default: () => [], | ||||||
|  |     validator: value => | ||||||
|  |       value.every( | ||||||
|  |         ({ action, value: tagValue, label }) => action && tagValue && label | ||||||
|  |       ), | ||||||
|   }, |   }, | ||||||
|   placeholder: { |   showDropdown: { type: Boolean, default: false }, | ||||||
|  |   mode: { | ||||||
|     type: String, |     type: String, | ||||||
|     default: '', |     default: 'multiple', | ||||||
|  |     validator: value => ['single', 'multiple'].includes(value), | ||||||
|   }, |   }, | ||||||
|  |   focusOnMount: { type: Boolean, default: false }, | ||||||
|  |   allowCreate: { type: Boolean, default: false }, | ||||||
| }); | }); | ||||||
|  |  | ||||||
| const emit = defineEmits(['update:modelValue']); | const emit = defineEmits([ | ||||||
|  |   'update:modelValue', | ||||||
|  |   'input', | ||||||
|  |   'blur', | ||||||
|  |   'focus', | ||||||
|  |   'onClickOutside', | ||||||
|  |   'add', | ||||||
|  |   'remove', | ||||||
|  | ]); | ||||||
|  |  | ||||||
|  | const modelValue = defineModel({ | ||||||
|  |   type: Array, | ||||||
|  |   default: () => [], | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const MODE = { | ||||||
|  |   SINGLE: 'single', | ||||||
|  |   MULTIPLE: 'multiple', | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const tagInputRef = ref(null); | ||||||
| const tags = ref(props.modelValue); | const tags = ref(props.modelValue); | ||||||
| const newTag = ref(''); | const newTag = ref(''); | ||||||
| const isFocused = ref(false); | const isFocused = ref(true); | ||||||
|  |  | ||||||
| const showInput = computed(() => isFocused.value || tags.value.length === 0); | const rules = computed(() => ({ | ||||||
|  |   newTag: props.type === 'email' ? { email } : {}, | ||||||
|  | })); | ||||||
|  |  | ||||||
| const addTag = () => { | const v$ = useVuelidate(rules, { newTag }); | ||||||
|   if (newTag.value.trim()) { | const isNewTagInValidType = computed(() => v$.value.$invalid); | ||||||
|     tags.value.push(newTag.value.trim()); |  | ||||||
|     newTag.value = ''; | const showInput = computed(() => | ||||||
|     emit('update:modelValue', tags.value); |   props.mode === MODE.SINGLE | ||||||
|  |     ? isFocused.value && !tags.value.length | ||||||
|  |     : isFocused.value || !tags.value.length | ||||||
|  | ); | ||||||
|  |  | ||||||
|  | const showDropdownMenu = computed(() => | ||||||
|  |   props.mode === MODE.SINGLE && tags.value.length >= 1 | ||||||
|  |     ? false | ||||||
|  |     : props.showDropdown | ||||||
|  | ); | ||||||
|  |  | ||||||
|  | const filteredMenuItems = computed(() => { | ||||||
|  |   if (props.mode === MODE.SINGLE && tags.value.length >= 1) return []; | ||||||
|  |  | ||||||
|  |   const availableMenuItems = props.menuItems.filter( | ||||||
|  |     item => !tags.value.includes(item.label) | ||||||
|  |   ); | ||||||
|  |  | ||||||
|  |   // Only show typed value as suggestion if: | ||||||
|  |   // 1. There's a value being typed | ||||||
|  |   // 2. The value isn't already in the tags | ||||||
|  |   // 3. There are no menu items available | ||||||
|  |   const trimmedNewTag = newTag.value.trim(); | ||||||
|  |   if ( | ||||||
|  |     trimmedNewTag && | ||||||
|  |     !tags.value.includes(trimmedNewTag) && | ||||||
|  |     !availableMenuItems.length && | ||||||
|  |     !props.isLoading | ||||||
|  |   ) { | ||||||
|  |     return [ | ||||||
|  |       { | ||||||
|  |         label: trimmedNewTag, | ||||||
|  |         value: trimmedNewTag, | ||||||
|  |         email: trimmedNewTag, | ||||||
|  |         thumbnail: { name: trimmedNewTag, src: '' }, | ||||||
|  |         action: 'create', | ||||||
|  |       }, | ||||||
|  |     ]; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   return availableMenuItems; | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const emitDataOnAdd = emailValue => { | ||||||
|  |   const matchingMenuItem = props.menuItems.find( | ||||||
|  |     item => item.email === emailValue | ||||||
|  |   ); | ||||||
|  |  | ||||||
|  |   return matchingMenuItem | ||||||
|  |     ? emit('add', { email: emailValue, ...matchingMenuItem }) | ||||||
|  |     : emit('add', { value: emailValue, action: 'create' }); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const addTag = async () => { | ||||||
|  |   const trimmedTag = newTag.value.trim(); | ||||||
|  |   if (!trimmedTag) return; | ||||||
|  |  | ||||||
|  |   if (props.mode === MODE.SINGLE && tags.value.length >= 1) { | ||||||
|  |     newTag.value = ''; | ||||||
|  |     return; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   if (props.type === 'email' || props.allowCreate) { | ||||||
|  |     if (!(await v$.value.$validate())) return; | ||||||
|  |     emitDataOnAdd(trimmedTag); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   tags.value.push(trimmedTag); | ||||||
|  |   newTag.value = ''; | ||||||
|  |   modelValue.value = tags.value; | ||||||
|  |   tagInputRef.value?.focus(); | ||||||
| }; | }; | ||||||
|  |  | ||||||
| const removeTag = index => { | const removeTag = index => { | ||||||
|   tags.value.splice(index, 1); |   tags.value.splice(index, 1); | ||||||
|   emit('update:modelValue', tags.value); |   modelValue.value = tags.value; | ||||||
|  |   emit('remove'); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const handleDropdownAction = async ({ email: emailAddress, ...rest }) => { | ||||||
|  |   if (props.mode === MODE.SINGLE && tags.value.length >= 1) return; | ||||||
|  |  | ||||||
|  |   if (props.type === 'email' && props.showDropdown) { | ||||||
|  |     newTag.value = emailAddress; | ||||||
|  |     if (!(await v$.value.$validate())) return; | ||||||
|  |     emit('add', { email: emailAddress, ...rest }); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   tags.value.push(emailAddress); | ||||||
|  |   newTag.value = ''; | ||||||
|  |   modelValue.value = tags.value; | ||||||
|  |   tagInputRef.value?.focus(); | ||||||
| }; | }; | ||||||
|  |  | ||||||
| const handleFocus = () => { | const handleFocus = () => { | ||||||
|  |   emit('focus'); | ||||||
|  |   tagInputRef.value?.focus(); | ||||||
|   isFocused.value = true; |   isFocused.value = true; | ||||||
| }; | }; | ||||||
|  |  | ||||||
| const handleClickOutside = () => { |  | ||||||
|   if (tags.value.length > 0) { |  | ||||||
|     isFocused.value = false; |  | ||||||
|   } |  | ||||||
| }; |  | ||||||
|  |  | ||||||
| const handleKeydown = event => { | const handleKeydown = event => { | ||||||
|   if (event.key === ',') { |   if (event.key === ',') { | ||||||
|     event.preventDefault(); |     event.preventDefault(); | ||||||
| @@ -53,43 +170,76 @@ const handleKeydown = event => { | |||||||
|   } |   } | ||||||
| }; | }; | ||||||
|  |  | ||||||
|  | const handleClickOutside = () => { | ||||||
|  |   if (tags.value.length) isFocused.value = false; | ||||||
|  |   emit('onClickOutside'); | ||||||
|  | }; | ||||||
|  |  | ||||||
| watch( | watch( | ||||||
|   () => props.modelValue, |   () => props.modelValue, | ||||||
|   newValue => { |   newValue => { | ||||||
|     tags.value = newValue; |     tags.value = newValue; | ||||||
|   } |   } | ||||||
| ); | ); | ||||||
|  |  | ||||||
|  | watch( | ||||||
|  |   () => newTag.value, | ||||||
|  |   async newValue => { | ||||||
|  |     if (props.type === 'email' && newValue.trim()?.length > 2) { | ||||||
|  |       await v$.value.$validate(); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | ); | ||||||
|  |  | ||||||
|  | const handleInput = e => emit('input', e); | ||||||
|  | const handleBlur = e => emit('blur', e); | ||||||
| </script> | </script> | ||||||
|  |  | ||||||
| <template> | <template> | ||||||
|   <OnClickOutside @trigger="handleClickOutside"> |   <div | ||||||
|  |     v-on-click-outside="() => handleClickOutside()" | ||||||
|  |     class="flex flex-wrap w-full gap-2 border border-transparent focus:outline-none" | ||||||
|  |     tabindex="0" | ||||||
|  |     @focus="handleFocus" | ||||||
|  |     @click="handleFocus" | ||||||
|  |   > | ||||||
|     <div |     <div | ||||||
|       class="flex flex-wrap w-full gap-2 border border-transparent focus:outline-none" |       v-for="(tag, index) in tags" | ||||||
|       tabindex="0" |       :key="index" | ||||||
|       @focus="handleFocus" |       class="flex items-center justify-center max-w-full gap-1 px-3 py-1 rounded-lg h-7 bg-n-alpha-2" | ||||||
|       @click="handleFocus" |  | ||||||
|     > |     > | ||||||
|       <div |       <span class="flex-grow min-w-0 text-sm truncate text-n-slate-12">{{ | ||||||
|         v-for="(tag, index) in tags" |         tag | ||||||
|         :key="index" |       }}</span> | ||||||
|         class="flex items-center justify-center max-w-full gap-1 px-3 py-1 rounded-lg h-7 bg-n-alpha-2" |       <span | ||||||
|       > |         class="flex-shrink-0 cursor-pointer i-lucide-x size-3.5 text-n-slate-11" | ||||||
|         <span class="flex-grow min-w-0 text-sm truncate text-n-slate-12"> |         @click.stop="removeTag(index)" | ||||||
|           {{ tag }} |  | ||||||
|         </span> |  | ||||||
|         <span |  | ||||||
|           class="flex-shrink-0 cursor-pointer i-lucide-x size-3.5 text-n-slate-11" |  | ||||||
|           @click.stop="removeTag(index)" |  | ||||||
|         /> |  | ||||||
|       </div> |  | ||||||
|       <InlineInput |  | ||||||
|         v-if="showInput" |  | ||||||
|         v-model="newTag" |  | ||||||
|         :placeholder="placeholder" |  | ||||||
|         custom-input-class="flex-grow" |  | ||||||
|         @enter-press="addTag" |  | ||||||
|         @keydown="handleKeydown" |  | ||||||
|       /> |       /> | ||||||
|     </div> |     </div> | ||||||
|   </OnClickOutside> |     <div class="relative flex items-center gap-2 flex-1 min-w-[200px] w-full"> | ||||||
|  |       <InlineInput | ||||||
|  |         v-if="showInput" | ||||||
|  |         ref="tagInputRef" | ||||||
|  |         v-model="newTag" | ||||||
|  |         :placeholder="placeholder" | ||||||
|  |         :type="type" | ||||||
|  |         :disabled="disabled" | ||||||
|  |         class="w-full" | ||||||
|  |         :focus-on-mount="focusOnMount" | ||||||
|  |         :custom-input-class="`w-full ${isNewTagInValidType ? '!text-n-ruby-9 dark:!text-n-ruby-9' : ''}`" | ||||||
|  |         @enter-press="addTag" | ||||||
|  |         @focus="handleFocus" | ||||||
|  |         @input="handleInput" | ||||||
|  |         @blur="handleBlur" | ||||||
|  |         @keydown="handleKeydown" | ||||||
|  |       /> | ||||||
|  |       <DropdownMenu | ||||||
|  |         v-if="showDropdownMenu" | ||||||
|  |         :menu-items="filteredMenuItems" | ||||||
|  |         :is-searching="isLoading" | ||||||
|  |         class="left-0 z-[100] top-8 overflow-y-auto max-h-60 w-[inherit] max-w-md dark:!outline-n-slate-5" | ||||||
|  |         @action="handleDropdownAction" | ||||||
|  |       /> | ||||||
|  |     </div> | ||||||
|  |   </div> | ||||||
| </template> | </template> | ||||||
|   | |||||||
| @@ -179,7 +179,7 @@ onMounted(() => { | |||||||
|         }" |         }" | ||||||
|         :disabled="disabled" |         :disabled="disabled" | ||||||
|         rows="1" |         rows="1" | ||||||
|         class="flex w-full reset-base text-sm p-0 !rounded-none !bg-transparent dark:!bg-transparent !border-0 !mb-0 placeholder:text-n-slate-11 dark:placeholder:text-n-slate-11 text-n-slate-12 dark:text-n-slate-12 disabled:cursor-not-allowed disabled:opacity-50 disabled:bg-slate-25 dark:disabled:bg-slate-900" |         class="flex w-full reset-base text-sm p-0 !rounded-none !bg-transparent dark:!bg-transparent !border-0 !mb-0 placeholder:text-n-slate-10 dark:placeholder:text-n-slate-10 text-n-slate-12 dark:text-n-slate-12 disabled:cursor-not-allowed disabled:opacity-50 disabled:bg-slate-25 dark:disabled:bg-slate-900" | ||||||
|         @input="handleInput" |         @input="handleInput" | ||||||
|         @focus="handleFocus" |         @focus="handleFocus" | ||||||
|         @blur="handleBlur" |         @blur="handleBlur" | ||||||
|   | |||||||
| @@ -653,5 +653,53 @@ | |||||||
|       "SEARCH_EMPTY_STATE_TITLE": "No contacts matches your search 🔍", |       "SEARCH_EMPTY_STATE_TITLE": "No contacts matches your search 🔍", | ||||||
|       "LIST_EMPTY_STATE_TITLE": "No contacts available in this view 📋" |       "LIST_EMPTY_STATE_TITLE": "No contacts available in this view 📋" | ||||||
|     } |     } | ||||||
|  |   }, | ||||||
|  |  | ||||||
|  |   "COMPOSE_NEW_CONVERSATION": { | ||||||
|  |     "CONTACT_SEARCH": { | ||||||
|  |       "ERROR_MESSAGE": "We couldn’t complete the search. Please try again." | ||||||
|  |     }, | ||||||
|  |     "FORM": { | ||||||
|  |       "GO_TO_CONVERSATION": "View", | ||||||
|  |       "SUCCESS_MESSAGE": "The message was sent successfully!", | ||||||
|  |       "ERROR_MESSAGE": "An error occurred while creating the conversation. Please try again later.", | ||||||
|  |       "NO_INBOX_ALERT": "There are no available inboxes to start a conversation with this contact.", | ||||||
|  |       "CONTACT_SELECTOR": { | ||||||
|  |         "LABEL": "To:", | ||||||
|  |         "TAG_INPUT_PLACEHOLDER": "Type an email address to search for the contact and press Enter", | ||||||
|  |         "CONTACT_CREATING": "Creating contact..." | ||||||
|  |       }, | ||||||
|  |       "INBOX_SELECTOR": { | ||||||
|  |         "LABEL": "Via:", | ||||||
|  |         "BUTTON": "Show inboxes" | ||||||
|  |       }, | ||||||
|  |       "EMAIL_OPTIONS": { | ||||||
|  |         "SUBJECT_LABEL": "Subject :", | ||||||
|  |         "SUBJECT_PLACEHOLDER": "Enter your email subject here", | ||||||
|  |         "CC_LABEL": "Cc:", | ||||||
|  |         "CC_PLACEHOLDER": "Type an email address to search for the contact and press Enter", | ||||||
|  |         "BCC_LABEL": "Bcc:", | ||||||
|  |         "BCC_PLACEHOLDER": "Type an email address to search for the contact and press Enter", | ||||||
|  |         "BCC_BUTTON": "Bcc" | ||||||
|  |       }, | ||||||
|  |       "MESSAGE_EDITOR": { | ||||||
|  |         "PLACEHOLDER": "Write your message here..." | ||||||
|  |       }, | ||||||
|  |       "WHATSAPP_OPTIONS": { | ||||||
|  |         "LABEL": "Select template", | ||||||
|  |         "SEARCH_PLACEHOLDER": "Search templates", | ||||||
|  |         "EMPTY_STATE": "No templates found", | ||||||
|  |         "TEMPLATE_PARSER": { | ||||||
|  |           "TEMPLATE_NAME": "WhatsApp template: {templateName}", | ||||||
|  |           "VARIABLES": "Variables", | ||||||
|  |           "BACK": "Go back", | ||||||
|  |           "SEND_MESSAGE": "Send message" | ||||||
|  |         } | ||||||
|  |       }, | ||||||
|  |       "ACTION_BUTTONS": { | ||||||
|  |         "DISCARD": "Discard", | ||||||
|  |         "SEND": "Send ({keyCode})" | ||||||
|  |       } | ||||||
|  |     } | ||||||
|   } |   } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -4,6 +4,7 @@ import i18nMessages from 'dashboard/i18n'; | |||||||
| import { createI18n } from 'vue-i18n'; | import { createI18n } from 'vue-i18n'; | ||||||
| import { vResizeObserver } from '@vueuse/components'; | import { vResizeObserver } from '@vueuse/components'; | ||||||
| import store from 'dashboard/store'; | import store from 'dashboard/store'; | ||||||
|  | import FloatingVue from 'floating-vue'; | ||||||
| import VueDOMPurifyHTML from 'vue-dompurify-html'; | import VueDOMPurifyHTML from 'vue-dompurify-html'; | ||||||
| import { domPurifyConfig } from 'shared/helpers/HTMLSanitizer.js'; | import { domPurifyConfig } from 'shared/helpers/HTMLSanitizer.js'; | ||||||
| import { directive as onClickaway } from 'vue3-click-away'; | import { directive as onClickaway } from 'vue3-click-away'; | ||||||
| @@ -17,6 +18,12 @@ const i18n = createI18n({ | |||||||
| export const setupVue3 = defineSetupVue3(({ app }) => { | export const setupVue3 = defineSetupVue3(({ app }) => { | ||||||
|   app.use(store); |   app.use(store); | ||||||
|   app.use(i18n); |   app.use(i18n); | ||||||
|  |   app.use(FloatingVue, { | ||||||
|  |     instantMove: true, | ||||||
|  |     arrowOverflow: false, | ||||||
|  |     disposeTimeout: 5000000, | ||||||
|  |   }); | ||||||
|  |  | ||||||
|   app.directive('resize', vResizeObserver); |   app.directive('resize', vResizeObserver); | ||||||
|   app.use(VueDOMPurifyHTML, domPurifyConfig); |   app.use(VueDOMPurifyHTML, domPurifyConfig); | ||||||
|   app.directive('on-clickaway', onClickaway); |   app.directive('on-clickaway', onClickaway); | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user
	 Sivin Varghese
					Sivin Varghese