Merge branch 'develop' into fix/cw-4085

This commit is contained in:
Muhsin Keloth
2025-09-09 12:26:35 +05:30
committed by GitHub
812 changed files with 24190 additions and 4826 deletions
@@ -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">
@@ -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>
@@ -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>
@@ -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"
@@ -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';