feat(v4): Compose new conversation

This commit is contained in:
iamsivin
2024-11-15 02:06:20 +05:30
parent bf59285041
commit 9ea992fe45
22 changed files with 1544 additions and 62 deletions
@@ -44,8 +44,6 @@ const ROUTE_MAPPINGS = {
};
const onClickViewDetails = async () => {
await store.dispatch('contacts/show', { id: props.id });
const dynamicRouteName =
ROUTE_MAPPINGS[route.name] || 'contacts_dashboard_edit_index';
@@ -0,0 +1,87 @@
<script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
// import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useRoute } from 'vue-router';
import { useAlert } from 'dashboard/composables';
import camelcaseKeys from 'camelcase-keys';
import ContactAPI from 'dashboard/api/contacts';
import { debounce } from '@chatwoot/utils';
import ComposeNewConversation from 'dashboard/components-next/NewConversation/ComposeNewConversation.vue';
const route = useRoute();
const { t } = useI18n();
const contacts = ref([]);
const isSearching = ref(false);
const showComposeNewConversation = ref(false);
const contactId = computed(() => route.params.contactId || null);
const generateContactQuery = ({ query }) => {
return {
payload: [
{
attribute_key: 'email',
filter_operator: 'contains',
values: [query],
attribute_model: 'standard',
custom_attribute_type: '',
},
],
};
};
const onContactSearch = debounce(
async query => {
isSearching.value = true;
contacts.value = [];
try {
const {
data: { payload },
} = await ContactAPI.filter(
undefined,
'name',
generateContactQuery({ query })
);
contacts.value = camelcaseKeys(payload, { deep: true });
isSearching.value = false;
} catch (error) {
useAlert(t('CONTACTS_LAYOUT.SIDEBAR.MERGE.SEARCH_ERROR_MESSAGE'));
} finally {
isSearching.value = false;
}
},
300,
false
);
const toggle = () => {
showComposeNewConversation.value = !showComposeNewConversation.value;
};
const closeCompose = () => {
showComposeNewConversation.value = false;
};
</script>
<template>
<div class="relative">
<slot
name="trigger"
:is-open="showComposeNewConversation"
:toggle="toggle"
/>
<ComposeNewConversation
v-if="showComposeNewConversation"
:contacts="contacts"
:contact-id="contactId"
:is-loading="isSearching"
@search-contacts="onContactSearch"
@discard="closeCompose"
/>
</div>
</template>
@@ -43,7 +43,7 @@ defineExpose({ dialogRef });
<Button
:label="t('DIALOG.BUTTONS.CANCEL')"
variant="link"
class="h-10 hover:no-underline hover:text-n-brand"
class="h-10 hover:!no-underline hover:text-n-brand"
@click="closeDialog"
/>
<Button
@@ -9,6 +9,7 @@ import Icon from 'dashboard/components-next/icon/Icon.vue';
import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue';
import Breadcrumb from 'dashboard/components-next/breadcrumb/Breadcrumb.vue';
import ContactActions from 'dashboard/components-next/Contacts/ContactHeader/ContactActions.vue';
import ComposeConversation from 'dashboard/components-next/Contacts/ContactsForm/ComposeConversation.vue';
const props = defineProps({
searchValue: {
@@ -168,7 +169,11 @@ const updateCurrentPage = page => {
@more="emit('more')"
/>
<div v-if="!isDetailView" class="w-px h-4 bg-n-strong" />
<Button :label="buttonLabel" size="sm" @click="emit('message')" />
<ComposeConversation>
<template #trigger="{ toggle }">
<Button :label="buttonLabel" size="sm" @click="toggle" />
</template>
</ComposeConversation>
</div>
</div>
</div>
@@ -110,7 +110,7 @@ onMounted(() => {
custom-label-class="min-w-[120px]"
/>
</div>
<div class="flex justify-between w-full gap-2 py-2">
<div class="flex justify-between w-full gap-3 py-2">
<label
class="text-sm font-medium whitespace-nowrap min-w-[120px] text-slate-900 dark:text-slate-50"
>
@@ -0,0 +1,420 @@
<script setup>
import { reactive, watch, ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { ExceptionWithMessage } from 'shared/helpers/CustomErrors';
import camelcaseKeys from 'camelcase-keys';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
import {
appendSignature,
removeSignature,
} from 'dashboard/helper/editorHelper';
import ContactAPI from 'dashboard/api/contacts';
import ContactSelector from './components/ContactSelector.vue';
import InboxSelector from './components/InboxSelector.vue';
import EmailOptions from './components/EmailOptions.vue';
import MessageEditor from './components/MessageEditor.vue';
import ActionButtons from './components/ActionButtons.vue';
const props = defineProps({
contacts: {
type: Array,
default: () => [],
},
contactId: {
type: String,
default: null,
},
isLoading: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['searchContacts', 'discard', 'success']);
const store = useStore();
const { t } = useI18n();
const showContactsDropdown = ref(false);
const showInboxesDropdown = ref(false);
const showCcEmailsDropdown = ref(false);
const showBccEmailsDropdown = ref(false);
const isCreatingContact = ref(false);
const contactById = useMapGetter('contacts/getContactById');
const currentUser = useMapGetter('getCurrentUser');
const activeContact = computed(() => contactById.value(props.contactId));
const selectedContact = ref(null);
const targetInbox = ref(null);
const state = reactive({
message: '',
subject: '',
ccEmails: '',
bccEmails: '',
});
const showBccInput = ref(false);
const isEmailInbox = computed(() => {
return targetInbox.value?.channelType === INBOX_TYPES.EMAIL;
});
const isTwilioInbox = computed(() => {
return targetInbox.value?.channelType === INBOX_TYPES.TWILIO;
});
const isWhatsappInbox = computed(() => {
return targetInbox.value?.channelType === INBOX_TYPES.WHATSAPP;
});
const isWebWidgetInbox = computed(() => {
return targetInbox.value?.channelType === INBOX_TYPES.WEB;
});
const isApiInbox = computed(() => {
return targetInbox.value?.channelType === INBOX_TYPES.API;
});
const isEmailOrWebWidgetInbox = computed(() => {
return isEmailInbox.value || isWebWidgetInbox.value;
});
const whatsappMessageTemplates = computed(() => {
return targetInbox.value?.messageTemplates;
});
const inboxChannelType = computed(() => {
return targetInbox.value?.channelType || '';
});
const newMessagePayload = () => {
const payload = {
inboxId: targetInbox.value.id,
sourceId: targetInbox.value.sourceId,
contactId: Number(selectedContact.value.id),
message: { content: state.message },
assigneeId: currentUser.value.id,
};
// if (this.attachedFiles && this.attachedFiles.length) {
// payload.files = [];
// setAttachmentPayload(payload);
// }
if (state.subject) {
payload.mailSubject = state.subject;
}
if (state.ccEmails) {
payload.message.cc_emails = state.ccEmails;
}
if (state.bccEmails) {
payload.message.bcc_emails = state.bccEmails;
}
return payload;
};
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})`;
}
if (channelType === INBOX_TYPES.API) {
return `${name} (API)`;
}
return name;
};
const contactableInboxesList = computed(() => {
return selectedContact.value?.contactInboxes?.map(
({ name, id, email, channelType, phoneNumber, ...rest }) => ({
id,
label: generateLabelForContactableInboxesList({
name,
email,
channelType,
phoneNumber,
}),
action: 'inbox',
value: id,
name,
email,
phoneNumber,
channelType,
...rest,
})
);
});
const getCapitalizedNameFromEmail = email => {
const name = email.match(/^([^@]*)@/)?.[1] || email.split('@')[0];
return name.charAt(0).toUpperCase() + name.slice(1);
};
const handleContactSearch = value => {
emit('searchContacts', value);
};
const handleDropdownUpdate = (type, value) => {
if (type === 'cc') {
showCcEmailsDropdown.value = value;
} else if (type === 'bcc') {
showBccEmailsDropdown.value = value;
} else {
showContactsDropdown.value = value;
}
};
const toggleBccInput = () => {
showBccInput.value = !showBccInput.value;
};
const searchCcEmails = value => {
showCcEmailsDropdown.value = true;
emit('searchContacts', value);
};
const searchBccEmails = value => {
showBccEmailsDropdown.value = true;
emit('searchContacts', value);
};
const setSelectedContact = async ({ value, action, ...rest }) => {
try {
let contact;
if (action === 'create') {
isCreatingContact.value = true;
const payload = {
name: getCapitalizedNameFromEmail(value),
email: value,
};
try {
const {
data: {
payload: { contact: newContact },
},
} = await ContactAPI.create(payload);
contact = camelcaseKeys(newContact, {
deep: true,
});
} catch (error) {
isCreatingContact.value = false;
return;
}
selectedContact.value = contact;
isCreatingContact.value = false;
} else {
contact = rest;
selectedContact.value = contact;
}
showContactsDropdown.value = false;
// Only proceed with fetching inboxes if we have a contact
if (contact?.id) {
const {
data: { payload: inboxes = [] },
} = await ContactAPI.getContactableInboxes(contact.id);
const contactableInboxes = inboxes.map(inbox => ({
...inbox.inbox,
sourceId: inbox.source_id,
}));
selectedContact.value.contactInboxes = camelcaseKeys(contactableInboxes, {
deep: true,
});
showInboxesDropdown.value = true;
}
} catch (error) {
// console.error('Error in setSelectedContact:', error);
// Reset states in case of error
isCreatingContact.value = false;
showContactsDropdown.value = false;
}
};
const handleInboxAction = ({ value, action, ...rest }) => {
targetInbox.value = {
...rest,
};
showInboxesDropdown.value = false;
};
const clearSelectedContact = () => {
selectedContact.value = null;
targetInbox.value = null;
};
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 clearForm = () => {
state.message = '';
state.subject = '';
state.ccEmails = '';
state.bccEmails = '';
};
const createConversation = async ({ payload, isFromWhatsApp }) => {
try {
const data = await store.dispatch('contactConversations/create', {
params: payload,
isFromWhatsApp,
});
const action = {
type: 'link',
to: `/app/accounts/${data.account_id}/conversations/${data.id}`,
message: t('NEW_CONVERSATION.FORM.GO_TO_CONVERSATION'),
};
emit('success');
useAlert(t('NEW_CONVERSATION.FORM.SUCCESS_MESSAGE'), action);
} catch (error) {
if (error instanceof ExceptionWithMessage) {
useAlert(error.data);
} else {
useAlert(t('NEW_CONVERSATION.FORM.ERROR_MESSAGE'));
}
}
};
const handleSendMessage = async () => {
await createConversation({
payload: newMessagePayload(),
isFromWhatsApp: false,
});
emit('discard');
clearForm();
};
const prepareWhatsAppMessagePayload = ({ message, templateParams }) => {
const payload = {
inboxId: targetInbox.value.id,
sourceId: targetInbox.value.sourceId,
contactId: selectedContact.value.id,
message: { content: message, template_params: templateParams },
assigneeId: currentUser.value.id,
};
return payload;
};
const handleSendWhatsappMessage = async payload => {
const whatsappPayload = prepareWhatsAppMessagePayload(payload);
await createConversation({ payload: whatsappPayload, isFromWhatsApp: true });
emit('discard');
};
watch(
activeContact,
() => {
if (activeContact.value && props.contactId) {
selectedContact.value = {
...activeContact.value,
contactInboxes: activeContact.value?.contactInboxes.map(inbox => ({
...inbox.inbox,
sourceId: inbox.sourceId,
})),
};
}
},
{ immediate: true, deep: true }
);
</script>
<template>
<div
class="divide-y divide-n-strong absolute right-0 w-[670px] mt-2 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"
>
<div class="flex flex-col divide-y divide-n-strong">
<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"
@search-contacts="handleContactSearch"
@set-selected-contact="setSelectedContact"
@clear-selected-contact="clearSelectedContact"
@update-dropdown="handleDropdownUpdate"
/>
<InboxSelector
:target-inbox="targetInbox"
:selected-contact="selectedContact"
:show-inboxes-dropdown="showInboxesDropdown"
:contactable-inboxes-list="contactableInboxesList"
@update-inbox="targetInbox = $event"
@toggle-dropdown="showInboxesDropdown = $event"
@handle-inbox-action="handleInboxAction"
/>
<EmailOptions
v-if="isEmailInbox"
v-model:cc-emails="state.ccEmails"
v-model:bcc-emails="state.bccEmails"
v-model:subject="state.subject"
:contacts="contacts"
:show-cc-emails-dropdown="showCcEmailsDropdown"
:show-bcc-emails-dropdown="showBccEmailsDropdown"
:show-bcc-input="showBccInput"
:is-loading="isLoading"
@search-cc-emails="searchCcEmails"
@search-bcc-emails="searchBccEmails"
@toggle-bcc="toggleBccInput"
@update-dropdown="handleDropdownUpdate"
/>
<MessageEditor
v-model="state.message"
:is-email-or-web-widget-inbox="isEmailOrWebWidgetInbox"
:is-whatsapp-inbox="isWhatsappInbox"
/>
</div>
<ActionButtons
:is-whatsapp-inbox="isWhatsappInbox"
:is-email-or-web-widget-inbox="isEmailOrWebWidgetInbox"
:is-twilio-inbox="isTwilioInbox"
:is-api-inbox="isApiInbox"
:message-templates="whatsappMessageTemplates"
:channel-type="inboxChannelType"
@discard="$emit('discard')"
@send-message="handleSendMessage"
@send-whatsapp-message="handleSendWhatsappMessage"
@insert-emoji="onClickInsertEmoji"
@add-signature="handleAddSignature"
@remove-signature="handleRemoveSignature"
/>
</div>
</template>
@@ -0,0 +1,154 @@
<script setup>
import { defineAsyncComponent, ref, computed } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
import { useUISettings } from 'dashboard/composables/useUISettings';
import Button from 'dashboard/components-next/button/Button.vue';
import WhatsAppOptions from './WhatsAppOptions.vue';
const props = defineProps({
isWhatsappInbox: {
type: Boolean,
required: true,
},
isEmailOrWebWidgetInbox: {
type: Boolean,
required: true,
},
// isTwilioInbox: {
// type: Boolean,
// required: true,
// },
// isApiInbox: {
// type: Boolean,
// required: true,
// },
messageTemplates: {
type: Object,
default: () => {},
},
channelType: {
type: String,
required: true,
},
});
const emit = defineEmits([
'discard',
'sendMessage',
'sendWhatsappMessage',
'insertEmoji',
'addSignature',
'removeSignature',
]);
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 } =
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);
};
</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"
v-on-clickaway="() => (isEmojiPickerOpen = false)"
class="relative"
>
<Button
icon="i-lucide-smile-plus"
color="slate"
size="sm"
class="!w-10"
@click="isEmojiPickerOpen = !isEmojiPickerOpen"
/>
<EmojiInput
v-if="isEmojiPickerOpen"
class="left-0 top-full mt-1.5"
:on-click="onClickInsertEmoji"
/>
</div>
<Button
v-if="isEmailOrWebWidgetInbox"
icon="i-lucide-plus"
color="slate"
size="sm"
class="!w-10"
/>
<Button
v-if="isEmailOrWebWidgetInbox"
icon="i-lucide-type"
color="slate"
size="sm"
class="!w-10"
@click="toggleMessageSignature"
/>
</div>
<div class="flex items-center gap-2">
<Button
label="Discard"
variant="faded"
color="slate"
size="sm"
class="!text-xs font-medium"
@click="emit('discard')"
/>
<Button
v-if="!isWhatsappInbox"
label="Send (↵)"
size="sm"
class="!text-xs font-medium"
@click="emit('sendMessage')"
/>
</div>
</div>
</template>
<style scoped lang="scss">
.emoji-dialog::before {
@apply hidden;
}
</style>
@@ -0,0 +1,107 @@
<script setup>
import { computed } from 'vue';
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,
},
});
const emit = defineEmits([
'searchContacts',
'setSelectedContact',
'clearSelectedContact',
'updateDropdown',
]);
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">
{{ 'To :' }}
</label>
<div
v-if="selectedContact"
class="flex items-center gap-1.5 rounded-md bg-n-alpha-2 px-3 min-h-7"
>
<span class="text-sm truncate text-n-slate-12">
{{ isCreatingContact ? 'Creating contact...' : selectedContactLabel }}
</span>
<Button
variant="ghost"
icon="i-lucide-x"
color="slate"
:disabled="contactId"
size="xs"
@click="emit('clearSelectedContact')"
/>
</div>
<TagInput
v-else
placeholder="Search or enter email and press Enter"
mode="single"
:menu-items="contactsList"
:show-dropdown="showContactsDropdown"
:is-loading="isLoading"
:disabled="contactableInboxesList?.length > 0 && showInboxesDropdown"
type="email"
class="flex-1 min-h-7"
@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,149 @@
<script setup>
import { computed } from 'vue';
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({
subject: {
type: String,
required: true,
},
ccEmails: {
type: String,
required: true,
},
bccEmails: {
type: String,
required: true,
},
contacts: {
type: Array,
required: true,
},
showCcEmailsDropdown: {
type: Boolean,
required: true,
},
showBccEmailsDropdown: {
type: Boolean,
required: true,
},
showBccInput: {
type: Boolean,
required: true,
},
isLoading: {
type: Boolean,
required: true,
},
});
const emit = defineEmits([
'update:ccEmails',
'update:bccEmails',
'update:subject',
'searchCcEmails',
'searchBccEmails',
'toggleBcc',
'updateDropdown',
]);
// 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 => {
emit('update:ccEmails', value.join(','));
};
const handleBccUpdate = value => {
emit('update:bccEmails', value.join(','));
};
</script>
<template>
<div class="flex flex-col divide-y divide-n-strong">
<div class="flex items-baseline flex-1 w-full h-8 gap-3 px-4 py-3">
<InlineInput
:model-value="subject"
placeholder="Enter your email subject here"
label="Subject :"
focus-on-mount
@update:model-value="emit('update:subject', $event)"
/>
</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"
>
{{ 'Cc :' }}
</label>
<div class="flex items-center w-full gap-3 min-h-7">
<TagInput
:model-value="ccEmailsArray"
placeholder="Search or enter email and press Enter"
: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="Bcc"
variant="ghost"
size="sm"
color="slate"
class="flex-shrink-0"
@click="emit('toggleBcc')"
/>
</div>
</div>
<div
v-if="showBccInput"
class="flex items-baseline flex-1 w-full gap-3 px-4 py-3 min-h-8"
>
<label
class="mb-0.5 text-sm font-medium whitespace-nowrap text-n-slate-11"
>
{{ 'Bcc :' }}
</label>
<TagInput
:model-value="bccEmailsArray"
placeholder="Search or enter email and press Enter"
: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,100 @@
<script setup>
import { computed } from 'vue';
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: () => [],
},
});
const emit = defineEmits([
'updateInbox',
'toggleDropdown',
'handleInboxAction',
]);
const generateLabelForContactableInboxesList = ({
name,
email,
channelType,
phoneNumber,
}) => {
if (channelType === 'EMAIL') {
return `${name} (${email})`;
}
if (channelType === 'TWILIO' || channelType === 'WHATSAPP') {
return `${name} (${phoneNumber})`;
}
if (channelType === 'API') {
return `${name} (API)`;
}
return name;
};
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">
{{ 'Via inbox :' }}
</label>
<div
v-if="targetInbox"
class="flex items-center gap-1.5 rounded-md bg-n-alpha-2 truncate px-3 h-7"
>
<span class="text-sm text-n-slate-12">
{{ targetInboxLabel }}
</span>
<Button
variant="ghost"
icon="i-lucide-x"
color="slate"
size="xs"
class="flex-shrink-0"
@click="emit('updateInbox', null)"
/>
</div>
<div
v-else
v-on-clickaway="() => emit('toggleDropdown', false)"
class="relative flex items-center h-7"
>
<Button
label="Show inboxes"
variant="link"
size="sm"
color="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,42 @@
<script setup>
import Editor from 'dashboard/components-next/Editor/Editor.vue';
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
defineProps({
modelValue: {
type: String,
required: true,
},
isEmailOrWebWidgetInbox: {
type: Boolean,
required: true,
},
isWhatsappInbox: {
type: Boolean,
required: true,
},
});
const emit = defineEmits(['update:modelValue']);
</script>
<template>
<div v-if="isEmailOrWebWidgetInbox" class="flex-1 h-full">
<Editor
:model-value="modelValue"
placeholder="Write your message here..."
class="[&>div]:!border-transparent [&>div]:px-4 [&>div]:py-4 [&>div]:!bg-transparent h-full [&_.ProseMirror-woot-style]:!max-h-[200px]"
:show-character-count="false"
@update:model-value="emit('update:modelValue', $event)"
/>
</div>
<div v-else-if="!isWhatsappInbox" class="flex-1 h-full">
<TextArea
:model-value="modelValue"
placeholder="Write your message here..."
class="!px-0 [&>div]:!px-4 [&>div]:!border-transparent [&>div]:!bg-transparent"
auto-height
@update:model-value="emit('update:modelValue', $event)"
/>
</div>
</template>
@@ -0,0 +1,113 @@
<script setup>
import { computed, ref } from 'vue';
import Button from 'dashboard/components-next/button/Button.vue';
import WhatsappTemplateParser from './WhatsappTemplateParser.vue';
const props = defineProps({
messageTemplates: {
type: Object,
default: () => {},
},
});
const emit = defineEmits(['sendMessage']);
// 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(() => {
return props.messageTemplates
.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="Select template"
color="slate"
size="sm"
:disabled="selectedTemplate"
class="!text-xs font-medium"
@click="handleTriggerClick"
/>
<div
v-if="showTemplatesMenu"
class="absolute top-full mt-1.5 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="Search templates"
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">
{{ 'No templates found' }}
</p>
</template>
</div>
<WhatsappTemplateParser
v-if="selectedTemplate"
:template="selectedTemplate"
@send-message="handleSendMessage"
@back="handleBack"
/>
</div>
</template>
@@ -0,0 +1,128 @@
<script setup>
import { computed, ref, onMounted } from 'vue';
import { useVuelidate } from '@vuelidate/core';
import { requiredIf } from '@vuelidate/validators';
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 processedParams = ref({});
const templateString = computed(() => {
return props.template?.components?.find(
component => component.type === 'BODY'
).text;
});
const allKeysRequired = value => {
const keys = Object.keys(value);
return keys.every(key => value[key]);
};
const variables = computed(() => {
return templateString.value.match(/{{([^}]+)}}/g);
});
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 v$ = useVuelidate(
{
processedParams: {
requiredIfKeysPresent: requiredIf(variables),
allKeysRequired,
},
},
{ processedParams }
);
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 = () => {
v$.value.$touch();
if (v$.value.$invalid) 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 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">
{{ `WhatsApp template: ${template.name}` }}
</span>
<p class="mb-0 text-sm text-n-slate-11">{{ processedString }}</p>
<span
v-if="processedParams.length"
class="text-sm font-medium text-n-slate-12"
>
{{ 'Variables' }}
</span>
<div
v-for="(variable, key) in processedParams"
:key="key"
class="flex flex-col w-full gap-4"
>
<span class="w-8 h-8 text-sm text-left text-n-slate-12">{{ key }}</span>
<Input v-model="processedParams[key]" custom-input-class="!h-8" />
</div>
<div class="flex items-end justify-between w-full gap-3 h-14">
<Button
label="Go back"
color="slate"
class="w-full font-medium"
@click="emit('back')"
/>
<Button
label="Send message"
class="w-full font-medium"
@click="sendMessage"
/>
</div>
</div>
</template>
@@ -25,6 +25,10 @@ const props = defineProps({
type: String,
default: '',
},
isSearching: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['action']);
@@ -81,7 +85,6 @@ onMounted(() => {
}"
:disabled="item.disabled"
@click="handleAction(item)"
@keydown.enter="handleAction(item)"
>
<slot name="thumbnail" :item="item">
<Avatar
@@ -102,7 +105,11 @@ onMounted(() => {
v-if="filteredMenuItems.length === 0"
class="text-sm text-n-slate-11 px-2 py-1.5"
>
{{ t('DROPDOWN_MENU.EMPTY_STATE') }}
{{
isSearching
? t('DROPDOWN_MENU.SEARCHING')
: t('DROPDOWN_MENU.EMPTY_STATE')
}}
</div>
</div>
</template>
@@ -1,5 +1,7 @@
<script setup>
defineProps({
import { ref, onMounted, nextTick } from 'vue';
const props = defineProps({
modelValue: {
type: [String, Number],
default: '',
@@ -32,13 +34,40 @@ defineProps({
type: Boolean,
default: false,
},
focusOnMount: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['update:modelValue', 'enterPress']);
const emit = defineEmits(['update:modelValue', 'enterPress', 'input', 'blur']);
const inlineInputRef = ref(null);
const onEnterPress = () => {
emit('enterPress');
};
const handleInput = event => {
emit('input', event.target.value);
emit('update:modelValue', event.target.value);
};
const handleBlur = event => {
emit('blur', event.target.value);
};
onMounted(() => {
nextTick(() => {
if (props.focusOnMount) {
inlineInputRef.value?.focus();
}
});
});
defineExpose({
focus: () => inlineInputRef.value?.focus(),
});
</script>
<template>
@@ -49,7 +78,7 @@ const onEnterPress = () => {
v-if="label"
:for="id"
:class="customLabelClass"
class="mb-0.5 text-sm font-medium text-gray-900 dark:text-gray-50"
class="mb-0.5 text-sm font-medium text-n-slate-11"
>
{{ label }}
</label>
@@ -57,13 +86,15 @@ const onEnterPress = () => {
<slot name="prefix" />
<input
:id="id"
ref="inlineInputRef"
:value="modelValue"
:type="type"
:placeholder="placeholder"
:disabled="disabled"
:class="customInputClass"
class="flex w-full reset-base text-sm h-6 !mb-0 border-0 rounded-lg bg-transparent dark:bg-transparent placeholder:text-slate-200 dark:placeholder:text-slate-500 disabled:cursor-not-allowed disabled:opacity-50 text-slate-900 dark:text-white transition-all duration-500 ease-in-out"
@input="$emit('update:modelValue', $event.target.value)"
class="flex w-full reset-base text-sm h-6 !mb-0 border-0 rounded-none bg-transparent dark:bg-transparent placeholder:text-n-slate-10 dark:placeholder:text-n-slate-10 disabled:cursor-not-allowed disabled:opacity-50 text-n-slate-12 dark:text-n-slate-12 transition-all duration-500 ease-in-out"
@input="handleInput"
@blur="handleBlur"
@keydown.enter.prevent="onEnterPress"
/>
</div>
@@ -1,49 +1,156 @@
<script setup>
import { ref, computed, watch } from 'vue';
import { OnClickOutside } from '@vueuse/components';
import InlineInput from 'dashboard/components-next/inline-input/InlineInput.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import { email } from '@vuelidate/validators';
import { useVuelidate } from '@vuelidate/core';
const props = defineProps({
modelValue: {
modelValue: { type: Array, default: () => [] },
placeholder: { type: String, default: '' },
disabled: { type: Boolean, default: false },
type: { type: String, default: 'text' },
isLoading: { type: Boolean, default: false },
menuItems: {
type: Array,
default: () => [],
validator: value =>
value.every(
({ action, value: tagValue, label }) => action && tagValue && label
),
},
placeholder: {
showDropdown: { type: Boolean, default: false },
mode: {
type: String,
default: '',
default: 'multiple',
validator: value => ['single', 'multiple'].includes(value),
},
focusOnMount: { type: Boolean, default: false },
});
const emit = defineEmits(['update:modelValue']);
const emit = defineEmits([
'update:modelValue',
'input',
'blur',
'focus',
'onClickOutside',
'add',
'remove',
]);
const MODE = {
SINGLE: 'single',
MULTIPLE: 'multiple',
};
const tagInputRef = ref(null);
const tags = ref(props.modelValue);
const newTag = ref('');
const isFocused = ref(false);
const isFocused = ref(true);
const showInput = computed(() => isFocused.value || tags.value.length === 0);
const rules = computed(() => ({
newTag: props.type === 'email' ? { email } : {},
}));
const addTag = () => {
if (newTag.value.trim()) {
tags.value.push(newTag.value.trim());
newTag.value = '';
emit('update:modelValue', tags.value);
const v$ = useVuelidate(rules, { newTag });
const isNewTagInValidType = computed(() => v$.value.$invalid);
const showInput = computed(() =>
props.mode === MODE.SINGLE
? isFocused.value && !tags.value.length
: isFocused.value || !tags.value.length
);
const showDropdownMenu = computed(() =>
props.mode === MODE.SINGLE && tags.value.length >= 1
? false
: props.showDropdown
);
const filteredMenuItems = computed(() => {
if (props.mode === MODE.SINGLE && tags.value.length >= 1) return [];
const availableMenuItems = props.menuItems.filter(
item => !tags.value.includes(item.label)
);
// Only show typed value as suggestion if:
// 1. There's a value being typed
// 2. The value isn't already in the tags
// 3. There are no menu items available
const trimmedNewTag = newTag.value.trim();
if (
trimmedNewTag &&
!tags.value.includes(trimmedNewTag) &&
!availableMenuItems.length &&
!props.isLoading
) {
return [
{
label: trimmedNewTag,
value: trimmedNewTag,
email: trimmedNewTag,
thumbnail: { name: trimmedNewTag, src: '' },
action: 'create',
},
];
}
return availableMenuItems;
});
const 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' && !(await v$.value.$validate())) return;
tags.value.push(trimmedTag);
newTag.value = '';
emit('update:modelValue', tags.value);
tagInputRef.value?.focus();
};
const removeTag = index => {
tags.value.splice(index, 1);
emit('update:modelValue', tags.value);
emit('remove');
};
const handleDropdownAction = async ({ email: emailAddress, ...rest }) => {
if (!emailAddress) {
emit('add', { email: emailAddress, ...rest });
return;
}
if (props.mode === MODE.SINGLE && tags.value.length >= 1) return;
if (props.type === 'email') {
newTag.value = emailAddress;
if (!(await v$.value.$validate())) return;
}
tags.value.push(emailAddress);
newTag.value = '';
emit('update:modelValue', tags.value);
tagInputRef.value?.focus();
emit('add', { email: emailAddress, ...rest });
};
const handleFocus = () => {
emit('focus');
tagInputRef.value?.focus();
isFocused.value = true;
};
const handleClickOutside = () => {
if (tags.value.length > 0) {
isFocused.value = false;
}
if (tags.value.length) isFocused.value = false;
emit('onClickOutside');
};
watch(
@@ -52,36 +159,64 @@ watch(
tags.value = newValue;
}
);
watch(
() => newTag.value,
async newValue => {
if (props.type === 'email' && newValue.trim()?.length > 2) {
await v$.value.$validate();
}
}
);
const handleInput = e => emit('input', e);
const handleBlur = e => emit('blur', e);
</script>
<template>
<OnClickOutside @trigger="handleClickOutside">
<div
v-on-clickaway="() => handleClickOutside()"
class="flex flex-wrap w-full gap-2 border border-transparent focus:outline-none"
tabindex="0"
@focus="handleFocus"
@click="handleFocus"
>
<div
class="flex flex-wrap w-full gap-2 border border-transparent focus:outline-none"
tabindex="0"
@focus="handleFocus"
@click="handleFocus"
v-for="(tag, index) in tags"
:key="index"
class="flex items-center justify-center max-w-full gap-1 px-3 py-1 rounded-lg h-7 bg-n-alpha-2"
>
<div
v-for="(tag, index) in tags"
:key="index"
class="flex items-center justify-center max-w-full gap-1 px-3 py-1 rounded-lg h-7 bg-n-alpha-2"
>
<span class="flex-grow min-w-0 text-sm truncate text-n-slate-12">
{{ tag }}
</span>
<span
class="flex-shrink-0 cursor-pointer i-lucide-x size-3.5 text-n-slate-11"
@click.stop="removeTag(index)"
/>
</div>
<InlineInput
v-if="showInput"
v-model="newTag"
:placeholder="placeholder"
custom-input-class="flex-grow"
@enter-press="addTag"
<span class="flex-grow min-w-0 text-sm truncate text-n-slate-12">{{
tag
}}</span>
<span
class="flex-shrink-0 cursor-pointer i-lucide-x size-3.5 text-n-slate-11"
@click.stop="removeTag(index)"
/>
</div>
</OnClickOutside>
<div class="relative flex items-center gap-2 flex-1 min-w-[200px] w-full">
<InlineInput
v-if="showInput"
ref="tagInputRef"
v-model="newTag"
:placeholder="placeholder"
:type="type"
:disabled="disabled"
class="w-full"
:focus-on-mount="focusOnMount"
:custom-input-class="`w-full ${isNewTagInValidType ? '!text-n-ruby-9 dark:!text-n-ruby-9' : ''}`"
@enter-press="addTag"
@focus="handleFocus"
@input="handleInput"
@blur="handleBlur"
/>
<DropdownMenu
v-if="showDropdownMenu"
:menu-items="filteredMenuItems"
:is-searching="isLoading"
class="left-0 z-[100] top-8 overflow-y-auto max-h-60 w-[inherit] max-w-md dark:!outline-n-slate-5"
@action="handleDropdownAction"
/>
</div>
</div>
</template>
@@ -179,7 +179,7 @@ onMounted(() => {
}"
:disabled="disabled"
rows="1"
class="flex w-full reset-base text-sm p-0 !rounded-none !bg-transparent dark:!bg-transparent !border-0 !mb-0 placeholder:text-n-slate-11 dark:placeholder:text-n-slate-11 text-n-slate-12 dark:text-n-slate-12 disabled:cursor-not-allowed disabled:opacity-50 disabled:bg-slate-25 dark:disabled:bg-slate-900"
class="flex w-full reset-base text-sm p-0 !rounded-none !bg-transparent dark:!bg-transparent !border-0 !mb-0 placeholder:text-n-slate-10 dark:placeholder:text-n-slate-10 text-n-slate-12 dark:text-n-slate-12 disabled:cursor-not-allowed disabled:opacity-50 disabled:bg-slate-25 dark:disabled:bg-slate-900"
@input="handleInput"
@focus="handleFocus"
@blur="handleBlur"
@@ -10,7 +10,8 @@
},
"DROPDOWN_MENU": {
"SEARCH_PLACEHOLDER": "Search...",
"EMPTY_STATE": "No results found."
"EMPTY_STATE": "No results found.",
"SEARCHING": "Searching..."
},
"DIALOG": {
"BUTTONS": {
@@ -393,6 +393,7 @@
"SEARCH_TITLE": "Search contacts",
"SEARCH_PLACEHOLDER": "Search...",
"MESSAGE_BUTTON": "Message",
"SEND_MESSAGE": "Send message",
"BREADCRUMB": {
"CONTACTS": "Contacts"
},
@@ -77,7 +77,11 @@ const goToContactsList = () => {
const fetchActiveContact = async () => {
if (route.params.contactId) {
store.dispatch('contacts/show', { id: route.params.contactId });
await store.dispatch('contacts/show', { id: route.params.contactId });
await store.dispatch(
'contacts/fetchContactableInbox',
route.params.contactId
);
}
};
@@ -112,7 +116,7 @@ onMounted(() => {
class="flex flex-col justify-between flex-1 h-full m-0 overflow-auto bg-n-background"
>
<ContactsLayout
:button-label="$t('CONTACTS_LAYOUT.HEADER.MESSAGE_BUTTON')"
:button-label="t('CONTACTS_LAYOUT.HEADER.SEND_MESSAGE')"
:selected-contact="selectedContact"
is-detail-view
:show-pagination-footer="false"
@@ -138,7 +138,7 @@ export default {
},
selectedInbox: {
get() {
const inboxList = this.contact.contactableInboxes || [];
const inboxList = this.contact.contact_inboxes || [];
return (
inboxList.find(inbox => {
return inbox.inbox?.id && inbox.inbox?.id === this.targetInbox?.id;
@@ -152,7 +152,7 @@ export default {
},
},
showNoInboxAlert() {
if (!this.contact.contactableInboxes) {
if (!this.contact.contact_inboxes) {
return false;
}
return this.inboxes.length === 0 && !this.uiFlags.isFetchingInboxes;
@@ -166,7 +166,7 @@ export default {
: this.$t('CONVERSATION.FOOTER.ENABLE_SIGN_TOOLTIP');
},
inboxes() {
const inboxList = this.contact.contactableInboxes || [];
const inboxList = this.contact.contact_inboxes || [];
return inboxList.map(inbox => ({
...inbox.inbox,
sourceId: inbox.source_id,
@@ -195,8 +195,8 @@ export const actions = {
try {
const response = await ContactAPI.getContactableInboxes(id);
const contact = {
id,
contactableInboxes: response.data.payload,
id: Number(id),
contact_inboxes: response.data.payload,
};
commit(types.SET_CONTACT_ITEM, contact);
} catch (error) {