Merge remote-tracking branch 'origin/feat/whatsapp-embedded-signup' into test-pdf-support-captain

This commit is contained in:
Tanmay Deep Sharma
2025-07-09 10:19:13 +07:00
33 changed files with 1864 additions and 55 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

@@ -0,0 +1,355 @@
import { ref, computed } from 'vue';
import { useStore } from 'vuex';
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import Auth from 'dashboard/api/auth';
export function useWhatsappEmbeddedSignup() {
const store = useStore();
const router = useRouter();
const { t } = useI18n();
// State
const fbSdkLoaded = ref(false);
const isProcessing = ref(false);
const processingMessage = ref('');
const authCodeReceived = ref(false);
const authCode = ref(null);
const businessData = ref(null);
const isAuthenticating = ref(false);
// Computed
const authHeaders = computed(() => {
if (Auth.hasAuthCookie()) {
const {
'access-token': accessToken,
'token-type': tokenType,
client,
expiry,
uid,
} = Auth.getAuthData();
return {
'access-token': accessToken,
'token-type': tokenType,
client,
expiry,
uid,
};
}
return {};
});
const benefits = computed(() => [
{
key: 'EASY_SETUP',
text: t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.BENEFITS.EASY_SETUP'),
},
{
key: 'SECURE_AUTH',
text: t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.BENEFITS.SECURE_AUTH'),
},
{
key: 'AUTO_CONFIG',
text: t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.BENEFITS.AUTO_CONFIG'),
},
]);
const showLoader = computed(
() => isAuthenticating.value || isProcessing.value
);
// Error handling
const handleSignupError = data => {
isProcessing.value = false;
authCodeReceived.value = false;
isAuthenticating.value = false;
const errorMessage =
data.error ||
data.message ||
t('INBOX_MGMT.ADD.WHATSAPP.API.ERROR_MESSAGE');
useAlert(errorMessage);
};
const handleSignupCancellation = data => {
isProcessing.value = false;
authCodeReceived.value = false;
isAuthenticating.value = false;
let message = t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.CANCELLED');
if (data.data?.current_step) {
message += ` (Step: ${data.data.current_step})`;
}
useAlert(message);
};
const handleSignupSuccess = inboxData => {
isProcessing.value = false;
isAuthenticating.value = false;
// Update the store with the new inbox data
if (inboxData && inboxData.id) {
// Add the new inbox to the store
store.commit('inboxes/ADD_INBOXES', inboxData);
useAlert(t('INBOX_MGMT.FINISH.MESSAGE'));
router.replace({
name: 'settings_inboxes_add_agents',
params: {
page: 'new',
inbox_id: inboxData.id,
},
});
} else {
// Fallback if inbox data is not properly formatted
useAlert(t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.SUCCESS_FALLBACK'));
router.replace({
name: 'settings_inbox_list',
});
}
};
// Signup flow
const completeSignupFlow = async businessDataParam => {
if (!authCodeReceived.value || !authCode.value) {
handleSignupError({
error: t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.AUTH_NOT_COMPLETED'),
});
return;
}
isProcessing.value = true;
processingMessage.value = t(
'INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.PROCESSING'
);
try {
// Send both auth code and business info together (synchronous flow)
const accountId = store.getters.getCurrentAccountId;
const response = await fetch('/whatsapp/embedded_signup', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': document
.querySelector('meta[name="csrf-token"]')
?.getAttribute('content'),
...authHeaders.value,
},
body: JSON.stringify({
account_id: accountId,
code: authCode.value,
business_id: businessDataParam.business_id,
waba_id: businessDataParam.waba_id,
phone_number_id: businessDataParam.phone_number_id,
}),
});
const responseData = await response.json();
if (response.ok) {
// Clear the stored auth code for security
authCode.value = null;
// Handle synchronous success response
handleSignupSuccess(responseData);
} else {
throw new Error(responseData.message || responseData.error);
}
} catch (error) {
handleSignupError({ error: error.message });
}
};
const isValidBusinessData = businessDataLocal => {
return (
businessDataLocal &&
(businessDataLocal.business_id || businessDataLocal.businessId) &&
(businessDataLocal.waba_id || businessDataLocal.wabaId)
);
};
// Message handling
const handleEmbeddedSignupData = async data => {
// Handle different embedded signup events per Facebook documentation
if (data.event === 'FINISH') {
// Facebook might send business data in different structures
let businessDataLocal = data.data;
// If data.data doesn't exist, try other possible structures
if (!businessDataLocal) {
businessDataLocal = data.business_data || data.details || data;
}
// Validate we have the required business information
if (isValidBusinessData(businessDataLocal)) {
// Normalize the data structure to match our backend expectations
const normalizedData = {
business_id:
businessDataLocal.business_id || businessDataLocal.businessId,
waba_id: businessDataLocal.waba_id || businessDataLocal.wabaId,
phone_number_id:
businessDataLocal.phone_number_id ||
businessDataLocal.phoneNumberId ||
businessDataLocal.phone_id,
};
// Store business data
businessData.value = normalizedData;
// Check if we already have auth code and process immediately
if (authCodeReceived.value && authCode.value) {
await completeSignupFlow(normalizedData);
} else {
processingMessage.value = t(
'INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.WAITING_FOR_AUTH'
);
}
} else {
handleSignupError({
error: t(
'INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.INVALID_BUSINESS_DATA'
),
});
}
} else if (data.event === 'CANCEL') {
handleSignupCancellation(data);
} else if (data.event === 'error') {
handleSignupError({
error:
data.error_message ||
t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.SIGNUP_ERROR'),
error_id: data.error_id,
session_id: data.session_id,
});
}
};
const fbLoginCallback = response => {
if (response.authResponse && response.authResponse.code) {
// Authorization code received from Facebook
authCode.value = response.authResponse.code;
authCodeReceived.value = true;
processingMessage.value = t(
'INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.WAITING_FOR_BUSINESS_INFO'
);
// Check if we already have business data and process immediately
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 => {
// Handle Facebook embedded signup message events
try {
const originUrl = new URL(event.origin);
const allowedHosts = ['facebook.com', 'www.facebook.com'];
if (!allowedHosts.includes(originUrl.hostname)) return;
} catch (error) {
// Invalid origin URL, reject the event
return;
}
try {
const data = JSON.parse(event.data);
if (data.type === 'WA_EMBEDDED_SIGNUP') {
handleEmbeddedSignupData(data);
}
} catch (error) {
// Handle non-JSON messages silently
}
};
// Facebook SDK
const loadFacebookSdk = () => {
if (window.FB) {
fbSdkLoaded.value = true;
return;
}
const script = document.createElement('script');
script.src = 'https://connect.facebook.net/en_US/sdk.js';
script.async = true;
script.defer = true;
script.onload = () => {
window.FB.init({
appId: window.chatwootConfig?.whatsappAppId,
status: true,
xfbml: true,
version: window.chatwootConfig?.whatsappApiVersion || 'v22.0',
});
fbSdkLoaded.value = true;
};
document.body.appendChild(script);
};
const launchEmbeddedSignup = () => {
processingMessage.value = t(
'INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.AUTH_PROCESSING'
);
if (!window.FB) {
loadFacebookSdk();
setTimeout(() => launchEmbeddedSignup(), 1000);
return;
}
isAuthenticating.value = true;
// Following Facebook's embedded signup documentation
window.FB.login(fbLoginCallback, {
config_id: window.chatwootConfig?.whatsappConfigurationId,
response_type: 'code',
override_default_response_type: true,
extras: {
setup: {},
featureType: '', // Leave empty for default flow
sessionInfoVersion: '3',
},
});
};
// Lifecycle
const setupMessageListener = () => {
window.addEventListener('message', handleSignupMessage);
};
const cleanupMessageListener = () => {
window.removeEventListener('message', handleSignupMessage);
};
// Initialize
const initialize = () => {
loadFacebookSdk();
setupMessageListener();
};
return {
// State
fbSdkLoaded,
isProcessing,
processingMessage,
authCodeReceived,
authCode,
businessData,
isAuthenticating,
// Computed
benefits,
showLoader,
// Methods
launchEmbeddedSignup,
initialize,
cleanupMessageListener,
};
}
@@ -473,7 +473,7 @@ export const inboxes = [
welcome_title: '',
welcome_tagline: '',
web_widget_script:
'\n <script>\n (function(d,t) {\n var BASE_URL="http://localhost:3000";\n var g=d.createElement(t),s=d.getElementsByTagName(t)[0];\n g.src=BASE_URL+"/packs/js/sdk.js";\n g.defer = true;\n g.async = true;\n s.parentNode.insertBefore(g,s);\n g.onload=function(){\n window.chatwootSDK.run({\n websiteToken: \'yZ7USzaEs7hrwUAHLGwjbxJ1\',\n baseUrl: BASE_URL\n })\n }\n })(document,"script");\n </script>\n ',
'\n <script>\n (function(d,t) {\n var BASE_URL="http://localhost:3000";\n var g=d.createElement(t),s=d.getElementsByTagName(t)[0];\n g.src=BASE_URL+"/packs/js/sdk.js";\n g.async = true;\n s.parentNode.insertBefore(g,s);\n g.onload=function(){\n window.chatwootSDK.run({\n websiteToken: \'yZ7USzaEs7hrwUAHLGwjbxJ1\',\n baseUrl: BASE_URL\n })\n }\n })(document,"script");\n </script>\n ',
website_token: 'yZ7USzaEs7hrwUAHLGwjbxJ1',
selected_feature_flags: ['attachments', 'emoji_picker', 'end_conversation'],
reply_time: 'in_a_few_minutes',
@@ -222,10 +222,17 @@
"DESC": "Start supporting your customers via WhatsApp.",
"PROVIDERS": {
"LABEL": "API Provider",
"WHATSAPP_EMBEDDED": "WhatsApp Business",
"TWILIO": "Twilio",
"WHATSAPP_CLOUD": "WhatsApp Cloud",
"WHATSAPP_CLOUD_DESC": "Quick setup through Meta",
"TWILIO_DESC": "Connect via Twilio credentials",
"360_DIALOG": "360Dialog"
},
"SELECT_PROVIDER": {
"TITLE": "Select your API provider",
"DESCRIPTION": "Choose your WhatsApp provider. You can connect directly through Meta which requires no setup, or connect through Twilio using your account credentials."
},
"INBOX_NAME": {
"LABEL": "Inbox Name",
"PLACEHOLDER": "Please enter an inbox name",
@@ -264,6 +271,28 @@
"WEBHOOK_VERIFICATION_TOKEN": "Webhook Verification Token"
},
"SUBMIT_BUTTON": "Create WhatsApp Channel",
"EMBEDDED_SIGNUP": {
"TITLE": "Quick Setup with Meta",
"DESC": "You will be redirected to Meta to log into your WhatsApp Business account. Having admin access will help make the setup smooth and easy.",
"BENEFITS": {
"TITLE": "Benefits of Embedded Signup:",
"EASY_SETUP": "No manual configuration required",
"SECURE_AUTH": "Secure OAuth based authentication",
"AUTO_CONFIG": "Automatic webhook and phone number configuration"
},
"SUBMIT_BUTTON": "Connect with WhatsApp Business",
"AUTH_PROCESSING": "Authenticating with Meta",
"WAITING_FOR_BUSINESS_INFO": "Please complete business setup in the Meta window...",
"PROCESSING": "Setting up your WhatsApp Business Account",
"LOADING_SDK": "Loading Facebook SDK...",
"CANCELLED": "WhatsApp Signup was cancelled",
"SUCCESS_TITLE": "WhatsApp Business Account Connected!",
"WAITING_FOR_AUTH": "Waiting for authentication...",
"INVALID_BUSINESS_DATA": "Invalid business data received from Facebook. Please try again.",
"SIGNUP_ERROR": "Signup error occurred",
"AUTH_NOT_COMPLETED": "Authentication not completed. Please restart the process.",
"SUCCESS_FALLBACK": "WhatsApp Business Account has been successfully configured"
},
"API": {
"ERROR_MESSAGE": "We were not able to save the WhatsApp channel"
}
@@ -47,6 +47,13 @@ export default {
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(
@@ -66,7 +73,7 @@ export default {
)}`;
}
if (this.isWhatsAppCloudInbox) {
if (this.isWhatsAppCloudInbox && this.shouldShowWhatsAppWebhookDetails) {
return `${this.$t('INBOX_MGMT.FINISH.MESSAGE')}. ${this.$t(
'INBOX_MGMT.ADD.WHATSAPP.API_CALLBACK.SUBTITLE'
)}`;
@@ -113,8 +120,11 @@ export default {
:script="currentInbox.callback_webhook_url"
/>
</div>
<div v-if="isWhatsAppCloudInbox" class="w-[50%] max-w-[50%] ml-[25%]">
<p class="mt-8 font-medium text-n-slate-11">
<div
v-if="shouldShowWhatsAppWebhookDetails"
class="w-[50%] max-w-[50%] ml-[25%]"
>
<p class="mt-8 font-medium text-slate-700 dark:text-slate-200">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.API_CALLBACK.WEBHOOK_URL') }}
</p>
<woot-code lang="html" :script="currentInbox.callback_webhook_url" />
@@ -1,48 +1,140 @@
<script>
import PageHeader from '../../SettingsSubPageHeader.vue';
<script setup>
import { computed } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import Twilio from './Twilio.vue';
import ThreeSixtyDialogWhatsapp from './360DialogWhatsapp.vue';
import CloudWhatsapp from './CloudWhatsapp.vue';
import WhatsappEmbeddedSignup from './WhatsappEmbeddedSignup.vue';
import whatsappIcon from 'dashboard/assets/images/whatsapp.png';
import twilioIcon from 'dashboard/assets/images/twilio.png';
export default {
components: {
PageHeader,
Twilio,
ThreeSixtyDialogWhatsapp,
CloudWhatsapp,
const route = useRoute();
const router = useRouter();
const { t } = useI18n();
const PROVIDER_TYPES = {
WHATSAPP: 'whatsapp',
TWILIO: 'twilio',
WHATSAPP_CLOUD: 'whatsapp_cloud',
WHATSAPP_EMBEDDED: 'whatsapp_embedded',
THREE_SIXTY_DIALOG: '360dialog',
};
const hasWhatsappAppId = computed(() => {
return (
window.chatwootConfig?.whatsappAppId &&
window.chatwootConfig.whatsappAppId !== 'none'
);
});
const selectedProvider = computed(() => route.query.provider);
const showProviderSelection = computed(() => !selectedProvider.value);
const showConfiguration = computed(() => Boolean(selectedProvider.value));
const availableProviders = computed(() => [
{
value: PROVIDER_TYPES.WHATSAPP,
label: t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.WHATSAPP_CLOUD'),
description: t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.WHATSAPP_CLOUD_DESC'),
icon: whatsappIcon,
},
data() {
return {
provider: 'whatsapp_cloud',
};
{
value: PROVIDER_TYPES.TWILIO,
label: t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.TWILIO'),
description: t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.TWILIO_DESC'),
icon: twilioIcon,
},
]);
const selectProvider = providerValue => {
router.push({
name: route.name,
params: route.params,
query: { provider: providerValue },
});
};
const shouldShowEmbeddedSignup = provider => {
return (
(provider === PROVIDER_TYPES.WHATSAPP && hasWhatsappAppId.value) ||
provider === PROVIDER_TYPES.WHATSAPP_EMBEDDED
);
};
const shouldShowCloudWhatsapp = provider => {
return provider === PROVIDER_TYPES.WHATSAPP && !hasWhatsappAppId.value;
};
</script>
<template>
<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"
>
<PageHeader
:header-title="$t('INBOX_MGMT.ADD.WHATSAPP.TITLE')"
:header-content="$t('INBOX_MGMT.ADD.WHATSAPP.DESC')"
/>
<div class="flex-shrink-0 flex-grow-0">
<label>
{{ $t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.LABEL') }}
<select v-model="provider">
<option value="whatsapp_cloud">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.WHATSAPP_CLOUD') }}
</option>
<option value="twilio">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.TWILIO') }}
</option>
</select>
</label>
<!-- Provider Selection View -->
<div v-if="showProviderSelection">
<div class="mb-10 text-left">
<h1 class="mb-2 text-lg font-medium text-slate-12">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.SELECT_PROVIDER.TITLE') }}
</h1>
<p class="text-sm leading-relaxed text-slate-11">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.SELECT_PROVIDER.DESCRIPTION') }}
</p>
</div>
<!-- Dynamic Provider Cards -->
<div class="flex justify-start gap-6">
<div
v-for="provider in availableProviders"
:key="provider.value"
class="gap-6 px-5 py-6 transition-all duration-200 border cursor-pointer w-96 border-n-weak rounded-2xl hover:bg-n-slate-3"
@click="selectProvider(provider.value)"
>
<!-- Provider Icon -->
<div class="flex justify-start mb-5">
<div
class="flex items-center justify-center size-10 bg-n-alpha-2 rounded-full"
>
<img
:src="provider.icon"
:alt="provider.label"
class="object-contain size-[26px]"
/>
</div>
</div>
<!-- Card Content -->
<div class="text-start">
<h3 class="mb-1.5 text-sm font-medium text-slate-12">
{{ provider.label }}
</h3>
<p class="text-sm text-slate-11">
{{ provider.description }}
</p>
</div>
</div>
</div>
</div>
<Twilio v-if="provider === 'twilio'" type="whatsapp" />
<ThreeSixtyDialogWhatsapp v-else-if="provider === '360dialog'" />
<CloudWhatsapp v-else />
<!-- Configuration View -->
<div v-else-if="showConfiguration">
<div class="py-5 px-6 border bg-n-solid-2 border-n-weak rounded-2xl">
<!-- Provider Configuration Forms -->
<WhatsappEmbeddedSignup
v-if="shouldShowEmbeddedSignup(selectedProvider)"
/>
<CloudWhatsapp v-else-if="shouldShowCloudWhatsapp(selectedProvider)" />
<Twilio
v-else-if="selectedProvider === PROVIDER_TYPES.TWILIO"
type="whatsapp"
/>
<ThreeSixtyDialogWhatsapp
v-else-if="selectedProvider === PROVIDER_TYPES.THREE_SIXTY_DIALOG"
/>
<CloudWhatsapp v-else />
</div>
</div>
</div>
</template>
@@ -0,0 +1,85 @@
<script setup>
import { onMounted, onBeforeUnmount } from 'vue';
import Icon from 'next/icon/Icon.vue';
import NextButton from 'next/button/Button.vue';
import LoadingState from 'dashboard/components/widgets/LoadingState.vue';
import whatsappIcon from 'dashboard/assets/images/whatsapp.png';
import { useWhatsappEmbeddedSignup } from 'dashboard/composables/useWhatsappEmbeddedSignup';
const {
fbSdkLoaded,
processingMessage,
isAuthenticating,
benefits,
showLoader,
launchEmbeddedSignup,
initialize,
cleanupMessageListener,
} = useWhatsappEmbeddedSignup();
onMounted(() => {
initialize();
});
onBeforeUnmount(() => {
cleanupMessageListener();
});
</script>
<template>
<div class="h-full">
<LoadingState v-if="showLoader" :message="processingMessage" />
<div v-else>
<div class="flex flex-col items-start mb-6 text-start">
<div class="flex justify-start mb-6">
<div
class="flex items-center justify-center w-12 h-12 bg-n-alpha-2 rounded-full"
>
<img
:src="whatsappIcon"
:alt="$t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.WHATSAPP_CLOUD')"
class="object-contain w-8 h-8"
draggable="false"
/>
</div>
</div>
<h3 class="mb-2 text-base font-medium text-n-slate-12">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.TITLE') }}
</h3>
<p class="text-sm leading-[24px] text-n-slate-12">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.DESC') }}
</p>
</div>
<div class="flex flex-col gap-2 mb-6">
<div
v-for="benefit in benefits"
:key="benefit.key"
class="flex items-center gap-2 text-sm text-n-slate-11"
>
<Icon icon="i-lucide-check" class="text-n-slate-11 size-4" />
{{ benefit.text }}
</div>
</div>
<div class="flex mt-4">
<NextButton
:disabled="!fbSdkLoaded || isAuthenticating"
:is-loading="isAuthenticating"
faded
slate
class="w-full"
@click="launchEmbeddedSignup"
>
{{ $t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.SUBMIT_BUTTON') }}
</NextButton>
</div>
<p v-if="!fbSdkLoaded" class="mt-3 text-xs text-start text-n-slate-11">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.LOADING_SDK') }}
</p>
</div>
</div>
</template>
+7
View File
@@ -72,6 +72,13 @@ const runSDK = ({ baseUrl, websiteToken }) => {
widgetStyle: getWidgetStyle(chatwootSettings.widgetStyle) || 'standard',
resetTriggered: false,
darkMode: getDarkMode(chatwootSettings.darkMode),
welcomeTitle: chatwootSettings.welcomeTitle || '',
welcomeDescription: chatwootSettings.welcomeDescription || '',
availableMessage: chatwootSettings.availableMessage || '',
unavailableMessage: chatwootSettings.unavailableMessage || '',
enableFileUpload: chatwootSettings.enableFileUpload ?? true,
enableEmojiPicker: chatwootSettings.enableEmojiPicker ?? true,
enableEndConversation: chatwootSettings.enableEndConversation ?? true,
toggle(state) {
IFrameHelper.events.toggleBubble(state);
+7
View File
@@ -166,6 +166,13 @@ export const IFrameHelper = {
darkMode: window.$chatwoot.darkMode,
showUnreadMessagesDialog: window.$chatwoot.showUnreadMessagesDialog,
campaignsSnoozedTill,
welcomeTitle: window.$chatwoot.welcomeTitle,
welcomeDescription: window.$chatwoot.welcomeDescription,
availableMessage: window.$chatwoot.availableMessage,
unavailableMessage: window.$chatwoot.unavailableMessage,
enableFileUpload: window.$chatwoot.enableFileUpload,
enableEmojiPicker: window.$chatwoot.enableEmojiPicker,
enableEndConversation: window.$chatwoot.enableEndConversation,
});
IFrameHelper.onLoad({
widgetColor: message.config.channelConfig.widgetColor,
@@ -24,7 +24,10 @@ export default {
return { isUploading: false };
},
computed: {
...mapGetters({ globalConfig: 'globalConfig/get' }),
...mapGetters({
globalConfig: 'globalConfig/get',
shouldShowFilePicker: 'appConfig/getShouldShowFilePicker',
}),
fileUploadSizeLimit() {
return MAXIMUM_FILE_UPLOAD_SIZE;
},
@@ -40,6 +43,9 @@ export default {
},
methods: {
handleClipboardPaste(e) {
// If file picker is not enabled, do not allow paste
if (!this.shouldShowFilePicker) return;
const items = (e.clipboardData || e.originalEvent.clipboardData).items;
// items is a DataTransferItemList object which does not have forEach method
const itemsArray = Array.from(items);
@@ -47,11 +47,11 @@ const containerClasses = computed(() => [
</div>
<h2
v-dompurify-html="introHeading"
class="mt-4 text-2xl mb-1.5 font-medium text-n-slate-12"
class="mt-4 text-2xl mb-1.5 font-medium text-n-slate-12 line-clamp-4"
/>
<p
v-dompurify-html="formatMessage(introBody)"
class="text-lg leading-normal text-n-slate-11 [&_a]:underline"
class="text-lg leading-normal text-n-slate-11 [&_a]:underline line-clamp-6"
/>
</header>
</template>
@@ -41,9 +41,15 @@ export default {
...mapGetters({
widgetColor: 'appConfig/getWidgetColor',
isWidgetOpen: 'appConfig/getIsWidgetOpen',
shouldShowFilePicker: 'appConfig/getShouldShowFilePicker',
shouldShowEmojiPicker: 'appConfig/getShouldShowEmojiPicker',
}),
showAttachment() {
return this.hasAttachmentsEnabled && this.userInput.length === 0;
return (
this.shouldShowFilePicker &&
this.hasAttachmentsEnabled &&
this.userInput.length === 0
);
},
showSendButton() {
return this.userInput.length > 0;
@@ -143,7 +149,7 @@ export default {
:on-attach="onSendAttachment"
/>
<button
v-if="hasEmojiPickerEnabled"
v-if="shouldShowEmojiPicker && hasEmojiPickerEnabled"
class="flex items-center justify-center min-h-8 min-w-8"
:aria-label="$t('EMOJI.ARIA_LABEL')"
@click="toggleEmojiPicker"
@@ -158,7 +164,7 @@ export default {
/>
</button>
<EmojiInput
v-if="showEmojiPicker"
v-if="shouldShowEmojiPicker && showEmojiPicker"
v-on-clickaway="hideEmojiPicker"
:on-click="emojiOnClick"
@keydown.esc="hideEmojiPicker"
@@ -23,6 +23,7 @@ export default {
computed: {
...mapGetters({
conversationAttributes: 'conversationAttributes/getConversationParams',
canUserEndConversation: 'appConfig/getCanUserEndConversation',
}),
canLeaveConversation() {
return [
@@ -82,6 +83,7 @@ export default {
<button
v-if="
canLeaveConversation &&
canUserEndConversation &&
hasEndConversationEnabled &&
showEndConversationButton
"
@@ -29,6 +29,8 @@ export default {
computed: {
...mapGetters({
widgetColor: 'appConfig/getWidgetColor',
availableMessage: 'appConfig/getAvailableMessage',
unavailableMessage: 'appConfig/getUnavailableMessage',
}),
textColor() {
return getContrastingTextColor(this.widgetColor);
@@ -40,6 +42,11 @@ export default {
id: agent.id,
}));
},
headerMessage() {
return this.isOnline
? this.availableMessage || this.$t('TEAM_AVAILABILITY.ONLINE')
: this.unavailableMessage || this.$t('TEAM_AVAILABILITY.OFFLINE');
},
isOnline() {
const { workingHoursEnabled } = this.channelConfig;
const anyAgentOnline = this.availableAgents.length > 0;
@@ -71,12 +78,8 @@ export default {
>
<div class="flex items-center justify-between gap-2">
<div class="flex flex-col gap-1">
<div class="font-medium text-n-slate-12">
{{
isOnline
? $t('TEAM_AVAILABILITY.ONLINE')
: $t('TEAM_AVAILABILITY.OFFLINE')
}}
<div class="font-medium text-n-slate-12 line-clamp-2">
{{ headerMessage }}
</div>
<div class="text-n-slate-11">
{{ replyWaitMessage }}
@@ -119,8 +119,10 @@ export default {
>
<ChatHeaderExpanded
v-if="!isHeaderCollapsed"
:intro-heading="channelConfig.welcomeTitle"
:intro-body="channelConfig.welcomeTagline"
:intro-heading="appConfig.welcomeTitle || channelConfig.welcomeTitle"
:intro-body="
appConfig.welcomeDescription || channelConfig.welcomeTagline
"
:avatar-url="channelConfig.avatarUrl"
:show-popout-button="appConfig.showPopoutButton"
/>
@@ -21,6 +21,13 @@ const state = {
widgetStyle: 'standard',
darkMode: 'light',
isUpdatingRoute: false,
welcomeTitle: '',
welcomeDescription: '',
availableMessage: '',
unavailableMessage: '',
enableFileUpload: true,
enableEmojiPicker: true,
enableEndConversation: true,
};
export const getters = {
@@ -34,6 +41,13 @@ export const getters = {
darkMode: $state => $state.darkMode,
getShowUnreadMessagesDialog: $state => $state.showUnreadMessagesDialog,
getIsUpdatingRoute: _state => _state.isUpdatingRoute,
getWelcomeHeading: $state => $state.welcomeTitle,
getWelcomeTagline: $state => $state.welcomeDescription,
getAvailableMessage: $state => $state.availableMessage,
getUnavailableMessage: $state => $state.unavailableMessage,
getShouldShowFilePicker: $state => $state.enableFileUpload,
getShouldShowEmojiPicker: $state => $state.enableEmojiPicker,
getCanUserEndConversation: $state => $state.enableEndConversation,
};
export const actions = {
@@ -46,6 +60,13 @@ export const actions = {
showUnreadMessagesDialog,
widgetStyle = 'rounded',
darkMode = 'light',
welcomeTitle = '',
welcomeDescription = '',
availableMessage = '',
unavailableMessage = '',
enableFileUpload = true,
enableEmojiPicker = true,
enableEndConversation = true,
}
) {
commit(SET_WIDGET_APP_CONFIG, {
@@ -55,6 +76,13 @@ export const actions = {
showUnreadMessagesDialog: !!showUnreadMessagesDialog,
widgetStyle,
darkMode,
welcomeTitle,
welcomeDescription,
availableMessage,
unavailableMessage,
enableFileUpload,
enableEmojiPicker,
enableEndConversation,
});
},
toggleWidgetOpen({ commit }, isWidgetOpen) {
@@ -90,6 +118,13 @@ export const mutations = {
$state.darkMode = data.darkMode;
$state.locale = data.locale || $state.locale;
$state.showUnreadMessagesDialog = data.showUnreadMessagesDialog;
$state.welcomeTitle = data.welcomeTitle;
$state.welcomeDescription = data.welcomeDescription;
$state.availableMessage = data.availableMessage;
$state.unavailableMessage = data.unavailableMessage;
$state.enableFileUpload = data.enableFileUpload;
$state.enableEmojiPicker = data.enableEmojiPicker;
$state.enableEndConversation = data.enableEndConversation;
},
[TOGGLE_WIDGET_OPEN]($state, isWidgetOpen) {
$state.isWidgetOpen = isWidgetOpen;
@@ -19,6 +19,48 @@ describe('#getters', () => {
expect(getters.getShowUnreadMessagesDialog(state)).toEqual(true);
});
});
describe('#getAvailableMessage', () => {
it('returns correct value', () => {
const state = { availableMessage: 'We reply quickly' };
expect(getters.getAvailableMessage(state)).toEqual('We reply quickly');
});
});
describe('#getWelcomeHeading', () => {
it('returns correct value', () => {
const state = { welcomeTitle: 'Hello!' };
expect(getters.getWelcomeHeading(state)).toEqual('Hello!');
});
});
describe('#getWelcomeTagline', () => {
it('returns correct value', () => {
const state = { welcomeDescription: 'Welcome to our site' };
expect(getters.getWelcomeTagline(state)).toEqual('Welcome to our site');
});
});
describe('#getShouldShowFilePicker', () => {
it('returns correct value', () => {
const state = { enableFileUpload: true };
expect(getters.getShouldShowFilePicker(state)).toEqual(true);
});
});
describe('#getShouldShowEmojiPicker', () => {
it('returns correct value', () => {
const state = { enableEmojiPicker: true };
expect(getters.getShouldShowEmojiPicker(state)).toEqual(true);
});
});
describe('#getCanUserEndConversation', () => {
it('returns correct value', () => {
const state = { enableEndConversation: true };
expect(getters.getCanUserEndConversation(state)).toEqual(true);
});
});
describe('#getUnavailableMessage', () => {
it('returns correct value', () => {
const state = { unavailableMessage: 'We are offline' };
expect(getters.getUnavailableMessage(state)).toEqual('We are offline');
});
});
describe('#getIsUpdatingRoute', () => {
it('returns correct value', () => {
const state = { isUpdatingRoute: true };