Merge remote-tracking branch 'origin/captain/pdf_support_fe' into captain/pdf_support

This commit is contained in:
Tanmay Sharma
2025-08-27 14:47:28 +07:00
993 changed files with 28669 additions and 8431 deletions
@@ -6,6 +6,7 @@ import { picoSearch } from '@scmmishra/pico-search';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import Button from 'dashboard/components-next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
@@ -20,6 +21,7 @@ const { t } = useI18n();
const route = useRoute();
const store = useStore();
const { uiSettings, updateUISettings } = useUISettings();
const { formatMessage } = useMessageFormatter();
const assistantId = route.params.assistantId;
const uiFlags = useMapGetter('captainScenarios/getUIFlags');
@@ -42,35 +44,25 @@ const breadcrumbItems = computed(() => {
];
});
const TOOL_LINK_REGEX = /\[([^\]]+)]\(tool:\/\/.+?\)/g;
const LINK_INSTRUCTION_CLASS =
'[&_a[href^="tool://"]]:text-n-iris-11 [&_a:not([href^="tool://"])]:text-n-slate-12 [&_a]:pointer-events-none [&_a]:cursor-default';
const renderInstruction = instruction => () =>
h('span', {
class: 'text-sm text-n-slate-12 py-4',
innerHTML: instruction.replace(
TOOL_LINK_REGEX,
(_, title) =>
`<span class="text-n-iris-11 font-medium">@${title.replace(/^@/, '')}</span>`
),
class: `text-sm text-n-slate-12 py-4 prose prose-sm min-w-0 break-words ${LINK_INSTRUCTION_CLASS}`,
innerHTML: instruction,
});
// Suggested example scenarios for quick add
const scenariosExample = [
{
id: 1,
title: 'Refund Order',
description: 'User encountered a technical issue or error message.',
title: 'Prospective Buyer',
description:
'Handle customers who are showing interest in purchasing a license',
instruction:
'Ask for steps to reproduce + browser/app version. Use [Known Issues](tool://known_issues) to check if its a known bug. File with [Create Bug Report](tool://bug_report_create) if new.',
tools: ['create_bug_report', 'known_issues'],
},
{
id: 2,
title: 'Product Recommendation',
description: 'User is unsure which product or service to choose.',
instruction:
'Ask 23 clarifying questions. Use [Product Match](tool://product_match[user_needs]) and suggest 23 options with pros/cons. Link to compare page if available.',
tools: ['product_match[user_needs]'],
'If someone is interested in purchasing a license, ask them for following:\n\n1. How many licenses are they willing to purchase?\n2. Are they migrating from another platform?\n. Once these details are collected, do the following steps\n1. add a private note to with the information you collected using [Add Private Note](tool://add_private_note)\n2. Add label "sales" to the contact using [Add Label to Conversation](tool://add_label_to_conversation)\n3. Reply saying "one of us will reach out soon" and provide an estimated timeline for the response and [Handoff to Human](tool://handoff)',
tools: ['add_private_note', 'add_label_to_conversation', 'handoff'],
},
];
@@ -248,7 +240,9 @@ onMounted(() => {
<span class="text-sm text-n-slate-11 mt-2">
{{ item.description }}
</span>
<component :is="renderInstruction(item.instruction)" />
<component
:is="renderInstruction(formatMessage(item.instruction, false))"
/>
<span class="text-sm text-n-slate-11 font-medium mb-1">
{{ t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.SUGGESTED.TOOLS_USED') }}
{{ item.tools?.map(tool => `@${tool}`).join(', ') }}
@@ -60,7 +60,9 @@ export default {
:chat="conversation"
:hide-inbox-name="false"
hide-thumbnail
class="compact"
enable-context-menu
compact
:allowed-context-menu-options="['open-new-tab', 'copy-link']"
/>
</div>
</div>
@@ -73,12 +75,4 @@ export default {
.no-label-message {
@apply text-n-slate-11 mb-4;
}
::v-deep .conversation {
@apply pr-0;
.conversation--details {
@apply pl-2;
}
}
</style>
@@ -10,10 +10,12 @@ import countries from 'shared/constants/countries.js';
import { isPhoneNumberValid } from 'shared/helpers/Validators';
import parsePhoneNumber from 'libphonenumber-js';
import NextButton from 'dashboard/components-next/button/Button.vue';
import Avatar from 'next/avatar/Avatar.vue';
export default {
components: {
NextButton,
Avatar,
},
props: {
contact: {
@@ -274,18 +276,19 @@ export default {
class="w-full px-8 pt-6 pb-8 contact--form"
@submit.prevent="handleSubmit"
>
<div>
<div class="w-full">
<woot-avatar-uploader
:label="$t('CONTACT_FORM.FORM.AVATAR.LABEL')"
:src="avatarUrl"
:username-avatar="name"
:delete-avatar="!!avatarUrl"
class="settings-item"
@on-avatar-select="handleImageUpload"
@on-avatar-delete="handleAvatarDelete"
/>
</div>
<div class="flex flex-col mb-4 items-start gap-1 w-full">
<label class="mb-0.5 text-sm font-medium text-n-slate-12">
{{ $t('CONTACT_FORM.FORM.AVATAR.LABEL') }}
</label>
<Avatar
:src="avatarUrl"
:size="72"
:name="contact.name"
allow-upload
rounded-full
@upload="handleImageUpload"
@delete="handleAvatarDelete"
/>
</div>
<div>
<div class="w-full">
@@ -4,13 +4,14 @@ import { useAlert } from 'dashboard/composables';
import { dynamicTime } from 'shared/helpers/timeHelper';
import { useAdmin } from 'dashboard/composables/useAdmin';
import ContactInfoRow from './ContactInfoRow.vue';
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
import Avatar from 'next/avatar/Avatar.vue';
import SocialIcons from './SocialIcons.vue';
import EditContact from './EditContact.vue';
import ContactMergeModal from 'dashboard/modules/contact/ContactMergeModal.vue';
import ComposeConversation from 'dashboard/components-next/NewConversation/ComposeConversation.vue';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import NextButton from 'dashboard/components-next/button/Button.vue';
import VoiceCallButton from 'dashboard/components-next/Contacts/VoiceCallButton.vue';
import {
isAConversationRoute,
@@ -24,10 +25,11 @@ export default {
NextButton,
ContactInfoRow,
EditContact,
Thumbnail,
Avatar,
ComposeConversation,
SocialIcons,
ContactMergeModal,
VoiceCallButton,
},
props: {
contact: {
@@ -179,12 +181,14 @@ export default {
<div class="relative items-center w-full p-4">
<div class="flex flex-col w-full gap-2 text-left rtl:text-right">
<div class="flex flex-row justify-between">
<Thumbnail
<Avatar
v-if="showAvatar"
:src="contact.thumbnail"
size="48px"
:username="contact.name"
:name="contact.name"
:status="contact.availability_status"
:size="48"
hide-offline-status
rounded-full
/>
</div>
@@ -276,6 +280,14 @@ export default {
/>
</template>
</ComposeConversation>
<VoiceCallButton
:phone="contact.phone_number"
icon="i-ri-phone-fill"
size="sm"
:tooltip-label="$t('CONTACT_PANEL.CALL')"
slate
faded
/>
<NextButton
v-tooltip.top-end="$t('EDIT_CONTACT.BUTTON_LABEL')"
icon="i-ph-pencil-simple"
@@ -246,7 +246,7 @@ onMounted(() => {
:key="notificationItem.id"
:inbox-item="notificationItem"
:state-inbox="stateInbox(notificationItem.primaryActor?.inboxId)"
class="inbox-card rounded-lg hover:rounded-lg hover:bg-n-alpha-1 dark:hover:bg-n-alpha-3"
class="inbox-card rounded-none hover:rounded-lg hover:bg-n-alpha-1 dark:hover:bg-n-alpha-3"
:class="
currentConversationId === notificationItem.primaryActor?.id
? 'bg-n-alpha-1 dark:bg-n-alpha-3 rounded-lg active'
@@ -1,32 +1,27 @@
<script>
<script setup>
import ContextMenu from 'dashboard/components/ui/ContextMenu.vue';
import MenuItem from './MenuItem.vue';
import MenuItem from 'dashboard/components/widgets/conversation/contextMenu/menuItem.vue';
export default {
components: {
MenuItem,
ContextMenu,
defineProps({
contextMenuPosition: {
type: Object,
default: () => ({}),
},
props: {
contextMenuPosition: {
type: Object,
default: () => ({}),
},
menuItems: {
type: Array,
default: () => [],
},
},
emits: ['close', 'selectAction'],
methods: {
handleClose() {
this.$emit('close');
},
onMenuItemClick(key) {
this.$emit('selectAction', key);
this.handleClose();
},
menuItems: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(['close', 'selectAction']);
const handleClose = () => {
emit('close');
};
const onMenuItemClick = key => {
emit('selectAction', key);
handleClose();
};
</script>
@@ -37,12 +32,14 @@ export default {
@close="handleClose"
>
<div
class="bg-n-alpha-3 backdrop-blur-[100px] w-40 py-2 px-2 outline outline-1 outline-n-container shadow-lg rounded-xl"
class="p-1 rounded-md shadow-xl bg-n-alpha-3/50 backdrop-blur-[100px] outline-1 outline outline-n-weak/50"
>
<MenuItem
v-for="item in menuItems"
:key="item.key"
:label="item.label"
:option="item"
variant="icon"
class="!w-48"
@click.stop="onMenuItemClick(item.key)"
/>
</div>
@@ -1,5 +1,5 @@
<script>
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
import Avatar from 'next/avatar/Avatar.vue';
import Spinner from 'shared/components/Spinner.vue';
import EmptyState from 'dashboard/components/widgets/EmptyState.vue';
import { dynamicTime } from 'shared/helpers/timeHelper';
@@ -8,7 +8,7 @@ import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: {
Thumbnail,
Avatar,
Spinner,
EmptyState,
NextButton,
@@ -107,11 +107,12 @@ export default {
</span>
</td>
<td class="thumbnail--column">
<Thumbnail
<Avatar
v-if="notificationItem.primary_actor.meta.assignee"
:src="notificationItem.primary_actor.meta.assignee.thumbnail"
size="28px"
:username="notificationItem.primary_actor.meta.assignee.name"
:size="28"
:name="notificationItem.primary_actor.meta.assignee.name"
rounded-full
/>
</td>
<td>
@@ -1,7 +1,7 @@
<script setup>
import { useAlert } from 'dashboard/composables';
import { computed, onMounted, ref } from 'vue';
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
import Avatar from 'next/avatar/Avatar.vue';
import { useI18n } from 'vue-i18n';
import {
useStoreGetters,
@@ -164,11 +164,13 @@ const confirmDeletion = () => {
<tr v-for="(agent, index) in agentList" :key="agent.email">
<td class="py-4 ltr:pr-4 rtl:pl-4">
<div class="flex flex-row items-center gap-4">
<Thumbnail
<Avatar
:src="agent.thumbnail"
:username="agent.name"
size="40px"
:name="agent.name"
:status="agent.availability_status"
:size="40"
hide-offline-status
rounded-full
/>
<div>
<span class="block font-medium capitalize">
@@ -468,6 +468,106 @@ export const AUTOMATIONS = {
},
],
},
conversation_resolved: {
conditions: [
{
key: 'browser_language',
name: 'BROWSER_LANGUAGE',
inputType: 'search_select',
filterOperators: OPERATOR_TYPES_1,
},
{
key: 'email',
name: 'EMAIL',
inputType: 'plain_text',
filterOperators: OPERATOR_TYPES_2,
},
{
key: 'mail_subject',
name: 'MAIL_SUBJECT',
inputType: 'plain_text',
filterOperators: OPERATOR_TYPES_2,
},
{
key: 'country_code',
name: 'COUNTRY_NAME',
inputType: 'search_select',
filterOperators: OPERATOR_TYPES_1,
},
{
key: 'referer',
name: 'REFERER_LINK',
inputType: 'plain_text',
filterOperators: OPERATOR_TYPES_2,
},
{
key: 'assignee_id',
name: 'ASSIGNEE_NAME',
inputType: 'search_select',
filterOperators: OPERATOR_TYPES_3,
},
{
key: 'phone_number',
name: 'PHONE_NUMBER',
inputType: 'plain_text',
filterOperators: OPERATOR_TYPES_6,
},
{
key: 'team_id',
name: 'TEAM_NAME',
inputType: 'search_select',
filterOperators: OPERATOR_TYPES_3,
},
{
key: 'inbox_id',
name: 'INBOX',
inputType: 'multi_select',
filterOperators: OPERATOR_TYPES_1,
},
{
key: 'conversation_language',
name: 'CONVERSATION_LANGUAGE',
inputType: 'multi_select',
filterOperators: OPERATOR_TYPES_1,
},
{
key: 'priority',
name: 'PRIORITY',
inputType: 'multi_select',
filterOperators: OPERATOR_TYPES_1,
},
],
actions: [
{
key: 'assign_agent',
name: 'ASSIGN_AGENT',
},
{
key: 'assign_team',
name: 'ASSIGN_TEAM',
},
{
key: 'send_email_to_team',
name: 'SEND_EMAIL_TO_TEAM',
},
{
key: 'send_message',
name: 'SEND_MESSAGE',
},
{
key: 'send_email_transcript',
name: 'SEND_EMAIL_TRANSCRIPT',
},
{
key: 'send_webhook_event',
name: 'SEND_WEBHOOK_EVENT',
},
{
key: 'send_attachment',
name: 'SEND_ATTACHMENT',
},
],
},
};
export const AUTOMATION_RULE_EVENTS = [
@@ -479,6 +579,10 @@ export const AUTOMATION_RULE_EVENTS = [
key: 'conversation_updated',
value: 'CONVERSATION_UPDATED',
},
{
key: 'conversation_resolved',
value: 'CONVERSATION_RESOLVED',
},
{
key: 'message_created',
value: 'MESSAGE_CREATED',
@@ -3,14 +3,19 @@ import ChannelItem from 'dashboard/components/widgets/ChannelItem.vue';
import router from '../../../index';
import PageHeader from '../SettingsSubPageHeader.vue';
import { mapGetters } from 'vuex';
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import { useBranding } from 'shared/composables/useBranding';
export default {
components: {
ChannelItem,
PageHeader,
},
mixins: [globalConfigMixin],
setup() {
const { replaceInstallationName } = useBranding();
return {
replaceInstallationName,
};
},
data() {
return {
enabledFeatures: {},
@@ -69,12 +74,7 @@ export default {
<PageHeader
class="max-w-4xl"
:header-title="$t('INBOX_MGMT.ADD.AUTH.TITLE')"
:header-content="
useInstallationName(
$t('INBOX_MGMT.ADD.AUTH.DESC'),
globalConfig.installationName
)
"
:header-content="replaceInstallationName($t('INBOX_MGMT.ADD.AUTH.DESC'))"
/>
<div
class="grid max-w-3xl grid-cols-2 mx-0 mt-6 sm:grid-cols-3 lg:grid-cols-4"
@@ -1,101 +1,169 @@
<script>
<script setup>
import { computed, onMounted, reactive, watch } from 'vue';
import { useRoute } from 'vue-router';
import { useStore } from 'vuex';
import { useI18n } from 'vue-i18n';
import QRCode from 'qrcode';
import EmptyState from '../../../../components/widgets/EmptyState.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import DuplicateInboxBanner from './channels/instagram/DuplicateInboxBanner.vue';
import { useInbox } from 'dashboard/composables/useInbox';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
export default {
components: {
EmptyState,
NextButton,
DuplicateInboxBanner,
const { t } = useI18n();
const route = useRoute();
const store = useStore();
const qrCodes = reactive({
whatsapp: '',
messenger: '',
telegram: '',
});
const currentInbox = computed(() =>
store.getters['inboxes/getInbox'](route.params.inbox_id)
);
// Use useInbox composable with the inbox ID
const {
isAWhatsAppCloudChannel,
isATwilioChannel,
isASmsInbox,
isALineChannel,
isAnEmailChannel,
isAWhatsAppChannel,
isAFacebookInbox,
isATelegramChannel,
} = useInbox(route.params.inbox_id);
const hasDuplicateInstagramInbox = computed(() => {
const instagramId = currentInbox.value.instagram_id;
const facebookInbox =
store.getters['inboxes/getFacebookInboxByInstagramId'](instagramId);
return (
currentInbox.value.channel_type === INBOX_TYPES.INSTAGRAM && facebookInbox
);
});
const shouldShowWhatsAppWebhookDetails = computed(() => {
return (
isAWhatsAppCloudChannel.value &&
currentInbox.value.provider_config?.source !== 'embedded_signup'
);
});
const isWhatsAppEmbeddedSignup = computed(() => {
return (
isAWhatsAppCloudChannel.value &&
currentInbox.value.provider_config?.source === 'embedded_signup'
);
});
const message = computed(() => {
if (isATwilioChannel.value) {
return `${t('INBOX_MGMT.FINISH.MESSAGE')}. ${t(
'INBOX_MGMT.ADD.TWILIO.API_CALLBACK.SUBTITLE'
)}`;
}
if (isASmsInbox.value) {
return `${t('INBOX_MGMT.FINISH.MESSAGE')}. ${t(
'INBOX_MGMT.ADD.SMS.BANDWIDTH.API_CALLBACK.SUBTITLE'
)}`;
}
if (isALineChannel.value) {
return `${t('INBOX_MGMT.FINISH.MESSAGE')}. ${t(
'INBOX_MGMT.ADD.LINE_CHANNEL.API_CALLBACK.SUBTITLE'
)}`;
}
if (isAWhatsAppCloudChannel.value && shouldShowWhatsAppWebhookDetails.value) {
return `${t('INBOX_MGMT.FINISH.MESSAGE')}. ${t(
'INBOX_MGMT.ADD.WHATSAPP.API_CALLBACK.SUBTITLE'
)}`;
}
if (isAnEmailChannel.value && !currentInbox.value.provider) {
return t('INBOX_MGMT.ADD.EMAIL_CHANNEL.FINISH_MESSAGE');
}
if (currentInbox.value.web_widget_script) {
return t('INBOX_MGMT.FINISH.WEBSITE_SUCCESS');
}
if (isWhatsAppEmbeddedSignup.value) {
return `${t('INBOX_MGMT.FINISH.MESSAGE')}. ${t(
'INBOX_MGMT.FINISH.WHATSAPP_QR_INSTRUCTION'
)}`;
}
return t('INBOX_MGMT.FINISH.MESSAGE');
});
async function generateQRCode(platform, identifier) {
if (!identifier || !identifier.trim()) {
// eslint-disable-next-line no-console
console.warn(`Invalid identifier for ${platform} QR code`);
return;
}
try {
const platformUrls = {
whatsapp: id => `https://wa.me/${id}`,
messenger: id => `https://m.me/${id}`,
telegram: id => `https://t.me/${id}`,
};
const url = platformUrls[platform](identifier);
const qrDataUrl = await QRCode.toDataURL(url);
qrCodes[platform] = qrDataUrl;
} catch (error) {
// eslint-disable-next-line no-console
console.error(`Error generating ${platform} QR code:`, error);
qrCodes[platform] = '';
}
}
async function generateQRCodes() {
if (!currentInbox.value) return;
// WhatsApp (both Cloud and Twilio)
if (currentInbox.value.phone_number && isAWhatsAppChannel.value) {
await generateQRCode('whatsapp', currentInbox.value.phone_number);
}
// Facebook Messenger
if (currentInbox.value.page_id && isAFacebookInbox.value) {
await generateQRCode('messenger', currentInbox.value.page_id);
}
// Telegram
if (isATelegramChannel.value && currentInbox.value.bot_name) {
await generateQRCode('telegram', currentInbox.value.bot_name);
}
}
// Watch for currentInbox changes and regenerate QR codes when available
watch(
currentInbox,
newInbox => {
if (newInbox) {
generateQRCodes();
}
},
computed: {
currentInbox() {
return this.$store.getters['inboxes/getInbox'](
this.$route.params.inbox_id
);
},
isATwilioInbox() {
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
);
{ immediate: true }
);
return (
this.currentInbox.channel_type === INBOX_TYPES.INSTAGRAM &&
facebookInbox
);
},
isAEmailInbox() {
return this.currentInbox.channel_type === 'Channel::Email';
},
isALineInbox() {
return this.currentInbox.channel_type === 'Channel::Line';
},
isASmsInbox() {
return this.currentInbox.channel_type === 'Channel::Sms';
},
isWhatsAppCloudInbox() {
return (
this.currentInbox.channel_type === 'Channel::Whatsapp' &&
this.currentInbox.provider === 'whatsapp_cloud'
);
},
// If the inbox is a whatsapp cloud inbox and the source is not embedded signup, then show the webhook details
shouldShowWhatsAppWebhookDetails() {
return (
this.isWhatsAppCloudInbox &&
this.currentInbox.provider_config?.source !== 'embedded_signup'
);
},
message() {
if (this.isATwilioInbox) {
return `${this.$t('INBOX_MGMT.FINISH.MESSAGE')}. ${this.$t(
'INBOX_MGMT.ADD.TWILIO.API_CALLBACK.SUBTITLE'
)}`;
}
if (this.isASmsInbox) {
return `${this.$t('INBOX_MGMT.FINISH.MESSAGE')}. ${this.$t(
'INBOX_MGMT.ADD.SMS.BANDWIDTH.API_CALLBACK.SUBTITLE'
)}`;
}
if (this.isALineInbox) {
return `${this.$t('INBOX_MGMT.FINISH.MESSAGE')}. ${this.$t(
'INBOX_MGMT.ADD.LINE_CHANNEL.API_CALLBACK.SUBTITLE'
)}`;
}
if (this.isWhatsAppCloudInbox && this.shouldShowWhatsAppWebhookDetails) {
return `${this.$t('INBOX_MGMT.FINISH.MESSAGE')}. ${this.$t(
'INBOX_MGMT.ADD.WHATSAPP.API_CALLBACK.SUBTITLE'
)}`;
}
if (this.isAEmailInbox && !this.currentInbox.provider) {
return this.$t('INBOX_MGMT.ADD.EMAIL_CHANNEL.FINISH_MESSAGE');
}
if (this.currentInbox.web_widget_script) {
return this.$t('INBOX_MGMT.FINISH.WEBSITE_SUCCESS');
}
return this.$t('INBOX_MGMT.FINISH.MESSAGE');
},
},
};
onMounted(() => {
generateQRCodes();
});
</script>
<template>
<div
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"
class="overflow-auto col-span-6 p-6 w-full h-full rounded-t-lg border border-b-0 border-n-weak bg-n-solid-1"
>
<DuplicateInboxBanner
v-if="hasDuplicateInstagramInbox"
@@ -115,7 +183,7 @@ export default {
</div>
<div class="w-[50%] max-w-[50%] ml-[25%]">
<woot-code
v-if="isATwilioInbox"
v-if="isATwilioChannel"
lang="html"
:script="currentInbox.callback_webhook_url"
/>
@@ -142,7 +210,7 @@ export default {
</div>
<div class="w-[50%] max-w-[50%] ml-[25%]">
<woot-code
v-if="isALineInbox"
v-if="isALineChannel"
lang="html"
:script="currentInbox.callback_webhook_url"
/>
@@ -155,12 +223,58 @@ export default {
/>
</div>
<div
v-if="isAEmailInbox && !currentInbox.provider"
v-if="isAnEmailChannel && !currentInbox.provider"
class="w-[50%] max-w-[50%] ml-[25%]"
>
<woot-code lang="html" :script="currentInbox.forward_to_email" />
</div>
<div class="flex justify-center gap-2 mt-4">
<div
v-if="isAWhatsAppChannel && qrCodes.whatsapp"
class="flex flex-col items-center mt-8 gap-3"
>
<p class="mt-2 text-sm text-n-slate-9">
{{ $t('INBOX_MGMT.FINISH.WHATSAPP_QR_INSTRUCTION') }}
</p>
<div class="outline-1 outline-n-strong outline rounded-lg shadow">
<img
:src="qrCodes.whatsapp"
alt="WhatsApp QR Code"
class="size-48 dark:invert rounded-lg"
/>
</div>
</div>
<div
v-if="isAFacebookInbox && qrCodes.messenger"
class="flex flex-col items-center mt-8 gap-3"
>
<p class="mt-2 text-sm text-n-slate-9">
{{ $t('INBOX_MGMT.FINISH.MESSENGER_QR_INSTRUCTION') }}
</p>
<div class="outline-1 outline-n-strong outline rounded-lg shadow">
<img
:src="qrCodes.messenger"
alt="Messenger QR Code"
class="size-48 dark:invert rounded-lg"
/>
</div>
</div>
<div
v-if="isATelegramChannel && qrCodes.telegram"
class="flex flex-col items-center mt-8 gap-4"
>
<p class="mt-2 text-sm text-n-slate-9">
{{ $t('INBOX_MGMT.FINISH.TELEGRAM_QR_INSTRUCTION') }}
</p>
<div class="outline-1 outline-n-strong outline rounded-lg shadow">
<img
:src="qrCodes.telegram"
alt="Telegram QR Code"
class="size-48 dark:invert rounded-lg"
/>
</div>
</div>
<div class="flex gap-2 justify-center mt-4">
<router-link
:to="{
name: 'settings_inbox_show',
@@ -1,9 +1,14 @@
<script>
import { mapGetters } from 'vuex';
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import { useBranding } from 'shared/composables/useBranding';
export default {
mixins: [globalConfigMixin],
setup() {
const { replaceInstallationName } = useBranding();
return {
replaceInstallationName,
};
},
computed: {
...mapGetters({
globalConfig: 'globalConfig/get',
@@ -29,10 +34,7 @@ export default {
items() {
return this.createFlowSteps.map(item => ({
...item,
body: this.useInstallationName(
item.body,
this.globalConfig.installationName
),
body: this.replaceInstallationName(item.body),
}));
},
},
@@ -2,7 +2,7 @@
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
import Avatar from 'next/avatar/Avatar.vue';
import { useAdmin } from 'dashboard/composables/useAdmin';
import SettingsLayout from '../SettingsLayout.vue';
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
@@ -101,16 +101,20 @@ const openDelete = inbox => {
<tr v-for="inbox in inboxesList" :key="inbox.id">
<td class="py-4 ltr:pr-4 rtl:pl-4">
<div class="flex items-center flex-row gap-4">
<Thumbnail
<div
v-if="inbox.avatar_url"
class="bg-n-alpha-3 rounded-full p-2 ring ring-n-solid-1 border border-n-strong shadow-sm"
:src="inbox.avatar_url"
:username="inbox.name"
size="48px"
/>
class="bg-n-alpha-3 rounded-full size-12 p-2 ring ring-n-solid-1 border border-n-strong shadow-sm"
>
<Avatar
:src="inbox.avatar_url"
:name="inbox.name"
:size="30"
rounded-full
/>
</div>
<div
v-else
class="w-[48px] h-[48px] flex justify-center items-center bg-n-alpha-3 rounded-full p-2 ring ring-n-solid-1 border border-n-strong shadow-sm"
class="size-12 flex justify-center items-center bg-n-alpha-3 rounded-full p-2 ring ring-n-solid-1 border border-n-strong shadow-sm"
>
<ChannelIcon class="size-5" :inbox="inbox" />
</div>
@@ -3,6 +3,7 @@ import { mapGetters } from 'vuex';
import { shouldBeUrl } from 'shared/helpers/Validators';
import { useAlert } from 'dashboard/composables';
import { useVuelidate } from '@vuelidate/core';
import Avatar from 'next/avatar/Avatar.vue';
import SettingIntroBanner from 'dashboard/components/widgets/SettingIntroBanner.vue';
import SettingsSection from '../../../../components/SettingsSection.vue';
import inboxMixin from 'shared/mixins/inboxMixin';
@@ -11,6 +12,7 @@ import InstagramReauthorize from './channels/instagram/Reauthorize.vue';
import DuplicateInboxBanner from './channels/instagram/DuplicateInboxBanner.vue';
import MicrosoftReauthorize from './channels/microsoft/Reauthorize.vue';
import GoogleReauthorize from './channels/google/Reauthorize.vue';
import WhatsappReauthorize from './channels/whatsapp/Reauthorize.vue';
import PreChatFormSettings from './PreChatForm/Settings.vue';
import WeeklyAvailability from './components/WeeklyAvailability.vue';
import GreetingsEditor from 'shared/components/GreetingsEditor.vue';
@@ -24,6 +26,7 @@ import SenderNameExamplePreview from './components/SenderNameExamplePreview.vue'
import NextButton from 'dashboard/components-next/button/Button.vue';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
import { WIDGET_BUILDER_EDITOR_MENU_OPTIONS } from 'dashboard/constants/editor';
import { getInboxIconByType } from 'dashboard/helper/inbox';
import Editor from 'dashboard/components-next/Editor/Editor.vue';
export default {
@@ -44,8 +47,10 @@ export default {
GoogleReauthorize,
NextButton,
InstagramReauthorize,
WhatsappReauthorize,
DuplicateInboxBanner,
Editor,
Avatar,
},
mixins: [inboxMixin],
setup() {
@@ -87,10 +92,7 @@ export default {
return this.tabs[this.selectedTabIndex]?.key;
},
shouldShowWhatsAppConfiguration() {
return !!(
this.isAWhatsAppCloudChannel &&
this.inbox.provider_config?.source !== 'embedded_signup'
);
return this.isAWhatsAppCloudChannel;
},
whatsAppAPIProviderName() {
if (this.isAWhatsAppCloudChannel) {
@@ -114,16 +116,22 @@ export default {
key: 'collaborators',
name: this.$t('INBOX_MGMT.TABS.COLLABORATORS'),
},
{
key: 'businesshours',
name: this.$t('INBOX_MGMT.TABS.BUSINESS_HOURS'),
},
{
key: 'csat',
name: this.$t('INBOX_MGMT.TABS.CSAT'),
},
];
if (!this.isAVoiceChannel) {
visibleToAllChannelTabs = [
...visibleToAllChannelTabs,
{
key: 'businesshours',
name: this.$t('INBOX_MGMT.TABS.BUSINESS_HOURS'),
},
{
key: 'csat',
name: this.$t('INBOX_MGMT.TABS.CSAT'),
},
];
}
if (this.isAWebWidgetInbox) {
visibleToAllChannelTabs = [
...visibleToAllChannelTabs,
@@ -142,6 +150,7 @@ export default {
this.isATwilioChannel ||
this.isALineChannel ||
this.isAPIInbox ||
this.isAVoiceChannel ||
(this.isAnEmailChannel && !this.inbox.provider) ||
this.shouldShowWhatsAppConfiguration ||
this.isAWebWidgetInbox
@@ -174,7 +183,10 @@ export default {
inbox() {
return this.$store.getters['inboxes/getInbox'](this.currentInboxId);
},
inboxIcon() {
const { medium, channel_type: type } = this.inbox;
return getInboxIconByType(type, medium);
},
inboxName() {
if (this.isATwilioSMSChannel || this.isATwilioWhatsAppChannel) {
return `${this.inbox.name} (${
@@ -247,6 +259,14 @@ export default {
this.inbox.reauthorization_required
);
},
whatsappUnauthorized() {
return (
this.isAWhatsAppChannel &&
this.inbox.provider === 'whatsapp_cloud' &&
this.inbox.provider_config?.source === 'embedded_signup' &&
this.inbox.reauthorization_required
);
},
},
watch: {
$route(to) {
@@ -276,8 +296,17 @@ export default {
}
return [...selected, current];
},
refreshAvatarUrlOnTabChange(index) {
// Refresh avatar URL on tab change from inbox_settings and widgetBuilder tabs, to ensure real-time updates
if (
this.inbox &&
['inbox_settings', 'widgetBuilder'].includes(this.tabs[index].key)
)
this.avatarUrl = this.inbox.avatar_url;
},
onTabChange(selectedTabIndex) {
this.selectedTabIndex = selectedTabIndex;
this.refreshAvatarUrlOnTabChange(selectedTabIndex);
},
fetchInboxSettings() {
this.selectedTabIndex = 0;
@@ -416,6 +445,7 @@ export default {
<FacebookReauthorize v-if="facebookUnauthorized" :inbox="inbox" />
<GoogleReauthorize v-if="googleUnauthorized" :inbox="inbox" />
<InstagramReauthorize v-if="instagramUnauthorized" :inbox="inbox" />
<WhatsappReauthorize v-if="whatsappUnauthorized" :inbox="inbox" />
<DuplicateInboxBanner
v-if="hasDuplicateInstagramInbox"
:content="$t('INBOX_MGMT.ADD.INSTAGRAM.DUPLICATE_INBOX_BANNER')"
@@ -427,14 +457,21 @@ export default {
:sub-title="$t('INBOX_MGMT.SETTINGS_POPUP.INBOX_UPDATE_SUB_TEXT')"
:show-border="false"
>
<woot-avatar-uploader
:label="$t('INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_AVATAR.LABEL')"
:src="avatarUrl"
class="pb-4"
delete-avatar
@on-avatar-select="handleImageUpload"
@on-avatar-delete="handleAvatarDelete"
/>
<div class="flex flex-col mb-4 items-start gap-1">
<label class="mb-0.5 text-sm font-medium text-n-slate-12">
{{ $t('INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_AVATAR.LABEL') }}
</label>
<Avatar
:src="avatarUrl"
:size="72"
:icon-name="inboxIcon"
name=""
allow-upload
rounded-full
@upload="handleImageUpload"
@delete="handleAvatarDelete"
/>
</div>
<woot-input
v-model="selectedInboxName"
class="pb-4"
@@ -646,7 +683,7 @@ export default {
}}
</p>
</label>
<div class="pb-4">
<div v-if="!isAVoiceChannel" class="pb-4">
<label>
{{ $t('INBOX_MGMT.HELP_CENTER.LABEL') }}
</label>
@@ -9,6 +9,7 @@ import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import { LocalStorage } from 'shared/helpers/localStorage';
import { WIDGET_BUILDER_EDITOR_MENU_OPTIONS } from 'dashboard/constants/editor';
import NextButton from 'dashboard/components-next/button/Button.vue';
import Avatar from 'next/avatar/Avatar.vue';
import Editor from 'dashboard/components-next/Editor/Editor.vue';
export default {
@@ -17,6 +18,7 @@ export default {
InputRadioGroup,
NextButton,
Editor,
Avatar,
},
props: {
inbox: {
@@ -276,15 +278,23 @@ export default {
<div class="w-100 lg:w-[40%]">
<div class="min-h-full py-4 overflow-y-scroll px-px">
<form @submit.prevent="updateWidget">
<woot-avatar-uploader
:label="
$t('INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.AVATAR.LABEL')
"
:src="avatarUrl"
delete-avatar
@on-avatar-select="handleImageUpload"
@on-avatar-delete="handleAvatarDelete"
/>
<div class="flex flex-col mb-4 items-start gap-1 w-full">
<label class="mb-0.5 text-sm font-medium text-n-slate-12">
{{
$t('INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.AVATAR.LABEL')
}}
</label>
<Avatar
:src="avatarUrl"
:size="72"
icon-name="i-ri-global-fill"
name=""
allow-upload
rounded-full
@upload="handleImageUpload"
@delete="handleAvatarDelete"
/>
</div>
<woot-input
v-model="websiteName"
:class="{ error: v$.websiteName.$error }"
@@ -6,11 +6,11 @@ import { useAlert } from 'dashboard/composables';
import { useAccount } from 'dashboard/composables/useAccount';
import { required } from '@vuelidate/validators';
import LoadingState from 'dashboard/components/widgets/LoadingState.vue';
import { mapGetters } from 'vuex';
import ChannelApi from '../../../../../api/channels';
import PageHeader from '../../SettingsSubPageHeader.vue';
import router from '../../../../index';
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import { useBranding } from 'shared/composables/useBranding';
import NextButton from 'dashboard/components-next/button/Button.vue';
import { loadScript } from 'dashboard/helper/DOMHelpers';
@@ -22,11 +22,12 @@ export default {
PageHeader,
NextButton,
},
mixins: [globalConfigMixin],
setup() {
const { accountId } = useAccount();
const { replaceInstallationName } = useBranding();
return {
accountId,
replaceInstallationName,
v$: useVuelidate(),
};
},
@@ -66,9 +67,6 @@ export default {
getSelectablePages() {
return this.pageList.filter(item => !item.exists);
},
...mapGetters({
globalConfig: 'globalConfig/get',
}),
},
mounted() {
@@ -223,12 +221,7 @@ export default {
/>
</a>
<p class="py-6">
{{
useInstallationName(
$t('INBOX_MGMT.ADD.FB.HELP'),
globalConfig.installationName
)
}}
{{ replaceInstallationName($t('INBOX_MGMT.ADD.FB.HELP')) }}
</p>
</div>
<div v-else>
@@ -249,10 +242,7 @@ export default {
<PageHeader
:header-title="$t('INBOX_MGMT.ADD.DETAILS.TITLE')"
:header-content="
useInstallationName(
$t('INBOX_MGMT.ADD.DETAILS.DESC'),
globalConfig.installationName
)
replaceInstallationName($t('INBOX_MGMT.ADD.DETAILS.DESC'))
"
/>
</div>
@@ -1,11 +1,9 @@
<script>
import { useVuelidate } from '@vuelidate/core';
import { useAccount } from 'dashboard/composables/useAccount';
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import instagramClient from 'dashboard/api/channel/instagramClient';
export default {
mixins: [globalConfigMixin],
setup() {
const { accountId } = useAccount();
return {
@@ -22,7 +22,6 @@ const state = reactive({
authToken: '',
apiKeySid: '',
apiKeySecret: '',
twimlAppSid: '',
});
const uiFlags = useMapGetter('inboxes/getUIFlags');
@@ -33,7 +32,6 @@ const validationRules = {
authToken: { required },
apiKeySid: { required },
apiKeySecret: { required },
twimlAppSid: { required },
};
const v$ = useVuelidate(validationRules, state);
@@ -55,9 +53,6 @@ const formErrors = computed(() => ({
apiKeySecret: v$.value.apiKeySecret?.$error
? t('INBOX_MGMT.ADD.VOICE.TWILIO.API_KEY_SECRET.REQUIRED')
: '',
twimlAppSid: v$.value.twimlAppSid?.$error
? t('INBOX_MGMT.ADD.VOICE.TWILIO.TWIML_APP_SID.REQUIRED')
: '',
}));
function getProviderConfig() {
@@ -67,7 +62,6 @@ function getProviderConfig() {
api_key_sid: state.apiKeySid,
api_key_secret: state.apiKeySecret,
};
if (state.twimlAppSid) config.outgoing_application_sid = state.twimlAppSid;
return config;
}
@@ -160,17 +154,6 @@ async function createChannel() {
@blur="v$.apiKeySecret?.$touch"
/>
<Input
v-model="state.twimlAppSid"
:label="t('INBOX_MGMT.ADD.VOICE.TWILIO.TWIML_APP_SID.LABEL')"
:placeholder="
t('INBOX_MGMT.ADD.VOICE.TWILIO.TWIML_APP_SID.PLACEHOLDER')
"
:message="formErrors.twimlAppSid"
:message-type="formErrors.twimlAppSid ? 'error' : 'info'"
@blur="v$.twimlAppSid?.$touch"
/>
<div>
<NextButton
:is-loading="uiFlags.isCreating"
@@ -7,8 +7,13 @@ import { useAlert } from 'dashboard/composables';
import Icon from 'next/icon/Icon.vue';
import NextButton from 'next/button/Button.vue';
import LoadingState from 'dashboard/components/widgets/LoadingState.vue';
import { loadScript } from 'dashboard/helper/DOMHelpers';
import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
import {
setupFacebookSdk,
initWhatsAppEmbeddedSignup,
createMessageHandler,
isValidBusinessData,
} from './whatsapp/utils';
const store = useStore();
const router = useRouter();
@@ -102,7 +107,7 @@ const completeSignupFlow = async businessDataParam => {
code: authCode.value,
business_id: businessDataParam.business_id,
waba_id: businessDataParam.waba_id,
phone_number_id: businessDataParam.phone_number_id,
phone_number_id: businessDataParam?.phone_number_id || '',
};
const responseData = await store.dispatch(
@@ -120,17 +125,12 @@ const completeSignupFlow = async businessDataParam => {
}
};
const isValidBusinessData = businessDataLocal => {
return (
businessDataLocal &&
businessDataLocal.business_id &&
businessDataLocal.waba_id
);
};
// Message handling
const handleEmbeddedSignupData = async data => {
if (data.event === 'FINISH') {
if (
data.event === 'FINISH' ||
data.event === 'FINISH_WHATSAPP_BUSINESS_APP_ONBOARDING'
) {
const businessDataLocal = data.data;
if (isValidBusinessData(businessDataLocal)) {
@@ -162,9 +162,26 @@ const handleEmbeddedSignupData = async data => {
}
};
const fbLoginCallback = response => {
if (response.authResponse && response.authResponse.code) {
authCode.value = response.authResponse.code;
const handleSignupMessage = createMessageHandler(handleEmbeddedSignupData);
const launchEmbeddedSignup = async () => {
try {
isAuthenticating.value = true;
processingMessage.value = t(
'INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.AUTH_PROCESSING'
);
await setupFacebookSdk(
window.chatwootConfig?.whatsappAppId,
window.chatwootConfig?.whatsappApiVersion
);
fbSdkLoaded.value = true;
const code = await initWhatsAppEmbeddedSignup(
window.chatwootConfig?.whatsappConfigurationId
);
authCode.value = code;
authCodeReceived.value = true;
processingMessage.value = t(
'INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.WAITING_FOR_BUSINESS_INFO'
@@ -173,79 +190,18 @@ const fbLoginCallback = response => {
if (businessData.value) {
completeSignupFlow(businessData.value);
}
} else if (response.error) {
handleSignupError({ error: response.error });
} else {
isProcessing.value = false;
isAuthenticating.value = false;
useAlert(t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.CANCELLED'));
}
};
const handleSignupMessage = event => {
// Validate origin for security - following Facebook documentation
// https://developers.facebook.com/docs/whatsapp/embedded-signup/implementation#step-3--add-embedded-signup-to-your-website
if (!event.origin.endsWith('facebook.com')) return;
// Parse and handle WhatsApp embedded signup events
try {
const data = JSON.parse(event.data);
if (data.type === 'WA_EMBEDDED_SIGNUP') {
handleEmbeddedSignupData(data);
}
} catch {
// Ignore non-JSON or irrelevant messages
}
};
const runFBInit = () => {
window.FB.init({
appId: window.chatwootConfig?.whatsappAppId,
autoLogAppEvents: true,
xfbml: true,
version: window.chatwootConfig?.whatsappApiVersion || 'v22.0',
});
fbSdkLoaded.value = true;
};
const loadFacebookSdk = async () => {
return loadScript('https://connect.facebook.net/en_US/sdk.js', {
async: true,
defer: true,
crossOrigin: 'anonymous',
});
};
const tryWhatsAppLogin = () => {
isAuthenticating.value = true;
processingMessage.value = t(
'INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.AUTH_PROCESSING'
);
window.FB.login(fbLoginCallback, {
config_id: window.chatwootConfig?.whatsappConfigurationId,
response_type: 'code',
override_default_response_type: true,
extras: {
setup: {},
featureType: '',
sessionInfoVersion: '3',
},
});
};
const launchEmbeddedSignup = async () => {
try {
// Load SDK first if not loaded, following Facebook.vue pattern exactly
await loadFacebookSdk();
runFBInit(); // Initialize FB after loading
// Now proceed with login
tryWhatsAppLogin();
} catch (error) {
handleSignupError({
error: t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.SDK_LOAD_ERROR'),
});
if (error.message === 'Login cancelled') {
isProcessing.value = false;
isAuthenticating.value = false;
useAlert(t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.CANCELLED'));
} else {
handleSignupError({
error:
error.message ||
t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.SDK_LOAD_ERROR'),
});
}
}
};
@@ -259,7 +215,6 @@ const cleanupMessageListener = () => {
};
const initialize = () => {
window.fbAsyncInit = runFBInit;
setupMessageListener();
};
@@ -310,6 +265,25 @@ onBeforeUnmount(() => {
</div>
</div>
<div class="flex flex-col gap-2 mb-6">
<span class="text-sm text-n-slate-11">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.LEARN_MORE.TEXT') }}
{{ ' ' }}
<a
:href="
$t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.LEARN_MORE.LINK_URL')
"
target="_blank"
rel="noopener noreferrer"
class="underline text-primary"
>
{{
$t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.LEARN_MORE.LINK_TEXT')
}}
</a>
</span>
</div>
<div class="flex mt-4">
<NextButton
:disabled="isAuthenticating"
@@ -0,0 +1,190 @@
<script setup>
import { ref, computed, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import InboxReconnectionRequired from '../../components/InboxReconnectionRequired.vue';
import whatsappChannel from 'dashboard/api/channel/whatsappChannel';
import {
setupFacebookSdk,
initWhatsAppEmbeddedSignup,
createMessageHandler,
isValidBusinessData,
} from './utils';
const props = defineProps({
inbox: {
type: Object,
required: true,
},
});
const { t } = useI18n();
const isRequestingAuthorization = ref(false);
const isLoadingFacebook = ref(true);
const whatsappAppId = computed(() => window.chatwootConfig.whatsappAppId);
const whatsappConfigurationId = computed(
() => window.chatwootConfig.whatsappConfigurationId
);
const reauthorizeWhatsApp = async params => {
isRequestingAuthorization.value = true;
try {
const response = await whatsappChannel.reauthorizeWhatsApp({
inboxId: props.inbox.id,
...params,
});
if (response.data.success) {
useAlert(t('INBOX.REAUTHORIZE.SUCCESS'));
} else {
useAlert(response.data.message || t('INBOX.REAUTHORIZE.ERROR'));
}
} catch (error) {
useAlert(error.message || t('INBOX.REAUTHORIZE.ERROR'));
} finally {
isRequestingAuthorization.value = false;
}
};
const handleEmbeddedSignupEvents = async (data, authCode) => {
if (!data || typeof data !== 'object') {
return;
}
// Handle different event types
if (data.event === 'FINISH') {
const businessData = data.data;
if (isValidBusinessData(businessData) && businessData.phone_number_id) {
await reauthorizeWhatsApp({
code: authCode,
business_id: businessData.business_id,
waba_id: businessData.waba_id,
phone_number_id: businessData.phone_number_id,
});
} else {
useAlert(
t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.INVALID_BUSINESS_DATA')
);
}
} else if (data.event === 'CANCEL') {
isRequestingAuthorization.value = false;
useAlert(t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.CANCELLED'));
} else if (data.event === 'error') {
isRequestingAuthorization.value = false;
useAlert(
data.error_message ||
t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.SIGNUP_ERROR')
);
}
};
const startEmbeddedSignup = authCode => {
const messageHandler = createMessageHandler(data =>
handleEmbeddedSignupEvents(data, authCode)
);
window.addEventListener('message', messageHandler);
};
const handleLoginAndReauthorize = async () => {
// Validate required configuration
if (!whatsappAppId.value) {
throw new Error('WhatsApp App ID is required');
}
if (!whatsappConfigurationId.value) {
throw new Error('WhatsApp Configuration ID is required');
}
try {
const authCode = await initWhatsAppEmbeddedSignup(
whatsappConfigurationId.value
);
// Check if this is a reauthorization scenario where we already have the business data
const existingConfig = props.inbox.provider_config;
if (
existingConfig &&
existingConfig.business_account_id &&
existingConfig.phone_number_id
) {
await reauthorizeWhatsApp({
code: authCode,
business_id: existingConfig.business_account_id,
waba_id: existingConfig.business_account_id,
phone_number_id: existingConfig.phone_number_id,
});
} else {
startEmbeddedSignup(authCode);
}
} catch (error) {
if (error.message === 'Login cancelled') {
useAlert(t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.CANCELLED'));
} else {
useAlert(
error.message ||
t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.AUTH_NOT_COMPLETED')
);
}
throw error;
}
};
const requestAuthorization = async () => {
if (isLoadingFacebook.value) {
useAlert(t('INBOX.REAUTHORIZE.LOADING_FACEBOOK'));
return;
}
isRequestingAuthorization.value = true;
try {
await handleLoginAndReauthorize();
} catch (error) {
useAlert(error.message || t('INBOX.REAUTHORIZE.CONFIGURATION_ERROR'));
} finally {
// Reset only if not already processing through embedded signup
if (!window.FB || !window.FB.getLoginStatus) {
isRequestingAuthorization.value = false;
}
}
};
onMounted(async () => {
try {
// Validate required configuration
if (!whatsappAppId.value) {
useAlert(t('INBOX.REAUTHORIZE.WHATSAPP_APP_ID_MISSING'));
return;
}
if (!whatsappConfigurationId.value) {
useAlert(t('INBOX.REAUTHORIZE.WHATSAPP_CONFIG_ID_MISSING'));
return;
}
// Load Facebook SDK and initialize
await setupFacebookSdk(
whatsappAppId.value,
window.chatwootConfig?.whatsappApiVersion
);
} catch (error) {
useAlert(t('INBOX.REAUTHORIZE.FACEBOOK_LOAD_ERROR'));
} finally {
isLoadingFacebook.value = false;
}
});
// Expose requestAuthorization function for parent components
defineExpose({
requestAuthorization,
});
</script>
<template>
<InboxReconnectionRequired
class="mx-8 mt-5"
:is-loading="isRequestingAuthorization"
@reauthorize="requestAuthorization"
/>
</template>
@@ -0,0 +1,89 @@
import { loadScript } from 'dashboard/helper/DOMHelpers';
export const loadFacebookSdk = async () => {
return loadScript('https://connect.facebook.net/en_US/sdk.js', {
async: true,
defer: true,
crossOrigin: 'anonymous',
});
};
export const initializeFacebook = (appId, apiVersion) => {
const version = apiVersion || 'v22.0';
return new Promise(resolve => {
const init = () => {
window.FB.init({
appId,
autoLogAppEvents: true,
xfbml: true,
version,
});
resolve();
};
if (window.FB) {
init();
} else {
window.fbAsyncInit = init;
}
});
};
export const isValidBusinessData = businessData => {
return businessData && businessData.business_id && businessData.waba_id;
};
export const createMessageHandler = onEmbeddedSignupData => {
return event => {
if (!event.origin.endsWith('facebook.com')) return;
try {
let data;
if (typeof event.data === 'string') {
data = JSON.parse(event.data);
} else if (typeof event.data === 'object' && event.data !== null) {
data = event.data;
} else {
return;
}
if (data.type === 'WA_EMBEDDED_SIGNUP') {
onEmbeddedSignupData(data);
}
} catch {
// Ignore non-JSON or irrelevant messages
}
};
};
export const initWhatsAppEmbeddedSignup = configId => {
return new Promise((resolve, reject) => {
window.FB.login(
response => {
if (response.authResponse && response.authResponse.code) {
resolve(response.authResponse.code);
} else if (response.error) {
reject(new Error(response.error));
} else {
reject(new Error('Login cancelled'));
}
},
{
config_id: configId,
response_type: 'code',
override_default_response_type: true,
extras: {
setup: {},
featureType: 'whatsapp_business_app_onboarding',
sessionInfoVersion: '3',
},
}
);
});
};
export const setupFacebookSdk = async (appId, apiVersion) => {
const version = apiVersion || 'v22.0';
await loadFacebookSdk();
await initializeFacebook(appId, version);
};
@@ -1,11 +1,11 @@
<script>
import PreviewCard from 'dashboard/components/ui/PreviewCard.vue';
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
import Avatar from 'next/avatar/Avatar.vue';
export default {
components: {
PreviewCard,
Thumbnail,
Avatar,
},
props: {
senderNameType: {
@@ -45,7 +45,7 @@ export default {
),
preview: {
senderName: '',
businessName: 'Chatwoot',
businessName: 'Chatwoot ',
email: '<support@yourbusiness.com>',
},
},
@@ -86,7 +86,7 @@ export default {
{{ $t('INBOX_MGMT.EDIT.SENDER_NAME_SECTION.FOR_EG') }}
</span>
<div class="flex flex-row items-center gap-2">
<Thumbnail :username="userName(keyOption)" size="32px" />
<Avatar :name="userName(keyOption)" :size="32" rounded-full />
<div class="flex flex-col items-start gap-1">
<div class="items-center flex flex-row gap-0.5 max-w-[18rem]">
<span
@@ -7,6 +7,7 @@ import SmtpSettings from '../SmtpSettings.vue';
import { useVuelidate } from '@vuelidate/core';
import { required } from '@vuelidate/validators';
import NextButton from 'dashboard/components-next/button/Button.vue';
import WhatsappReauthorize from '../channels/whatsapp/Reauthorize.vue';
export default {
components: {
@@ -14,6 +15,7 @@ export default {
ImapSettings,
SmtpSettings,
NextButton,
WhatsappReauthorize,
},
mixins: [inboxMixin],
props: {
@@ -29,12 +31,21 @@ export default {
return {
hmacMandatory: false,
whatsAppInboxAPIKey: '',
isRequestingReauthorization: false,
isSyncingTemplates: false,
};
},
validations: {
whatsAppInboxAPIKey: { required },
},
computed: {
isEmbeddedSignupWhatsApp() {
return this.inbox.provider_config?.source === 'embedded_signup';
},
whatsappAppId() {
return window.chatwootConfig?.whatsappAppId;
},
},
watch: {
inbox() {
this.setDefaults();
@@ -84,6 +95,11 @@ export default {
useAlert(this.$t('INBOX_MGMT.EDIT.API.ERROR_MESSAGE'));
}
},
async handleReconfigure() {
if (this.$refs.whatsappReauth) {
await this.$refs.whatsappReauth.requestAuthorization();
}
},
async syncTemplates() {
this.isSyncingTemplates = true;
try {
@@ -110,6 +126,25 @@ export default {
<woot-code :script="inbox.callback_webhook_url" lang="html" />
</SettingsSection>
</div>
<div v-else-if="isAVoiceChannel" class="mx-8">
<SettingsSection
:title="$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_VOICE_URL_TITLE')"
:sub-title="
$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_VOICE_URL_SUBTITLE')
"
>
<woot-code :script="inbox.voice_call_webhook_url" lang="html" />
</SettingsSection>
<SettingsSection
:title="$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_STATUS_URL_TITLE')"
:sub-title="
$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_STATUS_URL_SUBTITLE')
"
>
<woot-code :script="inbox.voice_status_webhook_url" lang="html" />
</SettingsSection>
</div>
<div v-else-if="isALineChannel" class="mx-8">
<SettingsSection
:title="$t('INBOX_MGMT.ADD.LINE_CHANNEL.API_CALLBACK.TITLE')"
@@ -210,45 +245,80 @@ export default {
</div>
<div v-else-if="isAWhatsAppChannel && !isATwilioChannel">
<div v-if="inbox.provider_config" class="mx-8">
<SettingsSection
:title="$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_WEBHOOK_TITLE')"
:sub-title="$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_WEBHOOK_SUBHEADER')"
>
<woot-code :script="inbox.provider_config.webhook_verify_token" />
</SettingsSection>
<SettingsSection
:title="$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_SECTION_TITLE')"
:sub-title="$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_SECTION_SUBHEADER')"
>
<woot-code :script="inbox.provider_config.api_key" />
</SettingsSection>
<SettingsSection
:title="$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_SECTION_UPDATE_TITLE')"
:sub-title="
$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_SECTION_UPDATE_SUBHEADER')
"
>
<div
class="flex flex-1 justify-between items-center mt-2 whatsapp-settings--content"
<!-- Embedded Signup Section -->
<template v-if="isEmbeddedSignupWhatsApp">
<SettingsSection
v-if="whatsappAppId"
:title="
$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_EMBEDDED_SIGNUP_TITLE')
"
:sub-title="
$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_EMBEDDED_SIGNUP_SUBHEADER')
"
>
<woot-input
v-model="whatsAppInboxAPIKey"
type="text"
class="flex-1 mr-2 [&>input]:!mb-0"
:placeholder="
$t(
'INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_SECTION_UPDATE_PLACEHOLDER'
)
"
/>
<NextButton
:disabled="v$.whatsAppInboxAPIKey.$invalid"
@click="updateWhatsAppInboxAPIKey"
<div class="flex gap-4 items-center">
<p class="text-sm text-slate-600">
{{
$t(
'INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_EMBEDDED_SIGNUP_DESCRIPTION'
)
}}
</p>
<NextButton @click="handleReconfigure">
{{ $t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_RECONFIGURE_BUTTON') }}
</NextButton>
</div>
</SettingsSection>
</template>
<!-- Manual Setup Section -->
<template v-else>
<SettingsSection
:title="$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_WEBHOOK_TITLE')"
:sub-title="
$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_WEBHOOK_SUBHEADER')
"
>
<woot-code :script="inbox.provider_config.webhook_verify_token" />
</SettingsSection>
<SettingsSection
:title="$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_SECTION_TITLE')"
:sub-title="
$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_SECTION_SUBHEADER')
"
>
<woot-code :script="inbox.provider_config.api_key" />
</SettingsSection>
<SettingsSection
:title="$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_SECTION_UPDATE_TITLE')"
:sub-title="
$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_SECTION_UPDATE_SUBHEADER')
"
>
<div
class="flex flex-1 justify-between items-center mt-2 whatsapp-settings--content"
>
{{ $t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_SECTION_UPDATE_BUTTON') }}
</NextButton>
</div>
</SettingsSection>
<woot-input
v-model="whatsAppInboxAPIKey"
type="text"
class="flex-1 mr-2 [&>input]:!mb-0"
:placeholder="
$t(
'INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_SECTION_UPDATE_PLACEHOLDER'
)
"
/>
<NextButton
:disabled="v$.whatsAppInboxAPIKey.$invalid"
@click="updateWhatsAppInboxAPIKey"
>
{{
$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_SECTION_UPDATE_BUTTON')
}}
</NextButton>
</div>
</SettingsSection>
</template>
<SettingsSection
:title="$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_TEMPLATES_SYNC_TITLE')"
:sub-title="
@@ -262,6 +332,12 @@ export default {
</div>
</SettingsSection>
</div>
<WhatsappReauthorize
v-if="isEmbeddedSignupWhatsApp"
ref="whatsappReauth"
:inbox="inbox"
class="hidden"
/>
</div>
</template>
@@ -3,7 +3,6 @@ import { mapGetters } from 'vuex';
import { useAlert } from 'dashboard/composables';
import DashboardAppModal from './DashboardAppModal.vue';
import DashboardAppsRow from './DashboardAppsRow.vue';
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import BaseSettingsHeader from '../../components/BaseSettingsHeader.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
@@ -14,7 +13,6 @@ export default {
DashboardAppsRow,
NextButton,
},
mixins: [globalConfigMixin],
data() {
return {
loading: {},
@@ -1,12 +1,14 @@
<script setup>
import { useStoreGetters, useStore } from 'dashboard/composables/store';
import { computed, onMounted } from 'vue';
import { useBranding } from 'shared/composables/useBranding';
import IntegrationItem from './IntegrationItem.vue';
import SettingsLayout from '../SettingsLayout.vue';
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
const store = useStore();
const getters = useStoreGetters();
const { replaceInstallationName } = useBranding();
const uiFlags = getters['integrations/getUIFlags'];
@@ -27,7 +29,9 @@ onMounted(() => {
<template #header>
<BaseSettingsHeader
:title="$t('INTEGRATION_SETTINGS.HEADER')"
:description="$t('INTEGRATION_SETTINGS.DESCRIPTION')"
:description="
replaceInstallationName($t('INTEGRATION_SETTINGS.DESCRIPTION'))
"
:link-text="$t('INTEGRATION_SETTINGS.LEARN_MORE')"
feature-name="integrations"
/>
@@ -5,7 +5,7 @@ import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { frontendURL } from '../../../../helper/URLHelper';
import { useAlert } from 'dashboard/composables';
import { useInstallationName } from 'shared/mixins/globalConfigMixin';
import { useBranding } from 'shared/composables/useBranding';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import Button from 'dashboard/components-next/button/Button.vue';
@@ -26,11 +26,11 @@ const props = defineProps({
const { t } = useI18n();
const store = useStore();
const router = useRouter();
const { replaceInstallationName } = useBranding();
const dialogRef = ref(null);
const accountId = computed(() => store.getters.getCurrentAccountId);
const globalConfig = computed(() => store.getters['globalConfig/get']);
const openDeletePopup = () => {
if (dialogRef.value) {
@@ -82,12 +82,7 @@ const confirmDeletion = () => {
{{ integrationName }}
</h3>
<p class="text-n-slate-11 text-sm leading-6">
{{
useInstallationName(
integrationDescription,
globalConfig.installationName
)
}}
{{ replaceInstallationName(integrationDescription) }}
</p>
</div>
</div>
@@ -3,7 +3,7 @@ import { computed } from 'vue';
import { useStoreGetters } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import { frontendURL } from 'dashboard/helper/URLHelper';
import { useInstallationName } from 'shared/mixins/globalConfigMixin';
import { useBranding } from 'shared/composables/useBranding';
import Button from 'dashboard/components-next/button/Button.vue';
@@ -28,9 +28,9 @@ const props = defineProps({
const getters = useStoreGetters();
const accountId = getters.getCurrentAccountId;
const globalConfig = getters['globalConfig/get'];
const { t } = useI18n();
const { replaceInstallationName } = useBranding();
const integrationStatus = computed(() =>
props.enabled
@@ -80,7 +80,7 @@ const actionURL = computed(() =>
</router-link>
</div>
<p class="text-n-slate-11">
{{ useInstallationName(description, globalConfig.installationName) }}
{{ replaceInstallationName(description) }}
</p>
</div>
</div>
@@ -1,5 +1,4 @@
<script>
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import Integration from './Integration.vue';
import IntegrationHelpText from './IntegrationHelpText.vue';
@@ -8,8 +7,6 @@ export default {
Integration,
IntegrationHelpText,
},
mixins: [globalConfigMixin],
props: {
integrationId: {
type: [String, Number],
@@ -3,7 +3,7 @@ import { ref, computed } from 'vue';
import { useStore } from 'vuex';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import { useInstallationName } from 'shared/mixins/globalConfigMixin';
import { useBranding } from 'shared/composables/useBranding';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import Button from 'dashboard/components-next/button/Button.vue';
@@ -18,6 +18,7 @@ const store = useStore();
const { t } = useI18n();
const { formatMessage } = useMessageFormatter();
const { replaceInstallationName } = useBranding();
const selectedChannelId = ref('');
const availableChannels = ref([]);
@@ -29,16 +30,9 @@ const errorDescription = computed(() => {
? t('INTEGRATION_SETTINGS.SLACK.SELECT_CHANNEL.DESCRIPTION')
: t('INTEGRATION_SETTINGS.SLACK.SELECT_CHANNEL.EXPIRED');
});
const globalConfig = computed(() => store.getters['globalConfig/get']);
const formattedErrorMessage = computed(() => {
return formatMessage(
useInstallationName(
errorDescription.value,
globalConfig.value.installationName
),
false
);
return formatMessage(replaceInstallationName(errorDescription.value), false);
});
const fetchChannels = async () => {
@@ -1,6 +1,7 @@
<script>
import { mapGetters } from 'vuex';
import { useAlert } from 'dashboard/composables';
import { useBranding } from 'shared/composables/useBranding';
import NextButton from 'dashboard/components-next/button/Button.vue';
import NewWebhook from './NewWebHook.vue';
import EditWebhook from './EditWebHook.vue';
@@ -17,6 +18,10 @@ export default {
EditWebhook,
WebhookRow,
},
setup() {
const { replaceInstallationName } = useBranding();
return { replaceInstallationName };
},
data() {
return {
loading: {},
@@ -99,7 +104,7 @@ export default {
<BaseSettingsHeader
v-if="integration.name"
:title="integration.name"
:description="integration.description"
:description="replaceInstallationName(integration.description)"
:link-text="$t('INTEGRATION_SETTINGS.WEBHOOK.LEARN_MORE')"
feature-name="webhook"
:back-button-label="$t('INTEGRATION_SETTINGS.HEADER')"
@@ -1,21 +1,25 @@
<script>
import { useAlert } from 'dashboard/composables';
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import { useBranding } from 'shared/composables/useBranding';
import { mapGetters } from 'vuex';
import WebhookForm from './WebhookForm.vue';
export default {
components: { WebhookForm },
mixins: [globalConfigMixin],
props: {
onClose: {
type: Function,
required: true,
},
},
setup() {
const { replaceInstallationName } = useBranding();
return {
replaceInstallationName,
};
},
computed: {
...mapGetters({
globalConfig: 'globalConfig/get',
uiFlags: 'webhooks/getUIFlags',
}),
},
@@ -43,10 +47,7 @@ export default {
<woot-modal-header
:header-title="$t('INTEGRATION_SETTINGS.WEBHOOK.ADD.TITLE')"
:header-content="
useInstallationName(
$t('INTEGRATION_SETTINGS.WEBHOOK.FORM.DESC'),
globalConfig.installationName
)
replaceInstallationName($t('INTEGRATION_SETTINGS.WEBHOOK.FORM.DESC'))
"
/>
<WebhookForm
@@ -1,6 +1,6 @@
<script setup>
import { computed } from 'vue';
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
import Avatar from 'next/avatar/Avatar.vue';
import { useI18n } from 'vue-i18n';
import Button from 'dashboard/components-next/button/Button.vue';
@@ -38,14 +38,14 @@ const visibilityLabel = computed(() => {
<td class="py-4 ltr:pr-4 rtl:pl-4 truncate">{{ macro.name }}</td>
<td class="py-4 ltr:pr-4 rtl:pl-4">
<div v-if="macro.created_by" class="flex items-center">
<Thumbnail :username="createdByName" size="24px" />
<Avatar :name="createdByName" :size="24" rounded-full />
<span class="mx-2">{{ createdByName }}</span>
</div>
<div v-else>--</div>
</td>
<td class="py-4 ltr:pr-4 rtl:pl-4">
<div v-if="macro.updated_by" class="flex items-center">
<Thumbnail :username="updatedByName" size="24px" />
<Avatar :name="updatedByName" :size="24" rounded-full />
<span class="mx-2">{{ updatedByName }}</span>
</div>
<div v-else>--</div>
@@ -3,10 +3,10 @@ import { mapGetters } from 'vuex';
import { useAlert } from 'dashboard/composables';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useFontSize } from 'dashboard/composables/useFontSize';
import { useBranding } from 'shared/composables/useBranding';
import { clearCookiesOnLogout } from 'dashboard/store/utils/api.js';
import { copyTextToClipboard } from 'shared/helpers/clipboard';
import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import UserProfilePicture from './UserProfilePicture.vue';
import UserBasicDetails from './UserBasicDetails.vue';
import MessageSignature from './MessageSignature.vue';
@@ -37,16 +37,17 @@ export default {
AudioNotifications,
AccessToken,
},
mixins: [globalConfigMixin],
setup() {
const { isEditorHotKeyEnabled, updateUISettings } = useUISettings();
const { currentFontSize, updateFontSize } = useFontSize();
const { replaceInstallationName } = useBranding();
return {
currentFontSize,
updateFontSize,
isEditorHotKeyEnabled,
updateUISettings,
replaceInstallationName,
};
},
data() {
@@ -215,7 +216,11 @@ export default {
</div>
<FormSection
:title="$t('PROFILE_SETTINGS.FORM.INTERFACE_SECTION.TITLE')"
:description="$t('PROFILE_SETTINGS.FORM.INTERFACE_SECTION.NOTE')"
:description="
replaceInstallationName(
$t('PROFILE_SETTINGS.FORM.INTERFACE_SECTION.NOTE')
)
"
>
<FontSize
:value="currentFontSize"
@@ -288,10 +293,7 @@ export default {
<FormSection
:title="$t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.TITLE')"
:description="
useInstallationName(
$t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.NOTE'),
globalConfig.installationName
)
replaceInstallationName($t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.NOTE'))
"
>
<AccessToken
@@ -3,7 +3,7 @@ import endOfDay from 'date-fns/endOfDay';
import getUnixTime from 'date-fns/getUnixTime';
import startOfDay from 'date-fns/startOfDay';
import subDays from 'date-fns/subDays';
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
import Avatar from 'next/avatar/Avatar.vue';
import WootDateRangePicker from 'dashboard/components/ui/DateRangePicker.vue';
import ToggleSwitch from 'dashboard/components-next/switch/Switch.vue';
@@ -13,7 +13,7 @@ const CUSTOM_DATE_RANGE_ID = 5;
export default {
components: {
WootDateRangePicker,
Thumbnail,
Avatar,
ToggleSwitch,
},
props: {
@@ -184,11 +184,13 @@ export default {
>
<template #singleLabel="props">
<div class="flex min-w-0 items-center gap-2">
<Thumbnail
<Avatar
:src="props.option.thumbnail"
:status="props.option.availability_status"
:username="props.option.name"
size="22px"
:name="props.option.name"
:size="22"
hide-offline-status
rounded-full
/>
<span class="my-0 text-n-slate-12 truncate">{{
props.option.name
@@ -197,11 +199,13 @@ export default {
</template>
<template #options="props">
<div class="flex items-center gap-2">
<Thumbnail
<Avatar
:src="props.option.thumbnail"
:status="props.option.availability_status"
:username="props.option.name"
size="22px"
:name="props.option.name"
:size="22"
hide-offline-status
rounded-full
/>
<p class="my-0 text-n-slate-12">
{{ props.option.name }}
@@ -1,6 +1,6 @@
<script setup>
import BaseCell from 'dashboard/components/table/BaseCell.vue';
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
import Avatar from 'next/avatar/Avatar.vue';
import { useMapGetter } from 'dashboard/composables/store';
defineProps({
@@ -19,11 +19,13 @@ const isRTL = useMapGetter('accounts/isRTL');
class="items-center flex text-left"
:class="{ 'flex-row-reverse': isRTL }"
>
<Thumbnail
<Avatar
:src="row.original.thumbnail"
size="32px"
:username="row.original.agent"
:name="row.original.agent"
:status="row.original.status"
:size="32"
hide-offline-status
rounded-full
/>
<div class="items-start flex flex-col min-w-0 my-0 mx-2">
<h6
@@ -1,11 +1,11 @@
<script>
import NextButton from 'dashboard/components-next/button/Button.vue';
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
import Avatar from 'next/avatar/Avatar.vue';
export default {
components: {
NextButton,
Thumbnail,
Avatar,
},
props: {
agentList: {
@@ -115,11 +115,13 @@ export default {
</td>
<td>
<div class="flex items-center gap-2">
<Thumbnail
<Avatar
:src="agent.thumbnail"
size="24px"
:username="agent.name"
:name="agent.name"
:status="agent.availability_status"
:size="24"
hide-offline-status
rounded-full
/>
<h4 class="text-base mb-0 text-n-slate-12">
{{ agent.name }}
@@ -4,6 +4,7 @@ import { useStore } from 'dashboard/composables/store';
import { useMapGetter } from 'dashboard/composables/store.js';
import { useRouter } from 'vue-router';
import { useAccount } from 'dashboard/composables/useAccount';
import { useConfig } from 'dashboard/composables/useConfig';
import { differenceInDays } from 'date-fns';
import { useAdmin } from 'dashboard/composables/useAdmin';
import { useI18n } from 'vue-i18n';
@@ -22,6 +23,7 @@ const router = useRouter();
const store = useStore();
const { t } = useI18n();
const { accountId, currentAccount } = useAccount();
const { isEnterprise } = useConfig();
const { isAdmin } = useAdmin();
const isOnChatwootCloud = useMapGetter('globalConfig/isOnChatwootCloud');
@@ -100,7 +102,11 @@ const routeToBilling = () => {
});
};
onMounted(() => fetchLimits());
onMounted(() => {
if (isEnterprise) {
fetchLimits();
}
});
defineExpose({ shouldShowUpgradePage });
</script>