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
@@ -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>