Merge pull request #31 from fazer-ai/chore/merge-upstream
Chore/merge upstream
This commit is contained in:
commit
f9f0bdedb9
@ -502,14 +502,14 @@ GEM
|
|||||||
newrelic_rpm (9.6.0)
|
newrelic_rpm (9.6.0)
|
||||||
base64
|
base64
|
||||||
nio4r (2.7.3)
|
nio4r (2.7.3)
|
||||||
nokogiri (1.18.4)
|
nokogiri (1.18.8)
|
||||||
mini_portile2 (~> 2.8.2)
|
mini_portile2 (~> 2.8.2)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
nokogiri (1.18.4-arm64-darwin)
|
nokogiri (1.18.8-arm64-darwin)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
nokogiri (1.18.4-x86_64-darwin)
|
nokogiri (1.18.8-x86_64-darwin)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
nokogiri (1.18.4-x86_64-linux-gnu)
|
nokogiri (1.18.8-x86_64-linux-gnu)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
oauth (1.1.0)
|
oauth (1.1.0)
|
||||||
oauth-tty (~> 1.0, >= 1.0.1)
|
oauth-tty (~> 1.0, >= 1.0.1)
|
||||||
|
|||||||
@ -9,7 +9,7 @@ class Campaigns::CampaignConversationBuilder
|
|||||||
@contact_inbox.lock!
|
@contact_inbox.lock!
|
||||||
|
|
||||||
# We won't send campaigns if a conversation is already present
|
# We won't send campaigns if a conversation is already present
|
||||||
raise 'Conversation alread present' if @contact_inbox.reload.conversations.present?
|
raise 'Conversation already present' if @contact_inbox.reload.conversations.present?
|
||||||
|
|
||||||
@conversation = ::Conversation.create!(conversation_params)
|
@conversation = ::Conversation.create!(conversation_params)
|
||||||
Messages::MessageBuilder.new(@campaign.sender, @conversation, message_params).perform
|
Messages::MessageBuilder.new(@campaign.sender, @conversation, message_params).perform
|
||||||
|
|||||||
@ -94,11 +94,10 @@ class Messages::Instagram::BaseMessageBuilder < Messages::Messenger::MessageBuil
|
|||||||
def build_message
|
def build_message
|
||||||
# Duplicate webhook events may be sent for the same message
|
# Duplicate webhook events may be sent for the same message
|
||||||
# when a user is connected to the Instagram account through both Messenger and Instagram login.
|
# when a user is connected to the Instagram account through both Messenger and Instagram login.
|
||||||
|
# There is chance for echo events to be sent for the same message.
|
||||||
# Therefore, we need to check if the message already exists before creating it.
|
# Therefore, we need to check if the message already exists before creating it.
|
||||||
return if message_already_exists?
|
return if message_already_exists?
|
||||||
|
|
||||||
return if @outgoing_echo
|
|
||||||
|
|
||||||
return if message_content.blank? && all_unsupported_files?
|
return if message_content.blank? && all_unsupported_files?
|
||||||
|
|
||||||
@message = conversation.messages.create!(message_params)
|
@message = conversation.messages.create!(message_params)
|
||||||
|
|||||||
@ -37,7 +37,7 @@ class Api::V1::Accounts::AgentBotsController < Api::V1::Accounts::BaseController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def permitted_params
|
def permitted_params
|
||||||
params.permit(:name, :description, :outgoing_url, :avatar, :avatar_url, :bot_type, bot_config: [:csml_content])
|
params.permit(:name, :description, :outgoing_url, :avatar, :avatar_url, :bot_type, bot_config: {})
|
||||||
end
|
end
|
||||||
|
|
||||||
def process_avatar_from_url
|
def process_avatar_from_url
|
||||||
|
|||||||
@ -36,7 +36,7 @@ class DashboardController < ActionController::Base
|
|||||||
'LOGOUT_REDIRECT_LINK',
|
'LOGOUT_REDIRECT_LINK',
|
||||||
'DISABLE_USER_PROFILE_UPDATE',
|
'DISABLE_USER_PROFILE_UPDATE',
|
||||||
'DEPLOYMENT_ENV',
|
'DEPLOYMENT_ENV',
|
||||||
'CSML_EDITOR_HOST', 'INSTALLATION_PRICING_PLAN'
|
'INSTALLATION_PRICING_PLAN'
|
||||||
).merge(app_config)
|
).merge(app_config)
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -65,6 +65,7 @@ class DashboardController < ActionController::Base
|
|||||||
VAPID_PUBLIC_KEY: VapidService.public_key,
|
VAPID_PUBLIC_KEY: VapidService.public_key,
|
||||||
ENABLE_ACCOUNT_SIGNUP: GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false'),
|
ENABLE_ACCOUNT_SIGNUP: GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false'),
|
||||||
FB_APP_ID: GlobalConfigService.load('FB_APP_ID', ''),
|
FB_APP_ID: GlobalConfigService.load('FB_APP_ID', ''),
|
||||||
|
INSTAGRAM_APP_ID: GlobalConfigService.load('INSTAGRAM_APP_ID', ''),
|
||||||
FACEBOOK_API_VERSION: GlobalConfigService.load('FACEBOOK_API_VERSION', 'v17.0'),
|
FACEBOOK_API_VERSION: GlobalConfigService.load('FACEBOOK_API_VERSION', 'v17.0'),
|
||||||
IS_ENTERPRISE: ChatwootApp.enterprise?,
|
IS_ENTERPRISE: ChatwootApp.enterprise?,
|
||||||
AZURE_APP_ID: GlobalConfigService.load('AZURE_APP_ID', ''),
|
AZURE_APP_ID: GlobalConfigService.load('AZURE_APP_ID', ''),
|
||||||
|
|||||||
@ -55,7 +55,7 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa
|
|||||||
|
|
||||||
def validate_business_account?
|
def validate_business_account?
|
||||||
# return true if the user is a business account, false if it is a gmail account
|
# return true if the user is a business account, false if it is a gmail account
|
||||||
auth_hash['info']['email'].exclude?('@gmail.com')
|
auth_hash['info']['email'].downcase.exclude?('@gmail.com')
|
||||||
end
|
end
|
||||||
|
|
||||||
def create_account_for_user
|
def create_account_for_user
|
||||||
|
|||||||
@ -1,9 +1,26 @@
|
|||||||
|
/* global axios */
|
||||||
import ApiClient from './ApiClient';
|
import ApiClient from './ApiClient';
|
||||||
|
|
||||||
class AgentBotsAPI extends ApiClient {
|
class AgentBotsAPI extends ApiClient {
|
||||||
constructor() {
|
constructor() {
|
||||||
super('agent_bots', { accountScoped: true });
|
super('agent_bots', { accountScoped: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
create(data) {
|
||||||
|
return axios.post(this.url, data, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
update(id, data) {
|
||||||
|
return axios.patch(`${this.url}/${id}`, data, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteAgentBotAvatar(botId) {
|
||||||
|
return axios.delete(`${this.url}/${botId}/avatar`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default new AgentBotsAPI();
|
export default new AgentBotsAPI();
|
||||||
|
|||||||
@ -7,8 +7,8 @@ import { useStore, useStoreGetters } from 'dashboard/composables/store';
|
|||||||
import { uploadFile } from 'dashboard/helper/uploadHelper';
|
import { uploadFile } from 'dashboard/helper/uploadHelper';
|
||||||
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
|
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
|
||||||
import { useVuelidate } from '@vuelidate/core';
|
import { useVuelidate } from '@vuelidate/core';
|
||||||
import { required, minLength } from '@vuelidate/validators';
|
import { required, minLength, helpers } from '@vuelidate/validators';
|
||||||
import { shouldBeUrl } from 'shared/helpers/Validators';
|
import { shouldBeUrl, isValidSlug } from 'shared/helpers/Validators';
|
||||||
|
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
import Input from 'dashboard/components-next/input/Input.vue';
|
import Input from 'dashboard/components-next/input/Input.vue';
|
||||||
@ -61,7 +61,16 @@ const liveChatWidgets = computed(() => {
|
|||||||
|
|
||||||
const rules = {
|
const rules = {
|
||||||
name: { required, minLength: minLength(2) },
|
name: { required, minLength: minLength(2) },
|
||||||
slug: { required },
|
slug: {
|
||||||
|
required: helpers.withMessage(
|
||||||
|
() => t('HELP_CENTER.CREATE_PORTAL_DIALOG.SLUG.ERROR'),
|
||||||
|
required
|
||||||
|
),
|
||||||
|
isValidSlug: helpers.withMessage(
|
||||||
|
() => t('HELP_CENTER.CREATE_PORTAL_DIALOG.SLUG.FORMAT_ERROR'),
|
||||||
|
isValidSlug
|
||||||
|
),
|
||||||
|
},
|
||||||
homePageLink: { shouldBeUrl },
|
homePageLink: { shouldBeUrl },
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -71,9 +80,9 @@ const nameError = computed(() =>
|
|||||||
v$.value.name.$error ? t('HELP_CENTER.CREATE_PORTAL_DIALOG.NAME.ERROR') : ''
|
v$.value.name.$error ? t('HELP_CENTER.CREATE_PORTAL_DIALOG.NAME.ERROR') : ''
|
||||||
);
|
);
|
||||||
|
|
||||||
const slugError = computed(() =>
|
const slugError = computed(() => {
|
||||||
v$.value.slug.$error ? t('HELP_CENTER.CREATE_PORTAL_DIALOG.SLUG.ERROR') : ''
|
return v$.value.slug.$errors[0]?.$message || '';
|
||||||
);
|
});
|
||||||
|
|
||||||
const homePageLinkError = computed(() =>
|
const homePageLinkError = computed(() =>
|
||||||
v$.value.homePageLink.$error
|
v$.value.homePageLink.$error
|
||||||
|
|||||||
@ -6,8 +6,9 @@ import { useAlert, useTrack } from 'dashboard/composables';
|
|||||||
import { PORTALS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
import { PORTALS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||||
import { convertToCategorySlug } from 'dashboard/helper/commons.js';
|
import { convertToCategorySlug } from 'dashboard/helper/commons.js';
|
||||||
import { useVuelidate } from '@vuelidate/core';
|
import { useVuelidate } from '@vuelidate/core';
|
||||||
import { required, minLength } from '@vuelidate/validators';
|
import { required, minLength, helpers } from '@vuelidate/validators';
|
||||||
import { buildPortalURL } from 'dashboard/helper/portalHelper';
|
import { buildPortalURL } from 'dashboard/helper/portalHelper';
|
||||||
|
import { isValidSlug } from 'shared/helpers/Validators';
|
||||||
|
|
||||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||||
import Input from 'dashboard/components-next/input/Input.vue';
|
import Input from 'dashboard/components-next/input/Input.vue';
|
||||||
@ -31,7 +32,16 @@ const state = reactive({
|
|||||||
|
|
||||||
const rules = {
|
const rules = {
|
||||||
name: { required, minLength: minLength(2) },
|
name: { required, minLength: minLength(2) },
|
||||||
slug: { required },
|
slug: {
|
||||||
|
required: helpers.withMessage(
|
||||||
|
() => t('HELP_CENTER.CREATE_PORTAL_DIALOG.SLUG.ERROR'),
|
||||||
|
required
|
||||||
|
),
|
||||||
|
isValidSlug: helpers.withMessage(
|
||||||
|
() => t('HELP_CENTER.CREATE_PORTAL_DIALOG.SLUG.FORMAT_ERROR'),
|
||||||
|
isValidSlug
|
||||||
|
),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const v$ = useVuelidate(rules, state);
|
const v$ = useVuelidate(rules, state);
|
||||||
@ -40,9 +50,9 @@ const nameError = computed(() =>
|
|||||||
v$.value.name.$error ? t('HELP_CENTER.CREATE_PORTAL_DIALOG.NAME.ERROR') : ''
|
v$.value.name.$error ? t('HELP_CENTER.CREATE_PORTAL_DIALOG.NAME.ERROR') : ''
|
||||||
);
|
);
|
||||||
|
|
||||||
const slugError = computed(() =>
|
const slugError = computed(() => {
|
||||||
v$.value.slug.$error ? t('HELP_CENTER.CREATE_PORTAL_DIALOG.SLUG.ERROR') : ''
|
return v$.value.slug.$errors[0]?.$message || '';
|
||||||
);
|
});
|
||||||
|
|
||||||
const isSubmitDisabled = computed(() => v$.value.$invalid);
|
const isSubmitDisabled = computed(() => v$.value.$invalid);
|
||||||
|
|
||||||
@ -131,6 +141,7 @@ defineExpose({ dialogRef });
|
|||||||
:message="
|
:message="
|
||||||
nameError || t('HELP_CENTER.CREATE_PORTAL_DIALOG.NAME.MESSAGE')
|
nameError || t('HELP_CENTER.CREATE_PORTAL_DIALOG.NAME.MESSAGE')
|
||||||
"
|
"
|
||||||
|
@blur="v$.name.$touch()"
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
id="portal-slug"
|
id="portal-slug"
|
||||||
@ -140,6 +151,8 @@ defineExpose({ dialogRef });
|
|||||||
:label="t('HELP_CENTER.CREATE_PORTAL_DIALOG.SLUG.LABEL')"
|
:label="t('HELP_CENTER.CREATE_PORTAL_DIALOG.SLUG.LABEL')"
|
||||||
:message-type="slugError ? 'error' : 'info'"
|
:message-type="slugError ? 'error' : 'info'"
|
||||||
:message="slugError || buildPortalURL(state.slug)"
|
:message="slugError || buildPortalURL(state.slug)"
|
||||||
|
@input="v$.slug.$touch()"
|
||||||
|
@blur="v$.slug.$touch()"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@ -33,6 +33,8 @@ const insertIntoRichEditor = computed(() => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const hasEmptyMessageContent = computed(() => !props.message?.content);
|
||||||
|
|
||||||
const useCopilotResponse = () => {
|
const useCopilotResponse = () => {
|
||||||
if (insertIntoRichEditor.value) {
|
if (insertIntoRichEditor.value) {
|
||||||
emitter.emit(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, props.message?.content);
|
emitter.emit(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, props.message?.content);
|
||||||
@ -53,9 +55,17 @@ const useCopilotResponse = () => {
|
|||||||
/>
|
/>
|
||||||
<div class="flex flex-col gap-1 text-n-slate-12">
|
<div class="flex flex-col gap-1 text-n-slate-12">
|
||||||
<div class="font-medium">{{ $t('CAPTAIN.NAME') }}</div>
|
<div class="font-medium">{{ $t('CAPTAIN.NAME') }}</div>
|
||||||
<div v-dompurify-html="messageContent" class="prose-sm break-words" />
|
<span v-if="hasEmptyMessageContent" class="text-n-ruby-11">
|
||||||
|
{{ $t('CAPTAIN.COPILOT.EMPTY_MESSAGE') }}
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
v-dompurify-html="messageContent"
|
||||||
|
class="prose-sm break-words"
|
||||||
|
/>
|
||||||
<div class="flex flex-row mt-1">
|
<div class="flex flex-row mt-1">
|
||||||
<Button
|
<Button
|
||||||
|
v-if="!hasEmptyMessageContent"
|
||||||
:label="$t('CAPTAIN.COPILOT.USE')"
|
:label="$t('CAPTAIN.COPILOT.USE')"
|
||||||
faded
|
faded
|
||||||
sm
|
sm
|
||||||
|
|||||||
@ -125,7 +125,10 @@ defineExpose({ open, close });
|
|||||||
<slot />
|
<slot />
|
||||||
<!-- Dialog content will be injected here -->
|
<!-- Dialog content will be injected here -->
|
||||||
<slot name="footer">
|
<slot name="footer">
|
||||||
<div class="flex items-center justify-between w-full gap-3">
|
<div
|
||||||
|
v-if="showCancelButton || showConfirmButton"
|
||||||
|
class="flex items-center justify-between w-full gap-3"
|
||||||
|
>
|
||||||
<Button
|
<Button
|
||||||
v-if="showCancelButton"
|
v-if="showCancelButton"
|
||||||
variant="faded"
|
variant="faded"
|
||||||
|
|||||||
@ -19,11 +19,17 @@ const {
|
|||||||
isAWebWidgetInbox,
|
isAWebWidgetInbox,
|
||||||
isAWhatsAppChannel,
|
isAWhatsAppChannel,
|
||||||
isAnEmailChannel,
|
isAnEmailChannel,
|
||||||
isAInstagramChannel,
|
isAnInstagramChannel,
|
||||||
} = useInbox();
|
} = useInbox();
|
||||||
|
|
||||||
const { status, isPrivate, createdAt, sourceId, messageType } =
|
const {
|
||||||
useMessageContext();
|
status,
|
||||||
|
isPrivate,
|
||||||
|
createdAt,
|
||||||
|
sourceId,
|
||||||
|
messageType,
|
||||||
|
contentAttributes,
|
||||||
|
} = useMessageContext();
|
||||||
|
|
||||||
const readableTime = computed(() =>
|
const readableTime = computed(() =>
|
||||||
messageTimestamp(createdAt.value, 'LLL d, h:mm a')
|
messageTimestamp(createdAt.value, 'LLL d, h:mm a')
|
||||||
@ -31,6 +37,11 @@ const readableTime = computed(() =>
|
|||||||
|
|
||||||
const showStatusIndicator = computed(() => {
|
const showStatusIndicator = computed(() => {
|
||||||
if (isPrivate.value) return false;
|
if (isPrivate.value) return false;
|
||||||
|
// Don't show status for failed messages, we already show error message
|
||||||
|
if (status.value === MESSAGE_STATUS.FAILED) return false;
|
||||||
|
// Don't show status for deleted messages
|
||||||
|
if (contentAttributes.value?.deleted) return false;
|
||||||
|
|
||||||
if (messageType.value === MESSAGE_TYPES.OUTGOING) return true;
|
if (messageType.value === MESSAGE_TYPES.OUTGOING) return true;
|
||||||
if (messageType.value === MESSAGE_TYPES.TEMPLATE) return true;
|
if (messageType.value === MESSAGE_TYPES.TEMPLATE) return true;
|
||||||
|
|
||||||
@ -49,7 +60,7 @@ const isSent = computed(() => {
|
|||||||
isAFacebookInbox.value ||
|
isAFacebookInbox.value ||
|
||||||
isASmsInbox.value ||
|
isASmsInbox.value ||
|
||||||
isATelegramChannel.value ||
|
isATelegramChannel.value ||
|
||||||
isAInstagramChannel.value
|
isAnInstagramChannel.value
|
||||||
) {
|
) {
|
||||||
return sourceId.value && status.value === MESSAGE_STATUS.SENT;
|
return sourceId.value && status.value === MESSAGE_STATUS.SENT;
|
||||||
}
|
}
|
||||||
@ -89,7 +100,7 @@ const isRead = computed(() => {
|
|||||||
isAWhatsAppChannel.value ||
|
isAWhatsAppChannel.value ||
|
||||||
isATwilioChannel.value ||
|
isATwilioChannel.value ||
|
||||||
isAFacebookInbox.value ||
|
isAFacebookInbox.value ||
|
||||||
isAInstagramChannel.value
|
isAnInstagramChannel.value
|
||||||
) {
|
) {
|
||||||
return sourceId.value && status.value === MESSAGE_STATUS.READ;
|
return sourceId.value && status.value === MESSAGE_STATUS.READ;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,24 @@
|
|||||||
|
<script setup>
|
||||||
|
import { defineProps, defineEmits } from 'vue';
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
showingOriginal: Boolean,
|
||||||
|
});
|
||||||
|
|
||||||
|
defineEmits(['toggle']);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span>
|
||||||
|
<span
|
||||||
|
class="text-xs text-n-slate-11 cursor-pointer hover:underline select-none"
|
||||||
|
@click="$emit('toggle')"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
showingOriginal
|
||||||
|
? $t('CONVERSATION.VIEW_TRANSLATED')
|
||||||
|
: $t('CONVERSATION.VIEW_ORIGINAL')
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
@ -9,9 +9,11 @@ import BaseBubble from 'next/message/bubbles/Base.vue';
|
|||||||
import FormattedContent from 'next/message/bubbles/Text/FormattedContent.vue';
|
import FormattedContent from 'next/message/bubbles/Text/FormattedContent.vue';
|
||||||
import AttachmentChips from 'next/message/chips/AttachmentChips.vue';
|
import AttachmentChips from 'next/message/chips/AttachmentChips.vue';
|
||||||
import EmailMeta from './EmailMeta.vue';
|
import EmailMeta from './EmailMeta.vue';
|
||||||
|
import TranslationToggle from 'dashboard/components-next/message/TranslationToggle.vue';
|
||||||
|
|
||||||
import { useMessageContext } from '../../provider.js';
|
import { useMessageContext } from '../../provider.js';
|
||||||
import { MESSAGE_TYPES } from 'next/message/constants.js';
|
import { MESSAGE_TYPES } from 'next/message/constants.js';
|
||||||
|
import { useTranslations } from 'dashboard/composables/useTranslations';
|
||||||
|
|
||||||
const { content, contentAttributes, attachments, messageType } =
|
const { content, contentAttributes, attachments, messageType } =
|
||||||
useMessageContext();
|
useMessageContext();
|
||||||
@ -19,35 +21,77 @@ const { content, contentAttributes, attachments, messageType } =
|
|||||||
const isExpandable = ref(false);
|
const isExpandable = ref(false);
|
||||||
const isExpanded = ref(false);
|
const isExpanded = ref(false);
|
||||||
const showQuotedMessage = ref(false);
|
const showQuotedMessage = ref(false);
|
||||||
|
const renderOriginal = ref(false);
|
||||||
const contentContainer = useTemplateRef('contentContainer');
|
const contentContainer = useTemplateRef('contentContainer');
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
isExpandable.value = contentContainer.value?.scrollHeight > 400;
|
isExpandable.value = contentContainer.value?.scrollHeight > 400;
|
||||||
});
|
});
|
||||||
|
|
||||||
const isOutgoing = computed(() => {
|
const isOutgoing = computed(() => messageType.value === MESSAGE_TYPES.OUTGOING);
|
||||||
return messageType.value === MESSAGE_TYPES.OUTGOING;
|
|
||||||
});
|
|
||||||
const isIncoming = computed(() => !isOutgoing.value);
|
const isIncoming = computed(() => !isOutgoing.value);
|
||||||
|
|
||||||
const textToShow = computed(() => {
|
const { hasTranslations, translationContent } =
|
||||||
|
useTranslations(contentAttributes);
|
||||||
|
|
||||||
|
const originalEmailText = computed(() => {
|
||||||
const text =
|
const text =
|
||||||
contentAttributes?.value?.email?.textContent?.full ?? content.value;
|
contentAttributes?.value?.email?.textContent?.full ?? content.value;
|
||||||
return text?.replace(/\n/g, '<br>');
|
return text?.replace(/\n/g, '<br>');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Use TextContent as the default to fullHTML
|
const originalEmailHtml = computed(
|
||||||
|
() =>
|
||||||
|
contentAttributes?.value?.email?.htmlContent?.full ??
|
||||||
|
originalEmailText.value
|
||||||
|
);
|
||||||
|
|
||||||
|
const messageContent = computed(() => {
|
||||||
|
// If translations exist and we're showing translations (not original)
|
||||||
|
if (hasTranslations.value && !renderOriginal.value) {
|
||||||
|
return translationContent.value;
|
||||||
|
}
|
||||||
|
// Otherwise show original content
|
||||||
|
return content.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
const textToShow = computed(() => {
|
||||||
|
// If translations exist and we're showing translations (not original)
|
||||||
|
if (hasTranslations.value && !renderOriginal.value) {
|
||||||
|
return translationContent.value;
|
||||||
|
}
|
||||||
|
// Otherwise show original text
|
||||||
|
return originalEmailText.value;
|
||||||
|
});
|
||||||
|
|
||||||
const fullHTML = computed(() => {
|
const fullHTML = computed(() => {
|
||||||
return contentAttributes?.value?.email?.htmlContent?.full ?? textToShow.value;
|
// If translations exist and we're showing translations (not original)
|
||||||
|
if (hasTranslations.value && !renderOriginal.value) {
|
||||||
|
return translationContent.value;
|
||||||
|
}
|
||||||
|
// Otherwise show original HTML
|
||||||
|
return originalEmailHtml.value;
|
||||||
});
|
});
|
||||||
|
|
||||||
const unquotedHTML = computed(() => {
|
const unquotedHTML = computed(() =>
|
||||||
return EmailQuoteExtractor.extractQuotes(fullHTML.value);
|
EmailQuoteExtractor.extractQuotes(fullHTML.value)
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasQuotedMessage = computed(() =>
|
||||||
|
EmailQuoteExtractor.hasQuotes(fullHTML.value)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Ensure unique keys for <Letter> when toggling between original and translated views.
|
||||||
|
// This forces Vue to re-render the component and update content correctly.
|
||||||
|
const translationKeySuffix = computed(() => {
|
||||||
|
if (renderOriginal.value) return 'original';
|
||||||
|
if (hasTranslations.value) return 'translated';
|
||||||
|
return 'original';
|
||||||
});
|
});
|
||||||
|
|
||||||
const hasQuotedMessage = computed(() => {
|
const handleSeeOriginal = () => {
|
||||||
return EmailQuoteExtractor.hasQuotes(fullHTML.value);
|
renderOriginal.value = !renderOriginal.value;
|
||||||
});
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@ -75,7 +119,7 @@ const hasQuotedMessage = computed(() => {
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-if="isExpandable && !isExpanded"
|
v-if="isExpandable && !isExpanded"
|
||||||
class="absolute left-0 right-0 bottom-0 h-40 px-8 flex items-end bg-gradient-to-t from-n-gray-3 via-n-gray-3 via-20% to-transparent"
|
class="absolute left-0 right-0 bottom-0 h-40 px-8 flex items-end bg-gradient-to-t from-n-slate-4 via-n-slate-4 via-20% to-transparent"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
class="text-n-slate-12 py-2 px-8 mx-auto text-center flex items-center gap-2"
|
class="text-n-slate-12 py-2 px-8 mx-auto text-center flex items-center gap-2"
|
||||||
@ -88,11 +132,12 @@ const hasQuotedMessage = computed(() => {
|
|||||||
<FormattedContent
|
<FormattedContent
|
||||||
v-if="isOutgoing && content"
|
v-if="isOutgoing && content"
|
||||||
class="text-n-slate-12"
|
class="text-n-slate-12"
|
||||||
:content="content"
|
:content="messageContent"
|
||||||
/>
|
/>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<Letter
|
<Letter
|
||||||
v-if="showQuotedMessage"
|
v-if="showQuotedMessage"
|
||||||
|
:key="`letter-quoted-${translationKeySuffix}`"
|
||||||
class-name="prose prose-bubble !max-w-none letter-render"
|
class-name="prose prose-bubble !max-w-none letter-render"
|
||||||
:allowed-css-properties="[
|
:allowed-css-properties="[
|
||||||
...allowedCssProperties,
|
...allowedCssProperties,
|
||||||
@ -104,6 +149,7 @@ const hasQuotedMessage = computed(() => {
|
|||||||
/>
|
/>
|
||||||
<Letter
|
<Letter
|
||||||
v-else
|
v-else
|
||||||
|
:key="`letter-unquoted-${translationKeySuffix}`"
|
||||||
class-name="prose prose-bubble !max-w-none letter-render"
|
class-name="prose prose-bubble !max-w-none letter-render"
|
||||||
:html="unquotedHTML"
|
:html="unquotedHTML"
|
||||||
:allowed-css-properties="[
|
:allowed-css-properties="[
|
||||||
@ -135,6 +181,12 @@ const hasQuotedMessage = computed(() => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
<TranslationToggle
|
||||||
|
v-if="hasTranslations"
|
||||||
|
class="py-2 px-3"
|
||||||
|
:showing-original="renderOriginal"
|
||||||
|
@toggle="handleSeeOriginal"
|
||||||
|
/>
|
||||||
<section
|
<section
|
||||||
v-if="Array.isArray(attachments) && attachments.length"
|
v-if="Array.isArray(attachments) && attachments.length"
|
||||||
class="px-4 pb-4 space-y-2"
|
class="px-4 pb-4 space-y-2"
|
||||||
|
|||||||
@ -3,16 +3,16 @@ import { computed, ref } from 'vue';
|
|||||||
import BaseBubble from 'next/message/bubbles/Base.vue';
|
import BaseBubble from 'next/message/bubbles/Base.vue';
|
||||||
import FormattedContent from './FormattedContent.vue';
|
import FormattedContent from './FormattedContent.vue';
|
||||||
import AttachmentChips from 'next/message/chips/AttachmentChips.vue';
|
import AttachmentChips from 'next/message/chips/AttachmentChips.vue';
|
||||||
|
import TranslationToggle from 'dashboard/components-next/message/TranslationToggle.vue';
|
||||||
import { MESSAGE_TYPES } from '../../constants';
|
import { MESSAGE_TYPES } from '../../constants';
|
||||||
import { useMessageContext } from '../../provider.js';
|
import { useMessageContext } from '../../provider.js';
|
||||||
|
import { useTranslations } from 'dashboard/composables/useTranslations';
|
||||||
|
|
||||||
const { content, attachments, contentAttributes, messageType } =
|
const { content, attachments, contentAttributes, messageType } =
|
||||||
useMessageContext();
|
useMessageContext();
|
||||||
|
|
||||||
const hasTranslations = computed(() => {
|
const { hasTranslations, translationContent } =
|
||||||
const { translations = {} } = contentAttributes.value;
|
useTranslations(contentAttributes);
|
||||||
return Object.keys(translations || {}).length > 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
const renderOriginal = ref(false);
|
const renderOriginal = ref(false);
|
||||||
|
|
||||||
@ -22,8 +22,7 @@ const renderContent = computed(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (hasTranslations.value) {
|
if (hasTranslations.value) {
|
||||||
const translations = contentAttributes.value.translations;
|
return translationContent.value;
|
||||||
return translations[Object.keys(translations)[0]];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return content.value;
|
return content.value;
|
||||||
@ -37,12 +36,6 @@ const isEmpty = computed(() => {
|
|||||||
return !content.value && !attachments.value?.length;
|
return !content.value && !attachments.value?.length;
|
||||||
});
|
});
|
||||||
|
|
||||||
const viewToggleKey = computed(() => {
|
|
||||||
return renderOriginal.value
|
|
||||||
? 'CONVERSATION.VIEW_TRANSLATED'
|
|
||||||
: 'CONVERSATION.VIEW_ORIGINAL';
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleSeeOriginal = () => {
|
const handleSeeOriginal = () => {
|
||||||
renderOriginal.value = !renderOriginal.value;
|
renderOriginal.value = !renderOriginal.value;
|
||||||
};
|
};
|
||||||
@ -55,15 +48,12 @@ const handleSeeOriginal = () => {
|
|||||||
{{ $t('CONVERSATION.NO_CONTENT') }}
|
{{ $t('CONVERSATION.NO_CONTENT') }}
|
||||||
</span>
|
</span>
|
||||||
<FormattedContent v-if="renderContent" :content="renderContent" />
|
<FormattedContent v-if="renderContent" :content="renderContent" />
|
||||||
<span class="-mt-3">
|
<TranslationToggle
|
||||||
<span
|
v-if="hasTranslations"
|
||||||
v-if="hasTranslations"
|
class="-mt-3"
|
||||||
class="text-xs text-n-slate-11 cursor-pointer hover:underline"
|
:showing-original="renderOriginal"
|
||||||
@click="handleSeeOriginal"
|
@toggle="handleSeeOriginal"
|
||||||
>
|
/>
|
||||||
{{ $t(viewToggleKey) }}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<AttachmentChips :attachments="attachments" class="gap-2" />
|
<AttachmentChips :attachments="attachments" class="gap-2" />
|
||||||
<template v-if="isTemplate">
|
<template v-if="isTemplate">
|
||||||
<div
|
<div
|
||||||
|
|||||||
@ -39,7 +39,7 @@ const textColorClass = computed(() => {
|
|||||||
docx: 'dark:text-[#D6E1FF] text-[#1F2D5C]', // indigo-12
|
docx: 'dark:text-[#D6E1FF] text-[#1F2D5C]', // indigo-12
|
||||||
json: 'text-n-slate-12',
|
json: 'text-n-slate-12',
|
||||||
odt: 'dark:text-[#D6E1FF] text-[#1F2D5C]', // indigo-12
|
odt: 'dark:text-[#D6E1FF] text-[#1F2D5C]', // indigo-12
|
||||||
pdf: 'text-n-ruby-12',
|
pdf: 'text-n-slate-12',
|
||||||
ppt: 'dark:text-[#FFE0C2] text-[#582D1D]',
|
ppt: 'dark:text-[#FFE0C2] text-[#582D1D]',
|
||||||
pptx: 'dark:text-[#FFE0C2] text-[#582D1D]',
|
pptx: 'dark:text-[#FFE0C2] text-[#582D1D]',
|
||||||
rar: 'dark:text-[#EDEEF0] text-[#2F265F]',
|
rar: 'dark:text-[#EDEEF0] text-[#2F265F]',
|
||||||
|
|||||||
@ -8,7 +8,6 @@ import { useStore } from 'vuex';
|
|||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useStorage } from '@vueuse/core';
|
import { useStorage } from '@vueuse/core';
|
||||||
import { useSidebarKeyboardShortcuts } from './useSidebarKeyboardShortcuts';
|
import { useSidebarKeyboardShortcuts } from './useSidebarKeyboardShortcuts';
|
||||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
|
||||||
|
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
import SidebarGroup from './SidebarGroup.vue';
|
import SidebarGroup from './SidebarGroup.vue';
|
||||||
@ -37,18 +36,6 @@ const toggleShortcutModalFn = show => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const currentAccountId = useMapGetter('getCurrentAccountId');
|
|
||||||
const isFeatureEnabledonAccount = useMapGetter(
|
|
||||||
'accounts/isFeatureEnabledonAccount'
|
|
||||||
);
|
|
||||||
|
|
||||||
const showV4Routes = computed(() => {
|
|
||||||
return isFeatureEnabledonAccount.value(
|
|
||||||
currentAccountId.value,
|
|
||||||
FEATURE_FLAGS.REPORT_V4
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
useSidebarKeyboardShortcuts(toggleShortcutModalFn);
|
useSidebarKeyboardShortcuts(toggleShortcutModalFn);
|
||||||
|
|
||||||
// We're using localStorage to store the expanded item in the sidebar
|
// We're using localStorage to store the expanded item in the sidebar
|
||||||
@ -116,32 +103,7 @@ const newReportRoutes = () => [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const oldReportRoutes = () => [
|
const reportRoutes = computed(() => newReportRoutes());
|
||||||
{
|
|
||||||
name: 'Reports Agent',
|
|
||||||
label: t('SIDEBAR.REPORTS_AGENT'),
|
|
||||||
to: accountScopedRoute('agent_reports'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Reports Label',
|
|
||||||
label: t('SIDEBAR.REPORTS_LABEL'),
|
|
||||||
to: accountScopedRoute('label_reports'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Reports Inbox',
|
|
||||||
label: t('SIDEBAR.REPORTS_INBOX'),
|
|
||||||
to: accountScopedRoute('inbox_reports'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Reports Team',
|
|
||||||
label: t('SIDEBAR.REPORTS_TEAM'),
|
|
||||||
to: accountScopedRoute('team_reports'),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const reportRoutes = computed(() =>
|
|
||||||
showV4Routes.value ? newReportRoutes() : oldReportRoutes()
|
|
||||||
);
|
|
||||||
|
|
||||||
const menuItems = computed(() => {
|
const menuItems = computed(() => {
|
||||||
return [
|
return [
|
||||||
|
|||||||
@ -127,7 +127,6 @@ const settings = accountId => ({
|
|||||||
meta: {
|
meta: {
|
||||||
permissions: ['administrator'],
|
permissions: ['administrator'],
|
||||||
},
|
},
|
||||||
globalConfigFlag: 'csmlEditorHost',
|
|
||||||
toState: frontendURL(`accounts/${accountId}/settings/agent-bots`),
|
toState: frontendURL(`accounts/${accountId}/settings/agent-bots`),
|
||||||
toStateName: 'agent_bots',
|
toStateName: 'agent_bots',
|
||||||
featureFlag: FEATURE_FLAGS.AGENT_BOTS,
|
featureFlag: FEATURE_FLAGS.AGENT_BOTS,
|
||||||
|
|||||||
@ -49,13 +49,6 @@ export default {
|
|||||||
return !!this.menuItem.children;
|
return !!this.menuItem.children;
|
||||||
},
|
},
|
||||||
isMenuItemVisible() {
|
isMenuItemVisible() {
|
||||||
if (this.menuItem.globalConfigFlag) {
|
|
||||||
// this checks for the `csmlEditorHost` flag in the global config
|
|
||||||
// if this is present, we toggle the CSML editor menu item
|
|
||||||
// TODO: This is very specific, and can be handled better, fix it
|
|
||||||
return !!this.globalConfig[this.menuItem.globalConfigFlag];
|
|
||||||
}
|
|
||||||
|
|
||||||
let isFeatureEnabled = true;
|
let isFeatureEnabled = true;
|
||||||
if (this.menuItem.featureFlag) {
|
if (this.menuItem.featureFlag) {
|
||||||
isFeatureEnabled = this.isFeatureEnabledonAccount(
|
isFeatureEnabled = this.isFeatureEnabledonAccount(
|
||||||
|
|||||||
@ -17,6 +17,9 @@ export default {
|
|||||||
hasFbConfigured() {
|
hasFbConfigured() {
|
||||||
return window.chatwootConfig?.fbAppId;
|
return window.chatwootConfig?.fbAppId;
|
||||||
},
|
},
|
||||||
|
hasInstagramConfigured() {
|
||||||
|
return window.chatwootConfig?.instagramAppId;
|
||||||
|
},
|
||||||
isActive() {
|
isActive() {
|
||||||
const { key } = this.channel;
|
const { key } = this.channel;
|
||||||
if (Object.keys(this.enabledFeatures).length === 0) {
|
if (Object.keys(this.enabledFeatures).length === 0) {
|
||||||
@ -33,7 +36,9 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (key === 'instagram') {
|
if (key === 'instagram') {
|
||||||
return this.enabledFeatures.channel_instagram;
|
return (
|
||||||
|
this.enabledFeatures.channel_instagram && this.hasInstagramConfigured
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
|||||||
@ -202,7 +202,7 @@ export default {
|
|||||||
if (this.isALineChannel) {
|
if (this.isALineChannel) {
|
||||||
return ALLOWED_FILE_TYPES_FOR_LINE;
|
return ALLOWED_FILE_TYPES_FOR_LINE;
|
||||||
}
|
}
|
||||||
if (this.isAInstagramChannel || this.isInstagramDM) {
|
if (this.isAnInstagramChannel || this.isInstagramDM) {
|
||||||
return ALLOWED_FILE_TYPES_FOR_INSTAGRAM;
|
return ALLOWED_FILE_TYPES_FOR_INSTAGRAM;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -37,6 +37,7 @@ import { REPLY_POLICY } from 'shared/constants/links';
|
|||||||
import wootConstants from 'dashboard/constants/globals';
|
import wootConstants from 'dashboard/constants/globals';
|
||||||
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
|
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
|
||||||
import { FEATURE_FLAGS } from '../../../featureFlags';
|
import { FEATURE_FLAGS } from '../../../featureFlags';
|
||||||
|
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||||
import WhatsappBaileysLinkDeviceModal from '../../../routes/dashboard/settings/inbox/components/WhatsappBaileysLinkDeviceModal.vue';
|
import WhatsappBaileysLinkDeviceModal from '../../../routes/dashboard/settings/inbox/components/WhatsappBaileysLinkDeviceModal.vue';
|
||||||
|
|
||||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||||
@ -218,6 +219,22 @@ export default {
|
|||||||
return contactLastSeenAt;
|
return contactLastSeenAt;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Check there is a instagram inbox exists with the same instagram_id
|
||||||
|
hasDuplicateInstagramInbox() {
|
||||||
|
const instagramId = this.inbox.instagram_id;
|
||||||
|
const { additional_attributes: additionalAttributes = {} } = this.inbox;
|
||||||
|
const instagramInbox =
|
||||||
|
this.$store.getters['inboxes/getInstagramInboxByInstagramId'](
|
||||||
|
instagramId
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
this.inbox.channel_type === INBOX_TYPES.FB &&
|
||||||
|
additionalAttributes.type === 'instagram_direct_message' &&
|
||||||
|
instagramInbox
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
replyWindowBannerMessage() {
|
replyWindowBannerMessage() {
|
||||||
if (this.isAWhatsAppChannel) {
|
if (this.isAWhatsAppChannel) {
|
||||||
return this.$t('CONVERSATION.TWILIO_WHATSAPP_CAN_REPLY');
|
return this.$t('CONVERSATION.TWILIO_WHATSAPP_CAN_REPLY');
|
||||||
@ -227,15 +244,21 @@ export default {
|
|||||||
if (additionalAttributes) {
|
if (additionalAttributes) {
|
||||||
const {
|
const {
|
||||||
agent_reply_time_window_message: agentReplyTimeWindowMessage,
|
agent_reply_time_window_message: agentReplyTimeWindowMessage,
|
||||||
|
agent_reply_time_window: agentReplyTimeWindow,
|
||||||
} = additionalAttributes;
|
} = additionalAttributes;
|
||||||
return agentReplyTimeWindowMessage;
|
return (
|
||||||
|
agentReplyTimeWindowMessage ||
|
||||||
|
this.$t('CONVERSATION.API_HOURS_WINDOW', {
|
||||||
|
hours: agentReplyTimeWindow,
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
return this.$t('CONVERSATION.CANNOT_REPLY');
|
return this.$t('CONVERSATION.CANNOT_REPLY');
|
||||||
},
|
},
|
||||||
replyWindowLink() {
|
replyWindowLink() {
|
||||||
if (this.isAFacebookInbox || this.isAInstagramChannel) {
|
if (this.isAFacebookInbox || this.isAnInstagramChannel) {
|
||||||
return REPLY_POLICY.FACEBOOK;
|
return REPLY_POLICY.FACEBOOK;
|
||||||
}
|
}
|
||||||
if (this.isAWhatsAppCloudChannel) {
|
if (this.isAWhatsAppCloudChannel) {
|
||||||
@ -250,7 +273,7 @@ export default {
|
|||||||
if (
|
if (
|
||||||
this.isAWhatsAppChannel ||
|
this.isAWhatsAppChannel ||
|
||||||
this.isAFacebookInbox ||
|
this.isAFacebookInbox ||
|
||||||
this.isAInstagramChannel
|
this.isAnInstagramChannel
|
||||||
) {
|
) {
|
||||||
return this.$t('CONVERSATION.24_HOURS_WINDOW');
|
return this.$t('CONVERSATION.24_HOURS_WINDOW');
|
||||||
}
|
}
|
||||||
@ -545,6 +568,12 @@ export default {
|
|||||||
:href-link="replyWindowLink"
|
:href-link="replyWindowLink"
|
||||||
:href-link-text="replyWindowLinkText"
|
:href-link-text="replyWindowLinkText"
|
||||||
/>
|
/>
|
||||||
|
<Banner
|
||||||
|
v-else-if="hasDuplicateInstagramInbox"
|
||||||
|
color-scheme="alert"
|
||||||
|
class="mx-2 mt-2 overflow-hidden rounded-lg"
|
||||||
|
:banner-message="$t('CONVERSATION.OLD_INSTAGRAM_INBOX_REPLY_BANNER')"
|
||||||
|
/>
|
||||||
<div class="flex justify-end">
|
<div class="flex justify-end">
|
||||||
<NextButton
|
<NextButton
|
||||||
faded
|
faded
|
||||||
|
|||||||
@ -241,15 +241,27 @@ export default {
|
|||||||
if (this.isAFacebookInbox) {
|
if (this.isAFacebookInbox) {
|
||||||
return MESSAGE_MAX_LENGTH.FACEBOOK;
|
return MESSAGE_MAX_LENGTH.FACEBOOK;
|
||||||
}
|
}
|
||||||
|
if (this.isAnInstagramChannel) {
|
||||||
|
return MESSAGE_MAX_LENGTH.INSTAGRAM;
|
||||||
|
}
|
||||||
if (this.isATwilioWhatsAppChannel) {
|
if (this.isATwilioWhatsAppChannel) {
|
||||||
return MESSAGE_MAX_LENGTH.TWILIO_WHATSAPP;
|
return MESSAGE_MAX_LENGTH.TWILIO_WHATSAPP;
|
||||||
}
|
}
|
||||||
|
if (this.isAWhatsAppCloudChannel) {
|
||||||
|
return MESSAGE_MAX_LENGTH.WHATSAPP_CLOUD;
|
||||||
|
}
|
||||||
if (this.isASmsInbox) {
|
if (this.isASmsInbox) {
|
||||||
return MESSAGE_MAX_LENGTH.TWILIO_SMS;
|
return MESSAGE_MAX_LENGTH.TWILIO_SMS;
|
||||||
}
|
}
|
||||||
if (this.isAnEmailChannel) {
|
if (this.isAnEmailChannel) {
|
||||||
return MESSAGE_MAX_LENGTH.EMAIL;
|
return MESSAGE_MAX_LENGTH.EMAIL;
|
||||||
}
|
}
|
||||||
|
if (this.isATwilioSMSChannel) {
|
||||||
|
return MESSAGE_MAX_LENGTH.TWILIO_SMS;
|
||||||
|
}
|
||||||
|
if (this.isAWhatsAppChannel) {
|
||||||
|
return MESSAGE_MAX_LENGTH.WHATSAPP_CLOUD;
|
||||||
|
}
|
||||||
return MESSAGE_MAX_LENGTH.GENERAL;
|
return MESSAGE_MAX_LENGTH.GENERAL;
|
||||||
},
|
},
|
||||||
showFileUpload() {
|
showFileUpload() {
|
||||||
@ -262,7 +274,7 @@ export default {
|
|||||||
this.isASmsInbox ||
|
this.isASmsInbox ||
|
||||||
this.isATelegramChannel ||
|
this.isATelegramChannel ||
|
||||||
this.isALineChannel ||
|
this.isALineChannel ||
|
||||||
this.isAInstagramChannel
|
this.isAnInstagramChannel
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
replyButtonLabel() {
|
replyButtonLabel() {
|
||||||
@ -388,9 +400,14 @@ export default {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
currentChat(conversation) {
|
currentChat(conversation, oldConversation) {
|
||||||
const { can_reply: canReply } = conversation;
|
const { can_reply: canReply } = conversation;
|
||||||
this.setCCAndToEmailsFromLastChat();
|
if (oldConversation && oldConversation.id !== conversation.id) {
|
||||||
|
// Only update email fields when switching to a completely different conversation (by ID)
|
||||||
|
// This prevents overwriting user input (e.g., CC/BCC fields) when performing actions
|
||||||
|
// like self-assign or other updates that do not actually change the conversation context
|
||||||
|
this.setCCAndToEmailsFromLastChat();
|
||||||
|
}
|
||||||
|
|
||||||
if (this.isOnPrivateNote) {
|
if (this.isOnPrivateNote) {
|
||||||
return;
|
return;
|
||||||
@ -406,13 +423,12 @@ export default {
|
|||||||
},
|
},
|
||||||
// When moving from one conversation to another, the store may not have the
|
// When moving from one conversation to another, the store may not have the
|
||||||
// list of all the messages. A fetch is subsequently made to get the messages.
|
// list of all the messages. A fetch is subsequently made to get the messages.
|
||||||
// However, this update does not trigger the `currentChat` watcher.
|
// This watcher handles two main cases:
|
||||||
// We can add a deep watcher to it, but then, that would be too broad of a net to cast
|
// 1. When switching conversations and messages are fetched/updated, ensures CC/BCC fields are set from the latest OUTGOING/INCOMING email (not activity/private messages).
|
||||||
// And would impact performance too. So we watch the messages directly.
|
// 2. Fixes and issue where CC/BCC fields could be reset/lost after assignment/activity actions or message mutations that did not represent a true email context change.
|
||||||
// The watcher here is `deep` too, because the messages array is mutated and
|
lastEmail: {
|
||||||
// not replaced. So, a shallow watcher would not catch the change.
|
handler(lastEmail) {
|
||||||
'currentChat.messages': {
|
if (!lastEmail) return;
|
||||||
handler() {
|
|
||||||
this.setCCAndToEmailsFromLastChat();
|
this.setCCAndToEmailsFromLastChat();
|
||||||
},
|
},
|
||||||
deep: true,
|
deep: true,
|
||||||
@ -686,7 +702,11 @@ export default {
|
|||||||
this.isATwilioWhatsAppChannel ||
|
this.isATwilioWhatsAppChannel ||
|
||||||
this.isAWhatsAppCloudChannel ||
|
this.isAWhatsAppCloudChannel ||
|
||||||
this.is360DialogWhatsAppChannel;
|
this.is360DialogWhatsAppChannel;
|
||||||
if (isOnWhatsApp && !this.isPrivate) {
|
// When users send messages containing both text and attachments on Instagram, Instagram treats them as separate messages.
|
||||||
|
// Although Chatwoot combines these into a single message, Instagram sends separate echo events for each component.
|
||||||
|
// This can create duplicate messages in Chatwoot. To prevent this issue, we'll handle text and attachments as separate messages.
|
||||||
|
const isOnInstagram = this.isAnInstagramChannel;
|
||||||
|
if ((isOnWhatsApp || isOnInstagram) && !this.isPrivate) {
|
||||||
this.sendMessageAsMultipleMessages(this.message);
|
this.sendMessageAsMultipleMessages(this.message);
|
||||||
} else {
|
} else {
|
||||||
const messagePayload = this.getMessagePayload(this.message);
|
const messagePayload = this.getMessagePayload(this.message);
|
||||||
@ -703,7 +723,7 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
sendMessageAsMultipleMessages(message) {
|
sendMessageAsMultipleMessages(message) {
|
||||||
const messages = this.getMessagePayloadForWhatsapp(message);
|
const messages = this.getMultipleMessagesPayload(message);
|
||||||
messages.forEach(messagePayload => {
|
messages.forEach(messagePayload => {
|
||||||
this.sendMessage(messagePayload);
|
this.sendMessage(messagePayload);
|
||||||
});
|
});
|
||||||
@ -935,11 +955,11 @@ export default {
|
|||||||
|
|
||||||
return payload;
|
return payload;
|
||||||
},
|
},
|
||||||
getMessagePayloadForWhatsapp(message) {
|
getMultipleMessagesPayload(message) {
|
||||||
const multipleMessagePayload = [];
|
const multipleMessagePayload = [];
|
||||||
|
|
||||||
if (this.attachedFiles && this.attachedFiles.length) {
|
if (this.attachedFiles && this.attachedFiles.length) {
|
||||||
let caption = message;
|
let caption = this.isAnInstagramChannel ? '' : message;
|
||||||
this.attachedFiles.forEach(attachment => {
|
this.attachedFiles.forEach(attachment => {
|
||||||
const attachedFile = this.globalConfig.directUploadsEnabled
|
const attachedFile = this.globalConfig.directUploadsEnabled
|
||||||
? attachment.blobSignedId
|
? attachment.blobSignedId
|
||||||
@ -954,9 +974,19 @@ export default {
|
|||||||
|
|
||||||
attachmentPayload = this.setReplyToInPayload(attachmentPayload);
|
attachmentPayload = this.setReplyToInPayload(attachmentPayload);
|
||||||
multipleMessagePayload.push(attachmentPayload);
|
multipleMessagePayload.push(attachmentPayload);
|
||||||
caption = '';
|
// For WhatsApp, only the first attachment gets a caption
|
||||||
|
if (!this.isAnInstagramChannel) caption = '';
|
||||||
});
|
});
|
||||||
} else {
|
}
|
||||||
|
|
||||||
|
const hasNoAttachments =
|
||||||
|
!this.attachedFiles || !this.attachedFiles.length;
|
||||||
|
// For Instagram, we need a separate text message
|
||||||
|
// For WhatsApp, we only need a text message if there are no attachments
|
||||||
|
if (
|
||||||
|
(this.isAnInstagramChannel && this.message) ||
|
||||||
|
(!this.isAnInstagramChannel && hasNoAttachments)
|
||||||
|
) {
|
||||||
let messagePayload = {
|
let messagePayload = {
|
||||||
conversationId: this.currentChat.id,
|
conversationId: this.currentChat.id,
|
||||||
message,
|
message,
|
||||||
@ -1136,7 +1166,7 @@ export default {
|
|||||||
v-else-if="!showRichContentEditor"
|
v-else-if="!showRichContentEditor"
|
||||||
ref="messageInput"
|
ref="messageInput"
|
||||||
v-model="message"
|
v-model="message"
|
||||||
class="input"
|
class="rounded-none input"
|
||||||
:placeholder="messagePlaceHolder"
|
:placeholder="messagePlaceHolder"
|
||||||
:min-height="4"
|
:min-height="4"
|
||||||
:signature="signatureToApply"
|
:signature="signatureToApply"
|
||||||
|
|||||||
@ -56,7 +56,7 @@ const unlinkIssue = () => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="absolute flex flex-col items-start bg-white dark:bg-slate-800 z-50 px-4 py-3 border border-solid border-ash-200 w-[384px] rounded-xl gap-4 max-h-96 overflow-auto"
|
class="absolute flex flex-col items-start bg-n-alpha-3 backdrop-blur-[100px] z-50 px-4 py-3 border border-solid border-n-container w-[384px] rounded-xl gap-4 max-h-96 overflow-auto"
|
||||||
>
|
>
|
||||||
<div class="flex flex-col w-full">
|
<div class="flex flex-col w-full">
|
||||||
<IssueHeader
|
<IssueHeader
|
||||||
@ -66,37 +66,37 @@ const unlinkIssue = () => {
|
|||||||
@unlink-issue="unlinkIssue"
|
@unlink-issue="unlinkIssue"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<span class="mt-2 text-sm font-medium text-ash-900">
|
<span class="mt-2 text-sm font-medium text-n-slate-12">
|
||||||
{{ issue.title }}
|
{{ issue.title }}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
v-if="issue.description"
|
v-if="issue.description"
|
||||||
class="mt-1 text-sm text-ash-800 line-clamp-3"
|
class="mt-1 text-sm text-n-slate-11 line-clamp-3"
|
||||||
>
|
>
|
||||||
{{ issue.description }}
|
{{ issue.description }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-row items-center h-6 gap-2">
|
<div class="flex flex-row items-center h-6 gap-2">
|
||||||
<UserAvatarWithName v-if="assignee" :user="assignee" class="py-1" />
|
<UserAvatarWithName v-if="assignee" :user="assignee" class="py-1" />
|
||||||
<div v-if="assignee" class="w-px h-3 bg-ash-200" />
|
<div v-if="assignee" class="w-px h-3 bg-n-slate-4" />
|
||||||
<div class="flex items-center gap-1 py-1">
|
<div class="flex items-center gap-1 py-1">
|
||||||
<fluent-icon
|
<fluent-icon
|
||||||
icon="status"
|
icon="status"
|
||||||
size="14"
|
size="14"
|
||||||
:style="{ color: issue.state.color }"
|
:style="{ color: issue.state.color }"
|
||||||
/>
|
/>
|
||||||
<h6 class="text-xs text-ash-900">
|
<h6 class="text-xs text-n-slate-12">
|
||||||
{{ issue.state.name }}
|
{{ issue.state.name }}
|
||||||
</h6>
|
</h6>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="priorityLabel" class="w-px h-3 bg-ash-200" />
|
<div v-if="priorityLabel" class="w-px h-3 bg-n-slate-4" />
|
||||||
<div v-if="priorityLabel" class="flex items-center gap-1 py-1">
|
<div v-if="priorityLabel" class="flex items-center gap-1 py-1">
|
||||||
<fluent-icon
|
<fluent-icon
|
||||||
:icon="`priority-${priorityLabel.toLowerCase()}`"
|
:icon="`priority-${priorityLabel.toLowerCase()}`"
|
||||||
size="14"
|
size="14"
|
||||||
view-box="0 0 12 12"
|
view-box="0 0 12 12"
|
||||||
/>
|
/>
|
||||||
<h6 class="text-xs text-ash-900">{{ priorityLabel }}</h6>
|
<h6 class="text-xs text-n-slate-12">{{ priorityLabel }}</h6>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="labels.length" class="flex flex-wrap items-center gap-1">
|
<div v-if="labels.length" class="flex flex-wrap items-center gap-1">
|
||||||
@ -111,7 +111,7 @@ const unlinkIssue = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<span class="text-xs text-ash-800">
|
<span class="text-xs text-n-slate-11">
|
||||||
{{
|
{{
|
||||||
$t('INTEGRATION_SETTINGS.LINEAR.ISSUE.CREATED_AT', {
|
$t('INTEGRATION_SETTINGS.LINEAR.ISSUE.CREATED_AT', {
|
||||||
createdAt: formattedDate,
|
createdAt: formattedDate,
|
||||||
|
|||||||
@ -100,14 +100,17 @@ onMounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="relative" :class="{ group: linkedIssue }">
|
<div
|
||||||
|
class="relative after:content-[''] after:h-5 after:bg-transparent after:top-5 after:w-full after:block after:absolute after:z-0"
|
||||||
|
:class="{ group: linkedIssue }"
|
||||||
|
>
|
||||||
<Button
|
<Button
|
||||||
v-on-clickaway="closeIssue"
|
v-on-clickaway="closeIssue"
|
||||||
v-tooltip="tooltipText"
|
v-tooltip="tooltipText"
|
||||||
sm
|
sm
|
||||||
ghost
|
ghost
|
||||||
slate
|
slate
|
||||||
class="!gap-1"
|
class="!gap-1 group-hover:bg-n-alpha-2"
|
||||||
@click="openIssue"
|
@click="openIssue"
|
||||||
>
|
>
|
||||||
<fluent-icon
|
<fluent-icon
|
||||||
@ -124,7 +127,7 @@ onMounted(() => {
|
|||||||
v-if="linkedIssue"
|
v-if="linkedIssue"
|
||||||
:issue="linkedIssue.issue"
|
:issue="linkedIssue.issue"
|
||||||
:link-id="linkedIssue.id"
|
:link-id="linkedIssue.id"
|
||||||
class="absolute right-0 top-[40px] invisible group-hover:visible"
|
class="absolute right-0 top-[36px] invisible group-hover:visible"
|
||||||
@unlink-issue="unlinkIssue"
|
@unlink-issue="unlinkIssue"
|
||||||
/>
|
/>
|
||||||
<woot-modal
|
<woot-modal
|
||||||
|
|||||||
@ -0,0 +1,39 @@
|
|||||||
|
import { ref } from 'vue';
|
||||||
|
import { useTranslations } from '../useTranslations';
|
||||||
|
|
||||||
|
describe('useTranslations', () => {
|
||||||
|
it('returns false and null when contentAttributes is null', () => {
|
||||||
|
const contentAttributes = ref(null);
|
||||||
|
const { hasTranslations, translationContent } =
|
||||||
|
useTranslations(contentAttributes);
|
||||||
|
expect(hasTranslations.value).toBe(false);
|
||||||
|
expect(translationContent.value).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false and null when translations are missing', () => {
|
||||||
|
const contentAttributes = ref({});
|
||||||
|
const { hasTranslations, translationContent } =
|
||||||
|
useTranslations(contentAttributes);
|
||||||
|
expect(hasTranslations.value).toBe(false);
|
||||||
|
expect(translationContent.value).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false and null when translations is an empty object', () => {
|
||||||
|
const contentAttributes = ref({ translations: {} });
|
||||||
|
const { hasTranslations, translationContent } =
|
||||||
|
useTranslations(contentAttributes);
|
||||||
|
expect(hasTranslations.value).toBe(false);
|
||||||
|
expect(translationContent.value).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns true and correct translation content when translations exist', () => {
|
||||||
|
const contentAttributes = ref({
|
||||||
|
translations: { en: 'Hello' },
|
||||||
|
});
|
||||||
|
const { hasTranslations, translationContent } =
|
||||||
|
useTranslations(contentAttributes);
|
||||||
|
expect(hasTranslations.value).toBe(true);
|
||||||
|
// Should return the first translation (en: 'Hello')
|
||||||
|
expect(translationContent.value).toBe('Hello');
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -128,7 +128,7 @@ export const useInbox = () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const isAInstagramChannel = computed(() => {
|
const isAnInstagramChannel = computed(() => {
|
||||||
return channelType.value === INBOX_TYPES.INSTAGRAM;
|
return channelType.value === INBOX_TYPES.INSTAGRAM;
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -149,6 +149,6 @@ export const useInbox = () => {
|
|||||||
is360DialogWhatsAppChannel,
|
is360DialogWhatsAppChannel,
|
||||||
isAWhatsAppBaileysChannel,
|
isAWhatsAppBaileysChannel,
|
||||||
isAnEmailChannel,
|
isAnEmailChannel,
|
||||||
isAInstagramChannel,
|
isAnInstagramChannel,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@ -7,8 +7,12 @@ import { formatTime } from '@chatwoot/utils';
|
|||||||
* @param {string} [accountSummaryKey='getAccountSummary'] - The key for accessing account summary data.
|
* @param {string} [accountSummaryKey='getAccountSummary'] - The key for accessing account summary data.
|
||||||
* @returns {Object} An object containing utility functions for report metrics.
|
* @returns {Object} An object containing utility functions for report metrics.
|
||||||
*/
|
*/
|
||||||
export function useReportMetrics(accountSummaryKey = 'getAccountSummary') {
|
export function useReportMetrics(
|
||||||
|
accountSummaryKey = 'getAccountSummary',
|
||||||
|
summarFetchingKey = 'getAccountSummaryFetchingStatus'
|
||||||
|
) {
|
||||||
const accountSummary = useMapGetter(accountSummaryKey);
|
const accountSummary = useMapGetter(accountSummaryKey);
|
||||||
|
const fetchingStatus = useMapGetter(summarFetchingKey);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculates the trend percentage for a given metric.
|
* Calculates the trend percentage for a given metric.
|
||||||
@ -53,5 +57,6 @@ export function useReportMetrics(accountSummaryKey = 'getAccountSummary') {
|
|||||||
calculateTrend,
|
calculateTrend,
|
||||||
isAverageMetricType,
|
isAverageMetricType,
|
||||||
displayMetric,
|
displayMetric,
|
||||||
|
fetchingStatus,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
22
app/javascript/dashboard/composables/useTranslations.js
Normal file
22
app/javascript/dashboard/composables/useTranslations.js
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composable to extract translation state/content from contentAttributes.
|
||||||
|
* @param {Ref|Reactive} contentAttributes - Ref or reactive object containing `translations` property
|
||||||
|
* @returns {Object} { hasTranslations, translationContent }
|
||||||
|
*/
|
||||||
|
export function useTranslations(contentAttributes) {
|
||||||
|
const hasTranslations = computed(() => {
|
||||||
|
if (!contentAttributes.value) return false;
|
||||||
|
const { translations = {} } = contentAttributes.value;
|
||||||
|
return Object.keys(translations || {}).length > 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
const translationContent = computed(() => {
|
||||||
|
if (!hasTranslations.value) return null;
|
||||||
|
const translations = contentAttributes.value.translations;
|
||||||
|
return translations[Object.keys(translations)[0]];
|
||||||
|
});
|
||||||
|
|
||||||
|
return { hasTranslations, translationContent };
|
||||||
|
}
|
||||||
@ -36,7 +36,7 @@ const useConversationSidebarItemsOrder = uiSettings => {
|
|||||||
const { conversation_sidebar_items_order: itemsOrder } = uiSettings.value;
|
const { conversation_sidebar_items_order: itemsOrder } = uiSettings.value;
|
||||||
// If the sidebar order is not set, use the default order.
|
// If the sidebar order is not set, use the default order.
|
||||||
if (!itemsOrder) {
|
if (!itemsOrder) {
|
||||||
return DEFAULT_CONVERSATION_SIDEBAR_ITEMS_ORDER;
|
return [...DEFAULT_CONVERSATION_SIDEBAR_ITEMS_ORDER];
|
||||||
}
|
}
|
||||||
// Create a copy of itemsOrder to avoid mutating the original store object.
|
// Create a copy of itemsOrder to avoid mutating the original store object.
|
||||||
const itemsOrderCopy = [...itemsOrder];
|
const itemsOrderCopy = [...itemsOrder];
|
||||||
|
|||||||
@ -1,6 +1,44 @@
|
|||||||
export const buildPortalURL = portalSlug => {
|
/**
|
||||||
const { hostURL, helpCenterURL } = window.chatwootConfig;
|
* Formats a custom domain with https protocol if needed
|
||||||
|
* @param {string} customDomain - The custom domain to format
|
||||||
|
* @returns {string} Formatted domain with https protocol
|
||||||
|
*/
|
||||||
|
const formatCustomDomain = customDomain =>
|
||||||
|
customDomain.startsWith('https') ? customDomain : `https://${customDomain}`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the default base URL from configuration
|
||||||
|
* @returns {string} The default base URL
|
||||||
|
* @throws {Error} If no valid base URL is found
|
||||||
|
*/
|
||||||
|
const getDefaultBaseURL = () => {
|
||||||
|
const { hostURL, helpCenterURL } = window.chatwootConfig || {};
|
||||||
const baseURL = helpCenterURL || hostURL || '';
|
const baseURL = helpCenterURL || hostURL || '';
|
||||||
|
|
||||||
|
if (!baseURL) {
|
||||||
|
throw new Error('No valid base URL found in configuration');
|
||||||
|
}
|
||||||
|
|
||||||
|
return baseURL;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the base URL from configuration or custom domain
|
||||||
|
* @param {string} [customDomain] - Optional custom domain for the portal
|
||||||
|
* @returns {string} The base URL for the portal
|
||||||
|
*/
|
||||||
|
const getPortalBaseURL = customDomain =>
|
||||||
|
customDomain ? formatCustomDomain(customDomain) : getDefaultBaseURL();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a portal URL using the provided portal slug and optional custom domain
|
||||||
|
* @param {string} portalSlug - The slug identifier for the portal
|
||||||
|
* @param {string} [customDomain] - Optional custom domain for the portal
|
||||||
|
* @returns {string} The complete portal URL
|
||||||
|
* @throws {Error} If portalSlug is not provided or invalid
|
||||||
|
*/
|
||||||
|
export const buildPortalURL = (portalSlug, customDomain) => {
|
||||||
|
const baseURL = getPortalBaseURL(customDomain);
|
||||||
return `${baseURL}/hc/${portalSlug}`;
|
return `${baseURL}/hc/${portalSlug}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -8,9 +46,10 @@ export const buildPortalArticleURL = (
|
|||||||
portalSlug,
|
portalSlug,
|
||||||
categorySlug,
|
categorySlug,
|
||||||
locale,
|
locale,
|
||||||
articleSlug
|
articleSlug,
|
||||||
|
customDomain
|
||||||
) => {
|
) => {
|
||||||
const portalURL = buildPortalURL(portalSlug);
|
const portalURL = buildPortalURL(portalSlug, customDomain);
|
||||||
return `${portalURL}/articles/${articleSlug}`;
|
return `${portalURL}/articles/${articleSlug}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -25,5 +25,47 @@ describe('PortalHelper', () => {
|
|||||||
).toEqual('https://help.chatwoot.com/hc/handbook/articles/article-slug');
|
).toEqual('https://help.chatwoot.com/hc/handbook/articles/article-slug');
|
||||||
window.chatwootConfig = {};
|
window.chatwootConfig = {};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('returns the correct url with custom domain', () => {
|
||||||
|
window.chatwootConfig = {
|
||||||
|
hostURL: 'https://app.chatwoot.com',
|
||||||
|
helpCenterURL: 'https://help.chatwoot.com',
|
||||||
|
};
|
||||||
|
expect(
|
||||||
|
buildPortalArticleURL(
|
||||||
|
'handbook',
|
||||||
|
'culture',
|
||||||
|
'fr',
|
||||||
|
'article-slug',
|
||||||
|
'custom-domain.dev'
|
||||||
|
)
|
||||||
|
).toEqual('https://custom-domain.dev/hc/handbook/articles/article-slug');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles https in custom domain correctly', () => {
|
||||||
|
window.chatwootConfig = {
|
||||||
|
hostURL: 'https://app.chatwoot.com',
|
||||||
|
helpCenterURL: 'https://help.chatwoot.com',
|
||||||
|
};
|
||||||
|
expect(
|
||||||
|
buildPortalArticleURL(
|
||||||
|
'handbook',
|
||||||
|
'culture',
|
||||||
|
'fr',
|
||||||
|
'article-slug',
|
||||||
|
'https://custom-domain.dev'
|
||||||
|
)
|
||||||
|
).toEqual('https://custom-domain.dev/hc/handbook/articles/article-slug');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses hostURL when helpCenterURL is not available', () => {
|
||||||
|
window.chatwootConfig = {
|
||||||
|
hostURL: 'https://app.chatwoot.com',
|
||||||
|
helpCenterURL: '',
|
||||||
|
};
|
||||||
|
expect(
|
||||||
|
buildPortalArticleURL('handbook', 'culture', 'fr', 'article-slug')
|
||||||
|
).toEqual('https://app.chatwoot.com/hc/handbook/articles/article-slug');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -2,23 +2,13 @@
|
|||||||
"AGENT_BOTS": {
|
"AGENT_BOTS": {
|
||||||
"HEADER": "Bots",
|
"HEADER": "Bots",
|
||||||
"LOADING_EDITOR": "Loading editor...",
|
"LOADING_EDITOR": "Loading editor...",
|
||||||
"DESCRIPTION": "Agent Bots are like the most fabulous members of your team. They can handle the small stuff, so you can focus on the stuff that matters. Give them a try.You can manage your bots from this page or create new ones using the 'Configure new bot' button.",
|
"DESCRIPTION": "Agent Bots are like the most fabulous members of your team. They can handle the small stuff, so you can focus on the stuff that matters. Give them a try. You can manage your bots from this page or create new ones using the 'Add Bot' button.",
|
||||||
"LEARN_MORE": "Learn about agent bots",
|
"LEARN_MORE": "Learn about agent bots",
|
||||||
"CSML_BOT_EDITOR": {
|
"GLOBAL_BOT": "System bot",
|
||||||
"NAME": {
|
"GLOBAL_BOT_BADGE": "System",
|
||||||
"LABEL": "Bot name",
|
"AVATAR": {
|
||||||
"PLACEHOLDER": "Name your bot.",
|
"SUCCESS_DELETE": "Bot avatar deleted successfully",
|
||||||
"ERROR": "Bot name is required."
|
"ERROR_DELETE": "Error deleting bot avatar, please try again"
|
||||||
},
|
|
||||||
"DESCRIPTION": {
|
|
||||||
"LABEL": "Bot description",
|
|
||||||
"PLACEHOLDER": "What does this bot do?"
|
|
||||||
},
|
|
||||||
"BOT_CONFIG": {
|
|
||||||
"ERROR": "Please enter your CSML bot configuration above.",
|
|
||||||
"API_ERROR": "Your CSML configuration is invalid. Please fix it and try again."
|
|
||||||
},
|
|
||||||
"SUBMIT": "Validate and save"
|
|
||||||
},
|
},
|
||||||
"BOT_CONFIGURATION": {
|
"BOT_CONFIGURATION": {
|
||||||
"TITLE": "Select an agent bot",
|
"TITLE": "Select an agent bot",
|
||||||
@ -32,7 +22,7 @@
|
|||||||
"SELECT_PLACEHOLDER": "Select bot"
|
"SELECT_PLACEHOLDER": "Select bot"
|
||||||
},
|
},
|
||||||
"ADD": {
|
"ADD": {
|
||||||
"TITLE": "Configure new bot",
|
"TITLE": "Add Bot",
|
||||||
"CANCEL_BUTTON_TEXT": "Cancel",
|
"CANCEL_BUTTON_TEXT": "Cancel",
|
||||||
"API": {
|
"API": {
|
||||||
"SUCCESS_MESSAGE": "Bot added successfully.",
|
"SUCCESS_MESSAGE": "Bot added successfully.",
|
||||||
@ -40,16 +30,22 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"LIST": {
|
"LIST": {
|
||||||
"404": "No bots found. You can create a bot by clicking the 'Configure new bot' button ↗",
|
"404": "No bots found. You can create a bot by clicking the 'Add Bot' button.",
|
||||||
"LOADING": "Fetching bots...",
|
"LOADING": "Fetching bots...",
|
||||||
"TYPE": "Bot type"
|
"TABLE_HEADER": {
|
||||||
|
"DETAILS": "Bot Details",
|
||||||
|
"URL": "Webhook URL"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"DELETE": {
|
"DELETE": {
|
||||||
"BUTTON_TEXT": "Delete",
|
"BUTTON_TEXT": "Delete",
|
||||||
"TITLE": "Delete bot",
|
"TITLE": "Delete bot",
|
||||||
"SUBMIT": "Delete",
|
"CONFIRM": {
|
||||||
"CANCEL_BUTTON_TEXT": "Cancel",
|
"TITLE": "Confirm Deletion",
|
||||||
"DESCRIPTION": "Are you sure you want to delete this bot? This action is irreversible.",
|
"MESSAGE": "Are you sure you want to delete {name}?",
|
||||||
|
"YES": "Yes, Delete",
|
||||||
|
"NO": "No, Keep"
|
||||||
|
},
|
||||||
"API": {
|
"API": {
|
||||||
"SUCCESS_MESSAGE": "Bot deleted successfully.",
|
"SUCCESS_MESSAGE": "Bot deleted successfully.",
|
||||||
"ERROR_MESSAGE": "Could not delete bot. Please try again."
|
"ERROR_MESSAGE": "Could not delete bot. Please try again."
|
||||||
@ -57,17 +53,44 @@
|
|||||||
},
|
},
|
||||||
"EDIT": {
|
"EDIT": {
|
||||||
"BUTTON_TEXT": "Edit",
|
"BUTTON_TEXT": "Edit",
|
||||||
"LOADING": "Fetching bots...",
|
|
||||||
"TITLE": "Edit bot",
|
"TITLE": "Edit bot",
|
||||||
"CANCEL_BUTTON_TEXT": "Cancel",
|
|
||||||
"API": {
|
"API": {
|
||||||
"SUCCESS_MESSAGE": "Bot updated successfully.",
|
"SUCCESS_MESSAGE": "Bot updated successfully.",
|
||||||
"ERROR_MESSAGE": "Could not update bot. Please try again."
|
"ERROR_MESSAGE": "Could not update bot. Please try again."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"FORM": {
|
||||||
|
"AVATAR": {
|
||||||
|
"LABEL": "Bot avatar"
|
||||||
|
},
|
||||||
|
"NAME": {
|
||||||
|
"LABEL": "Bot name",
|
||||||
|
"PLACEHOLDER": "Enter bot name",
|
||||||
|
"REQUIRED": "Bot name is required"
|
||||||
|
},
|
||||||
|
"DESCRIPTION": {
|
||||||
|
"LABEL": "Description",
|
||||||
|
"PLACEHOLDER": "What does this bot do?"
|
||||||
|
},
|
||||||
|
"WEBHOOK_URL": {
|
||||||
|
"LABEL": "Webhook URL",
|
||||||
|
"PLACEHOLDER": "https://example.com/webhook",
|
||||||
|
"REQUIRED": "Webhook URL is required"
|
||||||
|
},
|
||||||
|
"ERRORS": {
|
||||||
|
"NAME": "Bot name is required",
|
||||||
|
"URL": "Webhook URL is required",
|
||||||
|
"VALID_URL": "Please enter a valid URL starting with http:// or https://"
|
||||||
|
},
|
||||||
|
"CANCEL": "Cancel",
|
||||||
|
"CREATE": "Create Bot",
|
||||||
|
"UPDATE": "Update Bot"
|
||||||
|
},
|
||||||
|
"WEBHOOK": {
|
||||||
|
"DESCRIPTION": "Configure a webhook bot to integrate with your custom services. The bot will receive and process events from conversations and can respond to them."
|
||||||
|
},
|
||||||
"TYPES": {
|
"TYPES": {
|
||||||
"WEBHOOK": "Webhook bot",
|
"WEBHOOK": "Webhook bot"
|
||||||
"CSML": "CSML bot"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -32,10 +32,12 @@
|
|||||||
"LOADING_CONVERSATIONS": "Loading Conversations",
|
"LOADING_CONVERSATIONS": "Loading Conversations",
|
||||||
"CANNOT_REPLY": "You cannot reply due to",
|
"CANNOT_REPLY": "You cannot reply due to",
|
||||||
"24_HOURS_WINDOW": "24 hour message window restriction",
|
"24_HOURS_WINDOW": "24 hour message window restriction",
|
||||||
|
"API_HOURS_WINDOW": "You can only reply to this conversation within {hours} hours",
|
||||||
"NOT_ASSIGNED_TO_YOU": "This conversation is not assigned to you. Would you like to assign this conversation to yourself?",
|
"NOT_ASSIGNED_TO_YOU": "This conversation is not assigned to you. Would you like to assign this conversation to yourself?",
|
||||||
"ASSIGN_TO_ME": "Assign to me",
|
"ASSIGN_TO_ME": "Assign to me",
|
||||||
"TWILIO_WHATSAPP_CAN_REPLY": "You can only reply to this conversation using a template message due to",
|
"TWILIO_WHATSAPP_CAN_REPLY": "You can only reply to this conversation using a template message due to",
|
||||||
"TWILIO_WHATSAPP_24_HOURS_WINDOW": "24 hour message window restriction",
|
"TWILIO_WHATSAPP_24_HOURS_WINDOW": "24 hour message window restriction",
|
||||||
|
"OLD_INSTAGRAM_INBOX_REPLY_BANNER": "This Instagram account was migrated to the new Instagram channel inbox. All new messages will show up there. You won’t be able to send messages from this conversation anymore.",
|
||||||
"REPLYING_TO": "You are replying to:",
|
"REPLYING_TO": "You are replying to:",
|
||||||
"REMOVE_SELECTION": "Remove Selection",
|
"REMOVE_SELECTION": "Remove Selection",
|
||||||
"DOWNLOAD": "Download",
|
"DOWNLOAD": "Download",
|
||||||
|
|||||||
@ -696,7 +696,8 @@
|
|||||||
"SLUG": {
|
"SLUG": {
|
||||||
"LABEL": "Slug",
|
"LABEL": "Slug",
|
||||||
"PLACEHOLDER": "user-guide",
|
"PLACEHOLDER": "user-guide",
|
||||||
"ERROR": "Slug is required"
|
"ERROR": "Slug is required",
|
||||||
|
"FORMAT_ERROR": "Please enter a valid slug, for eg: user-guide"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"PORTAL_SETTINGS": {
|
"PORTAL_SETTINGS": {
|
||||||
|
|||||||
@ -51,7 +51,9 @@
|
|||||||
"CONNECT_YOUR_INSTAGRAM_PROFILE": "Connect your Instagram Profile",
|
"CONNECT_YOUR_INSTAGRAM_PROFILE": "Connect your Instagram Profile",
|
||||||
"HELP": "To add your Instagram profile as a channel, you need to authenticate your Instagram Profile by clicking on 'Continue with Instagram' ",
|
"HELP": "To add your Instagram profile as a channel, you need to authenticate your Instagram Profile by clicking on 'Continue with Instagram' ",
|
||||||
"ERROR_MESSAGE": "There was an error connecting to Instagram, please try again",
|
"ERROR_MESSAGE": "There was an error connecting to Instagram, please try again",
|
||||||
"ERROR_AUTH": "There was an error connecting to Instagram, please try again"
|
"ERROR_AUTH": "There was an error connecting to Instagram, please try again",
|
||||||
|
"NEW_INBOX_SUGGESTION": "This Instagram account was previously linked to a different inbox and has now been migrated here. All new messages will appear here. The old inbox will no longer be able to send or receive messages for this account.",
|
||||||
|
"DUPLICATE_INBOX_BANNER": "This Instagram account was migrated to the new Instagram channel inbox. You won’t be able to send/receive Instagram messages from this inbox anymore."
|
||||||
},
|
},
|
||||||
"TWITTER": {
|
"TWITTER": {
|
||||||
"HELP": "To add your Twitter profile as a channel, you need to authenticate your Twitter Profile by clicking on 'Sign in with Twitter' ",
|
"HELP": "To add your Twitter profile as a channel, you need to authenticate your Twitter Profile by clicking on 'Sign in with Twitter' ",
|
||||||
|
|||||||
@ -337,6 +337,7 @@
|
|||||||
"HEADER_KNOW_MORE": "Know more",
|
"HEADER_KNOW_MORE": "Know more",
|
||||||
"COPILOT": {
|
"COPILOT": {
|
||||||
"SEND_MESSAGE": "Send message...",
|
"SEND_MESSAGE": "Send message...",
|
||||||
|
"EMPTY_MESSAGE": "There was an error generating the response. Please try again.",
|
||||||
"LOADER": "Captain is thinking",
|
"LOADER": "Captain is thinking",
|
||||||
"YOU": "You",
|
"YOU": "You",
|
||||||
"USE": "Use this",
|
"USE": "Use this",
|
||||||
|
|||||||
@ -120,6 +120,7 @@ export default {
|
|||||||
|
|
||||||
mounted() {
|
mounted() {
|
||||||
this.$store.dispatch('agents/get');
|
this.$store.dispatch('agents/get');
|
||||||
|
this.$store.dispatch('portals/index');
|
||||||
this.initialize();
|
this.initialize();
|
||||||
this.$watch('$store.state.route', () => this.initialize());
|
this.$watch('$store.state.route', () => this.initialize());
|
||||||
this.$watch('chatList.length', () => {
|
this.$watch('chatList.length', () => {
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
<script>
|
<script>
|
||||||
import { debounce } from '@chatwoot/utils';
|
import { debounce } from '@chatwoot/utils';
|
||||||
import { useAlert } from 'dashboard/composables';
|
import { useAlert } from 'dashboard/composables';
|
||||||
|
import { mapGetters } from 'vuex';
|
||||||
import allLocales from 'shared/constants/locales.js';
|
import allLocales from 'shared/constants/locales.js';
|
||||||
|
|
||||||
import SearchHeader from './Header.vue';
|
import SearchHeader from './Header.vue';
|
||||||
@ -33,6 +34,15 @@ export default {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
...mapGetters({
|
||||||
|
portalBySlug: 'portals/portalBySlug',
|
||||||
|
}),
|
||||||
|
portal() {
|
||||||
|
return this.portalBySlug(this.selectedPortalSlug);
|
||||||
|
},
|
||||||
|
portalCustomDomain() {
|
||||||
|
return this.portal?.custom_domain;
|
||||||
|
},
|
||||||
articleViewerUrl() {
|
articleViewerUrl() {
|
||||||
const article = this.activeArticle(this.activeId);
|
const article = this.activeArticle(this.activeId);
|
||||||
if (!article) return '';
|
if (!article) return '';
|
||||||
@ -47,6 +57,7 @@ export default {
|
|||||||
|
|
||||||
return `${url}`;
|
return `${url}`;
|
||||||
},
|
},
|
||||||
|
|
||||||
searchResultsWithUrl() {
|
searchResultsWithUrl() {
|
||||||
return this.searchResults.map(article => ({
|
return this.searchResults.map(article => ({
|
||||||
...article,
|
...article,
|
||||||
@ -65,7 +76,8 @@ export default {
|
|||||||
this.selectedPortalSlug,
|
this.selectedPortalSlug,
|
||||||
'',
|
'',
|
||||||
'',
|
'',
|
||||||
article.slug
|
article.slug,
|
||||||
|
this.portalCustomDomain
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
localeName(code) {
|
localeName(code) {
|
||||||
@ -111,7 +123,6 @@ export default {
|
|||||||
},
|
},
|
||||||
onInsert(id) {
|
onInsert(id) {
|
||||||
const article = this.activeArticle(id || this.activeId);
|
const article = this.activeArticle(id || this.activeId);
|
||||||
|
|
||||||
this.$emit('insert', article);
|
this.$emit('insert', article);
|
||||||
useAlert(this.$t('HELP_CENTER.ARTICLE_SEARCH.SUCCESS_ARTICLE_INSERTED'));
|
useAlert(this.$t('HELP_CENTER.ARTICLE_SEARCH.SUCCESS_ARTICLE_INSERTED'));
|
||||||
this.onClose();
|
this.onClose();
|
||||||
|
|||||||
@ -20,17 +20,23 @@ const articleById = useMapGetter('articles/articleById');
|
|||||||
|
|
||||||
const article = computed(() => articleById.value(articleSlug));
|
const article = computed(() => articleById.value(articleSlug));
|
||||||
|
|
||||||
|
const portalBySlug = useMapGetter('portals/portalBySlug');
|
||||||
|
|
||||||
|
const portal = computed(() => portalBySlug.value(portalSlug));
|
||||||
|
|
||||||
const isUpdating = ref(false);
|
const isUpdating = ref(false);
|
||||||
const isSaved = ref(false);
|
const isSaved = ref(false);
|
||||||
|
|
||||||
const portalLink = computed(() => {
|
const articleLink = computed(() => {
|
||||||
const { slug: categorySlug, locale: categoryLocale } = article.value.category;
|
const { slug: categorySlug, locale: categoryLocale } = article.value.category;
|
||||||
const { slug: articleSlugValue } = article.value;
|
const { slug: articleSlugValue } = article.value;
|
||||||
|
const portalCustomDomain = portal.value?.custom_domain;
|
||||||
return buildPortalArticleURL(
|
return buildPortalArticleURL(
|
||||||
portalSlug,
|
portalSlug,
|
||||||
categorySlug,
|
categorySlug,
|
||||||
categoryLocale,
|
categoryLocale,
|
||||||
articleSlugValue
|
articleSlugValue,
|
||||||
|
portalCustomDomain
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -91,7 +97,7 @@ const fetchArticleDetails = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const previewArticle = () => {
|
const previewArticle = () => {
|
||||||
window.open(portalLink.value, '_blank');
|
window.open(articleLink.value, '_blank');
|
||||||
useTrack(PORTALS_EVENTS.PREVIEW_ARTICLE, {
|
useTrack(PORTALS_EVENTS.PREVIEW_ARTICLE, {
|
||||||
status: article.value?.status,
|
status: article.value?.status,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,102 +1,191 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue';
|
import { ref, computed, onMounted } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
|
||||||
import { useMapGetter, useStore } from 'dashboard/composables/store';
|
import { useMapGetter, useStore } from 'dashboard/composables/store';
|
||||||
import { useAlert } from 'dashboard/composables';
|
import { useAlert } from 'dashboard/composables';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { frontendURL } from 'dashboard/helper/URLHelper';
|
|
||||||
|
|
||||||
import AgentBotRow from './components/AgentBotRow.vue';
|
|
||||||
|
|
||||||
import SettingsLayout from '../SettingsLayout.vue';
|
import SettingsLayout from '../SettingsLayout.vue';
|
||||||
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
|
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
|
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||||
|
import AgentBotModal from './components/AgentBotModal.vue';
|
||||||
|
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||||
|
|
||||||
|
const MODAL_TYPES = {
|
||||||
|
CREATE: 'create',
|
||||||
|
EDIT: 'edit',
|
||||||
|
};
|
||||||
|
|
||||||
const router = useRouter();
|
|
||||||
const store = useStore();
|
const store = useStore();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
const accountId = useMapGetter('getCurrentAccountId');
|
|
||||||
const agentBots = useMapGetter('agentBots/getBots');
|
const agentBots = useMapGetter('agentBots/getBots');
|
||||||
const uiFlags = useMapGetter('agentBots/getUIFlags');
|
const uiFlags = useMapGetter('agentBots/getUIFlags');
|
||||||
|
|
||||||
const confirmDialog = ref(null);
|
const selectedBot = ref({});
|
||||||
|
const loading = ref({});
|
||||||
|
const modalType = ref(MODAL_TYPES.CREATE);
|
||||||
|
const agentBotModalRef = ref(null);
|
||||||
|
const agentBotDeleteDialogRef = ref(null);
|
||||||
|
|
||||||
const onConfigureNewBot = () => {
|
const tableHeaders = computed(() => {
|
||||||
router.push({
|
return [
|
||||||
name: 'agent_bots_csml_new',
|
t('AGENT_BOTS.LIST.TABLE_HEADER.DETAILS'),
|
||||||
});
|
t('AGENT_BOTS.LIST.TABLE_HEADER.URL'),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedBotName = computed(() => selectedBot.value?.name || '');
|
||||||
|
|
||||||
|
const openAddModal = () => {
|
||||||
|
modalType.value = MODAL_TYPES.CREATE;
|
||||||
|
selectedBot.value = {};
|
||||||
|
agentBotModalRef.value.dialogRef.open();
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEditModal = bot => {
|
||||||
|
modalType.value = MODAL_TYPES.EDIT;
|
||||||
|
selectedBot.value = bot;
|
||||||
|
agentBotModalRef.value.dialogRef.open();
|
||||||
|
};
|
||||||
|
|
||||||
|
const openDeletePopup = bot => {
|
||||||
|
selectedBot.value = bot;
|
||||||
|
agentBotDeleteDialogRef.value.open();
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteAgentBot = async id => {
|
||||||
|
try {
|
||||||
|
await store.dispatch('agentBots/delete', id);
|
||||||
|
useAlert(t('AGENT_BOTS.DELETE.API.SUCCESS_MESSAGE'));
|
||||||
|
} catch (error) {
|
||||||
|
useAlert(t('AGENT_BOTS.DELETE.API.ERROR_MESSAGE'));
|
||||||
|
} finally {
|
||||||
|
loading.value[id] = false;
|
||||||
|
selectedBot.value = {};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDeletion = () => {
|
||||||
|
loading.value[selectedBot.value.id] = true;
|
||||||
|
deleteAgentBot(selectedBot.value.id);
|
||||||
|
agentBotDeleteDialogRef.value.close();
|
||||||
};
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
store.dispatch('agentBots/get');
|
store.dispatch('agentBots/get');
|
||||||
});
|
});
|
||||||
|
|
||||||
const onDeleteAgentBot = async bot => {
|
|
||||||
const ok = await confirmDialog.value.showConfirmation();
|
|
||||||
|
|
||||||
if (ok) {
|
|
||||||
try {
|
|
||||||
await store.dispatch('agentBots/delete', bot.id);
|
|
||||||
useAlert(t('AGENT_BOTS.DELETE.API.SUCCESS_MESSAGE'));
|
|
||||||
} catch (error) {
|
|
||||||
useAlert(t('AGENT_BOTS.DELETE.API.ERROR_MESSAGE'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onEditAgentBot = bot => {
|
|
||||||
router.push(
|
|
||||||
frontendURL(
|
|
||||||
`accounts/${accountId.value}/settings/agent-bots/csml/${bot.id}`
|
|
||||||
)
|
|
||||||
);
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<SettingsLayout
|
<SettingsLayout
|
||||||
:is-loading="uiFlags.isFetching"
|
:is-loading="uiFlags.isFetching"
|
||||||
:loading-message="$t('AGENT_BOTS.LIST.LOADING')"
|
:loading-message="t('AGENT_BOTS.LIST.LOADING')"
|
||||||
:no-records-found="!agentBots.length"
|
:no-records-found="!agentBots.length"
|
||||||
:no-records-message="$t('AGENT_BOTS.LIST.404')"
|
:no-records-message="t('AGENT_BOTS.LIST.404')"
|
||||||
>
|
>
|
||||||
<template #header>
|
<template #header>
|
||||||
<BaseSettingsHeader
|
<BaseSettingsHeader
|
||||||
:title="$t('AGENT_BOTS.HEADER')"
|
:title="t('AGENT_BOTS.HEADER')"
|
||||||
:description="$t('AGENT_BOTS.DESCRIPTION')"
|
:description="t('AGENT_BOTS.DESCRIPTION')"
|
||||||
:link-text="$t('AGENT_BOTS.LEARN_MORE')"
|
:link-text="t('AGENT_BOTS.LEARN_MORE')"
|
||||||
feature-name="agent_bots"
|
feature-name="agent_bots"
|
||||||
>
|
>
|
||||||
<template #actions>
|
<template #actions>
|
||||||
<Button
|
<Button
|
||||||
icon="i-lucide-circle-plus"
|
icon="i-lucide-circle-plus"
|
||||||
:label="$t('AGENT_BOTS.ADD.TITLE')"
|
:label="$t('AGENT_BOTS.ADD.TITLE')"
|
||||||
@click="onConfigureNewBot"
|
@click="openAddModal"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</BaseSettingsHeader>
|
</BaseSettingsHeader>
|
||||||
</template>
|
</template>
|
||||||
<template #body>
|
<template #body>
|
||||||
<div class="flex-1 overflow-auto">
|
<table class="min-w-full overflow-x-auto divide-y divide-n-strong">
|
||||||
<table class="divide-y divide-slate-75 dark:divide-slate-700">
|
<thead>
|
||||||
<tbody class="divide-y divide-n-weak text-n-slate-11">
|
<th
|
||||||
<AgentBotRow
|
v-for="thHeader in tableHeaders"
|
||||||
v-for="(agentBot, index) in agentBots"
|
:key="thHeader"
|
||||||
:key="agentBot.id"
|
class="py-4 font-semibold text-left ltr:pr-4 rtl:pl-4 text-n-slate-11"
|
||||||
:agent-bot="agentBot"
|
>
|
||||||
:index="index"
|
{{ thHeader }}
|
||||||
@delete="onDeleteAgentBot"
|
</th>
|
||||||
@edit="onEditAgentBot"
|
</thead>
|
||||||
/>
|
<tbody class="flex-1 divide-y divide-n-weak text-n-slate-12">
|
||||||
</tbody>
|
<tr v-for="bot in agentBots" :key="bot.id">
|
||||||
</table>
|
<td class="py-4 ltr:pr-4 rtl:pl-4">
|
||||||
<woot-confirm-modal
|
<div class="flex flex-row items-center gap-4">
|
||||||
ref="confirmDialog"
|
<Avatar
|
||||||
:title="$t('AGENT_BOTS.DELETE.TITLE')"
|
:name="bot.name"
|
||||||
:description="$t('AGENT_BOTS.DELETE.DESCRIPTION')"
|
:src="bot.thumbnail"
|
||||||
/>
|
:size="40"
|
||||||
</div>
|
rounded-full
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<span class="block font-medium break-words">
|
||||||
|
{{ bot.name }}
|
||||||
|
<span
|
||||||
|
v-if="bot.system_bot"
|
||||||
|
class="text-xs text-n-slate-12 bg-n-blue-5 inline-block rounded-md py-0.5 px-1 ltr:ml-1 rtl:mr-1"
|
||||||
|
>
|
||||||
|
{{ $t('AGENT_BOTS.GLOBAL_BOT_BADGE') }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span class="text-sm text-n-slate-11">
|
||||||
|
{{ bot.description }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="py-4 ltr:pr-4 rtl:pl-4 text-sm">
|
||||||
|
{{ bot.outgoing_url || bot.bot_config?.webhook_url }}
|
||||||
|
</td>
|
||||||
|
<td class="py-4 min-w-xs">
|
||||||
|
<div class="flex gap-1 justify-end">
|
||||||
|
<Button
|
||||||
|
v-if="!bot.system_bot"
|
||||||
|
v-tooltip.top="t('AGENT_BOTS.EDIT.BUTTON_TEXT')"
|
||||||
|
icon="i-lucide-pen"
|
||||||
|
slate
|
||||||
|
xs
|
||||||
|
faded
|
||||||
|
:is-loading="loading[bot.id]"
|
||||||
|
@click="openEditModal(bot)"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
v-if="!bot.system_bot"
|
||||||
|
v-tooltip.top="t('AGENT_BOTS.DELETE.BUTTON_TEXT')"
|
||||||
|
icon="i-lucide-trash-2"
|
||||||
|
xs
|
||||||
|
ruby
|
||||||
|
faded
|
||||||
|
:is-loading="loading[bot.id]"
|
||||||
|
@click="openDeletePopup(bot)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<AgentBotModal
|
||||||
|
ref="agentBotModalRef"
|
||||||
|
:type="modalType"
|
||||||
|
:selected-bot="selectedBot"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
ref="agentBotDeleteDialogRef"
|
||||||
|
type="alert"
|
||||||
|
:title="t('AGENT_BOTS.DELETE.CONFIRM.TITLE')"
|
||||||
|
:description="
|
||||||
|
t('AGENT_BOTS.DELETE.CONFIRM.MESSAGE', { name: selectedBotName })
|
||||||
|
"
|
||||||
|
:is-loading="uiFlags.isDeleting"
|
||||||
|
:confirm-button-label="t('AGENT_BOTS.DELETE.CONFIRM.YES')"
|
||||||
|
:cancel-button-label="t('AGENT_BOTS.DELETE.CONFIRM.NO')"
|
||||||
|
@confirm="confirmDeletion"
|
||||||
|
/>
|
||||||
</SettingsLayout>
|
</SettingsLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -1,9 +1,6 @@
|
|||||||
import { FEATURE_FLAGS } from '../../../../featureFlags';
|
import { FEATURE_FLAGS } from '../../../../featureFlags';
|
||||||
import Bot from './Index.vue';
|
import Bot from './Index.vue';
|
||||||
import CsmlEditBot from './csml/Edit.vue';
|
|
||||||
import CsmlNewBot from './csml/New.vue';
|
|
||||||
import { frontendURL } from '../../../../helper/URLHelper';
|
import { frontendURL } from '../../../../helper/URLHelper';
|
||||||
import SettingsContent from '../Wrapper.vue';
|
|
||||||
import SettingsWrapper from '../SettingsWrapper.vue';
|
import SettingsWrapper from '../SettingsWrapper.vue';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
@ -26,36 +23,5 @@ export default {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: frontendURL('accounts/:accountId/settings/agent-bots'),
|
|
||||||
component: SettingsContent,
|
|
||||||
props: () => {
|
|
||||||
return {
|
|
||||||
headerTitle: 'AGENT_BOTS.HEADER',
|
|
||||||
icon: 'bot',
|
|
||||||
showBackButton: true,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
path: 'csml/new',
|
|
||||||
name: 'agent_bots_csml_new',
|
|
||||||
component: CsmlNewBot,
|
|
||||||
meta: {
|
|
||||||
featureFlag: FEATURE_FLAGS.AGENT_BOTS,
|
|
||||||
permissions: ['administrator'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'csml/:botId',
|
|
||||||
name: 'agent_bots_csml_edit',
|
|
||||||
component: CsmlEditBot,
|
|
||||||
meta: {
|
|
||||||
featureFlag: FEATURE_FLAGS.AGENT_BOTS,
|
|
||||||
permissions: ['administrator'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@ -0,0 +1,251 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, computed, reactive, watch } from 'vue';
|
||||||
|
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||||
|
import { useAlert } from 'dashboard/composables';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { required, helpers, url } from '@vuelidate/validators';
|
||||||
|
import { useVuelidate } from '@vuelidate/core';
|
||||||
|
|
||||||
|
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||||
|
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||||
|
import Input from 'dashboard/components-next/input/Input.vue';
|
||||||
|
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
|
||||||
|
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
type: {
|
||||||
|
type: String,
|
||||||
|
default: 'create',
|
||||||
|
validator: value => ['create', 'edit'].includes(value),
|
||||||
|
},
|
||||||
|
selectedBot: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const MODAL_TYPES = {
|
||||||
|
CREATE: 'create',
|
||||||
|
EDIT: 'edit',
|
||||||
|
};
|
||||||
|
|
||||||
|
const store = useStore();
|
||||||
|
const { t } = useI18n();
|
||||||
|
const dialogRef = ref(null);
|
||||||
|
const uiFlags = useMapGetter('agentBots/getUIFlags');
|
||||||
|
|
||||||
|
const formState = reactive({
|
||||||
|
botName: '',
|
||||||
|
botDescription: '',
|
||||||
|
botUrl: '',
|
||||||
|
botAvatar: null,
|
||||||
|
botAvatarUrl: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const v$ = useVuelidate(
|
||||||
|
{
|
||||||
|
botName: {
|
||||||
|
required: helpers.withMessage(
|
||||||
|
() => t('AGENT_BOTS.FORM.ERRORS.NAME'),
|
||||||
|
required
|
||||||
|
),
|
||||||
|
},
|
||||||
|
botUrl: {
|
||||||
|
required: helpers.withMessage(
|
||||||
|
() => t('AGENT_BOTS.FORM.ERRORS.URL'),
|
||||||
|
required
|
||||||
|
),
|
||||||
|
url: helpers.withMessage(
|
||||||
|
() => t('AGENT_BOTS.FORM.ERRORS.VALID_URL'),
|
||||||
|
url
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
formState
|
||||||
|
);
|
||||||
|
|
||||||
|
const isLoading = computed(() =>
|
||||||
|
props.type === MODAL_TYPES.CREATE
|
||||||
|
? uiFlags.value.isCreating
|
||||||
|
: uiFlags.value.isUpdating
|
||||||
|
);
|
||||||
|
|
||||||
|
const dialogTitle = computed(() =>
|
||||||
|
props.type === MODAL_TYPES.CREATE
|
||||||
|
? t('AGENT_BOTS.ADD.TITLE')
|
||||||
|
: t('AGENT_BOTS.EDIT.TITLE')
|
||||||
|
);
|
||||||
|
|
||||||
|
const confirmButtonLabel = computed(() =>
|
||||||
|
props.type === MODAL_TYPES.CREATE
|
||||||
|
? t('AGENT_BOTS.FORM.CREATE')
|
||||||
|
: t('AGENT_BOTS.FORM.UPDATE')
|
||||||
|
);
|
||||||
|
|
||||||
|
const botNameError = computed(() =>
|
||||||
|
v$.value.botName.$error ? v$.value.botName.$errors[0]?.$message : ''
|
||||||
|
);
|
||||||
|
|
||||||
|
const botUrlError = computed(() =>
|
||||||
|
v$.value.botUrl.$error ? v$.value.botUrl.$errors[0]?.$message : ''
|
||||||
|
);
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
Object.assign(formState, {
|
||||||
|
botName: '',
|
||||||
|
botDescription: '',
|
||||||
|
botUrl: '',
|
||||||
|
botAvatar: null,
|
||||||
|
botAvatarUrl: '',
|
||||||
|
});
|
||||||
|
v$.value.$reset();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImageUpload = ({ file, url: avatarUrl }) => {
|
||||||
|
formState.botAvatar = file;
|
||||||
|
formState.botAvatarUrl = avatarUrl;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAvatarDelete = async () => {
|
||||||
|
if (props.selectedBot?.id) {
|
||||||
|
try {
|
||||||
|
await store.dispatch(
|
||||||
|
'agentBots/deleteAgentBotAvatar',
|
||||||
|
props.selectedBot.id
|
||||||
|
);
|
||||||
|
formState.botAvatar = null;
|
||||||
|
formState.botAvatarUrl = '';
|
||||||
|
useAlert(t('AGENT_BOTS.AVATAR.SUCCESS_DELETE'));
|
||||||
|
} catch (error) {
|
||||||
|
useAlert(t('AGENT_BOTS.AVATAR.ERROR_DELETE'));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
formState.botAvatar = null;
|
||||||
|
formState.botAvatarUrl = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
v$.value.$touch();
|
||||||
|
if (v$.value.$invalid) return;
|
||||||
|
|
||||||
|
const botData = {
|
||||||
|
name: formState.botName,
|
||||||
|
description: formState.botDescription,
|
||||||
|
outgoing_url: formState.botUrl,
|
||||||
|
bot_type: 'webhook',
|
||||||
|
avatar: formState.botAvatar,
|
||||||
|
};
|
||||||
|
|
||||||
|
const isCreate = props.type === MODAL_TYPES.CREATE;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const actionPayload = isCreate
|
||||||
|
? botData
|
||||||
|
: { id: props.selectedBot.id, data: botData };
|
||||||
|
|
||||||
|
await store.dispatch(
|
||||||
|
`agentBots/${isCreate ? 'create' : 'update'}`,
|
||||||
|
actionPayload
|
||||||
|
);
|
||||||
|
|
||||||
|
const alertKey = isCreate
|
||||||
|
? t('AGENT_BOTS.ADD.API.SUCCESS_MESSAGE')
|
||||||
|
: t('AGENT_BOTS.EDIT.API.SUCCESS_MESSAGE');
|
||||||
|
useAlert(alertKey);
|
||||||
|
|
||||||
|
dialogRef.value.close();
|
||||||
|
resetForm();
|
||||||
|
} catch (error) {
|
||||||
|
const errorKey = isCreate
|
||||||
|
? t('AGENT_BOTS.ADD.API.ERROR_MESSAGE')
|
||||||
|
: t('AGENT_BOTS.EDIT.API.ERROR_MESSAGE');
|
||||||
|
useAlert(errorKey);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const initializeForm = () => {
|
||||||
|
if (props.selectedBot && Object.keys(props.selectedBot).length) {
|
||||||
|
const { name, description, outgoing_url, thumbnail, bot_config } =
|
||||||
|
props.selectedBot;
|
||||||
|
formState.botName = name || '';
|
||||||
|
formState.botDescription = description || '';
|
||||||
|
formState.botUrl = outgoing_url || bot_config?.webhook_url || '';
|
||||||
|
formState.botAvatarUrl = thumbnail || '';
|
||||||
|
} else {
|
||||||
|
resetForm();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(() => props.selectedBot, initializeForm, { immediate: true, deep: true });
|
||||||
|
|
||||||
|
defineExpose({ dialogRef });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Dialog
|
||||||
|
ref="dialogRef"
|
||||||
|
type="edit"
|
||||||
|
:title="dialogTitle"
|
||||||
|
:show-cancel-button="false"
|
||||||
|
:show-confirm-button="false"
|
||||||
|
@close="v$.$reset()"
|
||||||
|
>
|
||||||
|
<form class="flex flex-col gap-4" @submit.prevent="handleSubmit">
|
||||||
|
<div class="mb-2 flex flex-col items-start">
|
||||||
|
<span class="mb-2 text-sm font-medium text-n-slate-12">
|
||||||
|
{{ $t('AGENT_BOTS.FORM.AVATAR.LABEL') }}
|
||||||
|
</span>
|
||||||
|
<Avatar
|
||||||
|
:src="formState.botAvatarUrl"
|
||||||
|
:name="formState.botName"
|
||||||
|
:size="68"
|
||||||
|
allow-upload
|
||||||
|
@upload="handleImageUpload"
|
||||||
|
@delete="handleAvatarDelete"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
v-model="formState.botName"
|
||||||
|
:label="$t('AGENT_BOTS.FORM.NAME.LABEL')"
|
||||||
|
:placeholder="$t('AGENT_BOTS.FORM.NAME.PLACEHOLDER')"
|
||||||
|
:message="botNameError"
|
||||||
|
:message-type="botNameError ? 'error' : 'info'"
|
||||||
|
@blur="v$.botName.$touch()"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextArea
|
||||||
|
v-model="formState.botDescription"
|
||||||
|
:label="$t('AGENT_BOTS.FORM.DESCRIPTION.LABEL')"
|
||||||
|
:placeholder="$t('AGENT_BOTS.FORM.DESCRIPTION.PLACEHOLDER')"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
v-model="formState.botUrl"
|
||||||
|
:label="$t('AGENT_BOTS.FORM.WEBHOOK_URL.LABEL')"
|
||||||
|
:placeholder="$t('AGENT_BOTS.FORM.WEBHOOK_URL.PLACEHOLDER')"
|
||||||
|
:message="botUrlError"
|
||||||
|
:message-type="botUrlError ? 'error' : 'info'"
|
||||||
|
@blur="v$.botUrl.$touch()"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-end w-full gap-2 px-0 py-2">
|
||||||
|
<NextButton
|
||||||
|
faded
|
||||||
|
slate
|
||||||
|
type="reset"
|
||||||
|
:label="$t('AGENT_BOTS.FORM.CANCEL')"
|
||||||
|
@click="dialogRef.close()"
|
||||||
|
/>
|
||||||
|
<NextButton
|
||||||
|
type="submit"
|
||||||
|
data-testid="label-submit"
|
||||||
|
:label="confirmButtonLabel"
|
||||||
|
:is-loading="isLoading"
|
||||||
|
:disabled="v$.$invalid"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
@ -1,59 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import { computed } from 'vue';
|
|
||||||
import ShowMore from 'dashboard/components/widgets/ShowMore.vue';
|
|
||||||
import AgentBotType from './AgentBotType.vue';
|
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
agentBot: {
|
|
||||||
type: Object,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
index: {
|
|
||||||
type: Number,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const emit = defineEmits(['edit', 'delete']);
|
|
||||||
|
|
||||||
const isACSMLTypeBot = computed(() => {
|
|
||||||
const { bot_type: botType } = props.agentBot;
|
|
||||||
return botType === 'csml';
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<tr class="space-x-2">
|
|
||||||
<td class="py-4 ltr:pl-0 ltr:pr-4 rtl:pl-4 rtl:pr-0">
|
|
||||||
<div class="flex items-center break-words font-medium">
|
|
||||||
{{ agentBot.name }}
|
|
||||||
(<AgentBotType :bot-type="agentBot.bot_type" />)
|
|
||||||
</div>
|
|
||||||
<div class="text-sm">
|
|
||||||
<ShowMore :text="agentBot.description || ''" :limit="120" />
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td class="align-middle">
|
|
||||||
<div class="flex justify-end gap-1 h-full items-center">
|
|
||||||
<Button
|
|
||||||
v-if="isACSMLTypeBot"
|
|
||||||
v-tooltip.top="$t('AGENT_BOTS.EDIT.BUTTON_TEXT')"
|
|
||||||
icon="i-lucide-pen"
|
|
||||||
slate
|
|
||||||
xs
|
|
||||||
faded
|
|
||||||
@click="emit('edit', agentBot)"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
v-tooltip.top="$t('AGENT_BOTS.DELETE.BUTTON_TEXT')"
|
|
||||||
icon="i-lucide-trash-2"
|
|
||||||
xs
|
|
||||||
ruby
|
|
||||||
faded
|
|
||||||
@click="emit('delete', agentBot, index)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</template>
|
|
||||||
@ -1,36 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import { computed } from 'vue';
|
|
||||||
import { useI18n } from 'vue-i18n';
|
|
||||||
|
|
||||||
defineProps({
|
|
||||||
botType: {
|
|
||||||
type: String,
|
|
||||||
default: 'webhook',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { t } = useI18n();
|
|
||||||
|
|
||||||
const botTypeConfig = computed(() => ({
|
|
||||||
csml: {
|
|
||||||
label: t('AGENT_BOTS.TYPES.CSML'),
|
|
||||||
thumbnail: '/dashboard/images/agent-bots/csml.png',
|
|
||||||
},
|
|
||||||
webhook: {
|
|
||||||
label: t('AGENT_BOTS.TYPES.WEBHOOK'),
|
|
||||||
thumbnail: '/dashboard/images/agent-bots/webhook.svg',
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<span class="inline-flex items-center gap-1">
|
|
||||||
<img
|
|
||||||
v-tooltip="botTypeConfig[botType].label"
|
|
||||||
class="h-3 w-auto"
|
|
||||||
:src="botTypeConfig[botType].thumbnail"
|
|
||||||
:alt="botTypeConfig[botType].label"
|
|
||||||
/>
|
|
||||||
<span>{{ botTypeConfig[botType].label }}</span>
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
@ -1,99 +0,0 @@
|
|||||||
<script>
|
|
||||||
import { useVuelidate } from '@vuelidate/core';
|
|
||||||
import { required } from '@vuelidate/validators';
|
|
||||||
import CsmlMonacoEditor from './CSMLMonacoEditor.vue';
|
|
||||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
|
||||||
|
|
||||||
export default {
|
|
||||||
components: { CsmlMonacoEditor, NextButton },
|
|
||||||
props: {
|
|
||||||
agentBot: {
|
|
||||||
type: Object,
|
|
||||||
default: () => {},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
emits: ['submit'],
|
|
||||||
setup() {
|
|
||||||
return { v$: useVuelidate() };
|
|
||||||
},
|
|
||||||
validations: {
|
|
||||||
bot: {
|
|
||||||
name: { required },
|
|
||||||
csmlContent: { required },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
bot: {
|
|
||||||
name: this.agentBot.name || '',
|
|
||||||
description: this.agentBot.description || '',
|
|
||||||
csmlContent: this.agentBot.bot_config.csml_content || '',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
onSubmit() {
|
|
||||||
this.v$.$touch();
|
|
||||||
if (this.v$.$invalid) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.$emit('submit', {
|
|
||||||
id: this.agentBot.id || '',
|
|
||||||
...this.bot,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="flex flex-col h-auto overflow-auto">
|
|
||||||
<div class="flex flex-row">
|
|
||||||
<div class="w-[68%]">
|
|
||||||
<div class="h-[calc(100vh-56px)] relative">
|
|
||||||
<CsmlMonacoEditor v-model="bot.csmlContent" class="w-full h-full" />
|
|
||||||
<div
|
|
||||||
v-if="v$.bot.csmlContent.$error"
|
|
||||||
class="bg-red-100 dark:bg-red-200 text-white dark:text-white absolute bottom-0 w-full p-2.5 flex items-center text-xs justify-center flex-shrink-0"
|
|
||||||
>
|
|
||||||
<span>{{ $t('AGENT_BOTS.CSML_BOT_EDITOR.BOT_CONFIG.ERROR') }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="w-[32%] overflow-auto p-4 h-[calc(100vh-56px)]">
|
|
||||||
<form
|
|
||||||
class="flex flex-col justify-between h-full"
|
|
||||||
@submit.prevent="onSubmit"
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<label :class="{ error: v$.bot.name.$error }">
|
|
||||||
{{ $t('AGENT_BOTS.CSML_BOT_EDITOR.NAME.LABEL') }}
|
|
||||||
<input
|
|
||||||
v-model="bot.name"
|
|
||||||
type="text"
|
|
||||||
:placeholder="$t('AGENT_BOTS.CSML_BOT_EDITOR.NAME.PLACEHOLDER')"
|
|
||||||
/>
|
|
||||||
<span v-if="v$.bot.name.$error" class="message">
|
|
||||||
{{ $t('AGENT_BOTS.CSML_BOT_EDITOR.NAME.ERROR') }}
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
{{ $t('AGENT_BOTS.CSML_BOT_EDITOR.DESCRIPTION.LABEL') }}
|
|
||||||
<textarea
|
|
||||||
v-model="bot.description"
|
|
||||||
rows="4"
|
|
||||||
:placeholder="
|
|
||||||
$t('AGENT_BOTS.CSML_BOT_EDITOR.DESCRIPTION.PLACEHOLDER')
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<NextButton
|
|
||||||
type="submit"
|
|
||||||
:label="$t('AGENT_BOTS.CSML_BOT_EDITOR.SUBMIT')"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@ -1,71 +0,0 @@
|
|||||||
<script>
|
|
||||||
import LoadingState from 'dashboard/components/widgets/LoadingState.vue';
|
|
||||||
import { mapGetters } from 'vuex';
|
|
||||||
|
|
||||||
export default {
|
|
||||||
components: { LoadingState },
|
|
||||||
props: {
|
|
||||||
modelValue: {
|
|
||||||
type: String,
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
emits: ['update:modelValue'],
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
iframeLoading: true,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
...mapGetters({
|
|
||||||
globalConfig: 'globalConfig/get',
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
mounted() {
|
|
||||||
window.onmessage = e => {
|
|
||||||
if (
|
|
||||||
typeof e.data !== 'string' ||
|
|
||||||
!e.data.startsWith('chatwoot-csml-editor:update')
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const csmlContent = e.data.replace('chatwoot-csml-editor:update', '');
|
|
||||||
this.$emit('update:modelValue', csmlContent);
|
|
||||||
};
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
onEditorLoad() {
|
|
||||||
const frameElement = document.getElementById(`csml-editor--frame`);
|
|
||||||
const eventData = {
|
|
||||||
event: 'editorContext',
|
|
||||||
data: this.modelValue || '',
|
|
||||||
};
|
|
||||||
frameElement.contentWindow.postMessage(JSON.stringify(eventData), '*');
|
|
||||||
this.iframeLoading = false;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="csml-editor--container">
|
|
||||||
<LoadingState
|
|
||||||
v-if="iframeLoading"
|
|
||||||
:message="$t('AGENT_BOTS.LOADING_EDITOR')"
|
|
||||||
class="dashboard-app_loading-container"
|
|
||||||
/>
|
|
||||||
<iframe
|
|
||||||
id="csml-editor--frame"
|
|
||||||
:src="globalConfig.csmlEditorHost"
|
|
||||||
@load="onEditorLoad"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
#csml-editor--frame {
|
|
||||||
border: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@ -1,40 +0,0 @@
|
|||||||
<script>
|
|
||||||
import { useAlert } from 'dashboard/composables';
|
|
||||||
import Spinner from 'shared/components/Spinner.vue';
|
|
||||||
import CsmlBotEditor from '../components/CSMLBotEditor.vue';
|
|
||||||
|
|
||||||
export default {
|
|
||||||
components: { Spinner, CsmlBotEditor },
|
|
||||||
computed: {
|
|
||||||
agentBot() {
|
|
||||||
return this.$store.getters['agentBots/getBot'](this.$route.params.botId);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
mounted() {
|
|
||||||
this.$store.dispatch('agentBots/show', this.$route.params.botId);
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
async updateBot(bot) {
|
|
||||||
try {
|
|
||||||
await this.$store.dispatch('agentBots/update', {
|
|
||||||
id: bot.id,
|
|
||||||
name: bot.name,
|
|
||||||
description: bot.description,
|
|
||||||
bot_type: 'csml',
|
|
||||||
bot_config: { csml_content: bot.csmlContent },
|
|
||||||
});
|
|
||||||
useAlert(this.$t('AGENT_BOTS.EDIT.API.SUCCESS_MESSAGE'));
|
|
||||||
} catch (error) {
|
|
||||||
useAlert(this.$t('AGENT_BOTS.CSML_BOT_EDITOR.BOT_CONFIG.API_ERROR'));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<CsmlBotEditor v-if="agentBot.id" :agent-bot="agentBot" @submit="updateBot" />
|
|
||||||
<div v-else class="flex flex-col h-auto overflow-auto no-padding">
|
|
||||||
<Spinner />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@ -1,41 +0,0 @@
|
|||||||
<script>
|
|
||||||
import { mapGetters } from 'vuex';
|
|
||||||
import { useAlert } from 'dashboard/composables';
|
|
||||||
import { frontendURL } from '../../../../../helper/URLHelper';
|
|
||||||
import CsmlBotEditor from '../components/CSMLBotEditor.vue';
|
|
||||||
|
|
||||||
export default {
|
|
||||||
components: { CsmlBotEditor },
|
|
||||||
computed: {
|
|
||||||
...mapGetters({
|
|
||||||
accountId: 'getCurrentAccountId',
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
async saveBot(bot) {
|
|
||||||
try {
|
|
||||||
const agentBot = await this.$store.dispatch('agentBots/create', {
|
|
||||||
name: bot.name,
|
|
||||||
description: bot.description,
|
|
||||||
bot_type: 'csml',
|
|
||||||
bot_config: { csml_content: bot.csmlContent },
|
|
||||||
});
|
|
||||||
if (agentBot) {
|
|
||||||
this.$router.replace(
|
|
||||||
frontendURL(
|
|
||||||
`accounts/${this.accountId}/settings/agent-bots/csml/${agentBot.id}`
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
useAlert(this.$t('AGENT_BOTS.ADD.API.SUCCESS_MESSAGE'));
|
|
||||||
} catch (error) {
|
|
||||||
useAlert(this.$t('AGENT_BOTS.ADD.API.ERROR_MESSAGE'));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<CsmlBotEditor :agent-bot="{ bot_config: {} }" @submit="saveBot" />
|
|
||||||
</template>
|
|
||||||
@ -35,8 +35,7 @@ export default {
|
|||||||
},
|
},
|
||||||
{ key: 'telegram', name: 'Telegram' },
|
{ key: 'telegram', name: 'Telegram' },
|
||||||
{ key: 'line', name: 'Line' },
|
{ key: 'line', name: 'Line' },
|
||||||
// TODO: Add Instagram to the channel list after the feature is ready to use.
|
{ key: 'instagram', name: 'Instagram' },
|
||||||
// { key: 'instagram', name: 'Instagram' },
|
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
...mapGetters({
|
...mapGetters({
|
||||||
|
|||||||
@ -1,12 +1,15 @@
|
|||||||
<script>
|
<script>
|
||||||
import EmptyState from '../../../../components/widgets/EmptyState.vue';
|
import EmptyState from '../../../../components/widgets/EmptyState.vue';
|
||||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||||
|
import DuplicateInboxBanner from './channels/instagram/DuplicateInboxBanner.vue';
|
||||||
|
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||||
import WhatsappBaileysLinkDeviceModal from './components/WhatsappBaileysLinkDeviceModal.vue';
|
import WhatsappBaileysLinkDeviceModal from './components/WhatsappBaileysLinkDeviceModal.vue';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: {
|
components: {
|
||||||
EmptyState,
|
EmptyState,
|
||||||
NextButton,
|
NextButton,
|
||||||
|
DuplicateInboxBanner,
|
||||||
WhatsappBaileysLinkDeviceModal,
|
WhatsappBaileysLinkDeviceModal,
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
@ -23,6 +26,20 @@ export default {
|
|||||||
isATwilioInbox() {
|
isATwilioInbox() {
|
||||||
return this.currentInbox.channel_type === 'Channel::TwilioSms';
|
return this.currentInbox.channel_type === 'Channel::TwilioSms';
|
||||||
},
|
},
|
||||||
|
// Check if a facebook inbox exists with the same instagram_id
|
||||||
|
hasDuplicateInstagramInbox() {
|
||||||
|
const instagramId = this.currentInbox.instagram_id;
|
||||||
|
const facebookInbox =
|
||||||
|
this.$store.getters['inboxes/getFacebookInboxByInstagramId'](
|
||||||
|
instagramId
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
this.currentInbox.channel_type === INBOX_TYPES.INSTAGRAM &&
|
||||||
|
facebookInbox
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
isAEmailInbox() {
|
isAEmailInbox() {
|
||||||
return this.currentInbox.channel_type === 'Channel::Email';
|
return this.currentInbox.channel_type === 'Channel::Email';
|
||||||
},
|
},
|
||||||
@ -99,8 +116,12 @@ export default {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="border border-n-weak bg-n-solid-1 rounded-t-lg border-b-0 h-full w-full p-6 col-span-6 overflow-auto"
|
class="w-full h-full col-span-6 p-6 overflow-auto border border-b-0 rounded-t-lg border-n-weak bg-n-solid-1"
|
||||||
>
|
>
|
||||||
|
<DuplicateInboxBanner
|
||||||
|
v-if="hasDuplicateInstagramInbox"
|
||||||
|
:content="$t('INBOX_MGMT.ADD.INSTAGRAM.NEW_INBOX_SUGGESTION')"
|
||||||
|
/>
|
||||||
<EmptyState
|
<EmptyState
|
||||||
:title="$t('INBOX_MGMT.FINISH.TITLE')"
|
:title="$t('INBOX_MGMT.FINISH.TITLE')"
|
||||||
:message="message"
|
:message="message"
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import SettingsSection from '../../../../components/SettingsSection.vue';
|
|||||||
import inboxMixin from 'shared/mixins/inboxMixin';
|
import inboxMixin from 'shared/mixins/inboxMixin';
|
||||||
import FacebookReauthorize from './facebook/Reauthorize.vue';
|
import FacebookReauthorize from './facebook/Reauthorize.vue';
|
||||||
import InstagramReauthorize from './channels/instagram/Reauthorize.vue';
|
import InstagramReauthorize from './channels/instagram/Reauthorize.vue';
|
||||||
|
import DuplicateInboxBanner from './channels/instagram/DuplicateInboxBanner.vue';
|
||||||
import MicrosoftReauthorize from './channels/microsoft/Reauthorize.vue';
|
import MicrosoftReauthorize from './channels/microsoft/Reauthorize.vue';
|
||||||
import GoogleReauthorize from './channels/google/Reauthorize.vue';
|
import GoogleReauthorize from './channels/google/Reauthorize.vue';
|
||||||
import PreChatFormSettings from './PreChatForm/Settings.vue';
|
import PreChatFormSettings from './PreChatForm/Settings.vue';
|
||||||
@ -20,6 +21,7 @@ import BotConfiguration from './components/BotConfiguration.vue';
|
|||||||
import { FEATURE_FLAGS } from '../../../../featureFlags';
|
import { FEATURE_FLAGS } from '../../../../featureFlags';
|
||||||
import SenderNameExamplePreview from './components/SenderNameExamplePreview.vue';
|
import SenderNameExamplePreview from './components/SenderNameExamplePreview.vue';
|
||||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||||
|
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: {
|
components: {
|
||||||
@ -38,6 +40,7 @@ export default {
|
|||||||
GoogleReauthorize,
|
GoogleReauthorize,
|
||||||
NextButton,
|
NextButton,
|
||||||
InstagramReauthorize,
|
InstagramReauthorize,
|
||||||
|
DuplicateInboxBanner,
|
||||||
},
|
},
|
||||||
mixins: [inboxMixin],
|
mixins: [inboxMixin],
|
||||||
setup() {
|
setup() {
|
||||||
@ -141,11 +144,7 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
this.isFeatureEnabledonAccount(
|
this.isFeatureEnabledonAccount(this.accountId, FEATURE_FLAGS.AGENT_BOTS)
|
||||||
this.accountId,
|
|
||||||
FEATURE_FLAGS.AGENT_BOTS
|
|
||||||
) &&
|
|
||||||
!(this.isAnEmailChannel || this.isATwitterInbox)
|
|
||||||
) {
|
) {
|
||||||
visibleToAllChannelTabs = [
|
visibleToAllChannelTabs = [
|
||||||
...visibleToAllChannelTabs,
|
...visibleToAllChannelTabs,
|
||||||
@ -208,7 +207,17 @@ export default {
|
|||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
instagramUnauthorized() {
|
instagramUnauthorized() {
|
||||||
return this.isAInstagramChannel && this.inbox.reauthorization_required;
|
return this.isAnInstagramChannel && this.inbox.reauthorization_required;
|
||||||
|
},
|
||||||
|
// Check if a instagram inbox exists with the same instagram_id
|
||||||
|
hasDuplicateInstagramInbox() {
|
||||||
|
const instagramId = this.inbox.instagram_id;
|
||||||
|
const instagramInbox =
|
||||||
|
this.$store.getters['inboxes/getInstagramInboxByInstagramId'](
|
||||||
|
instagramId
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.inbox.channel_type === INBOX_TYPES.FB && instagramInbox;
|
||||||
},
|
},
|
||||||
microsoftUnauthorized() {
|
microsoftUnauthorized() {
|
||||||
return this.isAMicrosoftInbox && this.inbox.reauthorization_required;
|
return this.isAMicrosoftInbox && this.inbox.reauthorization_required;
|
||||||
@ -396,6 +405,11 @@ export default {
|
|||||||
<FacebookReauthorize v-if="facebookUnauthorized" :inbox="inbox" />
|
<FacebookReauthorize v-if="facebookUnauthorized" :inbox="inbox" />
|
||||||
<GoogleReauthorize v-if="googleUnauthorized" :inbox="inbox" />
|
<GoogleReauthorize v-if="googleUnauthorized" :inbox="inbox" />
|
||||||
<InstagramReauthorize v-if="instagramUnauthorized" :inbox="inbox" />
|
<InstagramReauthorize v-if="instagramUnauthorized" :inbox="inbox" />
|
||||||
|
<DuplicateInboxBanner
|
||||||
|
v-if="hasDuplicateInstagramInbox"
|
||||||
|
:content="$t('INBOX_MGMT.ADD.INSTAGRAM.DUPLICATE_INBOX_BANNER')"
|
||||||
|
class="mx-8 mt-5"
|
||||||
|
/>
|
||||||
<div v-if="selectedTabKey === 'inbox_settings'" class="mx-8">
|
<div v-if="selectedTabKey === 'inbox_settings'" class="mx-8">
|
||||||
<SettingsSection
|
<SettingsSection
|
||||||
:title="$t('INBOX_MGMT.SETTINGS_POPUP.INBOX_UPDATE_TITLE')"
|
:title="$t('INBOX_MGMT.SETTINGS_POPUP.INBOX_UPDATE_TITLE')"
|
||||||
|
|||||||
@ -0,0 +1,20 @@
|
|||||||
|
<script setup>
|
||||||
|
import Banner from 'dashboard/components-next/banner/Banner.vue';
|
||||||
|
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
content: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Banner color="amber">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Icon icon="i-lucide-info" class="flex-shrink-0 size-4" />
|
||||||
|
<span>{{ content }}</span>
|
||||||
|
</div>
|
||||||
|
</Banner>
|
||||||
|
</template>
|
||||||
@ -98,6 +98,7 @@ export default {
|
|||||||
<BotMetrics :filters="requestPayload" />
|
<BotMetrics :filters="requestPayload" />
|
||||||
<ReportContainer
|
<ReportContainer
|
||||||
account-summary-key="getBotSummary"
|
account-summary-key="getBotSummary"
|
||||||
|
summary-fetching-key="getBotSummaryFetchingStatus"
|
||||||
:group-by="groupBy"
|
:group-by="groupBy"
|
||||||
:report-keys="reportKeys"
|
:report-keys="reportKeys"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -19,6 +19,10 @@ export default {
|
|||||||
type: String,
|
type: String,
|
||||||
default: 'getAccountSummary',
|
default: 'getAccountSummary',
|
||||||
},
|
},
|
||||||
|
summaryFetchingKey: {
|
||||||
|
type: String,
|
||||||
|
default: 'getAccountSummaryFetchingStatus',
|
||||||
|
},
|
||||||
reportKeys: {
|
reportKeys: {
|
||||||
type: Object,
|
type: Object,
|
||||||
default: () => ({
|
default: () => ({
|
||||||
@ -148,7 +152,11 @@ export default {
|
|||||||
:key="metric.KEY"
|
:key="metric.KEY"
|
||||||
class="p-4 mb-3 rounded-md"
|
class="p-4 mb-3 rounded-md"
|
||||||
>
|
>
|
||||||
<ChartStats :metric="metric" :account-summary-key="accountSummaryKey" />
|
<ChartStats
|
||||||
|
:metric="metric"
|
||||||
|
:account-summary-key="accountSummaryKey"
|
||||||
|
:summary-fetching-key="summaryFetchingKey"
|
||||||
|
/>
|
||||||
<div class="mt-4 h-72">
|
<div class="mt-4 h-72">
|
||||||
<woot-loading-state
|
<woot-loading-state
|
||||||
v-if="accountReport.isFetching[metric.KEY]"
|
v-if="accountReport.isFetching[metric.KEY]"
|
||||||
|
|||||||
@ -1,5 +1,8 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { useReportMetrics } from 'dashboard/composables/useReportMetrics';
|
import { useReportMetrics } from 'dashboard/composables/useReportMetrics';
|
||||||
|
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||||
|
import { STATUS } from 'dashboard/store/constants';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
metric: {
|
metric: {
|
||||||
@ -10,11 +13,16 @@ const props = defineProps({
|
|||||||
type: String,
|
type: String,
|
||||||
default: 'getAccountSummary',
|
default: 'getAccountSummary',
|
||||||
},
|
},
|
||||||
|
summaryFetchingKey: {
|
||||||
|
type: String,
|
||||||
|
default: 'getAccountSummaryFetchingStatus',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { calculateTrend, displayMetric, isAverageMetricType } = useReportMetrics(
|
const { t } = useI18n();
|
||||||
props.accountSummaryKey
|
|
||||||
);
|
const { calculateTrend, displayMetric, isAverageMetricType, fetchingStatus } =
|
||||||
|
useReportMetrics(props.accountSummaryKey, props.summaryFetchingKey);
|
||||||
|
|
||||||
const trendColor = (value, key) => {
|
const trendColor = (value, key) => {
|
||||||
if (isAverageMetricType(key)) {
|
if (isAverageMetricType(key)) {
|
||||||
@ -34,10 +42,25 @@ const trendColor = (value, key) => {
|
|||||||
{{ metric.NAME }}
|
{{ metric.NAME }}
|
||||||
</span>
|
</span>
|
||||||
<div class="flex items-end text-n-slate-12">
|
<div class="flex items-end text-n-slate-12">
|
||||||
<div class="text-xl font-medium">
|
<div v-if="fetchingStatus === STATUS.FETCHING">
|
||||||
|
<Spinner />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else-if="fetchingStatus === STATUS.FAILED"
|
||||||
|
class="text-n-ruby-10 text-sm"
|
||||||
|
>
|
||||||
|
{{ t('REPORT.SUMMARY_FETCHING_FAILED') }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else-if="fetchingStatus === STATUS.FINISHED"
|
||||||
|
class="text-xl font-medium"
|
||||||
|
>
|
||||||
{{ displayMetric(metric.KEY) }}
|
{{ displayMetric(metric.KEY) }}
|
||||||
</div>
|
</div>
|
||||||
<div v-if="metric.trend" class="text-xs ml-4 flex items-center mb-0.5">
|
<div
|
||||||
|
v-if="metric.trend && fetchingStatus === STATUS.FINISHED"
|
||||||
|
class="text-xs ml-4 flex items-center mb-0.5"
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
v-if="metric.trend < 0"
|
v-if="metric.trend < 0"
|
||||||
class="h-0 w-0 border-x-4 medium border-x-transparent border-t-[8px] mr-1"
|
class="h-0 w-0 border-x-4 medium border-x-transparent border-t-[8px] mr-1"
|
||||||
|
|||||||
5
app/javascript/dashboard/store/constants.js
Normal file
5
app/javascript/dashboard/store/constants.js
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export const STATUS = {
|
||||||
|
FAILED: 'failed',
|
||||||
|
FETCHING: 'fetching',
|
||||||
|
FINISHED: 'finished',
|
||||||
|
};
|
||||||
@ -12,6 +12,7 @@ export const state = {
|
|||||||
isCreating: false,
|
isCreating: false,
|
||||||
isDeleting: false,
|
isDeleting: false,
|
||||||
isUpdating: false,
|
isUpdating: false,
|
||||||
|
isUpdatingAvatar: false,
|
||||||
isFetchingAgentBot: false,
|
isFetchingAgentBot: false,
|
||||||
isSettingAgentBot: false,
|
isSettingAgentBot: false,
|
||||||
isDisconnecting: false,
|
isDisconnecting: false,
|
||||||
@ -48,10 +49,23 @@ export const actions = {
|
|||||||
commit(types.SET_AGENT_BOT_UI_FLAG, { isFetching: false });
|
commit(types.SET_AGENT_BOT_UI_FLAG, { isFetching: false });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
create: async ({ commit }, agentBotObj) => {
|
|
||||||
|
create: async ({ commit }, botData) => {
|
||||||
commit(types.SET_AGENT_BOT_UI_FLAG, { isCreating: true });
|
commit(types.SET_AGENT_BOT_UI_FLAG, { isCreating: true });
|
||||||
try {
|
try {
|
||||||
const response = await AgentBotsAPI.create(agentBotObj);
|
// Create FormData for file upload
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('name', botData.name);
|
||||||
|
formData.append('description', botData.description);
|
||||||
|
formData.append('bot_type', botData.bot_type || 'webhook');
|
||||||
|
formData.append('outgoing_url', botData.outgoing_url);
|
||||||
|
|
||||||
|
// Add avatar file if available
|
||||||
|
if (botData.avatar) {
|
||||||
|
formData.append('avatar', botData.avatar);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await AgentBotsAPI.create(formData);
|
||||||
commit(types.ADD_AGENT_BOT, response.data);
|
commit(types.ADD_AGENT_BOT, response.data);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -61,10 +75,22 @@ export const actions = {
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
update: async ({ commit }, { id, ...agentBotObj }) => {
|
|
||||||
|
update: async ({ commit }, { id, data }) => {
|
||||||
commit(types.SET_AGENT_BOT_UI_FLAG, { isUpdating: true });
|
commit(types.SET_AGENT_BOT_UI_FLAG, { isUpdating: true });
|
||||||
try {
|
try {
|
||||||
const response = await AgentBotsAPI.update(id, agentBotObj);
|
// Create FormData for file upload
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('name', data.name);
|
||||||
|
formData.append('description', data.description);
|
||||||
|
formData.append('bot_type', data.bot_type || 'webhook');
|
||||||
|
formData.append('outgoing_url', data.outgoing_url);
|
||||||
|
|
||||||
|
if (data.avatar) {
|
||||||
|
formData.append('avatar', data.avatar);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await AgentBotsAPI.update(id, formData);
|
||||||
commit(types.EDIT_AGENT_BOT, response.data);
|
commit(types.EDIT_AGENT_BOT, response.data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throwErrorMessage(error);
|
throwErrorMessage(error);
|
||||||
@ -72,6 +98,7 @@ export const actions = {
|
|||||||
commit(types.SET_AGENT_BOT_UI_FLAG, { isUpdating: false });
|
commit(types.SET_AGENT_BOT_UI_FLAG, { isUpdating: false });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
delete: async ({ commit }, id) => {
|
delete: async ({ commit }, id) => {
|
||||||
commit(types.SET_AGENT_BOT_UI_FLAG, { isDeleting: true });
|
commit(types.SET_AGENT_BOT_UI_FLAG, { isDeleting: true });
|
||||||
try {
|
try {
|
||||||
@ -83,6 +110,20 @@ export const actions = {
|
|||||||
commit(types.SET_AGENT_BOT_UI_FLAG, { isDeleting: false });
|
commit(types.SET_AGENT_BOT_UI_FLAG, { isDeleting: false });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
deleteAgentBotAvatar: async ({ commit }, id) => {
|
||||||
|
commit(types.SET_AGENT_BOT_UI_FLAG, { isUpdatingAvatar: true });
|
||||||
|
try {
|
||||||
|
await AgentBotsAPI.deleteAgentBotAvatar(id);
|
||||||
|
// Update the thumbnail to empty string after deletion
|
||||||
|
commit(types.UPDATE_AGENT_BOT_AVATAR, { id, thumbnail: '' });
|
||||||
|
} catch (error) {
|
||||||
|
throwErrorMessage(error);
|
||||||
|
} finally {
|
||||||
|
commit(types.SET_AGENT_BOT_UI_FLAG, { isUpdatingAvatar: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
show: async ({ commit }, id) => {
|
show: async ({ commit }, id) => {
|
||||||
commit(types.SET_AGENT_BOT_UI_FLAG, { isFetchingItem: true });
|
commit(types.SET_AGENT_BOT_UI_FLAG, { isFetchingItem: true });
|
||||||
try {
|
try {
|
||||||
@ -150,6 +191,12 @@ export const mutations = {
|
|||||||
[inboxId]: agentBotId,
|
[inboxId]: agentBotId,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
[types.UPDATE_AGENT_BOT_AVATAR]($state, { id, thumbnail }) {
|
||||||
|
const botIndex = $state.records.findIndex(bot => bot.id === id);
|
||||||
|
if (botIndex !== -1) {
|
||||||
|
$state.records[botIndex].thumbnail = thumbnail || '';
|
||||||
|
}
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
|||||||
@ -122,6 +122,20 @@ export const getters = {
|
|||||||
item => item.channel_type !== INBOX_TYPES.EMAIL
|
item => item.channel_type !== INBOX_TYPES.EMAIL
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
getFacebookInboxByInstagramId: $state => instagramId => {
|
||||||
|
return $state.records.find(
|
||||||
|
item =>
|
||||||
|
item.instagram_id === instagramId &&
|
||||||
|
item.channel_type === INBOX_TYPES.FB
|
||||||
|
);
|
||||||
|
},
|
||||||
|
getInstagramInboxByInstagramId: $state => instagramId => {
|
||||||
|
return $state.records.find(
|
||||||
|
item =>
|
||||||
|
item.instagram_id === instagramId &&
|
||||||
|
item.channel_type === INBOX_TYPES.INSTAGRAM
|
||||||
|
);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const sendAnalyticsEvent = channelType => {
|
const sendAnalyticsEvent = channelType => {
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
/* eslint no-console: 0 */
|
/* eslint no-console: 0 */
|
||||||
import * as types from '../mutation-types';
|
import * as types from '../mutation-types';
|
||||||
|
import { STATUS } from '../constants';
|
||||||
import Report from '../../api/reports';
|
import Report from '../../api/reports';
|
||||||
import { downloadCsvFile, generateFileName } from '../../helper/downloadHelper';
|
import { downloadCsvFile, generateFileName } from '../../helper/downloadHelper';
|
||||||
import AnalyticsHelper from '../../helper/AnalyticsHelper';
|
import AnalyticsHelper from '../../helper/AnalyticsHelper';
|
||||||
@ -9,6 +10,8 @@ import liveReports from '../../api/liveReports';
|
|||||||
|
|
||||||
const state = {
|
const state = {
|
||||||
fetchingStatus: false,
|
fetchingStatus: false,
|
||||||
|
accountSummaryFetchingStatus: STATUS.FINISHED,
|
||||||
|
botSummaryFetchingStatus: STATUS.FINISHED,
|
||||||
accountReport: {
|
accountReport: {
|
||||||
isFetching: {
|
isFetching: {
|
||||||
conversations_count: false,
|
conversations_count: false,
|
||||||
@ -74,6 +77,12 @@ const getters = {
|
|||||||
getBotSummary(_state) {
|
getBotSummary(_state) {
|
||||||
return _state.botSummary;
|
return _state.botSummary;
|
||||||
},
|
},
|
||||||
|
getAccountSummaryFetchingStatus(_state) {
|
||||||
|
return _state.accountSummaryFetchingStatus;
|
||||||
|
},
|
||||||
|
getBotSummaryFetchingStatus(_state) {
|
||||||
|
return _state.botSummaryFetchingStatus;
|
||||||
|
},
|
||||||
getAccountConversationMetric(_state) {
|
getAccountConversationMetric(_state) {
|
||||||
return _state.overview.accountConversationMetric;
|
return _state.overview.accountConversationMetric;
|
||||||
},
|
},
|
||||||
@ -122,6 +131,7 @@ export const actions = {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
fetchAccountSummary({ commit }, reportObj) {
|
fetchAccountSummary({ commit }, reportObj) {
|
||||||
|
commit(types.default.SET_ACCOUNT_SUMMARY_STATUS, STATUS.FETCHING);
|
||||||
Report.getSummary(
|
Report.getSummary(
|
||||||
reportObj.from,
|
reportObj.from,
|
||||||
reportObj.to,
|
reportObj.to,
|
||||||
@ -132,12 +142,14 @@ export const actions = {
|
|||||||
)
|
)
|
||||||
.then(accountSummary => {
|
.then(accountSummary => {
|
||||||
commit(types.default.SET_ACCOUNT_SUMMARY, accountSummary.data);
|
commit(types.default.SET_ACCOUNT_SUMMARY, accountSummary.data);
|
||||||
|
commit(types.default.SET_ACCOUNT_SUMMARY_STATUS, STATUS.FINISHED);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
commit(types.default.TOGGLE_ACCOUNT_REPORT_LOADING, false);
|
commit(types.default.SET_ACCOUNT_SUMMARY_STATUS, STATUS.FAILED);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
fetchBotSummary({ commit }, reportObj) {
|
fetchBotSummary({ commit }, reportObj) {
|
||||||
|
commit(types.default.SET_BOT_SUMMARY_STATUS, STATUS.FETCHING);
|
||||||
Report.getBotSummary({
|
Report.getBotSummary({
|
||||||
from: reportObj.from,
|
from: reportObj.from,
|
||||||
to: reportObj.to,
|
to: reportObj.to,
|
||||||
@ -146,9 +158,10 @@ export const actions = {
|
|||||||
})
|
})
|
||||||
.then(botSummary => {
|
.then(botSummary => {
|
||||||
commit(types.default.SET_BOT_SUMMARY, botSummary.data);
|
commit(types.default.SET_BOT_SUMMARY, botSummary.data);
|
||||||
|
commit(types.default.SET_BOT_SUMMARY_STATUS, STATUS.FINISHED);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
commit(types.default.TOGGLE_ACCOUNT_REPORT_LOADING, false);
|
commit(types.default.SET_BOT_SUMMARY_STATUS, STATUS.FAILED);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
fetchAccountConversationMetric({ commit }, params = {}) {
|
fetchAccountConversationMetric({ commit }, params = {}) {
|
||||||
@ -277,6 +290,12 @@ const mutations = {
|
|||||||
[types.default.TOGGLE_ACCOUNT_REPORT_LOADING](_state, { metric, value }) {
|
[types.default.TOGGLE_ACCOUNT_REPORT_LOADING](_state, { metric, value }) {
|
||||||
_state.accountReport.isFetching[metric] = value;
|
_state.accountReport.isFetching[metric] = value;
|
||||||
},
|
},
|
||||||
|
[types.default.SET_BOT_SUMMARY_STATUS](_state, status) {
|
||||||
|
_state.botSummaryFetchingStatus = status;
|
||||||
|
},
|
||||||
|
[types.default.SET_ACCOUNT_SUMMARY_STATUS](_state, status) {
|
||||||
|
_state.accountSummaryFetchingStatus = status;
|
||||||
|
},
|
||||||
[types.default.TOGGLE_HEATMAP_LOADING](_state, flag) {
|
[types.default.TOGGLE_HEATMAP_LOADING](_state, flag) {
|
||||||
_state.overview.uiFlags.isFetchingAccountConversationsHeatmap = flag;
|
_state.overview.uiFlags.isFetchingAccountConversationsHeatmap = flag;
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { actions } from '../../agentBots';
|
import { actions } from '../../agentBots';
|
||||||
import types from '../../../mutation-types';
|
import types from '../../../mutation-types';
|
||||||
import { agentBotRecords } from './fixtures';
|
import { agentBotRecords, agentBotData } from './fixtures';
|
||||||
|
|
||||||
const commit = vi.fn();
|
const commit = vi.fn();
|
||||||
global.axios = axios;
|
global.axios = axios;
|
||||||
@ -30,16 +30,22 @@ describe('#actions', () => {
|
|||||||
describe('#create', () => {
|
describe('#create', () => {
|
||||||
it('sends correct actions if API is success', async () => {
|
it('sends correct actions if API is success', async () => {
|
||||||
axios.post.mockResolvedValue({ data: agentBotRecords[0] });
|
axios.post.mockResolvedValue({ data: agentBotRecords[0] });
|
||||||
await actions.create({ commit }, agentBotRecords[0]);
|
await actions.create({ commit }, agentBotData);
|
||||||
|
|
||||||
expect(commit.mock.calls).toEqual([
|
expect(commit.mock.calls).toEqual([
|
||||||
[types.SET_AGENT_BOT_UI_FLAG, { isCreating: true }],
|
[types.SET_AGENT_BOT_UI_FLAG, { isCreating: true }],
|
||||||
[types.ADD_AGENT_BOT, agentBotRecords[0]],
|
[types.ADD_AGENT_BOT, agentBotRecords[0]],
|
||||||
[types.SET_AGENT_BOT_UI_FLAG, { isCreating: false }],
|
[types.SET_AGENT_BOT_UI_FLAG, { isCreating: false }],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
expect(axios.post.mock.calls.length).toBe(1);
|
||||||
|
const formDataArg = axios.post.mock.calls[0][1];
|
||||||
|
expect(formDataArg instanceof FormData).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('sends correct actions if API is error', async () => {
|
it('sends correct actions if API is error', async () => {
|
||||||
axios.post.mockRejectedValue({ message: 'Incorrect header' });
|
axios.post.mockRejectedValue({ message: 'Incorrect header' });
|
||||||
await expect(actions.create({ commit })).rejects.toThrow(Error);
|
await expect(actions.create({ commit }, {})).rejects.toThrow(Error);
|
||||||
expect(commit.mock.calls).toEqual([
|
expect(commit.mock.calls).toEqual([
|
||||||
[types.SET_AGENT_BOT_UI_FLAG, { isCreating: true }],
|
[types.SET_AGENT_BOT_UI_FLAG, { isCreating: true }],
|
||||||
[types.SET_AGENT_BOT_UI_FLAG, { isCreating: false }],
|
[types.SET_AGENT_BOT_UI_FLAG, { isCreating: false }],
|
||||||
@ -50,17 +56,29 @@ describe('#actions', () => {
|
|||||||
describe('#update', () => {
|
describe('#update', () => {
|
||||||
it('sends correct actions if API is success', async () => {
|
it('sends correct actions if API is success', async () => {
|
||||||
axios.patch.mockResolvedValue({ data: agentBotRecords[0] });
|
axios.patch.mockResolvedValue({ data: agentBotRecords[0] });
|
||||||
await actions.update({ commit }, agentBotRecords[0]);
|
await actions.update(
|
||||||
|
{ commit },
|
||||||
|
{
|
||||||
|
id: agentBotRecords[0].id,
|
||||||
|
data: agentBotData,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
expect(commit.mock.calls).toEqual([
|
expect(commit.mock.calls).toEqual([
|
||||||
[types.SET_AGENT_BOT_UI_FLAG, { isUpdating: true }],
|
[types.SET_AGENT_BOT_UI_FLAG, { isUpdating: true }],
|
||||||
[types.EDIT_AGENT_BOT, agentBotRecords[0]],
|
[types.EDIT_AGENT_BOT, agentBotRecords[0]],
|
||||||
[types.SET_AGENT_BOT_UI_FLAG, { isUpdating: false }],
|
[types.SET_AGENT_BOT_UI_FLAG, { isUpdating: false }],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
expect(axios.patch.mock.calls.length).toBe(1);
|
||||||
|
const formDataArg = axios.patch.mock.calls[0][1];
|
||||||
|
expect(formDataArg instanceof FormData).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('sends correct actions if API is error', async () => {
|
it('sends correct actions if API is error', async () => {
|
||||||
axios.patch.mockRejectedValue({ message: 'Incorrect header' });
|
axios.patch.mockRejectedValue({ message: 'Incorrect header' });
|
||||||
await expect(
|
await expect(
|
||||||
actions.update({ commit }, agentBotRecords[0])
|
actions.update({ commit }, { id: 1, data: {} })
|
||||||
).rejects.toThrow(Error);
|
).rejects.toThrow(Error);
|
||||||
expect(commit.mock.calls).toEqual([
|
expect(commit.mock.calls).toEqual([
|
||||||
[types.SET_AGENT_BOT_UI_FLAG, { isUpdating: true }],
|
[types.SET_AGENT_BOT_UI_FLAG, { isUpdating: true }],
|
||||||
@ -68,7 +86,6 @@ describe('#actions', () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('#delete', () => {
|
describe('#delete', () => {
|
||||||
it('sends correct actions if API is success', async () => {
|
it('sends correct actions if API is success', async () => {
|
||||||
axios.delete.mockResolvedValue({ data: agentBotRecords[0] });
|
axios.delete.mockResolvedValue({ data: agentBotRecords[0] });
|
||||||
|
|||||||
@ -1,15 +1,35 @@
|
|||||||
export const agentBotRecords = [
|
export const agentBotRecords = [
|
||||||
{
|
{
|
||||||
|
account_id: 1,
|
||||||
id: 11,
|
id: 11,
|
||||||
name: 'Agent Bot 11',
|
name: 'Agent Bot 11',
|
||||||
description: 'Agent Bot Description',
|
description: 'Agent Bot Description',
|
||||||
type: 'csml',
|
bot_type: 'webhook',
|
||||||
|
thumbnail: 'https://example.com/thumbnail.jpg',
|
||||||
|
bot_config: {},
|
||||||
|
outgoing_url: 'https://example.com/outgoing',
|
||||||
|
access_token: 'hN8QwG769RqBXmme',
|
||||||
|
system_bot: false,
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
|
account_id: 1,
|
||||||
id: 12,
|
id: 12,
|
||||||
name: 'Agent Bot 12',
|
name: 'Agent Bot 12',
|
||||||
description: 'Agent Bot Description 12',
|
description: 'Agent Bot Description 12',
|
||||||
type: 'csml',
|
bot_type: 'webhook',
|
||||||
|
thumbnail: 'https://example.com/thumbnail.jpg',
|
||||||
|
bot_config: {},
|
||||||
|
outgoing_url: 'https://example.com/outgoing',
|
||||||
|
access_token: 'hN8QwG769RqBXmme',
|
||||||
|
system_bot: false,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export const agentBotData = {
|
||||||
|
name: 'Test Bot',
|
||||||
|
description: 'Test Description',
|
||||||
|
outgoing_url: 'https://test.com',
|
||||||
|
bot_type: 'webhook',
|
||||||
|
avatar: new File([''], 'filename'),
|
||||||
|
};
|
||||||
|
|||||||
@ -51,4 +51,16 @@ describe('#mutations', () => {
|
|||||||
expect(state.agentBotInbox).toEqual({ 3: 2 });
|
expect(state.agentBotInbox).toEqual({ 3: 2 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
describe('#UPDATE_AGENT_BOT_AVATAR', () => {
|
||||||
|
it('update agent bot avatar', () => {
|
||||||
|
const state = { records: [agentBotRecords[0]] };
|
||||||
|
mutations[types.UPDATE_AGENT_BOT_AVATAR](state, {
|
||||||
|
id: 11,
|
||||||
|
thumbnail: 'https://example.com/thumbnail.jpg',
|
||||||
|
});
|
||||||
|
expect(state.records[0].thumbnail).toEqual(
|
||||||
|
'https://example.com/thumbnail.jpg'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -9,6 +9,7 @@ export default [
|
|||||||
widget_color: null,
|
widget_color: null,
|
||||||
website_token: null,
|
website_token: null,
|
||||||
enable_auto_assignment: true,
|
enable_auto_assignment: true,
|
||||||
|
instagram_id: 123456789,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 2,
|
id: 2,
|
||||||
@ -62,4 +63,12 @@ export default [
|
|||||||
channel_type: 'Channel::Sms',
|
channel_type: 'Channel::Sms',
|
||||||
provider: 'default',
|
provider: 'default',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 7,
|
||||||
|
channel_id: 7,
|
||||||
|
name: 'Test Instagram 1',
|
||||||
|
channel_type: 'Channel::Instagram',
|
||||||
|
instagram_id: 123456789,
|
||||||
|
provider: 'default',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@ -26,7 +26,7 @@ describe('#getters', () => {
|
|||||||
|
|
||||||
it('dialogFlowEnabledInboxes', () => {
|
it('dialogFlowEnabledInboxes', () => {
|
||||||
const state = { records: inboxList };
|
const state = { records: inboxList };
|
||||||
expect(getters.dialogFlowEnabledInboxes(state).length).toEqual(6);
|
expect(getters.dialogFlowEnabledInboxes(state).length).toEqual(7);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('getInbox', () => {
|
it('getInbox', () => {
|
||||||
@ -43,6 +43,7 @@ describe('#getters', () => {
|
|||||||
widget_color: null,
|
widget_color: null,
|
||||||
website_token: null,
|
website_token: null,
|
||||||
enable_auto_assignment: true,
|
enable_auto_assignment: true,
|
||||||
|
instagram_id: 123456789,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -64,4 +65,32 @@ describe('#getters', () => {
|
|||||||
isDeleting: false,
|
isDeleting: false,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('getFacebookInboxByInstagramId', () => {
|
||||||
|
const state = { records: inboxList };
|
||||||
|
expect(getters.getFacebookInboxByInstagramId(state)(123456789)).toEqual({
|
||||||
|
id: 1,
|
||||||
|
channel_id: 1,
|
||||||
|
name: 'Test FacebookPage 1',
|
||||||
|
channel_type: 'Channel::FacebookPage',
|
||||||
|
avatar_url: 'random_image.png',
|
||||||
|
page_id: '12345',
|
||||||
|
widget_color: null,
|
||||||
|
website_token: null,
|
||||||
|
enable_auto_assignment: true,
|
||||||
|
instagram_id: 123456789,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getInstagramInboxByInstagramId', () => {
|
||||||
|
const state = { records: inboxList };
|
||||||
|
expect(getters.getInstagramInboxByInstagramId(state)(123456789)).toEqual({
|
||||||
|
id: 7,
|
||||||
|
channel_id: 7,
|
||||||
|
name: 'Test Instagram 1',
|
||||||
|
channel_type: 'Channel::Instagram',
|
||||||
|
instagram_id: 123456789,
|
||||||
|
provider: 'default',
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,14 +1,122 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { actions } from '../../reports';
|
import { actions } from '../../reports';
|
||||||
|
import * as types from '../../../mutation-types';
|
||||||
|
import { STATUS } from '../../../constants';
|
||||||
import * as DownloadHelper from 'dashboard/helper/downloadHelper';
|
import * as DownloadHelper from 'dashboard/helper/downloadHelper';
|
||||||
|
import { flushPromises } from '@vue/test-utils';
|
||||||
|
|
||||||
global.open = vi.fn();
|
global.open = vi.fn();
|
||||||
global.axios = axios;
|
global.axios = axios;
|
||||||
|
global.URL.createObjectURL = vi.fn();
|
||||||
|
|
||||||
vi.mock('axios');
|
vi.mock('axios');
|
||||||
vi.spyOn(DownloadHelper, 'downloadCsvFile');
|
vi.spyOn(DownloadHelper, 'downloadCsvFile');
|
||||||
|
|
||||||
describe('#actions', () => {
|
describe('#actions', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('#fetchAccountSummary', () => {
|
||||||
|
it('sends correct actions if API is success', async () => {
|
||||||
|
const commit = vi.fn();
|
||||||
|
const reportObj = {
|
||||||
|
from: 1630504922510,
|
||||||
|
to: 1630504922510,
|
||||||
|
type: 'account',
|
||||||
|
id: 1,
|
||||||
|
groupBy: 'day',
|
||||||
|
businessHours: true,
|
||||||
|
};
|
||||||
|
const summaryData = {
|
||||||
|
conversations_count: 10,
|
||||||
|
incoming_messages_count: 20,
|
||||||
|
outgoing_messages_count: 15,
|
||||||
|
avg_first_response_time: 30,
|
||||||
|
avg_resolution_time: 60,
|
||||||
|
resolutions_count: 5,
|
||||||
|
bot_resolutions_count: 2,
|
||||||
|
bot_handoffs_count: 1,
|
||||||
|
reply_time: 25,
|
||||||
|
};
|
||||||
|
axios.get.mockResolvedValue({ data: summaryData });
|
||||||
|
|
||||||
|
actions.fetchAccountSummary({ commit }, reportObj);
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(commit.mock.calls).toEqual([
|
||||||
|
[types.default.SET_ACCOUNT_SUMMARY_STATUS, STATUS.FETCHING],
|
||||||
|
[types.default.SET_ACCOUNT_SUMMARY, summaryData],
|
||||||
|
[types.default.SET_ACCOUNT_SUMMARY_STATUS, STATUS.FINISHED],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends correct actions if API fails', async () => {
|
||||||
|
const commit = vi.fn();
|
||||||
|
const reportObj = {
|
||||||
|
from: 1630504922510,
|
||||||
|
to: 1630504922510,
|
||||||
|
};
|
||||||
|
axios.get.mockRejectedValue(new Error('API Error'));
|
||||||
|
|
||||||
|
actions.fetchAccountSummary({ commit }, reportObj);
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(commit.mock.calls).toEqual([
|
||||||
|
[types.default.SET_ACCOUNT_SUMMARY_STATUS, STATUS.FETCHING],
|
||||||
|
[types.default.SET_ACCOUNT_SUMMARY_STATUS, STATUS.FAILED],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('#fetchBotSummary', () => {
|
||||||
|
it('sends correct actions if API is success', async () => {
|
||||||
|
const commit = vi.fn();
|
||||||
|
const reportObj = {
|
||||||
|
from: 1630504922510,
|
||||||
|
to: 1630504922510,
|
||||||
|
groupBy: 'day',
|
||||||
|
businessHours: true,
|
||||||
|
};
|
||||||
|
const summaryData = {
|
||||||
|
bot_resolutions_count: 10,
|
||||||
|
bot_handoffs_count: 5,
|
||||||
|
previous: {
|
||||||
|
bot_resolutions_count: 8,
|
||||||
|
bot_handoffs_count: 4,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
axios.get.mockResolvedValue({ data: summaryData });
|
||||||
|
|
||||||
|
actions.fetchBotSummary({ commit }, reportObj);
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(commit.mock.calls).toEqual([
|
||||||
|
[types.default.SET_BOT_SUMMARY_STATUS, STATUS.FETCHING],
|
||||||
|
[types.default.SET_BOT_SUMMARY, summaryData],
|
||||||
|
[types.default.SET_BOT_SUMMARY_STATUS, STATUS.FINISHED],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends correct actions if API fails', async () => {
|
||||||
|
const commit = vi.fn();
|
||||||
|
const reportObj = {
|
||||||
|
from: 1630504922510,
|
||||||
|
to: 1630504922510,
|
||||||
|
};
|
||||||
|
const error = new Error('API error');
|
||||||
|
axios.get.mockRejectedValueOnce(error);
|
||||||
|
|
||||||
|
actions.fetchBotSummary({ commit }, reportObj);
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(commit.mock.calls).toEqual([
|
||||||
|
[types.default.SET_BOT_SUMMARY_STATUS, STATUS.FETCHING],
|
||||||
|
[types.default.SET_BOT_SUMMARY_STATUS, STATUS.FAILED],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('#downloadAgentReports', () => {
|
describe('#downloadAgentReports', () => {
|
||||||
it('open CSV download prompt if API is success', async () => {
|
it('open CSV download prompt if API is success', async () => {
|
||||||
const data = `Agent name,Conversations count,Avg first response time (Minutes),Avg resolution time (Minutes)
|
const data = `Agent name,Conversations count,Avg first response time (Minutes),Avg resolution time (Minutes)
|
||||||
@ -20,7 +128,9 @@ describe('#actions', () => {
|
|||||||
to: 1630504922510,
|
to: 1630504922510,
|
||||||
fileName: 'agent-report-01-09-2021.csv',
|
fileName: 'agent-report-01-09-2021.csv',
|
||||||
};
|
};
|
||||||
await actions.downloadAgentReports(1, param);
|
actions.downloadAgentReports(1, param);
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
expect(DownloadHelper.downloadCsvFile).toBeCalledWith(
|
expect(DownloadHelper.downloadCsvFile).toBeCalledWith(
|
||||||
param.fileName,
|
param.fileName,
|
||||||
data
|
data
|
||||||
@ -39,7 +149,9 @@ describe('#actions', () => {
|
|||||||
type: 'label',
|
type: 'label',
|
||||||
fileName: 'label-report-01-09-2021.csv',
|
fileName: 'label-report-01-09-2021.csv',
|
||||||
};
|
};
|
||||||
await actions.downloadLabelReports(1, param);
|
actions.downloadLabelReports(1, param);
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
expect(DownloadHelper.downloadCsvFile).toBeCalledWith(
|
expect(DownloadHelper.downloadCsvFile).toBeCalledWith(
|
||||||
param.fileName,
|
param.fileName,
|
||||||
data
|
data
|
||||||
@ -59,7 +171,9 @@ describe('#actions', () => {
|
|||||||
to: 1635013800,
|
to: 1635013800,
|
||||||
fileName: 'inbox-report-24-10-2021.csv',
|
fileName: 'inbox-report-24-10-2021.csv',
|
||||||
};
|
};
|
||||||
await actions.downloadInboxReports(1, param);
|
actions.downloadInboxReports(1, param);
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
expect(DownloadHelper.downloadCsvFile).toBeCalledWith(
|
expect(DownloadHelper.downloadCsvFile).toBeCalledWith(
|
||||||
param.fileName,
|
param.fileName,
|
||||||
data
|
data
|
||||||
@ -78,7 +192,9 @@ describe('#actions', () => {
|
|||||||
to: 1635013800,
|
to: 1635013800,
|
||||||
fileName: 'inbox-report-24-10-2021.csv',
|
fileName: 'inbox-report-24-10-2021.csv',
|
||||||
};
|
};
|
||||||
await actions.downloadInboxReports(1, param);
|
actions.downloadInboxReports(1, param);
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
expect(DownloadHelper.downloadCsvFile).toBeCalledWith(
|
expect(DownloadHelper.downloadCsvFile).toBeCalledWith(
|
||||||
param.fileName,
|
param.fileName,
|
||||||
data
|
data
|
||||||
|
|||||||
@ -189,6 +189,8 @@ export default {
|
|||||||
SET_ACCOUNT_SUMMARY: 'SET_ACCOUNT_SUMMARY',
|
SET_ACCOUNT_SUMMARY: 'SET_ACCOUNT_SUMMARY',
|
||||||
SET_BOT_SUMMARY: 'SET_BOT_SUMMARY',
|
SET_BOT_SUMMARY: 'SET_BOT_SUMMARY',
|
||||||
TOGGLE_ACCOUNT_REPORT_LOADING: 'TOGGLE_ACCOUNT_REPORT_LOADING',
|
TOGGLE_ACCOUNT_REPORT_LOADING: 'TOGGLE_ACCOUNT_REPORT_LOADING',
|
||||||
|
SET_BOT_SUMMARY_STATUS: 'SET_BOT_SUMMARY_STATUS',
|
||||||
|
SET_ACCOUNT_SUMMARY_STATUS: 'SET_ACCOUNT_SUMMARY_STATUS',
|
||||||
SET_ACCOUNT_CONVERSATION_METRIC: 'SET_ACCOUNT_CONVERSATION_METRIC',
|
SET_ACCOUNT_CONVERSATION_METRIC: 'SET_ACCOUNT_CONVERSATION_METRIC',
|
||||||
TOGGLE_ACCOUNT_CONVERSATION_METRIC_LOADING:
|
TOGGLE_ACCOUNT_CONVERSATION_METRIC_LOADING:
|
||||||
'TOGGLE_ACCOUNT_CONVERSATION_METRIC_LOADING',
|
'TOGGLE_ACCOUNT_CONVERSATION_METRIC_LOADING',
|
||||||
@ -299,6 +301,7 @@ export default {
|
|||||||
EDIT_AGENT_BOT: 'EDIT_AGENT_BOT',
|
EDIT_AGENT_BOT: 'EDIT_AGENT_BOT',
|
||||||
DELETE_AGENT_BOT: 'DELETE_AGENT_BOT',
|
DELETE_AGENT_BOT: 'DELETE_AGENT_BOT',
|
||||||
SET_AGENT_BOT_INBOX: 'SET_AGENT_BOT_INBOX',
|
SET_AGENT_BOT_INBOX: 'SET_AGENT_BOT_INBOX',
|
||||||
|
UPDATE_AGENT_BOT_AVATAR: 'UPDATE_AGENT_BOT_AVATAR',
|
||||||
|
|
||||||
// MACROS
|
// MACROS
|
||||||
SET_MACROS_UI_FLAG: 'SET_MACROS_UI_FLAG',
|
SET_MACROS_UI_FLAG: 'SET_MACROS_UI_FLAG',
|
||||||
|
|||||||
@ -22,7 +22,11 @@ import {
|
|||||||
} from './bubbleHelpers';
|
} from './bubbleHelpers';
|
||||||
import { isWidgetColorLighter } from 'shared/helpers/colorHelper';
|
import { isWidgetColorLighter } from 'shared/helpers/colorHelper';
|
||||||
import { dispatchWindowEvent } from 'shared/helpers/CustomEventHelper';
|
import { dispatchWindowEvent } from 'shared/helpers/CustomEventHelper';
|
||||||
import { CHATWOOT_ERROR, CHATWOOT_READY } from '../widget/constants/sdkEvents';
|
import {
|
||||||
|
CHATWOOT_ERROR,
|
||||||
|
CHATWOOT_POSTBACK,
|
||||||
|
CHATWOOT_READY,
|
||||||
|
} from '../widget/constants/sdkEvents';
|
||||||
import { SET_USER_ERROR } from '../widget/constants/errorTypes';
|
import { SET_USER_ERROR } from '../widget/constants/errorTypes';
|
||||||
import { getUserCookieName, setCookieWithDomain } from './cookieHelpers';
|
import { getUserCookieName, setCookieWithDomain } from './cookieHelpers';
|
||||||
import {
|
import {
|
||||||
@ -205,7 +209,7 @@ export const IFrameHelper = {
|
|||||||
|
|
||||||
postback(data) {
|
postback(data) {
|
||||||
dispatchWindowEvent({
|
dispatchWindowEvent({
|
||||||
eventName: 'chatwoot:postback',
|
eventName: CHATWOOT_POSTBACK,
|
||||||
data,
|
data,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,6 +1,11 @@
|
|||||||
import { addClasses, removeClasses, toggleClass } from './DOMHelpers';
|
import { addClasses, removeClasses, toggleClass } from './DOMHelpers';
|
||||||
import { IFrameHelper } from './IFrameHelper';
|
import { IFrameHelper } from './IFrameHelper';
|
||||||
import { isExpandedView } from './settingsHelper';
|
import { isExpandedView } from './settingsHelper';
|
||||||
|
import {
|
||||||
|
CHATWOOT_CLOSED,
|
||||||
|
CHATWOOT_OPENED,
|
||||||
|
} from '../widget/constants/sdkEvents';
|
||||||
|
import { dispatchWindowEvent } from 'shared/helpers/CustomEventHelper';
|
||||||
|
|
||||||
export const bubbleSVG =
|
export const bubbleSVG =
|
||||||
'M240.808 240.808H122.123C56.6994 240.808 3.45695 187.562 3.45695 122.122C3.45695 56.7031 56.6994 3.45697 122.124 3.45697C187.566 3.45697 240.808 56.7031 240.808 122.122V240.808Z';
|
'M240.808 240.808H122.123C56.6994 240.808 3.45695 187.562 3.45695 122.122C3.45695 56.7031 56.6994 3.45697 122.124 3.45697C187.566 3.45697 240.808 56.7031 240.808 122.122V240.808Z';
|
||||||
@ -65,22 +70,30 @@ export const createBubbleHolder = hideMessageBubble => {
|
|||||||
body.appendChild(bubbleHolder);
|
body.appendChild(bubbleHolder);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleBubbleToggle = newIsOpen => {
|
||||||
|
IFrameHelper.events.onBubbleToggle(newIsOpen);
|
||||||
|
|
||||||
|
if (newIsOpen) {
|
||||||
|
dispatchWindowEvent({ eventName: CHATWOOT_OPENED });
|
||||||
|
} else {
|
||||||
|
dispatchWindowEvent({ eventName: CHATWOOT_CLOSED });
|
||||||
|
chatBubble.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const onBubbleClick = (props = {}) => {
|
export const onBubbleClick = (props = {}) => {
|
||||||
const { toggleValue } = props;
|
const { toggleValue } = props;
|
||||||
const { isOpen } = window.$chatwoot;
|
const { isOpen } = window.$chatwoot;
|
||||||
if (isOpen !== toggleValue) {
|
if (isOpen === toggleValue) return;
|
||||||
const newIsOpen = toggleValue === undefined ? !isOpen : toggleValue;
|
|
||||||
window.$chatwoot.isOpen = newIsOpen;
|
|
||||||
|
|
||||||
toggleClass(chatBubble, 'woot--hide');
|
const newIsOpen = toggleValue === undefined ? !isOpen : toggleValue;
|
||||||
toggleClass(closeBubble, 'woot--hide');
|
window.$chatwoot.isOpen = newIsOpen;
|
||||||
toggleClass(widgetHolder, 'woot--hide');
|
|
||||||
IFrameHelper.events.onBubbleToggle(newIsOpen);
|
|
||||||
|
|
||||||
if (!newIsOpen) {
|
toggleClass(chatBubble, 'woot--hide');
|
||||||
chatBubble.focus();
|
toggleClass(closeBubble, 'woot--hide');
|
||||||
}
|
toggleClass(widgetHolder, 'woot--hide');
|
||||||
}
|
|
||||||
|
handleBubbleToggle(newIsOpen);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const onClickChatBubble = () => {
|
export const onClickChatBubble = () => {
|
||||||
|
|||||||
@ -51,6 +51,7 @@ export const SDK_CSS = `
|
|||||||
user-select: none;
|
user-select: none;
|
||||||
width: 64px;
|
width: 64px;
|
||||||
z-index: 2147483000 !important;
|
z-index: 2147483000 !important;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.woot-widget-bubble.woot-widget-bubble--flat {
|
.woot-widget-bubble.woot-widget-bubble--flat {
|
||||||
|
|||||||
@ -44,6 +44,7 @@ export const ALLOWED_FILE_TYPES =
|
|||||||
'video/*,' +
|
'video/*,' +
|
||||||
'.3gpp,' +
|
'.3gpp,' +
|
||||||
'text/csv, text/plain, application/json, application/pdf, text/rtf,' +
|
'text/csv, text/plain, application/json, application/pdf, text/rtf,' +
|
||||||
|
'application/xml, text/xml,' +
|
||||||
'application/zip, application/x-7z-compressed application/vnd.rar application/x-tar,' +
|
'application/zip, application/x-7z-compressed application/vnd.rar application/x-tar,' +
|
||||||
'application/msword, application/vnd.ms-excel, application/vnd.ms-powerpoint, application/vnd.oasis.opendocument.text,' +
|
'application/msword, application/vnd.ms-excel, application/vnd.ms-powerpoint, application/vnd.oasis.opendocument.text,' +
|
||||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,' +
|
'application/vnd.openxmlformats-officedocument.presentationml.presentation, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,' +
|
||||||
|
|||||||
@ -4,8 +4,20 @@ export const isASubmittedFormMessage = (message = {}) =>
|
|||||||
|
|
||||||
export const MESSAGE_MAX_LENGTH = {
|
export const MESSAGE_MAX_LENGTH = {
|
||||||
GENERAL: 10000,
|
GENERAL: 10000,
|
||||||
FACEBOOK: 1000,
|
// https://developers.facebook.com/docs/messenger-platform/reference/send-api#request
|
||||||
|
FACEBOOK: 2000,
|
||||||
|
// https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/messaging-api#send-a-text-message
|
||||||
|
INSTAGRAM: 1000,
|
||||||
|
// https://www.twilio.com/docs/glossary/what-sms-character-limit
|
||||||
TWILIO_SMS: 320,
|
TWILIO_SMS: 320,
|
||||||
|
// https://help.twilio.com/articles/360033806753-Maximum-Message-Length-with-Twilio-Programmable-Messaging
|
||||||
TWILIO_WHATSAPP: 1600,
|
TWILIO_WHATSAPP: 1600,
|
||||||
|
// https://developers.facebook.com/docs/whatsapp/cloud-api/reference/messages#text-object
|
||||||
|
WHATSAPP_CLOUD: 4096,
|
||||||
|
// https://support.bandwidth.com/hc/en-us/articles/360010235373-What-are-Bandwidth-s-SMS-character-limits-and-concatenation-practices
|
||||||
|
BANDWIDTH_SMS: 160,
|
||||||
|
// https://core.telegram.org/bots/api#sendmessage
|
||||||
|
TELEGRAM: 4096,
|
||||||
|
LINE: 2000,
|
||||||
EMAIL: 25000,
|
EMAIL: 25000,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -100,3 +100,10 @@ export const getRegexp = regexPatternValue => {
|
|||||||
regexPatternValue.slice(lastSlash + 1)
|
regexPatternValue.slice(lastSlash + 1)
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a string is a valid slug (letters, numbers, hyphens only, no spaces or other symbols).
|
||||||
|
* @param {string} value - The slug to validate.
|
||||||
|
* @returns {boolean} True if the slug is valid, false otherwise.
|
||||||
|
*/
|
||||||
|
export const isValidSlug = value => /^[a-zA-Z0-9-]+$/.test(value);
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import {
|
|||||||
isNumber,
|
isNumber,
|
||||||
isDomain,
|
isDomain,
|
||||||
getRegexp,
|
getRegexp,
|
||||||
|
isValidSlug,
|
||||||
} from '../Validators';
|
} from '../Validators';
|
||||||
|
|
||||||
describe('#shouldBeUrl', () => {
|
describe('#shouldBeUrl', () => {
|
||||||
@ -153,3 +154,24 @@ describe('#getRegexp', () => {
|
|||||||
expect(regex.test('12-34-5678')).toBe(false);
|
expect(regex.test('12-34-5678')).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('#isValidSlug', () => {
|
||||||
|
it('should return true for valid slugs', () => {
|
||||||
|
expect(isValidSlug('abc')).toEqual(true);
|
||||||
|
expect(isValidSlug('abc-123')).toEqual(true);
|
||||||
|
expect(isValidSlug('a-b-c')).toEqual(true);
|
||||||
|
expect(isValidSlug('123')).toEqual(true);
|
||||||
|
expect(isValidSlug('abc123-def')).toEqual(true);
|
||||||
|
});
|
||||||
|
it('should return false for invalid slugs', () => {
|
||||||
|
expect(isValidSlug('abc_def')).toEqual(false);
|
||||||
|
expect(isValidSlug('abc def')).toEqual(false);
|
||||||
|
expect(isValidSlug('abc@def')).toEqual(false);
|
||||||
|
expect(isValidSlug('abc.def')).toEqual(false);
|
||||||
|
expect(isValidSlug('abc/def')).toEqual(false);
|
||||||
|
expect(isValidSlug('abc!def')).toEqual(false);
|
||||||
|
expect(isValidSlug('abc--def!')).toEqual(false);
|
||||||
|
expect(isValidSlug('abc-def ')).toEqual(false);
|
||||||
|
expect(isValidSlug(' abc-def')).toEqual(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -127,7 +127,7 @@ export default {
|
|||||||
this.isATwilioWhatsAppChannel
|
this.isATwilioWhatsAppChannel
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
isAInstagramChannel() {
|
isAnInstagramChannel() {
|
||||||
return this.channelType === INBOX_TYPES.INSTAGRAM;
|
return this.channelType === INBOX_TYPES.INSTAGRAM;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@ -5,7 +5,6 @@ const {
|
|||||||
AZURE_APP_ID: azureAppId,
|
AZURE_APP_ID: azureAppId,
|
||||||
BRAND_NAME: brandName,
|
BRAND_NAME: brandName,
|
||||||
CHATWOOT_INBOX_TOKEN: chatwootInboxToken,
|
CHATWOOT_INBOX_TOKEN: chatwootInboxToken,
|
||||||
CSML_EDITOR_HOST: csmlEditorHost,
|
|
||||||
CREATE_NEW_ACCOUNT_FROM_DASHBOARD: createNewAccountFromDashboard,
|
CREATE_NEW_ACCOUNT_FROM_DASHBOARD: createNewAccountFromDashboard,
|
||||||
DIRECT_UPLOADS_ENABLED: directUploadsEnabled,
|
DIRECT_UPLOADS_ENABLED: directUploadsEnabled,
|
||||||
DISPLAY_MANIFEST: displayManifest,
|
DISPLAY_MANIFEST: displayManifest,
|
||||||
@ -29,7 +28,6 @@ const state = {
|
|||||||
azureAppId,
|
azureAppId,
|
||||||
brandName,
|
brandName,
|
||||||
chatwootInboxToken,
|
chatwootInboxToken,
|
||||||
csmlEditorHost,
|
|
||||||
deploymentEnv,
|
deploymentEnv,
|
||||||
createNewAccountFromDashboard,
|
createNewAccountFromDashboard,
|
||||||
directUploadsEnabled: directUploadsEnabled === 'true',
|
directUploadsEnabled: directUploadsEnabled === 'true',
|
||||||
|
|||||||
@ -1,4 +1,7 @@
|
|||||||
export const CHATWOOT_ERROR = 'chatwoot:error';
|
export const CHATWOOT_ERROR = 'chatwoot:error';
|
||||||
export const CHATWOOT_ON_MESSAGE = 'chatwoot:on-message';
|
export const CHATWOOT_ON_MESSAGE = 'chatwoot:on-message';
|
||||||
export const CHATWOOT_ON_START_CONVERSATION = 'chatwoot:on-start-conversation';
|
export const CHATWOOT_ON_START_CONVERSATION = 'chatwoot:on-start-conversation';
|
||||||
|
export const CHATWOOT_POSTBACK = 'chatwoot:postback';
|
||||||
export const CHATWOOT_READY = 'chatwoot:ready';
|
export const CHATWOOT_READY = 'chatwoot:ready';
|
||||||
|
export const CHATWOOT_OPENED = 'chatwoot:opened';
|
||||||
|
export const CHATWOOT_CLOSED = 'chatwoot:closed';
|
||||||
|
|||||||
@ -1,10 +0,0 @@
|
|||||||
class AgentBots::CsmlJob < ApplicationJob
|
|
||||||
queue_as :high
|
|
||||||
|
|
||||||
def perform(event, agent_bot, message)
|
|
||||||
event_data = { message: message }
|
|
||||||
Integrations::Csml::ProcessorService.new(
|
|
||||||
event_name: event, agent_bot: agent_bot, event_data: event_data
|
|
||||||
).perform
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@ -24,6 +24,11 @@ class Webhooks::InstagramEventsJob < MutexApplicationJob
|
|||||||
private
|
private
|
||||||
|
|
||||||
def process_single_entry(entry)
|
def process_single_entry(entry)
|
||||||
|
if test_event?(entry)
|
||||||
|
process_test_event(entry)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
process_messages(entry)
|
process_messages(entry)
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -46,6 +51,20 @@ class Webhooks::InstagramEventsJob < MutexApplicationJob
|
|||||||
messaging[:message].present? && messaging[:message][:is_echo].present?
|
messaging[:message].present? && messaging[:message][:is_echo].present?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def test_event?(entry)
|
||||||
|
entry[:changes].present?
|
||||||
|
end
|
||||||
|
|
||||||
|
def process_test_event(entry)
|
||||||
|
messaging = extract_messaging_from_test_event(entry)
|
||||||
|
|
||||||
|
Instagram::TestEventService.new(messaging).perform if messaging.present?
|
||||||
|
end
|
||||||
|
|
||||||
|
def extract_messaging_from_test_event(entry)
|
||||||
|
entry[:changes].first&.dig(:value) if entry[:changes].present?
|
||||||
|
end
|
||||||
|
|
||||||
def instagram_id(messaging)
|
def instagram_id(messaging)
|
||||||
if agent_message_via_echo?(messaging)
|
if agent_message_via_echo?(messaging)
|
||||||
messaging[:sender][:id]
|
messaging[:sender][:id]
|
||||||
|
|||||||
@ -59,14 +59,10 @@ class AgentBotListener < BaseListener
|
|||||||
true
|
true
|
||||||
end
|
end
|
||||||
|
|
||||||
def process_message_event(method_name, agent_bot, message, event)
|
def process_message_event(method_name, agent_bot, message, _event)
|
||||||
case agent_bot.bot_type
|
# Only webhook bots are supported
|
||||||
when 'webhook'
|
payload = message.webhook_data.merge(event: method_name)
|
||||||
payload = message.webhook_data.merge(event: method_name)
|
process_webhook_bot_event(agent_bot, payload)
|
||||||
process_webhook_bot_event(agent_bot, payload)
|
|
||||||
when 'csml'
|
|
||||||
process_csml_bot_event(event.name, agent_bot, message)
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def process_webhook_bot_event(agent_bot, payload)
|
def process_webhook_bot_event(agent_bot, payload)
|
||||||
@ -74,8 +70,4 @@ class AgentBotListener < BaseListener
|
|||||||
|
|
||||||
AgentBots::WebhookJob.perform_later(agent_bot.outgoing_url, payload)
|
AgentBots::WebhookJob.perform_later(agent_bot.outgoing_url, payload)
|
||||||
end
|
end
|
||||||
|
|
||||||
def process_csml_bot_event(event, agent_bot, message)
|
|
||||||
AgentBots::CsmlJob.perform_later(event, agent_bot, message)
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|||||||
@ -1,4 +1,8 @@
|
|||||||
class ConversationReplyMailer < ApplicationMailer
|
class ConversationReplyMailer < ApplicationMailer
|
||||||
|
# We needs to expose large attachments to the view as links
|
||||||
|
# Small attachments are linked as mail attachments directly
|
||||||
|
attr_reader :large_attachments
|
||||||
|
|
||||||
include ConversationReplyMailerHelper
|
include ConversationReplyMailerHelper
|
||||||
default from: ENV.fetch('MAILER_SENDER_EMAIL', 'Chatwoot <accounts@chatwoot.com>')
|
default from: ENV.fetch('MAILER_SENDER_EMAIL', 'Chatwoot <accounts@chatwoot.com>')
|
||||||
layout :choose_layout
|
layout :choose_layout
|
||||||
|
|||||||
@ -17,11 +17,44 @@ module ConversationReplyMailerHelper
|
|||||||
google_smtp_settings
|
google_smtp_settings
|
||||||
set_delivery_method
|
set_delivery_method
|
||||||
|
|
||||||
Rails.logger.info("Email sent from #{email_from} to #{to_emails} with subject #{mail_subject}")
|
# Email type detection logic:
|
||||||
|
# - email_reply: Sets @message with a single message
|
||||||
|
# - Other actions: Set @messages with a collection of messages
|
||||||
|
#
|
||||||
|
# So this check implicitly determines we're handling an email_reply
|
||||||
|
# and not one of the other email types (summary, transcript, etc.)
|
||||||
|
process_attachments_as_files_for_email_reply if @message&.attachments.present?
|
||||||
mail(@options)
|
mail(@options)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def process_attachments_as_files_for_email_reply
|
||||||
|
# Attachment processing for direct email replies (when replying to a single message)
|
||||||
|
#
|
||||||
|
# How attachments are handled:
|
||||||
|
# 1. Total file size (<20MB): Added directly to the email as proper attachments
|
||||||
|
# 2. Total file size (>20MB): Added to @large_attachments to be displayed as links in the email
|
||||||
|
|
||||||
|
@options[:attachments] = []
|
||||||
|
@large_attachments = []
|
||||||
|
current_total_size = 0
|
||||||
|
|
||||||
|
@message.attachments.each do |attachment|
|
||||||
|
raw_data = attachment.file.download
|
||||||
|
attachment_name = attachment.file.filename.to_s
|
||||||
|
file_size = raw_data.bytesize
|
||||||
|
|
||||||
|
# Attach files directly until we hit 20MB total
|
||||||
|
# After reaching 20MB, send remaining files as links
|
||||||
|
if current_total_size + file_size <= 20.megabytes
|
||||||
|
mail.attachments[attachment_name] = raw_data
|
||||||
|
@options[:attachments] << { name: attachment_name }
|
||||||
|
current_total_size += file_size
|
||||||
|
else
|
||||||
|
@large_attachments << attachment
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def google_smtp_settings
|
def google_smtp_settings
|
||||||
@ -52,6 +85,8 @@ module ConversationReplyMailerHelper
|
|||||||
tls: false,
|
tls: false,
|
||||||
enable_starttls_auto: true,
|
enable_starttls_auto: true,
|
||||||
openssl_verify_mode: 'none',
|
openssl_verify_mode: 'none',
|
||||||
|
open_timeout: 15,
|
||||||
|
read_timeout: 15,
|
||||||
authentication: 'xoauth2'
|
authentication: 'xoauth2'
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|||||||
@ -25,9 +25,8 @@ class AgentBot < ApplicationRecord
|
|||||||
has_many :inboxes, through: :agent_bot_inboxes
|
has_many :inboxes, through: :agent_bot_inboxes
|
||||||
has_many :messages, as: :sender, dependent: :nullify
|
has_many :messages, as: :sender, dependent: :nullify
|
||||||
belongs_to :account, optional: true
|
belongs_to :account, optional: true
|
||||||
enum bot_type: { webhook: 0, csml: 1 }
|
enum bot_type: { webhook: 0 }
|
||||||
|
|
||||||
validate :validate_agent_bot_config
|
|
||||||
validates :outgoing_url, length: { maximum: Limits::URL_LENGTH_LIMIT }
|
validates :outgoing_url, length: { maximum: Limits::URL_LENGTH_LIMIT }
|
||||||
|
|
||||||
def available_name
|
def available_name
|
||||||
@ -51,9 +50,7 @@ class AgentBot < ApplicationRecord
|
|||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
def system_bot?
|
||||||
|
account.nil?
|
||||||
def validate_agent_bot_config
|
|
||||||
errors.add(:bot_config, 'Invalid Bot Configuration') unless AgentBots::ValidateBotService.new(agent_bot: self).perform
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@ -33,10 +33,6 @@ class Channel::Api < ApplicationRecord
|
|||||||
'API'
|
'API'
|
||||||
end
|
end
|
||||||
|
|
||||||
def messaging_window_enabled?
|
|
||||||
additional_attributes.present? && additional_attributes['agent_reply_time_window'].present?
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def ensure_valid_agent_reply_time_window
|
def ensure_valid_agent_reply_time_window
|
||||||
|
|||||||
@ -32,10 +32,6 @@ class Channel::FacebookPage < ApplicationRecord
|
|||||||
'Facebook'
|
'Facebook'
|
||||||
end
|
end
|
||||||
|
|
||||||
def messaging_window_enabled?
|
|
||||||
false
|
|
||||||
end
|
|
||||||
|
|
||||||
def create_contact_inbox(instagram_id, name)
|
def create_contact_inbox(instagram_id, name)
|
||||||
@contact_inbox = ::ContactInboxWithContactBuilder.new({
|
@contact_inbox = ::ContactInboxWithContactBuilder.new({
|
||||||
source_id: instagram_id,
|
source_id: instagram_id,
|
||||||
|
|||||||
@ -41,10 +41,6 @@ class Channel::TwilioSms < ApplicationRecord
|
|||||||
medium == 'sms' ? 'Twilio SMS' : 'Whatsapp'
|
medium == 'sms' ? 'Twilio SMS' : 'Whatsapp'
|
||||||
end
|
end
|
||||||
|
|
||||||
def messaging_window_enabled?
|
|
||||||
medium == 'whatsapp'
|
|
||||||
end
|
|
||||||
|
|
||||||
def send_message(to:, body:, media_url: nil)
|
def send_message(to:, body:, media_url: nil)
|
||||||
params = send_message_from.merge(to: to, body: body)
|
params = send_message_from.merge(to: to, body: body)
|
||||||
params[:media_url] = media_url if media_url.present?
|
params[:media_url] = media_url if media_url.present?
|
||||||
|
|||||||
@ -54,10 +54,6 @@ class Channel::Whatsapp < ApplicationRecord
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def messaging_window_enabled?
|
|
||||||
provider != 'baileys'
|
|
||||||
end
|
|
||||||
|
|
||||||
def mark_message_templates_updated
|
def mark_message_templates_updated
|
||||||
# rubocop:disable Rails/SkipsModelValidations
|
# rubocop:disable Rails/SkipsModelValidations
|
||||||
update_column(:message_templates_last_updated, Time.zone.now)
|
update_column(:message_templates_last_updated, Time.zone.now)
|
||||||
|
|||||||
@ -7,10 +7,6 @@ module Channelable
|
|||||||
after_update :create_audit_log_entry
|
after_update :create_audit_log_entry
|
||||||
end
|
end
|
||||||
|
|
||||||
def messaging_window_enabled?
|
|
||||||
false
|
|
||||||
end
|
|
||||||
|
|
||||||
def create_audit_log_entry; end
|
def create_audit_log_entry; end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@ -115,14 +115,7 @@ class Conversation < ApplicationRecord
|
|||||||
delegate :auto_resolve_duration, to: :account
|
delegate :auto_resolve_duration, to: :account
|
||||||
|
|
||||||
def can_reply?
|
def can_reply?
|
||||||
channel = inbox&.channel
|
Conversations::MessageWindowService.new(self).can_reply?
|
||||||
|
|
||||||
return can_reply_on_instagram? if additional_attributes['type'] == 'instagram_direct_message'
|
|
||||||
|
|
||||||
return true unless channel&.messaging_window_enabled?
|
|
||||||
|
|
||||||
messaging_window = inbox.api? ? channel.additional_attributes['agent_reply_time_window'].to_i : 24
|
|
||||||
last_message_in_messaging_window?(messaging_window)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def language
|
def language
|
||||||
@ -137,24 +130,6 @@ class Conversation < ApplicationRecord
|
|||||||
messages&.incoming&.last
|
messages&.incoming&.last
|
||||||
end
|
end
|
||||||
|
|
||||||
def last_message_in_messaging_window?(time)
|
|
||||||
return false if last_incoming_message.nil?
|
|
||||||
|
|
||||||
Time.current < last_incoming_message.created_at + time.hours
|
|
||||||
end
|
|
||||||
|
|
||||||
def can_reply_on_instagram?
|
|
||||||
global_config = GlobalConfig.get('ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT')
|
|
||||||
|
|
||||||
return false if last_incoming_message.nil?
|
|
||||||
|
|
||||||
if global_config['ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT']
|
|
||||||
Time.current < last_incoming_message.created_at + 7.days
|
|
||||||
else
|
|
||||||
last_message_in_messaging_window?(24)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def toggle_status
|
def toggle_status
|
||||||
# FIXME: implement state machine with aasm
|
# FIXME: implement state machine with aasm
|
||||||
self.status = open? ? :resolved : :open
|
self.status = open? ? :resolved : :open
|
||||||
|
|||||||
@ -1,38 +0,0 @@
|
|||||||
class AgentBots::ValidateBotService
|
|
||||||
pattr_initialize [:agent_bot]
|
|
||||||
def perform
|
|
||||||
return true unless agent_bot.bot_type == 'csml'
|
|
||||||
|
|
||||||
validate_csml_bot
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def csml_client
|
|
||||||
@csml_client ||= CsmlEngine.new
|
|
||||||
end
|
|
||||||
|
|
||||||
def csml_bot_payload
|
|
||||||
{
|
|
||||||
id: agent_bot[:name],
|
|
||||||
name: agent_bot[:name],
|
|
||||||
default_flow: 'Default',
|
|
||||||
flows: [
|
|
||||||
{
|
|
||||||
id: SecureRandom.uuid,
|
|
||||||
name: 'Default',
|
|
||||||
content: agent_bot.bot_config['csml_content'],
|
|
||||||
commands: []
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
def validate_csml_bot
|
|
||||||
response = csml_client.validate(csml_bot_payload)
|
|
||||||
response.blank? || response['valid']
|
|
||||||
rescue StandardError => e
|
|
||||||
ChatwootExceptionTracker.new(e, account: agent_bot&.account).capture_exception
|
|
||||||
false
|
|
||||||
end
|
|
||||||
end
|
|
||||||
66
app/services/conversations/message_window_service.rb
Normal file
66
app/services/conversations/message_window_service.rb
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
class Conversations::MessageWindowService
|
||||||
|
MESSAGING_WINDOW_24_HOURS = 24.hours
|
||||||
|
MESSAGING_WINDOW_7_DAYS = 7.days
|
||||||
|
|
||||||
|
def initialize(conversation)
|
||||||
|
@conversation = conversation
|
||||||
|
end
|
||||||
|
|
||||||
|
def can_reply?
|
||||||
|
return true if messaging_window.blank?
|
||||||
|
|
||||||
|
last_message_in_messaging_window?(messaging_window)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def messaging_window
|
||||||
|
case @conversation.inbox.channel_type
|
||||||
|
when 'Channel::Api'
|
||||||
|
api_messaging_window
|
||||||
|
when 'Channel::FacebookPage'
|
||||||
|
messenger_messaging_window
|
||||||
|
when 'Channel::Instagram'
|
||||||
|
instagram_messaging_window
|
||||||
|
when 'Channel::Whatsapp'
|
||||||
|
return if @conversation.inbox.channel.provider == 'baileys'
|
||||||
|
|
||||||
|
MESSAGING_WINDOW_24_HOURS
|
||||||
|
when 'Channel::TwilioSms'
|
||||||
|
twilio_messaging_window
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def last_message_in_messaging_window?(time)
|
||||||
|
return false if last_incoming_message.nil?
|
||||||
|
|
||||||
|
Time.current < last_incoming_message.created_at + time
|
||||||
|
end
|
||||||
|
|
||||||
|
def api_messaging_window
|
||||||
|
return if @conversation.inbox.channel.additional_attributes['agent_reply_time_window'].blank?
|
||||||
|
|
||||||
|
@conversation.inbox.channel.additional_attributes['agent_reply_time_window'].to_i.hours
|
||||||
|
end
|
||||||
|
|
||||||
|
# Check medium of the inbox to determine the messaging window
|
||||||
|
def twilio_messaging_window
|
||||||
|
@conversation.inbox.channel.medium == 'whatsapp' ? MESSAGING_WINDOW_24_HOURS : nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def messenger_messaging_window
|
||||||
|
meta_messaging_window('ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT')
|
||||||
|
end
|
||||||
|
|
||||||
|
def instagram_messaging_window
|
||||||
|
meta_messaging_window('ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT')
|
||||||
|
end
|
||||||
|
|
||||||
|
def meta_messaging_window(config_key)
|
||||||
|
GlobalConfigService.load(config_key, nil) ? MESSAGING_WINDOW_7_DAYS : MESSAGING_WINDOW_24_HOURS
|
||||||
|
end
|
||||||
|
|
||||||
|
def last_incoming_message
|
||||||
|
@last_incoming_message ||= @conversation.messages&.incoming&.last
|
||||||
|
end
|
||||||
|
end
|
||||||
79
app/services/instagram/test_event_service.rb
Normal file
79
app/services/instagram/test_event_service.rb
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
class Instagram::TestEventService
|
||||||
|
def initialize(messaging)
|
||||||
|
@messaging = messaging
|
||||||
|
end
|
||||||
|
|
||||||
|
def perform
|
||||||
|
Rails.logger.info("Processing Instagram test webhook event, #{@messaging}")
|
||||||
|
|
||||||
|
return false unless test_webhook_event?
|
||||||
|
|
||||||
|
create_test_text
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def test_webhook_event?
|
||||||
|
@messaging[:sender][:id] == '12334' && @messaging[:recipient][:id] == '23245'
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_test_text
|
||||||
|
# As of now, we are using the last created instagram channel as the test channel,
|
||||||
|
# since we don't have any other channel for testing purpose at the time of meta approval
|
||||||
|
channel = Channel::Instagram.last
|
||||||
|
|
||||||
|
@inbox = ::Inbox.find_by(channel: channel)
|
||||||
|
return unless @inbox
|
||||||
|
|
||||||
|
@contact = create_test_contact
|
||||||
|
|
||||||
|
@conversation ||= create_test_conversation(conversation_params)
|
||||||
|
|
||||||
|
@message = @conversation.messages.create!(test_message_params)
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_test_contact
|
||||||
|
@contact_inbox = @inbox.contact_inboxes.where(source_id: @messaging[:sender][:id]).first
|
||||||
|
unless @contact_inbox
|
||||||
|
@contact_inbox ||= @inbox.channel.create_contact_inbox(
|
||||||
|
'sender_username', 'sender_username'
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
@contact_inbox.contact
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_test_conversation(conversation_params)
|
||||||
|
Conversation.find_by(conversation_params) || build_conversation(conversation_params)
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_message_params
|
||||||
|
{
|
||||||
|
account_id: @conversation.account_id,
|
||||||
|
inbox_id: @conversation.inbox_id,
|
||||||
|
message_type: 'incoming',
|
||||||
|
source_id: @messaging[:message][:mid],
|
||||||
|
content: @messaging[:message][:text],
|
||||||
|
sender: @contact
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_conversation(conversation_params)
|
||||||
|
Conversation.create!(
|
||||||
|
conversation_params.merge(
|
||||||
|
contact_inbox_id: @contact_inbox.id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def conversation_params
|
||||||
|
{
|
||||||
|
account_id: @inbox.account_id,
|
||||||
|
inbox_id: @inbox.id,
|
||||||
|
contact_id: @contact.id,
|
||||||
|
additional_attributes: {
|
||||||
|
type: 'instagram_direct_message'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
@ -1,8 +1,10 @@
|
|||||||
json.id resource.id
|
json.id resource.id
|
||||||
json.name resource.name
|
json.name resource.name
|
||||||
json.description resource.description
|
json.description resource.description
|
||||||
json.outgoing_url resource.outgoing_url
|
json.thumbnail resource.avatar_url
|
||||||
|
json.outgoing_url resource.outgoing_url unless resource.system_bot?
|
||||||
json.bot_type resource.bot_type
|
json.bot_type resource.bot_type
|
||||||
json.bot_config resource.bot_config
|
json.bot_config resource.bot_config
|
||||||
json.account_id resource.account_id
|
json.account_id resource.account_id
|
||||||
json.access_token resource.access_token if resource.access_token.present?
|
json.access_token resource.access_token if resource.access_token.present?
|
||||||
|
json.system_bot resource.system_bot?
|
||||||
|
|||||||
@ -56,6 +56,7 @@ end
|
|||||||
|
|
||||||
## Instagram Attributes
|
## Instagram Attributes
|
||||||
json.reauthorization_required resource.channel.try(:reauthorization_required?) if resource.instagram?
|
json.reauthorization_required resource.channel.try(:reauthorization_required?) if resource.instagram?
|
||||||
|
json.instagram_id resource.channel.try(:instagram_id) if resource.instagram?
|
||||||
|
|
||||||
## Twilio Attributes
|
## Twilio Attributes
|
||||||
json.messaging_service_sid resource.channel.try(:messaging_service_sid)
|
json.messaging_service_sid resource.channel.try(:messaging_service_sid)
|
||||||
|
|||||||
@ -39,6 +39,10 @@ By default, it renders:
|
|||||||
<% else %>
|
<% else %>
|
||||||
<title><%= @portal.page_title%></title>
|
<title><%= @portal.page_title%></title>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
|
<% if @portal.logo.present? %>
|
||||||
|
<link rel="icon" href="<%= url_for(@portal.logo) %>">
|
||||||
|
<% end %>
|
||||||
|
|
||||||
<% unless @theme_from_params.blank? %>
|
<% unless @theme_from_params.blank? %>
|
||||||
<%# this adds the theme from params, ensuring that there a localstorage value set %>
|
<%# this adds the theme from params, ensuring that there a localstorage value set %>
|
||||||
|
|||||||
@ -35,6 +35,7 @@
|
|||||||
hostURL: '<%= ENV.fetch('FRONTEND_URL', '') %>',
|
hostURL: '<%= ENV.fetch('FRONTEND_URL', '') %>',
|
||||||
helpCenterURL: '<%= ENV.fetch('HELPCENTER_URL', '') %>',
|
helpCenterURL: '<%= ENV.fetch('HELPCENTER_URL', '') %>',
|
||||||
fbAppId: '<%= @global_config['FB_APP_ID'] %>',
|
fbAppId: '<%= @global_config['FB_APP_ID'] %>',
|
||||||
|
instagramAppId: '<%= @global_config['INSTAGRAM_APP_ID'] %>',
|
||||||
googleOAuthClientId: '<%= ENV.fetch('GOOGLE_OAUTH_CLIENT_ID', nil) %>',
|
googleOAuthClientId: '<%= ENV.fetch('GOOGLE_OAUTH_CLIENT_ID', nil) %>',
|
||||||
googleOAuthCallbackUrl: '<%= ENV.fetch('GOOGLE_OAUTH_CALLBACK_URL', nil) %>',
|
googleOAuthCallbackUrl: '<%= ENV.fetch('GOOGLE_OAUTH_CALLBACK_URL', nil) %>',
|
||||||
fbApiVersion: '<%= @global_config['FACEBOOK_API_VERSION'] %>',
|
fbApiVersion: '<%= @global_config['FACEBOOK_API_VERSION'] %>',
|
||||||
|
|||||||
@ -32,9 +32,10 @@
|
|||||||
<% if message.content %>
|
<% if message.content %>
|
||||||
<%= ChatwootMarkdownRenderer.new(message.content).render_message %>
|
<%= ChatwootMarkdownRenderer.new(message.content).render_message %>
|
||||||
<% end %>
|
<% end %>
|
||||||
<% if message.attachments %>
|
<% if message.attachments.present? %>
|
||||||
|
<p>Attachments:</p>
|
||||||
<% message.attachments.each do |attachment| %>
|
<% message.attachments.each do |attachment| %>
|
||||||
Attachment [<a href="<%= attachment.file_url %>" _target="blank">Click here to view</a>]
|
<p><a href="<%= attachment.file_url %>" target="_blank"><%= attachment.file.filename.to_s %></a></p>
|
||||||
<% end %>
|
<% end %>
|
||||||
<% end %>
|
<% end %>
|
||||||
<p style="font-size: 90%; font-size: 90%;color: #899096;margin-top: -8px; margin-bottom: 0px;">
|
<p style="font-size: 90%; font-size: 90%;color: #899096;margin-top: -8px; margin-bottom: 0px;">
|
||||||
|
|||||||
@ -1,8 +1,9 @@
|
|||||||
<% if @message.content %>
|
<% if @message.content %>
|
||||||
<%= ChatwootMarkdownRenderer.new(@message.content).render_message %>
|
<%= ChatwootMarkdownRenderer.new(@message.content).render_message %>
|
||||||
<% end %>
|
<% end %>
|
||||||
<% if @message.attachments %>
|
<% if @large_attachments.present? %>
|
||||||
<% @message.attachments.each do |attachment| %>
|
<p>Attachments:</p>
|
||||||
attachment [<a href="<%= attachment.file_url %>" _target="blank">click here to view</a>]
|
<% @large_attachments.each do |attachment| %>
|
||||||
|
<p><a href="<%= attachment.file_url %>" target="_blank"><%= attachment.file.filename.to_s %></a></p>
|
||||||
<% end %>
|
<% end %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|||||||
@ -20,9 +20,10 @@
|
|||||||
<% if message.content.present? %>
|
<% if message.content.present? %>
|
||||||
<hr style="border: 0; border-bottom: 1px solid #AEC3D5;"/>
|
<hr style="border: 0; border-bottom: 1px solid #AEC3D5;"/>
|
||||||
<% end %>
|
<% end %>
|
||||||
This message contains <%= message.attachments.count > 1 ? 'attachments' : 'an attachment' %>.
|
Attachments:
|
||||||
<% message.attachments.each do |attachment| %>
|
<% message.attachments.each_with_index do |attachment, index| %>
|
||||||
<br />- View the attachment <a href="<%= attachment.file_url %>" _target="blank">here</a>.
|
<% if index > 0 %><br /><% end %>
|
||||||
|
<a href="<%= attachment.file_url %>" target="_blank"><%= attachment.file.filename.to_s %></a>
|
||||||
<% end %>
|
<% end %>
|
||||||
</p>
|
</p>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|||||||
@ -3,9 +3,10 @@
|
|||||||
<% if message.content %>
|
<% if message.content %>
|
||||||
<%= ChatwootMarkdownRenderer.new(message.content).render_message %>
|
<%= ChatwootMarkdownRenderer.new(message.content).render_message %>
|
||||||
<% end %>
|
<% end %>
|
||||||
<% if message.attachments %>
|
<% if message.attachments.present? %>
|
||||||
|
<p>Attachments:</p>
|
||||||
<% message.attachments.each do |attachment| %>
|
<% message.attachments.each do |attachment| %>
|
||||||
attachment [<a href="<%= attachment.file_url %>" _target="blank">click here to view</a>]
|
<p><a href="<%= attachment.file_url %>" target="_blank"><%= attachment.file.filename.to_s %></a></p>
|
||||||
<% end %>
|
<% end %>
|
||||||
<% end %>
|
<% end %>
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user