Merge branch 'develop' into fix/cw-4085
This commit is contained in:
@@ -1,50 +1,57 @@
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
src: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
isComingSoon: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
<script setup>
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
|
||||
defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
};
|
||||
description: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
isComingSoon: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
class="relative bg-n-background cursor-pointer flex flex-col justify-end transition-all duration-200 ease-in -m-px py-4 px-0 items-center border border-solid border-n-weak hover:border-n-brand hover:shadow-md hover:z-50 disabled:opacity-60"
|
||||
class="relative bg-n-solid-1 gap-6 cursor-pointer rounded-2xl flex flex-col justify-start transition-all duration-200 ease-in -m-px py-6 px-5 items-start border border-solid border-n-weak"
|
||||
:class="{
|
||||
'hover:enabled:border-n-blue-9 hover:enabled:shadow-md disabled:opacity-60 disabled:cursor-not-allowed':
|
||||
!isComingSoon,
|
||||
'cursor-not-allowed disabled:opacity-80': isComingSoon,
|
||||
}"
|
||||
>
|
||||
<img :src="src" :alt="title" draggable="false" class="w-1/2 my-4 mx-auto" />
|
||||
<h3 class="text-n-slate-12 text-base text-center capitalize">
|
||||
{{ title }}
|
||||
</h3>
|
||||
<div
|
||||
class="flex size-10 items-center justify-center rounded-full bg-n-alpha-2"
|
||||
>
|
||||
<Icon :icon="icon" class="text-n-slate-10 size-6" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-start gap-1.5">
|
||||
<h3 class="text-n-slate-12 text-sm text-start font-medium capitalize">
|
||||
{{ title }}
|
||||
</h3>
|
||||
<p class="text-n-slate-11 text-start text-sm">
|
||||
{{ description }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isComingSoon"
|
||||
class="absolute inset-0 flex items-center justify-center backdrop-blur-[2px] rounded-md bg-gradient-to-br from-n-background/90 via-n-background/70 to-n-background/95"
|
||||
class="absolute inset-0 flex items-center justify-center backdrop-blur-[2px] rounded-2xl bg-gradient-to-br from-n-background/90 via-n-background/70 to-n-background/95 cursor-not-allowed"
|
||||
>
|
||||
<span class="text-n-slate-12 font-medium text-base">
|
||||
<span class="text-n-slate-12 font-medium text-sm">
|
||||
{{ $t('CHANNEL_SELECTOR.COMING_SOON') }} 🚀
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.inactive {
|
||||
img {
|
||||
filter: grayscale(100%);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
@apply border-n-strong shadow-none cursor-not-allowed;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
import Copilot from 'dashboard/components-next/copilot/Copilot.vue';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
@@ -100,20 +101,24 @@ const handleReset = () => {
|
||||
};
|
||||
|
||||
const sendMessage = async message => {
|
||||
if (selectedCopilotThreadId.value) {
|
||||
await store.dispatch('copilotMessages/create', {
|
||||
assistant_id: activeAssistant.value.id,
|
||||
conversation_id: currentChat.value?.id,
|
||||
threadId: selectedCopilotThreadId.value,
|
||||
message,
|
||||
});
|
||||
} else {
|
||||
const response = await store.dispatch('copilotThreads/create', {
|
||||
assistant_id: activeAssistant.value.id,
|
||||
conversation_id: currentChat.value?.id,
|
||||
message,
|
||||
});
|
||||
selectedCopilotThreadId.value = response.id;
|
||||
try {
|
||||
if (selectedCopilotThreadId.value) {
|
||||
await store.dispatch('copilotMessages/create', {
|
||||
assistant_id: activeAssistant.value.id,
|
||||
conversation_id: currentChat.value?.id,
|
||||
threadId: selectedCopilotThreadId.value,
|
||||
message,
|
||||
});
|
||||
} else {
|
||||
const response = await store.dispatch('copilotThreads/create', {
|
||||
assistant_id: activeAssistant.value.id,
|
||||
conversation_id: currentChat.value?.id,
|
||||
message,
|
||||
});
|
||||
selectedCopilotThreadId.value = response.id;
|
||||
}
|
||||
} catch (error) {
|
||||
useAlert(error.message);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,97 +1,81 @@
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
items: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
items: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
computed: {
|
||||
classObject() {
|
||||
return 'w-full';
|
||||
},
|
||||
activeIndex() {
|
||||
return this.items.findIndex(i => i.route === this.$route.name);
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
isActive(item) {
|
||||
return this.items.indexOf(item) === this.activeIndex;
|
||||
},
|
||||
isOver(item) {
|
||||
return this.items.indexOf(item) < this.activeIndex;
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
const activeIndex = computed(() => {
|
||||
const index = props.items.findIndex(i => i.route === route.name);
|
||||
return index === -1 ? 0 : index;
|
||||
});
|
||||
|
||||
const steps = computed(() =>
|
||||
props.items.map((item, index) => {
|
||||
const isActive = index === activeIndex.value;
|
||||
const isOver = index < activeIndex.value;
|
||||
return {
|
||||
...item,
|
||||
index,
|
||||
isActive,
|
||||
isOver,
|
||||
};
|
||||
})
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<transition-group
|
||||
name="wizard-items"
|
||||
tag="div"
|
||||
class="wizard-box"
|
||||
:class="classObject"
|
||||
>
|
||||
<transition-group tag="div">
|
||||
<div
|
||||
v-for="item in items"
|
||||
:key="item.route"
|
||||
class="item"
|
||||
:class="{ active: isActive(item), over: isOver(item) }"
|
||||
v-for="step in steps"
|
||||
:key="step.route"
|
||||
class="cursor-pointer flex items-start gap-6 relative after:content-[''] after:absolute after:w-0.5 after:h-full after:top-5 ltr:after:left-4 rtl:after:right-4 before:content-[''] before:absolute before:w-0.5 before:h-4 before:top-0 before:left-4 rtl:before:right-4 last:after:hidden last:before:hidden after:bg-n-slate-3 before:bg-n-slate-3"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<h3
|
||||
class="text-n-slate-12 text-base font-medium pl-6 overflow-hidden whitespace-nowrap mt-0.5 text-ellipsis leading-tight"
|
||||
<!-- Circle -->
|
||||
<div
|
||||
class="rounded-2xl flex-shrink-0 size-8 border-2 border-n-slate-3 flex items-center justify-center left-2 leading-4 z-10 top-5 transition-all duration-300 ease-in-out"
|
||||
:class="{
|
||||
'bg-n-slate-3': step.isActive || step.isOver,
|
||||
'bg-n-background': !step.isActive && !step.isOver,
|
||||
}"
|
||||
>
|
||||
<span
|
||||
v-if="!step.isOver"
|
||||
:key="'num-' + step.index"
|
||||
class="text-xs font-bold transition-colors duration-300"
|
||||
:class="step.isActive ? 'text-n-blue-11' : 'text-n-slate-11'"
|
||||
>
|
||||
{{ item.title }}
|
||||
</h3>
|
||||
<span v-if="isOver(item)" class="mx-1 mt-0.5 text-n-teal-9">
|
||||
<fluent-icon icon="checkmark" />
|
||||
{{ step.index + 1 }}
|
||||
</span>
|
||||
<Icon
|
||||
v-else
|
||||
:key="'check-' + step.index"
|
||||
icon="i-lucide-check"
|
||||
class="text-n-slate-11 size-4"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex flex-col items-start gap-1.5 pb-10 pt-1">
|
||||
<div class="flex items-center">
|
||||
<h3
|
||||
class="text-sm font-medium overflow-hidden whitespace-nowrap mt-0.5 text-ellipsis leading-tight"
|
||||
:class="step.isActive ? 'text-n-blue-11' : 'text-n-slate-12'"
|
||||
>
|
||||
{{ step.title }}
|
||||
</h3>
|
||||
</div>
|
||||
<p class="m-0 mt-1.5 text-sm text-n-slate-11">
|
||||
{{ step.body }}
|
||||
</p>
|
||||
</div>
|
||||
<span class="step">
|
||||
{{ items.indexOf(item) + 1 }}
|
||||
</span>
|
||||
<p class="pl-6 m-0 mt-1.5 text-sm text-n-slate-11">
|
||||
{{ item.body }}
|
||||
</p>
|
||||
</div>
|
||||
</transition-group>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.wizard-box {
|
||||
.item {
|
||||
@apply cursor-pointer after:bg-n-slate-6 before:bg-n-slate-6 py-4 ltr:pr-4 rtl:pl-4 ltr:pl-6 rtl:pr-6 relative before:h-4 before:top-0 last:before:h-0 first:before:h-0 last:after:h-0 before:content-[''] before:absolute before:w-0.5 after:content-[''] after:h-full after:absolute after:top-5 after:w-0.5 rtl:after:left-6 rtl:before:left-6;
|
||||
|
||||
&.active {
|
||||
h3 {
|
||||
@apply text-n-blue-text dark:text-n-blue-text;
|
||||
}
|
||||
|
||||
.step {
|
||||
@apply bg-n-brand dark:bg-n-brand;
|
||||
}
|
||||
}
|
||||
|
||||
&.over {
|
||||
&::after {
|
||||
@apply bg-n-brand dark:bg-n-brand;
|
||||
}
|
||||
|
||||
.step {
|
||||
@apply bg-n-brand dark:bg-n-brand;
|
||||
}
|
||||
|
||||
& + .item {
|
||||
&::before {
|
||||
@apply bg-n-brand dark:bg-n-brand;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.step {
|
||||
@apply bg-n-slate-7 rounded-2xl font-medium w-4 left-4 leading-4 z-10 absolute text-center text-white dark:text-white text-xxs top-5;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,91 +1,87 @@
|
||||
<script>
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import ChannelSelector from '../ChannelSelector.vue';
|
||||
export default {
|
||||
components: { ChannelSelector },
|
||||
props: {
|
||||
channel: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
enabledFeatures: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
emits: ['channelItemClick'],
|
||||
computed: {
|
||||
hasFbConfigured() {
|
||||
return window.chatwootConfig?.fbAppId;
|
||||
},
|
||||
hasInstagramConfigured() {
|
||||
return window.chatwootConfig?.instagramAppId;
|
||||
},
|
||||
isActive() {
|
||||
const { key } = this.channel;
|
||||
if (Object.keys(this.enabledFeatures).length === 0) {
|
||||
return false;
|
||||
}
|
||||
if (key === 'website') {
|
||||
return this.enabledFeatures.channel_website;
|
||||
}
|
||||
if (key === 'facebook') {
|
||||
return this.enabledFeatures.channel_facebook && this.hasFbConfigured;
|
||||
}
|
||||
if (key === 'email') {
|
||||
return this.enabledFeatures.channel_email;
|
||||
}
|
||||
|
||||
if (key === 'instagram') {
|
||||
return (
|
||||
this.enabledFeatures.channel_instagram && this.hasInstagramConfigured
|
||||
);
|
||||
}
|
||||
|
||||
if (key === 'voice') {
|
||||
return this.enabledFeatures.channel_voice;
|
||||
}
|
||||
|
||||
return [
|
||||
'website',
|
||||
'twilio',
|
||||
'api',
|
||||
'whatsapp',
|
||||
'sms',
|
||||
'telegram',
|
||||
'line',
|
||||
'instagram',
|
||||
'voice',
|
||||
].includes(key);
|
||||
},
|
||||
isComingSoon() {
|
||||
const { key } = this.channel;
|
||||
// Show "Coming Soon" only if the channel is marked as coming soon
|
||||
// and the corresponding feature flag is not enabled yet.
|
||||
return ['voice'].includes(key) && !this.isActive;
|
||||
},
|
||||
const props = defineProps({
|
||||
channel: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
methods: {
|
||||
getChannelThumbnail() {
|
||||
if (this.channel.key === 'api' && this.channel.thumbnail) {
|
||||
return this.channel.thumbnail;
|
||||
}
|
||||
return `/assets/images/dashboard/channels/${this.channel.key}.png`;
|
||||
},
|
||||
onItemClick() {
|
||||
if (this.isActive) {
|
||||
this.$emit('channelItemClick', this.channel.key);
|
||||
}
|
||||
},
|
||||
enabledFeatures: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['channelItemClick']);
|
||||
|
||||
const hasFbConfigured = computed(() => {
|
||||
return window.chatwootConfig?.fbAppId;
|
||||
});
|
||||
|
||||
const hasInstagramConfigured = computed(() => {
|
||||
return window.chatwootConfig?.instagramAppId;
|
||||
});
|
||||
|
||||
const isActive = computed(() => {
|
||||
const { key } = props.channel;
|
||||
if (Object.keys(props.enabledFeatures).length === 0) {
|
||||
return false;
|
||||
}
|
||||
if (key === 'website') {
|
||||
return props.enabledFeatures.channel_website;
|
||||
}
|
||||
if (key === 'facebook') {
|
||||
return props.enabledFeatures.channel_facebook && hasFbConfigured.value;
|
||||
}
|
||||
if (key === 'email') {
|
||||
return props.enabledFeatures.channel_email;
|
||||
}
|
||||
|
||||
if (key === 'instagram') {
|
||||
return (
|
||||
props.enabledFeatures.channel_instagram && hasInstagramConfigured.value
|
||||
);
|
||||
}
|
||||
|
||||
if (key === 'voice') {
|
||||
return props.enabledFeatures.channel_voice;
|
||||
}
|
||||
|
||||
return [
|
||||
'website',
|
||||
'twilio',
|
||||
'api',
|
||||
'whatsapp',
|
||||
'sms',
|
||||
'telegram',
|
||||
'line',
|
||||
'instagram',
|
||||
'voice',
|
||||
].includes(key);
|
||||
});
|
||||
|
||||
const isComingSoon = computed(() => {
|
||||
const { key } = props.channel;
|
||||
// Show "Coming Soon" only if the channel is marked as coming soon
|
||||
// and the corresponding feature flag is not enabled yet.
|
||||
return ['voice'].includes(key) && !isActive.value;
|
||||
});
|
||||
|
||||
const onItemClick = () => {
|
||||
if (isActive.value) {
|
||||
emit('channelItemClick', props.channel.key);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ChannelSelector
|
||||
:class="{ inactive: !isActive }"
|
||||
:title="channel.name"
|
||||
:src="getChannelThumbnail()"
|
||||
:title="channel.title"
|
||||
:description="channel.description"
|
||||
:icon="channel.icon"
|
||||
:is-coming-soon="isComingSoon"
|
||||
:disabled="!isActive"
|
||||
@click="onItemClick"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -13,7 +13,7 @@ defineProps({
|
||||
<div class="flex items-center text-n-slate-11 text-xs min-w-0">
|
||||
<ChannelIcon
|
||||
:inbox="inbox"
|
||||
class="size-3 ltr:mr-0.5 rtl:ml-0.5 flex-shrink-0"
|
||||
class="size-3 ltr:mr-1 rtl:ml-1 flex-shrink-0"
|
||||
/>
|
||||
<span class="truncate">
|
||||
{{ inbox.name }}
|
||||
|
||||
@@ -6,15 +6,12 @@ import FileUpload from 'vue-upload-component';
|
||||
import * as ActiveStorage from 'activestorage';
|
||||
import inboxMixin from 'shared/mixins/inboxMixin';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import {
|
||||
ALLOWED_FILE_TYPES,
|
||||
ALLOWED_FILE_TYPES_FOR_TWILIO_WHATSAPP,
|
||||
ALLOWED_FILE_TYPES_FOR_LINE,
|
||||
ALLOWED_FILE_TYPES_FOR_INSTAGRAM,
|
||||
} from 'shared/constants/messages';
|
||||
import { getAllowedFileTypesByChannel } from '@chatwoot/utils';
|
||||
import { ALLOWED_FILE_TYPES } from 'shared/constants/messages';
|
||||
import VideoCallButton from '../VideoCallButton.vue';
|
||||
import AIAssistanceButton from '../AIAssistanceButton.vue';
|
||||
import { REPLY_EDITOR_MODES } from './constants';
|
||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||
import { mapGetters } from 'vuex';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
@@ -98,6 +95,10 @@ export default {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
enableContentTemplates: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
conversationId: {
|
||||
type: Number,
|
||||
required: true,
|
||||
@@ -124,6 +125,7 @@ export default {
|
||||
'toggleInsertArticle',
|
||||
'toggleEditor',
|
||||
'selectWhatsappTemplate',
|
||||
'selectContentTemplate',
|
||||
],
|
||||
setup() {
|
||||
const { setSignatureFlagForInbox, fetchSignatureFlagFromUISettings } =
|
||||
@@ -155,6 +157,11 @@ export default {
|
||||
uploadRef,
|
||||
};
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
ALLOWED_FILE_TYPES,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
accountId: 'getCurrentAccountId',
|
||||
@@ -196,17 +203,21 @@ export default {
|
||||
return this.conversationType === 'instagram_direct_message';
|
||||
},
|
||||
allowedFileTypes() {
|
||||
if (this.isATwilioWhatsAppChannel) {
|
||||
return ALLOWED_FILE_TYPES_FOR_TWILIO_WHATSAPP;
|
||||
}
|
||||
if (this.isALineChannel) {
|
||||
return ALLOWED_FILE_TYPES_FOR_LINE;
|
||||
}
|
||||
if (this.isAnInstagramChannel || this.isInstagramDM) {
|
||||
return ALLOWED_FILE_TYPES_FOR_INSTAGRAM;
|
||||
// Use default file types for private notes
|
||||
if (this.isOnPrivateNote) {
|
||||
return this.ALLOWED_FILE_TYPES;
|
||||
}
|
||||
|
||||
return ALLOWED_FILE_TYPES;
|
||||
let channelType = this.channelType || this.inbox?.channel_type;
|
||||
|
||||
if (this.isAnInstagramChannel || this.isInstagramDM) {
|
||||
channelType = INBOX_TYPES.INSTAGRAM;
|
||||
}
|
||||
|
||||
return getAllowedFileTypesByChannel({
|
||||
channelType,
|
||||
medium: this.inbox?.medium,
|
||||
});
|
||||
},
|
||||
enableDragAndDrop() {
|
||||
return !this.newConversationModalActive;
|
||||
@@ -341,6 +352,15 @@ export default {
|
||||
sm
|
||||
@click="$emit('selectWhatsappTemplate')"
|
||||
/>
|
||||
<NextButton
|
||||
v-if="enableContentTemplates"
|
||||
v-tooltip.top-end="'Content Templates'"
|
||||
icon="i-ph-whatsapp-logo"
|
||||
slate
|
||||
faded
|
||||
sm
|
||||
@click="$emit('selectContentTemplate')"
|
||||
/>
|
||||
<VideoCallButton
|
||||
v-if="(isAWebWidgetInbox || isAPIInbox) && !isOnPrivateNote"
|
||||
:conversation-id="conversationId"
|
||||
@@ -355,7 +375,7 @@ export default {
|
||||
<transition name="modal-fade">
|
||||
<div
|
||||
v-show="uploadRef && uploadRef.dropActive"
|
||||
class="fixed top-0 bottom-0 left-0 right-0 z-20 flex flex-col items-center justify-center w-full h-full gap-2 text-n-slate-12 bg-modal-backdrop-light dark:bg-modal-backdrop-dark"
|
||||
class="flex fixed top-0 right-0 bottom-0 left-0 z-20 flex-col gap-2 justify-center items-center w-full h-full text-n-slate-12 bg-modal-backdrop-light dark:bg-modal-backdrop-dark"
|
||||
>
|
||||
<fluent-icon icon="cloud-backup" size="40" />
|
||||
<h4 class="text-2xl break-words text-n-slate-12">
|
||||
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import TemplatesPicker from './ContentTemplatesPicker.vue';
|
||||
import TemplateParser from '../../../../components-next/content-templates/ContentTemplateParser.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const props = defineProps({
|
||||
show: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
inboxId: {
|
||||
type: Number,
|
||||
default: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['onSend', 'cancel', 'update:show']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const selectedContentTemplate = ref(null);
|
||||
|
||||
const localShow = computed({
|
||||
get() {
|
||||
return props.show;
|
||||
},
|
||||
set(value) {
|
||||
emit('update:show', value);
|
||||
},
|
||||
});
|
||||
|
||||
const modalHeaderContent = computed(() => {
|
||||
return selectedContentTemplate.value
|
||||
? t('CONTENT_TEMPLATES.MODAL.TEMPLATE_SELECTED_SUBTITLE', {
|
||||
templateName: selectedContentTemplate.value.friendly_name,
|
||||
})
|
||||
: t('CONTENT_TEMPLATES.MODAL.SUBTITLE');
|
||||
});
|
||||
|
||||
const pickTemplate = template => {
|
||||
selectedContentTemplate.value = template;
|
||||
};
|
||||
|
||||
const onResetTemplate = () => {
|
||||
selectedContentTemplate.value = null;
|
||||
};
|
||||
|
||||
const onSendMessage = message => {
|
||||
emit('onSend', message);
|
||||
};
|
||||
|
||||
const onClose = () => {
|
||||
emit('cancel');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<woot-modal v-model:show="localShow" :on-close="onClose" size="modal-big">
|
||||
<woot-modal-header
|
||||
:header-title="$t('CONTENT_TEMPLATES.MODAL.TITLE')"
|
||||
:header-content="modalHeaderContent"
|
||||
/>
|
||||
<div class="px-8 py-6 row">
|
||||
<TemplatesPicker
|
||||
v-if="!selectedContentTemplate"
|
||||
:inbox-id="inboxId"
|
||||
@on-select="pickTemplate"
|
||||
/>
|
||||
<TemplateParser
|
||||
v-else
|
||||
:template="selectedContentTemplate"
|
||||
@reset-template="onResetTemplate"
|
||||
@send-message="onSendMessage"
|
||||
>
|
||||
<template #actions="{ sendMessage, resetTemplate, disabled }">
|
||||
<div class="flex gap-2 mt-6">
|
||||
<Button
|
||||
:label="t('CONTENT_TEMPLATES.PARSER.GO_BACK_LABEL')"
|
||||
color="slate"
|
||||
variant="faded"
|
||||
class="flex-1"
|
||||
@click="resetTemplate"
|
||||
/>
|
||||
<Button
|
||||
:label="t('CONTENT_TEMPLATES.PARSER.SEND_MESSAGE_LABEL')"
|
||||
class="flex-1"
|
||||
:disabled="disabled"
|
||||
@click="sendMessage"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</TemplateParser>
|
||||
</div>
|
||||
</woot-modal>
|
||||
</template>
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { TWILIO_CONTENT_TEMPLATE_TYPES } from 'shared/constants/messages';
|
||||
|
||||
const props = defineProps({
|
||||
inboxId: {
|
||||
type: Number,
|
||||
default: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['onSelect']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
const query = ref('');
|
||||
const isRefreshing = ref(false);
|
||||
|
||||
const twilioTemplates = computed(() => {
|
||||
const inbox = store.getters['inboxes/getInbox'](props.inboxId);
|
||||
return inbox?.content_templates?.templates || [];
|
||||
});
|
||||
|
||||
const filteredTemplateMessages = computed(() =>
|
||||
twilioTemplates.value.filter(
|
||||
template =>
|
||||
template.friendly_name
|
||||
.toLowerCase()
|
||||
.includes(query.value.toLowerCase()) && template.status === 'approved'
|
||||
)
|
||||
);
|
||||
|
||||
const getTemplateType = template => {
|
||||
if (template.template_type === TWILIO_CONTENT_TEMPLATE_TYPES.MEDIA) {
|
||||
return t('CONTENT_TEMPLATES.PICKER.TYPES.MEDIA');
|
||||
}
|
||||
if (template.template_type === TWILIO_CONTENT_TEMPLATE_TYPES.QUICK_REPLY) {
|
||||
return t('CONTENT_TEMPLATES.PICKER.TYPES.QUICK_REPLY');
|
||||
}
|
||||
return t('CONTENT_TEMPLATES.PICKER.TYPES.TEXT');
|
||||
};
|
||||
|
||||
const refreshTemplates = async () => {
|
||||
isRefreshing.value = true;
|
||||
try {
|
||||
await store.dispatch('inboxes/syncTemplates', props.inboxId);
|
||||
useAlert(t('CONTENT_TEMPLATES.PICKER.REFRESH_SUCCESS'));
|
||||
} catch (error) {
|
||||
useAlert(t('CONTENT_TEMPLATES.PICKER.REFRESH_ERROR'));
|
||||
} finally {
|
||||
isRefreshing.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<div class="flex gap-2 mb-2.5">
|
||||
<div
|
||||
class="flex flex-1 gap-1 items-center px-2.5 py-0 rounded-lg bg-n-alpha-black2 outline outline-1 outline-n-weak hover:outline-n-slate-6 dark:hover:outline-n-slate-6 focus-within:outline-n-brand dark:focus-within:outline-n-brand"
|
||||
>
|
||||
<fluent-icon icon="search" class="text-n-slate-12" size="16" />
|
||||
<input
|
||||
v-model="query"
|
||||
type="search"
|
||||
:placeholder="t('CONTENT_TEMPLATES.PICKER.SEARCH_PLACEHOLDER')"
|
||||
class="reset-base w-full h-9 bg-transparent text-n-slate-12 !text-sm !outline-0"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
:disabled="isRefreshing"
|
||||
class="flex justify-center items-center w-9 h-9 rounded-lg bg-n-alpha-black2 outline outline-1 outline-n-weak hover:outline-n-slate-6 dark:hover:outline-n-slate-6 hover:bg-n-alpha-2 dark:hover:bg-n-solid-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
:title="t('CONTENT_TEMPLATES.PICKER.REFRESH_BUTTON')"
|
||||
@click="refreshTemplates"
|
||||
>
|
||||
<Icon
|
||||
icon="i-lucide-refresh-ccw"
|
||||
class="text-n-slate-12 size-4"
|
||||
:class="{ 'animate-spin': isRefreshing }"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="bg-n-background outline-n-container outline outline-1 rounded-lg max-h-[18.75rem] overflow-y-auto p-2.5"
|
||||
>
|
||||
<div
|
||||
v-for="(template, i) in filteredTemplateMessages"
|
||||
:key="template.content_sid"
|
||||
>
|
||||
<button
|
||||
class="block p-2.5 w-full text-left rounded-lg cursor-pointer hover:bg-n-alpha-2 dark:hover:bg-n-solid-2"
|
||||
@click="emit('onSelect', template)"
|
||||
>
|
||||
<div>
|
||||
<div class="flex justify-between items-center mb-2.5">
|
||||
<p class="text-sm">
|
||||
{{ template.friendly_name }}
|
||||
</p>
|
||||
<div class="flex gap-2">
|
||||
<span
|
||||
class="inline-block px-2 py-1 text-xs leading-none rounded-lg cursor-default bg-n-slate-3 text-n-slate-12"
|
||||
>
|
||||
{{ getTemplateType(template) }}
|
||||
</span>
|
||||
<span
|
||||
class="inline-block px-2 py-1 text-xs leading-none rounded-lg cursor-default bg-n-slate-3 text-n-slate-12"
|
||||
>
|
||||
{{
|
||||
`${t('CONTENT_TEMPLATES.PICKER.LABELS.LANGUAGE')}: ${template.language}`
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div>
|
||||
<p class="text-xs font-medium text-n-slate-11">
|
||||
{{ t('CONTENT_TEMPLATES.PICKER.BODY') }}
|
||||
</p>
|
||||
<p class="text-sm label-body">
|
||||
{{ template.body || t('CONTENT_TEMPLATES.PICKER.NO_CONTENT') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between items-center mt-3">
|
||||
<div>
|
||||
<p class="text-xs font-medium text-n-slate-11">
|
||||
{{ t('CONTENT_TEMPLATES.PICKER.LABELS.CATEGORY') }}
|
||||
</p>
|
||||
<p class="text-sm">{{ template.category || 'utility' }}</p>
|
||||
</div>
|
||||
<div class="text-xs text-n-slate-11">
|
||||
{{ new Date(template.created_at).toLocaleDateString() }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<hr
|
||||
v-if="i != filteredTemplateMessages.length - 1"
|
||||
:key="`hr-${i}`"
|
||||
class="border-b border-solid border-n-weak my-2.5 mx-auto max-w-[95%]"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="!filteredTemplateMessages.length" class="py-8 text-center">
|
||||
<div v-if="query && twilioTemplates.length">
|
||||
<p>
|
||||
{{ t('CONTENT_TEMPLATES.PICKER.NO_TEMPLATES_FOUND') }}
|
||||
<strong>{{ query }}</strong>
|
||||
</p>
|
||||
</div>
|
||||
<div v-else-if="!twilioTemplates.length" class="space-y-4">
|
||||
<p class="text-n-slate-11">
|
||||
{{ t('CONTENT_TEMPLATES.PICKER.NO_TEMPLATES_AVAILABLE') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.label-body {
|
||||
font-family: monospace;
|
||||
}
|
||||
</style>
|
||||
@@ -3,6 +3,7 @@ import { computed, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import { getLastMessage } from 'dashboard/helper/conversationHelper';
|
||||
import { useVoiceCallStatus } from 'dashboard/composables/useVoiceCallStatus';
|
||||
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
|
||||
import Avatar from 'next/avatar/Avatar.vue';
|
||||
import MessagePreview from './MessagePreview.vue';
|
||||
@@ -82,6 +83,16 @@ const isInboxNameVisible = computed(() => !activeInbox.value);
|
||||
|
||||
const lastMessageInChat = computed(() => getLastMessage(props.chat));
|
||||
|
||||
const callStatus = computed(
|
||||
() => props.chat.additional_attributes?.call_status
|
||||
);
|
||||
const callDirection = computed(
|
||||
() => props.chat.additional_attributes?.call_direction
|
||||
);
|
||||
|
||||
const { labelKey: voiceLabelKey, listIconColor: voiceIconColor } =
|
||||
useVoiceCallStatus(callStatus, callDirection);
|
||||
|
||||
const inboxId = computed(() => props.chat.inbox_id);
|
||||
|
||||
const inbox = computed(() => {
|
||||
@@ -250,7 +261,6 @@ const deleteConversation = () => {
|
||||
:src="currentContact.thumbnail"
|
||||
:size="32"
|
||||
:status="currentContact.availability_status"
|
||||
:inbox="inbox"
|
||||
:class="!showInboxName ? 'mt-4' : 'mt-8'"
|
||||
hide-offline-status
|
||||
rounded-full
|
||||
@@ -307,14 +317,30 @@ const deleteConversation = () => {
|
||||
>
|
||||
{{ currentContact.name }}
|
||||
</h4>
|
||||
<div
|
||||
v-if="callStatus"
|
||||
key="voice-status-row"
|
||||
class="my-0 mx-2 leading-6 h-6 flex-1 min-w-0 text-sm overflow-hidden text-ellipsis whitespace-nowrap"
|
||||
:class="messagePreviewClass"
|
||||
>
|
||||
<span
|
||||
class="inline-block -mt-0.5 align-middle text-[16px] i-ph-phone-incoming"
|
||||
:class="[voiceIconColor]"
|
||||
/>
|
||||
<span class="mx-1">
|
||||
{{ $t(voiceLabelKey) }}
|
||||
</span>
|
||||
</div>
|
||||
<MessagePreview
|
||||
v-if="lastMessageInChat"
|
||||
v-else-if="lastMessageInChat"
|
||||
key="message-preview"
|
||||
:message="lastMessageInChat"
|
||||
class="my-0 mx-2 leading-6 h-6 flex-1 min-w-0 text-sm"
|
||||
:class="messagePreviewClass"
|
||||
/>
|
||||
<p
|
||||
v-else
|
||||
key="no-messages"
|
||||
class="text-n-slate-11 text-sm my-0 mx-2 leading-6 h-6 flex-1 min-w-0 overflow-hidden text-ellipsis whitespace-nowrap"
|
||||
:class="messagePreviewClass"
|
||||
>
|
||||
|
||||
@@ -15,7 +15,7 @@ import ReplyEmailHead from './ReplyEmailHead.vue';
|
||||
import ReplyBottomPanel from 'dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue';
|
||||
import ArticleSearchPopover from 'dashboard/routes/dashboard/helpcenter/components/ArticleSearch/SearchPopover.vue';
|
||||
import MessageSignatureMissingAlert from './MessageSignatureMissingAlert.vue';
|
||||
import Banner from 'dashboard/components/ui/Banner.vue';
|
||||
import ReplyBoxBanner from './ReplyBoxBanner.vue';
|
||||
import { REPLY_EDITOR_MODES } from 'dashboard/components/widgets/WootWriter/constants';
|
||||
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
|
||||
import AudioRecorder from 'dashboard/components/widgets/WootWriter/AudioRecorder.vue';
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
replaceVariablesInMessage,
|
||||
} from '@chatwoot/utils';
|
||||
import WhatsappTemplates from './WhatsappTemplates/Modal.vue';
|
||||
import ContentTemplates from './ContentTemplates/ContentTemplatesModal.vue';
|
||||
import { MESSAGE_MAX_LENGTH } from 'shared/helpers/MessageTypeHelper';
|
||||
import inboxMixin, { INBOX_FEATURES } from 'shared/mixins/inboxMixin';
|
||||
import { trimContent, debounce, getRecipients } from '@chatwoot/utils';
|
||||
@@ -52,8 +53,8 @@ export default {
|
||||
ArticleSearchPopover,
|
||||
AttachmentPreview,
|
||||
AudioRecorder,
|
||||
Banner,
|
||||
CannedResponse,
|
||||
ReplyBoxBanner,
|
||||
EmojiInput,
|
||||
MessageSignatureMissingAlert,
|
||||
ReplyBottomPanel,
|
||||
@@ -61,6 +62,7 @@ export default {
|
||||
ReplyToMessage,
|
||||
ReplyTopPanel,
|
||||
ResizableTextArea,
|
||||
ContentTemplates,
|
||||
WhatsappTemplates,
|
||||
WootMessageEditor,
|
||||
},
|
||||
@@ -109,6 +111,7 @@ export default {
|
||||
toEmails: '',
|
||||
doAutoSaveDraft: () => {},
|
||||
showWhatsAppTemplatesModal: false,
|
||||
showContentTemplatesModal: false,
|
||||
updateEditorSelectionWith: '',
|
||||
undefinedVariableMessage: '',
|
||||
showMentions: false,
|
||||
@@ -155,38 +158,12 @@ export default {
|
||||
|
||||
return false;
|
||||
},
|
||||
assignedAgent: {
|
||||
get() {
|
||||
return this.currentChat.meta.assignee;
|
||||
},
|
||||
set(agent) {
|
||||
const agentId = agent ? agent.id : 0;
|
||||
this.$store.dispatch('setCurrentChatAssignee', agent);
|
||||
this.$store
|
||||
.dispatch('assignAgent', {
|
||||
conversationId: this.currentChat.id,
|
||||
agentId,
|
||||
})
|
||||
.then(() => {
|
||||
useAlert(this.$t('CONVERSATION.CHANGE_AGENT'));
|
||||
});
|
||||
},
|
||||
},
|
||||
showSelfAssignBanner() {
|
||||
if (this.message !== '' && !this.isOnPrivateNote) {
|
||||
if (!this.assignedAgent) {
|
||||
return true;
|
||||
}
|
||||
if (this.assignedAgent.id !== this.currentUser.id) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
showWhatsappTemplates() {
|
||||
return this.isAWhatsAppCloudChannel && !this.isPrivate;
|
||||
},
|
||||
showContentTemplates() {
|
||||
return this.isATwilioWhatsAppChannel && !this.isPrivate;
|
||||
},
|
||||
isPrivate() {
|
||||
if (this.currentChat.can_reply || this.isAWhatsAppChannel) {
|
||||
return this.isOnPrivateNote;
|
||||
@@ -659,28 +636,11 @@ export default {
|
||||
hideWhatsappTemplatesModal() {
|
||||
this.showWhatsAppTemplatesModal = false;
|
||||
},
|
||||
onClickSelfAssign() {
|
||||
const {
|
||||
account_id,
|
||||
availability_status,
|
||||
available_name,
|
||||
email,
|
||||
id,
|
||||
name,
|
||||
role,
|
||||
avatar_url,
|
||||
} = this.currentUser;
|
||||
const selfAssign = {
|
||||
account_id,
|
||||
availability_status,
|
||||
available_name,
|
||||
email,
|
||||
id,
|
||||
name,
|
||||
role,
|
||||
thumbnail: avatar_url,
|
||||
};
|
||||
this.assignedAgent = selfAssign;
|
||||
openContentTemplateModal() {
|
||||
this.showContentTemplatesModal = true;
|
||||
},
|
||||
hideContentTemplatesModal() {
|
||||
this.showContentTemplatesModal = false;
|
||||
},
|
||||
confirmOnSendReply() {
|
||||
if (this.isReplyButtonDisabled) {
|
||||
@@ -774,6 +734,13 @@ export default {
|
||||
});
|
||||
this.hideWhatsappTemplatesModal();
|
||||
},
|
||||
async onSendContentTemplateReply(messagePayload) {
|
||||
this.sendMessage({
|
||||
conversationId: this.currentChat.id,
|
||||
...messagePayload,
|
||||
});
|
||||
this.hideContentTemplatesModal();
|
||||
},
|
||||
replaceText(message) {
|
||||
if (this.sendWithSignature && !this.private) {
|
||||
// if signature is enabled, append it to the message
|
||||
@@ -793,6 +760,10 @@ export default {
|
||||
}, 100);
|
||||
},
|
||||
setReplyMode(mode = REPLY_EDITOR_MODES.REPLY) {
|
||||
// Clear attachments when switching between private note and reply modes
|
||||
// This is to prevent from breaking the upload rules
|
||||
if (this.attachedFiles.length > 0) this.attachedFiles = [];
|
||||
|
||||
const { can_reply: canReply } = this.currentChat;
|
||||
this.$store.dispatch('draftMessages/setReplyEditorMode', {
|
||||
mode,
|
||||
@@ -1095,16 +1066,7 @@ export default {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Banner
|
||||
v-if="showSelfAssignBanner"
|
||||
action-button-variant="ghost"
|
||||
color-scheme="secondary"
|
||||
class="mx-2 mb-2 rounded-lg banner--self-assign"
|
||||
:banner-message="$t('CONVERSATION.NOT_ASSIGNED_TO_YOU')"
|
||||
has-action-button
|
||||
:action-button-label="$t('CONVERSATION.ASSIGN_TO_ME')"
|
||||
@primary-action="onClickSelfAssign"
|
||||
/>
|
||||
<ReplyBoxBanner :message="message" :is-on-private-note="isOnPrivateNote" />
|
||||
<div ref="replyEditor" class="reply-box" :class="replyBoxClass">
|
||||
<ReplyTopPanel
|
||||
:mode="replyType"
|
||||
@@ -1213,6 +1175,7 @@ export default {
|
||||
:conversation-id="conversationId"
|
||||
:enable-multiple-file-upload="enableMultipleFileUpload"
|
||||
:enable-whats-app-templates="showWhatsappTemplates"
|
||||
:enable-content-templates="showContentTemplates"
|
||||
:inbox="inbox"
|
||||
:is-on-private-note="isOnPrivateNote"
|
||||
:is-recording-audio="isRecordingAudio"
|
||||
@@ -1235,6 +1198,7 @@ export default {
|
||||
:portal-slug="connectedPortalSlug"
|
||||
:new-conversation-modal-active="newConversationModalActive"
|
||||
@select-whatsapp-template="openWhatsappTemplateModal"
|
||||
@select-content-template="openContentTemplateModal"
|
||||
@toggle-editor="toggleRichContentEditor"
|
||||
@replace-text="replaceText"
|
||||
@toggle-insert-article="toggleInsertArticle"
|
||||
@@ -1247,6 +1211,14 @@ export default {
|
||||
@cancel="hideWhatsappTemplatesModal"
|
||||
/>
|
||||
|
||||
<ContentTemplates
|
||||
:inbox-id="inbox.id"
|
||||
:show="showContentTemplatesModal"
|
||||
@close="hideContentTemplatesModal"
|
||||
@on-send="onSendContentTemplateReply"
|
||||
@cancel="hideContentTemplatesModal"
|
||||
/>
|
||||
|
||||
<woot-confirm-modal
|
||||
ref="confirmDialog"
|
||||
:title="$t('CONVERSATION.REPLYBOX.UNDEFINED_VARIABLES.TITLE')"
|
||||
@@ -1260,10 +1232,6 @@ export default {
|
||||
@apply mb-0;
|
||||
}
|
||||
|
||||
.banner--self-assign {
|
||||
@apply py-2;
|
||||
}
|
||||
|
||||
.attachment-preview-box {
|
||||
@apply bg-transparent py-0 px-4;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
|
||||
import Banner from 'dashboard/components/ui/Banner.vue';
|
||||
|
||||
const props = defineProps({
|
||||
message: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
isOnPrivateNote: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
const currentChat = useMapGetter('getSelectedChat');
|
||||
const currentUser = useMapGetter('getCurrentUser');
|
||||
|
||||
const assignedAgent = computed({
|
||||
get() {
|
||||
return currentChat.value?.meta?.assignee;
|
||||
},
|
||||
set(agent) {
|
||||
const agentId = agent ? agent.id : 0;
|
||||
store.dispatch('setCurrentChatAssignee', agent);
|
||||
store.dispatch('assignAgent', {
|
||||
conversationId: currentChat.value?.id,
|
||||
agentId,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const isUserTyping = computed(
|
||||
() => props.message !== '' && !props.isOnPrivateNote
|
||||
);
|
||||
const isUnassigned = computed(() => !assignedAgent.value);
|
||||
const isAssignedToOtherAgent = computed(
|
||||
() => assignedAgent.value?.id !== currentUser.value?.id
|
||||
);
|
||||
|
||||
const showSelfAssignBanner = computed(() => {
|
||||
return (
|
||||
isUserTyping.value && (isUnassigned.value || isAssignedToOtherAgent.value)
|
||||
);
|
||||
});
|
||||
|
||||
const showBotHandoffBanner = computed(
|
||||
() =>
|
||||
isUserTyping.value &&
|
||||
currentChat.value?.status === wootConstants.STATUS_TYPE.PENDING
|
||||
);
|
||||
|
||||
const botHandoffActionLabel = computed(() => {
|
||||
return assignedAgent.value?.id === currentUser.value?.id
|
||||
? t('CONVERSATION.BOT_HANDOFF_REOPEN_ACTION')
|
||||
: t('CONVERSATION.BOT_HANDOFF_ACTION');
|
||||
});
|
||||
|
||||
const selfAssignConversation = async () => {
|
||||
const { avatar_url, ...rest } = currentUser.value || {};
|
||||
assignedAgent.value = { ...rest, thumbnail: avatar_url };
|
||||
};
|
||||
|
||||
const needsAssignmentToCurrentUser = computed(() => {
|
||||
return isUnassigned.value || isAssignedToOtherAgent.value;
|
||||
});
|
||||
|
||||
const onClickSelfAssign = async () => {
|
||||
try {
|
||||
await selfAssignConversation();
|
||||
useAlert(t('CONVERSATION.CHANGE_AGENT'));
|
||||
} catch (error) {
|
||||
useAlert(t('CONVERSATION.CHANGE_AGENT_FAILED'));
|
||||
}
|
||||
};
|
||||
|
||||
const reopenConversation = async () => {
|
||||
await store.dispatch('toggleStatus', {
|
||||
conversationId: currentChat.value?.id,
|
||||
status: wootConstants.STATUS_TYPE.OPEN,
|
||||
});
|
||||
};
|
||||
|
||||
const onClickBotHandoff = async () => {
|
||||
try {
|
||||
await reopenConversation();
|
||||
|
||||
if (needsAssignmentToCurrentUser.value) {
|
||||
await selfAssignConversation();
|
||||
}
|
||||
|
||||
useAlert(t('CONVERSATION.BOT_HANDOFF_SUCCESS'));
|
||||
} catch (error) {
|
||||
useAlert(t('CONVERSATION.BOT_HANDOFF_ERROR'));
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Banner
|
||||
v-if="showSelfAssignBanner && !showBotHandoffBanner"
|
||||
action-button-variant="ghost"
|
||||
color-scheme="secondary"
|
||||
class="mx-2 mb-2 rounded-lg !py-2"
|
||||
:banner-message="$t('CONVERSATION.NOT_ASSIGNED_TO_YOU')"
|
||||
has-action-button
|
||||
:action-button-label="$t('CONVERSATION.ASSIGN_TO_ME')"
|
||||
@primary-action="onClickSelfAssign"
|
||||
/>
|
||||
<Banner
|
||||
v-if="showBotHandoffBanner"
|
||||
action-button-variant="ghost"
|
||||
color-scheme="secondary"
|
||||
class="mx-2 mb-2 rounded-lg !py-2"
|
||||
:banner-message="$t('CONVERSATION.BOT_HANDOFF_MESSAGE')"
|
||||
has-action-button
|
||||
:action-button-label="botHandoffActionLabel"
|
||||
@primary-action="onClickBotHandoff"
|
||||
/>
|
||||
</template>
|
||||
+3
-3
@@ -1,10 +1,10 @@
|
||||
<script>
|
||||
import TemplatesPicker from './TemplatesPicker.vue';
|
||||
import TemplateParser from './TemplateParser.vue';
|
||||
import WhatsAppTemplateReply from './WhatsAppTemplateReply.vue';
|
||||
export default {
|
||||
components: {
|
||||
TemplatesPicker,
|
||||
TemplateParser,
|
||||
WhatsAppTemplateReply,
|
||||
},
|
||||
props: {
|
||||
show: {
|
||||
@@ -68,7 +68,7 @@ export default {
|
||||
:inbox-id="inboxId"
|
||||
@on-select="pickTemplate"
|
||||
/>
|
||||
<TemplateParser
|
||||
<WhatsAppTemplateReply
|
||||
v-else
|
||||
:template="selectedWaTemplate"
|
||||
@reset-template="onResetTemplate"
|
||||
|
||||
-9
@@ -1,13 +1,4 @@
|
||||
<script setup>
|
||||
/**
|
||||
* This component handles parsing and sending WhatsApp message templates.
|
||||
* It works as follows:
|
||||
* 1. Displays the template text with variable placeholders.
|
||||
* 2. Generates input fields for each variable in the template.
|
||||
* 3. Validates that all variables are filled before sending.
|
||||
* 4. Replaces placeholders with user-provided values.
|
||||
* 5. Emits events to send the processed message or reset the template.
|
||||
*/
|
||||
import WhatsAppTemplateParser from 'dashboard/components-next/whatsapp/WhatsAppTemplateParser.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
Reference in New Issue
Block a user