mirror of
https://github.com/lingble/chatwoot.git
synced 2025-11-02 20:18:08 +00:00
feat: Add support for Whatsapp template messages in the UI (#4711)
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
This commit is contained in:
@@ -79,6 +79,16 @@
|
||||
:title="signatureToggleTooltip"
|
||||
@click="toggleMessageSignature"
|
||||
/>
|
||||
<woot-button
|
||||
v-if="showWhatsappTemplatesButton"
|
||||
v-tooltip.top-end="'Whatsapp Templates'"
|
||||
icon="whatsapp"
|
||||
color-scheme="secondary"
|
||||
variant="smooth"
|
||||
size="small"
|
||||
:title="'Whatsapp Templates'"
|
||||
@click="$emit('selectWhatsappTemplate')"
|
||||
/>
|
||||
<transition name="modal-fade">
|
||||
<div
|
||||
v-show="$refs.upload && $refs.upload.dropActive"
|
||||
@@ -218,6 +228,10 @@ export default {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
hasWhatsappTemplates: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
isNote() {
|
||||
@@ -261,6 +275,9 @@ export default {
|
||||
showMessageSignatureButton() {
|
||||
return !this.isPrivate && this.isAnEmailChannel;
|
||||
},
|
||||
showWhatsappTemplatesButton() {
|
||||
return !this.isOnPrivateNote && this.hasWhatsappTemplates;
|
||||
},
|
||||
sendWithSignature() {
|
||||
const { send_with_signature: isEnabled } = this.uiSettings;
|
||||
return isEnabled;
|
||||
|
||||
@@ -101,7 +101,7 @@
|
||||
:toggle-audio-recorder="toggleAudioRecorder"
|
||||
:toggle-audio-recorder-play-pause="toggleAudioRecorderPlayPause"
|
||||
:show-emoji-picker="showEmojiPicker"
|
||||
:on-send="sendMessage"
|
||||
:on-send="onSendReply"
|
||||
:is-send-disabled="isReplyButtonDisabled"
|
||||
:recording-audio-duration-text="recordingAudioDurationText"
|
||||
:recording-audio-state="recordingAudioState"
|
||||
@@ -112,7 +112,16 @@
|
||||
:enable-rich-editor="isRichEditorEnabled"
|
||||
:enter-to-send-enabled="enterToSendEnabled"
|
||||
:enable-multiple-file-upload="enableMultipleFileUpload"
|
||||
:has-whatsapp-templates="hasWhatsappTemplates"
|
||||
@toggleEnterToSend="toggleEnterToSend"
|
||||
@selectWhatsappTemplate="openWhatsappTemplateModal"
|
||||
/>
|
||||
<whatsapp-templates
|
||||
:inbox-id="inbox.id"
|
||||
:show="showWhatsAppTemplatesModal"
|
||||
@close="hideWhatsappTemplatesModal"
|
||||
@on-send="onSendWhatsAppReply"
|
||||
@cancel="hideWhatsappTemplatesModal"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -137,7 +146,7 @@ import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
|
||||
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
|
||||
import { MAXIMUM_FILE_UPLOAD_SIZE } from 'shared/constants/messages';
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
|
||||
import WhatsappTemplates from './WhatsappTemplates/Modal.vue';
|
||||
import {
|
||||
isEscape,
|
||||
isEnter,
|
||||
@@ -162,6 +171,7 @@ export default {
|
||||
WootMessageEditor,
|
||||
WootAudioRecorder,
|
||||
Banner,
|
||||
WhatsappTemplates,
|
||||
},
|
||||
mixins: [
|
||||
clickaway,
|
||||
@@ -201,6 +211,7 @@ export default {
|
||||
hasSlashCommand: false,
|
||||
bccEmails: '',
|
||||
ccEmails: '',
|
||||
showWhatsAppTemplatesModal: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@@ -212,7 +223,6 @@ export default {
|
||||
globalConfig: 'globalConfig/get',
|
||||
accountId: 'getCurrentAccountId',
|
||||
}),
|
||||
|
||||
showRichContentEditor() {
|
||||
if (this.isOnPrivateNote) {
|
||||
return true;
|
||||
@@ -256,7 +266,9 @@ export default {
|
||||
|
||||
return false;
|
||||
},
|
||||
|
||||
hasWhatsappTemplates() {
|
||||
return !!this.inbox.message_templates;
|
||||
},
|
||||
enterToSendEnabled() {
|
||||
return !!this.uiSettings.enter_to_send_enabled;
|
||||
},
|
||||
@@ -484,7 +496,7 @@ export default {
|
||||
hasSendOnEnterEnabled && !hasPressedShift(e) && this.isFocused;
|
||||
if (shouldSendMessage) {
|
||||
e.preventDefault();
|
||||
this.sendMessage();
|
||||
this.onSendReply();
|
||||
}
|
||||
} else if (hasPressedCommandPlusKKey(e)) {
|
||||
this.openCommandBar();
|
||||
@@ -497,6 +509,12 @@ export default {
|
||||
toggleEnterToSend(enterToSendEnabled) {
|
||||
this.updateUISettings({ enter_to_send_enabled: enterToSendEnabled });
|
||||
},
|
||||
openWhatsappTemplateModal() {
|
||||
this.showWhatsAppTemplatesModal = true;
|
||||
},
|
||||
hideWhatsappTemplatesModal() {
|
||||
this.showWhatsAppTemplatesModal = false;
|
||||
},
|
||||
onClickSelfAssign() {
|
||||
const {
|
||||
account_id,
|
||||
@@ -520,7 +538,7 @@ export default {
|
||||
};
|
||||
this.assignedAgent = selfAssign;
|
||||
},
|
||||
async sendMessage() {
|
||||
async onSendReply() {
|
||||
if (this.isReplyButtonDisabled) {
|
||||
return;
|
||||
}
|
||||
@@ -531,22 +549,31 @@ export default {
|
||||
}
|
||||
const messagePayload = this.getMessagePayload(newMessage);
|
||||
this.clearMessage();
|
||||
try {
|
||||
await this.$store.dispatch(
|
||||
'createPendingMessageAndSend',
|
||||
messagePayload
|
||||
);
|
||||
bus.$emit(BUS_EVENTS.SCROLL_TO_MESSAGE);
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error?.response?.data?.error ||
|
||||
this.$t('CONVERSATION.MESSAGE_ERROR');
|
||||
this.showAlert(errorMessage);
|
||||
}
|
||||
this.sendMessage(messagePayload);
|
||||
this.hideEmojiPicker();
|
||||
this.$emit('update:popoutReplyBox', false);
|
||||
}
|
||||
},
|
||||
async sendMessage(messagePayload) {
|
||||
try {
|
||||
await this.$store.dispatch(
|
||||
'createPendingMessageAndSend',
|
||||
messagePayload
|
||||
);
|
||||
bus.$emit(BUS_EVENTS.SCROLL_TO_MESSAGE);
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error?.response?.data?.error || this.$t('CONVERSATION.MESSAGE_ERROR');
|
||||
this.showAlert(errorMessage);
|
||||
}
|
||||
},
|
||||
async onSendWhatsAppReply(messagePayload) {
|
||||
this.sendMessage({
|
||||
conversationId: this.currentChat.id,
|
||||
...messagePayload,
|
||||
});
|
||||
this.hideWhatsappTemplatesModal();
|
||||
},
|
||||
replaceText(message) {
|
||||
setTimeout(() => {
|
||||
this.message = message;
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
<template>
|
||||
<woot-modal :show.sync="show" :on-close="onClose" size="modal-big">
|
||||
<woot-modal-header
|
||||
:header-title="$t('WHATSAPP_TEMPLATES.MODAL.TITLE')"
|
||||
:header-content="modalHeaderContent"
|
||||
/>
|
||||
<div class="row modal-content">
|
||||
<templates-picker
|
||||
v-if="!selectedWaTemplate"
|
||||
:inbox-id="inboxId"
|
||||
@onSelect="pickTemplate"
|
||||
/>
|
||||
<template-parser
|
||||
v-else
|
||||
:template="selectedWaTemplate"
|
||||
@resetTemplate="onResetTemplate"
|
||||
@sendMessage="onSendMessage"
|
||||
/>
|
||||
</div>
|
||||
</woot-modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import TemplatesPicker from './TemplatesPicker.vue';
|
||||
import TemplateParser from './TemplateParser.vue';
|
||||
export default {
|
||||
components: {
|
||||
TemplatesPicker,
|
||||
TemplateParser,
|
||||
},
|
||||
props: {
|
||||
inboxId: {
|
||||
type: Number,
|
||||
default: undefined,
|
||||
},
|
||||
show: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
selectedWaTemplate: null,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
modalHeaderContent() {
|
||||
return this.selectedWaTemplate
|
||||
? this.$t('WHATSAPP_TEMPLATES.MODAL.TEMPLATE_SELECTED_SUBTITLE', {
|
||||
templateName: this.selectedWaTemplate.name,
|
||||
})
|
||||
: this.$t('WHATSAPP_TEMPLATES.MODAL.SUBTITLE');
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
pickTemplate(template) {
|
||||
this.selectedWaTemplate = template;
|
||||
},
|
||||
onResetTemplate() {
|
||||
this.selectedWaTemplate = null;
|
||||
},
|
||||
onSendMessage(message) {
|
||||
this.$emit('on-send', message);
|
||||
},
|
||||
onClose() {
|
||||
this.$emit('cancel');
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.modal-content {
|
||||
padding: 2.5rem 3.2rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,183 @@
|
||||
<template>
|
||||
<div class="medium-12 columns">
|
||||
<textarea
|
||||
v-model="processedString"
|
||||
rows="4"
|
||||
readonly
|
||||
class="template-input"
|
||||
></textarea>
|
||||
<div>
|
||||
<div class="template__variables-container">
|
||||
<p class="variables-label">
|
||||
{{ $t('WHATSAPP_TEMPLATES.PARSER.VARIABLES_LABEL') }}
|
||||
</p>
|
||||
<div
|
||||
v-for="(variable, key) in processedParams"
|
||||
:key="key"
|
||||
class="template__variable-item"
|
||||
>
|
||||
<span class="variable-label">
|
||||
{{ key }}
|
||||
</span>
|
||||
<woot-input
|
||||
v-model="processedParams[key]"
|
||||
type="text"
|
||||
class="variable-input"
|
||||
:styles="{ marginBottom: 0 }"
|
||||
/>
|
||||
</div>
|
||||
<p v-if="showRequiredMessage" class="error">
|
||||
{{ $t('WHATSAPP_TEMPLATES.PARSER.FORM_ERROR_MESSAGE') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<footer>
|
||||
<woot-button variant="smooth" @click="$emit('resetTemplate')">
|
||||
{{ $t('WHATSAPP_TEMPLATES.PARSER.GO_BACK_LABEL') }}
|
||||
</woot-button>
|
||||
<woot-button @click="sendMessage">
|
||||
{{ $t('WHATSAPP_TEMPLATES.PARSER.SEND_MESSAGE_LABEL') }}
|
||||
</woot-button>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { required } from 'vuelidate/lib/validators';
|
||||
|
||||
const allKeysRequired = value => {
|
||||
const keys = Object.keys(value);
|
||||
return keys.every(key => value[key]);
|
||||
};
|
||||
export default {
|
||||
props: {
|
||||
template: {
|
||||
type: Object,
|
||||
default: () => {},
|
||||
},
|
||||
},
|
||||
validations: {
|
||||
processedParams: {
|
||||
required,
|
||||
allKeysRequired,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
message: this.template.message,
|
||||
processedParams: {},
|
||||
showRequiredMessage: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
variables() {
|
||||
const variables = this.templateString.match(/{{([^}]+)}}/g);
|
||||
return variables;
|
||||
},
|
||||
templateString() {
|
||||
return this.template.components.find(
|
||||
component => component.type === 'BODY'
|
||||
).text;
|
||||
},
|
||||
processedString() {
|
||||
return this.templateString.replace(/{{([^}]+)}}/g, (match, variable) => {
|
||||
const variableKey = this.processVariable(variable);
|
||||
return this.processedParams[variableKey] || `{{${variable}}}`;
|
||||
});
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.generateVariables();
|
||||
},
|
||||
methods: {
|
||||
sendMessage() {
|
||||
this.$v.$touch();
|
||||
if (this.$v.$invalid) {
|
||||
this.showRequiredMessage = true;
|
||||
return;
|
||||
}
|
||||
const message = {
|
||||
message: this.processedString,
|
||||
templateParams: {
|
||||
name: this.template.name,
|
||||
category: this.template.category,
|
||||
language: this.template.language,
|
||||
namespace: this.template.namespace,
|
||||
processed_params: this.processedParams,
|
||||
},
|
||||
};
|
||||
this.$emit('sendMessage', message);
|
||||
},
|
||||
processVariable(str) {
|
||||
return str.replace(/{{|}}/g, '');
|
||||
},
|
||||
generateVariables() {
|
||||
const templateString = this.template.components.find(
|
||||
component => component.type === 'BODY'
|
||||
).text;
|
||||
const variables = templateString.match(/{{([^}]+)}}/g).map(variable => {
|
||||
return this.processVariable(variable);
|
||||
});
|
||||
this.processedParams = variables.reduce((acc, variable) => {
|
||||
acc[variable] = '';
|
||||
return acc;
|
||||
}, {});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.template__variables-container {
|
||||
padding: var(--space-one);
|
||||
}
|
||||
|
||||
.variables-label {
|
||||
font-size: var(--font-size-small);
|
||||
font-weight: var(--font-weight-bold);
|
||||
margin-bottom: var(--space-one);
|
||||
}
|
||||
|
||||
.template__variable-item {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
margin-bottom: var(--space-one);
|
||||
|
||||
.label {
|
||||
font-size: var(--font-size-mini);
|
||||
}
|
||||
|
||||
.variable-input {
|
||||
flex: 1;
|
||||
font-size: var(--font-size-small);
|
||||
margin-left: var(--space-one);
|
||||
}
|
||||
|
||||
.variable-label {
|
||||
background-color: var(--s-75);
|
||||
border-radius: var(--border-radius-normal);
|
||||
display: inline-block;
|
||||
font-size: var(--font-size-mini);
|
||||
padding: var(--space-one) var(--space-medium);
|
||||
}
|
||||
}
|
||||
|
||||
footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
|
||||
button {
|
||||
margin-left: var(--space-one);
|
||||
}
|
||||
}
|
||||
.error {
|
||||
background-color: var(--r-100);
|
||||
border-radius: var(--border-radius-normal);
|
||||
color: var(--r-800);
|
||||
padding: var(--space-one);
|
||||
text-align: center;
|
||||
}
|
||||
.template-input {
|
||||
background-color: var(--s-25);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,163 @@
|
||||
<template>
|
||||
<div class="medium-12 columns">
|
||||
<div class="templates__list-search">
|
||||
<fluent-icon icon="search" class="search-icon" size="16" />
|
||||
<input
|
||||
ref="search"
|
||||
v-model="query"
|
||||
type="search"
|
||||
:placeholder="$t('WHATSAPP_TEMPLATES.PICKER.SEARCH_PLACEHOLDER')"
|
||||
class="templates__search-input"
|
||||
/>
|
||||
</div>
|
||||
<div class="template__list-container">
|
||||
<div v-for="(template, i) in filteredTemplateMessages" :key="template.id">
|
||||
<button
|
||||
class="template__list-item"
|
||||
@click="$emit('onSelect', template)"
|
||||
>
|
||||
<div>
|
||||
<div class="flex-between">
|
||||
<p class="label-title">
|
||||
{{ template.name }}
|
||||
</p>
|
||||
<span class="label-lang label">
|
||||
{{ $t('WHATSAPP_TEMPLATES.PICKER.LABELS.LANGUAGE') }} :
|
||||
{{ template.language }}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="strong">
|
||||
{{ $t('WHATSAPP_TEMPLATES.PICKER.LABELS.TEMPLATE_BODY') }}
|
||||
</p>
|
||||
<p class="label-body">{{ getTemplatebody(template) }}</p>
|
||||
</div>
|
||||
<div class="label-category">
|
||||
<p class="strong">
|
||||
{{ $t('WHATSAPP_TEMPLATES.PICKER.LABELS.CATEGORY') }}
|
||||
</p>
|
||||
<p>{{ template.category }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<hr v-if="i != filteredTemplateMessages.length - 1" :key="`hr-${i}`" />
|
||||
</div>
|
||||
<div v-if="!filteredTemplateMessages.length">
|
||||
<p>
|
||||
{{ $t('WHATSAPP_TEMPLATES.PICKER.NO_TEMPLATES_FOUND') }}
|
||||
<strong>{{ query }}</strong>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
inboxId: {
|
||||
type: Number,
|
||||
default: undefined,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
query: '',
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
whatsAppTemplateMessages() {
|
||||
return this.$store.getters['inboxes/getWhatsAppTemplates'](this.inboxId);
|
||||
},
|
||||
filteredTemplateMessages() {
|
||||
return this.whatsAppTemplateMessages.filter(template =>
|
||||
template.name.toLowerCase().includes(this.query.toLowerCase())
|
||||
);
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
getTemplatebody(template) {
|
||||
return template.components.find(component => component.type === 'BODY')
|
||||
.text;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.flex-between {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--space-one);
|
||||
}
|
||||
|
||||
.templates__list-search {
|
||||
align-items: center;
|
||||
background-color: var(--s-25);
|
||||
border-radius: var(--border-radius-medium);
|
||||
border: 1px solid var(--s-100);
|
||||
display: flex;
|
||||
margin-bottom: var(--space-one);
|
||||
padding: 0 var(--space-one);
|
||||
|
||||
.search-icon {
|
||||
color: var(--s-400);
|
||||
}
|
||||
|
||||
.templates__search-input {
|
||||
background-color: transparent;
|
||||
border: var(--space-large);
|
||||
font-size: var(--font-size-mini);
|
||||
height: unset;
|
||||
margin: var(--space-zero);
|
||||
}
|
||||
}
|
||||
.template__list-container {
|
||||
background-color: var(--s-25);
|
||||
border-radius: var(--border-radius-medium);
|
||||
max-height: 30rem;
|
||||
overflow-y: auto;
|
||||
padding: var(--space-one);
|
||||
|
||||
.template__list-item {
|
||||
border-radius: var(--border-radius-medium);
|
||||
cursor: pointer;
|
||||
display: block;
|
||||
padding: var(--space-one);
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--w-50);
|
||||
}
|
||||
|
||||
.label-title {
|
||||
font-size: var(--font-size-small);
|
||||
}
|
||||
|
||||
.label-category {
|
||||
margin-top: var(--space-two);
|
||||
|
||||
span {
|
||||
font-size: var(--font-size-small);
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
}
|
||||
|
||||
.label-body {
|
||||
font-family: monospace;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.strong {
|
||||
font-size: var(--font-size-mini);
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
|
||||
hr {
|
||||
border-bottom: 1px solid var(--s-100);
|
||||
margin: var(--space-one) auto;
|
||||
max-width: 95%;
|
||||
}
|
||||
</style>
|
||||
@@ -7,7 +7,7 @@
|
||||
<fluent-icon
|
||||
v-tooltip.top-start="$t('CHAT_LIST.SENT')"
|
||||
icon="checkmark"
|
||||
size="16"
|
||||
size="14"
|
||||
/>
|
||||
</span>
|
||||
<fluent-icon
|
||||
@@ -165,7 +165,11 @@ export default {
|
||||
return `https://www.instagram.com/stories/${storySender}/${storyId}`;
|
||||
},
|
||||
showSentIndicator() {
|
||||
return this.isOutgoing && this.sourceId && this.isAnEmailChannel;
|
||||
return (
|
||||
this.isOutgoing &&
|
||||
this.sourceId &&
|
||||
(this.isAnEmailChannel || this.isAWhatsappChannel)
|
||||
);
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
:type="type"
|
||||
:placeholder="placeholder"
|
||||
:readonly="readonly"
|
||||
:style="styles"
|
||||
@input="onChange"
|
||||
@blur="onBlur"
|
||||
/>
|
||||
@@ -47,6 +48,10 @@ export default {
|
||||
type: Boolean,
|
||||
deafaut: false,
|
||||
},
|
||||
styles: {
|
||||
type: Object,
|
||||
default: () => {},
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
onChange(e) {
|
||||
|
||||
Reference in New Issue
Block a user